|
|
@@ -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 {
|