package service import ( "context" "encoding/json" "errors" "fmt" "log" "strings" "time" "affiliate_dash/internal/model" "gorm.io/gorm" "gorm.io/gorm/clause" ) // BindingSnapshot 保存分配 Token 时上游的绑定快照 type BindingSnapshot struct { Bound bool `json:"bound"` RoleName string `json:"role_name"` RoleID string `json:"role_id"` Channel string `json:"channel"` BindChangedAt string `json:"bind_changed_at"` } // rockOrderTokenLockedMessage 一单只能占用一个提货号。绑定失败后不再发新号。 const rockOrderTokenLockedMessage = "该订单提货链接已绑定失败,不能更换新的提货号,请联系客服处理" // AssignTokenForOrder 为指定订单锁定分配一个可用 Token。 // 同一个订单号无论怎么更新 UID,都只能占用一个提货号:using/bound 复用原号并改期望 UID; // mismatch、已发货或异常则拒绝再分配,需客服人工重置后才能重新领取。 func (s *RockService) AssignTokenForOrder(ctx context.Context, orderNo, expectedUID string) (*model.RockToken, *RockRedeemDetailResponse, error) { cfg, err := s.GetConfig(ctx) if err != nil { return nil, nil, err } if !cfg.AutoDeliveryEnabled { return nil, nil, fmt.Errorf("Rock 自动发货通道已维护关闭") } var token model.RockToken // 1. 该订单已经占用过提货号时,禁止再领新号。 err = s.db.WithContext(ctx).Unscoped(). Where("assigned_order_no = ? AND status IN ?", orderNo, []string{ model.RockStatusUsing, model.RockStatusBound, model.RockStatusMismatch, model.RockStatusRedeemed, model.RockStatusAbnormal, }). Order("id DESC"). First(&token).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, err } if err == nil { switch token.Status { case model.RockStatusAbnormal: return nil, nil, errors.New(rockOrderTokenLockedMessage) case model.RockStatusRedeemed: return nil, nil, errors.New("该订单已发货,不能再次领取提货号") case model.RockStatusMismatch: trimmedUID := strings.TrimSpace(expectedUID) actualUID := strings.TrimSpace(token.ActualRoleID) // 方案 B:买家确认纠正为当前扫码角色的 UID if trimmedUID != "" && actualUID != "" && trimmedUID == actualUID { now := time.Now() token.ExpectedUID = trimmedUID token.Status = model.RockStatusBound token.MismatchReason = "" token.MismatchAt = nil token.BoundAt = &now _ = s.db.WithContext(ctx).Unscoped().Model(&token).Updates(map[string]interface{}{ "expected_uid": trimmedUID, "status": model.RockStatusBound, "mismatch_reason": "", "mismatch_at": nil, "bound_at": now, "updated_at": now, }).Error log.Printf("[RockService] 买家确认更换 UID 为扫码角色 UID 成功,解除阻断: order=%s uid=%s role=%s", orderNo, trimmedUID, token.ActualRoleName) } else { roleDesc := token.ActualRoleName if roleDesc == "" { roleDesc = actualUID } if actualUID != "" { return nil, nil, fmt.Errorf("该订单提货链接已扫码绑定角色「%s」(%s)。如确系本人账号,请确认将填写 UID 修改为 %s;不能更换为其他账号,如需更换请联系客服处理", roleDesc, actualUID, actualUID) } return nil, nil, errors.New(rockOrderTokenLockedMessage) } default: trimmedUID := strings.TrimSpace(expectedUID) if token.Status == model.RockStatusBound && trimmedUID != "" && token.ExpectedUID != trimmedUID { return nil, nil, errors.New("该订单提货号已绑定,不能更换 UID,请联系客服处理") } if trimmedUID != "" && token.ExpectedUID != trimmedUID { token.ExpectedUID = trimmedUID _ = s.db.WithContext(ctx).Unscoped().Model(&token).Update("expected_uid", trimmedUID).Error } } } // 2. 若尚未分配,锁定一个可用 Token (行锁防并发超卖) if errors.Is(err, gorm.ErrRecordNotFound) { err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}). Where("status = ? AND color = ?", model.RockStatusAvailable, model.RockColorBlack). Order("id ASC"). First(&token).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("Rock平台库存不足,请联系管理员补货") } return err } now := time.Now() token.Status = model.RockStatusUsing token.AssignedOrderNo = orderNo token.ExpectedUID = strings.TrimSpace(expectedUID) token.UsingAt = &now return tx.Save(&token).Error }) if err != nil { return nil, nil, err } // 检查水位触发异步自动补货 go s.CheckStockAndAutoPurchase(context.Background()) } // 3. 调上游获取绑定活动链接 detail, err := s.client.GetRedeemDetail(ctx, token.RedeemCode) if err != nil { return nil, nil, fmt.Errorf("获取上游活动绑定链接失败: %w", err) } s.rememberBindLink(token.ID, detail.BindURL, detail.QRURL) // 4. 首次分配记录上游初始绑定快照(防范 Ghost-Binding 历史残留数据) if token.InitialBoundSnapshot == "" && cfg.BearerToken != "" { ref, refErr := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) if refErr == nil && ref != nil { snap := BindingSnapshot{ Bound: ref.IsBound(), RoleName: ref.GetRoleName(), RoleID: ref.GetRoleID(), Channel: ref.GetChannel(), BindChangedAt: ref.Token.BindChangedAt, } snapBytes, _ := json.Marshal(snap) token.InitialBoundSnapshot = string(snapBytes) _ = s.db.WithContext(ctx).Unscoped().Model(&token).Update("initial_bound_snapshot", token.InitialBoundSnapshot).Error } } return &token, detail, nil }