fix(billing): add setImageQuota for admin absolute remaining/total
Memind CI / Test, build, and release guards (push) Has been cancelled

Grant-only bonus adjustments cannot lower capacity below the stored plan
limit; admin set now updates period_images_limit when needed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-05 16:21:07 +08:00
parent fda90d8579
commit f488d49d51
2 changed files with 132 additions and 0 deletions
+86
View File
@@ -704,6 +704,91 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
}
};
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_set',
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) {
@@ -820,6 +905,7 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
consumeImageQuota,
getImageQuota,
checkImageQuota,
setImageQuota,
grantImageQuota,
listImageQuotaLedger,
renewSubscription,