diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 7b5eac2..ceb530d 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -172,6 +172,9 @@ var ( } return nil }) + cs.Register("order_expiration", "*/5 * * * *", func(ctx context.Context) error { + return task.ExpirePendingOrders(ctx) + }) cs.StartBackground(ctx) // Initialize update manager diff --git a/internal/handler/public/payment.go b/internal/handler/public/payment.go index 260e98c..a332e2b 100644 --- a/internal/handler/public/payment.go +++ b/internal/handler/public/payment.go @@ -1,6 +1,8 @@ package public import ( + "time" + "github.com/gogf/gf/v2/net/ghttp" "github.com/qianfree/team-api/internal/logic/payment" @@ -31,10 +33,24 @@ func HandlePaymentCallback(r *ghttp.Request) { // 仅查询订单状态决定跳转目标,履约由异步回调完成。 func HandlePaymentEpayReturn(r *ghttp.Request) { orderNo := r.GetQuery("out_trade_no").String() - paid := payment.QueryOrderPaid(r.Context(), orderNo) + if orderNo == "" { + r.Response.RedirectTo("/console/wallet?pay=fail") + return + } + + ctx := r.Context() + // 异步 notify 可能晚于浏览器回跳到达:最多轮询约 4 秒等待回调入账。 + // 仍未到账不一定是失败(回调可能仍在途),引导到"处理中"页由前端继续刷新, + // 避免给用户展示误导性的"失败"。 + paid := payment.QueryOrderPaid(ctx, orderNo) + for i := 0; !paid && i < 8; i++ { + time.Sleep(500 * time.Millisecond) + paid = payment.QueryOrderPaid(ctx, orderNo) + } + if paid { r.Response.RedirectTo("/console/wallet?pay=success") return } - r.Response.RedirectTo("/console/wallet?pay=fail") + r.Response.RedirectTo("/console/wallet?pay=processing") } diff --git a/internal/logic/payment/config.go b/internal/logic/payment/config.go index 97b678e..657afef 100644 --- a/internal/logic/payment/config.go +++ b/internal/logic/payment/config.go @@ -57,9 +57,42 @@ func LoadChannelConfig(ctx context.Context, channelType string) (jsonStr string, // SaveChannelConfig 将渠道配置 JSON 保存到 sys_options。 func SaveChannelConfig(ctx context.Context, channelType string, configJSON string) error { + // 启用线上支付渠道前,必须先配置全局回调地址,否则网关异步通知无法回传, + // 支付成功也无法入账(发起支付时还有 RequireCallbackBaseURL 二次校验)。 + if err := validateChannelEnable(ctx, channelType, configJSON); err != nil { + return err + } return lcommon.Config().SetOption(ctx, channelConfigKey(channelType), configJSON) } +// validateChannelEnable 校验"启用渠道"的前置条件:启用易支付要求回调地址已配置。 +func validateChannelEnable(ctx context.Context, channelType string, configJSON string) error { + if channelType != "epay" { + return nil + } + cfg, err := ParseChannelConfig(channelType, configJSON) + if err != nil || cfg == nil { + return nil // JSON 合法性由调用方校验;此处只关心启用前置条件 + } + ec, ok := cfg.(*EpayConfig) + if !ok || !ec.IsEnabled { + return nil + } + if strings.TrimSpace(GetCallbackBaseURL(ctx)) == "" { + return lcommon.NewBadRequestError("请先在「支付设置」中配置回调基础URL,再启用支付渠道;否则支付成功后无法回调入账") + } + return nil +} + +// RequireCallbackBaseURL 发起支付前的二次防护:回调地址未配置则拒绝发起。 +// 渠道启用时已拦截一次,这里防止"启用后被清空回调地址"等场景下仍能发起支付。 +func RequireCallbackBaseURL(ctx context.Context) error { + if strings.TrimSpace(GetCallbackBaseURL(ctx)) == "" { + return lcommon.NewBusinessError(422, "支付回调地址未配置,请联系管理员在「支付设置」中配置回调基础URL") + } + return nil +} + // ListAllChannels 返回所有已注册渠道的配置(含未配置的,返回默认值)。 func ListAllChannels(ctx context.Context) []map[string]interface{} { result := make([]map[string]interface{}, 0, len(channelTypes)) diff --git a/internal/logic/tenant/order.go b/internal/logic/tenant/order.go index f428d0b..27b08c3 100644 --- a/internal/logic/tenant/order.go +++ b/internal/logic/tenant/order.go @@ -187,6 +187,9 @@ func (s *sTenant) OrderPay(ctx context.Context, req *v1.TenantOrderPayReq) (*v1. if req.PaymentChannel == "" { return nil, lcommon.NewBusinessError(422, "请选择支付渠道") } + if err := payment.RequireCallbackBaseURL(ctx); err != nil { + return nil, err + } // orderNo, finalAmount, currency, orderType, _, err := getOrderForPay(ctx, tenantID, orderID) @@ -342,6 +345,9 @@ func (s *sTenant) RechargeCreate(ctx context.Context, req *v1.TenantRechargeCrea } // 2. 从 sys_options 加载渠道配置 + if err := payment.RequireCallbackBaseURL(ctx); err != nil { + return nil, err + } cfg, err := payment.GetChannelConfigAndProvider(ctx, req.PaymentChannel) if err != nil { return nil, lcommon.NewBusinessError(422, err.Error()) diff --git a/web/tenant/src/views/finance/WalletPage.vue b/web/tenant/src/views/finance/WalletPage.vue index 2bf912a..262f532 100644 --- a/web/tenant/src/views/finance/WalletPage.vue +++ b/web/tenant/src/views/finance/WalletPage.vue @@ -17,7 +17,7 @@ const selectedPaymentMethod = ref('') const paymentInfo = ref(null) // Pay result notification -const payResult = ref<'success' | 'fail' | ''>('') +const payResult = ref<'success' | 'fail' | 'processing' | ''>('') // Frozen items const showFrozenModal = ref(false) @@ -239,11 +239,19 @@ onMounted(() => { // Handle pay result from return URL const pay = route.query.pay as string - if (pay === 'success' || pay === 'fail') { + if (pay === 'success' || pay === 'fail' || pay === 'processing') { payResult.value = pay - setTimeout(() => { payResult.value = '' }, 5000) if (pay === 'success') { + setTimeout(() => { payResult.value = '' }, 5000) fetchWallet() + } else if (pay === 'processing') { + // 异步回调可能仍在途:延迟刷新余额,到账后自动更新;超时后提示联系客服 + fetchWallet() + setTimeout(fetchWallet, 3000) + setTimeout(fetchWallet, 8000) + setTimeout(() => { payResult.value = '' }, 12000) + } else { + setTimeout(() => { payResult.value = '' }, 5000) } } }) @@ -295,6 +303,18 @@ onBeforeUnmount(() => { +
+
+ +
+
+

支付结果确认中

+

正在等待到账,余额会在确认后自动更新

+
+ +
@@ -713,6 +733,17 @@ onBeforeUnmount(() => { display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: #dc2626; } +.pay-banner-processing { + background-color: #fffbeb; + border: 1px solid rgba(252, 211, 77, 0.6); + color: #92400e; +} +.pay-banner-processing .pay-banner-icon { + height: 2rem; width: 2rem; border-radius: 9999px; + background-color: #fef3c7; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; color: #d97706; +} /* ========================================== Balance Hero Card