Files
affiliate_dash/backend/internal/service/rock_redeem.go
T

293 lines
9.8 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"
)
// 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).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
}
// 最终双重防御:核验 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).Unscoped().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
fromStatus := token.Status
_ = 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": now,
}).Error
writeRockTokenStatusChange(s.db.WithContext(ctx), &token, fromStatus, model.RockStatusRedeemed,
rockTokenSourceRedeem, "自动发货: "+targetSkin, false, nil)
return redeemResp, nil
}
// 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
}