取消独立配置审计并将配置结果写入运行日志
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-09 01:46:30 +08:00
parent 42a173f742
commit ae224649f3
25 changed files with 291 additions and 313 deletions

View File

@ -12,7 +12,8 @@ import (
func TestConfigManagerSwitchDatabaseCommitsSQLiteReference(t *testing.T) {
root := t.TempDir()
writeRuntimeFixture(t, root, validRuntimeConfig())
manager, err := NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}})
audit := &MemoryAuditWriter{}
manager, err := NewConfigManagerWithSecurity(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}, ConfigSecurity{Audit: audit})
if err != nil {
t.Fatal(err)
}
@ -29,7 +30,6 @@ func TestConfigManagerSwitchDatabaseCommitsSQLiteReference(t *testing.T) {
if len(status.PendingRestart) != 1 || status.PendingRestart[0] != ServiceDatabase {
t.Fatalf("pending restart services = %+v", status.PendingRestart)
}
audit := manager.security.Audit.(*FileAuditWriter)
events, err := audit.Query(context.Background(), ConfigAuditQuery{Operation: ConfigOperationMigration, Limit: 10})
if err != nil {
t.Fatal(err)
@ -45,7 +45,8 @@ func TestConfigManagerSwitchAcrossTypesPersistsAndRedactsAudit(t *testing.T) {
root := t.TempDir()
writeRuntimeFixture(t, root, validRuntimeConfig())
options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
manager, err := NewConfigManager(root, options)
audit := &MemoryAuditWriter{}
manager, err := NewConfigManagerWithSecurity(root, options, ConfigSecurity{Audit: audit})
if err != nil {
t.Fatal(err)
}
@ -64,7 +65,7 @@ func TestConfigManagerSwitchAcrossTypesPersistsAndRedactsAudit(t *testing.T) {
if got := reloaded.Snapshot(); got.Generation != 2 || got.Root.Database != target {
t.Fatal("target configuration was not persisted")
}
events, err := manager.security.Audit.(*FileAuditWriter).Query(context.Background(), ConfigAuditQuery{Operation: ConfigOperationMigration, Limit: 10})
events, err := audit.Query(context.Background(), ConfigAuditQuery{Operation: ConfigOperationMigration, Limit: 10})
if err != nil || len(events) != 1 {
t.Fatal("migration audit missing")
}

View File

@ -135,15 +135,7 @@ type ConfigManager struct {
}
func NewConfigManager(root string, options ConfigLoaderOptions) (*ConfigManager, error) {
audit, err := NewFileAuditWriter(root)
if err != nil {
return nil, err
}
return newConfigManager(root, options, ConfigSecurity{
Authorize: DefaultConfigAuthorization,
Confirm: DefaultConfigConfirmation,
Audit: audit,
})
return NewConfigManagerWithSecurity(root, options, ConfigSecurity{})
}
func NewConfigManagerWithSecurity(root string, options ConfigLoaderOptions, security ConfigSecurity) (*ConfigManager, error) {
@ -154,11 +146,7 @@ func NewConfigManagerWithSecurity(root string, options ConfigLoaderOptions, secu
security.Confirm = DefaultConfigConfirmation
}
if security.Audit == nil {
audit, err := NewFileAuditWriter(root)
if err != nil {
return nil, err
}
security.Audit = audit
security.Audit = RuntimeConfigLogWriter{}
}
return newConfigManager(root, options, security)
}

View File

@ -0,0 +1,37 @@
package config
import (
"context"
log "github.com/sirupsen/logrus"
)
// RuntimeConfigLogWriter 复用运行日志记录配置结果,不生成独立审计文件或数据库记录。
// 只保留定位操作需要的元数据,不输出配置值、凭据、用户名称或完整变更载荷。
type RuntimeConfigLogWriter struct{}
func (RuntimeConfigLogWriter) WriteConfigAudit(_ context.Context, event ConfigAuditEvent) error {
event, err := normalizeAuditEvent(event)
if err != nil {
return err
}
action := map[ConfigOperation]string{
ConfigOperationUpdate: "保存配置",
ConfigOperationRollback: "回滚配置",
ConfigOperationMigration: "迁移配置",
}[event.Operation]
entry := log.WithFields(log.Fields{
"operation": event.Operation,
"section": event.Section,
"actorId": event.ActorID,
"fromGeneration": event.FromGeneration,
"toGeneration": event.ToGeneration,
"result": event.Result,
})
if event.Result == "failure" {
entry.Warn(action + "失败")
} else {
entry.Info(action + "成功")
}
return nil
}

View File

@ -94,7 +94,7 @@ func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request)
response.NewSuccessResponse(page).FPrint(w)
return
}
if query.Kind != "config" && query.Kind != "operation" && query.Kind != "mail" {
if query.Kind != "operation" && query.Kind != "mail" {
writeConfigError(w, config.ErrInvalidAuditQuery)
return
}

View File

@ -223,31 +223,12 @@ func configSectionRoute(path, operation string) bool {
}
func configAuditRoute(ctx *context.Context, w http.ResponseWriter, r *http.Request) {
controllers.EnsureConfigRequestID(w, r)
switch r.Method {
case http.MethodGet:
controllers.ConfigAuditQuery(ctx, w, r)
case http.MethodPost:
controllers.ExportConfigAudit(ctx, w, r)
default:
allowRouteMethod(w, r, http.MethodGet, http.MethodPost)
}
w.Header().Set("Cache-Control", "no-store")
http.Error(w, "独立配置审计已停用,请查看运行日志", http.StatusGone)
}
func configAuditSubRoute(ctx *context.Context, w http.ResponseWriter, r *http.Request) {
controllers.EnsureConfigRequestID(w, r)
path := strings.TrimSuffix(r.URL.Path, "/")
switch path {
case "/api/v1/audits/config/export":
if !allowRouteMethod(w, r, http.MethodPost) {
return
}
controllers.ExportConfigAuditFormal(ctx, w, r)
default:
if !allowRouteMethod(w, r, http.MethodGet) {
return
}
controllers.GetConfigAuditDetail(ctx, w, r)
}
configAuditRoute(ctx, w, r)
}
func databaseMigrationSubRoute(ctx *context.Context, w http.ResponseWriter, r *http.Request) {

View File

@ -76,15 +76,6 @@ func startConfigManager() error {
return nil
}
security := config.ConfigSecurity{}
if store := logstore.Current(); store != nil {
audit := &logstore.AuditWriter{Store: store}
importCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := audit.ImportLegacy(importCtx, config.ROOT_PATH); err != nil {
return fmt.Errorf("迁移旧配置审计失败,源文件已保留: %w", err)
}
security.Audit = audit
}
manager, err := config.NewConfigManagerWithSecurity(config.ROOT_PATH, config.ConfigLoaderOptions{Validation: config.RuntimeValidationOptions{
HasSuperAdmin: true,
Production: !config.DevelopmentMode,

View File

@ -12,7 +12,6 @@ import (
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db/migration"
"git.pcux.cn/xuans/Smail/services/logstore"
)
// PortableExecutor 使用版本化目标结构复制业务数据,不拼接用户提供的 SQL。
@ -221,13 +220,8 @@ func (e *PortableExecutor) Run(ctx context.Context, plan Plan, update func(Statu
if err := update(StatusSwitching); err != nil {
return err
}
// 源库此时仍持有写锁,切换审计必须写入已复制且校验完成的目标库。
logStore, err := logstore.New(target, dialect)
if err != nil {
return err
}
switchContext := config.WithMigrationAuditWriter(ctx, &logstore.AuditWriter{Store: logStore})
err = e.switchConfig(switchContext, plan.expectedGeneration, plan.targetConfig, plan.actor)
// 配置切换结果只写运行日志,不再依赖源库或目标库保存独立审计。
err = e.switchConfig(ctx, plan.expectedGeneration, plan.targetConfig, plan.actor)
if err != nil && isKnownSwitchRejection(err) {
return err
}

View File

@ -5,28 +5,13 @@ import (
"encoding/json"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/utils/logpolicy"
)
type AuditWriter struct{ Store *Store }
func (w *AuditWriter) WriteConfigAudit(ctx context.Context, event config.ConfigAuditEvent) error {
if w == nil || w.Store == nil {
return ErrUnavailable
}
normalized, err := config.NormalizeConfigAuditEvent(event)
if err != nil {
return err
}
payload, err := json.Marshal(normalized)
if err != nil {
return err
}
return w.Store.Write(ctx, Entry{
ID: normalized.ID, CreatedAt: normalized.CreatedAt, Kind: "config",
ActorID: normalized.ActorID, Action: string(normalized.Operation),
Section: string(normalized.Section), Result: normalized.Result, Payload: payload,
})
// 旧调用方也转到运行日志,避免再次接入数据库审计。
return (config.RuntimeConfigLogWriter{}).WriteConfigAudit(ctx, event)
}
func (w *AuditWriter) Query(ctx context.Context, query config.ConfigAuditQuery) ([]config.ConfigAuditEvent, error) {
@ -70,15 +55,7 @@ func (w *AuditWriter) ExportCSV(ctx context.Context, query config.ConfigAuditQue
return config.ExportConfigAuditCSV(items)
}
func (w *AuditWriter) ImportLegacy(ctx context.Context, root string) error {
if w == nil || w.Store == nil {
return ErrUnavailable
}
legacy, err := config.NewFileAuditWriter(root)
if err != nil {
return err
}
return legacy.Visit(ctx, logpolicy.Cutoff(w.Store.now()), func(event config.ConfigAuditEvent) error {
return w.WriteConfigAudit(ctx, event)
})
func (w *AuditWriter) ImportLegacy(_ context.Context, _ string) error {
// 独立配置审计已停用,历史文件留在原处,不再导入数据库或重复输出。
return nil
}

View File

@ -92,6 +92,9 @@ func replacePlaceholders(statement string, index *int) string {
// Write 只接受业务点构造的脱敏载荷,禁止直接传入 HTTP 请求或邮件正文。
func (s *Store) Write(ctx context.Context, entry Entry) error {
if entry.Kind == "config" {
return ErrInvalid
}
if s == nil || s.db == nil {
return ErrUnavailable
}

View File

@ -1,5 +1,15 @@
# 日志存储与可视化改造
## 2026-09-09 取消独立配置审计
- [✓] 需求确认:取消配置审计分类、维护菜单及查询导出;配置修改仅在现有运行日志文件记录简短结果,操作记录与收发信存储方式不变。
- [✓] 修改前检查点 `42a173f` 已提交并推送;不删除历史审计文件或清空数据库。
- [✓] 默认配置管理器改用运行日志,停止数据库审计写入与历史审计导入;旧审计接口返回已停用,不再返回记录或下载文件。
- [✓] 配置保存、回滚、迁移仍使用原有事务、备份、权限与二次验证;运行日志不写配置前后值、密钥、密码或用户名称。跨数据库迁移不再创建目标库审计写入器,保留复制校验和源库写锁。
- [✓] 全量回归 `test-log/run-2026-09-08T17-37-37-092Z-35572`:前端 152 项、部署 4 项及后端全包通过。并发检测 `test-log/run-2026-09-08T17-42-33-063Z-34044`:配置、数据库日志、数据库迁移、HTTP 和运行日志包通过。
- [~] 完整编译部署,浏览器 MCP 验证三个日志分类、配置维护菜单和停用接口。
- [ ] 更新 `yunx-test` 并记录最终提交、运行版本与验证结果。
## 2026-09-09 倒序查询与写入前过滤
- [✓] 需求确认:四类日志统一倒序、每页十条、支持起止时间,默认最近二十四小时;翻页固定查询时段。

View File

@ -9,7 +9,6 @@ export function useMaintenance(pageKey) {
page.rollbackPreview.value = null
page.rollbackConfirmed.value = false
}
const auditPagination = usePagination(page.auditItems, 6)
const backupPagination = usePagination(page.backups, 6)
return { ...page, selectBackup, auditPagination, backupPagination }
return { ...page, selectBackup, backupPagination }
}

View File

@ -149,36 +149,6 @@
</div>
</section>
<section v-else-if="pageKey === 'audit' && canAudit" class="maintenance-panel audit-panel">
<div class="maintenance-body">
<div class="panel-heading">
<p class="heading-note">展示最近 50 条,导出最多 500 条记录。</p>
<div class="audit-actions">
<el-button size="small" :disabled="!!working" :loading="auditLoading" @click="loadAudit">查询</el-button>
<el-button v-if="canExportAudit" size="small" type="primary" plain :disabled="!!working" :loading="working === 'export'" @click="exportAudit('json')">导出 JSON</el-button>
<el-button v-if="canExportAudit" size="small" plain :disabled="!!working" :loading="working === 'export-csv'" @click="exportAudit('csv')">导出 CSV</el-button>
</div>
</div>
<el-alert v-if="auditError" :title="auditError" type="warning" :closable="false" show-icon />
<div v-else-if="auditItems.length" class="audit-list">
<div v-for="{ entry: item } in auditRows" :key="item.id || `${item.createdAt}-${item.operation}`" class="audit-item">
<div>
<strong>{{ operationLabel(item.operation) }}</strong>
<span>{{ sectionLabel(item.section) }} · {{ item.actorName || item.actorId || '系统' }}</span>
<details v-if="item.changes?.length" class="audit-changes">
<summary>{{ item.changes.length }} 个字段变更</summary>
<p v-for="change in item.changes" :key="change.field">
{{ change.field }}:{{ change.before }} → {{ change.after }}
</p>
</details>
</div>
<time>{{ formatTime(item.createdAt) }}</time>
</div>
</div>
<el-empty v-else-if="!auditLoading" description="暂无配置审计记录" :image-size="72" />
<ConfigPagination v-model:current-page="auditPage" :page-count="auditPageCount" :total="auditItems.length" label="审计记录" />
</div>
</section>
<el-empty v-else description="当前账号无权访问此页面" />
</template>
</div>
@ -201,10 +171,8 @@ const {
canRestart, serviceLabel, restartPreview, previewRestart, requestRestart,
canRollback, backupsLoading, backupsError, backups, rollbackGeneration, rollbackConfirmed,
rollbackPreview, loadBackups, selectBackup, previewRollback, executeRollback,
canAudit, canExportAudit, auditLoading, auditError, auditItems, loadAudit, exportAudit,
formatTime, sectionLabel, operationLabel, auditPagination, backupPagination
formatTime, sectionLabel, backupPagination
} = useMaintenance(props.pageKey)
const { currentPage: auditPage, pageCount: auditPageCount, pageItems: auditRows } = auditPagination
const { currentPage: backupPage, pageCount: backupPageCount, pageItems: backupRows } = backupPagination
defineExpose({ beforeLeave })

View File

@ -19,8 +19,7 @@ export function useNavigation() {
const tools = [
{ value: 'migration', label: '数据库迁移', capability: 'database.migrate' },
{ value: 'restart', label: '服务重启', capability: 'service.restart' },
{ value: 'backups', label: '备份与回滚', capability: 'config.rollback' },
{ value: 'audit', label: '配置审计', capability: 'audit.read' }
{ value: 'backups', label: '备份与回滚', capability: 'config.rollback' }
]
const sectionOptions = computed(() => store.hasCapability('config.read') ? pages : [])
const maintenanceOptions = computed(() => store.hasCapability('config.read')

View File

@ -177,20 +177,15 @@
.confirmation-check :deep(.el-checkbox__input) {
margin-top: 3px;
}
.action-row,
.audit-actions {
.action-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.action-row > .el-button,
.audit-actions > .el-button {
.action-row > .el-button {
margin-left: 0;
}
.audit-actions {
margin-top: 0;
}
.maintenance-heading {
display: flex;
align-items: baseline;
@ -315,37 +310,6 @@
margin: 0;
padding-left: 18px;
}
.audit-list {
display: grid;
gap: 10px;
margin-top: 18px;
}
.audit-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 8px 12px;
border-radius: var(--pm-radius-sm);
background: var(--pm-bg-primary);
}
.audit-item div {
display: grid;
gap: 4px;
min-width: 0;
overflow-wrap: anywhere;
}
.audit-item strong {
color: var(--pm-text-primary);
}
.audit-item span,
.audit-item time {
color: var(--pm-text-secondary);
font-size: 13px;
}
.audit-item time {
flex: none;
}
@container config-main (max-width: 620px) {
.secret-editor {
grid-template-columns: minmax(0, 1fr);
@ -362,12 +326,7 @@
.maintenance-summary {
flex-wrap: wrap;
}
.audit-item {
align-items: stretch;
flex-direction: column;
}
.action-row .el-button,
.audit-actions .el-button {
.action-row .el-button {
flex: 1;
min-width: 110px;
}

View File

@ -26,14 +26,6 @@ const serviceLabels = {
}
const sectionLabels = Object.fromEntries(sections.map(item => [item.value, item.label]))
const operationLabels = {
validate: '验证配置',
update: '保存配置',
apply: '应用配置',
rollback_preview: '预览回滚',
rollback_ticket: '签发回滚票据',
rollback: '执行回滚'
}
export function usePage(pageKey = 'base') {
const isConfigPage = sections.some(item => item.value === pageKey)
@ -45,9 +37,7 @@ export function usePage(pageKey = 'base') {
const saveFlow = ref('')
const busy = computed(() => !!working.value || !!saveFlow.value)
const backupsLoading = ref(false)
const auditLoading = ref(false)
const backupsError = ref('')
const auditError = ref('')
const status = reactive({
setupState: '',
tlsMode: '',
@ -116,7 +106,6 @@ export function usePage(pageKey = 'base') {
const rollbackGeneration = ref(0)
const rollbackConfirmed = ref(false)
const rollbackPreview = ref(null)
const auditItems = ref([])
const databaseConfig = reactive({ type: '', name: '', localRef: '' })
const migrationTargetName = ref('')
const migrationTarget = reactive({ type: 'sqlite', host: '127.0.0.1', port: 3306, username: '', password: '' })
@ -161,8 +150,6 @@ export function usePage(pageKey = 'base') {
const canWrite = computed(() => globalStatus.hasCapability('config.write_global'))
const canRollback = computed(() => globalStatus.hasCapability('config.rollback'))
const canAudit = computed(() => globalStatus.hasCapability('audit.read'))
const canExportAudit = computed(() => globalStatus.hasCapability('audit.export'))
const canMigrate = computed(() => globalStatus.hasCapability('database.migrate'))
const canRestart = computed(() => globalStatus.hasCapability('service.restart'))
const stateLabel = computed(() => ({ new: '未初始化', bootstrap: '初始化中', ready: '就绪', degraded: '降级' }[status.setupState] || status.setupState || '未知'))
@ -208,7 +195,6 @@ export function usePage(pageKey = 'base') {
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'
const serviceLabel = value => serviceLabels[value] || value
const sectionLabel = value => sectionLabels[value] || value || '-'
const operationLabel = value => operationLabels[value] || value || '-'
const migrationStatusLabels = {
previewed: '已生成预览',
created: '已创建',
@ -346,22 +332,6 @@ export function usePage(pageKey = 'base') {
}
}
const loadAudit = async () => {
if (!canAudit.value) return
auditLoading.value = true
auditError.value = ''
try {
const response = await http.get('/api/v1/audits/config?limit=50')
notifyResponseError(response, '配置审计读取失败')
auditItems.value = Array.isArray(response.data) ? response.data : []
} catch (error) {
auditItems.value = []
auditError.value = error.message || '配置审计读取失败'
} finally {
auditLoading.value = false
}
}
const previewDatabaseMigration = async () => {
if (!canMigrate.value || working.value || sectionError.value || !migrationReady.value) return
working.value = 'migration-preview'
@ -499,7 +469,6 @@ export function usePage(pageKey = 'base') {
if (isConfigPage || (pageKey === 'migration' && canMigrate.value)) await loadSection()
if (isConfigPage) await loadPending()
if (pageKey === 'backups') await loadBackups()
if (pageKey === 'audit') await loadAudit()
} catch (error) {
availabilityError.value = error.message || '配置管理数据读取失败'
} finally {
@ -723,38 +692,6 @@ export function usePage(pageKey = 'base') {
}
}
const exportAudit = async format => {
if (!canExportAudit.value || working.value) return
working.value = format === 'csv' ? 'export-csv' : 'export'
try {
const response = await requestWithRecentAuthentication(() => http.post(
'/api/v1/audits/config', { format, limit: 500 }, { responseType: 'blob' }
))
if (!(response instanceof Blob)) {
throw new Error(response?.errorMsg || '审计导出响应格式不正确')
}
// Blob 请求也可能返回 JSON 业务错误,不能把错误响应当作审计文件下载。
if (response.type.includes('json') || format === 'json') {
const payload = JSON.parse(await response.text())
if (payload && !Array.isArray(payload) && Object.hasOwn(payload, 'errorNo') && payload.errorNo !== 0) {
throw new Error(payload.errorMsg || '审计导出失败')
}
}
const blob = response
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `smail-config-audit.${format}`
anchor.click()
URL.revokeObjectURL(url)
ElMessage.success('审计记录已导出')
} catch (error) {
if (!isRecentAuthenticationCancelled(error)) ElMessage.error(error.message || '审计导出失败')
} finally {
working.value = ''
}
}
const beforeLeave = async () => {
if (busy.value) {
ElMessage.warning('当前操作尚未完成,请稍后再切换页面')
@ -795,11 +732,11 @@ export function usePage(pageKey = 'base') {
loading, availabilityError, working, status, stateLabel, stateTagType, sectionOptions: sections,
selectedSection, currentSection, sectionLoading, editorText, formConfig, formErrors, updateField, updateSecret,
sectionError, saveError, sensitiveHint, secretField, secretValue,
confirmed, savedGeneration, pendingCandidate, managePending, validationResult, canWrite, canRollback, canAudit, canExportAudit,
confirmed, savedGeneration, pendingCandidate, managePending, validationResult, canWrite, canRollback,
backupsLoading, backupsError, backups, rollbackGeneration, rollbackConfirmed, rollbackPreview,
auditLoading, auditError, auditItems, loadAll, loadSection, validateConfig, saveConfig, applyConfig, saveAndApply, continuePending,
loadBackups, previewRollback, executeRollback, loadAudit, exportAudit, formatTime,
serviceLabel, sectionLabel, operationLabel, databaseConfig, databaseTypeLabel, canMigrate,
loadAll, loadSection, validateConfig, saveConfig, applyConfig, saveAndApply, continuePending,
loadBackups, previewRollback, executeRollback, formatTime,
serviceLabel, sectionLabel, databaseConfig, databaseTypeLabel, canMigrate,
migrationTargetName, migrationPreview, migrationToken, migrationRecord, migrationStatusLabel, migrationStatusType,
migrationTarget, migrationReady, migrationError, invalidateMigration, changeMigrationType,
previewDatabaseMigration, loadMigrationStatus, executeDatabaseMigration, canRestart, restartPreview,

View File

@ -3,7 +3,7 @@ import { http } from '@/utils/axios'
import { parseRuntimeLine, summarizeRuntime, timeParts } from './RuntimeLog.js'
export function useLogs() {
const category = ref('config')
const category = ref('operation')
const loading = ref(false)
const error = ref('')
const items = ref([])
@ -25,7 +25,6 @@ export function useLogs() {
let appliedFilters = {}
const categories = [
{ label: '配置审计', value: 'config' },
{ label: '操作记录', value: 'operation' },
{ label: '收发信记录', value: 'mail' },
{ label: '运行日志', value: 'runtime' }
@ -36,7 +35,6 @@ export function useLogs() {
const actionLabel = value => ({ update: '保存配置', rollback: '回滚配置', migration: '迁移配置' }[value] || value)
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 || [])
const runtimeRows = computed(() => lines.value.map((line, index) => parseRuntimeLine(line, offset.value + index + 1)))
const runtimeSummary = computed(() => summarizeRuntime(runtimeRows.value))
const visibleCount = computed(() => isRuntime.value ? lines.value.length : items.value.length)
@ -125,7 +123,7 @@ export function useLogs() {
return {
category, categories, loading, error, items, lines, offset,
hasMore, actorId, dateRange, detail, details, changes, isRuntime,
hasMore, actorId, dateRange, detail, details, isRuntime,
runtimeDetail, runtimeRows, runtimeSummary, visibleCount, pageRange,
currentCategory, resultType, runtimeRowClass, timeParts,
formatTime, resultLabel, actionLabel, displayActor, search, recent, next, previous

View File

@ -129,15 +129,7 @@
<el-dialog :model-value="Boolean(detail)" title="日志详情" width="min(760px, 92vw)" append-to-body @close="detail = null">
<template v-if="detail">
<p>{{ formatTime(detail.createdAt) }} · {{ displayActor(detail) }} · {{ actionLabel(detail.action) }}</p>
<el-table v-if="changes.length" :data="changes" max-height="440">
<el-table-column prop="field" label="变更字段" min-width="200" />
<el-table-column prop="before" label="修改前" min-width="150" />
<el-table-column prop="after" label="修改后" min-width="150" />
</el-table>
<p v-else-if="category === 'config'">
{{ detail.id.startsWith('legacy-') || !details.actorName ? '此记录未保存字段前后值,不补造历史详情。' : '此次保存没有业务字段变更。' }}
</p>
<dl v-else-if="category === 'mail'" class="log-detail-grid">
<dl v-if="category === 'mail'" class="log-detail-grid">
<dt>发件人</dt><dd>{{ details.sender || '—' }}</dd>
<dt>收件人数</dt><dd>{{ details.recipientCount }}</dd>
<dt>邮件大小</dt><dd>{{ details.bytes }} 字节</dd>
@ -157,7 +149,7 @@ import { useLogs } from './LogSettings.js'
const {
category, categories, loading, error, items, offset,
hasMore, actorId, dateRange, detail, details, changes, isRuntime,
hasMore, actorId, dateRange, detail, details, isRuntime,
runtimeDetail, runtimeRows, runtimeSummary, pageRange,
currentCategory, resultType, runtimeRowClass, timeParts,
formatTime, resultLabel, actionLabel, displayActor, search, recent, next, previous

View File

@ -0,0 +1,110 @@
package config
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Smail/services/runtimelog"
log "github.com/sirupsen/logrus"
)
func TestDefaultConfigManagerUsesRuntimeLogAndPreservesLegacyFile(t *testing.T) {
for _, withSecurity := range []bool{false, true} {
t.Run(map[bool]string{false: "默认构造", true: "默认安全配置"}[withSecurity], func(t *testing.T) {
root := t.TempDir()
writeRuntimeFixture(t, root, validRuntimeConfig())
legacy, err := NewFileAuditWriter(root)
if err != nil {
t.Fatal(err)
}
history := []byte("保留旧审计原文,不解析、不导入、不追加\n")
if err := os.WriteFile(legacy.path, history, 0600); err != nil {
t.Fatal(err)
}
logger := log.StandardLogger()
oldHooks, oldOutput, oldFormatter, oldLevel, oldCaller := logger.Hooks, logger.Out, logger.Formatter, logger.GetLevel(), logger.ReportCaller
var output bytes.Buffer
logger.ReplaceHooks(make(log.LevelHooks))
logger.SetOutput(&output)
logger.SetFormatter(&logFormatter{})
logger.SetLevel(log.InfoLevel)
logger.SetReportCaller(true)
t.Cleanup(func() {
logger.ReplaceHooks(oldHooks)
logger.SetOutput(oldOutput)
logger.SetFormatter(oldFormatter)
logger.SetLevel(oldLevel)
logger.SetReportCaller(oldCaller)
})
writer, err := runtimelog.Start(root)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = writer.Close() })
options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
var manager *ConfigManager
if withSecurity {
manager, err = NewConfigManagerWithSecurity(root, options, ConfigSecurity{})
} else {
manager, err = NewConfigManager(root, options)
}
if err != nil {
t.Fatal(err)
}
if _, ok := manager.security.Audit.(RuntimeConfigLogWriter); !ok {
t.Fatal("默认写入器仍使用独立配置审计")
}
payload, err := json.Marshal(manager.Snapshot().Email)
if err != nil {
t.Fatal(err)
}
updated, err := manager.Update(context.Background(), UpdateRequest{Section: SectionEmail, ExpectedGeneration: 1, Payload: payload, Actor: authorizedConfigActor()})
if err != nil {
t.Fatal(err)
}
if _, err := manager.Apply(context.Background(), updated.Generation); err != nil {
t.Fatal(err)
}
if manager.Status().SetupState != SetupStateReady {
t.Fatal("取消审计影响了配置保存")
}
if _, err := manager.AuditQuery(context.Background(), ConfigAuditQuery{}); err != ErrAuditQueryUnavailable {
t.Fatal("默认管理器仍提供审计查询")
}
event := ConfigAuditEvent{Operation: ConfigOperationRollback, ActorID: "1", ActorRole: "super_admin", ActorName: "不应记录的名称", FromGeneration: 2, ToGeneration: 3, Result: "failure", CreatedAt: time.Now(), Changes: []ConfigAuditChange{{Field: "root.database.password", Before: "不应记录的旧值", After: "不应记录的新值"}}}
if err := (RuntimeConfigLogWriter{}).WriteConfigAudit(context.Background(), event); err != nil {
t.Fatal(err)
}
page, err := writer.Query(context.Background(), runtimelog.Query{Limit: 10})
if err != nil {
t.Fatal(err)
}
text := strings.Join(page.Lines, "\n")
if len(page.Lines) != 2 || !strings.Contains(text, "保存配置成功") || !strings.Contains(text, "回滚配置失败") || !strings.Contains(text, "[warning]") {
t.Fatalf("运行日志缺少配置结果:%s", text)
}
if strings.Contains(text, "不应记录") || strings.Contains(text, "password") || strings.Contains(text, "\"changes\"") {
t.Fatal("运行日志泄露配置详情")
}
unchanged, err := os.ReadFile(legacy.path)
if err != nil || !bytes.Equal(unchanged, history) {
t.Fatal("旧审计文件被修改")
}
files, err := os.ReadDir(filepath.Join(root, "log"))
if err != nil {
t.Fatal(err)
}
for _, file := range files {
if strings.Contains(file.Name(), "audit") {
t.Fatal("生成了独立审计文件")
}
}
})
}
}

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@ -39,7 +40,7 @@ func testStore(t *testing.T) *Store {
func TestLogStoreEmptyEntriesAreNotInserted(t *testing.T) {
s := testStore(t)
ctx := context.Background()
for _, kind := range []string{"config", "operation", "mail"} {
for _, kind := range []string{"operation", "mail"} {
for _, payload := range []string{`{}`, `null`, `[]`, `" "`, `{"message":"***"}`} {
if err := s.Write(ctx, Entry{Kind: kind, CreatedAt: time.Now(), Action: "---", Payload: []byte(payload)}); err != nil {
t.Fatal(err)
@ -129,7 +130,7 @@ func TestLogRetentionBoundariesAndBusinessSafety(t *testing.T) {
}
}
func TestLogDialectStatementsAndAuditReplay(t *testing.T) {
func TestLogDialectStatementsAndRetiredAuditWriter(t *testing.T) {
s := testStore(t)
for _, dialect := range []migration.Dialect{migration.DialectSQLite, migration.DialectMySQL, migration.DialectPostgres} {
s.dialect = dialect
@ -158,11 +159,38 @@ func TestLogDialectStatementsAndAuditReplay(t *testing.T) {
}
}
items, err := audit.Query(context.Background(), config.ConfigAuditQuery{})
if err != nil || len(items) != 1 || items[0].Changes[0].After != "debug" {
t.Fatalf("audit: %+v, %v", items, err)
if err != nil || len(items) != 0 {
t.Fatalf("已停用的配置审计仍写入数据库:%+v, %v", items, err)
}
data, err := audit.ExportCSV(context.Background(), config.ConfigAuditQuery{})
if err != nil || !strings.Contains(string(data), "tester") || !strings.Contains(string(data), "debug") {
t.Fatalf("csv: %s, %v", data, err)
if err := s.Write(context.Background(), Entry{Kind: "config", CreatedAt: time.Now(), Payload: []byte(`{}`)}); err != ErrInvalid {
t.Fatal("数据库写入入口必须拒绝配置审计")
}
}
func TestRetiredAuditImportPreservesLegacyFiles(t *testing.T) {
s := testStore(t)
root := t.TempDir()
directory := filepath.Join(root, "log")
if err := os.MkdirAll(directory, 0700); err != nil {
t.Fatal(err)
}
path := filepath.Join(directory, "config-audit-v2.jsonl")
history := []byte("旧审计内容不再解析或导入\n")
if err := os.WriteFile(path, history, 0600); err != nil {
t.Fatal(err)
}
audit := &AuditWriter{Store: s}
for index := 0; index < 2; index++ {
if err := audit.ImportLegacy(context.Background(), root); err != nil {
t.Fatal(err)
}
}
data, err := os.ReadFile(path)
if err != nil || string(data) != string(history) {
t.Fatal("旧审计文件被修改")
}
var count int
if err := s.db.QueryRow("SELECT COUNT(*) FROM operation_log").Scan(&count); err != nil || count != 0 {
t.Fatal("已停用的旧审计导入仍写入数据库")
}
}

View File

@ -69,7 +69,7 @@ func TestLogsAPIRejectsUnscopedAccessAndValidatesQueries(t *testing.T) {
}
}
}
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"} {
for _, query := range []string{"kind=config", "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)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
@ -16,6 +17,23 @@ import (
_ "modernc.org/sqlite"
)
func TestRetiredConfigAuditRoutesReturnGone(t *testing.T) {
for _, path := range []string{"/api/v1/audits/config", "/api/v1/audits/config/export", "/api/v1/audits/config/audit-00000000000000000000000000000000"} {
for _, method := range []string{http.MethodGet, http.MethodPost} {
request := httptest.NewRequest(method, path, nil)
recorder := httptest.NewRecorder()
if path == "/api/v1/audits/config" {
configAuditRoute(&appcontext.Context{}, recorder, request)
} else {
configAuditSubRoute(&appcontext.Context{}, recorder, request)
}
if recorder.Code != http.StatusGone || recorder.Header().Get("Cache-Control") != "no-store" || recorder.Header().Get("Content-Disposition") != "" {
t.Fatalf("已停用入口仍可查询或导出:%s %s", method, path)
}
}
}
}
func TestOperationLogUsesCompletedIdentityWithoutSensitivePayload(t *testing.T) {
database, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "operations.db"))
if err != nil {

View File

@ -31,8 +31,11 @@ test('配置管理只保留服务配置与维护入口,不再包含插件菜
await page.selectSection('invalid')
assert.equal(page.selectedSection.value, 'base')
allowed.add('audit.read')
assert.deepEqual(page.maintenanceOptions.value.map(item => item.value), ['audit'])
assert.deepEqual(page.maintenanceOptions.value.map(item => item.value), [])
await page.selectSection('audit')
assert.equal(page.selectedSection.value, 'base')
allowed.add('config.rollback')
await page.selectSection('backups')
assert.equal(page.activePage.value.component.name, 'ConfigMaintenance')
allowed.delete('config.read')
assert.equal(page.activePage.value, undefined)

View File

@ -60,61 +60,46 @@ function deferred() {
}
for (const cancelled of [false, true]) {
test(`审计导出身份过期后确认密码再重试,取消不误报失败:取消=${cancelled}`, async context => {
const originalDocument = globalThis.document
let downloads = 0
globalThis.document = { createElement: () => ({ click: () => { downloads++ } }) }
context.after(() => { globalThis.document = originalDocument })
test(`共享身份确认保留单次重试,移除审计不影响其他操作:取消=${cancelled}`, async () => {
const requests = []
let prompts = 0
const { page, messages, successes } = fixture(() => {}, 'audit', {
const helpers = compileRecentAuthentication({
prompt: async () => {
prompts++
if (cancelled) throw 'cancel'
return { value: 'test-password' }
},
http: {
post: async (url, body) => {
requests.push(url)
if (url.endsWith('/reauth')) {
assert.equal(body.password, 'test-password')
return { errorNo: 0 }
}
if (requests.length === 1) {
throw Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
}
return new Blob(['action\nupdate'], { type: 'text/csv' })
}
}
}, {
post: async (url, body) => {
requests.push(url)
assert.equal(body.password, 'test-password')
return { errorNo: 0 }
}
})
await page.exportAudit('csv')
let attempts = 0
const action = helpers.requestWithRecentAuthentication(async () => {
if (++attempts === 1) throw Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
return { errorNo: 0 }
})
if (cancelled) await assert.rejects(action, error => helpers.isRecentAuthenticationCancelled(error))
else assert.equal((await action).errorNo, 0)
assert.equal(prompts, 1)
assert.equal(downloads, cancelled ? 0 : 1)
assert.deepEqual(messages, [])
assert.equal(successes.length, cancelled ? 0 : 1)
assert.equal(page.working.value, '')
assert.deepEqual(requests, cancelled
? ['/api/v1/audits/config']
: ['/api/v1/audits/config', '/api/v1/security/reauth', '/api/v1/audits/config'])
assert.equal(attempts, cancelled ? 1 : 2)
assert.deepEqual(requests, cancelled ? [] : ['/api/v1/security/reauth'])
})
}
test('审计导出确认身份后仍被拒绝时只重试一次,不能下载错误文件', async () => {
let exports = 0
const { page, messages } = fixture(() => {}, 'audit', {
prompt: async () => ({ value: 'test-password' }),
http: {
post: async url => {
if (url.endsWith('/reauth')) return { errorNo: 0 }
exports++
throw Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
}
}
})
await page.exportAudit('csv')
assert.equal(exports, 2)
assert.deepEqual(messages, ['需要最近认证'])
assert.equal(page.working.value, '')
test('移除配置审计查询、导出和维护面板,不保留隐藏请求', async () => {
const requests = []
const { page } = fixture(async path => {
requests.push(path)
return { errorNo: 0, data: { available: true } }
}, 'audit')
await page.loadAll()
assert.deepEqual(requests, ['/api/v1/config/status'])
assert.equal(page.loadAudit, undefined)
assert.equal(page.exportAudit, undefined)
assert.doesNotMatch(source, /audits\/config|auditItems|canExportAudit/)
})
test('证书页从状态接口读取生效反代模式,不额外加载基础配置', async () => {
@ -210,12 +195,9 @@ test('配置页不加载备份或审计,维护页失败只显示本页错误',
assert.equal(page.editorText.value, '{}')
assert.deepEqual(requests, ['/api/v1/config/status', '/api/v1/config/base'])
const backup = fixture(get, 'backups').page
const audit = fixture(get, 'audit').page
await Promise.all([backup.loadAll(), audit.loadAll()])
await backup.loadAll()
assert.equal(backup.backupsError.value, '配置服务暂时不可用')
assert.equal(audit.auditError.value, '配置服务暂时不可用')
assert.equal(backup.editorText.value, '')
assert.equal(audit.editorText.value, '')
assert.deepEqual(messages, [])
await page.loadBackups()
assert.deepEqual(messages, [])
@ -229,24 +211,19 @@ test('附属模块失败清除旧数据和回滚选择,重试成功清除错
return { errorNo: 0, data: [{ generation: 2 }] }
})
page.backups.value = [{ generation: 1 }]
page.auditItems.value = [{ id: 'old' }]
page.rollbackGeneration.value = 1
page.rollbackPreview.value = { targetGeneration: 1 }
page.rollbackConfirmed.value = true
await Promise.all([page.loadBackups(), page.loadAudit()])
await page.loadBackups()
assert.deepEqual(page.backups.value, [])
assert.deepEqual(page.auditItems.value, [])
assert.equal(page.rollbackGeneration.value, 0)
assert.equal(page.rollbackPreview.value, null)
assert.equal(page.rollbackConfirmed.value, false)
fail = false
await Promise.all([page.loadBackups(), page.loadAudit()])
await page.loadBackups()
assert.equal(page.backupsError.value, '')
assert.equal(page.auditError.value, '')
assert.equal(page.backups.value.length, 1)
assert.equal(page.auditItems.value.length, 1)
assert.equal(page.backupsLoading.value, false)
assert.equal(page.auditLoading.value, false)
})
test('状态请求失败后可重试恢复,不保留不可用提示', async () => {
@ -478,10 +455,10 @@ test('独立页模板可编译,编辑器不再连接维护面板且权限绑
assert.match(template, /v-if="canWrite" class="editor-footer"/)
assert.doesNotMatch(template, /v-model="confirmed"/)
assert.doesNotMatch(template, /maintenance-panel|migration-panel|rollback-panel|audit-panel/)
for (const [key, capability] of [['migration', 'canMigrate'], ['restart', 'canRestart'], ['backups', 'canRollback'], ['audit', 'canAudit']]) {
for (const [key, capability] of [['migration', 'canMigrate'], ['restart', 'canRestart'], ['backups', 'canRollback']]) {
assert.ok(maintenance.includes(`pageKey === '${key}' && ${capability}`))
}
assert.match(maintenance, /v-if="canExportAudit"/)
assert.doesNotMatch(maintenance, /audit-panel|canExportAudit|loadAudit/)
for (const name of ['BaseConfig', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']) {
const page = readFileSync(new URL(`${name}/${name}.vue`, root), 'utf8')
const { descriptor, errors } = parse(page)

View File

@ -25,6 +25,13 @@ test('日志页面使用共享 HTTP 客户端的命名导出', () => {
assert.match(read('LogSettings.js'), /import\s*\{\s*http\s*\}\s*from\s*['"]@\/utils\/axios['"]/)
})
test('日志分类移除配置审计,保留操作、收发信与运行日志', () => {
const { page } = fixture(async () => ({ errorNo: 0, data: {} }))
assert.deepEqual(page.categories.map(item => item.value), ['operation', 'mail', 'runtime'])
assert.equal(page.category.value, 'operation')
assert.doesNotMatch(read('LogSettings.vue'), /category === 'config'|changes\.length/)
})
test('日志页面模板和样式能编译,详情不使用原始 HTML 渲染', () => {
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')

View File

@ -61,6 +61,7 @@ if ((suite === 'all' || suite === 'backend') && result === 0) {
'backend/config/audit_changes_test.go': 'test/backend/audit_changes_test.go',
'backend/config/json_format_test.go': 'test/backend/json_format_test.go',
'backend/config/log_formatter_test.go': 'test/backend/log_formatter_test.go',
'backend/config/config_runtime_log_test.go': 'test/backend/config_runtime_log_test.go',
'backend/config/deployment_state_test.go': 'test/backend/deployment_state_test.go',
'backend/config/ssl_retention_test.go': 'test/backend/ssl_retention_test.go',
'backend/utils/runtimepath/layout_test.go': 'test/backend/runtime_layout_test.go',