package service import ( "context" "errors" "fmt" "math" "strings" "sync" "time" "affiliate_dash/internal/model" "gorm.io/gorm" ) // RockService 负责管理 Rock 平台的配置、库存池、采购、UID强一致风控与状态机 type RockService struct { db *gorm.DB client *RockClient mu sync.Mutex refreshCache sync.Map // key: upstreamTokenID string, value: rockRefreshCacheItem bindLinkCache sync.Map // key: token ID uint, value: rockBindLinkCacheItem lastSummary *PurchaseSummary } func NewRockService(db *gorm.DB, client *RockClient) *RockService { return &RockService{ db: db, client: client, } } // RockStatsResponse 看板与库存统计返回 type RockStatsResponse struct { AvailableStock int64 `json:"available_stock"` UsingStock int64 `json:"using_stock"` BoundStock int64 `json:"bound_stock"` RedeemedStock int64 `json:"redeemed_stock"` MismatchStock int64 `json:"mismatch_stock"` // 绑定错误(待排查) AbnormalStock int64 `json:"abnormal_stock"` DeletedStock int64 `json:"deleted_stock"` // 回收站中已软删除的数量 UpstreamPoints float64 `json:"upstream_points"` AutoDeliveryEnabled bool `json:"auto_delivery_enabled"` TokenWarningStock int `json:"token_warning_stock"` AutoPurchaseEnabled bool `json:"auto_purchase_enabled"` // 自动补货冷却状态:供页面提示“下次可补货时间” AutoPurchaseCooldownSeconds int64 `json:"auto_purchase_cooldown_seconds"` NextAutoPurchaseAt string `json:"next_auto_purchase_at,omitempty"` Username string `json:"username"` LastHeartbeatAt string `json:"last_heartbeat_at,omitempty"` LastPurchaseError string `json:"last_purchase_error,omitempty"` LastPurchaseErrorAt string `json:"last_purchase_error_at,omitempty"` } type RockDashboardResult struct { AvailableBlack int `json:"available_black"` AvailableBlue int `json:"available_blue"` BlackTokenCost float64 `json:"black_token_cost"` BlueTokenCost float64 `json:"blue_token_cost"` CostUpdated bool `json:"cost_updated"` } // RockConfigValidationError 表示 Rock 配置参数不合法,handler 会转成 400。 type RockConfigValidationError struct { Message string } func (e *RockConfigValidationError) Error() string { return e.Message } type UpdateRockConfigReq struct { AutoDeliveryEnabled *bool `json:"auto_delivery_enabled"` BaseURL *string `json:"base_url"` BearerToken *string `json:"bearer_token"` MinPointsWarning *float64 `json:"min_points_warning"` TokenWarningStock *int `json:"token_warning_stock"` UsingWarnDays *int `json:"using_warn_days"` UsingDangerDays *int `json:"using_danger_days"` AutoPurchaseEnabled *bool `json:"auto_purchase_enabled"` AutoPurchaseCount *int `json:"auto_purchase_count"` PurchaseCooldownMinutes *int `json:"purchase_cooldown_minutes"` } // GetConfig 获取当前配置,若无则初始化 func (s *RockService) GetConfig(ctx context.Context) (*model.RockConfig, error) { var cfg model.RockConfig err := s.db.WithContext(ctx).First(&cfg, 1).Error if errors.Is(err, gorm.ErrRecordNotFound) { cfg = model.RockConfig{ ID: 1, AutoDeliveryEnabled: true, BaseURL: model.RockDefaultBaseURL, BearerToken: "", MinPointsWarning: 1050.00, BlackTokenCost: 20.00, TokenWarningStock: 10, UsingWarnDays: 3, UsingDangerDays: 5, AutoPurchaseEnabled: false, AutoPurchaseCount: 50, PurchaseCooldownMinutes: 10, } if createErr := s.db.WithContext(ctx).Create(&cfg).Error; createErr != nil { return nil, createErr } return &cfg, nil } if err != nil { return nil, err } return &cfg, nil } // ApplyConfigToClient 把数据库中的上游地址同步到 HTTP 客户端。 // // 必须在进程启动时调用一次:否则重启后客户端会回落到内置默认地址, // 后台改过的 base_url 就会表现为「保存时生效、重启即失效」。 func (s *RockService) ApplyConfigToClient(ctx context.Context) error { cfg, err := s.GetConfig(ctx) if err != nil { return err } s.client.SetBaseURL(cfg.BaseURL) return nil } // UpdateConfig 更新系统配置 func (s *RockService) UpdateConfig(ctx context.Context, req UpdateRockConfigReq) (*model.RockConfig, error) { cfg, err := s.GetConfig(ctx) if err != nil { return nil, err } updates := make(map[string]interface{}) if req.AutoDeliveryEnabled != nil { updates["auto_delivery_enabled"] = *req.AutoDeliveryEnabled } if req.BaseURL != nil && strings.TrimSpace(*req.BaseURL) != "" { updates["base_url"] = strings.TrimRight(strings.TrimSpace(*req.BaseURL), "/") // 立即生效,避免还要等下一次重启或配置读取;客户端内部已加锁。 s.client.SetBaseURL(updates["base_url"].(string)) } if req.BearerToken != nil { updates["bearer_token"] = strings.TrimSpace(*req.BearerToken) } if req.MinPointsWarning != nil { updates["min_points_warning"] = *req.MinPointsWarning } if req.TokenWarningStock != nil { updates["token_warning_stock"] = *req.TokenWarningStock } if req.AutoPurchaseEnabled != nil { updates["auto_purchase_enabled"] = *req.AutoPurchaseEnabled } if req.AutoPurchaseCount != nil { updates["auto_purchase_count"] = *req.AutoPurchaseCount } if req.PurchaseCooldownMinutes != nil { updates["purchase_cooldown_minutes"] = *req.PurchaseCooldownMinutes } // 使用中超时预警阈值:橙色必须小于红色,否则前端无法区分两级。 warnDays := cfg.UsingWarnDays if req.UsingWarnDays != nil { warnDays = *req.UsingWarnDays updates["using_warn_days"] = warnDays } dangerDays := cfg.UsingDangerDays if req.UsingDangerDays != nil { dangerDays = *req.UsingDangerDays updates["using_danger_days"] = dangerDays } if warnDays < 1 || warnDays > 365 { return nil, &RockConfigValidationError{Message: "使用中橙色预警天数必须在 1-365 之间"} } if dangerDays < 1 || dangerDays > 365 { return nil, &RockConfigValidationError{Message: "使用中红色预警天数必须在 1-365 之间"} } if dangerDays <= warnDays { return nil, &RockConfigValidationError{Message: "使用中红色预警天数必须大于橙色预警天数"} } if len(updates) > 0 { updates["updated_at"] = time.Now() if err := s.db.WithContext(ctx).Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Updates(updates).Error; err != nil { return nil, err } } return s.GetConfig(ctx) } // TestConnection 测试上游 Token 连通性并同步最新用户信息与积分 func (s *RockService) TestConnection(ctx context.Context, bearerToken string) (*RockMeResponse, error) { token := strings.TrimSpace(bearerToken) if token == "" { cfg, err := s.GetConfig(ctx) if err != nil { return nil, err } token = cfg.BearerToken } if token == "" { return nil, fmt.Errorf("请先输入或配置上游 Bearer Token") } me, err := s.client.GetMe(ctx, token) if err != nil { return nil, err } // 连通成功,顺便异步更新数据库快照 now := time.Now() _ = s.db.WithContext(ctx).Model(&model.RockConfig{}).Where("id = 1").Updates(map[string]interface{}{ "username": me.User.Username, "points": me.User.Points.Float64(), "last_heartbeat_at": now, }).Error return me, nil } // RefreshDashboard 拉取上游 dashboard,并在采购单价变化时同步到本系统。 func (s *RockService) RefreshDashboard(ctx context.Context) (*RockDashboardResult, error) { cfg, err := s.GetConfig(ctx) if err != nil { return nil, err } return s.refreshDashboardWithConfig(ctx, cfg) } // refreshDashboardWithConfig 复用已读取的配置,避免同一次采购内重复查库。 func (s *RockService) refreshDashboardWithConfig(ctx context.Context, cfg *model.RockConfig) (*RockDashboardResult, error) { if cfg == nil { return nil, errors.New("Rock 配置不存在") } if strings.TrimSpace(cfg.BearerToken) == "" { return nil, errors.New("请先配置 0666.pro Bearer Token") } dash, err := s.client.GetDashboard(ctx, cfg.BearerToken) if err != nil { return nil, err } result := &RockDashboardResult{ AvailableBlack: dash.Available.Black, AvailableBlue: dash.Available.Blue, BlueTokenCost: dash.ResolveBlueTokenCost(), } cost := dash.ResolveBlackTokenCost() if cost <= 0 { result.BlackTokenCost = cfg.BlackTokenCost return result, nil } result.BlackTokenCost = cost // 价格一样就完全不写库;只有变化超过 0.001 才更新。 if math.Abs(cfg.BlackTokenCost-cost) > 0.001 { if err := s.db.WithContext(ctx).Model(&model.RockConfig{}). Where("id = ?", cfg.ID). Update("black_token_cost", cost).Error; err != nil { return nil, err } result.CostUpdated = true } return result, nil } // GetStats 获取概览统计 func (s *RockService) GetStats(ctx context.Context) (*RockStatsResponse, error) { cfg, err := s.GetConfig(ctx) if err != nil { return nil, err } var stats RockStatsResponse stats.AutoDeliveryEnabled = cfg.AutoDeliveryEnabled stats.TokenWarningStock = cfg.TokenWarningStock stats.AutoPurchaseEnabled = cfg.AutoPurchaseEnabled stats.UpstreamPoints = cfg.Points stats.Username = cfg.Username if cfg.LastHeartbeatAt != nil { stats.LastHeartbeatAt = cfg.LastHeartbeatAt.Format("2006-01-02 15:04:05") } stats.LastPurchaseError = cfg.LastError if cfg.LastErrorAt != nil { stats.LastPurchaseErrorAt = cfg.LastErrorAt.Format("2006-01-02 15:04:05") } type CountResult struct { Status string Total int64 } var results []CountResult err = s.db.WithContext(ctx).Model(&model.RockToken{}). Select("status, count(*) as total"). Group("status"). Scan(&results).Error if err != nil { return nil, err } for _, r := range results { switch r.Status { case model.RockStatusAvailable: stats.AvailableStock = r.Total case model.RockStatusUsing: stats.UsingStock = r.Total case model.RockStatusBound: stats.BoundStock = r.Total case model.RockStatusRedeemed: stats.RedeemedStock = r.Total case model.RockStatusMismatch: stats.MismatchStock = r.Total case model.RockStatusAbnormal: stats.AbnormalStock = r.Total } } var deletedCount int64 if err := s.db.WithContext(ctx).Unscoped().Model(&model.RockToken{}). Where("deleted_at IS NOT NULL"). Count(&deletedCount).Error; err != nil { return nil, err } stats.DeletedStock = deletedCount if cfg.AutoPurchaseEnabled { if remain := rockAutoPurchaseCooldownRemaining(cfg, time.Now()); remain > 0 { stats.AutoPurchaseCooldownSeconds = int64((remain + time.Second - 1) / time.Second) if cfg.LastAutoAttemptAt != nil { next := cfg.LastAutoAttemptAt.Add(time.Duration(cfg.PurchaseCooldownMinutes) * time.Minute) stats.NextAutoPurchaseAt = next.Format("2006-01-02 15:04:05") } } } return &stats, nil }