// 管理后台业务模块,封装模型配置、兑换码、用户积分和操作审计的持久化事务。 package admin import ( "encoding/json" "errors" "fmt" "math" "strconv" "strings" "time" "juhe-factory/api/internal/billing" "juhe-factory/api/internal/model" legacyservice "juhe-factory/api/internal/service" "github.com/google/uuid" "gorm.io/gorm" ) // Service 提供管理后台模块的公开业务接口,并隐藏底层数据库连接。 type Service struct { db *gorm.DB } // ModelPriceInput 表示模型的单项计费配置。 type ModelPriceInput struct { PriceKey string `json:"price_key"` Unit string `json:"unit"` Price any `json:"price"` } // ModelInput 表示管理端新增或修改模型时提交的完整配置。 type ModelInput struct { ChannelID string `json:"channel_id"` Name string `json:"name"` ModelType string `json:"model_type"` Multimodal *bool `json:"multimodal"` TextBillingMode string `json:"text_billing_mode"` Enabled bool `json:"enabled"` Prices []ModelPriceInput `json:"prices"` } // NewService 创建管理后台业务服务。 func NewService(db *gorm.DB) *Service { return &Service{db: db} } // ListModels 按管理端筛选条件分页查询模型及价格配置。 func (s *Service) ListModels(keyword, modelType, channelID string, page, size int) (legacyservice.Page, error) { query := s.db.Table("models m").Joins("JOIN channels c ON c.id=m.channel_id").Where("m.deleted_at IS NULL") if keyword = strings.TrimSpace(keyword); keyword != "" { query = query.Where("m.name::text ILIKE ?", "%"+keyword+"%") } if modelType = strings.TrimSpace(modelType); modelType != "" { query = query.Where("m.model_type=?", modelType) } if channelID = strings.TrimSpace(channelID); channelID != "" { query = query.Where("m.channel_id=?", channelID) } var total int64 if err := query.Count(&total).Error; err != nil { return legacyservice.Page{}, err } items := make([]map[string]any, 0) err := query.Select(`m.id,m.channel_id,c.name AS channel_name,m.name,m.model_type,m.multimodal,m.text_billing_mode,m.enabled,c.enabled AS channel_enabled, coalesce((SELECT jsonb_agg(jsonb_build_object('price_key',p.price_key,'unit',p.unit,'price',p.price) ORDER BY CASE p.price_key WHEN 'default' THEN 0 WHEN '1K' THEN 1 WHEN '2K' THEN 2 WHEN '4K' THEN 3 WHEN '480p' THEN 4 WHEN '720p' THEN 5 WHEN '1080p' THEN 6 ELSE 99 END) FROM model_prices p WHERE p.model_id=m.id),'[]'::jsonb) AS prices`). Order("m.created_at DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error if err != nil { return legacyservice.Page{}, err } for i := range items { items[i]["prices"] = decodePriceList(items[i]["prices"]) } return legacyservice.Page{Items: items, Total: total, Page: page, PageSize: size}, nil } // SaveModel 校验模型计费配置,并在同一事务中保存模型及全部价格项。 func (s *Service) SaveModel(modelID string, input ModelInput) (string, bool, ModelInput, error) { if err := normalizeModelPrices(&input); err != nil { return "", false, input, err } created := modelID == "" if created { modelID = uuid.NewString() } err := s.db.Transaction(func(tx *gorm.DB) error { values := map[string]any{"channel_id": input.ChannelID, "name": input.Name, "model_type": input.ModelType, "enabled": input.Enabled, "text_billing_mode": nil} switch input.ModelType { case "text": values["multimodal"] = input.Multimodal values["text_billing_mode"] = input.TextBillingMode default: values["multimodal"] = nil } if created { values["id"] = modelID if err := tx.Table("models").Create(values).Error; err != nil { return err } } else if err := tx.Table("models").Where("id=? AND deleted_at IS NULL", modelID).Updates(values).Error; err != nil { return err } if err := tx.Exec("DELETE FROM model_prices WHERE model_id=?", modelID).Error; err != nil { return err } for _, price := range input.Prices { if err := tx.Exec("INSERT INTO model_prices(id,model_id,price_key,unit,price) VALUES(?,?,?,?,?)", uuid.NewString(), modelID, price.PriceKey, price.Unit, price.Price).Error; err != nil { return err } } return nil }) return modelID, created, input, err } // ListRedemptions 按关键字和状态分页查询兑换码记录。 func (s *Service) ListRedemptions(keyword, status string, page, size int) (legacyservice.Page, error) { query := s.db.Table("redemption_codes r").Joins("JOIN redemption_batches b ON b.id=r.batch_id").Joins("LEFT JOIN web_users u ON u.id=r.redeemed_by") if keyword = strings.TrimSpace(keyword); keyword != "" { query = query.Where("r.code ILIKE ? OR r.code_mask ILIKE ? OR b.name::text ILIKE ? OR u.uid=?", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", keyword) } if status = strings.TrimSpace(status); status != "" { query = query.Where("CASE WHEN r.status='unused' AND r.expires_at 0 { query = query.Or("resource_type IN ?", resourceTypes) } } var total int64 if err := query.Count(&total).Error; err != nil { return legacyservice.Page{}, err } items := make([]map[string]any, 0) err := query.Select(`id,admin_id,admin_username,action,resource_type,coalesce(resource_id,'') AS resource_id, coalesce(reason,'') AS reason,coalesce(trace_id,'') AS trace_id,created_at`). Order("created_at DESC,id DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error return legacyservice.Page{Items: items, Total: total, Page: page, PageSize: size}, err } // matchingAuditResourceTypes 将中文资源名称转换为数据库内部代码,支持管理端直接使用中文搜索。 func matchingAuditResourceTypes(keyword string) []string { keyword = strings.TrimSpace(keyword) if keyword == "" { return nil } labels := map[string]string{ "users": "用户", "redemption-codes": "兑换码", "styles": "风格", "prompts": "提示词", "channels": "渠道", "models": "模型", "admin-auth": "管理员账号", } result := make([]string, 0, len(labels)) for resourceType, label := range labels { if strings.Contains(label, keyword) { result = append(result, resourceType) } } return result } // CreateRedemptionBatch 校验生成限制,并在事务中创建兑换批次和明文返回值。 func (s *Service) CreateRedemptionBatch(adminID uuid.UUID, name string, pointsValue any, quantity int, expiresAt time.Time) (string, []string, string, error) { points, err := ValidateRedemptionBatchLimits(pointsValue, quantity) if err != nil { return "", nil, "", err } batchID := uuid.NewString() codes := make([]string, 0, quantity) err = s.db.Transaction(func(tx *gorm.DB) error { if err := tx.Exec("INSERT INTO redemption_batches(id,name,points,quantity,expires_at,created_by) VALUES(?,?,?,?,?,?)", batchID, name, points, quantity, expiresAt, adminID).Error; err != nil { // 命中 redemption_batches_name_key 唯一约束时返回业务友好提示,避免把底层 SQL 错误透传给前端 if strings.Contains(err.Error(), "23505") { return errors.New("批次名称已存在,请更换后重试") } return err } for i := 0; i < quantity; i++ { plain, hash, mask, err := legacyservice.GenerateRedemptionCode() if err != nil { return err } if err := tx.Exec("INSERT INTO redemption_codes(id,batch_id,code_hash,code_mask,code,points,expires_at) VALUES(?,?,?,?,?,?,?)", uuid.NewString(), batchID, hash, mask, plain, points, expiresAt).Error; err != nil { return err } codes = append(codes, plain) } return nil }) return batchID, codes, points, err } // GrantUserPoints 通过永久积分批次为指定用户增加积分,并使用请求号保证重复提交不会重复入账。 func (s *Service) GrantUserPoints(userID uuid.UUID, requestID string, pointsValue any) (string, bool, error) { requestID = strings.TrimSpace(requestID) if _, err := uuid.Parse(requestID); err != nil { return "", false, errors.New("积分发放请求号无效") } points := strings.TrimSpace(fmt.Sprint(pointsValue)) sourceID := userID.String() + ":" + requestID var balance string var credited bool err := s.db.Transaction(func(tx *gorm.DB) error { var err error balance, credited, err = billing.CreditPoints(tx, userID, "admin_grant", sourceID, points, "后台发放积分") return err }) return balance, credited, err } // WriteAudit 记录管理端操作审计信息,调用方无需接触数据库连接。 func (s *Service) WriteAudit(adminUser *model.AdminUser, action, resource, resourceID, reason, ip, traceID string, detail any) error { return legacyservice.WriteAudit(s.db, adminUser, action, resource, resourceID, reason, ip, traceID, detail) } // normalizeModelPrices 校验并标准化文本模型的计费键、单位和金额。 func normalizeModelPrices(input *ModelInput) error { if input.ModelType != "text" { input.TextBillingMode = "" return nil } expected := map[string]string{} switch input.TextBillingMode { case "per_request": expected["default"] = "次" case "per_token": expected["input"] = "M Token" expected["output"] = "M Token" default: return errors.New("文本模型计费模式无效") } if len(input.Prices) != len(expected) { return errors.New("文本模型价格配置不完整") } seen := map[string]bool{} for index := range input.Prices { key := strings.ToLower(strings.TrimSpace(input.Prices[index].PriceKey)) unit, ok := expected[key] value, err := strconv.ParseFloat(fmt.Sprint(input.Prices[index].Price), 64) if !ok || seen[key] || err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) { return errors.New("文本模型价格配置无效") } seen[key] = true input.Prices[index].PriceKey = key input.Prices[index].Unit = unit input.Prices[index].Price = math.Round(value*100) / 100 } return nil } // decodePriceList 将 PostgreSQL JSON 聚合结果转换为稳定的价格数组。 func decodePriceList(value any) []map[string]any { var raw []byte switch data := value.(type) { case []byte: raw = data case json.RawMessage: raw = data case string: raw = []byte(data) default: return []map[string]any{} } prices := make([]map[string]any, 0) if err := json.Unmarshal(raw, &prices); err != nil { return []map[string]any{} } return prices } // ValidateRedemptionBatchLimits 校验单码积分和单批生成数量的安全范围。 func ValidateRedemptionBatchLimits(value any, quantity int) (string, error) { points := strings.TrimSpace(fmt.Sprint(value)) parsed, err := strconv.ParseFloat(points, 64) if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed < 1 || parsed > 100 { return "", errors.New("单码积分数量必须为 1 至 100") } if quantity < 1 || quantity > 10 { return "", errors.New("生成数量必须为 1 至 10") } return points, nil }