diff --git a/.env.example b/.env.example index 7ce631e..5295b38 100644 --- a/.env.example +++ b/.env.example @@ -200,7 +200,8 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # 新增 goosed 调用须走 tkmind-proxy;MindSpace 例外见 goosed-proxy-boundary.mjs allowlist # H5 public HTML 假交付 guard(docs/pending-fixes/h5-public-html-fake-delivery-guard-20260706.md) -# 默认 0:仅 warn;设 1 时 Finish 后检测缺失 public/*.html / assets/* 并自动 session repair(每 session 1 次) +# 生产默认 0:仅 warn;设 1 时 Finish 后检测缺失 public/*.html / assets/* / 页面模板漂移并自动 session repair(每 session 1 次) +# pnpm dev(local / split-service profile)未显式配置时默认 1;可在 .env 设 MEMIND_H5_HTML_FINISH_GUARD=0 关闭 # MEMIND_H5_HTML_FINISH_GUARD=0 # H5 run SSE replay(Patch 4b,仅本地/测试分支;默认关,未合并 main / 未上 103) @@ -403,6 +404,18 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind # MINDSPACE_AGENT_JOBS_ENABLED=true # MINDSPACE_INTERNAL_AGENT_SECRET=local-dev-secret # MINDSPACE_FREE_PUBLIC_PAGE_LIMIT=10 +# MINDSPACE_SEO_GEO_ENABLED=false +# MINDSPACE_SEO_ENABLED=false +# MINDSPACE_SEO_CANONICAL=true +# MINDSPACE_SEO_SITEMAP=false +# MINDSPACE_SEO_ROBOTS_TXT=false +# MINDSPACE_SEO_BAIDU_PUSH=false +# MINDSPACE_GEO_ENABLED=false +# MINDSPACE_GEO_JSONLD=false +# MINDSPACE_GEO_LLMSTXT=false +# MINDSPACE_BAIDU_PUSH_TOKEN= +# MINDSPACE_BAIDU_SITE=m.tkmind.cn +# PLAZA_BAIDU_PUSH_TOKEN= # image_make 独立生图服务。只有 URL、Token 与 memind_adm 资产能力开关同时配置后才会调用。 # IMAGE_MAKE_BASE_URL=http://127.0.0.1:8080 # IMAGE_MAKE_TOKEN=dev-token diff --git a/AGENTS.md b/AGENTS.md index a455ed9..ab737b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ bash scripts/check-release-ready.sh | Page Data 数据集注册、绑定与交付验收 | [docs/regression-guards/page-data-delivery-contract.md](docs/regression-guards/page-data-delivery-contract.md) | | H5 SSE 断线续播、Portal/Goose 游标映射与 Finish 终态恢复 | [docs/regression-guards/h5-session-stream-replay.md](docs/regression-guards/h5-session-stream-replay.md) | | Memory V2 候选表初始化与生命周期灰度作用域 | [docs/regression-guards/memory-v2-candidate-and-lifecycle.md](docs/regression-guards/memory-v2-candidate-and-lifecycle.md) | +| MindSpace SEO/GEO 收录策略与私有页 noindex | [docs/regression-guards/mindspace-seo-geo.md](docs/regression-guards/mindspace-seo-geo.md) | 索引:[docs/regression-guards/README.md](docs/regression-guards/README.md) @@ -61,6 +62,8 @@ npm run verify:mindspace-publish-guards npm run verify:mindspace-publish-guards:full npm run verify:mindspace-page-sync-guards npm run verify:h5-session-patches +npm run verify:seo-geo +npm run verify:seo-discovery ``` 发版脚本(`scripts/release-portal-runtime-prod.sh`)当前在不跳过测试时会执行相关 verify;生产 `103` 发布禁止使用 `--skip-tests`,并且仍须通过完整生产发布守门员。 @@ -75,6 +78,7 @@ npm run verify:h5-session-patches - `mindspace-pages.mjs` - storage 缺失时 HTML 页回退读 workspace `relative_path` - `mindspace-page-sync-service.mjs` + `server.mjs` - remote 模式也必须 sync public HTML - `session-stream.mjs` + `session-stream-store.mjs` + `tkmind-proxy.mjs` - Portal replay ID 不得直接作为 Goose `Last-Event-ID` +- `mindspace-index-policy.mjs` + `mindspace-seo-geo-delivery.mjs` - 私有页必须 noindex;仅 confirmed public 可收录 代码内搜索 `REGRESSION GUARD` 可定位所有内联说明。 diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index 7e519d8..8b9ccb0 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -17,6 +17,10 @@ import { createSubscriptionService, ensurePlanCatalogSchema, } from './billing-subscription.mjs'; +import { + createPageTemplateCatalogService, + ensureTemplateCatalogSchema, +} from './page-template-catalog.mjs'; import { createDbPool, ensureAssetGatewaySchema, isDatabaseConfigured } from './db.mjs'; import { createUserAuth } from './user-auth.mjs'; import { createLlmProviderService } from './llm-providers.mjs'; @@ -74,6 +78,7 @@ export async function createAdminServices(env = {}) { // optional control-plane tables here instead of requiring the public boot path. await ensureAssetGatewaySchema(pool); await ensurePlanCatalogSchema(pool); + await ensureTemplateCatalogSchema(pool); // --- plaza graph (review queue, reports, featured, analytics, creators) --- const plazaRedis = createNoopPlazaRedis(); @@ -112,6 +117,11 @@ export async function createAdminServices(env = {}) { defaultSignupBalanceCents, subscriptionService, }); + const templateCatalogService = createPageTemplateCatalogService(pool, { + userAuth, + h5Root, + }); + await templateCatalogService.ensureReady(); if (env.ensureAdminUser !== false) { await userAuth.ensureAdminUser(); } @@ -195,5 +205,6 @@ export async function createAdminServices(env = {}) { plazaOps, wechatAdmin, subscriptionService, + templateCatalogService, }; } diff --git a/admin-routes.mjs b/admin-routes.mjs index 8250c16..1d46534 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -67,6 +67,7 @@ export function createAdminApi({ wechatAdmin, wechatIntentRouterConfigService, subscriptionService, + templateCatalogService, }) { function requireAdmin(req, res, next) { if (!req.currentUser || req.currentUser.role !== 'admin') { @@ -780,6 +781,31 @@ export function createAdminApi({ res.json(result); }); + adminApi.get('/template-catalog', requireAdmin, async (_req, res) => { + if (!templateCatalogService?.adminListCatalog) { + return res.status(503).json({ message: '模板商城未启用' }); + } + res.json({ items: await templateCatalogService.adminListCatalog() }); + }); + + adminApi.put('/template-catalog/:skillName', requireAdmin, async (req, res) => { + if (!templateCatalogService?.adminUpsertCatalog) { + return res.status(503).json({ message: '模板商城未启用' }); + } + const result = await templateCatalogService.adminUpsertCatalog(req.params.skillName, req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.post('/users/:userId/template-grants/:skillName', requireAdmin, async (req, res) => { + if (!templateCatalogService?.adminGrantTemplate) { + return res.status(503).json({ message: '模板商城未启用' }); + } + const result = await templateCatalogService.adminGrantTemplate(req.params.userId, req.params.skillName); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + adminApi.get('/ledger', requireAdmin, async (req, res) => { const userId = typeof req.query.userId === 'string' ? req.query.userId : null; const { page, pageSize } = req.query; diff --git a/admin-server.mjs b/admin-server.mjs index 713119f..784639b 100644 --- a/admin-server.mjs +++ b/admin-server.mjs @@ -93,6 +93,7 @@ const CONSOLES = { wechatAdmin: services.wechatAdmin, wechatIntentRouterConfigService: services.wechatIntentRouterConfigService, subscriptionService: services.subscriptionService, + templateCatalogService: services.templateCatalogService, }), }, ops: { diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index 486d0ca..1e6ab29 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -8,6 +8,10 @@ import { extractAiderDevelopmentTask, isPageDataIntent, } from './chat-skills.mjs'; +import { + buildPageTemplateMandatoryInstruction, + isPageTemplateSkill, +} from './page-template-enforcement.mjs'; import { createSessionAccess } from './session-broker.mjs'; import { extractRunFromStreamEvent, @@ -100,6 +104,44 @@ function rewriteAiderPageDataInstruction(userMessage, taskText) { return { ...userMessage, content }; } +export function enforcePageTemplateRuntime(userMessage, { h5Root = process.cwd() } = {}) { + const skillName = selectedChatSkill(userMessage); + if (!isPageTemplateSkill(skillName)) { + return { userMessage, pageTemplateSkill: null }; + } + const message = userMessage && typeof userMessage === 'object' && !Array.isArray(userMessage) + ? { ...userMessage } + : { value: userMessage }; + const metadata = message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata) + ? { ...message.metadata } + : {}; + const runMetadata = metadata.memindRun && typeof metadata.memindRun === 'object' && !Array.isArray(metadata.memindRun) + ? { ...metadata.memindRun } + : {}; + metadata.memindRun = { + ...runMetadata, + selectedChatSkill: skillName, + pageTemplateSkill: skillName, + pageTemplateStrict: true, + }; + const mandatory = buildPageTemplateMandatoryInstruction(skillName, h5Root); + const content = Array.isArray(message.content) + ? message.content.map((item) => { + if (item?.type !== 'text') return item; + const text = String(item.text ?? '').trim(); + if (!text || text.startsWith('【强制页面模板:')) return item; + return { + ...item, + text: `${mandatory}\n\n${text}`, + }; + }) + : message.content; + return { + userMessage: { ...message, metadata, content }, + pageTemplateSkill: skillName, + }; +} + export function enforceSelectedSkillRuntime(userMessage, { rawToolMode = 'chat', taskType = null, @@ -145,6 +187,7 @@ export function createPostAgentRunsHandler({ codeRunPolicyService = null, goalRunService = null, chatIntentRouter = null, + templateCatalogService = null, env = process.env, codeRunsEnabled = envFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED), codeRunUserIds = parseUserIdSet(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS), @@ -196,6 +239,13 @@ export function createPostAgentRunsHandler({ response.status(400).json({ message: '缺少 user_message' }); return; } + const pageTemplateRuntime = enforcePageTemplateRuntime(userMessage, { h5Root: process.cwd() }); + userMessage = pageTemplateRuntime.userMessage; + if (pageTemplateRuntime.pageTemplateSkill && templateCatalogService?.recordTemplateUsage) { + void templateCatalogService + .recordTemplateUsage(request.currentUser.id, pageTemplateRuntime.pageTemplateSkill) + .catch(() => {}); + } const selectedSkillRuntime = enforceSelectedSkillRuntime(userMessage, { rawToolMode, taskType, diff --git a/billing-recharge.mjs b/billing-recharge.mjs index ad38956..7167864 100644 --- a/billing-recharge.mjs +++ b/billing-recharge.mjs @@ -50,10 +50,15 @@ function mapOrderRow(row) { createdAt: Number(row.created_at), codeUrl: row.code_url ?? null, h5Url: row.h5_url ?? null, + orderPurpose: row.order_purpose ?? 'recharge', + orderPurposeRef: row.order_purpose_ref ?? null, }; } -export function createRechargeService(pool, { userAuth, wechatPay, config = loadRechargeConfig() } = {}) { +export function createRechargeService( + pool, + { userAuth, wechatPay, templateCatalogService = null, config = loadRechargeConfig() } = {}, +) { const expireStaleOrders = async (userId) => { const now = Date.now(); await pool.query( @@ -275,6 +280,37 @@ export function createRechargeService(pool, { userAuth, wechatPay, config = load return { ok: false, message: '订单状态不可支付' }; } + if (locked.orderPurpose === 'template_purchase') { + if (!templateCatalogService?.fulfillWechatPurchaseLocked) { + await conn.rollback(); + return { ok: false, message: '模板购买服务未启用' }; + } + const templateResult = await templateCatalogService.fulfillWechatPurchaseLocked({ + conn, + order: locked, + transaction, + now, + }); + if (!templateResult.ok) { + await conn.rollback(); + return templateResult; + } + await conn.query( + `UPDATE h5_payment_orders + SET status = 'paid', provider_txn = ?, paid_at = ?, updated_at = ? + WHERE id = ?`, + [transaction.transaction_id ?? transaction.out_trade_no ?? null, now, now, locked.id], + ); + await conn.commit(); + return { + ok: true, + user: await userAuth.getUserById(locked.userId), + balanceCents: templateResult.balanceCents, + grantedSkills: templateResult.grantedSkills, + skillName: templateResult.skillName, + }; + } + const recharge = await userAuth.recharge(locked.userId, locked.amountCents, null, '用户自助充值', { paymentOrderId: locked.id, conn, diff --git a/billing-recharge.test.mjs b/billing-recharge.test.mjs index fc80fdd..f571f40 100644 --- a/billing-recharge.test.mjs +++ b/billing-recharge.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { buildInsufficientBalancePayload, + createRechargeService, isAllowedRechargeAmount, loadRechargeConfig, } from './billing-recharge.mjs'; @@ -32,3 +33,83 @@ test('buildInsufficientBalancePayload includes recharge hints', () => { assert.equal(payload.balanceCents, 0); assert.deepEqual(payload.suggestedTiers, [500, 1000]); }); + +test('fulfillOrder routes template_purchase to template catalog service', async () => { + const orderRow = { + id: 'order-tpl-1', + user_id: 'user-1', + amount_cents: 990, + channel: 'wechat', + status: 'pending', + pay_mode: 'native', + out_trade_no: 'TPTEST001', + provider_txn: null, + paid_at: null, + expire_at: Date.now() + 60_000, + created_at: Date.now(), + code_url: null, + h5_url: null, + order_purpose: 'template_purchase', + order_purpose_ref: 'page-template-travel', + }; + const pool = { + async getConnection() { + const conn = { + async beginTransaction() {}, + async commit() {}, + async rollback() {}, + async release() {}, + async query(sql, params = []) { + const text = String(sql); + if (text.includes('FROM h5_payment_orders WHERE id = ? FOR UPDATE')) { + return [[orderRow], []]; + } + if (text.includes('UPDATE h5_payment_orders') && text.includes("status = 'paid'")) { + orderRow.status = 'paid'; + return [{ affectedRows: 1 }, []]; + } + throw new Error(`Unhandled SQL: ${text.slice(0, 100)}`); + }, + }; + return conn; + }, + }; + let templateFulfillArgs = null; + const templateCatalogService = { + async fulfillWechatPurchaseLocked(args) { + templateFulfillArgs = args; + return { + ok: true, + skillName: 'page-template-travel', + grantedSkills: ['page-template-travel'], + balanceCents: 1200, + }; + }, + }; + const userAuth = { + async getUserById(userId) { + return { id: userId, balance_cents: 1200 }; + }, + async recharge() { + throw new Error('recharge should not run for template_purchase'); + }, + }; + const service = createRechargeService(pool, { userAuth, templateCatalogService }); + const result = await service.fulfillOrder( + { + id: orderRow.id, + userId: orderRow.user_id, + amountCents: orderRow.amount_cents, + status: 'pending', + orderPurpose: 'template_purchase', + orderPurposeRef: 'page-template-travel', + }, + { trade_state: 'SUCCESS', amount: { total: 990 }, transaction_id: 'wx-txn-1' }, + ); + assert.equal(result.ok, true); + assert.equal(result.skillName, 'page-template-travel'); + assert.deepEqual(result.grantedSkills, ['page-template-travel']); + assert.equal(result.balanceCents, 1200); + assert.ok(templateFulfillArgs); + assert.equal(orderRow.status, 'paid'); +}); diff --git a/chat-skills.mjs b/chat-skills.mjs index e9885b6..a3da0d3 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -1,12 +1,31 @@ // Keep browser-safe: do not import user-publish.mjs (uses node:fs/path/url). import { shouldUseScheduledTaskAutomation } from './scheduled-task-intent.mjs'; +const PAGE_TEMPLATE_PROMPT_SUFFIX = + '【硬性要求】必须先 load_skill 并 read_file 官方 templates/*.html,仅替换 {{PLACEHOLDER}};' + + '禁止修改 + +
+{{HERO_SUBTITLE}}
+