Compare commits
2 Commits
5a8b0ca79c
...
37c83d4c12
| Author | SHA1 | Date | |
|---|---|---|---|
| 37c83d4c12 | |||
| 12de8be0cf |
@ -29,9 +29,15 @@ func (ac *AdminController) Dashboard(c *gin.Context) {
|
||||
if err != nil {
|
||||
totalUsers = 0
|
||||
}
|
||||
storeMetrics := ac.adminService.GetStoreMetrics()
|
||||
c.HTML(http.StatusOK, "admin/dashboard/index.html", common.BuildAdminPageData(c, gin.H{
|
||||
"Title": "管理首页",
|
||||
"TotalUsers": totalUsers,
|
||||
"Title": "管理首页",
|
||||
"TotalUsers": totalUsers,
|
||||
"StoreType": storeMetrics.Type,
|
||||
"StoreHealthy": storeMetrics.Healthy,
|
||||
"StoreDegraded": storeMetrics.Degraded,
|
||||
"DegradedNote": storeMetrics.DegradedNote,
|
||||
"ActiveSessions": storeMetrics.ActiveCount,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -3,14 +3,16 @@ package admin
|
||||
import (
|
||||
"metazone.cc/metalab/internal/model"
|
||||
"metazone.cc/metalab/internal/service"
|
||||
"metazone.cc/metalab/internal/session"
|
||||
)
|
||||
|
||||
// adminUseCase AdminController 对 AdminService 的最小依赖(ISP:4 个方法)
|
||||
// adminUseCase AdminController 对 AdminService 的最小依赖(ISP:5 个方法)
|
||||
type adminUseCase interface {
|
||||
ListUsers(params service.ListUsersParams) (*service.ListUsersResult, error)
|
||||
UpdateUserStatus(operatorUID, targetUID uint, newStatus string) error
|
||||
ResetUserToken(operatorUID, targetUID uint) error
|
||||
CountUsers() (int64, error)
|
||||
GetStoreMetrics() session.StoreMetrics
|
||||
}
|
||||
|
||||
// auditUseCase AuditController 对 AuditService 的最小依赖(ISP:3 个方法)
|
||||
|
||||
@ -112,6 +112,11 @@ func (s *AdminService) CountUsers() (int64, error) {
|
||||
return s.userRepo.CountSearchUsers("", "", "")
|
||||
}
|
||||
|
||||
// GetStoreMetrics 返回会话存储状态信息(管理首页用)
|
||||
func (s *AdminService) GetStoreMetrics() session.StoreMetrics {
|
||||
return s.sessionManager.GetStoreMetrics()
|
||||
}
|
||||
|
||||
// checkAndOperate 通用权限检查 + 执行操作
|
||||
func (s *AdminService) checkAndOperate(operatorUID, targetUID uint, operate func(*model.User, *model.User) error) error {
|
||||
// 1. 不可操作自己
|
||||
|
||||
@ -2,6 +2,8 @@ package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@ -11,19 +13,20 @@ import (
|
||||
|
||||
// FallbackStore 会话存储降级保护层
|
||||
// Redis 正常时委托给 RedisStore,异常时自动降级到 MemoryStore
|
||||
// 后台健康检测 goroutine 周期性检测 Redis 状态并自动恢复
|
||||
// Redis 恢复时自动将降级期间的内存会话静默迁移回 Redis,用户无感知
|
||||
type FallbackStore struct {
|
||||
redis *RedisStore
|
||||
memory Store
|
||||
client *redis.Client
|
||||
redis *RedisStore
|
||||
memory *MemoryStore
|
||||
client *redis.Client
|
||||
|
||||
healthy atomic.Bool // Redis 当前是否健康
|
||||
degraded atomic.Bool // 是否处于降级模式(曾因异常而切换到 Memory)
|
||||
migrating atomic.Bool // 是否正在执行迁移(避免并发迁移)
|
||||
}
|
||||
|
||||
// NewFallbackStore 创建降级保护存储
|
||||
// checkInterval: 健康检测间隔,建议与 session cleanup_interval 一致
|
||||
func NewFallbackStore(redis *RedisStore, memory Store, checkInterval time.Duration) *FallbackStore {
|
||||
func NewFallbackStore(redis *RedisStore, memory *MemoryStore, checkInterval time.Duration) *FallbackStore {
|
||||
fs := &FallbackStore{
|
||||
redis: redis,
|
||||
memory: memory,
|
||||
@ -34,11 +37,11 @@ func NewFallbackStore(redis *RedisStore, memory Store, checkInterval time.Durati
|
||||
// 后台健康检测 goroutine
|
||||
go fs.healthLoop(checkInterval)
|
||||
|
||||
log.Println("[FallbackStore] 已启动 Redis 健康检测")
|
||||
log.Println("[FallbackStore] 已启动 Redis 健康检测(含自动迁移)")
|
||||
return fs
|
||||
}
|
||||
|
||||
// healthLoop 定期 ping Redis,自动升降级
|
||||
// healthLoop 定期 ping Redis,自动升降级 + 静默迁移
|
||||
func (fs *FallbackStore) healthLoop(interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
@ -46,20 +49,62 @@ func (fs *FallbackStore) healthLoop(interval time.Duration) {
|
||||
for range ticker.C {
|
||||
prev := fs.healthy.Load()
|
||||
ok := fs.ping()
|
||||
fs.healthy.Store(ok)
|
||||
|
||||
if ok && !prev {
|
||||
// Redis 恢复
|
||||
// Redis 恢复:先迁移内存会话,再切回 Redis
|
||||
fs.migrateSessions()
|
||||
fs.healthy.Store(true)
|
||||
fs.degraded.Store(false)
|
||||
log.Println("[FallbackStore] Redis 已恢复,切回 Redis 存储")
|
||||
} else if !ok && prev {
|
||||
// Redis 掉线,标记降级
|
||||
fs.healthy.Store(false)
|
||||
fs.degraded.Store(true)
|
||||
log.Println("[FallbackStore] Redis 不可用,已降级为内存存储")
|
||||
} else {
|
||||
fs.healthy.Store(ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// migrateSessions 将降级期间内存中的活跃会话同步到 Redis
|
||||
func (fs *FallbackStore) migrateSessions() {
|
||||
if !fs.migrating.CompareAndSwap(false, true) {
|
||||
return // 已有迁移在进行中
|
||||
}
|
||||
defer fs.migrating.Store(false)
|
||||
|
||||
sessions := fs.memory.Snapshot()
|
||||
if len(sessions) == 0 {
|
||||
log.Println("[FallbackStore] 无需迁移(降级期间无活跃会话)")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pipe := fs.client.Pipeline()
|
||||
|
||||
for _, s := range sessions {
|
||||
data, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
log.Printf("[FallbackStore] 迁移序列化失败 sid=%s err=%v", s.ID[:16]+"...", err)
|
||||
continue
|
||||
}
|
||||
ttl := fs.redis.idleTimeout
|
||||
if s.RememberMe {
|
||||
ttl = fs.redis.rememberTimeout
|
||||
}
|
||||
pipe.Set(ctx, keyPrefix+s.ID, data, ttl)
|
||||
pipe.SAdd(ctx, userKeyPrefix+fmt.Sprint(s.UserID), s.ID)
|
||||
}
|
||||
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[FallbackStore] 迁移到 Redis 失败: %v,保持内存模式", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[FallbackStore] Redis 已恢复,静默迁移 %d 条会话", len(sessions))
|
||||
}
|
||||
|
||||
// ping 检查 Redis 连接状态(带超时)
|
||||
func (fs *FallbackStore) ping() bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
|
||||
@ -211,3 +211,23 @@ func (ms *MemoryStore) Metrics() StoreMetrics {
|
||||
ActiveCount: count,
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot 返回所有活跃会话的副本(用于 Redis 恢复时迁移)
|
||||
func (ms *MemoryStore) Snapshot() []*Session {
|
||||
ms.mu.RLock()
|
||||
defer ms.mu.RUnlock()
|
||||
|
||||
var active []*Session
|
||||
for _, s := range ms.sessions {
|
||||
timeout := ms.idleTimeout
|
||||
if s.RememberMe {
|
||||
timeout = ms.rememberTimeout
|
||||
}
|
||||
if !s.IsExpired(timeout) {
|
||||
// 返回副本,避免外部修改内部 map
|
||||
copyS := *s
|
||||
active = append(active, ©S)
|
||||
}
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
@ -19,6 +19,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid" style="margin-top: 1rem;">
|
||||
<div class="stat-card" style="grid-column: span 2;">
|
||||
<div class="stat-icon {{if .StoreDegraded}}stat-red{{else if eq .StoreType "redis"}}stat-green{{else}}stat-blue{{end}}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">会话存储后端</div>
|
||||
<div class="stat-value">
|
||||
{{if eq .StoreType "redis"}}Redis{{else}}内存{{end}}
|
||||
{{if .StoreDegraded}}<span class="stat-badge badge-warn">已降级</span>{{end}}
|
||||
</div>
|
||||
{{if .DegradedNote}}<div class="stat-desc">{{.DegradedNote}}</div>{{end}}
|
||||
{{if ge .ActiveSessions 0}}<div class="stat-desc">活跃会话数:{{.ActiveSessions}}</div>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>快速开始</h3>
|
||||
<ul>
|
||||
|
||||
@ -138,8 +138,12 @@ a:hover { color: var(--primary-hover); }
|
||||
.stat-blue { background: #e3f2fd; color: var(--primary); }
|
||||
.stat-green { background: #e8f5e9; color: var(--success); }
|
||||
.stat-orange { background: #fff3e0; color: var(--warning); }
|
||||
.stat-red { background: #ffebee; color: var(--danger, #e53935); }
|
||||
.stat-label { font-size: 12px; color: var(--text-muted); }
|
||||
.stat-value { font-size: 22px; font-weight: 600; color: var(--text); }
|
||||
.stat-desc { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||||
.stat-badge { display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 10px; margin-left: 8px; vertical-align: middle; }
|
||||
.badge-warn { background: #fff3e0; color: var(--warning); }
|
||||
|
||||
/* --- 信息卡片 --- */
|
||||
.info-card {
|
||||
|
||||
Reference in New Issue
Block a user