新增摸大红业务并修复后台链接按钮白字
支持分类筛选、搜索、下单支付建群与固定二维码;合并迁移种子数据;后台表格编辑/详情链接恢复主题色可见。
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user