Преглед изворни кода

feat: add legacy app cloud gateway matrix

彭峰 пре 1 месец
родитељ
комит
31f548896e

+ 4 - 2
docs/migration/admin-migration-status.md

@@ -6,7 +6,7 @@
 - `ZoomLaCMS.dll` 可恢复 79 个后台控制器、至少 1,042 个公开动作;详细清单见 `legacy-admin-inventory.generated.md`。
 - 线上 Parse 应用为 `7pIbDBJmKx_main`,当前可见 109 个 Schema,其中 96 个为 SQL Server 规范化迁移目标类。
 - 核心数据已存在:`CommonModel` 92,435、`VocabularyWord` 89,620、`NodeAuth` 5,935、`PracticeRecord` 816、`CourseAppointment` 345、`DailyStudyRecord` 108、`LessonRecord` 82。
-- 迁移开始前 `Function` 表为空;本阶段已部署 8 个云函数。
+- 迁移开始前 `Function` 表为空;本阶段已部署 9 个云函数。
 
 ## 本阶段已实现
 
@@ -14,7 +14,9 @@
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。
-- 8 个线上函数均验证未认证请求返回 401;临时帐套管理员完成真实只读冒烟测试后已删除。
+- 8 个管理/CMS 函数均验证未认证请求返回 401;临时帐套管理员完成真实只读冒烟测试后已删除。
+- 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:26 个已映射,65 个显式返回 501 阻塞原因。详见 `wxapp-cloud-action-matrix.md`。
+- 旧数据的 `isDeleted` 在部分物理表与 Parse Schema 不一致,把它放入 Parse 查询条件会返回 500;网关改为公司条件查询后在结果侧兼容过滤。
 
 ## SQL 视图 / 函数替代状态
 

+ 69 - 0
docs/migration/wxapp-cloud-action-matrix.md

@@ -0,0 +1,69 @@
+# WXAPP 云函数接口迁移矩阵
+
+## 口径
+
+- 基线来自旧 uni-app 源码静态扫描生成的 `SOURCE_API_ACTIONS`,共 91 个唯一 action。
+- 统一新入口为 `POST /api/functions/xiaoshu/app/gateway`。
+- 已有云函数映射 26 个,另有 65 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。矩阵不存在未知 action。
+- “已映射”表示已有云函数代码与权限边界;完成 H5 切换前仍需逐页响应字段回归。
+
+## 已映射的 26 个 action
+
+| 领域 | action |
+|---|---|
+| 版本 | `app_update` |
+| 内容 | `content_get`, `content_list`, `content_list_llk`, `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` |
+| 学习/预约只读 | `e_get_21list`, `e_get_21list_tj`, `e_order_detail`, `e_record_detail`, `e_words_list`, `user_point_list` |
+| 反馈 | `guestbook_add` |
+
+已实测 `content_list` 在公司帐套下返回 92,435 条,`modelId=56` 返回 108 条。读取用 Psql 并强制 `company=7pIbDBJmKx`,所有客户端输入均使用参数化查询。
+
+## 因目标 Schema/历史数据缺失而阻塞
+
+| 能力 | action | 缺失项 |
+|---|---|---|
+| 购物车 | `cart_list` | 无购物车类 |
+| 优惠券 | `coupon_list`, `coupon_usrgot_add`, `coupon_usrgot_list` | 无优惠券实例和用户领取关系类 |
+| 订单/物流 | `order_delivery`, `order_get`, `order_list`, `order_signfor`, `user_shop_order`, `user_shop_sales` | 无订单、商品明细、销售和物流类 |
+| 订单评价 | `order_comment_add`, `order_comment_list` | 无评价类 |
+| 支付 | `payment_cart`, `payment_cart_again`, `payment_success` | 无订单/支付明细类 |
+| 发票 | `invoice_add`, `invoice_del`, `invoice_get`, `invoice_list`, `invoice_upd` | 无发票类 |
+| 收货地址 | `receaddr_add`, `receaddr_del`, `receaddr_get`, `receaddr_list`, `receaddr_upd` | 无地址类 |
+| 提现账户 | `user_bank_add`, `user_bank_del`, `user_bank_get`, `user_bank_list` | 无提现账户类 |
+| 收藏 | `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,并取得可迁移的旧数据;仅有空表或空成功响应不能算迁移完成。
+
+## 因外部服务、安全或合规而阻塞
+
+| 能力 | action | 需要的前置条件 |
+|---|---|---|
+| 短信验证码 | `mcode_send`, `user_login_mobile`, `user_register_mobile` | 新短信服务商凭据、验证码存储和限流 |
+| 微信授权 | `user_sync2` | 新微信容器流程和凭据 |
+| 资金/积分 | `user_cash_add`, `user_cash_list`, `user_coin_recharge`, `user_exp_transfer`, `user_money_recharge`, `user_money_transfer` | 支付凭据、不可变事务账本和业务规则 |
+| 支付密码 | `user_update_paypwd` | 独立哈希服务和重置流程 |
+| 实名认证 | `user_rnauth_add`, `user_rnauth_get`, `user_rnauth_upd` | 合规审核、加密存储和访问审计 |
+
+## 有数据但尚未能证明 1:1 语义
+
+| 能力 | action | 待确认/待实现 |
+|---|---|---|
+| 内容写入 | `content_add`, `content_update` | `CommonModel` 与 addon 表双写事务 |
+| 商品写入 | `product_del`, `product_sale_change`, `product_stock_change` | 门店管理员权限与库存账本 |
+| 课程/生词 | `e_ck_list`, `e_add_words` | `CourseBinding` + `VocabularyWord` + 用户进度联表语义 |
+| 学习写入 | `stu_record_update_v2`, `unit_record_update` | 学习主记录、addon 和单元聚合的原子写入 |
+| 预约 | `e_order_tongji`, `e_order_update_v2` | 统计口径、状态机和角色权限 |
+| 发布 | `pub_add`, `pub_list` | `Pub` 业务类型、审核与可见范围 |
+
+## 前端切换决策
+
+当前 Angular H5 仍保留旧 `WXAPP` 入口。在 65 个 action 中包含登录、订单、支付和学习写入等主流程时,直接全量切换会导致页面大面积中断。切换条件是:
+
+1. 先对齐缺失 Schema/数据和第三方凭据。
+2. 完成剩余 action 的云函数事务、权限和字段回归。
+3. 将 H5 会话改为 Parse `sessionToken`,再一次性切换到 `/api/functions/xiaoshu/app/gateway`。

+ 1 - 0
docs/schema/functions-function.md

@@ -39,5 +39,6 @@ class Function {
 | `cms.exams.classes` | `xiaoshu/cms/exams/classes` | 考试班级分页投影 |
 | `cms.guest.bar` | `xiaoshu/cms/guest/bar` | 互动社区分页投影 |
 | `cms.search` | `xiaoshu/cms/search` | 规范化内容搜索 |
+| `xiaoshu.app.gateway` | `xiaoshu/app/gateway` | 旧 H5 `WXAPP` action 的云函数兼容网关与阻塞清单 |
 
 部署使用 `XIAOSHU_MASTER_KEY` 进程环境变量;仓库和浏览器构建产物不得保存真实值。

+ 103 - 4
scripts/deploy-admin-functions.mjs

@@ -43,6 +43,7 @@ function safeValue(value, depth = 0) {
   }
   return value;
 }
+function isVisible(object) { const value = object && typeof object.get === 'function' ? object.get('isDeleted') : object && object.isDeleted; return ![true, 1, '1', 'true', 'True', 'TRUE'].includes(value); }
 async function requireAdmin(request) {
   const current = request.user || (typeof user !== 'undefined' ? user : null);
   if (!current) fail(401, '管理员会话已失效');
@@ -148,12 +149,12 @@ async function handler(request, response) {
     }
     if (operation === 'list') {
       const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20));
-      let query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); if (fields.isDeleted) query.notEqualTo('isDeleted', true);
+      let query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId);
       const search = String(input.search || '').trim();
       if (search) { const searchField = ['name','title','username','realName','mobile','sourceKey'].find((name) => fields[name] && fields[name].type === 'String'); if (searchField) query.matches(searchField, escapeRegex(search), 'i'); }
       const sort = fields[input.sort] ? String(input.sort) : fields.updatedAt ? 'updatedAt' : 'createdAt'; if (input.order === 'asc') query.ascending(sort); else query.descending(sort);
       const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize);
-      const results = await query.find({ useMasterKey: true });
+      const results = (await query.find({ useMasterKey: true })).filter(isVisible);
       return response.json({ success: true, data: { className, page, pageSize, total, results: results.map(serializeObject) } });
     }
     const objectId = String(input.objectId || '');
@@ -198,7 +199,7 @@ function fail(status, message) { const error = new Error(message); error.status
 function safe(value, depth = 0) { if (value == null || depth > 4) return value; if (Array.isArray(value)) return value.map((item) => safe(item, depth + 1)); if (value && typeof value.toJSON === 'function') return safe(value.toJSON(), depth + 1); if (typeof value === 'object') { const output = {}; for (const [key,item] of Object.entries(value)) if (!HIDDEN.test(key)) output[key] = safe(item, depth + 1); return output; } return value; }
 async function auth(request) { const current = request.user || (typeof user !== 'undefined' ? user : null); if (!current) fail(401, '需要登录'); await current.fetch({ useMasterKey: true }); const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : []; const superAdmin = current.get('adminRoleKey') === 'super-admin' || roles.includes('super-admin'); if (!(current.get('isAdmin') === true || current.get('role') === 'admin' || roles.includes('admin') || superAdmin)) fail(403, '需要管理员权限'); const company = request.company || current.get('company') || null; if (!company && !superAdmin) fail(403, '管理员未分配帐套'); return { current, company, superAdmin }; }
 async function schema(name) { try { return (await new Parse.Schema(name).get({ useMasterKey: true })).fields || {}; } catch (_) { return {}; } }
-function tenant(query, fields, context) { if (fields.company && context.company) query.equalTo('company', context.company); if (fields.isDeleted) query.notEqualTo('isDeleted', true); }
+function tenant(query, fields, context) { if (fields.company && context.company) query.equalTo('company', context.company); }
 function stripHtml(content) { return String(content == null ? '' : content).replace(/<!--[\s\S]*?-->/g, ' ').replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ').replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;|&#160;/gi, ' ').replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>').replace(/&quot;/gi, '"').replace(/&#39;|&apos;/gi, "'").replace(/\s+/g, ' ').trim(); }
 function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); }
 async function handler(request, response) {
@@ -221,9 +222,106 @@ async function handler(request, response) {
     fail(400, '不支持的 CMS 查询');
   } catch (error) { const status = Number(error.status || (error.code === 101 ? 404 : 500)); return response.status(status).json({ success: false, error: status >= 500 ? 'cloud_function_error' : 'request_rejected', message: error.message || '云函数执行失败' }); }
 }
+
 `;
 }
 
+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 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 无用户优惠券类',
+  order_comment_add: '目标 Schema 无订单评价类', order_comment_list: '目标 Schema 无订单评价类', order_delivery: '目标 Schema 无订单与物流类', order_get: '目标 Schema 无订单类', order_list: '目标 Schema 无订单类', order_signfor: '目标 Schema 无订单类',
+  payment_cart: '目标 Schema 无订单与支付明细类', payment_cart_again: '目标 Schema 无订单与支付明细类', payment_success: '目标 Schema 无支付明细类',
+  invoice_add: '目标 Schema 无发票类', invoice_del: '目标 Schema 无发票类', invoice_get: '目标 Schema 无发票类', invoice_list: '目标 Schema 无发票类', invoice_upd: '目标 Schema 无发票类',
+  receaddr_add: '目标 Schema 无收货地址类', receaddr_del: '目标 Schema 无收货地址类', receaddr_get: '目标 Schema 无收货地址类', receaddr_list: '目标 Schema 无收货地址类', receaddr_upd: '目标 Schema 无收货地址类',
+  user_bank_add: '目标 Schema 无提现账户类', user_bank_del: '目标 Schema 无提现账户类', user_bank_get: '目标 Schema 无提现账户类', user_bank_list: '目标 Schema 无提现账户类', user_cash_add: '缺少提现事务账本', user_cash_list: '缺少提现事务账本',
+  user_coin_recharge: '缺少支付凭据与事务账本', user_exp_transfer: '缺少积分事务规则确认', user_money_recharge: '缺少支付凭据与事务账本', user_money_transfer: '缺少资金事务规则确认',
+  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 无可证明的投票记录类',
+  content_add: 'content/addon 双表写入关系尚待字段级回归', content_add_zt: 'content/addon 双表写入关系尚待字段级回归', content_update: 'content/addon 双表写入关系尚待字段级回归',
+  product_del: '尚未建立门店管理员授权关系', product_stock_change: '尚未建立门店管理员授权关系', product_sale_change: '尚未建立门店管理员授权关系',
+  e_ck_list: '需重建课程、词库与用户进度联表语义', e_add_words: '需按 CourseBinding 与 VocabularyWord 关系重建生词状态', stu_record_update_v2: '需重建学习主记录与 addon 的原子写入', unit_record_update: '需确认单元进度聚合规则', e_order_update_v2: '需确认预约状态机与角色权限', e_order_tongji: '需确认预约统计口径',
+  pub_add: '需确认 Pub 业务类型与审核规则', pub_list: '需确认 Pub 业务类型与可见范围', user_dept: '目标 Schema 无可证明的用户部门关系',
+  user_rnauth_add: '实名认证需新的合规审核与敏感数据存储', user_rnauth_get: '实名认证需新的合规审核与敏感数据存储', user_rnauth_upd: '实名认证需新的合规审核与敏感数据存储',
+  product_add: '需建立门店管理员授权与商品附加表事务', product_upd: '需建立门店管理员授权与商品附加表事务'
+};
+const SENSITIVE = /(?:password|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 }; }
+function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
+function safe(value, depth = 0) { if (value == null || depth > 4) return value; if (value instanceof Date) return value.toISOString(); if (Array.isArray(value)) return value.map((item) => safe(item, depth + 1)); if (value && typeof value.toJSON === 'function') return safe(value.toJSON(), depth + 1); if (typeof value === 'object') { const output = {}; for (const [key,item] of Object.entries(value)) if (!SENSITIVE.test(key)) output[key] = safe(item, depth + 1); return output; } return value; }
+function number(value, fallback = 0) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }
+function pageInput(input) { return { index: Math.max(1, number(input.cpage || input.page, 1)), size: Math.min(100, Math.max(1, number(input.psize || input.pageSize, 20))) }; }
+function companyPointer() { return Parse.Object.createWithoutData('Company', DEFAULT_COMPANY_ID); }
+async function fieldsOf(className) { return (await new Parse.Schema(className).get({ useMasterKey: true })).fields || {}; }
+function tenant(query, fields) { if (fields.company) query.equalTo('company', companyPointer()); }
+function isVisible(row) { const value = row && typeof row.get === 'function' ? row.get('isDeleted') : row && row.isDeleted; return ![true, 1, '1', 'true', 'True', 'TRUE'].includes(value); }
+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; }
+async function findByLegacyId(className, fields, legacyId, aliases) { const query = new Parse.Query(className); tenant(query, fields); const field = aliases.find((name) => fields[name]); if (!field) return null; query.equalTo(field, number(legacyId)); return query.first({ useMasterKey: true }); }
+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 } }; }
+async function contentPage(input) {
+  const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID]; const clauses = ['"company" = $1'];
+  if (input.objectId) { values.push(String(input.objectId)); clauses.push('"objectId" = $' + values.length); }
+  if (input.generalId) { values.push(String(input.generalId)); clauses.push('CAST("generalId" AS text) = $' + values.length); }
+  const nodeIds = String(input.nodeid || input.nid || input.nodes || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0');
+  if (nodeIds.length) { values.push(nodeIds); clauses.push('CAST("nodeId" AS text) = ANY($' + values.length + '::text[])'); }
+  if (input.modelid) { values.push(String(number(input.modelid))); clauses.push('CAST("modelId" AS text) = $' + values.length); }
+  const where = clauses.join(' AND '); const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" WHERE ' + where, values);
+  const rowValues = values.concat([paging.size, (paging.index - 1) * paging.size]); const rows = await Psql.query('SELECT * FROM "CommonModel" WHERE ' + where + ' ORDER BY "updatedAt" DESC LIMIT $' + (values.length + 1) + ' OFFSET $' + (values.length + 2), rowValues);
+  const total = number(countRow.total); return { rows: rows.filter(isVisible).map(safe), 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'];
+  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);
+  return { rows: rows.filter(isVisible).map(safe), page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
+}
+async function resolveContent(input, requireOne = false) { const fields = await fieldsOf('CommonModel'); const query = new Parse.Query('CommonModel'); tenant(query, fields); const id = String(input.id || input.gid || input.generalId || ''); if (!id) { if (requireOne) fail(400, '缺少内容 ID'); return { query, fields }; } if (/^[A-Za-z0-9_-]{10,40}$/.test(id)) { try { return { object: await query.get(id, { useMasterKey: true }), fields }; } catch (_) {} } query.equalTo('generalId', number(id)); return { object: await query.first({ useMasterKey: true }), fields }; }
+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','node_list','node_get','product_list','product_get','product_stock_list','e_get_21list','e_get_21list_tj','e_record_detail','e_order_detail','e_words_list','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('请输入账号和密码'));
+      try { const loggedIn = await Parse.User.logIn(username, password); if (loggedIn.get('passwordResetRequired') === true) return response.status(403).json(reject('旧系统账号必须先重置 Parse 密码')); return response.json(envelope(legacyUser(loggedIn, true), { State: loggedIn.get('isDisabled') === true ? 0 : 1 })); } catch (_) { return response.status(401).json(reject('账号或密码错误')); }
+    }
+    if (action === 'user_register') {
+      const username = String(input.name || '').trim(); const password = String(input.passwd || ''); if (!username || password.length < 8) return response.status(400).json(reject('账号不能为空,密码至少 8 位'));
+      const created = new Parse.User(); created.setUsername(username); created.setPassword(password); created.set('company', companyPointer()); created.set('type','user'); created.set('isDisabled', false); await created.signUp(); return response.json(envelope(legacyUser(created, true), { State: 1 }));
+    }
+    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); const current = await currentUser(request, !publicRead);
+    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_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); return response.json(envelope(result.rows, undefined, result.page)); }
+    if (action === 'content_get') { const id = String(input.id || input.gid || input.generalId || ''); if (!id) return response.status(400).json(reject('缺少内容 ID')); const result = await contentPage({ page: 1, pageSize: 1, ...(/^[A-Za-z0-9_-]{10,40}$/.test(id) ? { objectId: id } : { generalId: id }) }); return result.rows[0] ? response.json(envelope(result.rows[0])) : response.status(404).json(reject('内容不存在')); }
+    if (action === 'content_uphis') { const resolved = await resolveContent(input, true); if (!resolved.object) return response.status(404).json(reject('内容不存在')); resolved.object.increment('hits', Math.max(1, number(input.num, 1))); await resolved.object.save(null, { useMasterKey: true }); return response.json(envelope({ hits: resolved.object.get('hits') })); }
+    if (action === 'content_add' || action === 'content_update') { if (!current) fail(401,'需要登录'); const resolved = action === 'content_update' ? await resolveContent(input, true) : { object: new Parse.Object('CommonModel'), fields: await fieldsOf('CommonModel') }; const object = resolved.object; if (!object) return response.status(404).json(reject('内容不存在')); const allowed = ['title','subtitle','nodeId','modelId','status','topImg','tagKey','template']; for (const name of allowed) if (input[name] !== undefined && resolved.fields[name]) object.set(name, ['nodeId','modelId','status'].includes(name) ? number(input[name]) : input[name]); object.set('company', companyPointer()); object.set('inputer', String(current.get('username') || '')); await object.save(null, { useMasterKey: true }); return response.json(envelope(safe(object))); }
+    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 = number(current && (current.get('legacyUserId') || current.get('userid') || current.get('num')));
+    const listMappings = { e_get_21list:['MemoryPracticeRecord','yhid'], e_get_21list_tj:['MemoryPracticeRecord','yhid'], e_words_list:['VocabularyWord',null], 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)); }
+    const detailMappings = { e_record_detail:['DailyStudyRecord',['id','dsid']], e_order_detail:['CourseAppointment',['id','yykcid']] };
+    if (detailMappings[action]) { const [className, aliases] = detailMappings[action]; const fields = await fieldsOf(className); const found = await findByLegacyId(className, fields, input.id, aliases); return found ? response.json(envelope(safe(found))) : response.status(404).json(reject('记录不存在')); }
+    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)); return response.status(status).json(reject(status >= 500 ? '云函数执行失败' : error.message)); }
+}
+`;
+
 const definitions = [
   { name: 'xiaoshu.admin.gateway', desc: '小树陪练 Angular 管理后台统一数据网关(权限、帐套、CRUD、统计)', path: 'xiaoshu/admin/gateway', code: adminGatewayCode, params: [{ name: 'operation', type: 'String', required: true }] },
   { name: 'cms.content-normalizer', desc: '替代 ZL_StripeHtmlTag/ZL_StripeTrimstr,保守输出纯文本', path: 'xiaoshu/cms/content-normalizer', code: cmsReadCode('content-normalizer'), params: [{ name: 'content', type: 'String', required: false }] },
@@ -233,6 +331,7 @@ const definitions = [
   { name: 'cms.exams.classes', desc: '替代 ZL_Exam_ClassView', path: 'xiaoshu/cms/exams/classes', code: cmsReadCode('exam-classes'), params: [{ name: 'page', type: 'Number', required: false, default: 1 }] },
   { name: 'cms.guest.bar', desc: '替代 ZL_Guest_BarView', path: 'xiaoshu/cms/guest/bar', code: cmsReadCode('guest-bar'), params: [{ name: 'page', type: 'Number', required: false, default: 1 }] },
   { name: 'cms.search', desc: '替代 ZL_SearchView,搜索规范化 CommonModel', path: 'xiaoshu/cms/search', code: cmsReadCode('search'), params: [{ name: 'keyword', type: 'String', required: true }] },
+  { name: 'xiaoshu.app.gateway', desc: '旧 WXAPP action 到 Parse 规范化类的兼容云函数;无法证明等价的动作返回 migration_blocked', path: 'xiaoshu/app/gateway', code: appGatewayCode, params: [{ name: 'action', type: 'String', required: true }] },
 ];
 
 function validateDefinition(definition) {
@@ -270,7 +369,7 @@ async function upsert(definition) {
     respType: 'json',
     respJson: { success: true, data: {} },
     enabled: true,
-    version: '1.0.0',
+    version: '1.0.6',
   };
   const objectId = existing.results?.[0]?.objectId;
   if (objectId) {

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

@@ -1,6 +1,7 @@
 #!/usr/bin/env node
 
 import { randomBytes } from 'node:crypto';
+import { readFile } from 'node:fs/promises';
 
 const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
 const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
@@ -36,6 +37,24 @@ async function callFunction(path, token, params) {
   return payload.data;
 }
 
+async function callLegacyFunction(token, params, expectedStatus = 200) {
+  const response = await fetch(`${FUNCTION_URL}/xiaoshu/app/gateway`, {
+    method: 'POST',
+    headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' },
+    body: JSON.stringify(token ? { token, params } : params),
+  });
+  const payload = await response.json().catch(() => ({}));
+  if (response.status !== expectedStatus) throw new Error(`app gateway ${params.action} 期望 ${expectedStatus},实际 ${response.status}: ${payload.retmsg || payload.error || ''}`);
+  return payload;
+}
+
+async function sourceActions() {
+  const source = await readFile(new URL('../src/app/core/source-parity.generated.ts', import.meta.url), 'utf8');
+  const match = source.match(/export const SOURCE_API_ACTIONS = (\[[\s\S]*?\]) as const;/);
+  if (!match) throw new Error('无法读取 SOURCE_API_ACTIONS');
+  return JSON.parse(match[1]);
+}
+
 let userId = '';
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
@@ -78,7 +97,23 @@ try {
   const guest = await callFunction('xiaoshu/cms/guest/bar', login.sessionToken, { page: 1, pageSize: 2 });
   if (!Array.isArray(guest.results)) throw new Error('互动社区投影校验失败');
 
-  console.log('Cloud smoke passed: auth, tenant scope, dashboard, schema, list, normalizer, exams, guest.');
+  const migration = await callLegacyFunction('', { action: 'migration_status' });
+  const covered = new Set([...(migration.result?.implemented || []), ...Object.keys(migration.result?.blocked || {})]);
+  const missing = (await sourceActions()).filter((action) => !covered.has(action));
+  if (missing.length) throw new Error(`app gateway 迁移矩阵缺少:${missing.join(', ')}`);
+
+  const content = await callLegacyFunction('', { action: 'content_list', page: 1, pageSize: 2 });
+  if (!Array.isArray(content.result) || content.result.length !== 2 || Number(content.page?.itemCount) < 90000) throw new Error('公开内容分页校验失败');
+  const contentExact = await callLegacyFunction('', { action: 'content_list', page: 1, pageSize: 1, objectId: content.result[0].objectId });
+  if (contentExact.result?.[0]?.objectId !== content.result[0].objectId || contentExact.page?.itemCount !== 1) throw new Error('内容 objectId 精确查询校验失败');
+
+  const profile = await callLegacyFunction(login.sessionToken, { action: 'user_get' });
+  if (profile.retcode !== 0 || profile.result?.objectId !== userId) throw new Error('app gateway 用户会话校验失败');
+
+  const blocked = await callLegacyFunction('', { action: 'cart_list' }, 501);
+  if (!String(blocked.retmsg).startsWith('migration_blocked:')) throw new Error('阻塞接口未返回 migration_blocked');
+
+  console.log('Cloud smoke passed: admin auth/tenant/CRUD reads, CMS projections, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {
   if (userId) {
     await jsonRequest(`${PARSE_URL}/users/${userId}`, { method: 'DELETE' }, true).catch((error) => {