Files
ttstd-infinite-canvas/backend/internal/repository/skills.go
2026-10-06 09:24:21 +08:00

258 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repository
import (
"errors"
"strings"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type SkillListFilter struct {
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 {
SkillID string
AddedCount int64
LikeCount int64
}
type SkillCategoryCount struct {
Tag string
Count int64
}
// PublicSkillCategoryCounts 返回技能广场中已启用公开技能的共享分类数量。
func (r *Repository) PublicSkillCategoryCounts() (map[string]int64, error) {
var rows []SkillCategoryCount
err := r.db.Model(&model.Skill{}).
Select("tag, COUNT(*) AS count").
Where("status = ? AND is_private = ?", 1, false).
Group("tag").
Scan(&rows).Error
if err != nil {
return nil, err
}
counts := make(map[string]int64, len(rows))
for _, row := range rows {
counts[row.Tag] = row.Count
}
return counts, nil
}
func (r *Repository) Skills(filter SkillListFilter) ([]model.Skill, int64, error) {
query := r.db.Model(&model.Skill{}).Where("skills.status = ?", 1)
switch filter.Scope {
case "mine":
query = query.Joins("LEFT JOIN user_skill_states ON user_skill_states.skill_id = skills.id AND user_skill_states.user_id = ?", filter.UserID).
Where("skills.owner_id = ? OR (user_skill_states.added = ? AND skills.is_private = ?)", filter.UserID, true, false)
case "created":
query = query.Where("skills.owner_id = ?", filter.UserID)
if filter.LibraryCategoryID != "" || filter.LibraryUncategorized {
query = query.Joins("LEFT JOIN user_skill_states ON user_skill_states.skill_id = skills.id AND user_skill_states.user_id = ?", filter.UserID)
}
case "favorites":
query = query.Joins("JOIN user_skill_states ON user_skill_states.skill_id = skills.id AND user_skill_states.user_id = ? AND user_skill_states.liked = ?", filter.UserID, true).
Where("skills.owner_id = ? OR skills.is_private = ?", filter.UserID, false)
default:
query = query.Where("skills.is_private = ?", false)
}
if filter.Search != "" {
pattern := "%" + strings.ToLower(filter.Search) + "%"
query = query.Joins("LEFT JOIN users skill_owners ON skill_owners.id = skills.owner_id").
Where("lower(skills.name) LIKE ? OR lower(skills.description) LIKE ? OR lower(skills.instruction) LIKE ? OR lower(skills.extra_info) LIKE ? OR lower(skills.author_name) LIKE ? OR lower(skill_owners.display_name) LIKE ? OR lower(skill_owners.username) LIKE ?", pattern, pattern, pattern, pattern, pattern, pattern, pattern)
}
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 {
query = query.Where("COALESCE(user_skill_states.library_category_id, '') = ''")
}
// 用户状态按用户和技能唯一,列表 JOIN 不会重复;不要设置 DISTINCT,避免 GORM 将其带入 PostgreSQL 的热门排序查询。
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
order := "skills.sort_weight desc, skills.updated_at desc"
switch filter.Sort {
case "new":
order = "skills.created_at desc"
case "popular":
order = "(skills.initial_added_count + (SELECT COUNT(*) FROM user_skill_states metric_states WHERE metric_states.skill_id = skills.id AND metric_states.added = true)) desc, skills.updated_at desc"
}
var skills []model.Skill
err := query.Select("skills.*").Order(order).Limit(filter.Limit).Offset(filter.Offset).Find(&skills).Error
return skills, total, err
}
// 内置技能使用稳定的外部技能 ID 幂等更新,用户加入和收藏关系保留在独立状态表中。
func (r *Repository) UpsertBuiltinSkills(skills []model.Skill) error {
if len(skills) == 0 {
return errors.New("builtin skills are empty")
}
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{
"owner_id", "author_name", "author_avatar_url", "name", "description", "instruction", "status", "source", "tag", "sort_weight", "is_private",
"markdown_url", "showcase_media_json", "extra_info", "initial_like_count", "initial_added_count", "created_at", "updated_at",
}),
}).Create(&skills).Error
}
func (r *Repository) Skill(id string) (*model.Skill, error) {
var skill model.Skill
if err := r.db.First(&skill, "id = ? AND status = ?", id, 1).Error; err != nil {
return nil, err
}
return &skill, nil
}
func (r *Repository) CreateSkill(skill *model.Skill, ownerState *model.UserSkillState) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(skill).Error; err != nil {
return err
}
return tx.Create(ownerState).Error
})
}
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
}
var versions []model.SkillVersion
if err := tx.Where("skill_id = ?", id).Find(&versions).Error; err != nil {
return err
}
versionIDs := make([]string, 0, len(versions))
for _, version := range versions {
versionIDs = append(versionIDs, version.ID)
}
if len(versionIDs) > 0 {
if err := tx.Delete(&model.SkillFile{}, "skill_version_id IN ?", versionIDs).Error; err != nil {
return err
}
}
if err := tx.Delete(&model.SkillVersion{}, "skill_id = ?", id).Error; err != nil {
return err
}
return tx.Delete(&model.Skill{}, "id = ?", id).Error
})
}
func (r *Repository) UserSkillState(userID string, skillID string) (*model.UserSkillState, error) {
var state model.UserSkillState
if err := r.db.First(&state, "user_id = ? AND skill_id = ?", userID, skillID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &state, nil
}
func (r *Repository) UserSkillStatesBySkillIDs(userID string, skillIDs []string) ([]model.UserSkillState, error) {
if len(skillIDs) == 0 {
return nil, nil
}
var states []model.UserSkillState
err := r.db.Find(&states, "user_id = ? AND skill_id IN ?", userID, skillIDs).Error
return states, err
}
func (r *Repository) SetUserSkillAdded(state *model.UserSkillState) error {
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "skill_id"}},
DoUpdates: clause.AssignmentColumns([]string{"added", "installed_version_id", "auto_update", "library_category_id", "updated_at"}),
}).Create(state).Error
}
func (r *Repository) SetUserSkillLiked(state *model.UserSkillState) error {
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "skill_id"}},
DoUpdates: clause.AssignmentColumns([]string{"liked", "updated_at"}),
}).Create(state).Error
}
func (r *Repository) SkillMetrics(skillIDs []string) (map[string]SkillMetrics, error) {
metrics := make(map[string]SkillMetrics, len(skillIDs))
if len(skillIDs) == 0 {
return metrics, nil
}
var rows []SkillMetrics
err := r.db.Model(&model.UserSkillState{}).
Select("skill_id, SUM(CASE WHEN added THEN 1 ELSE 0 END) AS added_count, SUM(CASE WHEN liked THEN 1 ELSE 0 END) AS like_count").
Where("skill_id IN ?", skillIDs).
Group("skill_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, item := range rows {
metrics[item.SkillID] = item
}
return metrics, nil
}
func (r *Repository) SkillOwners(ownerIDs []string) (map[string]model.User, error) {
owners := make(map[string]model.User, len(ownerIDs))
if len(ownerIDs) == 0 {
return owners, nil
}
var users []model.User
if err := r.db.Find(&users, "id IN ?", ownerIDs).Error; err != nil {
return nil, err
}
for _, user := range users {
owners[user.ID] = user
}
return owners, nil
}
func (r *Repository) SkillOwnerAvatars(ownerIDs []string) (map[string]string, error) {
avatars := make(map[string]string, len(ownerIDs))
if len(ownerIDs) == 0 {
return avatars, nil
}
var identities []model.UserIdentity
if err := r.db.Where("user_id IN ? AND avatar_url <> ''", ownerIDs).Order("updated_at desc").Find(&identities).Error; err != nil {
return nil, err
}
for _, identity := range identities {
if avatars[identity.UserID] == "" {
avatars[identity.UserID] = identity.AvatarURL
}
}
return avatars, nil
}