cloud-functions.service.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. import { HttpClient, HttpErrorResponse } from '@angular/common/http';
  2. import { inject, Injectable } from '@angular/core';
  3. import { firstValueFrom, Subject, timeout, throwError, retry, timer, mergeMap, of } from 'rxjs';
  4. import { CLOUD_FUNCTION_BASE_URL } from './admin.constants';
  5. import { AdminSessionService } from './admin-session.service';
  6. import { CloudEnvelope, LegacyCloudEnvelope } from './admin.models';
  7. export class CloudFunctionError extends Error {
  8. constructor(message: string, readonly status = 0, readonly payload?: unknown) {
  9. super(message);
  10. }
  11. }
  12. @Injectable({ providedIn: 'root' })
  13. export class CloudFunctionsService {
  14. readonly dataChanges$ = new Subject<'local' | 'broadcast'>();
  15. private readonly http = inject(HttpClient);
  16. private readonly sessions = inject(AdminSessionService);
  17. private readonly dataChannel = this.createDataChannel();
  18. async admin<T>(operation: string, params: Record<string, unknown> = {}): Promise<T> {
  19. const token = this.sessions.session()?.sessionToken;
  20. if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
  21. try {
  22. const envelope = await this.request<CloudEnvelope<T>>('xiaoshu/admin/gateway', token,
  23. { ...params, operation }, false);
  24. if (!envelope.success) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.message || envelope.error || '云函数执行失败'); }
  25. if (this.isMutation(operation)) this.announceDataChange();
  26. return envelope.data;
  27. } catch (error) {
  28. if (error instanceof CloudFunctionError) throw error;
  29. if (error instanceof HttpErrorResponse) {
  30. const payload = error.error as { error?: string; message?: string } | null;
  31. await this.handleAuthenticationFailure(token, error.status, payload);
  32. throw new CloudFunctionError(payload?.message || payload?.error || error.message, error.status, payload);
  33. }
  34. throw new CloudFunctionError(error instanceof Error ? error.message : '未知云函数错误');
  35. }
  36. }
  37. // Explicitly allow only reads. Unknown operations and all submissions must never replay.
  38. private isReadOperation(operation: string): boolean {
  39. return /^ops\/(?:users\/(?:list|detail|notes|audit|credit-preview|credit-accounts|credit-ledger|credit-status)|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|list))$/.test(operation);
  40. }
  41. private transportMessage(readOnly: boolean): string {
  42. return readOnly
  43. ? '数据加载暂未完成,请稍后重试;已填写内容请保留。'
  44. : '连接暂未完成,提交结果待核对;请先刷新核对,避免重复提交,已填写内容请保留。';
  45. }
  46. private async request<T>(path: string, token: string, params: Record<string, unknown>, readOnly: boolean, timeoutMs = 20000): Promise<T> {
  47. try {
  48. return await firstValueFrom(this.http.post<T>(`${CLOUD_FUNCTION_BASE_URL}/${path}`, { token, params }).pipe(
  49. timeout({ first: timeoutMs, with: () => throwError(() => new CloudFunctionError(this.transportMessage(readOnly))) }),
  50. retry({ count: 1, delay: (error) => {
  51. const transient = error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status);
  52. // A timeout may leave work running on the server; do not add another slow request.
  53. if (!readOnly || !transient || this.sessions.session()?.sessionToken !== token) return throwError(() => error);
  54. return timer(500).pipe(mergeMap(() => this.sessions.session()?.sessionToken === token ? of(0) : throwError(() => error)));
  55. } }),
  56. ));
  57. } catch (error) {
  58. if (error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status)) {
  59. throw new CloudFunctionError(this.transportMessage(readOnly), error.status);
  60. }
  61. throw error;
  62. }
  63. }
  64. private async handleAuthenticationFailure(token: string, status: number, payload: unknown): Promise<void> {
  65. const body = payload as { code?: number; error?: string; message?: string; retmsg?: string } | null;
  66. const message = String(body?.message || body?.retmsg || body?.error || '');
  67. const invalid = status === 401 || Number(body?.code) === 209 ||
  68. /^(登录状态已失效|管理员会话已失效|账号已停用|账号已删除|invalid session token)[::。.!]?$/i.test(message.trim());
  69. if (/^(账号已停用|账号已删除)[::。.!]?$/.test(message.trim())) this.sessions.invalidateSession(token);
  70. else if (invalid) await this.sessions.verifySessionAfterFailure(token);
  71. }
  72. private isMutation(operation: string): boolean {
  73. return /\/(?:create|save|update|delete|recycle|restore|complete|adjust|post|commit|confirm|cancel|review|mark-paid|deactivate|run|retry|refresh|add-adjustment|exclude-line|import-commit)$/.test(operation);
  74. }
  75. private createDataChannel(): BroadcastChannel | null {
  76. if (typeof globalThis.BroadcastChannel !== 'function') return null;
  77. const channel = new globalThis.BroadcastChannel('xiaoshu-live-data-v1');
  78. channel.addEventListener('message', () => this.dataChanges$.next('broadcast'));
  79. return channel;
  80. }
  81. private changeBatchDepth = 0;
  82. private pendingDataChange = false;
  83. async batchDataChanges<T>(work: () => Promise<T>): Promise<T> {
  84. this.changeBatchDepth++;
  85. try { return await work(); }
  86. finally {
  87. // Even a lost write response may have committed. Refresh once at the end.
  88. this.pendingDataChange = true;
  89. if (--this.changeBatchDepth === 0 && this.pendingDataChange) {
  90. this.pendingDataChange = false;
  91. this.announceDataChange();
  92. }
  93. }
  94. }
  95. private announceDataChange(): void {
  96. if (this.changeBatchDepth) { this.pendingDataChange = true; return; }
  97. this.dataChanges$.next('local');
  98. this.dataChannel?.postMessage({ changedAt: new Date().toISOString() });
  99. }
  100. async operations<T>(operation: string, params: Record<string, unknown> = {}): Promise<T> {
  101. const token = this.sessions.session()?.sessionToken;
  102. if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
  103. try {
  104. const envelope = await this.request<CloudEnvelope<T>>('xiaoshu/ops/gateway-v3', token,
  105. { ...params, operation }, this.isReadOperation(operation),
  106. /^ops\/vocabulary\/import-(preview|commit)$/.test(operation) ? 60000 : 20000);
  107. if (!envelope.success) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.message || envelope.error || '运营云函数执行失败'); }
  108. if (this.isMutation(operation)) this.announceDataChange();
  109. return envelope.data;
  110. } catch (error) {
  111. if (error instanceof CloudFunctionError) throw error;
  112. if (error instanceof HttpErrorResponse) {
  113. const payload = error.error as { error?: string; message?: string } | null;
  114. await this.handleAuthenticationFailure(token, error.status, payload);
  115. throw new CloudFunctionError(payload?.message || payload?.error || error.message, error.status, payload);
  116. }
  117. throw new CloudFunctionError(error instanceof Error ? error.message : '未知运营云函数错误');
  118. }
  119. }
  120. async app<T>(action: string, params: Record<string, unknown> = {}): Promise<T> {
  121. const token = this.sessions.session()?.sessionToken;
  122. if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
  123. try {
  124. const envelope = await this.request<LegacyCloudEnvelope<T>>('xiaoshu/app/gateway', token,
  125. { ...params, action }, false);
  126. if (envelope.retcode === -1) { await this.handleAuthenticationFailure(token, 0, envelope); throw new CloudFunctionError(envelope.retmsg || '云函数执行失败'); }
  127. return envelope.result;
  128. } catch (error) {
  129. if (error instanceof CloudFunctionError) throw error;
  130. if (error instanceof HttpErrorResponse) {
  131. const payload = error.error as { error?: string; message?: string; retmsg?: string } | null;
  132. await this.handleAuthenticationFailure(token, error.status, payload);
  133. throw new CloudFunctionError(payload?.retmsg || payload?.message || payload?.error || error.message, error.status, payload);
  134. }
  135. throw new CloudFunctionError(error instanceof Error ? error.message : '未知云函数错误');
  136. }
  137. }
  138. }