Sfoglia il codice sorgente

feat: restore legacy team relationships

彭峰 1 mese fa
parent
commit
8c31cf4d97

+ 1 - 1
docs/migration/admin-migration-status.md

@@ -15,7 +15,7 @@
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。
 - 8 个管理/CMS 函数均验证未认证请求返回 401;临时帐套管理员完成真实只读冒烟测试后已删除。
-- 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:32 个已映射,源码清单内 59 个显式返回 501 阻塞原因;动态调用的兼容 action `content_add_zt` 也已映射,`product_add`、`product_upd` 仍显式阻塞,因此线上为 33 个已实现、61 个阻塞项。课程词库进度、学习次数/生词状态、15 段复习计划、7 类规范化内容新增/更新、预约状态/课时账本/抗遗忘批量创建及旧版统计公式均已完成;缺副表模型继续按请求明确 501。详见 `wxapp-cloud-action-matrix.md`。
+- 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:33 个已映射,源码清单内 58 个显式返回 501 阻塞原因;动态调用的兼容 action `content_add_zt` 也已映射,`product_add`、`product_upd` 仍显式阻塞,因此线上为 34 个已实现、60 个阻塞项。课程词库进度、学习次数/生词状态、直属团队/VIP 统计、陪练预约学员分组、15 段复习计划、7 类规范化内容新增/更新、预约状态/课时账本/抗遗忘批量创建及旧版统计公式均已完成;缺副表模型继续按请求明确 501。详见 `wxapp-cloud-action-matrix.md`。
 - 旧数据的 `isDeleted` 在部分物理表与 Parse Schema 不一致,把它放入 Parse 查询条件会返回 500;网关改为公司条件查询后在结果侧兼容过滤。
 - Angular H5 已开始按页回归切换:账号密码登录、版本、栏目、公开内容、8 类 addon 联表,以及学习记录详情、预约详情、生词本、21 天抗遗忘列表可走云函数;私有读取只在真实 Parse 会话下切换,其余 action 仍由显式迁移路由保留旧端点。
 

+ 8 - 6
docs/migration/wxapp-cloud-action-matrix.md

@@ -5,17 +5,17 @@
 - 基线来自旧 uni-app 源码静态扫描生成的 `SOURCE_API_ACTIONS`,共 91 个唯一 action。
 - 统一新入口为 `POST /api/functions/xiaoshu/app/gateway`。
 - 请求体统一使用 `{ "token": "<sessionToken>", "params": { "action": "...", ... } }`;执行器会占用顶层 `id`,业务参数不得放在顶层。公开接口可省略 `token`,但仍必须保留 `params`。
-- 源码清单内已有云函数映射 32 个,另有 59 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。动态调用的兼容 action `content_add_zt` 也已映射;`product_add`、`product_upd` 仍为兼容阻塞项,因此线上共 33 个已实现、61 个阻塞项。矩阵不存在未覆盖的源码 action。
+- 源码清单内已有云函数映射 33 个,另有 58 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。动态调用的兼容 action `content_add_zt` 也已映射;`product_add`、`product_upd` 仍为兼容阻塞项,因此线上共 34 个已实现、60 个阻塞项。矩阵不存在未覆盖的源码 action。
 - “已映射”表示已有云函数代码与权限边界;完成 H5 切换前仍需逐页响应字段回归。
 
-## 已映射的 29 个 action
+## 已映射 action
 
 | 领域 | action |
 |---|---|
 | 版本 | `app_update` |
 | 内容 | `content_add`, `content_get`, `content_list`, `content_list_llk`, `content_update`, `content_uphis`, `node_get`, `node_list` |
 | 商品只读 | `product_get`, `product_list`, `product_stock_list` |
-| 账号 | `user_get`, `user_info_name`, `user_list`, `user_login_passwd`, `user_register`, `user_update`, `user_update_pwd`, `user_update_pwdall`, `e_user_list` |
+| 账号/团队 | `user_get`, `user_info_name`, `user_list`, `user_dept`, `user_login_passwd`, `user_register`, `user_update`, `user_update_pwd`, `user_update_pwdall`, `e_user_list` |
 | 学习/预约 | `content_add_zt`, `e_add_words`, `e_ck_list`, `e_get_21list`, `e_order_detail`, `e_order_tongji`, `e_order_update_v2`, `e_record_detail`, `e_words_list`, `stu_record_update_v2`, `user_point_list` |
 | 反馈 | `guestbook_add` |
 
@@ -50,6 +50,9 @@
 - `e_order_tongji`:按交付包 `Pages/API/WXAPP.cshtml` 原公式恢复六个字符串字段。陪练按 `LessonRecord.jsmz` 统计课型 1/2/3,佣金分别为 20/40/40、时长分别为 0.5/1/1 小时;学员按 `CourseAppointment.szyh` 且 `dszt>10` 统计课型和时长,`t_total` 继续按旧逻辑统计 `PracticeRecord.yhid`。
 - `e_order_update_v2`:恢复 `0→10→11→20→30` 角色状态机。状态 11 以单条 PostgreSQL CTE 原子写入课时账本并更新 `_User.legacyUserData`:类型 1/2/3/4 分别扣 `Purse/SilverCoin/UserExp/UserPoint`,类型 1/2 同步扣 0.5/1 `UserPoint`;账本 `sourceKey` 作为幂等声明。随后按学习记录 `fxrl` 补齐最多 15 组 `MemoryPracticeRecord + CommonModel`,中断重试会修复缺失主记录,当前调用失败则补偿余额、账本及本次新增复习记录。
 - `e_get_21list`:以 `MemoryPracticeRecord` 为主记录,联查 Model 60、原学习记录和课程节点;学员按 `yhid`、教练收入列表按 `plid` 隔离。
+- `user_get`:从 `_User.legacyUserData` 恢复 `VIP/ParentUserID/GroupID/RegTime` 与余额字段,返回旧页面同时使用的 camelCase/PascalCase 别名;不返回 `UserPwd`、支付密码、哈希或会话字段。
+- `user_list`、`user_dept`:按迁移保留的 `legacyUserData.ParentUserID` 重建直属团队分页和 `v0-v5` 会员等级统计,并只允许本人纵向团队链或管理员查看。
+- `e_user_list`:按旧源码的 Model 54、`status=99`、`CourseAppointment.pl` 分组预约学员,不再依赖目标库中仅有 2 条的通用 `agent` 指针。
 
 迁移数据没有保存复习收入金额,也没有提供可验证的计价公式,因此 `e_get_21list_tj` 改为显式 501;收入列表仍返回复习事实,但用 `incomeRuleUnavailable=true` 标记金额不可恢复,不伪造收入。
 
@@ -68,7 +71,6 @@
 | 收藏 | `user_star_add`, `user_star_del`, `user_star_is` | 无用户收藏关系类 |
 | 会员订单 | `user_group_usr_supply`, `user_group_usr_upgrade` | 无会员购买/续费订单类 |
 | 投票 | `vote_add`, `vote_ask`, `vote_question` | 无可证明的投票记录类 |
-| 部门 | `user_dept` | 无可证明的用户部门关系 |
 
 上述接口要恢复 1:1 能力,必须先补建 Schema,并取得可迁移的旧数据;仅有空表或空成功响应不能算迁移完成。
 
@@ -90,7 +92,7 @@
 | 缺失内容副表 | `content_add` 的 Model 55、57 与 Model 59/Node 77 分支 | 数据库迁移未提供生词集合、单元聚合与连连看成绩目标类;接口逐请求返回 501 |
 | 商品写入 | `product_del`, `product_sale_change`, `product_stock_change` | 门店管理员权限与库存账本 |
 | 单元进度 | `unit_record_update` | 旧源码在接口调用前无条件 `return`;需业务方确认废弃或提供新的聚合规则 |
-| 发布 | `pub_add`, `pub_list` | `Pub` 业务类型、审核与可见范围 |
+| 发布 | `pub_add`, `pub_list` | 旧前端实际依赖 Pub 7–13(注销、供应商、商户邀请、退货、换货、结算),目标库只迁入 Pub 2–6 以及 Pub 3/4/5 对应的 `PubZXDC/PubTw/PubWTHD`;缺失业务配置、副表和历史记录,不能错写到现有问答/调查表 |
 
 ## 前端切换决策
 
@@ -98,7 +100,7 @@ Angular H5 已启用混合迁移路由:
 
 - `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。
 - `app_update`、`node_list`、`node_get` 已切换至云函数。
-- 持有真实 Parse 会话时,`content_add`、`content_add_zt`、`content_update`、`e_add_words`、`e_ck_list`、`e_order_detail`、`e_order_tongji`、`e_order_update_v2`、`e_record_detail`、`e_words_list`、`e_get_21list`、`stu_record_update_v2` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
+- 持有真实 Parse 会话时,`content_add`、`content_add_zt`、`content_update`、`e_add_words`、`e_ck_list`、`e_order_detail`、`e_order_tongji`、`e_order_update_v2`、`e_record_detail`、`e_user_list`、`e_words_list`、`e_get_21list`、`stu_record_update_v2`、`user_get`、`user_list`、`user_dept` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
 - 不含 addon 条件的 `content_list` 和 Model 52 公开词库已切换;已持有 Parse 会话的用户会把字段白名单内的 Model 53/54/56/58/59/60/61 addon 查询切到云函数,仍使用旧会话的存量账号自动保留旧端点。
 - 云函数返回同时提供规范化 camelCase 与旧系统 `GeneralID`/`NodeID`/`Title` 等字段别名。
 - `product_list` 尚未切换:当前 `Product` 类只有库存变体字段,缺少旧页依赖的商品名称、价格、图片和正文联表。

+ 47 - 6
scripts/deploy-admin-functions.mjs

@@ -244,11 +244,11 @@ const BLOCKED = {
   product_del: '尚未建立门店管理员授权关系', product_stock_change: '尚未建立门店管理员授权关系', product_sale_change: '尚未建立门店管理员授权关系',
   unit_record_update: '旧源码在调用前无条件 return;目标库也未迁移 Model 57 单元聚合副表',
   e_get_21list_tj: '迁移数据未包含复习收入金额字段或可验证的计价规则',
-  pub_add: '需确认 Pub 业务类型与审核规则', pub_list: '需确认 Pub 业务类型与可见范围', user_dept: '目标 Schema 无可证明的用户部门关系',
+  pub_add: '旧前端依赖 Pub 7-13,但目标库只迁入 Pub 2-6,且缺少注销、供应商、商户邀请、退换货与结算副表', pub_list: '旧前端依赖 Pub 7-13,但目标库只迁入 Pub 2-6,无法恢复对应历史记录与审核范围',
   user_rnauth_add: '实名认证需新的合规审核与敏感数据存储', user_rnauth_get: '实名认证需新的合规审核与敏感数据存储', user_rnauth_upd: '实名认证需新的合规审核与敏感数据存储',
   product_add: '需建立门店管理员授权与商品附加表事务', product_upd: '需建立门店管理员授权与商品附加表事务'
 };
-const SENSITIVE = /(?:password|secret|sessiontoken|masterkey|privatekey|legacyPasswordHash|adminPassword|payPassword)/i;
+const SENSITIVE = /(?:password|pwd|secret|sessiontoken|masterkey|privatekey|legacyPasswordHash|adminPassword|payPassword)/i;
 function inputOf(request) { const body = request.body || {}; return body.params && typeof body.params === 'object' ? body.params : body; }
 function envelope(result, addon, page) { const value = { retcode: 0, retmsg: '', result }; if (addon !== undefined) value.addon = addon; if (page) value.page = page; return value; }
 function reject(message) { return { retcode: -1, retmsg: message, result: null }; }
@@ -268,7 +268,21 @@ function legacyAliases(value, className) {
   }; const map = maps[className] || {}; for (const [legacy,source] of Object.entries(map)) if (row[legacy] === undefined && row[source] !== undefined) row[legacy] = row[source]; return row;
 }
 async function currentUser(request, required = true) { const current = request.user || (typeof user !== 'undefined' ? user : null); if (!current) { if (required) fail(401, '登录状态已失效'); return null; } await current.fetch({ useMasterKey: true }); const company = current.get('company'); if (company && company.id !== DEFAULT_COMPANY_ID) fail(403, '用户不属于小树英语帐套'); return current; }
-function legacyUser(current, withToken = false) { const json = safe(current); const value = { ...json, userId: number(current.get('legacyUserId') || current.get('userid') || current.get('num')), userName: String(current.get('username') || ''), honeyName: String(current.get('nickname') || current.get('nickName') || current.get('realName') || current.get('username') || ''), userFace: String(current.get('avatar') || ''), mobile: String(current.get('mobile') || current.get('phone') || ''), groupId: number(current.get('legacyGroupId')), groupName: String(current.get('roleName') || '') }; if (withToken) value.sessionToken = current.getSessionToken(); return value; }
+function objectValue(source, key) { return source && typeof source.get === 'function' ? source.get(key) : source && source[key]; }
+function legacyData(source) { const value = objectValue(source, 'legacyUserData'); return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
+function firstValue(...values) { return values.find((value) => value !== undefined && value !== null && value !== ''); }
+function legacyUser(current, withToken = false) {
+  const data = legacyData(current); const username = String(firstValue(objectValue(current,'username'),data.UserName,'') || ''); const userId = number(firstValue(objectValue(current,'legacyUserId'),objectValue(current,'userid'),objectValue(current,'num'),data.UserID)); const groupId = number(firstValue(objectValue(current,'legacyGroupId'),data.GroupID)); const honeyName = String(firstValue(objectValue(current,'nickname'),objectValue(current,'nickName'),objectValue(current,'realName'),data.HoneyName,username) || ''); const avatar = String(firstValue(objectValue(current,'avatar'),data.salt,'') || ''); const mobile = String(firstValue(objectValue(current,'mobile'),objectValue(current,'phone'),'') || ''); const parentId = number(firstValue(data.ParentUserID,objectValue(current,'puid'))); const vip = number(firstValue(data.VIP,objectValue(current,'vip')));
+  const value = { objectId:String(objectValue(current,'objectId') || current.id || ''), userId, userName:username, honeyName, userFace:avatar, mobile, groupId, groupName:String(objectValue(current,'roleName') || ''), email:String(firstValue(objectValue(current,'email'),data.Email,'') || ''), puid:parentId, vip, regTime:firstValue(data.RegTime,objectValue(current,'createdAt')), birthday:firstValue(objectValue(current,'birthday'),data.birthday,''), wechat:firstValue(objectValue(current,'wechat'),data.wechat,''), seturl:String(firstValue(data.seturl,objectValue(current,'seturl'),'') || '') };
+  if (withToken) value.sessionToken = current.getSessionToken(); return value;
+}
+function legacyUserAddon(current) {
+  const data = legacyData(current); const state = objectValue(current,'isDisabled') === true ? 0 : number(firstValue(data.State,1),1); const result = { vip:number(data.VIP), state, State:state, regTime:firstValue(data.RegTime,objectValue(current,'createdAt')), purse:number(data.Purse), silverCoin:number(data.SilverCoin), userExp:number(data.UserExp), userPoint:number(data.UserPoint), boffExp:number(data.boffExp) }; result.VIP = result.vip; result.Purse = result.purse; result.SilverCoin = result.silverCoin; result.UserExp = result.userExp; result.UserPoint = result.userPoint; return result;
+}
+function legacyUserRow(source) {
+  const data = legacyData(source); const userId = number(firstValue(source.legacyUserId,source.userid,source.num,data.UserID)); const username = String(firstValue(source.username,data.UserName,'') || ''); const honeyName = String(firstValue(source.nickname,source.nickName,source.realName,data.HoneyName,username) || ''); const groupId = number(firstValue(source.legacyGroupId,data.GroupID)); const parentId = number(firstValue(data.ParentUserID,source.puid)); const avatar = String(firstValue(source.avatar,data.salt,'') || ''); const teamSize = number(firstValue(source.__teamSize,source.TeamSize)); const vip = number(firstValue(data.VIP,source.vip)); const regTime = firstValue(data.RegTime,source.createdAt); const row = { objectId:String(source.objectId || ''), userId, userName:username, honeyName, userFace:avatar, mobile:String(firstValue(source.mobile,source.phone,'') || ''), groupId, puid:parentId, vip, regTime, teamSize, email:String(firstValue(source.email,data.Email,'') || ''), realName:String(firstValue(source.realName,data.TrueName,'') || '') };
+  return { ...row, UserID:userId, UserName:username, HoneyName:honeyName, UserFace:avatar, GroupID:groupId, ParentUserID:parentId, VIP:vip, RegTime:regTime, TeamSize:teamSize, Email:row.email, TrueName:row.realName, UserExp:number(data.UserExp), boffExp:number(data.boffExp), salt:avatar };
+}
 async function findByLegacyId(className, fields, legacyId, aliases) { const field = aliases.find((name) => fields[name]); if (!field) return null; const companyClause = fields.company ? ' AND "company" = $2' : ''; const values = fields.company ? [String(legacyId),DEFAULT_COMPANY_ID] : [String(legacyId)]; const rows = await Psql.query('SELECT "objectId" FROM "' + className + '" WHERE CAST("' + field + '" AS text) = $1' + companyClause + ' LIMIT 1', values); return rows[0] ? new Parse.Query(className).get(rows[0].objectId,{useMasterKey:true}) : null; }
 async function paged(className, input, configure) { const fields = await fieldsOf(className); const paging = pageInput(input); const query = new Parse.Query(className); tenant(query, fields); if (configure) configure(query, fields); const total = await query.count({ useMasterKey: true }); query.skip((paging.index - 1) * paging.size); query.limit(paging.size); query.descending(fields.updatedAt ? 'updatedAt' : 'createdAt'); const rows = (await query.find({ useMasterKey: true })).filter(isVisible); return { rows: rows.map(safe), page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } }; }
 const CONTENT_ADDONS = {
@@ -337,6 +351,31 @@ async function sqlPage(className, input, filters = []) {
 function parseArray(value) { if (Array.isArray(value)) return value; if (typeof value !== 'string' || !value.trim()) return []; try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch (_) { return []; } }
 function largePageInput(input) { return { index: Math.max(1, number(input.cpage || input.page, 1)), size: Math.min(500, Math.max(1, number(input.psize || input.pageSize, 20))) }; }
 function ownLegacyId(current) { return number(current && (current.get('legacyUserId') || current.get('userid') || current.get('num'))); }
+async function legacyUserObject(legacyId) { const fields = await fieldsOf('_User'); return findByLegacyId('_User', fields, legacyId, ['legacyUserId','userid','num']); }
+async function chainContains(startId, expectedAncestorId) {
+  let cursor = number(startId); const expected = number(expectedAncestorId); const visited = new Set();
+  for (let depth = 0; cursor && depth < 64 && !visited.has(cursor); depth += 1) { if (cursor === expected) return true; visited.add(cursor); const item = await legacyUserObject(cursor); if (!item) return false; cursor = number(legacyData(item).ParentUserID); }
+  return false;
+}
+async function authorizeTeamUser(current, requestedId) {
+  const ownId = ownLegacyId(current); const targetId = number(requestedId || ownId); if (!targetId) fail(400, '缺少用户 ID'); if (isAdminUser(current) || targetId === ownId) return targetId;
+  if (await chainContains(targetId, ownId)) return targetId; if (await chainContains(ownId, targetId)) return targetId; fail(403, '无权查看该团队成员');
+}
+function userSelect(alias = 'u') { return alias + '."objectId",' + alias + '."username",' + alias + '."email",' + alias + '."mobile",' + alias + '."phone",' + alias + '."avatar",' + alias + '."nickname",' + alias + '."nickName",' + alias + '."realName",' + alias + '."legacyUserId",' + alias + '."legacyGroupId",' + alias + '."legacyUserData",' + alias + '."createdAt"'; }
+async function teamUserPage(input, current) {
+  const paging = pageInput(input); const parentId = number(input.puid || ownLegacyId(current)); if (!parentId && !isAdminUser(current)) fail(400, '团队列表必须指定上级用户'); if (parentId) await authorizeTeamUser(current,parentId);
+  const values = [DEFAULT_COMPANY_ID]; const clauses = ['u."company" = $1','(u."isDeleted" IS NULL OR u."isDeleted" = FALSE)']; if (parentId) { values.push(String(parentId)); clauses.push('COALESCE(u."legacyUserData"->>\'ParentUserID\',\'0\') = $' + values.length); }
+  const groups = String(input.gids || '').split(',').map(number).filter((value) => value > 0); if (groups.length) { values.push(groups.map(String)); clauses.push('COALESCE(CAST(u."legacyGroupId" AS text),u."legacyUserData"->>\'GroupID\',\'0\') = ANY($' + values.length + '::text[])'); }
+  const where = clauses.join(' AND '); const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "_User" u WHERE ' + where, values); const rowValues = values.concat([paging.size,(paging.index - 1) * paging.size]); const order = groups.length ? 'CASE WHEN COALESCE(u."legacyUserData"->>\'UserExp\',\'0\') ~ \'^-?[0-9]+(?:\\.[0-9]+)?$\' THEN (u."legacyUserData"->>\'UserExp\')::numeric ELSE 0 END DESC,' : '';
+  const rows = await Psql.query('SELECT ' + userSelect('u') + ',(SELECT COUNT(*)::int FROM "_User" child WHERE child."company" = $1 AND COALESCE(child."legacyUserData"->>\'ParentUserID\',\'0\') = COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\',\'0\')) AS "__teamSize" FROM "_User" u WHERE ' + where + ' ORDER BY ' + order + ' CASE WHEN COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\',\'0\') ~ \'^[0-9]+$\' THEN COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\',\'0\')::numeric ELSE 0 END DESC LIMIT $' + (values.length + 1) + ' OFFSET $' + (values.length + 2), rowValues); const total = number(countRow.total);
+  return { rows:rows.map(legacyUserRow), page:{ itemCount:total, pageCount:Math.ceil(total / paging.size), pageIndex:paging.index, pageSize:paging.size } };
+}
+async function teamStats(input,current) {
+  const parentId = await authorizeTeamUser(current,input.uid); const rows = await Psql.query('SELECT COALESCE("legacyUserData"->>\'VIP\',\'0\') AS vip,COUNT(*)::int AS total FROM "_User" WHERE "company"=$1 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) AND COALESCE("legacyUserData"->>\'ParentUserID\',\'0\')=$2 GROUP BY COALESCE("legacyUserData"->>\'VIP\',\'0\')', [DEFAULT_COMPANY_ID,String(parentId)]); const result = { childs:0,v0:0,v1:0,v2:0,v3:0,v4:0,v5:0 }; for (const row of rows) { const total = number(row.total); result.childs += total; const key = 'v' + Math.max(0,Math.min(5,number(row.vip))); result[key] += total; } return result;
+}
+async function coachStudentPage(input,current) {
+  const coachId = ownLegacyId(current); if (!coachId) fail(400,'陪练账号缺少旧系统用户 ID'); const paging = pageInput(input); const baseValues = [DEFAULT_COMPANY_ID,String(coachId)]; const base = 'FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=$1 AND CAST(a."id" AS text)=CAST(c."itemId" AS text) WHERE c."company"=$1 AND CAST(c."modelId" AS text)=\'54\' AND CAST(c."status" AS text)=\'99\' AND CAST(a."pl" AS text)=$2 AND COALESCE(CAST(a."szyh" AS text),\'\')<>\'\''; const countRow = await Psql.one('SELECT COUNT(DISTINCT CAST(a."szyh" AS text))::int AS total ' + base,baseValues); const rows = await Psql.query('WITH students AS (SELECT CAST(a."szyh" AS text) AS uid,MAX(CAST(a."id" AS text)) AS appointment_id ' + base + ' GROUP BY CAST(a."szyh" AS text) ORDER BY MAX(c."updatedAt") DESC LIMIT $3 OFFSET $4) SELECT ' + userSelect('u') + ',students.uid AS "__studentId",students.appointment_id AS "__appointmentId" FROM students LEFT JOIN "_User" u ON u."company"=$1 AND CAST(u."legacyUserId" AS text)=students.uid',baseValues.concat([paging.size,(paging.index - 1) * paging.size])); const normalized = rows.map((row) => { const value = legacyUserRow(row); const studentId = number(row.__studentId || value.UserID); return { ...value, userId:studentId, UserID:studentId, szyh:studentId, ID:number(row.__appointmentId), honeyname:value.HoneyName, honeyName:value.HoneyName }; }); const total = number(countRow.total); return { rows:normalized,page:{ itemCount:total,pageCount:Math.ceil(total / paging.size),pageIndex:paging.index,pageSize:paging.size } };
+}
 async function authorizeRequestedUser(current, requestedId) {
   const targetId = number(requestedId || ownLegacyId(current)); if (!targetId) fail(400, '缺少用户 ID'); if (isAdminUser(current) || targetId === ownLegacyId(current)) return targetId;
   const fields = await fieldsOf('_User'); const target = await findByLegacyId('_User', fields, targetId, ['legacyUserId','userid','num']); const agent = target && target.get('agent'); if (!target || !agent || agent.id !== current.id) fail(403, '无权查看该学员的业务记录'); return targetId;
@@ -453,7 +492,7 @@ async function resolveContent(input, requireOne = false) { const fields = await
 async function handler(request, response) {
   try {
     const input = inputOf(request); const action = String(input.action || ''); if (!action) return response.status(400).json(reject('缺少 action'));
-    if (action === 'migration_status') return response.json(envelope({ implemented: ['app_update','user_login_passwd','user_register','user_info_name','user_get','user_list','e_user_list','user_update','user_update_pwd','user_update_pwdall','content_list','content_list_llk','content_get','content_uphis','content_add','content_add_zt','content_update','node_list','node_get','product_list','product_get','product_stock_list','e_add_words','e_ck_list','e_get_21list','e_order_detail','e_order_tongji','e_order_update_v2','e_record_detail','e_words_list','stu_record_update_v2','user_point_list','guestbook_add'], blocked: BLOCKED }));
+    if (action === 'migration_status') return response.json(envelope({ implemented: ['app_update','user_login_passwd','user_register','user_info_name','user_get','user_list','user_dept','e_user_list','user_update','user_update_pwd','user_update_pwdall','content_list','content_list_llk','content_get','content_uphis','content_add','content_add_zt','content_update','node_list','node_get','product_list','product_get','product_stock_list','e_add_words','e_ck_list','e_get_21list','e_order_detail','e_order_tongji','e_order_update_v2','e_record_detail','e_words_list','stu_record_update_v2','user_point_list','guestbook_add'], blocked: BLOCKED }));
     if (BLOCKED[action]) return response.status(501).json(reject('migration_blocked: ' + BLOCKED[action]));
     if (action === 'user_login_passwd') {
       const username = String(input.name || input.username || '').trim(); const password = String(input.passwd || input.password || ''); if (!username || !password) return response.status(400).json(reject('请输入账号和密码'));
@@ -465,8 +504,10 @@ async function handler(request, response) {
     }
     if (action === 'user_info_name') { const username = String(input.uname || input.name || '').trim(); if (!username) return response.status(400).json(reject('缺少用户名')); const rows = await Psql.query('SELECT "objectId" FROM "_User" WHERE "company" = $1 AND "username" = $2 LIMIT 1', [DEFAULT_COMPANY_ID, username]); const found = rows[0]; return found ? response.json(envelope({ objectId: found.objectId })) : response.status(404).json(reject('用户不存在')); }
     const publicRead = PUBLIC_READ.has(action) && !contentQueryRequiresAuth(action, input); const current = await currentUser(request, !publicRead); if (contentQueryRequiresAuth(action, input) && current) await authorizeContentAccess(current, input);
-    if (action === 'user_get') { const requestedId = number(input.uid || current.id); let target = current; const ownId = number(current.get('legacyUserId') || current.get('userid') || current.get('num')); if (requestedId && requestedId !== ownId) { const fields = await fieldsOf('_User'); const found = await findByLegacyId('_User', fields, requestedId, ['legacyUserId','userid','num']); if (!found) return response.status(404).json(reject('用户不存在')); const isAdmin = current.get('isAdmin') === true; const isAgentChild = found.get('agent') && found.get('agent').id === current.id; if (!isAdmin && !isAgentChild) return response.status(403).json(reject('无权查看该用户')); target = found; } return response.json(envelope(legacyUser(target), { State: target.get('isDisabled') === true ? 0 : 1 })); }
-    if (action === 'user_list' || action === 'e_user_list') { if (action === 'user_list' && current.get('isAdmin') !== true) return response.status(403).json(reject('仅管理员可查询全量用户')); const result = await paged('_User', input, (query, fields) => { const uid = number(current.get('legacyUserId') || current.get('userid')); if (action === 'e_user_list' && fields.agent) query.equalTo('agent', current); else if (fields.legacyUserId && uid) query.notEqualTo('legacyUserId', uid); }); return response.json(envelope(result.rows.map((row) => ({ ...row, userId: number(row.legacyUserId || row.userid || row.num), userName: row.username, honeyName: row.nickname || row.realName || row.username })), undefined, result.page)); }
+    if (action === 'user_get') { const requestedId = number(input.uid || ownLegacyId(current)); let target = current; if (requestedId && requestedId !== ownLegacyId(current)) { await authorizeTeamUser(current,requestedId); target = await legacyUserObject(requestedId); if (!target) return response.status(404).json(reject('用户不存在')); } return response.json(envelope(legacyUser(target),legacyUserAddon(target))); }
+    if (action === 'user_list') { const result = await teamUserPage(input,current); return response.json(envelope(result.rows,undefined,result.page)); }
+    if (action === 'user_dept') return response.json(envelope(await teamStats(input,current)));
+    if (action === 'e_user_list') { const result = await coachStudentPage(input,current); return response.json(envelope(result.rows,undefined,result.page)); }
     if (action === 'user_update' || action === 'user_update_pwd' || action === 'user_update_pwdall') { if (action !== 'user_update') { const password = String(input.passwd || input.password || input.pwd || ''); if (password.length < 8) return response.status(400).json(reject('新密码至少 8 位')); current.setPassword(password); current.set('passwordResetRequired', false); } else { const allowed = { honeyName:'nickname', nickname:'nickname', mobile:'mobile', userFace:'avatar', avatar:'avatar', realName:'realName', gender:'gender', school:'school' }; for (const [source,target] of Object.entries(allowed)) if (input[source] !== undefined) current.set(target, input[source]); } await current.save(null, { useMasterKey: true }); return response.json(envelope(legacyUser(current, action !== 'user_update'))); }
     if (action === 'app_update') { const filters = input.platform ? [{ field: 'platform', value: input.platform }] : []; const result = await sqlPage('App', { page: 1, pageSize: 1 }, filters); return response.json(envelope(result.rows[0] || null)); }
     if (action === 'content_list' || action === 'content_list_llk') { const result = await contentPage(input, !current); return response.json(envelope(result.rows, undefined, result.page)); }

+ 27 - 2
scripts/smoke-admin-functions.mjs

@@ -58,6 +58,7 @@ async function sourceActions() {
 let userId = '';
 let memberId = '';
 let memberLegacyId = 0;
+let teamChildId = '';
 let coachId = '';
 let coachLegacyId = 0;
 let temporaryAppointmentId = '';
@@ -88,9 +89,15 @@ try {
   memberLegacyId = 900000000 + Math.floor(Date.now() / 1000) % 90000000;
   const member = await jsonRequest(`${PARSE_URL}/users`, {
     method: 'POST',
-    body: JSON.stringify({ username: memberUsername, password: memberPassword, legacyUserId: memberLegacyId, legacyGroupId: 1, legacyUserData: { Purse: 2, SilverCoin: 2, UserExp: 2, UserPoint: 2 }, company }),
+    body: JSON.stringify({ username: memberUsername, password: memberPassword, legacyUserId: memberLegacyId, legacyGroupId: 1, legacyUserData: { UserID: memberLegacyId, UserName: memberUsername, HoneyName: '冒烟学员', GroupID: 1, ParentUserID: 0, VIP: 2, Purse: 2, SilverCoin: 2, UserExp: 2, UserPoint: 2 }, company }),
   }, true);
   memberId = member.objectId;
+  const childLegacyId = memberLegacyId + 2;
+  const child = await jsonRequest(`${PARSE_URL}/users`, {
+    method: 'POST',
+    body: JSON.stringify({ username: `codex_team_child_${Date.now()}`, password: randomBytes(24).toString('base64url'), legacyUserId: childLegacyId, legacyGroupId: 3, legacyUserData: { UserID: childLegacyId, UserName: `team_child_${childLegacyId}`, HoneyName: '冒烟团队成员', GroupID: 3, ParentUserID: memberLegacyId, VIP: 3, UserExp: 5, UserPwd: 'must-not-leak' }, company }),
+  }, true);
+  teamChildId = child.objectId;
   const coachUsername = `codex_coach_smoke_${Date.now()}`;
   const coachPassword = randomBytes(24).toString('base64url');
   coachLegacyId = memberLegacyId + 1;
@@ -154,7 +161,7 @@ try {
   const compatibilityActions = blockedActions.filter((action) => !sourceActionList.includes(action)).sort();
   const sourceImplemented = implementedActions.filter((action) => sourceActionList.includes(action));
   const compatibilityImplemented = implementedActions.filter((action) => !sourceActionList.includes(action)).sort();
-  if (sourceImplemented.length !== 32 || sourceBlocked.length !== 59) throw new Error(`app gateway 源 action 计数异常:${sourceImplemented.length} 已映射 / ${sourceBlocked.length} 阻塞`);
+  if (sourceImplemented.length !== 33 || sourceBlocked.length !== 58) throw new Error(`app gateway 源 action 计数异常:${sourceImplemented.length} 已映射 / ${sourceBlocked.length} 阻塞`);
   if (compatibilityImplemented.join(',') !== 'content_add_zt') throw new Error(`app gateway 已实现兼容 action 计数异常:${compatibilityImplemented.join(',')}`);
   if (compatibilityActions.join(',') !== 'product_add,product_upd') throw new Error(`app gateway 阻塞兼容 action 计数异常:${compatibilityActions.join(',')}`);
 
@@ -180,6 +187,18 @@ try {
   const appointment = await callLegacyFunction(login.sessionToken, { action: 'e_order_detail', id: appointments.result?.[0]?.GeneralID });
   if (!appointment.result?.[0]?.GeneralID || appointment.result[0].kcid === undefined) throw new Error('预约详情联查校验失败');
 
+  const memberProfile = await callLegacyFunction(memberLogin.sessionToken, { action: 'user_get', uid: memberLegacyId });
+  if (memberProfile.result?.honeyName !== '冒烟学员' || memberProfile.addon?.vip !== 2 || memberProfile.addon?.silverCoin !== 2 || JSON.stringify(memberProfile).includes('must-not-leak')) throw new Error('用户旧契约字段或敏感字段过滤异常');
+  const teamMembers = await callLegacyFunction(memberLogin.sessionToken, { action: 'user_list', puid: memberLegacyId, cpage: 1, psize: 10 });
+  if (teamMembers.page?.itemCount !== 1 || Number(teamMembers.result?.[0]?.ParentUserID) !== memberLegacyId || Number(teamMembers.result?.[0]?.VIP) !== 3) throw new Error(`团队成员列表异常:${JSON.stringify(teamMembers)}`);
+  const teamSummary = await callLegacyFunction(memberLogin.sessionToken, { action: 'user_dept', uid: memberLegacyId });
+  if (teamSummary.result?.childs !== 1 || teamSummary.result?.v3 !== 1) throw new Error(`团队会员统计异常:${JSON.stringify(teamSummary.result)}`);
+  const childProfile = await callLegacyFunction(memberLogin.sessionToken, { action: 'user_get', uid: childLegacyId });
+  if (childProfile.result?.userId !== childLegacyId || childProfile.result?.puid !== memberLegacyId || JSON.stringify(childProfile).includes('must-not-leak')) throw new Error('团队成员详情或密码字段过滤异常');
+  await callLegacyFunction(coachLogin.sessionToken, { action: 'user_get', uid: childLegacyId }, 403);
+  const coachStudents = await callLegacyFunction(coachLogin.sessionToken, { action: 'e_user_list', cpage: 1, psize: 10 });
+  if (coachStudents.page?.itemCount !== 1 || Number(coachStudents.result?.[0]?.szyh) !== memberLegacyId || coachStudents.result?.[0]?.honeyname !== '冒烟学员') throw new Error(`陪练学员分组列表异常:${JSON.stringify(coachStudents)}`);
+
   const newWords = await callLegacyFunction(login.sessionToken, { action: 'e_words_list', uid: 58, page: 1, pageSize: 2 });
   if (Number(newWords.page?.itemCount) < 1 || !newWords.result?.[0]?.detail?.[0]?.Title) throw new Error('用户生词联查校验失败');
 
@@ -321,6 +340,12 @@ try {
       process.exitCode = 1;
     });
   }
+  if (teamChildId) {
+    await jsonRequest(`${PARSE_URL}/users/${teamChildId}`, { method: 'DELETE' }, true).catch((error) => {
+      console.error(`临时团队成员清理失败:${error.message}`);
+      process.exitCode = 1;
+    });
+  }
   if (memberId) {
     await jsonRequest(`${PARSE_URL}/users/${memberId}`, { method: 'DELETE' }, true).catch((error) => {
       console.error(`临时普通用户清理失败:${error.message}`);

+ 14 - 0
src/app/core/api.service.spec.ts

@@ -80,6 +80,20 @@ describe('ApiService', () => {
     request.flush({ retcode: 0, result: [{ GeneralID: 2505, w_learned: 7 }] });
   });
 
+  it('routes team member, team statistics, and coach student reads with a Parse session', () => {
+    sessionToken = 'r:session-token';
+    api.get('user_list', { puid: 58, cpage: 1 }).subscribe();
+    api.get('user_dept', { uid: 58 }).subscribe();
+    api.get('e_user_list', { cpage: 1 }).subscribe();
+    const [members, statistics, students] = http.match(API_CONFIG.cloudFunctionUrl);
+    expect(members.request.body).toEqual({ token: 'r:session-token', params: { action: 'user_list', puid: 58, cpage: 1 } });
+    expect(statistics.request.body).toEqual({ token: 'r:session-token', params: { action: 'user_dept', uid: 58 } });
+    expect(students.request.body).toEqual({ token: 'r:session-token', params: { action: 'e_user_list', cpage: 1 } });
+    members.flush({ retcode: 0, result: [] });
+    statistics.flush({ retcode: 0, result: { childs: 0, v0: 0 } });
+    students.flush({ retcode: 0, result: [] });
+  });
+
   it('routes learning word state writes with a Parse session', () => {
     sessionToken = 'r:session-token';
     api.post('e_add_words', { uid: 58, wordsId: '2505,2503', kcid: 16, ifnew: 1 }).subscribe();

+ 4 - 0
src/app/core/cloud-action-migration.ts

@@ -21,8 +21,12 @@ const SESSION_CLOUD_ROUTED_ACTIONS = new Set([
   'e_order_tongji',
   'e_order_update_v2',
   'e_record_detail',
+  'e_user_list',
   'e_words_list',
   'stu_record_update_v2',
+  'user_dept',
+  'user_get',
+  'user_list',
 ]);
 
 const CONTENT_MODEL_FIELDS: Record<number, Set<string>> = {