service.xpcool.com/internal/service/log_manage.go
夏犀麟 8fdb25e6bd feat(admin): 后台登录/菜单/按钮级权限/RBAC管理与服务器日志监控
后端:
- 种子数据:超级管理员(admin/admin123)、super_admin 角色、22 条菜单(含按钮权限码)、角色绑定
- admin_menu 扩展 icon/component/hidden 字段(003_schema_ext.sql),手动补齐 entity/do/table
- auth 扩展:/auth/info、/auth/codes(仅登录),路由拆分公开/Profile/受保护三组
- 菜单路由:/menu/routes 返回 vben backend 动态路由树(按角色过滤、排序)
- RBAC 管理:/admins、/roles、/menus CRUD(含角色绑定、重置密码、菜单授权)
- 日志监控:/log/files、/log/tail(尾部读取+关键词过滤+路径穿越防护)
- 安全加固:接口鉴权改为「方法+路径→权限码」自动映射,杜绝任意权限码越权
- 修复:gf v2.10.2  不自动替换→cmd 注入;MySQL driver 需显式引入 contrib
- 全链路冒烟测试通过(含越权拒绝 30003)
2026-08-24 22:35:54 +08:00

168 lines
4.1 KiB
Go

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
}