feat(skills): 技能管理 - 增加可选的平台子分类策展 (#693)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Catlaxy AIandClaude Opus 5.5 authored and GitHub committed 2026-10-06 09:24:21 +08:00
1 parent 5976389ed4
commit c963fc7b89
32 files changed
+981 -24

No files matched your search

@@ -0,0 +1,16 @@
package app
import (
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/skills"
)
type SkillCuration = skills.SkillCuration
type SkillCurationUpdate = skills.SkillCurationUpdate
func (s *Service) SkillCuration(actor *model.User, admin bool) (*SkillCuration, error) {
return s.skillDomain().SkillCuration(actor, admin)
}
func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate, requestID string) (*SkillCuration, error) {
return s.skillDomain().UpdateSkillCuration(actor, req, requestID)
}
+3 -1
View File
@@ -11,7 +11,7 @@ import (
"gorm.io/gorm"
)
const CurrentSchemaVersion int64 = 43
const CurrentSchemaVersion int64 = 46
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
@@ -55,6 +55,7 @@ type migration struct {
}
var schemaMigrations = []migration{
// 44 and 45 are reserved for independent migrations.
{version: 1, name: "baseline_gorm_schema", checksum: baselineSchemaChecksum, apply: migrateSchemaV1},
{version: 2, name: "schema_migrations_applied_at_index", checksum: schemaMigrationAppliedAtIndexChecksum, apply: migrateSchemaV2},
{version: 3, name: "asset_taxonomy_candidate_identity", checksum: assetTaxonomyCandidateIdentityChecksum, apply: migrateSchemaV3},
@@ -145,6 +146,7 @@ var schemaMigrations = []migration{
{version: 43, name: "topup_sale_strategies", checksum: "sha256:topup-sale-strategies-v43-20260929", apply: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.TopupProduct{}, &model.PaymentOrder{})
}},
{version: 46, name: "skill_curation", checksum: "sha256:skill-curation-v46-20261005", apply: migrateSkillCuration},
}
func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error {
@@ -0,0 +1,14 @@
package database
import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
"infinite-canvas/backend/internal/model"
)
func migrateSkillCuration(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.SkillCurationSetting{}, &model.SkillCurationCategory{}, &model.SkillCurationAssignment{}); err != nil {
return err
}
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.SkillCurationSetting{ID: 1}).Error
}
@@ -0,0 +1,104 @@
package database
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"infinite-canvas/backend/internal/model"
"path/filepath"
"testing"
)
func TestSkillCurationUpgradeAndRollback(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "upgrade.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
if err := db.AutoMigrate(&schemaMigration{}); err != nil {
t.Fatal(err)
}
for _, m := range schemaMigrations {
if m.version > 43 {
break
}
if err := db.Transaction(func(tx *gorm.DB) error {
if err := m.apply(tx); err != nil {
return err
}
return tx.Create(&schemaMigration{Version: m.version, Name: m.name, Checksum: m.checksum}).Error
}); err != nil {
t.Fatal(err)
}
}
if db.Migrator().HasTable(&model.SkillCurationSetting{}) {
t.Fatal("fixture is not an original schema")
}
if err := db.Create(&model.Skill{ID: "existing", Name: "Existing", Tag: "drama", Status: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`CREATE TRIGGER reject_curation_migration BEFORE INSERT ON schema_migrations WHEN NEW.version = 46 BEGIN SELECT RAISE(ABORT, 'migration interrupted'); END`).Error; err != nil {
t.Fatal(err)
}
if err := MigrateSchema(db); err == nil {
t.Fatal("forced migration failure accepted")
}
if db.Migrator().HasTable(&model.SkillCurationSetting{}) {
t.Fatal("failed migration left partial tables")
}
if err := db.Exec("DROP TRIGGER reject_curation_migration").Error; err != nil {
t.Fatal(err)
}
if err := MigrateSchema(db); err != nil {
t.Fatal(err)
}
if err := MigrateSchema(db); err != nil {
t.Fatal(err)
}
var skill model.Skill
if err := db.First(&skill, "id = ?", "existing").Error; err != nil || skill.Tag != "drama" {
t.Fatalf("existing skill changed: %#v %v", skill, err)
}
var setting model.SkillCurationSetting
if err := db.First(&setting, 1).Error; err != nil || setting.Enabled {
t.Fatalf("upgrade enabled curation: %#v %v", setting, err)
}
}
func TestSkillCurationMigration(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "migration.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
for i := 0; i < 2; i++ {
if err := db.Transaction(migrateSkillCuration); err != nil {
t.Fatal(err)
}
}
var state model.SkillCurationSetting
if err := db.First(&state, 1).Error; err != nil {
t.Fatal(err)
}
if state.Enabled || state.Revision != 0 {
t.Fatalf("migration must default off: %#v", state)
}
db.Model(&state).Updates(map[string]any{"enabled": true, "revision": 3})
if err := db.Transaction(migrateSkillCuration); err != nil {
t.Fatal(err)
}
db.First(&state, 1)
if !state.Enabled || state.Revision != 3 {
t.Fatal("migration overwrote settings")
}
found := false
for _, m := range schemaMigrations {
if m.version == 46 {
found = m.name == "skill_curation" && m.checksum == "sha256:skill-curation-v46-20261005"
}
}
if !found {
t.Fatal("migration 46 is not registered")
}
}
@@ -0,0 +1,54 @@
package handler
import (
"encoding/json"
"github.com/gin-gonic/gin"
"infinite-canvas/backend/internal/service"
"io"
"net/http"
)
func registerSkillCurationRoutes(r *gin.RouterGroup, svc *service.Service) {
read := func(admin bool) gin.HandlerFunc {
return func(c *gin.Context) {
actor, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
result, err := svc.SkillCuration(actor, admin)
if err != nil {
failService(c, err)
return
}
ok(c, result)
}
}
r.GET("/skills/curation", read(false))
r.GET("/admin/skill-curation", read(true))
r.PUT("/admin/skill-curation", func(c *gin.Context) {
actor, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
dec := json.NewDecoder(c.Request.Body)
dec.DisallowUnknownFields()
var req service.SkillCurationUpdate
if err := dec.Decode(&req); err != nil {
failService(c, service.BadAuthRequest("分类请求格式无效"))
return
}
if err := dec.Decode(new(any)); err != io.EOF {
failService(c, service.BadAuthRequest("只允许一个请求对象"))
return
}
result, err := svc.UpdateSkillCuration(actor, req, RequestID(c))
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
}
@@ -0,0 +1,62 @@
package handler
import (
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"infinite-canvas/backend/internal/auth"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"infinite-canvas/backend/internal/service"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestSkillCurationHTTP(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
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 {
t.Fatal(err)
}
db.Create(&model.SkillCurationSetting{ID: 1})
for _, role := range []model.UserRole{model.UserRoleAdmin, model.UserRoleUser} {
db.Create(&model.User{ID: string(role), Username: string(role), Role: role, Status: model.UserStatusActive})
db.Create(&model.AuthSession{ID: string(role), UserID: string(role), TokenHash: auth.HashToken("test-token"), ExpiresAt: time.Now().Add(time.Hour)})
}
router := gin.New()
RegisterSkillRoutes(router.Group("/api"), service.New(repository.New(db), t.TempDir()))
for _, tc := range []struct {
role, method, path, body string
status int
}{
{"", "GET", "/api/skills/curation", "", 401},
{"user", "GET", "/api/admin/skill-curation", "", 403},
{"user", "PUT", "/api/admin/skill-curation", `{"expectedRevision":0,"enabled":true}`, 403},
{"user", "GET", "/api/skills/curation", "", 200},
{"admin", "PUT", "/api/admin/skill-curation", `{"enabled":true}`, 400},
{"admin", "PUT", "/api/admin/skill-curation", `{"expectedRevision":0,"enabled":true,"requestId":"forged"}`, 400},
{"admin", "PUT", "/api/admin/skill-curation", `{"expectedRevision":0,"enabled":true} {}`, 400},
{"admin", "PUT", "/api/admin/skill-curation", `{"expectedRevision":0,"enabled":true}`, 200},
{"admin", "PUT", "/api/admin/skill-curation", `{"expectedRevision":0,"enabled":false}`, 409},
} {
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
if tc.role != "" {
req.AddCookie(&http.Cookie{Name: service.SessionCookieName, Value: tc.role + ".test-token"})
}
out := httptest.NewRecorder()
router.ServeHTTP(out, req)
if out.Code != tc.status {
t.Fatalf("%s %s %s: %d %s", tc.role, tc.method, tc.path, out.Code, out.Body.String())
}
}
}
+2
View File
@@ -15,6 +15,7 @@ import (
)
func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) {
registerSkillCurationRoutes(r, svc)
r.POST("/skills/install", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
@@ -88,6 +89,7 @@ func RegisterSkillRoutes(r *gin.RouterGroup, svc *service.Service) {
return
}
result, err := svc.Skills(user.ID, service.SkillListRequest{
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"),
LibraryCategoryID: c.Query("libraryCategoryId"), LibraryUncategorized: libraryUncategorized,
+21
View File
@@ -0,0 +1,21 @@
package model
type SkillCurationSetting struct {
ID int `gorm:"primaryKey;autoIncrement:false" json:"-"`
Enabled bool `json:"enabled"`
Revision int64 `json:"revision"`
}
type SkillCurationCategory struct {
ID string `gorm:"primaryKey;size:36" json:"id"`
RootTag string `gorm:"size:36;not null;uniqueIndex:idx_curation_category_name" json:"rootTag"`
Name string `gorm:"size:64;not null" json:"name"`
NormalizedName string `gorm:"size:128;not null;uniqueIndex:idx_curation_category_name" json:"-"`
SortOrder int `json:"sortOrder"`
Enabled bool `json:"enabled"`
}
type SkillCurationAssignment struct {
SkillID string `gorm:"primaryKey;size:36" json:"skillId"`
CategoryID string `gorm:"primaryKey;size:36;index" json:"categoryId"`
}
@@ -21,6 +21,13 @@ func (r *Repository) BuiltinSkillTombstoned(skillID string) (bool, error) {
func (r *Repository) DeleteBuiltinSkill(skillID string, actorID string) error {
now := time.Now().UTC()
return r.db.Transaction(func(tx *gorm.DB) error {
var locked model.Skill
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, "id = ?", skillID).Error; err != nil {
return err
}
if err := tx.Delete(&model.SkillCurationAssignment{}, "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
}
@@ -0,0 +1,114 @@
package repository
import (
"encoding/json"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/model"
)
type SkillCuration struct {
model.SkillCurationSetting
Categories []model.SkillCurationCategory `json:"categories"`
Assignments []model.SkillCurationAssignment `json:"assignments"`
}
// Read the version before and after the snapshot so concurrent edits cannot
// produce a mixed snapshot that a client could save with a newer revision.
func (r *Repository) SkillCuration(admin bool) (*SkillCuration, error) {
for attempt := 0; attempt < 3; attempt++ {
out := &SkillCuration{Categories: []model.SkillCurationCategory{}, Assignments: []model.SkillCurationAssignment{}}
if err := r.db.First(&out.SkillCurationSetting, 1).Error; err != nil {
return nil, err
}
if !admin && !out.Enabled {
return out, nil
}
query := r.db.Order("sort_order, id")
if !admin {
query = query.Where("enabled = ?", true)
}
if err := query.Find(&out.Categories).Error; err != nil {
return nil, err
}
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")
}
if err := query.Order("skill_id, category_id").Find(&out.Assignments).Error; err != nil {
return nil, err
}
var after model.SkillCurationSetting
if err := r.db.First(&after, 1).Error; err != nil {
return nil, err
}
if after.Revision == out.Revision {
return out, nil
}
}
return nil, kernel.NewAppError(409, "分类正在更新,请重新加载")
}
func (r *Repository) UpdateSkillCuration(actorID string, revision int64, requestID string, change func(*Repository) error) error {
return r.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.SkillCurationSetting{}).Where("id = 1 AND revision = ?", revision).Update("revision", revision+1)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return kernel.NewAppError(409, "分类版本已更新,请重新加载")
}
var actor model.User
if err := tx.First(&actor, "id = ?", actorID).Error; err != nil {
return err
}
if actor.Role != model.UserRoleAdmin || actor.Status != model.UserStatusActive {
return kernel.Forbidden("需要管理员权限")
}
repo := New(tx)
before, err := repo.SkillCuration(true)
if err != nil {
return err
}
if err := change(repo); err != nil {
return err
}
after, err := repo.SkillCuration(true)
if err != nil {
return err
}
before.Revision = revision
metadata, err := json.Marshal(map[string]any{"requestId": requestID, "before": before, "after": after})
if err != nil {
return err
}
return tx.Create(&model.AdminAuditEvent{ID: kernel.NewID(), ActorUserID: actorID, Action: "skill_curation.update", TargetType: "skill_curation", TargetID: "1", MetadataJSON: string(metadata)}).Error
})
}
func (r *Repository) SetSkillCurationEnabled(enabled bool) error {
return r.db.Model(&model.SkillCurationSetting{}).Where("id = 1").Update("enabled", enabled).Error
}
func (r *Repository) SaveSkillCurationCategory(category *model.SkillCurationCategory) error {
return r.db.Save(category).Error
}
func (r *Repository) CurationSkill(id string) (*model.Skill, error) {
var row model.Skill
err := r.db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, "id = ? AND status = 1 AND is_private = false", id).Error
return &row, err
}
func (r *Repository) AssignSkillCuration(skillID string, ids []string) error {
if err := r.db.Delete(&model.SkillCurationAssignment{}, "skill_id = ?", skillID).Error; err != nil {
return err
}
for _, id := range ids {
if err := r.db.Create(&model.SkillCurationAssignment{SkillID: skillID, CategoryID: id}).Error; err != nil {
return err
}
}
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`
+24 -9
View File
@@ -11,15 +11,17 @@ import (
)
type SkillListFilter struct {
UserID string
Scope string
Search string
Tag string
LibraryCategoryID string
LibraryUncategorized bool
Sort string
Limit int
Offset int
PlatformCategoryID string
PlatformUncategorized bool
UserID string
Scope string
Search string
Tag string
LibraryCategoryID string
LibraryUncategorized bool
Sort string
Limit int
Offset int
}
type SkillMetrics struct {
@@ -77,6 +79,12 @@ func (r *Repository) Skills(filter SkillListFilter) ([]model.Skill, int64, error
if filter.Tag != "" {
query = query.Where("skills.tag = ?", filter.Tag)
}
if filter.PlatformCategoryID != "" {
query = query.Where("EXISTS ("+effectiveCurationCategory+" AND c.id = ?)", filter.PlatformCategoryID)
}
if filter.PlatformUncategorized {
query = query.Where("NOT EXISTS (" + effectiveCurationCategory + ")")
}
if filter.LibraryCategoryID != "" {
query = query.Where("user_skill_states.library_category_id = ?", filter.LibraryCategoryID)
} else if filter.LibraryUncategorized {
@@ -132,6 +140,13 @@ func (r *Repository) CreateSkill(skill *model.Skill, ownerState *model.UserSkill
func (r *Repository) DeleteSkill(id string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var locked model.Skill
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, "id = ?", id).Error; err != nil {
return err
}
if err := tx.Delete(&model.SkillCurationAssignment{}, "skill_id = ?", id).Error; err != nil {
return err
}
if err := tx.Delete(&model.UserSkillState{}, "skill_id = ?", id).Error; err != nil {
return err
}
@@ -0,0 +1,6 @@
package service
import "infinite-canvas/backend/internal/app"
type SkillCuration = app.SkillCuration
type SkillCurationUpdate = app.SkillCurationUpdate
+148
View File
@@ -0,0 +1,148 @@
package skills
import (
"infinite-canvas/backend/internal/kernel"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"strings"
"unicode/utf8"
)
type SkillCuration = repository.SkillCuration
type SkillCurationAssignmentInput struct {
SkillID string `json:"skillId"`
CategoryIDs []string `json:"categoryIds"`
}
type SkillCurationUpdate struct {
ExpectedRevision *int64 `json:"expectedRevision"`
Enabled *bool `json:"enabled,omitempty"`
Category *model.SkillCurationCategory `json:"category,omitempty"`
Assignment *SkillCurationAssignmentInput `json:"assignment,omitempty"`
}
func (s *Service) SkillCuration(actor *model.User, admin bool) (*SkillCuration, error) {
if err := s.curationActor(actor, admin); err != nil {
return nil, err
}
return s.repo.SkillCuration(admin)
}
func (s *Service) curationActor(actor *model.User, admin bool) error {
if actor == nil || actor.ID == "" {
return kernel.Unauthorized("请先登录")
}
if admin {
current, err := s.repo.User(actor.ID)
if err != nil {
return err
}
if current.Role != model.UserRoleAdmin || current.Status != model.UserStatusActive {
return kernel.Forbidden("需要管理员权限")
}
}
return nil
}
func (s *Service) UpdateSkillCuration(actor *model.User, req SkillCurationUpdate, requestID string) (*SkillCuration, error) {
if err := s.curationActor(actor, true); err != nil {
return nil, err
}
count := 0
if req.Enabled != nil {
count++
}
if req.Category != nil {
count++
}
if req.Assignment != nil {
count++
}
if req.ExpectedRevision == nil || *req.ExpectedRevision < 0 || count != 1 {
return nil, kernel.BadAuthRequest("需要版本号和一项修改")
}
err := s.repo.UpdateSkillCuration(actor.ID, *req.ExpectedRevision, requestID, func(repo *repository.Repository) error {
if req.Enabled != nil {
return repo.SetSkillCurationEnabled(*req.Enabled)
}
state, err := repo.SkillCuration(true)
if err != nil {
return err
}
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] == "" {
return kernel.BadAuthRequest("分类名称或一级分类无效")
}
exists := c.ID == ""
for _, old := range state.Categories {
if old.ID == c.ID {
exists = true
if old.RootTag != c.RootTag {
return kernel.BadAuthRequest("不能移动子分类")
}
}
if old.ID != c.ID && old.RootTag == c.RootTag && old.NormalizedName == c.NormalizedName {
return kernel.BadAuthRequest("同一级分类下名称不能重复")
}
}
if !exists {
return kernel.NotFound("分类不存在")
}
if c.ID == "" {
c.ID = kernel.NewID()
}
return repo.SaveSkillCurationCategory(&c)
}
a := req.Assignment
if a.CategoryIDs == nil || len(a.CategoryIDs) > 32 {
return kernel.BadAuthRequest("需要完整分类集合,最多32项")
}
skill, err := repo.CurationSkill(a.SkillID)
if err != nil {
return kernel.NotFound("公开技能不存在")
}
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 {
valid = true
}
}
if !valid || seen[id] {
return kernel.BadAuthRequest("分类必须启用、同根且不能重复")
}
seen[id] = true
}
return repo.AssignSkillCuration(a.SkillID, a.CategoryIDs)
})
if err != nil {
return nil, err
}
return s.repo.SkillCuration(true)
}
func (s *Service) curationFilter(req SkillListRequest) (string, bool, error) {
if req.PlatformCategoryID == "" && !req.PlatformUncategorized {
return "", false, nil
}
state, err := s.repo.SkillCuration(false)
if err != nil {
return "", false, err
}
if !state.Enabled {
return "", false, nil
}
if req.PlatformCategoryID != "" && req.PlatformUncategorized {
return "", false, kernel.BadAuthRequest("分类和未细分不能同时筛选")
}
if req.PlatformCategoryID != "" {
for _, c := range state.Categories {
if c.ID == req.PlatformCategoryID {
return c.ID, false, nil
}
}
return "", false, kernel.BadAuthRequest("分类已停用或不存在,请重新选择")
}
return "", req.PlatformUncategorized, nil
}
@@ -0,0 +1,166 @@
package skills
import (
"encoding/json"
"gorm.io/gorm"
"infinite-canvas/backend/internal/model"
"reflect"
"testing"
)
func TestSkillCurationManagementAvailable(t *testing.T) {
if _, ok := reflect.TypeOf((*Service)(nil)).MethodByName("UpdateSkillCuration"); !ok {
t.Fatal("missing platform curation management: skill categories are fixed and cannot be maintained by an administrator")
}
}
func newCurationTest(t *testing.T) (*Service, *gorm.DB, *model.User) {
t.Helper()
svc, db := newSkillLibraryCategoryTestService(t)
if err := db.AutoMigrate(&model.SkillCurationSetting{}, &model.SkillCurationCategory{}, &model.SkillCurationAssignment{}, &model.AdminAuditEvent{}); err != nil {
t.Fatal(err)
}
if err := db.Create(&model.SkillCurationSetting{ID: 1}).Error; err != nil {
t.Fatal(err)
}
actor := &model.User{ID: "curator", Role: model.UserRoleAdmin, Status: model.UserStatusActive, Username: "curator"}
if err := db.Create(actor).Error; err != nil {
t.Fatal(err)
}
return svc, db, actor
}
func TestSkillCurationDisabledSnapshot(t *testing.T) {
svc, db, actor := newCurationTest(t)
// Snapshot of the original empty /skills data and fixed category order.
const original = `{"skills":[],"totalCount":0,"hasMore":false,"nextOffset":0,"page":1,"pageSize":20,"categories":[{"value":"drama","label":"短剧影视"},{"value":"ecommerce","label":"电商营销"},{"value":"creative","label":"创意设计"},{"value":"social","label":"社媒内容"},{"value":"others","label":"其他"}]}`
if err := db.Create(&model.SkillCurationCategory{ID: "hidden", RootTag: "drama", Name: "Hidden", NormalizedName: "hidden", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
for _, req := range []SkillListRequest{{}, {PlatformCategoryID: "invalid", PlatformUncategorized: true}} {
result, err := svc.Skills(actor.ID, req)
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(result)
if string(data) != original {
t.Fatalf("legacy response changed:\n%s", data)
}
}
result, err := svc.SkillCuration(actor, false)
if err != nil {
t.Fatal(err)
}
if result.Enabled || len(result.Categories) != 0 || len(result.Assignments) != 0 {
t.Fatalf("disabled mode leaked curation: %#v", result)
}
}
func TestSkillCurationLifecycleAndGuards(t *testing.T) {
svc, db, actor := newCurationTest(t)
update := func(req SkillCurationUpdate) *SkillCuration {
t.Helper()
state, err := svc.SkillCuration(actor, true)
if err != nil {
t.Fatal(err)
}
req.ExpectedRevision = &state.Revision
out, err := svc.UpdateSkillCuration(actor, req, "test-request")
if err != nil {
t.Fatal(err)
}
return out
}
on := true
update(SkillCurationUpdate{Enabled: &on})
state := update(SkillCurationUpdate{Category: &model.SkillCurationCategory{RootTag: "drama", Name: "Story", SortOrder: 2, Enabled: true}})
c := state.Categories[0]
for _, s := range []model.Skill{{ID: "public", Name: "Story", Tag: "drama", Status: 1}, {ID: "other", Name: "Other", Tag: "social", Status: 1}, {ID: "private", Name: "Secret", Tag: "drama", Status: 1, IsPrivate: true, OwnerID: actor.ID}} {
if err := db.Create(&s).Error; err != nil {
t.Fatal(err)
}
}
state = update(SkillCurationUpdate{Assignment: &SkillCurationAssignmentInput{SkillID: "public", CategoryIDs: []string{c.ID}}})
list, err := svc.Skills(actor.ID, SkillListRequest{PlatformCategoryID: c.ID})
if err != nil || list.TotalCount != 1 {
t.Fatalf("filtered: %#v %v", list, err)
}
list, err = svc.Skills(actor.ID, SkillListRequest{PlatformUncategorized: true})
if err != nil || list.TotalCount != 1 {
t.Fatalf("uncategorized: %#v %v", list, err)
}
for _, id := range []string{"other", "private"} {
_, err := svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &state.Revision, Assignment: &SkillCurationAssignmentInput{SkillID: id, CategoryIDs: []string{c.ID}}}, "")
if err == nil {
t.Fatalf("accepted invalid assignment: %s", id)
}
}
stale := int64(0)
if _, err := svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &stale, Enabled: &on}, ""); appErrorStatus(err) != 409 {
t.Fatalf("stale update: %v", err)
}
c.Name = "Renamed"
c.SortOrder = -1
c.Enabled = false
state = update(SkillCurationUpdate{Category: &c})
public, err := svc.SkillCuration(actor, false)
if err != nil || len(public.Categories) != 0 || len(public.Assignments) != 0 {
t.Fatalf("disabled category: %#v %v", public, err)
}
var assignments int64
db.Model(&model.SkillCurationAssignment{}).Count(&assignments)
if assignments != 1 {
t.Fatal("disable deleted assignments")
}
c.Enabled = true
update(SkillCurationUpdate{Category: &c})
off := false
update(SkillCurationUpdate{Enabled: &off})
list, err = svc.Skills(actor.ID, SkillListRequest{PlatformCategoryID: c.ID})
if err != nil || list.TotalCount != 2 {
t.Fatalf("disabled switch must ignore filters: %#v %v", list, err)
}
// A stale in-memory administrator cannot keep writing after revocation.
db.Model(&model.User{}).Where("id = ?", actor.ID).Update("role", model.UserRoleUser)
if _, err := svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &state.Revision, Enabled: &on}, ""); appErrorStatus(err) != 403 {
t.Fatalf("revoked administrator: %v", err)
}
}
func TestSkillCurationAuditFailureRollsBack(t *testing.T) {
svc, db, actor := newCurationTest(t)
if err := db.Exec(`CREATE TRIGGER reject_curation_audit BEFORE INSERT ON admin_audit_events BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END`).Error; err != nil {
t.Fatal(err)
}
revision := int64(0)
on := true
if _, err := svc.UpdateSkillCuration(actor, SkillCurationUpdate{ExpectedRevision: &revision, Enabled: &on}, ""); err == nil {
t.Fatal("audit failure accepted")
}
state, err := svc.SkillCuration(actor, true)
if err != nil || state.Revision != 0 || state.Enabled {
t.Fatalf("partial commit: %#v %v", state, err)
}
}
func TestSkillCurationDeletionCleanup(t *testing.T) {
svc, db, _ := newCurationTest(t)
if err := db.AutoMigrate(&model.SkillVersion{}, &model.SkillFile{}, &model.BuiltinSkillTombstone{}); err != nil {
t.Fatal(err)
}
for _, id := range []string{"normal", "builtin"} {
db.Create(&model.Skill{ID: id, Status: 1})
db.Create(&model.SkillCurationAssignment{SkillID: id, CategoryID: "category"})
}
if err := svc.repo.DeleteSkill("normal"); err != nil {
t.Fatal(err)
}
if err := svc.repo.DeleteBuiltinSkill("builtin", "admin"); err != nil {
t.Fatal(err)
}
var count int64
db.Model(&model.SkillCurationAssignment{}).Count(&count)
if count != 0 {
t.Fatal("deleted skills retain assignments")
}
}
+15 -8
View File
@@ -108,14 +108,16 @@ type SkillCategory struct {
}
type SkillListRequest struct {
Page int
PageSize int
Scope string
Search string
Tag string
LibraryCategoryID string
LibraryUncategorized bool
Sort string
PlatformCategoryID string
PlatformUncategorized bool
Page int
PageSize int
Scope string
Search string
Tag string
LibraryCategoryID string
LibraryUncategorized bool
Sort string
}
type SkillList struct {
@@ -141,6 +143,10 @@ type SkillMutationRequest struct {
func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error) {
req = normalizeSkillListRequest(req)
categoryID, uncategorized, err := s.curationFilter(req)
if err != nil {
return nil, err
}
if req.LibraryCategoryID != "" && req.LibraryUncategorized {
return nil, kernel.BadAuthRequest("技能库分类与未分类筛选不能同时使用")
}
@@ -150,6 +156,7 @@ func (s *Service) Skills(userID string, req SkillListRequest) (*SkillList, error
}
}
rows, total, err := s.repo.Skills(repository.SkillListFilter{
PlatformCategoryID: categoryID, PlatformUncategorized: uncategorized,
UserID: userID,
Scope: req.Scope,
Search: req.Search,