350 lines
11 KiB
Go
350 lines
11 KiB
Go
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 "待校验的管理员 token(JSON)"
|
||
// @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
|
||
}
|