摸大红支持购物车多选一起结账

下单支持多商品行快照与一次支付;支付后整单复制文案,前台提供购物车加购与结账。
This commit is contained in:
yml2213
2026-07-16 20:58:39 +08:00
parent e241607535
commit 183351a297
17 changed files with 1159 additions and 117 deletions
+1
View File
@@ -66,6 +66,7 @@ type MohongOrder struct {
AmountCent int64 `gorm:"column:amount_cent;not null;default:0" json:"-"`
Status string `gorm:"size:32;not null;default:'pending_payment';index" json:"status"`
ProductSnapshot datatypes.JSON `gorm:"column:product_snapshot" json:"product_snapshot"`
ItemsSnapshot datatypes.JSON `gorm:"column:items_snapshot" json:"items_snapshot"`
QrcodeURLSnapshot string `gorm:"column:qrcode_url_snapshot;size:512;not null;default:''" json:"qrcode_url_snapshot"`
CopyText string `gorm:"type:text;not null" json:"copy_text"`
PaidAt *time.Time `json:"paid_at"`
+1 -1
View File
@@ -14,7 +14,7 @@ const (
configKeyDefaultQrcodeURL = "mohong.default_qrcode_url"
configKeyOrderCopyTemplate = "mohong.order_copy_template"
defaultOrderCopyTemplate = "【摸大红订单】\n订单号:{{order_no}}\n下单时间:{{created_at}}\n商品{{product_title}}\n数量:{{quantity}}\n金额:{{amount}}\n下单人:{{buyer_name}}{{buyer_phone}}"
defaultOrderCopyTemplate = "【摸大红订单】\n订单号:{{order_no}}\n下单时间:{{created_at}}\n商品明细:\n{{items}}\n数量合计{{quantity}}\n金额:{{amount}}\n下单人:{{buyer_name}}{{buyer_phone}}"
)
func (r *Repository) GetConfig(ctx context.Context) (*ConfigDTO, error) {
+58 -25
View File
@@ -87,36 +87,56 @@ type UpdateProductRequest struct {
QrcodeImageURL *string `json:"qrcode_image_url"`
}
type CreateOrderRequest struct {
type CreateOrderItemRequest struct {
ProductID uint64 `json:"product_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,min=1,max=99"`
}
// CreateOrderRequest 支持单商品(product_id+quantity)或多商品(items)。
type CreateOrderRequest struct {
ProductID uint64 `json:"product_id"`
Quantity int `json:"quantity"`
Items []CreateOrderItemRequest `json:"items"`
}
type OrderItemDTO struct {
ProductID uint64 `json:"product_id"`
Title string `json:"title"`
CoverURL string `json:"cover_url"`
Unit string `json:"unit"`
Quantity int `json:"quantity"`
UnitPriceCent int64 `json:"unit_price_cent"`
UnitPrice string `json:"unit_price"`
AmountCent int64 `json:"amount_cent"`
Amount string `json:"amount"`
}
type OrderDTO struct {
ID uint64 `json:"id"`
OrderNo string `json:"order_no"`
UserID uint64 `json:"user_id"`
ProductID uint64 `json:"product_id"`
Quantity int `json:"quantity"`
UnitPriceCent int64 `json:"unit_price_cent"`
UnitPrice string `json:"unit_price"`
AmountCent int64 `json:"amount_cent"`
Amount string `json:"amount"`
Status string `json:"status"`
ProductTitle string `json:"product_title"`
ProductCoverURL string `json:"product_cover_url"`
ProductUnit string `json:"product_unit"`
QrcodeURLSnapshot string `json:"qrcode_url_snapshot"`
CopyText string `json:"copy_text"`
BuyerNickname string `json:"buyer_nickname,omitempty"`
BuyerPhone string `json:"buyer_phone,omitempty"`
AdminRemark string `json:"admin_remark,omitempty"`
CancelReason string `json:"cancel_reason,omitempty"`
PaidAt *time.Time `json:"paid_at"`
CompletedAt *time.Time `json:"completed_at"`
CancelledAt *time.Time `json:"cancelled_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint64 `json:"id"`
OrderNo string `json:"order_no"`
UserID uint64 `json:"user_id"`
ProductID uint64 `json:"product_id"`
Quantity int `json:"quantity"`
UnitPriceCent int64 `json:"unit_price_cent"`
UnitPrice string `json:"unit_price"`
AmountCent int64 `json:"amount_cent"`
Amount string `json:"amount"`
Status string `json:"status"`
ProductTitle string `json:"product_title"`
ProductCoverURL string `json:"product_cover_url"`
ProductUnit string `json:"product_unit"`
Items []OrderItemDTO `json:"items,omitempty"`
QrcodeURLSnapshot string `json:"qrcode_url_snapshot"`
CopyText string `json:"copy_text"`
BuyerNickname string `json:"buyer_nickname,omitempty"`
BuyerPhone string `json:"buyer_phone,omitempty"`
AdminRemark string `json:"admin_remark,omitempty"`
CancelReason string `json:"cancel_reason,omitempty"`
PaidAt *time.Time `json:"paid_at"`
CompletedAt *time.Time `json:"completed_at"`
CancelledAt *time.Time `json:"cancelled_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type OrderListQuery struct {
@@ -162,3 +182,16 @@ type productSnapshot struct {
Unit string `json:"unit"`
QrcodeImageURL string `json:"qrcode_image_url"`
}
// orderItemSnapshot 订单商品行快照(多商品结账)。
type orderItemSnapshot struct {
ID uint64 `json:"id"`
Title string `json:"title"`
CoverURL string `json:"cover_url"`
ImageURLs []string `json:"image_urls"`
Description string `json:"description"`
PriceCent int64 `json:"price_cent"`
Unit string `json:"unit"`
QrcodeImageURL string `json:"qrcode_image_url"`
Quantity int `json:"quantity"`
}
+209 -68
View File
@@ -20,64 +20,88 @@ import (
)
func (r *Repository) CreateOrder(ctx context.Context, userID uint64, req CreateOrderRequest) (*OrderDTO, error) {
if userID == 0 || req.ProductID == 0 || req.Quantity < 1 || req.Quantity > 99 {
if userID == 0 {
return nil, ErrInvalidRequest
}
reqItems := normalizeCreateOrderItems(req)
if len(reqItems) == 0 {
return nil, ErrInvalidRequest
}
var createdID uint64
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var product model.MohongProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, req.ProductID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return ErrProductNotFound
}
return err
}
if product.Status != model.MohongProductStatusOnSale {
return ErrProductUnavailable
}
if product.PriceCent <= 0 {
return ErrProductUnavailable
}
if product.Stock >= 0 && product.Stock < req.Quantity {
return ErrStockInsufficient
}
// 下单时预扣库存,取消未支付订单时归还。
if product.Stock >= 0 {
product.Stock -= req.Quantity
if err := tx.Save(&product).Error; err != nil {
itemSnaps := make([]orderItemSnapshot, 0, len(reqItems))
var totalQty int
var totalAmount int64
var primary productSnapshot
for _, line := range reqItems {
var product model.MohongProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, line.ProductID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return ErrProductNotFound
}
return err
}
if product.Status != model.MohongProductStatusOnSale || product.PriceCent <= 0 {
return ErrProductUnavailable
}
if product.Stock >= 0 && product.Stock < line.Quantity {
return ErrStockInsufficient
}
// 下单时预扣库存,取消未支付订单时归还。
if product.Stock >= 0 {
product.Stock -= line.Quantity
if err := tx.Save(&product).Error; err != nil {
return err
}
}
images := decodeStringList(product.ImageURLs)
item := orderItemSnapshot{
ID: product.ID,
Title: product.Title,
CoverURL: product.CoverURL,
ImageURLs: images,
Description: product.Description,
PriceCent: product.PriceCent,
Unit: product.Unit,
QrcodeImageURL: product.QrcodeImageURL,
Quantity: line.Quantity,
}
itemSnaps = append(itemSnaps, item)
totalQty += line.Quantity
totalAmount += product.PriceCent * int64(line.Quantity)
if primary.ID == 0 {
primary = productSnapshot{
ID: product.ID, Title: product.Title, CoverURL: product.CoverURL,
ImageURLs: images, Description: product.Description, PriceCent: product.PriceCent,
Unit: product.Unit, QrcodeImageURL: product.QrcodeImageURL,
}
}
}
orderNo, err := newMohongOrderNo()
if err != nil {
return err
}
images := decodeStringList(product.ImageURLs)
snap := productSnapshot{
ID: product.ID,
Title: product.Title,
CoverURL: product.CoverURL,
ImageURLs: images,
Description: product.Description,
PriceCent: product.PriceCent,
Unit: product.Unit,
QrcodeImageURL: product.QrcodeImageURL,
}
snapRaw, err := json.Marshal(snap)
primaryRaw, err := json.Marshal(primary)
if err != nil {
return err
}
amount := product.PriceCent * int64(req.Quantity)
itemsRaw, err := json.Marshal(itemSnaps)
if err != nil {
return err
}
// 展示字段:单商品保持原语义;多商品 quantity 为合计,unit_price 用首项单价。
order := model.MohongOrder{
OrderNo: orderNo,
UserID: userID,
ProductID: product.ID,
Quantity: req.Quantity,
UnitPriceCent: product.PriceCent,
AmountCent: amount,
ProductID: primary.ID,
Quantity: totalQty,
UnitPriceCent: primary.PriceCent,
AmountCent: totalAmount,
Status: model.MohongOrderStatusPendingPayment,
ProductSnapshot: datatypes.JSON(snapRaw),
ProductSnapshot: datatypes.JSON(primaryRaw),
ItemsSnapshot: datatypes.JSON(itemsRaw),
CopyText: "",
}
if err := tx.Create(&order).Error; err != nil {
@@ -103,6 +127,40 @@ func (r *Repository) CreateOrder(ctx context.Context, userID uint64, req CreateO
return r.FindOrderForUser(ctx, userID, createdID)
}
func normalizeCreateOrderItems(req CreateOrderRequest) []CreateOrderItemRequest {
// 优先 items;否则兼容旧单商品参数。
if len(req.Items) > 0 {
merged := map[uint64]int{}
orderIDs := make([]uint64, 0, len(req.Items))
for _, item := range req.Items {
if item.ProductID == 0 || item.Quantity < 1 || item.Quantity > 99 {
continue
}
if _, ok := merged[item.ProductID]; !ok {
orderIDs = append(orderIDs, item.ProductID)
}
merged[item.ProductID] += item.Quantity
if merged[item.ProductID] > 99 {
merged[item.ProductID] = 99
}
}
out := make([]CreateOrderItemRequest, 0, len(orderIDs))
for _, id := range orderIDs {
if qty := merged[id]; qty > 0 {
out = append(out, CreateOrderItemRequest{ProductID: id, Quantity: qty})
}
}
if len(out) > 50 {
return out[:50]
}
return out
}
if req.ProductID > 0 && req.Quantity >= 1 && req.Quantity <= 99 {
return []CreateOrderItemRequest{{ProductID: req.ProductID, Quantity: req.Quantity}}
}
return nil
}
func (r *Repository) FindOrderForUser(ctx context.Context, userID, orderID uint64) (*OrderDTO, error) {
var row model.MohongOrder
if err := r.db.WithContext(ctx).Where("id = ? AND user_id = ?", orderID, userID).First(&row).Error; err != nil {
@@ -272,9 +330,10 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint
if err := tx.First(&user, order.UserID).Error; err != nil {
return 0, err
}
items := decodeOrderItems(order)
snap := decodeProductSnapshot(order.ProductSnapshot)
qrcodeURL := r.resolveQrcodeURL(tx, snap.QrcodeImageURL)
copyText := buildCopyText(r.loadCopyTemplate(tx), order, snap, user)
qrcodeURL := r.resolveQrcodeURL(tx, resolveOrderQrcode(items, snap))
copyText := buildCopyText(r.loadCopyTemplate(tx), order, items, snap, user)
now := time.Now()
order.Status = model.MohongOrderStatusPaid
@@ -303,15 +362,19 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint
func (r *Repository) NotifyNewConversation(conversationID uint64) {}
func (r *Repository) cancelPendingOrderTx(tx *gorm.DB, order *model.MohongOrder, reason string) error {
// 归还预扣库存
if order.Quantity > 0 {
// 归还预扣库存(支持多商品行)
for _, item := range decodeOrderItems(*order) {
if item.Quantity <= 0 || item.ID == 0 {
continue
}
var product model.MohongProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, order.ProductID).Error; err == nil {
if product.Stock >= 0 {
product.Stock += order.Quantity
if err := tx.Save(&product).Error; err != nil {
return err
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, item.ID).Error; err != nil {
continue
}
if product.Stock >= 0 {
product.Stock += item.Quantity
if err := tx.Save(&product).Error; err != nil {
return err
}
}
}
@@ -324,17 +387,18 @@ func (r *Repository) cancelPendingOrderTx(tx *gorm.DB, order *model.MohongOrder,
func (r *Repository) orderDTO(ctx context.Context, row model.MohongOrder, includeAdmin bool) (*OrderDTO, error) {
snap := decodeProductSnapshot(row.ProductSnapshot)
items := decodeOrderItems(row)
var nickname, phone string
var user model.User
if err := r.db.WithContext(ctx).Select("id", "nickname", "phone").First(&user, row.UserID).Error; err == nil {
nickname = user.Nickname
phone = user.Phone
}
dto := toOrderDTO(row, snap, nickname, phone, includeAdmin)
dto := toOrderDTO(row, snap, items, nickname, phone, includeAdmin)
return &dto, nil
}
func buildCopyText(template string, order model.MohongOrder, snap productSnapshot, user model.User) string {
func buildCopyText(template string, order model.MohongOrder, items []orderItemSnapshot, snap productSnapshot, user model.User) string {
if strings.TrimSpace(template) == "" {
template = defaultOrderCopyTemplate
}
@@ -342,33 +406,110 @@ func buildCopyText(template string, order model.MohongOrder, snap productSnapsho
if buyerName == "" {
buyerName = "用户"
}
createdAt := timeutil.ShanghaiNow().Format("2006-01-02 15:04:05")
if !order.CreatedAt.IsZero() {
createdAt = order.CreatedAt.In(timeutil.ShanghaiLocation()).Format("2006-01-02 15:04:05")
}
productTitle := snap.Title
if productTitle == "" && len(items) > 0 {
productTitle = items[0].Title
}
if len(items) > 1 {
productTitle = fmt.Sprintf("%s 等%d种", productTitle, len(items))
}
unit := snap.Unit
if unit == "" && len(items) > 0 {
unit = items[0].Unit
}
replacer := strings.NewReplacer(
"{{order_no}}", order.OrderNo,
"{{created_at}}", timeutil.ShanghaiNow().Format("2006-01-02 15:04:05"),
"{{product_title}}", snap.Title,
"{{created_at}}", createdAt,
"{{product_title}}", productTitle,
"{{items}}", formatItemsForCopy(items, snap, order.Quantity),
"{{quantity}}", fmt.Sprintf("%d", order.Quantity),
"{{amount}}", money.FormatWithSymbol(order.AmountCent),
"{{buyer_name}}", buyerName,
"{{buyer_phone}}", maskPhone(user.Phone),
"{{unit}}", snap.Unit,
"{{unit}}", unit,
)
// 下单时间用订单创建时间更准确
created := order.CreatedAt
if !created.IsZero() {
replacer = strings.NewReplacer(
"{{order_no}}", order.OrderNo,
"{{created_at}}", created.In(timeutil.ShanghaiLocation()).Format("2006-01-02 15:04:05"),
"{{product_title}}", snap.Title,
"{{quantity}}", fmt.Sprintf("%d", order.Quantity),
"{{amount}}", money.FormatWithSymbol(order.AmountCent),
"{{buyer_name}}", buyerName,
"{{buyer_phone}}", maskPhone(user.Phone),
"{{unit}}", snap.Unit,
)
}
return replacer.Replace(template)
}
func formatItemsForCopy(items []orderItemSnapshot, snap productSnapshot, fallbackQty int) string {
if len(items) == 0 && snap.ID > 0 {
items = []orderItemSnapshot{{
ID: snap.ID, Title: snap.Title, PriceCent: snap.PriceCent,
Unit: snap.Unit, Quantity: fallbackQty,
}}
}
if len(items) == 0 {
return "-"
}
lines := make([]string, 0, len(items))
for _, item := range items {
lineAmount := item.PriceCent * int64(item.Quantity)
unit := strings.TrimSpace(item.Unit)
if unit == "" {
unit = "件"
}
lines = append(lines, fmt.Sprintf(
"- %s ×%d%s 单价%s 小计%s",
item.Title,
item.Quantity,
unit,
money.FormatWithSymbol(item.PriceCent),
money.FormatWithSymbol(lineAmount),
))
}
return strings.Join(lines, "\n")
}
func decodeOrderItems(order model.MohongOrder) []orderItemSnapshot {
if len(order.ItemsSnapshot) > 0 {
var items []orderItemSnapshot
if err := json.Unmarshal(order.ItemsSnapshot, &items); err == nil && len(items) > 0 {
return items
}
}
// 兼容旧订单:从 product_snapshot 还原单行
snap := decodeProductSnapshot(order.ProductSnapshot)
if snap.ID == 0 && order.ProductID == 0 {
return nil
}
qty := order.Quantity
if qty < 1 {
qty = 1
}
id := snap.ID
if id == 0 {
id = order.ProductID
}
price := snap.PriceCent
if price <= 0 {
price = order.UnitPriceCent
}
return []orderItemSnapshot{{
ID: id,
Title: firstNonEmpty(snap.Title, "商品"),
CoverURL: snap.CoverURL,
ImageURLs: snap.ImageURLs,
Description: snap.Description,
PriceCent: price,
Unit: snap.Unit,
QrcodeImageURL: snap.QrcodeImageURL,
Quantity: qty,
}}
}
func resolveOrderQrcode(items []orderItemSnapshot, snap productSnapshot) string {
for _, item := range items {
if strings.TrimSpace(item.QrcodeImageURL) != "" {
return item.QrcodeImageURL
}
}
return snap.QrcodeImageURL
}
func newMohongOrderNo() (string, error) {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
+39 -4
View File
@@ -2,6 +2,7 @@ package mohong
import (
"encoding/json"
"fmt"
"strings"
"hfb_sys/backend/internal/model"
@@ -42,7 +43,40 @@ func toProductDTOWithCategory(row model.MohongProduct, categoryName string, incl
return dto
}
func toOrderDTO(row model.MohongOrder, snap productSnapshot, buyerNickname, buyerPhone string, includeAdmin bool) OrderDTO {
func toOrderDTO(row model.MohongOrder, snap productSnapshot, items []orderItemSnapshot, buyerNickname, buyerPhone string, includeAdmin bool) OrderDTO {
if len(items) == 0 {
items = decodeOrderItems(row)
}
title := snap.Title
cover := snap.CoverURL
unit := snap.Unit
if title == "" && len(items) > 0 {
title = items[0].Title
}
if cover == "" && len(items) > 0 {
cover = items[0].CoverURL
}
if unit == "" && len(items) > 0 {
unit = items[0].Unit
}
if len(items) > 1 && title != "" {
title = fmt.Sprintf("%s 等%d种", title, len(items))
}
itemDTOs := make([]OrderItemDTO, 0, len(items))
for _, item := range items {
lineAmount := item.PriceCent * int64(item.Quantity)
itemDTOs = append(itemDTOs, OrderItemDTO{
ProductID: item.ID,
Title: item.Title,
CoverURL: item.CoverURL,
Unit: item.Unit,
Quantity: item.Quantity,
UnitPriceCent: item.PriceCent,
UnitPrice: money.Format(item.PriceCent),
AmountCent: lineAmount,
Amount: money.Format(lineAmount),
})
}
dto := OrderDTO{
ID: row.ID,
OrderNo: row.OrderNo,
@@ -54,9 +88,10 @@ func toOrderDTO(row model.MohongOrder, snap productSnapshot, buyerNickname, buye
AmountCent: row.AmountCent,
Amount: money.Format(row.AmountCent),
Status: row.Status,
ProductTitle: snap.Title,
ProductCoverURL: snap.CoverURL,
ProductUnit: snap.Unit,
ProductTitle: title,
ProductCoverURL: cover,
ProductUnit: unit,
Items: itemDTOs,
QrcodeURLSnapshot: row.QrcodeURLSnapshot,
CopyText: row.CopyText,
CancelReason: row.CancelReason,
@@ -0,0 +1,15 @@
-- +goose Up
-- 摸大红订单支持多商品:商品行快照(兼容旧单:无 items 时仍读 product_snapshot
ALTER TABLE mohong_orders
ADD COLUMN items_snapshot JSON NULL COMMENT '订单商品行快照[{product_id,title,quantity,unit_price_cent,...}]' AFTER product_snapshot;
UPDATE system_configs
SET `value` = '【摸大红订单】\n订单号:{{order_no}}\n下单时间:{{created_at}}\n商品明细:\n{{items}}\n数量合计:{{quantity}}\n金额:{{amount}}\n下单人:{{buyer_name}}{{buyer_phone}}',
description = '摸大红订单一键复制文案模板(支持多商品 {{items}})'
WHERE `key` = 'mohong.order_copy_template'
AND (`value` LIKE '%商品:{{product_title}}%' OR `value` = '');
-- +goose Down
ALTER TABLE mohong_orders DROP COLUMN items_snapshot;