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 }}
+
+
+ {{ row.contact_name || '-' }}
+ {{ row.contact_phone || '-' }}
+
+
+
@@ -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 @@