问题:.gitignore 第 27 行的 `log/` 未锚定根目录,会匹配任意层级的 log 目录,把下面 4 个**源码包**一并忽略: - api/admin/base/log - api/admin/system/log - internal/service/admin/base/log - internal/service/admin/system/log 而引用它们的 internal/controller/admin/log.go 已在库中,于是 「已提交的代码 import 了不存在(未提交)的包」——干净检出后 `go build .` 直接失败: no required module provides package service.xpcool.com/api/admin/base/log 因为 service 仓库没有 CI 自动构建,这个问题一直没有暴露, 影响是:新克隆/新设备无法编译,任何干净检出构建都会失败。 修复: 1. 规则改为 `/log/`,只忽略仓库根目录下的运行日志输出目录 (config.dev.yaml 的 logger.path: "log"),不再误伤同名源码包; 2. 补交被忽略期间漏掉的 4 个 log 包,恢复 HEAD 完整可编译。
193 lines
5.5 KiB
Go
193 lines
5.5 KiB
Go
// 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] + "..."
|
||
}
|