feat(log): 操作、数据库、HTTP 日志支持按日期清理

- 选择日期后清理该日期及之前的日志,已归档的删除,正在写入的当前文件只清空
- 清理后在操作日志中记录一条,清空操作日志也能留下痕迹
- 修复日期下拉错位一天:零点轮转的归档时间戳是次日零点,改为按文件内日志的实际日期归类
- 同一天因大小轮转拆成多个文件时合并查看,今天也包含当天已轮转的部分
- 日期下拉默认显示今天,不再显示请选择

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
耗子
2026-09-24 19:46:35 +08:00
co-authored by Claude Opus 5.5
parent d417f6f932
commit 645afeecbf
12 changed files with 312 additions and 113 deletions
+1 -1
View File
@@ -372,7 +372,7 @@ func initAce() (*app.Ace, func() error, error) {
wireValue110_0 := data.NewLogRepo(wireValue4_0)
wireValue71_0 := biz.NewLogUsecase(wireValue110_0)
wireValue71_0 := biz.NewLogUsecase(wireValue110_0, wireValue3_0)
wireValue161_0 := service.NewLogService(wireValue71_0, wireValue1_0)
+18 -2
View File
@@ -1,6 +1,8 @@
package biz
import (
"context"
"log/slog"
"time"
)
@@ -51,14 +53,17 @@ type LogRepo interface {
List(logType string, limit int, date string) ([]LogEntry, error)
// ListDates 获取可用的日志日期列表
ListDates(logType string) ([]string, error)
// Clean 清理指定日期及之前的日志
Clean(logType string, date string) error
}
type LogUsecase struct {
repo LogRepo
log *slog.Logger
}
func NewLogUsecase(repo LogRepo) *LogUsecase {
return &LogUsecase{repo: repo}
func NewLogUsecase(repo LogRepo, log *slog.Logger) *LogUsecase {
return &LogUsecase{repo: repo, log: log}
}
func (uc *LogUsecase) List(logType string, limit int, date string) ([]LogEntry, error) {
@@ -68,3 +73,14 @@ func (uc *LogUsecase) List(logType string, limit int, date string) ([]LogEntry,
func (uc *LogUsecase) ListDates(logType string) ([]string, error) {
return uc.repo.ListDates(logType)
}
func (uc *LogUsecase) Clean(ctx context.Context, logType, date string) error {
if err := uc.repo.Clean(logType, date); err != nil {
return err
}
// 记录日志,操作日志被清空后留下这一条作为痕迹
uc.log.Info("logs cleaned", slog.String("type", OperationTypePanel), slog.Uint64("operator_id", operatorID(ctx)), slog.String("log_type", logType), slog.String("date", date))
return nil
}
+113 -101
View File
@@ -17,11 +17,15 @@ import (
"github.com/acepanel/panel/v3/internal/biz"
)
var (
logArchivePatternApp = regexp.MustCompile(`^app-(\d{4}-\d{2}-\d{2})T.*\.log$`)
logArchivePatternDB = regexp.MustCompile(`^db-(\d{4}-\d{2}-\d{2})T.*\.log$`)
logArchivePatternHTTP = regexp.MustCompile(`^http-(\d{4}-\d{2}-\d{2})T.*\.log$`)
)
// logArchivePattern 轮转归档的文件名,时间戳是该文件内日志的结束时刻
var logArchivePattern = regexp.MustCompile(`^(app|db|http)-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3})(?:\.\d+)?\.log$`)
// logFile 日志文件及其内日志所属的日期
type logFile struct {
path string
date string
current bool
}
type logRepo struct {
db *gorm.DB
@@ -36,75 +40,27 @@ func NewLogRepo(db *gorm.DB) biz.LogRepo {
// List 获取日志列表
// date 格式为 YYYY-MM-DD,空字符串表示当天日志
func (r *logRepo) List(logType string, limit int, date string) ([]biz.LogEntry, error) {
logDir := filepath.Join(app.Root, "panel/storage/logs")
var logPath string
if date == "" {
// 无日期参数,读取当前日志文件
logPath = filepath.Join(logDir, logType+".log")
} else {
// 有日期参数,查找对应的归档日志文件
pattern := getLogArchivePattern(logType)
date = time.Now().Format(time.DateOnly)
}
files, err := r.files(logType)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(logDir)
// 一天可能因为大小轮转分成多个文件,从最新的往前读,凑够 limit 行为止
var lines []string
for i := len(files) - 1; i >= 0 && len(lines) < limit; i-- {
if files[i].date != date {
continue
}
fileLines, err := readLines(files[i].path)
if err != nil {
if os.IsNotExist(err) {
return []biz.LogEntry{}, nil
}
return nil, err
}
found := false
for _, entry := range entries {
if entry.IsDir() {
continue
}
matches := pattern.FindStringSubmatch(entry.Name())
if len(matches) == 2 && matches[1] == date {
logPath = filepath.Join(logDir, entry.Name())
found = true
break
}
}
if !found {
return []biz.LogEntry{}, nil
}
lines = append(fileLines, lines...)
}
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:]
lines = lines[max(0, len(lines)-limit):]
// 倒序处理,最新的在前面
entries := make([]biz.LogEntry, 0, len(lines))
@@ -124,30 +80,17 @@ func (r *logRepo) List(logType string, limit int, date string) ([]biz.LogEntry,
return entries, nil
}
// ListDates 获取可用的日志日期列表
// ListDates 获取可用的日志日期列表,当天由 List 的空日期覆盖,不在其中
func (r *logRepo) ListDates(logType string) ([]string, error) {
logDir := filepath.Join(app.Root, "panel/storage/logs")
entries, err := os.ReadDir(logDir)
files, err := r.files(logType)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, err
}
pattern := getLogArchivePattern(logType)
dates := make([]string, 0)
for _, entry := range entries {
if entry.IsDir() {
continue
}
matches := pattern.FindStringSubmatch(entry.Name())
if len(matches) == 2 {
dates = append(dates, matches[1])
}
}
today := time.Now().Format(time.DateOnly)
dates := lo.Uniq(lo.FilterMap(files, func(file logFile, _ int) (string, bool) {
return file.date, file.date != today
}))
// 按日期倒序排列,最新的在前面
sort.Sort(sort.Reverse(sort.StringSlice(dates)))
@@ -155,6 +98,89 @@ func (r *logRepo) ListDates(logType string) ([]string, error) {
return dates, nil
}
// Clean 清理指定日期及之前的日志,当前文件还在写入,只能清空
func (r *logRepo) Clean(logType string, date string) error {
files, err := r.files(logType)
if err != nil {
return err
}
for _, file := range files {
if file.date > date {
continue
}
if file.current {
err = os.Truncate(file.path, 0)
} else {
err = os.Remove(file.path)
}
if err != nil {
return err
}
}
return nil
}
// files 按时间先后列出某类日志的归档与当前文件
func (r *logRepo) files(logType string) ([]logFile, error) {
dir := filepath.Join(app.Root, "panel/storage/logs")
entries, err := os.ReadDir(dir)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
files := make([]logFile, 0, len(entries))
for _, entry := range entries {
matches := logArchivePattern.FindStringSubmatch(entry.Name())
if matches == nil || matches[1] != logType {
continue
}
end, err := time.ParseInLocation("2006-01-02T15-04-05.000", matches[2], time.Local)
if err != nil {
continue
}
// 零点轮转的归档时间戳是次日零点,往前退一点才是日志所属的日期
files = append(files, logFile{
path: filepath.Join(dir, entry.Name()),
date: end.Add(-time.Nanosecond).Format(time.DateOnly),
})
}
// 当前文件名里没有时间戳,按最后写入时间算
current := filepath.Join(dir, logType+".log")
if info, err := os.Stat(current); err == nil {
files = append(files, logFile{path: current, date: info.ModTime().Format(time.DateOnly), current: true})
}
return files, nil
}
// readLines 读取文件中的非空行
func readLines(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
// 列出后可能刚被轮转清理掉
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer func(file *os.File) { _ = file.Close() }(file)
var lines []string
scanner := bufio.NewScanner(file)
// 增加缓冲区大小以处理较长的日志行
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
if line := scanner.Text(); strings.TrimSpace(line) != "" {
lines = append(lines, line)
}
}
return lines, scanner.Err()
}
// fillOperatorNames 填充操作员用户名
func (r *logRepo) fillOperatorNames(entries []biz.LogEntry) {
// 收集所有用户ID
@@ -236,17 +262,3 @@ func (r *logRepo) parseLine(line string, logType string) (biz.LogEntry, error) {
return entry, nil
}
// getLogArchivePattern 获取归档日志文件名匹配正则表达式
func getLogArchivePattern(logType string) *regexp.Regexp {
switch logType {
case biz.LogTypeApp:
return logArchivePatternApp
case biz.LogTypeDB:
return logArchivePatternDB
case biz.LogTypeHTTP:
return logArchivePatternHTTP
default:
return logArchivePatternApp
}
}
+50
View File
@@ -20,6 +20,9 @@ var _ biz.LogRepo = &LogRepo{}
//
// // make and configure a mocked biz.LogRepo
// mockedLogRepo := &LogRepo{
// CleanFunc: func(logType string, date string) error {
// panic("mock out the Clean method")
// },
// ListFunc: func(logType string, limit int, date string) ([]biz.LogEntry, error) {
// panic("mock out the List method")
// },
@@ -33,6 +36,9 @@ var _ biz.LogRepo = &LogRepo{}
//
// }
type LogRepo struct {
// CleanFunc mocks the Clean method.
CleanFunc func(logType string, date string) error
// ListFunc mocks the List method.
ListFunc func(logType string, limit int, date string) ([]biz.LogEntry, error)
@@ -41,6 +47,13 @@ type LogRepo struct {
// calls tracks calls to the methods.
calls struct {
// Clean holds details about calls to the Clean method.
Clean []struct {
// LogType is the logType argument value.
LogType string
// Date is the date argument value.
Date string
}
// List holds details about calls to the List method.
List []struct {
// LogType is the logType argument value.
@@ -56,10 +69,47 @@ type LogRepo struct {
LogType string
}
}
lockClean sync.RWMutex
lockList sync.RWMutex
lockListDates sync.RWMutex
}
// Clean calls CleanFunc.
func (mock *LogRepo) Clean(logType string, date string) error {
if mock.CleanFunc == nil {
panic("LogRepo.CleanFunc: method is nil but LogRepo.Clean was just called")
}
callInfo := struct {
LogType string
Date string
}{
LogType: logType,
Date: date,
}
mock.lockClean.Lock()
mock.calls.Clean = append(mock.calls.Clean, callInfo)
mock.lockClean.Unlock()
return mock.CleanFunc(logType, date)
}
// CleanCalls gets all the calls that were made to Clean.
// Check the length with:
//
// len(mockedLogRepo.CleanCalls())
func (mock *LogRepo) CleanCalls() []struct {
LogType string
Date string
} {
var calls []struct {
LogType string
Date string
}
mock.lockClean.RLock()
calls = mock.calls.Clean
mock.lockClean.RUnlock()
return calls
}
// List calls ListFunc.
func (mock *LogRepo) List(logType string, limit int, date string) ([]biz.LogEntry, error) {
if mock.ListFunc == nil {
+6
View File
@@ -11,3 +11,9 @@ type LogList struct {
type LogDates struct {
Type string `json:"type" form:"type" query:"type" validate:"required && in:app,db,http"`
}
// LogClean 日志清理请求
type LogClean struct {
Type string `json:"type" form:"type" validate:"required && in:app,db,http"`
Date string `json:"date" form:"date" validate:"required && datetime:2006-01-02"` // 清理该日期及之前的日志
}
+1
View File
@@ -14,6 +14,7 @@ func LogRoutes(logService *service.LogService) Endpoints {
return Endpoints{
{Method: http.MethodGet, Path: "/api/log/list", Handler: svc.List, Summary: "日志列表", Tags: []string{"日志"}, Document: DescribeReq[request.LogList]()},
{Method: http.MethodGet, Path: "/api/log/dates", Handler: svc.Dates, Summary: "日志日期列表", Tags: []string{"日志"}, Document: DescribeReq[request.LogDates]()},
{Method: http.MethodPost, Path: "/api/log/clean", Handler: svc.Clean, Summary: "清理日志", Tags: []string{"日志"}, Document: DescribeReq[request.LogClean]()},
{Method: http.MethodGet, Path: "/api/log/ssh", Handler: svc.SSH, Summary: "SSH 登录日志", Tags: []string{"日志"}},
}
}
+16
View File
@@ -79,6 +79,22 @@ func (s *LogService) Dates(w http.ResponseWriter, r *http.Request) {
Success(w, dates)
}
// Clean 清理指定日期及之前的日志
func (s *LogService) Clean(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.LogClean](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = s.logRepo.Clean(r.Context(), req.Type, req.Date); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
// SSH 获取 SSH 登录日志
func (s *LogService) SSH(w http.ResponseWriter, r *http.Request) {
limit := 100
+3
View File
@@ -6,6 +6,9 @@ export default {
http.Get('/log/list', { params: { type, limit, date } }),
// 获取日志日期列表
dates: (type: 'app' | 'db' | 'http'): any => http.Get('/log/dates', { params: { type } }),
// 清理指定日期及之前的日志
clean: (type: 'app' | 'db' | 'http', date: string): any =>
http.Post('/log/clean', { type, date }),
// 获取 SSH 登录日志
ssh: (limit: number = 100): any => http.Get('/log/ssh', { params: { limit } }),
}
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { useGettext } from 'vue3-gettext'
import log from '@/api/panel/log'
const props = defineProps<{
type: 'app' | 'db' | 'http'
}>()
const emit = defineEmits<{
(e: 'cleaned'): void
}>()
const { $gettext } = useGettext()
const show = ref(false)
const date = ref<string | null>(null)
const loading = ref(false)
const handleClean = () => {
loading.value = true
useRequest(log.clean(props.type, date.value!))
.onSuccess(() => {
show.value = false
date.value = null
window.$message.success($gettext('Cleaned successfully'))
emit('cleaned')
})
.onComplete(() => {
loading.value = false
})
}
</script>
<template>
<n-popover v-model:show="show" trigger="click" placement="bottom-start">
<template #trigger>
<n-button type="error" ghost>
{{ $gettext('Clean') }}
</n-button>
</template>
<n-flex vertical>
<n-date-picker
v-model:formatted-value="date"
value-format="yyyy-MM-dd"
type="date"
panel
:actions="null"
:is-date-disabled="(ts: number) => ts > Date.now()"
/>
<n-text depth="3">
{{ $gettext('Delete logs on and before the selected date') }}
</n-text>
<n-button type="error" block :disabled="!date" :loading="loading" @click="handleClean">
{{ $gettext('Clean') }}
</n-button>
</n-flex>
</n-popover>
</template>
+15 -3
View File
@@ -8,6 +8,8 @@ import { useGettext } from 'vue3-gettext'
import log from '@/api/panel/log'
import CleanPopover from './CleanPopover.vue'
const { $gettext } = useGettext()
// 日志条目类型定义
@@ -20,10 +22,12 @@ interface LogEntry {
// 数据加载
const limit = ref(200)
const selectedDate = ref<string | null>(null)
const selectedDate = ref('')
// 获取可用的日志日期列表
const { data: dates } = useRequest(() => log.dates('db'), { initialData: [] })
const { data: dates, send: refreshDates } = useRequest(() => log.dates('db'), {
initialData: [],
})
// 日期选项
const dateOptions = computed(() => {
@@ -40,7 +44,7 @@ const {
loading,
data,
send: refresh,
} = useRequest(() => log.list('db', limit.value, selectedDate.value || ''), { initialData: [] })
} = useRequest(() => log.list('db', limit.value, selectedDate.value), { initialData: [] })
// 表格列配置
const columns = [
@@ -104,6 +108,13 @@ const columns = [
const handleRefresh = () => {
refresh()
}
// 清理后选中的日期可能已被删掉,回到今天
const handleCleaned = () => {
selectedDate.value = ''
refreshDates()
refresh()
}
</script>
<template>
@@ -131,6 +142,7 @@ const handleRefresh = () => {
<n-button type="primary" @click="handleRefresh">
{{ $gettext('Refresh') }}
</n-button>
<clean-popover type="db" @cleaned="handleCleaned" />
</n-flex>
<n-data-table
class="flex-1 min-h-0"
+15 -3
View File
@@ -8,6 +8,8 @@ import { useGettext } from 'vue3-gettext'
import log from '@/api/panel/log'
import CleanPopover from './CleanPopover.vue'
const { $gettext } = useGettext()
// 日志条目类型定义
@@ -20,10 +22,12 @@ interface LogEntry {
// 数据加载
const limit = ref(200)
const selectedDate = ref<string | null>(null)
const selectedDate = ref('')
// 获取可用的日志日期列表
const { data: dates } = useRequest(() => log.dates('http'), { initialData: [] })
const { data: dates, send: refreshDates } = useRequest(() => log.dates('http'), {
initialData: [],
})
// 日期选项
const dateOptions = computed(() => {
@@ -40,7 +44,7 @@ const {
loading,
data,
send: refresh,
} = useRequest(() => log.list('http', limit.value, selectedDate.value || ''), { initialData: [] })
} = useRequest(() => log.list('http', limit.value, selectedDate.value), { initialData: [] })
// 获取状态码颜色
const getStatusType = (code: number): 'success' | 'warning' | 'error' | 'info' => {
@@ -133,6 +137,13 @@ const columns = [
const handleRefresh = () => {
refresh()
}
// 清理后选中的日期可能已被删掉,回到今天
const handleCleaned = () => {
selectedDate.value = ''
refreshDates()
refresh()
}
</script>
<template>
@@ -160,6 +171,7 @@ const handleRefresh = () => {
<n-button type="primary" @click="handleRefresh">
{{ $gettext('Refresh') }}
</n-button>
<clean-popover type="http" @cleaned="handleCleaned" />
</n-flex>
<n-data-table
class="flex-1 min-h-0"
+15 -3
View File
@@ -8,6 +8,8 @@ import { useGettext } from 'vue3-gettext'
import log from '@/api/panel/log'
import CleanPopover from './CleanPopover.vue'
const { $gettext } = useGettext()
// 日志条目类型定义
@@ -23,10 +25,12 @@ interface LogEntry {
// 数据加载
const limit = ref(200)
const selectedDate = ref<string | null>(null)
const selectedDate = ref('')
// 获取可用的日志日期列表
const { data: dates } = useRequest(() => log.dates('app'), { initialData: [] })
const { data: dates, send: refreshDates } = useRequest(() => log.dates('app'), {
initialData: [],
})
// 日期选项
const dateOptions = computed(() => {
@@ -43,7 +47,7 @@ const {
loading,
data,
send: refresh,
} = useRequest(() => log.list('app', limit.value, selectedDate.value || ''), { initialData: [] })
} = useRequest(() => log.list('app', limit.value, selectedDate.value), { initialData: [] })
// 表格列配置
const columns = [
@@ -156,6 +160,13 @@ const columns = [
const handleRefresh = () => {
refresh()
}
// 清理后选中的日期可能已被删掉,回到今天
const handleCleaned = () => {
selectedDate.value = ''
refreshDates()
refresh()
}
</script>
<template>
@@ -183,6 +194,7 @@ const handleRefresh = () => {
<n-button type="primary" @click="handleRefresh">
{{ $gettext('Refresh') }}
</n-button>
<clean-popover type="app" @cleaned="handleCleaned" />
</n-flex>
<n-data-table
class="flex-1 min-h-0"