feat: add audit and app log services

This commit is contained in:
QiuSW
2026-07-09 00:55:46 +08:00
parent 3bd05b316e
commit 769c861eb0
20 changed files with 804 additions and 30 deletions
+22
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"strconv"
"strings"
)
type AppConfigService struct {
@@ -39,6 +40,7 @@ func (r *AppConfigService) GetAppConfig(key string) (string, error) {
// 设置或更新配置项
func (r *AppConfigService) SetAppConfig(key, value string) error {
oldValue, _ := r.GetAppConfig(key)
configType := "user"
description := ""
if definition, ok := settingDefinitionByKey(key); ok {
@@ -59,6 +61,10 @@ func (r *AppConfigService) SetAppConfig(key, value string) error {
value=excluded.value,
description=excluded.description
`, key, configType, value, description)
if err == nil && oldValue != value && shouldAuditConfigChange(key) {
detail := fmt.Sprintf("%s: %q -> %q", key, oldValue, value)
_ = recordAuditLog(r.store, currentAuditActor(r.store), auditActionSettingsUpdate, "appconfig", key, detail)
}
return err
}
@@ -148,3 +154,19 @@ func validateSettingValue(definition models.AppSettingDefinition, value string)
}
return nil
}
func shouldAuditConfigChange(key string) bool {
if strings.HasPrefix(key, "window.") || strings.HasPrefix(key, "auth.local.") {
return false
}
definition, ok := settingDefinitionByKey(key)
if !ok {
return false
}
switch definition.Category {
case "appearance", "data", "logs", "auth":
return true
default:
return false
}
}
+1 -5
View File
@@ -198,11 +198,7 @@ func (s *AuthService) recordAudit(actor string, action string, targetType string
if strings.TrimSpace(actor) == "" {
actor = "anonymous"
}
_, err := s.store.DB.Exec(`
INSERT INTO audit_logs (actor, action, target_type, target_id, detail, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`, actor, action, targetType, targetID, detail, time.Now().UTC().Format(time.RFC3339))
return err
return recordAuditLog(s.store, actor, action, targetType, targetID, detail)
}
func scanAuthSession(row *sql.Row) (models.AuthSession, error) {
@@ -37,6 +37,7 @@ func NewImageOptimizationService(store *SuiStore) *ImageOptimizationService {
func (s *ImageOptimizationService) CreateTask(input models.ImageTaskInput) (models.ImageTaskDetail, error) {
sourcePath := strings.TrimSpace(input.SourcePath)
if sourcePath == "" {
_ = recordAppLog(s.store, appLogLevelWarn, "image_optimization", "create task failed", errImageTaskSourcePathRequired.Error())
return models.ImageTaskDetail{}, errImageTaskSourcePathRequired
}
@@ -51,6 +52,7 @@ func (s *ImageOptimizationService) CreateTask(input models.ImageTaskInput) (mode
tx, err := s.store.DB.Begin()
if err != nil {
_ = recordAppLog(s.store, appLogLevelError, "image_optimization", "begin task transaction failed", err.Error())
return models.ImageTaskDetail{}, err
}
defer func() {
@@ -64,11 +66,13 @@ func (s *ImageOptimizationService) CreateTask(input models.ImageTaskInput) (mode
VALUES (?, ?, ?, ?, '', ?, ?)
`, sourcePath, imageTaskStatusSucceeded, operation, 1200, now, now)
if err != nil {
_ = recordAppLog(s.store, appLogLevelError, "image_optimization", "insert image task failed", err.Error())
return models.ImageTaskDetail{}, err
}
taskID, err := result.LastInsertId()
if err != nil {
_ = recordAppLog(s.store, appLogLevelError, "image_optimization", "read image task id failed", err.Error())
return models.ImageTaskDetail{}, err
}
@@ -78,18 +82,21 @@ func (s *ImageOptimizationService) CreateTask(input models.ImageTaskInput) (mode
VALUES (?, ?, ?, ?, ?)
`, taskID, outputPath, beforeSize, afterSize, now)
if err != nil {
_ = recordAppLog(s.store, appLogLevelError, "image_optimization", "insert image task result failed", err.Error())
return models.ImageTaskDetail{}, err
}
resultID, err := result.LastInsertId()
if err != nil {
_ = recordAppLog(s.store, appLogLevelError, "image_optimization", "read image task result id failed", err.Error())
return models.ImageTaskDetail{}, err
}
if err = tx.Commit(); err != nil {
_ = recordAppLog(s.store, appLogLevelError, "image_optimization", "commit image task failed", err.Error())
return models.ImageTaskDetail{}, err
}
return models.ImageTaskDetail{
detail := models.ImageTaskDetail{
Task: models.ImageTask{
ID: taskID,
SourcePath: sourcePath,
@@ -108,7 +115,9 @@ func (s *ImageOptimizationService) CreateTask(input models.ImageTaskInput) (mode
AfterSize: afterSize,
CreatedAt: now,
},
}, nil
}
_ = recordAuditLog(s.store, currentAuditActor(s.store), auditActionImageTaskCreate, "image_task", fmt.Sprint(taskID), fmt.Sprintf("source=%s operation=%s", sourcePath, operation))
return detail, nil
}
func (s *ImageOptimizationService) ListTasks(filter models.ImageTaskFilter) ([]models.ImageTask, error) {
+228
View File
@@ -0,0 +1,228 @@
package services
import (
"cmbone/internal/models"
"strings"
"time"
)
const (
auditActionSettingsUpdate = "settings.update"
auditActionImageTaskCreate = "image_task.create"
appLogLevelDebug = "debug"
appLogLevelInfo = "info"
appLogLevelWarn = "warn"
appLogLevelError = "error"
defaultLogLimit = 50
maxLogLimit = 200
)
type AuditLogService struct {
store *SuiStore
}
func NewAuditLogService(store *SuiStore) *AuditLogService {
return &AuditLogService{store: store}
}
func (s *AuditLogService) Record(action string, targetType string, targetID string, detail string) error {
return recordAuditLog(s.store, currentAuditActor(s.store), action, targetType, targetID, detail)
}
func (s *AuditLogService) List(filter models.AuditLogFilter) ([]models.AuditLog, error) {
limit, offset := normalizeLimitOffset(filter.Limit, filter.Offset)
query := strings.Builder{}
query.WriteString(`
SELECT id, actor, action, target_type, target_id, detail, created_at
FROM audit_logs
WHERE 1 = 1
`)
args := make([]any, 0, 5)
if strings.TrimSpace(filter.Actor) != "" {
query.WriteString(" AND actor = ?")
args = append(args, strings.TrimSpace(filter.Actor))
}
if strings.TrimSpace(filter.Action) != "" {
query.WriteString(" AND action = ?")
args = append(args, strings.TrimSpace(filter.Action))
}
if strings.TrimSpace(filter.TargetType) != "" {
query.WriteString(" AND target_type = ?")
args = append(args, strings.TrimSpace(filter.TargetType))
}
query.WriteString(" ORDER BY id DESC LIMIT ? OFFSET ?")
args = append(args, limit, offset)
rows, err := s.store.DB.Query(query.String(), args...)
if err != nil {
return nil, err
}
defer rows.Close()
logs := make([]models.AuditLog, 0)
for rows.Next() {
log, err := scanAuditLog(rows)
if err != nil {
return nil, err
}
logs = append(logs, log)
}
if err := rows.Err(); err != nil {
return nil, err
}
return logs, nil
}
type AppLogService struct {
store *SuiStore
}
func NewAppLogService(store *SuiStore) *AppLogService {
return &AppLogService{store: store}
}
func (s *AppLogService) Record(level string, module string, message string, detail string) error {
return recordAppLog(s.store, level, module, message, detail)
}
func (s *AppLogService) List(filter models.AppLogFilter) ([]models.AppLog, error) {
limit, offset := normalizeLimitOffset(filter.Limit, filter.Offset)
query := strings.Builder{}
query.WriteString(`
SELECT id, level, module, message, detail, created_at
FROM app_logs
WHERE 1 = 1
`)
args := make([]any, 0, 4)
if strings.TrimSpace(filter.Level) != "" {
query.WriteString(" AND level = ?")
args = append(args, normalizeAppLogLevel(filter.Level))
}
if strings.TrimSpace(filter.Module) != "" {
query.WriteString(" AND module = ?")
args = append(args, strings.TrimSpace(filter.Module))
}
query.WriteString(" ORDER BY id DESC LIMIT ? OFFSET ?")
args = append(args, limit, offset)
rows, err := s.store.DB.Query(query.String(), args...)
if err != nil {
return nil, err
}
defer rows.Close()
logs := make([]models.AppLog, 0)
for rows.Next() {
log, err := scanAppLog(rows)
if err != nil {
return nil, err
}
logs = append(logs, log)
}
if err := rows.Err(); err != nil {
return nil, err
}
return logs, nil
}
func recordAuditLog(store *SuiStore, actor string, action string, targetType string, targetID string, detail string) error {
if store == nil || store.DB == nil {
return nil
}
if strings.TrimSpace(actor) == "" {
actor = "system"
}
_, err := store.DB.Exec(`
INSERT INTO audit_logs (actor, action, target_type, target_id, detail, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`, actor, strings.TrimSpace(action), strings.TrimSpace(targetType), strings.TrimSpace(targetID), strings.TrimSpace(detail), time.Now().UTC().Format(time.RFC3339))
return err
}
func recordAppLog(store *SuiStore, level string, module string, message string, detail string) error {
if store == nil || store.DB == nil {
return nil
}
_, err := store.DB.Exec(`
INSERT INTO app_logs (level, module, message, detail, created_at)
VALUES (?, ?, ?, ?, ?)
`, normalizeAppLogLevel(level), strings.TrimSpace(module), strings.TrimSpace(message), strings.TrimSpace(detail), time.Now().UTC().Format(time.RFC3339))
return err
}
func currentAuditActor(store *SuiStore) string {
if store == nil || store.DB == nil {
return "system"
}
var username string
err := store.DB.QueryRow(`
SELECT username
FROM auth_sessions
WHERE active = 1 AND expires_at > ?
ORDER BY id DESC
LIMIT 1
`, time.Now().UTC().Format(time.RFC3339)).Scan(&username)
if err != nil || strings.TrimSpace(username) == "" {
return "system"
}
return username
}
func normalizeLimitOffset(limit int, offset int) (int, int) {
if limit <= 0 {
limit = defaultLogLimit
}
if limit > maxLogLimit {
limit = maxLogLimit
}
if offset < 0 {
offset = 0
}
return limit, offset
}
func normalizeAppLogLevel(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) {
case appLogLevelDebug:
return appLogLevelDebug
case appLogLevelWarn:
return appLogLevelWarn
case appLogLevelError:
return appLogLevelError
default:
return appLogLevelInfo
}
}
func scanAuditLog(scanner rowScanner) (models.AuditLog, error) {
var log models.AuditLog
err := scanner.Scan(
&log.ID,
&log.Actor,
&log.Action,
&log.TargetType,
&log.TargetID,
&log.Detail,
&log.CreatedAt,
)
return log, err
}
func scanAppLog(scanner rowScanner) (models.AppLog, error) {
var log models.AppLog
err := scanner.Scan(
&log.ID,
&log.Level,
&log.Module,
&log.Message,
&log.Detail,
&log.CreatedAt,
)
return log, err
}
+111
View File
@@ -0,0 +1,111 @@
package services
import (
"testing"
"cmbone/internal/models"
)
func TestAuditLogServiceRecordAndList(t *testing.T) {
store := newTestStore(t)
service := NewAuditLogService(store)
if err := service.Record("settings.update", "appconfig", "theme.mode", "changed theme"); err != nil {
t.Fatalf("record audit log: %v", err)
}
logs, err := service.List(models.AuditLogFilter{Action: "settings.update"})
if err != nil {
t.Fatalf("list audit logs: %v", err)
}
if len(logs) != 1 {
t.Fatalf("logs len = %d, want 1", len(logs))
}
if logs[0].Actor != "system" {
t.Fatalf("actor = %q, want system", logs[0].Actor)
}
if logs[0].TargetID != "theme.mode" {
t.Fatalf("target id = %q, want theme.mode", logs[0].TargetID)
}
}
func TestAppLogServiceRecordAndList(t *testing.T) {
store := newTestStore(t)
service := NewAppLogService(store)
if err := service.Record("error", "image_optimization", "provider failed", "timeout"); err != nil {
t.Fatalf("record app log: %v", err)
}
logs, err := service.List(models.AppLogFilter{Level: "error", Module: "image_optimization"})
if err != nil {
t.Fatalf("list app logs: %v", err)
}
if len(logs) != 1 {
t.Fatalf("logs len = %d, want 1", len(logs))
}
if logs[0].Message != "provider failed" {
t.Fatalf("message = %q, want provider failed", logs[0].Message)
}
}
func TestAppConfigServiceRecordsSettingsAudit(t *testing.T) {
store := newTestStore(t)
service := NewAppConfigService(store)
if err := service.SetAppConfig("theme.mode", "dark"); err != nil {
t.Fatalf("set theme: %v", err)
}
var count int
if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = ? AND target_id = ?", auditActionSettingsUpdate, "theme.mode").Scan(&count); err != nil {
t.Fatalf("count settings audit logs: %v", err)
}
if count != 1 {
t.Fatalf("settings audit count = %d, want 1", count)
}
}
func TestAppConfigServiceSkipsWindowStateAudit(t *testing.T) {
store := newTestStore(t)
service := NewAppConfigService(store)
if err := service.SetAppConfig(settingKeyWindowWidth, "1400"); err != nil {
t.Fatalf("set window width: %v", err)
}
var count int
if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE target_id = ?", settingKeyWindowWidth).Scan(&count); err != nil {
t.Fatalf("count window audit logs: %v", err)
}
if count != 0 {
t.Fatalf("window audit count = %d, want 0", count)
}
}
func TestImageOptimizationServiceRecordsTaskAuditAndFailureLog(t *testing.T) {
store := newTestStore(t)
service := NewImageOptimizationService(store)
detail, err := service.CreateTask(models.ImageTaskInput{SourcePath: "demo.jpg"})
if err != nil {
t.Fatalf("create task: %v", err)
}
var auditCount int
if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = ? AND target_id = ?", auditActionImageTaskCreate, detail.Task.ID).Scan(&auditCount); err != nil {
t.Fatalf("count image task audit logs: %v", err)
}
if auditCount != 1 {
t.Fatalf("image task audit count = %d, want 1", auditCount)
}
_, _ = service.CreateTask(models.ImageTaskInput{})
var appLogCount int
if err := store.DB.QueryRow("SELECT COUNT(*) FROM app_logs WHERE module = ? AND message = ?", "image_optimization", "create task failed").Scan(&appLogCount); err != nil {
t.Fatalf("count image task app logs: %v", err)
}
if appLogCount != 1 {
t.Fatalf("image task app log count = %d, want 1", appLogCount)
}
}
+12
View File
@@ -126,6 +126,18 @@ func Migrate(db *sql.DB) error {
CREATE INDEX IF NOT EXISTS idx_image_tasks_status_created_at ON image_tasks(status, created_at);
CREATE INDEX IF NOT EXISTS idx_image_task_results_task_id ON image_task_results(task_id);
CREATE TABLE IF NOT EXISTS app_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
level TEXT NOT NULL,
module TEXT NOT NULL,
message TEXT NOT NULL,
detail TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_app_logs_level_created_at ON app_logs(level, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action_created_at ON audit_logs(action, created_at);
`)
if err != nil {
return err