| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128 |
- #!/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;
- }
|