Files
yml2213 6450b31129 撞车订单新增「接待中」状态,支持后台接待标记
已支付订单可标记为接待中,避免重复跟进;完成/取消覆盖接待中状态,前后台同步展示与筛选。
2026-07-19 16:08:41 +08:00

548 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mohong
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/timeutil"
"hfb_sys/backend/pkg/money"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (r *Repository) CreateOrder(ctx context.Context, userID uint64, req CreateOrderRequest) (*OrderDTO, error) {
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 {
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
}
primaryRaw, err := json.Marshal(primary)
if err != nil {
return err
}
itemsRaw, err := json.Marshal(itemSnaps)
if err != nil {
return err
}
// 展示字段:单商品保持原语义;多商品 quantity 为合计,unit_price 用首项单价。
order := model.MohongOrder{
OrderNo: orderNo,
UserID: userID,
ProductID: primary.ID,
Quantity: totalQty,
UnitPriceCent: primary.PriceCent,
AmountCent: totalAmount,
Status: model.MohongOrderStatusPendingPayment,
ProductSnapshot: datatypes.JSON(primaryRaw),
ItemsSnapshot: datatypes.JSON(itemsRaw),
CopyText: "",
}
if err := tx.Create(&order).Error; err != nil {
return err
}
orderID := order.ID
if err := notification.Append(tx, notification.Entry{
UserID: userID,
Type: "order",
Title: "撞车订单已创建",
Content: "订单已创建,请在有效时间内完成支付。",
BizType: "mohong_order",
BizID: &orderID,
}); err != nil {
return err
}
createdID = order.ID
return nil
})
if err != nil {
return nil, err
}
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 {
if err == gorm.ErrRecordNotFound {
return nil, ErrOrderNotFound
}
return nil, err
}
return r.orderDTO(ctx, row, false)
}
func (r *Repository) AdminFindOrder(ctx context.Context, orderID uint64) (*OrderDTO, error) {
var row model.MohongOrder
if err := r.db.WithContext(ctx).First(&row, orderID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrOrderNotFound
}
return nil, err
}
return r.orderDTO(ctx, row, true)
}
func (r *Repository) ListOrdersForUser(ctx context.Context, userID uint64, query OrderListQuery) (*PaginatedResult, error) {
query.UserID = userID
return r.listOrders(ctx, query, false)
}
func (r *Repository) AdminListOrders(ctx context.Context, query OrderListQuery) (*PaginatedResult, error) {
return r.listOrders(ctx, query, true)
}
func (r *Repository) listOrders(ctx context.Context, query OrderListQuery, includeAdmin bool) (*PaginatedResult, error) {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
db := r.db.WithContext(ctx).Model(&model.MohongOrder{})
if query.UserID > 0 {
db = db.Where("user_id = ?", query.UserID)
}
if status := strings.TrimSpace(query.Status); status != "" {
db = db.Where("status = ?", status)
}
if kw := strings.TrimSpace(query.Keyword); kw != "" {
like := "%" + kw + "%"
db = db.Where("order_no LIKE ? OR CAST(id AS CHAR) = ?", like, kw)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.MohongOrder
if err := db.Order("id DESC").
Offset((query.Page - 1) * query.PageSize).
Limit(query.PageSize).
Find(&rows).Error; err != nil {
return nil, err
}
items := make([]OrderDTO, 0, len(rows))
for _, row := range rows {
dto, err := r.orderDTO(ctx, row, includeAdmin)
if err != nil {
return nil, err
}
items = append(items, *dto)
}
return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil
}
func (r *Repository) CancelOrder(ctx context.Context, userID, orderID uint64, reason string) (*OrderDTO, error) {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.MohongOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND user_id = ?", orderID, userID).
First(&order).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return ErrOrderNotFound
}
return err
}
if order.Status != model.MohongOrderStatusPendingPayment {
return ErrOrderCannotCancel
}
return r.cancelPendingOrderTx(tx, &order, firstNonEmpty(reason, "用户取消"))
})
if err != nil {
return nil, err
}
return r.FindOrderForUser(ctx, userID, orderID)
}
func (r *Repository) AdminCancelOrder(ctx context.Context, orderID uint64, reason string) (*OrderDTO, error) {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.MohongOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return ErrOrderNotFound
}
return err
}
if order.Status != model.MohongOrderStatusPendingPayment &&
order.Status != model.MohongOrderStatusPaid &&
order.Status != model.MohongOrderStatusReceiving {
return ErrOrderCannotCancel
}
if order.Status == model.MohongOrderStatusPendingPayment {
return r.cancelPendingOrderTx(tx, &order, firstNonEmpty(reason, "后台取消"))
}
// 已支付/接待中:仅标记取消(退款后续可接支付退款)。
now := time.Now()
order.Status = model.MohongOrderStatusCancelled
order.CancelledAt = &now
order.CancelReason = firstNonEmpty(reason, "后台取消")
return tx.Save(&order).Error
})
if err != nil {
return nil, err
}
return r.AdminFindOrder(ctx, orderID)
}
// AdminReceiveOrder 标记订单为接待中(已支付 → 接待中)。
func (r *Repository) AdminReceiveOrder(ctx context.Context, orderID uint64, remark string) (*OrderDTO, error) {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.MohongOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return ErrOrderNotFound
}
return err
}
if order.Status != model.MohongOrderStatusPaid {
return ErrOrderCannotReceive
}
order.Status = model.MohongOrderStatusReceiving
if strings.TrimSpace(remark) != "" {
order.AdminRemark = strings.TrimSpace(remark)
}
return tx.Save(&order).Error
})
if err != nil {
return nil, err
}
return r.AdminFindOrder(ctx, orderID)
}
func (r *Repository) AdminCompleteOrder(ctx context.Context, orderID uint64, remark string) (*OrderDTO, error) {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var order model.MohongOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return ErrOrderNotFound
}
return err
}
if order.Status != model.MohongOrderStatusPaid && order.Status != model.MohongOrderStatusReceiving {
return ErrOrderCannotComplete
}
now := time.Now()
order.Status = model.MohongOrderStatusCompleted
order.CompletedAt = &now
if strings.TrimSpace(remark) != "" {
order.AdminRemark = strings.TrimSpace(remark)
}
return tx.Save(&order).Error
})
if err != nil {
return nil, err
}
return r.AdminFindOrder(ctx, orderID)
}
// ConfirmPaidFromChannelTx 支付成功后推进撞车订单:固化二维码快照与可复制文案。
// 返回值固定为 0,兼容支付模块会话通知签名(撞车无订单群)。
func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint64, error) {
var order model.MohongOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil {
return 0, err
}
if order.Status == model.MohongOrderStatusPaid ||
order.Status == model.MohongOrderStatusReceiving ||
order.Status == model.MohongOrderStatusCompleted {
return 0, nil
}
if order.Status != model.MohongOrderStatusPendingPayment {
return 0, ErrOrderCannotPay
}
var user model.User
if err := tx.First(&user, order.UserID).Error; err != nil {
return 0, err
}
items := decodeOrderItems(order)
snap := decodeProductSnapshot(order.ProductSnapshot)
qrcodeURL := r.resolveQrcodeURL(tx, resolveOrderQrcode(items, snap))
copyText := buildCopyText(r.loadCopyTemplate(tx), order, items, snap, user)
now := time.Now()
order.Status = model.MohongOrderStatusPaid
order.PaidAt = &now
order.QrcodeURLSnapshot = qrcodeURL
order.CopyText = copyText
if err := tx.Save(&order).Error; err != nil {
return 0, err
}
orderIDCopy := order.ID
if err := notification.Append(tx, notification.Entry{
UserID: order.UserID,
Type: "order",
Title: "撞车订单支付成功",
Content: "支付已完成,请在订单详情扫码联系客服,并复制订单信息发送。",
BizType: "mohong_order",
BizID: &orderIDCopy,
}); err != nil {
return 0, err
}
return 0, nil
}
// NotifyNewConversation 兼容支付模块接口;撞车不创建会话,空实现。
func (r *Repository) NotifyNewConversation(conversationID uint64) {}
func (r *Repository) cancelPendingOrderTx(tx *gorm.DB, order *model.MohongOrder, reason string) error {
// 归还预扣库存(支持多商品行)
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, item.ID).Error; err != nil {
continue
}
if product.Stock >= 0 {
product.Stock += item.Quantity
if err := tx.Save(&product).Error; err != nil {
return err
}
}
}
now := time.Now()
order.Status = model.MohongOrderStatusCancelled
order.CancelledAt = &now
order.CancelReason = reason
return tx.Save(order).Error
}
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, items, nickname, phone, includeAdmin)
return &dto, nil
}
func buildCopyText(template string, order model.MohongOrder, items []orderItemSnapshot, snap productSnapshot, user model.User) string {
if strings.TrimSpace(template) == "" {
template = defaultOrderCopyTemplate
}
buyerName := strings.TrimSpace(user.Nickname)
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}}", 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}}", 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 {
return "", err
}
return fmt.Sprintf("MH%s%s", time.Now().Format("20060102150405"), strings.ToUpper(hex.EncodeToString(b[:]))), nil
}