Files
affiliate_dash/backend/internal/service/provider_dlc.go
T
yml2213andCursor a2c4eb59b6 fix(dlc): 发货重试不再改写订单号
代练仓按原单号回查能不能发货。随机后缀查不到,正式单会失败。已有队列时应续发原单,不能换号。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 18:45:08 +08:00

599 lines
18 KiB
Go

package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"io"
"net/http"
"strings"
"sync"
"time"
"affiliate_dash/internal/model"
)
type bindCacheEntry struct {
account map[string]interface{}
err error
expiresAt time.Time
}
// DLCProvider 实现第一平台(聚鑫源 / dlc)履约逻辑
type DLCProvider struct {
bffBaseURL string
channel string
httpClient *http.Client
bindCacheMu sync.RWMutex
bindCache map[string]bindCacheEntry
}
func NewDLCProvider(bffBaseURL, channel string, httpClient *http.Client) *DLCProvider {
bffBaseURL = strings.TrimRight(strings.TrimSpace(bffBaseURL), "/")
if bffBaseURL == "" {
bffBaseURL = defaultDeliveryBFFBaseURL
}
channel = strings.TrimSpace(channel)
if channel == "" {
channel = "dlc"
}
if httpClient == nil {
httpClient = &http.Client{Timeout: deliveryUpstreamTimeout}
}
return &DLCProvider{
bffBaseURL: bffBaseURL,
channel: channel,
httpClient: httpClient,
bindCache: make(map[string]bindCacheEntry),
}
}
func (p *DLCProvider) Name() string {
return ProviderDLC
}
func (p *DLCProvider) PrepareDelivery(ctx context.Context, order *model.FulfillmentOrder, openOrder *OpenOrderQuery) (*DeliveryOrderState, error) {
sku := ""
configStr := ""
if order != nil {
sku = order.ProductSKU
if order.MerchantProduct != nil {
configStr = order.MerchantProduct.FulfillmentConfig
}
}
cfg := ParseProductRoutingConfig(configStr, sku)
goodID := cfg.DLCGoodID
if goodID == "" {
goodID = deliveryGoodID(p.channel, sku)
}
if goodID == "" {
return &DeliveryOrderState{
CanShip: false,
CannotShipReason: "未找到对应的第一平台商品发货配置",
Provider: ProviderDLC,
}, nil
}
var good map[string]interface{}
good, _ = p.goodsDetail(goodID)
var deliveryProd *DeliveryProduct
if openOrder != nil && openOrder.Product != nil {
deliveryProd = buildDeliveryProduct(openOrder.Product, good)
}
return &DeliveryOrderState{
CanShip: true,
Product: deliveryProd,
Good: good,
Provider: ProviderDLC,
}, nil
}
func (p *DLCProvider) Bind(ctx context.Context, order *model.FulfillmentOrder, gameAccount string) (*ProviderBindResult, error) {
sku := order.ProductSKU
configStr := ""
if order.MerchantProduct != nil {
configStr = order.MerchantProduct.FulfillmentConfig
}
cfg := ParseProductRoutingConfig(configStr, sku)
goodID := cfg.DLCGoodID
if goodID == "" {
goodID = deliveryGoodID(p.channel, sku)
}
if goodID == "" {
return nil, errors.New("未找到对应的商品发货配置")
}
var out struct {
BindUUID string `json:"bind_uuid"`
URL string `json:"url"`
}
if err := p.signProxy("/public/games/bind-account", "POST", map[string]interface{}{
"game_account": gameAccount,
"gameAccount": gameAccount,
"goodId": goodID,
"good_id": goodID,
}, &out); err != nil {
return nil, err
}
if out.BindUUID == "" || out.URL == "" {
return nil, errors.New("绑定服务未返回绑定二维码")
}
return &ProviderBindResult{
BindUUID: out.BindUUID,
BindURL: out.URL,
QRURL: qrCodeURL(out.URL),
Bound: false,
Provider: ProviderDLC,
}, nil
}
func (p *DLCProvider) CheckBind(ctx context.Context, order *model.FulfillmentOrder, bindUUID string) (*ProviderBindResult, error) {
sku := order.ProductSKU
configStr := ""
if order.MerchantProduct != nil {
configStr = order.MerchantProduct.FulfillmentConfig
}
cfg := ParseProductRoutingConfig(configStr, sku)
goodID := cfg.DLCGoodID
if goodID == "" {
goodID = deliveryGoodID(p.channel, sku)
}
if goodID == "" {
return nil, errors.New("未找到对应的商品发货配置")
}
account, err := p.accountBound(bindUUID, goodID)
if err != nil {
if strings.Contains(err.Error(), "尚未绑定") || strings.Contains(err.Error(), "未绑定") {
return &ProviderBindResult{
BindUUID: bindUUID,
Bound: false,
Message: "尚未绑定,请先完成扫码绑定",
Provider: ProviderDLC,
}, nil
}
if isRateLimitError(err) {
return &ProviderBindResult{
BindUUID: bindUUID,
Bound: false,
Message: "正在同步绑定状态,请稍候...",
Provider: ProviderDLC,
}, nil
}
return nil, err
}
boundAccount := stringFromMap(account, "game_account")
result := &ProviderBindResult{
BindUUID: bindUUID,
Bound: true,
GameAccount: boundAccount,
RoleName: stringFromMap(account, "game_account_role_name"),
GameChannel: gameChannelText(account),
Message: "绑定成功",
Provider: ProviderDLC,
}
if expected := requestDataString(order.RequestData, "game_account"); expected != "" {
result.ExpectedGameAccount = expected
if boundAccount != expected {
result.Mismatch = true
result.Message = "绑定成功,但绑定账号与下单预期账号不一致,请确认"
}
}
return result, nil
}
func (p *DLCProvider) Submit(ctx context.Context, order *model.FulfillmentOrder, gameAccount, bindUUID string) (*ProviderSubmitResult, error) {
sku := order.ProductSKU
configStr := ""
if order.MerchantProduct != nil {
configStr = order.MerchantProduct.FulfillmentConfig
}
cfg := ParseProductRoutingConfig(configStr, sku)
goodID := cfg.DLCGoodID
if goodID == "" {
goodID = deliveryGoodID(p.channel, sku)
}
if goodID == "" {
return nil, errors.New("未找到对应的商品发货配置")
}
boundAccount, err := p.accountBound(bindUUID, goodID)
if err != nil {
return nil, fmt.Errorf("绑定校验失败: %w", err)
}
if boundGameAccount := stringFromMap(boundAccount, "game_account"); boundGameAccount != "" && boundGameAccount != gameAccount {
return nil, errors.New("玩家编号与绑定的游戏账号不一致,请重新绑定")
}
goodsNo := sku
goodsName := order.ProductName
queueOrderID := resultDataString(order.ResultData, "queue_order_id")
partial := map[string]interface{}{
"provider": ProviderDLC,
}
// 代练仓下正式单前会按这个单号回查。随机后缀在库里不存在,不能再拿去下单。
effectiveOrderSN := strings.TrimSpace(order.OrderNo)
if savedSN := resultDataString(order.ResultData, "order_sn"); savedSN != "" && savedSN != effectiveOrderSN {
queueOrderID = ""
}
if queueOrderID == "" {
createdID, sn, err := p.createOrderQueue(goodID, order.OrderNo, goodsNo)
if err != nil {
return nil, fmt.Errorf("创建发货队列失败: %w", err)
}
queueOrderID = createdID
effectiveOrderSN = sn
partial["queue_order_id"] = queueOrderID
partial["order_sn"] = effectiveOrderSN
partial["provider_order_stage"] = deliveryStageQueueCreated
} else {
partial["queue_order_id"] = queueOrderID
partial["order_sn"] = effectiveOrderSN
partial["provider_order_stage"] = deliveryStageQueueCreated
}
if err := p.patchOrderQueue(queueOrderID, gameAccount, bindUUID, goodsNo, goodsName); err != nil {
if isQueueDeadError(err) {
// 旧队列已失效/不可操作,清除失效队列并重新创建
delete(partial, "queue_order_id")
newQueueID, newSN, createErr := p.createOrderQueue(goodID, order.OrderNo, goodsNo)
if createErr != nil {
return &ProviderSubmitResult{PatchResultData: partial}, fmt.Errorf("重置发货队列失败: %w (原错误: %v)", createErr, err)
}
queueOrderID = newQueueID
effectiveOrderSN = newSN
partial["queue_order_id"] = queueOrderID
partial["order_sn"] = effectiveOrderSN
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, effectiveOrderSN)
if err != nil {
return &ProviderSubmitResult{PatchResultData: partial}, fmt.Errorf("创建上游正式订单失败: %w", err)
}
providerOrderNo := firstNonEmpty(
stringFromMap(upstreamOrder, "order_no"),
stringFromMap(upstreamOrder, "_id"),
stringFromMap(upstreamOrder, "id"),
stringFromMap(upstreamOrder, "order_sn"),
queueOrderID,
)
nextStatus := model.OrderStatusDelivering
message := "已提交发货,等待发货结果回传"
if upstreamDeliverySucceeded(upstreamOrder) {
nextStatus = model.OrderStatusDelivered
message = "发货已完成"
}
return &ProviderSubmitResult{
ProviderOrderNo: providerOrderNo,
NextStatus: nextStatus,
Message: message,
GameAccount: boundAccount,
UpstreamOrder: upstreamOrder,
PatchResultData: map[string]interface{}{
"provider": ProviderDLC,
"provider_order_stage": deliveryStageSubmitted,
"queue_order_id": queueOrderID,
"provider_order_no": providerOrderNo,
"game_uid": gameAccount,
"role_name": stringFromMap(boundAccount, "game_account_role_name"),
"game_channel": gameChannelText(boundAccount),
"delivery": upstreamOrder,
},
}, nil
}
func (p *DLCProvider) Cancel(ctx context.Context, order *model.FulfillmentOrder) error {
// DLC 不支持在已提交后自动取消,直接返回 nil
return nil
}
// -----------------------------------------------------------------------------
// DLC 内部 HTTP 交互
// -----------------------------------------------------------------------------
func (p *DLCProvider) goodsDetail(goodID string) (map[string]interface{}, error) {
var out struct {
Good map[string]interface{} `json:"good"`
}
if err := p.signProxy("/public/goods/detail", "POST", map[string]interface{}{
"good_id": goodID,
}, &out); err != nil {
return nil, err
}
return out.Good, nil
}
func (p *DLCProvider) accountBound(bindUUID, goodID string) (map[string]interface{}, error) {
cacheKey := bindUUID + ":" + goodID
p.bindCacheMu.RLock()
if entry, ok := p.bindCache[cacheKey]; ok && time.Now().Before(entry.expiresAt) {
p.bindCacheMu.RUnlock()
return entry.account, entry.err
}
p.bindCacheMu.RUnlock()
var out struct {
GameAccount map[string]interface{} `json:"gameAccount"`
Snake map[string]interface{} `json:"game_account"`
}
err := p.signProxy("/public/games/account-bound", "POST", map[string]interface{}{
"bind_uuid": bindUUID,
"bindUuid": bindUUID,
"goodId": goodID,
"good_id": goodID,
}, &out)
var account map[string]interface{}
ttl := 5 * time.Second
if err != nil {
if isRateLimitError(err) {
ttl = 15 * time.Second
}
} else {
account = out.GameAccount
if account == nil {
account = out.Snake
}
if account == nil {
err = errors.New("账号尚未绑定,请扫码完成绑定后再提交")
ttl = 5 * time.Second
} else {
ttl = 30 * time.Second
}
}
p.bindCacheMu.Lock()
p.bindCache[cacheKey] = bindCacheEntry{
account: account,
err: err,
expiresAt: time.Now().Add(ttl),
}
p.bindCacheMu.Unlock()
return account, err
}
func (p *DLCProvider) createOrderQueue(goodID, orderNo, goodsNo string) (string, string, error) {
var out struct {
Orders []map[string]interface{} `json:"orders"`
}
orderNo = strings.TrimSpace(orderNo)
payload := map[string]interface{}{
"good_id": goodID,
"quantity": 1,
"order_sn": orderNo,
"h5_prefix": p.channel,
}
if goodsNo != "" {
payload["goods_no"] = goodsNo
}
// 代练仓已有这条单号时,原文是「请续发未完成项或重试失败项」。换一个带后缀的单号会让回查 404。
if err := p.signProxy("/public/users/orders-queue", "POST", payload, &out); err != nil {
return "", orderNo, err
}
if len(out.Orders) == 0 {
return "", orderNo, errors.New("发货服务未返回队列订单")
}
orderID := firstNonEmpty(stringFromMap(out.Orders[0], "_id"), stringFromMap(out.Orders[0], "id"))
if orderID == "" {
return "", orderNo, errors.New("发货服务未返回队列订单 ID")
}
return orderID, orderNo, nil
}
func (p *DLCProvider) patchOrderQueue(orderID, gameAccount, bindUUID, goodsNo, goodsName string) error {
var out map[string]interface{}
payload := map[string]interface{}{
"order_id": orderID,
"game_account": gameAccount,
"bind_uuid": bindUUID,
"h5_prefix": p.channel,
}
if goodsNo != "" {
payload["goods_no"] = goodsNo
}
if goodsName != "" {
payload["goods_name"] = goodsName
}
return p.signProxy("/public/users/orders-queue", "PATCH", payload, &out)
}
func (p *DLCProvider) createUpstreamOrder(orderID, gameAccount, goodID, orderNo string) (map[string]interface{}, error) {
var out struct {
Order map[string]interface{} `json:"order"`
}
if err := p.signProxy("/public/users/orders", "POST", map[string]interface{}{
"order_id": orderID,
"game_account": gameAccount,
"good_id": goodID,
"h5_prefix": p.channel,
"order_sn": orderNo,
}, &out); err != nil {
return nil, err
}
if out.Order == nil {
out.Order = map[string]interface{}{"order_id": orderID, "order_sn": orderNo}
}
return out.Order, nil
}
func (p *DLCProvider) SyncStatus(ctx context.Context, order *model.FulfillmentOrder) (*ProviderSyncResult, error) {
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, "队列已失效") ||
strings.Contains(msg, "正在处理中") ||
strings.Contains(msg, "请勿重复提交") ||
strings.Contains(msg, "已有发货记录") ||
strings.Contains(msg, "已处理")
}
func isRateLimitError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "请求过于频繁") ||
strings.Contains(msg, "稍后再试") ||
strings.Contains(msg, "too many requests") ||
strings.Contains(msg, "rate limit")
}
func isTransientDeliveryError(err error, statusCode int, message string) bool {
if err != nil {
return true
}
// 429 Too Many Requests 明确表示上游频控限流,严禁在毫秒/秒级盲目重试,必须立即降级避让
if statusCode == http.StatusTooManyRequests {
return false
}
if statusCode == http.StatusBadGateway || statusCode == http.StatusServiceUnavailable || statusCode == http.StatusGatewayTimeout {
return true
}
msg := strings.ToLower(message)
if strings.Contains(msg, "请求过于频繁") || strings.Contains(msg, "too many requests") || strings.Contains(msg, "rate limit") {
return false
}
return strings.Contains(msg, "系统繁忙") || strings.Contains(msg, "网络繁忙") || strings.Contains(msg, "稍后再试")
}
func generateForwardedIP(seed string) string {
if seed == "" {
seed = fmt.Sprintf("%d", time.Now().UnixNano())
}
h := fnv.New32a()
_, _ = h.Write([]byte(seed))
val := h.Sum32()
b1 := byte((val >> 16) & 0xFF)
b2 := byte((val >> 8) & 0xFF)
if b1 == 0 {
b1 = 1
}
if b2 == 0 {
b2 = 1
}
return fmt.Sprintf("118.248.%d.%d", b1, b2)
}
func (p *DLCProvider) signProxy(path, method string, data interface{}, out interface{}) error {
payload, err := json.Marshal(map[string]interface{}{
"path": path,
"method": strings.ToUpper(method),
"data": data,
})
if err != nil {
return err
}
maxRetries := 2
if path == "/public/games/account-bound" || path == "/public/users/orders" || path == "/public/games/bind-account" {
maxRetries = 0 // 轮询绑定接口、正式下单接口与绑定生成接口不触发传输层重试,避免堆积阻塞或单号重复冲突
}
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")
seed := ""
if m, ok := data.(map[string]interface{}); ok {
seed = firstNonEmpty(
stringFromMap(m, "order_sn"),
stringFromMap(m, "bind_uuid"),
stringFromMap(m, "bindUuid"),
stringFromMap(m, "game_account"),
stringFromMap(m, "gameAccount"),
stringFromMap(m, "good_id"),
stringFromMap(m, "goodId"),
)
}
forwardedIP := generateForwardedIP(seed)
req.Header.Set("X-Forwarded-For", forwardedIP)
req.Header.Set("X-Real-IP", forwardedIP)
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 nil
}
return lastErr
}