Răsfoiți Sursa

feat: DingTalk AI table sync (push/pull) for shared online archive

双层架构:AI 表格=在线主档案/共享层,本地湖=计算缓存层。
- server/services/aitable.js: token 客户端 + 字段映射(23 列)+ 推送
  (syncedAt 水位增量、业务键 日期|账号|维度|门店ID 幂等 upsert、分批)
  + 拉取(未指定主体时按账号名全量拉取并自动注册轻量主体,fresh 机器
  一键补全)+ 连通测试;钉钉端点细节全部收敛在此文件便于真机核对
- 同步完成后自动推送增量(启用时),失败仅记日志不影响本地同步
- 设置页新增「钉钉 AI 表格」面板:启用/凭据/Base/表名 + 测试连通/
  推送云端/从云端拉取;设置 API 脱敏 Secret
- 单测 6/6(映射往返/业务键/维度标签),冒烟 52/52
KenZ1117 2 săptămâni în urmă
părinte
comite
fe9c37d568
9 a modificat fișierele cu 477 adăugiri și 3 ștergeri
  1. 2 0
      README.md
  2. 44 0
      public/app.js
  3. 30 0
      public/index.html
  4. 3 1
      public/style.css
  5. 2 0
      server.js
  6. 2 0
      server/context.js
  7. 49 2
      server/routes/reports.js
  8. 17 0
      server/routes/settings.js
  9. 328 0
      server/services/aitable.js

+ 2 - 0
README.md

@@ -34,6 +34,7 @@
 - **BI 展示**:KPI 卡片 + 明细 / 按门店 / 按日期三种视图,列可自定义显示(列设置),支持排序、门店筛选、名称搜索与 CSV 导出。
 - **趋势图表**(独立菜单):环比指标条(7 项指标 vs 上期 ±%)、主指标趋势(可叠加上期虚线对比)、门店排行 Top10、转化漏斗(曝光→进店→下单)、门店对比 Top5 折线;一键导出自包含 HTML 经营周报。
 - **智能问数机器人**:仅经营分析相关页面显示的右下角悬浮助手,弹窗对话、支持上下文追问(如先问"上月营业额Top5门店"再问"那二店的单量呢"),回答可附图表并一键应用到数据采集报表。需在「系统设置 → API 设置 → 对话模型」选择对话模型(未保存时默认 deepseek-v4.1-flash,Key 留空复用生图 API Key)。
+- **钉钉 AI 表格同步**:把数据湖增量推送到钉钉 AI 表格(在线主档案/共享层),实现一人取数、多人共享、在线存档。其他电脑在「系统设置 → 钉钉 AI 表格」点「从云端拉取」即可补全本地数据(新账号自动注册主体),报表/图表/问数照常使用。需钉钉企业内部应用(开通 AI 表格 API 权限),配置 AppKey/AppSecret/Base ID/表名。
 - **同步记录诊断**:每次同步的步骤记录会携带请求参数与返回概况(行数/非零行/订单与营业额合计),门店粒度返回全零时自动标记「⚠全零」,便于远程排查。
 - **取数诊断脚本**:在装有商家客户端的电脑上运行 `node scripts/diagnose-napos.js [日期] [门店ID]`,对门店维度取数做参数对照实验并生成 JSON 诊断报告,拷回开发机即可定位取数异常。
 - **MCP 工具**:`node reports-mcp.js` 启动 stdio MCP server(reports_list_entities / reports_summary / reports_query),注册到 `~/.workbuddy/mcp.json` 即可供 Agent 直接查数,与 napos-mcp(取数)形成闭环。
@@ -93,6 +94,7 @@
 | Cookie | 用于淘宝闪购后台采集,仅保存在本地 `session.json`。 |
 | `accessToken` | 局域网访问口令:设置后其他电脑访问本服务需输入口令(本机 127.0.0.1 不受影响)。留空保持不变,输入 `-` 清除。也可用环境变量 `ACCESS_TOKEN` 临时开关(不落盘)。 |
 | `autoSync` | 每日自动同步:`{ "enabled": true, "time": "06:00" }`,也可在「同步数据」弹窗里开关。 |
+| `aitable` | 钉钉 AI 表格同步:`{ "enabled": true, "appKey": "...", "appSecret": "...", "baseId": "...", "sheetName": "经营数据" }`,也可在「系统设置 → 钉钉 AI 表格」配置。
 | `httpLog` | `true` 时在控制台打印每个 HTTP 请求(5xx 与超过 1 秒的慢请求始终打印)。 |
 
 API Key 获取与充值入口可在“系统设置 → API 设置”中打开。

+ 44 - 0
public/app.js

@@ -83,6 +83,13 @@
     const tokenInput = document.getElementById('settingsAccessToken');
     tokenInput.value = '';
     tokenInput.placeholder = settings.accessTokenSet ? '已设置,留空保持不变(输入 - 清除)' : '未设置';
+    document.getElementById('settingsAitableEnabled').checked = !!settings.aitableEnabled;
+    document.getElementById('settingsAitableAppKey').value = settings.aitableAppKey || '';
+    document.getElementById('settingsAitableBaseId').value = settings.aitableBaseId || '';
+    document.getElementById('settingsAitableSheetName').value = settings.aitableSheetName || '经营数据';
+    const aitableSecretInput = document.getElementById('settingsAitableAppSecret');
+    aitableSecretInput.value = '';
+    aitableSecretInput.placeholder = settings.aitableSecretSet ? '已设置,留空保持不变(输入 - 清除)' : '未设置';
     await loadModels(settings.model);
     showSettingsStatus('已同步');
   }
@@ -111,6 +118,13 @@
           ...(document.getElementById('settingsAccessToken').value.trim()
             ? { accessToken: document.getElementById('settingsAccessToken').value.trim() }
             : {}),
+          aitableEnabled: document.getElementById('settingsAitableEnabled').checked,
+          aitableAppKey: document.getElementById('settingsAitableAppKey').value.trim(),
+          aitableBaseId: document.getElementById('settingsAitableBaseId').value.trim(),
+          aitableSheetName: document.getElementById('settingsAitableSheetName').value.trim(),
+          ...(document.getElementById('settingsAitableAppSecret').value.trim()
+            ? { aitableAppSecret: document.getElementById('settingsAitableAppSecret').value.trim() }
+            : {}),
         }),
       });
       const data = await response.json();
@@ -199,6 +213,33 @@
     });
   }
 
+  async function aitableAction(kind) {
+    const status = document.getElementById('aitableStatus');
+    const labels = { test: '测试连通中…', push: '推送中…', pull: '拉取中…' };
+    status.textContent = labels[kind];
+    status.className = 'badge badge-info';
+    try {
+      const res = await fetch(`/api/reports/aitable/${kind}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
+      const data = await res.json();
+      if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
+      if (kind === 'test') {
+        status.textContent = '连通正常';
+        status.className = 'badge badge-ok';
+        return;
+      }
+      if (kind === 'push') {
+        status.textContent = `已推送 ${data.pushed} 行(新增 ${data.created} / 更新 ${data.updated})`;
+      } else {
+        const detail = (data.accounts || []).map((a) => `${a.account} ${a.rows}行`).join('、') || '无数据';
+        status.textContent = `已拉取 ${data.pulled} 行:${detail}`;
+      }
+      status.className = 'badge badge-ok';
+    } catch (error) {
+      status.textContent = error.message;
+      status.className = 'badge badge-fail';
+    }
+  }
+
   async function initSettings() {
     if (settingsLoaded) return;
     bindSettingsTabs();
@@ -208,6 +249,9 @@
     document.getElementById('btnSaveSettings').addEventListener('click', saveSettings);
     document.getElementById('btnTestImage').addEventListener('click', testImageApi);
     document.getElementById('btnTestChat').addEventListener('click', testChatModel);
+    document.getElementById('btnTestAitable').addEventListener('click', () => aitableAction('test'));
+    document.getElementById('btnAitablePush').addEventListener('click', () => aitableAction('push'));
+    document.getElementById('btnAitablePull').addEventListener('click', () => aitableAction('pull'));
     document.getElementById('btnClearLog').addEventListener('click', () => {
       clearLogs().catch(() => {});
     });

+ 30 - 0
public/index.html

@@ -555,6 +555,36 @@
                 <div class="help">设置后,同一局域网的其他电脑访问本服务需要输入此口令(本机 127.0.0.1 不受影响)。留空保持不变;输入 - 清除口令。临时开关也可用环境变量 ACCESS_TOKEN。</div>
               </div>
             </div>
+          </div>
+          <div class="panel panel-narrow">
+            <div class="stage-marker"><span class="num">4</span><span>钉钉 AI 表格</span></div>
+            <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="3"/><path d="M3 10h18M9 10v10"/></svg></span>
+            <div class="panel-body">
+              <div class="field">
+                <label style="display:flex;align-items:center;gap:6px;">
+                  <input type="checkbox" id="settingsAitableEnabled">
+                  <span>启用(同步完成后自动推送增量到云端)</span>
+                </label>
+              </div>
+              <div class="field">
+                <label>AppKey / AppSecret(钉钉企业内部应用)</label>
+                <input type="text" id="settingsAitableAppKey" placeholder="钉钉开放平台应用的 AppKey" autocomplete="off">
+                <input type="text" id="settingsAitableAppSecret" placeholder="AppSecret 未设置" autocomplete="off" style="margin-top:6px;">
+                <div class="help">Secret 保存后不回显;输入 - 清除。应用需开通「AI 表格」API 权限。</div>
+              </div>
+              <div class="field">
+                <label>Base ID / 数据表名称</label>
+                <input type="text" id="settingsAitableBaseId" placeholder="AI 表格的 Base ID" autocomplete="off">
+                <input type="text" id="settingsAitableSheetName" placeholder="经营数据" autocomplete="off" style="margin-top:6px;">
+              </div>
+              <div class="settings-links">
+                <button type="button" class="settings-link settings-link-btn" id="btnTestAitable"><span>测试连通</span></button>
+                <button type="button" class="settings-link settings-link-btn" id="btnAitablePush"><span>推送云端</span></button>
+                <button type="button" class="settings-link settings-link-btn" id="btnAitablePull"><span>从云端拉取</span></button>
+                <span id="aitableStatus" class="badge"></span>
+              </div>
+              <div class="help">架构:取数机「同步 → 自动推送」,其他电脑「从云端拉取」到本地后再看报表/图表。云表为在线主档案,一人取数、多人共享。</div>
+            </div>
           </div>
           	          </div>
 	                  </div>

+ 3 - 1
public/style.css

@@ -169,6 +169,7 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .settings-forms > .panel:nth-child(1) { grid-column: 1; grid-row: 1; }
 .settings-forms > .panel:nth-child(2) { grid-column: 2; grid-row: 1 / span 2; }
 .settings-forms > .panel:nth-child(3) { grid-column: 1; grid-row: 2; }
+.settings-forms > .panel:nth-child(4) { grid-column: 1; grid-row: 3; }
 .settings-forms .field textarea { min-height: 78px; resize: vertical; line-height: 1.5; }
 .settings-forms .panel,
 .panel-designed,
@@ -213,7 +214,8 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
   .settings-forms { grid-template-columns: 1fr; }
   .settings-forms > .panel:nth-child(1),
   .settings-forms > .panel:nth-child(2),
-  .settings-forms > .panel:nth-child(3) { grid-column: auto; grid-row: auto; }
+  .settings-forms > .panel:nth-child(3),
+  .settings-forms > .panel:nth-child(4) { grid-column: auto; grid-row: auto; }
   #viewLogs.active { display: block; height: auto; }
   #viewLogs .content { min-height: 0; }
   #viewLogs .settings-log { flex: none; height: auto; min-height: 420px; }

+ 2 - 0
server.js

@@ -15,6 +15,7 @@ const { sendJson } = require('./server/util.js');
 const { createCrawlService } = require('./server/services/crawl.js');
 const { createGenerationService } = require('./server/services/generation.js');
 const { createAutoSyncService } = require('./server/services/auto-sync.js');
+const { createAitableService } = require('./server/services/aitable.js');
 const { registerRoutes } = require('./server/routes/index.js');
 
 const HOST = process.env.HOST || '127.0.0.1';
@@ -23,6 +24,7 @@ const ctx = createContext();
 ctx.crawl = createCrawlService(ctx);
 ctx.generation = createGenerationService(ctx);
 ctx.autoSync = createAutoSyncService(ctx);
+ctx.aitable = createAitableService(ctx);
 
 const app = createRouter();
 registerRoutes(app, ctx);

+ 2 - 0
server/context.js

@@ -29,6 +29,8 @@ const CONFIG_DEFAULTS = {
   accessToken: '',
   // HTTP 访问日志:true 时在控制台打印每个请求(5xx 与超过 1s 的慢请求始终打印)
   httpLog: false,
+  // 钉钉 AI 表格(在线主档案/共享层):同步完成后自动推送,其他机器可拉取
+  aitable: { enabled: false, appKey: '', appSecret: '', baseId: '', sheetName: '经营数据' },
   // 经营数据每日自动同步(商家客户端在线时自动补昨日数据),默认关闭
   autoSync: { enabled: false, time: '06:00' },
 };

+ 49 - 2
server/routes/reports.js

@@ -71,15 +71,62 @@ function register(app, ctx) {
 
   app.post('/api/reports/sync', async ({ req, res }) => {
     const body = await readBody(req);
-    naposService.runSync({
+    const entityPromise = naposService.runSync({
       from: body.from, to: body.to,
       scope: body.scope || 'all',
       chainId: body.chainId || '',
       shopIds: Array.isArray(body.shopIds) ? body.shopIds : undefined,
-    }).catch(() => {});
+    }).catch((e) => {
+      // 同步失败也要让自动推送保持静默,但保留日志
+      ctx.logs.pushSystemLog(`同步结束(失败):${e.message || e}`, 'warn');
+      return null;
+    });
+    // 同步完成后:若启用钉钉 AI 表格,自动把增量推上云端(失败不影响本地同步结果)
+    entityPromise.then(async (snap) => {
+      if (!snap || !snap.entity || !(ctx.config.aitable && ctx.config.aitable.enabled)) return;
+      try {
+        const r = await ctx.aitable.pushLake(snap.entity.key);
+        ctx.logs.pushSystemLog(`AI 表格推送完成:新增 ${r.created} 行、更新 ${r.updated} 行`, 'success');
+      } catch (e) {
+        ctx.logs.pushSystemLog(`AI 表格推送失败:${e.message || e}`, 'error');
+      }
+    });
     return sendJson(res, 200, { ok: true, ...naposService.syncSnapshot() });
   });
 
+  // ---------- 钉钉 AI 表格(在线主档案/共享层) ----------
+
+  app.post('/api/reports/aitable/test', async ({ res }) => {
+    const r = await ctx.aitable.testConnection();
+    sendJson(res, 200, r);
+  });
+
+  app.post('/api/reports/aitable/push', async ({ req, res }) => {
+    const body = await readBody(req);
+    const entity = body.entity || (naposService.store.listEntities()[0] || {}).key;
+    if (!entity) { const e = new Error('暂无数据主体,请先完成一次同步'); e.code = 'AITABLE_NO_ENTITY'; e.status = 400; throw e; }
+    const r = await ctx.aitable.pushLake(entity, { force: !!body.force });
+    ctx.logs.pushSystemLog(`AI 表格推送完成:新增 ${r.created} 行、更新 ${r.updated} 行`, 'success');
+    sendJson(res, 200, r);
+  });
+
+  app.post('/api/reports/aitable/pull', async ({ req, res }) => {
+    const body = await readBody(req);
+    const r = await ctx.aitable.pullLake(body.entity);
+    const accounts = (r.accounts || []).map((a) => `${a.account}(新增${a.added}/更新${a.updated})`).join('、');
+    ctx.logs.pushSystemLog(`AI 表格拉取完成:${r.pulled} 行 ${accounts}`, 'success');
+    sendJson(res, 200, r);
+  });
+
+  app.get('/api/reports/aitable/status', async ({ res, query }) => {
+    const entity = query.entity || (naposService.store.listEntities()[0] || {}).key || '';
+    sendJson(res, 200, {
+      enabled: !!(ctx.config.aitable && ctx.config.aitable.enabled),
+      configured: !!(ctx.config.aitable && ctx.config.aitable.baseId && ctx.config.aitable.sheetName && ctx.config.aitable.appKey),
+      lastPush: entity ? ctx.aitable.lastPushInfo(entity) : null,
+    });
+  });
+
   app.get('/api/reports/sync/status', async ({ res }) => {
     sendJson(res, 200, naposService.syncSnapshot());
   });

+ 17 - 0
server/routes/settings.js

@@ -31,6 +31,11 @@ function register(app, ctx) {
       autoSyncEnabled: !!(ctx.config.autoSync && ctx.config.autoSync.enabled),
       autoSyncTime: (ctx.config.autoSync && ctx.config.autoSync.time) || '06:00',
       accessTokenSet: !!ctx.config.accessToken,
+      aitableEnabled: !!(ctx.config.aitable && ctx.config.aitable.enabled),
+      aitableAppKey: (ctx.config.aitable && ctx.config.aitable.appKey) || '',
+      aitableBaseId: (ctx.config.aitable && ctx.config.aitable.baseId) || '',
+      aitableSheetName: (ctx.config.aitable && ctx.config.aitable.sheetName) || '经营数据',
+      aitableSecretSet: !!(ctx.config.aitable && ctx.config.aitable.appSecret),
     });
   });
 
@@ -82,6 +87,18 @@ 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.aitableSheetName !== undefined || body.aitableAppSecret !== undefined) {
+      if (!config.aitable || typeof config.aitable !== 'object') config.aitable = { enabled: false, appKey: '', appSecret: '', baseId: '', sheetName: '经营数据' };
+      if (body.aitableEnabled !== undefined) config.aitable.enabled = !!body.aitableEnabled;
+      if (body.aitableAppKey !== undefined) config.aitable.appKey = String(body.aitableAppKey).trim();
+      if (body.aitableBaseId !== undefined) config.aitable.baseId = String(body.aitableBaseId).trim();
+      if (body.aitableSheetName !== undefined) config.aitable.sheetName = String(body.aitableSheetName).trim() || '经营数据';
+      if (body.aitableAppSecret !== undefined) {
+        const sec = String(body.aitableAppSecret).trim();
+        if (sec === '-') config.aitable.appSecret = ''; // 约定:输入 - 清除
+        else if (sec) config.aitable.appSecret = sec;
+      }
+    }
     ctx.saveConfig();
     if (body.cookie !== undefined) {
       ctx.session.cookie = body.cookie;

+ 328 - 0
server/services/aitable.js

@@ -0,0 +1,328 @@
+'use strict';
+
+// 钉钉 AI 表格(原多维表)同步服务
+// ----------------------------------------------------------------------------
+// 双层架构的云端层:AI 表格 = 在线主档案 + 多人共享层;本地数据湖 = 计算缓存层。
+//   · pushLake  — 把本地湖的新增/更新行(按 syncedAt 水位)upsert 到 AI 表格
+//   · pullLake  — 把 AI 表格记录拉回本地湖(其他机器补数据用,云端为准)
+// 业务键:日期|账号名|维度|门店ID(与本地 rowKey 语义对齐,幂等可重跑)。
+//
+// 钉钉侧契约(企业内部应用 access_token,域 api.dingtalk.com):
+//   token:  POST /v1.0/oauth2/accessToken  {appKey, appSecret} → {accessToken, expireIn}
+//   记录:   /v1.0/notable/bases/{baseId}/sheets/{sheetName}/records
+//           POST 批量新增 / PUT 批量更新 / GET 分页列表
+// ⚠ 端点形态与批量上限在首次真机联调时核对——所有钉钉细节都收敛在本文件,
+//   如有偏差只需改这里的常量与两个 transport 方法。
+
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+
+const API_BASE = 'https://api.dingtalk.com';
+const TOKEN_PATH = '/v1.0/oauth2/accessToken';
+const RECORDS_PATH = (baseId, sheetName) => `/v1.0/notable/bases/${encodeURIComponent(baseId)}/sheets/${encodeURIComponent(sheetName)}/records`;
+const INSERT_CHUNK = 100;      // 单批新增上限(保守值,真机核对后可调)
+const UPDATE_CHUNK = 100;
+const LIST_PAGE = 100;
+
+// 本地字段 → AI 表格列名(中文列头,人工可读可筛)
+const FIELD_COLUMNS = {
+  date: '日期',
+  account: '账号名',
+  dimension: '维度',
+  shopId: '门店ID',
+  shopName: '门店名称',
+  chainId: '连锁ID',
+  chainName: '连锁名称',
+  validOrderCnt: '有效订单',
+  incomeAmt: '收入(元)',
+  grossAmt: '营业额(元)',
+  expenseAmt: '支出(元)',
+  expUv: '曝光人数',
+  orderUserCnt: '下单人数',
+  clkExpRate: '进店转化率',
+  ordClkRate: '下单转化率',
+  perActualPay: '客单价(元)',
+  actOrdCnt: '活动订单',
+  actSubsidyAmt: '活动补贴(元)',
+  shopRoi: '推广ROI',
+  rebuyRate30d: '30日复购率',
+  bizHours: '营业时长(h)',
+  abnormalCloseHours: '异常关店时长(h)',
+  goodsSaleAmt: '商品销售额(元)',
+};
+const METRIC_FIELDS = ['validOrderCnt', 'incomeAmt', 'grossAmt', 'expenseAmt', 'expUv', 'orderUserCnt',
+  'clkExpRate', 'ordClkRate', 'perActualPay', 'actOrdCnt', 'actSubsidyAmt', 'shopRoi', 'rebuyRate30d',
+  'bizHours', 'abnormalCloseHours', 'goodsSaleAmt'];
+const DIMENSION_LABEL = { company: '公司', chain: '连锁', shop: '门店', summary: '汇总' };
+
+function rowToRecord(row, accountName) {
+  const fields = {
+    [FIELD_COLUMNS.date]: row.date || '',
+    [FIELD_COLUMNS.account]: accountName,
+    [FIELD_COLUMNS.dimension]: DIMENSION_LABEL[row.scope] || row.scope || '',
+    [FIELD_COLUMNS.shopId]: String(row.shopId || ''),
+    [FIELD_COLUMNS.shopName]: row.shopName || '',
+    [FIELD_COLUMNS.chainId]: String(row.chainId || ''),
+    [FIELD_COLUMNS.chainName]: row.chainName || '',
+  };
+  for (const f of METRIC_FIELDS) {
+    const v = row[f];
+    fields[FIELD_COLUMNS[f]] = v == null ? null : Number(v);
+  }
+  return { fields };
+}
+
+function recordKey(fields) {
+  return [fields[FIELD_COLUMNS.date], fields[FIELD_COLUMNS.account], fields[FIELD_COLUMNS.dimension],
+    String(fields[FIELD_COLUMNS.shopId] || '')].join('|');
+}
+
+function recordToRow(record, defaultAccount) {
+  const f = record.fields || {};
+  const byName = (col) => {
+    if (f[col] != null) return f[col];
+    // 兼容按 fieldId 返回的形态:取 record.fields 中无法按名列出的场景交由真机联调修正
+    return null;
+  };
+  const rev = {};
+  for (const [k, col] of Object.entries(FIELD_COLUMNS)) rev[col] = k;
+  const row = {
+    date: String(byName(FIELD_COLUMNS.date) || '').replace(/-/g, ''),
+    scope: Object.entries(DIMENSION_LABEL).find(([, label]) => label === byName(FIELD_COLUMNS.dimension))?.[0] || 'shop',
+    shopId: String(byName(FIELD_COLUMNS.shopId) || ''),
+    shopName: byName(FIELD_COLUMNS.shopName) || '',
+    chainId: String(byName(FIELD_COLUMNS.chainId) || ''),
+    chainName: byName(FIELD_COLUMNS.chainName) || '',
+    account: byName(FIELD_COLUMNS.account) || defaultAccount || '',
+  };
+  for (const f2 of METRIC_FIELDS) {
+    const v = byName(FIELD_COLUMNS[f2]);
+    row[f2] = v == null || v === '' ? 0 : Number(v);
+  }
+  row.syncedAt = new Date().toISOString();
+  return row;
+}
+
+function createAitableService(ctx) {
+  const { config, workspace, logs } = ctx;
+
+  // ---------- token(进程内缓存,提前 60s 过期) ----------
+  let tokenCache = { token: null, expireAt: 0 };
+
+  function transport(method, urlPath, bodyObj, token) {
+    return new Promise((resolve, reject) => {
+      const data = bodyObj !== undefined ? JSON.stringify(bodyObj) : null;
+      const req = https.request({
+        hostname: 'api.dingtalk.com', path: urlPath, method,
+        headers: {
+          'Content-Type': 'application/json',
+          ...(token ? { 'x-acs-dingtalk-access-token': token } : {}),
+          ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
+        },
+        timeout: 30000,
+      }, (res) => {
+        const chunks = [];
+        res.on('data', (c) => chunks.push(c));
+        res.on('end', () => {
+          const raw = Buffer.concat(chunks).toString('utf8');
+          let json = null;
+          try { json = JSON.parse(raw); } catch { /* keep raw */ }
+          resolve({ statusCode: res.statusCode, json, raw: raw.slice(0, 800) });
+        });
+      });
+      req.on('error', reject);
+      req.on('timeout', () => req.destroy(new Error('请求超时')));
+      if (data) req.write(data);
+      req.end();
+    });
+  }
+
+  async function getToken() {
+    const cfg = config.aitable || {};
+    if (!cfg.appKey || !cfg.appSecret) throw err('未配置钉钉应用凭据(appKey/appSecret)', 'AITABLE_NO_CFG');
+    const now = Date.now();
+    if (tokenCache.token && now < tokenCache.expireAt) return tokenCache.token;
+    const res = await transport('POST', TOKEN_PATH, { appKey: cfg.appKey, appSecret: cfg.appSecret });
+    const token = res.json && (res.json.accessToken || (res.json.result && res.json.result.accessToken));
+    const expireIn = Number((res.json && (res.json.expireIn ?? res.json.result?.expireIn)) || 7200);
+    if (res.statusCode !== 200 || !token) {
+      throw err(`获取 access_token 失败 HTTP ${res.statusCode}: ${(res.json && (res.json.message || res.json.errmsg)) || res.raw}`, 'AITABLE_AUTH_FAIL');
+    }
+    tokenCache = { token, expireAt: now + Math.max(60, expireIn - 60) * 1000 };
+    return token;
+  }
+
+  function err(message, code) { const e = new Error(message); e.code = code; e.status = 400; return e; }
+
+  function requireConfig() {
+    const cfg = config.aitable || {};
+    if (!cfg.enabled) throw err('钉钉 AI 表格同步未启用(系统设置中开启)', 'AITABLE_DISABLED');
+    if (!cfg.baseId || !cfg.sheetName || !cfg.appKey) throw err('钉钉 AI 表格配置不完整(AppKey / Base ID / 数据表名称)', 'AITABLE_NO_CFG');
+    return cfg;
+  }
+
+  async function listAllRecords(cfg) {
+    const token = await getToken();
+    const out = [];
+    let nextToken = '';
+    for (;;) {
+      const q = `?maxResults=${LIST_PAGE}${nextToken ? `&nextToken=${encodeURIComponent(nextToken)}` : ''}`;
+      const res = await transport('GET', RECORDS_PATH(cfg.baseId, cfg.sheetName) + q, undefined, token);
+      if (res.statusCode !== 200) throw err(`拉取记录失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_LIST_FAIL');
+      const d = res.json || {};
+      const records = Array.isArray(d.records) ? d.records
+        : Array.isArray(d.value) ? d.value
+        : Array.isArray(d.data) ? d.data : [];
+      out.push(...records);
+      nextToken = d.nextToken || d.nextCursor || '';
+      if (!nextToken) break;
+    }
+    return out;
+  }
+
+  function chunk(arr, n) {
+    const out = [];
+    for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
+    return out;
+  }
+
+  // ---------- 推送:本地湖 → AI 表格 ----------
+
+  const pushStateFile = () => path.join(workspace.getWorkspaceRoot(), '.genpic', 'aitable-push.json');
+
+  function readPushState() {
+    try { return JSON.parse(fs.readFileSync(pushStateFile(), 'utf8')); } catch { return {}; }
+  }
+
+  function writePushState(state) {
+    try {
+      const file = pushStateFile();
+      fs.mkdirSync(path.dirname(file), { recursive: true });
+      const temp = `${file}.${process.pid}.tmp`;
+      fs.writeFileSync(temp, JSON.stringify(state, null, 2), 'utf8');
+      fs.renameSync(temp, file);
+    } catch { /* 状态落盘失败不阻断推送 */ }
+  }
+
+  /** 推送一个主体的数据。force=true 时忽略水位推全量。返回统计。 */
+  async function pushLake(entity, opts = {}) {
+    const cfg = requireConfig();
+    const naposService = require('../../napos-service.js');
+    const key = naposService.store.sanitizeKey(entity);
+    const meta = naposService.store.getEntity(key);
+    const accountName = meta ? meta.name : key;
+
+    const state = readPushState();
+    const watermark = opts.force ? 0 : (state[key] || {}).lastPushedAt || 0;
+    let rows;
+    if (watermark) {
+      const all = naposService.store.loadRows(key) || [];
+      rows = all.filter((r) => new Date(r.syncedAt || 0).getTime() > watermark);
+    } else {
+      rows = naposService.store.loadRows(key) || [];
+    }
+    if (!rows.length) return { pushed: 0, created: 0, updated: 0, skipped: '无新增数据' };
+
+    const token = await getToken();
+    // 现有记录索引:业务键 → recordId(分页拉全量)
+    const existing = new Map();
+    for (const rec of await listAllRecords(cfg)) {
+      const f = rec.fields || {};
+      const k = recordKey({
+        [FIELD_COLUMNS.date]: f[FIELD_COLUMNS.date],
+        [FIELD_COLUMNS.account]: f[FIELD_COLUMNS.account],
+        [FIELD_COLUMNS.dimension]: f[FIELD_COLUMNS.dimension],
+        [FIELD_COLUMNS.shopId]: f[FIELD_COLUMNS.shopId],
+      });
+      if (rec.id) existing.set(k, rec.id);
+    }
+
+    let created = 0;
+    let updated = 0;
+    const toCreate = [];
+    const toUpdate = []; // {recordId, fields}
+    for (const row of rows) {
+      const rec = rowToRecord(row, accountName);
+      const k = recordKey(rec.fields);
+      const rid = existing.get(k);
+      if (rid) toUpdate.push({ record_id: rid, fields: rec.fields });
+      else toCreate.push(rec);
+    }
+    for (const part of chunk(toCreate, INSERT_CHUNK)) {
+      const res = await transport('POST', RECORDS_PATH(cfg.baseId, cfg.sheetName), { records: part }, token);
+      if (res.statusCode >= 300) throw err(`批量新增失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_CREATE_FAIL');
+      created += part.length;
+    }
+    for (const part of chunk(toUpdate, UPDATE_CHUNK)) {
+      const res = await transport('PUT', RECORDS_PATH(cfg.baseId, cfg.sheetName), { records: part }, token);
+      if (res.statusCode >= 300) throw err(`批量更新失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_UPDATE_FAIL');
+      updated += part.length;
+    }
+
+    const now = Date.now();
+    state[key] = { lastPushedAt: now, pushedRows: rows.length, at: new Date().toISOString() };
+    writePushState(state);
+    return { pushed: rows.length, created, updated, account: accountName };
+  }
+
+  // ---------- 拉取:AI 表格 → 本地湖 ----------
+
+  /**
+   * 拉取 AI 表格记录回本地湖(云端为准,本地同键行被覆盖)。
+   * entity 缺省 = 拉取云端全部账号:没有注册过的新账号会自动创建轻量主体
+   * (fresh 机器拉一次即可用报表/图表/问数)。
+   */
+  async function pullLake(entity) {
+    const cfg = requireConfig();
+    const naposService = require('../../napos-service.js');
+    const records = await listAllRecords(cfg);
+    const groups = new Map();
+    const wantName = entity
+      ? ((naposService.store.getEntity(naposService.store.sanitizeKey(entity)) || {}).name || entity)
+      : null;
+    for (const rec of records) {
+      const f = rec.fields || {};
+      const acc = String(f[FIELD_COLUMNS.account] || '').trim();
+      if (!acc) continue;
+      if (wantName && acc !== wantName) continue;
+      if (!groups.has(acc)) groups.set(acc, []);
+      groups.get(acc).push(recordToRow({ fields: f }, acc));
+    }
+    if (!groups.size) return { pulled: 0, accounts: [], message: '云端没有可拉取的数据' };
+
+    const result = { pulled: 0, accounts: [] };
+    for (const [acc, rows] of groups) {
+      let key = naposService.store.sanitizeKey(acc);
+      const meta = naposService.store.getEntity(key);
+      if (!meta || !meta.key) {
+        const created = naposService.store.upsertEntity({ merchantId: acc, merchantName: acc, chains: [], shops: [] });
+        key = created.entity.key;
+      }
+      const res = naposService.store.upsertRows(key, rows);
+      result.pulled += rows.length;
+      result.accounts.push({ account: acc, rows: rows.length, added: res.added, updated: res.updated });
+    }
+    return result;
+  }
+
+  /** 连通性测试:token + 表可读 */
+  async function testConnection() {
+    const cfg = requireConfig();
+    const token = await getToken();
+    const res = await transport('GET', RECORDS_PATH(cfg.baseId, cfg.sheetName) + `?maxResults=1`, undefined, token);
+    if (res.statusCode !== 200) throw err(`表读取失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_TEST_FAIL');
+    return { ok: true, baseId: cfg.baseId, sheetName: cfg.sheetName };
+  }
+
+  function lastPushInfo(entity) {
+    const key = require('../../napos-service.js').store.sanitizeKey(entity);
+    const s = readPushState()[key] || null;
+    return s;
+  }
+
+  logs.pushSystemLog('钉钉 AI 表格同步服务已就绪', 'info');
+  return { pushLake, pullLake, testConnection, lastPushInfo };
+}
+
+module.exports = { createAitableService, FIELD_COLUMNS, DIMENSION_LABEL, rowToRecord, recordKey, recordToRow };