初始化
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// 图片生成生图业务模块,负责提交用户级图片任务、查询历史并清理生成资源。
|
||||
package productimage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"juhe-factory/api/internal/billing"
|
||||
"juhe-factory/api/internal/model"
|
||||
queuepkg "juhe-factory/api/internal/queue"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hibiken/asynq"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var activeTaskStatuses = []string{"pending_submission", "submitting", "submitted", "processing", "result_ready", "downloading", "cancel_requested"}
|
||||
|
||||
// ReferenceUpload 表示已经上传到对象存储、等待绑定到生图任务的参考图。
|
||||
type ReferenceUpload struct {
|
||||
Asset *model.MediaAsset
|
||||
Name string
|
||||
}
|
||||
|
||||
// GenerateInput 是图片生成生图接口的配置参数和临时参考图。
|
||||
type GenerateInput struct {
|
||||
Prompt string
|
||||
ModelID uuid.UUID
|
||||
AspectRatio string
|
||||
Resolution string
|
||||
References []ReferenceUpload
|
||||
}
|
||||
|
||||
// GenerationView 汇总任务状态和已经落库的图片结果。
|
||||
type GenerationView struct {
|
||||
TaskID uuid.UUID `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
Prompt string `json:"prompt"`
|
||||
ModelName string `json:"model_name"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
MediaAssetID *uuid.UUID `json:"media_asset_id"`
|
||||
PublicURL string `json:"public_url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
EstimatedPoints string `json:"estimated_points"`
|
||||
ActualPoints *string `json:"actual_points"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
}
|
||||
|
||||
// Service 组合图片生成模块需要的数据库和任务队列依赖。
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Queue *asynq.Client
|
||||
}
|
||||
|
||||
// NewService 创建图片生成生图服务。
|
||||
func NewService(db *gorm.DB, queue *asynq.Client) *Service { return &Service{DB: db, Queue: queue} }
|
||||
|
||||
// QueueGeneration 校验配置、保存临时参考图并提交一个独立图片任务。
|
||||
func (s *Service) QueueGeneration(userID uuid.UUID, input GenerateInput) (*model.GenerationTask, error) {
|
||||
if s.Queue == nil {
|
||||
return nil, errors.New("生成任务队列不可用")
|
||||
}
|
||||
prompt := strings.TrimSpace(input.Prompt)
|
||||
if prompt == "" {
|
||||
return nil, errors.New("提示词不能为空")
|
||||
}
|
||||
if utf8.RuneCountInString(prompt) > 5000 {
|
||||
return nil, errors.New("提示词不能超过 5000 个字符")
|
||||
}
|
||||
if len(input.References) > 4 {
|
||||
return nil, errors.New("最多上传 4 张参考图")
|
||||
}
|
||||
if input.ModelID == uuid.Nil {
|
||||
return nil, errors.New("请选择图片模型")
|
||||
}
|
||||
if !validRatio(input.AspectRatio) || !validResolution(input.Resolution) {
|
||||
return nil, errors.New("图片生成参数无效")
|
||||
}
|
||||
var selected struct {
|
||||
ModelID uuid.UUID
|
||||
ChannelID uuid.UUID
|
||||
ModelName string
|
||||
Price string
|
||||
PriceExists bool
|
||||
}
|
||||
if err := s.DB.Raw(`SELECT model.id AS model_id,model.channel_id,model.name AS model_name,
|
||||
coalesce(price.price,0)::text AS price,(price.id IS NOT NULL) AS price_exists
|
||||
FROM models model JOIN channels channel ON channel.id=model.channel_id
|
||||
LEFT JOIN model_prices price ON price.model_id=model.id AND lower(price.price_key)=lower(?)
|
||||
WHERE model.id=? AND model.model_type='image' AND model.enabled=true AND model.deleted_at IS NULL
|
||||
AND channel.enabled=true AND channel.deleted_at IS NULL`, input.Resolution, input.ModelID).Scan(&selected).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if selected.ModelID == uuid.Nil {
|
||||
return nil, errors.New("图片模型不可用")
|
||||
}
|
||||
if !selected.PriceExists {
|
||||
return nil, fmt.Errorf("当前图片模型未配置 %s 价格", input.Resolution)
|
||||
}
|
||||
imageURLs := make([]string, 0, len(input.References))
|
||||
referenceIDs := make([]string, 0, len(input.References))
|
||||
referenceNames := make([]string, 0, len(input.References))
|
||||
seenNames := make(map[string]struct{}, len(input.References))
|
||||
for _, reference := range input.References {
|
||||
if reference.Asset == nil || strings.TrimSpace(reference.Asset.PublicURL) == "" {
|
||||
return nil, errors.New("参考图信息无效")
|
||||
}
|
||||
name := strings.TrimSpace(reference.Name)
|
||||
if name == "" {
|
||||
return nil, errors.New("参考图名称不能为空")
|
||||
}
|
||||
if _, exists := seenNames[name]; exists {
|
||||
return nil, errors.New("参考图名称不能重复")
|
||||
}
|
||||
seenNames[name] = struct{}{}
|
||||
imageURLs = append(imageURLs, reference.Asset.PublicURL)
|
||||
referenceIDs = append(referenceIDs, reference.Asset.ID.String())
|
||||
referenceNames = append(referenceNames, name)
|
||||
}
|
||||
providerPrompt := buildProviderPrompt(prompt, referenceNames)
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"product_image": true, "prompt": providerPrompt, "display_prompt": prompt,
|
||||
"model": selected.ModelName, "size": input.AspectRatio, "resolution": input.Resolution,
|
||||
"image_urls": imageURLs, "reference_media_asset_ids": referenceIDs, "reference_names": referenceNames, "n": 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task := &model.GenerationTask{ID: uuid.New(), RequestID: "product_image_" + uuid.NewString(), UserID: userID, ChannelID: &selected.ChannelID, ModelID: &selected.ModelID, TaskType: "image_generation", Status: "pending_submission", InputData: payload}
|
||||
err = s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, reference := range input.References {
|
||||
owner := userID
|
||||
reference.Asset.OwnerUserID = &owner
|
||||
if err := tx.Create(reference.Asset).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return billing.PrechargeGenerationTask(tx, task, selected.Price, 1, "图片生成")
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := queuepkg.EnqueueID(s.Queue, queuepkg.TypeDispatchChannel, selected.ChannelID, 0); err != nil {
|
||||
if refundErr := s.failQueuedTask(task.ID, err.Error()); refundErr != nil {
|
||||
return nil, fmt.Errorf("生成任务入队失败且预扣返还失败: %w", refundErr)
|
||||
}
|
||||
return nil, errors.New("生成任务队列暂时不可用,请稍后重试")
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// Generations 返回当前用户最近五十条图片生成记录。
|
||||
func (s *Service) Generations(userID uuid.UUID) ([]GenerationView, error) {
|
||||
items := make([]GenerationView, 0)
|
||||
err := s.DB.Raw(`SELECT task.id AS task_id,task.status,
|
||||
coalesce(task.input_data->>'display_prompt',task.input_data->>'prompt','') AS prompt,
|
||||
coalesce(model.name,task.input_data->>'model','') AS model_name,task.error_message,
|
||||
output.media_asset_id,coalesce(media.public_url,'') AS public_url,coalesce(media.mime_type,'') AS mime_type,
|
||||
task.estimated_points,task.actual_points,task.created_at,task.finished_at
|
||||
FROM generation_tasks task
|
||||
LEFT JOIN models model ON model.id=task.model_id
|
||||
LEFT JOIN generation_outputs output ON output.task_id=task.id AND output.sequence_no=1
|
||||
LEFT JOIN media_assets media ON media.id=output.media_asset_id AND media.deleted_at IS NULL
|
||||
WHERE task.user_id=? AND task.task_type='image_generation' AND task.input_data->>'product_image'='true'
|
||||
ORDER BY task.created_at DESC LIMIT 50`, userID).Scan(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// DeleteGeneration 清理当前用户的图片任务、生成媒体和参考媒体;活动任务也允许主动删除。
|
||||
func (s *Service) DeleteGeneration(userID, taskID uuid.UUID, deleteObjects func([]string) error) error {
|
||||
objectKeys := make([]string, 0)
|
||||
var channelID *uuid.UUID
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var task model.GenerationTask
|
||||
if err := tx.Where("id=? AND user_id=? AND task_type='image_generation' AND input_data->>'product_image'='true'", taskID, userID).Take(&task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !canDeleteGeneration(task.Status, task.ErrorMessage) {
|
||||
return errors.New("图片仍在正常生成中,暂时不能删除")
|
||||
}
|
||||
channelID = task.ChannelID
|
||||
var input struct {
|
||||
ReferenceMediaAssetIDs []uuid.UUID `json:"reference_media_asset_ids"`
|
||||
}
|
||||
_ = json.Unmarshal(task.InputData, &input)
|
||||
mediaIDs := append([]uuid.UUID(nil), input.ReferenceMediaAssetIDs...)
|
||||
var outputs []struct {
|
||||
MediaAssetID uuid.UUID
|
||||
ObjectKey string
|
||||
}
|
||||
if err := tx.Table("generation_outputs output").Select("output.media_asset_id,media.object_key").Joins("JOIN media_assets media ON media.id=output.media_asset_id").Where("output.task_id=?", taskID).Scan(&outputs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, output := range outputs {
|
||||
mediaIDs = append(mediaIDs, output.MediaAssetID)
|
||||
if output.ObjectKey != "" {
|
||||
objectKeys = append(objectKeys, output.ObjectKey)
|
||||
}
|
||||
}
|
||||
if len(mediaIDs) > 0 {
|
||||
var refs []struct{ ObjectKey string }
|
||||
if err := tx.Table("media_assets").Select("object_key").Where("id IN ?", mediaIDs).Scan(&refs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ref := range refs {
|
||||
if ref.ObjectKey != "" {
|
||||
objectKeys = append(objectKeys, ref.ObjectKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
if deleteObjects != nil {
|
||||
if err := deleteObjects(uniqueStrings(objectKeys)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM generation_outputs WHERE task_id=?", taskID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(mediaIDs) > 0 {
|
||||
if err := tx.Exec("DELETE FROM channel_asset_cache WHERE media_asset_id IN ?", mediaIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM media_assets WHERE id IN ?", mediaIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Exec("DELETE FROM generation_tasks WHERE id=?", taskID).Error
|
||||
})
|
||||
if err == nil && channelID != nil && s.Queue != nil {
|
||||
_ = queuepkg.EnqueueID(s.Queue, queuepkg.TypeDispatchChannel, *channelID, 0)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// canDeleteGeneration 判断图片任务是否已结束,或因错误停留在活动状态而允许用户清理。
|
||||
func canDeleteGeneration(status, errorMessage string) bool {
|
||||
for _, activeStatus := range activeTaskStatuses {
|
||||
if status == activeStatus {
|
||||
return strings.TrimSpace(errorMessage) != ""
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// failQueuedTask 在调度队列不可用时结束任务、返还积分并删除参考媒体记录。
|
||||
func (s *Service) failQueuedTask(taskID uuid.UUID, message string) error {
|
||||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var task model.GenerationTask
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=?", taskID).Take(&task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
refunded, err := billing.RefundGenerationTask(tx, &task, "图片生成入队失败返还")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var input struct {
|
||||
ReferenceMediaAssetIDs []uuid.UUID `json:"reference_media_asset_ids"`
|
||||
}
|
||||
_ = json.Unmarshal(task.InputData, &input)
|
||||
if len(input.ReferenceMediaAssetIDs) > 0 {
|
||||
if err := tx.Exec("DELETE FROM media_assets WHERE id IN ?", input.ReferenceMediaAssetIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
updates := map[string]any{"status": "failed", "error_code": "queue_unavailable", "error_message": message, "finished_at": time.Now(), "actual_points": "0.00"}
|
||||
if refunded {
|
||||
updates["cost_refunded"] = true
|
||||
}
|
||||
return tx.Model(&model.GenerationTask{}).Where("id=?", taskID).Updates(updates).Error
|
||||
})
|
||||
}
|
||||
|
||||
// validRatio 判断画幅比例是否属于图片生成工具公开支持的取值。
|
||||
func validRatio(value string) bool {
|
||||
for _, allowed := range []string{"9:21", "1:1", "3:4", "4:3", "9:16", "16:9"} {
|
||||
if value == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validResolution 判断分辨率是否属于图片生成工具公开支持的档位。
|
||||
func validResolution(value string) bool {
|
||||
for _, allowed := range []string{"1k", "2k", "4k"} {
|
||||
if value == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildProviderPrompt 将文件名提及与参考图数组顺序建立明确对应关系。
|
||||
func buildProviderPrompt(prompt string, names []string) string {
|
||||
if len(names) == 0 {
|
||||
return prompt
|
||||
}
|
||||
mappings := make([]string, 0, len(names))
|
||||
for index, name := range names {
|
||||
mappings = append(mappings, fmt.Sprintf("第%d张参考图名称为“%s”", index+1, name))
|
||||
}
|
||||
return "参考图对应关系:" + strings.Join(mappings, ";") + "。\n" + prompt
|
||||
}
|
||||
|
||||
// uniqueStrings 按首次出现顺序去除空对象键和重复对象键。
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok || value == "" {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 图片生成服务测试,验证参考图文件名映射和对象键去重规则。
|
||||
package productimage
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestBuildProviderPrompt 验证文件名提及会与参考图数组顺序建立稳定对应关系。
|
||||
func TestBuildProviderPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
prompt := "让@正面图.png中的商品使用@包装图.webp的包装"
|
||||
got := buildProviderPrompt(prompt, []string{"正面图.png", "包装图.webp"})
|
||||
want := "参考图对应关系:第1张参考图名称为“正面图.png”;第2张参考图名称为“包装图.webp”。\n" + prompt
|
||||
if got != want {
|
||||
t.Fatalf("buildProviderPrompt() = %q, want %q", got, want)
|
||||
}
|
||||
if withoutReferences := buildProviderPrompt(prompt, nil); withoutReferences != prompt {
|
||||
t.Fatalf("buildProviderPrompt() without references = %q, want %q", withoutReferences, prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUniqueStrings 验证重复或空对象键不会触发重复删除请求。
|
||||
func TestUniqueStrings(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := uniqueStrings([]string{"a", "", "b", "a", "b", "c"})
|
||||
want := []string{"a", "b", "c"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("uniqueStrings() length = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for index := range want {
|
||||
if got[index] != want[index] {
|
||||
t.Fatalf("uniqueStrings()[%d] = %q, want %q", index, got[index], want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanDeleteGeneration 验证失败停滞的活动任务可删除,而正常生成中的任务仍受保护。
|
||||
func TestCanDeleteGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
status string
|
||||
errorMessage string
|
||||
want bool
|
||||
}{
|
||||
{name: "正常生成", status: "processing", want: false},
|
||||
{name: "连接超时", status: "pending_submission", errorMessage: "dial tcp: i/o timeout", want: true},
|
||||
{name: "生成失败", status: "failed", want: true},
|
||||
{name: "生成成功", status: "succeeded", want: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := canDeleteGeneration(test.status, test.errorMessage); got != test.want {
|
||||
t.Fatalf("canDeleteGeneration(%q, %q) = %t, want %t", test.status, test.errorMessage, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// 提示词业务模块,统一封装用户提示词规则、选择关系和持久化操作。
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Service 提供提示词模块的公开业务接口,并隐藏底层数据库实现。
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// CustomPromptInput 表示用户自定义提示词的可编辑内容。
|
||||
type CustomPromptInput struct {
|
||||
Name string
|
||||
Type string
|
||||
Content string
|
||||
}
|
||||
|
||||
// CustomPrompt 表示返回给接口层的用户自定义提示词。
|
||||
type CustomPrompt struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Scope string `json:"scope"`
|
||||
Editable bool `json:"editable"`
|
||||
}
|
||||
|
||||
// SystemPromptInput 表示管理端可维护的系统提示词内容。
|
||||
type SystemPromptInput struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// SystemPrompt 表示管理端保存后返回的系统提示词。
|
||||
type SystemPrompt struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// VersionedPromptInput 表示旧版提示词工作流中的草稿和版本字段。
|
||||
type VersionedPromptInput struct {
|
||||
Code string
|
||||
Name string
|
||||
Category string
|
||||
Type string
|
||||
Content string
|
||||
Variables []string
|
||||
VersionNote string
|
||||
}
|
||||
|
||||
// UserPromptList 表示指定类型的可用提示词及用户当前选择。
|
||||
type UserPromptList struct {
|
||||
Prompts []map[string]any
|
||||
SelectedID *uuid.UUID
|
||||
}
|
||||
|
||||
// PersistenceError 标识应由接口层转换为内部错误的数据库故障。
|
||||
type PersistenceError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error 返回底层数据库错误文本,供日志与测试定位。
|
||||
func (e PersistenceError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
// Unwrap 暴露底层错误,保留 errors.Is 和 errors.As 语义。
|
||||
func (e PersistenceError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
var fixedDramaParsePromptTypes = []string{"剧本解析", "角色、场景、道具解析"}
|
||||
|
||||
// NewService 创建提示词服务,数据库连接仅在模块内部使用。
|
||||
func NewService(db *gorm.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
// IsFixedDramaParseType 判断提示词类型是否由后端固定配置且禁止用户编辑。
|
||||
func IsFixedDramaParseType(promptType string) bool {
|
||||
for _, fixedType := range fixedDramaParsePromptTypes {
|
||||
if strings.TrimSpace(promptType) == fixedType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsPersistenceError 判断错误是否属于需要隐藏细节的数据库故障。
|
||||
func IsPersistenceError(err error) bool {
|
||||
var target PersistenceError
|
||||
return errors.As(err, &target)
|
||||
}
|
||||
|
||||
// ListSystemPrompts 查询管理端可维护的系统提示词,不返回后端固定配置。
|
||||
func (s *Service) ListSystemPrompts(keyword, promptType string, page, size int) ([]map[string]any, int64, error) {
|
||||
query := s.db.Table("prompts p").Where("p.deleted_at IS NULL AND p.scope='system' AND p.type NOT IN ?", fixedDramaParsePromptTypes)
|
||||
if keyword = strings.TrimSpace(keyword); keyword != "" {
|
||||
query = query.Where("p.name::text ILIKE ? OR p.content ILIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
if promptType = strings.TrimSpace(promptType); promptType != "" {
|
||||
query = query.Where("p.type=?", promptType)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]map[string]any, 0)
|
||||
err := query.Select("p.id,p.name,p.type,p.content,p.scope,p.owner_user_id,p.created_at,p.updated_at").
|
||||
Order("p.updated_at DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
// SaveSystemPrompt 校验并新增或更新管理端系统提示词。
|
||||
func (s *Service) SaveSystemPrompt(promptID string, input SystemPromptInput) (SystemPrompt, error) {
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Type = strings.TrimSpace(input.Type)
|
||||
input.Content = strings.TrimSpace(input.Content)
|
||||
if input.Name == "" || input.Type == "" || input.Content == "" {
|
||||
return SystemPrompt{}, errors.New("name、type、content不能为空")
|
||||
}
|
||||
if IsFixedDramaParseType(input.Type) {
|
||||
return SystemPrompt{}, errors.New("该提示词已固定在后端代码中,不支持管理")
|
||||
}
|
||||
if promptID == "" {
|
||||
promptID = uuid.NewString()
|
||||
if err := s.db.Exec("INSERT INTO prompts(id,name,type,content,scope,owner_user_id) VALUES(?,?,?,?,'system',NULL)", promptID, input.Name, input.Type, input.Content).Error; err != nil {
|
||||
return SystemPrompt{}, err
|
||||
}
|
||||
} else if err := s.db.Exec("UPDATE prompts SET name=?,type=?,content=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND scope='system' AND deleted_at IS NULL", input.Name, input.Type, input.Content, promptID).Error; err != nil {
|
||||
return SystemPrompt{}, err
|
||||
}
|
||||
return SystemPrompt{ID: promptID, Name: input.Name, Type: input.Type, Content: input.Content}, nil
|
||||
}
|
||||
|
||||
// DeleteSystemPrompt 删除管理端指定的系统提示词。
|
||||
func (s *Service) DeleteSystemPrompt(promptID string) error {
|
||||
return s.db.Exec("DELETE FROM prompts WHERE id=? AND scope='system'", promptID).Error
|
||||
}
|
||||
|
||||
// SaveVersionedPrompt 保存旧版管理接口使用的提示词草稿及版本快照。
|
||||
func (s *Service) SaveVersionedPrompt(adminID uuid.UUID, promptID string, input VersionedPromptInput) (string, error) {
|
||||
if strings.TrimSpace(input.Category) == "" {
|
||||
input.Category = input.Type
|
||||
}
|
||||
if strings.TrimSpace(input.Type) == "" {
|
||||
input.Type = input.Category
|
||||
}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
version := 1
|
||||
if promptID == "" {
|
||||
promptID = uuid.NewString()
|
||||
if err := tx.Exec("INSERT INTO prompts(id,code,name,category,type,content,status) VALUES(?,?,?,?,?,?,'draft')", promptID, input.Code, input.Name, input.Category, input.Type, input.Content).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Raw("SELECT coalesce(max(version),0)+1 FROM prompt_versions WHERE prompt_id=?", promptID).Scan(&version).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("UPDATE prompts SET code=?,name=?,category=?,type=?,content=? WHERE id=? AND deleted_at IS NULL", input.Code, input.Name, input.Category, input.Type, input.Content, promptID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
variables, _ := json.Marshal(input.Variables)
|
||||
versionID := uuid.NewString()
|
||||
if err := tx.Exec("INSERT INTO prompt_versions(id,prompt_id,version,content,variables,version_note,status,operator_id) VALUES(?,?,?,?,?::jsonb,?,'draft',?)", versionID, promptID, version, input.Content, string(variables), input.VersionNote, adminID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Exec("UPDATE prompts SET current_version_id=?,status='draft' WHERE id=?", versionID, promptID).Error
|
||||
})
|
||||
return promptID, err
|
||||
}
|
||||
|
||||
// ApplyPromptAction 执行旧版提示词接口的发布、停用或版本回滚事务。
|
||||
func (s *Service) ApplyPromptAction(adminID uuid.UUID, promptID, action, versionID, versionNote string) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
switch action {
|
||||
case "publish":
|
||||
if err := tx.Exec("UPDATE prompt_versions SET status=CASE WHEN id=(SELECT current_version_id FROM prompts WHERE id=?) THEN 'published' ELSE 'retired' END WHERE prompt_id=?", promptID, promptID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Exec("UPDATE prompts SET status='published' WHERE id=?", promptID).Error
|
||||
case "disable":
|
||||
return tx.Exec("UPDATE prompts SET status='disabled' WHERE id=?", promptID).Error
|
||||
case "rollback":
|
||||
var source struct {
|
||||
Content string
|
||||
Variables string
|
||||
}
|
||||
if err := tx.Raw("SELECT content,variables::text FROM prompt_versions WHERE id=? AND prompt_id=?", versionID, promptID).Scan(&source).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var version int
|
||||
if err := tx.Raw("SELECT coalesce(max(version),0)+1 FROM prompt_versions WHERE prompt_id=?", promptID).Scan(&version).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
newVersionID := uuid.NewString()
|
||||
if err := tx.Exec("INSERT INTO prompt_versions(id,prompt_id,version,content,variables,version_note,status,operator_id) VALUES(?,?,?,?,?::jsonb,?,'draft',?)", newVersionID, promptID, version, source.Content, source.Variables, versionNote, adminID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Exec("UPDATE prompts SET current_version_id=?,status='draft' WHERE id=?", newVersionID, promptID).Error
|
||||
default:
|
||||
return errors.New("不支持的提示词操作")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ListPromptHistory 查询旧版提示词接口保留的版本历史。
|
||||
func (s *Service) ListPromptHistory(promptID string) ([]map[string]any, error) {
|
||||
items := make([]map[string]any, 0)
|
||||
err := s.db.Table("prompt_versions v").
|
||||
Select("v.id,v.version,v.content,v.variables,v.version_note,v.status,v.created_at,a.username AS operator").
|
||||
Joins("LEFT JOIN admin_users a ON a.id=v.operator_id").Where("v.prompt_id=?", promptID).
|
||||
Order("v.version DESC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// ListUserPrompts 查询系统提示词、当前用户自定义提示词及用户选择。
|
||||
func (s *Service) ListUserPrompts(userID uuid.UUID, promptType string) (UserPromptList, error) {
|
||||
if IsFixedDramaParseType(promptType) {
|
||||
return UserPromptList{Prompts: []map[string]any{}}, nil
|
||||
}
|
||||
var prompts []map[string]any
|
||||
err := s.db.Table("prompts").Select("id,name,type,content,scope,owner_user_id,created_at,updated_at,(scope='user') AS editable").
|
||||
Where("deleted_at IS NULL AND type=? AND (scope='system' OR (scope='user' AND owner_user_id=?))", promptType, userID).
|
||||
Order("CASE WHEN scope='system' THEN 0 ELSE 1 END, updated_at DESC").Find(&prompts).Error
|
||||
if err != nil {
|
||||
return UserPromptList{}, err
|
||||
}
|
||||
var selected struct{ PromptID *uuid.UUID }
|
||||
// 用户未设置偏好时返回空属于正常情况,用 Limit(1).Find 避免触发 GORM 的 record not found 日志
|
||||
s.db.Table("user_prompt_preferences").Select("prompt_id").Where("user_id=? AND prompt_type=?", userID, promptType).Limit(1).Find(&selected)
|
||||
return UserPromptList{Prompts: prompts, SelectedID: selected.PromptID}, nil
|
||||
}
|
||||
|
||||
// SelectUserPrompt 更新用户对指定提示词类型的选择,空值表示关闭选择。
|
||||
func (s *Service) SelectUserPrompt(userID uuid.UUID, promptType string, promptID *uuid.UUID) error {
|
||||
if IsFixedDramaParseType(promptType) {
|
||||
return errors.New("该提示词由平台固定配置,不支持选择")
|
||||
}
|
||||
if promptID == nil {
|
||||
if err := s.db.Exec("DELETE FROM user_prompt_preferences WHERE user_id=? AND prompt_type=?", userID, promptType).Error; err != nil {
|
||||
return PersistenceError{Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if err := s.db.Table("prompts").Where("id=? AND type=? AND deleted_at IS NULL AND (scope='system' OR (scope='user' AND owner_user_id=?))", *promptID, promptType, userID).Count(&count).Error; err != nil {
|
||||
return PersistenceError{Err: err}
|
||||
}
|
||||
if count == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
if err := s.db.Exec(`INSERT INTO user_prompt_preferences(id,user_id,prompt_id,prompt_type) VALUES(gen_random_uuid(),?,?,?) ON CONFLICT(user_id,prompt_type) DO UPDATE SET prompt_id=excluded.prompt_id,updated_at=CURRENT_TIMESTAMP`, userID, *promptID, promptType).Error; err != nil {
|
||||
return PersistenceError{Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateCustomPrompt 校验并创建当前用户拥有的自定义提示词。
|
||||
func (s *Service) CreateCustomPrompt(userID uuid.UUID, input CustomPromptInput) (CustomPrompt, error) {
|
||||
input = normalizeCustomPromptInput(input)
|
||||
if input.Name == "" || input.Type == "" || input.Content == "" {
|
||||
return CustomPrompt{}, errors.New("name、type、content不能为空")
|
||||
}
|
||||
if IsFixedDramaParseType(input.Type) {
|
||||
return CustomPrompt{}, errors.New("该提示词由平台固定配置,不支持自定义")
|
||||
}
|
||||
item := CustomPrompt{ID: uuid.New(), Name: input.Name, Type: input.Type, Content: input.Content, Scope: "user", Editable: true}
|
||||
if err := s.db.Exec("INSERT INTO prompts(id,name,type,content,scope,owner_user_id) VALUES(?,?,?,?,'user',?)", item.ID, item.Name, item.Type, item.Content, userID).Error; err != nil {
|
||||
return CustomPrompt{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// UpdateCustomPrompt 校验并更新当前用户拥有的自定义提示词。
|
||||
func (s *Service) UpdateCustomPrompt(userID, promptID uuid.UUID, input CustomPromptInput) (CustomPrompt, error) {
|
||||
input = normalizeCustomPromptInput(input)
|
||||
if input.Name == "" || input.Content == "" {
|
||||
return CustomPrompt{}, errors.New("name、content不能为空")
|
||||
}
|
||||
if IsFixedDramaParseType(input.Type) {
|
||||
return CustomPrompt{}, errors.New("该提示词由平台固定配置,不支持自定义")
|
||||
}
|
||||
result := s.db.Exec("UPDATE prompts SET name=?,type=COALESCE(NULLIF(?,''),type),content=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND scope='user' AND owner_user_id=? AND deleted_at IS NULL", input.Name, input.Type, input.Content, promptID, userID)
|
||||
if result.Error != nil {
|
||||
return CustomPrompt{}, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return CustomPrompt{}, gorm.ErrRecordNotFound
|
||||
}
|
||||
return CustomPrompt{ID: promptID, Name: input.Name, Type: input.Type, Content: input.Content, Scope: "user", Editable: true}, nil
|
||||
}
|
||||
|
||||
// DeleteCustomPrompt 在同一事务中删除用户选择关系及其自定义提示词。
|
||||
func (s *Service) DeleteCustomPrompt(userID, promptID uuid.UUID) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("DELETE FROM user_prompt_preferences WHERE prompt_id=? AND user_id=?", promptID, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Exec("DELETE FROM prompts WHERE id=? AND scope='user' AND owner_user_id=?", promptID, userID)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeCustomPromptInput 清理用户输入两端空白,保证校验和写入使用一致值。
|
||||
func normalizeCustomPromptInput(input CustomPromptInput) CustomPromptInput {
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Type = strings.TrimSpace(input.Type)
|
||||
input.Content = strings.TrimSpace(input.Content)
|
||||
return input
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// 提示词模块单元测试,验证固定类型规则和用户输入标准化行为。
|
||||
package prompt
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestIsFixedDramaParseType 验证固定提示词类型及空白清理规则。
|
||||
func TestIsFixedDramaParseType(t *testing.T) {
|
||||
for _, value := range []string{"剧本解析", " 角色、场景、道具解析 "} {
|
||||
if !IsFixedDramaParseType(value) {
|
||||
t.Fatalf("expected %q to be fixed", value)
|
||||
}
|
||||
}
|
||||
if IsFixedDramaParseType("剧本分析") {
|
||||
t.Fatal("剧本分析不应被识别为固定解析提示词")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeCustomPromptInput 验证提示词字段在校验和写入前统一清理两端空白。
|
||||
func TestNormalizeCustomPromptInput(t *testing.T) {
|
||||
input := normalizeCustomPromptInput(CustomPromptInput{Name: " 名称 ", Type: " 类型\n", Content: " 内容 "})
|
||||
if input.Name != "名称" || input.Type != "类型" || input.Content != "内容" {
|
||||
t.Fatalf("unexpected normalized input: %#v", input)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user