feat(server/ops-api): add operations console backend
Stand up an internal ops-api service with operator account, auth, and audit log subsystems backed by a dedicated migrations_ops migration set. Wire Makefile targets and the migrate Dockerfile stage so the new schema ships alongside the existing tenant + monitoring databases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/app"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
type createAccountRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type updateProfileRequest struct {
|
||||
DisplayName string `json:"display_name" binding:"required"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type setStatusRequest struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
}
|
||||
|
||||
type resetPasswordRequest struct {
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type changeOwnPasswordRequest struct {
|
||||
OldPassword string `json:"old_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
}
|
||||
|
||||
func listAccountsHandler(svc *app.AccountService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
result, err := svc.List(c.Request.Context(), app.ListInput{
|
||||
Keyword: c.Query("keyword"),
|
||||
Status: c.Query("status"),
|
||||
Page: page,
|
||||
Size: size,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func createAccountHandler(svc *app.AccountService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body createAccountRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
actor := actorFromGin(c)
|
||||
detail, err := svc.Create(c.Request.Context(), actor, app.CreateInput{
|
||||
Username: body.Username,
|
||||
Password: body.Password,
|
||||
DisplayName: body.DisplayName,
|
||||
Email: body.Email,
|
||||
Role: body.Role,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, detail)
|
||||
}
|
||||
}
|
||||
|
||||
func getAccountHandler(svc *app.AccountService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
detail, err := svc.Get(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, detail)
|
||||
}
|
||||
}
|
||||
|
||||
func updateAccountProfileHandler(svc *app.AccountService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
var body updateProfileRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
if err := svc.UpdateProfile(c.Request.Context(), actorFromGin(c), id, app.UpdateProfileInput{
|
||||
DisplayName: body.DisplayName,
|
||||
Email: body.Email,
|
||||
}); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
|
||||
func setAccountStatusHandler(svc *app.AccountService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
var body setStatusRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
if err := svc.SetStatus(c.Request.Context(), actorFromGin(c), id, body.Status); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"id": id, "status": body.Status})
|
||||
}
|
||||
}
|
||||
|
||||
func resetAccountPasswordHandler(svc *app.AccountService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
var body resetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
if err := svc.ResetPassword(c.Request.Context(), actorFromGin(c), id, body.Password); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
|
||||
func changeOwnPasswordHandler(svc *app.AccountService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body changeOwnPasswordRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
if err := svc.ChangeOwnPassword(c.Request.Context(), actorFromGin(c), app.ChangeOwnPasswordInput{
|
||||
OldPassword: body.OldPassword,
|
||||
NewPassword: body.NewPassword,
|
||||
}); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func parseIDParam(c *gin.Context) (int64, error) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, response.ErrBadRequest(40001, "invalid_id", "无效的 ID")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/app"
|
||||
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
func listAuditsHandler(svc *app.AuditService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "50"))
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 || size > 500 {
|
||||
size = 50
|
||||
}
|
||||
|
||||
filter := domain.AuditLogFilter{
|
||||
Action: c.Query("action"),
|
||||
Limit: size,
|
||||
Offset: (page - 1) * size,
|
||||
}
|
||||
|
||||
if v := c.Query("operator_id"); v != "" {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40002, "invalid_operator_id", "operator_id 不合法"))
|
||||
return
|
||||
}
|
||||
filter.OperatorID = &id
|
||||
}
|
||||
if v := c.Query("start_at"); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40003, "invalid_start_at", "start_at 必须是 RFC3339"))
|
||||
return
|
||||
}
|
||||
filter.StartAt = &t
|
||||
}
|
||||
if v := c.Query("end_at"); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40004, "invalid_end_at", "end_at 必须是 RFC3339"))
|
||||
return
|
||||
}
|
||||
filter.EndAt = &t
|
||||
}
|
||||
|
||||
items, total, err := svc.List(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
dtos := make([]auditLogDTO, 0, len(items))
|
||||
for _, log := range items {
|
||||
dtos = append(dtos, toAuditLogDTO(log))
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"items": dtos,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type auditLogDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
OperatorID *int64 `json:"operator_id"`
|
||||
OperatorName *string `json:"operator_name"`
|
||||
Action string `json:"action"`
|
||||
TargetType *string `json:"target_type"`
|
||||
TargetID *string `json:"target_id"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
IP *string `json:"ip"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
RequestID *string `json:"request_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func toAuditLogDTO(l domain.AuditLog) auditLogDTO {
|
||||
return auditLogDTO{
|
||||
ID: l.ID,
|
||||
OperatorID: l.OperatorID,
|
||||
OperatorName: l.OperatorName,
|
||||
Action: l.Action,
|
||||
TargetType: l.TargetType,
|
||||
TargetID: l.TargetID,
|
||||
Metadata: l.Metadata,
|
||||
IP: l.IP,
|
||||
UserAgent: l.UserAgent,
|
||||
RequestID: l.RequestID,
|
||||
CreatedAt: l.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/app"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Operator app.OperatorView `json:"operator"`
|
||||
}
|
||||
|
||||
func loginHandler(svc *app.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body loginRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := svc.Login(c.Request.Context(), app.LoginInput{
|
||||
Username: body.Username,
|
||||
Password: body.Password,
|
||||
IP: c.ClientIP(),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
RequestID: middleware.RequestIDFromGin(c),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, loginResponse{
|
||||
AccessToken: result.AccessToken,
|
||||
ExpiresAt: result.ExpiresAt,
|
||||
Operator: result.Operator,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func meHandler(accounts *app.AccountService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
actor := actorFromGin(c)
|
||||
if actor == nil {
|
||||
response.Error(c, response.ErrUnauthorized(40101, "missing_actor", "未登录"))
|
||||
return
|
||||
}
|
||||
detail, err := accounts.Get(c.Request.Context(), actor.OperatorID)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, detail)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/app"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
func authMiddleware(issuer *app.TokenIssuer) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
header := c.GetHeader("Authorization")
|
||||
raw, ok := bearerToken(header)
|
||||
if !ok {
|
||||
response.Error(c, response.ErrUnauthorized(40101, "missing_bearer_token", "缺少认证 token"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := issuer.Parse(raw)
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrUnauthorized(40102, "invalid_access_token", "token 无效或已过期"))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
actor := &app.Actor{
|
||||
OperatorID: claims.OperatorID,
|
||||
Username: claims.Username,
|
||||
DisplayName: claims.DisplayName,
|
||||
Role: claims.Role,
|
||||
IP: c.ClientIP(),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
RequestID: middleware.RequestIDFromGin(c),
|
||||
}
|
||||
c.Set(actorGinKey, actor)
|
||||
c.Request = c.Request.WithContext(app.WithActor(c.Request.Context(), actor))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
const actorGinKey = "ops_actor"
|
||||
|
||||
func actorFromGin(c *gin.Context) *app.Actor {
|
||||
if v, ok := c.Get(actorGinKey); ok {
|
||||
if actor, ok := v.(*app.Actor); ok {
|
||||
return actor
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bearerToken(header string) (string, bool) {
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
|
||||
return "", false
|
||||
}
|
||||
token := strings.TrimSpace(parts[1])
|
||||
return token, token != ""
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/app"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
type Deps struct {
|
||||
Engine *gin.Engine
|
||||
Logger *zap.Logger
|
||||
DB *pgxpool.Pool
|
||||
Issuer *app.TokenIssuer
|
||||
Auth *app.AuthService
|
||||
Accounts *app.AccountService
|
||||
Audits *app.AuditService
|
||||
}
|
||||
|
||||
func RegisterRoutes(d Deps) {
|
||||
d.Engine.Use(
|
||||
middleware.Recovery(d.Logger),
|
||||
middleware.RequestID(),
|
||||
middleware.Logger(d.Logger),
|
||||
middleware.CORS(),
|
||||
)
|
||||
|
||||
d.Engine.GET("/api/health/live", func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"status": "alive"})
|
||||
})
|
||||
d.Engine.GET("/api/health/ready", func(c *gin.Context) {
|
||||
if err := d.DB.Ping(c.Request.Context()); err != nil {
|
||||
response.Error(c, response.ErrServiceUnavailable(50301, "postgres_unavailable", "数据库不可达"))
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"status": "ready"})
|
||||
})
|
||||
|
||||
api := d.Engine.Group("/api/ops")
|
||||
{
|
||||
api.POST("/auth/login", loginHandler(d.Auth))
|
||||
}
|
||||
|
||||
authed := api.Group("")
|
||||
authed.Use(authMiddleware(d.Issuer))
|
||||
{
|
||||
authed.GET("/auth/me", meHandler(d.Accounts))
|
||||
authed.POST("/auth/password", changeOwnPasswordHandler(d.Accounts))
|
||||
|
||||
authed.GET("/accounts", listAccountsHandler(d.Accounts))
|
||||
authed.POST("/accounts", createAccountHandler(d.Accounts))
|
||||
authed.GET("/accounts/:id", getAccountHandler(d.Accounts))
|
||||
authed.PATCH("/accounts/:id", updateAccountProfileHandler(d.Accounts))
|
||||
authed.POST("/accounts/:id/status", setAccountStatusHandler(d.Accounts))
|
||||
authed.POST("/accounts/:id/password", resetAccountPasswordHandler(d.Accounts))
|
||||
|
||||
authed.GET("/audits", listAuditsHandler(d.Audits))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user