const Database = require('better-sqlite3') const path = require('path') const fs = require('fs') const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'birthday.db') // 确保数据目录存在 const dir = path.dirname(dbPath) if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) const db = new Database(dbPath) db.pragma('journal_mode = WAL') // 纪念日表:兼容原云数据库 anniversaries 集合字段 db.exec(` CREATE TABLE IF NOT EXISTS anniversaries ( id TEXT PRIMARY KEY, openid TEXT NOT NULL, personId TEXT, personName TEXT, type TEXT, customTypeName TEXT, isLunar INTEGER DEFAULT 0, solarYear INTEGER, solarMonth INTEGER, solarDay INTEGER, lunarYear INTEGER, lunarMonth INTEGER, lunarDay INTEGER, isLeapMonth INTEGER DEFAULT 0, importance TEXT, remindEnabled INTEGER DEFAULT 0, remindDays INTEGER DEFAULT 0, remark TEXT, createTime INTEGER, updateTime INTEGER ); CREATE INDEX IF NOT EXISTS idx_anniv_openid ON anniversaries(openid); CREATE INDEX IF NOT EXISTS idx_anniv_remind ON anniversaries(remindEnabled); CREATE TABLE IF NOT EXISTS persons ( id TEXT PRIMARY KEY, openid TEXT NOT NULL, name TEXT NOT NULL, nickname TEXT, avatar TEXT, remark TEXT, createTime INTEGER, updateTime INTEGER ); CREATE INDEX IF NOT EXISTS idx_person_openid ON persons(openid); CREATE TABLE IF NOT EXISTS remind_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, anniversaryId TEXT, personName TEXT, typeName TEXT, daysUntil INTEGER, sendDate INTEGER, status TEXT, error TEXT ); 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 // 失败说明列已存在,安全忽略 function tryAddColumn(table, column, type) { try { db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`).run() } catch (e) { if (!/duplicate column/i.test(e.message)) throw e } } tryAddColumn('anniversaries', 'lunarYear', 'INTEGER') 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