diff --git a/CLAUDE.md b/CLAUDE.md index 1a89804..2a493b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,4 +158,5 @@ - Phase 1 实施计划:`docs/superpowers/plans/2026-10-12-phase1-engine-merge-tenantization.md` - Phase 2 实施计划:`docs/superpowers/plans/2026-10-13-phase2-platform-layer.md` - Phase 3 实施计划:`docs/superpowers/plans/2026-10-13-phase3-admin-spa.md` -- 部署坑手册:Phase 4 补 `docs/deploy-production.md`(尚未存在;Phase 4 部署记档段已锚定要点) +- Phase 4 实施计划:`docs/superpowers/plans/2026-10-14-phase4-deploy-migration.md` +- 部署手册(含切换 runbook + 踩坑实录):`docs/deploy-production.md`;反代模板 `deploy/openresty-all-kefu.conf.template` diff --git a/deploy/openresty-all-kefu.conf.template b/deploy/openresty-all-kefu.conf.template new file mode 100644 index 0000000..145a359 --- /dev/null +++ b/deploy/openresty-all-kefu.conf.template @@ -0,0 +1,153 @@ +# ============================================================================= +# all-kefu 生产反代模板(openresty / nginx 通用,完整可抄) +# ============================================================================= +# 拓扑(spec §9):所有域名(租户域名 × N + 管理后台)反代到同一个容器 +# chat.agent.hnxinyouhe.com (租户 auto) ─┐ +# chat2.agent.hnxinyouhe.com (租户 jiancai) ─┼→ 127.0.0.1:8018 → all-kefu 容器 +# (形态 A,部署前由需求方确定)─┘ +# +# 两种用法: +# · 独立 openresty/nginx:整份可抄——upstream + server 块齐备,按需删掉 +# 不用的块(admin 只用形态 A 或 B 其一)。 +# · 1Panel:server 块由面板生成,**只抄 location 片段**到 +# /opt/1panel/www/sites/<域名>/proxy/*.conf——文末「1Panel 适配注记」 +# 有逐条对照与现成片段。 +# +# 血泪纪律(每条都出过线上事故,改配置前重读一遍): +# 1. Host 头必须透传(proxy_set_header Host $host)——本项目按 Host 头 +# 解析租户,漏了这一行全站 404(不是报错,是静默 404)。 +# 2. WS 升级头(Upgrade/Connection)全站只能出现一套——1Panel 面板生成 +# 的反代若已带一套,再抄一份会重复定义,uvicorn 严格解析直接拒绝 +# 握手(HTTP 正常、WS 426)。 +# 3. client_max_body_size 至少 5m——头像上传上限 2MB,nginx 默认 1m +# 会直接 413(应用层校验根本收不到请求)。 +# ============================================================================= + +# ---- upstream(独立部署时用;1Panel 场景见文末注记,直接写 IP:端口) -------- +upstream all_kefu { + server 127.0.0.1:8018; # 单容器单 upstream;换端口只改这里 +} + +# ---- 租户域名 server 块(每个租户域名复制一份,改 server_name 即可) -------- +# 含:全站反代 + WS 单套升级头 + /api/public(widget 头像/品牌,放行即全站 +# 反代的一部分,单独点名是防"按路径收紧"时漏掉它——漏配的症状是 widget +# 头像 404)。 +server { + listen 80; # 1Panel/证书层负责 443 与 HTTPS;独立部署自行补 listen 443 ssl + server_name chat2.agent.hnxinyouhe.com; # ← 每个租户域名一份:chat.(auto)/ chat2.(jiancai) + + # WebSocket(/ws/chat):升级头单套 + 长读超时(长连接心跳间隔内不断开) + location /ws/ { + proxy_pass http://all_kefu; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; # 纪律 1:WS 也按 Host 解析租户 + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 引擎 IP/区域解析取 XFF 第一段 + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # 其余全部(/, /static, /api/chat*, /api/public, /health …)—— + # /static/widget.js 的 no-cache 由应用层中间件下发(响应头已带 + # Cache-Control: no-cache),反代无需另加,也别加会覆盖它的 expires。 + location / { + proxy_pass http://all_kefu; + proxy_http_version 1.1; + proxy_set_header Host $host; # 纪律 1 + 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; + proxy_read_timeout 300s; + client_max_body_size 5m; # 纪律 3(头像上传) + } + + # ---- admin 形态 B(同域名路径分流;不用独立管理域名时启用) -------------- + # 应用层对 /admin 与 /api/platform/ 前缀豁免租户 Host 解析,挂在租户 + # 域名下照常工作(与租户聊天面共存,同源零 CORS)。启用 = 取消注释; + # nginx 最长前缀匹配,这两块优先于上面的 location /。 + # 已知边界:形态 B 下后台"品牌-头像预览"走本域 /api/public/avatar, + # 解析到的是**本域名对应的租户**——切换到其他租户的配置页时头像预览 + # 不准(已知项,CLAUDE.md Phase 4 清理单 #3/#4)。 + # + # location /admin { + # proxy_pass http://all_kefu; + # proxy_http_version 1.1; + # 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; + # proxy_read_timeout 300s; + # client_max_body_size 5m; # 头像上传走 /api/platform/.../brand/avatar + # } + # location /api/platform/ { + # proxy_pass http://all_kefu; + # proxy_http_version 1.1; + # 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; + # proxy_read_timeout 300s; + # client_max_body_size 5m; + # } +} + +# ---- admin 形态 A:独立管理域名 server 块(与形态 B 二选一) ------------------ +# 整域反代到同一容器:/admin(SPA 静态,含 assets)与 /api/platform(后台 +# API,认证由路由依赖守护)同一 upstream。全站反代即可,无需按路径拆分。 +# 入口是 https:///admin/——管理域根路径 / 走租户 Host 解析, +# 404 属预期(要友好可在本块加 location = / { return 302 /admin/; })。 +# 已知边界:独立管理域名下 /api/public/*(按租户 Host 解析)必 404——后台 +# "品牌-头像预览"在该形态加载不出(预览以本地简化预览为准),已知项 +# (CLAUDE.md Phase 4 清理单 #3),非配置错误,勿按 404 排障反代。 +server { + listen 80; + server_name admin.example.com; # ← 占位:管理域名部署前由需求方确定 + + location / { + proxy_pass http://all_kefu; + proxy_http_version 1.1; + proxy_set_header Host $host; # 形态 A 的 Host 是管理域(未知租户)—— + # /admin 与 /api/platform 豁免租户解析,照常工作 + 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; + proxy_read_timeout 300s; + client_max_body_size 5m; # 头像上传 + } +} + +# ============================================================================= +# 1Panel 适配注记(jc 项目两次线上事故的源头,逐条核对) +# ============================================================================= +# 1. server 块由面板建站生成,不要手写整个 server——只把上面的 location +# 片段放进站点目录: +# /opt/1panel/www/sites/<域名>/proxy/root.conf ← location /(全站反代) +# /opt/1panel/www/sites/<域名>/proxy/ws.conf ← location /ws/(升级头单套) +# /opt/1panel/www/sites/<域名>/proxy/admin.conf ← 形态 B 的两块(启用时) +# 片段内 proxy_pass 直接写 http://127.0.0.1:8018——upstream 块只能声明在 +# http 上下文,server 级 include 文件里放不进去(面板世界没有"全局 +# upstream 文件"的挂载点,直写地址即单一事实源,换端口全文替换)。 +# +# 2. 主 conf 的 include 行检查(1Panel 坑:面板建站默认可能不带): +# /opt/1panel/www/conf.d/<域名>.conf +# 里必须有(没有就手工补,改前备份同目录 *.conf.bak.<日期>): +# include /www/sites/<域名>/proxy/*.conf; +# 注意路径前缀是 /www/sites/...(1Panel 容器内视角),不是 /opt/1panel/...。 +# 面板配了反代却不生效,九成是这行缺失。 +# +# 3. 1Panel 面板生成的反代若自带 Upgrade/Connection 头,删掉面板那套再放 +# ws.conf(升级头只能一套,见顶部纪律 2)。 +# +# 4. 改完必验再重载(先 -t 再 reload;reload 平滑,不影响其他站点): +# docker exec $(docker ps --filter name=openresty -q | head -1) nginx -t +# docker exec $(docker ps --filter name=openresty -q | head -1) nginx -s reload +# 验证(逐域名): +# curl -s https://<租户域名>/health | head -c 200 # {"status":"ok",...} +# curl -sI https://<租户域名>/static/widget.js | grep -i cache # Cache-Control: no-cache +# curl -s -i --http1.1 -H "Connection: Upgrade" -H "Upgrade: websocket" \ +# -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" \ +# https://<租户域名>/ws/chat | head -1 # HTTP/2 101 或 HTTP/1.1 101 +# ============================================================================= diff --git a/docs/deploy-production.md b/docs/deploy-production.md new file mode 100644 index 0000000..e00d04b --- /dev/null +++ b/docs/deploy-production.md @@ -0,0 +1,440 @@ +# all-kefu 生产部署手册(含切换 runbook) + +> 对应工件:`Dockerfile`(两阶段构建)、`docker-compose.yml`(`name: all-kefu`)、 +> `deploy/openresty-all-kefu.conf.template`(反代模板)、`scripts/{bootstrap_platform,import_legacy,archive}.py`。 +> 本文是 **runbook 工件**(Phase 4 交付),不是已执行实录——首次实际部署到阿里云前, +> 按用户全局部署 SOP(`~/.claude/memory/general-deployment-sop.md` + 全局 CLAUDE.md 规程) +> 与用户确认环境与流程后再动手。 +> +> 手册继承 jc 项目 `docs/deploy-production.md` 的全部教训类条目(§7 踩坑实录逐条标注来源), +> 部署步骤按本项目实况重写:**单容器多租户**(jc 是每项目一容器),反代从"一站一容器" +> 变为"多域名同 upstream"。 + +## 0. 线上环境总览(目标形态) + +| 项 | 值 | +|---|---| +| 目标服务器 | 阿里云 ECS `47.254.176.10`(Debian + 1Panel,Docker 29.x 满足要求) | +| 应用容器 | `all-kefu-app-1`(宿主端口 `8018:8018`,内存限额 768m,`--workers 1` 硬约束) | +| 租户域名 | `chat.agent.hnxinyouhe.com`(auto)/ `chat2.agent.hnxinyouhe.com`(jiancai)——1Panel 各建一站,全部反代 → `127.0.0.1:8018` | +| 管理后台 | 形态 A(独立管理域名)或形态 B(租户域名下 `/admin` 路径分流),见 §3.3 | +| 代码位置 | 建议 `/opt/all-kefu`(compose 已钉项目名 `all-kefu`,目录名不影响项目名,与 1Panel 站点目录彻底解耦) | +| git 仓库 | ``(**占位——部署时替换**;本仓编写时点未配置 remote) | +| 持久化 | `./data`(platform.db + uploads + archives)、`./tenants`(每租户 `.db`)两个 bind mount,容器可随手重建 | + +**同服务器共存的老项目**(切换期绝对不要删,回滚全靠它们):jc 项目容器 +(`chat2.agent.hnxinyouhe.com`,端口 8017)、汽车客服容器(`chat.agent.hnxinyouhe.com`, +端口 8016)。切换 runbook 见 §5;观察期结束前老容器**保留不删**。 + +## 1. 环境与前置 + +### 1.1 服务器要求 + +- **Docker ≥ 24,Docker Compose ≥ 2.24**。Compose 下限是硬要求:本项目 + `docker-compose.yml` 的 env_file 用对象式写法(`path: .env` + `required: false`), + 2.24 以下直接语法报错。验证:`docker compose version`。 +- 服务器**无需 Python / pip / node**——运行时全在镜像内(python:3.13-slim,admin 前端 + 在构建阶段一由 node:20-alpine 编译,服务器零 node)。`pip install -r requirements.txt` + 只是**本地开发**的动作;本地必须装(含 `python-multipart>=0.0.20`——Phase 2 实证缺失 + 它头像上传直接 500,FastAPI multipart 解析依赖)。 +- 磁盘:日志已封顶(json-file 10m×3),主要增长项是 `tenants/*.db` 与 + `lead_push_logs.request_payload`(只涨不掉,超 12 个月走归档 §6.3)。 + +### 1.2 代码获取 + +```bash +git clone /opt/all-kefu # 部署时替换为真实仓库地址 +cd /opt/all-kefu +``` + +### 1.3 生产 .env 逐项说明 + +放在仓库根 `./.env`(compose 自动注入容器;`chmod 600 .env`)。缺省也能起 +(出厂默认 + 启动日志 WARNING),但生产**必须**提供下面标"必设"的项: + +```ini +# ---- 超管引导(bootstrap_platform.py 与应用 lifespan 同源消费)---- +ADMIN_USERNAME=admin # 可改;默认 admin +ADMIN_PASSWORD=<强密码> # 必设!默认 admin123——启动检测到默认值会打 + # WARNING 日志,且超管首登被强制改密(409 门) + +# ---- AI(平台默认层;租户凭据空槽回退到这里)---- +AI_PROVIDER=glm +ZHIPU_API_KEY=<智谱 Key> # 必设(聊天引擎无 Key 不可用) +ZHIPU_BASE_URL= # 留空=SDK 默认地址;coding 套餐填 + # https://open.bigmodel.cn/api/coding/paas/v4 +AI_MODEL=glm-4-flash + +# ---- 邮件(平台默认层;租户 config.email 空槽逐键回退)---- +SMTP_HOST=<发件主机> +SMTP_PORT=465 +SMTP_USER=<发件账号> +SMTP_PASS=<发件密码> +NOTIFY_EMAIL=<兜底询盘通知收件箱> # 租户未配 notify_emails 时的回退 + +# ---- 询盘推送 UniService(平台默认层;租户 config.push 空槽回退)---- +PUSH_ENABLED=false +PUSH_API_URL=https://uniservice.server.hnxinyouhe.com/open/v1/inquiry/push +PUSH_API_KEY=<开启时用> +PUSH_EXTERNAL_KEY_PREFIX=ai-kefu-jc # 平台兜底前缀;两租户已各自配 + # ai-kefu(auto)/ ai-kefu-jc(jiancai), + # 与老项目线索键隔离,勿改! +# ⚠ 推送探测 env 回退(已知项,CLAUDE.md Phase 4 清理单 #2):租户 push URL +# 空槽时回退 env PUSH_API_URL(默认即真 UniService 生产址)——后台凭据页点 +# 「探测」会对真实服务发空批量 POST({"messages": []},按契约应为无操作, +# 但属真实出站)。介意则把 env PUSH_API_URL 设为 stub 域名再探测。 + +# ---- 租户源 ---- +TENANT_SOURCE=db # 默认即 db(生产装配:platform.db 注册表); + # file = 降级读 seed_profiles 目录(冷备/本地 + # 调试形态,线上非应急勿用) + +# ---- 说明 ---- +# APP_HOST/APP_PORT 在容器内不生效(Dockerfile CMD 已钉 0.0.0.0:8018); +# APP_DEBUG 当前无消费方(摆设,写了也不起作用);TZ=Asia/Shanghai 由 +# compose environment 固定注入,勿动。 +``` + +`.env` 与 `data/`、`tenants/` 都不在 git 里(`.gitignore` 覆盖),发版更新不受影响。 + +## 2. 首次部署流程 + +```bash +cd /opt/all-kefu + +# 1. 镜像(两阶段构建:node 编译 admin → slim 只带运行时;首次约数分钟) +docker compose build + +# 2. 平台引导:建 platform.db schema + 超管账号 + 导入出厂租户(auto + jiancai) +# 幂等可复跑:超管空表才建、已存在 slug 的租户跳过 +docker compose run --rm app python scripts/bootstrap_platform.py + +# 3. 起容器 +docker compose up -d + +# 4. 健康检查(分级 JSON,解读见下) +curl -s http://127.0.0.1:8018/health +``` + +bootstrap 输出应含:`platform.db: /app/data/platform.db`、超管用户名、 +`auto: 导入(...)`、`jiancai: 导入(...)`(复跑则显示"跳过(tenants 已存在)")。 +platform.db 落在 **data/ bind mount 内**(宿主视角 `/opt/all-kefu/data/platform.db`), +容器重建不丢——这是 P4T1 审查修复项,compose 已落实,勿把库挪回仓库根。 + +`/health` 分级解读: + +```json +{ + "status": "ok", // 任一已建租户库探活失败 → "degraded"(仍 200, + // 单租户库损坏不杀容器、不影响其余租户) + "tenants": [ + {"slug": "auto", "status": "active", "engine_ok": null}, + {"slug": "jiancai", "status": "active", "engine_ok": true} + ], + "domains": 3 // 域名绑定总数:auto 1 + jiancai 2 + // (chat2.agent.hnxinyouhe.com + localhost.jc,出厂档案如此) +} +``` + +- `engine_ok: true` = 探活通过;`false` = 该租户库损坏(排查该租户的 db); + `null` = engine 懒建未就绪——**健康检查不触发建库**(设计如此),该租户 + 来过一次请求后才会变 true,刚启动全是 null 属正常。 +- `domains` 是导入完整性的人工核对口径:bootstrap 后应为 **3**(含 localhost.jc)。 + +浏览器打开 `https:///admin/`(反代未配时先见 §3;也可临时 +`ssh -L 8018:127.0.0.1:8018` 隧道 + `http://127.0.0.1:8018/admin/`),用 +`.env` 的超管账号首登——**强制改密**(`must_change_password` 409 门,改完才进)。 +租户管理员账号不在 bootstrap 里建:后台"租户管理 → 创建向导/详情"按需补。 + +## 3. 反代配置(1Panel) + +模板:`deploy/openresty-all-kefu.conf.template`(完整可抄 + 1Panel 适配注记)。 +要点速览: + +### 3.1 租户域名 → 127.0.0.1:8018 + +每租户域名一个 1Panel 站点(chat. / chat2. 各一站),upstream 全指向 +`127.0.0.1:8018`——**多域名同容器**,这是与 jc 时代"一站一容器"的最大差异。 +站点片段放 `/opt/1panel/www/sites/<域名>/proxy/{root,ws}.conf`。 + +### 3.2 必带的三件事(每站) + +1. **`proxy_set_header Host $host`**——本项目按 Host 头解析租户,漏了全站静默 404。 +2. **WS 升级头单套**(`location /ws/`:Upgrade/Connection + http 1.1 + 3600s)—— + 1Panel 面板生成的反代若自带一套 Upgrade/Connection,删面板那套,重复两套 = + uvicorn 拒绝握手(HTTP 正常、WS 426,jc 线上事故)。 +3. **`client_max_body_size 5m`**——头像上传上限 2MB,nginx 默认 1m 直接 413。 + +另外两条不改配置只做核对:`/static/widget.js` 响应应带 `Cache-Control: no-cache` +(应用层中间件已下发,反代别加会覆盖它的 expires);**`/api/public/*` 放行** +(widget 头像/品牌端点,按租户 Host 解析——全站反代天然覆盖,按路径收紧配置时 +漏掉它的症状是 widget 头像 404)。 + +### 3.3 管理后台两形态(二选一) + +- **形态 A:独立管理域名**。1Panel 为 ``(部署前由需求方确定)建站, + 整域反代 8018。`/admin`(SPA)与 `/api/platform`(后台 API)同容器同 upstream; + 入口是 `https:///admin/`——管理域根路径 `/` 走租户 Host 解析, + 404 属预期(可在 1Panel 站点配个跳转)。 + 已知边界:管理域下 `/api/public/*` 必 404(该前缀按租户 Host 解析,管理域是 + 未知 Host)——后台"品牌-头像预览"加载不出,**已知项不是故障**(清理单 #3), + 预览以页内本地简化预览为准。 +- **形态 B:路径分流**。不建独立域名,在(任一)租户域名站点下加 + `location /admin` 与 `location /api/platform/` 两块指向 8018——应用层对这两个 + 前缀豁免租户 Host 解析,挂在租户域名下照常工作,同源零 CORS。已知边界:头像 + 预览走本域 `/api/public/avatar`,解析到**本域名对应的租户**,跨租户切换配置页 + 时预览不准(清理单 #3/#4)。 + +### 3.4 主 conf include 行检查(1Panel 坑) + +`/opt/1panel/www/conf.d/<域名>.conf` 必须含 +`include /www/sites/<域名>/proxy/*.conf;`——1Panel 建站默认可能不带,面板配了 +反代却不生效九成是这行缺失。手工补前备份同目录 `*.conf.bak.<日期>`;改完 +`nginx -T` 可 dump 最终生效配置对照。 + +### 3.5 验证与重载 + +```bash +# 先 -t 再 reload(reload 平滑,不影响其他站点) +docker exec $(docker ps --filter name=openresty -q | head -1) nginx -t \ + && docker exec $(docker ps --filter name=openresty -q | head -1) nginx -s reload + +# 逐域名验证 +curl -s https://chat2.agent.hnxinyouhe.com/health # {"status":"ok",...} +curl -sI https://chat2.agent.hnxinyouhe.com/static/widget.js | grep -i cache +curl -s -i --http1.1 -H "Connection: Upgrade" -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" \ + https://chat2.agent.hnxinyouhe.com/ws/chat | head -1 # 期待 101 +``` + +## 4. 存量迁移(老 jc / auto 项目 → 本平台) + +**前提**:老项目与本项目同机(47.254.176.10);若跨机,先把导出文件 scp 到 +`/opt/all-kefu/data/` 下(compose 已挂载该目录,容器内路径 `/app/data/`; +**注意宿主 `/tmp` 默认不在容器挂载内**,`docker compose run` 看不到宿主 /tmp 的文件)。 + +### 4.1 老库导出(二选一,禁运行时裸 cp) + +```bash +# 方式一(最稳):停老容器再拷 +docker stop hs-ai-kefu # jc 容器名,以老项目实录为准 +cp /opt/1panel/www/sites/chat2.agent.hnxinyouhe.com/index/data/hesupipe_ai_kefu.db \ + /opt/all-kefu/data/legacy-jc.db + +# 方式二(不停机):SQLite backup API / VACUUM INTO 导一致性快照 +#(老容器内库路径以 jc 实录为准:WORKDIR /app + ./data/hesupipe_ai_kefu.db) +docker exec hs-ai-kefu python -c \ + "import sqlite3; c=sqlite3.connect('/app/data/hesupipe_ai_kefu.db'); \ + c.execute(\"VACUUM INTO '/app/data/export-jc.db'\"); c.close()" +cp /opt/1panel/www/sites/chat2.agent.hnxinyouhe.com/index/data/export-jc.db \ + /opt/all-kefu/data/legacy-jc.db +``` + +**禁止运行时直接 `cp` 活库文件**:SQLite WAL 模式下可能拷到写一半状态。 +停容器或 backup API / VACUUM INTO,没有第三条路。auto 项目同理(库名/容器名 +以汽车项目服务器实录为准)。 + +### 4.2 导入 + 对账 + +```bash +cd /opt/all-kefu +# 租户须已 bootstrap(§2 已导入 auto/jiancai 档案,门禁自会校验 platform.db) +docker compose run --rm app python scripts/import_legacy.py \ + --source /app/data/legacy-jc.db --tenant jiancai +# auto 项目同理: +docker compose run --rm app python scripts/import_legacy.py \ + --source /app/data/legacy-auto.db --tenant auto +``` + +脚本纪律:老库**只读**打开(mode=ro,绝不回写);保 ID/session_id/status/ +page_token 原样(external_key 连续,UniService 不产生重复线索);切换前 +`pending` 推送日志转 `failed` 并追加备注;幂等(INSERT OR IGNORE,幂等键=主键, +重跑零变化)。 + +对账报告(stdout)逐项核对: + +- 每表行数四元组:源/导入/跳过已存在/目标合计,源=目标才算一致; +- **最近 10 条会话抽查**:id/session/status 逐条对照; +- 各子表 `MAX(conversation_id)` 对照(ID 原样性); +- `pending→failed` 转换条数; +- 列差异标注(老库缺列→默认值填充、老库多列→不迁移)——确认无意外项。 + +任一不一致 → 脚本非零退出,**人工核查清楚之前不得切换域名**。 + +## 5. 切换 runbook(独立大节——按序执行,每阶段有出口) + +> 原则:域名最后切。切换 = 只改 1Panel 反代 upstream(老容器端口 → +> 127.0.0.1:8018),DNS 不动、老项目不删。 + +### 阶段一:验证(不切域名) + +新平台已部署(§2)+ 已迁移(§4)+ 反代片段已备好但**未指流量**。全功能验证 +走"隧道 + hosts",本机把租户域名锚到新平台: + +```bash +# 本机(运维机)hosts 加:127.0.0.1 chat.agent.hnxinyouhe.com chat2.agent.hnxinyouhe.com +ssh -L 8018:127.0.0.1:8018 root@47.254.176.10 # 隧道保持 +# 浏览器访问 http://chat2.agent.hnxinyouhe.com:8018/ 即为新平台(Host 命中租户) +``` + +验证清单(逐项过,全绿才进阶段二): + +- [ ] `/health`:status=ok、两租户 active、domains=3 +- [ ] 登录后台 `/admin/`:超管登录、强制改密流、租户管理员登录(后台建号) +- [ ] **两租户 widget 冒烟**:`http://chat<...>:8018/static/widget-test.html` + 邀请卡弹出、面板展开、发一句消息收到 AI 回复(真实 ZHIPU_API_KEY) +- [ ] WS 握手 101(上方 curl 法)+ 面板长连不掉 +- [ ] 配置中心:品牌/词表加载、版本历史(bootstrap 写入的版本 1 可见)、试金石跑一轮 +- [ ] 统计:迁移数据可见(会话数与老平台对得上)、推送健康表渲染 +- [ ] `/api/public/avatar` 200(头像)、`/static/widget.js` 带 no-cache +- [ ] 后台数据视图:翻到迁移导入的最近会话,消息流完整 + +### 阶段二:切反代(逐域名) + +1Panel 逐站点把反代 upstream 从老容器端口改为 `127.0.0.1:8018` +(一个域名一个站点;先切一个租户域名,观察半天再切第二个)。每切一个: +`nginx -t` → reload → 立即线上验证 `/health`、widget 加载、WS 101、一条真实 +留言走通(AI 回复 + 询盘邮件到达)。 + +### 阶段三:观察一周(日检清单) + +- [ ] `/health` 无 degraded(有 = 对应租户库问题,立刻查) +- [ ] 后台"统计 → 推送健康"成功率正常(与切换前老平台口径对比) +- [ ] 询盘邮件正常到达(单邮件通道,SMTP 失败不自动补发,头几天盯收件) +- [ ] **breach_rate 双向偏差观察**(沿用 jc 观察法):脚本在老仓 + `ai-kefu-jc/scripts/breach_rate.py`(**本仓未随迁**——观察期从老仓取用, + 对 `tenants/.db` 直跑;漏放/误杀残差与 jc 期基线对比) +- [ ] 磁盘与日志:`df -h`、`docker stats all-kefu-app-1 --no-stream` + (内存应显著低于 768m 限额)、`docker logs all-kefu-app-1 --tail 100` + +### 阶段四:老项目停用归档(观察期满且无回滚诉求) + +老容器 `docker stop`(**不 rm**)、镜像留存;老库文件归档备份至备份盘; +1Panel 老站点配置保留(回滚素材)。至此切换闭环。 + +### 回滚预案(任一阶段发现不可接受问题) + +反代切回老容器 = 该域名 1Panel 站点 upstream 改回老端口(8016/8017)→ +`nginx -t` → reload,**约 30 秒**。老项目容器/数据在观察期内原样保留,这就是 +不删它们的原因。注意:切换后新平台产生的会话在回滚后会留在新平台库(老平台 +没有)——属预期,如实告知需求方;UniService 线索不会重复(conversation_id +保原样 + external_key 前缀不变)。 + +## 6. 日常运维 + +### 6.1 备份(每日;platform.db 按敏感件对待——内含 SMTP/push/AI 密钥) + +用 SQLite backup API 做一致性快照(WAL 安全),**禁运行时直拷 .db**: + +```bash +cd /opt/all-kefu +docker compose exec -T app python - <<'PY' +import sqlite3, pathlib, datetime +stamp = datetime.date.today().strftime('%Y%m%d') +targets = [pathlib.Path('/app/data/platform.db'), + *sorted(pathlib.Path('/app/tenants').glob('*.db'))] +for p in targets: + dst = f'/app/data/backups/{stamp}-{p.name}' + pathlib.Path(dst).parent.mkdir(parents=True, exist_ok=True) + src, out = sqlite3.connect(p), sqlite3.connect(dst) + src.backup(out); out.close(); src.close(); print(dst) +PY +# 备份落在宿主 data/backups/(同盘!)——必须再 rsync/scp 出服务器才算备份 +``` + +cron 示例:把上面整段 heredoc 存成 `/root/all-kefu-backup.sh`,cron 挂 +`10 4 * * * /root/all-kefu-backup.sh`(快照后再由 rsync/scp 任务拉走,04:30 前后错开)。 +**季度恢复演练**(没验证过的备份等于没有备份):取一份历史备份,起临时容器验 +`PRAGMA integrity_check` + 关键表行数,与线上对得上才归档: + +```bash +docker compose run --rm -v /opt/backups/all-kefu/20260401-platform.db:/tmp/r.db:ro \ + app python -c "import sqlite3; c=sqlite3.connect('/tmp/r.db'); \ +print(c.execute('PRAGMA integrity_check').fetchone()); \ +print(c.execute('SELECT count(*) FROM tenants').fetchone())" +``` + +### 6.2 日志与 PII 现状 + +- 封顶已配:json-file `max-size 10m × max-file 3`(Docker 默认不限大小会涨满磁盘, + compose 已落实)。看日志:`docker logs all-kefu-app-1 --tail 100`。 +- **PII 现状(如实记录)**:spec §9 承诺"不打聊天内容等 PII",但当前**引擎层 + 日志含会话内容摘录**(conversation.py 若干 `logger.info/warning` 带消息片段 + `%r` 截断输出);平台层(app/platform/)零日志、不落凭据。整改方向:观察期内 + 收口(摘录降级为 session_id 索引)——在此之前,日志文件按含 PII 对待, + 备份/外发时注意。 +- 登录限流现状:反代形态下所有限流键同源(反代 IP),退化为按用户名限流 + (fail-safe 只更严不更松);XFF 可信解析归后续批次。 + +### 6.3 数据归档(archive.py) + +`lead_push_logs.request_payload` 存完整对话 JSON 只涨不掉;会话超 12 个月 +走归档(导出 JSON → 删行 → VACUUM 回收): + +```bash +docker compose run --rm app python scripts/archive.py --tenant jiancai --dry-run # 先只列不动 +docker compose run --rm app python scripts/archive.py --tenant jiancai # 实际执行 +``` + +导出落 `data/archives//<时间戳>-365d.json`(先落盘后删库,崩溃级安全); +对账不一致即非零退出。**维护窗口建议**:低峰(凌晨)执行——VACUUM 需独占, +被占用锁时脚本自动跳过并提示稍后手动补跑;导出/删除本身单事务安全。 + +### 6.4 升级发版与回滚 + +```bash +# 本地:全量测试全绿才发版(后端 pytest + 前端 vitest/build),然后 push +git push origin + +# 服务器: +cd /opt/all-kefu && git pull +docker compose build # 静态/前端改动必须 build(打进镜像,仅 up -d 不够) +docker compose up -d +curl -s http://127.0.0.1:8018/health +``` + +**版本 tag 纪律(30 秒回滚的来源)**:发版时在 compose 的 app 服务补/改 +`image: all-kefu:<版本tag>`(与 `build: .` 并存——build 产物自动打上该 tag), +`up -d` 即切新版;**回滚 = image 改回上一 tag 再 up -d**(旧镜像仍在本地, +30 秒内完成)。当前 compose 尚未带 image 字段(首发后落地此纪律)。 + +### 6.5 加租户(零容器操作) + +**加租户 = 后台建租户 + 1Panel 反代加一条,不起容器**: +后台"租户管理 → 创建向导"(消费出厂档案/空档案)→ 站点绑定新域名 → 1Panel +为新域名建站反代 `127.0.0.1:8018`(§3 同款三件事)→ 完成。同一容器自动承载。 + +### 6.6 bootstrap 复跑 + +`docker compose run --rm app python scripts/bootstrap_platform.py` 幂等: +已存在 slug 的租户跳过、超管空表才建——发版后跑一遍无副作用(也可不跑, +lifespan 启动同样保证 schema/超管就绪,两者同一套幂等实现)。 + +## 7. 踩坑实录(排查手册——每条都是真事故/真审查发现) + +| # | 现象 | 根因 | 解法(已内置/纪律) | 来源 | +|---|---|---|---|---| +| 1 | 同机两个客服项目起一个、另一个 502 | 1Panel 站点目录都叫 `index`,compose 默认以目录名为项目名 → 互删容器 | compose 钉死 `name: all-kefu`(根治);且本机只剩这一个 compose 项目 | jc 线上事故 | +| 2 | 改了 widget.js / admin 前端,线上"没生效" | 静态与前端 dist **打进镜像**,`up -d` 不重建镜像 | 发版必须 `docker compose build`;`/static/*` 已由应用层下发 no-cache(用户端 Ctrl+F5 一次即愈) | jc 线上事故 | +| 3 | 1Panel 面板配了反代不生效 | 面板建站主 conf 默认可能不含 `include proxy/*.conf` | 手工补 include 行(§3.4),备份后 `nginx -T` 验证 | jc 线上事故 | +| 4 | WS 握手 426(HTTP 正常) | 反代里 Upgrade/Connection 头重复两套,uvicorn 严格解析拒绝 | 升级头全局只留一套(§3.2);`nginx -T` dump 最终配置定位 | jc 线上事故 | +| 5 | bottom-left 弹出元素溢出屏幕 | widget 弹出层 CSS 硬编码只适配右下角 | 已随 `pos-` 类联动;**新增弹出元素必须同时加联动规则**(静态测试锁死) | jc 线上事故 | +| 6 | mem_limit 为什么是 768m | 单进程(`--workers 1` 硬约束)承载 admin SPA 服务 + 全部租户引擎,较 jc 单租户 512m 上调 | compose 已配;改前先看 `docker stats` 实测 | 本项目 | +| 7 | 容器内时间不对/推送窗口漂移 | 基镜像缺 tzdata 时 TZ 被静默忽略(退 UTC) | compose 固定 `TZ=Asia/Shanghai` + Dockerfile 显式装 tzdata(基镜像漂移保险);仪表盘"今日"边界依赖它 | 本项目 | +| 8 | widget 头像 404 / 后台头像预览空 | `/api/public/*` 按租户 Host 解析:反代按路径收紧时漏配它;admin 独立域名(形态 A)下必 404 | 租户域名站确保全站反代覆盖 `/api/public`;形态 A 预览缺失是已知项(清理单 #3)非故障 | 本项目 | +| 9 | platform.db 随容器重建丢失 | 库曾落仓库根(非卷内) | 已修:PLATFORM_DB 钉 `data/platform.db`(compose bind mount),**勿挪** | 本项目(P4T1 审查) | +| 10 | healthcheck 起不来容器 | slim 镜像无 curl | compose healthcheck 用容器内自带 python urllib 探 /health | 本项目 | +| 11 | admin 页白屏 | dist 挂载在 `/admin` 前缀,资源引用前缀不一致 | `VITE_BASE_URL=/admin/` 钉在 `admin/.env.production`(单一事实源,不靠构建期 env 注入);动它必须重新 build | 本项目 | +| 12 | 反代配好后全站 404(非报错) | 租户路由按 **Host 头**解析,反代漏 `proxy_set_header Host $host` → upstream 收到的是 127.0.0.1 这类未知 Host | 反代三件事之一(§3.2);排障先 curl 直打 8018 带 `-H "Host: <租户域名>"` 对比 | 本项目新增预判 | +| 13 | 头像上传 413 | nginx 默认 `client_max_body_size 1m` < 应用上限 2MB | 反代层放 5m(§3.2) | 本项目新增预判 | + +## 附:与 jc 手册的继承关系 + +- **继承**(教训类,全部进 §7 + §3/§6 相应小节):compose 撞名互删、静态打进 + 镜像、1Panel include 行、WS 426、widget 缓存、bottom-left 联动、备份禁裸拷、 + 日志封顶、WS/HTTP 分离的超时策略。 +- **不继承**(部署形态不同,已按本项目重写):一站一容器 → 单容器多租户多域名; + deploy.sh 一键脚本 → 未提供(首次部署按 §2 手工,稳定后可自行固化); + jc 的 `/admin` 单租户后台 → 本项目 `/admin/` 多租户 SPA + 两反代形态。 +- jc 手册原文:老仓 `D:\refactor\ai-kefu-jc\docs\deploy-production.md`(服务器 + 布局/跳板链路/凭据来源等实况细节需要时回查)。