fix(payment): 支付轮询链路收口——查单节流 + 前端轮询重写 #7
@@ -81,7 +81,11 @@ func (h *Handler) BillingCreateOrder(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadGateway, gin.H{"error": channel + "下单失败: " + err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": channel + "下单失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"order_id": o.ID, "code_url": intent.CodeURL, "amount_fen": o.AmountFen})
|
// expires_at 由后端下发:二维码有效期是服务端的 orderTTL 说了算,前端硬编码一份迟早漂移。
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"order_id": o.ID, "code_url": intent.CodeURL, "amount_fen": o.AmountFen,
|
||||||
|
"expires_at": o.CreatedAt.Add(orderTTL),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// BillingOrderStatus: GET /api/v1/billing/orders/:id —— 前端轮询订单态。
|
// BillingOrderStatus: GET /api/v1/billing/orders/:id —— 前端轮询订单态。
|
||||||
@@ -113,8 +117,12 @@ func (h *Handler) BillingOrderStatus(c *gin.Context) {
|
|||||||
func (h *Handler) reconcileOrder(ctx context.Context, o *store.PaymentOrder) (*store.PaymentOrder, bool) {
|
func (h *Handler) reconcileOrder(ctx context.Context, o *store.PaymentOrder) (*store.PaymentOrder, bool) {
|
||||||
ch := h.pay.Get(o.Channel) // 按订单实际所用渠道查单,不写死微信
|
ch := h.pay.Get(o.Channel) // 按订单实际所用渠道查单,不写死微信
|
||||||
if o.Status != store.OrderPending || ch == nil {
|
if o.Status != store.OrderPending || ch == nil {
|
||||||
|
forgetQuery(o.ID)
|
||||||
return o, false
|
return o, false
|
||||||
}
|
}
|
||||||
|
// 节流:间隔内跳过真实查单,只走下面本地可判定的 TTL 过期。回调是主路径,
|
||||||
|
// 少查几次不影响到账,只影响「用户在场时的确认延迟」,最坏多等 5s。
|
||||||
|
if allowQuery(o.ID) {
|
||||||
if r, err := ch.QueryOrder(ctx, o.ID); err == nil {
|
if r, err := ch.QueryOrder(ctx, o.ID); err == nil {
|
||||||
switch {
|
switch {
|
||||||
case r.Paid && r.AmountFen == o.AmountFen:
|
case r.Paid && r.AmountFen == o.AmountFen:
|
||||||
@@ -128,10 +136,14 @@ func (h *Handler) reconcileOrder(ctx context.Context, o *store.PaymentOrder) (*s
|
|||||||
o, _ = h.db.GetOrder(ctx, o.ID)
|
o, _ = h.db.GetOrder(ctx, o.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if o.Status == store.OrderPending && time.Since(o.CreatedAt) > orderTTL {
|
if o.Status == store.OrderPending && time.Since(o.CreatedAt) > orderTTL {
|
||||||
_ = h.db.ExpireOrder(ctx, o.ID)
|
_ = h.db.ExpireOrder(ctx, o.ID)
|
||||||
o, _ = h.db.GetOrder(ctx, o.ID)
|
o, _ = h.db.GetOrder(ctx, o.ID)
|
||||||
}
|
}
|
||||||
|
if o.Status != store.OrderPending {
|
||||||
|
forgetQuery(o.ID) // 已落终态,标记没用了
|
||||||
|
}
|
||||||
return o, false
|
return o, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sundynix/sundynix-gateway/internal/payment"
|
"github.com/sundynix/sundynix-gateway/internal/payment"
|
||||||
@@ -15,6 +16,43 @@ import (
|
|||||||
|
|
||||||
const reconcileInterval = 1 * time.Minute
|
const reconcileInterval = 1 * time.Minute
|
||||||
|
|
||||||
|
// 主动查单节流:前端支付弹窗每 2.5s 轮一次单态,而 reconcileOrder 见 pending 就直连渠道
|
||||||
|
// 查单——单笔订单在 30min TTL 内能打出约 720 次微信查单调用,微信侧有频控,多用户并发时
|
||||||
|
// 先被限流的反而是我们自己。回调才是入账主路径,查单只是兜底,给它一个最小间隔即可:
|
||||||
|
// 间隔内的轮询直接返回本地状态(订单一旦被回调入账,本地状态本来就是最新的)。
|
||||||
|
// 补偿定时器每分钟才跑一轮,远大于这个间隔,不受影响。
|
||||||
|
const minQueryInterval = 5 * time.Second
|
||||||
|
|
||||||
|
// lastQuery: orderID -> 上次真正打渠道查单的时刻。仅用于限流,进程级即可
|
||||||
|
// (多实例各自限流,量级仍降两个数量级);订单落终态或超期时清理,见 forgetQuery/pruneQueryMarks。
|
||||||
|
var lastQuery sync.Map
|
||||||
|
|
||||||
|
// allowQuery 判断此刻是否放行一次真实查单,放行则记下时刻。
|
||||||
|
func allowQuery(orderID string) bool {
|
||||||
|
now := time.Now()
|
||||||
|
if v, ok := lastQuery.Load(orderID); ok {
|
||||||
|
if last, _ := v.(time.Time); now.Sub(last) < minQueryInterval {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastQuery.Store(orderID, now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func forgetQuery(orderID string) { lastQuery.Delete(orderID) }
|
||||||
|
|
||||||
|
// pruneQueryMarks 清掉超过 TTL 的残留标记 —— 用户扫码前就关掉弹窗的订单不会再被轮询,
|
||||||
|
// 其标记无人清理,不定期回收会随进程运行时长单调增长。
|
||||||
|
func pruneQueryMarks() {
|
||||||
|
cutoff := time.Now().Add(-orderTTL)
|
||||||
|
lastQuery.Range(func(k, v any) bool {
|
||||||
|
if t, _ := v.(time.Time); t.Before(cutoff) {
|
||||||
|
lastQuery.Delete(k)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// StartReconcile 启动掉单补偿定时器(微信渠道未配置时空转,几乎零成本)。随进程生命周期运行,
|
// StartReconcile 启动掉单补偿定时器(微信渠道未配置时空转,几乎零成本)。随进程生命周期运行,
|
||||||
// ctx 取消即退出。返回给调用方保存以便优雅停机时取消。
|
// ctx 取消即退出。返回给调用方保存以便优雅停机时取消。
|
||||||
func (h *Handler) StartReconcile(ctx context.Context) {
|
func (h *Handler) StartReconcile(ctx context.Context) {
|
||||||
@@ -27,6 +65,7 @@ func (h *Handler) StartReconcile(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
h.reconcilePending(ctx)
|
h.reconcilePending(ctx)
|
||||||
|
pruneQueryMarks()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 查单节流是限制「我们打微信查单接口」频率的唯一闸门:前端支付弹窗 2.5s 轮一次,
|
||||||
|
// 没有它的话单笔订单在 30min TTL 内能打出数百次渠道调用。这里把行为钉死。
|
||||||
|
func TestAllowQuery_Throttle(t *testing.T) {
|
||||||
|
const id = "test-order-throttle"
|
||||||
|
defer forgetQuery(id)
|
||||||
|
|
||||||
|
if !allowQuery(id) {
|
||||||
|
t.Fatal("首次查单必须放行——否则用户扫码后要白等一个间隔才确认")
|
||||||
|
}
|
||||||
|
if allowQuery(id) {
|
||||||
|
t.Fatal("间隔内的第二次应被拦下")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 间隔过后重新放行
|
||||||
|
lastQuery.Store(id, time.Now().Add(-minQueryInterval-time.Second))
|
||||||
|
if !allowQuery(id) {
|
||||||
|
t.Fatal("超过 minQueryInterval 后应重新放行")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订单落终态后标记要清掉,否则同一订单号(或长期挂着的 pending)会把 map 撑大。
|
||||||
|
func TestForgetQuery_And_Prune(t *testing.T) {
|
||||||
|
const id = "test-order-forget"
|
||||||
|
allowQuery(id)
|
||||||
|
forgetQuery(id)
|
||||||
|
if _, ok := lastQuery.Load(id); ok {
|
||||||
|
t.Fatal("forgetQuery 应删除标记")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户扫码前就关掉弹窗的订单不会再被轮询,标记无人清理 —— 靠定时 prune 回收
|
||||||
|
const stale = "test-order-stale"
|
||||||
|
lastQuery.Store(stale, time.Now().Add(-orderTTL-time.Minute))
|
||||||
|
const fresh = "test-order-fresh"
|
||||||
|
lastQuery.Store(fresh, time.Now())
|
||||||
|
defer forgetQuery(fresh)
|
||||||
|
|
||||||
|
pruneQueryMarks()
|
||||||
|
|
||||||
|
if _, ok := lastQuery.Load(stale); ok {
|
||||||
|
t.Fatal("超过 orderTTL 的陈旧标记应被回收")
|
||||||
|
}
|
||||||
|
if _, ok := lastQuery.Load(fresh); !ok {
|
||||||
|
t.Fatal("仍在有效期内的标记不该被误删")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -271,8 +271,11 @@ export async function billingPacks(): Promise<{ packs: Pack[]; channels: string[
|
|||||||
return { packs: d.packs ?? [], channels: d.channels ?? [] };
|
return { packs: d.packs ?? [], channels: d.channels ?? [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// createWechatOrder 微信 Native 下单:返回订单号 + code_url(渲染成二维码扫码付)。
|
// createWechatOrder 微信 Native 下单:返回订单号 + code_url(渲染成二维码扫码付)
|
||||||
export async function createWechatOrder(packId: string): Promise<{ order_id: string; code_url: string; amount_fen: number }> {
|
// + expires_at(二维码有效期,由服务端 orderTTL 决定,前端只负责倒计时展示)。
|
||||||
|
export async function createWechatOrder(
|
||||||
|
packId: string,
|
||||||
|
): Promise<{ order_id: string; code_url: string; amount_fen: number; expires_at: string }> {
|
||||||
const res = guard401(
|
const res = guard401(
|
||||||
await fetch(`${GATEWAY}/api/v1/billing/orders`, {
|
await fetch(`${GATEWAY}/api/v1/billing/orders`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -284,10 +287,11 @@ export async function createWechatOrder(packId: string): Promise<{ order_id: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// orderStatus 轮询订单态(pending 时服务端顺路主动查单,本地也能确认到账)。
|
// orderStatus 轮询订单态(pending 时服务端顺路主动查单,本地也能确认到账)。
|
||||||
export async function orderStatus(orderId: string): Promise<TopupOrder> {
|
// warn 必须一并返回:服务端在「已付但金额与订单不符」时不入账、挂起人工核对,
|
||||||
|
// 订单会一直停在 pending —— 丢掉 warn 的话用户付了钱、界面却只会一直转圈等下去。
|
||||||
|
export async function orderStatus(orderId: string): Promise<{ order: TopupOrder; warn?: string }> {
|
||||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: bearer() }));
|
const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: bearer() }));
|
||||||
const d = await jsonOrThrow<{ order: TopupOrder }>(res, "查询失败");
|
return jsonOrThrow<{ order: TopupOrder; warn?: string }>(res, "查询失败");
|
||||||
return d.order;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// redeemCode 核销兑换码,返回入账后的余额。
|
// redeemCode 核销兑换码,返回入账后的余额。
|
||||||
|
|||||||
@@ -191,11 +191,10 @@ export function Usage() {
|
|||||||
pack={paying}
|
pack={paying}
|
||||||
onClose={(paid) => {
|
onClose={(paid) => {
|
||||||
setPaying(null);
|
setPaying(null);
|
||||||
if (paid) {
|
if (paid) toast.push("success", "支付成功,积分已入账");
|
||||||
toast.push("success", "支付成功,积分已入账");
|
// 无论如何都刷一次:用户可能在关弹窗前一刻付款、状态刚落地还没被轮询看到。
|
||||||
load();
|
load();
|
||||||
refresh();
|
refresh();
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -203,51 +202,111 @@ export function Usage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PayDialog 微信 Native 扫码支付:下单 → code_url 画二维码 → 轮询单态(2.5s;
|
// PayDialog 微信 Native 扫码支付:下单 → code_url 画二维码 → 轮询单态确认到账。
|
||||||
// 服务端 pending 时会顺路主动查单,收不到公网回调的环境也能确认到账)。
|
//
|
||||||
|
// 轮询这条链上几件必须处理的事:
|
||||||
|
// - 递归 setTimeout 而非 setInterval:请求慢于间隔时 setInterval 会把请求摞起来,
|
||||||
|
// 而这个端点每次都可能触发一次渠道查单,摞起来等于放大对微信的调用。
|
||||||
|
// - 标签页切走时暂停:用户扫完码要切到微信 App 付款,这段时间页面在后台空转没意义;
|
||||||
|
// 切回来立刻补查一次,回前台的确认几乎无延迟。
|
||||||
|
// - 连续失败指数退避:断网时别以固定频率猛打,封顶 15s。
|
||||||
|
// - warn(已付但金额与订单不符)必须显式呈现:这种单服务端**不入账**、订单一直停在
|
||||||
|
// pending,不提示的话用户会一直等一个永远不会来的结果。
|
||||||
|
const POLL_BASE_MS = 2500;
|
||||||
|
const POLL_MAX_MS = 15000;
|
||||||
|
|
||||||
|
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||||
|
|
||||||
function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) => void }) {
|
function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) => void }) {
|
||||||
const [qr, setQr] = useState("");
|
const [qr, setQr] = useState("");
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
|
const [warn, setWarn] = useState("");
|
||||||
const [state, setState] = useState<"creating" | "waiting" | "paid" | "expired">("creating");
|
const [state, setState] = useState<"creating" | "waiting" | "paid" | "expired">("creating");
|
||||||
|
const [left, setLeft] = useState(0); // 二维码剩余有效秒数
|
||||||
const orderRef = useRef("");
|
const orderRef = useRef("");
|
||||||
|
const expiresRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
let timer: number | null = null;
|
let timer: number | null = null;
|
||||||
|
let failures = 0;
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (timer) window.clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
};
|
||||||
|
const schedule = (ms: number) => {
|
||||||
|
stop();
|
||||||
|
timer = window.setTimeout(() => void tick(), ms);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
if (!alive) return;
|
||||||
|
if (document.hidden) return schedule(POLL_BASE_MS); // 后台不打请求,等切回来补
|
||||||
|
try {
|
||||||
|
const { order, warn: w } = await orderStatus(orderRef.current);
|
||||||
|
if (!alive) return;
|
||||||
|
failures = 0;
|
||||||
|
setWarn(w ?? "");
|
||||||
|
if (order.status === "paid") {
|
||||||
|
setState("paid");
|
||||||
|
stop();
|
||||||
|
window.setTimeout(() => onClose(true), 800);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (order.status === "expired" || order.status === "failed") {
|
||||||
|
setState("expired");
|
||||||
|
stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
schedule(POLL_BASE_MS);
|
||||||
|
} catch {
|
||||||
|
if (!alive) return;
|
||||||
|
failures += 1;
|
||||||
|
schedule(Math.min(POLL_BASE_MS * 2 ** failures, POLL_MAX_MS));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切回前台立刻查一次,不用等下一个 tick
|
||||||
|
const onVisible = () => {
|
||||||
|
if (!document.hidden && alive && orderRef.current) schedule(0);
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisible);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const o = await createWechatOrder(pack.id);
|
const o = await createWechatOrder(pack.id);
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
orderRef.current = o.order_id;
|
orderRef.current = o.order_id;
|
||||||
|
expiresRef.current = new Date(o.expires_at).getTime();
|
||||||
|
setLeft(Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000)));
|
||||||
setQr(await QRCode.toDataURL(o.code_url, { width: 240, margin: 1 }));
|
setQr(await QRCode.toDataURL(o.code_url, { width: 240, margin: 1 }));
|
||||||
setState("waiting");
|
setState("waiting");
|
||||||
timer = window.setInterval(async () => {
|
schedule(POLL_BASE_MS);
|
||||||
try {
|
|
||||||
const s = await orderStatus(orderRef.current);
|
|
||||||
if (!alive) return;
|
|
||||||
if (s.status === "paid") {
|
|
||||||
setState("paid");
|
|
||||||
if (timer) window.clearInterval(timer);
|
|
||||||
window.setTimeout(() => onClose(true), 800);
|
|
||||||
} else if (s.status === "expired" || s.status === "failed") {
|
|
||||||
setState("expired");
|
|
||||||
if (timer) window.clearInterval(timer);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* 单次轮询失败忽略,下个 tick 再试 */
|
|
||||||
}
|
|
||||||
}, 2500);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (alive) setErr((e as Error).message);
|
if (alive) setErr((e as Error).message);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
if (timer) window.clearInterval(timer);
|
stop();
|
||||||
|
document.removeEventListener("visibilitychange", onVisible);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [pack.id]);
|
}, [pack.id]);
|
||||||
|
|
||||||
|
// 倒计时:到点本地先置过期,省一次「扫了个死码才知道」。服务端 TTL 仍是权威。
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== "waiting") return;
|
||||||
|
const t = window.setInterval(() => {
|
||||||
|
const s = Math.max(0, Math.round((expiresRef.current - Date.now()) / 1000));
|
||||||
|
setLeft(s);
|
||||||
|
if (s === 0) setState("expired");
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(t);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open title={`微信扫码 · ${pack.name}`} onClose={() => onClose(state === "paid")}>
|
<Dialog open title={`微信扫码 · ${pack.name}`} onClose={() => onClose(state === "paid")}>
|
||||||
<div className="flex flex-col items-center gap-3 py-2">
|
<div className="flex flex-col items-center gap-3 py-2">
|
||||||
@@ -258,15 +317,44 @@ function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) =>
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* 二维码底色固定纯白:扫码器对暗色主题下的低对比码识别率差 */}
|
{/* 二维码底色固定纯白:扫码器对暗色主题下的低对比码识别率差 */}
|
||||||
<div className="rounded-lg bg-white p-2">
|
<div className="relative rounded-lg bg-white p-2">
|
||||||
<img src={qr} alt="微信支付二维码" width={240} height={240} />
|
<img src={qr} alt="微信支付二维码" width={240} height={240} />
|
||||||
|
{state === "expired" && (
|
||||||
|
// 盖住失效的码,避免用户扫一个必然失败的二维码
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-slate-900/80 text-xs text-slate-200">
|
||||||
|
二维码已失效
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg font-semibold tabular-nums text-slate-100">¥{(pack.price_fen / 100).toFixed(2)}</div>
|
<div className="text-lg font-semibold tabular-nums text-slate-100">¥{(pack.price_fen / 100).toFixed(2)}</div>
|
||||||
<div className="mt-1 text-xs text-slate-500">
|
<div className="mt-1 text-xs text-slate-500">
|
||||||
{state === "paid" ? "✅ 已支付,入账中…" : state === "expired" ? "订单已过期,请关闭后重新下单" : `微信扫一扫支付,到账 ${fmtCredits(pack.credits_micro)} 积分`}
|
{state === "paid"
|
||||||
|
? "✅ 已支付,入账中…"
|
||||||
|
: state === "expired"
|
||||||
|
? "订单已过期,请关闭后重新下单"
|
||||||
|
: `微信扫一扫支付,到账 ${fmtCredits(pack.credits_micro)} 积分`}
|
||||||
</div>
|
</div>
|
||||||
|
{state === "waiting" && (
|
||||||
|
<div className="mt-1 text-[11px] tabular-nums text-slate-500">二维码 {mmss(left)} 后失效</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 已付但金额与订单不符:服务端不入账、挂起人工核对,必须告诉用户别再等 */}
|
||||||
|
{warn && (
|
||||||
|
<p className="rounded-md bg-amber-500/10 px-3 py-2 text-center text-[11px] leading-relaxed text-amber-300">
|
||||||
|
{warn}
|
||||||
|
<br />
|
||||||
|
请联系客服并提供订单号 {orderRef.current}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 掉单补偿定时器在后台兜底,关页面不会丢钱——说清楚,省得用户守着不敢关 */}
|
||||||
|
{state === "waiting" && !warn && (
|
||||||
|
<p className="text-center text-[11px] leading-relaxed text-slate-600">
|
||||||
|
付完没反应?关掉也没关系,到账后积分会自动补上。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user