feat: 站点媒体库与移动端底部导航,服务测试补强

- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
2026-09-27 02:35:38 +08:00
parent 734afbc39f
commit 1d862a0dd3
61 changed files with 4757 additions and 1129 deletions

View File

@@ -0,0 +1,276 @@
package service
import (
"errors"
"testing"
"time"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func newLoginSessionTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.RefreshToken{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func newLoginSessionUser(t *testing.T, db *gorm.DB, username string) *model.User {
t.Helper()
hashed, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
user := model.User{Username: username, Password: string(hashed)}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
return &user
}
func countActiveSessions(t *testing.T, db *gorm.DB, userID uint) int64 {
t.Helper()
var n int64
if err := db.Model(&model.RefreshToken{}).
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).
Count(&n).Error; err != nil {
t.Fatalf("count active sessions: %v", err)
}
return n
}
// TestLoginMultiSessionCoexist 多会话并存:不同设备先后登录互不顶号,
// 各会话的 access token(family_id)均持续有效。
func TestLoginMultiSessionCoexist(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
user := newLoginSessionUser(t, db, "alice")
uas := []string{"Chrome-Windows", "Edge-Windows", "iPhone-Safari"}
families := make([]uint, 0, len(uas))
for i, ua := range uas {
access, refresh, _, kicked, err := s.Login("alice", "password123", "1.2.3.4", ua)
if err != nil {
t.Fatalf("login #%d: %v", i+1, err)
}
if kicked != 0 {
t.Fatalf("login #%d 不应踢掉旧会话,kicked=%d", i+1, kicked)
}
if _, err := s.ValidateRefreshToken(refresh); err != nil {
t.Fatalf("login #%d refresh 校验失败: %v", i+1, err)
}
claims, err := s.ParseToken(access)
if err != nil {
t.Fatalf("login #%d 解析 access token: %v", i+1, err)
}
if _, err := s.ValidateClaims(claims); err != nil {
t.Fatalf("login #%d 会话应有效: %v", i+1, err)
}
families = append(families, claims.FamilyID)
}
if n := countActiveSessions(t, db, user.ID); n != int64(len(uas)) {
t.Fatalf("应共存 %d 个活跃会话,实际 %d", len(uas), n)
}
for _, fam := range families {
if !s.familySessionActive(user.ID, fam) {
t.Fatalf("family=%d 会话应仍有效", fam)
}
}
}
// TestSessionCapEvictsOldest 会话上限守护:活跃会话超过 maxActiveSessions 时,
// 按 last_used_at 淘汰最旧会话,其余会话不受影响。
func TestSessionCapEvictsOldest(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
user := newLoginSessionUser(t, db, "bob")
families := make([]uint, 0, maxActiveSessions)
for i := 0; i < maxActiveSessions; i++ {
_, family, kicked, err := s.CreateRefreshToken(user.ID, "1.2.3.4", "device")
if err != nil {
t.Fatalf("create #%d: %v", i+1, err)
}
if kicked != 0 {
t.Fatalf("create #%d 不应淘汰会话,kicked=%d", i+1, kicked)
}
families = append(families, family)
}
// 人为拉开 last_used_at,保证淘汰顺序确定(第 1 个最旧)
base := time.Now().Add(-2 * time.Hour)
for i, fam := range families {
if err := db.Model(&model.RefreshToken{}).Where("family_id = ?", fam).
Update("last_used_at", base.Add(time.Duration(i)*time.Minute)).Error; err != nil {
t.Fatalf("调整 last_used_at: %v", err)
}
}
_, _, kicked, err := s.CreateRefreshToken(user.ID, "1.2.3.4", "device-new")
if err != nil {
t.Fatalf("create #%d: %v", maxActiveSessions+1, err)
}
if kicked != 1 {
t.Fatalf("超出上限应淘汰 1 个最旧会话,kicked=%d", kicked)
}
if n := countActiveSessions(t, db, user.ID); n != maxActiveSessions {
t.Fatalf("淘汰后应剩 %d 个活跃会话,实际 %d", maxActiveSessions, n)
}
if s.familySessionActive(user.ID, families[0]) {
t.Fatal("最旧会话应已被淘汰")
}
for _, fam := range families[1:] {
if !s.familySessionActive(user.ID, fam) {
t.Fatalf("family=%d 会话不应被淘汰", fam)
}
}
}
// TestRevokeLoginDeviceWithMultiSessions 多会话下剔除设备:仅目标会话失效,
// 当前会话不受影响(沿用既有剔除语义的回归保障)。
func TestRevokeLoginDeviceWithMultiSessions(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
user := newLoginSessionUser(t, db, "carol")
access1, refresh1, _, _, err := s.Login("carol", "password123", "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("login 1: %v", err)
}
claims1, err := s.ParseToken(access1)
if err != nil {
t.Fatalf("parse access 1: %v", err)
}
family1 := claims1.FamilyID
access2, _, _, _, err := s.Login("carol", "password123", "2.2.2.2", "iPhone-Mobile")
if err != nil {
t.Fatalf("login 2: %v", err)
}
claims2, err := s.ParseToken(access2)
if err != nil {
t.Fatalf("parse access 2: %v", err)
}
family2 := claims2.FamilyID
devices, err := s.ListLoginDevices(user.ID, family1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("list devices: %v", err)
}
if len(devices) != 2 {
t.Fatalf("应列出 2 台设备,实际 %d", len(devices))
}
var targetID uint
for _, d := range devices {
if !d.Current {
targetID = d.ID
}
}
if targetID == 0 {
t.Fatal("未找到非当前设备")
}
if err := s.RevokeLoginDevice(user.ID, family1, targetID); err != nil {
t.Fatalf("revoke device: %v", err)
}
if s.familySessionActive(user.ID, family2) {
t.Fatal("被剔除设备的会话应已失效")
}
if _, err := s.ValidateRefreshToken(refresh1); err != nil {
t.Fatalf("当前设备会话不应受影响: %v", err)
}
}
// TestRefreshReuseRevokesFamilyOnly 宽限期外的重放判定盗用时,仅吊销该轮转链
// 所在家族,不牵连同用户其他登录设备(此前误撤全用户会话:开发期 middleware
// /浏览器丢轮换的竞态会把 Chrome 等所有设备一齐踢下线)。
func TestRefreshReuseRevokesFamilyOnly(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
user := newLoginSessionUser(t, db, "dave")
access1, refresh1, _, _, err := s.Login("dave", "password123", "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("login 1: %v", err)
}
claims1, err := s.ParseToken(access1)
if err != nil {
t.Fatalf("parse access 1: %v", err)
}
access2, refresh2, _, _, err := s.Login("dave", "password123", "2.2.2.2", "iPhone-Mobile")
if err != nil {
t.Fatalf("login 2: %v", err)
}
claims2, err := s.ParseToken(access2)
if err != nil {
t.Fatalf("parse access 2: %v", err)
}
// 家族1 正常轮转一次:旧行吊销并链接到新行
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); err != nil {
t.Fatalf("rotate 1: %v", err)
}
// 把旧行 rotated_at 拨回宽限期之外,模拟「轮换响应丢失后的延迟重放」
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)
}
// 宽限期外重放:判定盗用,仅家族1 被整体吊销
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); !errors.Is(err, ErrRefreshReused) {
t.Fatalf("期望 ErrRefreshReused,实际 %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 _, err := s.ValidateRefreshToken(refresh2); err != nil {
t.Fatalf("其他设备的 refresh 不应受影响: %v", err)
}
}
// TestRefreshReuseDevGraceReturnsSamePair dev 模式宽限期放宽:轮换后远超
// 生产宽限窗的同链重放(dev 重启丢响应的常态)仍返回同一对,不升级为盗用判定。
func TestRefreshReuseDevGraceReturnsSamePair(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret").WithDevMode(true)
newLoginSessionUser(t, db, "eve")
_, refresh1, _, _, err := s.Login("eve", "password123", "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("login: %v", err)
}
_, newRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("rotate: %v", err)
}
// rotated_at 拨回生产宽限期之外(仍在 dev 宽限内)
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)
}
_, replayRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("dev 宽限期内重放不应报错: %v", err)
}
if replayRefresh != newRefresh {
t.Fatal("dev 宽限期内重放应返回同一对新 token")
}
}