feat(*): 创作工作台 - 完善媒体上传预览与连线交互并修复任务章节及日志筛选
This commit is contained in:
@@ -683,7 +683,7 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
logs, err := svc.AdminAPICallLogs(user, service.APICallLogQuery{AnalyticsQuery: analyticsQuery(c), Keyword: c.Query("keyword"), Status: c.Query("status"), Page: page, Limit: limit})
|
||||
logs, err := svc.AdminAPICallLogs(user, service.APICallLogQuery{AnalyticsQuery: analyticsQuery(c), RecordType: c.Query("recordType"), Keyword: c.Query("keyword"), Status: c.Query("status"), Page: page, Limit: limit})
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
@@ -776,7 +776,7 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
if value := strings.TrimSpace(c.Query("ids")); value != "" {
|
||||
selectedIDs = strings.Split(value, ",")
|
||||
}
|
||||
data, err := svc.AdminAPICallLogsCSV(user, service.APICallLogQuery{AnalyticsQuery: analyticsQuery(c), Keyword: c.Query("keyword"), Status: c.Query("status"), IDs: selectedIDs})
|
||||
data, err := svc.AdminAPICallLogsCSV(user, service.APICallLogQuery{AnalyticsQuery: analyticsQuery(c), RecordType: c.Query("recordType"), Keyword: c.Query("keyword"), Status: c.Query("status"), IDs: selectedIDs})
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
|
||||
@@ -21,11 +21,12 @@ type AnalyticsFilter struct {
|
||||
|
||||
type APICallLogFilter struct {
|
||||
AnalyticsFilter
|
||||
Keyword string
|
||||
Status string
|
||||
IDs []string
|
||||
Page int
|
||||
Limit int
|
||||
RecordType string
|
||||
Keyword string
|
||||
Status string
|
||||
IDs []string
|
||||
Page int
|
||||
Limit int
|
||||
}
|
||||
|
||||
func (r *Repository) RecordUserActivity(userID string, event string, count int, now time.Time) error {
|
||||
@@ -137,7 +138,14 @@ func (r *Repository) ExportAPICallLogs(filter APICallLogFilter, limit int) ([]mo
|
||||
}
|
||||
|
||||
func (r *Repository) filteredAPICallLogQuery(filter APICallLogFilter) *gorm.DB {
|
||||
query := visibleAPICallLogQuery(r.apiCallLogQuery(filter.AnalyticsFilter))
|
||||
query := r.apiCallLogQuery(filter.AnalyticsFilter)
|
||||
switch filter.RecordType {
|
||||
case "download":
|
||||
query = query.Where("api_call_logs.request_kind = ?", "download")
|
||||
case "all":
|
||||
default:
|
||||
query = visibleAPICallLogQuery(query).Where("COALESCE(api_call_logs.request_kind, '') <> ?", "download")
|
||||
}
|
||||
if value := strings.TrimSpace(filter.Keyword); value != "" {
|
||||
pattern := "%" + strings.ToLower(value) + "%"
|
||||
query = query.
|
||||
|
||||
@@ -18,6 +18,45 @@ type sqlCaptureLogger struct {
|
||||
statements []string
|
||||
}
|
||||
|
||||
func TestAPICallLogRecordTypeFiltersListAndExport(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:api-log-record-types?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.ApiCallLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
for _, item := range []model.ApiCallLog{
|
||||
{ID: "request", Capability: "video", RequestKind: "create", CreatedAt: now},
|
||||
{ID: "poll", Capability: "video", RequestKind: "poll", CreatedAt: now},
|
||||
{ID: "video-download", Capability: "video", RequestKind: "download", CreatedAt: now},
|
||||
{ID: "image-download", Capability: "image", RequestKind: "download", CreatedAt: now},
|
||||
} {
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
repo := New(db)
|
||||
for _, tc := range []struct {
|
||||
kind string
|
||||
count int
|
||||
}{{"", 1}, {"request", 1}, {"download", 2}, {"all", 4}} {
|
||||
filter := APICallLogFilter{AnalyticsFilter: AnalyticsFilter{From: now.Add(-time.Hour), To: now.Add(time.Hour)}, RecordType: tc.kind}
|
||||
logs, total, err := repo.QueryAPICallLogs(filter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(logs) != tc.count || total != int64(tc.count) {
|
||||
t.Fatalf("kind=%s: count=%d total=%d", tc.kind, len(logs), total)
|
||||
}
|
||||
exported, err := repo.ExportAPICallLogs(filter, 100)
|
||||
if err != nil || len(exported) != tc.count {
|
||||
t.Fatalf("kind=%s: export count=%d err=%v", tc.kind, len(exported), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *sqlCaptureLogger) LogMode(logger.LogLevel) logger.Interface { return l }
|
||||
func (*sqlCaptureLogger) Info(context.Context, string, ...any) {}
|
||||
func (*sqlCaptureLogger) Warn(context.Context, string, ...any) {}
|
||||
|
||||
@@ -103,11 +103,12 @@ type AnalyticsFailureRow struct {
|
||||
|
||||
type APICallLogQuery struct {
|
||||
AnalyticsQuery
|
||||
Keyword string
|
||||
Status string
|
||||
IDs []string
|
||||
Page int
|
||||
Limit int
|
||||
RecordType string
|
||||
Keyword string
|
||||
Status string
|
||||
IDs []string
|
||||
Page int
|
||||
Limit int
|
||||
}
|
||||
|
||||
type APICallLogPage struct {
|
||||
@@ -189,8 +190,11 @@ func (s *Service) AdminAPICallLogs(actor *model.User, query APICallLogQuery) (*A
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if query.RecordType != "" && query.RecordType != "request" && query.RecordType != "download" && query.RecordType != "all" {
|
||||
return nil, BadAuthRequest("请求明细类型无效")
|
||||
}
|
||||
filter := normalizeAnalyticsFilter(query.AnalyticsQuery)
|
||||
logs, total, err := s.repo.QueryAPICallLogs(repository.APICallLogFilter{AnalyticsFilter: filter, Keyword: query.Keyword, Status: query.Status, Page: query.Page, Limit: query.Limit})
|
||||
logs, total, err := s.repo.QueryAPICallLogs(repository.APICallLogFilter{AnalyticsFilter: filter, RecordType: query.RecordType, Keyword: query.Keyword, Status: query.Status, Page: query.Page, Limit: query.Limit})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -378,6 +382,9 @@ func (s *Service) AdminAPICallLogsCSV(actor *model.User, query APICallLogQuery)
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if query.RecordType != "" && query.RecordType != "request" && query.RecordType != "download" && query.RecordType != "all" {
|
||||
return nil, BadAuthRequest("请求明细类型无效")
|
||||
}
|
||||
filter := normalizeAnalyticsFilter(query.AnalyticsQuery)
|
||||
ids := uniqueNonEmpty(query.IDs)
|
||||
if len(query.IDs) > 0 && len(ids) == 0 {
|
||||
@@ -386,7 +393,7 @@ func (s *Service) AdminAPICallLogsCSV(actor *model.User, query APICallLogQuery)
|
||||
if len(ids) > 200 {
|
||||
return nil, BadAuthRequest("单次最多导出 200 条已选请求明细")
|
||||
}
|
||||
logs, err := s.repo.ExportAPICallLogs(repository.APICallLogFilter{AnalyticsFilter: filter, Keyword: query.Keyword, Status: query.Status, IDs: ids}, 10_000)
|
||||
logs, err := s.repo.ExportAPICallLogs(repository.APICallLogFilter{AnalyticsFilter: filter, RecordType: query.RecordType, Keyword: query.Keyword, Status: query.Status, IDs: ids}, 10_000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
27
backend/internal/service/chapter_assets_prompt_test.go
Normal file
27
backend/internal/service/chapter_assets_prompt_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChapterAssetsPromptContract(t *testing.T) {
|
||||
if !json.Valid([]byte(chapterAssetsJSONSchema)) {
|
||||
t.Fatal("invalid chapter assets schema")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
valid bool
|
||||
}{
|
||||
{`{"characters":[],"scenes":[{"name":"车站","description":"空旷站台","prompt":"夜间站台"}],"props":[{"name":"信封","description":"牛皮纸信封","prompt":"信封特写"}]}`, true},
|
||||
{`{"characters":[],"scenes":[],"props":[]}`, true},
|
||||
{`{"characters":[]}`, false},
|
||||
{`{"characters":[],"scenes":[{"name":"车站"}],"props":[]}`, false},
|
||||
{`{"characters":[{"name":"张三"}],"scenes":[],"props":[]}`, false},
|
||||
} {
|
||||
err := validatePromptTemplateResult(promptOperationChapterAssetsExtract, map[string]interface{}{"text": tc.text})
|
||||
if (err == nil) != tc.valid {
|
||||
t.Fatalf("valid=%v err=%v", tc.valid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,13 +382,40 @@ func validatePromptTemplateResult(operation string, result map[string]interface{
|
||||
extractor := extractJSONText
|
||||
// 角色卡契约要求顶层对象,模型正文里若先出现数组(例如先列一遍角色名)会让通用提取命中错误片段,
|
||||
// 因此这里优先挑出带 characters 键的对象候选。
|
||||
if operation == promptOperationCharacterExtract {
|
||||
if operation == promptOperationCharacterExtract || operation == promptOperationChapterAssetsExtract {
|
||||
extractor = func(raw string) (string, error) { return extractPreferredJSONText(raw, "characters") }
|
||||
}
|
||||
jsonText, err := extractor(text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s 返回内容不符合受保护 JSON 契约:%w", definition.Label, err)
|
||||
}
|
||||
if operation == promptOperationChapterAssetsExtract {
|
||||
var assets struct {
|
||||
Characters *[]map[string]interface{} `json:"characters"`
|
||||
Scenes *[]map[string]interface{} `json:"scenes"`
|
||||
Props *[]map[string]interface{} `json:"props"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jsonText), &assets); err != nil {
|
||||
return fmt.Errorf("章节资产提取 JSON 无法解析:%w", err)
|
||||
}
|
||||
if assets.Characters == nil || assets.Scenes == nil || assets.Props == nil {
|
||||
return errors.New("章节资产提取结果必须包含 characters、scenes 和 props 数组")
|
||||
}
|
||||
for _, group := range [][]map[string]interface{}{*assets.Scenes, *assets.Props} {
|
||||
for _, asset := range group {
|
||||
for _, field := range []string{"name", "description", "prompt"} {
|
||||
value, ok := asset[field].(string)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("章节场景或道具缺少有效的 %s", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(*assets.Characters) > 0 {
|
||||
return validatePromptTemplateResult(promptOperationCharacterExtract, map[string]interface{}{"text": jsonText})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if operation != promptOperationCharacterExtract {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
promptOperationStoryboardFirstFrame = "storyboard_first_frame"
|
||||
promptOperationStoryboardVideo = "storyboard_video"
|
||||
promptOperationCharacterExtract = "character_extract"
|
||||
promptOperationChapterAssetsExtract = "chapter_assets_extract"
|
||||
promptOperationCharacterTurnaround = "character_turnaround"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,12 @@ const legacyStoryboardVideoPromptPreamble = "生成单一连续镜头的视频
|
||||
|
||||
func defaultPromptDefinitions() []PromptOperationDefinition {
|
||||
return []PromptOperationDefinition{
|
||||
{
|
||||
Operation: promptOperationChapterAssetsExtract, Label: "章节角色、场景与道具提取", Category: "角色", OutputType: "json", SchemaKey: "chapter-assets/v1",
|
||||
Description: "从章节正文提取角色、实际发生剧情的场景和影响剧情的道具,分别进入资产待确认列表。",
|
||||
Variables: []PromptTemplateVariable{{Label: "项目名称", Placeholder: "{{项目名称}}"}, {Label: "章节名称", Placeholder: "{{章节名称}}"}, {Label: "项目画风", Placeholder: "{{项目画风}}"}},
|
||||
DefaultContent: `你是短剧资产导演。按章节事实提取角色、场景和道具。同一身份的别名合并,不编造正文未出现的资产;未明确的视觉信息写“正文未明确”。角色包含剧情定位、稳定外貌服装体态、性格、一致性提示、三视图提示及语言、声音年龄、音色。场景描述空间布局、环境和光照;道具描述材质、形状及剧情用途。无对应资产时返回空数组。`,
|
||||
},
|
||||
{
|
||||
Operation: promptOperationStoryboardPlan, Label: "分镜规划", Category: "分镜", OutputType: "json", SchemaKey: "storyboard-plan/v3",
|
||||
Description: "把剧情、项目画风和当前角色版本规划为可执行镜头。",
|
||||
@@ -105,6 +112,13 @@ func protectedPromptContext(operation string, values map[string]string) string {
|
||||
return storyboardRepairProtectedContext(values)
|
||||
case promptOperationCharacterExtract:
|
||||
return characterExtractProtectedContext(values)
|
||||
case promptOperationChapterAssetsExtract:
|
||||
return strings.Join([]string{
|
||||
fmt.Sprintf("【任务】\n从短剧项目《%s》的章节“%s”提取角色、场景和道具。", values["项目名称"], values["章节名称"]),
|
||||
"【项目画风】\n" + values["项目画风"],
|
||||
"【章节正文】\n" + values["章节正文"],
|
||||
"【受保护输出契约】\n" + promptOutputContract(operation),
|
||||
}, "\n\n")
|
||||
case promptOperationCharacterTurnaround:
|
||||
return strings.Join([]string{"【角色名称】\n" + values["角色名称"], "【项目画风】\n" + values["项目画风"], "【角色设定】\n" + values["角色设定"]}, "\n\n")
|
||||
default:
|
||||
@@ -118,6 +132,8 @@ func promptOutputContract(operation string) string {
|
||||
return "服务端固定 JSON Schema storyboard-plan/v3(不可由运营模板或用户定制覆盖):\n" + storyboardPlanJSONSchema
|
||||
case promptOperationCharacterExtract:
|
||||
return "服务端固定 JSON Schema character-breakdown/v1(不可由运营模板或用户定制覆盖):\n" + characterBreakdownJSONSchema
|
||||
case promptOperationChapterAssetsExtract:
|
||||
return "服务端固定 JSON Schema chapter-assets/v1(不可由运营模板或用户定制覆盖):\n" + chapterAssetsJSONSchema
|
||||
default:
|
||||
return "当前操作输出普通文本提示词,没有 JSON Schema。"
|
||||
}
|
||||
@@ -182,6 +198,24 @@ const storyboardPlanJSONSchema = `{
|
||||
}
|
||||
}`
|
||||
|
||||
const chapterAssetsJSONSchema = `{
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["characters", "scenes", "props"],
|
||||
"properties": {
|
||||
"characters": {"$ref": "#/$defs/characterBreakdown/properties/characters"},
|
||||
"scenes": {"type": "array", "items": {"$ref": "#/$defs/asset"}},
|
||||
"props": {"type": "array", "items": {"$ref": "#/$defs/asset"}}
|
||||
},
|
||||
"$defs": {
|
||||
"asset": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["name", "description", "prompt"],
|
||||
"properties": {"name": {"type": "string"}, "description": {"type": "string"}, "prompt": {"type": "string"}}
|
||||
},
|
||||
"characterBreakdown": ` + characterBreakdownJSONSchema + `
|
||||
}
|
||||
}`
|
||||
|
||||
const characterBreakdownJSONSchema = `{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
Reference in New Issue
Block a user