Przeglądaj źródła

fix: align ledger and product migrations

彭峰 2 miesięcy temu
rodzic
commit
d545fd277b

+ 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:33 个已映射,源码清单内 58 个显式返回 501 阻塞原因;动态调用的兼容 action `content_add_zt` 也已映射,`product_add`、`product_upd` 仍显式阻塞,因此线上为 34 个已实现、60 个阻塞项。课程词库进度、学习次数/生词状态、直属团队/VIP 统计、陪练预约学员分组、15 段复习计划、7 类规范化内容新增/更新、预约状态/课时账本/抗遗忘批量创建及旧版统计公式均已完成;缺副表模型继续按请求明确 501。详见 `wxapp-cloud-action-matrix.md`。
+- 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:30 个已映射,源码清单内 61 个显式返回 501 阻塞原因;动态调用的兼容 action `content_add_zt` 也已映射,`product_add`、`product_upd` 仍显式阻塞,因此线上为 31 个已实现、63 个阻塞项。课程词库进度、六类余额/积分账本读取、学习次数/生词状态、直属团队/VIP 统计、陪练预约学员分组、15 段复习计划、7 类规范化内容新增/更新、预约状态/课时账本/抗遗忘批量创建及旧版统计公式均已完成;缺主表的商品读取已从不完整投影纠正为明确 501,其他缺副表模型继续按请求明确 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,7 +5,7 @@
 - 基线来自旧 uni-app 源码静态扫描生成的 `SOURCE_API_ACTIONS`,共 91 个唯一 action。
 - 统一新入口为 `POST /api/functions/xiaoshu/app/gateway`。
 - 请求体统一使用 `{ "token": "<sessionToken>", "params": { "action": "...", ... } }`;执行器会占用顶层 `id`,业务参数不得放在顶层。公开接口可省略 `token`,但仍必须保留 `params`。
-- 源码清单内已有云函数映射 33 个,另有 58 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。动态调用的兼容 action `content_add_zt` 也已映射;`product_add`、`product_upd` 仍为兼容阻塞项,因此线上共 34 个已实现、60 个阻塞项。矩阵不存在未覆盖的源码 action。
+- 源码清单内已有云函数映射 30 个,另有 61 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。动态调用的兼容 action `content_add_zt` 也已映射;`product_add`、`product_upd` 仍为兼容阻塞项,因此线上共 31 个已实现、63 个阻塞项。矩阵不存在未覆盖的源码 action。
 - “已映射”表示已有云函数代码与权限边界;完成 H5 切换前仍需逐页响应字段回归。
 
 ## 已映射 action
@@ -14,7 +14,6 @@
 |---|---|
 | 版本 | `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_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` |
@@ -53,6 +52,7 @@
 - `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` 指针。
+- `user_point_list`:按旧接口 `stype=1..6` 分别查询 `UserExpDomP/UserSIcon/UserExpHis/UserUserPoint/UserDummyPoint/UserCredit`,恢复 `ExpHisID/HisTime/Detail` 别名;迁移资料没有提现费率,余额日志的 `purse_fee` 明确返回 `null` 并标记规则不可用。
 
 迁移数据没有保存复习收入金额,也没有提供可验证的计价公式,因此 `e_get_21list_tj` 改为显式 501;收入列表仍返回复习事实,但用 `incomeRuleUnavailable=true` 标记金额不可恢复,不伪造收入。
 
@@ -71,6 +71,8 @@
 | 收藏 | `user_star_add`, `user_star_del`, `user_star_is` | 无用户收藏关系类 |
 | 会员订单 | `user_group_usr_supply`, `user_group_usr_upgrade` | 无会员购买/续费订单类 |
 | 投票 | `vote_add`, `vote_ask`, `vote_question` | 无可证明的投票记录类 |
+| 商品读取 | `product_get`, `product_list` | 目标 `Product` 仅迁入 `ZL_P_Product` 附表的 `id/pics/size/color/nums/isChu`;缺 `ZL_Commodities` 主表的商品名、价格、正文、所有者、销售状态和库存 |
+| 库存流水 | `product_stock_list` | 无旧 `ZL_Shop_Stock` 对应类,不能用商品附表伪装库存流水 |
 
 上述接口要恢复 1:1 能力,必须先补建 Schema,并取得可迁移的旧数据;仅有空表或空成功响应不能算迁移完成。
 
@@ -90,7 +92,7 @@
 | 能力 | action | 待确认/待实现 |
 |---|---|---|
 | 缺失内容副表 | `content_add` 的 Model 55、57 与 Model 59/Node 77 分支 | 数据库迁移未提供生词集合、单元聚合与连连看成绩目标类;接口逐请求返回 501 |
-| 商品写入 | `product_del`, `product_sale_change`, `product_stock_change` | 门店管理员权限与库存账本 |
+| 商品写入 | `product_del`, `product_sale_change`, `product_stock_change` | 缺 `ZL_Commodities` 主表、`UserID/Sales/Stock` 字段及 `ZL_Shop_Stock` 库存流水;兼容动作 `product_add/product_upd` 同样缺主副表事务目标 |
 | 单元进度 | `unit_record_update` | 旧源码在接口调用前无条件 `return`;需业务方确认废弃或提供新的聚合规则 |
 | 发布 | `pub_add`, `pub_list` | 旧前端实际依赖 Pub 7–13(注销、供应商、商户邀请、退货、换货、结算),目标库只迁入 Pub 2–6 以及 Pub 3/4/5 对应的 `PubZXDC/PubTw/PubWTHD`;缺失业务配置、副表和历史记录,不能错写到现有问答/调查表 |
 
@@ -100,12 +102,12 @@ 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_user_list`、`e_words_list`、`e_get_21list`、`stu_record_update_v2`、`user_get`、`user_list`、`user_dept` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
+- 持有真实 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`、`user_point_list` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
 - 不含 addon 条件的 `content_list` 和 Model 52 公开词库已切换;已持有 Parse 会话的用户会把字段白名单内的 Model 53/54/56/58/59/60/61 addon 查询切到云函数,仍使用旧会话的存量账号自动保留旧端点。
 - 云函数返回同时提供规范化 camelCase 与旧系统 `GeneralID`/`NodeID`/`Title` 等字段别名。
-- `product_list` 尚未切换:当前 `Product` 类只有库存变体字段,缺少旧页依赖的商品名称、价格、图片和正文联表。
+- `product_list/product_get/product_stock_list` 已从“部分映射”纠正为显式 501:当前 `Product` 只是附表,不能满足旧商品或库存流水契约。
 
-在 62 个阻塞 action 中仍包含订单、支付和预约结束等主流程时,直接全量切换会导致页面大面积中断。剩余切换条件是:
+在线 63 个阻塞 action 中仍包含订单、支付、商品和退换货等主流程时,直接全量切换会导致页面大面积中断。剩余切换条件是:
 
 1. 先对齐缺失 Schema/数据和第三方凭据。
 2. 完成剩余 action 的云函数事务、权限和字段回归。

+ 13 - 11
scripts/deploy-admin-functions.mjs

@@ -228,7 +228,7 @@ async function handler(request, response) {
 
 const appGatewayCode = String.raw`
 const DEFAULT_COMPANY_ID = '7pIbDBJmKx';
-const PUBLIC_READ = new Set(['content_list','content_get','node_list','node_get','product_list','product_get','app_update']);
+const PUBLIC_READ = new Set(['content_list','content_get','node_list','node_get','app_update']);
 const BLOCKED = {
   mcode_send: '缺少新短信服务商凭据与验证码存储', user_login_mobile: '缺少新短信验证码服务', user_register_mobile: '缺少新短信验证码服务', user_sync2: '依赖微信容器授权与新版微信凭据',
   cart_list: '目标 Schema 无购物车类', coupon_list: '目标 Schema 无优惠券实例类', coupon_usrgot_add: '目标 Schema 无用户优惠券类', coupon_usrgot_list: '目标 Schema 无用户优惠券类',
@@ -241,12 +241,13 @@ const BLOCKED = {
   user_group_usr_supply: '目标 Schema 无会员续费订单类', user_group_usr_upgrade: '目标 Schema 无会员升级订单类', user_shop_order: '目标 Schema 无订单类', user_shop_sales: '目标 Schema 无销售明细类',
   user_star_add: '目标 Schema 无收藏关系类', user_star_del: '目标 Schema 无收藏关系类', user_star_is: '目标 Schema 无收藏关系类', user_update_paypwd: '需要独立支付密码哈希服务',
   vote_add: '目标 Schema 无可证明的投票记录类', vote_ask: '目标 Schema 无可证明的投票记录类', vote_question: '目标 Schema 无可证明的投票记录类',
-  product_del: '尚未建立门店管理员授权关系', product_stock_change: '尚未建立门店管理员授权关系', product_sale_change: '尚未建立门店管理员授权关系',
+  product_list: '目标 Product 仅是 ZL_P_Product 附表,缺少 ZL_Commodities 商品主表', product_get: '目标 Product 仅是 ZL_P_Product 附表,缺少商品名称、价格、正文、所有者与主键关系', product_stock_list: '目标 Schema 无 ZL_Shop_Stock 库存流水类',
+  product_del: '缺少 ZL_Commodities 主表及其 UserID 所有权字段', product_stock_change: '缺少 ZL_Commodities 主表、当前库存与 ZL_Shop_Stock 流水类', product_sale_change: '缺少 ZL_Commodities 主表及 Sales/UserID 字段',
   unit_record_update: '旧源码在调用前无条件 return;目标库也未迁移 Model 57 单元聚合副表',
   e_get_21list_tj: '迁移数据未包含复习收入金额字段或可验证的计价规则',
   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: '需建立门店管理员授权与商品附加表事务'
+  product_add: '缺少 ZL_Commodities 商品主表,无法与 Product 附表进行事务新增', product_upd: '缺少 ZL_Commodities 商品主表,无法恢复主表与附表事务更新'
 };
 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; }
@@ -264,7 +265,7 @@ function legacyAliases(value, className) {
   const row = safe(value); const maps = {
     CommonModel: { GeneralID:'generalId', OrderID:'orderId', NodeID:'nodeId', ModelID:'modelId', ItemID:'itemId', TableName:'tableName', Title:'title', Inputer:'inputer', Hits:'hits', CreateTime:'createTime', Status:'status', TopImg:'topImg', Subtitle:'subtitle' },
     Node: { NodeID:'nodeId', NodeName:'nodeName', NodeType:'nodeType', NodeDir:'nodeDir', NodeUrl:'nodeUrl', ParentID:'parentId', OrderID:'orderId', NodePic:'nodePicUrl', Description:'description' },
-    Product: { ID:'id', Stock:'nums' }, App: { ID:'id' }
+    App: { ID:'id' }
   }; 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; }
@@ -342,7 +343,7 @@ async function contentPage(input, publicOnly = false) {
   return { rows: normalized, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
 }
 async function sqlPage(className, input, filters = []) {
-  if (!['App','Node','Product'].includes(className)) fail(400, '不允许查询该类'); const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID]; const clauses = ['"company" = $1'];
+  if (!['App','Node'].includes(className)) fail(400, '不允许查询该类'); const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID]; const clauses = ['"company" = $1'];
   for (const filter of filters) { values.push(String(filter.value)); clauses.push('CAST("' + filter.field + '" AS text) = $' + values.length); }
   const where = clauses.join(' AND '); const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "' + className + '" WHERE ' + where, values); const rowValues = values.concat([paging.size, (paging.index - 1) * paging.size]);
   const rows = await Psql.query('SELECT * FROM "' + className + '" WHERE ' + where + ' ORDER BY "updatedAt" DESC LIMIT $' + (values.length + 1) + ' OFFSET $' + (values.length + 2), rowValues); const total = number(countRow.total);
@@ -376,6 +377,11 @@ async function teamStats(input,current) {
 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 } };
 }
+const POINT_HISTORY_CLASSES = { 1:'UserExpDomP',2:'UserSIcon',3:'UserExpHis',4:'UserUserPoint',5:'UserDummyPoint',6:'UserCredit' };
+function legacyPointRow(source) { const row = safe(source); row.ExpHisID = firstValue(row.expHisId,row.objectId); row.UserID = number(row.userId); row.HisTime = firstValue(row.hisTime,row.createdAt); row.Score = number(row.score); row.Detail = String(firstValue(row.detail,row.remark,'') || ''); row.Remark = String(row.remark || ''); row.score_before = number(row.scoreBefore); return row; }
+async function pointHistoryPage(input,current) {
+  const stype = number(input.stype); const className = POINT_HISTORY_CLASSES[stype]; if (!className) fail(400,'虚拟币类型必须为 1-6'); const uid = await authorizeRequestedUser(current,input.uid); const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID,String(uid)]; const where = '"company"=$1 AND CAST("userId" AS text)=$2'; const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "' + className + '" WHERE ' + where,values); const rows = await Psql.query('SELECT * FROM "' + className + '" WHERE ' + where + ' ORDER BY COALESCE("hisTime","updatedAt","createdAt") DESC LIMIT $3 OFFSET $4',values.concat([paging.size,(paging.index - 1) * paging.size])); const total = number(countRow.total); return { rows:rows.filter(isVisible).map(legacyPointRow),page:{ itemCount:total,pageCount:Math.ceil(total / paging.size),pageIndex:paging.index,pageSize:paging.size },addon:stype === 1 ? { purse_fee:null,purseFeeRuleUnavailable:true } : {} };
+}
 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;
@@ -492,7 +498,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','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 (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','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('请输入账号和密码'));
@@ -517,9 +523,6 @@ async function handler(request, response) {
     if (action === 'content_update') return response.json(envelope(await updateContentPair(input, current)));
     if (action === 'node_list') { const filters = input.pid !== undefined ? [{ field: 'parentId', value: number(input.pid) }] : []; const result = await sqlPage('Node', input, filters); return response.json(envelope(result.rows, undefined, result.page)); }
     if (action === 'node_get') { const result = await sqlPage('Node', { page: 1, pageSize: 1 }, [{ field: 'nodeId', value: number(input.id || input.nid) }]); return result.rows[0] ? response.json(envelope(result.rows[0])) : response.status(404).json(reject('栏目不存在')); }
-    if (action === 'product_list' || action === 'product_stock_list') { const result = await sqlPage('Product', input); return response.json(envelope(result.rows, undefined, result.page)); }
-    if (action === 'product_get') { const result = await sqlPage('Product', { page: 1, pageSize: 1 }, [{ field: 'id', value: number(input.id) }]); return result.rows[0] ? response.json(envelope(result.rows[0])) : response.status(404).json(reject('商品不存在')); }
-    if (action === 'product_del' || action === 'product_stock_change' || action === 'product_sale_change') { const fields = await fieldsOf('Product'); const found = await findByLegacyId('Product', fields, input.id, ['id']); if (!found) return response.status(404).json(reject('商品不存在')); if (action === 'product_del') await found.destroy({ useMasterKey: true }); else { const field = action === 'product_stock_change' ? 'nums' : 'isChu'; if (fields[field]) found.set(field, action === 'product_stock_change' ? number(input.nums || input.stock) : Boolean(input.value ?? input.status)); await found.save(null, { useMasterKey: true }); } return response.json(envelope({ objectId: found.id })); }
     const legacyId = ownLegacyId(current);
     if (action === 'content_add_zt') return response.json(envelope(await createStudyContent(input, current)));
     if (action === 'e_order_update_v2') return response.json(envelope(await updateOrderStatus(input,current)));
@@ -531,8 +534,7 @@ async function handler(request, response) {
     if (action === 'e_words_list') { const result = await newWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
     if (action === 'e_order_detail') { const found = await orderDetailRow(input.id, current); return found ? response.json(envelope([found])) : response.status(404).json(reject('预约记录不存在')); }
     if (action === 'e_record_detail') { const found = await recordDetailData(input, current); return found ? response.json(envelope(found.words, found.addon)) : response.status(404).json(reject('学习记录不存在')); }
-    const listMappings = { user_point_list:['UserUserPoint','userId'] };
-    if (listMappings[action]) { const [className,userField] = listMappings[action]; const result = await paged(className, input, (query, fields) => { if (userField && fields[userField]) query.equalTo(userField, legacyId); if (input.kcid && fields.kcid) query.equalTo('kcid', number(input.kcid)); }); return response.json(envelope(result.rows, undefined, result.page)); }
+    if (action === 'user_point_list') { const result = await pointHistoryPage(input,current); return response.json(envelope(result.rows,result.addon,result.page)); }
     if (action === 'guestbook_add') { const fields = await fieldsOf('Guestbook'); const item = new Parse.Object('Guestbook'); item.set('company',companyPointer()); if (fields.userid) item.set('userid',legacyId); if (fields.title) item.set('title',String(input.title || '用户反馈')); if (fields.tcontent) item.set('tcontent',String(input.content || input.tcontent || '')); if (fields.status) item.set('status',0); await item.save(null,{useMasterKey:true}); return response.json(envelope(safe(item))); }
     return response.status(501).json(reject('migration_blocked: 尚未完成动作映射 ' + action));
   } catch (error) { const status = Number(error.status || (error.code === 101 ? 404 : 500)); const message = status === 501 ? 'migration_blocked: ' + error.message : status >= 500 ? '云函数执行失败' : error.message; return response.status(status).json(reject(message)); }

+ 8 - 1
scripts/smoke-admin-functions.mjs

@@ -161,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 !== 33 || sourceBlocked.length !== 58) throw new Error(`app gateway 源 action 计数异常:${sourceImplemented.length} 已映射 / ${sourceBlocked.length} 阻塞`);
+  if (sourceImplemented.length !== 30 || sourceBlocked.length !== 61) 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(',')}`);
 
@@ -270,6 +270,11 @@ try {
     jsonRequest(`${PARSE_URL}/classes/CommonModel?where=${sideEffectWhere}&count=1&limit=0`, {}, true),
   ]);
   if (periodLogs.count !== 1 || pointLogs.count !== 1 || memoryRows.count !== 2 || memoryCommonRows.count !== 2) throw new Error(`预约结束账本/抗遗忘数量异常:${periodLogs.count}/${pointLogs.count}/${memoryRows.count}/${memoryCommonRows.count}`);
+  const purseHistory = await callLegacyFunction(memberLogin.sessionToken, { action: 'user_point_list', uid: memberLegacyId, stype: 1, cpage: 1, psize: 10 });
+  if (purseHistory.page?.itemCount !== 1 || Number(purseHistory.result?.[0]?.score) !== -1 || !purseHistory.result?.[0]?.ExpHisID || purseHistory.result?.[0]?.Detail === undefined || purseHistory.addon?.purseFeeRuleUnavailable !== true) throw new Error(`余额账本读取异常:${JSON.stringify(purseHistory)}`);
+  const couponHistory = await callLegacyFunction(memberLogin.sessionToken, { action: 'user_point_list', uid: memberLegacyId, stype: 4, cpage: 1, psize: 10 });
+  if (couponHistory.page?.itemCount !== 1 || Number(couponHistory.result?.[0]?.score) !== -0.5 || !couponHistory.result?.[0]?.HisTime) throw new Error(`点券账本读取异常:${JSON.stringify(couponHistory)}`);
+  await callLegacyFunction(memberLogin.sessionToken, { action: 'user_point_list', uid: memberLegacyId, stype: 7 }, 400);
   await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 20, content: orderContent }, 403);
   await callLegacyFunction(memberLogin.sessionToken, { action: 'e_order_update_v2', uid: memberLegacyId, status: 20, content: orderContent });
   await callLegacyFunction(coachLogin.sessionToken, { action: 'e_order_update_v2', uid: coachLegacyId, status: 30, content: orderContent });
@@ -289,6 +294,8 @@ try {
 
   const blocked = await callLegacyFunction('', { action: 'cart_list' }, 501);
   if (!String(blocked.retmsg).startsWith('migration_blocked:')) throw new Error('阻塞接口未返回 migration_blocked');
+  const productBlocked = await callLegacyFunction('', { action: 'product_list' }, 501);
+  if (!String(productBlocked.retmsg).includes('ZL_Commodities')) throw new Error('缺商品主表的读取接口未明确阻塞');
 
   console.log('Cloud smoke passed: admin auth/tenant/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {

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

@@ -94,6 +94,14 @@ describe('ApiService', () => {
     students.flush({ retcode: 0, result: [] });
   });
 
+  it('routes typed virtual-currency history reads with a Parse session', () => {
+    sessionToken = 'r:session-token';
+    api.get('user_point_list', { uid: 58, stype: 3, cpage: 1 }).subscribe();
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'user_point_list', uid: 58, stype: 3, cpage: 1 } });
+    request.flush({ retcode: 0, result: [{ ExpHisID: 1, score: 2 }] });
+  });
+
   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();

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

@@ -27,6 +27,7 @@ const SESSION_CLOUD_ROUTED_ACTIONS = new Set([
   'user_dept',
   'user_get',
   'user_list',
+  'user_point_list',
 ]);
 
 const CONTENT_MODEL_FIELDS: Record<number, Set<string>> = {