问题:.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 完整可编译。
130 lines
3.7 KiB
Go
130 lines
3.7 KiB
Go
// 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
|
||
}
|