package service import ( "context" "errors" "fmt" "net/http" "strings" "time" "affiliate_dash/internal/model" ) // TokenBindingRefreshResult 是人工“刷新绑定状态”的返回结果。 type TokenBindingRefreshResult struct { Status string `json:"status"` Bound bool `json:"bound"` RoleName string `json:"role_name"` RoleID string `json:"role_id"` Channel string `json:"channel"` Changed bool `json:"changed"` Warning bool `json:"warning"` Message string `json:"message"` RedeemedAt string `json:"redeemed_at,omitempty"` } func canRefreshRockBinding(status string) bool { switch status { case model.RockStatusUsing, model.RockStatusBound, model.RockStatusMismatch: return true default: return false } } // RefreshTokenBinding 通过兑换码调用上游 refresh,人工同步一次绑定状态。 // // 规则: // - 上游已发货 → 本地同步 redeemed; // - 上游已绑定且本地有预期 UID → 一致转 bound,不一致转 mismatch; // - 上游已绑定但本地没有预期 UID → 只同步角色信息,状态保持不变; // - 上游未绑定 → 本地状态保持不变(不自动把 bound 退回 using)。 func (s *RockService) RefreshTokenBinding(ctx context.Context, tokenID uint) (*TokenBindingRefreshResult, error) { var token model.RockToken if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { return nil, err } if !canRefreshRockBinding(token.Status) { return nil, errors.New("仅扫码使用中 / 已授权绑定 / 绑定错误的 Token 可以刷新绑定状态") } if strings.TrimSpace(token.RedeemCode) == "" { return nil, errors.New("该 Token 缺少兑换码,无法刷新绑定状态") } cfg, err := s.GetConfig(ctx) if err != nil { return nil, err } ref, err := s.client.RefreshRedeemBinding(ctx, token.RedeemCode) if err != nil { // 公开端接口不可用时,降级到管理端 refresh(需要 upstream_token_id + Bearer)。 var statusErr *RockUpstreamStatusError if errors.As(err, &statusErr) && (statusErr.StatusCode == http.StatusNotFound || statusErr.StatusCode == http.StatusMethodNotAllowed) && strings.TrimSpace(token.UpstreamTokenID) != "" { ref, err = s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID) } if err != nil { return nil, fmt.Errorf("刷新上游绑定状态失败: %w", err) } } if ref == nil { return nil, errors.New("上游未返回刷新结果") } // 上游请求期间本地状态可能已被买家轮询改写,落库前重新读取。 if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil { return nil, err } if !canRefreshRockBinding(token.Status) { return nil, fmt.Errorf("Token 状态已变为 %s,无法再刷新绑定状态", token.Status) } now := time.Now() originalStatus := token.Status originalRoleName := token.ActualRoleName originalRoleID := token.ActualRoleID originalChannel := token.ActualChannel actualRoleName := ref.GetRoleName() actualRoleID := ref.GetRoleID() actualChannel := ref.GetChannel() expectedUID := strings.TrimSpace(token.ExpectedUID) updates := map[string]interface{}{ "updated_at": now, } // 只有上游确实绑定时才同步角色字段;未绑定时 Region 可能只是领取区域,不能当成绑定渠道。 if ref.IsBound() { if actualRoleName != "" { updates["actual_role_name"] = actualRoleName } if actualRoleID != "" { updates["actual_role_id"] = actualRoleID } if actualChannel != "" { updates["actual_channel"] = actualChannel } } result := &TokenBindingRefreshResult{ Status: token.Status, Bound: ref.IsBound(), } if ref.IsBound() { result.RoleName = actualRoleName result.RoleID = actualRoleID result.Channel = actualChannel } upstreamStatus := strings.ToLower(strings.TrimSpace(ref.Token.Status)) redeemedAtRaw := strings.TrimSpace(ref.Token.RedeemedAt) switch { case upstreamStatus == model.RockStatusRedeemed || redeemedAtRaw != "": redeemedAt, ok := parseRockTime(redeemedAtRaw) if !ok { redeemedAt = now } updates["status"] = model.RockStatusRedeemed updates["redeemed_at"] = redeemedAt if len(ref.DeliveryList) > 0 { if fulfillID := ref.DeliveryList[len(ref.DeliveryList)-1].FulfillID; fulfillID > 0 { updates["upstream_fulfill_id"] = fulfillID } } result.Status = model.RockStatusRedeemed result.RedeemedAt = redeemedAt.Format("2006-01-02 15:04:05") result.Message = "上游已发货,本地状态已同步为已交付" case ref.IsBound() && actualRoleID == "": result.Warning = true result.Message = "上游返回已绑定但缺少角色 UID,未改动本地状态,请人工核对" case ref.IsBound() && expectedUID == "": // 没有预期 UID 无法核验,只同步角色信息。 result.Warning = true result.Message = fmt.Sprintf( "上游已绑定「%s」(UID %s),但本地缺少预期 UID,已同步角色信息,请人工核对", displayRockRole(actualRoleName, actualRoleID), actualRoleID, ) case ref.IsBound() && token.Status == model.RockStatusMismatch && actualRoleID == expectedUID: updates["status"] = model.RockStatusBound updates["mismatch_at"] = nil updates["mismatch_reason"] = "" if token.BoundAt == nil { updates["bound_at"] = resolveRockBoundAt(ref, now) } result.Status = model.RockStatusBound result.Message = "上游绑定 UID 与预期一致,已解除绑定错误" case ref.IsBound() && token.Status == model.RockStatusUsing && !rockBuyerNewlyBound(&token, ref): result.Warning = true result.Message = "检测到历史绑定残留(非本次扫码),已同步角色信息,状态保持不变" case ref.IsBound() && actualRoleID != expectedUID: reason := fmt.Sprintf("预期UID(%s) 与 扫码UID(%s/%s) 不一致", expectedUID, actualRoleName, actualRoleID) updates["status"] = model.RockStatusMismatch updates["mismatch_reason"] = reason if token.MismatchAt == nil { updates["mismatch_at"] = now } result.Status = model.RockStatusMismatch result.Warning = true result.Message = fmt.Sprintf( "上游绑定「%s」(UID %s) 与预期 UID(%s) 不一致,已标记绑定错误", displayRockRole(actualRoleName, actualRoleID), actualRoleID, expectedUID, ) case ref.IsBound(): updates["status"] = model.RockStatusBound updates["mismatch_at"] = nil updates["mismatch_reason"] = "" if token.BoundAt == nil { updates["bound_at"] = resolveRockBoundAt(ref, now) } result.Status = model.RockStatusBound result.Message = "上游已绑定,UID 核验一致" default: result.Message = "上游仍未绑定,本地状态保持不变" } result.Changed = result.Status != originalStatus || (ref.IsBound() && actualRoleName != "" && actualRoleName != originalRoleName) || (ref.IsBound() && actualRoleID != "" && actualRoleID != originalRoleID) || (ref.IsBound() && actualChannel != "" && actualChannel != originalChannel) if err := s.db.WithContext(ctx).Model(&token).Updates(updates).Error; err != nil { return nil, err } _ = writeAudit(s.db.WithContext(ctx), nil, nil, nil, "rock_token.refresh_binding", "rock_token", fmt.Sprintf("%d", token.ID), map[string]interface{}{ "status": result.Status, "role_id": result.RoleID, "message": result.Message, }) return result, nil } func displayRockRole(name, roleID string) string { if strings.TrimSpace(name) != "" { return strings.TrimSpace(name) } return strings.TrimSpace(roleID) } func resolveRockBoundAt(ref *RockRefreshResponse, fallback time.Time) time.Time { if ref != nil { if t, ok := parseRockTime(ref.Token.BindChangedAt); ok { return t } } return fallback }