clearlove2.1
1// Package migrate 将 ClearLove PHP 旧版(1.x)的数据迁移到 2.0。
2//
3// 支持两种数据来源,由文件内容自动识别:
4// - SQLite 数据库文件(旧版根目录 data.db)
5// - MySQL 导出文件(mysqldump / 宝塔导出的 .sql)
6//
7// 迁移内容:话题 / 帖子(含媒体文件与点赞数)/ 评论 / 公告 / 用户 / 管理员 /
8// 封禁名单 / 举报。访问量(visits)与置顶(is_pinned)在新版中没有对应结构,跳过。
9//
10// 特点:
11// - 保留原有自增 ID(评论、举报的关联关系不受影响)
12// - 可重复执行:已存在的记录按 ID / 用户名 / (类型,值) 判重跳过
13// - 旧版 status 语义为 0=正常 1=隐藏,新版为 1=可见 0=隐藏,迁移时自动取反
14package migrate
15
16import (
17 "database/sql"
18 "encoding/json"
19 "fmt"
20 "io"
21 "os"
22 "path/filepath"
23 "strings"
24 "time"
25
26 "clearlove/internal/config"
27 "clearlove/internal/database"
28 "clearlove/internal/models"
29 "clearlove/internal/util"
30)
31
32// Stats 迁移结果统计
33type Stats struct {
34 Source string `json:"source"` // sqlite / mysqldump
35 Topics int `json:"topics"`
36 Posts int `json:"posts"`
37 Media int `json:"media"`
38 Comments int `json:"comments"`
39 Notices int `json:"notices"`
40 Users int `json:"users"`
41 Admins int `json:"admins"`
42 Bans int `json:"bans"`
43 Reports int `json:"reports"`
44 Likes int `json:"likes"`
45 Skipped int `json:"skipped"` // 已存在而跳过的记录
46 Missing int `json:"missing"` // 媒体文件缺失数量
47 NoPostRef int `json:"no_post_ref"` // 引用了不存在帖子的评论 / 举报
48}
49
50// dry 试运行:只统计不写入
51var dry bool
52
53// pendingPosts 本次迁移中已导入(试运行时为「将会导入」)的帖子 ID。
54// 试运行不会真正写入帖子,若直接查库判断,评论与举报会被误判为"关联失效"而统计为 0。
55var pendingPosts = map[int64]bool{}
56
57// postExists 判断帖子是否存在于新版库或本次迁移范围内
58func postExists(id int64) bool {
59 if id <= 0 {
60 return false
61 }
62 if pendingPosts[id] {
63 return true
64 }
65 return exists("SELECT COUNT(1) FROM posts WHERE id=?", id)
66}
67
68// Run 按文件路径执行迁移,数据源类型由文件内容自动识别
69func Run(oldPath, oldUploads string, dryRun bool) (*Stats, error) {
70 if _, err := os.Stat(oldPath); err != nil {
71 return nil, fmt.Errorf("找不到旧版数据文件: %s", oldPath)
72 }
73 src, err := OpenSource(oldPath)
74 if err != nil {
75 return nil, fmt.Errorf("无法识别旧版数据文件: %w", err)
76 }
77 defer src.Close()
78 if oldUploads == "" {
79 oldUploads = filepath.Join(filepath.Dir(oldPath), "uploads")
80 }
81 return RunSource(src, oldUploads, dryRun)
82}
83
84// RunSource 从已打开的数据源执行迁移
85func RunSource(src Source, oldUploads string, dryRun bool) (*Stats, error) {
86 dry = dryRun
87 if !src.HasTable("posts") {
88 return nil, fmt.Errorf("数据源中找不到 posts 表,请确认这是旧版 ClearLove 的数据库(data.db 或导出的 .sql)")
89 }
90 st := &Stats{Source: src.Kind()}
91 pendingPosts = map[int64]bool{}
92
93 topicMap, err := migrateTopics(src, st)
94 if err != nil {
95 return st, err
96 }
97 if err := migratePosts(src, topicMap, oldUploads, st); err != nil {
98 return st, err
99 }
100 if err := migrateComments(src, st); err != nil {
101 return st, err
102 }
103 if err := migrateNotices(src, st); err != nil {
104 return st, err
105 }
106 if err := migrateUsers(src, st); err != nil {
107 return st, err
108 }
109 if err := migrateAdmins(src, st); err != nil {
110 return st, err
111 }
112 if err := migrateBans(src, st); err != nil {
113 return st, err
114 }
115 if err := migrateReports(src, st); err != nil {
116 return st, err
117 }
118 // 评论数以实际记录为准重算
119 if !dry {
120 _, _ = database.DB.Exec(`UPDATE posts SET comment_count =
121 (SELECT COUNT(1) FROM comments WHERE comments.post_id = posts.id)`)
122 }
123 return st, nil
124}
125
126// ---------- 通用辅助 ----------
127
128// exists 新版库中是否已有该记录
129func exists(q string, args ...any) bool {
130 return models.QueryInt(q, args...) > 0
131}
132
133// exec 写操作(试运行时不执行)
134func exec(q string, args ...any) (sql.Result, error) {
135 if dry {
136 return nil, nil
137 }
138 return database.DB.Exec(q, args...)
139}
140
141// parseTime 旧版时间转 RFC3339(SQLite 存 UTC,MySQL DATETIME 视为 UTC)
142func parseTime(s string) string {
143 s = strings.TrimSpace(s)
144 if s == "" {
145 return models.Now()
146 }
147 for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05Z07:00",
148 "2006-01-02T15:04:05", "2006-01-02"} {
149 if t, err := time.Parse(layout, s); err == nil {
150 return t.UTC().Format(time.RFC3339)
151 }
152 }
153 return models.Now()
154}
155
156// ensureTopic 按名称取得话题 ID(不存在则创建)
157func ensureTopic(name string, m map[string]int64) int64 {
158 name = strings.TrimSpace(name)
159 if name == "" {
160 return 0
161 }
162 if id, ok := m[name]; ok {
163 return id
164 }
165 if id := models.QueryInt("SELECT id FROM topics WHERE name=?", name); id > 0 {
166 m[name] = id
167 return id
168 }
169 res, err := exec("INSERT INTO topics(name,created_at) VALUES(?,?)", name, models.Now())
170 if err != nil || res == nil {
171 return 0
172 }
173 id, _ := res.LastInsertId()
174 m[name] = id
175 return id
176}
177
178// ---------- 各类数据 ----------
179
180// migrateTopics 迁移话题表
181func migrateTopics(src Source, st *Stats) (map[string]int64, error) {
182 m := map[string]int64{}
183 rows, err := src.Rows("topics", "name")
184 if err != nil {
185 return m, nil
186 }
187 for _, row := range rows {
188 name := strings.TrimSpace(field(row, "name"))
189 if name == "" {
190 continue
191 }
192 if exists("SELECT COUNT(1) FROM topics WHERE name=?", name) {
193 st.Skipped++
194 }
195 ensureTopic(name, m)
196 st.Topics++
197 }
198 return m, nil
199}
200
201// MediaItem 旧版媒体 JSON 单元
202type MediaItem struct {
203 File string `json:"file"`
204 Type string `json:"type"`
205}
206
207// migratePosts 迁移帖子与媒体、点赞
208func migratePosts(src Source, topicMap map[string]int64, oldUploads string, st *Stats) error {
209 rows, err := src.Rows("posts", "id", "nickname", "content", "topic", "media", "likes",
210 "ip_address", "ip", "fingerprint", "status", "is_admin", "created_at")
211 if err != nil {
212 return fmt.Errorf("读取旧版帖子失败: %w", err)
213 }
214 for _, row := range rows {
215 id := fieldInt(row, "id")
216 if id <= 0 || exists("SELECT COUNT(1) FROM posts WHERE id=?", id) {
217 st.Skipped++
218 continue
219 }
220 pendingPosts[id] = true
221 // 旧版:0(或 NULL)= 正常展示,1 = 隐藏;新版:1 = 可见,0 = 隐藏 -> 取反
222 newStatus := int64(1)
223 if strings.TrimSpace(field(row, "status")) == "1" {
224 newStatus = 0
225 }
226 nickname := strings.TrimSpace(field(row, "nickname"))
227 if nickname == "" {
228 nickname = "匿名"
229 }
230 isAdmin := fieldInt(row, "is_admin")
231 likes := fieldInt(row, "likes")
232 created := parseTime(field(row, "created_at"))
233 topicID := ensureTopic(field(row, "topic"), topicMap)
234
235 if _, err := exec(`INSERT INTO posts(id,user_id,nickname,content,topic_id,ip,fingerprint,
236 status,like_count,comment_count,is_admin,badges,created_at) VALUES(?,?,?,?,?,?,?,?,?,0,?,?,?)`,
237 id, 0, nickname, field(row, "content"), topicID,
238 field(row, "ip_address", "ip"), field(row, "fingerprint"),
239 newStatus, likes, isAdmin, "", created); err != nil {
240 return fmt.Errorf("写入帖子 #%d 失败: %w", id, err)
241 }
242 st.Posts++
243
244 // 点赞:新版点赞数由 likes 表实时统计,这里按旧版计数补齐占位记录,
245 // 避免迁移后用户点一次赞就把原有赞数清零
246 for i := int64(0); i < likes; i++ {
247 if _, err := exec("INSERT INTO likes(post_id,user_id,fingerprint,created_at) VALUES(?,0,?,?)",
248 id, fmt.Sprintf("legacy-%d-%d", id, i), created); err == nil {
249 st.Likes++
250 }
251 }
252
253 // 媒体:复制文件到新版 uploads/legacy/ 并建立 medias 记录
254 var items []MediaItem
255 if raw := strings.TrimSpace(field(row, "media")); raw != "" {
256 _ = json.Unmarshal([]byte(raw), &items)
257 }
258 for _, it := range items {
259 name := filepath.Base(strings.TrimSpace(it.File))
260 if name == "" || name == "." {
261 continue
262 }
263 if copyMedia(oldUploads, name, st) != "" {
264 typ := it.Type
265 if typ != "video" {
266 typ = "image"
267 }
268 if _, err := exec("INSERT INTO medias(post_id,type,path,created_at) VALUES(?,?,?,?)",
269 id, typ, "/uploads/legacy/"+name, created); err == nil {
270 st.Media++
271 }
272 }
273 }
274 }
275 util.Log("info", "旧版帖子迁移完成:新增 %d,跳过 %d", st.Posts, st.Skipped)
276 return nil
277}
278
279// copyMedia 把旧版上传目录中的文件复制到新版 uploads/legacy/,返回目标文件名
280func copyMedia(oldUploads, name string, st *Stats) string {
281 src := filepath.Join(oldUploads, name)
282 if fi, err := os.Stat(src); err != nil || fi.IsDir() {
283 st.Missing++
284 return ""
285 }
286 if dry {
287 return name
288 }
289 dstDir := filepath.Join(config.Cfg.UploadDir, "legacy")
290 if err := os.MkdirAll(dstDir, 0o755); err != nil {
291 st.Missing++
292 return ""
293 }
294 dst := filepath.Join(dstDir, name)
295 if _, err := os.Stat(dst); err == nil {
296 return name // 已复制过
297 }
298 in, err := os.Open(src)
299 if err != nil {
300 st.Missing++
301 return ""
302 }
303 defer in.Close()
304 out, err := os.Create(dst)
305 if err != nil {
306 st.Missing++
307 return ""
308 }
309 defer out.Close()
310 if _, err := io.Copy(out, in); err != nil {
311 st.Missing++
312 return ""
313 }
314 return name
315}
316
317// migrateComments 迁移评论
318func migrateComments(src Source, st *Stats) error {
319 rows, err := src.Rows("comments", "id", "post_id", "nickname", "content",
320 "ip_address", "ip", "fingerprint", "created_at")
321 if err != nil {
322 return nil
323 }
324 for _, row := range rows {
325 id, pid := fieldInt(row, "id"), fieldInt(row, "post_id")
326 if !postExists(pid) {
327 st.NoPostRef++
328 continue
329 }
330 if id > 0 && exists("SELECT COUNT(1) FROM comments WHERE id=?", id) {
331 st.Skipped++
332 continue
333 }
334 nickname := strings.TrimSpace(field(row, "nickname"))
335 if nickname == "" {
336 nickname = "匿名"
337 }
338 if _, err := exec(`INSERT INTO comments(id,post_id,user_id,nickname,content,ip,fingerprint,created_at)
339 VALUES(?,?,0,?,?,?,?,?)`,
340 id, pid, nickname, field(row, "content"),
341 field(row, "ip_address", "ip"), field(row, "fingerprint"),
342 parseTime(field(row, "created_at"))); err != nil {
343 return fmt.Errorf("写入评论 #%d 失败: %w", id, err)
344 }
345 st.Comments++
346 }
347 return nil
348}
349
350// migrateNotices 迁移公告(旧版 announcements -> 新版 notices)
351func migrateNotices(src Source, st *Stats) error {
352 rows, err := src.Rows("announcements", "content", "active", "created_at")
353 if err != nil {
354 return nil
355 }
356 for _, row := range rows {
357 // 旧版 active=0 表示已停用,导入会让它成为首页最新公告,故跳过
358 if strings.TrimSpace(field(row, "active")) == "0" {
359 continue
360 }
361 body := strings.TrimSpace(field(row, "content"))
362 if body == "" {
363 continue
364 }
365 // 新版公告有标题字段,旧版没有:截取正文前 20 字作为标题
366 title := []rune(body)
367 if len(title) > 20 {
368 title = title[:20]
369 }
370 if _, err := exec("INSERT INTO notices(title,content,created_at) VALUES(?,?,?)",
371 string(title), body, parseTime(field(row, "created_at"))); err != nil {
372 return fmt.Errorf("写入公告失败: %w", err)
373 }
374 st.Notices++
375 }
376 return nil
377}
378
379// migrateUsers 迁移注册用户(密码同为 bcrypt,可直接沿用)
380func migrateUsers(src Source, st *Stats) error {
381 if !src.HasTable("users") {
382 return nil
383 }
384 rows, err := src.Rows("users", "id", "username", "password", "email", "created_at")
385 if err != nil {
386 return nil
387 }
388 for _, row := range rows {
389 id := fieldInt(row, "id")
390 username := strings.TrimSpace(field(row, "username"))
391 password := field(row, "password")
392 if username == "" || password == "" {
393 continue
394 }
395 if exists("SELECT COUNT(1) FROM users WHERE username=?", username) {
396 st.Skipped++
397 continue
398 }
399 // 新版 email 非空且唯一:旧版可能为空或重复,这里补齐占位邮箱
400 email := strings.TrimSpace(field(row, "email"))
401 if email == "" || exists("SELECT COUNT(1) FROM users WHERE email=?", email) {
402 email = fmt.Sprintf("legacy_%d@migrated.local", id)
403 }
404 if _, err := exec("INSERT INTO users(username,email,password,avatar,status,created_at) VALUES(?,?,?,'',1,?)",
405 username, email, password, parseTime(field(row, "created_at"))); err != nil {
406 return fmt.Errorf("写入用户 %s 失败: %w", username, err)
407 }
408 st.Users++
409 }
410 return nil
411}
412
413// migrateAdmins 迁移管理员(密码同为 bcrypt)
414func migrateAdmins(src Source, st *Stats) error {
415 if !src.HasTable("admins") {
416 return nil
417 }
418 rows, err := src.Rows("admins", "username", "password", "role")
419 if err != nil {
420 return nil
421 }
422 for _, row := range rows {
423 username := strings.TrimSpace(field(row, "username"))
424 password := field(row, "password")
425 if username == "" || password == "" {
426 continue
427 }
428 if exists("SELECT COUNT(1) FROM admins WHERE username=?", username) {
429 st.Skipped++
430 continue
431 }
432 // 旧版权限模型与新版权限组不同:非超管先以空权限导入,由管理员在后台重新分配
433 newRole, perms := "custom", "[]"
434 if field(row, "role") == "super" {
435 newRole = "super"
436 }
437 if _, err := exec("INSERT INTO admins(username,password,role,perms,created_at) VALUES(?,?,?,?,?)",
438 username, password, newRole, perms, models.Now()); err != nil {
439 return fmt.Errorf("写入管理员 %s 失败: %w", username, err)
440 }
441 st.Admins++
442 }
443 return nil
444}
445
446// migrateBans 迁移封禁名单(banned_devices -> bans)
447func migrateBans(src Source, st *Stats) error {
448 rows, err := src.Rows("banned_devices", "type", "value")
449 if err != nil {
450 return nil
451 }
452 for _, row := range rows {
453 t := strings.TrimSpace(field(row, "type"))
454 v := strings.TrimSpace(field(row, "value"))
455 if (t != "ip" && t != "fingerprint") || v == "" {
456 continue
457 }
458 if exists("SELECT COUNT(1) FROM bans WHERE btype=? AND bvalue=?", t, v) {
459 st.Skipped++
460 continue
461 }
462 if _, err := exec("INSERT INTO bans(btype,bvalue,created_at) VALUES(?,?,?)", t, v, models.Now()); err != nil {
463 return fmt.Errorf("写入封禁记录失败: %w", err)
464 }
465 st.Bans++
466 }
467 return nil
468}
469
470// migrateReports 迁移举报
471func migrateReports(src Source, st *Stats) error {
472 if !src.HasTable("reports") {
473 return nil
474 }
475 rows, err := src.Rows("reports", "id", "post_id", "reason", "detail",
476 "reporter_ip", "ip", "status", "created_at")
477 if err != nil {
478 return nil
479 }
480 for _, row := range rows {
481 id, pid := fieldInt(row, "id"), fieldInt(row, "post_id")
482 // 帖子已被删除(或未迁移)的举报没有意义,跳过
483 if !postExists(pid) {
484 st.NoPostRef++
485 continue
486 }
487 if id > 0 && exists("SELECT COUNT(1) FROM reports WHERE id=?", id) {
488 st.Skipped++
489 continue
490 }
491 text := strings.TrimSpace(field(row, "reason"))
492 if d := strings.TrimSpace(field(row, "detail")); d != "" {
493 if text == "" {
494 text = d
495 } else {
496 text += ":" + d
497 }
498 }
499 // 旧版 0=待处理,1=已展示,2=已删除;新版 0=待处理,1=已处理
500 newStatus := int64(0)
501 if s := strings.TrimSpace(field(row, "status")); s != "" && s != "0" {
502 newStatus = 1
503 }
504 if _, err := exec("INSERT INTO reports(id,post_id,reason,reporter_ip,status,ai_result,created_at) VALUES(?,?,?,?,?,'',?)",
505 id, pid, text, field(row, "reporter_ip", "ip"), newStatus,
506 parseTime(field(row, "created_at"))); err != nil {
507 return fmt.Errorf("写入举报 #%d 失败: %w", id, err)
508 }
509 st.Reports++
510 }
511 return nil
512}