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

feat: qiwei 订阅余额状态区分与运行时平台检测

Co-Authored-By: Claude <noreply@anthropic.com>
gangvy 3 недель назад
Родитель
Сommit
5d92fa3658
2 измененных файлов с 111 добавлено и 8 удалено
  1. 8 0
      .gitignore
  2. 103 8
      src/app/qiwei-skill-install.component.ts

+ 8 - 0
.gitignore

@@ -41,3 +41,11 @@ testem.log
 .DS_Store
 Thumbs.db
 deploy.ps1
+
+# Playwright / 调试截图与脚本
+/.playwright-cli/
+/output/
+/ep-*.png
+/mod-*.png
+/shot-*.js
+/shot*.log

+ 103 - 8
src/app/qiwei-skill-install.component.ts

@@ -45,6 +45,15 @@ interface SubscriptionStatus {
   seats: number | null;
   usedSeats: number;
   balance?: number;
+  /**
+   * `available` and `unavailable` are returned for Feima sessions.  Older
+   * deployed Qiwei API modules did not include this field, so a numeric
+   * `balance` remains a supported legacy response while those modules roll
+   * forward.  `not_applicable` is used by NewAPI credentials.
+   */
+  balanceState?: 'available' | 'unavailable' | 'not_applicable';
+  pendingCreditAmount?: number;
+  pendingCreditCount?: number;
   lastIdempotencyKey?: string | null;
   quote: Quote;
 }
@@ -229,9 +238,18 @@ interface PendingOrder {
               }
             </dl>
 
-            @if (subscription()?.balance !== undefined) {
+            @if (hasDisplayableBalance()) {
               <div class="balance-line"><span>飞马可用余额</span><strong>¥{{ formatMoney(subscription()!.balance || 0) }}</strong></div>
             }
+            @if (subscription()?.balanceState === 'unavailable') {
+              <div class="balance-line balance-unavailable"><span>飞马余额暂时无法读取</span><strong>请刷新后重试</strong></div>
+            }
+            @if (subscription()?.balanceState === 'not_applicable') {
+              <div class="balance-line balance-unavailable"><span>当前授权不支持飞马余额支付</span><strong>请使用微信支付</strong></div>
+            }
+            @if ((subscription()?.pendingCreditCount || 0) > 0) {
+              <div class="balance-line balance-pending"><span>支付到账待核销({{ subscription()!.pendingCreditCount }} 笔)</span><strong>¥{{ formatMoney(subscription()!.pendingCreditAmount || 0) }}</strong></div>
+            }
 
             @if (errorMessage()) { <div class="flow-error">{{ errorMessage() }}</div> }
             @if (quote()!.allowed === false) { <div class="flow-error">{{ quote()!.reason || '当前订阅暂不支持该席位变更' }}</div> }
@@ -329,7 +347,7 @@ interface PendingOrder {
     .duration-control{margin-top:14px;padding:3px;display:grid;grid-template-columns:repeat(4,1fr);gap:3px;border:1px solid #313a40;border-radius:6px;background:#101416}.duration-control button{height:39px;border:0;border-radius:4px;background:transparent;color:#879398;font-size:12px}.duration-control button:disabled{opacity:.45;cursor:not-allowed}.duration-control button.selected{background:#25302c;color:#f1f7f4}
     .service-points{padding-top:20px;display:flex;flex-wrap:wrap;gap:13px 22px;border-top:1px solid #2a3338;color:#9ca8ac;font-size:12px}.service-points span{display:flex;align-items:center;gap:7px}.service-points svg{width:15px;color:#55d6a8}.pending-notice{margin-top:16px;padding:10px 12px;display:flex;align-items:center;justify-content:space-between;gap:14px;border-left:3px solid #55d6a8;background:#10211b;color:#9fc8b9;font-size:11px;line-height:1.55}.pending-notice span{min-width:0}.pending-notice button{flex:0 0 auto;padding:0;border:0;background:transparent;color:#6de0b1;font:inherit;font-weight:750;text-decoration:underline;text-underline-offset:3px;cursor:pointer}
     .order-summary{min-height:500px;padding:28px;border:1px solid #303a40;border-radius:8px;background:#12171a;box-shadow:0 28px 80px rgba(0,0,0,.32)}.summary-heading{display:flex;align-items:center;justify-content:space-between;color:#a4afb2;font-size:12px;font-weight:700}.summary-heading b{padding:3px 7px;border-radius:4px;background:#173426;color:#69dda9;font-size:11px}.loading-state{min-height:360px;display:flex;align-items:center;justify-content:center;gap:9px;color:#829094;font-size:13px}
-    .price-main{margin-top:24px;color:#fff;font-size:44px;line-height:1;font-weight:780}.price-main small{margin-right:4px;color:#809095;font-size:16px}.price-caption{margin-top:8px;color:#7f8c90;font-size:12px}.summary-lines{margin:26px 0 0;border-top:1px solid #2a3338}.summary-lines>div{padding:12px 0;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #252e33;font-size:12px}.summary-lines dt{color:#849196}.summary-lines dd{margin:0;color:#dbe3e1;font-weight:650}.summary-lines .saving dd{color:#55d6a8}.balance-line{margin-top:17px;padding:11px 12px;display:flex;align-items:center;justify-content:space-between;border:1px solid #2c493f;border-radius:6px;background:#10211b;color:#8fb3a6;font-size:12px}.balance-line strong{color:#dff8ee}
+    .price-main{margin-top:24px;color:#fff;font-size:44px;line-height:1;font-weight:780}.price-main small{margin-right:4px;color:#809095;font-size:16px}.price-caption{margin-top:8px;color:#7f8c90;font-size:12px}.summary-lines{margin:26px 0 0;border-top:1px solid #2a3338}.summary-lines>div{padding:12px 0;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #252e33;font-size:12px}.summary-lines dt{color:#849196}.summary-lines dd{margin:0;color:#dbe3e1;font-weight:650}.summary-lines .saving dd{color:#55d6a8}.balance-line{margin-top:17px;padding:11px 12px;display:flex;align-items:center;justify-content:space-between;border:1px solid #2c493f;border-radius:6px;background:#10211b;color:#8fb3a6;font-size:12px}.balance-line strong{color:#dff8ee}.balance-unavailable{border-color:#60482f;background:#231a12;color:#d7b17d}.balance-unavailable strong{color:#f0c98f}.balance-pending{border-color:#33515e;background:#11212a;color:#9fc3d1}.balance-pending strong{color:#c9e8f4}
     .primary-button,.copy-button{width:100%;min-height:46px;margin-top:18px;border:1px solid #25b983;border-radius:6px;background:#25b983;color:#06140f;font-size:13px;font-weight:800;display:flex;align-items:center;justify-content:center;gap:8px}.primary-button:hover,.copy-button:hover{background:#35ca94}.primary-button:disabled{opacity:.55;cursor:not-allowed}.secondary-button{min-height:40px;margin-top:10px;padding:0 14px;border:1px solid #39444a;border-radius:6px;background:#171d20;color:#c4cdcf;font-size:12px}.secondary-button.full{width:100%}.billing-note{margin:14px 0 0;color:#6f7b7f;font-size:10px;line-height:1.6;text-align:center}.flow-error,.payment-message{margin-top:14px;padding:10px 11px;border-left:3px solid #ef7359;background:#291713;color:#ffbaa9;font-size:12px;line-height:1.5}
     .spinner{width:17px;height:17px;display:inline-block;border:2px solid #3c484d;border-top-color:#55d6a8;border-radius:50%;animation:spin .75s linear infinite}.spinner.light{border-color:rgba(0,0,0,.25);border-top-color:#06140f}.spinner.dark{border-color:#dce3e1;border-top-color:#25b983}@keyframes spin{to{transform:rotate(360deg)}}
     .modal-backdrop{position:fixed;inset:0;z-index:1000;padding:20px;display:grid;place-items:center;background:rgba(3,5,6,.82);backdrop-filter:blur(8px)}.payment-dialog,.prompt-dialog{position:relative;width:min(460px,100%);padding:28px;border:1px solid #344047;border-radius:8px;background:#12171a;box-shadow:0 35px 110px rgba(0,0,0,.58)}.dialog-close{position:absolute;right:16px;top:16px}.payment-dialog h2{margin:0;text-align:center;font-size:19px}.dialog-amount{margin:8px 0 18px;text-align:center;color:#fff;font-size:32px;font-weight:760}.qr-frame{width:248px;height:248px;margin:0 auto;display:grid;place-items:center;border:1px solid #414d52;border-radius:7px;background:#fff}.qr-frame img{display:block}.dialog-hint{margin:15px 0;color:#9aa6aa;text-align:center;font-size:12px}.cancel-payment-note{margin:9px 0 0;color:#778488;text-align:center;font-size:10px;line-height:1.55}.processing-symbol{width:70px;height:70px;margin:30px auto 14px;display:grid;place-items:center;border-radius:50%;background:#10251d}.processing-symbol .spinner{width:28px;height:28px}
@@ -415,7 +433,7 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
     if (this.pendingOrder()) return `继续原订单 ¥${this.formatMoney(this.pendingOrder()!.amount)}`;
     const amount = this.quote()?.amount || 0;
     const balance = this.subscription()?.balance;
-    if (balance !== undefined && balance >= amount) return `使用余额开通 ¥${this.formatMoney(amount)}`;
+    if (this.canUseBalance() && balance !== undefined && balance >= amount) return `使用余额开通 ¥${this.formatMoney(amount)}`;
     return `微信支付 ¥${this.formatMoney(amount)}`;
   }
 
@@ -442,7 +460,7 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
       return;
     }
     const balance = this.subscription()?.balance;
-    if (balance !== undefined && balance >= this.quote()!.amount) {
+    if (this.canUseBalance() && balance !== undefined && balance >= this.quote()!.amount) {
       const key = this.createTradeNo('QWB');
       const pending: PendingOrder = {
         userId: this.userId,
@@ -461,6 +479,22 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
     await this.startPayment();
   }
 
+  private canUseBalance(): boolean {
+    const status = this.subscription();
+    return Boolean(
+      status?.balanceState === 'available' &&
+      Number.isFinite(status.balance)
+    );
+  }
+
+  hasDisplayableBalance(): boolean {
+    const status = this.subscription();
+    return Boolean(
+      status?.balanceState === 'available' &&
+      Number.isFinite(status.balance)
+    );
+  }
+
   async resumeActivation(): Promise<void> {
     const pending = this.pendingOrder() || this.readPendingOrder();
     if (!pending) return;
@@ -498,7 +532,8 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
     this.busy.set(true);
     this.errorMessage.set('');
     try {
-      const result = await this.authJson('/api/qiwei/install-prompt', 'POST', {});
+      const runtimePlatform = await this.detectRuntimePlatform();
+      const result = await this.authJson('/api/qiwei/install-prompt', 'POST', { runtimePlatform });
       if (result.code !== 200 || !result.data?.prompt) throw new Error(result.mess || '安装提示词生成失败');
       this.prompt.set(result.data.prompt);
       this.maskedPrompt.set(result.data.maskedPrompt || '专属授权已生成,完整内容仅在复制时出现。');
@@ -511,6 +546,33 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
     }
   }
 
+  private async detectRuntimePlatform(): Promise<'win-x64' | 'mac-arm64' | 'mac-x64' | 'auto'> {
+    const browserNavigator = navigator as Navigator & {
+      userAgentData?: {
+        platform?: string;
+        getHighEntropyValues?: (hints: string[]) => Promise<{ architecture?: string }>;
+      };
+    };
+    const platform = String(browserNavigator.userAgentData?.platform || browserNavigator.platform || '').toLowerCase();
+    const userAgent = String(browserNavigator.userAgent || '').toLowerCase();
+    let architecture = '';
+    try {
+      const highEntropy = await browserNavigator.userAgentData?.getHighEntropyValues?.(['architecture']);
+      architecture = String(highEntropy?.architecture || '').toLowerCase();
+    } catch {
+      architecture = '';
+    }
+    if (platform.includes('win') || /windows|win32/.test(userAgent)) {
+      return /arm|aarch/.test(architecture) ? 'auto' : 'win-x64';
+    }
+    if (platform.includes('mac') || /macintosh|mac os x|darwin/.test(userAgent)) {
+      if (/arm|aarch/.test(architecture)) return 'mac-arm64';
+      if (/x86|x64|amd64|intel/.test(architecture)) return 'mac-x64';
+      return 'auto';
+    }
+    return 'auto';
+  }
+
   async requestTrialCode(): Promise<void> {
     if (this.trialBusy()) return;
     this.trialBusy.set(true);
@@ -622,14 +684,47 @@ export class QiweiSkillInstallComponent implements OnInit, OnDestroy {
     try {
       const result = await this.authJson(`/api/qiwei/subscribe/status?seats=${this.seats()}&months=${this.months()}`, 'GET');
       if (result.code !== 200) throw new Error(result.mess || '订阅状态查询失败');
-      this.subscription.set(result.data);
-      if (result.data?.quote) this.quote.set(result.data.quote);
-      if (result.data?.subscribed && !this.purchaseMode()) this.stage.set('active');
+      const status = result.data as SubscriptionStatus;
+      if (this.isFeimaSession()) {
+        // Keep the payment page aligned with the Fmode Studio balance source.
+        // Older Qiwei API deployments can return a legacy ledger amount here.
+        try {
+          status.balance = await this.loadCanonicalFeimaBalance();
+          status.balanceState = 'available';
+        } catch (error: any) {
+          status.balance = undefined;
+          status.balanceState = 'unavailable';
+          this.errorMessage.set(error?.message || '飞马余额读取失败,请稍后重试。');
+        }
+      }
+      this.subscription.set(status);
+      if (status.quote) this.quote.set(status.quote);
+      if (status.subscribed && !this.purchaseMode()) this.stage.set('active');
     } catch (error: any) {
       this.errorMessage.set(error?.message || '订阅状态查询失败,请稍后重试。');
     }
   }
 
+  private isFeimaSession(): boolean {
+    return this.sessionToken.trim().startsWith('r:');
+  }
+
+  private async loadCanonicalFeimaBalance(): Promise<number> {
+    const response = await fetch(`${API_BASE}/api/fmode/newapi/user`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      cache: 'no-store',
+      body: JSON.stringify({ sessionToken: this.sessionToken })
+    });
+    const result = await response.json().catch(() => ({}));
+    if (!response.ok || result.code !== 200) {
+      throw new Error(result.mess || result.message || '飞马余额读取失败');
+    }
+    const balance = Number(result.data?.user?.balance);
+    if (!Number.isFinite(balance)) throw new Error('飞马余额数据无效');
+    return Number(balance.toFixed(3));
+  }
+
   private async loadTrialStatus(): Promise<void> {
     try {
       const result = await this.authJson('/api/qiwei/trial/status', 'GET');