379 lines
12 KiB
Go
379 lines
12 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 || req.ProductID == 0 || req.Quantity < 1 || req.Quantity > 99 {
|
|
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 {
|
|
return err
|
|
}
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
amount := product.PriceCent * int64(req.Quantity)
|
|
order := model.MohongOrder{
|
|
OrderNo: orderNo,
|
|
UserID: userID,
|
|
ProductID: product.ID,
|
|
Quantity: req.Quantity,
|
|
UnitPriceCent: product.PriceCent,
|
|
AmountCent: amount,
|
|
Status: model.MohongOrderStatusPendingPayment,
|
|
ProductSnapshot: datatypes.JSON(snapRaw),
|
|
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 (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 {
|
|
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)
|
|
}
|
|
|
|
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 {
|
|
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.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
|
|
}
|
|
snap := decodeProductSnapshot(order.ProductSnapshot)
|
|
qrcodeURL := r.resolveQrcodeURL(tx, snap.QrcodeImageURL)
|
|
copyText := buildCopyText(r.loadCopyTemplate(tx), order, 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 {
|
|
// 归还预扣库存
|
|
if order.Quantity > 0 {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
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)
|
|
return &dto, nil
|
|
}
|
|
|
|
func buildCopyText(template string, order model.MohongOrder, snap productSnapshot, user model.User) string {
|
|
if strings.TrimSpace(template) == "" {
|
|
template = defaultOrderCopyTemplate
|
|
}
|
|
buyerName := strings.TrimSpace(user.Nickname)
|
|
if buyerName == "" {
|
|
buyerName = "用户"
|
|
}
|
|
replacer := strings.NewReplacer(
|
|
"{{order_no}}", order.OrderNo,
|
|
"{{created_at}}", timeutil.ShanghaiNow().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,
|
|
)
|
|
// 下单时间用订单创建时间更准确
|
|
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 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
|
|
}
|