fix(delivery): add transient retry, fix queue deadlock, and gate delivery submit by scan auth
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user