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 { return nil, ErrInvalidRequest } reqItems := normalizeCreateOrderItems(req) if len(reqItems) == 0 { return nil, ErrInvalidRequest } var createdID uint64 err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { itemSnaps := make([]orderItemSnapshot, 0, len(reqItems)) var totalQty int var totalAmount int64 var primary productSnapshot for _, line := range reqItems { var product model.MohongProduct if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, line.ProductID).Error; err != nil { if err == gorm.ErrRecordNotFound { return ErrProductNotFound } return err } if product.Status != model.MohongProductStatusOnSale || product.PriceCent <= 0 { return ErrProductUnavailable } if product.Stock >= 0 && product.Stock < line.Quantity { return ErrStockInsufficient } // 下单时预扣库存,取消未支付订单时归还。 if product.Stock >= 0 { product.Stock -= line.Quantity if err := tx.Save(&product).Error; err != nil { return err } } images := decodeStringList(product.ImageURLs) item := orderItemSnapshot{ ID: product.ID, Title: product.Title, CoverURL: product.CoverURL, ImageURLs: images, Description: product.Description, PriceCent: product.PriceCent, Unit: product.Unit, QrcodeImageURL: product.QrcodeImageURL, Quantity: line.Quantity, } itemSnaps = append(itemSnaps, item) totalQty += line.Quantity totalAmount += product.PriceCent * int64(line.Quantity) if primary.ID == 0 { primary = productSnapshot{ ID: product.ID, Title: product.Title, CoverURL: product.CoverURL, ImageURLs: images, Description: product.Description, PriceCent: product.PriceCent, Unit: product.Unit, QrcodeImageURL: product.QrcodeImageURL, } } } orderNo, err := newMohongOrderNo() if err != nil { return err } primaryRaw, err := json.Marshal(primary) if err != nil { return err } itemsRaw, err := json.Marshal(itemSnaps) if err != nil { return err } // 展示字段:单商品保持原语义;多商品 quantity 为合计,unit_price 用首项单价。 order := model.MohongOrder{ OrderNo: orderNo, UserID: userID, ProductID: primary.ID, Quantity: totalQty, UnitPriceCent: primary.PriceCent, AmountCent: totalAmount, Status: model.MohongOrderStatusPendingPayment, ProductSnapshot: datatypes.JSON(primaryRaw), ItemsSnapshot: datatypes.JSON(itemsRaw), 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 normalizeCreateOrderItems(req CreateOrderRequest) []CreateOrderItemRequest { // 优先 items;否则兼容旧单商品参数。 if len(req.Items) > 0 { merged := map[uint64]int{} orderIDs := make([]uint64, 0, len(req.Items)) for _, item := range req.Items { if item.ProductID == 0 || item.Quantity < 1 || item.Quantity > 99 { continue } if _, ok := merged[item.ProductID]; !ok { orderIDs = append(orderIDs, item.ProductID) } merged[item.ProductID] += item.Quantity if merged[item.ProductID] > 99 { merged[item.ProductID] = 99 } } out := make([]CreateOrderItemRequest, 0, len(orderIDs)) for _, id := range orderIDs { if qty := merged[id]; qty > 0 { out = append(out, CreateOrderItemRequest{ProductID: id, Quantity: qty}) } } if len(out) > 50 { return out[:50] } return out } if req.ProductID > 0 && req.Quantity >= 1 && req.Quantity <= 99 { return []CreateOrderItemRequest{{ProductID: req.ProductID, Quantity: req.Quantity}} } return nil } 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 && order.Status != model.MohongOrderStatusReceiving { 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) } // AdminReceiveOrder 标记订单为接待中(已支付 → 接待中)。 func (r *Repository) AdminReceiveOrder(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 ErrOrderCannotReceive } order.Status = model.MohongOrderStatusReceiving 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) } 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 && order.Status != model.MohongOrderStatusReceiving { 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.MohongOrderStatusReceiving || 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 } items := decodeOrderItems(order) snap := decodeProductSnapshot(order.ProductSnapshot) qrcodeURL := r.resolveQrcodeURL(tx, resolveOrderQrcode(items, snap)) copyText := buildCopyText(r.loadCopyTemplate(tx), order, items, 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 { // 归还预扣库存(支持多商品行) for _, item := range decodeOrderItems(*order) { if item.Quantity <= 0 || item.ID == 0 { continue } var product model.MohongProduct if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, item.ID).Error; err != nil { continue } if product.Stock >= 0 { product.Stock += item.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) items := decodeOrderItems(row) 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, items, nickname, phone, includeAdmin) return &dto, nil } func buildCopyText(template string, order model.MohongOrder, items []orderItemSnapshot, snap productSnapshot, user model.User) string { if strings.TrimSpace(template) == "" { template = defaultOrderCopyTemplate } buyerName := strings.TrimSpace(user.Nickname) if buyerName == "" { buyerName = "用户" } createdAt := timeutil.ShanghaiNow().Format("2006-01-02 15:04:05") if !order.CreatedAt.IsZero() { createdAt = order.CreatedAt.In(timeutil.ShanghaiLocation()).Format("2006-01-02 15:04:05") } productTitle := snap.Title if productTitle == "" && len(items) > 0 { productTitle = items[0].Title } if len(items) > 1 { productTitle = fmt.Sprintf("%s 等%d种", productTitle, len(items)) } unit := snap.Unit if unit == "" && len(items) > 0 { unit = items[0].Unit } replacer := strings.NewReplacer( "{{order_no}}", order.OrderNo, "{{created_at}}", createdAt, "{{product_title}}", productTitle, "{{items}}", formatItemsForCopy(items, snap, order.Quantity), "{{quantity}}", fmt.Sprintf("%d", order.Quantity), "{{amount}}", money.FormatWithSymbol(order.AmountCent), "{{buyer_name}}", buyerName, "{{buyer_phone}}", maskPhone(user.Phone), "{{unit}}", unit, ) return replacer.Replace(template) } func formatItemsForCopy(items []orderItemSnapshot, snap productSnapshot, fallbackQty int) string { if len(items) == 0 && snap.ID > 0 { items = []orderItemSnapshot{{ ID: snap.ID, Title: snap.Title, PriceCent: snap.PriceCent, Unit: snap.Unit, Quantity: fallbackQty, }} } if len(items) == 0 { return "-" } lines := make([]string, 0, len(items)) for _, item := range items { lineAmount := item.PriceCent * int64(item.Quantity) unit := strings.TrimSpace(item.Unit) if unit == "" { unit = "件" } lines = append(lines, fmt.Sprintf( "- %s ×%d%s 单价%s 小计%s", item.Title, item.Quantity, unit, money.FormatWithSymbol(item.PriceCent), money.FormatWithSymbol(lineAmount), )) } return strings.Join(lines, "\n") } func decodeOrderItems(order model.MohongOrder) []orderItemSnapshot { if len(order.ItemsSnapshot) > 0 { var items []orderItemSnapshot if err := json.Unmarshal(order.ItemsSnapshot, &items); err == nil && len(items) > 0 { return items } } // 兼容旧订单:从 product_snapshot 还原单行 snap := decodeProductSnapshot(order.ProductSnapshot) if snap.ID == 0 && order.ProductID == 0 { return nil } qty := order.Quantity if qty < 1 { qty = 1 } id := snap.ID if id == 0 { id = order.ProductID } price := snap.PriceCent if price <= 0 { price = order.UnitPriceCent } return []orderItemSnapshot{{ ID: id, Title: firstNonEmpty(snap.Title, "商品"), CoverURL: snap.CoverURL, ImageURLs: snap.ImageURLs, Description: snap.Description, PriceCent: price, Unit: snap.Unit, QrcodeImageURL: snap.QrcodeImageURL, Quantity: qty, }} } func resolveOrderQrcode(items []orderItemSnapshot, snap productSnapshot) string { for _, item := range items { if strings.TrimSpace(item.QrcodeImageURL) != "" { return item.QrcodeImageURL } } return snap.QrcodeImageURL } 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 }