feat(skills): 技能管理 - 支持后台维护一级分类 (#694)
Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
1 parent
fb4af9bd34
commit
fe2b892f1f
29 files changed
+579
-37
No files matched your search
@@ -11,7 +11,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const CurrentSchemaVersion int64 = 46
|
||||
const CurrentSchemaVersion int64 = 47
|
||||
|
||||
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
|
||||
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
|
||||
@@ -147,6 +147,7 @@ var schemaMigrations = []migration{
|
||||
return tx.AutoMigrate(&model.TopupProduct{}, &model.PaymentOrder{})
|
||||
}},
|
||||
{version: 46, name: "skill_curation", checksum: "sha256:skill-curation-v46-20261005", apply: migrateSkillCuration},
|
||||
{version: 47, name: "skill_curation_roots", checksum: "sha256:skill-curation-roots-v47-20261005", apply: migrateSkillCurationRoots},
|
||||
}
|
||||
|
||||
func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func migrateSkillCurationRoots(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&model.SkillCurationRoot{}, &model.SkillCurationRootAssignment{}); err != nil {
|
||||
return err
|
||||
}
|
||||
roots := model.DefaultSkillCurationRoots()
|
||||
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&roots).Error
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSkillCurationRootsMigration(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "roots.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
defer sqlDB.Close()
|
||||
if err := db.Transaction(migrateSkillCuration); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Create(&model.SkillCurationCategory{ID: "old", Name: "Existing", NormalizedName: "existing", RootTag: "drama", Enabled: true})
|
||||
if err := db.Transaction(migrateSkillCurationRoots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var roots []model.SkillCurationRoot
|
||||
db.Find(&roots)
|
||||
if len(roots) != 5 {
|
||||
t.Fatalf("neutral defaults: %d", len(roots))
|
||||
}
|
||||
db.Model(&model.SkillCurationRoot{}).Where("id = ?", "drama").Updates(map[string]any{"name": "Edited", "enabled": false})
|
||||
if err := db.Transaction(migrateSkillCurationRoots); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var root model.SkillCurationRoot
|
||||
db.First(&root, "id = ?", "drama")
|
||||
if root.Name != "Edited" || root.Enabled {
|
||||
t.Fatal("migration overwrote root")
|
||||
}
|
||||
var category model.SkillCurationCategory
|
||||
db.First(&category, "id = ?", "old")
|
||||
if category.RootTag != "drama" {
|
||||
t.Fatal("migration changed U39 categories")
|
||||
}
|
||||
found := false
|
||||
for _, m := range schemaMigrations {
|
||||
if m.version == 47 {
|
||||
found = m.name == "skill_curation_roots" && m.checksum == "sha256:skill-curation-roots-v47-20261005"
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("migration 47 is not registered")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ func TestSkillCurationHTTP(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
if err := db.AutoMigrate(&model.SkillCurationRoot{}, &model.SkillCurationRootAssignment{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
defer sqlDB.Close()
|
||||
if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.Skill{}, &model.SkillCurationSetting{}, &model.SkillCurationCategory{}, &model.SkillCurationAssignment{}, &model.AdminAuditEvent{}); err != nil {
|
||||
|
||||
@@ -89,6 +89,7 @@ func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
return
|
||||
}
|
||||
result, err := svc.Skills(user.ID, service.SkillListRequest{
|
||||
PlatformRootID: c.Query("platformRootId"),
|
||||
PlatformCategoryID: c.Query("platformCategoryId"), PlatformUncategorized: c.Query("platformUncategorized") == "true",
|
||||
Page: page, PageSize: pageSize, Scope: c.DefaultQuery("scope", "public"),
|
||||
Search: c.Query("search"), Tag: c.Query("tag"), Sort: c.DefaultQuery("sort", "popular"),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package model
|
||||
|
||||
type SkillCurationRoot struct {
|
||||
ID string `gorm:"primaryKey;size:36" json:"id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
NormalizedName string `gorm:"size:128;not null;uniqueIndex" json:"-"`
|
||||
IconKey string `gorm:"size:32" json:"iconKey"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type SkillCurationRootAssignment struct {
|
||||
SkillID string `gorm:"primaryKey;size:36" json:"skillId"`
|
||||
RootID string `gorm:"size:36;not null;index" json:"rootId"`
|
||||
}
|
||||
|
||||
// These neutral defaults match the original catalog. No skill content or
|
||||
// skill-to-category mapping is shipped by the migration.
|
||||
func DefaultSkillCurationRoots() []SkillCurationRoot {
|
||||
return []SkillCurationRoot{
|
||||
{ID: "drama", Name: "短剧影视", NormalizedName: "短剧影视", IconKey: "film", SortOrder: 10, Enabled: true},
|
||||
{ID: "ecommerce", Name: "电商营销", NormalizedName: "电商营销", IconKey: "shopping-bag", SortOrder: 20, Enabled: true},
|
||||
{ID: "creative", Name: "创意设计", NormalizedName: "创意设计", IconKey: "palette", SortOrder: 30, Enabled: true},
|
||||
{ID: "social", Name: "社媒内容", NormalizedName: "社媒内容", IconKey: "users", SortOrder: 40, Enabled: true},
|
||||
{ID: "others", Name: "其他", NormalizedName: "其他", IconKey: "shapes", SortOrder: 50, Enabled: true},
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ func (r *Repository) DeleteBuiltinSkill(skillID string, actorID string) error {
|
||||
if err := tx.Delete(&model.SkillCurationAssignment{}, "skill_id = ?", skillID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(&model.SkillCurationRootAssignment{}, "skill_id = ?", skillID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "skill_id"}}, DoUpdates: clause.AssignmentColumns([]string{"deleted_by", "deleted_at"})}).Create(&model.BuiltinSkillTombstone{SkillID: skillID, DeletedBy: actorID, DeletedAt: now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
|
||||
type SkillCuration struct {
|
||||
model.SkillCurationSetting
|
||||
Categories []model.SkillCurationCategory `json:"categories"`
|
||||
Assignments []model.SkillCurationAssignment `json:"assignments"`
|
||||
Roots []model.SkillCurationRoot `json:"roots,omitempty"`
|
||||
RootAssignments []model.SkillCurationRootAssignment `json:"rootAssignments,omitempty"`
|
||||
Categories []model.SkillCurationCategory `json:"categories"`
|
||||
Assignments []model.SkillCurationAssignment `json:"assignments"`
|
||||
}
|
||||
|
||||
// Read the version before and after the snapshot so concurrent edits cannot
|
||||
@@ -25,6 +27,21 @@ func (r *Repository) SkillCuration(admin bool) (*SkillCuration, error) {
|
||||
if !admin && !out.Enabled {
|
||||
return out, nil
|
||||
}
|
||||
roots, err := r.SkillCurationRoots()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, root := range roots {
|
||||
if admin || root.Enabled {
|
||||
out.Roots = append(out.Roots, root)
|
||||
}
|
||||
}
|
||||
if !admin {
|
||||
out.Roots = append(out.Roots, model.SkillCurationRoot{ID: UnassignedSkillRoot, Name: "未归入启用分类", IconKey: "shapes", Enabled: true})
|
||||
}
|
||||
if err := r.db.Model(&model.SkillCurationRootAssignment{}).Select("skill_curation_root_assignments.*").Joins("JOIN skills ON skills.id = skill_curation_root_assignments.skill_id").Where("skills.status = 1 AND skills.is_private = false").Order("skill_id").Find(&out.RootAssignments).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := r.db.Order("sort_order, id")
|
||||
if !admin {
|
||||
query = query.Where("enabled = ?", true)
|
||||
@@ -32,10 +49,22 @@ func (r *Repository) SkillCuration(admin bool) (*SkillCuration, error) {
|
||||
if err := query.Find(&out.Categories).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !admin {
|
||||
visible := []model.SkillCurationCategory{}
|
||||
for _, c := range out.Categories {
|
||||
for _, root := range out.Roots {
|
||||
if root.ID == c.RootTag {
|
||||
visible = append(visible, c)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
out.Categories = visible
|
||||
}
|
||||
query = r.db.Model(&model.SkillCurationAssignment{}).Select("skill_curation_assignments.*").
|
||||
Joins("JOIN skills ON skills.id = skill_curation_assignments.skill_id").Where("skills.status = 1 AND skills.is_private = false")
|
||||
if !admin {
|
||||
query = query.Joins("JOIN skill_curation_categories c ON c.id = skill_curation_assignments.category_id").Where("c.enabled = true AND c.root_tag = skills.tag")
|
||||
query = query.Joins("JOIN skill_curation_categories c ON c.id = skill_curation_assignments.category_id").Where("c.enabled = true AND c.root_tag = " + effectiveCurationRoot)
|
||||
}
|
||||
if err := query.Order("skill_id, category_id").Find(&out.Assignments).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -111,4 +140,4 @@ func (r *Repository) AssignSkillCuration(skillID string, ids []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const effectiveCurationCategory = `SELECT 1 FROM skill_curation_assignments a JOIN skill_curation_categories c ON c.id = a.category_id WHERE a.skill_id = skills.id AND c.enabled = true AND c.root_tag = skills.tag AND skills.is_private = false`
|
||||
const effectiveCurationCategory = `SELECT 1 FROM skill_curation_assignments a JOIN skill_curation_categories c ON c.id = a.category_id WHERE a.skill_id = skills.id AND c.enabled = true AND c.root_tag = ` + effectiveCurationRoot + ` AND skills.is_private = false`
|
||||
@@ -0,0 +1,65 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
const UnassignedSkillRoot = "__unassigned__"
|
||||
|
||||
func (r *Repository) SkillCurationRoots() ([]model.SkillCurationRoot, error) {
|
||||
rows := []model.SkillCurationRoot{}
|
||||
if err := r.db.Order("sort_order, id").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return model.DefaultSkillCurationRoots(), nil
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
func (r *Repository) SaveSkillCurationRoot(root *model.SkillCurationRoot) error {
|
||||
// Materialize the fallback before its first edit, keeping existing children
|
||||
// attached to their stable root IDs even after an empty-table fallback.
|
||||
var count int64
|
||||
if err := r.db.Model(&model.SkillCurationRoot{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
rows := model.DefaultSkillCurationRoots()
|
||||
if err := r.db.Create(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return r.db.Save(root).Error
|
||||
}
|
||||
func (r *Repository) AssignSkillCurationRoot(skillID, rootID string) error {
|
||||
if err := r.db.Delete(&model.SkillCurationRootAssignment{}, "skill_id = ?", skillID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if rootID == "" {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(&model.SkillCurationRootAssignment{SkillID: skillID, RootID: rootID}).Error
|
||||
}
|
||||
|
||||
func (state *SkillCuration) EffectiveRoot(skill *model.Skill) string {
|
||||
id := skill.Tag
|
||||
if !skill.IsPrivate {
|
||||
for _, a := range state.RootAssignments {
|
||||
if a.SkillID == skill.ID {
|
||||
id = a.RootID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, root := range state.Roots {
|
||||
if root.ID == id && root.Enabled {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return UnassignedSkillRoot
|
||||
}
|
||||
|
||||
// Explicit assignments override the source tag. A disabled/missing target is
|
||||
// kept discoverable in the default bucket; it never falls into another root.
|
||||
const curationRootID = `COALESCE((SELECT ra.root_id FROM skill_curation_root_assignments ra WHERE ra.skill_id = skills.id AND skills.is_private = false),skills.tag)`
|
||||
const effectiveCurationRoot = `(CASE WHEN NOT EXISTS (SELECT 1 FROM skill_curation_roots) THEN CASE WHEN ` + curationRootID + ` IN ('drama','ecommerce','creative','social','others') THEN ` + curationRootID + ` ELSE '__unassigned__' END ELSE COALESCE((SELECT r.id FROM skill_curation_roots r WHERE r.enabled = true AND r.id = ` + curationRootID + `), '__unassigned__') END)`
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
)
|
||||
|
||||
type SkillListFilter struct {
|
||||
PlatformRootID string
|
||||
PlatformCategoryID string
|
||||
PlatformUncategorized bool
|
||||
UserID string
|
||||
@@ -79,6 +80,9 @@ func (r *Repository) Skills(filter SkillListFilter) ([]model.Skill, int64, error
|
||||
if filter.Tag != "" {
|
||||
query = query.Where("skills.tag = ?", filter.Tag)
|
||||
}
|
||||
if filter.PlatformRootID != "" {
|
||||
query = query.Where(effectiveCurationRoot+" = ?", filter.PlatformRootID)
|
||||
}
|
||||
if filter.PlatformCategoryID != "" {
|
||||
query = query.Where("EXISTS ("+effectiveCurationCategory+" AND c.id = ?)", filter.PlatformCategoryID)
|
||||
}
|
||||
@@ -147,6 +151,9 @@ func (r *Repository) DeleteSkill(id string) error {
|
||||
if err := tx.Delete(&model.SkillCurationAssignment{}, "skill_id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(&model.SkillCurationRootAssignment{}, "skill_id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(&model.UserSkillState{}, "skill_id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ import (
|
||||
|
||||
type SkillCuration = repository.SkillCuration
|
||||
type SkillCurationAssignmentInput struct {
|
||||
RootID *string `json:"rootId,omitempty"`
|
||||
SkillID string `json:"skillId"`
|
||||
CategoryIDs []string `json:"categoryIds"`
|
||||
}
|
||||
type SkillCurationUpdate struct {
|
||||
Root *model.SkillCurationRoot `json:"root,omitempty"`
|
||||
ExpectedRevision *int64 `json:"expectedRevision"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Category *model.SkillCurationCategory `json:"category,omitempty"`
|
||||
@@ -46,6 +48,9 @@ func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate
|
||||
return nil, err
|
||||
}
|
||||
count := 0
|
||||
if req.Root != nil {
|
||||
count++
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
count++
|
||||
}
|
||||
@@ -66,11 +71,14 @@ func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Root != nil {
|
||||
return saveCurationRoot(repo, state, *req.Root)
|
||||
}
|
||||
if req.Category != nil {
|
||||
c := *req.Category
|
||||
c.Name = strings.Join(strings.Fields(c.Name), " ")
|
||||
c.NormalizedName = strings.ToLower(c.Name)
|
||||
if utf8.RuneCountInString(c.Name) < 1 || utf8.RuneCountInString(c.Name) > 64 || skillCategoryLabels[c.RootTag] == "" {
|
||||
if utf8.RuneCountInString(c.Name) < 1 || utf8.RuneCountInString(c.Name) > 64 || !activeCurationRoot(state, c.RootTag) {
|
||||
return kernel.BadAuthRequest("分类名称或一级分类无效")
|
||||
}
|
||||
exists := c.ID == ""
|
||||
@@ -101,11 +109,24 @@ func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate
|
||||
if err != nil {
|
||||
return kernel.NotFound("公开技能不存在")
|
||||
}
|
||||
rootID := state.EffectiveRoot(skill)
|
||||
if a.RootID != nil {
|
||||
if *a.RootID != "" && !activeCurationRoot(state, *a.RootID) {
|
||||
return kernel.BadAuthRequest("一级分类必须存在且启用")
|
||||
}
|
||||
rootID = *a.RootID
|
||||
if rootID == "" {
|
||||
rootID = skill.Tag
|
||||
if !activeCurationRoot(state, rootID) {
|
||||
rootID = repository.UnassignedSkillRoot
|
||||
}
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, id := range a.CategoryIDs {
|
||||
valid := false
|
||||
for _, c := range state.Categories {
|
||||
if c.ID == id && c.Enabled && c.RootTag == skill.Tag {
|
||||
if c.ID == id && c.Enabled && c.RootTag == rootID {
|
||||
valid = true
|
||||
}
|
||||
}
|
||||
@@ -114,6 +135,11 @@ func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
if a.RootID != nil {
|
||||
if err := repo.AssignSkillCurationRoot(a.SkillID, *a.RootID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return repo.AssignSkillCuration(a.SkillID, a.CategoryIDs)
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"infinite-canvas/backend/internal/kernel"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func activeCurationRoot(state *SkillCuration, id string) bool {
|
||||
if id == repository.UnassignedSkillRoot {
|
||||
return false
|
||||
}
|
||||
for _, root := range state.Roots {
|
||||
if root.ID == id && root.Enabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func saveCurationRoot(repo *repository.Repository, state *SkillCuration, root model.SkillCurationRoot) error {
|
||||
root.Name = strings.Join(strings.Fields(root.Name), " ")
|
||||
root.NormalizedName = strings.ToLower(root.Name)
|
||||
if utf8.RuneCountInString(root.Name) < 1 || utf8.RuneCountInString(root.Name) > 64 {
|
||||
return kernel.BadAuthRequest("一级分类名称必须为1-64个字符")
|
||||
}
|
||||
switch root.IconKey {
|
||||
case "", "shapes", "film", "shopping-bag", "megaphone", "landmark", "palette", "users", "wrench":
|
||||
default:
|
||||
return kernel.BadAuthRequest("图示键无效")
|
||||
}
|
||||
exists := root.ID == ""
|
||||
for _, old := range state.Roots {
|
||||
if old.ID == root.ID {
|
||||
exists = true
|
||||
}
|
||||
if old.ID != root.ID && old.NormalizedName == root.NormalizedName {
|
||||
return kernel.BadAuthRequest("一级分类名称不能重复")
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
return kernel.NotFound("一级分类不存在")
|
||||
}
|
||||
if root.ID == "" {
|
||||
root.ID = kernel.NewID()
|
||||
}
|
||||
return repo.SaveSkillCurationRoot(&root)
|
||||
}
|
||||
|
||||
func (s *Service) curationRootFilter(id string) (string, error) {
|
||||
if id == "" {
|
||||
return "", nil
|
||||
}
|
||||
state, err := s.repo.SkillCuration(false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !state.Enabled {
|
||||
return "", nil
|
||||
}
|
||||
if id == repository.UnassignedSkillRoot || activeCurationRoot(state, id) {
|
||||
return id, nil
|
||||
}
|
||||
return "", kernel.BadAuthRequest("一级分类已停用或不存在,请重新选择")
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSkillCurationRootManagementAvailable(t *testing.T) {
|
||||
if _, ok := reflect.TypeOf(SkillCurationUpdate{}).FieldByName("Root"); !ok {
|
||||
t.Fatal("missing root management: U39 only supports the five hard-coded root tags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillCurationRootsLifecycle(t *testing.T) {
|
||||
svc, db, actor := newCurationTest(t)
|
||||
state, err := svc.SkillCuration(actor, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defaults := model.DefaultSkillCurationRoots()
|
||||
if !reflect.DeepEqual(state.Roots, defaults) {
|
||||
t.Fatalf("empty table fallback changed: %#v", state.Roots)
|
||||
}
|
||||
update := func(req SkillCurationUpdate) *SkillCuration {
|
||||
t.Helper()
|
||||
current, err := svc.SkillCuration(actor, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.ExpectedRevision = ¤t.Revision
|
||||
out, err := svc.UpdateSkillCuration(actor, req, "root-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
enabled := true
|
||||
update(SkillCurationUpdate{Enabled: &enabled})
|
||||
state = update(SkillCurationUpdate{Root: &model.SkillCurationRoot{Name: "New root", IconKey: "landmark", SortOrder: -10, Enabled: true}})
|
||||
root := state.Roots[0]
|
||||
if len(state.Roots) != 6 || root.Name != "New root" {
|
||||
t.Fatalf("root creation/order: %#v", state.Roots)
|
||||
}
|
||||
state = update(SkillCurationUpdate{Category: &model.SkillCurationCategory{Name: "Child", RootTag: root.ID, Enabled: true}})
|
||||
child := state.Categories[0]
|
||||
for _, skill := range []model.Skill{{ID: "assigned", Name: "Assigned", Tag: "drama", Status: 1}, {ID: "legacy", Name: "Legacy", Tag: "drama", Status: 1}, {ID: "unknown", Name: "Unknown", Tag: "unknown", Status: 1}, {ID: "private", Name: "Private", Tag: "drama", Status: 1, IsPrivate: true, OwnerID: actor.ID}} {
|
||||
if err := db.Create(&skill).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
state = update(SkillCurationUpdate{Assignment: &SkillCurationAssignmentInput{SkillID: "assigned", RootID: &root.ID, CategoryIDs: []string{child.ID}}})
|
||||
for _, req := range []SkillListRequest{{PlatformRootID: root.ID}, {PlatformCategoryID: child.ID}, {PlatformRootID: repository.UnassignedSkillRoot}} {
|
||||
list, err := svc.Skills(actor.ID, req)
|
||||
if err != nil || list.TotalCount != 1 {
|
||||
t.Fatalf("root/child/default list: %#v %v", list, err)
|
||||
}
|
||||
}
|
||||
skill, err := svc.repo.Skill("assigned")
|
||||
if err != nil || skill.Tag != "drama" {
|
||||
t.Fatal("root assignment changed source tag")
|
||||
}
|
||||
// Root changes must replace children atomically; reject incompatible old children.
|
||||
drama := "drama"
|
||||
_, err = svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &state.Revision, Assignment: &SkillCurationAssignmentInput{SkillID: "assigned", RootID: &drama, CategoryIDs: []string{child.ID}}}, "")
|
||||
if appErrorStatus(err) != 400 {
|
||||
t.Fatalf("cross-root update: %v", err)
|
||||
}
|
||||
current, _ := svc.SkillCuration(actor, true)
|
||||
if current.Revision != state.Revision || current.EffectiveRoot(skill) != root.ID {
|
||||
t.Fatal("failed change partially committed")
|
||||
}
|
||||
root.Enabled = false
|
||||
root.Name = "Renamed root"
|
||||
state = update(SkillCurationUpdate{Root: &root})
|
||||
public, err := svc.SkillCuration(actor, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(public.Categories) != 0 || len(public.Assignments) != 0 {
|
||||
t.Fatal("disabled root still exposes children")
|
||||
}
|
||||
list, err := svc.Skills(actor.ID, SkillListRequest{PlatformRootID: repository.UnassignedSkillRoot})
|
||||
if err != nil || list.TotalCount != 2 {
|
||||
t.Fatalf("disabled root lost skills: %#v %v", list, err)
|
||||
}
|
||||
root.Enabled = true
|
||||
update(SkillCurationUpdate{Root: &root})
|
||||
clear := ""
|
||||
update(SkillCurationUpdate{Assignment: &SkillCurationAssignmentInput{SkillID: "assigned", RootID: &clear, CategoryIDs: []string{}}})
|
||||
list, err = svc.Skills(actor.ID, SkillListRequest{PlatformRootID: "drama"})
|
||||
if err != nil || list.TotalCount != 2 {
|
||||
t.Fatalf("clear override: %#v %v", list, err)
|
||||
}
|
||||
off := false
|
||||
update(SkillCurationUpdate{Enabled: &off})
|
||||
original, err := svc.Skills(actor.ID, SkillListRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filtered, err := svc.Skills(actor.ID, SkillListRequest{PlatformRootID: "does-not-exist"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, _ := json.Marshal(original)
|
||||
b, _ := json.Marshal(filtered)
|
||||
if string(a) != string(b) {
|
||||
t.Fatal("disabled root filter changed original response")
|
||||
}
|
||||
public, err = svc.SkillCuration(actor, false)
|
||||
if err != nil || len(public.Roots) != 0 || len(public.RootAssignments) != 0 {
|
||||
t.Fatalf("disabled mode leaked roots: %#v %v", public, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillCurationRootsValidation(t *testing.T) {
|
||||
svc, _, actor := newCurationTest(t)
|
||||
revision := int64(0)
|
||||
for _, root := range []model.SkillCurationRoot{
|
||||
{Name: "Bad icon", IconKey: "https://example.invalid/icon.svg", Enabled: true},
|
||||
{Name: "短剧影视", Enabled: true},
|
||||
{ID: "unknown", Name: "Unknown", Enabled: true},
|
||||
} {
|
||||
if _, err := svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &revision, Root: &root}, ""); err == nil {
|
||||
t.Fatalf("accepted invalid root: %#v", root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillCurationRootsDeletionAndPrivateVisibility(t *testing.T) {
|
||||
svc, db, actor := newCurationTest(t)
|
||||
if err := db.AutoMigrate(&model.SkillVersion{}, &model.SkillFile{}, &model.BuiltinSkillTombstone{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{"normal", "builtin", "private"} {
|
||||
db.Create(&model.Skill{ID: id, Tag: "drama", Status: 1, IsPrivate: id == "private", OwnerID: actor.ID})
|
||||
db.Create(&model.SkillCurationRootAssignment{SkillID: id, RootID: "social"})
|
||||
}
|
||||
db.Model(&model.SkillCurationSetting{}).Where("id = 1").Update("enabled", true)
|
||||
state, err := svc.SkillCuration(actor, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(state.RootAssignments) != 2 {
|
||||
t.Fatalf("private override exposed: %#v", state.RootAssignments)
|
||||
}
|
||||
list, err := svc.Skills(actor.ID, SkillListRequest{Scope: "mine", PlatformRootID: "drama"})
|
||||
if err != nil || list.TotalCount != 1 || list.Skills[0].SkillID != "private" {
|
||||
t.Fatalf("private skill root changed: %#v %v", list, err)
|
||||
}
|
||||
if err := svc.repo.DeleteSkill("normal"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.repo.DeleteBuiltinSkill("builtin", actor.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&model.SkillCurationRootAssignment{}).Where("skill_id != ?", "private").Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("deleted root assignment retained")
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ func TestSkillCurationManagementAvailable(t *testing.T) {
|
||||
func newCurationTest(t *testing.T) (*Service, *gorm.DB, *model.User) {
|
||||
t.Helper()
|
||||
svc, db := newSkillLibraryCategoryTestService(t)
|
||||
if err := db.AutoMigrate(&model.SkillCurationRoot{}, &model.SkillCurationRootAssignment{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SkillCurationSetting{}, &model.SkillCurationCategory{}, &model.SkillCurationAssignment{}, &model.AdminAuditEvent{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ type SkillCategory struct {
|
||||
}
|
||||
|
||||
type SkillListRequest struct {
|
||||
PlatformRootID string
|
||||
PlatformCategoryID string
|
||||
PlatformUncategorized bool
|
||||
Page int
|
||||
@@ -147,6 +148,10 @@ func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rootID, err := s.curationRootFilter(req.PlatformRootID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.LibraryCategoryID != "" && req.LibraryUncategorized {
|
||||
return nil, kernel.BadAuthRequest("技能库分类与未分类筛选不能同时使用")
|
||||
}
|
||||
@@ -156,6 +161,7 @@ func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error
|
||||
}
|
||||
}
|
||||
rows, total, err := s.repo.Skills(repository.SkillListFilter{
|
||||
PlatformRootID: rootID,
|
||||
PlatformCategoryID: categoryID, PlatformUncategorized: uncategorized,
|
||||
UserID: userID,
|
||||
Scope: req.Scope,
|
||||
|
||||
Reference in new issue
Block a user