Browse Source

feat: operatorId (unionId) support for DingTalk notable API + discover script

KenZ1117 2 tuần trước cách đây
mục cha
commit
4f893bce54
4 tập tin đã thay đổi với 150 bổ sung và 10 xóa
  1. 118 0
      scripts/aitable-discover.js
  2. 2 1
      server/context.js
  3. 8 2
      server/routes/settings.js
  4. 22 7
      server/services/aitable.js

+ 118 - 0
scripts/aitable-discover.js

@@ -0,0 +1,118 @@
+'use strict';
+
+// 钉钉 AI 表格「操作者发现」脚本
+// ----------------------------------------------------------------------------
+// 背景:AI 表格(notable)所有接口必须在 query 上带 operatorId=操作人 unionId。
+//       企业应用的 token 推不出用户身份,unionId 要靠通讯录接口转换。
+// 前置:在钉钉开放平台给应用开通这几个权限点(第一次跑报错会给出精确的申请链接,
+//       点链接 → 开通 → 重跑本脚本即可):
+//       · 通讯录部门信息读权限 qyapi_get_department_list
+//       · 通讯录部门成员读权限 qyapi_get_department_member
+//       · 通讯录个人信息读权限(v2/user/get 取 unionId 用)
+// 用法:node scripts/aitable-discover.js
+// 输出:每个成员的 unionId + 谁能读 config.json 里配置的 Base(★ 命中者即为操作者),
+//       把命中的 unionId 填入 config.json → aitable.operatorId。
+
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+
+const ROOT = path.join(__dirname, '..');
+const cfg = (() => {
+  const raw = JSON.parse(fs.readFileSync(path.join(ROOT, 'config.json'), 'utf8'));
+  const a = raw.aitable || {};
+  if (!a.appKey || !a.appSecret || !a.baseId) {
+    console.error('✗ config.json 缺 aitable.appKey / appSecret / baseId');
+    process.exit(1);
+  }
+  return a;
+})();
+
+function call(url, { method = 'GET', headers = {}, body } = {}) {
+  return new Promise((resolve, reject) => {
+    const data = body ? JSON.stringify(body) : null;
+    const h = { ...headers };
+    if (data) { h['Content-Type'] = 'application/json'; h['Content-Length'] = Buffer.byteLength(data); }
+    const req = https.request(url, { method, headers: h, timeout: 20000 }, (res) => {
+      let buf = '';
+      res.on('data', (c) => { buf += c; });
+      res.on('end', () => {
+        let json = null;
+        try { json = JSON.parse(buf); } catch { /* 保留原文 */ }
+        resolve({ status: res.statusCode, json });
+      });
+    });
+    req.on('error', reject);
+    req.on('timeout', () => req.destroy(new Error('请求超时')));
+    if (data) req.write(data);
+    req.end();
+  });
+}
+
+function oldApi(pathQ, token, body) {
+  return call(`https://oapi.dingtalk.com${pathQ}${pathQ.includes('?') ? '&' : '?'}access_token=${token}`, { method: 'POST', body });
+}
+
+function permissionHint(json) {
+  const m = json && typeof json.sub_msg === 'string' ? json.sub_msg : '';
+  const link = /https:\S+/.exec(m);
+  return link ? `\n  → 点这个链接开通权限后重跑:${link[0]}` : '';
+}
+
+(async () => {
+  console.log('⊙ 前置权限:请先在钉钉开放平台给应用开通以下三个权限点(点击链接 → 申请开通):');
+  for (const scope of ['qyapi_get_department_list', 'qyapi_get_department_member', 'qyapi_get_member_detail']) {
+    console.log(`  · ${scope}`);
+    console.log(`    https://open-dev.dingtalk.com/appscope/apply?content=${encodeURIComponent(cfg.appKey)}%23${scope}`);
+  }
+  console.log('');
+
+  const t = await call('https://api.dingtalk.com/v1.0/oauth2/accessToken', { method: 'POST', body: { appKey: cfg.appKey, appSecret: cfg.appSecret } });
+  if (t.status !== 200 || !t.json.accessToken) { console.error('✗ 获取 token 失败', t.json); process.exit(1); }
+  const T = t.json.accessToken;
+  console.log('✓ token OK');
+
+  // 1) 部门:根部门 + 子部门
+  const depts = [1];
+  const r1 = await oldApi('/topapi/v2/department/listsub', T, { dept_id: 1 });
+  if (r1.json.errcode !== 0) { console.error(`✗ 拉部门失败 errcode=${r1.json.errcode}${permissionHint(r1.json)}`, r1.json.errmsg || ''); process.exit(1); }
+  for (const d of r1.json.result || []) depts.push(d.dept_id);
+  console.log(`✓ 部门 ${depts.length} 个`);
+
+  // 2) 成员
+  const userIds = [];
+  for (const d of depts) {
+    const r = await oldApi('/topapi/v2/user/list', T, { dept_id: d, cursor: 0, size: 50 });
+    if (r.json.errcode !== 0) { console.error(`✗ 拉成员失败 dept=${d} errcode=${r.json.errcode}${permissionHint(r.json)}`); process.exit(1); }
+    for (const u of (r.json.result && r.json.result.list) || []) if (!userIds.includes(u.userid)) userIds.push(u.userid);
+  }
+  console.log(`✓ 成员 ${userIds.length} 人`);
+
+  // 3) unionId
+  const cands = [];
+  for (const uid of userIds) {
+    const r = await oldApi('/topapi/v2/user/get', T, { userid: uid });
+    if (r.json.errcode !== 0) { console.error(`✗ 用户详情失败 ${uid} errcode=${r.json.errcode}${permissionHint(r.json)}`); process.exit(1); }
+    const u = r.json.result || {};
+    cands.push({ name: u.name, userid: uid, unionid: u.unionid, admin: !!u.admin });
+  }
+
+  // 4) 谁能读这个 Base
+  let hit = null;
+  for (const c of cands) {
+    const res = await call(`https://api.dingtalk.com/v1.0/notable/bases/${encodeURIComponent(cfg.baseId)}/sheets?operatorId=${encodeURIComponent(c.unionid)}`,
+      { headers: { 'x-acs-dingtalk-access-token': T } });
+    const ok = res.status === 200;
+    const sheets = ok && Array.isArray(res.json.value) ? res.json.value.length : (ok ? '?' : '');
+    console.log(`  ${ok ? '★' : '×'} ${c.name}${c.admin ? ' [管理员]' : ''} unionid=${c.unionid} 读Base=${ok}${ok ? ` 数据表数=${sheets}` : ` HTTP ${res.status}`}`);
+    if (ok && !hit) hit = c;
+  }
+
+  if (hit) {
+    console.log(`\n★ 操作者建议:${hit.name}  unionid=${hit.unionid}`);
+    console.log(`  写入方法:config.json → "aitable" 增加/修改 "operatorId": "${hit.unionid}"`);
+    if (hit.admin) console.log('  (该账号是管理员,推荐用管理员身份做系统级推送)');
+  } else {
+    console.log('\n✗ 没有人能读这个 Base:请确认 baseId 正确、且 AI 表格已分享给上述某个成员');
+  }
+})().catch((e) => { console.error('FATAL', e.message); process.exit(1); });

+ 2 - 1
server/context.js

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

+ 8 - 2
server/routes/settings.js

@@ -36,6 +36,7 @@ function register(app, ctx) {
       aitableBaseId: (ctx.config.aitable && ctx.config.aitable.baseId) || '',
       aitableSheetName: (ctx.config.aitable && ctx.config.aitable.sheetName) || '经营数据',
       aitableSecretSet: !!(ctx.config.aitable && ctx.config.aitable.appSecret),
+      aitableOperatorSet: !!(ctx.config.aitable && ctx.config.aitable.operatorId),
     });
   });
 
@@ -87,8 +88,8 @@ 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 || body.aitableAppKey !== undefined || body.aitableBaseId !== undefined || body.aitableSheetName !== undefined || body.aitableAppSecret !== undefined || body.aitableOperatorId !== undefined) {
+      if (!config.aitable || typeof config.aitable !== 'object') config.aitable = { enabled: false, appKey: '', appSecret: '', baseId: '', sheetName: '经营数据', operatorId: '' };
       if (body.aitableEnabled !== undefined) config.aitable.enabled = !!body.aitableEnabled;
       if (body.aitableAppKey !== undefined) config.aitable.appKey = String(body.aitableAppKey).trim();
       if (body.aitableBaseId !== undefined) {
@@ -103,6 +104,11 @@ function register(app, ctx) {
         if (sec === '-') config.aitable.appSecret = ''; // 约定:输入 - 清除
         else if (sec) config.aitable.appSecret = sec;
       }
+      if (body.aitableOperatorId !== undefined) {
+        const op = String(body.aitableOperatorId).trim();
+        if (op === '-') config.aitable.operatorId = '';
+        else if (op) config.aitable.operatorId = op;
+      }
     }
     ctx.saveConfig();
     if (body.cookie !== undefined) {

+ 22 - 7
server/services/aitable.js

@@ -11,6 +11,9 @@
 //   token:  POST /v1.0/oauth2/accessToken  {appKey, appSecret} → {accessToken, expireIn}
 //   记录:   /v1.0/notable/bases/{baseId}/sheets/{sheetName}/records
 //           POST 批量新增 / PUT 批量更新 / GET 分页列表
+//   ⚠ 所有 notable 接口必须在 query 上带 operatorId=操作人 unionId(企业内部应用
+//     无法从 token 推出用户身份;unionId 用 scripts/aitable-discover.js 发现一次后
+//     配到 config 的 aitable.operatorId,同一组织内固定不变)。
 // ⚠ 端点形态与批量上限在首次真机联调时核对——所有钉钉细节都收敛在本文件,
 //   如有偏差只需改这里的常量与两个 transport 方法。
 
@@ -155,10 +158,17 @@ function createAitableService(ctx) {
 
   function err(message, code) { const e = new Error(message); e.code = code; e.status = 400; return e; }
 
+  // notable 接口统一拼 query:业务参数 + 必填 operatorId
+  function notableQuery(cfg, params = {}) {
+    const q = new URLSearchParams({ operatorId: cfg.operatorId || '', ...params });
+    return `?${q.toString()}`;
+  }
+
   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');
+    if (!cfg.operatorId) throw err('缺少操作者 unionId:先在钉钉开放平台给应用开通通讯录读权限,再运行 node scripts/aitable-discover.js 发现并填入', 'AITABLE_NO_OPERATOR');
     return cfg;
   }
 
@@ -167,7 +177,7 @@ function createAitableService(ctx) {
     const out = [];
     let nextToken = '';
     for (;;) {
-      const q = `?maxResults=${LIST_PAGE}${nextToken ? `&nextToken=${encodeURIComponent(nextToken)}` : ''}`;
+      const q = notableQuery(cfg, { maxResults: LIST_PAGE, ...(nextToken ? { 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 || {};
@@ -250,12 +260,12 @@ function createAitableService(ctx) {
       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);
+      const res = await transport('POST', RECORDS_PATH(cfg.baseId, cfg.sheetName) + notableQuery(cfg), { 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);
+      const res = await transport('PUT', RECORDS_PATH(cfg.baseId, cfg.sheetName) + notableQuery(cfg), { 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;
     }
@@ -306,13 +316,18 @@ function createAitableService(ctx) {
     return result;
   }
 
-  /** 连通性测试:token + 表可读 */
+  /** 连通性测试: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 };
+    const res = await transport('GET', RECORDS_PATH(cfg.baseId, cfg.sheetName) + notableQuery(cfg, { maxResults: 1 }), undefined, token);
+    if (res.statusCode !== 200) {
+      const hint = res.statusCode === 403 || res.statusCode === 404
+        ? '——请确认该 AI 表格已分享给操作者账号(钉钉里把表格协作权限授给对应同事)'
+        : '';
+      throw err(`表读取失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}${hint}`, 'AITABLE_TEST_FAIL');
+    }
+    return { ok: true, baseId: cfg.baseId, sheetName: cfg.sheetName, operatorId: cfg.operatorId };
   }
 
   function lastPushInfo(entity) {