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

@@ -38,11 +38,12 @@ var (
adImageUploadRe = regexp.MustCompile(`(?i)^/uploads/(ads|images|brand)/[0-9a-f]{32}\.(png|jpe?g|gif|webp)$`)
)
// AdDurationOption 可购时长档位
// AdDurationOption 可购时长档位(图片 / 文字广告分别计价)
type AdDurationOption struct {
Days int `json:"days"`
Label string `json:"label"`
PriceHint string `json:"price_hint"`
Days int `json:"days"`
Label string `json:"label"`
PriceHint string `json:"price_hint"` // 图片广告价格
PriceHintText string `json:"price_hint_text"` // 文字广告价格
}
// AdPaymentOption 收款方式(展示二维码,人工确认)
@@ -68,11 +69,11 @@ func DefaultAdConfig() AdConfig {
Enabled: true,
PanelTitle: AdDefaultTitle,
Durations: []AdDurationOption{
{Days: 30, Label: "1 个月", PriceHint: ""},
{Days: 60, Label: "2 个月", PriceHint: ""},
{Days: 90, Label: "3 个月", PriceHint: ""},
{Days: 180, Label: "6 个月", PriceHint: ""},
{Days: 365, Label: "12 个月", PriceHint: ""},
{Days: 30, Label: "1 个月", PriceHint: "", PriceHintText: ""},
{Days: 60, Label: "2 个月", PriceHint: "", PriceHintText: ""},
{Days: 90, Label: "3 个月", PriceHint: "", PriceHintText: ""},
{Days: 180, Label: "6 个月", PriceHint: "", PriceHintText: ""},
{Days: 365, Label: "12 个月", PriceHint: "", PriceHintText: ""},
},
Payments: []AdPaymentOption{},
}
@@ -116,6 +117,9 @@ func (s *AdService) loadConfig() (AdConfig, error) {
if strings.TrimSpace(cfg.Durations[i].PriceHint) == "面议" {
cfg.Durations[i].PriceHint = ""
}
if strings.TrimSpace(cfg.Durations[i].PriceHintText) == "面议" {
cfg.Durations[i].PriceHintText = ""
}
}
return cfg, nil
}
@@ -195,7 +199,14 @@ func normalizeAdConfig(in AdConfig) (AdConfig, error) {
if utf8.RuneCountInString(price) > 32 {
return AdConfig{}, ErrAdInvalid
}
durs = append(durs, AdDurationOption{Days: d.Days, Label: label, PriceHint: price})
priceText := strings.TrimSpace(d.PriceHintText)
if priceText == "面议" {
priceText = ""
}
if utf8.RuneCountInString(priceText) > 32 {
return AdConfig{}, ErrAdInvalid
}
durs = append(durs, AdDurationOption{Days: d.Days, Label: label, PriceHint: price, PriceHintText: priceText})
}
out.Durations = durs

View File

@@ -73,6 +73,7 @@ type AuthService struct {
db *gorm.DB
jwtSecret []byte
encKey []byte // refresh token 落库密文的 AES-GCM 密钥(由 JWT 密钥派生)
devMode bool
}
func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
@@ -81,6 +82,21 @@ func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
return &AuthService{db: db, jwtSecret: []byte(jwtSecret), encKey: keySum[:]}
}
// WithDevMode dev 模式放宽轮转宽限期:开发中 go run / next dev 重启频繁,
// 轮换响应丢失后的重放往往远超 60s 才回来,同链重放不应升级为盗用判定
func (s *AuthService) WithDevMode(dev bool) *AuthService {
s.devMode = dev
return s
}
// rotationGrace 轮转宽限期:dev 放宽(见 RefreshRotationGrace 注释)
func (s *AuthService) rotationGrace() time.Duration {
if s.devMode {
return 15 * time.Minute
}
return RefreshRotationGrace
}
// dummyPasswordHash 用户不存在时也执行一次 bcrypt 比较,避免通过响应耗时枚举用户名
var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
[]byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost)
@@ -133,7 +149,8 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
}
// Login 用户登录,返回 access token + refresh token + user;
// kicked 为本次登录顶掉的旧会话数(真·单会话:任何新登录踢掉所有旧设备;>0 供上层广播告知)
// kicked 为本次登录因超出会话上限被淘汰的最旧会话数(多会话并存:
// 新登录不踢旧设备,仅超过 maxActiveSessions 时裁剪;>0 供上层广播告知)
func (s *AuthService) Login(username, password, ip, ua string) (string, string, *model.User, int, error) {
var user model.User
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
@@ -317,7 +334,8 @@ func (s *AuthService) decryptToken(encoded string) (string, error) {
}
// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文 + 设备信息)。
// 返回值 kicked 为被顶掉的旧会话行数(真·单会话:踢掉所有旧设备)。
// 多会话并存:各设备登录互不影响,新会话构成独立的轮转家族(family_id);
// 返回值 kicked 为因超出会话上限被淘汰的最旧会话行数。
func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, uint, int, error) {
token := generateRandomToken()
cipherText, err := s.encryptToken(token)
@@ -342,7 +360,7 @@ func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, ui
return "", 0, 0, err
}
rt.FamilyID = rt.ID
kicked := s.revokeAllSessionsExcept(userID, rt.FamilyID)
kicked := s.enforceSessionCap(userID)
return token, rt.FamilyID, kicked, nil
}
@@ -410,7 +428,7 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
if rt.Revoked {
// 宽限期内、且轮转链指向的新 token 仍健康:并发重放,返回同一对
if rt.RotatedTo != 0 && rt.RotatedAt != nil && now.Sub(*rt.RotatedAt) <= RefreshRotationGrace {
if rt.RotatedTo != 0 && rt.RotatedAt != nil && now.Sub(*rt.RotatedAt) <= s.rotationGrace() {
var next model.RefreshToken
if e := tx.First(&next, rt.RotatedTo).Error; e == nil &&
next.UserID == rt.UserID && !next.Revoked && now.Before(next.ExpiresAt) &&
@@ -423,13 +441,16 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
}
}
}
// 其他吊销后重放:判定为盗用,撤销该用户整个 refresh token 家族
// 其他吊销后重放:判定为盗用,仅撤销该轮转链所在家族(OAuth BCP:
// 被盗的是这台设备的链,不牵连用户其余登录设备——否则开发期
// middleware/浏览器丢轮换的竞态会把所有设备一齐踢下线)
fam := sessionFamilyID(rt)
if err := tx.Model(&model.RefreshToken{}).
Where("user_id = ? AND revoked = ?", rt.UserID, false).
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
}
log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 user=%d 的全部 refresh token", rt.UserID)
log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 family=%d 的 refresh token", fam)
outErr = ErrRefreshReused
return nil
}

View File

@@ -24,12 +24,14 @@ type BoardWithCount struct {
PostCount int64 `json:"post_count"`
}
// BoardModerator 板块管理员公开资料
// BoardModerator 板块管理员公开资料(含等级与颁发的徽章)
type BoardModerator struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Level int `json:"level"` // AfterFind 按 TotalPoints 计算
Badges []model.UserBadge `json:"badges,omitempty"` // 管理员颁发的徽章(Preload("Badges.Badge") 填充)
}
// BoardSidebarStats 板级统计(不含在线)
@@ -432,13 +434,14 @@ func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) {
return nil, err
}
// 板块管理员:仅 board_admin 且授权本板、未封禁
// 板块管理员:仅 board_admin 且授权本板、未封禁(等级由 AfterFind 算,徽章随 Preload 带)
var mods []model.User
if err := s.db.Table("users").
Select("users.id, users.username, users.nickname, users.avatar").
if err := s.db.Model(&model.User{}).
Select("users.id, users.username, users.nickname, users.avatar, users.total_points").
Joins("JOIN user_boards ON user_boards.user_id = users.id AND user_boards.board_id = ?", boardID).
Where("users.role = ? AND users.banned = ? AND users.deleted_at IS NULL", model.RoleBoardAdmin, false).
Order("users.id ASC").
Preload("Badges.Badge").
Find(&mods).Error; err != nil {
return nil, err
}
@@ -448,6 +451,8 @@ func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) {
Username: u.Username,
Nickname: u.Nickname,
Avatar: u.Avatar,
Level: u.Level,
Badges: u.Badges,
})
}

View File

@@ -0,0 +1,69 @@
package service
import (
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
// 板块右栏聚合:管理员应携带等级(AfterFind 按累计积分计算)与颁发的徽章
func TestBoardSidebarModeratorsCarryLevelAndBadges(t *testing.T) {
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.Board{}, &model.UserBoard{},
&model.Post{}, &model.Comment{}, &model.Badge{}, &model.UserBadge{}); err != nil {
t.Fatalf("migrate: %v", err)
}
board := model.Board{Name: "综合讨论"}
if err := db.Create(&board).Error; err != nil {
t.Fatalf("seed board: %v", err)
}
// 高积分管理员 + 低积分管理员 + 普通用户(不入列)
modHigh := model.User{Username: "mod_high", Password: "x", Role: model.RoleBoardAdmin, TotalPoints: 10000}
modLow := model.User{Username: "mod_low", Password: "x", Role: model.RoleBoardAdmin}
plain := model.User{Username: "plain", Password: "x"}
for _, u := range []*model.User{&modHigh, &modLow, &plain} {
if err := db.Create(u).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
}
for _, uid := range []uint{modHigh.ID, modLow.ID, plain.ID} {
if err := db.Create(&model.UserBoard{UserID: uid, BoardID: board.ID}).Error; err != nil {
t.Fatalf("seed user_board: %v", err)
}
}
badge := model.Badge{Name: "骨灰玩家", Icon: "medal", Color: "gold"}
if err := db.Create(&badge).Error; err != nil {
t.Fatalf("seed badge: %v", err)
}
if err := db.Create(&model.UserBadge{BadgeID: badge.ID, UserID: modHigh.ID, AwardedBy: 1}).Error; err != nil {
t.Fatalf("seed user_badge: %v", err)
}
s := NewBoardService(db)
data, err := s.Sidebar(board.ID)
if err != nil {
t.Fatalf("Sidebar: %v", err)
}
if len(data.Moderators) != 2 {
t.Fatalf("应只含 2 名 board_admin,got %d", len(data.Moderators))
}
first := data.Moderators[0]
if first.Username != "mod_high" {
t.Fatalf("排序错位: %+v", first)
}
if first.Level <= 1 {
t.Fatalf("高积分管理员等级应 >1,got %d", first.Level)
}
if len(first.Badges) != 1 || first.Badges[0].Badge.Name != "骨灰玩家" {
t.Fatalf("徽章未随行加载: %+v", first.Badges)
}
if data.Moderators[1].Level != 1 || len(data.Moderators[1].Badges) != 0 {
t.Fatalf("低积分管理员应为 Lv.1 无徽章: %+v", data.Moderators[1])
}
}

View File

@@ -397,7 +397,9 @@ func (s *ChatService) GetOrCreateDM(meID, peerID uint) (*RoomView, error) {
var existing model.ChatRoom
err := s.db.Where("room_type = ? AND direct_key = ?", model.ChatRoomTypeDirect, key).First(&existing).Error
if err == nil {
_ = s.ensureMember(existing.ID, meID)
// 关闭私聊会硬删成员行、丢失已读水位;主动重开视为已读历史,
// 否则重建行 last_read=0 会把全部历史消息重算成未读(列表徽标闪现)
_ = s.ensureMember(existing.ID, meID, existing.LastMessageID)
_ = s.ensureMember(existing.ID, peerID)
return s.RoomDetail(meID, existing.ID, false)
}
@@ -431,7 +433,8 @@ func (s *ChatService) GetOrCreateDM(meID, peerID uint) (*RoomView, error) {
return s.RoomDetail(meID, room.ID, false)
}
func (s *ChatService) ensureMember(roomID, userID uint) error {
// ensureMember 保证成员行存在;重建(如私聊关闭后重开)时可选恢复已读水位
func (s *ChatService) ensureMember(roomID, userID uint, readWatermark ...uint) error {
_, err := s.membership(s.db, roomID, userID)
if err == nil {
return nil
@@ -440,6 +443,9 @@ func (s *ChatService) ensureMember(roomID, userID uint) error {
return err
}
m := model.ChatRoomMember{RoomID: roomID, UserID: userID, Role: model.ChatRoleMember}
if len(readWatermark) > 0 {
m.LastReadMessageID = readWatermark[0]
}
if e := s.db.Create(&m).Error; e != nil {
return e
}

View File

@@ -8,6 +8,8 @@ import (
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -20,53 +22,110 @@ import (
"github.com/freefire/jiang13-bbs/model"
)
// 旧站(jiang13-forum,SQLite)数据导入:管理后台上传旧库 + 可选头像包,
// 旧站(jiang13-forum,SQLite)数据导入:管理后台上传旧库 + 可选头像包 / 帖子图片包,
// 导入用户账号(用户名 / bcrypt 密码 / 昵称 / 签名 / 邮箱 / 头像)与板块、帖子、评论。
// 两站密码同为 bcrypt,哈希原样复制,老用户用原密码即可登录。
// 支持预检(dry-run,不写库);ImportRecord 去重保证幂等可重复导入。
// 支持预检(dry-run,不写库,返回用户 / 帖子 / 评论清单供勾选);ImportRecord 去重保证幂等可重复导入。
// 支持选择性导入:按旧用户勾选是否建号、内容归属(指定已有账号 / 站长)、按帖子 / 评论排除。
// 帖子图片:正文引用的旧站 /uploads/posts/<名> 从图片包落盘到新站 uploads/images 并改写 URL。
// 来源参数化(source),为 WordPress / Typecho 等外部数据源预留扩展位。
const (
// LegacyDBMaxBytes 旧库上传上限(实测旧站整库约 2MB,留足余量)
LegacyDBMaxBytes = 64 << 20
// LegacyZipMaxBytes 头像压缩包上传上限
// LegacyZipMaxBytes 头像 / 帖子图片压缩包上传上限
LegacyZipMaxBytes = 128 << 20
// legacyAvatarFileMax 单个头像解压上限
legacyAvatarFileMax = 8 << 20
// legacyImageFileMax 单张帖子图片解压上限
legacyImageFileMax = 20 << 20
// legacySignatureMaxRunes 新站 Signature 列上限
legacySignatureMaxRunes = 255
// legacyEmailMaxRunes 新站 Email 列上限
legacyEmailMaxRunes = 128
// legacyAvatarPrefix 旧站头像 URL 约定前缀(与旧站静态服务规范一致)
legacyAvatarPrefix = "/uploads/avatars/"
// legacyPostImagePrefix 旧站帖子图片 URL 约定前缀
legacyPostImagePrefix = "/uploads/posts/"
// legacyImagePrefix 新站帖子图片落盘后的 URL 前缀
legacyImagePrefix = "/uploads/images/"
// legacyPreviewLimit 预检清单条数上限,防止超大库拖垮响应
legacyPreviewLimit = 2000
// legacyDetailLimit 报告明细条数上限,防止超长响应
legacyDetailLimit = 50
)
var (
ErrLegacyInvalidSQLite = errors.New("文件不是有效的旧站 SQLite 数据库")
ErrLegacyBadZip = errors.New("头像压缩包无法读取")
ErrLegacyBadZip = errors.New("压缩包无法读取")
ErrLegacyBadTarget = errors.New("内容归属目标账号不存在")
)
// legacyPostImageRe 匹配正文中对旧站帖子图片的引用(相对路径或任意域名绝对 URL),
// 捕获组为纯文件名(字符集不含路径分隔符,杜绝目录穿越)。
var legacyPostImageRe = regexp.MustCompile(`(?:https?://[^()\[\]\s]+)?/uploads/posts/([A-Za-z0-9._\-]+)`)
// LegacyImportSource 受支持的导入来源(handler 按此做路由白名单)
func LegacyImportSource(source string) bool {
return source == "jiang13"
}
// LegacyImportOptions 导入选项
// LegacyImportOptions 导入选项(skip / 归属参数由 handler 从表单解析)
type LegacyImportOptions struct {
WithContent bool // 同时导入板块 / 帖子 / 评论
DryRun bool // 预检:只统计不写库
WithContent bool // 同时导入板块 / 帖子 / 评论
DryRun bool // 预检:只统计不写库,并返回勾选清单
SkipUserIDs map[uint]bool // 不创建账号的旧用户(内容仍按归属规则落位)
OperatorUsers map[uint]bool // 内容明确归到站长的旧用户
UserTargetNames map[uint]string // 旧用户ID → 指定已有账号用户名(内容归属)
UserTargetMap map[uint]uint // (内部)UserTargetNames 解析后的目标账号 ID
SkipPostIDs map[uint]bool // 排除的旧帖(其评论自动跳过)
SkipCommentIDs map[uint]bool // 排除的旧评论
}
// LegacyImportReport 导入结果摘要(JSON 返回给前端)
type LegacyImportReport struct {
DryRun bool `json:"dry_run"`
Users LegacyUserReport `json:"users"`
Boards *LegacyBoardReport `json:"boards,omitempty"`
Posts *LegacyPostReport `json:"posts,omitempty"`
Comments *LegacyCommentReport `json:"comments,omitempty"`
Notes []string `json:"notes,omitempty"`
DryRun bool `json:"dry_run"`
Users LegacyUserReport `json:"users"`
Boards *LegacyBoardReport `json:"boards,omitempty"`
Posts *LegacyPostReport `json:"posts,omitempty"`
Comments *LegacyCommentReport `json:"comments,omitempty"`
UserList []LegacyUserPreview `json:"user_list,omitempty"` // 仅 dry-run:勾选清单
PostList []LegacyPostPreview `json:"post_list,omitempty"` // 仅 dry-run
CommentList []LegacyCommentPreview `json:"comment_list,omitempty"` // 仅 dry-run
Notes []string `json:"notes,omitempty"`
}
// LegacyUserPreview 预检清单:旧用户(供勾选是否建号 / 内容归属)
type LegacyUserPreview struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Posts int `json:"posts"`
Comments int `json:"comments"`
Exists bool `json:"exists"` // 本站已有同名账号(导入时自动跳过建号)
HasAvatar bool `json:"has_avatar"`
}
// LegacyPostPreview 预检清单:旧帖(取消勾选即排除)
type LegacyPostPreview struct {
ID uint `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Board string `json:"board"`
CreatedAt time.Time `json:"created_at"`
Poll bool `json:"poll"` // 投票帖不可迁移
Published bool `json:"published"` // 非公开帖导入时自动跳过
}
// LegacyCommentPreview 预检清单:旧评论(取消勾选即排除)
type LegacyCommentPreview struct {
ID uint `json:"id"`
PostID uint `json:"post_id"`
PostTitle string `json:"post_title"`
Author string `json:"author"`
Excerpt string `json:"excerpt"`
CreatedAt time.Time `json:"created_at"`
Published bool `json:"published"`
}
// LegacyUserReport 用户导入明细
@@ -74,8 +133,9 @@ type LegacyUserReport struct {
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Excluded int `json:"excluded"` // 手动排除建号
AvatarWritten int `json:"avatar_written"`
Conflicts []string `json:"conflicts,omitempty"` // 用户名已存在
Conflicts []string `json:"conflicts,omitempty"` // 用户名已存在
AvatarMissing []string `json:"avatar_missing,omitempty"` // 旧头像字段有值但包内缺失
Failed []string `json:"failed,omitempty"`
}
@@ -89,23 +149,31 @@ type LegacyBoardReport struct {
// LegacyPostReport 帖子导入明细
type LegacyPostReport struct {
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"` // 已导入过(去重记录)
PollsSkipped int `json:"polls_skipped"`
PollTitles []string `json:"poll_titles,omitempty"`
OwnerFallback []string `json:"owner_fallback,omitempty"` // 作者缺失归到站长
Failed []string `json:"failed,omitempty"`
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"` // 已导入过(去重记录)
Excluded int `json:"excluded"` // 手动排除
ExcludedDetail []string `json:"excluded_detail,omitempty"`
PollsSkipped int `json:"polls_skipped"`
PollTitles []string `json:"poll_titles,omitempty"`
OwnerFallback []string `json:"owner_fallback,omitempty"` // 作者缺失归到站长
ImagesWritten int `json:"images_written"` // 正文图片迁移落盘数
ImagesMissing []string `json:"images_missing,omitempty"` // 包内缺失 / 落盘失败,保留原链接
Failed []string `json:"failed,omitempty"`
}
// LegacyCommentReport 评论导入明细
type LegacyCommentReport struct {
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
SkippedDetail []string `json:"skipped_detail,omitempty"` // 非公开评论等
OwnerFallback []string `json:"owner_fallback,omitempty"`
Failed []string `json:"failed,omitempty"`
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Excluded int `json:"excluded"` // 手动排除(含所属帖子被排除)
ExcludedDetail []string `json:"excluded_detail,omitempty"`
SkippedDetail []string `json:"skipped_detail,omitempty"` // 非公开评论等
OwnerFallback []string `json:"owner_fallback,omitempty"`
ImagesWritten int `json:"images_written"`
ImagesMissing []string `json:"images_missing,omitempty"`
Failed []string `json:"failed,omitempty"`
}
// ===== 旧库行结构(只映射所需列) =====
@@ -176,8 +244,16 @@ func (legacyCommentRow) TableName() string { return "comments" }
// LegacyImportService 管理后台「数据导入」
type LegacyImportService struct {
db *gorm.DB
uploadsDir string // {DataDir}/uploads,头像写入 uploads/avatars
uploadsDir string // {DataDir}/uploads,头像写入 uploads/avatars,帖子图片写入 uploads/images
md *htmltomarkdown.Converter
ensureHall func(userID uint) error // 建号后加入默认大厅的钩子(可空;与注册链路一致)
}
// WithHallMembership 注入「加入默认全站大厅」回调(chatSvc.EnsureDefaultMembership)。
// 导入建号绕过注册链路,需显式接线保持行为一致;错误静默,幂等可补齐。
func (s *LegacyImportService) WithHallMembership(fn func(userID uint) error) *LegacyImportService {
s.ensureHall = fn
return s
}
func NewLegacyImportService(db *gorm.DB, uploadsDir string) *LegacyImportService {
@@ -189,77 +265,127 @@ func NewLegacyImportService(db *gorm.DB, uploadsDir string) *LegacyImportService
}
}
// ImportFromFiles dbPath 为旧站 SQLite 库路径(调用方先落临时文件),zipPath 为可选头像 zip(空串表示未上传)。
// operatorID 为执行导入的账号(站长),作者缺失的帖子/评论归属到该账号。
func (s *LegacyImportService) ImportFromFiles(dbPath, zipPath string, opts LegacyImportOptions, operatorID uint) (*LegacyImportReport, error) {
// zipPack 旧站文件包(头像 / 帖子图片)索引:纯文件名 → 条目。
// 兼容两种打包方式:文件平铺在 zip 根目录,或统一放在单个顶层文件夹下
// (Windows 右键压缩文件夹的产物)。zip-slip 防护:只取纯文件名作键,
// 更深层嵌套与 .. 一律忽略,落盘路径永远由键拼接。
type zipPack struct {
idx map[string]*zip.File
close func()
}
func openZipPack(zipPath string) (*zipPack, error) {
if zipPath == "" {
return &zipPack{idx: map[string]*zip.File{}, close: func() {}}, nil
}
zr, err := zip.OpenReader(zipPath)
if err != nil {
return nil, ErrLegacyBadZip
}
p := &zipPack{idx: make(map[string]*zip.File, len(zr.File)), close: func() { _ = zr.Close() }}
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
name := strings.ReplaceAll(f.Name, "\\", "/")
parts := strings.Split(name, "/")
var key string
switch {
case len(parts) == 1:
key = parts[0]
case len(parts) == 2 && parts[0] != "" && parts[0] != "." && parts[0] != "..":
key = parts[1]
default:
continue
}
if key == "" || key == "." || key == ".." {
continue
}
if _, exists := p.idx[key]; !exists {
p.idx[key] = f
}
}
return p, nil
}
// ImportFromFiles dbPath 为旧站 SQLite 库路径(调用方先落临时文件),zipPath / imagesZipPath
// 为可选头像包与帖子图片包(空串表示未上传)。operatorID 为执行导入的账号(站长),
// 作者缺失的帖子/评论归属到该账号。
func (s *LegacyImportService) ImportFromFiles(dbPath, zipPath, imagesZipPath string, opts LegacyImportOptions, operatorID uint) (*LegacyImportReport, error) {
if err := validateSQLiteMagic(dbPath); err != nil {
return nil, err
}
// 归属目标:用户名 → 本站已有账号 ID(含软删账号,内容归属不变)
if len(opts.UserTargetNames) > 0 {
resolved := make(map[uint]uint, len(opts.UserTargetNames))
for oldID, name := range opts.UserTargetNames {
var u model.User
if err := s.db.Unscoped().Where("username = ?", name).First(&u).Error; err != nil {
return nil, fmt.Errorf("%w: %s", ErrLegacyBadTarget, name)
}
resolved[oldID] = u.ID
}
opts.UserTargetNames = nil
opts.UserTargetMap = resolved
}
oldDB, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(dbPath)+"?mode=ro"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrLegacyInvalidSQLite, err)
}
// 导入结束释放 SQLite 句柄,避免 Windows 上文件被占用
if sqlDB, err := oldDB.DB(); err == nil {
defer func() { _ = sqlDB.Close() }()
}
avatars, err := openZipPack(zipPath)
if err != nil {
return nil, err
}
defer avatars.close()
images, err := openZipPack(imagesZipPath)
if err != nil {
return nil, err
}
defer images.close()
if !opts.DryRun {
_ = os.MkdirAll(filepath.Join(s.uploadsDir, "avatars"), 0o755)
_ = os.MkdirAll(filepath.Join(s.uploadsDir, "images"), 0o755)
}
rep := &LegacyImportReport{DryRun: opts.DryRun}
rep.Users = s.importUsers(oldDB, zipPath, opts)
if opts.DryRun {
s.buildPreview(oldDB, rep)
}
rep.Users = s.importUsers(oldDB, avatars, opts)
if !opts.WithContent {
return rep, nil
}
boardMap, boardRep := s.importBoards(oldDB, opts)
rep.Boards = boardRep
rep.Posts = s.importPosts(oldDB, boardMap, operatorID, opts, rep)
rep.Comments = s.importComments(oldDB, operatorID, opts, rep)
rep.Posts = s.importPosts(oldDB, boardMap, images, operatorID, opts, rep)
rep.Comments = s.importComments(oldDB, images, operatorID, opts, rep)
return rep, nil
}
// ===== 用户 =====
func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts LegacyImportOptions) LegacyUserReport {
func (s *LegacyImportService) importUsers(oldDB *gorm.DB, avatars *zipPack, opts LegacyImportOptions) LegacyUserReport {
var rows []legacyUserRow
if err := oldDB.Order("id ASC").Find(&rows).Error; err != nil {
return LegacyUserReport{Failed: []string{"读取 users 表失败: " + err.Error()}}
}
// 头像包索引:文件名 → 条目。
// 兼容两种打包方式:文件平铺在 zip 根目录,或统一放在单个顶层文件夹下
// (Windows 右键压缩文件夹的产物)。zip-slip 防护:只取纯文件名作键,
// 更深层嵌套与 .. 一律忽略,落盘路径永远由键拼接。
avatars := map[string]*zip.File{}
if zipPath != "" {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return LegacyUserReport{Failed: []string{ErrLegacyBadZip.Error()}}
}
defer zr.Close()
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
name := strings.ReplaceAll(f.Name, "\\", "/")
parts := strings.Split(name, "/")
var key string
switch {
case len(parts) == 1:
key = parts[0]
case len(parts) == 2 && parts[0] != "" && parts[0] != "." && parts[0] != "..":
key = parts[1]
default:
continue
}
if key == "" || key == "." || key == ".." {
continue
}
if _, exists := avatars[key]; !exists {
avatars[key] = f
}
}
}
rep := LegacyUserReport{Total: len(rows)}
avatarDir := filepath.Join(s.uploadsDir, "avatars")
for _, u := range rows {
// 手动排除:不创建账号,其内容按归属规则落位(见 resolveAuthor)
if opts.SkipUserIDs[u.ID] {
rep.Excluded++
continue
}
username := strings.TrimSpace(u.Username)
if username == "" || u.Password == "" {
rep.Failed = append(rep.Failed, fmt.Sprintf("#%d (用户名或密码为空)", u.ID))
@@ -277,7 +403,7 @@ func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts L
continue
}
avatarURL, wrote, err := s.resolveAvatar(u.Avatar, avatars, avatarDir, opts.DryRun)
avatarURL, wrote, err := s.resolveAvatar(u.Avatar, avatars.idx, avatarDir, opts.DryRun)
if err != nil {
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (头像写入失败: %v)", username, err))
continue
@@ -308,6 +434,9 @@ func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts L
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (写入失败: %v)", username, err))
continue
}
if s.ensureHall != nil {
_ = s.ensureHall(user.ID) // 加入默认全站大厅(幂等,失败不阻断导入)
}
rep.Imported++
}
return rep
@@ -342,42 +471,111 @@ func (s *LegacyImportService) resolveAvatar(oldAvatar string, pack map[string]*z
return "", false, nil // 包内缺失
}
dst := filepath.Join(avatarDir, name)
if _, err := os.Stat(dst); err == nil {
return legacyAvatarPrefix + name, false, nil // 已存在,幂等跳过
}
if dryRun {
if _, err := os.Stat(dst); err == nil {
return legacyAvatarPrefix + name, false, nil // 已存在,幂等跳过
}
return legacyAvatarPrefix + name, true, nil
}
wrote, err := extractZipEntry(f, dst, legacyAvatarFileMax)
if err != nil {
return "", false, err
}
return legacyAvatarPrefix + name, wrote, nil
}
// extractZipEntry 把 zip 条目解到 dst(先写临时文件再 rename,避免中断留下半截文件)。
// 目标已存在时不写入直接返回 false(幂等;兼容 Windows 上目标已存在时 Rename 失败的语义)。
func extractZipEntry(f *zip.File, dst string, max int64) (bool, error) {
if _, err := os.Stat(dst); err == nil {
return false, nil
}
src, err := f.Open()
if err != nil {
return "", false, err
return false, err
}
defer src.Close()
// 先写临时文件再 rename,避免中断留下半截文件
out, err := os.CreateTemp(avatarDir, ".legacy-*")
out, err := os.CreateTemp(filepath.Dir(dst), ".legacy-*")
if err != nil {
return "", false, err
return false, err
}
tmpName := out.Name()
defer os.Remove(tmpName) // rename 成功后此处 Remove 必失败,忽略
written, err := io.Copy(out, io.LimitReader(src, legacyAvatarFileMax+1))
written, err := io.Copy(out, io.LimitReader(src, max+1))
if closeErr := out.Close(); err == nil {
err = closeErr
}
if err != nil {
return "", false, err
return false, err
}
if written > legacyAvatarFileMax {
return "", false, fmt.Errorf("头像文件超过 %dMB 上限", legacyAvatarFileMax>>20)
if written > max {
return false, fmt.Errorf("文件超过 %dMB 上限", max>>20)
}
if err := os.Rename(tmpName, dst); err != nil {
// Windows 上目标已存在时 Rename 失败;视为已存在(幂等)
if _, statErr := os.Stat(dst); statErr == nil {
return legacyAvatarPrefix + name, false, nil
return false, nil // Windows 上目标已存在时 Rename 失败;视为已存在(幂等)
}
return "", false, err
return false, err
}
return legacyAvatarPrefix + name, true, nil
return true, nil
}
// ===== 帖子 / 评论图片迁移 =====
// legacyImageStat 单次导入的图片迁移统计(跨帖子/评论累计)
type legacyImageStat struct {
written int
missing map[string]bool
}
func newImageStat() *legacyImageStat {
return &legacyImageStat{missing: map[string]bool{}}
}
// names 返回缺失文件名(排序去重)
func (st *legacyImageStat) names() []string {
out := make([]string, 0, len(st.missing))
for k := range st.missing {
out = append(out, k)
}
sort.Strings(out)
return out
}
// migrateContentImages 把内容里引用的旧站帖子图片(/uploads/posts/<名>,含旧域名绝对 URL)
// 从图片包落盘到新站 uploads/images(文件名原样保留),并把 URL 改写为新站相对路径。
// 包内缺失或落盘失败的引用保留原链接并计入 missing。dryRun 只统计不落盘。
func (s *LegacyImportService) migrateContentImages(content string, pack *zipPack, dryRun bool, stat *legacyImageStat) string {
if !strings.Contains(content, legacyPostImagePrefix) {
return content
}
return legacyPostImageRe.ReplaceAllStringFunc(content, func(m string) string {
name := legacyPostImageRe.FindStringSubmatch(m)[1]
if name == "." || name == ".." || name != path.Base(name) {
return m
}
f, ok := pack.idx[name]
if !ok {
stat.missing[name] = true
return m
}
dst := filepath.Join(s.uploadsDir, "images", name)
if dryRun {
if _, err := os.Stat(dst); err != nil {
stat.written++
}
return legacyImagePrefix + name
}
wrote, err := extractZipEntry(f, dst, legacyImageFileMax)
if err != nil {
stat.missing[name] = true
return m
}
if wrote {
stat.written++
}
return legacyImagePrefix + name
})
}
// ===== 板块 =====
@@ -461,7 +659,22 @@ func (s *LegacyImportService) mapLegacyUsers(oldDB *gorm.DB) map[uint]uint {
return m
}
func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyPostReport {
// resolveAuthor 内容归属:管理员显式指定 > 旧用户名匹配 > 站长兜底。
// 第二个返回值表示是否因找不到作者而兜底到站长(显式指定不算,用于报告明细)。
func resolveAuthor(oldUserID uint, opts LegacyImportOptions, userMap map[uint]uint, operatorID uint) (uint, bool) {
if id, ok := opts.UserTargetMap[oldUserID]; ok && id > 0 {
return id, false
}
if opts.OperatorUsers[oldUserID] {
return operatorID, false
}
if id, ok := userMap[oldUserID]; ok {
return id, false
}
return operatorID, true
}
func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint, images *zipPack, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyPostReport {
out := &LegacyPostReport{}
if boardMap == nil || len(boardMap) == 0 {
out.Failed = append(out.Failed, "无可用板块映射,帖子未导入")
@@ -477,6 +690,7 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
userMap := s.mapLegacyUsers(oldDB)
missingAuthors := map[uint]bool{} // 旧用户ID → 已记录过 fallback
convFailed := 0
imgStat := newImageStat()
for _, p := range rows {
title := strings.TrimSpace(p.Title)
@@ -484,6 +698,11 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
out.Failed = append(out.Failed, fmt.Sprintf("#%d (标题为空)", p.ID))
continue
}
if opts.SkipPostIDs[p.ID] {
out.Excluded++
out.ExcludedDetail = append(out.ExcludedDetail, fmt.Sprintf("%s (手动排除)", title))
continue
}
if p.PostType == "poll" {
out.PollsSkipped++
out.PollTitles = append(out.PollTitles, title)
@@ -509,13 +728,11 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
}
}
// 作者映射,缺失归站长
authorID := operatorID
if id, ok := userMap[p.UserID]; ok {
authorID = id
} else if !missingAuthors[p.UserID] {
// 作者归属:显式指定 > 用户名匹配 > 站长兜底
authorID, fellBack := resolveAuthor(p.UserID, opts, userMap, operatorID)
if fellBack && !missingAuthors[p.UserID] {
missingAuthors[p.UserID] = true
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分内容(作者未找到,归到站长)", p.UserID))
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分内容(作者未匹配到账号,归到站长)", p.UserID))
}
// HTML → Markdown;失败保留原文(正文不丢)
@@ -527,6 +744,7 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
convFailed++
}
}
content = s.migrateContentImages(content, images, opts.DryRun, imgStat)
if opts.DryRun {
out.Imported++
@@ -557,13 +775,16 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
if convFailed > 0 {
rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条内容 HTML 转 Markdown 失败,已保留原始内容", convFailed))
}
out.ImagesWritten = imgStat.written
out.ImagesMissing = capList(imgStat.names())
out.OwnerFallback = capList(out.OwnerFallback)
out.ExcludedDetail = capList(out.ExcludedDetail)
out.Failed = capList(out.Failed)
out.PollTitles = capList(out.PollTitles)
return out
}
func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyCommentReport {
func (s *LegacyImportService) importComments(oldDB *gorm.DB, images *zipPack, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyCommentReport {
out := &LegacyCommentReport{}
var rows []legacyCommentRow
if err := oldDB.Order("post_id ASC, id ASC").Find(&rows).Error; err != nil {
@@ -584,8 +805,20 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
userMap := s.mapLegacyUsers(oldDB)
missingAuthors := map[uint]bool{}
convFailed := 0
imgStat := newImageStat()
for _, cm := range rows {
// 所属帖子被排除 → 评论自动跳过(无落点)
if opts.SkipPostIDs[cm.PostID] {
out.Excluded++
out.ExcludedDetail = append(out.ExcludedDetail, fmt.Sprintf("《%s》#%d (所属帖子被排除)", postTitles[cm.PostID], cm.ID))
continue
}
if opts.SkipCommentIDs[cm.ID] {
out.Excluded++
out.ExcludedDetail = append(out.ExcludedDetail, fmt.Sprintf("《%s》#%d (手动排除)", postTitles[cm.PostID], cm.ID))
continue
}
if cm.Status != "published" {
out.Skipped++
out.SkippedDetail = append(out.SkippedDetail, fmt.Sprintf("《%s》#%d (旧状态 %s)", postTitles[cm.PostID], cm.ID, cm.Status))
@@ -608,12 +841,11 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
}
}
authorID := operatorID
if id, ok := userMap[cm.UserID]; ok {
authorID = id
} else if !missingAuthors[cm.UserID] {
// 作者归属:显式指定 > 用户名匹配 > 站长兜底
authorID, fellBack := resolveAuthor(cm.UserID, opts, userMap, operatorID)
if fellBack && !missingAuthors[cm.UserID] {
missingAuthors[cm.UserID] = true
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分评论(作者未找到,归到站长)", cm.UserID))
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分评论(作者未匹配到账号,归到站长)", cm.UserID))
}
// HTML → Markdown;失败保留原文(内容不丢)
@@ -625,6 +857,7 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
convFailed++
}
}
content = s.migrateContentImages(content, images, opts.DryRun, imgStat)
if opts.DryRun {
out.Imported++
@@ -639,7 +872,10 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
if convFailed > 0 {
rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条评论 HTML 转 Markdown 失败,已保留原始内容", convFailed))
}
out.ImagesWritten = imgStat.written
out.ImagesMissing = capList(imgStat.names())
out.SkippedDetail = capList(out.SkippedDetail)
out.ExcludedDetail = capList(out.ExcludedDetail)
out.OwnerFallback = capList(out.OwnerFallback)
out.Failed = capList(out.Failed)
return out
@@ -699,6 +935,117 @@ func (s *LegacyImportService) importCommentOne(cm legacyCommentRow, authorID uin
})
}
// ===== 预检清单 =====
// buildPreview 汇总旧用户 / 帖子 / 评论清单供管理员勾选导入范围,仅 dry-run 调用。
// 超出 legacyPreviewLimit 的清单截断并记入 Notes。
func (s *LegacyImportService) buildPreview(oldDB *gorm.DB, rep *LegacyImportReport) {
var users []legacyUserRow
if err := oldDB.Unscoped().Order("id ASC").Find(&users).Error; err != nil {
return
}
var boards []legacyBoardRow
// 与 importBoards/importPosts/importComments 保持一致:不含软删行(用户除外,软删用户内容仍需归属)
_ = oldDB.Order("id ASC").Find(&boards).Error
boardNames := map[uint]string{}
for _, b := range boards {
boardNames[b.ID] = b.Name
}
var posts []legacyPostRow
_ = oldDB.Order("id ASC").Find(&posts).Error
var comments []legacyCommentRow
_ = oldDB.Order("post_id ASC, id ASC").Find(&comments).Error
authorName := map[uint]string{}
postCount := map[uint]int{}
commentCount := map[uint]int{}
for _, u := range users {
name := strings.TrimSpace(u.Nickname)
if name == "" {
name = strings.TrimSpace(u.Username)
}
authorName[u.ID] = name
}
postTitles := map[uint]string{}
for _, p := range posts {
postCount[p.UserID]++
postTitles[p.ID] = p.Title
}
for _, cm := range comments {
commentCount[cm.UserID]++
}
existing := s.existingUsernames()
for _, u := range users {
rep.UserList = append(rep.UserList, LegacyUserPreview{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Posts: postCount[u.ID],
Comments: commentCount[u.ID],
Exists: existing[strings.ToLower(strings.TrimSpace(u.Username))],
HasAvatar: u.Avatar != "",
})
}
for _, p := range posts {
rep.PostList = append(rep.PostList, LegacyPostPreview{
ID: p.ID,
Title: p.Title,
Author: authorName[p.UserID],
Board: boardNames[p.BoardID],
CreatedAt: p.CreatedAt,
Poll: p.PostType == "poll",
Published: p.Status == "published",
})
}
for _, cm := range comments {
rep.CommentList = append(rep.CommentList, LegacyCommentPreview{
ID: cm.ID,
PostID: cm.PostID,
PostTitle: postTitles[cm.PostID],
Author: authorName[cm.UserID],
Excerpt: legacyExcerpt(cm.Content, 60),
CreatedAt: cm.CreatedAt,
Published: cm.Status == "published",
})
}
if len(rep.UserList) > legacyPreviewLimit {
rep.Notes = append(rep.Notes, fmt.Sprintf("用户清单超过 %d 条,仅展示前 %d 条", legacyPreviewLimit, legacyPreviewLimit))
rep.UserList = rep.UserList[:legacyPreviewLimit]
}
if len(rep.PostList) > legacyPreviewLimit {
rep.Notes = append(rep.Notes, fmt.Sprintf("帖子清单超过 %d 条,仅展示前 %d 条", legacyPreviewLimit, legacyPreviewLimit))
rep.PostList = rep.PostList[:legacyPreviewLimit]
}
if len(rep.CommentList) > legacyPreviewLimit {
rep.Notes = append(rep.Notes, fmt.Sprintf("评论清单超过 %d 条,仅展示前 %d 条", legacyPreviewLimit, legacyPreviewLimit))
rep.CommentList = rep.CommentList[:legacyPreviewLimit]
}
}
// existingUsernames 本站已有用户名集合(含软删,忽略大小写)
func (s *LegacyImportService) existingUsernames() map[string]bool {
var rows []model.User
if err := s.db.Unscoped().Select("username").Find(&rows).Error; err != nil {
return map[string]bool{}
}
set := make(map[string]bool, len(rows))
for _, u := range rows {
set[strings.ToLower(u.Username)] = true
}
return set
}
var legacyHTMLTagRe = regexp.MustCompile(`<[^>]*>`)
// legacyExcerpt 内容摘要:去 HTML 标签、压平空白后按 rune 截断(预检清单展示用)
func legacyExcerpt(s string, max int) string {
s = legacyHTMLTagRe.ReplaceAllString(s, " ")
s = strings.Join(strings.Fields(s), " ")
return truncateRunesN(s, max)
}
// ===== 工具 =====
// capList 明细条数截断,超出部分以「…等 N 条」收尾

View File

@@ -0,0 +1,281 @@
package service
import (
"archive/zip"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// newLegacyTestEnv 建新站内存库 + 旧站 SQLite 文件 + 两个 zip 包,并灌入固定种子数据:
// 用户 alice(#1)/bob(#2)/carol(#3)/dave(#4),板块 闲聊(#10),
// 帖子 #100(alice)/#101(bob)/#102(carol 草稿)/#103(alice 投票)/#104(dave),
// 评论 #200(bob→100)/#201(carol→100)/#202(alice→101)。
func newLegacyTestEnv(t *testing.T) (*LegacyImportService, string, string, string, uint, map[uint]bool) {
t.Helper()
tmp := t.TempDir()
newDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open new db: %v", err)
}
if err := newDB.AutoMigrate(&model.User{}, &model.Board{}, &model.Post{}, &model.Comment{}, &model.ImportRecord{}); err != nil {
t.Fatalf("migrate new db: %v", err)
}
admin := model.User{Username: "admin", Password: "x", Nickname: "站长", Role: model.RoleOwner}
if err := newDB.Create(&admin).Error; err != nil {
t.Fatalf("seed admin: %v", err)
}
oldPath := filepath.Join(tmp, "old.db")
oldDB, err := gorm.Open(sqlite.Open(oldPath), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open old db: %v", err)
}
if err := oldDB.AutoMigrate(&legacyUserRow{}, &legacyBoardRow{}, &legacyPostRow{}, &legacyCommentRow{}); err != nil {
t.Fatalf("migrate old db: %v", err)
}
seed := []interface{}{
&legacyUserRow{ID: 1, Username: "alice", Password: "h1", Nickname: "爱丽丝", Avatar: "/uploads/avatars/a1.jpg"},
&legacyUserRow{ID: 2, Username: "bob", Password: "h2", Nickname: "鲍勃"},
&legacyUserRow{ID: 3, Username: "carol", Password: "h3", Nickname: "卡罗尔"},
&legacyUserRow{ID: 4, Username: "dave", Password: "h4"},
&legacyBoardRow{ID: 10, Name: "闲聊"},
&legacyPostRow{ID: 100, BoardID: 10, UserID: 1, Title: "看图帖", Status: "published", PostType: "normal",
Content: `<p>看图</p><p><img src="/uploads/posts/img1.jpg"></p>`},
&legacyPostRow{ID: 101, BoardID: 10, UserID: 2, Title: "普通帖", Status: "published", PostType: "normal", Content: `<p>正文</p>`},
&legacyPostRow{ID: 102, BoardID: 10, UserID: 3, Title: "草稿帖", Status: "draft", PostType: "normal", Content: `<p>草稿</p>`},
&legacyPostRow{ID: 103, BoardID: 10, UserID: 1, Title: "投票帖", Status: "published", PostType: "poll", Content: `<p>投票</p>`},
&legacyPostRow{ID: 104, BoardID: 10, UserID: 4, Title: "缺图帖", Status: "published", PostType: "normal",
Content: `<p>缺图</p><p><img src="/uploads/posts/missing.jpg"></p>`},
&legacyCommentRow{ID: 200, PostID: 100, UserID: 2, Status: "published",
Content: `<p>补一张</p><p><img src="https://old.example.com/uploads/posts/img1.jpg"></p>`},
&legacyCommentRow{ID: 201, PostID: 100, UserID: 3, Status: "published", Content: `<p>顶</p>`},
&legacyCommentRow{ID: 202, PostID: 101, UserID: 1, Status: "published", Content: `<p>前排</p>`},
}
for _, row := range seed {
if err := oldDB.Create(row).Error; err != nil {
t.Fatalf("seed old db: %v", err)
}
}
// seed 完成即释放句柄,避免 TempDir 清理时文件被占用
if sqlDB, err := oldDB.DB(); err == nil {
_ = sqlDB.Close()
}
avatarZip := filepath.Join(tmp, "avatars.zip")
writeTestZip(t, avatarZip, map[string]string{"a1.jpg": "fake-avatar"})
imagesZip := filepath.Join(tmp, "images.zip")
writeTestZip(t, imagesZip, map[string]string{"img1.jpg": "fake-image"})
svc := NewLegacyImportService(newDB, filepath.Join(tmp, "uploads"))
// 入厅钩子桩:生产为 PostgreSQL,ChatService.EnsureDefaultMembership 里的
// GREATEST 表达式 SQLite 不支持,故用等价桩验证「建号即触发钩子」的接线
hallMembers := map[uint]bool{}
svc.WithHallMembership(func(id uint) error {
hallMembers[id] = true
return nil
})
return svc, oldPath, avatarZip, imagesZip, admin.ID, hallMembers
}
func writeTestZip(t *testing.T, path string, files map[string]string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatalf("create zip: %v", err)
}
w := zip.NewWriter(f)
for name, content := range files {
fw, err := w.Create(name)
if err != nil {
t.Fatalf("zip entry: %v", err)
}
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatalf("zip write: %v", err)
}
}
if err := w.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("zip file close: %v", err)
}
}
func TestLegacyImportPreviewLists(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, _, _ := newLegacyTestEnv(t)
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, LegacyImportOptions{WithContent: true, DryRun: true}, 1)
if err != nil {
t.Fatalf("dry run: %v", err)
}
if len(rep.UserList) != 4 || len(rep.PostList) != 5 || len(rep.CommentList) != 3 {
t.Fatalf("预检清单数量不符: users=%d posts=%d comments=%d", len(rep.UserList), len(rep.PostList), len(rep.CommentList))
}
if rep.UserList[0].Username != "alice" || !rep.UserList[0].HasAvatar {
t.Fatal("alice 预检项不符")
}
// 将导入:#100/#101/#104(草稿、投票排除)
if rep.Posts.Imported != 3 || rep.Posts.PollsSkipped != 1 {
t.Fatalf("预检帖子统计不符: %+v", rep.Posts)
}
// dry-run 不落盘
if _, err := os.Stat(filepath.Join(svc.uploadsDir, "images", "img1.jpg")); !os.IsNotExist(err) {
t.Fatal("预检不应写文件")
}
}
func TestLegacyImportSelectiveRun(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, adminID, hallMembers := newLegacyTestEnv(t)
opts := LegacyImportOptions{
WithContent: true,
SkipUserIDs: map[uint]bool{2: true, 3: true}, // bob / carol 不建号
UserTargetNames: map[uint]string{2: "admin"}, // bob 的内容归到已有账号 admin
SkipCommentIDs: map[uint]bool{202: true}, // 排除 alice 在 #101 的评论
}
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if err != nil {
t.Fatalf("import: %v", err)
}
// 用户:alice / dave 建号,bob / carol 排除
if rep.Users.Imported != 2 || rep.Users.Excluded != 2 {
t.Fatalf("用户统计不符: %+v", rep.Users)
}
// 帖子:#100/#101/#104 导入,#102 草稿跳过,#103 投票
if rep.Posts.Imported != 3 || rep.Posts.Excluded != 0 || rep.Posts.PollsSkipped != 1 {
t.Fatalf("帖子统计不符: %+v", rep.Posts)
}
// 评论:#200/#201 导入,#202 排除
if rep.Comments.Imported != 2 || rep.Comments.Excluded != 1 {
t.Fatalf("评论统计不符: %+v", rep.Comments)
}
// 图片:#100 与 #200 各引用 img1.jpg,只落盘一次
if rep.Posts.ImagesWritten != 1 || len(rep.Comments.ImagesMissing) != 0 {
t.Fatalf("图片统计不符: posts=%+v comments=%+v", rep.Posts.ImagesWritten, rep.Comments.ImagesMissing)
}
if len(rep.Posts.ImagesMissing) != 1 || rep.Posts.ImagesMissing[0] != "missing.jpg" {
t.Fatalf("缺失图片应记录: %v", rep.Posts.ImagesMissing)
}
// 内容归属
var post100, post101 model.Post
if err := svc.db.Where("title = ?", "看图帖").First(&post100).Error; err != nil {
t.Fatalf("post100: %v", err)
}
if err := svc.db.Where("title = ?", "普通帖").First(&post101).Error; err != nil {
t.Fatalf("post101: %v", err)
}
var alice model.User
if err := svc.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
t.Fatalf("alice: %v", err)
}
if post100.UserID != alice.ID {
t.Fatalf("#100 应归 alice: got %d", post100.UserID)
}
if post101.UserID != adminID {
t.Fatalf("#101 应按 user_map 归 admin: got %d", post101.UserID)
}
// bob 的评论 #200 也归 admin;carol 的评论 #201 兜底归 admin
var cm200, cm201 model.Comment
if err := svc.db.Where("content LIKE ?", "%补一张%").First(&cm200).Error; err != nil {
t.Fatalf("cm200: %v", err)
}
if err := svc.db.Where("content LIKE ?", "%顶%").First(&cm201).Error; err != nil {
t.Fatalf("cm201: %v", err)
}
if cm200.UserID != adminID || cm201.UserID != adminID {
t.Fatalf("评论归属不符: cm200=%d cm201=%d", cm200.UserID, cm201.UserID)
}
// 图片 URL 改写 + 文件落盘
if !strings.Contains(post100.Content, legacyImagePrefix+"img1.jpg") || strings.Contains(post100.Content, legacyPostImagePrefix) {
t.Fatalf("#100 图片 URL 未改写: %s", post100.Content)
}
if !strings.Contains(cm200.Content, legacyImagePrefix+"img1.jpg") || strings.Contains(cm200.Content, "old.example.com") {
t.Fatalf("#200 图片 URL 未改写: %s", cm200.Content)
}
// 缺图帖:包内缺失的引用保留原链接,不改写
p104 := post104Content(t, svc)
if !strings.Contains(p104, legacyPostImagePrefix+"missing.jpg") || strings.Contains(p104, legacyImagePrefix) {
t.Fatalf("缺图引用应保留原链接: %s", p104)
}
if _, err := os.Stat(filepath.Join(svc.uploadsDir, "images", "img1.jpg")); err != nil {
t.Fatalf("img1.jpg 未落盘: %v", err)
}
if _, err := os.Stat(filepath.Join(svc.uploadsDir, "avatars", "a1.jpg")); err != nil {
t.Fatalf("头像未落盘: %v", err)
}
// 导入建号的用户触发入厅钩子;被排除建号的 bob/carol 不触发
var dave model.User
if err := svc.db.Where("username = ?", "dave").First(&dave).Error; err != nil {
t.Fatalf("dave: %v", err)
}
if len(hallMembers) != 2 || !hallMembers[alice.ID] || !hallMembers[dave.ID] {
t.Fatalf("入厅钩子应只对 alice/dave 触发: %v", hallMembers)
}
// 幂等:重复导入全部跳过
rep2, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if err != nil {
t.Fatalf("re-import: %v", err)
}
if rep2.Users.Imported != 0 || rep2.Posts.Imported != 0 || rep2.Comments.Imported != 0 {
t.Fatalf("重复导入应全部跳过: %+v", rep2)
}
if rep2.Users.Excluded != 2 || rep2.Posts.Skipped != 4 || rep2.Comments.Skipped != 2 {
t.Fatalf("重复导入统计不符: users=%+v posts=%+v comments=%+v", rep2.Users, rep2.Posts, rep2.Comments)
}
}
// TestLegacyImportSkipPostCascades 排除帖子时其评论自动跳过
func TestLegacyImportSkipPostCascades(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, adminID, _ := newLegacyTestEnv(t)
opts := LegacyImportOptions{
WithContent: true,
SkipPostIDs: map[uint]bool{101: true}, // #101 排除 → 其评论 #202 自动跳过
}
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if err != nil {
t.Fatalf("import: %v", err)
}
if rep.Posts.Imported != 2 || rep.Posts.Excluded != 1 {
t.Fatalf("帖子统计不符: %+v", rep.Posts)
}
if rep.Comments.Imported != 2 || rep.Comments.Excluded != 1 {
t.Fatalf("评论统计不符(#202 应随 #101 排除): %+v", rep.Comments)
}
var n int64
svc.db.Model(&model.Post{}).Where("title = ?", "普通帖").Count(&n)
if n != 0 {
t.Fatal("#101 不应导入")
}
}
// TestLegacyImportBadTarget 归属目标账号不存在时报错
func TestLegacyImportBadTarget(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, adminID, _ := newLegacyTestEnv(t)
opts := LegacyImportOptions{WithContent: true, UserTargetNames: map[uint]string{2: "ghost"}}
_, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if !errors.Is(err, ErrLegacyBadTarget) {
t.Fatalf("应返回 ErrLegacyBadTarget: %v", err)
}
}
func post104Content(t *testing.T, svc *LegacyImportService) string {
t.Helper()
var p model.Post
if err := svc.db.Where("title = ?", "缺图帖").First(&p).Error; err != nil {
t.Fatalf("post104: %v", err)
}
return p.Content
}

View File

@@ -163,8 +163,10 @@ func (s *AuthService) LookupFamilyByRefreshPlain(plain string, userID uint) uint
return sessionFamilyID(rt)
}
// TouchDeviceFromRefresh 用当前请求的 IP/UA 校准本会话,并回填尚未写入的 family_id。
func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint) uint {
// TouchDeviceFromRefresh 刷新本会话活跃时间,并回填尚未写入的 family_id。
// 会话的 IP/UA 以登录时记录为准,不随访问端环境漂移,
// 否则设备指纹会跟着请求变,设备列表在 UA 模拟/代理下会来回跳。
func (s *AuthService) TouchDeviceFromRefresh(plain string, userID uint) uint {
if plain == "" {
return 0
}
@@ -177,12 +179,6 @@ func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint)
}
now := time.Now()
upd := map[string]any{"last_used_at": now, "updated_at": now}
if v := truncateStr(ip, 45); v != "" {
upd["ip"] = v
}
if v := truncateStr(ua, 500); v != "" {
upd["user_agent"] = v
}
fid := sessionFamilyID(rt)
if rt.FamilyID == 0 {
upd["family_id"] = fid
@@ -191,26 +187,29 @@ func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint)
return fid
}
// revokeAllSessionsExcept 真·单会话:登录时吊销该用户除新会话外的全部
// 有效会话(任何新登录都踢掉所有旧设备);返回吊销行数供上层广播告知。
func (s *AuthService) revokeAllSessionsExcept(userID, keepFamily uint) int {
// maxActiveSessions 每个用户允许并存的活跃会话(登录设备)上限,
// 与设备列表展示上限 loginDeviceLimit 一致;超出时淘汰最久未使用的会话。
const maxActiveSessions = 20
// enforceSessionCap 会话上限守护:多会话并存下新登录不踢旧设备,仅当该用户
// 活跃会话超过 maxActiveSessions 时,按 last_used_at 淘汰最旧的会话腾位;
// 返回淘汰行数供上层广播告知(新会话 last_used_at 最新,不会被淘汰)。
func (s *AuthService) enforceSessionCap(userID uint) int {
now := time.Now()
var actives []model.RefreshToken
if err := s.db.Select("id", "family_id").
if err := s.db.Select("id").
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, now).
Order("last_used_at DESC, created_at DESC, id DESC").
Find(&actives).Error; err != nil {
return 0
}
ids := make([]uint, 0)
for _, t := range actives {
if sessionFamilyID(t) == keepFamily {
continue
}
ids = append(ids, t.ID)
}
if len(ids) == 0 {
if len(actives) <= maxActiveSessions {
return 0
}
ids := make([]uint, 0, len(actives)-maxActiveSessions)
for _, t := range actives[maxActiveSessions:] {
ids = append(ids, t.ID)
}
res := s.db.Model(&model.RefreshToken{}).Where("id IN ?", ids).
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now})
if res.Error != nil {

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")
}
}

View File

@@ -0,0 +1,388 @@
package service
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
)
// 媒体库(管理后台):盘点站点内全部图片资源。
// 数据源 = uploads 五个分类目录的磁盘扫描 + attachments 表元数据合并:
// - 旧站导入图 / 背景图 / 广告素材 / 品牌 Logo 落盘但无附件记录,只有磁盘扫描能覆盖
// - 附件记录(头像、帖子插图)补充上传者、创建时间等元数据;远程存储对象(/api/media/)仅存在于库中
// 同名(去扩展名)同时存在原图与 WebP 时只展示 WebP,原图 URL 挂在 OriginalURL 供查看。
// 媒体库分类:目录名即分类键,前端 tab 直接使用
var mediaLibraryCategories = []struct {
Key string // 分类键(image/avatar/background/ads/brand)
Dir string // uploads 下的子目录
Name string // 展示名
}{
{"image", "images", "帖子插图"},
{"avatar", "avatars", "用户头像"},
{"background", "backgrounds", "站点背景"},
{"ads", "ads", "广告素材"},
{"brand", "brand", "品牌资源"},
}
// MediaLibraryItem 媒体库条目
type MediaLibraryItem struct {
URL string `json:"url"` // 展示地址(有 WebP 时为 WebP)
OriginalURL string `json:"original_url,omitempty"` // 同名原图(仅当原图与 WebP 并存)
Category string `json:"category"` // image/avatar/background/ads/brand
CategoryName string `json:"category_name"`
Name string `json:"name"` // 文件名(含扩展名)
MIME string `json:"mime"`
Size int64 `json:"size"`
Width int `json:"width"`
Height int `json:"height"`
Uploader string `json:"uploader,omitempty"` // 上传者昵称(取自附件记录,无记录为空)
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
SourceURL string `json:"source_url,omitempty"` // 来源回链(帖子/评论锚点/用户主页)
SourceLabel string `json:"source_label,omitempty"` // 来源描述(如:帖子《…》下的评论)
}
// AdminMediaLibrary 全站图片盘点:磁盘扫描 + 附件元数据合并,按时间倒序。
// 返回条目列表与各分类计数(含全部计数由前端按列表累加)。
func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int, error) {
// 附件元数据索引:URL → 记录(联出上传者昵称)
type attMeta struct {
Kind string
URL string
MIME string
Size int
Width int
Height int
Nickname string
Username string
CreatedAt time.Time
}
var rows []attMeta
if err := s.db.Table("attachments").
Select("attachments.kind, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username").
Joins("LEFT JOIN users ON users.id = attachments.user_id").
Scan(&rows).Error; err != nil {
return nil, nil, err
}
metaByURL := make(map[string]attMeta, len(rows))
// 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入
items := make([]MediaLibraryItem, 0, len(rows))
categoryName := func(key string) string {
for _, cat := range mediaLibraryCategories {
if cat.Key == key {
return cat.Name
}
}
return key
}
for _, r := range rows {
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
if !strings.HasPrefix(r.URL, "/api/media/") {
continue
}
uploader := r.Nickname
if uploader == "" {
uploader = r.Username
}
cat := r.Kind
if cat != model.AttachmentKindImage && cat != model.AttachmentKindAvatar {
continue // 未知 kind 不进媒体库
}
ca := r.CreatedAt
items = append(items, MediaLibraryItem{
URL: r.URL, Category: cat, CategoryName: categoryName(cat),
Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME,
Size: int64(r.Size), Width: r.Width, Height: r.Height,
Uploader: uploader, UploadedAt: &ca,
})
}
// 磁盘扫描五类目录;同名(去扩展名)成组,WebP 优先展示、原图挂 OriginalURL
type group struct {
webp *MediaLibraryItem
original *MediaLibraryItem
}
for _, cat := range mediaLibraryCategories {
dir := filepath.Join(s.dir, cat.Dir)
entries, err := os.ReadDir(dir)
if err != nil {
continue // 目录不存在视为空
}
groups := map[string]*group{}
for _, e := range entries {
if e.IsDir() || strings.HasSuffix(e.Name(), ".partial") {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
default:
continue
}
url := "/uploads/" + cat.Dir + "/" + e.Name()
item := MediaLibraryItem{
URL: url, Category: cat.Key, CategoryName: cat.Name,
Name: e.Name(),
MIME: map[string]string{
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".webp": "image/webp", ".gif": "image/gif",
}[ext],
}
if info, err := e.Info(); err == nil {
item.Size = info.Size()
mt := info.ModTime()
item.UploadedAt = &mt
}
if m, ok := metaByURL[url]; ok {
// 附件记录有上传者与准确创建时间,覆盖磁盘信息
uploader := m.Nickname
if uploader == "" {
uploader = m.Username
}
ca := m.CreatedAt
item.Uploader = uploader
item.UploadedAt = &ca
if m.Size > 0 {
item.Size = int64(m.Size)
}
if m.Width > 0 {
item.Width, item.Height = m.Width, m.Height
}
}
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0)
if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 {
if f, err := os.Open(filepath.Join(dir, e.Name())); err == nil {
w, h, derr := decodeImageSizeReader(f, item.MIME)
_ = f.Close()
if derr == nil {
item.Width, item.Height = w, h
}
}
}
stem := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
g := groups[stem]
if g == nil {
g = &group{}
groups[stem] = g
}
if ext == ".webp" {
g.webp = &item
} else if g.original == nil {
g.original = &item
}
}
for _, g := range groups {
switch {
case g.webp != nil && g.original != nil:
// 原图与 WebP 并存:只展示 WebP,原图作 original_url
g.webp.OriginalURL = g.original.URL
if g.webp.Size == 0 {
g.webp.Size = g.original.Size
}
items = append(items, *g.webp)
case g.webp != nil:
items = append(items, *g.webp)
default:
items = append(items, *g.original)
}
}
}
sort.Slice(items, func(i, j int) bool {
ti, tj := items[i].UploadedAt, items[j].UploadedAt
switch {
case ti == nil && tj == nil:
return items[i].URL < items[j].URL
case ti == nil:
return false
case tj == nil:
return true
}
return ti.After(*tj)
})
counts := map[string]int{}
for _, it := range items {
counts[it.Category]++
}
s.attachMediaSources(items)
return items, counts, nil
}
// attachMediaSources 为条目补来源回链:
// - 头像:users.avatar 精确匹配 → 用户主页
// - 其余:分块 LIKE 扫描帖子/评论正文(展示 URL 与同名原图都参与匹配),帖子优先于评论
//
// 内容 LIKE 无索引,纯顺序扫描,但仅管理端低频调用;分块 OR 控制往返次数。
func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
urlSet := map[string]bool{}
for _, it := range items {
for _, u := range []string{it.URL, it.OriginalURL} {
if strings.HasPrefix(u, "/uploads/") {
urlSet[u] = true
}
}
}
if len(urlSet) == 0 {
return
}
urls := make([]string, 0, len(urlSet))
for u := range urlSet {
urls = append(urls, u)
}
type postRef struct {
id uint
title string
}
postByURL := map[string]postRef{}
type commentRef struct {
id, postID uint
}
commentByURL := map[string]commentRef{}
for i := 0; i < len(urls); i += 40 {
end := i + 40
if end > len(urls) {
end = len(urls)
}
chunk := urls[i:end]
conds := make([]string, len(chunk))
args := make([]any, len(chunk))
for j, u := range chunk {
conds[j] = "content LIKE ?"
args[j] = "%" + u + "%"
}
where := strings.Join(conds, " OR ")
var prows []struct {
ID uint
Title string
Content string
}
if err := s.db.Model(&model.Post{}).Select("id, title, content").
Where(where, args...).Order("id ASC").Limit(600).Scan(&prows).Error; err != nil {
return
}
for _, p := range prows {
for _, u := range chunk {
if strings.Contains(p.Content, u) {
if _, ok := postByURL[u]; !ok {
postByURL[u] = postRef{id: p.ID, title: p.Title}
}
}
}
}
var crows []struct {
ID uint
PostID uint
Content string
}
if err := s.db.Model(&model.Comment{}).Select("id, post_id, content").
Where(where, args...).Order("id ASC").Limit(600).Scan(&crows).Error; err != nil {
return
}
for _, cm := range crows {
for _, u := range chunk {
if strings.Contains(cm.Content, u) {
if _, ok := commentByURL[u]; !ok {
commentByURL[u] = commentRef{id: cm.ID, postID: cm.PostID}
}
}
}
}
}
// 评论来源要带宿主帖子标题,一次性补齐
postTitle := func(id uint) string {
var p model.Post
if err := s.db.Select("id, title").First(&p, id).Error; err != nil {
return ""
}
return p.Title
}
commentPostTitle := map[uint]string{}
for _, cm := range commentByURL {
if _, ok := commentPostTitle[cm.postID]; !ok {
commentPostTitle[cm.postID] = postTitle(cm.postID)
}
}
// 头像使用者:users.avatar 精确匹配(同一头像多人使用时取最早的用户)
userByAvatar := map[string]struct {
ID uint
Nickname, Username string
}{}
var urows []struct {
ID uint
Nickname string
Username string
Avatar string
}
if err := s.db.Model(&model.User{}).Select("id, nickname, username, avatar").
Where("avatar IN ?", urls).Order("id ASC").Find(&urows).Error; err == nil {
for _, u := range urows {
if _, ok := userByAvatar[u.Avatar]; !ok {
userByAvatar[u.Avatar] = struct {
ID uint
Nickname, Username string
}{ID: u.ID, Nickname: u.Nickname, Username: u.Username}
}
}
}
truncate := func(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
postLabel := func(p postRef) string {
title := p.title
if title == "" {
title = fmt.Sprintf("#%d", p.id)
}
return "帖子《" + truncate(title, 40) + "》"
}
for i := range items {
it := &items[i]
// 头像使用者优先(头像正文引用场景极少)
if u, ok := userByAvatar[it.URL]; ok {
name := u.Nickname
if name == "" {
name = u.Username
}
it.SourceURL = fmt.Sprintf("/u/%d", u.ID)
it.SourceLabel = "@" + name + " 的头像"
continue
}
for _, u := range []string{it.URL, it.OriginalURL} {
if u == "" || it.SourceURL != "" {
continue
}
if p, ok := postByURL[u]; ok {
it.SourceURL = fmt.Sprintf("/post/%d", p.id)
it.SourceLabel = postLabel(p)
break
}
if cm, ok := commentByURL[u]; ok {
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.id)
title := commentPostTitle[cm.postID]
if title != "" {
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"
} else {
it.SourceLabel = "帖子评论"
}
break
}
}
}
}

View File

@@ -0,0 +1,312 @@
package service
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"time"
xwebp "golang.org/x/image/webp"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newMediaLibraryService(t *testing.T) (*UploadService, string) {
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.Attachment{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
t.Fatalf("migrate: %v", err)
}
dir := t.TempDir()
s := NewUploadService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
return s, dir
}
func writeMediaFile(t *testing.T, dir, rel string, data []byte) {
t.Helper()
full := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, data, 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
func tinyPNG() []byte {
src := image.NewRGBA(image.Rect(0, 0, 12, 8))
src.Set(0, 0, color.RGBA{1, 2, 3, 255})
var buf bytes.Buffer
_ = png.Encode(&buf, src)
return buf.Bytes()
}
func TestAdminMediaLibraryWebpPreferredWithOriginalURL(t *testing.T) {
s, dir := newMediaLibraryService(t)
writeMediaFile(t, dir, "images/pair.jpg", []byte("fake-jpeg-bytes"))
writeMediaFile(t, dir, "images/pair.webp", []byte("fake-webp-bytes"))
writeMediaFile(t, dir, "images/solo.png", tinyPNG())
writeMediaFile(t, dir, "images/trash.txt", []byte("not an image"))
writeMediaFile(t, dir, "images/partial.jpg.partial", []byte("temp"))
writeMediaFile(t, dir, "ads/qr.gif", []byte("GIF89a-fake"))
items, counts, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
// 同名原图 + WebP 并存:只出 WebP 条目,原图挂 original_url
pair, ok := byURL["/uploads/images/pair.webp"]
if !ok {
t.Fatalf("pair.webp missing; items=%v", items)
}
if pair.OriginalURL != "/uploads/images/pair.jpg" {
t.Fatalf("want original pair.jpg, got %q", pair.OriginalURL)
}
if _, exists := byURL["/uploads/images/pair.jpg"]; exists {
t.Fatal("pair.jpg should be hidden behind webp")
}
// 无配对的单文件:保留原格式并解析出宽高
solo, ok := byURL["/uploads/images/solo.png"]
if !ok {
t.Fatal("solo.png missing")
}
if solo.MIME != "image/png" || solo.Width != 12 || solo.Height != 8 {
t.Fatalf("solo.png meta wrong: %+v", solo)
}
// 非图片与 .partial 被过滤
if _, exists := byURL["/uploads/images/trash.txt"]; exists {
t.Fatal("trash.txt should be excluded")
}
if _, exists := byURL["/uploads/images/partial.jpg.partial"]; exists {
t.Fatal(".partial should be excluded")
}
// 广告素材目录计入 ads 分类
if qr, ok := byURL["/uploads/ads/qr.gif"]; !ok || qr.Category != "ads" {
t.Fatalf("ads/qr.gif missing or wrong category: %+v", qr)
}
if counts["image"] != 2 || counts["ads"] != 1 {
t.Fatalf("counts wrong: %v", counts)
}
}
func TestAdminMediaLibraryMergesAttachmentMetaAndRemoteObjects(t *testing.T) {
s, dir := newMediaLibraryService(t)
// 附件记录引用的磁盘文件必须真实存在,否则该记录会被跳过(见 SkipsLocalMissingFiles)
writeMediaFile(t, dir, "images/abc.webp", []byte("fake-webp-bytes"))
created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
if err := s.db.Create(&model.User{Username: "alice", Nickname: "爱丽丝"}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
var alice model.User
if err := s.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
t.Fatalf("load user: %v", err)
}
if err := s.db.Create(&model.Attachment{
UserID: alice.ID, Kind: model.AttachmentKindImage,
URL: "/uploads/images/abc.webp", MIME: "image/webp",
Size: 4567, Width: 320, Height: 240, CreatedAt: created,
}).Error; err != nil {
t.Fatalf("create attachment: %v", err)
}
if err := s.db.Create(&model.Attachment{
UserID: alice.ID, Kind: model.AttachmentKindAvatar,
URL: "/api/media/remote01", MIME: "image/webp",
Size: 999, Width: 128, Height: 128, CreatedAt: created,
}).Error; err != nil {
t.Fatalf("create remote attachment: %v", err)
}
items, counts, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
// 磁盘文件与附件记录合并:元数据以记录为准(上传者/尺寸/创建时间)
disk, ok := byURL["/uploads/images/abc.webp"]
if !ok {
t.Fatalf("attachment-backed local file missing: %+v", items)
}
if disk.Uploader != "爱丽丝" || disk.Size != 4567 || disk.Width != 320 || disk.Height != 240 {
t.Fatalf("meta not merged: %+v", disk)
}
// 远程存储对象:不在磁盘上也必须出现在媒体库
remote, ok := byURL["/api/media/remote01"]
if !ok {
t.Fatal("remote object missing")
}
if remote.Category != "avatar" || remote.Uploader != "爱丽丝" {
t.Fatalf("remote meta wrong: %+v", remote)
}
if counts["avatar"] != 1 {
t.Fatalf("avatar count wrong: %v", counts)
}
}
func TestAdminMediaLibrarySkipsLocalMissingFiles(t *testing.T) {
s, _ := newMediaLibraryService(t)
if err := s.db.Create(&model.Attachment{
UserID: 1, Kind: model.AttachmentKindImage,
URL: "/uploads/images/ghost.webp", MIME: "image/webp", Size: 1,
}).Error; err != nil {
t.Fatalf("create attachment: %v", err)
}
items, _, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
for _, it := range items {
if it.URL == "/uploads/images/ghost.webp" {
t.Fatal("local-missing file should not be listed")
}
}
}
func TestAdminMediaLibraryAttachSources(t *testing.T) {
s, dir := newMediaLibraryService(t)
writeMediaFile(t, dir, "avatars/av1.webp", []byte("fake-webp"))
writeMediaFile(t, dir, "images/p1.webp", []byte("fake-webp"))
writeMediaFile(t, dir, "images/c1.jpg", []byte("fake-jpeg"))
// 头像被 bob 使用
if err := s.db.Create(&model.User{
Username: "bob", Nickname: "阿Bob", Avatar: "/uploads/avatars/av1.webp",
}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
// 帖子正文引用 p1
post := model.Post{UserID: 1, Title: "图片来源测试", Content: "看图 ![a](/uploads/images/p1.webp)"}
if err := s.db.Create(&post).Error; err != nil {
t.Fatalf("create post: %v", err)
}
// 评论引用 c1
if err := s.db.Create(&model.Comment{
PostID: post.ID, UserID: 1, Content: "评论里也有 /uploads/images/c1.jpg",
}).Error; err != nil {
t.Fatalf("create comment: %v", err)
}
items, _, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
av, ok := byURL["/uploads/avatars/av1.webp"]
if !ok {
t.Fatal("avatar item missing")
}
if av.SourceURL != "/u/1" || av.SourceLabel != "@阿Bob 的头像" {
t.Fatalf("avatar source wrong: %+v", av)
}
pimg, ok := byURL["/uploads/images/p1.webp"]
if !ok {
t.Fatal("post image missing")
}
if pimg.SourceURL != fmt.Sprintf("/post/%d", post.ID) ||
!strings.Contains(pimg.SourceLabel, "图片来源测试") {
t.Fatalf("post source wrong: %+v", pimg)
}
cimg, ok := byURL["/uploads/images/c1.jpg"]
if !ok {
t.Fatal("comment image missing")
}
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-", post.ID) &&
!strings.HasPrefix(cimg.SourceURL, fmt.Sprintf("/post/%d#comment-", post.ID)) {
t.Fatalf("comment source url wrong: %+v", cimg)
}
if !strings.Contains(cimg.SourceLabel, "下的评论") {
t.Fatalf("comment source label wrong: %+v", cimg)
}
}
func TestMediaThumb(t *testing.T) {
s, dir := newMediaLibraryService(t)
// 960×640 PNG → 缩到 480×320 的 WebP
big := image.NewRGBA(image.Rect(0, 0, 960, 640))
var bigBuf bytes.Buffer
if err := png.Encode(&bigBuf, big); err != nil {
t.Fatalf("encode png: %v", err)
}
writeMediaFile(t, dir, "images/big.png", bigBuf.Bytes())
// 小图(≤480)直接回原图
writeMediaFile(t, dir, "images/small.png", tinyPNG())
bigThumb, err := s.MediaThumb("/uploads/images/big.png")
if err != nil {
t.Fatalf("MediaThumb big: %v", err)
}
img, err := xwebp.Decode(bytes.NewReader(bigThumb))
if err != nil {
t.Fatalf("thumb not webp: %v", err)
}
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 320 {
t.Fatalf("thumb size wrong: %v", img.Bounds())
}
small, err := s.MediaThumb("/uploads/images/small.png")
if err != nil {
t.Fatalf("MediaThumb small: %v", err)
}
if !bytes.Equal(small, tinyPNG()) {
t.Fatal("small image should be returned as-is")
}
// 缓存命中:第二次调用结果一致
again, err := s.MediaThumb("/uploads/images/big.png")
if err != nil || !bytes.Equal(again, bigThumb) {
t.Fatalf("cache miss/mismatch: %v", err)
}
// 非法与越界路径
if _, err := s.MediaThumb("/api/media/xxx"); err == nil {
t.Fatal("remote url should be rejected")
}
if _, err := s.MediaThumb("/uploads/../secrets/x.png"); err == nil {
t.Fatal("traversal should be rejected")
}
if _, err := s.MediaThumb("/uploads/other/x.png"); err == nil {
t.Fatal("unknown category should be rejected")
}
}

View File

@@ -0,0 +1,114 @@
package service
import (
"bytes"
"crypto/sha1"
"encoding/hex"
"errors"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"os"
"path/filepath"
"strings"
webpenc "github.com/gen2brain/webp"
"golang.org/x/image/draw"
xwebp "golang.org/x/image/webp"
)
// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。
// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存,
// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片;
// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。
// MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够)
const MediaThumbMaxSide = 480
// MediaThumb 返回指定 /uploads/ 图片的缩略图字节。
func (s *UploadService) MediaThumb(url string) ([]byte, error) {
if !strings.HasPrefix(url, "/uploads/") || len(url) > 512 {
return nil, errors.New("无效地址")
}
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(url, "/uploads/")))
if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, "..") {
return nil, errors.New("无效地址")
}
dir := filepath.Dir(rel)
ok := false
for _, cat := range mediaLibraryCategories {
if cat.Dir == dir {
ok = true
break
}
}
if !ok {
return nil, errors.New("无效地址")
}
ext := strings.ToLower(filepath.Ext(rel))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
default:
return nil, errors.New("非图片")
}
src := filepath.Join(s.dir, rel)
info, err := os.Stat(src)
if err != nil || info.IsDir() {
return nil, errors.New("文件不存在")
}
// 磁盘缓存:哈希命名防跨分类文件名冲突;mtime 旧于源文件则重建
sum := sha1.Sum([]byte(url))
thumbPath := filepath.Join(s.dir, ".thumbs", hex.EncodeToString(sum[:])+".webp")
if ti, err := os.Stat(thumbPath); err == nil && !ti.IsDir() && !ti.ModTime().Before(info.ModTime()) {
return os.ReadFile(thumbPath)
}
data, err := os.ReadFile(src)
if err != nil {
return nil, err
}
var img image.Image
if ext == ".webp" {
img, err = xwebp.Decode(bytes.NewReader(data))
} else {
img, _, err = image.Decode(bytes.NewReader(data))
}
if err != nil {
return nil, err // 如动图 WebP,调用方回退原图
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w <= MediaThumbMaxSide && h <= MediaThumbMaxSide {
// 小图无需缩放,直接回原图省 CPU
return data, nil
}
nw, nh := w, h
if w >= h {
nw = MediaThumbMaxSide
nh = max(1, h*MediaThumbMaxSide/w)
} else {
nh = MediaThumbMaxSide
nw = max(1, w*MediaThumbMaxSide/h)
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
var buf bytes.Buffer
if err := webpenc.Encode(&buf, dst, webpenc.Options{Quality: 78}); err != nil {
return nil, err
}
// 原子落缓存(失败仅影响下次重复生成,不阻断响应)
if err := os.MkdirAll(filepath.Dir(thumbPath), 0o755); err == nil {
tmp := thumbPath + ".partial"
if err := os.WriteFile(tmp, buf.Bytes(), 0o644); err == nil {
if err := os.Rename(tmp, thumbPath); err != nil {
_ = os.Remove(tmp)
}
}
}
return buf.Bytes(), nil
}

View File

@@ -19,6 +19,7 @@ import (
"time"
"github.com/freefire/jiang13-bbs/model"
webpenc "github.com/gen2brain/webp"
"golang.org/x/image/webp"
"gorm.io/gorm"
)
@@ -29,10 +30,13 @@ const (
AvatarMinDim = 64
AvatarMaxDim = 512
// 帖子插图:允许 JPEG/PNG/WebP,不强制转码
ImageMaxBytes = 5 << 20 // 5 MiB
// 帖子插图:JPEG/PNG 统一转存 WebP;GIF(动图)与已是 WebP 的原样保留
ImageMaxBytes = 5 << 20 // 5 MiB(对原始上传文件生效)
ImageMaxDim = 4096
// JPEG 转 WebP 的有损质量;PNG 走无损,不受此参数影响
ImageWebPQuality = 80
// 站点背景图(前台/后台墙纸)
BackgroundMaxBytes = 8 << 20 // 8 MiB
BackgroundMaxDim = 8192
@@ -190,7 +194,50 @@ func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) {
return cfg.Width, cfg.Height, nil
}
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
// transcodeImageToWebP 把校验通过的 JPEG/PNG 临时文件解码后转码为 WebP:
// JPEG 走有损 q80,PNG 走无损(保留透明通道与文字锐度)。
// 成功返回新文件名/MIME/大小并清理原临时文件;失败返回 error(调用方保留原格式落盘)。
func (s *UploadService) transcodeImageToWebP(tmp, name, ext string) (string, string, int64, error) {
webpName := strings.TrimSuffix(name, ext) + ".webp"
webpPath := filepath.Join(s.dir, "images", webpName)
webpTmp := webpPath + ".partial"
if s.ops != nil {
release, e := s.ops.BeginTemporary(webpTmp)
if e != nil {
return "", "", 0, e
}
defer release()
}
f, err := os.Open(tmp)
if err != nil {
return "", "", 0, err
}
img, _, err := image.Decode(f) // 仅 JPEG/PNG 会走到这里,解码器已在文件头注册
_ = f.Close()
if err != nil {
return "", "", 0, err
}
var buf bytes.Buffer
if err := webpenc.Encode(&buf, img, webpenc.Options{
Quality: ImageWebPQuality,
Lossless: ext == ".png",
}); err != nil {
return "", "", 0, err
}
if err := os.WriteFile(webpTmp, buf.Bytes(), 0o644); err != nil {
_ = os.Remove(webpTmp)
return "", "", 0, err
}
if err := os.Rename(webpTmp, webpPath); err != nil {
_ = os.Remove(webpTmp)
return "", "", 0, err
}
_ = os.Remove(tmp)
return webpName, "image/webp", int64(buf.Len()), nil
}
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → JPEG/PNG 转 WebP → 落盘 → 写 attachments(kind=image)
func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) {
if src == nil {
return nil, errors.New("文件为空")
@@ -284,33 +331,48 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
_ = os.Remove(tmp)
return nil, errors.New("图片边长不能超过 4096px")
}
if err := os.Rename(tmp, fullPath); err != nil {
_ = os.Remove(tmp)
return nil, err
// JPEG/PNG 统一转存 WebP;GIF(动图)与已是 WebP 的原样保留
storeName, storeMime, storeSize := filename, format.mime, total
storePath := fullPath
if format.mime == "image/jpeg" || format.mime == "image/png" {
name, mime, size, err := s.transcodeImageToWebP(tmp, filename, format.ext)
if err != nil {
log.Printf("[upload] 插图转 WebP 失败,保留原格式 %s: %v", filename, err)
} else {
storeName, storeMime, storeSize = name, mime, size
storePath = filepath.Join(s.dir, "images", name)
}
}
if storePath == fullPath {
if err := os.Rename(tmp, fullPath); err != nil {
_ = os.Remove(tmp)
return nil, err
}
}
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindImage,
URL: "/uploads/images/" + filename,
MIME: format.mime,
Size: int(total),
URL: "/uploads/images/" + storeName,
MIME: storeMime,
Size: int(storeSize),
Width: w,
Height: h,
}
if s.ops != nil {
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
id, e := s.ops.StoreFile(storePath, att.MIME, true)
if e != nil {
_ = os.Remove(fullPath)
_ = os.Remove(storePath)
return nil, e
}
if id != "" {
att.URL = "/api/media/" + id
_ = os.Remove(fullPath)
_ = os.Remove(storePath)
}
}
if err := s.db.Create(att).Error; err != nil {
_ = os.Remove(fullPath)
_ = os.Remove(storePath)
return nil, err
}
return att, nil

View File

@@ -0,0 +1,172 @@
package service
import (
"bytes"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
webpenc "github.com/gen2brain/webp"
"github.com/glebarez/sqlite"
xwebp "golang.org/x/image/webp"
"gorm.io/gorm"
)
func newUploadTestService(t *testing.T) (*UploadService, string) {
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.Attachment{}); err != nil {
t.Fatalf("migrate: %v", err)
}
dir := t.TempDir()
s := NewUploadService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
return s, dir
}
func storedUploadPath(t *testing.T, dir, url string) []byte {
t.Helper()
rel := strings.TrimPrefix(url, "/uploads/")
data, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
if err != nil {
t.Fatalf("read stored file %s: %v", url, err)
}
return data
}
func TestSaveImageTranscodesJPEGToWebP(t *testing.T) {
s, dir := newUploadTestService(t)
src := image.NewRGBA(image.Rect(0, 0, 48, 36))
for y := 0; y < 36; y++ {
for x := 0; x < 48; x++ {
src.Set(x, y, color.RGBA{uint8(x * 5), uint8(y * 7), 128, 255})
}
}
var in bytes.Buffer
if err := jpeg.Encode(&in, src, nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
if !strings.HasSuffix(att.URL, ".webp") || att.MIME != "image/webp" {
t.Fatalf("want webp attachment, got url=%s mime=%s", att.URL, att.MIME)
}
if att.Width != 48 || att.Height != 36 {
t.Fatalf("dims changed: %dx%d", att.Width, att.Height)
}
img, err := xwebp.Decode(bytes.NewReader(storedUploadPath(t, dir, att.URL)))
if err != nil {
t.Fatalf("decode stored webp: %v", err)
}
if got := img.Bounds(); got.Dx() != 48 || got.Dy() != 36 {
t.Fatalf("stored webp dims: %v", got)
}
}
func TestSaveImageTranscodesPNGLosslessKeepsAlpha(t *testing.T) {
s, dir := newUploadTestService(t)
src := image.NewNRGBA(image.Rect(0, 0, 24, 24))
for y := 0; y < 24; y++ {
for x := 0; x < 24; x++ {
if y < 12 {
src.SetNRGBA(x, y, color.NRGBA{0, 0, 0, 0}) // 上半透明
} else {
src.SetNRGBA(x, y, color.NRGBA{12, 34, 56, 255})
}
}
}
var in bytes.Buffer
if err := png.Encode(&in, src); err != nil {
t.Fatalf("encode png: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
if !strings.HasSuffix(att.URL, ".webp") || att.MIME != "image/webp" {
t.Fatalf("want webp attachment, got url=%s mime=%s", att.URL, att.MIME)
}
img, err := xwebp.Decode(bytes.NewReader(storedUploadPath(t, dir, att.URL)))
if err != nil {
t.Fatalf("decode stored webp: %v", err)
}
if _, _, _, a := img.At(0, 0).RGBA(); a != 0 {
t.Fatalf("alpha lost at transparent pixel: %d", a)
}
r, g, b, _ := img.At(5, 20).RGBA()
if r>>8 != 12 || g>>8 != 34 || b>>8 != 56 {
t.Fatalf("opaque color changed: %d,%d,%d", r>>8, g>>8, b>>8)
}
}
func TestSaveImageKeepsGIFAsIs(t *testing.T) {
s, dir := newUploadTestService(t)
src := image.NewRGBA(image.Rect(0, 0, 16, 16))
for y := 0; y < 16; y++ {
for x := 0; x < 16; x++ {
src.Set(x, y, color.RGBA{uint8(x * 16), uint8(y * 16), 0, 255})
}
}
var in bytes.Buffer
if err := gif.Encode(&in, src, nil); err != nil {
t.Fatalf("encode gif: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
if !strings.HasSuffix(att.URL, ".gif") || att.MIME != "image/gif" {
t.Fatalf("gif should be kept as-is, got url=%s mime=%s", att.URL, att.MIME)
}
data := storedUploadPath(t, dir, att.URL)
magic := string(data[:6])
if magic != "GIF89a" && magic != "GIF87a" {
t.Fatalf("stored gif magic invalid: %q", magic)
}
}
func TestSaveImageKeepsWebPAsIs(t *testing.T) {
s, dir := newUploadTestService(t)
src := image.NewRGBA(image.Rect(0, 0, 20, 10))
var in bytes.Buffer
if err := webpenc.Encode(&in, src, webpenc.Options{Quality: ImageWebPQuality}); err != nil {
t.Fatalf("encode webp: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
if !strings.HasSuffix(att.URL, ".webp") || att.MIME != "image/webp" {
t.Fatalf("want webp attachment, got url=%s mime=%s", att.URL, att.MIME)
}
if !bytes.Equal(storedUploadPath(t, dir, att.URL), in.Bytes()) {
t.Fatal("webp input should be stored byte-for-byte")
}
if att.Size != in.Len() {
t.Fatalf("size mismatch: att=%d input=%d", att.Size, in.Len())
}
}