Files
memind/billing-subscription.mjs
T
2026-06-29 22:20:04 +08:00

754 lines
26 KiB
JavaScript

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