diff --git a/.gitignore b/.gitignore index 6aaa899..21ab8f0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ node_modules/ server/.env server/data/ server/node_modules/ + +# 子代理执行过程的临时产物(ledger、任务简报、评审包) +.superpowers/ diff --git a/docs/superpowers/plans/2026-08-15-订阅消息额度.md b/docs/superpowers/plans/2026-08-15-订阅消息额度.md index c4938dd..1164d62 100644 --- a/docs/superpowers/plans/2026-08-15-订阅消息额度.md +++ b/docs/superpowers/plans/2026-08-15-订阅消息额度.md @@ -1524,24 +1524,30 @@ Expected: 后端测试全绿,全部文件语法通过 1. `git push origin master` → Gitea Actions 自动部署后端(3–8 分钟) 2. 验证:`curl https://wxserver.ymxixi.space/api/health` -3. 验证后端对**线上旧版**小程序仍兼容:旧版不会调 `/api/subscribe`,且 `reminder.js` 对 `balance = 0` 的老用户会全部记 skipped——**这是本次上线最大的风险点**,见下方说明 +3. 验证后端对**线上旧版**小程序仍兼容:旧版不会调 `/api/subscribe`,老用户 `balance = 0`——见下方说明(合并前评审已修,风险大幅下降) 4. 确认无误后再上传小程序 → 体验版自测 → 提交审核 → 发布 -### ⚠️ 上线首日的额度真空问题 +### ⚠️ 上线首日的额度真空问题(已在合并前评审中修掉,此处保留背景) -新版 `reminder.js` 在 `balance <= 0` 时**不发任何请求**。而 `subscribe_quota` 表是新建的,所有存量用户的初始 `balance` 都是 0——这意味着**后端一上线,所有存量用户的提醒会立刻全部停发**,而新版小程序还在审核中,用户没有任何途径补额度。 +**原风险**:`reminder.js` 在 `balance <= 0` 时不发任何请求,而 `subscribe_quota` 是新建表,所有存量用户初始 `balance` 都是 0——后端一上线,存量用户的提醒会立刻全部停发,而新版小程序还在审核中,用户没有任何途径补额度。 -处理办法:部署后立刻给存量用户补一个初始余额,让他们至少维持现状不倒退。存量用户过去每存一条纪念日就授权过一次,所以按「该用户的纪念日条数」补是合理的估算: +**现状:不需要再手工执行任何 SQL。** 合并前评审拍板了两条修复,均已落地: + +1. **自动迁移**(`server/src/db.js`):下面这条 SQL 已经做成 `PRAGMA user_version` 驱动的一次性迁移,容器启动即执行,幂等(`user_version` 版本号 + `INSERT OR IGNORE`),只补 `subscribe_quota` 里尚无记录的 openid。**再手工敲一遍无害但完全多余。** +2. **探针机制**(`server/src/reminder.js`):即使余额为 0,每个用户每轮仍会把优先级最高的那条发出去探路;探针成功就继续正常发送。所以「记账低估 = 停发」这个前提本身已经不成立了。 + +原手工 SQL(仅作为迁移口径的备查,**不要执行**): ```bash -# 在群晖上对生产库执行(先备份) -cp /volume1/docker/apps/birthday-server/data/birthday.db{,.bak-$(date +%F)} -sqlite3 /volume1/docker/apps/birthday-server/data/birthday.db " -INSERT INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime) -SELECT openid, COUNT(*), COUNT(*), 0, strftime('%s','now')*1000 -FROM anniversaries GROUP BY openid -ON CONFLICT(openid) DO NOTHING; -" +# 已由 db.js 的 migrateV1() 自动完成,保留在此仅供理解口径 +# INSERT INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime) +# SELECT openid, COUNT(*), COUNT(*), 0, strftime('%s','now')*1000 +# FROM anniversaries GROUP BY openid +# ON CONFLICT(openid) DO NOTHING; ``` -这一步**必须在后端部署完成后、当天 9 点定时任务触发前**执行完毕。 +按 `MAINTENANCE.md` 的惯例,部署前依然建议先备份生产库: + +```bash +cp /volume1/docker/apps/birthday-server/data/birthday.db{,.bak-$(date +%F)} +``` diff --git a/pages/add-anniversary/add-anniversary.js b/pages/add-anniversary/add-anniversary.js index d6f7c34..81b5a0a 100644 --- a/pages/add-anniversary/add-anniversary.js +++ b/pages/add-anniversary/add-anniversary.js @@ -3,8 +3,13 @@ const storage = require('../../utils/storage') const dateUtils = require('../../utils/date') const sync = require('../../utils/sync') const lunar = require('../../utils/lunar') +const subscribe = require('../../utils/subscribe') Page({ + // 保存中标志。放在实例上而不是 data 里:它只用于防重入,不参与渲染, + // 走 setData 反而会多一次没必要的视图层通信。 + submitting: false, + data: { anniversaryId: null, personId: null, @@ -275,8 +280,21 @@ Page({ /** * 提交 + * + * ⚠️ 本方法绝对不能加 async,方法体内在调用 requestSubscribe 之前也不能出现任何 await + * 或其他异步跳跃(Promise.then、wx 异步 API 的回调里再调等): + * wx.requestSubscribeMessage 必须由用户的真实点击手势同步触发,一旦中间断开, + * 手势上下文丢失,真机上必然报 "fail can only be invoked by user TAP gesture"。 + * 从 bindtap="onSubmit" 进来到 requestSubscribe 之间,只有 setData 读取和几个同步校验。 */ - async onSubmit() { + onSubmit() { + // 防重入。 + // Why:保存链路本身是同步的,但保存成功后要等 1.5 秒的 toast 才 navigateBack, + // 这段时间用户完全可能再点一次「保存」;而 storage.addAnniversary 每次都会 + // generateId() 生成一个新 id,重复点击的结果就是存出两条一模一样的纪念日。 + // 注:这一句是同步的 if 判断,不影响下面订阅调用的手势上下文。 + if (this.submitting) return + const { formData, anniversaryId, inputName } = this.data let { personId } = this.data @@ -299,19 +317,26 @@ Page({ return } - // 如果开启了提醒,请求订阅消息授权 + // 如果开启了提醒,请求订阅消息授权。 + // 这里是 fire-and-forget,故意不 await: + // 1) await 会切断手势上下文,订阅调用必然失败(见方法头注释); + // 2) requestAndReport 内部的 grant 上报走 api.request,超时长达 10 秒。对已勾选 + // 「总是保持以上选择」的用户连订阅弹窗都不会出现,await 会让用户点完「保存」后 + // 最长干等 10 秒、界面毫无反馈; + // 3) 上报失败本来就有 pending_sync_queue 兜底,下次启动会自动 flush,等它没有意义。 + // requestAndReport 只 resolve、不 reject,所以这里不挂 catch 也不会产生未处理拒绝。 if (formData.remindEnabled) { - try { - await this.requestSubscribe() - } catch (err) { - console.log('用户拒绝订阅消息') - } + this.requestSubscribe() } + // 订阅调用已经发出,后面就没有手势上下文的顾虑了,可以安全地置位防重入标志 + this.submitting = true + // 新增模式且没绑定 personId → 按姓名查找或自动创建 if (!anniversaryId && !personId) { const person = storage.ensurePerson(name) if (!person) { + this.submitting = false wx.showToast({ title: '保存失败', icon: 'none' }) return } @@ -345,9 +370,13 @@ Page({ personName, ...formData }, 'update') - + wx.showToast({ title: '保存成功', icon: 'success' }) setTimeout(() => wx.navigateBack(), 1500) + } else { + // 写本地失败:必须放开防重入标志,否则用户连重试的机会都没有 + this.submitting = false + wx.showToast({ title: '保存失败', icon: 'none' }) } } else { // 新增模式 @@ -360,30 +389,28 @@ Page({ if (newAnniversary) { // 同步到云端 this.syncToCloud(newAnniversary.id, newAnniversary, 'add') - + wx.showToast({ title: '添加成功', icon: 'success' }) setTimeout(() => wx.navigateBack(), 1500) + } else { + // 同上:写本地失败要放开标志,让用户能重试 + this.submitting = false + wx.showToast({ title: '保存失败', icon: 'none' }) } } }, /** * 请求订阅消息授权 + * 委托给 utils/subscribe.js 的 requestAndReport:同意后会自动把新增额度 + * 上报给后端(走同步队列,失败自动入队)。 + * 注意:本方法不能加 async,且内部不能在调用 requestAndReport 之前插入 + * 任何 await —— wx.requestSubscribeMessage 必须由用户点击手势同步触发, + * 一旦中间出现异步跳跃,手势上下文丢失会导致调用必然失败。 + * @returns {Promise} 用户是否同意 */ requestSubscribe() { - return new Promise((resolve, reject) => { - wx.requestSubscribeMessage({ - tmplIds: ['6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw'], - success: (res) => { - console.log('订阅消息授权结果:', res) - resolve(res) - }, - fail: (err) => { - console.error('订阅消息授权失败:', err) - reject(err) - } - }) - }) + return subscribe.requestAndReport() }, /** diff --git a/pages/index/index.js b/pages/index/index.js index 3000807..f129870 100644 --- a/pages/index/index.js +++ b/pages/index/index.js @@ -2,6 +2,8 @@ const storage = require('../../utils/storage') const dateUtils = require('../../utils/date') const fmt = require('../../utils/format') +const api = require('../../utils/api') +const subscribe = require('../../utils/subscribe') Page({ data: { @@ -11,7 +13,8 @@ Page({ currentFilter: 'all', totalCount: 0, upcomingCount: 0, - todayText: '' + todayText: '', + atRiskText: '' }, onLoad() { @@ -20,6 +23,9 @@ Page({ onShow() { this.loadPersons() + this.refreshQuota() + // 提前查好订阅状态缓存到实例上:点击处理函数里不能再 await,否则手势上下文会丢 + subscribe.getStatus().then(status => { this.subscribeStatus = status }) }, /** @@ -150,15 +156,92 @@ Page({ }, /** - * 点击人员 + * 点击人员进详情 + * 顺带搭车补额度:仅对已勾选「总是保持以上选择」的用户,此调用不弹窗、完全无感。 + * 必须同步调用且放在 navigateTo 之前,否则手势上下文丢失。 */ onPersonTap(e) { + if (this.subscribeStatus === 'silent') subscribe.requestAndReport() + const id = e.currentTarget.dataset.id wx.navigateTo({ url: `/pages/person-detail/person-detail?id=${id}` }) }, + /** + * 拉取额度风险,拼成给用户看的文案 + * 说「谁的提醒有风险」而不是「还剩几次额度」——用户理解不了额度这种抽象概念 + * @returns {Promise} 本次刷新是否成功拿到了最新数据。 + * 失败时不清空/覆盖 atRiskText——网络抖动时清空反而会掩盖真实存在的风险, + * 比"文案没更新"更糟;调用方需要这个返回值来判断该给用户什么反馈(见 onTopUp)。 + */ + async refreshQuota() { + try { + const res = await api.subscribe('get') + if (!res || !res.success) return false + const names = res.atRiskNames || [] + let text = '' + if (names.length > 0 && names.length <= 3) { + text = names.join('、') + } else if (names.length > 3) { + text = names.slice(0, 3).join('、') + ' 等 ' + names.length + ' 人' + } + this.setData({ atRiskText: text }) + return true + } catch (e) { + // 查询失败就不显示提示,静默处理,不打扰用户;atRiskText 保留旧值不清空(理由见上) + console.warn('[index] 额度查询失败', e) + return false + } + }, + + /** + * 点击风险提示卡片补额度 + * 注意:wx.requestSubscribeMessage / wx.openSetting 必须在这里同步调用, + * 前面绝不能有 await,否则手势上下文丢失、调用必然失败 + */ + onTopUp() { + const status = this.subscribeStatus + if (status === 'rejected' || status === 'mainSwitchOff') { + wx.openSetting({ withSubscriptions: true }) + return + } + + // 显式处理 status === undefined,不让它靠"没匹配上第一个 if"隐式落地: + // undefined 出现在 onShow 里 subscribe.getStatus() 还没返回、用户就点了卡片的情况; + // 这里没法现查状态兜底——getStatus() 是异步的,哪怕 await 一下也会丢失手势上下文, + // 所以有意让 undefined 和 unknown(查询失败)一样,都保守地按 willPrompt 处理, + // 与 utils/subscribe.js 里「unknown 按 willPrompt 保守处理」的取向一致。 + // 代价:如果用户真实状态其实是 mainSwitchOff(通知总开关关了),这里不会拦截去设置页, + // 只会静默调用 wx.requestSubscribeMessage,微信不弹窗、直接判定未同意, + // 用户只会看到下面「未开启提醒」的兜底提示,不会被引导去设置页—— + // 这是已知且可接受的体验降级,不在本次修复范围内。 + const isConservativeFallback = status === undefined || status === 'unknown' + if (isConservativeFallback) { + // 不阻断流程,只留一条日志方便排查——命中这条分支时用户体验会比正常 + // 情况弱一点(见上面的注释),出问题时能在控制台里定位到具体是哪种取舍 + console.warn('[index] onTopUp 命中保守兜底分支,status =', status) + } + + subscribe.requestAndReport().then(accepted => { + if (!accepted) { + wx.showToast({ title: '未开启提醒', icon: 'none' }) + return + } + // 补额度这步已经成功(微信侧确实同意了),但下面刷新风险文案是另一次独立请求, + // 可能失败;两者不能混为一谈,否则会出现"已补充"toast 和纹丝不动的风险卡片 + // 同时出现的自相矛盾画面。用刷新结果决定 toast 措辞,不对刷新结果做绝对断言。 + this.refreshQuota().then(refreshed => { + if (refreshed) { + wx.showToast({ title: '已补充提醒次数', icon: 'success' }) + } else { + wx.showToast({ title: '已补充,稍后自动更新', icon: 'none' }) + } + }) + }) + }, + /** * 点击添加按钮:直接进添加纪念日(方案 B:流程合并,姓名不存在自动建人) */ diff --git a/pages/index/index.wxml b/pages/index/index.wxml index 48f90a6..6a9a88a 100644 --- a/pages/index/index.wxml +++ b/pages/index/index.wxml @@ -18,6 +18,12 @@ + + + ⚠️ + {{atRiskText}}的生日提醒可能发不出去,点这里补上 + + diff --git a/pages/index/index.wxss b/pages/index/index.wxss index 3d4bf39..cae96ae 100644 --- a/pages/index/index.wxss +++ b/pages/index/index.wxss @@ -391,3 +391,27 @@ page { line-height: 1; margin-top: -4rpx; } + +/* 额度风险提示卡片:仅在有提醒可能发不出去时出现 */ +.quota-warn { + display: flex; + align-items: center; + margin: 0 32rpx 24rpx; + padding: 24rpx 28rpx; + background: #FBF3E4; + border: 2rpx solid #E0C89A; + border-radius: 12rpx; +} + +.quota-warn-icon { + flex-shrink: 0; + margin-right: 16rpx; + font-size: 32rpx; +} + +.quota-warn-text { + flex: 1; + font-size: 26rpx; + line-height: 1.5; + color: #8A5A1F; +} diff --git a/server/package.json b/server/package.json index dd133d6..5cc202f 100644 --- a/server/package.json +++ b/server/package.json @@ -5,7 +5,8 @@ "main": "src/index.js", "scripts": { "start": "node src/index.js", - "dev": "node --watch src/index.js" + "dev": "node --watch src/index.js", + "test": "node --test test/" }, "dependencies": { "axios": "^1.7.7", diff --git a/server/src/atRisk.js b/server/src/atRisk.js new file mode 100644 index 0000000..f67c21c --- /dev/null +++ b/server/src/atRisk.js @@ -0,0 +1,74 @@ +/** + * 风险预警计算:算出未来一段时间内,有哪些提醒会因额度不足发不出去 + * + * 首页拿这个结果给用户看「谁的提醒有风险」,而不是「还剩几次额度」—— + * 用户理解不了额度这种抽象概念,但看得懂人名。 + */ + +const { getNextOccurrence, daysBetween } = require('./occurrence') +const { importanceRank } = require('./importance') + +// 预警窗口。太短则提示不及时,太长会拿几个月后的事惊扰用户,两个月是折中。 +const LOOKAHEAD_DAYS = 60 + +/** + * 把一条纪念日展开成未来窗口内的提醒事件 + * 每条纪念日最多产生两个事件:提前 N 天一个、当天一个 + */ +function expandEvents(anniv, today = new Date()) { + const next = getNextOccurrence(anniv, today) + const onDayIn = daysBetween(next, today) + const remindDays = anniv.remindDays || 0 + + const events = [{ + anniversaryId: anniv.id, + personName: anniv.personName, + kind: 'onDay', + fireInDays: onDayIn, + importance: anniv.importance + }] + + if (remindDays > 0) { + events.push({ + anniversaryId: anniv.id, + personName: anniv.personName, + kind: 'ahead', + fireInDays: onDayIn - remindDays, + importance: anniv.importance + }) + } + + // 已经过去的(如提前提醒的时点早已过)和超出窗口的都不算 + return events.filter(e => e.fireInDays >= 0 && e.fireInDays <= LOOKAHEAD_DAYS) +} + +/** + * 算出有风险的提醒 + * + * 排序必须与 reminder.js 的 runForUser 完全一致,否则首页提示的人名会跟 + * 定时任务实际跳过的人对不上:条数一样、人名却错了,用户会去补错人的额度。 + * 排序规则:先按 fireInDays 升序(跨天的额度是先到先消耗), + * 同一天内再按「当天 > 提前」,然后按重要程度降序(重要程度权重见 importance.js)。 + */ +function computeAtRisk(anniversaries, balance, today = new Date()) { + const events = (anniversaries || []) + .filter(a => a && a.remindEnabled) + .flatMap(a => expandEvents(a, today)) + .sort((x, y) => { + if (x.fireInDays !== y.fireInDays) return x.fireInDays - y.fireInDays + if (x.kind !== y.kind) return x.kind === 'onDay' ? -1 : 1 + return importanceRank(x.importance) - importanceRank(y.importance) + }) + + const safeCount = Math.max(0, balance) + const atRisk = events.slice(safeCount) + + const names = [] + for (const e of atRisk) { + if (e.personName && !names.includes(e.personName)) names.push(e.personName) + } + + return { atRiskCount: atRisk.length, atRiskNames: names } +} + +module.exports = { LOOKAHEAD_DAYS, expandEvents, computeAtRisk } diff --git a/server/src/db.js b/server/src/db.js index 2bf4f0c..760f604 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 @@ -78,4 +86,50 @@ tryAddColumn('anniversaries', 'lunarMonth', 'INTEGER') tryAddColumn('anniversaries', 'lunarDay', 'INTEGER') tryAddColumn('anniversaries', 'isLeapMonth', 'INTEGER DEFAULT 0') +// ---- 版本化数据迁移 ---- +// +// 用 SQLite 的 PRAGMA user_version 记录「这个库已经跑到第几版迁移」。 +// 它是存在数据库文件头里的一个整数,读写都随事务提交/回滚,天生适合做迁移标记 +// (见 MAINTENANCE.md 场景 11)。新增迁移的做法:写一个 migrateVN(), +// 在 runMigrations 里加一行 `if (current < N) migrateVN()`,并把 SCHEMA_VERSION 改成 N。 +const SCHEMA_VERSION = 1 + +/** + * v1:给存量用户补订阅额度初始值 + * + * Why:subscribe_quota 是随「订阅消息额度治理」新建的表,所有存量用户余额都是 0。 + * 而 reminder.js 在余额为 0 时只会发一条探针,其余按优先级取舍——不补的话, + * 后端一上线,老用户的提醒会立刻大面积降级,而他们手机上还是旧版小程序, + * 根本没有补额度的入口。 + * 存量用户过去每存一条纪念日就授权过一次订阅,所以按「该用户的纪念日条数」 + * 估算初始余额是合理的(这也是原计划文档里那条手工 SQL 的口径)。 + * 做成代码里的自动迁移是为了「部署即执行」,不依赖人记得上服务器敲命令。 + * + * 幂等性有两层保证:外层的 user_version 版本号(跑过就不再进来), + * 以及 INSERT OR IGNORE(只给 subscribe_quota 里尚无记录的 openid 补,绝不覆盖已有记录)。 + */ +function migrateV1() { + db.prepare(` + INSERT OR IGNORE INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime) + SELECT openid, COUNT(*), COUNT(*), 0, ? + FROM anniversaries + WHERE openid IS NOT NULL + GROUP BY openid + `).run(Date.now()) +} + +function runMigrations() { + const current = db.pragma('user_version', { simple: true }) + if (current >= SCHEMA_VERSION) return + + // 整段包在事务里:迁移和版本号必须同生共死,中途崩了要能整体回滚重来 + db.transaction(() => { + if (current < 1) migrateV1() + db.pragma(`user_version = ${SCHEMA_VERSION}`) + })() + + console.log(`[db] 数据迁移完成: user_version ${current} -> ${SCHEMA_VERSION}`) +} +runMigrations() + module.exports = db diff --git a/server/src/importance.js b/server/src/importance.js new file mode 100644 index 0000000..423801b --- /dev/null +++ b/server/src/importance.js @@ -0,0 +1,14 @@ +/** + * 重要程度排序权重(纯函数) + * + * 从 reminder.js 抽出,供「定时发送」(reminder.js)与「风险预警」(atRisk.js) + * 共用同一套排序权重,避免两处各写一套、后续改一处忘了改另一处导致排序不一致。 + */ + +// 重要程度排序权重,未知值排最后 +const IMPORTANCE_RANK = { high: 0, medium: 1, low: 2 } +function importanceRank(v) { + return IMPORTANCE_RANK[v] === undefined ? 3 : IMPORTANCE_RANK[v] +} + +module.exports = { importanceRank } diff --git a/server/src/index.js b/server/src/index.js index fda56d1..ea81541 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -4,6 +4,8 @@ const express = require('express') const db = require('./db') const wx = require('./wx') const reminder = require('./reminder') +const quota = require('./quota') +const atRisk = require('./atRisk') const app = express() app.use(express.json({ limit: '1mb' })) @@ -221,6 +223,41 @@ function getPersons(openid) { return { success: true, data: rows } } +// ---- 订阅额度 ---- + +// 前端授权成功后上报。纯累加、不去重:微信侧确实每次授权都 +1,如实记录即可。 +function grantQuota(openid, data) { + const count = (data && data.count) || 1 + return { success: true, balance: quota.grant(openid, count) } +} + +// 首页查询:返回余额和「哪几个人的提醒有风险」 +function getQuotaStatus(openid) { + const balance = quota.getBalance(openid) + // ORDER BY id 必须与 reminder.js 的取数保持一致:平局(同日、同 kind、同 importance)时 + // 先后顺序完全由这里决定,两边不一致就会出现「预告的人名不是真正被跳过的人」 + const rows = db.prepare('SELECT * FROM anniversaries WHERE openid = ? AND remindEnabled = 1 ORDER BY id').all(openid) + const { atRiskCount, atRiskNames } = atRisk.computeAtRisk(rows.map(normalize), balance) + return { success: true, balance, atRiskCount, atRiskNames } +} + +app.post('/api/subscribe', (req, res) => { + try { + const openid = req.headers['x-openid'] || req.body.openid + if (!openid) return res.json({ success: false, error: '缺少 openid' }) + + const { action, data } = req.body + switch (action) { + case 'grant': return res.json(grantQuota(openid, data)) + case 'get': return res.json(getQuotaStatus(openid)) + default: return res.json({ success: false, error: '未知操作' }) + } + } catch (err) { + console.error('订阅额度操作失败:', err.message) + res.json({ success: false, error: err.message }) + } +}) + // 手动触发提醒任务(便于调试,无需等到定点) app.post('/api/reminder/run', async (req, res) => { try { @@ -231,10 +268,17 @@ app.post('/api/reminder/run', async (req, res) => { } }) +module.exports = { grantQuota, getQuotaStatus } + // ---- 启动 ---- -const PORT = process.env.PORT || 3000 -app.listen(PORT, () => { - console.log(`生日提醒后端已启动,监听 ${PORT}`) - reminder.start() -}) +// 只在直接执行本文件(node src/index.js / npm start)时才真正监听端口、注册定时任务。 +// 测试用 require('../src/index') 只是拿函数引用,不需要(也不能)真的起服务—— +// 否则会占住端口且 cron 定时器让进程一直不退出,测试永远卡住。 +if (require.main === module) { + const PORT = process.env.PORT || 3000 + app.listen(PORT, () => { + console.log(`生日提醒后端已启动,监听 ${PORT}`) + reminder.start() + }) +} diff --git a/server/src/occurrence.js b/server/src/occurrence.js new file mode 100644 index 0000000..08a0fcc --- /dev/null +++ b/server/src/occurrence.js @@ -0,0 +1,48 @@ +/** + * 纪念日日期计算(纯函数,today 可注入以便测试) + * + * 从 reminder.js 抽出,供定时发送与「风险预警」计算共用,避免两处各写一套算错。 + */ + +const lunar = require('./lunar') + +// 把日期归到当天 00:00:00,消除时分秒对比较和天数差的干扰 +function startOfDay(date) { + const d = new Date(date) + d.setHours(0, 0, 0, 0) + return d +} + +/** + * 算出某条纪念日的下一次发生日(今天或以后最近的一次) + * + * 农历分支以「农历年」为基准递推:公历年和农历年不对齐,农历腊月通常落在下一个公历年, + * 直接拿公历年当农历年传会整整错一年。 + * isLunar 为真但缺 lunarMonth/lunarDay 的老数据,安全降级按公历算。 + */ +function getNextOccurrence(anniv, today = new Date()) { + const base = startOfDay(today) + + if (anniv.isLunar && anniv.lunarMonth && anniv.lunarDay) { + const wantLeap = !!anniv.isLeapMonth + const todayLunar = lunar.solarToLunar(base) + let target = startOfDay(lunar.lunarToSolar(todayLunar.year, anniv.lunarMonth, anniv.lunarDay, wantLeap)) + if (target < base) { + target = startOfDay(lunar.lunarToSolar(todayLunar.year + 1, anniv.lunarMonth, anniv.lunarDay, wantLeap)) + } + return target + } + + let target = startOfDay(new Date(base.getFullYear(), anniv.solarMonth - 1, anniv.solarDay)) + if (target < base) { + target = startOfDay(new Date(base.getFullYear() + 1, anniv.solarMonth - 1, anniv.solarDay)) + } + return target +} + +// 距离目标日还有多少天(负数表示已过) +function daysBetween(target, today = new Date()) { + return Math.round((startOfDay(target) - startOfDay(today)) / 86400000) +} + +module.exports = { startOfDay, getNextOccurrence, daysBetween } diff --git a/server/src/quota.js b/server/src/quota.js new file mode 100644 index 0000000..5e30778 --- /dev/null +++ b/server/src/quota.js @@ -0,0 +1,65 @@ +/** + * 订阅消息额度记账 + * + * ⚠️ 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) +} + +// 消费:即便该 openid 从未 grant 过也要建行,否则 sentTotal 会静默丢失 +// (新建行 balance 直接落 0,等价于「从 0 扣减再取 MAX(0, ...)」) +function consume(openid, count = 1) { + const n = _normalize(count) + db.prepare(` + INSERT INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime) + VALUES (@openid, 0, 0, @n, @now) + ON CONFLICT(openid) DO UPDATE SET + balance = MAX(0, balance - @n), + sentTotal = sentTotal + @n, + updateTime = @now + `).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/src/reminder.js b/server/src/reminder.js index b322daa..425ce55 100644 --- a/server/src/reminder.js +++ b/server/src/reminder.js @@ -1,7 +1,9 @@ const cron = require('node-cron') const db = require('./db') const wx = require('./wx') -const lunar = require('./lunar') +const occurrence = require('./occurrence') +const quota = require('./quota') +const { importanceRank } = require('./importance') const TEMPLATE_ID = process.env.WX_TEMPLATE_ID const MINIPROGRAM_STATE = process.env.WX_MINIPROGRAM_STATE || 'formal' @@ -27,40 +29,6 @@ function formatDate(date) { return `${y}年${m}月${d}日` } -// 算出"距离今年(或明年)这个纪念日还有多少天" -// 农历纪念日按 lunarMonth/lunarDay 计算每年对应的公历日期,否则按 solarMonth/solarDay -function getThisYearDate(anniv) { - const today = new Date() - today.setHours(0, 0, 0, 0) - - if (anniv.isLunar && anniv.lunarMonth && anniv.lunarDay) { - // 闰月生日按 A 方案处理:若当年无对应闰月,lunarToSolar 内部已自动按普通月份算 - const todayLunar = lunar.solarToLunar(today) - const wantLeap = !!anniv.isLeapMonth - let target = lunar.lunarToSolar(todayLunar.year, anniv.lunarMonth, anniv.lunarDay, wantLeap) - target.setHours(0, 0, 0, 0) - if (target < today) { - target = lunar.lunarToSolar(todayLunar.year + 1, anniv.lunarMonth, anniv.lunarDay, wantLeap) - target.setHours(0, 0, 0, 0) - } - return target - } - - let target = new Date(today.getFullYear(), anniv.solarMonth - 1, anniv.solarDay) - target.setHours(0, 0, 0, 0) - if (target < today) { - target = new Date(today.getFullYear() + 1, anniv.solarMonth - 1, anniv.solarDay) - target.setHours(0, 0, 0, 0) - } - return target -} - -function daysBetween(target) { - const today = new Date() - today.setHours(0, 0, 0, 0) - return Math.round((target - today) / 86400000) -} - // 检查今天是否已经给这条纪念日发过提醒 function alreadySentToday(anniversaryId) { const start = new Date() @@ -76,71 +44,163 @@ const insertLog = db.prepare(` VALUES (@anniversaryId, @personName, @typeName, @daysUntil, @sendDate, @status, @error) `) -async function runOnce() { - console.log('[reminder] 开始扫描纪念日...') +// 43101 = 用户拒收或下发次数不足,是我们与微信侧对账的唯一信号 +function isQuotaError(err) { + return err && err.errcode === 43101 +} - const list = db.prepare('SELECT * FROM anniversaries WHERE remindEnabled = 1').all() - console.log(`[reminder] 启用提醒的纪念日 ${list.length} 条`) +/** + * 处理单个用户的当日提醒 + * + * 按「当天 > 提前」、同级「重要程度降序」排序后依次发送。 + * 记账余额耗尽时不会直接全部放弃,而是发一条「探针」向微信求证(见下方 probe 注释)。 + */ +async function runForUser(openid, items, today = new Date()) { + const due = [] + for (const anniv of items) { + const target = occurrence.getNextOccurrence(anniv, today) + const daysUntil = occurrence.daysBetween(target, today) + const remindDays = anniv.remindDays || 0 - let ok = 0 - let fail = 0 + const isOnDay = daysUntil === 0 + const isAhead = remindDays > 0 && daysUntil === remindDays + if (!isOnDay && !isAhead) continue + if (alreadySentToday(anniv.id)) continue - for (const anniv of list) { - try { - const target = getThisYearDate(anniv) - const daysUntil = daysBetween(target) + due.push({ anniv, target, daysUntil, kind: isOnDay ? 'onDay' : 'ahead' }) + } - const shouldRemind = (daysUntil === 0) || (daysUntil === (anniv.remindDays || 0)) - if (!shouldRemind) continue + due.sort((x, y) => { + if (x.kind !== y.kind) return x.kind === 'onDay' ? -1 : 1 + return importanceRank(x.anniv.importance) - importanceRank(y.anniv.importance) + }) - if (alreadySentToday(anniv.id)) { - console.log(`[reminder] 今天已发过,跳过: ${anniv.personName}`) + let balance = quota.getBalance(openid) + let halted = false + let ok = 0, fail = 0, skipped = 0 + + // ---- 探针机制 ---- + // balance 只有「前端授权后上报 +1」这一条上升通道。没升级小程序的老用户在微信侧 + // 仍在真实累积额度(每存一条纪念日就授权一次)却永远不会上报,于是我们的记账会 + // 系统性低估。如果低估到 0 就一条都不发,反而比改造前更糟——改造前至少还会试一下。 + // + // 所以余额为 0 时仍然把「优先级最高的那一条」发出去,拿它当探针问微信要个答案: + // 探针成功 → 证实微信侧确实还有额度、是我们低估了,本轮继续往下发, + // 直到遇到 43101 或发完 + // 探针返回 43101 → 证实确实没额度,立即中止,剩余全部记 skipped(与原行为一致) + // 探针遇非额度错误 → 什么都没证明(网络/配置问题),记 failed、不动余额、不中止 + // 每个用户每轮最多探一次:在「有额度」这个结论被证实之前,不值得再赌第二个请求。 + let probeUsed = false // 本轮已经用掉探针机会 + let quotaProven = false // 探针已证实微信侧有额度,后续不必再受记账余额约束 + + // errorMsg 默认是「根本没发请求」的兜底文案;真正打了请求并被 43101 拒绝的那一条 + // 会传入微信返回的原始错误信息,两者都记为 skipped,但 error 字段要能区分开 + const logSkip = (item, errorMsg = 'quota_exhausted') => insertLog.run({ + anniversaryId: item.anniv.id, + personName: item.anniv.personName, + typeName: getTypeName(item.anniv.type, item.anniv.customTypeName), + daysUntil: item.daysUntil, + sendDate: Date.now(), + status: 'skipped', + error: errorMsg + }) + + for (const item of due) { + if (halted) { + logSkip(item); skipped++ + continue + } + + // 记账余额为 0 且尚未证实微信侧有额度时,只允许发一条探针 + let isProbe = false + if (balance <= 0 && !quotaProven) { + if (probeUsed) { + logSkip(item); skipped++ continue } + isProbe = true + probeUsed = true + } - const typeName = getTypeName(anniv.type, anniv.customTypeName) - + const anniv = item.anniv + const typeName = getTypeName(anniv.type, anniv.customTypeName) + try { await wx.sendSubscribeMessage({ - touser: anniv.openid, + touser: openid, page: 'pages/index/index', templateId: TEMPLATE_ID, miniprogramState: MINIPROGRAM_STATE, data: { name1: { value: anniv.personName }, - thing2: { value: daysUntil === 0 ? '今天' : `还有${daysUntil}天` }, - thing6: { value: formatDate(target) }, + thing2: { value: item.daysUntil === 0 ? '今天' : `还有${item.daysUntil}天` }, + thing6: { value: formatDate(item.target) }, thing5: { value: anniv.remark || '别忘了准备一份礼物哦!' } } }) - + // 探针成功时余额怎么记:照常 consume。 + // consume 内部是 MAX(0, balance - 1),余额本来就是 0,扣不出负数, + // 净效果是「balance 保持 0、sentTotal +1」。 + // 之所以不趁机把 balance 调高:微信不提供余额查询,探针只证明「至少还有 1 条」, + // 凭空补一个猜出来的数字会让首页的风险预告变成乐观的假话。宁可让 balance 保持 + // 保守的 0,靠每轮的探针去发现真实额度——账面继续低估是安全的,因为低估不再等于停发。 + quota.consume(openid, 1) + balance = Math.max(0, balance - 1) + if (isProbe) { + quotaProven = true + console.log(`[reminder] ${openid} 探针发送成功,微信侧仍有额度,记账偏低,本轮继续发送`) + } insertLog.run({ - anniversaryId: anniv.id, - personName: anniv.personName, - typeName, - daysUntil, - sendDate: Date.now(), - status: 'success', - error: null + anniversaryId: anniv.id, personName: anniv.personName, typeName, + daysUntil: item.daysUntil, sendDate: Date.now(), status: 'success', error: null }) ok++ - console.log(`[reminder] 发送成功: ${anniv.personName} (${typeName}, ${daysUntil}天)`) + console.log(`[reminder] 发送成功: ${anniv.personName} (${typeName}, ${item.daysUntil}天)`) } catch (err) { - fail++ - console.error(`[reminder] 发送失败: ${anniv.personName}`, err.message) - insertLog.run({ - anniversaryId: anniv.id, - personName: anniv.personName, - typeName: null, - daysUntil: null, - sendDate: Date.now(), - status: 'failed', - error: err.message - }) + if (isQuotaError(err)) { + // 微信侧实际已无额度(或用户关了通知总开关),归零并停止骚扰 + quota.reset(openid) + balance = 0 + halted = true + logSkip(item, err.message); skipped++ + console.warn(`[reminder] ${openid} 额度已耗尽,本轮剩余全部跳过: ${err.message}`) + } else { + insertLog.run({ + anniversaryId: anniv.id, personName: anniv.personName, typeName: null, + daysUntil: null, sendDate: Date.now(), status: 'failed', error: err.message + }) + fail++ + console.error(`[reminder] 发送失败: ${anniv.personName}`, err.message) + } } } - console.log(`[reminder] 完成: 成功 ${ok}, 失败 ${fail}`) - return { total: list.length, ok, fail } + return { ok, fail, skipped } +} + +async function runOnce() { + console.log('[reminder] 开始扫描纪念日...') + // ORDER BY id 不是为了排序好看,而是为了「平局可复现」: + // 同一天、同 kind、同 importance 的条目在 sort 里比不出先后(JS sort 是稳定的), + // 最终取舍就取决于 SQLite 的返回顺序。这里和 index.js 的 getQuotaStatus 必须用同一个 + // 兜底顺序,否则首页预告「谁有风险」的人名会和实际被跳过的人对不上。 + const list = db.prepare('SELECT * FROM anniversaries WHERE remindEnabled = 1 ORDER BY id').all() + console.log(`[reminder] 启用提醒的纪念日 ${list.length} 条`) + + // 额度是按用户算的,所以必须分组处理 + const byOpenid = new Map() + for (const a of list) { + if (!byOpenid.has(a.openid)) byOpenid.set(a.openid, []) + byOpenid.get(a.openid).push(a) + } + + let ok = 0, fail = 0, skipped = 0 + for (const [openid, items] of byOpenid) { + const r = await runForUser(openid, items) + ok += r.ok; fail += r.fail; skipped += r.skipped + } + + console.log(`[reminder] 完成: 成功 ${ok}, 失败 ${fail}, 跳过 ${skipped}`) + return { total: list.length, ok, fail, skipped } } function start() { @@ -153,4 +213,4 @@ function start() { console.log(`[reminder] 定时任务已注册: ${expr} (Asia/Shanghai)`) } -module.exports = { start, runOnce } +module.exports = { start, runOnce, runForUser } diff --git a/server/src/wx.js b/server/src/wx.js index fb77a4f..4ec80be 100644 --- a/server/src/wx.js +++ b/server/src/wx.js @@ -49,7 +49,9 @@ async function sendSubscribeMessage({ touser, page, data, templateId, miniprogra } const { data: res } = await axios.post(url, body, { timeout: 8000 }) if (res.errcode !== 0) { - throw new Error(`发送订阅消息失败: ${JSON.stringify(res)}`) + const err = new Error(`发送订阅消息失败: ${JSON.stringify(res)}`) + err.errcode = res.errcode // 让调用方能精确判断 43101,而不是靠字符串匹配 + throw err } return res } diff --git a/server/test/atRisk.test.js b/server/test/atRisk.test.js new file mode 100644 index 0000000..d0938b7 --- /dev/null +++ b/server/test/atRisk.test.js @@ -0,0 +1,125 @@ +const test = require('node:test') +const assert = require('node:assert') +const { expandEvents, computeAtRisk, LOOKAHEAD_DAYS } = require('../src/atRisk') + +const TODAY = new Date(2026, 7, 15) // 2026-08-15 + +// 距今 10 天的公历纪念日,提前 3 天提醒 +const near = { + id: 'a1', personName: '张三', remindEnabled: 1, importance: 'high', + isLunar: false, solarMonth: 8, solarDay: 25, remindDays: 3 +} +// 距今 40 天 +const mid = { + id: 'a2', personName: '李四', remindEnabled: 1, importance: 'low', + isLunar: false, solarMonth: 9, solarDay: 24, remindDays: 7 +} + +test('LOOKAHEAD_DAYS 为 60', () => { + assert.strictEqual(LOOKAHEAD_DAYS, 60) +}) + +test('一条纪念日展开出「当天」和「提前」两个事件', () => { + const events = expandEvents(near, TODAY) + assert.strictEqual(events.length, 2) + const onDay = events.find(e => e.kind === 'onDay') + const ahead = events.find(e => e.kind === 'ahead') + assert.strictEqual(onDay.fireInDays, 10) + assert.strictEqual(ahead.fireInDays, 7) +}) + +test('remindDays 为 0 时只有当天事件', () => { + const events = expandEvents({ ...near, remindDays: 0 }, TODAY) + assert.strictEqual(events.length, 1) + assert.strictEqual(events[0].kind, 'onDay') +}) + +test('超出 60 天窗口的事件被过滤掉', () => { + const far = { ...near, solarMonth: 12, solarDay: 25, remindDays: 3 } + assert.strictEqual(expandEvents(far, TODAY).length, 0) +}) + +test('提前日已过、当天未到时,只剩当天事件', () => { + // 距今 2 天,但提前 7 天的时点早已过去 + const soon = { ...near, solarMonth: 8, solarDay: 17, remindDays: 7 } + const events = expandEvents(soon, TODAY) + assert.strictEqual(events.length, 1) + assert.strictEqual(events[0].kind, 'onDay') +}) + +test('余额足够时没有风险', () => { + const r = computeAtRisk([near, mid], 10, TODAY) + assert.strictEqual(r.atRiskCount, 0) + assert.deepStrictEqual(r.atRiskNames, []) +}) + +test('余额为 0 时全部有风险', () => { + const r = computeAtRisk([near, mid], 0, TODAY) + assert.strictEqual(r.atRiskCount, 4) + assert.deepStrictEqual(r.atRiskNames, ['张三', '李四']) +}) + +test('按时间先后消耗额度,后面的才有风险', () => { + // 张三的两个事件在第 7、10 天,李四的在第 33、40 天 + const r = computeAtRisk([near, mid], 2, TODAY) + assert.strictEqual(r.atRiskCount, 2) + assert.deepStrictEqual(r.atRiskNames, ['李四']) +}) + +test('人名去重,同一人占两条只出现一次', () => { + const r = computeAtRisk([near], 0, TODAY) + assert.strictEqual(r.atRiskCount, 2) + assert.deepStrictEqual(r.atRiskNames, ['张三']) +}) + +test('未开启提醒的纪念日不参与计算', () => { + const r = computeAtRisk([{ ...near, remindEnabled: 0 }], 0, TODAY) + assert.strictEqual(r.atRiskCount, 0) +}) + +// ------ 同一天内排序需与 reminder.js 的 runForUser 完全对齐 ------ +// 四条纪念日都恰好在第 5 天产生一个事件: +// 甲(当天/high) 乙(当天/low) 丙(提前/high) 丁(提前/medium) +// 丙、丁的「当天」事件被安排在第 65 天(超出 60 天窗口),不会进入计算, +// 这样每人恰好只贡献一个事件,排序结果可以精确断言。 +const onDayHigh = { + id: 'e1', personName: '甲', remindEnabled: 1, importance: 'high', + isLunar: false, solarMonth: 8, solarDay: 20, remindDays: 0 +} +const onDayLow = { + id: 'e2', personName: '乙', remindEnabled: 1, importance: 'low', + isLunar: false, solarMonth: 8, solarDay: 20, remindDays: 0 +} +const aheadHigh = { + id: 'e3', personName: '丙', remindEnabled: 1, importance: 'high', + isLunar: false, solarMonth: 10, solarDay: 19, remindDays: 60 +} +const aheadMedium = { + id: 'e4', personName: '丁', remindEnabled: 1, importance: 'medium', + isLunar: false, solarMonth: 10, solarDay: 19, remindDays: 60 +} +// 刻意打乱输入顺序(不按「当天>提前、重要程度降序」排列), +// 这样如果排序逻辑退化成只按 fireInDays 排(Array.sort 是稳定排序, +// 同值会保留输入顺序),测试才能真正暴露出排序规则失效,而不是被输入顺序碰巧掩盖。 +const sameDayGroup = [aheadMedium, aheadHigh, onDayLow, onDayHigh] + +test('同一天内,当天事件排在提前事件之前(与 reminder.js 一致)', () => { + // 额度只够 2 条:应先消耗「当天」的甲、乙,风险留给「提前」的丙、丁 + const r = computeAtRisk(sameDayGroup, 2, TODAY) + assert.strictEqual(r.atRiskCount, 2) + assert.deepStrictEqual(r.atRiskNames, ['丙', '丁']) +}) + +test('同一天内,当天事件按重要程度降序(high 先于 low)', () => { + // 额度只够 1 条:当天里 high 的甲应排在 low 的乙前面,先被消耗 + const r = computeAtRisk(sameDayGroup, 1, TODAY) + assert.strictEqual(r.atRiskCount, 3) + assert.deepStrictEqual(r.atRiskNames, ['乙', '丙', '丁']) +}) + +test('同一天内,提前事件按重要程度降序(high 先于 medium)', () => { + // 额度够 3 条:甲、乙(当天)先消耗,提前事件里 high 的丙应先于 medium 的丁被消耗 + const r = computeAtRisk(sameDayGroup, 3, TODAY) + assert.strictEqual(r.atRiskCount, 1) + assert.deepStrictEqual(r.atRiskNames, ['丁']) +}) diff --git a/server/test/helper.js b/server/test/helper.js new file mode 100644 index 0000000..a1f43f0 --- /dev/null +++ b/server/test/helper.js @@ -0,0 +1,13 @@ +const fs = require('fs') +const os = require('os') +const path = require('path') + +// 把 DB_PATH 指向临时目录,避免测试碰到真实库。 +// 必须在 require('../src/db') 之前调用,因为 db.js 在 require 时就读取 DB_PATH。 +function useTempDb() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bday-test-')) + process.env.DB_PATH = path.join(dir, 'test.db') + return dir +} + +module.exports = { useTempDb } diff --git a/server/test/migration.test.js b/server/test/migration.test.js new file mode 100644 index 0000000..f585b22 --- /dev/null +++ b/server/test/migration.test.js @@ -0,0 +1,112 @@ +const test = require('node:test') +const assert = require('node:assert') +const { execFileSync } = require('node:child_process') +const Database = require('better-sqlite3') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +// db.js 的迁移是在 require 时执行一次的(模块单例),所以没法在同一个进程里反复触发。 +// 这里用子进程来「启动一次后端的数据层」,父进程只用 better-sqlite3 直接开同一个文件做 +// 前置造数和结果断言。全程使用系统临时目录,绝不碰 server/data/birthday.db。 +const DB_JS = path.join(__dirname, '..', 'src', 'db.js') + +function bootDb(dbPath) { + execFileSync(process.execPath, ['-e', 'require(process.env.MIGRATION_ENTRY)'], { + env: { ...process.env, MIGRATION_ENTRY: DB_JS, DB_PATH: dbPath }, + stdio: 'ignore' + }) +} + +function tempDbPath() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bday-mig-')) + return path.join(dir, 'mig.db') +} + +// 造一个「老库」:表结构齐全、有纪念日数据,但 subscribe_quota 为空、user_version 还是 0。 +// 这正是本次改造上线时线上生产库的样子。 +function makeLegacyDb(rows) { + const dbPath = tempDbPath() + bootDb(dbPath) // 先借 db.js 建出完整表结构 + const raw = new Database(dbPath) + const insert = raw.prepare(` + INSERT INTO anniversaries (id, openid, personName, type, solarMonth, solarDay, remindEnabled, remindDays) + VALUES (?, ?, ?, 'birthday', 1, 1, 1, 3) + `) + for (const [id, openid] of rows) insert.run(id, openid, '某人') + raw.prepare('DELETE FROM subscribe_quota').run() + raw.pragma('user_version = 0') + raw.close() + return dbPath +} + +function readQuota(dbPath) { + const raw = new Database(dbPath, { readonly: true }) + const list = raw.prepare('SELECT * FROM subscribe_quota ORDER BY openid').all() + const version = raw.pragma('user_version', { simple: true }) + raw.close() + return { list, version } +} + +test('迁移 v1:按纪念日条数给存量用户补初始余额', () => { + const dbPath = makeLegacyDb([ + ['a1', 'uOld1'], ['a2', 'uOld1'], ['a3', 'uOld1'], // 3 条 + ['b1', 'uOld2'] // 1 条 + ]) + + bootDb(dbPath) + + const { list, version } = readQuota(dbPath) + assert.strictEqual(version, 1, 'user_version 应被推进到 1') + assert.deepStrictEqual( + list.map(r => [r.openid, r.balance, r.grantedTotal, r.sentTotal]), + [['uOld1', 3, 3, 0], ['uOld2', 1, 1, 0]], + '每个存量用户的初始余额应等于其纪念日条数' + ) +}) + +test('迁移 v1:重复启动不会重复累加(幂等)', () => { + const dbPath = makeLegacyDb([['a1', 'uIdem'], ['a2', 'uIdem']]) + + bootDb(dbPath) // 第一次:补 2 + bootDb(dbPath) // 第二次:user_version 已是 1,应直接跳过 + bootDb(dbPath) // 第三次:同上 + + const { list } = readQuota(dbPath) + assert.strictEqual(list.length, 1) + assert.strictEqual(list[0].balance, 2, '反复启动后余额仍应是 2,不能累加成 4 或 6') + assert.strictEqual(list[0].grantedTotal, 2) +}) + +test('迁移 v1:已有额度记录的用户不被覆盖', () => { + const dbPath = makeLegacyDb([['a1', 'uHas'], ['a2', 'uHas'], ['c1', 'uNone']]) + + // 模拟「已经用新版小程序补过额度」的用户:subscribe_quota 里已有一行 + const raw = new Database(dbPath) + raw.prepare(` + INSERT INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime) + VALUES ('uHas', 7, 9, 2, 0) + `).run() + raw.close() + + bootDb(dbPath) + + const { list } = readQuota(dbPath) + const has = list.find(r => r.openid === 'uHas') + const none = list.find(r => r.openid === 'uNone') + assert.deepStrictEqual( + [has.balance, has.grantedTotal, has.sentTotal], [7, 9, 2], + '已有记录必须原样保留,不能被按纪念日条数估算出来的值覆盖' + ) + assert.strictEqual(none.balance, 1, '尚无记录的用户照常补') +}) + +test('迁移 v1:全新空库也能正常推进版本号', () => { + const dbPath = tempDbPath() + + bootDb(dbPath) + + const { list, version } = readQuota(dbPath) + assert.strictEqual(version, 1) + assert.strictEqual(list.length, 0, '没有纪念日就没有要补的用户') +}) diff --git a/server/test/occurrence.test.js b/server/test/occurrence.test.js new file mode 100644 index 0000000..d02618e --- /dev/null +++ b/server/test/occurrence.test.js @@ -0,0 +1,45 @@ +const test = require('node:test') +const assert = require('node:assert') +const { getNextOccurrence, daysBetween, startOfDay } = require('../src/occurrence') + +const fmt = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + +test('公历纪念日:今年还没到就取今年', () => { + const anniv = { isLunar: false, solarMonth: 12, solarDay: 25 } + assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2026-12-25') +}) + +test('公历纪念日:今年已过就取明年', () => { + const anniv = { isLunar: false, solarMonth: 5, solarDay: 20 } + assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2027-05-20') +}) + +test('农历腊月十五:跨公历年不会算错一年', () => { + const anniv = { isLunar: true, lunarMonth: 12, lunarDay: 15, isLeapMonth: false } + // 2027-01-20 时,下一次应是 2 天后的 2027-01-22,而不是 2028 年 + assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2027, 0, 20))), '2027-01-22') + // 过了之后应跳到下一个农历年 + assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2027, 0, 23))), '2028-01-11') +}) + +test('农历四月十七', () => { + const anniv = { isLunar: true, lunarMonth: 4, lunarDay: 17, isLeapMonth: false } + assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2027-05-22') +}) + +test('isLunar 但缺农历字段时降级按公历算,不抛错', () => { + const anniv = { isLunar: true, lunarMonth: null, lunarDay: null, solarMonth: 6, solarDay: 5 } + assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2027-06-05') +}) + +test('daysBetween 忽略时分秒', () => { + const today = new Date(2026, 7, 15, 23, 59, 0) + const target = new Date(2026, 7, 17, 0, 1, 0) + assert.strictEqual(daysBetween(target, today), 2) +}) + +test('startOfDay 归零时分秒', () => { + const d = startOfDay(new Date(2026, 7, 15, 13, 45, 30)) + assert.strictEqual(d.getHours(), 0) + assert.strictEqual(d.getMinutes(), 0) +}) diff --git a/server/test/quota.test.js b/server/test/quota.test.js new file mode 100644 index 0000000..9a78d55 --- /dev/null +++ b/server/test/quota.test.js @@ -0,0 +1,63 @@ +const test = require('node:test') +const assert = require('node:assert') +const { useTempDb } = require('./helper') + +useTempDb() // 必须在 require quota/db 之前 +const quota = require('../src/quota') +const db = require('../src/db') + +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) +}) + +test('consume 对从未 grant 过的 openid 也要建行,不丢失 sentTotal', () => { + quota.consume('u6', 3) + // 仅断言 getBalance 为 0 不足以证明建行了(缺行时本来就返回 0), + // 必须直接查表,确认行确实被创建且 sentTotal 记录了这次消费 + assert.strictEqual(quota.getBalance('u6'), 0) + const row = db.prepare('SELECT * FROM subscribe_quota WHERE openid = ?').get('u6') + assert.ok(row, 'u6 对应的记录行应当被创建') + assert.strictEqual(row.balance, 0) + assert.strictEqual(row.sentTotal, 3) +}) diff --git a/server/test/reminder.test.js b/server/test/reminder.test.js new file mode 100644 index 0000000..a42934c --- /dev/null +++ b/server/test/reminder.test.js @@ -0,0 +1,315 @@ +const test = require('node:test') +const assert = require('node:assert') +const { useTempDb } = require('./helper') + +useTempDb() +const db = require('../src/db') +const wx = require('../src/wx') +const quota = require('../src/quota') +const reminder = require('../src/reminder') + +// 用「今天」为基准造数据,保证 daysUntil 落在期望值上 +const TODAY = new Date() +const inDays = (n) => new Date(TODAY.getTime() + n * 86400000) + +function makeAnniv(id, openid, personName, importance, offsetDays, remindDays) { + const d = inDays(offsetDays) + return { + id, openid, personId: 'p', personName, type: 'birthday', + isLunar: 0, solarYear: d.getFullYear(), solarMonth: d.getMonth() + 1, solarDay: d.getDate(), + importance, remindEnabled: 1, remindDays + } +} + +function resetLogs() { + db.prepare('DELETE FROM remind_logs').run() +} + +test('额度够时全部发出', async () => { + resetLogs() + quota.grant('uA', 5) + const sent = [] + const restore = wx.sendSubscribeMessage + wx.sendSubscribeMessage = async (p) => { sent.push(p.data.name1.value); return { errcode: 0 } } + + const items = [ + makeAnniv('A1', 'uA', '甲', 'low', 0, 3), + makeAnniv('A2', 'uA', '乙', 'high', 0, 3) + ] + const r = await reminder.runForUser('uA', items, TODAY) + wx.sendSubscribeMessage = restore + + assert.strictEqual(r.ok, 2) + assert.strictEqual(r.skipped, 0) + assert.strictEqual(quota.getBalance('uA'), 3) +}) + +test('额度不足时当天优先、同级按重要程度', async () => { + resetLogs() + quota.grant('uB', 1) + const attempts = [] // 所有真正打出去的请求 + const sent = [] // 其中发送成功的 + const restore = wx.sendSubscribeMessage + // 第一条放行、之后一律 43101。 + // Why 要让微信亲口说 43101:记账余额只有 1,但第二条会被当作「探针」发出去(见探针机制), + // 只有微信侧确认没额度,「额度不足时的取舍」才真正成立。 + wx.sendSubscribeMessage = async (p) => { + attempts.push(p.data.name1.value) + if (attempts.length > 1) { + const e = new Error('发送订阅消息失败') + e.errcode = 43101 + throw e + } + sent.push(p.data.name1.value) + return { errcode: 0 } + } + + try { + const items = [ + makeAnniv('B1', 'uB', '提前的', 'high', 3, 3), // 提前事件(daysUntil=3=remindDays) + makeAnniv('B2', 'uB', '当天低', 'low', 0, 3), // 当天事件 + makeAnniv('B3', 'uB', '当天高', 'high', 0, 3) // 当天事件,重要程度更高 + ] + const r = await reminder.runForUser('uB', items, TODAY) + + assert.strictEqual(r.ok, 1) + assert.strictEqual(r.skipped, 2) + assert.deepStrictEqual(sent, ['当天高'], '应当只发出当天且最重要的那条') + assert.deepStrictEqual(attempts, ['当天高', '当天低'], '探针也要按优先级挑下一条,且被拒后不再打第三个请求') + } finally { + wx.sendSubscribeMessage = restore + } +}) + +test('记账余额为 0 但微信侧仍有额度:探针成功后本轮继续发送', async () => { + resetLogs() + // uP1 从未 grant 过,记账余额为 0——模拟「没升级小程序、授权从不上报」的老用户 + assert.strictEqual(quota.getBalance('uP1'), 0) + const sent = [] + const restore = wx.sendSubscribeMessage + wx.sendSubscribeMessage = async (p) => { sent.push(p.data.name1.value); return { errcode: 0 } } + + try { + const items = [ + makeAnniv('P1', 'uP1', '甲', 'high', 0, 3), + makeAnniv('P2', 'uP1', '乙', 'medium', 0, 3), + makeAnniv('P3', 'uP1', '丙', 'low', 0, 3) + ] + const r = await reminder.runForUser('uP1', items, TODAY) + + assert.strictEqual(r.ok, 3, '探针证实微信侧有额度后,剩下两条也应照常发出') + assert.strictEqual(r.skipped, 0) + assert.deepStrictEqual(sent, ['甲', '乙', '丙']) + assert.strictEqual(quota.getBalance('uP1'), 0, '探针成功不凭空补余额,账面保持保守的 0') + const row = db.prepare('SELECT sentTotal FROM subscribe_quota WHERE openid = ?').get('uP1') + assert.strictEqual(row.sentTotal, 3, 'sentTotal 要如实记录每一次成功发送,便于排查') + } finally { + wx.sendSubscribeMessage = restore + } +}) + +test('记账余额为 0 且微信侧确实没额度:探针被 43101 拒绝后立即中止', async () => { + resetLogs() + const WX_ERROR_MESSAGE = '发送订阅消息失败: {"errcode":43101}' + let called = 0 + const restore = wx.sendSubscribeMessage + wx.sendSubscribeMessage = async () => { + called++ + const e = new Error(WX_ERROR_MESSAGE) + e.errcode = 43101 + throw e + } + + try { + const items = [ + makeAnniv('Q1', 'uP2', '甲', 'high', 0, 3), + makeAnniv('Q2', 'uP2', '乙', 'high', 0, 3), + makeAnniv('Q3', 'uP2', '丙', 'high', 0, 3) + ] + const r = await reminder.runForUser('uP2', items, TODAY) + + assert.strictEqual(called, 1, '只探一次,被拒后不该继续打请求') + assert.strictEqual(r.ok, 0) + assert.strictEqual(r.skipped, 3, '含探针那条在内全部计为 skipped') + assert.strictEqual(quota.getBalance('uP2'), 0) + + const q1 = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'Q1'").get() + const q2 = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'Q2'").get() + assert.strictEqual(q1.error, WX_ERROR_MESSAGE, '探针那条真打了请求,应保留微信原始错误信息') + assert.strictEqual(q2.error, 'quota_exhausted', '后续条目根本没发请求,记兜底文案') + } finally { + wx.sendSubscribeMessage = restore + } +}) + +test('探针遇非额度错误:记 failed、不动余额、本轮不再重复探', async () => { + resetLogs() + let called = 0 + const restore = wx.sendSubscribeMessage + wx.sendSubscribeMessage = async () => { + called++ + const e = new Error('网络炸了') + e.errcode = 40003 + throw e + } + + try { + const items = [ + makeAnniv('R1', 'uP3', '甲', 'high', 0, 3), + makeAnniv('R2', 'uP3', '乙', 'high', 0, 3), + makeAnniv('R3', 'uP3', '丙', 'high', 0, 3) + ] + const r = await reminder.runForUser('uP3', items, TODAY) + + // 网络/配置错误什么都没证明,既不能当作「有额度」继续发,也不该反复赌请求 + assert.strictEqual(called, 1, '探针机会用掉就没了,不该对同一个用户反复重试') + assert.strictEqual(r.fail, 1, '探针那条记 failed,不是 skipped') + assert.strictEqual(r.skipped, 2) + assert.strictEqual(quota.getBalance('uP3'), 0, '非额度错误不动余额') + + const r1 = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'R1'").get() + assert.strictEqual(r1.status, 'failed') + assert.strictEqual(r1.error, '网络炸了') + } finally { + wx.sendSubscribeMessage = restore + } +}) + +test('遇到 43101 立即归零并中止本用户剩余发送', async () => { + resetLogs() + quota.grant('uD', 9) + let called = 0 + const WX_ERROR_MESSAGE = '发送订阅消息失败' + const restore = wx.sendSubscribeMessage + wx.sendSubscribeMessage = async () => { + called++ + const e = new Error(WX_ERROR_MESSAGE) + e.errcode = 43101 + throw e + } + + const items = [ + makeAnniv('D1', 'uD', '甲', 'high', 0, 3), + makeAnniv('D2', 'uD', '乙', 'high', 0, 3), + makeAnniv('D3', 'uD', '丙', 'high', 0, 3) + ] + const r = await reminder.runForUser('uD', items, TODAY) + wx.sendSubscribeMessage = restore + + assert.strictEqual(called, 1, '失败一次就该停,不该继续打请求') + assert.strictEqual(quota.getBalance('uD'), 0, '余额应被归零校正') + assert.strictEqual(r.skipped, 3, '含失败那条在内全部计为 skipped') + + // D1 是真正打了请求、被微信 43101 拒绝的那一条:error 字段应保留微信原始错误信息, + // 而不是「根本没发请求」时用的兜底文案 'quota_exhausted'。 + const d1Log = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'D1'").get() + assert.ok(d1Log, 'D1 应写入 remind_logs') + assert.strictEqual(d1Log.status, 'skipped') + assert.strictEqual(d1Log.error, WX_ERROR_MESSAGE, 'D1 应记录微信原始错误信息,而不是 quota_exhausted 兜底文案') + + // D2、D3 是因为 halted 而被跳过、根本没发起请求的,error 字段应保持默认兜底文案。 + const d2Log = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'D2'").get() + const d3Log = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'D3'").get() + assert.strictEqual(d2Log.error, 'quota_exhausted', 'D2 从未真正发起请求,应为兜底文案') + assert.strictEqual(d3Log.error, 'quota_exhausted', 'D3 从未真正发起请求,应为兜底文案') +}) + +test('非额度类错误只记 failed,不中止后续发送,也不动余额', async () => { + resetLogs() + quota.grant('uE', 4) + let called = 0 + const restore = wx.sendSubscribeMessage + wx.sendSubscribeMessage = async () => { + called++ + const e = new Error('网络炸了') + e.errcode = 40003 + throw e + } + + // 至少 2 条数据:如果有人误把 halted = true 加进非额度错误分支, + // 第二条就不会被尝试,called 会停在 1,这条断言就能抓到 + const items = [ + makeAnniv('E1', 'uE', '甲', 'high', 0, 3), + makeAnniv('E2', 'uE', '乙', 'high', 0, 3) + ] + const r = await reminder.runForUser('uE', items, TODAY) + wx.sendSubscribeMessage = restore + + assert.strictEqual(called, 2, '第二条也应被尝试,证明非额度错误不会误中止后续发送') + assert.strictEqual(r.fail, 2) + assert.strictEqual(quota.getBalance('uE'), 4, '配置/网络错误不该动余额') +}) + +test('skipped 会写入 remind_logs 便于排查', async () => { + resetLogs() + // 余额为 0 时第一条会被当探针发出去,所以要放两条,第二条才是「压根没发请求」的 skipped + const restore = wx.sendSubscribeMessage + wx.sendSubscribeMessage = async () => { + const e = new Error('发送订阅消息失败') + e.errcode = 43101 + throw e + } + try { + const items = [ + makeAnniv('F1', 'uF', '甲', 'high', 0, 3), + makeAnniv('F2', 'uF', '乙', 'high', 0, 3) + ] + await reminder.runForUser('uF', items, TODAY) + const row = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'F2'").get() + assert.ok(row, '应写入 skipped 日志') + assert.strictEqual(row.status, 'skipped') + assert.strictEqual(row.error, 'quota_exhausted') + } finally { + wx.sendSubscribeMessage = restore + } +}) + +// runOnce 是从数据库读数据的(SELECT * FROM anniversaries WHERE remindEnabled = 1), +// 不能像 runForUser 那样直接传数组,必须真的往临时库插行 +function insertAnniv(id, openid, personName, offsetDays, remindDays) { + const d = inDays(offsetDays) + db.prepare(` + INSERT INTO anniversaries + (id, openid, personId, personName, type, isLunar, solarYear, solarMonth, solarDay, + importance, remindEnabled, remindDays, createTime, updateTime) + VALUES (?, ?, 'p', ?, 'birthday', 0, ?, ?, ?, 'high', 1, ?, 0, 0) + `).run(id, openid, personName, d.getFullYear(), d.getMonth() + 1, d.getDate(), remindDays) +} + +test('runOnce 按 openid 分组结算,各用户额度互不影响', async () => { + resetLogs() + // 会读到库里所有 remindEnabled=1 的行,先清空避免受其它用例残留数据干扰 + db.prepare('DELETE FROM anniversaries').run() + + quota.grant('uG', 5) // uG 有额度 + // uH 不 grant,余额保持 0,且微信侧也确实没额度(下面 mock 里对 uH 回 43101) + + insertAnniv('G1', 'uG', '甲', 0, 3) // 今天到期 + insertAnniv('H1', 'uH', '乙', 0, 3) // 今天到期 + + const attempts = [] + const restore = wx.sendSubscribeMessage + wx.sendSubscribeMessage = async (p) => { + attempts.push(p.touser) + if (p.touser === 'uH') { + const e = new Error('发送订阅消息失败') + e.errcode = 43101 + throw e + } + return { errcode: 0 } + } + + try { + const r = await reminder.runOnce() + + assert.strictEqual(r.total, 2) + assert.strictEqual(r.ok, 1, '有额度的 uG 应正常发出') + assert.strictEqual(r.skipped, 1, '微信侧确认没额度的 uH 应被跳过') + assert.strictEqual(r.fail, 0) + assert.deepStrictEqual(attempts, ['uG', 'uH'], 'uH 只有一次探针请求,不能借用 uG 的额度') + assert.strictEqual(quota.getBalance('uG'), 4, 'uG 消费后余额正确减少') + assert.strictEqual(quota.getBalance('uH'), 0, 'uH 的余额不受 uG 影响') + } finally { + wx.sendSubscribeMessage = restore + } +}) diff --git a/server/test/subscribeApi.test.js b/server/test/subscribeApi.test.js new file mode 100644 index 0000000..1133a5c --- /dev/null +++ b/server/test/subscribeApi.test.js @@ -0,0 +1,58 @@ +const test = require('node:test') +const assert = require('node:assert') +const { useTempDb } = require('./helper') + +useTempDb() +const db = require('../src/db') +const { grantQuota, getQuotaStatus } = require('../src/index') + +const OPENID = 'o_test_1' + +// 往指定用户名下插一条纪念日 +function seed(openid, id, personName, offsetDays, remindDays) { + const d = new Date(Date.now() + offsetDays * 86400000) + db.prepare(` + INSERT OR REPLACE INTO anniversaries + (id, openid, personId, personName, type, isLunar, solarYear, solarMonth, solarDay, + importance, remindEnabled, remindDays, createTime, updateTime) + VALUES (?, ?, 'p1', ?, 'birthday', 0, ?, ?, ?, 'high', 1, ?, 0, 0) + `).run(id, openid, personName, d.getFullYear(), d.getMonth() + 1, d.getDate(), remindDays) +} + +seed(OPENID, 'seed1', '张三', 10, 3) + +test('grant 累加并返回新余额', () => { + assert.deepStrictEqual(grantQuota(OPENID, { count: 1 }), { success: true, balance: 1 }) + assert.deepStrictEqual(grantQuota(OPENID, { count: 1 }), { success: true, balance: 2 }) +}) + +test('grant 的 data 缺省时按 1 计', () => { + const before = getQuotaStatus(OPENID).balance + grantQuota(OPENID, undefined) + assert.strictEqual(getQuotaStatus(OPENID).balance, before + 1) +}) + +test('get 返回余额与风险信息', () => { + const res = getQuotaStatus(OPENID) + assert.strictEqual(res.success, true) + assert.strictEqual(typeof res.balance, 'number') + assert.ok(Array.isArray(res.atRiskNames)) +}) + +test('无纪念日时没有风险', () => { + const res = getQuotaStatus('o_empty') + assert.strictEqual(res.atRiskCount, 0) + assert.deepStrictEqual(res.atRiskNames, []) +}) + +test('纪念日多于余额时报出风险人名', () => { + const OP = 'o_test_2' + // 造两条近期纪念日,共 4 个提醒事件,但只给 1 点额度 + seed(OP, 'x1', '甲', 10, 3) + seed(OP, 'x2', '乙', 20, 3) + require('../src/quota').grant(OP, 1) + + const res = getQuotaStatus(OP) + assert.ok(res.atRiskCount >= 1, '应当报出风险') + assert.ok(res.atRiskNames.includes('乙'), '时间靠后的乙必然有风险') +}) diff --git a/server/test/wx.test.js b/server/test/wx.test.js new file mode 100644 index 0000000..d20ac39 --- /dev/null +++ b/server/test/wx.test.js @@ -0,0 +1,38 @@ +const test = require('node:test') +const assert = require('node:assert') +const { useTempDb } = require('./helper') + +// wx.js 本身不连库,但按项目约定统一在 require 业务模块前处理好临时库, +// 避免以后 wx.js 引入了连库逻辑时这份测试悄悄读写到真实数据库。 +useTempDb() + +const axios = require('axios') +const wx = require('../src/wx') + +test('微信返回非 0 errcode 时,抛出的 Error 对象应带上原始 errcode', async () => { + const restoreGet = axios.get + const restorePost = axios.post + + try { + // getAccessToken 走 axios.get,sendSubscribeMessage 走 axios.post,两个都要 mock + axios.get = async () => ({ data: { access_token: 'fake_token', expires_in: 7200 } }) + axios.post = async () => ({ data: { errcode: 43101, errmsg: 'user refuse to accept the msg' } }) + + await assert.rejects( + () => wx.sendSubscribeMessage({ + touser: 'oTest', + page: 'pages/index/index', + templateId: 'tplTest', + data: {} + }), + (err) => { + assert.strictEqual(err.errcode, 43101, 'Error 对象应带上微信返回的原始 errcode,而不是靠字符串匹配') + return true + } + ) + } finally { + // 断言先抛错也要恢复,避免污染同进程内后续用例 + axios.get = restoreGet + axios.post = restorePost + } +}) diff --git a/utils/api.js b/utils/api.js index 72dacbc..6615afa 100644 --- a/utils/api.js +++ b/utils/api.js @@ -72,4 +72,9 @@ function person(action, data) { return request({ url: '/api/person', data: { action, data } }) } -module.exports = { request, login, anniversary, person, BASE_URL } +// 订阅额度:grant 上报授权,get 查询余额与风险 +function subscribe(action, data) { + return request({ url: '/api/subscribe', data: { action, data } }) +} + +module.exports = { request, login, anniversary, person, subscribe, BASE_URL } diff --git a/utils/subscribe.js b/utils/subscribe.js new file mode 100644 index 0000000..ef25a27 --- /dev/null +++ b/utils/subscribe.js @@ -0,0 +1,68 @@ +/** + * 订阅消息统一入口 + * + * ⚠️ 最重要的约束:wx.requestSubscribeMessage 和 wx.openSetting 必须由真实点击手势 + * 直接触发(基础库 2.8.2+),且调用前不能有 await 等异步操作,否则手势上下文丢失、 + * 调用必然失败(报 fail can only be invoked by user TAP gesture)。 + * 所以本模块的 requestAndReport 必须在 bindtap 处理函数里同步调用, + * 用户状态要提前用 getStatus 查好缓存起来,不能现查现用。 + */ + +const sync = require('./sync') + +const TEMPLATE_ID = '6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw' + +/** + * 查询用户当前的订阅设置状态 + * itemSettings 只包含用户勾选过「总是保持以上选择」的模板,据此可判断调用会不会弹窗。 + * @returns {Promise} + * silent — 已长期同意,调用不弹窗,可在任意点击上搭车静默补额度 + * willPrompt — 没勾过,调用会弹窗,只在用户主动点击时才调 + * rejected — 已长期拒绝或被封禁,再调也没用,需引导去设置页 + * mainSwitchOff — 通知总开关关闭,需引导去设置页 + * unknown — 查询失败,按 willPrompt 保守处理 + */ +function getStatus() { + return new Promise((resolve) => { + wx.getSetting({ + withSubscriptions: true, + success: (res) => { + const s = res.subscriptionsSetting || {} + if (s.mainSwitch === false) return resolve('mainSwitchOff') + const item = s.itemSettings && s.itemSettings[TEMPLATE_ID] + if (item === 'accept') return resolve('silent') + if (item === 'reject' || item === 'ban') return resolve('rejected') + resolve('willPrompt') + }, + fail: () => resolve('unknown') + }) + }) +} + +/** + * 请求订阅并把新增额度上报后端 + * 必须在 tap handler 中同步调用(本函数第一条语句就是 wx.requestSubscribeMessage) + * @returns {Promise} 用户是否同意 + */ +function requestAndReport() { + return new Promise((resolve) => { + wx.requestSubscribeMessage({ + tmplIds: [TEMPLATE_ID], + success: (res) => { + const accepted = res[TEMPLATE_ID] === 'accept' + if (!accepted) return resolve(false) + // 走同步队列上报:失败会自动入队,下次启动 flush。 + // 不能静默吞掉——用户已经同意、微信侧额度确实 +1 了,我们漏记会导致余额低估, + // 进而白白跳过本可以发出去的提醒。 + sync.syncOrEnqueue({ kind: 'subscribe', action: 'grant', data: { count: 1 } }) + .then(() => resolve(true)) + }, + fail: (err) => { + console.warn('[subscribe] 订阅失败', err) + resolve(false) + } + }) + }) +} + +module.exports = { TEMPLATE_ID, getStatus, requestAndReport } diff --git a/utils/sync.js b/utils/sync.js index 03cd601..c8b1e5d 100644 --- a/utils/sync.js +++ b/utils/sync.js @@ -24,6 +24,7 @@ function enqueue(item) { async function dispatch(item) { if (item.kind === 'person') return api.person(item.action, item.data) if (item.kind === 'anniversary') return api.anniversary(item.action, item.data) + if (item.kind === 'subscribe') return api.subscribe(item.action, item.data) throw new Error('unknown sync kind: ' + item.kind) }