fmode-admin.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. #!/usr/bin/env node
  2. /**
  3. * Fmode 容器接入管理台
  4. *
  5. * 面向:**公司管理员**。用于把新用户绑定到容器、把容器移交给别人、把容器换到公司下。
  6. * 不做:用户自己切换组织(该能力由 App 侧 enterprise/switch 提供,本工具不碰)。
  7. *
  8. * 子命令:
  9. * node scripts/fmode-admin.mjs users <手机号|userId> 查用户(含 Profile 成员关系)
  10. * node scripts/fmode-admin.mjs agent <agentId> 查容器全貌(归属/ACL/绑定/房间)
  11. * node scripts/fmode-admin.mjs audit 列出本公司全部容器与绑定
  12. * node scripts/fmode-admin.mjs bind <agentId> --to <userId> 绑给指定用户(新增一条)
  13. * node scripts/fmode-admin.mjs transfer <agentId> --to <userId> 移交(改归属+清旧绑定+重开)
  14. *
  15. * 凭据:读 `~/.fmode/config/user.json` 的 sessionToken(管理员本人)。
  16. *
  17. * ⚠️ 写操作(bind/transfer)默认 **dry-run**,必须加 --yes 才真正执行。
  18. *
  19. * 依据:《docs/03-interface/容器接入App-操作手册/92-运维答复-落地设计.md》
  20. */
  21. import { readFileSync } from 'node:fs';
  22. import { createHash } from 'node:crypto';
  23. import { homedir } from 'node:os';
  24. import { join } from 'node:path';
  25. const FUNCTIONS_URL = 'https://server.fmode.cn/api/functions';
  26. const PARSE_URL = 'https://server.fmode.cn/parse';
  27. const MATRIX_HS = 'https://matrix.fmode.cn';
  28. const APP_ID = 'ncloudmaster';
  29. const FN_STATUS = 'xlosi3m46q';
  30. const FN_PROVISION = 'RP5Ht55NeX';
  31. // ---------- 基础 ----------
  32. function loadToken() {
  33. const p = join(homedir(), '.fmode', 'config', 'user.json');
  34. try {
  35. const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken;
  36. if (typeof t === 'string' && t.startsWith('r:')) return t;
  37. } catch {
  38. /* fallthrough */
  39. }
  40. fail(`读不到平台 sessionToken(期望 ${p} 的 sessionToken 且以 "r:" 开头)`, 2);
  41. }
  42. const TOKEN = loadToken();
  43. const ARGS = process.argv.slice(2);
  44. const CMD = ARGS[0];
  45. const DRY = !ARGS.includes('--yes');
  46. const ok = (s) => `✅ ${s}`;
  47. const bad = (s) => `❌ ${s}`;
  48. const warn = (s) => `⚠️ ${s}`;
  49. const info = (s) => ` ${s}`;
  50. function fail(msg, code = 1) {
  51. console.error(`❌ ${msg}`);
  52. process.exit(code);
  53. }
  54. function opt(name) {
  55. const i = ARGS.indexOf(`--${name}`);
  56. return i >= 0 ? ARGS[i + 1] : undefined;
  57. }
  58. // ---------- 平台调用 ----------
  59. async function callFn(id, params) {
  60. const res = await fetch(FUNCTIONS_URL, {
  61. method: 'POST',
  62. headers: { 'Content-Type': 'application/json' },
  63. body: JSON.stringify({ id, token: TOKEN, params }),
  64. });
  65. const t = await res.text();
  66. try {
  67. return JSON.parse(t);
  68. } catch {
  69. return { code: res.status, mess: t.slice(0, 200) };
  70. }
  71. }
  72. async function parseReq(method, path, body) {
  73. const res = await fetch(`${PARSE_URL}${path}`, {
  74. method,
  75. headers: {
  76. 'X-Parse-Application-Id': APP_ID,
  77. 'X-Parse-Session-Token': TOKEN,
  78. 'Content-Type': 'application/json',
  79. },
  80. body: body ? JSON.stringify(body) : undefined,
  81. });
  82. const t = await res.text();
  83. try {
  84. return { ...JSON.parse(t), _status: res.status };
  85. } catch {
  86. return { _status: res.status, error: t.slice(0, 200) };
  87. }
  88. }
  89. const q = (where, extra = '') =>
  90. parseReq('GET', `/classes/FmodeAgent?where=${encodeURIComponent(JSON.stringify(where))}${extra}`);
  91. const j = (o) => JSON.stringify(o);
  92. // ---------- 数据读取 ----------
  93. function parseJsonField(v) {
  94. if (!v) return {};
  95. if (typeof v === 'object') return v;
  96. try {
  97. return JSON.parse(v) ?? {};
  98. } catch {
  99. return {};
  100. }
  101. }
  102. async function findUser(key) {
  103. // key 可以是 objectId 或手机号
  104. const where = /^[A-Za-z0-9]{10}$/.test(key)
  105. ? { objectId: key }
  106. : { mobile: key };
  107. const r = await parseReq('GET', `/classes/_User?where=${encodeURIComponent(j(where))}&limit=50`);
  108. return r?.results ?? [];
  109. }
  110. async function findProfiles(userId) {
  111. const where = { user: { __type: 'Pointer', className: '_User', objectId: userId } };
  112. const r = await parseReq('GET', `/classes/Profile?where=${encodeURIComponent(j(where))}&limit=100`);
  113. return r?.results ?? [];
  114. }
  115. async function findCompany(companyId) {
  116. const r = await parseReq('GET', `/classes/Company/${companyId}`);
  117. return r?.error ? null : r;
  118. }
  119. async function findBindings(agentId) {
  120. const where = { agentId };
  121. const r = await parseReq(
  122. 'GET',
  123. `/classes/FmodeMatrixBinding?where=${encodeURIComponent(j(where))}&limit=100`,
  124. );
  125. return r?.results ?? [];
  126. }
  127. async function findAgent(agentId) {
  128. const r = await callFn(FN_STATUS, { op: 'query', scope: 'all', agentId });
  129. return r.code === 200 ? r.agent : null;
  130. }
  131. async function roleExists(name) {
  132. const r = await parseReq('GET', `/roles?where=${encodeURIComponent(j({ name }))}`);
  133. return (r?.results ?? []).length > 0;
  134. }
  135. // ---------- Matrix ----------
  136. async function derivePassword(objectId) {
  137. const raw = createHash('sha256').update(`fmode-agent-mx:${objectId}`, 'utf8').digest('hex');
  138. const lower = createHash('sha256')
  139. .update(`fmode-agent-mx:${objectId.toLowerCase()}`, 'utf8')
  140. .digest('hex');
  141. return [raw.slice(0, 24), lower.slice(0, 24)];
  142. }
  143. async function mxLogin(username, password) {
  144. const r = await fetch(`${MATRIX_HS}/_matrix/client/v3/login`, {
  145. method: 'POST',
  146. headers: { 'Content-Type': 'application/json' },
  147. body: JSON.stringify({
  148. type: 'm.login.password',
  149. identifier: { type: 'm.id.user', user: username },
  150. password,
  151. }),
  152. });
  153. const d = await r.json().catch(() => ({}));
  154. return d.access_token ?? null;
  155. }
  156. /** 用容器自己的凭据登录(两种派生密码都试) */
  157. async function agentToken(objectId) {
  158. const user = objectId.toLowerCase();
  159. for (const pw of await derivePassword(objectId)) {
  160. const t = await mxLogin(user, pw);
  161. if (t) return t;
  162. }
  163. return null;
  164. }
  165. async function mxGet(path, token) {
  166. const r = await fetch(`${MATRIX_HS}${path}`, { headers: { Authorization: `Bearer ${token}` } });
  167. return r.json().catch(() => null);
  168. }
  169. async function mxPost(path, token, body = {}) {
  170. const r = await fetch(`${MATRIX_HS}${path}`, {
  171. method: 'POST',
  172. headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
  173. body: JSON.stringify(body),
  174. });
  175. return r.json().catch(() => null);
  176. }
  177. const enc = (s) => encodeURIComponent(s);
  178. // ---------- 命令:users ----------
  179. async function cmdUsers(key) {
  180. if (!key) fail('用法:users <手机号|userId>', 2);
  181. const users = await findUser(key);
  182. if (!users.length) fail(`查不到用户:${key}`);
  183. console.log(`\n=== 用户查询:${key} ===\n`);
  184. console.log(`共 ${users.length} 条 _User 记录\n`);
  185. for (const u of users) {
  186. const company = u.company?.objectId ?? '';
  187. const c = company ? await findCompany(company) : null;
  188. const profiles = await findProfiles(u.objectId);
  189. const isMain = String(u.username ?? '').startsWith('fmode');
  190. console.log(`${isMain ? '★' : ' '} ${u.objectId} ${u.username}`);
  191. console.log(` 昵称 ${u.nickname ?? '-'} 手机 ${u.mobile ?? '-'}`);
  192. console.log(` 公司 ${company || '-'} ${c?.name ? `(${c.name})` : ''}`);
  193. console.log(` Profile ${profiles.length} 条`);
  194. for (const p of profiles) {
  195. const pc = await findCompany(p.company?.objectId ?? '');
  196. console.log(` - ${p.company?.objectId} ${pc?.name ?? ''}`);
  197. }
  198. console.log();
  199. }
  200. console.log('★ = 规范账号(username 以 fmode 开头,现行登录复用的就是它)');
  201. }
  202. // ---------- 命令:agent ----------
  203. async function cmdAgent(agentId) {
  204. if (!agentId) fail('用法:agent <agentId>', 2);
  205. const a = await findAgent(agentId);
  206. if (!a) fail(`FmodeAgent 查不到 ${agentId}`);
  207. const ch = parseJsonField(a.channels);
  208. const mc = ch.matrixConfig ?? {};
  209. const company = a.company?.objectId ?? '';
  210. const c = company ? await findCompany(company) : null;
  211. console.log(`\n=== 容器全貌:${agentId} ===\n`);
  212. console.log(`objectId ${a.objectId}`);
  213. console.log(`名称 ${a.name ?? '-'}`);
  214. console.log(`归属用户 ${a.user?.objectId ?? '-'} (${a.user?.nickname ?? a.ownerName ?? '-'})`);
  215. console.log(`归属公司 ${company} ${c?.name ? `(${c.name})` : ''}`);
  216. console.log(`上报 status=${a.status ?? '-'} updatedAt=${a.updatedAt ?? '-'}`);
  217. console.log(`Matrix matrix=${ch.matrix} state=${mc.channelState ?? '-'} listener=${mc.listenerOk ?? '-'}`);
  218. console.log(` userId=${mc.userId ?? '-'}`);
  219. // ACL 有效性
  220. const acl = a.ACL ?? {};
  221. const roleKeys = Object.keys(acl).filter((k) => k.startsWith('role:'));
  222. console.log(`\nACL(${Object.keys(acl).length} 项)`);
  223. for (const k of Object.keys(acl)) {
  224. if (!k.startsWith('role:')) {
  225. console.log(` ✅ ${k}(直接用户授权,生效)`);
  226. continue;
  227. }
  228. const name = k.slice(5);
  229. const exists = await roleExists(name);
  230. console.log(` ${exists ? '✅' : '⚠️ '} ${k} ${exists ? '' : '— _Role 里不存在,空授权'}`);
  231. }
  232. // 绑定
  233. const bindings = await findBindings(agentId);
  234. console.log(`\n绑定(${bindings.length} 条)`);
  235. if (!bindings.length) console.log(' (无)');
  236. for (const b of bindings) {
  237. const uid = b.userPointer?.objectId ?? b.userPointer ?? '?';
  238. console.log(` ${b.objectId} user=${uid} ${b.status} room=${b.roomId ?? '-'}`);
  239. }
  240. // 房间成员
  241. const at = await agentToken(a.objectId);
  242. if (!at) {
  243. console.log(warn('无法登录容器 Matrix 账号,跳过房间检查'));
  244. } else {
  245. const rooms = (await mxGet('/_matrix/client/v3/joined_rooms', at))?.joined_rooms ?? [];
  246. console.log(`\n容器所在房间(${rooms.length} 个)`);
  247. for (const rid of rooms) {
  248. const mem = (await mxGet(`/_matrix/client/v3/rooms/${enc(rid)}/joined_members`, at))?.joined;
  249. const members = mem ? Object.keys(mem) : [];
  250. const expect = bindings.find((b) => b.roomId === rid);
  251. const tag = expect ? '✅ 绑定房间' : '⚠️ 非绑定房间';
  252. console.log(` ${rid}`);
  253. console.log(` ${tag} 成员: ${members.join(', ')}`);
  254. }
  255. }
  256. console.log();
  257. }
  258. // ---------- 命令:audit ----------
  259. async function cmdAudit() {
  260. const me = await parseReq('GET', '/users/me');
  261. const companyId = me.company?.objectId;
  262. if (!companyId) fail('当前账号没有 company 指针');
  263. const r = await callFn(FN_STATUS, { op: 'query', scope: 'mine' });
  264. const agents = r.agents ?? [];
  265. const c = await findCompany(companyId);
  266. console.log(`\n=== 本公司容器审计:${c?.name ?? companyId} ===\n`);
  267. console.log('agentId'.padEnd(30), '归属用户'.padEnd(14), 'Matrix', '绑定数', '上报');
  268. console.log('-'.repeat(78));
  269. for (const a of agents) {
  270. const ch = parseJsonField(a.channels);
  271. const bs = await findBindings(a.agentId);
  272. const mine = String(a.company?.objectId ?? '') === companyId;
  273. console.log(
  274. String(a.agentId ?? '').padEnd(30),
  275. String(a.user?.objectId ?? '-').padEnd(14),
  276. String(!!ch.matrix).padEnd(6),
  277. String(bs.length).padEnd(6),
  278. mine ? (a.status ?? '-') : '⚠️ 非本公司',
  279. );
  280. }
  281. console.log(`\n共 ${agents.length} 个容器`);
  282. console.log('提示:绑定数 > 1 = 存在房间割裂隐患;「⚠️ 非本公司」= 公司指针与登录公司不符');
  283. }
  284. // ---------- 命令:bind ----------
  285. async function cmdBind(agentId, toUserId) {
  286. if (!agentId || !toUserId) fail('用法:bind <agentId> --to <userId>', 2);
  287. const a = await findAgent(agentId);
  288. if (!a) fail(`FmodeAgent 查不到 ${agentId}`);
  289. const target = await parseReq('GET', `/classes/_User/${toUserId}`);
  290. if (target.error) fail(`查不到用户 ${toUserId}`);
  291. console.log(`\n=== 绑定:${agentId} → ${target.nickname ?? target.username} ===\n`);
  292. console.log(`目标用户 ${toUserId} ${target.nickname ?? ''} ${target.mobile ?? ''}`);
  293. console.log(`用户公司 ${target.company?.objectId ?? '-'}`);
  294. console.log(`容器公司 ${a.company?.objectId ?? '-'}`);
  295. if ((target.company?.objectId ?? '') !== (a.company?.objectId ?? '')) {
  296. console.log(warn('容器与用户的公司不一致 —— 多容器协作可能不可用'));
  297. }
  298. const existing = await findBindings(agentId);
  299. const already = existing.find((b) => (b.userPointer?.objectId ?? b.userPointer) === toUserId);
  300. if (already) {
  301. console.log(ok(`该用户已有绑定 ${already.objectId},无需重复`));
  302. return;
  303. }
  304. console.log(warn('provision 必须用【目标用户本人】的 token 调用,本工具持有的是管理员 token'));
  305. console.log(info('请让目标用户在 App 登录后取 token,或用 enterprise 流程代取'));
  306. console.log();
  307. console.log('当前可执行的只读检查已完成。实际绑定需要目标用户 token,见文档 92 §四。');
  308. }
  309. // ---------- 命令:transfer ----------
  310. async function cmdTransfer(agentId, toUserId) {
  311. if (!agentId || !toUserId) fail('用法:transfer <agentId> --to <userId> [--yes]', 2);
  312. const a = await findAgent(agentId);
  313. if (!a) fail(`FmodeAgent 查不到 ${agentId}`);
  314. const target = await parseReq('GET', `/classes/_User/${toUserId}`);
  315. if (target.error) fail(`查不到用户 ${toUserId}`);
  316. const oldUid = a.user?.objectId ?? '';
  317. const bindings = await findBindings(agentId);
  318. const stale = bindings.filter((b) => (b.userPointer?.objectId ?? b.userPointer) !== toUserId);
  319. console.log(`\n=== 移交:${agentId} ===\n`);
  320. console.log(`现归属 ${oldUid} (${a.user?.nickname ?? a.ownerName ?? '-'})`);
  321. console.log(`新归属 ${toUserId} (${target.nickname ?? target.username})`);
  322. console.log(`现有绑定 ${bindings.length} 条,其中需清理 ${stale.length} 条\n`);
  323. console.log('将要执行(顺序不可换):');
  324. console.log(` 1. 改 FmodeAgent.user → Pointer<_User,${toUserId}>`);
  325. console.log(` 同时改 ownerMobile / ownerName / ACL 的直接用户授权项`);
  326. console.log(` 2. 删除旧绑定 ${stale.map((b) => b.objectId).join(', ') || '(无)'}`);
  327. console.log(' 3. 用新主人 token 调 provision(生成新 binding + 新 room)');
  328. console.log(' 4. 让容器退出旧房间(POST /rooms/<old>/leave)');
  329. console.log(' 5. 容器 env:MATRIX_ALLOWED_USERS → 新主人 mxid,重启 gateway');
  330. console.log(' 6. 容器 env:FMODE_SESSION_TOKEN → 新主人 token');
  331. console.log();
  332. if (DRY) {
  333. console.log(warn('DRY-RUN:未执行任何写操作。加 --yes 才会真正执行。'));
  334. console.log(info('注意:步骤 3/5/6 需要新主人 token 与容器权限,本工具无法独立完成。'));
  335. return;
  336. }
  337. // 真正执行:本工具只能做 1、2 步(平台数据),3-6 需容器侧配合
  338. console.log('▶ 步骤 1:改归属');
  339. const ACL = { ...(a.ACL ?? {}) };
  340. delete ACL[oldUid];
  341. ACL[toUserId] = { read: true, write: true };
  342. const res1 = await parseReq('PUT', `/classes/FmodeAgent/${a.objectId}`, {
  343. user: { __type: 'Pointer', className: '_User', objectId: toUserId },
  344. ownerMobile: target.mobile ?? '',
  345. ownerName: target.nickname ?? target.username ?? '',
  346. ACL,
  347. });
  348. if (res1.error) fail(`改归属失败:${j(res1)}`);
  349. console.log(ok('归属已改'));
  350. console.log('▶ 步骤 2:删除旧绑定');
  351. for (const b of stale) {
  352. const r = await parseReq('DELETE', `/classes/FmodeMatrixBinding/${b.objectId}`);
  353. console.log(r.error ? bad(` 删除 ${b.objectId} 失败:${j(r)}`) : ok(` 已删 ${b.objectId}`));
  354. }
  355. console.log('\n--- 平台侧完成 ---');
  356. console.log(warn('步骤 3-6 未执行,需要:'));
  357. console.log(' · 新主人的 sessionToken(调 provision)');
  358. console.log(' · 容器 /opt/data/.env 的写权限(改 ALLOWED_USERS 与 FMODE_SESSION_TOKEN)');
  359. console.log(' · 重启 gateway');
  360. console.log('\n请把以下信息交给容器侧执行者:');
  361. console.log(` agentId = ${agentId}`);
  362. console.log(` objectId = ${a.objectId}`);
  363. console.log(` 新主人 mxId = @${toUserId.toLowerCase()}:fmode.cn`);
  364. }
  365. // ---------- 主 ----------
  366. const HELP = `
  367. Fmode 容器接入管理台(公司管理员用)
  368. users <手机号|userId> 查用户 + Profile 成员关系
  369. agent <agentId> 查容器全貌(归属/ACL 有效性/绑定/房间)
  370. audit 列出本公司全部容器
  371. bind <agentId> --to <userId> 预检查(绑定需目标用户 token,见输出提示)
  372. transfer <agentId> --to <userId> 移交容器(默认 DRY-RUN,加 --yes 执行)
  373. 不做:用户自行切换组织(那是 App 侧 enterprise/switch 的职责)
  374. `;
  375. switch (CMD) {
  376. case 'users':
  377. await cmdUsers(ARGS[1]);
  378. break;
  379. case 'agent':
  380. await cmdAgent(ARGS[1]);
  381. break;
  382. case 'audit':
  383. await cmdAudit();
  384. break;
  385. case 'bind':
  386. await cmdBind(ARGS[1], opt('to'));
  387. break;
  388. case 'transfer':
  389. await cmdTransfer(ARGS[1], opt('to'));
  390. break;
  391. case undefined:
  392. case 'help':
  393. console.log(HELP);
  394. break;
  395. default:
  396. fail(`未知子命令:${CMD}`, 2);
  397. }