# 103 用户级灰度发布 Portal 的首个生产动作必须是用户级灰度,不能直接整包替换稳定版本。 ## 运行拓扑 | 组件 | 地址 | 说明 | |---|---|---| | 稳定 Portal | `103:8081` | 105 当前直接回源 `58.38.22.103:8081`;灰度发布不替换该进程 | | 灰度路由器 | `103 127.0.0.1:18082` | 避开已占用的 18080;只通过专用灰度隧道暴露给 105 | | 灰度边缘隧道 | `105 127.0.0.1:19082` | 反向 SSH 到 103 的 18082;不复用已退役的 19081 链路 | | 候选 Portal | `127.0.0.1:18081` | 独立、无源码 runtime;使用被动运行时模式 | | 候选 goosed | `127.0.0.1:18015` | 独立容器并挂载候选 runtime,不重建稳定 9 个容器 | | DeepSeek 兼容代理 | `103 0.0.0.0:18036` | 仅供宿主机和候选容器使用;不接入 105 或公网入口 | 候选 Portal 通过 `MEMIND_PORTAL_H5_ROOT` 共享经审计的持久数据和数据库,但 `MEMIND_RUNTIME_ROLE=candidate` 会禁止重复启动 MindSpace、Plaza、提醒、订阅续期和 Agent 恢复扫描等单例后台任务。 候选 Portal 必须从候选 runtime 目录启动,使相对模块只解析到本次候选 artifact。 候选 goosed 在 Docker 内执行扩展,MCP Node 和入口路径必须固定为 `/usr/local/bin/node` 与 `/opt/portal/mindspace-sandbox-mcp.mjs`;禁止继承稳定 `.env` 中仅宿主机可见的 `/Users/...` 路径。若日志出现 `Failed to add extension` 和宿主机绝对路径的 `MODULE_NOT_FOUND`,应先核对候选 进程环境与 `/opt/portal` 容器挂载,禁止把宿主机文件复制进容器临时修补。 DeepSeek V4 的工具轮次还必须经过候选 runtime 中的 `deepseek-no-think-proxy.mjs`。候选启动脚本必须强制 `MEMIND_DEEPSEEK_DISABLE_THINKING=1`,并把 goosed 自定义 provider 指向 `http://host.docker.internal:18036/v1`。该代理通过独立 LaunchAgent 管理; 发布必须同时验证 `/health` 的兼容契约和候选容器到该地址的连通性。灰度路由器把 该健康地址纳入候选健康闭包,代理掉线时必须自动回落稳定 Portal。 若日志出现 `The reasoning_content in the thinking mode must be passed back to the API.`, 说明 DeepSeek 请求绕过了兼容契约。禁止只在线补环境变量:必须检查 artifact、 LaunchAgent、Portal provider 同步和路由器依赖健康配置是否属于同一 release。 ## 身份和回落规则 - `john`:按数据库中的唯一用户名或用户 ID 匹配。 - 微信用户“唐”:只按绑定微信身份的唯一账号标识 `wx_ul610et8` 匹配;该值在生产库中是唯一 `username/slug`,不是显示昵称。 - 显示昵称永远不参与路由。 - Cookie 会话通过 `h5_login_sessions` 的 token hash 只读解析。 - 微信回调通过 `FromUserName` 和 `h5_user_wechat_identities` 只读解析到绑定账号,再用唯一账号标识匹配。 - 未命中、无身份、过期/撤销会话、数据库异常:走稳定版本。 - 候选 `/api/status` 不健康:自动走稳定版本。 - DeepSeek 兼容代理不健康:自动走稳定版本。 - SSE、附件、公开页和普通 API 均由同一反向代理转发;微信 XML body 必须原样保留。 ## 发布入口 候选必须来自干净且等于 `origin/main` 的完整 `main`,CI 成功,并具有与 `.runtime/portal` 完全一致、未过期且可从线上 base commit 重算选择结果的 Core + Impact Gate report。关键路径展开到预定义影响域;未映射路径或无效基线直接阻断。 ```bash bash scripts/release-portal-canary-prod.sh --yes ``` 该脚本按顺序执行: 1. 验证源码、分支、远端主线、artifact hash;缺少有效报告时自动执行 Core + Impact Gate。 2. 执行 103 只读预检。 3. 上传经过验证的候选包。 4. 创建稳定 live 全量备份、持久目录备份和 105 两份活动 nginx 配置备份,并校验完整性。 5. 启动独立候选 goosed 和 DeepSeek 兼容代理,验证兼容契约及容器连通性。 6. 启动候选 Portal 和灰度路由器。 7. 验证不可变身份配置、稳定/候选/依赖健康和路由器诊断。 8. 启动独立的 `105:19082 -> 103:18082` 灰度隧道并从 105 验证。 9. 短暂阻止新 Agent Run,排空活动任务。 10. 逐文件原子替换、验证并 reload 105 nginx,把活动上游从 `58.38.22.103:8081` 切到 `127.0.0.1:19082`。 11. 主动停止候选,证明目标身份会自动回落稳定版,再恢复候选。 任一步失败都会把 105 nginx 恢复到稳定 `58.38.22.103:8081`,停止灰度隧道和候选服务, 并保留候选目录与备份证据。活动 nginx 配置变更只能由已提交的发布脚本执行,禁止在线手改。 ## 回滚 ```bash bash scripts/rollback-portal-canary-prod.sh --yes ``` 回滚只执行以下动作: - 把 105 两份活动 nginx 上游恢复到稳定 Portal `58.38.22.103:8081` 并验证 reload。 - 停止灰度隧道、灰度路由器、候选 Portal、候选 goosed 和 DeepSeek 兼容代理。 - 清除发布排水标记。 - 验证稳定 Portal 和 105 HTTPS 入口健康。 不会删除候选目录、备份或稳定运行目录。 ## 全量晋升 `scripts/release-portal-runtime-prod.sh` 仍是整包替换脚本,不能把“灰度路由器已实现” 解释为自动获得全量切换权限。全量晋升必须使用同一 commit 和 artifact,保留灰度验收 证据,并再次取得明确批准;在晋升入口完成对应证据校验前,该脚本继续硬阻断非 dry-run。