feat: 新增旧版(PHP 1.x)数据迁移命令
5 个文件变更
+991
-0
fangqihang1717@163.com
| •.gitignore | +3 -0 |
| •README.md | +29 -0 |
| •internal/migrate/migrate.go | +624 -0 |
| •internal/migrate/migrate_test.go | +267 -0 |
| •main.go | +68 -0 |
变更内容
diff --git a/.gitignore b/.gitignore
index c17e9dc..20e6d7a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,9 @@ cloud/
# 本地 IDE / 助手数据
.codebuddy/
+# PHP 旧版源码副本(仅本地迁移参考,不纳入本仓库)
+旧版本/
+
# 系统文件
.DS_Store
Thumbs.db
diff --git a/README.md b/README.md
index 4f0ed76..549f329 100644
--- a/README.md
+++ b/README.md
@@ -128,6 +128,35 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o cle
安装状态与数据库配置保存在 `data/config.json`,之后启动直接进入前台首页。
+### 从 PHP 旧版(1.x)迁移数据
+
+如果原来用的是 ClearLove PHP 版(clearlove-lovecards 1.x),可以把老站数据整体搬到 2.0。
+**先完成新版安装**(能正常打开首页)后执行:
+
+```bash
+# 1. 停掉服务后,先试运行(只统计、不写入)
+./clearlove migrate /www/wwwroot/old/data.db -uploads /www/wwwroot/old/uploads -dry
+
+# 2. 确认数字无误后正式迁移
+./clearlove migrate /www/wwwroot/old/data.db -uploads /www/wwwroot/old/uploads
+
+# 3. 启动服务即可
+./clearlove
+```
+
+| 迁移内容 | 说明 |
+|---|---|
+| 帖子 / 评论 / 话题 | 保留原始 ID 与时间;旧版状态语义(0 正常 / 1 隐藏)自动取反为新版(1 可见 / 0 隐藏) |
+| 图片与视频 | 从旧版 `uploads/` 复制到新版 `uploads/legacy/`,文件缺失会跳过并统计 |
+| 点赞数 | 保留计数,并按其数量补齐占位记录,避免迁移后被一次点赞清零 |
+| 公告 | 仅迁移启用中的公告,正文前 20 字作为新版标题 |
+| 用户 / 管理员 | 密码同为 bcrypt(PHP `$2y$` 与 Go 兼容),**可直接用原密码登录** |
+| 封禁名单 | `ip` / `fingerprint` 原样迁移 |
+| 举报记录 | 状态归一为新版的「待处理 / 已处理」 |
+
+> ❌ 不迁移:访问量统计(新版统计口径不同)、帖子置顶(新版无此功能)、旧版主题与界面配置(需在新后台重新设置)。
+> ✅ 迁移可重复执行,已存在的记录会按 ID / 用户名 / 封禁项自动跳过;`-uploads` 省略时默认取旧库同级的 `uploads/` 目录。
+
---
## 🧭 宝塔面板部署(推荐)
diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go
new file mode 100644
index 0000000..dd5cb67
--- /dev/null
+++ b/internal/migrate/migrate.go
@@ -0,0 +1,624 @@
+// Package migrate 将 ClearLove PHP 旧版(1.x,SQLite data.db)的数据迁移到 2.0。
+//
+// 迁移内容:话题 / 帖子(含媒体文件与点赞数)/ 评论 / 公告 / 用户 / 管理员 /
+// 封禁名单 / 举报。访问量(visits)与置顶(is_pinned)在新版中没有对应结构,跳过。
+//
+// 特点:
+// - 保留原有自增 ID(评论、举报的关联关系不受影响)
+// - 可重复执行:已存在的记录按 ID / 用户名 / (类型,值) 判重跳过
+// - 旧版 status 语义为 0=正常 1=隐藏,新版为 1=可见 0=隐藏,迁移时自动取反
+package migrate
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ _ "modernc.org/sqlite"
+
+ "clearlove/internal/config"
+ "clearlove/internal/database"
+ "clearlove/internal/models"
+ "clearlove/internal/util"
+)
+
+// Stats 迁移结果统计
+type Stats struct {
+ Topics int
+ Posts int
+ Media int
+ Comments int
+ Notices int
+ Users int
+ Admins int
+ Bans int
+ Reports int
+ Likes int
+ Skipped int // 已存在而跳过的记录
+ Missing int // 媒体文件缺失数量
+ NoPostRef int // 引用了不存在帖子的评论 / 举报
+}
+
+// dry 试运行:只统计不写入
+var dry bool
+
+// Run 执行迁移。oldDB 为旧版 data.db 路径,oldUploads 为旧版 uploads 目录。
+func Run(oldDB, oldUploads string, dryRun bool) (*Stats, error) {
+ dry = dryRun
+ if _, err := os.Stat(oldDB); err != nil {
+ return nil, fmt.Errorf("找不到旧版数据库文件: %s", oldDB)
+ }
+ if oldUploads == "" {
+ oldUploads = filepath.Join(filepath.Dir(oldDB), "uploads")
+ }
+ old, err := sql.Open("sqlite", oldDB)
+ if err != nil {
+ return nil, fmt.Errorf("打开旧版数据库失败: %w", err)
+ }
+ defer old.Close()
+ if err := old.Ping(); err != nil {
+ return nil, fmt.Errorf("旧版数据库无法读取: %w", err)
+ }
+ if !hasTable(old, "posts") {
+ return nil, fmt.Errorf("%s 中找不到 posts 表,请确认这是旧版 ClearLove 的 data.db", oldDB)
+ }
+
+ st := &Stats{}
+ // 话题:先建立 名称 -> 新 ID 映射
+ topicMap, err := migrateTopics(old, st)
+ if err != nil {
+ return st, err
+ }
+ if err := migratePosts(old, topicMap, oldUploads, st); err != nil {
+ return st, err
+ }
+ if err := migrateComments(old, st); err != nil {
+ return st, err
+ }
+ if err := migrateNotices(old, st); err != nil {
+ return st, err
+ }
+ if err := migrateUsers(old, st); err != nil {
+ return st, err
+ }
+ if err := migrateAdmins(old, st); err != nil {
+ return st, err
+ }
+ if err := migrateBans(old, st); err != nil {
+ return st, err
+ }
+ if err := migrateReports(old, st); err != nil {
+ return st, err
+ }
+ // 评论数 / 点赞数以实际记录为准重算
+ if !dry {
+ _, _ = database.DB.Exec(`UPDATE posts SET comment_count =
+ (SELECT COUNT(1) FROM comments WHERE comments.post_id = posts.id)`)
+ }
+ return st, nil
+}
+
+// ---------- 通用辅助 ----------
+
+// hasTable 旧库中是否存在某张表
+func hasTable(db *sql.DB, name string) bool {
+ var n int
+ err := db.QueryRow("SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&n)
+ return err == nil && n > 0
+}
+
+// cols 读取旧表所有列名(兼容不同小版本字段差异)
+func cols(db *sql.DB, table string) map[string]bool {
+ out := map[string]bool{}
+ rows, err := db.Query("PRAGMA table_info(" + table + ")")
+ if err != nil {
+ return out
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var cid int
+ var name, ctype string
+ var notnull, pk int
+ var dflt sql.NullString
+ if rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk) == nil {
+ out[name] = true
+ }
+ }
+ return out
+}
+
+// pick 从列集合中挑选第一个存在的列名,不存在则返回 fallback 表达式
+func pick(has map[string]bool, names []string, fallback string) string {
+ for _, n := range names {
+ if has[n] {
+ return n
+ }
+ }
+ return fallback
+}
+
+// ns 空值安全的字符串
+func ns(v sql.NullString) string {
+ if v.Valid {
+ return v.String
+ }
+ return ""
+}
+
+// exists 新版库中是否已有该记录
+func exists(q string, args ...any) bool {
+ return models.QueryInt(q, args...) > 0
+}
+
+// exec 写操作(试运行时不执行)
+func exec(q string, args ...any) (sql.Result, error) {
+ if dry {
+ return nil, nil
+ }
+ return database.DB.Exec(q, args...)
+}
+
+// parseTime 旧版时间(SQLite 为 UTC 的 "YYYY-MM-DD HH:MM:SS")转 RFC3339
+func parseTime(s string) string {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return models.Now()
+ }
+ for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05Z07:00",
+ "2006-01-02T15:04:05", "2006-01-02"} {
+ if t, err := time.Parse(layout, s); err == nil {
+ return t.UTC().Format(time.RFC3339)
+ }
+ }
+ return models.Now()
+}
+
+// ensureTopic 按名称取得话题 ID(不存在则创建)
+func ensureTopic(name string, m map[string]int64) int64 {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return 0
+ }
+ if id, ok := m[name]; ok {
+ return id
+ }
+ if id := models.QueryInt("SELECT id FROM topics WHERE name=?", name); id > 0 {
+ m[name] = id
+ return id
+ }
+ res, err := exec("INSERT INTO topics(name,created_at) VALUES(?,?)", name, models.Now())
+ if err != nil || res == nil {
+ return 0
+ }
+ id, _ := res.LastInsertId()
+ m[name] = id
+ return id
+}
+
+// ---------- 各类数据 ----------
+
+// migrateTopics 迁移话题表
+func migrateTopics(old *sql.DB, st *Stats) (map[string]int64, error) {
+ m := map[string]int64{}
+ rows, err := old.Query("SELECT name FROM topics")
+ if err != nil {
+ return m, nil // 旧库可能没有 topics 表
+ }
+ defer rows.Close()
+ var names []string
+ for rows.Next() {
+ var name sql.NullString
+ if rows.Scan(&name) == nil {
+ if v := strings.TrimSpace(ns(name)); v != "" {
+ names = append(names, v)
+ }
+ }
+ }
+ for _, n := range names {
+ if exists("SELECT COUNT(1) FROM topics WHERE name=?", n) {
+ st.Skipped++
+ }
+ ensureTopic(n, m)
+ st.Topics++
+ }
+ return m, nil
+}
+
+// MediaItem 旧版媒体 JSON 单元
+type MediaItem struct {
+ File string `json:"file"`
+ Type string `json:"type"`
+}
+
+// migratePosts 迁移帖子与媒体、点赞
+func migratePosts(old *sql.DB, topicMap map[string]int64, oldUploads string, st *Stats) error {
+ has := cols(old, "posts")
+ q := fmt.Sprintf("SELECT id, nickname, content, %s AS topic, %s AS media, %s AS likes, %s AS ip, %s AS fp, %s AS status, %s AS is_admin, %s AS created FROM posts ORDER BY id",
+ pick(has, []string{"topic"}, "''"),
+ pick(has, []string{"media"}, "''"),
+ pick(has, []string{"likes"}, "0"),
+ pick(has, []string{"ip_address", "ip"}, "''"),
+ pick(has, []string{"fingerprint"}, "''"),
+ pick(has, []string{"status"}, "0"),
+ pick(has, []string{"is_admin"}, "0"),
+ pick(has, []string{"created_at"}, "''"))
+ rows, err := old.Query(q)
+ if err != nil {
+ return fmt.Errorf("读取旧版帖子失败: %w", err)
+ }
+ type post struct {
+ id int64
+ nickname string
+ content string
+ topic string
+ media string
+ likes int64
+ ip string
+ fp string
+ status int64
+ isAdmin int64
+ created string
+ }
+ var list []post
+ for rows.Next() {
+ var p post
+ var nn, ct, tp, md, ip, fp, cr sql.NullString
+ var ls, sta, adm sql.NullInt64
+ if err := rows.Scan(&p.id, &nn, &ct, &tp, &md, &ls, &ip, &fp, &sta, &adm, &cr); err != nil {
+ continue
+ }
+ p.nickname, p.content, p.topic, p.media = ns(nn), ns(ct), ns(tp), ns(md)
+ p.ip, p.fp, p.created = ns(ip), ns(fp), ns(cr)
+ p.likes, p.status, p.isAdmin = ls.Int64, sta.Int64, adm.Int64
+ list = append(list, p)
+ }
+ rows.Close()
+
+ for _, p := range list {
+ if exists("SELECT COUNT(1) FROM posts WHERE id=?", p.id) {
+ st.Skipped++
+ continue
+ }
+ // 旧版:0(或 NULL)= 正常展示,1 = 隐藏;新版:1 = 可见,0 = 隐藏 -> 取反
+ newStatus := int64(1)
+ if p.status == 1 {
+ newStatus = 0
+ }
+ nickname := strings.TrimSpace(p.nickname)
+ if nickname == "" {
+ nickname = "匿名"
+ }
+ topicID := ensureTopic(p.topic, topicMap)
+ if _, err := exec(`INSERT INTO posts(id,user_id,nickname,content,topic_id,ip,fingerprint,
+ status,like_count,comment_count,is_admin,badges,created_at) VALUES(?,?,?,?,?,?,?,?,?,0,?,?,?)`,
+ p.id, 0, nickname, p.content, topicID, p.ip, p.fp, newStatus, p.likes,
+ p.isAdmin, "", parseTime(p.created)); err != nil {
+ return fmt.Errorf("写入帖子 #%d 失败: %w", p.id, err)
+ }
+ st.Posts++
+
+ // 点赞:新版点赞数由 likes 表实时统计,这里按旧版计数补齐占位记录,
+ // 避免迁移后用户点一次赞就把原有赞数清零
+ if p.likes > 0 {
+ for i := int64(0); i < p.likes; i++ {
+ if _, err := exec("INSERT INTO likes(post_id,user_id,fingerprint,created_at) VALUES(?,0,?,?)",
+ p.id, fmt.Sprintf("legacy-%d-%d", p.id, i), parseTime(p.created)); err == nil {
+ st.Likes++
+ }
+ }
+ }
+
+ // 媒体:复制文件到新版 uploads/legacy/ 并建立 medias 记录
+ var items []MediaItem
+ if strings.TrimSpace(p.media) != "" {
+ _ = json.Unmarshal([]byte(p.media), &items)
+ }
+ for _, it := range items {
+ name := filepath.Base(strings.TrimSpace(it.File))
+ if name == "" || name == "." {
+ continue
+ }
+ if copyMedia(oldUploads, name, st) != "" {
+ typ := it.Type
+ if typ != "video" {
+ typ = "image"
+ }
+ if _, err := exec("INSERT INTO medias(post_id,type,path,created_at) VALUES(?,?,?,?)",
+ p.id, typ, "/uploads/legacy/"+name, parseTime(p.created)); err == nil {
+ st.Media++
+ }
+ }
+ }
+ }
+ util.Log("info", "旧版帖子迁移完成:新增 %d,跳过 %d", st.Posts, st.Skipped)
+ return nil
+}
+
+// copyMedia 把旧版上传目录中的文件复制到新版 uploads/legacy/,返回目标文件名
+func copyMedia(oldUploads, name string, st *Stats) string {
+ src := filepath.Join(oldUploads, name)
+ if fi, err := os.Stat(src); err != nil || fi.IsDir() {
+ st.Missing++
+ return ""
+ }
+ dstDir := filepath.Join(config.Cfg.UploadDir, "legacy")
+ if dry {
+ st.Media++
+ return name
+ }
+ if err := os.MkdirAll(dstDir, 0o755); err != nil {
+ st.Missing++
+ return ""
+ }
+ dst := filepath.Join(dstDir, name)
+ if _, err := os.Stat(dst); err == nil {
+ return name // 已复制过
+ }
+ in, err := os.Open(src)
+ if err != nil {
+ st.Missing++
+ return ""
+ }
+ defer in.Close()
+ out, err := os.Create(dst)
+ if err != nil {
+ st.Missing++
+ return ""
+ }
+ defer out.Close()
+ if _, err := io.Copy(out, in); err != nil {
+ st.Missing++
+ return ""
+ }
+ return name
+}
+
+// migrateComments 迁移评论
+func migrateComments(old *sql.DB, st *Stats) error {
+ has := cols(old, "comments")
+ q := fmt.Sprintf("SELECT id, post_id, nickname, content, %s AS ip, %s AS fp, %s AS created FROM comments ORDER BY id",
+ pick(has, []string{"ip_address", "ip"}, "''"),
+ pick(has, []string{"fingerprint"}, "''"),
+ pick(has, []string{"created_at"}, "''"))
+ rows, err := old.Query(q)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var id, pid sql.NullInt64
+ var nn, ct, ip, fp, cr sql.NullString
+ if err := rows.Scan(&id, &pid, &nn, &ct, &ip, &fp, &cr); err != nil {
+ continue
+ }
+ if !exists("SELECT COUNT(1) FROM posts WHERE id=?", pid.Int64) {
+ st.NoPostRef++
+ continue
+ }
+ if exists("SELECT COUNT(1) FROM comments WHERE id=?", id.Int64) {
+ st.Skipped++
+ continue
+ }
+ nickname := strings.TrimSpace(ns(nn))
+ if nickname == "" {
+ nickname = "匿名"
+ }
+ if _, err := exec(`INSERT INTO comments(id,post_id,user_id,nickname,content,ip,fingerprint,created_at)
+ VALUES(?,?,0,?,?,?,?,?)`,
+ id.Int64, pid.Int64, nickname, ns(ct), ns(ip), ns(fp), parseTime(ns(cr))); err != nil {
+ return fmt.Errorf("写入评论 #%d 失败: %w", id.Int64, err)
+ }
+ st.Comments++
+ }
+ return nil
+}
+
+// migrateNotices 迁移公告(旧版 announcements -> 新版 notices)
+func migrateNotices(old *sql.DB, st *Stats) error {
+ has := cols(old, "announcements")
+ q := fmt.Sprintf("SELECT content, %s AS active, %s AS created FROM announcements ORDER BY id",
+ pick(has, []string{"active"}, "1"),
+ pick(has, []string{"created_at"}, "''"))
+ rows, err := old.Query(q)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var content, created sql.NullString
+ var active sql.NullInt64
+ if err := rows.Scan(&content, &active, &created); err != nil {
+ continue
+ }
+ if active.Valid && active.Int64 != 1 {
+ continue // 已停用的公告不导入,避免它成为首页最新公告
+ }
+ body := strings.TrimSpace(ns(content))
+ if body == "" {
+ continue
+ }
+ // 新版公告有标题字段,旧版没有:截取正文前 20 字作为标题
+ title := []rune(body)
+ if len(title) > 20 {
+ title = title[:20]
+ }
+ if _, err := exec("INSERT INTO notices(title,content,created_at) VALUES(?,?,?)",
+ string(title), body, parseTime(ns(created))); err != nil {
+ return fmt.Errorf("写入公告失败: %w", err)
+ }
+ st.Notices++
+ }
+ return nil
+}
+
+// migrateUsers 迁移注册用户(密码同为 bcrypt,可直接沿用)
+func migrateUsers(old *sql.DB, st *Stats) error {
+ has := cols(old, "users")
+ if len(has) == 0 {
+ return nil
+ }
+ q := fmt.Sprintf("SELECT id, username, password, %s AS email, %s AS created FROM users ORDER BY id",
+ pick(has, []string{"email"}, "''"),
+ pick(has, []string{"created_at"}, "''"))
+ rows, err := old.Query(q)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var id sql.NullInt64
+ var un, pw, em, cr sql.NullString
+ if err := rows.Scan(&id, &un, &pw, &em, &cr); err != nil {
+ continue
+ }
+ username := strings.TrimSpace(ns(un))
+ if username == "" || ns(pw) == "" {
+ continue
+ }
+ if exists("SELECT COUNT(1) FROM users WHERE username=?", username) {
+ st.Skipped++
+ continue
+ }
+ // 新版 email 非空且唯一:旧版可能为空或重复,这里补齐占位邮箱
+ email := strings.TrimSpace(ns(em))
+ if email == "" {
+ email = fmt.Sprintf("legacy_%d@migrated.local", id.Int64)
+ }
+ if exists("SELECT COUNT(1) FROM users WHERE email=?", email) {
+ email = fmt.Sprintf("legacy_%d@migrated.local", id.Int64)
+ }
+ if _, err := exec("INSERT INTO users(username,email,password,avatar,status,created_at) VALUES(?,?,?,'',1,?)",
+ username, email, ns(pw), parseTime(ns(cr))); err != nil {
+ return fmt.Errorf("写入用户 %s 失败: %w", username, err)
+ }
+ st.Users++
+ }
+ return nil
+}
+
+// migrateAdmins 迁移管理员(密码同为 bcrypt)
+func migrateAdmins(old *sql.DB, st *Stats) error {
+ has := cols(old, "admins")
+ if len(has) == 0 {
+ return nil
+ }
+ q := fmt.Sprintf("SELECT username, password, %s AS role FROM admins",
+ pick(has, []string{"role"}, "'admin'"))
+ rows, err := old.Query(q)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var un, pw, role sql.NullString
+ if err := rows.Scan(&un, &pw, &role); err != nil {
+ continue
+ }
+ username := strings.TrimSpace(ns(un))
+ if username == "" || ns(pw) == "" {
+ continue
+ }
+ if exists("SELECT COUNT(1) FROM admins WHERE username=?", username) {
+ st.Skipped++
+ continue
+ }
+ // 旧版权限模型与新版权限组不同,非超管先以空权限导入,由管理员在后台重新分配
+ newRole, perms := "custom", "[]"
+ if ns(role) == "super" {
+ newRole, perms = "super", "[]"
+ }
+ if _, err := exec("INSERT INTO admins(username,password,role,perms,created_at) VALUES(?,?,?,?,?)",
+ username, ns(pw), newRole, perms, models.Now()); err != nil {
+ return fmt.Errorf("写入管理员 %s 失败: %w", username, err)
+ }
+ st.Admins++
+ }
+ return nil
+}
+
+// migrateBans 迁移封禁名单(banned_devices -> bans)
+func migrateBans(old *sql.DB, st *Stats) error {
+ rows, err := old.Query("SELECT type, value FROM banned_devices")
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var typ, val sql.NullString
+ if err := rows.Scan(&typ, &val); err != nil {
+ continue
+ }
+ t, v := strings.TrimSpace(ns(typ)), strings.TrimSpace(ns(val))
+ if (t != "ip" && t != "fingerprint") || v == "" {
+ continue
+ }
+ if exists("SELECT COUNT(1) FROM bans WHERE btype=? AND bvalue=?", t, v) {
+ st.Skipped++
+ continue
+ }
+ if _, err := exec("INSERT INTO bans(btype,bvalue,created_at) VALUES(?,?,?)", t, v, models.Now()); err != nil {
+ return fmt.Errorf("写入封禁记录失败: %w", err)
+ }
+ st.Bans++
+ }
+ return nil
+}
+
+// migrateReports 迁移举报
+func migrateReports(old *sql.DB, st *Stats) error {
+ has := cols(old, "reports")
+ if len(has) == 0 {
+ return nil
+ }
+ q := fmt.Sprintf("SELECT id, post_id, %s AS reason, %s AS detail, %s AS ip, %s AS status, %s AS created FROM reports ORDER BY id",
+ pick(has, []string{"reason"}, "''"),
+ pick(has, []string{"detail"}, "''"),
+ pick(has, []string{"reporter_ip", "ip"}, "''"),
+ pick(has, []string{"status"}, "0"),
+ pick(has, []string{"created_at"}, "''"))
+ rows, err := old.Query(q)
+ if err != nil {
+ return nil
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var id, pid, status sql.NullInt64
+ var reason, detail, ip, cr sql.NullString
+ if err := rows.Scan(&id, &pid, &reason, &detail, &ip, &status, &cr); err != nil {
+ continue
+ }
+ // 帖子已被删除(或未迁移)的举报没有意义,跳过
+ if !exists("SELECT COUNT(1) FROM posts WHERE id=?", pid.Int64) {
+ st.NoPostRef++
+ continue
+ }
+ if exists("SELECT COUNT(1) FROM reports WHERE id=?", id.Int64) {
+ st.Skipped++
+ continue
+ }
+ text := strings.TrimSpace(ns(reason))
+ if d := strings.TrimSpace(ns(detail)); d != "" {
+ if text == "" {
+ text = d
+ } else {
+ text += ":" + d
+ }
+ }
+ // 旧版 0=待处理,1=已展示,2=已删除;新版 0=待处理,1=已处理
+ newStatus := int64(0)
+ if status.Valid && status.Int64 != 0 {
+ newStatus = 1
+ }
+ if _, err := exec("INSERT INTO reports(id,post_id,reason,reporter_ip,status,ai_result,created_at) VALUES(?,?,?,?,?,'',?)",
+ id.Int64, pid.Int64, text, ns(ip), newStatus, parseTime(ns(cr))); err != nil {
+ return fmt.Errorf("写入举报 #%d 失败: %w", id.Int64, err)
+ }
+ st.Reports++
+ }
+ return nil
+}
diff --git a/internal/migrate/migrate_test.go b/internal/migrate/migrate_test.go
new file mode 100644
index 0000000..932c86b
--- /dev/null
+++ b/internal/migrate/migrate_test.go
@@ -0,0 +1,267 @@
+package migrate
+
+import (
+ "database/sql"
+ "os"
+ "path/filepath"
+ "testing"
+
+ _ "modernc.org/sqlite"
+
+ "clearlove/internal/config"
+ "clearlove/internal/database"
+ "clearlove/internal/models"
+)
+
+// buildOldDB 构造一个旧版(PHP 1.x)结构与数据的 SQLite 数据库
+func buildOldDB(t *testing.T, path string) {
+ t.Helper()
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatalf("创建旧库失败: %v", err)
+ }
+ defer db.Close()
+ stmts := []string{
+ `CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, nickname TEXT NOT NULL, content TEXT NOT NULL,
+ topic TEXT, media TEXT, likes INTEGER DEFAULT 0, is_pinned INTEGER DEFAULT 0, ip_address TEXT,
+ fingerprint TEXT, status INTEGER DEFAULT 0, is_admin INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`,
+ `CREATE TABLE comments (id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL, nickname TEXT NOT NULL,
+ content TEXT NOT NULL, ip_address TEXT, fingerprint TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`,
+ `CREATE TABLE topics (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`,
+ `CREATE TABLE announcements (id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, image TEXT,
+ active INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`,
+ `CREATE TABLE admins (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'admin', permissions TEXT NOT NULL DEFAULT '')`,
+ `CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL,
+ email TEXT, email_verified INTEGER DEFAULT 0, verification_token TEXT, email_notify INTEGER DEFAULT 1,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`,
+ `CREATE TABLE banned_devices (id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, value TEXT NOT NULL,
+ reason TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE (type, value))`,
+ `CREATE TABLE reports (id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL, reason TEXT NOT NULL,
+ detail TEXT, reporter_ip TEXT, reporter_fingerprint TEXT, status INTEGER DEFAULT 0, handled_by INTEGER DEFAULT 0,
+ handled_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`,
+ }
+ for _, s := range stmts {
+ if _, err := db.Exec(s); err != nil {
+ t.Fatalf("旧库建表失败: %v", err)
+ }
+ }
+ seed := []string{
+ `INSERT INTO topics(name) VALUES('校园'),('日常')`,
+ // 正常帖:1 张图片、5 个赞、status=0(旧版 0 = 正常展示)
+ `INSERT INTO posts(nickname,content,topic,media,likes,ip_address,fingerprint,status,is_admin,created_at)
+ VALUES('小明','第一条帖子','校园','[{"file":"a.webp","type":"image"}]',5,'1.1.1.1','fp1',0,0,'2026-01-02 03:04:05')`,
+ // 被隐藏的帖子:status=1
+ `INSERT INTO posts(nickname,content,topic,media,likes,status,created_at)
+ VALUES('被屏蔽者','这条被隐藏了','日常','[]',0,1,'2026-01-03 03:04:05')`,
+ // 管理员帖:is_admin=1
+ `INSERT INTO posts(nickname,content,topic,media,likes,status,is_admin,created_at)
+ VALUES('官方','管理员帖','','[]',2,0,1,'2026-01-04 03:04:05')`,
+ `INSERT INTO comments(post_id,nickname,content,ip_address,fingerprint,created_at) VALUES(1,'小红','评论一','2.2.2.2','fp2','2026-01-05 03:04:05')`,
+ `INSERT INTO comments(post_id,nickname,content,created_at) VALUES(1,'小刚','评论二','2026-01-06 03:04:05')`,
+ `INSERT INTO comments(post_id,nickname,content,created_at) VALUES(999,'幽灵','孤儿评论','2026-01-07 03:04:05')`,
+ `INSERT INTO announcements(content,active,created_at) VALUES('欢迎来到表白墙','1','2026-01-08 03:04:05')`,
+ `INSERT INTO announcements(content,active,created_at) VALUES('已停用公告','0','2026-01-09 03:04:05')`,
+ `INSERT INTO users(username,password,email,created_at) VALUES('alice','$2y$10$abcdefghijklmnopqrstuv','a@b.com','2026-01-10 03:04:05')`,
+ `INSERT INTO users(username,password,email,created_at) VALUES('bob','$2y$10$abcdefghijklmnopqrstuv','','2026-01-11 03:04:05')`,
+ `INSERT INTO admins(username,password,role) VALUES('boss','$2y$10$abcdefghijklmnopqrstuv','super')`,
+ `INSERT INTO admins(username,password,role) VALUES('mod','$2y$10$abcdefghijklmnopqrstuv','admin')`,
+ `INSERT INTO banned_devices(type,value) VALUES('ip','9.9.9.9'),('fingerprint','badfp')`,
+ `INSERT INTO reports(post_id,reason,detail,reporter_ip,status) VALUES(1,'广告','发小广告','3.3.3.3',0)`,
+ `INSERT INTO reports(post_id,reason,detail,reporter_ip,status) VALUES(999,'违规','帖子已删','4.4.4.4',0)`,
+ }
+ for _, s := range seed {
+ if _, err := db.Exec(s); err != nil {
+ t.Fatalf("旧库写入测试数据失败: %v\nSQL: %s", err, s)
+ }
+ }
+}
+
+// setupNew 准备新版数据库环境
+func setupNew(t *testing.T, root string) {
+ t.Helper()
+ newDir := filepath.Join(root, "new")
+ if err := os.MkdirAll(newDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ config.Cfg = config.Config{
+ DataDir: newDir,
+ UploadDir: filepath.Join(root, "newuploads"),
+ DBType: "sqlite",
+ SQLitePath: filepath.Join(newDir, "clearlove.db"),
+ Installed: true,
+ Secret: "test-secret",
+ }
+ if err := database.Connect(); err != nil {
+ t.Fatalf("新版数据库连接失败: %v", err)
+ }
+ // Windows 下临时目录清理前必须关闭连接,否则文件被占用
+ t.Cleanup(func() {
+ if database.DB != nil {
+ _ = database.DB.Close()
+ database.DB = nil
+ }
+ })
+ if err := database.Migrate(); err != nil {
+ t.Fatalf("新版数据库迁移失败: %v", err)
+ }
+}
+
+func TestRunMigration(t *testing.T) {
+ root := t.TempDir()
+ oldDir := filepath.Join(root, "old")
+ oldUploads := filepath.Join(oldDir, "uploads")
+ if err := os.MkdirAll(oldUploads, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(oldUploads, "a.webp"), []byte("fake-image-bytes"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ oldDB := filepath.Join(oldDir, "data.db")
+ buildOldDB(t, oldDB)
+ setupNew(t, root)
+
+ st, err := Run(oldDB, oldUploads, false)
+ if err != nil {
+ t.Fatalf("迁移失败: %v", err)
+ }
+
+ // 统计断言
+ if st.Posts != 3 {
+ t.Errorf("帖子数 = %d, 期望 3", st.Posts)
+ }
+ if st.Comments != 2 {
+ t.Errorf("评论数 = %d, 期望 2(孤儿评论应被跳过)", st.Comments)
+ }
+ if st.Media != 1 {
+ t.Errorf("媒体数 = %d, 期望 1", st.Media)
+ }
+ if st.Likes != 7 {
+ t.Errorf("点赞补齐 = %d, 期望 7", st.Likes)
+ }
+ if st.Users != 2 {
+ t.Errorf("用户数 = %d, 期望 2", st.Users)
+ }
+ if st.Admins != 2 {
+ t.Errorf("管理员数 = %d, 期望 2", st.Admins)
+ }
+ if st.Bans != 2 {
+ t.Errorf("封禁数 = %d, 期望 2", st.Bans)
+ }
+ if st.Reports != 1 {
+ t.Errorf("举报数 = %d, 期望 1(关联帖子不存在的应跳过)", st.Reports)
+ }
+ if st.Notices != 1 {
+ t.Errorf("公告数 = %d, 期望 1(停用公告不导入)", st.Notices)
+ }
+ if st.NoPostRef != 2 {
+ t.Errorf("无效关联数 = %d, 期望 2(1 条评论 + 1 条举报)", st.NoPostRef)
+ }
+
+ db := database.DB
+ // ID 保留
+ if n := models.QueryInt("SELECT COUNT(1) FROM posts WHERE id IN (1,2,3)"); n != 3 {
+ t.Errorf("帖子 ID 未保留,命中 %d 条", n)
+ }
+ // status 取反:旧 0 -> 新 1,旧 1 -> 新 0
+ if n := models.QueryInt("SELECT status FROM posts WHERE id=1"); n != 1 {
+ t.Errorf("帖子1 status = %d, 期望 1(旧版 0 为正常)", n)
+ }
+ if n := models.QueryInt("SELECT status FROM posts WHERE id=2"); n != 0 {
+ t.Errorf("帖子2 status = %d, 期望 0(旧版 1 为隐藏)", n)
+ }
+ // 管理员帖
+ if n := models.QueryInt("SELECT is_admin FROM posts WHERE id=3"); n != 1 {
+ t.Errorf("帖子3 is_admin = %d, 期望 1", n)
+ }
+ // 点赞数与 likes 记录一致
+ if n := models.QueryInt("SELECT like_count FROM posts WHERE id=1"); n != 5 {
+ t.Errorf("帖子1 点赞数 = %d, 期望 5", n)
+ }
+ if n := models.QueryInt("SELECT COUNT(1) FROM likes WHERE post_id=1"); n != 5 {
+ t.Errorf("帖子1 likes 记录 = %d, 期望 5", n)
+ }
+ // 评论数重算
+ if n := models.QueryInt("SELECT comment_count FROM posts WHERE id=1"); n != 2 {
+ t.Errorf("帖子1 评论数 = %d, 期望 2", n)
+ }
+ // 话题关联
+ if n := models.QueryInt("SELECT topic_id FROM posts WHERE id=1"); n == 0 {
+ t.Error("帖子1 未关联话题")
+ }
+ if n := models.QueryInt("SELECT COUNT(1) FROM topics WHERE name='校园'"); n != 1 {
+ t.Error("话题「校园」未迁移")
+ }
+ // 媒体:文件复制 + 记录写入
+ if _, err := os.Stat(filepath.Join(config.Cfg.UploadDir, "legacy", "a.webp")); err != nil {
+ t.Errorf("媒体文件未复制: %v", err)
+ }
+ if got := models.QueryStr("SELECT path FROM medias WHERE post_id=1"); got != "/uploads/legacy/a.webp" {
+ t.Errorf("媒体路径 = %q", got)
+ }
+ // 时间格式统一为 RFC3339
+ created := models.QueryStr("SELECT created_at FROM posts WHERE id=1")
+ if _, err := sql.Open("sqlite", oldDB); err == nil {
+ if created != "2026-01-02T03:04:05Z" {
+ t.Errorf("created_at = %q, 期望 2026-01-02T03:04:05Z", created)
+ }
+ }
+ // 用户:空邮箱补齐占位
+ if got := models.QueryStr("SELECT email FROM users WHERE username='bob'"); got == "" {
+ t.Error("用户 bob 的邮箱未补齐")
+ }
+ // 密码哈希原样保留(PHP bcrypt 与 Go bcrypt 兼容)
+ if got := models.QueryStr("SELECT password FROM admins WHERE username='boss'"); got != "$2y$10$abcdefghijklmnopqrstuv" {
+ t.Errorf("管理员密码未保留: %q", got)
+ }
+ if got := models.QueryStr("SELECT role FROM admins WHERE username='boss'"); got != "super" {
+ t.Errorf("超管角色 = %q", got)
+ }
+ if got := models.QueryStr("SELECT role FROM admins WHERE username='mod'"); got != "custom" {
+ t.Errorf("普通管理员角色 = %q, 期望 custom", got)
+ }
+ // 举报 reason 合并 detail
+ if got := models.QueryStr("SELECT reason FROM reports WHERE id=1"); got != "广告:发小广告" {
+ t.Errorf("举报理由 = %q", got)
+ }
+
+ // 幂等:再次执行不应重复写入
+ st2, err := Run(oldDB, oldUploads, false)
+ if err != nil {
+ t.Fatalf("二次迁移失败: %v", err)
+ }
+ if st2.Posts != 0 || st2.Comments != 0 || st2.Users != 0 || st2.Admins != 0 {
+ t.Errorf("二次迁移不应新增数据: %+v", st2)
+ }
+ if n := models.QueryInt("SELECT COUNT(1) FROM posts"); n != 3 {
+ t.Errorf("二次迁移后帖子数 = %d, 期望 3", n)
+ }
+ if n := models.QueryInt("SELECT COUNT(1) FROM likes"); n != 7 {
+ t.Errorf("二次迁移后点赞记录 = %d, 期望 7", n)
+ }
+ _ = db
+}
+
+// TestRunDryRun 试运行不应写入任何数据
+func TestRunDryRun(t *testing.T) {
+ root := t.TempDir()
+ oldDir := filepath.Join(root, "old")
+ oldUploads := filepath.Join(oldDir, "uploads")
+ if err := os.MkdirAll(oldUploads, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ oldDB := filepath.Join(oldDir, "data.db")
+ buildOldDB(t, oldDB)
+ setupNew(t, root)
+
+ st, err := Run(oldDB, oldUploads, true)
+ if err != nil {
+ t.Fatalf("试运行失败: %v", err)
+ }
+ if st.Posts == 0 {
+ t.Error("试运行应统计出帖子数量")
+ }
+ if n := models.QueryInt("SELECT COUNT(1) FROM posts"); n != 0 {
+ t.Errorf("试运行写入了 %d 条帖子", n)
+ }
+}
diff --git a/main.go b/main.go
index 0aac06f..d05f2fb 100644
--- a/main.go
+++ b/main.go
@@ -16,6 +16,7 @@ import (
"clearlove/internal/database"
"clearlove/internal/handlers"
"clearlove/internal/middleware"
+ "clearlove/internal/migrate"
)
// webFS 嵌入全部模板与静态资源,保证单文件部署
@@ -24,6 +25,13 @@ import (
var webFS embed.FS
func main() {
+ // 子命令:从 PHP 旧版迁移数据
+ // ./clearlove migrate /path/to/old/data.db [-uploads /path/to/old/uploads] [-dry]
+ if len(os.Args) > 1 && os.Args[1] == "migrate" {
+ runMigrate(os.Args[2:])
+ return
+ }
+
config.Load()
// 运行时目录(单二进制部署时自动创建,无需手工准备)
if err := ensureDirs(); err != nil {
@@ -63,6 +71,66 @@ func main() {
}
}
+// runMigrate 执行旧版(PHP 1.x)数据迁移,迁移前需先完成新版安装
+func runMigrate(args []string) {
+ config.Load()
+ oldPath, uploadsDir := "", ""
+ dryRun := false
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "-uploads", "--uploads":
+ if i+1 < len(args) {
+ uploadsDir = args[i+1]
+ i++
+ }
+ case "-dry", "--dry", "-dry-run":
+ dryRun = true
+ default:
+ if oldPath == "" {
+ oldPath = args[i]
+ }
+ }
+ }
+ if oldPath == "" {
+ log.Fatalf("用法: %s migrate <旧版 data.db 路径> [-uploads 旧版 uploads 目录] [-dry]\n"+
+ "示例: %s migrate /www/wwwroot/old/data.db -uploads /www/wwwroot/old/uploads",
+ os.Args[0], os.Args[0])
+ }
+ if err := ensureDirs(); err != nil {
+ log.Fatalf("目录初始化失败: %v", err)
+ }
+ if !config.Cfg.Installed {
+ log.Fatal("新版尚未完成安装:请先启动服务并访问 /install 完成安装向导,再执行数据迁移")
+ }
+ if err := database.Connect(); err != nil {
+ log.Fatalf("数据库连接失败: %v", err)
+ }
+ if err := database.Migrate(); err != nil {
+ log.Fatalf("数据库迁移失败: %v", err)
+ }
+ log.Printf("开始从旧版迁移数据:%s", oldPath)
+ st, err := migrate.Run(oldPath, uploadsDir, dryRun)
+ if err != nil {
+ log.Fatalf("旧版数据迁移失败: %v", err)
+ }
+ prefix := ""
+ if dryRun {
+ prefix = "[试运行] "
+ }
+ log.Printf("%s迁移结果:帖子 %d、点赞补齐 %d、媒体 %d、评论 %d、话题 %d、公告 %d、用户 %d、管理员 %d、封禁 %d、举报 %d",
+ prefix, st.Posts, st.Likes, st.Media, st.Comments, st.Topics, st.Notices,
+ st.Users, st.Admins, st.Bans, st.Reports)
+ if st.Skipped > 0 || st.Missing > 0 || st.NoPostRef > 0 {
+ log.Printf("%s其中:跳过已存在记录 %d 条、媒体文件缺失 %d 个、关联帖子不存在的记录 %d 条",
+ prefix, st.Skipped, st.Missing, st.NoPostRef)
+ }
+ if dryRun {
+ log.Print("这是试运行,未写入任何数据;去掉 -dry 参数即可正式执行")
+ } else {
+ log.Print("迁移完成,重启服务后即可在新版看到数据")
+ }
+}
+
// ensureDirs 创建并校验运行时目录可写性(提前失败,避免运行中出错)
func ensureDirs() error {
dirs := []string{