Files
affiliate_dash/backend/internal/service/rock_service.go
T
yml2213andCursor c85b9f4173 feat(rock): 同一提货号过期后重取二维码
轮询绑定时向 0666 换一条新的活动链接,提货号不变。下游只展示返回的链接。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 18:08:27 +08:00

1164 lines
38 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"
"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
}