package service import ( "bytes" "context" "os" "path/filepath" "sort" "strings" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gfile" "service.xpcool.com/internal/consts" "service.xpcool.com/internal/library/response" "service.xpcool.com/internal/model/dto" ) // ILogManage reads server log files for the monitoring page. type ILogManage interface { Files(context.Context) (string, []*dto.LogFile, error) Tail(context.Context, string, int, string) ([]string, error) } type logManage struct{} func NewLogManage() ILogManage { return &logManage{} } var localLogManage ILogManage func LogManage() ILogManage { if localLogManage == nil { panic("LogManage implementation not registered") } return localLogManage } func RegisterLogManage(i ILogManage) { localLogManage = i } // logDir returns the configured log directory (config logger.path), default "log". func logDir(ctx context.Context) string { dir := g.Cfg().MustGet(ctx, "logger.path").String() if dir == "" { dir = "log" } return dir } // resolvePath 校验 file 为日志目录内文件,防止路径穿越。 func resolvePath(ctx context.Context, file string) (string, error) { dir := logDir(ctx) absDir, err := filepath.Abs(dir) if err != nil { return "", gerror.Wrap(err, "resolve log dir") } full := filepath.Join(absDir, filepath.Clean(file)) if !strings.HasPrefix(full, absDir+string(os.PathSeparator)) && full != absDir { return "", response.Error(consts.CodeInvalidParam, "invalid log file path") } if !gfile.Exists(full) || gfile.IsDir(full) { return "", response.Error(consts.CodeInvalidParam, "log file not found") } return full, nil } func (s *logManage) Files(ctx context.Context) (string, []*dto.LogFile, error) { dir := logDir(ctx) if !gfile.Exists(dir) { return dir, nil, nil } paths, err := gfile.ScanDirFile(dir, "*.log", true) if err != nil { return "", nil, gerror.Wrap(err, "scan log files") } files := make([]*dto.LogFile, 0, len(paths)) for _, p := range paths { info, err := os.Stat(p) if err != nil { continue } files = append(files, &dto.LogFile{ Name: info.Name(), Path: strings.TrimPrefix(filepath.ToSlash(p), filepath.ToSlash(dir)+"/"), Size: info.Size(), ModTime: info.ModTime().Format("2006-01-02 15:04:05"), }) } sort.Slice(files, func(i, j int) bool { return files[i].ModTime > files[j].ModTime }) return dir, files, nil } func (s *logManage) Tail(ctx context.Context, file string, lines int, keyword string) ([]string, error) { full, err := resolvePath(ctx, file) if err != nil { return nil, err } content, err := tailLines(full, lines) if err != nil { return nil, gerror.Wrap(err, "read log tail") } if keyword != "" { var filtered []string for _, l := range content { if strings.Contains(l, keyword) { filtered = append(filtered, l) } } return filtered, nil } return content, nil } // tailLines reads the last n lines of a file efficiently (reverse chunk scan). func tailLines(path string, n int) ([]string, error) { if n <= 0 { n = 200 } f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() info, err := f.Stat() if err != nil { return nil, err } size := info.Size() if size == 0 { return nil, nil } const chunkSize = 8 << 10 // 8KB var ( offset int64 = size buf []byte lines []string ) for offset > 0 && len(lines) < n { readSize := int64(chunkSize) if offset < readSize { readSize = offset } offset -= readSize chunk := make([]byte, readSize) if _, err = f.ReadAt(chunk, offset); err != nil { return nil, err } combined := append(chunk, buf...) buf = nil idx := len(combined) - 1 for idx >= 0 && len(lines) < n { j := bytes.LastIndexByte(combined[:idx+1], '\n') if j < 0 { buf = append([]byte(nil), combined[:idx+1]...) break } lines = append(lines, string(combined[j+1:idx+1])) idx = j - 1 } } if len(buf) > 0 && len(lines) < n { lines = append(lines, string(buf)) } for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 { lines[i], lines[j] = lines[j], lines[i] } return lines, nil }