+ 需要管理员权限 +
+此区域仅供站点管理员访问,如有疑问请联系站长。
+ + 返回首页 + +diff --git a/backend/handler/auth.go b/backend/handler/auth.go index 150ec29..29c1aa6 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -242,13 +242,21 @@ func (h *Handlers) Refresh(c *gin.Context) { } accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken, c.ClientIP(), c.Request.UserAgent()) if err != nil { - clearAuthCookies(c, !h.Cfg.DevMode) if errors.Is(err, service.ErrAccountBanned) { // 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线 + clearAuthCookies(c, !h.Cfg.DevMode) c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"}) return } - c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"}) + if errors.Is(err, service.ErrRefreshInvalid) || errors.Is(err, service.ErrRefreshExpired) { + // 登录态确实失效:清 cookie 要求重新登录(前端弹"登录已失效") + clearAuthCookies(c, !h.Cfg.DevMode) + c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"}) + return + } + // 其他错误(数据库抖动等):服务暂不可用。保留 cookie 等下次刷新, + // 前端按瞬态失败处理(不弹"登录已失效"、不强制重登) + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "登录刷新暂不可用,请稍后重试"}) return } setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode) diff --git a/backend/handler/post.go b/backend/handler/post.go index 9920117..25eef0e 100644 --- a/backend/handler/post.go +++ b/backend/handler/post.go @@ -8,6 +8,7 @@ import ( "net/url" "path/filepath" "strconv" + "strings" "time" "github.com/freefire/jiang13-bbs/middleware" @@ -16,13 +17,20 @@ import ( "github.com/gin-gonic/gin" ) +// maxSearchKeywordRunes 帖子搜索关键词长度上限(字符数,与前端搜索框 maxLength 一致) +const maxSearchKeywordRunes = 100 + // Posts 获取帖子列表 func (h *Handlers) Posts(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64) sort := c.DefaultQuery("sort", "latest") - keyword := c.Query("keyword") + keyword := strings.TrimSpace(c.Query("keyword")) + // 搜索词上限:前端输入框同限;超长词 ILIKE 必然无意义结果,静默截断 + if runes := []rune(keyword); len(runes) > maxSearchKeywordRunes { + keyword = string(runes[:maxSearchKeywordRunes]) + } recommended := c.Query("recommended") == "true" var viewerID uint diff --git a/backend/router/router.go b/backend/router/router.go index feaddfb..0e7fd6f 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -120,9 +120,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { } limiter := service.DefaultRateLimiter() - // 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期) - pipeCtx, pipeCancel := context.WithCancel(context.Background()) - defer pipeCancel() + // 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)。 + // 不用 WithCancel+defer:Setup 返回即 cancel 会让落库协程在启动阶段就退出,事件永远不落库 + pipeCtx := context.Background() go visitSvc.StartFlusher(pipeCtx) go visitSvc.RunRetention(pipeCtx) diff --git a/backend/service/auth.go b/backend/service/auth.go index 0c1bbb7..b4bd0ab 100644 --- a/backend/service/auth.go +++ b/backend/service/auth.go @@ -282,7 +282,9 @@ var ( var ( ErrRefreshInvalid = errors.New("refresh token 无效") ErrRefreshExpired = errors.New("refresh token 已过期") - ErrRefreshReused = errors.New("refresh token 存在重用嫌疑") + // errReplayChainDead 宽限期外重放时,token 所在轮转链已无有效头节点 + // (家族被整体吊销:设备剔除/超限淘汰/改密/封禁),属真实失效 + errReplayChainDead = errors.New("refresh token 轮转链已无有效会话") ) // hashRefreshToken refresh token 只以 SHA-256 形式落库 @@ -401,7 +403,8 @@ func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) { // - 正常:旧行吊销并链接到新行,返回新 access + 新 refresh // - 宽限期并发重放(RSC 预取/多实例几乎同时到达):返回【同一个】新 token 对, // 不再轮转,避免浏览器收到互相吊销的两套 cookie 而随机掉线 -// - 吊销后重放(被盗信号):撤销该用户全部 refresh token,强制重新登录 +// - 宽限期外重放(轮换响应丢失后浏览器补投旧 token):轮转链头节点仍健康时 +// 返回头节点 token 对自愈放行;整链已死才是真失效(见 healReplayFromHead) func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, string, *model.User, error) { var ( accessToken, newRefresh string @@ -412,6 +415,11 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin var rt model.RefreshToken if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("token_hash = ?", hashRefreshToken(oldToken)).First(&rt).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + // 数据库故障 ≠ 凭据无效:透传给上层按"服务暂不可用"处理, + // 不得据此清除用户 cookie(否则 DB 抖动会变成全员强制重登) + return err + } outErr = ErrRefreshInvalid return nil } @@ -441,17 +449,24 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin } } } - // 其他吊销后重放:判定为盗用,仅撤销该轮转链所在家族(OAuth BCP: - // 被盗的是这台设备的链,不牵连用户其余登录设备——否则开发期 - // middleware/浏览器丢轮换的竞态会把所有设备一齐踢下线) - fam := sessionFamilyID(rt) - if err := tx.Model(&model.RefreshToken{}). - Where("(family_id = ? OR id = ?) AND revoked = ?", fam, fam, false). - Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now}).Error; err != nil { - return err + // 宽限期外重放:真实浏览器里最常见成因不是盗用,而是轮换响应丢失 + // (RSC 预取被浏览器中止、移动端切后台挂起截断请求、middleware 超时 + // 放弃、网络抖动)——后端已完成轮转落库,但 Set-Cookie 没进浏览器 + // jar,旧 token 因此变成"已吊销"。若按盗用吊销整个家族,等价于把 + // 无辜设备强制下线("登录已失效"弹窗的主要来源)。故沿 rotated_to + // 链找头节点:仍健康则补发头节点 token 对自愈;整链已死才是真失效。 + at, plain, healedUser, healErr := s.healReplayFromHead(tx, &rt, now) + if healErr == nil { + accessToken, newRefresh, outUser = at, plain, healedUser + return nil } - log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 family=%d 的 refresh token", fam) - outErr = ErrRefreshReused + if !errors.Is(healErr, errReplayChainDead) { + // 封禁等账号级错误透传,让前端给出对应告知 + outErr = healErr + return nil + } + log.Printf("[auth] refresh 宽限期外重放且轮转链已失效,拒绝 family=%d", sessionFamilyID(rt)) + outErr = ErrRefreshInvalid return nil } @@ -510,6 +525,46 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin return accessToken, newRefresh, outUser, nil } +// healReplayFromHead 宽限期外的同链重放自愈:沿 rotated_to 轮转链走到头节点 +// (该会话家族当前生效的 refresh token),头节点仍健康则返回其 token 对—— +// 等价于把丢失的轮换响应补投给浏览器。链已断/头已死(家族被整体吊销)时 +// 返回 errReplayChainDead;用户被封禁时透传 ErrAccountBanned。 +// 安全取舍:放行重放意味着偷到旧 token 者也能追上当前会话,故仅在此处用 +// 可用性换安全(HttpOnly cookie 下旧 token 外泄门槛高,且改密/封禁/剔除 +// 设备仍可随时终止会话);头部吊销型处置(改密、封禁、剔除、超限淘汰) +// 之后链上无活头节点,自愈自然失效,不会被绕过。 +func (s *AuthService) healReplayFromHead(tx *gorm.DB, rt *model.RefreshToken, now time.Time) (string, string, *model.User, error) { + cur := *rt + // 迭代上限防御脏数据成环;正常链长即 7 天寿命内的轮转次数 + for i := 0; cur.Revoked && i < 128; i++ { + if cur.RotatedTo == 0 { + return "", "", nil, errReplayChainDead + } + var next model.RefreshToken + if err := tx.First(&next, cur.RotatedTo).Error; err != nil { + return "", "", nil, errReplayChainDead + } + cur = next + } + if cur.Revoked || cur.UserID != rt.UserID || now.After(cur.ExpiresAt) || cur.TokenCipher == "" { + return "", "", nil, errReplayChainDead + } + user, err := s.loadActiveUser(tx, cur.UserID) + if err != nil { + return "", "", nil, err + } + plain, err := s.decryptToken(cur.TokenCipher) + if err != nil { + return "", "", nil, errReplayChainDead + } + at, err := s.generateToken(user, sessionFamilyID(cur)) + if err != nil { + return "", "", nil, err + } + log.Printf("[auth] refresh 宽限期外重放,轮转链头节点仍健康,自愈放行 family=%d", sessionFamilyID(cur)) + return at, plain, user, nil +} + // RevokeRefreshToken 撤销单个 refresh token(单设备登出时用) func (s *AuthService) RevokeRefreshToken(token string) error { if token == "" { diff --git a/backend/service/login_session_test.go b/backend/service/login_session_test.go index f202a5a..3de600e 100644 --- a/backend/service/login_session_test.go +++ b/backend/service/login_session_test.go @@ -188,10 +188,10 @@ func TestRevokeLoginDeviceWithMultiSessions(t *testing.T) { } } -// TestRefreshReuseRevokesFamilyOnly 宽限期外的重放判定盗用时,仅吊销该轮转链 -// 所在家族,不牵连同用户其他登录设备(此前误撤全用户会话:开发期 middleware -// /浏览器丢轮换的竞态会把 Chrome 等所有设备一齐踢下线)。 -func TestRefreshReuseRevokesFamilyOnly(t *testing.T) { +// TestRefreshReplayBeyondGraceSelfHeals 宽限期外的同链重放:最常见成因是 +// 轮换响应丢失(RSC 预取被中止、移动端切后台挂起等),而非盗用。重放应补发 +// 轮转链头节点 token 对自愈,家族保持活跃,也不牵连同用户其他登录设备。 +func TestRefreshReplayBeyondGraceSelfHeals(t *testing.T) { db := newLoginSessionTestDB(t) s := NewAuthService(db, "test-secret") user := newLoginSessionUser(t, db, "dave") @@ -204,7 +204,7 @@ func TestRefreshReuseRevokesFamilyOnly(t *testing.T) { if err != nil { t.Fatalf("parse access 1: %v", err) } - access2, refresh2, _, _, err := s.Login("dave", "password123", "2.2.2.2", "iPhone-Mobile") + access2, _, _, _, err := s.Login("dave", "password123", "2.2.2.2", "iPhone-Mobile") if err != nil { t.Fatalf("login 2: %v", err) } @@ -213,8 +213,9 @@ func TestRefreshReuseRevokesFamilyOnly(t *testing.T) { t.Fatalf("parse access 2: %v", err) } - // 家族1 正常轮转一次:旧行吊销并链接到新行 - if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); err != nil { + // 家族1 正常轮转一次:旧行吊销并链接到新行,得到头节点 refresh2 + _, refresh2, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home") + if err != nil { t.Fatalf("rotate 1: %v", err) } @@ -225,21 +226,102 @@ func TestRefreshReuseRevokesFamilyOnly(t *testing.T) { t.Fatalf("backdate rotated_at: %v", err) } - // 宽限期外重放:判定盗用,仅家族1 被整体吊销 - if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); !errors.Is(err, ErrRefreshReused) { - t.Fatalf("期望 ErrRefreshReused,实际 %v", err) + // 宽限期外重放:自愈放行,返回头节点(当前代)token 对 + at, replayRefresh, replayUser, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home") + if err != nil { + t.Fatalf("宽限期外重放应自愈放行,实际 %v", err) } - if s.familySessionActive(user.ID, claims1.FamilyID) { - t.Fatal("重放所在家族应被整体吊销") + if replayRefresh != refresh2 { + t.Fatal("自愈应返回轮转链头节点的 refresh token") + } + if replayUser == nil || replayUser.ID != user.ID { + t.Fatal("自愈应返回所属用户") + } + if _, err := s.ValidateRefreshToken(refresh2); err != nil { + t.Fatalf("头节点 refresh 应保持有效: %v", err) + } + if !s.familySessionActive(user.ID, claims1.FamilyID) { + t.Fatal("重放所在家族应保持活跃,不被吊销") } if !s.familySessionActive(user.ID, claims2.FamilyID) { t.Fatal("其他登录设备(家族)不应被牵连") } - if n := countActiveSessions(t, db, user.ID); n != 1 { - t.Fatalf("应仅剩家族2 的 1 个活跃会话,实际 %d", n) + if n := countActiveSessions(t, db, user.ID); n != 2 { + t.Fatalf("两个家族应共 2 个活跃会话,实际 %d", n) } - if _, err := s.ValidateRefreshToken(refresh2); err != nil { - t.Fatalf("其他设备的 refresh 不应受影响: %v", err) + if claims, err := s.ParseToken(at); err != nil { + t.Fatalf("自愈签发的 access token 应可解析: %v", err) + } else if _, err := s.ValidateClaims(claims); err != nil { + t.Fatalf("自愈签发的 access token 应有效: %v", err) + } +} + +// TestRefreshReplayMultiHopHealsToHead 轮转链已前进多代后重放最早一代: +// 应沿 rotated_to 链走到头节点,补发最新一代 token 对。 +func TestRefreshReplayMultiHopHealsToHead(t *testing.T) { + db := newLoginSessionTestDB(t) + s := NewAuthService(db, "test-secret") + newLoginSessionUser(t, db, "frank") + + _, refresh1, _, _, err := s.Login("frank", "password123", "1.1.1.1", "Chrome-Home") + if err != nil { + t.Fatalf("login: %v", err) + } + _, refresh2, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home") + if err != nil { + t.Fatalf("rotate 1: %v", err) + } + _, refresh3, _, err := s.RotateRefreshToken(refresh2, "1.1.1.1", "Chrome-Home") + if err != nil { + t.Fatalf("rotate 2: %v", err) + } + + // 各代 rotated_at 均拨回宽限期之外,模拟浏览器长时间后才补投旧 token + if err := db.Model(&model.RefreshToken{}). + Where("token_hash IN ?", []string{hashRefreshToken(refresh1), hashRefreshToken(refresh2)}). + Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil { + t.Fatalf("backdate rotated_at: %v", err) + } + + _, replayRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home") + if err != nil { + t.Fatalf("多级链重放应自愈到头节点,实际 %v", err) + } + if replayRefresh != refresh3 { + t.Fatal("自愈应返回最新一代(头节点)的 refresh token") + } +} + +// TestRefreshReplayDeadChainRejected 轮转链已无有效头节点(家族被整体吊销: +// 设备剔除/超限淘汰/改密/封禁)时,宽限期外重放属真实失效,应拒绝并返回 +// ErrRefreshInvalid,且不得复活任何会话。 +func TestRefreshReplayDeadChainRejected(t *testing.T) { + db := newLoginSessionTestDB(t) + s := NewAuthService(db, "test-secret") + user := newLoginSessionUser(t, db, "grace") + + _, refresh1, _, _, err := s.Login("grace", "password123", "1.1.1.1", "Chrome-Home") + if err != nil { + t.Fatalf("login: %v", err) + } + if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); err != nil { + t.Fatalf("rotate: %v", err) + } + // 模拟改密/封禁/剔除设备类处置:家族整体吊销 + if err := s.RevokeAllUserRefreshTokens(user.ID); err != nil { + t.Fatalf("revoke all: %v", err) + } + if err := db.Model(&model.RefreshToken{}). + Where("token_hash = ?", hashRefreshToken(refresh1)). + Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil { + t.Fatalf("backdate rotated_at: %v", err) + } + + if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); !errors.Is(err, ErrRefreshInvalid) { + t.Fatalf("死链重放应返回 ErrRefreshInvalid,实际 %v", err) + } + if n := countActiveSessions(t, db, user.ID); n != 0 { + t.Fatalf("死链自愈不得复活会话,活跃会话应仍为 0,实际 %d", n) } } diff --git a/backend/service/visit_ref_test.go b/backend/service/visit_ref_test.go index 75afafd..ff8ced0 100644 --- a/backend/service/visit_ref_test.go +++ b/backend/service/visit_ref_test.go @@ -29,3 +29,26 @@ func TestClassifyReferrer(t *testing.T) { } } } + +func TestDecodeSearchTerm(t *testing.T) { + // 「新人报道」的 UTF-8 百分号编码前缀,模拟 512 字节截断 + news := "%E6%96%B0%E4%BA%BA%E6%8A%A5%E9%81%93" + cases := []struct { + raw string + want string + }{ + {"%E4%BD%A0%E5%A5%BD", "你好"}, // 完整编码 + {"a+b", "a b"}, // + 按空格解码 + {"plain", "plain"}, // 无编码 + {"2222%E5%A6%82%E6%9E%9C", "2222如果"}, // 完整转义结尾 + {news + "%E6%96", "新人报道"}, // 截断后结尾是完整转义但只是半截字符 → 去非法 UTF-8 尾巴 + {news + "%E6%96%9", "新人报道"}, // 一位十六进制的残缺转义 → 剥尾重试 + 去半截字符 + {news + "%", "新人报道"}, // 截断在孤立的 % + {news + "%E", "新人报道"}, // 截断在 1 位十六进制 + } + for _, c := range cases { + if got := decodeSearchTerm(c.raw); got != c.want { + t.Errorf("decodeSearchTerm(%q) = %q, want %q", c.raw, got, c.want) + } + } +} diff --git a/backend/service/visitstats_query.go b/backend/service/visitstats_query.go index ca12e98..61f2d7c 100644 --- a/backend/service/visitstats_query.go +++ b/backend/service/visitstats_query.go @@ -1,8 +1,11 @@ package service import ( + "net/url" "strconv" + "strings" "time" + "unicode/utf8" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" @@ -88,11 +91,11 @@ type VisitSourcesKindDay struct { // VisitSources 来源分析 Tab type VisitSources struct { - RangeDays int `json:"range_days"` - Kinds []VisitNamedCount `json:"kinds"` - SearchHost []VisitNamedCount `json:"search_hosts"` - External []VisitNamedCount `json:"external"` - Trend []VisitSourcesKindDay `json:"trend"` + RangeDays int `json:"range_days"` + Kinds []VisitNamedCount `json:"kinds"` + SearchTerms []VisitNamedCount `json:"search_terms"` // 站内搜索词 Top N(/?q= 结果页浏览) + External []VisitNamedCount `json:"external"` + Trend []VisitSourcesKindDay `json:"trend"` } // VisitPages 受访页面 Tab @@ -336,8 +339,7 @@ func (s *VisitStatsService) Sources(rangeQ string) (*VisitSources, error) { if out.Kinds, err = s.groupCounts(start, "ref_kind", model.VisitKindPageview, "", 8); err != nil { return nil, err } - if out.SearchHost, err = s.groupCounts(start, "ref_host", model.VisitKindPageview, - "ref_kind = '"+RefKindSearch+"'", 20); err != nil { + if out.SearchTerms, err = s.siteSearchTerms(start, 10); err != nil { return nil, err } if out.External, err = s.groupCounts(start, "ref_host", model.VisitKindPageview, @@ -350,6 +352,64 @@ func (s *VisitStatsService) Sources(rangeQ string) (*VisitSources, error) { return out, nil } +// siteSearchTerms 站内搜索词:聚合搜索结果页(/?q=…,path 记录完整 query)的浏览量。 +// 词以 URL 编码存于 path,按编码串分组后解码展示(站内 encodeURIComponent 编码确定性强)。 +func (s *VisitStatsService) siteSearchTerms(start time.Time, limit int) ([]VisitNamedCount, error) { + const expr = "substring(path from '[?&]q=([^&]*)')" + var rows []struct { + Key string + Count int64 + } + if err := s.db.Model(&model.VisitEvent{}). + Select(expr+" AS key, COUNT(*) AS count"). + Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview). + Where(expr + " IS NOT NULL AND " + expr + " <> ''"). + Group("key").Order("count DESC").Limit(limit). + Scan(&rows).Error; err != nil { + return nil, err + } + out := make([]VisitNamedCount, 0, len(rows)) + for _, r := range rows { + term := decodeSearchTerm(r.Key) + if runes := []rune(term); len(runes) > 30 { + term = string(runes[:30]) + "…" + } + out = append(out, VisitNamedCount{Key: term, Label: term, Count: r.Count}) + } + return out, nil +} + +// decodeSearchTerm 解码 path 里 q 参数值(URL 编码)。path 512 字节截断可能: +// 1) 把 %XX 转义拦腰切断(孤 % / 一位十六进制)→ 整串解码失败,剥掉残缺尾部重试; +// 2) 恰好切在多字节字符的转义序列中间 → 解码「成功」但尾部是半截字符,去掉非法 UTF-8 尾巴。 +func decodeSearchTerm(raw string) string { + term := strings.ReplaceAll(raw, "+", " ") + dec, err := url.QueryUnescape(term) + if err != nil { + // 残缺转义(如结尾孤 % 或一位十六进制):剥掉最后一个 % 起的尾部重试 + if i := strings.LastIndex(term, "%"); i > 0 { + dec, err = url.QueryUnescape(term[:i]) + } + } + if err != nil { + return term + } + return validUTF8Prefix(dec) +} + +// validUTF8Prefix 返回 s 的最长合法 UTF-8 前缀(截断产生的半截多字节字符会被去掉; +// 真实的 U+FFFD 字符合法保留,与非法字节可区分)。 +func validUTF8Prefix(s string) string { + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + if r == utf8.RuneError && size == 1 { + return s[:i] + } + i += size + } + return s +} + // SourcesTrend 来源按日明细(kind 分列行) func (s *VisitStatsService) SourcesTrend(rangeQ string) ([]VisitSourcesKindDay, error) { days := parseRangeDays(rangeQ) diff --git a/frontend/app/admin/AdminSessionRecover.tsx b/frontend/app/admin/AdminSessionRecover.tsx new file mode 100644 index 0000000..e0696aa --- /dev/null +++ b/frontend/app/admin/AdminSessionRecover.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Loader2, RefreshCw, ShieldAlert } from "lucide-react"; +import { apiMe, apiRefresh } from "@/lib/api"; +import { hasAuthCookieHint } from "@/lib/cookies"; + +/** + * 后台布局降级自愈:SSR 侧 /api/me 未识别出用户(access 过期轮转竞态、后端 + * 瞬断等)时布局只能渲染无壳的降级卡片。本组件挂载后走客户端链路复判—— + * apiMe 内部会在 401 时经 fetchWithRefresh 自动续期后重试: + * - 复判出用户 → router.refresh() 让服务端重出完整后台壳(顶栏/抽屉回归); + * - 仍是游客且凭据已被清理 → 会话确实失效,带回归地址去登录页; + * - 仍是游客但凭据还在(refresh 瞬态失败/后端暂不可用)→ 转入 offline 态, + * 有限次自动重试(间隔 5s),期间随时可手动重试/重新登录。 + * refresh 成功但服务端仍识别失败时最多重试 3 次,超出转入手动重试,避免循环刷新。 + */ +const MAX_AUTO_REFRESH = 3; +// offline 态自动重试次数(间隔 5s,共 50s):后端短暂抖动/重启场景无需用户手动干预 +const MAX_AUTO_RETRY = 10; +const AUTO_RETRY_DELAY_MS = 5000; + +export default function AdminSessionRecover() { + const router = useRouter(); + const [state, setState] = useState<"recovering" | "offline">("recovering"); + const autoRefreshLeft = useRef(MAX_AUTO_REFRESH); + const autoRetryLeft = useRef(MAX_AUTO_RETRY); + + const recover = useCallback(() => { + setState("recovering"); + apiMe() + .then(async (res) => { + if (res.user) { + if (autoRefreshLeft.current > 0) { + autoRefreshLeft.current -= 1; + router.refresh(); + return; + } + setState("offline"); + return; + } + // apiMe 内部的续期复判以 sessionSeen 为门(本页从未识别出用户时为 false, + // 不会触发),这里显式补一轮 refresh:access 竞态/瞬断多数在此恢复 + const back = encodeURIComponent(location.pathname + location.search); + if (hasAuthCookieHint()) { + const refreshed = await apiRefresh(); + if (refreshed.ok) { + const again = await apiMe(); + if (again.user) { + if (autoRefreshLeft.current > 0) { + autoRefreshLeft.current -= 1; + router.refresh(); + return; + } + setState("offline"); + return; + } + setState("offline"); + return; + } + if (refreshed.transient) { + // 后端暂不可用:无法证明登录态失效,留在原地可手动重试 + setState("offline"); + return; + } + // refresh 明确失效(后端同时下发清 cookie):会话确实终结,回跳登录 + router.replace(`/login?redirect=${back}`); + return; + } + // 无任何凭据痕迹:直接回跳登录 + router.replace(`/login?redirect=${back}`); + }) + .catch(() => setState("offline")); + }, [router]); + + useEffect(() => { + recover(); + }, [recover]); + + // offline 态有限次自动重试:后端短暂抖动/重启期间留在本页轮询恢复, + // 次数用尽后仅手动重试可再触发,避免无限轮询;手动重试会重置次数。 + useEffect(() => { + if (state !== "offline") return; + if (autoRetryLeft.current <= 0) return; + autoRetryLeft.current -= 1; + const t = window.setTimeout(recover, AUTO_RETRY_DELAY_MS); + return () => window.clearTimeout(t); + }, [state, recover]); + + const loginHref = + state === "offline" && typeof window !== "undefined" + ? `/login?redirect=${encodeURIComponent(location.pathname + location.search)}` + : "/login"; + + return ( +
+
暂时无法确认登录状态,请稍后重试。
++ + 返回首页 + +
+此区域仅供站点管理员访问,如有疑问请联系站长。
+ + 返回首页 + +此区域仅供站点管理员访问,如有疑问请联系站长。
- - 返回首页 - -