From f439e23eb8f78a059489d5e7c37566327966779a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 4 Nov 2026 19:51:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(points):=20=E5=A2=9E=E5=8A=A0=E7=A7=AF?= =?UTF-8?q?=E5=88=86=E5=85=91=E6=8D=A2=E8=81=94=E7=B3=BB=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/model/points_mall.go | 3 + backend/internal/modules/pointsmall/dto.go | 11 +- .../internal/modules/pointsmall/handler.go | 4 +- .../internal/modules/pointsmall/repository.go | 32 +- .../modules/pointsmall/repository_test.go | 21 +- .../internal/modules/pointsmall/service.go | 4 +- .../admin/views/AdminPointsMallView.vue | 18 + .../src/features/points/api/pointsMall.ts | 14 +- .../features/points/views/PointsMallView.vue | 343 +++++++++++++++++- 9 files changed, 425 insertions(+), 25 deletions(-) diff --git a/backend/internal/model/points_mall.go b/backend/internal/model/points_mall.go index a422309..5c74e7a 100644 --- a/backend/internal/model/points_mall.go +++ b/backend/internal/model/points_mall.go @@ -31,6 +31,9 @@ type PointsMallRedemption struct { ProductTitle string `gorm:"size:128;not null" json:"product_title"` ProductCoverURL string `gorm:"size:512;not null;default:''" json:"product_cover_url"` RedemptionInstructions string `gorm:"type:text;not null" json:"redemption_instructions"` + ContactName string `gorm:"size:32;not null;default:''" json:"contact_name"` + ContactPhone string `gorm:"size:32;not null;default:''" json:"contact_phone"` + CustomerRemark string `gorm:"size:500;not null;default:''" json:"customer_remark"` PointsPrice int64 `gorm:"not null;default:0" json:"points_price"` Quantity int `gorm:"not null;default:1" json:"quantity"` TotalPoints int64 `gorm:"not null;default:0" json:"total_points"` diff --git a/backend/internal/modules/pointsmall/dto.go b/backend/internal/modules/pointsmall/dto.go index 260e477..7711ae5 100644 --- a/backend/internal/modules/pointsmall/dto.go +++ b/backend/internal/modules/pointsmall/dto.go @@ -56,7 +56,10 @@ type ProductRequest struct { } type RedeemRequest struct { - Quantity int `json:"quantity" binding:"required,min=1,max=10"` + Quantity int `json:"quantity" binding:"required,min=1,max=10"` + ContactName string `json:"contact_name" binding:"required,max=32"` + ContactPhone string `json:"contact_phone" binding:"required,len=11,numeric"` + CustomerRemark string `json:"customer_remark" binding:"max=500"` } type CompleteRedemptionRequest struct { @@ -73,6 +76,9 @@ type RedemptionDTO struct { ProductTitle string `json:"product_title"` ProductCoverURL string `json:"product_cover_url"` RedemptionInstructions string `json:"redemption_instructions"` + ContactName string `json:"contact_name"` + ContactPhone string `json:"contact_phone"` + CustomerRemark string `json:"customer_remark"` PointsPrice int64 `json:"points_price"` Quantity int `json:"quantity"` TotalPoints int64 `json:"total_points"` @@ -111,6 +117,9 @@ func toRedemptionDTO(item model.PointsMallRedemption) RedemptionDTO { ProductTitle: item.ProductTitle, ProductCoverURL: item.ProductCoverURL, RedemptionInstructions: item.RedemptionInstructions, + ContactName: item.ContactName, + ContactPhone: item.ContactPhone, + CustomerRemark: item.CustomerRemark, PointsPrice: item.PointsPrice, Quantity: item.Quantity, TotalPoints: item.TotalPoints, diff --git a/backend/internal/modules/pointsmall/handler.go b/backend/internal/modules/pointsmall/handler.go index 0b50d21..415a086 100644 --- a/backend/internal/modules/pointsmall/handler.go +++ b/backend/internal/modules/pointsmall/handler.go @@ -107,10 +107,10 @@ func (h *Handler) Redeem(c *gin.Context) { productID, _ := strconv.ParseUint(c.Param("id"), 10, 64) var req RedeemRequest if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "兑换数量不正确") + response.BadRequest(c, "请填写联系人、正确的手机号和兑换数量") return } - item, err := h.service.Redeem(c.Request.Context(), userID, productID, req.Quantity) + item, err := h.service.Redeem(c.Request.Context(), userID, productID, req) if err != nil { writeError(c, err) return diff --git a/backend/internal/modules/pointsmall/repository.go b/backend/internal/modules/pointsmall/repository.go index 4e08e1d..6f577c1 100644 --- a/backend/internal/modules/pointsmall/repository.go +++ b/backend/internal/modules/pointsmall/repository.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "regexp" "strings" "time" @@ -18,6 +19,8 @@ type Repository struct { db *gorm.DB } +var mobilePhonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`) + func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } @@ -115,8 +118,18 @@ func (r *Repository) findProduct(ctx context.Context, id uint64) (*ProductDTO, e return &dto, nil } -func (r *Repository) Redeem(ctx context.Context, userID, productID uint64, quantity int) (*RedemptionDTO, error) { - if userID == 0 || productID == 0 || quantity <= 0 || quantity > 10 { +func (r *Repository) Redeem(ctx context.Context, userID, productID uint64, req RedeemRequest) (*RedemptionDTO, error) { + contactName := strings.TrimSpace(req.ContactName) + contactPhone := strings.TrimSpace(req.ContactPhone) + customerRemark := strings.TrimSpace(req.CustomerRemark) + if userID == 0 || + productID == 0 || + req.Quantity <= 0 || + req.Quantity > 10 || + contactName == "" || + len([]rune(contactName)) > 32 || + !mobilePhonePattern.MatchString(contactPhone) || + len([]rune(customerRemark)) > 500 { return nil, ErrInvalidInput } var result model.PointsMallRedemption @@ -125,7 +138,7 @@ func (r *Repository) Redeem(ctx context.Context, userID, productID uint64, quant if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, productID).Error; err != nil { return err } - if product.Status != "online" || product.Stock < int64(quantity) { + if product.Status != "online" || product.Stock < int64(req.Quantity) { return ErrProductUnavailable } var redeemed int64 @@ -135,14 +148,14 @@ func (r *Repository) Redeem(ctx context.Context, userID, productID uint64, quant Scan(&redeemed).Error; err != nil { return err } - if redeemed+int64(quantity) > int64(product.PerUserLimit) { + if redeemed+int64(req.Quantity) > int64(product.PerUserLimit) { return ErrRedeemLimitExceeded } var user model.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil { return err } - totalPoints := product.PointsPrice * int64(quantity) + totalPoints := product.PointsPrice * int64(req.Quantity) if totalPoints <= 0 || user.PointsAvailable < totalPoints { return ErrInsufficientPoints } @@ -154,8 +167,11 @@ func (r *Repository) Redeem(ctx context.Context, userID, productID uint64, quant ProductTitle: product.Title, ProductCoverURL: product.CoverURL, RedemptionInstructions: product.RedemptionInstructions, + ContactName: contactName, + ContactPhone: contactPhone, + CustomerRemark: customerRemark, PointsPrice: product.PointsPrice, - Quantity: quantity, + Quantity: req.Quantity, TotalPoints: totalPoints, Status: "pending", CreatedAt: now, @@ -170,8 +186,8 @@ func (r *Repository) Redeem(ctx context.Context, userID, productID uint64, quant return err } if err := tx.Model(&product).Updates(map[string]any{ - "stock": gorm.Expr("stock - ?", quantity), - "redeemed_count": gorm.Expr("redeemed_count + ?", quantity), + "stock": gorm.Expr("stock - ?", req.Quantity), + "redeemed_count": gorm.Expr("redeemed_count + ?", req.Quantity), }).Error; err != nil { return err } diff --git a/backend/internal/modules/pointsmall/repository_test.go b/backend/internal/modules/pointsmall/repository_test.go index efbda97..bc0a062 100644 --- a/backend/internal/modules/pointsmall/repository_test.go +++ b/backend/internal/modules/pointsmall/repository_test.go @@ -27,13 +27,29 @@ func TestRedeemDeductsAvailablePointsAndStock(t *testing.T) { t.Fatalf("create product failed: %v", err) } repo := NewRepository(db) - redemption, err := repo.Redeem(t.Context(), user.ID, product.ID, 2) + req := RedeemRequest{ + Quantity: 2, + ContactName: "张三", + ContactPhone: "13800138000", + CustomerRemark: "请发送到邮箱", + } + invalidReq := req + invalidReq.ContactPhone = "123" + if _, err := repo.Redeem(t.Context(), user.ID, product.ID, invalidReq); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("invalid contact phone error = %v, want ErrInvalidInput", err) + } + redemption, err := repo.Redeem(t.Context(), user.ID, product.ID, req) if err != nil { t.Fatalf("redeem failed: %v", err) } if redemption.TotalPoints != 200 { t.Fatalf("total points = %d, want 200", redemption.TotalPoints) } + if redemption.ContactName != req.ContactName || + redemption.ContactPhone != req.ContactPhone || + redemption.CustomerRemark != req.CustomerRemark { + t.Fatalf("redemption contact = %+v, want %+v", redemption, req) + } var savedUser model.User if err := db.First(&savedUser, user.ID).Error; err != nil { t.Fatalf("find user failed: %v", err) @@ -48,7 +64,8 @@ func TestRedeemDeductsAvailablePointsAndStock(t *testing.T) { if savedProduct.Stock != 0 || savedProduct.RedeemedCount != 2 { t.Fatalf("product stock/redeemed = %d/%d", savedProduct.Stock, savedProduct.RedeemedCount) } - if _, err := repo.Redeem(t.Context(), user.ID, product.ID, 1); !errors.Is(err, ErrRedeemLimitExceeded) && !errors.Is(err, ErrProductUnavailable) { + req.Quantity = 1 + if _, err := repo.Redeem(t.Context(), user.ID, product.ID, req); !errors.Is(err, ErrRedeemLimitExceeded) && !errors.Is(err, ErrProductUnavailable) { t.Fatalf("repeat redeem error = %v, want limit or unavailable", err) } completed, err := repo.CompleteRedemption(t.Context(), 99, redemption.ID, "已发放") diff --git a/backend/internal/modules/pointsmall/service.go b/backend/internal/modules/pointsmall/service.go index 0c30215..b31302b 100644 --- a/backend/internal/modules/pointsmall/service.go +++ b/backend/internal/modules/pointsmall/service.go @@ -34,8 +34,8 @@ func (s *Service) UpdateProduct(ctx context.Context, id uint64, req ProductReque return s.repo.UpdateProduct(ctx, id, req) } -func (s *Service) Redeem(ctx context.Context, userID, productID uint64, quantity int) (*RedemptionDTO, error) { - return s.repo.Redeem(ctx, userID, productID, quantity) +func (s *Service) Redeem(ctx context.Context, userID, productID uint64, req RedeemRequest) (*RedemptionDTO, error) { + return s.repo.Redeem(ctx, userID, productID, req) } func (s *Service) ListRedemptions(ctx context.Context, userID uint64, admin bool) ([]RedemptionDTO, error) { diff --git a/frontend/src/features/admin/views/AdminPointsMallView.vue b/frontend/src/features/admin/views/AdminPointsMallView.vue index b1b3d7f..83f71a4 100644 --- a/frontend/src/features/admin/views/AdminPointsMallView.vue +++ b/frontend/src/features/admin/views/AdminPointsMallView.vue @@ -186,6 +186,16 @@ function defaultProduct(): PointsMallProductPayload { {{ row.user_phone || row.user_id }} + + + @@ -315,6 +325,14 @@ function defaultProduct(): PointsMallProductPayload { gap: 12px; } +.customer-remark { + margin: 5px 0 0; + color: #6b7280; + font-size: 12px; + line-height: 1.4; + white-space: normal; +} + .cover-editor { display: grid; grid-template-columns: 180px minmax(0, 1fr); diff --git a/frontend/src/features/points/api/pointsMall.ts b/frontend/src/features/points/api/pointsMall.ts index a18537b..b7c9fa4 100644 --- a/frontend/src/features/points/api/pointsMall.ts +++ b/frontend/src/features/points/api/pointsMall.ts @@ -49,6 +49,9 @@ export interface PointsMallRedemption { product_title: string product_cover_url: string redemption_instructions: string + contact_name: string + contact_phone: string + customer_remark: string points_price: number quantity: number total_points: number @@ -58,6 +61,13 @@ export interface PointsMallRedemption { created_at: string } +export interface PointsMallRedeemPayload { + quantity: number + contact_name: string + contact_phone: string + customer_remark: string +} + export interface PointsMallProductPayload { title: string subtitle: string @@ -86,10 +96,10 @@ export async function fetchPointsProducts() { return data.data } -export async function redeemPointsProduct(id: number, quantity = 1) { +export async function redeemPointsProduct(id: number, payload: PointsMallRedeemPayload) { const { data } = await apiClient.post>( `/points-mall/products/${id}/redeem`, - { quantity } + payload ) return data.data } diff --git a/frontend/src/features/points/views/PointsMallView.vue b/frontend/src/features/points/views/PointsMallView.vue index 8e1002c..6ab8fc3 100644 --- a/frontend/src/features/points/views/PointsMallView.vue +++ b/frontend/src/features/points/views/PointsMallView.vue @@ -1,5 +1,5 @@