diff --git a/backend/internal/model/mohong.go b/backend/internal/model/mohong.go
index d14762f..5ecc3f0 100644
--- a/backend/internal/model/mohong.go
+++ b/backend/internal/model/mohong.go
@@ -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"`
diff --git a/backend/internal/modules/mohong/config.go b/backend/internal/modules/mohong/config.go
index 436939b..e484fc6 100644
--- a/backend/internal/modules/mohong/config.go
+++ b/backend/internal/modules/mohong/config.go
@@ -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) {
diff --git a/backend/internal/modules/mohong/dto.go b/backend/internal/modules/mohong/dto.go
index e05e9ae..fe3a93e 100644
--- a/backend/internal/modules/mohong/dto.go
+++ b/backend/internal/modules/mohong/dto.go
@@ -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"`
+}
diff --git a/backend/internal/modules/mohong/order_repo.go b/backend/internal/modules/mohong/order_repo.go
index 4666d9e..5f71524 100644
--- a/backend/internal/modules/mohong/order_repo.go
+++ b/backend/internal/modules/mohong/order_repo.go
@@ -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 {
diff --git a/backend/internal/modules/mohong/presenter.go b/backend/internal/modules/mohong/presenter.go
index 52c716c..2af8996 100644
--- a/backend/internal/modules/mohong/presenter.go
+++ b/backend/internal/modules/mohong/presenter.go
@@ -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,
diff --git a/backend/migrations/000031_mohong_order_items.sql b/backend/migrations/000031_mohong_order_items.sql
new file mode 100644
index 0000000..5b7eb61
--- /dev/null
+++ b/backend/migrations/000031_mohong_order_items.sql
@@ -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;
diff --git a/frontend/src/features/admin/views/AdminMohongConfigView.vue b/frontend/src/features/admin/views/AdminMohongConfigView.vue
index c3adcbc..9add081 100644
--- a/frontend/src/features/admin/views/AdminMohongConfigView.vue
+++ b/frontend/src/features/admin/views/AdminMohongConfigView.vue
@@ -90,8 +90,9 @@ async function uploadQrcode(file: File) {
还没有选购商品
+¥{{ line.price || formatCent(line.price_cent) }} / {{ line.unit }}
+还没有选购商品
+¥{{ line.price || formatCent(line.price_cent) }}
++
¥{{ order.unit_price || formatCent(order.unit_price_cent) }} × {{ order.quantity }} {{ order.product_unit }}
+共 {{ order.items.length }} 种 · {{ order.quantity }} 件
合计 ¥{{ order.amount || formatCent(order.amount_cent) }}购买须知
+
¥{{ order.unit_price || formatCent(order.unit_price_cent) }} × {{ order.quantity }} {{ order.product_unit }}
+共 {{ order.items.length }} 种 · {{ order.quantity }} 件
合计 ¥{{ order.amount || formatCent(order.amount_cent) }}