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