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

@@ -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 条」收尾