Files
affiliate_dash/backend/internal/service/provider.go
T
zhaoxing f0dea6a583 feat: Rock 兑换支持上游异步 pending 并记录发货全链路报文
问题:上游 0666.pro 兑换返回 HTTP 200 + status=pending(官方处理中),
旧逻辑仅凭「没有 error 字段」就判定成功并写 redeemed 终态,导致本地显示
已发货而上游仍是 bound,且 redeemed 为硬终态无法自愈。

后端:
- RedeemSkin 改为三态判定(success / pending / failed),不再无 error 即成功
- 新增 redeeming「发货处理中」状态;pending 不写 redeemed_at/fulfill_id
- 成功落库前二次 refresh 确认上游 redeemed 或 redeemed_at 才置终态
- 新增确认 worker:20s 轮询、30s→5m 指数退避、110001 全局冷却 60s、
  15 分钟未确认仅告警不回退,确认后同步订单为 delivered 并触发回调
- pending 期间禁止重复兑换,订单保持 delivering,不下发发货成功回调
- 新增 rock_delivery_logs 表与记录器:入站参数、内部状态、上游请求/响应
  原文全部留档,保留 180 天
- 分配/绑定校验/刷新/导入/删除/回收站/统计/客服查询全部适配 redeeming
- 迁移 026:redeeming 跟踪字段、部分索引与报文日志表

前端:
- Token 列表新增「发货处理中」筛选项、标签与超时提示
- 删除/回收站纳入 redeeming 危险状态
- 历史抽屉拆为「状态历史 / 发货报文」双页签,按 trace 展示报文详情
- 买家发货页在 pending 时提示官方处理中,无需重复提交

测试:新增 pending 不落终态、异常响应不误判、重复提交不重复兑换、
确认后同步订单、保持 pending 并退避 5 个用例;go build/vet/test 与
前端构建均通过。
2026-11-14 12:09:16 +08:00

245 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"encoding/json"
"net/url"
"strings"
"affiliate_dash/internal/model"
)
const (
ProviderDLC = "dlc"
ProviderLewan = "lewan"
ProviderRock = "rock"
RoutingModePriority = "priority"
RoutingModeLewan = "lewan"
RoutingModeDLC = "dlc"
RoutingModeRock = "rock"
)
// ProductRoutingConfig 商品履约配置
type ProductRoutingConfig struct {
RoutingMode string `json:"routing_mode"` // priority | lewan | dlc
Priority []string `json:"priority"` // ["lewan", "dlc"] 或 ["dlc", "lewan"]
DLCGoodID string `json:"dlc_good_id"` // 第一平台 GoodID
LewanProductID string `json:"lewan_product_id"` // 乐玩商品 UUID
LewanName string `json:"lewan_name,omitempty"`
DeliveryType string `json:"delivery_type,omitempty"` // LINK_BINDING / EMAIL_BINDING
}
// ParseProductRoutingConfig 解析商品履约配置,若未配置则根据 SKU 默认走 DLC 兜底。
func ParseProductRoutingConfig(fulfillmentConfig string, sku string) ProductRoutingConfig {
var cfg ProductRoutingConfig
if strings.TrimSpace(fulfillmentConfig) != "" {
_ = json.Unmarshal([]byte(fulfillmentConfig), &cfg)
}
// 兼容老配置:只抽取字段,不提前固化 routing_mode,
// 避免仅有 lewan_product_id 时在补齐内置 DLC 前被锁成 lewan-only。
legacyProvider := ""
if cfg.RoutingMode == "" {
var legacy struct {
Provider string `json:"provider"`
LewanProductID string `json:"lewan_product_id"`
DLCGoodID string `json:"dlc_good_id"`
}
if strings.TrimSpace(fulfillmentConfig) != "" {
_ = json.Unmarshal([]byte(fulfillmentConfig), &legacy)
}
legacyProvider = strings.TrimSpace(legacy.Provider)
if cfg.LewanProductID == "" && legacy.LewanProductID != "" {
cfg.LewanProductID = legacy.LewanProductID
}
if cfg.DLCGoodID == "" && legacy.DLCGoodID != "" {
cfg.DLCGoodID = legacy.DLCGoodID
}
}
// 默认兜底:若第一平台有内置 GoodID,补齐 DLCGoodID
builtinDLC := deliveryGoodID("dlc", sku)
if cfg.DLCGoodID == "" && builtinDLC != "" {
cfg.DLCGoodID = builtinDLC
}
// 若未配置 RoutingMode:优先判断是否为 Rock 渠道专属商品
if cfg.RoutingMode == "" {
if strings.HasPrefix(sku, "rock_") {
cfg.RoutingMode = RoutingModeRock
cfg.Priority = []string{ProviderRock}
} else if cfg.LewanProductID != "" && cfg.DLCGoodID != "" {
cfg.RoutingMode = RoutingModePriority
cfg.Priority = []string{ProviderLewan, ProviderDLC}
} else if cfg.LewanProductID != "" || legacyProvider == ProviderLewan {
cfg.RoutingMode = RoutingModeLewan
} else if legacyProvider == ProviderDLC {
cfg.RoutingMode = RoutingModeDLC
} else {
cfg.RoutingMode = RoutingModeDLC
}
}
if len(cfg.Priority) == 0 && cfg.RoutingMode == RoutingModePriority {
cfg.Priority = []string{ProviderLewan, ProviderDLC}
}
return cfg
}
// DeliveryOrderState 抽象商品履约状态
type DeliveryOrderState struct {
CanShip bool
CannotShipReason string
Product *DeliveryProduct
Good map[string]interface{}
Provider string
}
// ProviderBindResult 抽象绑定返回
type ProviderBindResult 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"`
Provider string `json:"provider,omitempty"`
// PatchResultData 绑定成功后写入订单 result_data(如 lewan_order_id)
PatchResultData map[string]interface{} `json:"-"`
}
// ProviderSubmitResult 抽象提交返回
type ProviderSubmitResult struct {
ProviderOrderNo string `json:"provider_order_no"`
NextStatus string `json:"next_status"` // delivering / delivered
Pending bool `json:"pending,omitempty"`
Message string `json:"message"`
GameAccount map[string]interface{} `json:"game_account,omitempty"`
UpstreamOrder interface{} `json:"delivery,omitempty"`
PatchResultData map[string]interface{} `json:"patch_result_data,omitempty"`
}
// ProviderSyncResult 抽象主动查单同步状态返回
type ProviderSyncResult struct {
Status string // success / failed / delivering / ""
ProviderOrderNo string
FailReason string
GameUID string
RoleName string
RoleArea string
RawPayload string
}
// FulfillmentProvider 统一履约提供商接口
type FulfillmentProvider interface {
Name() string
PrepareDelivery(ctx context.Context, order *model.FulfillmentOrder, openOrder *OpenOrderQuery) (*DeliveryOrderState, error)
Bind(ctx context.Context, order *model.FulfillmentOrder, gameAccount string) (*ProviderBindResult, error)
CheckBind(ctx context.Context, order *model.FulfillmentOrder, bindUUID string) (*ProviderBindResult, error)
Submit(ctx context.Context, order *model.FulfillmentOrder, gameAccount, bindUUID string) (*ProviderSubmitResult, error)
SyncStatus(ctx context.Context, order *model.FulfillmentOrder) (*ProviderSyncResult, error)
Cancel(ctx context.Context, order *model.FulfillmentOrder) error
}
// FulfillmentRouter 履约路由器
type FulfillmentRouter struct {
providers map[string]FulfillmentProvider
}
func NewFulfillmentRouter(providers ...FulfillmentProvider) *FulfillmentRouter {
m := make(map[string]FulfillmentProvider)
for _, p := range providers {
if p != nil {
m[p.Name()] = p
}
}
return &FulfillmentRouter{providers: m}
}
func (r *FulfillmentRouter) GetProvider(name string) FulfillmentProvider {
return r.providers[name]
}
// ResolveProviders 返回订单可选的履约渠道列表(按优先级排序)
func (r *FulfillmentRouter) ResolveProviders(order *model.FulfillmentOrder) ([]FulfillmentProvider, ProductRoutingConfig) {
sku := ""
configStr := ""
if order != nil {
sku = order.ProductSKU
if order.MerchantProduct != nil {
configStr = order.MerchantProduct.FulfillmentConfig
}
}
cfg := ParseProductRoutingConfig(configStr, sku)
// 如果订单已有选定渠道记录(例如已经对乐玩或 DLC 发起过 bind/submit),则锁定该渠道
if order != nil && strings.TrimSpace(order.ResultData) != "" {
if active := resultDataString(order.ResultData, "provider"); active != "" {
if p, ok := r.providers[active]; ok {
return []FulfillmentProvider{p}, cfg
}
}
}
var list []FulfillmentProvider
switch cfg.RoutingMode {
case RoutingModeLewan:
if p, ok := r.providers[ProviderLewan]; ok {
list = append(list, p)
}
// 显式 lewan 但商品同时具备 DLC 配置时,乐玩不可用(密钥未配/上游失败)允许回退 DLC,
// 避免「套装-浪漫天命」等双渠道商品在 LEWAN_* 未注入时提交 UID 直接失败。
if cfg.DLCGoodID != "" {
if p, ok := r.providers[ProviderDLC]; ok {
list = append(list, p)
}
}
case RoutingModeDLC:
if p, ok := r.providers[ProviderDLC]; ok {
list = append(list, p)
}
case RoutingModeRock:
if p, ok := r.providers[ProviderRock]; ok {
list = append(list, p)
}
case RoutingModePriority:
for _, name := range cfg.Priority {
if p, ok := r.providers[name]; ok {
list = append(list, p)
}
}
}
// 最终安全兜底:若解析为空,优先尝试 DLC,若无则尝试任意可用 provider
if len(list) == 0 {
if dlc, ok := r.providers[ProviderDLC]; ok {
list = append(list, dlc)
} else {
for _, p := range r.providers {
list = append(list, p)
break
}
}
}
return list, cfg
}
func qrCodeURL(targetURL string) string {
return "https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=" + url.QueryEscape(targetURL)
}
func truncateText(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}