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 完整可编译。
This commit is contained in:
parent
d136c23874
commit
9010592c9e
5
.gitignore
vendored
5
.gitignore
vendored
@ -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*
|
||||
|
||||
40
api/admin/base/log/log.go
Normal file
40
api/admin/base/log/log.go
Normal file
@ -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"` // 日志行
|
||||
}
|
||||
51
api/admin/system/log/log.go
Normal file
51
api/admin/system/log/log.go
Normal file
@ -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"`
|
||||
}
|
||||
129
internal/service/admin/base/log/log.go
Normal file
129
internal/service/admin/base/log/log.go
Normal file
@ -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
|
||||
}
|
||||
192
internal/service/admin/system/log/log.go
Normal file
192
internal/service/admin/system/log/log.go
Normal file
@ -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] + "..."
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user