Jelajahi Sumber

fix: show live migration blockers in admin

彭峰 1 bulan lalu
induk
melakukan
241ac2bf90

+ 1 - 0
docs/migration/admin-migration-status.md

@@ -11,6 +11,7 @@
 ## 本阶段已实现
 ## 本阶段已实现
 
 
 - Angular 18 独立管理端路由 `/admin`,包含独立登录、后台布局、概览、动态 Schema 表格、动态编辑表单、分页、搜索、软删除和迁移状态页。
 - Angular 18 独立管理端路由 `/admin`,包含独立登录、后台布局、概览、动态 Schema 表格、动态编辑表单、分页、搜索、软删除和迁移状态页。
+- 后台迁移状态页直接读取线上 `xiaoshu.app.gateway/migration_status`,动态显示已实现 action 数量及完整阻塞原因清单,不再使用前端硬编码阻塞计数。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

+ 11 - 0
src/app/admin/admin.models.ts

@@ -27,6 +27,17 @@ export interface CloudEnvelope<T> {
   message?: string;
   message?: string;
 }
 }
 
 
+export interface LegacyCloudEnvelope<T> {
+  retcode: number;
+  retmsg?: string;
+  result: T;
+}
+
+export interface AppMigrationStatus {
+  implemented: string[];
+  blocked: Record<string, string>;
+}
+
 export interface AdminFieldSchema {
 export interface AdminFieldSchema {
   name: string;
   name: string;
   type: string;
   type: string;

+ 34 - 0
src/app/admin/cloud-functions.service.spec.ts

@@ -0,0 +1,34 @@
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import { CLOUD_FUNCTION_BASE_URL } from './admin.constants';
+import { AdminSessionService } from './admin-session.service';
+import { AppMigrationStatus } from './admin.models';
+import { CloudFunctionsService } from './cloud-functions.service';
+
+describe('CloudFunctionsService', () => {
+  let service: CloudFunctionsService;
+  let http: HttpTestingController;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [
+        provideHttpClient(),
+        provideHttpClientTesting(),
+        { provide: AdminSessionService, useValue: { session: () => ({ sessionToken: 'r:admin-session' }), logout: jasmine.createSpy('logout') } },
+      ],
+    });
+    service = TestBed.inject(CloudFunctionsService);
+    http = TestBed.inject(HttpTestingController);
+  });
+
+  afterEach(() => http.verify());
+
+  it('loads the live app migration matrix with the admin Parse session', async () => {
+    const pending = service.app<AppMigrationStatus>('migration_status');
+    const request = http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`);
+    expect(request.request.body).toEqual({ token: 'r:admin-session', params: { action: 'migration_status' } });
+    request.flush({ retcode: 0, retmsg: '', result: { implemented: ['app_update'], blocked: { cart_list: '目标 Schema 无购物车类' } } });
+    await expectAsync(pending).toBeResolvedTo({ implemented: ['app_update'], blocked: { cart_list: '目标 Schema 无购物车类' } });
+  });
+});

+ 22 - 1
src/app/admin/cloud-functions.service.ts

@@ -3,7 +3,7 @@ import { inject, Injectable } from '@angular/core';
 import { firstValueFrom } from 'rxjs';
 import { firstValueFrom } from 'rxjs';
 import { CLOUD_FUNCTION_BASE_URL } from './admin.constants';
 import { CLOUD_FUNCTION_BASE_URL } from './admin.constants';
 import { AdminSessionService } from './admin-session.service';
 import { AdminSessionService } from './admin-session.service';
-import { CloudEnvelope } from './admin.models';
+import { CloudEnvelope, LegacyCloudEnvelope } from './admin.models';
 
 
 export class CloudFunctionError extends Error {
 export class CloudFunctionError extends Error {
   constructor(message: string, readonly status = 0, readonly payload?: unknown) {
   constructor(message: string, readonly status = 0, readonly payload?: unknown) {
@@ -36,4 +36,25 @@ export class CloudFunctionsService {
       throw new CloudFunctionError(error instanceof Error ? error.message : '未知云函数错误');
       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 firstValueFrom(this.http.post<LegacyCloudEnvelope<T>>(
+        `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/app/gateway`,
+        { token, params: { action, ...params } },
+      ));
+      if (envelope.retcode === -1) 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();
+        throw new CloudFunctionError(payload?.retmsg || payload?.message || payload?.error || error.message, error.status, payload);
+      }
+      throw new CloudFunctionError(error instanceof Error ? error.message : '未知云函数错误');
+    }
+  }
 }
 }

+ 14 - 1
src/app/admin/pages/admin-migration.component.html

@@ -3,7 +3,20 @@
 <section class="summary">
 <section class="summary">
   <article><div class="icon green"><lucide-icon [img]="icons.Database" [size]="22" /></div><span>规范化数据迁移</span><strong>195,452</strong><small>来源记录已登记,96 个目标业务类</small></article>
   <article><div class="icon green"><lucide-icon [img]="icons.Database" [size]="22" /></div><span>规范化数据迁移</span><strong>195,452</strong><small>来源记录已登记,96 个目标业务类</small></article>
   <article><div class="icon blue"><lucide-icon [img]="icons.CloudCog" [size]="22" /></div><span>线上云函数</span><strong>{{ meta()?.cloudFunctions ?? '—' }}</strong><small>Function 表有效记录</small></article>
   <article><div class="icon blue"><lucide-icon [img]="icons.CloudCog" [size]="22" /></div><span>线上云函数</span><strong>{{ meta()?.cloudFunctions ?? '—' }}</strong><small>Function 表有效记录</small></article>
-  <article><div class="icon amber"><lucide-icon [img]="icons.CircleAlert" [size]="22" /></div><span>阻塞接口</span><strong>4</strong><small>源订单、购物车、博客表未迁入目标类</small></article>
+  <article><div class="icon amber"><lucide-icon [img]="icons.CircleAlert" [size]="22" /></div><span>阻塞 action</span><strong>{{ blockedEntries().length || '—' }}</strong><small>已实现 {{ appStatus()?.implemented?.length ?? '—' }} 个,阻塞项逐请求返回 501</small></article>
+</section>
+
+<section class="matrix blocked-matrix">
+  <header><div><h2>云函数阻塞清单</h2><p>直接读取线上 xiaoshu/app/gateway 的 migration_status,不使用前端硬编码计数</p></div><span>{{ blockedEntries().length }} 项</span></header>
+  @if (blockedEntries().length) {
+    <table><thead><tr><th>action</th><th>阻塞原因 / 恢复前置条件</th></tr></thead><tbody>
+      @for (item of blockedEntries(); track item[0]) {
+        <tr><td><code>{{ item[0] }}</code></td><td>{{ item[1] }}</td></tr>
+      }
+    </tbody></table>
+  } @else if (!error()) {
+    <div class="matrix-state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="20" />正在读取线上 action 状态…</div>
+  }
 </section>
 </section>
 
 
 @if (error()) { <div class="notice error">{{ error() }}</div> }
 @if (error()) { <div class="notice error">{{ error() }}</div> }

+ 1 - 0
src/app/admin/pages/admin-migration.component.scss

@@ -3,4 +3,5 @@
 .notice { display: flex; align-items: flex-start; gap: 10px; margin-top: 16px; padding: 13px 15px; border: 1px solid #d8e8f4; border-radius: 10px; color: #476278; background: #f1f8fd; font-size: 12px; line-height: 1.7; }.notice.error { border-color: #f1d6d8; color: #a43b43; background: #fff3f4; }
 .notice { display: flex; align-items: flex-start; gap: 10px; margin-top: 16px; padding: 13px 15px; border: 1px solid #d8e8f4; border-radius: 10px; color: #476278; background: #f1f8fd; font-size: 12px; line-height: 1.7; }.notice.error { border-color: #f1d6d8; color: #a43b43; background: #fff3f4; }
 .matrix { overflow: hidden; margin-top: 17px; border: 1px solid #e4e9f0; border-radius: 14px; background: white; }.matrix > header { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px; border-bottom: 1px solid #e9edf2; }.matrix h2 { margin: 0; font-size: 17px; }.matrix p { margin: 5px 0 0; color: #8b96a5; font-size: 11px; }.matrix > header > span { padding: 4px 9px; border-radius: 14px; color: #43705f; background: #eaf6f1; font-size: 11px; }
 .matrix { overflow: hidden; margin-top: 17px; border: 1px solid #e4e9f0; border-radius: 14px; background: white; }.matrix > header { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px; border-bottom: 1px solid #e9edf2; }.matrix h2 { margin: 0; font-size: 17px; }.matrix p { margin: 5px 0 0; color: #8b96a5; font-size: 11px; }.matrix > header > span { padding: 4px 9px; border-radius: 14px; color: #43705f; background: #eaf6f1; font-size: 11px; }
 table { width: 100%; border-collapse: collapse; }th, td { padding: 12px 20px; border-bottom: 1px solid #edf0f4; font-size: 12px; text-align: left; }th { color: #758194; background: #fafbfd; font-size: 11px; }code { padding: 3px 6px; border-radius: 5px; color: #4e6578; background: #f1f4f7; }.badge { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border-radius: 14px; font-size: 11px; }.badge.ok { color: #217756; background: #eaf7f1; }.badge.blocked { color: #9e6912; background: #fff4d9; }
 table { width: 100%; border-collapse: collapse; }th, td { padding: 12px 20px; border-bottom: 1px solid #edf0f4; font-size: 12px; text-align: left; }th { color: #758194; background: #fafbfd; font-size: 11px; }code { padding: 3px 6px; border-radius: 5px; color: #4e6578; background: #f1f4f7; }.badge { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border-radius: 14px; font-size: 11px; }.badge.ok { color: #217756; background: #eaf7f1; }.badge.blocked { color: #9e6912; background: #fff4d9; }
+.blocked-matrix td:first-child { width: 230px; }.blocked-matrix td:last-child { color: #596677; line-height: 1.65; }.matrix-state { display: flex; align-items: center; justify-content: center; gap: 9px; min-height: 100px; color: #7f8b9b; font-size: 12px; }.spin { animation: spin .9s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }
 @media (max-width: 760px) { .summary { grid-template-columns: 1fr; }.matrix { overflow-x: auto; }.matrix > header, th, td { padding-left: 14px; padding-right: 14px; }table { min-width: 680px; } }
 @media (max-width: 760px) { .summary { grid-template-columns: 1fr; }.matrix { overflow-x: auto; }.matrix > header, th, td { padding-left: 14px; padding-right: 14px; }table { min-width: 680px; } }

+ 12 - 2
src/app/admin/pages/admin-migration.component.ts

@@ -1,6 +1,7 @@
 import { CommonModule } from '@angular/common';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { CheckCircle2, CircleAlert, CloudCog, Database, LoaderCircle, LucideAngularModule } from 'lucide-angular';
 import { CheckCircle2, CircleAlert, CloudCog, Database, LoaderCircle, LucideAngularModule } from 'lucide-angular';
+import { AppMigrationStatus } from '../admin.models';
 import { CloudFunctionsService } from '../cloud-functions.service';
 import { CloudFunctionsService } from '../cloud-functions.service';
 
 
 interface MigrationMeta {
 interface MigrationMeta {
@@ -19,6 +20,8 @@ interface MigrationMeta {
 })
 })
 export class AdminMigrationComponent implements OnInit {
 export class AdminMigrationComponent implements OnInit {
   readonly meta = signal<MigrationMeta | null>(null);
   readonly meta = signal<MigrationMeta | null>(null);
+  readonly appStatus = signal<AppMigrationStatus | null>(null);
+  readonly blockedEntries = computed(() => Object.entries(this.appStatus()?.blocked ?? {}).sort(([left], [right]) => left.localeCompare(right)));
   readonly error = signal('');
   readonly error = signal('');
   readonly icons = { CheckCircle2, CircleAlert, CloudCog, Database, LoaderCircle };
   readonly icons = { CheckCircle2, CircleAlert, CloudCog, Database, LoaderCircle };
   readonly rewrites = [
   readonly rewrites = [
@@ -38,7 +41,14 @@ export class AdminMigrationComponent implements OnInit {
   private readonly functions = inject(CloudFunctionsService);
   private readonly functions = inject(CloudFunctionsService);
 
 
   async ngOnInit(): Promise<void> {
   async ngOnInit(): Promise<void> {
-    try { this.meta.set(await this.functions.admin<MigrationMeta>('meta')); }
+    try {
+      const [meta, appStatus] = await Promise.all([
+        this.functions.admin<MigrationMeta>('meta'),
+        this.functions.app<AppMigrationStatus>('migration_status'),
+      ]);
+      this.meta.set(meta);
+      this.appStatus.set(appStatus);
+    }
     catch (error) { this.error.set(error instanceof Error ? error.message : '迁移状态加载失败'); }
     catch (error) { this.error.set(error instanceof Error ? error.message : '迁移状态加载失败'); }
   }
   }
 }
 }