gangvy hace 2 días
padre
commit
a478e5f6ff

+ 49 - 0
client/src/app/api.service.ts

@@ -155,6 +155,46 @@ export interface ModuleForwardingDetails {
   note?: string;
 }
 
+export interface UsageDiagnostics {
+  available: boolean;
+  error?: string;
+  days?: number;
+  filters?: { usageTraceId: string | null; reportId: string | null; workflowType: string | null };
+  tasks: {
+    usageTraceId: string;
+    reportId: string | null;
+    workflowType: string | null;
+    skillId: string | null;
+    calls: number;
+    billedCalls: number;
+    quota: number;
+    costCny: number;
+    cacheHits: number;
+    upstreamCalls: number;
+    firstAt: number | null;
+    lastAt: number | null;
+    endpoints: {
+      endpoint: string;
+      calls: number;
+      billedCalls: number;
+      quota: number;
+      costCny: number;
+      cacheHits: number;
+      upstreamCalls: number;
+      unitPriceCny: number | null;
+    }[];
+    recommendations: {
+      endpoint: string;
+      action: string;
+      estimatedSavingCny: number;
+      informationLoss: string;
+      basis: string;
+    }[];
+  }[];
+  totals: { tasks: number; calls: number; billedCalls: number; quota: number; costCny: number };
+  note?: string;
+}
+
 export interface FmodeApiNewUsers {
   available: boolean;
   error?: string;
@@ -303,6 +343,15 @@ export class ApiService {
     return this.cachedGet<ModuleForwardingDetails>(`${this.base}/module-forwarding?${this.range(days, month)}`);
   }
 
+  getUsageDiagnostics(days: number, month?: string, filters: { usageTraceId?: string; reportId?: string; workflowType?: string } = {}): Observable<UsageDiagnostics> {
+    const query = new URLSearchParams(this.range(days, month));
+    Object.entries(filters).forEach(([key, value]) => {
+      if (value) query.set(key, value);
+    });
+    query.set('limit', '200');
+    return this.cachedGet<UsageDiagnostics>(`${this.base}/usage-diagnostics?${query.toString()}`);
+  }
+
   getFmodeApiNewUsers(days: number, month?: string): Observable<FmodeApiNewUsers> {
     return this.cachedGet<FmodeApiNewUsers>(`${this.base}/fmodeapi/new-users?${this.range(days, month)}`);
   }

+ 46 - 0
client/src/app/dashboard.component.html

@@ -518,6 +518,52 @@
       </ng-template>
     </section>
 
+    <!-- 任务成本诊断 -->
+    <section class="section" id="section-diagnostics" data-id="diagnostics" *ngIf="activeSection === 'diagnostics'">
+      <header class="section-head">
+        <h1>任务成本诊断</h1>
+        <p>按 usageTraceId 聚合真实扣费,定位高成本接口并给出可执行的降本候选。</p>
+      </header>
+      <ng-container *ngIf="usageDiagnostics as d; else diagnosticsLoading">
+        <div *ngIf="d.available; else diagnosticsError">
+          <div class="stat-grid">
+            <div class="stat"><div class="stat-label">任务数</div><div class="stat-value cyan">{{ d.totals.tasks | number }}</div><div class="stat-note">带追踪标识的执行</div></div>
+            <div class="stat"><div class="stat-label">实际调用</div><div class="stat-value">{{ d.totals.calls | number }}</div><div class="stat-note">计费 {{ d.totals.billedCalls | number }} 次</div></div>
+            <div class="stat"><div class="stat-label">Quota</div><div class="stat-value">{{ d.totals.quota | number }}</div><div class="stat-note">NewAPI 实际扣除</div></div>
+            <div class="stat"><div class="stat-label">用户消费</div><div class="stat-value amber">¥{{ d.totals.costCny | number: '1.2-2' }}</div><div class="stat-note">不含上游采购成本</div></div>
+          </div>
+          <p class="hint">{{ d.note }}</p>
+          <article class="module-detail-card" *ngFor="let task of d.tasks">
+            <div class="module-detail-body">
+              <div class="section-head compact">
+                <h2 class="mono-path">{{ task.usageTraceId }}</h2>
+                <p>{{ task.workflowType || '未标记工作流' }} · {{ task.reportId || '未标记报告' }} · ¥{{ task.costCny | number: '1.2-2' }}</p>
+              </div>
+              <div class="two-col module-detail-grid">
+                <div>
+                  <h3>接口消耗排行</h3>
+                  <table>
+                    <thead><tr><th>接口</th><th class="num">调用</th><th class="num">缓存</th><th class="num">Quota</th><th class="num">消费 (¥)</th></tr></thead>
+                    <tbody><tr *ngFor="let e of task.endpoints | slice: 0:20"><td class="mono-path">{{ e.endpoint }}</td><td class="num">{{ e.calls | number }}</td><td class="num cyan">{{ e.cacheHits | number }}</td><td class="num">{{ e.quota | number }}</td><td class="num amber">{{ e.costCny | number: '1.2-2' }}</td></tr></tbody>
+                  </table>
+                </div>
+                <div>
+                  <h3>降本候选</h3>
+                  <p class="hint" *ngIf="!task.recommendations.length">当前任务没有达到诊断阈值的候选接口。</p>
+                  <table *ngIf="task.recommendations.length">
+                    <thead><tr><th>接口</th><th>动作</th><th class="num">预计节省 (¥)</th><th>影响</th></tr></thead>
+                    <tbody><tr *ngFor="let r of task.recommendations"><td class="mono-path">{{ r.endpoint }}</td><td>{{ r.action }}</td><td class="num green">{{ r.estimatedSavingCny | number: '1.2-2' }}</td><td>{{ r.informationLoss }}</td></tr></tbody>
+                  </table>
+                </div>
+              </div>
+            </div>
+          </article>
+        </div>
+      </ng-container>
+      <ng-template #diagnosticsLoading><div class="card"><p class="hint">任务诊断加载中…</p></div></ng-template>
+      <ng-template #diagnosticsError><div class="card"><p class="hint">任务诊断暂不可用:{{ usageDiagnostics?.error }}</p></div></ng-template>
+    </section>
+
     <!-- 营收 -->
     <section class="section" id="section-revenue" data-id="revenue" *ngIf="activeSection === 'revenue'">
       <header class="section-head">

+ 18 - 1
client/src/app/dashboard.component.ts

@@ -5,7 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
 import gsap from 'gsap';
 import { ScrollTrigger } from 'gsap/ScrollTrigger';
 import type { EChartsOption } from 'echarts';
-import { ApiService, Overview, FmodeApiRecharge, FmodeApiUsers, FmodeApiUserStats, FmodeApiTopups, ApigOrderGroups, ApigOrderDetail, UpstreamCost, ModuleForwardingDetails, FmodeApiNewUsers } from './api.service';
+import { ApiService, Overview, FmodeApiRecharge, FmodeApiUsers, FmodeApiUserStats, FmodeApiTopups, ApigOrderGroups, ApigOrderDetail, UpstreamCost, ModuleForwardingDetails, FmodeApiNewUsers, UsageDiagnostics } from './api.service';
 import { EChartComponent } from './echart.component';
 
 gsap.registerPlugin(ScrollTrigger);
@@ -40,6 +40,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy {
     { id: 'overview', label: '总览', icon: '◎' },
     { id: 'upstreams', label: '上游余额与成本', icon: '☁' },
     { id: 'modules', label: '模块转发明细', icon: '◇' },
+    { id: 'diagnostics', label: '任务成本诊断', icon: '!' },
     { id: 'revenue', label: '营收与订单', icon: '¥' },
     { id: 'orders', label: '充值订单明细', icon: '☰' },
     { id: 'fmodeapi', label: 'fmodeapi 充值与用量', icon: '⚡' },
@@ -70,6 +71,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy {
   apigOrders: ApigOrderGroups | null = null;
   upstreamCost: UpstreamCost | null = null;
   moduleForwarding: ModuleForwardingDetails | null = null;
+  usageDiagnostics: UsageDiagnostics | null = null;
   newUsers: FmodeApiNewUsers | null = null;
   updatedAt = '';
 
@@ -481,6 +483,21 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy {
           },
         });
         break;
+      case 'diagnostics':
+        this.usageDiagnostics = null;
+        this.api.getUsageDiagnostics(this.days, month).subscribe({
+          next: (d) => {
+            this.usageDiagnostics = d;
+            this.loading = false;
+            this.updatedAt = new Date().toLocaleString('zh-CN');
+            setTimeout(() => ScrollTrigger.refresh(), 50);
+          },
+          error: (e) => {
+            this.error = e?.message || '加载失败';
+            this.loading = false;
+          },
+        });
+        break;
       case 'revenue':
         this.loadOverview(false);
         break;