fix(skills): 内置技能 - 展示媒体兼容 snake_case 字段 (#687)
内置技能 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
146
backend/internal/skills/skills_builtin_showcase_test.go
Normal file
146
backend/internal/skills/skills_builtin_showcase_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user