refactor(api): enforce protocol-neutral service boundaries

- refactor: move HTTP status and DTO concerns out of model and service
  layers into API and handler code.
- feat: add admin service boundary and route auth through services
  instead of direct repository access.
- test: add architecture and error tests covering package boundaries,
  redaction, and service behavior.
- docs: record the protocol-neutral boundary decision and update
  architecture and roadmap notes.
This commit is contained in:
2026-07-05 23:30:17 +08:00
parent 28e17a5b08
commit 63ede5c237
34 changed files with 1028 additions and 438 deletions
+46 -19
View File
@@ -3,7 +3,6 @@ package model
import (
"errors"
"fmt"
"net/http"
)
var (
@@ -14,48 +13,76 @@ var (
ErrUploadTooLarge = errors.New("upload exceeds maximum size")
)
// AppError is a service-layer error that carries an HTTP status, a user-safe
// message, and an optional internal cause for logging. Handlers unpack it
// transparently via api.RespondError.
// ErrorKind classifies domain errors without tying them to a transport.
type ErrorKind string
// Protocol-neutral error kinds.
const (
KindInvalidArgument ErrorKind = "invalid_argument"
KindUnauthenticated ErrorKind = "unauthenticated"
KindPermissionDenied ErrorKind = "permission_denied"
KindNotFound ErrorKind = "not_found"
KindConflict ErrorKind = "conflict"
KindPayloadTooLarge ErrorKind = "payload_too_large"
KindInternal ErrorKind = "internal"
)
// AppError is a service-layer error that carries a protocol-neutral kind, a
// user-safe message, and an optional internal cause for logging.
type AppError struct {
Status int // HTTP status code
Message string // safe for API response
Err error // internal cause (nil = user-caused, skip logging)
Kind ErrorKind
Message string
Err error
}
func (e *AppError) Error() string { return e.Message }
func (e *AppError) Unwrap() error { return e.Err }
// NewBadRequestError creates a 400 AppError.
// NewInvalidArgumentError creates an invalid-argument AppError.
func NewInvalidArgumentError(message string) *AppError {
return &AppError{Kind: KindInvalidArgument, Message: message}
}
// NewBadRequestError creates an invalid-argument AppError.
func NewBadRequestError(message string) *AppError {
return &AppError{Status: http.StatusBadRequest, Message: message}
return NewInvalidArgumentError(message)
}
// NewConflictError creates a 409 AppError.
// NewUnauthenticatedError creates an unauthenticated AppError.
func NewUnauthenticatedError(message string) *AppError {
return &AppError{Kind: KindUnauthenticated, Message: message}
}
// NewConflictError creates a conflict AppError.
func NewConflictError(message string) *AppError {
return &AppError{Status: http.StatusConflict, Message: message}
return &AppError{Kind: KindConflict, Message: message}
}
// NewNotFoundError creates a 404 AppError.
// NewNotFoundError creates a not-found AppError.
func NewNotFoundError(message string, cause error) *AppError {
return &AppError{Status: http.StatusNotFound, Message: message, Err: cause}
return &AppError{Kind: KindNotFound, Message: message, Err: cause}
}
// NewForbiddenError creates a 403 AppError.
// NewPermissionDeniedError creates a permission-denied AppError.
func NewPermissionDeniedError(message string, cause error) *AppError {
return &AppError{Kind: KindPermissionDenied, Message: message, Err: cause}
}
// NewForbiddenError creates a permission-denied AppError.
func NewForbiddenError(message string, cause error) *AppError {
return &AppError{Status: http.StatusForbidden, Message: message, Err: cause}
return NewPermissionDeniedError(message, cause)
}
// NewPayloadTooLargeError creates a 413 AppError.
// NewPayloadTooLargeError creates a payload-too-large AppError.
func NewPayloadTooLargeError(message string, cause error) *AppError {
return &AppError{Status: http.StatusRequestEntityTooLarge, Message: message, Err: cause}
return &AppError{Kind: KindPayloadTooLarge, Message: message, Err: cause}
}
// NewInternalError creates a 500 AppError with a wrapped internal cause.
// NewInternalError creates an internal AppError with a wrapped internal cause.
// msg describes the operation that failed; cause is the underlying error.
func NewInternalError(msg string, cause error) *AppError {
return &AppError{
Status: http.StatusInternalServerError,
Kind: KindInternal,
Message: "internal server error",
Err: fmt.Errorf("%s: %w", msg, cause),
}