Files
affiliate_dash/backend/internal/service/rock_redeem.go
T
zhaoxing a9473990be refactor: 移除 Rock 发货处理中状态,新增异常派生筛选标记
- 新增迁移 028:历史 redeeming 回退为 bound,重建 pending 部分索引
- pending 语义改为 bound + redeem_requested_at 标记,不再写状态与状态日志
- 新增 anomaly 虚拟筛选项:基于状态日志派生异常标记,不改状态、不落库
- 状态列追加「⚠️ 异常」标签,统计新增 anomaly_stock
- 清理前后端 redeeming 相关分支与文案
2026-11-14 15:40:19 +08:00

513 lines
18 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()
}
return nil, fmt.Errorf("调用上游兑换失败: %w", err)
}
// 上游已受理但官方仍在异步处理:进入 redeeming,等待后台轮询确认。
if redeemResp.IsPending() {
msg := redeemResp.FailureMessage("官方处理中,请稍后刷新查看结果")
now := time.Now()
token = s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
if recorder != nil {
recorder.Internal(ctx, "redeem_result", "pending", msg, map[string]interface{}{
"upstream_status": redeemResp.Status,
"pending": true,
})
}
_ = now
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 = s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
return &RockRedeemResult{Token: &token, Response: redeemResp, Pending: true, Message: msg, TraceID: recorderTraceID(recorder)}, nil
}
if ref != nil && !rockUpstreamRedeemed(ref) {
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 = s.markTokenRedeemPending(ctx, token, targetSkin, msg, recorder)
return &RockRedeemResult{Token: &token, Response: redeemResp, Pending: true, Message: msg, TraceID: recorderTraceID(recorder)}, nil
}
fulfillID := extractRockFulfillID(ref, rockClaimBaseline(&token))
redeemedAt := time.Now()
if ref != nil {
if parsed, ok := parseRockTime(ref.Token.RedeemedAt); ok {
redeemedAt = parsed
}
}
// 状态跃迁至 redeemed(只有拿到明确成功/明显履约结果才执行)
fromStatus := token.Status
now := time.Now()
_ = 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
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 {
now := time.Now()
requestedAt := token.RedeemRequestedAt
if requestedAt == nil {
requestedAt = &now
}
nextRetry := now.Add(30 * time.Second)
_ = 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
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
}
// 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
}
// rockUpstreamRedeemed 判断上游是否已明确完成发货。
func rockUpstreamRedeemed(ref *RockRefreshResponse) bool {
if ref == nil {
return false
}
if strings.EqualFold(strings.TrimSpace(ref.Token.Status), model.RockStatusRedeemed) {
return true
}
return strings.TrimSpace(ref.Token.RedeemedAt) != ""
}
// 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
}
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
RoleName string `json:"role_name,omitempty"`
RoleID string `json:"role_id,omitempty"`
Message string `json:"message"`
}
// 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 {
return nil, nil, fmt.Errorf("仅扫码使用中的链接可以释放回库存")
}
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
}
ref, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID)
if err != nil {
if isRockTokenNotFoundError(err) {
return &token, &RockReleaseCheckResult{
UpstreamStatus: "not_found",
Message: "上游卡密不存在(可能已失效)",
}, 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()
switch {
case upstreamStatus == model.RockStatusAbnormal:
return &token, &RockReleaseCheckResult{
UpstreamStatus: "abnormal",
RoleName: roleName,
RoleID: roleID,
Message: "上游状态异常(已上报失效)",
}, nil
case upstreamStatus == model.RockStatusRedeemed || strings.TrimSpace(ref.Token.RedeemedAt) != "":
return &token, &RockReleaseCheckResult{
UpstreamStatus: "redeemed",
RoleName: roleName,
RoleID: roleID,
Message: "上游已发货",
}, nil
case rockBuyerNewlyBound(&token, ref):
return &token, &RockReleaseCheckResult{
UpstreamStatus: "bound",
RoleName: roleName,
RoleID: roleID,
Message: fmt.Sprintf("上游已扫码绑定角色「%s」(%s)", displayRockRole(roleName, roleID), roleID),
}, 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}
}
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 {
return fmt.Errorf("仅扫码使用中的链接可以释放回库存")
}
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, // 人工释放回库存:下次分配优先使用
"updated_at": now,
}).Error; err != nil {
return err
}
released = token
writeRockTokenStatusChange(tx, &token, model.RockStatusUsing, model.RockStatusAvailable,
rockTokenSourceRelease, reason, force, actorUserID)
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
}