feat: 更新商城前后端代码
This commit is contained in:
@@ -50,8 +50,8 @@ type ProductRow struct {
|
||||
TotalSold int `gorm:"default:0"`
|
||||
ViewCount int `gorm:"default:0"`
|
||||
DeliveryMode string `gorm:"size:20;default:'auto'"`
|
||||
ShowNote bool `gorm:"default:false"`
|
||||
ShowContact bool `gorm:"default:false"`
|
||||
ShowNote bool `gorm:"default:true"`
|
||||
ShowContact bool `gorm:"default:true"`
|
||||
CreatedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"mengyastore-backend/internal/cache"
|
||||
"mengyastore-backend/internal/models"
|
||||
)
|
||||
|
||||
@@ -30,7 +32,19 @@ type togglePayload struct {
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// VerifyAdminToken 验证管理员令牌是否正确,返回 {"valid": true/false},不暴露实际令牌值。
|
||||
// invalidateProductCache removes the cached product list so that the next
|
||||
// public request rebuilds it from the freshest DB state (Write-Invalidate).
|
||||
func (h *AdminHandler) invalidateProductCache(c *gin.Context) {
|
||||
if h.cache == nil {
|
||||
return
|
||||
}
|
||||
if err := h.cache.Del(c.Request.Context(), cache.KeyProductList); err != nil {
|
||||
log.Printf("[cache] DEL %s error: %v", cache.KeyProductList, err)
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyAdminToken checks whether the supplied token is correct.
|
||||
// Returns {"valid": true/false} without leaking the real token value.
|
||||
func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
@@ -84,7 +98,7 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: payload.DeliveryMode,
|
||||
DeliveryMode: "auto",
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
@@ -93,6 +107,7 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c) // Write-Invalidate: clear stale cache
|
||||
c.JSON(http.StatusOK, gin.H{"data": created})
|
||||
}
|
||||
|
||||
@@ -127,7 +142,7 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
|
||||
Active: active,
|
||||
RequireLogin: payload.RequireLogin,
|
||||
MaxPerAccount: payload.MaxPerAccount,
|
||||
DeliveryMode: payload.DeliveryMode,
|
||||
DeliveryMode: "auto",
|
||||
ShowNote: payload.ShowNote,
|
||||
ShowContact: payload.ShowContact,
|
||||
}
|
||||
@@ -136,6 +151,7 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c)
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
@@ -154,6 +170,7 @@ func (h *AdminHandler) ToggleProduct(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c)
|
||||
c.JSON(http.StatusOK, gin.H{"data": updated})
|
||||
}
|
||||
|
||||
@@ -166,6 +183,7 @@ func (h *AdminHandler) DeleteProduct(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.invalidateProductCache(c)
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,295 +1,529 @@
|
||||
package handlers
|
||||
|
||||
|
||||
|
||||
import (
|
||||
|
||||
"fmt"
|
||||
|
||||
"log"
|
||||
|
||||
"net/http"
|
||||
|
||||
"net/url"
|
||||
|
||||
"strings"
|
||||
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
|
||||
|
||||
"mengyastore-backend/internal/auth"
|
||||
|
||||
"mengyastore-backend/internal/email"
|
||||
|
||||
"mengyastore-backend/internal/models"
|
||||
|
||||
"mengyastore-backend/internal/storage"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
const qrSize = "320x320"
|
||||
|
||||
|
||||
|
||||
type OrderHandler struct {
|
||||
|
||||
productStore *storage.ProductStore
|
||||
|
||||
orderStore *storage.OrderStore
|
||||
|
||||
siteStore *storage.SiteStore
|
||||
|
||||
authClient *auth.SproutGateClient
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
type checkoutPayload struct {
|
||||
|
||||
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.ProductStore, orderStore *storage.OrderStore, siteStore *storage.SiteStore, authClient *auth.SproutGateClient) *OrderHandler {
|
||||
|
||||
return &OrderHandler{productStore: productStore, orderStore: orderStore, siteStore: siteStore, authClient: authClient}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
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 ") {
|
||||
|
||||
return "", "", ""
|
||||
|
||||
}
|
||||
|
||||
userToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
result, err := h.authClient.VerifyToken(userToken)
|
||||
|
||||
if err != nil || !result.Valid || result.User == nil {
|
||||
|
||||
return "", "", ""
|
||||
|
||||
}
|
||||
|
||||
return result.User.Account, result.User.Username, result.User.Email
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (h *OrderHandler) CreateOrder(c *gin.Context) {
|
||||
|
||||
userAccount, userName, userEmail := h.tryExtractUserWithEmail(c)
|
||||
|
||||
|
||||
|
||||
var payload checkoutPayload
|
||||
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
payload.ProductID = strings.TrimSpace(payload.ProductID)
|
||||
|
||||
if payload.ProductID == "" {
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少必填字段"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
if payload.Quantity <= 0 {
|
||||
|
||||
payload.Quantity = 1
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
product, err := h.productStore.GetByID(payload.ProductID)
|
||||
|
||||
if err != nil {
|
||||
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
if !product.Active {
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "商品暂时无法购买"})
|
||||
|
||||
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
|
||||
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
deliveredCodes, ok := extractCodes(&product, payload.Quantity)
|
||||
|
||||
if !ok {
|
||||
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "卡密不足"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
deliveryMode := product.DeliveryMode
|
||||
if deliveryMode == "" {
|
||||
deliveryMode = "auto"
|
||||
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
|
||||
|
||||
}
|
||||
|
||||
// 通知邮箱优先级:
|
||||
// 1. SproutGate 账号邮箱(已登录用户,最可靠)
|
||||
// 2. 前端传入的 notifyEmail(来自 authState.email)
|
||||
// 3. 用户在结账表单填写的联系邮箱
|
||||
// 4. 均为空则不发送
|
||||
|
||||
|
||||
deliveryMode := "auto"
|
||||
|
||||
|
||||
|
||||
notifyEmail := strings.TrimSpace(userEmail)
|
||||
|
||||
if notifyEmail == "" {
|
||||
|
||||
notifyEmail = strings.TrimSpace(payload.NotifyEmail)
|
||||
}
|
||||
if notifyEmail == "" {
|
||||
notifyEmail = strings.TrimSpace(payload.ContactEmail)
|
||||
|
||||
}
|
||||
|
||||
// 自动发货订单立即设为已完成(卡密已提取);手动发货订单初始状态为待处理,由管理员确认。
|
||||
orderStatus := "pending"
|
||||
if !isManual {
|
||||
orderStatus = "completed"
|
||||
if notifyEmail == "" {
|
||||
|
||||
notifyEmail = strings.TrimSpace(payload.ContactEmail)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
order := models.Order{
|
||||
|
||||
ProductID: updatedProduct.ID,
|
||||
|
||||
ProductName: updatedProduct.Name,
|
||||
|
||||
UserAccount: userAccount,
|
||||
|
||||
UserName: userName,
|
||||
|
||||
Quantity: payload.Quantity,
|
||||
|
||||
DeliveredCodes: deliveredCodes,
|
||||
Status: orderStatus,
|
||||
|
||||
Status: "completed",
|
||||
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
if !isManual {
|
||||
if err := h.productStore.IncrementSold(updatedProduct.ID, payload.Quantity); err != nil {
|
||||
log.Printf("[Order] 更新销量失败 (非致命): %v", err)
|
||||
}
|
||||
// 自动发货:立即发送卡密通知邮件
|
||||
h.sendOrderNotify(notifyEmail, userName, updatedProduct.Name, created.ID, payload.Quantity, deliveredCodes, false)
|
||||
} else {
|
||||
// 手动发货:告知用户订单已收到,等待发货
|
||||
h.sendOrderNotify(notifyEmail, userName, updatedProduct.Name, created.ID, payload.Quantity, nil, true)
|
||||
created, err := h.orderStore.Create(order)
|
||||
|
||||
if err != nil {
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if err := h.productStore.IncrementSold(updatedProduct.ID, payload.Quantity); err != nil {
|
||||
|
||||
log.Printf("[Order] 更新销量失败 (非致命): %v", err)
|
||||
|
||||
}
|
||||
|
||||
h.sendOrderNotify(notifyEmail, userName, updatedProduct.Name, created.ID, payload.Quantity, deliveredCodes, false)
|
||||
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
"data": gin.H{
|
||||
|
||||
"orderId": created.ID,
|
||||
|
||||
"qrCodeUrl": qrURL,
|
||||
|
||||
"productId": created.ProductID,
|
||||
|
||||
"productQty": created.Quantity,
|
||||
|
||||
"viewCount": updatedProduct.ViewCount,
|
||||
|
||||
"status": created.Status,
|
||||
|
||||
},
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (h *OrderHandler) ConfirmOrder(c *gin.Context) {
|
||||
|
||||
orderID := c.Param("id")
|
||||
|
||||
order, err := h.orderStore.Confirm(orderID)
|
||||
|
||||
if err != nil {
|
||||
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
isManual := order.DeliveryMode == "manual"
|
||||
|
||||
// 手动发货确认后,发送"已发货"通知邮件
|
||||
|
||||
|
||||
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,
|
||||
|
||||
},
|
||||
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func (h *OrderHandler) ListMyOrders(c *gin.Context) {
|
||||
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
userToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
result, err := h.authClient.VerifyToken(userToken)
|
||||
|
||||
if err != nil || !result.Valid || result.User == nil {
|
||||
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
orders, err := h.orderStore.ListByAccount(result.User.Account)
|
||||
|
||||
if err != nil {
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": orders})
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
func extractCodes(product *models.Product, count int) ([]string, bool) {
|
||||
|
||||
if count <= 0 {
|
||||
|
||||
return nil, false
|
||||
|
||||
}
|
||||
|
||||
if len(product.Codes) < count {
|
||||
|
||||
return nil, false
|
||||
|
||||
}
|
||||
|
||||
delivered := make([]string, count)
|
||||
|
||||
copy(delivered, product.Codes[:count])
|
||||
|
||||
product.Codes = product.Codes[count:]
|
||||
|
||||
return delivered, true
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -59,4 +59,4 @@ func sanitizeForPublic(items []models.Product) []models.Product {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -240,6 +240,32 @@ func (s *ProductStore) IncrementView(id, fingerprint string) (models.Product, bo
|
||||
return rowToModel(row, nil), true, nil
|
||||
}
|
||||
|
||||
// IncrementViewCount atomically increments view_count by 1 and returns the
|
||||
// updated product (without codes). It is called by the handler when Redis-based
|
||||
// deduplication has already confirmed this is a new view, so no in-memory lock
|
||||
// is needed here.
|
||||
func (s *ProductStore) IncrementViewCount(id string) (models.Product, error) {
|
||||
if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).
|
||||
UpdateColumn("view_count", gorm.Expr("view_count + 1")).Error; err != nil {
|
||||
return models.Product{}, err
|
||||
}
|
||||
var row database.ProductRow
|
||||
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
|
||||
return models.Product{}, fmt.Errorf("product not found")
|
||||
}
|
||||
return rowToModel(row, nil), nil
|
||||
}
|
||||
|
||||
// GetViewState returns the current product state without modifying any counter.
|
||||
// Used by the handler when a view is detected as duplicate via Redis.
|
||||
func (s *ProductStore) GetViewState(id string) (models.Product, error) {
|
||||
var row database.ProductRow
|
||||
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
|
||||
return models.Product{}, fmt.Errorf("product not found")
|
||||
}
|
||||
return rowToModel(row, nil), nil
|
||||
}
|
||||
|
||||
func (s *ProductStore) Delete(id string) error {
|
||||
if err := s.db.Where("product_id = ?", id).Delete(&database.ProductCodeRow{}).Error; err != nil {
|
||||
return err
|
||||
@@ -272,7 +298,7 @@ func normalizeProduct(item models.Product) models.Product {
|
||||
item.VerificationURL = strings.TrimSpace(item.VerificationURL)
|
||||
item.Codes = sanitizeCodes(item.Codes)
|
||||
item.Quantity = len(item.Codes)
|
||||
if item.DeliveryMode == "" {
|
||||
if item.DeliveryMode == "" || item.DeliveryMode == "manual" {
|
||||
item.DeliveryMode = "auto"
|
||||
}
|
||||
return item
|
||||
|
||||
Reference in New Issue
Block a user