feat: 管理端板块管理与旧版数据导入,补充部署运营文档

- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
2026-09-24 03:37:44 +08:00
parent c1f6a6636b
commit 3e55b5d230
53 changed files with 5078 additions and 409 deletions

View File

@@ -0,0 +1,738 @@
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])
}