feat(server): online session history with configurable stats window (#1243)

Add persisted online sessions, configurable retention and online history statistics with lifecycle, recovery, timezone and concurrency regression coverage.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
Sam5440andnaiba/CloudCode authored and GitHub committed 2026-10-16 19:25:13 +08:00
1 parent 9881c47f0c
commit 8721a15b12
22 files changed
+2242 -20

No files matched your search

+11
View File
@@ -79,6 +79,10 @@ type ConfigDashboard struct {
IgnoredIPNotification string `koanf:"ignored_ip_notification" json:"ignored_ip_notification,omitempty"` // 特定服务器IP(多个服务器用逗号分隔)
DNSServers string `koanf:"dns_servers" json:"dns_servers,omitempty"`
// OnlineStatsDays 设备在线时间段统计的保留与查询窗口(天)。
// 取值范围 [MinOnlineStatsDays, MaxOnlineStatsDays],默认 DefaultOnlineStatsDays。
OnlineStatsDays int `koanf:"online_stats_days" json:"online_stats_days,omitempty"`
}
type Config struct {
@@ -232,6 +236,13 @@ func (c *Config) Read(path string, frontendTemplates []FrontendTemplate) error {
c.JWTTimeout = 1
}
if c.OnlineStatsDays == 0 {
c.OnlineStatsDays = DefaultOnlineStatsDays
} else if c.OnlineStatsDays < MinOnlineStatsDays || c.OnlineStatsDays > MaxOnlineStatsDays {
log.Printf("NEZHA>> online_stats_days %d out of range [%d, %d], reset to %d", c.OnlineStatsDays, MinOnlineStatsDays, MaxOnlineStatsDays, DefaultOnlineStatsDays)
c.OnlineStatsDays = DefaultOnlineStatsDays
}
c.jwtIPChangeAllowed.Store(c.AllowJWTIPChange)
if c.AgentSecretKey == "" {
+211
View File
@@ -0,0 +1,211 @@
package model
import (
"gorm.io/gorm"
"sort"
"time"
)
// 在线统计窗口的取值边界(天)。后台管理端可设置统计范围,查询窗口不允许
// 超过该设置,历史数据按该设置做保留清理。
const (
DefaultOnlineStatsDays = 7
MinOnlineStatsDays = 7
MaxOnlineStatsDays = 3650
)
// OnlineActiveWindow 服务器状态上报距该窗口内视为在线。与 MCP server.list
// 的 onlineWindow 口径保持一致;超过该窗口未上报的进行中会话由定时清扫关闭。
const OnlineActiveWindow = 30 * time.Second
// OnlineSession 记录一台服务器的一段连续在线时间。Ongoing=true 表示会话
// 仍在进行(此时 EndedAt 为零值);会话结束时写入 EndedAt 并置 Ongoing=false。
// LastActiveAt 由上报侧节流持久化,用于 dashboard 异常停机后把进行中的会话
// 结转到最后一次已知活跃时刻。
type OnlineSession struct {
ID uint64 `gorm:"primaryKey" json:"id,omitempty"`
CreatedAt time.Time `gorm:"index;<-:create" json:"created_at,omitempty"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"`
ServerID uint64 `gorm:"index:idx_online_sessions_server_started;index:idx_online_sessions_server_closed,priority:1;index:idx_online_sessions_server_open,priority:1" json:"server_id,omitempty"`
StartedAt time.Time `gorm:"index:idx_online_sessions_server_started;index:idx_online_sessions_server_open,priority:3" json:"started_at,omitempty"`
EndedAt time.Time `gorm:"index;index:idx_online_sessions_server_closed,priority:3" json:"ended_at,omitempty"` // 会话结束时刻,进行中为零值
// 上报侧节流持久化的最后活跃时刻(≥OnlineActiveWindow 间隔写一次)
LastActiveAt time.Time `json:"last_active_at,omitempty"`
Ongoing bool `gorm:"index:idx_online_sessions_server_closed,priority:2;index:idx_online_sessions_server_open,priority:2" json:"ongoing"`
}
// BeforeSave keeps SQLite's lexicographic timestamp comparisons chronological.
// Query parameters and map-based updates must use UTC as well.
func (s *OnlineSession) BeforeSave(_ *gorm.DB) error {
s.StartedAt = s.StartedAt.UTC()
s.EndedAt = s.EndedAt.UTC()
s.LastActiveAt = s.LastActiveAt.UTC()
return nil
}
// OnlinePeriod 是裁剪到查询窗口后的在线时间段,供前端时间轴直接渲染。
type OnlinePeriod struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
Ongoing bool `json:"ongoing"`
}
// DailyOnlineStat 是单个自然日(面板时区)在查询窗口内的在线秒数。
// 窗口首尾为非整日时,只统计与窗口重叠的部分。
type DailyOnlineStat struct {
Date string `json:"date"`
OnlineSeconds uint64 `json:"online_seconds"`
}
// OnlineHistoryResponse 是单台服务器在线时间段统计的聚合结果。
type OnlineHistoryResponse struct {
ServerID uint64 `json:"server_id"`
ServerName string `json:"server_name"`
Days int `json:"days"`
MaxDays int `json:"max_days"`
// Daily 拆日所用面板时区:IANA 名与窗口末端相对 UTC 的偏移秒数,
// 前端按同一时区渲染,保证跨时区访客的图形与数字一致
Timezone string `json:"timezone"`
TzOffset int `json:"tz_offset"`
WindowStart time.Time `json:"window_start"`
WindowEnd time.Time `json:"window_end"`
TotalOnlineSeconds uint64 `json:"total_online_seconds"`
OnlineRate float64 `json:"online_rate"`
CurrentlyOnline bool `json:"currently_online"`
// 从未上报过的服务器该值为 nil;指针避免把 Go 零值时间序列化成 0001-01-01
LastActive *time.Time `json:"last_active,omitempty"`
Periods []OnlinePeriod `json:"periods"`
Daily []DailyOnlineStat `json:"daily"`
}
// ClipOnlineSessions 把与窗口 [windowStart, windowEnd) 相交的会话裁剪成
// OnlinePeriod 列表。进行中的会话以 windowEnd 为右边界(保留 Ongoing 标记)。
// 输入需按 StartedAt 升序;输出与输入同序。
func ClipOnlineSessions(sessions []OnlineSession, windowStart, windowEnd time.Time) []OnlinePeriod {
periods := make([]OnlinePeriod, 0, len(sessions))
for _, s := range sessions {
start := s.StartedAt
if start.Before(windowStart) {
start = windowStart
}
end := s.EndedAt
ongoing := s.Ongoing || end.IsZero()
if ongoing || end.After(windowEnd) {
end = windowEnd
}
if !start.Before(end) {
// 与窗口零交集(理论上不会由查询条件选出)或空会话
continue
}
periods = append(periods, OnlinePeriod{Start: start, End: end, Ongoing: ongoing})
}
return periods
}
// DailyOnlineBreakdown 把在线时间段按面板时区的自然日拆分,返回窗口覆盖的
// 每一天的在线秒数(含在线为 0 秒的日期),按日期升序。
// 取整口径:每天先累计原始时长(日界切分不损失精度),仅在每日汇总时向下
// 取整一次;调用方以各日之和作为总时长,保证响应内部 total 与 daily[] 严格
// 对账,不与"逐段截断再累加"的口径混用(亚秒会话会让后者系统性偏小)。
// 时段二分定位起始日界后逐日推进,复杂度 O(P·log D + D);单服务器的会话
// 互不重叠,内层推进总量受窗口天数约束,长窗口(如 days=3650)下不会出现
// 时段数×天数的双重循环。
func DailyOnlineBreakdown(periods []OnlinePeriod, windowStart, windowEnd time.Time, loc *time.Location) []DailyOnlineStat {
first := windowStart.In(loc)
// Advance a timezone-independent calendar, not a normalized local midnight:
// midnight DST gaps can normalize into the previous day and repeat dates.
date := time.Date(first.Year(), first.Month(), first.Day(), 0, 0, 0, 0, time.UTC)
var days []DailyOnlineStat
var dayStarts, dayEnds []time.Time
for {
start := onlineCalendarBoundary(date, loc)
if !start.Before(windowEnd) {
break
}
nextDate := date.AddDate(0, 0, 1)
end := onlineCalendarBoundary(nextDate, loc)
if start.Before(end) {
dayStarts = append(dayStarts, start)
dayEnds = append(dayEnds, end)
days = append(days, DailyOnlineStat{Date: date.Format("2006-01-02")})
}
date = nextDate
}
if len(days) == 0 {
return days
}
dayDurations := make([]time.Duration, len(days))
for _, p := range periods {
// 该时段覆盖的第一天:不晚于时段开始的最后一个日界
i := sort.Search(len(dayStarts), func(i int) bool {
return dayStarts[i].After(p.Start)
}) - 1
if i < 0 {
i = 0
}
// 逐日推进,累加该时段与每一天 [dayStart, nextDay) 的交集
for ; i < len(dayStarts) && dayStarts[i].Before(p.End); i++ {
overlapStart := maxTime(p.Start, dayStarts[i])
overlapEnd := minTime(p.End, dayEnds[i])
if overlapStart.Before(overlapEnd) {
dayDurations[i] += overlapEnd.Sub(overlapStart)
}
}
}
for i := range days {
days[i].OnlineSeconds = uint64(dayDurations[i].Seconds())
}
return days
}
func maxTime(a, b time.Time) time.Time {
if a.After(b) {
return a
}
return b
}
func minTime(a, b time.Time) time.Time {
if a.Before(b) {
return a
}
return b
}
// A missing midnight (or an entirely skipped date) starts at the timezone
// transition, not at the previous date returned by time.Date normalization.
func onlineCalendarBoundary(date time.Time, loc *time.Location) time.Time {
boundary := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc)
if boundary.Format("2006-01-02") < date.Format("2006-01-02") {
_, end := boundary.ZoneBounds()
if !end.IsZero() {
return end
}
}
// time.Date may choose the second midnight in a rollback. Recover the
// earlier occurrence using the offset on the preceding side of the transition.
zoneStart, _ := boundary.ZoneBounds()
if !zoneStart.IsZero() {
_, currentOffset := boundary.Zone()
_, previousOffset := zoneStart.Add(-time.Nanosecond).Zone()
if previousOffset > currentOffset {
earlier := boundary.Add(-time.Duration(previousOffset-currentOffset) * time.Second)
if earlier.Format("2006-01-02 15:04:05") == boundary.Format("2006-01-02 15:04:05") {
boundary = earlier
}
}
}
return boundary
}
// OnlineHistoryWindowStart defines the shared query and retention calendar window.
func OnlineHistoryWindowStart(now time.Time, days int, loc *time.Location) time.Time {
// Calendar days follow the dashboard timezone, not the host timezone;
// DST days can contain 23 or 25 hours. Queries and pruning must agree.
if loc == nil {
loc = time.UTC
}
return now.In(loc).AddDate(0, 0, -days).UTC()
}
+220
View File
@@ -0,0 +1,220 @@
package model
import (
"math/rand"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var testZone = time.FixedZone("TEST", 8*3600)
func ts(y int, mo time.Month, d, h, mi, s int) time.Time {
return time.Date(y, mo, d, h, mi, s, 0, testZone)
}
func TestClipOnlineSessions(t *testing.T) {
windowStart := ts(2026, 10, 1, 0, 0, 0)
windowEnd := ts(2026, 10, 8, 0, 0, 0)
t.Run("session fully inside window", func(t *testing.T) {
sessions := []OnlineSession{
{ServerID: 1, StartedAt: ts(2026, 10, 2, 8, 0, 0), EndedAt: ts(2026, 10, 2, 9, 30, 0)},
}
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
require.Len(t, periods, 1)
require.Equal(t, ts(2026, 10, 2, 8, 0, 0), periods[0].Start)
require.Equal(t, ts(2026, 10, 2, 9, 30, 0), periods[0].End)
require.False(t, periods[0].Ongoing)
})
t.Run("session started before window is clipped", func(t *testing.T) {
sessions := []OnlineSession{
{ServerID: 1, StartedAt: ts(2026, 9, 28, 12, 0, 0), EndedAt: ts(2026, 10, 1, 5, 0, 0)},
}
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
require.Len(t, periods, 1)
require.Equal(t, windowStart, periods[0].Start)
require.Equal(t, ts(2026, 10, 1, 5, 0, 0), periods[0].End)
})
t.Run("ongoing session ends at window end", func(t *testing.T) {
sessions := []OnlineSession{
{ServerID: 1, StartedAt: ts(2026, 10, 7, 10, 0, 0), Ongoing: true},
}
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
require.Len(t, periods, 1)
require.Equal(t, windowEnd, periods[0].End)
require.True(t, periods[0].Ongoing)
})
t.Run("zero ended_at treated as ongoing", func(t *testing.T) {
sessions := []OnlineSession{
{ServerID: 1, StartedAt: ts(2026, 10, 7, 10, 0, 0)},
}
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
require.Len(t, periods, 1)
require.True(t, periods[0].Ongoing)
require.Equal(t, windowEnd, periods[0].End)
})
t.Run("session outside window yields no period", func(t *testing.T) {
sessions := []OnlineSession{
{ServerID: 1, StartedAt: ts(2026, 9, 1, 0, 0, 0), EndedAt: ts(2026, 9, 2, 0, 0, 0)},
}
require.Empty(t, ClipOnlineSessions(sessions, windowStart, windowEnd))
})
}
func TestDailyOnlineBreakdown(t *testing.T) {
loc := time.FixedZone("UTC+8", 8*3600)
windowStart := time.Date(2026, 10, 1, 10, 0, 0, 0, loc)
windowEnd := time.Date(2026, 10, 3, 14, 0, 0, 0, loc)
periods := []OnlinePeriod{
// 跨午夜:2 日 23:00 ~ 3 日 01:00
{Start: time.Date(2026, 10, 2, 23, 0, 0, 0, loc), End: time.Date(2026, 10, 3, 1, 0, 0, 0, loc)},
// 窗口前就开始的会话,裁剪后从窗口起点开始
{Start: windowStart, End: time.Date(2026, 10, 1, 12, 0, 0, 0, loc)},
}
daily := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
require.Len(t, daily, 3)
require.Equal(t, "2026-10-01", daily[0].Date)
require.Equal(t, "2026-10-02", daily[1].Date)
require.Equal(t, "2026-10-03", daily[2].Date)
// 1 日:12:00-10:00 = 2h;2 日:23:00-24:00 = 1h;3 日:0:00-1:00 + 窗口外无 = 1h
require.Equal(t, uint64(7200), daily[0].OnlineSeconds)
require.Equal(t, uint64(3600), daily[1].OnlineSeconds)
require.Equal(t, uint64(3600), daily[2].OnlineSeconds)
// 每日合计即总时长(口径:每日累计原始时长后统一取整)
var sum uint64
for _, d := range daily {
sum += d.OnlineSeconds
}
require.Equal(t, uint64(7200+3600+3600), sum)
}
// 评审回归:取整必须按日累计原始时长后统一转换。逐段截断再累加会让亚秒
// 会话系统性偏小——同一天两个 1.5s 的会话应得 3s,而不是 1+1=2s
func TestDailyOnlineBreakdownSubSecond(t *testing.T) {
loc := testZone
windowStart := ts(2026, 10, 1, 0, 0, 0)
windowEnd := ts(2026, 10, 2, 0, 0, 0)
periods := []OnlinePeriod{
{Start: windowStart.Add(100 * time.Millisecond), End: windowStart.Add(1600 * time.Millisecond)},
{Start: windowStart.Add(2 * time.Second), End: windowStart.Add(3500 * time.Millisecond)},
}
daily := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
require.Len(t, daily, 1)
require.Equal(t, uint64(3), daily[0].OnlineSeconds)
}
// 跨日时段按日界切分不损失精度:各日分别累计原始时长后各自取整
func TestDailyOnlineBreakdownCrossDayFraction(t *testing.T) {
loc := testZone
windowStart := ts(2026, 10, 1, 0, 0, 0)
windowEnd := ts(2026, 10, 3, 0, 0, 0)
periods := []OnlinePeriod{{
Start: time.Date(2026, 10, 1, 23, 59, 59, 250000000, loc),
End: time.Date(2026, 10, 2, 0, 0, 2, 750000000, loc),
}}
daily := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
require.Len(t, daily, 2)
require.Equal(t, uint64(0), daily[0].OnlineSeconds) // 0.75s → 0
require.Equal(t, uint64(2), daily[1].OnlineSeconds) // 2.75s → 2
}
func TestDailyOnlineBreakdownZeroOnline(t *testing.T) {
loc := time.FixedZone("UTC+8", 8*3600)
windowStart := time.Date(2026, 10, 1, 0, 0, 0, 0, loc)
windowEnd := time.Date(2026, 10, 4, 0, 0, 0, 0, loc)
daily := DailyOnlineBreakdown(nil, windowStart, windowEnd, loc)
require.Len(t, daily, 3)
for _, d := range daily {
require.Zero(t, d.OnlineSeconds)
}
}
func TestOnlineStatsDaysBounds(t *testing.T) {
require.Equal(t, 7, DefaultOnlineStatsDays)
require.Equal(t, 7, MinOnlineStatsDays)
require.Equal(t, 3650, MaxOnlineStatsDays)
}
// F1 回归保护:指针推进实现必须与逐日朴素实现对任意不相交时段集合等价
// (单服务器会话互不重叠的约束一致),长窗口大量时段下不再随时段数平方放大。
func TestDailyOnlineBreakdownEquivalence(t *testing.T) {
loc := testZone
windowStart := ts(2026, 1, 1, 0, 0, 0)
windowEnd := windowStart.AddDate(0, 0, 90)
rng := rand.New(rand.NewSource(42))
var periods []OnlinePeriod
cursor := windowStart
for cursor.Before(windowEnd) {
// 起止带亚秒抖动:让逐段截断与按日累计两种口径可区分
start := cursor.Add(time.Duration(rng.Intn(4*3600))*time.Second + time.Duration(rng.Int63n(int64(time.Second))))
end := start.Add(time.Duration(1+rng.Intn(6*3600))*time.Second + time.Duration(rng.Int63n(int64(time.Second))))
if end.After(windowEnd) {
end = windowEnd
}
periods = append(periods, OnlinePeriod{Start: start, End: end})
cursor = end.Add(time.Duration(1+rng.Intn(3600)) * time.Second)
}
days := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
// 参考实现与生产同一口径:每日累计原始时长、汇总时向下取整一次
reference := map[string]time.Duration{}
for _, p := range periods {
for d := windowStart; d.Before(windowEnd); d = d.AddDate(0, 0, 1) {
overlapStart := maxTime(p.Start, d)
overlapEnd := minTime(p.End, d.AddDate(0, 0, 1))
if overlapStart.Before(overlapEnd) {
reference[d.Format("2006-01-02")] += overlapEnd.Sub(overlapStart)
}
}
}
require.Len(t, days, 90)
for _, day := range days {
require.Equal(t, uint64(reference[day.Date].Seconds()), day.OnlineSeconds, "date %s", day.Date)
}
}
func TestDailyOnlineBreakdownMidnightDSTTransition(t *testing.T) {
loc, err := time.LoadLocation("America/Sao_Paulo")
require.NoError(t, err)
start := time.Date(2018, 11, 3, 0, 0, 0, 0, loc)
end := time.Date(2018, 11, 6, 0, 0, 0, 0, loc)
days := DailyOnlineBreakdown([]OnlinePeriod{{Start: start, End: end}}, start, end, loc)
require.Equal(t, []DailyOnlineStat{{Date: "2018-11-03", OnlineSeconds: 86400}, {Date: "2018-11-04", OnlineSeconds: 82800}, {Date: "2018-11-05", OnlineSeconds: 86400}}, days)
}
func TestDailyOnlineBreakdownRepeatedMidnight(t *testing.T) {
loc, err := time.LoadLocation("Asia/Amman")
require.NoError(t, err)
start := time.Date(2021, 10, 28, 21, 0, 0, 0, time.UTC)
end := time.Date(2021, 10, 29, 22, 0, 0, 0, time.UTC)
days := DailyOnlineBreakdown([]OnlinePeriod{{Start: start, End: end}}, start, end, loc)
require.Equal(t, []DailyOnlineStat{{Date: "2021-10-29", OnlineSeconds: 90000}}, days)
}
func TestOnlineHistoryWindowUsesDashboardCalendar(t *testing.T) {
loc, err := time.LoadLocation("America/New_York")
require.NoError(t, err)
for _, test := range []struct {
month time.Month
day, hours int
}{{time.March, 9, 23}, {time.November, 2, 25}} {
end := time.Date(2026, test.month, test.day, 0, 30, 0, 0, loc)
start := OnlineHistoryWindowStart(end.UTC(), 1, loc)
require.True(t, start.Equal(time.Date(2026, test.month, test.day-1, 0, 30, 0, 0, loc)))
require.Equal(t, time.Duration(test.hours)*time.Hour, end.Sub(start))
require.Len(t, DailyOnlineBreakdown(nil, start, end, loc), 2)
require.True(t, OnlineHistoryWindowStart(end.UTC(), 7, loc).Equal(end.In(loc).AddDate(0, 0, -7)))
}
}
+12 -3
View File
@@ -211,10 +211,16 @@ func (lease StateStreamLease) updateState(receiver *Server, state *HostState, la
}
func (lease StateStreamLease) Clear() bool {
return lease.clear(nil)
return lease.clear(nil, nil)
}
func (lease StateStreamLease) clear(receiver *Server) bool {
// ClearWithSideEffect serializes disconnect persistence with replacement streams.
// The callback must not acquire the runtime holder again.
func (lease StateStreamLease) ClearWithSideEffect(sideEffect func()) bool {
return lease.clear(nil, sideEffect)
}
func (lease StateStreamLease) clear(receiver *Server, sideEffect func()) bool {
if lease.holder == nil {
return false
}
@@ -226,6 +232,9 @@ func (lease StateStreamLease) clear(receiver *Server) bool {
lease.holder.stream = nil
lease.holder.lastActive = time.Time{}
lease.holder.canonical.LastActive = time.Time{}
if sideEffect != nil {
sideEffect()
}
return true
}
@@ -329,7 +338,7 @@ func (s *Server) UpdateStateIfCurrentWithSideEffect(lease StateStreamLease, stat
}
func (s *Server) ClearStateStreamIfCurrent(lease StateStreamLease) bool {
return lease.clear(s)
return lease.clear(s, nil)
}
// RuntimeSnapshot is a deep copy of the mutable runtime state.
+27
View File
@@ -367,3 +367,30 @@ func TestServerRuntimeOwnership_failedRestartAllowsHourlyRecordThenRetry(t *test
require.Equal(t, uint64(0), result.Transfer.In)
require.Equal(t, uint64(0), result.Transfer.Out)
}
func TestStateStreamDisconnectSideEffectSerializesReplacement(t *testing.T) {
server := &Server{}
old := server.AttachStateStream(runtimeOwnershipStream{})
entered := make(chan struct{})
release := make(chan struct{})
cleared := make(chan bool, 1)
go func() { cleared <- old.ClearWithSideEffect(func() { close(entered); <-release }) }()
<-entered
attached := make(chan StateStreamLease, 1)
go func() { attached <- server.AttachStateStream(runtimeOwnershipStream{}) }()
select {
case <-attached:
close(release)
t.Fatal("replacement attached before disconnect persistence finished")
case <-time.After(20 * time.Millisecond):
}
close(release)
require.True(t, <-cleared)
next := <-attached
require.True(t, next.UpdateState(&HostState{}, time.Now()))
called := false
require.False(t, old.ClearWithSideEffect(func() { called = true }))
require.False(t, called)
require.False(t, old.UpdateStateWithSideEffect(&HostState{}, time.Now(), func() error { called = true; return nil }))
require.False(t, called)
}
+2
View File
@@ -21,6 +21,8 @@ type SettingForm struct {
EnablePlainIPInNotification bool `json:"enable_plain_ip_in_notification,omitempty" validate:"optional"`
AllowJWTIPChange *bool `json:"allow_jwt_ip_change,omitempty" validate:"optional"`
EnableMCP *bool `json:"enable_mcp,omitempty" validate:"optional"`
OnlineStatsDays *int `json:"online_stats_days,omitempty" validate:"optional"` // 在线统计范围(天),nil=保留现值
}
type Setting struct {