feat(migrate): 支持 MySQL 导出文件,并可在后台上传数据库完成迁移
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, ¬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
@@ -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, ¬null, &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>