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 }