- 新增迁移 028:历史 redeeming 回退为 bound,重建 pending 部分索引
- pending 语义改为 bound + redeem_requested_at 标记,不再写状态与状态日志
- 新增 anomaly 虚拟筛选项:基于状态日志派生异常标记,不改状态、不落库
- 状态列追加「⚠️ 异常」标签,统计新增 anomaly_stock
- 清理前后端 redeeming 相关分支与文案
513 lines
18 KiB
Go
513 lines
18 KiB
Go
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
|
||
}
|