From 9010592c9e73529382935cf099d0a2a9ace1bf11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=8F=E7=8A=80=E9=BA=9F?= Date: Mon, 14 Sep 2026 02:34:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(build):=20=E4=BF=AE=E6=AD=A3=20.gitignore?= =?UTF-8?q?=20=E7=9A=84=20log/=20=E8=A7=84=E5=88=99=E8=AF=AF=E4=BC=A4?= =?UTF-8?q?=E6=BA=90=E7=A0=81=E5=8C=85=EF=BC=8C=E6=81=A2=E5=A4=8D=20HEAD?= =?UTF-8?q?=20=E5=8F=AF=E7=BC=96=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:.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 完整可编译。 --- .gitignore | 5 +- api/admin/base/log/log.go | 40 +++++ api/admin/system/log/log.go | 51 ++++++ internal/service/admin/base/log/log.go | 129 +++++++++++++++ internal/service/admin/system/log/log.go | 192 +++++++++++++++++++++++ 5 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 api/admin/base/log/log.go create mode 100644 api/admin/system/log/log.go create mode 100644 internal/service/admin/base/log/log.go create mode 100644 internal/service/admin/system/log/log.go diff --git a/.gitignore b/.gitignore index f6a8cac..60e58e6 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,10 @@ bin .workbuddy/memory/* !.workbuddy/memory/CHANGELOG.md # server runtime logs -log/ +# 注意必须锚定到仓库根目录:写成 `log/` 会匹配任意层级的 log 目录, +# 把 api/admin/**/log、internal/service/admin/**/log 这些**源码包**一并忽略, +# 导致它们被静默漏提交(引用方 controller 已入库 → HEAD 编译不过)。 +/log/ # 本地环境变量(含数据库口令),禁止提交 .env* diff --git a/api/admin/base/log/log.go b/api/admin/base/log/log.go new file mode 100644 index 0000000..aff8c34 --- /dev/null +++ b/api/admin/base/log/log.go @@ -0,0 +1,40 @@ +// Package admin_base_log 定义服务器日志监控接口契约。 +// +// 路由前缀 /api/service/admin/base/log,对应 admin_menu 中的: +// - POST /api/service/admin/base/log/files —— monitor:log:view(查看日志文件列表) +// - POST /api/service/admin/base/log/tail —— monitor:log:tail(实时读取日志尾部) +package admin_base_log + +import "github.com/gogf/gf/v2/frame/g" + +// LogFile 描述一个服务器日志文件。 +type LogFile struct { + Name string `json:"name"` // 文件名 + Path string `json:"path"` // 文件绝对路径 + Size int64 `json:"size"` // 文件大小(字节) + ModTime string `json:"modTime"` // 最后修改时间 +} + +// LogFilesReq 列出服务器日志文件。 +type LogFilesReq struct { + g.Meta `path:"/base/log/files" method:"post" tags:"Admin/Base/Log" summary:"日志文件列表"` +} + +// LogFilesRes 是 LogFilesReq 的响应。 +type LogFilesRes struct { + Dir string `json:"dir"` // 日志目录 + Files []*LogFile `json:"files"` // 文件列表 +} + +// LogTailReq 读取日志文件尾部,支持关键字过滤。 +type LogTailReq struct { + g.Meta `path:"/base/log/tail" method:"post" tags:"Admin/Base/Log" summary:"读取日志尾部"` + File string `json:"file" v:"required#日志文件名不能为空"` // 日志文件名(不含目录) + Lines int `json:"lines" d:"200" v:"min:1|max:5000#行数必须在 1-5000 之间"` + Keyword string `json:"keyword"` // 关键字过滤,为空则不过滤 +} + +// LogTailRes 是 LogTailReq 的响应。 +type LogTailRes struct { + Lines []string `json:"lines"` // 日志行 +} diff --git a/api/admin/system/log/log.go b/api/admin/system/log/log.go new file mode 100644 index 0000000..57a9d0c --- /dev/null +++ b/api/admin/system/log/log.go @@ -0,0 +1,51 @@ +// Package admin_system_log 定义管理员操作日志(操作审计)查询接口契约。 +// +// 路由前缀 /api/service/admin/system/log,对应 admin_menu: +// - POST /api/service/admin/system/log —— system:log:list(操作日志分页查询) +package admin_system_log + +import "github.com/gogf/gf/v2/frame/g" + +// LogItem 一条管理员操作日志记录。 +type LogItem struct { + Id uint64 `json:"id"` + AdminID uint64 `json:"adminId"` // 操作人ID + AdminUsername string `json:"adminUsername"` // 操作人账号(冗余) + Permission string `json:"permission"` // 命中的权限码 + Method string `json:"method"` // HTTP 方法 + Path string `json:"path"` // 请求路径 + IP string `json:"ip"` // 来源 IP + IpLocation string `json:"ipLocation"` // IP 归属地 + Param string `json:"param"` // 请求参数 + DurationMS uint `json:"durationMs"` // 耗时(毫秒) + StatusCode int `json:"statusCode"` // HTTP 状态码 + ErrorMessage string `json:"errorMessage"` // 失败原因 + UserAgent string `json:"userAgent"` // 浏览器 UA + CreatedAt string `json:"createdAt"` // 操作时间 +} + +// LogListReq 分页查询管理员操作日志。 +type LogListReq struct { + g.Meta `path:"/system/log" method:"post" tags:"Admin/System/Log" summary:"操作日志列表"` + Page int `json:"page" d:"1" v:"min:1"` + Size int `json:"size" d:"10" v:"min:1|max:100"` + AdminID uint64 `json:"adminId"` // 按管理员ID精确过滤 + Username string `json:"username"` // 按管理员账号模糊过滤 + IP string `json:"ip"` // 按来源 IP 模糊过滤 + IpLocation string `json:"ipLocation"` // 按 IP 归属地模糊过滤 + Method string `json:"method"` // 按 HTTP 方法精确过滤 + Status int `json:"status"` // 结果筛选:0 全部 / 1 成功 / 2 失败 + Keyword string `json:"keyword"` // 关键字:匹配 permission / path / ip + StartTime string `json:"startTime"` // 起始时间,格式 2006-01-02 15:04:05 + EndTime string `json:"endTime"` // 结束时间,格式 2006-01-02 15:04:05 + MinDuration int `json:"minDuration"` // 耗时下限(ms),0 不限 + MaxDuration int `json:"maxDuration"` // 耗时上限(ms),0 不限 + OrderBy string `json:"orderBy"` // 排序字段:createdAt(默认) | durationMs + OrderDir string `json:"orderDir"` // 排序方向:desc(默认) | asc +} + +// LogListRes 是 LogListReq 的响应。 +type LogListRes struct { + List []*LogItem `json:"list"` + Total int `json:"total"` +} diff --git a/internal/service/admin/base/log/log.go b/internal/service/admin/base/log/log.go new file mode 100644 index 0000000..40171d3 --- /dev/null +++ b/internal/service/admin/base/log/log.go @@ -0,0 +1,129 @@ +// Package admin_base_log 提供服务器日志文件管理服务:列出日志目录下的文件、读取文件尾部。 +// +// 日志目录取自配置项 logger.path(默认 "log",相对项目根目录)。 +// 安全约束:只允许访问日志目录内的文件,文件名做路径穿越校验,禁止读取目录外内容。 +package admin_base_log + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + + "service.xpcool.com/internal/model/dto" +) + +// ILogManage 服务器日志管理服务接口。 +type ILogManage interface { + // Files 列出日志目录下的全部日志文件。 + Files(context.Context) (dir string, files []*dto.LogFile, err error) + // Tail 读取指定日志文件的尾部若干行,可按关键字过滤。 + Tail(ctx context.Context, file string, lines int, keyword string) ([]string, error) +} + +var localLogManage ILogManage + +// LogManage 获取服务器日志管理服务实例。 +func LogManage() ILogManage { + if localLogManage == nil { + panic("LogManage 实现未注册") + } + return localLogManage +} + +// RegisterLogManage 注册服务器日志管理服务实现。 +func RegisterLogManage(i ILogManage) { localLogManage = i } + +// NewLogManage 创建服务器日志管理服务实例。 +func NewLogManage() ILogManage { return &logManage{} } + +type logManage struct{} + +// logDir 返回日志目录的绝对路径。 +func (s *logManage) logDir(ctx context.Context) string { + dir := g.Cfg().MustGet(ctx, "logger.path", "log").String() + if dir == "" { + dir = "log" + } + if abs, err := filepath.Abs(dir); err == nil { + return abs + } + return dir +} + +// Files 列出日志目录下的全部日志文件(不递归子目录)。 +func (s *logManage) Files(ctx context.Context) (string, []*dto.LogFile, error) { + dir := s.logDir(ctx) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + // 日志目录尚未创建属正常情况(服务还没写日志),返回空列表而非报错。 + return dir, []*dto.LogFile{}, nil + } + return "", nil, gerror.Wrap(err, "读取日志目录失败") + } + files := make([]*dto.LogFile, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + files = append(files, &dto.LogFile{ + Name: e.Name(), + Path: filepath.Join(dir, e.Name()), + Size: info.Size(), + ModTime: info.ModTime().Format("2006-01-02 15:04:05"), + }) + } + return dir, files, nil +} + +// Tail 读取指定日志文件的尾部 lines 行;keyword 非空时只返回包含该关键字的行。 +func (s *logManage) Tail(ctx context.Context, file string, lines int, keyword string) ([]string, error) { + if lines <= 0 { + lines = 200 + } + dir := s.logDir(ctx) + // 路径穿越防护:文件名不允许包含路径分隔符与上跳符。 + if file == "" || strings.ContainsAny(file, `/\`) || strings.Contains(file, "..") { + return nil, gerror.New("非法的日志文件名") + } + full := filepath.Join(dir, file) + + data, err := os.ReadFile(full) + if err != nil { + if os.IsNotExist(err) { + return nil, gerror.New("日志文件不存在") + } + return nil, gerror.Wrap(err, "读取日志文件失败") + } + + all := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") + // 末尾通常是空行,去掉以免占用一行额度。 + if n := len(all); n > 0 && all[n-1] == "" { + all = all[:n-1] + } + + // 关键字过滤。 + if keyword != "" { + filtered := make([]string, 0, len(all)) + for _, l := range all { + if strings.Contains(l, keyword) { + filtered = append(filtered, l) + } + } + all = filtered + } + + // 只取尾部 lines 行。 + if len(all) > lines { + all = all[len(all)-lines:] + } + return all, nil +} diff --git a/internal/service/admin/system/log/log.go b/internal/service/admin/system/log/log.go new file mode 100644 index 0000000..f33ebd5 --- /dev/null +++ b/internal/service/admin/system/log/log.go @@ -0,0 +1,192 @@ +// 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] + "..." +}