保存日志权限分页与归档并发修正,继续运行验收
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-08 22:44:21 +08:00
parent 5faadcd6eb
commit b77b272a5e
16 changed files with 280 additions and 40 deletions

View File

@ -2,6 +2,7 @@ package controllers
import (
"context"
"errors"
"net/http"
"strconv"
"time"
@ -12,14 +13,21 @@ import (
"git.pcux.cn/xuans/Smail/services/logstore"
"git.pcux.cn/xuans/Smail/services/runtimelog"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
log "github.com/sirupsen/logrus"
)
func logQueryError(w http.ResponseWriter, err error) {
if errors.Is(err, logstore.ErrInvalid) || errors.Is(err, runtimelog.ErrInvalid) {
writeConfigError(w, config.ErrInvalidAuditQuery)
return
}
log.WithError(err).Warn("日志查询失败")
response.NewErrorResponse(response.ServerError, "日志查询失败,请检查日志存储状态", "LOG_QUERY_FAILED").FPrint(w)
}
// GetLogs 只读日志入口,授权在服务端检查,不依赖导航隐藏。
func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if err := authorizeCapability(ctx, access.CapabilityAuditRead, "", false); err != nil {
writeConfigAccessError(w, err)
return
@ -42,7 +50,7 @@ func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request)
if values.Get("kind") == "runtime" {
writer := runtimelog.Current()
if writer == nil {
logQueryError(w, runtimelog.ErrInvalid)
logQueryError(w, logstore.ErrUnavailable)
return
}
if values.Get("file") == "" {

View File

@ -94,7 +94,7 @@ func TestAdminPluginRestartScope(t *testing.T) {
func TestManagementEndpointCatalog(t *testing.T) {
endpoints := ManagementEndpoints()
if len(endpoints) != 34 {
if len(endpoints) != 35 {
t.Fatalf("unexpected endpoint count: %d", len(endpoints))
}
seen := map[string]struct{}{}
@ -112,6 +112,10 @@ func TestManagementEndpointCatalog(t *testing.T) {
if !ok || endpoint.Capability != CapabilityPluginWriteOwn {
t.Fatal("plugin settings endpoint was not matched")
}
endpoint, ok = LookupEndpoint("GET", "/api/v1/logs")
if !ok || endpoint.Capability != CapabilityAuditRead {
t.Fatal("log endpoint was not matched")
}
endpoint, ok = LookupEndpoint("POST", "/api/v1/config/smtp/validate")
if !ok || endpoint.Capability != CapabilityConfigWriteGlobal {
t.Fatal("configuration validation endpoint was not matched")

View File

@ -27,6 +27,7 @@ var managementEndpoints = []Endpoint{
{Method: "GET", Path: "/api/v1/config/:section", Capability: CapabilityConfigRead},
{Method: "PUT", Path: "/api/v1/config/:section", Capability: CapabilityConfigWriteGlobal},
{Method: "GET", Path: "/api/v1/audits/config", Capability: CapabilityAuditRead},
{Method: "GET", Path: "/api/v1/logs", Capability: CapabilityAuditRead},
{Method: "GET", Path: "/api/v1/audits/config/:id", Capability: CapabilityAuditRead},
{Method: "POST", Path: "/api/v1/audits/config", Capability: CapabilityAuditExport},
{Method: "POST", Path: "/api/v1/audits/config/export", Capability: CapabilityAuditExport},

View File

@ -81,19 +81,24 @@ func (w *Writer) Maintain(ctx context.Context, now time.Time) error {
if now.IsZero() {
return ErrInvalid
}
w.mu.Lock()
defer w.mu.Unlock()
w.archiveMu.Lock()
defer w.archiveMu.Unlock()
if w.closed {
return nil
}
// 关闭当前文件后维护,避免 Windows 无法替换句柄;下一条日志会重新打开。
if w.file != nil {
cutoff := logpolicy.Cutoff(now)
w.mu.Lock()
openDay, _ := time.Parse("2006-01-02", w.day)
// 仅关闭本轮要处理的历史句柄,当天写入不与历史文件扫描共用长时间锁。
if w.file != nil && (!openDay.After(cutoff) || !openDay.Add(24*time.Hour).After(now.Add(-logpolicy.CompressionAge))) {
err := errors.Join(w.file.Sync(), w.file.Close())
w.file = nil
if err != nil {
w.mu.Unlock()
return err
}
}
w.mu.Unlock()
directory, err := w.root.Open(".")
if err != nil {
return err
@ -103,7 +108,6 @@ func (w *Writer) Maintain(ctx context.Context, now time.Time) error {
if err != nil || closeErr != nil {
return errors.Join(err, closeErr)
}
cutoff := logpolicy.Cutoff(now)
var failures []error
for _, entry := range entries {
if err := ctx.Err(); err != nil {

View File

@ -22,8 +22,8 @@ type Page struct {
}
func (w *Writer) Files() ([]File, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.archiveMu.Lock()
defer w.archiveMu.Unlock()
if w.closed {
return nil, ErrInvalid
}
@ -57,8 +57,8 @@ func (w *Writer) Files() ([]File, error) {
// Read 仅允许固定名称的受管日志,不接受任意路径;压缩内容在服务端流式读取。
func (w *Writer) Read(ctx context.Context, name string, offset, limit int) (Page, error) {
w.mu.Lock()
defer w.mu.Unlock()
w.archiveMu.Lock()
defer w.archiveMu.Unlock()
if _, ok := fileDay(name); !ok || offset < 0 || offset > 1000000 || limit < 1 || limit > 500 || w.closed {
return Page{}, ErrInvalid
}

View File

@ -24,14 +24,15 @@ var ErrInvalid = errors.New("运行日志文件或查询参数无效")
var active atomic.Pointer[Writer]
type Writer struct {
mu sync.Mutex
root *os.Root
file *os.File
day string
now func() time.Time
cancel context.CancelFunc
done chan struct{}
closed bool
archiveMu sync.Mutex
mu sync.Mutex
root *os.Root
file *os.File
day string
now func() time.Time
cancel context.CancelFunc
done chan struct{}
closed bool
}
func Current() *Writer { return active.Load() }
@ -95,11 +96,6 @@ func (w *Writer) Fire(entry *log.Entry) error {
}
func (w *Writer) WriteAt(at time.Time, message string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
return nil
}
if at.IsZero() {
return ErrInvalid
}
@ -111,6 +107,18 @@ func (w *Writer) WriteAt(at time.Time, message string) error {
message = string([]rune(message)[:min(len([]rune(message)), (maxLineBytes-128)/4)]) + "…"
}
day := at.UTC().Format("2006-01-02")
w.mu.Lock()
// 当天句柄已打开时直接追加,不等待历史归档的读取或压缩。
if !w.closed && (w.file == nil || w.day != day) {
w.mu.Unlock()
w.archiveMu.Lock()
defer w.archiveMu.Unlock()
w.mu.Lock()
}
defer w.mu.Unlock()
if w.closed {
return nil
}
if w.file == nil || w.day != day {
if w.file != nil {
if err := errors.Join(w.file.Sync(), w.file.Close()); err != nil {
@ -158,6 +166,8 @@ func (w *Writer) Close() error {
w.cancel()
<-w.done
}
w.archiveMu.Lock()
defer w.archiveMu.Unlock()
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {

View File

@ -6,7 +6,8 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$runtime = Join-Path $root 'yunx-test'
$logs = Join-Path $runtime 'log'
$logs = Join-Path $root 'test-log\service-launch'
# 控制台与热更新输出属于模拟部署测试记录,业务运行日志由程序按天管理。
New-Item -ItemType Directory -Path $logs -Force | Out-Null
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
$previousRoot = $env:SMAIL_ROOT
@ -78,7 +79,7 @@ try {
Start-Sleep -Seconds 3
$process.Refresh()
if ($process.HasExited) { throw "服务启动失败;标准输出:$stdout;错误日志:$stderr" }
Write-Output "$Service 已启动,进程编号:$($process.Id);日志目录:yunx-test/log"
Write-Output "$Service 已启动,进程编号:$($process.Id);运行日志:yunx-test/log;启动诊断:test-log/service-launch"
Write-Output "运行文件:$binary;标准输出:$stdout;错误日志:$stderr"
} finally {
$env:SMAIL_ROOT = $previousRoot

View File

@ -23,12 +23,14 @@ foreach ($name in @('SMAIL_ROOT', 'SMAIL_HTTP_PORT_OVERRIDE', 'SMAIL_HTTP_TLS_MO
try {
$env:SMAIL_ROOT = $runtime
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
$logs = Join-Path $root 'test-log\service-launch'
New-Item -ItemType Directory -Path $logs -Force | Out-Null
$process = Start-Process -FilePath $binary -WorkingDirectory $runtime -WindowStyle Hidden -PassThru `
-RedirectStandardOutput (Join-Path $runtime "log\production-$stamp.stdout.log") `
-RedirectStandardError (Join-Path $runtime "log\production-$stamp.stderr.log")
-RedirectStandardOutput (Join-Path $logs "production-$stamp.stdout.log") `
-RedirectStandardError (Join-Path $logs "production-$stamp.stderr.log")
Start-Sleep -Seconds 3
$process.Refresh()
if ($process.HasExited) { throw '生产模拟启动失败,请检查 yunx-test/log;不会自动豁免管理员恢复通道保护' }
if ($process.HasExited) { throw '生产模拟启动失败,请检查 test-log/service-launch;不会自动豁免管理员恢复通道保护' }
Write-Output "生产模拟已启动:$($process.Id),使用嵌入前端和 yunx-test 配置,管理员恢复通道保持保护。"
} finally {
foreach ($name in $previous.Keys) { [Environment]::SetEnvironmentVariable($name, $previous[$name], 'Process') }

View File

@ -55,8 +55,12 @@ export function usePage() {
{ key: 'users', label: lang.user_management, hint: '账号与权限管理', icon: User, component: UserManagement, capability: 'user.manage' },
{ key: 'plugins', label: lang.plugin_settings, hint: '扩展功能管理', icon: Grid, component: PluginSettings, capability: 'plugin.read' },
{ key: 'logs', label: '日志', hint: '操作、收发信与运行记录', icon: Document, component: LogSettings, capability: 'audit.read' },
].filter(item => globalStatus.hasCapability(item.capability) ||
(item.key === 'plugins' && globalStatus.hasCapability('config.read'))))
].filter(item => {
// 全局日志包含其他账号与运行信息,不能把本人审计能力当作全局权限。
if (item.key === 'logs' && globalStatus.role !== 'super_admin') return false
return globalStatus.hasCapability(item.capability) ||
(item.key === 'plugins' && globalStatus.hasCapability('config.read'))
}))
const activeSettings = computed(() => settingsMenu.value.find(item => item.key === globalStatus.settingsActiveTab))
const settingsPages = new Map()

View File

@ -17,6 +17,7 @@ export function useLogs() {
const pageSize = 50
let requestId = 0
let aborter
const previousOffsets = []
const categories = [
{ label: '配置审计', value: 'config' },
@ -28,13 +29,13 @@ export function useLogs() {
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
const resultLabel = value => ({ success: '成功', failure: '失败', unknown: '结果未知', accepted: '已接收' }[value] || value)
const actionLabel = value => ({ update: '保存配置', rollback: '回滚配置', migration: '迁移配置' }[value] || value)
const displayActor = item => item.payload?.actorName || item.actorId || '系统'
const displayActor = item => item.payload?.actorName || (item.actorId && item.actorId !== '0' ? item.actorId : '系统')
const details = computed(() => detail.value?.payload || {})
const changes = computed(() => details.value.changes || [])
function requireData(result) {
if (result?.errorNo) throw new Error(result.errorMsg || '日志查询失败')
return result?.data || {}
if (result?.errorNo !== 0 || !result.data) throw new Error(result?.errorMsg || '日志查询失败')
return result.data
}
async function load() {
@ -53,6 +54,9 @@ export function useLogs() {
files.value = listing.files || []
if (!files.value.some(file => file.name === selectedFile.value)) {
selectedFile.value = files.value[0]?.name || ''
offset.value = 0
params.offset = 0
previousOffsets.length = 0
}
if (!selectedFile.value) {
lines.value = []
@ -86,15 +90,19 @@ export function useLogs() {
function search() {
offset.value = 0
load()
previousOffsets.length = 0
return load()
}
function next() {
if (loading.value || !hasMore.value) return
previousOffsets.push(offset.value)
offset.value += isRuntime.value ? lines.value.length : pageSize
load()
return load()
}
function previous() {
offset.value = Math.max(0, offset.value - pageSize)
load()
if (loading.value || offset.value === 0) return
offset.value = previousOffsets.pop() ?? 0
return load()
}
watch(category, () => {
detail.value = null

View File

@ -0,0 +1,77 @@
package controllers
import (
"context"
"database/sql"
"encoding/json"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"git.pcux.cn/xuans/Smail/db/migration"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/logstore"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
_ "modernc.org/sqlite"
)
func TestLogsAPIRejectsUnscopedAccessAndValidatesQueries(t *testing.T) {
database, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "logs.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
// 与正式 SQLite 连接池一致,避免测试自行引入多写连接锁冲突。
database.SetMaxOpenConns(1)
items := migration.DefaultMigrations()
for _, statement := range items[len(items)-1].StatementsFor(migration.DialectSQLite) {
if _, err := database.Exec(statement); err != nil {
t.Fatal(err)
}
}
store, err := logstore.New(database, migration.DialectSQLite)
if err != nil {
t.Fatal(err)
}
runtime := logstore.Start(store)
defer runtime.Stop()
if err := store.Write(context.Background(), logstore.Entry{Kind: "operation", CreatedAt: time.Now(), ActorID: "42", Action: "probe", Result: "success", Payload: []byte(`{}`)}); err != nil {
t.Fatal(err)
}
call := func(role access.Role, query string) response.Response {
t.Helper()
request := httptest.NewRequest("GET", "/api/v1/logs?"+query, nil)
recorder := httptest.NewRecorder()
GetLogs(&appcontext.Context{Role: role, UserID: 42}, recorder, request)
if recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatal("sensitive log response can be cached")
}
var result response.Response
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result
}
for _, role := range []access.Role{access.RoleUser, access.RoleAdmin} {
for _, kind := range []string{"operation", "config", "mail", "runtime"} {
if got := call(role, "kind="+kind+"&actorId=42"); got.ErrorNo != response.NoAccessPrivileges {
t.Fatalf("unscoped role %s accessed %s: %d", role, kind, got.ErrorNo)
}
}
}
for _, query := range []string{"kind=unknown", "kind=mail&limit=501", "kind=mail&offset=-1", "kind=mail&from=invalid", "kind=mail&from=2026-09-08T00:00:00Z&to=2026-09-01T00:00:00Z"} {
if got := call(access.RoleSuperAdmin, query); got.ErrorNo != response.ParamsError {
t.Fatalf("invalid query accepted: %s => %d", query, got.ErrorNo)
}
}
result := call(access.RoleSuperAdmin, "kind=operation&actorId=42&limit=1")
if result.ErrorNo != 0 {
t.Fatalf("administrator query failed: %d", result.ErrorNo)
}
data := result.Data.(map[string]any)
if len(data["items"].([]any)) != 1 || data["retentionDays"] != float64(30) {
t.Fatal("log query lost records or retention policy")
}
}

View File

@ -93,3 +93,29 @@ func TestRuntimeLogCorruptionAndUnknownFilesArePreserved(t *testing.T) {
}
}
}
func TestRuntimeLogCurrentWritesDoNotWaitForArchiveRead(t *testing.T) {
w, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer w.Close()
now := time.Now()
if err := w.WriteAt(now, "first"); err != nil {
t.Fatal(err)
}
w.archiveMu.Lock()
done := make(chan error, 1)
go func() { done <- w.WriteAt(now, "second") }()
select {
case err := <-done:
w.archiveMu.Unlock()
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
w.archiveMu.Unlock()
<-done
t.Fatal("current log write waited for archive lock")
}
}

View File

@ -0,0 +1,87 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { compileFunction } from 'node:vm'
import { createRequire } from 'node:module'
import { computed, ref } from './vue.mjs'
const root = new URL('../../frontend/src/views/components/LogSettings/', import.meta.url)
const read = name => readFileSync(new URL(name, root), 'utf8')
const create = compileFunction(
read('LogSettings.js').replace(/^import .*$/gm, '').replace('export function', 'function') + '\nreturn useLogs()',
['computed', 'onBeforeUnmount', 'onMounted', 'ref', 'watch', 'http']
)
function fixture(get) {
let dispose
const page = create(computed, callback => { dispose = callback }, () => {}, ref, () => {}, { get })
return { page, dispose: () => dispose() }
}
test('日志页面模板和样式能编译,详情不使用原始 HTML 渲染', () => {
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')
const { descriptor, errors } = parse(read('LogSettings.vue'))
assert.deepEqual(errors, [])
assert.doesNotThrow(() => compileScript(descriptor, { id: 'logs', inlineTemplate: true }))
assert.deepEqual(compileStyle({ source: read('LogSettings.css'), id: 'logs', scoped: true }).errors, [])
assert.doesNotMatch(read('LogSettings.vue'), /v-html/)
})
test('日志查询传递时间与操作者筛选,并将业务失败显示为错误', async () => {
const requests = []
const { page } = fixture(async (_, options) => {
requests.push(options.params)
return requests.length === 1
? { errorNo: 0, data: { items: [{ id: 'safe' }], hasMore: false } }
: { errorNo: 500, errorMsg: '日志暂不可用' }
})
page.actorId.value = ' 42 '
page.dateRange.value = ['2026-09-01T00:00:00Z', '2026-09-08T00:00:00Z']
await page.search()
assert.equal(requests[0].actorId, '42')
assert.equal(requests[0].from, '2026-09-01T00:00:00.000Z')
assert.equal(page.items.value.length, 1)
await page.search()
assert.equal(page.error.value, '日志暂不可用')
assert.equal(page.items.value.length, 0)
assert.equal(page.displayActor({ actorId: '0' }), '系统')
})
test('压缩日志短页正确返回上一页,文件过期后重置偏移', async () => {
let filename = 'runtime-2026-08-15.log.gz'
const offsets = []
const { page } = fixture(async (_, { params }) => {
if (!params.file) return { errorNo: 0, data: { files: [{ name: filename, compressed: true }] } }
offsets.push(params.offset)
return { errorNo: 0, data: { lines: Array(params.offset === 0 ? 8 : 12).fill('日志'), hasMore: true } }
})
page.category.value = 'runtime'
await page.search()
await page.next()
await page.next()
await page.previous()
assert.deepEqual(offsets, [0, 8, 20, 8])
filename = 'runtime-2026-08-16.log.gz'
await page.next()
assert.equal(page.offset.value, 0)
assert.equal(offsets.at(-1), 0)
})
test('慢响应及卸载后的响应不得覆盖最新日志', async () => {
let complete
let count = 0
const { page, dispose } = fixture(async () => {
if (++count === 1) return new Promise(resolve => { complete = resolve })
return { errorNo: 0, data: { items: [{ id: 'new' }] } }
})
const first = page.search()
await page.search()
complete({ errorNo: 0, data: { items: [{ id: 'old' }] } })
await first
assert.equal(page.items.value[0].id, 'new')
const last = page.search()
dispose()
await last
assert.equal(page.items.value[0].id, 'new')
})

View File

@ -48,7 +48,7 @@ test('开发启动必须固定运行配置根目录并集中保存日志', async
assert.match(content, /\$workingDirectory = \$runtime/)
for (const name of ['spam_block.exe', 'pmail_telegram_push.exe', 'wechat_push.exe']) assert.ok(content.includes(name))
assert.match(content, /Join-Path \$runtime 'smail\.exe'/)
assert.match(content, /Join-Path \$runtime 'log'/)
assert.match(content, /Join-Path \$root 'test-log\\service-launch'/)
assert.match(content, /-WindowStyle Hidden/)
assert.doesNotMatch(content, /SkipBuild/)
assert.match(content, /--dev-disable-admin-reset/)

View File

@ -35,7 +35,7 @@ test('二级菜单只发送可见且尚未选中的选项,不提前修改父
test('一级菜单保留能力过滤和当前分类标题联动', () => {
const source = read('HomeHeader/HomeHeader.js')
const names = [
'EpMenu', 'Lock', 'Connection', 'Setting', 'FolderOpened', 'Filter', 'User', 'Grid',
'EpMenu', 'Lock', 'Connection', 'Setting', 'FolderOpened', 'Filter', 'User', 'Grid', 'Document',
'SettingsPageStatus', 'BrandLogo'
]
const createPage = compileFunction(
@ -64,6 +64,13 @@ test('一级菜单保留能力过滤和当前分类标题联动', () => {
assert.equal(page.settingsMenu.value.some(item => item.key === 'plugins'), false)
capabilities.add('plugin.read')
assert.equal(page.settingsMenu.value.some(item => item.key === 'plugins'), true)
capabilities.add('audit.read')
for (const role of ['user', 'admin']) {
store.role = role
assert.equal(page.settingsMenu.value.some(item => item.key === 'logs'), false)
}
store.role = 'super_admin'
assert.equal(page.settingsMenu.value.at(-1).key, 'logs')
})
test('三级菜单只发送当前分组的可见子项,选中状态仍由父页面确认', () => {

View File

@ -49,6 +49,7 @@ if ((suite === 'all' || suite === 'backend') && result === 0) {
const args = extra[0] === '--' ? extra.slice(1) : extra
// 独立回归源码集中放在 test/backend,通过 overlay 保留同包测试语义。
const replacements = {
'backend/controllers/logs_api_test.go': 'test/backend/logs_api_test.go',
'backend/services/logstore/log_store_test.go': 'test/backend/log_store_test.go',
'backend/services/runtimelog/runtime_logs_test.go': 'test/backend/runtime_logs_test.go',
'backend/db/migration/log_migration_test.go': 'test/backend/log_migration_test.go',