3 Commits
23 changed files with 567 additions and 72 deletions
+18
View File
@@ -0,0 +1,18 @@
# v0.6.1 发布说明(2026-10-26)
## 数据安全:服务器数据纪元保护(重要)
- 服务端为每个用户的数据文件记录「数据纪元」(首次创建时间),随同步接口返回。
- 客户端检测到纪元变化(说明服务器数据存储被重建,例如误删 data 目录后重启)时,自动作废本地同步镜像,**把本机全量数据重新推回服务器**,避免「服务器空了 → 拉到空快照 → 本地缓存也被清空」的连锁数据丢失。
- 也就是说:只要还有任何一台设备保留着本地缓存,升级到本版本后第一次同步就会把数据恢复到服务器。
- 服务器部署文档已移除危险的 `rm -rf data` 步骤,并明确标注 data 目录为正式数据、严禁删除。
## 功能修正
- 详情页导出:「当日」改为「指定日」——导出的是详情页正在浏览的那一天;切换浏览日期时,导出区间(周/月/自定义)锚点自动跟随。
- 新增**全局计时时钟面板**:计划开始计时后,任何页面右下角都会悬浮显示计时任务(脉冲动画 + 实时走秒 + 一键停止),点击可跳回计划页;手机端悬浮于底部标签栏上方。
## 升级
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(**切勿删除 server/data 目录**)
- 桌面 / 安卓:安装对应 0.6.1 安装包。
+18
View File
@@ -0,0 +1,18 @@
# v0.6.2 发布说明(2026-10-26)
## 数据安全:补上「首次升级即遇灾」的恢复漏洞(关键)
- v0.6.1 的数据纪元保护只对「已存过纪元」的设备生效;所有设备第一次跑新版时检测不到服务器数据被重建。
- 本版本增加**二道防线**(不依赖任何历史状态):若本地镜像认为服务器应有 N 行,而服务器实际行数更少——即服务器数据发生过丢失/重建——立即作废本地镜像,把本机全量数据重推回服务器,再拉取快照,本地与服务器同时恢复。
- 已通过灾难恢复冒烟测试(7/7):删除服务器用户数据文件后,保留缓存的设备下一次同步自动重推全部数据(备忘录、密码库密文、主密码盐与校验值、考勤规则),全新设备随后完整拉回。
## 与数据丢失相关的说明
- 密码库:密文行与主密码盐/校验值均随同步存储于服务器;只要任一快照存活即可整体恢复,主密码不变。
- 备忘录:明文行随同步存储,恢复机制相同。
- 若所有设备都已在数据丢失后打开过应用(本地缓存同样被旧版清空),且服务器 data 目录被删、COS 未配置,则丢失部分无法找回;建议尽快配置 COS 异地备份。
## 升级
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(切勿删除 server/data)
- 桌面 / 安卓:安装 0.6.2;网页端刷新页面即为新版。
+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`
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
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-BbcS1QA8.js","./index-CtHE6m7N.js","./index-C8-KlPfD.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-CtHE6m7N.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-BbcS1QA8.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-DmHJbQPa.js","./index-BytpYHOU.js","./index-OQmtzryQ.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-BytpYHOU.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-DmHJbQPa.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
+2 -2
View File
@@ -8,8 +8,8 @@
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
<title>实习工作日志</title>
<script type="module" crossorigin src="./assets/index-CtHE6m7N.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-C8-KlPfD.css">
<script type="module" crossorigin src="./assets/index-BytpYHOU.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-OQmtzryQ.css">
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "intern-log",
"private": true,
"version": "0.6.0",
"version": "0.6.3",
"type": "module",
"scripts": {
"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'))
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 === 'ms2'))
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')
check('注册表仅存哈希不存明文密码', !!aRec && !JSON.stringify(aRec).includes('aapass'))
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(() => ({}))
check('health 暴露备份状态', hInfo && hInfo.backup === 'local-only', hInfo.backup)
+82
View File
@@ -0,0 +1,82 @@
import 'fake-indexeddb/auto'
import { bindDatabase, WorklogDB } from '../src/db/schema'
// node 环境的 localStorage 垫片(引擎的纪元/idMap 存取需要)
const lsMap = new Map<string, string>()
;(globalThis as any).localStorage = {
getItem: (k: string) => (lsMap.has(k) ? lsMap.get(k)! : null),
setItem: (k: string, v: string) => { lsMap.set(k, String(v)) },
removeItem: (k: string) => { lsMap.delete(k) }
}
const BASE = 'http://127.0.0.1:3780'
let pass = 0
let fail = 0
function check(name: string, ok: boolean, detail?: string): void {
if (ok) { pass++; console.log(' PASS ' + name) }
else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
}
async function main() {
await fetch(BASE + '/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'restore', password: 'dev123456' })
}).catch(() => { /* 已存在忽略 */ })
const engine = await import('../src/sync/engine')
const CONF = { baseUrl: BASE, username: 'restore', password: 'dev123456' }
// ===== 设备 A:备忘录 x3 + 密码库密文 x1 + 设置 =====
bindDatabase(new WorklogDB('restore_a'))
const a = (await import('../src/db/schema')).db
const now = '2026-10-26T10:00:00'
await a.memo.bulkAdd([
{ title: '备忘一', content: '内容一', created_at: now, updated_at: now },
{ title: '备忘二', content: '内容二', created_at: now, updated_at: now },
{ title: '备忘三', content: '内容三', created_at: now, updated_at: now }
])
await a.credential.bulkAdd([
{ system: '企业邮箱', account: 'dev@x.com', password: 'enc:v1:FAKECIPHER', remark: '', created_at: now, updated_at: now }
])
await a.setting.bulkAdd([
{ key: 'work_start', value: '09:00' },
{ key: 'pwd_salt', value: 'SALTSALT' },
{ key: 'pwd_verifier', value: 'enc:v1:VERIFIER' }
])
const rA = await engine.runSync(CONF)
console.log('设备A 首次推送:', JSON.stringify({ p: rA.pushed, e: rA.errors }))
// ===== 灾难:服务器用户数据文件被删(模拟 rm -rf data / 目录重建)=====
const fsx = await import('fs')
const usersDir = 'server/data/users'
for (const f of fsx.readdirSync(usersDir)) fsx.rmSync(usersDir + '/' + f)
console.log('已删除服务器用户数据文件')
// ===== 设备 A 再次同步:应触发二道防线,自动全量重推自救 =====
// (设备 A 此前从未存过纪元 —— 恰好覆盖「首次升级即遇到灾难」的窗口)
const rA2 = await engine.runSync(CONF)
console.log('设备A 自救同步:', JSON.stringify({ p: rA2.pushed, e: rA2.errors }))
check('自救时自动重推了全部本地数据(pushed>=7)', rA2.pushed >= 7, 'pushed=' + rA2.pushed)
const aMemo = await a.memo.toArray()
const aCred = await a.credential.toArray()
check('设备A 本地数据未被清空(备忘录仍 3 条)', aMemo.length === 3, 'len=' + aMemo.length)
check('设备A 本地密码库未被清空(仍 1 条)', aCred.length === 1, 'len=' + aCred.length)
// ===== 全新设备 C:拉取验证服务器已被 A 恢复 =====
bindDatabase(new WorklogDB('restore_c'))
const c = (await import('../src/db/schema')).db
const rC = await engine.runSync(CONF)
console.log('设备C 拉取:', JSON.stringify({ p: rC.pushed, e: rC.errors }))
const cMemo = await c.memo.toArray()
const cCred = await c.credential.toArray()
const cSet = await c.setting.toArray()
check('设备C 拉到 3 条备忘录', cMemo.length === 3, 'len=' + cMemo.length)
check('设备C 拉到密码库密文', cCred.length === 1 && cCred[0].system === '企业邮箱', 'len=' + cCred.length)
check('设备C 拉到主密码盐与校验值(密码库可解锁前提)', cSet.some(s => s.key === 'pwd_salt' && s.value === 'SALTSALT') && cSet.some(s => s.key === 'pwd_verifier'))
check('设备C 拉到考勤规则', cSet.some(s => s.key === 'work_start' && s.value === '09:00'))
console.log('RESULT: ' + (fail === 0 ? 'ALL PASS ' + pass : 'FAIL ' + fail + '/' + (pass + fail)))
if (fail > 0) process.exit(1)
}
main().catch(e => { console.error('SMOKE ERROR', e); process.exit(1) })
+3
View File
@@ -10,6 +10,7 @@ import LedgerView from './views/LedgerView.vue'
import MemoView from './views/MemoView.vue'
import VaultView from './views/VaultView.vue'
import SettingsView from './views/SettingsView.vue'
import TimerPanel from './components/TimerPanel.vue'
import UserAdminView from './views/UserAdminView.vue'
import LoginView from './views/LoginView.vue'
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth'
@@ -379,6 +380,8 @@ onMounted(async () => {
</div>
</div>
</div>
<!-- 全局计时时钟面板:有计划在计时时悬浮显示 -->
<TimerPanel v-if="session" />
<div class="toast-wrap">
<transition-group name="toast">
<div v-for="t in toasts" :key="t.id" class="toast">{{ t.text }}</div>
+154
View File
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { listRunningPlan, stopPlanTimer, type PlanItem } from '../db/repo'
import { planTimerPulse } from '../lib/bus'
import { showToast } from '../lib/toast'
// 全局计时时钟面板:任何页面都能看到正在计时的计划,实时走秒、一键停止、点击跳转计划页
const plan = ref<PlanItem | null>(null)
const nowMs = ref(Date.now())
let poll: number | null = null
let tick: number | null = null
async function refresh() {
try {
plan.value = await listRunningPlan()
} catch { /* 库未就绪时静默,下轮轮询重试 */ }
}
const elapsedSec = computed(() => {
const p = plan.value
if (!p || !p.timer_started_at) return 0
const base = (p.actual_minutes || 0) * 60
const extra = Math.max(0, Math.floor((nowMs.value - new Date(p.timer_started_at).getTime()) / 1000))
return base + extra
})
const elapsedText = computed(() => {
const s = elapsedSec.value
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const ss = s % 60
const p2 = (n: number) => String(n).padStart(2, '0')
return h > 0 ? h + ':' + p2(m) + ':' + p2(ss) : m + ':' + p2(ss)
})
async function stop() {
const p = plan.value
if (!p) return
await stopPlanTimer(p.id as number)
showToast('已停止计时:' + p.title)
await refresh()
}
function goPlan() {
location.hash = '#/plan'
}
watch(planTimerPulse, () => { void refresh() })
onMounted(() => {
void refresh()
poll = window.setInterval(() => { void refresh() }, 15000)
tick = window.setInterval(() => { nowMs.value = Date.now() }, 1000)
window.addEventListener('focus', refresh)
})
onUnmounted(() => {
if (poll !== null) window.clearInterval(poll)
if (tick !== null) window.clearInterval(tick)
window.removeEventListener('focus', refresh)
})
</script>
<template>
<transition name="tp">
<div v-if="plan && plan.timer_started_at" class="tp" @click="goPlan" title="点击查看计划页">
<span class="tp-pulse"></span>
<span class="tp-main">
<span class="tp-title">{{ plan.title }}</span>
<span class="tp-meta">{{ plan.kind }} · 计时中</span>
</span>
<span class="tp-time num">{{ elapsedText }}</span>
<span class="tp-stop" role="button" title="停止计时" @click.stop="stop">停止</span>
</div>
</transition>
</template>
<style scoped>
.tp {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 40;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
max-width: 340px;
padding: 10px 12px;
background: var(--bg-page);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow-float);
cursor: pointer;
text-align: left;
transition: border-color 0.12s;
}
.tp:hover { border-color: var(--border-strong); }
.tp-pulse {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--accent);
position: relative;
flex: none;
}
.tp-pulse::after {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
border: 2px solid var(--accent);
animation: tp-ring 1.6s ease-out infinite;
}
@keyframes tp-ring {
0% { transform: scale(0.5); opacity: 0.6; }
100% { transform: scale(1.5); opacity: 0; }
}
.tp-main { display: flex; flex-direction: column; min-width: 0; }
.tp-title {
font-size: 13px;
font-weight: 600;
color: var(--text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tp-meta { font-size: 11px; color: var(--text-3); }
.tp-time {
font-size: 16px;
font-weight: 600;
color: var(--accent);
font-variant-numeric: tabular-nums;
margin-left: 2px;
flex: none;
}
.tp-stop {
flex: none;
font-size: 12px;
color: var(--text-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 3px 10px;
transition: all 0.12s;
}
.tp-stop:hover { color: var(--danger); border-color: var(--danger); background: #FDF0F0; }
.tp-enter-active, .tp-leave-active { transition: all 0.2s ease; }
.tp-enter-from, .tp-leave-to { opacity: 0; transform: translateY(10px); }
@media (max-width: 720px) {
.tp {
left: 12px;
right: 12px;
bottom: calc(66px + env(safe-area-inset-bottom));
max-width: none;
}
}
</style>
+6
View File
@@ -379,6 +379,12 @@ export async function listPlansByDate(date: string): Promise<PlanItem[]> {
return rows.sort((a, b) => (a.start_time < b.start_time ? -1 : 1))
}
// 全局计时时钟面板:查找正在进行中的计时任务(跨日期,任意一天开始的都算)
export async function listRunningPlan(): Promise<PlanItem | null> {
const rows = await db.plan.filter(p => !!p.timer_started_at).toArray()
return rows.length ? rows[0] : null
}
export async function addPlan(input: PlanInput): Promise<void> {
const now = nowLocalISO()
await db.plan.add({
+6
View File
@@ -9,3 +9,9 @@ export const attendanceChanged = ref(0)
export function notifyAttendanceChanged(): void {
attendanceChanged.value++
}
// 计划计时启停信号:全局计时时钟面板监听并即时刷新
export const planTimerPulse = ref(0)
export function notifyPlanTimer(): void {
planTimerPulse.value++
}
+24 -1
View File
@@ -300,7 +300,7 @@ export async function runSync(config: SyncConfig): Promise<SyncResult> {
}
}
async function doRunSync(config: SyncConfig): Promise<SyncResult> {
async function doRunSync(config: SyncConfig, epochRetry = false): Promise<SyncResult> {
const base = config.baseUrl.trim().replace(/\/+$/, '')
if (!base) throw new Error('请先配置同步服务地址')
await ensureSyncFields()
@@ -358,9 +358,31 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
}
if (!resp.ok) throw new Error('同步服务返回 ' + resp.status)
const data = await resp.json()
// 服务器数据纪元检测:纪元变化说明服务端数据存储被重建(如误删 data 目录后重启)。
// 此时本地镜像不可信(镜像认为「早已同步」而不推送,随后会拉到空快照把本地缓存清空)。
// 处置:作废镜像并以本地全量数据重推一次,让服务器从本设备恢复。
const epoch = String(data.dataEpoch || '')
const epochKey = 'dsh_sync_epoch_' + (getCloudCreds()?.username || 'anon')
const prevEpoch = epoch ? localStorage.getItem(epochKey) : null
if (!epochRetry && prevEpoch && epoch && prevEpoch !== epoch) {
await db.sync_mirror.clear()
localStorage.setItem(epochKey, epoch)
return doRunSync(config, true)
}
const serverTables: Record<string, AnyRow[]> = data.tables || {}
const serverTombstones: { table: string; uuid: string; deleted_at: string }[] = data.tombstones || []
// 二道防线(不依赖纪元存储,首次升级即可生效):
// 本地镜像认为服务器应有 N 行,但服务器实际行数更少 —— 说明服务器数据发生过丢失/重建。
// 此时若照常拉快照,会把本地缓存也清空。处置:作废镜像并以本地全量数据重推自救。
const serverCount = TABLES.reduce((s, tn) => s + ((serverTables[tn] || []).length), 0)
const expectedMin = mirrorRows.length - tombstones.length
if (!epochRetry && serverCount < expectedMin) {
await db.sync_mirror.clear()
return doRunSync(config, true)
}
// 3. 用服务器快照全量重建本地缓存(本地不再有映射逻辑,服务器永远是对的)
const applyResult = await applySnapshot(serverTables, serverTombstones)
@@ -368,6 +390,7 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
ensureSSE(base, getToken() || token)
const at = nowLocalISO()
await putSyncLastTime(at)
if (epoch) localStorage.setItem(epochKey, epoch)
if (applyResult.changed > 0 || applyResult.removed > 0 || pushed > 0) notifyAttendanceChanged()
return { pushed, pulled: applyResult.changed, removed: applyResult.removed, errors: 0, at }
}
+2 -1
View File
@@ -8,7 +8,7 @@ import {
addGoal, updateGoal, deleteGoal, listGoals, getGoalProgress, type Goal
} from '../db/repo'
import { addDays, parseDateStr, todayStr, toDateStr, formatDateCN } from '../lib/date'
import { attendanceChanged } from '../lib/bus'
import { attendanceChanged, notifyPlanTimer } from '../lib/bus'
import { showToast } from '../lib/toast'
// ---- 长期计划 ----
@@ -144,6 +144,7 @@ async function toggleTimer(p: PlanItem) {
await startPlanTimer(p.id as number)
showToast('开始计时:' + p.title)
}
notifyPlanTimer()
await load()
}
+7 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import Icon from '../components/Icon.vue'
import DayPanel from '../components/log/DayPanel.vue'
import { listLogItemsBetween, listAttendanceBetween, listProjects, getWorkRule } from '../db/repo'
@@ -23,7 +23,8 @@ const expStart = ref(todayStr())
const expEnd = ref(todayStr())
const exporting = ref(false)
function rangeStartEnd(): { start: string; end: string; label: string } {
if (expRange.value === 'day') return { start: expStart.value, end: expStart.value, label: expStart.value }
// 「指定日」导出:导的是详情页当前浏览的那一天,而不是今天
if (expRange.value === 'day') return { start: date.value, end: date.value, label: date.value }
if (expRange.value === 'week') {
const ws = weekStart(expStart.value)
return { start: ws, end: addDays(ws, 6), label: ws + '_' + addDays(ws, 6) }
@@ -87,6 +88,9 @@ function onDateInput(e: Event) {
const v = (e.target as HTMLInputElement).value
if (v) date.value = v
}
// 浏览日期变化时,导出区间锚点跟随(周/月/自定义默认围绕当前指定日)
watch(date, d => { expStart.value = d; if (expRange.value === 'custom') expEnd.value = d })
</script>
<template>
<div class="detail-page">
@@ -102,7 +106,7 @@ function onDateInput(e: Event) {
<div class="exp-row">
<span class="muted exp-label">导出</span>
<div class="seg">
<button class="seg-btn" :class="{ active: expRange === 'day' }" @click="expRange = 'day'">当日</button>
<button class="seg-btn" :class="{ active: expRange === 'day' }" @click="expRange = 'day'">指定日</button>
<button class="seg-btn" :class="{ active: expRange === 'week' }" @click="expRange = 'week'">当周</button>
<button class="seg-btn" :class="{ active: expRange === 'month' }" @click="expRange = 'month'">当月</button>
<button class="seg-btn" :class="{ active: expRange === 'custom' }" @click="expRange = 'custom'">自定义</button>
+10 -4
View File
@@ -14,10 +14,11 @@
cd /opt
git clone https://gitea.linyux.cn/root/daywork.git daywork 2>/dev/null || (cd daywork && git pull)
cd daywork/server
rm -rf data
docker compose up -d --build
注:rm -rf data 清除开发期残留的测试密钥,服务端首次启动会自动生成全新的签名密钥。
**警告:绝对不要删除 server/data 目录** —— 这里是所有用户的正式数据(注册表、业务数据、每日备份)。
首次全新部署(且确认 data 目录从未存在过)无需任何清理动作,服务端首次启动会自动生成签名密钥。
另外,客户端内置「数据纪元」保护:即使服务器数据目录意外重建,任一保留本地缓存的设备在下次同步时会自动把全量数据推回服务器。
容器 internlog-sync 加入 mynet 网络(与 caddy 同网络,Caddy 可用容器名访问)。
@@ -61,8 +62,13 @@ Caddy 会自动为 daywk.linyux.cn 申请并续期 HTTPS 证书(需 DNS 已生
- 数据目录:/opt/daywork/server/data
- registry.json 用户注册表(用户名 / 角色 / PBKDF2 哈希)
- users/<id>.json 每用户九张表数据 + 墓碑
- backups/<日期>/ **每日自动本地备份**(启动与每次注册表变更也会触发,保留最近 14 天)
- users/<id>.json 每用户全部业务表数据 + 墓碑
- backups/<日期>/ 每日快照。**当日首份不可变**(完整证据链),同日再备份写入 `<日期>_t<时分秒>` 副本
- backups/weekly/<日期>/ 每周一额外深快照,保留 8 份
- 保留策略:每日快照 14 天;写入均为「临时文件 + fsync + 原子改名」,断电不会留下半截文件
- 用户数据文件若解析失败会被自动隔离为 `<文件>.corrupt-<时间戳>` 并在日志中大声报错,绝不静默当作空数据
- 墓碑自动回收:删除标记保留 14 天后自动清除(足够覆盖离线设备),文件不会无限膨胀
- 单次同步携带 ≥20 条墓碑(大批删除)时,服务端会**先自动快照再应用删除**,可随时回滚
- **异地备份(推荐开启)**:在 docker-compose.yml 的 internlog-sync 服务加环境变量,备份自动上传腾讯云 COS:
environment:
+110 -27
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 TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 天
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']
@@ -35,7 +40,13 @@ function readJson(file, fallback) {
}
function writeJson(file, data) {
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))
}
@@ -126,8 +137,37 @@ function isAdminToken(token) {
function saveRegistry() { writeJson(registryFile, registry); try { doBackup() } catch { /* 忽略 */ } }
function userFile(userId) { return path.join(DATA_DIR, 'users', userId + '.json') }
function emptyUserData(userId) {
return { userId, cursor: 0, tables: {}, tombstones: {}, created_at: '' }
}
function loadUserData(userId) {
return readJson('users/' + userId + '.json', { userId, cursor: 0, tables: {}, tombstones: {} })
const full = userFile(userId)
if (fs.existsSync(full)) {
try {
return JSON.parse(fs.readFileSync(full, 'utf-8'))
} catch (e) {
// 数据文件损坏:隔离保留原件并大声报错,绝不静默当作空数据(否则客户端同步会引发连锁覆盖)
const quarantine = full + '.corrupt-' + new Date().toISOString().replace(/[:.]/g, '-')
try { fs.renameSync(full, quarantine) } catch { /* 忽略 */ }
console.error('[存储] 用户数据文件解析失败,已隔离:' + quarantine + '(' + (e && e.message) + ')')
return emptyUserData(userId)
}
}
return emptyUserData(userId)
}
// 墓碑回收:超过保留期的墓碑删除(全量快照重建的客户端不依赖过期墓碑也能收敛)
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) {
fs.mkdirSync(path.join(DATA_DIR, 'users'), { recursive: true })
@@ -266,7 +306,15 @@ async function handle(req, res) {
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 无法自定义请求头)
if (req.method === 'GET' && route === '/api/events') {
@@ -356,9 +404,11 @@ async function handle(req, res) {
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
const body = await readBody(req)
const userData = loadUserData(auth.userId)
if (!userData.cursor) userData.cursor = 0
if (!userData.tables) userData.tables = {}
if (!userData.tombstones) userData.tombstones = {}
let dirty = false
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 || {}
@@ -408,12 +458,18 @@ async function handle(req, res) {
userData.tombstones[t][conflictUuid] = now
changed++
}
// 内容与已存行完全一致:不计变更、不触发保存(空推送不落盘)
if (!conflictUuid && existing && JSON.stringify(existing) === JSON.stringify(clean)) continue
userData.tables[t][row.uuid] = clean
changed++
}
}
// 应用墓碑
const incomingTombs = body.tombstones || []
if (incomingTombs.length >= LARGE_TOMBSTONE_BACKUP) {
// 大批删除的前置保险:先把当前数据快照留存,再应用删除(可追溯、可回滚)
try { doBackup(); console.log('[存储] 检测到大批删除(' + incomingTombs.length + ' 条墓碑),已先行快照') } catch { /* 忽略 */ }
}
for (const tb of incomingTombs) {
if (!TABLES.includes(tb.table) || !tb.uuid) continue
userData.tombstones[tb.table] = userData.tombstones[tb.table] || {}
@@ -431,7 +487,8 @@ async function handle(req, res) {
if (row && 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')
// 返回权威全量
@@ -442,7 +499,7 @@ async function handle(req, res) {
const tombs = userData.tombstones[t] || {}
for (const [u, deletedAt] of Object.entries(tombs)) outTombs.push({ table: t, uuid: u, deleted_at: deletedAt })
}
json(res, 200, { tables: outTables, tombstones: outTombs, serverTime: new Date().toISOString() })
json(res, 200, { tables: outTables, tombstones: outTombs, serverTime: new Date().toISOString(), dataEpoch: String(userData.created_at || '') })
return
}
@@ -508,27 +565,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() {
try {
const day = new Date().toISOString().slice(0, 10)
const dir = path.join(DATA_DIR, 'backups', day)
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')) fs.copyFileSync(path.join(usersDir, f), path.join(dir, 'users', f))
}
}
} catch { /* 忽略 */ }
// 保留最近 14 天
try {
const backupsRoot = path.join(DATA_DIR, 'backups')
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 { /* 忽略 */ } }
} catch { /* 忽略 */ }
const now = new Date()
const day = now.toISOString().slice(0, 10)
const root = path.join(DATA_DIR, 'backups')
let dir = path.join(root, day)
// 当日首份快照不可变(当日完整证据链);之后的同日备份落时间戳副本
if (fs.existsSync(dir)) dir = path.join(root, day + '_t' + now.toTimeString().slice(0, 8).replace(/:/g, ''))
snapshotTo(dir)
// 每周一额外做周快照(更深恢复点),保留 8 份
if (now.getDay() === 1) {
const wdir = path.join(root, 'weekly', day)
if (!fs.existsSync(wdir)) snapshotTo(wdir)
try {
const wroot = path.join(root, 'weekly')
const ws = fs.readdirSync(wroot).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort()
while (ws.length > WEEKLY_KEEP) { const old = ws.shift(); try { fs.rmSync(path.join(wroot, old), { recursive: true, force: true }) } catch { /* 忽略 */ } }
} catch { /* 忽略 */ }
}
pruneBackups()
lastBackupAt = new Date().toISOString()
} catch (e) { console.error('backup failed:', e && e.message) }
void backupOffsite()
}
+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) })