feat(billing): enforce image generation quota with admin API and user display
Memind CI / Test, build, and release guards (push) Failing after 3s
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:
+255
-13
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user