diff --git a/server/src/db.js b/server/src/db.js index 2bf4f0c..1f1228e 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -62,6 +62,14 @@ db.exec(` ); CREATE INDEX IF NOT EXISTS idx_log_anniv ON remind_logs(anniversaryId); CREATE INDEX IF NOT EXISTS idx_log_date ON remind_logs(sendDate); + + CREATE TABLE IF NOT EXISTS subscribe_quota ( + openid TEXT PRIMARY KEY, + balance INTEGER NOT NULL DEFAULT 0, + grantedTotal INTEGER NOT NULL DEFAULT 0, + sentTotal INTEGER NOT NULL DEFAULT 0, + updateTime INTEGER + ); `) // 旧库迁移:CREATE TABLE IF NOT EXISTS 不会给已存在的表加列,需要手动 ALTER diff --git a/server/src/quota.js b/server/src/quota.js new file mode 100644 index 0000000..cf5ca42 --- /dev/null +++ b/server/src/quota.js @@ -0,0 +1,60 @@ +/** + * 订阅消息额度记账 + * + * ⚠️ balance 是估算值,不是权威数据。真值只存在于微信服务器,且没有接口可查。 + * 我们靠「前端授权成功上报 +1、发送成功 -1」维护,并在发送返回 43101 时归零校正。 + * 任何地方都不要把 balance 当作可信的强校验依据。 + * + * 额度是「每用户 × 每模板」维度的,所以以 openid 为主键;本项目只有一个模板,故不再分列。 + */ + +const db = require('./db') + +const MAX_GRANT_PER_CALL = 50 // 单次上报上限,防御异常入参 + +function getBalance(openid) { + const row = db.prepare('SELECT balance FROM subscribe_quota WHERE openid = ?').get(openid) + return row ? row.balance : 0 +} + +// 把任意入参收敛成 1..MAX_GRANT_PER_CALL 的整数 +function _normalize(count) { + const n = parseInt(count, 10) + if (!Number.isFinite(n) || n < 1) return 1 + return Math.min(n, MAX_GRANT_PER_CALL) +} + +// 授权上报:纯累加、不去重——微信侧确实每次授权都 +1,如实记录即可 +function grant(openid, count = 1) { + const n = _normalize(count) + db.prepare(` + INSERT INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime) + VALUES (@openid, @n, @n, 0, @now) + ON CONFLICT(openid) DO UPDATE SET + balance = balance + @n, + grantedTotal = grantedTotal + @n, + updateTime = @now + `).run({ openid, n, now: Date.now() }) + return getBalance(openid) +} + +function consume(openid, count = 1) { + const n = _normalize(count) + db.prepare(` + UPDATE subscribe_quota + SET balance = MAX(0, balance - @n), sentTotal = sentTotal + @n, updateTime = @now + WHERE openid = @openid + `).run({ openid, n, now: Date.now() }) + return getBalance(openid) +} + +// 余额归零:发送返回 43101 时调用,说明微信侧实际已无额度(或用户关了通知总开关) +function reset(openid) { + db.prepare(` + INSERT INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime) + VALUES (@openid, 0, 0, 0, @now) + ON CONFLICT(openid) DO UPDATE SET balance = 0, updateTime = @now + `).run({ openid, now: Date.now() }) +} + +module.exports = { getBalance, grant, consume, reset } diff --git a/server/test/quota.test.js b/server/test/quota.test.js new file mode 100644 index 0000000..8ab39a1 --- /dev/null +++ b/server/test/quota.test.js @@ -0,0 +1,51 @@ +const test = require('node:test') +const assert = require('node:assert') +const { useTempDb } = require('./helper') + +useTempDb() // 必须在 require quota/db 之前 +const quota = require('../src/quota') + +test('未记录过的用户余额为 0', () => { + assert.strictEqual(quota.getBalance('u_new'), 0) +}) + +test('grant 纯累加,不去重', () => { + quota.grant('u1', 1) + quota.grant('u1', 1) + quota.grant('u1', 1) + assert.strictEqual(quota.getBalance('u1'), 3) +}) + +test('consume 扣减余额', () => { + quota.grant('u2', 5) + quota.consume('u2', 2) + assert.strictEqual(quota.getBalance('u2'), 3) +}) + +test('consume 不会把余额扣成负数', () => { + quota.grant('u3', 1) + quota.consume('u3', 10) + assert.strictEqual(quota.getBalance('u3'), 0) +}) + +test('reset 把余额归零', () => { + quota.grant('u4', 8) + quota.reset('u4') + assert.strictEqual(quota.getBalance('u4'), 0) +}) + +test('grant 对非法 count 做兜底', () => { + quota.grant('u5', 'abc') + assert.strictEqual(quota.getBalance('u5'), 1) + quota.grant('u5', -5) + assert.strictEqual(quota.getBalance('u5'), 2) + quota.grant('u5', 9999) + assert.strictEqual(quota.getBalance('u5'), 52) // 单次上限 50 +}) + +test('各用户额度互不影响', () => { + quota.grant('a', 3) + quota.grant('b', 1) + assert.strictEqual(quota.getBalance('a'), 3) + assert.strictEqual(quota.getBalance('b'), 1) +})