初始化

This commit is contained in:
Ran
2026-08-25 17:59:42 +08:00
commit 4b7380dd9b
408 changed files with 327400 additions and 0 deletions
+306
View File
@@ -0,0 +1,306 @@
// 管理后台业务模块,封装模型配置、兑换码、用户积分和操作审计的持久化事务。
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<CURRENT_TIMESTAMP THEN 'expired' ELSE r.status END=?", status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return legacyservice.Page{}, err
}
items := make([]map[string]any, 0)
err := query.Select("r.id,r.code,b.name AS batch_name,r.points,CASE WHEN r.status='unused' AND r.expires_at<CURRENT_TIMESTAMP THEN 'expired' ELSE r.status END AS status,u.uid AS redeemed_uid,r.redeemed_at,r.expires_at,a.username AS created_by,r.created_at").
Joins("JOIN admin_users a ON a.id=b.created_by").Order("r.created_at DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error
if err != nil {
return legacyservice.Page{}, err
}
return legacyservice.Page{Items: items, Total: total, Page: page, PageSize: size}, nil
}
// ListAuditLogs 按关键字分页查询管理端操作审计记录,仅提供只读访问。
func (s *Service) ListAuditLogs(keyword string, page, size int) (legacyservice.Page, error) {
query := s.db.Table("admin_audit_logs")
if keyword = strings.TrimSpace(keyword); keyword != "" {
pattern := "%" + keyword + "%"
query = query.Where(`admin_username ILIKE ? OR action::text ILIKE ? OR resource_type::text ILIKE ?
OR coalesce(resource_id,'') ILIKE ? OR coalesce(reason,'') ILIKE ? OR coalesce(trace_id,'') ILIKE ?`,
pattern, pattern, pattern, pattern, pattern, pattern)
if resourceTypes := matchingAuditResourceTypes(keyword); len(resourceTypes) > 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
}
@@ -0,0 +1,67 @@
// 管理后台模块单元测试,验证模型计费、兑换码批次和审计搜索的纯业务规则。
package admin
import (
"slices"
"testing"
)
// TestNormalizeModelPrices 验证按 Token 计费时价格键、单位和金额标准化。
func TestNormalizeModelPrices(t *testing.T) {
input := ModelInput{
ModelType: "text",
TextBillingMode: "per_token",
Prices: []ModelPriceInput{
{PriceKey: " INPUT ", Price: "1.236"},
{PriceKey: "output", Price: 2},
},
}
if err := normalizeModelPrices(&input); err != nil {
t.Fatalf("normalize model prices: %v", err)
}
if input.Prices[0].PriceKey != "input" || input.Prices[0].Unit != "M Token" || input.Prices[0].Price != 1.24 {
t.Fatalf("unexpected normalized input price: %#v", input.Prices[0])
}
}
// TestNormalizeModelPricesRejectsIncompleteConfig 验证文本模型缺少价格项时拒绝保存。
func TestNormalizeModelPricesRejectsIncompleteConfig(t *testing.T) {
input := ModelInput{ModelType: "text", TextBillingMode: "per_token", Prices: []ModelPriceInput{{PriceKey: "input", Price: 1}}}
if err := normalizeModelPrices(&input); err == nil {
t.Fatal("expected incomplete token prices to be rejected")
}
}
// TestValidateRedemptionBatchLimits 验证兑换码单码积分与批次数量边界。
func TestValidateRedemptionBatchLimits(t *testing.T) {
if points, err := ValidateRedemptionBatchLimits("10.5", 10); err != nil || points != "10.5" {
t.Fatalf("expected valid redemption limits, points=%q err=%v", points, err)
}
for _, test := range []struct {
points any
quantity int
}{{0, 1}, {101, 1}, {10, 0}, {10, 11}} {
if _, err := ValidateRedemptionBatchLimits(test.points, test.quantity); err == nil {
t.Fatalf("expected limits to be rejected: %#v", test)
}
}
}
// TestMatchingAuditResourceTypes 验证中文资源名称可转换为审计记录使用的内部代码。
func TestMatchingAuditResourceTypes(t *testing.T) {
for _, test := range []struct {
keyword string
want string
}{
{keyword: "渠道", want: "channels"},
{keyword: "兑换", want: "redemption-codes"},
{keyword: "管理员账号", want: "admin-auth"},
} {
if got := matchingAuditResourceTypes(test.keyword); !slices.Contains(got, test.want) {
t.Fatalf("keyword %q matched %v, want %q", test.keyword, got, test.want)
}
}
if got := matchingAuditResourceTypes("不存在的资源"); len(got) != 0 {
t.Fatalf("unexpected resource matches: %v", got)
}
}