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 下通过;全量测试通过。
This commit is contained in:
yml2213
2026-10-06 19:42:58 +08:00
parent d423fc652c
commit 80e2bbf875
12 changed files with 1242 additions and 982 deletions
+8 -2
View File
@@ -8,6 +8,7 @@ import (
"affiliate_dash/internal/config"
"affiliate_dash/internal/database"
"affiliate_dash/internal/handler"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/applog"
"affiliate_dash/internal/pkg/jwt"
"affiliate_dash/internal/pkg/lewan"
@@ -102,9 +103,14 @@ func main() {
lewanProvider := service.NewLewanProvider(lewanClient)
deliverySvc.RegisterProvider(lewanProvider)
// Rock 平台 (0666.pro) 客户端、服务与履约提供商注册
rockClient := service.NewRockClient("https://0666.pro")
// Rock 平台客户端、服务与履约提供商注册。
// 这里传的只是初始/兜底地址,真正的上游地址以 rock_configs.base_url 为准,
// 因此紧接着要同步一次,否则后台改过的地址重启后会失效。
rockClient := service.NewRockClient(model.RockDefaultBaseURL)
rockSvc := service.NewRockService(db, rockClient)
if err := rockSvc.ApplyConfigToClient(context.Background()); err != nil {
applog.Warnf("读取 Rock 平台配置失败,暂用默认上游地址 %s: %v", model.RockDefaultBaseURL, err)
}
rockProvider := service.NewRockProvider(rockSvc)
deliverySvc.RegisterProvider(rockProvider)
+11 -2
View File
@@ -6,6 +6,15 @@ import (
// Rock 相关常量
const (
// RockDefaultBaseURL 是 Rock 上游平台的默认地址,只作为「初始值 / 兜底值」。
// 运行期一律以 rock_configs.base_url 为准:启动时由
// RockService.ApplyConfigToClient 同步到 HTTP 客户端,否则后台改过的地址
// 会在进程重启后失效。
//
// 注意:迁移文件 012_rock_fulfillment.sql 中的默认值受 checksum 保护,
// 不能为了统一而修改它,否则已有部署会因 checksum mismatch 启动失败。
RockDefaultBaseURL = "https://0666.pro"
RockColorBlack = "black"
RockColorBlue = "blue"
@@ -26,8 +35,8 @@ const (
// RockConfig 平台配置项
type RockConfig struct {
ID uint `gorm:"primaryKey;default:1" json:"id"`
AutoDeliveryEnabled bool `gorm:"not null;default:true" json:"auto_delivery_enabled"` // 全局自动发货总开关
BaseURL string `gorm:"size:255;not null;default:'https://0666.pro'" json:"base_url"`
AutoDeliveryEnabled bool `gorm:"not null;default:true" json:"auto_delivery_enabled"` // 全局自动发货总开关
BaseURL string `gorm:"size:255;not null;default:'https://0666.pro'" json:"base_url"` // 默认值见 RockDefaultBaseURL
BearerToken string `gorm:"type:text;not null;default:''" json:"bearer_token"`
Username string `gorm:"size:128;not null;default:''" json:"username"`
Points float64 `gorm:"type:numeric(12,2);not null;default:0.00" json:"points"`
+158
View File
@@ -0,0 +1,158 @@
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
}
@@ -0,0 +1,123 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"affiliate_dash/internal/model"
)
// TestRockServiceBaseURLFromConfig 锁定「后台配置的上游地址必须真正生效,并且能跨进程重启」。
//
// 回归背景:main.go 曾把 baseURL 硬编码进 NewRockClient,而 GetConfig 从
// 不把 rock_configs.base_url 回写到客户端。结果是后台改了地址在保存那一刻生效,
// 进程一重启就悄悄退回 0666.pro。
func TestRockServiceBaseURLFromConfig(t *testing.T) {
db := newRockTestDB(t)
ctx := context.Background()
client := NewRockClient(model.RockDefaultBaseURL)
svc := NewRockService(db, client)
// 首次读取会初始化配置,默认地址应与常量一致。
cfg, err := svc.GetConfig(ctx)
if err != nil {
t.Fatalf("读取 Rock 配置失败: %v", err)
}
if cfg.BaseURL != model.RockDefaultBaseURL {
t.Fatalf("初始 base_url 期望 %s,实际 %s", model.RockDefaultBaseURL, cfg.BaseURL)
}
// 后台改地址后必须立即生效,不需要重启。
custom := "https://rock-mirror.example.com"
if _, err := svc.UpdateConfig(ctx, UpdateRockConfigReq{BaseURL: &custom}); err != nil {
t.Fatalf("更新 Rock 配置失败: %v", err)
}
if got := client.BaseURL(); got != custom {
t.Fatalf("UpdateConfig 未立即生效:期望 %s,实际 %s", custom, got)
}
// 模拟进程重启:新的客户端 + 新的 service,读同一个数据库。
restarted := NewRockClient(model.RockDefaultBaseURL)
restartedSvc := NewRockService(db, restarted)
if got := restarted.BaseURL(); got != model.RockDefaultBaseURL {
t.Fatalf("同步前应保持构造时传入的地址,实际 %s", got)
}
if err := restartedSvc.ApplyConfigToClient(ctx); err != nil {
t.Fatalf("启动同步 Rock 配置失败: %v", err)
}
if got := restarted.BaseURL(); got != custom {
t.Fatalf("重启后未采用数据库中的上游地址:期望 %s,实际 %s", custom, got)
}
}
func TestRockClientSetBaseURL(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{"空值回落到默认地址", "", model.RockDefaultBaseURL},
{"纯空白回落到默认地址", " ", model.RockDefaultBaseURL},
{"裁剪末尾斜杠", "https://a.example.com/", "https://a.example.com"},
{"裁剪多余空白与斜杠", " https://b.example.com// ", "https://b.example.com"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
client := NewRockClient(tc.input)
if got := client.BaseURL(); got != tc.want {
t.Fatalf("期望 %q,实际 %q", tc.want, got)
}
})
}
}
// TestRockClientOriginHeaderFollowsBaseURL 确认 Origin / Referer 跟随配置地址,
// 而不是始终写死上游域名。
func TestRockClientOriginHeaderFollowsBaseURL(t *testing.T) {
var gotOrigin, gotReferer string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotOrigin = r.Header.Get("Origin")
gotReferer = r.Header.Get("Referer")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"tokens":[{"id":1,"redeem_code":"ABC"}]}`))
}))
defer server.Close()
client := NewRockClient(server.URL)
if _, err := client.ClaimTokens(context.Background(), "bearer", model.RockColorBlack, 1); err != nil {
t.Fatalf("claim 失败: %v", err)
}
if gotOrigin != server.URL {
t.Fatalf("Origin 期望 %s,实际 %s", server.URL, gotOrigin)
}
if want := server.URL + "/"; gotReferer != want {
t.Fatalf("Referer 期望 %s,实际 %s", want, gotReferer)
}
}
// TestRockClientBaseURLConcurrentAccess 在 -race 下验证读写 baseURL 无数据竞争。
func TestRockClientBaseURLConcurrentAccess(t *testing.T) {
client := NewRockClient(model.RockDefaultBaseURL)
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 200; i++ {
client.SetBaseURL("https://rock-mirror.example.com")
client.SetBaseURL(model.RockDefaultBaseURL)
}
}()
for i := 0; i < 200; i++ {
if got := client.BaseURL(); got == "" {
t.Error("BaseURL 不应为空")
break
}
}
<-done
}
+213
View File
@@ -0,0 +1,213 @@
package service
import (
"context"
"fmt"
"strings"
"time"
"affiliate_dash/internal/model"
)
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
}
+77
View File
@@ -0,0 +1,77 @@
package service
import (
"context"
"strings"
"time"
"affiliate_dash/internal/model"
)
type rockRefreshCacheItem struct {
response *RockRefreshResponse
cachedAt time.Time
}
// 腾讯活动链接按生成时间签名。同一提货号过期后重取链接,不另发新号。
const rockBindLinkMaxAge = 8 * time.Minute
type rockBindLinkCacheItem struct {
bindURL string
qrURL string
cachedAt time.Time
}
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
}
+48 -20
View File
@@ -10,7 +10,10 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"time"
"affiliate_dash/internal/model"
)
// FlexibleFloat64 兼容反序列化 JSON 中的数字或字符串(如 "8271.2")
@@ -53,23 +56,48 @@ func (f FlexibleFloat64) Float64() float64 {
return float64(f)
}
// RockClient 负责与 0666.pro 平台进行 HTTP 通信
// RockClient 负责与 Rock 上游平台进行 HTTP 通信。
//
// baseURL 在运行期可被后台配置改动(见 RockService.UpdateConfig),而读取方
// (HTTP 请求、库存巡检 goroutine)是并发的,因此必须加锁访问。
type RockClient struct {
mu sync.RWMutex
baseURL string
httpClient *http.Client
}
func NewRockClient(baseURL string) *RockClient {
base := strings.TrimRight(baseURL, "/")
if base == "" {
base = "https://0666.pro"
}
return &RockClient{
baseURL: base,
c := &RockClient{
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
c.SetBaseURL(baseURL)
return c
}
// BaseURL 返回当前生效的上游地址。
func (c *RockClient) BaseURL() string {
if c == nil {
return ""
}
c.mu.RLock()
defer c.mu.RUnlock()
return c.baseURL
}
// SetBaseURL 更新上游地址;传入空值或只有空白时回落到内置默认地址。
func (c *RockClient) SetBaseURL(baseURL string) {
if c == nil {
return
}
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if base == "" {
base = model.RockDefaultBaseURL
}
c.mu.Lock()
c.baseURL = base
c.mu.Unlock()
}
type RockMeResponse struct {
@@ -223,7 +251,7 @@ type RockExchangeRecordsResponse struct {
// GetMe 获取商户个人信息及积分
func (c *RockClient) GetMe(ctx context.Context, bearerToken string) (*RockMeResponse, error) {
reqURL := fmt.Sprintf("%s/api/me", c.baseURL)
reqURL := fmt.Sprintf("%s/api/me", c.BaseURL())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
@@ -264,7 +292,7 @@ func (c *RockClient) ClaimTokens(ctx context.Context, bearerToken string, color
if count <= 0 {
return nil, fmt.Errorf("提取数量必须大于 0")
}
reqURL := fmt.Sprintf("%s/api/user/tokens/claim", c.baseURL)
reqURL := fmt.Sprintf("%s/api/user/tokens/claim", c.BaseURL())
payload := map[string]interface{}{
"color": color,
"count": fmt.Sprintf("%d", count),
@@ -278,8 +306,8 @@ func (c *RockClient) ClaimTokens(ctx context.Context, bearerToken string, color
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Origin", "https://0666.pro")
req.Header.Set("Referer", "https://0666.pro/")
req.Header.Set("Origin", c.BaseURL())
req.Header.Set("Referer", c.BaseURL()+"/")
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -308,7 +336,7 @@ func (c *RockClient) ClaimTokens(ctx context.Context, bearerToken string, color
// GetRedeemDetail 根据 redeem_code 免登录获取腾讯官方绑定链接与状态
func (c *RockClient) GetRedeemDetail(ctx context.Context, redeemCode string) (*RockRedeemDetailResponse, error) {
reqURL := fmt.Sprintf("%s/api/redeem/%s", c.baseURL, url.PathEscape(redeemCode))
reqURL := fmt.Sprintf("%s/api/redeem/%s", c.BaseURL(), url.PathEscape(redeemCode))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
@@ -339,7 +367,7 @@ func (c *RockClient) GetRedeemDetail(ctx context.Context, redeemCode string) (*R
// RefreshToken 刷新 Token 绑定状态并获取官方履约结果
func (c *RockClient) RefreshToken(ctx context.Context, bearerToken string, upstreamTokenID string) (*RockRefreshResponse, error) {
reqURL := fmt.Sprintf("%s/api/user/tokens/%s/refresh", c.baseURL, url.PathEscape(upstreamTokenID))
reqURL := fmt.Sprintf("%s/api/user/tokens/%s/refresh", c.BaseURL(), url.PathEscape(upstreamTokenID))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil)
if err != nil {
return nil, err
@@ -348,8 +376,8 @@ func (c *RockClient) RefreshToken(ctx context.Context, bearerToken string, upstr
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken)))
}
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Origin", "https://0666.pro")
req.Header.Set("Referer", "https://0666.pro/")
req.Header.Set("Origin", c.BaseURL())
req.Header.Set("Referer", c.BaseURL()+"/")
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -375,7 +403,7 @@ func (c *RockClient) RefreshToken(ctx context.Context, bearerToken string, upstr
// RedeemSkin 执行最终皮肤兑换发货(需携带商户 Bearer Token 鉴权)
func (c *RockClient) RedeemSkin(ctx context.Context, bearerToken, upstreamTokenID, skin, currency, account string) (*RockRedeemResponse, error) {
reqURL := fmt.Sprintf("%s/api/redeem/%s", c.baseURL, url.PathEscape(upstreamTokenID))
reqURL := fmt.Sprintf("%s/api/redeem/%s", c.BaseURL(), url.PathEscape(upstreamTokenID))
payload := map[string]interface{}{
"skin": skin,
"currency": currency,
@@ -392,8 +420,8 @@ func (c *RockClient) RedeemSkin(ctx context.Context, bearerToken, upstreamTokenI
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Origin", "https://0666.pro")
req.Header.Set("Referer", "https://0666.pro/")
req.Header.Set("Origin", c.BaseURL())
req.Header.Set("Referer", c.BaseURL()+"/")
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -428,7 +456,7 @@ func (c *RockClient) QueryExchangeRecords(ctx context.Context, bearerToken strin
if pageSize <= 0 {
pageSize = 20
}
reqURL := fmt.Sprintf("%s/api/user/exchange-records?page=%d&pageSize=%d&search=%s", c.baseURL, page, pageSize, url.QueryEscape(search))
reqURL := fmt.Sprintf("%s/api/user/exchange-records?page=%d&pageSize=%d&search=%s", c.BaseURL(), page, pageSize, url.QueryEscape(search))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
@@ -460,7 +488,7 @@ func (c *RockClient) QueryExchangeRecords(ctx context.Context, bearerToken strin
// ReportAbnormal 上报异常 Token
func (c *RockClient) ReportAbnormal(ctx context.Context, bearerToken, upstreamTokenID, reason string) error {
reqURL := fmt.Sprintf("%s/api/user/tokens/%s/report", c.baseURL, url.PathEscape(upstreamTokenID))
reqURL := fmt.Sprintf("%s/api/user/tokens/%s/report", c.BaseURL(), url.PathEscape(upstreamTokenID))
payload := map[string]string{"reason": reason}
jsonBytes, _ := json.Marshal(payload)
+127
View File
@@ -0,0 +1,127 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 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
}
+168
View File
@@ -0,0 +1,168 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
)
// 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
}
// 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
}
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
package service
import (
"encoding/json"
"strings"
"time"
"affiliate_dash/internal/model"
)
// 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
}
+177
View File
@@ -0,0 +1,177 @@
package service
import (
"context"
"fmt"
"log"
"strings"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
)
// 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)
}
}
}
}
// 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
}