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 }