Ver código fonte

feat: migrate normalized content addon reads

彭峰 2 meses atrás
pai
commit
5544b3124c

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

@@ -17,7 +17,7 @@
 - 8 个管理/CMS 函数均验证未认证请求返回 401;临时帐套管理员完成真实只读冒烟测试后已删除。
 - 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:26 个已映射,65 个显式返回 501 阻塞原因。详见 `wxapp-cloud-action-matrix.md`。
 - 旧数据的 `isDeleted` 在部分物理表与 Parse Schema 不一致,把它放入 Parse 查询条件会返回 500;网关改为公司条件查询后在结果侧兼容过滤。
-- Angular H5 已开始按页回归切换:账号密码登录、版本、栏目和无 addon 条件的内容列表已走云函数,其余 action 仍由显式迁移路由保留旧端点。
+- Angular H5 已开始按页回归切换:账号密码登录、版本、栏目、公开内容及 8 类已映射 addon 联表可走云函数,其余 action 仍由显式迁移路由保留旧端点。
 
 ## SQL 视图 / 函数替代状态
 

+ 16 - 1
docs/migration/wxapp-cloud-action-matrix.md

@@ -21,6 +21,21 @@
 
 已实测 `content_list` 在公司帐套下返回 92,435 条,`modelId=56` 返回 108 条。读取用 Psql 并强制 `company=7pIbDBJmKx`,所有客户端输入均使用参数化查询。
 
+`content_list` 已按 `CommonModel.itemId = addon.id` 重建下列规范化联表:
+
+| ModelID | 旧表 | 规范化类 | 已核验数量 |
+|---:|---|---|---:|
+| 52 | `ZL_C_ck` | `VocabularyWord` | 89,620 |
+| 53 | `ZL_C_lxjl` | `PracticeRecord` | 816 |
+| 54 | `ZL_C_order` | `CourseAppointment` | 345 |
+| 56 | `ZL_C_ss` | `DailyStudyRecord` | 108 |
+| 58 | `ZL_C_kcbd` | `CourseBinding` | 55 |
+| 59 | `ZL_C_skjl` | `LessonRecord` | 82 |
+| 60 | `ZL_C_gywjl` | `MemoryPracticeRecord` | 1,380 |
+| 61 | `ZL_C_cpda` | `AssessmentProfile` | 14 |
+
+`myfield`/`myfield2` 只允许白名单字段和参数化等值过滤,`orders` 只允许已映射字段的 `ASC`/`DESC` 或旧 `NEWID()` 随机排序。Model 53/54/56/58/59/60/61 的列表与详情禁止匿名读取,非管理员仅能访问本人或指派给自己的学员记录。
+
 ## 因目标 Schema/历史数据缺失而阻塞
 
 | 能力 | action | 缺失项 |
@@ -67,7 +82,7 @@ Angular H5 已启用混合迁移路由:
 
 - `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。
 - `app_update`、`node_list`、`node_get` 已切换至云函数。
-- 不含 `modelId`/`myfield`/`myfield2` addon 联表条件的 `content_list` 已切换;学习和预约页的 addon 查询仍保留旧端点。
+- 不含 addon 条件的 `content_list` 和 Model 52 公开词库已切换;已持有 Parse 会话的用户会把字段白名单内的 Model 53/54/56/58/59/60/61 addon 查询切到云函数,仍使用旧会话的存量账号自动保留旧端点。
 - 云函数返回同时提供规范化 camelCase 与旧系统 `GeneralID`/`NodeID`/`Title` 等字段别名。
 - `product_list` 尚未切换:当前 `Product` 类只有库存变体字段,缺少旧页依赖的商品名称、价格、图片和正文联表。
 

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

@@ -271,16 +271,57 @@ async function currentUser(request, required = true) { const current = request.u
 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 CONTENT_ADDONS = {
+  52: { className:'VocabularyWord', fields:['id','lj','sy','yb','yp'] },
+  53: { className:'PracticeRecord', fields:['id','kcid','scid','xxcs','yhid','jrscb'] },
+  54: { className:'CourseAppointment', fields:['id','pl','bxrq','dslx','dszt','fxpl','jffs','jssj','kcid','kssj','plxm','scsj','sdsd','szmd','szyh','yysj','yykcid'] },
+  56: { className:'DailyStudyRecord', fields:['id','pl','con','djq','ygg','dqrq','dsid','fxrl','xxqs','szmdid','userId','learned'] },
+  58: { className:'CourseBinding', fields:['id','yxx','cksl','kcid','syjd','yhid'] },
+  59: { className:'LessonRecord', fields:['id','pf','jffs','jsmz','kcid','kclx','kcmc','kzsj','pjnr','pldp','xymz','yyds','plpjsj','szmdid'] },
+  60: { className:'MemoryPracticeRecord', fields:['id','fxzt','kcid','plid','wcsj','yhid','kywrq','kywsj','xxjlid','orderId'] },
+  61: { className:'AssessmentProfile', fields:['id','df','askid','wrong','userId','answerid','dontKnow','prevScore','totalScore'] }
+};
+const NODE_MODELS = { 28:58, 29:54, 32:53, 291:56, 296:59, 327:52, 388:60, 389:61 };
+const PRIVATE_CONTENT_MODELS = new Set([53,54,56,58,59,60,61]);
+function contentModel(input, nodeIds) { const explicit = number(input.modelId || input.modelid || input.ModelID); return explicit || (nodeIds.length === 1 ? NODE_MODELS[number(nodeIds[0])] || 0 : 0); }
+function requestedContentModel(input) { const nodes = String(input.nodeid || input.nid || input.nodes || '').split(',').map(number).filter(Boolean); return contentModel(input, nodes); }
+function legacyFilterPairs(input) {
+  const source = [input.myfield, input.myfield2].filter(Boolean).join('|'); if (!source) return [];
+  return source.split('|').map((part) => { const match = String(part).trim().match(/^([A-Za-z][A-Za-z0-9_.]*)=(.*)$/); if (!match) fail(400, '内容过滤条件格式错误'); return { name: match[1].replace(/^B\./i,''), value: match[2] }; });
+}
+function addonField(config, requested) { return config && config.fields.find((field) => field.toLowerCase() === String(requested).toLowerCase()); }
+function contentQueryRequiresAuth(action, input) { return (action === 'content_list' || action === 'content_list_llk') && (PRIVATE_CONTENT_MODELS.has(requestedContentModel(input)) || legacyFilterPairs(input).length > 0); }
+async function authorizeContentAccess(current, input) {
+  if (!PRIVATE_CONTENT_MODELS.has(requestedContentModel(input))) return; const pairs = legacyFilterPairs(input); await authorizeOwnerPairs(current, pairs.map((pair) => ({ name: pair.name.toLowerCase(), value: number(pair.value) })).filter((pair) => pair.value));
+}
+function isAdminUser(current) { const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : []; return current.get('isAdmin') === true || current.get('role') === 'admin' || roles.includes('admin'); }
+async function authorizeOwnerPairs(current, pairs) {
+  if (isAdminUser(current)) return; const owners = pairs.filter((pair) => ['userid','yhid','xymz','jsmz','szyh'].includes(pair.name)); if (!owners.length) fail(403, '私有内容查询必须限定用户');
+  const ownId = number(current.get('legacyUserId') || current.get('userid') || current.get('num')); const subjects = owners.filter((pair) => ['userid','yhid','xymz'].includes(pair.name)); if (subjects.some((pair) => pair.value === ownId)) return;
+  const targets = subjects.length ? subjects : owners; const fields = await fieldsOf('_User'); for (const targetId of [...new Set(targets.map((pair) => pair.value))]) { if (targetId === ownId) continue; 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, '无权查看该学员的内容记录'); }
+}
+async function authorizeContentDetail(current, detail) { const pairs = ['userId','yhid','xymz','jsmz','szyh'].map((name) => ({ name: name.toLowerCase(), value: number(detail[name] ?? detail[name === 'userId' ? 'UserID' : name]) })).filter((pair) => pair.value); await authorizeOwnerPairs(current, pairs); }
+function contentOrder(input, config) {
+  const raw = String(input.orders || '').trim(); if (!raw) return 'c."updatedAt" DESC'; if (/^NEWID\(\)$/i.test(raw)) return 'RANDOM()';
+  const match = raw.match(/^([A-Za-z][A-Za-z0-9_.]*)\s*=\s*(ASC|DESC)$/i); if (!match) return 'c."updatedAt" DESC'; const requested = match[1].replace(/^B\./i,''); const direction = match[2].toUpperCase();
+  const addon = addonField(config, requested); if (addon) return 'a."' + addon + '" ' + direction; const base = { id:'itemId', generalid:'generalId', createtime:'createTime', updatedat:'updatedAt', title:'title' }[requested.toLowerCase()]; return base ? 'c."' + base + '" ' + direction : 'c."updatedAt" DESC';
+}
+async function contentPage(input, publicOnly = false) {
+  const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID]; const clauses = ['c."company" = $1'];
+  if (input.objectId) { values.push(String(input.objectId)); clauses.push('c."objectId" = $' + values.length); }
+  if (input.generalId) { values.push(String(input.generalId)); clauses.push('CAST(c."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((row) => legacyAliases(row, 'CommonModel')), page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
+  if (nodeIds.length) { values.push(nodeIds); clauses.push('CAST(c."nodeId" AS text) = ANY($' + values.length + '::text[])'); }
+  const modelId = contentModel(input, nodeIds); const config = CONTENT_ADDONS[modelId] || null; if (modelId) { values.push(String(modelId)); clauses.push('CAST(c."modelId" AS text) = $' + values.length); }
+  if (publicOnly && !modelId) { values.push([...PRIVATE_CONTENT_MODELS].map(String)); clauses.push('CAST(c."modelId" AS text) <> ALL($' + values.length + '::text[])'); }
+  const filters = legacyFilterPairs(input); if (filters.length && !config) fail(501, '该内容模型的 addon 联表尚未映射');
+  for (const filter of filters) { const field = addonField(config, filter.name); if (!field) fail(501, 'addon 字段尚未映射: ' + filter.name); values.push(String(filter.value)); clauses.push('CAST(a."' + field + '" AS text) = $' + values.length); }
+  if (input.skey) { values.push('%' + String(input.skey).replace(/[%_]/g, '') + '%'); clauses.push('c."title" ILIKE $' + values.length); }
+  const join = config ? ' LEFT JOIN "' + config.className + '" a ON a."company" = $1 AND CAST(a."id" AS text) = CAST(c."itemId" AS text)' : ''; const where = clauses.join(' AND ');
+  const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c' + join + ' WHERE ' + where, values); const rowValues = values.concat([paging.size, (paging.index - 1) * paging.size]);
+  const select = config ? 'c.*, row_to_json(a) AS "__addon"' : 'c.*'; const rows = await Psql.query('SELECT ' + select + ' FROM "CommonModel" c' + join + ' WHERE ' + where + ' ORDER BY ' + contentOrder(input, config) + ' LIMIT $' + (values.length + 1) + ' OFFSET $' + (values.length + 2), rowValues);
+  const normalized = rows.filter(isVisible).map((source) => { const addon = source.__addon || {}; const row = { ...source, ...addon }; delete row.__addon; if (source.objectId) row.objectId = source.objectId; return legacyAliases(row, 'CommonModel'); }); 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 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'];
@@ -304,13 +345,13 @@ async function handler(request, response) {
       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);
+    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_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_list' || action === 'content_list_llk') { const result = await contentPage(input, !current); 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 identity = /^[A-Za-z0-9_-]{10,40}$/.test(id) ? { objectId: id } : { generalId: id }; const base = await contentPage({ page: 1, pageSize: 1, ...identity }); let detail = base.rows[0]; if (!detail) return response.status(404).json(reject('内容不存在')); const detailModel = number(detail.modelId || detail.ModelID); if (CONTENT_ADDONS[detailModel]) detail = (await contentPage({ page: 1, pageSize: 1, modelId: detailModel, ...identity })).rows[0] || detail; if (PRIVATE_CONTENT_MODELS.has(detailModel)) { if (!current) return response.status(401).json(reject('该内容详情需要登录')); await authorizeContentDetail(current, detail); } return response.json(envelope(detail)); }
     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)); }
@@ -376,7 +417,7 @@ async function upsert(definition) {
     respType: 'json',
     respJson: { success: true, data: {} },
     enabled: true,
-    version: '1.0.9',
+    version: '1.1.2',
   };
   const objectId = existing.results?.[0]?.objectId;
   if (objectId) {

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

@@ -56,6 +56,7 @@ async function sourceActions() {
 }
 
 let userId = '';
+let memberId = '';
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
   const companyId = sample.results?.[0]?.objectId;
@@ -76,6 +77,19 @@ try {
   });
   if (!login.sessionToken) throw new Error('临时管理员登录未返回 sessionToken');
 
+  const memberUsername = `codex_member_smoke_${Date.now()}`;
+  const memberPassword = randomBytes(24).toString('base64url');
+  const 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, company }),
+  }, true);
+  memberId = member.objectId;
+  const memberLogin = await jsonRequest(`${PARSE_URL}/login`, {
+    method: 'POST',
+    body: JSON.stringify({ username: memberUsername, password: memberPassword }),
+  });
+
   const meta = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'meta' });
   if (meta.identity?.objectId !== userId || !meta.cloudFunctions) throw new Error('管理员 meta 校验失败');
 
@@ -103,12 +117,25 @@ try {
   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('公开内容分页校验失败');
+  if (!Array.isArray(content.result) || content.result.length !== 2 || Number(content.page?.itemCount) < 89000) 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 contentDetail = await callLegacyFunction('', { action: 'content_get', id: content.result[0].objectId });
   if (contentDetail.result?.objectId !== content.result[0].objectId) throw new Error('内容详情 ID 校验失败');
 
+  const words = await callLegacyFunction('', { action: 'content_list', modelId: 52, page: 1, pageSize: 1 });
+  if (Number(words.page?.itemCount) < 89000 || !words.result?.[0]?.GeneralID || words.result?.[0]?.sy === undefined) throw new Error('词库 addon 联表校验失败');
+
+  const privateContent = await callLegacyFunction('', { action: 'content_list', modelId: 56, page: 1, pageSize: 1 }, 401);
+  if (!String(privateContent.retmsg).includes('登录')) throw new Error('学习记录未阻止匿名访问');
+  const learning = await callLegacyFunction(login.sessionToken, { action: 'content_list', modelId: 56, page: 1, pageSize: 1 });
+  if (!learning.result?.[0]?.GeneralID || learning.result?.[0]?.xxqs === undefined) throw new Error('学习记录 addon 联表校验失败');
+
+  const ownEmpty = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_list', modelId: 56, myfield2: `UserId=${memberLegacyId}`, page: 1, pageSize: 1 });
+  if (!Array.isArray(ownEmpty.result) || ownEmpty.page?.itemCount !== 0) throw new Error('普通用户本人记录权限校验失败');
+  const denied = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_list', modelId: 56, myfield2: 'UserId=3', page: 1, pageSize: 1 }, 403);
+  if (!String(denied.retmsg).includes('无权')) throw new Error('普通用户跨用户记录未被拒绝');
+
   const profile = await callLegacyFunction(login.sessionToken, { action: 'user_get' });
   if (profile.retcode !== 0 || profile.result?.objectId !== userId) throw new Error('app gateway 用户会话校验失败');
 
@@ -117,6 +144,12 @@ try {
 
   console.log('Cloud smoke passed: admin auth/tenant/CRUD reads, CMS projections, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {
+  if (memberId) {
+    await jsonRequest(`${PARSE_URL}/users/${memberId}`, { method: 'DELETE' }, true).catch((error) => {
+      console.error(`临时普通用户清理失败:${error.message}`);
+      process.exitCode = 1;
+    });
+  }
   if (userId) {
     await jsonRequest(`${PARSE_URL}/users/${userId}`, { method: 'DELETE' }, true).catch((error) => {
       console.error(`临时管理员清理失败:${error.message}`);

+ 20 - 2
src/app/core/api.service.spec.ts

@@ -3,13 +3,16 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common
 import { TestBed } from '@angular/core/testing';
 import { API_CONFIG } from './app.constants';
 import { ApiError, ApiService } from './api.service';
+import { SessionService } from './session.service';
 
 describe('ApiService', () => {
   let api: ApiService;
   let http: HttpTestingController;
+  let sessionToken = '';
 
   beforeEach(() => {
-    TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
+    sessionToken = '';
+    TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting(), { provide: SessionService, useValue: { token: () => sessionToken } }] });
     api = TestBed.inject(ApiService);
     http = TestBed.inject(HttpTestingController);
   });
@@ -42,7 +45,7 @@ describe('ApiService', () => {
     request.flush({ retcode: 0, result: { id: 81 } });
   });
 
-  it('keeps addon-dependent content and product queries on the legacy gateway', () => {
+  it('keeps private addon content without a Parse session and product queries on the legacy gateway', () => {
     api.get('content_list', { modelId: 56, myfield2: 'UserId=42' }).subscribe();
     api.get('product_list', { cpage: 1 }).subscribe();
     const content = http.expectOne((candidate) => candidate.urlWithParams.includes('action=content_list'));
@@ -53,6 +56,21 @@ describe('ApiService', () => {
     products.flush({ retcode: 0, result: [] });
   });
 
+  it('routes supported private addon content with a Parse session', () => {
+    sessionToken = 'r:session-token';
+    api.get('content_list', { modelId: 56, myfield2: 'UserId=42|dqrq=20260818' }).subscribe();
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'content_list', modelId: 56, myfield2: 'UserId=42|dqrq=20260818' } });
+    request.flush({ retcode: 0, result: [] });
+  });
+
+  it('routes public vocabulary addon content without a session', () => {
+    api.get('content_list', { modelId: 52, nid: 327 }).subscribe();
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.body).toEqual({ params: { action: 'content_list', modelId: 52, nid: 327 } });
+    request.flush({ retcode: 0, result: [] });
+  });
+
   it('turns retcode -1 responses into ApiError', () => {
     let actual: unknown;
     api.post('user_login_passwd', {}).subscribe({ error: (error) => actual = error });

+ 13 - 4
src/app/core/api.service.ts

@@ -2,8 +2,9 @@ import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http'
 import { inject, Injectable } from '@angular/core';
 import { catchError, map, Observable, throwError } from 'rxjs';
 import { API_CONFIG } from './app.constants';
-import { usesCloudGateway } from './cloud-action-migration';
+import { requiresCloudSession, usesCloudGateway } from './cloud-action-migration';
 import { ApiEnvelope } from './models';
+import { SessionService } from './session.service';
 
 export class ApiError extends Error {
   constructor(message: string, readonly status = 0, readonly payload?: unknown) {
@@ -14,23 +15,26 @@ export class ApiError extends Error {
 @Injectable({ providedIn: 'root' })
 export class ApiService {
   private readonly http = inject(HttpClient);
+  private readonly sessions = inject(SessionService);
 
   get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
-    if (usesCloudGateway(action, params)) return this.callCloud<T>(action, params);
+    const token = this.parseSessionToken();
+    if (usesCloudGateway(action, params, Boolean(token))) return this.callCloud<T>(action, params, requiresCloudSession(action, params) ? token : '');
     return this.http
       .get<unknown>(this.actionUrl(action), { params: this.toHttpParams(params) })
       .pipe(map((response) => this.normalize<T>(response)), catchError((error) => this.handleError(error)));
   }
 
   post<T>(action: string, body: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
-    if (action === 'user_login_passwd' && usesCloudGateway(action, body)) {
+    const token = this.parseSessionToken();
+    if (action === 'user_login_passwd' && usesCloudGateway(action, body, Boolean(token))) {
       return this.callCloud<T>(action, body).pipe(
         catchError((error: ApiError) => [401, 403].includes(error.status)
           ? this.postLegacy<T>(action, body)
           : throwError(() => error)),
       );
     }
-    if (usesCloudGateway(action, body)) return this.callCloud<T>(action, body);
+    if (usesCloudGateway(action, body, Boolean(token))) return this.callCloud<T>(action, body, requiresCloudSession(action, body) ? token : '');
     return this.postLegacy<T>(action, body);
   }
 
@@ -120,4 +124,9 @@ export class ApiService {
     }
     return throwError(() => new ApiError(error instanceof Error ? error.message : '未知错误'));
   }
+
+  private parseSessionToken(): string {
+    const token = this.sessions.token();
+    return token.startsWith('r:') ? token : '';
+  }
 }

+ 36 - 3
src/app/core/cloud-action-migration.ts

@@ -10,9 +10,42 @@ export const H5_CLOUD_ROUTED_ACTIONS = new Set([
   'user_login_passwd',
 ]);
 
-export function usesCloudGateway(action: string, params: Record<string, unknown> = {}): boolean {
+const CONTENT_MODEL_FIELDS: Record<number, Set<string>> = {
+  52: new Set(['id', 'lj', 'sy', 'yb', 'yp']),
+  53: new Set(['id', 'kcid', 'scid', 'xxcs', 'yhid', 'jrscb']),
+  54: new Set(['id', 'pl', 'bxrq', 'dslx', 'dszt', 'fxpl', 'jffs', 'jssj', 'kcid', 'kssj', 'plxm', 'scsj', 'sdsd', 'szmd', 'szyh', 'yysj', 'yykcid']),
+  56: new Set(['id', 'pl', 'con', 'djq', 'ygg', 'dqrq', 'dsid', 'fxrl', 'xxqs', 'szmdid', 'userid', 'learned']),
+  58: new Set(['id', 'yxx', 'cksl', 'kcid', 'syjd', 'yhid']),
+  59: new Set(['id', 'pf', 'jffs', 'jsmz', 'kcid', 'kclx', 'kcmc', 'kzsj', 'pjnr', 'pldp', 'xymz', 'yyds', 'plpjsj', 'szmdid']),
+  60: new Set(['id', 'fxzt', 'kcid', 'plid', 'wcsj', 'yhid', 'kywrq', 'kywsj', 'xxjlid', 'orderid']),
+  61: new Set(['id', 'df', 'askid', 'wrong', 'userid', 'answerid', 'dontknow', 'prevscore', 'totalscore']),
+};
+const NODE_MODELS: Record<number, number> = { 28: 58, 29: 54, 32: 53, 291: 56, 296: 59, 327: 52, 388: 60, 389: 61 };
+
+export function usesCloudGateway(action: string, params: Record<string, unknown> = {}, hasParseSession = false): boolean {
   if (H5_CLOUD_ROUTED_ACTIONS.has(action)) return true;
   if (action !== 'content_list') return false;
-  const addonQueryKeys = ['modelId', 'modelid', 'ModelID', 'myfield', 'myfield2'];
-  return addonQueryKeys.every((key) => params[key] === undefined || params[key] === null || params[key] === '');
+  const modelId = contentModel(params);
+  if (!modelId) return !params['myfield'] && !params['myfield2'];
+  const fields = CONTENT_MODEL_FIELDS[modelId];
+  if (!fields || (modelId !== 52 && !hasParseSession)) return false;
+  return filterNames(params).every((field) => fields.has(field));
+}
+
+export function requiresCloudSession(action: string, params: Record<string, unknown> = {}): boolean {
+  return action === 'content_list' && contentModel(params) !== 52 && Boolean(contentModel(params));
+}
+
+function contentModel(params: Record<string, unknown>): number {
+  const explicit = Number(params['modelId'] ?? params['modelid'] ?? params['ModelID'] ?? 0);
+  if (explicit) return explicit;
+  const nodes = String(params['nodeid'] ?? params['nid'] ?? params['nodes'] ?? '').split(',').map(Number).filter(Boolean);
+  return nodes.length === 1 ? NODE_MODELS[nodes[0]] ?? 0 : 0;
+}
+
+function filterNames(params: Record<string, unknown>): string[] {
+  return [params['myfield'], params['myfield2']]
+    .filter(Boolean)
+    .flatMap((value) => String(value).split('|'))
+    .map((part) => part.split('=', 1)[0].replace(/^B\./i, '').toLowerCase());
 }