4 Commits
29 changed files with 483 additions and 94 deletions
+26
View File
@@ -0,0 +1,26 @@
# v0.6.3 发布说明(2026-10-26)
服务端存储层加固(纯服务端变更,客户端无需重装;桌面/安卓继续使用 0.6.2 安装包)。
## 存储可靠性
- **原子落盘**:所有 JSON 写入改为「临时文件 + fsync + 原子改名」,进程崩溃/断电不会留下半截数据文件
- **损坏隔离**:用户数据文件若解析失败,自动隔离为 `.corrupt-<时间戳>` 保留原件并在日志报错——绝不静默当作空数据(旧逻辑的静默空文件正是数据事故的放大器)
- **墓碑自动回收**:删除标记保留 14 天后自动清除,数据文件不再随历史无限膨胀,也不会永久重放旧删除
- **空同步不落盘**:内容无变化的同步不再重写磁盘(顺带修复 cursor 假值导致的每轮必写)
## 备份体系
- **当日首份快照不可变**:同日再备份写入带时间戳的副本目录,当日证据链不再被覆盖(本次事故中 11:11 快照被 20:03 覆盖正是教训)
- **每周深快照**:每周一额外保留一份,保留 8 份
- **大批删除前置保险**:单次同步携带 ≥20 条墓碑时,服务端先自动快照再应用删除,可随时回滚
- `/api/health` 新增 users / lastBackup 字段
## 验证
- 服务端协议审计 36/36、用户管理审计 16/16、灾难恢复自救 7/7、存储加固冒烟 9/9 全部通过
- 修复审计中两处过时预期(墓碑 GC 后老日期墓碑不再回传属预期;当日首份快照布局变更)
## 部署
`cd /opt/daywork && git pull && cd server && docker compose up -d --build`
+23
View File
@@ -0,0 +1,23 @@
# v0.6.4 发布说明(2026-10-26)
## 移动端布局与板块尺寸优化
**触控基线**(全部界面统一):
- 常规按钮 36px / 次级 32px / 小按钮 30px(原 24-28px,触控不友好)
- 输入框与下拉 38px 高;分段选择器 34px;图标按钮 34px
- 弹窗底部主按钮 40px 并等宽铺满;弹窗宽距收窄近全屏
**板块重排**:
- 详情页:出勤信息改 2×2 紧凑网格(原手机上拉成 1 列过高);打卡/休假按钮并排等宽
- 计划页:每日任务操作按钮(计时/完成/跳过)等宽铺满、触控高度 32px;「今天 / 添加计划」等宽;长期计划卡操作角标化
- 记账页:工具栏分区——月份导航一行、两个筛选并排、「记一笔」全宽主按钮
- 详情页导出:指定日/当周/当月/自定义分段等宽铺满,日期输入全宽
- 总结页:范围分段铺满一行;草稿编辑器高度收窄
- 备忘录:列表收窄至 210px,底部按钮等宽
- 底部标签栏加高至 56px、图标 22px;悬浮计时时钟位置同步适配
- 顶栏打卡按钮加高至 34px,更易点按
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(网页端即生效)
- 桌面 / 安卓:安装 0.6.4 安装包
+11
View File
@@ -0,0 +1,11 @@
# v0.6.5 发布说明(2026-10-26)
## 修复
- **写操作不再因同步失败弹全屏报错**:此前任何一次写后同步失败(如服务端 500)都会以「应用内部错误」遮罩抛到界面,一次服务端故障会让每次点击都报错。现在写操作在本地提交成功即视为成功,同步失败仅记录状态(设置页「多端数据同步」可见原因),并由下一轮自动同步继续完成。
- **服务端 500 留痕**:接口异常现在会连同堆栈写入容器日志(`docker logs internlog-sync`),用于定位「同步服务返回 500」的真实原因。
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`
- 桌面 / 安卓:安装 0.6.5(或继续用 0.6.2+ 旧版客户端,报错遮罩问题主要影响 0.6.4 网页端)
+23
View File
@@ -0,0 +1,23 @@
# v0.6.6 发布说明(2026-10-26)
## 修复「同步服务返回 500」与二次数据丢失(根因修复)
**根因**:此前数据恢复用的合并脚本把用户数据文件中的表写成了**数组**形态,而服务端按「uuid 键对象」读写。后果:数组上的附加属性在序列化时被丢弃(**每次容器重启丢失合并后新写的数据**)、删除/半途状态产生 null 行(**自然键扫描读到 null.date → 每次同步 500**)。该脚本缺陷由开发者引入,本版彻底治愈。
## 修复内容
- **服务端加载即自愈**:读取用户数据时自动把数组形态转回 uuid 键对象、剔除 null/非法行(一次性治愈线上文件,无需任何手动操作),自愈结果立即持久化
- **自然键扫描 / 墓碑生效循环**:对非对象行防御性跳过
- **客户端快照重建**:跳过 null / 无 uuid 行,垃圾行不再进入本地缓存
- 合并后丢失的增量数据:各端升级后首次同步会触发行数比对防线,**自动把设备缓存中的数据重推回服务器**
## 验证
- 表结构自愈冒烟 8/8(数组+null 混合文件 → 同步一次 → 治愈且数据完整、快照无 null)
- 存储冒烟 9/9 · 协议审计 36/36 · 用户管理审计 16/16 · 灾难恢复自救 7/7
## 部署
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`
- 桌面 / 安卓:安装 0.6.6(网页端刷新即新版)
- 部署后各端打开一次,核对数据(工作记录/记账等);若仍有缺,检查设置页「立即同步」的结果行并反馈
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-B-qBD-4O.js","./index-DFZaRaeX.js","./index-OQmtzryQ.css"])))=>i.map(i=>d[i]); const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-B4MkFHMk.js","./index-DP7vL7H5.js","./index-BuZbSc5_.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-DFZaRaeX.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-B-qBD-4O.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem}; import{r as s,_ as i}from"./index-DP7vL7H5.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-B4MkFHMk.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,8 +8,8 @@
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" /> <link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" /> <link rel="apple-touch-icon" href="./icons/icon-192.png" />
<title>实习工作日志</title> <title>实习工作日志</title>
<script type="module" crossorigin src="./assets/index-DFZaRaeX.js"></script> <script type="module" crossorigin src="./assets/index-DP7vL7H5.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-OQmtzryQ.css"> <link rel="stylesheet" crossorigin href="./assets/index-BuZbSc5_.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "intern-log", "name": "intern-log",
"private": true, "private": true,
"version": "0.6.2", "version": "0.6.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+6 -2
View File
@@ -75,7 +75,8 @@ async function main() {
check('LWW 反向: 旧 work_start 被墓碑', tombsOf(lwwR2).some(t => t.table === 'setting' && t.uuid === 'su1')) check('LWW 反向: 旧 work_start 被墓碑', tombsOf(lwwR2).some(t => t.table === 'setting' && t.uuid === 'su1'))
await post('/api/sync', { tables: { memo: [row('memo', { uuid: 'mt1', title: '待删', content: 'x', updated_at: '2026-04-01T00:00:00' }), row('memo', { uuid: 'ms2', title: '更迟', content: 'y', updated_at: '2030-01-01T00:00:00' })] } }, tokA) await post('/api/sync', { tables: { memo: [row('memo', { uuid: 'mt1', title: '待删', content: 'x', updated_at: '2026-04-01T00:00:00' }), row('memo', { uuid: 'ms2', title: '更迟', content: 'y', updated_at: '2030-01-01T00:00:00' })] } }, tokA)
const delPush = await post('/api/sync', { tombstones: [{ table: 'memo', uuid: 'mt1', deleted_at: '2026-04-02T00:00:00' }] }, tokA) // deleted_at 用当前时间:过期墓碑会被服务端 14 天 GC 自动回收(这是预期行为,不能用老日期验证回传)
const delPush = await post('/api/sync', { tombstones: [{ table: 'memo', uuid: 'mt1', deleted_at: new Date().toISOString() }] }, tokA)
check('墓碑同步后行被清除', !((rowsOf(delPush).memo || []).some(x => x.uuid === 'mt1'))) check('墓碑同步后行被清除', !((rowsOf(delPush).memo || []).some(x => x.uuid === 'mt1')))
check('新于删除时间的行保留(防误删)', (rowsOf(delPush).memo || []).some(x => x.uuid === 'ms2')) check('新于删除时间的行保留(防误删)', (rowsOf(delPush).memo || []).some(x => x.uuid === 'ms2'))
check('墓碑记录回传', tombsOf(delPush).some(t => t.table === 'memo' && t.uuid === 'mt1')) check('墓碑记录回传', tombsOf(delPush).some(t => t.table === 'memo' && t.uuid === 'mt1'))
@@ -127,7 +128,10 @@ async function main() {
const aRec = regJson.users.find(u => u.username === 'audit_a') const aRec = regJson.users.find(u => u.username === 'audit_a')
check('注册表仅存哈希不存明文密码', !!aRec && !JSON.stringify(aRec).includes('aapass')) check('注册表仅存哈希不存明文密码', !!aRec && !JSON.stringify(aRec).includes('aapass'))
await new Promise(r => globalThis.setTimeout(r, 400)) await new Promise(r => globalThis.setTimeout(r, 400))
check('启动自动备份已生成', fsMod.existsSync('server/data/backups/' + new Date().toISOString().slice(0, 10) + '/registry.json')) // 当日首份快照不可变:registry.json 可能位于当日基准目录或其 _t 时间戳副本
const today = new Date().toISOString().slice(0, 10)
const todayDirs = fsMod.readdirSync('server/data/backups').filter(d => d.startsWith(today))
check('启动自动备份已生成', todayDirs.length > 0 && todayDirs.some(d => fsMod.existsSync('server/data/backups/' + d + '/registry.json')), todayDirs.join(','))
const hInfo = await fetch(BASE + '/api/health').then(x => x.json()).catch(() => ({})) const hInfo = await fetch(BASE + '/api/health').then(x => x.json()).catch(() => ({}))
check('health 暴露备份状态', hInfo && hInfo.backup === 'local-only', hInfo.backup) check('health 暴露备份状态', hInfo && hInfo.backup === 'local-only', hInfo.backup)
+4 -2
View File
@@ -743,14 +743,16 @@ onMounted(async () => {
.mob-search-btn { display: flex; } .mob-search-btn { display: flex; }
.topbar-actions { margin-left: auto; gap: 6px; } .topbar-actions { margin-left: auto; gap: 6px; }
.week-tip { height: 28px; max-width: 118px; padding: 0 8px; white-space: nowrap; overflow: hidden; } .week-tip { height: 28px; max-width: 118px; padding: 0 8px; white-space: nowrap; overflow: hidden; }
.topbar-actions .btn { padding: 0 10px; height: 32px; } .topbar-actions .btn { padding: 0 12px; height: 34px; }
.topbar-actions .btn .num { font-size: 12px; } .topbar-actions .btn .num { font-size: 12px; }
.tabbar { display: flex; } .tabbar { display: flex; }
.tab-btn { height: 56px; font-size: 11px; }
.tab-btn .icon { width: 22px; height: 22px; }
.content { flex: 1; min-height: 0; } .content { flex: 1; min-height: 0; }
.content-inner { .content-inner {
max-width: none; max-width: none;
padding: 12px 12px calc(70px + env(safe-area-inset-bottom)); padding: 12px 12px calc(78px + env(safe-area-inset-bottom));
} }
.toast-wrap { .toast-wrap {
+1 -1
View File
@@ -147,7 +147,7 @@ onUnmounted(() => {
.tp { .tp {
left: 12px; left: 12px;
right: 12px; right: 12px;
bottom: calc(66px + env(safe-area-inset-bottom)); bottom: calc(64px + env(safe-area-inset-bottom));
max-width: none; max-width: none;
} }
} }
@@ -163,4 +163,8 @@ function save() {
.dialog { width: 480px; } .dialog { width: 480px; }
.add-cat { display: flex; gap: 6px; margin-top: 6px; } .add-cat { display: flex; gap: 6px; margin-top: 6px; }
.add-cat-input { flex: 1; height: 28px; font-size: 12px; } .add-cat-input { flex: 1; height: 28px; font-size: 12px; }
@media (max-width: 720px) {
.add-cat-input { height: 34px; }
.add-cat .btn { height: 34px; }
}
</style> </style>
+10 -2
View File
@@ -452,7 +452,15 @@ async function confirmDelete() {
} }
.dp-foot { margin-top: 10px; font-size: 12px; text-align: right; } .dp-foot { margin-top: 10px; font-size: 12px; text-align: right; }
@media (max-width: 560px) { @media (max-width: 720px) {
.att-grid { grid-template-columns: 1fr; } .dp-head { margin-bottom: 10px; }
.att-grid { grid-template-columns: 1fr 1fr; gap: 10px 12px; }
.att-cell label { margin-bottom: 3px; }
.att-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.att-actions .btn { width: 100%; }
.edit-row { flex-wrap: wrap; }
.edit-row .time-input { width: 100%; flex: 1 1 100%; }
.edit-row .btn { flex: 1; }
.item-main { gap: 6px; }
} }
</style> </style>
+42 -27
View File
@@ -1,11 +1,40 @@
/* ============================================================ /* ============================================================
手机端(<=720px)全局适配:作用于各视图与弹窗组件 手机端(<=720px)全局适配
(App.vue 的壳层样式是 scoped,跨组件规则统一放这里,便于集中管理) 原则:触控目标 ≥30px(按钮 32-36 / 输入 38 / 弹窗主按钮 40)、
板块两列优先避免过度堆叠、内容优先密度适中
============================================================ */ ============================================================ */
@media (max-width: 720px) { @media (max-width: 720px) {
/* 卡片在窄屏贴边、圆角略收 */ /* ---------- 触控基线 ---------- */
.btn { height: 36px; padding: 0 14px; }
.btn-sm { height: 32px; padding: 0 12px; }
.btn-xs { height: 30px; padding: 0 12px; }
.icon-btn { width: 34px; height: 34px; }
.seg-btn { height: 34px; padding: 0 13px; }
.input, .select { height: 38px; padding: 0 12px; }
.textarea { min-height: 76px; padding: 10px 12px; }
.toggle-btn { height: 38px; }
.tag { height: 24px; line-height: 22px; padding: 0 9px; }
.dot { width: 7px; height: 7px; }
/* ---------- 弹窗:近全屏 + 大按钮 ---------- */
.dialog {
width: calc(100vw - 16px) !important;
max-width: calc(100vw - 16px) !important;
max-height: 90vh;
max-height: 90dvh;
}
.dialog-head { padding: 14px 16px 10px !important; }
.dialog-body { padding: 14px 16px !important; }
.dialog-btns { padding: 10px 16px calc(12px + env(safe-area-inset-bottom)) !important; gap: 10px; }
.dialog-btns .btn { flex: 1; height: 40px; }
.form-grid { grid-template-columns: 1fr !important; }
/* ---------- 卡片与分区 ---------- */
.card { border-radius: 12px; } .card { border-radius: 12px; }
.section, .sec { padding: 12px; }
.stat-num { font-size: 20px; }
.empty { padding: 22px 12px; }
input.input, select.select, textarea.textarea { input.input, select.select, textarea.textarea {
width: 100%; width: 100%;
@@ -13,20 +42,7 @@
} }
.btn { touch-action: manipulation; } .btn { touch-action: manipulation; }
/* ---------- 弹窗:窄屏近全屏 ---------- */ /* ---------- 日历当日抽屉:底部抽屉 ---------- */
.dialog {
width: calc(100vw - 20px) !important;
max-width: calc(100vw - 20px) !important;
max-height: 88vh;
max-height: 88dvh;
}
.dialog-head { padding: 14px 16px 10px !important; }
.dialog-body { padding: 14px 16px !important; }
.dialog-btns { padding: 10px 16px 14px !important; }
.dialog-btns .btn { flex: 1; }
.form-grid { grid-template-columns: 1fr !important; }
/* ---------- 日历当日抽屉:改为底部抽屉 ---------- */
.drawer { .drawer {
top: auto !important; top: auto !important;
left: 0 !important; left: 0 !important;
@@ -42,7 +58,7 @@
.drawer-head { border-radius: 14px 14px 0 0; } .drawer-head { border-radius: 14px 14px 0 0; }
.drawer-body { padding: 12px !important; } .drawer-body { padding: 12px !important; }
/* ---------- 记账表格:卡片行(带行内标签),手机上不横滚 ---------- */ /* ---------- 记账表格:卡片行(带行内标签) ---------- */
.tx-table { min-width: 0 !important; } .tx-table { min-width: 0 !important; }
.tx-table thead { display: none !important; } .tx-table thead { display: none !important; }
.tx-table tr { display: block; padding: 10px 12px; border-bottom: 1px solid var(--border); background: transparent; } .tx-table tr { display: block; padding: 10px 12px; border-bottom: 1px solid var(--border); background: transparent; }
@@ -63,11 +79,12 @@
flex: none; flex: none;
} }
.tx-table td.ops-cell::before { content: none; } .tx-table td.ops-cell::before { content: none; }
.tx-table td.ops-cell { justify-content: flex-end; } .tx-table td.ops-cell { justify-content: flex-end; flex-wrap: wrap; gap: 8px; }
.tx-table td.ops-cell .btn { flex: none; }
.tx-table td.empty { justify-content: center; } .tx-table td.empty { justify-content: center; }
.tx-table td.empty::before { content: none !important; } .tx-table td.empty::before { content: none !important; }
/* ---------- 密码库等其余宽表:横向滚动 ---------- */ /* ---------- 密码库等宽表:横滚 + 工具栏堆叠 ---------- */
.table-card, .tx-card { overflow-x: auto; } .table-card, .tx-card { overflow-x: auto; }
.cred-table { min-width: 560px !important; } .cred-table { min-width: 560px !important; }
.th-ops { width: auto !important; } .th-ops { width: auto !important; }
@@ -79,15 +96,13 @@
/* ---------- 统计卡两列 ---------- */ /* ---------- 统计卡两列 ---------- */
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 10px !important; } .cards { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; gap: 10px !important; }
.stat-card { padding: 12px 14px !important; } .stat-card { padding: 12px 14px !important; }
.stat-num { font-size: 19px !important; }
/* ---------- 分区与栅格收窄 ---------- */ /* ---------- 分区与栅格 ---------- */
.section, .sec { padding: 12px 12px !important; }
.rule-grid { grid-template-columns: 1fr !important; } .rule-grid { grid-template-columns: 1fr !important; }
.two-col { grid-template-columns: 1fr !important; } .two-col { grid-template-columns: 1fr !important; }
/* ---------- 日历格子收窄 ---------- */ /* ---------- 日历格子 ---------- */
.cal-toolbar { margin-bottom: 8px; } .cal-toolbar { margin-bottom: 10px; }
.cal-cell { min-height: 54px !important; padding: 4px 5px !important; } .cal-cell { min-height: 58px !important; padding: 5px 6px !important; }
.cal-marks { margin-top: 3px !important; height: 12px !important; gap: 3px !important; } .cal-marks { margin-top: 4px !important; height: 14px !important; gap: 4px !important; }
} }
+6 -3
View File
@@ -159,14 +159,15 @@ export async function syncWithStoredConfig(): Promise<SyncResult> {
return runSync({ baseUrl: await baseOf(), username: creds.username, password: creds.password }) return runSync({ baseUrl: await baseOf(), username: creds.username, password: creds.password })
} }
// 界面写操作完成后调用:立即推送差异并以服务器快照重建缓存(失败会把缓存回退到服务器真值) // 界面写操作完成后调用:立即推送差异并以服务器快照重建缓存
// 注意:绝不向界面抛出——写操作本身已在本地提交成功,同步失败只记录状态
// (设置页可见 + 下一轮自动同步续传),否则一次服务端故障会把每次点击都变成全屏报错
export async function flushAfterWrite(): Promise<void> { export async function flushAfterWrite(): Promise<void> {
try { try {
await syncWithStoredConfig() await syncWithStoredConfig()
} catch (e) { } catch (e) {
syncLastError.value = e instanceof Error ? e.message : String(e) syncLastError.value = e instanceof Error ? e.message : String(e)
try { await syncWithStoredConfig() } catch { /* 服务器不可达:本地暂存修改,下次自动推送或全量刷新时对齐 */ } try { await syncWithStoredConfig() } catch { /* 服务器不可达:本地暂存修改,下次自动推送或全量刷新时对齐 */ }
throw e
} }
} }
@@ -437,7 +438,9 @@ async function applySnapshot(
const prevById = new Map(prevRows.map(r => [pkOf(t, r), r])) const prevById = new Map(prevRows.map(r => [pkOf(t, r), r]))
const prevByUuid = new Map(prevRows.filter(r => r.uuid).map(r => [r.uuid as string, r])) const prevByUuid = new Map(prevRows.filter(r => r.uuid).map(r => [r.uuid as string, r]))
const live: AnyRow[] = (serverTables[t] || []).filter(r => !dead.has(t + '|' + r.uuid)) const live: AnyRow[] = (serverTables[t] || [])
.filter(r => r && typeof r === 'object' && r.uuid)
.filter(r => !dead.has(t + '|' + r.uuid))
// 设置表的临时键:不写回本地缓存;顺手在服务端也清掉(下一轮推送墓碑) // 设置表的临时键:不写回本地缓存;顺手在服务端也清掉(下一轮推送墓碑)
const filtered = live.filter(r => !(t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key))) const filtered = live.filter(r => !(t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key)))
const tmpKeys = live.filter(r => t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key)) const tmpKeys = live.filter(r => t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key))
-2
View File
@@ -200,9 +200,7 @@ watch(focusDate, d => {
.drawer-enter-from, .drawer-leave-to { opacity: 0; } .drawer-enter-from, .drawer-leave-to { opacity: 0; }
@media (max-width: 720px) { @media (max-width: 720px) {
.cal-cell { min-height: 60px; padding: 4px 5px; }
.cal-title { min-width: 76px; font-size: 13px; } .cal-title { min-width: 76px; font-size: 13px; }
.cal-marks { gap: 3px; margin-top: 4px; height: 12px; }
.drawer { width: 100%; } .drawer { width: 100%; }
} }
</style> </style>
+6 -2
View File
@@ -332,7 +332,11 @@ async function confirmDelete() {
.table-card { overflow-x: auto; } .table-card { overflow-x: auto; }
.table-note { margin-top: 10px; font-size: 12px; text-align: right; } .table-note { margin-top: 10px; font-size: 12px; text-align: right; }
@media (max-width: 560px) { @media (max-width: 720px) {
.ledger-toolbar .w-filter { flex: 1; min-width: 0; width: auto; } .ledger-toolbar { gap: 8px; }
.ledger-toolbar .flex1 { display: none; }
.month-title { min-width: 0; flex: 1; text-align: left; }
.w-filter { flex: 1 1 calc(50% - 4px); min-width: 0; width: auto; }
.ledger-toolbar .btn-primary { flex: 1 1 100%; order: 9; }
} }
</style> </style>
+6 -3
View File
@@ -248,8 +248,11 @@ function fmtTime(s: string): string {
} }
@media (max-width: 800px) { @media (max-width: 800px) {
.memo-page { grid-template-columns: 1fr; } .memo-page { grid-template-columns: 1fr; gap: 10px; }
.memo-list { max-height: 240px; } .memo-list { max-height: 210px; }
.content-textarea { min-height: 240px; } .content-textarea { min-height: 260px; }
.title-input { height: 38px; }
.editor-foot { flex-wrap: wrap; }
.editor-foot .btn { flex: 1; min-height: 34px; }
} }
</style> </style>
+13 -2
View File
@@ -459,8 +459,19 @@ function weekday(s: string): string {
.goal-hint { font-size: 12px; margin-top: 8px; } .goal-hint { font-size: 12px; margin-top: 8px; }
@media (max-width: 720px) { @media (max-width: 720px) {
.plan-item { flex-wrap: wrap; } .blk-head { margin: 0 0 10px; }
.pi-ops { width: 100%; justify-content: flex-start; } .day-head { margin-top: 16px; }
.day-ops { width: 100%; } .day-ops { width: 100%; }
.day-ops .btn { flex: 1; min-width: 0; }
.day-ops .btn-primary { flex: 1.5; }
.date-chip { min-width: 64px; }
.plan-item { flex-wrap: wrap; padding: 12px; gap: 10px; }
.pi-time { min-width: 0; padding-top: 0; }
.pi-ops { width: 100%; justify-content: flex-start; gap: 8px; }
.pi-ops .btn-xs { flex: 1; min-height: 32px; }
.goal-card { position: relative; flex-direction: column; gap: 8px; }
.goal-main { padding-right: 0; }
.goal-line { padding-right: 72px; }
.goal-ops { position: absolute; top: 10px; right: 8px; }
} }
</style> </style>
+7
View File
@@ -228,4 +228,11 @@ function undoPolish() {
@media (max-width: 960px) { @media (max-width: 960px) {
.report-grid { grid-template-columns: 1fr; } .report-grid { grid-template-columns: 1fr; }
} }
@media (max-width: 720px) {
.report-toolbar { gap: 8px; }
.report-toolbar .seg { flex: 1 1 100%; display: flex; }
.report-toolbar .seg-btn { flex: 1; }
.w-date { flex: 1; min-width: 0; width: auto; }
.md-editor { min-height: 300px; }
}
</style> </style>
+6 -1
View File
@@ -138,8 +138,13 @@ watch(date, d => { expStart.value = d; if (expRange.value === 'custom') expEnd.v
@media (max-width: 720px) { @media (max-width: 720px) {
.tool-sep { display: none; } .tool-sep { display: none; }
.nav-row { flex: 1; } .toolbar { padding: 10px 12px; gap: 10px; }
.nav-row { flex: 1 1 100%; }
.date-title { min-width: 0; flex: 1; text-align: left; } .date-title { min-width: 0; flex: 1; text-align: left; }
.w-date { flex: 1; min-width: 120px; width: auto; } .w-date { flex: 1; min-width: 120px; width: auto; }
.exp-row { width: 100%; }
.exp-row .seg { flex: 1; display: flex; }
.exp-row .seg-btn { flex: 1; padding: 0 6px; }
.w-exp { flex: 1; min-width: 0; width: auto; }
} }
</style> </style>
BIN
View File
Binary file not shown.
+7 -2
View File
@@ -62,8 +62,13 @@ Caddy 会自动为 daywk.linyux.cn 申请并续期 HTTPS 证书(需 DNS 已生
- 数据目录:/opt/daywork/server/data - 数据目录:/opt/daywork/server/data
- registry.json 用户注册表(用户名 / 角色 / PBKDF2 哈希) - registry.json 用户注册表(用户名 / 角色 / PBKDF2 哈希)
- users/<id>.json 每用户九张表数据 + 墓碑 - users/<id>.json 每用户全部业务表数据 + 墓碑
- backups/<日期>/ **每日自动本地备份**(启动与每次注册表变更也会触发,保留最近 14 天) - backups/<日期>/ 每日快照。**当日首份不可变**(完整证据链),同日再备份写入 `<日期>_t<时分秒>` 副本
- backups/weekly/<日期>/ 每周一额外深快照,保留 8 份
- 保留策略:每日快照 14 天;写入均为「临时文件 + fsync + 原子改名」,断电不会留下半截文件
- 用户数据文件若解析失败会被自动隔离为 `<文件>.corrupt-<时间戳>` 并在日志中大声报错,绝不静默当作空数据
- 墓碑自动回收:删除标记保留 14 天后自动清除(足够覆盖离线设备),文件不会无限膨胀
- 单次同步携带 ≥20 条墓碑(大批删除)时,服务端会**先自动快照再应用删除**,可随时回滚
- **异地备份(推荐开启)**:在 docker-compose.yml 的 internlog-sync 服务加环境变量,备份自动上传腾讯云 COS: - **异地备份(推荐开启)**:在 docker-compose.yml 的 internlog-sync 服务加环境变量,备份自动上传腾讯云 COS:
environment: environment:
+52
View File
@@ -0,0 +1,52 @@
// 表结构自愈冒烟:数组形态 + null 行 + 合法行混合 → 同步一次 → 治愈为 uuid 键对象且数据完整
const BASE = 'http://127.0.0.1:3780'
let pass = 0, fail = 0
function check(name, ok, detail) {
if (ok) { pass++; console.log(' PASS ' + name) } else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
}
async function j(path, body, token) {
const r = await fetch(BASE + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, body: body ? JSON.stringify(body) : undefined })
return { status: r.status, json: await r.json().catch(() => ({})) }
}
async function main() {
const fsp = await import('fs')
const reg = await j('/api/auth/register', { username: 'healtest', password: 'test123456' })
const tok = reg.json.token
const file = 'server/data/users/' + reg.json.userId + '.json'
// 构造坏文件:work_log 是数组 + 含 null 槽位;attendance 数组含 null;memo 正常对象但混入 null 行
const bad = {
userId: reg.json.userId, cursor: 0,
created_at: '2026-10-26T12:00:00.000Z',
tables: {
work_log: [ { uuid: 'wl1', date: '2026-10-20', created_at: '2026-10-20T00:00:00', updated_at: '2026-10-20T00:00:00' }, null, { uuid: 'wl2', date: '2026-10-21', created_at: '2026-10-21T00:00:00', updated_at: '2026-10-21T00:00:00' } ],
attendance: [ null, { uuid: 'at1', date: '2026-10-20', created_at: '2026-10-20T00:00:00', updated_at: '2026-10-20T00:00:00' } ],
memo: { m1: { uuid: 'm1', title: '幸存备忘', content: '', created_at: '2026-10-20T00:00:00', updated_at: '2026-10-20T00:00:00' }, bad: null },
plan: []
},
tombstones: { memo: { g1: null }, work_log: {} }
}
fsp.mkdirSync('server/data/users', { recursive: true })
fsp.writeFileSync(file, JSON.stringify(bad))
// 推送一行 work_log(自然键 date 扫描会遍历表)→ 修复前这里 500,修复后应 200
const r1 = await j('/api/sync', { device: 't', tables: { work_log: [ { uuid: 'wl3', date: '2026-10-22', created_at: '2026-10-22T00:00:00', updated_at: '2026-10-22T00:00:00' } ] }, tombstones: [] }, tok)
check('同步 200(不再读 null.date)', r1.status === 200, 'status=' + r1.status)
const healed = JSON.parse(fsp.readFileSync(file, 'utf8'))
check('work_log 已转 uuid 键对象', !Array.isArray(healed.tables.work_log) && !!healed.tables.work_log.wl1 && !!healed.tables.work_log.wl3)
check('null 行已剔除', !('1' in (healed.tables.work_log || {})) && !healed.tables.attendance['0'])
check('attendance 幸存行保留', healed.tables.attendance && healed.tables.attendance.at1)
check('memo 正常对象保留', healed.tables.memo && healed.tables.memo.m1)
check('null 墓碑已清理', !healed.tombstones.memo || !healed.tombstones.memo.g1)
check('全表均为对象形态', Object.values(healed.tables).every(x => x && !Array.isArray(x) && typeof x === 'object'))
// 推送 m1 更新(memo 无自然键)→ 再拉全量,快照中不得出现 null
await j('/api/sync', { device: 't', tables: { memo: [ { uuid: 'm2', title: '新备忘', content: '', created_at: '2026-10-26T00:00:00', updated_at: '2026-10-26T00:00:00' } ] }, tombstones: [] }, tok)
const r3 = await j('/api/sync', { device: 't', tables: {}, tombstones: [] }, tok)
const flat = Object.values(r3.json.tables || {}).flat()
check('权威快照不含 null/无uuid行', r3.status === 200 && flat.every(x => x && typeof x === 'object' && x.uuid), JSON.stringify(flat.filter(x => !x || !x.uuid)).slice(0, 100))
console.log('RESULT: ' + (fail === 0 ? 'ALL PASS ' + pass : 'FAIL ' + fail + '/' + (pass + fail)))
if (fail) process.exit(1)
}
main().catch(e => { console.error('SMOKE ERROR', e); process.exit(1) })
+157 -33
View File
@@ -11,6 +11,11 @@ const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data')
const PORT = Number(process.env.PORT || 3780) const PORT = Number(process.env.PORT || 3780)
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天 const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天
const ITER = 150000 const ITER = 150000
const BACKUP_DAYS = 14 // 每日快照保留天数
const WEEKLY_KEEP = 8 // 每周深快照保留份数
const TOMBSTONE_TTL_MS = 14 * 24 * 60 * 60 * 1000 // 墓碑保留 14 天:足够覆盖离线设备,防止无限膨胀与重放
const LARGE_TOMBSTONE_BACKUP = 20 // 单次同步墓碑达到该数量时,先做数据快照再应用(大批删除的前置保险)
let lastBackupAt = ''
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'goal', 'transaction', 'plan'] const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'goal', 'transaction', 'plan']
@@ -35,7 +40,13 @@ function readJson(file, fallback) {
} }
function writeJson(file, data) { function writeJson(file, data) {
const tmp = path.join(DATA_DIR, file + '.tmp') const tmp = path.join(DATA_DIR, file + '.tmp')
fs.writeFileSync(tmp, JSON.stringify(data)) const fd = fs.openSync(tmp, 'w')
try {
fs.writeSync(fd, JSON.stringify(data))
fs.fsyncSync(fd) // 落盘后再原子改名:进程崩溃/断电不会留下半截文件
} finally {
fs.closeSync(fd)
}
fs.renameSync(tmp, path.join(DATA_DIR, file)) fs.renameSync(tmp, path.join(DATA_DIR, file))
} }
@@ -126,11 +137,77 @@ function isAdminToken(token) {
function saveRegistry() { writeJson(registryFile, registry); try { doBackup() } catch { /* 忽略 */ } } function saveRegistry() { writeJson(registryFile, registry); try { doBackup() } catch { /* 忽略 */ } }
function userFile(userId) { return path.join(DATA_DIR, 'users', userId + '.json') } function userFile(userId) { return path.join(DATA_DIR, 'users', userId + '.json') }
function emptyUserData(userId) {
return { userId, cursor: 0, tables: {}, tombstones: {}, created_at: '' }
}
// 表结构自愈:tables[t] 必须是「uuid -> 行对象」;
// 兼容历史误存为数组的形态(并剔除 null / 非法行),避免自然键扫描在 null 上崩溃、避免序列化丢弃行
function sanitizeTables(data) {
let healed = false
const src = data.tables && typeof data.tables === 'object' && !Array.isArray(data.tables) ? data.tables : null
if (!src) { data.tables = {}; return true }
const out = {}
for (const t of Object.keys(src)) {
const container = src[t]
if (Array.isArray(container)) {
healed = true
const m = {}
for (const row of container) {
if (row && typeof row === 'object' && row.uuid) m[row.uuid] = row
}
out[t] = m
continue
}
if (!container || typeof container !== 'object') { healed = true; out[t] = {}; continue }
const m = {}
for (const k of Object.keys(container)) {
const row = container[k]
if (!row || typeof row !== 'object' || Array.isArray(row) || !row.uuid) { healed = true; continue }
m[k] = row
}
out[t] = m
}
data.tables = out
if (!data.tombstones || typeof data.tombstones !== 'object' || Array.isArray(data.tombstones)) { data.tombstones = {}; healed = true }
for (const t of Object.keys(data.tombstones)) {
const bucket = data.tombstones[t]
if (!bucket || typeof bucket !== 'object' || Array.isArray(bucket)) { delete data.tombstones[t]; healed = true; continue }
for (const k of Object.keys(bucket)) {
if (!bucket[k]) { delete bucket[k]; healed = true }
}
}
return healed
}
function loadUserData(userId) { function loadUserData(userId) {
const data = readJson('users/' + userId + '.json', { userId, cursor: 0, tables: {}, tombstones: {} }) const full = userFile(userId)
// 数据纪元:用户数据文件首次创建时间。目录被误删重建后该值改变,客户端据此识别并全量重推自救 if (fs.existsSync(full)) {
if (!data.created_at) data.created_at = new Date().toISOString() try {
return data const data = JSON.parse(fs.readFileSync(full, 'utf-8'))
const healed = sanitizeTables(data)
return { data, healed }
} catch (e) {
// 数据文件损坏:隔离保留原件并大声报错,绝不静默当作空数据(否则客户端同步会引发连锁覆盖)
const quarantine = full + '.corrupt-' + new Date().toISOString().replace(/[:.]/g, '-')
try { fs.renameSync(full, quarantine) } catch { /* 忽略 */ }
console.error('[存储] 用户数据文件解析失败,已隔离:' + quarantine + '(' + (e && e.message) + ')')
return { data: emptyUserData(userId), healed: false }
}
}
return { data: emptyUserData(userId), healed: false }
}
// 墓碑回收:超过保留期的墓碑删除(全量快照重建的客户端不依赖过期墓碑也能收敛)
function gcTombstones(data) {
const cutoff = Date.now() - TOMBSTONE_TTL_MS
let pruned = 0
for (const t of Object.keys(data.tombstones || {})) {
const bucket = data.tombstones[t]
for (const u of Object.keys(bucket)) {
const ts = Date.parse(bucket[u])
if (Number.isFinite(ts) && ts < cutoff) { delete bucket[u]; pruned++ }
}
if (!Object.keys(bucket).length) delete data.tombstones[t]
}
return pruned
} }
function saveUserData(userId, data) { function saveUserData(userId, data) {
fs.mkdirSync(path.join(DATA_DIR, 'users'), { recursive: true }) fs.mkdirSync(path.join(DATA_DIR, 'users'), { recursive: true })
@@ -269,7 +346,15 @@ async function handle(req, res) {
return return
} }
if (req.method === 'GET' && route === '/api/health') { json(res, 200, { ok: true, backup: lastOffsiteBackup.state === 'disabled' ? 'local-only' : lastOffsiteBackup }); return } if (req.method === 'GET' && route === '/api/health') {
json(res, 200, {
ok: true,
backup: lastOffsiteBackup.state === 'disabled' ? 'local-only' : lastOffsiteBackup,
users: registry.users.length,
lastBackup: lastBackupAt
})
return
}
// SSE 事件流:token 经查询参数传递(EventSource 无法自定义请求头) // SSE 事件流:token 经查询参数传递(EventSource 无法自定义请求头)
if (req.method === 'GET' && route === '/api/events') { if (req.method === 'GET' && route === '/api/events') {
@@ -358,10 +443,13 @@ async function handle(req, res) {
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim()) const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return } if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
const body = await readBody(req) const body = await readBody(req)
const userData = loadUserData(auth.userId) const loaded = loadUserData(auth.userId)
if (!userData.cursor) userData.cursor = 0 const userData = loaded.data
if (!userData.tables) userData.tables = {} let dirty = loaded.healed
if (!userData.tombstones) userData.tombstones = {} if (userData.cursor == null) { userData.cursor = 0; dirty = true }
if (!userData.tables) { userData.tables = {}; dirty = true }
if (!userData.tombstones) { userData.tombstones = {}; dirty = true }
if (!userData.created_at) { userData.created_at = new Date().toISOString(); dirty = true } // 数据纪元(客户端灾难自检依据)
// 应用推送的变更(新者胜 + 自然键去重) // 应用推送的变更(新者胜 + 自然键去重)
const incomingTables = body.tables || {} const incomingTables = body.tables || {}
@@ -382,7 +470,9 @@ async function handle(req, res) {
if (nkVal) { if (nkVal) {
for (const u2 of Object.keys(userData.tables[t])) { for (const u2 of Object.keys(userData.tables[t])) {
if (u2 === row.uuid) continue if (u2 === row.uuid) continue
if (String(userData.tables[t][u2][nk] || '') === nkVal) { conflictUuid = u2; break } const other = userData.tables[t][u2]
if (!other || typeof other !== 'object') continue
if (String(other[nk] || '') === nkVal) { conflictUuid = u2; break }
} }
} }
} }
@@ -411,12 +501,18 @@ async function handle(req, res) {
userData.tombstones[t][conflictUuid] = now userData.tombstones[t][conflictUuid] = now
changed++ changed++
} }
// 内容与已存行完全一致:不计变更、不触发保存(空推送不落盘)
if (!conflictUuid && existing && JSON.stringify(existing) === JSON.stringify(clean)) continue
userData.tables[t][row.uuid] = clean userData.tables[t][row.uuid] = clean
changed++ changed++
} }
} }
// 应用墓碑 // 应用墓碑
const incomingTombs = body.tombstones || [] const incomingTombs = body.tombstones || []
if (incomingTombs.length >= LARGE_TOMBSTONE_BACKUP) {
// 大批删除的前置保险:先把当前数据快照留存,再应用删除(可追溯、可回滚)
try { doBackup(); console.log('[存储] 检测到大批删除(' + incomingTombs.length + ' 条墓碑),已先行快照') } catch { /* 忽略 */ }
}
for (const tb of incomingTombs) { for (const tb of incomingTombs) {
if (!TABLES.includes(tb.table) || !tb.uuid) continue if (!TABLES.includes(tb.table) || !tb.uuid) continue
userData.tombstones[tb.table] = userData.tombstones[tb.table] || {} userData.tombstones[tb.table] = userData.tombstones[tb.table] || {}
@@ -431,10 +527,11 @@ async function handle(req, res) {
const tombs = userData.tombstones[t] || {} const tombs = userData.tombstones[t] || {}
for (const [u, deletedAt] of Object.entries(tombs)) { for (const [u, deletedAt] of Object.entries(tombs)) {
const row = userData.tables[t][u] const row = userData.tables[t][u]
if (row && String(row.updated_at || '') <= String(deletedAt)) delete userData.tables[t][u] if (row && typeof row === 'object' && String(row.updated_at || '') <= String(deletedAt)) delete userData.tables[t][u]
} }
} }
saveUserData(auth.userId, userData) if (gcTombstones(userData) > 0) dirty = true
if (dirty || changed > 0) saveUserData(auth.userId, userData)
if (changed) broadcast(auth.userId, 'change') if (changed) broadcast(auth.userId, 'change')
// 返回权威全量 // 返回权威全量
@@ -511,27 +608,53 @@ async function handle(req, res) {
} }
// ---------- 自动备份:服务器是唯一数据源,必须自带兜底 ---------- // ---------- 自动备份:服务器是唯一数据源,必须自带兜底 ----------
function snapshotTo(dir) {
fs.mkdirSync(path.join(dir, 'users'), { recursive: true })
try { fs.copyFileSync(path.join(DATA_DIR, registryFile), path.join(dir, 'registry.json')) } catch { /* 忽略 */ }
try { fs.copyFileSync(path.join(DATA_DIR, 'secret.key'), path.join(dir, 'secret.key')) } catch { /* 忽略 */ }
try {
const usersDir = path.join(DATA_DIR, 'users')
if (fs.existsSync(usersDir)) {
for (const f of fs.readdirSync(usersDir)) {
if (f.endsWith('.json') && !f.includes('.corrupt-')) fs.copyFileSync(path.join(usersDir, f), path.join(dir, 'users', f))
}
}
} catch { /* 忽略 */ }
}
function pruneBackups() {
try {
const root = path.join(DATA_DIR, 'backups')
const entries = fs.readdirSync(root).filter(d => /^\d{4}-\d{2}-\d{2}/.test(d))
const days = Array.from(new Set(entries.map(d => d.slice(0, 10)))).sort()
while (days.length > BACKUP_DAYS) {
const dead = days.shift()
for (const d of entries) {
if (d.startsWith(dead)) { try { fs.rmSync(path.join(root, d), { recursive: true, force: true }) } catch { /* 忽略 */ } }
}
}
} catch { /* 忽略 */ }
}
function doBackup() { function doBackup() {
try { try {
const day = new Date().toISOString().slice(0, 10) const now = new Date()
const dir = path.join(DATA_DIR, 'backups', day) const day = now.toISOString().slice(0, 10)
fs.mkdirSync(path.join(dir, 'users'), { recursive: true }) const root = path.join(DATA_DIR, 'backups')
try { fs.copyFileSync(path.join(DATA_DIR, registryFile), path.join(dir, 'registry.json')) } catch { /* 忽略 */ } let dir = path.join(root, day)
try { fs.copyFileSync(path.join(DATA_DIR, 'secret.key'), path.join(dir, 'secret.key')) } catch { /* 忽略 */ } // 当日首份快照不可变(当日完整证据链);之后的同日备份落时间戳副本
try { if (fs.existsSync(dir)) dir = path.join(root, day + '_t' + now.toTimeString().slice(0, 8).replace(/:/g, ''))
const usersDir = path.join(DATA_DIR, 'users') snapshotTo(dir)
if (fs.existsSync(usersDir)) { // 每周一额外做周快照(更深恢复点),保留 8 份
for (const f of fs.readdirSync(usersDir)) { if (now.getDay() === 1) {
if (f.endsWith('.json')) fs.copyFileSync(path.join(usersDir, f), path.join(dir, 'users', f)) const wdir = path.join(root, 'weekly', day)
} if (!fs.existsSync(wdir)) snapshotTo(wdir)
} try {
} catch { /* 忽略 */ } const wroot = path.join(root, 'weekly')
// 保留最近 14 天 const ws = fs.readdirSync(wroot).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort()
try { while (ws.length > WEEKLY_KEEP) { const old = ws.shift(); try { fs.rmSync(path.join(wroot, old), { recursive: true, force: true }) } catch { /* 忽略 */ } }
const backupsRoot = path.join(DATA_DIR, 'backups') } catch { /* 忽略 */ }
const days = fs.readdirSync(backupsRoot).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort() }
while (days.length > 14) { const old = days.shift(); try { fs.rmSync(path.join(backupsRoot, old), { recursive: true, force: true }) } catch { /* 忽略 */ } } pruneBackups()
} catch { /* 忽略 */ } lastBackupAt = new Date().toISOString()
} catch (e) { console.error('backup failed:', e && e.message) } } catch (e) { console.error('backup failed:', e && e.message) }
void backupOffsite() void backupOffsite()
} }
@@ -541,7 +664,8 @@ setInterval(doBackup, 24 * 60 * 60 * 1000)
// ---------- HTTP 服务 ---------- // ---------- HTTP 服务 ----------
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
handle(req, res).catch(err => { handle(req, res).catch(err => {
json(res, 500, { error: err.message || 'server error' }) console.error('[api] ' + req.method + ' ' + req.url + ' -> 500: ' + ((err && (err.stack || err.message)) || String(err)))
json(res, 500, { error: (err && err.message) || 'server error' })
}) })
}) })
+61
View File
@@ -0,0 +1,61 @@
// 服务端存储加固冒烟:空同步不落盘 / 墓碑GC / 大批删除前置快照 / 损坏隔离
const BASE = 'http://127.0.0.1:3780'
let pass = 0, fail = 0
function check(name, ok, detail) {
if (ok) { pass++; console.log(' PASS ' + name) } else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
}
const sleep = ms => new Promise(r => setTimeout(r, ms))
async function j(path, body, token) {
const r = await fetch(BASE + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, body: body ? JSON.stringify(body) : undefined })
return { status: r.status, json: await r.json().catch(() => ({})) }
}
async function main() {
const fsp = await import('fs')
let reg = await j('/api/auth/register', { username: 'storagetest', password: 'test123456' })
if (reg.status === 409) reg = await j('/api/auth/login', { username: 'storagetest', password: 'test123456' })
console.log('auth status:', reg.status)
const tok = reg.json.token
const file = 'server/data/users/' + reg.json.userId + '.json'
const row = { uuid: 'm1', title: 't', content: 'c', created_at: '2026-10-26T00:00:00', updated_at: '2026-10-26T00:00:00' }
const push = tables => j('/api/sync', { device: 't', tables, tombstones: [] }, tok)
const p1 = await push({ memo: [row] })
console.log('push1 status:', p1.status)
check('首次同步落盘(200+文件存在)', p1.status === 200 && fsp.existsSync(file))
const m1 = fsp.statSync(file).mtimeMs
await sleep(30)
const p2 = await push({ memo: [row] })
await sleep(30)
const m2 = fsp.statSync(file).mtimeMs
check('空推送不重写文件(脏检查)', p2.status === 200 && m2 === m1, 'mtime ' + m1 + ' -> ' + m2)
await j('/api/sync', { device: 't', tables: {}, tombstones: [{ table: 'memo', uuid: 'old1', deleted_at: '2026-09-01T00:00:00' }] }, tok)
const d = JSON.parse(fsp.readFileSync(file, 'utf8'))
check('过期墓碑(>14天)自动回收', !d.tombstones.memo || !d.tombstones.memo.old1, JSON.stringify(d.tombstones))
const dirBefore = fsp.readdirSync('server/data/backups').filter(x => /^\d{4}-\d{2}-\d{2}/.test(x))
const tombs = Array.from({ length: 20 }, (_, i) => ({ table: 'memo', uuid: 'ghost' + i, deleted_at: '2026-10-26T23:59:00' }))
tombs.push({ table: 'memo', uuid: 'm1', deleted_at: '2026-10-26T23:59:00' }) // m1 本尊:应被删除
await sleep(1100) // 错开秒级时间戳,避免 _t 目录同名覆盖
const bigResp = await j('/api/sync', { device: 't', tables: {}, tombstones: tombs }, tok)
console.log('big-tombstone status:', bigResp.status)
const dirAfter = fsp.readdirSync('server/data/backups').filter(x => /^\d{4}-\d{2}-\d{2}/.test(x))
console.log('backup dirs:', dirAfter.join(' | '))
check('大批删除触发前置快照', dirAfter.length > dirBefore.length, dirBefore.length + '->' + dirAfter.length)
const d2 = JSON.parse(fsp.readFileSync(file, 'utf8'))
check('墓碑应用后行已删除', !d2.tables.memo || !d2.tables.memo.m1, JSON.stringify(Object.keys(d2.tables.memo || {})))
check('墓碑已记录(待客户端收敛)', Object.keys(d2.tombstones.memo || {}).length >= 21, JSON.stringify(d2.tombstones.memo || {}))
fsp.writeFileSync(file, '{ this is not json !!!')
const r5 = await push({ memo: [{ uuid: 'm2', title: 'x', content: '', created_at: '2026-10-26T00:00:00', updated_at: '2026-10-26T00:00:00' }] })
check('损坏文件不再静默当空(同步仍200)', r5.status === 200, 'status=' + r5.status)
const quarantined = fsp.readdirSync('server/data/users').some(f => f.includes('.corrupt-'))
check('损坏文件已隔离保留原件', quarantined, fsp.readdirSync('server/data/users').join(','))
const d3 = JSON.parse(fsp.readFileSync('server/data/users/' + reg.json.userId + '.json', 'utf8'))
check('新纪元已写入(created_at 非空)', !!d3.created_at)
console.log('RESULT: ' + (fail === 0 ? 'ALL PASS ' + pass : 'FAIL ' + fail + '/' + (pass + fail)))
if (fail) process.exit(1)
}
main().catch(e => { console.error('SMOKE ERROR', e); process.exit(1) })