548 lines
16 KiB
Go
548 lines
16 KiB
Go
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
|
||
}
|