| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152 |
- 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<T>(operation: string, params: Record<string, unknown> = {}): Promise<T> {
- const token = this.sessions.session()?.sessionToken;
- if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
- try {
- 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;
- } 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<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 || '');
- 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<T>(work: () => Promise<T>): Promise<T> {
- 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<T>(operation: string, params: Record<string, unknown> = {}): Promise<T> {
- const token = this.sessions.session()?.sessionToken;
- if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
- try {
- 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;
- } 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<T>(action: string, params: Record<string, unknown> = {}): Promise<T> {
- const token = this.sessions.session()?.sessionToken;
- if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
- try {
- 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) {
- 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 : '未知云函数错误');
- }
- }
- }
|