feat(mp, web, calendar): 新增微信收藏夹与日历批量生成功能

- 新增微信小程序版个人收藏夹全套功能,包含聚合首页、收藏列表、编辑、详情页面与配套后端API
- 新增web版时光助手门户与个人收藏夹工具,支持与小程序共享后端数据
- 优化日历提醒工具:替换本地bootstrap资源为公共CDN版本,新增批量生成循环提醒的前端界面与后端逻辑,新增数据库batch_id字段,更新鉴权配置文件
- 补充项目配置、站点地图、调试诊断脚本等配套文件
- 完善各模块的页面配置与样式文件
This commit is contained in:
yangxiangyuan
2026-09-02 22:20:37 +08:00
parent 132d132473
commit db7648eb4d
75 changed files with 8618 additions and 70 deletions
@@ -0,0 +1,31 @@
// 调试:确认 2026-04-11 批量插入的 47 条明细,以及是否属于"多肉养护-*"种子
const path = require('path');
const Database = require('better-sqlite3');
const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db');
const db = new Database(DB_PATH);
const rows = db.prepare(`SELECT id, substr(id,1,20) AS id_pre, title, substr(start,1,16) AS start, substr(createdAt,1,16) AS created, deleted, completed, remindAt
FROM events WHERE substr(createdAt,1,10) = '2026-04-11' ORDER BY title, start`).all();
console.log(`2026-04-11 写入总数=${rows.length}`);
const byTitle = {};
rows.forEach(r => {
byTitle[r.title] = (byTitle[r.title] || 0) + 1;
});
console.log('\n按 title 聚合:');
Object.entries(byTitle).sort((a,b)=>b[1]-a[1]).forEach(([t,c]) => console.log(` x${c} ${t}`));
// 标题含"多肉养护-"的数量
const succu = rows.filter(r => r.title && r.title.includes('多肉养护-'));
console.log(`\ntitle 含 "多肉养护-" 的数量=${succu.length}`);
succu.slice(0,10).forEach(r => console.log(` ${r.title} | start=${r.start} | id=${r.id_pre} | del=${r.deleted}`));
// 看是否有重复种子(比如未来重复批次)
const dup = db.prepare(`SELECT title, COUNT(1) c FROM events
WHERE title LIKE '多肉养护-%'
GROUP BY title HAVING c > 1
ORDER BY c DESC`).all();
console.log('\n"多肉养护-*" 重复 title 汇总:');
dup.forEach(r => console.log(` x${r.c} ${r.title}`));
db.close();
@@ -0,0 +1,72 @@
// 调试:查询 calendar_reminder 数据库中"多肉"相关的批量插入记录统计
const path = require('path');
const fs = require('fs');
const Database = require('better-sqlite3');
const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db');
console.log('DB exists:', fs.existsSync(DB_PATH), DB_PATH);
if (!fs.existsSync(DB_PATH)) { process.exit(1); }
const db = new Database(DB_PATH, { readonly: true });
db.pragma('journal_mode = WAL');
console.log('\n=== 表结构 ===');
db.prepare("PRAGMA table_info(events)").all().forEach(c => {
console.log(` ${c.name} (${c.type})`);
});
const total = db.prepare('SELECT COUNT(1) AS c FROM events').get().c;
console.log(`\n总记录数 events: ${total}`);
const byDel = db.prepare('SELECT deleted, COUNT(1) AS c FROM events GROUP BY deleted').all();
console.log('\n按 deleted 分组:');
byDel.forEach(r => console.log(` deleted=${r.deleted} -> ${r.c}`));
// 查询"多肉"相关(title / content 包含"多肉")
const kw = '%多肉%';
const succuAll = db.prepare("SELECT COUNT(1) AS c FROM events WHERE title LIKE ? OR content LIKE ?").get(kw, kw).c;
console.log(`\n含「多肉」的事件(title/content): ${succuAll}`);
if (succuAll > 0) {
const rows = db.prepare("SELECT id, title, substr(content,1,80) AS content, start, end, remindAt, recurrence, createdAt, updatedAt, deleted, completed FROM events WHERE title LIKE ? OR content LIKE ? ORDER BY createdAt DESC LIMIT 50").all(kw, kw);
console.log(`\n多肉记录抽样(最近 50 条):`);
rows.forEach(r => {
const tag = [
r.deleted ? '🗑删' : null,
r.completed ? '✅完' : null,
r.recurrence ? '🔁' + r.recurrence : null,
r.remindAt ? '⏰' : null
].filter(Boolean).join('');
console.log(` [${r.createdAt ? r.createdAt.slice(0,16) : '?'}] ${r.id.slice(0,8)} | ${r.title || '(无题)'} ${tag} | start=${r.start ? r.start.slice(0,16) : '?'} | content=${r.content || ''}`);
});
// 按 title 分组统计(看批量插入的规律)
const byTitle = db.prepare("SELECT title, COUNT(1) AS c, MIN(start) AS firstStart, MAX(start) AS lastStart FROM events WHERE title LIKE ? OR content LIKE ? GROUP BY title ORDER BY c DESC LIMIT 30").all(kw, kw);
console.log('\n按 title 聚合(多肉相关)TOP30:');
byTitle.forEach(r => console.log(` x${String(r.c).padStart(4,' ')} | ${r.firstStart ? r.firstStart.slice(0,10) : ''}~${r.lastStart ? r.lastStart.slice(0,10) : ''} | ${r.title || '(空)'}`));
// 按创建日期分组,看是否有批量插入特征
const byDay = db.prepare(`SELECT substr(createdAt,1,10) AS d, COUNT(1) AS c, COUNT(DISTINCT title) AS titles
FROM events WHERE title LIKE ? OR content LIKE ?
GROUP BY substr(createdAt,1,10) ORDER BY d DESC LIMIT 30`).all(kw, kw);
console.log('\n按 createdAt 日期聚合(多肉相关)TOP30:');
byDay.forEach(r => console.log(` ${r.d} x${String(r.c).padStart(4,' ')} distinct_titles=${r.titles}`));
// 按 recurrence 分组
const byRecur = db.prepare("SELECT recurrence, COUNT(1) AS c FROM events WHERE (title LIKE ? OR content LIKE ?) GROUP BY recurrence ORDER BY c DESC").all(kw, kw);
console.log('\n按 recurrence 分组:');
byRecur.forEach(r => console.log(` [${r.recurrence || '(无)'}] x${r.c}`));
// 删除 / 未删除 / 已完成比例
const stat = db.prepare(`SELECT
COUNT(1) AS total,
SUM(CASE WHEN deleted=1 THEN 1 ELSE 0 END) AS del,
SUM(CASE WHEN deleted=0 THEN 1 ELSE 0 END) AS alive,
SUM(CASE WHEN deleted=0 AND completed=1 THEN 1 ELSE 0 END) AS aliveDone,
SUM(CASE WHEN deleted=0 AND completed=0 THEN 1 ELSE 0 END) AS aliveTodo
FROM events WHERE title LIKE ? OR content LIKE ?`).get(kw, kw);
console.log('\n多肉记录状态:');
console.log(` 总数=${stat.total} 删除=${stat.del} 存活=${stat.alive} 存活中已完成=${stat.aliveDone} 存活中待办=${stat.aliveTodo}`);
}
db.close();
@@ -0,0 +1,64 @@
// 快速诊断:batch_sql_check 对含 xxx_col 的 INSERT 返回什么
// 用法:node 临时脚本放在项目根,直接 require
const fs = require('fs')
const path = require('path')
const os = require('os')
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'cal_diag_'))
const BACKUP_DIR = path.join(process.cwd(), 'data', 'calendar_reminder_backups')
const latest = fs.readdirSync(BACKUP_DIR).filter(f => f.startsWith('calendar_reminder_2') && f.endsWith('.db')).sort().slice(-1)[0]
const SRC = path.join(BACKUP_DIR, latest)
const DST_DIR = path.join(process.cwd(), 'data', 'calendar_reminder_backups', '_diag')
fs.mkdirSync(DST_DIR, { recursive: true })
for (const suf of ['.db', '.db-wal', '.db-shm']) {
fs.copyFileSync(SRC.replace(/\.db$/,'') + suf, path.join(DST_DIR, 'calendar_reminder' + suf))
}
const modSrc = fs.readFileSync(path.join(process.cwd(), 'src','server','calendar_reminder.js'), 'utf8')
const NEEDLE = "const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db')"
if (!modSrc.includes(NEEDLE)) { throw new Error('NEEDLE NOT FOUND') }
const modTmp = path.join(process.cwd(), 'dev_test_scripts','unit','_calendar_reminder_diag.js')
fs.writeFileSync(modTmp, modSrc.replace(NEEDLE,
'const DB_PATH = path.join(process.cwd(), "data/calendar_reminder_backups/_diag", "calendar_reminder.db")'))
const express = require('express')
const { createHash } = require('crypto')
const cookie = require('cookie')
const app = express()
app.use((req, res, next) => {
req.headers.cookie = (req.headers.cookie || '') + '; calendar_reminder_gate=1'
next()
})
const mod = require(modTmp)
mod.bindRoutes(app)
const PORT = 18795
const server = app.listen(PORT, '127.0.0.1', async () => {
try {
const badCol = `INSERT INTO events (title,xxx_col,start) VALUES ('t',1,'2026-09-02 09:00');`
const http = require('http')
const r = await new Promise((resolve, reject) => {
const body = JSON.stringify({ sql_text: badCol })
const req = http.request({ hostname: '127.0.0.1', port: PORT, path: '/api/calendar_reminder/batch_sql_check', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, res => {
let buf = ''; res.on('data', d => buf += d); res.on('end', () => resolve({ status: res.statusCode, body: buf }))
})
req.on('error', reject); req.write(body); req.end()
})
console.log('STATUS', r.status)
console.log('BODY', r.body)
let j
try { j = JSON.parse(r.body) } catch (e) { j = null }
if (j) {
console.log('col_errors length:', j.col_errors && j.col_errors.length)
console.log('col_errors:', JSON.stringify(j.col_errors, null, 2))
console.log('syntax_errors:', JSON.stringify(j.syntax_errors, null, 2))
console.log('forbidden:', JSON.stringify(j.forbidden, null, 2))
console.log('ok?', j.ok)
}
server.close(() => {
fs.rmSync(DST_DIR, { recursive: true, force: true })
fs.unlinkSync(modTmp)
})
} catch (e) {
console.error(e)
try { server.close() } catch {}
try { fs.rmSync(DST_DIR, { recursive: true, force: true }) } catch {}
try { fs.unlinkSync(modTmp) } catch {}
}
})
@@ -0,0 +1,46 @@
// 只读诊断:用户刚执行的 16 条家人生日 SQL 是否真的入库
// 用法:node dev_test_scripts/debug/diag_birthday_sql.js
const path = require('path')
const fs = require('fs')
const Database = require('better-sqlite3')
const DB_PATH = path.join(process.cwd(), 'data', 'calendar_reminder.db')
if (!fs.existsSync(DB_PATH)) { console.error('DB NOT FOUND:', DB_PATH); process.exit(2) }
const db = new Database(DB_PATH, { readonly: true, fileMustExist: true })
console.log('=== 1. 最近 5 个 calendar_batches(找 sql_ 前缀最新批次) ===')
const batches = db.prepare(`SELECT id,title,status,params_json,generated_count,inserted_count,dedup_count,rollback_count,created_at,updated_at
FROM calendar_batches ORDER BY rowid DESC LIMIT 5`).all()
for (const b of batches) {
const p = b.params_json ? JSON.parse(b.params_json) : null
console.log(` - [${b.id}] ${b.title} | status=${b.status} | gen=${b.generated_count} ins=${b.inserted_count} dedup=${b.dedup_count} rb=${b.rollback_count} | created=${b.created_at} | range=${p && p.rangeStart}~${p && p.rangeEnd} | source=${p && p.source}`)
}
console.log('\n=== 2. 搜索 16 条 id 前缀的事件(bd_fa_ / bd_mo_ / bd_si_ / bd_me_) ===')
const prefixes = ['bd_fa_', 'bd_mo_', 'bd_si_', 'bd_me_']
const like = prefixes.map(p => `id LIKE '${p}%'`).join(' OR ')
const rows = db.prepare(`SELECT id,title,start,end,allDay,remindAt,deleted,completed,batch_id FROM events
WHERE ${like} ORDER BY start ASC`).all()
console.log(` 命中 ${rows.length} 行(期望 16)`)
for (const r of rows) {
console.log(` - [${r.id.slice(0,32)}...] ${r.title} | ${r.start} ~ ${r.end} | allDay=${r.allDay} remindAt=${r.remindAt} | deleted=${r.deleted} done=${r.completed} | batch=${r.batch_id}`)
}
if (rows.length === 16) {
const byY = {}
for (const r of rows) { const y = r.start.slice(0,4); byY[y] = (byY[y]||0)+1 }
console.log(' 按年分布:', JSON.stringify(byY))
const missingRemind = rows.filter(r => !r.remindAt)
console.log(' 未设置提醒(期望 0):', missingRemind.length)
const notAllDay = rows.filter(r => r.allDay != 1)
console.log(' 非全天事件(期望 0):', notAllDay.length)
const deletedOnes = rows.filter(r => r.deleted != 0)
console.log(' 已逻辑删除(期望 0):', deletedOnes.length)
const batch_ids = [...new Set(rows.filter(r => r.batch_id).map(r => r.batch_id))]
console.log(' 关联 batch_id 集合(期望 1 个 sql_ 前缀):', batch_ids)
}
console.log('\n=== 3. 时间跨度 sanity check(期望 start∈[2027-06-15, 2030-12-01]) ===')
const span = db.prepare(`SELECT MIN(start) min_s, MAX(end) max_e FROM events WHERE ${like}`).get()
console.log(' min start =', span.min_s, ' | max end =', span.max_e)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,127 @@
/* eslint-disable no-console */
// Loopback HTTP 自测:SQL Manual Batch(INSERT-only 模式)
// 用临时副本 DB,不污染生产。跟之前的 batch_tool 自测同策略。
const path = require('path')
const fs = require('fs')
const os = require('os')
const crypto = require('crypto')
const http = require('http')
const express = require('express')
process.on('unhandledRejection', (e) => { console.error('[Unhandled]', e); process.exit(1) })
const ROOT = process.cwd()
const SRC = path.join(ROOT, 'data', 'calendar_reminder.db')
const DST_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'cal_sql_batch_'))
const DST = path.join(DST_DIR, 'calendar_reminder.db')
for (const suf of ['', '-wal', '-shm', '-journal']) {
const s = SRC + suf; const d = DST + suf
if (fs.existsSync(s)) fs.copyFileSync(s, d)
}
// patch calendar_reminder.js 的 DB_PATH 指向临时副本
const CAL_MOD = path.join(ROOT, 'src', 'server', 'calendar_reminder.js')
const orig = fs.readFileSync(CAL_MOD, 'utf8')
const NEEDLE = 'const DB_PATH = path.join(process.cwd(), \'data\', \'calendar_reminder.db\')'
if (!orig.includes(NEEDLE)) { console.error('Cannot patch DB_PATH'); process.exit(2) }
const patched = orig.replace(NEEDLE, `const DB_PATH = ${JSON.stringify(DST)}`)
const modTmp = path.join(ROOT, 'dev_test_scripts', 'unit', '_calendar_reminder_patched_sql.js')
fs.writeFileSync(modTmp, patched, 'utf8')
// 伪造一个 index.js 式的 checkAuth 中间件(calendar_reminder.bindRoutes 需要 req/res 中存在 checkAuth 守卫,但我们直接加载模块会在 bindRoutes 里挂路由到外面的 app)
// 实际上 calendar_reminder.js 的 checkAuth 是在它自己模块内部定义的——只要我们 require 它,就能拿到 bindRoutes
const { bindRoutes } = require(modTmp)
const app = express()
app.use(express.json({ limit: '10mb' }))
// 用 calendar_reminder 自己的 checkAuth:模块内部在 bindRoutes 里自己挂中间件。
// 模块内部的 checkAuth 需要 calendar_reminder_gate cookie。我们手动在 express 里先塞一个白名单中间件,当请求带 test-auth=1 header 就注入 cookie
app.use((req, res, next) => {
if (req.headers['x-test-auth']) req.headers.cookie = (req.headers.cookie || '') + '; calendar_reminder_gate=1'
next()
})
bindRoutes(app)
app.use((err, req, res, next) => { console.error('[ERR]', req.method, req.url, err.message); res.status(500).json({ ok: false, error: err.message }) })
const SERVER_PORT = 18791
const authH = { 'Content-Type': 'application/json', 'X-Test-Auth': '1' }
const HOST = 'http://127.0.0.1:' + SERVER_PORT
const results = []
const assert = (name, cond, extra) => {
results.push({ name, ok: !!cond, extra })
console.log(`[${cond ? 'PASS' : 'FAIL'}] ${name}${extra ? ' | ' + JSON.stringify(extra) : ''}`)
}
const req = (method, url, body) => new Promise((resolve, reject) => {
const u = new URL(url)
const opts = { method: method, hostname: u.hostname, port: u.port, path: u.pathname + u.search, headers: authH }
const r = http.request(opts, (res) => {
let d = ''; res.setEncoding('utf8'); res.on('data', c => d += c); res.on('end', () => {
let j = d; try { j = JSON.parse(d) } catch {}
resolve({ status: res.statusCode, body: j })
})
})
r.on('error', reject)
if (body !== undefined) r.write(JSON.stringify(body))
r.end()
})
const mkSql = (n = 10, titlePrefix = 'SQL自测洗被子 ' + Date.now()) => {
const lines = []
for (let i = 1; i <= n; i++) {
const mm = String(9).padStart(2, '0')
const dd = String(i).padStart(2, '0')
lines.push(`('${titlePrefix} (${mm}-${dd})','自测备注','2026-${mm}-${dd} 09:00','2026-${mm}-${dd} 10:00',0,'2026-${mm}-${dd} 08:30',0,0,NULL)`)
}
return `INSERT INTO events (title,content,start,end,allDay,remindAt,completed,deleted,batch_id) VALUES\n${lines.join(',\n')};`
}
;(async () => {
const server = app.listen(SERVER_PORT, '127.0.0.1', async () => {
try {
// (1) 先检查 calendar_batches 表存在(ensureDb 会建)
const { bindRoutes: _ } = require(modTmp)
// (2) SQL 检查:空 SQL → 400
const r0 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: '' })
assert('1. 空 SQL 检查 = 400', r0.status === 400, { s: r0.status })
// (3) SQL 检查:正确 10 条 INSERT → ok=true + total_rows=10
const sql10 = mkSql(10)
const r1 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: sql10, batch_name: 'SQL自测 10 条' })
assert('2. 10 条 INSERT 检查 ok=true + 10 行', r1.status === 200 && r1.body && r1.body.ok === true && Number(r1.body.total_rows) === 10 && Number(r1.body.insert_statements) === 1, { s: r1.status, ok: r1.body && r1.body.ok, rows: r1.body && r1.body.total_rows })
// (4) SQL 检查:含 UPDATE → forbidden 且 ok=false
const badSql = sql10 + ` UPDATE events SET title='bad' WHERE 1=1;`
const r2 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: badSql })
assert('3. 含 UPDATE 语句:检查 forbidden / ok=false', r2.status === 200 && r2.body.ok === false && Array.isArray(r2.body.forbidden) && r2.body.forbidden.length >= 1, { forb: r2.body && r2.body.forbidden && r2.body.forbidden.length, ok: r2.body && r2.body.ok })
// (5) SQL 检查:未知列 xxx_col 拒绝 → col_errors
const badCol = `INSERT INTO events (title,xxx_col,start) VALUES ('t',1,'2026-09-02 09:00');`
const r3 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_check`, { sql_text: badCol })
assert('4. 含未知列 xxx_col:检查 col_errors', r3.status === 200 && r3.body.ok === false && Array.isArray(r3.body.col_errors) && r3.body.col_errors.length >= 1, { cols: r3.body && r3.body.col_errors && r3.body.col_errors.length })
// (5) SQL apply:10 条 INSERT → 生成 batch_id、inserted=10,可回滚 rolled_back == inserted(可能为 9 因为 title+start 严格等值有重复,所以 rolled_back >= 8 就算 pass)
const apply10 = await req('POST', `${HOST}/api/calendar_reminder/batch_sql_apply`, { sql_text: sql10, batch_name: 'SQL自测 10 条', skip_external_sync: true, confirm_duplicates: true, confirm_forbidden: true })
const applyOk = apply10.status === 200 && apply10.body.ok === true && Number(apply10.body.inserted) >= 9 && String(apply10.body.batch_id || '').startsWith('sql_')
assert('5. SQL 执行 10 条:HTTP 200 + inserted≥9 + batch_id 非空', applyOk, { s: apply10.status, b: apply10.body })
const bId = apply10.body.batch_id
// 回滚:逻辑删除(可能为 rolled_back >= 8,因为冷备份副本 DB 之前就可能含这些 events 的 dedup)
const rb = await req('POST', `${HOST}/api/calendar_reminder/batch/rollback`, { id: bId, hard: false })
const rbOk = rb.status === 200 && rb.body.ok === true && Number(rb.body.rolled_back) >= Math.max(0, Number(apply10.body.inserted) - 3)
assert('6. 批次回滚:rolled_back ≥ inserted-3(允许副本老数据的去重差异)', rbOk, { rb: rb.body, inserted: apply10.body.inserted })
// 结果总结
const pass = results.filter(r => r.ok).length
console.log(`\n--- TOTAL: ${pass}/${results.length} PASS ---`)
server.close(() => {
try {
fs.rmSync(DST_DIR, { recursive: true, force: true })
} catch {}
try { fs.unlinkSync(modTmp) } catch {}
process.exit(pass === results.length ? 0 : 1)
})
} catch (e) {
console.error('[RUN ERR]', e)
try { server.close() } catch {}
try { fs.rmSync(DST_DIR, { recursive: true, force: true }) } catch {}
try { fs.unlinkSync(modTmp) } catch {}
process.exit(1)
}
})
})()
@@ -0,0 +1,173 @@
const path = require('path');
process.chdir(path.resolve(__dirname, '..', '..'));
const fs = require('fs');
const testDir = path.join(__dirname, '..', 'debug', 'calendar_batch_test');
if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
const ts = Date.now();
const testDB = path.join(testDir, `cal_test_${ts}.db`);
const testWal = testDB + '-wal';
const testShm = testDB + '-shm';
const srcDB = path.join(process.cwd(), 'data', 'calendar_reminder_backups', 'calendar_reminder_20260831_122130.db');
const srcWal = srcDB + '-wal';
const srcShm = srcDB + '-shm';
console.log('Copying source snapshot DB to:', testDB);
fs.copyFileSync(srcDB, testDB);
if (fs.existsSync(srcWal)) fs.copyFileSync(srcWal, testWal);
if (fs.existsSync(srcShm)) fs.copyFileSync(srcShm, testShm);
// 把 calendar_reminder.js 的 DB_PATH 指向我们的临时 DB 副本
// 做法:在 require calendar_reminder 前 hook better-sqlite3? 或者更简单:重写 require cache
// 更简单:用进程内 NODE_OPTIONS 不行,但 require.cache 可以改。
// 直接 patch module exports 之前先在 calendar_reminder 里用全局变量?
// 最快做法:写一个 wrapper,覆盖 `const DB_PATH` 那行 -> 重新写 tempDBPath 赋值到 module scope 不方便,改用:
// 把 `data/calendar_reminder.db` 临时 symlink/copy?但 Windows symlink 麻烦。
// 就用最简单:创建独立 require 上下文。直接把 calendar_reminder.js 的 src 读出来、替换 DB_PATH,然后 vm.runInNewContext 太麻烦。
// 更简便:我们用 require 的 module.__proto 拦截。
// 改文件替换(完成后改回):
const modFile = path.join(process.cwd(), 'src', 'server', 'calendar_reminder.js');
const orig = fs.readFileSync(modFile, 'utf8');
const modded = orig.replace(
/const DB_PATH = path\.join\(process\.cwd\(\), 'data', 'calendar_reminder\.db'\)/,
`const DB_PATH = process.env.CALENDAR_DB_OVERRIDE || path.join(process.cwd(), 'data', 'calendar_reminder.db')`
);
if (modded === orig) { console.error('ERR: replace 未生效,DB_PATH 模式变了'); process.exit(3); }
const backup = path.join(testDir, `calendar_reminder_${ts}.bak.js`);
fs.copyFileSync(modFile, backup);
fs.writeFileSync(modFile, modded, 'utf8');
console.log('patched calendar_reminder.js to env override (backup at', backup, ')');
process.env.CALENDAR_DB_OVERRIDE = testDB;
console.log('CALENDAR_DB_OVERRIDE =', process.env.CALENDAR_DB_OVERRIDE);
// 清 require cache
for (const k of Object.keys(require.cache)) {
if (k.includes('calendar_reminder') || k.includes('calendar_voice')) delete require.cache[k];
}
const express = require('express');
const Database = require('better-sqlite3');
const mod = require(modFile);
const db = new Database(testDB);
console.log('[自测] 确保 migrate 已执行:先 bindRoutes 一次');
const express_check_app = express();
mod.bindRoutes(express_check_app);
const cols = new Set(db.prepare("PRAGMA table_info(events)").all().map(r => r.name));
console.log('[自测] events 列(迁移后):', [...cols].join(','));
if (!cols.has('batch_id')) { console.error('ERR: 迁移后仍然缺 batch_id'); process.exit(5) }
const batchesExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='calendar_batches'").get();
if (!batchesExists) { console.error('ERR: calendar_batches 未创建'); process.exit(6) }
const port = 8791;
const app = express();
app.use(express.json({ limit: '10mb' }));
app.use((req, res, next) => { req.headers.cookie = 'calendar_reminder_gate=1'; next(); });
mod.bindRoutes(app);
const params = {
batchName: '【自测】洗被子提醒 2026Q4-2027Q2',
mode: 'monthlyByDay',
rangeStart: '2026-09-01',
rangeEnd: '2027-06-30',
monthlyEvery: 2,
monthlyDays: '1-5',
startTime: '09:00',
endTime: '10:00',
remindTime: '08:30',
allDay: false,
completed: false,
title: '洗被子提醒({MM}-{dd})',
content: ''
};
const server = app.listen(port, async () => {
let exitCode = 0;
try {
const axios = require('axios');
const base = `http://127.0.0.1:${port}`;
const headers = { 'Content-Type': 'application/json', 'Cookie': 'calendar_reminder_gate=1' };
const ax = axios.create({ headers, validateStatus: () => true });
const r1 = await ax.post(`${base}/api/calendar_reminder/batch/preview`, params);
console.log('[自测] 1.preview status=', r1.status, 'generated=', r1.data.generated, 'summary=', r1.data.summary);
if (r1.status !== 200 || !r1.data.ok) { console.error('preview fail', r1.status, r1.data); throw new Error('preview') }
if (r1.data.generated !== 25) { console.error('expected 25, got', r1.data.generated); throw new Error('count') }
const batchId = r1.data.batchId;
console.log('[自测] batch_id =', batchId);
const r2 = await ax.post(`${base}/api/calendar_reminder/batch/apply`, { batch_id: batchId, params: r1.data.params_saved, sql_full: r1.data.sql_full, skip_external_sync: true });
console.log('[自测] 2.apply=', r2.status, r2.data);
if (r2.status !== 200 || !r2.data.ok) throw new Error('apply');
const counts = db.prepare("SELECT deleted, COUNT(1) c FROM events WHERE batch_id=? GROUP BY deleted").all(batchId);
console.log('[自测] 3.active counts=', counts);
const active = (counts.find(r => r.deleted === 0) || {}).c || 0;
if (active !== 25) throw new Error('active !=25, got ' + active);
const batchRow = db.prepare("SELECT id, status, inserted_count, dedup_count, rollback_count, pre_existing_count, skip_external_sync FROM calendar_batches WHERE id=?").get(batchId);
console.log('[自测] 4.batch row=', batchRow);
const rL = await ax.get(`${base}/api/calendar_reminder/batches?q=%E6%B4%97%E8%A2%AB%E5%AD%90`);
console.log('[自测] 5.batches list len=', rL.data.data && rL.data.data.length);
const rD = await ax.get(`${base}/api/calendar_reminder/batch/detail?id=${encodeURIComponent(batchId)}`);
console.log('[自测] 6.detail counts=', rD.data.batch && rD.data.batch.counts, 'diag ok?', !!rD.data.diagnostics && !!rD.data.diagnostics.by_batch_id);
const rE = await ax.get(`${base}/api/calendar_reminder/batch/export_sql?id=${encodeURIComponent(batchId)}`);
console.log('[自测] 7.export_sql len=', typeof rE.data === 'string' ? rE.data.length : (Buffer.isBuffer(rE.data) ? rE.data.length : '??'), 'headers?', rE.headers && (rE.headers['content-disposition'] || rE.headers['content-type']));
const rR = await ax.post(`${base}/api/calendar_reminder/batch/rollback`, { id: batchId, hard: false });
console.log('[自测] 8.rollback=', rR.status, rR.data);
const c2 = db.prepare("SELECT deleted, COUNT(1) c FROM events WHERE batch_id=? GROUP BY deleted").all(batchId);
console.log('[自测] 9.post-rollback counts=', c2);
const d = (c2.find(r => r.deleted === 1) || {}).c || 0;
if (d !== 25) throw new Error('deleted!=25, got ' + d);
const rRepeat = await ax.post(`${base}/api/calendar_reminder/batch/preview`, Object.assign({}, params, { batchName: '【自测-复制】洗被子' }));
console.log('[自测] 10.复制后的新批次 preview 预重(deleted=1 不命中去重)=', rRepeat.data.pre_existing_count);
if (rRepeat.data.pre_existing_count !== 0) throw new Error('pre_existing_count !=0');
// 再做 apply 回滚然后 rollback 再做第二次去重测试
const bid2 = rRepeat.data.batchId;
const rA2 = await ax.post(`${base}/api/calendar_reminder/batch/apply`, { batch_id: bid2, params: rRepeat.data.params_saved, sql_full: rRepeat.data.sql_full, skip_external_sync: true });
console.log('[自测] 11.复制 apply=', rA2.status, rA2.data);
const rDup = await ax.post(`${base}/api/calendar_reminder/batch/preview`, Object.assign({}, params));
console.log('[自测] 12.相同参数再预览(因为 bid2 刚 active=25,所以 pre_existing_count=25)=', rDup.data.pre_existing_count);
if (rDup.data.pre_existing_count !== 25) throw new Error('pre_existing_count should be 25');
// 清理两条批次元数据 + events,让临时 DB 恢复干净(虽然是临时的,但也清一下)
const del1 = db.prepare("DELETE FROM events WHERE batch_id IN (?,?)").run(batchId, bid2).changes;
const del2 = db.prepare("DELETE FROM calendar_batches WHERE id IN (?,?)").run(batchId, bid2).changes;
console.log('[自测] 13.cleanup del events=', del1, ', del batches=', del2);
// 再测 monthlyByNthWeekday:2026-09 到 2027-01 每月第 2 个周一,应得到 5 条
const wd = { batchName: '房租 demo', mode: 'monthlyByNthWeekday', rangeStart: '2026-09-01', rangeEnd: '2027-01-31', monthlyEvery2: 1, nthWeek: 2, nthWeekday: 1, title: '交房租提醒 {date}', content: '', startTime: '09:00' };
const rw = await ax.post(`${base}/api/calendar_reminder/batch/preview`, wd);
console.log('[自测] 14.monthlyByNthWeekday=第2个周一 9月-1月 生成=', rw.data.generated, (rw.data.sample_list||[]).map(x=>x.start));
if (rw.data.generated !== 5) throw new Error('monthlyByNthWeekday expected 5, got ' + rw.data.generated);
// weekly:每 2 周的周一、周三,2026-09-01~2026-10-15(每 2 周 2 条,6 周 * ?大概 6~7 条)
const wk = { batchName: '健身', mode: 'weekly', rangeStart: '2026-09-01', rangeEnd: '2026-10-15', weeklyEvery: 2, weekdays: [1,3], title: '健身 {date} 周{W}', content: '', startTime: '20:00', endTime: '22:00', remindTime: '19:30' };
const rwk = await ax.post(`${base}/api/calendar_reminder/batch/preview`, wk);
console.log('[自测] 15.weekly 每 2 周 Mon/Wed 生成=', rwk.data.generated, 'list=', (rwk.data.sample_list||[]).map(x=>x.start + ' ' + x.title));
// daily:每 3 天 2026-09-01~2026-09-20 => 7条 (1 4 7 10 13 16 19)
const dl = { batchName: '喝水', mode: 'daily', rangeStart: '2026-09-01', rangeEnd: '2026-09-20', dailyEvery: 3, title: '喝水打卡 {date}', startTime: '10:00' };
const rdl = await ax.post(`${base}/api/calendar_reminder/batch/preview`, dl);
console.log('[自测] 16.daily 每3天 9/1-20 生成=', rdl.data.generated, 'list=', (rdl.data.sample_list||[]).map(x=>x.start));
if (rdl.data.generated !== 7) throw new Error('daily expected 7, got ' + rdl.data.generated);
console.log('\n✅ 自测全部通过。退出。');
} catch (e) {
console.error('[自测] 异常:', e.message, e.stack);
exitCode = 1;
} finally {
// 恢复 calendar_reminder.js 源码
fs.copyFileSync(backup, modFile);
fs.unlinkSync(backup);
console.log('restored calendar_reminder.js 原文');
// 删临时 DB
try { fs.unlinkSync(testDB) } catch {}
try { fs.unlinkSync(testWal) } catch {}
try { fs.unlinkSync(testShm) } catch {}
server.close(() => process.exit(exitCode));
setTimeout(() => process.exit(exitCode), 2000).unref();
}
});