Files
memind/billing-subscription.mjs
T
john 6f48cad4c2
Memind CI / Test, build, and release guards (push) Successful in 5m1s
fix(billing): apply margin multiplier to subscription quota consumption
Subscription plans now deduct period_tokens_used as upstream tokens ×
marginMultiplier, aligning package quota burn with wallet cost-mode pricing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 20:31:38 +08:00

1153 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
periodImagesBonus: Number(row.period_images_bonus ?? 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 computeImageQuotaView(sub) {
if (!sub) {
return {
limit: 0,
bonus: 0,
used: 0,
total: 0,
remaining: 0,
unlimited: false,
};
}
const limit = Number(sub.periodImagesLimit ?? 0);
const bonus = Number(sub.periodImagesBonus ?? 0);
const used = Number(sub.periodImagesUsed ?? 0);
const unlimited = limit === 0;
const total = unlimited ? null : limit + bonus;
const remaining = unlimited ? null : Math.max(0, total - used);
return {
limit,
bonus,
used,
total,
remaining,
unlimited,
periodEnd: sub.periodEnd ?? null,
planType: sub.planType ?? null,
};
}
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_images_bonus,
period_start, period_end, expires_at, overage_rate, auto_renew,
operator_id, note, created_at, updated_at)
VALUES (?, ?, ?, 'active', ?, 0, ?, 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 quotaTokens (upstream tokens × billing marginMultiplier) from the active subscription.
// Returns { fullyCovers: bool, overageRate: number }
const consumeQuota = async (userId, quotaTokens, conn) => {
if (!quotaTokens || quotaTokens <= 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 >= quotaTokens) {
await conn.query(
`UPDATE h5_subscriptions
SET period_tokens_used = period_tokens_used + ?, updated_at = ?
WHERE id = ?`,
[quotaTokens, 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_images_bonus,
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,
sub.periodImagesBonus ?? 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_images_bonus,
period_start, period_end, expires_at, overage_rate, auto_renew,
operator_id, note, created_at, updated_at)
VALUES (?, ?, ?, 'active', ?, 0, ?, 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 expired with auto_renew = 1.
// Retries until balance is sufficient; does not disable auto_renew on failure.
const processAutoRenewalsForUser = async (userId, { now = Date.now() } = {}) => {
const [activeRows] = await pool.query(
`SELECT id FROM h5_subscriptions
WHERE user_id = ? AND status = 'active' AND expires_at > ?
LIMIT 1`,
[userId, now],
);
if (activeRows.length) {
return { ok: false, reason: 'already_active' };
}
const [candidateRows] = 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.user_id = ? AND s.auto_renew = 1 AND s.expires_at <= ?
ORDER BY s.expires_at DESC
LIMIT 1`,
[userId, now],
);
const row = candidateRows[0];
if (!row) {
return { ok: false, reason: 'no_candidate' };
}
const sub = mapSubRow(row);
const plan = await resolvePlan(sub.planType);
if (!plan || plan.priceCents === 0) {
return { ok: false, reason: 'invalid_plan' };
}
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
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) {
await conn.rollback();
console.log(`Auto-renew failed for user ${sub.userId} (${sub.planType}): insufficient balance`);
return { ok: false, reason: 'insufficient_balance', balanceCents, requiredCents: plan.priceCents };
}
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],
);
if (sub.status === 'active') {
await conn.query(
`UPDATE h5_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`,
[now, sub.id],
);
}
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_images_bonus,
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,
sub.periodImagesBonus ?? 0,
now, newPeriodEnd, newPeriodEnd,
Number(plan.overageRate.toFixed(2)),
now, now,
],
);
await conn.query(
`UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`,
[sub.planType, now, sub.userId],
);
await conn.commit();
console.log(`Auto-renewed ${sub.planType} for user ${sub.userId}`);
const [newRows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [newId]);
return { ok: true, subscription: mapSubRow(newRows[0]), balanceCents: balanceCents - plan.priceCents };
} catch (err) {
await conn.rollback();
console.warn(`Auto-renew error for user ${sub.userId}:`, err);
throw err;
} finally {
conn.release();
}
};
const processAutoRenewals = async () => {
const now = Date.now();
const [rows] = await pool.query(
`SELECT DISTINCT s.user_id
FROM h5_subscriptions s
WHERE s.auto_renew = 1
AND s.expires_at <= ?
AND s.plan_type != 'free'
AND NOT EXISTS (
SELECT 1 FROM h5_subscriptions s2
WHERE s2.user_id = s.user_id
AND s2.status = 'active'
AND s2.expires_at > ?
)`,
[now, now],
);
let renewed = 0;
let failed = 0;
for (const row of rows) {
try {
const result = await processAutoRenewalsForUser(row.user_id, { now });
if (result.ok) {
renewed += 1;
} else if (result.reason === 'insufficient_balance') {
failed += 1;
}
} catch {
failed += 1;
}
}
return { renewed, failed };
};
const appendImageQuotaLedgerTx = async (conn, {
userId,
delta,
balanceAfter,
reason,
refId = null,
operatorId = null,
note = null,
}) => {
await conn.query(
`INSERT INTO h5_image_quota_ledger
(id, user_id, delta, balance_after, reason, ref_id, operator_id, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
crypto.randomUUID(),
userId,
delta,
balanceAfter,
reason,
refId ? String(refId).slice(0, 128) : null,
operatorId,
note ? String(note).slice(0, 512) : null,
Date.now(),
],
);
};
const getImageQuota = async (userId) => {
const sub = await getActiveSubscription(userId);
if (!sub) {
return {
ok: false,
code: 'no_subscription',
message: '当前没有有效订阅,无法使用图片生成额度',
};
}
return { ok: true, subscription: sub, quota: computeImageQuotaView(sub) };
};
const checkImageQuota = async (userId, count = 1) => {
const safeCount = Math.max(1, Math.floor(Number(count) || 1));
const result = await getImageQuota(userId);
if (!result.ok) return result;
const { quota } = result;
if (quota.unlimited || (quota.remaining ?? 0) >= safeCount) {
return { ok: true, ...result, requested: safeCount };
}
return {
ok: false,
code: 'image_quota_exceeded',
message: '图片生成额度不足',
subscription: result.subscription,
quota,
requested: safeCount,
};
};
const consumeImageQuotaTx = async (userId, count, conn, options = {}) => {
if (!count || count <= 0) return { fullyCovers: true };
const refId = String(options.refId ?? '').trim() || null;
const now = Date.now();
if (refId) {
const [existing] = await conn.query(
`SELECT id FROM h5_image_quota_ledger
WHERE user_id = ? AND ref_id = ? AND reason = 'consume'
LIMIT 1`,
[userId, refId],
);
if (existing.length) return { fullyCovers: true, duplicate: true };
}
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 capacity = unlimited ? Infinity : sub.periodImagesLimit + sub.periodImagesBonus;
const remaining = unlimited ? Infinity : capacity - 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],
);
const balanceAfter = unlimited
? null
: Math.max(0, capacity - (sub.periodImagesUsed + count));
if (refId) {
await appendImageQuotaLedgerTx(conn, {
userId,
delta: -count,
balanceAfter,
reason: 'consume',
refId,
operatorId: options.operatorId ?? null,
note: options.note ?? null,
});
}
return { fullyCovers: true, balanceAfter };
}
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, options = {}) => {
if (conn) return consumeImageQuotaTx(userId, count, conn, options);
const ownConn = await pool.getConnection();
try {
await ownConn.beginTransaction();
const result = await consumeImageQuotaTx(userId, count, ownConn, options);
await ownConn.commit();
return result;
} catch (err) {
await ownConn.rollback();
throw err;
} finally {
ownConn.release();
}
};
const setImageQuota = async (userId, { remaining = null, total = null } = {}, { operatorId = null, note = '' } = {}) => {
const hasRemaining = remaining !== null && remaining !== undefined;
const hasTotal = total !== null && total !== undefined;
if (hasRemaining === hasTotal) {
return { ok: false, message: '请指定 remaining 或 total 其中之一' };
}
const target = Math.floor(Number(hasRemaining ? remaining : total));
if (!Number.isFinite(target) || target < 0) {
return { ok: false, message: '额度必须是非负整数' };
}
const now = Date.now();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
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) {
await conn.rollback();
return { ok: false, message: '用户没有有效订阅,无法设置图片额度' };
}
if (sub.periodImagesLimit === 0) {
await conn.rollback();
return { ok: false, message: '无限额度套餐无法调整' };
}
const used = sub.periodImagesUsed;
const oldCapacity = sub.periodImagesLimit + sub.periodImagesBonus;
const oldRemaining = Math.max(0, oldCapacity - used);
const targetCapacity = hasRemaining ? used + target : target;
if (targetCapacity < used) {
await conn.rollback();
return { ok: false, message: `额度不能低于已用量(${used} 张)` };
}
let newLimit = sub.periodImagesLimit;
let newBonus = sub.periodImagesBonus;
if (targetCapacity >= sub.periodImagesLimit) {
newBonus = targetCapacity - sub.periodImagesLimit;
} else {
newLimit = targetCapacity;
newBonus = 0;
}
if (newLimit === sub.periodImagesLimit && newBonus === sub.periodImagesBonus) {
await conn.rollback();
return { ok: true, subscription: sub, quota: computeImageQuotaView(sub), unchanged: true };
}
await conn.query(
`UPDATE h5_subscriptions
SET period_images_limit = ?, period_images_bonus = ?, updated_at = ?
WHERE id = ?`,
[newLimit, newBonus, now, sub.id],
);
const updatedSub = {
...sub,
periodImagesLimit: newLimit,
periodImagesBonus: newBonus,
};
const quota = computeImageQuotaView(updatedSub);
const ledgerDelta = hasRemaining ? target - oldRemaining : targetCapacity - oldCapacity;
await appendImageQuotaLedgerTx(conn, {
userId,
delta: ledgerDelta,
balanceAfter: quota.unlimited ? null : quota.remaining,
reason: 'admin_adjust',
operatorId,
note,
});
await conn.commit();
return { ok: true, subscription: updatedSub, quota };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
};
const grantImageQuota = async (userId, delta, { operatorId = null, note = '' } = {}) => {
const safeDelta = Math.floor(Number(delta));
if (!Number.isFinite(safeDelta) || safeDelta === 0) {
return { ok: false, message: '充值额度必须是非零整数' };
}
const now = Date.now();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
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) {
await conn.rollback();
return { ok: false, message: '用户没有有效订阅,无法充值图片额度' };
}
if (safeDelta < 0 && sub.periodImagesLimit !== 0) {
const capacity = sub.periodImagesLimit + sub.periodImagesBonus;
const remaining = capacity - sub.periodImagesUsed;
if (remaining + safeDelta < 0) {
await conn.rollback();
return { ok: false, message: '扣减后图片额度不能为负数' };
}
}
await conn.query(
`UPDATE h5_subscriptions
SET period_images_bonus = GREATEST(0, period_images_bonus + ?), updated_at = ?
WHERE id = ?`,
[safeDelta, now, sub.id],
);
const updatedSub = {
...sub,
periodImagesBonus: Math.max(0, sub.periodImagesBonus + safeDelta),
};
const quota = computeImageQuotaView(updatedSub);
await appendImageQuotaLedgerTx(conn, {
userId,
delta: safeDelta,
balanceAfter: quota.unlimited ? null : quota.remaining,
reason: 'admin_grant',
operatorId,
note,
});
await conn.commit();
return { ok: true, subscription: updatedSub, quota };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
};
const listImageQuotaLedger = async ({
userId = 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('l.user_id = ?');
params.push(userId);
}
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const [[{ total }]] = await pool.query(
`SELECT COUNT(*) AS total FROM h5_image_quota_ledger l ${where}`,
params,
);
const [rows] = await pool.query(
`SELECT l.*, u.username, u.display_name
FROM h5_image_quota_ledger l
JOIN h5_users u ON u.id = l.user_id
${where}
ORDER BY l.created_at DESC
LIMIT ${safePageSize} OFFSET ${offset}`,
params,
);
return {
entries: rows.map((row) => ({
id: row.id,
userId: row.user_id,
username: row.username,
displayName: row.display_name,
delta: Number(row.delta),
balanceAfter: row.balance_after == null ? null : Number(row.balance_after),
reason: row.reason,
refId: row.ref_id,
operatorId: row.operator_id,
note: row.note,
createdAt: Number(row.created_at),
})),
total: Number(total),
page: safePage,
pageSize: safePageSize,
totalPages: Math.max(1, Math.ceil(Number(total) / safePageSize)),
};
};
return {
getActiveSubscription,
grantSubscription,
purchaseSubscription,
setAutoRenew,
processAutoRenewals,
processAutoRenewalsForUser,
consumeQuota,
consumeImageQuota,
getImageQuota,
checkImageQuota,
setImageQuota,
grantImageQuota,
listImageQuotaLedger,
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',
'ALTER TABLE h5_subscriptions ADD COLUMN period_images_bonus INT NOT NULL DEFAULT 0',
]) {
try { await pool.query(col); } catch (_) { /* column already exists */ }
}
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_image_quota_ledger (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
delta INT NOT NULL,
balance_after INT NULL,
reason ENUM('admin_grant', 'admin_adjust', 'consume', 'period_reset', 'plan_change') NOT NULL,
ref_id VARCHAR(128) NULL,
operator_id CHAR(36) NULL,
note VARCHAR(512) NULL,
created_at BIGINT NOT NULL,
KEY idx_h5_image_quota_user_created (user_id, created_at),
KEY idx_h5_image_quota_ref (user_id, ref_id),
CONSTRAINT fk_h5_image_quota_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// 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],
);
}
// Repair active subscriptions that inherited DEFAULT 0 (unlimited) from the
// pre-image-quota free-plan backfill, but whose catalog quota is finite.
// True unlimited plans (catalog period_images = 0) are left unchanged.
await pool.query(
`INSERT INTO h5_image_quota_ledger
(id, user_id, delta, balance_after, reason, ref_id, operator_id, note, created_at)
SELECT UUID(), s.user_id, p.period_images,
GREATEST(0, p.period_images + s.period_images_bonus - s.period_images_used),
'plan_change', s.id, NULL,
'回填套餐默认图片额度(补建漏写)', ?
FROM h5_subscriptions s
INNER JOIN h5_plan_catalog p ON p.plan_type = s.plan_type
WHERE s.status = 'active'
AND s.expires_at > ?
AND s.period_images_limit = 0
AND p.period_images > 0`,
[now, now],
);
await pool.query(
`UPDATE h5_subscriptions s
INNER JOIN h5_plan_catalog p ON p.plan_type = s.plan_type
SET s.period_images_limit = p.period_images,
s.updated_at = ?
WHERE s.status = 'active'
AND s.expires_at > ?
AND s.period_images_limit = 0
AND p.period_images > 0`,
[now, now],
);
}
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 };
}