Files
hfb_sys/backend/internal/modules/mohong/order_repo.go
T
yml2213 e07532a188 移除摸大红支付后建群逻辑,仅保留扫码与复制
支付成功只固化客服二维码与订单文案;同步清理迁移、配置与前后端相关字段。
2026-07-16 19:52:40 +08:00

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
}