service.xpcool.com/internal/service/admin/system/log/log.go
夏犀麟 9010592c9e fix(build): 修正 .gitignore 的 log/ 规则误伤源码包,恢复 HEAD 可编译
问题:.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 完整可编译。
2026-09-14 02:34:46 +08:00

193 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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] + "..."
}