683 lines
24 KiB
JavaScript
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,
|
||
|
|
};
|
||
|
|
}
|