Files
affiliate_dash/backend/internal/service/rock_redeem.go
T
zhaoxing 3ea93c0e0d fix: 修复释放回库存的上游限频误判与不可用强制释放
- 110001/请求过于频繁归为 rate_limited,冷却期直接返回剩余秒数且不再请求上游
- 释放校验区分限频、校验失败与可强制释放:仅 upstream_status=bound 允许强制
- 弹窗对限频/校验失败改为可重试或关闭,不再展示必然失败的强制释放输入
- 提交前重新校验按钮复用 checkSeq,避免重复打开弹窗
- 修正绑定刷新测试夹具的上游时间未按业务时区格式化导致的失败
2026-11-15 14:13:39 +08:00

591 lines
22 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"
)
// RockRedeemResult 是一次自动发货尝试的结果。
type RockRedeemResult struct {
Token *model.RockToken
Response *RockRedeemResponse
Pending bool
Message string
TraceID string
}
// RedeemTokenForOrder 执行最终发货兑换(动态判定皮肤)。
//
// 上游 0666.pro 是异步兑奖:HTTP 200 + status=pending 表示「官方处理中」。
// 此时 Token 主状态保持 bound,只写 redeem_* 标记字段等待后台确认,
// 绝不写 redeemed 终态,也不新增中间状态。
func (s *RockService) RedeemTokenForOrder(ctx context.Context, orderNo, sku, expectedUID string, recorder *RockDeliveryRecorder) (*RockRedeemResult, error) {
cfg, err := s.GetConfig(ctx)
if err != nil {
return nil, err
}
if !cfg.AutoDeliveryEnabled {
return nil, fmt.Errorf("Rock 自动发货通道维护中,已关闭自动兑换")
}
// 发货确认中(待确认)的 Token 不重复提交兑换,直接返回处理中,避免重复发放。
var token model.RockToken
err = s.db.WithContext(ctx).Unscoped().
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
}
if recorder != nil {
recorder.AttachToken(&token)
}
ctx = withRockDeliveryRecorder(ctx, recorder)
if rockTokenRedeemPending(&token) {
msg := strings.TrimSpace(token.RedeemLastMessage)
if msg == "" {
msg = "上游官方处理中,请稍后刷新查看结果"
}
if recorder != nil {
recorder.Internal(ctx, "submit_request", "pending", msg, map[string]interface{}{
"order_no": orderNo,
"token_id": token.ID,
"upstream_token_id": token.UpstreamTokenID,
"expected_uid": expectedUID,
"duplicated": true,
})
}
return &RockRedeemResult{Token: &token, Pending: true, Message: msg, TraceID: recorderTraceID(recorder)}, nil
}
// 最终双重防御:核验 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
}
// 确保买家角色名存在。上游要求 account 必须与绑定角色名一致,
// 不能用 UID 兜底,否则会被上游拒绝兑换。
actualRoleName := strings.TrimSpace(token.ActualRoleName)
if actualRoleName == "" {
if ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID); refErr == nil && ref != nil {
actualRoleName = strings.TrimSpace(ref.GetRoleName())
if actualRoleName != "" {
_ = s.db.WithContext(ctx).Unscoped().Model(&token).Update("actual_role_name", actualRoleName).Error
token.ActualRoleName = actualRoleName
}
if token.ActualRoleID == "" && strings.TrimSpace(ref.GetRoleID()) != "" {
token.ActualRoleID = strings.TrimSpace(ref.GetRoleID())
}
}
}
if actualRoleName == "" {
return nil, fmt.Errorf("上游未返回绑定角色名,无法兑换发货,请刷新绑定状态后重试")
}
if recorder != nil {
recorder.Internal(ctx, "submit_request", "started", "提交上游兑换", map[string]interface{}{
"order_no": orderNo,
"token_id": token.ID,
"upstream_token_id": token.UpstreamTokenID,
"expected_uid": trimmedExpected,
"actual_role_id": token.ActualRoleID,
"actual_role_name": actualRoleName,
"skin": targetSkin,
"currency": "幸运币",
})
}
// 调上游兑换发货接口(携带 Bearer Token 鉴权)
redeemResp, err := s.client.RedeemSkin(ctx, cfg.BearerToken, token.UpstreamTokenID, targetSkin, "幸运币", actualRoleName)
if err != nil {
if recorder != nil {
recorder.Internal(ctx, "redeem_result", "failed", err.Error(), nil)
}
if isRockRateLimitError(err) {
markRockUpstreamRateLimited()
}
wrapped := fmt.Errorf("调用上游兑换失败: %w", err)
if _, incidentErr := s.RecordRockDeliveryIncident(ctx, &token, orderNo, wrapped); incidentErr != nil {
return nil, fmt.Errorf("%w;记录发货异常失败: %v", wrapped, incidentErr)
}
return nil, wrapped
}
// 上游已受理但官方仍在异步处理:进入 redeeming,等待后台轮询确认。
if redeemResp.IsPending() {
msg := redeemResp.FailureMessage("官方处理中,请稍后刷新查看结果")
var pendingErr error
token, pendingErr = s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
if pendingErr != nil {
return nil, fmt.Errorf("上游已受理兑换,但记录发货确认状态失败: %w", pendingErr)
}
if recorder != nil {
recorder.Internal(ctx, "redeem_result", "pending", msg, map[string]interface{}{
"upstream_status": redeemResp.Status,
"pending": true,
})
}
return &RockRedeemResult{Token: &token, Response: redeemResp, Pending: true, Message: msg, TraceID: recorderTraceID(recorder)}, nil
}
// 上游返回成功后再 refresh 一次,确认 token 已真正进入 redeemed 且有履约流水。
ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID)
if refErr != nil {
if isRockRateLimitError(refErr) {
markRockUpstreamRateLimited()
}
if recorder != nil {
recorder.Internal(ctx, "redeem_confirm", "pending", "兑换成功但二次确认失败,等待后台确认: "+refErr.Error(), nil)
}
// 刷新失败不直接否认可信的成功响应,但也不立即写终态;交给后台轮询确认。
msg := "上游已受理,正在等待发货结果确认"
token, pendingErr := s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
if pendingErr != nil {
return nil, fmt.Errorf("上游已受理兑换,但记录发货确认状态失败: %w", pendingErr)
}
return &RockRedeemResult{Token: &token, Response: redeemResp, Pending: true, Message: msg, TraceID: recorderTraceID(recorder)}, nil
}
deliveryEvidence := rockCurrentCycleDelivery(&token, ref)
if ref != nil && !deliveryEvidence.Current && !deliveryEvidence.Ambiguous {
msg := "上游返回成功但尚未确认发货,正在等待官方结果"
if recorder != nil {
recorder.Internal(ctx, "redeem_confirm", "pending", msg, map[string]interface{}{
"upstream_status": ref.Token.Status,
"redeemed_at": ref.Token.RedeemedAt,
})
}
token, pendingErr := s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
if pendingErr != nil {
return nil, fmt.Errorf("上游已受理兑换,但记录发货确认状态失败: %w", pendingErr)
}
return &RockRedeemResult{Token: &token, Response: redeemResp, Pending: true, Message: msg, TraceID: recorderTraceID(recorder)}, nil
}
fulfillID := deliveryEvidence.FulfillID
redeemedAt := time.Now()
if !deliveryEvidence.At.IsZero() {
redeemedAt = deliveryEvidence.At
}
// 状态跃迁至 redeemed(只有拿到明确成功/明显履约结果才执行)
fromStatus := token.Status
now := time.Now()
if err := s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
"status": model.RockStatusRedeemed,
"upstream_fulfill_id": fulfillID,
"target_skin": targetSkin,
"target_currency": "幸运币",
"target_currency_quantity": 1,
"redeemed_at": redeemedAt,
"redeem_requested_at": nil,
"redeem_checked_at": now,
"redeem_next_retry_at": nil,
"redeem_last_message": "",
"redeem_last_error": "",
"redeem_timeout_warned_at": nil,
}).Error; err != nil {
return nil, fmt.Errorf("上游已发货,但更新本地 Token 状态失败: %w", err)
}
writeRockTokenStatusChange(s.db.WithContext(ctx), &token, fromStatus, model.RockStatusRedeemed,
rockTokenSourceRedeem, "自动发货: "+targetSkin, false, nil)
if recorder != nil {
recorder.Internal(ctx, "state_change", "success", "发货完成", map[string]interface{}{
"from_status": fromStatus,
"to_status": model.RockStatusRedeemed,
"upstream_fulfill_id": fulfillID,
"redeemed_at": redeemedAt.Format("2006-01-02 15:04:05"),
})
}
token.Status = model.RockStatusRedeemed
token.UpstreamFulfillID = fulfillID
token.TargetSkin = targetSkin
token.TargetCurrency = "幸运币"
token.TargetCurrencyQuantity = 1
token.RedeemedAt = &redeemedAt
return &RockRedeemResult{Token: &token, Response: redeemResp, Pending: false, Message: "发货成功", TraceID: recorderTraceID(recorder)}, nil
}
// markTokenRedeemPending 记录「发货确认中」标记(不改变 Token 状态)。
func (s *RockService) markTokenRedeemPending(ctx context.Context, token model.RockToken, targetSkin, message string, recorder *RockDeliveryRecorder) (model.RockToken, error) {
now := time.Now()
requestedAt := token.RedeemRequestedAt
if requestedAt == nil {
requestedAt = &now
}
nextRetry := now.Add(30 * time.Second)
if err := s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{
"target_skin": targetSkin,
"target_currency": "幸运币",
"redeem_requested_at": requestedAt,
"redeem_checked_at": now,
"redeem_attempts": 0,
"redeem_next_retry_at": nextRetry,
"redeem_last_message": strings.TrimSpace(message),
"redeem_last_error": "",
"updated_at": now,
}).Error; err != nil {
return token, err
}
if recorder != nil {
recorder.Internal(ctx, "redeem_pending", "pending", strings.TrimSpace(message), map[string]interface{}{
"status": token.Status,
"redeem_requested_at": requestedAt.Format("2006-01-02 15:04:05"),
"next_retry_at": nextRetry.Format("2006-01-02 15:04:05"),
})
}
token.TargetSkin = targetSkin
token.TargetCurrency = "幸运币"
token.RedeemRequestedAt = requestedAt
token.RedeemCheckedAt = &now
token.RedeemAttempts = 0
token.RedeemNextRetryAt = &nextRetry
token.RedeemLastMessage = strings.TrimSpace(message)
token.RedeemLastError = ""
return token, nil
}
// rockTokenRedeemPending 判断 Token 是否处于「发货确认中」:
// 已提交兑换、还没拿到履约结果、主状态仍是 bound。
func rockTokenRedeemPending(token *model.RockToken) bool {
if token == nil {
return false
}
if token.RedeemRequestedAt == nil || token.RedeemedAt != nil {
return false
}
return token.Status == model.RockStatusBound
}
func recorderTraceID(recorder *RockDeliveryRecorder) string {
if recorder == nil {
return ""
}
return recorder.TraceID
}
// extractRockFulfillID 从上游客服端返回的 deliveryList 中取本次履约流水。
// 只取本次领取之后产生的记录;历史流水不能作为本轮发货依据。
func extractRockFulfillID(ref *RockRefreshResponse, baseline time.Time) int64 {
if ref == nil || len(ref.DeliveryList) == 0 {
return 0
}
var best int64
var bestAt time.Time
for _, item := range ref.DeliveryList {
if item.FulfillID <= 0 {
continue
}
at, ok := parseRockTime(item.Time)
if ok && !baseline.IsZero() && at.Before(baseline.Add(-time.Minute)) {
continue
}
if at.After(bestAt) || (at.Equal(bestAt) && item.FulfillID > best) {
best = item.FulfillID
bestAt = at
}
}
if best > 0 {
return best
}
if !baseline.IsZero() {
return 0
}
for _, item := range ref.DeliveryList {
if item.FulfillID > best {
best = item.FulfillID
}
}
return best
}
// RockReleaseCheckResult 是释放回库存前的上游校验结果。
type RockReleaseCheckResult struct {
CanRelease bool `json:"can_release"`
UpstreamStatus string `json:"upstream_status"` // unbound / bound / redeemed / abnormal / not_found / check_failed / rate_limited
RoleName string `json:"role_name,omitempty"`
RoleID string `json:"role_id,omitempty"`
Message string `json:"message"`
// RetryAfterSeconds 仅在上游限频(110001)时返回,提示前端冷却剩余秒数。
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
}
// RockReleaseRequiresForceError 表示上游状态不允许直接释放,需要人工强制确认。
type RockReleaseRequiresForceError struct {
Check RockReleaseCheckResult
}
func (e *RockReleaseRequiresForceError) Error() string {
return e.Check.Message
}
// CheckReleaseToken 在执行释放前校验上游状态,供前端展示人工确认弹窗。
func (s *RockService) CheckReleaseToken(ctx context.Context, tokenID uint) (*RockReleaseCheckResult, error) {
_, check, err := s.checkReleaseToken(ctx, tokenID)
if err != nil {
return nil, err
}
return check, nil
}
// checkReleaseToken 加载 Token 并调用上游确认是否允许回库。
func (s *RockService) checkReleaseToken(ctx context.Context, tokenID uint) (*model.RockToken, *RockReleaseCheckResult, error) {
var token model.RockToken
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
return nil, nil, err
}
if token.Status != model.RockStatusUsing && token.Status != model.RockStatusBound {
return nil, nil, fmt.Errorf("仅使用中或已绑定的链接可以释放回库存")
}
if rockTokenRedeemPending(&token) {
return nil, nil, fmt.Errorf("发货确认中的 Token 禁止释放回库存")
}
if token.RedeemedAt != nil {
return nil, nil, fmt.Errorf("本地已有发货记录的 Token 禁止释放回库存")
}
cfg, err := s.GetConfig(ctx)
if err != nil {
return nil, nil, err
}
if strings.TrimSpace(cfg.BearerToken) == "" || strings.TrimSpace(token.UpstreamTokenID) == "" {
return &token, &RockReleaseCheckResult{
UpstreamStatus: "check_failed",
Message: "缺少上游凭证,无法确认是否可以释放",
}, nil
}
// 全局频控冷却期内不再请求上游,直接返回可重试的限频状态。
if remain := rockUpstreamRateLimitRemaining(); remain > 0 {
seconds := int((remain + time.Second - 1) / time.Second)
return &token, &RockReleaseCheckResult{
UpstreamStatus: "rate_limited",
Message: fmt.Sprintf("上游限频冷却中,请 %d 秒后重试", seconds),
RetryAfterSeconds: seconds,
}, nil
}
ref, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID)
if err != nil {
if isRockTokenNotFoundError(err) {
return &token, &RockReleaseCheckResult{
UpstreamStatus: "not_found",
Message: "上游卡密不存在(可能已失效)",
}, nil
}
// 110001 是上游全局频控,不是 Token 状态问题,单独归为可重试状态。
if isRockRateLimitError(err) {
markRockUpstreamRateLimited()
seconds := int((rockUpstreamRateLimitRemaining() + time.Second - 1) / time.Second)
if seconds <= 0 {
seconds = int(rockRateLimitCooldown / time.Second)
}
return &token, &RockReleaseCheckResult{
UpstreamStatus: "rate_limited",
Message: fmt.Sprintf("上游请求过于频繁(110001),请 %d 秒后重试", seconds),
RetryAfterSeconds: seconds,
}, nil
}
return &token, &RockReleaseCheckResult{
UpstreamStatus: "check_failed",
Message: "确认上游状态失败: " + err.Error(),
}, nil
}
if ref == nil {
return &token, &RockReleaseCheckResult{
UpstreamStatus: "check_failed",
Message: "上游未返回状态,无法确认是否可以释放",
}, nil
}
upstreamStatus := strings.ToLower(strings.TrimSpace(ref.Token.Status))
roleName := ref.GetRoleName()
roleID := ref.GetRoleID()
deliveryEvidence := rockCurrentCycleDelivery(&token, ref)
currentBound, _ := rockCurrentCycleBinding(&token, ref)
baselineUnknown := rockClaimBaseline(&token).IsZero()
matchesInitialSnapshot := rockBindingMatchesInitialSnapshot(&token, ref)
switch {
case upstreamStatus == model.RockStatusAbnormal:
return &token, &RockReleaseCheckResult{
UpstreamStatus: "abnormal",
RoleName: roleName,
RoleID: roleID,
Message: "上游状态异常(已上报失效)",
}, nil
case deliveryEvidence.Current || deliveryEvidence.Ambiguous:
message := "上游已发货"
if deliveryEvidence.Ambiguous {
message = "上游显示已发货,但无法确认是否属于本轮,禁止释放"
}
return &token, &RockReleaseCheckResult{
UpstreamStatus: "redeemed",
RoleName: roleName,
RoleID: roleID,
Message: message,
}, nil
case currentBound:
return &token, &RockReleaseCheckResult{
UpstreamStatus: "bound",
RoleName: roleName,
RoleID: roleID,
Message: fmt.Sprintf("上游已扫码绑定角色「%s」(%s)", displayRockRole(roleName, roleID), roleID),
}, nil
case baselineUnknown && !matchesInitialSnapshot &&
(ref.HasBindingSignal() || strings.TrimSpace(rockBindingChangedAt(ref)) != ""):
return &token, &RockReleaseCheckResult{
UpstreamStatus: "bound",
RoleName: roleName,
RoleID: roleID,
Message: "上游存在绑定信息,但缺少本轮 using_at,无法自动判断,请人工核对后强制释放",
}, nil
default:
return &token, &RockReleaseCheckResult{
CanRelease: true,
UpstreamStatus: "unbound",
Message: "上游未绑定,可以释放回库存",
}, nil
}
}
// ReleaseUnboundUsingToken 把使用中的提货链接释放回可用库存。
//
// 非强制释放前必须通过上游校验;force=true 表示人工已确认上游状态并承担风险。
func (s *RockService) ReleaseUnboundUsingToken(ctx context.Context, tokenID uint, force bool, actorUserID *uint) error {
_, check, err := s.checkReleaseToken(ctx, tokenID)
if err != nil {
return err
}
if !check.CanRelease && !force {
return &RockReleaseRequiresForceError{Check: *check}
}
if check.UpstreamStatus == "rate_limited" {
return errors.New("上游限频(110001),请稍后重新校验通过后再释放")
}
switch check.UpstreamStatus {
case "redeemed", "abnormal", "not_found", "check_failed":
return fmt.Errorf("上游状态为 %s,禁止释放回库存", check.UpstreamStatus)
}
reason := "人工释放(上游未绑定)"
if force {
reason = fmt.Sprintf("人工强制释放(上游状态: %s)", check.UpstreamStatus)
}
var released model.RockToken
var orderNo string
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var token model.RockToken
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&token, tokenID).Error; err != nil {
return err
}
if token.Status != model.RockStatusUsing && token.Status != model.RockStatusBound {
return fmt.Errorf("仅使用中或已绑定的链接可以释放回库存")
}
if rockTokenRedeemPending(&token) {
return fmt.Errorf("发货确认中的 Token 禁止释放回库存")
}
if token.RedeemedAt != nil {
return fmt.Errorf("本地已有发货记录的 Token 禁止释放回库存")
}
fromStatus := token.Status
orderNo = token.AssignedOrderNo
now := time.Now()
if err := tx.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,
"using_at": nil,
"redeemed_at": nil,
"upstream_fulfill_id": 0,
"restocked_at": now, // 人工释放回库存:下次分配优先使用
"redeem_requested_at": nil,
"redeem_checked_at": nil,
"redeem_attempts": 0,
"redeem_next_retry_at": nil,
"redeem_last_message": "",
"redeem_last_error": "",
"redeem_timeout_warned_at": nil,
"updated_at": now,
}).Error; err != nil {
return err
}
released = token
writeRockTokenStatusChange(tx, &token, fromStatus, model.RockStatusAvailable,
rockTokenSourceRelease, reason, force, actorUserID)
if err := resolveOpenRockIncidents(tx, tokenID, "release", reason, actorUserID); err != nil {
return err
}
return writeAudit(tx, nil, actorUserID, nil, "rock_token.release", "rock_token",
fmt.Sprintf("%d", token.ID), map[string]interface{}{
"order_no": orderNo,
"forced": force,
"upstream_status": check.UpstreamStatus,
"upstream_role_id": check.RoleID,
"reason": reason,
})
})
if err != nil {
return err
}
s.refreshCache.Delete(released.UpstreamTokenID)
s.detachReleasedRockToken(ctx, orderNo, released.ID)
log.Printf("[RockService] 人工释放库存: token_id=%d order=%s forced=%v upstream=%s",
released.ID, orderNo, force, check.UpstreamStatus)
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
}