From 9b4a25799ffa2573af546ec0012b2c2ba94ec8f4 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 26 Jun 2026 15:19:03 +0800 Subject: [PATCH] Add smart ACK provider for WeChat MP replies Replace fixed ackText with a rule-based AckProvider that picks response templates by message type and intent (translate, summary, rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync, zero I/O, auto-falls back to config.ackText on any error. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 5 +- .gitignore | 6 +- DEVELOPMENT_RELEASE_RULES.md | 10 + ENGINEERING_WORKFLOW_RULES.md | 38 + PRODUCTION_RELEASE_RULES.md | 12 + README.md | 94 +- TEST_RELEASE_RULES.md | 9 + admin-bootstrap.mjs | 24 +- admin-routes.mjs | 95 ++ admin-server.mjs | 13 + billing-subscription.mjs | 730 +++++++++ billing-subscription.test.mjs | 213 +++ chat-skills.mjs | 14 + chat-skills.test.mjs | 10 + db.mjs | 166 +++ .../g2.tkmind.cn.redirect.nginx.conf | 38 + deploy/plaza-105/m.tkmind.cn.nginx.conf | 55 + deploy/plaza-105/plaza-105.env.example | 1 + .../plaza-105/wechat.m.tkmind.cn.nginx.conf | 36 + docs/103-105-upgrade-runbook-2026-06-26.md | 215 +++ docs/103-local-drift-report-2026-06-26.md | 244 +++ docs/103-realignment-plan-2026-06-26.md | 96 ++ docs/103-reconciliation-2026-06-26.md | 138 ++ docs/BASELINE.md | 2 +- docs/g2-load-balancing.md | 63 +- docs/gadm-local.md | 150 -- docs/local-dev.md | 29 +- docs/memind-ai-mind-integration-assessment.md | 362 +++++ docs/memindadm-goose-gateway-design.md | 646 ++++++++ docs/no-source-portal-migration.md | 86 ++ docs/openhands-install.md | 171 +++ docs/plaza-local.md | 98 +- docs/release-deploy.md | 346 +---- docs/service-isolation-runbook.md | 95 +- docs/studio-goose-permission-runbook.md | 304 ++++ fetch_news.py | 81 - llm-providers.mjs | 952 +++++++++++- llm-providers.test.mjs | 323 ++++ local_restart.sh | 25 + mindspace-assets.mjs | 141 +- mindspace-assets.test.mjs | 71 +- mindspace-chat-save.mjs | 124 +- mindspace-chat-save.test.mjs | 49 +- mindspace-config.mjs | 96 ++ mindspace-config.test.mjs | 66 + mindspace-content-scan.mjs | 8 + mindspace-og-tags.test.mjs | 10 +- mindspace-pages.mjs | 120 ++ mindspace-publications.mjs | 124 +- mindspace-publications.test.mjs | 60 + mindspace-sandbox-mcp.mjs | 399 ++++- mindspace-sandbox-mcp.test.mjs | 104 ++ mindspace.mjs | 37 +- mindspace.test.mjs | 83 ++ ops/src/App.tsx | 2 - ops/src/api/admin.ts | 116 +- ops/src/components/AdminLayout.tsx | 3 +- ops/src/components/OpsLayout.tsx | 23 +- ops/src/pages/admin/LlmPage.tsx | 354 ----- ops/src/pages/admin/SummaryPage.tsx | 23 + ops/src/pages/admin/WechatPage.tsx | 231 ++- package.json | 13 +- plaza-embed.mjs | 91 +- plaza-embed.test.mjs | 20 + plaza-posts.mjs | 43 +- plaza-posts.test.mjs | 30 + plaza-public.mjs | 139 +- plaza-public.test.mjs | 17 + plaza-recommend.mjs | 23 +- policies.mjs | 41 + policies.test.mjs | 45 +- rsync_to_server.sh | 501 +------ schedule-intent.mjs | 62 + schedule-intent.test.mjs | 53 +- schedule-reminder-worker.mjs | 61 + schedule-reminder-worker.test.mjs | 60 + schedule-service.mjs | 404 +++++ schedule-service.test.mjs | 33 + schema.sql | 163 +- scripts/.releaseignore-prod | 24 + scripts/audit-103-state.sh | 41 + scripts/backfill-plaza-cover-urls.mjs | 70 + scripts/backfill-plaza-public-urls.mjs | 96 ++ scripts/build-portal-runtime.mjs | 28 +- scripts/deploy-plaza-105.sh | 6 +- scripts/dev-core.mjs | 162 ++ scripts/dev-plaza.mjs | 7 +- scripts/dev.mjs | 9 +- scripts/g2-lb.Caddyfile | 45 + scripts/g2-lb.Caddyfile.active | 10 + scripts/g2-lb.Caddyfile.bak-20260616-204004 | 10 + scripts/g2-lb.Caddyfile.bak-20260619-162631 | 22 + scripts/install-g2-lb.sh | 2 +- scripts/install-goosed-monitor.sh | 58 + scripts/install-prod-services.sh | 4 +- scripts/launch-executor.mjs | 120 ++ scripts/memind-fwd-tunnel.sh | 2 +- scripts/memind-portal-tunnel.sh | 14 + scripts/monitor-goosed-fds.mjs | 126 ++ scripts/patch-studio-wechat-aes.py | 105 ++ scripts/regenerate-page-goose-test.mjs | 2 +- scripts/release-portal-runtime-prod.sh | 357 +++++ scripts/release-prod.sh | 299 ++++ scripts/run-memind-portal-prod.sh | 3 +- scripts/run-plaza-prod.sh | 1 + scripts/start-plaza-prod.mjs | 9 +- scripts/sync-to-105.sh | 63 +- scripts/wechat-mp-menu.mjs | 20 +- server.mjs | 577 +++++++- session-reconcile.mjs | 27 +- session-reconcile.test.mjs | 74 +- session-snapshot.mjs | 157 ++ skills-registry.mjs | 2 + skills-registry.test.mjs | 4 + skills/product-campaign-page/SKILL.md | 72 + skills/schedule-assistant/SKILL.md | 48 + skills/static-page-publish/SKILL.md | 2 +- src/App.tsx | 2 + src/api/client.ts | 170 ++- src/components/BalanceRing.tsx | 271 +++- src/components/ChatHeaderMoreMenu.tsx | 27 +- src/components/ChatPanel.tsx | 1 + src/components/ChatView.tsx | 11 +- src/components/MessageList.tsx | 11 +- src/components/MindSpacePageDetail.tsx | 30 +- .../MindSpacePageEditablePreviewFrame.tsx | 2 +- src/components/MindSpacePagePreviewPanel.tsx | 34 + src/components/MindSpaceView.tsx | 391 ++++- src/components/NotificationCenter.tsx | 270 ++++ src/components/PageSavePreviewPanel.tsx | 2 +- src/components/RechargeModal.tsx | 2 +- src/components/SubscribeModal.tsx | 287 ++++ src/context/ChatProvider.tsx | 12 + src/dev/mindspacePreviewData.ts | 2 +- src/hooks/useTKMindChat.ts | 140 +- src/index.css | 777 ++++++++++ src/types.ts | 82 +- src/utils/messageSave.ts | 70 +- src/utils/mindspaceCards.ts | 8 +- src/utils/shareChannels.ts | 10 +- src/utils/userAddress.ts | 6 +- src/utils/wordFilter.ts | 29 + tkmind-proxy-vision.test.mjs | 84 ++ tkmind-proxy.mjs | 271 +++- user-auth.test.mjs | 369 +++++ user-memory-profile.mjs | 170 +++ user-memory-profile.test.mjs | 79 +- user-publish.mjs | 7 +- user-publish.test.mjs | 22 +- user-space.mjs | 8 +- user-space.test.mjs | 25 + wechat-admin.mjs | 212 +++ wechat-admin.test.mjs | 76 + wechat-media.mjs | 156 ++ wechat-mp.mjs | 841 ++++++++++- wechat-mp.test.mjs | 1317 ++++++++++++++++- wechat/ack/ack-builders.mjs | 62 + wechat/ack/ack-intent.mjs | 30 + wechat/ack/ack-provider.mjs | 29 + wechat/ack/ack-provider.test.mjs | 93 ++ wechat/ack/ack-templates.mjs | 33 + word-filter.mjs | 83 ++ 162 files changed, 17276 insertions(+), 2054 deletions(-) create mode 100644 DEVELOPMENT_RELEASE_RULES.md create mode 100644 ENGINEERING_WORKFLOW_RULES.md create mode 100644 PRODUCTION_RELEASE_RULES.md create mode 100644 TEST_RELEASE_RULES.md create mode 100644 billing-subscription.mjs create mode 100644 billing-subscription.test.mjs create mode 100644 deploy/plaza-105/g2.tkmind.cn.redirect.nginx.conf create mode 100644 deploy/plaza-105/m.tkmind.cn.nginx.conf create mode 100644 deploy/plaza-105/wechat.m.tkmind.cn.nginx.conf create mode 100644 docs/103-105-upgrade-runbook-2026-06-26.md create mode 100644 docs/103-local-drift-report-2026-06-26.md create mode 100644 docs/103-realignment-plan-2026-06-26.md create mode 100644 docs/103-reconciliation-2026-06-26.md delete mode 100644 docs/gadm-local.md create mode 100644 docs/memind-ai-mind-integration-assessment.md create mode 100644 docs/memindadm-goose-gateway-design.md create mode 100644 docs/no-source-portal-migration.md create mode 100644 docs/openhands-install.md create mode 100644 docs/studio-goose-permission-runbook.md delete mode 100644 fetch_news.py create mode 100755 local_restart.sh create mode 100644 mindspace-config.mjs create mode 100644 mindspace-config.test.mjs create mode 100644 mindspace-sandbox-mcp.test.mjs delete mode 100644 ops/src/pages/admin/LlmPage.tsx create mode 100644 plaza-public.test.mjs create mode 100644 schedule-service.test.mjs create mode 100644 scripts/.releaseignore-prod create mode 100755 scripts/audit-103-state.sh create mode 100644 scripts/backfill-plaza-cover-urls.mjs create mode 100644 scripts/backfill-plaza-public-urls.mjs create mode 100644 scripts/dev-core.mjs create mode 100644 scripts/g2-lb.Caddyfile create mode 100644 scripts/g2-lb.Caddyfile.active create mode 100644 scripts/g2-lb.Caddyfile.bak-20260616-204004 create mode 100644 scripts/g2-lb.Caddyfile.bak-20260619-162631 create mode 100755 scripts/install-goosed-monitor.sh create mode 100644 scripts/launch-executor.mjs create mode 100644 scripts/memind-portal-tunnel.sh create mode 100755 scripts/monitor-goosed-fds.mjs create mode 100644 scripts/patch-studio-wechat-aes.py create mode 100755 scripts/release-portal-runtime-prod.sh create mode 100755 scripts/release-prod.sh create mode 100644 session-snapshot.mjs create mode 100644 skills/product-campaign-page/SKILL.md create mode 100644 skills/schedule-assistant/SKILL.md create mode 100644 src/components/NotificationCenter.tsx create mode 100644 src/components/SubscribeModal.tsx create mode 100644 src/utils/wordFilter.ts create mode 100644 tkmind-proxy-vision.test.mjs create mode 100644 wechat-admin.test.mjs create mode 100644 wechat-media.mjs create mode 100644 wechat/ack/ack-builders.mjs create mode 100644 wechat/ack/ack-intent.mjs create mode 100644 wechat/ack/ack-provider.mjs create mode 100644 wechat/ack/ack-provider.test.mjs create mode 100644 wechat/ack/ack-templates.mjs create mode 100644 word-filter.mjs diff --git a/.env.example b/.env.example index ba27658..7203e3b 100644 --- a/.env.example +++ b/.env.example @@ -94,8 +94,8 @@ H5_ACCESS_PASSWORD=change-me # # H5_WECHAT_NOTIFY_URL=https://goo.tkmind.cn/webhooks/wechat-pay/notify # 微信内 JSAPI 支付(服务号网页): -# - 服务号「设置 → 功能设置 → JS接口安全域名」:g2.tkmind.cn -# - 商户平台「产品中心 → 开发配置 → 支付授权目录」:https://g2.tkmind.cn/ +# - 服务号「设置 → 功能设置 → JS接口安全域名」:m.tkmind.cn +# - 商户平台「产品中心 → 开发配置 → 支付授权目录」:https://m.tkmind.cn/ # - 用户须先用同一服务号微信 OAuth 登录(获取 openid)后再充值 # 本地调试验签可临时开启(生产务必关闭): # H5_WECHAT_SKIP_NOTIFY_VERIFY=0 @@ -144,6 +144,7 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # MINDSPACE_UPLOAD_ENABLED=false # MINDSPACE_PUBLISH_ENABLED=true # MINDSPACE_PRIVATE_ENABLED=false +# MINDSPACE_MAX_FILE_BYTES=5242880 # Agent 后台任务(创建/执行空间内 Agent 任务,默认关闭) # MINDSPACE_AGENT_JOBS_ENABLED=true # MINDSPACE_INTERNAL_AGENT_SECRET=local-dev-secret diff --git a/.gitignore b/.gitignore index cc07efc..4ac0320 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ ops/dist/ .env .env.local .env.*.local +scripts/g2-aliyun-cert.env *.log *.pid @@ -19,8 +20,3 @@ users/ .idea/ .vscode/ .aider* - -scripts/g2-aliyun-cert.env -scripts/__pycache__/ -*.bak-* -temp/ diff --git a/DEVELOPMENT_RELEASE_RULES.md b/DEVELOPMENT_RELEASE_RULES.md new file mode 100644 index 0000000..14d9f21 --- /dev/null +++ b/DEVELOPMENT_RELEASE_RULES.md @@ -0,0 +1,10 @@ +# 开发环境规则 + +1. 本仓库是 **本机 Mac 开发环境**,默认职责是开发、联调、测试、预演,不是生产运行目录。 +2. 每次开发都必须形成本地 Git 提交;至少在切换任务、交接、发布前,不能只停留在未提交工作区。 +3. 本机允许跑 `dev`、测试、构建、dry-run,但不允许从本机直接 `rsync` 到 `103` 或 `105`。 +4. 任何准备上线的改动都要先形成“可复现产物”,发布来源必须是可追溯 commit,而不是临时工作区状态。 +5. 本地验证至少包含受影响模块的最小测试、必要的 `npm run build` 或接口自测。 +6. 本地需要保留发布清单:当前分支、HEAD、关键变更、是否含未提交改动。 +7. 涉及 `.env`、`MindSpace/`、`data/`、`users/` 的内容都属于运行态资产,不进入发布包。 +8. 紧急修复也不能绕过上述约束;先在本地固化 commit,再进入发布流程。 diff --git a/ENGINEERING_WORKFLOW_RULES.md b/ENGINEERING_WORKFLOW_RULES.md new file mode 100644 index 0000000..f01a0d2 --- /dev/null +++ b/ENGINEERING_WORKFLOW_RULES.md @@ -0,0 +1,38 @@ +# 标准化开发测试发布约束 + +## 1. 仓库定位 + +1. 本仓库是 **本机 Mac 开发仓库**,不是生产目录。 +2. `103` 是正式运行目标,`105` 不是源码发布目标,不允许再把它当作可直接同步的开发延伸。 + +## 2. 开发约束 + +1. 每次开发都必须形成本地 Git commit。 +2. 一个 commit 只解决一类问题,避免把功能、环境、运维脚本混成一团。 +3. 不允许长期堆积“只有自己知道用途”的未提交改动。 +4. `.env`、账号、密钥、运行态数据不进入 Git。 + +## 3. 测试约束 + +1. 任何会发布的改动,至少要有最小可复现验证。 +2. 只看首页 200 不算通过,必须验证本次功能真实路径。 +3. 涉及数据结构变化时,必须写清楚兼容方式、回滚方式、补数据方式。 + +## 4. 发布约束 + +1. 本机不允许直接 `rsync` 到 `103` 或 `105`。 +2. Portal 生产与测试统一走“本机构建 runtime artifact -> 打包发布”,**禁止**在 `103` 解源码包后 `npm install` / `npm run build`。 +3. Portal 唯一合法生产入口是 `bash scripts/release-portal-runtime-prod.sh`。 +4. 发布来源必须是可追溯 commit,不允许从不明工作区直接出包。 +5. 发布前必须有备份,发布后必须有健康检查和业务验收。 + +## 5. 文档约束 + +1. 端口、主机、运行目录、发布入口发生变化时,文档必须同次更新。 +2. 如果一个事故已经查明根因,要把根因和排查入口写进仓库文档,而不是只留在聊天记录里。 + +## 6. 建议长期执行的附加规范 + +1. 发布前要求工作区可读:`git status` 不能混入无关改动。 +2. 为每次正式发布保留 manifest、备份包路径、验证结果。 +3. 涉及数据库、计费、用户空间、鉴权的改动,要额外保留一份业务验收清单。 diff --git a/PRODUCTION_RELEASE_RULES.md b/PRODUCTION_RELEASE_RULES.md new file mode 100644 index 0000000..6d4621d --- /dev/null +++ b/PRODUCTION_RELEASE_RULES.md @@ -0,0 +1,12 @@ +# 生产发布规则 + +1. `103` 是正式生产主机,`105` 是云侧入口/历史链路;**本机一律不允许直接 `rsync` 到 `103` 或 `105`**,也不允许在线改源码后继续运行。 +2. **Portal 生产必须是无源码 runtime 模式**:构建只发生在本机 Mac,产物是 `.runtime/portal/`;`103` 只接收 runtime artifact、继承持久目录、启动服务,**禁止**在 `103` 上 `npm install`、`npm run build` 或保留可运行源码树。 +3. Portal 生产发布唯一合法路径是:本地已提交代码 -> 本机 `node scripts/build-portal-runtime.mjs` -> `bash scripts/release-portal-runtime-prod.sh` -> 上传 `103` -> 全量备份 + 持久目录备份 -> 原子切换 live 目录 -> 重启 Portal -> 健康检查。 +4. `scripts/release-prod.sh`(源码包发布)已停用,不得再用于 Portal;`rsync_to_server.sh` 与任何面向 `105` 的直接同步脚本也只保留为禁用提示。 +5. 发布包不得携带运行态资产;`.env`、`MindSpace/`、`data/`、`users/`、`.tailscale/`、`public/plaza-covers/`、`logs/` 只能从线上现有 live 目录继承。 +6. runtime artifact 必须包含 `server.mjs` 与 `mindspace-sandbox-mcp.mjs`(`sandbox-fs` 扩展依赖的独立子进程入口)。 +7. 每次生产发布前必须先做全量备份;发布失败必须自动回滚到切换前的 live 目录。 +8. 发布清单必须记录:本地 commit、分支、发布时间、发布编号、是否含额外手工环境变更。 +9. Portal 生产验证至少包含 `http://127.0.0.1:8081/api/status` 的 200 健康检查,并补充本次功能对应的业务路径验收;Plaza 仍按各自发布流程单独验收。 +10. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。 diff --git a/README.md b/README.md index 31dbc07..eeb689b 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,21 @@ -> **生产环境警示:当前目录 `/Users/john/Project/Memind` 为生产目录与生产环境,禁止重启服务;操作前先阅读 [docs/service-isolation-runbook.md](docs/service-isolation-runbook.md)。** +> **本仓库说明:这是 `test-memind` 本地开发仓库,默认用于本机联调与验收。生产 / 测试 / 预览隔离规则单独看 [docs/service-isolation-runbook.md](docs/service-isolation-runbook.md),不要把生产同步流程混进本地开发。** +> +> **重要:** `pnpm dev` 只启动本仓库自己的服务,不再联动 Plaza。需要全栈联动时请显式使用 `pnpm dev:all`。 +> +> **流程硬约束:** 本机开发每次都要形成 Git commit;本机不允许直接 `rsync` 到 `103` 或 `105`,只能从本地 commit 打包发布。 # Memind (TKMind H5) -Memind 主应用、Plaza 发现广场与管理后台。当前生产入口统一是 `105 -> Studio` 反代链路,详细流程分别见下面三份 runbook。 +Memind 主应用与 Plaza 发现广场。**当前以本机 Mac 为主服务**,105 服务器已停用。 > **开发基线:** `v0.1.0-baseline`(2026-06-16)— 后续开发请从此 tag 创建分支,详见 [docs/BASELINE.md](docs/BASELINE.md)。 -## 统一拓扑 - -```mermaid -flowchart TB - User[用户 / 浏览器 / 微信] --> N105[105 公网入口] - - N105 --> G2[g2.tkmind.cn] - N105 --> Plaza[plaza.tkmind.cn] - N105 --> Gadm[gadm.tkmind.cn] - - G2 --> StudioG2[Studio 生产机
Memind H5 :8081 / Plaza :3001] - Plaza --> StudioPlaza[Studio 生产机
Plaza :3001] - Gadm --> StudioAdm[Studio 生产机
memind_adm :8082] - - StudioG2 --> Portal[Portal / API] - StudioPlaza --> Portal - StudioAdm --> AdminAPI[admin-server.mjs] -``` - -## 入口总览 - -| 入口 | 说明 | 文档 | -|------|------|------| -| `g2.tkmind.cn` | 主应用 H5,105 入口转发到 Studio | [docs/release-deploy.md](docs/release-deploy.md) | -| `plaza.tkmind.cn` | Plaza 前台,105 入口转发到 Studio | [docs/plaza-local.md](docs/plaza-local.md) | -| `gadm.tkmind.cn` | `memind_adm` 后台,105 入口转发到 Studio | [docs/gadm-local.md](docs/gadm-local.md) | - ## 快速开始 ```bash pnpm install cp .env.example .env # 按需填写 -pnpm dev # MindSpace + Plaza + Ops +pnpm dev # MindSpace + API / Portal + Ops ``` | 服务 | 地址 | @@ -49,17 +26,19 @@ pnpm dev # MindSpace + Plaza + Ops | API / Portal | http://127.0.0.1:8081 | | memind_adm | http://127.0.0.1:8082 | -详见 [docs/local-dev.md](docs/local-dev.md)。生产机器上预览前先看 [生产 / 测试 / 预览隔离规程](docs/service-isolation-runbook.md),不要占用 `8081`。 +`pnpm dev` 现在只启动本仓库自己的服务,Plaza 已单独剥离为 `pnpm dev:plaza` / `pnpm start:plaza`。如果你确实要一次性拉起旧式全家桶,请用 `pnpm dev:all`。 + +详见 [docs/local-dev.md](docs/local-dev.md)。如果你在本机联调 Plaza,请优先看本地开发文档里 `PLAZA_APP_DIR` 的默认路径;生产 / 测试 / 预览隔离另看 [生产 / 测试 / 预览隔离规程](docs/service-isolation-runbook.md)。 仅 Plaza: ```bash -pnpm start:plaza # 本机正式模式(105 反代回源) +pnpm start:plaza # 本机正式模式(Tunnel 回源) pnpm dev:plaza # 开发热更新 pnpm open:plaza # 浏览器打开 https://plaza.tkmind.cn/plaza ``` -## Plaza 入口 +## Plaza 本机为主 完整说明见 **[docs/plaza-local.md](docs/plaza-local.md)**,摘要如下: @@ -72,59 +51,46 @@ pnpm open:plaza # 浏览器打开 https://plaza.tkmind.cn/plaza | 开发调试 | `pnpm dev:plaza` | | 诊断 | `pnpm check:plaza` | -公网 `https://plaza.tkmind.cn` 经 105 反代回源 Studio 本机 `:3001`,需保持 `pnpm start:plaza` 运行。 - -## gadm / memind_adm - -管理后台和运维接口说明见 **[docs/gadm-local.md](docs/gadm-local.md)**。 - -| 服务 | 地址 | -|------|------| -| memind_adm | http://127.0.0.1:8082 | -| ops 审核后台 | http://127.0.0.1:3002/ops/ | +公网 `https://plaza.tkmind.cn` 经 Cloudflare Tunnel 回源本机 `:3001`,需保持 `pnpm start:plaza` 运行。 ## 常用脚本 | 命令 | 说明 | |------|------| -| `pnpm dev` | 全栈开发 | +| `pnpm dev` | 本仓库开发(不含 Plaza) | +| `pnpm dev:all` | 兼容入口:全栈开发(含 Plaza),仅在需要联动 Plaza 时使用 | | `pnpm open:local-test` | 浏览器打开 H5 | | `pnpm start:plaza` | Plaza 正式模式 + Portal | | `pnpm dev:plaza` | Plaza 开发模式 + Portal | | `pnpm open:plaza` | 浏览器强制本地解析 | | `pnpm check:plaza` | 本地 Plaza 诊断 | | `pnpm test` | 单元测试 | -| `bash scripts/deploy-to-test-host.sh --dry-run` | 预览并检查到 `192.168.1.9` 的同步差异 | -| `bash scripts/deploy-to-test-host.sh` | 同步 `Memind / memind_adm / Plaza` 到测试机 | +| `bash scripts/release-portal-runtime-prod.sh --dry-run` | 预演 103 Portal runtime 发布包,不做实际切换 | +| `bash scripts/release-portal-runtime-prod.sh` | 本机构建 runtime artifact 并按正式流程发布 Portal 到 103 | -## 通过 rsync 发布到测试机(推荐) - -目标机路径(你给的基线): - -- `/Users/john/PycharmProjects/test/test-memind` -- `/Users/john/PycharmProjects/test/test-memindadm` -- `/Users/john/PycharmProjects/test/test-memindplaza` - -执行: +## 发布规则入口 ```bash -cd /Users/john/Project/Memind -bash scripts/deploy-to-test-host.sh --dry-run -bash scripts/deploy-to-test-host.sh +cd /Users/john/PycharmProjects/test/test-memind +bash scripts/release-portal-runtime-prod.sh --dry-run +bash scripts/release-portal-runtime-prod.sh ``` 说明: -- 这是“仅源码同步”流程,不会包含 goose 相关服务 -- 默认会同步三个目录(`--only-memind` / `--only-adm` / `--only-plaza` 可单独操作) -- `--skip-delete` 可避免远端删除未在本次排除列表中的文件 -- 同步后仍需按你们现网启动方式重启对应服务 +- 103 Portal 生产必须是**无源码 runtime 模式** +- 103 生产禁止 `rsync` +- `bash scripts/release-prod.sh` 已停用,不得再用于 Portal +- 规则文档见仓库根目录的开发 / 测试 / 生产发布规则,以及 [Portal 无源码迁移说明](docs/no-source-portal-migration.md) ## 文档 - [本地开发(test.*.tkmind.cn)](docs/local-dev.md) - [Memind 小白使用手册](docs/memind-beginner-guide.md) +- [标准化开发测试发布约束](ENGINEERING_WORKFLOW_RULES.md) +- [开发环境规则](DEVELOPMENT_RELEASE_RULES.md) +- [测试发布规则](TEST_RELEASE_RULES.md) +- [生产发布规则](PRODUCTION_RELEASE_RULES.md) - [生产更新发布指南](docs/release-deploy.md) - [生产 / 测试 / 预览隔离规程](docs/service-isolation-runbook.md) -- [Plaza 入口与发布](docs/plaza-local.md) -- [gadm 入口与发布](docs/gadm-local.md) +- [Plaza 本机部署与 Tunnel](docs/plaza-local.md) diff --git a/TEST_RELEASE_RULES.md b/TEST_RELEASE_RULES.md new file mode 100644 index 0000000..fb951b9 --- /dev/null +++ b/TEST_RELEASE_RULES.md @@ -0,0 +1,9 @@ +# 测试发布规则 + +1. 测试环境发布和生产同构,必须走“本地 commit -> 打包产物 -> 上传 -> 远端解包 -> 安装依赖 -> 构建 -> 重启验证”。 +2. 禁止使用 `rsync` 直接覆盖测试目录,避免把本地临时文件、删除操作、脏目录状态带进测试机。 +3. 测试发布不得直接从未提交工作区取代码;至少要能回溯到明确的本地 commit。 +4. 每次测试发布都要保留发布编号、发布清单、远端日志和回滚入口。 +5. 测试环境允许更快迭代,但不允许跳过备份、依赖安装和健康检查。 +6. 只要涉及接口、鉴权、计费、空间、发布链路,就必须补一条对应的业务验收路径,不只看健康检查。 +7. 只有测试环境验证通过的发布流程,才能复用到生产。 diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index 28dc915..e22d7e3 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -12,6 +12,7 @@ // The same factory functions imported here are the ones server.mjs uses, so the // domain logic has a single source of truth; only the wiring differs. import path from 'node:path'; +import { createSubscriptionService } from './billing-subscription.mjs'; import { createDbPool, isDatabaseConfigured } from './db.mjs'; import { createUserAuth } from './user-auth.mjs'; import { createLlmProviderService } from './llm-providers.mjs'; @@ -21,7 +22,7 @@ import { createPlazaOpsService } from './plaza-ops.mjs'; import { createNoopPlazaRedis } from './plaza-redis.mjs'; import { ensureAlgorithmConfig, loadAlgorithmConfig } from './plaza-algorithm.mjs'; import { createWechatAdminService } from './wechat-admin.mjs'; -import { loadWechatMpConfig } from './wechat-mp.mjs'; +import { createWechatMpService, loadWechatMpConfig } from './wechat-mp.mjs'; const noop = () => {}; @@ -77,21 +78,38 @@ export async function createAdminServices(env = {}) { }); // --- platform super-admin services --- + const subscriptionService = createSubscriptionService(pool); const userAuth = createUserAuth(pool, { usersRoot, h5Root, defaultSignupBalanceCents, + subscriptionService, }); if (env.ensureAdminUser !== false) { await userAuth.ensureAdminUser(); } const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret }); + const wechatMpConfig = loadWechatMpConfig(); + const wechatMpService = createWechatMpService({ + config: wechatMpConfig, + userAuth, + apiFetch: async () => { + throw new Error('admin bootstrap does not proxy WeChat chat sessions'); + }, + }); + userAuth.setRechargeNotifier(async ({ userId, title, body }) => { + if (!wechatMpService?.enabled) return; + await wechatMpService.sendTextToUser(userId, `${title}\n${body}`.trim()); + }); const wechatAdmin = createWechatAdminService(pool, { - config: loadWechatMpConfig(), + config: wechatMpConfig, scheduleEnabled: process.env.H5_SCHEDULE_ENABLED === '1', reminderWorkerEnabled: process.env.H5_REMINDER_WORKER_ENABLED === '1', + sendWechatTextToUser: wechatMpService?.enabled + ? (userId, text) => wechatMpService.sendTextToUser(userId, text) + : null, }); - return { pool, userAuth, llmProviderService, plazaPosts, plazaOps, wechatAdmin }; + return { pool, userAuth, llmProviderService, plazaPosts, plazaOps, wechatAdmin, subscriptionService }; } diff --git a/admin-routes.mjs b/admin-routes.mjs index 0c4820c..2999728 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -11,6 +11,7 @@ // createOpsApi -> /api/ops/v1/* plaza operations console (ops_role) import express from 'express'; import { sendData, sendError } from './api-response.mjs'; +import { PLAN_CATALOG, tokensToCallsApprox } from './billing-subscription.mjs'; import { hasOpsRole } from './plaza-ops.mjs'; import { mapPlazaError } from './plaza-posts.mjs'; @@ -42,6 +43,7 @@ export function createAdminApi({ plazaPosts, plazaOps, wechatAdmin, + subscriptionService, }) { function requireAdmin(req, res, next) { if (!req.currentUser || req.currentUser.role !== 'admin') { @@ -272,6 +274,44 @@ export function createAdminApi({ res.json(await wechatAdmin.listDeliveries(req.query)); }); + adminApi.get('/wechat/balance-alerts', requireAdmin, async (req, res) => { + if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); + res.json(await wechatAdmin.listBalanceAlerts(req.query)); + }); + + adminApi.post('/wechat/balance-alerts', requireAdmin, async (req, res) => { + if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); + const result = await wechatAdmin.createBalanceAlert(req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.status(201).json(result); + }); + + adminApi.post('/wechat/balance-alerts/:id/cancel', requireAdmin, async (req, res) => { + if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); + const result = await wechatAdmin.cancelBalanceAlert(req.params.id); + if (!result.ok) return res.status(404).json({ message: result.message ?? '取消失败' }); + res.json(result); + }); + + adminApi.post('/wechat/balance-alerts/:id/resume', requireAdmin, async (req, res) => { + if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); + const result = await wechatAdmin.resumeBalanceAlert(req.params.id); + if (!result.ok) return res.status(404).json({ message: result.message ?? '恢复失败' }); + res.json(result); + }); + + adminApi.get('/wechat/web-notifications', requireAdmin, async (req, res) => { + if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); + res.json(await wechatAdmin.listWebNotifications(req.query)); + }); + + adminApi.post('/wechat/web-notifications', requireAdmin, async (req, res) => { + if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); + const result = await wechatAdmin.createWebNotification(req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.status(201).json(result); + }); + adminApi.post('/wechat/users/:userId/route/clear', requireAdmin, async (req, res) => { if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); const result = await wechatAdmin.clearRouteForUser(req.params.userId); @@ -407,6 +447,61 @@ export function createAdminApi({ } }); + // ── Subscription management ─────────────────────────────────────────────── + + adminApi.get('/subscriptions', requireAdmin, async (req, res) => { + if (!subscriptionService) return res.status(503).json({ message: '订阅服务未启用' }); + const { userId, status, page, pageSize } = req.query; + const result = await subscriptionService.listSubscriptions({ userId, status, page, pageSize }); + res.json(result); + }); + + adminApi.get('/subscriptions/plans', requireAdmin, (_req, res) => { + res.json( + Object.entries(PLAN_CATALOG).map(([planType, def]) => ({ + planType, + ...def, + approxCalls: tokensToCallsApprox(def.periodTokens), + })), + ); + }); + + adminApi.get('/users/:userId/subscription', requireAdmin, async (req, res) => { + if (!subscriptionService) return res.status(503).json({ message: '订阅服务未启用' }); + const sub = await subscriptionService.getActiveSubscription(req.params.userId); + res.json({ subscription: sub }); + }); + + adminApi.post('/users/:userId/subscription', requireAdmin, async (req, res) => { + if (!subscriptionService) return res.status(503).json({ message: '订阅服务未启用' }); + const { planType, durationDays, note } = req.body ?? {}; + if (!planType) return res.status(400).json({ message: '缺少 planType' }); + const result = await subscriptionService.grantSubscription( + req.params.userId, + planType, + durationDays ? Number(durationDays) : undefined, + req.currentUser.id, + note, + ); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.delete('/users/:userId/subscription', requireAdmin, async (req, res) => { + if (!subscriptionService) return res.status(503).json({ message: '订阅服务未启用' }); + const result = await subscriptionService.cancelSubscription( + req.params.userId, + req.currentUser.id, + ); + res.json(result); + }); + + adminApi.post('/subscriptions/expire-stale', requireAdmin, async (_req, res) => { + if (!subscriptionService) return res.status(503).json({ message: '订阅服务未启用' }); + const count = await subscriptionService.expireStaleSubscriptions(); + res.json({ expiredCount: count }); + }); + return adminApi; } diff --git a/admin-server.mjs b/admin-server.mjs index 47284fe..2ac45b4 100644 --- a/admin-server.mjs +++ b/admin-server.mjs @@ -79,6 +79,7 @@ const CONSOLES = { plazaPosts: services.plazaPosts, plazaOps: services.plazaOps, wechatAdmin: services.wechatAdmin, + subscriptionService: services.subscriptionService, }), }, ops: { @@ -173,6 +174,18 @@ async function main() { app.use((req, res) => res.status(404).json({ message: 'not found' })); + if (services.subscriptionService) { + const subExpiryTimer = setInterval(async () => { + try { + const n = await services.subscriptionService.expireStaleSubscriptions(); + if (n > 0) console.log(`Expired ${n} stale subscription(s)`); + } catch (err) { + console.warn('Subscription expiry check failed:', err); + } + }, 60 * 60 * 1000); + subExpiryTimer.unref?.(); + } + app.listen(PORT, HOST, () => { console.log(`memind_adm @ http://${HOST}:${PORT} (public host: ${PUBLIC_HOST})`); for (const { console_, allowedHosts, ipAllowlist, guarded } of summary) { diff --git a/billing-subscription.mjs b/billing-subscription.mjs new file mode 100644 index 0000000..6178c75 --- /dev/null +++ b/billing-subscription.mjs @@ -0,0 +1,730 @@ +import crypto from 'node:crypto'; + +// Hardcoded fallback catalog used when h5_plan_catalog table is not yet seeded. +// priceCents: monthly price in CNY cents (0 = free) +// periodTokens: token allowance per period (0 = unlimited) +// periodImages: image generation quota per period (0 = unlimited) +// modelTier: 'basic' | 'standard' | 'premium' +// overageRate: cost multiplier when quota exhausted (1.0 = no discount) +// periodDays: rolling period length in days +export const PLAN_CATALOG = { + free: { + name: '免费版', + priceCents: 0, + periodTokens: 150_000, + periodImages: 10, + modelTier: 'basic', + overageRate: 1.00, + periodDays: 30, + }, + lite: { + name: '轻量版', + priceCents: 990, + periodTokens: 1_200_000, + periodImages: 50, + modelTier: 'basic', + overageRate: 0.50, + periodDays: 30, + }, + standard: { + name: '标准版', + priceCents: 2900, + periodTokens: 4_500_000, + periodImages: 200, + modelTier: 'standard', + overageRate: 0.70, + periodDays: 30, + }, + pro: { + name: '专业版', + priceCents: 7900, + periodTokens: 0, + periodImages: 0, + modelTier: 'premium', + overageRate: 0.80, + periodDays: 30, + }, +}; + +export function getPlanDef(planType) { + return PLAN_CATALOG[planType] ?? null; +} + +// Tier order: higher index = higher plan. Used for upgrade/downgrade checks. +const PLAN_ORDER = Object.fromEntries( + Object.keys(PLAN_CATALOG).map((key, i) => [key, i]), +); + +// How many "typical calls" a token budget represents (for display purposes). +const AVG_TOKENS_PER_CALL = 3000; +export function tokensToCallsApprox(tokens) { + if (tokens === 0) return null; // unlimited + return Math.floor(tokens / AVG_TOKENS_PER_CALL); +} + +function mapSubRow(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + planType: row.plan_type, + status: row.status, + periodTokensLimit: Number(row.period_tokens_limit ?? 0), + periodTokensUsed: Number(row.period_tokens_used ?? 0), + periodImagesLimit: Number(row.period_images_limit ?? 0), + periodImagesUsed: Number(row.period_images_used ?? 0), + periodStart: Number(row.period_start), + periodEnd: Number(row.period_end), + expiresAt: Number(row.expires_at), + overageRate: Number(row.overage_rate ?? 1.0), + autoRenew: Boolean(row.auto_renew), + operatorId: row.operator_id ?? null, + note: row.note ?? null, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }; +} + +export function createSubscriptionService(pool, { getPlanAsync = null } = {}) { + // Resolve plan definition: prefer DB-backed loader, fall back to hardcoded catalog. + const resolvePlan = async (planType) => { + if (getPlanAsync) { + const p = await getPlanAsync(planType); + if (p) return p; + } + const p = getPlanDef(planType); + return p ? { ...p, periodImages: p.periodImages ?? 0 } : null; + }; + + const getActiveSubscription = async (userId) => { + const now = Date.now(); + const [rows] = await pool.query( + `SELECT * FROM h5_subscriptions + WHERE user_id = ? AND status = 'active' AND expires_at > ? + ORDER BY expires_at DESC LIMIT 1`, + [userId, now], + ); + return mapSubRow(rows[0]); + }; + + // Grant a new subscription (admin or system action). Cancels the current active one. + const grantSubscription = async (userId, planType, durationDays, operatorId = null, note = '') => { + const plan = await resolvePlan(planType); + if (!plan) return { ok: false, message: `未知套餐类型: ${planType}` }; + + const now = Date.now(); + const periodDays = durationDays ?? plan.periodDays; + const periodEnd = now + periodDays * 24 * 60 * 60 * 1000; + + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + + // Cancel any existing active subscription + await conn.query( + `UPDATE h5_subscriptions SET status = 'cancelled', updated_at = ? + WHERE user_id = ? AND status = 'active'`, + [now, userId], + ); + + const id = crypto.randomUUID(); + await conn.query( + `INSERT INTO h5_subscriptions + (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, + period_images_limit, period_images_used, + period_start, period_end, expires_at, overage_rate, auto_renew, + operator_id, note, created_at, updated_at) + VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, + [ + id, userId, planType, plan.periodTokens, + plan.periodImages ?? 0, + now, periodEnd, periodEnd, + Number(plan.overageRate.toFixed(2)), + operatorId, note || null, now, now, + ], + ); + + await conn.query( + `UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`, + [planType, now, userId], + ); + + await conn.commit(); + const [rows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [id]); + return { ok: true, subscription: mapSubRow(rows[0]) }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + }; + + // Called inside an existing transaction (conn already in transaction). + // Deducts deltaTokens from the active subscription quota if available. + // Returns { fullyCovers: bool, overageRate: number } + const consumeQuota = async (userId, deltaTokens, conn) => { + if (!deltaTokens || deltaTokens <= 0) { + return { fullyCovers: true, overageRate: 1.0 }; + } + const now = Date.now(); + const [rows] = await conn.query( + `SELECT * FROM h5_subscriptions + WHERE user_id = ? AND status = 'active' AND expires_at > ? + ORDER BY expires_at DESC LIMIT 1 + FOR UPDATE`, + [userId, now], + ); + const sub = mapSubRow(rows[0]); + if (!sub) return { fullyCovers: false, overageRate: 1.0 }; + + const unlimited = sub.periodTokensLimit === 0; + const remaining = unlimited ? Infinity : sub.periodTokensLimit - sub.periodTokensUsed; + + if (unlimited || remaining >= deltaTokens) { + await conn.query( + `UPDATE h5_subscriptions + SET period_tokens_used = period_tokens_used + ?, updated_at = ? + WHERE id = ?`, + [deltaTokens, now, sub.id], + ); + return { fullyCovers: true, overageRate: sub.overageRate }; + } + + // Partially exhausted — consume remainder, overage applies to whole request for simplicity + if (remaining > 0) { + await conn.query( + `UPDATE h5_subscriptions + SET period_tokens_used = period_tokens_limit, updated_at = ? + WHERE id = ?`, + [now, sub.id], + ); + } + return { fullyCovers: false, overageRate: sub.overageRate }; + }; + + // Renew an existing subscription for another period (same plan). + // Used by a scheduled worker or manual admin action. + const renewSubscription = async (subId, operatorId = null) => { + const [rows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [subId]); + const sub = mapSubRow(rows[0]); + if (!sub) return { ok: false, message: '订阅不存在' }; + + const plan = await resolvePlan(sub.planType); + if (!plan) return { ok: false, message: '套餐定义已失效' }; + + const now = Date.now(); + const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1000; + + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const newId = crypto.randomUUID(); + await conn.query( + `UPDATE h5_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`, + [now, subId], + ); + await conn.query( + `INSERT INTO h5_subscriptions + (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, + period_images_limit, period_images_used, + period_start, period_end, expires_at, overage_rate, auto_renew, + operator_id, note, created_at, updated_at) + VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + newId, sub.userId, sub.planType, plan.periodTokens, + plan.periodImages ?? 0, + now, newPeriodEnd, newPeriodEnd, + Number(plan.overageRate.toFixed(2)), + sub.autoRenew ? 1 : 0, + operatorId, '自动续订', now, now, + ], + ); + await conn.commit(); + const [newRows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [newId]); + return { ok: true, subscription: mapSubRow(newRows[0]) }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + }; + + // Mark expired subscriptions and reset plan_type to 'free'. + // Call periodically from a worker. + const expireStaleSubscriptions = async () => { + const now = Date.now(); + const [result] = await pool.query( + `UPDATE h5_subscriptions SET status = 'expired', updated_at = ? + WHERE status = 'active' AND expires_at <= ?`, + [now, now], + ); + if (result.affectedRows > 0) { + await pool.query( + `UPDATE h5_users u + LEFT JOIN h5_subscriptions s + ON s.user_id = u.id AND s.status = 'active' AND s.expires_at > ? + SET u.plan_type = 'free', u.updated_at = ? + WHERE u.plan_type != 'free' AND s.id IS NULL`, + [now, now], + ); + } + return result.affectedRows; + }; + + const cancelSubscription = async (userId, operatorId = null) => { + const now = Date.now(); + const [result] = await pool.query( + `UPDATE h5_subscriptions SET status = 'cancelled', updated_at = ? + WHERE user_id = ? AND status = 'active'`, + [now, userId], + ); + if (result.affectedRows > 0) { + await pool.query( + `UPDATE h5_users SET plan_type = 'free', updated_at = ? WHERE id = ?`, + [now, userId], + ); + } + return { ok: true, cancelled: result.affectedRows > 0 }; + }; + + const listSubscriptions = async ({ userId = null, status = null, page = 1, pageSize = 20 } = {}) => { + const safePageSize = Math.min(Math.max(Number(pageSize) || 20, 1), 100); + const safePage = Math.max(Number(page) || 1, 1); + const offset = (safePage - 1) * safePageSize; + const params = []; + const clauses = []; + if (userId) { clauses.push('s.user_id = ?'); params.push(userId); } + if (status) { clauses.push('s.status = ?'); params.push(status); } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const [[{ total }]] = await pool.query( + `SELECT COUNT(*) AS total FROM h5_subscriptions s ${where}`, params, + ); + const [rows] = await pool.query( + `SELECT s.*, u.username, u.display_name + FROM h5_subscriptions s JOIN h5_users u ON u.id = s.user_id + ${where} ORDER BY s.created_at DESC LIMIT ${safePageSize} OFFSET ${offset}`, + params, + ); + return { + total: Number(total), + page: safePage, + pageSize: safePageSize, + items: rows.map((r) => ({ + ...mapSubRow(r), + username: r.username, + displayName: r.display_name, + })), + }; + }; + + // User self-service: purchase/upgrade a subscription by deducting from wallet balance. + // Upgrades are always allowed. Downgrades are blocked while an active subscription exists. + // autoRenew: whether to auto-charge and renew when the subscription expires. + const purchaseSubscription = async (userId, planType, autoRenew = false) => { + const plan = await resolvePlan(planType); + if (!plan || plan.priceCents === 0) { + return { ok: false, message: '无效套餐或免费套餐不可购买' }; + } + + const now = Date.now(); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + + // Check current subscription (with row lock) to enforce upgrade-only rule. + const [currentSubRows] = await conn.query( + `SELECT * FROM h5_subscriptions + WHERE user_id = ? AND status = 'active' AND expires_at > ? + ORDER BY expires_at DESC LIMIT 1 FOR UPDATE`, + [userId, now], + ); + const currentSub = mapSubRow(currentSubRows[0]); + if (currentSub) { + const currentOrder = PLAN_ORDER[currentSub.planType] ?? 0; + const newOrder = PLAN_ORDER[planType] ?? 0; + if (newOrder < currentOrder) { + await conn.rollback(); + return { + ok: false, + code: 'DOWNGRADE_NOT_ALLOWED', + message: '当前套餐有效期内不可降级,到期后将自动降为免费版', + currentPlanType: currentSub.planType, + }; + } + } + + const [walletRows] = await conn.query( + `SELECT balance_cents FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`, + [userId], + ); + const balanceCents = Number(walletRows[0]?.balance_cents ?? 0); + + if (balanceCents < plan.priceCents) { + await conn.rollback(); + return { + ok: false, + code: 'INSUFFICIENT_BALANCE', + message: '余额不足,请充值后订阅', + balanceCents, + requiredCents: plan.priceCents, + shortfallCents: plan.priceCents - balanceCents, + }; + } + + await conn.query( + `UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`, + [plan.priceCents, now, userId], + ); + + await conn.query( + `INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at) + VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`, + [userId, plan.priceCents, `subscription:${planType}`, now], + ); + + await conn.query( + `UPDATE h5_subscriptions SET status = 'cancelled', updated_at = ? + WHERE user_id = ? AND status = 'active'`, + [now, userId], + ); + + const id = crypto.randomUUID(); + const periodEnd = now + plan.periodDays * 24 * 60 * 60 * 1000; + await conn.query( + `INSERT INTO h5_subscriptions + (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, + period_images_limit, period_images_used, + period_start, period_end, expires_at, overage_rate, auto_renew, + operator_id, note, created_at, updated_at) + VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, NULL, '用户自助购买', ?, ?)`, + [ + id, userId, planType, plan.periodTokens, + plan.periodImages ?? 0, + now, periodEnd, periodEnd, + Number(plan.overageRate.toFixed(2)), + autoRenew ? 1 : 0, + now, now, + ], + ); + + await conn.query( + `UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`, + [planType, now, userId], + ); + + await conn.commit(); + const [subRows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [id]); + return { + ok: true, + subscription: mapSubRow(subRows[0]), + balanceCents: balanceCents - plan.priceCents, + }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + }; + + // Toggle auto-renew on the user's current active subscription. + const setAutoRenew = async (userId, enabled) => { + const now = Date.now(); + const [result] = await pool.query( + `UPDATE h5_subscriptions SET auto_renew = ?, updated_at = ? + WHERE user_id = ? AND status = 'active' AND expires_at > ?`, + [enabled ? 1 : 0, now, userId, now], + ); + return { ok: true, updated: result.affectedRows > 0 }; + }; + + // Process auto-renewals for subscriptions that just expired with auto_renew = 1. + // Should be called BEFORE expireStaleSubscriptions in the periodic worker. + const processAutoRenewals = async () => { + const now = Date.now(); + // Find active subscriptions that have expired with auto_renew on. + const [rows] = await pool.query( + `SELECT s.*, w.balance_cents + FROM h5_subscriptions s + JOIN h5_user_wallets w ON w.user_id = s.user_id + WHERE s.status = 'active' AND s.auto_renew = 1 AND s.expires_at <= ?`, + [now], + ); + + let renewed = 0; + let failed = 0; + + for (const row of rows) { + const sub = mapSubRow(row); + const plan = getPlanDef(sub.planType); + if (!plan || plan.priceCents === 0) continue; + + const balance = Number(row.balance_cents ?? 0); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + + // Re-lock wallet and verify balance + const [walletRows] = await conn.query( + `SELECT balance_cents FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`, + [sub.userId], + ); + const balanceCents = Number(walletRows[0]?.balance_cents ?? 0); + + if (balanceCents < plan.priceCents) { + // Not enough — let expireStaleSubscriptions handle the reset + await conn.rollback(); + failed++; + console.log(`Auto-renew failed for user ${sub.userId} (${sub.planType}): insufficient balance`); + continue; + } + + // Deduct wallet + await conn.query( + `UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`, + [plan.priceCents, now, sub.userId], + ); + await conn.query( + `INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at) + VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`, + [sub.userId, plan.priceCents, `auto_renew:${sub.planType}`, now], + ); + + // Expire old subscription + await conn.query( + `UPDATE h5_subscriptions SET status = 'expired', auto_renew = 0, updated_at = ? WHERE id = ?`, + [now, sub.id], + ); + + // Create new subscription + const newId = crypto.randomUUID(); + const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1000; + await conn.query( + `INSERT INTO h5_subscriptions + (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, + period_images_limit, period_images_used, + period_start, period_end, expires_at, overage_rate, auto_renew, + operator_id, note, created_at, updated_at) + VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, 1, NULL, '自动续费', ?, ?)`, + [ + newId, sub.userId, sub.planType, plan.periodTokens, + plan.periodImages ?? 0, + now, newPeriodEnd, newPeriodEnd, + Number(plan.overageRate.toFixed(2)), + now, now, + ], + ); + + // Keep user's plan_type current + await conn.query( + `UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`, + [sub.planType, now, sub.userId], + ); + + await conn.commit(); + renewed++; + console.log(`Auto-renewed ${sub.planType} for user ${sub.userId}`); + } catch (err) { + await conn.rollback(); + console.warn(`Auto-renew error for user ${sub.userId}:`, err); + failed++; + } finally { + conn.release(); + } + } + + return { renewed, failed }; + }; + + const consumeImageQuotaTx = async (userId, count, conn) => { + if (!count || count <= 0) return { fullyCovers: true }; + const now = Date.now(); + const [rows] = await conn.query( + `SELECT * FROM h5_subscriptions + WHERE user_id = ? AND status = 'active' AND expires_at > ? + ORDER BY expires_at DESC LIMIT 1 FOR UPDATE`, + [userId, now], + ); + const sub = mapSubRow(rows[0]); + if (!sub) return { fullyCovers: false }; + + const unlimited = sub.periodImagesLimit === 0; + const remaining = unlimited ? Infinity : sub.periodImagesLimit - sub.periodImagesUsed; + + if (unlimited || remaining >= count) { + await conn.query( + `UPDATE h5_subscriptions + SET period_images_used = period_images_used + ?, updated_at = ? + WHERE id = ?`, + [count, now, sub.id], + ); + return { fullyCovers: true }; + } + return { fullyCovers: false }; + }; + + // Deduct image count from the active subscription quota. + // If conn is omitted, this method manages its own short transaction. + const consumeImageQuota = async (userId, count, conn = null) => { + if (conn) return consumeImageQuotaTx(userId, count, conn); + + const ownConn = await pool.getConnection(); + try { + await ownConn.beginTransaction(); + const result = await consumeImageQuotaTx(userId, count, ownConn); + await ownConn.commit(); + return result; + } catch (err) { + await ownConn.rollback(); + throw err; + } finally { + ownConn.release(); + } + }; + + return { + getActiveSubscription, + grantSubscription, + purchaseSubscription, + setAutoRenew, + processAutoRenewals, + consumeQuota, + consumeImageQuota, + renewSubscription, + expireStaleSubscriptions, + cancelSubscription, + listSubscriptions, + }; +} + +// ── Plan Catalog (DB-backed) ───────────────────────────────────────────────── + +// Ensure h5_plan_catalog table exists, add image columns to h5_subscriptions, +// and seed default plans from PLAN_CATALOG if the table is empty. +export async function ensurePlanCatalogSchema(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_plan_catalog ( + plan_type VARCHAR(32) PRIMARY KEY, + name VARCHAR(64) NOT NULL, + price_cents INT NOT NULL DEFAULT 0, + period_days INT NOT NULL DEFAULT 30, + period_tokens BIGINT NOT NULL DEFAULT 0, + period_images INT NOT NULL DEFAULT 0, + model_tier VARCHAR(32) NOT NULL DEFAULT 'basic', + overage_rate DECIMAL(4,2) NOT NULL DEFAULT 1.00, + sort_order INT NOT NULL DEFAULT 0, + is_active TINYINT(1) NOT NULL DEFAULT 1, + description VARCHAR(512) NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + // Idempotently add image quota columns to h5_subscriptions. + for (const col of [ + 'ALTER TABLE h5_subscriptions ADD COLUMN period_images_limit INT NOT NULL DEFAULT 0', + 'ALTER TABLE h5_subscriptions ADD COLUMN period_images_used INT NOT NULL DEFAULT 0', + ]) { + try { await pool.query(col); } catch (_) { /* column already exists */ } + } + + // Seed defaults if catalog is empty. + const [[{ cnt }]] = await pool.query(`SELECT COUNT(*) AS cnt FROM h5_plan_catalog`); + if (Number(cnt) === 0) { + const now = Date.now(); + const entries = Object.entries(PLAN_CATALOG); + for (const [i, [planType, plan]] of entries.entries()) { + await pool.query( + `INSERT IGNORE INTO h5_plan_catalog + (plan_type, name, price_cents, period_days, period_tokens, period_images, + model_tier, overage_rate, sort_order, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, + [ + planType, plan.name, plan.priceCents, plan.periodDays, + plan.periodTokens, plan.periodImages ?? 0, + plan.modelTier, plan.overageRate, i, now, now, + ], + ); + } + } +} + +export function createPlanCatalogService(pool) { + function mapPlanRow(row) { + return { + planType: row.plan_type, + name: row.name, + priceCents: Number(row.price_cents), + periodDays: Number(row.period_days), + periodTokens: Number(row.period_tokens), + periodImages: Number(row.period_images), + modelTier: row.model_tier, + overageRate: Number(row.overage_rate), + sortOrder: Number(row.sort_order), + isActive: Boolean(row.is_active), + description: row.description ?? null, + }; + } + + const listPlans = async ({ includeInactive = true } = {}) => { + const where = includeInactive ? '' : 'WHERE is_active = 1'; + const [rows] = await pool.query( + `SELECT * FROM h5_plan_catalog ${where} ORDER BY sort_order ASC, price_cents ASC`, + ); + return rows.map(mapPlanRow); + }; + + const getPlan = async (planType) => { + const [rows] = await pool.query( + `SELECT * FROM h5_plan_catalog WHERE plan_type = ?`, [planType], + ); + return rows[0] ? mapPlanRow(rows[0]) : null; + }; + + const upsertPlan = async (planType, data) => { + if (!planType || !/^[a-z0-9_]{1,32}$/.test(planType)) { + return { ok: false, message: '套餐标识只能使用小写字母、数字和下划线,最长32位' }; + } + const now = Date.now(); + await pool.query( + `INSERT INTO h5_plan_catalog + (plan_type, name, price_cents, period_days, period_tokens, period_images, + model_tier, overage_rate, sort_order, is_active, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), price_cents = VALUES(price_cents), + period_days = VALUES(period_days), period_tokens = VALUES(period_tokens), + period_images = VALUES(period_images), model_tier = VALUES(model_tier), + overage_rate = VALUES(overage_rate), sort_order = VALUES(sort_order), + is_active = VALUES(is_active), description = VALUES(description), + updated_at = VALUES(updated_at)`, + [ + planType, + String(data.name ?? '').trim() || planType, + Math.max(0, Math.round(Number(data.priceCents ?? 0))), + Math.max(1, Math.round(Number(data.periodDays ?? 30))), + Math.max(0, Math.round(Number(data.periodTokens ?? 0))), + Math.max(0, Math.round(Number(data.periodImages ?? 0))), + ['basic', 'standard', 'premium'].includes(data.modelTier) ? data.modelTier : 'basic', + Math.min(2, Math.max(0, Number((data.overageRate ?? 1.0).toFixed(2)))), + Math.round(Number(data.sortOrder ?? 0)), + data.isActive !== false ? 1 : 0, + data.description ? String(data.description).trim() : null, + now, now, + ], + ); + return { ok: true, plan: await getPlan(planType) }; + }; + + const deletePlan = async (planType) => { + if (planType === 'free') return { ok: false, message: '免费套餐不可删除' }; + const [result] = await pool.query( + `DELETE FROM h5_plan_catalog WHERE plan_type = ?`, [planType], + ); + return { ok: true, deleted: result.affectedRows > 0 }; + }; + + return { listPlans, getPlan, upsertPlan, deletePlan }; +} diff --git a/billing-subscription.test.mjs b/billing-subscription.test.mjs new file mode 100644 index 0000000..326690c --- /dev/null +++ b/billing-subscription.test.mjs @@ -0,0 +1,213 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + PLAN_CATALOG, + createSubscriptionService, + getPlanDef, + tokensToCallsApprox, +} from './billing-subscription.mjs'; + +describe('PLAN_CATALOG', () => { + it('has expected plan types', () => { + assert.ok(PLAN_CATALOG.free); + assert.ok(PLAN_CATALOG.lite); + assert.ok(PLAN_CATALOG.standard); + assert.ok(PLAN_CATALOG.pro); + }); + + it('free plan has positive token limit', () => { + assert.ok(PLAN_CATALOG.free.periodTokens > 0); + assert.equal(PLAN_CATALOG.free.priceCents, 0); + }); + + it('pro plan has unlimited tokens (0)', () => { + assert.equal(PLAN_CATALOG.pro.periodTokens, 0); + }); + + it('all plans have valid overageRate', () => { + for (const [, plan] of Object.entries(PLAN_CATALOG)) { + assert.ok(plan.overageRate > 0 && plan.overageRate <= 1.0, `overageRate out of range: ${plan.overageRate}`); + } + }); +}); + +describe('getPlanDef', () => { + it('returns plan for known type', () => { + assert.deepEqual(getPlanDef('standard'), PLAN_CATALOG.standard); + }); + + it('returns null for unknown type', () => { + assert.equal(getPlanDef('enterprise'), null); + }); +}); + +describe('tokensToCallsApprox', () => { + it('returns null for unlimited (0)', () => { + assert.equal(tokensToCallsApprox(0), null); + }); + + it('calculates approximate calls', () => { + assert.equal(tokensToCallsApprox(150_000), 50); // free ~50 calls + assert.equal(tokensToCallsApprox(1_200_000), 400); // lite ~400 calls + }); +}); + +// Mock pool helper +function makePool(subRow = null) { + const queries = []; + const conn = { + queries: [], + async query(sql, params) { + this.queries.push({ sql, params }); + if (sql.includes('FOR UPDATE') && subRow) { + return [[subRow]]; + } + if (sql.includes('UPDATE h5_subscriptions')) { + return [{ affectedRows: 1 }]; + } + return [[subRow].filter(Boolean)]; + }, + async beginTransaction() {}, + async commit() {}, + async rollback() {}, + release() {}, + }; + + return { + queries, + async query(sql, params) { + queries.push({ sql, params }); + if (sql.includes('SELECT * FROM h5_subscriptions') && subRow) return [[subRow]]; + if (sql.includes('COUNT(*)')) return [[{ total: subRow ? 1 : 0 }]]; + if (sql.includes('UPDATE h5_subscriptions') || sql.includes('UPDATE h5_users')) return [{ affectedRows: 1 }]; + if (sql.includes('INSERT INTO h5_subscriptions')) return [{ insertId: 1 }]; + return [subRow ? [subRow] : []]; + }, + async getConnection() { return conn; }, + _conn: conn, + }; +} + +function makeSubRow(overrides = {}) { + const now = Date.now(); + return { + id: 'sub-1', + user_id: 'user-1', + plan_type: 'standard', + status: 'active', + period_tokens_limit: 4_500_000, + period_tokens_used: 0, + period_images_limit: 50, + period_images_used: 0, + period_start: now, + period_end: now + 30 * 24 * 60 * 60 * 1000, + expires_at: now + 30 * 24 * 60 * 60 * 1000, + overage_rate: 0.70, + operator_id: null, + note: null, + created_at: now, + updated_at: now, + ...overrides, + }; +} + +describe('createSubscriptionService', () => { + describe('getActiveSubscription', () => { + it('returns null when no active subscription', async () => { + const pool = makePool(null); + const svc = createSubscriptionService(pool); + const result = await svc.getActiveSubscription('user-1'); + assert.equal(result, null); + }); + + it('returns mapped subscription when active', async () => { + const pool = makePool(makeSubRow()); + const svc = createSubscriptionService(pool); + const result = await svc.getActiveSubscription('user-1'); + assert.ok(result); + assert.equal(result.planType, 'standard'); + assert.equal(result.periodTokensLimit, 4_500_000); + assert.equal(result.overageRate, 0.70); + }); + }); + + describe('consumeQuota', () => { + it('returns fullyCovers=true for unlimited plan (limit=0)', async () => { + const subRow = makeSubRow({ plan_type: 'pro', period_tokens_limit: 0, period_tokens_used: 0 }); + const pool = makePool(subRow); + const svc = createSubscriptionService(pool); + const coverage = await svc.consumeQuota('user-1', 5000, pool._conn); + assert.equal(coverage.fullyCovers, true); + }); + + it('returns fullyCovers=true when quota has enough tokens', async () => { + const subRow = makeSubRow({ period_tokens_limit: 4_500_000, period_tokens_used: 0 }); + const pool = makePool(subRow); + const svc = createSubscriptionService(pool); + const coverage = await svc.consumeQuota('user-1', 2000, pool._conn); + assert.equal(coverage.fullyCovers, true); + }); + + it('returns fullyCovers=false when quota exhausted', async () => { + const subRow = makeSubRow({ period_tokens_limit: 4_500_000, period_tokens_used: 4_500_000 }); + const pool = makePool(subRow); + const svc = createSubscriptionService(pool); + const coverage = await svc.consumeQuota('user-1', 2000, pool._conn); + assert.equal(coverage.fullyCovers, false); + assert.equal(coverage.overageRate, 0.70); + }); + + it('returns fullyCovers=false and overageRate=1.0 when no subscription', async () => { + const pool = makePool(null); + const svc = createSubscriptionService(pool); + const coverage = await svc.consumeQuota('user-1', 2000, pool._conn); + assert.equal(coverage.fullyCovers, false); + assert.equal(coverage.overageRate, 1.0); + }); + + it('returns fullyCovers=true for zero delta tokens', async () => { + const pool = makePool(null); + const svc = createSubscriptionService(pool); + const coverage = await svc.consumeQuota('user-1', 0, pool._conn); + assert.equal(coverage.fullyCovers, true); + }); + }); + + describe('consumeImageQuota', () => { + it('deducts one image via its own transaction when quota exists', async () => { + const subRow = makeSubRow({ period_images_limit: 50, period_images_used: 0 }); + const pool = makePool(subRow); + const svc = createSubscriptionService(pool); + const coverage = await svc.consumeImageQuota('user-1', 1); + + assert.equal(coverage.fullyCovers, true); + assert.ok( + pool._conn.queries.some( + ({ sql, params }) => + sql.includes('SET period_images_used = period_images_used + ?') && + params?.[0] === 1, + ), + ); + }); + }); + + describe('cancelSubscription', () => { + it('returns cancelled=true when active sub exists', async () => { + const pool = makePool(makeSubRow()); + const svc = createSubscriptionService(pool); + const result = await svc.cancelSubscription('user-1'); + assert.equal(result.ok, true); + assert.equal(result.cancelled, true); + }); + }); + + describe('grantSubscription', () => { + it('returns error for unknown plan type', async () => { + const pool = makePool(null); + const svc = createSubscriptionService(pool); + const result = await svc.grantSubscription('user-1', 'enterprise', 30); + assert.equal(result.ok, false); + assert.match(result.message, /未知套餐/); + }); + }); +}); diff --git a/chat-skills.mjs b/chat-skills.mjs index 8486d0f..d9aab80 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -50,6 +50,14 @@ export const CHAT_SKILL_DEFINITIONS = [ prefillOnly: true, promptKey: 'table-viewer', }, + { + id: 'product-campaign-page', + label: '商品页', + icon: 'page', + skillName: 'product-campaign-page', + requiresSkill: 'product-campaign-page', + promptKey: 'product-campaign-page', + }, { id: 'summarize', label: '总结内容', @@ -84,6 +92,12 @@ export function buildChatSkillPrompt(promptKey, skillName) { return `请使用 ${skillName ?? 'form-builder'} 技能:请用交互式表单收集以下场景所需的结构化字段(字段不超过 8 个):`; case 'table-viewer': return `请使用 ${skillName ?? 'table-viewer'} 技能:请把以下数据整理成可排序、可筛选的交互式表格:`; + case 'product-campaign-page': + return ( + `请使用 ${skillName ?? 'product-campaign-page'} 技能:帮我制作一个商品宣传 / 活动页。` + + '请先询问商品链接、页面主题、是否有商品图片或品牌素材;信息足够后,生成包含购买跳转按钮的页面方案。' + + '如果我确认要发布成 H5 页面,请继续使用 static-page-publish 技能生成可访问链接。' + ); case 'summarize': return '请总结以下内容,提炼核心结论、重点信息和可执行建议(条理清晰、中文输出):'; case 'analyze': diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index 52920bd..103edb4 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -22,6 +22,15 @@ test('filterChatSkills gates platform skills by grantedSkills', () => { assert.ok(visible.some((item) => item.id === 'web-search')); assert.ok(visible.some((item) => item.id === 'code-search')); assert.equal(visible.some((item) => item.id === 'form-collect'), false); + assert.equal(visible.some((item) => item.id === 'product-campaign-page'), false); +}); + +test('filterChatSkills shows product campaign page when granted', () => { + const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, { + canPublish: false, + grantedSkills: ['product-campaign-page'], + }); + assert.ok(visible.some((item) => item.id === 'product-campaign-page')); }); test('filterChatSkills shows generate-page when publish is allowed', () => { @@ -31,6 +40,7 @@ test('filterChatSkills shows generate-page when publish is allowed', () => { test('buildChatSkillPrompt includes skill name for platform skills', () => { assert.match(buildChatSkillPrompt('web', 'web'), /请使用 web 技能/); + assert.match(buildChatSkillPrompt('product-campaign-page'), /商品宣传 \/ 活动页/); assert.match(buildChatSkillPrompt('generate-page'), /static-page-publish/); }); diff --git a/db.mjs b/db.mjs index 811e090..92dedcc 100644 --- a/db.mjs +++ b/db.mjs @@ -175,6 +175,7 @@ export async function migrateSchema(pool) { ['goosed_provider_id', 'VARCHAR(64) NULL AFTER models_json'], ['engine', "VARCHAR(32) NOT NULL DEFAULT 'openai' AFTER goosed_provider_id"], ['relay_provider', 'VARCHAR(64) NULL AFTER engine'], + ['is_vision_selected', 'TINYINT(1) NOT NULL DEFAULT 0 AFTER is_selected'], ]; for (const [column, definition] of llmColumns) { if (!(await columnExists(pool, 'h5_llm_provider_keys', column))) { @@ -182,6 +183,22 @@ export async function migrateSchema(pool) { } } + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_llm_executor_bindings ( + id CHAR(36) PRIMARY KEY, + executor ENUM('goose', 'aider', 'openhands') NOT NULL, + purpose VARCHAR(32) NOT NULL DEFAULT 'default', + provider_key_id CHAR(36) NULL, + model VARCHAR(128) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_llm_executor_binding (executor, purpose), + KEY idx_h5_llm_executor_provider (provider_key_id), + CONSTRAINT fk_h5_llm_executor_provider FOREIGN KEY (provider_key_id) REFERENCES h5_llm_provider_keys(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + const publishColumns = [ ['plaza_view_count', 'BIGINT NOT NULL DEFAULT 0'], ['plaza_like_count', 'BIGINT NOT NULL DEFAULT 0'], @@ -272,6 +289,35 @@ export async function migrateSchema(pool) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_wechat_mp_message_details ( + id CHAR(36) PRIMARY KEY, + app_id VARCHAR(32) NOT NULL, + openid VARCHAR(64) NOT NULL, + user_id CHAR(36) NULL, + msg_id VARCHAR(128) NULL, + msg_type VARCHAR(32) NOT NULL, + display_text TEXT NULL, + agent_text TEXT NULL, + media_id VARCHAR(256) NULL, + media_url TEXT NULL, + media_public_url TEXT NULL, + media_format VARCHAR(64) NULL, + location_lat DECIMAL(10,7) NULL, + location_lng DECIMAL(10,7) NULL, + location_label VARCHAR(255) NULL, + link_url TEXT NULL, + link_title VARCHAR(255) NULL, + raw_xml_hash CHAR(40) NULL, + raw_json JSON NULL, + created_at BIGINT NOT NULL, + KEY idx_wechat_mp_message_details_user (app_id, openid, created_at), + KEY idx_wechat_mp_message_details_msg (msg_id), + KEY idx_wechat_mp_message_details_type (msg_type, created_at), + CONSTRAINT fk_wechat_mp_message_details_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` CREATE TABLE IF NOT EXISTS h5_schedule_items ( id CHAR(36) PRIMARY KEY, @@ -350,6 +396,32 @@ export async function migrateSchema(pool) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_balance_alert_subscriptions ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + threshold_cents BIGINT NOT NULL, + channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat', + status ENUM('active', 'locked', 'failed', 'cancelled') NOT NULL DEFAULT 'active', + next_run_at BIGINT NOT NULL, + last_run_at BIGINT NULL, + last_notified_balance_cents BIGINT NULL, + attempts INT NOT NULL DEFAULT 0, + locked_until BIGINT NULL, + last_error VARCHAR(500) NULL, + source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent', + source_session_id VARCHAR(128) NULL, + source_message_id VARCHAR(128) NULL, + source_text TEXT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_balance_alert_user_channel (user_id, channel), + KEY idx_balance_alert_due (status, next_run_at), + KEY idx_balance_alert_user (user_id, status, next_run_at), + CONSTRAINT fk_balance_alert_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` CREATE TABLE IF NOT EXISTS h5_schedule_delivery_logs ( id CHAR(36) PRIMARY KEY, @@ -371,10 +443,104 @@ export async function migrateSchema(pool) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_user_notifications ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + channel ENUM('web', 'wechat') NOT NULL DEFAULT 'web', + notification_type VARCHAR(64) NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + data_json JSON NULL, + status ENUM('unread', 'read') NOT NULL DEFAULT 'unread', + read_at BIGINT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_user_notifications_user_status_created (user_id, status, created_at), + KEY idx_user_notifications_user_created (user_id, created_at), + CONSTRAINT fk_user_notifications_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query( `ALTER TABLE h5_payment_orders MODIFY pay_mode ENUM('native', 'h5', 'jsapi') NOT NULL DEFAULT 'native'`, ); + + // Session snapshot cache table (additive, no existing-data impact). + // Rollback: DROP TABLE h5_session_snapshots — safe, no dependants. + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_session_snapshots ( + agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY, + user_id CHAR(36) NOT NULL, + name VARCHAR(512) NOT NULL DEFAULT '', + working_dir VARCHAR(1024) NOT NULL DEFAULT '', + created_at_str VARCHAR(64) NOT NULL DEFAULT '', + updated_at_str VARCHAR(64) NOT NULL DEFAULT '', + user_set_name TINYINT(1) NOT NULL DEFAULT 0, + recipe_json TEXT NULL, + synced_msg_count INT NOT NULL DEFAULT 0, + source_updated_at VARCHAR(64) NOT NULL DEFAULT '', + messages_json LONGTEXT NOT NULL, + synced_at BIGINT NOT NULL, + KEY idx_h5_snapshots_user (user_id), + KEY idx_h5_snapshots_synced (synced_at), + CONSTRAINT fk_h5_snapshot_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + // Subscription plan table for tiered monthly billing. + // Rollback: DROP TABLE h5_subscriptions — safe, no dependants. + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_subscriptions ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + plan_type VARCHAR(32) NOT NULL DEFAULT 'free', + status ENUM('active', 'expired', 'cancelled') NOT NULL DEFAULT 'active', + period_tokens_limit BIGINT NOT NULL DEFAULT 0, + period_tokens_used BIGINT NOT NULL DEFAULT 0, + period_start BIGINT NOT NULL, + period_end BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + overage_rate DECIMAL(4,2) NOT NULL DEFAULT 1.00, + operator_id CHAR(36) NULL, + note VARCHAR(512) NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_h5_sub_user_status (user_id, status), + KEY idx_h5_sub_expires (expires_at, status), + CONSTRAINT fk_h5_sub_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + // Back-fill free subscriptions for existing users who pre-date the subscription system. + // Idempotent: only inserts where no active subscription exists. + await pool.query(` + INSERT INTO h5_subscriptions + (id, user_id, plan_type, status, + period_tokens_limit, period_tokens_used, + period_start, period_end, expires_at, overage_rate, + operator_id, note, created_at, updated_at) + SELECT + UUID(), + u.id, + 'free', + 'active', + 150000, 0, + UNIX_TIMESTAMP() * 1000, + (UNIX_TIMESTAMP() + 30 * 86400) * 1000, + (UNIX_TIMESTAMP() + 30 * 86400) * 1000, + 1.00, + NULL, + '系统迁移补建免费套餐', + UNIX_TIMESTAMP() * 1000, + UNIX_TIMESTAMP() * 1000 + FROM h5_users u + WHERE NOT EXISTS ( + SELECT 1 FROM h5_subscriptions s + WHERE s.user_id = u.id AND s.status = 'active' + ) + `); } export async function initSchema(pool) { diff --git a/deploy/plaza-105/g2.tkmind.cn.redirect.nginx.conf b/deploy/plaza-105/g2.tkmind.cn.redirect.nginx.conf new file mode 100644 index 0000000..d7741fe --- /dev/null +++ b/deploy/plaza-105/g2.tkmind.cn.redirect.nginx.conf @@ -0,0 +1,38 @@ +# g2.tkmind.cn → 301 跳转到 m.tkmind.cn(保留微信 webhook 直至后台改完服务器 URL) +# 安装:/etc/nginx/conf.d/g2.tkmind.cn.conf + +server { + listen 80; + listen [::]:80; + server_name g2.tkmind.cn; + location /.well-known/acme-challenge/ { root /var/www/certbot; } + location / { return 301 https://$host$request_uri; } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name g2.tkmind.cn; + + ssl_certificate /etc/letsencrypt/live/g2.tkmind.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/g2.tkmind.cn/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location = /webhooks/wechat-mp/messages { + proxy_pass http://127.0.0.1:19081; + proxy_http_version 1.1; + proxy_set_header Host g2.tkmind.cn; + 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 60; + proxy_send_timeout 60; + add_header Alt-Svc "" always; + proxy_buffering off; + } + + location / { + return 301 https://m.tkmind.cn$request_uri; + } +} diff --git a/deploy/plaza-105/m.tkmind.cn.nginx.conf b/deploy/plaza-105/m.tkmind.cn.nginx.conf new file mode 100644 index 0000000..efaf5d3 --- /dev/null +++ b/deploy/plaza-105/m.tkmind.cn.nginx.conf @@ -0,0 +1,55 @@ +# m.tkmind.cn → 103 Memind Portal (:8081) +# 安装:/etc/nginx/conf.d/m.tkmind.cn.conf +# 替换旧 ai_mind 配置,作为 Memind H5 正式公网入口 + +server { + listen 80; + listen [::]:80; + server_name m.tkmind.cn; + location /.well-known/acme-challenge/ { root /var/www/certbot; } + location / { return 301 https://$host$request_uri; } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name m.tkmind.cn; + + ssl_certificate /etc/letsencrypt/live/m.tkmind.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/m.tkmind.cn/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + client_max_body_size 100M; + + location = /webhooks/wechat-mp/messages { + proxy_pass http://127.0.0.1:19081; + proxy_http_version 1.1; + proxy_set_header Host m.tkmind.cn; + 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 60; + proxy_send_timeout 60; + add_header Alt-Svc "" always; + proxy_buffering off; + } + + location = /MindSpace/a3c16286-055a-442c-89c9-af9949aca23d/public/memind-dep-analysis.html { + return 301 https://m.tkmind.cn/MindSpace/a3c16286-055a-442c-89c9-af9949aca23d/public/memind-deep-analysis.html; + } + + location / { + proxy_pass http://127.0.0.1:19081; + 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_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 86400; + proxy_send_timeout 86400; + proxy_buffering off; + } +} diff --git a/deploy/plaza-105/plaza-105.env.example b/deploy/plaza-105/plaza-105.env.example index fec2584..0d62aca 100644 --- a/deploy/plaza-105/plaza-105.env.example +++ b/deploy/plaza-105/plaza-105.env.example @@ -12,4 +12,5 @@ H5_SYSTEMD_SERVICE=goose-h5 # 本地 Next.js 源码(默认 sibling 仓库) # PLAZA_APP_DIR=/Users/john/Project/tkmind_go/ui/plaza +# 运维/部署直接使用固定公网地址 root@120.26.184.105 # DNS:plaza.tkmind.cn A → 120.26.184.105 diff --git a/deploy/plaza-105/wechat.m.tkmind.cn.nginx.conf b/deploy/plaza-105/wechat.m.tkmind.cn.nginx.conf new file mode 100644 index 0000000..baee759 --- /dev/null +++ b/deploy/plaza-105/wechat.m.tkmind.cn.nginx.conf @@ -0,0 +1,36 @@ +# wechat.m.tkmind.cn → 微信服务号消息推送专用域名 +# 安装:/etc/nginx/conf.d/wechat.m.tkmind.cn.conf + +server { + listen 80; + listen [::]:80; + server_name wechat.m.tkmind.cn; + location /.well-known/acme-challenge/ { root /var/www/certbot; } + location / { return 301 https://$host$request_uri; } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name wechat.m.tkmind.cn; + + ssl_certificate /etc/letsencrypt/live/wechat.m.tkmind.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/wechat.m.tkmind.cn/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location = /webhooks/wechat-mp/messages { + proxy_pass http://127.0.0.1:19081; + proxy_http_version 1.1; + proxy_set_header Host m.tkmind.cn; + 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 60; + proxy_send_timeout 60; + add_header Alt-Svc "" always; + proxy_buffering off; + } + + location / { return 301 https://m.tkmind.cn$request_uri; } +} diff --git a/docs/103-105-upgrade-runbook-2026-06-26.md b/docs/103-105-upgrade-runbook-2026-06-26.md new file mode 100644 index 0000000..53f0bbf --- /dev/null +++ b/docs/103-105-upgrade-runbook-2026-06-26.md @@ -0,0 +1,215 @@ +# 103 / 105 一次性升级实施记录(2026-06-26) + +## 目标 + +本次升级的目标不是继续修补旧工作树,而是一次性把: + +1. 本地三仓库收口为唯一源码真相。 +2. `103` 收口为“只接收发布包”的正式运行机。 +3. `105` 收口为入口 / 代理机,不再承担可写业务数据目录。 + +## 已确认的真实结构 + +### 103 + +- 主机:`john@58.38.22.103` +- 主运行根目录:`/Users/john/Project` +- 当前业务目录: + - `/Users/john/Project/Memind` + - `/Users/john/Project/memind_adm` + - `/Users/john/Project/memind_plaza` +- 当前产物目录骨架已经存在: + - `/Users/john/Project/backups` + - `/Users/john/Project/incoming` + - `/Users/john/Project/releases` + +### 105 + +- 可达 SSH 入口:`root@120.26.184.105` +- 旧文档中的固定内网地址 `100.101.255.32` 当前已视为废弃,不应继续写入脚本或说明 +- 当前 H5 运行目录:`/root/tkmind_go/ui/h5` +- 当前 Plaza 目录:`/root/tkmind_go/ui/plaza` +- 105 上没有 Goose 双实例,只是入口 / portal 层 + +## 关键结论 + +### 1. MindSpace 数据真身在 103,不在 105 + +103 `.env` 明确指向: + +- `H5_USERS_ROOT=/Users/john/Project/Memind/users` +- `MINDSPACE_STORAGE_ROOT=/Users/john/Project/Memind/data/mindspace` +- `MEMIND_SHARED_PUBLISH_ROOT=/Users/john/Project/Memind/MindSpace` + +因此: + +- `103` 是 MindSpace 用户空间、发布页、资产存储的真实落盘位置 +- 105 不是这批数据的主存储 + +### 2. 105 通过 rclone 挂载 103 的共享目录 + +105 现有 systemd mount: + +- `memind-shared-mindspace.mount.service` + - 把 `memind-mac:/Users/john/Project/Memind/MindSpace` 挂到 `/mnt/memind-shared/MindSpace` +- `memind-shared-storage.mount.service` + - 把 `memind-mac:/Users/john/Project/Memind/data/mindspace` 挂到 `/mnt/memind-shared/data/mindspace` + +这意味着: + +- 105 的无缝升级前提是 **103 上这两条真实路径不能变** +- 可以重建代码目录 +- 不能随意移动这两棵持久目录的位置 + +### 3. 本次升级不能把 MindSpace 数据从路径上“迁走” + +如果本次升级把以下路径改掉: + +- `/Users/john/Project/Memind/MindSpace` +- `/Users/john/Project/Memind/data/mindspace` +- `/Users/john/Project/Memind/users` + +那么: + +- 103 本地服务会断引用 +- 105 的 rclone mount 会失效 +- 旧发布页 / 用户空间会出现访问中断 + +因此本次升级的正确策略是: + +1. 保持这三条路径不变 +2. 让新 release 继续继承这些目录 +3. 把“源码目录可手改”废除,而不是把“数据根路径”迁走 + +## 本次备份结果 + +### 103 + +备份目录: + +- `/Users/john/Project/backups/upgrade-20260626-094108` + +包含: + +- `103-Memind-live.tgz` +- `103-Memind-persistent.tgz` +- `103-memind_adm-live.tgz` +- `103-memind_plaza-live.tgz` + +其中最关键的是: + +- `103-Memind-persistent.tgz` + +它单独保存了: + +- `MindSpace` +- `data` +- `users` +- `public/plaza-covers` +- `.env` + +### 105 + +备份目录: + +- `/root/service_backup/tkmind-upgrade-20260626-094109` + +包含: + +- `105-h5-live.tgz` +- `105-plaza-live.tgz` +- `105-h5.env` +- `105-service-config.tgz` + +## 当前数据体量 + +### 103 + +- `/Users/john/Project/Memind/MindSpace`: `95M` +- `/Users/john/Project/Memind/data`: `40M` +- `/Users/john/Project/Memind/users`: `52K` +- `/Users/john/Project/memind_adm`: `112M` +- `/Users/john/Project/memind_plaza`: `617M` +- `/Users/john/Project/tkmind_go`: `45G` + +### 重要说明 + +`/Users/john/Project/tkmind_go` 很大,但它不是本次无缝迁移的关键业务数据真身。 + +本次升级不应先去搬这 `45G`,而应优先保证: + +1. `Memind` 的持久目录不丢 +2. `105` 的入口配置不丢 +3. 三个本地仓库的首次正式 bundle 发布可回退 + +## 推荐升级顺序 + +### 第一步:保留 103 持久路径不动 + +以下路径本次不改名、不挪位置: + +- `/Users/john/Project/Memind/MindSpace` +- `/Users/john/Project/Memind/data/mindspace` +- `/Users/john/Project/Memind/users` +- `/Users/john/Project/Memind/public/plaza-covers` + +### 第二步:三项目按发布包重建代码版本 + +建议顺序: + +1. `Memind` +2. `memind_adm` +3. `memind_plaza` + +原因: + +1. `Memind` 决定主持久目录和 105 的共享挂载根 +2. `memind_adm` 独立但依赖 `Memind` 共享实现 +3. `memind_plaza` 最后切,避免前端入口先于主共享资源变化 + +### 第三步:105 只保留入口角色 + +本次升级后,105 应保留: + +1. `goose-h5.service` +2. 反代 / 入口相关配置 +3. 指向 103 的共享挂载与远端 API 目标 + +本次升级后,105 不应继续承担: + +1. 可写业务数据真身 +2. 源码发布主线 +3. Goose 实例 + +## 这次如何做到“无缝” + +无缝的关键不是不重启,而是: + +1. 数据路径保持不变 +2. 新版本继承同一批持久目录 +3. 入口配置不突然指向新路径 +4. 每次切换都有可回退包 + +因此本次切换原则是: + +1. 先切代码 +2. 不搬数据根 +3. 不改共享路径 +4. 切完再验证挂载和业务路径 + +## 切换后必须验证 + +### 103 + +1. `http://127.0.0.1:8081/api/status` +2. `http://127.0.0.1:3001/plaza` +3. MindSpace 已有用户空间可读 +4. 发布页可打开 +5. `data/mindspace/users/*` 能继续访问对应资产 + +### 105 + +1. `goose-h5.service` 正常 +2. `/mnt/memind-shared/MindSpace` 挂载正常 +3. `/mnt/memind-shared/data/mindspace` 挂载正常 +4. H5 请求仍能正确回源 103 的 Goose / shared data diff --git a/docs/103-local-drift-report-2026-06-26.md b/docs/103-local-drift-report-2026-06-26.md new file mode 100644 index 0000000..56c6dc7 --- /dev/null +++ b/docs/103-local-drift-report-2026-06-26.md @@ -0,0 +1,244 @@ +# 103 与本地差异报告(2026-06-26) + +## 目的 + +这份报告用于回答三个问题: + +1. `103` 当前运行代码与本地代码差异到底在哪里。 +2. 哪些差异可以忽略,哪些必须回收到本地。 +3. 后续如何把生产发布收口到“只能本地打包发布”。 + +本次对比没有直接污染本地主开发目录,而是先从 `103` 拉出只读基线副本: + +- `/Users/john/PycharmProjects/test/_103_baselines/Memind` +- `/Users/john/PycharmProjects/test/_103_baselines/memind_adm` + +## 总结结论 + +### Memind + +`test-memind` 当前代码内容与 `103` 拉下来的只读基线已经非常接近,重点业务文件基本一致。 + +可见差异主要是: + +- 生产侧多了 `.release-manifest.txt` +- 本地多了一些文档、审计脚本和历史备份文件 + +结论: + +- `Memind` 不存在明显的“线上独有核心逻辑未回收”问题 +- 后续只需要继续坚持发布包流程,不应再直接改 `103` + +### memind_adm + +`test-memindadm` 与 `103` 基线仍然存在一批结构性差异,暂时不能直接把“线上就是最新”或“本地可以无脑全量覆盖”当成事实。 + +这些差异集中在: + +- 用户详情与空间额度字段 +- 订阅计划同步接口 +- LLM provider 加载方式 +- 启动注入与路由装配 + +结论: + +- `memind_adm` 需要一次明确的差异回收 +- 但回收动作应该发生在本地代码库中 +- 生产只能作为对账依据,不能继续反向当开发主线 + +## 本次对比方法 + +### 只读基线 + +从 `103` 通过 SSH 22 拉取只读副本,并排除运行态内容: + +- `.git` +- `.env` +- `node_modules` +- `dist` +- 日志 +- pid 文件 +- `.mindops` + +这样做的目的是: + +- 保留真实源码结构 +- 不把线上运行态垃圾带回本地 +- 不覆盖本地主开发工作区 + +### 对比范围 + +优先比对高价值文件: + +- 服务启动入口 +- API 路由 +- 用户与计费相关页面 +- 类型定义 +- 发布脚本 +- 部署文档 + +## Memind 对账结果 + +### 生产独有 + +- `.release-manifest.txt` + +这是正常的发布产物,不需要回收到源码仓库。 + +### 本地独有 + +- `docs/103-reconciliation-2026-06-26.md` +- `scripts/audit-103-state.sh` +- `scripts/g2-lb.Caddyfile.bak-20260616-204004` +- `scripts/g2-lb.Caddyfile.bak-20260619-162631` +- `server.mjs.bak-20260616-195243` +- `.gitignore` + +这些内容里: + +- 文档与审计脚本应保留在本地仓库 +- 备份文件需要后续择机清理 + +### 重点文件比对 + +以下关键文件对比结果为一致: + +- `server.mjs` +- `user-auth.mjs` +- `db.mjs` +- `schema.sql` +- `src/App.tsx` +- `src/api/client.ts` +- `src/components/MindSpaceView.tsx` +- `package.json` +- `rsync_to_server.sh` +- `scripts/release-prod.sh` +- `docs/release-deploy.md` + +判断: + +- `Memind` 当前无需做额外的线上热修回收 +- 可以直接进入“只允许发布包上线”的治理阶段 + +## memind_adm 对账结果 + +### 生产独有 + +- `server/llm-provider-loader.mjs` + +这说明当前线上仍保留一层 provider 加载包装逻辑,而本地已改成直接走共享实现。 + +这不是简单的“多一个文件”,它意味着本地与线上在启动装配方式上已经分叉。 + +### 本地独有 + +- `PRODUCTION_RELEASE_RULES.md` +- `scripts/.releaseignore-prod` +- `scripts/release-prod.sh` +- `server/plan-sync.mjs` + +这里面: + +- 发布规则与发布脚本属于这次治理新增,应保留 +- `server/plan-sync.mjs` 是本地新增业务能力,需要确认是否就是想带到生产的新主线 + +### 双方都改了的重点文件 + +- `.env.example` +- `docs/DEPLOY.md` +- `package.json` +- `scripts/rsync_to_server.sh` +- `server/app.mjs` +- `server/bootstrap.mjs` +- `server/index.mjs` +- `src/admin/pages/BillingPage.tsx` +- `src/admin/pages/UserDetailPage.tsx` +- `src/admin/pages/UsersPage.tsx` +- `src/api/client.ts` +- `src/types.ts` + +### 高价值差异解读 + +#### 1. 用户详情与空间额度 + +本地版本新增了: + +- `GET /users/:userId` +- `spaceQuotaBytes` +- `spaceUsedBytes` +- `spaceReservedBytes` +- `spaceAvailableBytes` + +同时前端用户详情页和用户列表页也接入了这些字段。 + +这组改动和“后台可调空间、前台可购买空间”的需求方向一致,应视为本地主线能力,而不是线上应保留的旧逻辑。 + +#### 2. 订阅计划同步 + +本地版本新增了计划同步服务与同步结果结构: + +- `server/plan-sync.mjs` +- `PlanSyncResult` +- Billing 页面同步结果展示 + +这组改动属于本地主线增强能力,线上基线暂未完整具备。 + +#### 3. LLM provider 装配方式 + +线上基线: + +- `server/bootstrap.mjs` 通过 `./llm-provider-loader.mjs` 加载 + +本地版本: + +- 直接从共享实现创建 `createLlmProviderService` + +这是当前最需要审慎处理的一组差异,因为它影响服务启动边界,而不是单纯 UI 逻辑。 + +#### 4. 启动与监听方式 + +本地 `server/index.mjs` 增加了: + +- `createPlanSyncService` +- 显式绑定 `127.0.0.1` + +这类差异需要和 `103` 现有反向代理、双 Goose、启动脚本一起确认,但不需要为了本地开发去复制生产双负载拓扑。 + +## 风险判断 + +### 可以接受的不一致 + +- 生产 Goose 是双实例负载,本地不是 +- 生产目录里有发布清单和运行态资产,本地没有 +- 本地为了开发保留文档、审计脚本和发布脚本 + +这些不一致属于“环境差异”,不是“源码真相冲突”。 + +### 必须收口的不一致 + +- `memind_adm` 启动装配路径分叉 +- 用户详情与空间字段相关接口分叉 +- 订阅计划同步相关接口分叉 +- 生产工作树仍然允许历史上留下的手改漂移存在 + +这些不一致会直接影响后续版本归属,必须在本地仓库中收口。 + +## 推荐收口顺序 + +1. 以本地 `test-memindadm` 为主线,明确保留“空间额度 + 用户详情 + 订阅同步”这组新能力。 +2. 单独审查 `server/llm-provider-loader.mjs` 是否仍有线上必需逻辑。 +3. 如果该文件只是在做兼容装配,则把必需逻辑回收进本地主线,再删除这层分叉。 +4. 基于本地仓库走 `scripts/release-prod.sh` 做第一次正式发布。 +5. 发布成功后,把 `103` 只保留为运行目标与审计对象,不再作为源码修改点。 + +## 最终原则 + +`103` 是运行事实来源,但不是源码主线。 + +后续必须坚持: + +1. 本地仓库合并差异 +2. 本地提交生成发布包 +3. 生产只接收发布包 +4. 不再直接 `rsync` +5. 不再在线改源码 diff --git a/docs/103-realignment-plan-2026-06-26.md b/docs/103-realignment-plan-2026-06-26.md new file mode 100644 index 0000000..f2feb56 --- /dev/null +++ b/docs/103-realignment-plan-2026-06-26.md @@ -0,0 +1,96 @@ +# Memind 103 收口方案(2026-06-26) + +## 当前判断 + +`Memind` 已经接近“可以收口”的状态,但 `103` 当前运行目录已经不是一个可依赖 Git 的干净工作树。 + +已确认: + +1. `103` 运行目录:`/Users/john/Project/Memind` +2. `103` 当前目录存在 `.release-manifest.txt`,说明至少已有发布包切换痕迹。 +3. `103` 上当前 **无法直接把 `Memind` 当成 Git 工作树使用**。 +4. 本地 `test-memind` 是后续唯一应保留的源码主线。 + +## 这意味着什么 + +1. 以后不能再依赖“线上 `git rev-parse` 一致”来判断是否对齐。 +2. `103` 只能作为运行事实来源与只读基线来源。 +3. 真正要收口的动作,必须发生在本地仓库里。 + +## 当前差异判断 + +本地与 `103` 只读基线相比: + +1. 业务主文件大体已经一致。 +2. 本地多出了规则文档、审计脚本、运行资源、历史备份文件。 +3. 这批差异里,大多数不是“线上独有必须回收”的功能逻辑。 + +## 收口目标 + +把 `Memind` 从“本地和线上都可能被手改”收口成: + +1. 本地 `test-memind` 是唯一源码真相。 +2. `103` 只接收发布包,不再手改源码。 +3. 发布后通过 manifest、健康检查、业务验收确认版本一致。 + +## 具体执行步骤 + +### 第一步:冻结线上直改 + +1. 不再在 `/Users/john/Project/Memind` 直接改源码。 +2. 不再把 `103` 当作可直接修代码的工作目录。 + +### 第二步:只保留只读基线 + +当前只读基线目录: + +- `/Users/john/PycharmProjects/test/_103_baselines/Memind` + +用途: + +1. 对账 +2. 取证 +3. 回收必要热修 + +禁止: + +1. 用它覆盖本地主开发目录 +2. 把它误当主仓库继续开发 + +### 第三步:清理本地“非源码差异” + +优先处理: + +1. 历史 `.bak` 文件 +2. 明显的临时日志与运行输出 +3. 不应该长期存在于主仓库的临时资源 + +注意: + +1. 先分类,再清理 +2. 不要误删正在使用的真实业务资源 + +### 第四步:做第一次收口发布 + +1. 本地把必须保留的改动整理成 commit。 +2. 用 `bash scripts/release-prod.sh` 生成发布包并发布到 `103`。 +3. 发布后保留 `.release-manifest.txt`、备份包路径、健康检查结果。 + +### 第五步:建立后续一致性 + +以后每次发布必须满足: + +1. 本地先 commit +2. 从 commit 打包 +3. `103` 只收包 +4. 发布后验证 +5. 出问题回滚 + +## 何时算收口完成 + +满足以下条件即可视为完成: + +1. `103` 上不再直接改源码 +2. 本地变更都能追溯到 commit +3. 最近一次线上版本来自 `scripts/release-prod.sh` +4. 业务验收结果与 manifest 能对应到同一发布编号 diff --git a/docs/103-reconciliation-2026-06-26.md b/docs/103-reconciliation-2026-06-26.md new file mode 100644 index 0000000..b36893d --- /dev/null +++ b/docs/103-reconciliation-2026-06-26.md @@ -0,0 +1,138 @@ +# 103 生产收口记录(2026-06-26) + +## 目标 + +把 `103` 从“可直接手改的运行工作树”收口为“只接收本地发布包的生产环境”。 + +这份记录只做两件事: + +1. 固定 2026-06-26 看到的真实生产状态。 +2. 给后续“本地唯一发布源”改造提供基线。 + +## 已确认的生产真相 + +### 主机与入口 + +- 生产主机:`john@58.38.22.103` +- 当前 SSH 端口:`22` +- 旧的 `2222` 已关闭,不应再写入文档或脚本 + +### 运行目录 + +- Memind: `/Users/john/Project/Memind` +- 管理后台: `/Users/john/Project/memind_adm` +- Plaza: `/Users/john/Project/memind_plaza` + +### 运行服务 + +- `cn.tkmind.memind-portal` +- `cn.tkmind.plaza` +- `cn.tkmind.memind-adm-web` +- `cn.tkmind.memind-adm-api` +- `cn.tkmind.goosed-18006` +- `cn.tkmind.goosed-18007` + +结论:Goose 在线上是双实例负载,本地不需要强行复制这套拓扑;这部分应作为“发布后巡检”处理,而不是“本地开发必须等价”处理。 + +## 数据库结论 + +### 生产正在使用的业务库 + +`/Users/john/Project/Memind/.env` 与 `/Users/john/Project/memind_adm/.env` 都指向同一个 RDS: + +- `DATABASE_URL=.../goose` + +因此: + +- 当前正式业务库是 `goose` +- `memind` 库仍存在,但不是当前正式流量的主写入目标 + +### 2026-06-26 抽样结果 + +`goose`: + +- `h5_users`: `41` +- 有充值/加款记录的用户数:`27` + +`memind`: + +- `h5_users`: `26` +- 有充值/加款记录的用户数:`12` + +这说明 `memind` 更像旧数据或历史分流,当前生产业务应以 `goose` 为准。 + +### 付费用户观察 + +按 `goose.h5_billing_ledger` 统计: + +- 除去明显超大测试/管理员账户 `admin` +- 充值额度最高的真实用户:`wx_95eskyda / cheng cong👑` +- 实际消耗最高的真实用户之一:`wx_ul610et8 / 唐` + +## 工作树漂移结论 + +### 103 生产工作树 + +- `Memind` + - branch: `main` + - commit: `728b01c` + - dirty files: `154` + +- `memind_adm` + - branch: `main` + - commit: `7a5b9cc` + - dirty files: `42` + +### 本地工作树 + +- `test-memind` + - commit: `d51df2f` + - local diff count: `102` + +- `test-memindadm` + - commit: `f217232` + - local diff count: `28` + +## 判断 + +不能把 `103` 视为“最新版本”,也不能把本地直接视为“可全量覆盖生产”的唯一事实。 + +当前状态更准确的描述是: + +1. `103` 是真实运行版本,但带有大量未回收漂移。 +2. 本地是主要开发来源,但尚未完成对 `103` 有效差异的回收。 +3. 因此必须先做一次“收口”,再执行“本地唯一发布源”制度。 + +## 收口策略 + +### 禁止动作 + +- 禁止再用 `rsync` 直接覆盖生产工作树 +- 禁止在 `103` 直接改源码后继续跑 +- 禁止把 `103` 直接反向同步进本地主开发目录 + +### 允许动作 + +1. 单独创建 `103-baseline` 副本或 worktree +2. 将 `103` 与本地、目标主线做三向比较 +3. 只回收“生产独有且必须保留”的热修 +4. 回收完成后,以本地提交为唯一发布源重新出包上线 + +## 后续制度 + +从这次收口完成后开始: + +1. 本地源码是唯一发布源 +2. 生产只接收发布包 +3. 发布包上传到 `incoming/` +4. 生产解包到 `releases/` +5. 备份当前 live 目录 +6. 原子切换目录并重启 +7. 失败自动回滚 + +## 需要继续执行的事项 + +1. 为 `memind_adm` 补齐与 `Memind` 一样的发布包脚本和规则文档 +2. 从 `103` 导出一个只读差异清单 +3. 把真正必须保留的生产热修回收到本地 +4. 收口完成后,把 `103` 变成“不可手改源码”的发布目标 diff --git a/docs/BASELINE.md b/docs/BASELINE.md index e303433..63baa6f 100644 --- a/docs/BASELINE.md +++ b/docs/BASELINE.md @@ -13,7 +13,7 @@ | H5 门户 | MindSpace、Plaza 发现广场、Agent Jobs | | 认证 | 微信登录绑定门控、PC 扫码、移动端引导 | | MindSpace | 页面实时编辑、Chat Skills、可视化 HTML 编辑器(预览内编辑 + undo/redo) | -| Plaza | 105 入口反代到 Studio 的 `:3001` | +| Plaza | 本机 Mac 为主服务,Cloudflare Tunnel 公网回源 | | 部署 | H5 / Plaza 本机与脚本化部署工具链 | ## 团队成员操作指南 diff --git a/docs/g2-load-balancing.md b/docs/g2-load-balancing.md index 08d96c0..c8ea505 100644 --- a/docs/g2-load-balancing.md +++ b/docs/g2-load-balancing.md @@ -1,49 +1,54 @@ # g2.tkmind.cn 负载均衡(Studio + 105) > 2026-06-17 上线。把 g2 H5 的请求按权重分到两台机器:Studio(主)和 105(灰度副)。 -> goose **只在 Studio 跑一份**,105 是无状态前端孪生,通过 Tailscale 把请求代理回 Studio 的 goosed。 +> Goose **只在 103 / Studio 跑**,105 是无状态前端孪生,通过固定公网地址把请求代理回 Studio 的 goosed。 +> 2026-06-22 已确认:Studio 上 Goose 不是单实例,而是双实例负载:`18006` 主、`18007` 备用/第二实例;105 仍然不跑 Goose。 ## 流量拓扑 ``` 用户 → 阿里云解析 → 105 服务器公网入口 - ├─ 转发到本地 Mac 1.6 机器 → 本机 portal 127.0.0.1:8081 + ├─ 转发到 103 / Studio 服务器 → Studio portal 127.0.0.1:8081 └─ [SSH 正向隧道 127.0.0.1:18080] → 105 portal :8080 │ - 105 / 本地主机 两个 portal ── 都连 ──→ 本地 goosed 100.99.38.66:18006(唯一实例) - 发布页文件 ── 都读 ──→ 本地 canonical /Users/john/Project/Memind/MindSpace + 105 / Studio 两个 portal ── 都连 ──→ Studio goosed 58.38.22.103:18006(主) + └────→ Studio goosed 58.38.22.103:18007(备用/第二实例) + 发布页文件 ── 都读 ──→ Studio canonical /Users/john/Project/Memind/MindSpace (105 经反向隧道 + rclone 挂载到本地同路径) ``` -- **为什么不用旧的 Cloudflare 直连**:当前入口已经迁到阿里云解析,外网流量先到 105,再由 105 转发回本地 Mac 1.6 机器。 -- **为什么 105 经 SSH 隧道接入**:入口机和 105 之间保持本地 HTTP/SSH 链路,避免把 105 公网地址直接写进上游。 +- **为什么不用旧的 Cloudflare 直连**:当前入口已经迁到阿里云解析,外网流量先到 105,再由 105 转发到 103 / Studio。 +- **为什么直接固定公网 IP**:当前服务器互联和部署链路统一使用固定公网地址 `120.26.184.105` / `58.38.22.103`,不再依赖内网地址、域名别名或临时回退链路。 -## 调灰度比例(最常用) +## 105 / 103 联通约束(硬性要求) -## 105 联通约束(硬性要求) +**要求:域名只做用户入口;服务器互联、健康检查、部署同步统一走固定公网 IP。** -**要求:105 机器与本机通信必须走 Tailscale 隧道,不允许走外网 IP。** - -- 访问、同步、部署 105 一律使用 `ssh105`。 -- `ssh105` 在 `~/.ssh/config` 中绑定为 `100.101.255.32` 并通过 `tailscale nc` 代理; -- 现有脚本默认主机已改为 `root@ssh105`。 -- 本机对 105 的联通入口是 `127.0.0.1:18080`,不是 `105.tkmind.cn`。 +- 105 运维地址:`120.26.184.105`。 +- 103 / Studio 运维地址:`58.38.22.103`。 +- 如果 SSH 别名未更新,直接写 `root@120.26.184.105` / `john@58.38.22.103`。 +- Studio / goosed 目标直接写 `58.38.22.103`,不要写 `g2.tkmind.cn` 或 105 域名。 +- Studio 对 105 的联通入口是 `127.0.0.1:18080`,不是 `105.tkmind.cn`。 快速自检(每次操作 105 前): ```bash -tailscale --socket /Users/john/Project/ollama/.tailscale/tailscaled.sock status -tailscale --socket /Users/john/Project/ollama/.tailscale/tailscaled.sock ping 100.101.255.32 -ssh ssh105 'echo tunnel-ok-105' +ping -c 1 120.26.184.105 +ping -c 1 58.38.22.103 +ssh root@120.26.184.105 'echo ssh-ok-105' +ssh john@58.38.22.103 'echo ssh-ok-103' curl -s http://127.0.0.1:18080/api/status # 应返回 ok ``` -- 禁止:`HostName 120.26.184.105` 直接作为 105 脚本主机。 - 禁止:用 `105.tkmind.cn` 做 API 健康检查或同步链路入口。 +- 禁止:把 105 公网 IP 当成业务上游写进 Caddy / goosed 目标;公网 IP 只保留给外部入口或应急 SSH。 +- 应急:如果 SSH 别名或本地缓存配置仍残留旧地址,显式设置 `H5_DEPLOY_HOST=root@120.26.184.105`,Studio 目标改为 `john@58.38.22.103`。 -编辑 **本地 Mac 1.6** 上的入口配置,调整流量分配(第一个=本机 :8081,第二个=105 :18080): +## 调灰度比例(最常用) -| 配置 | 本机 | 105 | +编辑 **103 / Studio** 上的入口配置,调整流量分配(第一个=Studio :8081,第二个=105 :18080): + +| 配置 | Studio | 105 | |------|------|-----| | `weighted_round_robin 19 1` | 95% | 5%(默认上线值) | | `weighted_round_robin 9 1` | 90% | 10% | @@ -53,7 +58,7 @@ curl -s http://127.0.0.1:18080/api/status # 应返回 ok 改完**零停机生效**: ```bash -ssh 本机入口机 +ssh john@58.38.22.103 cd ~/Project/Memind/scripts caddy validate --config g2-lb.Caddyfile # 可选,先校验 caddy reload --config g2-lb.Caddyfile # 零停机热重载 @@ -74,7 +79,7 @@ caddy reload --config g2-lb.Caddyfile ## 验证 / 观测 -每个 g2 响应都带 `X-Memind-Upstream` 头,标记实际命中的上游(`127.0.0.1:8081`=本机,`127.0.0.1:18080`=105): +每个 g2 响应都带 `X-Memind-Upstream` 头,标记实际命中的上游(`127.0.0.1:8081`=Studio,`127.0.0.1:18080`=105): ```bash # 打 N 次看分流比例 @@ -84,7 +89,7 @@ for i in $(seq 1 40); do done | sort | uniq -c # 看入口反代两个上游健康状态 -curl -s http://127.0.0.1:2019/reverse_proxy/upstreams # 在本机入口机上执行 +curl -s http://127.0.0.1:2019/reverse_proxy/upstreams # 在 103 / Studio 上执行 ``` 健康检查会每 10s 打一次各上游的 `/api/status`,连不上或非 200 就自动摘除该上游、流量全转到健康节点;恢复后自动加回。 @@ -93,15 +98,15 @@ curl -s http://127.0.0.1:2019/reverse_proxy/upstreams # 在本机入口机上 | 组件 | 位置 | 作用 | |------|------|------| -| 入口反代 | 本机入口机 `scripts/g2-lb.Caddyfile`(LaunchAgent `cn.tkmind.g2-lb`,:8090) | 入口转发 + 健康检查 | -| 正向隧道 | 本机入口机 `scripts/memind-fwd-tunnel.sh`(LaunchAgent `cn.tkmind.memind-fwd-tunnel`) | 本机 `127.0.0.1:18080` → 105 `:8080` | -| 反向隧道 | 本机入口机 `scripts/memind-mac-tunnel.sh`(LaunchAgent `cn.tkmind.memind-tunnel`) | 105 经此挂载本地 MindSpace 文件 | -| 105 portal | 105 systemd `goose-h5`(:8080),`/root/tkmind_go/ui/h5/.env` | 无状态前端,`TKMIND_API_TARGET=https://100.99.38.66:18006` | +| 入口反代 | 103 / Studio `scripts/g2-lb.Caddyfile`(LaunchAgent `cn.tkmind.g2-lb`,:8090) | 入口转发 + 健康检查 | +| 正向隧道 | 103 / Studio `scripts/memind-fwd-tunnel.sh`(LaunchAgent `cn.tkmind.memind-fwd-tunnel`) | Studio `127.0.0.1:18080` → 105 `:8080` | +| 反向隧道 | 103 / Studio `scripts/memind-mac-tunnel.sh`(LaunchAgent `cn.tkmind.memind-tunnel`) | 105 经此挂载 Studio MindSpace 文件 | +| 105 portal | 105 systemd `goose-h5`(:8080),`/root/tkmind_go/ui/h5/.env` | 无状态前端,主 Goose 指向 `https://58.38.22.103:18006`,第二 Goose 指向 `https://58.38.22.103:18007` | | 105 文件挂载 | 105 systemd `.mount` → `/mnt/memind-shared`(rclone over 反向隧道) | 发布页/工作区共享只读 | ## 关键约束(改动前必读) -- **105 永不跑 goose**。goosed 单实例只在本地 Mac 1.6 机器;session 存本地 SQLite,文件操作也在本地完成。105 只是代理 + serve。 +- **105 永不跑 goose**。goosed 只在 103 / Studio;当前是双实例 `18006` / `18007`。105 只是代理 + serve。 - 105 的 portal 必须设 `MEMIND_WORKSPACE_MAINTENANCE=0`、`MINDSPACE_AGENT_JOBS_ENABLED=false`——工作区维护守护(缩略图/资产同步 watcher)只该在本地跑,否则 105 对 rclone 挂载树做递归 fs.watch 会占满 libuv 线程池导致 portal 启动卡死。 -- 105 必须与本地主机跑**同一份代码**(用 `sync-to-105.sh` 从主机同步)和**相同的 `TKMIND_SERVER__SECRET_KEY`**(否则连不上 goosed / 解不开共享 RDS 里的加密设置)。 +- 105 必须与 103 / Studio 跑**同一份代码**(用 `sync-to-105.sh` 从 Studio 同步)和**相同的 `TKMIND_SERVER__SECRET_KEY`**(否则连不上 goosed / 解不开共享 RDS 里的加密设置)。 - 调权重只动 `g2-lb.Caddyfile`,不要动 105 的 systemd 单元或 .env。 diff --git a/docs/gadm-local.md b/docs/gadm-local.md deleted file mode 100644 index 36aea29..0000000 --- a/docs/gadm-local.md +++ /dev/null @@ -1,150 +0,0 @@ -# gadm 由 105 入口转发到 Studio - -`gadm.tkmind.cn` 的生产链路和 Plaza 一样,都是 `105 入口 -> Studio 生产机`。 -这里的后端服务名是 `memind_adm`,代码入口在本仓库的 `admin-server.mjs`。 -根路径 `/` 默认进入 `gadm` 的管理页,也就是 `ops` 前端里的 `/admin` 段。 - -## 角色拆分 - -```text -浏览器 / 内部管理人员 - ↓ -105 公网入口(gadm.tkmind.cn) - ↓ nginx / 反代 -Studio / 本机生产机 - ├─ ops 前台(/ops/,Vite 开发服务) - └─ memind_adm :8082(admin-server.mjs) - ├─ /healthz - ├─ /admin-api/* - └─ /api/ops/v1/* -``` - -`memind_adm` 只负责后端管理 API,不直接托管大部分前台页面。 -`ops/` 里的后台 SPA 是独立页面,生产上由 `gadm.tkmind.cn/` 先跳转到 `/ops/admin/`,再由 105 反代回 Studio。 - -## 本地开发 - -```bash -pnpm dev:adm -``` - -默认监听 `http://127.0.0.1:8082`。 - -常见本地访问地址: - -| 服务 | 地址 | -|------|------| -| memind_adm | http://127.0.0.1:8082 | -| ops 后台 | http://127.0.0.1:3002/ops/ | - -## 测试机同步 - -如果要同步到测试目录,`memind_adm` 对应的是: - -- 源码目录:`/Users/john/PycharmProjects/test/test-memind` -- 测试镜像目录:`/Users/john/PycharmProjects/test/test-memindadm` - -同步脚本: - -```bash -bash scripts/deploy-to-test-host.sh --only-adm -``` - -如果你要同步三套目录,也可以直接: - -```bash -bash scripts/deploy-to-test-host.sh -``` - -## 生产发布流程 - -生产上,`gadm.tkmind.cn` 解析到 105,105 负责把: - -- `/` 重定向到 `/ops/admin/` -- `/ops/` 反代到 Studio 上的 ops 页面 -- `/admin-api/` 和 `/api/ops/v1/` 反代到 Studio 上的 `memind_adm` - -### 0. 本地改动确认 - -推荐从本地开发目录发布到 Studio: - -```bash -cd /Users/john/PycharmProjects/test/test-memind -git status --short -``` - -如果你改的是 `admin-server.mjs`、`admin-routes.mjs`、`admin-*.mjs` 或 `ops/`,记得先本地自测。 - -### 1. 本地启动 / 自测 - -```bash -pnpm dev:adm -``` - -默认监听 `http://127.0.0.1:8082`,启动后先看: - -```bash -curl -s http://127.0.0.1:8082/healthz -``` - -### 2. 同步到测试机 - -```bash -bash scripts/deploy-to-test-host.sh --only-adm -``` - -测试镜像目录是 `/Users/john/PycharmProjects/test/test-memindadm`。 - -### 3. Studio 生产机启动 / 重启 - -在 Studio 机器上,`memind_adm` 最终都应该由同一个入口启动: - -```bash -cd /Users/john/Project/Memind -node admin-server.mjs -``` - -如果你们在 Studio 上用了 launchd / pm2 / systemd,就让守护脚最终执行上面这条命令。 - -常见的生产检查: - -```bash -curl -s http://127.0.0.1:8082/healthz -curl -s http://127.0.0.1:8082/admin-api/summary -``` - -### 4. 105 入口反代 - -105 上的 nginx 应该把 `gadm.tkmind.cn` 回源到 Studio 的 `memind_adm :8082`。 - -如果你要确认 nginx 当前配置,优先看: - -```bash -ssh ssh105 'nginx -t && systemctl reload nginx' -ssh ssh105 'sed -n "1,220p" /etc/nginx/conf.d/gadm.tkmind.cn.conf' -``` - -上线后优先验证: - -```bash -curl -I https://gadm.tkmind.cn/ -curl -I https://gadm.tkmind.cn/ops/admin/ -curl -s -D - -o /dev/null https://gadm.tkmind.cn/admin-api/summary -curl -s https://gadm.tkmind.cn/healthz -``` - -## 故障排查顺序 - -如果 `gadm.tkmind.cn` 挂了,按这个顺序查: - -1. Studio 上 `http://127.0.0.1:8082/healthz` 是否正常 -2. Studio 上 `memind_adm` 是否还在运行 -3. 105 上 nginx 是否把 `gadm.tkmind.cn` 转发到了 Studio -4. `ADMIN_CONSOLES`、`ADMIN_API_ALLOWED_HOSTS`、`OPS_API_ALLOWED_HOSTS` 是否限制过严 -5. `ops/` 是否还在用正确的 `'/admin-api'` 代理地址 - -## 相关文件 - -- [`admin-server.mjs`](/Users/john/PycharmProjects/test/test-memind/admin-server.mjs) -- [`scripts/deploy-to-test-host.sh`](/Users/john/PycharmProjects/test/test-memind/scripts/deploy-to-test-host.sh) -- [`scripts/local-test-proxy.mjs`](/Users/john/PycharmProjects/test/test-memind/scripts/local-test-proxy.mjs) diff --git a/docs/local-dev.md b/docs/local-dev.md index 16edeff..9cfd137 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -1,40 +1,33 @@ # 本地开发 -> 生产安全提醒:`g2.tkmind.cn` 当前使用本机 `8081`。普通开发预览必须走测试端口,不要直接在生产目录运行默认 `pnpm dev`。完整规程见 [生产 / 测试 / 预览隔离规程](./service-isolation-runbook.md)。 +> 这是本机本地开发文档,只处理当前工作区里的源码联调,不做任何生产同步。 +> `pnpm dev` 不再启动 Plaza;如果需要联动 Plaza,请显式运行 `pnpm dev:all`,或者单独运行 `pnpm dev:plaza` / `pnpm start:plaza`。 +> Plaza 专用脚本的源码默认指向同级仓库 `../test-memindplaza/app/plaza`;如你的目录不同,请用 `PLAZA_APP_DIR` 覆盖。 +> 生产 / 测试 / 预览隔离仍单独看 [生产 / 测试 / 预览隔离规程](./service-isolation-runbook.md)。 -`pnpm dev` 启动后,用 **127.0.0.1 + 端口** 访问: +`pnpm dev` 启动后,用 **127.0.0.1 + 端口** 访问本仓库自己的服务: | 服务 | 地址 | |------|------| | MindSpace H5 | http://127.0.0.1:5173/?preview=mindspace | -| Plaza | http://127.0.0.1:3001/plaza | | Ops 审核后台 | http://127.0.0.1:3002/ops/ | | API / Portal | http://127.0.0.1:8081 | | memind_adm | http://127.0.0.1:8082 | +| Plaza | http://127.0.0.1:3001/plaza | ```bash pnpm install cp .env.example .env -PLAZA_APP_DIR=/path/to/plaza pnpm dev +pnpm dev pnpm open:local-test # 浏览器打开 H5 ``` -在生产机器上做预览时,请使用测试目录和测试端口: +需要旧式全栈联动时,改用: ```bash -cd /Users/john/Project/test/Memind -H5_PORT=18081 \ -VITE_PORT=15173 \ -ADMIN_PORT=18082 \ -PLAZA_PORT=13001 \ -OPS_PORT=13002 \ -H5_PUBLIC_BASE_URL=http://127.0.0.1:15173 \ -VITE_MINDSPACE_BASE=http://127.0.0.1:15173 \ -pnpm dev +pnpm dev:all ``` -预览地址:`http://127.0.0.1:15173/?preview=mindspace`。 - ## 环境变量 | 变量 | 默认 | 说明 | @@ -45,9 +38,9 @@ pnpm dev | `OPS_PORT` | 3002 | Ops SPA | | `ADMIN_PORT` | 8082 | memind_adm | | `H5_PUBLIC_BASE_URL` | http://127.0.0.1:5173 | 公开链接基址 | -| `PLAZA_APP_DIR` | ../tkmind_go/ui/plaza | Plaza 源码路径 | +| `PLAZA_APP_DIR` | ../test-memindplaza/app/plaza | Plaza 专用脚本的源码路径(`pnpm dev:plaza` / `pnpm start:plaza` / `pnpm dev:all`) | -Plaza 公网部署见 [plaza-local.md](./plaza-local.md)。 +Plaza 本地开发说明见 [plaza-local.md](./plaza-local.md)。生产发布、同步与回滚不要在这里处理,统一看 [生产更新发布指南](./release-deploy.md)。 ## 公众号 Agent 调试 diff --git a/docs/memind-ai-mind-integration-assessment.md b/docs/memind-ai-mind-integration-assessment.md new file mode 100644 index 0000000..b5fce3f --- /dev/null +++ b/docs/memind-ai-mind-integration-assessment.md @@ -0,0 +1,362 @@ +# Memind 打通 AI Mind 项目评估报告 + +日期:2026-06-24 +范围:`/Users/john/PycharmProjects/test/test-memind` 与 `/Users/john/PycharmProjects/ai_mind` +状态:只读评估结论整理,后续可继续深化方案与实施拆解 + +## 1. 结论 + +Memind 可以与 AI Mind 项目关联,而且关联价值很高。 + +推荐方向不是把两个项目合并,也不是直接共享数据库表,而是让 Memind 继续作为入口、账号、MindSpace、H5 体验和代理层,让 AI Mind 作为长期记忆、数字人格、认知画像、人生流与 persona runtime 的认知后端。 + +一句话概括: + +> Memind 负责人机入口,AI Mind 负责脑和性格。 + +综合判断: + +- 可行性:8/10 +- 推荐度:9/10 +- 直接合并推荐度:2/10 + +最合理的路线是先做轻量桥接,再做记忆回流,最后做深度融合。 + +## 2. 当前项目角色判断 + +### 2.1 Memind 的现状 + +Memind 当前更像 H5 入口、门户、用户空间、会话代理和 MindSpace 体验层。 + +它已有: + +- 用户体系:`h5_users` +- Agent 会话归属:`h5_user_sessions` +- TKMind API proxy +- Goose/agent session 转发链路 +- MindSpace 页面、资料、发布、广场、微信接入、计费等业务能力 +- 轻量用户偏好画像:`.tkmind-profile.json` + +其中 `.tkmind-profile.json` 更像面向 Goose 会话的用户偏好卡片,字段包括语言、回复风格、偏好列表等。它可以提供基础记忆提示,但不是完整长期认知系统。 + +### 2.2 AI Mind 的现状 + +AI Mind 是一个更完整的认知与数字人格后端。 + +它已有: + +- FastAPI `/api/v1/*` API 体系 +- `chat`、`memory`、`profile`、`persona`、`life_stream`、`coremind` 等正式路由 +- 长期记忆:`memory_items`、`memory_chunks`、`memory_vectors` +- 用户认知画像:`user_cognitive_profile` +- 数字人体系:`digital_personas`、`persona_conversations`、`persona_messages` +- 数字人画像、知识库、技能、反思、自我核心、自治策略 +- 人生流 L1/L2/Phase3、profile evidence、memory bridge +- Persona external API,可供外部系统调用数字人对话与画像 + +AI Mind 中的 persona context 已经能把用户画像、长期记忆、知识库和技能聚合成 prompt 上下文。这正是 Memind 想要获得的“真的有记忆、性格、特征”的能力基础。 + +## 3. 可关联的关键依据 + +### 3.1 Memind 已有代理层,适合接外部认知服务 + +Memind 服务端已经存在 TKMind API proxy。该代理层会处理: + +- 登录态校验 +- 当前用户解析 +- 会话归属校验 +- 上游 API 转发 +- LLM provider 应用 +- 策略与能力检查 + +这意味着 AI Mind 可以作为外部认知服务接入,而不需要先改造整个 Memind 会话系统。 + +### 3.2 AI Mind 已有外部数字人接口 + +AI Mind 已有: + +- `POST /api/v1/persona/external/chat` +- `GET /api/v1/persona/external/profile` +- `POST /api/v1/persona/external/profile` + +这些接口通过 `X-API-Token` 识别外部启用的数字人,可返回人格化回复、conversation_id、persona_id、resource_summary、response_mode、画像与成长指标等。 + +这非常适合作为 Memind 的第一阶段桥接入口。 + +### 3.3 AI Mind 已有人生流投递接口 + +AI Mind 的 life_stream 支持: + +- JWT 鉴权 +- 或 `X-Life-Stream-Ingest-Key + X-Life-Stream-User-Id` 机机投递 + +Memind 可以把重要对话、MindSpace 页面、资料摘要、微信消息摘要、用户行为事件投递为 life_stream event,由 AI Mind 进一步生成分析、证据、记忆、画像和 persona pipeline。 + +## 4. 推荐架构 + +推荐采用三层桥接架构: + +```text +用户 + | + v +Memind H5 / MindSpace / 微信 / Plaza + | + v +Memind Bridge Layer + |-- 调用 AI Mind persona external chat + |-- 投递 AI Mind life_stream events + |-- 拉取 AI Mind persona profile / memory summary + | + v +AI Mind + |-- 用户画像 + |-- 长期记忆 + |-- 数字人格 + |-- 人生流 + |-- coremind / runtime +``` + +职责边界: + +- Memind 保留入口、账号、空间、页面、发布、微信、广场、计费和 agent session。 +- AI Mind 提供记忆、人格、认知画像、数字人对话、人生流分析和长期沉淀。 +- Bridge Layer 负责身份映射、Token 管理、调用限流、错误降级和审计。 + +## 5. 最小可验证版本 + +最小 MVP 可以这样做: + +1. 在 Memind 配置 AI Mind 地址,例如 `AI_MIND_BASE_URL=http://127.0.0.1:18000`。 +2. 为 Memind 用户绑定一个 AI Mind 用户和默认 persona。 +3. 保存该 persona 的 external token。 +4. Memind 新增一个“人格对话/记忆增强”调用: + - `POST /api/v1/persona/external/chat` + - Header: `X-API-Token: ` + - Body: `message`、`conversation_id`、`external_user_id` +5. Memind 异步投递重要事件: + - `POST /api/v1/life-stream/events` + - Header: `X-Life-Stream-Ingest-Key` + - Header: `X-Life-Stream-User-Id` +6. Memind 拉取画像: + - `GET /api/v1/persona/external/profile` +7. 在前端展示数字人的成长阶段、记忆分、技能分、画像摘要等。 + +这个版本可以在不破坏现有 Goose agent、计费、微信和 MindSpace 链路的情况下验证核心价值。 + +## 6. 身份映射设计建议 + +这是整个集成最关键的部分。 + +Memind 用户 ID 是 UUID 字符串: + +```text +h5_users.id CHAR(36) +``` + +AI Mind 用户 ID 是自增整数: + +```text +users.id Integer +``` + +因此不能直接共享 user_id,也不建议互相引用对方数据库外键。 + +建议增加一张 Memind 侧或独立桥接侧映射表: + +```text +memind_ai_mind_bindings +``` + +建议字段: + +- `id` +- `memind_user_id` +- `ai_mind_user_id` +- `default_persona_id` +- `persona_external_token` +- `external_user_id` +- `enabled` +- `scopes` +- `created_at` +- `updated_at` +- `last_sync_at` + +其中: + +- `memind_user_id` 对应 `h5_users.id` +- `ai_mind_user_id` 对应 AI Mind `users.id` +- `default_persona_id` 对应 AI Mind `digital_personas.id` +- `persona_external_token` 用于调用 `/api/v1/persona/external/chat` +- `external_user_id` 可以稳定使用 `memind:`,避免多端混淆 +- `scopes` 可控制允许 chat、profile、life_stream、memory_read 等能力 + +## 7. 推荐分阶段路线 + +### 阶段一:轻量桥接 + +目标:先让 Memind 能调用 AI Mind 数字人,并能读取数字人画像。 + +动作: + +- 新增 AI Mind bridge client +- 新增用户绑定配置 +- 增加 persona external chat 调用 +- 增加 persona profile 拉取 +- 前端提供一个最小入口或隐藏实验入口 + +收益: + +- 低风险 +- 快速验证“人格化回复”和“画像展示” +- 不影响原有 agent session 主链路 + +### 阶段二:记忆回流 + +目标:让 Memind 的真实用户行为成为 AI Mind 长期记忆和画像原料。 + +动作: + +- 把重要对话摘要投递到 life_stream +- 把 MindSpace 页面保存、编辑、发布事件投递到 life_stream +- 把微信重要消息摘要投递到 life_stream +- 开启 AI Mind 的 life_stream analysis、memory bridge、profile evidence merge + +收益: + +- AI Mind 开始持续学习用户 +- 数字人能逐渐拥有更稳定的记忆、性格和特征 +- Memind 的空间内容不再只是文件,而是可认知的生命流材料 + +### 阶段三:深度融合 + +目标:让 AI Mind 的人格与记忆反向增强 Memind 的 Goose/agent 会话。 + +动作: + +- Memind agent session 启动或恢复时,从 AI Mind 拉 persona context +- 把 persona context 注入 Goose 会话上下文 +- 支持用户切换不同 persona 作为对话人格层 +- 支持对话后的高价值内容自动回写 AI Mind + +收益: + +- Memind 主对话获得长期记忆 +- 不同数字人格可在同一入口中体现不同性格和能力 +- MindSpace、Agent、微信、人生流形成闭环 + +## 8. 不建议的方案 + +### 8.1 不建议直接合并项目 + +Memind 是 Node/React/Express/Vite 体系,AI Mind 是 Python/FastAPI/Vue 体系。直接合并会引入运行时、依赖、部署、鉴权、数据库迁移等复杂问题。 + +### 8.2 不建议直接共享数据库表 + +两边用户 ID、权限模型、数据生命周期、业务边界不同。直接共享表容易造成: + +- 用户串号 +- Token 泄漏 +- 权限误用 +- 数据一致性问题 +- 后续迁移困难 + +### 8.3 不建议立刻替换 Memind 主聊天链路 + +Memind 当前 `/sessions/:id/reply` 链路已有: + +- 会话归属 +- 策略控制 +- 能力检查 +- 计费 +- SSE +- LLM provider fallback + +第一阶段应采用旁路增强,而不是直接接管全部聊天。 + +## 9. 主要风险与缓解 + +### 9.1 身份映射风险 + +风险:Memind 用户和 AI Mind 用户映射错误,导致记忆或数字人串号。 + +缓解: + +- 使用显式绑定表 +- 所有调用带 `external_user_id` +- 调用前校验 binding enabled 和 scopes +- 管理后台可查看与解绑 + +### 9.2 Token 安全风险 + +风险:AI Mind persona external token 泄漏或配置错。 + +缓解: + +- Token 仅服务端保存 +- 前端不暴露 external token +- 绑定表支持禁用和轮换 +- 所有调用写审计日志 + +### 9.3 记忆污染风险 + +风险:低价值、错误、临时内容进入长期记忆。 + +缓解: + +- life_stream 投递先标记 source、privacy_class、metadata +- 只投递摘要或高价值事件 +- 用户显式删除/忘记时要回写 AI Mind +- 长期记忆写入应走 AI Mind 已有置信度和候选审核机制 + +### 9.4 体验延迟风险 + +风险:persona deep mode 可能比普通聊天慢。 + +缓解: + +- 首阶段作为独立人格入口,不阻塞主 agent +- 对短消息允许 style_only 或降级 +- 缓存 profile +- 后台异步投递 life_stream + +### 9.5 服务可用性风险 + +风险:AI Mind 不可用时影响 Memind。 + +缓解: + +- Bridge client 设置超时 +- AI Mind 调用失败时降级为普通 Memind/Goose 回复 +- UI 显示“记忆增强暂不可用” +- 不让 AI Mind 成为 Memind 基础功能的强依赖 + +## 10. 后续分析问题 + +后续可继续深入以下问题: + +1. Memind 用户与 AI Mind 用户是否已有线上对应关系? +2. 是否要为每个 Memind 用户自动创建 AI Mind 用户和默认 persona? +3. 默认 persona 是“用户的数字分身”,还是“陪伴型助手人格”? +4. 哪些 Memind 事件应该进入 life_stream? +5. 哪些内容只做短期上下文,不进入长期记忆? +6. 用户如何查看、删除、纠正 AI Mind 记住的内容? +7. 是否需要在 Memind 管理后台增加 AI Mind 绑定和诊断页? +8. MVP 是先做个人实验入口,还是直接作为正式功能灰度? +9. 是否要把 MindSpace 页面摘要同步为 persona knowledge,而不仅是 life_stream? +10. 是否需要单独的隐私分级和用户授权开关? + +## 11. 建议的下一步 + +建议下一步先做一份实施设计,不急着写代码: + +- 定义 binding 表结构 +- 定义 AI Mind bridge client API +- 定义 persona chat 调用协议 +- 定义 life_stream event 映射规范 +- 定义前端入口和降级体验 +- 定义审计日志和安全边界 +- 列出 MVP 验收用例 + +完成这些后,再进入代码实现会更稳。 + diff --git a/docs/memindadm-goose-gateway-design.md b/docs/memindadm-goose-gateway-design.md new file mode 100644 index 0000000..1ac1282 --- /dev/null +++ b/docs/memindadm-goose-gateway-design.md @@ -0,0 +1,646 @@ +# memindadm 内 Goose 网关策略中心设计 + +> **定位:** `memindadm` 后台内的一个功能模块,不是独立治理平台,也不是 Plaza 审核后台的一部分。 +> +> **目标:** 做一个可配置、可审计、可扩展的 Goose 网关策略中心。 +> +> **原则:** 不做大而全的中台,不引入复杂流程编排,先把策略控制、路由分发、审计留痕做扎实。 +> +> **边界:** 该模块只做旁路审计和策略决策,不改变现有 Goose 服务的核心行为,不把 Goose 变成被动依赖,也不在第一阶段强制改造现有 Goose 服务链路。它归属 `memindadm` 的用户管理后台,不归属 Plaza 审核后台。 +> +> **统一要求:** `Goose / Aider / OpenHands` 的 LLM 配置必须收敛到 `memindadm`,后台只保留一套统一模型配置与执行器绑定,不允许给 Goose 单独再开一套独立 LLM 设置。 + +## 1. 背景 + +现有 `memindadm` 后台已经承担了平台管理、能力配置、审计查看等职责。基于这套后台能力,可以新增一个面向 Goose 调用链路的策略模块,用来统一管理: + +- 输入内容过滤 +- 敏感词与敏感表达屏蔽 +- 输出话术约束 +- 执行器分配策略 +- 高风险操作拦截与人工确认 +- 调用审计与复盘 + +这里的核心不是“再做一个后台”,而是把 Goose 的调用前、调用中、调用后策略,纳入 `memindadm` 统一管理。 + +### 1.1 非侵入式要求 + +这一版必须满足两个硬约束: + +1. 现有 Goose 服务仍然可以按原方式独立运行。 +2. `memindadm` 只在调用入口、审计链路、策略配置层提供旁路能力,不要求 Goose 原服务先完成深度改造。 + +换句话说: + +- 先接策略,不先改服务。 +- 先留审计,不先改执行。 +- 先做可观察性,不先做强制接管。 + +如果后续要把 `memindadm` 的策略真正注入到 Goose 执行链路里,再单独做一个可控的接入阶段。 + +### 1.2 统一模型配置要求 + +模型配置必须只有一个控制平面: + +- `memindadm` 维护 Provider、API Key、Base URL、模型列表、默认模型。 +- `Goose`、`Aider`、`OpenHands` 只从 `memindadm` 读取模型绑定。 +- 后台不再出现“Goose 单独配置 LLM”的入口。 +- 某个执行器如果暂时不用,可以禁用绑定,但不能绕开 `memindadm` 单独配。 + +迁移期间如果需要,可以先停掉原来的独立 Goose 服务配置,让它完全切到 `memindadm` 的统一配置上。 + +## 2. 设计目标 + +这个模块要解决的事情很明确: + +1. 当前任务是否允许执行。 +2. 输入内容是否包含敏感信息。 +3. 输出话术是否符合产品约束。 +4. 当前任务应该由 Goose 自处理、Aider 执行,还是 OpenHands 执行。 +5. 是否需要人工确认。 +6. 执行结果是否需要记录、复核或回滚。 +7. `Goose / Aider / OpenHands` 是否使用同一 Provider 下的不同模型绑定。 + +最终效果是: + +- `memindadm` = 策略配置与审计后台 +- `memindadm` = 统一模型与策略控制台 +- Goose Gateway = 策略执行入口 +- Aider / OpenHands = 具体执行器 +- Audit Log = 全链路留痕 + +## 3. 总体架构 + +```mermaid +flowchart TD + U["用户 / 前端 / 管理员"] --> A["memindadm 后台"] + A --> P["Goose 网关策略中心"] + P --> G["Goose Gateway"] + G --> E["策略判断引擎"] + E --> F["内容过滤规则"] + E --> S["话术约束规则"] + E --> R["执行器分配规则"] + E --> H["高风险操作规则"] + E --> M["人工确认规则"] + E --> X["执行器路由"] + X --> G1["Goose 自处理"] + X --> G2["Aider 执行"] + X --> G3["OpenHands 执行"] + G1 --> L["结果 / Diff / 审计日志"] + G2 --> L + G3 --> L + L --> A +``` + +### 架构解读 + +- `memindadm` 负责配置和查看,不直接承担执行。 +- Goose Gateway 是统一入口,所有 Goose 相关调用都从这里经过。 +- 策略判断引擎先做规则匹配,再决定是否执行、交给谁执行、是否要人工确认。 +- 执行结果回流到 `memindadm` 审计中心。 + +## 4. 菜单结构 + +建议在 `memindadm` 下新增一个一级菜单: + +- `Goose 网关` + +下面放 5 个子菜单即可,保持克制: + +1. 内容过滤 +2. 敏感词 +3. 话术约束 +4. 执行策略 +5. 调用日志 + +同时建议保留或升级原有 `LLM 配置` 页面为 `统一模型中心`,专门管理: + +- Provider +- API Key +- Base URL +- 模型列表 +- 默认模型 +- 执行器绑定 + +这个页面要同时服务 `Goose / Aider / OpenHands`,不是只给 Goose 用。 + +如果后续确实需要,再补: + +- 风险规则 +- 人工确认 +- 策略测试 + +第一版不建议拆太多页,避免后台变复杂。 + +## 5. 核心模块 + +### 5.1 内容过滤规则 + +内容过滤用于处理三类文本: + +- `input`:用户输入 +- `prompt`:发给 Goose / Aider / OpenHands 的任务内容 +- `output`:最终返回给用户的内容 + +推荐支持的匹配方式: + +- 关键词匹配 +- 正则表达式匹配 +- 敏感字段检测 +- 文件路径检测 +- 高危命令检测 +- 代码操作风险检测 + +推荐动作: + +- `pass`:允许通过 +- `mask`:脱敏替换 +- `block`:阻断执行 +- `confirm`:需要人工确认 +- `log_only`:只记录不拦截 + +示例: + +```yaml +rule_id: filter_001 +name: 禁止输出密钥 +target: output +type: regex +pattern: "(sk-[a-zA-Z0-9]{20,}|AKIA[0-9A-Z]{16})" +action: mask +replacement: "[已屏蔽密钥]" +level: high +enabled: true +``` + +### 5.2 敏感词管理 + +敏感词不建议只做单一词库,最好分层: + +- 基础敏感词:明确禁止出现的词 +- 业务敏感词:项目、客户、合同、价格、内部系统等 +- 技术敏感词:密钥、Token、数据库连接串、服务器地址、生产账号等 + +对于技术类内容,不建议一律 `block`。更合理的策略是: + +- 普通讨论:允许 +- 包含真实值:脱敏 +- 涉及生产操作:人工确认 +- 要求输出密钥:阻断 + +### 5.3 话术约束 + +话术约束主要用于控制 Goose 的表达方式,避免输出不符合产品定位的内容。 + +建议分三类: + +1. 固定禁止话术 +2. 固定推荐话术 +3. 场景化话术模板 + +示例模板: + +```yaml +scene: coding_task_result +name: 编码任务完成话术 +template: + - 执行器:{executor} + - 修改范围:{changed_files} + - 测试结果:{test_result} + - 风险提示:{risk_summary} + - 下一步建议:{next_action} +``` + +### 5.4 执行器分配策略 + +执行器建议分成 5 类: + +- `goose`:分析、设计、轻量编排 +- `aider`:小范围代码修改、补丁式修复 +- `openhands`:复杂开发任务、多文件改造、仓库探索、命令执行 +- `manual`:需要人工确认 +- `reject`:拒绝执行 + +建议第一阶段使用规则引擎,不做复杂模型决策。 + +示例策略: + +```yaml +policy_id: route_001 +name: 小范围代码修改走 Aider +conditions: + task_type: code_change + max_files: 3 + requires_browser: false + requires_long_running_env: false + risk_level: low +executor: aider +priority: 100 +enabled: true +--- +policy_id: route_002 +name: 复杂仓库任务走 OpenHands +conditions: + task_type: + - feature_dev + - bug_fix_complex + - repo_refactor + min_files: 4 + requires_command_execution: true +executor: openhands +priority: 90 +enabled: true +--- +policy_id: route_003 +name: 只分析不改代码走 Goose +conditions: + task_type: + - architecture_design + - code_review + - requirement_analysis + write_permission_required: false +executor: goose +priority: 80 +enabled: true +--- +policy_id: route_004 +name: 高风险操作需要人工确认 +conditions: + risk_keywords: + - 删除数据库 + - 生产环境 + - 支付接口 + - 权限系统 + - 用户数据 + risk_level: high +executor: manual +priority: 200 +enabled: true +``` + +优先级原则: + +- 高风险规则优先 +- 阻断规则优先 +- 人工确认优先 +- 明确执行器规则优先 +- 默认 Goose 自处理 + +### 5.5 任务识别器 + +Goose Gateway 在执行前需要先把用户任务识别成结构化结果。 + +示例: + +```json +{ + "task_type": "feature_dev", + "risk_level": "medium", + "requires_code_change": true, + "requires_command_execution": true, + "estimated_files": 5, + "requires_browser": false, + "target_repo": "memind-h5", + "suggested_executor": "openhands" +} +``` + +推荐流程: + +1. 用户输入 +2. LLM 初步识别任务类型 +3. 规则引擎二次校验 +4. 匹配执行器策略 +5. 生成执行计划 + +### 5.6 高风险操作控制 + +高风险类型建议内置: + +- 生产数据库操作 +- 删除文件或目录 +- 批量修改用户数据 +- 支付、充值、订单相关逻辑 +- 权限、登录、Token、密钥相关逻辑 +- 服务器部署、重启、停止服务 +- 对外发送消息、邮件、公众号发布 + +这些动作统一进入人工确认: + +```text +confirm_required = true +``` + +确认内容应包含: + +- 任务说明 +- 执行器 +- 目标仓库 +- 计划修改文件 +- 预计执行命令 +- 风险点 +- 回滚建议 + +## 6. 调用流程 + +### 6.1 普通任务 + +```text +用户提交任务 + ↓ +Goose Gateway 接收 + ↓ +内容过滤 + ↓ +任务识别 + ↓ +执行器策略匹配 + ↓ +调用 Aider / OpenHands / Goose + ↓ +结果过滤 + ↓ +话术约束 + ↓ +返回用户 + ↓ +写入审计日志 +``` + +### 6.2 高风险任务 + +```text +用户提交任务 + ↓ +内容过滤 + ↓ +命中高风险规则 + ↓ +生成执行计划 + ↓ +进入人工确认 + ↓ +管理员确认 + ↓ +执行器执行 + ↓ +结果审计 + ↓ +返回用户 +``` + +## 7. 审计日志 + +每一次调用都必须记录。 + +建议字段: + +- `request_id` +- `user_id` +- 原始输入 +- 过滤结果 +- 命中规则 +- 任务类型 +- 风险等级 +- 选择的执行器 +- 执行参数 +- 执行日志 +- 修改文件 +- diff 摘要 +- 测试结果 +- 最终输出 +- 创建时间 +- 完成时间 +- 执行状态 + +审计日志第一阶段可以先不做复杂可视化,但数据库结构要先预留。 + +## 8. 表结构建议 + +### `goose_policy_rule` + +- `id` +- `rule_name` +- `rule_type` +- `target` +- `match_type` +- `pattern` +- `action` +- `risk_level` +- `priority` +- `enabled` +- `created_at` +- `updated_at` + +### `goose_sensitive_word` + +- `id` +- `group_name` +- `word` +- `scope` +- `action` +- `enabled` +- `created_at` +- `updated_at` + +### `goose_route_policy` + +- `id` +- `policy_name` +- `task_type` +- `conditions_json` +- `executor` +- `priority` +- `enabled` +- `created_at` +- `updated_at` + +### `goose_execution_log` + +- `id` +- `request_id` +- `user_id` +- `task_type` +- `risk_level` +- `executor` +- `input_text` +- `filtered_input` +- `matched_rules_json` +- `execution_status` +- `execution_summary` +- `diff_summary` +- `test_result` +- `llm_provider` +- `llm_model` +- `created_at` +- `finished_at` + +### `goose_manual_confirm` + +- `id` +- `request_id` +- `confirm_type` +- `risk_summary` +- `planned_action` +- `status` +- `operator_id` +- `confirmed_at` +- `created_at` + +## 9. `memindadm` 后台页面建议 + +### 9.1 内容过滤规则页 + +字段: + +- 规则名称 +- 检测对象 +- 匹配方式 +- 关键词 / 正则 +- 处理动作 +- 风险等级 +- 是否启用 +- 优先级 + +### 9.2 敏感词管理页 + +字段: + +- 词库分组 +- 敏感词 +- 作用范围 +- 处理动作 +- 是否启用 + +### 9.3 话术约束页 + +字段: + +- 场景 +- 禁止话术 +- 推荐话术 +- 输出模板 +- 是否启用 + +### 9.4 执行器分配策略页 + +字段: + +- 策略名称 +- 任务类型 +- 条件配置 +- 目标执行器 +- 风险等级 +- 优先级 +- 是否启用 + +### 9.5 调用日志页 + +字段: + +- 请求时间 +- 用户 +- 任务类型 +- 命中规则 +- 执行器 +- 风险等级 +- 执行状态 +- 详情查看 + +## 10. MVP 范围 + +第一阶段只做小而精,不做复杂流程引擎。 + +### 必须做 + +1. 内容过滤规则配置 +2. 敏感词配置 +3. 执行器分配策略配置 +4. Goose 调用前策略判断 +5. Aider / OpenHands 路由选择 +6. 调用审计日志 +7. 统一模型中心 + +### 可以暂缓 + +1. 多级审批 +2. 复杂权限矩阵 +3. 策略版本管理 +4. 复杂可视化编排 +5. 自动回滚 +6. 多租户策略隔离 + +## 11. 推荐落地顺序 + +### 第一步:先做表和日志 + +先把规则、敏感词、执行器策略、日志表建起来。 + +### 第二步:做 Goose Gateway 策略判断 + +在 Goose 调用前增加统一入口: + +```text +before_execute(task) +``` + +负责: + +- 过滤输入 +- 识别任务 +- 判断风险 +- 选择执行器 +- 生成执行计划 + +### 第三步:接入 Aider + +先支持小范围代码任务走 Aider,因为调用简单、成本低、见效快。 + +### 第四步:接入 OpenHands + +再把复杂任务转给 OpenHands,让它负责仓库探索、多文件开发和命令执行。 + +### 第五步:把 Goose 也切到统一模型中心 + +Goose 不再使用单独的模型配置页面,而是直接读取 `memindadm` 的统一模型中心。必要时可以先停掉原来独立的 Goose LLM 配置,保证入口只有一个。 + +### 第六步:做 `memindadm` 后台页面 + +先做简单 CRUD,不追求复杂交互。 + +## 12. 预期效果 + +完成后,`memindadm` 会具备一套轻量级 Goose 网关治理能力: + +- 可配置内容过滤 +- 可配置敏感词屏蔽 +- 可配置话术约束 +- 可配置执行器路由 +- 可拦截高风险操作 +- 可审计每次调用过程 + +这样 Goose 不再只是单一 Agent,而是变成一个可以统一调度 Aider、OpenHands、Claude Code 等工具的策略入口。 + +## 13. 最终定位 + +- `memindadm` = 用户与策略后台 +- `Goose Gateway` = Agent 编排入口 +- `Aider` = 小型代码修改执行器 +- `OpenHands` = 复杂代码任务执行器 +- `Goose` = 策略与分析执行器,模型同样来自统一模型中心 +- `Policy Center` = 安全与路由规则中心 +- `Audit Log` = 行为留痕与复盘中心 + +第一版建议在产品命名上保持克制,后台菜单直接叫: + +- `Goose 网关` + +只放这 5 个子菜单: + +1. 内容过滤 +2. 敏感词 +3. 话术约束 +4. 执行策略 +5. 调用日志 + +这样产品上小,架构上完整,后续可以自然扩展。 diff --git a/docs/no-source-portal-migration.md b/docs/no-source-portal-migration.md new file mode 100644 index 0000000..db7e2e1 --- /dev/null +++ b/docs/no-source-portal-migration.md @@ -0,0 +1,86 @@ +# test-memind Portal 无源码迁移说明 + +## 目标 + +把 `test-memind` 的 Portal 先迁到“`103` 仅运行 runtime artifact,不再依赖源码树”的模式,同时严格保护这些持久数据: + +- `MindSpace/` +- `data/` +- `users/` +- `public/plaza-covers/` +- `.env` +- `.tailscale/` +- `logs/` + +## 已确认的生产差异 + +这些差异不应该再体现在源码目录里,而应该全部通过运行时配置保留: + +1. 本地默认可以连本地数据库;生产实际使用 RDS。 +2. 本地 Goose 可以是单实例;生产 `103` 是双实例: + - `TKMIND_API_TARGET=https://127.0.0.1:18006` + - `TKMIND_API_TARGET_1=https://127.0.0.1:18007` +3. 本地端口和外网入口可以不同;生产实际是: + - Portal `127.0.0.1:8081` + - Plaza `127.0.0.1:3001` + - `105` 只做入口/转发,不承载主 Portal 源码运行 +4. 生产用户公开入口是 `https://g2.tkmind.cn`,不是裸 IP,也不是本地 localhost。 +5. 运维、发包、健康检查统一走固定公网地址,不再依赖 `10.10.*` 局域网: + - `105`:`120.26.184.105` + - `103 / Studio`:`58.38.22.103` + +## 迁移原则 + +1. 构建发生在本机,不发生在 `103`。 +2. `103` 不再 `npm install`,不再 `npm run build`。 +3. `103` 只接收运行产物、继承持久目录、启动服务。 +4. 生产差异通过 `.env` 和启动环境注入,不通过“线上源码和本地源码不同”来兜底。 +5. 迁移前必须先打整包备份,且单独备份持久目录。 +6. 后续所有发布脚本默认都应优先直连 `58.38.22.103`,只有用户明确要求时才允许覆盖 `STUDIO_HOST`。 + +## Portal 运行产物 + +新增本地构建脚本: + +```bash +node scripts/build-portal-runtime.mjs +``` + +产物目录: + +```text +.runtime/portal +``` + +当前产物包含: + +1. `dist/` 前端静态资源。 +2. `public/` 静态资源。 +3. `server.mjs` 后端单文件 runtime bundle。 +4. `mindspace-sandbox-mcp.mjs` sandbox-fs 扩展子进程入口(必须与 `server.mjs` 同目录)。 +5. `node_modules/` 运行依赖。 +6. `package.json`、`scripts/run-memind-portal-prod.sh` 与运行说明。 + +## 为什么本地/生产差异不会阻止无源码部署 + +因为这些差异本质上都属于运行时配置: + +1. 数据库差异: + 本地和生产只是 `.env` 里的 `DATABASE_URL` / `MYSQL_*` 不同,artifact 不应该内置数据库地址。 +2. Goose 单例/双例差异: + 只是 `TKMIND_API_TARGET` / `TKMIND_API_TARGET_1` 的值不同,artifact 不应该写死实例数量。 +3. 105 转发到 103: + 这是入口层拓扑,不是 Portal 源码结构。只要 `103` 上的 Portal 监听端口不变,入口层可以保持不变。 +4. 端口差异: + 只是 `H5_PORT`、`PLAZA_PORT`、反代配置不同,不是源码必须驻留在 `103` 的理由。 + +## 生产发布入口 + +Portal 在 `103` 上必须只运行 runtime artifact,唯一合法发布命令: + +```bash +bash scripts/release-portal-runtime-prod.sh --dry-run +bash scripts/release-portal-runtime-prod.sh +``` + +`bash scripts/release-prod.sh` 已停用,不得再用于 Portal。 diff --git a/docs/openhands-install.md b/docs/openhands-install.md new file mode 100644 index 0000000..201c192 --- /dev/null +++ b/docs/openhands-install.md @@ -0,0 +1,171 @@ +# OpenHands 安装说明 + +> 目标:先把 OpenHands 独立安装并跑起来,确认可用后,再接入 `memindadm` 的 Goose 网关策略中心。 +> +> 原则:安装阶段不改动现有 Goose 服务,不影响当前生产/测试链路。 + +## 1. 推荐方案 + +如果你的目标是后续和 Goose 做集成,建议先用 **OpenHands CLI + GUI Server** 方式启动: + +- 本地直接运行 +- 可挂载当前仓库目录 +- 便于后续做执行器接入验证 + +官方文档对 `openhands serve` 的说明是:它会通过 Docker 启动本地 GUI Server,支持挂载当前目录,适合直接对仓库做任务。 + +## 2. 前置条件 + +### 必需 + +- `uv` +- Python 3.12+ +- Docker Desktop 已安装并运行 +- 可用的 LLM Provider / Model / API Key + +### Mac 上建议额外确认 + +- Docker Desktop 的默认 socket 访问已开启 +- `docker ps` 可以正常执行 + +如果后面 `serve` 起不来,优先检查 Docker 是否在运行,以及 Docker Desktop 的相关网络设置。 + +## 3. 安装方式 + +### 方式 A:推荐,使用 `uv` + +```bash +uv tool install openhands --python 3.12 +``` + +安装完成后启动: + +```bash +openhands serve --mount-cwd +``` + +说明: + +- `serve` 会启动 GUI Server +- `--mount-cwd` 会把当前目录挂进 OpenHands 的工作区 +- 这样它可以直接面对你的仓库做任务 +- 首次启动后需要在界面里选择 LLM Provider、Model,并填入对应 API Key + +如果你只想先验证能跑起来,也可以先不加 `--mount-cwd`: + +```bash +openhands serve +``` + +### 方式 B:使用官方安装脚本 + +```bash +curl -fsSL https://install.openhands.dev/install.sh | sh +``` + +安装后同样可以启动: + +```bash +openhands serve --mount-cwd +``` + +### 方式 C:Docker / Agent Canvas + +如果你更偏向容器化,可按 OpenHands 的 Agent Canvas / Docker 文档走容器启动方式。这个方式适合未来把 OpenHands 作为更独立的执行环境来跑。 + +### 端口与资源 + +- GUI Server 默认会占用 `3000` 端口 +- Docker 镜像和运行时需要一定磁盘空间 +- 如果要跑 GPU,可以在 `serve` 时加 `--gpu` + +## 4. Mac 最短安装路径 + +如果你现在是在 Mac 上直接装,我建议按这个顺序走: + +### 4.1 安装 Docker Desktop + +去 Docker 官方页面下载安装包,按芯片类型选择 Apple Silicon 或 Intel 版本。 + +- 安装页:`Docker Desktop for Mac` + +安装完成后,先启动 Docker Desktop,等左上角状态变成运行中。 + +### 4.2 打开 Docker Socket 选项 + +按 OpenHands 官方本地安装说明,进入: + +- `Docker Desktop` +- `Settings` +- `Advanced` +- 勾选 `Allow the default Docker socket to be used` + +这个开关是 OpenHands 本地运行最关键的前置条件之一。 + +### 4.3 验证 Docker 是否可用 + +在终端执行: + +```bash +docker ps +``` + +如果能正常返回容器列表或空列表,说明 Docker daemon 已经起来了。 + +### 4.4 启动 OpenHands + +回到你的仓库目录后执行: + +```bash +openhands serve --mount-cwd +``` + +如果一切正常,你会看到 OpenHands GUI Server 在本机启动。 + +## 5. 如果你暂时不想装 Docker + +如果你只是想先体验界面,不急着本地执行仓库任务,可以先考虑 OpenHands 的 `web` 模式。这个模式是终端界面的浏览器版,不是完整 GUI Server,和 `serve` 不是一回事。 + +但如果你的目标是后面和 `memindadm` 做执行器集成,我还是建议装 Docker,后续链路会更稳。 + +## 6. 启动后如何确认正常 + +启动成功后,确认以下几点: + +1. 浏览器能打开 OpenHands 的界面。 +2. 可以创建一次简单任务。 +3. 如果用了 `--mount-cwd`,任务可以看到当前仓库。 +4. Docker 不报权限或 socket 错误。 + +## 7. 用于后续 Goose 集成时的建议 + +为了后面接入 `memindadm`,建议你先准备好这些信息: + +- OpenHands 的启动方式 +- 本地可访问地址 +- 是否需要挂载仓库目录 +- 是否允许命令执行 +- 是否要使用 GPU +- 计划给 Goose 侧调用的入口方式 + +建议后续接入时优先走“策略中心路由到 OpenHands”,而不是让 Goose 直接接管 OpenHands 的所有行为。这样 `memindadm` 的审计和拦截链路会更清晰。 + +如果你后面已经决定把 `Goose / Aider / OpenHands` 的模型统一收口到 `memindadm`,那么 OpenHands 这边也不要再单独维护自己的模型配置,统一从后台读取即可。 + +## 8. 和现有 Goose 服务的关系 + +这一步是旁路安装,不会改动现有 Goose 服务。 + +后续集成时,建议保持下面的边界: + +- 现有 Goose 服务继续照常运行 +- `memindadm` 只在旁路做策略判断、审计记录、路由建议 +- OpenHands 先作为独立执行器接入 +- 真正切流之前,先做只读审计和联调验证 + +## 9. 官方参考 + +- OpenHands 安装文档 +- OpenHands GUI Server +- OpenHands Local Setup +- OpenHands Docker Sandbox diff --git a/docs/plaza-local.md b/docs/plaza-local.md index 8fec2df..57e81e8 100644 --- a/docs/plaza-local.md +++ b/docs/plaza-local.md @@ -1,39 +1,48 @@ -# Plaza 由 105 入口转发到 Studio +# Plaza 本机为主 -`plaza.tkmind.cn` 现在解析到 105 服务器,105 只负责公网入口和反代,最终回源到 Studio 上的 Plaza 生产服务。 +105 服务器已停用。Plaza 与 Memind H5 均在本机 Mac 运行;公网 `plaza.tkmind.cn` 经 **Cloudflare Tunnel** 回源到本机。 ## 架构 -```text +``` 浏览器 / 微信 ↓ -105 公网入口(plaza.tkmind.cn) - ↓ nginx / 反代 -Studio / 本机生产机 +Cloudflare(plaza.tkmind.cn) + ↓ Cloudflare Tunnel +本机 Mac ├─ :3001 Plaza Next.js(/plaza、/_next) │ └─ rewrites → :8081(/api、/auth、/u) └─ :8081 Memind Portal(server.mjs) ``` -Next.js 通过 `PLAZA_API_PROXY` 把 API 请求转发到 Portal,无需单独 nginx。105 侧只负责把 `https://plaza.tkmind.cn/plaza` 回源到 Studio 的 `:3001`。 +Next.js 通过 `PLAZA_API_PROXY` 把 API 请求转发到 Portal,无需单独 nginx。 -## 运行前提 +## 首次配置(一次性) -- 105 上 nginx 配置正常,`/plaza` 和 `/_next` 代理到 Studio -- Studio 上 Plaza 已完成 `next build` -- Studio 上 Plaza 生产进程正在监听 `127.0.0.1:3001` -- Studio 上 Portal 正在监听 `127.0.0.1:8081` +```bash +# 1. 本地 DNS(覆盖 Tailscale / Clash fake-ip) +sudo pnpm setup:plaza-dns + +# 2. 本地 HTTPS 证书(可选,供本机 https://plaza.tkmind.cn) +sudo pnpm setup:plaza-local + +# 3. 公网隧道(plaza.tkmind.cn → 本机 :3001) +pnpm setup:plaza-tunnel +``` ## 日常启动 ```bash -# Studio 上启动 Plaza 生产模式 +# 本机正式模式(next build + start,供 Tunnel 回源,推荐公网) pnpm start:plaza # 开发模式(热更新,仅本地调试) pnpm dev:plaza -# 全栈(MindSpace + Plaza + Ops) +# 另开终端:本机 HTTPS 入口(可选,纯本地 HTTPS 时用) +sudo pnpm dev:plaza-proxy + +# 或全栈(MindSpace + Plaza + Ops) pnpm dev ``` @@ -42,30 +51,61 @@ pnpm dev | 场景 | 地址 | |------|------| | 本机直连 | http://127.0.0.1:3001/plaza | -| 105 公网入口 | https://plaza.tkmind.cn/plaza | +| 本机域名(需 proxy) | https://plaza.tkmind.cn/plaza | | 浏览器强制本地 | `pnpm open:plaza` | +| 公网(需 dev:plaza + 隧道) | https://plaza.tkmind.cn/plaza | -## 常见故障 +### 浏览器提示「连接不是私密连接」? -### 访问 502 +本机曾用 `setup:plaza-dns` 把域名指到 `127.0.0.1`,HTTPS 走自签证书。若已配置 Cloudflare Tunnel,应 **去掉本地劫持**,走公网证书(仍回源本机): -优先按这个顺序检查: +```bash +sudo pnpm setup:plaza-dns:remove +``` -1. Studio 上 `http://127.0.0.1:3001/plaza` 是否返回 200 -2. Studio 上 Plaza 生产进程是否还在 -3. 105 上 nginx 是否已经 reload 到最新配置 +然后刷新 https://plaza.tkmind.cn/ 即可。 -如果 Studio 的 `:3001` 没起来,105 再怎么转发也会 502。 +### 浏览器打不开? -### Studio 启动失败 +Chrome / Cursor 内置浏览器默认走 **Secure DNS**,会绕过 `/etc/hosts` 连到 Cloudflare(105 停服时显示 502)。 -如果 `pnpm start:plaza` 一直退出,优先看 `~/Library/Logs/plaza-prod.log`。这次修复里我们遇到过两类问题: +解决: -- `next/font/google` 在离线构建时会拉不到字体资源 -- 类型检查会因为事件批处理数组的推断过宽而失败 +1. `pnpm open:plaza`(推荐) +2. 关闭浏览器「安全 DNS / Secure DNS」 +3. `pnpm check:plaza` 查看诊断 + +## 公网 Tunnel + +隧道配置写在 `ollama-tkmind` 的 cloudflared config(默认 `~/Project/ollama/cloudflare/config.yml`)。 + +```bash +pnpm setup:plaza-tunnel # 写入 ingress + DNS 路由 + 重启 cloudflared +``` + +**前提:** `pnpm start:plaza`(或 `pnpm dev:plaza`)已在跑(:3001 可访问)。Mac 休眠或服务停掉时,外网会 502/503。 + +自定义: + +```bash +CLOUDFLARE_TUNNEL_CONFIG=/path/to/config.yml \ +CLOUDFLARE_TUNNEL_NAME=ollama-tkmind \ +PLAZA_TUNNEL_PORT=3001 \ +pnpm setup:plaza-tunnel +``` + +## 环境变量 + +见 `.env.example` 中 Plaza 相关注释。常用: + +| 变量 | 默认 | 说明 | +|------|------|------| +| `PLAZA_PORT` | 3001 | Plaza Next.js 端口 | +| `H5_PORT` | 8081 | Portal / API 端口 | +| `PLAZA_LOCAL_HOST` | plaza.tkmind.cn | 本地域名 | +| `PLAZA_PUBLIC_BASE` | https://plaza.tkmind.cn | 公开 URL 前缀 | ## 已弃用 -- 把 `plaza.tkmind.cn` 直接指到本机 `127.0.0.1` 的旧本地劫持流程 -- 直接把 `plaza.tkmind.cn` 说明成 Cloudflare Tunnel 直连本机的旧说法 -- “105 已停用、Plaza 全在本机且不经过 105”的旧文档描述 +- `pnpm deploy:105`、`pnpm deploy:plaza-105` — 105 不再作为主服务 +- 105 上 nginx / goose-h5 / goose-plaza-web — 已停服,公网不再回源 105 diff --git a/docs/release-deploy.md b/docs/release-deploy.md index 8673397..93ca8c6 100644 --- a/docs/release-deploy.md +++ b/docs/release-deploy.md @@ -1,315 +1,47 @@ -# Memind 生产更新发布指南 +# Memind Portal 生产更新发布指南 -> 本文描述如何把本地开发代码安全发布到 **g2.tkmind.cn** 生产环境。 -> 发布前请先阅读 [生产 / 测试 / 预览隔离规程](./service-isolation-runbook.md),避免误占生产端口或覆盖用户数据。 +> 2026-06-26 起,103 / Studio 正式禁止 `rsync` 发布。 +> Portal 生产必须是**无源码 runtime 模式**,唯一合法入口是 `bash scripts/release-portal-runtime-prod.sh`。 -## 1. 架构与发布目标 +## 当前规则 -用户访问 `https://g2.tkmind.cn/` 的流量路径: +1. 构建只发生在本机 Mac:`node scripts/build-portal-runtime.mjs`。 +2. 103 只接收 `.runtime/portal/` 打出来的 artifact,不直接覆盖源码树。 +3. 103 在切换前必须做 `Memind` 全量备份,并单独备份持久目录。 +4. 103 **禁止** `npm install`、`npm run build`、在线改源码后继续运行。 +5. 切换后必须通过 Portal 健康检查;失败立即回滚。 -```text -阿里云解析(DNS) - → 105 服务器(公网入口 / 反代) - → 本地 Mac 1.6 机器(主服务) - ├─ 主流量 → 本地 Portal(127.0.0.1:8081) - └─ 备用/灰度 → 105 Portal(经隧道 127.0.0.1:18080 → :8080) -``` - -两台 Portal 都是 **无状态前端**,共用 Studio 上的 goosed 与 MindSpace 数据目录。 - -```mermaid -flowchart TB - Dev[本地开发目录] -->|rsync_to_server.sh| Studio[Studio 生产机
/Users/john/Project/Memind] - Studio -->|sync-to-105.sh| N105[105 入口 / 反代] - N105 -->|g2.tkmind.cn| G2[g2 H5 / Portal] - N105 -->|plaza.tkmind.cn| Plaza[Plaza 前台] - N105 -->|gadm.tkmind.cn| Gadm[memind_adm] - G2 --> Portal[Portal :8081] - Plaza --> PlazaSrv[Plaza :3001] - Gadm --> AdmSrv[memind_adm :8082] -``` - -| 角色 | 机器 | 代码目录 | 服务 | 重启方式 | -|------|------|----------|------|----------| -| 生产主 | 本地 Mac 1.6 机器 | `/Users/john/Project/Memind` | Portal `:8081`、Plaza `:3001` | `launchctl kickstart` | -| 入口转发 | 105 服务器 | 入口转发配置 | 反代 `:80/:443` | 阿里云解析切换后生效 | -| 灰度副 | 105(经 Tailscale `ssh105`) | `/root/tkmind_go/ui/h5` | `goose-h5` `:8080` | `systemctl restart goose-h5` | - -更详细的流量与灰度比例说明见 [g2 负载均衡](./g2-load-balancing.md)。 - -## 2. 目录与环境对照 - -| 环境 | 典型目录 | 端口 | 用途 | -|------|----------|------|------| -| **生产(Studio)** | `/Users/john/Project/Memind` | `8081` / `3001` | 线上 g2 / plaza | -| **开发预览** | `/Users/john/Project/test/Memind` 或本机副本 | `18081` / `13001` | 本地联调,禁止占 `8081` | -| **105 副机** | `/root/tkmind_go/ui/h5` | `8080` | g2 灰度流量 | - -**重要:** `rsync_to_server.sh` 会把**你执行命令时所在的本地目录**同步到 Studio 生产目录。发布前请确认当前目录里的代码就是你要上线的版本,而不是半成品或未验证的分支。 - -## 3. 发布入口(主流程) - -**推荐唯一入口:** 项目根目录的 `rsync_to_server.sh` +## 唯一入口 ```bash -cd /path/to/your/memind-repo # 开发完成、已自测的目录 - -# ① 预览(不修改任何远端) -./rsync_to_server.sh --dry-run - -# ② 正式发布(Studio + 105 全量) -./rsync_to_server.sh +bash scripts/release-portal-runtime-prod.sh --dry-run +bash scripts/release-portal-runtime-prod.sh ``` -脚本会自动完成: - -1. 本地 Pre-flight(关键文件、安全 patch、exclude 规则) -2. Studio Pre-flight(SSH、`.env`、MindSpace、data 完整性) -3. 交互确认(可用 `--yes` 跳过) -4. rsync 代码 → Studio 生产目录 -5. rsync 后校验(`.env` 未变、MindSpace 未减少、patch 仍在) -6. Studio 上 `npm install` + `npm run build` - 7. 重启本地 Mac 1.6 Portal → Plaza,并做健康检查 -8. 经 Studio 触发 `scripts/sync-to-105.sh`,同步并重启 105 - -## 4. 发布前检查清单 - -在 `./rsync_to_server.sh` 之前,逐项确认: - -### 4.1 本地构建与测试 - -```bash -pnpm install # 依赖有变更时 -pnpm run build # 前端改动必须能编过 -pnpm test # 建议跑;涉及核心逻辑时必跑 -``` - -| 改动类型 | 是否必须 build | 能否 `--skip-build` | -|----------|----------------|---------------------| -| 前端(`.tsx` / `.css` / `src/`) | **是** | 否 | -| 仅后端(`.mjs`) | 否(但 build 无害) | 可以 | -| 仅文档 / 脚本 | 否 | 可以 | - -### 4.2 生产在线(只读) - -```bash -curl -s http://127.0.0.1:8081/api/status # 本地 Portal,期望 ok -curl -s http://127.0.0.1:18080/api/status # 105 隧道,期望 ok -``` - -105 联通必须走 Tailscale,不要用公网 IP: - -```bash -ssh ssh105 'echo tunnel-ok-105' -``` - -### 4.3 安全约束(脚本会自动检查) - -- `server.mjs` 必须含 `WORKSPACE_MAINTENANCE_ENABLED` patch(否则 105 重启会在 rclone 挂载上卡死) -- `.env`、`MindSpace/`、`data/` 在 exclude 列表中,**不会被 rsync 覆盖** -- rsync 使用 `--delete`:本地已删的文件会从 Studio 删掉(exclude 外的路径) - -### 4.4 发布范围确认 - -- `rsync_to_server.sh` 同步的是**整个仓库**(除 exclude 外),不是单个文件 -- 工作区若有未完成的其它改动,会一并上线——发布前建议 commit 或整理干净 -- 涉及数据库迁移、批量清数据、支付回调测试等,**不要**直接在生产目录试跑,见 [隔离规程](./service-isolation-runbook.md) - -## 5. 分步与保守发布 - -`rsync_to_server.sh` 支持的常用参数: - -| 参数 | 作用 | 适用场景 | -|------|------|----------| -| `--dry-run` | 只预览 diff,不改远端 | **每次正式发布前必做** | -| `--only-100` | 只更新本地主机,不推 105 | 先让主流量生效,105 稍后 | -| `--only-105` | 只触发本地主机→105 同步 | 主机已是最新,只补 105 | -| `--skip-build` | 跳过远端 `npm run build` | 纯后端改动且确认 dist 无需更新 | -| `--no-restart` | 只同步代码,不重启服务 | 分批发布;需自行重启 | -| `--yes` / `-y` | 跳过交互确认 | 自动化或你已看过 dry-run | - -示例: - -```bash -# 只发 Studio(主流量 95%) -./rsync_to_server.sh --only-100 - -# 纯 server.mjs 改动,跳过 build -./rsync_to_server.sh --skip-build - -# 先同步代码,稍后再重启 -./rsync_to_server.sh --no-restart -# 之后在 Studio 上手动 kickstart,或再跑一遍带重启的同步 -``` - -## 6. 105 单独同步 - -若 Studio 代码已是最新,只需更新 105: - -```bash -# 在 Studio 生产目录执行 -cd /Users/john/Project/Memind -bash scripts/sync-to-105.sh - -# 或从本地只触发 105 链路 -./rsync_to_server.sh --only-105 -``` - -`sync-to-105.sh` 会: - -1. rsync 代码(`--delete`)与 `dist/` 到 `root@ssh105:/root/tkmind_go/ui/h5` -2. 远端 `npm install` + `npm run build`(可用 `SKIP_BUILD=1` 跳过) -3. `systemctl restart goose-h5` -4. 校验关键文件 md5 与 `:8080/api/status` - -环境变量(可选): - -| 变量 | 默认值 | 说明 | -|------|--------|------| -| `H5_DEPLOY_HOST` | `root@ssh105` | 105 SSH 目标 | -| `H5_REMOTE_DIR` | `/root/tkmind_go/ui/h5` | 105 代码路径 | -| `H5_SYSTEMD_SERVICE` | `goose-h5` | systemd 服务名 | -| `SKIP_BUILD` | `0` | `1` 跳过远端 build | -| `NO_RESTART` | `0` | `1` 不重启服务 | - -package.json 中的 `pnpm deploy:105` 等价于本地执行 `bash scripts/sync-to-105.sh`(需本机能 SSH 到 105,或已在 Studio 上)。 - -## 7. 发布过程与影响窗口 - -全量 `./rsync_to_server.sh` 典型耗时 **5~10 分钟**。 - -| 阶段 | 用户可见影响 | -|------|----------------| -| rsync + npm install + build | 无(旧进程仍在跑) | -| 本地 Portal 重启 | **8081 短暂不可用**,约 10~40 秒;主流量可能短暂 502 | -| 105 goose-h5 重启 | **灰度流量(约 5%)** 短暂不可用 | -| Plaza 重启 | plaza.tkmind.cn 可能短暂不可用;与 g2 主聊天无关 | - -入口反代会对不健康上游做健康检查,105 重启期间可能暂时从池中摘除。 - -## 8. 发布后验证 - -### 8.1 健康检查 - -```bash -curl -s http://127.0.0.1:8081/api/status -curl -s http://127.0.0.1:18080/api/status -curl -s https://g2.tkmind.cn/api/status -``` - -### 8.2 确认命中哪台上游 - -g2 响应头 `X-Memind-Upstream`: - -- `127.0.0.1:8081` → 本地 Mac 1.6 机器 -- `127.0.0.1:18080` → 105 - -```bash -curl -s -D - -o /dev/null https://g2.tkmind.cn/api/status | grep -i x-memind-upstream -``` - -### 8.3 业务冒烟 - -按本次改动选手动验证,例如: - -- 打开 g2 聊天页,测试新功能 -- 登录 / 微信 OAuth(若动到 auth) -- MindSpace 页面读写(若动到 pages) -- Plaza 列表(若动到 plaza) - -### 8.4 日志 - -```bash -# 本地 Portal -tail -f ~/Library/Logs/memind-portal.log - -# Studio Plaza -tail -f ~/Library/Logs/plaza-prod.log - -# 105(经 ssh105) -ssh ssh105 'journalctl -u goose-h5 -n 50 --no-pager' -``` - -## 9. 回滚思路 - -项目没有一键回滚脚本,常见做法: - -1. **代码回滚:** 在本地 git 回到上一个 good commit,`./rsync_to_server.sh` 再发一版 -2. **仅本地主机:** 若 105 有问题,可临时把流量全部收回主机(见 [g2-load-balancing.md](./g2-load-balancing.md)) -3. **紧急恢复 Portal:** 若 8081 挂了,见 [隔离规程 · 事故恢复](./service-isolation-runbook.md#事故恢复最小步骤) - -发布前建议打 tag 或记录当前 commit,便于回滚: - -```bash -git rev-parse HEAD -git tag -a release-2026-06-19 -m "before voice UI deploy" -``` - -## 10. 其它发布路径(非 g2 主站) - -### 10.1 同步到局域网测试机 - -仅源码同步到 `192.168.1.9` 上的 test 目录,**不是生产**: - -```bash -bash scripts/deploy-to-test-host.sh --dry-run -bash scripts/deploy-to-test-host.sh -``` - -目标:`test-memind` / `test-memindadm` / `test-memindplaza`。同步后需按该机习惯手动重启服务。 - -### 10.2 Plaza 105 入口转发 - -```bash -pnpm deploy:plaza-105 -``` - -`plaza.tkmind.cn` 一样是 `105 -> Studio` 的入口模式,只是它对应的是 Plaza 前台而不是管理后台。 -当前真实链路见 [Plaza 入口与发布](./plaza-local.md)。 - -### 10.3 gadm / memind_adm - -`gadm.tkmind.cn` 的生产入口和 Plaza 相同,也是 `105 -> Studio`。 -更完整的操作手册见 [gadm 入口与发布](./gadm-local.md)。 - -### 10.4 已废弃 / 不可用 - -| 命令 | 状态 | -|------|------| -| `pnpm deploy:prod` | 指向 `../../deploy/deploy-h5-prod.sh`,当前仓库旁路不存在,**勿用** | - -## 11. 推荐发布 SOP(标准作业) - -适合大多数功能迭代的固定步骤: - -```text -1. 在测试端口完成开发与自测(18081,勿占 8081) -2. pnpm run build && pnpm test -3. ./rsync_to_server.sh --dry-run ← 看清将要同步什么 -4. 确认无多余改动、无数据库破坏性操作 -5. ./rsync_to_server.sh ← 全量 Studio + 105 -6. curl 健康检查 + 浏览器冒烟 -7. 观察 5~10 分钟日志,确认无 ERROR 尖峰 -``` - -**保守版(先主后副):** - -```text -1~4 同上 -5. ./rsync_to_server.sh --only-100 -6. 冒烟通过后 -7. ./rsync_to_server.sh --only-105 -``` - -## 12. 相关文档 - -| 文档 | 内容 | -|------|------| -| [service-isolation-runbook.md](./service-isolation-runbook.md) | 生产 / 测试端口隔离、禁止事项、事故恢复 | -| [g2-load-balancing.md](./g2-load-balancing.md) | g2 权重、105 隧道、灰度比例调整 | -| [local-dev.md](./local-dev.md) | 本地开发端口与 preview | -| [plaza-local.md](./plaza-local.md) | Plaza 部署与 Tunnel | - ---- - -**维护说明:** 若部署脚本路径、主机名或 systemd 服务名变更,请同步更新本文与 `rsync_to_server.sh` 头部注释。 +## 产物发布流程 + +1. 本地 commit 当前改动。 +2. 本机构建 runtime:`node scripts/build-portal-runtime.mjs`(发布脚本默认会自动执行)。 +3. 校验 artifact 至少包含:`server.mjs`、`mindspace-sandbox-mcp.mjs`、`dist/`、`scripts/run-memind-portal-prod.sh`。 +4. 本地生成 `memind-portal-runtime-.tar.gz` 和发布清单。 +5. 通过 `scp` 上传到 103 的 `incoming/memind-portal-runtime/`。 +6. 103 备份当前 `/Users/john/Project/Memind` 全目录 + 持久目录。 +7. 停止旧 Portal,解包 runtime artifact 到新的 live 目录,继承 `.env` / `MindSpace/` / `data/` / `users/` / `.tailscale/` / `public/plaza-covers/` / `logs/`。 +8. 更新 LaunchAgent 指向 `${APP_DIR}/scripts/run-memind-portal-prod.sh`。 +9. 启动 Portal,检查 `127.0.0.1:8081/api/status` 为 200。 +10. 旧源码 live 目录移入 `archives/`,线上不再保留可运行源码树。 + +## 禁止事项 + +- 禁止 `rsync_to_server.sh` +- 禁止 `bash scripts/release-prod.sh`(源码包发布已停用) +- 禁止手工拖文件覆盖 103 +- 禁止在 103 直接改源码后继续跑 +- 禁止跳过备份和健康检查 + +## 相关文档 + +- [Portal 无源码迁移说明](no-source-portal-migration.md) +- [开发环境规则](../DEVELOPMENT_RELEASE_RULES.md) +- [测试发布规则](../TEST_RELEASE_RULES.md) +- [生产发布规则](../PRODUCTION_RELEASE_RULES.md) diff --git a/docs/service-isolation-runbook.md b/docs/service-isolation-runbook.md index dfc9913..3a27bb3 100644 --- a/docs/service-isolation-runbook.md +++ b/docs/service-isolation-runbook.md @@ -1,4 +1,4 @@ -> **生产环境警示:当前目录 `/Users/john/Project/Memind` 为生产目录与生产环境,禁止重启服务,所有操作必须谨慎并优先避免影响在线流量。** +> **隔离说明:这份文档只讲生产 / 测试 / 预览边界和事故恢复,不是本地联调手册。要做本机开发,请看 [docs/local-dev.md](./local-dev.md)。** # 生产 / 测试 / 预览隔离规程 @@ -8,14 +8,14 @@ | 环境 | 目录 | 用途 | 端口 | |------|------|------|------| -| 生产 | `/Users/john/Project/Memind` | `g2.tkmind.cn` 当前在线服务(阿里云解析 → 105 → 本地 Mac 1.6) | `8081` | -| 生产 Plaza | `/Users/john/Project/Memind` + Plaza | `plaza.tkmind.cn` 当前在线服务(105 反代到 Studio) | `3001` | +| 生产 | `/Users/john/Project/Memind` | `g2.tkmind.cn` 当前在线服务(阿里云解析 → 105 → 本机 Mac) | `8081` | +| 生产 Plaza | `/Users/john/Project/Memind` + Plaza | `plaza.tkmind.cn` 当前在线服务 | `3001` | | 生产入口 | `/Users/john/Project/Memind/scripts/g2-lb.Caddyfile` | 105 转发入口 / 反代配置 | `8090` | -| 测试 Portal | `/Users/john/Project/test/Memind` | 开发预览 API / Portal | `18081` | -| 测试 Vite | `/Users/john/Project/test/Memind` | 开发预览前端 | `15173` | -| 测试 Admin | `/Users/john/Project/test/Memind` | 开发预览后台 | `18082` | -| 测试 Plaza | `/Users/john/Project/test/Memind` | 开发预览 Plaza | `13001` | -| 测试 Ops | `/Users/john/Project/test/Memind` | 开发预览 Ops | `13002` | +| 测试 Portal | `/Users/john/PycharmProjects/test/test-memind` | 开发预览 API / Portal | `18081` | +| 测试 Vite | `/Users/john/PycharmProjects/test/test-memind` | 开发预览前端 | `15173` | +| 测试 Admin | `/Users/john/PycharmProjects/test/test-memind` | 开发预览后台 | `18082` | +| 测试 Plaza | `/Users/john/PycharmProjects/test/test-memind` | 开发预览 Plaza | `13001` | +| 测试 Ops | `/Users/john/PycharmProjects/test/test-memind` | 开发预览 Ops | `13002` | 硬规则: @@ -63,8 +63,8 @@ g2.tkmind.cn 生产请求 | 项目目录 | 记忆含义 | |----------|----------| | `/Users/john/Project/Memind` | 生产项目的开发记忆 | -| `/Users/john/Project/test/Memind` | 测试项目的开发记忆 | -| `/Users/john/Project/test/test_tkmind_go` | 测试 Goose 的开发记忆 | +| `/Users/john/PycharmProjects/test/test-memind` | 测试项目的开发记忆 | +| 测试 Goose 项目目录(按本机实际路径填写) | 测试 Goose 的开发记忆 | 硬规则: @@ -109,7 +109,7 @@ scripts/install-prod-services.sh 优先在测试目录进行: ```bash -cd /Users/john/Project/test/Memind +cd /Users/john/PycharmProjects/test/test-memind ``` 启动前先确认生产还在: @@ -145,7 +145,7 @@ pnpm dev 如果只改前端样式,优先只启动 Vite: ```bash -cd /Users/john/Project/test/Memind +cd /Users/john/PycharmProjects/test/test-memind VITE_PORT=15173 \ H5_PUBLIC_BASE_URL=http://127.0.0.1:15173 \ VITE_MINDSPACE_BASE=http://127.0.0.1:15173 \ @@ -171,6 +171,68 @@ pnpm dev:vite -- --host 127.0.0.1 --port 15173 测试 H5 如果要连测试 Goose,必须在测试目录 `.env` 中显式设置对应地址,不能指向生产 Goose。 +## goosed extension 堆积与文件句柄告警 + +如果 H5 / MindSpace 侧出现下面这类错误: + +```text +会话策略同步失败:{"message":"Failed to add extension: IO error: Too many open files (os error 24)"} +``` + +先不要直接重启生产服务。这个报错在本机更常见的含义是: + +- `goosed agent` 下面堆积了过多旧的 extension 子进程。 +- macOS `launchctl limit maxfiles` 软上限偏低时,`/agent/add_extension` 会先撞到文件句柄上限。 +- 如果恢复会话时无条件重启 agent,会把旧子进程堆得更快。 + +先做只读确认: + +```bash +launchctl limit maxfiles +lsof -nP -iTCP -sTCP:LISTEN | rg '18006|18007|8081|3001|8090' +for pid in 51613 51657; do + echo "PID $pid $(ps -p $pid -o command=)" + lsof -n -p $pid 2>/dev/null | wc -l +done +ps -axo pid,ppid,etime,command | rg 'mindspace-sandbox-mcp|mcp-server-fetch|goosed agent' +``` + +判断口径: + +- `8081`、`3001`、`8090` 正常监听,说明主站未必有故障。 +- 如果某个 `goosed agent` 的 FD 数已经逼近或超过 `256`,优先怀疑 extension 子进程堆积。 +- 如果同一个 `MindSpace/` 在同一个 `goosed` 父进程下出现多个 `mindspace-sandbox-mcp.mjs`,通常只应保留最新一个。 + +安全处理顺序: + +1. 先确认主站端口 `8081`、`3001`、`8090` 正常,不要把 route 问题误判成全站宕机。 +2. 不先杀 `goosed agent` 主进程,也不要碰 `8081` 上的 `server.mjs`。 +3. 优先只清理同父进程、同 `MindSpace/` 下重复堆积的旧 `mindspace-sandbox-mcp.mjs` 子进程,以及重复的旧 `mcp-server-fetch` 子进程。 +4. 清理后立刻复查 `18006` / `18007` 的 `/status` 和 `goosed` FD 数,确认 agent 仍存活。 + +这次线上排查的经验值: + +- 两个 `goosed agent` 的 FD 数曾达到 `263` / `209`,而 `launchctl limit maxfiles` 软上限是 `256`。 +- 仅清理重复 extension 子进程后,FD 数降到 `96` / `77`,`18006` 和 `18007` 的 `/status` 仍为 `ok`。 +- 代码层已改为仅在会话策略真正变化时才触发 agent 重启,减少恢复会话时继续堆积 extension 子进程的概率。 + +自动巡检: + +- `scripts/monitor-goosed-fds.mjs` 每次检查 `goosed agent` FD 数和 `mindspace-sandbox-mcp.mjs` 子进程数。 +- `scripts/install-goosed-monitor.sh` 会安装 `~/Library/LaunchAgents/cn.tkmind.goosed-monitor.plist`,默认每 60 秒运行一次。 +- 默认策略只清理: + - orphan 的 sandbox MCP 子进程; + - 同一父进程、同一 `MindSpace/` 下超过 2 个的重复 sandbox MCP; + - 总 sandbox MCP 数超过 80 时最旧的一批。 +- 默认不按存活时间清理单个老进程,避免误伤长会话。 +- goosed FD 超过 `GOOSED_FD_WARN=180` 只记录 warning;超过 `GOOSED_FD_RESTART=3200` 才滚动重启对应 goosed。 + +硬规则: + +- 不要因为 `Too many open files` 先去重启 `8081` 生产 Portal。 +- 不要在未确认影响窗口前直接重启 `goosed agent` 主进程。 +- 优先清理“明显重复”的 extension 子进程,而不是清空所有 agent 子进程。 + ## 数据库口径 短期如果沿用同一个 RDS 库,只允许做 UI 和非破坏性流程预览。 @@ -190,7 +252,7 @@ pnpm dev:vite -- --host 127.0.0.1 --port 15173 最小检查: -1. 在测试端口(如 `18081`)完成开发与预览,不要在生产目录跑 `pnpm dev`。 +1. 在测试端口(如 `18081`)完成开发与预览;本机联调目录请看 [docs/local-dev.md](./local-dev.md),不要把这份规程当成开发启动手册。 2. `pnpm run build` + `pnpm test`。 3. `curl -s http://127.0.0.1:8081/api/status` 确认生产仍在线。 4. `./rsync_to_server.sh --dry-run` 预览 diff,确认后再执行正式发布。 @@ -220,10 +282,3 @@ curl -s http://127.0.0.1:8081/api/status ```bash curl -s http://127.0.0.1:8090/api/status ``` - -如果要同时确认 Plaza 和 `gadm`,直接检查: - -```bash -curl -s https://plaza.tkmind.cn/plaza -curl -s https://gadm.tkmind.cn/healthz -``` diff --git a/docs/studio-goose-permission-runbook.md b/docs/studio-goose-permission-runbook.md new file mode 100644 index 0000000..9b58ff5 --- /dev/null +++ b/docs/studio-goose-permission-runbook.md @@ -0,0 +1,304 @@ +# Studio Goose 权限与 Session 排障 Runbook + +> 目的:以后再遇到“库里有权限,但 Agent 在对话里说自己没有某能力”的问题,直接按这份 runbook 查,不要从头摸。 + +> 2026-06-22 已确认的稳定事实: + +- Studio 上 Goose 是双实例:`18006` 主、`18007` 备用/第二实例。 +- 105 不跑 Goose;105 只是无状态 H5 前端,代理回 Studio。 +- Studio 可直接 SSH:`ssh john@58.38.22.103`。不要为了查 Studio 再绕 105。 +- H5 会话权限不是只看数据库,还要看会话启动时实际下发给 Goose 的 `extension_overrides`。 + +## 这次案例结论 + +用户: + +- `username`: `wx_ul610et8` +- `display_name`: `唐` +- `user_id`: `a70ff537-8908-486e-9b6c-042e07cc25db` + +线上生产结论: + +- `h5_capability_grants` 里该用户有 `aider=1` +- 角色默认 `role:user` 也有 `aider=1` +- `h5_user_policies` 里该用户没有单独禁用项 +- 角色策略是: + - `workspace_access=readwrite` + - `network_egress=allow` + - `goose_mode=auto` + - `api_lockdown=true` + +按当前代码实时计算 `getAgentSessionPolicy(userId)` 的结果: + +- `capabilities.aider = true` +- `developer` 扩展应带 `write/edit/shell/tree/read_image` +- `extensionOverrides` 里应出现 `aider` + +因此: + +- 如果对话里 Agent 说“没有 `aider` / 没有 `write_file` / 没有 `edit_file`”,优先怀疑是 **旧会话扩展未同步**、**Goose 实例漂移** 或 **子会话能力不一致**,不是先怀疑 DB 权限没开。 +- 注意:`aider` grant 为 true 只表示会话可挂 `aider` 平台扩展;Goose 的 `aider` 执行器仍会按任务/目录做运行时保护。MindSpace 用户工作区默认禁止 `aider` 直接执行,除非显式打开 `GOOSE_AIDER_ALLOW_MINDSPACE=1`。 + +## 2026-06-22 实际修复记录 + +用户贴出的异常话术对应 session: + +- `agent_session_id`: `20260620_45` +- `title`: `西湖跑步喝茶计划` +- `goosed_node`: `0` +- 实例:Studio 主 Goose `18006` + +异常表现: + +- Goose session 里有 `aider` 扩展 +- `developer.available_tools` 只有 `read_image` +- 缺少 `sandbox-fs` +- 因此 Agent 看不到 `write_file` / `edit_file` + +已执行修复: + +- 用生产代码的 `getAgentSessionPolicy(userId)` 重新计算该用户应有能力 +- 对 `20260620_45` 执行 `reconcileAgentSession(...)` +- 修复后 `20260620_45` 已挂上 `sandbox-fs` +- `sandbox-fs.available_tools` 已包含: + - `read_file` + - `write_file` + - `edit_file` + - `create_dir` + - `list_dir` + - `private_data_info` + - `private_data_schema` + - `private_data_query` + - `private_data_execute` + +这说明原问题已经定位并修复到具体旧会话:不是用户 DB 权限缺失,而是旧 Goose session 的扩展集没有包含当前策略要求的 `sandbox-fs`。 + +同时确认: + +- `aider__code` 对 MindSpace 页面类任务返回 `Selected engine: blocked` 是运行时保护策略,不是这个用户没有 `aider` grant。 +- 相关保护在两处存在: + - `/Users/john/Project/tkmind_go/crates/goose/src/agents/platform_extensions/aider.rs` + - `/Users/john/Project/tkmind_go/deploy/coding_router.sh` +- 对 MindSpace 页面/HTML/游戏生成任务,应使用 `sandbox-fs` 的 `write_file/edit_file` 或发布/App 工具,不应强制走 `aider`。 + +## 2026-06-22 深入排查:其他用户影响面 + +排查口径: + +- 不要只看 Goose SQLite 的 `sessions.extension_data`,它是历史快照/扩展记忆结构,实时状态以 Goose API 为准。 +- 正确实时检查方式:按 `h5_user_sessions.goosed_node` 路由到 `18006` 或 `18007`,调用 `/sessions/{id}/extensions`。 + +最近 120 条 active 用户 session 的实时抽样结果: + +- 可访问 session:120/120 +- 缺 `sandbox-fs`:24/120 +- node 0:89 条,缺 21 条 +- node 1:31 条,缺 3 条 +- 缺失集中在 2026-06-18 到 2026-06-20 的旧会话;2026-06-21 到 2026-06-22 新会话抽样未见缺失。 + +受影响样本用户: + +- `wx_ul610et8 / 唐`:3 条旧会话仍缺 `sandbox-fs` +- `wx_mk4zzaps / Mark 刘名章`:1 条旧会话缺 `sandbox-fs` +- `wx_5dljrg1a / 123`:1 条旧会话缺 `sandbox-fs` +- `john / John`:若干旧测试/管理会话缺 `sandbox-fs` +- `admin / 管理员`:若干管理会话缺 `sandbox-fs` + +结论: + +- 这不是“当前双 Goose 负载随机丢能力”的现象。 +- 更像是 `sandbox-fs` 上线/策略迁移前后创建的旧 session 没有自动补齐扩展。 +- 双 Goose 会放大排查复杂度,因为每个 session 固定在自己的 `goosed_node`,必须去对应节点查实时 extensions;但只要 `start/resume/reply` 都走策略同步,就不会因为双 Goose 本身产生跨节点同步问题。 + +代码兜底: + +- `/agent/start` 已同步策略。 +- `/agent/resume` 已同步策略。 +- 2026-06-22 新增本地修复:`/sessions/:id/reply` 前也调用 `reconcileSessionPolicyForUser(...)`。 +- 这样旧会话即使没有显式经过前端 `resumeSession()`,在下一次发消息前也会先补齐 `sandbox-fs` 等当前策略要求的扩展。 + +验证: + +- `node --check server.mjs` 通过 +- `node --check tkmind-proxy.mjs` 通过 +- `node --test session-reconcile.test.mjs` 通过,6/6 +- `node --test policies.test.mjs capabilities.test.mjs user-memory-profile.test.mjs` 通过,38/38 +- 全量 `npm test -- session-reconcile.test.mjs` 会被项目脚本展开成全量测试,目前失败在既有 `llm-providers.test.mjs` 断言:实际返回 `Aider 未启用模型绑定`,不是本次 session policy sync 改动引入。 + +## 代码链路 + +关键代码: + +- [user-auth.mjs](../user-auth.mjs) + - `resolveUserCapabilities()` + - `resolveUserPolicies()` + - `getAgentSessionPolicy()` +- [policies.mjs](../policies.mjs) + - `applyPoliciesToCapabilities()` +- [capabilities.mjs](../capabilities.mjs) + - `buildAgentExtensionPolicy()` +- [tkmind-proxy.mjs](../tkmind-proxy.mjs) + - `POST /agent/start` + - `POST /agent/resume` + - `resolveTarget(sessionId)` + +关键判断: + +- `workspace_access=readonly` 会强制关闭: + - `shell` + - `filesystem` + - `static_publish` + - `aider` +- `POST /agent/start` 会实时计算 `getAgentSessionPolicy()` 并下发 `extension_overrides` +- `POST /agent/resume` 恢复旧会话时,必须结合 session 所在 Goose 实例继续看,不要只看数据库 + +## 架构事实 + +Studio H5 侧: + +- 运行中 Portal 的 `TKMIND_API_TARGET` 指向主 Goose:`https://127.0.0.1:18006` +- 运行中 Portal 的 `TKMIND_API_TARGET_1` 指向第二 Goose:`https://127.0.0.1:18007` +- `server.mjs` 会构造: + - `API_TARGETS = [TKMIND_API_TARGET, TKMIND_API_TARGET_1]` +- 105 或外部机器可以通过 Tailscale/内网目标代理到 Studio,但排查 Studio 时优先直接 SSH 到 Studio。 + +Session 路由: + +- `h5_user_sessions.goosed_node` + - `0` 表示主 Goose + - `1` 表示第二 Goose +- `tkmind-proxy.resolveTarget(sessionId)` 会按 `goosed_node` 把 session 路由回对应 Goose + +## 标准排查顺序 + +### 1. 先查用户真实权限,不要先猜 + +查 `h5_capability_grants`: + +- 用户级覆盖 +- `role:user` 默认值 + +查 `h5_user_policies`: + +- 用户级覆盖 +- `role:user` 默认值 + +重点看: + +- `aider` +- `filesystem` +- `shell` +- `code_browse` +- `static_publish` +- `workspace_access` + +### 2. 再让线上代码实时算一次 session policy + +不要只看表。 + +必须直接调用: + +- `getAgentSessionPolicy(userId)` + +重点看结果里的: + +- `capabilities` +- `policies` +- `extensionOverrides` +- 是否真的有 `aider` +- `developer.available_tools` 是否真的包含 `write/edit` + +### 3. 再看 session 落到哪个 Goose 实例 + +查: + +- `h5_user_sessions.agent_session_id` +- `h5_user_sessions.goosed_node` + +如果同一用户的不同 session 分布在 `0` 和 `1` 两个节点: + +- 说明必须分别检查 `18006` 和 `18007` +- 不能只看一个 Goose 的状态就下结论 + +### 4. 如果用户说“这条对话里没有能力” + +优先排查: + +1. 这条对话是否是旧 session +2. 这条对话是否落在第二 Goose +3. 两个 Goose 的扩展配置是否漂移 +4. 是否进入了能力被收窄的子会话 + +## 这次已确认的反常点 + +- 线上 DB 与 `getAgentSessionPolicy()` 都显示:`wx_ul610et8` 应有 `aider` 和 `write/edit` +- 但用户贴出的某条对话话术显示:Agent 自述没有 `write_file / edit_file` +- 因为 Studio 是双 Goose,不是单 Goose,所以这类现象优先按“实例间配置漂移”处理 + +## 下次直接复用的命令清单 + +### 查 105 H5 当前指向的 Goose / RDS + +优先查 Studio: + +```bash +ssh -o BatchMode=yes -o ConnectTimeout=8 john@58.38.22.103 \ + "cd /Users/john/Project/Memind && ps eww -p \$(pgrep -f 'node .*server.mjs' | head -1) | tr ' ' '\n' | grep '^TKMIND_API_TARGET'" +``` + +如果确实要查 105: + +```bash +ssh -i ~/.ssh/id_ed25519 root@120.26.184.105 \ + "sed -n '1,80p' /root/tkmind_go/ui/h5/.env" +``` + +### 查生产用户权限 + +```bash +ssh john@58.38.22.103 +cd /Users/john/Project/Memind + +node -e 'process.loadEnvFile(".env"); const mysql=require("mysql2/promise"); (async()=>{ const pool=mysql.createPool(process.env.DATABASE_URL); const userId="a70ff537-8908-486e-9b6c-042e07cc25db"; const [userCaps]=await pool.query("SELECT capability_key, allowed FROM h5_capability_grants WHERE subject_type=\"user\" AND subject_id=? ORDER BY capability_key",[userId]); const [roleCaps]=await pool.query("SELECT capability_key, allowed FROM h5_capability_grants WHERE subject_type=\"role\" AND subject_id=\"user\" ORDER BY capability_key"); const [userPolicies]=await pool.query("SELECT policy_key, policy_value FROM h5_user_policies WHERE subject_type=\"user\" AND subject_id=? ORDER BY policy_key",[userId]); const [rolePolicies]=await pool.query("SELECT policy_key, policy_value FROM h5_user_policies WHERE subject_type=\"role\" AND subject_id=\"user\" ORDER BY policy_key"); console.log(JSON.stringify({userCaps,roleCaps,userPolicies,rolePolicies},null,2)); await pool.end(); })();' +``` + +### 让线上代码直接算最终 session policy + +```bash +ssh john@58.38.22.103 +cd /Users/john/Project/Memind + +node --input-type=module -e 'import process from "node:process"; process.loadEnvFile(".env"); const { createDbPool } = await import("./db.mjs"); const { createUserAuth } = await import("./user-auth.mjs"); const pool = createDbPool(); const userAuth = createUserAuth(pool, { h5Root: process.cwd() }); const policy = await userAuth.getAgentSessionPolicy("a70ff537-8908-486e-9b6c-042e07cc25db"); console.log(JSON.stringify(policy, null, 2)); await pool.end();' +``` + +### 查用户最近 session 及节点分布 + +```bash +ssh john@58.38.22.103 +cd /Users/john/Project/Memind + +node -e 'process.loadEnvFile(".env"); const mysql=require("mysql2/promise"); (async()=>{ const pool=mysql.createPool(process.env.DATABASE_URL); const [rows]=await pool.query("SELECT agent_session_id, user_id, goosed_node, created_at FROM h5_user_sessions WHERE user_id = ? ORDER BY created_at DESC LIMIT 20", ["a70ff537-8908-486e-9b6c-042e07cc25db"]); console.log(JSON.stringify(rows,null,2)); await pool.end(); })();' +``` + +### 查某条 Goose session 实际扩展 + +先用 `h5_user_sessions.goosed_node` 判断端口: + +- `0` -> `18006` +- `1` -> `18007` + +然后查扩展: + +```bash +ssh john@58.38.22.103 \ + 'curl -sk -H "X-Secret-Key: local-dev-secret" https://127.0.0.1:18006/sessions/20260620_45/extensions | python3 -m json.tool' +``` + +## 必记事项 + +- 以后再看到“Agent 说自己没有 `aider` / `write_file` / `edit_file`”,不要直接重新从数据库查起。 +- 先看: + - 这条 session 在哪个 `goosed_node` + - 那个 Goose 实例是不是 `18006` 还是 `18007` + - 那个实例实际拿到的 `extension_overrides` +- 如果文档还没补全第二 Goose 的运行环境注入方式,优先继续补这里,不要让同样的排查再来一遍。 diff --git a/fetch_news.py b/fetch_news.py deleted file mode 100644 index 84ed785..0000000 --- a/fetch_news.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -"""Quick news fetcher - single file, no deps needed.""" -import json -import urllib.request -import ssl -import sys - -ctx = ssl.create_default_context() -ctx.check_hostname = False -ctx.verify_mode = ssl.CERT_NONE - -H = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"} - -def get(url): - try: - req = urllib.request.Request(url, headers=H) - with urllib.request.urlopen(req, timeout=15, context=ctx) as r: - return r.read().decode("utf-8", errors="replace") - except Exception as e: - return None - -def getj(url): - d = get(url) - return json.loads(d) if d else None - -def show_results(): - out = [] - out.append("=" * 65) - out.append(" 🔥 今 日 热 点 新 闻 — 2026年6月20日 (周六)") - out.append("=" * 65) - - # 1. Hacker News - hn = getj("https://hacker-news.firebaseio.com/v0/topstories.json") - if hn: - out.append("\n 🌍 Hacker News (科技)") - out.append(" " + "─" * 60) - for i, sid in enumerate(hn[:10], 1): - item = getj(f"https://hacker-news.firebaseio.com/v0/item/{sid}.json") - if item: - t = item.get("title", "") - s = item.get("score", 0) - if t: - out.append(f" {i:2d}. {t[:75]} (👍{s})") - - # 2. Reddit worldnews - rd = get("https://www.reddit.com/r/worldnews/hot/.json?limit=8") - if rd: - try: - data = json.loads(rd) - out.append("\n 📰 Reddit /r/worldnews (国际新闻)") - out.append(" " + "─" * 60) - for i, c in enumerate(data["data"]["children"][:8], 1): - p = c["data"] - t = p.get("title", "") - s = p.get("score", 0) - if t: - out.append(f" {i:2d}. {t[:75]} (👍{s})") - except: - pass - - # 3. GitHub - gh = getj("https://api.github.com/search/repositories?q=stars:>100000&sort=stars&order=desc&per_page=10") - if gh: - out.append("\n ⭐ GitHub 超级热门开源项目") - out.append(" " + "─" * 60) - for i, r in enumerate(gh.get("items", []), 1): - n = r.get("full_name", "") - s = r.get("stargazers_count", 0) - d = (r.get("description") or "")[:60] - out.append(f" {i:2d}. {n} (⭐{s})") - if d: - out.append(f" {d}") - - out.append("\n" + "=" * 65) - out.append(" 数据来源: Hacker News API / Reddit / GitHub API") - out.append("=" * 65) - - print("\n".join(out)) - -if __name__ == "__main__": - show_results() diff --git a/llm-providers.mjs b/llm-providers.mjs index e3fe73a..719928c 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -1,4 +1,8 @@ import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; import { Agent, fetch as undiciFetch } from 'undici'; export const CUSTOM_PROVIDER_ID = '__custom__'; @@ -44,9 +48,47 @@ export const LLM_PROVIDER_CATALOG = [ defaultModel: 'claude-sonnet-4-20250514', models: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'], }, + { + id: 'custom_qwen', + label: 'Qwen VL (通义千问)', + kind: 'builtin', + apiKeyEnv: null, + // stored as custom provider; apiUrl is injected at createKey time + apiUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + defaultModel: 'qwen-vl-max', + models: ['qwen-vl-max', 'qwen-vl-max-latest', 'qwen-vl-plus', 'qwen-plus', 'qwen-turbo'], + }, +]; + +export const LLM_EXECUTOR_CATALOG = [ + { + id: 'goose', + label: 'Goose', + description: '策略判断、分析、总结和轻量任务编排', + purposes: ['default'], + }, + { + id: 'aider', + label: 'Aider', + description: '小范围代码修改和补丁式修复', + purposes: ['default'], + }, + { + id: 'openhands', + label: 'OpenHands', + description: '复杂仓库任务、多文件改造和命令执行', + purposes: ['default'], + }, ]; const catalogById = Object.fromEntries(LLM_PROVIDER_CATALOG.map((item) => [item.id, item])); +const executorById = Object.fromEntries(LLM_EXECUTOR_CATALOG.map((item) => [item.id, item])); + +const BUILTIN_PROVIDER_TEST_URLS = { + custom_deepseek: process.env.DEEPSEEK_API_BASE_URL ?? 'https://api.deepseek.com/v1', + openai: process.env.OPENAI_API_BASE_URL ?? 'https://api.openai.com/v1', + openrouter: process.env.OPENROUTER_API_BASE_URL ?? 'https://openrouter.ai/api/v1', +}; const insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false }, @@ -113,6 +155,15 @@ export function normalizeApiUrl(raw) { return `http://${trimmed.replace(/\/+$/, '')}`; } +export function resolveApiBaseUrl(apiUrl) { + const normalized = normalizeApiUrl(apiUrl); + if (!normalized) return ''; + if (normalized.endsWith('/chat/completions')) { + return normalized.slice(0, -'/chat/completions'.length).replace(/\/+$/, ''); + } + return normalized.replace(/\/+$/, ''); +} + export function resolveChatCompletionsUrl(apiUrl) { const normalized = normalizeApiUrl(apiUrl); if (!normalized) return ''; @@ -263,6 +314,7 @@ function rowToPublic(row, catalogItem, apiKeyForMask) { goosedProviderId: row.goosed_provider_id ?? null, status: row.status, isSelected: Boolean(row.is_selected), + isVisionSelected: Boolean(row.is_vision_selected), apiKeyMasked: maskApiKey(apiKeyForMask), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), @@ -290,6 +342,258 @@ function profileFromRow(row, decryptRow) { }; } +function normalizeExecutor(raw) { + const executor = String(raw ?? '').trim().toLowerCase(); + return executorById[executor] ? executor : null; +} + +function normalizePurpose(raw) { + return String(raw ?? 'default').trim() || 'default'; +} + +function rowToExecutorBinding(row, keyPublic = null) { + const executor = normalizeExecutor(row.executor); + const meta = executorById[executor] ?? null; + return { + id: row.id, + executor, + executorLabel: meta?.label ?? row.executor, + executorDescription: meta?.description ?? '', + purpose: row.purpose ?? 'default', + providerKeyId: row.provider_key_id ?? null, + providerName: keyPublic?.name ?? null, + providerLabel: keyPublic?.providerLabel ?? null, + providerId: keyPublic?.providerId ?? null, + model: row.model ?? '', + enabled: Boolean(row.enabled), + availableModels: keyPublic?.models ?? [], + createdAt: row.created_at ? Number(row.created_at) : null, + updatedAt: row.updated_at ? Number(row.updated_at) : null, + }; +} + +function executorEnvForProfile(executor, profile, model, { includeSecret = false } = {}) { + const apiKeyValue = includeSecret ? profile.apiKey : '[hidden]'; + const baseUrl = resolveApiBaseUrl(profile.apiUrl); + const providerId = profile.providerId; + const common = { + TKMIND_EXECUTOR: executor, + TKMIND_EXECUTOR_PROVIDER: providerId, + TKMIND_EXECUTOR_MODEL: model, + }; + if (baseUrl) common.TKMIND_EXECUTOR_API_BASE = baseUrl; + if (includeSecret) common.TKMIND_EXECUTOR_API_KEY = profile.apiKey; + + if (executor === 'aider') { + const env = { + ...common, + AIDER_MODEL: model, + }; + if (providerId === 'anthropic') { + env.AIDER_ANTHROPIC_API_KEY = apiKeyValue; + env.ANTHROPIC_API_KEY = apiKeyValue; + } else { + env.AIDER_OPENAI_API_KEY = apiKeyValue; + env.OPENAI_API_KEY = apiKeyValue; + if (providerId === 'openrouter') env.OPENROUTER_API_KEY = apiKeyValue; + if (providerId === 'custom_deepseek') env.DEEPSEEK_API_KEY = apiKeyValue; + } + if (baseUrl) { + env.AIDER_OPENAI_API_BASE = baseUrl; + env.OPENAI_API_BASE = baseUrl; + } + return env; + } + + if (executor === 'openhands') { + return { + ...common, + LLM_MODEL: model, + LLM_API_KEY: apiKeyValue, + ...(baseUrl ? { LLM_BASE_URL: baseUrl } : {}), + }; + } + + return { + ...common, + GOOSE_PROVIDER: profile.providerKind === 'custom' + ? (profile.goosedProviderId ?? profile.providerId) + : profile.providerId, + GOOSE_MODEL: model, + TKMIND_PROVIDER: profile.providerKind === 'custom' + ? (profile.goosedProviderId ?? profile.providerId) + : profile.providerId, + TKMIND_MODEL: model, + }; +} + +function launchLogDir() { + return path.join(os.tmpdir(), 'memindadm-launches'); +} + +function normalizeLaunchMode(mode) { + const normalized = String(mode ?? 'headless').trim().toLowerCase(); + return normalized === 'serve' ? 'serve' : 'headless'; +} + +function commandExists(command) { + const probe = spawnSync('sh', ['-lc', `command -v ${JSON.stringify(command).slice(1, -1)}`], { + stdio: 'ignore', + }); + return probe.status === 0; +} + +function spawnDetachedExecutor(plan, { logDir = launchLogDir() } = {}) { + if (!plan?.ok) { + return plan; + } + if (!plan.command) { + return { + ok: false, + executor: plan.executor ?? null, + message: '启动命令缺失', + }; + } + if (!commandExists(plan.command)) { + return { + ok: false, + executor: plan.executor ?? null, + message: `${plan.command} 未安装或不在 PATH 中`, + }; + } + fs.mkdirSync(logDir, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const logFile = path.join( + logDir, + `${plan.executor ?? 'executor'}-${stamp}-${crypto.randomUUID().slice(0, 8)}.log`, + ); + const fd = fs.openSync(logFile, 'a'); + try { + const child = spawn(plan.command, plan.args ?? [], { + cwd: plan.cwd ?? process.cwd(), + env: { + ...process.env, + ...(plan.env ?? {}), + }, + detached: true, + stdio: ['ignore', fd, fd], + }); + child.unref(); + return { + ok: true, + executor: plan.executor ?? null, + pid: child.pid ?? null, + command: plan.command, + args: plan.args ?? [], + cwd: plan.cwd ?? process.cwd(), + logFile, + notes: plan.notes ?? [], + }; + } catch (err) { + return { + ok: false, + executor: plan.executor ?? null, + message: err instanceof Error ? err.message : '启动失败', + logFile, + }; + } finally { + try { + fs.closeSync(fd); + } catch {} + } +} + +function isProcessAlive(pid) { + if (!pid || Number.isNaN(Number(pid))) return false; + try { + process.kill(Number(pid), 0); + return true; + } catch (err) { + if (err && typeof err === 'object' && 'code' in err && err.code === 'EPERM') return true; + return false; + } +} + +function launchStateKey(executor, purpose = 'default') { + return `${normalizeExecutor(executor)}:${normalizePurpose(purpose)}`; +} + +export function buildExecutorLaunchPlan(runtime, options = {}) { + if (!runtime?.ok) { + return { + ok: false, + executor: runtime?.executor ?? null, + message: runtime?.message ?? '运行配置未就绪', + }; + } + const mode = normalizeLaunchMode(options.mode ?? 'headless'); + const cwd = String(options.cwd ?? process.cwd()); + const instruction = String(options.instruction ?? '').trim(); + const env = runtime.env ?? {}; + + if (runtime.executor === 'goose') { + return { + ok: false, + executor: 'goose', + message: 'Goose 作为现有服务入口,不通过本方法启动', + }; + } + + if (runtime.executor === 'aider') { + const hasInstruction = Boolean(instruction); + return { + ok: true, + executor: 'aider', + cwd, + command: 'aider', + args: [ + '--model', + runtime.model, + ...(hasInstruction ? ['--message', instruction] : []), + '--yes-always', + ], + env, + notes: [ + 'Aider 通过统一模型中心提供的 API Key / Base URL 运行。', + '实际启动时建议在仓库目录中执行。', + ], + }; + } + + if (runtime.executor === 'openhands') { + if (mode === 'headless' && !instruction) { + return { + ok: false, + executor: 'openhands', + message: 'OpenHands headless 模式需要 instruction', + }; + } + const args = + mode === 'serve' + ? ['serve', '--mount-cwd'] + : ['--headless', '--json', '--override-with-envs', '--task', instruction]; + return { + ok: true, + executor: 'openhands', + cwd, + command: 'openhands', + args, + env, + notes: [ + mode === 'serve' + ? 'OpenHands GUI Server 依赖 Docker,适合人工交互调试。' + : 'Headless 模式适合后续和后台调度对接。', + ], + }; + } + + return { + ok: false, + executor: runtime.executor, + message: '不支持的执行器', + }; +} + async function goosedApiFetch(apiTarget, apiSecret, pathname, init = {}, fetchImpl = undiciFetch) { const url = new URL(pathname, apiTarget); const headers = { @@ -559,6 +863,8 @@ export function createLlmProviderService( pool, { apiTarget, apiSecret, encryptionKey, apiFetchImpl = undiciFetch } = {}, ) { + const launchStates = new Map(); + function catalogItem(providerId) { return catalogById[providerId] ?? null; } @@ -589,13 +895,53 @@ export function createLlmProviderService( return rows[0] ?? null; } + async function getExecutorBindingRow(executor, purpose = 'default') { + const normalizedExecutor = normalizeExecutor(executor); + if (!normalizedExecutor) return null; + const [rows] = await pool.query( + 'SELECT * FROM h5_llm_executor_bindings WHERE executor = ? AND purpose = ? LIMIT 1', + [normalizedExecutor, normalizePurpose(purpose)], + ); + return rows[0] ?? null; + } + async function clearSelected() { await pool.query('UPDATE h5_llm_provider_keys SET is_selected = 0, updated_at = ?', [ Date.now(), ]); } + async function getVisionRow() { + const [rows] = await pool.query( + 'SELECT * FROM h5_llm_provider_keys WHERE is_vision_selected = 1 AND status = ? LIMIT 1', + ['active'], + ); + return rows[0] ?? null; + } + + async function clearVisionSelected() { + await pool.query('UPDATE h5_llm_provider_keys SET is_vision_selected = 0, updated_at = ?', [ + Date.now(), + ]); + } + + // Lightweight in-memory flag so proxyFallback can skip DB query on text-only messages. + // Invalidated on every setVisionKey / clearVisionKey call. + let visionKeyConfiguredCache = null; + + // Cache last sync per row to avoid redundant Goosed /config/upsert calls on every request. + // Key: `${row.id}:${row.updated_at}`, value: { goosedProviderId, syncedAt }. + // Each sync makes 4-5 HTTP calls to Goosed; under load these accumulate open FDs on the + // Goosed side and can exhaust the OS file descriptor limit (EMFILE / error 24). + const syncRowCache = new Map(); + const SYNC_ROW_CACHE_TTL_MS = 30_000; + async function syncRow(row, fetchImpl = apiFetchImpl) { + const cacheKey = `${row.id}:${row.updated_at}`; + const cached = syncRowCache.get(cacheKey); + if (cached && Date.now() - cached.syncedAt < SYNC_ROW_CACHE_TTL_MS && fetchImpl === apiFetchImpl) { + return cached.goosedProviderId; + } const profile = profileFromRow(row, decryptRow); const goosedProviderId = await syncProfileToGoosed( apiTarget, @@ -609,9 +955,201 @@ export function createLlmProviderService( [goosedProviderId, goosedProviderId, Date.now(), row.id], ); } + if (fetchImpl === apiFetchImpl) { + syncRowCache.set(cacheKey, { goosedProviderId, syncedAt: Date.now() }); + } return goosedProviderId; } + async function syncRowWithModel(row, model, fetchImpl = apiFetchImpl) { + const originalModel = row.default_model; + row.default_model = model || row.default_model; + try { + return await syncRow(row, fetchImpl); + } finally { + row.default_model = originalModel; + } + } + + async function resolveExecutorProvider(executor, purpose = 'default', fetchImpl = apiFetchImpl) { + const binding = await getExecutorBindingRow(executor, purpose); + if (!binding?.enabled) { + return { ok: false, message: `${executorById[executor]?.label ?? executor} 未启用模型绑定` }; + } + if (!binding.provider_key_id) { + return { ok: false, message: `${executorById[executor]?.label ?? executor} 未绑定 Provider` }; + } + const row = await getRowById(binding.provider_key_id); + if (!row || row.status !== 'active') { + return { ok: false, message: '绑定的 Provider 不存在或已禁用' }; + } + const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row)); + if (!publicRow.models.includes(binding.model)) { + return { ok: false, message: '绑定模型不在当前 Provider 支持列表中' }; + } + try { + const goosedProviderId = await syncRowWithModel(row, binding.model, fetchImpl); + const profile = profileFromRow(row, decryptRow); + const providerId = + profile.providerKind === 'custom' + ? (goosedProviderId ?? profile.goosedProviderId ?? profile.providerId) + : profile.providerId; + return { + ok: true, + providerId, + model: binding.model, + source: 'executor_binding', + executor, + purpose: binding.purpose, + }; + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : '同步执行器模型配置失败', + }; + } + } + + async function resolveExecutorRuntimeConfig( + executor, + { purpose = 'default', includeSecret = false } = {}, + ) { + const normalizedExecutor = normalizeExecutor(executor); + if (!normalizedExecutor) return { ok: false, message: '不支持的执行器' }; + const binding = await getExecutorBindingRow(normalizedExecutor, purpose); + if (!binding?.enabled) { + return { + ok: false, + executor: normalizedExecutor, + purpose, + message: `${executorById[normalizedExecutor]?.label ?? normalizedExecutor} 未启用模型绑定`, + }; + } + if (!binding.provider_key_id) { + return { + ok: false, + executor: normalizedExecutor, + purpose, + message: `${executorById[normalizedExecutor]?.label ?? normalizedExecutor} 未绑定 Provider`, + }; + } + const row = await getRowById(binding.provider_key_id); + if (!row || row.status !== 'active') { + return { + ok: false, + executor: normalizedExecutor, + purpose, + message: '绑定的 Provider 不存在或已禁用', + }; + } + const keyPublic = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row)); + if (!keyPublic.models.includes(binding.model)) { + return { + ok: false, + executor: normalizedExecutor, + purpose, + message: '绑定模型不在当前 Provider 支持列表中', + }; + } + const profile = profileFromRow(row, decryptRow); + const runtimeProfile = includeSecret + ? profile + : { ...profile, apiKey: '' }; + return { + ok: true, + executor: normalizedExecutor, + executorLabel: executorById[normalizedExecutor]?.label ?? normalizedExecutor, + purpose: binding.purpose, + providerKeyId: row.id, + providerName: row.name, + providerKind: profile.providerKind, + providerId: profile.providerId, + providerLabel: keyPublic.providerLabel, + goosedProviderId: profile.goosedProviderId, + model: binding.model, + apiUrl: profile.apiUrl, + apiBaseUrl: resolveApiBaseUrl(profile.apiUrl), + apiKeyMasked: keyPublic.apiKeyMasked, + env: executorEnvForProfile(normalizedExecutor, runtimeProfile, binding.model, { + includeSecret, + }), + }; + } + + function getLaunchState(executor, purpose = 'default') { + const key = launchStateKey(executor, purpose); + const current = launchStates.get(key); + if (!current) return null; + if (current.pid && !isProcessAlive(current.pid)) { + current.running = false; + current.stoppedAt = current.stoppedAt ?? Date.now(); + current.exitReason = current.exitReason ?? 'not_running'; + launchStates.set(key, current); + } + return current; + } + + function setLaunchState(executor, purpose, state) { + const key = launchStateKey(executor, purpose); + launchStates.set(key, { + ...state, + executor: normalizeExecutor(executor), + purpose: normalizePurpose(purpose), + }); + return launchStates.get(key); + } + + function stateToPublic(state) { + if (!state) return null; + return { + executor: state.executor, + purpose: state.purpose, + pid: state.pid ?? null, + running: Boolean(state.running), + command: state.command ?? null, + args: state.args ?? [], + cwd: state.cwd ?? null, + logFile: state.logFile ?? null, + startedAt: state.startedAt ?? null, + stoppedAt: state.stoppedAt ?? null, + exitReason: state.exitReason ?? null, + mode: state.mode ?? null, + instruction: state.instruction ?? null, + }; + } + + async function stopLaunchState(state, { force = false } = {}) { + if (!state?.pid) { + return { ok: false, message: '未找到运行中的执行器进程' }; + } + if (!isProcessAlive(state.pid)) { + state.running = false; + state.stoppedAt = state.stoppedAt ?? Date.now(); + state.exitReason = state.exitReason ?? 'not_running'; + return { ok: true, stopped: true, state: stateToPublic(state) }; + } + try { + process.kill(state.pid, 'SIGTERM'); + if (!force) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + if (force || isProcessAlive(state.pid)) { + try { + process.kill(state.pid, 'SIGKILL'); + } catch {} + } + state.running = false; + state.stoppedAt = Date.now(); + state.exitReason = force ? 'force_kill' : 'sigterm'; + return { ok: true, stopped: true, state: stateToPublic(state) }; + } catch (err) { + return { + ok: false, + message: err instanceof Error ? err.message : '停止失败', + }; + } + } + async function testSelectedRow(row) { const profile = profileFromRow(row, decryptRow); if (profile.providerKind === 'custom') { @@ -702,6 +1240,7 @@ export function createLlmProviderService( return { catalog: LLM_PROVIDER_CATALOG, + executorCatalog: LLM_EXECUTOR_CATALOG, async listKeys() { const [rows] = await pool.query( @@ -712,6 +1251,243 @@ export function createLlmProviderService( ); }, + async listExecutorBindings() { + const keys = await this.listKeys(); + const keyMap = new Map(keys.map((key) => [key.id, key])); + const [rows] = await pool.query( + 'SELECT * FROM h5_llm_executor_bindings ORDER BY FIELD(executor, "goose", "aider", "openhands"), purpose', + ); + const rowMap = new Map( + rows.map((row) => [`${row.executor}:${row.purpose}`, row]), + ); + return LLM_EXECUTOR_CATALOG.map((executor) => { + const row = rowMap.get(`${executor.id}:default`); + if (!row) { + return { + id: null, + executor: executor.id, + executorLabel: executor.label, + executorDescription: executor.description, + purpose: 'default', + providerKeyId: null, + providerName: null, + providerLabel: null, + providerId: null, + model: '', + enabled: false, + availableModels: [], + createdAt: null, + updatedAt: null, + }; + } + return rowToExecutorBinding(row, keyMap.get(row.provider_key_id) ?? null); + }); + }, + + async setExecutorBinding(executor, payload = {}) { + const normalizedExecutor = normalizeExecutor(executor); + if (!normalizedExecutor) return { ok: false, message: '不支持的执行器' }; + const purpose = normalizePurpose(payload.purpose); + const enabled = payload.enabled !== false; + const keyId = String(payload.keyId ?? payload.providerKeyId ?? '').trim() || null; + const model = String(payload.model ?? '').trim(); + + if (enabled && !keyId) return { ok: false, message: '请选择 Provider' }; + if (enabled && !model) return { ok: false, message: '请选择模型' }; + + let keyPublic = null; + if (keyId) { + const row = await getRowById(keyId); + if (!row) return { ok: false, message: 'Provider 不存在' }; + if (row.status !== 'active') return { ok: false, message: 'Provider 已禁用' }; + keyPublic = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row)); + if (model && !keyPublic.models.includes(model)) { + return { ok: false, message: '模型不在该 Provider 支持列表中' }; + } + } + + const existing = await getExecutorBindingRow(normalizedExecutor, purpose); + const now = Date.now(); + if (existing) { + await pool.query( + `UPDATE h5_llm_executor_bindings + SET provider_key_id = ?, model = ?, enabled = ?, updated_at = ? + WHERE id = ?`, + [keyId, model, enabled ? 1 : 0, now, existing.id], + ); + } else { + await pool.query( + `INSERT INTO h5_llm_executor_bindings + (id, executor, purpose, provider_key_id, model, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [crypto.randomUUID(), normalizedExecutor, purpose, keyId, model, enabled ? 1 : 0, now, now], + ); + } + + const row = await getExecutorBindingRow(normalizedExecutor, purpose); + return { ok: true, binding: rowToExecutorBinding(row, keyPublic) }; + }, + + async getExecutorRuntimeConfig(executor, options = {}) { + return resolveExecutorRuntimeConfig(executor, options); + }, + + async listExecutorRuntimeConfigs(options = {}) { + const configs = await Promise.all( + LLM_EXECUTOR_CATALOG.map((executor) => + resolveExecutorRuntimeConfig(executor.id, options), + ), + ); + return configs; + }, + + async getExecutorLaunchPlan(executor, options = {}) { + const runtime = await resolveExecutorRuntimeConfig(executor, { + purpose: options.purpose ?? 'default', + includeSecret: options.includeSecret ?? false, + }); + return buildExecutorLaunchPlan(runtime, options); + }, + + async listExecutorLaunchPlans(options = {}) { + const runtimes = await this.listExecutorRuntimeConfigs({ + purpose: options.purpose ?? 'default', + includeSecret: options.includeSecret ?? false, + }); + return runtimes.map((runtime) => buildExecutorLaunchPlan(runtime, options)); + }, + + async listExecutorLaunchStates(options = {}) { + const purpose = normalizePurpose(options.purpose ?? 'default'); + return LLM_EXECUTOR_CATALOG.map((item) => stateToPublic(getLaunchState(item.id, purpose))); + }, + + async getExecutorLaunchState(executor, options = {}) { + const purpose = normalizePurpose(options.purpose ?? 'default'); + return stateToPublic(getLaunchState(executor, purpose)); + }, + + async launchExecutor(executor, options = {}) { + const normalizedExecutor = normalizeExecutor(executor); + if (!normalizedExecutor) { + return { ok: false, message: '不支持的执行器' }; + } + const purpose = normalizePurpose(options.purpose ?? 'default'); + const mode = normalizeLaunchMode(options.mode ?? (normalizedExecutor === 'openhands' ? 'serve' : 'headless')); + const instruction = String(options.instruction ?? '').trim(); + const currentState = getLaunchState(normalizedExecutor, purpose); + if (currentState?.running) { + return { + ok: true, + executor: normalizedExecutor, + purpose, + running: true, + reused: true, + launch: stateToPublic(currentState), + }; + } + + if (normalizedExecutor === 'goose') { + return { + ok: false, + executor: 'goose', + message: 'Goose 作为现有服务入口,不通过后台直接启动', + }; + } + + if (normalizedExecutor === 'aider' && !instruction) { + return { + ok: false, + executor: 'aider', + message: 'Aider 启动需要 instruction', + }; + } + + if (normalizedExecutor === 'openhands' && mode === 'headless' && !instruction) { + return { + ok: false, + executor: 'openhands', + message: 'OpenHands headless 启动需要 instruction', + }; + } + + const runtime = await resolveExecutorRuntimeConfig(normalizedExecutor, { + purpose, + includeSecret: true, + }); + const plan = buildExecutorLaunchPlan(runtime, { + purpose, + mode, + cwd: options.cwd, + instruction, + includeSecret: true, + }); + if (!plan.ok) { + return plan; + } + const launch = spawnDetachedExecutor(plan, { logDir: options.logDir }); + if (!launch.ok) return launch; + const state = setLaunchState(normalizedExecutor, purpose, { + pid: launch.pid ?? null, + running: true, + command: launch.command, + args: launch.args ?? [], + cwd: launch.cwd ?? plan.cwd, + logFile: launch.logFile, + startedAt: Date.now(), + mode, + instruction: instruction || null, + }); + return { + ...launch, + purpose, + running: true, + launch: stateToPublic(state), + }; + }, + + async stopExecutor(executor, options = {}) { + const normalizedExecutor = normalizeExecutor(executor); + if (!normalizedExecutor) { + return { ok: false, message: '不支持的执行器' }; + } + const purpose = normalizePurpose(options.purpose ?? 'default'); + const state = getLaunchState(normalizedExecutor, purpose); + if (!state) { + return { ok: false, executor: normalizedExecutor, purpose, message: '未找到执行记录' }; + } + const result = await stopLaunchState(state, { force: Boolean(options.force) }); + if (!result.ok) return { ok: false, executor: normalizedExecutor, purpose, message: result.message }; + setLaunchState(normalizedExecutor, purpose, state); + return { + ok: true, + executor: normalizedExecutor, + purpose, + launch: result.state, + }; + }, + + async restartExecutor(executor, options = {}) { + const normalizedExecutor = normalizeExecutor(executor); + if (!normalizedExecutor) { + return { ok: false, message: '不支持的执行器' }; + } + const purpose = normalizePurpose(options.purpose ?? 'default'); + const state = getLaunchState(normalizedExecutor, purpose); + if (!state) { + return { ok: false, executor: normalizedExecutor, purpose, message: '未找到可重启的执行记录' }; + } + await stopLaunchState(state, { force: true }); + const launchOptions = { + purpose, + mode: options.mode ?? state.mode ?? (normalizedExecutor === 'openhands' ? 'serve' : 'headless'), + cwd: options.cwd ?? state.cwd ?? process.cwd(), + instruction: options.instruction ?? state.instruction ?? '', + logDir: options.logDir, + }; + return this.launchExecutor(normalizedExecutor, launchOptions); + }, + async createKey(payload) { const custom = isCustomPayload(payload); let providerKind = 'builtin'; @@ -740,6 +1516,18 @@ export function createLlmProviderService( relayProvider = validated.value.relayProvider; defaultModel = validated.value.defaultModel; modelsJson = JSON.stringify(validated.value.models); + } else if (providerId === 'custom_qwen') { + // Qwen VL: displayed as builtin in the UI but stored as a custom OpenAI-compatible + // provider so Goose receives images (custom_deepseek is in PROVIDERS_WITHOUT_VISION). + if (!meta) return { ok: false, message: '不支持的 provider' }; + if (!insertName) return { ok: false, message: '请填写配置名称' }; + if (!apiKey) return { ok: false, message: '请填写 API Key' }; + providerKind = 'custom'; + apiUrl = meta.apiUrl ?? 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + const qwenModels = meta.models; + defaultModel = defaultModel || meta.defaultModel || qwenModels[0]; + if (!qwenModels.includes(defaultModel)) defaultModel = qwenModels[0]; + modelsJson = JSON.stringify(qwenModels); } else { if (!meta) return { ok: false, message: '不支持的 provider' }; if (!insertName) return { ok: false, message: '请填写配置名称' }; @@ -954,7 +1742,10 @@ export function createLlmProviderService( }, async syncSelectedToGoosed() { - const resolved = await resolveSelectedProvider(); + let resolved = await resolveExecutorProvider('goose'); + if (!resolved.ok) { + resolved = await resolveSelectedProvider(); + } if (!resolved.ok) { return { ok: false, synced: false, message: resolved.message }; } @@ -968,7 +1759,10 @@ export function createLlmProviderService( }, async applyBestProviderForSession(sessionId, fetchImpl = apiFetchImpl) { - const resolved = await resolveSelectedProvider(fetchImpl); + let resolved = await resolveExecutorProvider('goose', 'default', fetchImpl); + if (!resolved.ok) { + resolved = await resolveSelectedProvider(fetchImpl); + } if (!resolved.ok) { return resolved; } @@ -990,6 +1784,112 @@ export function createLlmProviderService( return resolved; }, + async hasVisionKey() { + if (visionKeyConfiguredCache !== null) return visionKeyConfiguredCache; + const row = await getVisionRow(); + visionKeyConfiguredCache = row !== null; + return visionKeyConfiguredCache; + }, + + async getVisionSettings() { + const row = await getVisionRow(); + if (!row) { + return { keyId: null, keyName: null, providerLabel: null, visionModel: null, availableModels: [] }; + } + const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row)); + return { + keyId: publicRow.id, + keyName: publicRow.name, + providerLabel: publicRow.providerLabel, + visionModel: publicRow.defaultModel, + availableModels: publicRow.models, + }; + }, + + async setVisionKey(keyId, model) { + const nextModel = String(model ?? '').trim(); + if (!nextModel) return { ok: false, message: '请选择视觉模型' }; + const row = await getRowById(keyId); + if (!row) return { ok: false, message: '配置不存在' }; + if (row.status !== 'active') return { ok: false, message: '该配置已禁用' }; + const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row)); + if (!publicRow.models.includes(nextModel)) { + return { ok: false, message: '模型不在该配置支持列表中' }; + } + await clearVisionSelected(); + const now = Date.now(); + await pool.query( + 'UPDATE h5_llm_provider_keys SET is_vision_selected = 1, default_model = ?, updated_at = ? WHERE id = ?', + [nextModel, now, keyId], + ); + visionKeyConfiguredCache = true; + return { ok: true, vision: await this.getVisionSettings() }; + }, + + async clearVisionKey() { + await clearVisionSelected(); + visionKeyConfiguredCache = false; + return { ok: true }; + }, + + async applyVisionProviderForSession(sessionId, fetchImpl = apiFetchImpl) { + const row = await getVisionRow(); + if (!row) return { ok: false, message: '未配置图片任务模型' }; + const sessionGoosedApi = (pathname, init) => + goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl); + let goosedProviderId; + try { + goosedProviderId = await syncRow(row, fetchImpl); + if (!goosedProviderId) { + goosedProviderId = profileFromRow(row, decryptRow).goosedProviderId ?? row.provider_id; + } + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : '同步视觉模型失败' }; + } + await updateSessionProvider(sessionGoosedApi, sessionId, goosedProviderId, row.default_model); + return { ok: true, providerId: goosedProviderId, model: row.default_model, source: 'vision' }; + }, + + // Calls the vision provider directly (not through Goose) to analyze images. + // Returns the model's text description, or null on failure. + async analyzeImagesWithVision(imageItems, userText) { + const row = await getVisionRow(); + if (!row) return null; + const apiUrl = String(row.api_url ?? '').trim(); + if (!apiUrl) return null; + const apiKey = decryptRow(row); + if (!apiKey) return null; + const model = String(row.default_model ?? 'qwen-vl-max').trim(); + + const content = [ + ...imageItems.map((item) => ({ + type: 'image_url', + image_url: { url: `data:${item.mimeType};base64,${item.data}` }, + })), + { type: 'text', text: String(userText || '请描述这张图片的内容').trim() }, + ]; + try { + const resp = await undiciFetch(`${apiUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + messages: [{ role: 'user', content }], + max_tokens: 1200, + }), + signal: AbortSignal.timeout(30_000), + }); + if (!resp.ok) return null; + const json = await resp.json(); + return json?.choices?.[0]?.message?.content ?? null; + } catch { + return null; + } + }, + async getGlobalSettings() { const row = await getSelectedRow(); if (!row) { @@ -1052,20 +1952,36 @@ export function createLlmProviderService( ); }, - async testKey(id, testModel) { - const row = await getRowById(id); - if (!row) return { ok: false, message: '配置不存在' }; - if ((row.provider_kind ?? 'builtin') !== 'custom') { - return { ok: false, message: '内置 Provider 联通测试暂未开放' }; - } - const model = String(testModel ?? row.default_model).trim(); - const models = parseModelsJson(row.models_json); - if (!models.includes(model)) { - return { ok: false, message: '测试模型不在配置列表中' }; - } - return testRelayConnection( - { - apiUrl: row.api_url, + async testKey(id, testModel) { + const row = await getRowById(id); + if (!row) return { ok: false, message: '配置不存在' }; + const model = String(testModel ?? row.default_model).trim(); + const providerKind = row.provider_kind ?? 'builtin'; + const meta = catalogItem(row.provider_id); + const models = providerKind === 'custom' + ? parseModelsJson(row.models_json) + : (meta?.models ?? []); + if (!models.includes(model)) { + return { ok: false, message: '测试模型不在配置列表中' }; + } + if (providerKind !== 'custom') { + const apiUrl = BUILTIN_PROVIDER_TEST_URLS[row.provider_id]; + if (!apiUrl) { + return { ok: false, message: '该内置 Provider 暂不支持联通测试' }; + } + return testRelayConnection( + { + apiUrl, + apiKey: decryptRow(row), + model, + relayProvider: null, + }, + apiFetchImpl, + ); + } + return testRelayConnection( + { + apiUrl: row.api_url, apiKey: decryptRow(row), model, relayProvider: row.relay_provider, @@ -1080,10 +1996,6 @@ export function createLlmProviderService( [RELAY_BOOTSTRAP.name], ); if (existing.length > 0) { - const row = await getRowById(existing[0].id); - if (row && !row.is_selected) { - await this.selectKey(row.id); - } return { ok: true, created: false, keyId: existing[0].id }; } diff --git a/llm-providers.test.mjs b/llm-providers.test.mjs index 0aa17e6..b3c9671 100644 --- a/llm-providers.test.mjs +++ b/llm-providers.test.mjs @@ -3,12 +3,14 @@ import test from 'node:test'; import { createLlmProviderService, CUSTOM_PROVIDER_ID, + buildExecutorLaunchPlan, decryptSecret, encryptSecret, LLM_PROVIDER_CATALOG, LOCAL_LLM_FALLBACK, maskApiKey, normalizeApiUrl, + resolveApiBaseUrl, parseModelList, resolveChatCompletionsUrl, testLocalLlmConnection, @@ -248,6 +250,27 @@ test('catalog includes custom provider template', () => { assert.equal(custom.kind, 'custom'); }); +test('listExecutorBindings returns all executor placeholders', async () => { + const pool = { + async query(sql) { + if (sql.includes('ORDER BY is_selected DESC')) return [[]]; + if (sql.includes('FROM h5_llm_executor_bindings')) return [[]]; + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; + const service = createLlmProviderService(pool, { + apiTarget: 'https://127.0.0.1:18006', + apiSecret: 'secret', + encryptionKey: 'unit-test-secret', + }); + const bindings = await service.listExecutorBindings(); + assert.deepEqual( + bindings.map((binding) => binding.executor), + ['goose', 'aider', 'openhands'], + ); + assert.equal(bindings[0].enabled, false); +}); + test('resolveChatCompletionsUrl keeps full completions path', () => { assert.equal( resolveChatCompletionsUrl('http://127.0.0.1:18300/relay/buyer/v1/chat/completions'), @@ -255,6 +278,261 @@ test('resolveChatCompletionsUrl keeps full completions path', () => { ); }); +test('resolveApiBaseUrl strips chat completions suffix', () => { + assert.equal( + resolveApiBaseUrl('http://127.0.0.1:18300/relay/buyer/v1/chat/completions'), + 'http://127.0.0.1:18300/relay/buyer/v1', + ); +}); + +test('testKey checks builtin DeepSeek through OpenAI compatible endpoint', async () => { + const encrypted = encryptSecret('sk-deepseek-test', 'unit-test-secret'); + const row = { + id: 'key-deepseek', + provider_id: 'custom_deepseek', + provider_kind: 'builtin', + name: 'DeepSeek', + api_key_ciphertext: encrypted.ciphertext, + api_key_iv: encrypted.iv, + api_key_tag: encrypted.tag, + default_model: 'deepseek-chat', + status: 'active', + is_selected: 1, + created_at: 1, + updated_at: 1, + }; + const pool = { + async query(sql, params = []) { + if (sql.includes('SELECT * FROM h5_llm_provider_keys WHERE id = ?')) { + assert.equal(params[0], 'key-deepseek'); + return [[row]]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; + const calls = []; + const mockFetch = async (url, init) => { + calls.push({ url: String(url), init, body: JSON.parse(init.body) }); + return { + ok: true, + text: async () => JSON.stringify({ choices: [{ message: { content: 'ok' } }] }), + }; + }; + const service = createLlmProviderService(pool, { + apiTarget: 'https://127.0.0.1:18006', + apiSecret: 'secret', + encryptionKey: 'unit-test-secret', + apiFetchImpl: mockFetch, + }); + const result = await service.testKey('key-deepseek', 'deepseek-chat'); + assert.equal(result.ok, true); + assert.equal(calls[0].url, 'https://api.deepseek.com/v1/chat/completions'); + assert.equal(calls[0].init.headers.Authorization, 'Bearer sk-deepseek-test'); + assert.equal(calls[0].body.model, 'deepseek-chat'); +}); + +test('getExecutorRuntimeConfig resolves aider env from binding', async () => { + const encrypted = encryptSecret('sk-deepseek-test', 'unit-test-secret'); + const keyRow = { + id: 'key-deepseek', + provider_id: 'custom_deepseek', + provider_kind: 'builtin', + name: 'DeepSeek 主账号', + api_key_ciphertext: encrypted.ciphertext, + api_key_iv: encrypted.iv, + api_key_tag: encrypted.tag, + default_model: 'deepseek-chat', + status: 'active', + is_selected: 1, + created_at: 1, + updated_at: 1, + }; + const bindingRow = { + id: 'binding-aider', + executor: 'aider', + purpose: 'default', + provider_key_id: 'key-deepseek', + model: 'deepseek-reasoner', + enabled: 1, + created_at: 1, + updated_at: 1, + }; + const pool = { + async query(sql, params = []) { + if (sql.includes('SELECT * FROM h5_llm_executor_bindings')) return [[bindingRow]]; + if (sql.includes('SELECT * FROM h5_llm_provider_keys WHERE id = ?')) { + assert.equal(params[0], 'key-deepseek'); + return [[keyRow]]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; + const service = createLlmProviderService(pool, { + apiTarget: 'https://127.0.0.1:18006', + apiSecret: 'secret', + encryptionKey: 'unit-test-secret', + }); + const runtime = await service.getExecutorRuntimeConfig('aider'); + assert.equal(runtime.ok, true); + assert.equal(runtime.model, 'deepseek-reasoner'); + assert.equal(runtime.env.AIDER_MODEL, 'deepseek-reasoner'); + assert.equal(runtime.env.DEEPSEEK_API_KEY, '[hidden]'); +}); + +test('buildExecutorLaunchPlan creates openhands headless command', () => { + const plan = buildExecutorLaunchPlan({ + ok: true, + executor: 'openhands', + executorLabel: 'OpenHands', + purpose: 'default', + providerName: 'DeepSeek 主账号', + model: 'deepseek-reasoner', + env: { LLM_MODEL: 'deepseek-reasoner', LLM_API_KEY: '[hidden]' }, + }, { + mode: 'headless', + cwd: '/repo/memind', + instruction: 'add search', + }); + assert.equal(plan.ok, true); + assert.equal(plan.command, 'openhands'); + assert.deepEqual(plan.args.slice(0, 3), ['--headless', '--json', '--override-with-envs']); + assert.equal(plan.cwd, '/repo/memind'); + assert.ok(plan.args.includes('--task')); +}); + +test('buildExecutorLaunchPlan creates openhands serve command', () => { + const plan = buildExecutorLaunchPlan({ + ok: true, + executor: 'openhands', + executorLabel: 'OpenHands', + purpose: 'default', + providerName: 'DeepSeek 主账号', + model: 'deepseek-reasoner', + env: { LLM_MODEL: 'deepseek-reasoner', LLM_API_KEY: '[hidden]' }, + }, { + mode: 'serve', + cwd: '/repo/memind', + }); + assert.equal(plan.ok, true); + assert.equal(plan.command, 'openhands'); + assert.deepEqual(plan.args, ['serve', '--mount-cwd']); +}); + +test('buildExecutorLaunchPlan adds aider one-shot message when provided', () => { + const plan = buildExecutorLaunchPlan({ + ok: true, + executor: 'aider', + executorLabel: 'Aider', + purpose: 'default', + providerName: 'DeepSeek 主账号', + model: 'deepseek-chat', + env: { AIDER_MODEL: 'deepseek-chat', OPENAI_API_KEY: '[hidden]' }, + }, { + cwd: '/repo/memind', + instruction: 'fix login button', + }); + assert.equal(plan.ok, true); + assert.equal(plan.command, 'aider'); + assert.ok(plan.args.includes('--message')); + assert.ok(plan.args.includes('fix login button')); +}); + +test('buildExecutorLaunchPlan rejects openhands headless without instruction', () => { + const plan = buildExecutorLaunchPlan({ + ok: true, + executor: 'openhands', + executorLabel: 'OpenHands', + purpose: 'default', + providerName: 'DeepSeek 主账号', + model: 'deepseek-reasoner', + env: { LLM_MODEL: 'deepseek-reasoner', LLM_API_KEY: '[hidden]' }, + }, { + mode: 'headless', + cwd: '/repo/memind', + }); + assert.equal(plan.ok, false); + assert.match(plan.message ?? '', /instruction/); +}); + +test('launchExecutor rejects goose launch path', async () => { + const pool = { + async query() { + throw new Error('should not query for goose launch'); + }, + }; + const service = createLlmProviderService(pool, { + apiTarget: 'https://127.0.0.1:18006', + apiSecret: 'secret', + encryptionKey: 'unit-test-secret', + }); + const result = await service.launchExecutor('goose'); + assert.equal(result.ok, false); + assert.match(result.message ?? '', /Goose/); +}); + +test('launchExecutor rejects openhands headless without instruction', async () => { + const pool = { + async query() { + throw new Error('should not query for validation failure'); + }, + }; + const service = createLlmProviderService(pool, { + apiTarget: 'https://127.0.0.1:18006', + apiSecret: 'secret', + encryptionKey: 'unit-test-secret', + }); + const result = await service.launchExecutor('openhands', { mode: 'headless' }); + assert.equal(result.ok, false); + assert.match(result.message ?? '', /instruction/); +}); + +test('launchExecutor returns friendly error when command is missing', async () => { + const keyRow = { + id: 'key-1', + provider_id: 'custom_deepseek', + provider_kind: 'builtin', + name: 'DeepSeek', + api_key_ciphertext: encryptSecret('sk-x', 'unit-test-secret').ciphertext, + api_key_iv: encryptSecret('sk-x', 'unit-test-secret').iv, + api_key_tag: encryptSecret('sk-x', 'unit-test-secret').tag, + default_model: 'deepseek-chat', + status: 'active', + is_selected: 1, + created_at: 1, + updated_at: 1, + }; + const bindingRow = { + id: 'binding-1', + executor: 'aider', + purpose: 'default', + provider_key_id: 'key-1', + model: 'deepseek-chat', + enabled: 1, + created_at: 1, + updated_at: 1, + }; + const pool = { + async query(sql) { + if (sql.includes('SELECT * FROM h5_llm_executor_bindings')) return [[]]; + if (sql.includes('SELECT * FROM h5_llm_provider_keys WHERE id = ?')) { + return [[keyRow]]; + } + if (sql.includes('SELECT * FROM h5_llm_executor_bindings WHERE executor = ? AND purpose = ? LIMIT 1')) { + return [[bindingRow]]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; + const service = createLlmProviderService(pool, { + apiTarget: 'https://127.0.0.1:18006', + apiSecret: 'secret', + encryptionKey: 'unit-test-secret', + }); + const result = await service.launchExecutor('aider', { instruction: 'hello' }); + assert.equal(result.ok, false); + assert.match(result.message ?? '', /aider .*PATH|aider 未安装/); +}); + test('testRelayConnection surfaces 401 as token error', async () => { const mockFetch = async () => ({ ok: false, @@ -380,6 +658,7 @@ test('syncSelectedToGoosed does not auto-fallback to local when selected relay i }; const pool = { async query(sql) { + if (sql.includes('SELECT * FROM h5_llm_executor_bindings')) return [[]]; if (sql.includes('is_selected = 1')) return [[row]]; throw new Error(`Unexpected SQL: ${sql}`); }, @@ -398,3 +677,47 @@ test('syncSelectedToGoosed does not auto-fallback to local when selected relay i assert.equal(result.synced, false); assert.ok(result.message); }); + +test('ensureBootstrapRelay keeps existing selected provider', async () => { + const rows = [ + { + id: 'key-deepseek', + provider_id: 'custom_deepseek', + provider_kind: 'builtin', + name: 'DeepSeek', + status: 'active', + is_selected: 1, + updated_at: 1, + }, + { + id: 'key-relay', + provider_id: 'custom_relay_buyer_ollama_27', + provider_kind: 'custom', + name: 'Relay Buyer Ollama', + status: 'active', + is_selected: 0, + updated_at: 1, + }, + ]; + const pool = { + async query(sql, params = []) { + if (sql.includes('SELECT id FROM h5_llm_provider_keys WHERE name = ?')) { + return [[rows.find((row) => row.name === params[0])].filter(Boolean)]; + } + if (sql.includes('UPDATE h5_llm_provider_keys SET is_selected')) { + throw new Error('bootstrap relay should not change the selected provider'); + } + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; + const service = createLlmProviderService(pool, { + apiTarget: 'https://127.0.0.1:18006', + apiSecret: 'secret', + encryptionKey: 'unit-test-secret', + }); + const result = await service.ensureBootstrapRelay(); + assert.equal(result.ok, true); + assert.equal(result.created, false); + assert.equal(rows.find((row) => row.id === 'key-deepseek').is_selected, 1); + assert.equal(rows.find((row) => row.id === 'key-relay').is_selected, 0); +}); diff --git a/local_restart.sh b/local_restart.sh new file mode 100755 index 0000000..740e770 --- /dev/null +++ b/local_restart.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LOG_DIR="${ROOT_DIR}/logs" +LOG_FILE="${LOG_DIR}/local_restart.log" +mkdir -p "${LOG_DIR}" + +if ! command -v pnpm >/dev/null 2>&1; then + echo "pnpm not found" + exit 1 +fi + +cd "${ROOT_DIR}" + +echo "Starting Memind local stack via pnpm dev..." +nohup pnpm dev >"${LOG_FILE}" 2>&1 & +echo $! > "${LOG_DIR}/local_restart.pid" + +echo "Started." +echo "Log: ${LOG_FILE}" +echo "MindSpace: http://127.0.0.1:5173/?preview=mindspace" +echo "API / Portal: http://127.0.0.1:8081" +echo "Ops: http://127.0.0.1:3002/ops/" +echo "Admin: http://127.0.0.1:8082" diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index e4010fa..2905494 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -10,6 +10,7 @@ import { scheduleHtmlThumbnail, } from './mindspace-thumbnails.mjs'; import { removeZoneMirror, resolveUserWorkspaceRoot } from './user-space.mjs'; +import { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR, resolvePublicBaseUrl } from './user-publish.mjs'; import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs'; import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs'; @@ -32,6 +33,13 @@ const ALLOWED_EXTENSIONS = new Map([ ['.htm', 'text/html'], ]); const MAX_IMAGE_UPLOAD_BYTES = 1536 * 1024; +const PUBLIC_TEMP_IMAGE_DIR = '.tmp-images'; + +const PUBLIC_IMAGE_EXTENSIONS = new Map([ + ['image/png', '.png'], + ['image/jpeg', '.jpg'], + ['image/webp', '.webp'], +]); function asNumber(value) { return Number(value ?? 0); @@ -95,6 +103,31 @@ function visibilityForCategory(categoryCode) { return categoryCode === 'public' ? 'public_candidate' : 'private'; } +function isPublicImageAsset(row) { + return row?.category_code === 'public' && String(row?.mime_type ?? '').startsWith('image/'); +} + +function publicTempImageFilename(assetId, mimeType, fallbackFilename = '') { + const fallbackExtension = path.extname(String(fallbackFilename)).toLowerCase(); + const extension = PUBLIC_IMAGE_EXTENSIONS.get(mimeType) || fallbackExtension || '.img'; + return `${assetId}${extension}`; +} + +function publicTempImageStorageKey(userId, assetId, mimeType, fallbackFilename = '') { + return path.posix.join( + 'users', + userId, + PUBLIC_ZONE_DIR, + PUBLIC_TEMP_IMAGE_DIR, + publicTempImageFilename(assetId, mimeType, fallbackFilename), + ); +} + +function publicTempImageUrl(userId, assetId, mimeType, fallbackFilename = '') { + const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename); + return `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(userId)}/${PUBLIC_ZONE_DIR}/${PUBLIC_TEMP_IMAGE_DIR}/${encodeURIComponent(filename)}`; +} + function assetResponse(row) { return { id: row.id, @@ -112,6 +145,9 @@ function assetResponse(row) { scanStatus: row.scan_status ?? 'passed', sourceType: row.source_type, hasThumbnail: Boolean(row.has_thumbnail ?? row.mime_type === 'text/html'), + publicUrl: isPublicImageAsset(row) + ? publicTempImageUrl(row.user_id, row.id, row.mime_type, row.original_filename) + : null, sourcePageId: row.source_page_id ?? null, createdAt: asNumber(row.created_at), updatedAt: asNumber(row.updated_at), @@ -173,6 +209,24 @@ export function createAssetService(pool, options = {}) { }); }; + const writePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename, sourcePath) => { + if (!h5Root) return null; + const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename); + const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId }); + const target = path.join(workspaceRoot, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR, filename); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.copyFile(sourcePath, target); + return target; + }; + + const removePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename) => { + if (!h5Root) return; + const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename); + const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId }); + const target = path.join(workspaceRoot, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR, filename); + await fs.rm(target, { force: true }); + }; + const absoluteStoragePath = (storageKey) => { const resolved = path.resolve(storageRoot, storageKey); if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) { @@ -352,6 +406,8 @@ export function createAssetService(pool, options = {}) { const completeUpload = async (userId, uploadId) => { const conn = await pool.getConnection(); let temporaryPath; + let finalPath; + let publicMirror = null; try { await conn.beginTransaction(); const [rows] = await conn.query( @@ -389,7 +445,6 @@ export function createAssetService(pool, options = {}) { const assetId = idFactory(); const versionId = idFactory(); - const finalStorageKey = upload.temporary_storage_key; temporaryPath = absoluteStoragePath(upload.temporary_storage_key); const fileBuffer = await fs.readFile(temporaryPath); const scan = runBasicFileScan(fileBuffer, { @@ -398,6 +453,27 @@ export function createAssetService(pool, options = {}) { }); const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined'; const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked'; + const shouldPublishTempImage = + upload.category_code === 'public' && + upload.detected_mime_type.startsWith('image/') && + scan.scanStatus === 'passed'; + const finalStorageKey = shouldPublishTempImage + ? publicTempImageStorageKey(userId, assetId, upload.detected_mime_type, upload.filename) + : upload.temporary_storage_key; + finalPath = absoluteStoragePath(finalStorageKey); + if (finalStorageKey !== upload.temporary_storage_key) { + await fs.mkdir(path.dirname(finalPath), { recursive: true }); + await fs.rename(temporaryPath, finalPath); + } + if (shouldPublishTempImage) { + publicMirror = await writePublicTempImageMirror( + userId, + assetId, + upload.detected_mime_type, + upload.filename, + finalPath, + ); + } const now = Date.now(); const visibility = visibilityForCategory(upload.category_code); @@ -460,6 +536,7 @@ export function createAssetService(pool, options = {}) { await conn.commit(); return { id: assetId, + user_id: userId, categoryId: upload.category_id, categoryCode: upload.category_code, assetType: assetTypeForMime(upload.detected_mime_type), @@ -475,9 +552,14 @@ export function createAssetService(pool, options = {}) { sourceType: 'upload', createdAt: now, updatedAt: now, + publicUrl: shouldPublishTempImage + ? publicTempImageUrl(userId, assetId, upload.detected_mime_type, upload.filename) + : null, }; } catch (error) { await conn.rollback(); + if (finalPath && finalPath !== temporaryPath) await fs.rm(finalPath, { force: true }).catch(() => {}); + if (publicMirror) await fs.rm(publicMirror, { force: true }).catch(() => {}); throw error; } finally { conn.release(); @@ -559,12 +641,15 @@ export function createAssetService(pool, options = {}) { const deleteAsset = async (userId, assetId) => { const conn = await pool.getConnection(); let mirrorCleanup = null; + let storageCleanup = null; try { await conn.beginTransaction(); const [rows] = await conn.query( - `SELECT a.id, a.space_id, a.size_bytes, a.status, a.original_filename, c.category_code + `SELECT a.id, a.space_id, a.size_bytes, a.status, a.original_filename, a.mime_type, + c.category_code, v.storage_key FROM h5_assets a JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id + LEFT JOIN h5_asset_versions v ON v.id = a.current_version_id WHERE a.id = ? AND a.user_id = ? LIMIT 1 FOR UPDATE`, @@ -617,10 +702,13 @@ export function createAssetService(pool, options = {}) { await conn.commit(); if (h5Root) { mirrorCleanup = { + id: asset.id, categoryCode: asset.category_code, filename: asset.original_filename, + mimeType: asset.mime_type, }; } + if (asset.storage_key) storageCleanup = asset.storage_key; } catch (error) { await conn.rollback(); throw error; @@ -642,10 +730,25 @@ export function createAssetService(pool, options = {}) { filename: thumbName, }); } + if (mirrorCleanup.categoryCode === 'public' && mirrorCleanup.mimeType?.startsWith?.('image/')) { + await removePublicTempImageMirror( + userId, + mirrorCleanup.id, + mirrorCleanup.mimeType, + mirrorCleanup.filename, + ); + } } catch { // Best-effort workspace mirror cleanup; DB delete already committed. } } + if (storageCleanup) { + try { + await fs.rm(absoluteStoragePath(storageCleanup), { force: true }); + } catch { + // Best-effort physical storage cleanup; DB delete already committed. + } + } return { deleted: true }; }; @@ -688,6 +791,7 @@ export function createAssetService(pool, options = {}) { } const conn = await pool.getConnection(); let finalPath; + let publicMirror = null; try { await conn.beginTransaction(); const [categories] = await conn.query( @@ -730,18 +834,6 @@ export function createAssetService(pool, options = {}) { const assetId = idFactory(); const versionId = idFactory(); - const finalStorageKey = path.posix.join( - 'users', - userId, - 'assets', - assetId, - 'versions', - versionId, - ); - finalPath = absoluteStoragePath(finalStorageKey); - await fs.mkdir(path.dirname(finalPath), { recursive: true }); - await fs.writeFile(finalPath, buffer, { flag: 'wx' }); - const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); const scan = runBasicFileScan(buffer, { filename: normalizedFilename, @@ -749,6 +841,25 @@ export function createAssetService(pool, options = {}) { }); const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined'; const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked'; + const shouldPublishTempImage = + category.category_code === 'public' && + detectedMimeType.startsWith('image/') && + scan.scanStatus === 'passed'; + const finalStorageKey = shouldPublishTempImage + ? publicTempImageStorageKey(userId, assetId, detectedMimeType, normalizedFilename) + : path.posix.join('users', userId, 'assets', assetId, 'versions', versionId); + finalPath = absoluteStoragePath(finalStorageKey); + await fs.mkdir(path.dirname(finalPath), { recursive: true }); + await fs.writeFile(finalPath, buffer, { flag: 'wx' }); + if (shouldPublishTempImage) { + publicMirror = await writePublicTempImageMirror( + userId, + assetId, + detectedMimeType, + normalizedFilename, + finalPath, + ); + } const now = Date.now(); const visibility = visibilityForCategory(category.category_code); await conn.query( @@ -809,6 +920,7 @@ export function createAssetService(pool, options = {}) { } return assetResponse({ id: assetId, + user_id: userId, category_id: category.id, category_code: category.category_code, asset_type: assetTypeForMime(detectedMimeType), @@ -829,6 +941,7 @@ export function createAssetService(pool, options = {}) { } catch (error) { await conn.rollback(); if (finalPath) await fs.rm(finalPath, { force: true }).catch(() => {}); + if (publicMirror) await fs.rm(publicMirror, { force: true }).catch(() => {}); throw error; } finally { conn.release(); diff --git a/mindspace-assets.test.mjs b/mindspace-assets.test.mjs index b8a247c..4fdb658 100644 --- a/mindspace-assets.test.mjs +++ b/mindspace-assets.test.mjs @@ -4,6 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { createAssetService, validateUploadRequest } from './mindspace-assets.mjs'; +import { DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs'; function createMockPool(state) { return { @@ -87,7 +88,7 @@ function createMockPool(state) { state.versions.push({ id: params[0], asset_id: params[1], - storage_key: params[3], + storage_key: params[2], scan_status: params[8], }); return [[]]; @@ -166,6 +167,23 @@ test('validateUploadRequest rejects traversal filenames', () => { ); }); +test('validateUploadRequest allows 5MB uploads by default', () => { + assert.doesNotThrow(() => + validateUploadRequest({ + filename: 'handbook.pdf', + sizeBytes: DEFAULT_MAX_FILE_BYTES, + }), + ); + assert.throws( + () => + validateUploadRequest({ + filename: 'handbook.pdf', + sizeBytes: DEFAULT_MAX_FILE_BYTES + 1, + }), + (error) => error.code === 'file_too_large', + ); +}); + test('completeUpload marks safe files ready and blocks script payloads', async () => { const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-')); const state = { @@ -206,6 +224,57 @@ test('completeUpload marks safe files ready and blocks script payloads', async ( assert.equal(asset.scanStatus, 'blocked'); }); +test('completeUpload publishes public images to the public temp image library', async () => { + const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-')); + const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-h5-')); + const state = { + categories: [ + { id: 'cat-public', user_id: 'user-1', space_id: 'space-1', category_code: 'public' }, + ], + spaces: [ + { + id: 'space-1', + user_id: 'user-1', + quota_bytes: 5 * 1024 * 1024, + used_bytes: 0, + reserved_bytes: 0, + status: 'active', + }, + ], + uploads: [], + assets: [], + versions: [], + }; + let nextId = 0; + const service = createAssetService(createMockPool(state), { + storageRoot, + h5Root, + idFactory: () => `id-${++nextId}`, + }); + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', + ); + + const upload = await service.createUpload('user-1', { + categoryId: 'cat-public', + filename: 'look.png', + sizeBytes: png.length, + }); + await service.writeUploadContent('user-1', upload.id, png); + const asset = await service.completeUpload('user-1', upload.id); + + assert.equal(asset.status, 'ready'); + assert.match(asset.publicUrl, /^https:\/\/g2\.tkmind\.cn\/MindSpace\/user-1\/public\/\.tmp-images\/id-2\.png$/); + assert.equal(state.versions[0].storage_key, 'users/user-1/public/.tmp-images/id-2.png'); + await assert.doesNotReject(() => + fs.stat(path.join(storageRoot, 'users/user-1/public/.tmp-images/id-2.png')), + ); + await assert.doesNotReject(() => + fs.stat(path.join(h5Root, 'MindSpace/user-1/public/.tmp-images/id-2.png')), + ); +}); + test('createUpload rejects another users category id', async () => { const state = { categories: [ diff --git a/mindspace-chat-save.mjs b/mindspace-chat-save.mjs index 46e8618..d4c8b71 100644 --- a/mindspace-chat-save.mjs +++ b/mindspace-chat-save.mjs @@ -14,6 +14,33 @@ function decodePathSegment(segment) { } } +function encodeUrlPath(relativePath) { + return String(relativePath ?? '') + .split('/') + .filter(Boolean) + .map((part) => encodeURIComponent(part)) + .join('/'); +} + +export function normalizeStaticHtmlRelativePath(relativePath) { + const parts = String(relativePath ?? '') + .replace(/^\/+/, '') + .split('/') + .filter((part) => part && part !== '.' && part !== '..'); + if (parts.length === 0) return ''; + if (parts[0].toLowerCase() === 'public') return ['public', ...parts.slice(1)].join('/'); + if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`; + return parts.join('/'); +} + +function canonicalizeStaticPageUrl(publicUrl, originalRelativePath, canonicalRelativePath) { + if (!canonicalRelativePath || canonicalRelativePath === String(originalRelativePath ?? '').replace(/^\/+/, '')) { + return publicUrl; + } + const suffix = encodeUrlPath(originalRelativePath).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return String(publicUrl).replace(new RegExp(`${suffix}$`), encodeUrlPath(canonicalRelativePath)); +} + export function extractStaticPageLinks(content, { userId, username } = {}) { const text = String(content ?? ''); const links = []; @@ -22,9 +49,10 @@ export function extractStaticPageLinks(content, { userId, username } = {}) { const normalizedUsername = username ? String(username).trim().toLowerCase() : null; for (const match of text.matchAll(URL_PATTERN)) { const owner = decodePathSegment(match[1]).toLowerCase(); - const relativePath = decodePathSegment(match[2]); + const originalRelativePath = decodePathSegment(match[2]); + const relativePath = normalizeStaticHtmlRelativePath(originalRelativePath); if (normalizedUserId) { - if (owner !== normalizedUserId) continue; + if (owner !== normalizedUserId && owner !== normalizedUsername) continue; } else if (normalizedUsername && owner !== normalizedUsername) { continue; } @@ -32,7 +60,7 @@ export function extractStaticPageLinks(content, { userId, username } = {}) { if (seen.has(key)) continue; seen.add(key); links.push({ - publicUrl: match[0], + publicUrl: canonicalizeStaticPageUrl(match[0], originalRelativePath, relativePath), owner, relativePath, filename: path.basename(relativePath), @@ -114,8 +142,91 @@ async function walkPublishHtmlByBasename(publishRoot, basename, maxDepth = 6, de return null; } +async function collectPublishHtmlPaths(publishRoot, results, maxDepth = 6, depth = 0) { + if (depth > maxDepth || results.length >= 200) return; + let entries; + try { + entries = await fs.readdir(publishRoot, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const full = path.join(publishRoot, entry.name); + if (entry.isFile() && entry.name.toLowerCase().endsWith('.html')) { + results.push(full); + if (results.length >= 200) return; + } + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue; + await collectPublishHtmlPaths(path.join(publishRoot, entry.name), results, maxDepth, depth + 1); + if (results.length >= 200) return; + } +} + +function levenshteinDistance(left, right) { + if (left === right) return 0; + if (!left) return right.length; + if (!right) return left.length; + const prev = Array.from({ length: right.length + 1 }, (_, index) => index); + const next = new Array(right.length + 1); + for (let i = 0; i < left.length; i += 1) { + next[0] = i + 1; + for (let j = 0; j < right.length; j += 1) { + const cost = left[i] === right[j] ? 0 : 1; + next[j + 1] = Math.min( + next[j] + 1, + prev[j + 1] + 1, + prev[j] + cost, + ); + } + for (let j = 0; j <= right.length; j += 1) { + prev[j] = next[j]; + } + } + return prev[right.length]; +} + +function htmlNameForSimilarity(relativePath) { + return path.basename(String(relativePath ?? ''), '.html').toLowerCase(); +} + +function isAcceptableSimilarHtmlMatch(requested, candidate, score, nextScore = Infinity) { + if (!requested || !candidate || requested === candidate) return false; + const longest = Math.max(requested.length, candidate.length); + const allowedDistance = longest >= 18 ? 2 : 1; + if (score > allowedDistance) return false; + return nextScore > score; +} + +export async function resolveClosestHtmlRelativePath(rootDir, relativePath) { + const normalized = normalizeStaticHtmlRelativePath(relativePath); + if (!normalized.toLowerCase().endsWith('.html')) return null; + const requestedName = htmlNameForSimilarity(normalized); + const candidates = []; + await collectPublishHtmlPaths(rootDir, candidates); + const ranked = candidates + .map((absolute) => { + const candidateRelative = path.relative(rootDir, absolute).split(path.sep).join('/'); + const candidateName = htmlNameForSimilarity(candidateRelative); + return { + relativePath: candidateRelative, + score: levenshteinDistance(requestedName, candidateName), + }; + }) + .sort((left, right) => left.score - right.score || left.relativePath.localeCompare(right.relativePath)); + if (ranked.length === 0) return null; + const best = ranked[0]; + const nextBestScore = ranked[1]?.score ?? Infinity; + if (!isAcceptableSimilarHtmlMatch(requestedName, htmlNameForSimilarity(best.relativePath), best.score, nextBestScore)) { + return null; + } + return best.relativePath; +} + export async function findPublishHtml(h5Root, userId, relativePath) { - const normalized = String(relativePath ?? '').replace(/^\/+/, ''); + const normalized = normalizeStaticHtmlRelativePath(relativePath); const basename = path.basename(normalized); const candidates = [ normalized, @@ -142,6 +253,11 @@ export async function findPublishHtml(h5Root, userId, relativePath) { return readPublishHtml(h5Root, userId, resolvedRelativePath); } + const similarRelativePath = await resolveClosestHtmlRelativePath(publishRoot, normalized); + if (similarRelativePath) { + return readPublishHtml(h5Root, userId, similarRelativePath); + } + throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' }); } diff --git a/mindspace-chat-save.test.mjs b/mindspace-chat-save.test.mjs index a87d2a1..3fcf887 100644 --- a/mindspace-chat-save.test.mjs +++ b/mindspace-chat-save.test.mjs @@ -13,6 +13,7 @@ import { extractStaticPageLinks, findPublishHtml, injectHtmlBaseHref, + resolveClosestHtmlRelativePath, resolveChatSaveAnalysis, sanitizeMessageContentForSave, } from './mindspace-chat-save.mjs'; @@ -22,16 +23,17 @@ const USER_ID = 'a6fb1e97-2b0f-447b-b138-4561d8e5c53e'; test('extractStaticPageLinks finds user-owned MindSpace html links', () => { const content = ` 页面已完成! -https://g2.tkmind.cn/MindSpace/${USER_ID}/mapo-tofu.html +https://m.tkmind.cn/MindSpace/${USER_ID}/mapo-tofu.html `; const links = extractStaticPageLinks(content, { userId: USER_ID }); assert.equal(links.length, 1); assert.equal(links[0].filename, 'mapo-tofu.html'); - assert.equal(links[0].relativePath, 'mapo-tofu.html'); + assert.equal(links[0].relativePath, 'public/mapo-tofu.html'); + assert.equal(links[0].publicUrl, `https://m.tkmind.cn/MindSpace/${USER_ID}/public/mapo-tofu.html`); }); test('extractStaticPageLinks ignores other users', () => { - const content = 'https://g2.tkmind.cn/MindSpace/other-id/report.html'; + const content = 'https://m.tkmind.cn/MindSpace/other-id/report.html'; const links = extractStaticPageLinks(content, { userId: USER_ID }); assert.equal(links.length, 0); }); @@ -40,11 +42,27 @@ test('extractStaticPageLinks accepts legacy username urls when username provided const content = 'https://goo.tkmind.cn/MindSpace/john/report.html'; const links = extractStaticPageLinks(content, { username: 'john' }); assert.equal(links.length, 1); + assert.equal(links[0].relativePath, 'public/report.html'); + assert.equal(links[0].publicUrl, 'https://goo.tkmind.cn/MindSpace/john/public/report.html'); +}); + +test('extractStaticPageLinks accepts uuid links when both user id and username are provided', () => { + const content = `https://goo.tkmind.cn/MindSpace/${USER_ID}/public/report.html`; + const links = extractStaticPageLinks(content, { userId: USER_ID, username: 'john' }); + assert.equal(links.length, 1); + assert.equal(links[0].relativePath, 'public/report.html'); +}); + +test('extractStaticPageLinks accepts legacy username links when user id is available', () => { + const content = 'https://goo.tkmind.cn/MindSpace/john/report.html'; + const links = extractStaticPageLinks(content, { userId: USER_ID, username: 'john' }); + assert.equal(links.length, 1); + assert.equal(links[0].publicUrl, 'https://goo.tkmind.cn/MindSpace/john/public/report.html'); }); test('analyzeChatMessageForSave prefers static html mode', () => { const analysis = analyzeChatMessageForSave({ - content: `链接 https://g2.tkmind.cn/MindSpace/${USER_ID}/report.html`, + content: `链接 https://m.tkmind.cn/MindSpace/${USER_ID}/report.html`, userId: USER_ID, username: 'john', h5Root: '/tmp/h5', @@ -110,6 +128,29 @@ test('findPublishHtml resolves nested public html by basename', async () => { assert.match(loaded.content, /Welcome/); }); +test('findPublishHtml resolves close html filename typos when match is unique', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-typo-')); + const publishDir = path.join(root, 'MindSpace', USER_ID, 'public'); + await fs.mkdir(publishDir, { recursive: true }); + await fs.writeFile( + path.join(publishDir, 'memind-deep-analysis.html'), + 'Deep Analysisok', + 'utf8', + ); + const loaded = await findPublishHtml(root, USER_ID, 'public/memind-dep-analysis.html'); + assert.equal(loaded.relativePath, 'public/memind-deep-analysis.html'); + assert.match(loaded.content, /Deep Analysis/); +}); + +test('resolveClosestHtmlRelativePath ignores ambiguous typo matches', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-ambiguous-')); + await fs.mkdir(path.join(root, 'public'), { recursive: true }); + await fs.writeFile(path.join(root, 'public', 'mindmap-plan.html'), '', 'utf8'); + await fs.writeFile(path.join(root, 'public', 'mindmap-play.html'), '', 'utf8'); + const resolved = await resolveClosestHtmlRelativePath(root, 'public/mindmap-plab.html'); + assert.equal(resolved, null); +}); + test('injectHtmlBaseHref adds base tag for relative assets', () => { const html = 'x'; const next = injectHtmlBaseHref(html, buildWorkspaceBaseHref(USER_ID, 'public/page.html')); diff --git a/mindspace-config.mjs b/mindspace-config.mjs new file mode 100644 index 0000000..eb3181c --- /dev/null +++ b/mindspace-config.mjs @@ -0,0 +1,96 @@ +const PUBLIC_PAGE_LIMIT_KEY = 'public_page_limit'; + +function asPositiveInteger(value, fallback) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.floor(parsed); +} + +async function ensureConfigTable(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS mindspace_config ( + \`key\` VARCHAR(64) PRIMARY KEY, + value TEXT NOT NULL, + description VARCHAR(255) NULL, + updated_at BIGINT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); +} + +export function defaultMindSpaceConfig(env = process.env) { + return { + publicPageLimit: asPositiveInteger(env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5, 5), + }; +} + +async function readConfigRows(pool) { + const [rows] = await pool.query( + 'SELECT `key`, value FROM mindspace_config', + ); + return rows; +} + +export async function ensureMindSpaceConfig(pool, { env = process.env, seedDefault = true } = {}) { + await ensureConfigTable(pool); + if (!seedDefault) return; + const [rows] = await pool.query('SELECT COUNT(*) AS count FROM mindspace_config'); + if (Number(rows[0]?.count ?? 0) > 0) return; + + const now = Date.now(); + const defaults = defaultMindSpaceConfig(env); + await pool.query( + `INSERT INTO mindspace_config (\`key\`, value, description, updated_at) + VALUES (?, ?, ?, ?)`, + [PUBLIC_PAGE_LIMIT_KEY, String(defaults.publicPageLimit), '公开页面数量上限', now], + ); +} + +export async function loadMindSpaceConfig(pool, { env = process.env } = {}) { + const config = defaultMindSpaceConfig(env); + try { + const rows = await readConfigRows(pool); + for (const row of rows) { + if (row.key === PUBLIC_PAGE_LIMIT_KEY) { + config.publicPageLimit = asPositiveInteger(row.value, config.publicPageLimit); + } + } + } catch (error) { + if (error?.code === 'ER_NO_SUCH_TABLE') return config; + throw error; + } + return config; +} + +export async function updateMindSpaceConfig(pool, patch, { env = process.env } = {}) { + await ensureConfigTable(pool); + const updates = []; + if (patch?.publicPageLimit !== undefined) { + const publicPageLimit = asPositiveInteger(patch.publicPageLimit, null); + if (!publicPageLimit) { + throw Object.assign(new Error('公开页面数量上限必须是大于 0 的整数'), { + code: 'invalid_mindspace_config', + }); + } + updates.push([PUBLIC_PAGE_LIMIT_KEY, String(publicPageLimit), '公开页面数量上限']); + } + + if (updates.length === 0) return loadMindSpaceConfig(pool, { env }); + + const now = Date.now(); + for (const [key, value, description] of updates) { + await pool.query( + `INSERT INTO mindspace_config (\`key\`, value, description, updated_at) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + value = VALUES(value), + description = VALUES(description), + updated_at = VALUES(updated_at)`, + [key, value, description, now], + ); + } + return loadMindSpaceConfig(pool, { env }); +} + +export const mindspaceConfigInternals = { + PUBLIC_PAGE_LIMIT_KEY, +}; diff --git a/mindspace-config.test.mjs b/mindspace-config.test.mjs new file mode 100644 index 0000000..2fd1364 --- /dev/null +++ b/mindspace-config.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + defaultMindSpaceConfig, + ensureMindSpaceConfig, + loadMindSpaceConfig, + updateMindSpaceConfig, +} from './mindspace-config.mjs'; + +test('defaultMindSpaceConfig falls back to env or 5', () => { + assert.equal(defaultMindSpaceConfig({ MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '8' }).publicPageLimit, 8); + assert.equal(defaultMindSpaceConfig({ MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '0' }).publicPageLimit, 5); +}); + +test('ensureMindSpaceConfig seeds the default row only when empty', async () => { + const calls = []; + const pool = { + async query(sql, params) { + calls.push({ sql, params }); + if (sql.includes('COUNT(*) AS count')) return [[{ count: 0 }]]; + return [[]]; + }, + }; + + await ensureMindSpaceConfig(pool, { env: { MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '9' } }); + + assert.equal(calls.length, 3); + assert.match(calls[0].sql, /CREATE TABLE IF NOT EXISTS mindspace_config/); + assert.match(calls[2].sql, /INSERT INTO mindspace_config/); + assert.deepEqual(calls[2].params.slice(0, 3), ['public_page_limit', '9', '公开页面数量上限']); +}); + +test('loadMindSpaceConfig merges stored config with fallback', async () => { + const pool = { + async query(sql) { + if (sql.includes('FROM mindspace_config')) { + return [[ + { key: 'public_page_limit', value: '12' }, + { key: 'ignored', value: '99' }, + ]]; + } + return [[]]; + }, + }; + + const config = await loadMindSpaceConfig(pool, { env: { MINDSPACE_FREE_PUBLIC_PAGE_LIMIT: '7' } }); + assert.equal(config.publicPageLimit, 12); +}); + +test('updateMindSpaceConfig persists a positive integer limit', async () => { + const calls = []; + const pool = { + async query(sql, params) { + calls.push({ sql, params }); + if (sql.includes('CREATE TABLE IF NOT EXISTS mindspace_config')) return [[]]; + if (sql.includes('FROM mindspace_config')) { + return [[{ key: 'public_page_limit', value: '15' }]]; + } + return [[]]; + }, + }; + + const config = await updateMindSpaceConfig(pool, { publicPageLimit: 15 }); + assert.equal(config.publicPageLimit, 15); + assert.equal(calls.some(({ sql }) => sql.includes('ON DUPLICATE KEY UPDATE')), true); +}); diff --git a/mindspace-content-scan.mjs b/mindspace-content-scan.mjs index a5a1043..37ab359 100644 --- a/mindspace-content-scan.mjs +++ b/mindspace-content-scan.mjs @@ -159,6 +159,14 @@ const TRUSTED_EXTERNAL_HOSTS = new Set([ 'images.unsplash.com', ]); +export function replacePrivateResourceReferences(content, replacement) { + const source = String(content ?? ''); + if (!source) return source; + const replacer = + typeof replacement === 'function' ? replacement : () => String(replacement ?? ''); + return source.replace(PRIVATE_URL_PATTERN, (...args) => replacer(...args)); +} + function inferFormat(content, format) { if (format === 'html') return 'html'; return HTML_TAG_PATTERN.test(String(content ?? '')) ? 'html' : 'text'; diff --git a/mindspace-og-tags.test.mjs b/mindspace-og-tags.test.mjs index 4e131ae..528cb4e 100644 --- a/mindspace-og-tags.test.mjs +++ b/mindspace-og-tags.test.mjs @@ -3,9 +3,9 @@ import assert from 'node:assert/strict'; import { injectOgTags } from './mindspace-og-tags.mjs'; const ctx = { - origin: 'https://g2.tkmind.cn', - pageUrl: 'https://g2.tkmind.cn/MindSpace/john/public/space.html', - pageDirUrl: 'https://g2.tkmind.cn/MindSpace/john/public/', + origin: 'https://m.tkmind.cn', + pageUrl: 'https://m.tkmind.cn/MindSpace/john/public/space.html', + pageDirUrl: 'https://m.tkmind.cn/MindSpace/john/public/', }; test('injects og/twitter tags with absolute image from a relative cover', () => { @@ -25,7 +25,7 @@ test('falls back to the thumbnail png when the page has no cover of its own', () const html = `纯文字页面`; const out = injectOgTags(html, { ...ctx, - fallbackImageUrl: 'https://g2.tkmind.cn/MindSpace/john/public/space.thumbnail.png', + fallbackImageUrl: 'https://m.tkmind.cn/MindSpace/john/public/space.thumbnail.png', }); assert.match(out, //); assert.match(out, //); @@ -33,7 +33,7 @@ test('falls back to the thumbnail png when the page has no cover of its own', () test('prefers the page cover over the thumbnail fallback', () => { const html = `X`; - const out = injectOgTags(html, { ...ctx, fallbackImageUrl: 'https://g2.tkmind.cn/x.thumbnail.png' }); + const out = injectOgTags(html, { ...ctx, fallbackImageUrl: 'https://m.tkmind.cn/x.thumbnail.png' }); assert.match(out, /og:image" content="https:\/\/g2\.tkmind\.cn\/MindSpace\/john\/public\/assets\/hero\.jpg"/); assert.doesNotMatch(out, /thumbnail\.png/); }); diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs index 8971430..92fca08 100644 --- a/mindspace-pages.mjs +++ b/mindspace-pages.mjs @@ -20,6 +20,8 @@ const MAX_SUMMARY_LENGTH = 1000; const MAX_CONTENT_BYTES = 1024 * 1024; const TEMPLATE_IDS = new Set(['editorial', 'report', 'profile', 'knowledge-card', 'static-html']); const SAVE_CATEGORY_CODES = new Set(['draft', 'oa', 'private', 'public']); +const PRIVATE_ASSET_URL_PATTERN = + /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; function asNumber(value) { return Number(value ?? 0); @@ -75,6 +77,16 @@ function normalizePageInput(input) { }; } +function extensionForMime(mimeType, filename = '') { + const ext = path.extname(String(filename ?? '')).replace(/^\./, '').toLowerCase(); + if (ext && /^[a-z0-9]{1,8}$/.test(ext)) return ext === 'jpeg' ? 'jpg' : ext; + if (mimeType === 'image/jpeg') return 'jpg'; + if (mimeType === 'image/png') return 'png'; + if (mimeType === 'image/webp') return 'webp'; + if (mimeType === 'image/gif') return 'gif'; + return 'bin'; +} + function pageResponse(row) { return { id: row.id, @@ -657,6 +669,113 @@ export function createPageService(pool, options = {}) { }; }; + const localizePrivateResources = async (userId, pageId, input = {}) => { + const page = await getPage(userId, pageId); + if (input.pageVersionId && input.pageVersionId !== page.currentVersionId) { + throw pageError('只能基于当前版本修复页面', 'version_conflict', { + currentVersion: page.currentVersionId, + }); + } + if (input.expectedVersion != null && asNumber(input.expectedVersion) !== asNumber(page.versionNo)) { + throw pageError('页面已被更新,请刷新后重试', 'version_conflict', { + currentVersion: asNumber(page.versionNo), + }); + } + if (page.contentFormat !== 'html') { + throw pageError('该页面不支持资源本地化修复', 'unsupported_publish_fix'); + } + + const sourceTitle = input.title ?? page.title; + const sourceSummary = input.summary ?? page.summary ?? ''; + const sourceContent = input.content ?? page.content ?? ''; + const matches = [...String(sourceContent).matchAll(PRIVATE_ASSET_URL_PATTERN)]; + if (matches.length === 0) { + throw pageError('未发现可安全修复的私有图片引用', 'publish_fix_not_needed'); + } + + const assetIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))]; + const [assets] = await pool.query( + `SELECT a.id, a.mime_type, a.original_filename, v.storage_key + FROM h5_assets a + JOIN h5_asset_versions v ON v.id = a.current_version_id + WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%' + AND a.status <> 'deleted'`, + [userId, assetIds], + ); + const byId = new Map(assets.map((asset) => [asset.id, asset])); + const replacements = new Map(); + for (const assetId of assetIds) { + const asset = byId.get(assetId); + if (!asset) continue; + const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key)); + const mimeType = String(asset.mime_type || 'application/octet-stream'); + const ext = extensionForMime(mimeType, asset.original_filename); + const dataUri = `data:${mimeType};base64,${buffer.toString('base64')}`; + replacements.set(assetId, { dataUri, ext }); + } + + if (replacements.size === 0) { + throw pageError('私有资源不是可内联的图片,无法自动修复', 'unsupported_publish_fix'); + } + + let changedCount = 0; + const updatedContent = String(sourceContent).replace( + PRIVATE_ASSET_URL_PATTERN, + (value, assetId) => { + const replacement = replacements.get(assetId); + if (!replacement) return value; + changedCount += 1; + return replacement.dataUri; + }, + ); + + if (updatedContent === sourceContent || changedCount === 0) { + throw pageError('未发现可安全修复的私有图片引用', 'publish_fix_not_needed'); + } + + const updated = await createVersion( + userId, + { + pageId, + expectedVersion: page.versionNo, + title: sourceTitle, + summary: sourceSummary, + content: updatedContent, + templateId: page.templateId, + pageType: page.pageType, + contentFormat: 'html', + changeNote: `一键发布修复 v${page.versionNo + 1}`, + }, + { + type: 'generated', + snapshot: { + localized_private_resources: true, + }, + }, + ); + + return { + page: updated, + originalScan: scanContent(`${sourceTitle}\n${sourceSummary}\n${sourceContent}`, { + format: 'html', + }), + redactedScan: scanContent( + `${updated.title}\n${updated.summary ?? ''}\n${updated.content ?? ''}`, + { format: 'html' }, + ), + redactionsApplied: changedCount, + changes: [ + { + field: 'content', + fieldLabel: '正文', + type: 'private_resource_reference', + label: '私有资源引用', + count: changedCount, + }, + ], + }; + }; + const collectLinkedAssets = async (conn, userId, pageId) => { const [versions] = await conn.query( `SELECT pv.content_asset_id, pv.bundle_asset_id @@ -1024,6 +1143,7 @@ export function createPageService(pool, options = {}) { createPage: (userId, input) => createVersion(userId, input, { type: 'template' }), updatePage: (userId, pageId, input) => createVersion(userId, { ...input, pageId }, { type: 'generated' }), + localizePrivateResources, redactPage, createRedactedCopy: redactPage, listPages, diff --git a/mindspace-publications.mjs b/mindspace-publications.mjs index 1703e23..c6bc567 100644 --- a/mindspace-publications.mjs +++ b/mindspace-publications.mjs @@ -2,10 +2,14 @@ import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { localizeGoogleFontsCss } from './mindspace-html-localize.mjs'; -import { scanContent } from './mindspace-content-scan.mjs'; +import { replacePrivateResourceReferences, scanContent } from './mindspace-content-scan.mjs'; import { pageInternals } from './mindspace-pages.mjs'; +import { loadMindSpaceConfig } from './mindspace-config.mjs'; +import { resolvePublicBaseUrl } from './user-publish.mjs'; const SCANNER_VERSION = 'mindspace-content-v1'; +const PRIVATE_ASSET_DOWNLOAD_URL_PATTERN = + /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; const ACCESS_MODES = new Set([ 'public', 'password', @@ -151,10 +155,76 @@ function publicHomepageResponse(owner, pages) { }; } +function buildPublicationThumbnailFallback(ownerSlug, urlSlug) { + return `/u/${encodeURIComponent(ownerSlug)}/pages/${encodeURIComponent(urlSlug)}.thumbnail.png`; +} + +async function localizePrivateImageReferences({ pool, userId, html, absoluteStoragePath }) { + const source = String(html ?? ''); + const matches = [...source.matchAll(PRIVATE_ASSET_DOWNLOAD_URL_PATTERN)]; + if (matches.length === 0) return source; + + const assetIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))]; + if (assetIds.length === 0) return source; + + const [assets] = await pool.query( + `SELECT a.id, a.mime_type, v.storage_key + FROM h5_assets a + JOIN h5_asset_versions v ON v.id = a.current_version_id + WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%' + AND a.status <> 'deleted'`, + [userId, assetIds], + ); + const byId = new Map(assets.map((asset) => [asset.id, asset])); + const replacements = new Map(); + + for (const assetId of assetIds) { + const asset = byId.get(assetId); + if (!asset) continue; + const mimeType = String(asset.mime_type || 'application/octet-stream'); + const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key)); + replacements.set(assetId, `data:${mimeType};base64,${buffer.toString('base64')}`); + } + + if (replacements.size === 0) return source; + return source.replace(PRIVATE_ASSET_DOWNLOAD_URL_PATTERN, (value, assetId) => { + return replacements.get(assetId) ?? value; + }); +} + +async function prepareHtmlPublishContent({ + pool, + userId, + html, + ownerSlug, + urlSlug, + absoluteStoragePath, +}) { + let publishContent = (await localizeGoogleFontsCss(html)).html; + publishContent = await localizePrivateImageReferences({ + pool, + userId, + html: publishContent, + absoluteStoragePath, + }); + return replacePrivateResourceReferences( + publishContent, + buildPublicationThumbnailFallback(ownerSlug, urlSlug), + ); +} + export function createPublicationService(pool, options = {}) { const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace')); const idFactory = options.idFactory ?? (() => crypto.randomUUID()); - const publicPageLimit = Number(options.publicPageLimit ?? 5); + const publicPageLimitFallback = Number(options.publicPageLimit ?? 5); + const resolvePublicPageLimit = async () => { + try { + const config = await loadMindSpaceConfig(pool); + return Number(config.publicPageLimit ?? publicPageLimitFallback); + } catch { + return publicPageLimitFallback; + } + }; const absoluteStoragePath = (storageKey) => { const resolved = path.resolve(storageRoot, storageKey); @@ -216,6 +286,32 @@ export function createPublicationService(pool, options = {}) { }; }; + const loadOwnerSlug = async (userId) => { + const [rows] = await pool.query( + `SELECT COALESCE(slug, username) AS public_slug + FROM h5_users + WHERE id = ? + LIMIT 1`, + [userId], + ); + const ownerSlug = String(rows[0]?.public_slug ?? '').trim(); + if (!ownerSlug) throw publicationError('用户公开地址不存在', 'publication_owner_not_found'); + return ownerSlug; + }; + + const preparePublishContent = async (page, ownerSlug, urlSlug) => { + let publishContent = page.content; + if (page.page_type !== 'html') return publishContent; + return prepareHtmlPublishContent({ + pool, + userId: page.user_id, + html: publishContent, + ownerSlug, + urlSlug, + absoluteStoragePath, + }); + }; + const persistScan = async (conn, userId, pageVersionId, scan, now) => { const scanId = idFactory(); await conn.query( @@ -277,10 +373,8 @@ export function createPublicationService(pool, options = {}) { [userId, slug, pageId], ); const conflict = conflicts[0] ?? null; - let publishContent = page.content; - if (page.page_type === 'html') { - publishContent = (await localizeGoogleFontsCss(page.content)).html; - } + const ownerSlug = await loadOwnerSlug(userId); + const publishContent = await preparePublishContent(page, ownerSlug, slug); const scan = scanContent(`${page.title}\n${page.summary ?? ''}\n${publishContent}`, { format: page.page_type === 'html' ? 'html' : 'text', allowHtmlActiveContent: page.page_type === 'html', @@ -322,10 +416,8 @@ export function createPublicationService(pool, options = {}) { }); } const page = await loadVersion(userId, pageId, result.pageVersionId); - let publishContent = page.content; - if (page.page_type === 'html') { - publishContent = (await localizeGoogleFontsCss(page.content)).html; - } + const ownerSlug = await loadOwnerSlug(userId); + const publishContent = await preparePublishContent(page, ownerSlug, result.urlSlug); const html = pageInternals.renderPublicationHtml({ ...page, content: publishContent }); const htmlBytes = Buffer.byteLength(html); const conn = await pool.getConnection(); @@ -354,6 +446,7 @@ export function createPublicationService(pool, options = {}) { WHERE user_id = ? AND status = 'online'`, [pageId, userId], ); + const publicPageLimit = await resolvePublicPageLimit(); if ( !Number(publicationUsage[0]?.page_already_online) && Number(publicationUsage[0]?.public_page_used) >= publicPageLimit @@ -394,14 +487,10 @@ export function createPublicationService(pool, options = {}) { await fs.mkdir(path.dirname(writtenPath), { recursive: true }); await fs.writeFile(writtenPath, html, { flag: 'wx' }); const checksum = crypto.createHash('sha256').update(html).digest('hex'); - const [users] = await conn.query( - `SELECT COALESCE(slug, username) AS public_slug FROM h5_users WHERE id = ? LIMIT 1`, - [userId], - ); - const ownerSlug = users[0]?.public_slug; + const publicBaseUrl = resolvePublicBaseUrl(); const publicUrl = privateToken ? `/s/${privateToken}` - : `/u/${encodeURIComponent(ownerSlug)}/pages/${result.urlSlug}`; + : `${publicBaseUrl}/u/${encodeURIComponent(ownerSlug)}/pages/${result.urlSlug}`; await conn.query( `INSERT INTO h5_assets @@ -765,5 +854,8 @@ export const publicationInternals = { deviceType, referrerHost, publicHomepageResponse, + buildPublicationThumbnailFallback, + localizePrivateImageReferences, + prepareHtmlPublishContent, scanContent, // re-exported from mindspace-content-scan.mjs }; diff --git a/mindspace-publications.test.mjs b/mindspace-publications.test.mjs index fe23df3..9d97d56 100644 --- a/mindspace-publications.test.mjs +++ b/mindspace-publications.test.mjs @@ -1,4 +1,7 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; import { publicationInternals } from './mindspace-publications.mjs'; @@ -135,3 +138,60 @@ test('publicHomepageResponse summarizes public cards and total views', () => { assert.equal(result.pageCount, 2); assert.equal(result.pages[0].publicUrl, '/u/john/pages/one'); }); + +test('buildPublicationThumbnailFallback points private resources at the public page thumbnail', () => { + assert.equal( + publicationInternals.buildPublicationThumbnailFallback('john', 'page-037d497f'), + '/u/john/pages/page-037d497f.thumbnail.png', + ); + assert.equal( + publicationInternals.buildPublicationThumbnailFallback('木子', 'demo-page'), + '/u/%E6%9C%A8%E5%AD%90/pages/demo-page.thumbnail.png', + ); +}); + +test('prepareHtmlPublishContent inlines owned private image assets before scanning', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-publish-')); + const storageKey = 'users/user-1/assets/asset-1/current.png'; + await fs.mkdir(path.dirname(path.join(root, storageKey)), { recursive: true }); + await fs.writeFile(path.join(root, storageKey), Buffer.from('png-bytes')); + + const pool = { + async query(sql, params) { + assert.match(sql, /h5_assets/); + assert.equal(params[0], 'user-1'); + assert.deepEqual(params[1], ['asset-1']); + return [ + [ + { + id: 'asset-1', + mime_type: 'image/png', + storage_key: storageKey, + }, + ], + ]; + }, + }; + + try { + const html = ''; + const prepared = await publicationInternals.prepareHtmlPublishContent({ + pool, + userId: 'user-1', + html, + ownerSlug: 'john', + urlSlug: 'korean-daily-look', + absoluteStoragePath: (key) => path.join(root, key), + }); + + assert.match(prepared, /src="data:image\/png;base64,/); + assert.doesNotMatch(prepared, /\/api\/mindspace\/v1\/assets\//); + const scan = publicationInternals.scanContent(prepared, { format: 'html' }); + assert.equal( + scan.findings.some((finding) => finding.type === 'private_resource_reference'), + false, + ); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/mindspace-sandbox-mcp.mjs b/mindspace-sandbox-mcp.mjs index 4fa7f89..f8b2095 100644 --- a/mindspace-sandbox-mcp.mjs +++ b/mindspace-sandbox-mcp.mjs @@ -11,6 +11,9 @@ import path from 'node:path'; import fs from 'node:fs'; import readline from 'node:readline'; +import { execFileSync } from 'node:child_process'; +import mysql from 'mysql2/promise'; +import { createScheduleService } from './schedule-service.mjs'; const SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim(); if (!SANDBOX_ROOT) { @@ -19,6 +22,13 @@ if (!SANDBOX_ROOT) { } const SANDBOX = path.resolve(SANDBOX_ROOT); +const PRIVATE_DATA_DIR = path.join(SANDBOX, '.mindspace'); +const PRIVATE_DATA_DB = path.join(PRIVATE_DATA_DIR, 'private-data.sqlite'); +const SQLITE_BIN = process.env.SQLITE_BIN?.trim() || 'sqlite3'; +const PRIVATE_DATA_MAX_BYTES = Number(process.env.PRIVATE_DATA_MAX_BYTES ?? 20 * 1024 * 1024); +const PRIVATE_DATA_QUERY_TIMEOUT_MS = Number(process.env.PRIVATE_DATA_QUERY_TIMEOUT_MS ?? 5000); +const PRIVATE_DATA_MAX_ROWS = Number(process.env.PRIVATE_DATA_MAX_ROWS ?? 200); +const PRIVATE_DATA_USER_ID = process.env.PRIVATE_DATA_USER_ID?.trim(); const allowedToolsEnv = process.env.ALLOWED_TOOLS?.trim(); const ALLOWED_TOOLS = allowedToolsEnv ? new Set(allowedToolsEnv.split(',').map((s) => s.trim())) : null; @@ -94,11 +104,314 @@ const ALL_TOOLS = [ required: ['path'], }, }, + { + name: 'private_data_info', + description: + '查看当前用户“用户私有数据空间”的状态。每个用户只有一个私有 SQLite 数据库,适合保存问卷、表单、清单、调研数据和分析中间表;不要创建额外 SQLite 文件。', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'private_data_schema', + description: + '查看用户私有数据空间中的表和字段。用于了解当前用户自己的 SQLite 表结构。', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'private_data_query', + description: + '只读查询用户私有数据空间。仅允许 SELECT/WITH,默认最多返回 200 行,适合统计问卷回答、筛选表单数据、分析用户私有数据。', + inputSchema: { + type: 'object', + properties: { + sql: { type: 'string', description: '只读 SQL,必须是 SELECT/WITH' }, + }, + required: ['sql'], + }, + }, + { + name: 'private_data_execute', + description: + '写入或变更用户私有数据空间。可用于创建问卷/表单/清单等私有表并写入数据;禁止 ATTACH、load_extension、PRAGMA writable_schema、VACUUM INTO 等越界或危险操作。', + inputSchema: { + type: 'object', + properties: { + sql: { type: 'string', description: '要执行的 SQLite SQL' }, + }, + required: ['sql'], + }, + }, ]; +let quotaPool = null; +let scheduleService = null; + +function isQuotaSyncConfigured() { + return Boolean( + PRIVATE_DATA_USER_ID && + (process.env.DATABASE_URL || + (process.env.MYSQL_HOST && process.env.MYSQL_DATABASE)), + ); +} + +function getQuotaPool() { + if (!isQuotaSyncConfigured()) return null; + if (quotaPool) return quotaPool; + quotaPool = process.env.DATABASE_URL + ? mysql.createPool(process.env.DATABASE_URL) + : mysql.createPool({ + host: process.env.MYSQL_HOST ?? 'localhost', + port: Number(process.env.MYSQL_PORT ?? 3306), + user: process.env.MYSQL_USER ?? 'boot', + password: process.env.MYSQL_PASSWORD ?? '', + database: process.env.MYSQL_DATABASE ?? 'tkmind', + waitForConnections: true, + connectionLimit: 2, + }); + return quotaPool; +} + +function isScheduleConfigured() { + return isQuotaSyncConfigured(); +} + +function getScheduleService() { + if (!PRIVATE_DATA_USER_ID) throw new Error('当前会话没有用户上下文,不能操作待办'); + const pool = getQuotaPool(); + if (!pool || !isScheduleConfigured()) { + throw new Error('当前环境未配置待办数据库连接'); + } + if (!scheduleService) { + scheduleService = createScheduleService(pool, { + defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', + }); + } + return scheduleService; +} + +if (isScheduleConfigured()) { + ALL_TOOLS.push( + { + name: 'schedule_create_item', + description: + '为当前用户创建待办或日程事项。仅写入当前用户自己的 h5_schedule_items,不可操作其他用户数据。', + inputSchema: { + type: 'object', + properties: { + kind: { type: 'string', description: 'task 或 event,默认 task' }, + title: { type: 'string', description: '事项标题' }, + description: { type: 'string', description: '事项描述,可选' }, + startAt: { type: 'number', description: '开始时间 Unix 毫秒时间戳,可选' }, + endAt: { type: 'number', description: '结束时间 Unix 毫秒时间戳,可选' }, + dueAt: { type: 'number', description: '截止时间 Unix 毫秒时间戳,可选' }, + allDay: { type: 'boolean', description: '是否全天事项,可选' }, + timezone: { type: 'string', description: '时区,可选' }, + location: { type: 'string', description: '地点,可选' }, + sourceMessageId: { type: 'string', description: '服务号消息 ID;有值时必须原样传入' }, + sourceText: { type: 'string', description: '原始用户文本,可选' }, + }, + required: ['title'], + }, + }, + { + name: 'schedule_create_reminder', + description: + '为当前用户已有事项创建单次提醒。必须先有 itemId,再写入 h5_schedule_reminders。', + inputSchema: { + type: 'object', + properties: { + itemId: { type: 'string', description: '事项 ID' }, + remindAt: { type: 'number', description: '提醒触发时间 Unix 毫秒时间戳' }, + offsetMinutes: { type: 'number', description: '相对事项时间的提前分钟数,可选' }, + channel: { type: 'string', description: '提醒通道,默认 wechat' }, + }, + required: ['itemId', 'remindAt'], + }, + }, + { + name: 'schedule_list_items', + description: + '查询当前用户的待办/日程事项列表。仅返回当前用户数据,可按时间范围过滤。', + inputSchema: { + type: 'object', + properties: { + from: { type: 'number', description: '起始时间 Unix 毫秒时间戳,可选' }, + to: { type: 'number', description: '结束时间 Unix 毫秒时间戳,可选' }, + status: { type: 'string', description: '事项状态,默认 active,可选' }, + limit: { type: 'number', description: '返回数量,默认 50,可选' }, + }, + }, + }, + ); +} + const TOOLS = ALLOWED_TOOLS ? ALL_TOOLS.filter((t) => ALLOWED_TOOLS.has(t.name)) : ALL_TOOLS; -function callTool(name, args) { +function ensurePrivateDataDb() { + fs.mkdirSync(PRIVATE_DATA_DIR, { recursive: true }); + if (!fs.existsSync(PRIVATE_DATA_DB)) { + runSqlite(['-batch', PRIVATE_DATA_DB, 'PRAGMA journal_mode=WAL; PRAGMA user_version = 1;']); + } + return PRIVATE_DATA_DB; +} + +function privateDataSize() { + let total = 0; + for (const file of fs.existsSync(PRIVATE_DATA_DIR) ? fs.readdirSync(PRIVATE_DATA_DIR) : []) { + if (file === 'private-data.sqlite' || file.startsWith('private-data.sqlite-')) { + total += fs.statSync(path.join(PRIVATE_DATA_DIR, file)).size; + } + } + return total; +} + +function runSqlite(args) { + return execFileSync(SQLITE_BIN, args, { + encoding: 'utf8', + timeout: PRIVATE_DATA_QUERY_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + }); +} + +function sqliteScalar(sql) { + return Number(runSqlite(['-batch', '-noheader', PRIVATE_DATA_DB, sql]).trim() || 0); +} + +async function getQuotaState({ forUpdate = false, conn = null } = {}) { + const pool = getQuotaPool(); + if (!pool) return null; + const db = conn ?? pool; + const [rows] = await db.query( + `SELECT id, quota_bytes, used_bytes, reserved_bytes, status + FROM h5_user_spaces + WHERE user_id = ? + LIMIT 1 ${forUpdate ? 'FOR UPDATE' : ''}`, + [PRIVATE_DATA_USER_ID], + ); + const row = rows[0]; + if (!row) return null; + const quotaBytes = Number(row.quota_bytes ?? 0); + const usedBytes = Number(row.used_bytes ?? 0); + const reservedBytes = Number(row.reserved_bytes ?? 0); + return { + id: row.id, + status: row.status, + quotaBytes, + usedBytes, + reservedBytes, + availableBytes: Math.max(0, quotaBytes - usedBytes - reservedBytes), + }; +} + +async function sqliteMaxPagePragmaForQuota(currentBytes) { + const quota = await getQuotaState(); + if (!quota) return ''; + if (quota.status !== 'active') throw new Error('用户空间不可写'); + if (quota.availableBytes <= 0 && currentBytes >= PRIVATE_DATA_MAX_BYTES) { + throw new Error('用户私有数据空间配额不足'); + } + const allowedSize = Math.min(PRIVATE_DATA_MAX_BYTES, currentBytes + quota.availableBytes); + const pageSize = sqliteScalar('PRAGMA page_size;') || 4096; + const currentPages = sqliteScalar('PRAGMA page_count;') || 1; + const maxPages = Math.max(currentPages, Math.max(1, Math.floor(allowedSize / pageSize))); + return `PRAGMA max_page_count=${maxPages};`; +} + +async function syncPrivateDataQuota(deltaBytes) { + const pool = getQuotaPool(); + if (!pool || !deltaBytes) return null; + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const quota = await getQuotaState({ forUpdate: true, conn }); + if (!quota) { + await conn.rollback(); + return null; + } + if (quota.status !== 'active') throw new Error('用户空间不可写'); + if (deltaBytes > quota.availableBytes) { + throw new Error(`用户私有数据空间配额不足:需要 ${deltaBytes} 字节,可用 ${quota.availableBytes} 字节`); + } + await conn.query( + `UPDATE h5_user_spaces + SET used_bytes = GREATEST(0, used_bytes + ?), updated_at = ? + WHERE id = ? AND user_id = ?`, + [deltaBytes, Date.now(), quota.id, PRIVATE_DATA_USER_ID], + ); + await conn.commit(); + return { deltaBytes }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } +} + +function stripSqlComments(sql) { + return String(sql ?? '') + .replace(/--.*$/gm, '') + .replace(/\/\*[\s\S]*?\*\//g, '') + .trim(); +} + +function rejectDangerousSql(sql, { readonly = false } = {}) { + const cleaned = stripSqlComments(sql); + if (!cleaned) throw new Error('SQL 不能为空'); + if (cleaned.length > 20000) throw new Error('SQL 过长'); + if (/^\s*\./m.test(cleaned)) { + throw new Error('SQL 不允许使用 sqlite3 dot command'); + } + if (/\b(ATTACH|DETACH|LOAD_EXTENSION|VACUUM\s+INTO)\b/i.test(cleaned)) { + throw new Error('SQL 包含用户私有数据空间不允许的操作'); + } + if (/\bPRAGMA\s+writable_schema\b/i.test(cleaned)) { + throw new Error('SQL 包含不允许的 PRAGMA'); + } + if (readonly && !/^\s*(SELECT|WITH)\b/i.test(cleaned)) { + throw new Error('private_data_query 只允许 SELECT/WITH'); + } + return cleaned; +} + +async function queryPrivateData(sql) { + const db = ensurePrivateDataDb(); + const cleaned = rejectDangerousSql(sql, { readonly: true }); + const limited = `SELECT * FROM (${cleaned.replace(/;\s*$/, '')}) LIMIT ${PRIVATE_DATA_MAX_ROWS}`; + const output = runSqlite(['-json', db, limited]); + return output.trim() || '[]'; +} + +async function executePrivateData(sql) { + const db = ensurePrivateDataDb(); + const before = privateDataSize(); + if (before > PRIVATE_DATA_MAX_BYTES) throw new Error('用户私有数据空间已超过大小限制'); + const cleaned = rejectDangerousSql(sql); + const quotaPragma = await sqliteMaxPagePragmaForQuota(before); + runSqlite(['-batch', db, `${quotaPragma}\n${cleaned}`]); + const after = privateDataSize(); + if (after > PRIVATE_DATA_MAX_BYTES) { + throw new Error(`用户私有数据空间超过大小限制:${after}/${PRIVATE_DATA_MAX_BYTES} 字节`); + } + const delta = after - before; + const quotaSync = await syncPrivateDataQuota(delta); + return `已执行。当前数据空间大小 ${after} 字节${quotaSync ? `,已同步空间占用 ${delta} 字节` : ''}`; +} + +async function privateDataSchema() { + const db = ensurePrivateDataDb(); + const output = runSqlite([ + '-json', + db, + `SELECT m.name AS table_name, p.cid, p.name AS column_name, p.type, p."notnull" AS not_null, p.pk + FROM sqlite_master m + JOIN pragma_table_info(m.name) p + WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' + ORDER BY m.name, p.cid`, + ]); + return output.trim() || '[]'; +} + +async function callTool(name, args) { if (ALLOWED_TOOLS && !ALLOWED_TOOLS.has(name)) { throw new Error(`工具 ${name} 未授权`); } @@ -137,6 +450,86 @@ function callTool(name, args) { fs.mkdirSync(abs, { recursive: true }); return [{ type: 'text', text: `已创建目录 ${args.path}` }]; } + case 'private_data_info': { + ensurePrivateDataDb(); + const size = privateDataSize(); + const quota = await getQuotaState().catch(() => null); + return [ + { + type: 'text', + text: JSON.stringify( + { + name: '用户私有数据空间', + database: '.mindspace/private-data.sqlite', + maxBytes: PRIVATE_DATA_MAX_BYTES, + sizeBytes: size, + quotaSyncEnabled: Boolean(getQuotaPool()), + quota: quota + ? { + status: quota.status, + quotaBytes: quota.quotaBytes, + usedBytes: quota.usedBytes, + reservedBytes: quota.reservedBytes, + availableBytes: quota.availableBytes, + } + : null, + rules: [ + '每个用户只能使用这个唯一 SQLite 数据库', + '适合问卷、表单、清单、调研数据和分析中间表', + '不要存账号、计费、权限、审计、公开平台数据或跨用户数据', + ], + }, + null, + 2, + ), + }, + ]; + } + case 'private_data_schema': + return [{ type: 'text', text: await privateDataSchema() }]; + case 'private_data_query': + return [{ type: 'text', text: await queryPrivateData(args.sql) }]; + case 'private_data_execute': + return [{ type: 'text', text: await executePrivateData(args.sql) }]; + case 'schedule_create_item': { + const item = await getScheduleService().createItem({ + userId: PRIVATE_DATA_USER_ID, + kind: args.kind, + title: args.title, + description: args.description ?? null, + startAt: args.startAt ?? null, + endAt: args.endAt ?? null, + dueAt: args.dueAt ?? null, + allDay: Boolean(args.allDay), + timezone: args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai', + location: args.location ?? null, + sourceChannel: 'agent', + sourceMessageId: args.sourceMessageId ?? null, + sourceText: args.sourceText ?? null, + metadata: { source: 'schedule_assistant_skill' }, + }); + return [{ type: 'text', text: JSON.stringify(item, null, 2) }]; + } + case 'schedule_create_reminder': { + const reminder = await getScheduleService().createReminder({ + userId: PRIVATE_DATA_USER_ID, + itemId: args.itemId, + remindAt: args.remindAt, + offsetMinutes: args.offsetMinutes ?? null, + channel: args.channel ?? 'wechat', + }); + return [{ type: 'text', text: JSON.stringify(reminder, null, 2) }]; + } + case 'schedule_list_items': { + const items = await getScheduleService().listItems({ + userId: PRIVATE_DATA_USER_ID, + from: args.from ?? null, + to: args.to ?? null, + status: args.status ?? 'active', + limit: args.limit ?? 50, + }); + return [{ type: 'text', text: JSON.stringify(items, null, 2) }]; + } default: throw new Error(`未知工具:${name}`); } @@ -158,7 +551,7 @@ function respondError(id, message, code = -32603) { const rl = readline.createInterface({ input: process.stdin, terminal: false }); -rl.on('line', (raw) => { +rl.on('line', async (raw) => { const line = raw.trim(); if (!line) return; @@ -192,7 +585,7 @@ rl.on('line', (raw) => { case 'tools/call': { const { name, arguments: toolArgs } = params ?? {}; try { - const content = callTool(name, toolArgs ?? {}); + const content = await callTool(name, toolArgs ?? {}); respond(id, { content, isError: false }); } catch (err) { respond(id, { diff --git a/mindspace-sandbox-mcp.test.mjs b/mindspace-sandbox-mcp.test.mjs new file mode 100644 index 0000000..f9676e6 --- /dev/null +++ b/mindspace-sandbox-mcp.test.mjs @@ -0,0 +1,104 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; + +function startSandbox(root, envOverrides = {}) { + const child = spawn(process.execPath, ['mindspace-sandbox-mcp.mjs', root], { + cwd: process.cwd(), + env: { + ...process.env, + ALLOWED_TOOLS: + 'private_data_info,private_data_schema,private_data_query,private_data_execute', + PRIVATE_DATA_MAX_BYTES: String(1024 * 1024), + ...envOverrides, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const pending = new Map(); + let nextId = 1; + let buffer = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + buffer += chunk; + let idx; + while ((idx = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (!line.trim()) continue; + const msg = JSON.parse(line); + pending.get(msg.id)?.(msg); + pending.delete(msg.id); + } + }); + const request = (method, params = {}) => + new Promise((resolve) => { + const id = nextId++; + pending.set(id, resolve); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); + }); + return { child, request }; +} + +test('sandbox MCP exposes and protects the user private data space', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mindspace-sandbox-')); + const server = startSandbox(root); + t.after(() => server.child.kill()); + + await server.request('initialize'); + const listed = await server.request('tools/list'); + assert.deepEqual( + listed.result.tools.map((tool) => tool.name), + ['private_data_info', 'private_data_schema', 'private_data_query', 'private_data_execute'], + ); + + const create = await server.request('tools/call', { + name: 'private_data_execute', + arguments: { + sql: `CREATE TABLE surveys (id INTEGER PRIMARY KEY, title TEXT NOT NULL); + INSERT INTO surveys (title) VALUES ('满意度调查');`, + }, + }); + assert.equal(create.result.isError, false); + + const query = await server.request('tools/call', { + name: 'private_data_query', + arguments: { sql: 'SELECT id, title FROM surveys ORDER BY id' }, + }); + assert.equal(query.result.isError, false); + assert.deepEqual(JSON.parse(query.result.content[0].text), [{ id: 1, title: '满意度调查' }]); + assert.equal(fs.existsSync(path.join(root, '.mindspace', 'private-data.sqlite')), true); + + const rejected = await server.request('tools/call', { + name: 'private_data_query', + arguments: { sql: "ATTACH DATABASE '/tmp/other.sqlite' AS other" }, + }); + assert.equal(rejected.result.isError, true); + assert.match(rejected.result.content[0].text, /不允许|只允许/); + + const dotCommand = await server.request('tools/call', { + name: 'private_data_execute', + arguments: { sql: `.open /tmp/other.sqlite` }, + }); + assert.equal(dotCommand.result.isError, true); + assert.match(dotCommand.result.content[0].text, /dot command/); +}); + +test('sandbox MCP exposes schedule tools only when schedule env is configured', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mindspace-sandbox-schedule-')); + const server = startSandbox(root, { + ALLOWED_TOOLS: 'schedule_create_item,schedule_create_reminder,schedule_list_items', + PRIVATE_DATA_USER_ID: 'user-1', + DATABASE_URL: 'mysql://boot:pass@127.0.0.1:3306/test', + }); + t.after(() => server.child.kill()); + + await server.request('initialize'); + const listed = await server.request('tools/list'); + assert.deepEqual( + listed.result.tools.map((tool) => tool.name), + ['schedule_create_item', 'schedule_create_reminder', 'schedule_list_items'], + ); +}); diff --git a/mindspace.mjs b/mindspace.mjs index eaa0b3c..a67c2c9 100644 --- a/mindspace.mjs +++ b/mindspace.mjs @@ -1,7 +1,8 @@ import crypto from 'node:crypto'; +import { loadMindSpaceConfig } from './mindspace-config.mjs'; export const DEFAULT_SPACE_QUOTA_BYTES = 5 * 1024 * 1024; -export const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024; +export const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024; export const SYSTEM_CATEGORIES = Object.freeze([ { @@ -141,8 +142,17 @@ export async function ensureDefaultSpaces(pool, options = {}) { export function createMindSpaceService(pool, options = {}) { const maxFileBytes = Number(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES); const aiDailyLimit = Number(options.aiDailyLimit ?? 10); - const publicPageLimit = Number(options.publicPageLimit ?? 5); + const publicPageLimitFallback = Number(options.publicPageLimit ?? 5); const monthlyViewLimit = Number(options.monthlyViewLimit ?? 1000); + const scheduleService = options.scheduleService ?? null; + const resolvePublicPageLimit = async () => { + try { + const config = await loadMindSpaceConfig(pool); + return Number(config.publicPageLimit ?? publicPageLimitFallback); + } catch { + return publicPageLimitFallback; + } + }; const getSpace = async (userId) => { const [spaces] = await pool.query( @@ -190,6 +200,28 @@ export function createMindSpaceService(pool, options = {}) { FROM h5_publish_records WHERE user_id = ?`, [Date.now() - 30 * 24 * 60 * 60 * 1000, userId], ); + const publicPageLimit = await resolvePublicPageLimit(); + let schedule = null; + if (scheduleService) { + try { + const [todayTodoItems, digestSubscriptions] = await Promise.all([ + typeof scheduleService.listTodayTodoItems === 'function' + ? scheduleService.listTodayTodoItems({ userId }) + : Promise.resolve([]), + typeof scheduleService.listDigestSubscriptions === 'function' + ? scheduleService.listDigestSubscriptions({ + userId, + digestType: 'todo_day', + status: ['active', 'locked'], + limit: 10, + }) + : Promise.resolve([]), + ]); + schedule = { todayTodoItems, digestSubscriptions }; + } catch { + schedule = null; + } + } return { id: row.id, userId: row.user_id, @@ -211,6 +243,7 @@ export function createMindSpaceService(pool, options = {}) { categories: categories.map(categoryResponse), createdAt: asNumber(row.created_at), updatedAt: asNumber(row.updated_at), + schedule, }; }; diff --git a/mindspace.test.mjs b/mindspace.test.mjs index d3030e9..813d07d 100644 --- a/mindspace.test.mjs +++ b/mindspace.test.mjs @@ -108,6 +108,89 @@ test('getSpace scopes space and categories to the authenticated user', async () assert.deepEqual(calls[1].params, ['user-1', 'space-1']); }); +test('getSpace includes schedule snapshot when schedule service is available', async () => { + const pool = { + async query(sql, params) { + if (sql.includes('FROM h5_user_spaces')) { + return [[{ + id: 'space-1', + user_id: 'user-1', + space_name: '我的空间', + quota_bytes: 5 * 1024 * 1024, + used_bytes: 1024, + reserved_bytes: 2048, + status: 'active', + created_at: 10, + updated_at: 20, + }]]; + } + if (sql.includes('FROM h5_space_categories')) { + return [[{ + id: 'category-1', + category_code: 'private', + category_name: '私人区', + visibility_policy: 'private', + ai_access_policy: 'explicit_asset_grant', + publish_policy: 'desensitized_copy_only', + is_system: 1, + sort_order: 20, + item_count: 0, + }]]; + } + if (sql.includes('FROM h5_publish_records')) { + return [[{ public_page_used: 0, monthly_view_used: 0 }]]; + } + return [[]]; + }, + }; + const scheduleService = { + async listTodayTodoItems({ userId }) { + assert.equal(userId, 'user-1'); + return [ + { + id: 'item-1', + kind: 'task', + title: '跟进报价单', + description: '来自服务号', + status: 'active', + startAt: null, + endAt: null, + dueAt: Date.UTC(2026, 5, 18, 1, 30, 0), + allDay: false, + timezone: 'Asia/Shanghai', + location: null, + createdAt: 10, + updatedAt: 20, + }, + ]; + }, + async listDigestSubscriptions({ userId, digestType, status }) { + assert.equal(userId, 'user-1'); + assert.equal(digestType, 'todo_day'); + assert.deepEqual(status, ['active', 'locked']); + return [ + { + id: 'digest-1', + hour: 7, + minute: 0, + timezone: 'Asia/Shanghai', + channel: 'wechat', + status: 'active', + nextRunAt: Date.UTC(2026, 5, 18, 23, 0, 0), + lastRunAt: null, + attempts: 0, + }, + ]; + }, + }; + + const service = createMindSpaceService(pool, { scheduleService }); + const space = await service.getSpace('user-1'); + + assert.equal(space.schedule.todayTodoItems[0].title, '跟进报价单'); + assert.equal(space.schedule.digestSubscriptions[0].hour, 7); +}); + test('getSpace returns null when the user has no space', async () => { const service = createMindSpaceService({ async query() { diff --git a/ops/src/App.tsx b/ops/src/App.tsx index 61c864a..445f27b 100644 --- a/ops/src/App.tsx +++ b/ops/src/App.tsx @@ -10,7 +10,6 @@ import { ReportsPage } from './pages/ReportsPage'; import { ReviewPage } from './pages/ReviewPage'; import { SummaryPage } from './pages/admin/SummaryPage'; import { UsersPage } from './pages/admin/UsersPage'; -import { LlmPage } from './pages/admin/LlmPage'; import { BillingPage } from './pages/admin/BillingPage'; import { WechatPage } from './pages/admin/WechatPage'; @@ -43,7 +42,6 @@ export function App() { > } /> } /> - } /> } /> } /> } /> diff --git a/ops/src/api/admin.ts b/ops/src/api/admin.ts index 892a6d8..330fbf7 100644 --- a/ops/src/api/admin.ts +++ b/ops/src/api/admin.ts @@ -42,16 +42,6 @@ export type AdminSummary = { }; }; -export type LlmKey = { - id: string; - name: string; - provider: string; - model?: string; - models?: string[]; - isSelected: boolean; - createdAt?: string; -}; - export type LedgerEntry = { id: string; userId: string; @@ -160,6 +150,33 @@ export type WechatDeliveryLog = { digestType: string | null; }; +export type WechatWebNotification = { + id: string; + userId: string; + username: string; + displayName: string; + channel: string; + notificationType: string; + title: string; + body: string; + status: string; + readAt: number | null; + createdAt: number; + updatedAt: number; +}; + +export type CreateWechatNotificationPayload = { + userId?: string; + userIds?: string[]; + audience?: 'all'; + allUsers?: boolean; + title: string; + body: string; + notificationType?: string; + channel?: 'web' | 'wechat'; + channels?: Array<'web' | 'wechat'>; +}; + // ─── Summary ────────────────────────────────────────────────────────────────── export async function fetchAdminSummary() { @@ -269,6 +286,28 @@ export async function fetchWechatDeliveries(params: { status?: string; limit?: n return adminFetch<{ deliveries: WechatDeliveryLog[] }>(`/admin-api/wechat/deliveries?${q}`); } +export async function fetchWechatWebNotifications(params: { status?: string; limit?: number } = {}) { + const q = new URLSearchParams(); + if (params.status) q.set('status', params.status); + if (params.limit) q.set('limit', String(params.limit)); + return adminFetch<{ notifications: WechatWebNotification[] }>( + `/admin-api/wechat/web-notifications?${q}`, + ); +} + +export async function createWechatWebNotification(body: CreateWechatNotificationPayload) { + return adminFetch<{ + ok: boolean; + created: number; + wechatSent: number; + wechatFailures: Array<{ userId: string; message: string }>; + targets: number; + }>('/admin-api/wechat/web-notifications', { + method: 'POST', + body: JSON.stringify(body), + }); +} + export async function clearWechatRoute(userId: string) { return adminFetch<{ ok: boolean; deleted: number; openidMasked: string }>( `/admin-api/wechat/users/${userId}/route/clear`, @@ -285,60 +324,3 @@ export async function resumeWechatDigest(id: string) { method: 'POST', }); } - -// ─── LLM Providers ─────────────────────────────────────────────────────────── - -export async function fetchLlmKeys() { - return adminFetch<{ keys: LlmKey[] }>('/admin-api/llm-providers/keys'); -} - -export async function createLlmKey(body: { - name: string; - provider: string; - apiKey: string; - model?: string; - models?: string; - baseUrl?: string; -}) { - return adminFetch<{ key: LlmKey }>('/admin-api/llm-providers/keys', { - method: 'POST', - body: JSON.stringify(body), - }); -} - -export async function patchLlmKey(keyId: string, patch: { name?: string; apiKey?: string; model?: string }) { - return adminFetch<{ key: LlmKey }>(`/admin-api/llm-providers/keys/${keyId}`, { - method: 'PATCH', - body: JSON.stringify(patch), - }); -} - -export async function selectLlmKey(keyId: string) { - return adminFetch(`/admin-api/llm-providers/keys/${keyId}/select`, { method: 'POST' }); -} - -export async function deleteLlmKey(keyId: string) { - return adminFetch(`/admin-api/llm-providers/keys/${keyId}`, { method: 'DELETE' }); -} - -export async function testLlmKey(keyId: string) { - return adminFetch<{ ok: boolean; model?: string; error?: string }>( - `/admin-api/llm-providers/keys/${keyId}/test`, - { method: 'POST' }, - ); -} - -export async function fetchLlmGlobal() { - return adminFetch<{ settings: { model: string | null } }>('/admin-api/llm-providers/global'); -} - -export async function putLlmGlobal(model: string) { - return adminFetch('/admin-api/llm-providers/global', { - method: 'PUT', - body: JSON.stringify({ model }), - }); -} - -export async function syncLlmProviders() { - return adminFetch<{ synced: number }>('/admin-api/llm-providers/sync', { method: 'POST' }); -} diff --git a/ops/src/components/AdminLayout.tsx b/ops/src/components/AdminLayout.tsx index 375ab7c..94ae4d2 100644 --- a/ops/src/components/AdminLayout.tsx +++ b/ops/src/components/AdminLayout.tsx @@ -3,7 +3,6 @@ import { NavLink, Outlet } from 'react-router-dom'; const links = [ { to: '/admin', label: '概览', end: true }, { to: '/admin/users', label: '用户管理' }, - { to: '/admin/llm', label: 'LLM 配置' }, { to: '/admin/billing', label: '账单记录' }, { to: '/admin/wechat', label: '服务号管理' }, ]; @@ -13,7 +12,7 @@ export function AdminLayout() {

超级管理后台

-

用户、计费、LLM 与服务号

+

用户、计费与服务号

diff --git a/ops/src/pages/admin/LlmPage.tsx b/ops/src/pages/admin/LlmPage.tsx deleted file mode 100644 index 27eac78..0000000 --- a/ops/src/pages/admin/LlmPage.tsx +++ /dev/null @@ -1,354 +0,0 @@ -import { useEffect, useState } from 'react'; -import { - fetchLlmKeys, - fetchLlmGlobal, - createLlmKey, - patchLlmKey, - deleteLlmKey, - selectLlmKey, - testLlmKey, - putLlmGlobal, - syncLlmProviders, - type LlmKey, -} from '../../api/admin'; - -export function LlmPage() { - const [keys, setKeys] = useState([]); - const [globalModel, setGlobalModel] = useState(''); - const [globalModelInput, setGlobalModelInput] = useState(''); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - const [testResults, setTestResults] = useState>({}); - const [showCreate, setShowCreate] = useState(false); - const [editKey, setEditKey] = useState(null); - - const load = async () => { - setError(null); - try { - const [keysResult, globalResult] = await Promise.all([fetchLlmKeys(), fetchLlmGlobal()]); - setKeys(keysResult.keys); - const model = globalResult.settings?.model ?? ''; - setGlobalModel(model); - setGlobalModelInput(model); - } catch (err) { - setError(err instanceof Error ? err.message : '加载失败'); - } - }; - - useEffect(() => { void load(); }, []); - - const handleSelect = async (keyId: string) => { - setBusy(true); - try { - await selectLlmKey(keyId); - await load(); - } catch (err) { - setError(err instanceof Error ? err.message : '操作失败'); - } finally { - setBusy(false); - } - }; - - const handleDelete = async (key: LlmKey) => { - if (!window.confirm(`确认删除密钥「${key.name}」?`)) return; - setBusy(true); - try { - await deleteLlmKey(key.id); - await load(); - } catch (err) { - setError(err instanceof Error ? err.message : '操作失败'); - } finally { - setBusy(false); - } - }; - - const handleTest = async (key: LlmKey) => { - setTestResults((prev) => ({ ...prev, [key.id]: { ok: false, msg: '测试中…' } })); - try { - const result = await testLlmKey(key.id); - setTestResults((prev) => ({ - ...prev, - [key.id]: { ok: result.ok, msg: result.ok ? `OK (${result.model ?? ''})` : (result.error ?? '失败') }, - })); - } catch (err) { - setTestResults((prev) => ({ - ...prev, - [key.id]: { ok: false, msg: err instanceof Error ? err.message : '连接失败' }, - })); - } - }; - - const handleSaveGlobal = async () => { - setBusy(true); - try { - await putLlmGlobal(globalModelInput.trim()); - setGlobalModel(globalModelInput.trim()); - } catch (err) { - setError(err instanceof Error ? err.message : '保存失败'); - } finally { - setBusy(false); - } - }; - - const handleSync = async () => { - setBusy(true); - try { - const result = await syncLlmProviders(); - alert(`同步完成,更新 ${result.synced} 条`); - await load(); - } catch (err) { - setError(err instanceof Error ? err.message : '同步失败'); - } finally { - setBusy(false); - } - }; - - return ( -
- {error ?

{error}

: null} - - {/* Global model */} -
-

全局模型设置

-
- setGlobalModelInput(e.target.value)} - placeholder="如 deepseek-chat" - style={{ flex: '1 1 200px', padding: '8px 12px', borderRadius: 999, border: '1px solid #d6d0c3' }} - /> - -
- {globalModel ? ( -

当前:{globalModel}

- ) : ( -

未设置(使用选中密钥自带的 model)

- )} -
- - {/* Keys list */} -
-
-

API 密钥({keys.length})

-
- - -
-
- - {keys.length === 0 ? ( -

暂无配置密钥

- ) : ( - keys.map((key) => ( -
-
-
- {key.name} - {key.isSelected ? ( - ✓ 当前选中 - ) : null} -

- {key.provider} {key.model ? `· ${key.model}` : ''} - {key.models?.length ? ` · ${key.models.join(', ')}` : ''} -

-
-
- {!key.isSelected ? ( - - ) : null} - - - -
-
- {testResults[key.id] ? ( -

- {testResults[key.id].msg} -

- ) : null} -
- )) - )} -
- - {showCreate ? ( - setShowCreate(false)} - onSuccess={() => { setShowCreate(false); void load(); }} - /> - ) : null} - - {editKey ? ( - setEditKey(null)} - onSuccess={() => { setEditKey(null); void load(); }} - /> - ) : null} -
- ); -} - -function CreateKeyModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) { - const [name, setName] = useState(''); - const [provider, setProvider] = useState('openai'); - const [apiKey, setApiKey] = useState(''); - const [model, setModel] = useState(''); - const [baseUrl, setBaseUrl] = useState(''); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - const handleSubmit = async () => { - if (!name.trim() || !apiKey.trim()) { setError('名称和 API Key 必填'); return; } - setBusy(true); - try { - await createLlmKey({ name: name.trim(), provider, apiKey, model: model.trim() || undefined, baseUrl: baseUrl.trim() || undefined }); - onSuccess(); - } catch (err) { - setError(err instanceof Error ? err.message : '创建失败'); - } finally { - setBusy(false); - } - }; - - return ( - -
- setName(e.target.value)} placeholder="DeepSeek Default" /> - - - - setApiKey(e.target.value)} type="password" placeholder="sk-..." /> - setModel(e.target.value)} placeholder="deepseek-chat" /> - setBaseUrl(e.target.value)} placeholder="https://api.deepseek.com/v1" /> - {error ?

{error}

: null} - void handleSubmit()} busy={busy} /> -
-
- ); -} - -function EditKeyModal({ llmKey, onClose, onSuccess }: { llmKey: LlmKey; onClose: () => void; onSuccess: () => void }) { - const [name, setName] = useState(llmKey.name); - const [apiKey, setApiKey] = useState(''); - const [model, setModel] = useState(llmKey.model ?? ''); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - const handleSubmit = async () => { - setBusy(true); - try { - const patch: Record = {}; - if (name !== llmKey.name) patch.name = name.trim(); - if (apiKey) patch.apiKey = apiKey; - if (model !== (llmKey.model ?? '')) patch.model = model.trim(); - if (Object.keys(patch).length === 0) { onClose(); return; } - await patchLlmKey(llmKey.id, patch); - onSuccess(); - } catch (err) { - setError(err instanceof Error ? err.message : '更新失败'); - } finally { - setBusy(false); - } - }; - - return ( - -
- setName(e.target.value)} /> - setApiKey(e.target.value)} type="password" placeholder="留空不修改" /> - setModel(e.target.value)} /> - {error ?

{error}

: null} - void handleSubmit()} busy={busy} /> -
-
- ); -} - -function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) { - return ( -
e.target === e.currentTarget && onClose()} - > -
-

{title}

- {children} -
-
- ); -} - -function LlmField({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- - {children} -
- ); -} - -function ModalActions({ onClose, onSubmit, busy }: { onClose: () => void; onSubmit: () => void; busy: boolean }) { - return ( -
- - -
- ); -} diff --git a/ops/src/pages/admin/SummaryPage.tsx b/ops/src/pages/admin/SummaryPage.tsx index c608a7b..6de18a9 100644 --- a/ops/src/pages/admin/SummaryPage.tsx +++ b/ops/src/pages/admin/SummaryPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { fetchAdminSummary, type AdminSummary } from '../../api/admin'; function yuan(cents: number) { @@ -50,6 +51,28 @@ export function SummaryPage() {

LLM 服务未启用

)} + +
+
+

常用入口

+

+ 这里可以直接进入用户充值、服务号通知和网页端通知管理。 +

+
+ + 进入通知推送 + + + 进入用户管理 + +
); } diff --git a/ops/src/pages/admin/WechatPage.tsx b/ops/src/pages/admin/WechatPage.tsx index 518e5e1..db19e76 100644 --- a/ops/src/pages/admin/WechatPage.tsx +++ b/ops/src/pages/admin/WechatPage.tsx @@ -1,18 +1,23 @@ import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'; import { cancelWechatDigest, + createWechatWebNotification, clearWechatRoute, + fetchAdminUsers, fetchWechatBindings, fetchWechatDeliveries, fetchWechatDigests, fetchWechatMessages, fetchWechatSummary, + fetchWechatWebNotifications, resumeWechatDigest, + type AdminUser, type WechatAdminSummary, type WechatBinding, type WechatDeliveryLog, type WechatDigestSubscription, type WechatMessage, + type WechatWebNotification, } from '../../api/admin'; function time(value?: number | null) { @@ -66,29 +71,55 @@ export function WechatPage() { const [messages, setMessages] = useState([]); const [digests, setDigests] = useState([]); const [deliveries, setDeliveries] = useState([]); + const [webNotifications, setWebNotifications] = useState([]); + const [users, setUsers] = useState([]); const [search, setSearch] = useState(''); const [messageStatus, setMessageStatus] = useState(''); const [digestStatus, setDigestStatus] = useState(''); const [deliveryStatus, setDeliveryStatus] = useState(''); + const [notificationStatus, setNotificationStatus] = useState(''); + const [notifyAudience, setNotifyAudience] = useState<'single' | 'multi' | 'all'>('single'); + const [notifyUserId, setNotifyUserId] = useState(''); + const [notifyUserIds, setNotifyUserIds] = useState([]); + const [notifyTitle, setNotifyTitle] = useState(''); + const [notifyBody, setNotifyBody] = useState(''); + const [notifyType, setNotifyType] = useState('manual'); + const [notifyChannels, setNotifyChannels] = useState>(['web']); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); const load = async () => { setError(null); try { - const [summaryResult, bindingResult, messageResult, digestResult, deliveryResult] = + const [ + summaryResult, + bindingResult, + messageResult, + digestResult, + deliveryResult, + notificationResult, + userResult, + ] = await Promise.all([ fetchWechatSummary(), fetchWechatBindings({ search, limit: 80 }), fetchWechatMessages({ status: messageStatus || undefined, limit: 80 }), fetchWechatDigests({ status: digestStatus || undefined, limit: 80 }), fetchWechatDeliveries({ status: deliveryStatus || undefined, limit: 80 }), + fetchWechatWebNotifications({ status: notificationStatus || undefined, limit: 80 }), + fetchAdminUsers({ page: 1, pageSize: 200, status: 'active' }), ]); setSummary(summaryResult); setBindings(bindingResult.bindings); setMessages(messageResult.messages); setDigests(digestResult.digests); setDeliveries(deliveryResult.deliveries); + setWebNotifications(notificationResult.notifications); + setUsers(userResult.users); + setNotifyUserId((current) => + current || userResult.users[0]?.id || '', + ); } catch (err) { setError(err instanceof Error ? err.message : '加载失败'); } @@ -136,11 +167,80 @@ export function WechatPage() { } }; + const toggleChannel = (channel: 'web' | 'wechat') => { + setNotifyChannels((current) => { + if (current.includes(channel)) { + if (current.length === 1) return current; + return current.filter((item) => item !== channel); + } + return [...current, channel]; + }); + }; + + const handleSubmitNotification = async () => { + const title = notifyTitle.trim(); + const body = notifyBody.trim(); + if (!title) { + setError('请填写通知标题'); + return; + } + if (!body) { + setError('请填写通知内容'); + return; + } + setBusy(true); + setError(null); + setNotice(null); + try { + const payload = + notifyAudience === 'all' + ? { + audience: 'all' as const, + allUsers: true, + title, + body, + notificationType: notifyType.trim() || 'manual', + channels: notifyChannels, + } + : notifyAudience === 'multi' + ? { + userIds: notifyUserIds, + title, + body, + notificationType: notifyType.trim() || 'manual', + channels: notifyChannels, + } + : { + userId: notifyUserId, + title, + body, + notificationType: notifyType.trim() || 'manual', + channels: notifyChannels, + }; + const result = await createWechatWebNotification(payload); + const failureCount = result.wechatFailures?.length ?? 0; + setNotice( + `发送完成:目标 ${result.targets} 人,网页通知 ${result.created} 条,公众号成功 ${result.wechatSent} 条${ + failureCount ? `,失败 ${failureCount} 条` : '' + }。`, + ); + setNotifyTitle(''); + setNotifyBody(''); + if (notifyAudience === 'multi') setNotifyUserIds([]); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : '发送失败'); + } finally { + setBusy(false); + } + }; + if (!summary && !error) return

加载中...

; return (
{error ?

{error}

: null} + {notice ?

{notice}

: null} {summary ? (
) : null} +
+
+

主动通知推送

+ +
+
+ + + + + setNotifyType(event.target.value)} placeholder="manual / recharge / balance_low" /> + + +
+ + +
+
+ {notifyAudience === 'single' ? ( + + + + ) : null} + {notifyAudience === 'multi' ? ( + + + + ) : null} +
+ + setNotifyTitle(event.target.value)} placeholder="例如:系统维护提醒" /> + + +