aitable.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. 'use strict';
  2. // 钉钉 AI 表格(原多维表)同步服务
  3. // ----------------------------------------------------------------------------
  4. // 双层架构的云端层:AI 表格 = 在线主档案 + 多人共享层;本地数据湖 = 计算缓存层。
  5. // · pushLake — 把本地湖的新增/更新行(按 syncedAt 水位)upsert 到 AI 表格
  6. // · pullLake — 把 AI 表格记录拉回本地湖(其他机器补数据用,云端为准)
  7. // 业务键:日期|账号名|维度|门店ID(与本地 rowKey 语义对齐,幂等可重跑)。
  8. //
  9. // 钉钉侧契约(企业内部应用 access_token,域 api.dingtalk.com;2026-10-17 真机核对):
  10. // token: POST /v1.0/oauth2/accessToken {appKey, appSecret} → {accessToken, expireIn}
  11. // 列记录: GET …/records?operatorId&maxResults&nextToken → {records:[{id,fields}],nextToken,hasMore}
  12. // 新增: POST …/records {records:[{fields}]} → {value:[{id}]}(响应键是 value)
  13. // 更新: PUT …/records {records:[{id,fields}]}(记录标识键是 id,不是 record_id)
  14. // 删除: POST …/records/delete {recordIds:[…]}(同步语义只 upsert,用不到)
  15. // · 所有 notable 接口 query 必带 operatorId=操作人 unionId(scripts/aitable-discover.js 发现)
  16. // · 数值字段读回是字符串("12345.67"),recordToRow 已做 Number() 归一
  17. // · number 列 formatter=INT 只影响显示,存储小数保真
  18. // ⚠ 批量上限(100)是保守值,首推大批量时如遇限流再调。
  19. const fs = require('fs');
  20. const path = require('path');
  21. const https = require('https');
  22. const API_BASE = 'https://api.dingtalk.com';
  23. const TOKEN_PATH = '/v1.0/oauth2/accessToken';
  24. const RECORDS_PATH = (baseId, sheetName) => `/v1.0/notable/bases/${encodeURIComponent(baseId)}/sheets/${encodeURIComponent(sheetName)}/records`;
  25. const INSERT_CHUNK = 100; // 单批新增上限(保守值,真机核对后可调)
  26. const UPDATE_CHUNK = 100;
  27. const LIST_PAGE = 100;
  28. // 本地字段 → AI 表格列名(中文列头,人工可读可筛)
  29. const FIELD_COLUMNS = {
  30. date: '日期',
  31. account: '账号名',
  32. dimension: '维度',
  33. shopId: '门店ID',
  34. shopName: '门店名称',
  35. chainId: '连锁ID',
  36. chainName: '连锁名称',
  37. validOrderCnt: '有效订单',
  38. incomeAmt: '收入(元)',
  39. grossAmt: '营业额(元)',
  40. expenseAmt: '支出(元)',
  41. expUv: '曝光人数',
  42. orderUserCnt: '下单人数',
  43. clkExpRate: '进店转化率',
  44. ordClkRate: '下单转化率',
  45. perActualPay: '客单价(元)',
  46. actOrdCnt: '活动订单',
  47. actSubsidyAmt: '活动补贴(元)',
  48. shopRoi: '推广ROI',
  49. rebuyRate30d: '30日复购率',
  50. bizHours: '营业时长(h)',
  51. abnormalCloseHours: '异常关店时长(h)',
  52. goodsSaleAmt: '商品销售额(元)',
  53. };
  54. const METRIC_FIELDS = ['validOrderCnt', 'incomeAmt', 'grossAmt', 'expenseAmt', 'expUv', 'orderUserCnt',
  55. 'clkExpRate', 'ordClkRate', 'perActualPay', 'actOrdCnt', 'actSubsidyAmt', 'shopRoi', 'rebuyRate30d',
  56. 'bizHours', 'abnormalCloseHours', 'goodsSaleAmt'];
  57. const DIMENSION_LABEL = { company: '公司', chain: '连锁', shop: '门店', summary: '汇总' };
  58. function rowToRecord(row, accountName) {
  59. const fields = {
  60. [FIELD_COLUMNS.date]: row.date || '',
  61. [FIELD_COLUMNS.account]: accountName,
  62. [FIELD_COLUMNS.dimension]: DIMENSION_LABEL[row.scope] || row.scope || '',
  63. [FIELD_COLUMNS.shopId]: String(row.shopId || ''),
  64. [FIELD_COLUMNS.shopName]: row.shopName || '',
  65. [FIELD_COLUMNS.chainId]: String(row.chainId || ''),
  66. [FIELD_COLUMNS.chainName]: row.chainName || '',
  67. };
  68. for (const f of METRIC_FIELDS) {
  69. const v = row[f];
  70. fields[FIELD_COLUMNS[f]] = v == null ? null : Number(v);
  71. }
  72. return { fields };
  73. }
  74. function recordKey(fields) {
  75. return [fields[FIELD_COLUMNS.date], fields[FIELD_COLUMNS.account], fields[FIELD_COLUMNS.dimension],
  76. String(fields[FIELD_COLUMNS.shopId] || '')].join('|');
  77. }
  78. function recordToRow(record, defaultAccount) {
  79. const f = record.fields || {};
  80. const byName = (col) => {
  81. if (f[col] != null) return f[col];
  82. // 兼容按 fieldId 返回的形态:取 record.fields 中无法按名列出的场景交由真机联调修正
  83. return null;
  84. };
  85. const rev = {};
  86. for (const [k, col] of Object.entries(FIELD_COLUMNS)) rev[col] = k;
  87. const row = {
  88. date: String(byName(FIELD_COLUMNS.date) || '').replace(/-/g, ''),
  89. scope: Object.entries(DIMENSION_LABEL).find(([, label]) => label === byName(FIELD_COLUMNS.dimension))?.[0] || 'shop',
  90. shopId: String(byName(FIELD_COLUMNS.shopId) || ''),
  91. shopName: byName(FIELD_COLUMNS.shopName) || '',
  92. chainId: String(byName(FIELD_COLUMNS.chainId) || ''),
  93. chainName: byName(FIELD_COLUMNS.chainName) || '',
  94. account: byName(FIELD_COLUMNS.account) || defaultAccount || '',
  95. };
  96. for (const f2 of METRIC_FIELDS) {
  97. const v = byName(FIELD_COLUMNS[f2]);
  98. row[f2] = v == null || v === '' ? 0 : Number(v);
  99. }
  100. row.syncedAt = new Date().toISOString();
  101. return row;
  102. }
  103. function createAitableService(ctx) {
  104. const { config, workspace, logs } = ctx;
  105. // ---------- token(进程内缓存,提前 60s 过期) ----------
  106. let tokenCache = { token: null, expireAt: 0 };
  107. function transport(method, urlPath, bodyObj, token) {
  108. return new Promise((resolve, reject) => {
  109. const data = bodyObj !== undefined ? JSON.stringify(bodyObj) : null;
  110. const req = https.request({
  111. hostname: 'api.dingtalk.com', path: urlPath, method,
  112. headers: {
  113. 'Content-Type': 'application/json',
  114. ...(token ? { 'x-acs-dingtalk-access-token': token } : {}),
  115. ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
  116. },
  117. timeout: 30000,
  118. }, (res) => {
  119. const chunks = [];
  120. res.on('data', (c) => chunks.push(c));
  121. res.on('end', () => {
  122. const raw = Buffer.concat(chunks).toString('utf8');
  123. let json = null;
  124. try { json = JSON.parse(raw); } catch { /* keep raw */ }
  125. resolve({ statusCode: res.statusCode, json, raw: raw.slice(0, 800) });
  126. });
  127. });
  128. req.on('error', reject);
  129. req.on('timeout', () => req.destroy(new Error('请求超时')));
  130. if (data) req.write(data);
  131. req.end();
  132. });
  133. }
  134. async function getToken() {
  135. const cfg = config.aitable || {};
  136. if (!cfg.appKey || !cfg.appSecret) throw err('未配置钉钉应用凭据(appKey/appSecret)', 'AITABLE_NO_CFG');
  137. const now = Date.now();
  138. if (tokenCache.token && now < tokenCache.expireAt) return tokenCache.token;
  139. const res = await transport('POST', TOKEN_PATH, { appKey: cfg.appKey, appSecret: cfg.appSecret });
  140. const token = res.json && (res.json.accessToken || (res.json.result && res.json.result.accessToken));
  141. const expireIn = Number((res.json && (res.json.expireIn ?? res.json.result?.expireIn)) || 7200);
  142. if (res.statusCode !== 200 || !token) {
  143. throw err(`获取 access_token 失败 HTTP ${res.statusCode}: ${(res.json && (res.json.message || res.json.errmsg)) || res.raw}`, 'AITABLE_AUTH_FAIL');
  144. }
  145. tokenCache = { token, expireAt: now + Math.max(60, expireIn - 60) * 1000 };
  146. return token;
  147. }
  148. function err(message, code) { const e = new Error(message); e.code = code; e.status = 400; return e; }
  149. // notable 接口统一拼 query:业务参数 + 必填 operatorId
  150. function notableQuery(cfg, params = {}) {
  151. const q = new URLSearchParams({ operatorId: cfg.operatorId || '', ...params });
  152. return `?${q.toString()}`;
  153. }
  154. function requireConfig() {
  155. const cfg = config.aitable || {};
  156. if (!cfg.enabled) throw err('钉钉 AI 表格同步未启用(系统设置中开启)', 'AITABLE_DISABLED');
  157. if (!cfg.baseId || !cfg.sheetName || !cfg.appKey) throw err('钉钉 AI 表格配置不完整(AppKey / Base ID / 数据表名称)', 'AITABLE_NO_CFG');
  158. if (!cfg.operatorId) throw err('缺少操作者 unionId:先在钉钉开放平台给应用开通通讯录读权限,再运行 node scripts/aitable-discover.js 发现并填入', 'AITABLE_NO_OPERATOR');
  159. return cfg;
  160. }
  161. async function listAllRecords(cfg) {
  162. const token = await getToken();
  163. const out = [];
  164. let nextToken = '';
  165. for (;;) {
  166. const q = notableQuery(cfg, { maxResults: LIST_PAGE, ...(nextToken ? { nextToken } : {}) });
  167. const res = await transport('GET', RECORDS_PATH(cfg.baseId, cfg.sheetName) + q, undefined, token);
  168. if (res.statusCode !== 200) throw err(`拉取记录失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_LIST_FAIL');
  169. const d = res.json || {};
  170. const records = Array.isArray(d.records) ? d.records
  171. : Array.isArray(d.value) ? d.value
  172. : Array.isArray(d.data) ? d.data : [];
  173. out.push(...records);
  174. nextToken = d.nextToken || d.nextCursor || '';
  175. if (!nextToken) break;
  176. }
  177. return out;
  178. }
  179. function chunk(arr, n) {
  180. const out = [];
  181. for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
  182. return out;
  183. }
  184. // ---------- 推送:本地湖 → AI 表格 ----------
  185. const pushStateFile = () => path.join(workspace.getWorkspaceRoot(), '.genpic', 'aitable-push.json');
  186. function readPushState() {
  187. try { return JSON.parse(fs.readFileSync(pushStateFile(), 'utf8')); } catch { return {}; }
  188. }
  189. function writePushState(state) {
  190. try {
  191. const file = pushStateFile();
  192. fs.mkdirSync(path.dirname(file), { recursive: true });
  193. const temp = `${file}.${process.pid}.tmp`;
  194. fs.writeFileSync(temp, JSON.stringify(state, null, 2), 'utf8');
  195. fs.renameSync(temp, file);
  196. } catch { /* 状态落盘失败不阻断推送 */ }
  197. }
  198. /** 推送一个主体的数据。force=true 时忽略水位推全量、并跳过冲突/全零保护。
  199. * 冲突策略:同键行「数据新者胜」——云端记录的 lastModifiedTime 比 本地行 syncedAt
  200. * (取数时间)新则跳过(skippedStale),避免另一台机器后推的旧数据盖掉云端新数据。 */
  201. async function pushLake(entity, opts = {}) {
  202. const cfg = requireConfig();
  203. const naposService = require('../../napos-service.js');
  204. const key = naposService.store.sanitizeKey(entity);
  205. const meta = naposService.store.getEntity(key);
  206. const accountName = meta ? meta.name : key;
  207. const state = readPushState();
  208. const watermark = opts.force ? 0 : (state[key] || {}).lastPushedAt || 0;
  209. let rows;
  210. if (watermark) {
  211. const all = naposService.store.loadRows(key) || [];
  212. rows = all.filter((r) => new Date(r.syncedAt || 0).getTime() > watermark);
  213. } else {
  214. rows = naposService.store.loadRows(key) || [];
  215. }
  216. if (!rows.length) return { pushed: 0, created: 0, updated: 0, skipped: '无新增数据' };
  217. // 全零保护:大批量行指标全零基本可断定取数异常(已知门店级 bug 的症状),
  218. // 防止把坏数据灌进云端主档案;确认要推(force)可绕过。
  219. const zeroRows = rows.filter((r) => METRIC_FIELDS.every((f) => !Number(r[f]))).length;
  220. if (!opts.force && rows.length >= 20 && zeroRows / rows.length > 0.8) {
  221. throw err(`本批 ${rows.length} 行中 ${zeroRows} 行指标全零,疑似取数异常,已拦截推送(确认无误可在数据中台强制推送)`, 'AITABLE_ALL_ZERO');
  222. }
  223. const token = await getToken();
  224. // 现有记录索引:业务键 → {recordId, mtime}(分页拉全量)
  225. const existing = new Map();
  226. for (const rec of await listAllRecords(cfg)) {
  227. const f = rec.fields || {};
  228. const k = recordKey({
  229. [FIELD_COLUMNS.date]: f[FIELD_COLUMNS.date],
  230. [FIELD_COLUMNS.account]: f[FIELD_COLUMNS.account],
  231. [FIELD_COLUMNS.dimension]: f[FIELD_COLUMNS.dimension],
  232. [FIELD_COLUMNS.shopId]: f[FIELD_COLUMNS.shopId],
  233. });
  234. if (rec.id) existing.set(k, { id: rec.id, mtime: Number(rec.lastModifiedTime) || 0 });
  235. }
  236. let created = 0;
  237. let updated = 0;
  238. let skippedStale = 0;
  239. const toCreate = [];
  240. const toUpdate = []; // {id, fields}(更新接口的记录标识键是 id,不是 record_id)
  241. for (const row of rows) {
  242. const rec = rowToRecord(row, accountName);
  243. const k = recordKey(rec.fields);
  244. const hit = existing.get(k);
  245. if (hit) {
  246. const localAt = new Date(row.syncedAt || 0).getTime();
  247. if (!opts.force && hit.mtime && localAt && hit.mtime > localAt) { skippedStale += 1; continue; }
  248. toUpdate.push({ id: hit.id, fields: rec.fields });
  249. } else {
  250. toCreate.push(rec);
  251. }
  252. }
  253. for (const part of chunk(toCreate, INSERT_CHUNK)) {
  254. const res = await transport('POST', RECORDS_PATH(cfg.baseId, cfg.sheetName) + notableQuery(cfg), { records: part }, token);
  255. if (res.statusCode >= 300) throw err(`批量新增失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_CREATE_FAIL');
  256. created += part.length;
  257. }
  258. for (const part of chunk(toUpdate, UPDATE_CHUNK)) {
  259. const res = await transport('PUT', RECORDS_PATH(cfg.baseId, cfg.sheetName) + notableQuery(cfg), { records: part }, token);
  260. if (res.statusCode >= 300) throw err(`批量更新失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_UPDATE_FAIL');
  261. updated += part.length;
  262. }
  263. const now = Date.now();
  264. state[key] = { lastPushedAt: now, pushedRows: rows.length, at: new Date().toISOString() };
  265. writePushState(state);
  266. return { pushed: rows.length, created, updated, skippedStale, account: accountName };
  267. }
  268. // ---------- 拉取:AI 表格 → 本地湖 ----------
  269. /**
  270. * 拉取 AI 表格记录回本地湖(云端为准,本地同键行被覆盖)。
  271. * entity 缺省 = 拉取云端全部账号:没有注册过的新账号会自动创建轻量主体
  272. * (fresh 机器拉一次即可用报表/图表/问数)。
  273. */
  274. async function pullLake(entity) {
  275. const cfg = requireConfig();
  276. const naposService = require('../../napos-service.js');
  277. const records = await listAllRecords(cfg);
  278. const groups = new Map();
  279. const wantName = entity
  280. ? ((naposService.store.getEntity(naposService.store.sanitizeKey(entity)) || {}).name || entity)
  281. : null;
  282. for (const rec of records) {
  283. const f = rec.fields || {};
  284. const acc = String(f[FIELD_COLUMNS.account] || '').trim();
  285. if (!acc) continue;
  286. if (wantName && acc !== wantName) continue;
  287. if (!groups.has(acc)) groups.set(acc, []);
  288. groups.get(acc).push(recordToRow({ fields: f }, acc));
  289. }
  290. if (!groups.size) return { pulled: 0, accounts: [], message: '云端没有可拉取的数据' };
  291. const result = { pulled: 0, accounts: [] };
  292. for (const [acc, rows] of groups) {
  293. let key = naposService.store.sanitizeKey(acc);
  294. const meta = naposService.store.getEntity(key);
  295. if (!meta || !meta.key) {
  296. const created = naposService.store.upsertEntity({ merchantId: acc, merchantName: acc, chains: [], shops: [] });
  297. key = created.entity.key;
  298. }
  299. const res = naposService.store.upsertRows(key, rows);
  300. result.pulled += rows.length;
  301. result.accounts.push({ account: acc, rows: rows.length, added: res.added, updated: res.updated });
  302. }
  303. return result;
  304. }
  305. /** 连通性测试:token + 操作者身份 + 表可读 */
  306. async function testConnection() {
  307. const cfg = requireConfig();
  308. const token = await getToken();
  309. const res = await transport('GET', RECORDS_PATH(cfg.baseId, cfg.sheetName) + notableQuery(cfg, { maxResults: 1 }), undefined, token);
  310. if (res.statusCode !== 200) {
  311. const hint = res.statusCode === 403 || res.statusCode === 404
  312. ? '——请确认该 AI 表格已分享给操作者账号(钉钉里把表格协作权限授给对应同事)'
  313. : '';
  314. throw err(`表读取失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}${hint}`, 'AITABLE_TEST_FAIL');
  315. }
  316. return { ok: true, baseId: cfg.baseId, sheetName: cfg.sheetName, operatorId: cfg.operatorId };
  317. }
  318. function lastPushInfo(entity) {
  319. const key = require('../../napos-service.js').store.sanitizeKey(entity);
  320. const s = readPushState()[key] || null;
  321. return s;
  322. }
  323. /** 待推送行数:水位之后有变化(syncedAt 更新)的行;配置不齐返回 null(调用方显示配置态) */
  324. function pendingCount(entity) {
  325. const cfg = config.aitable || {};
  326. if (!cfg.enabled || !cfg.baseId || !cfg.sheetName || !cfg.appKey || !cfg.operatorId) return null;
  327. const naposService = require('../../napos-service.js');
  328. const key = naposService.store.sanitizeKey(entity);
  329. const watermark = (readPushState()[key] || {}).lastPushedAt || 0;
  330. const all = naposService.store.loadRows(key) || [];
  331. if (!watermark) return all.length;
  332. return all.filter((r) => new Date(r.syncedAt || 0).getTime() > watermark).length;
  333. }
  334. logs.pushSystemLog('钉钉 AI 表格同步服务已就绪', 'info');
  335. return { pushLake, pullLake, testConnection, lastPushInfo, pendingCount };
  336. }
  337. module.exports = { createAitableService, FIELD_COLUMNS, DIMENSION_LABEL, rowToRecord, recordKey, recordToRow };