diff --git a/OPS_MANUAL.md b/OPS_MANUAL.md
new file mode 100644
index 0000000..6d76a3c
--- /dev/null
+++ b/OPS_MANUAL.md
@@ -0,0 +1,396 @@
+# CareerBot 线上运维手册
+
+## 1. 服务器信息
+
+| 项目 | 详情 |
+|------|------|
+| 云服务商 | 阿里云 ECS |
+| 公网 IP | `39.106.14.107` |
+| 操作系统 | Alibaba Cloud Linux 3 (OpenAnolis Edition) |
+| 配置 | 2 核 CPU / 2GB 内存 / 40GB 磁盘 |
+| Python 版本 | 3.11.13 |
+| Nginx 版本 | 1.20.1 |
+| Gitea 版本 | 1.22.6 |
+
+---
+
+## 2. 账号信息
+
+### 2.1 服务器账号
+
+| 账号 | 用途 | 登录方式 |
+|------|------|---------|
+| `root` | 系统管理 | SSH 密钥 (`~/.ssh/id_ed25519`) |
+| `deploy` | 应用部署和运行 | SSH 密钥(同上),sudo 免密 |
+| `gitea` | Gitea 服务运行 | 系统用户,不可直接登录 |
+| `nginx` | Nginx 服务运行 | 系统用户,不可直接登录 |
+
+**本地 SSH 快捷连接**(已配置在 `~/.ssh/config`):
+```bash
+ssh ecs # 以 deploy 用户连接
+ssh root@ecs # 以 root 用户连接(需将 config 中 User 临时改为 root,或直接用 IP)
+ssh root@39.106.14.107 # root 直连
+```
+
+### 2.2 应用账号
+
+| 系统 | 地址 | 账号 | 密码 |
+|------|------|------|------|
+| CareerBot 管理后台 | `http://39.106.14.107/admin/login` | `ln0422@gmail.com` | `qshs123456` |
+| Gitea 代码管理 | `http://39.106.14.107:3000` | `ln0422` | `Qshs123456_` |
+
+### 2.3 ECS 用户密码
+
+| 用户 | 密码 |
+|------|------|
+| `deploy` | `CareerBot2026!` |
+
+> **注意**:日常运维通过 SSH 密钥登录,密码仅在密钥不可用时作为备用。
+
+---
+
+## 3. 服务架构
+
+```
+外部访问
+ │
+ ▼
+┌─────────────────────────────────────────┐
+│ Nginx (:80) │
+│ 配置: /etc/nginx/sites-enabled/ │
+│ 日志: /var/log/nginx/ │
+├─────────────────────────────────────────┤
+│ http://39.106.14.107/ │
+│ → proxy_pass http://127.0.0.1:8000 │
+│ → CareerBot (uvicorn) │
+├─────────────────────────────────────────┤
+│ http://39.106.14.107:3000 │
+│ → Gitea (直接监听,未经 Nginx 代理) │
+└─────────────────────────────────────────┘
+```
+
+### 端口使用
+
+| 端口 | 服务 | 监听地址 | 防火墙 | 安全组 |
+|------|------|---------|--------|--------|
+| 22 | SSH | 0.0.0.0 | 已放行 | 已放行 |
+| 80 | Nginx → CareerBot | 0.0.0.0 | 已放行 | 已放行 |
+| 443 | HTTPS (预留) | - | 已放行 | 已放行 |
+| 3000 | Gitea | 0.0.0.0 | 已放行 | 已放行 |
+| 8000 | CareerBot (uvicorn) | 127.0.0.1 | 内部 | 无需 |
+
+---
+
+## 4. 文件目录结构
+
+```
+/home/deploy/apps/CareerBot/ ← 项目根目录
+├── venv/ ← Python 虚拟环境
+├── app/ ← 应用代码
+├── templates/ ← 页面模板
+├── careerbot.db ← SQLite 数据库(重要数据!)
+├── uploads/ ← 用户上传文件
+├── requirements.txt
+├── init_data.py
+├── start.bat / stop.bat ← Windows 本地启动脚本(线上不用)
+├── DESIGN.md ← 设计文档
+└── OPS_MANUAL.md ← 本文档
+
+/etc/systemd/system/
+├── careerbot.service ← CareerBot systemd 服务
+└── gitea.service ← Gitea systemd 服务
+
+/etc/nginx/
+├── nginx.conf ← Nginx 主配置
+├── sites-available/
+│ └── careerbot.conf ← CareerBot 站点配置
+└── sites-enabled/
+ └── careerbot.conf → ../sites-available/careerbot.conf
+
+/usr/local/bin/gitea ← Gitea 二进制文件
+/etc/gitea/app.ini ← Gitea 配置文件
+/var/lib/gitea/ ← Gitea 数据目录(仓库、数据库)
+```
+
+---
+
+## 5. 常用运维命令
+
+### 5.1 服务管理
+
+```bash
+# ── CareerBot ──
+sudo systemctl status careerbot # 查看状态
+sudo systemctl restart careerbot # 重启
+sudo systemctl stop careerbot # 停止
+sudo systemctl start careerbot # 启动
+sudo journalctl -u careerbot -f # 实时查看日志
+sudo journalctl -u careerbot --since "1 hour ago" # 查看最近1小时日志
+
+# ── Nginx ──
+sudo systemctl status nginx
+sudo systemctl reload nginx # 重载配置(不中断连接)
+sudo systemctl restart nginx # 重启
+sudo nginx -t # 测试配置文件语法
+
+# ── Gitea ──
+sudo systemctl status gitea
+sudo systemctl restart gitea
+sudo journalctl -u gitea -f
+```
+
+### 5.2 代码更新部署
+
+**从本地推送代码并部署到线上(标准流程):**
+
+```bash
+# 1. 本地:提交并推送到 Gitea
+cd D:/Files/Projects/VibeCoding/CareerBot
+git add .
+git commit -m "描述改动"
+git push origin main
+
+# 2. 线上:拉取并重启
+ssh ecs "cd ~/apps/CareerBot && git pull && sudo systemctl restart careerbot"
+```
+
+**一键部署脚本(在本地执行):**
+```bash
+ssh ecs "cd ~/apps/CareerBot && git pull origin main && sudo systemctl restart careerbot && sleep 2 && sudo systemctl is-active careerbot"
+```
+
+### 5.3 数据库操作
+
+```bash
+# 连接到 SQLite 数据库
+ssh ecs "cd ~/apps/CareerBot && sqlite3 careerbot.db"
+
+# 常用 SQL
+.tables # 查看所有表
+SELECT * FROM admin_user; # 查看管理员
+SELECT token, note, max_questions, used_questions, is_active FROM access_token; # 查看令牌
+SELECT * FROM recruiter_message ORDER BY created_at DESC LIMIT 10; # 最近招聘消息
+SELECT COUNT(*) FROM chat_history; # 对话记录总数
+.quit # 退出
+```
+
+### 5.4 系统监控
+
+```bash
+# 查看内存使用
+ssh ecs "free -h"
+
+# 查看磁盘使用
+ssh ecs "df -h /"
+
+# 查看各服务内存占用
+ssh ecs "ps aux --sort=-rss | head -10"
+
+# 查看端口监听
+ssh ecs "ss -tlnp"
+```
+
+---
+
+## 6. 配置文件详情
+
+### 6.1 CareerBot systemd 服务
+
+文件:`/etc/systemd/system/careerbot.service`
+```ini
+[Unit]
+Description=CareerBot Web Application
+After=network.target
+
+[Service]
+Type=simple
+User=deploy
+WorkingDirectory=/home/deploy/apps/CareerBot
+ExecStart=/home/deploy/apps/CareerBot/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
+Restart=always
+RestartSec=5
+Environment=PATH=/home/deploy/apps/CareerBot/venv/bin:/usr/bin
+
+[Install]
+WantedBy=multi-user.target
+```
+
+关键参数:
+- `--host 127.0.0.1`:只监听本地,由 Nginx 代理外部访问
+- `Restart=always`:崩溃后自动重启
+- `RestartSec=5`:重启间隔 5 秒
+
+### 6.2 Nginx 站点配置
+
+文件:`/etc/nginx/sites-available/careerbot.conf`
+```nginx
+server {
+ listen 80;
+ server_name 39.106.14.107;
+
+ client_max_body_size 10M;
+
+ location / {
+ proxy_pass http://127.0.0.1:8000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ # SSE 流式响应支持(关键!关闭缓冲)
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 300s;
+ }
+
+ location /uploads/ {
+ alias /home/deploy/apps/CareerBot/uploads/;
+ }
+}
+```
+
+关键配置说明:
+- `proxy_buffering off` + `proxy_cache off`:**必须关闭**,否则 SSE 流式对话无法实时返回
+- `proxy_read_timeout 300s`:LLM 长回复可能需要较长时间
+- `client_max_body_size 10M`:允许上传最大 10MB 文件
+
+### 6.3 防火墙规则
+
+```bash
+# 查看当前规则
+sudo firewall-cmd --list-ports
+# 输出: 22/tcp 80/tcp 443/tcp 3000/tcp
+
+# 添加新端口(如未来部署新项目)
+sudo firewall-cmd --permanent --add-port=PORT/tcp
+sudo firewall-cmd --reload
+```
+
+> **注意**:开放端口还需要在阿里云控制台的**安全组**中同步添加入方向规则,否则外部仍无法访问。
+
+---
+
+## 7. 备份策略
+
+### 7.1 数据库备份
+
+CareerBot 的所有业务数据存储在 SQLite 文件中:
+
+```bash
+# 手动备份
+ssh ecs "cp ~/apps/CareerBot/careerbot.db ~/apps/CareerBot/careerbot.db.bak.$(date +%Y%m%d)"
+
+# 下载到本地
+scp ecs:~/apps/CareerBot/careerbot.db ./careerbot_backup_$(date +%Y%m%d).db
+```
+
+### 7.2 Gitea 数据备份
+
+```bash
+# Gitea 数据和仓库
+ssh ecs "sudo tar czf /tmp/gitea-backup.tar.gz /var/lib/gitea /etc/gitea"
+scp ecs:/tmp/gitea-backup.tar.gz ./gitea-backup.tar.gz
+```
+
+### 7.3 需要备份的关键文件
+
+| 文件/目录 | 说明 | 重要程度 |
+|-----------|------|---------|
+| `~/apps/CareerBot/careerbot.db` | CareerBot 全部业务数据 | **极高** |
+| `~/apps/CareerBot/uploads/` | 用户上传的文件 | 高 |
+| `/var/lib/gitea/` | Gitea 所有仓库和数据 | **极高** |
+| `/etc/gitea/app.ini` | Gitea 配置 | 中 |
+| `/etc/nginx/sites-available/` | Nginx 站点配置 | 中 |
+| `/etc/systemd/system/careerbot.service` | 服务配置 | 低(可重建) |
+
+---
+
+## 8. 故障排查
+
+### 8.1 CareerBot 无法访问
+
+```bash
+# 1. 检查服务状态
+sudo systemctl status careerbot
+
+# 2. 查看错误日志
+sudo journalctl -u careerbot --since "10 min ago"
+
+# 3. 检查端口
+ss -tlnp | grep 8000
+
+# 4. 检查 Nginx
+sudo nginx -t
+sudo systemctl status nginx
+
+# 5. 手动启动测试
+cd ~/apps/CareerBot
+source venv/bin/activate
+uvicorn app.main:app --host 127.0.0.1 --port 8000
+```
+
+### 8.2 AI 对话不工作
+
+- 检查管理后台 LLM Config 是否已配置(API URL、API Key、Model Name)
+- 点击 "Test Connection" 测试连通性
+- 确认 ECS 能访问外网:`curl https://api.deepseek.com`
+
+### 8.3 Gitea 无法访问
+
+```bash
+sudo systemctl status gitea
+sudo journalctl -u gitea --since "10 min ago"
+# 检查 3000 端口和安全组
+```
+
+### 8.4 磁盘/内存不足
+
+```bash
+# 查看磁盘
+df -h /
+
+# 清理日志
+sudo journalctl --vacuum-time=7d
+
+# 查看内存
+free -h
+
+# 查看占用最多的进程
+ps aux --sort=-rss | head -10
+```
+
+---
+
+## 9. 新项目部署指南
+
+后续在同一台 ECS 上部署新项目的标准流程:
+
+1. **Gitea 上创建仓库**:`http://39.106.14.107:3000` → New Repository
+
+2. **本地推送代码**
+3. **ECS 上克隆并配置**:
+ ```bash
+ cd ~/apps
+ git clone http://39.106.14.107:3000/ln0422/新项目.git
+ # 安装依赖、初始化等
+ ```
+
+4. **创建 systemd 服务**(参考 careerbot.service,注意更换端口号)
+
+5. **添加 Nginx 站点配置**:
+ ```bash
+ sudo vim /etc/nginx/sites-available/新项目.conf
+ sudo ln -s /etc/nginx/sites-available/新项目.conf /etc/nginx/sites-enabled/
+ sudo nginx -t && sudo systemctl reload nginx
+ ```
+
+6. **如需新端口**:防火墙 + 阿里云安全组同步放行
+
+---
+
+## 10. 安全注意事项
+
+1. **SSH 密钥**:本地私钥 `~/.ssh/id_ed25519` 请妥善保管,丢失需重新生成并更新服务器
+2. **Gitea 端口**:3000 端口对外开放,建议设置强密码,或后续通过 Nginx 代理并限制访问
+3. **数据库文件**:`careerbot.db` 不应对外暴露,uvicorn 仅监听 127.0.0.1 已确保安全
+4. **定期更新**:定期执行 `sudo dnf update -y` 更新系统安全补丁
+5. **HTTPS**:后续绑定域名后建议配置 Let's Encrypt SSL 证书
diff --git a/app/config.py b/app/config.py
index cc51c4e..9db3634 100644
--- a/app/config.py
+++ b/app/config.py
@@ -8,6 +8,7 @@ class Settings(BaseSettings):
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480
UPLOAD_DIR: str = "./uploads"
+ BASE_PATH: str = "/careerbot"
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
diff --git a/app/main.py b/app/main.py
index c59fade..c27084b 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,6 +1,7 @@
import os
from fastapi import FastAPI
+from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from app.config import settings
@@ -9,18 +10,30 @@ from app.models import AdminUser, Profile
from app.routers import auth, public, admin, chat
from app.routers.auth import hash_password
+BASE = settings.BASE_PATH
+
app = FastAPI(title="CareerBot", description="Personal Career Showcase with AI Assistant")
-# Mount static files and uploads
-app.mount("/static", StaticFiles(directory="app/static"), name="static")
+# Mount static files and uploads under BASE_PATH
+app.mount(f"{BASE}/static", StaticFiles(directory="app/static"), name="static")
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
-app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
+app.mount(f"{BASE}/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
-# Include routers
-app.include_router(auth.router)
-app.include_router(public.router)
-app.include_router(admin.router)
-app.include_router(chat.router)
+# Include routers with BASE_PATH prefix
+app.include_router(auth.router, prefix=BASE)
+app.include_router(public.router, prefix=BASE)
+app.include_router(admin.router, prefix=BASE)
+app.include_router(chat.router, prefix=BASE)
+
+# Set Jinja2 template globals for BASE_PATH
+admin.templates.env.globals["base"] = BASE
+public.templates.env.globals["base"] = BASE
+
+
+# Root redirect to CareerBot
+@app.get("/")
+def root_redirect():
+ return RedirectResponse(url=f"{BASE}/", status_code=302)
@app.on_event("startup")
diff --git a/app/routers/admin.py b/app/routers/admin.py
index 333a900..bc2a61c 100644
--- a/app/routers/admin.py
+++ b/app/routers/admin.py
@@ -9,6 +9,9 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.config import settings
+
+BASE = settings.BASE_PATH
+
from app.database import get_db
from app.models import (
Profile, Skill, Education, WorkExperience,
@@ -95,7 +98,7 @@ async def upload_photo(
if not p:
p = Profile()
db.add(p)
- p.photo_url = f"/uploads/{filename}"
+ p.photo_url = f"{BASE}/uploads/{filename}"
db.commit()
return {"photo_url": p.photo_url}
diff --git a/app/routers/auth.py b/app/routers/auth.py
index 0124daf..4fba663 100644
--- a/app/routers/auth.py
+++ b/app/routers/auth.py
@@ -12,7 +12,7 @@ from app.database import get_db
from app.models import AdminUser, AccessToken
router = APIRouter()
-oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/admin/login", auto_error=False)
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.BASE_PATH}/api/admin/login", auto_error=False)
def hash_password(password: str) -> str:
diff --git a/app/routers/public.py b/app/routers/public.py
index 2f9f2b3..a0900f7 100644
--- a/app/routers/public.py
+++ b/app/routers/public.py
@@ -3,9 +3,12 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
+from app.config import settings
from app.database import get_db
from app.models import Profile, Skill, Education, WorkExperience, AccessToken
+BASE = settings.BASE_PATH
+
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@@ -14,14 +17,14 @@ templates = Jinja2Templates(directory="templates")
def index_page(request: Request, db: Session = Depends(get_db)):
token = request.cookies.get("visitor_token")
if not token:
- return RedirectResponse(url="/login", status_code=302)
+ return RedirectResponse(url=f"{BASE}/login", status_code=302)
# Allow both valid tokens and anonymous
if token != "__anonymous__":
record = db.query(AccessToken).filter(
AccessToken.token == token, AccessToken.is_active == True
).first()
if not record:
- return RedirectResponse(url="/login", status_code=302)
+ return RedirectResponse(url=f"{BASE}/login", status_code=302)
return templates.TemplateResponse(request, "index.html")
diff --git a/app/static/js/admin.js b/app/static/js/admin.js
index 70b961c..de95dad 100644
--- a/app/static/js/admin.js
+++ b/app/static/js/admin.js
@@ -1,36 +1,37 @@
// ── Admin Panel JavaScript ──
+const BASE_PATH = window.BASE_PATH || '';
function authHeaders() {
return { 'Authorization': 'Bearer ' + localStorage.getItem('admin_token') };
}
async function apiGet(url) {
- const resp = await fetch(url, { headers: authHeaders() });
- if (resp.status === 401) { window.location.href = '/admin/login'; return null; }
+ const resp = await fetch(BASE_PATH + url, { headers: authHeaders() });
+ if (resp.status === 401) { window.location.href = BASE_PATH + '/admin/login'; return null; }
return resp.json();
}
async function apiPost(url, data) {
- const resp = await fetch(url, {
+ const resp = await fetch(BASE_PATH + url, {
method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
- if (resp.status === 401) { window.location.href = '/admin/login'; return null; }
+ if (resp.status === 401) { window.location.href = BASE_PATH + '/admin/login'; return null; }
return resp.json();
}
async function apiPut(url, data) {
- const resp = await fetch(url, {
+ const resp = await fetch(BASE_PATH + url, {
method: 'PUT', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
- if (resp.status === 401) { window.location.href = '/admin/login'; return null; }
+ if (resp.status === 401) { window.location.href = BASE_PATH + '/admin/login'; return null; }
return resp.json();
}
async function apiDelete(url) {
- const resp = await fetch(url, { method: 'DELETE', headers: authHeaders() });
- if (resp.status === 401) { window.location.href = '/admin/login'; return null; }
+ const resp = await fetch(BASE_PATH + url, { method: 'DELETE', headers: authHeaders() });
+ if (resp.status === 401) { window.location.href = BASE_PATH + '/admin/login'; return null; }
return resp.json();
}
@@ -68,7 +69,7 @@ document.addEventListener('DOMContentLoaded', () => {
function adminLogout() {
localStorage.removeItem('admin_token');
- window.location.href = '/admin/login';
+ window.location.href = BASE_PATH + '/admin/login';
}
// ── Dashboard ──
@@ -113,10 +114,10 @@ async function uploadPhoto() {
if (!input.files[0]) return;
const formData = new FormData();
formData.append('file', input.files[0]);
- const resp = await fetch('/api/admin/profile/photo', {
+ const resp = await fetch(BASE_PATH + '/api/admin/profile/photo', {
method: 'POST', headers: authHeaders(), body: formData,
});
- if (resp.status === 401) { window.location.href = '/admin/login'; return; }
+ if (resp.status === 401) { window.location.href = BASE_PATH + '/admin/login'; return; }
const data = await resp.json();
if (data.photo_url) {
document.getElementById('photo-preview').innerHTML = ``;
diff --git a/app/static/js/chat.js b/app/static/js/chat.js
index 54cf569..2ba829f 100644
--- a/app/static/js/chat.js
+++ b/app/static/js/chat.js
@@ -1,4 +1,5 @@
// ── Chat Widget JavaScript ──
+const BASE_PATH = window.BASE_PATH || '';
let chatSessionId = sessionStorage.getItem('chat_session');
if (!chatSessionId) {
@@ -66,7 +67,7 @@ document.addEventListener('DOMContentLoaded', () => {
async function loadChatConfig() {
try {
- const resp = await fetch('/api/chat/config');
+ const resp = await fetch(BASE_PATH + '/api/chat/config');
if (resp.ok) {
const data = await resp.json();
maxQuestions = data.max_questions || 10;
@@ -96,7 +97,7 @@ async function loadChatConfig() {
async function loadChatHistory() {
try {
- const resp = await fetch(`/api/chat/history/${chatSessionId}`);
+ const resp = await fetch(BASE_PATH + `/api/chat/history/${chatSessionId}`);
if (!resp.ok) return;
const history = await resp.json();
for (const msg of history) {
@@ -147,7 +148,7 @@ async function sendMessage() {
formData.append('message', message);
if (file) formData.append('file', file);
- const response = await fetch('/api/chat', {
+ const response = await fetch(BASE_PATH + '/api/chat', {
method: 'POST',
body: formData,
});
@@ -243,7 +244,7 @@ async function enterToken() {
const token = prompt('请输入访问令牌 (Access Token):');
if (!token || !token.trim()) return;
try {
- const resp = await fetch('/api/verify-token', {
+ const resp = await fetch(BASE_PATH + '/api/verify-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token.trim() }),
diff --git a/app/static/js/main.js b/app/static/js/main.js
index 0e947b1..aa5b708 100644
--- a/app/static/js/main.js
+++ b/app/static/js/main.js
@@ -1,4 +1,5 @@
// ── Main Page JavaScript ──
+const BASE_PATH = window.BASE_PATH || '';
document.addEventListener('DOMContentLoaded', () => {
loadProfile();
@@ -8,9 +9,9 @@ document.addEventListener('DOMContentLoaded', () => {
});
async function apiFetch(url) {
- const resp = await fetch(url);
+ const resp = await fetch(BASE_PATH + url);
if (resp.status === 401) {
- window.location.href = '/login';
+ window.location.href = BASE_PATH + '/login';
return null;
}
return resp.json();
diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html
index 2a32316..187e269 100644
--- a/templates/admin/dashboard.html
+++ b/templates/admin/dashboard.html
@@ -4,22 +4,22 @@