新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
1086 lines
39 KiB
Go
1086 lines
39 KiB
Go
package service
|
||
|
||
import (
|
||
"archive/zip"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown"
|
||
"github.com/glebarez/sqlite"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/logger"
|
||
|
||
"github.com/freefire/jiang13-bbs/model"
|
||
)
|
||
|
||
// 旧站(jiang13-forum,SQLite)数据导入:管理后台上传旧库 + 可选头像包 / 帖子图片包,
|
||
// 导入用户账号(用户名 / bcrypt 密码 / 昵称 / 签名 / 邮箱 / 头像)与板块、帖子、评论。
|
||
// 两站密码同为 bcrypt,哈希原样复制,老用户用原密码即可登录。
|
||
// 支持预检(dry-run,不写库,返回用户 / 帖子 / 评论清单供勾选);ImportRecord 去重保证幂等可重复导入。
|
||
// 支持选择性导入:按旧用户勾选是否建号、内容归属(指定已有账号 / 站长)、按帖子 / 评论排除。
|
||
// 帖子图片:正文引用的旧站 /uploads/posts/<名> 从图片包落盘到新站 uploads/images 并改写 URL。
|
||
// 来源参数化(source),为 WordPress / Typecho 等外部数据源预留扩展位。
|
||
|
||
const (
|
||
// LegacyDBMaxBytes 旧库上传上限(实测旧站整库约 2MB,留足余量)
|
||
LegacyDBMaxBytes = 64 << 20
|
||
// 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("压缩包无法读取")
|
||
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 导入选项(skip / 归属参数由 handler 从表单解析)
|
||
type LegacyImportOptions struct {
|
||
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"`
|
||
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 用户导入明细
|
||
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"` // 用户名已存在
|
||
AvatarMissing []string `json:"avatar_missing,omitempty"` // 旧头像字段有值但包内缺失
|
||
Failed []string `json:"failed,omitempty"`
|
||
}
|
||
|
||
// LegacyBoardReport 板块导入明细
|
||
type LegacyBoardReport struct {
|
||
Created int `json:"created"` // 新建(预检时为「将新建」)
|
||
Reused int `json:"reused"` // 复用同名已有板块
|
||
Names []string `json:"names,omitempty"` // 新建板块名
|
||
}
|
||
|
||
// LegacyPostReport 帖子导入明细
|
||
type LegacyPostReport struct {
|
||
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"`
|
||
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"`
|
||
}
|
||
|
||
// ===== 旧库行结构(只映射所需列) =====
|
||
|
||
type legacyUserRow struct {
|
||
ID uint `gorm:"primaryKey"`
|
||
Username string `gorm:"column:username"`
|
||
Email string `gorm:"column:email"`
|
||
Password string `gorm:"column:password"`
|
||
Nickname string `gorm:"column:nickname"`
|
||
Signature string `gorm:"column:signature"`
|
||
Avatar string `gorm:"column:avatar"`
|
||
Role string `gorm:"column:role"`
|
||
Banned bool `gorm:"column:banned"`
|
||
CreatedAt time.Time `gorm:"column:created_at"`
|
||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` // 软删行由 GORM 自动过滤
|
||
}
|
||
|
||
func (legacyUserRow) TableName() string { return "users" }
|
||
|
||
type legacyBoardRow struct {
|
||
ID uint `gorm:"primaryKey"`
|
||
Name string `gorm:"column:name"`
|
||
Description string `gorm:"column:description"`
|
||
Icon string `gorm:"column:icon"`
|
||
ColorIndex int `gorm:"column:color_index"`
|
||
SortOrder int `gorm:"column:sort_order"`
|
||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||
}
|
||
|
||
func (legacyBoardRow) TableName() string { return "boards" }
|
||
|
||
type legacyPostRow struct {
|
||
ID uint `gorm:"primaryKey"`
|
||
BoardID uint `gorm:"column:board_id"`
|
||
UserID uint `gorm:"column:user_id"`
|
||
Title string `gorm:"column:title"`
|
||
Content string `gorm:"column:content"`
|
||
Tags string `gorm:"column:tags"`
|
||
Pinned int `gorm:"column:pinned"`
|
||
Featured bool `gorm:"column:featured"`
|
||
Status string `gorm:"column:status"`
|
||
PostType string `gorm:"column:post_type"`
|
||
LikeCount int `gorm:"column:like_count"`
|
||
ViewCount int `gorm:"column:view_count"`
|
||
CreatedAt time.Time `gorm:"column:created_at"`
|
||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||
}
|
||
|
||
func (legacyPostRow) TableName() string { return "posts" }
|
||
|
||
type legacyCommentRow struct {
|
||
ID uint `gorm:"primaryKey"`
|
||
PostID uint `gorm:"column:post_id"`
|
||
UserID uint `gorm:"column:user_id"`
|
||
ReplyTo *uint `gorm:"column:reply_to"`
|
||
Content string `gorm:"column:content"`
|
||
Status string `gorm:"column:status"`
|
||
LikeCount int `gorm:"column:like_count"`
|
||
CreatedAt time.Time `gorm:"column:created_at"`
|
||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||
}
|
||
|
||
func (legacyCommentRow) TableName() string { return "comments" }
|
||
|
||
// LegacyImportService 管理后台「数据导入」
|
||
type LegacyImportService struct {
|
||
db *gorm.DB
|
||
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 {
|
||
return &LegacyImportService{
|
||
db: db,
|
||
uploadsDir: uploadsDir,
|
||
// 空域名:相对链接保持原样;CommonMark 风格输出
|
||
md: htmltomarkdown.NewConverter("", true, nil),
|
||
}
|
||
}
|
||
|
||
// 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}
|
||
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, images, operatorID, opts, rep)
|
||
rep.Comments = s.importComments(oldDB, images, operatorID, opts, rep)
|
||
return rep, nil
|
||
}
|
||
|
||
// ===== 用户 =====
|
||
|
||
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()}}
|
||
}
|
||
|
||
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))
|
||
continue
|
||
}
|
||
// 用户名列是硬唯一索引,软删行仍占用 → Unscoped 查重,存在即跳过(幂等)
|
||
var n int64
|
||
if err := s.db.Unscoped().Model(&model.User{}).Where("username = ?", username).Count(&n).Error; err != nil {
|
||
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (查重失败: %v)", username, err))
|
||
continue
|
||
}
|
||
if n > 0 {
|
||
rep.Skipped++
|
||
rep.Conflicts = append(rep.Conflicts, username)
|
||
continue
|
||
}
|
||
|
||
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
|
||
}
|
||
if wrote {
|
||
rep.AvatarWritten++
|
||
}
|
||
if u.Avatar != "" && avatarURL == "" {
|
||
rep.AvatarMissing = append(rep.AvatarMissing, fmt.Sprintf("%s: %s", username, u.Avatar))
|
||
}
|
||
|
||
if opts.DryRun {
|
||
rep.Imported++
|
||
continue
|
||
}
|
||
user := model.User{
|
||
Username: username,
|
||
Email: truncateRunesN(u.Email, legacyEmailMaxRunes),
|
||
Password: u.Password, // bcrypt 哈希原样复制
|
||
Nickname: s.uniqueNickname(u.Nickname, username, u.ID),
|
||
Signature: truncateRunesN(u.Signature, legacySignatureMaxRunes),
|
||
Avatar: avatarURL,
|
||
Role: model.RoleUser, // 导入者即新站站长,旧角色一律降为普通用户
|
||
Banned: u.Banned,
|
||
CreatedAt: u.CreatedAt, // 保留原注册时间
|
||
}
|
||
if err := s.db.Create(&user).Error; err != nil {
|
||
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (写入失败: %v)", username, err))
|
||
continue
|
||
}
|
||
if s.ensureHall != nil {
|
||
_ = s.ensureHall(user.ID) // 加入默认全站大厅(幂等,失败不阻断导入)
|
||
}
|
||
rep.Imported++
|
||
}
|
||
return rep
|
||
}
|
||
|
||
// uniqueNickname 旧昵称与本站已有昵称(忽略大小写;软删不占用,与唯一索引语义一致)冲突时降级:
|
||
// 旧昵称 → 用户名 → 用户名-旧ID,保证满足 LOWER(nickname) 唯一索引且不为空。
|
||
func (s *LegacyImportService) uniqueNickname(nickname, username string, oldID uint) string {
|
||
candidates := []string{strings.TrimSpace(nickname), username, fmt.Sprintf("%s-%d", username, oldID)}
|
||
last := candidates[len(candidates)-1]
|
||
for _, cand := range candidates {
|
||
var n int64
|
||
if err := s.db.Model(&model.User{}).Where("LOWER(nickname) = LOWER(?)", cand).Count(&n).Error; err != nil || n == 0 {
|
||
return cand // 查重失败按可用处理,Create 时仍有唯一索引兜底
|
||
}
|
||
}
|
||
return last
|
||
}
|
||
|
||
// resolveAvatar 从头像包解出旧头像文件到新站 avatars 目录(文件名原样保留,URL 即旧值)。
|
||
// 返回新 Avatar 字段值(空串=无法迁移)与是否实际写入了文件。dryRun 时只判断不解压。
|
||
func (s *LegacyImportService) resolveAvatar(oldAvatar string, pack map[string]*zip.File, avatarDir string, dryRun bool) (string, bool, error) {
|
||
if oldAvatar == "" || len(oldAvatar) <= len(legacyAvatarPrefix) {
|
||
return "", false, nil
|
||
}
|
||
name := oldAvatar[len(legacyAvatarPrefix):]
|
||
if name != path.Base(name) || strings.ContainsRune(name, '\\') {
|
||
return "", false, nil // 不符合旧站 URL 约定(如外部链接),无法迁移
|
||
}
|
||
f, ok := pack[name]
|
||
if !ok {
|
||
return "", false, nil // 包内缺失
|
||
}
|
||
dst := filepath.Join(avatarDir, name)
|
||
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
|
||
}
|
||
defer src.Close()
|
||
out, err := os.CreateTemp(filepath.Dir(dst), ".legacy-*")
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
tmpName := out.Name()
|
||
defer os.Remove(tmpName) // rename 成功后此处 Remove 必失败,忽略
|
||
written, err := io.Copy(out, io.LimitReader(src, max+1))
|
||
if closeErr := out.Close(); err == nil {
|
||
err = closeErr
|
||
}
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if written > max {
|
||
return false, fmt.Errorf("文件超过 %dMB 上限", max>>20)
|
||
}
|
||
if err := os.Rename(tmpName, dst); err != nil {
|
||
if _, statErr := os.Stat(dst); statErr == nil {
|
||
return false, nil // Windows 上目标已存在时 Rename 失败;视为已存在(幂等)
|
||
}
|
||
return false, err
|
||
}
|
||
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
|
||
})
|
||
}
|
||
|
||
// ===== 板块 =====
|
||
|
||
// importBoards 按旧板块名自动建新板块(同名忽略大小写复用),返回 旧板块ID → 新板块ID 映射。
|
||
func (s *LegacyImportService) importBoards(oldDB *gorm.DB, opts LegacyImportOptions) (map[uint]uint, *LegacyBoardReport) {
|
||
rep := &LegacyBoardReport{}
|
||
m := map[uint]uint{}
|
||
var rows []legacyBoardRow
|
||
if err := oldDB.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||
return m, rep // 板块读取失败:帖子逐条按 board_id 缺失进 failed
|
||
}
|
||
for _, b := range rows {
|
||
name := strings.TrimSpace(b.Name)
|
||
if name == "" {
|
||
continue
|
||
}
|
||
var existing model.Board
|
||
if err := s.db.Where("LOWER(name) = LOWER(?)", name).First(&existing).Error; err == nil {
|
||
m[b.ID] = existing.ID
|
||
rep.Reused++
|
||
continue
|
||
}
|
||
rep.Created++
|
||
rep.Names = append(rep.Names, name)
|
||
if opts.DryRun {
|
||
// 预检不写库;占位映射(0 不会用于真实写入)让帖子预检仍按「将创建」计数
|
||
m[b.ID] = 0
|
||
continue
|
||
}
|
||
board := model.Board{
|
||
Name: name,
|
||
Description: b.Description,
|
||
Icon: b.Icon,
|
||
ColorIndex: b.ColorIndex,
|
||
SortOrder: b.SortOrder,
|
||
}
|
||
if err := s.db.Create(&board).Error; err != nil {
|
||
continue // 创建失败:对应帖子进 failed
|
||
}
|
||
s.db.Create(&model.ImportRecord{Source: "jiang13", Kind: "board", SourceID: strconv.FormatUint(uint64(b.ID), 10), NewID: board.ID})
|
||
m[b.ID] = board.ID
|
||
}
|
||
return m, rep
|
||
}
|
||
|
||
// ===== 帖子 / 评论 =====
|
||
|
||
// mapLegacyUsers 旧用户ID → 本站用户ID。先按用户名映射(含软删本站用户),
|
||
// 找不到回退到 operatorID(站长,由调用方处理并记录明细)。
|
||
func (s *LegacyImportService) mapLegacyUsers(oldDB *gorm.DB) map[uint]uint {
|
||
type oldRef struct {
|
||
ID uint
|
||
Username string
|
||
}
|
||
var olds []oldRef
|
||
// 含软删旧用户:其内容仍需归属。
|
||
// oldRef 是包内匿名结构体,必须显式指定表名,否则 GORM 会推断成 old_refs
|
||
if err := oldDB.Unscoped().Table("users").Select("id", "username").Find(&olds).Error; err != nil {
|
||
return map[uint]uint{}
|
||
}
|
||
var news []model.User
|
||
// Unscoped:软删本站账号也参与映射(内容归属不变)
|
||
if err := s.db.Unscoped().Select("id", "username").Find(&news).Error; err != nil {
|
||
return map[uint]uint{}
|
||
}
|
||
byName := make(map[string]uint, len(news))
|
||
for _, u := range news {
|
||
byName[strings.ToLower(u.Username)] = u.ID
|
||
}
|
||
m := make(map[uint]uint, len(olds))
|
||
for _, o := range olds {
|
||
name := strings.ToLower(strings.TrimSpace(o.Username))
|
||
if name == "" {
|
||
continue
|
||
}
|
||
if id, ok := byName[name]; ok {
|
||
m[o.ID] = id
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
// 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, "无可用板块映射,帖子未导入")
|
||
return out
|
||
}
|
||
var rows []legacyPostRow
|
||
if err := oldDB.Order("id ASC").Find(&rows).Error; err != nil {
|
||
out.Failed = append(out.Failed, "读取 posts 表失败: "+err.Error())
|
||
return out
|
||
}
|
||
out.Total = len(rows)
|
||
|
||
userMap := s.mapLegacyUsers(oldDB)
|
||
missingAuthors := map[uint]bool{} // 旧用户ID → 已记录过 fallback
|
||
convFailed := 0
|
||
imgStat := newImageStat()
|
||
|
||
for _, p := range rows {
|
||
title := strings.TrimSpace(p.Title)
|
||
if title == "" {
|
||
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)
|
||
continue
|
||
}
|
||
if p.Status != "published" {
|
||
out.Skipped++
|
||
out.Failed = append(out.Failed, fmt.Sprintf("%s (旧状态 %s,跳过)", title, p.Status))
|
||
continue
|
||
}
|
||
boardID, ok := boardMap[p.BoardID]
|
||
if !ok {
|
||
out.Failed = append(out.Failed, fmt.Sprintf("%s (旧板块 #%d 无法映射)", title, p.BoardID))
|
||
continue
|
||
}
|
||
// 幂等:去重记录已存在即跳过
|
||
if !opts.DryRun {
|
||
var n int64
|
||
s.db.Model(&model.ImportRecord{}).Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "post", strconv.FormatUint(uint64(p.ID), 10)).Count(&n)
|
||
if n > 0 {
|
||
out.Skipped++
|
||
continue
|
||
}
|
||
}
|
||
|
||
// 作者归属:显式指定 > 用户名匹配 > 站长兜底
|
||
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))
|
||
}
|
||
|
||
// HTML → Markdown;失败保留原文(正文不丢)
|
||
content := p.Content
|
||
if strings.Contains(strings.ToLower(content), "<") {
|
||
if md, err := s.md.ConvertString(content); err == nil && strings.TrimSpace(md) != "" {
|
||
content = md
|
||
} else {
|
||
convFailed++
|
||
}
|
||
}
|
||
content = s.migrateContentImages(content, images, opts.DryRun, imgStat)
|
||
|
||
if opts.DryRun {
|
||
out.Imported++
|
||
continue
|
||
}
|
||
post := model.Post{
|
||
BoardID: boardID,
|
||
UserID: authorID,
|
||
Title: truncateRunesN(title, 256),
|
||
Content: content,
|
||
Tags: p.Tags,
|
||
PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion
|
||
Pinned: p.Pinned,
|
||
Recommended: p.Featured,
|
||
Status: model.ContentStatusPublished,
|
||
LikeCount: p.LikeCount,
|
||
ViewCount: p.ViewCount,
|
||
CreatedAt: p.CreatedAt,
|
||
UpdatedAt: p.UpdatedAt,
|
||
}
|
||
if err := s.db.Create(&post).Error; err != nil {
|
||
out.Failed = append(out.Failed, fmt.Sprintf("%s (写入失败: %v)", title, err))
|
||
continue
|
||
}
|
||
s.db.Create(&model.ImportRecord{Source: "jiang13", Kind: "post", SourceID: strconv.FormatUint(uint64(p.ID), 10), NewID: post.ID})
|
||
out.Imported++
|
||
}
|
||
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, 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 {
|
||
out.Failed = append(out.Failed, "读取 comments 表失败: "+err.Error())
|
||
return out
|
||
}
|
||
out.Total = len(rows)
|
||
|
||
var posts []legacyPostRow
|
||
if err := oldDB.Unscoped().Select("id", "title").Find(&posts).Error; err != nil {
|
||
posts = nil
|
||
}
|
||
postTitles := map[uint]string{}
|
||
for _, p := range posts {
|
||
postTitles[p.ID] = p.Title
|
||
}
|
||
|
||
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))
|
||
continue
|
||
}
|
||
// 幂等:去重记录已存在即跳过
|
||
if !opts.DryRun {
|
||
var n int64
|
||
s.db.Model(&model.ImportRecord{}).Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "comment", strconv.FormatUint(uint64(cm.ID), 10)).Count(&n)
|
||
if n > 0 {
|
||
out.Skipped++
|
||
continue
|
||
}
|
||
// 所属帖子必须已导入(否则评论无落点)
|
||
var pn int64
|
||
s.db.Model(&model.ImportRecord{}).Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "post", strconv.FormatUint(uint64(cm.PostID), 10)).Count(&pn)
|
||
if pn == 0 {
|
||
out.Failed = append(out.Failed, fmt.Sprintf("《%s》#%d (所属帖子未导入)", postTitles[cm.PostID], cm.ID))
|
||
continue
|
||
}
|
||
}
|
||
|
||
// 作者归属:显式指定 > 用户名匹配 > 站长兜底
|
||
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))
|
||
}
|
||
|
||
// HTML → Markdown;失败保留原文(内容不丢)
|
||
content := cm.Content
|
||
if strings.Contains(strings.ToLower(content), "<") {
|
||
if md, err := s.md.ConvertString(content); err == nil && strings.TrimSpace(md) != "" {
|
||
content = md
|
||
} else {
|
||
convFailed++
|
||
}
|
||
}
|
||
content = s.migrateContentImages(content, images, opts.DryRun, imgStat)
|
||
|
||
if opts.DryRun {
|
||
out.Imported++
|
||
continue
|
||
}
|
||
if err := s.importCommentOne(cm, authorID, content); err != nil {
|
||
out.Failed = append(out.Failed, fmt.Sprintf("《%s》#%d (写入失败: %v)", postTitles[cm.PostID], cm.ID, err))
|
||
continue
|
||
}
|
||
out.Imported++
|
||
}
|
||
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
|
||
}
|
||
|
||
// importCommentOne 写入单条评论并处理回复链挂接与去重记录。
|
||
// 源 ID 顺序保证父评论先于子评论处理;reply_to 指向的父评论通过去重记录取回新 ID。
|
||
func (s *LegacyImportService) importCommentOne(cm legacyCommentRow, authorID uint, content string) error {
|
||
// 源帖子 → 新帖子 ID
|
||
var postRec model.ImportRecord
|
||
if err := s.db.Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "post", strconv.FormatUint(uint64(cm.PostID), 10)).First(&postRec).Error; err != nil {
|
||
return errors.New("找不到源帖子记录")
|
||
}
|
||
|
||
comment := model.Comment{
|
||
PostID: postRec.NewID,
|
||
UserID: authorID,
|
||
Content: content,
|
||
Status: model.ContentStatusPublished,
|
||
CreatedAt: cm.CreatedAt,
|
||
UpdatedAt: cm.UpdatedAt,
|
||
}
|
||
|
||
// 回复挂接:reply_to 指向同帖已有评论;找不到父评论(被删/跨帖)→ 落为主评论
|
||
if cm.ReplyTo != nil && *cm.ReplyTo > 0 {
|
||
var parent model.Comment
|
||
err := s.db.Where("comments.post_id = ?", postRec.NewID).
|
||
Joins("JOIN import_records ir ON ir.kind = 'comment' AND ir.new_id = comments.id").
|
||
Where("ir.source = ? AND ir.source_id = ?", "jiang13", strconv.FormatUint(uint64(*cm.ReplyTo), 10)).
|
||
First(&parent).Error
|
||
if err == nil {
|
||
comment.ParentID = &parent.ID
|
||
comment.Depth = parent.Depth + 1
|
||
if parent.RootID != nil {
|
||
root := *parent.RootID
|
||
comment.RootID = &root
|
||
} else {
|
||
comment.RootID = &parent.ID
|
||
}
|
||
}
|
||
}
|
||
|
||
// 评论 / 计数 / 去重记录同一事务,避免重试导致重复评论
|
||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||
if err := tx.Create(&comment).Error; err != nil {
|
||
return err
|
||
}
|
||
// 帖子评论计数 +1(UpdateColumn 不触碰 updated_at)
|
||
if err := tx.Model(&model.Post{}).Where("id = ?", postRec.NewID).
|
||
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error; err != nil {
|
||
return err
|
||
}
|
||
return tx.Create(&model.ImportRecord{
|
||
Source: "jiang13", Kind: "comment",
|
||
SourceID: strconv.FormatUint(uint64(cm.ID), 10), NewID: comment.ID,
|
||
}).Error
|
||
})
|
||
}
|
||
|
||
// ===== 预检清单 =====
|
||
|
||
// 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 条」收尾
|
||
func capList(list []string) []string {
|
||
if len(list) <= legacyDetailLimit {
|
||
return list
|
||
}
|
||
out := append([]string{}, list[:legacyDetailLimit]...)
|
||
out = append(out, fmt.Sprintf("…等共 %d 条", len(list)))
|
||
return out
|
||
}
|
||
|
||
// validateSQLiteMagic 校验文件以 SQLite 3 魔数开头,避免把任意文件喂给解析器
|
||
func validateSQLiteMagic(p string) error {
|
||
f, err := os.Open(p)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer f.Close()
|
||
magic := make([]byte, 16)
|
||
if _, err := io.ReadFull(f, magic); err != nil {
|
||
return ErrLegacyInvalidSQLite
|
||
}
|
||
if string(magic) != "SQLite format 3\x00" {
|
||
return ErrLegacyInvalidSQLite
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// truncateRunesN 按 rune 数截断字符串到 max
|
||
func truncateRunesN(s string, max int) string {
|
||
r := []rune(s)
|
||
if len(r) <= max {
|
||
return s
|
||
}
|
||
return string(r[:max])
|
||
}
|