// Package admin_system_log 提供管理员操作日志(操作审计)服务:写(Record,中间件调用落库)与读(List,分页查询)。 package admin_system_log import ( "context" "strings" "github.com/gogf/gf/v2/errors/gerror" "service.xpcool.com/internal/dao" "service.xpcool.com/internal/model/do" "service.xpcool.com/internal/model/dto" "service.xpcool.com/internal/model/entity" ) // AuditEvent 一次管理员操作的审计事件,由 AdminAuth 中间件在请求结束后回调传入。 type AuditEvent struct { AdminID uint64 Permission string Method string Path string IP string IPLocation string Param string DurationMS int StatusCode int ErrorMessage string UserAgent string } // IAdminAudit 操作审计服务接口:审计写入 + 分页查询。 type IAdminAudit interface { Record(context.Context, AuditEvent) error List(context.Context, dto.LogQuery) ([]*dto.LogItem, int, error) } var localAdminAudit IAdminAudit // AdminAudit 获取操作审计服务实例。 func AdminAudit() IAdminAudit { if localAdminAudit == nil { panic("AdminAudit 实现未注册") } return localAdminAudit } // RegisterAdminAudit 注册操作审计服务实现。 func RegisterAdminAudit(i IAdminAudit) { localAdminAudit = i } // NewAdminAudit 创建操作审计服务实例。 func NewAdminAudit() IAdminAudit { return &adminAudit{} } type adminAudit struct{} // Record 写入一条操作审计记录。 // 说明:审计失败不应影响业务请求,调用方(中间件)已忽略此处返回的 error,仅打日志。 func (s *adminAudit) Record(ctx context.Context, e AuditEvent) error { // 参数与 UA 可能很长,按表结构上限截断(request_param/user_agent VARCHAR(512),error_message VARCHAR(512))。 param := truncate(e.Param, 512) ua := truncate(e.UserAgent, 512) errMsg := truncate(e.ErrorMessage, 512) ipLoc := truncate(e.IPLocation, 255) _, err := dao.AdminOperationLog.Ctx(ctx).Data(do.AdminOperationLog{ AdminUserId: e.AdminID, Permission: e.Permission, Method: e.Method, Path: e.Path, Ip: e.IP, IpLocation: ipLoc, RequestParam: param, DurationMs: uint(e.DurationMS), StatusCode: e.StatusCode, ErrorMessage: errMsg, UserAgent: ua, }).Insert() return gerror.Wrap(err, "写入操作日志失败") } // List 分页查询操作日志,支持账号/IP/方法/结果/耗时/关键字/时间范围过滤与排序。 func (s *adminAudit) List(ctx context.Context, q dto.LogQuery) ([]*dto.LogItem, int, error) { m := dao.AdminOperationLog.Ctx(ctx) // 过滤条件。 if q.AdminID > 0 { m = m.Where(dao.AdminOperationLog.Columns().AdminUserId, q.AdminID) } if q.Username != "" { m = m.WhereLike("admin_username", "%"+q.Username+"%") } if q.IP != "" { m = m.WhereLike(dao.AdminOperationLog.Columns().Ip, "%"+q.IP+"%") } if q.IpLocation != "" { m = m.WhereLike("ip_location", "%"+q.IpLocation+"%") } if q.Method != "" { m = m.Where(dao.AdminOperationLog.Columns().Method, strings.ToUpper(q.Method)) } // 结果筛选:1 成功(2xx) / 2 失败(非 2xx)。 switch q.Status { case 1: m = m.Where("status_code >= ? AND status_code < ?", 200, 300) case 2: m = m.Where("status_code < ? OR status_code >= ?", 200, 300) } if q.Keyword != "" { like := "%" + q.Keyword + "%" m = m.Where( "(permission LIKE ? OR path LIKE ? OR ip LIKE ?)", like, like, like, ) } if q.StartTime != "" { m = m.WhereGTE(dao.AdminOperationLog.Columns().CreatedAt, q.StartTime) } if q.EndTime != "" { m = m.WhereLTE(dao.AdminOperationLog.Columns().CreatedAt, q.EndTime) } if q.MinDuration > 0 { m = m.WhereGTE(dao.AdminOperationLog.Columns().DurationMs, q.MinDuration) } if q.MaxDuration > 0 { m = m.WhereLTE(dao.AdminOperationLog.Columns().DurationMs, q.MaxDuration) } total, err := m.Count() if err != nil { return nil, 0, gerror.Wrap(err, "统计操作日志总数失败") } if total == 0 { return nil, 0, nil } // 排序:白名单字段,避免 SQL 注入。 switch q.OrderBy { case "durationMs": if strings.EqualFold(q.OrderDir, "asc") { m = m.OrderAsc(dao.AdminOperationLog.Columns().DurationMs) } else { m = m.OrderDesc(dao.AdminOperationLog.Columns().DurationMs) } default: if strings.EqualFold(q.OrderDir, "asc") { m = m.OrderAsc(dao.AdminOperationLog.Columns().CreatedAt) } else { m = m.OrderDesc(dao.AdminOperationLog.Columns().CreatedAt) } } var list []entity.AdminOperationLog if err = m.Page(q.Page, q.Size).Scan(&list); err != nil { return nil, 0, gerror.Wrap(err, "查询操作日志列表失败") } items := make([]*dto.LogItem, 0, len(list)) for i := range list { l := &list[i] createdAt := "" if l.CreatedAt != nil { createdAt = l.CreatedAt.Layout("2006-01-02 15:04:05") } items = append(items, &dto.LogItem{ Id: l.Id, AdminID: l.AdminUserId, AdminUsername: l.AdminUsername, Permission: l.Permission, Method: l.Method, Path: l.Path, IP: l.Ip, IpLocation: l.IpLocation, Param: l.RequestParam, DurationMS: l.DurationMs, StatusCode: l.StatusCode, ErrorMessage: l.ErrorMessage, UserAgent: l.UserAgent, CreatedAt: createdAt, }) } return items, total, nil } // truncate 按最大字符数截断字符串,超长时保留前 n 个字符并追加省略标记。 func truncate(s string, n int) string { if len(s) <= n { return s } if n <= 3 { return s[:n] } return s[:n-3] + "..." }