import crypto from 'node:crypto'; // Hardcoded fallback catalog used when h5_plan_catalog table is not yet seeded. // priceCents: monthly price in CNY cents (0 = free) // periodTokens: token allowance per period (0 = unlimited) // periodImages: image generation quota per period (0 = unlimited) // modelTier: 'basic' | 'standard' | 'premium' // overageRate: cost multiplier when quota exhausted (1.0 = no discount) // periodDays: rolling period length in days export const PLAN_CATALOG = { free: { name: '免费版', priceCents: 0, periodTokens: 450_000, periodImages: 10, modelTier: 'basic', overageRate: 1.00, periodDays: 30, }, lite: { name: '轻量版', priceCents: 990, periodTokens: 3_600_000, periodImages: 50, modelTier: 'basic', overageRate: 0.50, periodDays: 30, }, standard: { name: '标准版', priceCents: 2900, periodTokens: 13_500_000, periodImages: 200, modelTier: 'standard', overageRate: 0.70, periodDays: 30, }, pro: { name: '专业版', priceCents: 7900, periodTokens: 0, periodImages: 0, modelTier: 'premium', overageRate: 0.80, periodDays: 30, }, }; const PLAN_TOKEN_ALLOWANCE_MIGRATIONS = [ ['free', 150_000, PLAN_CATALOG.free.periodTokens], ['lite', 1_200_000, PLAN_CATALOG.lite.periodTokens], ['standard', 4_500_000, PLAN_CATALOG.standard.periodTokens], ]; export function getPlanDef(planType) { return PLAN_CATALOG[planType] ?? null; } // Tier order: higher index = higher plan. Used for upgrade/downgrade checks. const PLAN_ORDER = Object.fromEntries( Object.keys(PLAN_CATALOG).map((key, i) => [key, i]), ); // How many "typical calls" a token budget represents (for display purposes). const AVG_TOKENS_PER_CALL = 3000; export function tokensToCallsApprox(tokens) { if (tokens === 0) return null; // unlimited return Math.floor(tokens / AVG_TOKENS_PER_CALL); } function mapSubRow(row) { if (!row) return null; return { id: row.id, userId: row.user_id, planType: row.plan_type, status: row.status, periodTokensLimit: Number(row.period_tokens_limit ?? 0), periodTokensUsed: Number(row.period_tokens_used ?? 0), periodImagesLimit: Number(row.period_images_limit ?? 0), periodImagesUsed: Number(row.period_images_used ?? 0), periodStart: Number(row.period_start), periodEnd: Number(row.period_end), expiresAt: Number(row.expires_at), overageRate: Number(row.overage_rate ?? 1.0), autoRenew: Boolean(row.auto_renew), operatorId: row.operator_id ?? null, note: row.note ?? null, createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), }; } export function createSubscriptionService(pool, { getPlanAsync = null } = {}) { // Resolve plan definition: prefer DB-backed loader, fall back to hardcoded catalog. const resolvePlan = async (planType) => { if (getPlanAsync) { const p = await getPlanAsync(planType); if (p) return p; } const p = getPlanDef(planType); return p ? { ...p, periodImages: p.periodImages ?? 0 } : null; }; const getActiveSubscription = async (userId) => { const now = Date.now(); const [rows] = await pool.query( `SELECT * FROM h5_subscriptions WHERE user_id = ? AND status = 'active' AND expires_at > ? ORDER BY expires_at DESC LIMIT 1`, [userId, now], ); return mapSubRow(rows[0]); }; // Grant a new subscription (admin or system action). Cancels the current active one. const grantSubscription = async (userId, planType, durationDays, operatorId = null, note = '') => { const plan = await resolvePlan(planType); if (!plan) return { ok: false, message: `未知套餐类型: ${planType}` }; const now = Date.now(); const periodDays = durationDays ?? plan.periodDays; const periodEnd = now + periodDays * 24 * 60 * 60 * 1000; const conn = await pool.getConnection(); try { await conn.beginTransaction(); // Cancel any existing active subscription await conn.query( `UPDATE h5_subscriptions SET status = 'cancelled', updated_at = ? WHERE user_id = ? AND status = 'active'`, [now, userId], ); const id = crypto.randomUUID(); await conn.query( `INSERT INTO h5_subscriptions (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, period_images_limit, period_images_used, period_start, period_end, expires_at, overage_rate, auto_renew, operator_id, note, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, [ id, userId, planType, plan.periodTokens, plan.periodImages ?? 0, now, periodEnd, periodEnd, Number(plan.overageRate.toFixed(2)), operatorId, note || null, now, now, ], ); await conn.query( `UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`, [planType, now, userId], ); await conn.commit(); const [rows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [id]); return { ok: true, subscription: mapSubRow(rows[0]) }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } }; // Called inside an existing transaction (conn already in transaction). // Deducts deltaTokens from the active subscription quota if available. // Returns { fullyCovers: bool, overageRate: number } const consumeQuota = async (userId, deltaTokens, conn) => { if (!deltaTokens || deltaTokens <= 0) { return { fullyCovers: true, overageRate: 1.0 }; } const now = Date.now(); const [rows] = await conn.query( `SELECT * FROM h5_subscriptions WHERE user_id = ? AND status = 'active' AND expires_at > ? ORDER BY expires_at DESC LIMIT 1 FOR UPDATE`, [userId, now], ); const sub = mapSubRow(rows[0]); if (!sub) return { fullyCovers: false, overageRate: 1.0 }; const unlimited = sub.periodTokensLimit === 0; const remaining = unlimited ? Infinity : sub.periodTokensLimit - sub.periodTokensUsed; if (unlimited || remaining >= deltaTokens) { await conn.query( `UPDATE h5_subscriptions SET period_tokens_used = period_tokens_used + ?, updated_at = ? WHERE id = ?`, [deltaTokens, now, sub.id], ); return { fullyCovers: true, overageRate: sub.overageRate }; } // Partially exhausted — consume remainder, overage applies to whole request for simplicity if (remaining > 0) { await conn.query( `UPDATE h5_subscriptions SET period_tokens_used = period_tokens_limit, updated_at = ? WHERE id = ?`, [now, sub.id], ); } return { fullyCovers: false, overageRate: sub.overageRate }; }; // Renew an existing subscription for another period (same plan). // Used by a scheduled worker or manual admin action. const renewSubscription = async (subId, operatorId = null) => { const [rows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [subId]); const sub = mapSubRow(rows[0]); if (!sub) return { ok: false, message: '订阅不存在' }; const plan = await resolvePlan(sub.planType); if (!plan) return { ok: false, message: '套餐定义已失效' }; const now = Date.now(); const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1000; const conn = await pool.getConnection(); try { await conn.beginTransaction(); const newId = crypto.randomUUID(); await conn.query( `UPDATE h5_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`, [now, subId], ); await conn.query( `INSERT INTO h5_subscriptions (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, period_images_limit, period_images_used, period_start, period_end, expires_at, overage_rate, auto_renew, operator_id, note, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ newId, sub.userId, sub.planType, plan.periodTokens, plan.periodImages ?? 0, now, newPeriodEnd, newPeriodEnd, Number(plan.overageRate.toFixed(2)), sub.autoRenew ? 1 : 0, operatorId, '自动续订', now, now, ], ); await conn.commit(); const [newRows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [newId]); return { ok: true, subscription: mapSubRow(newRows[0]) }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } }; // Mark expired subscriptions and reset plan_type to 'free'. // Call periodically from a worker. const expireStaleSubscriptions = async () => { const now = Date.now(); const [result] = await pool.query( `UPDATE h5_subscriptions SET status = 'expired', updated_at = ? WHERE status = 'active' AND expires_at <= ?`, [now, now], ); if (result.affectedRows > 0) { await pool.query( `UPDATE h5_users u LEFT JOIN h5_subscriptions s ON s.user_id = u.id AND s.status = 'active' AND s.expires_at > ? SET u.plan_type = 'free', u.updated_at = ? WHERE u.plan_type != 'free' AND s.id IS NULL`, [now, now], ); } return result.affectedRows; }; const cancelSubscription = async (userId, operatorId = null) => { const now = Date.now(); const [result] = await pool.query( `UPDATE h5_subscriptions SET status = 'cancelled', updated_at = ? WHERE user_id = ? AND status = 'active'`, [now, userId], ); if (result.affectedRows > 0) { await pool.query( `UPDATE h5_users SET plan_type = 'free', updated_at = ? WHERE id = ?`, [now, userId], ); } return { ok: true, cancelled: result.affectedRows > 0 }; }; const listSubscriptions = async ({ userId = null, status = null, page = 1, pageSize = 20 } = {}) => { const safePageSize = Math.min(Math.max(Number(pageSize) || 20, 1), 100); const safePage = Math.max(Number(page) || 1, 1); const offset = (safePage - 1) * safePageSize; const params = []; const clauses = []; if (userId) { clauses.push('s.user_id = ?'); params.push(userId); } if (status) { clauses.push('s.status = ?'); params.push(status); } const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; const [[{ total }]] = await pool.query( `SELECT COUNT(*) AS total FROM h5_subscriptions s ${where}`, params, ); const [rows] = await pool.query( `SELECT s.*, u.username, u.display_name FROM h5_subscriptions s JOIN h5_users u ON u.id = s.user_id ${where} ORDER BY s.created_at DESC LIMIT ${safePageSize} OFFSET ${offset}`, params, ); return { total: Number(total), page: safePage, pageSize: safePageSize, items: rows.map((r) => ({ ...mapSubRow(r), username: r.username, displayName: r.display_name, })), }; }; // User self-service: purchase/upgrade a subscription by deducting from wallet balance. // Upgrades are always allowed. Downgrades are blocked while an active subscription exists. // autoRenew: whether to auto-charge and renew when the subscription expires. const purchaseSubscription = async (userId, planType, autoRenew = false) => { const plan = await resolvePlan(planType); if (!plan || plan.priceCents === 0) { return { ok: false, message: '无效套餐或免费套餐不可购买' }; } const now = Date.now(); const conn = await pool.getConnection(); try { await conn.beginTransaction(); // Check current subscription (with row lock) to enforce upgrade-only rule. const [currentSubRows] = await conn.query( `SELECT * FROM h5_subscriptions WHERE user_id = ? AND status = 'active' AND expires_at > ? ORDER BY expires_at DESC LIMIT 1 FOR UPDATE`, [userId, now], ); const currentSub = mapSubRow(currentSubRows[0]); if (currentSub) { const currentOrder = PLAN_ORDER[currentSub.planType] ?? 0; const newOrder = PLAN_ORDER[planType] ?? 0; if (newOrder < currentOrder) { await conn.rollback(); return { ok: false, code: 'DOWNGRADE_NOT_ALLOWED', message: '当前套餐有效期内不可降级,到期后将自动降为免费版', currentPlanType: currentSub.planType, }; } } 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 < plan.priceCents) { await conn.rollback(); return { ok: false, code: 'INSUFFICIENT_BALANCE', message: '余额不足,请充值后订阅', balanceCents, requiredCents: plan.priceCents, shortfallCents: plan.priceCents - balanceCents, }; } await conn.query( `UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`, [plan.priceCents, now, userId], ); await conn.query( `INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at) VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`, [userId, plan.priceCents, `subscription:${planType}`, now], ); await conn.query( `UPDATE h5_subscriptions SET status = 'cancelled', updated_at = ? WHERE user_id = ? AND status = 'active'`, [now, userId], ); const id = crypto.randomUUID(); const periodEnd = now + plan.periodDays * 24 * 60 * 60 * 1000; await conn.query( `INSERT INTO h5_subscriptions (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, period_images_limit, period_images_used, period_start, period_end, expires_at, overage_rate, auto_renew, operator_id, note, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, NULL, '用户自助购买', ?, ?)`, [ id, userId, planType, plan.periodTokens, plan.periodImages ?? 0, now, periodEnd, periodEnd, Number(plan.overageRate.toFixed(2)), autoRenew ? 1 : 0, now, now, ], ); await conn.query( `UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`, [planType, now, userId], ); await conn.commit(); const [subRows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [id]); return { ok: true, subscription: mapSubRow(subRows[0]), balanceCents: balanceCents - plan.priceCents, }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } }; // Toggle auto-renew on the user's current active subscription. const setAutoRenew = async (userId, enabled) => { const now = Date.now(); const [result] = await pool.query( `UPDATE h5_subscriptions SET auto_renew = ?, updated_at = ? WHERE user_id = ? AND status = 'active' AND expires_at > ?`, [enabled ? 1 : 0, now, userId, now], ); return { ok: true, updated: result.affectedRows > 0 }; }; // Process auto-renewals for subscriptions that just expired with auto_renew = 1. // Should be called BEFORE expireStaleSubscriptions in the periodic worker. const processAutoRenewals = async () => { const now = Date.now(); // Find active subscriptions that have expired with auto_renew on. const [rows] = await pool.query( `SELECT s.*, w.balance_cents FROM h5_subscriptions s JOIN h5_user_wallets w ON w.user_id = s.user_id WHERE s.status = 'active' AND s.auto_renew = 1 AND s.expires_at <= ?`, [now], ); let renewed = 0; let failed = 0; for (const row of rows) { const sub = mapSubRow(row); const plan = await resolvePlan(sub.planType); if (!plan || plan.priceCents === 0) continue; const balance = Number(row.balance_cents ?? 0); const conn = await pool.getConnection(); try { await conn.beginTransaction(); // Re-lock wallet and verify balance const [walletRows] = await conn.query( `SELECT balance_cents FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`, [sub.userId], ); const balanceCents = Number(walletRows[0]?.balance_cents ?? 0); if (balanceCents < plan.priceCents) { // Not enough — let expireStaleSubscriptions handle the reset await conn.rollback(); failed++; console.log(`Auto-renew failed for user ${sub.userId} (${sub.planType}): insufficient balance`); continue; } // Deduct wallet await conn.query( `UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`, [plan.priceCents, now, sub.userId], ); await conn.query( `INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at) VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`, [sub.userId, plan.priceCents, `auto_renew:${sub.planType}`, now], ); // Expire old subscription await conn.query( `UPDATE h5_subscriptions SET status = 'expired', auto_renew = 0, updated_at = ? WHERE id = ?`, [now, sub.id], ); // Create new subscription const newId = crypto.randomUUID(); const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1000; await conn.query( `INSERT INTO h5_subscriptions (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, period_images_limit, period_images_used, period_start, period_end, expires_at, overage_rate, auto_renew, operator_id, note, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, 1, NULL, '自动续费', ?, ?)`, [ newId, sub.userId, sub.planType, plan.periodTokens, plan.periodImages ?? 0, now, newPeriodEnd, newPeriodEnd, Number(plan.overageRate.toFixed(2)), now, now, ], ); // Keep user's plan_type current await conn.query( `UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`, [sub.planType, now, sub.userId], ); await conn.commit(); renewed++; console.log(`Auto-renewed ${sub.planType} for user ${sub.userId}`); } catch (err) { await conn.rollback(); console.warn(`Auto-renew error for user ${sub.userId}:`, err); failed++; } finally { conn.release(); } } return { renewed, failed }; }; const consumeImageQuotaTx = async (userId, count, conn) => { if (!count || count <= 0) return { fullyCovers: true }; const now = Date.now(); const [rows] = await conn.query( `SELECT * FROM h5_subscriptions WHERE user_id = ? AND status = 'active' AND expires_at > ? ORDER BY expires_at DESC LIMIT 1 FOR UPDATE`, [userId, now], ); const sub = mapSubRow(rows[0]); if (!sub) return { fullyCovers: false }; const unlimited = sub.periodImagesLimit === 0; const remaining = unlimited ? Infinity : sub.periodImagesLimit - sub.periodImagesUsed; if (unlimited || remaining >= count) { await conn.query( `UPDATE h5_subscriptions SET period_images_used = period_images_used + ?, updated_at = ? WHERE id = ?`, [count, now, sub.id], ); return { fullyCovers: true }; } return { fullyCovers: false }; }; // Deduct image count from the active subscription quota. // If conn is omitted, this method manages its own short transaction. const consumeImageQuota = async (userId, count, conn = null) => { if (conn) return consumeImageQuotaTx(userId, count, conn); const ownConn = await pool.getConnection(); try { await ownConn.beginTransaction(); const result = await consumeImageQuotaTx(userId, count, ownConn); await ownConn.commit(); return result; } catch (err) { await ownConn.rollback(); throw err; } finally { ownConn.release(); } }; return { getActiveSubscription, grantSubscription, purchaseSubscription, setAutoRenew, processAutoRenewals, consumeQuota, consumeImageQuota, renewSubscription, expireStaleSubscriptions, cancelSubscription, listSubscriptions, }; } // ── Plan Catalog (DB-backed) ───────────────────────────────────────────────── // Ensure h5_plan_catalog table exists, add image columns to h5_subscriptions, // and seed default plans from PLAN_CATALOG if the table is empty. export async function ensurePlanCatalogSchema(pool) { await pool.query(` CREATE TABLE IF NOT EXISTS h5_plan_catalog ( plan_type VARCHAR(32) PRIMARY KEY, name VARCHAR(64) NOT NULL, price_cents INT NOT NULL DEFAULT 0, period_days INT NOT NULL DEFAULT 30, period_tokens BIGINT NOT NULL DEFAULT 0, period_images INT NOT NULL DEFAULT 0, model_tier VARCHAR(32) NOT NULL DEFAULT 'basic', overage_rate DECIMAL(4,2) NOT NULL DEFAULT 1.00, sort_order INT NOT NULL DEFAULT 0, is_active TINYINT(1) NOT NULL DEFAULT 1, description VARCHAR(512) NULL, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); // Idempotently add image quota columns to h5_subscriptions. for (const col of [ 'ALTER TABLE h5_subscriptions ADD COLUMN period_images_limit INT NOT NULL DEFAULT 0', 'ALTER TABLE h5_subscriptions ADD COLUMN period_images_used INT NOT NULL DEFAULT 0', ]) { try { await pool.query(col); } catch (_) { /* column already exists */ } } // Seed defaults if catalog is empty. const [[{ cnt }]] = await pool.query(`SELECT COUNT(*) AS cnt FROM h5_plan_catalog`); if (Number(cnt) === 0) { const now = Date.now(); const entries = Object.entries(PLAN_CATALOG); for (const [i, [planType, plan]] of entries.entries()) { await pool.query( `INSERT IGNORE INTO h5_plan_catalog (plan_type, name, price_cents, period_days, period_tokens, period_images, model_tier, overage_rate, sort_order, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`, [ planType, plan.name, plan.priceCents, plan.periodDays, plan.periodTokens, plan.periodImages ?? 0, plan.modelTier, plan.overageRate, i, now, now, ], ); } } const now = Date.now(); for (const [planType, previousTokens, nextTokens] of PLAN_TOKEN_ALLOWANCE_MIGRATIONS) { if (nextTokens <= previousTokens) continue; await pool.query( `UPDATE h5_plan_catalog SET period_tokens = ?, updated_at = ? WHERE plan_type = ? AND period_tokens = ?`, [nextTokens, now, planType, previousTokens], ); await pool.query( `UPDATE h5_subscriptions SET period_tokens_limit = ?, updated_at = ? WHERE status = 'active' AND plan_type = ? AND period_tokens_limit = ?`, [nextTokens, now, planType, previousTokens], ); } } export function createPlanCatalogService(pool) { function mapPlanRow(row) { return { planType: row.plan_type, name: row.name, priceCents: Number(row.price_cents), periodDays: Number(row.period_days), periodTokens: Number(row.period_tokens), periodImages: Number(row.period_images), modelTier: row.model_tier, overageRate: Number(row.overage_rate), sortOrder: Number(row.sort_order), isActive: Boolean(row.is_active), description: row.description ?? null, }; } const listPlans = async ({ includeInactive = true } = {}) => { const where = includeInactive ? '' : 'WHERE is_active = 1'; const [rows] = await pool.query( `SELECT * FROM h5_plan_catalog ${where} ORDER BY sort_order ASC, price_cents ASC`, ); return rows.map(mapPlanRow); }; const getPlan = async (planType) => { const [rows] = await pool.query( `SELECT * FROM h5_plan_catalog WHERE plan_type = ?`, [planType], ); return rows[0] ? mapPlanRow(rows[0]) : null; }; const upsertPlan = async (planType, data) => { if (!planType || !/^[a-z0-9_]{1,32}$/.test(planType)) { return { ok: false, message: '套餐标识只能使用小写字母、数字和下划线,最长32位' }; } const now = Date.now(); await pool.query( `INSERT INTO h5_plan_catalog (plan_type, name, price_cents, period_days, period_tokens, period_images, model_tier, overage_rate, sort_order, is_active, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), price_cents = VALUES(price_cents), period_days = VALUES(period_days), period_tokens = VALUES(period_tokens), period_images = VALUES(period_images), model_tier = VALUES(model_tier), overage_rate = VALUES(overage_rate), sort_order = VALUES(sort_order), is_active = VALUES(is_active), description = VALUES(description), updated_at = VALUES(updated_at)`, [ planType, String(data.name ?? '').trim() || planType, Math.max(0, Math.round(Number(data.priceCents ?? 0))), Math.max(1, Math.round(Number(data.periodDays ?? 30))), Math.max(0, Math.round(Number(data.periodTokens ?? 0))), Math.max(0, Math.round(Number(data.periodImages ?? 0))), ['basic', 'standard', 'premium'].includes(data.modelTier) ? data.modelTier : 'basic', Math.min(2, Math.max(0, Number((data.overageRate ?? 1.0).toFixed(2)))), Math.round(Number(data.sortOrder ?? 0)), data.isActive !== false ? 1 : 0, data.description ? String(data.description).trim() : null, now, now, ], ); return { ok: true, plan: await getPlan(planType) }; }; const deletePlan = async (planType) => { if (planType === 'free') return { ok: false, message: '免费套餐不可删除' }; const [result] = await pool.query( `DELETE FROM h5_plan_catalog WHERE plan_type = ?`, [planType], ); return { ok: true, deleted: result.affectedRows > 0 }; }; return { listPlans, getPlan, upsertPlan, deletePlan }; }