Files
panel/internal/data/log.go
T
Copilot f2e41a3364 feat: 添加操作日志记录功能和前端日志查看页面 (#1227)
* Initial plan

* feat: 添加操作日志记录功能和前端日志查看页面

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: 修复 HttpLog 中 IP 字符串处理的类型安全问题

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

* feat: 完善面板内日志记录,添加网站/数据库/项目/计划任务/备份CRUD日志

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* feat: 完善更多模块的日志记录(证书/SSH/Webhook/用户/设置/安全)

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: 改进cert_dns删除方法,在删除前获取实体信息用于日志记录

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

* feat: 从session获取操作员ID并在前端显示用户名

Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>

* fix: lint

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com>
Co-authored-by: 耗子 <haozi@loli.email>
2026-01-12 23:31:22 +08:00

180 lines
3.6 KiB
Go

package data
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
"gorm.io/gorm"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/biz"
)
type logRepo struct {
db *gorm.DB
}
func NewLogRepo(db *gorm.DB) biz.LogRepo {
return &logRepo{
db: db,
}
}
// List 获取日志列表
func (r *logRepo) List(logType string, limit int) ([]biz.LogEntry, error) {
var filename string
switch logType {
case biz.LogTypeApp:
filename = "app.log"
case biz.LogTypeDB:
filename = "db.log"
case biz.LogTypeHTTP:
filename = "http.log"
default:
filename = "app.log"
}
logPath := filepath.Join(app.Root, "panel/storage/logs", filename)
file, err := os.Open(logPath)
if err != nil {
if os.IsNotExist(err) {
return []biz.LogEntry{}, nil
}
return nil, err
}
defer func(file *os.File) { _ = file.Close() }(file)
// 读取所有行
var lines []string
scanner := bufio.NewScanner(file)
// 增加缓冲区大小以处理较长的日志行
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) != "" {
lines = append(lines, line)
}
}
if err = scanner.Err(); err != nil {
return nil, err
}
// 从末尾取指定数量的行
start := 0
if len(lines) > limit {
start = len(lines) - limit
}
lines = lines[start:]
// 倒序处理,最新的在前面
entries := make([]biz.LogEntry, 0, len(lines))
for i := len(lines) - 1; i >= 0; i-- {
entry, err := r.parseLine(lines[i], logType)
if err != nil {
continue
}
entries = append(entries, entry)
}
// 如果是app日志,查询用户名
if logType == biz.LogTypeApp {
r.fillOperatorNames(entries)
}
return entries, nil
}
// fillOperatorNames 填充操作员用户名
func (r *logRepo) fillOperatorNames(entries []biz.LogEntry) {
// 收集所有用户ID
userIDs := make(map[uint]bool)
for _, entry := range entries {
if entry.OperatorID > 0 {
userIDs[entry.OperatorID] = true
}
}
if len(userIDs) == 0 {
return
}
// 批量查询用户名
ids := make([]uint, 0, len(userIDs))
for id := range userIDs {
ids = append(ids, id)
}
var users []biz.User
r.db.Select("id", "username").Where("id IN ?", ids).Find(&users)
// 构建ID到用户名的映射
userMap := make(map[uint]string)
for _, user := range users {
userMap[user.ID] = user.Username
}
// 填充用户名
for i := range entries {
if entries[i].OperatorID > 0 {
if username, ok := userMap[entries[i].OperatorID]; ok {
entries[i].OperatorName = username
}
}
}
}
// parseLine 解析日志行
func (r *logRepo) parseLine(line string, logType string) (biz.LogEntry, error) {
var rawEntry map[string]any
if err := json.Unmarshal([]byte(line), &rawEntry); err != nil {
return biz.LogEntry{}, err
}
entry := biz.LogEntry{
Extra: make(map[string]any),
}
// 解析通用字段
if t, ok := rawEntry["time"].(string); ok {
if parsed, err := time.Parse(time.RFC3339Nano, t); err == nil {
entry.Time = parsed
}
}
if level, ok := rawEntry["level"].(string); ok {
entry.Level = level
}
if msg, ok := rawEntry["msg"].(string); ok {
entry.Msg = msg
}
// 解析操作日志特有字段
if logType == biz.LogTypeApp {
if t, ok := rawEntry["type"].(string); ok {
entry.Type = t
}
if opID, ok := rawEntry["operator_id"].(float64); ok {
entry.OperatorID = uint(opID)
}
}
// 其他字段放入Extra
excludeKeys := map[string]bool{
"time": true, "level": true, "msg": true, "type": true, "operator_id": true,
}
for k, v := range rawEntry {
if !excludeKeys[k] {
entry.Extra[k] = v
}
}
return entry, nil
}