api.service.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import { HttpClient, HttpErrorResponse } from '@angular/common/http';
  2. import { inject, Injectable } from '@angular/core';
  3. import { Router } from '@angular/router';
  4. import { catchError, finalize, from, map, Observable, retry, shareReplay, Subject, switchMap, tap, throwError, timer } from 'rxjs';
  5. import { API_CONFIG } from './app.constants';
  6. import { requiresCloudSession } from './cloud-action-migration';
  7. import { ApiEnvelope } from './models';
  8. import { SessionService } from './session.service';
  9. export class ApiError extends Error {
  10. constructor(message: string, readonly status = 0, readonly payload?: unknown) {
  11. super(message);
  12. }
  13. }
  14. @Injectable({ providedIn: 'root' })
  15. export class ApiService {
  16. private readonly http = inject(HttpClient);
  17. private readonly sessions = inject(SessionService);
  18. private readonly router = inject(Router, { optional: true });
  19. readonly dataChanges$ = new Subject<{ source: 'local' | 'broadcast'; changedAt: string }>();
  20. private readonly overviewRequests = new Map<string, Observable<ApiEnvelope<unknown>>>();
  21. private readonly cachedOverviewActions = new Set(['app_learning_overview', 'app_companion_overview', 'app_account_overview']);
  22. private readonly dataChannel = this.createDataChannel();
  23. private sessionRedirecting = false;
  24. private readonly pendingWrites=new Map<string,string>();
  25. get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
  26. const token = this.parseSessionToken();
  27. return this.callWithOverviewCache<T>(action, params, token);
  28. }
  29. post<T>(action: string, body: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
  30. const token = this.parseSessionToken();
  31. return this.callWithOverviewCache<T>(action, body, token);
  32. }
  33. callCloud<T>(action: string, params: Record<string, unknown> = {}, sessionToken = this.parseSessionToken()): Observable<ApiEnvelope<T>> {
  34. const teachingWrite=['e_add_words','content_add_zt','content_update','content_add','stu_record_update_v2','unit_record_update','vote_add','reading/create'].includes(action);
  35. const requestIdentity=this.parseSessionToken()+'|'+action+'|'+this.stableJson(params);
  36. if(teachingWrite && !params['requestKey']) {
  37. const key=this.pendingWrites.get(requestIdentity)||crypto.randomUUID();
  38. this.pendingWrites.set(requestIdentity,key);params={...params,requestKey:key};
  39. }
  40. const body = {
  41. ...(sessionToken ? { token: sessionToken } : {}),
  42. params: { action, ...params },
  43. };
  44. return this.resilient<T>(this.http.post<unknown>(API_CONFIG.cloudFunctionUrl, body, {
  45. headers: {
  46. 'Content-Type': 'application/json',
  47. 'X-Parse-Application-Id': API_CONFIG.parseAppId,
  48. },
  49. }), sessionToken.startsWith('r:') || requiresCloudSession(action, params), this.isReadAction(action)).pipe(tap(() => {
  50. if (!this.isReadAction(action)) {
  51. this.invalidateOverviews();
  52. this.announceDataChange();
  53. }
  54. this.pendingWrites.delete(requestIdentity);
  55. }));
  56. }
  57. upload(file: File): Observable<ApiEnvelope<string>> {
  58. return from(this.fileAsBase64(file)).pipe(switchMap((base64) => this.callCloud<string>('upload', {
  59. fileName: file.name,
  60. contentType: file.type,
  61. base64,
  62. }, this.parseSessionToken())));
  63. }
  64. assetUrl(path: unknown): string {
  65. const value = String(path ?? '').trim();
  66. if (!value) return `${API_CONFIG.baseUrl}/UploadFiles/nopic.svg`;
  67. if (/^(data:image|https?:\/\/)/i.test(value)) return value;
  68. return `${API_CONFIG.baseUrl}${value.startsWith('/') ? value : `/${value}`}`;
  69. }
  70. invalidateOverviews(): void { this.overviewRequests.clear(); }
  71. private isReadAction(action: string): boolean {
  72. return /(?:_list|_get|_detail|_overview|_tongji|_tj|_is)$/.test(action)
  73. || ['reading/me','reading/query','reading/get','migration_status', 'app_update', 'node_list', 'node_get', 'content_get', 'vote_ask', 'vote_question', 'e_get_21list', 'app_live_versions', 'app_review_page', 'app_lesson_report'].includes(action);
  74. }
  75. private normalize<T>(input: unknown): ApiEnvelope<T> {
  76. const envelope = (input ?? {}) as ApiEnvelope<unknown>;
  77. let result = envelope.result;
  78. if (typeof result === 'string') {
  79. try {
  80. result = JSON.parse(result) as unknown;
  81. } catch {
  82. // Some endpoints intentionally return plain text.
  83. }
  84. }
  85. const normalized = { ...envelope, result } as ApiEnvelope<T>;
  86. if (normalized.retcode === -1) {
  87. throw new ApiError(normalized.retmsg || '请求失败', 200, normalized);
  88. }
  89. return normalized;
  90. }
  91. private fileAsBase64(file: File): Promise<string> {
  92. return new Promise((resolve, reject) => {
  93. const reader = new FileReader();
  94. reader.onerror = () => reject(new ApiError('文件读取失败'));
  95. reader.onload = () => resolve(String(reader.result ?? '').replace(/^data:[^;]+;base64,/i, ''));
  96. reader.readAsDataURL(file);
  97. });
  98. }
  99. private resilient<T>(request: Observable<unknown>, authenticatedRequest = false, retryRead = true): Observable<ApiEnvelope<T>> {
  100. return request.pipe(
  101. retry({
  102. count: retryRead ? 2 : 0,
  103. delay: (error, retryCount) => this.isTransientError(error)
  104. ? timer(retryCount * 400)
  105. : throwError(() => error),
  106. }),
  107. map((response) => this.normalize<T>(response)),
  108. catchError((error) => this.handleError(error, authenticatedRequest)),
  109. );
  110. }
  111. private callWithOverviewCache<T>(action: string, params: Record<string, unknown>, sessionToken: string): Observable<ApiEnvelope<T>> {
  112. if (!this.cachedOverviewActions.has(action)) return this.callCloud<T>(action, params, sessionToken);
  113. const cacheParams = { ...params };
  114. delete cacheParams['refresh'];
  115. const key = `${this.sessionCacheIdentity()}|${this.parseSessionToken()}|${action}|${this.stableJson(cacheParams)}`;
  116. const pending = this.overviewRequests.get(key);
  117. if (pending) return pending as Observable<ApiEnvelope<T>>;
  118. const response = this.callCloud<T>(action, params, sessionToken).pipe(
  119. finalize(() => this.overviewRequests.delete(key)),
  120. shareReplay({ bufferSize: 1, refCount: false }),
  121. );
  122. this.overviewRequests.set(key, response as Observable<ApiEnvelope<unknown>>);
  123. return response;
  124. }
  125. private createDataChannel(): BroadcastChannel | null {
  126. if (typeof globalThis.BroadcastChannel !== 'function') return null;
  127. const channel = new globalThis.BroadcastChannel('xiaoshu-live-data-v1');
  128. channel.addEventListener('message', () => this.dataChanges$.next({ source: 'broadcast', changedAt: new Date().toISOString() }));
  129. return channel;
  130. }
  131. private announceDataChange(): void {
  132. const event = { source: 'local' as const, changedAt: new Date().toISOString() };
  133. this.dataChanges$.next(event);
  134. this.dataChannel?.postMessage(event);
  135. }
  136. private sessionCacheIdentity(): string {
  137. const current = this.sessions.user();
  138. return String(current?.['objectId'] || current?.userId || this.parseSessionToken().slice(-24) || 'anonymous');
  139. }
  140. private stableJson(value: unknown): string {
  141. if (Array.isArray(value)) return `[${value.map((item) => this.stableJson(item)).join(',')}]`;
  142. if (value && typeof value === 'object') {
  143. return `{${Object.entries(value as Record<string, unknown>)
  144. .sort(([left], [right]) => left.localeCompare(right))
  145. .map(([key, item]) => `${JSON.stringify(key)}:${this.stableJson(item)}`).join(',')}}`;
  146. }
  147. return JSON.stringify(value) ?? 'null';
  148. }
  149. private handleError(error: unknown, authenticatedRequest: boolean): Observable<never> {
  150. if (error instanceof ApiError) return throwError(() => error);
  151. if (error instanceof HttpErrorResponse) {
  152. if (error.status === 401 && authenticatedRequest) this.expireSession();
  153. const payload = error.error as { retmsg?: string } | string | null;
  154. const message = this.isTransientError(error)
  155. ? '请求暂未完成,请检查网络后重试;已填写内容会保留。'
  156. : typeof payload === 'object' && payload?.retmsg
  157. ? payload.retmsg
  158. : error.message || '网络请求失败';
  159. return throwError(() => new ApiError(message, error.status, payload));
  160. }
  161. return throwError(() => new ApiError(error instanceof Error ? error.message : '未知错误'));
  162. }
  163. private parseSessionToken(): string {
  164. const token = this.sessions.token();
  165. return token.startsWith('r:') ? token : '';
  166. }
  167. private isTransientError(error: unknown): boolean {
  168. return error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status);
  169. }
  170. private expireSession(): void {
  171. this.overviewRequests.clear();
  172. this.sessions.logout();
  173. if (!this.router || this.sessionRedirecting) return;
  174. this.sessionRedirecting = true;
  175. const currentUrl = this.router.url && !this.router.url.startsWith('/pages/member/wxauth')
  176. ? this.router.url
  177. : '/pages/home/index';
  178. void this.router.navigate(['/pages/member/wxauth'], {
  179. queryParams: { returnUrl: currentUrl, expired: 1 },
  180. replaceUrl: true,
  181. }).finally(() => this.sessionRedirecting = false);
  182. }
  183. }