feat(*): 创作工作台 - 完善媒体上传预览与连线交互并修复任务章节及日志筛选

This commit is contained in:
ddCat
2026-09-08 17:26:34 +08:00
parent 95b004ec0f
commit 068a8daea6
56 changed files with 3316 additions and 274 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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) {}

View File

@@ -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
}

View 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)
}
}
}

View File

@@ -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
}

View File

@@ -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,