fmodeagent-users.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. // FmodeAgent 用户管理云函数(2026-11-10 新建)
  2. //
  3. // 目的:给公司管理员提供「组织成员」的增删改查。
  4. //
  5. // op: list { keyword?, page?, pageSize? } → { items, total, page, pageSize }
  6. // op: add { userId } → 把用户拉进管理员所在组织
  7. // op: update { userId, nickname?, mobile?, password? } → 改资料 / 重置密码
  8. // op: remove { userId } → **软删**(isDeleted=true)+ 移出组织
  9. // op: get { userId } → 单条详情(编辑弹窗回填用)
  10. //
  11. // 🔴 安全边界(用户 2026-11-10 明确要求):
  12. // ① **每个 op 的第一件事都是服务端鉴权** —— 前端 `canManageCompany` 只决定
  13. // 「入口显不显示」,改了前端也越不了权;
  14. // ② 作用域**强制锁在管理员自己的 companyId**,前端传任何 company 参数一律忽略;
  15. // ③ 改密码、软删必须 `useMasterKey: true`(前端直改 `_User` 是被禁止的)。
  16. //
  17. // 平台约定(与 fmodeagent-chatthreads 一致,实测):
  18. // · 入口 async function handler(request, response);入参在全局 params
  19. // · 身份:优先 request.user(平台按 sessionToken 注入),否则按 token 查 _Session
  20. // · 🔴 输出必须 response.status(200).json(...) 显式写出;直接 return 对象平台不回包
  21. //
  22. // 表:
  23. // _User objectId / username / mobile / nickname / company Pointer<Company> / isDeleted
  24. // _Role <companyId>_Admin / <companyId>_Public / FMODE_AGENT_SUPERADMIN
  25. var CLASS_USER = '_User';
  26. var SUPER_ROLE = 'FMODE_AGENT_SUPERADMIN';
  27. var DEFAULT_PAGE_SIZE = 20;
  28. var MAX_PAGE_SIZE = 100;
  29. function _ok(data) {
  30. response.status(200).json(Object.assign({ code: 200 }, data || {}));
  31. return true;
  32. }
  33. function _fail(code, mess) {
  34. response.status(200).json({ code: code, mess: mess });
  35. return false;
  36. }
  37. function _str(v, max) {
  38. var s = v == null ? '' : String(v);
  39. return max && s.length > max ? s.slice(0, max) : s;
  40. }
  41. function _sessionToken() {
  42. var h = request && request.headers ? request.headers : {};
  43. if (h['x-parse-session-token']) return h['x-parse-session-token'];
  44. if (params && params.sessionToken) return params.sessionToken;
  45. if (request && request.body && request.body.token) return request.body.token;
  46. return null;
  47. }
  48. async function _getUser(t) {
  49. if (!t) return null;
  50. if (request.user && request.user.id && !(params && params.sessionToken)) return request.user;
  51. try {
  52. var q = new Parse.Query(Parse.Session);
  53. q.equalTo('sessionToken', t);
  54. var s = await q.first({ useMasterKey: true });
  55. return s ? s.get('user') : null;
  56. } catch (e) {
  57. return null;
  58. }
  59. }
  60. async function _roleUserIds(name) {
  61. try {
  62. var q = new Parse.Query(Parse.Role);
  63. q.equalTo('name', name);
  64. var role = await q.first({ useMasterKey: true });
  65. if (!role) return [];
  66. var us = await role.getUsers().query().find({ useMasterKey: true });
  67. return us.map(function (u) {
  68. return u.id;
  69. });
  70. } catch (e) {
  71. return [];
  72. }
  73. }
  74. /** 当前用户的公司指针(无组织返回 null) */
  75. function _userCompanyPtr(u) {
  76. try {
  77. var p = u.get('company');
  78. return p && p.id ? p : null;
  79. } catch (e) {
  80. return null;
  81. }
  82. }
  83. /**
  84. * 当前用户是否「只开了个人试用组织」而非真正加入了组织(2026-11-10)。
  85. *
  86. * ## 为什么要判
  87. *
  88. * 实测(2026-11-10):新用户登录时系统会**自动创建一个以他命名的组织**
  89. * (如 liujun 的组织就叫「liujun」),并写入 `enterpriseTrialClaimedAt`。
  90. * 所以 `companyId` 非空**并不能**说明他已被某个组织接纳 ——
  91. * 只判 `companyId` 会让等待页形同虚设。
  92. *
  93. * ## 判据(两项都满足才算个人试用)
  94. *
  95. * 1. 该组织的 `enterpriseTrialClaimedAt` 有值(实测:100 个组织里 86 个个人组织全带此标记,
  96. * 康师傅/路觅教育这类正式组织都没有);
  97. * 2. 该组织里**只有他一个人**(被管理员拉进来的组织至少有 2 人)。
  98. *
  99. * 第 2 条兜住「试用组织后来被别人加入」→ 自动转为正式组织的情况。
  100. *
  101. * @returns true = 个人试用组织,应视为「无组织」
  102. */
  103. async function _isPersonalTrialOrg(companyPtr, userId) {
  104. if (!companyPtr) return false;
  105. try {
  106. var cq = new Parse.Query('Company');
  107. var company = await cq.get(companyPtr.id, { useMasterKey: true });
  108. if (!company || !company.get('enterpriseTrialClaimedAt')) return false;
  109. var uq = new Parse.Query(CLASS_USER);
  110. uq.equalTo('company', companyPtr);
  111. uq.notEqualTo('isDeleted', true);
  112. var count = await uq.count({ useMasterKey: true });
  113. return count <= 1;
  114. } catch (e) {
  115. /* 查不到就按「正式组织」处理,避免把正常用户挡在外面 */
  116. return false;
  117. }
  118. }
  119. /** 判断某个用户是否有「真正归属的组织」 */
  120. async function _hasRealOrg(user) {
  121. var ptr = _userCompanyPtr(user);
  122. if (!ptr) return false;
  123. return !(await _isPersonalTrialOrg(ptr, user.id));
  124. }
  125. /**
  126. * 重新从库中取一份完整用户对象。
  127. *
  128. * 🔴 平台注入的 `request.user` 是**代理对象**:只有 `id`,
  129. * 调 `get('company')` 拿不到字段(实测:明明有组织却判成「未归属」)。
  130. * 所以鉴权后必须按 objectId 重新查一次。
  131. */
  132. async function _reloadUser(u) {
  133. try {
  134. var q = new Parse.Query(CLASS_USER);
  135. var fresh = await q.get(u.id, { useMasterKey: true });
  136. return fresh || u;
  137. } catch (e) {
  138. return u;
  139. }
  140. }
  141. /**
  142. * 服务端鉴权 —— **每个 op 必须先过这里**。
  143. *
  144. * @returns { user, companyId, isSuperAdmin } 或 null(已被 _fail 写出)
  145. */
  146. async function _authorize() {
  147. var user = await _getUser(_sessionToken());
  148. if (!user) {
  149. _fail(401, 'SessionToken 无效或已过期');
  150. return null;
  151. }
  152. /* 平台注入的对象只有 id,字段要重新取(见 _reloadUser 注释) */
  153. user = await _reloadUser(user);
  154. var superIds = await _roleUserIds(SUPER_ROLE);
  155. var isSuperAdmin = superIds.indexOf(user.id) >= 0;
  156. var companyPtr = _userCompanyPtr(user);
  157. var companyId = companyPtr ? companyPtr.id : null;
  158. /*
  159. * 超管:与公司管理员一样,**只操作自己所属的组织**。
  160. *
  161. * 🔴 不接受前端传的 `companyId` —— 那等于给超管开了一个「查任意租户」的入口,
  162. * 前端一改就能越权。跨租户管理如果需要,应另做受审计的运维接口。
  163. */
  164. if (isSuperAdmin) {
  165. if (!companyId) {
  166. _fail(403, '当前超管账号未归属任何组织,无法使用成员管理');
  167. return null;
  168. }
  169. return { user: user, companyId: companyId, isSuperAdmin: true };
  170. }
  171. // 公司管理员:roles 里必须有 `<companyId>_Admin`
  172. if (!companyId) {
  173. _fail(403, '当前账号不属于任何组织');
  174. return null;
  175. }
  176. var roleNames = [];
  177. try {
  178. var rq = new Parse.Query(Parse.Role);
  179. rq.equalTo('name', companyId + '_Admin');
  180. var role = await rq.first({ useMasterKey: true });
  181. if (role) {
  182. var us = await role.getUsers().query().find({ useMasterKey: true });
  183. roleNames = us.map(function (u) {
  184. return u.id;
  185. });
  186. }
  187. } catch (e) {
  188. /* 查不到角色 → 视为非管理员 */
  189. }
  190. if (roleNames.indexOf(user.id) < 0) {
  191. _fail(403, '仅公司管理员可管理成员');
  192. return null;
  193. }
  194. return { user: user, companyId: companyId, isSuperAdmin: false };
  195. }
  196. /** 用户 → 返回给前端的精简结构 */
  197. function _toItem(u) {
  198. return {
  199. id: u.id,
  200. username: _str(u.get('username'), 80),
  201. nickname: _str(u.get('nickname'), 60),
  202. mobile: _str(u.get('mobile'), 20),
  203. isDeleted: !!u.get('isDeleted'),
  204. createdAt: u.createdAt ? u.createdAt.getTime() : 0,
  205. };
  206. }
  207. /** 该用户是否在本组织内 */
  208. async function _userInCompany(userId, companyId) {
  209. try {
  210. var q = new Parse.Query(CLASS_USER);
  211. var u = await q.get(userId, { useMasterKey: true });
  212. var p = _userCompanyPtr(u);
  213. return { user: u, inCompany: !!p && p.id === companyId };
  214. } catch (e) {
  215. return { user: null, inCompany: false };
  216. }
  217. }
  218. async function opList(ctx) {
  219. var page = Math.max(1, parseInt(params.page, 10) || 1);
  220. var pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, parseInt(params.pageSize, 10) || DEFAULT_PAGE_SIZE));
  221. var keyword = _str(params.keyword, 60).trim();
  222. var companyPtr = { __type: 'Pointer', className: 'Company', objectId: ctx.companyId };
  223. function buildQuery() {
  224. var q = new Parse.Query(CLASS_USER);
  225. q.equalTo('company', companyPtr);
  226. q.notEqualTo('isDeleted', true);
  227. if (keyword) {
  228. /* 昵称 / 手机号 / objectId 三选一命中 */
  229. var byName = new Parse.Query(CLASS_USER);
  230. byName.contains('nickname', keyword);
  231. var byMobile = new Parse.Query(CLASS_USER);
  232. byMobile.contains('mobile', keyword);
  233. var byUsername = new Parse.Query(CLASS_USER);
  234. byUsername.contains('username', keyword);
  235. if (keyword.length <= 12) {
  236. var byId = new Parse.Query(CLASS_USER);
  237. byId.equalTo('objectId', keyword);
  238. q = Parse.Query.or(byName, byMobile, byUsername, byId);
  239. } else {
  240. q = Parse.Query.or(byName, byMobile, byUsername);
  241. }
  242. /* 关键词查询也要受组织约束 —— 否则能搜到别的组织的人 */
  243. q.equalTo('company', companyPtr);
  244. q.notEqualTo('isDeleted', true);
  245. }
  246. q.descending('createdAt');
  247. return q;
  248. }
  249. var total = await buildQuery().count({ useMasterKey: true });
  250. var q = buildQuery();
  251. q.skip((page - 1) * pageSize);
  252. q.limit(pageSize);
  253. var rows = await q.find({ useMasterKey: true });
  254. return _ok({
  255. items: rows.map(_toItem),
  256. total: total,
  257. page: page,
  258. pageSize: pageSize,
  259. });
  260. }
  261. async function opAdd(ctx) {
  262. var userId = _str(params.userId, 120).trim();
  263. if (!userId) return _fail(400, '请填写用户 ID');
  264. var found;
  265. try {
  266. var q = new Parse.Query(CLASS_USER);
  267. found = await q.get(userId, { useMasterKey: true });
  268. } catch (e) {
  269. return _fail(404, '用户不存在:' + userId);
  270. }
  271. if (found.get('isDeleted')) return _fail(400, '该用户已被删除,不能加入组织');
  272. var cur = _userCompanyPtr(found);
  273. if (cur && cur.id !== ctx.companyId) {
  274. return _fail(409, '该用户已属于其他组织,请先联系其原组织处理');
  275. }
  276. /* 改 `_User.company` 指针(运维口径:换组织改指针,objectId 不变) */
  277. var companyPtr = { __type: 'Pointer', className: 'Company', objectId: ctx.companyId };
  278. found.set('company', Parse.Object.fromJSON(companyPtr));
  279. await found.save(null, { useMasterKey: true });
  280. /* 补上普通成员角色(人群聊/可见性等 ACL 依赖它) */
  281. try {
  282. var rq = new Parse.Query(Parse.Role);
  283. rq.equalTo('name', ctx.companyId + '_Public');
  284. var role = await rq.first({ useMasterKey: true });
  285. if (role) await role.getUsers().add(found).save(null, { useMasterKey: true });
  286. } catch (e) {
  287. /* 角色补不上不影响主流程 */
  288. }
  289. return _ok({ item: _toItem(found) });
  290. }
  291. async function opGet(ctx) {
  292. var userId = _str(params.userId, 120).trim();
  293. if (!userId) return _fail(400, '用户 ID 不能为空');
  294. var r = await _userInCompany(userId, ctx.companyId);
  295. if (!r.user) return _fail(404, '用户不存在');
  296. if (!r.inCompany) return _fail(403, '该用户不在本组织');
  297. return _ok({ item: _toItem(r.user) });
  298. }
  299. async function opUpdate(ctx) {
  300. var userId = _str(params.userId, 120).trim();
  301. if (!userId) return _fail(400, '用户 ID 不能为空');
  302. var r = await _userInCompany(userId, ctx.companyId);
  303. if (!r.user) return _fail(404, '用户不存在');
  304. if (!r.inCompany) return _fail(403, '该用户不在本组织');
  305. var u = r.user;
  306. if (params.nickname != null) u.set('nickname', _str(params.nickname, 60));
  307. if (params.mobile != null) {
  308. var mobile = _str(params.mobile, 20).trim();
  309. if (mobile && !/^1\d{10}$/.test(mobile)) return _fail(400, '手机号格式不正确');
  310. u.set('mobile', mobile);
  311. }
  312. /* 🔴 改密码必须 useMasterKey;空串表示「不修改」 */
  313. if (params.password != null && String(params.password).length > 0) {
  314. var pwd = String(params.password);
  315. if (pwd.length < 6) return _fail(400, '密码至少 6 位');
  316. u.set('password', pwd);
  317. }
  318. await u.save(null, { useMasterKey: true });
  319. return _ok({ item: _toItem(u) });
  320. }
  321. async function opRemove(ctx) {
  322. var userId = _str(params.userId, 120).trim();
  323. if (!userId) return _fail(400, '用户 ID 不能为空');
  324. if (userId === ctx.user.id) return _fail(400, '不能删除自己');
  325. var r = await _userInCompany(userId, ctx.companyId);
  326. if (!r.user) return _fail(404, '用户不存在');
  327. if (!r.inCompany) return _fail(403, '该用户不在本组织');
  328. /*
  329. * 软删(平台铁律:删除用软删 isDeleted=true)+ 移出组织。
  330. * 不物理删除:Matrix 绑定、会话归属等仍引用该 objectId,物理删会留下悬空指针。
  331. */
  332. var u = r.user;
  333. u.set('isDeleted', true);
  334. u.unset('company');
  335. await u.save(null, { useMasterKey: true });
  336. return _ok({ removed: true });
  337. }
  338. async function opMe(ctx) {
  339. /*
  340. * 「我有没有真正的组织」—— 供前端 /pending 页与 orgGuard 判定。
  341. *
  342. * 🔴 不走管理员鉴权(`_authorize` 会 403),每个登录用户都要能调,
  343. * 所以这里单独处理:只回自己的身份与归属,不泄露任何他人数据。
  344. */
  345. var user = await _getUser(_sessionToken());
  346. if (!user) return _fail(401, 'SessionToken 无效或已过期');
  347. user = await _reloadUser(user);
  348. var ptr = _userCompanyPtr(user);
  349. var real = ptr ? !(await _isPersonalTrialOrg(ptr, user.id)) : false;
  350. var companyName = '';
  351. if (ptr) {
  352. try {
  353. var c = await new Parse.Query('Company').get(ptr.id, { useMasterKey: true });
  354. companyName = c.get('name') || '';
  355. } catch (e) {
  356. /* 忽略 */
  357. }
  358. }
  359. return _ok({
  360. userId: user.id,
  361. hasOrg: real,
  362. companyId: ptr ? ptr.id : '',
  363. companyName: companyName,
  364. });
  365. }
  366. async function handler(request, response) {
  367. try {
  368. params = params && typeof params === 'object' ? params : (request && request.body && request.body.params) || {};
  369. /* `/me` 只需登录态,不需要管理员身份 —— 必须放在 _authorize 之前 */
  370. if (params.op === 'me') return await opMe();
  371. var ctx = await _authorize();
  372. if (!ctx) return; // _authorize 已写出错误响应
  373. switch (params.op) {
  374. case 'list':
  375. return await opList(ctx);
  376. case 'add':
  377. return await opAdd(ctx);
  378. case 'get':
  379. return await opGet(ctx);
  380. case 'update':
  381. return await opUpdate(ctx);
  382. case 'remove':
  383. return await opRemove(ctx);
  384. default:
  385. return _fail(400, '未知 op: ' + params.op);
  386. }
  387. } catch (e) {
  388. return _fail(500, '用户管理服务异常: ' + ((e && e.message) || String(e)));
  389. }
  390. }