Smail/backend/services/runtimelog/range.go
smail fd475cd88d
Some checks are pending
Security Checks / security (push) Waiting to run
保存角色权限分层改造续审现场
2026-09-09 20:27:50 +08:00

194 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package runtimelog
import (
"container/heap"
"context"
"errors"
"sort"
"time"
"git.pcux.cn/xuans/Smail/utils/logpolicy"
)
type Query struct {
// nil 表示完整管理视图;非 nil 时先按可信归属过滤并脱敏,再进行分页。
// 映射值为 true 表示本人;false 表示代管普通用户,事件发生时也必须是普通用户。
AllowedActors map[string]bool
File string
From time.Time
To time.Time
Offset int
Limit int
}
type lineRef struct {
at time.Time
file int
position int
bytes int
}
// 索引只保存时间与行位置,不把历史日志全文装入内存。
type recentLines []lineRef
func (h recentLines) Len() int { return len(h) }
func (h recentLines) Less(i, j int) bool {
if !h[i].at.Equal(h[j].at) {
return h[i].at.Before(h[j].at)
}
if h[i].file != h[j].file {
return h[i].file > h[j].file
}
return h[i].position < h[j].position
}
func (h recentLines) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *recentLines) Push(value any) { *h = append(*h, value.(lineRef)) }
func (h *recentLines) Pop() any {
last := len(*h) - 1
value := (*h)[last]
*h = (*h)[:last]
return value
}
// Query 跨日流式扫描并按真实时间倒序,兼容压缩文件和同一文件中的乱序写入。
// 额外内存由最大偏移限制的行索引和两兆响应组成,不随日志文件体积增长。
func (w *Writer) Query(ctx context.Context, query Query) (Page, error) {
if query.File != "" {
if _, ok := fileDay(query.File); !ok {
return Page{}, ErrInvalid
}
}
if query.Offset < 0 || query.Offset > 100000 || query.Limit < 1 || query.Limit > 500 {
return Page{}, ErrInvalid
}
if query.To.IsZero() {
query.To = w.now()
}
if query.From.IsZero() {
query.From = query.To.Add(-24 * time.Hour)
}
if query.From.After(query.To) {
return Page{}, ErrInvalid
}
query.From = maxTime(query.From, logpolicy.Cutoff(w.now()))
// 等待归档时也响应取消,避免查询超时后继续占用 HTTP 工作协程。
for !w.archiveMu.TryLock() {
select {
case <-ctx.Done():
return Page{}, ctx.Err()
case <-time.After(10 * time.Millisecond):
}
}
defer w.archiveMu.Unlock()
if err := ctx.Err(); err != nil {
return Page{}, err
}
// 在短写锁内冻结文件长度;两次扫描不会读到新追加的半条记录。
w.mu.Lock()
files, err := w.files()
w.mu.Unlock()
if err != nil {
return Page{}, err
}
page := Page{Lines: make([]string, 0, query.Limit)}
if query.From.After(query.To) {
return page, nil
}
capacity := query.Offset + query.Limit + 1
refs := make(recentLines, 0, capacity)
for index, file := range files {
if query.File != "" && query.File != file.Name {
continue
}
day, _ := fileDay(file.Name)
if day.After(query.To) || !day.Add(24*time.Hour).After(query.From) {
continue
}
position := 0
err := w.scanSize(ctx, file.Name, file.Bytes, func(line []byte, at time.Time) error {
position++
if !at.UTC().Truncate(24 * time.Hour).Equal(day) {
return ErrInvalid
}
if at.Before(query.From) || at.After(query.To) {
return nil
}
visible, keep := presentScopedLine(string(line), query.AllowedActors)
if !keep {
return nil
}
candidate := lineRef{at: at, file: index, position: position, bytes: len(visible)}
if len(refs) < capacity {
heap.Push(&refs, candidate)
} else if (recentLines{refs[0], candidate}).Less(0, 1) {
refs[0] = candidate
heap.Fix(&refs, 0)
}
return nil
})
if err != nil {
return Page{}, err
}
}
sort.Sort(sort.Reverse(refs))
if query.Offset >= len(refs) {
return page, nil
}
refs = refs[query.Offset:]
page.HasMore = len(refs) > query.Limit
refs = refs[:min(len(refs), query.Limit)]
bytes := 0
for index, ref := range refs {
if bytes+ref.bytes > 2*1024*1024 {
refs = refs[:index]
page.HasMore = true
break
}
bytes += ref.bytes
}
page.Lines = make([]string, len(refs))
selected := make(map[int]map[int]int)
for index, ref := range refs {
if selected[ref.file] == nil {
selected[ref.file] = make(map[int]int)
}
selected[ref.file][ref.position] = index
}
finished := errors.New("本页内容读取完成")
for fileIndex, positions := range selected {
position, remaining := 0, len(positions)
file := files[fileIndex]
err := w.scanSize(ctx, file.Name, file.Bytes, func(line []byte, at time.Time) error {
position++
index, ok := positions[position]
if !ok {
return nil
}
visible, keep := presentScopedLine(string(line), query.AllowedActors)
if !keep || !at.Equal(refs[index].at) || len(visible) != refs[index].bytes {
return ErrInvalid
}
page.Lines[index] = visible
remaining--
if remaining == 0 {
return finished
}
return nil
})
if err != nil && !errors.Is(err, finished) {
return Page{}, err
}
if remaining != 0 {
return Page{}, ErrInvalid
}
}
return page, nil
}
func maxTime(left, right time.Time) time.Time {
if left.After(right) {
return left
}
return right
}