Files
sub2api/backend/internal/service/audit_log_service.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

238 lines
5.7 KiB
Go

package service
import (
"context"
"fmt"
"os"
"sync"
"sync/atomic"
"time"
)
const (
auditLogQueueCapacity = 4096
auditLogBatchSize = 100
auditLogFlushInterval = time.Second
auditRetentionCheckInterval = 24 * time.Hour
auditRetentionStartupDelay = 5 * time.Minute
auditRetentionBatchSize = 5000
)
// AuditLogService 管理面操作审计日志服务。
// 写入端为非阻塞异步批量落库(不拖慢管理请求);
// 读取端提供分页查询;清空端点由 handler 层做 TOTP 强校验后调用 ClearAll。
type AuditLogService struct {
repo AuditLogRepository
settingService *SettingService
queue chan *AuditLog
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
droppedCount uint64
writeFailed uint64
writtenCount uint64
}
func NewAuditLogService(repo AuditLogRepository, settingService *SettingService) *AuditLogService {
ctx, cancel := context.WithCancel(context.Background())
return &AuditLogService{
repo: repo,
settingService: settingService,
queue: make(chan *AuditLog, auditLogQueueCapacity),
ctx: ctx,
cancel: cancel,
}
}
// Start 启动异步写入与保留期清理协程。
func (s *AuditLogService) Start() {
if s == nil || s.repo == nil {
return
}
s.wg.Add(2)
go s.runWriter()
go s.runRetentionLoop()
}
// Stop 停止服务并尽量落盘队列中剩余记录。
func (s *AuditLogService) Stop() {
if s == nil {
return
}
s.cancel()
s.wg.Wait()
}
// Record 非阻塞入队一条审计记录;队列打满时丢弃并计数(管理面流量下几乎不可能发生)。
func (s *AuditLogService) Record(entry *AuditLog) {
if s == nil || entry == nil {
return
}
if entry.CreatedAt.IsZero() {
entry.CreatedAt = time.Now().UTC()
}
select {
case <-s.ctx.Done():
return
default:
}
select {
case s.queue <- entry:
default:
atomic.AddUint64(&s.droppedCount, 1)
}
}
// List 分页查询审计日志。
func (s *AuditLogService) List(ctx context.Context, filter *AuditLogFilter) (*AuditLogList, error) {
return s.repo.List(ctx, filter)
}
// GetByID 查询单条详情。
func (s *AuditLogService) GetByID(ctx context.Context, id int64) (*AuditLog, error) {
return s.repo.GetByID(ctx, id)
}
// ClearAll 全量清空审计日志并写入留痕记录。
// 调用方(handler)必须先完成 TOTP 验证;本方法负责:
// 1. 统计并清空全表
// 2. 同步写入一条 "audit_log.clear" 留痕记录(绕过异步队列,保证落库)
func (s *AuditLogService) ClearAll(ctx context.Context, trace *AuditLog) (int64, error) {
deleted, err := s.repo.Count(ctx)
if err != nil {
return 0, fmt.Errorf("count audit logs: %w", err)
}
if err := s.repo.TruncateAll(ctx); err != nil {
return 0, fmt.Errorf("truncate audit logs: %w", err)
}
if trace != nil {
trace.Action = AuditActionAuditLogClear
if trace.CreatedAt.IsZero() {
trace.CreatedAt = time.Now().UTC()
}
if trace.Extra == nil {
trace.Extra = map[string]any{}
}
trace.Extra["deleted_rows"] = deleted
if err := s.repo.Insert(ctx, trace); err != nil {
// 留痕失败必须显式暴露:清空已发生,但审计链断裂。
return deleted, fmt.Errorf("audit logs cleared (%d rows) but failed to persist clear-trace record: %w", deleted, err)
}
}
return deleted, nil
}
func (s *AuditLogService) runWriter() {
defer s.wg.Done()
ticker := time.NewTicker(auditLogFlushInterval)
defer ticker.Stop()
batch := make([]*AuditLog, 0, auditLogBatchSize)
flush := func() {
if len(batch) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
inserted, err := s.repo.BatchInsert(ctx, batch)
cancel()
if err != nil {
atomic.AddUint64(&s.writeFailed, uint64(len(batch)))
_, _ = fmt.Fprintf(os.Stderr, "time=%s level=WARN msg=\"audit log flush failed\" err=%v batch=%d\n",
time.Now().Format(time.RFC3339Nano), err, len(batch))
} else {
atomic.AddUint64(&s.writtenCount, uint64(inserted))
}
batch = batch[:0]
}
for {
select {
case <-s.ctx.Done():
// 停机前排空队列。
for {
select {
case item := <-s.queue:
if item == nil {
continue
}
batch = append(batch, item)
if len(batch) >= auditLogBatchSize {
flush()
}
default:
flush()
return
}
}
case item := <-s.queue:
if item == nil {
continue
}
batch = append(batch, item)
if len(batch) >= auditLogBatchSize {
flush()
}
case <-ticker.C:
flush()
}
}
}
// runRetentionLoop 按保留期定期删除过期审计日志。
// 删除操作幂等,多实例并发执行无害,因此无需选主。
func (s *AuditLogService) runRetentionLoop() {
defer s.wg.Done()
startupTimer := time.NewTimer(auditRetentionStartupDelay)
defer startupTimer.Stop()
select {
case <-s.ctx.Done():
return
case <-startupTimer.C:
}
ticker := time.NewTicker(auditRetentionCheckInterval)
defer ticker.Stop()
s.runRetentionOnce()
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
s.runRetentionOnce()
}
}
}
func (s *AuditLogService) runRetentionOnce() {
ctx, cancel := context.WithTimeout(s.ctx, 10*time.Minute)
defer cancel()
days := 0
if s.settingService != nil {
days = s.settingService.GetAuditLogRetentionDays(ctx)
}
if days <= 0 {
return // 0 或负值表示永久保留,仅支持手动清空
}
cutoff := time.Now().UTC().AddDate(0, 0, -days)
for {
deleted, err := s.repo.DeleteBefore(ctx, cutoff, auditRetentionBatchSize)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "time=%s level=WARN msg=\"audit log retention cleanup failed\" err=%v\n",
time.Now().Format(time.RFC3339Nano), err)
return
}
if deleted == 0 {
return
}
}
}