Files
hunli/hunliji-api/router/routers.go
2026-08-02 19:04:04 +08:00

604 lines
16 KiB
Go

package router
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"mime/multipart"
"net/url"
"path/filepath"
"strings"
"time"
"hunliji-api/configsection"
"hunliji-api/models"
"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": {},
}
// 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.POST("/ai/blessing", handleAIBlessing)
api.POST("/rsvp", handleCreateRsvp)
api.GET("/rsvp/list", handleRsvpList)
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)
visit.Register(api, db, utils.RequireAdmin)
configsection.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.FirstOrCreate(&config, 1)
config.ConfigData = string(jsonBytes)
if err := db.Save(&config).Error; err != nil {
c.JSON(500, gin.H{"error": "数据库保存配置失败"})
return
}
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 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 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"`
}
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
}
if err := db.Create(&models.SiteLike{ClientID: clientID}).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}, "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)
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
}
c.JSON(200, gin.H{"code": 200, "url": objectURL(config, objectKey), "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)
}
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
}
add(page["img1"])
add(page["img2"])
if pageImages, ok := page["images"].([]interface{}); ok {
for _, src := range pageImages {
add(src)
}
}
}
}
return images
}