package projects import ( "errors" "strings" "gorm.io/gorm" "senlinai-agent/backend/internal/models" ) type Service struct{} func NewService() *Service { return &Service{} } func (s *Service) CreateProject(ownerID uint, name string, description string) (*models.SenlinAgentProject, error) { return s.CreateProjectWithInput(ownerID, CreateProjectRequest{Name: name, Description: description}) } func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectRequest) (*models.SenlinAgentProject, error) { name := strings.TrimSpace(input.Name) if name == "" { return nil, ErrProjectNameRequired } identifier := strings.TrimSpace(input.Identifier) if identifier == "" { identifier = projectInitials(name) } project := &models.SenlinAgentProject{ OwnerID: ownerID, Name: name, Identifier: identifier, Icon: strings.TrimSpace(input.Icon), Background: strings.TrimSpace(input.Background), Description: strings.TrimSpace(input.Description), } if err := projectWriteError(models.DBService.Create(project).Error); err != nil { return nil, err } return project, nil } // ListProjects 只返回当前所有者的公开 DTO,不让 handler 接触或序列化数据库模型。 func (s *Service) ListProjects(ownerID uint) ([]ProjectDTO, error) { var projects []models.SenlinAgentProject 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") ) // projectWriteError 将 Postgres/SQLite 经 Gorm 翻译后的唯一约束错误收敛为稳定业务冲突。 func projectWriteError(err error) error { if errors.Is(err, gorm.ErrDuplicatedKey) { return ErrProjectIdentifierConflict } return err } // 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 } return projectWriteError(models.DBService.Model(project).Updates(updates).Error) } 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 }