Sfoglia il codice sorgente

fix(admin): isolate internal requests and recover transient read failures

彭峰 3 ore fa
parent
commit
5e391e414c

+ 48 - 0
projects/xiaoshu-admin/src/app/cloud-functions.service.spec.ts

@@ -74,4 +74,52 @@ describe('CloudFunctionsService', () => {
     let result:unknown;service.operations('ops/vocabulary/import-preview').then(value=>result=value);
     const req=http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);tick(27500);expect(req.cancelled).toBeFalse();req.flush({success:true,data:{batchId:'batch'}});tick();expect(result).toEqual({batchId:'batch'});
   }));
+  it('recovers one interrupted calendar read without losing the session',fakeAsync(()=>{
+    let result:unknown;service.operations('ops/schedule/calendar').then(v=>result=v);
+    http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).error(new ProgressEvent('error'));
+    tick(500);
+    http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).flush({success:true,data:{appointments:[]}});
+    tick();expect(result).toEqual({appointments:[]});
+    expect(TestBed.inject(AdminSessionService).verifySessionAfterFailure).not.toHaveBeenCalled();
+  }));
+
+  it('bounds retries for a temporarily unavailable member list and hides transport internals',fakeAsync(()=>{
+    let failure:Error|undefined;service.operations('ops/users/list').catch(e=>failure=e);
+    http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).flush('unavailable',{status:503,statusText:'Service Unavailable'});
+    tick(500);
+    http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).error(new ProgressEvent('error'));
+    tick(1000);http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);
+    expect(failure?.message).toContain('加载');expect(failure?.message).not.toContain('https://');expect(failure?.message).not.toContain('结果待核对');
+  }));
+
+  for(const operation of ['ops/appointments/create','ops/users/reset-password','ops/member-credits/post','ops/unknown/action']){
+    it(`does not replay an uncertain write: ${operation}`,fakeAsync(()=>{
+      let failure:Error|undefined;service.operations(operation,{idempotencyKey:'same-command'}).catch(e=>failure=e);
+      http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).error(new ProgressEvent('error'));
+      tick(1000);http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);
+      expect(failure?.message).toContain('结果待核对');expect(failure?.message).not.toContain('Unknown Error');
+    }));
+  }
+
+  it('does not retry a slow read and labels it as a load failure',fakeAsync(()=>{
+    let failure:Error|undefined;service.operations('ops/schedule/calendar').catch(e=>failure=e);
+    const request=http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);tick(21000);
+    expect(request.cancelled).toBeTrue();http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);
+    expect(failure?.message).toContain('加载');expect(failure?.message).not.toContain('结果待核对');
+  }));
+
+  it('cannot turn a safe read into a retried write through params',fakeAsync(()=>{
+    let result:unknown;service.operations('ops/users/list',{operation:'ops/users/create'}).then(v=>result=v);
+    const request=http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);
+    expect(request.request.body.params.operation).toBe('ops/users/list');
+    request.flush({success:true,data:[]});tick();expect(result).toEqual([]);
+  }));
+
+  it('does not retry with an old session when the user logs out during backoff',fakeAsync(()=>{
+    let failure:unknown;service.operations('ops/users/list').catch(e=>failure=e);
+    http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).error(new ProgressEvent('error'));
+    spyOn(TestBed.inject(AdminSessionService),'session').and.returnValue(null);
+    tick(500);http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);expect(failure).toBeDefined();
+  }));
+
 });

+ 38 - 13
projects/xiaoshu-admin/src/app/cloud-functions.service.ts

@@ -1,6 +1,6 @@
 import { HttpClient, HttpErrorResponse } from '@angular/common/http';
 import { inject, Injectable } from '@angular/core';
-import { firstValueFrom, Subject, timeout, throwError } from 'rxjs';
+import { firstValueFrom, Subject, timeout, throwError, retry, timer, mergeMap, of } from 'rxjs';
 import { CLOUD_FUNCTION_BASE_URL } from './admin.constants';
 import { AdminSessionService } from './admin-session.service';
 import { CloudEnvelope, LegacyCloudEnvelope } from './admin.models';
@@ -22,10 +22,8 @@ export class CloudFunctionsService {
     const token = this.sessions.session()?.sessionToken;
     if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
     try {
-      const envelope = await firstValueFrom(this.http.post<CloudEnvelope<T>>(
-        `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`,
-        { token, params: { operation, ...params } },
-      ).pipe(timeout({first:20000,with:()=>throwError(()=>new CloudFunctionError('请求暂未完成,结果待核对;请刷新后重试,已填写内容请保留。'))})));
+      const envelope = await this.request<CloudEnvelope<T>>('xiaoshu/admin/gateway', token,
+        { ...params, operation }, false);
       if (!envelope.success) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.message || envelope.error || '云函数执行失败'); }
       if (this.isMutation(operation)) this.announceDataChange();
       return envelope.data;
@@ -40,6 +38,36 @@ export class CloudFunctionsService {
     }
   }
 
+  // Explicitly allow only reads. Unknown operations and all submissions must never replay.
+  private isReadOperation(operation: string): boolean {
+    return /^ops\/(?:users\/(?:list|detail|notes|audit)|schedule\/calendar|live\/versions|dashboard\/(?:summary|schedule-trend)|coaches\/(?:list|detail)|stores\/(?:list|detail)|coach-availability\/list|learning-reports\/(?:list|detail)|vocabulary\/(?:tree|list)|teaching-rules\/list|pay-rates\/list|appointments\/preflight)$/.test(operation);
+  }
+
+  private transportMessage(readOnly: boolean): string {
+    return readOnly
+      ? '数据加载暂未完成,请稍后重试;已填写内容请保留。'
+      : '连接暂未完成,提交结果待核对;请先刷新核对,避免重复提交,已填写内容请保留。';
+  }
+
+  private async request<T>(path: string, token: string, params: Record<string, unknown>, readOnly: boolean, timeoutMs = 20000): Promise<T> {
+    try {
+      return await firstValueFrom(this.http.post<T>(`${CLOUD_FUNCTION_BASE_URL}/${path}`, { token, params }).pipe(
+        timeout({ first: timeoutMs, with: () => throwError(() => new CloudFunctionError(this.transportMessage(readOnly))) }),
+        retry({ count: 1, delay: (error) => {
+          const transient = error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status);
+          // A timeout may leave work running on the server; do not add another slow request.
+          if (!readOnly || !transient || this.sessions.session()?.sessionToken !== token) return throwError(() => error);
+          return timer(500).pipe(mergeMap(() => this.sessions.session()?.sessionToken === token ? of(0) : throwError(() => error)));
+        } }),
+      ));
+    } catch (error) {
+      if (error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status)) {
+        throw new CloudFunctionError(this.transportMessage(readOnly), error.status);
+      }
+      throw error;
+    }
+  }
+
   private async handleAuthenticationFailure(token: string, status: number, payload: unknown): Promise<void> {
     const body = payload as { code?: number; error?: string; message?: string; retmsg?: string } | null;
     const message = String(body?.message || body?.retmsg || body?.error || '');
@@ -69,10 +97,9 @@ export class CloudFunctionsService {
     const token = this.sessions.session()?.sessionToken;
     if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
     try {
-      const envelope = await firstValueFrom(this.http.post<CloudEnvelope<T>>(
-        `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`,
-        { token, params: { operation, ...params } },
-      ).pipe(timeout({first:/^ops\/vocabulary\/import-(preview|commit)$/.test(operation)?60000:20000,with:()=>throwError(()=>new CloudFunctionError('请求暂未完成,结果待核对;请刷新后重试,已填写内容请保留。'))})));
+      const envelope = await this.request<CloudEnvelope<T>>('xiaoshu/ops/gateway-v3', token,
+        { ...params, operation }, this.isReadOperation(operation),
+        /^ops\/vocabulary\/import-(preview|commit)$/.test(operation) ? 60000 : 20000);
       if (!envelope.success) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.message || envelope.error || '运营云函数执行失败'); }
       if (this.isMutation(operation)) this.announceDataChange();
       return envelope.data;
@@ -91,10 +118,8 @@ export class CloudFunctionsService {
     const token = this.sessions.session()?.sessionToken;
     if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
     try {
-      const envelope = await firstValueFrom(this.http.post<LegacyCloudEnvelope<T>>(
-        `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`,
-        { token, params: { action, ...params } },
-      ).pipe(timeout({first:20000,with:()=>throwError(()=>new CloudFunctionError('请求暂未完成,结果待核对;请刷新后重试,已填写内容请保留。'))})));
+      const envelope = await this.request<LegacyCloudEnvelope<T>>('xiaoshu/app/gateway', token,
+        { ...params, action }, false);
       if (envelope.retcode === -1) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.retmsg || '云函数执行失败'); }
       return envelope.result;
     } catch (error) {

+ 11 - 0
scripts/lib/admin-internal-routing.mjs

@@ -0,0 +1,11 @@
+// Only this application's server-to-server Parse traffic uses loopback.
+export function internalRoutingConfig(current) {
+  const parse = current?.parse;
+  const internal = 'http://127.0.0.1:10337/parse';
+  const external = 'https://server.xiaoshu.pro/parse';
+  if (parse?.appId !== '7pIbDBJmKx_main' || Number(parse.port) !== 10337 ||
+      ![external, internal].includes(parse.serverURL) || parse.publicServerURL !== external) {
+    throw new Error('Unexpected application routing; no settings changed');
+  }
+  return { ...current, parse: { ...parse, serverURL: internal } };
+}

+ 57 - 0
scripts/repair-admin-internal-routing.mjs

@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+// Operator-bound, application-scoped repair. No restart and no shared service changes.
+import { internalRoutingConfig } from './lib/admin-internal-routing.mjs';
+import { withGateway } from '../perf/speed-round3-2026-11-10/server-util.mjs';
+const mode = process.argv[2] || '--check';
+if (!['--check', '--apply', '--rollback'].includes(mode)) throw new Error('Use --check, --apply EXPECTED_SHA256, or --rollback EXPECTED_SHA256 BACKUP_PATH');
+const expected = process.argv[3] || '';
+if (mode !== '--check' && !/^[a-f0-9]{64}$/.test(expected)) throw new Error('Apply requires the checked configuration SHA-256');
+if (mode === '--rollback' && !process.argv[4]) throw new Error('Rollback requires its exact backup path');
+async function run(input) {
+  const fs = await import('node:fs/promises'), crypto = await import('node:crypto');
+  const path = '/opt/future-server/config/config.xiaoshu.json';
+  const original = await fs.readFile(path, 'utf8'), before = JSON.parse(original);
+  let next = internalRoutingConfig(before);
+  const sha = value => crypto.createHash('sha256').update(value).digest('hex');
+  if (input.rollback) {
+    if (!/^\/opt\/future-server\/config\/\.xiaoshu-routing-backups\/[0-9]+\.json$/.test(input.rollback)) throw new Error('Invalid rollback path');
+    const saved = JSON.parse(await fs.readFile(input.rollback, 'utf8'));
+    if (JSON.stringify(internalRoutingConfig(saved)) !== JSON.stringify(internalRoutingConfig(before))) throw new Error('Backup contains unrelated configuration changes');
+    next = saved;
+  }
+  if (Parse.applicationId !== before.parse.appId || global.Config?.parse?.appId !== before.parse.appId) throw new Error('Unexpected runtime application');
+  if (![before.parse.serverURL, next.parse.serverURL].includes(Parse.serverURL)) throw new Error('Unexpected SDK routing');
+  // Verify the target app through its own loopback listener before any change.
+  const response = await fetch(internalRoutingConfig(before).parse.serverURL + '/classes/Company?limit=0&count=1', {
+    headers: { 'X-Parse-Application-Id': Parse.applicationId, 'X-Parse-Master-Key': Parse.masterKey },
+    signal: AbortSignal.timeout(5000),
+  });
+  const data = await response.json();
+  if (!response.ok || !Number.isInteger(data.count)) throw new Error('Loopback application check failed');
+  let backup = null;
+  if (input.apply) {
+    if (sha(original) !== input.expected) throw new Error('Configuration changed since check');
+    if (next.parse.serverURL !== before.parse.serverURL) {
+      const dir = '/opt/future-server/config/.xiaoshu-routing-backups';
+      await fs.mkdir(dir, { recursive: true, mode: 0o700 });
+      backup = dir + '/' + Date.now() + '.json';
+      await fs.writeFile(backup, original, { mode: 0o600, flag: 'wx' });
+      const temp = path + '.routing-' + crypto.randomUUID();
+      await fs.writeFile(temp, JSON.stringify(next, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
+      try {
+        if (sha(await fs.readFile(path, 'utf8')) !== input.expected) throw new Error('Configuration changed before replacement');
+        await fs.rename(temp, path);
+      } finally { await fs.unlink(temp).catch(() => {}); }
+    }
+    // All functions in this Xiaoshu process share this SDK; active requests keep running.
+    Parse.serverURL = next.parse.serverURL;
+    global.Config.parse.serverURL = next.parse.serverURL;
+  }
+  const stored = JSON.parse(await fs.readFile(path, 'utf8'));
+  return { applied: Boolean(input.apply), beforeSha256: sha(original), afterSha256: sha(await fs.readFile(path, 'utf8')),
+    serverURL: stored.parse.serverURL, publicServerURL: stored.parse.publicServerURL,
+    runtimeServerURL: Parse.serverURL, appId: Parse.applicationId, backup, pid: process.pid };
+}
+await withGateway(internalRoutingConfig.toString() + '\n' + run.toString(), async call => {
+  console.log(JSON.stringify(await call({ apply: mode !== '--check', expected, rollback: mode === '--rollback' ? process.argv[4] : undefined })));
+});

+ 16 - 0
scripts/tests/admin-internal-routing.test.mjs

@@ -0,0 +1,16 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { internalRoutingConfig } from '../lib/admin-internal-routing.mjs';
+const fixture=()=>({proxy:{enabled:true},parse:{appId:'7pIbDBJmKx_main',port:10337,serverURL:'https://server.xiaoshu.pro/parse',publicServerURL:'https://server.xiaoshu.pro/parse',masterKey:'fixture-only',databaseURI:'fixture-database',protectedFields:{_User:['secret']}},liveQuery:{enabled:false}});
+test('changes only the Xiaoshu internal URL and preserves public URLs and all other settings',()=>{
+ const before=fixture(),after=internalRoutingConfig(before);
+ assert.equal(after.parse.serverURL,'http://127.0.0.1:10337/parse');
+ assert.deepEqual({...after,parse:{...after.parse,serverURL:before.parse.serverURL}},before);
+ assert.equal(before.parse.serverURL,'https://server.xiaoshu.pro/parse');
+ assert.deepEqual(internalRoutingConfig(after),after);
+});
+test('rejects other applications, ports and unexpected public endpoints',()=>{
+ for(const change of [{appId:'other'},{port:7337},{serverURL:'https://other.example/parse'},{publicServerURL:'https://other.example/parse'}]){
+  assert.throws(()=>internalRoutingConfig({...fixture(),parse:{...fixture().parse,...change}}));
+ }
+});