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,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();
}
});