diff --git a/.env.example b/.env.example index 632dca0..ba27658 100644 --- a/.env.example +++ b/.env.example @@ -136,6 +136,7 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # Plaza 发现广场(本机为主,105 已停用) # PLAZA_AUTO_APPROVE=true +# VITE_PLAZA_BASE=http://127.0.0.1:3001 # 文档:docs/plaza-local.md | 本地:http://127.0.0.1:3001/plaza # MindSpace(默认启用;子功能需按需开启) @@ -146,4 +147,5 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # Agent 后台任务(创建/执行空间内 Agent 任务,默认关闭) # MINDSPACE_AGENT_JOBS_ENABLED=true # MINDSPACE_INTERNAL_AGENT_SECRET=local-dev-secret +# MINDSPACE_FREE_PUBLIC_PAGE_LIMIT=10 # MINDSPACE_STORAGE_ROOT=/path/to/mindspace-storage diff --git a/.gitignore b/.gitignore index 4116b8f..cc07efc 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ users/ .idea/ .vscode/ .aider* + +scripts/g2-aliyun-cert.env +scripts/__pycache__/ +*.bak-* +temp/ diff --git a/README.md b/README.md index 4524762..6c7113d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +> **生产环境警示:当前目录 `/Users/john/Project/Memind` 为生产目录与生产环境,禁止重启服务;操作前先阅读 [docs/service-isolation-runbook.md](docs/service-isolation-runbook.md)。** + # Memind (TKMind H5) Memind 主应用与 Plaza 发现广场。**当前以本机 Mac 为主服务**,105 服务器已停用。 @@ -20,7 +22,7 @@ pnpm dev # MindSpace + Plaza + Ops | API / Portal | http://127.0.0.1:8081 | | memind_adm | http://127.0.0.1:8082 | -详见 [docs/local-dev.md](docs/local-dev.md)。 +详见 [docs/local-dev.md](docs/local-dev.md)。生产机器上预览前先看 [生产 / 测试 / 预览隔离规程](docs/service-isolation-runbook.md),不要占用 `8081`。 仅 Plaza: @@ -56,8 +58,36 @@ pnpm open:plaza # 浏览器打开 https://plaza.tkmind.cn/plaza | `pnpm open:plaza` | 浏览器强制本地解析 | | `pnpm check:plaza` | 本地 Plaza 诊断 | | `pnpm test` | 单元测试 | +| `bash scripts/deploy-to-test-host.sh --dry-run` | 预览并检查到 `192.168.1.9` 的同步差异 | +| `bash scripts/deploy-to-test-host.sh` | 同步 `Memind / memind_adm / Plaza` 到测试机 | + +## 通过 rsync 发布到测试机(推荐) + +目标机路径(你给的基线): + +- `/Users/john/PycharmProjects/test/test-memind` +- `/Users/john/PycharmProjects/test/test-memindadm` +- `/Users/john/PycharmProjects/test/test-memindplaza` + +执行: + +```bash +cd /Users/john/Project/Memind +bash scripts/deploy-to-test-host.sh --dry-run +bash scripts/deploy-to-test-host.sh +``` + +说明: + +- 这是“仅源码同步”流程,不会包含 goose 相关服务 +- 默认会同步三个目录(`--only-memind` / `--only-adm` / `--only-plaza` 可单独操作) +- `--skip-delete` 可避免远端删除未在本次排除列表中的文件 +- 同步后仍需按你们现网启动方式重启对应服务 ## 文档 - [本地开发(test.*.tkmind.cn)](docs/local-dev.md) +- [Memind 小白使用手册](docs/memind-beginner-guide.md) +- [生产更新发布指南](docs/release-deploy.md) +- [生产 / 测试 / 预览隔离规程](docs/service-isolation-runbook.md) - [Plaza 本机部署与 Tunnel](docs/plaza-local.md) diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index f16a7ba..28dc915 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -20,6 +20,8 @@ import { createPlazaInteractionService } from './plaza-interactions.mjs'; import { createPlazaOpsService } from './plaza-ops.mjs'; import { createNoopPlazaRedis } from './plaza-redis.mjs'; import { ensureAlgorithmConfig, loadAlgorithmConfig } from './plaza-algorithm.mjs'; +import { createWechatAdminService } from './wechat-admin.mjs'; +import { loadWechatMpConfig } from './wechat-mp.mjs'; const noop = () => {}; @@ -32,7 +34,7 @@ const noop = () => {}; * @param {string} [env.apiSecret] Goosed/relay API secret. * @param {number} [env.defaultSignupBalanceCents] * @param {boolean} [env.ensureAdminUser] Ensure an admin account exists on boot (default true). - * @returns {Promise<{pool, userAuth, llmProviderService, plazaPosts, plazaOps}>} + * @returns {Promise<{pool, userAuth, llmProviderService, plazaPosts, plazaOps, wechatAdmin}>} */ export async function createAdminServices(env = {}) { if (!isDatabaseConfigured()) { @@ -85,6 +87,11 @@ export async function createAdminServices(env = {}) { } const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret }); + const wechatAdmin = createWechatAdminService(pool, { + config: loadWechatMpConfig(), + scheduleEnabled: process.env.H5_SCHEDULE_ENABLED === '1', + reminderWorkerEnabled: process.env.H5_REMINDER_WORKER_ENABLED === '1', + }); - return { pool, userAuth, llmProviderService, plazaPosts, plazaOps }; + return { pool, userAuth, llmProviderService, plazaPosts, plazaOps, wechatAdmin }; } diff --git a/admin-routes.mjs b/admin-routes.mjs index e17499a..0c4820c 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -31,8 +31,18 @@ function plazaRouteError(res, req, error) { * @param {object|null} deps.llmProviderService * @param {object|null} deps.plazaPosts * @param {object|null} deps.plazaOps + * @param {object|null} deps.wechatAdmin */ -export function createAdminApi({ jsonBody, getToken, ready, userAuth, llmProviderService, plazaPosts, plazaOps }) { +export function createAdminApi({ + jsonBody, + getToken, + ready, + userAuth, + llmProviderService, + plazaPosts, + plazaOps, + wechatAdmin, +}) { function requireAdmin(req, res, next) { if (!req.currentUser || req.currentUser.role !== 'admin') { res.status(403).json({ message: '需要管理员权限' }); @@ -237,6 +247,52 @@ export function createAdminApi({ jsonBody, getToken, ready, userAuth, llmProvide res.json(result); }); + adminApi.get('/wechat/summary', requireAdmin, async (_req, res) => { + if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); + res.json(await wechatAdmin.getSummary()); + }); + + adminApi.get('/wechat/bindings', requireAdmin, async (req, res) => { + if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); + res.json(await wechatAdmin.listBindings(req.query)); + }); + + adminApi.get('/wechat/messages', requireAdmin, async (req, res) => { + if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); + res.json(await wechatAdmin.listMessages(req.query)); + }); + + adminApi.get('/wechat/digests', requireAdmin, async (req, res) => { + if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); + res.json(await wechatAdmin.listDigests(req.query)); + }); + + adminApi.get('/wechat/deliveries', requireAdmin, async (req, res) => { + if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); + res.json(await wechatAdmin.listDeliveries(req.query)); + }); + + 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); + if (!result.ok) return res.status(404).json({ message: result.message ?? '清除失败' }); + res.json(result); + }); + + adminApi.post('/wechat/digests/:id/cancel', requireAdmin, async (req, res) => { + if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); + const result = await wechatAdmin.cancelDigest(req.params.id); + if (!result.ok) return res.status(404).json({ message: '订阅不存在或已暂停' }); + res.json(result); + }); + + adminApi.post('/wechat/digests/:id/resume', requireAdmin, async (req, res) => { + if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' }); + const result = await wechatAdmin.resumeDigest(req.params.id); + if (!result.ok) return res.status(404).json({ message: result.message ?? '恢复失败' }); + res.json(result); + }); + adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); res.json({ catalog: llmProviderService.catalog }); diff --git a/admin-server.mjs b/admin-server.mjs index 5a0ebf2..47284fe 100644 --- a/admin-server.mjs +++ b/admin-server.mjs @@ -78,6 +78,7 @@ const CONSOLES = { llmProviderService: services.llmProviderService, plazaPosts: services.plazaPosts, plazaOps: services.plazaOps, + wechatAdmin: services.wechatAdmin, }), }, ops: { diff --git a/billing.mjs b/billing.mjs index 1a0572f..a04ee8b 100644 --- a/billing.mjs +++ b/billing.mjs @@ -1,9 +1,13 @@ export function loadBillingConfig() { // 默认按人民币分(CNY cents)计费;仅当 H5_USE_BACKEND_COST=1 时才用上游 USD 成本换算。 const useBackendCost = process.env.H5_USE_BACKEND_COST === '1'; + // 成本模式下的毛利倍数:最终扣费 = 上游真实成本(USD) × 汇率 × marginMultiplier。 + // 默认 1(按成本价卖,零毛利)——启用 useBackendCost 时务必显式设置目标倍数。 + const marginMultiplier = Number(process.env.H5_MARGIN_MULTIPLIER ?? 1); return { useBackendCost, usdCnyRate: Number(process.env.H5_USD_CNY_RATE ?? 7.2), + marginMultiplier: Number.isFinite(marginMultiplier) && marginMultiplier > 0 ? marginMultiplier : 1, inputCentsPer1k: Number(process.env.H5_BILL_INPUT_CENTS_PER_1K ?? 2), outputCentsPer1k: Number(process.env.H5_BILL_OUTPUT_CENTS_PER_1K ?? 6), minBillCents: Number(process.env.H5_MIN_BILL_CENTS ?? 1), @@ -46,15 +50,21 @@ export function computeDeltaCostCents(previous, current, config = loadBillingCon const currCost = current.accumulatedCost == null ? null : Number(current.accumulatedCost); + // 成本模式:按上游真实成本(USD)增量 × 汇率 × 毛利倍数扣费,自动跟随 provider/模型, + // 无需为每个模型重调 token 单价。仅在上游确实回传 accumulatedCost 时生效;否则回退 token 路径。 + const margin = config.marginMultiplier ?? 1; if (config.useBackendCost && prevCost != null && currCost != null && currCost >= prevCost) { const deltaUsd = currCost - prevCost; - return Math.max(config.minBillCents, Math.ceil(deltaUsd * config.usdCnyRate * 100)); + if (deltaUsd <= 0) return 0; + return Math.max(config.minBillCents, Math.ceil(deltaUsd * config.usdCnyRate * 100 * margin)); } if (config.useBackendCost && prevCost == null && currCost != null && currCost > 0) { - return Math.max(config.minBillCents, Math.ceil(currCost * config.usdCnyRate * 100)); + return Math.max(config.minBillCents, Math.ceil(currCost * config.usdCnyRate * 100 * margin)); } - // 默认路径:按 Token 增量 × 人民币单价(分/1k tokens)扣费。 + // 默认/回退路径:按 Token 增量 × 人民币单价(分/1k tokens)扣费。 + // 注意:flat 单价与 provider 真实成本脱钩(对 DeepSeek 中继约 20× 超收), + // 生产应启用 useBackendCost + marginMultiplier 走成本模式,详见 docs/h5-metering-gateway.md。 const prevIn = Number(previous?.lastInputTokens ?? 0); const prevOut = Number(previous?.lastOutputTokens ?? 0); diff --git a/billing.test.mjs b/billing.test.mjs index 5279949..196f632 100644 --- a/billing.test.mjs +++ b/billing.test.mjs @@ -49,6 +49,30 @@ test('computeDeltaCostCents ignores backend USD cost unless explicitly enabled', }); const rmbConfig = { ...config, useBackendCost: false }; assert.equal(computeDeltaCostCents(previous, current, rmbConfig), 5); - const usdConfig = { ...config, useBackendCost: true }; + // 成本模式默认 margin=1:deltaUsd 0.01 × 7.2 × 100 = 7.2 → ceil = 8 + const usdConfig = { ...config, useBackendCost: true, marginMultiplier: 1 }; assert.equal(computeDeltaCostCents(previous, current, usdConfig), 8); }); + +test('computeDeltaCostCents applies margin multiplier in cost mode', () => { + const previous = { lastInputTokens: 0, lastOutputTokens: 0, lastAccumulatedCost: 0 }; + const current = normalizeTokenState({ + accumulatedInputTokens: 1_000_000, + accumulatedOutputTokens: 10_000, + accumulatedCost: 0.25, + }); + // deltaUsd 0.25 × 7.2 × 100 × 3 = 540 分(而非 flat token 的 2060 分) + const costConfig = { ...config, useBackendCost: true, marginMultiplier: 3 }; + assert.equal(computeDeltaCostCents(previous, current, costConfig), 540); +}); + +test('computeDeltaCostCents falls back to token path when accumulatedCost missing', () => { + const previous = { lastInputTokens: 0, lastOutputTokens: 0 }; + const current = normalizeTokenState({ + accumulatedInputTokens: 1000, + accumulatedOutputTokens: 500, + // 上游未回传 accumulatedCost → 即便开启成本模式也回退 token 计费 + }); + const costConfig = { ...config, useBackendCost: true, marginMultiplier: 3 }; + assert.equal(computeDeltaCostCents(previous, current, costConfig), 5); +}); diff --git a/capabilities.test.mjs b/capabilities.test.mjs index 8526a60..4a786e0 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -120,6 +120,23 @@ test('buildPageEditAgentPolicy without shell keeps empty developer tools', () => assert.deepEqual(narrowed.extensionOverrides, []); }); +test('buildPageEditAgentPolicy grants shell for static_publish sandbox users with shell capability', () => { + const base = { + ...buildAgentExtensionPolicy( + { ...DEFAULT_USER_CAPABILITIES, static_publish: true, shell: true }, + { + sandboxMcp: { + serverPath: '/tmp/mindspace-sandbox-mcp.mjs', + sandboxRoot: '/tmp/sandbox', + }, + }, + ), + capabilities: { ...DEFAULT_USER_CAPABILITIES, static_publish: true, shell: true }, + }; + const narrowed = buildPageEditAgentPolicy(base); + assert.deepEqual(narrowed.extensionOverrides?.[0]?.available_tools, ['shell']); +}); + test('sandboxMcpTools returns correct tool list based on capabilities', () => { const base = { ...DEFAULT_USER_CAPABILITIES, static_publish: true }; assert.deepEqual(sandboxMcpTools(base), ['read_file', 'write_file', 'edit_file', 'create_dir']); diff --git a/db.mjs b/db.mjs index 0c11c60..811e090 100644 --- a/db.mjs +++ b/db.mjs @@ -216,6 +216,12 @@ export async function migrateSchema(pool) { ); } + if (!(await columnExists(pool, 'h5_user_sessions', 'goosed_node'))) { + await pool.query( + `ALTER TABLE h5_user_sessions ADD COLUMN goosed_node TINYINT UNSIGNED NOT NULL DEFAULT 0`, + ); + } + const oauthStateColumns = [ ['intent', "VARCHAR(16) NOT NULL DEFAULT 'login' AFTER utm_campaign"], ['bind_user_id', 'CHAR(36) NULL AFTER intent'], @@ -251,6 +257,120 @@ export async function migrateSchema(pool) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_wechat_mp_messages ( + app_id VARCHAR(32) NOT NULL, + openid VARCHAR(64) NOT NULL, + msg_id VARCHAR(128) NOT NULL, + status ENUM('processing', 'done', 'failed') NOT NULL DEFAULT 'processing', + agent_session_id VARCHAR(128) NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + PRIMARY KEY (app_id, openid, msg_id), + KEY idx_wechat_mp_messages_updated (updated_at), + KEY idx_wechat_mp_messages_session (agent_session_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_schedule_items ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + kind ENUM('task', 'event') NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT NULL, + status ENUM('active', 'completed', 'cancelled', 'deleted') NOT NULL DEFAULT 'active', + start_at BIGINT NULL, + end_at BIGINT NULL, + due_at BIGINT NULL, + all_day TINYINT(1) NOT NULL DEFAULT 0, + timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai', + location VARCHAR(255) NULL, + source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent', + source_session_id VARCHAR(128) NULL, + source_message_id VARCHAR(128) NULL, + source_text TEXT NULL, + metadata_json JSON NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + deleted_at BIGINT NULL, + KEY idx_schedule_user_time (user_id, status, start_at, due_at), + KEY idx_schedule_user_updated (user_id, updated_at), + CONSTRAINT fk_schedule_item_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_schedule_reminders ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + item_id CHAR(36) NOT NULL, + remind_at BIGINT NOT NULL, + offset_minutes INT NULL, + channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat', + status ENUM('pending', 'locked', 'sent', 'failed', 'cancelled') NOT NULL DEFAULT 'pending', + attempts INT NOT NULL DEFAULT 0, + last_error VARCHAR(500) NULL, + locked_until BIGINT NULL, + sent_at BIGINT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_schedule_item_remind_at (item_id, remind_at, channel), + KEY idx_reminder_due (status, remind_at), + KEY idx_reminder_user (user_id, status, remind_at), + CONSTRAINT fk_schedule_reminder_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_schedule_reminder_item FOREIGN KEY (item_id) REFERENCES h5_schedule_items(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_schedule_digest_subscriptions ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + digest_type ENUM('todo_day') NOT NULL DEFAULT 'todo_day', + hour TINYINT UNSIGNED NOT NULL, + minute TINYINT UNSIGNED NOT NULL DEFAULT 0, + timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai', + channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat', + status ENUM('active', 'locked', 'failed', 'cancelled') NOT NULL DEFAULT 'active', + next_run_at BIGINT NOT NULL, + last_run_at BIGINT NULL, + attempts INT NOT NULL DEFAULT 0, + locked_until BIGINT NULL, + last_error VARCHAR(500) NULL, + source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent', + source_session_id VARCHAR(128) NULL, + source_message_id VARCHAR(128) NULL, + source_text TEXT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_schedule_digest_user_type_channel (user_id, digest_type, channel), + KEY idx_schedule_digest_due (status, next_run_at), + CONSTRAINT fk_schedule_digest_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_schedule_delivery_logs ( + id CHAR(36) PRIMARY KEY, + reminder_id CHAR(36) NULL, + subscription_id CHAR(36) NULL, + user_id CHAR(36) NOT NULL, + channel ENUM('wechat', 'in_app') NOT NULL, + status ENUM('success', 'failed') NOT NULL, + provider_message_id VARCHAR(128) NULL, + error_code VARCHAR(64) NULL, + error_message VARCHAR(500) NULL, + created_at BIGINT NOT NULL, + KEY idx_delivery_reminder (reminder_id, created_at), + KEY idx_delivery_subscription (subscription_id, created_at), + KEY idx_delivery_user (user_id, created_at), + CONSTRAINT fk_schedule_delivery_reminder FOREIGN KEY (reminder_id) REFERENCES h5_schedule_reminders(id) ON DELETE CASCADE, + CONSTRAINT fk_schedule_delivery_subscription FOREIGN KEY (subscription_id) REFERENCES h5_schedule_digest_subscriptions(id) ON DELETE CASCADE, + CONSTRAINT fk_schedule_delivery_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query( `ALTER TABLE h5_payment_orders MODIFY pay_mode ENUM('native', 'h5', 'jsapi') NOT NULL DEFAULT 'native'`, diff --git a/docs/g2-load-balancing.md b/docs/g2-load-balancing.md index e92ec82..afbb6dc 100644 --- a/docs/g2-load-balancing.md +++ b/docs/g2-load-balancing.md @@ -20,6 +20,27 @@ ## 调灰度比例(最常用) +## 105 联通约束(硬性要求) + +**要求:105 机器与本机通信必须走 Tailscale 隧道,不允许走外网 IP。** + +- 访问、同步、部署 105 一律使用 `ssh105`。 +- `ssh105` 在 `~/.ssh/config` 中绑定为 `100.101.255.32` 并通过 `tailscale nc` 代理; +- 现有脚本默认主机已改为 `root@ssh105`。 +- 本机对 105 的联通入口是 `127.0.0.1:18080`,不是 `105.tkmind.cn`。 + +快速自检(每次操作 105 前): + +```bash +tailscale --socket /Users/john/Project/ollama/.tailscale/tailscaled.sock status +tailscale --socket /Users/john/Project/ollama/.tailscale/tailscaled.sock ping 100.101.255.32 +ssh ssh105 'echo tunnel-ok-105' +curl -s http://127.0.0.1:18080/api/status # 应返回 ok +``` + +- 禁止:`HostName 120.26.184.105` 直接作为 105 脚本主机。 +- 禁止:用 `105.tkmind.cn` 做 API 健康检查或同步链路入口。 + 编辑 **Studio** 上的 `~/Project/Memind/scripts/g2-lb.Caddyfile`,改 `lb_policy weighted_round_robin` 后面两个权重(第一个=Studio :8081,第二个=105 :18080): | 配置 | Studio | 105 | diff --git a/docs/h5-metering-gateway.md b/docs/h5-metering-gateway.md new file mode 100644 index 0000000..67da475 --- /dev/null +++ b/docs/h5-metering-gateway.md @@ -0,0 +1,51 @@ +# H5 计量计费网关设计 + +## 背景:当前计费的问题 + +当前计费内联在 `tkmind-proxy.mjs` 里,靠嗅探上游 SSE 的 `Finish` 帧扣费。100 服务实测发现三类问题: + +1. **定价与成本脱钩,约 20× 超收**:默认 flat `2分/1k input` 对 DeepSeek 中继(goose `custom_tkmind_relay_deepseek`)严重偏贵。对账每条会话 H5 实扣 / goose 自报 `accumulated_cost × 7.2` 稳定落在 ~20×。换 provider 又会反向亏——单一硬编码 token 单价无法跟随模型。 +2. **计量点分散**:只覆盖走 `tkmind-proxy` 的 H5 会话;`mindspace-agent-runner` 等其他 LLM 出口可能未计费。 +3. **可靠性弱**:`onFinish` 为 fire-and-forget(`sse-billing.mjs` 的 `.catch(()=>{})`),扣费失败即漏计;`requestId` 全程传 `null`,无幂等键、无流水对账;读 `previous` 在事务外存在并发双扣竞态(`user-auth.mjs:943`)。 + +## 已落地的止血改动(本次) + +`billing.mjs` 扩展 `H5_USE_BACKEND_COST` 成本路径,新增 `H5_MARGIN_MULTIPLIER`: + +``` +最终扣费 = 上游 accumulated_cost(USD) 增量 × H5_USD_CNY_RATE × H5_MARGIN_MULTIPLIER +``` + +- 自动跟随 provider/模型,换模型不需重调 token 单价。 +- 仅当上游回传 `accumulatedCost` 时生效;缺失则**回退**原 flat token 路径,不破坏现有计费。 +- 生产启用:`.env` 设 `H5_USE_BACKEND_COST=1` + `H5_MARGIN_MULTIPLIER=<目标毛利>`。 + +> ⚠️ 上线前需确认:一帧真实 SSE 的 `token_state` 是否带 `accumulated_cost`(goose `sessions.db` 有该列,但要确认 SSE Finish 事件也序列化了它)。确认前 multiplier 改动是安全的(无 cost 即回退)。 + +## 目标架构:独立计量网关 + +把"计量 + 定价 + 扣费 + 对账"从 H5 代理里抽出,成为所有 LLM 出口的统一收口点。 + +``` +H5 session ─┐ +agent-runner ┤→ [计量网关] → upstream(goose/...) → usage+cost +cover-ai 等 ─┘ │ + ├─ 定价引擎(按 provider/模型,cost × margin) + ├─ 幂等扣费(requestId 去重 + 事务内读写钱包) + └─ ledger 流水(每次调用一条,可对账/审计/退款) +``` + +### 关键设计点 + +1. **统一收口**:所有发往 LLM 的调用都经网关,拿 provider 回传的真实 `usage` + `cost`,而非各出口各自实现。 +2. **定价引擎按 provider 配置**:`{ provider, model } → { 计价基准, marginMultiplier }`。DeepSeek 与未来的 Claude 成本差几十倍,必须差异化,不能一个 flat 单价打天下。优先用成本基准(cost × margin),无 cost 的 provider 才退回 token 单价表。 +3. **幂等扣费**:每次调用带 `requestId`;ledger 以 `requestId` 唯一约束,重放/重连不重复扣。当前 SSE 重连靠"accumulated 单调 → delta 0"兜底,脆弱,应换显式幂等键。 +4. **事务内读写**:钱包扣减与计费状态读取在同一事务 + `FOR UPDATE`,消除并发双扣。 +5. **ledger 流水表**:`(request_id, user_id, session_id, provider, model, input_tokens, output_tokens, upstream_cost_usd, charged_cents, margin, created_at)`。支撑对账(H5 扣费 vs 上游成本)、用户账单明细、纠纷退款。 +6. **失败可恢复**:扣费写库失败要可重试/补偿,不能像现在 fire-and-forget 直接吞掉。 + +### 落地阶段 + +- **P0(已做)**:成本模式 + margin,env 切换止血 20× 超收。 +- **P1**:补 `requestId` 幂等 + 修事务竞态 + ledger 流水表(仍在 proxy 内,先把可靠性和对账补齐)。 +- **P2**:抽出独立计量模块/服务,纳入 agent-runner 等其他出口,定价引擎按 provider 配置化。 diff --git a/docs/local-dev.md b/docs/local-dev.md index 7de25f6..16edeff 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -1,5 +1,7 @@ # 本地开发 +> 生产安全提醒:`g2.tkmind.cn` 当前使用本机 `8081`。普通开发预览必须走测试端口,不要直接在生产目录运行默认 `pnpm dev`。完整规程见 [生产 / 测试 / 预览隔离规程](./service-isolation-runbook.md)。 + `pnpm dev` 启动后,用 **127.0.0.1 + 端口** 访问: | 服务 | 地址 | @@ -17,6 +19,22 @@ PLAZA_APP_DIR=/path/to/plaza pnpm dev pnpm open:local-test # 浏览器打开 H5 ``` +在生产机器上做预览时,请使用测试目录和测试端口: + +```bash +cd /Users/john/Project/test/Memind +H5_PORT=18081 \ +VITE_PORT=15173 \ +ADMIN_PORT=18082 \ +PLAZA_PORT=13001 \ +OPS_PORT=13002 \ +H5_PUBLIC_BASE_URL=http://127.0.0.1:15173 \ +VITE_MINDSPACE_BASE=http://127.0.0.1:15173 \ +pnpm dev +``` + +预览地址:`http://127.0.0.1:15173/?preview=mindspace`。 + ## 环境变量 | 变量 | 默认 | 说明 | @@ -30,3 +48,28 @@ pnpm open:local-test # 浏览器打开 H5 | `PLAZA_APP_DIR` | ../tkmind_go/ui/plaza | Plaza 源码路径 | Plaza 公网部署见 [plaza-local.md](./plaza-local.md)。 + +## 公众号 Agent 调试 + +服务号消息转发到专属 Agent 这条链路默认关闭,只有显式设置下面这些变量后才会启用: + +- `H5_WECHAT_MP_ENABLED=1` +- `H5_WECHAT_MP_APP_ID` +- `H5_WECHAT_MP_APP_SECRET` +- `H5_WECHAT_MP_TOKEN` + +服务号后台需要把服务器地址指向: + +```text +{H5_PUBLIC_BASE_URL}/webhooks/wechat-mp/messages +``` + +当前版本只支持“明文模式”回调,不支持 `aes` 安全模式解密。要保证 H5 微信 OAuth 和公众号消息使用同一个服务号 `AppID`,这样消息里的 `openid` 才能直接命中已绑定用户。 + +如果要立即停用这版,不改代码也可以先把: + +```bash +H5_WECHAT_MP_ENABLED=0 +``` + +然后重启 `server.mjs`。这相当于运行时止血;如果确认整版不要,再按代码回撤处理。 diff --git a/docs/memind-beginner-guide.md b/docs/memind-beginner-guide.md new file mode 100644 index 0000000..c0a2097 --- /dev/null +++ b/docs/memind-beginner-guide.md @@ -0,0 +1,215 @@ +# Memind 小白使用手册 + +这份手册给第一次使用 Memind 的人看。先照着主流程走,熟悉以后再看空间分类、Plaza 和记忆功能。 + +## 1. 打开 Memind + +常用入口: + +- 主应用: +- 发现广场: + +打开主应用后,如果已经登录,会看到顶部有你的名字、额度、我的空间、新会话等按钮;页面底部有输入框。 + +第一次使用只记住三个位置: + +- 输入框:告诉 AI 你想做什么。 +- 发送按钮:输入内容后才会亮起。 +- 我的空间:查看、编辑和分享已经生成的页面或文件。 + +## 2. 跟 AI 对话 + +在底部输入框里直接写需求,例如: + +```text +帮我生成一个 hello 页面 +``` + +也可以写得更具体: + +```text +帮我做一个 618 活动页,风格清爽一点,要有标题、活动规则和报名按钮。 +``` + +发送后等待 AI 回复。如果它生成了页面,通常会在回复里给出一个可点击的链接。 + +小提示: + +- 需求越清楚,结果越接近你想要的。 +- 如果不满意,直接继续说“把颜色改成绿色”“加一个价格表”“标题再短一点”。 +- 顶部会显示账户额度;额度很低时,尽量先想清楚再发送。 + +## 3. 实操示例:生成一个精美页面 + +下面是一条完整实操流程,可以照着做一遍。 + +### 第一步:点新会话 + +在聊天页顶部点击“新会话”。 + +页面可能会短暂显示: + +```text +正在连接会话… +``` + +等输入框恢复成“输入任务,例如:列出当前目录文件”后,再开始输入。 + +### 第二步:输入一个清楚的需求 + +不要只写“帮我做个页面”。可以把主题、内容和风格都写出来。 + +这次实测使用的示例: + +```text +请帮我生成一个精美、有趣的一页式网页,主题叫「时间旅行咖啡馆」。 + +页面设定:一家开在午夜街角的咖啡馆,菜单不是咖啡口味,而是不同时间片段:1999、2035、童年夏天、未来清晨。 + +页面要求: +1. 第一屏要惊艳,有大标题、短副标题和一个醒目的行动按钮。 +2. 往下有 4 个「时间菜单」卡片,每张卡片写一个时间片段、风味描述和适合谁点。 +3. 再加一个「今晚营业规则」小版块,语气轻松好玩。 +4. 风格要精美、有层次,适合手机和电脑打开。 +5. 生成完成后请保存成一个可以点开的 MindSpace 页面,并把链接发给我。 +``` + +### 第三步:发送并等待 + +输入后,“发送”按钮会亮起。点击发送后,页面底部会变成“停止”,输入框也会暂时不能输入。 + +这表示 AI 正在生成,不要重复点击,也不要急着刷新页面。 + +实测中,AI 会先显示几段进度,比如: + +- 开始制作页面。 +- 工具完成。 +- 页面已保存。 +- 返回一个可以点击的公开链接。 + +生成精美页面可能需要几十秒到一分钟左右。 + +### 第四步:点开生成链接 + +生成完成后,AI 会在聊天里发出页面链接。实测生成的页面标题是: + +```text +时间旅行咖啡馆 · 午夜街角的时空驿站 +``` + +打开后能看到完整网页:第一屏有星空背景、大标题、当前时间和“翻阅时间菜单”按钮;往下滚动能看到 4 张时间菜单卡片和营业规则。 + +如果点击链接后没有跳转,可以复制链接或在新标签页打开。只要公开地址能打开,就说明页面已经生成成功。 + +### 第五步:回到我的空间确认 + +页面生成后,可以点顶部“我的空间”,看看它是否出现在“最近页面”。 + +注意:实测时,聊天里的公开链接可以正常打开,但“我的空间”的最近页面没有立刻出现这个新页面。遇到这种情况不要慌: + +1. 先回聊天页,从 AI 回复里的链接打开。 +2. 如果需要再次使用,可以从历史会话里找到这次对话。 +3. 如果要正式管理、编辑或发布,优先检查“最近页面”“页面草稿”和“公开区”。 + +## 4. 查看历史对话 + +点击左上角的“展开历史对话”,可以看到以前的会话。 + +使用方法: + +- 点击会话标题,可以继续查看或接着聊。 +- 点击“+ 新对话”,开始一个全新的任务。 +- 不要随手点“删除”,删除后对应历史会话就不好找了。 + +注意:打开历史会话时,页面可能会短暂显示“正在连接会话…”,等几秒即可。 + +## 5. 使用我的空间 + +点击顶部“我的空间”,进入你的内容管理页。 + +这里主要看两块: + +- 空间分类:OA 工作区、私人区、公开区、页面草稿、归档区。 +- 最近页面:最近生成或保存的页面列表。 + +新手优先看“最近页面”。每个页面旁边通常有这些按钮: + +- 预览:打开页面看看效果。 +- 编辑:继续修改页面内容。 +- 公开链接:获取可以分享给别人的链接。 +- 保存长图:把页面导出成长图。 +- 删除:删除这个页面,谨慎点击。 + +如果你只是想找刚刚生成的东西,先看“最近页面”的第一屏。 + +## 6. 分享一个页面 + +当页面做好后,进入“我的空间”,找到对应页面。 + +推荐流程: + +1. 先点“预览”,确认内容没有问题。 +2. 再点“公开链接”,获取分享地址。 +3. 把链接发给别人,对方就可以直接打开网页。 + +公开页面是一个独立网页,例如这类地址: + +```text +https://g2.tkmind.cn/MindSpace/.../public/hello.html +``` + +分享前最好检查: + +- 页面标题是否正确。 +- 是否包含隐私信息。 +- 手机和电脑打开是否都能看清楚。 + +## 7. 逛 Plaza 找灵感 + +Plaza 是公开作品广场,入口是: + + + +你可以在这里: + +- 按分类浏览作品,比如学习笔记、旅行攻略、数据分析、创意作品。 +- 看热门或最新作品。 +- 打开作品详情页,参考结构、标题和展示方式。 +- 从顶部“我的空间”回到自己的内容管理页。 + +Plaza 更像灵感库,不是你的私人文件夹。自己的页面还是回“我的空间”管理。 + +## 8. 常见问题 + +### 发送按钮是灰色的 + +通常是因为输入框还没有内容。先输入需求,发送按钮就会变成可点状态。 + +### 历史会话一直显示正在连接 + +先等几秒。如果仍然不动,可以点“新会话”重新开始,或者刷新页面后再打开历史。 + +### 找不到刚生成的页面 + +按这个顺序找: + +1. 回到聊天,看 AI 回复里有没有页面链接。 +2. 点“我的空间”。 +3. 看“最近页面”的前几项。 +4. 如果是草稿,看“页面草稿”。 +5. 如果已经公开,看“公开区”。 +6. 如果空间里暂时找不到,先从历史会话里的链接打开。 + +### 不想让别人看到页面 + +不要分享公开链接。发布前检查页面内容,敏感资料建议放在“私人区”,并在公开前做脱敏。 + +### 页面做得不满意 + +回到对应聊天或点页面的“编辑”,直接提出修改要求。比如: + +```text +把首页标题改短一点,按钮放到第一屏,整体更像一个正式活动页。 +``` + + diff --git a/docs/release-deploy.md b/docs/release-deploy.md new file mode 100644 index 0000000..bd7dccc --- /dev/null +++ b/docs/release-deploy.md @@ -0,0 +1,297 @@ +# Memind 生产更新发布指南 + +> 本文描述如何把本地开发代码安全发布到 **g2.tkmind.cn** 生产环境。 +> 发布前请先阅读 [生产 / 测试 / 预览隔离规程](./service-isolation-runbook.md),避免误占生产端口或覆盖用户数据。 + +## 1. 架构与发布目标 + +用户访问 `https://g2.tkmind.cn/` 的流量路径: + +```text +Cloudflare(橙云) + → Cloudflare Tunnel + → Studio Caddy(:8090,g2 负载均衡) + ├─ 权重 19 → Studio Portal(127.0.0.1:8081) ← 主流量 + └─ 权重 1 → 105 Portal(经隧道 127.0.0.1:18080 → :8080) ← 灰度副机 +``` + +两台 Portal 都是 **无状态前端**,共用 Studio 上的 goosed 与 MindSpace 数据目录。 + +| 角色 | 机器 | 代码目录 | 服务 | 重启方式 | +|------|------|----------|------|----------| +| 生产主 | Studio Mac(`100.99.38.66`) | `/Users/john/Project/Memind` | Portal `:8081`、Plaza `:3001` | `launchctl kickstart` | +| 灰度副 | 105(经 Tailscale `ssh105`) | `/root/tkmind_go/ui/h5` | `goose-h5` `:8080` | `systemctl restart goose-h5` | +| 负载均衡 | Studio | `scripts/g2-lb.Caddyfile` | Caddy `:8090` | 改权重后 `caddy reload` | + +更详细的流量与灰度比例说明见 [g2 负载均衡](./g2-load-balancing.md)。 + +## 2. 目录与环境对照 + +| 环境 | 典型目录 | 端口 | 用途 | +|------|----------|------|------| +| **生产(Studio)** | `/Users/john/Project/Memind` | `8081` / `3001` | 线上 g2 / plaza | +| **开发预览** | `/Users/john/Project/test/Memind` 或本机副本 | `18081` / `13001` | 本地联调,禁止占 `8081` | +| **105 副机** | `/root/tkmind_go/ui/h5` | `8080` | g2 灰度流量 | + +**重要:** `rsync_to_server.sh` 会把**你执行命令时所在的本地目录**同步到 Studio 生产目录。发布前请确认当前目录里的代码就是你要上线的版本,而不是半成品或未验证的分支。 + +## 3. 发布入口(主流程) + +**推荐唯一入口:** 项目根目录的 `rsync_to_server.sh` + +```bash +cd /path/to/your/memind-repo # 开发完成、已自测的目录 + +# ① 预览(不修改任何远端) +./rsync_to_server.sh --dry-run + +# ② 正式发布(Studio + 105 全量) +./rsync_to_server.sh +``` + +脚本会自动完成: + +1. 本地 Pre-flight(关键文件、安全 patch、exclude 规则) +2. Studio Pre-flight(SSH、`.env`、MindSpace、data 完整性) +3. 交互确认(可用 `--yes` 跳过) +4. rsync 代码 → Studio 生产目录 +5. rsync 后校验(`.env` 未变、MindSpace 未减少、patch 仍在) +6. Studio 上 `npm install` + `npm run build` +7. 重启 Studio Portal → Plaza,并做健康检查 +8. 经 Studio 触发 `scripts/sync-to-105.sh`,同步并重启 105 + +## 4. 发布前检查清单 + +在 `./rsync_to_server.sh` 之前,逐项确认: + +### 4.1 本地构建与测试 + +```bash +pnpm install # 依赖有变更时 +pnpm run build # 前端改动必须能编过 +pnpm test # 建议跑;涉及核心逻辑时必跑 +``` + +| 改动类型 | 是否必须 build | 能否 `--skip-build` | +|----------|----------------|---------------------| +| 前端(`.tsx` / `.css` / `src/`) | **是** | 否 | +| 仅后端(`.mjs`) | 否(但 build 无害) | 可以 | +| 仅文档 / 脚本 | 否 | 可以 | + +### 4.2 生产在线(只读) + +```bash +curl -s http://127.0.0.1:8081/api/status # Studio Portal,期望 ok +curl -s http://127.0.0.1:18080/api/status # 105 隧道,期望 ok +``` + +105 联通必须走 Tailscale,不要用公网 IP: + +```bash +ssh ssh105 'echo tunnel-ok-105' +``` + +### 4.3 安全约束(脚本会自动检查) + +- `server.mjs` 必须含 `WORKSPACE_MAINTENANCE_ENABLED` patch(否则 105 重启会在 rclone 挂载上卡死) +- `.env`、`MindSpace/`、`data/` 在 exclude 列表中,**不会被 rsync 覆盖** +- rsync 使用 `--delete`:本地已删的文件会从 Studio 删掉(exclude 外的路径) + +### 4.4 发布范围确认 + +- `rsync_to_server.sh` 同步的是**整个仓库**(除 exclude 外),不是单个文件 +- 工作区若有未完成的其它改动,会一并上线——发布前建议 commit 或整理干净 +- 涉及数据库迁移、批量清数据、支付回调测试等,**不要**直接在生产目录试跑,见 [隔离规程](./service-isolation-runbook.md) + +## 5. 分步与保守发布 + +`rsync_to_server.sh` 支持的常用参数: + +| 参数 | 作用 | 适用场景 | +|------|------|----------| +| `--dry-run` | 只预览 diff,不改远端 | **每次正式发布前必做** | +| `--only-100` | 只更新 Studio,不推 105 | 先让 95% 主流量生效,105 稍后 | +| `--only-105` | 只触发 Studio→105 同步 | Studio 已是最新,只补 105 | +| `--skip-build` | 跳过远端 `npm run build` | 纯后端改动且确认 dist 无需更新 | +| `--no-restart` | 只同步代码,不重启服务 | 分批发布;需自行重启 | +| `--yes` / `-y` | 跳过交互确认 | 自动化或你已看过 dry-run | + +示例: + +```bash +# 只发 Studio(主流量 95%) +./rsync_to_server.sh --only-100 + +# 纯 server.mjs 改动,跳过 build +./rsync_to_server.sh --skip-build + +# 先同步代码,稍后再重启 +./rsync_to_server.sh --no-restart +# 之后在 Studio 上手动 kickstart,或再跑一遍带重启的同步 +``` + +## 6. 105 单独同步 + +若 Studio 代码已是最新,只需更新 105: + +```bash +# 在 Studio 生产目录执行 +cd /Users/john/Project/Memind +bash scripts/sync-to-105.sh + +# 或从本地只触发 105 链路 +./rsync_to_server.sh --only-105 +``` + +`sync-to-105.sh` 会: + +1. rsync 代码(`--delete`)与 `dist/` 到 `root@ssh105:/root/tkmind_go/ui/h5` +2. 远端 `npm install` + `npm run build`(可用 `SKIP_BUILD=1` 跳过) +3. `systemctl restart goose-h5` +4. 校验关键文件 md5 与 `:8080/api/status` + +环境变量(可选): + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `H5_DEPLOY_HOST` | `root@ssh105` | 105 SSH 目标 | +| `H5_REMOTE_DIR` | `/root/tkmind_go/ui/h5` | 105 代码路径 | +| `H5_SYSTEMD_SERVICE` | `goose-h5` | systemd 服务名 | +| `SKIP_BUILD` | `0` | `1` 跳过远端 build | +| `NO_RESTART` | `0` | `1` 不重启服务 | + +package.json 中的 `pnpm deploy:105` 等价于本地执行 `bash scripts/sync-to-105.sh`(需本机能 SSH 到 105,或已在 Studio 上)。 + +## 7. 发布过程与影响窗口 + +全量 `./rsync_to_server.sh` 典型耗时 **5~10 分钟**。 + +| 阶段 | 用户可见影响 | +|------|----------------| +| rsync + npm install + build | 无(旧进程仍在跑) | +| Studio Portal 重启 | **8081 短暂不可用**,约 10~40 秒;g2 主流量(约 95%)可能短暂 502 | +| 105 goose-h5 重启 | **灰度流量(约 5%)** 短暂不可用 | +| Plaza 重启 | plaza.tkmind.cn 可能短暂不可用;与 g2 主聊天无关 | + +Caddy 会对不健康上游做 active health check,105 重启期间可能暂时从池中摘除。 + +## 8. 发布后验证 + +### 8.1 健康检查 + +```bash +curl -s http://127.0.0.1:8081/api/status +curl -s http://127.0.0.1:18080/api/status +curl -s https://g2.tkmind.cn/api/status +``` + +### 8.2 确认命中哪台上游 + +g2 响应头 `X-Memind-Upstream`: + +- `127.0.0.1:8081` → Studio +- `127.0.0.1:18080` → 105 + +```bash +curl -s -D - -o /dev/null https://g2.tkmind.cn/api/status | grep -i x-memind-upstream +``` + +### 8.3 业务冒烟 + +按本次改动选手动验证,例如: + +- 打开 g2 聊天页,测试新功能 +- 登录 / 微信 OAuth(若动到 auth) +- MindSpace 页面读写(若动到 pages) +- Plaza 列表(若动到 plaza) + +### 8.4 日志 + +```bash +# Studio Portal +tail -f ~/Library/Logs/memind-portal.log + +# Studio Plaza +tail -f ~/Library/Logs/plaza-prod.log + +# 105(经 ssh105) +ssh ssh105 'journalctl -u goose-h5 -n 50 --no-pager' +``` + +## 9. 回滚思路 + +项目没有一键回滚脚本,常见做法: + +1. **代码回滚:** 在本地 git 回到上一个 good commit,`./rsync_to_server.sh` 再发一版 +2. **仅 Studio:** 若 105 有问题,可临时把 g2 权重调到 100% Studio(见 [g2-load-balancing.md](./g2-load-balancing.md)) +3. **紧急恢复 Portal:** 若 8081 挂了,见 [隔离规程 · 事故恢复](./service-isolation-runbook.md#事故恢复最小步骤) + +发布前建议打 tag 或记录当前 commit,便于回滚: + +```bash +git rev-parse HEAD +git tag -a release-2026-06-19 -m "before voice UI deploy" +``` + +## 10. 其它发布路径(非 g2 主站) + +### 10.1 同步到局域网测试机 + +仅源码同步到 `192.168.1.9` 上的 test 目录,**不是生产**: + +```bash +bash scripts/deploy-to-test-host.sh --dry-run +bash scripts/deploy-to-test-host.sh +``` + +目标:`test-memind` / `test-memindadm` / `test-memindplaza`。同步后需按该机习惯手动重启服务。 + +### 10.2 Plaza 105 静态站 + +```bash +pnpm deploy:plaza-105 +``` + +依赖 goose-h5 已部署(`pnpm deploy:105` 或全量 rsync)。详见 [Plaza 本机部署](./plaza-local.md)。 + +### 10.3 已废弃 / 不可用 + +| 命令 | 状态 | +|------|------| +| `pnpm deploy:prod` | 指向 `../../deploy/deploy-h5-prod.sh`,当前仓库旁路不存在,**勿用** | + +## 11. 推荐发布 SOP(标准作业) + +适合大多数功能迭代的固定步骤: + +```text +1. 在测试端口完成开发与自测(18081,勿占 8081) +2. pnpm run build && pnpm test +3. ./rsync_to_server.sh --dry-run ← 看清将要同步什么 +4. 确认无多余改动、无数据库破坏性操作 +5. ./rsync_to_server.sh ← 全量 Studio + 105 +6. curl 健康检查 + 浏览器冒烟 +7. 观察 5~10 分钟日志,确认无 ERROR 尖峰 +``` + +**保守版(先主后副):** + +```text +1~4 同上 +5. ./rsync_to_server.sh --only-100 +6. 冒烟通过后 +7. ./rsync_to_server.sh --only-105 +``` + +## 12. 相关文档 + +| 文档 | 内容 | +|------|------| +| [service-isolation-runbook.md](./service-isolation-runbook.md) | 生产 / 测试端口隔离、禁止事项、事故恢复 | +| [g2-load-balancing.md](./g2-load-balancing.md) | g2 权重、105 隧道、灰度比例调整 | +| [local-dev.md](./local-dev.md) | 本地开发端口与 preview | +| [plaza-local.md](./plaza-local.md) | Plaza 部署与 Tunnel | + +--- + +**维护说明:** 若部署脚本路径、主机名或 systemd 服务名变更,请同步更新本文与 `rsync_to_server.sh` 头部注释。 diff --git a/docs/schedule-reminder-design.md b/docs/schedule-reminder-design.md new file mode 100644 index 0000000..964110a --- /dev/null +++ b/docs/schedule-reminder-design.md @@ -0,0 +1,683 @@ +# 待办 / 日程 / 提醒能力设计文档 + +> **状态:** 设计稿 +> **目标版本:** v0.2.x +> **适用范围:** H5 门户、微信服务号 Agent、MindSpace 行程展示页 +> **生产提醒:** 当前仓库目录可能承载生产服务。开发和验证按 `docs/service-isolation-runbook.md` 先在测试目录完成,不在生产目录直接跑迁移或重启服务。 + +## 背景 + +用户希望用自然语言完成两类高频动作: + +1. 记录事项:例如“我明天要去开会,帮我记录增加一个提醒”。 +2. 查看计划:例如“看看我的行程计划”。 + +现状里已有几个可利用基础: + +- `capabilities.mjs` 已预留 `todo` 能力,但默认关闭,且当前项目没有本地一等的待办/日程表。 +- `wechat-mp.mjs` 已能接收微信服务号文本、转给用户专属 Agent、再通过客服消息回复。 +- MindSpace 已有生成公开/私有 HTML 页面的能力,适合把一周行程做成简洁精美页面。 + +缺口在于:日程和提醒还没有结构化存储、没有到点派发 worker、没有 Agent 可调用的本地日程工具,也没有固定的澄清规则。 + +## 产品目标 + +### 必须支持 + +- 用户用自然语言创建待办、日程、提醒。 +- 缺少必要时间信息时,助手必须追问,不猜测具体钟点。 +- 用户明确“不提醒”时,只记录到待办或日程列表,不创建提醒推送。 +- 对会议类表达,如果已有明确开始时间但没有指定提前多久提醒,默认提前 1 小时提醒。 +- 用户查看行程时,按指定时间范围展示;未指定范围时展示最近 7 天。 +- 行程查询结果优先以简洁消息回复;当内容较多或用户要求“页面/好看一点”时,生成 MindSpace HTML 页面。 + +### 暂不纳入 MVP + +- 复杂重复规则,如“每月第二个周三”。 +- 多人共享日程、会议邀请、外部日历同步。 +- 地理围栏提醒。 +- 跨端原生 push。MVP 先走已绑定微信服务号通知通道,后续再扩展短信、邮件或 App push。 + +## 概念模型 + +| 概念 | 说明 | 例子 | +|------|------|------| +| 待办 task | 有或没有截止时间的任务,不一定占用时间段 | “买票”、“周五前交材料” | +| 日程 event | 有开始时间,通常可以有结束时间,占用时间段 | “今天下午三点开会” | +| 提醒 reminder | 某个时间点触发的一次通知,可挂在 task/event 上 | “会议前 1 小时提醒” | + +设计原则: + +- 待办和日程是“记录”;提醒是“通知计划”。 +- 一条待办/日程可以没有提醒。 +- 一条待办/日程可以有多条提醒,但 MVP 默认最多一条。 +- 所有时间入库使用 epoch 毫秒,额外保存用户时区,展示时按用户时区格式化。 + +## 自然语言交互规则 + +### 创建提醒或日程 + +#### 规则 1:缺少事件发生时间时必须追问 + +用户说: + +```text +我明天要去开会,帮我记录增加一个提醒 +``` + +如果系统当前日期是 2026-06-18,助手能解析“明天”为 2026-06-19,但缺少具体钟点,不能创建到点提醒。回复: + +```text +可以。我先记下“明天开会”。你想几点提醒?会议大概几点开始,提前多久提醒你? +``` + +待用户补充后再创建。 + +#### 规则 2:用户明确不提醒时只记录 + +用户说: + +```text +不用提醒,先记一下 +``` + +系统创建一条待办或全天日程,不创建 `h5_schedule_reminders` 记录。回复: + +```text +已记录到待办列表:明天开会,未设置提醒。 +``` + +#### 规则 3:会议类有明确开始时间时默认提前 1 小时 + +用户说: + +```text +今天下午三点有个会 +``` + +如果系统当前日期是 2026-06-18,解析为: + +- 日程:2026-06-18 15:00,标题“开会”或“会议” +- 提醒:2026-06-18 14:00 +- 提醒偏移:60 分钟 + +回复: + +```text +已记录:今天 15:00 会议。我会提前 1 小时,也就是 14:00 提醒你。 +``` + +#### 规则 4:非会议类默认不擅自加提前提醒 + +用户说: + +```text +明天上午十点去取护照 +``` + +创建日程,但如果用户没有说“提醒我”,不自动创建提醒。回复可提示: + +```text +已记录:明天 10:00 取护照。需要我提前提醒的话,可以告诉我提前多久。 +``` + +#### 规则 5:用户直接指定提醒时间时按提醒时间创建 + +用户说: + +```text +明天上午九点提醒我带材料 +``` + +创建待办“带材料”,并创建 `remind_at = 明天 09:00` 的提醒。此时不需要追问“提前多久”。 + +#### 规则 6:每天固定时间推送当天待办记录 + +用户说: + +```text +每天早上 7 点给我发一天的待办记录 +``` + +系统必须创建一条每日待办摘要订阅: + +- 类型:`todo_day` +- 时间:每天 07:00 +- 通道:微信服务号 +- 行为:每天到点查询用户当天待办记录,通过服务号主动发送给用户 + +回复: + +```text +已设置:我会每天早上 7点 通过服务号把当天待办记录发给你。 +``` + +如果用户只说“每天给我发待办记录”,缺少时间,必须追问具体时间。 + +### 查看行程 + +#### 规则 7:没有时间范围时默认最近 7 天 + +用户说: + +```text +看看我的行程计划 +``` + +查询范围: + +- 起点:用户时区当天 00:00 +- 终点:起点 + 7 天 + +回复格式优先按日期分组: + +```text +未来 7 天你有 3 个安排: + +6 月 18 日 周四 +14:00 会议提醒 +15:00 会议 + +6 月 19 日 周五 +全天 开会 +``` + +#### 规则 8:指定时间范围时按范围查询 + +用户说“看下明天的安排”、“下周有什么会”、“6 月 20 到 25 日的计划”,按指定范围查询。 + +#### 规则 9:需要精美展示时生成页面 + +触发条件: + +- 用户明确说“用页面展示”、“好看一点”、“生成一个行程页”。 +- 查询结果超过 8 条,普通文本不易读。 +- 用户来自 H5 页面上下文,适合打开 MindSpace 页面。 + +页面要求: + +- 第一屏直接是行程表,不做营销式 landing page。 +- 按日期分组,突出今天、明天、逾期、即将到来。 +- 对日程、待办、提醒用不同视觉标识。 +- 移动端优先,宽屏下使用双栏或周视图。 + +## 系统架构 + +```mermaid +flowchart TD + U["用户文本: 微信/H5"] --> I["意图解析层"] + I -->|缺必要信息| Q["追问用户"] + I -->|可执行| T["Schedule Service"] + T --> DB["MySQL: schedule tables"] + T --> R["Reminder Worker"] + R --> W["微信通知通道"] + T --> V["行程查询 API"] + V --> A["Agent 文本回复"] + V --> P["MindSpace 行程页面"] +``` + +### 组件职责 + +| 组件 | 职责 | +|------|------| +| 意图解析层 | 从自然语言提取 action、title、date/time、reminder offset、query range;判断是否需要追问 | +| Schedule Service | 统一创建、查询、修改、取消待办/日程/提醒 | +| Reminder Worker | 周期扫描到期提醒,锁定、投递、记录成功/失败 | +| 微信通知通道 | 发送提醒消息。MVP 优先复用已绑定服务号 openid,生产上线前确认模板/订阅通知资质 | +| MindSpace 展示 | 将查询结果渲染为 HTML 页面,保存到用户 `public/` 或私有页面记录 | + +## 数据库设计 + +### `h5_schedule_items` + +记录待办和日程主体。 + +```sql +CREATE TABLE IF NOT EXISTS h5_schedule_items ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + kind ENUM('task', 'event') NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT NULL, + status ENUM('active', 'completed', 'cancelled', 'deleted') NOT NULL DEFAULT 'active', + start_at BIGINT NULL, + end_at BIGINT NULL, + due_at BIGINT NULL, + all_day TINYINT(1) NOT NULL DEFAULT 0, + timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai', + location VARCHAR(255) NULL, + source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent', + source_session_id VARCHAR(128) NULL, + source_message_id VARCHAR(128) NULL, + source_text TEXT NULL, + metadata_json JSON NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + deleted_at BIGINT NULL, + KEY idx_schedule_user_time (user_id, status, start_at, due_at), + KEY idx_schedule_user_updated (user_id, updated_at), + CONSTRAINT fk_schedule_item_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +字段说明: + +- `kind = task`:待办,通常使用 `due_at`,也可没有时间。 +- `kind = event`:日程,通常使用 `start_at/end_at`。 +- `all_day = 1`:只有日期没有具体钟点,如“明天开会”。 +- `source_text`:保留用户原文,便于回溯和修正。 + +### `h5_schedule_reminders` + +记录提醒计划和投递状态。 + +```sql +CREATE TABLE IF NOT EXISTS h5_schedule_reminders ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + item_id CHAR(36) NOT NULL, + remind_at BIGINT NOT NULL, + offset_minutes INT NULL, + channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat', + status ENUM('pending', 'locked', 'sent', 'failed', 'cancelled') NOT NULL DEFAULT 'pending', + attempts INT NOT NULL DEFAULT 0, + last_error VARCHAR(500) NULL, + locked_until BIGINT NULL, + sent_at BIGINT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_schedule_item_remind_at (item_id, remind_at, channel), + KEY idx_reminder_due (status, remind_at), + KEY idx_reminder_user (user_id, status, remind_at), + CONSTRAINT fk_schedule_reminder_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_schedule_reminder_item FOREIGN KEY (item_id) REFERENCES h5_schedule_items(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +### `h5_schedule_delivery_logs` + +记录每次投递尝试,用于排错和审计。 + +```sql +CREATE TABLE IF NOT EXISTS h5_schedule_delivery_logs ( + id CHAR(36) PRIMARY KEY, + reminder_id CHAR(36) NOT NULL, + user_id CHAR(36) NOT NULL, + channel ENUM('wechat', 'in_app') NOT NULL, + status ENUM('success', 'failed') NOT NULL, + provider_message_id VARCHAR(128) NULL, + error_code VARCHAR(64) NULL, + error_message VARCHAR(500) NULL, + created_at BIGINT NOT NULL, + KEY idx_delivery_reminder (reminder_id, created_at), + KEY idx_delivery_user (user_id, created_at), + CONSTRAINT fk_schedule_delivery_reminder FOREIGN KEY (reminder_id) REFERENCES h5_schedule_reminders(id) ON DELETE CASCADE, + CONSTRAINT fk_schedule_delivery_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +## 后端模块设计 + +### 新增文件建议 + +| 文件 | 说明 | +|------|------| +| `schedule-service.mjs` | 领域服务:创建、查询、更新、取消、完成 | +| `schedule-intent.mjs` | 轻量规则解析和澄清决策,先覆盖中文常用表达 | +| `schedule-reminder-worker.mjs` | 到点提醒扫描、锁定、投递、重试 | +| `schedule-render.mjs` | 文本摘要和页面数据结构渲染 | +| `schedule-service.test.mjs` | 服务层单测 | +| `schedule-intent.test.mjs` | 意图解析和追问规则单测 | +| `schedule-reminder-worker.test.mjs` | worker 锁和重试单测 | + +### `schedule-service.mjs` + +建议导出: + +```js +export function createScheduleService(pool, deps = {}) { + return { + createItem, + updateItem, + completeItem, + cancelItem, + listItems, + createReminder, + cancelReminder, + listDueReminders, + lockReminder, + markReminderSent, + markReminderFailed, + }; +} +``` + +关键约束: + +- 所有写入校验 `user_id` 所属。 +- 删除使用软删除,避免误删历史提醒。 +- 创建提醒时必须确认 `remind_at` 是具体时间点,不能是全天日期。 +- `remind_at <= now` 的提醒允许创建,但 worker 应尽快投递,并在回复里提示“时间已到,会立即提醒”。 + +### `schedule-intent.mjs` + +MVP 不必追求全量 NLP。先做规则 + LLM 辅助的混合策略: + +1. 规则层识别高置信意图:提醒、开会、行程查询、不提醒、完成/取消。 +2. 规则层解析常用中文时间:今天、明天、后天、上午/下午/晚上、几点、半点、下周。 +3. 不确定时让 Agent 追问,而不是静默创建。 +4. 后续可引入 LLM JSON 解析,但输出必须过 schema 校验。 + +解析结果结构: + +```ts +type ScheduleIntent = + | { + action: 'create'; + kind: 'task' | 'event'; + title: string; + startAt?: number; + endAt?: number; + dueAt?: number; + allDay?: boolean; + reminder?: { remindAt?: number; offsetMinutes?: number; explicitNoReminder?: boolean }; + needsClarification?: Array<'event_time' | 'reminder_time' | 'reminder_offset'>; + } + | { + action: 'query'; + rangeStart?: number; + rangeEnd?: number; + preferPage?: boolean; + } + | { + action: 'update' | 'cancel' | 'complete'; + targetText: string; + }; +``` + +### Agent 接入策略 + +推荐分两阶段做。 + +#### 阶段 A:微信服务号入口先做确定性拦截 + +在 `wechat-mp.mjs` 转发给 Agent 前增加可选的 schedule preflight: + +1. 对用户文本调用 `schedule-intent.mjs`。 +2. 如果能确定执行,直接调用 `schedule-service.mjs` 并通过微信回复结果。 +3. 如果缺必要信息,直接追问。 +4. 如果不是日程意图,继续走现有 Agent 回复链路。 + +优点: + +- 不依赖 Agent 是否会正确调用工具。 +- 对“提醒”这种强业务流程更稳定。 +- 不需要先改 goosed extension。 + +#### 阶段 B:给 H5 Agent 增加本地 schedule 工具 + +后续把日程能力暴露为平台工具,而不是让 Agent 调 H5 内部 API。建议工具: + +- `schedule_create_item` +- `schedule_list_items` +- `schedule_update_item` +- `schedule_cancel_item` +- `schedule_create_reminder` +- `schedule_render_plan_page` + +注意:当前 `api_lockdown` 会阻止 Agent 经代理访问 H5 本地接口,所以不要设计成“Agent 直接请求 `/mindspace/...` 或 `/api/schedule/...`”。更稳的是在服务端扩展平台工具,或在代理层专门白名单化受控的 schedule 工具调用。 + +## API 设计 + +H5 前端和管理调试可用 REST API;Agent 工具不直接走这些 API。 + +### 创建事项 + +`POST /api/schedule/items` + +```json +{ + "kind": "event", + "title": "会议", + "start_at": 1781766000000, + "end_at": null, + "all_day": false, + "timezone": "Asia/Shanghai", + "source_text": "今天下午三点有个会", + "reminder": { + "remind_at": 1781762400000, + "offset_minutes": 60, + "channel": "wechat" + } +} +``` + +### 查询事项 + +`GET /api/schedule/items?from=1781712000000&to=1782316800000&include_reminders=1` + +返回按时间升序的 items,每条带提醒摘要。 + +### 更新、完成、取消 + +- `PATCH /api/schedule/items/:id` +- `POST /api/schedule/items/:id/complete` +- `POST /api/schedule/items/:id/cancel` +- `POST /api/schedule/reminders/:id/cancel` + +所有接口需要登录态和 user ownership 校验。 + +## 提醒 worker 设计 + +### 扫描策略 + +每 30 秒扫描一次: + +```sql +SELECT * +FROM h5_schedule_reminders +WHERE status = 'pending' + AND remind_at <= ? +ORDER BY remind_at ASC +LIMIT 50 +``` + +锁定时使用事务和状态更新: + +```sql +UPDATE h5_schedule_reminders +SET status = 'locked', + locked_until = ?, + attempts = attempts + 1, + updated_at = ? +WHERE id = ? + AND status = 'pending'; +``` + +投递成功后标记 `sent`;失败则: + +- 可重试错误:状态改回 `pending`,`remind_at = now + backoff`。 +- 不可重试错误:状态改为 `failed`。 +- 超过最大次数:状态改为 `failed`。 + +### 幂等和并发 + +- `locked_until` 防止多进程重复投递。 +- `delivery_logs` 记录每次尝试。 +- `uq_schedule_item_remind_at` 防止同一事项重复创建同一时间提醒。 + +### 通知通道 + +MVP: + +- 已绑定微信服务号用户:通过微信通知。 +- 未绑定或通知失败:保留站内提醒状态,用户下次打开 H5 时展示。 + +生产上线前必须确认: + +- 服务号客服消息是否适用于该次主动提醒。 +- 是否需要模板消息或订阅通知模板。 +- 对失败码做明确分流,例如未关注、超出发送窗口、模板不可用。 + +## 行程页面设计 + +### 页面数据结构 + +```json +{ + "title": "未来 7 天行程", + "range_label": "6 月 18 日 - 6 月 24 日", + "days": [ + { + "date": "2026-06-18", + "weekday": "周四", + "items": [ + { + "time": "15:00", + "title": "会议", + "kind": "event", + "reminder": "14:00 提醒", + "status": "active" + } + ] + } + ] +} +``` + +### 视觉要求 + +- 页面第一屏就是行程,不做大段介绍。 +- 移动端使用纵向时间线;桌面端可使用 7 日网格。 +- 颜色不使用单一紫蓝渐变;建议用清爽白底、墨色文本、低饱和蓝/绿/橙作为状态色。 +- 所有文字在 375px 宽度下不溢出。 +- 对“已过期、今天、明天、已提醒、无提醒”有明确状态。 + +## 权限和隐私 + +- 行程属于用户私密数据,默认不发布到 Plaza。 +- 生成 MindSpace 页面时默认保存为私有草稿或用户私有空间;只有用户明确要求分享时才创建公开页。 +- API 只返回当前登录用户的数据。 +- 日程原文 `source_text` 可能含隐私,后台日志不要直接打印全文。 + +## 配置项 + +建议新增环境变量: + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `H5_SCHEDULE_ENABLED` | `0` | 是否启用日程 API 和微信 preflight | +| `H5_REMINDER_WORKER_ENABLED` | `0` | 是否启动提醒 worker | +| `H5_REMINDER_SCAN_INTERVAL_MS` | `30000` | worker 扫描间隔 | +| `H5_REMINDER_DEFAULT_MEETING_OFFSET_MINUTES` | `60` | 会议默认提前提醒分钟数 | +| `H5_REMINDER_MAX_ATTEMPTS` | `5` | 最大投递次数 | +| `H5_DEFAULT_TIMEZONE` | `Asia/Shanghai` | 默认用户时区 | + +部署“每天早上 7 点服务号推送待办记录”时,至少需要: + +```bash +H5_WECHAT_MP_ENABLED=1 +H5_SCHEDULE_ENABLED=1 +H5_REMINDER_WORKER_ENABLED=1 +H5_DEFAULT_TIMEZONE=Asia/Shanghai +``` + +## 开发步骤 + +### P0:设计和测试骨架 + +- 新增本文档。 +- 新增 `schedule-intent.test.mjs`,把关键中文场景先写成测试。 +- 新增空服务骨架,确保不影响现有启动。 + +### P1:本地存储和 API + +- 在 `schema.sql` 加三张表。 +- 在 `db.mjs` 的 `migrateSchema` 加 `CREATE TABLE IF NOT EXISTS`。 +- 实现 `schedule-service.mjs`。 +- 实现 REST API,并补 `src/api/client.ts` 类型。 + +### P2:微信入口 preflight + +- 在 `wechat-mp.mjs` 中,当 `H5_SCHEDULE_ENABLED=1` 时启用日程意图解析。 +- 对可执行请求直接创建并回复。 +- 对缺信息请求直接追问。 +- 非日程消息保持现有 Agent 路径。 + +### P3:提醒 worker + +- 实现 `schedule-reminder-worker.mjs`。 +- 在 `server.mjs` 启动时按 env 开关启动。 +- 先接入微信发送方法,失败后写 delivery log。 + +### P4:行程展示 + +- 文本摘要:直接在微信/H5 聊天里展示。 +- 页面摘要:生成 MindSpace HTML 草稿或公开页,用户确认后再公开。 + +## 测试计划 + +### 单元测试 + +必须覆盖: + +- “我明天要去开会,帮我记录增加一个提醒”解析为缺 `event_time` 和 `reminder_offset`。 +- “不用提醒,先记一下”不会创建 reminder。 +- “今天下午三点有个会”创建 event,并默认 offset 60。 +- “明天上午九点提醒我带材料”创建 task reminder,remind_at 为明天 09:00。 +- “看看我的行程计划”默认查询最近 7 天。 +- “看下明天的安排”查询明天 00:00 到后天 00:00。 + +### 服务层测试 + +- 创建 event + reminder 成功。 +- 同一 item 同一 remind_at 重复创建被幂等处理。 +- cancel item 后 reminder 自动不可投递。 +- listItems 不返回其他用户数据。 + +### Worker 测试 + +- 到期 reminder 被锁定并发送。 +- 并发 worker 不重复发送。 +- 发送失败按 backoff 重试。 +- 超过最大次数标记 failed。 + +### 回归测试 + +```bash +pnpm test +pnpm run build +``` + +涉及微信入口时补 `wechat-mp.test.mjs`: + +- 日程意图命中时不进入 Agent。 +- 非日程文本仍走 Agent。 +- 重复微信 `msgId` 仍只处理一次。 + +## 验收用例 + +| 输入 | 期望 | +|------|------| +| 我明天要去开会,帮我记录增加一个提醒 | 追问具体会议时间和提前多久提醒 | +| 不提醒,先记一下 | 创建待办/全天日程,无 reminder | +| 今天下午三点有个会 | 创建 15:00 会议,14:00 提醒 | +| 明天上午九点提醒我带材料 | 创建 09:00 提醒 | +| 每天早上 7 点给我发一天的待办记录 | 创建每日 07:00 服务号待办摘要订阅 | +| 看看我的行程计划 | 展示最近 7 天 | +| 看看明天行程 | 只展示明天 | +| 生成一个好看的本周行程页面 | 生成 MindSpace 行程页面 | + +## 风险和决策 + +| 风险 | 处理 | +|------|------| +| 微信主动通知规则限制 | 上线前确认模板/订阅通知资质;客服消息仅作可用时通道 | +| 自然语言时间歧义 | 缺关键时间必须追问 | +| Agent 幻觉创建 | MVP 在微信入口做确定性 preflight,后续再开放工具 | +| 生产库迁移风险 | 测试库先迁移,生产按维护窗口执行 | +| 用户隐私 | 默认私有,不自动公开行程页面 | + +## 推荐结论 + +这项能力可做,建议按 P1 到 P3 先交付一个可靠 MVP:能记录、能追问、能查询、能到点提醒。MindSpace 精美行程页作为 P4 增强,不阻塞核心提醒能力上线。 diff --git a/docs/service-isolation-runbook.md b/docs/service-isolation-runbook.md new file mode 100644 index 0000000..a24d8e3 --- /dev/null +++ b/docs/service-isolation-runbook.md @@ -0,0 +1,222 @@ +> **生产环境警示:当前目录 `/Users/john/Project/Memind` 为生产目录与生产环境,禁止重启服务,所有操作必须谨慎并优先避免影响在线流量。** + +# 生产 / 测试 / 预览隔离规程 + +这份文档的目标只有一个:开发预览不能再影响生产。 + +## 端口边界 + +| 环境 | 目录 | 用途 | 端口 | +|------|------|------|------| +| 生产 | `/Users/john/Project/Memind` | `g2.tkmind.cn` 当前在线服务 | `8081` | +| 生产 Plaza | `/Users/john/Project/Memind` + Plaza | `plaza.tkmind.cn` 当前在线服务 | `3001` | +| 生产 Caddy | `/Users/john/Project/Memind/scripts/g2-lb.Caddyfile` | Cloudflare Tunnel 回源入口 | `8090` | +| 测试 Portal | `/Users/john/Project/test/Memind` | 开发预览 API / Portal | `18081` | +| 测试 Vite | `/Users/john/Project/test/Memind` | 开发预览前端 | `15173` | +| 测试 Admin | `/Users/john/Project/test/Memind` | 开发预览后台 | `18082` | +| 测试 Plaza | `/Users/john/Project/test/Memind` | 开发预览 Plaza | `13001` | +| 测试 Ops | `/Users/john/Project/test/Memind` | 开发预览 Ops | `13002` | + +硬规则: + +- 不在开发预览中使用 `8081`、`3001`、`8090`。 +- 不在生产目录里跑会清理端口的开发脚本。 +- 不执行 `scripts/install-prod-services.sh` 来做开发预览;它会释放生产端口。 +- 不手动 kill `8081` 上的进程,除非目标就是恢复/重启生产,并且已经确认影响窗口。 + +## Harness 角色边界 + +Harness 是开发工具层,不是生产服务层。 + +正确关系: + +```text +Codex / Cursor / Goose 开发过程 + -> harness 记忆、recall、context pack、审计 + -> Memind / tkmind_go 开发 + -> 测试通过后发布到生产 +``` + +禁止关系: + +```text +g2.tkmind.cn 生产请求 + -> 必须依赖 harness 才能运行 +``` + +本机已有全局 harness: + +```text +/Users/john/Project/harness +``` + +它应该嵌入 Codex、Cursor 这类开发工具,让项目开发有记忆: + +```bash +/Users/john/Project/harness/bin/codex-harness +/Users/john/Project/harness/bin/install_cursor_hooks.sh +/Users/john/Project/harness/bin/start_dashboard.sh +``` + +开发时按项目目录区分记忆上下文: + +| 项目目录 | 记忆含义 | +|----------|----------| +| `/Users/john/Project/Memind` | 生产项目的开发记忆 | +| `/Users/john/Project/test/Memind` | 测试项目的开发记忆 | +| `/Users/john/Project/test/test_tkmind_go` | 测试 Goose 的开发记忆 | + +硬规则: + +- 生产启动脚本不能依赖 harness 才能启动。 +- 生产请求链路不能调用 harness 才能响应。 +- 测试开发可以使用 harness,但不能写入或覆盖生产运行数据。 +- 如果要做测试专用长期记忆,优先放在 `/Users/john/Project/test/harness` 或明确的 test 项目命名空间。 + +## 当前生产如何确认 + +只读检查: + +```bash +cd /Users/john/Project/Memind +lsof -nP -iTCP:8081 -sTCP:LISTEN +curl -s http://127.0.0.1:8081/api/status +cat .h5.pid +``` + +期望: + +- `8081` 有 `node server.mjs` 监听。 +- `/api/status` 返回 `ok`。 +- `.h5.pid` 指向当前生产进程。 + +不要用下面这些命令做普通预览: + +```bash +pnpm dev +pnpm dev:server +pnpm start +pnpm start:plaza +node scripts/dev.mjs +node server.mjs +scripts/install-prod-services.sh +``` + +这些命令如果没有端口隔离,可能占用或释放生产端口。 + +## 下次开发要预览,怎么做 + +优先在测试目录进行: + +```bash +cd /Users/john/Project/test/Memind +``` + +启动前先确认生产还在: + +```bash +curl -s http://127.0.0.1:8081/api/status +lsof -nP -iTCP:8081 -sTCP:LISTEN +``` + +再用测试端口启动预览: + +```bash +H5_PORT=18081 \ +VITE_PORT=15173 \ +ADMIN_PORT=18082 \ +PLAZA_PORT=13001 \ +OPS_PORT=13002 \ +H5_PUBLIC_BASE_URL=http://127.0.0.1:15173 \ +VITE_MINDSPACE_BASE=http://127.0.0.1:15173 \ +pnpm dev +``` + +访问地址: + +| 页面 | 地址 | +|------|------| +| H5 预览 | `http://127.0.0.1:15173/?preview=mindspace` | +| 测试 Portal | `http://127.0.0.1:18081/api/status` | +| 测试 Admin | `http://127.0.0.1:18082/healthz` | +| 测试 Plaza | `http://127.0.0.1:13001/plaza` | +| 测试 Ops | `http://127.0.0.1:13002/ops/` | + +如果只改前端样式,优先只启动 Vite: + +```bash +cd /Users/john/Project/test/Memind +VITE_PORT=15173 \ +H5_PUBLIC_BASE_URL=http://127.0.0.1:15173 \ +VITE_MINDSPACE_BASE=http://127.0.0.1:15173 \ +pnpm dev:vite -- --host 127.0.0.1 --port 15173 +``` + +这种方式不会碰 `8081`,适合做 UI 预览。 + +## Goose 测试服务 + +生产 Goose 端口当前不要复用。测试 Goose 放在: + +```text +/Users/john/Project/test/test_tkmind_go +``` + +测试 Goose 端口建议固定为: + +| 用途 | 端口 | +|------|------| +| 测试 goosed A | `18106` | +| 测试 goosed B | `18107` | + +测试 H5 如果要连测试 Goose,必须在测试目录 `.env` 中显式设置对应地址,不能指向生产 Goose。 + +## 数据库口径 + +短期如果沿用同一个 RDS 库,只允许做 UI 和非破坏性流程预览。 + +不能在共用库时做这些事: + +- 改表结构。 +- 跑迁移脚本。 +- 批量清理数据。 +- 测试支付回调、发布审核、权限变更等会污染真实用户状态的流程。 + +涉及数据结构或真实业务状态的开发,先建独立测试库,再预览。 + +## 发布前流程 + +完整步骤、分步发布、105 同步与回滚见 **[生产更新发布指南](./release-deploy.md)**。 + +最小检查: + +1. 在测试端口(如 `18081`)完成开发与预览,不要在生产目录跑 `pnpm dev`。 +2. `pnpm run build` + `pnpm test`。 +3. `curl -s http://127.0.0.1:8081/api/status` 确认生产仍在线。 +4. `./rsync_to_server.sh --dry-run` 预览 diff,确认后再执行正式发布。 + +## 事故恢复最小步骤 + +如果发现 `g2.tkmind.cn` 异常,先检查本机生产: + +```bash +cd /Users/john/Project/Memind +lsof -nP -iTCP:8081 -sTCP:LISTEN +curl -s http://127.0.0.1:8081/api/status +``` + +如果 `8081` 没有监听,再恢复生产 Portal: + +```bash +cd /Users/john/Project/Memind +nohup /opt/homebrew/opt/node@24/bin/node server.mjs >> h5.log 2>&1 & +echo $! > .h5.pid +sleep 2 +curl -s http://127.0.0.1:8081/api/status +``` + +恢复后再检查 Caddy: + +```bash +curl -s http://127.0.0.1:8090/api/status +``` diff --git a/llm-providers.mjs b/llm-providers.mjs index bdee02f..e3fe73a 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -595,13 +595,13 @@ export function createLlmProviderService( ]); } - async function syncRow(row) { + async function syncRow(row, fetchImpl = apiFetchImpl) { const profile = profileFromRow(row, decryptRow); const goosedProviderId = await syncProfileToGoosed( apiTarget, apiSecret, profile, - apiFetchImpl, + fetchImpl, ); if (profile.providerKind === 'custom' && goosedProviderId !== row.goosed_provider_id) { await pool.query( @@ -635,7 +635,7 @@ export function createLlmProviderService( return { ok: true, model: profile.defaultModel }; } - async function resolveSelectedProvider() { + async function resolveSelectedProvider(fetchImpl = apiFetchImpl) { const row = await getSelectedRow(); if (!row) { return { ok: false, message: '请先启用 LLM 配置' }; @@ -649,7 +649,7 @@ export function createLlmProviderService( }; } try { - const goosedProviderId = await syncRow(row); + const goosedProviderId = await syncRow(row, fetchImpl); const providerId = profile.providerKind === 'custom' ? (goosedProviderId ?? profile.goosedProviderId ?? profile.providerId) @@ -669,7 +669,7 @@ export function createLlmProviderService( } /** Local fallback is only for DeepSeek creditsExhausted — never auto-selected at boot/session start. */ - async function resolveLocalFallbackProvider() { + async function resolveLocalFallbackProvider(fetchImpl = apiFetchImpl) { let localTest; try { localTest = await testLocalLlmConnection(apiFetchImpl); @@ -688,7 +688,7 @@ export function createLlmProviderService( const providerId = await ensureLocalFallbackProviderOnGoosed( apiTarget, apiSecret, - apiFetchImpl, + fetchImpl, ); return { ok: true, @@ -967,22 +967,26 @@ export function createLlmProviderService( }; }, - async applyBestProviderForSession(sessionId) { - const resolved = await resolveSelectedProvider(); + async applyBestProviderForSession(sessionId, fetchImpl = apiFetchImpl) { + const resolved = await resolveSelectedProvider(fetchImpl); if (!resolved.ok) { return resolved; } - await updateSessionProvider(goosedApi, sessionId, resolved.providerId, resolved.model); + const sessionGoosedApi = (pathname, init) => + goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl); + await updateSessionProvider(sessionGoosedApi, sessionId, resolved.providerId, resolved.model); return resolved; }, /** Switch session to local Ollama (credits exhausted or relay 500 payload limit). */ - async applyLocalFallbackForSession(sessionId) { - const resolved = await resolveLocalFallbackProvider(); + async applyLocalFallbackForSession(sessionId, fetchImpl = apiFetchImpl) { + const resolved = await resolveLocalFallbackProvider(fetchImpl); if (!resolved.ok) { return resolved; } - await updateSessionProvider(goosedApi, sessionId, resolved.providerId, resolved.model); + const sessionGoosedApi = (pathname, init) => + goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl); + await updateSessionProvider(sessionGoosedApi, sessionId, resolved.providerId, resolved.model); return resolved; }, diff --git a/mindspace-agent-jobs.mjs b/mindspace-agent-jobs.mjs index ee388ae..467c95a 100644 --- a/mindspace-agent-jobs.mjs +++ b/mindspace-agent-jobs.mjs @@ -150,6 +150,37 @@ export function createAgentJobService(pool, options = {}) { return resolved; }; + const resolveStoragePathCandidates = (storageKey) => { + const normalized = String(storageKey ?? '').replace(/\\/g, '/'); + const withoutMd = normalized.endsWith('.md') ? normalized.slice(0, -3) : normalized; + const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/); + if (!match) return [normalized]; + const [, userId, scope, entityId, versionTag] = match; + return [ + normalized, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}`, + ].filter((candidate, index, list) => list.indexOf(candidate) === index); + }; + + const resolveReadableStoragePath = async (storageKey) => { + let lastError = null; + for (const candidate of resolveStoragePathCandidates(storageKey)) { + const absolutePath = absoluteStoragePath(candidate); + try { + await fs.stat(absolutePath); + return absolutePath; + } catch (error) { + if (error?.code === 'ENOENT') { + lastError = error; + continue; + } + throw error; + } + } + throw lastError ?? Object.assign(new Error('存储文件不存在'), { code: 'storage_not_found' }); + }; + const fetchJobAssets = async (jobId) => { const [rows] = await pool.query( `SELECT ja.asset_id, ja.asset_version_id, ja.permission, a.display_name, a.mime_type, @@ -536,7 +567,7 @@ export function createAgentJobService(pool, options = {}) { assetVersionId: asset.asset_version_id, displayName: asset.display_name, mimeType: asset.mime_type, - path: absoluteStoragePath(asset.storage_key), + path: await resolveReadableStoragePath(asset.storage_key), }; }; diff --git a/mindspace-asset-preview.mjs b/mindspace-asset-preview.mjs index 60df7ba..829b26a 100644 --- a/mindspace-asset-preview.mjs +++ b/mindspace-asset-preview.mjs @@ -24,9 +24,57 @@ th{background:#faf7ef} .docx-preview p{margin:0 0 12px;text-indent:2em} .docx-preview .meta{color:#6b7280;font-size:13px;margin-bottom:20px;text-indent:0} .pdf-frame,.image-frame{display:block;width:100%;min-height:calc(100vh - 48px);border:0;border-radius:12px;background:#fff} -.image-frame{object-fit:contain;max-height:calc(100vh - 48px);width:auto;max-width:100%;margin:0 auto} +.image-frame{object-fit:contain;max-height:calc(100vh - 48px);width:auto;max-width:100%;margin:0 auto;cursor:zoom-in} +.image-viewer{padding:0} +.image-viewer h1,.image-viewer .meta{display:none} +.image-viewer .image-frame{min-height:100vh;max-height:100vh;margin:0;border-radius:0;background:#0b100e} +.image-lightbox[hidden]{display:none} +.image-lightbox{position:fixed;inset:0;z-index:9999;display:grid;place-items:center;padding:max(16px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(16px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));background:rgba(7,12,10,.9);cursor:zoom-out} +.image-lightbox img{display:block;max-width:min(100%,1600px);max-height:calc(100vh - 32px);object-fit:contain;border-radius:8px;box-shadow:0 24px 80px rgba(0,0,0,.35);cursor:default} +.image-lightbox-close{position:fixed;top:max(16px,env(safe-area-inset-top));right:max(16px,env(safe-area-inset-right));z-index:10000;display:grid;place-items:center;width:40px;height:40px;padding:0;border:0;border-radius:999px;color:#fffaf0;background:rgba(24,33,29,.72);box-shadow:0 8px 24px rgba(0,0,0,.28);cursor:pointer} +.image-lightbox-close svg{display:block;width:18px;height:18px;stroke:currentColor;stroke-width:2;fill:none} +.image-lightbox-close:hover{background:rgba(24,33,29,.92)} `; +const IMAGE_LIGHTBOX_CLOSE_ICON = + ''; + +const IMAGE_LIGHTBOX_SCRIPT = ``; + function escapeHtml(text) { return String(text ?? '') .replace(/&/g, '&') @@ -35,7 +83,11 @@ function escapeHtml(text) { .replace(/"/g, '"'); } -function previewDocument(title, bodyHtml, { downloadUrl = null, extraHead = '' } = {}) { +function previewDocument( + title, + bodyHtml, + { downloadUrl = null, extraHead = '', allowScripts = false, bodyClass = '' } = {}, +) { const csp = [ "default-src 'none'", "style-src 'unsafe-inline'", @@ -45,12 +97,44 @@ function previewDocument(title, bodyHtml, { downloadUrl = null, extraHead = '' } "object-src 'self'", "base-uri 'none'", "form-action 'none'", - "script-src 'none'", + allowScripts ? "script-src 'unsafe-inline'" : "script-src 'none'", ].join('; '); const downloadLink = downloadUrl ? `

下载原文件

` : ''; - return `${escapeHtml(title)}${extraHead}

${escapeHtml(title)}

${downloadLink}${bodyHtml}`; + const bodyAttrs = bodyClass ? ` class="${escapeHtml(bodyClass)}"` : ''; + return `${escapeHtml(title)}${extraHead}

${escapeHtml(title)}

${downloadLink}${bodyHtml}`; +} + +function renderImageLightboxMarkup({ downloadUrl, title, triggerClass = 'image-frame' }) { + const safeUrl = escapeHtml(downloadUrl); + const safeTitle = escapeHtml(title); + return ( + `${safeTitle}` + + `${IMAGE_LIGHTBOX_SCRIPT}` + ); +} + +export function wantsInlineImageViewer(req) { + if (req?.query?.viewer === '1') return true; + if (req?.query?.viewer === '0') return false; + const fetchDest = String(req?.get?.('sec-fetch-dest') ?? req?.headers?.['sec-fetch-dest'] ?? '').toLowerCase(); + if (fetchDest === 'image') return false; + if (fetchDest === 'document' || fetchDest === 'iframe') return true; + const accept = String(req?.get?.('accept') ?? req?.headers?.accept ?? ''); + return /text\/html/i.test(accept); +} + +export function renderImageAssetViewerHtml({ asset, downloadUrl }) { + const title = asset.displayName || asset.filename; + return previewDocument(title, renderImageLightboxMarkup({ downloadUrl, title }), { + downloadUrl, + allowScripts: true, + bodyClass: 'image-viewer', + }); } function renderInlineMarkdown(text) { @@ -220,11 +304,10 @@ export function renderAssetPreviewHtml({ asset, buffer, downloadUrl }) { } if (mimeType.startsWith('image/')) { - return previewDocument( - title, - `${escapeHtml(title)}`, - { downloadUrl }, - ); + return previewDocument(title, renderImageLightboxMarkup({ downloadUrl, title }), { + downloadUrl, + allowScripts: true, + }); } if (mimeType === 'text/csv') { diff --git a/mindspace-asset-preview.test.mjs b/mindspace-asset-preview.test.mjs index cbcf2fa..4bb44d7 100644 --- a/mindspace-asset-preview.test.mjs +++ b/mindspace-asset-preview.test.mjs @@ -3,7 +3,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs'; +import { canPreviewAsset, renderAssetPreviewHtml, renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs'; test('canPreviewAsset covers common workspace file types', () => { assert.equal(canPreviewAsset('text/html'), true); @@ -30,6 +30,50 @@ test('renderAssetPreviewHtml renders csv as table', () => { assert.match(html, /下载原文件/); }); +test('renderAssetPreviewHtml renders image lightbox controls', () => { + const html = renderAssetPreviewHtml({ + asset: { + displayName: '封面', + filename: 'cover.jpg', + mimeType: 'image/jpeg', + }, + buffer: Buffer.from('fake'), + downloadUrl: '/api/mindspace/v1/assets/a/download?inline=1', + }); + assert.match(html, /data-image-lightbox-trigger/); + assert.match(html, /image-lightbox-close/); + assert.match(html, /event\.key === 'Escape'/); + assert.match(html, /script-src 'unsafe-inline'/); +}); + +test('renderImageAssetViewerHtml uses minimal image viewer shell', () => { + const html = renderImageAssetViewerHtml({ + asset: { + displayName: '封面', + filename: 'cover.jpg', + mimeType: 'image/jpeg', + }, + downloadUrl: '/api/mindspace/v1/assets/a/download?inline=1', + }); + assert.match(html, /class="image-viewer"/); + assert.match(html, /aria-label="关闭"/); +}); + +test('wantsInlineImageViewer distinguishes embed vs page navigation', () => { + assert.equal( + wantsInlineImageViewer({ get: (name) => (name === 'sec-fetch-dest' ? 'image' : ''), query: {} }), + false, + ); + assert.equal( + wantsInlineImageViewer({ get: (name) => (name === 'sec-fetch-dest' ? 'document' : ''), query: {} }), + true, + ); + assert.equal( + wantsInlineImageViewer({ get: () => '', query: { viewer: '1' } }), + true, + ); +}); + test('renderAssetPreviewHtml extracts docx text', async () => { const docPath = path.join( process.cwd(), diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index bbc1f70..b7a42bf 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -30,6 +30,7 @@ const ALLOWED_EXTENSIONS = new Map([ ['.html', 'text/html'], ['.htm', 'text/html'], ]); +const MAX_IMAGE_UPLOAD_BYTES = 1536 * 1024; function asNumber(value) { return Number(value ?? 0); @@ -141,6 +142,9 @@ export function validateUploadRequest({ filename, sizeBytes, maxFileBytes = DEFA if (!mimeType) { throw Object.assign(new Error('暂不支持该文件类型'), { code: 'unsupported_file_type' }); } + if (mimeType.startsWith('image/') && normalizedSize > MAX_IMAGE_UPLOAD_BYTES) { + throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' }); + } return { filename: normalizedFilename, sizeBytes: normalizedSize, expectedMimeType: mimeType }; } @@ -176,6 +180,37 @@ export function createAssetService(pool, options = {}) { return resolved; }; + const resolveStoragePathCandidates = (storageKey) => { + const normalized = String(storageKey ?? '').replace(/\\/g, '/'); + const withoutMd = normalized.endsWith('.md') ? normalized.slice(0, -3) : normalized; + const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/); + if (!match) return [normalized]; + const [, userId, scope, entityId, versionTag] = match; + return [ + normalized, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}`, + ].filter((candidate, index, list) => list.indexOf(candidate) === index); + }; + + const resolveReadableStoragePath = async (storageKey) => { + let lastError = null; + for (const candidate of resolveStoragePathCandidates(storageKey)) { + const absolutePath = absoluteStoragePath(candidate); + try { + await fs.stat(absolutePath); + return absolutePath; + } catch (error) { + if (error?.code === 'ENOENT') { + lastError = error; + continue; + } + throw error; + } + } + throw lastError ?? Object.assign(new Error('存储文件不存在'), { code: 'storage_not_found' }); + }; + const createUpload = async (userId, input) => { const validated = validateUploadRequest({ ...input, maxFileBytes }); const conn = await pool.getConnection(); @@ -293,6 +328,9 @@ export function createAssetService(pool, options = {}) { if (!detectedMimeType) { throw Object.assign(new Error('无法确认文件类型'), { code: 'unsupported_file_type' }); } + if (detectedMimeType.startsWith('image/') && buffer.length > MAX_IMAGE_UPLOAD_BYTES) { + throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' }); + } const target = absoluteStoragePath(upload.temporary_storage_key); await fs.mkdir(path.dirname(target), { recursive: true }); @@ -644,7 +682,10 @@ export function createAssetService(pool, options = {}) { const asset = rows[0]; if (!asset) throw Object.assign(new Error('资产不存在'), { code: 'asset_not_found' }); assertAssetDownloadable(asset); - return { asset: assetResponse(asset), path: absoluteStoragePath(asset.storage_key) }; + return { + asset: assetResponse(asset), + path: await resolveReadableStoragePath(asset.storage_key), + }; }; const createChatAsset = async ( @@ -662,6 +703,9 @@ export function createAssetService(pool, options = {}) { if (!detectedMimeType) { throw Object.assign(new Error('无法确认文件类型'), { code: 'unsupported_file_type' }); } + if (detectedMimeType.startsWith('image/') && buffer.length > MAX_IMAGE_UPLOAD_BYTES) { + throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' }); + } const conn = await pool.getConnection(); let finalPath; try { diff --git a/mindspace-og-tags.mjs b/mindspace-og-tags.mjs new file mode 100644 index 0000000..e7688af --- /dev/null +++ b/mindspace-og-tags.mjs @@ -0,0 +1,77 @@ +// Inject Open Graph / Twitter Card tags into published MindSpace HTML at serve time, +// so forwarded links unfurl with a cover image in WeChat / browsers / IM clients. +// +// Source of truth for the cover is the page's own (mindspace-cover JSON / +// / hero ), reused via extractCoverSignals. Authors who +// already wrote their own og:image are left untouched. +import { extractCoverSignals } from './mindspace-thumbnails.mjs'; + +function rawTitleFromHtml(html) { + // Keep the original verbatim (emoji included) for og:title. + return String(html).match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? ''; +} + +function escapeAttr(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} + +/** + * Resolve a cover reference (relative path, root-absolute, or full URL) to an + * absolute https URL the unfurling client can fetch. + * @returns {string|null} absolute URL, or null when no usable raster cover exists. + */ +function resolveImageUrl(image, { origin, pageDirUrl }) { + if (!image) return null; + const trimmed = String(image).trim(); + if (!trimmed || trimmed.startsWith('data:')) return null; + // og:image must be a raster the client can render; SVG is not honored by WeChat/most unfurlers. + if (/\.svg(?:[?#]|$)/i.test(trimmed)) return null; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + if (trimmed.startsWith('//')) return `https:${trimmed}`; + if (trimmed.startsWith('/')) return `${origin}${trimmed}`; + return `${pageDirUrl}${trimmed}`; +} + +/** + * Inject og:/twitter: meta into an HTML document. + * @param {string} html raw page HTML + * @param {{ origin: string, pageUrl: string, pageDirUrl: string, fallbackImageUrl?: string }} ctx + * origin: https://host pageUrl: canonical page URL pageDirUrl: page directory URL (trailing '/') + * fallbackImageUrl: absolute raster URL to use when the page has no cover of its own + * @returns {string} HTML with tags injected (or unchanged when not applicable) + */ +export function injectOgTags(html, { origin, pageUrl, pageDirUrl, fallbackImageUrl = '' }) { + const source = String(html); + // Respect a page that already declares its own Open Graph image. + if (/<meta[^>]+property=["']og:image["']/i.test(source)) return source; + + const signals = extractCoverSignals(source); + const title = rawTitleFromHtml(source) || signals.title; + const description = signals.subtitle || ''; + const imageUrl = resolveImageUrl(signals.image, { origin, pageDirUrl }) || fallbackImageUrl || null; + + const tags = [ + '<meta property="og:type" content="article">', + pageUrl ? `<meta property="og:url" content="${escapeAttr(pageUrl)}">` : '', + title ? `<meta property="og:title" content="${escapeAttr(title)}">` : '', + description ? `<meta property="og:description" content="${escapeAttr(description)}">` : '', + imageUrl ? `<meta property="og:image" content="${escapeAttr(imageUrl)}">` : '', + `<meta name="twitter:card" content="${imageUrl ? 'summary_large_image' : 'summary'}">`, + title ? `<meta name="twitter:title" content="${escapeAttr(title)}">` : '', + description ? `<meta name="twitter:description" content="${escapeAttr(description)}">` : '', + imageUrl ? `<meta name="twitter:image" content="${escapeAttr(imageUrl)}">` : '', + ].filter(Boolean); + + const block = `\n${tags.map((t) => ` ${t}`).join('\n')}\n`; + if (/<\/head>/i.test(source)) { + return source.replace(/<\/head>/i, `${block}</head>`); + } + if (/<head[^>]*>/i.test(source)) { + return source.replace(/(<head[^>]*>)/i, `$1${block}`); + } + return source; +} diff --git a/mindspace-og-tags.test.mjs b/mindspace-og-tags.test.mjs new file mode 100644 index 0000000..4e131ae --- /dev/null +++ b/mindspace-og-tags.test.mjs @@ -0,0 +1,70 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { injectOgTags } from './mindspace-og-tags.mjs'; + +const ctx = { + origin: 'https://g2.tkmind.cn', + pageUrl: 'https://g2.tkmind.cn/MindSpace/john/public/space.html', + pageDirUrl: 'https://g2.tkmind.cn/MindSpace/john/public/', +}; + +test('injects og/twitter tags with absolute image from a relative cover', () => { + const html = `<html><head><title>🚀 太空小勇士 - 闯关大冒险` + + `` + + ``; + const out = injectOgTags(html, ctx); + assert.match(out, //); + assert.match(out, //); + assert.match(out, //); + assert.match(out, //); + // injected before + assert.ok(out.indexOf('og:image') < out.indexOf('')); +}); + +test('falls back to the thumbnail png when the page has no cover of its own', () => { + const html = `纯文字页面`; + const out = injectOgTags(html, { + ...ctx, + fallbackImageUrl: 'https://g2.tkmind.cn/MindSpace/john/public/space.thumbnail.png', + }); + assert.match(out, //); + assert.match(out, //); +}); + +test('prefers the page cover over the thumbnail fallback', () => { + const html = `X`; + const out = injectOgTags(html, { ...ctx, fallbackImageUrl: 'https://g2.tkmind.cn/x.thumbnail.png' }); + assert.match(out, /og:image" content="https:\/\/g2\.tkmind\.cn\/MindSpace\/john\/public\/assets\/hero\.jpg"/); + assert.doesNotMatch(out, /thumbnail\.png/); +}); + +test('keeps an author-provided og:image and does not duplicate', () => { + const html = `T`; + const out = injectOgTags(html, ctx); + assert.equal((out.match(/og:image/g) ?? []).length, 1); +}); + +test('skips og:image for svg-only cover, falls back to summary card', () => { + const html = `仅SVG`; + const out = injectOgTags(html, ctx); + assert.doesNotMatch(out, /og:image/); + assert.match(out, //); +}); + +test('passes through a full https cover url unchanged', () => { + const html = `X`; + const out = injectOgTags(html, ctx); + assert.match(out, //); +}); + +test('resolves a root-absolute cover against the origin', () => { + const html = `X`; + const out = injectOgTags(html, ctx); + assert.match(out, //); +}); + +test('escapes quotes and ampersands in title to keep the meta tag well-formed', () => { + const html = `say "hi" & bye`; + const out = injectOgTags(html, ctx); + assert.match(out, //); +}); diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs index dcbc2e2..8971430 100644 --- a/mindspace-pages.mjs +++ b/mindspace-pages.mjs @@ -92,6 +92,7 @@ function pageResponse(row) { status: row.status, visibility: row.visibility, publicationAccessMode: row.pub_access_mode ?? null, + publicationUrl: row.pub_public_url ?? null, currentVersionId: row.current_version_id, versionNo: asNumber(row.version_no), content: row.content, @@ -225,6 +226,37 @@ export function createPageService(pool, options = {}) { return resolved; }; + const resolveStoragePathCandidates = (storageKey) => { + const normalized = String(storageKey ?? '').replace(/\\/g, '/'); + const withoutMd = normalized.endsWith('.md') ? normalized.slice(0, -3) : normalized; + const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/); + if (!match) return [normalized]; + const [, userId, scope, entityId, versionTag] = match; + return [ + normalized, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}`, + ].filter((candidate, index, list) => list.indexOf(candidate) === index); + }; + + const resolveReadableStoragePath = async (storageKey) => { + let lastError = null; + for (const candidate of resolveStoragePathCandidates(storageKey)) { + const absolutePath = absoluteStoragePath(candidate); + try { + await fs.stat(absolutePath); + return absolutePath; + } catch (error) { + if (error?.code === 'ENOENT') { + lastError = error; + continue; + } + throw error; + } + } + throw lastError ?? Object.assign(new Error('存储文件不存在'), { code: 'storage_not_found' }); + }; + const writeVersionContent = async (userId, assetId, versionId, content) => { const storageKey = path.posix.join( 'users', @@ -479,8 +511,8 @@ export function createPageService(pool, options = {}) { const findPageBySourceAsset = async (userId, assetId) => { if (!assetId) return null; - const [rows] = await pool.query( - `SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode + const [rows] = await pool.query( + `SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url FROM h5_page_records p JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id @@ -501,7 +533,7 @@ export function createPageService(pool, options = {}) { params.push(filters.status); } const [rows] = await pool.query( - `SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode + `SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url FROM h5_page_records p JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id @@ -526,7 +558,8 @@ export function createPageService(pool, options = {}) { ); const row = rows[0]; if (!row) throw pageError('页面不存在', 'page_not_found'); - const content = await fs.readFile(absoluteStoragePath(row.storage_key), 'utf8'); + const storagePath = await resolveReadableStoragePath(row.storage_key); + const content = await fs.readFile(storagePath, 'utf8'); return pageResponse({ ...row, content }); } @@ -863,7 +896,8 @@ export function createPageService(pool, options = {}) { if (row.page_type !== 'html') { throw pageError('该页面不支持缩略图', 'thumbnail_not_supported'); } - const content = await fs.readFile(absoluteStoragePath(row.storage_key), 'utf8'); + const storagePath = await resolveReadableStoragePath(row.storage_key); + const content = await fs.readFile(storagePath, 'utf8'); let snapshot = {}; try { snapshot = JSON.parse(row.source_snapshot_json ?? '{}'); diff --git a/mindspace-publications.mjs b/mindspace-publications.mjs index 2ea119d..8ab2c72 100644 --- a/mindspace-publications.mjs +++ b/mindspace-publications.mjs @@ -164,6 +164,37 @@ export function createPublicationService(pool, options = {}) { return resolved; }; + const resolveStoragePathCandidates = (storageKey) => { + const normalized = String(storageKey ?? '').replace(/\\/g, '/'); + const withoutMd = normalized.endsWith('.md') ? normalized.slice(0, -3) : normalized; + const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/); + if (!match) return [normalized]; + const [, userId, scope, entityId, versionTag] = match; + return [ + normalized, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`, + `users/${userId}/${scope}/${entityId}/versions/${versionTag}`, + ].filter((candidate, index, list) => list.indexOf(candidate) === index); + }; + + const resolveReadableStoragePath = async (storageKey) => { + let lastError = null; + for (const candidate of resolveStoragePathCandidates(storageKey)) { + const absolutePath = absoluteStoragePath(candidate); + try { + await fs.stat(absolutePath); + return absolutePath; + } catch (error) { + if (error?.code === 'ENOENT') { + lastError = error; + continue; + } + throw error; + } + } + throw lastError ?? Object.assign(new Error('存储文件不存在'), { code: 'storage_not_found' }); + }; + const loadVersion = async (userId, pageId, pageVersionId) => { const [rows] = await pool.query( `SELECT p.id AS page_id, p.title, p.summary, p.page_type, p.template_id, p.current_version_id, @@ -181,7 +212,7 @@ export function createPublicationService(pool, options = {}) { return { ...row, version_no: Number(row.version_no), - content: await fs.readFile(absoluteStoragePath(row.storage_key), 'utf8'), + content: await fs.readFile(await resolveReadableStoragePath(row.storage_key), 'utf8'), }; }; @@ -610,7 +641,7 @@ export function createPublicationService(pool, options = {}) { ], ); return { - html: await fs.readFile(absoluteStoragePath(row.storage_key), 'utf8'), + html: await fs.readFile(await resolveReadableStoragePath(row.storage_key), 'utf8'), publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }), }; }; diff --git a/mindspace-thumbnail-png.mjs b/mindspace-thumbnail-png.mjs new file mode 100644 index 0000000..88d8b5f --- /dev/null +++ b/mindspace-thumbnail-png.mjs @@ -0,0 +1,45 @@ +// Rasterize a feed thumbnail SVG to PNG so every published page has a guaranteed +// raster og:image (WeChat / browsers don't render SVG cover images). +// +// PNGs are derived lazily from the sibling `.thumbnail.svg` and cached on disk +// as `.thumbnail.png`. Regenerated when the SVG is newer than the cached PNG. +import fs from 'node:fs'; +import { Resvg } from '@resvg/resvg-js'; + +const RENDER_WIDTH = 540; // matches the 540x720 feed card aspect + +/** SVG → PNG buffer. White background avoids black fills where clients ignore alpha. */ +export function rasterizeThumbnailSvgToPng(svg) { + const resvg = new Resvg(svg, { + background: 'white', + fitTo: { mode: 'width', value: RENDER_WIDTH }, + font: { loadSystemFonts: true }, // CJK + serif via the host font stack (PingFang/Georgia on macOS) + }); + return resvg.render().asPng(); +} + +/** Absolute path of the PNG that mirrors a `.thumbnail.svg`. */ +export function thumbnailPngPathForSvg(svgAbsPath) { + return svgAbsPath.replace(/\.svg$/i, '.png'); +} + +/** + * Ensure a `.thumbnail.png` exists and is at least as fresh as its `.thumbnail.svg`. + * @returns {string|null} the PNG path, or null when no source SVG is available. + */ +export function ensureThumbnailPng(svgAbsPath) { + if (!fs.existsSync(svgAbsPath)) return null; + const pngPath = thumbnailPngPathForSvg(svgAbsPath); + try { + const svgStat = fs.statSync(svgAbsPath); + if (fs.existsSync(pngPath) && fs.statSync(pngPath).mtimeMs >= svgStat.mtimeMs) { + return pngPath; + } + const svg = fs.readFileSync(svgAbsPath, 'utf8'); + fs.writeFileSync(pngPath, rasterizeThumbnailSvgToPng(svg)); + return pngPath; + } catch { + // If rasterization fails, fall back to an existing PNG if present, else give up. + return fs.existsSync(pngPath) ? pngPath : null; + } +} diff --git a/ops/rollback-wechat-mp-20260618.sh b/ops/rollback-wechat-mp-20260618.sh new file mode 100755 index 0000000..ca0fb8d --- /dev/null +++ b/ops/rollback-wechat-mp-20260618.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -euo pipefail + +ROOT="/Users/john/Project/Memind" +BACKUP="/Users/john/Project/.codex-baselines/Memind/wechat-service-agent-20260618-084500/.env.before-prod-mp-20260618-092652" +NODE_BIN="/opt/homebrew/opt/node@24/bin/node" + +cd "$ROOT" + +if [[ ! -f "$BACKUP" ]]; then + echo "backup env not found: $BACKUP" >&2 + exit 1 +fi + +cp "$BACKUP" .env + +PORT_PID="$(lsof -tiTCP:8081 -sTCP:LISTEN || true)" +if [[ -n "$PORT_PID" ]]; then + kill "$PORT_PID" + sleep 2 +fi + +nohup "$NODE_BIN" server.mjs >> h5.log 2>&1 & +echo $! > .h5.pid +sleep 3 +curl -s http://127.0.0.1:8081/api/status diff --git a/ops/src/App.tsx b/ops/src/App.tsx index 189bb85..61c864a 100644 --- a/ops/src/App.tsx +++ b/ops/src/App.tsx @@ -12,6 +12,7 @@ import { SummaryPage } from './pages/admin/SummaryPage'; import { UsersPage } from './pages/admin/UsersPage'; import { LlmPage } from './pages/admin/LlmPage'; import { BillingPage } from './pages/admin/BillingPage'; +import { WechatPage } from './pages/admin/WechatPage'; export function App() { return ( @@ -44,6 +45,7 @@ export function App() { } /> } /> } /> + } /> } /> diff --git a/ops/src/api/admin.ts b/ops/src/api/admin.ts index f8f98c0..892a6d8 100644 --- a/ops/src/api/admin.ts +++ b/ops/src/api/admin.ts @@ -74,6 +74,92 @@ export type UsageRecord = { createdAt: string; }; +export type WechatAdminSummary = { + config: { + mpEnabled: boolean; + scheduleEnabled: boolean; + reminderWorkerEnabled: boolean; + appId: string | null; + publicBaseUrl: string | null; + bindPath: string | null; + tokenEndpointConfigured: boolean; + customerServiceEndpointConfigured: boolean; + }; + counts: { + boundUsers: number; + routes: { total: number; active: number }; + recentMessages: Record; + digests: Record; + recentDeliveries: Record; + }; +}; + +export type WechatBinding = { + userId: string; + username: string; + displayName: string; + status: string; + appId: string | null; + openidMasked: string; + nickname: string | null; + avatarUrl: string | null; + lastLoginAt: number; + boundAt: number; + routeId: string | null; + routeStatus: string | null; + agentSessionId: string | null; + routeUpdatedAt: number | null; +}; + +export type WechatMessage = { + appId: string | null; + openidMasked: string; + msgId: string; + status: string; + agentSessionId: string | null; + createdAt: number; + updatedAt: number; + userId: string | null; + username: string | null; + displayName: string | null; +}; + +export type WechatDigestSubscription = { + id: string; + userId: string; + username: string; + displayName: string; + digestType: string; + hour: number; + minute: number; + timezone: string; + channel: string; + status: string; + nextRunAt: number; + lastRunAt: number | null; + attempts: number; + lastError: string | null; + sourceText: string | null; + createdAt: number; + updatedAt: number; +}; + +export type WechatDeliveryLog = { + id: string; + reminderId: string | null; + subscriptionId: string | null; + userId: string; + username: string; + displayName: string; + channel: string; + status: string; + providerMessageId: string | null; + errorCode: string | null; + errorMessage: string | null; + createdAt: number; + digestType: string | null; +}; + // ─── Summary ────────────────────────────────────────────────────────────────── export async function fetchAdminSummary() { @@ -149,6 +235,57 @@ export async function fetchAdminUsage(params: { page?: number; pageSize?: number ); } +// ─── WeChat MP ──────────────────────────────────────────────────────────────── + +export async function fetchWechatSummary() { + return adminFetch('/admin-api/wechat/summary'); +} + +export async function fetchWechatBindings(params: { search?: string; limit?: number } = {}) { + const q = new URLSearchParams(); + if (params.search) q.set('search', params.search); + if (params.limit) q.set('limit', String(params.limit)); + return adminFetch<{ bindings: WechatBinding[] }>(`/admin-api/wechat/bindings?${q}`); +} + +export async function fetchWechatMessages(params: { status?: string; limit?: number } = {}) { + const q = new URLSearchParams(); + if (params.status) q.set('status', params.status); + if (params.limit) q.set('limit', String(params.limit)); + return adminFetch<{ messages: WechatMessage[] }>(`/admin-api/wechat/messages?${q}`); +} + +export async function fetchWechatDigests(params: { status?: string; limit?: number } = {}) { + const q = new URLSearchParams(); + if (params.status) q.set('status', params.status); + if (params.limit) q.set('limit', String(params.limit)); + return adminFetch<{ digests: WechatDigestSubscription[] }>(`/admin-api/wechat/digests?${q}`); +} + +export async function fetchWechatDeliveries(params: { status?: string; limit?: number } = {}) { + const q = new URLSearchParams(); + if (params.status) q.set('status', params.status); + if (params.limit) q.set('limit', String(params.limit)); + return adminFetch<{ deliveries: WechatDeliveryLog[] }>(`/admin-api/wechat/deliveries?${q}`); +} + +export async function clearWechatRoute(userId: string) { + return adminFetch<{ ok: boolean; deleted: number; openidMasked: string }>( + `/admin-api/wechat/users/${userId}/route/clear`, + { method: 'POST' }, + ); +} + +export async function cancelWechatDigest(id: string) { + return adminFetch<{ ok: boolean }>(`/admin-api/wechat/digests/${id}/cancel`, { method: 'POST' }); +} + +export async function resumeWechatDigest(id: string) { + return adminFetch<{ ok: boolean; nextRunAt: number }>(`/admin-api/wechat/digests/${id}/resume`, { + method: 'POST', + }); +} + // ─── LLM Providers ─────────────────────────────────────────────────────────── export async function fetchLlmKeys() { diff --git a/ops/src/components/AdminLayout.tsx b/ops/src/components/AdminLayout.tsx index 63c237f..375ab7c 100644 --- a/ops/src/components/AdminLayout.tsx +++ b/ops/src/components/AdminLayout.tsx @@ -5,6 +5,7 @@ const links = [ { to: '/admin/users', label: '用户管理' }, { to: '/admin/llm', label: 'LLM 配置' }, { to: '/admin/billing', label: '账单记录' }, + { to: '/admin/wechat', label: '服务号管理' }, ]; export function AdminLayout() { @@ -12,7 +13,7 @@ export function AdminLayout() {

超级管理后台

-

用户、计费与 LLM 配置

+

用户、计费、LLM 与服务号