fix(payment): 修正扫呗签名并自动查询支付结果
This commit is contained in:
@@ -160,7 +160,7 @@ func (c *Client) CreatePayment(ctx context.Context, req CreatePaymentRequest) (*
|
||||
"terminal_trace": req.ThirdOrderID,
|
||||
"terminal_time": now.Format("20060102150405"),
|
||||
"total_fee": strconv.FormatInt(req.AmountCent, 10),
|
||||
"order_body": sanitizeText(req.Body, 128),
|
||||
"order_body": sanitizeOrderBody(req.Body),
|
||||
"notify_url": firstNonEmpty(req.NotifyURL, c.cfg.NotifyURL),
|
||||
"attach": sanitizeText(req.Attach, 128),
|
||||
"timeout_express": strconv.Itoa(expireSeconds),
|
||||
@@ -175,8 +175,12 @@ func (c *Client) CreatePayment(ctx context.Context, req CreatePaymentRequest) (*
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.verifyResponse(raw); err != nil {
|
||||
return nil, err
|
||||
if err := c.verifyResponseOrChannelError(raw); err != nil {
|
||||
return &CreatePaymentResponse{
|
||||
ErrorMessage: responseMessage(raw),
|
||||
Raw: raw,
|
||||
RawRequest: copyMap(params),
|
||||
}, err
|
||||
}
|
||||
return &CreatePaymentResponse{
|
||||
OK: responseOK(raw),
|
||||
@@ -219,7 +223,7 @@ func (c *Client) QueryPayment(ctx context.Context, req QueryPaymentRequest) (*Qu
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.verifyResponse(raw); err != nil {
|
||||
if err := c.verifyResponseOrChannelError(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &QueryPaymentResponse{
|
||||
@@ -269,7 +273,7 @@ func (c *Client) CreateRefund(ctx context.Context, req CreateRefundRequest) (*Cr
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.verifyResponse(raw); err != nil {
|
||||
if err := c.verifyResponseOrChannelError(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CreateRefundResponse{
|
||||
@@ -313,7 +317,7 @@ func (c *Client) QueryRefund(ctx context.Context, req QueryRefundRequest) (*Quer
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := c.verifyResponse(raw); err != nil {
|
||||
if err := c.verifyResponseOrChannelError(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &QueryRefundResponse{
|
||||
@@ -468,6 +472,18 @@ func (c *Client) verifyResponse(raw map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) verifyResponseOrChannelError(raw map[string]string) error {
|
||||
if err := c.verifyResponse(raw); err == nil {
|
||||
return nil
|
||||
}
|
||||
// 扫呗的失败响应通常不带 key_sign。失败响应只会阻断支付或触发退款查询,
|
||||
// 可以安全地保留为业务错误;成功响应仍必须严格验签。
|
||||
if responseCode(raw) != "01" {
|
||||
return nil
|
||||
}
|
||||
return ErrVerifyFailed
|
||||
}
|
||||
|
||||
func responseCode(raw map[string]string) string {
|
||||
return strings.TrimSpace(raw["return_code"])
|
||||
}
|
||||
@@ -573,6 +589,22 @@ func sanitizeText(value string, max int) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func sanitizeOrderBody(value string) string {
|
||||
value = strings.Join(strings.Fields(value), "-")
|
||||
value = strings.NewReplacer(
|
||||
"+", "-",
|
||||
"/", "-",
|
||||
"?", "",
|
||||
"%", "",
|
||||
"#", "",
|
||||
"&", "",
|
||||
"=", "",
|
||||
"【", "[",
|
||||
"】", "]",
|
||||
).Replace(value)
|
||||
return sanitizeText(value, 128)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestCreatePaymentPostsQRPayAndVerifiesResponse(t *testing.T) {
|
||||
resp, err := client.CreatePayment(context.Background(), CreatePaymentRequest{
|
||||
ThirdOrderID: "PAY1",
|
||||
AmountCent: 100,
|
||||
Body: "租号订单",
|
||||
Body: "租号订单 ORD1",
|
||||
Attach: "ORD1",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -82,6 +82,33 @@ func TestCreatePaymentPostsQRPayAndVerifiesResponse(t *testing.T) {
|
||||
if captured["repeated_trace"] != "3" || captured["auto_pay"] != "1" {
|
||||
t.Fatalf("qrpay safety fields = %#v", captured)
|
||||
}
|
||||
if captured["order_body"] != "租号订单-ORD1" {
|
||||
t.Fatalf("order_body = %q, want safe characters only", captured["order_body"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePaymentTreatsUnsignedFailureAsBusinessError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"return_code":"02","return_msg":"签名未通过校验!","result_code":"02"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Config{
|
||||
GatewayURL: server.URL,
|
||||
MerchantID: "M1",
|
||||
TerminalID: "T1",
|
||||
AccessToken: "token-1",
|
||||
})
|
||||
resp, err := client.CreatePayment(context.Background(), CreatePaymentRequest{
|
||||
ThirdOrderID: "PAY1",
|
||||
AmountCent: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePayment() error = %v, want nil business error", err)
|
||||
}
|
||||
if resp.OK || resp.ErrorMessage == "" || resp.Raw["return_code"] != "02" {
|
||||
t.Fatalf("CreatePayment() response = %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryPaymentUsesTraceTimeAndMapsSuccess(t *testing.T) {
|
||||
|
||||
@@ -497,6 +497,13 @@ func (c saobeiChannel) CreatePayment(ctx context.Context, req channelCreatePayme
|
||||
Attach: req.Attach,
|
||||
})
|
||||
if err != nil {
|
||||
if resp != nil {
|
||||
return &channelCreatePaymentResponse{
|
||||
ErrorMessage: resp.ErrorMessage,
|
||||
RawRequest: resp.RawRequest,
|
||||
Raw: resp.Raw,
|
||||
}, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &channelCreatePaymentResponse{
|
||||
|
||||
@@ -68,11 +68,15 @@ func (r *Repository) StartMohong(ctx context.Context, userID uint64, orderID uin
|
||||
NotifyURL: runtimeConfig.NotifyURL,
|
||||
JumpURL: runtimeConfig.JumpURL,
|
||||
ClientIP: clientIP,
|
||||
Body: "撞车订单 " + orderRow.OrderNo,
|
||||
Body: "撞车订单-" + orderRow.OrderNo,
|
||||
Attach: orderRow.OrderNo,
|
||||
})
|
||||
if err != nil {
|
||||
_ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error())
|
||||
var raw map[string]string
|
||||
if resp != nil {
|
||||
raw = resp.Raw
|
||||
}
|
||||
_ = r.markPaymentFailed(ctx, payment.ID, raw, err.Error())
|
||||
r.log().Warn("魔哄支付下单失败", paymentLogFields(ctx, appendFields(
|
||||
paymentOrderFields(payment),
|
||||
runtimeConfigFields(runtimeConfig),
|
||||
|
||||
@@ -64,11 +64,15 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r
|
||||
NotifyURL: runtimeConfig.NotifyURL,
|
||||
JumpURL: runtimeConfig.JumpURL,
|
||||
ClientIP: clientIP,
|
||||
Body: "租号订单 " + orderRow.OrderNo,
|
||||
Body: "租号订单-" + orderRow.OrderNo,
|
||||
Attach: orderRow.OrderNo,
|
||||
})
|
||||
if err != nil {
|
||||
_ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error())
|
||||
var raw map[string]string
|
||||
if resp != nil {
|
||||
raw = resp.Raw
|
||||
}
|
||||
_ = r.markPaymentFailed(ctx, payment.ID, raw, err.Error())
|
||||
r.log().Warn("支付下单请求失败", paymentLogFields(ctx, appendFields(
|
||||
paymentOrderFields(payment),
|
||||
runtimeConfigFields(runtimeConfig),
|
||||
|
||||
@@ -76,6 +76,7 @@ export function OrderActionPanel({
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [payURL, setPayURL] = useState('')
|
||||
const autoPayTriggered = useRef(false)
|
||||
const payPolling = useRef<number | null>(null)
|
||||
const actions = getOrderActionAvailability(order, user?.id)
|
||||
const checkout = order.checkout
|
||||
|
||||
@@ -144,6 +145,42 @@ export function OrderActionPanel({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoPay, actions.canPay])
|
||||
|
||||
useEffect(() => {
|
||||
if (dialog !== 'pay' || !payURL) return
|
||||
|
||||
let cancelled = false
|
||||
let attempts = 0
|
||||
const poll = async () => {
|
||||
if (cancelled) return
|
||||
attempts += 1
|
||||
try {
|
||||
const payment = await queryOrderPayment(order.id)
|
||||
if (cancelled) return
|
||||
if (payment.paid) {
|
||||
message.success('支付成功,订单已进入待交接')
|
||||
setDialog(null)
|
||||
setPayURL('')
|
||||
await onChanged()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// 自动轮询不打断用户;保留手动检查作为明确反馈。
|
||||
}
|
||||
if (!cancelled && attempts < 200) {
|
||||
payPolling.current = window.setTimeout(() => void poll(), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
payPolling.current = window.setTimeout(() => void poll(), 3000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (payPolling.current !== null) {
|
||||
window.clearTimeout(payPolling.current)
|
||||
payPolling.current = null
|
||||
}
|
||||
}
|
||||
}, [dialog, message, onChanged, order.id, payURL])
|
||||
|
||||
const run = async (key: string, fn: () => Promise<void>) => {
|
||||
setBusy(key)
|
||||
try {
|
||||
@@ -174,7 +211,7 @@ export function OrderActionPanel({
|
||||
const url = payment.jspay_url || payment.td_code || payment.jspay_info || ''
|
||||
if (url) {
|
||||
setPayURL(url)
|
||||
message.info('支付单已创建,请打开支付页面完成付款')
|
||||
message.info('支付单已创建,系统会自动检查支付结果')
|
||||
} else {
|
||||
message.warning('支付单已创建,但渠道没有返回支付链接,请稍后检查支付状态')
|
||||
}
|
||||
@@ -493,7 +530,7 @@ export function OrderActionPanel({
|
||||
type="info"
|
||||
showIcon
|
||||
message="选择支付方式"
|
||||
description="支付成功后才进入交接流程;支付完成后回到本页点击「检查支付状态」。"
|
||||
description="支付成功后才进入交接流程;二维码创建后系统会自动检查支付结果。"
|
||||
/>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="payWay" label="支付方式">
|
||||
@@ -511,7 +548,7 @@ export function OrderActionPanel({
|
||||
description={
|
||||
<Space direction="vertical" align="center">
|
||||
<QRCode value={payURL} size={180} />
|
||||
<span>请使用微信、支付宝或云闪付扫码付款,完成后回到本页检查支付状态。</span>
|
||||
<span>请使用微信、支付宝或云闪付扫码付款,支付成功后本页会自动刷新。</span>
|
||||
<Button type="primary" href={payURL} target="_blank">
|
||||
在支付应用中打开
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user