293 lines
9.8 KiB
Go
293 lines
9.8 KiB
Go
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
|
||
}
|