Files
mengyastore/mengyastore-backend-go/internal/handlers/admin_product.go
2026-05-16 19:03:36 +08:00

350 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
PaymentQrURLs []string `json:"paymentQrUrls"`
Description string `json:"description"`
Active *bool `json:"active"`
RequireLogin bool `json:"requireLogin"`
MaxPerAccount int `json:"maxPerAccount"`
DeliveryMode string `json:"deliveryMode"`
FulfillmentType string `json:"fulfillmentType"`
FixedContent string `json:"fixedContent"`
ShowNote bool `json:"showNote"`
ShowContact bool `json:"showContact"`
}
func normalizeFulfillmentPayload(payload *ProductPayload) string {
ft := strings.TrimSpace(strings.ToLower(payload.FulfillmentType))
if ft == "fixed" {
return "fixed"
}
return "card"
}
type TogglePayload struct {
Active bool `json:"active"`
}
// AdminVerifyTokenRequest 管理端校验令牌Body非 Header
type AdminVerifyTokenRequest struct {
Token string `json:"token"`
}
// VerifyAdminToken 校验请求中的令牌是否正确。
// @Summary 校验管理端令牌是否有效
// @Description 请求体 JSON 含 `token` 字段;响应仅返回 `{"valid": true|false}`,不会返回真实口令或敏感信息。
// @Tags 管理端-认证
// @Accept json
// @Produce json
// @Param body body AdminVerifyTokenRequest true "待校验的管理员 tokenJSON"
// @Success 200 {object} SwaggerValidBody
// @Router /api/admin/verify [post]
func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
var payload AdminVerifyTokenRequest
if err := c.ShouldBindJSON(&payload); err != nil || payload.Token == "" {
c.JSON(http.StatusOK, gin.H{"valid": false})
return
}
c.JSON(http.StatusOK, gin.H{"valid": payload.Token == h.cfg.AdminToken})
}
// ListAllProducts 管理端商品全量列表(含下架与卡密等敏感字段)。
// @Summary 管理端获取全部商品列表
// @Description 含下架商品、卡密字段等完整数据,仅限管理令牌访问。需在请求头携带 `X-Admin-Token`。
// @Tags 管理端-商品
// @Produce json
// @Security AdminToken
// @Success 200 {object} SwaggerProductListBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/products [get]
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})
}
// CreateProduct 创建商品。
// @Summary 管理端创建商品
// @Description 提交完整商品载荷:价格、卡密/固定内容、收款码链接、是否上架等。校验失败返回 400。
// @Tags 管理端-商品
// @Accept json
// @Produce json
// @Security AdminToken
// @Param body body ProductPayload true "商品各字段(含可选卡密、截图、收款码等)"
// @Success 200 {object} SwaggerProductOneBody
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/products [post]
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": "请求参数有误"})
return
}
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 6 条"})
return
}
paymentQrURLs, valid := normalizePaymentQrURLs(payload.PaymentQrURLs)
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "萌芽收款渠道链接最多 6 条"})
return
}
active := true
if payload.Active != nil {
active = *payload.Active
}
ft := normalizeFulfillmentPayload(&payload)
if ft == "fixed" && strings.TrimSpace(payload.FixedContent) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "固定内容发货须填写发货内容(如网盘链接、说明文字)"})
return
}
dm := strings.TrimSpace(payload.DeliveryMode)
if dm == "" {
dm = "auto"
}
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,
PaymentQrURLs: paymentQrURLs,
Description: payload.Description,
Active: active,
RequireLogin: payload.RequireLogin,
MaxPerAccount: payload.MaxPerAccount,
DeliveryMode: dm,
FulfillmentType: ft,
FixedContent: payload.FixedContent,
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})
}
// UpdateProduct 更新商品。
// @Summary 管理端更新商品
// @Description 路径参数为商品 ID请求体为整包覆盖式更新以服务端绑定逻辑为准。未找到则 404。
// @Tags 管理端-商品
// @Accept json
// @Produce json
// @Security AdminToken
// @Param id path string true "商品ID"
// @Param body body ProductPayload true "待写入的商品字段"
// @Success 200 {object} SwaggerProductOneBody
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 404 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/products/{id} [put]
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": "请求参数有误"})
return
}
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 6 条"})
return
}
paymentQrURLs, valid := normalizePaymentQrURLs(payload.PaymentQrURLs)
if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "萌芽收款渠道链接最多 6 条"})
return
}
active := false
if payload.Active != nil {
active = *payload.Active
}
ft := normalizeFulfillmentPayload(&payload)
if ft == "fixed" && strings.TrimSpace(payload.FixedContent) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "固定内容发货须填写发货内容(如网盘链接、说明文字)"})
return
}
dm := strings.TrimSpace(payload.DeliveryMode)
if dm == "" {
dm = "auto"
}
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,
PaymentQrURLs: paymentQrURLs,
Description: payload.Description,
Active: active,
RequireLogin: payload.RequireLogin,
MaxPerAccount: payload.MaxPerAccount,
DeliveryMode: dm,
FulfillmentType: ft,
FixedContent: payload.FixedContent,
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})
}
// ToggleProduct 上架/下架切换。
// @Summary 管理端切换商品上架状态
// @Description PATCH 请求体为 `{ "active": true|false }`,用于快速上下架而不改其它字段。
// @Tags 管理端-商品
// @Accept json
// @Produce json
// @Security AdminToken
// @Param id path string true "商品ID"
// @Param body body TogglePayload true "上架开关 { \"active\": bool }"
// @Success 200 {object} SwaggerProductOneBody
// @Failure 400 {object} SwaggerErrorBody
// @Failure 401 {object} SwaggerErrorBody
// @Failure 404 {object} SwaggerErrorBody
// @Router /api/admin/products/{id}/status [patch]
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": "请求参数有误"})
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})
}
// DeleteProduct 删除商品。
// @Summary 管理端删除商品
// @Description 按 ID 永久删除商品记录(影响以数据库外键/业务逻辑为准),需管理令牌。
// @Tags 管理端-商品
// @Produce json
// @Security AdminToken
// @Param id path string true "商品ID"
// @Success 200 {object} SwaggerBoolOKWrap
// @Failure 401 {object} SwaggerErrorBody
// @Failure 500 {object} SwaggerErrorBody
// @Router /api/admin/products/{id} [delete]
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{"data": gin.H{"ok": true}})
}
const maxScreenshotURLsAdmin = 6
const maxPaymentQrURLsAdmin = 6
func normalizePaymentQrURLs(urls []string) ([]string, bool) {
cleaned := make([]string, 0, len(urls))
seen := map[string]bool{}
for _, u := range urls {
trimmed := strings.TrimSpace(u)
if trimmed == "" || seen[trimmed] {
continue
}
seen[trimmed] = true
cleaned = append(cleaned, trimmed)
if len(cleaned) > maxPaymentQrURLsAdmin {
return nil, false
}
}
return cleaned, true
}
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) > maxScreenshotURLsAdmin {
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
}