// 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) }