feat(skills): 场景预设 - 提供公开只读目录并校验内置技能引用 (#603)
This commit is contained in:
@@ -24,6 +24,7 @@ type (
|
||||
SkillPackageBundleFile = skills.SkillPackageBundleFile
|
||||
SkillPackageBundle = skills.SkillPackageBundle
|
||||
SkillFileSearchResult = skills.SkillFileSearchResult
|
||||
SkillPreset = skills.SkillPreset
|
||||
)
|
||||
|
||||
func (s *Service) skillDomain() *skills.Service {
|
||||
@@ -44,6 +45,11 @@ func (s *Service) AddedSkills(userID string) ([]SkillItem, error) {
|
||||
return s.skillDomain().AddedSkills(userID)
|
||||
}
|
||||
|
||||
// SkillPresets 返回场景预设目录(只读;数据随技能种子发布,无需用户上下文)。
|
||||
func (s *Service) SkillPresets() ([]SkillPreset, error) {
|
||||
return s.skillDomain().SkillPresets()
|
||||
}
|
||||
|
||||
func (s *Service) SkillDetail(userID string, id string) (*SkillItem, error) {
|
||||
return s.skillDomain().SkillDetail(userID, id)
|
||||
}
|
||||
@@ -69,6 +75,10 @@ func (s *Service) SetSkillLiked(userID string, id string, liked bool) (*SkillIte
|
||||
}
|
||||
|
||||
func (s *Service) EnsureBuiltinSkills() error {
|
||||
// 在同步种子之前校验同版本的预设,避免服务启动后才在公开目录暴露坏引用。
|
||||
if _, err := s.SkillPresets(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.skillDomain().EnsureBuiltinSkills()
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ func TestRegisterCanvasAPIExposesOpenAPIAndProjects(t *testing.T) {
|
||||
"POST /api/tasks": false,
|
||||
"POST /api/tasks/:id/recover-media": false,
|
||||
"GET /api/resources": false,
|
||||
"GET /api/skills/presets": false,
|
||||
}
|
||||
for _, route := range router.Routes() {
|
||||
key := route.Method + " " + route.Path
|
||||
@@ -40,4 +41,10 @@ func TestRegisterCanvasAPIExposesOpenAPIAndProjects(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "openapi: 3.0.3") || !strings.Contains(recorder.Body.String(), "url: /api") {
|
||||
t.Fatalf("openapi.yaml status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/skills/presets", nil))
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"presets"`) {
|
||||
t.Fatalf("skills presets status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,15 @@ paths:
|
||||
"200": {description: 标准信封 data 为完整工具}
|
||||
"400": {description: 参数无效或预览资源不符合要求}
|
||||
"401": {description: 未登录}
|
||||
/skills/presets:
|
||||
get:
|
||||
tags: [canvas]
|
||||
security: []
|
||||
summary: 获取公开场景技能预设目录
|
||||
responses:
|
||||
"200":
|
||||
description: 标准信封 data 为 presets 数组;预设只引用公开且启用的种子技能
|
||||
"500": {$ref: "#/components/responses/Error"}
|
||||
/tools/{id}:
|
||||
parameters:
|
||||
- {in: path, name: id, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
|
||||
@@ -91,6 +91,16 @@ func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
ok(c, result)
|
||||
})
|
||||
|
||||
r.GET("/skills/presets", func(c *gin.Context) {
|
||||
// 场景预设目录:公开只读的市场元数据(与 /openapi.yaml 同模式,无用户数据)。
|
||||
presets, err := svc.SkillPresets()
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"presets": presets})
|
||||
})
|
||||
|
||||
r.GET("/skills/added", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
|
||||
@@ -283,6 +283,7 @@ type (
|
||||
SkillPackageBundleFile = app.SkillPackageBundleFile
|
||||
SkillPackageFileContent = app.SkillPackageFileContent
|
||||
SkillPackageFileItem = app.SkillPackageFileItem
|
||||
SkillPreset = app.SkillPreset
|
||||
SkillShowcaseMedia = app.SkillShowcaseMedia
|
||||
StorageMigrationSummary = app.StorageMigrationSummary
|
||||
StyleProfileFavoriteRequest = app.StyleProfileFavoriteRequest
|
||||
|
||||
87
backend/internal/skills/seed/presets.json
Normal file
87
backend/internal/skills/seed/presets.json
Normal file
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updated": "2026-09-22",
|
||||
"note": "场景预设内容层(平台层 RFC 见 项目/场景预设平台层-RFC-2026-09-22.md)。v1 只引用已进种子市场的技能(33 官方 + 35 judian 域包),未上架的 56 个 singles 不引用——避免用户一键挂载时遇到装不上的技能。skillIds 与上游 backend/internal/skills/seed/skills.json 对齐,变更前必须跑 presets_check.py 对账。",
|
||||
"presets": [
|
||||
{
|
||||
"presetId": "short-drama-starter",
|
||||
"name": "短剧爆款起步",
|
||||
"scene": "drama",
|
||||
"skillIds": ["16000000000081", "16000000000077", "16000000000091", "14811816970508"],
|
||||
"rationale": "新手第一站:实战手册管钩子/反转/爽点方法论,一镜到底流水线管单集秒级节奏,模型路由先答该用哪个模型,官方叙事分镜把节奏落地成表。一个预设覆盖从想法到分镜表。",
|
||||
"source": "hand-curated",
|
||||
"evidence": "E4",
|
||||
"upgrade": "singles 上架后,用三张 E1 实测卡(短剧开篇八法与付费点/爽点与钩子设计/反转结尾写法)替换对应域包位,粒度更轻;若 #590 显示用户反复跨对话重挂同一预设,再评估项目级默认(需 schema)。"
|
||||
},
|
||||
{
|
||||
"presetId": "short-drama-pilot",
|
||||
"name": "短剧立项与大纲",
|
||||
"scene": "drama",
|
||||
"skillIds": ["16000000000084", "16000000000073", "16000000000079", "16000000000075", "14811816983052"],
|
||||
"rationale": "从零立项一部短剧:结构引擎给坐标系,角色工坊立人物,系列弧光规划跨集,对白工坊磨台词;官方故事开发兜底生产。适合还没动笔的阶段。",
|
||||
"source": "hand-curated",
|
||||
"evidence": "E4",
|
||||
"upgrade": "视 #590 的 topCards 数据,把使用率低的域包换成同场景高命中单卡(待 singles 上架)。"
|
||||
},
|
||||
{
|
||||
"presetId": "ad-full-chain",
|
||||
"name": "广告全链路",
|
||||
"scene": "ecommerce",
|
||||
"skillIds": ["16000000000105", "16000000000098", "16000000000097", "16000000000095", "14519800321292"],
|
||||
"rationale": "调研→概念→脚本→分镜一次配齐:全链路打法是主战,消费心理给卖点依据,文案四步打磨标题与长文案,视觉提示词工程管画面;官方波普广告导演做最终生产。",
|
||||
"source": "hand-curated",
|
||||
"evidence": "E4",
|
||||
"upgrade": "电商场景模板包(分镜节点+batch-table)落地后,此预设可挂到模板上一并分发。"
|
||||
},
|
||||
{
|
||||
"presetId": "h3-video",
|
||||
"name": "H3 视频出片",
|
||||
"scene": "creative",
|
||||
"skillIds": ["16000000000090", "16000000000091"],
|
||||
"rationale": "即梦 H3 出片最小组合:提示词套件含六段式/首尾帧衔接/资产绑定,模型路由答该用哪个模型族。两个就够,不凑数。",
|
||||
"source": "hand-curated",
|
||||
"evidence": "E4",
|
||||
"upgrade": "若 H3 参考资产绑定类问题在 #590 的 readFailures 里占比高,把 singles 的 H3 参考资产绑定卡(E4 待测)纳入替换。"
|
||||
},
|
||||
{
|
||||
"presetId": "seedance-video",
|
||||
"name": "Seedance 生视频",
|
||||
"scene": "creative",
|
||||
"skillIds": ["16000000000093", "16000000000092"],
|
||||
"rationale": "Seedance 出片:提示词工程给八块公式与参考图语法,2.5 导演工艺补模式选择与多镜连续性。单镜头用前者,多镜连续再加后者。",
|
||||
"source": "hand-curated",
|
||||
"evidence": "E4",
|
||||
"upgrade": "Seedance 八块公式 single 已通过 E1 实测(2026-09-22),待 singles 上架后替换域包位。"
|
||||
},
|
||||
{
|
||||
"presetId": "ecom-image",
|
||||
"name": "电商大图与系列图",
|
||||
"scene": "ecommerce",
|
||||
"skillIds": ["16000000000095", "16000000000089", "16000000000105", "14811816982028"],
|
||||
"rationale": "产品图/系列图:视觉提示词工程管四层结构与控色,风格库给模板与陷阱,全链路打法深读产品描述与商品图模式卡;官方系列套图生成做批量生产。",
|
||||
"source": "hand-curated",
|
||||
"evidence": "E4",
|
||||
"upgrade": "singles 的生图五槽去油腻模板卡(E4 待测)上架后可作轻量替换项。"
|
||||
},
|
||||
{
|
||||
"presetId": "ai-performer",
|
||||
"name": "AI 角色表演",
|
||||
"scene": "drama",
|
||||
"skillIds": ["16000000000086", "16000000000073", "16000000000096", "14889899552012"],
|
||||
"rationale": "IP 短片/数字人:表演实验室管表情/肢体/情绪节拍提示词,角色工坊保人设一致,视觉基调定全片气质;官方微表情导演做视频生产。",
|
||||
"source": "hand-curated",
|
||||
"evidence": "E4",
|
||||
"upgrade": "AI 角色表演分层写法 single 待 E1 实测后(下批灯塔卡候选),可作替换位。"
|
||||
},
|
||||
{
|
||||
"presetId": "brand-positioning",
|
||||
"name": "品牌定位与策略",
|
||||
"scene": "ecommerce",
|
||||
"skillIds": ["16000000000102", "16000000000104", "16000000000099", "16000000000106"],
|
||||
"rationale": "不写片不拍片的日子用这组:定位策略找心智空格,故事营销把目的讲成故事,决策模型格森做判断,STEPPS 设计传播。纯思考层,无生产技能。",
|
||||
"source": "hand-curated",
|
||||
"evidence": "E4",
|
||||
"upgrade": "与官方技能无重叠冲突,可长期稳定;若定位类 singles(定位心智图等)上架,可拆出轻量版。"
|
||||
}
|
||||
]
|
||||
}
|
||||
131
backend/internal/skills/skills_presets.go
Normal file
131
backend/internal/skills/skills_presets.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed seed/presets.json
|
||||
var builtinSkillPresetsJSON []byte
|
||||
|
||||
// 场景预设(Scene Presets):把「说人话的场景」映射到一组已启用技能,
|
||||
// 让用户不必在 120+ 个技能里盲挑。v1 为只读目录,与技能种子同源发布。
|
||||
//
|
||||
// 分层契约:内容层(组合知识)在 judian-skills 的 presets.json 独立演进,
|
||||
// 本文件只做加载、校验与分发;preset 引用一律用 skillId(编号一编永不变),
|
||||
// 因此技能改名不影响预设有效性。
|
||||
type SkillPreset struct {
|
||||
PresetID string `json:"presetId"`
|
||||
Name string `json:"name"`
|
||||
Scene string `json:"scene"`
|
||||
SkillIDs []string `json:"skillIds"`
|
||||
Rationale string `json:"rationale"`
|
||||
Source string `json:"source"`
|
||||
Evidence string `json:"evidence"`
|
||||
Upgrade string `json:"upgrade"`
|
||||
}
|
||||
|
||||
type skillPresetsFile struct {
|
||||
Version int `json:"version"`
|
||||
Updated string `json:"updated"`
|
||||
Note string `json:"note"`
|
||||
Presets []SkillPreset `json:"presets"`
|
||||
}
|
||||
|
||||
var skillPresetIDPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
|
||||
var skillPresetScenes = map[string]struct{}{
|
||||
"drama": {}, "creative": {}, "ecommerce": {}, "social": {}, "others": {},
|
||||
}
|
||||
|
||||
var skillPresetEvidence = map[string]struct{}{
|
||||
"E1": {}, "E2": {}, "E3": {}, "E4": {}, "E5": {},
|
||||
}
|
||||
|
||||
// SkillPresets 返回校验合格的场景预设目录;数据非法时返回错误(启动即失败好过线上坏数据)。
|
||||
func (s *Service) SkillPresets() ([]SkillPreset, error) {
|
||||
var file skillPresetsFile
|
||||
if err := json.Unmarshal(builtinSkillPresetsJSON, &file); err != nil {
|
||||
return nil, fmt.Errorf("解析场景预设失败: %w", err)
|
||||
}
|
||||
if file.Version != 1 {
|
||||
return nil, fmt.Errorf("场景预设版本必须为 1,当前 %d", file.Version)
|
||||
}
|
||||
if len(file.Presets) == 0 {
|
||||
return nil, fmt.Errorf("场景预设不能为空")
|
||||
}
|
||||
seeded := make(map[string]struct{}, 128)
|
||||
seedIDs, err := builtinSeedSkillIDs()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("校验场景预设引用失败: %w", err)
|
||||
}
|
||||
for _, definition := range seedIDs {
|
||||
seeded[definition] = struct{}{}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(file.Presets))
|
||||
for _, preset := range file.Presets {
|
||||
id := strings.TrimSpace(preset.PresetID)
|
||||
if !skillPresetIDPattern.MatchString(id) {
|
||||
return nil, fmt.Errorf("场景预设 ID 非法: %q", preset.PresetID)
|
||||
}
|
||||
if _, dup := seen[id]; dup {
|
||||
return nil, fmt.Errorf("场景预设 ID 重复: %s", id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
if strings.TrimSpace(preset.Name) == "" {
|
||||
return nil, fmt.Errorf("场景预设 %s 缺名称", id)
|
||||
}
|
||||
if _, ok := skillPresetScenes[preset.Scene]; !ok {
|
||||
return nil, fmt.Errorf("场景预设 %s 的分类非法: %q", id, preset.Scene)
|
||||
}
|
||||
if len(preset.SkillIDs) == 0 || len(preset.SkillIDs) > 8 {
|
||||
return nil, fmt.Errorf("场景预设 %s 的技能数 %d 超出 1-8(每轮激活上限)", id, len(preset.SkillIDs))
|
||||
}
|
||||
presetSeen := make(map[string]struct{}, len(preset.SkillIDs))
|
||||
for _, skillID := range preset.SkillIDs {
|
||||
if _, dup := presetSeen[skillID]; dup {
|
||||
return nil, fmt.Errorf("场景预设 %s 的技能重复: %s", id, skillID)
|
||||
}
|
||||
presetSeen[skillID] = struct{}{}
|
||||
// v1 硬约束:只允许引用种子市场已上架技能,避免用户一键挂载时遇到装不上的技能。
|
||||
if _, ok := seeded[skillID]; !ok {
|
||||
return nil, fmt.Errorf("场景预设 %s 引用了未上架技能: %s", id, skillID)
|
||||
}
|
||||
}
|
||||
if preset.Source != "hand-curated" {
|
||||
return nil, fmt.Errorf("场景预设 %s 的 source 必须为 hand-curated(v1 禁止遥测驱动)", id)
|
||||
}
|
||||
if _, ok := skillPresetEvidence[preset.Evidence]; !ok {
|
||||
return nil, fmt.Errorf("场景预设 %s 的证据等级非法: %q", id, preset.Evidence)
|
||||
}
|
||||
if rationale := strings.TrimSpace(preset.Rationale); rationale == "" || len([]rune(rationale)) > 200 {
|
||||
return nil, fmt.Errorf("场景预设 %s 的推荐理由为空或超过 200 字", id)
|
||||
}
|
||||
}
|
||||
return file.Presets, nil
|
||||
}
|
||||
|
||||
// builtinSeedSkillIDs 从内置种子清单提取全部 skillId,供预设引用对账。
|
||||
func builtinSeedSkillIDs() ([]string, error) {
|
||||
var definitions []builtinSkillDefinition
|
||||
if err := json.Unmarshal(builtinSkillsJSON, &definitions); err != nil {
|
||||
return nil, fmt.Errorf("解析内置技能失败: %w", err)
|
||||
}
|
||||
definitions = append(definitions, builtinImageEditingSkillDefinitions()...)
|
||||
ids := make([]string, 0, len(definitions))
|
||||
for _, definition := range definitions {
|
||||
if definition.Status != skillStatusEnabled || definition.IsPrivate {
|
||||
continue
|
||||
}
|
||||
if id := strings.TrimSpace(definition.SkillID); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("内置种子技能不能为空")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
109
backend/internal/skills/skills_presets_test.go
Normal file
109
backend/internal/skills/skills_presets_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSkillPresetsCatalogIsValid(t *testing.T) {
|
||||
svc := New(nil, "", nil)
|
||||
presets, err := svc.SkillPresets()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(presets) == 0 {
|
||||
t.Fatal("场景预设目录不能为空")
|
||||
}
|
||||
seeded := make(map[string]struct{}, 128)
|
||||
seedIDs, err := builtinSeedSkillIDs()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range seedIDs {
|
||||
seeded[id] = struct{}{}
|
||||
}
|
||||
if len(seeded) == 0 {
|
||||
t.Fatal("内置种子技能为空,对账失去意义")
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, preset := range presets {
|
||||
if _, dup := seen[preset.PresetID]; dup {
|
||||
t.Fatalf("presetId 重复: %s", preset.PresetID)
|
||||
}
|
||||
seen[preset.PresetID] = struct{}{}
|
||||
if len(preset.SkillIDs) == 0 || len(preset.SkillIDs) > 8 {
|
||||
t.Fatalf("%s 技能数 %d 超出 1-8", preset.PresetID, len(preset.SkillIDs))
|
||||
}
|
||||
for _, skillID := range preset.SkillIDs {
|
||||
// v1 硬约束:preset 只允许引用种子市场已上架技能。
|
||||
if _, ok := seeded[skillID]; !ok {
|
||||
t.Fatalf("%s 引用未上架技能 %s", preset.PresetID, skillID)
|
||||
}
|
||||
}
|
||||
if preset.Rationale == "" {
|
||||
t.Fatalf("%s 缺推荐理由", preset.PresetID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillPresetsRejectUnseededSkillReference(t *testing.T) {
|
||||
// 防回归:preset 引用一个不在种子清单里的 skillId 时必须被校验拦下,
|
||||
// 否则用户一键挂载会遇到装不上的技能(v1 明确禁止)。
|
||||
original := builtinSkillPresetsJSON
|
||||
defer func() { builtinSkillPresetsJSON = original }()
|
||||
builtinSkillPresetsJSON = []byte(`{"version":1,"presets":[{"presetId":"bad","name":"坏预设","scene":"drama","skillIds":["16000000000099","99999999999999"],"rationale":"引用了不存在的技能","source":"hand-curated","evidence":"E4","upgrade":""}]}`)
|
||||
svc := New(nil, "", nil)
|
||||
if _, err := svc.SkillPresets(); err == nil || !strings.Contains(err.Error(), "未上架技能") {
|
||||
t.Fatalf("应拒绝未上架技能引用,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillPresetsRejectInvalidSkillSeed(t *testing.T) {
|
||||
original := builtinSkillsJSON
|
||||
defer func() { builtinSkillsJSON = original }()
|
||||
builtinSkillsJSON = []byte(`not-json`)
|
||||
if _, err := New(nil, "", nil).SkillPresets(); err == nil || !strings.Contains(err.Error(), "解析内置技能失败") {
|
||||
t.Fatalf("应明确报告内置技能种子损坏,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillPresetsIgnoreDisabledOrPrivateSeeds(t *testing.T) {
|
||||
original := builtinSkillsJSON
|
||||
defer func() { builtinSkillsJSON = original }()
|
||||
builtinSkillsJSON = []byte(`[
|
||||
{"skill_id":"enabled-public","status":1,"is_private":false},
|
||||
{"skill_id":"disabled-public","status":0,"is_private":false},
|
||||
{"skill_id":"enabled-private","status":1,"is_private":true}
|
||||
]`)
|
||||
|
||||
originalPresets := builtinSkillPresetsJSON
|
||||
defer func() { builtinSkillPresetsJSON = originalPresets }()
|
||||
builtinSkillPresetsJSON = []byte(`{"version":1,"presets":[{"presetId":"ok","name":"可用预设","scene":"drama","skillIds":["enabled-public"],"rationale":"只引用公开启用技能","source":"hand-curated","evidence":"E4","upgrade":""}]}`)
|
||||
|
||||
if _, err := New(nil, "", nil).SkillPresets(); err != nil {
|
||||
t.Fatalf("公开启用种子应可被预设引用: %v", err)
|
||||
}
|
||||
|
||||
for _, skillID := range []string{"disabled-public", "enabled-private"} {
|
||||
builtinSkillPresetsJSON = []byte(`{"version":1,"presets":[{"presetId":"bad","name":"非法预设","scene":"drama","skillIds":["` + skillID + `"],"rationale":"不应通过校验","source":"hand-curated","evidence":"E4","upgrade":""}]}`)
|
||||
if _, err := New(nil, "", nil).SkillPresets(); err == nil || !strings.Contains(err.Error(), "未上架技能") {
|
||||
t.Fatalf("%s 不应被视为已上架,实际: %v", skillID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillPresetsRejectOverBudgetAndTelemetrySource(t *testing.T) {
|
||||
original := builtinSkillPresetsJSON
|
||||
defer func() { builtinSkillPresetsJSON = original }()
|
||||
// 超过 8 个激活位必须被拒(影策每轮最多激活 8 个技能)。
|
||||
builtinSkillPresetsJSON = []byte(`{"version":1,"presets":[{"presetId":"big","name":"超大预设","scene":"drama","skillIds":["a","b","c","d","e","f","g","h","i"],"rationale":"超预算","source":"hand-curated","evidence":"E4","upgrade":""}]}`)
|
||||
svc := New(nil, "", nil)
|
||||
if _, err := svc.SkillPresets(); err == nil || !strings.Contains(err.Error(), "1-8") {
|
||||
t.Fatalf("应拒绝超 8 技能预设,实际: %v", err)
|
||||
}
|
||||
// v1 只允许手工策展:遥测驱动的预设要等 #590 有数据后才放开。
|
||||
builtinSkillPresetsJSON = []byte(`{"version":1,"presets":[{"presetId":"tele","name":"遥测预设","scene":"drama","skillIds":["16000000000099"],"rationale":"数据驱动","source":"telemetry","evidence":"E4","upgrade":""}]}`)
|
||||
if _, err := svc.SkillPresets(); err == nil || !strings.Contains(err.Error(), "hand-curated") {
|
||||
t.Fatalf("应拒绝非手工策展来源,实际: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user