项目文档
项目代号:OCBridge · 配套仓库:opencode_chat
使用 FastAPI 后端 + SQLite + 纯前端三件套,把
opencode serve封装成多用户、带权限、可流式聊天的网页工具。
目录
- 总体架构与设计
- 环境准备与 opencode serve 启动
- 认识 opencode API
- 后端骨架与配置
- 数据库设计与初始化
- 认证与权限
- 项目管理与用户管理
- 认识 opencode 客户端
- SSE 流式对话
- 对话路由层
- 前端基础:SPA 与 API 封装
- 前端聊天界面
- 前端流式接收与交互
- 管理后台前端
- 安全加固与生产部署
- 数据库迁移:SQLite → MongoDB / MySQL
00 · 总体架构与设计
本项目使用 FastAPI 后端 + SQLite 数据库 + 纯前端三件套(HTML/CSS/JS),把
opencode serve封装成一个多用户、带权限、可流式聊天的网页工具。
0.1 背景与定位
opencode 是一款终端里的 AI 编程助手。它通过 opencode serve 启动一个 HTTP 服务,把"让 AI 干活"变成一套标准的 REST 接口 + 实时事件流(SSE),从而可以被任何会写 HTTP 请求的程序调用。
本项目在这之上做了一层"网关":
浏览器(用户聊天界面)
│ REST + SSE
▼
FastAPI 后端(OCBridge) ──► SQLite(用户/项目/会话元数据)
│ REST + SSE
▼
opencode serve(AI 后端,运行在 WSL 里)
- 用户无需接触任何 opencode 配置,只面对一个网页对话框。
- 管理员配置"项目"(指向哪台 opencode、端口、可选账号密码),普通用户注册后选择项目即可开聊。
- 项目不引入前端框架,前端为纯静态三件套,无构建步骤。
0.2 功能清单
- 登录 / 注册:注册时选择所属项目
- 聊天主界面(豆包风格):
- 左侧历史会话列表,支持新建 / 置顶 / 删除 / 搜索
- 流式打字机效果的 AI 回复、Markdown 渲染、代码高亮、推理过程折叠、工具调用展示
- 顶部锚点侧边栏,点击跳转到任意一次用户输入的位置
- 撤回消息与恢复撤回
- 管理后台:项目管理(增删改查 opencode 后端连接)、导航链接配置、用户管理(封禁 / 重置密码 / 查看对话记录)、操作日志
0.3 技术选型
| 层 | 选型 | 理由 |
|---|---|---|
| 后端框架 | FastAPI | 原生 async、SSE 流式响应、依赖注入、自动校验 |
| 数据库 | SQLite + aiosqlite | 单文件、零运维、异步驱动,本项目量级够用 |
| 认证 | JWT(python-jose)+ bcrypt | 无状态;密码用 bcrypt 安全哈希 |
| HTTP 客户端 | httpx | 同时提供 AsyncClient 与 SSE 流式读取 |
| 前端 | 原生 HTML/CSS/JS | 无构建步骤;marked + highlight.js + DOMPurify 渲染 Markdown |
| AI 后端 | opencode serve(WSL) | 提供 REST + SSE 能力 |
0.4 架构图
浏览器 (SPA)
├─ api.js 所有 REST 调用 + SSE 流解析
├─ app.js hash 路由与视图切换
├─ chat.js 聊天页
└─ admin.js 管理后台
│ Cookie(JWT) / REST / SSE
▼
FastAPI (app/)
├─ main.py 装配、路由挂载、SPA 托管
├─ config.py 环境变量配置
├─ database.py SQLite + get_db 依赖
├─ auth.py bcrypt + JWT + 权限依赖
├─ opencode_client.py 对接 opencode(REST + SSE 并发)
└─ routes/ public / auth / admin / chat / logs
│ httpx (可选 Basic Auth) / REST + SSE
▼
opencode serve (WSL)
├─ POST /session 创建会话
├─ GET /session/{id}/message 拉取历史消息
├─ POST /session/{id}/message 发送消息(阻塞直到回复完成)
├─ POST /session/{id}/revert|unrevert 撤回/恢复
└─ GET /event SSE 实时事件流
0.5 数据库设计
五张表:
| 表 | 存什么 |
|---|---|
projects | opencode 后端连接配置(host/port/可选账号密码) |
nav_links | 每个项目顶部的导航按钮配置 |
users | 用户(密码哈希、管理员/封禁标记、所属项目) |
sessions | 本地会话元数据,opencode_session_id 指向真实会话 |
operation_logs | 全操作审计日志 |
关键设计:消息本体不落本地库。对话内容全在 opencode 侧,本地只存"会话指针",避免两套消息存储的同步难题。
0.6 运行环境要求
- 一台能跑 WSL 的 Windows(或 Linux/Mac,WSL 只是本项目演示环境)
- Python 3.10+,已安装 opencode 命令行工具(用于
opencode serve)
01 · 环境准备与 opencode serve 启动
说明如何安装、启动并验证
opencode serve。它作为本系统的 AI 后端,由 FastAPI 网关把网页请求转发给它。
1.1 概览
本项目里 FastAPI 后端充当"网关",第一步就是先把 opencode 这个 AI 后端启动好。最终效果:
curl http://127.0.0.1:4096/global/health
→ {"healthy":true,"version":"..."}
1.2 为什么用 WSL 2
opencode 是为类 Unix 环境设计的终端 AI 助手。Windows 用户最省心的方式:装 WSL 2,在 Linux 子系统里跑 opencode,Windows 上的 FastAPI 通过 127.0.0.1 访问。装好 Ubuntu 后确认默认版本为 2:
wsl --set-default-version 2
wsl --install -d Ubuntu-22.04 # 可指定发行版
wsl -l -v # VERSION 列应为 2;若是 1,用 wsl --set-version <发行版> 2 升级
在 WSL 里安装 opencode 并确认版本:
curl -fsSL https://opencode.ai/install | bash
opencode --version
1.3 启动 opencode serve
原理:工作目录 = 项目根目录
opencode serve 没有 --directory 参数,它把进程的工作目录当作项目根目录。先建一个独立工作区并 cd 进去,同时 git init(opencode 依赖 git 记录文件增删改,用于 diff 和撤回):
mkdir -p ~/opencode-workspace && cd ~/opencode-workspace
git init && git config user.email "opencode@local" && git config user.name "opencode"
echo "# Workspace" > README.md && git add . && git commit -m "init" --allow-empty
以只读模式启动(强烈建议)
多个用户会话在同一工作区里操作,默认用只读权限启动,避免互相写坏文件。写一份权限配置到 ~/.config/opencode/opencode.json(当前 opencode 实际读 opencode.jsonc,两者等效,按生效文件名写):
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"edit": "deny", "bash": "deny",
"webfetch": "allow"
}
}
含义:禁止 AI 编辑/创建文件、执行 bash;允许联网(查询 agent 需要)。
read属只读允许,但可被第 14 篇的external_directory规则进一步收紧。
启动
opencode serve --hostname 0.0.0.0 --port 4096
# 长期运行:nohup ... & (需先 cd 到工作区)
--hostname 0.0.0.0 让 Windows 侧也能访问;--port 4096 是本项目 config.py 默认值。
1.4 验证接口
curl http://127.0.0.1:4096/global/health # 健康检查
curl http://127.0.0.1:4096/doc -o doc.json # 拉取 OpenAPI 规范(仓库里就是它)
curl -X POST http://127.0.0.1:4096/session -H "Content-Type: application/json" -d '{"title":"hello"}'
# → {"id":"ses_01H...","title":"hello",...} # 记下 ses_ 开头的 ID
curl -X POST http://127.0.0.1:4096/session/<SESSION_ID>/message \
-H "Content-Type: application/json" \
-d '{"parts":[{"type":"text","text":"你好"}]}' # 阻塞直到回复完成
POST 发消息一直阻塞到 AI 回复完成。要流式效果必须用
/event(SSE),见 02 / 08。
1.5 安全提醒:别用 root 跑
WSL 默认用户可能是 root。opencode 以 root 运行意味着读写整个系统。生产上至少开一个普通用户跑服务,详见 14 · 安全加固与生产部署。
1.6 本项目自带的启动脚本
仓库里 scripts/start_opencode.sh 就是上述步骤的自动化:建 ~/opencode-workspace 并 git init → 写只读权限配置 → 检查安装 → exec opencode serve。用环境变量可覆盖 OPENCODE_PORT / OPENCODE_HOST / WORKSPACE_DIR。从 Windows 侧调用:
wsl bash /mnt/d/opencode_chat/scripts/start_opencode.sh
02 · 认识 opencode API
梳理 opencode 的 REST 与 SSE 接口,为客户端封装提供依据。
2.1 接口概览
本项目只用到 6 类接口:
| 接口 | 方法 | 用途 | 阻塞? |
|---|---|---|---|
/global/health | GET | 健康检查 | 否 |
/session | POST | 创建会话 | 否 |
/session/{id} | GET / DELETE / PATCH | 查询 / 删除 / 改名 | 否 |
/session/{id}/message | GET | 拉历史 | 否 |
/session/{id}/message | POST | 发消息 | 是 |
/session/{id}/revert unrevert | POST | 撤回 / 恢复 | 是 |
/event | GET(SSE) | 实时事件流 | 长连接 |
离线规范在仓库根目录 doc.json(4 万行,别硬翻,用 grep 或 Swagger UI)。
2.2 创建会话与响应
POST /session body: { "title": "新对话", "agent": "build", ... }
→ Session 对象,关键字段 id(ses_ 开头的 ULID)
可选字段:parentID(继承父会话)、model、permission(按会话单独设权限)、workspaceID。
2.3 历史消息结构
GET /session/{id}/message → [ {info, parts}, ... ]
info:消息元数据(id、sessionID、role、time、agent、model)parts:内容块数组(text / reasoning / tool 等),文本在parts[].text- assistant 的
info额外有parentID、finish(stop/error)、tokens、cost
2.4 ⭐ 大坑:不要自己生成 messageID
POST /message 请求体里可选的 messageID 字段允许自定义(正则 ^msg)。本项目踩过的坑:
opencode 每条消息的 ID 是有序 ULID(如 msg_fd77e42d...)。在对话循环里(SessionPrompt.runLoop),服务端用消息 ID 的大小顺序判断本轮是否结束(条件类似 lastUser.id < lastAssistant.id 时退出)。如果客户端传自造的 msg_<uuid十六进制>,它在字典序上小于服务端 ULID(msg_fd...),于是:
- 第一轮正常,从第二轮起 AI 视为"已处理过"直接返回旧回复,什么都不生成
- 更糟的变体:传
msg_zzzz...(大于 ULID)甚至可能不入库、接口挂起
结论:客户端永远不要传
messageID,让服务端自己生成单调递增的 ULID。
本项目 app/opencode_client.py 用注释记录了这个坑:
# NOTE: no client-generated ``messageID`` is sent. opencode's run loop
# exits when the newest user message id sorts below the last assistant
# message id ... a client id like ``msg_<uuid hex>`` (which sorts below
# server ULIDs ``msg_fd...``) makes every follow-up message silently
# produce no reply.
2.5 SSE 实时事件流
GET /event (长连接,持续推送 JSON 事件)
每个事件的形状:
{ "id": "evt_...", "type": "message.part.delta",
"properties": { "sessionID": "ses_...", "messageID": "msg_...", "partID": "prt_...", "field": "text", "delta": "你" } }
我们关心的几种事件:
| type | 要点 | 用途 |
|---|---|---|
message.updated | sessionID, info: Message | assistant 完成/更新,info.finish 有值时表示回复结束 |
message.part.updated | sessionID, part | 内容块创建/落定 |
message.part.delta | sessionID, messageID, partID, field, delta | 流式增量文本(打字机) |
session.status | status: {type: idle|busy} | 会话忙闲 |
session.updated | info: Session | 会话信息(含 title) |
session.error | error | 出错了 |
状态机:{type: idle} / {type: busy} / {type: retry, ...}。
⚠️ idle 不能当完成信号:opencode 在保存用户消息之后、AI 生成之前会先发一次
idle。把它当完成会提前退出并取消 POST,AI 永不回复。(第 8 篇讲正确的完成判定。)
2.6 快速看眼 SSE 流(Python)
with httpx.stream("GET", "http://127.0.0.1:4096/event", timeout=30) as r:
for line in r.iter_lines():
print(line)
另开终端给会话发消息,就能看到事件瀑布。这是第 8 篇并发设计的依据。
三个必须记住的教训:① 别自己生成 messageID;② POST 发消息是阻塞的,流式要靠 /event;③ idle 不能当完成信号。
03 · 后端骨架与配置
FastAPI 项目按模块分层:配置、数据库、认证、客户端、路由、前端。
3.1 目录结构
opencode_chat/
├── run.py # 入口:init_db.init() → uvicorn
├── init_db.py # 建表 + 种子数据(默认管理员 + 测试项目)
├── requirements.txt
├── app/
│ ├── config.py # 环境变量 → Settings
│ ├── main.py # FastAPI 实例、路由挂载、静态托管 + SPA 回退
│ ├── database.py # SQLite 表结构 + get_db 依赖
│ ├── schemas.py # Pydantic 模型
│ ├── auth.py # bcrypt + JWT + 权限依赖
│ ├── helpers.py # 日志 / 项目 / 会话辅助
│ ├── opencode_client.py# 对接 opencode
│ └── routes/ # public / auth / admin / chat / logs
├── static/ # 纯前端
└── data/ # SQLite 数据文件
3.2 config.py —— 一切可配置的都进环境变量
# 伪代码
BASIC = {
BASE_DIR: 项目根(Path(__file__).parent.parent,不依赖 cwd)
DB_PATH: <root>/data/opencode_chat.db
STATIC_DIR: <root>/static
Settings: PORT 8000 | JWT_SECRET(默认占位) | JWT_TTL_HOURS 24
COOKIE_NAME oc_chat_token | COOKIE_SECURE false
ADMIN_USERNAME/PASSWORD | INITIAL_PROJECT_* (host/port/en/cn)
}
设计点:路径不写死、数据与代码分离、生产必须覆盖默认 JWT_SECRET 和 ADMIN_PASSWORD。
3.3 main.py —— 装配要点
# 伪代码:app/main.py
lifespan: 启动时 await init_db() # 幂等建表
app = FastAPI(...)
app.add_middleware(CORSMiddleware, allow_origins=["*"], ...) # 开发期全开
挂载全部 router(public/auth/admin/chat/logs)
@get("/api/health") → {"success": True, "message": "ok"}
若 STATIC_DIR 存在:
mount("/static", StaticFiles) # 必须先于通配路由
@get("/") → index.html
@get("/{path:path}") → 非 /api/* 都回退 index.html;/api/* 返回 JSON 404
⚠️ 顺序:
mount/static必须写在@get("/{path:path}")之前,否则通配路由吞掉静态文件。
3.4 run.py —— 统一启动入口
# 伪代码
def main():
asyncio.run(init_db.init()) # 造默认管理员 + 测试项目
uvicorn.run("app.main:app", host="0.0.0.0", port=settings.PORT, reload=False)
main.py 的 lifespan 只建表,run.py 额外保证种子数据就位;开发调试与正式启动分离。
3.5 运行
pip install -r requirements.txt
python run.py # 或 PORT=8080 python run.py
# 浏览器打开 http://localhost:8000
04 · 数据库设计与初始化
说明五张表的建表方式与种子数据。关于"什么存本地":消息本体由 opencode 保管,本地只存业务元数据。
4.1 建表 SQL(要点)
app/database.py 里用 INIT_SQL 字符串集中定义全部表结构(全部 IF NOT EXISTS):
-- 伪代码:五张表
projects (id PK, name_en UNIQUE, name_cn, opencode_host, opencode_port,
opencode_user?, opencode_password?) -- 可选 Basic Auth 凭证
nav_links (id PK, project_id FK→projects ON DELETE CASCADE, label, url, icon='link', sort_order)
users (id PK, username UNIQUE, password_hash, is_admin 0/1, is_banned 0/1, project_id FK)
sessions (id PK, user_id FK→users CASCADE, project_id FK→projects CASCADE,
opencode_session_id, title, is_pinned 0/1, last_used_at, created_at)
operation_logs (id PK, user_id FK→users CASCADE, action, details, created_at)
索引: idx_sessions_user(user_id), idx_sessions_pinned(is_pinned),
idx_logs_user(user_id), idx_navlinks_project(project_id)
逐表解读:
- projects:
user 不填则 password 不能填;user 填了 password 可选(Pydantic 校验兜底)。 - nav_links:挂在 project 下,
ON DELETE CASCADE,icon+sort_order控制显示与排序。 - users:
password_hash只存 bcrypt 哈希,绝不存明文;is_admin/is_banned是 0/1 整数;管理员也有project_id,但概念上不属于任何项目、不能聊天。 - sessions:
opencode_session_id指向 opencode 的ses_...ULID,本地整数id只是自增主键。 - operation_logs:记录所有关键操作,删用户时级联清理。
4.2 连接管理 get_db
# 伪代码
async def get_db():
db = await aiosqlite.connect(str(DB_PATH))
db.row_factory = aiosqlite.Row # 路由里可写 row["title"],而非裸元组
try: yield db
finally: await db.close()
4.3 初始化 init_db.py
# 伪代码
async def init():
executescript(INIT_SQL)
若 projects 里没有 name_en=settings.INITIAL_PROJECT_EN → 插入默认项目(test)
若 users 里没有 admin → 插入管理员(is_admin=1, project_id=该项目)
# 全部"存在才跳过",幂等;值全部来自 Settings(环境变量可覆盖)
⚠️
init_db.py用裸连接(无row_factory),fetchone()返回元组用row[0];路由里是aiosqlite.Row用row["col"],别搞混。
4.4 验证
python init_db.py
sqlite3 data/opencode_chat.db ".tables" # projects nav_links users sessions operation_logs
sqlite3 data/opencode_chat.db "SELECT id,username,is_admin FROM users;" # 1|admin|1
05 · 认证与权限
密码哈希、JWT 签发校验、Cookie 会话、两个 FastAPI 权限依赖。
5.1 方案总览
密码存储 bcrypt 单向哈希(绝不明文)
登录态 JWT (HS256) → 放入 HttpOnly Cookie(oc_chat_token)
接口保护 FastAPI 依赖: require_auth / require_admin
5.2 bcrypt 密码哈希
def hash_password(password): return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode()
def verify_password(password, row_hash):
try: return bcrypt.checkpw(pw.encode(), row_hash.encode())
except Exception: return False # 哈希损坏返回 False 而非崩溃
2026 年 1 月起 PyPI 移除了 2.4.1 之前 bcrypt 轮子;requirements 用
bcrypt>=4.1.0。
5.3 JWT 签发 / 校验
def sign_token(payload): # 追加 exp(UTC) 后 HS256 encode
def verify_token(token): # 任意失败返回 None
载荷: {"uid": uid, "username": ..., "is_admin": bool}
⚠️
is_admin存 token 且校验时不查库:改用户角色要等旧 token 过期才生效。本项目可接受;生产要改成每次查库。
5.4 Cookie 会话
# 伪代码
get_token_from_request: 优先 Cookie,其次 Authorization: Bearer 头
set_auth_cookie: key/值/过期时间三原则:httponly=True(防XSS), samesite=lax(防CSRF),
secure=Settings.COOKIE_SECURE, max_age=TTL, path="/"
clear_auth_cookie: delete_cookie
5.5 权限依赖
async def require_auth(request): # FastAPI 依赖注入
取 token,没有 → 401"未登录"
verify_token 失败 → 401"登录已过期"
返回 {"uid","username","is_admin"}
async def require_admin(request): # 依赖:管理员权限
user = await require_auth(request)
不是管理员 → 403"需要管理员权限"
返回 user
用法:路由函数参数 `user: dict = Depends(require_auth/im,权限自动生效。
5.6 认证路由 / 伪代码
# app/routes/auth.py(伪代码,细节见源码)
POST /register # 校验项目存在 → 用户名不重复 → hash → 插入(is_admin=0);永远造普通用户
POST /login # 校验密码 + is_banned(封禁不可登录) → sign_token → set_cookie → 写日志 → 返回 token
POST /logout # 清 cookie + 写日志
GET /me # LEFT JOIN projects 返回用户+项目名
POST /change-password # 校验原密码 → 更新哈希 → 写日志
5.7 Pydantic 校验
# 伪代码
RegisterRequest: username(3~32位,正则 ^[a-zA-Z0-9_]+$) / password(>=6) / project_id
LoginRequest / ChangePasswordRequest(new_password>=6)
5.8 验证
curl -X POST http://127.0.0.1:8000/api/auth/register -H "Content-Type: application/json" \
-d '{"username":"alice","password":"secret123","project_id":1}' # 注册
curl -i -X POST http://127.0.0.1:8000/api/auth/login -H "Content-Type: application/json" \
-d '{"username":"alice","password":"secret123"}' # 登录(Set-Cookie)
curl -b "oc_chat_token=<token>" http://127.0.0.1:8000/api/auth/me # 受保护接口
最后三个教训回顾:密码只存哈希;JWT 无状态改角色不即时;注册永远造普通用户 + 封禁禁止登录。
06 · 项目管理与用户管理
管理员后台接口层:项目 CRUD、导航链接、用户封禁/重置密码/查看对话记录、操作日志。
6.1 边界
- 所有接口挂
require_admin(管理员专属),与普通用户隔离 - 管理员自己不能聊天(第 9 篇会话路由拒绝)
- 用户不可删除(避免数据冲突),但可封禁 / 重置密码 / 查看对话记录
6.2 路由伪代码
# app/routes/admin.py(全部 require_admin)
GET /projects # 列表(明文返回 opencode_password,管理员可见,勿暴露给普通用户)
POST /projects # 创建
PUT /projects/{pid} # 只更新传了值的字段:model_dump(exclude_none=True)
DELETE /projects/{pid} # 保护:还有普通用户时 400 拒绝删除;删 nav+项目
GET/POST/PUT/DELETE /projects/{pid}/nav-links # 导航链接 CRUD(更新/删除与项目同构)
GET /users # LEFT JOIN projects 带项目名
PUT /users/{uid}/ban # 管理员不可被封禁;只影响登录(is_banned)
POST /users/{uid}/reset-password # 随机生成或指定;把新密码返回管理员(仅一次可看)
GET /users/{uid}/sessions # 查本地会话 + 逐个从 opencode 拉消息(只读审计)
GET /logs # 管理员全量日志(LIMIT 500)
伪代码补充两个重点:
更新项目(动态字段,白名单安全)
for key, val in req.model_dump(exclude_none=True).items():
fields.append(f"{key} = ?"); values.append(val)
await db.execute(f"UPDATE projects SET {', '.join(fields)} WHERE id = ?", values + [pid])
字段名来自 Pydantic 模型键(白名单),值全走 ? 参数化,无注入风险。
查看用户对话记录(核心,02 / 08 的复用)
def list_user_sessions(uid):
校验用户存在
sessions = SELECT * FROM sessions WHERE user_id=? ORDER BY is_pinned DESC, last_used_at DESC
for s in session:
try: s["messages"] = OpenCodeClient.get_messages(项目host/port/user/pw, oc_id)
except: s["messages"] = [] # 拉取失败不阻塞列表
log_operation(admin, "view_user_sessions", ...)
消息本体存在 opencode 侧,本地只有指针,所以逐个会话从 opencode 拉。单会话拉取失败置空、不阻塞整体。这是只读审计接口。
6.3 普通用户自己的日志
# app/routes/logs.py
GET / user["uid"] 的日志,ORDER BY id DESC LIMIT 200
# 注意:路由注册是 @router.get("/"),实际路径 /api/logs/,客户端要带尾斜杠
07 · 认识 opencode 客户端
OpenCodeClient:把 opencode 的 REST 翻译成可复用方法,供路由层调用。
7.1 模块定位
- 一个无状态工具类(全部 classmethod),方法都接收
host/port/user/password(每个项目有自己的地址) - Basic Auth 只在 user+password 都配了才带
- 不同接口不同超时(会话 30s、发消息 120s、SSE 流式读 300s)
7.2 基础设施
# 伪代码
_base_url(host, port) → http://{host}:{port}
_auth(user, password) → httpx.BasicAuth(...) if user and password else None
# 每次请求都用 async with httpx.AsyncClient(auth=auth, timeout=...) as c: 一次性客户端,无连接泄漏
# 失败统一 raise_for_status()
7.3 方法清单
# 伪代码(REST 部分)
create_session(host,port,user,pw,title=None) → POST /session
list_sessions(...) → GET /session
delete_session(...) → DELETE /session/{id}(200/204 算成功)
get_messages(...) → GET /session/{id}/message(返回 [{info,parts},...])
send_message(...) → POST /session/{id}/message
注意:body 里绝不传 messageID(坑,见 02,代码里用注释记录)
revert_message / unrevert / ... → POST /session/{id}/revert | unrevert
message_id 是服务端分配的真实消息 ID(从历史消息里拿到的 msg_...),不是客户端生成的。
7.4 SSE 监听 stream_events(单向)
# 伪代码
async def stream_events(host,...,session_id):
@GET /event (httpx 流式 client.stream,read 300s)
逐行处理 SSE 分帧:
line.startswith("event:") → current_event = ...
line.startswith("data:") → json.loads(data)
按 session_id 过滤(/event 是全局广播)
统一形状 {"type": evt_type, "data": evt_data} yield 出去
若 _is_completion_event → return(正常收尾)
异常 → put {"error": ...}
7.5 ⭐ 完成信号 _is_completion_event(必须懂)
@staticmethod
def _is_completion_event(evt_type, evt_data) -> bool:
# 绝不把 session.status: idle 当完成:opencode 在保存用户消息之后、
# AI 生成之前会先发一次 idle,误判会让 SSE 提前退出导致 AI 永不回复
t = evt_type.lower()
info = evt_data.get("info", {})
if "message" in t and "update" in t: # message.updated 且 role=assistant 带 finish/error
if (info.role or) == "assistant" and (info.finish or info.error): return True
if t in ("message.complete", "session.complete", "agent.complete", "response.done"):
return True # 显式完成事件
return False
两种终点:① assistant 消息的
message.updated带finish/error;② 显式*.complete事件。
08 · SSE 流式对话
send_and_stream:同时跑 SSE 监听与 POST 发送,把实时增量转发给前端,解决 opencode POST 阻塞的问题。
8.1 问题
POST /message阻塞到回复完成才返回 → 傻等 = 用户看"转 N 秒瀑布"- 正解:SSE 监听 + POST 并发,增量立刻推给前端,POST 完成兜底结束流
8.2 核心骨架 send_and_stream
# 伪代码
async def send_and_stream(host,port,user,pw,session_id,text,agent=None):
body = {"parts": [{"type":"text","text": text}]}; (+agent)
queue = asyncio.Queue(); SENTINEL = object()
# 1) 先启动 SSE 监听任务(把事件塞进队列)
listen_task = asyncio.create_task(_listen_events(queue))
await asyncio.sleep(0.2) # 给 SSE 一点连上的时间
# 2) 再发 POST
post_task = asyncio.create_task(_send_message(task))
yield {"type": "message.sent", ...} # 立刻通知前端"已接受"
while True: # 排水循环
if post_task.done():
evt = await asyncio.wait_for(queue.get(), timeout=30.0) # 30s 兜底
else:
evt = await queue.get()
if evt is SENTINEL or evt is error: break
yield evt(f"{'data: '}{json}") # 转发给前端
if post_task 出错 → 发 error 帧,break
finally: # 统一取消未完成任务,防协程泄漏
cancel(listen_task idle); cancel(post_task done)
yield {"type": "stream.end", ...}
四个关键机制:
asyncio.Queue:SSE 任务 → 主协程的管道_SENTINEL哨兵:SSE 结束塞哨兵,主循环见到即退出- 30s 排水超时:POST 完成但 SSE 没推完成事件时兜底退出,防止挂死
finally取消任务:不留后台协程
8.3 SSE 监听任务 _listen_events
# 伪代码(细节见源码)
async def _listen_events(queue):
seen_activity = False
try:
<SSE 连接并逐行解析事件(流程见 07.4)>
# ⭐ 门闩 seen_activity:连上 /event 后,
# 只有"亲眼看到 AI 开始干活"(session busy / assistant 消息 / part.delta)
# 之后出现的完成信号才算数——过滤掉上一轮残留的 idle
if seen_activity and _is_completion_event(...): return
except asyncio.CancelledError: raise
except Exception as e: queue.put({"_error": str(e)}) # 错误也进队列,主循环转 error 帧
finally: queue.put(_SENTINEL)
为什么 seen_activity 要存在?SSE 全局广播可能残留上一轮的 idle 事件。不挡它,SSE 会在 AI 还没开工前"提前完成",POST 被取消,AI 永不回复。
8.4 事件时间线(一个完整回合)
前端 FastAPI opencode
│ POST /send ▲GET /event (SSE)
│ 启动 _listen_events ─►
│ sleep 0.2s 后 POST /message ─► (保存用户消息)
│ data: message.sent ◄─
│ data: session.status(busy) ◄─┘
│ data: message.part.delta "你"┄┄┄ ─► ← 打字机
│ data: message.updated (assistant, finish:stop) ◄─ 完成
│ → SSE 任务结束;POST 返回
│ data: stream.end ◄─
8.5 常见坑自查
- ❌ 传了客户端
message_id→ 第二轮起 AI 不回复 - ❌ 把
idle当完成信号 → SSE 提前退出、POST 被取消、AI 永不回复 - ❌ 顺序 await POST → 用户看不到打字机
- ❌ 不按 session 过滤
/event→ 别的会话事件混进来 - ❌ 没有 30s 兜底 → 事件丢失时前端无限转圈
- ❌ 不取消任务 → 协程泄漏
09 · 对话路由层
app/routes/chat.py,前缀/api/chat,全部require_auth。前后端唯一打交道的地方。
9.1 接口总览
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /sessions | 我的会话列表 |
| POST | /sessions | 新建会话(管理员除外) |
| DELETE | /sessions/{sid} | 删除会话 |
| PUT | /sessions/{sid}/pin | 置顶/取消置顶 |
| GET | /sessions/{sid}/messages | 拉历史 |
| POST | /sessions/{sid}/send | 发消息(SSE 流式) |
| POST | /sessions/{sid}/revert unrevert | 撤回 / 恢复 |
| GET | /nav-links | 我项目的导航链接 |
9.2 两个安全辅助函数(关键)
# 伪代码(app/helpers.py)
get_session_row(db, session_id, user_id):
SELECT * FROM sessions WHERE id=? AND user_id=? # 会话必须属于当前用户!
get_user_project(db, user_id):
SELECT p.* FROM projects p JOIN users u ON u.project_id=p.id WHERE u.id=?
越权防护关键:
get_session_row用WHERE id = ? AND user_id = ?——即使知道别人的会话 ID 也查不到、改不了。
9.3 各接口伪代码
# 伪代码
GET /sessions → SELECT ... WHERE user_id=? ORDER BY is_pinned DESC, last_used_at DESC
# title 空则兜底"新对话"
POST /sessions → 管理员拒绝(403) → 查 user_project → OpenCodeClient.create_session 在 opencode 建
真实会话 → 本地 INSERT 会话指针(存 oc_id ULID) → 返回 {本地id, opencode_session_id}
DELETE /sessions/{sid} → get_session_row → 尽力删 opencode 侧会话(best-effort) → 删本地 → 写日志
PUT /sessions/{sid}/pin → get_session_row → UPDATE is_pinned 翻转
GET /sessions/{sid}/messages → get_session_row → UPDATE last_used_at → get_messages 透传返回
POST /sessions/{sid}/send → 见 9.4(SSE 流式)
POST /sessions/{sid}/revert|unrevert → get_session_row → revert_message(req.message_id) → 写日志
GET /nav-links → 按 user 项目查 nav_links,无配置返回空数组(配置驱动 UI)
两个 ID:本地
id(自增整数,前端路由用)↔opencode_session_id(ses_...ULID,真正干活用),靠sessions表映射。
9.4 ⭐ 发送消息(SSE 流式)—— 项目最关键路由
@router.post("/sessions/{session_id}/send")
def send_message(sid, req, user=Depends(require_auth), ...):
check_permission(session) # get_session_row 归属校验
update last_used_at; log_operation("send_message")
def event_stream():
try:
async for chunk in OpenCodeClient.send_and_stream(项目host/port/user/pw, oc_id, req.text, req.agent):
yield chunk # 逐块转发 SSE 帧
except Exception as e:
yield error 帧
return StreamingResponse(event_stream(), media_type="text/event-stream",
headers={Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no})
要点:
event_stream()是异步生成器,逐块 yield;出错吐 error 帧StreamingResponsemedia_type=text/event-streamX-Accel-Buffering: no告诉 nginx 别缓冲 SSE(否则打字机失效)
因为
send_and_stream是异步生成器,路由层保持干净,全部并发在大理客户端里。
10 · 前端基础:SPA 与 API 封装
纯 HTML/CSS/JS 单页应用:hash 路由、视图切换、登录/注册、统一 API 封装。
10.1 技术栈与结构
- 零构建、零黑盒:
python run.py起来即用 - 前端库(全部 CDN):marked(Markdown)、highlight.js(代码高亮)、DOMPurify(XSS 消毒,渲染 AI 输出必备)
index.html:<div id="app"> 并列四个视图,display 切换:
| 视图 | id | 用途 |
|---|---|---|
| 登录 | #view-login | 用户名/密码 |
| 注册 | #view-register | 用户名/密码/选项目 |
| 聊天 | #view-chat | 主聊天界面 |
| 管理 | #view-admin | 管理后台 |
脚本加载顺序(依赖关系):api.js → app.js → chat.js → admin.js,均带 ?v=N 缓存破坏。
10.2 hash 路由 app.js(伪代码)
const App = { user: null,
async init() { try{this.user=(await API.me()).user}catch{} ; route(); hashchange→route() },
route() {
未登录: hash==#/register → showView('register') else showView('login')
已登录: is_admin ? showView('admin'); Admin.init()
else showView('chat'); Chat.init()
},
showView(name){ 切 display: flex/none }
}
DOMContentLoaded → App.init()
. _bound技巧:route()可能重复调用initLogin(),用标志位防事件重复绑定。
10.3 API 封装 api.js
async _request(method, path, body){
fetch(path, {method, credentials:'include', headers}/*body JSON*/)
→ !res.ok || data.success===false → throw Error(优先 error→detail→message→HTTP 状态)
return data
}
// 方法一览:register/login/logout/me/listSessions/createSession/deleteSession/togglePin
// getMessages/revertMessage/unrevertMessage/admin*...
credentials:'include' 带 Cookie;统一错误提取,调用方 try/catch。
10.4 ⭐ SSE 流式读取 sendMessageStream(前端关键)
async sendMessageStream(sid, text, onEvent, signal){
const res = await fetch(`/api/chat/sessions/${sid}/send`, {POST, credentials:'include', body:{text}, signal})
if(!res.ok){取 detail/error 抛错}
const reader = res.body.getReader(); // 手动解析 SSE 帧
let buffer=''; const decoder = new TextDecoder();
while(true){
const {done,value} = await reader.read();
if(done)break;
buffer += decoder.decode(value, {stream:true}); // ⭐ 必须是流式解码,否则中文被切开会乱码
while(buffer.includes('\n')){ const line=buffer.split('\n')[0]; buffer=buffer.slice(line.length+1);
if(line.trim().startsWith('data: ')) { try{onEvent(JSON.parse(line.trim().slice(6)))}catch{} }
}
}
// 尾部残余 data: 帧也要处理
catch(err){ if AbortError reader.cancel(); throw }
}
要点:TextDecoder({stream:true}) 防多字节乱码;分帧缓存未完成行;signal 支持停止(AbortController)。
10.5 工具函数 Util(伪代码)
escapeHtml(text) // 用 div.textContent→innerHTML 自动转义
renderMarkdown(text) // setOptions(breaks,gfm) → marked.parse →
// return DOMPurify? DOMPurify.sanitize(raw) : raw // ⭐ 安全底线
formatTime(ts) // 处理 毫秒/>1e12 / 秒 / ISO → "刚刚/N 分钟前..."
highlightCode(container) // pre code 语言徽章 + hljs.highlightElement
toast / showLoading / showModal(title,fields,onSubmit) / confirmDialog(title,onConfirm)
10.6 注册表单(伪代码)
async initRegister(){
// 加载项目下拉框(API.listProjects)
form._bound 防重复;提交 → API.register → toast 成功 → 1s 后跳 #/login
}
11 · 前端聊天界面
会话侧栏、消息气泡渲染、Markdown/代码高亮、锚点面板。流式接收见 12。
11.1 布局
.chat-topbar logo/标题/项目名徽章 + #chat-nav-links(配置驱动) + 用户名/退出
.chat-body
├─ .chat-sidebar(270px) 新对话 #btn|搜索引擎 #sidebar-search | #session-list(pin/delete/active高亮)
└─ .chat-main
├─ .chat-header 标题 + 锚点切换
├─ #anchor-panel 锚点侧栏
├─ #messages 可滚动消息区
└─ .chat-input 输入框 + 发送/停止
11.2 会话侧栏(伪代码)
loadSessions() → API.listSessions → set
renderSessionList() → 时间格式化/置顶 icon/active 高亮;运算按钮 + event.stopPropagation()
selectSession(sid) → isStream 守卫 → getMessages → renderMessages
流式生成中禁止切换/删除/重发(
isStreaming守卫),保证单流并发安全。
11.3 parseMessage —— 消息归一化(各种形状 → 统一结构)
// 原始消息可能 {info:{...},parts:[...]} 或 SSE 的 {data:{...}},全部归一化:
parseMessage(msg) {
return { id, role, parts:[{type,text,data}], time, model, agent } // 各自按 info/扁平/SSE 兼容取值
}
11.4 消息气泡(伪代码)
appendMessageEl(parsed) → 建 div.message(avatar/header/内容区);标 dataset.anchorIndex
renderPart(part, role):
type=text → user 用 textContent(纯文本)、assistant 用 renderMarkdown(内部 DOMPurify)
type=reasoning → <details> 折叠"推理过程"
type=tool/step-start → <details> 折叠"工具调用"
// 三处防注入:用户文本 textContent、AI 文本 DOMPurify、工具内容 escapeHtml
11.5 锚点面板(伪代码)
updateAnchorPanel() {
// 遍历 #messages [data-role=user] 消息 → 每条 #1 #2 #3...
// 点击 → scrollIntoView(smooth) + 高亮 1s
}
12 · 前端流式接收与交互
打字机、停止按钮、撤回/恢复闭环。
12.1 sendMessage 完整回合(伪代码)
async sendMessage() {
guard: 空文本 / isStreaming / 无当前会话 → return
new AbortController() // 支持停止
乐观渲染:立刻贴出用户气泡(temp 前缀 ID)
贴 AI 占位气泡("生成中..." + 闪烁光标)
state = { parts:[], currentText:'', done:false, messageId:null }
await API.sendMessageStream(sid, text, evt=>this.handleStreamEvent(evt,state,bodyEl), signal)
catch: AbortError/已停止 → 优雅收尾;否则错误提示
finally: 恢复 UI、最终 Markdown 渲染、updateAnchorPanel、刷新会话列表
}
12.2 handleStreamEvent 事件中枢(伪代码)
message.part.delta → 累积 state.parts
⭐ 只渲染属于 AI 消息的 delta:state.messageId(获自 message.updated)之前 → 只记录不显示;
之后 → 把 delta 拼进 state.currentText
message.updated → role==assistant → 记录 state.messageId(后续 delta 过滤钥匙)+ 完整快照
message.part.updated → 合并 part,属于 AI 的文本才渲染
session.updated/status → idle/completed → state.done=true;更新标题
error / stream.end / complete → done=true
⭐
state.messageId门锁:AI 消息 ID 要到message.updated才知道。之前到达的 delta 不显示; 知道了只拼 AI 消息的 delta —— 避免"用户消息被当作打字机回显"。
12.3 打字机渲染
updateStreamingText(el, text){ el.innerHTML = Util.renderMarkdown(text)+'<span class="streaming-cursor"></span>' }
12.4 停止(AbortController)
stopStreaming(){ if(!isStreaming)return; wasStopped=true; abortController.abort(); toast '已停止' }
// 结束:setStreamingUI(false)→恢复发送/输入;最终渲染带 "(已停止)" 徽章
链条:stop → abort → fetch 抛 AbortError → api.js 释放 reader → chat.js 优雅收尾。
12.5 撤回 / 恢复
revertMessage(msgId) → confirmDialog → API.revert → 记住 lastReverted → 重新拉消息渲染 + showRevertBanner
unrevertMessage() → API.unrevert(LastReverted) → 重新拉取渲染
showRevertBanner() → 顶部横幅"已撤回消息,可以恢复"[恢复消息]
13 · 管理面板前端
管理员端四个面板:项目 / 导航 / 用户 / 日志。全部复用通用弹窗。
13.1 结构
#view-admin → .admin-tabs(项目/用户/日志切换) + .admin-content
├─ #admin-tab-projects 项目卡片列表 + 导航链接区
├─ #admin-tab-users 用户列表
└─ #admin-tab-logs 操作日志
13.2 面板实现(伪代码)
switchTab(target) → 高亮 tab、切 content、懒加载(logs→loadLogs, users→loadUsers)
// 事件委托:.admin-tabs click → closest('.admin-tab') → switchTab
renderProjects() → admin-card(中文名/英文名/地址/用户)+ 编辑/导航/删除按钮
showProjectModal(project=null) → Util.showModal(['name_en','name_cn','host','port','user','password'], submit)
selectProjectForNav(pid) // 选中项目才显示导航区
showNavLinkModal(link) → label/url/icon/sort
renderUsers(users) → 徽章(管理员/普通/已封禁);管理员无操作按钮(后端双重拒绝)
toggleBan(uid) → API.adminBanUser → toast → reload
resetPassword(uid,uname) → showModal 新密码(留空=随机) → 替换弹窗为"密码已重置"视图 + 复制按钮
// 唯一一次能看新密码,醒目提示保存
viewUserSessions(uid, uname) → loading → API.adminListUserSessions → renderUserSessions(sessions) 弹窗
renderUserSessions → 每个会话卡片(标题/时间/消息),消息 _formatSessionMessage 200 字符截断 + escapeHtml
操作日志 → actionLabels 映射中文 + 逐行渲染
actionLabels 末尾追加 'view_user_sessions': '查看对话记录'。
14 · 安全加固与生产部署
生产加固要点:opencode 进程权限、应用加固、进程守护、常见坑。
14.1 威胁模型
| 风险 | 来源 | 后果 |
|---|---|---|
| opencode 进程权限过大 | 以 root 跑、工作区无隔离 | AI 能 R/W/执行宿主机任意文件、命令 |
| 认证凭证泄露 | 默认密钥/密码、明文 | 伪造登录、控制管理后台 |
| XSS | AI 输出未消毒 | 窃取 Cookie |
| CSRF | Cookie 认证 | 跨站操作 |
| 越权 | 会话/接口归属校验不严 | 访问他人数据 |
代码已内建防线见各篇,本篇讲没做或没做够的。
14.2 ⭐ opencode 进程权限控制
两层做:
第一层:opencode 自带 permission(软限制,第 01 篇已有基础配置):
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"*": "ask", // 整体默认询问
"bash": "deny", // 禁止执行 shell
"read": {
"*": "allow", // 工作区内可读
"/root/.config/opencode/**": "deny" // ⭐ 关键:禁止读 opencode 自身配置/密钥
},
"webfetch": "allow",
"external_directory": "ask" // ⭐ 工作区之外一律询问(默认已是 ask)
}
}
用
read的路径规则 +external_directory才能把"读任意绝对路径"堵住。伪代码:
# 伪代码(权限边界)
permission = {
read allow only workspace/** # 工作区允许
external_directory deny-all # 任何外部路径被拒绝
bash deny # 堵住 cat /etc/ 之类的 shell 逃逸
edit/write deny # 只读 agent
}
# 没有 root 权限时,OS 文件系统仍能兜底(chmod 700 工作区、非 root 用户)
第二层:OS 级限制(硬限制):
- 建专用普通用户:
sudo adduser --disabled-password opencode - 收紧工作区:
chown -R opencode: ~/opencode-workspace && chmod 700 ~/opencode-workspace - 容器隔离(最强):Docker 只挂载工作区 +
--workdir /workspace,--hostname 0.0.0.0别暴露公网 - opencode serve 默认监听 0.0.0.0:只本机访问时绑
127.0.0.1
结论:本项目默认 = 只读 permission(第一层)。要真的隔离,必加第二层(非 root + 容器)。
14.3 认证与密钥加固
export JWT_SECRET="$(openssl rand -hex 32)"
export COOKIE_SECURE="true" # HTTPS 下
export ADMIN_USERNAME="opsadmin"; ADMIN_PASSWORD="$(openssl rand -base64 16)"
projects.opencode_password明文存、管理员可读:改进 = 密钥管理(sops/vault)或接口脱敏(******)
已知取舍(记录):is_admin 存 token → 改角色要等过期;封禁只拦登录 → 已登录旧 token 仍有效。生产可每次查库或加 token 黑名单。
14.4 XSS / CSRF / 越权回顾
- XSS:用户文本
textContent、AI 文本DOMPurify.sanitize、工具/推理内容escapeHtml——DOMPurify 是安全底线,别省 - CSRF:Cookie
samesite="lax"+ HTTPS +secure - 越权(代码已内建):会话
user_id归属校验、普通用户只有自己项目、管理员接口全require_admin、管理员不能会话/被封禁/被重置
14.5 进程守护与部署
systemd 守护 opencode
[Service]
User=opencode
WorkingDirectory=/home/opencode/opencode-workspace
ExecStart=/home/opencode/.bin/opencode serve --hostname 127.0.0.1 --port 4096
Restart=always; RestartSec=3; Environment=HOME=/home/opencode
守护 FastAPI
# 简单
nohup uvicorn app.main:app --host 0.0.0.0 --port 8000 &
# 或多进程
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2
⚠️ run.py 会先跑幂等的 init_db.init() 再 uvicorn;多 worker 时 lifespan 建表也不会冲突。
nginx 反代(SSE 必须关缓冲)
location /api/chat/ {
proxy_pass http://127.0.0.1:8000;
proxy_buffering off; # ⭐ 否则打字机变一次性
proxy_cache off; proxy_http_version 1.1; proxy_set_header Connection '';
}
location / { proxy_pass http://127.0.0.1:8000; }
后端 SSE 响应头已带 X-Accel-Buffering: no,nginx 侧 proxy_buffering off 双保险。
14.6 生产清单(Checklist)
- opencode 非 root 用户;工作区 700 或容器
-
JWT_SECRET随机;COOKIE_SECURE=true;管理员密码已改 -
opencode serve不暴露公网(127.0.0.1 或防火墙) - nginx
proxy_buffering off;前端 DOMPurify 消毒 -
data/定期备份(sqlite3 .backup);日志审计定期检查
14.7 常见坑速查
| 症状 | 原因 | 修复 |
|---|---|---|
| 第二轮起 AI 不回复 | 客户端自造 messageID | 去掉,服务端 ULID |
| 打字机变一次性 | nginx 缓冲 SSE | proxy_buffering off |
| AI 永不回复(转圈) | 把 idle 当完成 | assistant message.updated 带 finish |
| 中文乱码 | SSE 分帧没流式解码 | TextDecoder({stream:true}) |
| 管理后台 403 | 非管理员 | 登录管理员账号 |
| 撤回 409 | 会话忙 | 前端等停止后再撤回 |
14.8 结语
浏览器 ── FastAPI 网关 ── opencode serve
│ │
SQLite WSL AI
JWT/SSE 只读/隔离
覆盖:看懂 OpenAPI 并封装客户端;FastAPI + aiosqlite 多用户;纯 JS SPA + 流式打字机 + 安全渲染;权限双分层(应用层认证 + 进程层隔离)。
可扩展方向:消息本地缓存、opencode v2 分阶段撤回、每会话独立工作区、多模型/多 agent、用量统计(tokens/成本)、WebSocket 替代 SSE。
15 · 数据库迁移:SQLite → MongoDB / MySQL
把 SQLite 迁移到 MongoDB / MySQL 的改造方案。只做方案,不改代码。
15.1 现状梳理
- 驱动
aiosqlite,get_db每请求一个连接,row_factory = Row(结果可row["col"]) - 访问方式统一:
cursor=db.execute(sql, params)→fetchone()/fetchall(),db.commit()手动提交,lastrowid取自增主键(chat 新建会话) - 表间关系靠 SQL:JOIN(
/me、管理员用户列表)、ON DELETE CASCADE(删项目连带删导航/会话) - 动态更新用
f"UPDATE ... SET {', '.join(fields)}"(字段来自 Pydantic 白名单)
改动文件:database.py、init_db.py、helpers.py、routes/{auth,chat,admin,public,logs}.py。
15.2 先想清楚:选型决策
| 决策点 | 建议 |
|---|---|
| 数据规模 | 消息在 opencode 侧,本地只有元数据,量级小;SQLite 单机够用。迁移收益 = 多实例 / 集中管理 / 已有基建 |
| 一致性 | 强关系(用户→项目→会话→日志,删级联)→ MySQL 天然契合;Mongo 需应用层处理级联 |
| 成本 | MySQL 兼容 SQL(改动小);Mongo 需多次查询改写(改动大) |
结论:不是必须迁 。真要迁优先 MySQL。
15.3 通用改造思路:先抽象访问层
2 步:① 建 Repository 层把 db.execute(...) 收敛成独立函数(路由只调函数);② 换 get_db 连接只改 database.py。把"改 40+ 处 SQL"降到"改 1 文件 + Repository"。
15.4 MySQL 方案
连接: 驱动选 aiomysql,get_db 用连接池 + aiomysql.DictCursor(结果 row["col"] 可直接用,等价 aiosqlite.Row,路由写法不动):
# 伪代码
async def get_db():
async with _pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
yield cur # row["col"] 可用
表结构对照:INT AUTO_INCREMENT PRIMARY KEY / VARCHAR(n)/INT/CURRENT_TIMESTAMP / ON DELETE CASCADE(需 InnoDB)/ CREATE INDEX。引擎统一 ENGINE=InnoDB DEFAULT CHARSET=utf8mb4(才能存中文/emoji)。
代码改造点:
- 占位符
?→%s(全局唯一语法差异) datetime('now')→NOW();验证工具 sqlite3 → mysql CLI- commit 照旧;
lastrowid语义相同;动态 UPDATE 写法不变 - MySQL 默认排序规则
=不分大小写:若用户名需大小写敏感,加COLLATE utf8mb4_bin
种子:init_db.py 逻辑不变,连接换连接池 + 建表 IF NOT EXISTS,建表脚本与种子分开。
15.5 MongoDB 迁移
连接: motor(官方异步驱动),get_db 直接 yield MotorDatabase 对象:
# 伪代码
async def get_db(): yield motor_db # 路由拿到的是 MotorDatabase
集合设计: 5 表 → 5 集合;_id 建议存 int(前端 URL 和 row["id"] 大量用整数 id)。无自增需 counters 集合 + find_one_and_update 递增。时间一律 ISODate(UTC)。
sessions 文档示例:
{ _id: 5, user_id: 3, project_id: 1,
opencode_session_id: "ses_02...", title: "新对话",
is_pinned: false, last_used_at: ISODate(...), created_at: ISODate(...) }
关键差异(改写清单):
| 现有写法 | Mongo 改写 |
|---|---|
JOIN projects | 先查 user 得 project_id 再查 projects(无 JOIN) |
ORDER BY a,b + LIMIT | .sort([...]).limit(n) |
datetime('now') | 应用层 UTC 或 $currentDate |
ON DELETE CASCADE | 应用层事务(session.start_transaction())显式删子文档 |
COUNT(*) / row["cnt"] | count_documents() |
改造点:/me 的 LEFT JOIN 改两次查询(或冗余 project_name);admin 删项目级联删导航/COUNT 用户改应用层;chat 新建会话 lastrowid 改先取 counter;helpers 三个函数返回 dict 才能 row["col"]。
15.6 数据迁移脚本(SQLite → 目标库)
1 步照做:只读打开 SQLite → 按依赖顺序(projects → nav_links → users → sessions → operation_logs)读出写入目标库 → 时间字段(MySQL 直接插入 / Mongo 转 ISODate)→ id 原样保留(会话 URL、get_session_row 依赖)→ 目标库先清空再写,跑完对比行数校验。
15.7 分步清单
MySQL:建 utf8mb4 库 → config 加连接变量 → 连接池 + DictCursor → INIT_SQL 改方言 → ?→%s 全局替换 → 迁移脚本 → 回归(注册/登录/聊天/撤回/后台)。
MongoDB:建 5 集合 + counters → motor 连接 → helpers 返回 dict → 逐路由改 JOIN/级联/lastrowid → 迁移脚本(时间 ISODate、id 保留)→ 回归。
15.8 常见坑速查
| 症状 | 原因 | 解决 |
|---|---|---|
| 迁移后中文乱码 | 连接/库非 utf8mb4 | 库/表/连接全 utf8mb4 |
row["col"] 取不到 | aiomysql 默认元组游标 | 用 DictCursor |
| 删项目外键错 | 不是 InnoDB | 建表统一 InnoDB |
| Mongo 孤儿数据 | 无级联 | 应用层显式删 + 事务 |
| 自增 id 冲突 | 迁移重新生成 id | 保留原值 |
| 时间差 8 小时 | 存本地时区 | 统一 UTC |
15.9 相关文档
- 04 · 数据库设计与初始化 —— 现有五张表结构与种子数据
- 09 · 对话路由层 —— 会话查询 /
lastrowid - 14 · 安全加固与生产部署 —— 部署与备份