9b4a25799f
Replace fixed ackText with a rule-based AckProvider that picks response templates by message type and intent (translate, summary, rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync, zero I/O, auto-falls back to config.ackText on any error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
731 lines
26 KiB
JavaScript
731 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: 150_000,
|
|
periodImages: 10,
|
|
modelTier: 'basic',
|
|
overageRate: 1.00,
|
|
periodDays: 30,
|
|
},
|
|
lite: {
|
|
name: '轻量版',
|
|
priceCents: 990,
|
|
periodTokens: 1_200_000,
|
|
periodImages: 50,
|
|
modelTier: 'basic',
|
|
overageRate: 0.50,
|
|
periodDays: 30,
|
|
},
|
|
standard: {
|
|
name: '标准版',
|
|
priceCents: 2900,
|
|
periodTokens: 4_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,
|
|
},
|
|
};
|
|
|
|
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 = getPlanDef(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,
|
|
],
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 };
|
|
}
|