diff --git a/README.md b/README.md index c5c8df4..6ba1eac 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ npm run dev - 用户管理后台已接入,页面为 `http://localhost:5173/admin/users`,支持冻结和解冻用户。 - 订单管理后台已接入,页面为 `http://localhost:5173/admin/orders`,支持查看全量订单和交接记录。 - 商品审核后台已接入,页面为 `http://localhost:5173/admin/listings/review`。 +- 审计日志后台已接入,页面为 `http://localhost:5173/admin/audit-logs`,支持查看高风险操作明细。 ## 文档 diff --git a/backend/internal/modules/adminaudit/dto.go b/backend/internal/modules/adminaudit/dto.go new file mode 100644 index 0000000..f6dfafb --- /dev/null +++ b/backend/internal/modules/adminaudit/dto.go @@ -0,0 +1,29 @@ +package adminaudit + +import ( + "time" + + "gorm.io/datatypes" +) + +type Query struct { + ActorID uint64 + Action string + BizType string + Limit int +} + +type LogDTO struct { + ID uint64 `json:"id"` + ActorType string `json:"actor_type"` + ActorID uint64 `json:"actor_id"` + ActorUsername string `json:"actor_username"` + ActorNickname string `json:"actor_nickname"` + Action string `json:"action"` + BizType string `json:"biz_type"` + BizID *uint64 `json:"biz_id"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + Detail datatypes.JSON `json:"detail"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/internal/modules/adminaudit/handler.go b/backend/internal/modules/adminaudit/handler.go new file mode 100644 index 0000000..ecba3a5 --- /dev/null +++ b/backend/internal/modules/adminaudit/handler.go @@ -0,0 +1,64 @@ +package adminaudit + +import ( + "errors" + "net/http" + "strconv" + + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) List(c *gin.Context) { + query, ok := parseQuery(c) + if !ok { + return + } + items, err := h.service.List(query) + if err != nil { + writeAuditError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + +func parseQuery(c *gin.Context) (Query, bool) { + var query Query + if raw := c.Query("actor_id"); raw != "" { + value, err := strconv.ParseUint(raw, 10, 64) + if err != nil || value == 0 { + response.BadRequest(c, "管理员 ID 不正确") + return query, false + } + query.ActorID = value + } + if raw := c.Query("limit"); raw != "" { + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + response.BadRequest(c, "查询条数不正确") + return query, false + } + query.Limit = value + } + query.Action = c.Query("action") + query.BizType = c.Query("biz_type") + return query, true +} + +func writeAuditError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + default: + response.Error(c, http.StatusInternalServerError, "internal_error", "审计日志服务暂时不可用") + } +} diff --git a/backend/internal/modules/adminaudit/repository.go b/backend/internal/modules/adminaudit/repository.go new file mode 100644 index 0000000..9ff8c11 --- /dev/null +++ b/backend/internal/modules/adminaudit/repository.go @@ -0,0 +1,81 @@ +package adminaudit + +import ( + "time" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) List(query Query) ([]LogDTO, error) { + limit := query.Limit + if limit <= 0 || limit > 500 { + limit = 200 + } + + db := r.db.Table("audit_logs AS al"). + Select(`al.id, al.actor_type, al.actor_id, COALESCE(au.username, '') AS actor_username, + COALESCE(au.nickname, '') AS actor_nickname, al.action, al.biz_type, al.biz_id, + al.ip, al.user_agent, al.detail, al.created_at`). + Joins("LEFT JOIN admin_users AS au ON au.id = al.actor_id AND al.actor_type = ?", "admin") + + if query.ActorID > 0 { + db = db.Where("al.actor_id = ?", query.ActorID) + } + if query.Action != "" { + db = db.Where("al.action = ?", query.Action) + } + if query.BizType != "" { + db = db.Where("al.biz_type = ?", query.BizType) + } + + var rows []auditLogRow + if err := db.Order("al.id DESC").Limit(limit).Scan(&rows).Error; err != nil { + return nil, err + } + items := make([]LogDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO()) + } + return items, nil +} + +type auditLogRow struct { + ID uint64 + ActorType string + ActorID uint64 + ActorUsername string + ActorNickname string + Action string + BizType string + BizID *uint64 + IP string + UserAgent string + Detail datatypes.JSON + CreatedAt time.Time +} + +func (row auditLogRow) toDTO() LogDTO { + return LogDTO{ + ID: row.ID, + ActorType: row.ActorType, + ActorID: row.ActorID, + ActorUsername: row.ActorUsername, + ActorNickname: row.ActorNickname, + Action: row.Action, + BizType: row.BizType, + BizID: row.BizID, + IP: row.IP, + UserAgent: row.UserAgent, + Detail: row.Detail, + CreatedAt: row.CreatedAt, + } +} diff --git a/backend/internal/modules/adminaudit/service.go b/backend/internal/modules/adminaudit/service.go new file mode 100644 index 0000000..2f51af4 --- /dev/null +++ b/backend/internal/modules/adminaudit/service.go @@ -0,0 +1,20 @@ +package adminaudit + +import "errors" + +var ErrDependencyUnavailable = errors.New("dependency unavailable") + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) List(query Query) ([]LogDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.List(query) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index cb3c3f9..6fc3b7e 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -4,6 +4,7 @@ import ( "hfb_sys/backend/internal/config" "hfb_sys/backend/internal/handler" "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/internal/modules/adminaudit" "hfb_sys/backend/internal/modules/adminauth" "hfb_sys/backend/internal/modules/admindashboard" "hfb_sys/backend/internal/modules/adminuser" @@ -58,6 +59,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } adminUserService := adminuser.NewService(adminUserRepo) adminUserHandler := adminuser.NewHandler(adminUserService) + var adminAuditRepo *adminaudit.Repository + if deps.DB != nil { + adminAuditRepo = adminaudit.NewRepository(deps.DB) + } + adminAuditService := adminaudit.NewService(adminAuditRepo) + adminAuditHandler := adminaudit.NewHandler(adminAuditService) userHandler := user.NewHandler(userRepo) var realnameRepo *realname.Repository if deps.DB != nil { @@ -198,6 +205,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/wallet/ledger", walletHandler.AdminLedger) adminRoutes.GET("/system-configs", systemConfigHandler.List) adminRoutes.PUT("/system-configs/:key", systemConfigHandler.Update) + adminRoutes.GET("/audit-logs", adminAuditHandler.List) } } diff --git a/docs/api.md b/docs/api.md index 07f6f6e..6c61ba4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -65,5 +65,6 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `GET /api/admin/wallet/ledger` - `GET /api/admin/system-configs` - `PUT /api/admin/system-configs/{key}` +- `GET /api/admin/audit-logs` -说明:`GET /api/admin/wallet/ledger` 支持按 `user_id`、`order_id`、`biz_type` 和 `limit` 查询最近资金流水。`/api/admin/*` 当前已使用独立后台登录,后续接入 RBAC 和 Casbin 权限后再按角色收紧访问控制。 +说明:`GET /api/admin/wallet/ledger` 支持按 `user_id`、`order_id`、`biz_type` 和 `limit` 查询最近资金流水。`GET /api/admin/audit-logs` 支持按 `actor_id`、`action`、`biz_type` 和 `limit` 查询最近审计日志。`/api/admin/*` 当前已使用独立后台登录,后续接入 RBAC 和 Casbin 权限后再按角色收紧访问控制。 diff --git a/docs/business-rules.md b/docs/business-rules.md index e790f55..49ff211 100644 --- a/docs/business-rules.md +++ b/docs/business-rules.md @@ -128,3 +128,10 @@ - 后台可查看全量订单、租客、号主、订单状态、交接状态、结算状态、租金和押金。 - 订单详情页 `/admin/orders/:id` 展示订单状态、双方用户、租期、交接记录和账号资产快照。 - 当前后台订单管理先做只读能力,后续再补后台关闭订单、标记异常和客服介入操作。 + +## 开发态审计日志 + +- 审计日志接口为 `/api/admin/audit-logs`,前端页面为 `/admin/audit-logs`。 +- 当前可查看管理员、操作动作、业务类型、业务 ID、IP、User-Agent、操作明细和创建时间。 +- 当前已写入审计日志的动作包括系统配置创建/更新、用户冻结和用户解冻。 +- 审计日志只做追加和只读查询,不提供后台删除或修改入口。 diff --git a/frontend/src/api/adminAudit.ts b/frontend/src/api/adminAudit.ts new file mode 100644 index 0000000..afd0551 --- /dev/null +++ b/frontend/src/api/adminAudit.ts @@ -0,0 +1,35 @@ +import { apiClient } from './client' + +export interface AdminAuditLog { + id: number + actor_type: string + actor_id: number + actor_username: string + actor_nickname: string + action: string + biz_type: string + biz_id?: number + ip: string + user_agent: string + detail?: Record | null + created_at: string +} + +export interface AdminAuditQuery { + actor_id?: string + action?: string + biz_type?: string + limit?: number +} + +interface ApiResponse { + code: string + message: string + data: T +} + +export async function fetchAdminAuditLogs(query: AdminAuditQuery = {}) { + const params = Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)) + const { data } = await apiClient.get>('/admin/audit-logs', { params }) + return data.data.items +} diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 5b257c1..9b594db 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -1,5 +1,5 @@ + +