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配置与连通测试、库存监控、一键补货及错单解冻重置。
633 lines
20 KiB
Go
633 lines
20 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"affiliate_dash/internal/model"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// RockService 负责管理 Rock 平台的配置、库存池、采购、UID强一致风控与状态机
|
|
type RockService struct {
|
|
db *gorm.DB
|
|
client *RockClient
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func NewRockService(db *gorm.DB, client *RockClient) *RockService {
|
|
return &RockService{
|
|
db: db,
|
|
client: client,
|
|
}
|
|
}
|
|
|
|
// RockStatsResponse 看板与库存统计返回
|
|
type RockStatsResponse struct {
|
|
AvailableStock int64 `json:"available_stock"`
|
|
UsingStock int64 `json:"using_stock"`
|
|
BoundStock int64 `json:"bound_stock"`
|
|
RedeemedStock int64 `json:"redeemed_stock"`
|
|
MismatchStock int64 `json:"mismatch_stock"` // 绑定错误(待排查)
|
|
AbnormalStock int64 `json:"abnormal_stock"`
|
|
UpstreamPoints float64 `json:"upstream_points"`
|
|
AutoDeliveryEnabled bool `json:"auto_delivery_enabled"`
|
|
TokenWarningStock int `json:"token_warning_stock"`
|
|
AutoPurchaseEnabled bool `json:"auto_purchase_enabled"`
|
|
Username string `json:"username"`
|
|
LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"`
|
|
}
|
|
|
|
type UpdateRockConfigReq struct {
|
|
AutoDeliveryEnabled *bool `json:"auto_delivery_enabled"`
|
|
BaseURL *string `json:"base_url"`
|
|
BearerToken *string `json:"bearer_token"`
|
|
MinPointsWarning *float64 `json:"min_points_warning"`
|
|
TokenWarningStock *int `json:"token_warning_stock"`
|
|
AutoPurchaseEnabled *bool `json:"auto_purchase_enabled"`
|
|
AutoPurchaseCount *int `json:"auto_purchase_count"`
|
|
PurchaseCooldownMinutes *int `json:"purchase_cooldown_minutes"`
|
|
}
|
|
|
|
// GetConfig 获取当前配置,若无则初始化
|
|
func (s *RockService) GetConfig(ctx context.Context) (*model.RockConfig, error) {
|
|
var cfg model.RockConfig
|
|
err := s.db.WithContext(ctx).First(&cfg, 1).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
cfg = model.RockConfig{
|
|
ID: 1,
|
|
AutoDeliveryEnabled: true,
|
|
BaseURL: "https://0666.pro",
|
|
BearerToken: "",
|
|
MinPointsWarning: 1050.00,
|
|
TokenWarningStock: 10,
|
|
AutoPurchaseEnabled: false,
|
|
AutoPurchaseCount: 50,
|
|
PurchaseCooldownMinutes: 10,
|
|
}
|
|
if createErr := s.db.WithContext(ctx).Create(&cfg).Error; createErr != nil {
|
|
return nil, createErr
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
// UpdateConfig 更新系统配置
|
|
func (s *RockService) UpdateConfig(ctx context.Context, req UpdateRockConfigReq) (*model.RockConfig, error) {
|
|
cfg, err := s.GetConfig(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
updates := make(map[string]interface{})
|
|
if req.AutoDeliveryEnabled != nil {
|
|
updates["auto_delivery_enabled"] = *req.AutoDeliveryEnabled
|
|
}
|
|
if req.BaseURL != nil && strings.TrimSpace(*req.BaseURL) != "" {
|
|
updates["base_url"] = strings.TrimRight(strings.TrimSpace(*req.BaseURL), "/")
|
|
s.client.baseURL = updates["base_url"].(string)
|
|
}
|
|
if req.BearerToken != nil {
|
|
updates["bearer_token"] = strings.TrimSpace(*req.BearerToken)
|
|
}
|
|
if req.MinPointsWarning != nil {
|
|
updates["min_points_warning"] = *req.MinPointsWarning
|
|
}
|
|
if req.TokenWarningStock != nil {
|
|
updates["token_warning_stock"] = *req.TokenWarningStock
|
|
}
|
|
if req.AutoPurchaseEnabled != nil {
|
|
updates["auto_purchase_enabled"] = *req.AutoPurchaseEnabled
|
|
}
|
|
if req.AutoPurchaseCount != nil {
|
|
updates["auto_purchase_count"] = *req.AutoPurchaseCount
|
|
}
|
|
if req.PurchaseCooldownMinutes != nil {
|
|
updates["purchase_cooldown_minutes"] = *req.PurchaseCooldownMinutes
|
|
}
|
|
|
|
if len(updates) > 0 {
|
|
updates["updated_at"] = time.Now()
|
|
if err := s.db.WithContext(ctx).Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Updates(updates).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return s.GetConfig(ctx)
|
|
}
|
|
|
|
// TestConnection 测试上游 Token 连通性并同步最新用户信息与积分
|
|
func (s *RockService) TestConnection(ctx context.Context, bearerToken string) (*RockMeResponse, error) {
|
|
token := strings.TrimSpace(bearerToken)
|
|
if token == "" {
|
|
cfg, err := s.GetConfig(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
token = cfg.BearerToken
|
|
}
|
|
if token == "" {
|
|
return nil, fmt.Errorf("请先输入或配置上游 Bearer Token")
|
|
}
|
|
|
|
me, err := s.client.GetMe(ctx, token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 连通成功,顺便异步更新数据库快照
|
|
now := time.Now()
|
|
_ = s.db.WithContext(ctx).Model(&model.RockConfig{}).Where("id = 1").Updates(map[string]interface{}{
|
|
"username": me.User.Username,
|
|
"points": me.User.Points,
|
|
"last_heartbeat_at": now,
|
|
}).Error
|
|
|
|
return me, nil
|
|
}
|
|
|
|
// GetStats 获取概览统计
|
|
func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error) {
|
|
cfg, err := s.GetConfig(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var stats RockStatsResponse
|
|
stats.AutoDeliveryEnabled = cfg.AutoDeliveryEnabled
|
|
stats.TokenWarningStock = cfg.TokenWarningStock
|
|
stats.AutoPurchaseEnabled = cfg.AutoPurchaseEnabled
|
|
stats.UpstreamPoints = cfg.Points
|
|
stats.Username = cfg.Username
|
|
if cfg.LastHeartbeatAt != nil {
|
|
stats.LastHeartbeatAt = cfg.LastHeartbeatAt.Format("2006-01-02 15:04:05")
|
|
}
|
|
|
|
type CountResult struct {
|
|
Status string
|
|
Total int64
|
|
}
|
|
var results []CountResult
|
|
err = s.db.WithContext(ctx).Model(&model.RockToken{}).
|
|
Select("status, count(*) as total").
|
|
Group("status").
|
|
Scan(&results).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, r := range results {
|
|
switch r.Status {
|
|
case model.RockStatusAvailable:
|
|
stats.AvailableStock = r.Total
|
|
case model.RockStatusUsing:
|
|
stats.UsingStock = r.Total
|
|
case model.RockStatusBound:
|
|
stats.BoundStock = r.Total
|
|
case model.RockStatusRedeemed:
|
|
stats.RedeemedStock = r.Total
|
|
case model.RockStatusMismatch:
|
|
stats.MismatchStock = r.Total
|
|
case model.RockStatusAbnormal:
|
|
stats.AbnormalStock = r.Total
|
|
}
|
|
}
|
|
|
|
return &stats, nil
|
|
}
|
|
|
|
// PurchaseTokens 执行采购并批量入库(通用黑色 Token)
|
|
func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool) ([]model.RockToken, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
cfg, err := s.GetConfig(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if cfg.BearerToken == "" {
|
|
return nil, fmt.Errorf("尚未配置上游 Bearer Token,无法执行采购")
|
|
}
|
|
|
|
// 自动补货防抖冷却检查
|
|
now := time.Now()
|
|
if isAuto && cfg.LastPurchasedAt != nil {
|
|
cooldown := time.Duration(cfg.PurchaseCooldownMinutes) * time.Minute
|
|
if now.Sub(*cfg.LastPurchasedAt) < cooldown {
|
|
return nil, fmt.Errorf("自动采购处于冷却中(冷却时间 %d 分钟)", cfg.PurchaseCooldownMinutes)
|
|
}
|
|
}
|
|
|
|
// 调上游 Claim 接口(统一下单黑色通用 Token)
|
|
tokens, err := s.client.ClaimTokens(ctx, cfg.BearerToken, model.RockColorBlack, count)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("调用上游采购接口失败: %w", err)
|
|
}
|
|
|
|
if len(tokens) == 0 {
|
|
return nil, fmt.Errorf("上游未返回任何有效 Token")
|
|
}
|
|
|
|
var dbTokens []model.RockToken
|
|
for _, t := range tokens {
|
|
dbTokens = append(dbTokens, model.RockToken{
|
|
UpstreamTokenID: fmt.Sprintf("%d", t.ID),
|
|
Token: t.Token,
|
|
RedeemCode: t.RedeemCode,
|
|
Color: model.RockColorBlack,
|
|
Region: t.Region,
|
|
Status: model.RockStatusAvailable,
|
|
ClaimedAt: &now,
|
|
})
|
|
}
|
|
|
|
// 事务写入库存表
|
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Create(&dbTokens).Error; err != nil {
|
|
return err
|
|
}
|
|
// 更新最后采购时间
|
|
return tx.Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Update("last_purchased_at", now).Error
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("入库采购 Token 失败: %w", err)
|
|
}
|
|
|
|
// 异步更新上游剩余积分快照
|
|
go func() {
|
|
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
me, meErr := s.client.GetMe(bgCtx, cfg.BearerToken)
|
|
if meErr == nil && me != nil {
|
|
_ = s.db.Model(&model.RockConfig{}).Where("id = 1").Update("points", me.User.Points).Error
|
|
}
|
|
}()
|
|
|
|
return dbTokens, nil
|
|
}
|
|
|
|
// CheckStockAndAutoPurchase 水位检查与自动采购触发
|
|
func (s *RockService) CheckStockAndAutoPurchase(ctx context.Context) {
|
|
cfg, err := s.GetConfig(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
var count int64
|
|
s.db.WithContext(ctx).Model(&model.RockToken{}).
|
|
Where("status = ? AND color = ?", model.RockStatusAvailable, model.RockColorBlack).
|
|
Count(&count)
|
|
|
|
if count <= int64(cfg.TokenWarningStock) {
|
|
log.Printf("[RockService] 可用库存预警: 当前仅存 %d 个 (预警阈值 %d)", count, cfg.TokenWarningStock)
|
|
if cfg.AutoPurchaseEnabled {
|
|
buyCount := cfg.AutoPurchaseCount
|
|
if buyCount <= 0 {
|
|
buyCount = 50
|
|
}
|
|
log.Printf("[RockService] 触发自动补货,采购数量: %d", buyCount)
|
|
_, err := s.PurchaseTokens(ctx, buyCount, true)
|
|
if err != nil {
|
|
log.Printf("[RockService] 自动补货失败: %v", err)
|
|
} else {
|
|
log.Printf("[RockService] 自动补货成功,新增 %d 个 Token", buyCount)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// BindingSnapshot 保存分配 Token 时上游的绑定快照
|
|
type BindingSnapshot struct {
|
|
Bound bool `json:"bound"`
|
|
RoleName string `json:"role_name"`
|
|
RoleID string `json:"role_id"`
|
|
Channel string `json:"channel"`
|
|
BindChangedAt string `json:"bind_changed_at"`
|
|
}
|
|
|
|
// AssignTokenForOrder 为指定订单锁定分配一个可用 Token
|
|
func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expectedUID string) (*model.RockToken, *RockRedeemDetailResponse, error) {
|
|
cfg, err := s.GetConfig(ctx)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !cfg.AutoDeliveryEnabled {
|
|
return nil, nil, fmt.Errorf("Rock 自动发货通道已维护关闭")
|
|
}
|
|
|
|
var token model.RockToken
|
|
|
|
// 1. 检查订单是否已分配过 Token
|
|
err = s.db.WithContext(ctx).
|
|
Where("assigned_order_no = ? AND status IN (?, ?)", orderNo, model.RockStatusUsing, model.RockStatusBound).
|
|
First(&token).Error
|
|
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// 2. 若尚未分配,锁定一个可用 Token (行锁防并发超卖)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
|
|
Where("status = ? AND color = ?", model.RockStatusAvailable, model.RockColorBlack).
|
|
Order("id ASC").
|
|
First(&token).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fmt.Errorf("Rock平台库存不足,请联系管理员补货")
|
|
}
|
|
return err
|
|
}
|
|
|
|
token.Status = model.RockStatusUsing
|
|
token.AssignedOrderNo = orderNo
|
|
token.ExpectedUID = strings.TrimSpace(expectedUID)
|
|
return tx.Save(&token).Error
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// 检查水位触发异步自动补货
|
|
go s.CheckStockAndAutoPurchase(context.Background())
|
|
}
|
|
|
|
// 3. 调上游获取绑定活动链接
|
|
detail, err := s.client.GetRedeemDetail(ctx, token.RedeemCode)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("获取上游活动绑定链接失败: %w", err)
|
|
}
|
|
|
|
// 4. 首次分配记录上游初始绑定快照(防范 Ghost-Binding 历史残留数据)
|
|
if token.InitialBoundSnapshot == "" && cfg.BearerToken != "" {
|
|
ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID)
|
|
if refErr == nil && ref != nil {
|
|
snap := BindingSnapshot{
|
|
Bound: ref.Binding.Bound,
|
|
RoleName: ref.Binding.RoleName,
|
|
RoleID: ref.Binding.RoleID,
|
|
Channel: ref.Binding.Channel,
|
|
BindChangedAt: ref.Token.BindChangedAt,
|
|
}
|
|
snapBytes, _ := json.Marshal(snap)
|
|
token.InitialBoundSnapshot = string(snapBytes)
|
|
_ = s.db.WithContext(ctx).Model(&token).Update("initial_bound_snapshot", token.InitialBoundSnapshot).Error
|
|
}
|
|
}
|
|
|
|
return &token, detail, nil
|
|
}
|
|
|
|
type CheckBindingResult struct {
|
|
Bound bool `json:"bound"`
|
|
RoleName string `json:"role_name"`
|
|
RoleID string `json:"role_id"`
|
|
Channel string `json:"channel"`
|
|
ExpectedUID string `json:"expected_uid"`
|
|
Mismatch bool `json:"mismatch"`
|
|
Message string `json:"message"`
|
|
UpstreamFulfillID int64 `json:"upstream_fulfill_id,omitempty"`
|
|
}
|
|
|
|
// CheckBindingAndVerifyUID Anti-Ghost 基线比对与 UID 强一致门禁
|
|
func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint, expectedUID string) (*CheckBindingResult, error) {
|
|
cfg, err := s.GetConfig(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var token model.RockToken
|
|
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 若已经是 mismatch 状态,直接返回阻断提示
|
|
if token.Status == model.RockStatusMismatch {
|
|
return &CheckBindingResult{
|
|
Bound: false,
|
|
Mismatch: true,
|
|
RoleName: token.ActualRoleName,
|
|
RoleID: token.ActualRoleID,
|
|
Channel: token.ActualChannel,
|
|
ExpectedUID: token.ExpectedUID,
|
|
Message: fmt.Sprintf("UID 不匹配阻断: %s", token.MismatchReason),
|
|
}, nil
|
|
}
|
|
|
|
// 调上游 refresh 接口
|
|
ref, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("刷新绑定状态失败: %w", err)
|
|
}
|
|
|
|
// 1. 检查上游是否已绑定
|
|
if !ref.Binding.Bound || strings.TrimSpace(ref.Binding.RoleID) == "" {
|
|
return &CheckBindingResult{
|
|
Bound: false,
|
|
Message: "等待买家微信/QQ扫码授权",
|
|
}, nil
|
|
}
|
|
|
|
// 2. Anti-Ghost 历史残留比对
|
|
if token.InitialBoundSnapshot != "" {
|
|
var snap BindingSnapshot
|
|
if jsonErr := json.Unmarshal([]byte(token.InitialBoundSnapshot), &snap); jsonErr == nil {
|
|
// 若当前绑定的角色、UID 与初始分配时完全一样且变更时间没有更新,说明买家还未扫码,仍是历史旧数据
|
|
if snap.Bound && snap.RoleID == ref.Binding.RoleID && snap.BindChangedAt == ref.Token.BindChangedAt {
|
|
return &CheckBindingResult{
|
|
Bound: false,
|
|
Message: "等待买家扫码(检测到历史绑定残留,已自动忽略)",
|
|
}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. 核心门禁:UID 强一致校验
|
|
trimmedExpected := strings.TrimSpace(expectedUID)
|
|
if trimmedExpected == "" {
|
|
trimmedExpected = strings.TrimSpace(token.ExpectedUID)
|
|
}
|
|
trimmedActual := strings.TrimSpace(ref.Binding.RoleID)
|
|
|
|
now := time.Now()
|
|
if trimmedExpected != "" && trimmedActual != trimmedExpected {
|
|
// 严禁发货!跃迁为 mismatch
|
|
mismatchReason := fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", trimmedExpected, ref.Binding.RoleName, trimmedActual)
|
|
_ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
|
|
"status": model.RockStatusMismatch,
|
|
"actual_role_name": ref.Binding.RoleName,
|
|
"actual_role_id": trimmedActual,
|
|
"actual_channel": ref.Binding.Channel,
|
|
"mismatch_at": now,
|
|
"mismatch_reason": mismatchReason,
|
|
}).Error
|
|
|
|
return &CheckBindingResult{
|
|
Bound: false,
|
|
Mismatch: true,
|
|
RoleName: ref.Binding.RoleName,
|
|
RoleID: trimmedActual,
|
|
Channel: ref.Binding.Channel,
|
|
ExpectedUID: trimmedExpected,
|
|
Message: fmt.Sprintf("扫码角色UID(%s)与订单填写UID(%s)不一致,严禁发货!", trimmedActual, trimmedExpected),
|
|
}, nil
|
|
}
|
|
|
|
// 4. UID 一致,更新为 bound 成功状态
|
|
_ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
|
|
"status": model.RockStatusBound,
|
|
"actual_role_name": ref.Binding.RoleName,
|
|
"actual_role_id": trimmedActual,
|
|
"actual_channel": ref.Binding.Channel,
|
|
"bound_at": now,
|
|
}).Error
|
|
|
|
return &CheckBindingResult{
|
|
Bound: true,
|
|
RoleName: ref.Binding.RoleName,
|
|
RoleID: trimmedActual,
|
|
Channel: ref.Binding.Channel,
|
|
ExpectedUID: trimmedExpected,
|
|
Message: "扫码授权成功,UID核验一致",
|
|
}, nil
|
|
}
|
|
|
|
// RedeemTokenForOrder 执行最终发货兑换(动态判定皮肤)
|
|
func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, expectedUID string) (*RockRedeemResponse, error) {
|
|
cfg, err := s.GetConfig(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !cfg.AutoDeliveryEnabled {
|
|
return nil, fmt.Errorf("Rock 自动发货通道维护中,已关闭自动兑换")
|
|
}
|
|
|
|
var token model.RockToken
|
|
err = s.db.WithContext(ctx).
|
|
Where("assigned_order_no = ? AND status = ?", orderNo, model.RockStatusBound).
|
|
First(&token).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fmt.Errorf("未找到该订单已绑定的有效 Token,或当前状态不可发货")
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// 最终双重防御:核验 UID
|
|
trimmedExpected := strings.TrimSpace(expectedUID)
|
|
if trimmedExpected == "" {
|
|
trimmedExpected = strings.TrimSpace(token.ExpectedUID)
|
|
}
|
|
if trimmedExpected != "" && token.ActualRoleID != trimmedExpected {
|
|
return nil, fmt.Errorf("终审拦截:扫码UID(%s)与预期UID(%s)不符,禁止发货!", token.ActualRoleID, trimmedExpected)
|
|
}
|
|
|
|
// 根据商品 SKU 动态决定兑换的皮肤名称
|
|
targetSkin := model.RockSkinBlackTshirt
|
|
if sku == model.RockSKUBlueSportswear {
|
|
targetSkin = model.RockSkinBlueSportswear
|
|
}
|
|
|
|
// 调上游兑换发货接口
|
|
redeemResp, err := s.client.RedeemSkin(ctx, token.UpstreamTokenID, targetSkin, "幸运币", token.ActualRoleName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("调用上游兑换失败: %w", err)
|
|
}
|
|
|
|
// 提取官方 fulfillId,若兑换响应中没有则通过 refresh 提取
|
|
fulfillID := redeemResp.Delivery.FulfillID
|
|
now := time.Now()
|
|
if fulfillID == 0 {
|
|
ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID)
|
|
if refErr == nil && len(ref.DeliveryList) > 0 {
|
|
fulfillID = ref.DeliveryList[len(ref.DeliveryList)-1].FulfillID
|
|
}
|
|
}
|
|
|
|
// 状态跃迁至 redeemed
|
|
_ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
|
|
"status": model.RockStatusRedeemed,
|
|
"upstream_fulfill_id": fulfillID,
|
|
"target_skin": targetSkin,
|
|
"target_currency": "幸运币",
|
|
"target_currency_quantity": redeemResp.Delivery.Quantity,
|
|
"redeemed_at": now,
|
|
}).Error
|
|
|
|
return redeemResp, nil
|
|
}
|
|
|
|
// ListTokens 分页查询 Token 列表
|
|
func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status, search string) ([]model.RockToken, int64, error) {
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
|
|
db := s.db.WithContext(ctx).Model(&model.RockToken{})
|
|
if strings.TrimSpace(status) != "" {
|
|
db = db.Where("status = ?", strings.TrimSpace(status))
|
|
}
|
|
if strings.TrimSpace(search) != "" {
|
|
term := "%" + strings.TrimSpace(search) + "%"
|
|
db = db.Where("assigned_order_no LIKE ? OR expected_uid LIKE ? OR actual_role_name LIKE ? OR actual_role_id LIKE ? OR redeem_code LIKE ?",
|
|
term, term, term, term, term)
|
|
}
|
|
|
|
var total int64
|
|
if err := db.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
var tokens []model.RockToken
|
|
err := db.Order("id DESC").
|
|
Offset((page - 1) * pageSize).
|
|
Limit(pageSize).
|
|
Find(&tokens).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return tokens, total, nil
|
|
}
|
|
|
|
// ResetMismatchToken 客服人工重置解冻绑定错误的 Token
|
|
func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) error {
|
|
var token model.RockToken
|
|
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if token.Status != model.RockStatusMismatch {
|
|
return fmt.Errorf("仅 mismatch 状态的 Token 允许人工解冻重置")
|
|
}
|
|
|
|
// 重置回 available,清空订单关联
|
|
return s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
|
|
"status": model.RockStatusAvailable,
|
|
"assigned_order_no": "",
|
|
"expected_uid": "",
|
|
"actual_role_name": "",
|
|
"actual_role_id": "",
|
|
"actual_channel": "",
|
|
"initial_bound_snapshot": "",
|
|
"mismatch_at": nil,
|
|
"mismatch_reason": "",
|
|
"updated_at": time.Now(),
|
|
}).Error
|
|
}
|