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]) }