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, }; }