diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go
index f8a20fe..ee07c63 100644
--- a/backend/controllers/logs.go
+++ b/backend/controllers/logs.go
@@ -48,6 +48,20 @@ func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request)
bounded, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
query := logstore.Query{Kind: values.Get("kind"), ActorID: values.Get("actorId"), Limit: limit + 1, Offset: offset}
+ if values.Has("systemOnly") {
+ value := values.Get("systemOnly")
+ if query.Kind != "mail" || (value != "true" && value != "false") {
+ writeConfigError(w, config.ErrInvalidAuditQuery)
+ return
+ }
+ if value == "true" {
+ if ctx.EffectiveRole() != access.RoleSuperAdmin {
+ writeConfigAccessError(w, &access.Denial{Code: access.CodeAccessDenied})
+ return
+ }
+ query.SystemOnly = true
+ }
+ }
if ctx.EffectiveRole() != access.RoleSuperAdmin {
query.ScopeActorID = strconv.Itoa(ctx.UserID)
query.IncludeOrdinaryUsers = ctx.EffectiveRole() == access.RoleAdmin
diff --git a/backend/db/init.go b/backend/db/init.go
index 0f1e930..265f697 100644
--- a/backend/db/init.go
+++ b/backend/db/init.go
@@ -10,7 +10,6 @@ import (
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db/migration"
"git.pcux.cn/xuans/Smail/models"
- "git.pcux.cn/xuans/Smail/services/access"
requestcontext "git.pcux.cn/xuans/Smail/utils/context"
"git.pcux.cn/xuans/Smail/utils/errors"
_ "github.com/go-sql-driver/mysql"
@@ -174,17 +173,8 @@ func fixHistoryData() error {
return errors.Wrap(err)
}
- // 只有一个账号,且不是管理员账号,将账号提权为管理员
- if len(user) == 1 && user[0].IsAdmin == 0 {
- u := user[0]
- u.ApplyRole(access.RoleSuperAdmin)
- _, err = Instance.Update(&u)
- if err != nil {
- return errors.Wrap(err)
- }
- }
-
- if len(user) != 1 {
+ // 历史邮件只能归入唯一超级管理员,不能把仅存的普通账号自动提权。
+ if len(user) != 1 || ValidateSuperAdmin(Instance) != nil {
return nil
}
diff --git a/backend/db/super_admin.go b/backend/db/super_admin.go
new file mode 100644
index 0000000..26be7dc
--- /dev/null
+++ b/backend/db/super_admin.go
@@ -0,0 +1,26 @@
+package db
+
+import (
+ "errors"
+ "fmt"
+
+ "git.pcux.cn/xuans/Smail/models"
+ "xorm.io/xorm"
+)
+
+var ErrSuperAdminInvariant = errors.New("必须有且仅有一个启用的超级管理员,请在部署端核查账号数据")
+
+// ValidateSuperAdmin 在初始化完成和业务启动前检查唯一管理员,不自动提权或降级任何账号。
+func ValidateSuperAdmin(engine *xorm.Engine) error {
+ if engine == nil {
+ return fmt.Errorf("%w:数据库尚未初始化", ErrSuperAdminInvariant)
+ }
+ var users []models.User
+ if err := engine.Cols("id", "disabled").Where(models.SuperAdminPredicate).Limit(2).Find(&users); err != nil {
+ return fmt.Errorf("读取超级管理员状态失败:%w", err)
+ }
+ if len(users) != 1 || users[0].Disabled != 0 {
+ return ErrSuperAdminInvariant
+ }
+ return nil
+}
diff --git a/backend/res_init/init.go b/backend/res_init/init.go
index c062352..c8a4005 100644
--- a/backend/res_init/init.go
+++ b/backend/res_init/init.go
@@ -172,6 +172,9 @@ func Init(serverVersion string) error {
if err := db.Init(serverVersion); err != nil {
return err
}
+ if err := db.ValidateSuperAdmin(db.Instance); err != nil {
+ return err
+ }
dialect, err := migration.ParseDialect(cfg.DbType)
if err != nil {
return err
diff --git a/backend/services/logstore/store.go b/backend/services/logstore/store.go
index ed504e1..9675284 100644
--- a/backend/services/logstore/store.go
+++ b/backend/services/logstore/store.go
@@ -38,6 +38,7 @@ type Query struct {
// ScopeActorID 不接受客户端直接赋值;非空时由服务端强制限制可见账号。
ScopeActorID string
IncludeOrdinaryUsers bool
+ SystemOnly bool
ID string
Kind string
ActorID string
@@ -180,6 +181,13 @@ func (s *Store) Query(ctx context.Context, query Query) ([]Entry, error) {
}
statement := "SELECT " + columns + " FROM " + table + " WHERE created_at >= ? AND kind = ?"
args := []any{from.UnixNano(), query.Kind}
+ if query.SystemOnly {
+ if query.Kind != "mail" {
+ return nil, ErrInvalid
+ }
+ // 兼容早期系统记录的空操作者;仍与服务端账号范围条件取交集。
+ statement += " AND (actor_id = '0' OR actor_id = '')"
+ }
if query.ScopeActorID != "" {
statement += " AND (actor_id = ?"
args = append(args, query.ScopeActorID)
diff --git a/backend/services/setup/db.go b/backend/services/setup/db.go
index 8feeeec..2855810 100644
--- a/backend/services/setup/db.go
+++ b/backend/services/setup/db.go
@@ -18,6 +18,7 @@ import (
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/db/migration"
"git.pcux.cn/xuans/Smail/models"
+ "git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/utils/array"
"git.pcux.cn/xuans/Smail/utils/context"
@@ -147,10 +148,13 @@ func GetAdminPassword(ctx *context.Context) (string, error) {
return "", databaseError("数据库尚未初始化,请先完成数据库设置", nil)
}
users := []*models.User{}
- if err := db.Instance.Find(&users); err != nil {
+ if err := db.Instance.Where(models.SuperAdminPredicate).Limit(2).Find(&users); err != nil {
return "", errors.Wrap(err)
}
- if len(users) > 0 {
+ if len(users) > 1 {
+ return "", db.ErrSuperAdminInvariant
+ }
+ if len(users) == 1 {
return users[0].Account, nil
}
return "", nil
@@ -173,21 +177,40 @@ func SetAdminPassword(ctx *context.Context, account, pwd string) error {
if err != nil {
return errors.Wrap(err)
}
- count, err := db.Instance.Count(new(models.User))
+ user := models.User{Account: account, Name: "admin", SessionVersion: 1, SecurityVersion: 1}
+ user.ApplyRole(access.RoleSuperAdmin)
+ if err := auth.SetPassword(&user, pwd); err != nil {
+ return errors.Wrap(err)
+ }
+ tx := db.Instance.NewSession()
+ defer tx.Close()
+ if err := tx.Begin(); err != nil {
+ return errors.Wrap(err)
+ }
+ defer tx.Rollback()
+ // 锁定必然存在的首条迁移记录,跨进程串行化初始化;三种数据库均支持此写锁。
+ // 先获得写锁再读取用户数,避免两个初始化进程同时看到空库。
+ if _, err := tx.Exec("UPDATE schema_migration SET execution_ms = execution_ms WHERE version = 1"); err != nil {
+ return errors.Wrap(err)
+ }
+ lockRows, err := tx.QueryString("SELECT version FROM schema_migration WHERE version = 1")
+ if err != nil {
+ return errors.Wrap(err)
+ }
+ if len(lockRows) != 1 {
+ return databaseError("数据库迁移记录不完整,不能初始化超级管理员", nil)
+ }
+ count, err := tx.Count(new(models.User))
if err != nil {
return errors.Wrap(err)
}
if count != 0 {
- return errors.New("administrator already exists")
+ return databaseError("账号已初始化,不能再次创建超级管理员", nil)
}
- user := models.User{Account: account, Name: "admin", IsAdmin: 1, SessionVersion: 1}
- if err := auth.SetPassword(&user, pwd); err != nil {
+ if _, err := tx.Insert(&user); err != nil {
return errors.Wrap(err)
}
- if _, err := db.Instance.Insert(&user); err != nil {
- return errors.Wrap(err)
- }
- return nil
+ return tx.Commit()
}
func validateAdminPassword(pwd string) error {
diff --git a/backend/services/setup/finish.go b/backend/services/setup/finish.go
index 5b6cdf6..021b891 100644
--- a/backend/services/setup/finish.go
+++ b/backend/services/setup/finish.go
@@ -2,6 +2,7 @@ package setup
import (
"git.pcux.cn/xuans/Smail/config"
+ "git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/signal"
"git.pcux.cn/xuans/Smail/utils/errors"
"sync"
@@ -13,6 +14,9 @@ var finishMu sync.Mutex
func Finish() error {
finishMu.Lock()
defer finishMu.Unlock()
+ if err := db.ValidateSuperAdmin(db.Instance); err != nil {
+ return err
+ }
cfg, err := config.ReadConfig()
if err != nil {
return errors.Wrap(err)
diff --git a/backend/services/setup/finish_test.go b/backend/services/setup/finish_test.go
index a555709..10c31c0 100644
--- a/backend/services/setup/finish_test.go
+++ b/backend/services/setup/finish_test.go
@@ -2,9 +2,13 @@ package setup
import (
"os"
+ "path/filepath"
"testing"
"git.pcux.cn/xuans/Smail/config"
+ "git.pcux.cn/xuans/Smail/db"
+ "git.pcux.cn/xuans/Smail/models"
+ "xorm.io/xorm"
)
func TestFinishPersistsServicePorts(t *testing.T) {
@@ -120,6 +124,22 @@ func prepareFinishConfigTest(t *testing.T) {
previousManager := config.CurrentManager()
previousSetupPort := config.RuntimeSetupPort()
previousInitState := config.IsInit
+ previousDB := db.Instance
+ engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "finish.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ db.Instance = previousDB
+ _ = engine.Close()
+ })
+ if err := engine.Sync2(new(models.User)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := engine.Insert(&models.User{Account: "root", Name: "root", Password: "unused", Role: "super_admin", IsAdmin: 1}); err != nil {
+ t.Fatal(err)
+ }
+ db.Instance = engine
config.ROOT_PATH = t.TempDir() + string(os.PathSeparator)
config.Instance = nil
diff --git a/frontend/src/views/components/LogSettings/LogSettings.css b/frontend/src/views/components/LogSettings/LogSettings.css
index 71c5cf6..960aa4f 100644
--- a/frontend/src/views/components/LogSettings/LogSettings.css
+++ b/frontend/src/views/components/LogSettings/LogSettings.css
@@ -102,6 +102,11 @@
max-width: 190px;
}
+.log-filters .mail-scope {
+ flex: 0 1 145px;
+ min-width: 130px;
+}
+
.log-filter-actions {
display: flex;
align-items: center;
@@ -371,4 +376,4 @@
.log-categories :deep(.el-radio-button__inner) {
font-size: 12px;
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/views/components/LogSettings/LogSettings.js b/frontend/src/views/components/LogSettings/LogSettings.js
index fda900b..5ee993e 100644
--- a/frontend/src/views/components/LogSettings/LogSettings.js
+++ b/frontend/src/views/components/LogSettings/LogSettings.js
@@ -14,6 +14,9 @@ export function useLogs() {
const offset = ref(0)
const hasMore = ref(false)
const actorId = ref('')
+ const mailScope = ref('all')
+ const canFilterSystemMail = computed(() => store.role === 'super_admin' && category.value === 'mail')
+ const systemMailSelected = computed(() => canFilterSystemMail.value && mailScope.value === 'system')
const recentRange = () => {
const end = new Date()
return [new Date(end.getTime() - 24 * 60 * 60 * 1000), end]
@@ -38,7 +41,7 @@ export function useLogs() {
const actionLabel = value => ({ update: '保存配置', rollback: '回滚配置', migration: '迁移配置' }[value] || value || '未记录操作')
const displayObject = item => item.payload?.object || item.payload?.stage || '未记录对象'
const displayOutcome = item => item.payload?.outcome || '历史记录未提供结果说明'
- const displayActor = item => item.payload?.actorName || (item.actorId && item.actorId !== '0' ? item.actorId : '系统')
+ const displayActor = item => !item.actorId || item.actorId === '0' ? '系统' : (item.payload?.actorName || item.actorId)
const details = computed(() => detail.value?.payload || {})
const runtimeRows = computed(() => lines.value.map((line, index) => parseRuntimeLine(line, offset.value + index + 1)))
const runtimeSummary = computed(() => summarizeRuntime(runtimeRows.value))
@@ -93,7 +96,8 @@ export function useLogs() {
return
}
appliedFilters = { kind: category.value, from: from.toISOString(), to: to.toISOString() }
- if (canFilterActor.value && !isRuntime.value && actorId.value.trim()) appliedFilters.actorId = actorId.value.trim()
+ if (systemMailSelected.value) appliedFilters.systemOnly = true
+ else if (canFilterActor.value && !isRuntime.value && actorId.value.trim()) appliedFilters.actorId = actorId.value.trim()
offset.value = 0
previousOffsets.length = 0
detail.value = null
@@ -120,6 +124,7 @@ export function useLogs() {
runtimeDetail.value = null
search()
})
+ watch(mailScope, search)
onMounted(search)
onBeforeUnmount(() => {
++requestId
@@ -129,6 +134,7 @@ export function useLogs() {
return {
category, categories, loading, error, items, lines, offset,
canFilterActor,
+ mailScope, canFilterSystemMail, systemMailSelected,
hasMore, actorId, dateRange, detail, details, isRuntime,
runtimeDetail, runtimeRows, runtimeSummary, visibleCount, pageRange,
currentCategory, resultType, runtimeRowClass, timeParts,
diff --git a/frontend/src/views/components/LogSettings/LogSettings.vue b/frontend/src/views/components/LogSettings/LogSettings.vue
index 0d76921..4d6b3f4 100644
--- a/frontend/src/views/components/LogSettings/LogSettings.vue
+++ b/frontend/src/views/components/LogSettings/LogSettings.vue
@@ -19,7 +19,11 @@
format="YYYY-MM-DD HH:mm"
aria-label="日志时间范围"
/>
-