Просмотр исходного кода

fix: restore content detail contracts

彭峰 1 месяц назад
Родитель
Сommit
3e93d34d2c

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

@@ -56,9 +56,14 @@
 - `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` 并标记规则不可用。
 - `guestbook_add`:兼容旧页的 `model` JSON(`UserID/Title/TContent/Cateid`)和 Angular 表单的扁平字段,限制 800 字并阻止普通用户冒用他人 `UserID`。
+- `content_get`:恢复旧页面依赖的单元素数组 `result[0]` 契约;私有 Model 详情继续要求真实 Parse 会话并校验本人、直属学员或管理员范围。
+- `content_uphis`:文章浏览量允许匿名原子 `+1`,忽略客户端自定义增量;私有 Model 仍要求会话和记录归属权限。
 - `node_list`:普通栏目按旧 `OrderID/NodeID` 升序;`ifunit=1` 分支使用真实 Parse 会话,按 Model 52 词库和 `PracticeRecord.xxcs` 返回每单元 `total/yx_word/process`。
+- `node_get`:除通用栏目别名外,补齐连连看页实际读取的 `ConsumePoint/ConsumeDeposit` 及相关消费字段 PascalCase 别名。
 - `app_update`:从目标 `App.index/version/changelog/downUrl` 恢复 `ver/nver/intro/path`;迁移资料没有 APK 字节大小,因此 `size=null` 并显式标记 `sizeUnavailable=true`。目标记录中的安装包地址已保留,但其文件域名在 2026-08-18 检查时 TLS 证书已过期,实际下载仍属于外部环境阻塞。
 
+`user_info_name` 仅用于匿名用户名存在性查重,不复制旧微信页“只凭手机号查询结果直接建立本地登录态”的无凭证登录语义;手机号登录必须在 `mcode_send/user_login_mobile` 具备新短信校验链路后恢复,微信登录必须在 `user_sync2` 具备新版容器授权后恢复。
+
 迁移数据没有保存复习收入金额,也没有提供可验证的计价公式,因此 `e_get_21list_tj` 改为显式 501;收入列表仍返回复习事实,但用 `incomeRuleUnavailable=true` 标记金额不可恢复,不伪造收入。
 
 ## 因目标 Schema/历史数据缺失而阻塞
@@ -107,7 +112,7 @@
 Angular H5 已启用混合迁移路由:
 
 - `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。`user_info_name` 查重和 `user_register` 经典注册已直接切换云函数。
-- `app_update`、`node_list`、`node_get` 已切换至云函数。
+- `app_update`、`node_list`、`node_get` 已切换至云函数;匿名公开内容及持有真实 Parse 会话的 `content_get/content_uphis` 已切换,旧会话继续留在旧端点,避免私有学习详情在密码迁移完成前丢失鉴权。
 - 持有真实 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`、`guestbook_add`、`stu_record_update_v2`、`user_get`、`user_list`、`user_dept`、`user_point_list`、`user_update` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
 - 不含 addon 条件的 `content_list` 和 Model 52 公开词库已切换;已持有 Parse 会话的用户会把字段白名单内的 Model 53/54/56/58/59/60/61 addon 查询切到云函数,仍使用旧会话的存量账号自动保留旧端点。
 - 云函数返回同时提供规范化 camelCase 与旧系统 `GeneralID`/`NodeID`/`Title` 等字段别名。

+ 4 - 4
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','app_update']);
+const PUBLIC_READ = new Set(['content_list','content_get','content_uphis','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 无用户优惠券类',
@@ -265,7 +265,7 @@ function isVisible(row) { const value = row && typeof row.get === 'function' ? r
 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' },
+    Node: { NodeID:'nodeId', NodeName:'nodeName', NodeType:'nodeType', NodeDir:'nodeDir', NodeUrl:'nodeUrl', ParentID:'parentId', OrderID:'orderId', NodePic:'nodePicUrl', Description:'description', ConsumePoint:'consumePoint', ConsumeDeposit:'consumeDeposit', ConsumeType:'consumeType', ConsumeTime:'consumeTime', ConsumeCount:'consumeCount', AddPoint:'addPoint' },
     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;
 }
@@ -535,8 +535,8 @@ async function handler(request, response) {
     if (action === 'user_update') { const updated = await updateUserProfile(input,current); return response.json(envelope(updated.result,updated.addon)); }
     if (action === 'app_update') { const result = await sqlPage('App', { page: 1, pageSize: 1 }); return response.json(envelope(legacyAppRow(result.rows[0]))); }
     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 = /^\d+$/.test(id) ? { generalId: id } : { objectId: 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_get') { const id = String(input.id || input.gid || input.generalId || ''); if (!id) return response.status(400).json(reject('缺少内容 ID')); const identity = /^\d+$/.test(id) ? { generalId: id } : { objectId: 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 id = String(input.id || input.gid || input.generalId || ''); if (!id) return response.status(400).json(reject('缺少内容 ID')); const identity = /^\d+$/.test(id) ? { generalId:id } : { objectId:id }; const base = await contentPage({ page:1,pageSize:1,...identity }); const detail = base.rows[0]; if (!detail) return response.status(404).json(reject('内容不存在')); const detailModel = number(detail.modelId || detail.ModelID); if (PRIVATE_CONTENT_MODELS.has(detailModel)) { if (!current) return response.status(401).json(reject('该内容详情需要登录')); await authorizeContentDetail(current,detail); } const updated = await Psql.one('UPDATE "CommonModel" SET "hits"=COALESCE("hits",0)+1,"updatedAt"=CURRENT_TIMESTAMP WHERE "company"=$1 AND CAST("objectId" AS text)=$2 RETURNING "hits"',[DEFAULT_COMPANY_ID,String(detail.objectId)]); return response.json(envelope({ hits:number(updated.hits) })); }
     if (action === 'content_add') return response.json(envelope(await createContentPair(input, current)));
     if (action === 'content_update') return response.json(envelope(await updateContentPair(input, current)));
     if (action === 'node_list') { const result = privateNodeProgress ? await nodeUnitPage(input,current) : await nodePage(input); return response.json(envelope(result.rows, undefined, result.page)); }

+ 19 - 4
scripts/smoke-admin-functions.mjs

@@ -65,6 +65,8 @@ let coachLegacyId = 0;
 let temporaryAppointmentId = '';
 let temporaryAppointmentObjectId = '';
 let temporaryGuestbookId = '';
+let contentHitObjectId = '';
+let contentHitOriginal = 0;
 const temporaryLessonIds = [];
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
@@ -195,9 +197,18 @@ try {
   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 校验失败');
+  if (!Array.isArray(contentDetail.result) || contentDetail.result[0]?.objectId !== content.result[0].objectId) throw new Error('内容详情旧数组契约校验失败');
+  contentHitObjectId = String(content.result[0].objectId);
+  contentHitOriginal = Number(content.result[0].hits ?? content.result[0].Hits ?? 0);
+  const contentHit = await callLegacyFunction('', { action: 'content_uphis', id: contentHitObjectId, num: 999 });
+  if (Number(contentHit.result?.hits) !== contentHitOriginal + 1) throw new Error(`公开内容浏览量未固定原子加一:${JSON.stringify(contentHit)}`);
+  await jsonRequest(`${PARSE_URL}/classes/CommonModel/${contentHitObjectId}`, { method: 'PUT', body: JSON.stringify({ hits: contentHitOriginal }) }, true);
+  contentHitObjectId = '';
+  await callLegacyFunction('', { action: 'content_uphis', id: temporaryAppointmentId }, 401);
   const appUpdate = await callLegacyFunction('', { action: 'app_update' });
   if (Number(appUpdate.result?.ver) !== 113 || appUpdate.result?.nver !== '1.1.8' || !String(appUpdate.result?.path).startsWith('https://') || appUpdate.result?.size !== null || appUpdate.result?.sizeUnavailable !== true) throw new Error(`应用版本旧契约别名异常:${JSON.stringify(appUpdate)}`);
+  const gameNode = await callLegacyFunction('', { action: 'node_get', id: 77 });
+  if (gameNode.result?.NodeID === undefined || gameNode.result?.ConsumePoint === undefined || gameNode.result?.ConsumeDeposit === undefined) throw new Error(`栏目详情游戏消费字段别名异常:${JSON.stringify(gameNode.result)}`);
 
   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 联表校验失败');
@@ -260,17 +271,17 @@ try {
   const scheduledStudy = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_add_zt', content: JSON.stringify({ ModelID: 56, nodeId: 291, inputer: memberUsername, status: 99, Hits: 1, title: memberUsername }), addon: JSON.stringify({ UserID: memberLegacyId, dqrq: '20991231', learned: 1, xxqs: JSON.stringify([{ GeneralID: 2505, Title: 'woman', check: 0 }]) }) });
   if (!/^\d{15}$/.test(String(scheduledStudy.result))) throw new Error('带复习计划的学习记录创建结果异常');
   const scheduledDetail = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_get', id: scheduledStudy.result });
-  const reviewDates = String(scheduledDetail.result?.fxrl || '').split(',').filter(Boolean);
+  const reviewDates = String(scheduledDetail.result?.[0]?.fxrl || '').split(',').filter(Boolean);
   if (reviewDates.length !== 15 || !reviewDates.every((date) => /^\d{8}$/.test(date))) throw new Error('15 段抗遗忘复习日期生成失败');
   await callLegacyFunction(coachLogin.sessionToken, { action: 'content_update', content: JSON.stringify({ GeneralID: scheduledStudy.result }), addon: JSON.stringify({ con: '越权更新' }) }, 403);
   const scheduledUpdate = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_update', content: JSON.stringify({ GeneralID: scheduledStudy.result, Title: '已更新学习记录' }), addon: JSON.stringify({ con: '规范化内容更新', learned: 1 }) });
   if (String(scheduledUpdate.result) !== String(scheduledStudy.result)) throw new Error('规范化内容更新返回值异常');
   const updatedScheduledDetail = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_get', id: scheduledStudy.result });
-  if (updatedScheduledDetail.result?.con !== '规范化内容更新' || updatedScheduledDetail.result?.Title !== '已更新学习记录') throw new Error('规范化内容双表更新写后读失败');
+  if (updatedScheduledDetail.result?.[0]?.con !== '规范化内容更新' || updatedScheduledDetail.result?.[0]?.Title !== '已更新学习记录') throw new Error('规范化内容双表更新写后读失败');
   const assessment = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_add', content: JSON.stringify({ ModelID: 61, nodeId: 389, inputer: memberUsername, status: 99, Hits: 1, title: memberUsername }), addon: JSON.stringify({ UserID: memberLegacyId, askid: 1, prev_score: 0, totalScore: 10, wrong: 2, dontKnow: 1, answerid: 7, df: 7 }) });
   if (!/^\d{15}$/.test(String(assessment.result))) throw new Error('规范化测评内容新增结果异常');
   const assessmentDetail = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_get', id: assessment.result });
-  if (String(assessmentDetail.result?.df) !== '7' || String(assessmentDetail.result?.prevScore) !== '0' || Number(assessmentDetail.result?.UserID ?? assessmentDetail.result?.userId) !== memberLegacyId) throw new Error('规范化测评内容新增写后读失败');
+  if (String(assessmentDetail.result?.[0]?.df) !== '7' || String(assessmentDetail.result?.[0]?.prevScore) !== '0' || Number(assessmentDetail.result?.[0]?.UserID ?? assessmentDetail.result?.[0]?.userId) !== memberLegacyId) throw new Error('规范化测评内容新增写后读失败');
   const missingSchemaCreate = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_add', content: JSON.stringify({ ModelID: 55, nodeId: 255 }), addon: JSON.stringify({ UserID: memberLegacyId, scnr: '[]' }) }, 501);
   if (!String(missingSchemaCreate.retmsg).includes('未迁移内容模型 55')) throw new Error('缺 Schema 内容新增未明确拒绝');
   const studyAddon = { con: '云函数学习记录冒烟测试', dqrq: '20260818', fxrl: '20260819,20260820', UserID: memberLegacyId, learned: 1, ygg: 0, djq: 0, xxqs: JSON.stringify([{ GeneralID: 2505, Title: 'woman', check: 0 }]) };
@@ -330,6 +341,10 @@ try {
 
   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 {
+  if (contentHitObjectId) await jsonRequest(`${PARSE_URL}/classes/CommonModel/${contentHitObjectId}`, { method: 'PUT', body: JSON.stringify({ hits: contentHitOriginal }) }, true).catch((error) => {
+    console.error(`公开内容浏览量恢复失败:${error.message}`);
+    process.exitCode = 1;
+  });
   if (registeredUserId) await jsonRequest(`${PARSE_URL}/users/${registeredUserId}`, { method: 'DELETE' }, true).catch((error) => {
     console.error(`临时注册用户清理失败:${error.message}`);
     process.exitCode = 1;

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

@@ -45,6 +45,37 @@ describe('ApiService', () => {
     request.flush({ retcode: 0, result: { id: 81 } });
   });
 
+  it('routes public content details and hit increments through the cloud gateway', () => {
+    api.get('content_get', { id: 931 }).subscribe();
+    const detail = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(detail.request.body).toEqual({ params: { action: 'content_get', id: 931 } });
+    detail.flush({ retcode: 0, result: [{ GeneralID: 931, Title: '文章' }] });
+
+    api.post('content_uphis', { id: 931 }).subscribe();
+    const hits = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(hits.request.body).toEqual({ params: { action: 'content_uphis', id: 931 } });
+    hits.flush({ retcode: 0, result: { hits: 2 } });
+  });
+
+  it('passes Parse sessions to private-capable content actions and preserves legacy sessions', () => {
+    sessionToken = 'r:session-token';
+    api.get('content_get', { id: 931 }).subscribe();
+    const cloud = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(cloud.request.body).toEqual({ token: 'r:session-token', params: { action: 'content_get', id: 931 } });
+    cloud.flush({ retcode: 0, result: [{ GeneralID: 931 }] });
+
+    sessionToken = 'legacy-session';
+    api.get('content_get', { id: 931 }).subscribe();
+    const legacyDetail = http.expectOne((candidate) => candidate.urlWithParams.includes('action=content_get'));
+    expect(legacyDetail.request.url).toContain(API_CONFIG.endpoint);
+    legacyDetail.flush({ retcode: 0, result: [{ GeneralID: 931 }] });
+
+    api.post('content_uphis', { id: 931 }).subscribe();
+    const legacyHits = http.expectOne((candidate) => candidate.urlWithParams.includes('action=content_uphis'));
+    expect(legacyHits.request.url).toContain(API_CONFIG.endpoint);
+    legacyHits.flush({ retcode: 0, result: null });
+  });
+
   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();

+ 5 - 3
src/app/core/api.service.ts

@@ -18,23 +18,25 @@ export class ApiService {
   private readonly sessions = inject(SessionService);
 
   get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
+    const hasAnySession = Boolean(this.sessions.token());
     const token = this.parseSessionToken();
-    if (usesCloudGateway(action, params, Boolean(token))) return this.callCloud<T>(action, params, requiresCloudSession(action, params) ? token : '');
+    if (usesCloudGateway(action, params, Boolean(token), hasAnySession)) 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>> {
+    const hasAnySession = Boolean(this.sessions.token());
     const token = this.parseSessionToken();
-    if (action === 'user_login_passwd' && usesCloudGateway(action, body, Boolean(token))) {
+    if (action === 'user_login_passwd' && usesCloudGateway(action, body, Boolean(token), hasAnySession)) {
       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, Boolean(token))) return this.callCloud<T>(action, body, requiresCloudSession(action, body) ? token : '');
+    if (usesCloudGateway(action, body, Boolean(token), hasAnySession)) return this.callCloud<T>(action, body, requiresCloudSession(action, body) ? token : '');
     return this.postLegacy<T>(action, body);
   }
 

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

@@ -5,6 +5,8 @@
  */
 export const H5_CLOUD_ROUTED_ACTIONS = new Set([
   'app_update',
+  'content_get',
+  'content_uphis',
   'node_get',
   'node_list',
   'user_info_name',
@@ -46,7 +48,8 @@ const CONTENT_MODEL_FIELDS: Record<number, Set<string>> = {
 };
 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 {
+export function usesCloudGateway(action: string, params: Record<string, unknown> = {}, hasParseSession = false, hasAnySession = hasParseSession): boolean {
+  if (['content_get', 'content_uphis'].includes(action) && hasAnySession && !hasParseSession) return false;
   if (action === 'node_list' && Number(params['ifunit']) === 1) return hasParseSession;
   if (H5_CLOUD_ROUTED_ACTIONS.has(action)) return true;
   if (SESSION_CLOUD_ROUTED_ACTIONS.has(action)) return hasParseSession;
@@ -60,6 +63,7 @@ export function usesCloudGateway(action: string, params: Record<string, unknown>
 
 export function requiresCloudSession(action: string, params: Record<string, unknown> = {}): boolean {
   return SESSION_CLOUD_ROUTED_ACTIONS.has(action)
+    || ['content_get', 'content_uphis'].includes(action)
     || (action === 'node_list' && Number(params['ifunit']) === 1)
     || (action === 'content_list' && contentModel(params) !== 52 && Boolean(contentModel(params)));
 }