From 04d5a9c4f08d02f367b23593e6b205e9acdeff33 Mon Sep 17 00:00:00 2001 From: Catlaxy AI Date: Tue, 6 Oct 2026 09:23:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(skills):=20=E5=86=85=E7=BD=AE=E6=8A=80?= =?UTF-8?q?=E8=83=BD=20-=20=E5=B1=95=E7=A4=BA=E5=AA=92=E4=BD=93=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=20snake=5Fcase=20=E5=AD=97=E6=AE=B5=20(#687)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内置技能 frontmatter 的 showcaseMedia 使用 showcase_uri/showcase_url,加载器直接解码到 camelCase 的 SkillShowcaseMedia,封面地址在入库前就丢失;当前 71 个内置技能中有 66 个受影响,接口返回的展示媒体地址为空(读取端已有的 snake_case 兼容无法补回入库前丢失的值)。 - 内置加载器同时接受 snake_case 与 camelCase,统一输出 camelCase,数据库与公开 API 契约不变 - 同一字段两种写法的值不一致时记录技能 ID 与原因并跳过该技能,其他技能继续加载;showcaseMedia 不是数组时同样跳过 - 加载器增加接受 fs.FS 的内部入口,正式入口仍使用 embed FS - 补充测试:自带最小 fixture 与真实内置技能的封面地址保留、字段格式与冲突、跳过隔离与日志 Co-authored-by: Claude Opus 5.5 --- backend/internal/skills/skills_builtin.go | 71 +++++++-- .../skills/skills_builtin_showcase_test.go | 146 ++++++++++++++++++ 2 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 backend/internal/skills/skills_builtin_showcase_test.go diff --git a/backend/internal/skills/skills_builtin.go b/backend/internal/skills/skills_builtin.go index 7c0ab2a5a..d150a9eea 100644 --- a/backend/internal/skills/skills_builtin.go +++ b/backend/internal/skills/skills_builtin.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io/fs" + "log" "path" "strconv" "strings" @@ -80,7 +81,13 @@ func (s *Service) EnsureBuiltinSkills() error { func loadBuiltinSkillPackages(tombstones interface { BuiltinSkillTombstoned(string) (bool, error) }) ([]builtinSkillPackage, error) { - entries, err := builtinSkillFiles.ReadDir(".") + return loadBuiltinSkillPackagesFromFS(builtinSkillFiles, tombstones) +} + +func loadBuiltinSkillPackagesFromFS(filesystem fs.FS, tombstones interface { + BuiltinSkillTombstoned(string) (bool, error) +}) ([]builtinSkillPackage, error) { + entries, err := fs.ReadDir(filesystem, ".") if err != nil { return nil, fmt.Errorf("读取内置技能目录失败: %w", err) } @@ -100,7 +107,7 @@ func loadBuiltinSkillPackages(tombstones interface { seen[skillID] = struct{}{} base := skillID - body, err := builtinSkillFiles.ReadFile(path.Join(base, "SKILL.md")) + body, err := fs.ReadFile(filesystem, path.Join(base, "SKILL.md")) if err != nil { return nil, fmt.Errorf("读取内置技能 %s 入口文件失败: %w", skillID, err) } @@ -135,7 +142,7 @@ func loadBuiltinSkillPackages(tombstones interface { } files := make(map[string][]byte) - if err := fs.WalkDir(builtinSkillFiles, base, func(filePath string, item fs.DirEntry, walkErr error) error { + if err := fs.WalkDir(filesystem, base, func(filePath string, item fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } @@ -147,7 +154,7 @@ func loadBuiltinSkillPackages(tombstones interface { if err != nil { return err } - content, err := builtinSkillFiles.ReadFile(path.Join(base, relative)) + content, err := fs.ReadFile(filesystem, path.Join(base, relative)) if err != nil { return err } @@ -161,11 +168,10 @@ func loadBuiltinSkillPackages(tombstones interface { return nil, fmt.Errorf("内置技能 %s 文件包无效: %w", skillID, err) } - showcaseMedia := []SkillShowcaseMedia{} - if metadata.ShowcaseMediaRaw != "" { - if err := json.Unmarshal([]byte(metadata.ShowcaseMediaRaw), &showcaseMedia); err != nil { - return nil, fmt.Errorf("内置技能 %s 展示媒体无效: %w", skillID, err) - } + showcaseMedia, err := parseBuiltinShowcaseMedia(metadata.ShowcaseMediaRaw) + if err != nil { + log.Printf("内置技能 %s 展示媒体无效,跳过加载: %v", skillID, err) + continue } mediaJSON, err := json.Marshal(showcaseMedia) if err != nil { @@ -186,6 +192,53 @@ func loadBuiltinSkillPackages(tombstones interface { return packages, nil } +func parseBuiltinShowcaseMedia(raw string) ([]SkillShowcaseMedia, error) { + items := []SkillShowcaseMedia{} + if strings.TrimSpace(raw) == "" { + return items, nil + } + if !strings.HasPrefix(strings.TrimSpace(raw), "[") { + return nil, fmt.Errorf("showcaseMedia 必须是数组") + } + var entries []map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &entries); err != nil { + return nil, err + } + for index, entry := range entries { + for _, field := range []struct{ snake, camel string }{ + {"showcase_uri", "showcaseUri"}, {"showcase_url", "showcaseUrl"}, + } { + legacy, exists := entry[field.snake] + if !exists { + continue + } + if current, exists := entry[field.camel]; exists { + var a, b string + if err := json.Unmarshal(legacy, &a); err != nil { + return nil, err + } + if err := json.Unmarshal(current, &b); err != nil { + return nil, err + } + if a != b { + return nil, fmt.Errorf("showcaseMedia[%d] 的 %s 与 %s 冲突", index, field.snake, field.camel) + } + } + entry[field.camel] = legacy + } + data, err := json.Marshal(entry) + if err != nil { + return nil, err + } + var item SkillShowcaseMedia + if err := json.Unmarshal(data, &item); err != nil { + return nil, err + } + items = append(items, item) + } + return items, nil +} + func normalizeBuiltinSkillMetadata(skillID string, metadata builtinSkillMetadata) (builtinSkillMetadata, error) { if metadata.Owner == "" { metadata.Owner = "yingce-system" diff --git a/backend/internal/skills/skills_builtin_showcase_test.go b/backend/internal/skills/skills_builtin_showcase_test.go new file mode 100644 index 000000000..d27da5b3c --- /dev/null +++ b/backend/internal/skills/skills_builtin_showcase_test.go @@ -0,0 +1,146 @@ +package skills + +import ( + "bytes" + "encoding/json" + "errors" + "io/fs" + "log" + "strings" + "testing" + "testing/fstest" + + "infinite-canvas/backend/builtin" +) + +func TestBuiltinShowcasePreservesSnakeCaseURL(t *testing.T) { + const path = "珠宝电商图文视频一站式/SKILL.md" + t.Run("self-contained", func(t *testing.T) { + files := fstest.MapFS{ + path: &fstest.MapFile{Data: []byte(`--- +name: 测试 +description: 描述 +metadata: + showcaseMedia: [{"type":"image","showcase_uri":"fixture/image.png","showcase_url":"https://example.com/image.png"}] +--- +正文 +`)}, + } + assertBuiltinShowcasePreservesSnakeCaseURL(t, files, path) + }) + + var files fs.FS = builtin.FS + // Only absence of the sample from the catalog is optional. Unreadable files + // and real samples rejected by the loader must still fail. + if _, err := fs.Stat(files, path); err != nil { + if errors.Is(err, fs.ErrNotExist) { + t.Log("real snake_case sample absent from catalog; self-contained fixture verified") + return + } + t.Fatal(err) + } + t.Run("real-builtin", func(t *testing.T) { + assertBuiltinShowcasePreservesSnakeCaseURL(t, files, path) + }) +} + +func assertBuiltinShowcasePreservesSnakeCaseURL(t *testing.T, files fs.FS, path string) { + t.Helper() + packages, err := loadBuiltinSkillPackagesFromFS(files, nil) + if err != nil { + t.Fatal(err) + } + for _, item := range packages { + if item.skill.MarkdownURL != path { + continue + } + metadata, err := parseBuiltinSkillMetadata(item.archive.Files["SKILL.md"]) + if err != nil { + t.Fatal(err) + } + var source []map[string]string + if err := json.Unmarshal([]byte(metadata.ShowcaseMediaRaw), &source); err != nil { + t.Fatal(err) + } + var got []SkillShowcaseMedia + if err := json.Unmarshal([]byte(item.skill.ShowcaseMediaJSON), &got); err != nil { + t.Fatal(err) + } + if len(source) == 0 || source[0]["showcase_url"] == "" || len(got) != len(source) { + t.Fatal("expected nonempty snake_case showcase fixture") + } + if got[0].ShowcaseURL != source[0]["showcase_url"] { + t.Fatalf("builtin showcase URL = %q, want original nonempty snake_case URL", got[0].ShowcaseURL) + } + return + } + t.Fatal("builtin fixture not found") +} + +func TestBuiltinShowcaseFormats(t *testing.T) { + for _, tc := range []struct { + name, raw, uri, url, wantError string + count int + }{ + {"snake", `[{"type":"image","showcase_uri":"uri","showcase_url":"https://example.com/a.png"}]`, "uri", "https://example.com/a.png", "", 1}, + {"camel", `[{"type":"image","showcaseUri":"uri","showcaseUrl":"https://example.com/a.png"}]`, "uri", "https://example.com/a.png", "", 1}, + {"equal", `[{"type":"image","showcase_uri":"uri","showcaseUri":"uri","showcase_url":"url","showcaseUrl":"url"}]`, "uri", "url", "", 1}, + {"conflicting URL", `[{"showcase_url":"a","showcaseUrl":"b"}]`, "", "", "showcase_url 与 showcaseUrl 冲突", 0}, + {"conflicting URI", `[{"showcase_uri":"a","showcaseUri":"b"}]`, "", "", "showcase_uri 与 showcaseUri 冲突", 0}, + {"explicit empty conflicts", `[{"showcase_url":"a","showcaseUrl":""}]`, "", "", "冲突", 0}, + {"missing fields", `[{"type":"image"}]`, "", "", "", 1}, + {"missing metadata", "", "", "", "", 0}, + {"empty array", `[]`, "", "", "", 0}, + {"object", `{}`, "", "", "必须是数组", 0}, + {"string", `"url"`, "", "", "必须是数组", 0}, + {"null", `null`, "", "", "必须是数组", 0}, + {"invalid field", `[{"showcase_url":123}]`, "", "", "cannot unmarshal", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + items, err := parseBuiltinShowcaseMedia(tc.raw) + if tc.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("error = %v, want %s", err, tc.wantError) + } + return + } + if err != nil || len(items) != tc.count { + t.Fatalf("items = %#v, error = %v", items, err) + } + if len(items) > 0 && (items[0].ShowcaseURI != tc.uri || items[0].ShowcaseURL != tc.url) { + t.Fatalf("items = %#v", items) + } + encoded, err := json.Marshal(items) + if err != nil || strings.Contains(string(encoded), "showcase_") { + t.Fatalf("output = %s, error = %v", encoded, err) + } + if len(items) > 0 && (!strings.Contains(string(encoded), `"showcaseUri"`) || !strings.Contains(string(encoded), `"showcaseUrl"`)) { + t.Fatalf("missing camelCase keys: %s", encoded) + } + }) + } +} + +func TestBuiltinShowcaseSkipsInvalidSkillAndLogsReason(t *testing.T) { + fixture := func(raw string) *fstest.MapFile { + return &fstest.MapFile{Data: []byte("---\nname: 测试\ndescription: 描述\nmetadata:\n showcaseMedia: " + raw + "\n---\n正文\n")} + } + files := fstest.MapFS{ + "a-conflict/SKILL.md": fixture(`[{"showcase_url":"a","showcaseUrl":"b"}]`), + "b-invalid/SKILL.md": fixture(`{}`), + "c-valid/SKILL.md": fixture(`[{"type":"image","showcase_url":"https://example.com/a.png"}]`), + } + var output bytes.Buffer + previous := log.Writer() + log.SetOutput(&output) + t.Cleanup(func() { log.SetOutput(previous) }) + packages, err := loadBuiltinSkillPackagesFromFS(files, nil) + if err != nil || len(packages) != 1 || packages[0].skill.ID != "c-valid" { + t.Fatalf("packages = %#v, error = %v", packages, err) + } + for _, want := range []string{"a-conflict", "showcase_url 与 showcaseUrl 冲突", "b-invalid", "必须是数组", "跳过加载"} { + if !strings.Contains(output.String(), want) { + t.Errorf("log missing %q: %s", want, output.String()) + } + } +}