ALIEZ 1 bulan lalu
induk
melakukan
4298096cb8

+ 30 - 12
src/app/[locale]/checkout/checkout-form.tsx

@@ -3,21 +3,25 @@
 import { useSearchParams } from "next/navigation";
 import { useTranslations } from "next-intl";
 import { useRouter } from "@/i18n/navigation";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useId, useMemo, useState } from "react";
 import { getCourseBySlug } from "@/data/courses";
 import { useAuth } from "@/providers/auth-provider";
 import {
   fetchBankChannelOptions,
   fetchRemittanceChannels,
   isTelegraphicStylePayRequestUrl,
+  payAmountWithChannelRate,
+  resolvePayRateMultiplier,
   submitXfgPayOrder,
   type BankChannelOption,
   type RemittanceChannel,
 } from "@/lib/checkout-api";
 import { InlineLoading } from "@/components/ui/loading-state";
 import { ModalShell } from "@/components/ui/modal-shell";
+import { resolveRemittanceChannelIconUrl } from "@/lib/remittance-icon-url";
 
 export function CheckoutForm() {
+  const checkoutFormId = useId();
   const t = useTranslations("checkout");
   const router = useRouter();
   const searchParams = useSearchParams();
@@ -104,14 +108,30 @@ export function CheckoutForm() {
       .sort((a, b) => a.order - b.order);
   }, [channels]);
 
+  const selectedChannel = channels.find((c) => c.id === selectedChannelId);
+  const selectedBank = bankOptions.find((b) => b.value === selectedBankCode);
+  const shouldShowBankSelector = selectedChannel?.bankValid === 1;
+
+  const payRateMultiplier = useMemo(
+    () =>
+      resolvePayRateMultiplier({
+        bankSelectorVisible: shouldShowBankSelector,
+        selectedBankCode,
+        bankOptions,
+        channelRate: selectedChannel?.rate ?? null,
+      }),
+    [shouldShowBankSelector, selectedBankCode, bankOptions, selectedChannel?.rate],
+  );
+
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
     if (!user) return;
     const depositAmountNum = Number(depositAmount);
+    const pricedByRate = payAmountWithChannelRate(displayCoursePrice, payRateMultiplier);
     const finalAmount =
       Number.isFinite(depositAmountNum) && depositAmountNum > 0
         ? depositAmountNum
-        : displayCoursePrice;
+        : pricedByRate;
     if (!goodsId) {
       setMsg("商品ID缺失,无法提交订单。");
       return;
@@ -172,10 +192,6 @@ export function CheckoutForm() {
     setDepositAmount("");
   };
 
-  const selectedChannel = channels.find((c) => c.id === selectedChannelId);
-  const selectedBank = bankOptions.find((b) => b.value === selectedBankCode);
-  const shouldShowBankSelector = selectedChannel?.bankValid === 1;
-
   const toggleGroup = (groupName: string) => {
     setExpandedGroups((prev) => ({
       ...prev,
@@ -214,14 +230,15 @@ export function CheckoutForm() {
   }, [isPaymentModalOpen, selectedChannelId, selectedChannel?.code, shouldShowBankSelector]);
 
   useEffect(() => {
-    if (!isPaymentModalOpen) return;
-    if (depositAmount) return;
+    if (!isPaymentModalOpen || !selectedChannelId) return;
     if (!(displayCoursePrice > 0)) return;
-    setDepositAmount(String(displayCoursePrice));
-  }, [isPaymentModalOpen, displayCoursePrice, depositAmount]);
+    const amount = payAmountWithChannelRate(displayCoursePrice, payRateMultiplier);
+    if (!(amount > 0)) return;
+    setDepositAmount(String(amount));
+  }, [isPaymentModalOpen, selectedChannelId, displayCoursePrice, payRateMultiplier]);
 
   return (
-    <form onSubmit={handleSubmit} className="space-y-8">
+    <form id={checkoutFormId} onSubmit={handleSubmit} className="space-y-8">
       <div className="rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6">
         {channelsLoading ? <InlineLoading text="通道加载中..." className="mt-4" /> : null}
         {channelsError ? (
@@ -270,7 +287,7 @@ export function CheckoutForm() {
                                 {channel.icon ? (
                                   // eslint-disable-next-line @next/next/no-img-element
                                   <img
-                                    src={channel.icon}
+                                    src={resolveRemittanceChannelIconUrl(channel.icon)}
                                     alt={channel.name}
                                     className="h-7 w-auto rounded object-contain"
                                   />
@@ -477,6 +494,7 @@ export function CheckoutForm() {
             <div className="mt-6">
               <button
                 type="submit"
+                form={checkoutFormId}
                 disabled={
                   !user ||
                   !hasCourseInfo ||

+ 44 - 3
src/lib/checkout-api.ts

@@ -14,12 +14,16 @@ export type RemittanceChannel = {
   groupOrder: number;
   channelType: number | null;
   bankValid: number;
+  /** 支付金额倍率;与标价相乘。为 null 或非有限数时按 1 处理。 */
+  rate: number | null;
 };
 
 export type BankChannelOption = {
   value: string;
   label: string;
   currency: string;
+  /** 与外层通道 `rate` 同语义;为 null 或非有限数时乘价按 1。 */
+  rate: number | null;
 };
 
 function toNumber(v: unknown): number | null {
@@ -55,7 +59,7 @@ function pickAmountRange(record: Record<string, unknown>): string {
 }
 
 function pickFee(record: Record<string, unknown>): string {
-  const feeRate = toNumber(record.feeRate ?? record.rate);
+  const feeRate = toNumber(record.feeRate);
   if (feeRate !== null) {
     if (feeRate <= 1) return `${(feeRate * 100).toFixed(2).replace(/\.00$/, "")}%`;
     return `${feeRate.toFixed(2).replace(/\.00$/, "")}%`;
@@ -155,11 +159,36 @@ function normalizeChannelList(raw: unknown): RemittanceChannel[] {
       groupOrder,
       channelType: toNumber(row.type ?? row.channelType ?? row.payTypeType),
       bankValid: toNumber(row.bankValid) ?? 0,
+      rate: toNumber(row.rate),
     });
   }
   return output;
 }
 
+/** 支付展示/提交金额 = 标价 × 通道 `rate`;`rate` 为 null 或非有限数时按 1。 */
+export function payAmountWithChannelRate(basePrice: number, rate: number | null): number {
+  if (!Number.isFinite(basePrice) || basePrice < 0) return 0;
+  const mult = rate != null && Number.isFinite(rate) ? rate : 1;
+  return Math.round(basePrice * mult * 1e8) / 1e8;
+}
+
+/**
+ * 有二级「选择付款方式」且已选中时,用该项的 `rate`;
+ * 否则用 `/remittance/channel/list` 外层通道的 `rate`。
+ */
+export function resolvePayRateMultiplier(input: {
+  bankSelectorVisible: boolean;
+  selectedBankCode: string;
+  bankOptions: BankChannelOption[];
+  channelRate: number | null;
+}): number | null {
+  if (input.bankSelectorVisible && input.selectedBankCode.trim()) {
+    const bank = input.bankOptions.find((b) => b.value === input.selectedBankCode);
+    if (bank) return bank.rate;
+  }
+  return input.channelRate;
+}
+
 export async function fetchRemittanceChannels(): Promise<RemittanceChannel[]> {
   // 存款通道固定走 remittance 路径,避免误取到提款通道(/remit/channel/list)。
   const raw = await postRemittance<unknown>("/remittance/channel/list", {});
@@ -180,11 +209,23 @@ function normalizeBankChannelList(raw: unknown): BankChannelOption[] {
   for (const item of source) {
     if (!item || typeof item !== "object") continue;
     const row = item as Record<string, unknown>;
-    const value = pickString(row, ["code", "name", "currency", "enName"]);
+    const valueFromCodeKeys: unknown[] = [row.code, row.channelCode, row.payCode];
+    let value = "";
+    for (const v of valueFromCodeKeys) {
+      if (typeof v === "number" && Number.isFinite(v)) {
+        value = String(v);
+        break;
+      }
+      if (typeof v === "string" && v.trim()) {
+        value = v.trim();
+        break;
+      }
+    }
+    if (!value) value = pickString(row, ["name", "currency", "enName"]);
     if (!value) continue;
     const label = pickString(row, ["name", "enName", "currency", "code"]) || value;
     const currency = pickString(row, ["currency", "code", "name"]) || "USDT";
-    output.push({ value, label, currency });
+    output.push({ value, label, currency, rate: toNumber(row.rate) });
   }
   return output;
 }

+ 62 - 0
src/lib/remittance-icon-url.ts

@@ -0,0 +1,62 @@
+import { getMediaOrigin } from "@/lib/env";
+
+/**
+ * 与 legacy Vue 中 `Host80 = ht + "//secure." + ho + "." + tld` 一致,用于汇款通道 icon 等相对路径。
+ * 使用 `location.hostname`(不含端口),避免 `host` 里端口影响分段。
+ *
+ * 注意:`localhost`、单段主机名无法按该规则拼出 `secure.*`,会返回 `null`,此时请用
+ * {@link getRemittanceIconBase} 的回退(`NEXT_PUBLIC_MEDIA_ORIGIN` 或当前站 `origin`)。
+ */
+export function getSecureDynamicHostBase(): string | null {
+  if (typeof window === "undefined") return null;
+  const ht = window.location.protocol;
+  const hostParts = window.location.hostname.split(".");
+  if (hostParts.length < 2) return null;
+
+  let ho: string;
+  let tld: string;
+  if (hostParts.length >= 3) {
+    ho = hostParts[1]!;
+    tld = hostParts.slice(2).join(".") || "com";
+  } else {
+    ho = hostParts[0]!;
+    tld = hostParts[1]!;
+  }
+
+  return `${ht}//secure.${ho}.${tld}`;
+}
+
+/**
+ * 通道 icon 的绝对地址前缀(不含末尾 `/`):
+ * 1. 多段正式域名:`https://secure.{主域}.{tld}`(与旧 Vue Host80 一致)
+ * 2. 否则:配置了 `NEXT_PUBLIC_MEDIA_ORIGIN` 时用其作为源站
+ * 3. 浏览器内:回退为当前页 `origin`(本地 `localhost:3000` 时至少拼成完整 URL,便于走同源反代)
+ */
+export function getRemittanceIconBase(): string | null {
+  const secure = getSecureDynamicHostBase();
+  if (secure) return secure;
+  const media = getMediaOrigin().replace(/\/$/, "");
+  if (media) return media;
+  if (typeof window !== "undefined" && window.location?.origin) {
+    return window.location.origin.replace(/\/$/, "");
+  }
+  return null;
+}
+
+/** 通道 icon:相对路径前拼 {@link getRemittanceIconBase};已是 http(s) 或协议相对 URL 则不改。 */
+export function resolveRemittanceChannelIconUrl(icon: string | undefined | null): string {
+  const trimmed = icon?.trim() ?? "";
+  if (!trimmed) return "";
+  if (/^https?:\/\//i.test(trimmed)) return trimmed;
+  if (trimmed.startsWith("//")) {
+    if (typeof window !== "undefined" && window.location.protocol) {
+      return `${window.location.protocol}${trimmed}`;
+    }
+    return `https:${trimmed}`;
+  }
+  const base = getRemittanceIconBase();
+  if (!base) return trimmed;
+  let path = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
+  path = path.replace(/\/{2,}/g, "/");
+  return `${base}${path}`;
+}