- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
739 lines
25 KiB
Go
739 lines
25 KiB
Go
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])
|
||
}
|