1853 lines
46 KiB
Go
1853 lines
46 KiB
Go
package router
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"mime/multipart"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"hunliji-api/configsection"
|
||
"hunliji-api/models"
|
||
"hunliji-api/posterconfig"
|
||
"hunliji-api/spark"
|
||
"hunliji-api/utils"
|
||
"hunliji-api/visit"
|
||
|
||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type Deps struct {
|
||
DB *gorm.DB
|
||
Spark *spark.Client
|
||
AdminAccount string
|
||
AdminPassword string
|
||
}
|
||
|
||
var (
|
||
db *gorm.DB
|
||
sparkClient *spark.Client
|
||
adminAccount string
|
||
adminPassword string
|
||
)
|
||
|
||
var danmakuColorWhitelist = map[string]struct{}{
|
||
"champagne": {}, "blush": {}, "apricot": {}, "gold": {}, "lilac": {},
|
||
"sky": {}, "mauve": {}, "slate": {}, "ink": {},
|
||
"gradSunset": {}, "gradChampagne": {}, "gradBlush": {},
|
||
"gradOcean": {}, "gradAurora": {}, "gradEmber": {},
|
||
}
|
||
|
||
// giftTypeWhitelist 礼物类型白名单(与前端常量表一致)
|
||
var giftTypeWhitelist = map[string]string{
|
||
"520": "520",
|
||
"1314": "1314",
|
||
"forever": "长长久久",
|
||
"bald": "白头偕老",
|
||
"concentric": "永结同心",
|
||
"heaven": "天作之合",
|
||
"moisten": "相濡以沫",
|
||
"match": "佳偶天成",
|
||
}
|
||
|
||
func emptyGiftCounts() map[string]int64 {
|
||
counts := make(map[string]int64, len(giftTypeWhitelist))
|
||
for k := range giftTypeWhitelist {
|
||
counts[k] = 0
|
||
}
|
||
return counts
|
||
}
|
||
|
||
func loadGiftCounts() map[string]int64 {
|
||
counts := emptyGiftCounts()
|
||
type row struct {
|
||
GiftType string `gorm:"column:gift_type"`
|
||
Cnt int64 `gorm:"column:cnt"`
|
||
}
|
||
var rows []row
|
||
db.Model(&models.SiteGift{}).
|
||
Select("gift_type, COUNT(*) as cnt").
|
||
Group("gift_type").
|
||
Find(&rows)
|
||
for _, r := range rows {
|
||
if _, ok := giftTypeWhitelist[r.GiftType]; ok {
|
||
counts[r.GiftType] = r.Cnt
|
||
}
|
||
}
|
||
return counts
|
||
}
|
||
|
||
// defaultGiftCosts 默认三阶梯:50 / 100 / 200(永结同心为终章,默认 200)
|
||
var defaultGiftCosts = map[string]int64{
|
||
"520": 50,
|
||
"forever": 50,
|
||
"bald": 50,
|
||
"1314": 100,
|
||
"heaven": 100,
|
||
"moisten": 100,
|
||
"match": 200,
|
||
"concentric": 200,
|
||
}
|
||
|
||
const (
|
||
giftCostMin int64 = 1
|
||
giftCostMax int64 = 10000
|
||
likeBatchMax = 200
|
||
)
|
||
|
||
func defaultGiftCostsCopy() map[string]int64 {
|
||
out := make(map[string]int64, len(defaultGiftCosts))
|
||
for k, v := range defaultGiftCosts {
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
func loadGiftCosts() map[string]int64 {
|
||
costs := defaultGiftCostsCopy()
|
||
var cfg models.GiftConfig
|
||
if err := db.First(&cfg, 1).Error; err != nil || strings.TrimSpace(cfg.CostsData) == "" {
|
||
return costs
|
||
}
|
||
parsed := map[string]int64{}
|
||
if err := json.Unmarshal([]byte(cfg.CostsData), &parsed); err != nil {
|
||
return costs
|
||
}
|
||
for k := range giftTypeWhitelist {
|
||
if v, ok := parsed[k]; ok && v >= giftCostMin && v <= giftCostMax {
|
||
costs[k] = v
|
||
}
|
||
}
|
||
return costs
|
||
}
|
||
|
||
func saveGiftCosts(costs map[string]int64) error {
|
||
normalized := defaultGiftCostsCopy()
|
||
for k := range giftTypeWhitelist {
|
||
if v, ok := costs[k]; ok && v >= giftCostMin && v <= giftCostMax {
|
||
normalized[k] = v
|
||
}
|
||
}
|
||
raw, err := json.Marshal(normalized)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var cfg models.GiftConfig
|
||
if err := db.First(&cfg, 1).Error; err != nil {
|
||
cfg = models.GiftConfig{ID: 1, CostsData: string(raw)}
|
||
return db.Create(&cfg).Error
|
||
}
|
||
return db.Model(&cfg).Update("costs_data", string(raw)).Error
|
||
}
|
||
|
||
// giftBalance 点赞余额:可用 = 我的点赞 - 已兑换消耗
|
||
func giftBalance(clientID string) (myLikes, spent, available int64) {
|
||
clientID = strings.TrimSpace(clientID)
|
||
if clientID == "" {
|
||
return 0, 0, 0
|
||
}
|
||
db.Model(&models.SiteLike{}).Where("client_id = ?", clientID).Count(&myLikes)
|
||
db.Model(&models.SiteGift{}).Where("client_id = ?", clientID).Select("COALESCE(SUM(cost), 0)").Scan(&spent)
|
||
available = myLikes - spent
|
||
if available < 0 {
|
||
available = 0
|
||
}
|
||
return
|
||
}
|
||
|
||
func giftQuotaPayload(clientID string) gin.H {
|
||
myLikes, spent, available := giftBalance(clientID)
|
||
var giftUsed int64
|
||
if strings.TrimSpace(clientID) != "" {
|
||
db.Model(&models.SiteGift{}).Where("client_id = ?", clientID).Count(&giftUsed)
|
||
}
|
||
return gin.H{
|
||
"my_likes": myLikes,
|
||
"spent": spent,
|
||
"available": available,
|
||
"gift_used": giftUsed,
|
||
"costs": loadGiftCosts(),
|
||
}
|
||
}
|
||
|
||
// Register 挂载静态资源、中间件与全部 /api 路由
|
||
func Register(r *gin.Engine, deps Deps) {
|
||
db = deps.DB
|
||
sparkClient = deps.Spark
|
||
adminAccount = deps.AdminAccount
|
||
adminPassword = deps.AdminPassword
|
||
|
||
r.Use(utils.CORSMiddleware())
|
||
r.Static("/uploads", "./uploads")
|
||
|
||
api := r.Group("/api")
|
||
registerAPI(api)
|
||
}
|
||
|
||
func registerAPI(api *gin.RouterGroup) {
|
||
api.POST("/admin/login", handleAdminLogin)
|
||
api.GET("/config", handleGetConfig)
|
||
api.POST("/config", handleSaveConfig)
|
||
api.GET("/album", handleAlbum)
|
||
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)
|
||
api.DELETE("/rsvp/:id", handleDeleteRsvp)
|
||
api.GET("/danmaku", handlePublicDanmaku)
|
||
api.POST("/danmaku", handleCreateDanmaku)
|
||
api.GET("/danmaku/list", handleDanmakuList)
|
||
api.POST("/danmaku/:id/status", handleDanmakuStatus)
|
||
api.GET("/like", handleLikeCount)
|
||
api.POST("/like", handleLike)
|
||
api.GET("/gift", handleGiftStatus)
|
||
api.POST("/gift", handleCreateGift)
|
||
api.GET("/gift/stats", handleGiftStats)
|
||
api.GET("/gift/list", handleGiftList)
|
||
api.DELETE("/gift/:id", handleDeleteGift)
|
||
api.GET("/gift/costs", handleGetGiftCosts)
|
||
api.POST("/gift/costs", handleSaveGiftCosts)
|
||
api.POST("/cash-gift", handleCreateCashGift)
|
||
api.GET("/cash-gift/recent", handleCashGiftRecent)
|
||
api.GET("/cash-gift/notes", handleGetCashGiftNotes)
|
||
api.POST("/cash-gift/notes", handleSaveCashGiftNotes)
|
||
api.GET("/cash-gift/view-pass", handleGetCashGiftViewPass)
|
||
api.POST("/cash-gift/view-pass", handleSaveCashGiftViewPass)
|
||
api.POST("/cash-gift/view/unlock", handleCashGiftViewUnlock)
|
||
api.GET("/cash-gift/view/list", handleCashGiftViewList)
|
||
api.GET("/cash-gift/entry-pass", handleGetCashGiftEntryPass)
|
||
api.POST("/cash-gift/entry-pass", handleSaveCashGiftEntryPass)
|
||
api.POST("/cash-gift/entry/unlock", handleCashGiftEntryUnlock)
|
||
api.GET("/cash-gift/list", handleCashGiftList)
|
||
api.PUT("/cash-gift/:id", handleUpdateCashGift)
|
||
api.DELETE("/cash-gift/:id", handleDeleteCashGift)
|
||
|
||
visit.Register(api, db, utils.RequireAdmin)
|
||
configsection.Register(api, db, utils.RequireAdmin)
|
||
posterconfig.Register(api, db, utils.RequireAdmin)
|
||
}
|
||
|
||
func handleAdminLogin(c *gin.Context) {
|
||
var body struct {
|
||
Account string `json:"account"`
|
||
Password string `json:"password"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
if body.Account != adminAccount || body.Password != adminPassword {
|
||
c.JSON(401, gin.H{"error": "账号或密码错误"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"token": utils.SignToken(body.Account),
|
||
"user": gin.H{"account": body.Account},
|
||
})
|
||
}
|
||
|
||
func handleGetConfig(c *gin.Context) {
|
||
var config models.TemplateConfig
|
||
if err := db.First(&config, 1).Error; err != nil {
|
||
c.JSON(200, gin.H{"code": 200, "data": json.RawMessage(`{}`)})
|
||
return
|
||
}
|
||
raw := strings.TrimSpace(config.ConfigData)
|
||
if raw == "" {
|
||
raw = "{}"
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "data": json.RawMessage(raw)})
|
||
}
|
||
|
||
func handleSaveConfig(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
var rawBody interface{}
|
||
if err := c.BindJSON(&rawBody); err != nil {
|
||
c.JSON(400, gin.H{"error": "无效的JSON数据"})
|
||
return
|
||
}
|
||
jsonBytes, _ := json.Marshal(rawBody)
|
||
var config models.TemplateConfig
|
||
db.Attrs(models.TemplateConfig{ConfigData: "{}"}).FirstOrCreate(&config, models.TemplateConfig{ID: 1})
|
||
config.ConfigData = string(jsonBytes)
|
||
if err := db.Save(&config).Error; err != nil {
|
||
c.JSON(500, gin.H{"error": "数据库保存配置失败"})
|
||
return
|
||
}
|
||
// 保存后从配置抓取 OSS/远程图写入 media_assets
|
||
go harvestConfigMediaAssets()
|
||
c.JSON(200, gin.H{"code": 200, "msg": "配置保存成功"})
|
||
}
|
||
|
||
func handleAlbum(c *gin.Context) {
|
||
var config models.TemplateConfig
|
||
if err := db.First(&config, 1).Error; err != nil {
|
||
c.JSON(200, gin.H{"code": 200, "data": []string{}})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "data": collectAlbumImages(config.ConfigData)})
|
||
}
|
||
|
||
func handleGetUploadConfig(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(loadUploadConfig())})
|
||
}
|
||
|
||
func handleSaveUploadConfig(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
var body struct {
|
||
Provider string `json:"provider"`
|
||
AccessKeyID string `json:"accessKeyId"`
|
||
AccessKeySecret string `json:"accessKeySecret"`
|
||
Bucket string `json:"bucket"`
|
||
Folder string `json:"folder"`
|
||
Domain string `json:"domain"`
|
||
Region string `json:"region"`
|
||
Endpoint string `json:"endpoint"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
config := loadUploadConfig()
|
||
provider := strings.TrimSpace(body.Provider)
|
||
if provider != "oss" {
|
||
provider = "local"
|
||
}
|
||
config.Provider = provider
|
||
config.AccessKeyID = strings.TrimSpace(body.AccessKeyID)
|
||
if strings.TrimSpace(body.AccessKeySecret) != "" {
|
||
config.AccessKeySecret = strings.TrimSpace(body.AccessKeySecret)
|
||
}
|
||
config.Bucket = strings.TrimSpace(body.Bucket)
|
||
config.Folder = strings.Trim(body.Folder, "/ ")
|
||
config.Domain = strings.TrimSpace(body.Domain)
|
||
config.Region = strings.TrimSpace(body.Region)
|
||
config.Endpoint = strings.TrimSpace(body.Endpoint)
|
||
if err := db.Save(&config).Error; err != nil {
|
||
c.JSON(500, gin.H{"error": "上传配置保存失败"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(config), "msg": "上传配置保存成功"})
|
||
}
|
||
|
||
func handleUpload(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
file, err := c.FormFile("file")
|
||
if err != nil {
|
||
c.JSON(400, gin.H{"error": "未获取到上传文件"})
|
||
return
|
||
}
|
||
config := loadUploadConfig()
|
||
if canUseOSS(config) {
|
||
saveOSSUpload(c, file, config)
|
||
return
|
||
}
|
||
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 未配置"})
|
||
return
|
||
}
|
||
var body struct {
|
||
Style string `json:"style"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
if _, ok := spark.NormalizeStyle(body.Style); !ok {
|
||
c.JSON(400, gin.H{"error": "请选择祝福风格"})
|
||
return
|
||
}
|
||
text, err := sparkClient.GenerateBlessing(body.Style)
|
||
if err != nil {
|
||
c.JSON(500, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"text": text}})
|
||
}
|
||
|
||
func handleCreateRsvp(c *gin.Context) {
|
||
var rsvp models.Rsvp
|
||
if err := c.ShouldBindJSON(&rsvp); err != nil {
|
||
c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()})
|
||
return
|
||
}
|
||
wishes := strings.TrimSpace(rsvp.Wishes)
|
||
if wishes != "" {
|
||
ok, reason := moderateOrPass(rsvp.Name, wishes, "rsvp")
|
||
if !ok {
|
||
c.JSON(400, gin.H{"error": "内容未通过审核:" + reason})
|
||
return
|
||
}
|
||
}
|
||
if err := db.Create(&rsvp).Error; err != nil {
|
||
c.JSON(500, gin.H{"error": "出席信息写入数据库失败"})
|
||
return
|
||
}
|
||
var danmaku *models.Danmaku
|
||
if wishes != "" {
|
||
rid := rsvp.ID
|
||
item := models.Danmaku{
|
||
Name: rsvp.Name, Content: wishes, Color: "champagne",
|
||
Source: "rsvp", RsvpID: &rid, Status: "approved",
|
||
}
|
||
if err := db.Create(&item).Error; err == nil {
|
||
danmaku = &item
|
||
}
|
||
}
|
||
resp := gin.H{"code": 200, "msg": "回执提交成功"}
|
||
if danmaku != nil {
|
||
resp["danmaku"] = danmaku
|
||
}
|
||
c.JSON(200, resp)
|
||
}
|
||
|
||
func handleRsvpList(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
var list []models.Rsvp
|
||
db.Order("created_at desc").Find(&list)
|
||
c.JSON(200, gin.H{"code": 200, "data": list})
|
||
}
|
||
|
||
func handleDeleteRsvp(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
id := strings.TrimSpace(c.Param("id"))
|
||
if id == "" {
|
||
c.JSON(400, gin.H{"error": "缺少 ID"})
|
||
return
|
||
}
|
||
res := db.Delete(&models.Rsvp{}, id)
|
||
if res.Error != nil {
|
||
c.JSON(500, gin.H{"error": "删除失败"})
|
||
return
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
c.JSON(404, gin.H{"error": "记录不存在"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "msg": "已删除"})
|
||
}
|
||
|
||
func handlePublicDanmaku(c *gin.Context) {
|
||
q := db.Where("status = ?", "approved")
|
||
if after := strings.TrimSpace(c.Query("after_id")); after != "" {
|
||
var afterID uint64
|
||
if _, err := fmt.Sscanf(after, "%d", &afterID); err == nil && afterID > 0 {
|
||
q = q.Where("id > ?", afterID)
|
||
}
|
||
}
|
||
var list []models.Danmaku
|
||
q.Order("id asc").Find(&list)
|
||
c.JSON(200, gin.H{"code": 200, "data": list})
|
||
}
|
||
|
||
func handleCreateDanmaku(c *gin.Context) {
|
||
var body struct {
|
||
Name string `json:"name"`
|
||
Content string `json:"content"`
|
||
Color string `json:"color"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()})
|
||
return
|
||
}
|
||
name := strings.TrimSpace(body.Name)
|
||
content := strings.TrimSpace(body.Content)
|
||
if name == "" || content == "" {
|
||
c.JSON(400, gin.H{"error": "请填写姓名和祝福"})
|
||
return
|
||
}
|
||
if len([]rune(name)) > 50 {
|
||
c.JSON(400, gin.H{"error": "姓名过长"})
|
||
return
|
||
}
|
||
ok, reason := moderateOrPass(name, content, "danmaku")
|
||
if !ok {
|
||
c.JSON(400, gin.H{"error": "内容未通过审核:" + reason})
|
||
return
|
||
}
|
||
item := models.Danmaku{
|
||
Name: name, Content: content, Color: normalizeDanmakuColor(body.Color),
|
||
Source: "danmaku", Status: "approved",
|
||
}
|
||
if err := db.Create(&item).Error; err != nil {
|
||
c.JSON(500, gin.H{"error": "祝福提交失败"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "data": item, "msg": "发送成功"})
|
||
}
|
||
|
||
func handleDanmakuList(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
q := db.Order("created_at desc")
|
||
if status := strings.TrimSpace(c.Query("status")); status != "" {
|
||
q = q.Where("status = ?", status)
|
||
}
|
||
var list []models.Danmaku
|
||
q.Find(&list)
|
||
c.JSON(200, gin.H{"code": 200, "data": list})
|
||
}
|
||
|
||
func handleDanmakuStatus(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
var body struct {
|
||
Status string `json:"status"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
status := strings.TrimSpace(body.Status)
|
||
if status != "approved" && status != "rejected" && status != "pending" {
|
||
c.JSON(400, gin.H{"error": "无效的状态"})
|
||
return
|
||
}
|
||
id := c.Param("id")
|
||
res := db.Model(&models.Danmaku{}).Where("id = ?", id).Update("status", status)
|
||
if res.Error != nil {
|
||
c.JSON(500, gin.H{"error": "更新失败"})
|
||
return
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
c.JSON(404, gin.H{"error": "记录不存在"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "msg": "状态已更新"})
|
||
}
|
||
|
||
func handleLikeCount(c *gin.Context) {
|
||
var count int64
|
||
db.Model(&models.SiteLike{}).Count(&count)
|
||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}})
|
||
}
|
||
|
||
func handleLike(c *gin.Context) {
|
||
var body struct {
|
||
ClientID string `json:"client_id"`
|
||
Count int `json:"count"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
clientID := strings.TrimSpace(body.ClientID)
|
||
if clientID == "" || len(clientID) > 64 {
|
||
c.JSON(400, gin.H{"error": "无效的 client_id"})
|
||
return
|
||
}
|
||
n := body.Count
|
||
if n <= 0 {
|
||
n = 1
|
||
}
|
||
if n > likeBatchMax {
|
||
c.JSON(400, gin.H{"error": fmt.Sprintf("单次最多点赞 %d 次", likeBatchMax)})
|
||
return
|
||
}
|
||
rows := make([]models.SiteLike, n)
|
||
now := time.Now()
|
||
for i := 0; i < n; i++ {
|
||
rows[i] = models.SiteLike{ClientID: clientID, CreatedAt: now}
|
||
}
|
||
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
|
||
c.JSON(500, gin.H{"error": "点赞失败"})
|
||
return
|
||
}
|
||
var count int64
|
||
db.Model(&models.SiteLike{}).Count(&count)
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"data": gin.H{
|
||
"count": count,
|
||
"added": n,
|
||
"quota": giftQuotaPayload(clientID),
|
||
},
|
||
"msg": "点赞成功",
|
||
})
|
||
}
|
||
|
||
func handleGiftStatus(c *gin.Context) {
|
||
counts := loadGiftCounts()
|
||
var recent []models.SiteGift
|
||
db.Model(&models.SiteGift{}).
|
||
Order("id DESC").
|
||
Limit(20).
|
||
Find(&recent)
|
||
items := make([]gin.H, 0, len(recent))
|
||
for _, g := range recent {
|
||
items = append(items, gin.H{
|
||
"gift_type": g.GiftType,
|
||
"name": g.Name,
|
||
"created_at": g.CreatedAt,
|
||
})
|
||
}
|
||
// 回放按时间正序(最早在前)
|
||
for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 {
|
||
items[i], items[j] = items[j], items[i]
|
||
}
|
||
var total int64
|
||
for _, n := range counts {
|
||
total += n
|
||
}
|
||
clientID := strings.TrimSpace(c.Query("client_id"))
|
||
quota := giftQuotaPayload(clientID)
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"data": gin.H{
|
||
"counts": counts,
|
||
"total": total,
|
||
"recent": items,
|
||
"quota": quota,
|
||
},
|
||
})
|
||
}
|
||
|
||
func handleCreateGift(c *gin.Context) {
|
||
var body struct {
|
||
GiftType string `json:"gift_type"`
|
||
Name string `json:"name"`
|
||
ClientID string `json:"client_id"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
giftType := strings.TrimSpace(body.GiftType)
|
||
if _, ok := giftTypeWhitelist[giftType]; !ok {
|
||
c.JSON(400, gin.H{"error": "无效的礼物类型"})
|
||
return
|
||
}
|
||
name := strings.TrimSpace(body.Name)
|
||
if name == "" {
|
||
c.JSON(400, gin.H{"error": "请填写姓名"})
|
||
return
|
||
}
|
||
if len([]rune(name)) > 50 {
|
||
c.JSON(400, gin.H{"error": "姓名过长"})
|
||
return
|
||
}
|
||
clientID := strings.TrimSpace(body.ClientID)
|
||
if clientID == "" || len(clientID) > 64 {
|
||
c.JSON(400, gin.H{"error": "无效的 client_id"})
|
||
return
|
||
}
|
||
costs := loadGiftCosts()
|
||
cost := costs[giftType]
|
||
if cost < giftCostMin {
|
||
cost = defaultGiftCosts[giftType]
|
||
}
|
||
_, _, available := giftBalance(clientID)
|
||
if available < cost {
|
||
need := cost - available
|
||
c.JSON(400, gin.H{
|
||
"error": fmt.Sprintf("点赞不足,再点赞 %d 次可兑换「%s」", need, giftTypeWhitelist[giftType]),
|
||
"data": giftQuotaPayload(clientID),
|
||
})
|
||
return
|
||
}
|
||
if err := db.Create(&models.SiteGift{
|
||
GiftType: giftType,
|
||
Name: name,
|
||
ClientID: clientID,
|
||
Cost: cost,
|
||
}).Error; err != nil {
|
||
c.JSON(500, gin.H{"error": "送礼失败"})
|
||
return
|
||
}
|
||
counts := loadGiftCounts()
|
||
var total int64
|
||
for _, n := range counts {
|
||
total += n
|
||
}
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"data": gin.H{
|
||
"counts": counts,
|
||
"total": total,
|
||
"gift_type": giftType,
|
||
"cost": cost,
|
||
"quota": giftQuotaPayload(clientID),
|
||
},
|
||
"msg": "送礼成功",
|
||
})
|
||
}
|
||
|
||
func handleGiftStats(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
counts := loadGiftCounts()
|
||
var total int64
|
||
for _, n := range counts {
|
||
total += n
|
||
}
|
||
labels := make(map[string]string, len(giftTypeWhitelist))
|
||
for k, v := range giftTypeWhitelist {
|
||
labels[k] = v
|
||
}
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"data": gin.H{
|
||
"counts": counts,
|
||
"labels": labels,
|
||
"total": total,
|
||
"costs": loadGiftCosts(),
|
||
},
|
||
})
|
||
}
|
||
|
||
func handleGiftList(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
var list []models.SiteGift
|
||
db.Order("created_at desc, id desc").Find(&list)
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"data": gin.H{
|
||
"list": list,
|
||
"count": len(list),
|
||
},
|
||
})
|
||
}
|
||
|
||
func handleDeleteGift(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
id := strings.TrimSpace(c.Param("id"))
|
||
if id == "" {
|
||
c.JSON(400, gin.H{"error": "缺少 ID"})
|
||
return
|
||
}
|
||
res := db.Delete(&models.SiteGift{}, id)
|
||
if res.Error != nil {
|
||
c.JSON(500, gin.H{"error": "删除失败"})
|
||
return
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
c.JSON(404, gin.H{"error": "记录不存在"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "msg": "已删除"})
|
||
}
|
||
|
||
func handleGetGiftCosts(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
labels := make(map[string]string, len(giftTypeWhitelist))
|
||
for k, v := range giftTypeWhitelist {
|
||
labels[k] = v
|
||
}
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"data": gin.H{
|
||
"costs": loadGiftCosts(),
|
||
"labels": labels,
|
||
"defaults": defaultGiftCostsCopy(),
|
||
},
|
||
})
|
||
}
|
||
|
||
func handleSaveGiftCosts(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
var body struct {
|
||
Costs map[string]int64 `json:"costs"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil || body.Costs == nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
for k, v := range body.Costs {
|
||
if _, ok := giftTypeWhitelist[k]; !ok {
|
||
continue
|
||
}
|
||
if v < giftCostMin || v > giftCostMax {
|
||
c.JSON(400, gin.H{"error": fmt.Sprintf("「%s」兑换点赞需在 %d–%d 之间", giftTypeWhitelist[k], giftCostMin, giftCostMax)})
|
||
return
|
||
}
|
||
}
|
||
if err := saveGiftCosts(body.Costs); err != nil {
|
||
c.JSON(500, gin.H{"error": "保存失败"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"data": gin.H{"costs": loadGiftCosts()},
|
||
"msg": "礼物兑换配置已保存",
|
||
})
|
||
}
|
||
|
||
func normalizeDanmakuColor(color string) string {
|
||
c := strings.TrimSpace(color)
|
||
if c == "rose" || c == "sage" {
|
||
return "champagne"
|
||
}
|
||
if _, ok := danmakuColorWhitelist[c]; ok {
|
||
return c
|
||
}
|
||
return "champagne"
|
||
}
|
||
|
||
func moderationCacheContent(name, content string) string {
|
||
return strings.TrimSpace(name) + "\n" + strings.TrimSpace(content)
|
||
}
|
||
|
||
func hashModerationContent(content string) string {
|
||
sum := sha256.Sum256([]byte(content))
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func saveModerationCache(typ, content, status, reason string) {
|
||
row := models.AiModerationCache{
|
||
Content: content, ContentHash: hashModerationContent(content),
|
||
Type: typ, Status: status, Reason: reason,
|
||
}
|
||
if err := db.Create(&row).Error; err != nil {
|
||
log.Printf("写入审核缓存失败: %v", err)
|
||
}
|
||
}
|
||
|
||
func moderateOrPass(name, content, typ string) (bool, string) {
|
||
guest := strings.TrimSpace(name)
|
||
body := strings.TrimSpace(content)
|
||
if body == "" {
|
||
return true, ""
|
||
}
|
||
cacheKey := moderationCacheContent(guest, body)
|
||
cacheHash := hashModerationContent(cacheKey)
|
||
|
||
var cached models.AiModerationCache
|
||
err := db.Where("type = ? AND content_hash = ?", typ, cacheHash).
|
||
Order("id desc").First(&cached).Error
|
||
if err == nil {
|
||
if cached.Status == "approved" {
|
||
return true, ""
|
||
}
|
||
reason := strings.TrimSpace(cached.Reason)
|
||
if reason == "" {
|
||
reason = "内容未通过审核"
|
||
}
|
||
return false, reason
|
||
}
|
||
if sparkClient == nil || !sparkClient.Enabled() {
|
||
return true, ""
|
||
}
|
||
ok, reason, err := sparkClient.ModerateText(guest, body)
|
||
if err != nil {
|
||
log.Printf("AI 审核失败,放行: %v", err)
|
||
return true, ""
|
||
}
|
||
if !ok {
|
||
if strings.TrimSpace(reason) == "" {
|
||
reason = "内容未通过审核"
|
||
}
|
||
saveModerationCache(typ, cacheKey, "rejected", reason)
|
||
return false, reason
|
||
}
|
||
saveModerationCache(typ, cacheKey, "approved", "")
|
||
return true, ""
|
||
}
|
||
|
||
func defaultUploadConfig() models.UploadConfig {
|
||
return models.UploadConfig{ID: 1, Provider: "local"}
|
||
}
|
||
|
||
func loadUploadConfig() models.UploadConfig {
|
||
var config models.UploadConfig
|
||
if err := db.First(&config, 1).Error; err != nil {
|
||
config = defaultUploadConfig()
|
||
_ = db.Create(&config).Error
|
||
}
|
||
if config.Provider == "" {
|
||
config.Provider = "local"
|
||
}
|
||
return config
|
||
}
|
||
|
||
func uploadConfigResponse(config models.UploadConfig) gin.H {
|
||
return gin.H{
|
||
"provider": config.Provider, "accessKeyId": config.AccessKeyID,
|
||
"hasSecret": config.AccessKeySecret != "", "bucket": config.Bucket,
|
||
"folder": config.Folder, "domain": config.Domain,
|
||
"region": config.Region, "endpoint": config.Endpoint,
|
||
"updated_at": config.UpdatedAt,
|
||
}
|
||
}
|
||
|
||
func objectURL(config models.UploadConfig, objectKey string) string {
|
||
escapedKey := utils.EscapedObjectKey(objectKey)
|
||
if strings.TrimSpace(config.Domain) != "" {
|
||
domain := strings.TrimRight(strings.TrimSpace(config.Domain), "/")
|
||
if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") {
|
||
domain = "https://" + domain
|
||
}
|
||
return domain + "/" + escapedKey
|
||
}
|
||
endpoint := strings.TrimRight(utils.NormalizeEndpoint(config.Endpoint, config.Region), "/")
|
||
u, err := url.Parse(endpoint)
|
||
if err == nil && u.Host != "" {
|
||
return u.Scheme + "://" + config.Bucket + "." + u.Host + "/" + escapedKey
|
||
}
|
||
if endpoint == "" {
|
||
return escapedKey
|
||
}
|
||
return endpoint + "/" + escapedKey
|
||
}
|
||
|
||
func canUseOSS(config models.UploadConfig) bool {
|
||
return config.Provider == "oss" &&
|
||
strings.TrimSpace(config.AccessKeyID) != "" &&
|
||
strings.TrimSpace(config.AccessKeySecret) != "" &&
|
||
strings.TrimSpace(config.Bucket) != "" &&
|
||
(strings.TrimSpace(config.Endpoint) != "" || strings.TrimSpace(config.Region) != "")
|
||
}
|
||
|
||
func saveLocalUpload(c *gin.Context, file *multipart.FileHeader) {
|
||
filename := fmt.Sprintf("%d_%s", time.Now().Unix(), utils.CleanObjectName(file.Filename))
|
||
savePath := filepath.Join("uploads", filename)
|
||
if err := c.SaveUploadedFile(file, savePath); err != nil {
|
||
c.JSON(500, gin.H{"error": "文件保存至服务器失败"})
|
||
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"})
|
||
}
|
||
|
||
func saveOSSUpload(c *gin.Context, fileHeader *multipart.FileHeader, config models.UploadConfig) bool {
|
||
file, err := fileHeader.Open()
|
||
if err != nil {
|
||
c.JSON(500, gin.H{"error": "文件打开失败"})
|
||
return false
|
||
}
|
||
defer file.Close()
|
||
|
||
endpoint := utils.NormalizeEndpoint(config.Endpoint, config.Region)
|
||
provider := credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.AccessKeySecret)
|
||
ossConfig := oss.LoadDefaultConfig().
|
||
WithCredentialsProvider(provider).
|
||
WithRegion(config.Region).
|
||
WithEndpoint(endpoint)
|
||
client := oss.NewClient(ossConfig)
|
||
|
||
objectKey := utils.BuildObjectKey(config.Folder, fileHeader.Filename)
|
||
_, err = client.PutObject(context.Background(), &oss.PutObjectRequest{
|
||
Bucket: oss.Ptr(config.Bucket),
|
||
Key: oss.Ptr(objectKey),
|
||
Acl: oss.ObjectACLPublicRead,
|
||
Body: file,
|
||
})
|
||
if err != nil {
|
||
c.JSON(500, gin.H{"error": "OSS上传失败: " + err.Error()})
|
||
return false
|
||
}
|
||
fileURL := objectURL(config, objectKey)
|
||
recordMediaAsset(fileURL, fileHeader.Filename, "oss")
|
||
c.JSON(200, gin.H{"code": 200, "url": fileURL, "provider": "oss", "key": objectKey})
|
||
return true
|
||
}
|
||
|
||
func collectAlbumImages(configData string) []string {
|
||
var raw map[string]interface{}
|
||
if err := json.Unmarshal([]byte(configData), &raw); err != nil {
|
||
return []string{}
|
||
}
|
||
seen := map[string]bool{}
|
||
images := []string{}
|
||
add := func(value interface{}) {
|
||
src, ok := value.(string)
|
||
if !ok {
|
||
return
|
||
}
|
||
src = strings.TrimSpace(src)
|
||
if src == "" || seen[src] {
|
||
return
|
||
}
|
||
seen[src] = true
|
||
images = append(images, src)
|
||
}
|
||
// 相册灯箱优先原图;无原图时再退缩放展示图
|
||
addPrefer := func(primary interface{}, fallback interface{}) {
|
||
if src, ok := primary.(string); ok && strings.TrimSpace(src) != "" {
|
||
add(primary)
|
||
return
|
||
}
|
||
add(fallback)
|
||
}
|
||
add(raw["heroImg"])
|
||
add(raw["endImg"])
|
||
if pages, ok := raw["photoPages"].([]interface{}); ok {
|
||
for _, item := range pages {
|
||
page, ok := item.(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
addPrefer(page["img1"], page["img1Resized"])
|
||
addPrefer(page["img2"], page["img2Resized"])
|
||
pageImages, _ := page["images"].([]interface{})
|
||
pageResized, _ := page["imagesResized"].([]interface{})
|
||
n := len(pageImages)
|
||
if len(pageResized) > n {
|
||
n = len(pageResized)
|
||
}
|
||
for i := 0; i < n; i++ {
|
||
var primary, fallback interface{}
|
||
if i < len(pageImages) {
|
||
primary = pageImages[i]
|
||
}
|
||
if i < len(pageResized) {
|
||
fallback = pageResized[i]
|
||
}
|
||
addPrefer(primary, fallback)
|
||
}
|
||
}
|
||
}
|
||
return images
|
||
}
|
||
|
||
func handleCreateCashGift(c *gin.Context) {
|
||
if !requireCashGiftEntryAccess(c) {
|
||
return
|
||
}
|
||
var body struct {
|
||
Name string `json:"name"`
|
||
Amount int `json:"amount"`
|
||
Note string `json:"note"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
name := strings.TrimSpace(body.Name)
|
||
note := strings.TrimSpace(body.Note)
|
||
if name == "" {
|
||
c.JSON(400, gin.H{"error": "请填写姓名"})
|
||
return
|
||
}
|
||
if len([]rune(name)) > 50 {
|
||
c.JSON(400, gin.H{"error": "姓名过长"})
|
||
return
|
||
}
|
||
if body.Amount <= 0 {
|
||
c.JSON(400, gin.H{"error": "请填写正确金额"})
|
||
return
|
||
}
|
||
if body.Amount > 10000000 {
|
||
c.JSON(400, gin.H{"error": "金额过大"})
|
||
return
|
||
}
|
||
if len([]rune(note)) > 200 {
|
||
c.JSON(400, gin.H{"error": "备注过长"})
|
||
return
|
||
}
|
||
item := models.CashGift{
|
||
Name: name,
|
||
Amount: body.Amount,
|
||
Note: note,
|
||
}
|
||
if err := db.Create(&item).Error; err != nil {
|
||
c.JSON(500, gin.H{"error": "保存失败"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "msg": "已登记", "data": item})
|
||
}
|
||
|
||
func handleCashGiftRecent(c *gin.Context) {
|
||
if !requireCashGiftEntryAccess(c) {
|
||
return
|
||
}
|
||
limit := 3
|
||
if v := strings.TrimSpace(c.Query("limit")); v != "" {
|
||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 20 {
|
||
limit = n
|
||
}
|
||
}
|
||
var list []models.CashGift
|
||
db.Order("created_at desc, id desc").Limit(limit).Find(&list)
|
||
c.JSON(200, gin.H{"code": 200, "data": list})
|
||
}
|
||
|
||
func defaultCashGiftNotes() []string {
|
||
return []string{
|
||
"男方亲戚", "女方亲戚",
|
||
"男方朋友", "女方朋友",
|
||
"男方同学", "女方同学",
|
||
"男方同事", "女方同事",
|
||
"男方邻居", "女方邻居",
|
||
"其他",
|
||
}
|
||
}
|
||
|
||
func normalizeCashGiftNotes(raw []string) []string {
|
||
seen := map[string]bool{}
|
||
out := make([]string, 0, len(raw))
|
||
for _, s := range raw {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" || seen[s] {
|
||
continue
|
||
}
|
||
if len([]rune(s)) > 30 {
|
||
s = string([]rune(s)[:30])
|
||
}
|
||
seen[s] = true
|
||
out = append(out, s)
|
||
if len(out) >= 40 {
|
||
break
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func loadCashGiftNotes() []string {
|
||
var cfg models.CashGiftConfig
|
||
if err := db.First(&cfg, 1).Error; err != nil || strings.TrimSpace(cfg.NotesData) == "" {
|
||
return defaultCashGiftNotes()
|
||
}
|
||
var notes []string
|
||
if err := json.Unmarshal([]byte(cfg.NotesData), ¬es); err != nil {
|
||
return defaultCashGiftNotes()
|
||
}
|
||
notes = normalizeCashGiftNotes(notes)
|
||
if len(notes) == 0 {
|
||
return defaultCashGiftNotes()
|
||
}
|
||
return notes
|
||
}
|
||
|
||
func saveCashGiftNotes(notes []string) error {
|
||
notes = normalizeCashGiftNotes(notes)
|
||
if len(notes) == 0 {
|
||
notes = defaultCashGiftNotes()
|
||
}
|
||
raw, err := json.Marshal(notes)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var cfg models.CashGiftConfig
|
||
if err := db.First(&cfg, 1).Error; err != nil {
|
||
cfg = models.CashGiftConfig{ID: 1, NotesData: string(raw)}
|
||
return db.Create(&cfg).Error
|
||
}
|
||
cfg.NotesData = string(raw)
|
||
return db.Save(&cfg).Error
|
||
}
|
||
|
||
func loadCashGiftViewPass() string {
|
||
var cfg models.CashGiftConfig
|
||
if err := db.First(&cfg, 1).Error; err != nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(cfg.ViewPass)
|
||
}
|
||
|
||
func loadCashGiftEntryPass() string {
|
||
var cfg models.CashGiftConfig
|
||
if err := db.First(&cfg, 1).Error; err != nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(cfg.EntryPass)
|
||
}
|
||
|
||
func saveCashGiftViewPass(pass string) error {
|
||
pass = strings.TrimSpace(pass)
|
||
if len([]rune(pass)) > 64 {
|
||
pass = string([]rune(pass)[:64])
|
||
}
|
||
var cfg models.CashGiftConfig
|
||
if err := db.First(&cfg, 1).Error; err != nil {
|
||
notes, _ := json.Marshal(defaultCashGiftNotes())
|
||
cfg = models.CashGiftConfig{ID: 1, NotesData: string(notes), ViewPass: pass}
|
||
return db.Create(&cfg).Error
|
||
}
|
||
cfg.ViewPass = pass
|
||
return db.Save(&cfg).Error
|
||
}
|
||
|
||
func saveCashGiftEntryPass(pass string) error {
|
||
pass = strings.TrimSpace(pass)
|
||
if len([]rune(pass)) > 64 {
|
||
pass = string([]rune(pass)[:64])
|
||
}
|
||
var cfg models.CashGiftConfig
|
||
if err := db.First(&cfg, 1).Error; err != nil {
|
||
notes, _ := json.Marshal(defaultCashGiftNotes())
|
||
cfg = models.CashGiftConfig{ID: 1, NotesData: string(notes), EntryPass: pass}
|
||
return db.Create(&cfg).Error
|
||
}
|
||
cfg.EntryPass = pass
|
||
return db.Save(&cfg).Error
|
||
}
|
||
|
||
func cashGiftViewTokenFromRequest(c *gin.Context) string {
|
||
if t := strings.TrimSpace(c.GetHeader("X-Cash-Gift-Token")); t != "" {
|
||
return t
|
||
}
|
||
return utils.BearerToken(c)
|
||
}
|
||
|
||
func cashGiftEntryTokenFromRequest(c *gin.Context) string {
|
||
if t := strings.TrimSpace(c.GetHeader("X-Cash-Gift-Entry-Token")); t != "" {
|
||
return t
|
||
}
|
||
return utils.BearerToken(c)
|
||
}
|
||
|
||
func requireCashGiftViewAccess(c *gin.Context) bool {
|
||
token := cashGiftViewTokenFromRequest(c)
|
||
if utils.VerifyCashGiftViewToken(token) {
|
||
return true
|
||
}
|
||
if utils.VerifyToken(token) {
|
||
return true
|
||
}
|
||
c.JSON(403, gin.H{"error": "请先输入查看口令"})
|
||
return false
|
||
}
|
||
|
||
func requireCashGiftEntryAccess(c *gin.Context) bool {
|
||
token := cashGiftEntryTokenFromRequest(c)
|
||
if utils.VerifyCashGiftEntryToken(token) {
|
||
return true
|
||
}
|
||
if utils.VerifyToken(token) {
|
||
return true
|
||
}
|
||
c.JSON(403, gin.H{"error": "请先输入记账口令"})
|
||
return false
|
||
}
|
||
|
||
func handleGetCashGiftViewPass(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
pass := loadCashGiftViewPass()
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"data": gin.H{
|
||
"has_pass": pass != "",
|
||
"password": pass,
|
||
},
|
||
})
|
||
}
|
||
|
||
func handleSaveCashGiftViewPass(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
var body struct {
|
||
Password string `json:"password"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
pass := strings.TrimSpace(body.Password)
|
||
if pass == "" {
|
||
c.JSON(400, gin.H{"error": "请设置查看口令"})
|
||
return
|
||
}
|
||
if err := saveCashGiftViewPass(pass); err != nil {
|
||
c.JSON(500, gin.H{"error": "保存失败"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "msg": "口令已保存", "data": gin.H{"has_pass": true}})
|
||
}
|
||
|
||
func handleCashGiftViewUnlock(c *gin.Context) {
|
||
var body struct {
|
||
Password string `json:"password"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
pass := loadCashGiftViewPass()
|
||
if pass == "" {
|
||
c.JSON(403, gin.H{"error": "尚未配置查看口令,请先在后台设置"})
|
||
return
|
||
}
|
||
if strings.TrimSpace(body.Password) != pass {
|
||
c.JSON(403, gin.H{"error": "口令错误"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"msg": "已解锁",
|
||
"data": gin.H{"token": utils.SignCashGiftViewToken()},
|
||
})
|
||
}
|
||
|
||
func handleGetCashGiftEntryPass(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
pass := loadCashGiftEntryPass()
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"data": gin.H{
|
||
"has_pass": pass != "",
|
||
"password": pass,
|
||
},
|
||
})
|
||
}
|
||
|
||
func handleSaveCashGiftEntryPass(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
var body struct {
|
||
Password string `json:"password"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
pass := strings.TrimSpace(body.Password)
|
||
if pass == "" {
|
||
c.JSON(400, gin.H{"error": "请设置记账口令"})
|
||
return
|
||
}
|
||
if err := saveCashGiftEntryPass(pass); err != nil {
|
||
c.JSON(500, gin.H{"error": "保存失败"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "msg": "口令已保存", "data": gin.H{"has_pass": true}})
|
||
}
|
||
|
||
func handleCashGiftEntryUnlock(c *gin.Context) {
|
||
var body struct {
|
||
Password string `json:"password"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
pass := loadCashGiftEntryPass()
|
||
if pass == "" {
|
||
c.JSON(403, gin.H{"error": "尚未配置记账口令,请先在后台设置"})
|
||
return
|
||
}
|
||
if strings.TrimSpace(body.Password) != pass {
|
||
c.JSON(403, gin.H{"error": "口令错误"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"msg": "已解锁",
|
||
"data": gin.H{"token": utils.SignCashGiftEntryToken()},
|
||
})
|
||
}
|
||
|
||
func handleCashGiftViewList(c *gin.Context) {
|
||
if !requireCashGiftViewAccess(c) {
|
||
return
|
||
}
|
||
q := strings.TrimSpace(c.Query("q"))
|
||
note := strings.TrimSpace(c.Query("note"))
|
||
side := strings.TrimSpace(c.Query("side")) // male | female | other | ""
|
||
sortKey := strings.TrimSpace(c.Query("sort"))
|
||
minAmount := 0
|
||
maxAmount := 0
|
||
if v := strings.TrimSpace(c.Query("min")); v != "" {
|
||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||
minAmount = n
|
||
}
|
||
}
|
||
if v := strings.TrimSpace(c.Query("max")); v != "" {
|
||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||
maxAmount = n
|
||
}
|
||
}
|
||
|
||
tx := db.Model(&models.CashGift{})
|
||
if q != "" {
|
||
like := "%" + q + "%"
|
||
tx = tx.Where("name LIKE ? OR note LIKE ?", like, like)
|
||
}
|
||
if note != "" {
|
||
tx = tx.Where("note = ?", note)
|
||
}
|
||
switch side {
|
||
case "male":
|
||
tx = tx.Where("note LIKE ?", "男方%")
|
||
case "female":
|
||
tx = tx.Where("note LIKE ?", "女方%")
|
||
case "other":
|
||
tx = tx.Where("note NOT LIKE ? AND note NOT LIKE ? AND note <> ''", "男方%", "女方%")
|
||
}
|
||
if minAmount > 0 {
|
||
tx = tx.Where("amount >= ?", minAmount)
|
||
}
|
||
if maxAmount > 0 {
|
||
tx = tx.Where("amount <= ?", maxAmount)
|
||
}
|
||
|
||
order := "created_at desc, id desc"
|
||
switch sortKey {
|
||
case "time_asc":
|
||
order = "created_at asc, id asc"
|
||
case "amount_desc":
|
||
order = "amount desc, created_at desc"
|
||
case "amount_asc":
|
||
order = "amount asc, created_at desc"
|
||
case "name_asc":
|
||
order = "name asc, created_at desc"
|
||
}
|
||
|
||
var list []models.CashGift
|
||
tx.Order(order).Find(&list)
|
||
total := 0
|
||
for _, it := range list {
|
||
total += it.Amount
|
||
}
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"data": gin.H{
|
||
"list": list,
|
||
"total": total,
|
||
"count": len(list),
|
||
"notes": loadCashGiftNotes(),
|
||
},
|
||
})
|
||
}
|
||
|
||
func handleGetCashGiftNotes(c *gin.Context) {
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"data": gin.H{
|
||
"notes": loadCashGiftNotes(),
|
||
"defaults": defaultCashGiftNotes(),
|
||
},
|
||
})
|
||
}
|
||
|
||
func handleSaveCashGiftNotes(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
var body struct {
|
||
Notes []string `json:"notes"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
notes := normalizeCashGiftNotes(body.Notes)
|
||
if len(notes) == 0 {
|
||
c.JSON(400, gin.H{"error": "至少保留一个快捷备注"})
|
||
return
|
||
}
|
||
if err := saveCashGiftNotes(notes); err != nil {
|
||
c.JSON(500, gin.H{"error": "保存失败"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "msg": "已保存", "data": gin.H{"notes": notes}})
|
||
}
|
||
|
||
func handleCashGiftList(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
var list []models.CashGift
|
||
db.Order("created_at desc, id desc").Find(&list)
|
||
total := 0
|
||
for _, it := range list {
|
||
total += it.Amount
|
||
}
|
||
c.JSON(200, gin.H{
|
||
"code": 200,
|
||
"data": gin.H{
|
||
"list": list,
|
||
"total": total,
|
||
"count": len(list),
|
||
},
|
||
})
|
||
}
|
||
|
||
func handleUpdateCashGift(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
id, err := strconv.ParseUint(strings.TrimSpace(c.Param("id")), 10, 64)
|
||
if err != nil || id == 0 {
|
||
c.JSON(400, gin.H{"error": "无效 ID"})
|
||
return
|
||
}
|
||
var body struct {
|
||
Name string `json:"name"`
|
||
Amount int `json:"amount"`
|
||
Note string `json:"note"`
|
||
}
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(400, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
name := strings.TrimSpace(body.Name)
|
||
note := strings.TrimSpace(body.Note)
|
||
if name == "" {
|
||
c.JSON(400, gin.H{"error": "请填写姓名"})
|
||
return
|
||
}
|
||
if body.Amount <= 0 {
|
||
c.JSON(400, gin.H{"error": "请填写正确金额"})
|
||
return
|
||
}
|
||
var item models.CashGift
|
||
if err := db.First(&item, id).Error; err != nil {
|
||
c.JSON(404, gin.H{"error": "记录不存在"})
|
||
return
|
||
}
|
||
item.Name = name
|
||
item.Amount = body.Amount
|
||
item.Note = note
|
||
if err := db.Save(&item).Error; err != nil {
|
||
c.JSON(500, gin.H{"error": "保存失败"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "msg": "已更新", "data": item})
|
||
}
|
||
|
||
func handleDeleteCashGift(c *gin.Context) {
|
||
if !utils.RequireAdmin(c) {
|
||
return
|
||
}
|
||
id := strings.TrimSpace(c.Param("id"))
|
||
if id == "" {
|
||
c.JSON(400, gin.H{"error": "缺少 ID"})
|
||
return
|
||
}
|
||
res := db.Delete(&models.CashGift{}, id)
|
||
if res.Error != nil {
|
||
c.JSON(500, gin.H{"error": "删除失败"})
|
||
return
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
c.JSON(404, gin.H{"error": "记录不存在"})
|
||
return
|
||
}
|
||
c.JSON(200, gin.H{"code": 200, "msg": "已删除"})
|
||
}
|