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
+7 -7
View File
@@ -8,11 +8,11 @@ import (
// The primary password is stored on the User model; additional credentials
// (app passkeys, WebAuthn, OAuth) are stored here with a type discriminator.
type Credential struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
UserID string `gorm:"index;type:varchar(36);not null" json:"user_id"`
Type string `gorm:"index;type:varchar(32);not null" json:"type"`
Label string `gorm:"type:varchar(128)" json:"label"`
SecretHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
ID string `gorm:"primaryKey;type:varchar(36)"`
UserID string `gorm:"index;type:varchar(36);not null"`
Type string `gorm:"index;type:varchar(32);not null"`
Label string `gorm:"type:varchar(128)"`
SecretHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
LastUsedAt *time.Time
CreatedAt time.Time
}
+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),
}
+79
View File
@@ -0,0 +1,79 @@
package model
import (
"errors"
"testing"
)
func TestAppErrorConstructors(t *testing.T) {
cause := errors.New("database offline")
tests := []struct {
name string
err *AppError
kind ErrorKind
message string
cause error
}{
{
name: "invalid argument",
err: NewInvalidArgumentError("bad input"),
kind: KindInvalidArgument,
message: "bad input",
},
{
name: "unauthenticated",
err: NewUnauthenticatedError("invalid token"),
kind: KindUnauthenticated,
message: "invalid token",
},
{
name: "permission denied",
err: NewPermissionDeniedError("access denied", ErrForbidden),
kind: KindPermissionDenied,
message: "access denied",
cause: ErrForbidden,
},
{
name: "not found",
err: NewNotFoundError("missing", ErrNotFound),
kind: KindNotFound,
message: "missing",
cause: ErrNotFound,
},
{
name: "conflict",
err: NewConflictError("duplicate"),
kind: KindConflict,
message: "duplicate",
},
{
name: "payload too large",
err: NewPayloadTooLargeError("too large", ErrUploadTooLarge),
kind: KindPayloadTooLarge,
message: "too large",
cause: ErrUploadTooLarge,
},
{
name: "internal",
err: NewInternalError("load record", cause),
kind: KindInternal,
message: "internal server error",
cause: cause,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.err.Kind != tt.kind {
t.Fatalf("Kind = %q, want %q", tt.err.Kind, tt.kind)
}
if tt.err.Message != tt.message {
t.Fatalf("Message = %q, want %q", tt.err.Message, tt.message)
}
if tt.cause != nil && !errors.Is(tt.err, tt.cause) {
t.Fatalf("errors.Is(%v, %v) = false", tt.err, tt.cause)
}
})
}
}
+12 -12
View File
@@ -9,16 +9,16 @@ const StatusUserDeleted = "user_deleted"
// File represents a file or directory entry in the virtual filesystem.
type File struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
UserID string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:1;type:varchar(36);not null" json:"user_id"`
ParentID *string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:2;type:varchar(36)" json:"parent_id"`
Name string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_parent_name,priority:3" json:"name"`
Size int64 `gorm:"default:0" json:"size"`
MimeType string `gorm:"type:varchar(127)" json:"mime_type"`
StoragePath string `gorm:"type:varchar(512)" json:"storage_path"`
Hash string `gorm:"type:varchar(64)" json:"-"`
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
IsDir bool `gorm:"default:false" json:"is_dir"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `gorm:"primaryKey;type:varchar(36)"`
UserID string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:1;type:varchar(36);not null"`
ParentID *string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:2;type:varchar(36)"`
Name string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_parent_name,priority:3"`
Size int64 `gorm:"default:0"`
MimeType string `gorm:"type:varchar(127)"`
StoragePath string `gorm:"type:varchar(512)" json:"-"`
Hash string `gorm:"type:varchar(64)" json:"-"`
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
IsDir bool `gorm:"default:false"`
CreatedAt time.Time
UpdatedAt time.Time
}
+18 -18
View File
@@ -6,18 +6,20 @@ import (
"time"
)
func TestFile_StatusExcludedFromJSON(t *testing.T) {
func TestFileJSONOmitsInternalFields(t *testing.T) {
f := &File{
ID: "1",
UserID: "u1",
ParentID: nil,
Name: "test.txt",
Size: 100,
MimeType: "text/plain",
Status: StatusActive,
IsDir: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ID: "1",
UserID: "u1",
ParentID: nil,
Name: "test.txt",
Size: 100,
MimeType: "text/plain",
StoragePath: "users/u1/files/1",
Hash: "abc123",
Status: StatusActive,
IsDir: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
data, err := json.Marshal(f)
@@ -30,13 +32,11 @@ func TestFile_StatusExcludedFromJSON(t *testing.T) {
t.Fatalf("json.Unmarshal: %v", err)
}
if _, ok := result["status"]; ok {
t.Errorf("Status field should be excluded from JSON (json:\"-\"), but it appeared in output")
}
if _, ok := result["hash"]; ok {
t.Errorf("Hash field should be excluded from JSON (json:\"-\"), but it appeared in output")
}
assertJSONKeysAbsent(t, result,
"StoragePath", "storage_path",
"Hash", "hash",
"Status", "status",
)
}
func TestStatusConstants(t *testing.T) {
+4 -4
View File
@@ -6,9 +6,9 @@ import (
// Session stores a refresh token for a user session.
type Session struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
UserID string `gorm:"index;type:varchar(36);not null" json:"user_id"`
ID string `gorm:"primaryKey;type:varchar(36)"`
UserID string `gorm:"index;type:varchar(36);not null"`
TokenHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `gorm:"not null"`
CreatedAt time.Time
}
+8 -8
View File
@@ -6,14 +6,14 @@ import (
// User represents a registered account.
type User struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
Username string `gorm:"uniqueIndex;type:varchar(64);not null" json:"username"`
Email string `gorm:"uniqueIndex;type:varchar(255);not null" json:"email"`
PasswordHash string `gorm:"type:varchar(255);not null" json:"-"`
IsAdmin bool `gorm:"default:false" json:"is_admin"`
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `gorm:"primaryKey;type:varchar(36)"`
Username string `gorm:"uniqueIndex;type:varchar(64);not null"`
Email string `gorm:"uniqueIndex;type:varchar(255);not null"`
PasswordHash string `gorm:"type:varchar(255);not null" json:"-"`
IsAdmin bool `gorm:"default:false"`
Status string `gorm:"type:varchar(32);not null;default:active;index"`
CreatedAt time.Time
UpdatedAt time.Time
}
// User status constants.
+58 -7
View File
@@ -5,12 +5,13 @@ import (
"testing"
)
func TestUserJSONOmitsStatusField(t *testing.T) {
func TestUserJSONOmitsPasswordHash(t *testing.T) {
u := User{
ID: "user-1",
Username: "alice",
Email: "alice@example.com",
Status: StatusActive,
ID: "user-1",
Username: "alice",
Email: "alice@example.com",
PasswordHash: "hash-secret",
Status: StatusActive,
}
raw, err := json.Marshal(u)
@@ -23,7 +24,57 @@ func TestUserJSONOmitsStatusField(t *testing.T) {
t.Fatalf("json.Unmarshal: %v", err)
}
if _, ok := m["status"]; ok {
t.Error("Status field should not appear in JSON output")
assertJSONKeysAbsent(t, m, "PasswordHash", "password_hash")
}
func TestSessionJSONOmitsTokenHash(t *testing.T) {
s := Session{
ID: "session-1",
UserID: "user-1",
TokenHash: "token-hash-secret",
}
raw, err := json.Marshal(s)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
assertJSONKeysAbsent(t, m, "TokenHash", "token_hash")
}
func TestCredentialJSONOmitsSecretHash(t *testing.T) {
c := Credential{
ID: "credential-1",
UserID: "user-1",
Type: "app_passkey",
Label: "Phone",
SecretHash: "credential-secret",
}
raw, err := json.Marshal(c)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("json.Unmarshal: %v", err)
}
assertJSONKeysAbsent(t, m, "SecretHash", "secret_hash")
}
func assertJSONKeysAbsent(t *testing.T, m map[string]interface{}, keys ...string) {
t.Helper()
for _, key := range keys {
if _, ok := m[key]; ok {
t.Errorf("%s field should not appear in JSON output", key)
}
}
}