接入分销订单支付退款与站点隔离
This commit is contained in:
@@ -59,18 +59,19 @@ func (ChatMessage) TableName() string {
|
||||
}
|
||||
|
||||
type ChatQrCode struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
ImageURL string `gorm:"size:512;not null" json:"image_url"`
|
||||
GroupName string `gorm:"size:128;not null;default:''" json:"group_name"`
|
||||
Status string `gorm:"size:16;not null;default:'unused'" json:"status"`
|
||||
ConversationID *uint64 `gorm:"index" json:"conversation_id"`
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
CreatedBy uint64 `gorm:"not null" json:"created_by"`
|
||||
Note string `gorm:"size:255;not null;default:''" json:"note"`
|
||||
WecomRenamed bool `gorm:"not null;default:false" json:"wecom_renamed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
ImageURL string `gorm:"size:512;not null" json:"image_url"`
|
||||
GroupName string `gorm:"size:128;not null;default:''" json:"group_name"`
|
||||
DistributionSiteID *uint64 `gorm:"index" json:"distribution_site_id,omitempty"`
|
||||
Status string `gorm:"size:16;not null;default:'unused'" json:"status"`
|
||||
ConversationID *uint64 `gorm:"index" json:"conversation_id"`
|
||||
UsedAt *time.Time `json:"used_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
CreatedBy uint64 `gorm:"not null" json:"created_by"`
|
||||
Note string `gorm:"size:255;not null;default:''" json:"note"`
|
||||
WecomRenamed bool `gorm:"not null;default:false" json:"wecom_renamed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (ChatQrCode) TableName() string {
|
||||
|
||||
@@ -91,7 +91,7 @@ func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferr
|
||||
// 4. 取二维码(带行锁)
|
||||
var qrcode *model.ChatQrCode
|
||||
var qrcodeImageURL string
|
||||
qrcodeObj, err := fetchUnusedQrCode(tx)
|
||||
qrcodeObj, err := fetchUnusedQrCodeForListing(tx, listing.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -301,12 +301,32 @@ func updateConversationLastMessage(tx *gorm.DB, conversationID uint64, message *
|
||||
}
|
||||
|
||||
func fetchUnusedQrCode(tx *gorm.DB) (*model.ChatQrCode, error) {
|
||||
return fetchUnusedQrCodeForSite(tx, nil)
|
||||
}
|
||||
|
||||
func fetchUnusedQrCodeForListing(tx *gorm.DB, listingID uint64) (*model.ChatQrCode, error) {
|
||||
var relation model.DistributionSiteAccount
|
||||
if err := tx.Where("listing_id = ? AND display_status = 'enabled'", listingID).Order("id ASC").First(&relation).Error; err == nil {
|
||||
return fetchUnusedQrCodeForSite(tx, &relation.SiteID)
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 兼容尚未执行分销迁移的环境,继续使用全局二维码池。
|
||||
return fetchUnusedQrCodeForSite(tx, nil)
|
||||
}
|
||||
return fetchUnusedQrCodeForSite(tx, nil)
|
||||
}
|
||||
|
||||
func fetchUnusedQrCodeForSite(tx *gorm.DB, siteID *uint64) (*model.ChatQrCode, error) {
|
||||
var qrcode model.ChatQrCode
|
||||
now := time.Now()
|
||||
|
||||
err := tx.Where("status = ?", QrCodeStatusUnused).
|
||||
query := tx.Where("status = ?", QrCodeStatusUnused).
|
||||
Where("expires_at IS NULL OR expires_at > ?", now).
|
||||
Order("id ASC").
|
||||
Order("id ASC")
|
||||
if siteID == nil {
|
||||
query = query.Where("distribution_site_id IS NULL")
|
||||
} else {
|
||||
query = query.Where("distribution_site_id = ?", *siteID)
|
||||
}
|
||||
err := query.
|
||||
Limit(1).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
First(&qrcode).Error
|
||||
|
||||
@@ -17,7 +17,8 @@ type Handler struct{ service *Service }
|
||||
func NewHandler(service *Service) *Handler { return &Handler{service: service} }
|
||||
|
||||
func (h *Handler) ListSites(c *gin.Context) {
|
||||
items, err := h.service.ListSites(c.Request.Context())
|
||||
adminID, _ := currentAdminID(c)
|
||||
items, err := h.service.ListSitesForAdmin(c.Request.Context(), adminID)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -130,9 +131,15 @@ func (h *Handler) ListOrders(c *gin.Context) {
|
||||
if siteID < 0 {
|
||||
return
|
||||
}
|
||||
adminID, _ := currentAdminID(c)
|
||||
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, uint64(siteID))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
result, err := h.service.ListOrders(c.Request.Context(), DistributionOrderListQuery{SiteID: uint64(siteID), Kind: c.Query("kind"), Status: c.Query("status"), Page: page, Size: size})
|
||||
result, err := h.service.ListOrders(c.Request.Context(), DistributionOrderListQuery{SiteID: scoped, Kind: c.Query("kind"), Status: c.Query("status"), Page: page, Size: size})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -179,7 +186,13 @@ func (h *Handler) ListAccountDisplays(c *gin.Context) {
|
||||
}
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListAccountDisplays(c.Request.Context(), uint64(siteID), c.Query("status"))
|
||||
adminID, _ := currentAdminID(c)
|
||||
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, uint64(siteID))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListAccountDisplays(c.Request.Context(), scoped, c.Query("status"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -211,7 +224,13 @@ func (h *Handler) ListPaymentChannels(c *gin.Context) {
|
||||
response.BadRequest(c, "site_id 不正确")
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListPaymentChannels(c.Request.Context(), uint64(siteID))
|
||||
adminID, _ := currentAdminID(c)
|
||||
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, uint64(siteID))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListPaymentChannels(c.Request.Context(), scoped)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -225,12 +244,18 @@ func (h *Handler) BindPaymentChannel(c *gin.Context) {
|
||||
response.BadRequest(c, "site_id 不正确")
|
||||
return
|
||||
}
|
||||
adminID, _ := currentAdminID(c)
|
||||
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, uint64(siteID))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
var req BindPaymentChannelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "支付通道参数不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.BindPaymentChannel(c.Request.Context(), uint64(siteID), req)
|
||||
item, err := h.service.BindPaymentChannel(c.Request.Context(), scoped, req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -256,7 +281,13 @@ func (h *Handler) ListSettlementBatches(c *gin.Context) {
|
||||
response.BadRequest(c, "site_id 不正确")
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListSettlementBatches(c.Request.Context(), uint64(siteID))
|
||||
adminID, _ := currentAdminID(c)
|
||||
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, uint64(siteID))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListSettlementBatches(c.Request.Context(), scoped)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -334,6 +365,8 @@ func writeError(c *gin.Context, err error) {
|
||||
response.NotFound(c, "分销资源不存在")
|
||||
case errors.Is(err, ErrInvalidAmount), errors.Is(err, ErrInvalidRate), errors.Is(err, ErrInvalidServiceUnit), errors.Is(err, ErrUnsupportedMode), errors.Is(err, ErrInvalidOrderKind), errors.Is(err, ErrInvalidPeriod), errors.Is(err, ErrBatchAlreadySettled):
|
||||
response.BadRequest(c, "分销规则参数不正确")
|
||||
case errors.Is(err, ErrSiteAccessDenied):
|
||||
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该分销站点")
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
response.ServiceUnavailable(c, "分销服务暂时不可用")
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package distribution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ResolveOrderSourceTx 校验站点、账号展示关系和生效规则,供正常订单创建事务写入快照引用。
|
||||
func ResolveOrderSourceTx(tx *gorm.DB, siteCode string, listingID, accountID uint64, at time.Time) (*model.DistributionSite, *model.DistributionRuleVersion, error) {
|
||||
if siteCode == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
var site model.DistributionSite
|
||||
if err := tx.Where("site_code = ? AND status = 'active' AND display_account_enabled = ?", siteCode, true).First(&site).Error; err != nil {
|
||||
return nil, nil, ErrSiteNotFound
|
||||
}
|
||||
var relation model.DistributionSiteAccount
|
||||
if err := tx.Where("site_id = ? AND listing_id = ? AND account_id = ? AND display_status = 'enabled'", site.ID, listingID, accountID).First(&relation).Error; err != nil {
|
||||
return nil, nil, ErrAccountDisplayNotFound
|
||||
}
|
||||
var rule model.DistributionRuleVersion
|
||||
query := tx.Where("mode = ? AND status = 'active' AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)", ModeTwoPartyDistributorReceive, at, at).
|
||||
Where("site_id = ? OR site_id IS NULL", site.ID).
|
||||
Order("CASE WHEN site_id IS NULL THEN 1 ELSE 0 END, effective_from DESC, id DESC")
|
||||
if err := query.First(&rule).Error; err != nil {
|
||||
return nil, nil, ErrRuleNotFound
|
||||
}
|
||||
return &site, &rule, nil
|
||||
}
|
||||
|
||||
// SnapshotPaidOrderTx 在支付确认事务内幂等写入财务和参与方快照。
|
||||
func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error {
|
||||
if !tx.Migrator().HasTable(&model.DistributionOrderFinancial{}) {
|
||||
return nil
|
||||
}
|
||||
var order model.RentalOrder
|
||||
if err := tx.First(&order, orderID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if order.DistributionSiteID == nil || order.DistributionRuleVersionID == nil {
|
||||
return nil
|
||||
}
|
||||
var rule model.DistributionRuleVersion
|
||||
if err := tx.First(&rule, *order.DistributionRuleVersionID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
markup := order.PlatformFeeCent
|
||||
result, err := CalculateSettlement(Rule{ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS}, SettlementInput{GrossPaidAmountCent: grossPaidCent, MarkupAmountCent: markup, GrossProfitByParty: []PartyProfitInput{{PartyRole: "distributor_receive", IncomeAmountCent: order.OwnerRentAmountCent}, {PartyRole: "platform_sell", IncomeAmountCent: order.PlatformFeeCent}}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
financial := model.DistributionOrderFinancial{OrderID: order.ID, SiteID: *order.DistributionSiteID, RuleVersionID: rule.ID, GrossPaidAmountCent: grossPaidCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: markup, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, SettlementStatus: "pending"}
|
||||
if err := tx.Where("order_id = ?", order.ID).Assign(financial).FirstOrCreate(&financial).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&model.DistributionOrderParty{}).Where("order_id = ?", order.ID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
parties := []model.DistributionOrderParty{
|
||||
{OrderID: order.ID, SiteID: *order.DistributionSiteID, PartyRole: "distributor_receive", PartyType: "site", PartyID: *order.DistributionSiteID, Ratio: rule.DistributorReceiveRatio, IncomeAmountCent: order.OwnerRentAmountCent, ProfitAmountCent: order.OwnerRentAmountCent},
|
||||
{OrderID: order.ID, SiteID: *order.DistributionSiteID, PartyRole: "platform_sell", PartyType: "platform", Ratio: rule.PlatformSellRatio, IncomeAmountCent: order.PlatformFeeCent, ProfitAmountCent: order.PlatformFeeCent},
|
||||
}
|
||||
return tx.Create(&parties).Error
|
||||
}
|
||||
|
||||
// ApplyRefundTx 按累计成功退款金额重算手续费和分成基数,不覆盖订单创建时的规则版本。
|
||||
func ApplyRefundTx(tx *gorm.DB, orderID uint64, refundAmountCent int64) error {
|
||||
if !tx.Migrator().HasTable(&model.DistributionOrderFinancial{}) {
|
||||
return nil
|
||||
}
|
||||
var financial model.DistributionOrderFinancial
|
||||
if err := tx.Where("order_id = ?", orderID).First(&financial).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if refundAmountCent < 0 || refundAmountCent > financial.GrossPaidAmountCent {
|
||||
return ErrInvalidAmount
|
||||
}
|
||||
var rule model.DistributionRuleVersion
|
||||
if err := tx.First(&rule, financial.RuleVersionID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := CalculateSettlement(Rule{ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS}, SettlementInput{GrossPaidAmountCent: financial.GrossPaidAmountCent, RefundAmountCent: refundAmountCent, MarkupAmountCent: financial.MarkupAmountCent})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&financial).Updates(map[string]any{"refund_amount_cent": refundAmountCent, "net_paid_amount_cent": result.NetPaidAmountCent, "payment_fee_cent": result.PaymentFeeCent, "service_fee_cent": result.ServiceFeeCent, "split_base_amount_cent": result.SplitBaseAmountCent, "settlement_status": "pending"}).Error
|
||||
}
|
||||
@@ -21,12 +21,38 @@ var (
|
||||
ErrInvalidPeriod = errors.New("invalid distribution settlement period")
|
||||
ErrSettlementBatchNotFound = errors.New("distribution settlement batch not found")
|
||||
ErrBatchAlreadySettled = errors.New("distribution settlement batch already settled")
|
||||
ErrSiteAccessDenied = errors.New("distribution site access denied")
|
||||
)
|
||||
|
||||
type Repository struct{ db *gorm.DB }
|
||||
|
||||
func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} }
|
||||
|
||||
func (r *Repository) ScopeSiteID(ctx context.Context, adminID, requested uint64) (uint64, error) {
|
||||
if adminID == 0 {
|
||||
return requested, nil
|
||||
}
|
||||
var members []model.DistributionSiteMember
|
||||
if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return requested, nil
|
||||
}
|
||||
if requested > 0 {
|
||||
for _, member := range members {
|
||||
if member.SiteID == requested {
|
||||
return requested, nil
|
||||
}
|
||||
}
|
||||
return 0, ErrSiteAccessDenied
|
||||
}
|
||||
if len(members) == 1 {
|
||||
return members[0].SiteID, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListSites(ctx context.Context) ([]SiteDTO, error) {
|
||||
var rows []model.DistributionSite
|
||||
if err := r.db.WithContext(ctx).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
|
||||
@@ -16,7 +16,7 @@ func distributionTestDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.ChatConversation{}, &model.RentalOrder{}, &model.DistributionSite{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.ChatConversation{}, &model.RentalOrder{}, &model.DistributionSite{}, &model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
@@ -85,3 +85,24 @@ func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) {
|
||||
t.Fatalf("second settle err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeSiteIDRestrictsBoundMember(t *testing.T) {
|
||||
db := distributionTestDB(t)
|
||||
repo := NewRepository(db)
|
||||
site := model.DistributionSite{SiteCode: "scope", SiteName: "范围站", Domain: "scope.example.com"}
|
||||
if err := db.Create(&site).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.DistributionSiteMember{SiteID: site.ID, AdminUserID: 7, MemberType: "customer_service", Status: "active"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := repo.ScopeSiteID(t.Context(), 7, 0); err != nil || got != site.ID {
|
||||
t.Fatalf("scope = %d, err=%v", got, err)
|
||||
}
|
||||
if _, err := repo.ScopeSiteID(t.Context(), 7, site.ID+1); err != ErrSiteAccessDenied {
|
||||
t.Fatalf("access error = %v", err)
|
||||
}
|
||||
if got, err := repo.ScopeSiteID(t.Context(), 99, 123); err != nil || got != 123 {
|
||||
t.Fatalf("global scope = %d, err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package distribution
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
)
|
||||
|
||||
var ErrDependencyUnavailable = errors.New("distribution dependency unavailable")
|
||||
@@ -23,6 +25,42 @@ func (s *Service) ListSites(ctx context.Context) ([]SiteDTO, error) {
|
||||
}
|
||||
return s.repo.ListSites(ctx)
|
||||
}
|
||||
func (s *Service) ListSitesForAdmin(ctx context.Context, adminID uint64) ([]SiteDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := s.repo.ListSites(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var members []model.DistributionSiteMember
|
||||
if adminID == 0 || s.repo.db == nil {
|
||||
return items, nil
|
||||
}
|
||||
if err := s.repo.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return items, nil
|
||||
}
|
||||
allowed := make(map[uint64]struct{}, len(members))
|
||||
for _, member := range members {
|
||||
allowed[member.SiteID] = struct{}{}
|
||||
}
|
||||
filtered := make([]SiteDTO, 0, len(allowed))
|
||||
for _, item := range items {
|
||||
if _, ok := allowed[item.ID]; ok {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
func (s *Service) ScopeSiteID(ctx context.Context, adminID, requested uint64) (uint64, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.repo.ScopeSiteID(ctx, adminID, requested)
|
||||
}
|
||||
func (s *Service) FindSite(ctx context.Context, id uint64) (*SiteDTO, error) {
|
||||
if err := s.available(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -142,7 +142,8 @@ type ActiveDisputeDTO struct {
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
ListingID uint64 `json:"listing_id" binding:"required"`
|
||||
ListingID uint64 `json:"listing_id" binding:"required"`
|
||||
DistributionSiteCode string `json:"distribution_site_code" binding:"omitempty,max=64"`
|
||||
}
|
||||
|
||||
type SubmitHandoffRequest struct {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/chat"
|
||||
"hfb_sys/backend/internal/modules/distribution"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||
|
||||
@@ -50,6 +51,10 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
||||
}
|
||||
rentHours := estimateOrderDurationHours(snapshot)
|
||||
pricing := buildOrderPricing(listing, account)
|
||||
distributionSite, distributionRule, err := distribution.ResolveOrderSourceTx(tx, req.DistributionSiteCode, listing.ID, account.ID, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rentOriginalAmountCent := pricing.RentAmountCent
|
||||
growthSnapshot, err := rentergrowth.SnapshotForUser(tx, renterID)
|
||||
if err != nil {
|
||||
@@ -97,6 +102,11 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
||||
SettlementStatus: settlementStatusUnsettled,
|
||||
OfflineSettlementStatus: offlineSettlementStatusNone,
|
||||
}
|
||||
if distributionSite != nil && distributionRule != nil {
|
||||
order.DistributionSiteID = &distributionSite.ID
|
||||
order.DistributionMode = distributionRule.Mode
|
||||
order.DistributionRuleVersionID = &distributionRule.ID
|
||||
}
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package payment
|
||||
import (
|
||||
"context"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/distribution"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -69,6 +70,9 @@ func (r *Repository) confirmPaid(ctx context.Context, payment *model.PaymentOrde
|
||||
}
|
||||
newConvID = convID
|
||||
notifyFn = r.orderRepo.NotifyNewConversation
|
||||
if err := distribution.SnapshotPaidOrderTx(tx, payment.OrderID, payment.AmountCent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/distribution"
|
||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -429,7 +430,10 @@ func (r *Repository) updateOrderRefundStatus(ctx context.Context, orderID uint64
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Save(&order).Error
|
||||
if err := tx.Save(&order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return distribution.ApplyRefundTx(tx, orderID, refundAmountCent)
|
||||
})
|
||||
}
|
||||
func (r *Repository) markOrderRefunding(ctx context.Context, orderID uint64, refundAmountCent int64) error {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE chat_qrcode_pool
|
||||
ADD COLUMN distribution_site_id BIGINT UNSIGNED NULL AFTER group_name,
|
||||
ADD KEY idx_chat_qrcode_distribution_site (distribution_site_id, status),
|
||||
ADD CONSTRAINT fk_chat_qrcode_distribution_site FOREIGN KEY (distribution_site_id) REFERENCES distribution_sites (id);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE chat_qrcode_pool
|
||||
DROP FOREIGN KEY fk_chat_qrcode_distribution_site,
|
||||
DROP KEY idx_chat_qrcode_distribution_site,
|
||||
DROP COLUMN distribution_site_id;
|
||||
+5
-2
@@ -762,7 +762,7 @@ distribution.hao858.com {
|
||||
|
||||
后续每次确认业务规则后,优先更新第 7、15 节,再同步更新第 8、9、13、14 节,确保规则、数据、接口、实施和验收标准保持一致。
|
||||
|
||||
## 16. 当前实现基线(v0.3)
|
||||
## 16. 当前实现基线(v0.4)
|
||||
|
||||
本分支已落地第一批可运行功能,主站域名为 `https://hao858.com/`,独立后台工程位于 `distribution-admin/`,子域名暂定 `https://distribution.hao858.com/`:
|
||||
|
||||
@@ -772,5 +772,8 @@ distribution.hao858.com {
|
||||
- 管理 API 已覆盖站点、规则、售出/收号订单查询、财务快照、账号展示开关、支付通道绑定和结算批次。
|
||||
- 后台权限使用 `distribution:*` 权限码;后续接入成员站点数据范围校验时,不能使用请求参数扩大范围。
|
||||
- `distribution-admin` 使用 React + TypeScript + Vite + Ant Design,已提供仪表盘、站点、售出订单、收号订单及结算/支付/账号/规则/客服入口和 Docker 镜像。
|
||||
- 正常订单创建可通过 `distribution_site_code` 选择站点;后端校验账号展示关系和当前生效规则后写入站点、模式和规则版本快照。支付成功自动生成财务与参与方快照,退款成功按净支付金额更新手续费和分成基数。
|
||||
- 二维码池支持 `distribution_site_id`,分销站点建群时优先从专属池取码;未绑定站点或旧环境自动回退全局池。
|
||||
- 已绑定分销站点的后台成员在订单、账号、支付通道和结算查询中强制使用成员范围;无站点成员关系的主站管理员保留全局视图。
|
||||
|
||||
当前仍需在下一迭代接入真实支付回调、退款/订单完成结算、钱包流水、客服二维码池和成员级站点隔离;在这些链路接通前,财务快照接口只作为后台受控的幂等写入入口。
|
||||
当前仍需在下一迭代接入分销钱包流水、完整订单完成结算和客服消息发送权限;财务快照、支付成功和退款更新已具备幂等基础。
|
||||
|
||||
Reference in New Issue
Block a user