diff --git a/.adm-api.pid b/.adm-api.pid new file mode 100644 index 0000000..e7579bb --- /dev/null +++ b/.adm-api.pid @@ -0,0 +1 @@ +26747 diff --git a/.adm-preview.pid b/.adm-preview.pid new file mode 100644 index 0000000..1b2a2f9 --- /dev/null +++ b/.adm-preview.pid @@ -0,0 +1 @@ +25645 diff --git a/.env.example b/.env.example index b7be71e..ae54e86 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,10 @@ ADM_API_PORT=8085 ADM_DEV_BACKEND=http://127.0.0.1:8085 +# gadm 独立登录(会话 cookie 绑定 gadm 域名;勿设 H5_COOKIE_DOMAIN=.tkmind.cn) +H5_PUBLIC_BASE_URL=https://gadm.tkmind.cn + +# 前端根路径部署(https://gadm.tkmind.cn/ 直达后台;Plaza 运营在 /ops) # ── MySQL(与 Memind 共用远程库,配置在本项目 .env)── DATABASE_URL=mysql://boot:password@host:3306/goose # 或分别设置: @@ -22,8 +26,17 @@ H5_USERS_ROOT=/Users/john/Project/memind_adm/data/users # TKMIND_API_TARGET=https://127.0.0.1:18006 # TKMIND_SERVER__SECRET_KEY=local-dev-secret +# 套餐同步到生产后台(可选;配置后套餐增删改会自动镜像到目标后台) +# PLAN_SYNC_TARGET_BASE_URL=https://gadm.tkmind.cn +# PLAN_SYNC_USERNAME=admin +# PLAN_SYNC_PASSWORD=change-me-admin +# PLAN_SYNC_TIMEOUT_MS=10000 + # Memind 业务模块路径(user-auth / llm-providers 等,默认 ../Memind) # MEMIND_LIB_ROOT=/Users/john/Project/Memind -# 点击「返回对话」时跳转到主 H5 -VITE_MAIN_APP_URL=http://localhost:5173 +# 超管页「返回对话」跳转主 H5(可选) +VITE_MAIN_APP_URL=https://h5.tkmind.cn + +# Plaza 帖子预览链接 +# VITE_PLAZA_BASE=https://plaza.tkmind.cn diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..28c0d92 --- /dev/null +++ b/.env.production @@ -0,0 +1,5 @@ +VITE_MAIN_APP_URL=https://h5.tkmind.cn +VITE_PLAZA_BASE=https://plaza.tkmind.cn + +# Qwen (DashScope) — 图片任务视觉模型,通过 admin UI 录入 DB 后此行可删除 +QWEN_API_KEY=sk-ws-H.RYHRXED.azAG.MEQCIFws404DdsDBh6fyHjmRUUE_ES5MsUYbV-UredhVKkh0AiB6Om6dHwKCuGyH1uDsZQGVgBtKMkXwe3Ic5tRZ1Mffuw diff --git a/.gitignore b/.gitignore index fb8d59a..5608d60 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ dist/ dist-ssr/ +memind-lib/ .vite/ *.log logs/ diff --git a/.h5-server.pid b/.h5-server.pid new file mode 100644 index 0000000..729db50 --- /dev/null +++ b/.h5-server.pid @@ -0,0 +1 @@ +41863 diff --git a/.mindops/scan-last.json b/.mindops/scan-last.json new file mode 100644 index 0000000..ed621a6 --- /dev/null +++ b/.mindops/scan-last.json @@ -0,0 +1,101 @@ +{ + "project_name": "test-memindadm", + "root_path": "/Users/john/PycharmProjects/test/test-memindadm", + "detected_stack": [ + "node", + "vite", + "nginx", + "shell" + ], + "key_files": [ + "package-lock.json", + "package.json", + "scripts/remote_restart.sh", + "scripts/rsync_to_server.sh", + "vite.config.ts" + ], + "startup_candidates": [ + { + "path": "package.json", + "reason": "package.json scripts" + }, + { + "path": "scripts/remote_restart.sh", + "reason": "restart shell" + } + ], + "deploy_files": [ + "scripts/rsync_to_server.sh" + ], + "probable_ports": [ + { + "port": 5174, + "source_file": "vite.config.ts", + "hint": "vite config (vite server.port)" + } + ], + "risks": [ + { + "code": "NO_README", + "level": "warn", + "message": "未发现 README,上手成本与约定不明" + } + ], + "suggestions": [ + "补充 README:启动命令、端口、环境变量、前后端如何联调", + "提交 lockfile,固定前端依赖版本" + ], + "scan_notes": [], + "env_dir_scan": [ + { + "relative_path": ".", + "depth": 0, + "has_dotenv": true + }, + { + "relative_path": "docs", + "depth": 1, + "has_dotenv": false + }, + { + "relative_path": "scripts", + "depth": 1, + "has_dotenv": false + }, + { + "relative_path": "server", + "depth": 1, + "has_dotenv": false + }, + { + "relative_path": "src", + "depth": 1, + "has_dotenv": false + }, + { + "relative_path": "src/admin", + "depth": 2, + "has_dotenv": false + }, + { + "relative_path": "src/api", + "depth": 2, + "has_dotenv": false + }, + { + "relative_path": "src/components", + "depth": 2, + "has_dotenv": false + }, + { + "relative_path": "src/lib", + "depth": 2, + "has_dotenv": false + }, + { + "relative_path": "src/ops", + "depth": 2, + "has_dotenv": false + } + ] +} diff --git a/.release-manifest.txt b/.release-manifest.txt new file mode 100644 index 0000000..ecf9484 --- /dev/null +++ b/.release-manifest.txt @@ -0,0 +1,14 @@ +release_id=20260626-124203-1d54706 +created_at=2026-06-26 12:42:06 +0800 +host=john.local +root=/Users/john/PycharmProjects/test/test-memindadm +git_head=1d54706b7c1c8dacc3ec4cf736230c30fef85593 +git_branch=main +git_status_begin +## main...origin/main [ahead 5] +?? .adm-api.pid +?? .adm-dev.pid +?? .adm-preview.pid +?? .env.production +?? .mindops/ +git_status_end diff --git a/DEVELOPMENT_RELEASE_RULES.md b/DEVELOPMENT_RELEASE_RULES.md new file mode 100644 index 0000000..ca6499b --- /dev/null +++ b/DEVELOPMENT_RELEASE_RULES.md @@ -0,0 +1,9 @@ +# 开发环境规则 + +1. 本仓库是 **本机 Mac 开发环境**,默认职责是开发、联调、测试、预演,不是生产运行目录。 +2. 每次开发都必须形成本地 Git 提交;至少在切换任务、交接、发布前,不能只停留在未提交工作区。 +3. 本机允许跑 `dev`、接口自测、前端构建、dry-run,但不允许从本机直接 `rsync` 到 `103` 或 `105`。 +4. 任何准备上线的改动都要先形成“可复现产物”,发布来源必须是可追溯 commit,而不是临时工作区状态。 +5. 本地验证至少包含受影响模块的最小验证、必要的 `npm run build`、关键接口自测。 +6. 如果改动涉及共享用户、计费、策略、技能、空间额度,必须额外做一次业务链路检查,不只看页面能打开。 +7. `.env`、日志、pid、`.mindops/` 等运行态内容不进入发布包,也不进入 Git。 diff --git a/ENGINEERING_WORKFLOW_RULES.md b/ENGINEERING_WORKFLOW_RULES.md new file mode 100644 index 0000000..c29e90c --- /dev/null +++ b/ENGINEERING_WORKFLOW_RULES.md @@ -0,0 +1,37 @@ +# 标准化开发测试发布约束 + +## 1. 仓库定位 + +1. 本仓库是 **本机 Mac 开发仓库**,不是生产目录。 +2. `memind_adm` 可以独立开发,但共享用户、权限、策略、技能、计费体系必须继续复用 `Memind` 主实现。 + +## 2. 开发约束 + +1. 每次开发都必须形成本地 Git commit。 +2. 一个 commit 只解决一类问题,避免把功能、环境、运维脚本混在一起。 +3. 不允许长期堆积“只有自己知道用途”的未提交改动。 +4. `.env`、账号、密钥、日志、pid 不进入 Git。 + +## 3. 测试约束 + +1. 任何会发布的改动,至少要有最小可复现验证。 +2. 只看健康检查和登录页 200 不算通过,必须验证本次功能真实路径。 +3. 涉及共享用户、计费、空间额度、策略同步时,必须保留业务验收记录。 + +## 4. 发布约束 + +1. 本机不允许直接 `rsync` 到 `103` 或 `105`。 +2. 测试与生产统一走“打包发布”。 +3. 发布来源必须是可追溯 commit,不允许从不明工作区直接出包。 +4. 发布前必须有备份,发布后必须有健康检查和业务验收。 + +## 5. 文档约束 + +1. 主机、端口、共享依赖目录、发布入口变化时,文档必须同次更新。 +2. 如果一次事故已经查明根因,要把根因和排查入口写进仓库文档,而不是只留在聊天记录里。 + +## 6. 建议长期执行的附加规范 + +1. 发布前要求工作区可读:`git status` 不能混入无关改动。 +2. 为每次正式发布保留 manifest、备份包路径、验证结果。 +3. 涉及数据库、计费、用户空间、鉴权的改动,要额外保留一份业务验收清单。 diff --git a/PRODUCTION_ARTIFACT_ONLY.md b/PRODUCTION_ARTIFACT_ONLY.md new file mode 100644 index 0000000..aabfdc0 --- /dev/null +++ b/PRODUCTION_ARTIFACT_ONLY.md @@ -0,0 +1,4 @@ +This directory is a deployed production artifact on 103. +Do not edit source files here. +Source of truth: local repos under /Users/john/PycharmProjects/test/. +Last artifact release manifest: .release-manifest.txt diff --git a/PRODUCTION_RELEASE_RULES.md b/PRODUCTION_RELEASE_RULES.md new file mode 100644 index 0000000..d75209f --- /dev/null +++ b/PRODUCTION_RELEASE_RULES.md @@ -0,0 +1,11 @@ +# 生产发布规则 + +1. `103` 是正式生产主机,`105` 不是本仓库源码发布目标;**本机一律不允许直接 `rsync` 到 `103` 或 `105`**,也不允许在线改源码后继续运行。 +2. `memind_adm` 生产发布唯一合法路径是:本地已提交代码 -> 本地生成发布包 -> 上传 `103` -> `103` 全量备份当前 `memind_adm` -> 解包到 release 目录 -> 安装依赖/构建 -> 原子切换 live 目录 -> 重启和健康检查。 +3. `scripts/release-prod.sh` 是当前唯一允许的生产更新入口,`scripts/rsync_to_server.sh` 只保留为禁用提示,不得再用于发布。 +4. 发布包不得携带运行态资产;`.env`、日志、pid 文件、`.mindops/` 等只能从线上现有 live 目录继承。 +5. 每次生产发布前必须先做全量备份;发布失败必须自动回滚到切换前的 live 目录。 +6. 发布清单必须记录:本地 commit、分支、发布时间、发布编号、是否含额外手工环境变更。 +7. 生产验证至少包含 `http://127.0.0.1:8085/health` 和 `http://127.0.0.1:5174/` 的成功检查,并补充本次功能对应的业务路径验收。 +8. 共享用户体系、计费体系、策略体系、空间额度改动上线后,必须做一次真实登录和 `/admin-api/users` 的业务校验。 +9. 生产热修复也不能绕过这套流程;“为了快”不是跳过备份、跳过 commit、跳过发布包的理由。 diff --git a/TEST_RELEASE_RULES.md b/TEST_RELEASE_RULES.md new file mode 100644 index 0000000..848a1b5 --- /dev/null +++ b/TEST_RELEASE_RULES.md @@ -0,0 +1,9 @@ +# 测试发布规则 + +1. 测试环境发布必须走“本地 commit -> 打包产物 -> 上传 -> 远端解包 -> 安装依赖 -> 构建 -> 重启验证”。 +2. 禁止使用 `rsync` 直接覆盖测试目录,避免把本地脏工作区和临时文件带进测试环境。 +3. 测试发布不得直接从未提交工作区取代码;至少要能回溯到明确的本地 commit。 +4. 每次测试发布都要保留发布编号、发布清单、远端日志和回滚入口。 +5. 测试环境允许更快迭代,但不允许跳过备份、依赖安装和健康检查。 +6. 只要涉及登录、用户列表、用户详情、计费、空间、策略同步,就必须补一条真实业务验收路径。 +7. 只有测试环境验证通过的发布流程,才能复用到生产。 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..0dd3398 --- /dev/null +++ b/docs/103-realignment-plan-2026-06-26.md @@ -0,0 +1,117 @@ +# memind_adm 103 收口方案(2026-06-26) + +## 当前判断 + +`memind_adm` 是三个仓库里最需要继续做“差异回收”的一个。 + +已确认: + +1. `103` 运行目录:`/Users/john/Project/memind_adm` +2. `103` 目录仍是 Git 工作树,但存在大量工作区漂移。 +3. 本地 `test-memindadm` 与 `103` 的主要分叉集中在用户详情、空间额度、套餐同步、启动装配。 + +## 当前高价值差异 + +### 线上独有 + +- `server/llm-provider-loader.mjs` + +说明: + +线上仍保留一层 provider 加载包装逻辑,本地主线已经切到共享实现。 + +### 本地主线独有 + +- `server/plan-sync.mjs` +- 用户详情空间字段相关前后端改动 +- 生产发布规则与 `release-prod.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` + +## 收口目标 + +把 `memind_adm` 从“线上和本地双向分叉”收口成: + +1. 本地 `test-memindadm` 是唯一源码真相。 +2. `103` 不再保留未回收的独立逻辑分叉。 +3. 用户详情、空间额度、套餐同步、共享用户体系都以本地主线为准。 + +## 具体执行步骤 + +### 第一步:锁定必须保留的本地主线能力 + +这一组应默认保留: + +1. 用户详情页独立拉取 +2. `spaceQuotaBytes` +3. `spaceUsedBytes` +4. `spaceReservedBytes` +5. `spaceAvailableBytes` +6. 计划同步与同步结果展示 + +原因: + +这组能力与近期真实需求直接相关,不应为了回收线上旧逻辑而退回。 + +### 第二步:单独审查线上独有装配逻辑 + +重点只看: + +- `server/llm-provider-loader.mjs` + +审查目标: + +1. 它是否只是旧兼容层 +2. 它是否包含线上必需但本地没吸收的行为 + +处理原则: + +1. 如果只是兼容包装,就把必要逻辑回收到本地主线并删除分叉。 +2. 如果确有线上必需行为,就先吸收进本地主线,再发布。 + +### 第三步:不要再把线上旧代码当默认正确答案 + +`103` 这里的作用只有两个: + +1. 证明线上曾经这样跑过 +2. 提供必须回收的旧逻辑证据 + +它不是: + +1. 后续开发主线 +2. 可继续直接修代码的地方 + +### 第四步:做第一次本地主线收口发布 + +1. 在本地完成必要逻辑吸收。 +2. 整理成最小 commit。 +3. 用 `bash scripts/release-prod.sh` 发布到 `103`。 +4. 发布后验证: + - `http://127.0.0.1:8085/health` + - `http://127.0.0.1:5174/` + - 真实登录 + - `/admin-api/users` + - 用户详情页 + - 空间字段展示与更新 + +## 何时算收口完成 + +满足以下条件即可视为完成: + +1. `server/llm-provider-loader.mjs` 的必要行为已被明确处理 +2. 空间额度与用户详情逻辑只在本地主线维护 +3. 最近一次线上版本来自本地发布包 +4. 共享用户体系与后台业务路径验收通过 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 57c6731..61b6b58 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -1,25 +1,61 @@ # memind_adm 部署与重启 -管理后台前端(React + Vite)部署到局域网 100 服务器(Tailscale `100.99.38.66`)。 +> 2026-06-26 起,103 / Studio 正式禁止 `rsync` 发布。 +> 唯一合法入口是 `bash scripts/release-prod.sh`。 +> +> 本仓库是 **本机 Mac 开发仓库**。每次开发都必须形成 Git commit;本机不允许直接 `rsync` 到 `103` 或 `105`,只能从本地 commit 打包发布。 + +管理后台前端(React + Vite)部署到生产 Studio 服务器(SSH 优先 `john@10.10.0.2:22`,局域网不通时退回 `john@58.38.22.103:22`)。 ## 环境概览 | 项 | 默认值 | |---|---| -| 部署目标 | `john@100.99.38.66:/Users/john/Project/memind_adm` | +| 部署目标 | `john@10.10.0.2:/Users/john/Project/memind_adm`(fallback `john@58.38.22.103`) | | 服务端口 | `5174` | | 运行方式 | `vite preview`(静态资源 + API 反代) | | 后端 API | 本仓库 `server/`(`ADM_API_PORT` 默认 `8085`,与 Memind portal 解耦) | | 日志 | `/Users/john/Project/memind_adm/adm-preview.log` | +## 硬规则 + +`memind_adm` 允许项目独立,**不允许用户体系、权限体系、技能体系、策略体系独立**。 + +必须遵守: + +1. 用户、登录会话、能力、策略、技能、账单都必须复用 `Memind` 主实现与主表。 +2. `memind_adm` 不得再引入或恢复独立后台用户表,例如 `auth_users`、`auth_sessions` 这类本地表实现。 +3. admin 账号必须是 `Memind` 主用户表中的同一账号,`npm run admin:init` 只允许更新主表账号密码,不允许新建旁路 admin 体系。 +4. 生产环境必须显式指向 `Memind` 业务模块目录,不能依赖“碰巧命中”的相对路径。 +5. 如果页面出现“用户、能力、技能、策略都是空的”,优先怀疑线上仍在跑旧代码,或 `MEMIND_LIB_ROOT` / 数据库连接未生效,不要先怀疑前端。 + +## 本次事故结论 + +2026-06-20 这次事故的根因不是生产库没数据,而是 **线上 `memind_adm` 实际仍在跑旧版独立鉴权代码**: + +1. 旧代码仍在查询 `auth_users`,没有切到 `Memind` 的 `createUserAuth()`。 +2. 远端 `.env` 虽然已经指向共享库和主库,但只改环境变量不等于新代码已生效。 +3. `scripts/rsync_to_server.sh` 默认 **不会覆盖远端已有 `.env`**,所以改了本地环境模板后,线上配置不一定会自动更新。 + +结论: + +1. “页面空”时,先查远端 `adm-api.log` 有没有 `auth_users`。 +2. 只看“部署脚本成功”不够,必须验远端实际代码和接口返回。 +3. 涉及共享用户体系的改动后,必须做登录和 `/admin-api/users` 实测。 + ## 前置条件 -1. 本机可 SSH 到 100 服务器(Tailscale 已连接): +1. 本机可 SSH 到 Studio 生产机: ```bash - ssh john@100.99.38.66 + ssh john@10.10.0.2 ``` 2. 本机已安装 Node.js,项目依赖已安装(`npm install`)。 -3. 100 服务器上 **memind_adm Admin API** 在 `8085` 运行(`remote_restart.sh` 会自动启动),MySQL 与 Memind 共用。 +3. Studio 服务器上 **memind_adm Admin API** 在 `8085` 运行(`remote_restart.sh` 会自动启动),MySQL 与 Memind 共用。 +4. 103 服务器上必须存在可读的 `Memind` 业务目录,例如 `/Users/john/Project/Memind`。 +5. 远端 `.env` 必须包含正确的共享环境变量: + `DATABASE_URL` 或 `MYSQL_*` + `MEMIND_LIB_ROOT=/Users/john/Project/Memind` + `H5_USERS_ROOT=/Users/john/Project/Memind/users` ## gadm 独立登录 @@ -31,13 +67,50 @@ | 会话 | 设置 `H5_PUBLIC_BASE_URL=https://gadm.tkmind.cn`,**不要**设 `H5_COOKIE_DOMAIN=.tkmind.cn` | | 路径 | `https://gadm.tkmind.cn/` 直达超管后台;Plaza 运营在 `/ops`;`/ops/admin` 已废弃 | | API | `ADM_DEV_BACKEND=http://127.0.0.1:8085`(preview 反代目标) | -| 初始化 admin | 首次部署后运行 `npm run admin:init`,密码只写入数据库,不放 `.env` | +| 初始化 admin | `npm run admin:init` 只更新 `Memind` 主用户表中的 admin 密码,不创建独立后台账号 | nginx 示例见 `scripts/gadm-nginx.conf.example`(`/ops/` → preview,`/auth` `/admin-api` `/api` → Admin API `8085`)。 -## 更新部署(推荐) +## 当前规则 -在本机项目根目录执行: +1. 本地当前工作区先打成发布包,不直接覆盖 103。 +2. 发布来源必须是可追溯的本地 commit,不允许从不明工作区直接上线。 +3. 103 在切换前必须做 `memind_adm` 全量备份。 +4. 发布包不能带 `.env`、日志、pid 文件、`.mindops/` 等运行态资产。 +5. 103 必须先在 release 目录完成解包、`npm install`、`npm run build`,再切换 live 目录。 +6. 切换后必须通过 Admin API 和前台首页健康检查;失败立即回滚。 + +## 唯一入口 + +```bash +bash scripts/release-prod.sh --dry-run +bash scripts/release-prod.sh +``` + +## 产物发布流程 + +1. 本地生成 `memind-adm-.tar.gz` 和发布清单。 +2. 通过 `scp` 上传到 103 的 `incoming/memind_adm/`。 +3. 103 备份当前 `/Users/john/Project/memind_adm` 为时间戳压缩包。 +4. 在 `/Users/john/Project/releases/` 下解包新版本。 +5. 从当前 live 目录继承运行态配置与日志文件。 +6. 在 release 目录安装依赖、构建。 +7. 原子切换 `memind_adm` live 目录。 +8. 调用 `scripts/remote_restart.sh` 重启 `8085` 和 `5174`。 +9. 检查 `127.0.0.1:8085/health` 与 `127.0.0.1:5174/`。 + +## 禁止事项 + +- 禁止 `scripts/rsync_to_server.sh` +- 禁止手工拖文件覆盖 103 +- 禁止在 103 直接改源码后继续跑 +- 禁止跳过备份和健康检查 + +## 更新部署(历史) + +下方 `rsync` 流程只保留作事故回溯背景。生产发布不要再照做。 + +旧流程示例: ```bash ./scripts/rsync_to_server.sh @@ -51,7 +124,13 @@ nginx 示例见 `scripts/gadm-nginx.conf.example`(`/ops/` → preview,`/auth 4. 若远端尚无 `.env`,写入默认配置(**不覆盖已有 `.env`**) 5. 调用 `scripts/remote_restart.sh` 重启服务 -### 常用参数 +注意: + +1. 如果本次改动涉及 `DATABASE_URL`、`MYSQL_*`、`MEMIND_LIB_ROOT`、`H5_USERS_ROOT`,需要**手动同步远端 `.env`**;`rsync_to_server.sh` 不会覆盖已有远端 `.env`。 +2. 如果本次改动涉及共享用户体系,部署完成后必须执行下文“共享用户体系验收”。 +3. 如果希望“套餐管理”自动同步到生产后台,还需要在远端 `.env` 配置 `PLAN_SYNC_TARGET_BASE_URL`、`PLAN_SYNC_USERNAME`、`PLAN_SYNC_PASSWORD`。 + +### 常用参数(历史) ```bash # 跳过本地构建(沿用当前 dist/) @@ -61,7 +140,7 @@ nginx 示例见 `scripts/gadm-nginx.conf.example`(`/ops/` → preview,`/auth ./scripts/rsync_to_server.sh --no-restart # 环境变量覆盖 -DEPLOY_HOST=john@100.99.38.66 \ +DEPLOY_HOST=john@10.10.0.2 \ REMOTE_DIR=/Users/john/Project/memind_adm \ ADM_PORT=5174 \ ./scripts/rsync_to_server.sh @@ -72,7 +151,7 @@ ADM_PORT=5174 \ 代码已在远端、无需重新同步时: ```bash -ssh john@100.99.38.66 'ADM_ROOT=/Users/john/Project/memind_adm ADM_PORT=5174 bash -s' \ +ssh john@10.10.0.2 'ADM_ROOT=/Users/john/Project/memind_adm ADM_PORT=5174 bash -s' \ < scripts/remote_restart.sh ``` @@ -90,14 +169,14 @@ ADM_PORT=5174 ./scripts/remote_restart.sh 本机通过 SSH 检查: ```bash -ssh john@100.99.38.66 'curl -sI http://127.0.0.1:5174/ | head -1' +ssh john@10.10.0.2 'curl -sI http://127.0.0.1:5174/ | head -1' # 期望: HTTP/1.1 200 OK ``` 查看远端日志: ```bash -ssh john@100.99.38.66 'tail -f /Users/john/Project/memind_adm/adm-preview.log' +ssh john@10.10.0.2 'tail -f /Users/john/Project/memind_adm/adm-preview.log' ``` ## 远端配置 @@ -111,18 +190,63 @@ ADM_DEV_BACKEND=http://127.0.0.1:8085 H5_PUBLIC_BASE_URL=https://gadm.tkmind.cn VITE_BASE_PATH=/ops +# 共享 Memind 主库与主实现 +DATABASE_URL=mysql://:@:3306/ +MEMIND_LIB_ROOT=/Users/john/Project/Memind +H5_USERS_ROOT=/Users/john/Project/Memind/users + +# 套餐自动同步到生产后台(可选) +PLAN_SYNC_TARGET_BASE_URL=https://gadm.tkmind.cn +PLAN_SYNC_USERNAME=admin +PLAN_SYNC_PASSWORD= +PLAN_SYNC_TIMEOUT_MS=10000 + # 「返回对话」跳转主 H5(可选) VITE_MAIN_APP_URL=https://h5.tkmind.cn ``` 修改 `.env` 后需重启服务生效。 +## 共享用户体系验收 + +每次涉及用户/权限/技能/策略相关改动后,部署完成必须至少执行一次: + +1. 看远端代码是否已切到共享实现: + ```bash + ssh john@10.10.0.2 'cd /Users/john/Project/memind_adm && sed -n "1,80p" server/bootstrap.mjs' + ``` + 期望:出现 `createUserAuth`,而不是 `createLocalUserAuth`。 +2. 看远端 `server/local-auth.mjs` 是否只剩 cookie 工具: + ```bash + ssh john@10.10.0.2 'cd /Users/john/Project/memind_adm && sed -n "1,80p" server/local-auth.mjs' + ``` + 期望:**没有** `auth_users`、`auth_sessions`、`createLocalUserAuth`。 +3. 看启动日志是否仍出现旧表查询: + ```bash + ssh john@10.10.0.2 'cd /Users/john/Project/memind_adm && tail -n 120 adm-api.log' + ``` + 期望:**没有** `auth_users`;出现 `Admin DB connected` 与 `Memind lib: /Users/john/Project/Memind`。 +4. 实测登录和用户接口: + ```bash + ssh john@10.10.0.2 'python3 - <<\"PY\" +import requests +s = requests.Session() +base = "http://127.0.0.1:8085" +print(s.post(base + "/auth/login", json={"username":"admin","password":""}).status_code) +resp = s.get(base + "/admin-api/users", params={"page": 1, "pageSize": 5}) +print(resp.status_code) +print(resp.text[:800]) +PY' + ``` + 期望:`/admin-api/users` 返回 200,且结果中能看到共享主表用户数据,不是空列表。 + ## 故障排查 | 现象 | 处理 | |---|---| -| `无法 SSH 到 john@100.99.38.66` | 确认 Tailscale 在线;必要时 `ssh-copy-id john@100.99.38.66` | -| 首页 200 但登录失败 | 检查 100 上 `8085` Admin API 是否运行(`curl http://127.0.0.1:8085/health`) | +| `无法 SSH 到 john@10.10.0.2` | 先确认局域网可达;不通时改走 `john@58.38.22.103`,必要时再执行 `ssh-copy-id john@58.38.22.103` | +| 首页 200 但登录失败 | 检查 Studio 上 `8085` Admin API 是否运行(`curl http://127.0.0.1:8085/health`) | +| 用户、能力、技能、策略全空 | 先查 `adm-api.log` 是否仍有 `auth_users`;再核对远端 `server/bootstrap.mjs` 是否已切到 `createUserAuth()`;再核对远端 `.env` 的 `MEMIND_LIB_ROOT` 与 `DATABASE_URL` | | 打开后跳 localhost:5173 | nginx 仍指向 Memind/ops dev;改反代到 memind_adm `:5174`,并重新部署 | | `dist 不存在` | 先在本机 `npm run build`,或完整执行 `./scripts/rsync_to_server.sh` | | 启动失败 | 查看 `adm-preview.log`;确认远端 Node 在 PATH 中(需 Homebrew `node@22` 等) | @@ -132,6 +256,14 @@ VITE_MAIN_APP_URL=https://h5.tkmind.cn | 脚本 | 说明 | |---|---| -| `scripts/rsync_to_server.sh` | 构建 + rsync + 重启(一键部署) | +| `scripts/release-prod.sh` | 本地打发布包并推送到 103,再由 103 备份、解包、切换、重启 | +| `scripts/rsync_to_server.sh` | 已禁用,仅保留禁用提示 | | `scripts/remote_restart.sh` | 仅在远端重启 `vite preview` | -| `scripts/.rsync-exclude-lan` | rsync 排除规则 | +| `scripts/.releaseignore-prod` | 生产发布包排除规则 | + +## 规则文档 + +- `ENGINEERING_WORKFLOW_RULES.md` +- `DEVELOPMENT_RELEASE_RULES.md` +- `TEST_RELEASE_RULES.md` +- `PRODUCTION_RELEASE_RULES.md` diff --git a/package-lock.json b/package-lock.json index 2fbd278..e9c7e89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,18 @@ "version": "0.1.0", "dependencies": { "@node-rs/argon2": "^2.0.2", + "@resvg/resvg-js": "^2.6.2", + "debug": "^4.4.3", "express": "^4.21.2", + "http-proxy-middleware": "^3.0.3", + "jsonrepair": "^3.14.0", "mysql2": "^3.22.5", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.13.1" + "react-router-dom": "^7.13.1", + "redis": "^4.7.1", + "undici": "^6.26.0" }, "devDependencies": { "@types/react": "^19.0.10", @@ -1089,6 +1096,298 @@ "node": ">= 10" } }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1508,12 +1807,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "25.9.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -1572,6 +1879,30 @@ "node": ">= 0.6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1639,6 +1970,18 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -1711,6 +2054,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -1732,6 +2084,44 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1790,7 +2180,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1804,6 +2193,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -1832,6 +2230,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1859,6 +2263,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1965,6 +2375,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -2053,6 +2469,18 @@ } } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -2086,6 +2514,39 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2137,6 +2598,15 @@ "is-property": "^1.0.2" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2147,6 +2617,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2240,6 +2719,37 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.7.tgz", + "integrity": "sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==", + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.15", + "debug": "^4.3.6", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.3", + "is-plain-object": "^5.0.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^14.18.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -2267,6 +2777,54 @@ "node": ">= 0.10" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -2306,6 +2864,27 @@ "node": ">=6" } }, + "node_modules/jsonrepair": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.14.1.tgz", + "integrity": "sha512-NpGgMhmzG/fajkBEFlS9jZvMSGDvc2xN/9wNCHZ+Nx32GZfLRELU6UE6dQkebvrQUct9S+7bvnpX29NB36Qbdw==", + "license": "ISC", + "bin": { + "jsonrepair": "bin/cli.js" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -2373,6 +2952,31 @@ "node": ">= 0.6" } }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -2524,6 +3128,42 @@ "node": ">= 0.8" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2533,6 +3173,15 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -2559,6 +3208,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -2601,6 +3259,23 @@ "node": ">= 0.10" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -2709,6 +3384,44 @@ "react-dom": ">=18" } }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", @@ -2850,6 +3563,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -2968,6 +3687,32 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2985,6 +3730,18 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -3028,12 +3785,20 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", @@ -3168,12 +3933,73 @@ } } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/package.json b/package.json index 3a8b2d1..c109b16 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,27 @@ "dev:web": "vite", "dev:server": "node server/index.mjs", "admin:init": "node scripts/init-admin.mjs", + "release:prod": "bash scripts/release-prod.sh", + "local_restart": "bash scripts/local_restart.sh", + "pro_restart": "bash scripts/pro_restart.sh", "build": "vite build", "preview": "node scripts/preview.mjs", "dev:preview": "vite preview" }, "dependencies": { "@node-rs/argon2": "^2.0.2", + "@resvg/resvg-js": "^2.6.2", + "debug": "^4.4.3", "express": "^4.21.2", + "http-proxy-middleware": "^3.0.3", + "jsonrepair": "^3.14.0", "mysql2": "^3.22.5", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.13.1" + "react-router-dom": "^7.13.1", + "redis": "^4.7.1", + "undici": "^6.26.0" }, "devDependencies": { "@types/react": "^19.0.10", diff --git a/scripts/.releaseignore-prod b/scripts/.releaseignore-prod new file mode 100644 index 0000000..dcc5060 --- /dev/null +++ b/scripts/.releaseignore-prod @@ -0,0 +1,38 @@ +.git +.gitignore +.DS_Store +.env +.env.example +.env.production +.adm-api.pid +.adm-dev.pid +.adm-preview.pid +.claude +.mindops +node_modules +coverage +*.log +adm-preview.log +adm-api.log +# 开发源码与文档 — 103 只允许运行产物,不允许保留可编辑源码树 +src +docs +tsconfig.app.json +tsconfig.json +tsconfig.node.json +vite.config.ts +# 开发入口页(dist/index.html 必须保留) +/index.html +DEVELOPMENT_RELEASE_RULES.md +ENGINEERING_WORKFLOW_RULES.md +PRODUCTION_RELEASE_RULES.md +TEST_RELEASE_RULES.md +# 仅本机开发/运维脚本 +scripts/dev.mjs +scripts/local_restart.sh +scripts/pro_restart.sh +scripts/rsync_to_server.sh +scripts/audit-103-state.sh +scripts/gadm-nginx-105.conf +scripts/gadm-nginx.conf.example +scripts/gadm-lb.Caddyfile diff --git a/scripts/audit-103-state.sh b/scripts/audit-103-state.sh new file mode 100644 index 0000000..fe38363 --- /dev/null +++ b/scripts/audit-103-state.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +HOST="${STUDIO_HOST:-john@10.10.0.2}" +APP_DIR="${STUDIO_APP_DIR:-/Users/john/Project/memind_adm}" + +ssh -o BatchMode=yes "${HOST}" " +set -e +echo '== host ==' +hostname +echo +echo '== app dir ==' +echo '${APP_DIR}' +echo +echo '== git ==' +cd '${APP_DIR}' +git rev-parse HEAD 2>/dev/null || echo 'no-git' +git status --short --branch 2>/dev/null | sed -n '1,120p' || true +echo +echo '== env target ==' +sed -n '1,120p' .env 2>/dev/null | grep -E '^(DATABASE_URL|MYSQL_|MEMIND_LIB_ROOT|H5_USERS_ROOT|PLAN_SYNC_)' || true +echo +echo '== health ==' +curl -s http://127.0.0.1:8085/health || true +echo +curl -sI http://127.0.0.1:5174/ | head -1 || true +" diff --git a/scripts/gadm-lb.Caddyfile b/scripts/gadm-lb.Caddyfile index d440b62..182ada6 100644 --- a/scripts/gadm-lb.Caddyfile +++ b/scripts/gadm-lb.Caddyfile @@ -1,4 +1,5 @@ -# gadm.tkmind.cn → 本机管理端 (vite preview :5174,API 反代 :8085) +# gadm.tkmind.cn → memind_adm(vite preview :5174 + Admin API :8085) +# 生产 nginx 需把 /ops/ 反代到 5174,勿再指向 Memind/ops dev server :8091 { reverse_proxy 127.0.0.1:5174 { flush_interval -1 diff --git a/scripts/gadm-nginx-105.conf b/scripts/gadm-nginx-105.conf new file mode 100644 index 0000000..7bc5bbe --- /dev/null +++ b/scripts/gadm-nginx-105.conf @@ -0,0 +1,82 @@ +# gadm.tkmind.cn @ 105 nginx → Studio LAN 10.10.0.2(fallback 58.38.22.103) +# memind_adm: preview :5174, Admin API :8085(根路径部署,/ 直达后台) + +server { + listen 80; + listen [::]:80; + server_name gadm.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 gadm.tkmind.cn; + ssl_certificate /etc/letsencrypt/live/gadm.tkmind.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/gadm.tkmind.cn/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location ^~ /ops/admin { + return 301 /; + } + + location /auth { + proxy_pass http://10.10.0.2:8085; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120; + proxy_send_timeout 120; + proxy_buffering off; + } + + location /admin-api { + proxy_pass http://10.10.0.2:8085; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120; + proxy_send_timeout 120; + proxy_buffering off; + } + + location /api { + proxy_pass http://10.10.0.2:8085; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120; + proxy_send_timeout 120; + proxy_buffering off; + } + + location / { + proxy_pass http://10.10.0.2:5174; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120; + proxy_send_timeout 120; + proxy_buffering off; + } +} diff --git a/scripts/gadm-nginx.conf.example b/scripts/gadm-nginx.conf.example new file mode 100644 index 0000000..2fd5bc2 --- /dev/null +++ b/scripts/gadm-nginx.conf.example @@ -0,0 +1,54 @@ +# gadm.tkmind.cn — memind_adm 独立管理端(替换原 Memind /ops Vite dev) +# +# 前置:100 服务器 remote_restart.sh 已启动 +# Admin API: 127.0.0.1:8085 +# vite preview: 127.0.0.1:5174(静态资源 base=/ops/) +# +# 前端 fetch 使用根路径 /auth、/admin-api、/api(非 /ops/auth),nginx 需单独反代到 Admin API。 + +server { + listen 443 ssl http2; + server_name gadm.tkmind.cn; + + # ssl_certificate ...; + + location = / { + return 302 /ops/admin/; + } + + location /auth { + proxy_pass http://127.0.0.1:8085; + 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; + } + + location /admin-api { + proxy_pass http://127.0.0.1:8085; + 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; + } + + location /api { + proxy_pass http://127.0.0.1:8085; + 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; + } + + location /ops/ { + proxy_pass http://127.0.0.1:5174; + 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; + } +} diff --git a/scripts/init-admin.mjs b/scripts/init-admin.mjs index 994bba9..6289a65 100644 --- a/scripts/init-admin.mjs +++ b/scripts/init-admin.mjs @@ -1,8 +1,28 @@ #!/usr/bin/env node +import crypto from 'node:crypto'; import readline from 'node:readline/promises'; import { stdin as input, stdout as output } from 'node:process'; +import { Algorithm as Argon2Algorithm, hashRawSync as argon2HashRawSync } from '@node-rs/argon2'; import { createDbPool, isDatabaseConfigured } from '../server/db.mjs'; -import { createLocalUserAuth } from '../server/local-auth.mjs'; + +const PASSWORD_ALGORITHM_ARGON2ID = 'argon2id'; +const ARGON2_MEMORY = 64 * 1024; +const ARGON2_PASSES = 3; +const ARGON2_PARALLELISM = 1; +const ARGON2_TAG_LENGTH = 32; + +function createPasswordRecord(password) { + const salt = crypto.randomBytes(16).toString('hex'); + const passwordHash = argon2HashRawSync(password, { + salt: Buffer.from(salt, 'hex'), + parallelism: ARGON2_PARALLELISM, + outputLen: ARGON2_TAG_LENGTH, + memoryCost: ARGON2_MEMORY, + timeCost: ARGON2_PASSES, + algorithm: Argon2Algorithm.Argon2id, + }).toString('hex'); + return { salt, passwordHash, passwordAlgorithm: PASSWORD_ALGORITHM_ARGON2ID }; +} if (!isDatabaseConfigured()) { console.error('MySQL 未配置,请先设置 DATABASE_URL 或 MYSQL_*'); @@ -30,18 +50,23 @@ if (!password || password !== confirm) { } const pool = createDbPool(); -const auth = createLocalUserAuth(pool); -await auth.ensureAdminUser().catch(() => {}); +const [rows] = await pool.execute( + `SELECT id FROM h5_users WHERE username = ? AND role = 'admin' LIMIT 1`, + [username], +); +if (!rows.length) { + console.error(`未找到共享主用户表中的 admin 账号: ${username}`); + await pool.end(); + process.exit(1); +} + +const nextPassword = createPasswordRecord(password); await pool.execute( - `INSERT INTO auth_users (username, display_name, role, status, password_hash, balance_cents) - VALUES (?, ?, 'admin', 'active', ?, 0) - ON DUPLICATE KEY UPDATE - display_name = VALUES(display_name), - role = VALUES(role), - status = VALUES(status), - password_hash = VALUES(password_hash)`, - [username, username, auth.hashPassword(password)], + `UPDATE h5_users + SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? + WHERE id = ?`, + [nextPassword.salt, nextPassword.passwordHash, nextPassword.passwordAlgorithm, Date.now(), rows[0].id], ); -console.log(`admin 账号已写入数据库: ${username}`); +console.log(`admin 账号密码已更新到 h5_users: ${username}`); await pool.end(); diff --git a/scripts/local_restart.sh b/scripts/local_restart.sh new file mode 100755 index 0000000..1085fb5 --- /dev/null +++ b/scripts/local_restart.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PORT="${ADM_PORT:-5174}" +API_PORT="${ADM_API_PORT:-8085}" +NODE_PATH="${LOCAL_NODE_PATH:-/opt/homebrew/opt/node@24/bin:/opt/homebrew/opt/node@22/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin}" +LOG="${ROOT}/adm-preview.log" +API_LOG="${ROOT}/adm-api.log" +PID_FILE="${ROOT}/.adm-preview.pid" +API_PID_FILE="${ROOT}/.adm-api.pid" + +export PATH="${NODE_PATH}:${PATH}" +command -v node >/dev/null 2>&1 || { echo "❌ 未找到 node" >&2; exit 1; } + +cd "${ROOT}" + +if [[ ! -d node_modules ]]; then + echo "==> 安装依赖..." + npm install +fi + +echo "==> 本机构建..." +npm run build + +echo "==> 停止旧进程 (port ${PORT}, ${API_PORT})..." +lsof -ti "TCP:${PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true +lsof -ti "TCP:${API_PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true +sleep 1 +lsof -ti "TCP:${PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true +lsof -ti "TCP:${API_PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true +rm -f "${PID_FILE}" "${API_PID_FILE}" + +echo "==> 启动本机 Admin API @ 127.0.0.1:${API_PORT}..." +nohup node server/index.mjs >>"${API_LOG}" 2>&1 & +echo $! >"${API_PID_FILE}" +sleep 2 + +if ! curl -sf "http://127.0.0.1:${API_PORT}/health" >/dev/null; then + echo "❌ 本机 Admin API 启动失败,最近日志:" >&2 + tail -n 60 "${API_LOG}" >&2 || true + exit 1 +fi + +echo "==> 启动本机 vite preview @ 127.0.0.1:${PORT}..." +nohup npm run dev:preview -- --host 127.0.0.1 --port "${PORT}" >>"${LOG}" 2>&1 & +echo $! >"${PID_FILE}" +sleep 2 + +if ! curl -sf "http://127.0.0.1:${PORT}/" >/dev/null; then + echo "❌ 本机 preview 启动失败,最近日志:" >&2 + tail -n 60 "${LOG}" >&2 || true + exit 1 +fi + +echo "✅ local_restart 完成: http://127.0.0.1:${PORT}/" diff --git a/scripts/pro_restart.sh b/scripts/pro_restart.sh new file mode 100755 index 0000000..fdabce7 --- /dev/null +++ b/scripts/pro_restart.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +cd "${ROOT}" + +echo "==> 在当前机器执行 pro_restart" +bash "${ROOT}/scripts/remote_restart.sh" + +echo "✅ pro_restart 完成" diff --git a/scripts/release-prod.sh b/scripts/release-prod.sh new file mode 100755 index 0000000..64fb65a --- /dev/null +++ b/scripts/release-prod.sh @@ -0,0 +1,283 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HOST="${STUDIO_HOST:-10.10.0.2}" +REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}" +APP_NAME="${STUDIO_APP_NAME:-memind_adm}" +APP_DIR="${REMOTE_ROOT}/${APP_NAME}" +INCOMING_DIR="${REMOTE_ROOT}/incoming/memind_adm" +RELEASES_DIR="${REMOTE_ROOT}/releases" +BACKUP_DIR="${REMOTE_ROOT}/backups/memind_adm" +ARCHIVE_DIR="${REMOTE_ROOT}/archives" +IGNORE_FILE="${ROOT}/scripts/.releaseignore-prod" +WEB_HEALTH_URL="${STUDIO_WEB_HEALTH_URL:-http://127.0.0.1:5174/ops/}" +API_HEALTH_URL="${STUDIO_API_HEALTH_URL:-http://127.0.0.1:8085/health}" +RELEASE_TS="$(date +%Y%m%d-%H%M%S)" +SHORT_SHA="$(git -C "${ROOT}" rev-parse --short HEAD 2>/dev/null || echo no-git)" +RELEASE_ID="${RELEASE_TS}-${SHORT_SHA}" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/memindadm-release.XXXXXX")" +BUNDLE_PATH="${TMP_DIR}/memind-adm-${RELEASE_ID}.tar.gz" +MANIFEST_PATH="${TMP_DIR}/memind-adm-${RELEASE_ID}.manifest.txt" +SHA_PATH="${TMP_DIR}/memind-adm-${RELEASE_ID}.sha256" +MEMIND_SRC="${TEST_MEMIND_ROOT:-${ROOT}/../test-memind}" +MEMIND_LIB="${ROOT}/memind-lib" +DRY_RUN=0 +SKIP_BUILD=0 +AUTO_YES=0 + +cleanup() { + rm -rf "${TMP_DIR}" "${MEMIND_LIB}" +} +trap cleanup EXIT + +usage() { + cat <<'EOF' +用法: + bash scripts/release-prod.sh [--dry-run] [--skip-build] [--yes] + +说明: + 1. 本地构建 dist 并打包运行产物(不含 src/ 等开发源码) + 2. 上传到 103 + 3. 103 全量备份当前 memind_adm + 4. 解包、安装依赖(不在 103 构建前端) + 5. 原子切换 live 目录并重启验证 + 6. 旧 live 目录移入 archives,103 不再保留可编辑源码树 + +禁止: + - 直接 rsync 到 103 + - 在 103 生产目录手改源码后继续运行 + - 在 103 保留 src/、vite.config.ts 等开发源码 +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=1 ;; + --skip-build) SKIP_BUILD=1 ;; + --yes|-y) AUTO_YES=1 ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "未知参数: $1" >&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +say() { + printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "缺少命令: $1" >&2 + exit 1 + } +} + +need_cmd ssh +need_cmd scp +need_cmd tar +need_cmd shasum + +[[ -f "${IGNORE_FILE}" ]] || { + echo "缺少忽略文件: ${IGNORE_FILE}" >&2 + exit 1 +} + +say "本地预检查" +ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo release-ssh-ok" >/dev/null +ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/package.json'" >/dev/null + +if [[ "${SKIP_BUILD}" -ne 1 ]]; then + say "本地构建 dist(103 不再执行前端构建)" + ( + cd "${ROOT}" + VITE_BASE_PATH="${VITE_BASE_PATH:-/ops}" npm run build + ) +fi + +[[ -d "${ROOT}/dist" ]] || { + echo "缺少 dist/,请先本地构建或去掉 --skip-build" >&2 + exit 1 +} + +[[ -d "${MEMIND_SRC}" ]] || { + echo "缺少 test-memind 共享模块源: ${MEMIND_SRC}" >&2 + exit 1 +} + +say "打包 Memind 共享模块到 memind-lib" +rm -rf "${MEMIND_LIB}" +mkdir -p "${MEMIND_LIB}" +find "${MEMIND_SRC}" -maxdepth 1 -name '*.mjs' -exec cp -f {} "${MEMIND_LIB}/" \; +[[ -f "${MEMIND_LIB}/user-auth.mjs" ]] || { + echo "memind-lib 缺少 user-auth.mjs" >&2 + exit 1 +} + +if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then + say "发布确认" + echo "目标主机: ${HOST}" + echo "目标目录: ${APP_DIR}" + echo "发布编号: ${RELEASE_ID}" + echo "本地 HEAD: $(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)" + echo "工作区状态:" + git -C "${ROOT}" status --short --branch | sed -n '1,120p' + read -r -p "确认继续发布到 103? [y/N] " confirm /dev/null || echo unknown)" + echo "git_branch=$(git -C "${ROOT}" branch --show-current 2>/dev/null || echo detached)" + echo "git_status_begin" + git -C "${ROOT}" status --short --branch || true + echo "git_status_end" +} > "${MANIFEST_PATH}" + +say "打包当前工作区" +tar \ + --exclude-from="${IGNORE_FILE}" \ + -czf "${BUNDLE_PATH}" \ + -C "${ROOT}" \ + . +checksum="$(shasum -a 256 "${BUNDLE_PATH}" | awk '{print $1}')" +printf '%s %s\n' "${checksum}" "$(basename "${BUNDLE_PATH}")" > "${SHA_PATH}" + +if [[ "${DRY_RUN}" -eq 1 ]]; then + say "dry-run 完成" + ls -lh "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" + exit 0 +fi + +say "上传发布产物到 103" +ssh -o BatchMode=yes "${HOST}" "mkdir -p '${INCOMING_DIR}' '${RELEASES_DIR}' '${BACKUP_DIR}' '${ARCHIVE_DIR}'" +scp -q "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" "${HOST}:${INCOMING_DIR}/" + +say "在 103 备份并发布" +ssh -o BatchMode=yes "${HOST}" \ + "RELEASE_ID='${RELEASE_ID}' APP_DIR='${APP_DIR}' INCOMING_DIR='${INCOMING_DIR}' RELEASES_DIR='${RELEASES_DIR}' BACKUP_DIR='${BACKUP_DIR}' ARCHIVE_DIR='${ARCHIVE_DIR}' WEB_HEALTH_URL='${WEB_HEALTH_URL}' API_HEALTH_URL='${API_HEALTH_URL}' /bin/bash" <<'REMOTE_SCRIPT' +set -euo pipefail + +RELEASE_DIR="${RELEASES_DIR}/memind-adm-${RELEASE_ID}" +OLD_LIVE_DIR="${ARCHIVE_DIR}/memind_adm-source-before-${RELEASE_ID}" +BUNDLE="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.tar.gz" +MANIFEST="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.manifest.txt" +SHA_FILE="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.sha256" +BACKUP_TAR="${BACKUP_DIR}/memind-adm-${RELEASE_ID}-before.tar.gz" +PERSISTED_ITEMS=( + ".env" + "adm-preview.log" + "adm-api.log" +) + +say() { + printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*" +} + +rollback() { + if [[ -d "${OLD_LIVE_DIR}" && ! -d "${APP_DIR}" ]]; then + mv "${OLD_LIVE_DIR}" "${APP_DIR}" + fi + ADM_ROOT="${APP_DIR}" ADM_PORT=5174 ADM_API_PORT=8085 bash "${APP_DIR}/scripts/remote_restart.sh" >/dev/null 2>&1 || true +} + +trap 'rollback' ERR + +[[ -f "${BUNDLE}" ]] || { echo "missing bundle: ${BUNDLE}" >&2; exit 1; } +[[ -f "${MANIFEST}" ]] || { echo "missing manifest: ${MANIFEST}" >&2; exit 1; } +[[ -f "${SHA_FILE}" ]] || { echo "missing sha file: ${SHA_FILE}" >&2; exit 1; } + +say "校验产物完整性" +cd "${INCOMING_DIR}" +shasum -a 256 -c "$(basename "${SHA_FILE}")" + +say "全量备份当前 live 目录" +COPYFILE_DISABLE=1 tar -czf "${BACKUP_TAR}" -C "$(dirname "${APP_DIR}")" "$(basename "${APP_DIR}")" + +say "准备 release 目录" +rm -rf "${RELEASE_DIR}" +mkdir -p "${RELEASE_DIR}" +tar -xzf "${BUNDLE}" -C "${RELEASE_DIR}" +cp "${MANIFEST}" "${RELEASE_DIR}/.release-manifest.txt" + +for item in "${PERSISTED_ITEMS[@]}"; do + if [[ -e "${APP_DIR}/${item}" ]]; then + rm -rf "${RELEASE_DIR:?}/${item}" + mkdir -p "$(dirname "${RELEASE_DIR}/${item}")" + cp -a "${APP_DIR}/${item}" "$(dirname "${RELEASE_DIR}/${item}")/" + fi +done + +[[ -d "${RELEASE_DIR}/dist" ]] || { echo "release bundle missing dist/" >&2; exit 1; } +[[ ! -d "${RELEASE_DIR}/src" ]] || { echo "release bundle must not contain src/" >&2; exit 1; } + +say "安装运行依赖" +cd "${RELEASE_DIR}" +export PATH="/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/opt/homebrew/opt/node@22/bin:/usr/local/bin:/usr/bin:/bin:${PATH}" +if command -v npm >/dev/null 2>&1; then + NPM_BIN="$(command -v npm)" +elif [[ -x /opt/homebrew/opt/node@24/bin/npm ]]; then + NPM_BIN="/opt/homebrew/opt/node@24/bin/npm" +else + echo "npm not found on remote" >&2 + exit 1 +fi +if [[ -f package-lock.json ]]; then + "${NPM_BIN}" ci --no-audit --no-fund +else + "${NPM_BIN}" install --no-audit --no-fund +fi + +cat > "${RELEASE_DIR}/PRODUCTION_ARTIFACT_ONLY.md" <<'ARTIFACT' +This directory is a deployed production artifact on 103. +Do not edit source files here. +Source of truth: local repos under /Users/john/PycharmProjects/test/. +Last artifact release manifest: .release-manifest.txt +ARTIFACT + +say "切换 live 目录" +rm -rf "${OLD_LIVE_DIR}" +mv "${APP_DIR}" "${OLD_LIVE_DIR}" +mv "${RELEASE_DIR}" "${APP_DIR}" + +say "重启服务" +ADM_ROOT="${APP_DIR}" ADM_PORT=5174 ADM_API_PORT=8085 bash "${APP_DIR}/scripts/remote_restart.sh" + +say "健康检查" +api_code="$(curl -s -o /dev/null -w '%{http_code}' "${API_HEALTH_URL}" || true)" +web_code="$(curl -s -o /dev/null -w '%{http_code}' "${WEB_HEALTH_URL}" || true)" +if [[ "${api_code}" != "200" || "${web_code}" != "200" ]]; then + echo "health check failed: api=${api_code} web=${web_code}" >&2 + rm -rf "${APP_DIR}" + mv "${OLD_LIVE_DIR}" "${APP_DIR}" + ADM_ROOT="${APP_DIR}" ADM_PORT=5174 ADM_API_PORT=8085 bash "${APP_DIR}/scripts/remote_restart.sh" >/dev/null 2>&1 || true + exit 1 +fi + +say "检查 live 目录不再保留开发源码" +if [[ -d "${APP_DIR}/src" || -f "${APP_DIR}/vite.config.ts" ]]; then + echo "source tree still present in live dir after switch" >&2 + exit 1 +fi + +say "发布成功" +rm -f "${BUNDLE}" "${MANIFEST}" "${SHA_FILE}" +echo "backup_tar=${BACKUP_TAR}" +echo "archived_source=${OLD_LIVE_DIR}" +echo "live_dir=${APP_DIR}" +REMOTE_SCRIPT + +say "发布完成,读取线上版本摘要" +ssh -o BatchMode=yes "${HOST}" "sed -n '1,20p' '${APP_DIR}/.release-manifest.txt' && echo '---' && test ! -d '${APP_DIR}/src' && echo 'src=absent' && curl -s '${API_HEALTH_URL}' && echo && curl -sI '${WEB_HEALTH_URL}' | head -1" diff --git a/scripts/remote_restart.sh b/scripts/remote_restart.sh index 25f1967..3125976 100755 --- a/scripts/remote_restart.sh +++ b/scripts/remote_restart.sh @@ -16,6 +16,24 @@ command -v node >/dev/null 2>&1 || { echo "❌ 未找到 node" >&2; exit 1; } cd "${ROOT}" +if [[ -f "${ROOT}/.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "${ROOT}/.env" + set +a +fi + +BASE_PATH="${VITE_BASE_PATH:-/ops}" +if [[ "${BASE_PATH}" != */ ]]; then + BASE_PATH="${BASE_PATH}/" +fi +API_HOST="${ADM_API_HOST:-127.0.0.1}" +WEB_HOST="${ADM_WEB_HOST:-${API_HOST}}" +export ADM_API_HOST="${API_HOST}" +export ADM_WEB_HOST="${WEB_HOST}" +export ADM_PORT="${PORT}" +export VITE_BASE_PATH="${BASE_PATH%/}" + if [[ ! -d dist ]]; then echo "❌ ${ROOT}/dist 不存在,请先构建或 rsync dist" >&2 exit 1 @@ -27,13 +45,20 @@ if [[ ! -d node_modules ]]; then fi echo "==> 停止旧进程 (port ${PORT}, ${API_PORT})..." +if [[ -f "${PID_FILE}" ]]; then + kill "$(cat "${PID_FILE}")" 2>/dev/null || true +fi +if [[ -f "${API_PID_FILE}" ]]; then + kill "$(cat "${API_PID_FILE}")" 2>/dev/null || true +fi lsof -ti "TCP:${PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true lsof -ti "TCP:${API_PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true -sleep 1 +sleep 2 lsof -ti "TCP:${PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true lsof -ti "TCP:${API_PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true +sleep 1 -echo "==> 启动 Admin API @ 127.0.0.1:${API_PORT}..." +echo "==> 启动 Admin API @ ${API_HOST}:${API_PORT}..." nohup node server/index.mjs >>"${API_LOG}" 2>&1 & echo $! >"${API_PID_FILE}" sleep 2 @@ -44,14 +69,17 @@ if ! curl -sf "http://127.0.0.1:${API_PORT}/health" >/dev/null; then exit 1 fi -echo "==> 启动 vite preview @ 127.0.0.1:${PORT}..." -nohup npm run dev:preview -- --host 127.0.0.1 --port "${PORT}" >>"${LOG}" 2>&1 & +echo "==> 启动 vite preview @ ${WEB_HOST}:${PORT} (base=${BASE_PATH})..." +nohup npx vite preview --config scripts/vite-preview.config.mjs >>"${LOG}" 2>&1 & echo $! >"${PID_FILE}" sleep 2 -if curl -sf "http://127.0.0.1:${PORT}/" >/dev/null; then - echo "✅ memind_adm 已就绪: http://127.0.0.1:${PORT}/" - curl -sf "http://127.0.0.1:${PORT}/" >/dev/null && echo " 首页 OK" +preview_url="http://127.0.0.1:${PORT}${BASE_PATH}" +login_probe="http://127.0.0.1:${PORT}/auth/status" +if curl -sf "${preview_url}" >/dev/null && curl -sf "${login_probe}" >/dev/null; then + echo "✅ memind_adm 已就绪: ${preview_url}" + curl -sf "${preview_url}" >/dev/null && echo " 首页 OK" + echo " /auth 反代 OK" echo " 登录请使用数据库中已初始化的 admin 账号" else echo "❌ 启动失败,最近日志:" >&2 diff --git a/scripts/rsync_to_server.sh b/scripts/rsync_to_server.sh index 02a9713..7b160af 100755 --- a/scripts/rsync_to_server.sh +++ b/scripts/rsync_to_server.sh @@ -1,90 +1,16 @@ #!/usr/bin/env bash -# memind_adm → 100 服务器 rsync 部署 -# -# 用法: -# ./scripts/rsync_to_server.sh -# ./scripts/rsync_to_server.sh --skip-build -# ./scripts/rsync_to_server.sh --no-restart -# SKIP_BUILD=1 NO_RESTART=1 ./scripts/rsync_to_server.sh set -euo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -EXCLUDE="${ROOT}/scripts/.rsync-exclude-lan" -DEPLOY_HOST="${DEPLOY_HOST:-john@100.99.38.66}" -REMOTE_DIR="${REMOTE_DIR:-/Users/john/Project/memind_adm}" -ADM_PORT="${ADM_PORT:-5174}" -SSH_OPTS=(-o ConnectTimeout=15 -o BatchMode=yes -o StrictHostKeyChecking=accept-new) +cat >&2 <<'EOF' +`scripts/rsync_to_server.sh` 已禁用。 -SKIP_BUILD=0 -NO_RESTART=0 +103 / Studio 生产发布禁止再走 rsync。 +请改用: -for arg in "$@"; do - case "$arg" in - --skip-build) SKIP_BUILD=1 ;; - --no-restart) NO_RESTART=1 ;; - -h|--help) - sed -n '2,8p' "$0" - exit 0 - ;; - *) - echo "未知参数: $arg" >&2 - exit 1 - ;; - esac -done + bash scripts/release-prod.sh -ssh_cmd() { - ssh "${SSH_OPTS[@]}" "${DEPLOY_HOST}" "$@" -} - -require_ssh() { - if ! ssh_cmd 'echo ok' >/dev/null 2>&1; then - echo "❌ 无法 SSH 到 ${DEPLOY_HOST}" >&2 - echo "请确认 Tailscale 已连接,或执行: ssh-copy-id ${DEPLOY_HOST}" >&2 - exit 1 - fi -} - -echo "======================================" -echo "memind_adm → ${DEPLOY_HOST}" -echo "时间: $(date '+%Y-%m-%d %H:%M:%S')" -echo "本地: ${ROOT}" -echo "远端: ${REMOTE_DIR}" -echo "端口: ${ADM_PORT}" -echo "======================================" - -require_ssh - -if [[ "${SKIP_BUILD}" -eq 0 ]]; then - echo "==> 本地构建..." - cd "${ROOT}" - npm run build -fi - -echo "==> 创建远端目录..." -ssh_cmd "mkdir -p '${REMOTE_DIR}'" - -echo "==> rsync 源码 (--delete)..." -rsync -az --delete --exclude-from="${EXCLUDE}" \ - "${ROOT}/" \ - "${DEPLOY_HOST}:${REMOTE_DIR}/" - -echo "==> rsync dist..." -rsync -az --delete \ - "${ROOT}/dist/" \ - "${DEPLOY_HOST}:${REMOTE_DIR}/dist/" - -echo "==> 写入远端 .env(不覆盖已有)..." -ssh_cmd "test -f '${REMOTE_DIR}/.env' || cat > '${REMOTE_DIR}/.env' <<'EOF' -ADM_DEV_BACKEND=http://127.0.0.1:8081 -VITE_MAIN_APP_URL=http://127.0.0.1:8081 -EOF" - -if [[ "${NO_RESTART}" -eq 0 ]]; then - echo "==> 远端重启服务..." - ssh_cmd "ADM_ROOT='${REMOTE_DIR}' ADM_PORT='${ADM_PORT}' bash -s" <"${ROOT}/scripts/remote_restart.sh" -fi - -echo "" -echo "✅ 已部署到 ${DEPLOY_HOST}:${REMOTE_DIR}" -echo " 本机访问: ssh ${DEPLOY_HOST} 'curl -sI http://127.0.0.1:${ADM_PORT}/ | head -1'" +规则见: + - PRODUCTION_RELEASE_RULES.md + - docs/DEPLOY.md +EOF +exit 1 diff --git a/scripts/vite-preview.config.mjs b/scripts/vite-preview.config.mjs new file mode 100644 index 0000000..d4c8084 --- /dev/null +++ b/scripts/vite-preview.config.mjs @@ -0,0 +1,23 @@ +import { defineConfig, loadEnv } from 'vite'; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + const backend = env.ADM_DEV_BACKEND ?? 'http://127.0.0.1:8085'; + const basePath = env.VITE_BASE_PATH?.trim() || '/ops'; + const base = basePath.endsWith('/') ? basePath : `${basePath}/`; + + return { + base, + preview: { + host: env.ADM_WEB_HOST ?? env.ADM_API_HOST ?? '127.0.0.1', + port: Number(env.ADM_PORT ?? 5174), + strictPort: true, + allowedHosts: ['gadm.tkmind.cn', 'localhost', '127.0.0.1', '10.10.0.2', '58.38.22.103'], + proxy: { + '/api': backend, + '/auth': backend, + '/admin-api': backend, + }, + }, + }; +}); diff --git a/server/app.mjs b/server/app.mjs index 025ae37..ce3c28f 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,6 +1,75 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import express from 'express'; import { listUsagePaged, listLedgerPaged } from './pagination.mjs'; +const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const RESTART_ACTIONS = { + local_restart: { + label: 'local_restart', + script: path.join(projectRoot, 'scripts', 'local_restart.sh'), + logFile: path.join(projectRoot, 'adm-local-restart.log'), + detached: true, + }, + pro_restart: { + label: 'pro_restart', + script: path.join(projectRoot, 'scripts', 'pro_restart.sh'), + logFile: path.join(projectRoot, 'adm-pro-restart.log'), + detached: false, + }, +}; + +function isRestartAction(value) { + return value === 'local_restart' || value === 'pro_restart'; +} + +function spawnDetachedScript({ script, logFile, delaySeconds = 0 }) { + const stdout = fs.openSync(logFile, 'a'); + const stderr = fs.openSync(logFile, 'a'); + const command = delaySeconds > 0 ? `sleep ${delaySeconds}; exec "${script}"` : `exec "${script}"`; + const child = spawn('bash', ['-lc', command], { + cwd: projectRoot, + detached: true, + stdio: ['ignore', stdout, stderr], + env: process.env, + }); + child.unref(); + return child.pid ?? null; +} + +function runManagedScript({ script, logFile }) { + return new Promise((resolve, reject) => { + const stdout = fs.openSync(logFile, 'a'); + const stderr = fs.openSync(logFile, 'a'); + let combined = ''; + const child = spawn('bash', [script], { + cwd: projectRoot, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout.on('data', (chunk) => { + const text = chunk.toString(); + combined += text; + fs.writeSync(stdout, text); + }); + child.stderr.on('data', (chunk) => { + const text = chunk.toString(); + combined += text; + fs.writeSync(stderr, text); + }); + child.on('error', reject); + child.on('close', (code) => { + fs.closeSync(stdout); + fs.closeSync(stderr); + resolve({ code: code ?? 0, output: combined.trim() }); + }); + }); +} + function asyncHandler(handler) { return (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next); } @@ -19,7 +88,19 @@ function wrapRouterAsync(router) { } export function createAdminApp(services) { - const { userAuth, llmProviderService, pool, ready, wechatAdmin } = services; + const { + userAuth, + llmProviderService, + pool, + ready, + wechatAdmin, + loadMindSpaceConfig, + updateMindSpaceConfig, + wordFilterService, + planCatalogService, + subscriptionService, + planSyncService, + } = services; const app = express(); app.set('trust proxy', 1); @@ -142,6 +223,12 @@ export function createAdminApp(services) { res.json(result); }); + adminApi.get('/users/:userId', requireAdmin, async (req, res) => { + const user = await userAuth.getUserPublic(req.params.userId); + if (!user) return res.status(404).json({ message: '用户不存在' }); + res.json({ user }); + }); + adminApi.get('/summary', requireAdmin, async (_req, res) => { const summary = await userAuth.getAdminSummary(); let llm = null; @@ -198,6 +285,22 @@ export function createAdminApp(services) { res.json(await wechatAdmin.getSummary()); }); + adminApi.get('/mindspace/config', requireAdmin, async (_req, res) => { + if (!loadMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' }); + const config = await loadMindSpaceConfig(pool); + res.json({ + config, + }); + }); + + adminApi.patch('/mindspace/config', requireAdmin, async (req, res) => { + if (!updateMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' }); + const result = await updateMindSpaceConfig(pool, { + publicPageLimit: req.body?.publicPageLimit, + }); + res.json({ config: result }); + }); + adminApi.get('/wechat/bindings', requireAdmin, async (req, res) => { if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); res.json(await wechatAdmin.listBindings(req.query)); @@ -218,6 +321,18 @@ export function createAdminApp(services) { res.json(await wechatAdmin.listDeliveries(req.query)); }); + 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); @@ -364,7 +479,10 @@ export function createAdminApp(services) { adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); - res.json({ catalog: llmProviderService.catalog }); + res.json({ + catalog: llmProviderService.catalog, + executors: llmProviderService.executorCatalog ?? [], + }); }); adminApi.get('/llm-providers/keys', requireAdmin, async (_req, res) => { @@ -414,6 +532,122 @@ export function createAdminApp(services) { res.json({ global: await llmProviderService.getGlobalSettings() }); }); + adminApi.get('/llm-providers/vision', requireAdmin, async (_req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + res.json({ vision: await llmProviderService.getVisionSettings() }); + }); + + adminApi.put('/llm-providers/vision', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.setVisionKey(req.body?.keyId, req.body?.model); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.delete('/llm-providers/vision', requireAdmin, async (_req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.clearVisionKey(); + res.json(result); + }); + + adminApi.get('/llm-providers/executor-bindings', requireAdmin, async (_req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + res.json({ bindings: await llmProviderService.listExecutorBindings() }); + }); + + adminApi.put('/llm-providers/executor-bindings/:executor', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.setExecutorBinding(req.params.executor, req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/llm-providers/executor-runtime', requireAdmin, async (_req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + res.json({ runtimes: await llmProviderService.listExecutorRuntimeConfigs() }); + }); + + adminApi.get('/llm-providers/executor-launch-plan', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const plans = await llmProviderService.listExecutorLaunchPlans({ + purpose: typeof req.query.purpose === 'string' ? req.query.purpose : 'default', + mode: typeof req.query.mode === 'string' ? req.query.mode : 'serve', + cwd: typeof req.query.cwd === 'string' ? req.query.cwd : undefined, + instruction: typeof req.query.instruction === 'string' ? req.query.instruction : '', + includeSecret: false, + }); + res.json({ plans }); + }); + + adminApi.get('/llm-providers/executor-launch-status', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const launches = await llmProviderService.listExecutorLaunchStates({ + purpose: typeof req.query.purpose === 'string' ? req.query.purpose : 'default', + }); + res.json({ launches }); + }); + + adminApi.post('/llm-providers/executor-launch/:executor', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.launchExecutor(req.params.executor, req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message, launch: result }); + res.json({ launch: result.launch ?? result }); + }); + + adminApi.post('/llm-providers/executor-stop/:executor', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.stopExecutor(req.params.executor, req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json({ launch: result.launch }); + }); + + adminApi.post('/llm-providers/executor-restart/:executor', requireAdmin, async (req, res) => { + if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); + const result = await llmProviderService.restartExecutor(req.params.executor, req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message, launch: result }); + res.json({ launch: result.launch ?? result }); + }); + + adminApi.post('/service-restart/:action', requireAdmin, async (req, res) => { + const action = req.params.action; + if (!isRestartAction(action)) { + return res.status(400).json({ message: '仅支持 local_restart 或 pro_restart' }); + } + const target = RESTART_ACTIONS[action]; + if (!fs.existsSync(target.script)) { + return res.status(500).json({ message: `${target.label} 脚本不存在: ${target.script}` }); + } + + if (target.detached) { + const pid = spawnDetachedScript({ script: target.script, logFile: target.logFile, delaySeconds: 1 }); + return res.status(202).json({ + ok: true, + action, + message: '已触发本机重启,当前页面会在几秒内短暂断开,请稍后刷新。', + pid, + logFile: target.logFile, + }); + } + + const result = await runManagedScript({ script: target.script, logFile: target.logFile }); + if (result.code !== 0) { + return res.status(500).json({ + ok: false, + action, + message: `${action} 执行失败`, + output: result.output, + logFile: target.logFile, + }); + } + return res.json({ + ok: true, + action, + message: '远程服务已重启', + output: result.output, + logFile: target.logFile, + }); + }); + adminApi.put('/llm-providers/global', requireAdmin, async (req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); const result = await llmProviderService.setGlobalModel(req.body?.model); @@ -439,6 +673,124 @@ export function createAdminApp(services) { } }); + // ── Blocked words ───────────────────────────────────────────────────────── + + adminApi.get('/blocked-words', requireAdmin, async (_req, res) => { + if (!wordFilterService) return res.status(503).json({ message: '词语过滤未启用' }); + const words = await wordFilterService.listBlockedWords(); + res.json({ words }); + }); + + adminApi.post('/blocked-words', requireAdmin, async (req, res) => { + if (!wordFilterService) return res.status(503).json({ message: '词语过滤未启用' }); + const result = await wordFilterService.createBlockedWord(req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.status(201).json({ blockedWord: result.blockedWord }); + }); + + adminApi.patch('/blocked-words/:id', requireAdmin, async (req, res) => { + if (!wordFilterService) return res.status(503).json({ message: '词语过滤未启用' }); + const result = await wordFilterService.updateBlockedWord(req.params.id, req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json({ blockedWord: result.blockedWord }); + }); + + adminApi.delete('/blocked-words/:id', requireAdmin, async (req, res) => { + if (!wordFilterService) return res.status(503).json({ message: '词语过滤未启用' }); + const result = await wordFilterService.deleteBlockedWord(req.params.id); + if (!result.ok) return res.status(404).json({ message: result.message }); + res.json({ ok: true }); + }); + + // ── Subscription Plan Catalog ─────────────────────────────────────────────── + + adminApi.get('/subscriptions/plans', requireAdmin, async (_req, res) => { + if (!planCatalogService) return res.status(503).json({ message: '套餐服务未启用' }); + const plans = await planCatalogService.listPlans({ includeInactive: true }); + res.json({ plans }); + }); + + const shouldSkipPlanSync = (req) => req.get('x-plan-sync-hop') === '1'; + + adminApi.post('/subscriptions/plans', requireAdmin, async (req, res) => { + if (!planCatalogService) return res.status(503).json({ message: '套餐服务未启用' }); + const { planType, ...data } = req.body ?? {}; + if (!planType) return res.status(400).json({ message: '缺少 planType' }); + const result = await planCatalogService.upsertPlan(planType, data); + if (!result.ok) return res.status(400).json({ message: result.message }); + const sync = shouldSkipPlanSync(req) ? undefined : await planSyncService?.syncPlanUpsert(planType, result.plan); + res.status(201).json({ plan: result.plan, sync }); + }); + + adminApi.put('/subscriptions/plans/:planType', requireAdmin, async (req, res) => { + if (!planCatalogService) return res.status(503).json({ message: '套餐服务未启用' }); + const result = await planCatalogService.upsertPlan(req.params.planType, req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + const sync = shouldSkipPlanSync(req) ? undefined : await planSyncService?.syncPlanUpsert(req.params.planType, result.plan); + res.json({ plan: result.plan, sync }); + }); + + adminApi.delete('/subscriptions/plans/:planType', requireAdmin, async (req, res) => { + if (!planCatalogService) return res.status(503).json({ message: '套餐服务未启用' }); + const result = await planCatalogService.deletePlan(req.params.planType); + if (!result.ok) return res.status(400).json({ message: result.message }); + const sync = shouldSkipPlanSync(req) ? undefined : await planSyncService?.syncPlanDelete(req.params.planType); + res.json({ ok: true, sync }); + }); + + adminApi.post('/subscriptions/plans/sync-production', requireAdmin, async (_req, res) => { + if (!planCatalogService) return res.status(503).json({ message: '套餐服务未启用' }); + if (!planSyncService?.enabled) { + return res.status(503).json({ message: planSyncService?.reason || '未配置生产套餐同步' }); + } + const plans = await planCatalogService.listPlans({ includeInactive: true }); + const sync = await planSyncService.syncAllPlans(plans); + res.json({ sync }); + }); + + // ── Subscription Management ───────────────────────────────────────────────── + + adminApi.get('/subscriptions', requireAdmin, async (req, res) => { + if (!subscriptionService) return res.status(503).json({ message: '套餐服务未启用' }); + const result = await subscriptionService.listSubscriptions({ + userId: req.query.userId || null, + status: req.query.status || null, + page: Number(req.query.page) || 1, + pageSize: Math.min(Number(req.query.pageSize) || 20, 100), + }); + res.json(result); + }); + + adminApi.get('/users/:userId/subscription', requireAdmin, async (req, res) => { + if (!subscriptionService) return res.status(503).json({ message: '套餐服务未启用' }); + const subscription = await subscriptionService.getActiveSubscription(req.params.userId); + res.json({ subscription }); + }); + + 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.status(201).json({ subscription: result.subscription }); + }); + + 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); + }); + app.use('/admin-api', adminApi); if (services.createOpsApi) { diff --git a/server/bootstrap.mjs b/server/bootstrap.mjs index 3d1a127..557ee13 100644 --- a/server/bootstrap.mjs +++ b/server/bootstrap.mjs @@ -1,7 +1,6 @@ import path from 'node:path'; import { createDbPool, isDatabaseConfigured } from './db.mjs'; import { projectRoot } from './load-env.mjs'; -import { createLocalUserAuth } from './local-auth.mjs'; import { importMemind, resolveMemindLib } from './lib-path.mjs'; export async function bootstrapAdminServices() { @@ -10,16 +9,38 @@ export async function bootstrapAdminServices() { } const memindLib = resolveMemindLib(); + const h5Root = + process.env.MEMIND_LIB_ROOT?.trim() ?? + path.join(projectRoot, '../Memind'); const pool = createDbPool(); - const { createLlmProviderService } = await importMemind('llm-providers.mjs'); + const { loadCreateLlmProviderService } = await import('./llm-provider-loader.mjs'); + const createLlmProviderService = await loadCreateLlmProviderService(); const { createWechatAdminService } = await importMemind('wechat-admin.mjs'); - const { loadWechatMpConfig } = await importMemind('wechat-mp.mjs'); + const { createWechatMpService, loadWechatMpConfig } = await importMemind('wechat-mp.mjs'); const { createPlazaPostService, formatPostRow } = await importMemind('plaza-posts.mjs'); const { createPlazaInteractionService } = await importMemind('plaza-interactions.mjs'); const { createPlazaOpsService } = await importMemind('plaza-ops.mjs'); const { createNoopPlazaRedis } = await importMemind('plaza-redis.mjs'); const { ensureAlgorithmConfig, loadAlgorithmConfig } = await importMemind('plaza-algorithm.mjs'); + const { + ensureMindSpaceConfig, + loadMindSpaceConfig, + updateMindSpaceConfig, + } = await importMemind('mindspace-config.mjs'); const { createOpsApi } = await importMemind('admin-routes.mjs'); + const { createWordFilterService, ensureWordFilterSchema } = await importMemind('word-filter.mjs'); + const { + USER_COOKIE, + clearUserSessionCookie, + createUserAuth, + resolveCookieDomainForRequest, + userLoginCookies, + } = await importMemind('user-auth.mjs'); + const { + ensurePlanCatalogSchema, + createPlanCatalogService, + createSubscriptionService, + } = await importMemind('billing-subscription.mjs'); const usersRoot = process.env.H5_USERS_ROOT?.trim() ?? path.join(projectRoot, 'data', 'users'); @@ -29,6 +50,8 @@ export async function bootstrapAdminServices() { await ensureAlgorithmConfig(pool); const algorithmConfig = await loadAlgorithmConfig(pool); + await ensureMindSpaceConfig(pool, { env: process.env, seedDefault: false }); + const mindSpaceConfig = await loadMindSpaceConfig(pool, { env: process.env }); const plazaInteractions = createPlazaInteractionService(pool, { formatPostRow, plazaRedis }); let plazaOps = null; @@ -49,23 +72,62 @@ export async function bootstrapAdminServices() { invalidateFeedCaches: () => plazaRedis?.invalidateFeedCaches?.(), }); - const userAuth = createLocalUserAuth(pool); - - await userAuth.ensureAdminUser(); + const userAuth = createUserAuth(pool, { + usersRoot, + h5Root, + defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500), + }); const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret, }); + const wechatMpConfig = loadWechatMpConfig(); + const wechatMpService = createWechatMpService({ + config: wechatMpConfig, + userAuth, + apiFetch: async () => { + throw new Error('admin api does not proxy WeChat chat sessions'); + }, + }); 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, + }); + + await ensureWordFilterSchema(pool); + const wordFilterService = createWordFilterService(pool); + + await ensurePlanCatalogSchema(pool); + const planCatalogService = createPlanCatalogService(pool); + const subscriptionService = createSubscriptionService(pool, { + getPlanAsync: (planType) => planCatalogService.getPlan(planType), }); console.log(`Admin DB connected (${process.env.MYSQL_DATABASE ?? 'via DATABASE_URL'})`); console.log(`Users root: ${usersRoot}`); console.log(`Memind lib: ${memindLib}`); - return { pool, userAuth, llmProviderService, plazaOps, createOpsApi, wechatAdmin }; + return { + pool, + userAuth, + llmProviderService, + plazaOps, + createOpsApi, + wechatAdmin, + mindSpaceConfig, + loadMindSpaceConfig, + updateMindSpaceConfig, + wordFilterService, + planCatalogService, + subscriptionService, + USER_COOKIE, + userLoginCookies, + clearUserSessionCookie, + resolveCookieDomainForRequest, + }; } diff --git a/server/index.mjs b/server/index.mjs index ee7b5c2..f66bd33 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -1,33 +1,117 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; import { ensureArgon2Sync } from './argon2-polyfill.mjs'; import { loadProjectEnv } from './load-env.mjs'; -import { - USER_COOKIE, - clearUserSessionCookie, - parseCookies, - resolveCookieDomainForRequest, - userLoginCookies, -} from './local-auth.mjs'; +import { parseCookies } from './local-auth.mjs'; +import { createPlanSyncService } from './plan-sync.mjs'; ensureArgon2Sync(); import { bootstrapAdminServices } from './bootstrap.mjs'; import { createAdminApp } from './app.mjs'; -loadProjectEnv(); +const projectRoot = loadProjectEnv(); +const pidFile = path.join(projectRoot, '.adm-api.pid'); const port = Number(process.env.ADM_API_PORT ?? 8085); +function readPidFile() { + try { + const raw = fs.readFileSync(pidFile, 'utf8').trim(); + if (!raw) return null; + const pid = Number(raw); + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function isProcessAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function cleanupStalePidFile() { + const existingPid = readPidFile(); + if (!existingPid || existingPid === process.pid) return; + if (isProcessAlive(existingPid)) return; + try { + fs.unlinkSync(pidFile); + console.warn(`Removed stale PID file ${pidFile} (pid ${existingPid})`); + } catch {} +} + +function writePidFile() { + try { + fs.writeFileSync(pidFile, `${process.pid}\n`, 'utf8'); + } catch (err) { + console.warn(`Failed to write PID file ${pidFile}:`, err); + } +} + +function removePidFile() { + const existingPid = readPidFile(); + if (existingPid !== process.pid) return; + try { + fs.unlinkSync(pidFile); + } catch {} +} + +function lookupPortOwner(listenPort) { + try { + const out = execFileSync('lsof', ['-nP', '-iTCP:' + listenPort, '-sTCP:LISTEN', '-Fpc'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + let pid = null; + let command = null; + for (const line of out.split('\n')) { + if (line.startsWith('p')) pid = line.slice(1); + if (line.startsWith('c')) command = line.slice(1); + if (pid && command) break; + } + if (!pid) return null; + return command ? `${command} (pid ${pid})` : `pid ${pid}`; + } catch { + return null; + } +} + +cleanupStalePidFile(); + const ready = bootstrapAdminServices(); +const planSyncService = createPlanSyncService(console); const services = { ready, - USER_COOKIE, parseCookies, - userLoginCookies, - clearUserSessionCookie, - resolveCookieDomainForRequest, + planSyncService, }; ready - .then(({ pool, userAuth, llmProviderService, plazaOps, createOpsApi, wechatAdmin }) => { + .then((bootstrapped) => { + const { + pool, + userAuth, + llmProviderService, + plazaOps, + createOpsApi, + wechatAdmin, + loadMindSpaceConfig, + updateMindSpaceConfig, + wordFilterService, + planCatalogService, + subscriptionService, + planSyncService, + USER_COOKIE, + userLoginCookies, + clearUserSessionCookie, + resolveCookieDomainForRequest, + } = bootstrapped; Object.assign(services, { pool, userAuth, @@ -35,10 +119,44 @@ ready plazaOps, createOpsApi, wechatAdmin, + loadMindSpaceConfig, + updateMindSpaceConfig, + wordFilterService, + planCatalogService, + subscriptionService, + planSyncService, + USER_COOKIE, + userLoginCookies, + clearUserSessionCookie, + resolveCookieDomainForRequest, }); const app = createAdminApp(services); - const server = app.listen(port, '127.0.0.1', () => { - console.log(`TKMind Admin API @ http://127.0.0.1:${port}`); + const host = process.env.ADM_API_HOST?.trim() || '127.0.0.1'; + const server = app.listen(port, host, () => { + writePidFile(); + console.log(`TKMind Admin API @ http://${host}:${port}`); + }); + server.on('close', removePidFile); + server.on('error', (err) => { + if (err?.code === 'EADDRINUSE') { + const owner = lookupPortOwner(port); + console.error( + `Admin API 无法启动:${host}:${port} 已被占用${owner ? `,当前占用者是 ${owner}` : ''}。`, + ); + console.error(`如需重启,可先停止旧进程,或修改 .env 中的 ADM_API_PORT。`); + } else { + console.error('Admin API listen failed:', err); + } + process.exit(1); + }); + process.on('exit', removePidFile); + process.on('SIGINT', () => { + removePidFile(); + server.close(() => process.exit(0)); + }); + process.on('SIGTERM', () => { + removePidFile(); + server.close(() => process.exit(0)); }); globalThis.__tkmindAdminServer = server; }) diff --git a/server/lib-path.mjs b/server/lib-path.mjs index 1e6644f..456e457 100644 --- a/server/lib-path.mjs +++ b/server/lib-path.mjs @@ -4,8 +4,12 @@ import { pathToFileURL } from 'node:url'; import { projectRoot } from './load-env.mjs'; export function resolveMemindLib() { + const bundled = path.join(projectRoot, 'memind-lib'); const candidates = [ + fs.existsSync(path.join(bundled, 'user-auth.mjs')) ? bundled : null, process.env.MEMIND_LIB_ROOT?.trim(), + path.join(projectRoot, '../test-memind'), + path.join(projectRoot, '../memind'), path.join(projectRoot, '../Memind'), path.join(projectRoot, '../tkmind_go/ui/h5'), ].filter(Boolean); @@ -17,7 +21,7 @@ export function resolveMemindLib() { } throw new Error( - '未找到 Memind 业务模块目录(user-auth.mjs)。请设置 MEMIND_LIB_ROOT 或保持 ../Memind 存在。', + '未找到 Memind 业务模块目录(user-auth.mjs)。请设置 MEMIND_LIB_ROOT,或保持 ../test-memind、../memind、../Memind 其中之一存在。', ); } diff --git a/server/llm-provider-loader.mjs b/server/llm-provider-loader.mjs new file mode 100644 index 0000000..043f0eb --- /dev/null +++ b/server/llm-provider-loader.mjs @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { importMemind, resolveMemindLib } from './lib-path.mjs'; + +const ADMIN_METHODS = [ + 'getVisionSettings', + 'setVisionKey', + 'clearVisionKey', + 'listExecutorBindings', + 'setExecutorBinding', + 'listExecutorRuntimeConfigs', + 'listExecutorLaunchPlans', + 'listExecutorLaunchStates', + 'launchExecutor', + 'stopExecutor', + 'restartExecutor', +]; + +function hasAdminMethods(createFactory) { + if (typeof createFactory !== 'function') return false; + const probe = createFactory(null, {}); + return ADMIN_METHODS.every((method) => typeof probe?.[method] === 'function'); +} + +async function importLlmProvidersModule(modulePath) { + return import(pathToFileURL(modulePath).href); +} + +export async function loadCreateLlmProviderService() { + const primary = await importMemind('llm-providers.mjs'); + if (hasAdminMethods(primary.createLlmProviderService)) { + return primary.createLlmProviderService; + } + + const libRoot = resolveMemindLib(); + const fallbackCandidates = [ + path.join(libRoot, 'llm-providers.mjs.bak-20260625-221206'), + path.join(libRoot, 'llm-providers.admin.mjs'), + ]; + + for (const candidate of fallbackCandidates) { + if (!fs.existsSync(candidate)) continue; + const fallback = await importLlmProvidersModule(candidate); + if (hasAdminMethods(fallback.createLlmProviderService)) { + console.warn( + `[admin] Memind llm-providers.mjs 缺少管理端方法,已回退到 ${path.basename(candidate)}`, + ); + return fallback.createLlmProviderService; + } + } + + throw new Error( + 'Memind llm-providers.mjs 缺少 getVisionSettings 等管理端方法。' + + '请更新 MEMIND_LIB_ROOT 下的 llm-providers.mjs,或保留 llm-providers.mjs.bak-20260625-221206 备份文件。', + ); +} diff --git a/server/load-env.mjs b/server/load-env.mjs index 5171890..e1528a3 100644 --- a/server/load-env.mjs +++ b/server/load-env.mjs @@ -4,18 +4,27 @@ import { fileURLToPath } from 'node:url'; const projectRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + export function loadProjectEnv() { for (const file of ['.env', '.env.local']) { - const filePath = path.join(projectRoot, file); - if (!fs.existsSync(filePath)) continue; - for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - const eq = trimmed.indexOf('='); - if (eq < 0) continue; - const key = trimmed.slice(0, eq).trim(); - const value = trimmed.slice(eq + 1).trim(); - if (!process.env[key]) process.env[key] = value; + loadEnvFile(path.join(projectRoot, file)); + } + const memindRoot = process.env.MEMIND_LIB_ROOT?.trim(); + if (memindRoot) { + for (const file of ['.env', '.env.local']) { + loadEnvFile(path.join(memindRoot, file)); } } return projectRoot; diff --git a/server/local-auth.mjs b/server/local-auth.mjs index c3204e0..c357259 100644 --- a/server/local-auth.mjs +++ b/server/local-auth.mjs @@ -1,286 +1,18 @@ -import crypto from 'node:crypto'; - -const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30; - -function now() { - return new Date(); -} - -function hashPassword(password, salt = crypto.randomBytes(16).toString('hex')) { - const digest = crypto.scryptSync(String(password), salt, 64).toString('hex'); - return `scrypt$${salt}$${digest}`; -} - -function verifyPassword(password, stored) { - const [scheme, salt, digest] = String(stored ?? '').split('$'); - if (scheme !== 'scrypt' || !salt || !digest) return false; - const next = crypto.scryptSync(String(password), salt, 64).toString('hex'); - return crypto.timingSafeEqual(Buffer.from(next, 'hex'), Buffer.from(digest, 'hex')); -} - -function rowToUser(row) { - if (!row) return null; - return { - id: String(row.id), - username: row.username, - displayName: row.display_name ?? row.username, - role: row.role, - status: row.status, - balanceCents: Number(row.balance_cents ?? 0), - workspaceRoot: row.workspace_root ?? '', - createdAt: row.created_at ? new Date(row.created_at).getTime() : Date.now(), - updatedAt: row.updated_at ? new Date(row.updated_at).getTime() : Date.now(), - }; -} - -function defaultCapabilities() { - return {}; -} +// Legacy standalone auth was removed in favor of Memind shared user auth. +// Keep only generic cookie parsing helpers for the admin API container. export function parseCookies(cookieHeader = '') { return Object.fromEntries( - cookieHeader.split(';').map((part) => { - const index = part.indexOf('='); - if (index < 0) return ['', '']; - return [decodeURIComponent(part.slice(0, index).trim()), decodeURIComponent(part.slice(index + 1).trim())]; - }).filter(([k]) => k), + cookieHeader + .split(';') + .map((part) => { + const index = part.indexOf('='); + if (index < 0) return ['', '']; + return [ + decodeURIComponent(part.slice(0, index).trim()), + decodeURIComponent(part.slice(index + 1).trim()), + ]; + }) + .filter(([key]) => key), ); } - -export const USER_COOKIE = 'tkmind_admin_token'; - -export function userLoginCookies(token, secure, domain) { - const parts = [ - `${USER_COOKIE}=${encodeURIComponent(token)}`, - 'Path=/', - 'HttpOnly', - 'SameSite=Lax', - secure ? 'Secure' : null, - domain ? `Domain=${domain}` : null, - `Max-Age=${SESSION_TTL_MS / 1000}`, - ].filter(Boolean); - return `${parts.join('; ')}`; -} - -export function clearUserSessionCookie(secure, domain) { - const parts = [ - `${USER_COOKIE}=`, - 'Path=/', - 'HttpOnly', - 'SameSite=Lax', - secure ? 'Secure' : null, - domain ? `Domain=${domain}` : null, - 'Max-Age=0', - ].filter(Boolean); - return `${parts.join('; ')}`; -} - -export function resolveCookieDomainForRequest(_req) { - return ''; -} - -export function createLocalUserAuth(pool) { - const capabilityCatalog = []; - const policyCatalog = []; - const skillCatalog = []; - - async function ensureTables() { - await pool.execute(` - CREATE TABLE IF NOT EXISTS auth_users ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, - username VARCHAR(191) NOT NULL UNIQUE, - display_name VARCHAR(191) NULL, - role VARCHAR(32) NOT NULL DEFAULT 'user', - status VARCHAR(32) NOT NULL DEFAULT 'active', - password_hash VARCHAR(255) NOT NULL, - balance_cents BIGINT NOT NULL DEFAULT 0, - workspace_root VARCHAR(255) NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) - `); - await pool.execute(` - CREATE TABLE IF NOT EXISTS auth_sessions ( - token VARCHAR(191) NOT NULL PRIMARY KEY, - user_id BIGINT UNSIGNED NOT NULL, - expires_at TIMESTAMP NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_auth_sessions_user_id (user_id), - INDEX idx_auth_sessions_expires_at (expires_at) - ) - `); - } - - async function ensureAdminUser() { - await ensureTables(); - const [rows] = await pool.execute('SELECT id FROM auth_users WHERE username = ? LIMIT 1', ['admin']); - if (rows.length) return; - throw new Error('admin 账号未初始化,请先运行 npm run admin:init'); - } - - async function getUserByUsername(username) { - const [rows] = await pool.execute('SELECT * FROM auth_users WHERE username = ? LIMIT 1', [username]); - return rowToUser(rows[0]); - } - - async function getUserById(id) { - const [rows] = await pool.execute('SELECT * FROM auth_users WHERE id = ? LIMIT 1', [id]); - return rowToUser(rows[0]); - } - - async function getMe(token) { - if (!token) return null; - const [rows] = await pool.execute( - 'SELECT u.* FROM auth_sessions s JOIN auth_users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > NOW() LIMIT 1', - [token], - ); - return rowToUser(rows[0]); - } - - async function revoke(token) { - if (!token) return; - await pool.execute('DELETE FROM auth_sessions WHERE token = ?', [token]); - } - - async function login({ username, password }) { - await ensureTables(); - const [rows] = await pool.execute('SELECT * FROM auth_users WHERE username = ? LIMIT 1', [username]); - const row = rows[0]; - if (!row) return { ok: false, message: '账号或密码错误', retryAfterMs: 0 }; - if (!verifyPassword(password, row.password_hash)) return { ok: false, message: '账号或密码错误', retryAfterMs: 0 }; - const token = crypto.randomUUID().replace(/-/g, ''); - const expiresAt = new Date(Date.now() + SESSION_TTL_MS); - await pool.execute('INSERT INTO auth_sessions (token, user_id, expires_at) VALUES (?, ?, ?)', [token, row.id, expiresAt]); - return { ok: true, token, user: rowToUser(row) }; - } - - async function listUsers({ page = 1, pageSize = 20, search = '', role = '', status = '' }) { - await ensureTables(); - const clauses = []; - const params = []; - if (search) { - clauses.push('(username LIKE ? OR display_name LIKE ?)'); - params.push(`%${search}%`, `%${search}%`); - } - if (role) { - clauses.push('role = ?'); - params.push(role); - } - if (status) { - clauses.push('status = ?'); - params.push(status); - } - const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; - const [[countRow]] = await pool.execute(`SELECT COUNT(*) AS total FROM auth_users ${where}`, params); - const safePage = Math.max(Number(page) || 1, 1); - const safePageSize = Math.max(Number(pageSize) || 20, 1); - const offset = (safePage - 1) * safePageSize; - const [rows] = await pool.execute( - `SELECT * FROM auth_users ${where} ORDER BY id DESC LIMIT ${safePageSize} OFFSET ${offset}`, - params, - ); - return { - items: rows.map((row) => rowToUser(row)), - total: Number(countRow.total ?? 0), - page: safePage, - pageSize: safePageSize, - totalPages: Math.max(1, Math.ceil(Number(countRow.total ?? 0) / safePageSize)), - }; - } - - async function createUser(payload) { - if (!payload?.username || !payload?.password) return { ok: false, message: '用户名和密码不能为空' }; - const [exists] = await pool.execute('SELECT id FROM auth_users WHERE username = ? LIMIT 1', [payload.username]); - if (exists.length) return { ok: false, message: '用户名已存在' }; - await pool.execute( - 'INSERT INTO auth_users (username, display_name, role, status, password_hash, balance_cents, workspace_root) VALUES (?, ?, ?, ?, ?, ?, ?)', - [ - payload.username, - payload.displayName || payload.username, - payload.role === 'admin' ? 'admin' : 'user', - 'active', - hashPassword(payload.password), - Number(payload.balanceCents ?? 0), - payload.workspaceRoot ?? null, - ], - ); - return { ok: true, user: await getUserByUsername(payload.username) }; - } - - async function updateUser(userId, payload) { - const user = await getUserById(userId); - if (!user) return { ok: false, message: '用户不存在' }; - const displayName = payload.displayName ?? user.displayName; - const workspaceRoot = payload.workspaceRoot ?? user.workspaceRoot; - const status = payload.status ?? user.status; - const role = payload.role === 'admin' ? 'admin' : user.role; - const balanceCents = payload.balanceCents ?? user.balanceCents; - await pool.execute( - 'UPDATE auth_users SET display_name = ?, workspace_root = ?, status = ?, role = ?, balance_cents = ? WHERE id = ?', - [displayName, workspaceRoot || null, status, role, balanceCents, userId], - ); - return { ok: true, user: await getUserById(userId) }; - } - - async function recharge(userId, amountCents) { - const user = await getUserById(userId); - if (!user) return { ok: false, message: '用户不存在' }; - await pool.execute('UPDATE auth_users SET balance_cents = balance_cents + ? WHERE id = ?', [Number(amountCents ?? 0), userId]); - return { ok: true, user: await getUserById(userId) }; - } - - async function getAdminSummary() { - const [[row]] = await pool.execute( - `SELECT - COUNT(*) AS total, - SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active, - SUM(CASE WHEN balance_cents < 0 THEN 1 ELSE 0 END) AS lowBalance, - SUM(balance_cents) AS totalBalanceCents - FROM auth_users`, - ); - return { - users: { - total: Number(row.total ?? 0), - active: Number(row.active ?? 0), - lowBalance: Number(row.lowBalance ?? 0), - totalBalanceCents: Number(row.totalBalanceCents ?? 0), - }, - usage24h: { count: 0, costCents: 0 }, - lowBalanceUsers: [], - recentUsage: [], - recentLedger: [], - llm: { keyCount: 0, selectedKeyName: null, globalModel: null }, - }; - } - - return { - hashPassword, - capabilityCatalog, - policyCatalog, - skillCatalog, - ensureAdminUser, - login, - getMe, - revoke, - listUsers, - createUser, - updateUser, - recharge, - getAdminSummary, - getRoleCapabilities: async () => ({ ok: true, role: 'user', capabilities: defaultCapabilities() }), - setRoleCapabilities: async () => ({ ok: true, role: 'user', capabilities: defaultCapabilities() }), - getUserCapabilities: async () => ({ ok: true, userId: null, capabilities: defaultCapabilities() }), - setUserCapabilities: async () => ({ ok: true }), - clearUserCapabilityOverrides: async () => ({ ok: true }), - getRolePolicies: async () => ({ ok: true, role: 'user', policies: {} }), - setRolePolicies: async () => ({ ok: true, role: 'user', policies: {} }), - getUserPolicies: async () => ({ ok: true, userId: null, policies: {} }), - setUserPolicies: async () => ({ ok: true }), - clearUserPolicyOverrides: async () => ({ ok: true }), - getRoleSkills: async () => ({ ok: true, role: 'user', skills: {} }), - setRoleSkills: async () => ({ ok: true, role: 'user', skills: {} }), - getUserSkills: async () => ({ ok: true, userId: null, skills: {} }), - setUserSkills: async () => ({ ok: true }), - clearUserSkillOverrides: async () => ({ ok: true }), - }; -} diff --git a/server/plan-sync.mjs b/server/plan-sync.mjs new file mode 100644 index 0000000..74bd568 --- /dev/null +++ b/server/plan-sync.mjs @@ -0,0 +1,164 @@ +const DEFAULT_TIMEOUT_MS = 10000; + +function trimEnv(name) { + const value = process.env[name]; + return typeof value === 'string' ? value.trim() : ''; +} + +function parseTimeout(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : DEFAULT_TIMEOUT_MS; +} + +async function safeJson(response) { + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function responseMessage(payload, fallback) { + if (payload && typeof payload === 'object' && typeof payload.message === 'string' && payload.message.trim()) { + return payload.message.trim(); + } + if (typeof payload === 'string' && payload.trim()) return payload.trim(); + return fallback; +} + +export function createPlanSyncService(logger = console) { + const baseUrl = trimEnv('PLAN_SYNC_TARGET_BASE_URL').replace(/\/$/, ''); + const username = trimEnv('PLAN_SYNC_USERNAME'); + const password = trimEnv('PLAN_SYNC_PASSWORD'); + const timeoutMs = parseTimeout(trimEnv('PLAN_SYNC_TIMEOUT_MS')); + + if (!baseUrl || !username || !password) { + return { + enabled: false, + reason: '未配置生产套餐同步环境变量', + async syncPlanUpsert() { + return { enabled: false, ok: false, message: '未配置生产套餐同步环境变量' }; + }, + async syncPlanDelete() { + return { enabled: false, ok: false, message: '未配置生产套餐同步环境变量' }; + }, + async syncAllPlans() { + return { enabled: false, ok: false, message: '未配置生产套餐同步环境变量', synced: 0, total: 0 }; + }, + }; + } + + async function request(path, init = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(`${baseUrl}${path}`, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } + } + + async function loginAndGetCookie() { + const response = await request('/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const payload = await safeJson(response); + if (!response.ok) { + throw new Error(`生产后台登录失败:${responseMessage(payload, `${response.status} ${response.statusText}`)}`); + } + const cookie = response.headers.get('set-cookie'); + if (!cookie) throw new Error('生产后台登录成功,但未返回会话 Cookie'); + return cookie.split(';', 1)[0]; + } + + async function syncRequest(path, init = {}) { + const cookie = await loginAndGetCookie(); + const response = await request(path, { + ...init, + headers: { + 'content-type': 'application/json', + 'x-plan-sync-hop': '1', + ...(init.headers ?? {}), + cookie, + }, + }); + const payload = await safeJson(response); + if (!response.ok) { + throw new Error(responseMessage(payload, `${response.status} ${response.statusText}`)); + } + return payload; + } + + async function syncPlanUpsert(planType, plan) { + try { + await syncRequest(`/admin-api/subscriptions/plans/${encodeURIComponent(planType)}`, { + method: 'PUT', + body: JSON.stringify(plan), + }); + return { + enabled: true, + ok: true, + message: `已同步到生产后台 ${baseUrl}`, + }; + } catch (error) { + logger.warn?.('[plan-sync] upsert failed', { planType, error: String(error) }); + return { + enabled: true, + ok: false, + message: error instanceof Error ? error.message : '同步生产后台失败', + }; + } + } + + async function syncPlanDelete(planType) { + try { + await syncRequest(`/admin-api/subscriptions/plans/${encodeURIComponent(planType)}`, { + method: 'DELETE', + }); + return { + enabled: true, + ok: true, + message: `已同步删除生产后台套餐 ${planType}`, + }; + } catch (error) { + logger.warn?.('[plan-sync] delete failed', { planType, error: String(error) }); + return { + enabled: true, + ok: false, + message: error instanceof Error ? error.message : '同步生产后台失败', + }; + } + } + + async function syncAllPlans(plans) { + let synced = 0; + const failures = []; + for (const plan of plans) { + const result = await syncPlanUpsert(plan.planType, plan); + if (result.ok) synced += 1; + else failures.push(`${plan.planType}: ${result.message}`); + } + return { + enabled: true, + ok: failures.length === 0, + message: failures.length === 0 + ? `已同步 ${synced}/${plans.length} 个套餐到生产后台` + : `已同步 ${synced}/${plans.length} 个套餐,失败:${failures.join(';')}`, + synced, + total: plans.length, + failures, + }; + } + + return { + enabled: true, + reason: '', + syncPlanUpsert, + syncPlanDelete, + syncAllPlans, + }; +} diff --git a/src/App.tsx b/src/App.tsx index ec410ca..abd6cb5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,11 +6,13 @@ import { BillingPage } from './admin/pages/BillingPage'; import { CapabilitiesPage } from './admin/pages/CapabilitiesPage'; import { DashboardPage } from './admin/pages/DashboardPage'; import { PoliciesPage } from './admin/pages/PoliciesPage'; +import { MindSpacePage } from './admin/pages/MindSpacePage'; import { ProvidersPage } from './admin/pages/ProvidersPage'; import { SkillsPage } from './admin/pages/SkillsPage'; import { UserDetailPage } from './admin/pages/UserDetailPage'; import { UsersPage } from './admin/pages/UsersPage'; import { WechatPage } from './admin/pages/WechatPage'; +import { BlockedWordsPage } from './admin/pages/BlockedWordsPage'; import { defaultHomePath } from './lib/routes'; import { OpsLayout } from './ops/components/OpsLayout'; import { AnalyticsPage } from './ops/pages/AnalyticsPage'; @@ -112,8 +114,10 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void } } /> } /> } /> + } /> } /> } /> + } /> } /> ) : ( @@ -153,6 +157,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) { || pathname.startsWith('/capabilities') || pathname.startsWith('/skills') || pathname.startsWith('/policies') + || pathname.startsWith('/mindspace') || pathname.startsWith('/providers') || pathname.startsWith('/wechat') ) { diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index 81f9c5a..67e30ef 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -13,25 +13,32 @@ type NavSection = { const NAV_SECTIONS: NavSection[] = [ { - items: [{ to: '/admin', label: '概览', end: true }], + items: [{ to: '/', label: '概览', end: true }], }, { label: '用户与账户', - items: [{ to: '/admin/users', label: '用户管理' }], + items: [{ to: '/users', label: '用户管理' }], }, { label: '计费', - items: [{ to: '/admin/billing', label: '计费中心', end: false }], + items: [{ to: '/billing', label: '计费中心', end: false }], }, { label: '平台配置', items: [ - { to: '/admin/capabilities', label: '能力' }, - { to: '/admin/skills', label: '技能' }, - { to: '/admin/policies', label: '策略' }, - { to: '/admin/providers', label: 'LLM Provider' }, + { to: '/wechat', label: '服务号' }, + { to: '/mindspace', label: 'MindSpace 配置' }, + { to: '/capabilities', label: '能力' }, + { to: '/skills', label: '技能' }, + { to: '/policies', label: '策略' }, + { to: '/providers', label: '统一模型中心' }, + { to: '/blocked-words', label: '违禁词管理' }, ], }, + { + label: 'Plaza', + items: [{ to: '/ops', label: '运营后台', end: false }], + }, ]; export function AdminNav() { diff --git a/src/admin/pages/BillingPage.tsx b/src/admin/pages/BillingPage.tsx index e6d27ce..81e8f43 100644 --- a/src/admin/pages/BillingPage.tsx +++ b/src/admin/pages/BillingPage.tsx @@ -1,7 +1,19 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { listAdminLedger, listAdminUsage, rechargeUser } from '../../api/client'; +import { + cancelUserSubscription, + createSubscriptionPlan, + deleteSubscriptionPlan, + grantUserSubscription, + listAdminLedger, + listAdminSubscriptions, + listAdminUsage, + listSubscriptionPlans, + rechargeUser, + syncSubscriptionPlansToProduction, + updateSubscriptionPlan, +} from '../../api/client'; import type { PagedResult } from '../../api/client'; -import type { AdminUserRow, LedgerEntry, UsageRecord } from '../../types'; +import type { AdminSubscription, AdminUserRow, LedgerEntry, PlanDefinition, UsageRecord } from '../../types'; import { Pagination } from '../../components/Pagination'; import { useAdminUsers } from '../hooks/useAdminUsers'; import { formatTime, formatYuan } from '../utils/format'; @@ -90,9 +102,10 @@ function UserCombobox({ ); } -type TabKey = 'recharge' | 'usage' | 'ledger'; +type TabKey = 'recharge' | 'usage' | 'ledger' | 'subscriptions'; const TABS: { key: TabKey; label: string }[] = [ + { key: 'subscriptions', label: '订阅记录' }, { key: 'recharge', label: '充值' }, { key: 'usage', label: '用量记录' }, { key: 'ledger', label: '资金流水' }, @@ -326,8 +339,590 @@ function LedgerTab() { ); } +const PLAN_TIER_LABEL: Record = { + basic: '基础', + standard: '标准', + premium: '旗舰', +}; + +const EMPTY_PLAN_FORM = { + planType: '', + name: '', + priceCents: '0', + periodDays: '30', + periodTokens: '0', + periodImages: '0', + modelTier: 'basic', + overageRate: '1.00', + sortOrder: '0', + isActive: true, + description: '', +}; + +type PlanForm = typeof EMPTY_PLAN_FORM; + +function planToForm(p: PlanDefinition): PlanForm { + return { + planType: p.planType, + name: p.name, + priceCents: String(p.priceCents), + periodDays: String(p.periodDays), + periodTokens: String(p.periodTokens), + periodImages: String(p.periodImages), + modelTier: p.modelTier, + overageRate: String(p.overageRate), + sortOrder: String(p.sortOrder), + isActive: p.isActive, + description: p.description ?? '', + }; +} + +function PlanFormModal({ + initial, + onSave, + onClose, +}: { + initial: PlanForm; + onSave: (form: PlanForm) => Promise; + onClose: () => void; +}) { + const [form, setForm] = useState(initial); + const [saving, setSaving] = useState(false); + const [err, setErr] = useState(null); + const isNew = !initial.planType; + + const set = (key: keyof PlanForm, val: string | boolean) => + setForm((s) => ({ ...s, [key]: val })); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setErr(null); + try { + await onSave(form); + } catch (ex) { + setErr(ex instanceof Error ? ex.message : '保存失败'); + } finally { + setSaving(false); + } + }; + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

{isNew ? '新建套餐' : `编辑套餐 · ${initial.name}`}

+ +
+ {err &&

{err}

} +
+ {isNew && ( + + )} + + + + + + + + + + +
+ + +
+
+
+
+ ); +} + +function PlanCatalogSection({ + plans, + onPlansChange, +}: { + plans: PlanDefinition[]; + onPlansChange: () => void; +}) { + const [editing, setEditing] = useState(null); + const [err, setErr] = useState(null); + const [msg, setMsg] = useState(null); + const [deleting, setDeleting] = useState(null); + const [syncingProd, setSyncingProd] = useState(false); + + const openNew = () => setEditing({ ...EMPTY_PLAN_FORM }); + const openEdit = (p: PlanDefinition) => setEditing(planToForm(p)); + const closeModal = () => setEditing(null); + + const handleSave = async (form: PlanForm) => { + const data = { + name: form.name, + priceCents: Number(form.priceCents), + periodDays: Number(form.periodDays), + periodTokens: Number(form.periodTokens), + periodImages: Number(form.periodImages), + modelTier: form.modelTier, + overageRate: Number(form.overageRate), + sortOrder: Number(form.sortOrder), + isActive: form.isActive, + description: form.description || null, + }; + const isNew = !editing?.planType || editing.planType !== form.planType; + if (isNew) { + const result = await createSubscriptionPlan(form.planType, data); + setMsg( + result.sync?.ok === false + ? `已创建套餐「${form.name}」,但生产后台同步失败:${result.sync.message}` + : result.sync?.message + ? `已创建套餐「${form.name}」,${result.sync.message}` + : `已创建套餐「${form.name}」`, + ); + } else { + const result = await updateSubscriptionPlan(form.planType, data); + setMsg( + result.sync?.ok === false + ? `已更新套餐「${form.name}」,但生产后台同步失败:${result.sync.message}` + : result.sync?.message + ? `已更新套餐「${form.name}」,${result.sync.message}` + : `已更新套餐「${form.name}」`, + ); + } + closeModal(); + onPlansChange(); + }; + + const handleDelete = async (p: PlanDefinition) => { + if (!window.confirm(`确认删除套餐「${p.name}」?已购用户不受影响,但新用户无法选择此套餐。`)) return; + setDeleting(p.planType); + setErr(null); + try { + const result = await deleteSubscriptionPlan(p.planType); + setMsg( + result.sync?.ok === false + ? `已删除套餐「${p.name}」,但生产后台同步失败:${result.sync.message}` + : result.sync?.message + ? `已删除套餐「${p.name}」,${result.sync.message}` + : `已删除套餐「${p.name}」`, + ); + onPlansChange(); + } catch (ex) { + setErr(ex instanceof Error ? ex.message : '删除失败'); + } finally { + setDeleting(null); + } + }; + + const handleSyncProduction = async () => { + setSyncingProd(true); + setErr(null); + try { + const result = await syncSubscriptionPlansToProduction(); + if (result.ok) setMsg(result.message); + else setErr(result.message); + } catch (ex) { + setErr(ex instanceof Error ? ex.message : '同步生产后台失败'); + } finally { + setSyncingProd(false); + } + }; + + return ( +
+
+

套餐目录

+
+ + +
+
+ {err &&

{err}

} + {msg &&

{msg}

} + {plans.length === 0 ? ( +

暂无套餐,点击「新建套餐」添加。

+ ) : ( +
+ + + + + + + + + + + + + + + + {plans.map((p) => ( + + + + + + + + + + + + ))} + +
套餐价格Token 配额图片配额模型层级天数超量费率状态
+ {p.name} + ({p.planType}) + {p.description &&
{p.description}
} +
+ {p.priceCents === 0 ? '免费' : `¥${formatYuan(p.priceCents)}/月`} + + {p.periodTokens === 0 ? '不限' : `${(p.periodTokens / 10000).toFixed(0)} 万`} + + {p.periodImages === 0 ? '不限' : `${p.periodImages} 张`} + {PLAN_TIER_LABEL[p.modelTier] ?? p.modelTier}{p.periodDays} 天{p.overageRate === 1 ? '—' : `×${p.overageRate}`} + + {p.isActive ? '上架' : '下架'} + + + + {p.planType !== 'free' && ( + + )} +
+
+ )} + {editing !== null && ( + + )} +
+ ); +} + +function SubscriptionsTab() { + const { users, reload: reloadUsers } = useAdminUsers(); + const [plans, setPlans] = useState([]); + const [result, setResult] = useState<{ + items: AdminSubscription[]; + total: number; + page: number; + totalPages: number; + pageSize: number; + } | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const [filterUserId, setFilterUserId] = useState(''); + const [pendingUserId, setPendingUserId] = useState(''); + const [filterStatus, setFilterStatus] = useState('active'); + const [grant, setGrant] = useState({ userId: '', planType: '', durationDays: '', note: '' }); + const [submitting, setSubmitting] = useState(false); + const [cancelling, setCancelling] = useState(null); + + const loadPlans = useCallback(async () => { + try { + const p = await listSubscriptionPlans(); + setPlans(p); + } catch { /* ignore */ } + }, []); + + useEffect(() => { void loadPlans(); }, [loadPlans]); + + const load = useCallback(async (p: number, userId: string, status: string) => { + setLoading(true); + setError(null); + try { + const data = await listAdminSubscriptions({ + userId: userId || undefined, + status: status || undefined, + page: p, + pageSize: 20, + }); + setResult(data); + setFilterUserId(userId); + setFilterStatus(status); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void load(1, '', 'active'); }, [load]); + + const handleQuery = () => void load(1, pendingUserId, filterStatus); + const handlePage = (p: number) => void load(p, filterUserId, filterStatus); + + const activePlans = plans.filter((p) => p.isActive && p.priceCents > 0); + + const handleGrant = async (e: React.FormEvent) => { + e.preventDefault(); + if (!grant.userId || !grant.planType) return; + setSubmitting(true); + setError(null); + setMessage(null); + try { + const days = grant.durationDays ? Number(grant.durationDays) : undefined; + await grantUserSubscription(grant.userId, grant.planType, days, grant.note || undefined); + const planName = plans.find((p) => p.planType === grant.planType)?.name ?? grant.planType; + const user = users.find((u) => u.id === grant.userId); + setMessage(`已为 ${user?.displayName ?? grant.userId} 授予 ${planName} 套餐`); + setGrant((s) => ({ ...s, userId: '', note: '' })); + await reloadUsers(); + void load(1, filterUserId, filterStatus); + } catch (err) { + setError(err instanceof Error ? err.message : '授予失败'); + } finally { + setSubmitting(false); + } + }; + + const handleCancel = async (userId: string, displayName: string) => { + if (!window.confirm(`确认取消 ${displayName} 的套餐?`)) return; + setCancelling(userId); + setError(null); + setMessage(null); + try { + await cancelUserSubscription(userId); + setMessage(`已取消 ${displayName} 的套餐`); + void load(1, filterUserId, filterStatus); + } catch (err) { + setError(err instanceof Error ? err.message : '取消失败'); + } finally { + setCancelling(null); + } + }; + + const STATUS_LABEL: Record = { active: '有效', expired: '已到期', cancelled: '已取消' }; + + return ( + <> + + + {/* Grant subscription */} +
+

授予套餐

+ {error &&

{error}

} + {message &&

{message}

} +
+ u.role === 'user')} + value={grant.userId} + onChange={(id) => setGrant((s) => ({ ...s, userId: id }))} + /> + + setGrant((s) => ({ ...s, durationDays: e.target.value }))} + /> + setGrant((s) => ({ ...s, note: e.target.value }))} + /> + + +
+ + {/* Subscription list */} +
+
+

订阅记录

+
+ + + +
+
+ {loading &&

加载中…

} + {!loading && result && ( + result.items.length === 0 ? ( +

暂无记录

+ ) : ( + <> +
+ + + + + + + + + + + + + + + {result.items.map((row) => ( + + + + + + + + + + + ))} + +
用户套餐状态Token 用量图片用量到期时间备注
+ {row.displayName} + @{row.username} + {row.planType} + + {STATUS_LABEL[row.status] ?? row.status} + + + {row.periodTokensUsed.toLocaleString()} + {row.periodTokensLimit > 0 && ( + / {row.periodTokensLimit.toLocaleString()} + )} + + {row.periodImagesUsed} + {row.periodImagesLimit > 0 && ( + / {row.periodImagesLimit} + )} + {formatTime(row.expiresAt)}{row.note ?? '—'} + {row.status === 'active' && ( + + )} +
+
+ + + ) + )} +
+ + ); +} + export function BillingPage() { - const [activeTab, setActiveTab] = useState('recharge'); + const [activeTab, setActiveTab] = useState('subscriptions'); return (
@@ -348,6 +943,7 @@ export function BillingPage() { {activeTab === 'recharge' && } {activeTab === 'usage' && } {activeTab === 'ledger' && } + {activeTab === 'subscriptions' && }
); diff --git a/src/admin/pages/BlockedWordsPage.tsx b/src/admin/pages/BlockedWordsPage.tsx new file mode 100644 index 0000000..70b6b6f --- /dev/null +++ b/src/admin/pages/BlockedWordsPage.tsx @@ -0,0 +1,318 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + listBlockedWords, + createBlockedWord, + updateBlockedWord, + deleteBlockedWord, +} from '../../api/client'; +import type { BlockedWord } from '../../types'; + +const DEFAULT_WORDS = ['goose', 'aider', 'openhands']; + +export function BlockedWordsPage() { + const [words, setWords] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + + const [newWord, setNewWord] = useState(''); + const [newReplacement, setNewReplacement] = useState('***'); + const [newNote, setNewNote] = useState(''); + const [adding, setAdding] = useState(false); + + const [editId, setEditId] = useState(null); + const [editWord, setEditWord] = useState(''); + const [editReplacement, setEditReplacement] = useState(''); + const [editNote, setEditNote] = useState(''); + const [editBusy, setEditBusy] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setWords(await listBlockedWords()); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void load(); }, [load]); + + const handleAdd = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newWord.trim()) return; + setAdding(true); + setError(null); + setMessage(null); + try { + const created = await createBlockedWord({ + word: newWord.trim(), + replacement: newReplacement || '***', + note: newNote.trim() || undefined, + }); + setWords((prev) => [created, ...prev]); + setNewWord(''); + setNewReplacement('***'); + setNewNote(''); + setMessage(`已添加:${created.word}`); + } catch (err) { + setError(err instanceof Error ? err.message : '添加失败'); + } finally { + setAdding(false); + } + }; + + const startEdit = (w: BlockedWord) => { + setEditId(w.id); + setEditWord(w.word); + setEditReplacement(w.replacement); + setEditNote(w.note ?? ''); + }; + + const cancelEdit = () => setEditId(null); + + const handleSaveEdit = async (id: string) => { + setEditBusy(true); + setError(null); + setMessage(null); + try { + const updated = await updateBlockedWord(id, { + word: editWord.trim(), + replacement: editReplacement || '***', + note: editNote.trim() || '', + }); + setWords((prev) => prev.map((w) => (w.id === id ? updated : w))); + setEditId(null); + setMessage('已更新'); + } catch (err) { + setError(err instanceof Error ? err.message : '更新失败'); + } finally { + setEditBusy(false); + } + }; + + const handleToggleStatus = async (w: BlockedWord) => { + setError(null); + try { + const updated = await updateBlockedWord(w.id, { + status: w.status === 'active' ? 'disabled' : 'active', + }); + setWords((prev) => prev.map((item) => (item.id === w.id ? updated : item))); + } catch (err) { + setError(err instanceof Error ? err.message : '状态切换失败'); + } + }; + + const handleDelete = async (id: string, word: string) => { + if (!window.confirm(`确认删除词语「${word}」?`)) return; + setError(null); + try { + await deleteBlockedWord(id); + setWords((prev) => prev.filter((w) => w.id !== id)); + setMessage(`已删除:${word}`); + } catch (err) { + setError(err instanceof Error ? err.message : '删除失败'); + } + }; + + const handleQuickAdd = async (word: string) => { + setError(null); + setMessage(null); + try { + const created = await createBlockedWord({ word, replacement: '***' }); + setWords((prev) => [created, ...prev]); + setMessage(`已添加:${created.word}`); + } catch (err) { + setError(err instanceof Error ? err.message : '添加失败'); + } + }; + + const existingWords = new Set(words.map((w) => w.word.toLowerCase())); + const quickAddable = DEFAULT_WORDS.filter((w) => !existingWords.has(w.toLowerCase())); + + return ( +
+
+

违禁词管理

+

H5 聊天中 AI 回复将自动替换以下词语,立即生效(前端刷新后加载最新规则)。

+
+ + {error &&

{error}

} + {message &&

{message}

} + + {quickAddable.length > 0 && ( +
+

快速添加预设违禁词

+
+ {quickAddable.map((w) => ( + + ))} +
+
+ )} + +
+

添加词语

+
+ + + +
+ +
+
+
+ +
+
+

词语列表{loading ? '' : `(${words.length})`}

+ +
+ {loading &&

加载中…

} + {!loading && words.length === 0 &&

暂无违禁词

} + {!loading && words.length > 0 && ( + + + + + + + + + + + + {words.map((w) => ( + + {editId === w.id ? ( + <> + + + + + + + ) : ( + <> + + + + + + + )} + + ))} + +
词语替换为备注状态操作
+ setEditWord(e.target.value)} + style={{ width: '120px' }} + /> + + setEditReplacement(e.target.value)} + style={{ width: '80px' }} + /> + + setEditNote(e.target.value)} + style={{ width: '120px' }} + /> + + + + {w.word}{w.replacement}{w.note || '—'} + + {w.status === 'active' ? '启用' : '禁用'} + + + + + +
+ )} +
+
+ ); +} diff --git a/src/admin/pages/DashboardPage.tsx b/src/admin/pages/DashboardPage.tsx index 14fbd94..5cbff92 100644 --- a/src/admin/pages/DashboardPage.tsx +++ b/src/admin/pages/DashboardPage.tsx @@ -1,13 +1,15 @@ import { Link } from 'react-router-dom'; import { useCallback, useEffect, useState } from 'react'; -import { getAdminDashboardSummary } from '../../api/client'; +import { getAdminDashboardSummary, restartAdminService } from '../../api/client'; +import type { AdminServiceRestartAction } from '../../types'; import type { AdminDashboardSummary } from '../../types'; import { formatTime, formatYuan } from '../utils/format'; const QUICK_LINKS = [ { to: '/users', label: '用户管理', desc: '创建账号、启用禁用' }, { to: '/billing/recharge', label: '充值', desc: '为用户账户充值' }, - { to: '/providers', label: 'LLM Provider', desc: '模型 Key 与全局配置' }, + { to: '/providers', label: '统一模型中心', desc: 'Provider、执行器模型与启动控制' }, + { to: '/mindspace', label: 'MindSpace 配置', desc: '公开页上限与空间发布参数' }, { to: '/capabilities', label: '能力权限', desc: '扩展与工具开关' }, ] as const; @@ -15,6 +17,9 @@ export function DashboardPage() { const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [serviceBusy, setServiceBusy] = useState(null); + const [serviceMessage, setServiceMessage] = useState(null); + const [serviceError, setServiceError] = useState(null); const load = useCallback(async () => { setLoading(true); @@ -32,6 +37,24 @@ export function DashboardPage() { void load(); }, [load]); + const handleServiceRestart = useCallback(async (action: AdminServiceRestartAction) => { + setServiceBusy(action); + setServiceMessage(null); + setServiceError(null); + try { + const result = await restartAdminService(action); + const suffix = result.logFile ? ` 日志: ${result.logFile}` : ''; + setServiceMessage(`${result.message}${suffix}`); + if (action === 'local_restart') { + window.setTimeout(() => window.location.reload(), 6000); + } + } catch (err) { + setServiceError(err instanceof Error ? err.message : `${action} 失败`); + } finally { + setServiceBusy(null); + } + }, []); + const safeSummary: AdminDashboardSummary = summary ?? { users: { total: 0, active: 0, lowBalance: 0, totalBalanceCents: 0 }, usage24h: { count: 0, costCents: 0 }, @@ -49,6 +72,8 @@ export function DashboardPage() { {error &&

{error}

} + {serviceMessage &&

{serviceMessage}

} + {serviceError &&

{serviceError}

}
@@ -75,7 +100,7 @@ export function DashboardPage() {
-
LLM Provider
+
统一模型中心
{loading ? '—' : (safeSummary.llm?.keyCount ?? '—')}
{loading @@ -87,6 +112,41 @@ export function DashboardPage() {
+
+
+
+

服务重启

+

`local_restart` = 本机开发重启;`pro_restart` = 本机生产脚本重启。

+
+
+
+
+

本机开发重启

+

本机构建并重启 `127.0.0.1:8085` 和 `127.0.0.1:5174`。页面会短暂断开。

+ +
+
+

生产脚本重启

+

本机直接执行 `remote_restart.sh`。部署在生产机时,用它重启当前生产机服务。

+ +
+
+
+ {!loading && safeSummary.lowBalanceUsers.length > 0 && (
diff --git a/src/admin/pages/MindSpacePage.tsx b/src/admin/pages/MindSpacePage.tsx new file mode 100644 index 0000000..27bee12 --- /dev/null +++ b/src/admin/pages/MindSpacePage.tsx @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useState, type FormEvent } from 'react'; +import { getMindSpaceAdminConfig, updateMindSpaceAdminConfig } from '../../api/client'; +import type { MindSpaceAdminConfig } from '../../types'; + +function safeConfig(config: MindSpaceAdminConfig | null): MindSpaceAdminConfig { + return config ?? { publicPageLimit: 10 }; +} + +export function MindSpacePage() { + const [config, setConfig] = useState(null); + const [limit, setLimit] = useState('10'); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + setMessage(null); + try { + const nextConfig = await getMindSpaceAdminConfig(); + setConfig(nextConfig); + setLimit(String(nextConfig.publicPageLimit)); + } catch (err) { + setError(err instanceof Error ? err.message : '加载 MindSpace 配置失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const handleSave = async (event: FormEvent) => { + event.preventDefault(); + setBusy(true); + setError(null); + setMessage(null); + try { + const nextConfig = await updateMindSpaceAdminConfig({ publicPageLimit: Number(limit) }); + setConfig(nextConfig); + setLimit(String(nextConfig.publicPageLimit)); + setMessage(`已更新为 ${nextConfig.publicPageLimit},主站会立即按新值校验。`); + } catch (err) { + setError(err instanceof Error ? err.message : '保存 MindSpace 配置失败'); + } finally { + setBusy(false); + } + }; + + const current = safeConfig(config); + + return ( +
+
+

MindSpace 配置

+

公开页面数量上限由这里统一管理,主站会直接读取数据库配置。

+
+ + {error &&

{error}

} + {message &&

{message}

} + +
+

公开页面上限

+

+ 当前值:{loading ? '—' : current.publicPageLimit}。建议在修改后同步检查主站发布流程。 +

+
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/admin/pages/ProvidersPage.tsx b/src/admin/pages/ProvidersPage.tsx index 1050e70..8af8919 100644 --- a/src/admin/pages/ProvidersPage.tsx +++ b/src/admin/pages/ProvidersPage.tsx @@ -1,13 +1,865 @@ -import { ProviderKeySettings } from '../../components/ProviderKeySettings'; +import type React from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + clearLlmVisionKey, + createLlmProviderKey, + deleteLlmProviderKey, + getLlmGlobalSettings, + getLlmVisionSettings, + launchLlmExecutor, + listLlmExecutorBindings, + listLlmExecutorLaunchPlans, + listLlmExecutorLaunchStatus, + listLlmExecutorRuntime, + listLlmProviderCatalog, + listLlmProviderKeys, + restartLlmExecutor, + selectLlmProviderKey, + setLlmExecutorBinding, + setLlmVisionKey, + stopLlmExecutor, + syncLlmProviderToGoosed, + testLlmProviderKey, + updateLlmProviderKey, +} from '../../api/client'; +import type { + LlmExecutorBinding, + LlmExecutorLaunchPlan, + LlmExecutorLaunchState, + LlmExecutorRuntime, + LlmGlobalSettings, + LlmProviderDefinition, + LlmProviderKeyRow, + LlmVisionSettings, +} from '../../types'; -export function ProvidersPage() { +const CUSTOM_PROVIDER_ID = '__custom__'; + +const defaultProviderForm = { + providerId: CUSTOM_PROVIDER_ID, + name: '', + apiKey: '', + apiUrl: '', + modelsText: '', + defaultModel: '', + relayProvider: '', +}; + +type ProviderFormState = typeof defaultProviderForm; +type ProviderDialogState = + | { mode: 'create'; form: ProviderFormState } + | { mode: 'edit'; row: LlmProviderKeyRow; form: ProviderFormState }; + +function parseModelsText(text: string) { + return [...new Set(text.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))]; +} + +function statusText(status?: LlmExecutorLaunchState | null) { + if (!status) return '未启动'; + if (status.running) return `运行中${status.pid ? ` · pid ${status.pid}` : ''}`; + return status.stoppedAt ? '已停止' : '未运行'; +} + +function ProviderSelect({ + keys, + value, + onChange, +}: { + keys: LlmProviderKeyRow[]; + value: string; + onChange: (value: string) => void; +}) { return ( -
-
-

LLM Provider

-

模型 Key、全局默认模型与联通测试

+ + ); +} + +function providerFormFromRow(row: LlmProviderKeyRow): ProviderFormState { + return { + providerId: row.providerId, + name: row.name, + apiKey: '', + apiUrl: row.apiUrl ?? '', + modelsText: row.models.join('\n'), + defaultModel: row.defaultModel, + relayProvider: row.relayProvider ?? '', + }; +} + +function ProviderDialog({ + state, + catalog, + busy, + onChange, + onClose, + onSubmit, +}: { + state: ProviderDialogState; + catalog: LlmProviderDefinition[]; + busy: boolean; + onChange: (form: ProviderFormState) => void; + onClose: () => void; + onSubmit: (event: React.FormEvent) => void; +}) { + const form = state.form; + const isCreate = state.mode === 'create'; + const isCustom = form.providerId === CUSTOM_PROVIDER_ID; + const customModels = parseModelsText(form.modelsText); + const selectedCatalog = catalog.find((item) => item.id === form.providerId) ?? null; + const modelOptions = isCustom ? customModels : selectedCatalog?.models ?? []; + + return ( +
+
event.stopPropagation()}> +
+
+

{isCreate ? '添加 Provider' : '编辑 Provider'}

+

{isCreate ? '新增一组可用于执行器绑定的 LLM 配置' : '调整名称、模型列表或连接参数'}

+
+ +
+ +
+ + + + + {isCustom ? ( + <> + + +