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 }