feat: major update - MySQL, chat, wishlist, PWA, admin overhaul
This commit is contained in:
@@ -1,160 +1,24 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
|
||||
"mengyastore-backend/internal/config"
|
||||
"mengyastore-backend/internal/models"
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
|
||||
// AdminHandler holds dependencies for all admin-related routes.
|
||||
type AdminHandler struct {
|
||||
store *storage.JSONStore
|
||||
cfg *config.Config
|
||||
store *storage.JSONStore
|
||||
cfg *config.Config
|
||||
siteStore *storage.SiteStore
|
||||
orderStore *storage.OrderStore
|
||||
chatStore *storage.ChatStore
|
||||
}
|
||||
|
||||
type productPayload struct {
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
DiscountPrice float64 `json:"discountPrice"`
|
||||
Tags string `json:"tags"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Codes []string `json:"codes"`
|
||||
ScreenshotURLs []string `json:"screenshotUrls"`
|
||||
Description string `json:"description"`
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
|
||||
type togglePayload struct {
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
func NewAdminHandler(store *storage.JSONStore, cfg *config.Config) *AdminHandler {
|
||||
return &AdminHandler{store: store, cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetAdminToken(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"token": h.cfg.AdminToken})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListAllProducts(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
items, err := h.store.ListAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
var payload productPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
|
||||
if !valid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "screenshot urls must be 5 or fewer"})
|
||||
return
|
||||
}
|
||||
active := true
|
||||
if payload.Active != nil {
|
||||
active = *payload.Active
|
||||
}
|
||||
product := models.Product{
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
}
|
||||
created, err := h.store.Create(product)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": created})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
var payload productPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
|
||||
if !valid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "screenshot urls must be 5 or fewer"})
|
||||
return
|
||||
}
|
||||
active := false
|
||||
if payload.Active != nil {
|
||||
active = *payload.Active
|
||||
}
|
||||
patch := models.Product{
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
}
|
||||
updated, err := h.store.Update(id, patch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ToggleProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
var payload togglePayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
updated, err := h.store.Toggle(id, payload.Active)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
if err := h.store.Delete(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
func NewAdminHandler(store *storage.JSONStore, cfg *config.Config, siteStore *storage.SiteStore, orderStore *storage.OrderStore, chatStore *storage.ChatStore) *AdminHandler {
|
||||
return &AdminHandler{store: store, cfg: cfg, siteStore: siteStore, orderStore: orderStore, chatStore: chatStore}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) requireAdmin(c *gin.Context) bool {
|
||||
@@ -168,43 +32,3 @@ func (h *AdminHandler) requireAdmin(c *gin.Context) bool {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeScreenshotURLs(urls []string) ([]string, bool) {
|
||||
cleaned := make([]string, 0, len(urls))
|
||||
for _, url := range urls {
|
||||
trimmed := strings.TrimSpace(url)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, trimmed)
|
||||
if len(cleaned) > 5 {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return cleaned, true
|
||||
}
|
||||
|
||||
func normalizeTags(tagsCSV string) []string {
|
||||
if tagsCSV == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(tagsCSV, ",")
|
||||
clean := make([]string, 0, len(parts))
|
||||
seen := map[string]bool{}
|
||||
for _, p := range parts {
|
||||
t := strings.TrimSpace(p)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(t)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
clean = append(clean, t)
|
||||
if len(clean) >= 20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
88
mengyastore-backend/internal/handlers/admin_chat.go
Normal file
88
mengyastore-backend/internal/handlers/admin_chat.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetAllConversations returns all conversations (map of accountID -> messages).
|
||||
func (h *AdminHandler) GetAllConversations(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
convs, err := h.chatStore.ListConversations()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"conversations": convs}})
|
||||
}
|
||||
|
||||
// GetConversation returns all messages for a specific account.
|
||||
func (h *AdminHandler) GetConversation(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
accountID := c.Param("account")
|
||||
if accountID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing account"})
|
||||
return
|
||||
}
|
||||
msgs, err := h.chatStore.GetMessages(accountID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"messages": msgs}})
|
||||
}
|
||||
|
||||
type adminChatPayload struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// AdminReply sends a reply from admin to a specific user.
|
||||
func (h *AdminHandler) AdminReply(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
accountID := c.Param("account")
|
||||
if accountID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing account"})
|
||||
return
|
||||
}
|
||||
var payload adminChatPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
content := strings.TrimSpace(payload.Content)
|
||||
if content == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "消息不能为空"})
|
||||
return
|
||||
}
|
||||
msg, err := h.chatStore.SendAdminMessage(accountID, content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"message": msg}})
|
||||
}
|
||||
|
||||
// ClearConversation deletes all messages with a specific user.
|
||||
func (h *AdminHandler) ClearConversation(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
accountID := c.Param("account")
|
||||
if accountID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing account"})
|
||||
return
|
||||
}
|
||||
if err := h.chatStore.ClearConversation(accountID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||||
}
|
||||
35
mengyastore-backend/internal/handlers/admin_orders.go
Normal file
35
mengyastore-backend/internal/handlers/admin_orders.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *AdminHandler) ListAllOrders(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
orders, err := h.orderStore.ListAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": orders})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteOrder(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
orderID := c.Param("id")
|
||||
if orderID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing order id"})
|
||||
return
|
||||
}
|
||||
if err := h.orderStore.Delete(orderID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||||
}
|
||||
202
mengyastore-backend/internal/handlers/admin_product.go
Normal file
202
mengyastore-backend/internal/handlers/admin_product.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyastore-backend/internal/models"
|
||||
)
|
||||
|
||||
type productPayload struct {
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
DiscountPrice float64 `json:"discountPrice"`
|
||||
Tags string `json:"tags"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Codes []string `json:"codes"`
|
||||
ScreenshotURLs []string `json:"screenshotUrls"`
|
||||
Description string `json:"description"`
|
||||
Active *bool `json:"active"`
|
||||
RequireLogin bool `json:"requireLogin"`
|
||||
MaxPerAccount int `json:"maxPerAccount"`
|
||||
DeliveryMode string `json:"deliveryMode"`
|
||||
ShowNote bool `json:"showNote"`
|
||||
ShowContact bool `json:"showContact"`
|
||||
}
|
||||
|
||||
type togglePayload struct {
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetAdminToken(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"token": h.cfg.AdminToken})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListAllProducts(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
items, err := h.store.ListAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
var payload productPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
|
||||
if !valid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "screenshot urls must be 5 or fewer"})
|
||||
return
|
||||
}
|
||||
active := true
|
||||
if payload.Active != nil {
|
||||
active = *payload.Active
|
||||
}
|
||||
product := models.Product{
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: payload.DeliveryMode,
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
created, err := h.store.Create(product)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": created})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
var payload productPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
|
||||
if !valid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "screenshot urls must be 5 or fewer"})
|
||||
return
|
||||
}
|
||||
active := false
|
||||
if payload.Active != nil {
|
||||
active = *payload.Active
|
||||
}
|
||||
patch := models.Product{
|
||||
Name: payload.Name,
|
||||
Price: payload.Price,
|
||||
DiscountPrice: payload.DiscountPrice,
|
||||
Tags: normalizeTags(payload.Tags),
|
||||
CoverURL: strings.TrimSpace(payload.CoverURL),
|
||||
Codes: payload.Codes,
|
||||
ScreenshotURLs: screenshotURLs,
|
||||
Description: payload.Description,
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: payload.DeliveryMode,
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
updated, err := h.store.Update(id, patch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ToggleProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
var payload togglePayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
updated, err := h.store.Toggle(id, payload.Active)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteProduct(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
if err := h.store.Delete(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
func normalizeScreenshotURLs(urls []string) ([]string, bool) {
|
||||
cleaned := make([]string, 0, len(urls))
|
||||
for _, url := range urls {
|
||||
trimmed := strings.TrimSpace(url)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
cleaned = append(cleaned, trimmed)
|
||||
if len(cleaned) > 5 {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return cleaned, true
|
||||
}
|
||||
|
||||
func normalizeTags(tagsCSV string) []string {
|
||||
if tagsCSV == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(tagsCSV, ",")
|
||||
clean := make([]string, 0, len(parts))
|
||||
seen := map[string]bool{}
|
||||
for _, p := range parts {
|
||||
t := strings.TrimSpace(p)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(t)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
clean = append(clean, t)
|
||||
if len(clean) >= 20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return clean
|
||||
}
|
||||
73
mengyastore-backend/internal/handlers/admin_site.go
Normal file
73
mengyastore-backend/internal/handlers/admin_site.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
|
||||
type maintenancePayload struct {
|
||||
Maintenance bool `json:"maintenance"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (h *AdminHandler) SetMaintenance(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
var payload maintenancePayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
if err := h.siteStore.SetMaintenance(payload.Maintenance, payload.Reason); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"maintenance": payload.Maintenance,
|
||||
"reason": payload.Reason,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetSMTPConfig(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
cfg, err := h.siteStore.GetSMTPConfig()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Mask password in response
|
||||
masked := cfg
|
||||
if masked.Password != "" {
|
||||
masked.Password = "••••••••"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": masked})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) SetSMTPConfig(c *gin.Context) {
|
||||
if !h.requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
var payload storage.SMTPConfig
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
// If password is the masked sentinel, preserve the existing one
|
||||
if payload.Password == "••••••••" {
|
||||
existing, _ := h.siteStore.GetSMTPConfig()
|
||||
payload.Password = existing.Password
|
||||
}
|
||||
if err := h.siteStore.SetSMTPConfig(payload); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": "ok"})
|
||||
}
|
||||
82
mengyastore-backend/internal/handlers/chat.go
Normal file
82
mengyastore-backend/internal/handlers/chat.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyastore-backend/internal/auth"
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
|
||||
type ChatHandler struct {
|
||||
chatStore *storage.ChatStore
|
||||
authClient *auth.SproutGateClient
|
||||
}
|
||||
|
||||
func NewChatHandler(chatStore *storage.ChatStore, authClient *auth.SproutGateClient) *ChatHandler {
|
||||
return &ChatHandler{chatStore: chatStore, authClient: authClient}
|
||||
}
|
||||
|
||||
func (h *ChatHandler) requireChatUser(c *gin.Context) (account, name string, ok bool) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return "", "", false
|
||||
}
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
result, err := h.authClient.VerifyToken(token)
|
||||
if err != nil || !result.Valid || result.User == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||
return "", "", false
|
||||
}
|
||||
return result.User.Account, result.User.Username, true
|
||||
}
|
||||
|
||||
// GetMyMessages returns all chat messages for the currently logged-in user.
|
||||
func (h *ChatHandler) GetMyMessages(c *gin.Context) {
|
||||
account, _, ok := h.requireChatUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
msgs, err := h.chatStore.GetMessages(account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"messages": msgs}})
|
||||
}
|
||||
|
||||
type chatMessagePayload struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// SendMyMessage sends a message from the current user to admin.
|
||||
func (h *ChatHandler) SendMyMessage(c *gin.Context) {
|
||||
account, name, ok := h.requireChatUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var payload chatMessagePayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
content := strings.TrimSpace(payload.Content)
|
||||
if content == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "消息不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
msg, rateLimited, err := h.chatStore.SendUserMessage(account, name, content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if rateLimited {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "发送太频繁,请稍候"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"message": msg}})
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyastore-backend/internal/auth"
|
||||
"mengyastore-backend/internal/email"
|
||||
"mengyastore-backend/internal/models"
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
@@ -19,47 +20,70 @@ const qrSize = "320x320"
|
||||
type OrderHandler struct {
|
||||
productStore *storage.JSONStore
|
||||
orderStore *storage.OrderStore
|
||||
siteStore *storage.SiteStore
|
||||
authClient *auth.SproutGateClient
|
||||
}
|
||||
|
||||
type checkoutPayload struct {
|
||||
ProductID string `json:"productId"`
|
||||
Quantity int `json:"quantity"`
|
||||
ProductID string `json:"productId"`
|
||||
Quantity int `json:"quantity"`
|
||||
Note string `json:"note"`
|
||||
ContactPhone string `json:"contactPhone"`
|
||||
ContactEmail string `json:"contactEmail"`
|
||||
NotifyEmail string `json:"notifyEmail"`
|
||||
}
|
||||
|
||||
func NewOrderHandler(productStore *storage.JSONStore, orderStore *storage.OrderStore, authClient *auth.SproutGateClient) *OrderHandler {
|
||||
return &OrderHandler{productStore: productStore, orderStore: orderStore, authClient: authClient}
|
||||
func NewOrderHandler(productStore *storage.JSONStore, orderStore *storage.OrderStore, siteStore *storage.SiteStore, authClient *auth.SproutGateClient) *OrderHandler {
|
||||
return &OrderHandler{productStore: productStore, orderStore: orderStore, siteStore: siteStore, authClient: authClient}
|
||||
}
|
||||
|
||||
func (h *OrderHandler) tryExtractUser(c *gin.Context) (string, string) {
|
||||
func (h *OrderHandler) sendOrderNotify(toEmail, toName, productName, orderID string, qty int, codes []string, isManual bool) {
|
||||
if toEmail == "" {
|
||||
return
|
||||
}
|
||||
cfg, err := h.siteStore.GetSMTPConfig()
|
||||
if err != nil || !cfg.IsConfiguredEmail() {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
emailCfg := email.Config{
|
||||
SMTPHost: cfg.Host,
|
||||
SMTPPort: cfg.Port,
|
||||
From: cfg.Email,
|
||||
Password: cfg.Password,
|
||||
FromName: cfg.FromName,
|
||||
}
|
||||
if err := email.SendOrderNotify(emailCfg, email.OrderNotifyData{
|
||||
ToEmail: toEmail,
|
||||
ToName: toName,
|
||||
ProductName: productName,
|
||||
OrderID: orderID,
|
||||
Quantity: qty,
|
||||
Codes: codes,
|
||||
IsManual: isManual,
|
||||
}); err != nil {
|
||||
log.Printf("[Email] 发送通知失败 order=%s to=%s: %v", orderID, toEmail, err)
|
||||
} else {
|
||||
log.Printf("[Email] 发送通知成功 order=%s to=%s", orderID, toEmail)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (h *OrderHandler) tryExtractUserWithEmail(c *gin.Context) (account, username, userEmail string) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
log.Println("[Order] 无 Authorization header,匿名下单")
|
||||
return "", ""
|
||||
return "", "", ""
|
||||
}
|
||||
userToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
log.Printf("[Order] 检测到用户 token,正在验证 (长度=%d)", len(userToken))
|
||||
|
||||
result, err := h.authClient.VerifyToken(userToken)
|
||||
if err != nil {
|
||||
log.Printf("[Order] 验证 token 失败: %v", err)
|
||||
return "", ""
|
||||
if err != nil || !result.Valid || result.User == nil {
|
||||
return "", "", ""
|
||||
}
|
||||
if !result.Valid {
|
||||
log.Println("[Order] token 验证返回 valid=false")
|
||||
return "", ""
|
||||
}
|
||||
if result.User == nil {
|
||||
log.Println("[Order] token 验证成功但 user 为空")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
log.Printf("[Order] 用户身份验证成功: account=%s username=%s", result.User.Account, result.User.Username)
|
||||
return result.User.Account, result.User.Username
|
||||
return result.User.Account, result.User.Username, result.User.Email
|
||||
}
|
||||
|
||||
func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
userAccount, userName := h.tryExtractUser(c)
|
||||
userAccount, userName, userEmail := h.tryExtractUserWithEmail(c)
|
||||
|
||||
var payload checkoutPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
@@ -85,21 +109,72 @@ func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "product is not available"})
|
||||
return
|
||||
}
|
||||
|
||||
if product.RequireLogin && userAccount == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "该商品需要登录后才能购买"})
|
||||
return
|
||||
}
|
||||
|
||||
if product.MaxPerAccount > 0 && userAccount != "" {
|
||||
purchased, err := h.orderStore.CountPurchasedByAccount(userAccount, product.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if purchased+payload.Quantity > product.MaxPerAccount {
|
||||
remain := product.MaxPerAccount - purchased
|
||||
if remain <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("每个账户最多购买 %d 个,您已达上限", product.MaxPerAccount)})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("每个账户最多购买 %d 个,您还可购买 %d 个", product.MaxPerAccount, remain)})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if product.Quantity < payload.Quantity {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "库存不足"})
|
||||
return
|
||||
}
|
||||
|
||||
deliveredCodes, ok := extractCodes(&product, payload.Quantity)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "卡密不足"})
|
||||
return
|
||||
isManual := product.DeliveryMode == "manual"
|
||||
|
||||
var deliveredCodes []string
|
||||
var updatedProduct models.Product
|
||||
|
||||
if isManual {
|
||||
updatedProduct = product
|
||||
} else {
|
||||
var ok bool
|
||||
deliveredCodes, ok = extractCodes(&product, payload.Quantity)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "卡密不足"})
|
||||
return
|
||||
}
|
||||
product.Quantity = len(product.Codes)
|
||||
updatedProduct, err = h.productStore.Update(product.ID, product)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
product.Quantity = len(product.Codes)
|
||||
updatedProduct, err := h.productStore.Update(product.ID, product)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
deliveryMode := product.DeliveryMode
|
||||
if deliveryMode == "" {
|
||||
deliveryMode = "auto"
|
||||
}
|
||||
|
||||
// Notification email priority:
|
||||
// 1. SproutGate account email (logged-in user, most reliable)
|
||||
// 2. notifyEmail passed by frontend (also comes from authState.email)
|
||||
// 3. contactEmail explicitly filled by user in checkout form
|
||||
// 4. empty → skip sending
|
||||
notifyEmail := strings.TrimSpace(userEmail)
|
||||
if notifyEmail == "" {
|
||||
notifyEmail = strings.TrimSpace(payload.NotifyEmail)
|
||||
}
|
||||
if notifyEmail == "" {
|
||||
notifyEmail = strings.TrimSpace(payload.ContactEmail)
|
||||
}
|
||||
|
||||
order := models.Order{
|
||||
@@ -110,6 +185,11 @@ func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
Quantity: payload.Quantity,
|
||||
DeliveredCodes: deliveredCodes,
|
||||
Status: "pending",
|
||||
DeliveryMode: deliveryMode,
|
||||
Note: strings.TrimSpace(payload.Note),
|
||||
ContactPhone: strings.TrimSpace(payload.ContactPhone),
|
||||
ContactEmail: strings.TrimSpace(payload.ContactEmail),
|
||||
NotifyEmail: notifyEmail,
|
||||
}
|
||||
created, err := h.orderStore.Create(order)
|
||||
if err != nil {
|
||||
@@ -117,6 +197,17 @@ func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !isManual {
|
||||
if err := h.productStore.IncrementSold(updatedProduct.ID, payload.Quantity); err != nil {
|
||||
log.Printf("[Order] 更新销量失败 (非致命): %v", err)
|
||||
}
|
||||
// Send delivery notification for auto-delivery orders immediately
|
||||
h.sendOrderNotify(notifyEmail, userName, updatedProduct.Name, created.ID, payload.Quantity, deliveredCodes, false)
|
||||
} else {
|
||||
// For manual delivery, notify user that order is received and pending
|
||||
h.sendOrderNotify(notifyEmail, userName, updatedProduct.Name, created.ID, payload.Quantity, nil, true)
|
||||
}
|
||||
|
||||
qrPayload := fmt.Sprintf("order:%s:%s", created.ID, created.ProductID)
|
||||
qrURL := fmt.Sprintf("https://api.qrserver.com/v1/create-qr-code/?size=%s&data=%s", qrSize, url.QueryEscape(qrPayload))
|
||||
|
||||
@@ -139,11 +230,25 @@ func (h *OrderHandler) ConfirmOrder(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
isManual := order.DeliveryMode == "manual"
|
||||
|
||||
// For manual delivery, send a "delivered" notification when admin confirms
|
||||
if isManual {
|
||||
confirmNotifyEmail := order.NotifyEmail
|
||||
if confirmNotifyEmail == "" {
|
||||
confirmNotifyEmail = order.ContactEmail
|
||||
}
|
||||
h.sendOrderNotify(confirmNotifyEmail, order.UserName, order.ProductName, order.ID, order.Quantity, order.DeliveredCodes, false)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"orderId": order.ID,
|
||||
"status": order.Status,
|
||||
"deliveryMode": order.DeliveryMode,
|
||||
"deliveredCodes": order.DeliveredCodes,
|
||||
"isManual": isManual,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -50,3 +50,17 @@ func (h *StatsHandler) RecordVisit(c *gin.Context) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *StatsHandler) GetMaintenance(c *gin.Context) {
|
||||
enabled, reason, err := h.siteStore.GetMaintenance()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"maintenance": enabled,
|
||||
"reason": reason,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
88
mengyastore-backend/internal/handlers/wishlist.go
Normal file
88
mengyastore-backend/internal/handlers/wishlist.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyastore-backend/internal/auth"
|
||||
"mengyastore-backend/internal/storage"
|
||||
)
|
||||
|
||||
type WishlistHandler struct {
|
||||
wishlistStore *storage.WishlistStore
|
||||
authClient *auth.SproutGateClient
|
||||
}
|
||||
|
||||
func NewWishlistHandler(wishlistStore *storage.WishlistStore, authClient *auth.SproutGateClient) *WishlistHandler {
|
||||
return &WishlistHandler{wishlistStore: wishlistStore, authClient: authClient}
|
||||
}
|
||||
|
||||
func (h *WishlistHandler) requireUser(c *gin.Context) (string, bool) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return "", false
|
||||
}
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
result, err := h.authClient.VerifyToken(token)
|
||||
if err != nil || !result.Valid || result.User == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||
return "", false
|
||||
}
|
||||
return result.User.Account, true
|
||||
}
|
||||
|
||||
func (h *WishlistHandler) GetWishlist(c *gin.Context) {
|
||||
account, ok := h.requireUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ids, err := h.wishlistStore.Get(account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}})
|
||||
}
|
||||
|
||||
type wishlistItemPayload struct {
|
||||
ProductID string `json:"productId"`
|
||||
}
|
||||
|
||||
func (h *WishlistHandler) AddToWishlist(c *gin.Context) {
|
||||
account, ok := h.requireUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var payload wishlistItemPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil || payload.ProductID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
if err := h.wishlistStore.Add(account, payload.ProductID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ids, _ := h.wishlistStore.Get(account)
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}})
|
||||
}
|
||||
|
||||
func (h *WishlistHandler) RemoveFromWishlist(c *gin.Context) {
|
||||
account, ok := h.requireUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
productID := c.Param("id")
|
||||
if productID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing product id"})
|
||||
return
|
||||
}
|
||||
if err := h.wishlistStore.Remove(account, productID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ids, _ := h.wishlistStore.Get(account)
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}})
|
||||
}
|
||||
Reference in New Issue
Block a user