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 下通过;全量测试通过。
159 lines
5.6 KiB
Go
159 lines
5.6 KiB
Go
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
|
||
}
|