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