fix(delivery): add transient retry, fix queue deadlock, and gate delivery submit by scan auth

This commit is contained in:
yml2213
2026-09-18 14:49:54 +08:00
parent 848d3da809
commit 20bde97d7a
8 changed files with 404 additions and 40 deletions
+14
View File
@@ -53,6 +53,20 @@ func (h *DeliveryHandler) Bind(c *gin.Context) {
response.OK(c, result)
}
func (h *DeliveryHandler) GetBindResult(c *gin.Context) {
bindUUID := strings.TrimSpace(c.Query("bind_uuid"))
if bindUUID == "" {
response.BadRequest(c, "bind_uuid 不能为空")
return
}
result, err := h.deliverySvc.GetBindResultLinked(c.Param("order_no"), bindUUID, parseDeliveryAuth(c))
if err != nil {
writeDeliveryError(c, err)
return
}
response.OK(c, result)
}
type deliverySubmitReq struct {
GameAccount string `json:"game_account" binding:"required"`
BindUUID string `json:"bind_uuid" binding:"required"`
+1
View File
@@ -111,6 +111,7 @@ func Setup(h *Handlers) *gin.Engine {
{
delivery.GET("/orders/:order_no", h.Delivery.GetOrder)
delivery.POST("/orders/:order_no/bind", h.Delivery.Bind)
delivery.GET("/orders/:order_no/bind-result", h.Delivery.GetBindResult)
delivery.POST("/orders/:order_no/submit", h.Delivery.Submit)
delivery.GET("/image-proxy", h.Delivery.ImageProxy)
delivery.HEAD("/image-proxy", h.Delivery.ImageProxy)
+36
View File
@@ -383,6 +383,42 @@ func (s *DeliveryService) GetBindResult(merchantID uint, orderNo, bindUUID strin
}, nil
}
// GetBindResultLinked 为公开发货链接场景提供查询绑定结果的能力。
func (s *DeliveryService) GetBindResultLinked(orderNo, bindUUID string, auth DeliveryLinkAuth) (*DeliveryBindResult, error) {
bindUUID = strings.TrimSpace(bindUUID)
if bindUUID == "" {
return nil, errors.New("bind_uuid 不能为空")
}
openOrder, order, goodID, err := s.loadLinkedOrder(orderNo, &auth)
if err != nil {
return nil, err
}
if _, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false); err != nil {
return nil, err
}
providers, _ := s.router.ResolveProviders(order)
if len(providers) == 0 {
return nil, errors.New("未配置发货渠道")
}
p := providers[0]
res, err := p.CheckBind(context.Background(), order, bindUUID)
if err != nil {
return nil, err
}
return &DeliveryBindResult{
BindUUID: res.BindUUID,
BindURL: res.BindURL,
QRURL: res.QRURL,
Bound: res.Bound,
GameAccount: res.GameAccount,
RoleName: res.RoleName,
GameChannel: res.GameChannel,
ExpectedGameAccount: res.ExpectedGameAccount,
Mismatch: res.Mismatch,
Message: res.Message,
}, nil
}
func (s *DeliveryService) bindWithOrder(orderNo, gameAccount string, auth *DeliveryLinkAuth, merchantID uint) (*DeliveryBindResult, error) {
gameAccount = strings.TrimSpace(gameAccount)
if gameAccount == "" {
+123 -31
View File
@@ -215,12 +215,45 @@ func (p *DLCProvider) Submit(ctx context.Context, order *model.FulfillmentOrder,
}
if err := p.patchOrderQueue(queueOrderID, gameAccount, bindUUID, goodsNo, goodsName); err != nil {
return &ProviderSubmitResult{PatchResultData: partial}, fmt.Errorf("补充发货账号失败: %w", err)
if isQueueDeadError(err) {
// 旧队列已失效/不可操作,清除失效队列并重新创建
delete(partial, "queue_order_id")
newQueueID, createErr := p.createOrderQueue(goodID, order.OrderNo, goodsNo)
if createErr != nil {
return &ProviderSubmitResult{PatchResultData: partial}, fmt.Errorf("重置发货队列失败: %w (原错误: %v)", createErr, err)
}
queueOrderID = newQueueID
partial["queue_order_id"] = queueOrderID
if patchErr := p.patchOrderQueue(queueOrderID, gameAccount, bindUUID, goodsNo, goodsName); patchErr != nil {
return &ProviderSubmitResult{PatchResultData: partial}, fmt.Errorf("补充发货账号失败: %w", patchErr)
}
} else {
return &ProviderSubmitResult{PatchResultData: partial}, fmt.Errorf("补充发货账号失败: %w", err)
}
}
upstreamOrder, err := p.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo)
if err != nil {
return &ProviderSubmitResult{PatchResultData: partial}, fmt.Errorf("创建上游正式订单失败: %w", err)
if isQueueDeadError(err) {
// 创建正式单阶段出现订单不可操作,尝试重建队列重试一次
delete(partial, "queue_order_id")
newQueueID, createErr := p.createOrderQueue(goodID, order.OrderNo, goodsNo)
if createErr == nil {
queueOrderID = newQueueID
partial["queue_order_id"] = queueOrderID
if patchErr := p.patchOrderQueue(queueOrderID, gameAccount, bindUUID, goodsNo, goodsName); patchErr == nil {
if retriedOrder, retriedErr := p.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo); retriedErr == nil {
upstreamOrder = retriedOrder
err = nil
} else {
err = retriedErr
}
}
}
}
if err != nil {
return &ProviderSubmitResult{PatchResultData: partial}, fmt.Errorf("创建上游正式订单失败: %w", err)
}
}
providerOrderNo := firstNonEmpty(
@@ -366,6 +399,34 @@ func (p *DLCProvider) SyncStatus(ctx context.Context, order *model.FulfillmentOr
return nil, nil
}
func isQueueDeadError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "订单不可操作") ||
strings.Contains(msg, "不可操作") ||
strings.Contains(msg, "订单不存在") ||
strings.Contains(msg, "队列已失效")
}
func isTransientDeliveryError(err error, statusCode int, message string) bool {
if err != nil {
return true
}
if statusCode == http.StatusTooManyRequests || statusCode == http.StatusBadGateway ||
statusCode == http.StatusServiceUnavailable || statusCode == http.StatusGatewayTimeout {
return true
}
msg := strings.ToLower(message)
return strings.Contains(msg, "请求过于频繁") ||
strings.Contains(msg, "稍后再试") ||
strings.Contains(msg, "系统繁忙") ||
strings.Contains(msg, "网络繁忙") ||
strings.Contains(msg, "too many requests") ||
strings.Contains(msg, "rate limit")
}
func (p *DLCProvider) signProxy(path, method string, data interface{}, out interface{}) error {
payload, err := json.Marshal(map[string]interface{}{
"path": path,
@@ -375,38 +436,69 @@ func (p *DLCProvider) signProxy(path, method string, data interface{}, out inter
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, p.bffBaseURL+"/sign-proxy", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("发货服务请求失败:%w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var envelope struct {
Code interface{} `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return fmt.Errorf("发货服务响应无法解析:%s", truncateDeliveryText(string(body)))
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || !isZeroCode(envelope.Code) {
if envelope.Message != "" {
return errors.New(envelope.Message)
const maxRetries = 2
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
// 遭遇频控或瞬时网络波动时的指数避让 (1.2s, 2.4s)
backoff := time.Duration(1200*attempt)*time.Millisecond + time.Duration((attempt*137)%300)*time.Millisecond
time.Sleep(backoff)
}
req, err := http.NewRequest(http.MethodPost, p.bffBaseURL+"/sign-proxy", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("发货服务请求失败:%w", err)
if attempt < maxRetries && isTransientDeliveryError(err, 0, "") {
continue
}
return lastErr
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
var envelope struct {
Code interface{} `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
lastErr = fmt.Errorf("发货服务响应无法解析:%s", truncateDeliveryText(string(body)))
if attempt < maxRetries && resp.StatusCode >= http.StatusInternalServerError {
continue
}
return lastErr
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || !isZeroCode(envelope.Code) {
errMsg := envelope.Message
if errMsg == "" {
errMsg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
lastErr = errors.New(errMsg)
if attempt < maxRetries && isTransientDeliveryError(nil, resp.StatusCode, errMsg) {
continue
}
return lastErr
}
if out == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" {
return nil
}
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("发货服务数据无法解析:%w", err)
}
return fmt.Errorf("发货服务请求失败:HTTP %d", resp.StatusCode)
}
if out == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" {
return nil
}
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("发货服务数据无法解析:%w", err)
}
return nil
return lastErr
}
func timeSecond(s int) time.Duration {
@@ -0,0 +1,115 @@
package service
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func TestIsQueueDeadError(t *testing.T) {
tests := []struct {
err error
expected bool
}{
{nil, false},
{errors.New("订单不可操作"), true},
{errors.New("补充发货账号失败: 订单不可操作"), true},
{errors.New("订单不存在"), true},
{errors.New("队列已失效"), true},
{errors.New("账号尚未绑定"), false},
{errors.New("请求过于频繁"), false},
}
for _, tc := range tests {
got := isQueueDeadError(tc.err)
if got != tc.expected {
t.Errorf("isQueueDeadError(%v) = %v, expected %v", tc.err, got, tc.expected)
}
}
}
func TestIsTransientDeliveryError(t *testing.T) {
tests := []struct {
err error
statusCode int
message string
expected bool
}{
{errors.New("dial tcp timeout"), 0, "", true},
{nil, 429, "", true},
{nil, 502, "", true},
{nil, 503, "", true},
{nil, 504, "", true},
{nil, 400, "绑定校验失败: 请求过于频繁,请稍后再试", true},
{nil, 400, "请求过于频繁", true},
{nil, 400, "系统繁忙,请稍后再试", true},
{nil, 400, "网络繁忙", true},
{nil, 400, "账号尚未绑定,请扫码完成绑定后再提交", false},
{nil, 400, "订单不可操作", false},
}
for _, tc := range tests {
got := isTransientDeliveryError(tc.err, tc.statusCode, tc.message)
if got != tc.expected {
t.Errorf("isTransientDeliveryError(%v, %d, %q) = %v, expected %v", tc.err, tc.statusCode, tc.message, got, tc.expected)
}
}
}
func TestSignProxyRetriesOnRateLimitAndSucceeds(t *testing.T) {
var callCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count := atomic.AddInt32(&callCount, 1)
w.Header().Set("Content-Type", "application/json")
if count == 1 {
// 第一次调用返回频控限流
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": 1,
"message": "请求过于频繁,请稍后再试",
})
return
}
// 第二次重试成功
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": 0,
"message": "success",
"data": map[string]interface{}{
"gameAccount": map[string]interface{}{
"game_account": "12345678",
},
},
})
}))
defer server.Close()
client := &http.Client{Timeout: 5 * time.Second}
p := NewDLCProvider(server.URL, "dlc", client)
var out struct {
GameAccount map[string]interface{} `json:"gameAccount"`
}
err := p.signProxy("/public/games/account-bound", "POST", map[string]interface{}{
"bind_uuid": "test-uuid",
}, &out)
if err != nil {
t.Fatalf("signProxy 预期在退避重试后成功,但返回错误: %v", err)
}
finalCount := atomic.LoadInt32(&callCount)
if finalCount != 2 {
t.Fatalf("预期总共调用 2 次(1次失败 + 1次重试成功),实际调用: %d", finalCount)
}
if out.GameAccount == nil || out.GameAccount["game_account"] != "12345678" {
t.Fatalf("预期返回账号信息,实际: %v", out.GameAccount)
}
}
+4
View File
@@ -175,6 +175,10 @@ export const deliveryApi = {
},
{ params: auth },
).then((r) => r.data.data as DeliveryBindResult),
getBindResult: (orderNo: string, bindUUID: string, auth?: { exp?: string; sign?: string }) =>
request.get(`/delivery/v1/orders/${encodeURIComponent(orderNo)}/bind-result`, {
params: { ...auth, bind_uuid: bindUUID },
}).then((r) => r.data.data as DeliveryBindResult),
submit: (orderNo: string, gameAccount: string, bindUUID: string, auth?: { exp?: string; sign?: string }) =>
request.post(
`/delivery/v1/orders/${encodeURIComponent(orderNo)}/submit`,
+104 -9
View File
@@ -61,6 +61,13 @@ export default function Delivery() {
const [form] = Form.useForm<{ game_account: string }>()
const [order, setOrder] = useState<DeliveryOrderInfo | null>(null)
const [bindResult, setBindResult] = useState<DeliveryBindResult | null>(null)
const [boundInfo, setBoundInfo] = useState<{
bound: boolean
role_name?: string
game_account?: string
game_channel?: string
} | null>(null)
const [checkingBind, setCheckingBind] = useState(false)
const [submitResult, setSubmitResult] = useState<DeliverySubmitResult | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
@@ -197,14 +204,55 @@ export default function Delivery() {
}
}
const checkBindStatus = useCallback(
async (silent = false) => {
if (!bindResult?.bind_uuid) return
if (!silent) setCheckingBind(true)
try {
const res = await deliveryApi.getBindResult(decodedOrderNo, bindResult.bind_uuid, deliveryAuth)
if (res.bound) {
setBoundInfo({
bound: true,
role_name: res.role_name,
game_account: res.game_account,
game_channel: res.game_channel,
})
if (!silent) {
message.success(`扫码授权成功!已绑定角色:${res.role_name || res.game_account}`)
}
} else if (!silent) {
message.info(res.message || '尚未检测到扫码授权,请使用微信/QQ扫码后重试')
}
} catch (e) {
if (!silent) {
message.error(e instanceof Error ? e.message : '检查绑定状态失败')
}
} finally {
if (!silent) setCheckingBind(false)
}
},
[bindResult?.bind_uuid, decodedOrderNo, deliveryAuth]
)
useEffect(() => {
if (!bindResult?.bind_uuid || boundInfo?.bound || submitting || submitResult) {
return
}
const timer = setInterval(() => {
checkBindStatus(true)
}, 2500)
return () => clearInterval(timer)
}, [bindResult?.bind_uuid, boundInfo?.bound, submitting, submitResult, checkBindStatus])
const bindAccount = async () => {
const values = await form.validateFields()
setBinding(true)
setSubmitResult(null)
setBoundInfo(null)
try {
const result = await deliveryApi.bind(decodedOrderNo, values.game_account, deliveryAuth)
setBindResult(result)
message.success('绑定二维码已生成')
message.success('绑定二维码已生成,请使用手机微信/QQ扫码授权')
} catch (e) {
message.error(e instanceof Error ? e.message : '生成二维码失败')
} finally {
@@ -218,6 +266,10 @@ export default function Delivery() {
message.warning('请先生成绑定二维码')
return
}
if (!boundInfo?.bound) {
message.warning('请先完成手机微信/QQ扫码授权后再提交发货')
return
}
setSubmitting(true)
try {
const result = await deliveryApi.submit(decodedOrderNo, values.game_account, bindResult.bind_uuid, deliveryAuth)
@@ -229,13 +281,13 @@ export default function Delivery() {
status: result.status,
can_ship: false,
game_uid: values.game_account,
role_name: (result.game_account as Record<string, string>)?.role_name || prev.role_name,
role_name: (result.game_account as Record<string, string>)?.role_name || boundInfo?.role_name || prev.role_name,
}
: prev
)
message.success(result.message || '已提交')
message.success(result.message || '发货已提交')
} catch (e) {
message.error(e instanceof Error ? e.message : '提交失败')
message.error(e instanceof Error ? e.message : '提交发货失败')
} finally {
setSubmitting(false)
}
@@ -258,7 +310,7 @@ export default function Delivery() {
}
const status = statusMap[order.status] || { color: 'default', text: order.status }
const step = submitResult ? 2 : bindResult ? 1 : 0
const step = submitResult ? 2 : boundInfo?.bound ? 2 : bindResult ? 1 : 0
return (
<main style={{ minHeight: '100vh', background: '#f5f7fb', padding: '32px 16px' }}>
@@ -535,23 +587,66 @@ export default function Delivery() {
<Space wrap>
<Button
type="primary"
type={bindResult ? 'default' : 'primary'}
icon={<QrcodeOutlined />}
loading={binding}
onClick={bindAccount}
>
生成绑定二维码
{bindResult ? '重新生成二维码' : '生成绑定二维码'}
</Button>
{bindResult && !boundInfo?.bound && (
<Button
icon={<ReloadOutlined />}
loading={checkingBind}
onClick={() => checkBindStatus(false)}
>
检查扫码状态
</Button>
)}
<Button
type="primary"
icon={<SendOutlined />}
loading={submitting}
disabled={!bindResult || !gameAccount}
disabled={!bindResult || !gameAccount || !boundInfo?.bound}
onClick={submitDelivery}
style={
boundInfo?.bound
? { background: '#16a34a', borderColor: '#16a34a' }
: undefined
}
>
我已扫码,提交发货
{boundInfo?.bound ? '扫码已确认,立即发货' : '请先微信/QQ扫码授权'}
</Button>
</Space>
{bindResult && !boundInfo?.bound && (
<Alert
type="info"
showIcon
icon={<Spin size="small" />}
message="正在等待扫码授权"
description="请使用手机微信或手机QQ扫描下方二维码完成角色授权。系统检测到授权成功后将自动解锁发货按钮。"
/>
)}
{boundInfo?.bound && (
<Alert
type="success"
showIcon
message="✅ 扫码授权成功!已绑定游戏角色"
description={
<div style={{ marginTop: 4 }}>
<div>角色名称:<strong>{boundInfo.role_name || '-'}</strong></div>
<div>游戏账号 UID:<strong>{boundInfo.game_account || gameAccount || '-'}</strong></div>
{boundInfo.game_channel && <div>游戏区服:{boundInfo.game_channel}</div>}
<div style={{ color: '#16a34a', fontWeight: 500, marginTop: 6 }}>
已确认绑定无误,请点击上方「扫码已确认,立即发货」按钮完成充值发货。
</div>
</div>
}
/>
)}
{bindResult && (
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'center' }}>
<Image
+7
View File
@@ -201,6 +201,13 @@ export interface DeliveryBindResult {
bind_uuid: string
bind_url: string
qr_url: string
bound?: boolean
game_account?: string
role_name?: string
game_channel?: string
expected_game_account?: string
mismatch?: boolean
message?: string
}
export interface DeliverySubmitResult {