// migrate-base64-images 将 chapters 表中 content 内的 base64 图片提取为文件并替换为 URL // 用法:cd soul-api && go run ./cmd/migrate-base64-images [--dry-run] // 测试环境:APP_ENV=development 时加载 .env.development,请先在测试库验证 package main import ( "encoding/base64" "flag" "fmt" "log" "math/rand" "os" "path/filepath" "regexp" "strings" "time" "soul-api/internal/config" "soul-api/internal/database" "soul-api/internal/model" ) // data:image/png;base64,iVBORw0KG... 或 data:image/jpeg;base64,/9j/4AAQ... var base64ImgRe = regexp.MustCompile(`(?i)src=["'](data:image/([^;"']+);base64,([A-Za-z0-9+/=]+))["']`) func main() { dryRun := flag.Bool("dry-run", false, "仅统计和预览,不写入文件与数据库") flag.Parse() cfg, err := config.Load() if err != nil { log.Fatal("load config: ", err) } config.SetCurrent(cfg) if err := database.Init(cfg.DBDSN); err != nil { log.Fatal("database: ", err) } uploadDir := cfg.UploadDir if uploadDir == "" { uploadDir = "uploads" } bookImagesDir := filepath.Join(uploadDir, "book-images") if !*dryRun { if err := os.MkdirAll(bookImagesDir, 0755); err != nil { log.Fatal("mkdir book-images: ", err) } } db := database.DB() var chapters []model.Chapter if err := db.Select("id", "mid", "section_title", "content").Where("content LIKE ?", "%data:image%").Find(&chapters).Error; err != nil { log.Fatal("query chapters: ", err) } log.Printf("找到 %d 篇含 base64 图片的章节", len(chapters)) if len(chapters) == 0 { return } rand.Seed(time.Now().UnixNano()) const letters = "abcdefghijklmnopqrstuvwxyz0123456789" randomStr := func(n int) string { b := make([]byte, n) for i := range b { b[i] = letters[rand.Intn(len(letters))] } return string(b) } mimeToExt := map[string]string{ "png": ".png", "jpeg": ".jpg", "jpg": ".jpg", "gif": ".gif", "webp": ".webp", } totalReplaced := 0 totalFiles := 0 for _, ch := range chapters { matches := base64ImgRe.FindAllStringSubmatch(ch.Content, -1) if len(matches) == 0 { continue } newContent := ch.Content for _, m := range matches { fullDataURL := m[1] mime := strings.ToLower(strings.TrimSpace(m[2])) b64 := m[3] ext := mimeToExt[mime] if ext == "" { ext = ".png" } decoded, err := base64.StdEncoding.DecodeString(b64) if err != nil { log.Printf(" [%s] base64 解码失败: %v", ch.ID, err) continue } name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStr(6), ext) dst := filepath.Join(bookImagesDir, name) url := "/uploads/" + filepath.ToSlash(filepath.Join("book-images", name)) if !*dryRun { if err := os.WriteFile(dst, decoded, 0644); err != nil { log.Printf(" [%s] 写入文件失败 %s: %v", ch.ID, name, err) continue } } oldSrc := `src="` + fullDataURL + `"` newSrc := `src="` + url + `"` if strings.Contains(newContent, oldSrc) { newContent = strings.Replace(newContent, oldSrc, newSrc, 1) } else { oldSrc2 := `src='` + fullDataURL + `'` newSrc2 := `src="` + url + `"` newContent = strings.Replace(newContent, oldSrc2, newSrc2, 1) } totalFiles++ log.Printf(" [%s] %s -> %s (%d bytes)", ch.ID, mime, name, len(decoded)) } if newContent != ch.Content { totalReplaced++ oldLen := len(ch.Content) newLen := len(newContent) if !*dryRun { if err := db.Model(&model.Chapter{}).Where("id = ?", ch.ID).Update("content", newContent).Error; err != nil { log.Printf(" [%s] 更新数据库失败: %v", ch.ID, err) continue } } log.Printf(" [%s] 已更新,content 长度 %d -> %d (减少 %d)", ch.ID, oldLen, newLen, oldLen-newLen) } } if *dryRun { log.Printf("[dry-run] 将处理 %d 篇章节,共 %d 张 base64 图片", totalReplaced, totalFiles) log.Printf("[dry-run] 去掉 --dry-run 后执行以实际写入") } else { log.Printf("完成:更新 %d 篇章节,提取 %d 张图片到 uploads/book-images/", totalReplaced, totalFiles) } }