30a40252c6
原计划是部署后上服务器手工执行一条 SQL 补额度,漏执行的后果是所有 存量用户的提醒立刻大面积降级。改成 PRAGMA user_version 驱动的一次性 自动迁移,部署即执行、不可能漏。 幂等性两层保证:user_version 版本号 + INSERT OR IGNORE 只补尚无记录的 openid,已有记录一律不覆盖。整段包在事务里。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
136 lines
4.9 KiB
JavaScript
136 lines
4.9 KiB
JavaScript
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
|