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,
+46
View File
@@ -273,6 +273,52 @@ describe('createSubscriptionService', () => {
});
});
describe('setImageQuota', () => {
it('lowers remaining below plan limit by reducing period_images_limit', async () => {
const subRow = makeSubRow({
period_images_limit: 1000,
period_images_bonus: 0,
period_images_used: 32,
});
const pool = makePool(subRow);
const svc = createSubscriptionService(pool);
const result = await svc.setImageQuota('user-1', { remaining: 100 });
assert.equal(result.ok, true);
assert.equal(result.quota.remaining, 100);
assert.equal(result.quota.total, 132);
assert.ok(
pool._conn.queries.some(
({ sql, params }) =>
sql.includes('SET period_images_limit = ?, period_images_bonus = ?') &&
params?.[0] === 132 &&
params?.[1] === 0,
),
);
});
it('raises remaining above plan limit via bonus', async () => {
const subRow = makeSubRow({
period_images_limit: 50,
period_images_bonus: 0,
period_images_used: 10,
});
const pool = makePool(subRow);
const svc = createSubscriptionService(pool);
const result = await svc.setImageQuota('user-1', { remaining: 45 });
assert.equal(result.ok, true);
assert.equal(result.quota.remaining, 45);
assert.equal(result.quota.total, 55);
assert.ok(
pool._conn.queries.some(
({ sql, params }) =>
sql.includes('SET period_images_limit = ?, period_images_bonus = ?') &&
params?.[0] === 50 &&
params?.[1] === 5,
),
);
});
});
describe('cancelSubscription', () => {
it('returns cancelled=true when active sub exists', async () => {
const pool = makePool(makeSubRow());