#!/usr/bin/env node /** * Fmode 容器接入管理台 * * 面向:**公司管理员**。用于把新用户绑定到容器、把容器移交给别人、把容器换到公司下。 * 不做:用户自己切换组织(该能力由 App 侧 enterprise/switch 提供,本工具不碰)。 * * 子命令: * node scripts/fmode-admin.mjs users <手机号|userId> 查用户(含 Profile 成员关系) * node scripts/fmode-admin.mjs agent 查容器全貌(归属/ACL/绑定/房间) * node scripts/fmode-admin.mjs audit 列出本公司全部容器与绑定 * node scripts/fmode-admin.mjs bind --to 绑给指定用户(新增一条) * node scripts/fmode-admin.mjs transfer --to 移交(改归属+清旧绑定+重开) * * 凭据:读 `~/.fmode/config/user.json` 的 sessionToken(管理员本人)。 * * ⚠️ 写操作(bind/transfer)默认 **dry-run**,必须加 --yes 才真正执行。 * * 依据:《docs/03-interface/容器接入App-操作手册/92-运维答复-落地设计.md》 */ import { readFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { homedir } from 'node:os'; import { join } from 'node:path'; const FUNCTIONS_URL = 'https://server.fmode.cn/api/functions'; const PARSE_URL = 'https://server.fmode.cn/parse'; const MATRIX_HS = 'https://matrix.fmode.cn'; const APP_ID = 'ncloudmaster'; const FN_STATUS = 'xlosi3m46q'; const FN_PROVISION = 'RP5Ht55NeX'; // ---------- 基础 ---------- function loadToken() { const p = join(homedir(), '.fmode', 'config', 'user.json'); try { const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken; if (typeof t === 'string' && t.startsWith('r:')) return t; } catch { /* fallthrough */ } fail(`读不到平台 sessionToken(期望 ${p} 的 sessionToken 且以 "r:" 开头)`, 2); } const TOKEN = loadToken(); const ARGS = process.argv.slice(2); const CMD = ARGS[0]; const DRY = !ARGS.includes('--yes'); const ok = (s) => `✅ ${s}`; const bad = (s) => `❌ ${s}`; const warn = (s) => `⚠️ ${s}`; const info = (s) => ` ${s}`; function fail(msg, code = 1) { console.error(`❌ ${msg}`); process.exit(code); } function opt(name) { const i = ARGS.indexOf(`--${name}`); return i >= 0 ? ARGS[i + 1] : undefined; } // ---------- 平台调用 ---------- async function callFn(id, params) { const res = await fetch(FUNCTIONS_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, token: TOKEN, params }), }); const t = await res.text(); try { return JSON.parse(t); } catch { return { code: res.status, mess: t.slice(0, 200) }; } } async function parseReq(method, path, body) { const res = await fetch(`${PARSE_URL}${path}`, { method, headers: { 'X-Parse-Application-Id': APP_ID, 'X-Parse-Session-Token': TOKEN, 'Content-Type': 'application/json', }, body: body ? JSON.stringify(body) : undefined, }); const t = await res.text(); try { return { ...JSON.parse(t), _status: res.status }; } catch { return { _status: res.status, error: t.slice(0, 200) }; } } const q = (where, extra = '') => parseReq('GET', `/classes/FmodeAgent?where=${encodeURIComponent(JSON.stringify(where))}${extra}`); const j = (o) => JSON.stringify(o); // ---------- 数据读取 ---------- function parseJsonField(v) { if (!v) return {}; if (typeof v === 'object') return v; try { return JSON.parse(v) ?? {}; } catch { return {}; } } async function findUser(key) { // key 可以是 objectId 或手机号 const where = /^[A-Za-z0-9]{10}$/.test(key) ? { objectId: key } : { mobile: key }; const r = await parseReq('GET', `/classes/_User?where=${encodeURIComponent(j(where))}&limit=50`); return r?.results ?? []; } async function findProfiles(userId) { const where = { user: { __type: 'Pointer', className: '_User', objectId: userId } }; const r = await parseReq('GET', `/classes/Profile?where=${encodeURIComponent(j(where))}&limit=100`); return r?.results ?? []; } async function findCompany(companyId) { const r = await parseReq('GET', `/classes/Company/${companyId}`); return r?.error ? null : r; } async function findBindings(agentId) { const where = { agentId }; const r = await parseReq( 'GET', `/classes/FmodeMatrixBinding?where=${encodeURIComponent(j(where))}&limit=100`, ); return r?.results ?? []; } async function findAgent(agentId) { const r = await callFn(FN_STATUS, { op: 'query', scope: 'all', agentId }); return r.code === 200 ? r.agent : null; } async function roleExists(name) { const r = await parseReq('GET', `/roles?where=${encodeURIComponent(j({ name }))}`); return (r?.results ?? []).length > 0; } // ---------- Matrix ---------- async function derivePassword(objectId) { const raw = createHash('sha256').update(`fmode-agent-mx:${objectId}`, 'utf8').digest('hex'); const lower = createHash('sha256') .update(`fmode-agent-mx:${objectId.toLowerCase()}`, 'utf8') .digest('hex'); return [raw.slice(0, 24), lower.slice(0, 24)]; } async function mxLogin(username, password) { const r = await fetch(`${MATRIX_HS}/_matrix/client/v3/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'm.login.password', identifier: { type: 'm.id.user', user: username }, password, }), }); const d = await r.json().catch(() => ({})); return d.access_token ?? null; } /** 用容器自己的凭据登录(两种派生密码都试) */ async function agentToken(objectId) { const user = objectId.toLowerCase(); for (const pw of await derivePassword(objectId)) { const t = await mxLogin(user, pw); if (t) return t; } return null; } async function mxGet(path, token) { const r = await fetch(`${MATRIX_HS}${path}`, { headers: { Authorization: `Bearer ${token}` } }); return r.json().catch(() => null); } async function mxPost(path, token, body = {}) { const r = await fetch(`${MATRIX_HS}${path}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); return r.json().catch(() => null); } const enc = (s) => encodeURIComponent(s); // ---------- 命令:users ---------- async function cmdUsers(key) { if (!key) fail('用法:users <手机号|userId>', 2); const users = await findUser(key); if (!users.length) fail(`查不到用户:${key}`); console.log(`\n=== 用户查询:${key} ===\n`); console.log(`共 ${users.length} 条 _User 记录\n`); for (const u of users) { const company = u.company?.objectId ?? ''; const c = company ? await findCompany(company) : null; const profiles = await findProfiles(u.objectId); const isMain = String(u.username ?? '').startsWith('fmode'); console.log(`${isMain ? '★' : ' '} ${u.objectId} ${u.username}`); console.log(` 昵称 ${u.nickname ?? '-'} 手机 ${u.mobile ?? '-'}`); console.log(` 公司 ${company || '-'} ${c?.name ? `(${c.name})` : ''}`); console.log(` Profile ${profiles.length} 条`); for (const p of profiles) { const pc = await findCompany(p.company?.objectId ?? ''); console.log(` - ${p.company?.objectId} ${pc?.name ?? ''}`); } console.log(); } console.log('★ = 规范账号(username 以 fmode 开头,现行登录复用的就是它)'); } // ---------- 命令:agent ---------- async function cmdAgent(agentId) { if (!agentId) fail('用法:agent ', 2); const a = await findAgent(agentId); if (!a) fail(`FmodeAgent 查不到 ${agentId}`); const ch = parseJsonField(a.channels); const mc = ch.matrixConfig ?? {}; const company = a.company?.objectId ?? ''; const c = company ? await findCompany(company) : null; console.log(`\n=== 容器全貌:${agentId} ===\n`); console.log(`objectId ${a.objectId}`); console.log(`名称 ${a.name ?? '-'}`); console.log(`归属用户 ${a.user?.objectId ?? '-'} (${a.user?.nickname ?? a.ownerName ?? '-'})`); console.log(`归属公司 ${company} ${c?.name ? `(${c.name})` : ''}`); console.log(`上报 status=${a.status ?? '-'} updatedAt=${a.updatedAt ?? '-'}`); console.log(`Matrix matrix=${ch.matrix} state=${mc.channelState ?? '-'} listener=${mc.listenerOk ?? '-'}`); console.log(` userId=${mc.userId ?? '-'}`); // ACL 有效性 const acl = a.ACL ?? {}; const roleKeys = Object.keys(acl).filter((k) => k.startsWith('role:')); console.log(`\nACL(${Object.keys(acl).length} 项)`); for (const k of Object.keys(acl)) { if (!k.startsWith('role:')) { console.log(` ✅ ${k}(直接用户授权,生效)`); continue; } const name = k.slice(5); const exists = await roleExists(name); console.log(` ${exists ? '✅' : '⚠️ '} ${k} ${exists ? '' : '— _Role 里不存在,空授权'}`); } // 绑定 const bindings = await findBindings(agentId); console.log(`\n绑定(${bindings.length} 条)`); if (!bindings.length) console.log(' (无)'); for (const b of bindings) { const uid = b.userPointer?.objectId ?? b.userPointer ?? '?'; console.log(` ${b.objectId} user=${uid} ${b.status} room=${b.roomId ?? '-'}`); } // 房间成员 const at = await agentToken(a.objectId); if (!at) { console.log(warn('无法登录容器 Matrix 账号,跳过房间检查')); } else { const rooms = (await mxGet('/_matrix/client/v3/joined_rooms', at))?.joined_rooms ?? []; console.log(`\n容器所在房间(${rooms.length} 个)`); for (const rid of rooms) { const mem = (await mxGet(`/_matrix/client/v3/rooms/${enc(rid)}/joined_members`, at))?.joined; const members = mem ? Object.keys(mem) : []; const expect = bindings.find((b) => b.roomId === rid); const tag = expect ? '✅ 绑定房间' : '⚠️ 非绑定房间'; console.log(` ${rid}`); console.log(` ${tag} 成员: ${members.join(', ')}`); } } console.log(); } // ---------- 命令:audit ---------- async function cmdAudit() { const me = await parseReq('GET', '/users/me'); const companyId = me.company?.objectId; if (!companyId) fail('当前账号没有 company 指针'); const r = await callFn(FN_STATUS, { op: 'query', scope: 'mine' }); const agents = r.agents ?? []; const c = await findCompany(companyId); console.log(`\n=== 本公司容器审计:${c?.name ?? companyId} ===\n`); console.log('agentId'.padEnd(30), '归属用户'.padEnd(14), 'Matrix', '绑定数', '上报'); console.log('-'.repeat(78)); for (const a of agents) { const ch = parseJsonField(a.channels); const bs = await findBindings(a.agentId); const mine = String(a.company?.objectId ?? '') === companyId; console.log( String(a.agentId ?? '').padEnd(30), String(a.user?.objectId ?? '-').padEnd(14), String(!!ch.matrix).padEnd(6), String(bs.length).padEnd(6), mine ? (a.status ?? '-') : '⚠️ 非本公司', ); } console.log(`\n共 ${agents.length} 个容器`); console.log('提示:绑定数 > 1 = 存在房间割裂隐患;「⚠️ 非本公司」= 公司指针与登录公司不符'); } // ---------- 命令:bind ---------- async function cmdBind(agentId, toUserId) { if (!agentId || !toUserId) fail('用法:bind --to ', 2); const a = await findAgent(agentId); if (!a) fail(`FmodeAgent 查不到 ${agentId}`); const target = await parseReq('GET', `/classes/_User/${toUserId}`); if (target.error) fail(`查不到用户 ${toUserId}`); console.log(`\n=== 绑定:${agentId} → ${target.nickname ?? target.username} ===\n`); console.log(`目标用户 ${toUserId} ${target.nickname ?? ''} ${target.mobile ?? ''}`); console.log(`用户公司 ${target.company?.objectId ?? '-'}`); console.log(`容器公司 ${a.company?.objectId ?? '-'}`); if ((target.company?.objectId ?? '') !== (a.company?.objectId ?? '')) { console.log(warn('容器与用户的公司不一致 —— 多容器协作可能不可用')); } const existing = await findBindings(agentId); const already = existing.find((b) => (b.userPointer?.objectId ?? b.userPointer) === toUserId); if (already) { console.log(ok(`该用户已有绑定 ${already.objectId},无需重复`)); return; } console.log(warn('provision 必须用【目标用户本人】的 token 调用,本工具持有的是管理员 token')); console.log(info('请让目标用户在 App 登录后取 token,或用 enterprise 流程代取')); console.log(); console.log('当前可执行的只读检查已完成。实际绑定需要目标用户 token,见文档 92 §四。'); } // ---------- 命令:transfer ---------- async function cmdTransfer(agentId, toUserId) { if (!agentId || !toUserId) fail('用法:transfer --to [--yes]', 2); const a = await findAgent(agentId); if (!a) fail(`FmodeAgent 查不到 ${agentId}`); const target = await parseReq('GET', `/classes/_User/${toUserId}`); if (target.error) fail(`查不到用户 ${toUserId}`); const oldUid = a.user?.objectId ?? ''; const bindings = await findBindings(agentId); const stale = bindings.filter((b) => (b.userPointer?.objectId ?? b.userPointer) !== toUserId); console.log(`\n=== 移交:${agentId} ===\n`); console.log(`现归属 ${oldUid} (${a.user?.nickname ?? a.ownerName ?? '-'})`); console.log(`新归属 ${toUserId} (${target.nickname ?? target.username})`); console.log(`现有绑定 ${bindings.length} 条,其中需清理 ${stale.length} 条\n`); console.log('将要执行(顺序不可换):'); console.log(` 1. 改 FmodeAgent.user → Pointer<_User,${toUserId}>`); console.log(` 同时改 ownerMobile / ownerName / ACL 的直接用户授权项`); console.log(` 2. 删除旧绑定 ${stale.map((b) => b.objectId).join(', ') || '(无)'}`); console.log(' 3. 用新主人 token 调 provision(生成新 binding + 新 room)'); console.log(' 4. 让容器退出旧房间(POST /rooms//leave)'); console.log(' 5. 容器 env:MATRIX_ALLOWED_USERS → 新主人 mxid,重启 gateway'); console.log(' 6. 容器 env:FMODE_SESSION_TOKEN → 新主人 token'); console.log(); if (DRY) { console.log(warn('DRY-RUN:未执行任何写操作。加 --yes 才会真正执行。')); console.log(info('注意:步骤 3/5/6 需要新主人 token 与容器权限,本工具无法独立完成。')); return; } // 真正执行:本工具只能做 1、2 步(平台数据),3-6 需容器侧配合 console.log('▶ 步骤 1:改归属'); const ACL = { ...(a.ACL ?? {}) }; delete ACL[oldUid]; ACL[toUserId] = { read: true, write: true }; const res1 = await parseReq('PUT', `/classes/FmodeAgent/${a.objectId}`, { user: { __type: 'Pointer', className: '_User', objectId: toUserId }, ownerMobile: target.mobile ?? '', ownerName: target.nickname ?? target.username ?? '', ACL, }); if (res1.error) fail(`改归属失败:${j(res1)}`); console.log(ok('归属已改')); console.log('▶ 步骤 2:删除旧绑定'); for (const b of stale) { const r = await parseReq('DELETE', `/classes/FmodeMatrixBinding/${b.objectId}`); console.log(r.error ? bad(` 删除 ${b.objectId} 失败:${j(r)}`) : ok(` 已删 ${b.objectId}`)); } console.log('\n--- 平台侧完成 ---'); console.log(warn('步骤 3-6 未执行,需要:')); console.log(' · 新主人的 sessionToken(调 provision)'); console.log(' · 容器 /opt/data/.env 的写权限(改 ALLOWED_USERS 与 FMODE_SESSION_TOKEN)'); console.log(' · 重启 gateway'); console.log('\n请把以下信息交给容器侧执行者:'); console.log(` agentId = ${agentId}`); console.log(` objectId = ${a.objectId}`); console.log(` 新主人 mxId = @${toUserId.toLowerCase()}:fmode.cn`); } // ---------- 主 ---------- const HELP = ` Fmode 容器接入管理台(公司管理员用) users <手机号|userId> 查用户 + Profile 成员关系 agent 查容器全貌(归属/ACL 有效性/绑定/房间) audit 列出本公司全部容器 bind --to 预检查(绑定需目标用户 token,见输出提示) transfer --to 移交容器(默认 DRY-RUN,加 --yes 执行) 不做:用户自行切换组织(那是 App 侧 enterprise/switch 的职责) `; switch (CMD) { case 'users': await cmdUsers(ARGS[1]); break; case 'agent': await cmdAgent(ARGS[1]); break; case 'audit': await cmdAudit(); break; case 'bind': await cmdBind(ARGS[1], opt('to')); break; case 'transfer': await cmdTransfer(ARGS[1], opt('to')); break; case undefined: case 'help': console.log(HELP); break; default: fail(`未知子命令:${CMD}`, 2); }