问题:上游 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 与 前端构建均通过。
245 lines
8.0 KiB
Go
245 lines
8.0 KiB
Go
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] + "..."
|
||
}
|