// FmodeAgent 用户管理云函数(2026-11-10 新建) // // 目的:给公司管理员提供「组织成员」的增删改查。 // // op: list { keyword?, page?, pageSize? } → { items, total, page, pageSize } // op: add { userId } → 把用户拉进管理员所在组织 // op: update { userId, nickname?, mobile?, password? } → 改资料 / 重置密码 // op: remove { userId } → **软删**(isDeleted=true)+ 移出组织 // op: get { userId } → 单条详情(编辑弹窗回填用) // // 🔴 安全边界(用户 2026-11-10 明确要求): // ① **每个 op 的第一件事都是服务端鉴权** —— 前端 `canManageCompany` 只决定 // 「入口显不显示」,改了前端也越不了权; // ② 作用域**强制锁在管理员自己的 companyId**,前端传任何 company 参数一律忽略; // ③ 改密码、软删必须 `useMasterKey: true`(前端直改 `_User` 是被禁止的)。 // // 平台约定(与 fmodeagent-chatthreads 一致,实测): // · 入口 async function handler(request, response);入参在全局 params // · 身份:优先 request.user(平台按 sessionToken 注入),否则按 token 查 _Session // · 🔴 输出必须 response.status(200).json(...) 显式写出;直接 return 对象平台不回包 // // 表: // _User objectId / username / mobile / nickname / company Pointer / isDeleted // _Role _Admin / _Public / FMODE_AGENT_SUPERADMIN var CLASS_USER = '_User'; var SUPER_ROLE = 'FMODE_AGENT_SUPERADMIN'; var DEFAULT_PAGE_SIZE = 20; var MAX_PAGE_SIZE = 100; function _ok(data) { response.status(200).json(Object.assign({ code: 200 }, data || {})); return true; } function _fail(code, mess) { response.status(200).json({ code: code, mess: mess }); return false; } function _str(v, max) { var s = v == null ? '' : String(v); return max && s.length > max ? s.slice(0, max) : s; } function _sessionToken() { var h = request && request.headers ? request.headers : {}; if (h['x-parse-session-token']) return h['x-parse-session-token']; if (params && params.sessionToken) return params.sessionToken; if (request && request.body && request.body.token) return request.body.token; return null; } async function _getUser(t) { if (!t) return null; if (request.user && request.user.id && !(params && params.sessionToken)) return request.user; try { var q = new Parse.Query(Parse.Session); q.equalTo('sessionToken', t); var s = await q.first({ useMasterKey: true }); return s ? s.get('user') : null; } catch (e) { return null; } } async function _roleUserIds(name) { try { var q = new Parse.Query(Parse.Role); q.equalTo('name', name); var role = await q.first({ useMasterKey: true }); if (!role) return []; var us = await role.getUsers().query().find({ useMasterKey: true }); return us.map(function (u) { return u.id; }); } catch (e) { return []; } } /** 当前用户的公司指针(无组织返回 null) */ function _userCompanyPtr(u) { try { var p = u.get('company'); return p && p.id ? p : null; } catch (e) { return null; } } /** * 当前用户是否「只开了个人试用组织」而非真正加入了组织(2026-11-10)。 * * ## 为什么要判 * * 实测(2026-11-10):新用户登录时系统会**自动创建一个以他命名的组织** * (如 liujun 的组织就叫「liujun」),并写入 `enterpriseTrialClaimedAt`。 * 所以 `companyId` 非空**并不能**说明他已被某个组织接纳 —— * 只判 `companyId` 会让等待页形同虚设。 * * ## 判据(两项都满足才算个人试用) * * 1. 该组织的 `enterpriseTrialClaimedAt` 有值(实测:100 个组织里 86 个个人组织全带此标记, * 康师傅/路觅教育这类正式组织都没有); * 2. 该组织里**只有他一个人**(被管理员拉进来的组织至少有 2 人)。 * * 第 2 条兜住「试用组织后来被别人加入」→ 自动转为正式组织的情况。 * * @returns true = 个人试用组织,应视为「无组织」 */ async function _isPersonalTrialOrg(companyPtr, userId) { if (!companyPtr) return false; try { var cq = new Parse.Query('Company'); var company = await cq.get(companyPtr.id, { useMasterKey: true }); if (!company || !company.get('enterpriseTrialClaimedAt')) return false; var uq = new Parse.Query(CLASS_USER); uq.equalTo('company', companyPtr); uq.notEqualTo('isDeleted', true); var count = await uq.count({ useMasterKey: true }); return count <= 1; } catch (e) { /* 查不到就按「正式组织」处理,避免把正常用户挡在外面 */ return false; } } /** 判断某个用户是否有「真正归属的组织」 */ async function _hasRealOrg(user) { var ptr = _userCompanyPtr(user); if (!ptr) return false; return !(await _isPersonalTrialOrg(ptr, user.id)); } /** * 重新从库中取一份完整用户对象。 * * 🔴 平台注入的 `request.user` 是**代理对象**:只有 `id`, * 调 `get('company')` 拿不到字段(实测:明明有组织却判成「未归属」)。 * 所以鉴权后必须按 objectId 重新查一次。 */ async function _reloadUser(u) { try { var q = new Parse.Query(CLASS_USER); var fresh = await q.get(u.id, { useMasterKey: true }); return fresh || u; } catch (e) { return u; } } /** * 服务端鉴权 —— **每个 op 必须先过这里**。 * * @returns { user, companyId, isSuperAdmin } 或 null(已被 _fail 写出) */ async function _authorize() { var user = await _getUser(_sessionToken()); if (!user) { _fail(401, 'SessionToken 无效或已过期'); return null; } /* 平台注入的对象只有 id,字段要重新取(见 _reloadUser 注释) */ user = await _reloadUser(user); var superIds = await _roleUserIds(SUPER_ROLE); var isSuperAdmin = superIds.indexOf(user.id) >= 0; var companyPtr = _userCompanyPtr(user); var companyId = companyPtr ? companyPtr.id : null; /* * 超管:与公司管理员一样,**只操作自己所属的组织**。 * * 🔴 不接受前端传的 `companyId` —— 那等于给超管开了一个「查任意租户」的入口, * 前端一改就能越权。跨租户管理如果需要,应另做受审计的运维接口。 */ if (isSuperAdmin) { if (!companyId) { _fail(403, '当前超管账号未归属任何组织,无法使用成员管理'); return null; } return { user: user, companyId: companyId, isSuperAdmin: true }; } // 公司管理员:roles 里必须有 `_Admin` if (!companyId) { _fail(403, '当前账号不属于任何组织'); return null; } var roleNames = []; try { var rq = new Parse.Query(Parse.Role); rq.equalTo('name', companyId + '_Admin'); var role = await rq.first({ useMasterKey: true }); if (role) { var us = await role.getUsers().query().find({ useMasterKey: true }); roleNames = us.map(function (u) { return u.id; }); } } catch (e) { /* 查不到角色 → 视为非管理员 */ } if (roleNames.indexOf(user.id) < 0) { _fail(403, '仅公司管理员可管理成员'); return null; } return { user: user, companyId: companyId, isSuperAdmin: false }; } /** 用户 → 返回给前端的精简结构 */ function _toItem(u) { return { id: u.id, username: _str(u.get('username'), 80), nickname: _str(u.get('nickname'), 60), mobile: _str(u.get('mobile'), 20), isDeleted: !!u.get('isDeleted'), createdAt: u.createdAt ? u.createdAt.getTime() : 0, }; } /** 该用户是否在本组织内 */ async function _userInCompany(userId, companyId) { try { var q = new Parse.Query(CLASS_USER); var u = await q.get(userId, { useMasterKey: true }); var p = _userCompanyPtr(u); return { user: u, inCompany: !!p && p.id === companyId }; } catch (e) { return { user: null, inCompany: false }; } } async function opList(ctx) { var page = Math.max(1, parseInt(params.page, 10) || 1); var pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, parseInt(params.pageSize, 10) || DEFAULT_PAGE_SIZE)); var keyword = _str(params.keyword, 60).trim(); var companyPtr = { __type: 'Pointer', className: 'Company', objectId: ctx.companyId }; function buildQuery() { var q = new Parse.Query(CLASS_USER); q.equalTo('company', companyPtr); q.notEqualTo('isDeleted', true); if (keyword) { /* 昵称 / 手机号 / objectId 三选一命中 */ var byName = new Parse.Query(CLASS_USER); byName.contains('nickname', keyword); var byMobile = new Parse.Query(CLASS_USER); byMobile.contains('mobile', keyword); var byUsername = new Parse.Query(CLASS_USER); byUsername.contains('username', keyword); if (keyword.length <= 12) { var byId = new Parse.Query(CLASS_USER); byId.equalTo('objectId', keyword); q = Parse.Query.or(byName, byMobile, byUsername, byId); } else { q = Parse.Query.or(byName, byMobile, byUsername); } /* 关键词查询也要受组织约束 —— 否则能搜到别的组织的人 */ q.equalTo('company', companyPtr); q.notEqualTo('isDeleted', true); } q.descending('createdAt'); return q; } var total = await buildQuery().count({ useMasterKey: true }); var q = buildQuery(); q.skip((page - 1) * pageSize); q.limit(pageSize); var rows = await q.find({ useMasterKey: true }); return _ok({ items: rows.map(_toItem), total: total, page: page, pageSize: pageSize, }); } async function opAdd(ctx) { var userId = _str(params.userId, 120).trim(); if (!userId) return _fail(400, '请填写用户 ID'); var found; try { var q = new Parse.Query(CLASS_USER); found = await q.get(userId, { useMasterKey: true }); } catch (e) { return _fail(404, '用户不存在:' + userId); } if (found.get('isDeleted')) return _fail(400, '该用户已被删除,不能加入组织'); var cur = _userCompanyPtr(found); if (cur && cur.id !== ctx.companyId) { return _fail(409, '该用户已属于其他组织,请先联系其原组织处理'); } /* 改 `_User.company` 指针(运维口径:换组织改指针,objectId 不变) */ var companyPtr = { __type: 'Pointer', className: 'Company', objectId: ctx.companyId }; found.set('company', Parse.Object.fromJSON(companyPtr)); await found.save(null, { useMasterKey: true }); /* 补上普通成员角色(人群聊/可见性等 ACL 依赖它) */ try { var rq = new Parse.Query(Parse.Role); rq.equalTo('name', ctx.companyId + '_Public'); var role = await rq.first({ useMasterKey: true }); if (role) await role.getUsers().add(found).save(null, { useMasterKey: true }); } catch (e) { /* 角色补不上不影响主流程 */ } return _ok({ item: _toItem(found) }); } async function opGet(ctx) { var userId = _str(params.userId, 120).trim(); if (!userId) return _fail(400, '用户 ID 不能为空'); var r = await _userInCompany(userId, ctx.companyId); if (!r.user) return _fail(404, '用户不存在'); if (!r.inCompany) return _fail(403, '该用户不在本组织'); return _ok({ item: _toItem(r.user) }); } async function opUpdate(ctx) { var userId = _str(params.userId, 120).trim(); if (!userId) return _fail(400, '用户 ID 不能为空'); var r = await _userInCompany(userId, ctx.companyId); if (!r.user) return _fail(404, '用户不存在'); if (!r.inCompany) return _fail(403, '该用户不在本组织'); var u = r.user; if (params.nickname != null) u.set('nickname', _str(params.nickname, 60)); if (params.mobile != null) { var mobile = _str(params.mobile, 20).trim(); if (mobile && !/^1\d{10}$/.test(mobile)) return _fail(400, '手机号格式不正确'); u.set('mobile', mobile); } /* 🔴 改密码必须 useMasterKey;空串表示「不修改」 */ if (params.password != null && String(params.password).length > 0) { var pwd = String(params.password); if (pwd.length < 6) return _fail(400, '密码至少 6 位'); u.set('password', pwd); } await u.save(null, { useMasterKey: true }); return _ok({ item: _toItem(u) }); } async function opRemove(ctx) { var userId = _str(params.userId, 120).trim(); if (!userId) return _fail(400, '用户 ID 不能为空'); if (userId === ctx.user.id) return _fail(400, '不能删除自己'); var r = await _userInCompany(userId, ctx.companyId); if (!r.user) return _fail(404, '用户不存在'); if (!r.inCompany) return _fail(403, '该用户不在本组织'); /* * 软删(平台铁律:删除用软删 isDeleted=true)+ 移出组织。 * 不物理删除:Matrix 绑定、会话归属等仍引用该 objectId,物理删会留下悬空指针。 */ var u = r.user; u.set('isDeleted', true); u.unset('company'); await u.save(null, { useMasterKey: true }); return _ok({ removed: true }); } async function opMe(ctx) { /* * 「我有没有真正的组织」—— 供前端 /pending 页与 orgGuard 判定。 * * 🔴 不走管理员鉴权(`_authorize` 会 403),每个登录用户都要能调, * 所以这里单独处理:只回自己的身份与归属,不泄露任何他人数据。 */ var user = await _getUser(_sessionToken()); if (!user) return _fail(401, 'SessionToken 无效或已过期'); user = await _reloadUser(user); var ptr = _userCompanyPtr(user); var real = ptr ? !(await _isPersonalTrialOrg(ptr, user.id)) : false; var companyName = ''; if (ptr) { try { var c = await new Parse.Query('Company').get(ptr.id, { useMasterKey: true }); companyName = c.get('name') || ''; } catch (e) { /* 忽略 */ } } return _ok({ userId: user.id, hasOrg: real, companyId: ptr ? ptr.id : '', companyName: companyName, }); } async function handler(request, response) { try { params = params && typeof params === 'object' ? params : (request && request.body && request.body.params) || {}; /* `/me` 只需登录态,不需要管理员身份 —— 必须放在 _authorize 之前 */ if (params.op === 'me') return await opMe(); var ctx = await _authorize(); if (!ctx) return; // _authorize 已写出错误响应 switch (params.op) { case 'list': return await opList(ctx); case 'add': return await opAdd(ctx); case 'get': return await opGet(ctx); case 'update': return await opUpdate(ctx); case 'remove': return await opRemove(ctx); default: return _fail(400, '未知 op: ' + params.op); } } catch (e) { return _fail(500, '用户管理服务异常: ' + ((e && e.message) || String(e))); } }