2026-07-18 21:58:15 +08:00
|
|
|
|
package projects
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
2026-07-21 00:46:09 +08:00
|
|
|
|
"gorm.io/gorm"
|
2026-07-18 21:58:15 +08:00
|
|
|
|
"senlinai-agent/backend/internal/models"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-21 14:58:25 +08:00
|
|
|
|
type Service struct{}
|
2026-07-20 21:45:36 +08:00
|
|
|
|
|
2026-07-18 21:58:15 +08:00
|
|
|
|
func NewService() *Service {
|
|
|
|
|
|
return &Service{}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Service) CreateProject(ownerID uint, name string, description string) (*models.SenlinAgentProject, error) {
|
2026-07-21 14:22:13 +08:00
|
|
|
|
return s.CreateProjectWithInput(ownerID, CreateProjectRequest{Name: name, Description: description})
|
2026-07-21 00:46:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 14:22:13 +08:00
|
|
|
|
func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectRequest) (*models.SenlinAgentProject, error) {
|
2026-07-21 00:46:09 +08:00
|
|
|
|
name := strings.TrimSpace(input.Name)
|
2026-07-18 21:58:15 +08:00
|
|
|
|
if name == "" {
|
2026-07-21 14:22:13 +08:00
|
|
|
|
return nil, ErrProjectNameRequired
|
2026-07-18 21:58:15 +08:00
|
|
|
|
}
|
2026-07-21 00:46:09 +08:00
|
|
|
|
identifier := strings.TrimSpace(input.Identifier)
|
|
|
|
|
|
if identifier == "" {
|
|
|
|
|
|
identifier = projectInitials(name)
|
|
|
|
|
|
}
|
|
|
|
|
|
project := &models.SenlinAgentProject{
|
2026-07-21 14:58:25 +08:00
|
|
|
|
OwnerID: ownerID, Name: name, Identifier: identifier,
|
|
|
|
|
|
Icon: strings.TrimSpace(input.Icon), Background: strings.TrimSpace(input.Background),
|
2026-07-21 00:46:09 +08:00
|
|
|
|
Description: strings.TrimSpace(input.Description),
|
|
|
|
|
|
}
|
2026-07-21 14:41:30 +08:00
|
|
|
|
if err := projectWriteError(models.DBService.Create(project).Error); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return project, nil
|
2026-07-18 21:58:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 14:22:13 +08:00
|
|
|
|
// ListProjects 只返回当前所有者的公开 DTO,不让 handler 接触或序列化数据库模型。
|
|
|
|
|
|
func (s *Service) ListProjects(ownerID uint) ([]ProjectDTO, error) {
|
2026-07-18 21:58:15 +08:00
|
|
|
|
var projects []models.SenlinAgentProject
|
2026-07-21 14:22:13 +08:00
|
|
|
|
if err := models.DBService.Where("owner_id = ?", ownerID).Order("updated_at desc").Find(&projects).Error; err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
result := make([]ProjectDTO, 0, len(projects))
|
|
|
|
|
|
for _, project := range projects {
|
|
|
|
|
|
result = append(result, projectDTO(project))
|
|
|
|
|
|
}
|
|
|
|
|
|
return result, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetProject 使用公开 identity 和所有权边界读取单个项目。
|
|
|
|
|
|
func (s *Service) GetProject(ownerID uint, identity string) (ProjectDTO, error) {
|
|
|
|
|
|
project, err := FindOwnedProject(ownerID, identity)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return ProjectDTO{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return projectDTO(*project), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
|
ErrProjectNameRequired = errors.New("project name is required")
|
|
|
|
|
|
ErrProjectIdentifierRequired = errors.New("project identifier is required")
|
|
|
|
|
|
ErrProjectIdentifierConflict = errors.New("project identifier already exists")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-21 14:41:30 +08:00
|
|
|
|
// projectWriteError 将 Postgres/SQLite 经 Gorm 翻译后的唯一约束错误收敛为稳定业务冲突。
|
|
|
|
|
|
func projectWriteError(err error) error {
|
|
|
|
|
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
|
|
|
|
|
return ErrProjectIdentifierConflict
|
|
|
|
|
|
}
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-21 14:22:13 +08:00
|
|
|
|
// UpdateProject 仅更新项目设置白名单字段,查询和冲突检查均限定在当前所有者内。
|
|
|
|
|
|
func (s *Service) UpdateProject(ownerID uint, identity string, input UpdateProjectRequest) error {
|
|
|
|
|
|
project, err := FindOwnedProject(ownerID, identity)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updates := make(map[string]any, 5)
|
|
|
|
|
|
if input.Name != nil {
|
|
|
|
|
|
name := strings.TrimSpace(*input.Name)
|
|
|
|
|
|
if name == "" {
|
|
|
|
|
|
return ErrProjectNameRequired
|
|
|
|
|
|
}
|
|
|
|
|
|
updates["name"] = name
|
|
|
|
|
|
}
|
|
|
|
|
|
if input.Identifier != nil {
|
|
|
|
|
|
identifier := strings.TrimSpace(*input.Identifier)
|
|
|
|
|
|
if identifier == "" {
|
|
|
|
|
|
return ErrProjectIdentifierRequired
|
|
|
|
|
|
}
|
|
|
|
|
|
updates["identifier"] = identifier
|
|
|
|
|
|
}
|
|
|
|
|
|
if input.Icon != nil {
|
|
|
|
|
|
updates["icon"] = strings.TrimSpace(*input.Icon)
|
|
|
|
|
|
}
|
|
|
|
|
|
if input.Background != nil {
|
|
|
|
|
|
updates["background"] = strings.TrimSpace(*input.Background)
|
|
|
|
|
|
}
|
|
|
|
|
|
if input.Description != nil {
|
|
|
|
|
|
updates["description"] = strings.TrimSpace(*input.Description)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(updates) == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-07-21 14:41:30 +08:00
|
|
|
|
return projectWriteError(models.DBService.Model(project).Updates(updates).Error)
|
2026-07-18 21:58:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-19 21:46:43 +08:00
|
|
|
|
func projectInitials(name string) string {
|
|
|
|
|
|
words := strings.Fields(name)
|
|
|
|
|
|
if len(words) == 0 {
|
|
|
|
|
|
return "P"
|
|
|
|
|
|
}
|
|
|
|
|
|
for i := len(words) - 1; i >= 0; i-- {
|
|
|
|
|
|
word := strings.Trim(words[i], "#_-")
|
|
|
|
|
|
if isShortCode(word) {
|
|
|
|
|
|
return strings.ToUpper(word)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(words) == 1 {
|
|
|
|
|
|
runes := []rune(words[0])
|
|
|
|
|
|
if len(runes) == 1 {
|
|
|
|
|
|
return strings.ToUpper(string(runes[0]))
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.ToUpper(string(runes[:2]))
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.ToUpper(string([]rune(words[0])[0]) + string([]rune(words[len(words)-1])[0]))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func isShortCode(value string) bool {
|
|
|
|
|
|
runes := []rune(value)
|
|
|
|
|
|
if len(runes) == 0 || len(runes) > 3 {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
hasDigit := false
|
|
|
|
|
|
for _, r := range runes {
|
|
|
|
|
|
if r >= '0' && r <= '9' {
|
|
|
|
|
|
hasDigit = true
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return hasDigit
|
|
|
|
|
|
}
|