Files
affiliate_dash/backend/internal/service/rock_assign.go
T
yml2213 80e2bbf875 fix(rock): 上游地址改为以数据库配置为准,并消除 baseURL 数据竞争
GetConfig 从不把 rock_configs.base_url 回写到客户端,main.go 又硬编码了
https://0666.pro,导致后台改上游地址「保存那一刻生效、进程重启后失效」,
数据库里的值被静默忽略且无任何提示。

- 新增 model.RockDefaultBaseURL 统一默认值来源
- 新增 RockService.ApplyConfigToClient,启动时把 DB 地址同步到客户端
- UpdateConfig 改用 client.SetBaseURL 立即生效,不再直接写字段
- RockClient.baseURL 原为裸字段,被 HTTP 请求线程与库存巡检 goroutine 并发
  读写(数据竞争),改为 sync.RWMutex 保护并新增 BaseURL/SetBaseURL
- ClaimTokens/RefreshToken/RedeemSkin 的 Origin/Referer 跟随配置地址
- 空值回落默认地址,末尾斜杠自动裁剪

为保持该提交可编译,同一次改动把 rock_service.go(1177 行)按域拆分为
8 个文件(最大 221 行):rock_service / rock_bindlink / rock_stock /
rock_assign / rock_bindcheck / rock_redeem / rock_recover / rock_snapshot。
35 个声明块全部有归属,无遗漏无重复;迁移文件 012 受 checksum 保护故未改动。

验证:TestRockServiceBaseURLFromConfig 覆盖「改地址→立即生效→重启→仍生效」;
并发用例在 -race 下通过;全量测试通过。
2026-10-06 19:42:58 +08:00

159 lines
5.6 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"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 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
}