仰望星辰工作室

feat: 新增旧版(PHP 1.x)数据迁移命令

F fqh 提交于 2026-09-13 00:01 · e3ce873 ·父提交 5be97f3
feat: 新增旧版(PHP 1.x)数据迁移命令

- 新增 internal/migrate:迁移话题/帖子/媒体/点赞/评论/公告/用户/管理员/封禁/举报
- 主程序新增子命令 ./clearlove migrate <旧版 data.db> [-uploads 目录] [-dry]
- 旧版 status(0 正常 / 1 隐藏)自动取反为新版(1 可见 / 0 隐藏)
- 保留原始 ID 与时间;点赞数按数量补齐 likes 占位记录,避免被一次点赞清零
- 媒体文件复制到 uploads/legacy/;用户/管理员 bcrypt 密码直接沿用
- 迁移幂等可重复执行;-dry 支持试运行只统计不写入
- 新增单元测试覆盖字段映射、状态取反、媒体复制与幂等性
- README 补充迁移章节与不迁移项说明
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, &notnull, &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{