1. 修复海报图片

This commit is contained in:
李琦
2026-08-03 16:00:13 +08:00
parent 8543d0bb25
commit 18d7296f01
13 changed files with 1052 additions and 105 deletions

View File

@@ -31,6 +31,7 @@ func migrateSchema(db *gorm.DB) {
{&models.TemplateConfig{}, "请柬模板配置", "template_configs"},
{&models.PosterConfig{}, "邀请函海报配置", "poster_configs"},
{&models.UploadConfig{}, "上传配置", "upload_configs"},
{&models.MediaAsset{}, "上传图库", "media_assets"},
{&models.Rsvp{}, "出席回执", "rsvps"},
{&models.Danmaku{}, "祝福弹幕", "danmakus"},
{&models.SiteLike{}, "站点点赞", "site_likes"},

View File

@@ -34,6 +34,18 @@ type UploadConfig struct {
func (UploadConfig) TableName() string { return "upload_configs" }
// MediaAsset 已上传图片资源(图库选图)
type MediaAsset struct {
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
// url 索引长度受 InnoDB utf8mb4 限制≤768 字符),过长会导致建表失败
URL string `gorm:"column:url;size:768;not null;index;comment:访问地址" json:"url"`
Name string `gorm:"column:name;size:255;not null;default:'';comment:原始文件名" json:"name"`
Provider string `gorm:"column:provider;size:20;not null;default:local;comment:来源 local|oss" json:"provider"`
CreatedAt time.Time `gorm:"column:created_at;not null;index;comment:上传时间" json:"created_at"`
}
func (MediaAsset) TableName() string { return "media_assets" }
type Rsvp struct {
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
Name string `gorm:"column:name;size:50;not null;default:'';comment:宾客姓名" json:"name"`

View File

@@ -38,21 +38,17 @@ func resolveSide(raw string) string {
}
}
func isLegacyFlat(m map[string]interface{}) bool {
if m == nil {
return false
// resolveLayout 返回 "1"(竖版)或 "2"(横版);无法识别返回空
func resolveLayout(raw string) string {
v := strings.ToLower(strings.TrimSpace(raw))
switch v {
case "1", "portrait", "v", "shu", "竖版":
return "1"
case "2", "landscape", "h", "heng", "横版":
return "2"
default:
return ""
}
for _, k := range []string{"1", "2", "3", "male", "female", "hm"} {
if _, ok := m[k]; ok {
return false
}
}
for _, k := range []string{"title", "lines", "heroImg", "sonName", "template"} {
if _, ok := m[k]; ok {
return true
}
}
return false
}
func emptyObj() map[string]interface{} {
@@ -75,28 +71,93 @@ func takeSide(raw map[string]interface{}, keys ...string) map[string]interface{}
return emptyObj()
}
// normalizeBundle 统一为 { "1", "2", "3" };兼容旧 male/female 与扁平配置
func isLegacyFlat(m map[string]interface{}) bool {
if m == nil {
return false
}
for _, k := range []string{"1", "2", "3", "male", "female", "hm"} {
if _, ok := m[k]; ok {
return false
}
}
for _, k := range []string{"title", "lines", "heroImg", "sonName", "template"} {
if _, ok := m[k]; ok {
return true
}
}
return false
}
func isFlatPosterConfig(m map[string]interface{}) bool {
if m == nil || len(m) == 0 {
return false
}
// 已有嵌套 layout
for _, k := range []string{"1", "2"} {
if child := asObject(m[k]); len(child) > 0 {
for _, fk := range []string{"title", "lines", "heroImg", "sonName", "template", "groomName"} {
if _, ok := child[fk]; ok {
return false
}
}
}
}
for _, k := range []string{"title", "lines", "heroImg", "sonName", "template", "groomName", "mobileLayout", "pageTitle"} {
if _, ok := m[k]; ok {
return true
}
}
return false
}
func emptyLayouts() map[string]interface{} {
return map[string]interface{}{
"1": emptyObj(),
"2": emptyObj(),
}
}
// normalizeSideLayouts 将单侧归一为 { "1": 竖版, "2": 横版 }
func normalizeSideLayouts(raw map[string]interface{}) map[string]interface{} {
out := emptyLayouts()
if raw == nil {
return out
}
if isFlatPosterConfig(raw) {
out["1"] = raw
return out
}
if m := asObject(raw["1"]); len(m) > 0 {
out["1"] = m
}
if m := asObject(raw["2"]); len(m) > 0 {
out["2"] = m
}
return out
}
// normalizeBundle 统一为 { "1"|"2"|"3": { "1"|"2": cfg } }
func normalizeBundle(raw map[string]interface{}) map[string]interface{} {
if raw == nil {
raw = emptyObj()
}
out := map[string]interface{}{
"1": emptyObj(),
"2": emptyObj(),
"3": emptyObj(),
"1": emptyLayouts(),
"2": emptyLayouts(),
"3": emptyLayouts(),
}
if isLegacyFlat(raw) {
out["1"] = raw
out["1"] = normalizeSideLayouts(raw)
return out
}
if m := takeSide(raw, "1", "male"); len(m) > 0 {
out["1"] = m
out["1"] = normalizeSideLayouts(m)
}
if f := takeSide(raw, "2", "female"); len(f) > 0 {
out["2"] = f
out["2"] = normalizeSideLayouts(f)
}
if h := takeSide(raw, "3", "hm", "hmy"); len(h) > 0 {
out["3"] = h
out["3"] = normalizeSideLayouts(h)
}
return out
}
@@ -120,6 +181,12 @@ func loadBundle() map[string]interface{} {
func handleGetPosterConfig(c *gin.Context) {
bundle := loadBundle()
side := resolveSide(c.Query("side"))
layout := resolveLayout(c.Query("layout"))
if side != "" && layout != "" {
sideObj := asObject(bundle[side])
c.JSON(200, gin.H{"code": 200, "data": asObject(sideObj[layout])})
return
}
if side != "" {
c.JSON(200, gin.H{"code": 200, "data": bundle[side]})
return

View File

@@ -9,9 +9,11 @@ import (
"log"
"mime/multipart"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"hunliji-api/configsection"
@@ -202,6 +204,7 @@ func registerAPI(api *gin.RouterGroup) {
api.GET("/upload/config", handleGetUploadConfig)
api.POST("/upload/config", handleSaveUploadConfig)
api.POST("/upload", handleUpload)
api.GET("/upload/assets", handleUploadAssets)
api.POST("/ai/blessing", handleAIBlessing)
api.POST("/rsvp", handleCreateRsvp)
api.GET("/rsvp/list", handleRsvpList)
@@ -289,6 +292,8 @@ func handleSaveConfig(c *gin.Context) {
c.JSON(500, gin.H{"error": "数据库保存配置失败"})
return
}
// 保存后从配置抓取 OSS/远程图写入 media_assets
go harvestConfigMediaAssets()
c.JSON(200, gin.H{"code": 200, "msg": "配置保存成功"})
}
@@ -365,6 +370,290 @@ func handleUpload(c *gin.Context) {
saveLocalUpload(c, file)
}
func recordMediaAsset(fileURL, name, provider string) {
fileURL = strings.TrimSpace(fileURL)
if fileURL == "" {
return
}
// 与表字段 varchar(768) 对齐,避免超长写入失败
if len(fileURL) > 768 {
fileURL = fileURL[:768]
}
var n int64
if db.Model(&models.MediaAsset{}).Where("url = ?", fileURL).Limit(1).Count(&n); n > 0 {
return
}
if strings.TrimSpace(provider) == "" {
provider = guessMediaProvider(fileURL)
}
if strings.TrimSpace(name) == "" {
name = mediaNameFromURL(fileURL)
}
_ = db.Create(&models.MediaAsset{
URL: fileURL,
Name: strings.TrimSpace(name),
Provider: provider,
}).Error
}
// recordMediaAssetsBulk 批量登记:先一次性拉取已有 URL再只插入缺失项
func recordMediaAssetsBulk(items []models.MediaAsset) {
if len(items) == 0 {
return
}
var existing []string
_ = db.Model(&models.MediaAsset{}).Pluck("url", &existing)
have := make(map[string]bool, len(existing))
for _, u := range existing {
have[u] = true
}
toCreate := make([]models.MediaAsset, 0, 16)
for _, it := range items {
u := strings.TrimSpace(it.URL)
if u == "" {
continue
}
if len(u) > 768 {
u = u[:768]
}
if have[u] {
continue
}
have[u] = true
name := strings.TrimSpace(it.Name)
if name == "" {
name = mediaNameFromURL(u)
}
provider := strings.TrimSpace(it.Provider)
if provider == "" {
provider = guessMediaProvider(u)
}
toCreate = append(toCreate, models.MediaAsset{
URL: u,
Name: name,
Provider: provider,
})
}
if len(toCreate) == 0 {
return
}
_ = db.CreateInBatches(&toCreate, 100).Error
}
// harvestConfigMediaAssets 从请柬/海报配置抓取已用图片(含 OSS写入 media_assets不调 OSS 列表接口
func harvestConfigMediaAssets() {
items := make([]models.MediaAsset, 0, 64)
var tpl models.TemplateConfig
if err := db.First(&tpl, 1).Error; err == nil {
for _, u := range collectImageURLsFromJSON(tpl.ConfigData) {
items = append(items, models.MediaAsset{
URL: u,
Name: mediaNameFromURL(u),
Provider: guessMediaProvider(u),
})
}
}
var poster models.PosterConfig
if err := db.First(&poster, 1).Error; err == nil {
for _, u := range collectImageURLsFromJSON(poster.ConfigData) {
items = append(items, models.MediaAsset{
URL: u,
Name: mediaNameFromURL(u),
Provider: guessMediaProvider(u),
})
}
}
recordMediaAssetsBulk(items)
}
func scanLocalUploadAssets() []models.MediaAsset {
entries, err := os.ReadDir("./uploads")
if err != nil {
return nil
}
out := make([]models.MediaAsset, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !isImageFilename(e.Name()) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, models.MediaAsset{
URL: fmt.Sprintf("http://localhost:8080/uploads/%s", e.Name()),
Name: e.Name(),
Provider: "local",
CreatedAt: info.ModTime(),
})
}
return out
}
func syncMediaLibrary() {
harvestConfigMediaAssets()
recordMediaAssetsBulk(scanLocalUploadAssets())
}
var (
mediaSyncMu sync.Mutex
mediaSyncing bool
mediaLastSync time.Time
)
// trySyncMediaLibraryAsync 后台低频同步图库,不阻塞列表接口
func trySyncMediaLibraryAsync() {
mediaSyncMu.Lock()
if mediaSyncing || time.Since(mediaLastSync) < 2*time.Minute {
mediaSyncMu.Unlock()
return
}
mediaSyncing = true
mediaSyncMu.Unlock()
go func() {
defer func() {
mediaSyncMu.Lock()
mediaSyncing = false
mediaLastSync = time.Now()
mediaSyncMu.Unlock()
}()
syncMediaLibrary()
}()
}
func handleUploadAssets(c *gin.Context) {
if !utils.RequireAdmin(c) {
return
}
// 默认只读库refresh=1 时同步补全后再返回(手动刷新)
if strings.TrimSpace(c.Query("refresh")) == "1" {
syncMediaLibrary()
} else {
trySyncMediaLibraryAsync()
}
page := 1
pageSize := 6
if v := strings.TrimSpace(c.Query("page")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
page = n
}
}
if v := strings.TrimSpace(c.Query("pageSize")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 {
pageSize = n
}
}
var total int64
db.Model(&models.MediaAsset{}).Count(&total)
var list []models.MediaAsset
offset := (page - 1) * pageSize
db.Order("created_at desc, id desc").Offset(offset).Limit(pageSize).Find(&list)
c.JSON(200, gin.H{
"code": 200,
"data": list,
"total": total,
"page": page,
"pageSize": pageSize,
})
}
func guessMediaProvider(fileURL string) string {
u := strings.ToLower(strings.TrimSpace(fileURL))
if strings.Contains(u, "/uploads/") || strings.Contains(u, "localhost") || strings.Contains(u, "127.0.0.1") {
return "local"
}
return "oss"
}
func mediaNameFromURL(fileURL string) string {
u := strings.TrimSpace(fileURL)
if i := strings.IndexAny(u, "?#"); i >= 0 {
u = u[:i]
}
base := filepath.Base(u)
if base == "" || base == "." || base == "/" {
return "图片"
}
if dec, err := url.PathUnescape(base); err == nil && dec != "" {
return dec
}
return base
}
func isImageFilename(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg", ".avif":
return true
default:
return false
}
}
func looksLikeImageURL(s string) bool {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") {
return false
}
path := s
if i := strings.IndexAny(path, "?#"); i >= 0 {
path = path[:i]
}
if isImageFilename(path) {
return true
}
lower := strings.ToLower(path)
// 配置里常见 OSS / CDN 图(无扩展名时也尽量纳入)
for _, hint := range []string{
"aliyuncs.com", "oss-", "/uploads/", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif",
} {
if strings.Contains(lower, hint) {
return true
}
}
return false
}
func collectImageURLsFromValue(v interface{}, out *[]string, seen map[string]bool) {
switch t := v.(type) {
case string:
src := strings.TrimSpace(t)
if !looksLikeImageURL(src) || seen[src] {
return
}
seen[src] = true
*out = append(*out, src)
case []interface{}:
for _, item := range t {
collectImageURLsFromValue(item, out, seen)
}
case map[string]interface{}:
for _, item := range t {
collectImageURLsFromValue(item, out, seen)
}
}
}
func collectImageURLsFromJSON(rawJSON string) []string {
rawJSON = strings.TrimSpace(rawJSON)
if rawJSON == "" {
return nil
}
var parsed interface{}
if err := json.Unmarshal([]byte(rawJSON), &parsed); err != nil {
return nil
}
out := make([]string, 0, 32)
seen := map[string]bool{}
collectImageURLsFromValue(parsed, &out, seen)
return out
}
func handleAIBlessing(c *gin.Context) {
if sparkClient == nil || !sparkClient.Enabled() {
c.JSON(503, gin.H{"error": "AI 未配置"})
@@ -944,6 +1233,7 @@ func saveLocalUpload(c *gin.Context, file *multipart.FileHeader) {
return
}
fileURL := fmt.Sprintf("http://localhost:8080/uploads/%s", filename)
recordMediaAsset(fileURL, file.Filename, "local")
c.JSON(200, gin.H{"code": 200, "url": fileURL, "provider": "local"})
}
@@ -974,7 +1264,9 @@ func saveOSSUpload(c *gin.Context, fileHeader *multipart.FileHeader, config mode
c.JSON(500, gin.H{"error": "OSS上传失败: " + err.Error()})
return false
}
c.JSON(200, gin.H{"code": 200, "url": objectURL(config, objectKey), "provider": "oss", "key": objectKey})
fileURL := objectURL(config, objectKey)
recordMediaAsset(fileURL, fileHeader.Filename, "oss")
c.JSON(200, gin.H{"code": 200, "url": fileURL, "provider": "oss", "key": objectKey})
return true
}