轮询绑定时向 0666 换一条新的活动链接,提货号不变。下游只展示返回的链接。 Co-authored-by: Cursor <cursoragent@cursor.com>
1164 lines
38 KiB
Go
1164 lines
38 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"
|
||
)
|
||
|
||
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
|
||
}
|