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 响应为 {"valid": true/false},不泄露真实口令。 // @Tags 管理端-认证 // @Accept json // @Produce json // @Param body body AdminVerifyTokenRequest true "待校验 token" // @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 全部商品(管理) // @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 创建商品 // @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 更新商品 // @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 切换上架状态 // @Tags 管理端-商品 // @Accept json // @Produce json // @Security AdminToken // @Param id path string true "商品 ID" // @Param body body TogglePayload true "{active}" // @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 删除商品 // @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 }