Files
2026-06-22 08:55:57 +08:00

159 lines
4.4 KiB
Go

// handler.go 暴露 admin-only 的审计日志查看接口(spec §11 步骤4)。
//
// GET /api/v1/admin/audit-logs?action=&action_prefix=&target_type=&target_id=
// &user_id=&from=&to=&limit=&offset=
//
// 鉴权:复刻 chat.adminGuard —— Auth 中间件解出 uid,再经 AdminLookup.IsAdmin
// 门禁;非 admin 返回 403。from/to 接受 RFC3339 时间字符串。
package audit
import (
"context"
"net/http"
"strconv"
"time"
"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 resolves whether a user is an admin (narrow interface; mirrors
// chat/acp handler pattern). user.Service satisfies it.
type AdminLookup interface {
IsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
}
// Handler exposes the admin audit-log read endpoint.
type Handler struct {
reader Reader
resolver middleware.SessionResolver
admin AdminLookup
}
// NewHandler constructs the audit read handler.
func NewHandler(reader Reader, resolver middleware.SessionResolver, admin AdminLookup) *Handler {
return &Handler{reader: reader, resolver: resolver, admin: admin}
}
// Mount registers the admin audit routes under /api/v1/admin.
func (h *Handler) Mount(r chi.Router) {
r.Route("/api/v1/admin/audit-logs", func(r chi.Router) {
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
r.Use(h.adminGuard)
r.Get("/", h.list)
})
}
func (h *Handler) adminGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid, ok := middleware.UserIDFromContext(r.Context())
if !ok {
h.writeErr(w, r, errs.New(errs.CodeUnauthorized, "unauthorized"))
return
}
isAdmin, err := h.admin.IsAdmin(r.Context(), uid)
if err != nil {
h.writeErr(w, r, err)
return
}
if !isAdmin {
h.writeErr(w, r, errs.New(errs.CodeForbidden, "admin only"))
return
}
next.ServeHTTP(w, r)
})
}
type auditLogDTO struct {
ID int64 `json:"id"`
UserID *string `json:"user_id,omitempty"`
Action string `json:"action"`
TargetType string `json:"target_type,omitempty"`
TargetID string `json:"target_id,omitempty"`
IP string `json:"ip,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt string `json:"created_at"`
}
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
f := AuditFilter{
Action: q.Get("action"),
ActionPrefix: q.Get("action_prefix"),
TargetType: q.Get("target_type"),
TargetID: q.Get("target_id"),
}
if v := q.Get("user_id"); v != "" {
uid, err := uuid.Parse(v)
if err != nil {
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "user_id 格式错误"))
return
}
f.UserID = &uid
}
if v := q.Get("from"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "from 须为 RFC3339"))
return
}
f.From = &t
}
if v := q.Get("to"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "to 须为 RFC3339"))
return
}
f.To = &t
}
if v := q.Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "limit 须为非负整数"))
return
}
f.Limit = n
}
if v := q.Get("offset"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "offset 须为非负整数"))
return
}
f.Offset = n
}
entries, total, err := h.reader.List(r.Context(), f)
if err != nil {
h.writeErr(w, r, err)
return
}
out := make([]auditLogDTO, 0, len(entries))
for _, e := range entries {
dto := auditLogDTO{
ID: e.ID,
Action: e.Action,
TargetType: e.TargetType,
TargetID: e.TargetID,
IP: e.IP,
Metadata: e.Metadata,
CreatedAt: e.CreatedAt.UTC().Format(time.RFC3339),
}
if e.UserID != nil {
s := e.UserID.String()
dto.UserID = &s
}
out = append(out, dto)
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": out, "total": total})
}
func (h *Handler) writeErr(w http.ResponseWriter, r *http.Request, err error) {
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
}