Files

329 lines
10 KiB
Go

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"`
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
return &stats, nil
}