import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; 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'; export class CloudFunctionError extends Error { constructor(message: string, readonly status = 0, readonly payload?: unknown) { super(message); } } @Injectable({ providedIn: 'root' }) export class CloudFunctionsService { readonly dataChanges$ = new Subject<'local' | 'broadcast'>(); private readonly http = inject(HttpClient); private readonly sessions = inject(AdminSessionService); private readonly dataChannel = this.createDataChannel(); async admin(operation: string, params: Record = {}): Promise { const token = this.sessions.session()?.sessionToken; if (!token) throw new CloudFunctionError('管理员会话已失效', 401); try { const envelope = await this.request>('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; } catch (error) { if (error instanceof CloudFunctionError) throw error; if (error instanceof HttpErrorResponse) { const payload = error.error as { error?: string; message?: string } | null; await this.handleAuthenticationFailure(token, error.status, payload); throw new CloudFunctionError(payload?.message || payload?.error || error.message, error.status, payload); } throw new CloudFunctionError(error instanceof Error ? error.message : '未知云函数错误'); } } // Explicitly allow only reads. Unknown operations and all submissions must never replay. private isReadOperation(operation: string): boolean { 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); } private transportMessage(readOnly: boolean): string { return readOnly ? '数据加载暂未完成,请稍后重试;已填写内容请保留。' : '连接暂未完成,提交结果待核对;请先刷新核对,避免重复提交,已填写内容请保留。'; } private async request(path: string, token: string, params: Record, readOnly: boolean, timeoutMs = 20000): Promise { try { return await firstValueFrom(this.http.post(`${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 { const body = payload as { code?: number; error?: string; message?: string; retmsg?: string } | null; const message = String(body?.message || body?.retmsg || body?.error || ''); const invalid = status === 401 || Number(body?.code) === 209 || /^(登录状态已失效|管理员会话已失效|账号已停用|账号已删除|invalid session token)[::。.!]?$/i.test(message.trim()); if (/^(账号已停用|账号已删除)[::。.!]?$/.test(message.trim())) this.sessions.invalidateSession(token); else if (invalid) await this.sessions.verifySessionAfterFailure(token); } private isMutation(operation: string): boolean { 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); } 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('broadcast')); return channel; } private changeBatchDepth = 0; private pendingDataChange = false; async batchDataChanges(work: () => Promise): Promise { this.changeBatchDepth++; try { return await work(); } finally { // Even a lost write response may have committed. Refresh once at the end. this.pendingDataChange = true; if (--this.changeBatchDepth === 0 && this.pendingDataChange) { this.pendingDataChange = false; this.announceDataChange(); } } } private announceDataChange(): void { if (this.changeBatchDepth) { this.pendingDataChange = true; return; } this.dataChanges$.next('local'); this.dataChannel?.postMessage({ changedAt: new Date().toISOString() }); } async operations(operation: string, params: Record = {}): Promise { const token = this.sessions.session()?.sessionToken; if (!token) throw new CloudFunctionError('管理员会话已失效', 401); try { const envelope = await this.request>('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; } catch (error) { if (error instanceof CloudFunctionError) throw error; if (error instanceof HttpErrorResponse) { const payload = error.error as { error?: string; message?: string } | null; await this.handleAuthenticationFailure(token, error.status, payload); throw new CloudFunctionError(payload?.message || payload?.error || error.message, error.status, payload); } throw new CloudFunctionError(error instanceof Error ? error.message : '未知运营云函数错误'); } } async app(action: string, params: Record = {}): Promise { const token = this.sessions.session()?.sessionToken; if (!token) throw new CloudFunctionError('管理员会话已失效', 401); try { const envelope = await this.request>('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) { if (error instanceof CloudFunctionError) throw error; if (error instanceof HttpErrorResponse) { const payload = error.error as { error?: string; message?: string; retmsg?: string } | null; await this.handleAuthenticationFailure(token, error.status, payload); throw new CloudFunctionError(payload?.retmsg || payload?.message || payload?.error || error.message, error.status, payload); } throw new CloudFunctionError(error instanceof Error ? error.message : '未知云函数错误'); } } }