新增摸大红业务并修复后台链接按钮白字

支持分类筛选、搜索、下单支付建群与固定二维码;合并迁移种子数据;后台表格编辑/详情链接恢复主题色可见。
This commit is contained in:
yml2213
2026-07-16 18:08:18 +08:00
parent 97a34e0329
commit 6d61027318
49 changed files with 9533 additions and 20 deletions
+1
View File
@@ -10,6 +10,7 @@ type ChatConversation struct {
ID uint64 `gorm:"primaryKey" json:"id"`
OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"`
ListingID *uint64 `gorm:"index" json:"listing_id"`
MohongOrderID *uint64 `gorm:"column:mohong_order_id;uniqueIndex" json:"mohong_order_id"`
Type string `gorm:"size:32;not null;default:'order_group'" json:"type"`
Title string `gorm:"size:128;not null" json:"title"`
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
+98
View File
@@ -0,0 +1,98 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// MohongCategory 摸大红商品分类。
type MohongCategory struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64;not null;uniqueIndex" json:"name"`
Code string `gorm:"size:64;not null;default:''" json:"code"`
SortOrder int `gorm:"column:sort_order;not null;default:0" json:"sort_order"`
Status string `gorm:"size:16;not null;default:'enabled'" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (MohongCategory) TableName() string {
return "mohong_categories"
}
// MohongProductCategory 商品与分类多对多(同一商品可出现在大红/九格等)。
type MohongProductCategory struct {
ProductID uint64 `gorm:"column:product_id;primaryKey" json:"product_id"`
CategoryID uint64 `gorm:"column:category_id;primaryKey;index" json:"category_id"`
}
func (MohongProductCategory) TableName() string {
return "mohong_product_categories"
}
// MohongProduct 摸大红商品。
type MohongProduct struct {
ID uint64 `gorm:"primaryKey" json:"id"`
CategoryID *uint64 `gorm:"column:category_id;index" json:"category_id"` // 主分类(展示用)
Title string `gorm:"size:128;not null" json:"title"`
CoverURL string `gorm:"column:cover_url;size:512;not null;default:''" json:"cover_url"`
ImageURLs datatypes.JSON `gorm:"column:image_urls" json:"image_urls"`
Description string `gorm:"type:text;not null" json:"description"`
PriceCent int64 `gorm:"column:price_cent;not null;default:0" json:"-"`
OriginalPriceCent int64 `gorm:"column:original_price_cent;not null;default:0" json:"-"`
Unit string `gorm:"size:32;not null;default:'份'" json:"unit"`
Stock int `gorm:"not null;default:-1" json:"stock"`
SortOrder int `gorm:"column:sort_order;not null;default:0" json:"sort_order"`
Status string `gorm:"size:16;not null;default:'draft'" json:"status"`
QrcodeImageURL string `gorm:"column:qrcode_image_url;size:512;not null;default:''" json:"qrcode_image_url"`
CreatedBy *uint64 `gorm:"column:created_by" json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (MohongProduct) TableName() string {
return "mohong_products"
}
// MohongOrder 摸大红订单。
type MohongOrder struct {
ID uint64 `gorm:"primaryKey" json:"id"`
OrderNo string `gorm:"size:64;not null;uniqueIndex" json:"order_no"`
UserID uint64 `gorm:"not null;index" json:"user_id"`
ProductID uint64 `gorm:"not null;index" json:"product_id"`
Quantity int `gorm:"not null;default:1" json:"quantity"`
UnitPriceCent int64 `gorm:"column:unit_price_cent;not null;default:0" json:"-"`
AmountCent int64 `gorm:"column:amount_cent;not null;default:0" json:"-"`
Status string `gorm:"size:32;not null;default:'pending_payment';index" json:"status"`
ProductSnapshot datatypes.JSON `gorm:"column:product_snapshot" json:"product_snapshot"`
QrcodeURLSnapshot string `gorm:"column:qrcode_url_snapshot;size:512;not null;default:''" json:"qrcode_url_snapshot"`
CopyText string `gorm:"type:text;not null" json:"copy_text"`
ConversationID *uint64 `gorm:"column:conversation_id" json:"conversation_id"`
PaidAt *time.Time `json:"paid_at"`
CompletedAt *time.Time `json:"completed_at"`
CancelledAt *time.Time `json:"cancelled_at"`
CancelReason string `gorm:"size:255;not null;default:''" json:"cancel_reason"`
AdminRemark string `gorm:"size:255;not null;default:''" json:"admin_remark"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (MohongOrder) TableName() string {
return "mohong_orders"
}
const (
MohongCategoryStatusEnabled = "enabled"
MohongCategoryStatusDisabled = "disabled"
MohongProductStatusDraft = "draft"
MohongProductStatusOnSale = "on_sale"
MohongProductStatusOffSale = "off_sale"
MohongOrderStatusPendingPayment = "pending_payment"
MohongOrderStatusPaid = "paid"
MohongOrderStatusCompleted = "completed"
MohongOrderStatusCancelled = "cancelled"
MohongOrderStatusRefunded = "refunded"
)
+36
View File
@@ -0,0 +1,36 @@
package chat
import (
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
// DefaultSupportAdminID 导出默认客服选择逻辑,供其他业务模块建群时复用。
func DefaultSupportAdminID(tx *gorm.DB) uint64 {
return defaultSupportAdminID(tx)
}
// SendSystemMessageForTx 在事务内发送系统文本消息。
func SendSystemMessageForTx(tx *gorm.DB, conversationID uint64, content string) error {
return sendSystemMessage(tx, conversationID, content)
}
// SendImageMessageForTx 在事务内发送系统图片消息。
func SendImageMessageForTx(tx *gorm.DB, conversationID uint64, content, imageURL string) error {
if content == "" {
content = "👇 请扫码联系客服"
}
message := model.ChatMessage{
ConversationID: conversationID,
SenderType: "system",
SenderRole: "system",
ContentType: "image",
Content: content,
AttachmentURLS: encodeStringList([]string{imageURL}),
}
if err := tx.Create(&message).Error; err != nil {
return err
}
return updateConversationLastMessage(tx, conversationID, &message)
}
+6 -1
View File
@@ -65,7 +65,12 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
response.BadRequest(c, "文件 key 不正确")
return
}
if publicOnly && !strings.HasPrefix(key, "home-banner/") && !strings.HasPrefix(key, "avatar/") && !strings.HasPrefix(key, "payment-cert/") && !strings.HasPrefix(key, "announcement/") {
if publicOnly &&
!strings.HasPrefix(key, "home-banner/") &&
!strings.HasPrefix(key, "avatar/") &&
!strings.HasPrefix(key, "payment-cert/") &&
!strings.HasPrefix(key, "announcement/") &&
!strings.HasPrefix(key, "mohong/") {
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
return
}
+2 -2
View File
@@ -106,7 +106,7 @@ func normalizeContentType(contentType string, data []byte) string {
func fileURLForScene(scene string, key string) string {
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
if scene == "home-banner" || scene == "avatar" || scene == "payment-cert" || scene == "announcement" {
if scene == "home-banner" || scene == "avatar" || scene == "payment-cert" || scene == "announcement" || scene == "mohong" {
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
}
return fileURL
@@ -115,7 +115,7 @@ func fileURLForScene(scene string, key string) string {
func normalizeScene(scene string) string {
scene = strings.TrimSpace(strings.ToLower(scene))
switch scene {
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode":
case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong":
return scene
default:
return "misc"
@@ -0,0 +1,204 @@
package mohong
import (
"context"
"strings"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
func (r *Repository) ListPublicCategories(ctx context.Context) ([]CategoryDTO, error) {
var rows []model.MohongCategory
if err := r.db.WithContext(ctx).
Where("status = ?", model.MohongCategoryStatusEnabled).
Order("sort_order ASC, id ASC").
Find(&rows).Error; err != nil {
return nil, err
}
return r.attachCategoryCounts(ctx, rows, true)
}
func (r *Repository) AdminListCategories(ctx context.Context) ([]CategoryDTO, error) {
var rows []model.MohongCategory
if err := r.db.WithContext(ctx).
Order("sort_order ASC, id ASC").
Find(&rows).Error; err != nil {
return nil, err
}
return r.attachCategoryCounts(ctx, rows, false)
}
func (r *Repository) attachCategoryCounts(ctx context.Context, rows []model.MohongCategory, onlyOnSale bool) ([]CategoryDTO, error) {
type countRow struct {
CategoryID uint64
Cnt int64
}
var counts []countRow
// 优先按多对多关联统计;兼容仅写了主分类 category_id 的旧数据
q := r.db.WithContext(ctx).Table("mohong_product_categories AS pc").
Select("pc.category_id AS category_id, COUNT(DISTINCT pc.product_id) AS cnt").
Joins("JOIN mohong_products p ON p.id = pc.product_id")
if onlyOnSale {
q = q.Where("p.status = ?", model.MohongProductStatusOnSale)
}
if err := q.Group("pc.category_id").Scan(&counts).Error; err != nil {
return nil, err
}
// 补充仅有主分类、未写入关联表的商品
var fallback []countRow
fq := r.db.WithContext(ctx).Model(&model.MohongProduct{}).
Select("category_id AS category_id, COUNT(*) AS cnt").
Where("category_id IS NOT NULL").
Where("id NOT IN (SELECT product_id FROM mohong_product_categories)")
if onlyOnSale {
fq = fq.Where("status = ?", model.MohongProductStatusOnSale)
}
_ = fq.Group("category_id").Scan(&fallback).Error
for _, c := range fallback {
counts = append(counts, c)
}
countMap := map[uint64]int64{}
for _, c := range counts {
countMap[c.CategoryID] = c.Cnt
}
items := make([]CategoryDTO, 0, len(rows))
for _, row := range rows {
items = append(items, CategoryDTO{
ID: row.ID,
Name: row.Name,
Code: row.Code,
SortOrder: row.SortOrder,
Status: row.Status,
ProductCount: countMap[row.ID],
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
})
}
return items, nil
}
func (r *Repository) CreateCategory(ctx context.Context, req CreateCategoryRequest) (*CategoryDTO, error) {
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, ErrInvalidRequest
}
status := strings.TrimSpace(req.Status)
if status == "" {
status = model.MohongCategoryStatusEnabled
}
if status != model.MohongCategoryStatusEnabled && status != model.MohongCategoryStatusDisabled {
return nil, ErrInvalidRequest
}
row := model.MohongCategory{
Name: name,
Code: strings.TrimSpace(req.Code),
SortOrder: req.SortOrder,
Status: status,
}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
return nil, err
}
return &CategoryDTO{
ID: row.ID,
Name: row.Name,
Code: row.Code,
SortOrder: row.SortOrder,
Status: row.Status,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}, nil
}
func (r *Repository) UpdateCategory(ctx context.Context, id uint64, req UpdateCategoryRequest) (*CategoryDTO, error) {
var row model.MohongCategory
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrInvalidRequest
}
return nil, err
}
updates := map[string]any{}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
if name == "" {
return nil, ErrInvalidRequest
}
updates["name"] = name
}
if req.Code != nil {
updates["code"] = strings.TrimSpace(*req.Code)
}
if req.SortOrder != nil {
updates["sort_order"] = *req.SortOrder
}
if req.Status != nil {
status := strings.TrimSpace(*req.Status)
if status != model.MohongCategoryStatusEnabled && status != model.MohongCategoryStatusDisabled {
return nil, ErrInvalidRequest
}
updates["status"] = status
}
if len(updates) > 0 {
if err := r.db.WithContext(ctx).Model(&row).Updates(updates).Error; err != nil {
return nil, err
}
}
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
return nil, err
}
items, err := r.attachCategoryCounts(ctx, []model.MohongCategory{row}, false)
if err != nil {
return nil, err
}
return &items[0], nil
}
func (r *Repository) DeleteCategory(ctx context.Context, id uint64) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.MohongProduct{}).
Where("category_id = ?", id).
Update("category_id", nil).Error; err != nil {
return err
}
res := tx.Delete(&model.MohongCategory{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrInvalidRequest
}
return nil
})
}
func (r *Repository) loadCategoryNameMap(ctx context.Context, ids []uint64) map[uint64]string {
out := map[uint64]string{}
if len(ids) == 0 {
return out
}
uniq := make([]uint64, 0, len(ids))
seen := map[uint64]struct{}{}
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
uniq = append(uniq, id)
}
if len(uniq) == 0 {
return out
}
var rows []model.MohongCategory
if err := r.db.WithContext(ctx).Where("id IN ?", uniq).Find(&rows).Error; err != nil {
return out
}
for _, row := range rows {
out[row.ID] = row.Name
}
return out
}
+117
View File
@@ -0,0 +1,117 @@
package mohong
import (
"context"
"strings"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
configKeyDefaultQrcodeURL = "mohong.default_qrcode_url"
configKeyGroupWelcomeText = "mohong.group_welcome_text"
configKeyOrderCopyTemplate = "mohong.order_copy_template"
defaultGroupWelcomeText = "订单已支付,请扫码联系客服,并将下方订单信息复制发送给客服。"
defaultOrderCopyTemplate = "【摸大红订单】\n订单号:{{order_no}}\n下单时间:{{created_at}}\n商品:{{product_title}}\n数量:{{quantity}}\n金额:{{amount}}\n下单人:{{buyer_name}}{{buyer_phone}}"
)
func (r *Repository) GetConfig(ctx context.Context) (*ConfigDTO, error) {
if r.db == nil {
return nil, ErrDependencyUnavailable
}
keys := []string{configKeyDefaultQrcodeURL, configKeyGroupWelcomeText, configKeyOrderCopyTemplate}
var rows []model.SystemConfig
if err := r.db.WithContext(ctx).Where("`key` IN ?", keys).Find(&rows).Error; err != nil {
return nil, err
}
values := map[string]string{}
for _, row := range rows {
values[row.Key] = row.Value
}
return &ConfigDTO{
DefaultQrcodeURL: strings.TrimSpace(values[configKeyDefaultQrcodeURL]),
GroupWelcomeText: firstNonEmpty(values[configKeyGroupWelcomeText], defaultGroupWelcomeText),
OrderCopyTemplate: firstNonEmpty(values[configKeyOrderCopyTemplate], defaultOrderCopyTemplate),
}, nil
}
func (r *Repository) UpdateConfig(ctx context.Context, req UpdateConfigRequest) (*ConfigDTO, error) {
if r.db == nil {
return nil, ErrDependencyUnavailable
}
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if req.DefaultQrcodeURL != nil {
if err := upsertConfig(tx, configKeyDefaultQrcodeURL, strings.TrimSpace(*req.DefaultQrcodeURL), "摸大红全局默认固定二维码图片URL"); err != nil {
return err
}
}
if req.GroupWelcomeText != nil {
if err := upsertConfig(tx, configKeyGroupWelcomeText, strings.TrimSpace(*req.GroupWelcomeText), "摸大红订单群欢迎语"); err != nil {
return err
}
}
if req.OrderCopyTemplate != nil {
if err := upsertConfig(tx, configKeyOrderCopyTemplate, strings.TrimSpace(*req.OrderCopyTemplate), "摸大红订单一键复制文案模板"); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return r.GetConfig(ctx)
}
func upsertConfig(tx *gorm.DB, key, value, description string) error {
row := model.SystemConfig{
Key: key,
Value: value,
Description: description,
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value", "description"}),
}).Create(&row).Error
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func (r *Repository) resolveQrcodeURL(tx *gorm.DB, productQrcode string) string {
productQrcode = strings.TrimSpace(productQrcode)
if productQrcode != "" {
return productQrcode
}
var cfg model.SystemConfig
if err := tx.Where("`key` = ?", configKeyDefaultQrcodeURL).First(&cfg).Error; err == nil {
return strings.TrimSpace(cfg.Value)
}
return ""
}
func (r *Repository) loadWelcomeText(tx *gorm.DB) string {
var cfg model.SystemConfig
if err := tx.Where("`key` = ?", configKeyGroupWelcomeText).First(&cfg).Error; err == nil && strings.TrimSpace(cfg.Value) != "" {
return strings.TrimSpace(cfg.Value)
}
return defaultGroupWelcomeText
}
func (r *Repository) loadCopyTemplate(tx *gorm.DB) string {
var cfg model.SystemConfig
if err := tx.Where("`key` = ?", configKeyOrderCopyTemplate).First(&cfg).Error; err == nil && strings.TrimSpace(cfg.Value) != "" {
return strings.TrimSpace(cfg.Value)
}
return defaultOrderCopyTemplate
}
+167
View File
@@ -0,0 +1,167 @@
package mohong
import "time"
type CategoryDTO struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
SortOrder int `json:"sort_order"`
Status string `json:"status"`
ProductCount int64 `json:"product_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateCategoryRequest struct {
Name string `json:"name" binding:"required,max=64"`
Code string `json:"code"`
SortOrder int `json:"sort_order"`
Status string `json:"status"`
}
type UpdateCategoryRequest struct {
Name *string `json:"name"`
Code *string `json:"code"`
SortOrder *int `json:"sort_order"`
Status *string `json:"status"`
}
type ProductDTO struct {
ID uint64 `json:"id"`
CategoryID *uint64 `json:"category_id"`
CategoryName string `json:"category_name,omitempty"`
Title string `json:"title"`
CoverURL string `json:"cover_url"`
ImageURLs []string `json:"image_urls"`
Description string `json:"description"`
PriceCent int64 `json:"price_cent"`
Price string `json:"price"`
OriginalPriceCent int64 `json:"original_price_cent"`
OriginalPrice string `json:"original_price,omitempty"`
Unit string `json:"unit"`
Stock int `json:"stock"`
SortOrder int `json:"sort_order"`
Status string `json:"status"`
QrcodeImageURL string `json:"qrcode_image_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ProductListQuery struct {
Keyword string
Status string
CategoryID uint64
Page int
PageSize int
}
type CreateProductRequest struct {
CategoryID *uint64 `json:"category_id"`
Title string `json:"title" binding:"required,max=128"`
CoverURL string `json:"cover_url"`
ImageURLs []string `json:"image_urls"`
Description string `json:"description"`
PriceCent int64 `json:"price_cent" binding:"required,min=1"`
OriginalPriceCent int64 `json:"original_price_cent"`
Unit string `json:"unit"`
Stock *int `json:"stock"`
SortOrder int `json:"sort_order"`
Status string `json:"status"`
QrcodeImageURL string `json:"qrcode_image_url"`
}
type UpdateProductRequest struct {
CategoryID *uint64 `json:"category_id"`
ClearCategory bool `json:"clear_category"`
Title *string `json:"title"`
CoverURL *string `json:"cover_url"`
ImageURLs []string `json:"image_urls"`
Description *string `json:"description"`
PriceCent *int64 `json:"price_cent"`
OriginalPriceCent *int64 `json:"original_price_cent"`
Unit *string `json:"unit"`
Stock *int `json:"stock"`
SortOrder *int `json:"sort_order"`
Status *string `json:"status"`
QrcodeImageURL *string `json:"qrcode_image_url"`
}
type CreateOrderRequest struct {
ProductID uint64 `json:"product_id" binding:"required"`
Quantity int `json:"quantity" binding:"required,min=1,max=99"`
}
type OrderDTO struct {
ID uint64 `json:"id"`
OrderNo string `json:"order_no"`
UserID uint64 `json:"user_id"`
ProductID uint64 `json:"product_id"`
Quantity int `json:"quantity"`
UnitPriceCent int64 `json:"unit_price_cent"`
UnitPrice string `json:"unit_price"`
AmountCent int64 `json:"amount_cent"`
Amount string `json:"amount"`
Status string `json:"status"`
ProductTitle string `json:"product_title"`
ProductCoverURL string `json:"product_cover_url"`
ProductUnit string `json:"product_unit"`
QrcodeURLSnapshot string `json:"qrcode_url_snapshot"`
CopyText string `json:"copy_text"`
ConversationID *uint64 `json:"conversation_id"`
BuyerNickname string `json:"buyer_nickname,omitempty"`
BuyerPhone string `json:"buyer_phone,omitempty"`
AdminRemark string `json:"admin_remark,omitempty"`
CancelReason string `json:"cancel_reason,omitempty"`
PaidAt *time.Time `json:"paid_at"`
CompletedAt *time.Time `json:"completed_at"`
CancelledAt *time.Time `json:"cancelled_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type OrderListQuery struct {
UserID uint64
Status string
Keyword string
Page int
PageSize int
}
type AdminCompleteOrderRequest struct {
Remark string `json:"remark"`
}
type AdminCancelOrderRequest struct {
Reason string `json:"reason"`
}
type ConfigDTO struct {
DefaultQrcodeURL string `json:"default_qrcode_url"`
GroupWelcomeText string `json:"group_welcome_text"`
OrderCopyTemplate string `json:"order_copy_template"`
}
type UpdateConfigRequest struct {
DefaultQrcodeURL *string `json:"default_qrcode_url"`
GroupWelcomeText *string `json:"group_welcome_text"`
OrderCopyTemplate *string `json:"order_copy_template"`
}
type PaginatedResult struct {
Items any `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type productSnapshot struct {
ID uint64 `json:"id"`
Title string `json:"title"`
CoverURL string `json:"cover_url"`
ImageURLs []string `json:"image_urls"`
Description string `json:"description"`
PriceCent int64 `json:"price_cent"`
Unit string `json:"unit"`
QrcodeImageURL string `json:"qrcode_image_url"`
}
+16
View File
@@ -0,0 +1,16 @@
package mohong
import "errors"
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrProductNotFound = errors.New("product not found")
ErrProductUnavailable = errors.New("product unavailable")
ErrStockInsufficient = errors.New("stock insufficient")
ErrInvalidRequest = errors.New("invalid request")
ErrOrderNotFound = errors.New("order not found")
ErrOrderCannotPay = errors.New("order cannot pay")
ErrOrderCannotCancel = errors.New("order cannot cancel")
ErrOrderCannotComplete = errors.New("order cannot complete")
ErrUnauthorized = errors.New("unauthorized")
)
+415
View File
@@ -0,0 +1,415 @@
package mohong
import (
"errors"
"strconv"
"strings"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) ListCategories(c *gin.Context) {
items, err := h.service.ListPublicCategories(c.Request.Context())
if err != nil {
response.InternalServerError(c, "获取分类失败")
return
}
response.OK(c, items)
}
func (h *Handler) ListProducts(c *gin.Context) {
query := ProductListQuery{Keyword: c.Query("keyword")}
if catID, err := strconv.ParseUint(c.Query("category_id"), 10, 64); err == nil {
query.CategoryID = catID
}
query.Page, query.PageSize = parsePagination(c)
result, err := h.service.ListPublicProducts(c.Request.Context(), query)
if err != nil {
response.InternalServerError(c, "获取商品列表失败")
return
}
response.OK(c, result)
}
func (h *Handler) GetProduct(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
item, err := h.service.FindPublicProduct(c.Request.Context(), id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) CreateOrder(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
var req CreateOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数不正确")
return
}
item, err := h.service.CreateOrder(c.Request.Context(), userID, req)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) ListMyOrders(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
query := OrderListQuery{Status: c.Query("status"), Keyword: c.Query("keyword")}
query.Page, query.PageSize = parsePagination(c)
result, err := h.service.ListOrdersForUser(c.Request.Context(), userID, query)
if err != nil {
response.InternalServerError(c, "获取订单列表失败")
return
}
response.OK(c, result)
}
func (h *Handler) GetMyOrder(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
item, err := h.service.FindOrderForUser(c.Request.Context(), userID, id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) CancelMyOrder(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var body struct {
Reason string `json:"reason"`
}
_ = c.ShouldBindJSON(&body)
item, err := h.service.CancelOrder(c.Request.Context(), userID, id, body.Reason)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) AdminListCategories(c *gin.Context) {
items, err := h.service.AdminListCategories(c.Request.Context())
if err != nil {
response.InternalServerError(c, "获取分类失败")
return
}
response.OK(c, items)
}
func (h *Handler) AdminCreateCategory(c *gin.Context) {
var req CreateCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数不正确")
return
}
item, err := h.service.CreateCategory(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) AdminUpdateCategory(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数不正确")
return
}
item, err := h.service.UpdateCategory(c.Request.Context(), id, req)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) AdminDeleteCategory(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.service.DeleteCategory(c.Request.Context(), id); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"message": "删除成功"})
}
func (h *Handler) AdminListProducts(c *gin.Context) {
query := ProductListQuery{
Keyword: c.Query("keyword"),
Status: c.Query("status"),
}
if catID, err := strconv.ParseUint(c.Query("category_id"), 10, 64); err == nil {
query.CategoryID = catID
}
query.Page, query.PageSize = parsePagination(c)
result, err := h.service.AdminListProducts(c.Request.Context(), query)
if err != nil {
response.InternalServerError(c, "获取商品列表失败")
return
}
response.OK(c, result)
}
func (h *Handler) AdminGetProduct(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
item, err := h.service.AdminFindProduct(c.Request.Context(), id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) AdminCreateProduct(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "未授权")
return
}
var req CreateProductRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数不正确")
return
}
item, err := h.service.CreateProduct(c.Request.Context(), adminID, req)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) AdminUpdateProduct(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateProductRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数不正确")
return
}
item, err := h.service.UpdateProduct(c.Request.Context(), id, req)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) AdminDeleteProduct(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.service.DeleteProduct(c.Request.Context(), id); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"message": "删除成功"})
}
func (h *Handler) AdminListOrders(c *gin.Context) {
query := OrderListQuery{
Status: c.Query("status"),
Keyword: c.Query("keyword"),
}
if userID, err := strconv.ParseUint(c.Query("user_id"), 10, 64); err == nil {
query.UserID = userID
}
query.Page, query.PageSize = parsePagination(c)
result, err := h.service.AdminListOrders(c.Request.Context(), query)
if err != nil {
response.InternalServerError(c, "获取订单列表失败")
return
}
response.OK(c, result)
}
func (h *Handler) AdminGetOrder(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
item, err := h.service.AdminFindOrder(c.Request.Context(), id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) AdminCompleteOrder(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req AdminCompleteOrderRequest
_ = c.ShouldBindJSON(&req)
item, err := h.service.AdminCompleteOrder(c.Request.Context(), id, req.Remark)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) AdminCancelOrder(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req AdminCancelOrderRequest
_ = c.ShouldBindJSON(&req)
item, err := h.service.AdminCancelOrder(c.Request.Context(), id, req.Reason)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) AdminGetConfig(c *gin.Context) {
item, err := h.service.GetConfig(c.Request.Context())
if err != nil {
response.InternalServerError(c, "获取配置失败")
return
}
response.OK(c, item)
}
func (h *Handler) AdminUpdateConfig(c *gin.Context) {
var req UpdateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数不正确")
return
}
item, err := h.service.UpdateConfig(c.Request.Context(), req)
if err != nil {
response.InternalServerError(c, "更新配置失败")
return
}
response.OK(c, item)
}
func writeError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrProductNotFound), errors.Is(err, ErrOrderNotFound):
response.NotFound(c, err.Error())
case errors.Is(err, ErrProductUnavailable):
response.Error(c, 409, "product_unavailable", "商品不可购买")
case errors.Is(err, ErrStockInsufficient):
response.Error(c, 409, "stock_insufficient", "库存不足")
case errors.Is(err, ErrOrderCannotPay):
response.Error(c, 409, "order_cannot_pay", "订单无法支付")
case errors.Is(err, ErrOrderCannotCancel):
response.Error(c, 409, "order_cannot_cancel", "订单无法取消")
case errors.Is(err, ErrOrderCannotComplete):
response.Error(c, 409, "order_cannot_complete", "订单无法完成")
case errors.Is(err, ErrInvalidRequest):
response.BadRequest(c, "请求参数不正确")
case errors.Is(err, ErrUnauthorized):
response.Unauthorized(c, "未授权")
default:
msg := strings.TrimSpace(err.Error())
if msg == "" {
msg = "操作失败"
}
response.InternalServerError(c, msg)
}
}
func parseID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "ID不正确")
return 0, false
}
return id, true
}
func parsePagination(c *gin.Context) (int, int) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}
func currentUserID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextUserID)
if !ok {
return 0, false
}
id, ok := value.(uint64)
return id, ok
}
func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID)
if !ok {
return 0, false
}
id, ok := value.(uint64)
return id, ok
}
@@ -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
}
@@ -0,0 +1,132 @@
package mohong
import (
"encoding/json"
"strings"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/pkg/money"
)
func toProductDTO(row model.MohongProduct, includeAdminFields bool) ProductDTO {
images := decodeStringList(row.ImageURLs)
dto := ProductDTO{
ID: row.ID,
CategoryID: row.CategoryID,
Title: row.Title,
CoverURL: row.CoverURL,
ImageURLs: images,
Description: row.Description,
PriceCent: row.PriceCent,
Price: money.Format(row.PriceCent),
OriginalPriceCent: row.OriginalPriceCent,
Unit: row.Unit,
Stock: row.Stock,
SortOrder: row.SortOrder,
Status: row.Status,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if row.OriginalPriceCent > 0 {
dto.OriginalPrice = money.Format(row.OriginalPriceCent)
}
if includeAdminFields {
dto.QrcodeImageURL = row.QrcodeImageURL
}
return dto
}
func toProductDTOWithCategory(row model.MohongProduct, categoryName string, includeAdminFields bool) ProductDTO {
dto := toProductDTO(row, includeAdminFields)
dto.CategoryName = categoryName
return dto
}
func toOrderDTO(row model.MohongOrder, snap productSnapshot, buyerNickname, buyerPhone string, includeAdmin bool) OrderDTO {
dto := OrderDTO{
ID: row.ID,
OrderNo: row.OrderNo,
UserID: row.UserID,
ProductID: row.ProductID,
Quantity: row.Quantity,
UnitPriceCent: row.UnitPriceCent,
UnitPrice: money.Format(row.UnitPriceCent),
AmountCent: row.AmountCent,
Amount: money.Format(row.AmountCent),
Status: row.Status,
ProductTitle: snap.Title,
ProductCoverURL: snap.CoverURL,
ProductUnit: snap.Unit,
QrcodeURLSnapshot: row.QrcodeURLSnapshot,
CopyText: row.CopyText,
ConversationID: row.ConversationID,
CancelReason: row.CancelReason,
PaidAt: row.PaidAt,
CompletedAt: row.CompletedAt,
CancelledAt: row.CancelledAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if includeAdmin {
dto.BuyerNickname = buyerNickname
dto.BuyerPhone = maskPhone(buyerPhone)
dto.AdminRemark = row.AdminRemark
} else {
dto.BuyerNickname = buyerNickname
dto.BuyerPhone = maskPhone(buyerPhone)
}
return dto
}
func decodeStringList(raw []byte) []string {
if len(raw) == 0 {
return []string{}
}
var list []string
if err := json.Unmarshal(raw, &list); err != nil {
return []string{}
}
out := make([]string, 0, len(list))
for _, item := range list {
item = strings.TrimSpace(item)
if item != "" {
out = append(out, item)
}
}
return out
}
func encodeStringList(list []string) []byte {
clean := make([]string, 0, len(list))
for _, item := range list {
item = strings.TrimSpace(item)
if item != "" {
clean = append(clean, item)
}
}
if len(clean) == 0 {
return []byte("[]")
}
raw, err := json.Marshal(clean)
if err != nil {
return []byte("[]")
}
return raw
}
func decodeProductSnapshot(raw []byte) productSnapshot {
var snap productSnapshot
if len(raw) == 0 {
return snap
}
_ = json.Unmarshal(raw, &snap)
return snap
}
func maskPhone(phone string) string {
phone = strings.TrimSpace(phone)
if len(phone) < 7 {
return phone
}
return phone[:3] + "****" + phone[len(phone)-4:]
}
@@ -0,0 +1,303 @@
package mohong
import (
"context"
"strings"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (r *Repository) ListPublicProducts(ctx context.Context, query ProductListQuery) (*PaginatedResult, error) {
query = normalizePage(query)
db := r.db.WithContext(ctx).Model(&model.MohongProduct{}).Where("status = ?", model.MohongProductStatusOnSale)
if query.CategoryID > 0 {
// 多对多分类 + 主分类兜底
db = db.Where(
`(id IN (SELECT product_id FROM mohong_product_categories WHERE category_id = ?) OR category_id = ?)`,
query.CategoryID, query.CategoryID,
)
}
if kw := strings.TrimSpace(query.Keyword); kw != "" {
like := "%" + kw + "%"
db = db.Where("(title LIKE ? OR description LIKE ?)", like, like)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.MohongProduct
if err := db.Order("sort_order DESC, id DESC").
Offset((query.Page - 1) * query.PageSize).
Limit(query.PageSize).
Find(&rows).Error; err != nil {
return nil, err
}
return r.productPageDTO(ctx, rows, total, query, false)
}
func (r *Repository) FindPublicProduct(ctx context.Context, id uint64) (*ProductDTO, error) {
var row model.MohongProduct
if err := r.db.WithContext(ctx).
Where("id = ? AND status = ?", id, model.MohongProductStatusOnSale).
First(&row).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrProductNotFound
}
return nil, err
}
name := ""
if row.CategoryID != nil {
name = r.loadCategoryNameMap(ctx, []uint64{*row.CategoryID})[*row.CategoryID]
}
dto := toProductDTOWithCategory(row, name, false)
return &dto, nil
}
func (r *Repository) AdminListProducts(ctx context.Context, query ProductListQuery) (*PaginatedResult, error) {
query = normalizePage(query)
db := r.db.WithContext(ctx).Model(&model.MohongProduct{})
if status := strings.TrimSpace(query.Status); status != "" {
db = db.Where("status = ?", status)
}
if query.CategoryID > 0 {
db = db.Where(
`(id IN (SELECT product_id FROM mohong_product_categories WHERE category_id = ?) OR category_id = ?)`,
query.CategoryID, query.CategoryID,
)
}
if kw := strings.TrimSpace(query.Keyword); kw != "" {
like := "%" + kw + "%"
db = db.Where("(title LIKE ? OR description LIKE ?)", like, like)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.MohongProduct
if err := db.Order("sort_order DESC, id DESC").
Offset((query.Page - 1) * query.PageSize).
Limit(query.PageSize).
Find(&rows).Error; err != nil {
return nil, err
}
return r.productPageDTO(ctx, rows, total, query, true)
}
func (r *Repository) productPageDTO(ctx context.Context, rows []model.MohongProduct, total int64, query ProductListQuery, admin bool) (*PaginatedResult, error) {
ids := make([]uint64, 0, len(rows))
for _, row := range rows {
if row.CategoryID != nil {
ids = append(ids, *row.CategoryID)
}
}
names := r.loadCategoryNameMap(ctx, ids)
items := make([]ProductDTO, 0, len(rows))
for _, row := range rows {
name := ""
if row.CategoryID != nil {
name = names[*row.CategoryID]
}
items = append(items, toProductDTOWithCategory(row, name, admin))
}
return &PaginatedResult{Items: items, Total: total, Page: query.Page, PageSize: query.PageSize}, nil
}
func (r *Repository) AdminFindProduct(ctx context.Context, id uint64) (*ProductDTO, error) {
var row model.MohongProduct
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrProductNotFound
}
return nil, err
}
name := ""
if row.CategoryID != nil {
name = r.loadCategoryNameMap(ctx, []uint64{*row.CategoryID})[*row.CategoryID]
}
dto := toProductDTOWithCategory(row, name, true)
return &dto, nil
}
func (r *Repository) CreateProduct(ctx context.Context, adminID uint64, req CreateProductRequest) (*ProductDTO, error) {
status := strings.TrimSpace(req.Status)
if status == "" {
status = model.MohongProductStatusDraft
}
if !isValidProductStatus(status) {
return nil, ErrInvalidRequest
}
stock := -1
if req.Stock != nil {
stock = *req.Stock
}
unit := strings.TrimSpace(req.Unit)
if unit == "" {
unit = "份"
}
row := model.MohongProduct{
CategoryID: normalizeCategoryID(req.CategoryID),
Title: strings.TrimSpace(req.Title),
CoverURL: strings.TrimSpace(req.CoverURL),
ImageURLs: encodeStringList(req.ImageURLs),
Description: strings.TrimSpace(req.Description),
PriceCent: req.PriceCent,
OriginalPriceCent: maxInt64(req.OriginalPriceCent, 0),
Unit: unit,
Stock: stock,
SortOrder: req.SortOrder,
Status: status,
QrcodeImageURL: strings.TrimSpace(req.QrcodeImageURL),
CreatedBy: &adminID,
}
if row.Title == "" || row.PriceCent <= 0 {
return nil, ErrInvalidRequest
}
if err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&row).Error; err != nil {
return err
}
return syncProductCategories(tx, row.ID, row.CategoryID)
}); err != nil {
return nil, err
}
return r.AdminFindProduct(ctx, row.ID)
}
func (r *Repository) UpdateProduct(ctx context.Context, id uint64, req UpdateProductRequest) (*ProductDTO, error) {
var row model.MohongProduct
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrProductNotFound
}
return nil, err
}
updates := map[string]any{}
if req.ClearCategory {
updates["category_id"] = nil
} else if req.CategoryID != nil {
updates["category_id"] = normalizeCategoryID(req.CategoryID)
}
if req.Title != nil {
title := strings.TrimSpace(*req.Title)
if title == "" {
return nil, ErrInvalidRequest
}
updates["title"] = title
}
if req.CoverURL != nil {
updates["cover_url"] = strings.TrimSpace(*req.CoverURL)
}
if req.ImageURLs != nil {
updates["image_urls"] = encodeStringList(req.ImageURLs)
}
if req.Description != nil {
updates["description"] = strings.TrimSpace(*req.Description)
}
if req.PriceCent != nil {
if *req.PriceCent <= 0 {
return nil, ErrInvalidRequest
}
updates["price_cent"] = *req.PriceCent
}
if req.OriginalPriceCent != nil {
updates["original_price_cent"] = maxInt64(*req.OriginalPriceCent, 0)
}
if req.Unit != nil {
unit := strings.TrimSpace(*req.Unit)
if unit == "" {
unit = "份"
}
updates["unit"] = unit
}
if req.Stock != nil {
updates["stock"] = *req.Stock
}
if req.SortOrder != nil {
updates["sort_order"] = *req.SortOrder
}
if req.Status != nil {
status := strings.TrimSpace(*req.Status)
if !isValidProductStatus(status) {
return nil, ErrInvalidRequest
}
updates["status"] = status
}
if req.QrcodeImageURL != nil {
updates["qrcode_image_url"] = strings.TrimSpace(*req.QrcodeImageURL)
}
if err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if len(updates) > 0 {
if err := tx.Model(&row).Updates(updates).Error; err != nil {
return err
}
}
// 刷新主分类后同步关联表(至少保证主分类在关联里)
var latest model.MohongProduct
if err := tx.First(&latest, id).Error; err != nil {
return err
}
return syncProductCategories(tx, latest.ID, latest.CategoryID)
}); err != nil {
return nil, err
}
return r.AdminFindProduct(ctx, id)
}
// syncProductCategories 保证主分类写入多对多表;不删除其它已有关联(种子数据可挂多分类)。
func syncProductCategories(tx *gorm.DB, productID uint64, primary *uint64) error {
if primary == nil || *primary == 0 {
return nil
}
rel := model.MohongProductCategory{ProductID: productID, CategoryID: *primary}
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rel).Error
}
func (r *Repository) DeleteProduct(ctx context.Context, id uint64) error {
res := r.db.WithContext(ctx).Delete(&model.MohongProduct{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrProductNotFound
}
return nil
}
func isValidProductStatus(status string) bool {
switch status {
case model.MohongProductStatusDraft, model.MohongProductStatusOnSale, model.MohongProductStatusOffSale:
return true
default:
return false
}
}
func normalizePage(query ProductListQuery) ProductListQuery {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
return query
}
func maxInt64(a, b int64) int64 {
if a > b {
return a
}
return b
}
func normalizeCategoryID(id *uint64) *uint64 {
if id == nil || *id == 0 {
return nil
}
return id
}
@@ -0,0 +1,19 @@
package mohong
import "gorm.io/gorm"
// ChatNotifier 会话创建后的异步通知接口。
type ChatNotifier interface {
NotifyNewConversation(conversationID uint64)
}
// Repository 摸大红数据访问。
type Repository struct {
db *gorm.DB
chatNotifier ChatNotifier
}
// NewRepository 创建仓库。
func NewRepository(db *gorm.DB, chatNotifier ChatNotifier) *Repository {
return &Repository{db: db, chatNotifier: chatNotifier}
}
+173
View File
@@ -0,0 +1,173 @@
package mohong
import "context"
type Service struct {
repo *Repository
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) ListPublicCategories(ctx context.Context) ([]CategoryDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListPublicCategories(ctx)
}
func (s *Service) AdminListCategories(ctx context.Context) ([]CategoryDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.AdminListCategories(ctx)
}
func (s *Service) CreateCategory(ctx context.Context, req CreateCategoryRequest) (*CategoryDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.CreateCategory(ctx, req)
}
func (s *Service) UpdateCategory(ctx context.Context, id uint64, req UpdateCategoryRequest) (*CategoryDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.UpdateCategory(ctx, id, req)
}
func (s *Service) DeleteCategory(ctx context.Context, id uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.DeleteCategory(ctx, id)
}
func (s *Service) ListPublicProducts(ctx context.Context, query ProductListQuery) (*PaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListPublicProducts(ctx, query)
}
func (s *Service) FindPublicProduct(ctx context.Context, id uint64) (*ProductDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.FindPublicProduct(ctx, id)
}
func (s *Service) AdminListProducts(ctx context.Context, query ProductListQuery) (*PaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.AdminListProducts(ctx, query)
}
func (s *Service) AdminFindProduct(ctx context.Context, id uint64) (*ProductDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.AdminFindProduct(ctx, id)
}
func (s *Service) CreateProduct(ctx context.Context, adminID uint64, req CreateProductRequest) (*ProductDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.CreateProduct(ctx, adminID, req)
}
func (s *Service) UpdateProduct(ctx context.Context, id uint64, req UpdateProductRequest) (*ProductDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.UpdateProduct(ctx, id, req)
}
func (s *Service) DeleteProduct(ctx context.Context, id uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.DeleteProduct(ctx, id)
}
func (s *Service) CreateOrder(ctx context.Context, userID uint64, req CreateOrderRequest) (*OrderDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.CreateOrder(ctx, userID, req)
}
func (s *Service) FindOrderForUser(ctx context.Context, userID, orderID uint64) (*OrderDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.FindOrderForUser(ctx, userID, orderID)
}
func (s *Service) ListOrdersForUser(ctx context.Context, userID uint64, query OrderListQuery) (*PaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListOrdersForUser(ctx, userID, query)
}
func (s *Service) CancelOrder(ctx context.Context, userID, orderID uint64, reason string) (*OrderDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.CancelOrder(ctx, userID, orderID, reason)
}
func (s *Service) AdminListOrders(ctx context.Context, query OrderListQuery) (*PaginatedResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.AdminListOrders(ctx, query)
}
func (s *Service) AdminFindOrder(ctx context.Context, orderID uint64) (*OrderDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.AdminFindOrder(ctx, orderID)
}
func (s *Service) AdminCompleteOrder(ctx context.Context, orderID uint64, remark string) (*OrderDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.AdminCompleteOrder(ctx, orderID, remark)
}
func (s *Service) AdminCancelOrder(ctx context.Context, orderID uint64, reason string) (*OrderDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.AdminCancelOrder(ctx, orderID, reason)
}
func (s *Service) GetConfig(ctx context.Context) (*ConfigDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.GetConfig(ctx)
}
func (s *Service) UpdateConfig(ctx context.Context, req UpdateConfigRequest) (*ConfigDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.UpdateConfig(ctx, req)
}
// Repo 暴露给支付模块注入。
func (s *Service) Repo() *Repository {
if s == nil {
return nil
}
return s.repo
}
@@ -45,16 +45,31 @@ func isOrderPaymentTerminalStatus(status string) bool {
}
func (r *Repository) confirmPaid(ctx context.Context, payment *model.PaymentOrder, status string, paidAt time.Time, raw map[string]string, source string) error {
var newConvID uint64
var notifyFn func(uint64)
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if payment.OrderID != 0 {
if r.orderRepo == nil {
return ErrDependencyUnavailable
switch payment.BizType {
case "mohong_pay":
if r.mohongRepo == nil {
return ErrDependencyUnavailable
}
convID, err := r.mohongRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID)
if err != nil {
return err
}
newConvID = convID
notifyFn = r.mohongRepo.NotifyNewConversation
default:
if r.orderRepo == nil {
return ErrDependencyUnavailable
}
convID, err := r.orderRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID)
if err != nil {
return err
}
newConvID = convID
notifyFn = r.orderRepo.NotifyNewConversation
}
convID, err := r.orderRepo.ConfirmPaidFromChannelTx(tx, payment.OrderID)
if err != nil {
return err
}
newConvID = convID
}
var latest model.PaymentOrder
@@ -75,8 +90,8 @@ func (r *Repository) confirmPaid(ctx context.Context, payment *model.PaymentOrde
if err != nil {
return err
}
if newConvID > 0 && r.orderRepo != nil {
r.orderRepo.NotifyNewConversation(newConvID)
if newConvID > 0 && notifyFn != nil {
notifyFn(newConvID)
}
if latest, err := r.findPaymentByID(ctx, payment.ID); err == nil {
if runtimeConfig, err := r.runtimeConfigForPayment(ctx, latest); err == nil {
@@ -91,6 +91,26 @@ func (h *Handler) Start(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) StartMohong(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
orderID, ok := parseID(c)
if !ok {
return
}
var req StartPaymentRequest
_ = c.ShouldBindJSON(&req)
item, err := h.service.StartMohong(c.Request.Context(), userID, orderID, req, c.ClientIP())
if err != nil {
writePaymentError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) Query(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
@@ -109,6 +129,24 @@ func (h *Handler) Query(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) QueryMohong(c *gin.Context) {
userID, ok := currentUserID(c)
if !ok {
response.Unauthorized(c, "缺少用户上下文")
return
}
orderID, ok := parseID(c)
if !ok {
return
}
item, err := h.service.QueryMohong(c.Request.Context(), userID, orderID)
if err != nil {
writePaymentError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) QueryRefundStatus(c *gin.Context) {
orderID, ok := parseID(c)
if !ok {
@@ -0,0 +1,245 @@
package payment
import (
"context"
"time"
"hfb_sys/backend/internal/model"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const bizTypeMohongPay = "mohong_pay"
// StartMohong 发起摸大红订单支付。
func (r *Repository) StartMohong(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) {
req.PayWay = normalizePayWay(req.PayWay)
defaultConfig, err := r.defaultRuntimeConfig(ctx, req.PayWay)
if err != nil {
return nil, ErrPaymentUnavailable
}
payment, orderRow, err := r.prepareMohongPayment(ctx, userID, orderID, req, *defaultConfig)
if err != nil {
return nil, err
}
runtimeConfig, err := r.runtimeConfigForPayment(ctx, payment)
if err != nil {
return nil, ErrPaymentUnavailable
}
if payment.Status == "paid" {
r.recordConfigUsage(ctx, runtimeConfig, payment)
dto := toDTO(*payment)
return &dto, nil
}
if runtimeConfig.isMockMode() {
if err := r.confirmPaid(ctx, payment, "2", time.Now(), map[string]string{
"mock": "true",
"third_order_id": payment.ThirdOrderID,
"leshua_order_id": payment.ProviderOrderID,
"status": "2",
}, channelSourceMock); err != nil {
return nil, err
}
latest, err := r.findPaymentByID(ctx, payment.ID)
if err != nil {
return nil, err
}
r.recordConfigUsage(ctx, runtimeConfig, latest)
dto := toDTO(*latest)
return &dto, nil
}
if payment.Status == "paying" && (payment.TDCode != "" || payment.JSPayURL != "" || payment.JSPayInfo != "") {
r.recordConfigUsage(ctx, runtimeConfig, payment)
dto := toDTO(*payment)
return &dto, nil
}
if runtimeConfig.Channel == nil {
_ = r.markPaymentFailed(ctx, payment.ID, nil, "payment channel unavailable")
return nil, ErrPaymentUnavailable
}
r.log().Info("mohong payment start", paymentLogFields(ctx, appendFields(
paymentOrderFields(payment),
runtimeConfigFields(runtimeConfig),
)...,
)...)
resp, err := runtimeConfig.Channel.CreatePayment(ctx, channelCreatePaymentRequest{
ThirdOrderID: payment.ThirdOrderID,
AmountCent: payment.AmountCent,
PayWay: payment.PayWay,
JSPayFlag: payment.JSPayFlag,
NotifyURL: runtimeConfig.NotifyURL,
JumpURL: runtimeConfig.JumpURL,
ClientIP: clientIP,
Body: "摸大红订单 " + orderRow.OrderNo,
Attach: orderRow.OrderNo,
})
if err != nil {
_ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error())
r.log().Warn("mohong payment request failed", paymentLogFields(ctx, appendFields(
paymentOrderFields(payment),
runtimeConfigFields(runtimeConfig),
[]zap.Field{zap.Error(err)},
)...,
)...)
return nil, err
}
if !resp.OK {
_ = r.markPaymentFailed(ctx, payment.ID, resp.Raw, resp.ErrorMessage)
return nil, ErrPaymentUnavailable
}
if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
"status": "paying",
"provider_order_id": resp.ProviderOrderID,
"pay_way": firstNonEmpty(resp.PayWay, payment.PayWay),
"td_code": resp.TDCode,
"jspay_url": resp.JSPayURL,
"jspay_info": resp.JSPayInfo,
"raw_request": jsonMap(resp.RawRequest),
"raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)),
}).Error; err != nil {
return nil, err
}
latest, err := r.findPaymentByID(ctx, payment.ID)
if err != nil {
return nil, err
}
r.recordConfigUsage(ctx, runtimeConfig, latest)
dto := toDTO(*latest)
return &dto, nil
}
func (r *Repository) prepareMohongPayment(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (*model.PaymentOrder, *model.MohongOrder, error) {
var paymentID uint64
var orderRow model.MohongOrder
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var row model.MohongOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND user_id = ?", orderID, userID).
First(&row).Error; err != nil {
return err
}
if row.Status != model.MohongOrderStatusPendingPayment {
return ErrPaymentCannotStart
}
if row.AmountCent <= 0 {
return ErrPaymentCannotStart
}
var existing model.PaymentOrder
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("order_id = ? AND biz_type = ?", row.ID, bizTypeMohongPay).
Order("id DESC").
First(&existing).Error
if err == nil {
if canReuseOrderPayment(existing, runtimeConfig) {
existing.PayWay = firstNonEmpty(req.PayWay, existing.PayWay, runtimeConfig.PayWay, "ZFBZF")
existing.JSPayFlag = firstNonEmpty(req.JSPayFlag, existing.JSPayFlag, runtimeConfig.JSPayFlag, "2")
existing.AmountCent = row.AmountCent
existing.PaymentConfigID = firstNonZero(existing.PaymentConfigID, runtimeConfig.ID)
existing.Provider = firstNonEmpty(existing.Provider, runtimeConfig.Provider)
existing.MerchantID = firstNonEmpty(existing.MerchantID, runtimeConfig.MerchantID)
if existing.Provider == "mock" && existing.ProviderOrderID == "" {
existing.ProviderOrderID = "MOCK" + existing.ThirdOrderID
}
if err := tx.Save(&existing).Error; err != nil {
return err
}
paymentID = existing.ID
orderRow = row
return nil
}
} else if err != gorm.ErrRecordNotFound {
return err
}
payment, err := newMohongPayment(row, req, runtimeConfig)
if err != nil {
return err
}
if err := tx.Create(&payment).Error; err != nil {
return err
}
paymentID = payment.ID
orderRow = row
return nil
})
if err != nil {
return nil, nil, err
}
payment, err := r.findPaymentByID(ctx, paymentID)
if err != nil {
return nil, nil, err
}
return payment, &orderRow, nil
}
func newMohongPayment(row model.MohongOrder, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (model.PaymentOrder, error) {
paymentNo, err := newPaymentNo()
if err != nil {
return model.PaymentOrder{}, err
}
payment := model.PaymentOrder{
PaymentNo: paymentNo,
OrderID: row.ID,
OrderNo: row.OrderNo,
UserID: row.UserID,
PaymentConfigID: runtimeConfig.ID,
Provider: runtimeConfig.Provider,
MerchantID: runtimeConfig.MerchantID,
ThirdOrderID: paymentNo,
ProviderOrderID: "",
PayWay: firstNonEmpty(req.PayWay, runtimeConfig.PayWay, "ZFBZF"),
JSPayFlag: firstNonEmpty(req.JSPayFlag, runtimeConfig.JSPayFlag, "2"),
AmountCent: row.AmountCent,
BizType: bizTypeMohongPay,
Status: "created",
}
if runtimeConfig.isMockMode() {
payment.ProviderOrderID = "MOCK" + paymentNo
payment.TDCode = "mock://payment/pay/" + paymentNo
}
return payment, nil
}
// QueryMohong 查询摸大红订单支付状态。
func (r *Repository) QueryMohong(ctx context.Context, userID uint64, orderID uint64) (*PaymentDTO, error) {
var payment model.PaymentOrder
if err := r.db.WithContext(ctx).
Where("order_id = ? AND user_id = ? AND biz_type = ?", orderID, userID, bizTypeMohongPay).
Order("id DESC").
First(&payment).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrPaymentNotFound
}
return nil, err
}
if isOrderPaymentTerminalStatus(payment.Status) {
dto := toDTO(payment)
return &dto, nil
}
runtimeConfig, err := r.runtimeConfigForPayment(ctx, &payment)
if err != nil {
return nil, ErrPaymentUnavailable
}
if runtimeConfig.isMockMode() {
dto := toDTO(payment)
return &dto, nil
}
if runtimeConfig.Channel == nil {
return nil, ErrPaymentUnavailable
}
resp, err := runtimeConfig.Channel.QueryPayment(ctx, payment.ThirdOrderID, payment.ProviderOrderID)
if err != nil {
return nil, err
}
if err := r.applyChannelStatus(ctx, &payment, resp.Status, resp.PayTime, resp.Raw, channelSourceQuery); err != nil {
return nil, err
}
latest, err := r.findPaymentByID(ctx, payment.ID)
if err != nil {
return nil, err
}
dto := toDTO(*latest)
return &dto, nil
}
+15 -1
View File
@@ -13,10 +13,17 @@ import (
"gorm.io/gorm"
)
// MohongOrderConfirmer 摸大红订单支付确认接口,避免 payment 与 mohong 循环依赖。
type MohongOrderConfirmer interface {
ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint64, error)
NotifyNewConversation(conversationID uint64)
}
type Repository struct {
db *gorm.DB
configRepo *paymentconfig.Repository
orderRepo *order.Repository
mohongRepo MohongOrderConfirmer
logger *zap.Logger
}
@@ -82,6 +89,13 @@ func WithLogger(logger *zap.Logger) RepositoryOption {
}
}
// WithMohongRepo 注入摸大红订单确认器。
func WithMohongRepo(repo MohongOrderConfirmer) RepositoryOption {
return func(r *Repository) {
r.mohongRepo = repo
}
}
func (r *Repository) log() *zap.Logger {
if r == nil || r.logger == nil {
return zap.NewNop()
@@ -237,7 +251,7 @@ func (r *Repository) recordConfigUsage(ctx context.Context, runtimeConfig *runti
if r.configRepo == nil || runtimeConfig == nil || payment == nil || runtimeConfig.ID == 0 {
return
}
if payment.BizType != "order_pay" || payment.Status != "paid" {
if (payment.BizType != "order_pay" && payment.BizType != "mohong_pay") || payment.Status != "paid" {
return
}
if err := r.configRepo.RecordUsage(ctx, runtimeConfig.ID, payment.ID, runtimeConfig.Provider, runtimeConfig.MerchantID, payment.AmountCent, payment.BizType); err != nil {
@@ -32,6 +32,16 @@ func (s *Service) Start(ctx context.Context, userID uint64, orderID uint64, req
return s.repo.Start(ctx, userID, orderID, req, clientIP)
}
func (s *Service) StartMohong(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 || orderID == 0 {
return nil, ErrPaymentCannotStart
}
return s.repo.StartMohong(ctx, userID, orderID, req, clientIP)
}
func (s *Service) Query(ctx context.Context, userID uint64, orderID uint64) (*PaymentDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
@@ -42,6 +52,16 @@ func (s *Service) Query(ctx context.Context, userID uint64, orderID uint64) (*Pa
return s.repo.Query(ctx, userID, orderID)
}
func (s *Service) QueryMohong(ctx context.Context, userID uint64, orderID uint64) (*PaymentDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if userID == 0 || orderID == 0 {
return nil, ErrPaymentNotFound
}
return s.repo.QueryMohong(ctx, userID, orderID)
}
func (s *Service) HandleLeshuaNotify(ctx context.Context, params map[string]string, rawPayload string, contentType string) (*NotifyResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
+55 -1
View File
@@ -24,6 +24,7 @@ import (
"hfb_sys/backend/internal/modules/dispute"
filemodule "hfb_sys/backend/internal/modules/file"
"hfb_sys/backend/internal/modules/listing"
"hfb_sys/backend/internal/modules/mohong"
"hfb_sys/backend/internal/modules/notification"
"hfb_sys/backend/internal/modules/order"
"hfb_sys/backend/internal/modules/payment"
@@ -254,8 +255,21 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
paymentConfigHandler = paymentconfig.NewHandler(paymentConfigService)
}
var mohongRepo *mohong.Repository
var mohongService *mohong.Service
var mohongHandler *mohong.Handler
if deps.DB != nil {
paymentRepo = payment.NewRepository(deps.DB, paymentConfigRepo, orderRepo, payment.WithLogger(logger))
mohongRepo = mohong.NewRepository(deps.DB, chatRepo)
mohongService = mohong.NewService(mohongRepo)
mohongHandler = mohong.NewHandler(mohongService)
}
if deps.DB != nil {
paymentOpts := []payment.RepositoryOption{payment.WithLogger(logger)}
if mohongRepo != nil {
paymentOpts = append(paymentOpts, payment.WithMohongRepo(mohongRepo))
}
paymentRepo = payment.NewRepository(deps.DB, paymentConfigRepo, orderRepo, paymentOpts...)
}
paymentService := payment.NewService(paymentRepo)
paymentHandler := payment.NewHandler(paymentService, payment.WithHandlerLogger(logger))
@@ -414,6 +428,25 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
sellerPickupRoutes.GET("", pickupHandler.ListForSeller)
}
// 摸大红:商品公开,下单需登录+实名
if mohongHandler != nil {
mohongPublic := api.Group("/mohong")
{
mohongPublic.GET("/categories", mohongHandler.ListCategories)
mohongPublic.GET("/products", mohongHandler.ListProducts)
mohongPublic.GET("/products/:id", mohongHandler.GetProduct)
}
mohongAuth := api.Group("/mohong", requireAuth)
{
mohongAuth.POST("/orders", requireRealname, mohongHandler.CreateOrder)
mohongAuth.GET("/orders", mohongHandler.ListMyOrders)
mohongAuth.GET("/orders/:id", mohongHandler.GetMyOrder)
mohongAuth.POST("/orders/:id/cancel", mohongHandler.CancelMyOrder)
mohongAuth.POST("/orders/:id/start-payment", requireRealname, paymentHandler.StartMohong)
mohongAuth.GET("/orders/:id/query-payment", paymentHandler.QueryMohong)
}
}
orderRoutes := api.Group("/orders", requireAuth)
{
orderRoutes.POST("", requireRealname, orderHandler.Create)
@@ -565,6 +598,27 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.POST("/listings/:id/reject", requirePerm("listing:reject"), listingHandler.Reject)
adminRoutes.POST("/listings/:id/offline", requirePerm("listing:offline"), listingHandler.AdminOffline)
adminRoutes.POST("/listings/:id/mark-abnormal", requirePerm("listing:offline"), listingHandler.AdminMarkAbnormal)
// 摸大红
if mohongHandler != nil {
// 商品编辑需要拉分类列表,故 GET 用 product_view
adminRoutes.GET("/mohong/categories", requirePerm("mohong:product_view"), mohongHandler.AdminListCategories)
adminRoutes.POST("/mohong/categories", requirePerm("mohong:category"), mohongHandler.AdminCreateCategory)
adminRoutes.PUT("/mohong/categories/:id", requirePerm("mohong:category"), mohongHandler.AdminUpdateCategory)
adminRoutes.DELETE("/mohong/categories/:id", requirePerm("mohong:category"), mohongHandler.AdminDeleteCategory)
adminRoutes.GET("/mohong/products", requirePerm("mohong:product_view"), mohongHandler.AdminListProducts)
adminRoutes.GET("/mohong/products/:id", requirePerm("mohong:product_view"), mohongHandler.AdminGetProduct)
adminRoutes.POST("/mohong/products", requirePerm("mohong:product_manage"), mohongHandler.AdminCreateProduct)
adminRoutes.PUT("/mohong/products/:id", requirePerm("mohong:product_manage"), mohongHandler.AdminUpdateProduct)
adminRoutes.DELETE("/mohong/products/:id", requirePerm("mohong:product_manage"), mohongHandler.AdminDeleteProduct)
adminRoutes.GET("/mohong/orders", requirePerm("mohong:order_view"), mohongHandler.AdminListOrders)
adminRoutes.GET("/mohong/orders/:id", requirePerm("mohong:order_view"), mohongHandler.AdminGetOrder)
adminRoutes.POST("/mohong/orders/:id/complete", requirePerm("mohong:order_manage"), mohongHandler.AdminCompleteOrder)
adminRoutes.POST("/mohong/orders/:id/cancel", requirePerm("mohong:order_manage"), mohongHandler.AdminCancelOrder)
adminRoutes.GET("/mohong/config", requirePerm("mohong:config"), mohongHandler.AdminGetConfig)
adminRoutes.PUT("/mohong/config", requirePerm("mohong:config"), mohongHandler.AdminUpdateConfig)
}
adminRoutes.GET("/disputes", requirePerm("dispute:view"), disputeHandler.AdminList)
adminRoutes.POST("/disputes/:id/arbitrate", requirePerm("dispute:arbitrate"), disputeHandler.AdminArbitrate)
adminRoutes.GET("/finance/dashboard", requirePerm("wallet:view"), adminFinanceHandler.Dashboard)