diff --git a/internal/project/project_service.go b/internal/project/project_service.go new file mode 100644 index 0000000..b85487a --- /dev/null +++ b/internal/project/project_service.go @@ -0,0 +1,202 @@ +// Package project 内的 project_service.go 实装 ProjectService 接口。 +// +// 鉴权矩阵(PM spec §1.3): +// +// read private: owner + admin +// read internal: 任意已登录 +// write *: owner + admin +// +// 已归档项目下的所有写操作(包含 Requirement / Issue 的写)一律返回 +// CodeProjectArchived;这是 spec 强约定,不区分 owner / admin。 +package project + +import ( + "context" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +type projectService struct { + repo Repository + audit audit.Recorder +} + +// NewProjectService 用 Repository 与可选 audit recorder 构造 ProjectService。 +// recorder == nil 时跳过审计写入(便于单测)。 +func NewProjectService(repo Repository, rec audit.Recorder) ProjectService { + return &projectService{repo: repo, audit: rec} +} + +// assertReadable 是 read 类操作的鉴权门面:private 仅 owner+admin,internal +// 任意登录用户。失败统一返回 CodeNotFound(避免存在性探测)。 +func assertReadable(p *Project, c Caller) error { + if p.Visibility == VisibilityInternal { + return nil + } + if c.IsAdmin || p.OwnerID == c.UserID { + return nil + } + return errs.New(errs.CodeNotFound, "project 不存在") +} + +// assertWritable 是 write 类操作的鉴权门面:仅 owner+admin。已归档项目额外 +// 拒绝;调用方应在执行写入前调用此函数。 +func assertWritable(p *Project, c Caller) error { + if !(c.IsAdmin || p.OwnerID == c.UserID) { + // stranger 对 private 看不见;对 internal 也不能写。统一 not_found 防探测。 + if p.Visibility == VisibilityPrivate { + return errs.New(errs.CodeNotFound, "project 不存在") + } + return errs.New(errs.CodeForbidden, "无权修改该项目") + } + if p.ArchivedAt != nil { + return errs.New(errs.CodeProjectArchived, "项目已归档,禁止写操作") + } + return nil +} + +func (s *projectService) Create(ctx context.Context, c Caller, in CreateProjectInput) (*Project, error) { + if in.Slug == "" || in.Name == "" { + return nil, errs.New(errs.CodeInvalidInput, "slug / name 必填") + } + if in.Visibility == "" { + in.Visibility = VisibilityPrivate + } + if !in.Visibility.IsValid() { + return nil, errs.New(errs.CodeInvalidInput, "visibility 非法") + } + p := &Project{ + ID: uuid.New(), Slug: in.Slug, Name: in.Name, Description: in.Description, + Visibility: in.Visibility, OwnerID: c.UserID, + } + out, err := s.repo.CreateProject(ctx, p) + if err != nil { + return nil, err + } + s.recordAudit(ctx, c, "project.create", out.ID.String(), map[string]any{ + "slug": out.Slug, "name": out.Name, "visibility": string(out.Visibility), + }) + return out, nil +} + +func (s *projectService) Get(ctx context.Context, c Caller, slug string) (*Project, error) { + p, err := s.repo.GetProjectBySlug(ctx, slug) + if err != nil { + return nil, err + } + if err := assertReadable(p, c); err != nil { + return nil, err + } + return p, nil +} + +func (s *projectService) List(ctx context.Context, c Caller, includeArchived bool) ([]*Project, error) { + all, err := s.repo.ListProjects(ctx, includeArchived) + if err != nil { + return nil, err + } + out := make([]*Project, 0, len(all)) + for _, p := range all { + if assertReadable(p, c) == nil { + out = append(out, p) + } + } + return out, nil +} + +func (s *projectService) Update(ctx context.Context, c Caller, slug string, in UpdateProjectInput) (*Project, error) { + p, err := s.repo.GetProjectBySlug(ctx, slug) + if err != nil { + return nil, err + } + if err := assertWritable(p, c); err != nil { + return nil, err + } + changed := map[string]any{} + if in.Name != nil { + p.Name = *in.Name + changed["name"] = *in.Name + } + if in.Description != nil { + p.Description = *in.Description + changed["description"] = "" + } + if in.Visibility != nil { + if !in.Visibility.IsValid() { + return nil, errs.New(errs.CodeInvalidInput, "visibility 非法") + } + p.Visibility = *in.Visibility + changed["visibility"] = string(*in.Visibility) + } + if len(changed) == 0 { + return p, nil + } + out, err := s.repo.UpdateProject(ctx, p) + if err != nil { + return nil, err + } + s.recordAudit(ctx, c, "project.update", out.ID.String(), map[string]any{"changed_fields": changed}) + return out, nil +} + +func (s *projectService) Archive(ctx context.Context, c Caller, slug string) error { + p, err := s.repo.GetProjectBySlug(ctx, slug) + if err != nil { + return err + } + // 归档/取消归档仍要 owner+admin 校验,但已归档不再额外阻塞 archive。 + if !(c.IsAdmin || p.OwnerID == c.UserID) { + if p.Visibility == VisibilityPrivate { + return errs.New(errs.CodeNotFound, "project 不存在") + } + return errs.New(errs.CodeForbidden, "无权操作该项目") + } + if p.ArchivedAt != nil { + return nil // 幂等 + } + if err := s.repo.ArchiveProject(ctx, p.ID); err != nil { + return err + } + s.recordAudit(ctx, c, "project.archive", p.ID.String(), nil) + return nil +} + +func (s *projectService) Unarchive(ctx context.Context, c Caller, slug string) error { + p, err := s.repo.GetProjectBySlug(ctx, slug) + if err != nil { + return err + } + if !(c.IsAdmin || p.OwnerID == c.UserID) { + if p.Visibility == VisibilityPrivate { + return errs.New(errs.CodeNotFound, "project 不存在") + } + return errs.New(errs.CodeForbidden, "无权操作该项目") + } + if p.ArchivedAt == nil { + return nil + } + if err := s.repo.UnarchiveProject(ctx, p.ID); err != nil { + return err + } + s.recordAudit(ctx, c, "project.unarchive", p.ID.String(), nil) + return nil +} + +// recordAudit 是 best-effort 写入:失败仅吞错(不影响业务)。ctx 用 +// context.WithoutCancel 派生,使 HTTP 响应已返回但审计仍能落库。 +func (s *projectService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) { + if s.audit == nil { + return + } + uid := c.UserID + _ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{ + UserID: &uid, + Action: action, + TargetType: "project", + TargetID: targetID, + Metadata: meta, + }) +} diff --git a/internal/project/project_service_test.go b/internal/project/project_service_test.go new file mode 100644 index 0000000..af6984d --- /dev/null +++ b/internal/project/project_service_test.go @@ -0,0 +1,473 @@ +package project + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// fakeRepo 是内存版 Repository,仅用于 service 单测;按 ID/slug 查找用 map, +// number 自增手动维护。并发安全(一把大锁就够)。 +type fakeRepo struct { + mu sync.Mutex + projects map[uuid.UUID]*Project + requirements map[uuid.UUID]*Requirement + issues map[uuid.UUID]*Issue +} + +func newFakeRepo() *fakeRepo { + return &fakeRepo{ + projects: map[uuid.UUID]*Project{}, + requirements: map[uuid.UUID]*Requirement{}, + issues: map[uuid.UUID]*Issue{}, + } +} + +func (r *fakeRepo) cloneProject(p *Project) *Project { v := *p; return &v } +func (r *fakeRepo) cloneReq(p *Requirement) *Requirement { v := *p; return &v } +func (r *fakeRepo) cloneIssue(p *Issue) *Issue { v := *p; return &v } + +func (r *fakeRepo) CreateProject(_ context.Context, p *Project) (*Project, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, x := range r.projects { + if x.Slug == p.Slug { + return nil, errs.New(errs.CodeProjectSlugTaken, "slug 已被使用") + } + } + now := time.Now() + p.CreatedAt, p.UpdatedAt = now, now + r.projects[p.ID] = r.cloneProject(p) + return r.cloneProject(p), nil +} + +func (r *fakeRepo) GetProjectBySlug(_ context.Context, slug string) (*Project, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, p := range r.projects { + if p.Slug == slug { + return r.cloneProject(p), nil + } + } + return nil, errs.New(errs.CodeNotFound, "project 不存在") +} + +func (r *fakeRepo) GetProjectByID(_ context.Context, id uuid.UUID) (*Project, error) { + r.mu.Lock() + defer r.mu.Unlock() + p, ok := r.projects[id] + if !ok { + return nil, errs.New(errs.CodeNotFound, "project 不存在") + } + return r.cloneProject(p), nil +} + +func (r *fakeRepo) ListProjects(_ context.Context, includeArchived bool) ([]*Project, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*Project, 0, len(r.projects)) + for _, p := range r.projects { + if !includeArchived && p.ArchivedAt != nil { + continue + } + out = append(out, r.cloneProject(p)) + } + return out, nil +} + +func (r *fakeRepo) UpdateProject(_ context.Context, p *Project) (*Project, error) { + r.mu.Lock() + defer r.mu.Unlock() + cur, ok := r.projects[p.ID] + if !ok { + return nil, errs.New(errs.CodeNotFound, "project 不存在") + } + cur.Name, cur.Description, cur.Visibility = p.Name, p.Description, p.Visibility + cur.UpdatedAt = time.Now() + return r.cloneProject(cur), nil +} + +func (r *fakeRepo) ArchiveProject(_ context.Context, id uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + p, ok := r.projects[id] + if !ok { + return nil + } + now := time.Now() + p.ArchivedAt, p.UpdatedAt = &now, now + return nil +} + +func (r *fakeRepo) UnarchiveProject(_ context.Context, id uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + p, ok := r.projects[id] + if !ok { + return nil + } + p.ArchivedAt, p.UpdatedAt = nil, time.Now() + return nil +} + +// ===== Requirement ===== + +func (r *fakeRepo) nextRequirementNumber(projectID uuid.UUID) int { + max := 0 + for _, x := range r.requirements { + if x.ProjectID == projectID && x.Number > max { + max = x.Number + } + } + return max + 1 +} + +func (r *fakeRepo) CreateRequirement(_ context.Context, in *Requirement) (*Requirement, error) { + r.mu.Lock() + defer r.mu.Unlock() + in.Number = r.nextRequirementNumber(in.ProjectID) + now := time.Now() + in.CreatedAt, in.UpdatedAt = now, now + if in.Phase == "" { + in.Phase = PhasePlanning + } + if in.Status == "" { + in.Status = StatusOpen + } + r.requirements[in.ID] = r.cloneReq(in) + return r.cloneReq(in), nil +} + +func (r *fakeRepo) GetRequirementByNumber(_ context.Context, projectID uuid.UUID, number int) (*Requirement, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, x := range r.requirements { + if x.ProjectID == projectID && x.Number == number { + return r.cloneReq(x), nil + } + } + return nil, errs.New(errs.CodeNotFound, "requirement 不存在") +} + +func (r *fakeRepo) GetRequirementByID(_ context.Context, id uuid.UUID) (*Requirement, error) { + r.mu.Lock() + defer r.mu.Unlock() + x, ok := r.requirements[id] + if !ok { + return nil, errs.New(errs.CodeNotFound, "requirement 不存在") + } + return r.cloneReq(x), nil +} + +func (r *fakeRepo) ListRequirements(_ context.Context, projectID uuid.UUID, phase Phase, status Status) ([]*Requirement, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*Requirement, 0) + for _, x := range r.requirements { + if x.ProjectID != projectID { + continue + } + if phase != "" && x.Phase != phase { + continue + } + if status != "" && x.Status != status { + continue + } + out = append(out, r.cloneReq(x)) + } + return out, nil +} + +func (r *fakeRepo) UpdateRequirement(_ context.Context, in *Requirement) (*Requirement, error) { + r.mu.Lock() + defer r.mu.Unlock() + cur, ok := r.requirements[in.ID] + if !ok { + return nil, errs.New(errs.CodeNotFound, "requirement 不存在") + } + cur.Title, cur.Description, cur.OwnerID = in.Title, in.Description, in.OwnerID + cur.UpdatedAt = time.Now() + return r.cloneReq(cur), nil +} + +func (r *fakeRepo) UpdateRequirementPhase(_ context.Context, id uuid.UUID, to Phase) (*Requirement, error) { + r.mu.Lock() + defer r.mu.Unlock() + cur, ok := r.requirements[id] + if !ok { + return nil, errs.New(errs.CodeNotFound, "requirement 不存在") + } + cur.Phase = to + cur.UpdatedAt = time.Now() + return r.cloneReq(cur), nil +} + +func (r *fakeRepo) CloseRequirement(_ context.Context, id uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + cur, ok := r.requirements[id] + if !ok || cur.Status != StatusOpen { + return nil + } + now := time.Now() + cur.Status, cur.ClosedAt, cur.UpdatedAt = StatusClosed, &now, now + return nil +} + +func (r *fakeRepo) ReopenRequirement(_ context.Context, id uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + cur, ok := r.requirements[id] + if !ok || cur.Status != StatusClosed { + return nil + } + cur.Status, cur.ClosedAt, cur.UpdatedAt = StatusOpen, nil, time.Now() + return nil +} + +// ===== Issue ===== + +func (r *fakeRepo) nextIssueNumber(projectID uuid.UUID) int { + max := 0 + for _, x := range r.issues { + if x.ProjectID == projectID && x.Number > max { + max = x.Number + } + } + return max + 1 +} + +func (r *fakeRepo) CreateIssue(_ context.Context, in *Issue) (*Issue, error) { + r.mu.Lock() + defer r.mu.Unlock() + in.Number = r.nextIssueNumber(in.ProjectID) + now := time.Now() + in.CreatedAt, in.UpdatedAt = now, now + if in.Status == "" { + in.Status = StatusOpen + } + r.issues[in.ID] = r.cloneIssue(in) + return r.cloneIssue(in), nil +} + +func (r *fakeRepo) GetIssueByNumber(_ context.Context, projectID uuid.UUID, number int) (*Issue, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, x := range r.issues { + if x.ProjectID == projectID && x.Number == number { + return r.cloneIssue(x), nil + } + } + return nil, errs.New(errs.CodeNotFound, "issue 不存在") +} + +func (r *fakeRepo) ListIssues(_ context.Context, projectID uuid.UUID, filter IssueFilter, requirementID *uuid.UUID) ([]*Issue, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*Issue, 0) + for _, x := range r.issues { + if x.ProjectID != projectID { + continue + } + if filter.Status != "" && x.Status != filter.Status { + continue + } + if filter.Requirement != nil { + switch { + case *filter.Requirement == 0: + if x.RequirementID != nil { + continue + } + default: + if x.RequirementID == nil || *x.RequirementID != *requirementID { + continue + } + } + } + if filter.AssigneeID != nil { + if x.AssigneeID == nil || *x.AssigneeID != *filter.AssigneeID { + continue + } + } + out = append(out, r.cloneIssue(x)) + } + return out, nil +} + +func (r *fakeRepo) ListIssuesByRequirement(_ context.Context, requirementID uuid.UUID) ([]*Issue, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*Issue, 0) + for _, x := range r.issues { + if x.RequirementID != nil && *x.RequirementID == requirementID { + out = append(out, r.cloneIssue(x)) + } + } + return out, nil +} + +func (r *fakeRepo) UpdateIssueCore(_ context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID) (*Issue, error) { + r.mu.Lock() + defer r.mu.Unlock() + cur, ok := r.issues[id] + if !ok { + return nil, errs.New(errs.CodeNotFound, "issue 不存在") + } + cur.Title, cur.Description, cur.RequirementID = title, description, requirementID + cur.UpdatedAt = time.Now() + return r.cloneIssue(cur), nil +} + +func (r *fakeRepo) UpdateIssueAssignee(_ context.Context, id uuid.UUID, assignee *uuid.UUID) (*Issue, error) { + r.mu.Lock() + defer r.mu.Unlock() + cur, ok := r.issues[id] + if !ok { + return nil, errs.New(errs.CodeNotFound, "issue 不存在") + } + cur.AssigneeID = assignee + cur.UpdatedAt = time.Now() + return r.cloneIssue(cur), nil +} + +func (r *fakeRepo) CloseIssue(_ context.Context, id uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + cur, ok := r.issues[id] + if !ok || cur.Status != StatusOpen { + return nil + } + now := time.Now() + cur.Status, cur.ClosedAt, cur.UpdatedAt = StatusClosed, &now, now + return nil +} + +func (r *fakeRepo) ReopenIssue(_ context.Context, id uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + cur, ok := r.issues[id] + if !ok || cur.Status != StatusClosed { + return nil + } + cur.Status, cur.ClosedAt, cur.UpdatedAt = StatusOpen, nil, time.Now() + return nil +} + +// spyAudit 收集所有写入的 audit entry,方便断言。 +type spyAudit struct { + mu sync.Mutex + entries []audit.Entry +} + +func (s *spyAudit) Record(_ context.Context, e audit.Entry) error { + s.mu.Lock() + defer s.mu.Unlock() + s.entries = append(s.entries, e) + return nil +} + +func (s *spyAudit) actions() []string { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, 0, len(s.entries)) + for _, e := range s.entries { + out = append(out, e.Action) + } + return out +} + +// seedProject 写一条测试用 Project 并返回。caller 默认为 owner。 +func seedProject(t *testing.T, r *fakeRepo, ownerID uuid.UUID) *Project { + t.Helper() + p := &Project{ + ID: uuid.New(), Slug: "demo", Name: "Demo", + Visibility: VisibilityPrivate, OwnerID: ownerID, + } + out, err := r.CreateProject(context.Background(), p) + require.NoError(t, err) + return out +} + +func TestProjectService_Create_HappyPath(t *testing.T) { + repo := newFakeRepo() + spy := &spyAudit{} + svc := NewProjectService(repo, spy) + owner := uuid.New() + p, err := svc.Create(context.Background(), Caller{UserID: owner, IsAdmin: false}, CreateProjectInput{ + Slug: "demo", Name: "Demo", Visibility: VisibilityPrivate, + }) + require.NoError(t, err) + require.Equal(t, "demo", p.Slug) + require.Equal(t, owner, p.OwnerID) + require.Equal(t, []string{"project.create"}, spy.actions()) +} + +func TestProjectService_Create_SlugTaken(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc := NewProjectService(repo, nil) + _, err := svc.Create(context.Background(), Caller{UserID: owner}, CreateProjectInput{ + Slug: "demo", Name: "Other", Visibility: VisibilityPrivate, + }) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeProjectSlugTaken, ae.Code) +} + +func TestProjectService_Get_PrivateRejectsStranger(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc := NewProjectService(repo, nil) + _, err := svc.Get(context.Background(), Caller{UserID: uuid.New()}, "demo") + ae, ok := errs.As(err) + require.True(t, ok) + // stranger 看 private project:屏蔽存在性,按 not_found 处理。 + require.Equal(t, errs.CodeNotFound, ae.Code) +} + +func TestProjectService_Get_InternalAllowsLoggedIn(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + p := seedProject(t, repo, owner) + p.Visibility = VisibilityInternal + _, err := repo.UpdateProject(context.Background(), p) + require.NoError(t, err) + svc := NewProjectService(repo, nil) + got, err := svc.Get(context.Background(), Caller{UserID: uuid.New()}, "demo") + require.NoError(t, err) + require.Equal(t, "demo", got.Slug) +} + +func TestProjectService_Update_RequiresOwnerOrAdmin(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc := NewProjectService(repo, nil) + name := "Renamed" + _, err := svc.Update(context.Background(), Caller{UserID: uuid.New()}, "demo", UpdateProjectInput{Name: &name}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeNotFound, ae.Code, "stranger 看 private 直接 not_found") +} + +func TestProjectService_Archive_BlocksWrites(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc := NewProjectService(repo, nil) + require.NoError(t, svc.Archive(context.Background(), Caller{UserID: owner}, "demo")) + name := "X" + _, err := svc.Update(context.Background(), Caller{UserID: owner}, "demo", UpdateProjectInput{Name: &name}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeProjectArchived, ae.Code) +}