合并订阅消息额度治理

后端按 openid 记账订阅消息下发次数,发送时按优先级取舍、余额耗尽即停;
前端在用户点击时补额度,并在首页预告哪些人的提醒有风险。

微信一次性订阅消息「授权一次才能发一条」且不提供余额查询接口,原先每条纪念日
每年要发 2 条提醒、用户却只在保存时授权一次,供需倒挂导致提醒大量静默失败。

主要内容:
- 新增 subscribe_quota 表与 /api/subscribe 接口(授权上报 + 风险查询)
- 定时任务改为按用户分组,当天优先于提前、同级按重要程度,43101 时归零并中止
- 余额为 0 时发探针(每人每轮一条),成功则继续发,保证不比改动前更差
- 存量用户初始额度做成 db.js 里的幂等自动迁移,无需手工执行 SQL
- 删除/更新接口改为幂等,避免同步队列永久重试
- 首页新增风险提示卡片作为补额度主入口,文案说人名而非抽象额度
- 从零搭建后端测试基建,48 个测试

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuming
2026-08-16 10:39:20 +08:00
26 changed files with 1476 additions and 122 deletions
+3
View File
@@ -17,3 +17,6 @@ node_modules/
server/.env server/.env
server/data/ server/data/
server/node_modules/ server/node_modules/
# 子代理执行过程的临时产物(ledger、任务简报、评审包)
.superpowers/
@@ -1524,24 +1524,30 @@ Expected: 后端测试全绿,全部文件语法通过
1. `git push origin master` → Gitea Actions 自动部署后端(38 分钟) 1. `git push origin master` → Gitea Actions 自动部署后端(38 分钟)
2. 验证:`curl https://wxserver.ymxixi.space/api/health` 2. 验证:`curl https://wxserver.ymxixi.space/api/health`
3. 验证后端对**线上旧版**小程序仍兼容:旧版不会调 `/api/subscribe``reminder.js``balance = 0` 的老用户会全部记 skipped——**这是本次上线最大的风险点**,见下方说明 3. 验证后端对**线上旧版**小程序仍兼容:旧版不会调 `/api/subscribe`老用户 `balance = 0`——见下方说明(合并前评审已修,风险大幅下降)
4. 确认无误后再上传小程序 → 体验版自测 → 提交审核 → 发布 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 ```bash
# 在群晖上对生产库执行(先备份) # 已由 db.js 的 migrateV1() 自动完成,保留在此仅供理解口径
cp /volume1/docker/apps/birthday-server/data/birthday.db{,.bak-$(date +%F)} # INSERT INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime)
sqlite3 /volume1/docker/apps/birthday-server/data/birthday.db " # SELECT openid, COUNT(*), COUNT(*), 0, strftime('%s','now')*1000
INSERT INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime) # FROM anniversaries GROUP BY openid
SELECT openid, COUNT(*), COUNT(*), 0, strftime('%s','now')*1000 # ON CONFLICT(openid) DO NOTHING;
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)}
```
+47 -20
View File
@@ -3,8 +3,13 @@ const storage = require('../../utils/storage')
const dateUtils = require('../../utils/date') const dateUtils = require('../../utils/date')
const sync = require('../../utils/sync') const sync = require('../../utils/sync')
const lunar = require('../../utils/lunar') const lunar = require('../../utils/lunar')
const subscribe = require('../../utils/subscribe')
Page({ Page({
// 保存中标志。放在实例上而不是 data 里:它只用于防重入,不参与渲染,
// 走 setData 反而会多一次没必要的视图层通信。
submitting: false,
data: { data: {
anniversaryId: null, anniversaryId: null,
personId: 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 const { formData, anniversaryId, inputName } = this.data
let { personId } = this.data let { personId } = this.data
@@ -299,19 +317,26 @@ Page({
return 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) { if (formData.remindEnabled) {
try { this.requestSubscribe()
await this.requestSubscribe()
} catch (err) {
console.log('用户拒绝订阅消息')
}
} }
// 订阅调用已经发出,后面就没有手势上下文的顾虑了,可以安全地置位防重入标志
this.submitting = true
// 新增模式且没绑定 personId → 按姓名查找或自动创建 // 新增模式且没绑定 personId → 按姓名查找或自动创建
if (!anniversaryId && !personId) { if (!anniversaryId && !personId) {
const person = storage.ensurePerson(name) const person = storage.ensurePerson(name)
if (!person) { if (!person) {
this.submitting = false
wx.showToast({ title: '保存失败', icon: 'none' }) wx.showToast({ title: '保存失败', icon: 'none' })
return return
} }
@@ -348,6 +373,10 @@ Page({
wx.showToast({ title: '保存成功', icon: 'success' }) wx.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => wx.navigateBack(), 1500) setTimeout(() => wx.navigateBack(), 1500)
} else {
// 写本地失败:必须放开防重入标志,否则用户连重试的机会都没有
this.submitting = false
wx.showToast({ title: '保存失败', icon: 'none' })
} }
} else { } else {
// 新增模式 // 新增模式
@@ -363,27 +392,25 @@ Page({
wx.showToast({ title: '添加成功', icon: 'success' }) wx.showToast({ title: '添加成功', icon: 'success' })
setTimeout(() => wx.navigateBack(), 1500) setTimeout(() => wx.navigateBack(), 1500)
} else {
// 同上:写本地失败要放开标志,让用户能重试
this.submitting = false
wx.showToast({ title: '保存失败', icon: 'none' })
} }
} }
}, },
/** /**
* 请求订阅消息授权 * 请求订阅消息授权
* 委托给 utils/subscribe.js 的 requestAndReport:同意后会自动把新增额度
* 上报给后端(走同步队列,失败自动入队)。
* 注意:本方法不能加 async,且内部不能在调用 requestAndReport 之前插入
* 任何 await —— wx.requestSubscribeMessage 必须由用户点击手势同步触发,
* 一旦中间出现异步跳跃,手势上下文丢失会导致调用必然失败。
* @returns {Promise<boolean>} 用户是否同意
*/ */
requestSubscribe() { requestSubscribe() {
return new Promise((resolve, reject) => { return subscribe.requestAndReport()
wx.requestSubscribeMessage({
tmplIds: ['6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw'],
success: (res) => {
console.log('订阅消息授权结果:', res)
resolve(res)
},
fail: (err) => {
console.error('订阅消息授权失败:', err)
reject(err)
}
})
})
}, },
/** /**
+85 -2
View File
@@ -2,6 +2,8 @@
const storage = require('../../utils/storage') const storage = require('../../utils/storage')
const dateUtils = require('../../utils/date') const dateUtils = require('../../utils/date')
const fmt = require('../../utils/format') const fmt = require('../../utils/format')
const api = require('../../utils/api')
const subscribe = require('../../utils/subscribe')
Page({ Page({
data: { data: {
@@ -11,7 +13,8 @@ Page({
currentFilter: 'all', currentFilter: 'all',
totalCount: 0, totalCount: 0,
upcomingCount: 0, upcomingCount: 0,
todayText: '' todayText: '',
atRiskText: ''
}, },
onLoad() { onLoad() {
@@ -20,6 +23,9 @@ Page({
onShow() { onShow() {
this.loadPersons() this.loadPersons()
this.refreshQuota()
// 提前查好订阅状态缓存到实例上:点击处理函数里不能再 await,否则手势上下文会丢
subscribe.getStatus().then(status => { this.subscribeStatus = status })
}, },
/** /**
@@ -150,15 +156,92 @@ Page({
}, },
/** /**
* 点击人员 * 点击人员进详情
* 顺带搭车补额度:仅对已勾选「总是保持以上选择」的用户,此调用不弹窗、完全无感。
* 必须同步调用且放在 navigateTo 之前,否则手势上下文丢失。
*/ */
onPersonTap(e) { onPersonTap(e) {
if (this.subscribeStatus === 'silent') subscribe.requestAndReport()
const id = e.currentTarget.dataset.id const id = e.currentTarget.dataset.id
wx.navigateTo({ wx.navigateTo({
url: `/pages/person-detail/person-detail?id=${id}` url: `/pages/person-detail/person-detail?id=${id}`
}) })
}, },
/**
* 拉取额度风险,拼成给用户看的文案
* 说「谁的提醒有风险」而不是「还剩几次额度」——用户理解不了额度这种抽象概念
* @returns {Promise<boolean>} 本次刷新是否成功拿到了最新数据。
* 失败时不清空/覆盖 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:流程合并,姓名不存在自动建人) * 点击添加按钮:直接进添加纪念日(方案 B:流程合并,姓名不存在自动建人)
*/ */
+6
View File
@@ -18,6 +18,12 @@
</view> </view>
</view> </view>
<!-- 额度风险提示卡片:谁的提醒可能发不出去,同时是补额度的主入口 -->
<view wx:if="{{atRiskText}}" class="quota-warn" bindtap="onTopUp">
<text class="quota-warn-icon">⚠️</text>
<text class="quota-warn-text">{{atRiskText}}的生日提醒可能发不出去,点这里补上</text>
</view>
<!-- 搜索栏 --> <!-- 搜索栏 -->
<view class="search-wrap"> <view class="search-wrap">
<view class="search-box"> <view class="search-box">
+24
View File
@@ -391,3 +391,27 @@ page {
line-height: 1; line-height: 1;
margin-top: -4rpx; 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;
}
+2 -1
View File
@@ -5,7 +5,8 @@
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "node --watch src/index.js" "dev": "node --watch src/index.js",
"test": "node --test test/"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
+74
View File
@@ -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 }
+54
View File
@@ -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_anniv ON remind_logs(anniversaryId);
CREATE INDEX IF NOT EXISTS idx_log_date ON remind_logs(sendDate); 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 // 旧库迁移:CREATE TABLE IF NOT EXISTS 不会给已存在的表加列,需要手动 ALTER
@@ -78,4 +86,50 @@ tryAddColumn('anniversaries', 'lunarMonth', 'INTEGER')
tryAddColumn('anniversaries', 'lunarDay', 'INTEGER') tryAddColumn('anniversaries', 'lunarDay', 'INTEGER')
tryAddColumn('anniversaries', 'isLeapMonth', 'INTEGER DEFAULT 0') 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:给存量用户补订阅额度初始值
*
* Whysubscribe_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 module.exports = db
+14
View File
@@ -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 }
+44
View File
@@ -4,6 +4,8 @@ const express = require('express')
const db = require('./db') const db = require('./db')
const wx = require('./wx') const wx = require('./wx')
const reminder = require('./reminder') const reminder = require('./reminder')
const quota = require('./quota')
const atRisk = require('./atRisk')
const app = express() const app = express()
app.use(express.json({ limit: '1mb' })) app.use(express.json({ limit: '1mb' }))
@@ -221,6 +223,41 @@ function getPersons(openid) {
return { success: true, data: rows } 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) => { app.post('/api/reminder/run', async (req, res) => {
try { try {
@@ -231,10 +268,17 @@ app.post('/api/reminder/run', async (req, res) => {
} }
}) })
module.exports = { grantQuota, getQuotaStatus }
// ---- 启动 ---- // ---- 启动 ----
// 只在直接执行本文件(node src/index.js / npm start)时才真正监听端口、注册定时任务。
// 测试用 require('../src/index') 只是拿函数引用,不需要(也不能)真的起服务——
// 否则会占住端口且 cron 定时器让进程一直不退出,测试永远卡住。
if (require.main === module) {
const PORT = process.env.PORT || 3000 const PORT = process.env.PORT || 3000
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`生日提醒后端已启动,监听 ${PORT}`) console.log(`生日提醒后端已启动,监听 ${PORT}`)
reminder.start() reminder.start()
}) })
}
+48
View File
@@ -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 }
+65
View File
@@ -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 }
+134 -74
View File
@@ -1,7 +1,9 @@
const cron = require('node-cron') const cron = require('node-cron')
const db = require('./db') const db = require('./db')
const wx = require('./wx') 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 TEMPLATE_ID = process.env.WX_TEMPLATE_ID
const MINIPROGRAM_STATE = process.env.WX_MINIPROGRAM_STATE || 'formal' const MINIPROGRAM_STATE = process.env.WX_MINIPROGRAM_STATE || 'formal'
@@ -27,40 +29,6 @@ function formatDate(date) {
return `${y}${m}${d}` 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) { function alreadySentToday(anniversaryId) {
const start = new Date() const start = new Date()
@@ -76,71 +44,163 @@ const insertLog = db.prepare(`
VALUES (@anniversaryId, @personName, @typeName, @daysUntil, @sendDate, @status, @error) VALUES (@anniversaryId, @personName, @typeName, @daysUntil, @sendDate, @status, @error)
`) `)
async function runOnce() { // 43101 = 用户拒收或下发次数不足,是我们与微信侧对账的唯一信号
console.log('[reminder] 开始扫描纪念日...') 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 const isOnDay = daysUntil === 0
let fail = 0 const isAhead = remindDays > 0 && daysUntil === remindDays
if (!isOnDay && !isAhead) continue
if (alreadySentToday(anniv.id)) continue
for (const anniv of list) { due.push({ anniv, target, daysUntil, kind: isOnDay ? 'onDay' : 'ahead' })
try { }
const target = getThisYearDate(anniv)
const daysUntil = daysBetween(target)
const shouldRemind = (daysUntil === 0) || (daysUntil === (anniv.remindDays || 0)) due.sort((x, y) => {
if (!shouldRemind) continue if (x.kind !== y.kind) return x.kind === 'onDay' ? -1 : 1
return importanceRank(x.anniv.importance) - importanceRank(y.anniv.importance)
})
if (alreadySentToday(anniv.id)) { let balance = quota.getBalance(openid)
console.log(`[reminder] 今天已发过,跳过: ${anniv.personName}`) 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 continue
} }
const typeName = getTypeName(anniv.type, anniv.customTypeName) // 记账余额为 0 且尚未证实微信侧有额度时,只允许发一条探针
let isProbe = false
if (balance <= 0 && !quotaProven) {
if (probeUsed) {
logSkip(item); skipped++
continue
}
isProbe = true
probeUsed = true
}
const anniv = item.anniv
const typeName = getTypeName(anniv.type, anniv.customTypeName)
try {
await wx.sendSubscribeMessage({ await wx.sendSubscribeMessage({
touser: anniv.openid, touser: openid,
page: 'pages/index/index', page: 'pages/index/index',
templateId: TEMPLATE_ID, templateId: TEMPLATE_ID,
miniprogramState: MINIPROGRAM_STATE, miniprogramState: MINIPROGRAM_STATE,
data: { data: {
name1: { value: anniv.personName }, name1: { value: anniv.personName },
thing2: { value: daysUntil === 0 ? '今天' : `还有${daysUntil}` }, thing2: { value: item.daysUntil === 0 ? '今天' : `还有${item.daysUntil}` },
thing6: { value: formatDate(target) }, thing6: { value: formatDate(item.target) },
thing5: { value: anniv.remark || '别忘了准备一份礼物哦!' } 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({ insertLog.run({
anniversaryId: anniv.id, anniversaryId: anniv.id, personName: anniv.personName, typeName,
personName: anniv.personName, daysUntil: item.daysUntil, sendDate: Date.now(), status: 'success', error: null
typeName,
daysUntil,
sendDate: Date.now(),
status: 'success',
error: null
}) })
ok++ ok++
console.log(`[reminder] 发送成功: ${anniv.personName} (${typeName}, ${daysUntil}天)`) console.log(`[reminder] 发送成功: ${anniv.personName} (${typeName}, ${item.daysUntil}天)`)
} catch (err) { } catch (err) {
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++ fail++
console.error(`[reminder] 发送失败: ${anniv.personName}`, err.message) 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
})
} }
} }
console.log(`[reminder] 完成: 成功 ${ok}, 失败 ${fail}`) return { ok, fail, skipped }
return { total: list.length, ok, fail } }
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() { function start() {
@@ -153,4 +213,4 @@ function start() {
console.log(`[reminder] 定时任务已注册: ${expr} (Asia/Shanghai)`) console.log(`[reminder] 定时任务已注册: ${expr} (Asia/Shanghai)`)
} }
module.exports = { start, runOnce } module.exports = { start, runOnce, runForUser }
+3 -1
View File
@@ -49,7 +49,9 @@ async function sendSubscribeMessage({ touser, page, data, templateId, miniprogra
} }
const { data: res } = await axios.post(url, body, { timeout: 8000 }) const { data: res } = await axios.post(url, body, { timeout: 8000 })
if (res.errcode !== 0) { 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 return res
} }
+125
View File
@@ -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, ['丁'])
})
+13
View File
@@ -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 }
+112
View File
@@ -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, '没有纪念日就没有要补的用户')
})
+45
View File
@@ -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)
})
+63
View File
@@ -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)
})
+315
View File
@@ -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
}
})
+58
View File
@@ -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('乙'), '时间靠后的乙必然有风险')
})
+38
View File
@@ -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.getsendSubscribeMessage 走 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
}
})
+6 -1
View File
@@ -72,4 +72,9 @@ function person(action, data) {
return request({ url: '/api/person', data: { 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 }
+68
View File
@@ -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<string>}
* 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<boolean>} 用户是否同意
*/
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 }
+1
View File
@@ -24,6 +24,7 @@ function enqueue(item) {
async function dispatch(item) { async function dispatch(item) {
if (item.kind === 'person') return api.person(item.action, item.data) 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 === '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) throw new Error('unknown sync kind: ' + item.kind)
} }