从 0 到 1 · 手把手搭建 opencode 网页对话网关—— 完整文档

5 阅读20分钟

项目文档

项目代号:OCBridge · 配套仓库:opencode_chat

使用 FastAPI 后端 + SQLite + 纯前端三件套,把 opencode serve 封装成多用户、带权限、可流式聊天的网页工具。

目录

  1. 总体架构与设计
  2. 环境准备与 opencode serve 启动
  3. 认识 opencode API
  4. 后端骨架与配置
  5. 数据库设计与初始化
  6. 认证与权限
  7. 项目管理与用户管理
  8. 认识 opencode 客户端
  9. SSE 流式对话
  10. 对话路由层
  11. 前端基础:SPA 与 API 封装
  12. 前端聊天界面
  13. 前端流式接收与交互
  14. 管理后台前端
  15. 安全加固与生产部署
  16. 数据库迁移: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 数据库设计

五张表:

存什么
projectsopencode 后端连接配置(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/healthGET健康检查
/sessionPOST创建会话
/session/{id}GET / DELETE / PATCH查询 / 删除 / 改名
/session/{id}/messageGET拉历史
/session/{id}/messagePOST发消息
/session/{id}/revert unrevertPOST撤回 / 恢复
/eventGET(SSE)实时事件流长连接

离线规范在仓库根目录 doc.json(4 万行,别硬翻,用 grep 或 Swagger UI)。

2.2 创建会话与响应

POST /session   body: { "title": "新对话", "agent": "build", ... }
→ Session 对象,关键字段 id(ses_ 开头的 ULID)

可选字段:parentID(继承父会话)、modelpermission(按会话单独设权限)、workspaceID

2.3 历史消息结构

GET /session/{id}/message → [ {info, parts}, ... ]
  • info:消息元数据(id、sessionID、role、time、agent、model)
  • parts:内容块数组(text / reasoning / tool 等),文本在 parts[].text
  • assistant 的 info 额外有 parentIDfinishstop/error)、tokenscost

2.4 ⭐ 大坑:不要自己生成 messageID

POST /message 请求体里可选的 messageID 字段允许自定义(正则 ^msg)。本项目踩过的坑:

opencode 每条消息的 ID 是有序 ULID(如 msg_fd77e42d...)。在对话循环里(SessionPrompt.runLoop),服务端用消息 ID 的大小顺序判断本轮是否结束(条件类似 lastUser.id < lastAssistant.id 时退出)。如果客户端传自造的 msg_<uuid十六进制>,它在字典序上小于服务端 ULIDmsg_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.updatedsessionID, info: Messageassistant 完成/更新,info.finish 有值时表示回复结束
message.part.updatedsessionID, part内容块创建/落定
message.part.deltasessionID, messageID, partID, field, delta流式增量文本(打字机)
session.statusstatus: {type: idle|busy}会话忙闲
session.updatedinfo: Session会话信息(含 title)
session.errorerror出错了

状态机:{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_SECRETADMIN_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)

逐表解读:

  • projectsuser 不填则 password 不能填;user 填了 password 可选(Pydantic 校验兜底)。
  • nav_links:挂在 project 下,ON DELETE CASCADEicon+sort_order 控制显示与排序。
  • userspassword_hash 只存 bcrypt 哈希,绝不存明文;is_admin/is_banned 是 0/1 整数;管理员也有 project_id,但概念上不属于任何项目、不能聊天。
  • sessionsopencode_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.Rowrow["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.updatedfinish/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_rowWHERE 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_idses_... 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})

要点:

  1. event_stream() 是异步生成器,逐块 yield;出错吐 error 帧
  2. StreamingResponse media_type=text/event-stream
  3. X-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 }
}
DOMContentLoadedApp.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===falsethrow 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.listSessionsset
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.adminListUserSessionsrenderUserSessions(sessions) 弹窗
renderUserSessions → 每个会话卡片(标题/时间/消息),消息 _formatSessionMessage 200 字符截断 + escapeHtml
操作日志 → actionLabels 映射中文 + 逐行渲染

actionLabels 末尾追加 'view_user_sessions': '查看对话记录'


14 · 安全加固与生产部署

生产加固要点:opencode 进程权限、应用加固、进程守护、常见坑。

14.1 威胁模型

风险来源后果
opencode 进程权限过大以 root 跑、工作区无隔离AI 能 R/W/执行宿主机任意文件、命令
认证凭证泄露默认密钥/密码、明文伪造登录、控制管理后台
XSSAI 输出未消毒窃取 Cookie
CSRFCookie 认证跨站操作
越权会话/接口归属校验不严访问他人数据

代码已内建防线见各篇,本篇讲没做或没做够的

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 级限制(硬限制):

  1. 建专用普通用户:sudo adduser --disabled-password opencode
  2. 收紧工作区:chown -R opencode: ~/opencode-workspace && chmod 700 ~/opencode-workspace
  3. 容器隔离(最强):Docker 只挂载工作区 + --workdir /workspace--hostname 0.0.0.0 别暴露公网
  4. 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 缓冲 SSEproxy_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 现状梳理

  • 驱动 aiosqliteget_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.pyinit_db.pyhelpers.pyroutes/{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 方案

连接: 驱动选 aiomysqlget_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 相关文档