feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
@@ -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 条」收尾
|
||||
|
||||
Reference in New Issue
Block a user