feat(billing): enforce image generation quota with admin API and user display
Memind CI / Test, build, and release guards (push) Failing after 3s

Add period_images_bonus and ledger tracking, gate image_make on remaining quota,
expose admin image-quota routes, show remaining image quota in the balance popover,
and document that admin UI must live in memind_adm (5174) not ops.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-03 14:58:38 +08:00
parent 36d8e74784
commit 946d8756c8
14 changed files with 639 additions and 23 deletions
+255 -13
View File
@@ -79,6 +79,7 @@ function mapSubRow(row) {
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),
@@ -91,6 +92,35 @@ function mapSubRow(row) {
};
}
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) => {
@@ -137,10 +167,10 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
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_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, ?, ?, ?, ?)`,
VALUES (?, ?, ?, 'active', ?, 0, ?, 0, 0, ?, ?, ?, ?, 0, ?, ?, ?, ?)`,
[
id, userId, planType, plan.periodTokens,
plan.periodImages ?? 0,
@@ -233,13 +263,14 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
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_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,
@@ -401,10 +432,10 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
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_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, ?, ?, ?, ?, ?, NULL, '用户自助购买', ?, ?)`,
VALUES (?, ?, ?, 'active', ?, 0, ?, 0, 0, ?, ?, ?, ?, ?, NULL, '用户自助购买', ?, ?)`,
[
id, userId, planType, plan.periodTokens,
plan.periodImages ?? 0,
@@ -510,13 +541,14 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
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_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, '自动续费', ?, ?)`,
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,
@@ -544,9 +576,76 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
return { renewed, failed };
};
const consumeImageQuotaTx = async (userId, count, conn) => {
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 > ?
@@ -557,7 +656,8 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
if (!sub) return { fullyCovers: false };
const unlimited = sub.periodImagesLimit === 0;
const remaining = unlimited ? Infinity : sub.periodImagesLimit - sub.periodImagesUsed;
const capacity = unlimited ? Infinity : sub.periodImagesLimit + sub.periodImagesBonus;
const remaining = unlimited ? Infinity : capacity - sub.periodImagesUsed;
if (unlimited || remaining >= count) {
await conn.query(
@@ -566,20 +666,34 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
WHERE id = ?`,
[count, now, sub.id],
);
return { fullyCovers: true };
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) => {
if (conn) return consumeImageQuotaTx(userId, count, conn);
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);
const result = await consumeImageQuotaTx(userId, count, ownConn, options);
await ownConn.commit();
return result;
} catch (err) {
@@ -590,6 +704,112 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
}
};
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,
@@ -598,6 +818,10 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
processAutoRenewals,
consumeQuota,
consumeImageQuota,
getImageQuota,
checkImageQuota,
grantImageQuota,
listImageQuotaLedger,
renewSubscription,
expireStaleSubscriptions,
cancelSubscription,
@@ -632,10 +856,28 @@ export async function ensurePlanCatalogSchema(pool) {
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) {