Scout/internal/registry/projects.go

232 lines
6.3 KiB
Go

package registry
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
const DefaultProject = "default"
var projectIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ProjectRegistry struct {
root string
projects map[string]Project
registries map[string]*Registry
mu sync.RWMutex
}
func ValidateProjectID(id string) error {
if !projectIDPattern.MatchString(strings.TrimSpace(id)) {
return errors.New("项目标识只能包含字母、数字、点、下划线和短横线,长度为 1 到 64")
}
return nil
}
func NewProjectRegistry(root string) (*ProjectRegistry, error) {
if strings.TrimSpace(root) == "" {
return nil, errors.New("项目存储目录不能为空")
}
projectsRoot := filepath.Join(root, "projects")
if err := os.MkdirAll(projectsRoot, 0o755); err != nil {
return nil, fmt.Errorf("创建项目存储目录失败: %w", err)
}
result := &ProjectRegistry{
root: projectsRoot,
projects: make(map[string]Project),
registries: make(map[string]*Registry),
}
entries, err := os.ReadDir(projectsRoot)
if err != nil {
return nil, fmt.Errorf("读取项目目录失败: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() || ValidateProjectID(entry.Name()) != nil {
continue
}
if err := result.loadProject(entry.Name()); err != nil {
return nil, err
}
}
return result, nil
}
func (r *ProjectRegistry) Create(input Project) (Project, error) {
input.ID = strings.TrimSpace(input.ID)
if err := ValidateProjectID(input.ID); err != nil {
return Project{}, err
}
if input.ID == DefaultProject {
return Project{}, errors.New("default 是兼容项目标识,不能创建")
}
if input.Name == "" {
input.Name = input.ID
}
input.Status = strings.TrimSpace(input.Status)
if input.Status == "" {
input.Status = "active"
}
if input.Status != "active" && input.Status != "disabled" {
return Project{}, errors.New("项目状态只能是 active 或 disabled")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.projects[input.ID]; exists {
return Project{}, errors.New("项目已经存在")
}
now := time.Now().UTC()
input.CreatedAt = now
input.UpdatedAt = now
projectDir := filepath.Join(r.root, input.ID)
if err := os.MkdirAll(projectDir, 0o755); err != nil {
return Project{}, fmt.Errorf("创建项目目录失败: %w", err)
}
if err := writeProjectMetadata(projectDir, input); err != nil {
return Project{}, err
}
store, err := New(projectDir)
if err != nil {
return Project{}, err
}
r.projects[input.ID] = input
r.registries[input.ID] = store
return input, nil
}
func (r *ProjectRegistry) Update(id string, input Project) (Project, error) {
id = strings.TrimSpace(id)
if err := ValidateProjectID(id); err != nil {
return Project{}, err
}
if id == DefaultProject {
return Project{}, errors.New("default 是兼容项目标识,不能修改")
}
if strings.TrimSpace(input.Name) == "" {
return Project{}, errors.New("项目名称不能为空")
}
if input.Status != "active" && input.Status != "disabled" {
return Project{}, errors.New("项目状态只能是 active 或 disabled")
}
r.mu.Lock()
defer r.mu.Unlock()
current, exists := r.projects[id]
if !exists {
return Project{}, errors.New("项目不存在")
}
current.Name = strings.TrimSpace(input.Name)
current.Description = strings.TrimSpace(input.Description)
current.Status = input.Status
current.UpdatedAt = time.Now().UTC()
if err := writeProjectMetadata(filepath.Join(r.root, id), current); err != nil {
return Project{}, err
}
r.projects[id] = current
return current, nil
}
func (r *ProjectRegistry) Ensure(id, name, description string) (Project, *Registry, error) {
id = strings.TrimSpace(id)
if err := ValidateProjectID(id); err != nil {
return Project{}, nil, err
}
if id == DefaultProject {
return Project{}, nil, errors.New("default 是兼容项目标识,请继续使用旧接口")
}
r.mu.RLock()
project, exists := r.projects[id]
store := r.registries[id]
r.mu.RUnlock()
if exists {
return project, store, nil
}
project, err := r.Create(Project{ID: id, Name: name, Description: description})
if err != nil {
return Project{}, nil, err
}
r.mu.RLock()
store = r.registries[id]
r.mu.RUnlock()
return project, store, nil
}
func (r *ProjectRegistry) Get(id string) (Project, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
project, ok := r.projects[strings.TrimSpace(id)]
return project, ok
}
func (r *ProjectRegistry) List() []Project {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]Project, 0, len(r.projects))
for _, project := range r.projects {
result = append(result, project)
}
sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID })
return result
}
func (r *ProjectRegistry) Store(id string) (*Registry, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
store, ok := r.registries[strings.TrimSpace(id)]
return store, ok
}
func (r *ProjectRegistry) loadProject(id string) error {
projectDir := filepath.Join(r.root, id)
project := Project{ID: id, Name: id, Status: "active"}
metadata, err := os.ReadFile(filepath.Join(projectDir, "project.json"))
if err == nil {
if err := json.Unmarshal(metadata, &project); err != nil {
return fmt.Errorf("读取项目元数据失败: %w", err)
}
project.ID = id
if project.Name == "" {
project.Name = id
}
if project.Status == "" {
project.Status = "active"
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("读取项目元数据失败: %w", err)
}
store, err := New(projectDir)
if err != nil {
return fmt.Errorf("加载项目 %s 失败: %w", id, err)
}
r.projects[id] = project
r.registries[id] = store
return nil
}
func writeProjectMetadata(projectDir string, project Project) error {
data, err := json.MarshalIndent(project, "", " ")
if err != nil {
return fmt.Errorf("编码项目元数据失败: %w", err)
}
if err := os.WriteFile(filepath.Join(projectDir, "project.json"), data, 0o644); err != nil {
return fmt.Errorf("保存项目元数据失败: %w", err)
}
return nil
}