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 }