Просмотр исходного кода

fix(admin): verify sessions before automatic sign-out

彭峰 1 неделя назад
Родитель
Сommit
6ba9834f99

+ 27 - 0
docs/operations/admin-session-stability-2026-10-13.md

@@ -0,0 +1,27 @@
+# 管理后台操作中自动退出:排查与修复(2026-10-13)
+
+用户确认问题发生在操作过程中。当前发现并修复的是前端误退出路径;没有留存用户当次失败请求的网络记录,因此不把某一次具体 401 的上游原因视为已复现。
+
+## 核实结果
+
+- 生产只读检查发现 25 个管理员会话,期限约 365 天且均有效。没有发现后台设置了分钟级会话期限;未延长服务器会话期限。
+- admin/operations 的任意 HTTP 401 都会直接调用 `logout()`,该方法会清除本地状态并向服务端注销会话,没有先核实该 token 是否真的失效。
+- app 接口额外把所有 HTTP 403 也视为登录失效,混淆了操作权限不足和登录过期。
+- 请求失败时注销的是“当前”会话,而不是请求发出时的会话;旧请求的迟到错误能影响新登录。
+- 登录页在身份信息查询或后续导航失败时无条件注销刚刚成功的登录。
+- sessionStorage 只随当前标签页保存,是重开标签页丢失登录的独立问题,并非用户本次操作中退出的充分解释。
+
+## 修复行为
+
+1. 业务网关返回鉴权错误时,使用同一个请求 token 调用 Parse `/users/me` 复核;并发检查合并,不重发原业务写操作。会话有效或复核网络故障时保留登录;确认 token 无效、账号停用或删除时清除状态并返回登录页。
+2. 普通 403 权限拒绝只返回操作错误。失效处理按请求 token 匹配当前会话,且不再发送多余的服务端 logout。
+3. 登录成功后身份查询遇到网络/服务错误不再注销;新登录明确没有管理员权限时才退出该次登录。旧身份元数据也不能覆盖其他账号。
+4. 登录状态持久保存并兼容现存标签页会话;跨标签页主动退出同步清除,保留返回原页面的地址。服务器权限验证仍是业务请求的依据。
+
+## 验证边界
+
+- ChromeHeadless:会话服务、云请求服务、登录组件、页面缓存和实时刷新共 27 项测试通过。
+- 管理后台生产构建通过,只有已有样式预算/CommonJS 警告。
+- 真实 Parse `/users/me` 返回 HTTP 200,验证端点可用;临时只读检查函数已删除。
+- 本机 `http://127.0.0.1:4300` 页面及脚本 HTTP 200,实际脚本包含新会话复核、持久保存及按 token 失效处理。
+- 未宣称已经复现用户当时的具体失败接口,也未把本机构建等同线上静态站点发布。

+ 75 - 1
projects/xiaoshu-admin/src/app/admin-session.service.spec.ts

@@ -2,7 +2,7 @@ import { provideHttpClient } from '@angular/common/http';
 import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
 import { TestBed } from '@angular/core/testing';
 import { Router } from '@angular/router';
-import { ADMIN_SESSION_KEY, CLOUD_FUNCTION_BASE_URL } from './admin.constants';
+import { ADMIN_PARSE_URL, ADMIN_SESSION_KEY, CLOUD_FUNCTION_BASE_URL } from './admin.constants';
 import { AdminSessionService } from './admin-session.service';
 
 describe('AdminSessionService', () => {
@@ -11,6 +11,7 @@ describe('AdminSessionService', () => {
 
   beforeEach(() => {
     sessionStorage.clear();
+    localStorage.removeItem(ADMIN_SESSION_KEY);
     router = jasmine.createSpyObj<Router>('Router', ['navigate']);
     TestBed.configureTestingModule({
       providers: [provideHttpClient(), provideHttpClientTesting(), AdminSessionService, { provide: Router, useValue: router }],
@@ -21,6 +22,7 @@ describe('AdminSessionService', () => {
   afterEach(() => {
     http.verify();
     sessionStorage.clear();
+    localStorage.removeItem(ADMIN_SESSION_KEY);
   });
 
   it('logs administrators in through the app cloud gateway', async () => {
@@ -33,6 +35,7 @@ describe('AdminSessionService', () => {
 
     await expectAsync(result).toBeResolvedTo(jasmine.objectContaining({ objectId: 'admin-1', username: 'admin', displayName: '管理员', sessionToken: 'r:admin-token' }));
     expect(JSON.parse(sessionStorage.getItem(ADMIN_SESSION_KEY) || '{}').sessionToken).toBe('r:admin-token');
+    expect(JSON.parse(localStorage.getItem(ADMIN_SESSION_KEY) || '{}').sessionToken).toBe('r:admin-token');
   });
 
   it('revokes the Parse session through the admin cloud function on logout', () => {
@@ -47,4 +50,75 @@ describe('AdminSessionService', () => {
     expect(sessionStorage.getItem(ADMIN_SESSION_KEY)).toBeNull();
     request.flush({ success: true, data: { revoked: true } });
   });
+  it('restores a login after the previous tab was closed and mirrors it for route caches', () => {
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'saved'}));
+    const service=TestBed.inject(AdminSessionService);
+    expect(service.session()?.sessionToken).toBe('saved');
+    expect(JSON.parse(sessionStorage.getItem(ADMIN_SESSION_KEY)!).sessionToken).toBe('saved');
+  });
+
+  it('migrates an existing tab login without requiring another password', () => {
+    sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'legacy'}));
+    expect(TestBed.inject(AdminSessionService).session()?.sessionToken).toBe('legacy');
+    expect(JSON.parse(localStorage.getItem(ADMIN_SESSION_KEY)!).sessionToken).toBe('legacy');
+  });
+
+  it('ignores failure from an older session and never revokes the new one', () => {
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'new'}));
+    const service=TestBed.inject(AdminSessionService);
+    service.invalidateSession('old');
+    expect(service.session()?.sessionToken).toBe('new');
+    expect(router.navigate).not.toHaveBeenCalled();
+    http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`);
+  });
+
+  it('clears a confirmed expired session once, without sending a logout request', () => {
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'expired'}));
+    const service=TestBed.inject(AdminSessionService);
+    service.invalidateSession('expired');service.invalidateSession('expired');
+    expect(service.session()).toBeNull();expect(localStorage.getItem(ADMIN_SESSION_KEY)).toBeNull();
+    expect(sessionStorage.getItem(ADMIN_SESSION_KEY)).toBeNull();expect(router.navigate).toHaveBeenCalledTimes(1);
+    http.expectNone(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`);
+  });
+
+  it('shares logout across tabs without another server logout call', () => {
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'shared'}));
+    const service=TestBed.inject(AdminSessionService);
+    localStorage.removeItem(ADMIN_SESSION_KEY);
+    window.dispatchEvent(new StorageEvent('storage',{key:ADMIN_SESSION_KEY,newValue:null,storageArea:localStorage}));
+    expect(service.session()).toBeNull();expect(sessionStorage.getItem(ADMIN_SESSION_KEY)).toBeNull();
+    expect(router.navigate).toHaveBeenCalled();
+  });
+
+  it('deduplicates gateway-failure checks and retains a session that Parse confirms is valid', async () => {
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'valid'}));
+    const service=TestBed.inject(AdminSessionService);
+    const a=service.verifySessionAfterFailure('valid'),b=service.verifySessionAfterFailure('valid');
+    expect(a).toBe(b);
+    const req=http.expectOne(`${ADMIN_PARSE_URL}/users/me`);
+    expect(req.request.headers.get('X-Parse-Session-Token')).toBe('valid');req.flush({objectId:'admin-1'});
+    await a;expect(service.isAuthenticated()).toBeTrue();expect(router.navigate).not.toHaveBeenCalled();
+  });
+
+  it('requires re-login after Parse confirms an invalid token', async () => {
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'expired'}));
+    const service=TestBed.inject(AdminSessionService),pending=service.verifySessionAfterFailure('expired');
+    http.expectOne(`${ADMIN_PARSE_URL}/users/me`).flush({code:209,error:'invalid session token'},{status:400,statusText:'Bad Request'});
+    await pending;expect(service.isAuthenticated()).toBeFalse();expect(router.navigate).toHaveBeenCalled();
+  });
+
+  it('keeps the login when session verification has a network failure', async () => {
+    localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({objectId:'admin-1',sessionToken:'valid'}));
+    const service=TestBed.inject(AdminSessionService),pending=service.verifySessionAfterFailure('valid');
+    http.expectOne(`${ADMIN_PARSE_URL}/users/me`).error(new ProgressEvent('network'));
+    await pending;expect(service.isAuthenticated()).toBeTrue();
+  });
+
+  it('does not apply stale identity metadata to a different account',()=>{
+    localStorage.setItem(ADMIN_SESSION_KEY,JSON.stringify({objectId:'new-admin',sessionToken:'new'}));
+    const service=TestBed.inject(AdminSessionService);
+    service.applyIdentity({objectId:'old-admin',username:'old',displayName:'old',roles:['super-admin'],isSuperAdmin:true,operationsRole:'ops-manager'});
+    expect(service.session()?.objectId).toBe('new-admin');expect(service.session()?.isSuperAdmin).toBeUndefined();
+  });
+
 });

+ 89 - 17
projects/xiaoshu-admin/src/app/admin-session.service.ts

@@ -1,16 +1,31 @@
-import { HttpClient, HttpHeaders } from '@angular/common/http';
-import { computed, inject, Injectable, signal } from '@angular/core';
+import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
+import { computed, DestroyRef, inject, Injectable, signal } from '@angular/core';
 import { Router } from '@angular/router';
-import { firstValueFrom } from 'rxjs';
-import { ADMIN_SESSION_KEY, CLOUD_FUNCTION_BASE_URL, PARSE_APP_ID } from './admin.constants';
+import { firstValueFrom, timeout } from 'rxjs';
+import { ADMIN_PARSE_URL, ADMIN_SESSION_KEY, CLOUD_FUNCTION_BASE_URL, PARSE_APP_ID } from './admin.constants';
 import { AdminIdentity, AdminSession } from './admin.models';
 
 @Injectable({ providedIn: 'root' })
 export class AdminSessionService {
   private readonly http = inject(HttpClient);
   private readonly router = inject(Router);
+  private readonly sessionChecks = new Map<string, Promise<void>>();
   private readonly state = signal<AdminSession | null>(this.restore());
 
+  constructor() {
+    const onStorage = (event: StorageEvent) => {
+      if (event.storageArea !== localStorage || event.key !== ADMIN_SESSION_KEY) return;
+      const previous = this.state();
+      const next = this.decode(event.newValue);
+      this.state.set(next);
+      this.writeTabSession(next);
+      if (previous && !next) this.redirectToLogin();
+      else if (previous && next && previous.objectId !== next.objectId) void this.router.navigate(['/admin/dashboard']);
+    };
+    window.addEventListener('storage', onStorage);
+    inject(DestroyRef).onDestroy(() => window.removeEventListener('storage', onStorage));
+  }
+
   readonly session = this.state.asReadonly();
   readonly isAuthenticated = computed(() => Boolean(this.state()?.sessionToken));
 
@@ -36,14 +51,14 @@ export class AdminSessionService {
       isSuperAdmin: result['adminRoleKey'] === 'super-admin' || (Array.isArray(result['roles']) && result['roles'].includes('super-admin')),
     };
     if (!session.objectId || !session.sessionToken) throw new Error('登录响应缺少有效会话');
-    sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
+    this.persist(session);
     this.state.set(session);
     return session;
   }
 
   applyIdentity(identity: AdminIdentity): void {
     const current = this.state();
-    if (!current) return;
+    if (!current || current.objectId !== identity.objectId) return;
     const session: AdminSession = {
       ...current,
       displayName: identity.displayName,
@@ -52,13 +67,13 @@ export class AdminSessionService {
       operationsRole: identity.operationsRole,
       company: identity.company ?? current.company,
     };
-    sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
+    this.persist(session);
     this.state.set(session);
   }
 
   logout(redirect = true): void {
     const sessionToken = this.state()?.sessionToken ?? '';
-    sessionStorage.removeItem(ADMIN_SESSION_KEY);
+    this.persist(null);
     this.state.set(null);
     if (sessionToken) {
       this.http.post(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`, {
@@ -66,18 +81,75 @@ export class AdminSessionService {
         params: { operation: 'logout', sessionToken },
       }).subscribe({ error: () => undefined });
     }
-    if (redirect) void this.router.navigate(['/admin/login']);
+    if (redirect) this.redirectToLogin();
   }
 
-  private restore(): AdminSession | null {
+  /** Confirm with Parse before turning one gateway failure into a global logout. */
+  verifySessionAfterFailure(token: string): Promise<void> {
+    if (this.state()?.sessionToken !== token) return Promise.resolve();
+    const pending = this.sessionChecks.get(token);
+    if (pending) return pending;
+    const check = this.checkSession(token).finally(() => this.sessionChecks.delete(token));
+    this.sessionChecks.set(token, check);
+    return check;
+  }
+
+  private async checkSession(token: string): Promise<void> {
+    try {
+      const user = await firstValueFrom(this.http.get<{ objectId: string; isDisabled?: boolean; isDeleted?: boolean }>(
+        `${ADMIN_PARSE_URL}/users/me`, { headers: { 'X-Parse-Application-Id': PARSE_APP_ID, 'X-Parse-Session-Token': token } },
+      ).pipe(timeout(10000)));
+      if (this.state()?.sessionToken === token && (user.objectId !== this.state()?.objectId || user.isDisabled || user.isDeleted)) this.invalidateSession(token);
+    } catch (error) {
+      if (error instanceof HttpErrorResponse && (error.status === 401 || Number(error.error?.code) === 209)) this.invalidateSession(token);
+      // Network errors and gateway outages are not evidence of an expired session.
+    }
+  }
+
+  /** A late response for an older login must never clear a newer session. */
+  invalidateSession(requestToken: string): void {
+    if (!requestToken || this.state()?.sessionToken !== requestToken) return;
+    this.persist(null);
+    this.state.set(null);
+    this.redirectToLogin();
+  }
+
+  private redirectToLogin(): void {
+    const returnUrl = this.router.url;
+    void this.router.navigate(['/admin/login'], {
+      queryParams: returnUrl?.startsWith('/admin/') && !returnUrl.startsWith('/admin/login') ? { returnUrl } : {},
+    });
+  }
+
+  private decode(value: string | null): AdminSession | null {
     try {
-      const value = sessionStorage.getItem(ADMIN_SESSION_KEY);
-      if (!value) return null;
-      const session = JSON.parse(value) as AdminSession;
-      return session.sessionToken ? session : null;
-    } catch {
-      sessionStorage.removeItem(ADMIN_SESSION_KEY);
-      return null;
+      const session = JSON.parse(value || 'null') as AdminSession | null;
+      return session && typeof session.sessionToken === 'string' && session.sessionToken && session.objectId ? session : null;
+    } catch { return null; }
+  }
+
+  private writeTabSession(session: AdminSession | null): void {
+    try {
+      if (session) sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
+      else sessionStorage.removeItem(ADMIN_SESSION_KEY);
+    } catch { /* The current in-memory login remains usable if storage is unavailable. */ }
+  }
+
+  private persist(session: AdminSession | null): void {
+    try {
+      if (session) localStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
+      else localStorage.removeItem(ADMIN_SESSION_KEY);
+    } catch { /* Fall back to the current tab when persistent storage is unavailable. */ }
+    this.writeTabSession(session);
+  }
+
+  private restore(): AdminSession | null {
+    let session: AdminSession | null = null;
+    try { session = this.decode(localStorage.getItem(ADMIN_SESSION_KEY)); } catch { /* restricted storage */ }
+    if (!session) {
+      try { session = this.decode(sessionStorage.getItem(ADMIN_SESSION_KEY)); } catch { /* restricted storage */ }
     }
+    this.persist(session);
+    return session;
   }
 }

+ 2 - 0
projects/xiaoshu-admin/src/app/admin.constants.ts

@@ -10,6 +10,8 @@ export const LEGACY_ASSET_BASE_URL = isLocalDevelopment
   ? '/backend'
   : 'https://test.fmode.cn/engxs';
 
+export const ADMIN_PARSE_URL = isLocalDevelopment ? '/ncloud' : 'https://server.xiaoshu.pro/parse';
+
 export const ADMIN_SESSION_KEY = 'xiaoshu_admin_session_v1';
 
 export interface AdminNavigationItem { label: string; icon: string; route: string; }

+ 19 - 1
projects/xiaoshu-admin/src/app/cloud-functions.service.spec.ts

@@ -15,7 +15,7 @@ describe('CloudFunctionsService', () => {
       providers: [
         provideHttpClient(),
         provideHttpClientTesting(),
-        { provide: AdminSessionService, useValue: { session: () => ({ sessionToken: 'r:admin-session' }), logout: jasmine.createSpy('logout') } },
+        { provide: AdminSessionService, useValue: { session: () => ({ sessionToken: 'r:admin-session' }), logout: jasmine.createSpy('logout'), invalidateSession:jasmine.createSpy('invalidateSession'), verifySessionAfterFailure:jasmine.createSpy('verifySessionAfterFailure').and.resolveTo() } },
       ],
     });
     service = TestBed.inject(CloudFunctionsService);
@@ -46,4 +46,22 @@ describe('CloudFunctionsService', () => {
     await assertion; expect(TestBed.inject(AdminSessionService).logout).not.toHaveBeenCalled();
   });
 
+  it('does not treat an app permission denial as a lost session', async()=>{
+    const pending=service.app('migration_status'),assertion=expectAsync(pending).toBeRejected();
+    http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`).flush({retmsg:'需要管理员权限'},{status:403,statusText:'Forbidden'});
+    await assertion;const sessions=TestBed.inject(AdminSessionService);
+    expect(sessions.invalidateSession).not.toHaveBeenCalled();expect(sessions.verifySessionAfterFailure).not.toHaveBeenCalled();expect(sessions.logout).not.toHaveBeenCalled();
+  });
+  it('verifies a gateway 401 using the token belonging to that request', async()=>{
+    const pending=service.operations('ops/live/versions'),assertion=expectAsync(pending).toBeRejected();
+    http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`).flush({message:'管理员会话已失效'},{status:401,statusText:'Unauthorized'});
+    await assertion;expect(TestBed.inject(AdminSessionService).verifySessionAfterFailure).toHaveBeenCalledWith('r:admin-session');
+    expect(TestBed.inject(AdminSessionService).logout).not.toHaveBeenCalled();
+  });
+  it('honors an explicit disabled-account response',async()=>{
+    const pending=service.app('migration_status'),assertion=expectAsync(pending).toBeRejected();
+    http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`).flush({retmsg:'账号已停用'},{status:403,statusText:'Forbidden'});
+    await assertion;expect(TestBed.inject(AdminSessionService).invalidateSession).toHaveBeenCalledWith('r:admin-session');
+  });
+
 });

+ 15 - 6
projects/xiaoshu-admin/src/app/cloud-functions.service.ts

@@ -26,20 +26,29 @@ export class CloudFunctionsService {
         `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`,
         { token, params: { operation, ...params } },
       ));
-      if (!envelope.success) throw new CloudFunctionError(envelope.message || envelope.error || '云函数执行失败');
+      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;
-        if (error.status === 401) this.sessions.logout();
+        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 : '未知云函数错误');
     }
   }
 
+  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);
   }
@@ -64,14 +73,14 @@ export class CloudFunctionsService {
         `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`,
         { token, params: { operation, ...params } },
       ));
-      if (!envelope.success) throw new CloudFunctionError(envelope.message || envelope.error || '运营云函数执行失败');
+      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;
-        if (error.status === 401) this.sessions.logout();
+        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 : '未知运营云函数错误');
@@ -86,13 +95,13 @@ export class CloudFunctionsService {
         `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`,
         { token, params: { action, ...params } },
       ));
-      if (envelope.retcode === -1) throw new CloudFunctionError(envelope.retmsg || '云函数执行失败');
+      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;
-        if (error.status === 401 || error.status === 403) this.sessions.logout();
+        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 : '未知云函数错误');

+ 31 - 0
projects/xiaoshu-admin/src/app/pages/admin-login.component.spec.ts

@@ -0,0 +1,31 @@
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRoute, Router } from '@angular/router';
+import { AdminLoginComponent } from './admin-login.component';
+import { AdminSessionService } from '../admin-session.service';
+import { CloudFunctionError, CloudFunctionsService } from '../cloud-functions.service';
+import { OperationsService } from '../operations.service';
+
+describe('AdminLoginComponent session handling',()=>{
+  let sessions: {login:jasmine.Spy;logout:jasmine.Spy;session:jasmine.Spy;applyIdentity:jasmine.Spy};
+  let functions: {admin:jasmine.Spy};
+  beforeEach(()=>{
+    sessions={login:jasmine.createSpy().and.resolveTo({sessionToken:'new'}),logout:jasmine.createSpy(),session:jasmine.createSpy().and.returnValue({sessionToken:'new'}),applyIdentity:jasmine.createSpy()};
+    functions={admin:jasmine.createSpy()};
+    TestBed.configureTestingModule({providers:[{provide:AdminSessionService,useValue:sessions},{provide:CloudFunctionsService,useValue:functions},{provide:Router,useValue:{navigateByUrl:jasmine.createSpy()}},{provide:ActivatedRoute,useValue:{snapshot:{queryParamMap:{get:()=>'/admin/members'}}}},{provide:OperationsService,useValue:{dashboard:()=>Promise.resolve({})}}]});
+  });
+  it('retains a successful login if metadata loading temporarily fails',async()=>{
+    functions.admin.and.rejectWith(new CloudFunctionError('network unavailable',0));
+    const component=TestBed.runInInjectionContext(()=>new AdminLoginComponent());component.username='admin';component.password='password';
+    await component.submit();expect(sessions.logout).not.toHaveBeenCalled();expect(component.error()).toBe('network unavailable');
+  });
+  it('rejects a freshly logged-in account that has no admin permission',async()=>{
+    functions.admin.and.rejectWith(new CloudFunctionError('需要管理员权限',403));
+    const component=TestBed.runInInjectionContext(()=>new AdminLoginComponent());component.username='member';component.password='password';
+    await component.submit();expect(sessions.logout).toHaveBeenCalledOnceWith(false);
+  });
+  it('does not revoke another successful login when an earlier password attempt fails',async()=>{
+    sessions.login.and.rejectWith(new Error('密码错误'));
+    const component=TestBed.runInInjectionContext(()=>new AdminLoginComponent());component.username='admin';component.password='wrong';
+    await component.submit();expect(sessions.logout).not.toHaveBeenCalled();
+  });
+});

+ 5 - 3
projects/xiaoshu-admin/src/app/pages/admin-login.component.ts

@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { Eye, EyeOff, LoaderCircle, LockKeyhole, LucideAngularModule, UserRound } from 'lucide-angular';
 import { AdminSessionService } from '../admin-session.service';
-import { CloudFunctionsService } from '../cloud-functions.service';
+import { CloudFunctionError, CloudFunctionsService } from '../cloud-functions.service';
 import { AdminIdentity } from '../admin.models';
 import { OperationsService } from '../operations.service';
 
@@ -40,8 +40,9 @@ export class AdminLoginComponent {
     }
     this.loading.set(true);
     this.error.set('');
+    let attemptToken = '';
     try {
-      await this.sessions.login(this.username, this.password);
+      attemptToken = (await this.sessions.login(this.username, this.password)).sessionToken;
       const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') || '/admin/dashboard';
       if (returnUrl.split('?')[0] === '/admin/dashboard') {
         void this.operations.dashboard().catch(() => undefined);
@@ -52,7 +53,8 @@ export class AdminLoginComponent {
       this.sessions.applyIdentity(meta.identity);
       await this.router.navigateByUrl(returnUrl);
     } catch (error) {
-      this.sessions.logout(false);
+      // Transient metadata/network failures must not revoke a successful login.
+      if (error instanceof CloudFunctionError && error.status === 403 && attemptToken && this.sessions.session()?.sessionToken === attemptToken) this.sessions.logout(false);
       this.error.set(error instanceof Error ? error.message : '登录失败');
     } finally {
       this.loading.set(false);