Files
agentic-coding-workflow/internal/project/project_service.go
T

203 lines
5.9 KiB
Go

// 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"] = "<changed>"
}
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,
})
}