Files
affiliate_dash/backend/internal/service/rock_redeem.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

169 lines
5.6 KiB
Go

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
}