Files
memind/page-template-catalog.mjs
T

683 lines
24 KiB
JavaScript
Raw Normal View History

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