fix(billing): add setImageQuota for admin absolute remaining/total
Memind CI / Test, build, and release guards (push) Has been cancelled
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:
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user