feat(skills): 技能管理 - 增加可选的平台子分类策展 (#693)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user