1. 修复海报图片
This commit is contained in:
@@ -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"},
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user