仰望星辰工作室

feat(migrate): 支持 MySQL 导出文件,并可在后台上传数据库完成迁移

F fqh 提交于 2026-09-13 00:09 · f67ea54 ·父提交 e3ce873
feat(migrate): 支持 MySQL 导出文件,并可在后台上传数据库完成迁移

数据源抽象
- 新增 Source 接口,统一 SQLite 文件与 MySQL 导出文件两种来源
- 新增 MySQL 导出文件解析器:语句切分(跳过字符串/注释)、CREATE TABLE 列定义、
  INSERT 多值元组、反斜杠与 '' 转义、无列名列表时按建表顺序还原
- 按文件内容自动识别来源(SQLite 文件头 / .sql 文本)

后台迁移界面
- 新增后台「数据迁移」页,支持上传 data.db 或 .sql,自动识别来源
- 支持填写旧版 uploads 目录绝对路径,自动复制媒体到 uploads/legacy/
- 迁移在后台异步执行 + 前端轮询进度,避免大文件导入时请求超时
- 统计结果表格化展示(帖子/评论/媒体/用户/管理员/封禁/举报/跳过/缺失等)

修正
- 试运行时帖子未真正入库,导致评论与举报被误判为关联失效而统计为 0,
  改为按本次迁移的帖子 ID 集合判断关联
- 修复 MySQL 解析中无列名列表的 INSERT:原先用 map 遍历导致列顺序随机、字段错位

测试
- 新增 MySQL 导出文件迁移测试(含转义、无列名 INSERT、状态取反、邮箱补齐)
- 试运行测试补充关联统计断言
- README 补充两种迁移方式说明
11 个文件变更 +1270 -279 fangqihang1717@163.com
•README.md +17 -6
•internal/handlers/migrate.go +165 -0
•internal/migrate/dump.go +424 -0
•internal/migrate/migrate.go +159 -271
•internal/migrate/migrate_test.go +157 -0
•internal/migrate/source.go +175 -0
•main.go +8 -2
•web/static/app.css +8 -0
•web/static/app.js +106 -0
•web/templates/admin.html +49 -0
•web/templates/admin_layout.html +2 -0
变更内容
diff --git a/README.md b/README.md
index 549f329..c594d5d 100644
--- a/README.md
+++ b/README.md
@@ -131,17 +131,28 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o cle
 ### 从 PHP 旧版(1.x)迁移数据
 
 如果原来用的是 ClearLove PHP 版(clearlove-lovecards 1.x),可以把老站数据整体搬到 2.0。
-**先完成新版安装**(能正常打开首页)后执行:
+**先完成新版安装**(能正常打开首页),然后二选一:
+
+#### 方式一:后台上传迁移(推荐)
+
+后台左侧「**数据迁移**」→ 上传旧版数据库文件 → 勾选「试运行」先看数量 → 确认无误后取消勾选再执行一次。
+
+| 支持的文件 | 获取方式 |
+|---|---|
+| 旧版 **SQLite** 的 `data.db` | 旧版项目根目录下的同名文件 |
+| 旧版 **MySQL** 导出的 `.sql` | 宝塔「数据库 → 备份」下载,或 `mysqldump -u用户 -p 库名 > old.sql` |
+
+上传后程序会**自动识别**类型,无需手动选择。「旧版 uploads 目录」填写服务器上的绝对路径(如 `/www/wwwroot/old/uploads`)时,会把旧站图片与视频复制到新版 `uploads/legacy/`;留空则只迁移文字数据。迁移在后台异步执行,**可以关掉页面**,稍后回来查看结果。
+
+#### 方式二:命令行
 
 ```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
+# MySQL 导出文件同样支持(自动识别)
+./clearlove migrate /www/wwwroot/old/old.sql
 ```
 
 | 迁移内容 | 说明 |
diff --git a/internal/handlers/migrate.go b/internal/handlers/migrate.go
new file mode 100644
index 0000000..11bfc4e
--- /dev/null
+++ b/internal/handlers/migrate.go
@@ -0,0 +1,165 @@
+package handlers
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+
+	"clearlove/internal/config"
+	"clearlove/internal/migrate"
+	"clearlove/internal/models"
+	"clearlove/internal/util"
+)
+
+// 单个上传文件大小上限(MB)
+const maxMigrateMB = 200
+
+// migrateJob 迁移任务状态(同一时间只允许一个任务)
+type migrateJob struct {
+	mu      sync.Mutex
+	running bool
+	done    bool
+	err     string
+	stats   *migrate.Stats
+	dry     bool
+	startAt time.Time
+}
+
+var migJob = &migrateJob{}
+
+// snapshot 读取任务状态快照(供页面渲染与轮询接口共用)
+func (j *migrateJob) snapshot() map[string]any {
+	j.mu.Lock()
+	defer j.mu.Unlock()
+	out := map[string]any{
+		"running": j.running,
+		"done":    j.done,
+		"err":     j.err,
+		"dry_run": j.dry,
+	}
+	if j.stats != nil {
+		out["stats"] = j.stats
+	}
+	if !j.startAt.IsZero() {
+		out["seconds"] = int(time.Since(j.startAt).Seconds())
+	}
+	return out
+}
+
+// AdminMigrate 旧版数据迁移页
+func AdminMigrate(w http.ResponseWriter, r *http.Request) {
+	if a := requireAdmin(w, r, models.PermSetting); a == nil {
+		return
+	}
+	Render(w, r, "admin_layout.html", "pg_admin_migrate", map[string]any{
+		"status": migJob.snapshot(),
+		"maxMB":  maxMigrateMB,
+	})
+}
+
+// AdminMigrateStart 接收旧版数据库文件并异步执行迁移
+func AdminMigrateStart(w http.ResponseWriter, r *http.Request) {
+	admin := requireAdmin(w, r, models.PermSetting)
+	if admin == nil {
+		return
+	}
+	migJob.mu.Lock()
+	running := migJob.running
+	migJob.mu.Unlock()
+	if running {
+		fail(w, http.StatusConflict, "已有迁移任务正在执行,请等待完成")
+		return
+	}
+
+	r.Body = http.MaxBytesReader(w, r.Body, maxMigrateMB<<20)
+	if err := r.ParseMultipartForm(16 << 20); err != nil {
+		fail(w, 400, fmt.Sprintf("上传失败:文件不能超过 %dMB", maxMigrateMB))
+		return
+	}
+	file, hdr, err := r.FormFile("file")
+	if err != nil {
+		fail(w, 400, "请选择旧版数据库文件")
+		return
+	}
+	defer file.Close()
+
+	tmpDir := filepath.Join(config.Cfg.DataDir, "tmp")
+	if err := os.MkdirAll(tmpDir, 0o755); err != nil {
+		fail(w, 500, "无法创建临时目录:"+err.Error())
+		return
+	}
+	ext := strings.ToLower(filepath.Ext(hdr.Filename))
+	switch ext {
+	case ".sql", ".sqlite", ".sqlite3":
+	default:
+		ext = ".db"
+	}
+	dst := filepath.Join(tmpDir, "migrate-"+util.RandomHex(8)+ext)
+	out, err := os.Create(dst)
+	if err != nil {
+		fail(w, 500, "无法保存上传文件:"+err.Error())
+		return
+	}
+	if _, err := io.Copy(out, file); err != nil {
+		out.Close()
+		_ = os.Remove(dst)
+		fail(w, 500, "写入文件失败:"+err.Error())
+		return
+	}
+	out.Close()
+
+	uploadsDir := strings.TrimSpace(r.FormValue("uploads_dir"))
+	dryRun := r.FormValue("dry") == "1"
+	if !startMigrateJob(dst, uploadsDir, dryRun) {
+		_ = os.Remove(dst)
+		fail(w, http.StatusConflict, "已有迁移任务正在执行,请等待完成")
+		return
+	}
+	util.Log("info", "管理员 %s 启动旧版数据迁移(试运行=%v):%s", admin.Username, dryRun, hdr.Filename)
+	okJSON(w, map[string]any{"msg": "迁移任务已启动"})
+}
+
+// AdminMigrateStatus 查询迁移进度(前端轮询)
+func AdminMigrateStatus(w http.ResponseWriter, r *http.Request) {
+	if a := requireAdmin(w, r, models.PermSetting); a == nil {
+		return
+	}
+	okJSON(w, migJob.snapshot())
+}
+
+// startMigrateJob 启动迁移协程,已有任务运行时返回 false
+func startMigrateJob(dbFile, uploadsDir string, dryRun bool) bool {
+	migJob.mu.Lock()
+	if migJob.running {
+		migJob.mu.Unlock()
+		return false
+	}
+	migJob.running = true
+	migJob.done = false
+	migJob.err = ""
+	migJob.stats = nil
+	migJob.dry = dryRun
+	migJob.startAt = time.Now()
+	migJob.mu.Unlock()
+
+	go func() {
+		st, err := migrate.Run(dbFile, uploadsDir, dryRun)
+		migJob.mu.Lock()
+		migJob.running = false
+		migJob.done = true
+		if err != nil {
+			migJob.err = err.Error()
+		} else {
+			migJob.stats = st
+		}
+		migJob.mu.Unlock()
+		// 迁移结束后删除临时上传文件
+		_ = os.Remove(dbFile)
+	}()
+	return true
+}
diff --git a/internal/migrate/dump.go b/internal/migrate/dump.go
new file mode 100644
index 0000000..fd73c98
--- /dev/null
+++ b/internal/migrate/dump.go
@@ -0,0 +1,424 @@
+package migrate
+
+import (
+	"errors"
+	"strings"
+)
+
+var errEmptyFile = errors.New("文件内容为空")
+
+// dumpSource MySQL 导出文件(.sql)数据源。
+// tables 保存的是**有序**列名列表:部分 INSERT 不带列名列表,
+// 必须按建表语句的列顺序还原,否则字段会错位。
+type dumpSource struct {
+	tables map[string][]string
+	rows   map[string][]Row
+}
+
+func (s *dumpSource) HasTable(table string) bool { _, ok := s.tables[table]; return ok }
+
+func (s *dumpSource) Kind() string { return "mysqldump" }
+
+func (s *dumpSource) Close() error { return nil }
+
+func (s *dumpSource) Rows(table string, _ ...string) ([]Row, error) {
+	return s.rows[table], nil
+}
+
+// parseDump 解析 MySQL 导出文件
+func parseDump(data []byte) (Source, error) {
+	s := &dumpSource{tables: map[string][]string{}, rows: map[string][]Row{}}
+	stmts := splitSQL(string(data))
+	for _, stmt := range stmts {
+		up := strings.ToUpper(strings.TrimSpace(stmt))
+		switch {
+		case strings.HasPrefix(up, "CREATE TABLE"):
+			name, cols := parseCreateTable(stmt)
+			if name == "" {
+				continue
+			}
+			s.tables[name] = cols
+		case strings.HasPrefix(up, "INSERT INTO"):
+			name, cols, tuples := parseInsert(stmt)
+			if name == "" || len(tuples) == 0 {
+				continue
+			}
+			if len(cols) == 0 {
+				// 没有列名列表时按建表语句的列顺序还原
+				cols = s.tables[name]
+				if len(cols) == 0 {
+					continue
+				}
+			}
+			if _, ok := s.tables[name]; !ok {
+				s.tables[name] = cols
+			}
+			for _, t := range tuples {
+				row := Row{}
+				for i, c := range cols {
+					if i < len(t) {
+						row[c] = t[i]
+					}
+				}
+				s.rows[name] = append(s.rows[name], row)
+			}
+		}
+	}
+	if len(s.tables) == 0 {
+		return nil, errors.New("未能从文件中解析出任何数据表,请确认这是 MySQL 导出的 .sql 文件")
+	}
+	return s, nil
+}
+
+// ---------- SQL 语句切分 ----------
+
+// splitSQL 按分号切分 SQL 语句,自动跳过字符串、标识符与注释中的分号
+func splitSQL(s string) []string {
+	var out []string
+	var cur strings.Builder
+	n := len(s)
+	for i := 0; i < n; i++ {
+		c := s[i]
+		switch c {
+		case '\'', '"', '`':
+			// 引用内容整体拷贝(含转义)
+			q := c
+			cur.WriteByte(c)
+			i++
+			for i < n {
+				if s[i] == '\\' && i+1 < n && q != '`' {
+					cur.WriteByte(s[i])
+					cur.WriteByte(s[i+1])
+					i += 2
+					continue
+				}
+				cur.WriteByte(s[i])
+				if s[i] == q {
+					i++
+					break
+				}
+				i++
+			}
+			i--
+		case '-':
+			// -- 行注释
+			if i+1 < n && s[i+1] == '-' {
+				for i < n && s[i] != '\n' {
+					i++
+				}
+			} else {
+				cur.WriteByte(c)
+			}
+		case '#':
+			for i < n && s[i] != '\n' {
+				i++
+			}
+		case '/':
+			// 块注释(含 /*! 条件注释)
+			if i+1 < n && s[i+1] == '*' {
+				i += 2
+				for i+1 < n && !(s[i] == '*' && s[i+1] == '/') {
+					i++
+				}
+				i++
+			} else {
+				cur.WriteByte(c)
+			}
+		case ';':
+			if stmt := strings.TrimSpace(cur.String()); stmt != "" {
+				out = append(out, stmt)
+			}
+			cur.Reset()
+		default:
+			cur.WriteByte(c)
+		}
+	}
+	if stmt := strings.TrimSpace(cur.String()); stmt != "" {
+		out = append(out, stmt)
+	}
+	return out
+}
+
+// ---------- CREATE TABLE ----------
+
+// parseCreateTable 解析建表语句,返回表名与列名列表
+func parseCreateTable(stmt string) (string, []string) {
+	up := strings.ToUpper(stmt)
+	i := strings.Index(up, "CREATE TABLE")
+	if i < 0 {
+		return "", nil
+	}
+	i += len("CREATE TABLE")
+	// 跳过 IF NOT EXISTS
+	rest := strings.ToUpper(stmt[i:])
+	if j := strings.Index(rest, "IF NOT EXISTS"); j >= 0 && j < 24 {
+		i += j + len("IF NOT EXISTS")
+	}
+	i = skipSpace(stmt, i)
+	name, i := readIdent(stmt, i)
+	i = skipSpace(stmt, i)
+	if name == "" || i >= len(stmt) || stmt[i] != '(' {
+		return name, nil
+	}
+	end := matchParen(stmt, i)
+	if end < 0 {
+		return name, nil
+	}
+	var cols []string
+	for _, part := range splitTopLevel(stmt[i+1 : end]) {
+		part = strings.TrimSpace(part)
+		// 只取以反引号开头的列定义,跳过 PRIMARY KEY / KEY / UNIQUE KEY / CONSTRAINT 等
+		if !strings.HasPrefix(part, "`") {
+			continue
+		}
+		if c, _ := readIdent(part, 0); c != "" {
+			cols = append(cols, c)
+		}
+	}
+	return name, cols
+}
+
+// ---------- INSERT INTO ----------
+
+// parseInsert 解析插入语句,返回表名、列名列表与值元组
+func parseInsert(stmt string) (string, []string, [][]string) {
+	up := strings.ToUpper(stmt)
+	i := strings.Index(up, "INSERT INTO")
+	if i < 0 {
+		return "", nil, nil
+	}
+	i += len("INSERT INTO")
+	i = skipSpace(stmt, i)
+	// 可选修饰符
+	for _, kw := range []string{"LOW_PRIORITY", "DELAYED", "HIGH_PRIORITY", "IGNORE"} {
+		if strings.HasPrefix(strings.ToUpper(stmt[i:]), kw) {
+			i += len(kw)
+			i = skipSpace(stmt, i)
+		}
+	}
+	table, i := readIdent(stmt, i)
+	if table == "" {
+		return "", nil, nil
+	}
+	i = skipSpace(stmt, i)
+	var cols []string
+	if i < len(stmt) && stmt[i] == '(' {
+		end := matchParen(stmt, i)
+		if end < 0 {
+			return table, nil, nil
+		}
+		for _, c := range strings.Split(stmt[i+1:end], ",") {
+			c = strings.TrimSpace(c)
+			c = strings.Trim(c, "`")
+			if c != "" {
+				cols = append(cols, c)
+			}
+		}
+		i = end + 1
+	}
+	rest := stmt[i:]
+	vi := strings.Index(strings.ToUpper(rest), "VALUES")
+	if vi < 0 {
+		return table, cols, nil
+	}
+	return table, cols, parseTuples(rest[vi+len("VALUES"):])
+}
+
+// ---------- 词法辅助 ----------
+
+func skipSpace(s string, i int) int {
+	for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') {
+		i++
+	}
+	return i
+}
+
+// readIdent 读取标识符(支持反引号包裹)
+func readIdent(s string, i int) (string, int) {
+	i = skipSpace(s, i)
+	if i >= len(s) {
+		return "", i
+	}
+	if s[i] == '`' {
+		i++
+		var b strings.Builder
+		for i < len(s) {
+			if s[i] == '`' {
+				if i+1 < len(s) && s[i+1] == '`' {
+					b.WriteByte('`')
+					i += 2
+					continue
+				}
+				i++
+				break
+			}
+			b.WriteByte(s[i])
+			i++
+		}
+		return b.String(), i
+	}
+	start := i
+	for i < len(s) && !strings.ContainsRune(" \t\n\r(,;", rune(s[i])) {
+		i++
+	}
+	return s[start:i], i
+}
+
+// matchParen 返回与 s[i]('(')匹配的 ')' 下标,找不到返回 -1
+func matchParen(s string, i int) int {
+	depth := 0
+	for ; i < len(s); i++ {
+		switch s[i] {
+		case '(':
+			depth++
+		case ')':
+			depth--
+			if depth == 0 {
+				return i
+			}
+		case '\'', '"', '`':
+			q := s[i]
+			i++
+			for i < len(s) {
+				if s[i] == '\\' && i+1 < len(s) && q != '`' {
+					i += 2
+					continue
+				}
+				if s[i] == q {
+					break
+				}
+				i++
+			}
+		}
+	}
+	return -1
+}
+
+// splitTopLevel 按顶层逗号切分(忽略括号与引号内的逗号)
+func splitTopLevel(s string) []string {
+	var out []string
+	depth, start := 0, 0
+	for i := 0; i < len(s); i++ {
+		switch s[i] {
+		case '(':
+			depth++
+		case ')':
+			depth--
+		case '\'', '"', '`':
+			q := s[i]
+			i++
+			for i < len(s) {
+				if s[i] == '\\' && i+1 < len(s) && q != '`' {
+					i += 2
+					continue
+				}
+				if s[i] == q {
+					break
+				}
+				i++
+			}
+		case ',':
+			if depth == 0 {
+				out = append(out, s[start:i])
+				start = i + 1
+			}
+		}
+	}
+	out = append(out, s[start:])
+	return out
+}
+
+// parseTuples 解析 VALUES 后的 "(v1,v2),(v3,v4)" 列表
+func parseTuples(s string) [][]string {
+	var out [][]string
+	i, n := 0, len(s)
+	for i < n {
+		for i < n && s[i] != '(' {
+			i++
+		}
+		if i >= n {
+			break
+		}
+		i++ // 跳过 '('
+		var vals []string
+		for {
+			v, ni := parseValue(s, i)
+			vals = append(vals, v)
+			i = skipSpace(s, ni)
+			if i < n && s[i] == ',' {
+				i++
+				continue
+			}
+			if i < n && s[i] == ')' {
+				i++
+			}
+			break
+		}
+		out = append(out, vals)
+	}
+	return out
+}
+
+// parseValue 解析一个值,返回字符串值与新的下标(NULL 与空串统一为空串)
+func parseValue(s string, i int) (string, int) {
+	n := len(s)
+	i = skipSpace(s, i)
+	if i >= n {
+		return "", i
+	}
+	if s[i] == '\'' || s[i] == '"' {
+		q := s[i]
+		i++
+		var b strings.Builder
+		for i < n {
+			c := s[i]
+			if c == '\\' && i+1 < n {
+				b.WriteByte(decodeEscape(s[i+1]))
+				i += 2
+				continue
+			}
+			if c == q {
+				// '' 表示一个引号字符
+				if i+1 < n && s[i+1] == q {
+					b.WriteByte(q)
+					i += 2
+					continue
+				}
+				i++
+				break
+			}
+			b.WriteByte(c)
+			i++
+		}
+		return b.String(), i
+	}
+	start := i
+	for i < n && s[i] != ',' && s[i] != ')' {
+		i++
+	}
+	raw := strings.TrimSpace(s[start:i])
+	if strings.EqualFold(raw, "NULL") {
+		return "", i
+	}
+	return raw, i
+}
+
+// decodeEscape 解析 MySQL 反斜杠转义
+func decodeEscape(c byte) byte {
+	switch c {
+	case 'n':
+		return '\n'
+	case 'r':
+		return '\r'
+	case 't':
+		return '\t'
+	case '0':
+		return 0
+	case 'b':
+		return '\b'
+	case 'Z':
+		return 26
+	default:
+		return c // \\ \' \" \% \_ 等保持原字符
+	}
+}
diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go
index dd5cb67..874da69 100644
--- a/internal/migrate/migrate.go
+++ b/internal/migrate/migrate.go
@@ -1,4 +1,8 @@
-// Package migrate 将 ClearLove PHP 旧版(1.x,SQLite data.db)的数据迁移到 2.0。
+// Package migrate 将 ClearLove PHP 旧版(1.x)的数据迁移到 2.0。
+//
+// 支持两种数据来源,由文件内容自动识别:
+//   - SQLite 数据库文件(旧版根目录 data.db)
+//   - MySQL 导出文件(mysqldump / 宝塔导出的 .sql)
 //
 // 迁移内容:话题 / 帖子(含媒体文件与点赞数)/ 评论 / 公告 / 用户 / 管理员 /
 // 封禁名单 / 举报。访问量(visits)与置顶(is_pinned)在新版中没有对应结构,跳过。
@@ -19,8 +23,6 @@ import (
 	"strings"
 	"time"
 
-	_ "modernc.org/sqlite"
-
 	"clearlove/internal/config"
 	"clearlove/internal/database"
 	"clearlove/internal/models"
@@ -29,73 +31,91 @@ import (
 
 // 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 // 引用了不存在帖子的评论 / 举报
+	Source    string `json:"source"` // sqlite / mysqldump
+	Topics    int    `json:"topics"`
+	Posts     int    `json:"posts"`
+	Media     int    `json:"media"`
+	Comments  int    `json:"comments"`
+	Notices   int    `json:"notices"`
+	Users     int    `json:"users"`
+	Admins    int    `json:"admins"`
+	Bans      int    `json:"bans"`
+	Reports   int    `json:"reports"`
+	Likes     int    `json:"likes"`
+	Skipped   int    `json:"skipped"`     // 已存在而跳过的记录
+	Missing   int    `json:"missing"`     // 媒体文件缺失数量
+	NoPostRef int    `json:"no_post_ref"` // 引用了不存在帖子的评论 / 举报
 }
 
 // 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)
+// pendingPosts 本次迁移中已导入(试运行时为「将会导入」)的帖子 ID。
+// 试运行不会真正写入帖子,若直接查库判断,评论与举报会被误判为"关联失效"而统计为 0。
+var pendingPosts = map[int64]bool{}
+
+// postExists 判断帖子是否存在于新版库或本次迁移范围内
+func postExists(id int64) bool {
+	if id <= 0 {
+		return false
 	}
-	if oldUploads == "" {
-		oldUploads = filepath.Join(filepath.Dir(oldDB), "uploads")
+	if pendingPosts[id] {
+		return true
 	}
-	old, err := sql.Open("sqlite", oldDB)
+	return exists("SELECT COUNT(1) FROM posts WHERE id=?", id)
+}
+
+// Run 按文件路径执行迁移,数据源类型由文件内容自动识别
+func Run(oldPath, oldUploads string, dryRun bool) (*Stats, error) {
+	if _, err := os.Stat(oldPath); err != nil {
+		return nil, fmt.Errorf("找不到旧版数据文件: %s", oldPath)
+	}
+	src, err := OpenSource(oldPath)
 	if err != nil {
-		return nil, fmt.Errorf("打开旧版数据库失败: %w", err)
+		return nil, fmt.Errorf("无法识别旧版数据文件: %w", err)
 	}
-	defer old.Close()
-	if err := old.Ping(); err != nil {
-		return nil, fmt.Errorf("旧版数据库无法读取: %w", err)
+	defer src.Close()
+	if oldUploads == "" {
+		oldUploads = filepath.Join(filepath.Dir(oldPath), "uploads")
 	}
-	if !hasTable(old, "posts") {
-		return nil, fmt.Errorf("%s 中找不到 posts 表,请确认这是旧版 ClearLove 的 data.db", oldDB)
+	return RunSource(src, oldUploads, dryRun)
+}
+
+// RunSource 从已打开的数据源执行迁移
+func RunSource(src Source, oldUploads string, dryRun bool) (*Stats, error) {
+	dry = dryRun
+	if !src.HasTable("posts") {
+		return nil, fmt.Errorf("数据源中找不到 posts 表,请确认这是旧版 ClearLove 的数据库(data.db 或导出的 .sql)")
 	}
+	st := &Stats{Source: src.Kind()}
+	pendingPosts = map[int64]bool{}
 
-	st := &Stats{}
-	// 话题:先建立 名称 -> 新 ID 映射
-	topicMap, err := migrateTopics(old, st)
+	topicMap, err := migrateTopics(src, st)
 	if err != nil {
 		return st, err
 	}
-	if err := migratePosts(old, topicMap, oldUploads, st); err != nil {
+	if err := migratePosts(src, topicMap, oldUploads, st); err != nil {
 		return st, err
 	}
-	if err := migrateComments(old, st); err != nil {
+	if err := migrateComments(src, st); err != nil {
 		return st, err
 	}
-	if err := migrateNotices(old, st); err != nil {
+	if err := migrateNotices(src, st); err != nil {
 		return st, err
 	}
-	if err := migrateUsers(old, st); err != nil {
+	if err := migrateUsers(src, st); err != nil {
 		return st, err
 	}
-	if err := migrateAdmins(old, st); err != nil {
+	if err := migrateAdmins(src, st); err != nil {
 		return st, err
 	}
-	if err := migrateBans(old, st); err != nil {
+	if err := migrateBans(src, st); err != nil {
 		return st, err
 	}
-	if err := migrateReports(old, st); err != nil {
+	if err := migrateReports(src, 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)`)
@@ -105,51 +125,6 @@ func Run(oldDB, oldUploads string, dryRun bool) (*Stats, error) {
 
 // ---------- 通用辅助 ----------
 
-// 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
@@ -163,7 +138,7 @@ func exec(q string, args ...any) (sql.Result, error) {
 	return database.DB.Exec(q, args...)
 }
 
-// parseTime 旧版时间(SQLite 为 UTC 的 "YYYY-MM-DD HH:MM:SS")转 RFC3339
+// parseTime 旧版时间转 RFC3339(SQLite 存 UTC,MySQL DATETIME 视为 UTC)
 func parseTime(s string) string {
 	s = strings.TrimSpace(s)
 	if s == "" {
@@ -203,27 +178,21 @@ func ensureTopic(name string, m map[string]int64) int64 {
 // ---------- 各类数据 ----------
 
 // migrateTopics 迁移话题表
-func migrateTopics(old *sql.DB, st *Stats) (map[string]int64, error) {
+func migrateTopics(src Source, st *Stats) (map[string]int64, error) {
 	m := map[string]int64{}
-	rows, err := old.Query("SELECT name FROM topics")
+	rows, err := src.Rows("topics", "name")
 	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)
-			}
-		}
+		return m, nil
 	}
-	for _, n := range names {
-		if exists("SELECT COUNT(1) FROM topics WHERE name=?", n) {
+	for _, row := range rows {
+		name := strings.TrimSpace(field(row, "name"))
+		if name == "" {
+			continue
+		}
+		if exists("SELECT COUNT(1) FROM topics WHERE name=?", name) {
 			st.Skipped++
 		}
-		ensureTopic(n, m)
+		ensureTopic(name, m)
 		st.Topics++
 	}
 	return m, nil
@@ -236,87 +205,55 @@ type MediaItem struct {
 }
 
 // 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)
+func migratePosts(src Source, topicMap map[string]int64, oldUploads string, st *Stats) error {
+	rows, err := src.Rows("posts", "id", "nickname", "content", "topic", "media", "likes",
+		"ip_address", "ip", "fingerprint", "status", "is_admin", "created_at")
 	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) {
+	for _, row := range rows {
+		id := fieldInt(row, "id")
+		if id <= 0 || exists("SELECT COUNT(1) FROM posts WHERE id=?", id) {
 			st.Skipped++
 			continue
 		}
+		pendingPosts[id] = true
 		// 旧版:0(或 NULL)= 正常展示,1 = 隐藏;新版:1 = 可见,0 = 隐藏 -> 取反
 		newStatus := int64(1)
-		if p.status == 1 {
+		if strings.TrimSpace(field(row, "status")) == "1" {
 			newStatus = 0
 		}
-		nickname := strings.TrimSpace(p.nickname)
+		nickname := strings.TrimSpace(field(row, "nickname"))
 		if nickname == "" {
 			nickname = "匿名"
 		}
-		topicID := ensureTopic(p.topic, topicMap)
+		isAdmin := fieldInt(row, "is_admin")
+		likes := fieldInt(row, "likes")
+		created := parseTime(field(row, "created_at"))
+		topicID := ensureTopic(field(row, "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)
+			id, 0, nickname, field(row, "content"), topicID,
+			field(row, "ip_address", "ip"), field(row, "fingerprint"),
+			newStatus, likes, isAdmin, "", created); err != nil {
+			return fmt.Errorf("写入帖子 #%d 失败: %w", 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++
-				}
+		for i := int64(0); i < likes; i++ {
+			if _, err := exec("INSERT INTO likes(post_id,user_id,fingerprint,created_at) VALUES(?,0,?,?)",
+				id, fmt.Sprintf("legacy-%d-%d", id, i), created); err == nil {
+				st.Likes++
 			}
 		}
 
 		// 媒体:复制文件到新版 uploads/legacy/ 并建立 medias 记录
 		var items []MediaItem
-		if strings.TrimSpace(p.media) != "" {
-			_ = json.Unmarshal([]byte(p.media), &items)
+		if raw := strings.TrimSpace(field(row, "media")); raw != "" {
+			_ = json.Unmarshal([]byte(raw), &items)
 		}
 		for _, it := range items {
 			name := filepath.Base(strings.TrimSpace(it.File))
@@ -329,7 +266,7 @@ func migratePosts(old *sql.DB, topicMap map[string]int64, oldUploads string, st
 					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 {
+					id, typ, "/uploads/legacy/"+name, created); err == nil {
 					st.Media++
 				}
 			}
@@ -346,11 +283,10 @@ func copyMedia(oldUploads, name string, st *Stats) string {
 		st.Missing++
 		return ""
 	}
-	dstDir := filepath.Join(config.Cfg.UploadDir, "legacy")
 	if dry {
-		st.Media++
 		return name
 	}
+	dstDir := filepath.Join(config.Cfg.UploadDir, "legacy")
 	if err := os.MkdirAll(dstDir, 0o755); err != nil {
 		st.Missing++
 		return ""
@@ -379,39 +315,32 @@ func copyMedia(oldUploads, name string, st *Stats) string {
 }
 
 // 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)
+func migrateComments(src Source, st *Stats) error {
+	rows, err := src.Rows("comments", "id", "post_id", "nickname", "content",
+		"ip_address", "ip", "fingerprint", "created_at")
 	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) {
+	for _, row := range rows {
+		id, pid := fieldInt(row, "id"), fieldInt(row, "post_id")
+		if !postExists(pid) {
 			st.NoPostRef++
 			continue
 		}
-		if exists("SELECT COUNT(1) FROM comments WHERE id=?", id.Int64) {
+		if id > 0 && exists("SELECT COUNT(1) FROM comments WHERE id=?", id) {
 			st.Skipped++
 			continue
 		}
-		nickname := strings.TrimSpace(ns(nn))
+		nickname := strings.TrimSpace(field(row, "nickname"))
 		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)
+			id, pid, nickname, field(row, "content"),
+			field(row, "ip_address", "ip"), field(row, "fingerprint"),
+			parseTime(field(row, "created_at"))); err != nil {
+			return fmt.Errorf("写入评论 #%d 失败: %w", id, err)
 		}
 		st.Comments++
 	}
@@ -419,26 +348,17 @@ func migrateComments(old *sql.DB, st *Stats) error {
 }
 
 // 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)
+func migrateNotices(src Source, st *Stats) error {
+	rows, err := src.Rows("announcements", "content", "active", "created_at")
 	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 {
+	for _, row := range rows {
+		// 旧版 active=0 表示已停用,导入会让它成为首页最新公告,故跳过
+		if strings.TrimSpace(field(row, "active")) == "0" {
 			continue
 		}
-		if active.Valid && active.Int64 != 1 {
-			continue // 已停用的公告不导入,避免它成为首页最新公告
-		}
-		body := strings.TrimSpace(ns(content))
+		body := strings.TrimSpace(field(row, "content"))
 		if body == "" {
 			continue
 		}
@@ -448,7 +368,7 @@ func migrateNotices(old *sql.DB, st *Stats) error {
 			title = title[:20]
 		}
 		if _, err := exec("INSERT INTO notices(title,content,created_at) VALUES(?,?,?)",
-			string(title), body, parseTime(ns(created))); err != nil {
+			string(title), body, parseTime(field(row, "created_at"))); err != nil {
 			return fmt.Errorf("写入公告失败: %w", err)
 		}
 		st.Notices++
@@ -457,27 +377,19 @@ func migrateNotices(old *sql.DB, st *Stats) error {
 }
 
 // migrateUsers 迁移注册用户(密码同为 bcrypt,可直接沿用)
-func migrateUsers(old *sql.DB, st *Stats) error {
-	has := cols(old, "users")
-	if len(has) == 0 {
+func migrateUsers(src Source, st *Stats) error {
+	if !src.HasTable("users") {
 		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)
+	rows, err := src.Rows("users", "id", "username", "password", "email", "created_at")
 	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) == "" {
+	for _, row := range rows {
+		id := fieldInt(row, "id")
+		username := strings.TrimSpace(field(row, "username"))
+		password := field(row, "password")
+		if username == "" || password == "" {
 			continue
 		}
 		if exists("SELECT COUNT(1) FROM users WHERE username=?", username) {
@@ -485,15 +397,12 @@ func migrateUsers(old *sql.DB, st *Stats) error {
 			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)
+		email := strings.TrimSpace(field(row, "email"))
+		if email == "" || exists("SELECT COUNT(1) FROM users WHERE email=?", email) {
+			email = fmt.Sprintf("legacy_%d@migrated.local", id)
 		}
 		if _, err := exec("INSERT INTO users(username,email,password,avatar,status,created_at) VALUES(?,?,?,'',1,?)",
-			username, email, ns(pw), parseTime(ns(cr))); err != nil {
+			username, email, password, parseTime(field(row, "created_at"))); err != nil {
 			return fmt.Errorf("写入用户 %s 失败: %w", username, err)
 		}
 		st.Users++
@@ -502,38 +411,31 @@ func migrateUsers(old *sql.DB, st *Stats) error {
 }
 
 // migrateAdmins 迁移管理员(密码同为 bcrypt)
-func migrateAdmins(old *sql.DB, st *Stats) error {
-	has := cols(old, "admins")
-	if len(has) == 0 {
+func migrateAdmins(src Source, st *Stats) error {
+	if !src.HasTable("admins") {
 		return nil
 	}
-	q := fmt.Sprintf("SELECT username, password, %s AS role FROM admins",
-		pick(has, []string{"role"}, "'admin'"))
-	rows, err := old.Query(q)
+	rows, err := src.Rows("admins", "username", "password", "role")
 	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) == "" {
+	for _, row := range rows {
+		username := strings.TrimSpace(field(row, "username"))
+		password := field(row, "password")
+		if username == "" || password == "" {
 			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 field(row, "role") == "super" {
+			newRole = "super"
 		}
 		if _, err := exec("INSERT INTO admins(username,password,role,perms,created_at) VALUES(?,?,?,?,?)",
-			username, ns(pw), newRole, perms, models.Now()); err != nil {
+			username, password, newRole, perms, models.Now()); err != nil {
 			return fmt.Errorf("写入管理员 %s 失败: %w", username, err)
 		}
 		st.Admins++
@@ -542,18 +444,14 @@ func migrateAdmins(old *sql.DB, st *Stats) error {
 }
 
 // migrateBans 迁移封禁名单(banned_devices -> bans)
-func migrateBans(old *sql.DB, st *Stats) error {
-	rows, err := old.Query("SELECT type, value FROM banned_devices")
+func migrateBans(src Source, st *Stats) error {
+	rows, err := src.Rows("banned_devices", "type", "value")
 	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))
+	for _, row := range rows {
+		t := strings.TrimSpace(field(row, "type"))
+		v := strings.TrimSpace(field(row, "value"))
 		if (t != "ip" && t != "fingerprint") || v == "" {
 			continue
 		}
@@ -570,39 +468,28 @@ func migrateBans(old *sql.DB, st *Stats) error {
 }
 
 // migrateReports 迁移举报
-func migrateReports(old *sql.DB, st *Stats) error {
-	has := cols(old, "reports")
-	if len(has) == 0 {
+func migrateReports(src Source, st *Stats) error {
+	if !src.HasTable("reports") {
 		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)
+	rows, err := src.Rows("reports", "id", "post_id", "reason", "detail",
+		"reporter_ip", "ip", "status", "created_at")
 	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
-		}
+	for _, row := range rows {
+		id, pid := fieldInt(row, "id"), fieldInt(row, "post_id")
 		// 帖子已被删除(或未迁移)的举报没有意义,跳过
-		if !exists("SELECT COUNT(1) FROM posts WHERE id=?", pid.Int64) {
+		if !postExists(pid) {
 			st.NoPostRef++
 			continue
 		}
-		if exists("SELECT COUNT(1) FROM reports WHERE id=?", id.Int64) {
+		if id > 0 && exists("SELECT COUNT(1) FROM reports WHERE id=?", id) {
 			st.Skipped++
 			continue
 		}
-		text := strings.TrimSpace(ns(reason))
-		if d := strings.TrimSpace(ns(detail)); d != "" {
+		text := strings.TrimSpace(field(row, "reason"))
+		if d := strings.TrimSpace(field(row, "detail")); d != "" {
 			if text == "" {
 				text = d
 			} else {
@@ -611,12 +498,13 @@ func migrateReports(old *sql.DB, st *Stats) error {
 		}
 		// 旧版 0=待处理,1=已展示,2=已删除;新版 0=待处理,1=已处理
 		newStatus := int64(0)
-		if status.Valid && status.Int64 != 0 {
+		if s := strings.TrimSpace(field(row, "status")); s != "" && s != "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)
+			id, pid, text, field(row, "reporter_ip", "ip"), newStatus,
+			parseTime(field(row, "created_at"))); err != nil {
+			return fmt.Errorf("写入举报 #%d 失败: %w", id, err)
 		}
 		st.Reports++
 	}
diff --git a/internal/migrate/migrate_test.go b/internal/migrate/migrate_test.go
index 932c86b..8e70698 100644
--- a/internal/migrate/migrate_test.go
+++ b/internal/migrate/migrate_test.go
@@ -4,6 +4,7 @@ import (
 	"database/sql"
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 
 	_ "modernc.org/sqlite"
@@ -242,6 +243,158 @@ func TestRunMigration(t *testing.T) {
 	_ = db
 }
 
+// TestMySQLDumpMigration 验证从 MySQL 导出文件(宝塔 / mysqldump)迁移。
+// 反引号在 Go 原始字符串里无法书写,用 « 占位后替换。
+func TestMySQLDumpMigration(t *testing.T) {
+	root := t.TempDir()
+	setupNew(t, root)
+
+	dumpRaw := `-- MySQL dump 10.13  Distrib 5.7.44, for Linux (x86_64)
+/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
+/*!40000 ALTER TABLE «posts» DISABLE KEYS */;
+
+DROP TABLE IF EXISTS «posts»;
+CREATE TABLE «posts» (
+  «id» int(11) NOT NULL AUTO_INCREMENT,
+  «nickname» varchar(50) NOT NULL,
+  «content» text NOT NULL,
+  «topic» varchar(100) DEFAULT NULL,
+  «media» text,
+  «likes» int(11) DEFAULT '0',
+  «is_pinned» tinyint(1) DEFAULT '0',
+  «ip_address» varchar(45) DEFAULT NULL,
+  «fingerprint» varchar(64) DEFAULT NULL,
+  «status» tinyint(4) DEFAULT '0',
+  «is_admin» tinyint(4) DEFAULT '0',
+  «created_at» datetime DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY («id»),
+  KEY «idx_created» («created_at»)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO «posts» VALUES (1,'小明','第一条帖\n带换行和单引号''测试','校园','[{"file":"a.webp","type":"image"}]',5,0,'1.1.1.1','fp1',0,0,'2026-01-02 03:04:05'),(2,'被屏蔽者','隐藏帖','日常',NULL,0,0,NULL,NULL,1,0,'2026-01-03 03:04:05');
+
+CREATE TABLE «comments» (
+  «id» int(11) NOT NULL AUTO_INCREMENT,
+  «post_id» int(11) NOT NULL,
+  «nickname» varchar(50) NOT NULL,
+  «content» text NOT NULL,
+  «created_at» datetime DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY («id»)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO «comments» («id», «post_id», «nickname», «content», «created_at») VALUES (1,1,'小红','评论一','2026-01-05 03:04:05'),(2,999,'幽灵','孤儿评论','2026-01-07 03:04:05');
+
+CREATE TABLE «announcements» (
+  «id» int(11) NOT NULL AUTO_INCREMENT,
+  «content» text NOT NULL,
+  «active» tinyint(1) DEFAULT '1',
+  «created_at» datetime DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY («id»)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO «announcements» VALUES (1,'欢迎来到表白墙',1,'2026-01-08 03:04:05'),(2,'已停用公告',0,'2026-01-09 03:04:05');
+
+CREATE TABLE «users» (
+  «id» int(11) NOT NULL AUTO_INCREMENT,
+  «username» varchar(50) NOT NULL,
+  «password» varchar(255) NOT NULL,
+  «email» varchar(100) DEFAULT NULL,
+  «created_at» datetime DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY («id»)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO «users» VALUES (1,'alice','$2y$10$abcdefghijklmnopqrstuv','a@b.com','2026-01-10 03:04:05'),(2,'bob','$2y$10$abcdefghijklmnopqrstuv',NULL,'2026-01-11 03:04:05');
+
+CREATE TABLE «admins» (
+  «id» int(11) NOT NULL AUTO_INCREMENT,
+  «username» varchar(50) NOT NULL,
+  «password» varchar(255) NOT NULL,
+  «role» varchar(10) NOT NULL DEFAULT 'admin',
+  PRIMARY KEY («id»)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO «admins» VALUES (1,'boss','$2y$10$abcdefghijklmnopqrstuv','super'),(2,'mod','$2y$10$abcdefghijklmnopqrstuv','admin');
+
+CREATE TABLE «banned_devices» (
+  «id» int(11) NOT NULL AUTO_INCREMENT,
+  «type» varchar(15) NOT NULL,
+  «value» varchar(64) NOT NULL,
+  PRIMARY KEY («id»)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO «banned_devices» VALUES (1,'ip','9.9.9.9'),(2,'fingerprint','badfp');
+
+CREATE TABLE «reports» (
+  «id» int(11) NOT NULL AUTO_INCREMENT,
+  «post_id» int(11) NOT NULL,
+  «reason» varchar(50) NOT NULL,
+  «detail» text,
+  «reporter_ip» varchar(45) DEFAULT NULL,
+  «status» tinyint(4) DEFAULT '0',
+  «created_at» datetime DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY («id»)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO «reports» VALUES (1,1,'广告','发小广告','3.3.3.3',0,'2026-01-12 03:04:05'),(2,1,'重复','重复贴','5.5.5.5',2,'2026-01-13 03:04:05'),(3,999,'违规','帖子已删','4.4.4.4',0,'2026-01-14 03:04:05');
+
+/*!40000 ALTER TABLE «posts» ENABLE KEYS */;
+-- Dump completed on 2026-09-13  0:00:00
+`
+	dump := strings.NewReplacer("«", "`", "»", "`").Replace(dumpRaw)
+
+	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("img"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	dumpPath := filepath.Join(oldDir, "clearlove.sql")
+	if err := os.WriteFile(dumpPath, []byte(dump), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	st, err := Run(dumpPath, oldUploads, false)
+	if err != nil {
+		t.Fatalf("从 MySQL 导出文件迁移失败: %v", err)
+	}
+	if st.Source != "mysqldump" {
+		t.Errorf("数据源识别为 %q, 期望 mysqldump", st.Source)
+	}
+	if st.Posts != 2 || st.Comments != 1 || st.Media != 1 || st.Likes != 5 {
+		t.Errorf("统计异常: 帖子=%d 评论=%d 媒体=%d 点赞=%d", st.Posts, st.Comments, st.Media, st.Likes)
+	}
+	if st.Users != 2 || st.Admins != 2 || st.Bans != 2 || st.Reports != 2 || st.Notices != 1 {
+		t.Errorf("统计异常: 用户=%d 管理员=%d 封禁=%d 举报=%d 公告=%d",
+			st.Users, st.Admins, st.Bans, st.Reports, st.Notices)
+	}
+	if st.NoPostRef != 2 {
+		t.Errorf("无效关联数 = %d, 期望 2(1 条孤儿评论 + 1 条失效举报)", st.NoPostRef)
+	}
+	// 转义与引号还原
+	content := models.QueryStr("SELECT content FROM posts WHERE id=1")
+	if content != "第一条帖\n带换行和单引号'测试" {
+		t.Errorf("转义解析异常: %q", content)
+	}
+	// 状态取反
+	if n := models.QueryInt("SELECT status FROM posts WHERE id=2"); n != 0 {
+		t.Errorf("隐藏帖 status = %d, 期望 0", n)
+	}
+	// 无列名列表的 INSERT 也能按建表顺序解析(posts 表即为此种写法)
+	if n := models.QueryInt("SELECT like_count FROM posts WHERE id=1"); n != 5 {
+		t.Errorf("点赞数 = %d, 期望 5", n)
+	}
+	// 邮箱为空时补齐占位
+	if got := models.QueryStr("SELECT email FROM users WHERE username='bob'"); got == "" {
+		t.Error("bob 的邮箱未补齐")
+	}
+	// 举报状态归一
+	if n := models.QueryInt("SELECT status FROM reports WHERE id=2"); n != 1 {
+		t.Errorf("举报2 状态 = %d, 期望 1(旧版 2 表示已处理)", n)
+	}
+}
+
 // TestRunDryRun 试运行不应写入任何数据
 func TestRunDryRun(t *testing.T) {
 	root := t.TempDir()
@@ -261,6 +414,10 @@ func TestRunDryRun(t *testing.T) {
 	if st.Posts == 0 {
 		t.Error("试运行应统计出帖子数量")
 	}
+	// 试运行不会真正写入帖子,评论/举报的关联判定不能因此失效
+	if st.Comments == 0 || st.Reports == 0 {
+		t.Errorf("试运行应统计出评论与举报: 评论=%d 举报=%d", st.Comments, st.Reports)
+	}
 	if n := models.QueryInt("SELECT COUNT(1) FROM posts"); n != 0 {
 		t.Errorf("试运行写入了 %d 条帖子", n)
 	}
diff --git a/internal/migrate/source.go b/internal/migrate/source.go
new file mode 100644
index 0000000..789449c
--- /dev/null
+++ b/internal/migrate/source.go
@@ -0,0 +1,175 @@
+package migrate
+
+import (
+	"database/sql"
+	"os"
+	"strconv"
+	"strings"
+
+	_ "modernc.org/sqlite"
+)
+
+// Row 一行数据:列名 -> 字符串值(NULL 统一映射为空串)
+type Row map[string]string
+
+// Source 旧版数据源。支持两种来源:
+//   - SQLite 数据库文件(旧版 data.db)
+//   - MySQL 导出文件(mysqldump / 宝塔导出的 .sql)
+type Source interface {
+	// HasTable 数据源中是否存在指定表
+	HasTable(table string) bool
+	// Rows 读取整表,fields 为候选列名(数据源中不存在的列不会出现在结果行里)
+	Rows(table string, fields ...string) ([]Row, error)
+	// Kind 数据源类型(sqlite / mysqldump)
+	Kind() string
+	// Close 释放资源
+	Close() error
+}
+
+// field 从行中按候选列名取第一个非空值
+func field(row Row, names ...string) string {
+	for _, n := range names {
+		if v, ok := row[n]; ok && v != "" {
+			return v
+		}
+	}
+	return ""
+}
+
+// fieldInt 取整数值(缺失或非法时返回 0)
+func fieldInt(row Row, names ...string) int64 {
+	n, _ := strconv.ParseInt(strings.TrimSpace(field(row, names...)), 10, 64)
+	return n
+}
+
+// ---------- SQLite 数据源 ----------
+
+type sqliteSource struct {
+	db   *sql.DB
+	has  map[string]bool
+	cols map[string]map[string]bool
+}
+
+// openSQLiteSource 打开旧版 SQLite 数据库文件
+func openSQLiteSource(path string) (*sqliteSource, error) {
+	db, err := sql.Open("sqlite", path)
+	if err != nil {
+		return nil, err
+	}
+	if err := db.Ping(); err != nil {
+		db.Close()
+		return nil, err
+	}
+	s := &sqliteSource{db: db, has: map[string]bool{}, cols: map[string]map[string]bool{}}
+	rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table'")
+	if err != nil {
+		db.Close()
+		return nil, err
+	}
+	var tables []string
+	for rows.Next() {
+		var name sql.NullString
+		if rows.Scan(&name) == nil && name.Valid {
+			tables = append(tables, name.String)
+		}
+	}
+	rows.Close()
+	for _, t := range tables {
+		s.has[t] = true
+		s.cols[t] = map[string]bool{}
+		cr, err := db.Query("PRAGMA table_info(" + quoteIdent(t) + ")")
+		if err != nil {
+			continue
+		}
+		for cr.Next() {
+			var cid int
+			var name, ctype string
+			var notnull, pk int
+			var dflt sql.NullString
+			if cr.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk) == nil {
+				s.cols[t][name] = true
+			}
+		}
+		cr.Close()
+	}
+	return s, nil
+}
+
+func (s *sqliteSource) HasTable(table string) bool { return s.has[table] }
+
+func (s *sqliteSource) Kind() string { return "sqlite" }
+
+func (s *sqliteSource) Close() error { return s.db.Close() }
+
+func (s *sqliteSource) Rows(table string, fields ...string) ([]Row, error) {
+	if !s.has[table] {
+		return nil, nil
+	}
+	var exprs []string
+	for _, f := range fields {
+		if s.cols[table][f] {
+			exprs = append(exprs, quoteIdent(f)+" AS "+quoteIdent(f))
+		}
+	}
+	if len(exprs) == 0 {
+		return nil, nil
+	}
+	rows, err := s.db.Query("SELECT " + strings.Join(exprs, ",") + " FROM " + quoteIdent(table))
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	names, err := rows.Columns()
+	if err != nil {
+		return nil, err
+	}
+	var out []Row
+	for rows.Next() {
+		vals := make([]sql.NullString, len(names))
+		ptrs := make([]any, len(names))
+		for i := range vals {
+			ptrs[i] = &vals[i]
+		}
+		if err := rows.Scan(ptrs...); err != nil {
+			continue
+		}
+		row := Row{}
+		for i, c := range names {
+			if vals[i].Valid {
+				row[c] = vals[i].String
+			}
+		}
+		out = append(out, row)
+	}
+	return out, nil
+}
+
+// quoteIdent 用双引号包裹标识符(SQLite 兼容)
+func quoteIdent(s string) string {
+	return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
+}
+
+// ---------- 数据源探测 ----------
+
+// OpenSource 按文件内容自动识别数据源类型:SQLite 数据库文件或 MySQL 导出文件
+func OpenSource(path string) (Source, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	head := make([]byte, 16)
+	n, _ := f.Read(head)
+	f.Close()
+	// SQLite 文件头:"SQLite format 3\x00"
+	if n >= 16 && string(head[:15]) == "SQLite format 3" {
+		return openSQLiteSource(path)
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	if len(data) == 0 {
+		return nil, errEmptyFile
+	}
+	return parseDump(data)
+}
diff --git a/main.go b/main.go
index d05f2fb..b9ad50d 100644
--- a/main.go
+++ b/main.go
@@ -92,8 +92,10 @@ func runMigrate(args []string) {
 		}
 	}
 	if oldPath == "" {
-		log.Fatalf("用法: %s migrate <旧版 data.db 路径> [-uploads 旧版 uploads 目录] [-dry]\n"+
-			"示例: %s migrate /www/wwwroot/old/data.db -uploads /www/wwwroot/old/uploads",
+		log.Fatalf("用法: %s migrate <旧版数据文件> [-uploads 旧版 uploads 目录] [-dry]\n"+
+			"支持旧版 SQLite 的 data.db 与 MySQL 导出的 .sql(自动识别)\n"+
+			"示例: %s migrate /www/wwwroot/old/data.db -uploads /www/wwwroot/old/uploads\n"+
+			"也可以在后台「数据迁移」页面上传数据库文件完成迁移",
 			os.Args[0], os.Args[0])
 	}
 	if err := ensureDirs(); err != nil {
@@ -223,6 +225,10 @@ func buildMux() *http.ServeMux {
 	mux.HandleFunc("POST /admin/plugins/toggle", handlers.AdminPluginToggle)
 	mux.HandleFunc("POST /admin/plugins/delete", handlers.AdminPluginDelete)
 	mux.HandleFunc("POST /admin/plugins/config", handlers.AdminPluginConfig)
+	// 旧版数据迁移(上传 data.db 或 MySQL 导出文件)
+	mux.HandleFunc("GET /admin/migrate", handlers.AdminMigrate)
+	mux.HandleFunc("POST /admin/migrate/start", handlers.AdminMigrateStart)
+	mux.HandleFunc("GET /admin/migrate/status", handlers.AdminMigrateStatus)
 	// 插件商店(对接云端插件社区)
 	mux.HandleFunc("GET /admin/store", handlers.AdminStore)
 	mux.HandleFunc("POST /admin/store/install", handlers.AdminStoreInstall)
diff --git a/web/static/app.css b/web/static/app.css
index 57544d1..5702d8e 100644
--- a/web/static/app.css
+++ b/web/static/app.css
@@ -344,6 +344,14 @@ textarea { resize: vertical; }
 .settings-form .card { padding: 18px; }
 .check-row { display: flex; align-items: center; gap: 8px; }
 .check-row input { width: auto; margin: 0; }
+/* ---------------- 数据迁移 ---------------- */
+.tips-list { margin: 6px 0 0; padding-left: 20px; font-size: 13px; color: #4b5060; line-height: 1.9; }
+.tips-list li { margin-bottom: 2px; }
+.tips-list code { background: rgba(127, 127, 127, .1); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
+.card > .table { margin-top: 4px; }
+.card > .table td { padding: 7px 0; border-bottom: 1px dashed var(--clv-border); font-size: 13px; }
+.card > .table tr:last-child td { border-bottom: 0; }
+
 /* ---------------- 插件商店(云端) ---------------- */
 .store-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
 .store-card { display: flex; flex-direction: column; margin: 0; }
diff --git a/web/static/app.js b/web/static/app.js
index 1e61b9c..99253e0 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -900,6 +900,111 @@
     }
   }
 
+  /* ---------------- 旧版数据迁移(后台) ---------------- */
+  function initMigrate() {
+    var form = document.getElementById('migrate-form');
+    if (!form) return;
+    var fileInput = document.getElementById('migrate-file');
+    var btn = document.getElementById('migrate-start');
+    var bar = document.getElementById('migrate-progress');
+    var okBox = document.getElementById('migrate-ok');
+    var errBox = document.getElementById('migrate-err');
+    var resultCard = document.getElementById('migrate-result');
+    var resultBody = document.getElementById('migrate-result-body');
+    var timer = null;
+
+    function setBar(pct) {
+      if (!bar) return;
+      bar.hidden = false;
+      var span = bar.firstElementChild;
+      if (span) span.style.width = pct + '%';
+    }
+    function hideBar() { if (bar) bar.hidden = true; }
+    function idleBtn() { if (btn) { btn.disabled = false; btn.textContent = '开始迁移'; } }
+    function showErr(msg) {
+      if (errBox) { errBox.textContent = msg; errBox.hidden = false; }
+      if (okBox) okBox.hidden = true;
+    }
+
+    function renderStats(st, dry) {
+      if (!resultCard || !resultBody) return;
+      var rows = [
+        ['数据来源', st.source === 'mysqldump' ? 'MySQL 导出文件' : 'SQLite 数据库'],
+        ['帖子', st.posts], ['评论', st.comments], ['话题', st.topics],
+        ['点赞补齐', st.likes], ['媒体文件', st.media],
+        ['公告', st.notices], ['用户', st.users], ['管理员', st.admins],
+        ['封禁名单', st.bans], ['举报', st.reports],
+        ['跳过(已存在)', st.skipped], ['媒体文件缺失', st.missing], ['关联失效已忽略', st.no_post_ref]
+      ];
+      var html = '<table class="table"><tbody>';
+      for (var i = 0; i < rows.length; i++) {
+        html += '<tr><td>' + rows[i][0] + '</td><td><b>' + rows[i][1] + '</b></td></tr>';
+      }
+      html += '</tbody></table>';
+      html += dry
+        ? '<p class="form-ok">以上为试运行结果,未写入任何数据。确认无误后,取消勾选「试运行」再执行一次即可正式导入。</p>'
+        : '<p class="form-ok">迁移完成,刷新前台即可看到数据。若媒体文件缺失较多,请检查旧版 uploads 目录路径是否正确。</p>';
+      resultBody.innerHTML = html;
+      resultCard.hidden = false;
+    }
+
+    // 轮询迁移进度(服务端异步执行,避免大文件导入时请求超时)
+    function poll() {
+      var xhr = new XMLHttpRequest();
+      xhr.open('GET', '/admin/migrate/status');
+      xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
+      xhr.onload = function () {
+        var st = {};
+        try { st = JSON.parse(xhr.responseText); } catch (e) { return; }
+        if (st.running) { setBar(70); return; }
+        if (timer) { clearInterval(timer); timer = null; }
+        hideBar();
+        idleBtn();
+        if (st.err) { showErr('迁移失败:' + st.err); return; }
+        if (st.stats) {
+          if (okBox) { okBox.textContent = st.dry_run ? '试运行完成' : '迁移执行完成'; okBox.hidden = false; }
+          renderStats(st.stats, st.dry_run);
+        }
+      };
+      xhr.send();
+    }
+
+    form.addEventListener('submit', function (ev) {
+      ev.preventDefault();
+      if (errBox) errBox.hidden = true;
+      if (okBox) okBox.hidden = true;
+      if (fileInput && !fileInput.files.length) { showErr('请选择旧版数据库文件'); return; }
+      if (resultCard) resultCard.hidden = true;
+
+      var fd = new FormData(form);
+      var xhr = new XMLHttpRequest();
+      xhr.open('POST', '/admin/migrate/start');
+      xhr.setRequestHeader('X-CSRF-Token', CSRF);
+      xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
+      if (btn) { btn.disabled = true; btn.textContent = '上传中...'; }
+      xhr.upload.onprogress = function (e) {
+        if (e.lengthComputable) setBar(Math.round(e.loaded / e.total * 100));
+      };
+      xhr.upload.onload = function () { if (btn) btn.textContent = '迁移中...'; };
+      xhr.onload = function () {
+        var r = {};
+        try { r = JSON.parse(xhr.responseText); } catch (e) { }
+        if (xhr.status !== 200 || !r.ok) {
+          hideBar();
+          idleBtn();
+          showErr(r.msg || '启动迁移失败');
+          return;
+        }
+        setBar(70);
+        if (timer) clearInterval(timer);
+        timer = setInterval(poll, 1200);
+        poll();
+      };
+      xhr.onerror = function () { hideBar(); idleBtn(); showErr('网络异常,请稍后重试'); };
+      xhr.send(fd);
+    });
+  }
+
   /* ---------------- 启动 ---------------- */
 
   document.addEventListener('DOMContentLoaded', function () {
@@ -911,5 +1016,6 @@
     initInstall();
     initPatrol();
     initAdmin();
+    initMigrate();
   });
 })();
diff --git a/web/templates/admin.html b/web/templates/admin.html
index ff7c214..23ab7f6 100644
--- a/web/templates/admin.html
+++ b/web/templates/admin.html
@@ -646,6 +646,55 @@
 {{end}}
 
 {{/* ============ 关于 ============ */}}
+{{/* ============ 数据迁移(旧版 PHP 1.x → 2.0) ============ */}}
+{{define "pg_admin_migrate"}}
+<div class="page-head">
+  <h1 class="page-title">数据迁移</h1>
+  <p class="muted">把 ClearLove PHP 旧版(1.x)的帖子、评论、用户等数据迁移到当前站点</p>
+</div>
+
+<div class="card">
+  <h3>迁移说明</h3>
+  <ul class="tips-list">
+    <li>支持旧版 <b>SQLite</b> 数据库文件(旧版根目录的 <code>data.db</code>)与 <b>MySQL</b> 导出的 <code>.sql</code> 文件(宝塔「数据库 → 备份」或 <code>mysqldump</code>),上传后自动识别</li>
+    <li>迁移会<b>保留原有 ID 与发布时间</b>,旧版的「正常 / 隐藏」状态会自动换算为新版语义(不会出现帖子集体消失或隐藏帖冒出来)</li>
+    <li>用户与管理员密码同为 bcrypt,<b>迁移后可直接用原密码登录</b></li>
+    <li>可重复执行:已存在的记录会自动跳过,不会产生重复数据</li>
+    <li>迁移期间请勿发帖;数据量大时耗时较长,可以关闭页面稍后回来查看结果</li>
+  </ul>
+</div>
+
+<div class="card">
+  <h3>上传旧版数据库</h3>
+  <form id="migrate-form" enctype="multipart/form-data">
+    <input type="hidden" name="_csrf" value="{{.csrf}}">
+    <label>数据库文件
+      <input type="file" name="file" id="migrate-file" accept=".db,.sql,.sqlite,.sqlite3" required>
+    </label>
+    <p class="muted small">单个文件不超过 {{.maxMB}}MB。SQLite 请上传旧版根目录的 <code>data.db</code>;MySQL 请上传导出的 <code>.sql</code>。</p>
+
+    <label>旧版 uploads 目录路径(可选)
+      <input type="text" name="uploads_dir" id="migrate-uploads" placeholder="/www/wwwroot/old/uploads">
+    </label>
+    <p class="muted small">填写服务器上的绝对路径时,会把旧站图片 / 视频复制到新版 <code>uploads/legacy/</code>;留空则只迁移文字数据(图片会缺失,但不影响帖子本身)。</p>
+
+    <label class="check-row"><input type="checkbox" name="dry" value="1" id="migrate-dry" checked> 试运行(只统计不写入,建议先跑一次)</label>
+
+    <div class="btn-row">
+      <button class="btn btn-primary" type="submit" id="migrate-start">开始迁移</button>
+    </div>
+  </form>
+  <div class="progress" id="migrate-progress" hidden><span></span></div>
+  <p class="form-ok" id="migrate-ok" hidden></p>
+  <p class="form-err" id="migrate-err" hidden></p>
+</div>
+
+<div class="card" id="migrate-result" hidden>
+  <h3>迁移结果</h3>
+  <div id="migrate-result-body"></div>
+</div>
+{{end}}
+
 {{define "pg_admin_about"}}
 <h1 class="page-title">关于</h1>
 <div class="card about-card">
diff --git a/web/templates/admin_layout.html b/web/templates/admin_layout.html
index b80ff24..eefb79d 100644
--- a/web/templates/admin_layout.html
+++ b/web/templates/admin_layout.html
@@ -38,6 +38,8 @@
       <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2.5 2.5"/></svg>API 接口</a>{{end}}
     <a href="/admin/about" class="{{if eq .Page "pg_admin_about"}}active{{end}}">
       <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 11v6M12 7.5v.5"/></svg>关于</a>
+    {{if .admin.HasPerm "settings"}}<a href="/admin/migrate" class="{{if eq .Page "pg_admin_migrate"}}active{{end}}">
+      <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5.5" rx="7" ry="2.8"/><path d="M5 5.5v13c0 1.5 3.1 2.8 7 2.8s7-1.3 7-2.8v-13"/><path d="M5 12c0 1.5 3.1 2.8 7 2.8s7-1.3 7-2.8"/></svg>数据迁移</a>{{end}}
     {{if .admin.HasPerm "settings"}}<a href="/admin/update" class="{{if eq .Page "pg_admin_update"}}active{{end}}">
       <svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 12a8 8 0 1 1-2.3-5.7M20 4v5h-5"/></svg>检查更新</a>{{end}}
   </nav>