feat(mp, web, calendar): 新增微信收藏夹与日历批量生成功能
- 新增微信小程序版个人收藏夹全套功能,包含聚合首页、收藏列表、编辑、详情页面与配套后端API - 新增web版时光助手门户与个人收藏夹工具,支持与小程序共享后端数据 - 优化日历提醒工具:替换本地bootstrap资源为公共CDN版本,新增批量生成循环提醒的前端界面与后端逻辑,新增数据库batch_id字段,更新鉴权配置文件 - 补充项目配置、站点地图、调试诊断脚本等配套文件 - 完善各模块的页面配置与样式文件
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user