diff --git a/server/src/atRisk.js b/server/src/atRisk.js index 3a87040..f67c21c 100644 --- a/server/src/atRisk.js +++ b/server/src/atRisk.js @@ -6,6 +6,7 @@ */ const { getNextOccurrence, daysBetween } = require('./occurrence') +const { importanceRank } = require('./importance') // 预警窗口。太短则提示不及时,太长会拿几个月后的事惊扰用户,两个月是折中。 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()) { const events = (anniversaries || []) .filter(a => a && a.remindEnabled) .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 atRisk = events.slice(safeCount) 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/reminder.js b/server/src/reminder.js index ef387f0..b72d219 100644 --- a/server/src/reminder.js +++ b/server/src/reminder.js @@ -3,6 +3,7 @@ const db = require('./db') const wx = require('./wx') 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' @@ -43,12 +44,6 @@ const insertLog = db.prepare(` 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 = 用户拒收或下发次数不足,是我们与微信侧对账的唯一信号 function isQuotaError(err) { return err && err.errcode === 43101 diff --git a/server/test/atRisk.test.js b/server/test/atRisk.test.js index 5f38309..d0938b7 100644 --- a/server/test/atRisk.test.js +++ b/server/test/atRisk.test.js @@ -76,3 +76,50 @@ 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, ['丁']) +})