Files
jiang13-bbs/backend/service/legacyimport.go
freefire 3e55b5d230 feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
2026-09-24 03:37:44 +08:00

739 lines
25 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"
"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 去重保证幂等可重复导入。
// 来源参数化(source),为 WordPress / Typecho 等外部数据源预留扩展位。
const (
// LegacyDBMaxBytes 旧库上传上限(实测旧站整库约 2MB,留足余量)
LegacyDBMaxBytes = 64 << 20
// LegacyZipMaxBytes 头像压缩包上传上限
LegacyZipMaxBytes = 128 << 20
// legacyAvatarFileMax 单个头像解压上限
legacyAvatarFileMax = 8 << 20
// legacySignatureMaxRunes 新站 Signature 列上限
legacySignatureMaxRunes = 255
// legacyEmailMaxRunes 新站 Email 列上限
legacyEmailMaxRunes = 128
// legacyAvatarPrefix 旧站头像 URL 约定前缀(与旧站静态服务规范一致)
legacyAvatarPrefix = "/uploads/avatars/"
// legacyDetailLimit 报告明细条数上限,防止超长响应
legacyDetailLimit = 50
)
var (
ErrLegacyInvalidSQLite = errors.New("文件不是有效的旧站 SQLite 数据库")
ErrLegacyBadZip = errors.New("头像压缩包无法读取")
)
// LegacyImportSource 受支持的导入来源(handler 按此做路由白名单)
func LegacyImportSource(source string) bool {
return source == "jiang13"
}
// LegacyImportOptions 导入选项
type LegacyImportOptions struct {
WithContent bool // 同时导入板块 / 帖子 / 评论
DryRun 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"`
}
// LegacyUserReport 用户导入明细
type LegacyUserReport struct {
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
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"` // 已导入过(去重记录)
PollsSkipped int `json:"polls_skipped"`
PollTitles []string `json:"poll_titles,omitempty"`
OwnerFallback []string `json:"owner_fallback,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"`
}
// ===== 旧库行结构(只映射所需列) =====
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
md *htmltomarkdown.Converter
}
func NewLegacyImportService(db *gorm.DB, uploadsDir string) *LegacyImportService {
return &LegacyImportService{
db: db,
uploadsDir: uploadsDir,
// 空域名:相对链接保持原样;CommonMark 风格输出
md: htmltomarkdown.NewConverter("", true, nil),
}
}
// ImportFromFiles dbPath 为旧站 SQLite 库路径(调用方先落临时文件),zipPath 为可选头像 zip(空串表示未上传)。
// operatorID 为执行导入的账号(站长),作者缺失的帖子/评论归属到该账号。
func (s *LegacyImportService) ImportFromFiles(dbPath, zipPath string, opts LegacyImportOptions, operatorID uint) (*LegacyImportReport, error) {
if err := validateSQLiteMagic(dbPath); err != nil {
return nil, err
}
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)
}
rep := &LegacyImportReport{DryRun: opts.DryRun}
rep.Users = s.importUsers(oldDB, zipPath, 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)
return rep, nil
}
// ===== 用户 =====
func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, 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 {
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, 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
}
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 _, err := os.Stat(dst); err == nil {
return legacyAvatarPrefix + name, false, nil // 已存在,幂等跳过
}
if dryRun {
return legacyAvatarPrefix + name, true, nil
}
src, err := f.Open()
if err != nil {
return "", false, err
}
defer src.Close()
// 先写临时文件再 rename,避免中断留下半截文件
out, err := os.CreateTemp(avatarDir, ".legacy-*")
if err != nil {
return "", false, err
}
tmpName := out.Name()
defer os.Remove(tmpName) // rename 成功后此处 Remove 必失败,忽略
written, err := io.Copy(out, io.LimitReader(src, legacyAvatarFileMax+1))
if closeErr := out.Close(); err == nil {
err = closeErr
}
if err != nil {
return "", false, err
}
if written > legacyAvatarFileMax {
return "", false, fmt.Errorf("头像文件超过 %dMB 上限", legacyAvatarFileMax>>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, err
}
return legacyAvatarPrefix + name, true, nil
}
// ===== 板块 =====
// 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
}
func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint, 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
for _, p := range rows {
title := strings.TrimSpace(p.Title)
if title == "" {
out.Failed = append(out.Failed, fmt.Sprintf("#%d (标题为空)", p.ID))
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 := operatorID
if id, ok := userMap[p.UserID]; ok {
authorID = id
} else if !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++
}
}
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.OwnerFallback = capList(out.OwnerFallback)
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 {
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
for _, cm := range rows {
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 := operatorID
if id, ok := userMap[cm.UserID]; ok {
authorID = id
} else if !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++
}
}
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.SkippedDetail = capList(out.SkippedDetail)
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
})
}
// ===== 工具 =====
// 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])
}