Files
hfb_sys/backend/internal/modules/mohong/order_repo.go
T
yml2213 6d61027318 新增摸大红业务并修复后台链接按钮白字
支持分类筛选、搜索、下单支付建群与固定二维码;合并迁移种子数据;后台表格编辑/详情链接恢复主题色可见。
2026-07-16 18:08:18 +08:00

471 lines
14 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/chat"
"hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/modules/supportgroup"
"hfb_sys/backend/internal/timeutil"
"hfb_sys/backend/pkg/money"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const ConversationTypeMohongOrder = "mohong_order"
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 支付成功后推进摸大红订单:建群、发固定码、写复制文案。
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 {
if order.ConversationID != nil {
return *order.ConversationID, nil
}
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()
conv, err := ensureMohongOrderConversation(tx, order, user)
if err != nil {
return 0, err
}
welcome := r.loadWelcomeText(tx)
if err := chat.SendSystemMessageForTx(tx, conv.ID, welcome); err != nil {
return 0, err
}
if qrcodeURL != "" {
if err := chat.SendImageMessageForTx(tx, conv.ID, "👇 请扫码联系客服", qrcodeURL); err != nil {
return 0, err
}
} else {
if err := chat.SendSystemMessageForTx(tx, conv.ID, "固定二维码尚未配置,请将下方订单信息复制后联系客服。"); err != nil {
return 0, err
}
}
if copyText != "" {
if err := chat.SendSystemMessageForTx(tx, conv.ID, copyText+"\n\n(长按/复制后发送给客服)"); err != nil {
return 0, err
}
}
order.Status = model.MohongOrderStatusPaid
order.PaidAt = &now
order.QrcodeURLSnapshot = qrcodeURL
order.CopyText = copyText
order.ConversationID = &conv.ID
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 conv.ID, nil
}
func (r *Repository) NotifyNewConversation(conversationID uint64) {
if conversationID > 0 && r.chatNotifier != nil {
r.chatNotifier.NotifyNewConversation(conversationID)
}
}
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 ensureMohongOrderConversation(tx *gorm.DB, order model.MohongOrder, user model.User) (*model.ChatConversation, error) {
var existing model.ChatConversation
err := tx.Where("mohong_order_id = ?", order.ID).First(&existing).Error
if err == nil {
return &existing, nil
}
if err != gorm.ErrRecordNotFound {
return nil, err
}
now := time.Now()
title := "摸大红 " + order.OrderNo
if name := strings.TrimSpace(user.Nickname); name != "" {
title = "摸大红-" + name
}
conversation := model.ChatConversation{
MohongOrderID: &order.ID,
Type: ConversationTypeMohongOrder,
Title: title,
Status: "active",
}
if err := tx.Create(&conversation).Error; err != nil {
return nil, err
}
participants := []model.ChatParticipant{
{
ConversationID: conversation.ID,
ParticipantType: "user",
ParticipantID: order.UserID,
Role: "buyer",
JoinedAt: now,
LastReadAt: &now,
},
}
supportID, err := supportgroup.PickSupportAdmin(tx, supportgroup.GroupCodeRenterHandoff)
if err != nil {
return nil, err
}
if supportID <= 0 {
supportID = chat.DefaultSupportAdminID(tx)
}
if supportID > 0 {
participants = append(participants, model.ChatParticipant{
ConversationID: conversation.ID,
ParticipantType: "admin",
ParticipantID: supportID,
Role: "support",
JoinedAt: now,
})
}
for _, p := range participants {
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&p).Error; err != nil {
return nil, err
}
}
return &conversation, 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
}