490 lines
14 KiB
Go
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")
|
|
}
|