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

feat: route verified H5 actions to cloud

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

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

@@ -17,6 +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 仍由显式迁移路由保留旧端点。
 
 ## SQL 视图 / 函数替代状态
 

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

@@ -63,7 +63,15 @@
 
 ## 前端切换决策
 
-当前 Angular H5 仍保留旧 `WXAPP` 入口。在 65 个 action 中包含登录、订单、支付和学习写入等主流程时,直接全量切换会导致页面大面积中断。切换条件是:
+Angular H5 已启用混合迁移路由:
+
+- `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。
+- `app_update`、`node_list`、`node_get` 已切换至云函数。
+- 不含 `modelId`/`myfield`/`myfield2` addon 联表条件的 `content_list` 已切换;学习和预约页的 addon 查询仍保留旧端点。
+- 云函数返回同时提供规范化 camelCase 与旧系统 `GeneralID`/`NodeID`/`Title` 等字段别名。
+- `product_list` 尚未切换:当前 `Product` 类只有库存变体字段,缺少旧页依赖的商品名称、价格、图片和正文联表。
+
+在 65 个阻塞 action 中包含订单、支付和学习写入等主流程时,直接全量切换会导致页面大面积中断。剩余切换条件是:
 
 1. 先对齐缺失 Schema/数据和第三方凭据。
 2. 完成剩余 action 的云函数事务、权限和字段回归。

+ 10 - 3
scripts/deploy-admin-functions.mjs

@@ -260,6 +260,13 @@ function companyPointer() { return Parse.Object.createWithoutData('Company', DEF
 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); }
+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' }
+  }; const map = maps[className] || {}; for (const [legacy,source] of Object.entries(map)) if (row[legacy] === undefined && row[source] !== undefined) row[legacy] = row[source]; return row;
+}
 async function currentUser(request, required = true) { const current = request.user || (typeof user !== 'undefined' ? user : null); if (!current) { if (required) fail(401, '登录状态已失效'); return null; } await current.fetch({ useMasterKey: true }); const company = current.get('company'); if (company && company.id !== DEFAULT_COMPANY_ID) fail(403, '用户不属于小树英语帐套'); return current; }
 function legacyUser(current, withToken = false) { const json = safe(current); const value = { ...json, userId: number(current.get('legacyUserId') || current.get('userid') || current.get('num')), userName: String(current.get('username') || ''), honeyName: String(current.get('nickname') || current.get('nickName') || current.get('realName') || current.get('username') || ''), userFace: String(current.get('avatar') || ''), mobile: String(current.get('mobile') || current.get('phone') || ''), groupId: number(current.get('legacyGroupId')), groupName: String(current.get('roleName') || '') }; if (withToken) value.sessionToken = current.getSessionToken(); return value; }
 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 }); }
@@ -273,14 +280,14 @@ async function contentPage(input) {
   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 } };
+  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 } };
 }
 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 } };
+  return { rows: rows.filter(isVisible).map((row) => legacyAliases(row, className)), 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) {
@@ -369,7 +376,7 @@ async function upsert(definition) {
     respType: 'json',
     respJson: { success: true, data: {} },
     enabled: true,
-    version: '1.0.8',
+    version: '1.0.9',
   };
   const objectId = existing.results?.[0]?.objectId;
   if (objectId) {

+ 41 - 4
src/app/core/api.service.spec.ts

@@ -16,20 +16,57 @@ describe('ApiService', () => {
 
   afterEach(() => http.verify());
 
-  it('uses the legacy action gateway and parses JSON result strings', () => {
+  it('routes migrated actions through the cloud gateway and parses JSON result strings', () => {
     let actual: unknown;
-    api.get('product_list', { cpage: 1 }).subscribe((response) => actual = response.result);
-    const request = http.expectOne((candidate) => candidate.url.includes(API_CONFIG.endpoint) && candidate.urlWithParams.includes('action=product_list'));
-    expect(request.request.params.get('cpage')).toBe('1');
+    api.get('node_list', { cpage: 1 }).subscribe((response) => actual = response.result);
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.method).toBe('POST');
+    expect(request.request.headers.get('X-Parse-Application-Id')).toBe(API_CONFIG.parseAppId);
+    expect(request.request.body).toEqual({ params: { action: 'node_list', cpage: 1 } });
     request.flush({ retcode: 0, result: '[{"ID":1}]' });
     expect(actual).toEqual([{ ID: 1 }]);
   });
 
+  it('keeps not-yet-migrated actions on the legacy gateway', () => {
+    api.get('order_list', { cpage: 2 }).subscribe();
+    const request = http.expectOne((candidate) => candidate.url.includes(API_CONFIG.endpoint) && candidate.urlWithParams.includes('action=order_list'));
+    expect(request.request.method).toBe('GET');
+    expect(request.request.params.get('cpage')).toBe('2');
+    request.flush({ retcode: 0, result: [] });
+  });
+
+  it('nests reserved id parameters inside params', () => {
+    api.get('node_get', { id: 81 }).subscribe();
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.body).toEqual({ params: { action: 'node_get', id: 81 } });
+    request.flush({ retcode: 0, result: { id: 81 } });
+  });
+
+  it('keeps addon-dependent content 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'));
+    const products = http.expectOne((candidate) => candidate.urlWithParams.includes('action=product_list'));
+    expect(content.request.url).toContain(API_CONFIG.endpoint);
+    expect(products.request.url).toContain(API_CONFIG.endpoint);
+    content.flush({ retcode: 0, result: [] });
+    products.flush({ retcode: 0, result: [] });
+  });
+
   it('turns retcode -1 responses into ApiError', () => {
     let actual: unknown;
     api.post('user_login_passwd', {}).subscribe({ error: (error) => actual = error });
+    http.expectOne(API_CONFIG.cloudFunctionUrl).flush({ retcode: -1, retmsg: '账号或密码错误', result: null }, { status: 401, statusText: 'Unauthorized' });
     http.expectOne((candidate) => candidate.urlWithParams.includes('action=user_login_passwd')).flush({ retcode: -1, retmsg: '登录失败', result: null });
     expect(actual instanceof ApiError).toBeTrue();
     expect((actual as ApiError).message).toBe('登录失败');
   });
+
+  it('keeps a successful password login on the cloud response', () => {
+    let actual: unknown;
+    api.post('user_login_passwd', { name: 'tree', passwd: 'password' }).subscribe((response) => actual = response.result);
+    http.expectOne(API_CONFIG.cloudFunctionUrl).flush({ retcode: 0, result: { userId: 42, sessionToken: 'r:token' } });
+    http.expectNone((candidate) => candidate.url.includes(API_CONFIG.endpoint));
+    expect(actual).toEqual({ userId: 42, sessionToken: 'r:token' });
+  });
 });

+ 29 - 0
src/app/core/api.service.ts

@@ -2,6 +2,7 @@ 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 { ApiEnvelope } from './models';
 
 export class ApiError extends Error {
@@ -15,12 +16,25 @@ export class ApiService {
   private readonly http = inject(HttpClient);
 
   get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
+    if (usesCloudGateway(action, params)) return this.callCloud<T>(action, params);
     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)) {
+      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);
+    return this.postLegacy<T>(action, body);
+  }
+
+  private postLegacy<T>(action: string, body: Record<string, unknown>): Observable<ApiEnvelope<T>> {
     const encoded = this.toHttpParams(body);
     return this.http
       .post<unknown>(this.actionUrl(action), encoded.toString(), {
@@ -29,6 +43,21 @@ export class ApiService {
       .pipe(map((response) => this.normalize<T>(response)), catchError((error) => this.handleError(error)));
   }
 
+  callCloud<T>(action: string, params: Record<string, unknown> = {}, sessionToken = ''): Observable<ApiEnvelope<T>> {
+    const body = {
+      ...(sessionToken ? { token: sessionToken } : {}),
+      params: { action, ...params },
+    };
+    return this.http
+      .post<unknown>(API_CONFIG.cloudFunctionUrl, body, {
+        headers: {
+          'Content-Type': 'application/json',
+          'X-Parse-Application-Id': API_CONFIG.parseAppId,
+        },
+      })
+      .pipe(map((response) => this.normalize<T>(response)), catchError((error) => this.handleError(error)));
+  }
+
   upload(file: File): Observable<ApiEnvelope<string>> {
     const formData = new FormData();
     formData.append('file', file, file.name);

+ 4 - 0
src/app/core/app.constants.ts

@@ -8,6 +8,10 @@ export const API_CONFIG = {
   endpoint: '/api/WXAPP',
   apiId: '206a54b501ad6d7d8df0f78812e156c2',
   apiKey: '41350aa1dcf7c9444841cdb153b4338d',
+  cloudFunctionUrl: isLocalDevelopment
+    ? '/cloud-functions/xiaoshu/app/gateway'
+    : 'https://server.xiaoshu.pro/api/functions/xiaoshu/app/gateway',
+  parseAppId: '7pIbDBJmKx_main',
 } as const;
 
 export const BYTEDESK_CONFIG = {

+ 51 - 0
src/app/core/auth.service.spec.ts

@@ -0,0 +1,51 @@
+import { TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
+import { ApiService } from './api.service';
+import { AuthService } from './auth.service';
+
+describe('AuthService', () => {
+  let auth: AuthService;
+  let api: jasmine.SpyObj<ApiService>;
+
+  beforeEach(() => {
+    api = jasmine.createSpyObj<ApiService>('ApiService', ['post', 'callCloud']);
+    TestBed.configureTestingModule({ providers: [AuthService, { provide: ApiService, useValue: api }] });
+    auth = TestBed.inject(AuthService);
+  });
+
+  it('uses the Parse session token to load the member after password login', () => {
+    api.post.and.returnValue(of({ retcode: 0, result: { userId: 42, userName: 'tree', sessionToken: 'r:session' } }));
+    api.callCloud.and.returnValue(of({ retcode: 0, result: { userId: 42, honeyName: '小树' }, addon: { State: 1 } }));
+
+    let actual: Record<string, unknown> | undefined;
+    auth.login('user_login_passwd', { name: 'tree', passwd: 'password' }).subscribe((user) => actual = user);
+
+    expect(api.callCloud).toHaveBeenCalledWith('user_get', { uid: 42 }, 'r:session');
+    expect(actual?.['sessionToken']).toBe('r:session');
+    expect(actual?.['honeyName']).toBe('小树');
+  });
+
+  it('keeps mobile login member loading on the legacy gateway', () => {
+    api.post.and.returnValues(
+      of({ retcode: 0, result: { userId: 7, userName: 'mobile' } }),
+      of({ retcode: 0, result: { userId: 7 }, addon: { State: 1 } }),
+    );
+
+    auth.login('user_login_mobile', { mobile: '13800000000', vcode: '1234' }).subscribe();
+
+    expect(api.post.calls.argsFor(1)).toEqual(['user_get', { uid: 7 }]);
+    expect(api.callCloud).not.toHaveBeenCalled();
+  });
+
+  it('keeps a legacy password login working when no Parse session is returned', () => {
+    api.post.and.returnValues(
+      of({ retcode: 0, result: { userId: 9, userName: 'legacy' } }),
+      of({ retcode: 0, result: { userId: 9 }, addon: { State: 1 } }),
+    );
+
+    auth.login('user_login_passwd', { name: 'legacy', passwd: 'password' }).subscribe();
+
+    expect(api.post.calls.argsFor(1)).toEqual(['user_get', { uid: 9 }]);
+    expect(api.callCloud).not.toHaveBeenCalled();
+  });
+});

+ 6 - 2
src/app/core/auth.service.ts

@@ -11,12 +11,16 @@ export class AuthService {
     return this.api.post<AppUser>(action, payload).pipe(
       switchMap(({ result }) => {
         if (!result?.userId) return throwError(() => new ApiError('登录结果缺少会员信息'));
-        return this.api.post<AppUser>('user_get', { uid: result.userId }).pipe(
+        const sessionToken = String(result['sessionToken'] ?? '');
+        const memberRequest = sessionToken
+          ? this.api.callCloud<AppUser>('user_get', { uid: result.userId }, sessionToken)
+          : this.api.post<AppUser>('user_get', { uid: result.userId });
+        return memberRequest.pipe(
           map(({ result: member, addon }) => {
             const extra = this.asRecord(addon);
             const state = Number(extra['State'] ?? extra['state'] ?? 0);
             if (!state) throw new ApiError('您的账号正在审核中,请审核通过后再登录');
-            return { ...result, ...member, ...extra } as AppUser;
+            return { ...result, ...member, ...extra, ...(sessionToken ? { sessionToken } : {}) } as AppUser;
           }),
         );
       }),

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

@@ -0,0 +1,18 @@
+/**
+ * Actions that are safe to route from the current Angular H5 application to
+ * the new Parse cloud-function gateway. Keep this list narrower than the
+ * server-side mapping until each page's response shape has passed regression.
+ */
+export const H5_CLOUD_ROUTED_ACTIONS = new Set([
+  'app_update',
+  'node_get',
+  'node_list',
+  'user_login_passwd',
+]);
+
+export function usesCloudGateway(action: string, params: Record<string, unknown> = {}): 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] === '');
+}

+ 2 - 1
src/app/core/session.service.ts

@@ -31,7 +31,8 @@ export class SessionService {
 
   setLogin(user: AppUser): void {
     this.user.set(user);
-    this.token.set(this.makeToken(user));
+    const sessionToken = typeof user['sessionToken'] === 'string' ? user['sessionToken'].trim() : '';
+    this.token.set(sessionToken || this.makeToken(user));
   }
 
   setStudent(student: AppUser | null): void {