Files
jiang13-bbs/backend/service/legacyimport.go
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

1086 lines
39 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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])
}