// 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 类操作的鉴权门面。成员/角色 ACL(autonomy roadmap §11) // 在既有规则之上"叠加"成员授权(只增不减): // - internal 可见性:任意登录用户可读(既有) // - owner / global-admin:可读(既有) // - 任何成员角色(viewer/member/admin):可读(新增叠加) // // 失败统一返回 CodeNotFound(避免存在性探测)。role 由 service 在调用前查得, // 非成员传 RoleNone。 func assertReadable(p *Project, c Caller, role Role) error { if p.Visibility == VisibilityInternal { return nil } if c.IsAdmin || p.OwnerID == c.UserID { return nil } if role.CanRead() { return nil } return errs.New(errs.CodeNotFound, "project 不存在") } // assertWritable 是 write 类操作的鉴权门面。成员/角色 ACL 叠加写授权: // - owner / global-admin:可写(既有) // - member / admin 成员角色:可写(新增叠加) // - viewer / 非成员:不可写 // // 已归档项目额外拒绝;调用方应在执行写入前调用此函数。role 由 service 查得。 func assertWritable(p *Project, c Caller, role Role) error { if !c.IsAdmin && p.OwnerID != c.UserID && !role.CanWrite() { // stranger 对 private 看不见;对 internal/viewer 也不能写。统一 not_found // 防探测——但若 caller 至少能读(internal 可见性或 viewer 成员),暴露 // forbidden 比 not_found 更准确(其存在性本就可见)。 if p.Visibility == VisibilityPrivate && !role.CanRead() { return errs.New(errs.CodeNotFound, "project 不存在") } return errs.New(errs.CodeForbidden, "无权修改该项目") } if p.ArchivedAt != nil { return errs.New(errs.CodeProjectArchived, "项目已归档,禁止写操作") } return nil } // memberRoleFor 查 caller 在 project 的成员角色。owner/global-admin 隐式视为 // RoleAdmin(与回填一致,避免对 owner 多查一次也无妨——这里仍走 DB 但 owner // 经迁移已有 admin 行)。查询失败按 RoleNone 处理(best-effort,既有 owner/admin // 规则仍兜底)。 func (s *projectService) memberRoleFor(ctx context.Context, p *Project, c Caller) Role { if c.IsAdmin || p.OwnerID == c.UserID { return RoleAdmin } role, err := s.repo.GetMemberRole(ctx, p.ID, c.UserID) if err != nil { return RoleNone } return role } 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, s.memberRoleFor(ctx, p, c)); err != nil { return nil, err } return p, nil } // GetByID 与 Get 等价但按 uuid 寻址,用于 workspace.ProjectAccess.ResolveByID。 // 鉴权矩阵与 Get 相同:private 仅 owner+admin,internal 任意登录用户。 func (s *projectService) GetByID(ctx context.Context, c Caller, id uuid.UUID) (*Project, error) { p, err := s.repo.GetProjectByID(ctx, id) if err != nil { return nil, err } if err := assertReadable(p, c, s.memberRoleFor(ctx, 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 { // 列表场景:internal/owner/admin 命中即收,避免对每个 private 项目都查一次 // 成员表;仅当上述既有规则不命中时才回退查成员角色(叠加 viewer/member)。 if assertReadable(p, c, RoleNone) == nil { out = append(out, p) continue } if role := s.memberRoleFor(ctx, p, c); assertReadable(p, c, role) == 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, s.memberRoleFor(ctx, 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 / global-admin / project-admin 角色,但已归档不再额外 // 阻塞 archive。project-admin 视为项目管理者,可执行生命周期操作。 if !c.IsAdmin && p.OwnerID != c.UserID && !s.memberRoleFor(ctx, p, c).CanManageMembers() { 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 && !s.memberRoleFor(ctx, p, c).CanManageMembers() { 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 } // ===== 成员/角色 ACL(autonomy roadmap §11)===== // ListMembers 返回 slug 项目的全部成员。要求 caller 对项目可读(owner/admin/ // 任意成员/internal 可见性)。 func (s *projectService) ListMembers(ctx context.Context, c Caller, slug string) ([]*Member, error) { p, err := s.repo.GetProjectBySlug(ctx, slug) if err != nil { return nil, err } if err := assertReadable(p, c, s.memberRoleFor(ctx, p, c)); err != nil { return nil, err } return s.repo.ListMembers(ctx, p.ID) } // AddMember 新增或更新一名成员的角色。仅 project-admin 角色或 global-admin 可调用。 func (s *projectService) AddMember(ctx context.Context, c Caller, slug string, in AddMemberInput) (*Member, error) { if in.UserID == uuid.Nil { return nil, errs.New(errs.CodeInvalidInput, "user_id 必填") } if !in.Role.IsValid() { return nil, errs.New(errs.CodeInvalidInput, "role 非法(仅 viewer/member/admin)") } p, err := s.repo.GetProjectBySlug(ctx, slug) if err != nil { return nil, err } if err := s.assertCanManageMembers(ctx, p, c); err != nil { return nil, err } addedBy := c.UserID m, err := s.repo.UpsertMember(ctx, p.ID, in.UserID, in.Role, &addedBy) if err != nil { return nil, err } s.recordAudit(ctx, c, "project.member_add", p.ID.String(), map[string]any{ "user_id": in.UserID.String(), "role": string(in.Role), }) return m, nil } // RemoveMember 移除一名成员。仅 project-admin 角色或 global-admin 可调用。 // 拒绝移除项目 owner(owner 经回填为 admin,移除会让其失去隐式 owner 之外的角色行, // 但 owner 仍由 OwnerID 兜底拥有权限——为避免误导,直接拒绝移除 owner)。 func (s *projectService) RemoveMember(ctx context.Context, c Caller, slug string, userID uuid.UUID) error { if userID == uuid.Nil { return errs.New(errs.CodeInvalidInput, "user_id 必填") } p, err := s.repo.GetProjectBySlug(ctx, slug) if err != nil { return err } if err := s.assertCanManageMembers(ctx, p, c); err != nil { return err } if userID == p.OwnerID { return errs.New(errs.CodeForbidden, "不能移除项目 owner") } n, err := s.repo.DeleteMember(ctx, p.ID, userID) if err != nil { return err } if n == 0 { return errs.New(errs.CodeNotFound, "成员不存在") } s.recordAudit(ctx, c, "project.member_remove", p.ID.String(), map[string]any{ "user_id": userID.String(), }) return nil } // MemberRole 返回 userID 在 projectID 的成员角色(非成员返回 RoleNone)。 // owner/global-admin 的隐式 admin 不在此体现——本方法只读 project_members 行; // app 层 projectACL 自行叠加 owner/admin 判定。 func (s *projectService) MemberRole(ctx context.Context, projectID, userID uuid.UUID) (Role, error) { return s.repo.GetMemberRole(ctx, projectID, userID) } // assertCanManageMembers 校验 caller 可管理 p 的成员:global-admin、owner 或 // project-admin 角色。private 不可见者返回 NotFound 防探测。 func (s *projectService) assertCanManageMembers(ctx context.Context, p *Project, c Caller) error { if c.IsAdmin || p.OwnerID == c.UserID { return nil } role := s.memberRoleFor(ctx, p, c) if role.CanManageMembers() { return nil } // 不可读者:private 返回 NotFound 防探测;否则 Forbidden。 if assertReadable(p, c, role) != nil { return errs.New(errs.CodeNotFound, "project 不存在") } return errs.New(errs.CodeForbidden, "无权管理项目成员") } // 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, }) }