Parcourir la source

feat(sync): define nightly legacy sync with guarded execution and reports

彭峰 il y a 1 semaine
Parent
commit
a452627941

+ 19 - 0
deployment/systemd/xiaoshu-legacy-sync-nightly.service

@@ -0,0 +1,19 @@
+[Unit]
+Description=Xiaoshu nightly legacy sync with conflict protection and run report
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=oneshot
+WorkingDirectory=/opt/xiaoshu-angular
+EnvironmentFile=/etc/xiaoshu/legacy-sync-worker.env
+Environment=XIAOSHU_SYNC_REPORT_DIR=/var/lib/xiaoshu-legacy-sync
+Environment=XIAOSHU_SYNC_DATASETS=nodes,vocabulary,special-training,course-bindings,learning-records,practice-records,memory-records,assessments,appointments,lessons
+ExecStart=/usr/bin/node scripts/run-nightly-legacy-sync.mjs
+TimeoutStartSec=50min
+User=xiaoshu
+Group=xiaoshu
+StateDirectory=xiaoshu-legacy-sync
+UMask=0077
+NoNewPrivileges=true
+PrivateTmp=true

+ 12 - 0
deployment/systemd/xiaoshu-legacy-sync-nightly.timer

@@ -0,0 +1,12 @@
+[Unit]
+Description=Run Xiaoshu legacy sync daily at 23:00 Asia/Shanghai
+
+[Timer]
+OnCalendar=*-*-* 23:00:00 Asia/Shanghai
+Persistent=true
+AccuracySec=1s
+RandomizedDelaySec=0
+Unit=xiaoshu-legacy-sync-nightly.service
+
+[Install]
+WantedBy=timers.target

+ 51 - 0
docs/migration/nightly-legacy-sync.md

@@ -0,0 +1,51 @@
+# 每天 23:00 的旧系统同步任务
+
+时区固定 Asia/Shanghai,每晚 23:00 从旧系统读取并同步到新系统。客户继续使用旧站;保留新系统修改,记录冲突,不自动覆盖冲突、删除新系统记录或修改账户课时余额。
+
+## 2026-10-11 核查
+
+此前没有启用 23 点调度:本机 crontab 为空,没有小树 LaunchAgent 或已保存的 Codex 定时任务;仓库只有默认每 5 秒运行的 worker service 示例,不代表生产已经安装。不能读取远程服务器的调度器,因此不能断言所有服务器上都没有任务。
+
+当日生产复查:旧同步桥健康接口仍 HTTP 404,当前帐套 LegacySyncCheckpoint 为 0,未解决冲突为 684,失败队列为 0。普通管理员访问同步状态返回 403;普通管理员不能作为同步执行账号。上次手动补同步和本地脚本验证不等于夜间任务上线。
+
+随后使用已有有效超级管理员会话查询成功,云端明确返回 configured=false、bridgeReachable=false,所有数据集最近尝试和成功时间均为空。当前已将该执行会话保存到本机专用环境文件 `/Users/mac/.config/xiaoshu/legacy-sync-nightly.env`(0600),不包含主密钥;会话到期时间为 2027-09-09T04:36:34.414Z。账户权限不再是本次阻塞,桥部署与云端配置仍未就绪。不要把该环境文件提交或复制进报告。
+
+已通过应用工具创建并回读当前聊天的每日 23:00 自动任务,ID 为 23。它检查正式任务运行结果;尚未上线时核查同步条件,满足后执行 `npm run sync-worker:nightly`,不满足就持久保存失败原因。此聊天任务依赖电脑开机和应用运行,不能替代服务器调度。
+
+## 执行入口与成功标准
+
+`npm run sync-worker:nightly -- --check` 仅验收前提;`npm run sync-worker:nightly` 执行一轮。运行报告默认保存在 `~/.local/state/xiaoshu/legacy-sync/`,使用北京时间业务日期和唯一运行 ID,每次记录起止、状态、原因及各数据集计数;文件权限 0600。缺配置或接口故障也必须留下失败报告。
+
+本机聊天任务使用支持 `--env-file` 的 Node.js 20.6+:`node --env-file=/Users/mac/.config/xiaoshu/legacy-sync-nightly.env scripts/run-nightly-legacy-sync.mjs`,不要把凭据写到命令行参数或输出中。2026-10-11 已实跑 `--check`,得到 blocked / bridge_http_404,失败报告已保存。8 项自动测试验证了 404/凭据过期/未配置/保护缺失时不写数据、多轮分页和依赖顺序、未追平不报成功、冲突保留及缺少对账证据不报成功。
+
+运行器先检查健康服务身份、超级管理员凭据、签名 manifest 和冲突保护,再按固定依赖顺序分页,直到每个数据集 hasMore=false。最多 45 分钟、每数据集 200 轮。只读请求最多重试三次;写请求超时不盲目重发,下次通过服务器 checkpoint 续跑。并发运行由目录锁阻止;进程被强制杀死后的残留锁必须先确认原进程已退出再人工移除,不能直接强制并发。
+
+当前云端代码只对部分教学记录有保护,**尚未验收所有数据集“保留新修改”**,不能仅凭健康恢复就启动覆盖同步。网关需实现并通过并发更新、主附表原子性、冲突保留、重跑幂等验证后,才可在 `ops/sync/status` 返回 `syncProtection: { conflictPolicy: 'preserve-new', atomicProjection: true }`。运行器缺少这一证据即停止,不能在客户端设置标记绕过。
+
+同步后再次检查签名 manifest、失败和冲突;服务端须对各数据集做同水位业务 ID 集合与校验和核验,实际一致后返回每数据集 `reconciled=true`。仅计数相同或有检查点不足以通过。未解决冲突保留新数据并报告 completed_with_conflicts;没有对账证据报告 awaiting_reconciliation,均不宣称全部成功。
+
+默认范围为栏目、词库、专项训练、课程绑定、每日学习、练习、抗遗忘、测评、预约、上课,共 10 个数据集。账户/余额/课时归新系统管理,当前网关明确拒绝 users 同步;旧站新增用户须另做只新增、身份匹配、禁止财务覆盖的接口验收后才能加入任务,不能直接放开原 users 全字段覆盖。
+
+## 完成上线的次序
+
+1. 取得当前可用主机发布入口或托管方代部署。修正旧站域名下 IIS `/xiaoshu-sync` 独立应用;按桥 README 备份、预检。在桥和消费端准备就绪后安装 SQL 变更日志,验证健康、签名 manifest 和 11 个 changes 查询。
+2. 服务端安全配置桥 URL、HMAC KeyId/Secret、出口 IP,保持旧系统写入关闭。配置专用超级管理员同步身份和到期/轮换处理;不提升普通运营账号权限。所有密钥放服务端环境,不进 Git 或任务提示词。
+3. 完成上述云端冲突保护、原子性和 ID/校验和对账;通过 `--check` 后执行一次真实同步。对 684 条现存冲突保留并分别复核,禁止自动标记解决。
+4. 把下列 timer/service 安装到持续运行的新系统 Linux 主机,先确认现有持续 worker 的状态和业务依赖,再切换调度,避免两个入口重复同步。不要未经评估直接停掉已有持续 worker;工资结算的两分钟新鲜度要求与每天一次的节奏不同,夜间任务不能放宽此限制。
+5. 验证服务器 timer 的下一次触发时间,完成一次人工触发和第一次真实 23 点运行验收,再把聊天任务调整为核查服务器结果。失败通过现有运维监控告警,记录运行报告;还需配置并验证告警渠道,不能把日志文件等同通知已送达。
+
+## 服务器安装模板(尚未远程安装)
+
+将仓库放到 `/opt/xiaoshu-angular`,环境放 `/etc/xiaoshu/legacy-sync-worker.env`,文件权限 0600,执行用户 xiaoshu 必须能读取。环境需含 XIAOSHU_SYNC_OPERATOR_TOKEN、XIAOSHU_SYNC_COMPANY_ID、XIAOSHU_FUNCTION_URL、XIAOSHU_LEGACY_SYNC_BASE_URL。执行:
+
+```bash
+sudo install -m 0644 deployment/systemd/xiaoshu-legacy-sync-nightly.service /etc/systemd/system/
+sudo install -m 0644 deployment/systemd/xiaoshu-legacy-sync-nightly.timer /etc/systemd/system/
+sudo systemctl daemon-reload
+sudo systemctl start xiaoshu-legacy-sync-nightly.service
+sudo systemctl enable --now xiaoshu-legacy-sync-nightly.timer
+systemctl list-timers xiaoshu-legacy-sync-nightly.timer
+journalctl -u xiaoshu-legacy-sync-nightly.service --since today
+```
+
+人工运行失败时先处理报告,不把 timer 已启用视为同步已成功。Persistent=true 允许主机恢复后补触发错过的运行;服务器必须支持该带时区的 OnCalendar 表达式,并以服务器实际 list-timers 输出验收。

+ 1 - 0
package.json

@@ -33,6 +33,7 @@
     "sync-bridge:publish": "dotnet publish legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj -c Release -r win-x64 --self-contained true -o dist/legacy-sync-bridge-win-x64",
     "sync-bridge:package": "npm run sync-bridge:publish && bash legacy-sync-bridge/deployment/package.sh",
     "sync-bridge:smoke": "dotnet build legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj -c Release --no-restore && node scripts/smoke-legacy-sync-bridge.mjs",
+    "sync-worker:nightly": "node scripts/run-nightly-legacy-sync.mjs",
     "sync-worker:once": "node scripts/run-legacy-sync-worker.mjs --once",
     "sync-worker:start": "node scripts/run-legacy-sync-worker.mjs",
     "test:sync-worker": "node --test scripts/tests/legacy-sync-worker.test.mjs",

+ 128 - 0
scripts/run-nightly-legacy-sync.mjs

@@ -0,0 +1,128 @@
+#!/usr/bin/env node
+
+import { mkdir, writeFile, rename, rm } from 'node:fs/promises';
+import { homedir } from 'node:os';
+import { resolve, join } from 'node:path';
+import { randomUUID } from 'node:crypto';
+
+const defaultDatasets = ['nodes', 'vocabulary', 'special-training', 'course-bindings', 'learning-records', 'practice-records', 'memory-records', 'assessments', 'appointments', 'lessons'];
+const reportDir = resolve(process.env.XIAOSHU_SYNC_REPORT_DIR || join(homedir(), '.local/state/xiaoshu/legacy-sync'));
+const token = String(process.env.XIAOSHU_SYNC_OPERATOR_TOKEN || '').trim();
+const companyId = process.env.XIAOSHU_SYNC_COMPANY_ID || '7pIbDBJmKx';
+const functionUrl = (process.env.XIAOSHU_FUNCTION_URL || 'https://server.xiaoshu.pro/api/functions').replace(/\/$/, '');
+const bridgeUrl = (process.env.XIAOSHU_LEGACY_SYNC_BASE_URL || 'https://a018.2018.z01.com/xiaoshu-sync').replace(/\/$/, '');
+const appId = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const datasets = String(process.env.XIAOSHU_SYNC_DATASETS || defaultDatasets.join(',')).split(',').map(value => value.trim()).filter(Boolean);
+const maxRounds = Number(process.env.XIAOSHU_SYNC_MAX_ROUNDS || 200);
+const maxDurationMs = Number(process.env.XIAOSHU_SYNC_MAX_DURATION_MS || 2_700_000);
+const deadline = Date.now() + maxDurationMs;
+const runId = randomUUID();
+const businessDate = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date());
+const report = { runId, businessDate, timeZone: 'Asia/Shanghai', startedAt: new Date().toISOString(), status: 'running', reason: '', datasets: {}, retries: 0 };
+const lock = join(reportDir, 'nightly.lock');
+let locked = false;
+
+class RunError extends Error {
+  constructor(reason, status = 'failed') { super(reason); this.runStatus = status; }
+}
+function abort(reason, status = 'blocked') { throw new RunError(reason, status); }
+async function request(url, options = {}, allowRetry = true) {
+  for (let attempt = 0; ; attempt++) {
+    if (Date.now() >= deadline) abort('run_time_limit', 'incomplete');
+    try {
+      const response = await fetch(url, { ...options, signal: AbortSignal.timeout(Math.min(90_000, deadline - Date.now())) });
+      if (allowRetry && [429, 502, 503, 504].includes(response.status) && attempt < 2) {
+        report.retries++;
+        await new Promise(done => setTimeout(done, 1000 * (attempt + 1)));
+        continue;
+      }
+      return response;
+    } catch (error) {
+      if (error instanceof RunError) throw error;
+      if (!allowRetry || attempt >= 2) abort('network_request_failed', 'failed');
+      report.retries++;
+      await new Promise(done => setTimeout(done, 1000 * (attempt + 1)));
+    }
+  }
+}
+async function gateway(params) {
+  const response = await request(`${functionUrl}/xiaoshu/ops/gateway-v3`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': appId },
+    body: JSON.stringify({ token, params: { ...params, companyId } }),
+  }, params.operation === 'ops/sync/status');
+  if (!response.ok) abort(`gateway_http_${response.status}`);
+  const payload = await response.json().catch(() => null);
+  if (payload?.success !== true || !payload.data) abort('invalid_gateway_response');
+  return payload.data;
+}
+function assertProtection(status) {
+  // Do not infer protection from a caller-supplied flag. The deployed gateway
+  // must advertise this only after its implementation and acceptance checks pass.
+  if (status.syncProtection?.conflictPolicy !== 'preserve-new' || status.syncProtection?.atomicProjection !== true) {
+    abort('preserve_new_conflict_protection_not_verified');
+  }
+}
+
+try {
+  await mkdir(reportDir, { recursive: true, mode: 0o700 });
+  try { await mkdir(lock, { mode: 0o700 }); locked = true; }
+  catch (error) { if (error.code === 'EEXIST') abort('another_run_or_stale_lock', 'skipped'); throw error; }
+  await writeFile(join(lock, 'owner.json'), JSON.stringify({ runId, pid: process.pid, startedAt: report.startedAt }), { mode: 0o600 });
+  if (!Number.isSafeInteger(maxRounds) || maxRounds < 1 || !Number.isSafeInteger(maxDurationMs) || maxDurationMs < 1) abort('invalid_run_limits');
+  if (!datasets.length || new Set(datasets).size !== datasets.length || datasets.some(dataset => !defaultDatasets.includes(dataset))) abort('invalid_datasets');
+  if (datasets.includes('memory-records') && !datasets.includes('learning-records')) abort('missing_learning_dependency');
+  if (datasets.includes('lessons') && !datasets.includes('appointments')) abort('missing_appointment_dependency');
+  const healthResponse = await request(`${bridgeUrl}/v1/health`);
+  if (!healthResponse.ok) abort(`bridge_http_${healthResponse.status}`);
+  const health = await healthResponse.json().catch(() => null);
+  if (health?.status !== 'ok' || health.service !== 'xiaoshu-legacy-sync' || health.version !== 'v1') abort('wrong_bridge_service');
+  if (!token) abort('missing_super_admin_token');
+  if (process.env.XIAOSHU_SYNC_OPERATOR_EXPIRES_AT && !(Date.parse(process.env.XIAOSHU_SYNC_OPERATOR_EXPIRES_AT) > Date.now())) abort('expired_super_admin_token');
+  const initial = await gateway({ operation: 'ops/sync/status' });
+  if (!initial.configured || !initial.bridgeReachable) abort('bridge_not_configured_or_signed_manifest_unavailable');
+  assertProtection(initial);
+  if (process.argv.includes('--check')) {
+    report.status = 'ready';
+  } else {
+    // Use a fixed topological order, regardless of environment list order.
+    for (const dataset of defaultDatasets.filter(value => datasets.includes(value))) {
+      const totals = report.datasets[dataset] = { rounds: 0, processed: 0, created: 0, updated: 0, deleted: 0, conflicts: 0, failures: 0, caughtUp: false };
+      for (let round = 0; round < maxRounds; round++) {
+        // A POST timeout is ambiguous. Stop and retain the server checkpoint;
+        // the next scheduled run resumes it rather than replaying blindly.
+        const result = await gateway({ operation: 'ops/sync/run', dataset, maxPages: 10, pageSize: 500, conflictPolicy: 'preserve-new' });
+        if (result.dataset !== dataset || typeof result.hasMore !== 'boolean' || !Number.isFinite(result.failures)) abort('invalid_sync_result', 'failed');
+        totals.rounds++;
+        for (const key of ['processed', 'created', 'updated', 'deleted', 'conflicts', 'failures']) totals[key] += Number(result[key] || 0);
+        if (result.failures) abort(`dataset_failed:${dataset}`, 'failed');
+        if (!result.hasMore) { totals.caughtUp = true; break; }
+      }
+      if (!totals.caughtUp) abort(`round_limit:${dataset}`, 'incomplete');
+    }
+    const final = await gateway({ operation: 'ops/sync/status' });
+    assertProtection(final);
+    report.pendingFailures = Number(final.totalFailures || 0);
+    report.pendingConflicts = Number(final.totalConflicts || 0);
+    if (report.pendingFailures) abort('pending_failures', 'failed');
+    if (report.pendingConflicts) abort('pending_conflicts', 'completed_with_conflicts');
+    if (!final.configured || !final.bridgeReachable || !datasets.every(key => final.datasets?.some(row => row.key === key && row.reconciled === true))) {
+      abort('business_id_checksum_reconciliation_not_verified', 'awaiting_reconciliation');
+    }
+    report.status = 'success';
+  }
+} catch (error) {
+  report.status = error.runStatus || 'failed';
+  // Never persist a raw server error, token, payload, or business record.
+  report.reason = error instanceof RunError ? error.message : 'unexpected_runner_error';
+} finally {
+  report.finishedAt = new Date().toISOString();
+  await mkdir(reportDir, { recursive: true, mode: 0o700 });
+  const reportPath = join(reportDir, `${businessDate}-${runId}.json`);
+  const temporary = `${reportPath}.tmp`;
+  await writeFile(temporary, JSON.stringify(report, null, 2), { mode: 0o600 });
+  await rename(temporary, reportPath);
+  if (locked) await rm(lock, { recursive: true });
+  console.log(JSON.stringify({ status: report.status, reason: report.reason, reportPath }));
+  process.exitCode = ['success', 'ready'].includes(report.status) ? 0 : 1;
+}

+ 101 - 0
scripts/tests/nightly-legacy-sync.test.mjs

@@ -0,0 +1,101 @@
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { createServer } from 'node:http';
+import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import test from 'node:test';
+
+async function fixture(mode) {
+  const reportDir = await mkdtemp(join(tmpdir(), 'xiaoshu-nightly-test-'));
+  const calls = [];
+  const batches = new Map();
+  const datasets = ['memory-records', 'learning-records', 'lessons', 'appointments'];
+  const server = createServer(async (request, response) => {
+    response.setHeader('Content-Type', 'application/json');
+    if (request.url.endsWith('/health')) {
+      response.statusCode = mode === '404' ? 404 : 200;
+      response.end(JSON.stringify({ status: 'ok', service: 'xiaoshu-legacy-sync', version: 'v1' }));
+      return;
+    }
+    let body = '';
+    for await (const chunk of request) body += chunk;
+    const { params } = JSON.parse(body);
+    calls.push(params);
+    let data;
+    if (params.operation === 'ops/sync/status') {
+      data = {
+        configured: mode !== 'unconfigured', bridgeReachable: true,
+        syncProtection: mode === 'unprotected' ? undefined : { conflictPolicy: 'preserve-new', atomicProjection: true },
+        totalFailures: 0, totalConflicts: mode === 'conflicts' ? 684 : 0,
+        datasets: datasets.map(key => ({ key, reconciled: mode !== 'unreconciled' })),
+      };
+    } else {
+      assert.equal(params.conflictPolicy, 'preserve-new');
+      const round = (batches.get(params.dataset) || 0) + 1;
+      batches.set(params.dataset, round);
+      data = { dataset: params.dataset, failures: 0, processed: 1, created: 1, hasMore: params.dataset === 'learning-records' && (mode === 'incomplete' || round < 2) };
+    }
+    response.end(JSON.stringify({ success: true, data }));
+  });
+  await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+  const origin = `http://127.0.0.1:${server.address().port}`;
+  try {
+    const child = spawn(process.execPath, ['scripts/run-nightly-legacy-sync.mjs'], {
+      cwd: process.cwd(),
+      env: { ...process.env, XIAOSHU_FUNCTION_URL: origin, XIAOSHU_LEGACY_SYNC_BASE_URL: origin,
+        XIAOSHU_SYNC_OPERATOR_TOKEN: 'never-log-test-token', XIAOSHU_SYNC_DATASETS: datasets.join(','),
+        XIAOSHU_SYNC_OPERATOR_EXPIRES_AT: mode === 'expired' ? '2000-01-01T00:00:00Z' : '2099-01-01T00:00:00Z',
+        XIAOSHU_SYNC_MAX_ROUNDS: '2', XIAOSHU_SYNC_REPORT_DIR: reportDir },
+      stdio: ['ignore', 'pipe', 'pipe'],
+    });
+    let output = '';
+    child.stdout.on('data', chunk => { output += chunk; });
+    child.stderr.on('data', chunk => { output += chunk; });
+    const code = await new Promise(resolve => child.on('close', resolve));
+    const files = (await readdir(reportDir)).filter(name => name.endsWith('.json'));
+    assert.equal(files.length, 1, output);
+    const raw = await readFile(join(reportDir, files[0]), 'utf8');
+    assert(!raw.includes('never-log-test-token'));
+    assert(!output.includes('never-log-test-token'));
+    return { code, report: JSON.parse(raw), calls, batches };
+  } finally {
+    await new Promise(resolve => server.close(resolve));
+    await rm(reportDir, { recursive: true, force: true });
+  }
+}
+
+for (const [mode, reason] of [['404', 'bridge_http_404'], ['expired', 'expired_super_admin_token'], ['unconfigured', 'bridge_not_configured_or_signed_manifest_unavailable'], ['unprotected', 'preserve_new_conflict_protection_not_verified']]) {
+  test(`nightly refuses writes when ${mode}, and persists the failure`, async () => {
+    const result = await fixture(mode);
+    assert.equal(result.code, 1);
+    assert.equal(result.report.status, 'blocked');
+    assert.equal(result.report.reason, reason);
+    assert(!result.calls.some(call => call.operation === 'ops/sync/run'));
+  });
+}
+test('nightly drains multiple pages before dependents and requires reconciliation', async () => {
+  const result = await fixture('success');
+  assert.equal(result.code, 0);
+  assert.equal(result.report.status, 'success');
+  const requested = result.calls.filter(call => call.operation === 'ops/sync/run').map(call => call.dataset);
+  assert.deepEqual(requested, ['learning-records', 'learning-records', 'memory-records', 'appointments', 'lessons']);
+  assert.equal(result.report.datasets['learning-records'].created, 2);
+});
+test('round limit is a failure and never starts a dependent', async () => {
+  const result = await fixture('incomplete');
+  assert.equal(result.code, 1);
+  assert.equal(result.report.status, 'incomplete');
+  assert(!result.batches.has('memory-records'));
+});
+test('retained conflicts are recorded and never called success', async () => {
+  const result = await fixture('conflicts');
+  assert.equal(result.code, 1);
+  assert.equal(result.report.status, 'completed_with_conflicts');
+  assert.equal(result.report.pendingConflicts, 684);
+});
+test('caught up without business ID checksums is not success', async () => {
+  const result = await fixture('unreconciled');
+  assert.equal(result.code, 1);
+  assert.equal(result.report.status, 'awaiting_reconciliation');
+});