Sfoglia il codice sorgente

feat: 云端数据双保险——推送前自动备份被覆盖记录(保留20份可回滚)+ 云端写保护开关(本机只拉不推)

KenZ1117 2 settimane fa
parent
commit
94a01c1036
6 ha cambiato i file con 52 aggiunte e 1 eliminazioni
  1. 2 0
      public/app.js
  2. 5 0
      public/index.html
  3. 9 0
      scripts/smoke.js
  4. 1 0
      server/context.js
  5. 3 1
      server/routes/settings.js
  6. 32 0
      server/services/aitable.js

+ 2 - 0
public/app.js

@@ -88,6 +88,7 @@
     tokenInput.value = '';
     tokenInput.placeholder = settings.accessTokenSet ? '已设置,留空保持不变(输入 - 清除)' : '未设置';
     document.getElementById('settingsAitableEnabled').checked = !!settings.aitableEnabled;
+    document.getElementById('settingsAitableReadOnly').checked = !!settings.aitableReadOnly;
     document.getElementById('settingsAitableAppKey').value = settings.aitableAppKey || '';
     document.getElementById('settingsAitableBaseId').value = settings.aitableBaseId || '';
     document.getElementById('settingsAitableSheetDetail').value = settings.aitableSheetDetail || '门店明细';
@@ -159,6 +160,7 @@
   function saveAitableSettings() {
     return saveSettingsPart(() => ({
       aitableEnabled: document.getElementById('settingsAitableEnabled').checked,
+      aitableReadOnly: document.getElementById('settingsAitableReadOnly').checked,
       aitableAppKey: val('settingsAitableAppKey'),
       aitableBaseId: val('settingsAitableBaseId'),
       aitableSheetDetail: val('settingsAitableSheetDetail'),

+ 5 - 0
public/index.html

@@ -378,6 +378,7 @@
                     <li><b>层间矛盾拦截:</b>门店明细全部全零而连锁/公司汇总有数(取数异常形态)时拒绝推送;新店多导致的大面积全零是正常形态,不受影响。</li>
                     <li><b>拉取不算待推:</b>云端拉取回来的数据本来就已在云端,不计入本机「待推送」;从未本地同步过的只读机器恒显示与云端一致。</li>
                     <li><b>拉取以云端为准:</b>其他电脑执行「云端拉取」后,本地同键行被云端版本覆盖。</li>
+                    <li><b>推送前备份:</b>每次推送会把将被覆盖的云端记录自动备份到本机(.genpic/aitable-backup/,保留 20 份),坏推送可回滚;不放心误推的机器可在 系统设置 里开「云端写保护」,只拉不推。</li>
                   </ol>
                 </div>
               </span>
@@ -789,6 +790,10 @@
                     <input type="checkbox" id="settingsAitableEnabled">
                     <span>启用(同步完成后自动推送增量到云端)</span>
                   </label>
+                  <label style="display:flex;align-items:center;gap:6px;">
+                    <input type="checkbox" id="settingsAitableReadOnly">
+                    <span>云端写保护(本机只拉不推,防误推坏数据;要推送需先回这里关闭)</span>
+                  </label>
                 </div>
                 <div class="field">
                   <label>AppKey / AppSecret(钉钉企业内部应用)</label>

+ 9 - 0
scripts/smoke.js

@@ -184,6 +184,15 @@ async function main() {
     });
 
     // ---- 采集域 ----
+    await check('POST /api/settings 云端写保护开关读写', async () => {
+      let r = await request(port, 'POST', '/api/settings', { aitableReadOnly: true });
+      expect(r.status === 200, `status=${r.status}`);
+      r = await request(port, 'GET', '/api/settings');
+      expect(r.json().aitableReadOnly === true, '写保护保存未生效');
+      await request(port, 'POST', '/api/settings', { aitableReadOnly: false });
+      r = await request(port, 'GET', '/api/settings');
+      expect(r.json().aitableReadOnly === false, '写保护关闭未生效');
+    });
     await check('GET /api/session', async () => {
       const r = await request(port, 'GET', '/api/session');
       expect(r.status === 200 && typeof r.json().cookieConfigured === 'boolean', `status=${r.status}`);

+ 1 - 0
server/context.js

@@ -43,6 +43,7 @@ const CONFIG_DEFAULTS = {
   // 旧单表 sheetName 已废弃不读,读取处对缺省键兜底(老 config.json 零迁移)。
   aitable: {
     enabled: false,
+    readOnly: false,
     appKey: 'ding5vlpq3yeu7h6c6kj',
     appSecret: '',
     baseId: '7QG4Yx2JpLkaBl7EIpXNx0aGJ9dEq3XD',

+ 3 - 1
server/routes/settings.js

@@ -34,6 +34,7 @@ function register(app, ctx) {
       autoSyncTime: (ctx.config.autoSync && ctx.config.autoSync.time) || '06:00',
       accessTokenSet: !!ctx.config.accessToken,
       aitableEnabled: !!(ctx.config.aitable && ctx.config.aitable.enabled),
+      aitableReadOnly: !!(ctx.config.aitable && ctx.config.aitable.readOnly),
       aitableAppKey: (ctx.config.aitable && ctx.config.aitable.appKey) || '',
       aitableBaseId: (ctx.config.aitable && ctx.config.aitable.baseId) || '',
       aitableSheetDetail: (ctx.config.aitable && ctx.config.aitable.sheetDetail) || '门店明细',
@@ -94,9 +95,10 @@ function register(app, ctx) {
       if (t === '-') config.accessToken = ''; // 约定:输入 - 清除口令
       else if (t) config.accessToken = t;
     }
-    if (body.aitableEnabled !== undefined || body.aitableAppKey !== undefined || body.aitableBaseId !== undefined || body.aitableSheetDetail !== undefined || body.aitableSheetChain !== undefined || body.aitableSheetCompany !== undefined || body.aitableAppSecret !== undefined || body.aitableOperatorId !== undefined) {
+    if (body.aitableEnabled !== undefined || body.aitableReadOnly !== undefined || body.aitableAppKey !== undefined || body.aitableBaseId !== undefined || body.aitableSheetDetail !== undefined || body.aitableSheetChain !== undefined || body.aitableSheetCompany !== undefined || body.aitableAppSecret !== undefined || body.aitableOperatorId !== undefined) {
       if (!config.aitable || typeof config.aitable !== 'object') config.aitable = { enabled: false, appKey: '', appSecret: '', baseId: '', operatorId: '' };
       if (body.aitableEnabled !== undefined) config.aitable.enabled = !!body.aitableEnabled;
+      if (body.aitableReadOnly !== undefined) config.aitable.readOnly = !!body.aitableReadOnly;
       if (body.aitableAppKey !== undefined) config.aitable.appKey = String(body.aitableAppKey).trim();
       if (body.aitableBaseId !== undefined) {
         // 支持直接粘贴 AI 表格分享链接:自动提取 /i/nodes/<ID> 里的节点 ID 作为 Base ID

+ 32 - 0
server/services/aitable.js

@@ -285,6 +285,33 @@ function createAitableService(ctx) {
 
   const pushStateFile = () => path.join(workspace.getWorkspaceRoot(), '.genpic', 'aitable-push.json');
 
+  const BACKUP_KEEP = 20; // 每账号保留的推送前备份份数
+
+  /** 推送前备份:把本次将被覆盖的云端记录(id+字段)落盘。坏推送可据此回滚;
+   *  备份失败则中止推送(宁可推不出去,不可无备份地覆盖云端主档案)。 */
+  function backupSheetUpdates(accountName, sheetName, toUpdate) {
+    try {
+      const dir = path.join(workspace.getWorkspaceRoot(), '.genpic', 'aitable-backup',
+        String(accountName || 'account').replace(/[\\/:*?"<>|]/g, '_'));
+      fs.mkdirSync(dir, { recursive: true });
+      const payload = {
+        savedAt: new Date().toISOString(), account: accountName, sheet: sheetName,
+        updatedCount: toUpdate.length,
+        records: toUpdate.map((r) => ({ id: r.id, fields: r.fields })),
+      };
+      const file = path.join(dir, `${Date.now()}-${sheetName}.json`);
+      const temp = `${file}.tmp`;
+      fs.writeFileSync(temp, JSON.stringify(payload), 'utf8');
+      fs.renameSync(temp, file);
+      const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
+      for (const f of files.slice(0, Math.max(0, files.length - BACKUP_KEEP))) {
+        try { fs.unlinkSync(path.join(dir, f)); } catch { /* 单份清理失败不影响 */ }
+      }
+    } catch (e) {
+      throw err(`推送前备份失败(${e.message || e}),已中止本次推送以保护云端`, 'AITABLE_BACKUP_FAIL');
+    }
+  }
+
   function readPushState() {
     try { return JSON.parse(fs.readFileSync(pushStateFile(), 'utf8')); } catch { return {}; }
   }
@@ -334,6 +361,7 @@ function createAitableService(ctx) {
         toCreate.push(rec);
       }
     }
+    if (toUpdate.length) backupSheetUpdates(accountName, sheetName, toUpdate);
     for (const part of chunk(toCreate, INSERT_CHUNK)) {
       const res = await transport('POST', RECORDS_PATH(cfg.baseId, sheetName) + notableQuery(cfg), { records: part }, token);
       if (res.statusCode >= 300) throw err(`「${sheetName}」批量新增失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_CREATE_FAIL');
@@ -350,6 +378,10 @@ function createAitableService(ctx) {
   /** 推送一个主体的数据到三张表。force=true 时忽略水位推全量、并跳过冲突/全零保护。
    *  水位按「主体 + 三表名指纹」记录:表名变化(如单表→三表迁移)自动降级为全量重推。 */
   async function pushLake(entity, opts = {}) {
+    // 云端写保护:本机只拉不推(防误推坏数据污染共享主档案),硬开关,force 也不绕过
+    if (config.aitable && config.aitable.readOnly) {
+      throw err('本机已开启「云端写保护」,数据只拉不推。如需推送,请在 系统设置 → 钉钉 AI 表格 里关闭写保护', 'AITABLE_READ_ONLY');
+    }
     if (pushing) throw err('已有推送在进行中,请稍后再试', 'AITABLE_PUSH_BUSY');
     pushing = true;
     try {