feat(skills): 技能管理 - 支持后台维护一级分类 (#694)

Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
Catlaxy AIandddcat666 authored and GitHub committed 2026-10-06 09:28:03 +08:00
1 parent fb4af9bd34
commit fe2b892f1f
29 files changed
+579 -37

No files matched your search

+2 -1
View File
@@ -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 {
+1
View File
@@ -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
}
+33 -4
View File
@@ -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)`
+7
View File
@@ -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
}
+28 -2
View File
@@ -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 = &current.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)
}
+6
View File
@@ -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,