修复首页风险预告与实际发送的排序不一致问题

atRisk.js 之前只按 fireInDays 排序,同一天内多条提醒的先后完全未定义,
和 reminder.js 实际发送时「当天>提前、重要程度降序」的规则对不上,导致
额度卡在中间时首页提示的风险人名和定时任务实际跳过的人不一致。

- 把 importanceRank 从 reminder.js 抽到共享的 src/importance.js,两处复用
- atRisk.js 的 computeAtRisk 排序改为三段式:fireInDays 升序 →
  同天内 kind(当天优先于提前)→ 同 kind 内重要程度降序
- 补充 atRisk.test.js 用例覆盖「同一天多条事件、额度只够一部分」场景
This commit is contained in:
yuming
2026-08-16 09:21:09 +08:00
parent c7bdd3770a
commit d8573359a2
4 changed files with 73 additions and 9 deletions
+11 -3
View File
@@ -6,6 +6,7 @@
*/ */
const { getNextOccurrence, daysBetween } = require('./occurrence') const { getNextOccurrence, daysBetween } = require('./occurrence')
const { importanceRank } = require('./importance')
// 预警窗口。太短则提示不及时,太长会拿几个月后的事惊扰用户,两个月是折中。 // 预警窗口。太短则提示不及时,太长会拿几个月后的事惊扰用户,两个月是折中。
const LOOKAHEAD_DAYS = 60 const LOOKAHEAD_DAYS = 60
@@ -43,14 +44,21 @@ function expandEvents(anniv, today = new Date()) {
/** /**
* 算出有风险的提醒 * 算出有风险的提醒
* 排序用「时间先后」而非重要程度——跨天的额度就是先到先消耗。 *
* 重要程度只在同一天内比较(那部分逻辑在 reminder.js)。 * 排序必须与 reminder.js 的 runForUser 完全一致,否则首页提示的人名会跟
* 定时任务实际跳过的人对不上:条数一样、人名却错了,用户会去补错人的额度。
* 排序规则:先按 fireInDays 升序(跨天的额度是先到先消耗),
* 同一天内再按「当天 > 提前」,然后按重要程度降序(重要程度权重见 importance.js)。
*/ */
function computeAtRisk(anniversaries, balance, today = new Date()) { function computeAtRisk(anniversaries, balance, today = new Date()) {
const events = (anniversaries || []) const events = (anniversaries || [])
.filter(a => a && a.remindEnabled) .filter(a => a && a.remindEnabled)
.flatMap(a => expandEvents(a, today)) .flatMap(a => expandEvents(a, today))
.sort((x, y) => x.fireInDays - y.fireInDays) .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 safeCount = Math.max(0, balance)
const atRisk = events.slice(safeCount) const atRisk = events.slice(safeCount)
+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 }
+1 -6
View File
@@ -3,6 +3,7 @@ const db = require('./db')
const wx = require('./wx') const wx = require('./wx')
const occurrence = require('./occurrence') const occurrence = require('./occurrence')
const quota = require('./quota') 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'
@@ -43,12 +44,6 @@ const insertLog = db.prepare(`
VALUES (@anniversaryId, @personName, @typeName, @daysUntil, @sendDate, @status, @error) VALUES (@anniversaryId, @personName, @typeName, @daysUntil, @sendDate, @status, @error)
`) `)
// 重要程度排序权重,未知值排最后
const IMPORTANCE_RANK = { high: 0, medium: 1, low: 2 }
function importanceRank(v) {
return IMPORTANCE_RANK[v] === undefined ? 3 : IMPORTANCE_RANK[v]
}
// 43101 = 用户拒收或下发次数不足,是我们与微信侧对账的唯一信号 // 43101 = 用户拒收或下发次数不足,是我们与微信侧对账的唯一信号
function isQuotaError(err) { function isQuotaError(err) {
return err && err.errcode === 43101 return err && err.errcode === 43101
+47
View File
@@ -76,3 +76,50 @@ test('未开启提醒的纪念日不参与计算', () => {
const r = computeAtRisk([{ ...near, remindEnabled: 0 }], 0, TODAY) const r = computeAtRisk([{ ...near, remindEnabled: 0 }], 0, TODAY)
assert.strictEqual(r.atRiskCount, 0) 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, ['丁'])
})