| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467 |
- #!/usr/bin/env node
- /**
- * Fmode 容器接入管理台
- *
- * 面向:**公司管理员**。用于把新用户绑定到容器、把容器移交给别人、把容器换到公司下。
- * 不做:用户自己切换组织(该能力由 App 侧 enterprise/switch 提供,本工具不碰)。
- *
- * 子命令:
- * node scripts/fmode-admin.mjs users <手机号|userId> 查用户(含 Profile 成员关系)
- * node scripts/fmode-admin.mjs agent <agentId> 查容器全貌(归属/ACL/绑定/房间)
- * node scripts/fmode-admin.mjs audit 列出本公司全部容器与绑定
- * node scripts/fmode-admin.mjs bind <agentId> --to <userId> 绑给指定用户(新增一条)
- * node scripts/fmode-admin.mjs transfer <agentId> --to <userId> 移交(改归属+清旧绑定+重开)
- *
- * 凭据:读 `~/.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 <agentId>', 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 <agentId> --to <userId>', 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 <agentId> --to <userId> [--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/<old>/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 <agentId> 查容器全貌(归属/ACL 有效性/绑定/房间)
- audit 列出本公司全部容器
- bind <agentId> --to <userId> 预检查(绑定需目标用户 token,见输出提示)
- transfer <agentId> --to <userId> 移交容器(默认 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);
- }
|