Files
hunli/hunliji-api/main.go
2026-08-01 13:57:14 +08:00

490 lines
14 KiB
Go

package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"mime/multipart"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"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/driver/mysql"
"gorm.io/gorm"
)
type TemplateConfig struct {
ID uint `gorm:"primaryKey" json:"id"`
ConfigData string `gorm:"type:json;column:config_data" json:"config_data"`
UpdatedAt time.Time `json:"updated_at"`
}
type UploadConfig struct {
ID uint `gorm:"primaryKey" json:"id"`
Provider string `gorm:"column:provider;size:20" json:"provider"`
AccessKeyID string `gorm:"column:access_key_id;size:255" json:"accessKeyId"`
AccessKeySecret string `gorm:"column:access_key_secret;type:text" json:"-"`
Bucket string `gorm:"column:bucket;size:255" json:"bucket"`
Folder string `gorm:"column:folder;size:255" json:"folder"`
Domain string `gorm:"column:domain;size:255" json:"domain"`
Region string `gorm:"column:region;size:100" json:"region"`
Endpoint string `gorm:"column:endpoint;size:255" json:"endpoint"`
UpdatedAt time.Time `json:"updated_at"`
}
type Rsvp struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"column:name;size:50" json:"name"`
GuestCount string `gorm:"column:guest_count;size:20" json:"guest_count"`
Wishes string `gorm:"column:wishes;type:text" json:"wishes"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
}
var db *gorm.DB
var (
adminAccount = getEnv("ADMIN_ACCOUNT", "admin")
adminPassword = getEnv("ADMIN_PASSWORD", "admin123")
adminSecret = getEnv("ADMIN_SECRET", "wedding-admin-secret-2026")
)
func getEnv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func signToken(account string) string {
payload := fmt.Sprintf(`{"account":%q,"exp":%d}`, account, time.Now().Add(24*time.Hour).Unix())
b64 := base64.StdEncoding.EncodeToString([]byte(payload))
mac := hmac.New(sha256.New, []byte(adminSecret))
mac.Write([]byte(b64))
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return b64 + "." + sig
}
func verifyToken(token string) bool {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return false
}
mac := hmac.New(sha256.New, []byte(adminSecret))
mac.Write([]byte(parts[0]))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(parts[1])) {
return false
}
raw, err := base64.StdEncoding.DecodeString(parts[0])
if err != nil {
return false
}
var claims struct {
Account string `json:"account"`
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(raw, &claims); err != nil {
return false
}
return time.Now().Unix() <= claims.Exp
}
func bearerToken(c *gin.Context) string {
return strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
}
func requireAdmin(c *gin.Context) bool {
if !verifyToken(bearerToken(c)) {
c.JSON(401, gin.H{"error": "未授权,请先登录后台"})
return false
}
return true
}
func initDB() {
dsn := "root:root@tcp(127.0.0.1:3306)/nl_wedding?charset=utf8mb4&parseTime=True&loc=Local"
//dsn := "root:mysql_PKC65h@tcp(127.0.0.1:3306)/nl_wedding?charset=utf8mb4&parseTime=True&loc=Local"
var err error
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatalf("数据库连接失败: %v", err)
}
_ = db.AutoMigrate(&TemplateConfig{}, &UploadConfig{}, &Rsvp{})
_ = os.MkdirAll("./uploads", os.ModePerm)
fmt.Println("数据库初始化成功,表结构已同步,上传目录已就绪")
}
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
func defaultUploadConfig() UploadConfig {
return UploadConfig{ID: 1, Provider: "local"}
}
func loadUploadConfig() UploadConfig {
var config 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 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 cleanObjectName(name string) string {
name = filepath.Base(name)
name = strings.ReplaceAll(name, "\\", "_")
name = strings.ReplaceAll(name, "/", "_")
name = strings.TrimSpace(name)
if name == "" || name == "." {
return "upload"
}
return name
}
func buildObjectKey(folder string, originalName string) string {
folder = strings.Trim(folder, "/ ")
name := cleanObjectName(originalName)
fileName := fmt.Sprintf("%s_%s", time.Now().Format("150405000000000"), name)
day := time.Now().Format("20060102")
if folder == "" {
return day + "/" + fileName
}
return folder + "/" + day + "/" + fileName
}
func escapedObjectKey(key string) string {
parts := strings.Split(key, "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
}
func normalizeEndpoint(endpoint string, region string) string {
endpoint = strings.TrimSpace(endpoint)
region = strings.TrimSpace(region)
if endpoint == "" && region != "" {
endpoint = "https://oss-" + region + ".aliyuncs.com"
}
if endpoint != "" && !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
endpoint = "https://" + endpoint
}
return endpoint
}
func objectURL(config UploadConfig, objectKey string) string {
escapedKey := 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(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 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(), 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 UploadConfig) bool {
file, err := fileHeader.Open()
if err != nil {
c.JSON(500, gin.H{"error": "文件打开失败"})
return false
}
defer file.Close()
endpoint := 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 := 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
}
func main() {
initDB()
r := gin.Default()
r.Use(corsMiddleware())
r.Static("/uploads", "./uploads")
api := r.Group("/api")
{
api.POST("/admin/login", func(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": signToken(body.Account),
"user": gin.H{"account": body.Account},
})
})
api.GET("/config", func(c *gin.Context) {
var config TemplateConfig
if err := db.First(&config, 1).Error; err != nil {
c.JSON(200, gin.H{"code": 200, "data": "{}"})
return
}
c.JSON(200, gin.H{"code": 200, "data": config.ConfigData})
})
api.GET("/album", func(c *gin.Context) {
var config 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)})
})
api.POST("/config", func(c *gin.Context) {
if !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 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": "配置保存成功"})
})
api.GET("/upload/config", func(c *gin.Context) {
if !requireAdmin(c) {
return
}
c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(loadUploadConfig())})
})
api.POST("/upload/config", func(c *gin.Context) {
if !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": "上传配置保存成功"})
})
api.POST("/rsvp", func(c *gin.Context) {
var rsvp Rsvp
if err := c.ShouldBindJSON(&rsvp); err != nil {
c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()})
return
}
if err := db.Create(&rsvp).Error; err != nil {
c.JSON(500, gin.H{"error": "出席信息写入数据库失败"})
return
}
c.JSON(200, gin.H{"code": 200, "msg": "回执提交成功"})
})
api.POST("/upload", func(c *gin.Context) {
if !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)
})
api.GET("/rsvp/list", func(c *gin.Context) {
if !requireAdmin(c) {
return
}
var list []Rsvp
db.Order("created_at desc").Find(&list)
c.JSON(200, gin.H{"code": 200, "data": list})
})
}
fmt.Println("婚礼纪后端 API 已在 http://localhost:15201 启动")
r.Run(":15201")
}