Files
memind/page-template-catalog.mjs
T
john fde6503bdf
Memind CI / Test, build, and release guards (push) Has been cancelled
feat(mindspace): add SEO/GEO delivery, page template catalog, and admin hooks
Enable optional SEO/GEO injection and discovery routes for confirmed public pages while keeping private pages noindex. Add premium page template skills, portal catalog API, template shop UI, and Baidu push gated by memind_adm config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 08:06:12 +08:00

683 lines
24 KiB
JavaScript

import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { loadRechargeConfig } from './billing-recharge.mjs';
import {
isPageTemplateSkill,
listPlatformSkillCatalog,
} from './skills-registry.mjs';
import { TEMPLATE_PREVIEW_DEMO } from './page-template-preview-demos.mjs';
function resolveTemplateHtmlPath(skillName, h5Root = process.cwd()) {
const templatesDir = path.join(h5Root, 'skills', skillName, 'templates');
if (!fs.existsSync(templatesDir)) return null;
const files = fs
.readdirSync(templatesDir)
.filter((name) => name.endsWith('.html'))
.sort();
if (!files.length) return null;
return path.join(templatesDir, files[0]);
}
export function buildTemplatePreviewHtml(skillName, h5Root = process.cwd()) {
if (!isPageTemplateSkill(skillName)) {
return { ok: false, message: '无效的页面模板 skill' };
}
const templatePath = resolveTemplateHtmlPath(skillName, h5Root);
if (!templatePath) {
return { ok: false, message: '模板 HTML 不存在' };
}
const demo = TEMPLATE_PREVIEW_DEMO[skillName] ?? {};
let html = fs.readFileSync(templatePath, 'utf8');
for (const [key, value] of Object.entries(demo)) {
html = html.replaceAll(`{{${key}}}`, String(value));
}
html = html.replace(/\{\{[A-Z0-9_]+\}\}/g, '');
return { ok: true, html, skillName, source: 'demo-fill' };
}
function createOutTradeNo(prefix = 'TK') {
const stamp = Date.now().toString(36).toUpperCase();
const rand = crypto.randomBytes(4).toString('hex').toUpperCase();
return `${prefix}${stamp}${rand}`.slice(0, 32);
}
export async function ensurePaymentOrderPurposeColumns(pool) {
for (const statement of [
"ALTER TABLE h5_payment_orders ADD COLUMN order_purpose ENUM('recharge','template_purchase') NOT NULL DEFAULT 'recharge'",
'ALTER TABLE h5_payment_orders ADD COLUMN order_purpose_ref VARCHAR(64) NULL',
]) {
try {
await pool.query(statement);
} catch {
/* column exists */
}
}
}
export function mapTemplateCatalogRow(row) {
if (!row) return null;
return {
skillName: row.skill_name,
label: row.label,
description: row.description ?? '',
previewUrl: row.preview_url ?? null,
priceCents: Number(row.price_cents ?? 0),
currency: row.currency ?? 'CNY',
billingMode: row.billing_mode ?? 'one_time',
status: row.status ?? 'active',
sortOrder: Number(row.sort_order ?? 0),
chatSkillId: row.skill_name,
usageCount: Number(row.usage_count ?? 0),
favoriteCount: Number(row.favorite_count ?? 0),
};
}
function mapPurchaseRow(row) {
if (!row) return null;
return {
id: row.id,
userId: row.user_id,
skillName: row.skill_name,
orderId: row.order_id,
source: row.source,
purchasedAt: Number(row.purchased_at),
expiresAt: row.expires_at == null ? null : Number(row.expires_at),
};
}
function catalogDefaultsFromSkill(item) {
const catalog = item?.manifest?.catalog ?? {};
return {
label: catalog.label || item.label || item.name,
description: catalog.description || item.description || '',
previewUrl: catalog.previewImage ?? catalog.previewUrl ?? null,
priceCents: Number(catalog.priceCents ?? 0),
billingMode: catalog.billingMode ?? 'one_time',
sortOrder: Number(catalog.sortOrder ?? 0),
};
}
export async function ensureTemplateCatalogSchema(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_template_catalog (
skill_name VARCHAR(64) PRIMARY KEY,
label VARCHAR(128) NOT NULL,
description TEXT,
preview_url VARCHAR(512) NULL,
price_cents INT NOT NULL DEFAULT 0,
currency VARCHAR(8) NOT NULL DEFAULT 'CNY',
billing_mode ENUM('free','one_time','subscription') NOT NULL DEFAULT 'one_time',
status ENUM('draft','active','archived') NOT NULL DEFAULT 'draft',
sort_order INT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_template_purchases (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
skill_name VARCHAR(64) NOT NULL,
order_id VARCHAR(64) NULL,
source ENUM('purchase','admin_grant','promo') NOT NULL,
purchased_at BIGINT NOT NULL,
expires_at BIGINT NULL,
UNIQUE KEY uq_user_template (user_id, skill_name),
KEY idx_template_user (user_id),
CONSTRAINT fk_template_purchase_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_template_favorites (
user_id CHAR(36) NOT NULL,
skill_name VARCHAR(64) NOT NULL,
created_at BIGINT NOT NULL,
PRIMARY KEY (user_id, skill_name),
KEY idx_template_fav_skill (skill_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_template_user_usage (
user_id CHAR(36) NOT NULL,
skill_name VARCHAR(64) NOT NULL,
usage_count INT NOT NULL DEFAULT 0,
updated_at BIGINT NOT NULL,
PRIMARY KEY (user_id, skill_name),
KEY idx_template_usage_skill (skill_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
for (const statement of [
'ALTER TABLE h5_template_catalog ADD COLUMN usage_count INT NOT NULL DEFAULT 0',
'ALTER TABLE h5_template_catalog ADD COLUMN favorite_count INT NOT NULL DEFAULT 0',
]) {
try {
await pool.query(statement);
} catch {
/* column exists */
}
}
await ensurePaymentOrderPurposeColumns(pool);
}
export async function seedTemplateCatalogFromSkills(pool, h5Root = process.cwd()) {
const catalog = listPlatformSkillCatalog(h5Root).filter((item) => isPageTemplateSkill(item.name));
const now = Date.now();
for (const item of catalog) {
const defaults = catalogDefaultsFromSkill(item);
await pool.query(
`INSERT INTO h5_template_catalog
(skill_name, label, description, preview_url, price_cents, currency, billing_mode, status, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'CNY', ?, 'active', ?, ?, ?)
ON DUPLICATE KEY UPDATE
label = IF(label = '' OR label IS NULL, VALUES(label), label),
description = IF(description IS NULL OR description = '', VALUES(description), description),
preview_url = COALESCE(preview_url, VALUES(preview_url)),
updated_at = VALUES(updated_at)`,
[
item.name,
defaults.label,
defaults.description,
defaults.previewUrl,
defaults.priceCents,
defaults.billingMode === 'free' ? 'free' : defaults.billingMode,
defaults.sortOrder,
now,
now,
],
);
}
}
export function createPageTemplateCatalogService(
pool,
{ userAuth, h5Root = process.cwd(), wechatPay = null, rechargeConfig = loadRechargeConfig() } = {},
) {
if (!pool || !userAuth) {
throw new Error('createPageTemplateCatalogService requires pool and userAuth');
}
const listActiveCatalogRows = async () => {
const [rows] = await pool.query(
`SELECT * FROM h5_template_catalog
WHERE status = 'active'
ORDER BY sort_order ASC, label ASC`,
);
return rows;
};
const getCatalogItem = async (skillName) => {
const [rows] = await pool.query(
`SELECT * FROM h5_template_catalog WHERE skill_name = ? LIMIT 1`,
[skillName],
);
return mapTemplateCatalogRow(rows[0]);
};
const listOwnedSkillNames = async (userId) => {
const now = Date.now();
const [rows] = await pool.query(
`SELECT skill_name
FROM h5_template_purchases
WHERE user_id = ?
AND (expires_at IS NULL OR expires_at > ?)`,
[userId, now],
);
return rows.map((row) => row.skill_name);
};
const hasValidPurchase = async (userId, skillName) => {
const now = Date.now();
const [rows] = await pool.query(
`SELECT id FROM h5_template_purchases
WHERE user_id = ? AND skill_name = ?
AND (expires_at IS NULL OR expires_at > ?)
LIMIT 1`,
[userId, skillName, now],
);
return Boolean(rows[0]);
};
const attachUserStats = async (userId, items) => {
if (!userId || !items.length) {
return items.map((item) => ({ ...item, favorited: false, myUsageCount: 0 }));
}
const skillNames = items.map((item) => item.skillName);
const placeholders = skillNames.map(() => '?').join(',');
const [favoriteRows] = await pool.query(
`SELECT skill_name FROM h5_template_favorites
WHERE user_id = ? AND skill_name IN (${placeholders})`,
[userId, ...skillNames],
);
const favoriteSet = new Set(favoriteRows.map((row) => row.skill_name));
const [usageRows] = await pool.query(
`SELECT skill_name, usage_count FROM h5_template_user_usage
WHERE user_id = ? AND skill_name IN (${placeholders})`,
[userId, ...skillNames],
);
const usageMap = new Map(
usageRows.map((row) => [row.skill_name, Number(row.usage_count ?? 0)]),
);
return items.map((item) => ({
...item,
favorited: favoriteSet.has(item.skillName),
myUsageCount: usageMap.get(item.skillName) ?? 0,
}));
};
const listCatalogForUser = async (userId) => {
const rows = await listActiveCatalogRows();
const owned = new Set(await listOwnedSkillNames(userId));
let grantedSet = new Set();
if (userId) {
const skillState = await userAuth.getUserSkills(userId);
if (skillState.ok) {
grantedSet = new Set(skillState.grantedSkills ?? []);
}
}
const items = rows.map((row) => {
const item = mapTemplateCatalogRow(row);
const granted = grantedSet.has(item.skillName);
return {
...item,
owned: owned.has(item.skillName) || granted,
granted,
};
});
return attachUserStats(userId, items);
};
const listMine = async (userId) => {
const items = await listCatalogForUser(userId);
return items.filter((item) => item.owned);
};
const recordTemplateUsage = async (userId, skillName) => {
if (!userId || !isPageTemplateSkill(skillName)) {
return { ok: false, message: '无效参数' };
}
const item = await getCatalogItem(skillName);
if (!item || item.status !== 'active') {
return { ok: false, message: '模板不存在' };
}
const now = Date.now();
await pool.query(
`UPDATE h5_template_catalog SET usage_count = usage_count + 1, updated_at = ? WHERE skill_name = ?`,
[now, skillName],
);
await pool.query(
`INSERT INTO h5_template_user_usage (user_id, skill_name, usage_count, updated_at)
VALUES (?, ?, 1, ?)
ON DUPLICATE KEY UPDATE usage_count = usage_count + 1, updated_at = VALUES(updated_at)`,
[userId, skillName, now],
);
return { ok: true, skillName };
};
const toggleTemplateFavorite = async (userId, skillName) => {
if (!userId || !isPageTemplateSkill(skillName)) {
return { ok: false, message: '无效参数' };
}
const item = await getCatalogItem(skillName);
if (!item || item.status !== 'active') {
return { ok: false, message: '模板不存在' };
}
const now = Date.now();
const [existing] = await pool.query(
`SELECT skill_name FROM h5_template_favorites WHERE user_id = ? AND skill_name = ? LIMIT 1`,
[userId, skillName],
);
if (existing[0]) {
await pool.query(
`DELETE FROM h5_template_favorites WHERE user_id = ? AND skill_name = ?`,
[userId, skillName],
);
await pool.query(
`UPDATE h5_template_catalog
SET favorite_count = GREATEST(0, favorite_count - 1), updated_at = ?
WHERE skill_name = ?`,
[now, skillName],
);
const updated = await getCatalogItem(skillName);
return { ok: true, favorited: false, favoriteCount: updated?.favoriteCount ?? 0 };
}
await pool.query(
`INSERT INTO h5_template_favorites (user_id, skill_name, created_at) VALUES (?, ?, ?)`,
[userId, skillName, now],
);
await pool.query(
`UPDATE h5_template_catalog SET favorite_count = favorite_count + 1, updated_at = ? WHERE skill_name = ?`,
[now, skillName],
);
const updated = await getCatalogItem(skillName);
return { ok: true, favorited: true, favoriteCount: updated?.favoriteCount ?? 0 };
};
const grantTemplateAccess = async ({ userId, skillName, source, orderId = null, expiresAt = null }) => {
const now = Date.now();
await pool.query(
`INSERT INTO h5_template_purchases
(id, user_id, skill_name, order_id, source, purchased_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
order_id = COALESCE(VALUES(order_id), order_id),
source = VALUES(source),
purchased_at = VALUES(purchased_at),
expires_at = VALUES(expires_at)`,
[crypto.randomUUID(), userId, skillName, orderId, source, now, expiresAt],
);
const result = await userAuth.setUserSkills(userId, { [skillName]: true });
if (!result.ok) {
return result;
}
return { ok: true, skillName, grantedSkills: result.grantedSkills ?? [] };
};
const purchaseWithBalance = async (userId, skillName) => {
const item = await getCatalogItem(skillName);
if (!item) return { ok: false, message: '模板不存在' };
if (item.status !== 'active') return { ok: false, message: '模板未上架' };
if (!isPageTemplateSkill(skillName)) {
return { ok: false, message: '无效的页面模板 skill' };
}
if (await hasValidPurchase(userId, skillName)) {
await userAuth.setUserSkills(userId, { [skillName]: true });
const user = await userAuth.getUserById(userId);
return {
ok: true,
alreadyOwned: true,
skillName,
balanceCents: Number(user?.balance_cents ?? 0),
};
}
const priceCents = Number(item.priceCents ?? 0);
if (priceCents <= 0 || item.billingMode === 'free') {
return grantTemplateAccess({ userId, skillName, source: 'promo' });
}
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const now = Date.now();
const [walletRows] = await conn.query(
`SELECT balance_cents FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`,
[userId],
);
const balanceCents = Number(walletRows[0]?.balance_cents ?? 0);
if (balanceCents < priceCents) {
await conn.rollback();
return {
ok: false,
code: 'INSUFFICIENT_BALANCE',
message: '余额不足,请先充值后再购买模板',
balanceCents,
minRechargeCents: Math.max(500, priceCents - balanceCents),
suggestedTiers: loadRechargeConfig().tiersCents,
};
}
await conn.query(
`UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`,
[priceCents, now, userId],
);
const orderId = `tpl-${skillName}-${now}`;
await conn.query(
`INSERT INTO h5_template_purchases
(id, user_id, skill_name, order_id, source, purchased_at, expires_at)
VALUES (?, ?, ?, ?, 'purchase', ?, NULL)`,
[crypto.randomUUID(), userId, skillName, orderId, now],
);
await conn.query(
`INSERT INTO h5_billing_ledger
(user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`,
[userId, priceCents, `template_purchase:${skillName}`, now],
);
await conn.commit();
const grant = await userAuth.setUserSkills(userId, { [skillName]: true });
const user = await userAuth.getUserById(userId);
return {
ok: true,
skillName,
priceCents,
orderId,
balanceCents: Number(user?.balance_cents ?? Math.max(0, balanceCents - priceCents)),
grantedSkills: grant.grantedSkills ?? [],
};
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
};
const adminListCatalog = async () => {
const [rows] = await pool.query(
`SELECT * FROM h5_template_catalog ORDER BY sort_order ASC, label ASC`,
);
return rows.map((row) => mapTemplateCatalogRow(row));
};
const adminUpsertCatalog = async (skillName, patch = {}) => {
if (!isPageTemplateSkill(skillName)) {
return { ok: false, message: '无效的页面模板 skill' };
}
const existing = await getCatalogItem(skillName);
const now = Date.now();
const label = patch.label ?? existing?.label ?? skillName;
const description = patch.description ?? existing?.description ?? '';
const previewUrl = patch.previewUrl ?? existing?.previewUrl ?? null;
const priceCents = Number(patch.priceCents ?? existing?.priceCents ?? 0);
const billingMode = patch.billingMode ?? existing?.billingMode ?? 'one_time';
const status = patch.status ?? existing?.status ?? 'active';
const sortOrder = Number(patch.sortOrder ?? existing?.sortOrder ?? 0);
await pool.query(
`INSERT INTO h5_template_catalog
(skill_name, label, description, preview_url, price_cents, currency, billing_mode, status, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'CNY', ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
label = VALUES(label),
description = VALUES(description),
preview_url = VALUES(preview_url),
price_cents = VALUES(price_cents),
billing_mode = VALUES(billing_mode),
status = VALUES(status),
sort_order = VALUES(sort_order),
updated_at = VALUES(updated_at)`,
[skillName, label, description, previewUrl, priceCents, billingMode, status, sortOrder, now, now],
);
return { ok: true, item: await getCatalogItem(skillName) };
};
const adminGrantTemplate = async (userId, skillName) => {
const item = await getCatalogItem(skillName);
if (!item) return { ok: false, message: '模板不存在' };
return grantTemplateAccess({ userId, skillName, source: 'admin_grant' });
};
const createWechatCheckout = async ({ userId, skillName, payScene, clientIp }) => {
const item = await getCatalogItem(skillName);
if (!item) return { ok: false, message: '模板不存在' };
if (item.status !== 'active') return { ok: false, message: '模板未上架' };
if (!isPageTemplateSkill(skillName)) {
return { ok: false, message: '无效的页面模板 skill' };
}
if (await hasValidPurchase(userId, skillName)) {
await userAuth.setUserSkills(userId, { [skillName]: true });
return { ok: false, message: '您已拥有该模板' };
}
const priceCents = Number(item.priceCents ?? 0);
if (priceCents <= 0) {
return grantTemplateAccess({ userId, skillName, source: 'promo' });
}
if (!wechatPay?.enabled) {
return { ok: false, message: '微信支付尚未配置,请使用余额购买或联系管理员' };
}
const user = await userAuth.getUserById(userId);
if (!user) return { ok: false, message: '用户不存在' };
if (user.status === 'disabled') return { ok: false, message: '账户已禁用' };
const now = Date.now();
const orderId = crypto.randomUUID();
const outTradeNo = createOutTradeNo('TP');
const expireAt = now + rechargeConfig.orderTtlMs;
const description = `TKMind页面模板-${item.label}`;
const mode = payScene === 'jsapi' ? 'jsapi' : payScene === 'h5' ? 'h5' : 'native';
let codeUrl = null;
let h5Url = null;
let jsapiParams = null;
try {
if (mode === 'jsapi') {
const appId = wechatPay.appId;
if (!appId) return { ok: false, message: '微信支付 AppID 未配置' };
const openid = await userAuth.getWechatOpenidForUser(userId, appId);
if (!openid) {
return { ok: false, message: '请先用微信登录并绑定账号后再购买' };
}
const result = await wechatPay.createJsapiOrder({
outTradeNo,
description,
amountCents: priceCents,
clientIp,
openid,
});
jsapiParams = result.jsapiParams;
} else if (mode === 'h5') {
const result = await wechatPay.createH5Order({
outTradeNo,
description,
amountCents: priceCents,
clientIp,
});
h5Url = result.h5Url;
} else {
const result = await wechatPay.createNativeOrder({
outTradeNo,
description,
amountCents: priceCents,
clientIp,
});
codeUrl = result.codeUrl;
}
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : '创建微信支付订单失败',
};
}
await pool.query(
`INSERT INTO h5_payment_orders
(id, user_id, amount_cents, channel, status, pay_mode, out_trade_no,
code_url, h5_url, expire_at, client_ip, order_purpose, order_purpose_ref, created_at, updated_at)
VALUES (?, ?, ?, 'wechat', 'pending', ?, ?, ?, ?, ?, ?, 'template_purchase', ?, ?, ?)`,
[
orderId,
userId,
priceCents,
mode,
outTradeNo,
codeUrl,
h5Url,
expireAt,
clientIp ?? null,
skillName,
now,
now,
],
);
return {
ok: true,
order: {
id: orderId,
amountCents: priceCents,
status: 'pending',
payMode: mode,
expireAt,
codeUrl,
h5Url,
jsapiParams,
skillName,
label: item.label,
},
};
};
const fulfillWechatPurchaseLocked = async ({ conn, order, transaction, now = Date.now() }) => {
const skillName = order.orderPurposeRef;
if (!skillName || !isPageTemplateSkill(skillName)) {
return { ok: false, message: '模板订单缺少 skill 引用' };
}
const item = await getCatalogItem(skillName);
if (!item) return { ok: false, message: '模板不存在' };
await conn.query(
`INSERT INTO h5_template_purchases
(id, user_id, skill_name, order_id, source, purchased_at, expires_at)
VALUES (?, ?, ?, ?, 'purchase', ?, NULL)
ON DUPLICATE KEY UPDATE
order_id = VALUES(order_id),
source = VALUES(source),
purchased_at = VALUES(purchased_at)`,
[crypto.randomUUID(), order.userId, skillName, order.id, now],
);
await conn.query(
`INSERT INTO h5_billing_ledger
(user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`,
[order.userId, order.amountCents, `template_purchase_wechat:${skillName}`, now],
);
const grant = await userAuth.setUserSkills(order.userId, { [skillName]: true });
if (!grant.ok) {
return grant;
}
const user = await userAuth.getUserById(order.userId);
return {
ok: true,
skillName,
grantedSkills: grant.grantedSkills ?? [],
balanceCents: Number(user?.balance_cents ?? 0),
};
};
const getTemplatePreviewHtml = async (skillName) => {
const item = await getCatalogItem(skillName);
if (!item) return { ok: false, message: '模板不存在' };
if (item.status !== 'active') return { ok: false, message: '模板未上架' };
return buildTemplatePreviewHtml(skillName, h5Root);
};
return {
ensureReady: async () => {
await ensureTemplateCatalogSchema(pool);
await seedTemplateCatalogFromSkills(pool, h5Root);
},
listCatalogForUser,
listMine,
getCatalogItem,
purchaseWithBalance,
createWechatCheckout,
fulfillWechatPurchaseLocked,
getTemplatePreviewHtml,
recordTemplateUsage,
toggleTemplateFavorite,
adminListCatalog,
adminUpsertCatalog,
adminGrantTemplate,
listOwnedSkillNames,
mapPurchaseRow,
};
}