Files
affiliate_dash/backend/internal/service/provider.go
T
yml2213andCursor 6c41834274 fix(delivery): 乐玩不可用时回退 DLC,避免提交 UID 绑定失败
legacy 双渠道配置升为 priority;显式 lewan 且具备 DLC 时允许回退,缓解 LEWAN_* 未注入导致的 bind 失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 11:51:32 +08:00

235 lines
7.7 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"
RoutingModePriority = "priority"
RoutingModeLewan = "lewan"
RoutingModeDLC = "dlc"
)
// 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:双渠道优先 priority,单渠道按已有 ID / legacy provider 决定
if cfg.RoutingMode == "" {
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
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 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] + "..."
}