| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204 |
- import { HttpClient, HttpErrorResponse } from '@angular/common/http';
- import { inject, Injectable } from '@angular/core';
- import { Router } from '@angular/router';
- import { catchError, finalize, from, map, Observable, retry, shareReplay, Subject, switchMap, tap, throwError, timer } from 'rxjs';
- import { API_CONFIG } from './app.constants';
- import { requiresCloudSession } from './cloud-action-migration';
- import { ApiEnvelope } from './models';
- import { SessionService } from './session.service';
- export class ApiError extends Error {
- constructor(message: string, readonly status = 0, readonly payload?: unknown) {
- super(message);
- }
- }
- @Injectable({ providedIn: 'root' })
- export class ApiService {
- private readonly http = inject(HttpClient);
- private readonly sessions = inject(SessionService);
- private readonly router = inject(Router, { optional: true });
- readonly dataChanges$ = new Subject<{ source: 'local' | 'broadcast'; changedAt: string }>();
- private readonly overviewRequests = new Map<string, Observable<ApiEnvelope<unknown>>>();
- private readonly cachedOverviewActions = new Set(['app_learning_overview', 'app_companion_overview', 'app_account_overview']);
- private readonly dataChannel = this.createDataChannel();
- private sessionRedirecting = false;
- private readonly pendingWrites=new Map<string,string>();
- get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
- const token = this.parseSessionToken();
- return this.callWithOverviewCache<T>(action, params, token);
- }
- post<T>(action: string, body: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
- const token = this.parseSessionToken();
- return this.callWithOverviewCache<T>(action, body, token);
- }
- callCloud<T>(action: string, params: Record<string, unknown> = {}, sessionToken = this.parseSessionToken()): Observable<ApiEnvelope<T>> {
- 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);
- const requestIdentity=this.parseSessionToken()+'|'+action+'|'+this.stableJson(params);
- if(teachingWrite && !params['requestKey']) {
- const key=this.pendingWrites.get(requestIdentity)||crypto.randomUUID();
- this.pendingWrites.set(requestIdentity,key);params={...params,requestKey:key};
- }
- const body = {
- ...(sessionToken ? { token: sessionToken } : {}),
- params: { action, ...params },
- };
- return this.resilient<T>(this.http.post<unknown>(API_CONFIG.cloudFunctionUrl, body, {
- headers: {
- 'Content-Type': 'application/json',
- 'X-Parse-Application-Id': API_CONFIG.parseAppId,
- },
- }), sessionToken.startsWith('r:') || requiresCloudSession(action, params), this.isReadAction(action)).pipe(tap(() => {
- if (!this.isReadAction(action)) {
- this.invalidateOverviews();
- this.announceDataChange();
- }
- this.pendingWrites.delete(requestIdentity);
- }));
- }
- upload(file: File): Observable<ApiEnvelope<string>> {
- return from(this.fileAsBase64(file)).pipe(switchMap((base64) => this.callCloud<string>('upload', {
- fileName: file.name,
- contentType: file.type,
- base64,
- }, this.parseSessionToken())));
- }
- assetUrl(path: unknown): string {
- const value = String(path ?? '').trim();
- if (!value) return `${API_CONFIG.baseUrl}/UploadFiles/nopic.svg`;
- if (/^(data:image|https?:\/\/)/i.test(value)) return value;
- return `${API_CONFIG.baseUrl}${value.startsWith('/') ? value : `/${value}`}`;
- }
- invalidateOverviews(): void { this.overviewRequests.clear(); }
- private isReadAction(action: string): boolean {
- return /(?:_list|_get|_detail|_overview|_tongji|_tj|_is)$/.test(action)
- || ['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);
- }
- private normalize<T>(input: unknown): ApiEnvelope<T> {
- const envelope = (input ?? {}) as ApiEnvelope<unknown>;
- let result = envelope.result;
- if (typeof result === 'string') {
- try {
- result = JSON.parse(result) as unknown;
- } catch {
- // Some endpoints intentionally return plain text.
- }
- }
- const normalized = { ...envelope, result } as ApiEnvelope<T>;
- if (normalized.retcode === -1) {
- throw new ApiError(normalized.retmsg || '请求失败', 200, normalized);
- }
- return normalized;
- }
- private fileAsBase64(file: File): Promise<string> {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onerror = () => reject(new ApiError('文件读取失败'));
- reader.onload = () => resolve(String(reader.result ?? '').replace(/^data:[^;]+;base64,/i, ''));
- reader.readAsDataURL(file);
- });
- }
- private resilient<T>(request: Observable<unknown>, authenticatedRequest = false, retryRead = true): Observable<ApiEnvelope<T>> {
- return request.pipe(
- retry({
- count: retryRead ? 2 : 0,
- delay: (error, retryCount) => this.isTransientError(error)
- ? timer(retryCount * 400)
- : throwError(() => error),
- }),
- map((response) => this.normalize<T>(response)),
- catchError((error) => this.handleError(error, authenticatedRequest)),
- );
- }
- private callWithOverviewCache<T>(action: string, params: Record<string, unknown>, sessionToken: string): Observable<ApiEnvelope<T>> {
- if (!this.cachedOverviewActions.has(action)) return this.callCloud<T>(action, params, sessionToken);
- const cacheParams = { ...params };
- delete cacheParams['refresh'];
- const key = `${this.sessionCacheIdentity()}|${this.parseSessionToken()}|${action}|${this.stableJson(cacheParams)}`;
- const pending = this.overviewRequests.get(key);
- if (pending) return pending as Observable<ApiEnvelope<T>>;
- const response = this.callCloud<T>(action, params, sessionToken).pipe(
- finalize(() => this.overviewRequests.delete(key)),
- shareReplay({ bufferSize: 1, refCount: false }),
- );
- this.overviewRequests.set(key, response as Observable<ApiEnvelope<unknown>>);
- return response;
- }
- private createDataChannel(): BroadcastChannel | null {
- if (typeof globalThis.BroadcastChannel !== 'function') return null;
- const channel = new globalThis.BroadcastChannel('xiaoshu-live-data-v1');
- channel.addEventListener('message', () => this.dataChanges$.next({ source: 'broadcast', changedAt: new Date().toISOString() }));
- return channel;
- }
- private announceDataChange(): void {
- const event = { source: 'local' as const, changedAt: new Date().toISOString() };
- this.dataChanges$.next(event);
- this.dataChannel?.postMessage(event);
- }
- private sessionCacheIdentity(): string {
- const current = this.sessions.user();
- return String(current?.['objectId'] || current?.userId || this.parseSessionToken().slice(-24) || 'anonymous');
- }
- private stableJson(value: unknown): string {
- if (Array.isArray(value)) return `[${value.map((item) => this.stableJson(item)).join(',')}]`;
- if (value && typeof value === 'object') {
- return `{${Object.entries(value as Record<string, unknown>)
- .sort(([left], [right]) => left.localeCompare(right))
- .map(([key, item]) => `${JSON.stringify(key)}:${this.stableJson(item)}`).join(',')}}`;
- }
- return JSON.stringify(value) ?? 'null';
- }
- private handleError(error: unknown, authenticatedRequest: boolean): Observable<never> {
- if (error instanceof ApiError) return throwError(() => error);
- if (error instanceof HttpErrorResponse) {
- if (error.status === 401 && authenticatedRequest) this.expireSession();
- const payload = error.error as { retmsg?: string } | string | null;
- const message = this.isTransientError(error)
- ? '请求暂未完成,请检查网络后重试;已填写内容会保留。'
- : typeof payload === 'object' && payload?.retmsg
- ? payload.retmsg
- : error.message || '网络请求失败';
- return throwError(() => new ApiError(message, error.status, payload));
- }
- return throwError(() => new ApiError(error instanceof Error ? error.message : '未知错误'));
- }
- private parseSessionToken(): string {
- const token = this.sessions.token();
- return token.startsWith('r:') ? token : '';
- }
- private isTransientError(error: unknown): boolean {
- return error instanceof HttpErrorResponse && [0, 502, 503, 504].includes(error.status);
- }
- private expireSession(): void {
- this.overviewRequests.clear();
- this.sessions.logout();
- if (!this.router || this.sessionRedirecting) return;
- this.sessionRedirecting = true;
- const currentUrl = this.router.url && !this.router.url.startsWith('/pages/member/wxauth')
- ? this.router.url
- : '/pages/home/index';
- void this.router.navigate(['/pages/member/wxauth'], {
- queryParams: { returnUrl: currentUrl, expired: 1 },
- replaceUrl: true,
- }).finally(() => this.sessionRedirecting = false);
- }
- }
|