diff --git a/backend/internal/service/delivery.go b/backend/internal/service/delivery.go index 266499e..c725b0e 100644 --- a/backend/internal/service/delivery.go +++ b/backend/internal/service/delivery.go @@ -1,18 +1,11 @@ package service import ( - "context" - "encoding/json" - "errors" "net/http" "strings" "time" "affiliate_dash/internal/model" - "affiliate_dash/internal/pkg/timeutil" - - "gorm.io/gorm" - "gorm.io/gorm/clause" ) const defaultDeliveryBFFBaseURL = "https://www.jxya.top/bff" @@ -120,1043 +113,3 @@ func (s *DeliveryService) RegisterProvider(provider FulfillmentProvider) { } // CancelUpstream 取消订单时同步取消已锁定/已建单的上游履约渠道。 -func (s *DeliveryService) CancelUpstream(order *model.FulfillmentOrder) error { - if order == nil || s.router == nil { - return nil - } - if name := resultDataString(order.ResultData, "provider"); name != "" { - if p := s.router.GetProvider(name); p != nil { - return p.Cancel(context.Background(), order) - } - } - if resultDataString(order.ResultData, "lewan_order_id") != "" { - if p := s.router.GetProvider(ProviderLewan); p != nil { - return p.Cancel(context.Background(), order) - } - } - return nil -} - -// SyncDeliveringOrder 主动向上游渠道查询处于 delivering 状态的订单结果,以补充 Webhook 丢失或延迟。 -func (s *DeliveryService) SyncDeliveringOrder(ctx context.Context, order *model.FulfillmentOrder) (bool, error) { - if order == nil || s.router == nil || order.OrderStatus != model.OrderStatusDelivering { - return false, nil - } - if ctx == nil { - ctx = context.Background() - } - - providerName := resultDataString(order.ResultData, "provider") - if providerName == "" && (resultDataString(order.ResultData, "lewan_order_id") != "" || resultDataString(order.ResultData, "lewan_order_no") != "" || strings.HasPrefix(order.ProviderOrderNo, "OMU")) { - providerName = ProviderLewan - } - - providers := make([]FulfillmentProvider, 0, 2) - if providerName != "" { - if p := s.router.GetProvider(providerName); p != nil { - providers = append(providers, p) - } - } - if len(providers) == 0 { - resolved, _ := s.router.ResolveProviders(order) - providers = resolved - } - - for _, p := range providers { - res, err := p.SyncStatus(ctx, order) - if err != nil || res == nil { - continue - } - if res.Status == "success" || res.Status == "failed" { - input := ShipNotifyInput{ - OrderNo: order.OrderNo, - ShipStatus: res.Status, - ProviderOrderNo: res.ProviderOrderNo, - FailReason: res.FailReason, - RawPayload: res.RawPayload, - } - if res.GameUID != "" { - input.GameUID = &res.GameUID - } - if res.RoleName != "" { - input.RoleName = &res.RoleName - } - if res.RoleArea != "" { - input.GameChannel = &res.RoleArea - } - if _, err := s.fulfillment.HandleShipNotify(input); err != nil { - return false, err - } - return true, nil - } - // 如果仍在发货中,且获得了游戏UID/角色名/区服,补充写入 ResultData - patch := map[string]interface{}{} - if res.GameUID != "" && resultDataString(order.ResultData, "game_uid") == "" { - patch["game_uid"] = res.GameUID - } - if res.RoleName != "" && resultDataString(order.ResultData, "role_name") == "" { - patch["role_name"] = res.RoleName - } - if res.RoleArea != "" && resultDataString(order.ResultData, "game_channel") == "" { - patch["game_channel"] = res.RoleArea - } - if len(patch) > 0 { - _ = s.persistOrderResultPatch(order, patch) - } - return false, nil - } - return false, nil -} - -// persistOrderResultPatch 合并写入订单 result_data,不改变发货 stage。 -func (s *DeliveryService) persistOrderResultPatch(order *model.FulfillmentOrder, patch map[string]interface{}) error { - if order == nil || len(patch) == 0 { - return nil - } - resultData := mergeResultData(order.ResultData, patch) - if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}). - Where("id = ?", order.ID). - Update("result_data", resultData).Error; err != nil { - return err - } - order.ResultData = resultData - return nil -} - -func (s *DeliveryService) clearSelectedProvider(order *model.FulfillmentOrder) { - if order == nil || strings.TrimSpace(order.ResultData) == "" { - return - } - var raw map[string]interface{} - if err := json.Unmarshal([]byte(order.ResultData), &raw); err != nil { - return - } - if _, ok := raw["provider"]; !ok { - return - } - delete(raw, "provider") - b, err := json.Marshal(raw) - if err != nil { - return - } - resultData := string(b) - _ = s.fulfillment.db.Model(&model.FulfillmentOrder{}). - Where("id = ?", order.ID). - Update("result_data", resultData).Error - order.ResultData = resultData -} - -type DeliveryOrderInfo struct { - OrderNo string `json:"order_no"` - Status string `json:"status"` - CanShip bool `json:"can_ship"` - CannotShipReason string `json:"cannot_ship_reason,omitempty"` - Product *DeliveryProduct `json:"product,omitempty"` - BuyerName string `json:"buyer_name"` - Amount int64 `json:"amount"` - ProviderOrderNo string `json:"provider_order_no,omitempty"` - ProviderName string `json:"provider_name,omitempty"` - CreatedAt time.Time `json:"created_at"` - ShippedAt *time.Time `json:"shipped_at"` - ShipFailReason string `json:"ship_fail_reason,omitempty"` - GameChannel string `json:"game_channel,omitempty"` - GameUID string `json:"game_uid,omitempty"` - RoleName string `json:"role_name,omitempty"` - PayScore int `json:"pay_score,omitempty"` - Data map[string]interface{} `json:"data,omitempty"` - Good map[string]interface{} `json:"good,omitempty"` -} - -type DeliveryProduct struct { - Name string `json:"name"` - SKU string `json:"sku"` - Game string `json:"game"` - Image string `json:"image,omitempty"` -} - -type DeliveryBindResult struct { - BindUUID string `json:"bind_uuid"` - BindURL string `json:"bind_url"` - QRURL string `json:"qr_url"` - Bound bool `json:"bound"` - GameAccount string `json:"game_account,omitempty"` - RoleName string `json:"role_name,omitempty"` - GameChannel string `json:"game_channel,omitempty"` - ExpectedGameAccount string `json:"expected_game_account,omitempty"` - Mismatch bool `json:"mismatch,omitempty"` - Message string `json:"message,omitempty"` -} - -type DeliveryLinkResult struct { - OrderNo string `json:"order_no"` - DeliveryURL string `json:"delivery_url"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` // null 表示永久有效 - Exp int64 `json:"exp"` - Sign string `json:"sign"` -} - -type DeliverySubmitResult struct { - OrderNo string `json:"order_no"` - Status string `json:"status"` - Message string `json:"message"` - ProviderOrderNo string `json:"provider_order_no,omitempty"` - GameAccount map[string]interface{} `json:"game_account,omitempty"` - UpstreamOrder interface{} `json:"delivery,omitempty"` -} - -type DeliveryLinkAuth struct { - Exp int64 - Sign string -} - -type deliveryHTTPError struct { - status int - message string -} - -func (e deliveryHTTPError) Error() string { - return e.message -} - -func (e deliveryHTTPError) HTTPStatus() int { - return e.status -} - -func newDeliveryHTTPError(status int, message string) error { - return deliveryHTTPError{status: status, message: message} -} - -func (s *DeliveryService) GetOrder(orderNo string, auth DeliveryLinkAuth) (*DeliveryOrderInfo, error) { - info, _, _, err := s.prepareOrder(orderNo, false, &auth) - return info, err -} - -func (s *DeliveryService) GetMerchantOrder(merchantID uint, orderNo string) (*DeliveryOrderInfo, error) { - openOrder, order, goodID, err := s.loadMerchantOrder(merchantID, orderNo) - if err != nil { - return nil, err - } - info, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false) - return info, err -} - -func (s *DeliveryService) Bind(orderNo, gameAccount string, auth DeliveryLinkAuth) (*DeliveryBindResult, error) { - return s.bindWithOrder(orderNo, gameAccount, &auth, 0) -} - -func (s *DeliveryService) BindForMerchant(merchantID uint, orderNo, gameAccount string) (*DeliveryBindResult, error) { - return s.bindWithOrder(orderNo, gameAccount, nil, merchantID) -} - -// GetBindResult 查询绑定结果(自建发货页轮询用)。 -// 绑定未完成时返回 bound=false(不报错),由前端提示继续扫码。 -func (s *DeliveryService) GetBindResult(merchantID uint, orderNo, bindUUID string) (*DeliveryBindResult, error) { - bindUUID = strings.TrimSpace(bindUUID) - if bindUUID == "" { - return nil, errors.New("bind_uuid 不能为空") - } - openOrder, order, goodID, err := s.loadMerchantOrder(merchantID, orderNo) - 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 -} - -// 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 == "" { - return nil, errors.New("请输入玩家编号") - } - var ( - openOrder *OpenOrderQuery - order *model.FulfillmentOrder - goodID string - err error - ) - if merchantID > 0 { - openOrder, order, goodID, err = s.loadMerchantOrder(merchantID, orderNo) - } else { - openOrder, order, goodID, err = s.loadLinkedOrder(orderNo, auth) - } - if err != nil { - return nil, err - } - _, _, _, err = s.buildDeliveryState(openOrder, order, goodID, true) - if err != nil { - return nil, err - } - - providers, _ := s.router.ResolveProviders(order) - if len(providers) == 0 { - return nil, errors.New("未配置发货渠道") - } - - var lastErr error - var bindRes *ProviderBindResult - var usedProvider FulfillmentProvider - tried := map[string]bool{} - for _, p := range providers { - tried[p.Name()] = true - res, err := p.Bind(context.Background(), order, gameAccount) - if err == nil && res != nil { - bindRes = res - usedProvider = p - break - } - lastErr = err - } - - // Prepare 可能因 GET 成功先锁住乐玩;随后写接口凭据/IP 校验失败时,清除锁定并尝试其余渠道(如 DLC)。 - if bindRes == nil { - lockedName := resultDataString(order.ResultData, "provider") - if lockedName != "" || lastErr != nil { - s.clearSelectedProvider(order) - fallbackProviders, _ := s.router.ResolveProviders(order) - for _, p := range fallbackProviders { - if tried[p.Name()] { - continue - } - tried[p.Name()] = true - res, err := p.Bind(context.Background(), order, gameAccount) - if err == nil && res != nil { - bindRes = res - usedProvider = p - break - } - lastErr = err - } - } - } - - if bindRes == nil { - if lastErr != nil { - return nil, lastErr - } - return nil, errors.New("发货渠道未就绪") - } - - // 记录选定渠道与上游单号(如 lewan_order_id),不提前写入 submit claimed stage - if usedProvider != nil { - patch := map[string]interface{}{ - "provider": usedProvider.Name(), - } - for k, v := range bindRes.PatchResultData { - patch[k] = v - } - _ = s.persistOrderResultPatch(order, patch) - } - - return &DeliveryBindResult{ - BindUUID: bindRes.BindUUID, - BindURL: bindRes.BindURL, - QRURL: bindRes.QRURL, - Bound: bindRes.Bound, - GameAccount: bindRes.GameAccount, - RoleName: bindRes.RoleName, - GameChannel: bindRes.GameChannel, - ExpectedGameAccount: bindRes.ExpectedGameAccount, - Mismatch: bindRes.Mismatch, - Message: bindRes.Message, - }, nil -} - -func (s *DeliveryService) bindGood(gameAccount, goodID string) (*DeliveryBindResult, error) { - dlc, ok := s.router.GetProvider(ProviderDLC).(*DLCProvider) - if !ok || dlc == nil { - return nil, errors.New("DLC 渠道未就绪") - } - res, err := dlc.Bind(context.Background(), &model.FulfillmentOrder{ - ProductSKU: "", - }, gameAccount) - if err != nil { - return nil, err - } - return &DeliveryBindResult{ - BindUUID: res.BindUUID, - BindURL: res.BindURL, - QRURL: res.QRURL, - Bound: res.Bound, - }, nil -} - -func (s *DeliveryService) Submit(orderNo, gameAccount, bindUUID string, auth DeliveryLinkAuth) (*DeliverySubmitResult, error) { - return s.submit(orderNo, gameAccount, bindUUID, 0, &auth, 0) -} - -func (s *DeliveryService) SubmitForMerchant(merchantID, apiClientID uint, orderNo, gameAccount, bindUUID string) (*DeliverySubmitResult, error) { - return s.submit(orderNo, gameAccount, bindUUID, apiClientID, nil, merchantID) -} - -func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClientID uint, auth *DeliveryLinkAuth, merchantID uint) (*DeliverySubmitResult, error) { - gameAccount = strings.TrimSpace(gameAccount) - bindUUID = strings.TrimSpace(bindUUID) - orderNo = strings.TrimSpace(orderNo) - if gameAccount == "" || bindUUID == "" { - return nil, errors.New("玩家编号和绑定凭证不能为空") - } - var ( - openOrder *OpenOrderQuery - order *model.FulfillmentOrder - goodID string - err error - ) - if merchantID > 0 { - openOrder, order, goodID, err = s.loadMerchantOrder(merchantID, orderNo) - } else { - openOrder, order, goodID, err = s.loadLinkedOrder(orderNo, auth) - } - if err != nil { - return nil, err - } - info, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false) - if err != nil { - return nil, err - } - orderStatus := normalizeOrderStatus(order) - if orderStatus == model.OrderStatusDelivering || - orderStatus == model.OrderStatusDelivered || - strings.TrimSpace(order.ProviderOrderNo) != "" || - strings.Contains(order.ResultData, "\"queue_order_id\"") || - strings.Contains(order.ResultData, "\"lewan_order_id\"") { - if existing := buildExistingDeliverySubmitResult(order); existing != nil { - return existing, nil - } - } - // 已在上游创建正式订单的失败订单禁止自动重发,避免重复发货。 - if orderStatus == model.OrderStatusShipFailed && deliverySubmittedUpstream(order) { - return nil, newDeliveryHTTPError(http.StatusConflict, "该订单已进入发货流程,为避免重复发货不能直接重试,请稍后查询订单状态或联系平台处理") - } - if !info.CanShip { - reason := info.CannotShipReason - if reason == "" { - reason = "订单暂不可发货" - } - return nil, newDeliveryHTTPError(http.StatusBadRequest, reason) - } - claimed, claimedNow, err := s.claimDeliverySubmission(order.MerchantID, apiClientID, order.OrderNo) - if err != nil { - return nil, err - } - if !claimedNow { - if existing := buildExistingDeliverySubmitResult(claimed); existing != nil { - return existing, nil - } - return nil, newDeliveryHTTPError(http.StatusConflict, "订单暂时正在发货中,请稍后查询") - } - - providers, _ := s.router.ResolveProviders(claimed) - if len(providers) == 0 { - s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageCreateOrder, "未配置发货渠道") - return nil, errors.New("未配置发货渠道") - } - activeProvider := providers[0] - - submitRes, err := activeProvider.Submit(context.Background(), claimed, gameAccount, bindUUID) - if submitRes != nil && len(submitRes.PatchResultData) > 0 { - stage := deliveryStageCreateOrder - if v, ok := submitRes.PatchResultData["provider_order_stage"].(string); ok && v != "" { - stage = v - } - _ = s.recordDeliverySubmissionStage(claimed, stage, submitRes.PatchResultData) - } - if err != nil { - failStage := deliveryStageCreateOrder - if submitRes != nil { - if v, ok := submitRes.PatchResultData["provider_order_stage"].(string); ok && v != "" { - failStage = v - } - } - s.markDeliverySubmissionFailed(claimed, apiClientID, failStage, err.Error()) - return nil, err - } - - resultData := mergeResultData(claimed.ResultData, submitRes.PatchResultData) - updated, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{ - MerchantID: order.MerchantID, - APIClientID: apiClientID, - OrderNo: order.OrderNo, - Status: submitRes.NextStatus, - ProviderOrderNo: submitRes.ProviderOrderNo, - ResultData: resultData, - }) - if err != nil { - // 上游已有身份时先固化 result_data,避免重试双发 - _ = s.recordDeliverySubmissionStage(claimed, deliveryStageSubmitted, submitRes.PatchResultData) - return nil, err - } - return &DeliverySubmitResult{ - OrderNo: updated.OrderNo, - Status: normalizeOrderStatus(updated), - Message: submitRes.Message, - ProviderOrderNo: submitRes.ProviderOrderNo, - GameAccount: submitRes.GameAccount, - UpstreamOrder: submitRes.UpstreamOrder, - }, nil -} - -func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint, orderNo string) (*model.FulfillmentOrder, bool, error) { - var updated model.FulfillmentOrder - claimedNow := false - err := s.fulfillment.db.Transaction(func(tx *gorm.DB) error { - var order model.FulfillmentOrder - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Preload("MerchantProduct"). - Where("merchant_id = ? AND order_no = ?", merchantID, orderNo). - First(&order).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("订单不存在") - } - return err - } - orderStatus := normalizeOrderStatus(&order) - if orderStatus == model.OrderStatusDelivering || orderStatus == model.OrderStatusDelivered { - updated = order - return nil - } - if err := validateOrderStatusTransition(&order, model.OrderStatusDelivering, fulfillmentTransitionUpdate); err != nil { - return newDeliveryHTTPError(http.StatusConflict, "订单暂不可发货") - } - now := time.Now() - stage := resultDataString(order.ResultData, "provider_order_stage") - startedAtRaw := resultDataString(order.ResultData, "submit_started_at") - - // 1. 如果已有协程正在活跃提交中(在 35 秒执行窗口内且处于 claimed/queue_created 阶段), - // 即使已被外部 Webhook 临时推回 ship_failed,也绝对不允许并发重入发货打架 - if deliverySubmissionInProgress(stage) && startedAtRaw != "" { - if startedAt, parseErr := time.Parse(timeutil.APITimeLayout, startedAtRaw); parseErr == nil { - if now.Sub(startedAt) < 35*time.Second { - updated = order - return nil - } - } - } - - // 2. 正常并发防双发:仅对 paid 等非最终失败态订单检查 10 分钟未超时 - if orderStatus != model.OrderStatusShipFailed && - deliverySubmissionInProgress(stage) && - !deliverySubmissionStale(&order, now) { - updated = order - return nil - } - patch := map[string]interface{}{ - "source": "delivery_proxy", - "submit_started_at": timeutil.FormatAPITime(now), - "provider_order_stage": deliveryStageClaimed, - "ship_attempts": resultDataNumber(order.ResultData, "ship_attempts") + 1, - } - if err := tx.Model(&order).Updates(map[string]interface{}{ - "result_data": mergeResultData(order.ResultData, patch), - "failure_reason": "", - }).Error; err != nil { - return err - } - claimedNow = true - if err := tx.Preload("MerchantProduct").First(&updated, order.ID).Error; err != nil { - return err - } - apiClientIDPtr := optionalUint(apiClientID) - if err := writeAudit(tx, &merchantID, nil, apiClientIDPtr, "delivery.submit.claim", "fulfillment_order", order.OrderNo, nil); err != nil { - return err - } - return nil - }) - if err != nil { - return nil, false, err - } - return &updated, claimedNow, nil -} - -func (s *DeliveryService) recordDeliverySubmissionStage(order *model.FulfillmentOrder, stage string, patch map[string]interface{}) error { - if order == nil { - return errors.New("订单不存在") - } - if patch == nil { - patch = map[string]interface{}{} - } - patch["provider_order_stage"] = stage - resultData := mergeResultData(order.ResultData, patch) - if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}). - Where("id = ?", order.ID). - Update("result_data", resultData).Error; err != nil { - return err - } - order.ResultData = resultData - return nil -} - -func (s *DeliveryService) markDeliverySubmissionFailed(order *model.FulfillmentOrder, apiClientID uint, stage, reason string) { - if order == nil { - return - } - latest := order - if fresh, err := s.fulfillment.GetOrder(order.MerchantID, order.OrderNo); err == nil { - latest = fresh - } - // 确保失败时 stage 绝不能继续保持在 in_progress(claimed / queue_created),否则阻碍重试 - releaseStage := stage - if deliverySubmissionInProgress(releaseStage) { - releaseStage = deliveryStageCreateOrder - } - resultData := mergeResultData(latest.ResultData, map[string]interface{}{ - "provider_order_stage": releaseStage, - "fail_stage": stage, - "fail_reason": reason, - "fail_at": timeutil.FormatAPITime(time.Now()), - }) - _, _ = s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{ - MerchantID: order.MerchantID, - APIClientID: apiClientID, - OrderNo: order.OrderNo, - Status: model.OrderStatusShipFailed, - FailureReason: reason, - ResultData: resultData, - }) -} - -func (s *DeliveryService) loadLinkedOrder(orderNo string, auth *DeliveryLinkAuth) (*OpenOrderQuery, *model.FulfillmentOrder, string, error) { - openOrder, err := s.fulfillment.QueryOpenOrder(orderNo) - if err != nil { - return nil, nil, "", err - } - order, err := s.fulfillment.GetByOrderNo(orderNo) - if err != nil { - return nil, nil, "", err - } - if auth != nil { - if err := s.authorizeDeliveryLink(order, *auth); err != nil { - return nil, nil, "", err - } - } - goodID := "" - if openOrder.Product != nil { - goodID = deliveryGoodID(s.channel, openOrder.Product.SKU) - } - return openOrder, order, goodID, nil -} - -func (s *DeliveryService) loadMerchantOrder(merchantID uint, orderNo string) (*OpenOrderQuery, *model.FulfillmentOrder, string, error) { - if merchantID == 0 { - return nil, nil, "", errors.New("无效的商户") - } - openOrder, err := s.fulfillment.QueryOpenOrder(orderNo) - if err != nil { - return nil, nil, "", err - } - order, err := s.fulfillment.GetOrder(merchantID, orderNo) - if err != nil { - return nil, nil, "", err - } - if order.OrderStatus == model.OrderStatusDelivering { - if synced, _ := s.SyncDeliveringOrder(context.Background(), order); synced { - openOrder, _ = s.fulfillment.QueryOpenOrder(orderNo) - order, _ = s.fulfillment.GetOrder(merchantID, orderNo) - } - } - goodID := "" - if openOrder != nil && openOrder.Product != nil { - goodID = deliveryGoodID(s.channel, openOrder.Product.SKU) - } - return openOrder, order, goodID, nil -} - -func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *model.FulfillmentOrder, goodID string, requireCanShip bool) (*DeliveryOrderInfo, *model.FulfillmentOrder, string, error) { - if openOrder == nil || order == nil { - return nil, nil, "", errors.New("订单不存在") - } - canShip, reason := CanFulfill(order) - if !openOrder.CanShip { - reason = openOrder.CannotShipReason - canShip = false - } - if !canShip && reason == "" { - reason = "订单暂不可发货" - } - if openOrder.Product == nil || openOrder.Product.SKU == "" { - canShip = false - reason = "订单缺少商品 SKU" - } - - var ( - deliveryProd *DeliveryProduct - goodData map[string]interface{} - ) - - providers, _ := s.router.ResolveProviders(order) - if len(providers) > 0 { - locked := resultDataString(order.ResultData, "provider") != "" - matchedState, lastReason := s.tryPrepareProviders(order, openOrder, providers) - // 已锁定渠道不可发时,清除锁定并按完整优先级重新选择 - if matchedState == nil && locked { - s.clearSelectedProvider(order) - providers, _ = s.router.ResolveProviders(order) - matchedState, lastReason = s.tryPrepareProviders(order, openOrder, providers) - } - if matchedState != nil { - deliveryProd = matchedState.Product - goodData = matchedState.Good - if matchedState.Provider != "" { - _ = s.persistOrderResultPatch(order, map[string]interface{}{ - "provider": matchedState.Provider, - }) - } - } else { - canShip = false - if reason == "" || reason == "订单暂不可发货" { - reason = lastReason - if reason == "" { - reason = "未找到适用的履约渠道配置" - } - } - } - } else if goodID != "" { - goodData, _ = s.goodsDetail(goodID) - } - - if deliveryProd == nil && openOrder.Product != nil { - deliveryProd = buildDeliveryProduct(openOrder.Product, goodData) - } - - if requireCanShip && !canShip { - return nil, nil, "", newDeliveryHTTPError(http.StatusBadRequest, reason) - } - - providerOrderNo := strings.TrimSpace(openOrder.ProviderOrderNo) - if providerOrderNo == "" { - providerOrderNo = strings.TrimSpace(order.ProviderOrderNo) - } - if providerOrderNo == "" { - providerOrderNo = resultDataString(order.ResultData, "lewan_order_no") - } - if providerOrderNo == "" { - providerOrderNo = resultDataString(order.ResultData, "provider_order_no") - } - - providerName := resultDataString(order.ResultData, "provider") - if providerName == "" { - if strings.TrimSpace(order.ProviderOrderNo) != "" || resultDataString(order.ResultData, "lewan_order_id") != "" { - providerName = "lewan" - } - } - - roleName := openOrder.RoleName - if roleName == "" { - roleName = resultDataString(order.ResultData, "role_name") - } - gameChannel := openOrder.GameChannel - if gameChannel == "" { - gameChannel = resultDataString(order.ResultData, "game_channel") - } - // 若角色名或区服为空,尝试从关联的 rock_tokens 表反查并自动回填 - if (roleName == "" || gameChannel == "") && s.fulfillment != nil && s.fulfillment.db != nil { - var rockToken model.RockToken - if err := s.fulfillment.db.Where("assigned_order_no = ?", order.OrderNo).First(&rockToken).Error; err == nil { - patches := map[string]interface{}{} - if roleName == "" && rockToken.ActualRoleName != "" { - roleName = rockToken.ActualRoleName - patches["role_name"] = roleName - } - if gameChannel == "" && rockToken.ActualChannel != "" { - gameChannel = rockToken.ActualChannel - patches["game_channel"] = gameChannel - } - if len(patches) > 0 { - _ = s.persistOrderResultPatch(order, patches) - } - } - } - - info := &DeliveryOrderInfo{ - OrderNo: openOrder.OrderNo, - Status: openOrder.Status, - CanShip: canShip, - CannotShipReason: reason, - Product: deliveryProd, - BuyerName: openOrder.BuyerName, - Amount: openOrder.Amount, - ProviderOrderNo: providerOrderNo, - ProviderName: providerName, - CreatedAt: openOrder.CreatedAt, - ShippedAt: openOrder.ShippedAt, - ShipFailReason: openOrder.ShipFailReason, - GameChannel: gameChannel, - GameUID: openOrder.GameUID, - RoleName: roleName, - PayScore: openOrder.PayScore, - Data: requestDataMap(order.RequestData), - Good: goodData, - } - return info, order, goodID, nil -} - -// tryPrepareProviders 按优先级尝试 Prepare,返回第一个 CanShip 的渠道状态。 -func (s *DeliveryService) tryPrepareProviders(order *model.FulfillmentOrder, openOrder *OpenOrderQuery, providers []FulfillmentProvider) (*DeliveryOrderState, string) { - lastReason := "" - for _, p := range providers { - state, err := p.PrepareDelivery(context.Background(), order, openOrder) - if err != nil || state == nil { - continue - } - if state.Provider == "" { - state.Provider = p.Name() - } - if state.CanShip { - return state, "" - } - if state.CannotShipReason != "" { - lastReason = state.CannotShipReason - } - } - return nil, lastReason -} - -func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) { - orderNo = strings.TrimSpace(orderNo) - if orderNo == "" { - return nil, errors.New("订单号不能为空") - } - order, err := s.fulfillment.GetOrder(merchantID, orderNo) - if err != nil { - return nil, err - } - // 已取消订单即使存在未过期链接也不再发放(买家侧授权同样拦截已取消订单)。 - if normalizeOrderStatus(order) == model.OrderStatusCancelled { - return nil, newDeliveryHTTPError(http.StatusBadRequest, "订单已取消,无法获取发货链接") - } - now := time.Now().UTC() - if order.DeliveryLinkRevokedAt != nil { - return nil, newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废") - } - // 复用未过期链接:已交付/发货中订单也允许查看兑换结果或进度,仅停止发放新链接。 - if order.DeliveryLinkExpiresAt != nil && order.DeliveryLinkExpiresAt.After(now) { - expiresAt := order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second) - return s.buildDeliveryLinkResult(order.OrderNo, &expiresAt, requestBaseURL), nil - } - // 永久有效链接(已生成、未作废、无有效期)直接复用,不重复生成。 - if order.DeliveryLinkGeneratedAt != nil && order.DeliveryLinkExpiresAt == nil { - return s.buildDeliveryLinkResult(order.OrderNo, nil, requestBaseURL), nil - } - // 仅待发货(含可重试的发货失败)订单能生成或续期新链接。 - canShip, reason := CanFulfill(order) - if !canShip { - if reason == "" { - reason = "订单暂不可发货" - } - return nil, newDeliveryHTTPError(http.StatusBadRequest, reason) - } - var expiresAt *time.Time - if s.linkTTL > 0 { - value := now.Add(s.linkTTL).UTC().Truncate(time.Second) - expiresAt = &value - } - if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}). - Where("id = ?", order.ID). - Updates(map[string]interface{}{ - "delivery_link_expires_at": expiresAt, - "delivery_link_revoked_at": nil, - "delivery_link_generated_at": now, - }).Error; err != nil { - return nil, err - } - return s.buildDeliveryLinkResult(order.OrderNo, expiresAt, requestBaseURL), nil -} - -func (s *DeliveryService) RevokeDeliveryLink(merchantID, actorUserID uint, orderNo string) error { - orderNo = strings.TrimSpace(orderNo) - if orderNo == "" { - return errors.New("订单号不能为空") - } - order, err := s.fulfillment.GetOrder(merchantID, orderNo) - if err != nil { - return err - } - now := time.Now().UTC().Truncate(time.Second) - return s.fulfillment.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.FulfillmentOrder{}). - Where("id = ?", order.ID). - Updates(map[string]interface{}{ - "delivery_link_revoked_at": now, - }).Error; err != nil { - return err - } - return writeAudit(tx, &merchantID, optionalUint(actorUserID), nil, "delivery_link.revoke", "fulfillment_order", order.OrderNo, map[string]interface{}{ - "revoked_at": timeutil.FormatAPITime(now), - }) - }) -} - -func (s *DeliveryService) RestoreDeliveryLink(merchantID, actorUserID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) { - orderNo = strings.TrimSpace(orderNo) - if orderNo == "" { - return nil, errors.New("订单号不能为空") - } - order, err := s.fulfillment.GetOrder(merchantID, orderNo) - if err != nil { - return nil, err - } - canShip, reason := CanFulfill(order) - if !canShip { - if reason == "" { - reason = "订单暂不可发货" - } - return nil, newDeliveryHTTPError(http.StatusBadRequest, reason) - } - now := time.Now().UTC() - var expiresAt *time.Time - if s.linkTTL > 0 { - value := now.Add(s.linkTTL).Truncate(time.Second) - if order.DeliveryLinkExpiresAt != nil && value.Equal(order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)) { - value = value.Add(time.Second) - } - expiresAt = &value - } - if err := s.fulfillment.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.FulfillmentOrder{}). - Where("id = ?", order.ID). - Updates(map[string]interface{}{ - "delivery_link_expires_at": expiresAt, - "delivery_link_revoked_at": nil, - "delivery_link_generated_at": now, - }).Error; err != nil { - return err - } - metadata := map[string]interface{}{ - "generated_at": timeutil.FormatAPITime(now), - } - if expiresAt != nil { - metadata["expires_at"] = timeutil.FormatAPITime(*expiresAt) - } else { - metadata["permanent"] = true - } - return writeAudit(tx, &merchantID, optionalUint(actorUserID), nil, "delivery_link.restore", "fulfillment_order", order.OrderNo, metadata) - }); err != nil { - return nil, err - } - return s.buildDeliveryLinkResult(order.OrderNo, expiresAt, requestBaseURL), nil -} - -func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool, auth *DeliveryLinkAuth) (*DeliveryOrderInfo, *model.FulfillmentOrder, string, error) { - orderNo = strings.TrimSpace(orderNo) - if orderNo == "" { - return nil, nil, "", errors.New("订单号不能为空") - } - openOrder, err := s.fulfillment.QueryOpenOrder(orderNo) - if err != nil { - return nil, nil, "", err - } - order, err := s.fulfillment.GetByOrderNo(orderNo) - if err != nil { - return nil, nil, "", err - } - if auth != nil { - if err := s.authorizeDeliveryLink(order, *auth); err != nil { - return nil, nil, "", err - } - } - if order.OrderStatus == model.OrderStatusDelivering { - if synced, _ := s.SyncDeliveringOrder(context.Background(), order); synced { - openOrder, _ = s.fulfillment.QueryOpenOrder(orderNo) - order, _ = s.fulfillment.GetByOrderNo(orderNo) - } - } - return s.buildDeliveryState(openOrder, order, "", requireCanShip) -} - -func deliveryGoodID(channel, sku string) string { - if channel != "dlc" { - return "" - } - return map[string]string{ - "suit_alan_walker": "682ef3c5a8f40c4234c59f47", - "suit_shadow_gothic": "682ef39ca8f40c4234c59f36", - "top_black_elite_trainer": "682ef39ca8f40c4234c59f37", - "m416_hamster_gray": "682ef3c5a8f40c4234c59f52", - "bag_cute_bear": "682ef3c5a8f40c4234c59f4a", - "suit_dual_fluffy": "682ef3c5a8f40c4234c59f49", - "suit_pink_sheep": "682ef3c5a8f40c4234c59f4d", - "suit_first_peach": "682ef3c5a8f40c4234c59f48", - "suit_romantic_destiny": "682ef39ca8f40c4234c59f38", - "pack_western_cowboy": "682ef3c5a8f40c4234c59f4c", - "smoke_pink_sheep": "682ef3c5a8f40c4234c59f50", - "frag_pink_sheep": "682ef3c5a8f40c4234c59f51", - "suit_hamster_gray": "682ef3c5a8f40c4234c59f53", - "suit_cute_bear": "682ef3c5a8f40c4234c59f4b", - "bag_pink_sheep": "682ef3c5a8f40c4234c59f4f", - "helmet_pink_sheep": "682ef3c5a8f40c4234c59f4e", - "bag_hamster_gray": "682ef3c5a8f40c4234c59f55", - "helmet_hamster_gray": "682ef3c5a8f40c4234c59f54", - "suit_western_mystery": "682ef39ca8f40c4234c59f35", - "helmet_panda_treasure": "682ef39ca8f40c4234c59f39", - "suit_panda_round": "682ef39ca8f40c4234c59f3a", - "suit_panda_tuan": "682ef39ca8f40c4234c59f3b", - "pack_lava_ranger": "682ef39ca8f40c4234c59f3c", - "suit_sand_dancer": "682ef39ca8f40c4234c59f3d", - "pack_star_roam_outfit": "682ef39ca8f40c4234c59f3e", - "pack_star_roam_weapon": "682ef39ca8f40c4234c59f3f", - "suit_cloud_bear": "682ef39ca8f40c4234c59f40", - "honor_medal_x2": "682ef39ca8f40c4234c59f45", - "honor_medal_x30": "682ef39ca8f40c4234c59f43", - "honor_medal_x90": "682ef39ca8f40c4234c59f41", - "lucky_coin_x2": "682ef39ca8f40c4234c59f46", - "lucky_coin_x30": "682ef39ca8f40c4234c59f44", - "lucky_coin_x90": "682ef39ca8f40c4234c59f42", - }[sku] -} diff --git a/backend/internal/service/delivery_bind.go b/backend/internal/service/delivery_bind.go new file mode 100644 index 0000000..d786537 --- /dev/null +++ b/backend/internal/service/delivery_bind.go @@ -0,0 +1,207 @@ +package service + +import ( + "context" + "errors" + "strings" + + "affiliate_dash/internal/model" +) + +func (s *DeliveryService) Bind(orderNo, gameAccount string, auth DeliveryLinkAuth) (*DeliveryBindResult, error) { + return s.bindWithOrder(orderNo, gameAccount, &auth, 0) +} + +func (s *DeliveryService) BindForMerchant(merchantID uint, orderNo, gameAccount string) (*DeliveryBindResult, error) { + return s.bindWithOrder(orderNo, gameAccount, nil, merchantID) +} + +// GetBindResult 查询绑定结果(自建发货页轮询用)。 +// 绑定未完成时返回 bound=false(不报错),由前端提示继续扫码。 +func (s *DeliveryService) GetBindResult(merchantID uint, orderNo, bindUUID string) (*DeliveryBindResult, error) { + bindUUID = strings.TrimSpace(bindUUID) + if bindUUID == "" { + return nil, errors.New("bind_uuid 不能为空") + } + openOrder, order, goodID, err := s.loadMerchantOrder(merchantID, orderNo) + 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 +} + +// 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 == "" { + return nil, errors.New("请输入玩家编号") + } + var ( + openOrder *OpenOrderQuery + order *model.FulfillmentOrder + goodID string + err error + ) + if merchantID > 0 { + openOrder, order, goodID, err = s.loadMerchantOrder(merchantID, orderNo) + } else { + openOrder, order, goodID, err = s.loadLinkedOrder(orderNo, auth) + } + if err != nil { + return nil, err + } + _, _, _, err = s.buildDeliveryState(openOrder, order, goodID, true) + if err != nil { + return nil, err + } + + providers, _ := s.router.ResolveProviders(order) + if len(providers) == 0 { + return nil, errors.New("未配置发货渠道") + } + + var lastErr error + var bindRes *ProviderBindResult + var usedProvider FulfillmentProvider + tried := map[string]bool{} + for _, p := range providers { + tried[p.Name()] = true + res, err := p.Bind(context.Background(), order, gameAccount) + if err == nil && res != nil { + bindRes = res + usedProvider = p + break + } + lastErr = err + } + + // Prepare 可能因 GET 成功先锁住乐玩;随后写接口凭据/IP 校验失败时,清除锁定并尝试其余渠道(如 DLC)。 + if bindRes == nil { + lockedName := resultDataString(order.ResultData, "provider") + if lockedName != "" || lastErr != nil { + s.clearSelectedProvider(order) + fallbackProviders, _ := s.router.ResolveProviders(order) + for _, p := range fallbackProviders { + if tried[p.Name()] { + continue + } + tried[p.Name()] = true + res, err := p.Bind(context.Background(), order, gameAccount) + if err == nil && res != nil { + bindRes = res + usedProvider = p + break + } + lastErr = err + } + } + } + + if bindRes == nil { + if lastErr != nil { + return nil, lastErr + } + return nil, errors.New("发货渠道未就绪") + } + + // 记录选定渠道与上游单号(如 lewan_order_id),不提前写入 submit claimed stage + if usedProvider != nil { + patch := map[string]interface{}{ + "provider": usedProvider.Name(), + } + for k, v := range bindRes.PatchResultData { + patch[k] = v + } + _ = s.persistOrderResultPatch(order, patch) + } + + return &DeliveryBindResult{ + BindUUID: bindRes.BindUUID, + BindURL: bindRes.BindURL, + QRURL: bindRes.QRURL, + Bound: bindRes.Bound, + GameAccount: bindRes.GameAccount, + RoleName: bindRes.RoleName, + GameChannel: bindRes.GameChannel, + ExpectedGameAccount: bindRes.ExpectedGameAccount, + Mismatch: bindRes.Mismatch, + Message: bindRes.Message, + }, nil +} + +func (s *DeliveryService) bindGood(gameAccount, goodID string) (*DeliveryBindResult, error) { + dlc, ok := s.router.GetProvider(ProviderDLC).(*DLCProvider) + if !ok || dlc == nil { + return nil, errors.New("DLC 渠道未就绪") + } + res, err := dlc.Bind(context.Background(), &model.FulfillmentOrder{ + ProductSKU: "", + }, gameAccount) + if err != nil { + return nil, err + } + return &DeliveryBindResult{ + BindUUID: res.BindUUID, + BindURL: res.BindURL, + QRURL: res.QRURL, + Bound: res.Bound, + }, nil +} diff --git a/backend/internal/service/delivery_helpers.go b/backend/internal/service/delivery_helpers.go index b6716ca..4fbc2a9 100644 --- a/backend/internal/service/delivery_helpers.go +++ b/backend/internal/service/delivery_helpers.go @@ -75,3 +75,44 @@ func truncateDeliveryText(value string) string { } return value[:300] } + +func deliveryGoodID(channel, sku string) string { + if channel != "dlc" { + return "" + } + return map[string]string{ + "suit_alan_walker": "682ef3c5a8f40c4234c59f47", + "suit_shadow_gothic": "682ef39ca8f40c4234c59f36", + "top_black_elite_trainer": "682ef39ca8f40c4234c59f37", + "m416_hamster_gray": "682ef3c5a8f40c4234c59f52", + "bag_cute_bear": "682ef3c5a8f40c4234c59f4a", + "suit_dual_fluffy": "682ef3c5a8f40c4234c59f49", + "suit_pink_sheep": "682ef3c5a8f40c4234c59f4d", + "suit_first_peach": "682ef3c5a8f40c4234c59f48", + "suit_romantic_destiny": "682ef39ca8f40c4234c59f38", + "pack_western_cowboy": "682ef3c5a8f40c4234c59f4c", + "smoke_pink_sheep": "682ef3c5a8f40c4234c59f50", + "frag_pink_sheep": "682ef3c5a8f40c4234c59f51", + "suit_hamster_gray": "682ef3c5a8f40c4234c59f53", + "suit_cute_bear": "682ef3c5a8f40c4234c59f4b", + "bag_pink_sheep": "682ef3c5a8f40c4234c59f4f", + "helmet_pink_sheep": "682ef3c5a8f40c4234c59f4e", + "bag_hamster_gray": "682ef3c5a8f40c4234c59f55", + "helmet_hamster_gray": "682ef3c5a8f40c4234c59f54", + "suit_western_mystery": "682ef39ca8f40c4234c59f35", + "helmet_panda_treasure": "682ef39ca8f40c4234c59f39", + "suit_panda_round": "682ef39ca8f40c4234c59f3a", + "suit_panda_tuan": "682ef39ca8f40c4234c59f3b", + "pack_lava_ranger": "682ef39ca8f40c4234c59f3c", + "suit_sand_dancer": "682ef39ca8f40c4234c59f3d", + "pack_star_roam_outfit": "682ef39ca8f40c4234c59f3e", + "pack_star_roam_weapon": "682ef39ca8f40c4234c59f3f", + "suit_cloud_bear": "682ef39ca8f40c4234c59f40", + "honor_medal_x2": "682ef39ca8f40c4234c59f45", + "honor_medal_x30": "682ef39ca8f40c4234c59f43", + "honor_medal_x90": "682ef39ca8f40c4234c59f41", + "lucky_coin_x2": "682ef39ca8f40c4234c59f46", + "lucky_coin_x30": "682ef39ca8f40c4234c59f44", + "lucky_coin_x90": "682ef39ca8f40c4234c59f42", + }[sku] +} diff --git a/backend/internal/service/delivery_link.go b/backend/internal/service/delivery_link.go index 21e1d1f..71bf1ff 100644 --- a/backend/internal/service/delivery_link.go +++ b/backend/internal/service/delivery_link.go @@ -4,6 +4,7 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" + "errors" "fmt" "net/http" "net/url" @@ -12,6 +13,8 @@ import ( "time" "affiliate_dash/internal/model" + "affiliate_dash/internal/pkg/timeutil" + "gorm.io/gorm" ) func (s *DeliveryService) authorizeDeliveryLink(order *model.FulfillmentOrder, auth DeliveryLinkAuth) error { @@ -88,3 +91,128 @@ func (s *DeliveryService) signDeliveryLink(orderNo string, exp int64) string { _, _ = mac.Write([]byte(orderNo + "|" + strconv.FormatInt(exp, 10))) return hex.EncodeToString(mac.Sum(nil)) } + +func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) { + orderNo = strings.TrimSpace(orderNo) + if orderNo == "" { + return nil, errors.New("订单号不能为空") + } + order, err := s.fulfillment.GetOrder(merchantID, orderNo) + if err != nil { + return nil, err + } + // 已取消订单即使存在未过期链接也不再发放(买家侧授权同样拦截已取消订单)。 + if normalizeOrderStatus(order) == model.OrderStatusCancelled { + return nil, newDeliveryHTTPError(http.StatusBadRequest, "订单已取消,无法获取发货链接") + } + now := time.Now().UTC() + if order.DeliveryLinkRevokedAt != nil { + return nil, newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废") + } + // 复用未过期链接:已交付/发货中订单也允许查看兑换结果或进度,仅停止发放新链接。 + if order.DeliveryLinkExpiresAt != nil && order.DeliveryLinkExpiresAt.After(now) { + expiresAt := order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second) + return s.buildDeliveryLinkResult(order.OrderNo, &expiresAt, requestBaseURL), nil + } + // 永久有效链接(已生成、未作废、无有效期)直接复用,不重复生成。 + if order.DeliveryLinkGeneratedAt != nil && order.DeliveryLinkExpiresAt == nil { + return s.buildDeliveryLinkResult(order.OrderNo, nil, requestBaseURL), nil + } + // 仅待发货(含可重试的发货失败)订单能生成或续期新链接。 + canShip, reason := CanFulfill(order) + if !canShip { + if reason == "" { + reason = "订单暂不可发货" + } + return nil, newDeliveryHTTPError(http.StatusBadRequest, reason) + } + var expiresAt *time.Time + if s.linkTTL > 0 { + value := now.Add(s.linkTTL).UTC().Truncate(time.Second) + expiresAt = &value + } + if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}). + Where("id = ?", order.ID). + Updates(map[string]interface{}{ + "delivery_link_expires_at": expiresAt, + "delivery_link_revoked_at": nil, + "delivery_link_generated_at": now, + }).Error; err != nil { + return nil, err + } + return s.buildDeliveryLinkResult(order.OrderNo, expiresAt, requestBaseURL), nil +} + +func (s *DeliveryService) RevokeDeliveryLink(merchantID, actorUserID uint, orderNo string) error { + orderNo = strings.TrimSpace(orderNo) + if orderNo == "" { + return errors.New("订单号不能为空") + } + order, err := s.fulfillment.GetOrder(merchantID, orderNo) + if err != nil { + return err + } + now := time.Now().UTC().Truncate(time.Second) + return s.fulfillment.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.FulfillmentOrder{}). + Where("id = ?", order.ID). + Updates(map[string]interface{}{ + "delivery_link_revoked_at": now, + }).Error; err != nil { + return err + } + return writeAudit(tx, &merchantID, optionalUint(actorUserID), nil, "delivery_link.revoke", "fulfillment_order", order.OrderNo, map[string]interface{}{ + "revoked_at": timeutil.FormatAPITime(now), + }) + }) +} + +func (s *DeliveryService) RestoreDeliveryLink(merchantID, actorUserID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) { + orderNo = strings.TrimSpace(orderNo) + if orderNo == "" { + return nil, errors.New("订单号不能为空") + } + order, err := s.fulfillment.GetOrder(merchantID, orderNo) + if err != nil { + return nil, err + } + canShip, reason := CanFulfill(order) + if !canShip { + if reason == "" { + reason = "订单暂不可发货" + } + return nil, newDeliveryHTTPError(http.StatusBadRequest, reason) + } + now := time.Now().UTC() + var expiresAt *time.Time + if s.linkTTL > 0 { + value := now.Add(s.linkTTL).Truncate(time.Second) + if order.DeliveryLinkExpiresAt != nil && value.Equal(order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)) { + value = value.Add(time.Second) + } + expiresAt = &value + } + if err := s.fulfillment.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.FulfillmentOrder{}). + Where("id = ?", order.ID). + Updates(map[string]interface{}{ + "delivery_link_expires_at": expiresAt, + "delivery_link_revoked_at": nil, + "delivery_link_generated_at": now, + }).Error; err != nil { + return err + } + metadata := map[string]interface{}{ + "generated_at": timeutil.FormatAPITime(now), + } + if expiresAt != nil { + metadata["expires_at"] = timeutil.FormatAPITime(*expiresAt) + } else { + metadata["permanent"] = true + } + return writeAudit(tx, &merchantID, optionalUint(actorUserID), nil, "delivery_link.restore", "fulfillment_order", order.OrderNo, metadata) + }); err != nil { + return nil, err + } + return s.buildDeliveryLinkResult(order.OrderNo, expiresAt, requestBaseURL), nil +} diff --git a/backend/internal/service/delivery_provider.go b/backend/internal/service/delivery_provider.go new file mode 100644 index 0000000..083a712 --- /dev/null +++ b/backend/internal/service/delivery_provider.go @@ -0,0 +1,135 @@ +package service + +import ( + "context" + "encoding/json" + "strings" + + "affiliate_dash/internal/model" +) + +func (s *DeliveryService) CancelUpstream(order *model.FulfillmentOrder) error { + if order == nil || s.router == nil { + return nil + } + if name := resultDataString(order.ResultData, "provider"); name != "" { + if p := s.router.GetProvider(name); p != nil { + return p.Cancel(context.Background(), order) + } + } + if resultDataString(order.ResultData, "lewan_order_id") != "" { + if p := s.router.GetProvider(ProviderLewan); p != nil { + return p.Cancel(context.Background(), order) + } + } + return nil +} + +// SyncDeliveringOrder 主动向上游渠道查询处于 delivering 状态的订单结果,以补充 Webhook 丢失或延迟。 +func (s *DeliveryService) SyncDeliveringOrder(ctx context.Context, order *model.FulfillmentOrder) (bool, error) { + if order == nil || s.router == nil || order.OrderStatus != model.OrderStatusDelivering { + return false, nil + } + if ctx == nil { + ctx = context.Background() + } + + providerName := resultDataString(order.ResultData, "provider") + if providerName == "" && (resultDataString(order.ResultData, "lewan_order_id") != "" || resultDataString(order.ResultData, "lewan_order_no") != "" || strings.HasPrefix(order.ProviderOrderNo, "OMU")) { + providerName = ProviderLewan + } + + providers := make([]FulfillmentProvider, 0, 2) + if providerName != "" { + if p := s.router.GetProvider(providerName); p != nil { + providers = append(providers, p) + } + } + if len(providers) == 0 { + resolved, _ := s.router.ResolveProviders(order) + providers = resolved + } + + for _, p := range providers { + res, err := p.SyncStatus(ctx, order) + if err != nil || res == nil { + continue + } + if res.Status == "success" || res.Status == "failed" { + input := ShipNotifyInput{ + OrderNo: order.OrderNo, + ShipStatus: res.Status, + ProviderOrderNo: res.ProviderOrderNo, + FailReason: res.FailReason, + RawPayload: res.RawPayload, + } + if res.GameUID != "" { + input.GameUID = &res.GameUID + } + if res.RoleName != "" { + input.RoleName = &res.RoleName + } + if res.RoleArea != "" { + input.GameChannel = &res.RoleArea + } + if _, err := s.fulfillment.HandleShipNotify(input); err != nil { + return false, err + } + return true, nil + } + // 如果仍在发货中,且获得了游戏UID/角色名/区服,补充写入 ResultData + patch := map[string]interface{}{} + if res.GameUID != "" && resultDataString(order.ResultData, "game_uid") == "" { + patch["game_uid"] = res.GameUID + } + if res.RoleName != "" && resultDataString(order.ResultData, "role_name") == "" { + patch["role_name"] = res.RoleName + } + if res.RoleArea != "" && resultDataString(order.ResultData, "game_channel") == "" { + patch["game_channel"] = res.RoleArea + } + if len(patch) > 0 { + _ = s.persistOrderResultPatch(order, patch) + } + return false, nil + } + return false, nil +} + +// persistOrderResultPatch 合并写入订单 result_data,不改变发货 stage。 +func (s *DeliveryService) persistOrderResultPatch(order *model.FulfillmentOrder, patch map[string]interface{}) error { + if order == nil || len(patch) == 0 { + return nil + } + resultData := mergeResultData(order.ResultData, patch) + if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}). + Where("id = ?", order.ID). + Update("result_data", resultData).Error; err != nil { + return err + } + order.ResultData = resultData + return nil +} + +func (s *DeliveryService) clearSelectedProvider(order *model.FulfillmentOrder) { + if order == nil || strings.TrimSpace(order.ResultData) == "" { + return + } + var raw map[string]interface{} + if err := json.Unmarshal([]byte(order.ResultData), &raw); err != nil { + return + } + if _, ok := raw["provider"]; !ok { + return + } + delete(raw, "provider") + b, err := json.Marshal(raw) + if err != nil { + return + } + resultData := string(b) + _ = s.fulfillment.db.Model(&model.FulfillmentOrder{}). + Where("id = ?", order.ID). + Update("result_data", resultData).Error + order.ResultData = resultData +} diff --git a/backend/internal/service/delivery_query.go b/backend/internal/service/delivery_query.go new file mode 100644 index 0000000..f9a9d87 --- /dev/null +++ b/backend/internal/service/delivery_query.go @@ -0,0 +1,247 @@ +package service + +import ( + "context" + "errors" + "net/http" + "strings" + + "affiliate_dash/internal/model" +) + +func (s *DeliveryService) GetOrder(orderNo string, auth DeliveryLinkAuth) (*DeliveryOrderInfo, error) { + info, _, _, err := s.prepareOrder(orderNo, false, &auth) + return info, err +} + +func (s *DeliveryService) GetMerchantOrder(merchantID uint, orderNo string) (*DeliveryOrderInfo, error) { + openOrder, order, goodID, err := s.loadMerchantOrder(merchantID, orderNo) + if err != nil { + return nil, err + } + info, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false) + return info, err +} + +func (s *DeliveryService) loadLinkedOrder(orderNo string, auth *DeliveryLinkAuth) (*OpenOrderQuery, *model.FulfillmentOrder, string, error) { + openOrder, err := s.fulfillment.QueryOpenOrder(orderNo) + if err != nil { + return nil, nil, "", err + } + order, err := s.fulfillment.GetByOrderNo(orderNo) + if err != nil { + return nil, nil, "", err + } + if auth != nil { + if err := s.authorizeDeliveryLink(order, *auth); err != nil { + return nil, nil, "", err + } + } + goodID := "" + if openOrder.Product != nil { + goodID = deliveryGoodID(s.channel, openOrder.Product.SKU) + } + return openOrder, order, goodID, nil +} + +func (s *DeliveryService) loadMerchantOrder(merchantID uint, orderNo string) (*OpenOrderQuery, *model.FulfillmentOrder, string, error) { + if merchantID == 0 { + return nil, nil, "", errors.New("无效的商户") + } + openOrder, err := s.fulfillment.QueryOpenOrder(orderNo) + if err != nil { + return nil, nil, "", err + } + order, err := s.fulfillment.GetOrder(merchantID, orderNo) + if err != nil { + return nil, nil, "", err + } + if order.OrderStatus == model.OrderStatusDelivering { + if synced, _ := s.SyncDeliveringOrder(context.Background(), order); synced { + openOrder, _ = s.fulfillment.QueryOpenOrder(orderNo) + order, _ = s.fulfillment.GetOrder(merchantID, orderNo) + } + } + goodID := "" + if openOrder != nil && openOrder.Product != nil { + goodID = deliveryGoodID(s.channel, openOrder.Product.SKU) + } + return openOrder, order, goodID, nil +} + +func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *model.FulfillmentOrder, goodID string, requireCanShip bool) (*DeliveryOrderInfo, *model.FulfillmentOrder, string, error) { + if openOrder == nil || order == nil { + return nil, nil, "", errors.New("订单不存在") + } + canShip, reason := CanFulfill(order) + if !openOrder.CanShip { + reason = openOrder.CannotShipReason + canShip = false + } + if !canShip && reason == "" { + reason = "订单暂不可发货" + } + if openOrder.Product == nil || openOrder.Product.SKU == "" { + canShip = false + reason = "订单缺少商品 SKU" + } + + var ( + deliveryProd *DeliveryProduct + goodData map[string]interface{} + ) + + providers, _ := s.router.ResolveProviders(order) + if len(providers) > 0 { + locked := resultDataString(order.ResultData, "provider") != "" + matchedState, lastReason := s.tryPrepareProviders(order, openOrder, providers) + // 已锁定渠道不可发时,清除锁定并按完整优先级重新选择 + if matchedState == nil && locked { + s.clearSelectedProvider(order) + providers, _ = s.router.ResolveProviders(order) + matchedState, lastReason = s.tryPrepareProviders(order, openOrder, providers) + } + if matchedState != nil { + deliveryProd = matchedState.Product + goodData = matchedState.Good + if matchedState.Provider != "" { + _ = s.persistOrderResultPatch(order, map[string]interface{}{ + "provider": matchedState.Provider, + }) + } + } else { + canShip = false + if reason == "" || reason == "订单暂不可发货" { + reason = lastReason + if reason == "" { + reason = "未找到适用的履约渠道配置" + } + } + } + } else if goodID != "" { + goodData, _ = s.goodsDetail(goodID) + } + + if deliveryProd == nil && openOrder.Product != nil { + deliveryProd = buildDeliveryProduct(openOrder.Product, goodData) + } + + if requireCanShip && !canShip { + return nil, nil, "", newDeliveryHTTPError(http.StatusBadRequest, reason) + } + + providerOrderNo := strings.TrimSpace(openOrder.ProviderOrderNo) + if providerOrderNo == "" { + providerOrderNo = strings.TrimSpace(order.ProviderOrderNo) + } + if providerOrderNo == "" { + providerOrderNo = resultDataString(order.ResultData, "lewan_order_no") + } + if providerOrderNo == "" { + providerOrderNo = resultDataString(order.ResultData, "provider_order_no") + } + + providerName := resultDataString(order.ResultData, "provider") + if providerName == "" { + if strings.TrimSpace(order.ProviderOrderNo) != "" || resultDataString(order.ResultData, "lewan_order_id") != "" { + providerName = "lewan" + } + } + + roleName := openOrder.RoleName + if roleName == "" { + roleName = resultDataString(order.ResultData, "role_name") + } + gameChannel := openOrder.GameChannel + if gameChannel == "" { + gameChannel = resultDataString(order.ResultData, "game_channel") + } + // 若角色名或区服为空,尝试从关联的 rock_tokens 表反查并自动回填 + if (roleName == "" || gameChannel == "") && s.fulfillment != nil && s.fulfillment.db != nil { + var rockToken model.RockToken + if err := s.fulfillment.db.Where("assigned_order_no = ?", order.OrderNo).First(&rockToken).Error; err == nil { + patches := map[string]interface{}{} + if roleName == "" && rockToken.ActualRoleName != "" { + roleName = rockToken.ActualRoleName + patches["role_name"] = roleName + } + if gameChannel == "" && rockToken.ActualChannel != "" { + gameChannel = rockToken.ActualChannel + patches["game_channel"] = gameChannel + } + if len(patches) > 0 { + _ = s.persistOrderResultPatch(order, patches) + } + } + } + + info := &DeliveryOrderInfo{ + OrderNo: openOrder.OrderNo, + Status: openOrder.Status, + CanShip: canShip, + CannotShipReason: reason, + Product: deliveryProd, + BuyerName: openOrder.BuyerName, + Amount: openOrder.Amount, + ProviderOrderNo: providerOrderNo, + ProviderName: providerName, + CreatedAt: openOrder.CreatedAt, + ShippedAt: openOrder.ShippedAt, + ShipFailReason: openOrder.ShipFailReason, + GameChannel: gameChannel, + GameUID: openOrder.GameUID, + RoleName: roleName, + PayScore: openOrder.PayScore, + Data: requestDataMap(order.RequestData), + Good: goodData, + } + return info, order, goodID, nil +} + +// tryPrepareProviders 按优先级尝试 Prepare,返回第一个 CanShip 的渠道状态。 +func (s *DeliveryService) tryPrepareProviders(order *model.FulfillmentOrder, openOrder *OpenOrderQuery, providers []FulfillmentProvider) (*DeliveryOrderState, string) { + lastReason := "" + for _, p := range providers { + state, err := p.PrepareDelivery(context.Background(), order, openOrder) + if err != nil || state == nil { + continue + } + if state.Provider == "" { + state.Provider = p.Name() + } + if state.CanShip { + return state, "" + } + if state.CannotShipReason != "" { + lastReason = state.CannotShipReason + } + } + return nil, lastReason +} + +func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool, auth *DeliveryLinkAuth) (*DeliveryOrderInfo, *model.FulfillmentOrder, string, error) { + orderNo = strings.TrimSpace(orderNo) + if orderNo == "" { + return nil, nil, "", errors.New("订单号不能为空") + } + openOrder, err := s.fulfillment.QueryOpenOrder(orderNo) + if err != nil { + return nil, nil, "", err + } + order, err := s.fulfillment.GetByOrderNo(orderNo) + if err != nil { + return nil, nil, "", err + } + if auth != nil { + if err := s.authorizeDeliveryLink(order, *auth); err != nil { + return nil, nil, "", err + } + } + if order.OrderStatus == model.OrderStatusDelivering { + if synced, _ := s.SyncDeliveringOrder(context.Background(), order); synced { + openOrder, _ = s.fulfillment.QueryOpenOrder(orderNo) + order, _ = s.fulfillment.GetByOrderNo(orderNo) + } + } + return s.buildDeliveryState(openOrder, order, "", requireCanShip) +} diff --git a/backend/internal/service/delivery_submit.go b/backend/internal/service/delivery_submit.go new file mode 100644 index 0000000..1b137a3 --- /dev/null +++ b/backend/internal/service/delivery_submit.go @@ -0,0 +1,248 @@ +package service + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "affiliate_dash/internal/model" + "affiliate_dash/internal/pkg/timeutil" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func (s *DeliveryService) Submit(orderNo, gameAccount, bindUUID string, auth DeliveryLinkAuth) (*DeliverySubmitResult, error) { + return s.submit(orderNo, gameAccount, bindUUID, 0, &auth, 0) +} + +func (s *DeliveryService) SubmitForMerchant(merchantID, apiClientID uint, orderNo, gameAccount, bindUUID string) (*DeliverySubmitResult, error) { + return s.submit(orderNo, gameAccount, bindUUID, apiClientID, nil, merchantID) +} + +func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClientID uint, auth *DeliveryLinkAuth, merchantID uint) (*DeliverySubmitResult, error) { + gameAccount = strings.TrimSpace(gameAccount) + bindUUID = strings.TrimSpace(bindUUID) + orderNo = strings.TrimSpace(orderNo) + if gameAccount == "" || bindUUID == "" { + return nil, errors.New("玩家编号和绑定凭证不能为空") + } + var ( + openOrder *OpenOrderQuery + order *model.FulfillmentOrder + goodID string + err error + ) + if merchantID > 0 { + openOrder, order, goodID, err = s.loadMerchantOrder(merchantID, orderNo) + } else { + openOrder, order, goodID, err = s.loadLinkedOrder(orderNo, auth) + } + if err != nil { + return nil, err + } + info, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false) + if err != nil { + return nil, err + } + orderStatus := normalizeOrderStatus(order) + if orderStatus == model.OrderStatusDelivering || + orderStatus == model.OrderStatusDelivered || + strings.TrimSpace(order.ProviderOrderNo) != "" || + strings.Contains(order.ResultData, "\"queue_order_id\"") || + strings.Contains(order.ResultData, "\"lewan_order_id\"") { + if existing := buildExistingDeliverySubmitResult(order); existing != nil { + return existing, nil + } + } + // 已在上游创建正式订单的失败订单禁止自动重发,避免重复发货。 + if orderStatus == model.OrderStatusShipFailed && deliverySubmittedUpstream(order) { + return nil, newDeliveryHTTPError(http.StatusConflict, "该订单已进入发货流程,为避免重复发货不能直接重试,请稍后查询订单状态或联系平台处理") + } + if !info.CanShip { + reason := info.CannotShipReason + if reason == "" { + reason = "订单暂不可发货" + } + return nil, newDeliveryHTTPError(http.StatusBadRequest, reason) + } + claimed, claimedNow, err := s.claimDeliverySubmission(order.MerchantID, apiClientID, order.OrderNo) + if err != nil { + return nil, err + } + if !claimedNow { + if existing := buildExistingDeliverySubmitResult(claimed); existing != nil { + return existing, nil + } + return nil, newDeliveryHTTPError(http.StatusConflict, "订单暂时正在发货中,请稍后查询") + } + + providers, _ := s.router.ResolveProviders(claimed) + if len(providers) == 0 { + s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageCreateOrder, "未配置发货渠道") + return nil, errors.New("未配置发货渠道") + } + activeProvider := providers[0] + + submitRes, err := activeProvider.Submit(context.Background(), claimed, gameAccount, bindUUID) + if submitRes != nil && len(submitRes.PatchResultData) > 0 { + stage := deliveryStageCreateOrder + if v, ok := submitRes.PatchResultData["provider_order_stage"].(string); ok && v != "" { + stage = v + } + _ = s.recordDeliverySubmissionStage(claimed, stage, submitRes.PatchResultData) + } + if err != nil { + failStage := deliveryStageCreateOrder + if submitRes != nil { + if v, ok := submitRes.PatchResultData["provider_order_stage"].(string); ok && v != "" { + failStage = v + } + } + s.markDeliverySubmissionFailed(claimed, apiClientID, failStage, err.Error()) + return nil, err + } + + resultData := mergeResultData(claimed.ResultData, submitRes.PatchResultData) + updated, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{ + MerchantID: order.MerchantID, + APIClientID: apiClientID, + OrderNo: order.OrderNo, + Status: submitRes.NextStatus, + ProviderOrderNo: submitRes.ProviderOrderNo, + ResultData: resultData, + }) + if err != nil { + // 上游已有身份时先固化 result_data,避免重试双发 + _ = s.recordDeliverySubmissionStage(claimed, deliveryStageSubmitted, submitRes.PatchResultData) + return nil, err + } + return &DeliverySubmitResult{ + OrderNo: updated.OrderNo, + Status: normalizeOrderStatus(updated), + Message: submitRes.Message, + ProviderOrderNo: submitRes.ProviderOrderNo, + GameAccount: submitRes.GameAccount, + UpstreamOrder: submitRes.UpstreamOrder, + }, nil +} + +func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint, orderNo string) (*model.FulfillmentOrder, bool, error) { + var updated model.FulfillmentOrder + claimedNow := false + err := s.fulfillment.db.Transaction(func(tx *gorm.DB) error { + var order model.FulfillmentOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Preload("MerchantProduct"). + Where("merchant_id = ? AND order_no = ?", merchantID, orderNo). + First(&order).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("订单不存在") + } + return err + } + orderStatus := normalizeOrderStatus(&order) + if orderStatus == model.OrderStatusDelivering || orderStatus == model.OrderStatusDelivered { + updated = order + return nil + } + if err := validateOrderStatusTransition(&order, model.OrderStatusDelivering, fulfillmentTransitionUpdate); err != nil { + return newDeliveryHTTPError(http.StatusConflict, "订单暂不可发货") + } + now := time.Now() + stage := resultDataString(order.ResultData, "provider_order_stage") + startedAtRaw := resultDataString(order.ResultData, "submit_started_at") + + // 1. 如果已有协程正在活跃提交中(在 35 秒执行窗口内且处于 claimed/queue_created 阶段), + // 即使已被外部 Webhook 临时推回 ship_failed,也绝对不允许并发重入发货打架 + if deliverySubmissionInProgress(stage) && startedAtRaw != "" { + if startedAt, parseErr := time.Parse(timeutil.APITimeLayout, startedAtRaw); parseErr == nil { + if now.Sub(startedAt) < 35*time.Second { + updated = order + return nil + } + } + } + + // 2. 正常并发防双发:仅对 paid 等非最终失败态订单检查 10 分钟未超时 + if orderStatus != model.OrderStatusShipFailed && + deliverySubmissionInProgress(stage) && + !deliverySubmissionStale(&order, now) { + updated = order + return nil + } + patch := map[string]interface{}{ + "source": "delivery_proxy", + "submit_started_at": timeutil.FormatAPITime(now), + "provider_order_stage": deliveryStageClaimed, + "ship_attempts": resultDataNumber(order.ResultData, "ship_attempts") + 1, + } + if err := tx.Model(&order).Updates(map[string]interface{}{ + "result_data": mergeResultData(order.ResultData, patch), + "failure_reason": "", + }).Error; err != nil { + return err + } + claimedNow = true + if err := tx.Preload("MerchantProduct").First(&updated, order.ID).Error; err != nil { + return err + } + apiClientIDPtr := optionalUint(apiClientID) + if err := writeAudit(tx, &merchantID, nil, apiClientIDPtr, "delivery.submit.claim", "fulfillment_order", order.OrderNo, nil); err != nil { + return err + } + return nil + }) + if err != nil { + return nil, false, err + } + return &updated, claimedNow, nil +} + +func (s *DeliveryService) recordDeliverySubmissionStage(order *model.FulfillmentOrder, stage string, patch map[string]interface{}) error { + if order == nil { + return errors.New("订单不存在") + } + if patch == nil { + patch = map[string]interface{}{} + } + patch["provider_order_stage"] = stage + resultData := mergeResultData(order.ResultData, patch) + if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}). + Where("id = ?", order.ID). + Update("result_data", resultData).Error; err != nil { + return err + } + order.ResultData = resultData + return nil +} + +func (s *DeliveryService) markDeliverySubmissionFailed(order *model.FulfillmentOrder, apiClientID uint, stage, reason string) { + if order == nil { + return + } + latest := order + if fresh, err := s.fulfillment.GetOrder(order.MerchantID, order.OrderNo); err == nil { + latest = fresh + } + // 确保失败时 stage 绝不能继续保持在 in_progress(claimed / queue_created),否则阻碍重试 + releaseStage := stage + if deliverySubmissionInProgress(releaseStage) { + releaseStage = deliveryStageCreateOrder + } + resultData := mergeResultData(latest.ResultData, map[string]interface{}{ + "provider_order_stage": releaseStage, + "fail_stage": stage, + "fail_reason": reason, + "fail_at": timeutil.FormatAPITime(time.Now()), + }) + _, _ = s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{ + MerchantID: order.MerchantID, + APIClientID: apiClientID, + OrderNo: order.OrderNo, + Status: model.OrderStatusShipFailed, + FailureReason: reason, + ResultData: resultData, + }) +} diff --git a/backend/internal/service/delivery_types.go b/backend/internal/service/delivery_types.go new file mode 100644 index 0000000..1e6bfcb --- /dev/null +++ b/backend/internal/service/delivery_types.go @@ -0,0 +1,85 @@ +package service + +import ( + "time" +) + +type DeliveryOrderInfo struct { + OrderNo string `json:"order_no"` + Status string `json:"status"` + CanShip bool `json:"can_ship"` + CannotShipReason string `json:"cannot_ship_reason,omitempty"` + Product *DeliveryProduct `json:"product,omitempty"` + BuyerName string `json:"buyer_name"` + Amount int64 `json:"amount"` + ProviderOrderNo string `json:"provider_order_no,omitempty"` + ProviderName string `json:"provider_name,omitempty"` + CreatedAt time.Time `json:"created_at"` + ShippedAt *time.Time `json:"shipped_at"` + ShipFailReason string `json:"ship_fail_reason,omitempty"` + GameChannel string `json:"game_channel,omitempty"` + GameUID string `json:"game_uid,omitempty"` + RoleName string `json:"role_name,omitempty"` + PayScore int `json:"pay_score,omitempty"` + Data map[string]interface{} `json:"data,omitempty"` + Good map[string]interface{} `json:"good,omitempty"` +} + +type DeliveryProduct struct { + Name string `json:"name"` + SKU string `json:"sku"` + Game string `json:"game"` + Image string `json:"image,omitempty"` +} + +type DeliveryBindResult struct { + BindUUID string `json:"bind_uuid"` + BindURL string `json:"bind_url"` + QRURL string `json:"qr_url"` + Bound bool `json:"bound"` + GameAccount string `json:"game_account,omitempty"` + RoleName string `json:"role_name,omitempty"` + GameChannel string `json:"game_channel,omitempty"` + ExpectedGameAccount string `json:"expected_game_account,omitempty"` + Mismatch bool `json:"mismatch,omitempty"` + Message string `json:"message,omitempty"` +} + +type DeliveryLinkResult struct { + OrderNo string `json:"order_no"` + DeliveryURL string `json:"delivery_url"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` // null 表示永久有效 + Exp int64 `json:"exp"` + Sign string `json:"sign"` +} + +type DeliverySubmitResult struct { + OrderNo string `json:"order_no"` + Status string `json:"status"` + Message string `json:"message"` + ProviderOrderNo string `json:"provider_order_no,omitempty"` + GameAccount map[string]interface{} `json:"game_account,omitempty"` + UpstreamOrder interface{} `json:"delivery,omitempty"` +} + +type DeliveryLinkAuth struct { + Exp int64 + Sign string +} + +type deliveryHTTPError struct { + status int + message string +} + +func (e deliveryHTTPError) Error() string { + return e.message +} + +func (e deliveryHTTPError) HTTPStatus() int { + return e.status +} + +func newDeliveryHTTPError(status int, message string) error { + return deliveryHTTPError{status: status, message: message} +}