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:
夏犀麟 2026-09-14 02:34:46 +08:00
parent d136c23874
commit 9010592c9e
5 changed files with 416 additions and 1 deletions

5
.gitignore vendored
View File

@ -24,7 +24,10 @@ bin
.workbuddy/memory/*
!.workbuddy/memory/CHANGELOG.md
# server runtime logs
log/
# 注意必须锚定到仓库根目录写成 `log/` 会匹配任意层级的 log 目录
# api/admin/**/loginternal/service/admin/**/log 这些**源码包**一并忽略
# 导致它们被静默漏提交引用方 controller 已入库 HEAD 编译不过
/log/
# 本地环境变量含数据库口令禁止提交
.env*

40
api/admin/base/log/log.go Normal file
View 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"` // 日志行
}

View 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"` // 耗时下限ms0 不限
MaxDuration int `json:"maxDuration"` // 耗时上限ms0 不限
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"`
}

View 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
}

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