feat: backend service
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"galaxy/backend/internal/server/handlers"
|
||||
"galaxy/backend/internal/server/httperr"
|
||||
"galaxy/backend/internal/server/middleware/basicauth"
|
||||
"galaxy/backend/internal/user"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminUsersHandlers groups the admin-side user-management handlers
|
||||
// under `/api/v1/admin/users/*`. The current implementation ships real implementations
|
||||
// backed by `*user.Service`; tests that supply a nil service fall back
|
||||
// to the Stage-3 placeholder body so the contract test continues to
|
||||
// validate the OpenAPI envelope without booting a database.
|
||||
type AdminUsersHandlers struct {
|
||||
svc *user.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminUsersHandlers constructs the handler set. svc may be nil — in
|
||||
// that case every handler returns 501 not_implemented, matching the
|
||||
// pre-Stage-5.2 placeholder. logger may also be nil; zap.NewNop is used
|
||||
// in that case.
|
||||
func NewAdminUsersHandlers(svc *user.Service, logger *zap.Logger) *AdminUsersHandlers {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &AdminUsersHandlers{svc: svc, logger: logger.Named("http.admin.users")}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/admin/users.
|
||||
func (h *AdminUsersHandlers) List() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminUsersList")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
result, err := h.svc.ListAccounts(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "admin users list", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accountListToWire(result))
|
||||
}
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/admin/users/{user_id}.
|
||||
func (h *AdminUsersHandlers) Get() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminUsersGet")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
account, err := h.svc.GetAccount(ctx, userID)
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "admin users get", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accountResponseToWire(account))
|
||||
}
|
||||
}
|
||||
|
||||
// AddSanction handles POST /api/v1/admin/users/{user_id}/sanctions.
|
||||
func (h *AdminUsersHandlers) AddSanction() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminUsersAddSanction")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req adminUserSanctionRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
expiresAt, err := parseTimePtr(req.ExpiresAt)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "expires_at must be RFC 3339")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
account, err := h.svc.ApplySanction(ctx, user.ApplySanctionInput{
|
||||
UserID: userID,
|
||||
SanctionCode: req.SanctionCode,
|
||||
Scope: req.Scope,
|
||||
ReasonCode: req.ReasonCode,
|
||||
Actor: wireToActorRef(req.Actor, c),
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "admin users add sanction", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accountResponseToWire(account))
|
||||
}
|
||||
}
|
||||
|
||||
// AddLimit handles POST /api/v1/admin/users/{user_id}/limits.
|
||||
func (h *AdminUsersHandlers) AddLimit() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminUsersAddLimit")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req adminUserLimitRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
expiresAt, err := parseTimePtr(req.ExpiresAt)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "expires_at must be RFC 3339")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
account, err := h.svc.ApplyLimit(ctx, user.ApplyLimitInput{
|
||||
UserID: userID,
|
||||
LimitCode: req.LimitCode,
|
||||
Value: req.Value,
|
||||
ReasonCode: req.ReasonCode,
|
||||
Actor: wireToActorRef(req.Actor, c),
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "admin users add limit", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accountResponseToWire(account))
|
||||
}
|
||||
}
|
||||
|
||||
// AddEntitlement handles POST /api/v1/admin/users/{user_id}/entitlements.
|
||||
func (h *AdminUsersHandlers) AddEntitlement() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminUsersAddEntitlement")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req adminUserEntitlementRequestWire
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
|
||||
return
|
||||
}
|
||||
startsAt, err := parseTimePtr(req.StartsAt)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "starts_at must be RFC 3339")
|
||||
return
|
||||
}
|
||||
endsAt, err := parseTimePtr(req.EndsAt)
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "ends_at must be RFC 3339")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
account, err := h.svc.ApplyEntitlement(ctx, user.ApplyEntitlementInput{
|
||||
UserID: userID,
|
||||
Tier: req.Tier,
|
||||
Source: req.Source,
|
||||
Actor: wireToActorRef(req.Actor, c),
|
||||
ReasonCode: req.ReasonCode,
|
||||
StartsAt: startsAt,
|
||||
EndsAt: endsAt,
|
||||
})
|
||||
if err != nil {
|
||||
respondAccountError(c, h.logger, "admin users add entitlement", ctx, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, accountResponseToWire(account))
|
||||
}
|
||||
}
|
||||
|
||||
// SoftDelete handles POST /api/v1/admin/users/{user_id}/soft-delete.
|
||||
func (h *AdminUsersHandlers) SoftDelete() gin.HandlerFunc {
|
||||
if h.svc == nil {
|
||||
return handlers.NotImplemented("adminUsersSoftDelete")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
userID, ok := parseUserIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
username, _ := basicauth.UsernameFromContext(ctx)
|
||||
actor := user.ActorRef{Type: "admin", ID: username}
|
||||
if err := h.svc.SoftDelete(ctx, userID, actor); err != nil {
|
||||
if errors.Is(err, user.ErrAccountNotFound) {
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
// Cascade errors do not mask the canonical state — the
|
||||
// account is soft-deleted in Postgres. Surface 204 with
|
||||
// the error logged so caller UI proceeds.
|
||||
h.logger.Warn("admin users soft-delete cascade returned error", zap.Error(err))
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// parseUserIDParam reads `user_id` from the path. On invalid input it
|
||||
// writes the standard 400 envelope and returns (uuid.Nil, false).
|
||||
func parseUserIDParam(c *gin.Context) (uuid.UUID, bool) {
|
||||
parsed, err := uuid.Parse(c.Param("user_id"))
|
||||
if err != nil {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user_id must be a valid UUID")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
// parsePositiveQueryInt parses a non-negative integer query parameter.
|
||||
// Empty / non-numeric values fall back to fallback.
|
||||
func parsePositiveQueryInt(raw string, fallback int) int {
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// wireToActorRef converts the wire-level ActorRef into the user-domain
|
||||
// type. The basic-auth context plumbing supplies a fallback id when the
|
||||
// client omits one, so admin actions always carry the operator
|
||||
// identity.
|
||||
func wireToActorRef(actor *actorRefWire, c *gin.Context) user.ActorRef {
|
||||
if actor == nil {
|
||||
username, _ := basicauth.UsernameFromContext(c.Request.Context())
|
||||
return user.ActorRef{Type: "admin", ID: username}
|
||||
}
|
||||
out := user.ActorRef{Type: actor.Type, ID: actor.ID}
|
||||
if out.ID == "" {
|
||||
if username, ok := basicauth.UsernameFromContext(c.Request.Context()); ok {
|
||||
out.ID = username
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// accountListToWire renders the AccountPage into the AdminUserList
|
||||
// schema declared in openapi.yaml.
|
||||
func accountListToWire(page user.AccountPage) adminUserListWire {
|
||||
out := adminUserListWire{
|
||||
Items: make([]accountWire, 0, len(page.Items)),
|
||||
Page: page.Page,
|
||||
PageSize: page.PageSize,
|
||||
Total: page.Total,
|
||||
}
|
||||
for _, a := range page.Items {
|
||||
out.Items = append(out.Items, accountToWire(a))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// adminUserSanctionRequestWire mirrors `AdminUserSanctionRequest`.
|
||||
type adminUserSanctionRequestWire struct {
|
||||
SanctionCode string `json:"sanction_code"`
|
||||
Scope string `json:"scope"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor *actorRefWire `json:"actor"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// adminUserLimitRequestWire mirrors `AdminUserLimitRequest`.
|
||||
type adminUserLimitRequestWire struct {
|
||||
LimitCode string `json:"limit_code"`
|
||||
Value int32 `json:"value"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Actor *actorRefWire `json:"actor"`
|
||||
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// adminUserEntitlementRequestWire mirrors `AdminUserEntitlementRequest`.
|
||||
type adminUserEntitlementRequestWire struct {
|
||||
Tier string `json:"tier"`
|
||||
Source string `json:"source"`
|
||||
Actor *actorRefWire `json:"actor"`
|
||||
ReasonCode string `json:"reason_code,omitempty"`
|
||||
StartsAt *string `json:"starts_at,omitempty"`
|
||||
EndsAt *string `json:"ends_at,omitempty"`
|
||||
}
|
||||
|
||||
// adminUserListWire mirrors `AdminUserList`.
|
||||
type adminUserListWire struct {
|
||||
Items []accountWire `json:"items"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
Reference in New Issue
Block a user