feat(project): http handler with project/requirement/issue endpoints

This commit is contained in:
2026-05-01 10:27:42 +08:00
parent 13b4d45868
commit 76a1461f4b
2 changed files with 829 additions and 0 deletions
+713
View File
@@ -0,0 +1,713 @@
// Package project 内的 handler.go 是三个聚合根的 HTTP 接入层。
//
// 路由约定(PM spec §3.1):
//
// /api/v1/projects CRUD + archive
// /api/v1/projects/{slug}/requirements/... CRUD + phase + close
// /api/v1/projects/{slug}/issues/... CRUD + assign + close
//
// 设计取舍:
// - 把 Caller 提取做成 callerFromCtx helper:从 middleware.UserIDFromContext
// 读 uid,再通过 AdminLookup 拿 is_admin。AdminLookup 是窄接口,避免直接
// 依赖 internal/user 的具体类型(也方便测试时注入 stub)。
// - phase 切换 / close / reopen / archive / assign 都走独立 POST endpoint:
// 便于 audit 区分语义;PATCH 仅改 title/description/visibility/owner_id。
package project
import (
"context"
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
)
// AdminLookup 根据 user id 判 is_admin。
type AdminLookup interface {
IsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
}
// Handler 把三个 service 与一个 AdminLookup 拼到 HTTP 路由上。
type Handler struct {
projects ProjectService
requirements RequirementService
issues IssueService
resolver middleware.SessionResolver
users AdminLookup
}
// NewHandler 构造 Handler。
func NewHandler(p ProjectService, r RequirementService, i IssueService, resolver middleware.SessionResolver, users AdminLookup) *Handler {
return &Handler{projects: p, requirements: r, issues: i, resolver: resolver, users: users}
}
// Mount 把所有 PM 路由挂到 r 上,并装入 Auth 中间件。
func (h *Handler) Mount(r chi.Router) {
r.Route("/api/v1/projects", func(r chi.Router) {
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
r.Post("/", h.createProject)
r.Get("/", h.listProjects)
r.Get("/{slug}", h.getProject)
r.Patch("/{slug}", h.updateProject)
r.Post("/{slug}/archive", h.archiveProject)
r.Post("/{slug}/unarchive", h.unarchiveProject)
r.Post("/{slug}/requirements", h.createRequirement)
r.Get("/{slug}/requirements", h.listRequirements)
r.Get("/{slug}/requirements/{number}", h.getRequirement)
r.Patch("/{slug}/requirements/{number}", h.updateRequirement)
r.Post("/{slug}/requirements/{number}/phase", h.changeRequirementPhase)
r.Post("/{slug}/requirements/{number}/close", h.closeRequirement)
r.Post("/{slug}/requirements/{number}/reopen", h.reopenRequirement)
r.Post("/{slug}/issues", h.createIssue)
r.Get("/{slug}/issues", h.listIssues)
r.Get("/{slug}/issues/{number}", h.getIssue)
r.Patch("/{slug}/issues/{number}", h.updateIssue)
r.Post("/{slug}/issues/{number}/assign", h.assignIssue)
r.Post("/{slug}/issues/{number}/close", h.closeIssue)
r.Post("/{slug}/issues/{number}/reopen", h.reopenIssue)
})
}
// callerFromCtx 从 ctx 取出 uid,再调用 AdminLookup 取 is_admin。
// 没有 uid 时返回 401(Auth 中间件已经在前面拦了,这里是双保险)。
func (h *Handler) callerFromCtx(r *http.Request) (Caller, error) {
uid, ok := middleware.UserIDFromContext(r.Context())
if !ok {
return Caller{}, errs.New(errs.CodeUnauthorized, "未认证")
}
isAdmin, err := h.users.IsAdmin(r.Context(), uid)
if err != nil {
return Caller{}, err
}
return Caller{UserID: uid, IsAdmin: isAdmin}, nil
}
// numberParam 解析 chi URL 中的 {number}。失败时返回 invalid_input。
func numberParam(r *http.Request) (int, error) {
raw := chi.URLParam(r, "number")
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return 0, errs.New(errs.CodeInvalidInput, "number 非法")
}
return n, nil
}
func writeErr(w http.ResponseWriter, r *http.Request, err error) {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
}
func decodeBody(r *http.Request, dst any) error {
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
return errs.New(errs.CodeInvalidInput, "请求体无效")
}
return nil
}
// ===== DTO =====
type projectDTO struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
OwnerID string `json:"owner_id"`
ArchivedAt *string `json:"archived_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type requirementDTO struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Number int `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
Phase string `json:"phase"`
Status string `json:"status"`
OwnerID string `json:"owner_id"`
ClosedAt *string `json:"closed_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type issueDTO struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
RequirementID *string `json:"requirement_id"`
Number int `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
AssigneeID *string `json:"assignee_id"`
CreatedBy string `json:"created_by"`
ClosedAt *string `json:"closed_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func toProjectDTO(p *Project) projectDTO {
d := projectDTO{
ID: p.ID.String(), Slug: p.Slug, Name: p.Name, Description: p.Description,
Visibility: string(p.Visibility), OwnerID: p.OwnerID.String(),
CreatedAt: p.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
UpdatedAt: p.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
if p.ArchivedAt != nil {
s := p.ArchivedAt.UTC().Format("2006-01-02T15:04:05Z")
d.ArchivedAt = &s
}
return d
}
func toRequirementDTO(r *Requirement) requirementDTO {
d := requirementDTO{
ID: r.ID.String(), ProjectID: r.ProjectID.String(),
Number: r.Number, Title: r.Title, Description: r.Description,
Phase: string(r.Phase), Status: string(r.Status), OwnerID: r.OwnerID.String(),
CreatedAt: r.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
UpdatedAt: r.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
if r.ClosedAt != nil {
s := r.ClosedAt.UTC().Format("2006-01-02T15:04:05Z")
d.ClosedAt = &s
}
return d
}
func toIssueDTO(i *Issue) issueDTO {
d := issueDTO{
ID: i.ID.String(), ProjectID: i.ProjectID.String(),
Number: i.Number, Title: i.Title, Description: i.Description,
Status: string(i.Status), CreatedBy: i.CreatedBy.String(),
CreatedAt: i.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
UpdatedAt: i.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
if i.RequirementID != nil {
s := i.RequirementID.String()
d.RequirementID = &s
}
if i.AssigneeID != nil {
s := i.AssigneeID.String()
d.AssigneeID = &s
}
if i.ClosedAt != nil {
s := i.ClosedAt.UTC().Format("2006-01-02T15:04:05Z")
d.ClosedAt = &s
}
return d
}
// ===== Project endpoints =====
type createProjectReq struct {
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
}
func (h *Handler) createProject(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
var req createProjectReq
if err := decodeBody(r, &req); err != nil {
writeErr(w, r, err)
return
}
vis := Visibility(req.Visibility)
if req.Visibility == "" {
vis = VisibilityPrivate
}
out, err := h.projects.Create(r.Context(), c, CreateProjectInput{
Slug: req.Slug, Name: req.Name, Description: req.Description, Visibility: vis,
})
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, toProjectDTO(out))
}
func (h *Handler) listProjects(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
includeArchived := r.URL.Query().Get("archived") == "true"
out, err := h.projects.List(r.Context(), c, includeArchived)
if err != nil {
writeErr(w, r, err)
return
}
dtos := make([]projectDTO, 0, len(out))
for _, p := range out {
dtos = append(dtos, toProjectDTO(p))
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos})
}
func (h *Handler) getProject(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
out, err := h.projects.Get(r.Context(), c, chi.URLParam(r, "slug"))
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, toProjectDTO(out))
}
type updateProjectReq struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Visibility *string `json:"visibility,omitempty"`
}
func (h *Handler) updateProject(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
var req updateProjectReq
if err := decodeBody(r, &req); err != nil {
writeErr(w, r, err)
return
}
in := UpdateProjectInput{Name: req.Name, Description: req.Description}
if req.Visibility != nil {
v := Visibility(*req.Visibility)
in.Visibility = &v
}
out, err := h.projects.Update(r.Context(), c, chi.URLParam(r, "slug"), in)
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, toProjectDTO(out))
}
func (h *Handler) archiveProject(w http.ResponseWriter, r *http.Request) { h.toggleArchive(w, r, true) }
func (h *Handler) unarchiveProject(w http.ResponseWriter, r *http.Request) { h.toggleArchive(w, r, false) }
func (h *Handler) toggleArchive(w http.ResponseWriter, r *http.Request, archive bool) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
slug := chi.URLParam(r, "slug")
if archive {
err = h.projects.Archive(r.Context(), c, slug)
} else {
err = h.projects.Unarchive(r.Context(), c, slug)
}
if err != nil {
writeErr(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ===== Requirement endpoints =====
type createRequirementReq struct {
Title string `json:"title"`
Description string `json:"description"`
OwnerID *string `json:"owner_id,omitempty"`
}
func (h *Handler) createRequirement(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
var req createRequirementReq
if err := decodeBody(r, &req); err != nil {
writeErr(w, r, err)
return
}
in := CreateRequirementInput{Title: req.Title, Description: req.Description}
if req.OwnerID != nil {
uid, perr := uuid.Parse(*req.OwnerID)
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "owner_id 非法"))
return
}
in.OwnerID = &uid
}
out, err := h.requirements.Create(r.Context(), c, chi.URLParam(r, "slug"), in)
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, toRequirementDTO(out))
}
func (h *Handler) listRequirements(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
out, err := h.requirements.List(r.Context(), c, chi.URLParam(r, "slug"), RequirementFilter{
Phase: Phase(r.URL.Query().Get("phase")),
Status: Status(r.URL.Query().Get("status")),
})
if err != nil {
writeErr(w, r, err)
return
}
dtos := make([]requirementDTO, 0, len(out))
for _, x := range out {
dtos = append(dtos, toRequirementDTO(x))
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos})
}
func (h *Handler) getRequirement(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
num, err := numberParam(r)
if err != nil {
writeErr(w, r, err)
return
}
req, err := h.requirements.Get(r.Context(), c, chi.URLParam(r, "slug"), num)
if err != nil {
writeErr(w, r, err)
return
}
// 详情携带挂在其下的 issues 摘要
issues, _ := h.issues.List(r.Context(), c, chi.URLParam(r, "slug"), IssueFilter{Requirement: &num})
idtos := make([]issueDTO, 0, len(issues))
for _, i := range issues {
idtos = append(idtos, toIssueDTO(i))
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"requirement": toRequirementDTO(req),
"issues": idtos,
})
}
type updateRequirementReq struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
OwnerID *string `json:"owner_id,omitempty"`
}
func (h *Handler) updateRequirement(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
num, err := numberParam(r)
if err != nil {
writeErr(w, r, err)
return
}
var req updateRequirementReq
if err := decodeBody(r, &req); err != nil {
writeErr(w, r, err)
return
}
in := UpdateRequirementInput{Title: req.Title, Description: req.Description}
if req.OwnerID != nil {
uid, perr := uuid.Parse(*req.OwnerID)
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "owner_id 非法"))
return
}
in.OwnerID = &uid
}
out, err := h.requirements.Update(r.Context(), c, chi.URLParam(r, "slug"), num, in)
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, toRequirementDTO(out))
}
type phaseReq struct {
Phase string `json:"phase"`
}
func (h *Handler) changeRequirementPhase(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
num, err := numberParam(r)
if err != nil {
writeErr(w, r, err)
return
}
var req phaseReq
if err := decodeBody(r, &req); err != nil {
writeErr(w, r, err)
return
}
out, err := h.requirements.ChangePhase(r.Context(), c, chi.URLParam(r, "slug"), num, Phase(req.Phase))
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, toRequirementDTO(out))
}
func (h *Handler) closeRequirement(w http.ResponseWriter, r *http.Request) { h.toggleReqStatus(w, r, true) }
func (h *Handler) reopenRequirement(w http.ResponseWriter, r *http.Request) { h.toggleReqStatus(w, r, false) }
func (h *Handler) toggleReqStatus(w http.ResponseWriter, r *http.Request, doClose bool) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
num, err := numberParam(r)
if err != nil {
writeErr(w, r, err)
return
}
if doClose {
err = h.requirements.Close(r.Context(), c, chi.URLParam(r, "slug"), num)
} else {
err = h.requirements.Reopen(r.Context(), c, chi.URLParam(r, "slug"), num)
}
if err != nil {
writeErr(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ===== Issue endpoints =====
type createIssueReq struct {
Title string `json:"title"`
Description string `json:"description"`
RequirementNumber *int `json:"requirement_number,omitempty"`
AssigneeID *string `json:"assignee_id,omitempty"`
}
func (h *Handler) createIssue(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
var req createIssueReq
if err := decodeBody(r, &req); err != nil {
writeErr(w, r, err)
return
}
in := CreateIssueInput{Title: req.Title, Description: req.Description, RequirementNumber: req.RequirementNumber}
if req.AssigneeID != nil {
uid, perr := uuid.Parse(*req.AssigneeID)
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "assignee_id 非法"))
return
}
in.AssigneeID = &uid
}
out, err := h.issues.Create(r.Context(), c, chi.URLParam(r, "slug"), in)
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusCreated, toIssueDTO(out))
}
func (h *Handler) listIssues(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
q := r.URL.Query()
filter := IssueFilter{Status: Status(q.Get("status"))}
switch req := q.Get("requirement"); req {
case "":
// 不过滤
case "none", "null":
zero := 0
filter.Requirement = &zero
default:
n, perr := strconv.Atoi(req)
if perr != nil || n <= 0 {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "requirement 参数非法"))
return
}
filter.Requirement = &n
}
if a := q.Get("assignee"); a != "" {
uid, perr := uuid.Parse(a)
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "assignee 非法"))
return
}
filter.AssigneeID = &uid
}
out, err := h.issues.List(r.Context(), c, chi.URLParam(r, "slug"), filter)
if err != nil {
writeErr(w, r, err)
return
}
dtos := make([]issueDTO, 0, len(out))
for _, x := range out {
dtos = append(dtos, toIssueDTO(x))
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos})
}
func (h *Handler) getIssue(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
num, err := numberParam(r)
if err != nil {
writeErr(w, r, err)
return
}
out, err := h.issues.Get(r.Context(), c, chi.URLParam(r, "slug"), num)
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, toIssueDTO(out))
}
// updateIssueReq 用 json.RawMessage 区分 "未传"、"显式传 null"、"传值"。
type updateIssueReq struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
RequirementNumber json.RawMessage `json:"requirement_number,omitempty"`
}
func (h *Handler) updateIssue(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
num, err := numberParam(r)
if err != nil {
writeErr(w, r, err)
return
}
var req updateIssueReq
if err := decodeBody(r, &req); err != nil {
writeErr(w, r, err)
return
}
in := UpdateIssueInput{Title: req.Title, Description: req.Description}
if len(req.RequirementNumber) > 0 {
// 字段在 JSON 中存在
if string(req.RequirementNumber) == "null" {
var nilPtr *int
in.RequirementNumber = &nilPtr
} else {
var n int
if jerr := json.Unmarshal(req.RequirementNumber, &n); jerr != nil || n <= 0 {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "requirement_number 非法"))
return
}
pn := &n
in.RequirementNumber = &pn
}
}
out, err := h.issues.Update(r.Context(), c, chi.URLParam(r, "slug"), num, in)
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, toIssueDTO(out))
}
type assignReq struct {
AssigneeID *string `json:"assignee_id"`
}
func (h *Handler) assignIssue(w http.ResponseWriter, r *http.Request) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
num, err := numberParam(r)
if err != nil {
writeErr(w, r, err)
return
}
var req assignReq
if err := decodeBody(r, &req); err != nil {
writeErr(w, r, err)
return
}
var assignee *uuid.UUID
if req.AssigneeID != nil {
uid, perr := uuid.Parse(*req.AssigneeID)
if perr != nil {
writeErr(w, r, errs.New(errs.CodeInvalidInput, "assignee_id 非法"))
return
}
assignee = &uid
}
out, err := h.issues.Assign(r.Context(), c, chi.URLParam(r, "slug"), num, assignee)
if err != nil {
writeErr(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, toIssueDTO(out))
}
func (h *Handler) closeIssue(w http.ResponseWriter, r *http.Request) { h.toggleIssueStatus(w, r, true) }
func (h *Handler) reopenIssue(w http.ResponseWriter, r *http.Request) { h.toggleIssueStatus(w, r, false) }
func (h *Handler) toggleIssueStatus(w http.ResponseWriter, r *http.Request, doClose bool) {
c, err := h.callerFromCtx(r)
if err != nil {
writeErr(w, r, err)
return
}
num, err := numberParam(r)
if err != nil {
writeErr(w, r, err)
return
}
if doClose {
err = h.issues.Close(r.Context(), c, chi.URLParam(r, "slug"), num)
} else {
err = h.issues.Reopen(r.Context(), c, chi.URLParam(r, "slug"), num)
}
if err != nil {
writeErr(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}