diff --git a/billing-subscription.mjs b/billing-subscription.mjs index 584e343..6563256 100644 --- a/billing-subscription.mjs +++ b/billing-subscription.mjs @@ -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, diff --git a/billing-subscription.test.mjs b/billing-subscription.test.mjs index 59992ca..d9198d1 100644 --- a/billing-subscription.test.mjs +++ b/billing-subscription.test.mjs @@ -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());