package service import ( "context" "encoding/json" "errors" "fmt" "log" "strings" "sync" "time" "affiliate_dash/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) type rockRefreshCacheItem struct { response *RockRefreshResponse cachedAt time.Time } // 腾讯活动链接按生成时间签名。同一提货号过期后重取链接,不另发新号。 const rockBindLinkMaxAge = 8 * time.Minute type rockBindLinkCacheItem struct { bindURL string qrURL string cachedAt time.Time } // RockService 负责管理 Rock 平台的配置、库存池、采购、UID强一致风控与状态机 type RockService struct { db *gorm.DB client *RockClient mu sync.Mutex refreshCache sync.Map // key: upstreamTokenID string, value: rockRefreshCacheItem bindLinkCache sync.Map // key: token ID uint, value: rockBindLinkCacheItem } func NewRockService(db *gorm.DB, client *RockClient) *RockService { return &RockService{ db: db, client: client, } } func (s *RockService) rememberBindLink(tokenID uint, bindURL, qrURL string) { bindURL = strings.TrimSpace(bindURL) if s == nil || tokenID == 0 || bindURL == "" { return } qrURL = strings.TrimSpace(qrURL) if qrURL == "" { qrURL = qrCodeURL(bindURL) } s.bindLinkCache.Store(tokenID, rockBindLinkCacheItem{ bindURL: bindURL, qrURL: qrURL, cachedAt: time.Now(), }) } // freshBindLink 只为仍在扫码的同一提货号重取活动链接。过期前复用缓存,避免每轮轮询都打 0666。 func (s *RockService) freshBindLink(ctx context.Context, tokenID uint, orderNo string) (string, string) { if s == nil || s.client == nil || tokenID == 0 { return "", "" } var token model.RockToken if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { return "", "" } if token.Status != model.RockStatusUsing || strings.TrimSpace(token.RedeemCode) == "" { return "", "" } if orderNo = strings.TrimSpace(orderNo); orderNo != "" && token.AssignedOrderNo != "" && token.AssignedOrderNo != orderNo { return "", "" } if item, ok := s.bindLinkCache.Load(token.ID); ok { cached := item.(rockBindLinkCacheItem) if cached.bindURL != "" && time.Since(cached.cachedAt) < rockBindLinkMaxAge { return cached.bindURL, cached.qrURL } } detail, err := s.client.GetRedeemDetail(ctx, token.RedeemCode) if err != nil || detail == nil || strings.TrimSpace(detail.BindURL) == "" { if item, ok := s.bindLinkCache.Load(token.ID); ok { cached := item.(rockBindLinkCacheItem) return cached.bindURL, cached.qrURL } return "", "" } bindURL := strings.TrimSpace(detail.BindURL) qrURL := strings.TrimSpace(detail.QRURL) s.rememberBindLink(token.ID, bindURL, qrURL) if qrURL == "" { qrURL = qrCodeURL(bindURL) } return bindURL, qrURL } // 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.Float64(), "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.Float64()).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"` } // rockOrderTokenLockedMessage 一单只能占用一个提货号。绑定失败后不再发新号。 const rockOrderTokenLockedMessage = "该订单提货链接已绑定失败,不能更换新的提货号,请联系客服处理" // AssignTokenForOrder 为指定订单锁定分配一个可用 Token。 // 同一个订单号无论怎么更新 UID,都只能占用一个提货号:using/bound 复用原号并改期望 UID; // mismatch、已发货或异常则拒绝再分配,需客服人工重置后才能重新领取。 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. 该订单已经占用过提货号时,禁止再领新号。 err = s.db.WithContext(ctx). Where("assigned_order_no = ? AND status IN ?", orderNo, []string{ model.RockStatusUsing, model.RockStatusBound, model.RockStatusMismatch, model.RockStatusRedeemed, model.RockStatusAbnormal, }). Order("id DESC"). First(&token).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, err } if err == nil { switch token.Status { case model.RockStatusAbnormal: return nil, nil, errors.New(rockOrderTokenLockedMessage) case model.RockStatusRedeemed: return nil, nil, errors.New("该订单已发货,不能再次领取提货号") case model.RockStatusMismatch: trimmedUID := strings.TrimSpace(expectedUID) actualUID := strings.TrimSpace(token.ActualRoleID) // 方案 B:买家确认纠正为当前扫码角色的 UID if trimmedUID != "" && actualUID != "" && trimmedUID == actualUID { now := time.Now() token.ExpectedUID = trimmedUID token.Status = model.RockStatusBound token.MismatchReason = "" token.MismatchAt = nil token.BoundAt = &now _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ "expected_uid": trimmedUID, "status": model.RockStatusBound, "mismatch_reason": "", "mismatch_at": nil, "bound_at": now, "updated_at": now, }).Error log.Printf("[RockService] 买家确认更换 UID 为扫码角色 UID 成功,解除阻断: order=%s uid=%s role=%s", orderNo, trimmedUID, token.ActualRoleName) } else { roleDesc := token.ActualRoleName if roleDesc == "" { roleDesc = actualUID } if actualUID != "" { return nil, nil, fmt.Errorf("该订单提货链接已扫码绑定角色「%s」(%s)。如确系本人账号,请确认将填写 UID 修改为 %s;不能更换为其他账号,如需更换请联系客服处理", roleDesc, actualUID, actualUID) } return nil, nil, errors.New(rockOrderTokenLockedMessage) } default: trimmedUID := strings.TrimSpace(expectedUID) if token.Status == model.RockStatusBound && trimmedUID != "" && token.ExpectedUID != trimmedUID { return nil, nil, errors.New("该订单提货号已绑定,不能更换 UID,请联系客服处理") } if trimmedUID != "" && token.ExpectedUID != trimmedUID { token.ExpectedUID = trimmedUID _ = s.db.WithContext(ctx).Model(&token).Update("expected_uid", trimmedUID).Error } } } // 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) } s.rememberBindLink(token.ID, detail.BindURL, detail.QRURL) // 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.IsBound(), RoleName: ref.GetRoleName(), RoleID: ref.GetRoleID(), Channel: ref.GetChannel(), 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, orderNo, 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 状态,检查传入的 expectedUID 是否已纠正为扫码角色的 UID if token.Status == model.RockStatusMismatch { trimmedExpected := strings.TrimSpace(expectedUID) if trimmedExpected == "" { trimmedExpected = strings.TrimSpace(token.ExpectedUID) } actualUID := strings.TrimSpace(token.ActualRoleID) if trimmedExpected != "" && actualUID != "" && trimmedExpected == actualUID { now := time.Now() token.Status = model.RockStatusBound token.ExpectedUID = trimmedExpected token.MismatchReason = "" token.MismatchAt = nil token.BoundAt = &now _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ "expected_uid": trimmedExpected, "status": model.RockStatusBound, "mismatch_reason": "", "mismatch_at": nil, "bound_at": now, "updated_at": now, }).Error return &CheckBindingResult{ Bound: true, RoleName: token.ActualRoleName, RoleID: actualUID, Channel: token.ActualChannel, ExpectedUID: trimmedExpected, Mismatch: false, Message: "扫码授权成功,UID核验一致", }, nil } roleDesc := token.ActualRoleName if roleDesc == "" { roleDesc = actualUID } hintMsg := fmt.Sprintf("扫码角色「%s」UID(%s) 与填写 UID(%s) 不一致。如确系本人账号,请修改填写 UID 为 %s 确认绑定,否则请联系客服处理", roleDesc, actualUID, trimmedExpected, actualUID) return &CheckBindingResult{ Bound: false, Mismatch: true, RoleName: token.ActualRoleName, RoleID: token.ActualRoleID, Channel: token.ActualChannel, ExpectedUID: token.ExpectedUID, Message: hintMsg, }, nil } // 已释放回库存、或已改派给其他订单的链接,不能再被旧发货页轮询改写。 if token.Status != model.RockStatusUsing && token.Status != model.RockStatusBound { return &CheckBindingResult{ Bound: false, Message: "该提货链接已释放回库存,请重新获取二维码", }, nil } if strings.TrimSpace(orderNo) != "" && token.AssignedOrderNo != "" && token.AssignedOrderNo != strings.TrimSpace(orderNo) { return &CheckBindingResult{ Bound: false, Message: "该提货链接已不属于当前订单", }, nil } // 调上游 refresh 接口(带 800ms 瞬时防抖缓存,避免自动轮询与手动高频点击撞上上游 110001 频控) var ref *RockRefreshResponse cacheVal, hasCache := s.refreshCache.Load(token.UpstreamTokenID) if hasCache { item := cacheVal.(rockRefreshCacheItem) if time.Since(item.cachedAt) < 800*time.Millisecond { ref = item.response } } if ref == nil { upstreamRef, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) if err != nil { // 若上游返回 110001 "请求过于频繁",进行平滑降级处理,绝不报致命错误打断买家 if strings.Contains(err.Error(), "过于频繁") || strings.Contains(err.Error(), "110001") { if hasCache { ref = cacheVal.(rockRefreshCacheItem).response } else if token.Status == model.RockStatusBound { return &CheckBindingResult{ Bound: true, RoleName: token.ActualRoleName, RoleID: token.ActualRoleID, Channel: token.ActualChannel, ExpectedUID: token.ExpectedUID, Message: "扫码授权成功,UID核验一致", }, nil } else { return &CheckBindingResult{ Bound: false, Message: "正在同步微信/QQ扫码状态,请稍后再次检查", }, nil } } else { return nil, fmt.Errorf("刷新绑定状态失败: %w", err) } } else { ref = upstreamRef s.refreshCache.Store(token.UpstreamTokenID, rockRefreshCacheItem{ response: ref, cachedAt: time.Now(), }) } } // 1. 检查上游是否已绑定 (上游状态 bound 或 bound_account_id 有值) if !ref.IsBound() || strings.TrimSpace(ref.GetRoleID()) == "" { return &CheckBindingResult{ Bound: false, Message: "等待买家微信/QQ扫码授权", }, nil } actualRoleName := ref.GetRoleName() actualRoleID := ref.GetRoleID() actualChannel := ref.GetChannel() // 2. 历史残留不算本次扫码:角色和绑定时间与分配快照一致,或发货记录都早于本次领取。 if !rockBuyerNewlyBound(&token, ref) { return &CheckBindingResult{ Bound: false, Message: "等待买家扫码(检测到历史绑定残留,已自动忽略)", }, nil } // 3. 核心门禁:UID 强一致校验 trimmedExpected := strings.TrimSpace(expectedUID) if trimmedExpected == "" { trimmedExpected = strings.TrimSpace(token.ExpectedUID) } now := time.Now() if trimmedExpected != "" && actualRoleID != trimmedExpected { // 严禁发货!跃迁为 mismatch mismatchReason := fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", trimmedExpected, actualRoleName, actualRoleID) _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ "status": model.RockStatusMismatch, "actual_role_name": actualRoleName, "actual_role_id": actualRoleID, "actual_channel": actualChannel, "mismatch_at": now, "mismatch_reason": mismatchReason, }).Error roleDesc := actualRoleName if roleDesc == "" { roleDesc = actualRoleID } return &CheckBindingResult{ Bound: false, Mismatch: true, RoleName: actualRoleName, RoleID: actualRoleID, Channel: actualChannel, ExpectedUID: trimmedExpected, Message: fmt.Sprintf("扫码角色「%s」UID(%s) 与填写 UID(%s) 不一致。如确系本人账号,请修改填写 UID 为 %s 确认绑定,否则请联系客服处理", roleDesc, actualRoleID, trimmedExpected, actualRoleID), }, nil } // 4. UID 一致,更新为 bound 成功状态并保存玩家角色名称 _ = s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{ "status": model.RockStatusBound, "actual_role_name": actualRoleName, "actual_role_id": actualRoleID, "actual_channel": actualChannel, "bound_at": now, }).Error return &CheckBindingResult{ Bound: true, RoleName: actualRoleName, RoleID: actualRoleID, Channel: actualChannel, 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 } // 确保买家角色名存在 actualRoleName := strings.TrimSpace(token.ActualRoleName) if actualRoleName == "" { if ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID); refErr == nil && ref != nil { actualRoleName = ref.GetRoleName() if actualRoleName != "" { _ = s.db.WithContext(ctx).Model(&token).Update("actual_role_name", actualRoleName).Error } } } if actualRoleName == "" { actualRoleName = token.ActualRoleID } // 调上游兑换发货接口(携带 Bearer Token 鉴权) redeemResp, err := s.client.RedeemSkin(ctx, cfg.BearerToken, token.UpstreamTokenID, targetSkin, "幸运币", actualRoleName) if err != nil { return nil, fmt.Errorf("调用上游兑换失败: %w", err) } // 提取官方 fulfillId var fulfillID int64 now := time.Now() 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": 1, "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("CAST(id AS TEXT) = ? OR upstream_token_id = ? OR upstream_token_id LIKE ? OR assigned_order_no LIKE ? OR expected_uid LIKE ? OR actual_role_name LIKE ? OR actual_role_id LIKE ? OR redeem_code LIKE ? OR assigned_order_no IN (SELECT order_no FROM fulfillment_orders WHERE client_order_no LIKE ?)", strings.TrimSpace(search), strings.TrimSpace(search), term, term, 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 } // 批量反查关联订单的商户单号(创建订单时的 ClientOrderNo) orderNos := make([]string, 0, len(tokens)) for _, t := range tokens { if t.AssignedOrderNo != "" { orderNos = append(orderNos, t.AssignedOrderNo) } } if len(orderNos) > 0 { var orders []struct { OrderNo string `gorm:"column:order_no"` ClientOrderNo string `gorm:"column:client_order_no"` } if scanErr := s.db.WithContext(ctx).Table("fulfillment_orders"). Select("order_no, client_order_no"). Where("order_no IN ?", orderNos). Scan(&orders).Error; scanErr == nil { clientMap := make(map[string]string, len(orders)) for _, o := range orders { clientMap[o.OrderNo] = o.ClientOrderNo } for i := range tokens { if cNo, ok := clientMap[tokens[i].AssignedOrderNo]; ok && cNo != "" { tokens[i].ClientOrderNo = cNo } } } } return tokens, total, nil } // CorrectMismatchExpectedUID 客服修改买家填写的 UID,提货号继续留在原订单上。 // 改成当前扫码角色的 UID 时解除阻断并记为已绑定;改成其他 UID 只更新期望值,仍保持阻断。 func (s *RockService) CorrectMismatchExpectedUID(ctx context.Context, tokenID uint, expectedUID string) (*model.RockToken, error) { trimmedUID := strings.TrimSpace(expectedUID) if trimmedUID == "" || !isDigits(trimmedUID) || len(trimmedUID) > 15 { return nil, errors.New("请填写 15 位以内的纯数字 UID") } var token model.RockToken err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&token, tokenID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("提货号不存在") } return err } if token.Status != model.RockStatusMismatch { return errors.New("仅绑定错误的提货号可以修改买家 UID") } if strings.TrimSpace(token.AssignedOrderNo) == "" { return errors.New("该提货号没有关联订单,不能修改 UID") } now := time.Now() actualUID := strings.TrimSpace(token.ActualRoleID) updates := map[string]interface{}{ "expected_uid": trimmedUID, "updated_at": now, } if actualUID != "" && trimmedUID == actualUID { updates["status"] = model.RockStatusBound updates["mismatch_reason"] = "" updates["mismatch_at"] = nil updates["bound_at"] = now token.Status = model.RockStatusBound token.MismatchReason = "" token.MismatchAt = nil token.BoundAt = &now } else if actualUID != "" { updates["mismatch_reason"] = fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", trimmedUID, token.ActualRoleName, actualUID) token.MismatchReason = updates["mismatch_reason"].(string) } if err := tx.Model(&token).Updates(updates).Error; err != nil { return err } token.ExpectedUID = trimmedUID var order model.FulfillmentOrder if err := tx.Where("order_no = ?", token.AssignedOrderNo).First(&order).Error; err == nil { data := resultDataMap(order.ResultData) data["expected_uid"] = trimmedUID raw, marshalErr := json.Marshal(data) if marshalErr != nil { return marshalErr } if err := tx.Model(&order).Update("result_data", string(raw)).Error; err != nil { return err } } return writeAudit(tx, nil, nil, nil, "rock_token.correct_expected_uid", "rock_token", fmt.Sprintf("%d", token.ID), map[string]interface{}{ "assigned_order_no": token.AssignedOrderNo, "expected_uid": trimmedUID, "actual_role_id": actualUID, "status": token.Status, }) }) if err != nil { return nil, err } return &token, nil } func isDigits(value string) bool { if value == "" { return false } for _, r := range value { if r < '0' || r > '9' { return false } } return true } // 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 允许人工解冻重置") } s.refreshCache.Delete(token.UpstreamTokenID) // 重置回 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 } // rockBuyerNewlyBound 判断上游展示的绑定是不是本次领取之后新扫上的。 // 和分配快照一致,或者绑定时间仍为空且发货记录都早于本次领取,都是历史残留,链接可以再次使用。 // 没有快照、也没有更早的发货记录时,上游已有角色按新绑定处理,避免把刚扫上的号放回库存。 func rockBuyerNewlyBound(token *model.RockToken, ref *RockRefreshResponse) bool { if ref == nil || !ref.IsBound() || strings.TrimSpace(ref.GetRoleID()) == "" { return false } snap, hasSnap := bindingSnapshot(token) if hasSnap && snap.Bound && snap.RoleID == ref.GetRoleID() && snap.BindChangedAt == strings.TrimSpace(ref.Token.BindChangedAt) { return false } if deliveryAfterClaim(token, ref) || bindChangedAfterClaim(token, ref.Token.BindChangedAt) { return true } if hasSnap && snap.RoleID != "" && snap.RoleID != ref.GetRoleID() { return true } if strings.TrimSpace(ref.Token.BindChangedAt) == "" && historicalDeliveriesOnly(token, ref) { return false } if hasSnap { return false } return true } func historicalDeliveriesOnly(token *model.RockToken, ref *RockRefreshResponse) bool { if ref == nil || len(ref.DeliveryList) == 0 { return false } return !deliveryAfterClaim(token, ref) } func bindingSnapshot(token *model.RockToken) (BindingSnapshot, bool) { if token == nil || strings.TrimSpace(token.InitialBoundSnapshot) == "" { return BindingSnapshot{}, false } var snap BindingSnapshot if err := json.Unmarshal([]byte(token.InitialBoundSnapshot), &snap); err != nil { return BindingSnapshot{}, false } snap.RoleID = strings.TrimSpace(snap.RoleID) snap.BindChangedAt = strings.TrimSpace(snap.BindChangedAt) return snap, true } func rockClaimBaseline(token *model.RockToken) time.Time { if token == nil { return time.Time{} } if token.ClaimedAt != nil && !token.ClaimedAt.IsZero() { return token.ClaimedAt.UTC() } return token.CreatedAt.UTC() } func parseRockTime(raw string) (time.Time, bool) { raw = strings.TrimSpace(raw) if raw == "" { return time.Time{}, false } layouts := []string{ time.RFC3339Nano, "2006-01-02T15:04:05.999999999", "2006-01-02T15:04:05", "2006-01-02 15:04:05", } for _, layout := range layouts { if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil { return t.UTC(), true } } return time.Time{}, false } func bindChangedAfterClaim(token *model.RockToken, changedAt string) bool { changed, ok := parseRockTime(changedAt) if !ok { return false } baseline := rockClaimBaseline(token) if baseline.IsZero() { return true } return !changed.Before(baseline.Add(-time.Minute)) } func deliveryAfterClaim(token *model.RockToken, ref *RockRefreshResponse) bool { if ref == nil { return false } baseline := rockClaimBaseline(token) if baseline.IsZero() { return false } for _, item := range ref.DeliveryList { deliveredAt, ok := parseRockTime(item.Time) if !ok { continue } if !deliveredAt.Before(baseline.Add(-time.Minute)) { return true } } return false } // ReleaseUnboundUsingToken 把尚未扫码绑定的使用中链接释放回可用库存。 // 已绑定或上游已出现新扫码时拒绝释放,避免退款后的旧二维码被当成新库存发出去。 func (s *RockService) ReleaseUnboundUsingToken(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.RockStatusUsing { return fmt.Errorf("仅扫码使用中且尚未绑定的链接可以释放回库存") } if token.BoundAt != nil || strings.TrimSpace(token.ActualRoleID) != "" { return fmt.Errorf("该链接已扫码绑定,不能释放回库存") } cfg, err := s.GetConfig(ctx) if err != nil { return err } if strings.TrimSpace(cfg.BearerToken) == "" || strings.TrimSpace(token.UpstreamTokenID) == "" { return fmt.Errorf("缺少上游凭证,无法确认是否已扫码绑定") } ref, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) if err != nil { return fmt.Errorf("确认上游绑定状态失败: %w", err) } if rockBuyerNewlyBound(&token, ref) { return fmt.Errorf("上游已扫码绑定角色 %s(%s),不能释放", ref.GetRoleName(), ref.GetRoleID()) } s.refreshCache.Delete(token.UpstreamTokenID) orderNo := token.AssignedOrderNo if err := 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": "", "bound_at": nil, "updated_at": time.Now(), }).Error; err != nil { return err } s.detachReleasedRockToken(ctx, orderNo, token.ID) log.Printf("[RockService] 人工释放未绑定库存: token_id=%d order=%s", token.ID, orderNo) return nil } // detachReleasedRockToken 清掉原订单上指向这条链接的记录,避免旧发货页继续占用它。 func (s *RockService) detachReleasedRockToken(ctx context.Context, orderNo string, tokenID uint) { orderNo = strings.TrimSpace(orderNo) if orderNo == "" || tokenID == 0 { return } var order model.FulfillmentOrder if err := s.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&order).Error; err != nil { return } if resultDataNumber(order.ResultData, "rock_token_id") != int64(tokenID) { return } raw := resultDataMap(order.ResultData) delete(raw, "rock_token_id") delete(raw, "rock_redeem_code") raw["rock_token_released"] = true encoded, err := json.Marshal(raw) if err != nil { return } _ = s.db.WithContext(ctx).Model(&order).Update("result_data", string(encoded)).Error }