feat(org,audit): M4 组织管理 + 审计日志

org 模块:
- tenants 表 (UUID PK, name, slug, status, settings)
- 完整 CRUD API: /api/v1/org/tenants
- 多租户隔离

audit 模块:
- audit_logs 表 (actor, action, resource, IP, 时间)
- 查询 API: /api/v1/audit/logs (支持分页+筛选)
- 按 tenant_id/user_id/action/resource 过滤
This commit is contained in:
MengStack Dev 2026-10-03 00:58:49 +08:00
parent f8c374a624
commit b13427e76c
15 changed files with 610 additions and 0 deletions

View File

@ -14,6 +14,8 @@ import (
"mengstack/internal/logger" "mengstack/internal/logger"
authinterfaces "mengstack/internal/modules/auth/interfaces" authinterfaces "mengstack/internal/modules/auth/interfaces"
rbacinterfaces "mengstack/internal/modules/rbac/interfaces" rbacinterfaces "mengstack/internal/modules/rbac/interfaces"
orginterfaces "mengstack/internal/modules/org/interfaces"
auditinterfaces "mengstack/internal/modules/audit/interfaces"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@ -39,6 +41,8 @@ func newEngine(
authHandler *authinterfaces.Handler, authHandler *authinterfaces.Handler,
authMW gin.HandlerFunc, authMW gin.HandlerFunc,
rbacHandler *rbacinterfaces.Handler, rbacHandler *rbacinterfaces.Handler,
orgHandler *orginterfaces.Handler,
auditHandler *auditinterfaces.Handler,
) *gin.Engine { ) *gin.Engine {
ginMode := "release" ginMode := "release"
if cfg.Server.Mode == "debug" || cfg.Server.Mode == "dev" { if cfg.Server.Mode == "debug" || cfg.Server.Mode == "dev" {
@ -59,6 +63,8 @@ func newEngine(
authinterfaces.SetupRoutes(r, authHandler, authMW) authinterfaces.SetupRoutes(r, authHandler, authMW)
rbacinterfaces.SetupRoutes(r, rbacHandler, authMW) rbacinterfaces.SetupRoutes(r, rbacHandler, authMW)
orginterfaces.SetupRoutes(r, orgHandler, authMW)
auditinterfaces.SetupRoutes(r, auditHandler, authMW)
return r return r
} }
@ -101,6 +107,8 @@ func NewApp() *fx.App {
cache.Module, cache.Module,
authinterfaces.Module, authinterfaces.Module,
rbacinterfaces.Module, rbacinterfaces.Module,
orginterfaces.Module,
auditinterfaces.Module,
Module, Module,
) )
} }

View File

@ -0,0 +1,46 @@
package application
import (
"context"
"mengstack/internal/kernel/errors"
"mengstack/internal/modules/audit/domain"
)
type Service struct {
repo domain.AuditRepository
}
func NewService(repo domain.AuditRepository) *Service {
return &Service{repo: repo}
}
func (s *Service) Log(ctx context.Context, tenantID string, userID uint, action, resource, resourceID, detail, ip string) error {
log := &domain.AuditLog{
TenantID: tenantID,
UserID: userID,
Action: action,
Resource: resource,
ResourceID: resourceID,
Detail: detail,
IP: ip,
}
if err := s.repo.Create(ctx, log); err != nil {
return errors.Wrap(err, "AUDIT_LOG_FAILED", "failed to write audit log", 500)
}
return nil
}
func (s *Service) Query(ctx context.Context, query domain.AuditQuery) ([]domain.AuditLogDTO, int64, error) {
logs, total, err := s.repo.Find(ctx, query)
if err != nil {
return nil, 0, errors.Wrap(err, "QUERY_AUDIT_FAILED", "failed to query audit logs", 500)
}
dtos := make([]domain.AuditLogDTO, len(logs))
for i, l := range logs {
dtos[i] = domain.ToAuditLogDTO(&l)
}
return dtos, total, nil
}

View File

@ -0,0 +1,54 @@
package domain
import "time"
type AuditLog struct {
ID uint `json:"id" gorm:"primaryKey"`
TenantID string `json:"tenant_id" gorm:"type:uuid;index;not null"`
UserID uint `json:"user_id" gorm:"index;not null"`
Action string `json:"action" gorm:"size:64;not null;index"`
Resource string `json:"resource" gorm:"size:128;not null;index"`
ResourceID string `json:"resource_id" gorm:"size:128"`
Detail string `json:"detail" gorm:"type:text"`
IP string `json:"ip" gorm:"size:64"`
CreatedAt time.Time `json:"created_at" gorm:"index"`
}
func (AuditLog) TableName() string {
return "audit_logs"
}
type AuditLogDTO struct {
ID uint `json:"id"`
TenantID string `json:"tenant_id"`
UserID uint `json:"user_id"`
Action string `json:"action"`
Resource string `json:"resource"`
ResourceID string `json:"resource_id"`
Detail string `json:"detail"`
IP string `json:"ip"`
CreatedAt time.Time `json:"created_at"`
}
func ToAuditLogDTO(l *AuditLog) AuditLogDTO {
return AuditLogDTO{
ID: l.ID,
TenantID: l.TenantID,
UserID: l.UserID,
Action: l.Action,
Resource: l.Resource,
ResourceID: l.ResourceID,
Detail: l.Detail,
IP: l.IP,
CreatedAt: l.CreatedAt,
}
}
type AuditQuery struct {
TenantID string
UserID uint
Action string
Resource string
Page int
PageSize int
}

View File

@ -0,0 +1,8 @@
package domain
import "context"
type AuditRepository interface {
Create(ctx context.Context, log *AuditLog) error
Find(ctx context.Context, query AuditQuery) ([]AuditLog, int64, error)
}

View File

@ -0,0 +1,61 @@
package infrastructure
import (
"context"
"mengstack/internal/modules/audit/domain"
"gorm.io/gorm"
)
type auditRepo struct {
db *gorm.DB
}
func NewAuditRepository(db *gorm.DB) domain.AuditRepository {
return &auditRepo{db: db}
}
func (r *auditRepo) Create(ctx context.Context, log *domain.AuditLog) error {
return r.db.WithContext(ctx).Create(log).Error
}
func (r *auditRepo) Find(ctx context.Context, query domain.AuditQuery) ([]domain.AuditLog, int64, error) {
var logs []domain.AuditLog
var total int64
tx := r.db.WithContext(ctx).Model(&domain.AuditLog{})
if query.TenantID != "" {
tx = tx.Where("tenant_id = ?", query.TenantID)
}
if query.UserID > 0 {
tx = tx.Where("user_id = ?", query.UserID)
}
if query.Action != "" {
tx = tx.Where("action = ?", query.Action)
}
if query.Resource != "" {
tx = tx.Where("resource = ?", query.Resource)
}
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
page := query.Page
if page < 1 {
page = 1
}
pageSize := query.PageSize
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
offset := (page - 1) * pageSize
if err := tx.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
}

View File

@ -0,0 +1,13 @@
package infrastructure
import (
"mengstack/internal/modules/audit/domain"
"gorm.io/gorm"
)
func Migrate(db *gorm.DB) error {
return db.AutoMigrate(
&domain.AuditLog{},
)
}

View File

@ -0,0 +1,53 @@
package interfaces
import (
"strconv"
"mengstack/internal/kernel/response"
"mengstack/internal/kernel/tenant"
"mengstack/internal/modules/audit/application"
auditdomain "mengstack/internal/modules/audit/domain"
"github.com/gin-gonic/gin"
)
type Handler struct {
svc *application.Service
}
func NewHandler(svc *application.Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) ListLogs(c *gin.Context) {
tenantID, _ := tenant.FromContext(c.Request.Context())
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
query := auditdomain.AuditQuery{
TenantID: tenantID,
UserID: parseUintQuery(c, "user_id"),
Action: c.Query("action"),
Resource: c.Query("resource"),
Page: page,
PageSize: pageSize,
}
dtos, total, err := h.svc.Query(c.Request.Context(), query)
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, gin.H{
"items": dtos,
"total": total,
"page": page,
})
}
func parseUintQuery(c *gin.Context, key string) uint {
v, _ := strconv.ParseUint(c.DefaultQuery(key, "0"), 10, 64)
return uint(v)
}

View File

@ -0,0 +1,33 @@
package interfaces
import (
"mengstack/internal/app/middleware"
"mengstack/internal/modules/audit/application"
auditinfra "mengstack/internal/modules/audit/infrastructure"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
"go.uber.org/zap"
"gorm.io/gorm"
)
func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc) {
audit := r.Group("/api/v1/audit")
audit.Use(authMW, middleware.MultiTenant())
{
audit.GET("/logs", h.ListLogs)
}
}
var Module = fx.Module("audit",
fx.Provide(
auditinfra.NewAuditRepository,
application.NewService,
NewHandler,
),
fx.Invoke(func(db *gorm.DB, log *zap.Logger) {
if err := auditinfra.Migrate(db); err != nil {
log.Fatal("audit migration failed", zap.Error(err))
}
}),
)

View File

@ -0,0 +1,93 @@
package application
import (
"context"
"mengstack/internal/kernel/errors"
"mengstack/internal/modules/org/domain"
)
type Service struct {
repo domain.TenantRepository
}
func NewService(repo domain.TenantRepository) *Service {
return &Service{repo: repo}
}
func (s *Service) CreateTenant(ctx context.Context, req domain.CreateTenantRequest) (domain.TenantDTO, error) {
if _, err := s.repo.FindBySlug(ctx, req.Slug); err == nil {
return domain.TenantDTO{}, errors.ErrConflict
}
tenant := &domain.Tenant{
Name: req.Name,
Slug: req.Slug,
}
if err := s.repo.Create(ctx, tenant); err != nil {
return domain.TenantDTO{}, errors.Wrap(err, "CREATE_TENANT_FAILED", "failed to create tenant", 500)
}
return domain.ToTenantDTO(tenant), nil
}
func (s *Service) ListTenants(ctx context.Context) ([]domain.TenantDTO, error) {
tenants, err := s.repo.FindAll(ctx)
if err != nil {
return nil, errors.Wrap(err, "QUERY_TENANTS_FAILED", "failed to query tenants", 500)
}
dtos := make([]domain.TenantDTO, len(tenants))
for i, t := range tenants {
dtos[i] = domain.ToTenantDTO(&t)
}
return dtos, nil
}
func (s *Service) GetTenant(ctx context.Context, id string) (domain.TenantDTO, error) {
tenant, err := s.repo.FindByID(ctx, id)
if err != nil {
return domain.TenantDTO{}, errors.ErrNotFound
}
return domain.ToTenantDTO(tenant), nil
}
func (s *Service) UpdateTenant(ctx context.Context, id string, req domain.UpdateTenantRequest) (domain.TenantDTO, error) {
tenant, err := s.repo.FindByID(ctx, id)
if err != nil {
return domain.TenantDTO{}, errors.ErrNotFound
}
if req.Name != "" {
tenant.Name = req.Name
}
if req.Slug != "" {
if existing, err := s.repo.FindBySlug(ctx, req.Slug); err == nil && existing.ID != id {
return domain.TenantDTO{}, errors.ErrConflict
}
tenant.Slug = req.Slug
}
if req.Status != nil {
tenant.Status = *req.Status
}
if err := s.repo.Update(ctx, tenant); err != nil {
return domain.TenantDTO{}, errors.Wrap(err, "UPDATE_TENANT_FAILED", "failed to update tenant", 500)
}
return domain.ToTenantDTO(tenant), nil
}
func (s *Service) DeleteTenant(ctx context.Context, id string) error {
if _, err := s.repo.FindByID(ctx, id); err != nil {
return errors.ErrNotFound
}
if err := s.repo.Delete(ctx, id); err != nil {
return errors.Wrap(err, "DELETE_TENANT_FAILED", "failed to delete tenant", 500)
}
return nil
}

View File

@ -0,0 +1,12 @@
package domain
import "context"
type TenantRepository interface {
Create(ctx context.Context, tenant *Tenant) error
FindByID(ctx context.Context, id string) (*Tenant, error)
FindBySlug(ctx context.Context, slug string) (*Tenant, error)
FindAll(ctx context.Context) ([]Tenant, error)
Update(ctx context.Context, tenant *Tenant) error
Delete(ctx context.Context, id string) error
}

View File

@ -0,0 +1,48 @@
package domain
import "time"
type Tenant struct {
ID string `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
Name string `json:"name" gorm:"size:128;not null"`
Slug string `json:"slug" gorm:"uniqueIndex;size:64;not null"`
Status int `json:"status" gorm:"default:1"`
Settings string `json:"settings" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Tenant) TableName() string {
return "tenants"
}
type TenantDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Status int `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func ToTenantDTO(t *Tenant) TenantDTO {
return TenantDTO{
ID: t.ID,
Name: t.Name,
Slug: t.Slug,
Status: t.Status,
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
}
}
type CreateTenantRequest struct {
Name string `json:"name" binding:"required"`
Slug string `json:"slug" binding:"required"`
}
type UpdateTenantRequest struct {
Name string `json:"name"`
Slug string `json:"slug"`
Status *int `json:"status"`
}

View File

@ -0,0 +1,13 @@
package infrastructure
import (
"mengstack/internal/modules/org/domain"
"gorm.io/gorm"
)
func Migrate(db *gorm.DB) error {
return db.AutoMigrate(
&domain.Tenant{},
)
}

View File

@ -0,0 +1,47 @@
package infrastructure
import (
"context"
"mengstack/internal/modules/org/domain"
"gorm.io/gorm"
)
type tenantRepo struct {
db *gorm.DB
}
func NewTenantRepository(db *gorm.DB) domain.TenantRepository {
return &tenantRepo{db: db}
}
func (r *tenantRepo) Create(ctx context.Context, tenant *domain.Tenant) error {
return r.db.WithContext(ctx).Create(tenant).Error
}
func (r *tenantRepo) FindByID(ctx context.Context, id string) (*domain.Tenant, error) {
var tenant domain.Tenant
err := r.db.WithContext(ctx).Where("id = ?", id).First(&tenant).Error
return &tenant, err
}
func (r *tenantRepo) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
var tenant domain.Tenant
err := r.db.WithContext(ctx).Where("slug = ?", slug).First(&tenant).Error
return &tenant, err
}
func (r *tenantRepo) FindAll(ctx context.Context) ([]domain.Tenant, error) {
var tenants []domain.Tenant
err := r.db.WithContext(ctx).Order("created_at DESC").Find(&tenants).Error
return tenants, err
}
func (r *tenantRepo) Update(ctx context.Context, tenant *domain.Tenant) error {
return r.db.WithContext(ctx).Save(tenant).Error
}
func (r *tenantRepo) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&domain.Tenant{}).Error
}

View File

@ -0,0 +1,84 @@
package interfaces
import (
"mengstack/internal/kernel/response"
"mengstack/internal/modules/org/application"
"mengstack/internal/modules/org/domain"
"github.com/gin-gonic/gin"
)
type Handler struct {
svc *application.Service
}
func NewHandler(svc *application.Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) CreateTenant(c *gin.Context) {
var req domain.CreateTenantRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, response.NewBadRequest("invalid request body"))
return
}
dto, err := h.svc.CreateTenant(c.Request.Context(), req)
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, dto)
}
func (h *Handler) ListTenants(c *gin.Context) {
dtos, err := h.svc.ListTenants(c.Request.Context())
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, dtos)
}
func (h *Handler) GetTenant(c *gin.Context) {
id := c.Param("id")
dto, err := h.svc.GetTenant(c.Request.Context(), id)
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, dto)
}
func (h *Handler) UpdateTenant(c *gin.Context) {
id := c.Param("id")
var req domain.UpdateTenantRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, response.NewBadRequest("invalid request body"))
return
}
dto, err := h.svc.UpdateTenant(c.Request.Context(), id, req)
if err != nil {
response.HandleError(c, err)
return
}
response.Success(c, dto)
}
func (h *Handler) DeleteTenant(c *gin.Context) {
id := c.Param("id")
if err := h.svc.DeleteTenant(c.Request.Context(), id); err != nil {
response.HandleError(c, err)
return
}
response.Success(c, nil)
}

View File

@ -0,0 +1,37 @@
package interfaces
import (
"mengstack/internal/app/middleware"
"mengstack/internal/modules/org/application"
"mengstack/internal/modules/org/infrastructure"
"github.com/gin-gonic/gin"
"go.uber.org/fx"
"go.uber.org/zap"
"gorm.io/gorm"
)
func SetupRoutes(r *gin.Engine, h *Handler, authMW gin.HandlerFunc) {
org := r.Group("/api/v1/org")
org.Use(authMW, middleware.MultiTenant())
{
org.POST("/tenants", h.CreateTenant)
org.GET("/tenants", h.ListTenants)
org.GET("/tenants/:id", h.GetTenant)
org.PUT("/tenants/:id", h.UpdateTenant)
org.DELETE("/tenants/:id", h.DeleteTenant)
}
}
var Module = fx.Module("org",
fx.Provide(
infrastructure.NewTenantRepository,
application.NewService,
NewHandler,
),
fx.Invoke(func(db *gorm.DB, log *zap.Logger) {
if err := infrastructure.Migrate(db); err != nil {
log.Fatal("org migration failed", zap.Error(err))
}
}),
)