1. 数据库新增 012_rock_fulfillment 迁移,创建 rock_configs 配置表与 rock_tokens 通用库存表,补齐黑色ROCK长袖T恤与蓝色运动服上衣商品目录; 2. 封装 RockClient 与 RockService,实现 0666.pro 平台 Bearer Token 极简连通校验、批量采购50个入库、水位监控巡检与自动补货; 3. 新增 RockProvider 实现标准履约路由,引入 Anti-Ghost 旧绑定基线比对与 UID 强一致门禁,扫错号硬阻断并记录为 mismatch 状态; 4. 针对黑色ROCK与蓝色运动服实现动态发货(绑定后发货前动态选择 skin),并提取腾讯官方发货流水号 fulfillId 对账; 5. 新增全局自动发货总开关(Kill Switch),支持一键软熔断降级转人工接单处理; 6. 前端新增 PlatformRock 管理大屏,提供总开关切换、Token配置与连通测试、库存监控、一键补货及错单解冻重置。
244 lines
7.9 KiB
Go
244 lines
7.9 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
|
||
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] + "..."
|
||
}
|