文章编辑器问题

This commit is contained in:
Alex-larget
2026-03-14 23:27:22 +08:00
parent d82ef6d8e4
commit 7ece8f52ff
24 changed files with 642 additions and 217 deletions

View File

@@ -0,0 +1,146 @@
// 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)
}
}

View File

@@ -47,6 +47,9 @@ func main() {
go func() {
log.Printf("soul-api listen on :%s (mode=%s)", cfg.Port, cfg.Mode)
log.Printf(" -> 访问地址: http://localhost:%s (健康检查: http://localhost:%s/health)", cfg.Port, cfg.Port)
if cfg.UploadDir != "" {
log.Printf(" -> 上传目录: %s", cfg.UploadDir)
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("listen: ", err)
}

View File

@@ -49,6 +49,9 @@ type Config struct {
// 订单对账定时任务间隔分钟0 表示不启动内置定时任务
SyncOrdersIntervalMinutes int
// 上传目录绝对路径air 运行时避免相对路径解析错误)
UploadDir string
}
// BaseURLJoin 将路径拼接到 BaseURLpath 应以 / 开头
@@ -239,6 +242,14 @@ func Load() (*Config, error) {
}
}
// 上传目录:优先 UPLOAD_DIR 环境变量,否则用项目根下的 uploads
uploadDir := strings.TrimSpace(os.Getenv("UPLOAD_DIR"))
if uploadDir == "" {
uploadDir = resolveUploadDir(workDir, execDir)
} else if !filepath.IsAbs(uploadDir) {
uploadDir, _ = filepath.Abs(filepath.Join(workDir, uploadDir))
}
return &Config{
Port: port,
Mode: mode,
@@ -265,5 +276,21 @@ func Load() (*Config, error) {
AdminPassword: adminPassword,
AdminSessionSecret: adminSessionSecret,
SyncOrdersIntervalMinutes: syncOrdersInterval,
UploadDir: uploadDir,
}, nil
}
// resolveUploadDir 解析上传目录绝对路径air 运行时 exe 在 tmp/,需用项目根)
func resolveUploadDir(workDir, execDir string) string {
root := workDir
if execDir != "" {
base := filepath.Base(execDir)
if base == "tmp" {
root = filepath.Dir(execDir)
} else {
root = execDir
}
}
abs, _ := filepath.Abs(filepath.Join(root, "uploads"))
return abs
}

View File

@@ -306,8 +306,47 @@ func DBBookAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "sections": sections, "total": len(sections)})
return
case "read":
midStr := c.Query("mid")
if midStr != "" {
// 优先用 mid 获取(管理端编辑、小程序跳转推荐)
mid, err := strconv.Atoi(midStr)
if err != nil || mid < 1 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "mid 必须为正整数"})
return
}
var ch model.Chapter
if err := db.Where("mid = ?", mid).First(&ch).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "章节不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
price := 1.0
if ch.Price != nil {
price = *ch.Price
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"section": gin.H{
"id": ch.ID,
"title": ch.SectionTitle,
"price": price,
"content": ch.Content,
"isNew": ch.IsNew,
"partId": ch.PartID,
"partTitle": ch.PartTitle,
"chapterId": ch.ChapterID,
"chapterTitle": ch.ChapterTitle,
"editionStandard": ch.EditionStandard,
"editionPremium": ch.EditionPremium,
},
})
return
}
if id == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或 mid"})
return
}
var ch model.Chapter

View File

@@ -71,7 +71,7 @@ func SearchGet(c *gin.Context) {
price = *ch.Price
}
results = append(results, gin.H{
"id": ch.ID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
"id": ch.ID, "mid": ch.MID, "title": ch.SectionTitle, "partTitle": ch.PartTitle, "chapterTitle": ch.ChapterTitle,
"price": price, "isFree": ch.IsFree, "matchType": matchType, "score": score, "snippet": snippet,
})
}

View File

@@ -9,10 +9,10 @@ import (
"strings"
"time"
"soul-api/internal/config"
"github.com/gin-gonic/gin"
)
const uploadDir = "uploads"
const maxUploadBytes = 5 * 1024 * 1024 // 5MB
var allowedTypes = map[string]bool{"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true}
@@ -40,6 +40,10 @@ func UploadPost(c *gin.Context) {
if folder == "" {
folder = "avatars"
}
uploadDir := config.Get().UploadDir
if uploadDir == "" {
uploadDir = "uploads"
}
dir := filepath.Join(uploadDir, folder)
_ = os.MkdirAll(dir, 0755)
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
@@ -48,8 +52,12 @@ func UploadPost(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
return
}
url := "/" + filepath.ToSlash(filepath.Join(uploadDir, folder, name))
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct}})
relPath := "/uploads/" + filepath.ToSlash(filepath.Join(folder, name))
fullURL := relPath
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
fullURL = cfg.BaseURLJoin(relPath)
}
c.JSON(http.StatusOK, gin.H{"success": true, "url": fullURL, "data": gin.H{"url": fullURL, "fileName": name, "size": file.Size, "type": ct}})
}
func randomStrUpload(n int) string {
@@ -72,7 +80,13 @@ func UploadDelete(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限删除此文件"})
return
}
fullPath := strings.TrimPrefix(path, "/")
rel := strings.TrimPrefix(path, "/uploads/")
rel = strings.TrimPrefix(rel, "uploads/")
uploadDir := config.Get().UploadDir
if uploadDir == "" {
uploadDir = "uploads"
}
fullPath := filepath.Join(uploadDir, filepath.FromSlash(rel))
if err := os.Remove(fullPath); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "文件不存在或删除失败"})
return

View File

@@ -28,7 +28,11 @@ func Setup(cfg *config.Config) *gin.Engine {
rateLimiter := middleware.NewRateLimiter(100, 200)
r.Use(rateLimiter.Middleware())
r.Static("/uploads", "./uploads")
uploadDir := cfg.UploadDir
if uploadDir == "" {
uploadDir = "./uploads"
}
r.Static("/uploads", uploadDir)
api := r.Group("/api")
{

View File

@@ -0,0 +1,62 @@
# 文章 base64 图片迁移脚本
`chapters` 表中 `content` 字段内嵌的 base64 图片提取为独立文件,并替换为 `/uploads/book-images/xxx` 的 URL减小文章体积。
## 适用场景
- 历史文章中有大量粘贴的 base64 图片
- 保存时因 content 过大导致超时或失败
- 需要将 base64 转为文件存储
## 执行方式
### 1. 测试环境(建议先执行)
```bash
cd soul-api
# 加载测试环境配置(.env.development
$env:APP_ENV="development"
# 先 dry-run 预览,不写入
go run ./cmd/migrate-base64-images --dry-run
# 确认无误后正式执行
go run ./cmd/migrate-base64-images
```
### 2. 生产环境
```bash
cd soul-api
$env:APP_ENV="production"
go run ./cmd/migrate-base64-images --dry-run # 先预览
go run ./cmd/migrate-base64-images # 正式执行
```
### 3. 指定 DSN覆盖 .env
```bash
$env:DB_DSN="user:pass@tcp(host:port)/db?charset=utf8mb4&parseTime=True"
go run ./cmd/migrate-base64-images --dry-run
```
## 参数
| 参数 | 说明 |
|------|------|
| `--dry-run` | 仅统计和预览,不写入文件与数据库 |
## 行为说明
1. 查询 `content LIKE '%data:image%'` 的章节
2. 用正则提取 `src="data:image/xxx;base64,..."``src='...'`
3. 解码 base64保存到 `uploads/book-images/{timestamp}_{random}.{ext}`
4. 将 content 中的 base64 src 替换为 `/uploads/book-images/xxx`
5. 更新数据库
## 注意事项
- **务必先在测试环境验证**,确认无误后再跑生产
- 脚本依赖 `UPLOAD_DIR` 或默认 `uploads` 目录
- 图片格式支持png、jpeg、jpg、gif、webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB