From 68acbb38096891b3f4e7bb3ed739a3ddc8b5622c Mon Sep 17 00:00:00 2001 From: yuming Date: Sat, 15 Aug 2026 17:30:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=BD=E5=87=BA=E6=97=A5=E6=9C=9F=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E7=BA=AF=E5=87=BD=E6=95=B0=E6=A8=A1=E5=9D=97=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=90=AD=E5=BB=BA=E5=90=8E=E7=AB=AF=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=9F=BA=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 server/src/occurrence.js:startOfDay/getNextOccurrence/daysBetween,today 可注入 - reminder.js 改为调用新模块,删除内联的 getThisYearDate/daysBetween - 引入 node:test 作为测试框架,加 npm test 脚本 - 新增 test/helper.js(临时库辅助,供后续任务用)和 test/occurrence.test.js(7 个用例,覆盖农历跨年等边界) --- server/package.json | 3 ++- server/src/occurrence.js | 48 ++++++++++++++++++++++++++++++++++ server/src/reminder.js | 39 +++------------------------ server/test/helper.js | 13 +++++++++ server/test/occurrence.test.js | 45 +++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 37 deletions(-) create mode 100644 server/src/occurrence.js create mode 100644 server/test/helper.js create mode 100644 server/test/occurrence.test.js 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/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/reminder.js b/server/src/reminder.js index b322daa..40e580c 100644 --- a/server/src/reminder.js +++ b/server/src/reminder.js @@ -2,6 +2,7 @@ const cron = require('node-cron') const db = require('./db') const wx = require('./wx') const lunar = require('./lunar') +const occurrence = require('./occurrence') const TEMPLATE_ID = process.env.WX_TEMPLATE_ID const MINIPROGRAM_STATE = process.env.WX_MINIPROGRAM_STATE || 'formal' @@ -27,40 +28,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() @@ -87,8 +54,8 @@ async function runOnce() { for (const anniv of list) { try { - const target = getThisYearDate(anniv) - const daysUntil = daysBetween(target) + const target = occurrence.getNextOccurrence(anniv) + const daysUntil = occurrence.daysBetween(target) const shouldRemind = (daysUntil === 0) || (daysUntil === (anniv.remindDays || 0)) if (!shouldRemind) continue 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/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) +})