实现分销网后台基础功能与独立前端

This commit is contained in:
yml2213
2026-09-05 10:52:36 +08:00
parent 85332df2bd
commit 2ffa7a89b1
24 changed files with 6122 additions and 25 deletions
+164
View File
@@ -0,0 +1,164 @@
package model
import "time"
// DistributionSite 表示一个独立分销网站主体。
type DistributionSite struct {
ID uint64 `gorm:"primaryKey" json:"id"`
SiteCode string `gorm:"size:64;not null;uniqueIndex" json:"site_code"`
SiteName string `gorm:"size:128;not null" json:"site_name"`
Domain string `gorm:"size:255;not null;uniqueIndex" json:"domain"`
Status string `gorm:"size:16;not null;default:'active';index" json:"status"`
DisplayAccountEnabled bool `gorm:"not null;default:true" json:"display_account_enabled"`
DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"`
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (DistributionSite) TableName() string { return "distribution_sites" }
// DistributionSiteMember 将现有后台管理员映射到分销网站。
type DistributionSiteMember struct {
ID uint64 `gorm:"primaryKey" json:"id"`
SiteID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_member;index" json:"site_id"`
AdminUserID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_member;index" json:"admin_user_id"`
MemberType string `gorm:"size:32;not null" json:"member_type"`
Status string `gorm:"size:16;not null;default:'active';index" json:"status"`
DataScope string `gorm:"size:32;not null;default:'site'" json:"data_scope"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (DistributionSiteMember) TableName() string { return "distribution_site_members" }
// DistributionSiteAccount 控制账号在分销网站的展示状态。
type DistributionSiteAccount struct {
ID uint64 `gorm:"primaryKey" json:"id"`
SiteID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_account;index" json:"site_id"`
AccountID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_account;index" json:"account_id"`
ListingID uint64 `gorm:"not null;index" json:"listing_id"`
DisplayStatus string `gorm:"size:16;not null;default:'enabled';index" json:"display_status"`
DisplayStartedAt *time.Time `json:"display_started_at,omitempty"`
DisplayEndedAt *time.Time `json:"display_ended_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (DistributionSiteAccount) TableName() string { return "distribution_site_accounts" }
// DistributionRuleVersion 是新订单使用的分账规则快照来源。
type DistributionRuleVersion struct {
ID uint64 `gorm:"primaryKey" json:"id"`
SiteID *uint64 `gorm:"index" json:"site_id,omitempty"`
Mode string `gorm:"size:32;not null;index" json:"mode"`
Version int `gorm:"not null" json:"version"`
Status string `gorm:"size:16;not null;default:'draft';index" json:"status"`
PlatformReceiveRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"platform_receive_ratio"`
PlatformSellRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"platform_sell_ratio"`
DistributorReceiveRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"distributor_receive_ratio"`
DistributorSellRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"distributor_sell_ratio"`
DistributorARatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"distributor_a_ratio"`
DistributorBRatio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"distributor_b_ratio"`
ServiceFeeValue float64 `gorm:"type:decimal(12,4);not null;default:0" json:"service_fee_value"`
ServiceFeeUnit string `gorm:"size:24;not null;default:'percent'" json:"service_fee_unit"`
PaymentFeeBPS int64 `gorm:"not null;default:50" json:"payment_fee_bps"`
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
EffectiveFrom time.Time `gorm:"index" json:"effective_from"`
EffectiveTo *time.Time `gorm:"index" json:"effective_to,omitempty"`
CreatedBy *uint64 `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func (DistributionRuleVersion) TableName() string { return "distribution_rule_versions" }
// DistributionOrderFinancial 保存正常订单的分销财务快照。
type DistributionOrderFinancial struct {
ID uint64 `gorm:"primaryKey" json:"id"`
OrderID uint64 `gorm:"not null;uniqueIndex" json:"order_id"`
SiteID uint64 `gorm:"not null;index" json:"site_id"`
RuleVersionID uint64 `gorm:"not null" json:"rule_version_id"`
GrossPaidAmountCent int64 `gorm:"not null;default:0" json:"gross_paid_amount_cent"`
RefundAmountCent int64 `gorm:"not null;default:0" json:"refund_amount_cent"`
NetPaidAmountCent int64 `gorm:"not null;default:0" json:"net_paid_amount_cent"`
PaymentFeeBPS int64 `gorm:"not null;default:50" json:"payment_fee_bps"`
PaymentFeeCent int64 `gorm:"not null;default:0" json:"payment_fee_cent"`
ServiceFeeValue float64 `gorm:"type:decimal(12,4);not null;default:0" json:"service_fee_value"`
ServiceFeeUnit string `gorm:"size:24;not null;default:'percent'" json:"service_fee_unit"`
ServiceFeeCent int64 `gorm:"not null;default:0" json:"service_fee_cent"`
MarkupAmountCent int64 `gorm:"not null;default:0" json:"markup_amount_cent"`
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
TaxAmountCent int64 `gorm:"not null;default:0" json:"tax_amount_cent"`
SplitBaseAmountCent int64 `gorm:"not null;default:0" json:"split_base_amount_cent"`
RoundingAdjustmentCent int64 `gorm:"not null;default:0" json:"rounding_adjustment_cent"`
SettlementStatus string `gorm:"size:24;not null;default:'pending';index" json:"settlement_status"`
SettledAt *time.Time `json:"settled_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (DistributionOrderFinancial) TableName() string { return "distribution_order_financials" }
// DistributionOrderParty 保存订单创建时的参与方和金额快照,避免规则或站点变更影响历史订单。
type DistributionOrderParty struct {
ID uint64 `gorm:"primaryKey" json:"id"`
OrderID uint64 `gorm:"not null;index" json:"order_id"`
SiteID uint64 `gorm:"not null;index" json:"site_id"`
PartyRole string `gorm:"size:32;not null;index" json:"party_role"`
PartyType string `gorm:"size:16;not null" json:"party_type"`
PartyID uint64 `gorm:"not null" json:"party_id"`
Ratio float64 `gorm:"type:decimal(10,2);not null;default:0" json:"ratio"`
IncomeAmountCent int64 `gorm:"not null;default:0" json:"income_amount_cent"`
CostAmountCent int64 `gorm:"not null;default:0" json:"cost_amount_cent"`
ProfitAmountCent int64 `gorm:"not null;default:0" json:"profit_amount_cent"`
TaxAmountCent int64 `gorm:"not null;default:0" json:"tax_amount_cent"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (DistributionOrderParty) TableName() string { return "distribution_order_parties" }
// DistributionSettlementBatch 是按站点生成的结算批次。
type DistributionSettlementBatch struct {
ID uint64 `gorm:"primaryKey" json:"id"`
SiteID uint64 `gorm:"not null;index" json:"site_id"`
BatchNo string `gorm:"size:64;not null;uniqueIndex" json:"batch_no"`
PeriodStart time.Time `gorm:"not null" json:"period_start"`
PeriodEnd time.Time `gorm:"not null" json:"period_end"`
TotalAmountCent int64 `gorm:"not null;default:0" json:"total_amount_cent"`
Status string `gorm:"size:16;not null;default:'draft';index" json:"status"`
CreatedBy *uint64 `json:"created_by,omitempty"`
SettledAt *time.Time `json:"settled_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (DistributionSettlementBatch) TableName() string { return "distribution_settlement_batches" }
type DistributionSettlementItem struct {
ID uint64 `gorm:"primaryKey" json:"id"`
BatchID uint64 `gorm:"not null;index" json:"batch_id"`
OrderID uint64 `gorm:"not null;index" json:"order_id"`
PartyID uint64 `gorm:"not null;index" json:"party_id"`
AmountCent int64 `gorm:"not null;default:0" json:"amount_cent"`
Status string `gorm:"size:16;not null;default:'pending'" json:"status"`
CreatedAt time.Time `json:"created_at"`
}
func (DistributionSettlementItem) TableName() string { return "distribution_settlement_items" }
// DistributionSitePaymentChannel 将一个或多个现有支付商户配置绑定到分销站点。
type DistributionSitePaymentChannel struct {
ID uint64 `gorm:"primaryKey" json:"id"`
SiteID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_payment;index" json:"site_id"`
ConfigID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_payment;index" json:"config_id"`
Priority int `gorm:"not null;default:0" json:"priority"`
Status string `gorm:"size:16;not null;default:'active';index" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (DistributionSitePaymentChannel) TableName() string { return "distribution_site_payment_channels" }
+30 -24
View File
@@ -41,30 +41,36 @@ type RentalOrder struct {
GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"`
GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"`
AccountSnapshot datatypes.JSON `json:"account_snapshot"`
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"`
ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"`
SettlementStatus string `gorm:"size:32;not null;default:'unsettled'" json:"settlement_status"`
OfflineSettlementStatus string `gorm:"size:16;not null;default:'none';index" json:"offline_settlement_status"`
OfflineSettlementAmountCent int64 `gorm:"not null;default:0" json:"offline_settlement_amount_cent"`
OfflineSettlementRemark string `gorm:"size:255;not null;default:''" json:"offline_settlement_remark"`
OfflineSettledBy *uint64 `json:"offline_settled_by"`
OfflineSettledAt *time.Time `json:"offline_settled_at"`
RefundStatus string `gorm:"size:32;not null;default:'none';index" json:"refund_status"`
RefundAmountCent int64 `gorm:"not null;default:0" json:"refund_amount_cent"`
RefundedAt *time.Time `json:"refunded_at"`
DepositHoldStatus string `gorm:"size:16;not null;default:'none';index" json:"deposit_hold_status"`
DepositHoldAmountCent int64 `gorm:"not null;default:0" json:"deposit_hold_amount_cent"`
DepositHoldReason string `gorm:"size:255;not null;default:''" json:"deposit_hold_reason"`
DepositHeldBy *uint64 `json:"deposit_held_by"`
DepositHeldAt *time.Time `json:"deposit_held_at"`
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at"`
OwnerSettledAt *time.Time `json:"owner_settled_at"`
SettledAt *time.Time `json:"settled_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"`
SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"`
// 分销正常订单字段:仅在卖家和租客都通过分销网站完成交易时填写。
DistributionSiteID *uint64 `gorm:"index" json:"distribution_site_id,omitempty"`
DistributionMode string `gorm:"size:32;not null;default:'';index" json:"distribution_mode"`
DistributionRuleVersionID *uint64 `gorm:"index" json:"distribution_rule_version_id,omitempty"`
Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"`
HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"`
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"`
ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"`
SettlementStatus string `gorm:"size:32;not null;default:'unsettled'" json:"settlement_status"`
OfflineSettlementStatus string `gorm:"size:16;not null;default:'none';index" json:"offline_settlement_status"`
OfflineSettlementAmountCent int64 `gorm:"not null;default:0" json:"offline_settlement_amount_cent"`
OfflineSettlementRemark string `gorm:"size:255;not null;default:''" json:"offline_settlement_remark"`
OfflineSettledBy *uint64 `json:"offline_settled_by"`
OfflineSettledAt *time.Time `json:"offline_settled_at"`
RefundStatus string `gorm:"size:32;not null;default:'none';index" json:"refund_status"`
RefundAmountCent int64 `gorm:"not null;default:0" json:"refund_amount_cent"`
RefundedAt *time.Time `json:"refunded_at"`
DepositHoldStatus string `gorm:"size:16;not null;default:'none';index" json:"deposit_hold_status"`
DepositHoldAmountCent int64 `gorm:"not null;default:0" json:"deposit_hold_amount_cent"`
DepositHoldReason string `gorm:"size:255;not null;default:''" json:"deposit_hold_reason"`
DepositHeldBy *uint64 `json:"deposit_held_by"`
DepositHeldAt *time.Time `json:"deposit_held_at"`
DepositHoldReleasedAt *time.Time `json:"deposit_hold_released_at"`
OwnerSettledAt *time.Time `json:"owner_settled_at"`
SettledAt *time.Time `json:"settled_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (RentalOrder) TableName() string {
@@ -0,0 +1,179 @@
package distribution
import "time"
type SiteDTO struct {
ID uint64 `json:"id"`
SiteCode string `json:"site_code"`
SiteName string `json:"site_name"`
Domain string `json:"domain"`
Status string `json:"status"`
DisplayAccountEnabled bool `json:"display_account_enabled"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
TaxEnabled bool `json:"tax_enabled"`
TaxRateBPS int64 `json:"tax_rate_bps"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateSiteRequest struct {
SiteCode string `json:"site_code" binding:"required,max=64"`
SiteName string `json:"site_name" binding:"required,max=128"`
Domain string `json:"domain" binding:"required,max=255"`
DisplayAccountEnabled *bool `json:"display_account_enabled"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
TaxEnabled *bool `json:"tax_enabled"`
TaxRateBPS *int64 `json:"tax_rate_bps"`
}
type UpdateSiteRequest struct {
SiteName *string `json:"site_name"`
Domain *string `json:"domain"`
Status *string `json:"status"`
DisplayAccountEnabled *bool `json:"display_account_enabled"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
TaxEnabled *bool `json:"tax_enabled"`
TaxRateBPS *int64 `json:"tax_rate_bps"`
}
type RuleDTO struct {
ID uint64 `json:"id"`
SiteID *uint64 `json:"site_id,omitempty"`
Mode string `json:"mode"`
Version int `json:"version"`
Status string `json:"status"`
PlatformReceiveRatio float64 `json:"platform_receive_ratio"`
PlatformSellRatio float64 `json:"platform_sell_ratio"`
DistributorReceiveRatio float64 `json:"distributor_receive_ratio"`
DistributorSellRatio float64 `json:"distributor_sell_ratio"`
DistributorARatio float64 `json:"distributor_a_ratio"`
DistributorBRatio float64 `json:"distributor_b_ratio"`
ServiceFeeValue float64 `json:"service_fee_value"`
ServiceFeeUnit string `json:"service_fee_unit"`
PaymentFeeBPS int64 `json:"payment_fee_bps"`
TaxEnabled bool `json:"tax_enabled"`
TaxRateBPS int64 `json:"tax_rate_bps"`
EffectiveFrom time.Time `json:"effective_from"`
EffectiveTo *time.Time `json:"effective_to,omitempty"`
CreatedBy *uint64 `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type CreateRuleRequest struct {
SiteID *uint64 `json:"site_id"`
Mode string `json:"mode" binding:"required"`
PlatformReceiveRatio float64 `json:"platform_receive_ratio"`
PlatformSellRatio float64 `json:"platform_sell_ratio"`
DistributorReceiveRatio float64 `json:"distributor_receive_ratio"`
DistributorSellRatio float64 `json:"distributor_sell_ratio"`
DistributorARatio float64 `json:"distributor_a_ratio"`
DistributorBRatio float64 `json:"distributor_b_ratio"`
ServiceFeeValue float64 `json:"service_fee_value"`
ServiceFeeUnit string `json:"service_fee_unit" binding:"required"`
PaymentFeeBPS int64 `json:"payment_fee_bps"`
TaxEnabled bool `json:"tax_enabled"`
TaxRateBPS int64 `json:"tax_rate_bps"`
EffectiveFrom time.Time `json:"effective_from"`
}
type UpdateRuleStatusRequest struct {
Status string `json:"status" binding:"required,oneof=draft active disabled"`
}
type DistributionOrderDTO struct {
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
SiteID uint64 `json:"site_id"`
ListingID uint64 `json:"listing_id"`
AccountID uint64 `json:"account_id"`
OwnerID uint64 `json:"owner_id"`
RenterID uint64 `json:"renter_id"`
Status string `json:"status"`
HandoffStatus string `json:"handoff_status"`
RefundStatus string `json:"refund_status"`
SettlementStatus string `json:"settlement_status"`
DistributionMode string `json:"distribution_mode"`
GrossPaidAmountCent int64 `json:"gross_paid_amount_cent"`
RefundAmountCent int64 `json:"refund_amount_cent"`
NetPaidAmountCent int64 `json:"net_paid_amount_cent"`
PaymentFeeCent int64 `json:"payment_fee_cent"`
ServiceFeeCent int64 `json:"service_fee_cent"`
MarkupAmountCent int64 `json:"markup_amount_cent"`
TaxAmountCent int64 `json:"tax_amount_cent"`
SplitBaseAmountCent int64 `json:"split_base_amount_cent"`
CreatedAt time.Time `json:"created_at"`
}
type DistributionOrderListQuery struct {
SiteID uint64
Kind string
Status string
Page int
Size int
}
type DistributionOrderPage struct {
Items []DistributionOrderDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type AccountDisplayDTO struct {
ID uint64 `json:"id"`
SiteID uint64 `json:"site_id"`
AccountID uint64 `json:"account_id"`
ListingID uint64 `json:"listing_id"`
DisplayStatus string `json:"display_status"`
}
type UpdateAccountDisplayRequest struct {
DisplayStatus string `json:"display_status" binding:"required,oneof=enabled disabled sold offline"`
}
type PaymentChannelDTO struct {
ID uint64 `json:"id"`
SiteID uint64 `json:"site_id"`
ConfigID uint64 `json:"config_id"`
Priority int `json:"priority"`
Status string `json:"status"`
}
type BindPaymentChannelRequest struct {
ConfigID uint64 `json:"config_id" binding:"required"`
Priority int `json:"priority"`
}
type SettlementBatchDTO struct {
ID uint64 `json:"id"`
SiteID uint64 `json:"site_id"`
BatchNo string `json:"batch_no"`
PeriodStart time.Time `json:"period_start"`
PeriodEnd time.Time `json:"period_end"`
TotalAmountCent int64 `json:"total_amount_cent"`
Status string `json:"status"`
}
type CreateSettlementBatchRequest struct {
SiteID uint64 `json:"site_id" binding:"required"`
PeriodStart time.Time `json:"period_start" binding:"required"`
PeriodEnd time.Time `json:"period_end" binding:"required"`
}
type SnapshotPartyRequest struct {
PartyRole string `json:"party_role" binding:"required"`
PartyType string `json:"party_type" binding:"required"`
PartyID uint64 `json:"party_id"`
Ratio float64 `json:"ratio"`
IncomeAmountCent int64 `json:"income_amount_cent"`
CostAmountCent int64 `json:"cost_amount_cent"`
}
type CreateFinancialSnapshotRequest struct {
SiteID uint64 `json:"site_id" binding:"required"`
RuleVersionID uint64 `json:"rule_version_id" binding:"required"`
GrossPaidAmountCent int64 `json:"gross_paid_amount_cent"`
RefundAmountCent int64 `json:"refund_amount_cent"`
MarkupAmountCent int64 `json:"markup_amount_cent"`
Parties []SnapshotPartyRequest `json:"parties"`
}
@@ -0,0 +1,343 @@
package distribution
import (
"errors"
"net/http"
"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) ListSites(c *gin.Context) {
items, err := h.service.ListSites(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) GetSite(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
item, err := h.service.FindSite(c.Request.Context(), id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) CreateSite(c *gin.Context) {
var req CreateSiteRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "分销网站参数不正确")
return
}
item, err := h.service.CreateSite(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) UpdateSite(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateSiteRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "分销网站参数不正确")
return
}
item, err := h.service.UpdateSite(c.Request.Context(), id, req)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) ListRules(c *gin.Context) {
var siteID *uint64
if raw := strings.TrimSpace(c.Query("site_id")); raw != "" {
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "site_id 不正确")
return
}
siteID = &id
}
items, err := h.service.ListRules(c.Request.Context(), siteID, c.Query("mode"))
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) CreateRule(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req CreateRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "分销规则参数不正确")
return
}
item, err := h.service.CreateRule(c.Request.Context(), adminID, req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) UpdateRuleStatus(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateRuleStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "规则状态不正确")
return
}
item, err := h.service.UpdateRuleStatus(c.Request.Context(), id, req.Status)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) ListOrders(c *gin.Context) {
siteID := parseQueryUint(c, "site_id")
if siteID < 0 {
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})
if err != nil {
writeError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) FindOrder(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
item, err := h.service.FindOrder(c.Request.Context(), id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) CreateFinancialSnapshot(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req CreateFinancialSnapshotRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "分销财务快照参数不正确")
return
}
item, err := h.service.CreateFinancialSnapshot(c.Request.Context(), id, req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) ListAccountDisplays(c *gin.Context) {
siteID := parseQueryUint(c, "site_id")
if siteID <= 0 {
if siteID == 0 {
response.BadRequest(c, "site_id 不正确")
}
return
}
items, err := h.service.ListAccountDisplays(c.Request.Context(), uint64(siteID), c.Query("status"))
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) UpdateAccountDisplay(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateAccountDisplayRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "账号展示状态不正确")
return
}
item, err := h.service.UpdateAccountDisplay(c.Request.Context(), id, req.DisplayStatus)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) ListPaymentChannels(c *gin.Context) {
siteID := parseQueryUint(c, "site_id")
if siteID <= 0 {
response.BadRequest(c, "site_id 不正确")
return
}
items, err := h.service.ListPaymentChannels(c.Request.Context(), uint64(siteID))
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) BindPaymentChannel(c *gin.Context) {
siteID := parseQueryUint(c, "site_id")
if siteID <= 0 {
response.BadRequest(c, "site_id 不正确")
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)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) UnbindPaymentChannel(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.service.UnbindPaymentChannel(c.Request.Context(), id); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) ListSettlementBatches(c *gin.Context) {
siteID := parseQueryUint(c, "site_id")
if siteID <= 0 {
response.BadRequest(c, "site_id 不正确")
return
}
items, err := h.service.ListSettlementBatches(c.Request.Context(), uint64(siteID))
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) CreateSettlementBatch(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req CreateSettlementBatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "结算周期参数不正确")
return
}
item, err := h.service.CreateSettlementBatch(c.Request.Context(), adminID, req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) MarkSettlementBatch(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
item, err := h.service.MarkSettlementBatch(c.Request.Context(), id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
// parseQueryUint 返回 -1 表示格式错误,0 表示未传入。
func parseQueryUint(c *gin.Context, key string) int64 {
raw := strings.TrimSpace(c.Query(key))
if raw == "" {
return 0
}
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, key+" 不正确")
return -1
}
return int64(id)
}
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 && id > 0
}
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 writeError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrSiteNotFound), errors.Is(err, ErrRuleNotFound), errors.Is(err, ErrOrderNotFound), errors.Is(err, ErrAccountDisplayNotFound), errors.Is(err, ErrPaymentChannelNotFound), errors.Is(err, ErrSettlementBatchNotFound):
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, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "分销服务暂时不可用")
default:
response.RecordError(c, err)
response.Error(c, http.StatusInternalServerError, "distribution_error", "分销服务暂时不可用")
}
}
@@ -0,0 +1,162 @@
package distribution
import (
"errors"
"math"
)
const (
ModeTwoPartyDistributorReceive = "two_party_distributor_receive"
ModeTwoPartyPlatformReceive = "two_party_platform_receive"
ModeThreeParty = "three_party"
ServiceFeeUnitPercent = "percent"
ServiceFeeUnitFixedAmount = "fixed_amount"
ServiceFeeUnitRatio = "ratio"
)
var (
ErrInvalidAmount = errors.New("invalid distribution amount")
ErrInvalidRate = errors.New("invalid distribution rate")
ErrInvalidServiceUnit = errors.New("invalid service fee unit")
ErrUnsupportedMode = errors.New("unsupported distribution mode")
)
// Rule 是后台可编辑的分账规则。比例字段按参与方角色命名,避免方向歧义。
type Rule struct {
Mode string
PlatformReceiveRatio float64
PlatformSellRatio float64
DistributorReceiveRatio float64
DistributorSellRatio float64
DistributorARatio float64
DistributorBRatio float64
ServiceFeeValue float64
ServiceFeeUnit string
PaymentFeeBPS int64
TaxEnabled bool
TaxRateBPS int64
}
// SettlementInput 使用支付单和退款单的整数分快照计算分销费用。
type SettlementInput struct {
GrossPaidAmountCent int64
RefundAmountCent int64
MarkupAmountCent int64
GrossProfitByParty []PartyProfitInput
}
type PartyProfitInput struct {
PartyRole string
IncomeAmountCent int64
CostAmountCent int64
}
type PartyTaxResult struct {
PartyRole string `json:"party_role"`
GrossProfitCent int64 `json:"gross_profit_cent"`
TaxAmountCent int64 `json:"tax_amount_cent"`
}
type SettlementResult struct {
GrossPaidAmountCent int64
RefundAmountCent int64
NetPaidAmountCent int64
PaymentFeeCent int64
ServiceFeeCent int64
MarkupAmountCent int64
SplitBaseAmountCent int64
TaxAmountCent int64
TaxByParty []PartyTaxResult
}
// DefaultRule 返回文档中约定的第一版默认值;调用方应将其写入规则表而非写死到业务流程。
func DefaultRule(mode string) (Rule, error) {
base := Rule{
Mode: mode, ServiceFeeValue: 2.5, ServiceFeeUnit: ServiceFeeUnitRatio,
PaymentFeeBPS: 50, TaxEnabled: false, TaxRateBPS: 100,
}
switch mode {
case ModeTwoPartyDistributorReceive:
base.DistributorReceiveRatio = 3.5
base.PlatformSellRatio = 4
case ModeTwoPartyPlatformReceive:
base.PlatformReceiveRatio = 3.5
base.DistributorSellRatio = 4
case ModeThreeParty:
base.DistributorARatio = 3.5
base.DistributorBRatio = 4
default:
return Rule{}, ErrUnsupportedMode
}
return base, nil
}
// CalculateSettlement 先扣支付手续费,再计算服务费和分成基数;退款金额不能超过支付金额。
func CalculateSettlement(rule Rule, input SettlementInput) (SettlementResult, error) {
if input.GrossPaidAmountCent < 0 || input.RefundAmountCent < 0 || input.MarkupAmountCent < 0 || input.RefundAmountCent > input.GrossPaidAmountCent {
return SettlementResult{}, ErrInvalidAmount
}
if rule.PaymentFeeBPS < 0 || rule.PaymentFeeBPS > 10000 || rule.TaxRateBPS < 0 || rule.TaxRateBPS > 10000 || rule.ServiceFeeValue < 0 {
return SettlementResult{}, ErrInvalidRate
}
if rule.ServiceFeeUnit != ServiceFeeUnitPercent && rule.ServiceFeeUnit != ServiceFeeUnitFixedAmount && rule.ServiceFeeUnit != ServiceFeeUnitRatio {
return SettlementResult{}, ErrInvalidServiceUnit
}
netPaid := input.GrossPaidAmountCent - input.RefundAmountCent
paymentFee := roundHalfUp(float64(netPaid) * float64(rule.PaymentFeeBPS) / 10000)
serviceFee, err := serviceFeeCent(rule, netPaid)
if err != nil {
return SettlementResult{}, err
}
splitBase := netPaid - paymentFee - serviceFee
if splitBase < 0 {
splitBase = 0
}
result := SettlementResult{
GrossPaidAmountCent: input.GrossPaidAmountCent,
RefundAmountCent: input.RefundAmountCent,
NetPaidAmountCent: netPaid,
PaymentFeeCent: paymentFee,
ServiceFeeCent: serviceFee,
MarkupAmountCent: input.MarkupAmountCent,
SplitBaseAmountCent: splitBase,
}
if rule.TaxEnabled {
result.TaxByParty = make([]PartyTaxResult, 0, len(input.GrossProfitByParty))
for _, party := range input.GrossProfitByParty {
profit := party.IncomeAmountCent - party.CostAmountCent
tax := roundHalfUp(float64(max(profit, 0)) * float64(rule.TaxRateBPS) / 10000)
result.TaxAmountCent += tax
result.TaxByParty = append(result.TaxByParty, PartyTaxResult{PartyRole: party.PartyRole, GrossProfitCent: profit, TaxAmountCent: tax})
}
}
return result, nil
}
func serviceFeeCent(rule Rule, netPaid int64) (int64, error) {
switch rule.ServiceFeeUnit {
case ServiceFeeUnitPercent:
return roundHalfUp(float64(netPaid) * rule.ServiceFeeValue / 100), nil
case ServiceFeeUnitFixedAmount:
return roundHalfUp(rule.ServiceFeeValue * 100), nil
case ServiceFeeUnitRatio:
if rule.ServiceFeeValue <= 0 {
return 0, nil
}
return roundHalfUp(float64(netPaid) / rule.ServiceFeeValue), nil
default:
return 0, ErrInvalidServiceUnit
}
}
func roundHalfUp(value float64) int64 {
return int64(math.Floor(value + 0.5))
}
func max(a, b int64) int64 {
if a > b {
return a
}
return b
}
@@ -0,0 +1,55 @@
package distribution
import "testing"
func TestDefaultRules(t *testing.T) {
cases := []struct {
mode string
check func(Rule) bool
}{
{ModeTwoPartyDistributorReceive, func(r Rule) bool { return r.DistributorReceiveRatio == 3.5 && r.PlatformSellRatio == 4 }},
{ModeTwoPartyPlatformReceive, func(r Rule) bool { return r.PlatformReceiveRatio == 3.5 && r.DistributorSellRatio == 4 }},
{ModeThreeParty, func(r Rule) bool { return r.DistributorARatio == 3.5 && r.DistributorBRatio == 4 }},
}
for _, item := range cases {
rule, err := DefaultRule(item.mode)
if err != nil || !item.check(rule) || rule.ServiceFeeValue != 2.5 || rule.PaymentFeeBPS != 50 {
t.Fatalf("default rule %s = %#v, err=%v", item.mode, rule, err)
}
}
}
func TestCalculateSettlementDeductsFeeBeforeSplit(t *testing.T) {
rule := Rule{ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50}
got, err := CalculateSettlement(rule, SettlementInput{GrossPaidAmountCent: 10001, RefundAmountCent: 1000, MarkupAmountCent: 300})
if err != nil {
t.Fatal(err)
}
if got.NetPaidAmountCent != 9001 || got.PaymentFeeCent != 45 || got.SplitBaseAmountCent != 8956 {
t.Fatalf("unexpected settlement: %#v", got)
}
}
func TestCalculateSettlementTaxesEachPositivePartyProfit(t *testing.T) {
rule := Rule{ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 0, TaxEnabled: true, TaxRateBPS: 100}
got, err := CalculateSettlement(rule, SettlementInput{
GrossPaidAmountCent: 10000,
GrossProfitByParty: []PartyProfitInput{
{PartyRole: "distributor_a", IncomeAmountCent: 1000, CostAmountCent: 400},
{PartyRole: "distributor_b", IncomeAmountCent: 500, CostAmountCent: 700},
},
})
if err != nil {
t.Fatal(err)
}
if got.TaxAmountCent != 6 || len(got.TaxByParty) != 2 || got.TaxByParty[0].TaxAmountCent != 6 || got.TaxByParty[1].TaxAmountCent != 0 {
t.Fatalf("unexpected tax result: %#v", got)
}
}
func TestCalculateSettlementRejectsRefundOverPayment(t *testing.T) {
_, err := CalculateSettlement(Rule{ServiceFeeUnit: ServiceFeeUnitFixedAmount}, SettlementInput{GrossPaidAmountCent: 1, RefundAmountCent: 2})
if err != ErrInvalidAmount {
t.Fatalf("err = %v", err)
}
}
@@ -0,0 +1,399 @@
package distribution
import (
"context"
"errors"
"strings"
"time"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
var (
ErrSiteNotFound = errors.New("distribution site not found")
ErrRuleNotFound = errors.New("distribution rule not found")
ErrOrderNotFound = errors.New("distribution order not found")
ErrInvalidOrderKind = errors.New("invalid distribution order kind")
ErrAccountDisplayNotFound = errors.New("distribution account display not found")
ErrPaymentChannelNotFound = errors.New("distribution payment channel not found")
ErrInvalidPeriod = errors.New("invalid distribution settlement period")
ErrSettlementBatchNotFound = errors.New("distribution settlement batch not found")
ErrBatchAlreadySettled = errors.New("distribution settlement batch already settled")
)
type Repository struct{ db *gorm.DB }
func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} }
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 {
return nil, err
}
items := make([]SiteDTO, 0, len(rows))
for _, row := range rows {
items = append(items, siteDTO(row))
}
return items, nil
}
func (r *Repository) FindSite(ctx context.Context, id uint64) (*SiteDTO, error) {
var row model.DistributionSite
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSiteNotFound
}
return nil, err
}
item := siteDTO(row)
return &item, nil
}
func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteDTO, error) {
row := model.DistributionSite{
SiteCode: strings.TrimSpace(req.SiteCode), SiteName: strings.TrimSpace(req.SiteName), Domain: strings.TrimSpace(req.Domain),
Status: "active", DisplayAccountEnabled: true, TaxRateBPS: 100,
}
if req.DisplayAccountEnabled != nil {
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
}
if req.DefaultSupportGroupID != nil {
row.DefaultSupportGroupID = req.DefaultSupportGroupID
}
if req.TaxEnabled != nil {
row.TaxEnabled = *req.TaxEnabled
}
if req.TaxRateBPS != nil {
row.TaxRateBPS = *req.TaxRateBPS
}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
return nil, err
}
item := siteDTO(row)
return &item, nil
}
func (r *Repository) UpdateSite(ctx context.Context, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
var row model.DistributionSite
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSiteNotFound
}
return nil, err
}
if req.SiteName != nil {
row.SiteName = strings.TrimSpace(*req.SiteName)
}
if req.Domain != nil {
row.Domain = strings.TrimSpace(*req.Domain)
}
if req.Status != nil {
row.Status = strings.TrimSpace(*req.Status)
}
if req.DisplayAccountEnabled != nil {
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
}
if req.DefaultSupportGroupID != nil {
row.DefaultSupportGroupID = req.DefaultSupportGroupID
}
if req.TaxEnabled != nil {
row.TaxEnabled = *req.TaxEnabled
}
if req.TaxRateBPS != nil {
row.TaxRateBPS = *req.TaxRateBPS
}
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
return nil, err
}
item := siteDTO(row)
return &item, nil
}
func (r *Repository) ListRules(ctx context.Context, siteID *uint64, mode string) ([]RuleDTO, error) {
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).Order("effective_from DESC, id DESC")
if siteID == nil {
db = db.Where("site_id IS NULL")
} else {
db = db.Where("site_id = ?", *siteID)
}
if mode = strings.TrimSpace(mode); mode != "" {
db = db.Where("mode = ?", mode)
}
var rows []model.DistributionRuleVersion
if err := db.Find(&rows).Error; err != nil {
return nil, err
}
items := make([]RuleDTO, 0, len(rows))
for _, row := range rows {
items = append(items, ruleDTO(row))
}
return items, nil
}
func (r *Repository) CreateRule(ctx context.Context, adminID uint64, req CreateRuleRequest) (*RuleDTO, error) {
version := 1
var maxVersion int
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).Where("mode = ?", req.Mode)
if req.SiteID == nil {
db = db.Where("site_id IS NULL")
} else {
db = db.Where("site_id = ?", *req.SiteID)
}
if err := db.Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
return nil, err
}
version = maxVersion + 1
effectiveFrom := req.EffectiveFrom
if effectiveFrom.IsZero() {
effectiveFrom = time.Now()
}
row := model.DistributionRuleVersion{
SiteID: req.SiteID, Mode: strings.TrimSpace(req.Mode), Version: version, Status: "draft",
PlatformReceiveRatio: req.PlatformReceiveRatio, PlatformSellRatio: req.PlatformSellRatio,
DistributorReceiveRatio: req.DistributorReceiveRatio, DistributorSellRatio: req.DistributorSellRatio,
DistributorARatio: req.DistributorARatio, DistributorBRatio: req.DistributorBRatio,
ServiceFeeValue: req.ServiceFeeValue, ServiceFeeUnit: strings.TrimSpace(req.ServiceFeeUnit),
PaymentFeeBPS: req.PaymentFeeBPS, TaxEnabled: req.TaxEnabled, TaxRateBPS: req.TaxRateBPS,
EffectiveFrom: effectiveFrom, CreatedBy: &adminID,
}
if row.PaymentFeeBPS == 0 {
row.PaymentFeeBPS = 50
}
if row.TaxRateBPS == 0 {
row.TaxRateBPS = 100
}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
return nil, err
}
item := ruleDTO(row)
return &item, nil
}
func (r *Repository) UpdateRuleStatus(ctx context.Context, id uint64, status string) (*RuleDTO, error) {
var row model.DistributionRuleVersion
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrRuleNotFound
}
return nil, err
}
row.Status = strings.TrimSpace(status)
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
return nil, err
}
item := ruleDTO(row)
return &item, nil
}
func siteDTO(row model.DistributionSite) SiteDTO {
return SiteDTO{ID: row.ID, SiteCode: row.SiteCode, SiteName: row.SiteName, Domain: row.Domain, Status: row.Status, DisplayAccountEnabled: row.DisplayAccountEnabled, DefaultSupportGroupID: row.DefaultSupportGroupID, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
}
func ruleDTO(row model.DistributionRuleVersion) RuleDTO {
return RuleDTO{ID: row.ID, SiteID: row.SiteID, Mode: row.Mode, Version: row.Version, Status: row.Status, PlatformReceiveRatio: row.PlatformReceiveRatio, PlatformSellRatio: row.PlatformSellRatio, DistributorReceiveRatio: row.DistributorReceiveRatio, DistributorSellRatio: row.DistributorSellRatio, DistributorARatio: row.DistributorARatio, DistributorBRatio: row.DistributorBRatio, ServiceFeeValue: row.ServiceFeeValue, ServiceFeeUnit: row.ServiceFeeUnit, PaymentFeeBPS: row.PaymentFeeBPS, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, EffectiveFrom: row.EffectiveFrom, EffectiveTo: row.EffectiveTo, CreatedBy: row.CreatedBy, CreatedAt: row.CreatedAt}
}
func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderListQuery) (*DistributionOrderPage, error) {
page, size := query.Page, query.Size
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
base := r.db.WithContext(ctx).Table("rental_orders ro").Joins("JOIN distribution_order_financials f ON f.order_id = ro.id")
if query.SiteID > 0 {
base = base.Where("ro.distribution_site_id = ?", query.SiteID)
}
if query.Status != "" {
base = base.Where("ro.status = ?", query.Status)
}
switch strings.TrimSpace(query.Kind) {
case "sold":
base = base.Where("EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND p.party_role IN ('distributor_sell','distributor_b'))")
case "receive":
base = base.Where("EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND p.party_role IN ('distributor_receive','distributor_a'))")
case "", "all":
default:
return nil, ErrInvalidOrderKind
}
var total int64
if err := base.Session(&gorm.Session{}).Select("ro.id").Count(&total).Error; err != nil {
return nil, err
}
items := make([]DistributionOrderDTO, 0)
selectSQL := "ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, ro.created_at"
if err := base.Select(selectSQL).Order("ro.id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error; err != nil {
return nil, err
}
return &DistributionOrderPage{Items: items, Total: total, Page: page, PageSize: size}, nil
}
func (r *Repository) FindOrder(ctx context.Context, id uint64) (*DistributionOrderDTO, error) {
var item DistributionOrderDTO
err := r.db.WithContext(ctx).Table("rental_orders ro").Joins("JOIN distribution_order_financials f ON f.order_id = ro.id").Select("ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, ro.created_at").Where("ro.id = ?", id).Scan(&item).Error
if err != nil {
return nil, err
}
if item.OrderID == 0 {
return nil, ErrOrderNotFound
}
return &item, nil
}
func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) {
var rows []model.DistributionSiteAccount
db := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("id DESC")
if status != "" {
db = db.Where("display_status = ?", status)
}
if err := db.Find(&rows).Error; err != nil {
return nil, err
}
items := make([]AccountDisplayDTO, 0, len(rows))
for _, row := range rows {
items = append(items, AccountDisplayDTO{ID: row.ID, SiteID: row.SiteID, AccountID: row.AccountID, ListingID: row.ListingID, DisplayStatus: row.DisplayStatus})
}
return items, nil
}
func (r *Repository) UpdateAccountDisplay(ctx context.Context, id uint64, status string) (*AccountDisplayDTO, error) {
var row model.DistributionSiteAccount
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAccountDisplayNotFound
}
return nil, err
}
row.DisplayStatus = strings.TrimSpace(status)
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
return nil, err
}
item := &AccountDisplayDTO{ID: row.ID, SiteID: row.SiteID, AccountID: row.AccountID, ListingID: row.ListingID, DisplayStatus: row.DisplayStatus}
return item, nil
}
func (r *Repository) ListPaymentChannels(ctx context.Context, siteID uint64) ([]PaymentChannelDTO, error) {
var rows []model.DistributionSitePaymentChannel
if err := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("priority ASC, id ASC").Find(&rows).Error; err != nil {
return nil, err
}
items := make([]PaymentChannelDTO, 0, len(rows))
for _, row := range rows {
items = append(items, PaymentChannelDTO{ID: row.ID, SiteID: row.SiteID, ConfigID: row.ConfigID, Priority: row.Priority, Status: row.Status})
}
return items, nil
}
func (r *Repository) BindPaymentChannel(ctx context.Context, siteID uint64, req BindPaymentChannelRequest) (*PaymentChannelDTO, error) {
row := model.DistributionSitePaymentChannel{SiteID: siteID, ConfigID: req.ConfigID, Priority: req.Priority, Status: "active"}
if err := r.db.WithContext(ctx).Where("site_id = ? AND config_id = ?", siteID, req.ConfigID).FirstOrCreate(&row).Error; err != nil {
return nil, err
}
return &PaymentChannelDTO{ID: row.ID, SiteID: row.SiteID, ConfigID: row.ConfigID, Priority: row.Priority, Status: row.Status}, nil
}
func (r *Repository) UnbindPaymentChannel(ctx context.Context, id uint64) error {
result := r.db.WithContext(ctx).Delete(&model.DistributionSitePaymentChannel{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrPaymentChannelNotFound
}
return nil
}
func (r *Repository) ListSettlementBatches(ctx context.Context, siteID uint64) ([]SettlementBatchDTO, error) {
var rows []model.DistributionSettlementBatch
if err := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("id DESC").Find(&rows).Error; err != nil {
return nil, err
}
items := make([]SettlementBatchDTO, 0, len(rows))
for _, row := range rows {
items = append(items, SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status})
}
return items, nil
}
func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64, req CreateSettlementBatchRequest) (*SettlementBatchDTO, error) {
if req.PeriodEnd.Before(req.PeriodStart) {
return nil, ErrInvalidPeriod
}
row := model.DistributionSettlementBatch{SiteID: req.SiteID, BatchNo: "DS" + time.Now().Format("20060102150405.000000000"), PeriodStart: req.PeriodStart, PeriodEnd: req.PeriodEnd, Status: "draft", CreatedBy: &adminID}
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
return nil, err
}
return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil
}
func (r *Repository) MarkSettlementBatch(ctx context.Context, id uint64) (*SettlementBatchDTO, error) {
var row model.DistributionSettlementBatch
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSettlementBatchNotFound
}
return nil, err
}
if row.Status == "settled" {
return nil, ErrBatchAlreadySettled
}
now := time.Now()
row.Status = "settled"
row.SettledAt = &now
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
return nil, err
}
return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil
}
func (r *Repository) CreateFinancialSnapshot(ctx context.Context, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
var rule model.DistributionRuleVersion
if err := r.db.WithContext(ctx).First(&rule, req.RuleVersionID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrRuleNotFound
}
return nil, err
}
if req.SiteID == 0 || orderID == 0 {
return nil, ErrInvalidAmount
}
partyInputs := make([]PartyProfitInput, 0, len(req.Parties))
for _, party := range req.Parties {
partyInputs = append(partyInputs, PartyProfitInput{PartyRole: party.PartyRole, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent})
}
result, err := CalculateSettlement(Rule{ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS}, SettlementInput{GrossPaidAmountCent: req.GrossPaidAmountCent, RefundAmountCent: req.RefundAmountCent, MarkupAmountCent: req.MarkupAmountCent, GrossProfitByParty: partyInputs})
if err != nil {
return nil, err
}
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
financial := model.DistributionOrderFinancial{OrderID: orderID, SiteID: req.SiteID, RuleVersionID: req.RuleVersionID, GrossPaidAmountCent: result.GrossPaidAmountCent, RefundAmountCent: result.RefundAmountCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: result.MarkupAmountCent, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, SettlementStatus: "pending"}
if err := tx.Where("order_id = ?", orderID).Assign(financial).FirstOrCreate(&financial).Error; err != nil {
return err
}
if err := tx.Where("order_id = ?", orderID).Delete(&model.DistributionOrderParty{}).Error; err != nil {
return err
}
for idx, party := range req.Parties {
tax := int64(0)
if idx < len(result.TaxByParty) {
tax = result.TaxByParty[idx].TaxAmountCent
}
profit := party.IncomeAmountCent - party.CostAmountCent
row := model.DistributionOrderParty{OrderID: orderID, SiteID: req.SiteID, PartyRole: party.PartyRole, PartyType: party.PartyType, PartyID: party.PartyID, Ratio: party.Ratio, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent, ProfitAmountCent: profit, TaxAmountCent: tax}
if err := tx.Create(&row).Error; err != nil {
return err
}
}
return tx.Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{"distribution_site_id": req.SiteID, "distribution_rule_version_id": req.RuleVersionID}).Error
})
if err != nil {
return nil, err
}
return r.FindOrder(ctx, orderID)
}
@@ -0,0 +1,87 @@
package distribution
import (
"testing"
"time"
"hfb_sys/backend/internal/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func distributionTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
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 {
t.Fatal(err)
}
return db
}
func TestDistributionOrderSnapshotAndQueries(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
site := model.DistributionSite{SiteCode: "demo", SiteName: "演示站", Domain: "distribution.example.com", DisplayAccountEnabled: true, TaxRateBPS: 100}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", ServiceFeeValue: 0, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50, TaxRateBPS: 100, EffectiveFrom: now}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
order := model.RentalOrder{OrderNo: "D-1", ListingID: 1, AccountID: 2, OwnerID: 3, RenterID: 4, Status: "completed"}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
_, err := repo.CreateFinancialSnapshot(t.Context(), order.ID, CreateFinancialSnapshotRequest{SiteID: site.ID, RuleVersionID: rule.ID, GrossPaidAmountCent: 10001, RefundAmountCent: 1000, MarkupAmountCent: 300, Parties: []SnapshotPartyRequest{{PartyRole: "distributor_receive", PartyType: "site", PartyID: site.ID, IncomeAmountCent: 1000, CostAmountCent: 400}}})
if err != nil {
t.Fatal(err)
}
page, err := repo.ListOrders(t.Context(), DistributionOrderListQuery{SiteID: site.ID, Kind: "receive", Page: 1, Size: 20})
if err != nil {
t.Fatal(err)
}
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].PaymentFeeCent != 45 || page.Items[0].NetPaidAmountCent != 9001 {
t.Fatalf("unexpected page: %#v", page)
}
if _, err := repo.FindOrder(t.Context(), order.ID); err != nil {
t.Fatal(err)
}
}
func TestDistributionPaymentChannelAndSettlementBatch(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
site := model.DistributionSite{SiteCode: "demo", SiteName: "演示站", Domain: "distribution.example.com", TaxRateBPS: 100}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
channel, err := repo.BindPaymentChannel(t.Context(), site.ID, BindPaymentChannelRequest{ConfigID: 9, Priority: 1})
if err != nil {
t.Fatal(err)
}
items, err := repo.ListPaymentChannels(t.Context(), site.ID)
if err != nil || len(items) != 1 || items[0].ConfigID != 9 {
t.Fatalf("channels = %#v, err=%v", items, err)
}
if err := repo.UnbindPaymentChannel(t.Context(), channel.ID); err != nil {
t.Fatal(err)
}
start := time.Now().Add(-time.Hour)
batch, err := repo.CreateSettlementBatch(t.Context(), 1, CreateSettlementBatchRequest{SiteID: site.ID, PeriodStart: start, PeriodEnd: time.Now()})
if err != nil {
t.Fatal(err)
}
settled, err := repo.MarkSettlementBatch(t.Context(), batch.ID)
if err != nil || settled.Status != "settled" {
t.Fatalf("settled = %#v, err=%v", settled, err)
}
if _, err := repo.MarkSettlementBatch(t.Context(), batch.ID); err != ErrBatchAlreadySettled {
t.Fatalf("second settle err = %v", err)
}
}
@@ -0,0 +1,129 @@
package distribution
import (
"context"
"errors"
)
var ErrDependencyUnavailable = errors.New("distribution dependency unavailable")
type Service struct{ repo *Repository }
func NewService(repo *Repository) *Service { return &Service{repo: repo} }
func (s *Service) available() error {
if s == nil || s.repo == nil {
return ErrDependencyUnavailable
}
return nil
}
func (s *Service) ListSites(ctx context.Context) ([]SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListSites(ctx)
}
func (s *Service) FindSite(ctx context.Context, id uint64) (*SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.FindSite(ctx, id)
}
func (s *Service) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CreateSite(ctx, req)
}
func (s *Service) UpdateSite(ctx context.Context, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateSite(ctx, id, req)
}
func (s *Service) ListRules(ctx context.Context, siteID *uint64, mode string) ([]RuleDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListRules(ctx, siteID, mode)
}
func (s *Service) CreateRule(ctx context.Context, adminID uint64, req CreateRuleRequest) (*RuleDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CreateRule(ctx, adminID, req)
}
func (s *Service) UpdateRuleStatus(ctx context.Context, id uint64, status string) (*RuleDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateRuleStatus(ctx, id, status)
}
func (s *Service) ListOrders(ctx context.Context, query DistributionOrderListQuery) (*DistributionOrderPage, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListOrders(ctx, query)
}
func (s *Service) FindOrder(ctx context.Context, id uint64) (*DistributionOrderDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.FindOrder(ctx, id)
}
func (s *Service) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListAccountDisplays(ctx, siteID, status)
}
func (s *Service) UpdateAccountDisplay(ctx context.Context, id uint64, status string) (*AccountDisplayDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateAccountDisplay(ctx, id, status)
}
func (s *Service) ListPaymentChannels(ctx context.Context, siteID uint64) ([]PaymentChannelDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListPaymentChannels(ctx, siteID)
}
func (s *Service) BindPaymentChannel(ctx context.Context, siteID uint64, req BindPaymentChannelRequest) (*PaymentChannelDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.BindPaymentChannel(ctx, siteID, req)
}
func (s *Service) UnbindPaymentChannel(ctx context.Context, id uint64) error {
if err := s.available(); err != nil {
return err
}
return s.repo.UnbindPaymentChannel(ctx, id)
}
func (s *Service) ListSettlementBatches(ctx context.Context, siteID uint64) ([]SettlementBatchDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListSettlementBatches(ctx, siteID)
}
func (s *Service) CreateSettlementBatch(ctx context.Context, adminID uint64, req CreateSettlementBatchRequest) (*SettlementBatchDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CreateSettlementBatch(ctx, adminID, req)
}
func (s *Service) MarkSettlementBatch(ctx context.Context, id uint64) (*SettlementBatchDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.MarkSettlementBatch(ctx, id)
}
func (s *Service) CreateFinancialSnapshot(ctx context.Context, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.CreateFinancialSnapshot(ctx, orderID, req)
}
+26
View File
@@ -22,6 +22,7 @@ import (
"hfb_sys/backend/internal/modules/chat"
"hfb_sys/backend/internal/modules/chathub"
"hfb_sys/backend/internal/modules/dispute"
"hfb_sys/backend/internal/modules/distribution"
filemodule "hfb_sys/backend/internal/modules/file"
"hfb_sys/backend/internal/modules/listing"
"hfb_sys/backend/internal/modules/mohong"
@@ -308,6 +309,13 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
}
systemConfigService := systemconfig.NewService(systemConfigRepo)
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
var distributionRepo *distribution.Repository
if deps.DB != nil {
distributionRepo = distribution.NewRepository(deps.DB)
}
distributionService := distribution.NewService(distributionRepo)
distributionHandler := distribution.NewHandler(distributionService)
backupMonitorHandler := backupmonitor.NewHandler(cfg.BackupStatusFile)
var adminRoleRepo *adminrole.Repository
if deps.DB != nil {
adminRoleRepo = adminrole.NewRepository(deps.DB, deps.Redis)
@@ -588,6 +596,24 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.POST("/auth/logout", adminAuthHandler.Logout)
adminRoutes.POST("/files/upload", fileHandler.Upload)
adminRoutes.GET("/dashboard", requirePerm("dashboard:view"), adminDashboardHandler.Summary)
adminRoutes.GET("/distribution/sites", requirePerm("distribution:site_view"), distributionHandler.ListSites)
adminRoutes.GET("/distribution/sites/:id", requirePerm("distribution:site_view"), distributionHandler.GetSite)
adminRoutes.POST("/distribution/sites", requirePerm("distribution:site_manage"), distributionHandler.CreateSite)
adminRoutes.PUT("/distribution/sites/:id", requirePerm("distribution:site_manage"), distributionHandler.UpdateSite)
adminRoutes.GET("/distribution/rules", requirePerm("distribution:rule_view"), distributionHandler.ListRules)
adminRoutes.POST("/distribution/rules", requirePerm("distribution:rule_manage"), distributionHandler.CreateRule)
adminRoutes.PUT("/distribution/rules/:id/status", requirePerm("distribution:rule_manage"), distributionHandler.UpdateRuleStatus)
adminRoutes.GET("/distribution/orders", requirePerm("distribution:order_view"), distributionHandler.ListOrders)
adminRoutes.GET("/distribution/orders/:id", requirePerm("distribution:order_view"), distributionHandler.FindOrder)
adminRoutes.POST("/distribution/orders/:id/financial-snapshot", requirePerm("distribution:settlement_manage"), distributionHandler.CreateFinancialSnapshot)
adminRoutes.GET("/distribution/accounts", requirePerm("distribution:account_view"), distributionHandler.ListAccountDisplays)
adminRoutes.PUT("/distribution/accounts/:id/display", requirePerm("distribution:account_display"), distributionHandler.UpdateAccountDisplay)
adminRoutes.GET("/distribution/payment-channels", requirePerm("distribution:payment_view"), distributionHandler.ListPaymentChannels)
adminRoutes.POST("/distribution/payment-channels", requirePerm("distribution:payment_manage"), distributionHandler.BindPaymentChannel)
adminRoutes.DELETE("/distribution/payment-channels/:id", requirePerm("distribution:payment_manage"), distributionHandler.UnbindPaymentChannel)
adminRoutes.GET("/distribution/settlement-batches", requirePerm("distribution:settlement_view"), distributionHandler.ListSettlementBatches)
adminRoutes.POST("/distribution/settlement-batches", requirePerm("distribution:settlement_manage"), distributionHandler.CreateSettlementBatch)
adminRoutes.POST("/distribution/settlement-batches/:id/settle", requirePerm("distribution:settlement_manage"), distributionHandler.MarkSettlementBatch)
adminRoutes.GET("/files/object", fileHandler.Object)
adminRoutes.GET("/users", requirePerm("user:view"), adminUserHandler.List)
adminRoutes.POST("/users/:id/freeze", requirePerm("user:freeze"), adminUserHandler.Freeze)
@@ -0,0 +1,259 @@
-- +goose Up
CREATE TABLE distribution_sites (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
site_code VARCHAR(64) NOT NULL,
site_name VARCHAR(128) NOT NULL,
domain VARCHAR(255) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
display_account_enabled TINYINT(1) NOT NULL DEFAULT 1,
default_support_group_id BIGINT UNSIGNED NULL,
tax_enabled TINYINT(1) NOT NULL DEFAULT 0,
tax_rate_bps BIGINT NOT NULL DEFAULT 100,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_distribution_sites_code (site_code),
UNIQUE KEY uk_distribution_sites_domain (domain),
KEY idx_distribution_sites_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销网站主体';
CREATE TABLE distribution_site_members (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
site_id BIGINT UNSIGNED NOT NULL,
admin_user_id BIGINT UNSIGNED NOT NULL,
member_type VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
data_scope VARCHAR(32) NOT NULL DEFAULT 'site',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_distribution_site_member (site_id, admin_user_id),
KEY idx_distribution_site_members_admin (admin_user_id, status),
CONSTRAINT fk_distribution_site_members_site FOREIGN KEY (site_id) REFERENCES distribution_sites (id),
CONSTRAINT fk_distribution_site_members_admin FOREIGN KEY (admin_user_id) REFERENCES admin_users (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销网站后台成员';
CREATE TABLE distribution_site_accounts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
site_id BIGINT UNSIGNED NOT NULL,
account_id BIGINT UNSIGNED NOT NULL,
listing_id BIGINT UNSIGNED NOT NULL,
display_status VARCHAR(16) NOT NULL DEFAULT 'enabled',
display_started_at DATETIME NULL,
display_ended_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_distribution_site_account (site_id, account_id),
KEY idx_distribution_site_accounts_listing (listing_id),
KEY idx_distribution_site_accounts_display (site_id, display_status),
CONSTRAINT fk_distribution_site_accounts_site FOREIGN KEY (site_id) REFERENCES distribution_sites (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销网站账号展示关系';
CREATE TABLE distribution_rule_versions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
site_id BIGINT UNSIGNED NULL,
mode VARCHAR(32) NOT NULL,
version INT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
platform_receive_ratio DECIMAL(10,2) NOT NULL DEFAULT 0,
platform_sell_ratio DECIMAL(10,2) NOT NULL DEFAULT 0,
distributor_receive_ratio DECIMAL(10,2) NOT NULL DEFAULT 0,
distributor_sell_ratio DECIMAL(10,2) NOT NULL DEFAULT 0,
distributor_a_ratio DECIMAL(10,2) NOT NULL DEFAULT 0,
distributor_b_ratio DECIMAL(10,2) NOT NULL DEFAULT 0,
service_fee_value DECIMAL(12,4) NOT NULL DEFAULT 0,
service_fee_unit VARCHAR(24) NOT NULL DEFAULT 'ratio',
payment_fee_bps BIGINT NOT NULL DEFAULT 50,
tax_enabled TINYINT(1) NOT NULL DEFAULT 0,
tax_rate_bps BIGINT NOT NULL DEFAULT 100,
effective_from DATETIME NOT NULL,
effective_to DATETIME NULL,
created_by BIGINT UNSIGNED NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_distribution_rule_version (site_id, mode, version),
KEY idx_distribution_rule_effective (site_id, mode, status, effective_from),
CONSTRAINT fk_distribution_rule_site FOREIGN KEY (site_id) REFERENCES distribution_sites (id),
CONSTRAINT fk_distribution_rule_admin FOREIGN KEY (created_by) REFERENCES admin_users (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销分账规则版本';
CREATE TABLE distribution_order_financials (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
site_id BIGINT UNSIGNED NOT NULL,
rule_version_id BIGINT UNSIGNED NOT NULL,
gross_paid_amount_cent BIGINT NOT NULL DEFAULT 0,
refund_amount_cent BIGINT NOT NULL DEFAULT 0,
net_paid_amount_cent BIGINT NOT NULL DEFAULT 0,
payment_fee_bps BIGINT NOT NULL DEFAULT 50,
payment_fee_cent BIGINT NOT NULL DEFAULT 0,
service_fee_value DECIMAL(12,4) NOT NULL DEFAULT 0,
service_fee_unit VARCHAR(24) NOT NULL DEFAULT 'ratio',
service_fee_cent BIGINT NOT NULL DEFAULT 0,
markup_amount_cent BIGINT NOT NULL DEFAULT 0,
tax_enabled TINYINT(1) NOT NULL DEFAULT 0,
tax_rate_bps BIGINT NOT NULL DEFAULT 100,
tax_amount_cent BIGINT NOT NULL DEFAULT 0,
split_base_amount_cent BIGINT NOT NULL DEFAULT 0,
rounding_adjustment_cent BIGINT NOT NULL DEFAULT 0,
settlement_status VARCHAR(24) NOT NULL DEFAULT 'pending',
settled_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_distribution_order_financial_order (order_id),
KEY idx_distribution_order_financial_site_status (site_id, settlement_status),
CONSTRAINT fk_distribution_order_financial_order FOREIGN KEY (order_id) REFERENCES rental_orders (id),
CONSTRAINT fk_distribution_order_financial_site FOREIGN KEY (site_id) REFERENCES distribution_sites (id),
CONSTRAINT fk_distribution_order_financial_rule FOREIGN KEY (rule_version_id) REFERENCES distribution_rule_versions (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销正常订单财务快照';
CREATE TABLE distribution_order_parties (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
site_id BIGINT UNSIGNED NOT NULL,
party_role VARCHAR(32) NOT NULL,
party_type VARCHAR(16) NOT NULL,
party_id BIGINT UNSIGNED NOT NULL,
ratio DECIMAL(10,2) NOT NULL DEFAULT 0,
income_amount_cent BIGINT NOT NULL DEFAULT 0,
cost_amount_cent BIGINT NOT NULL DEFAULT 0,
profit_amount_cent BIGINT NOT NULL DEFAULT 0,
tax_amount_cent BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY idx_distribution_order_parties_order (order_id),
KEY idx_distribution_order_parties_site_role (site_id, party_role),
CONSTRAINT fk_distribution_order_parties_order FOREIGN KEY (order_id) REFERENCES rental_orders (id),
CONSTRAINT fk_distribution_order_parties_site FOREIGN KEY (site_id) REFERENCES distribution_sites (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销订单参与方快照';
CREATE TABLE distribution_settlement_batches (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
site_id BIGINT UNSIGNED NOT NULL,
batch_no VARCHAR(64) NOT NULL,
period_start DATETIME NOT NULL,
period_end DATETIME NOT NULL,
total_amount_cent BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'draft',
created_by BIGINT UNSIGNED NULL,
settled_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_distribution_settlement_batch_no (batch_no),
KEY idx_distribution_settlement_batches_site_status (site_id, status),
CONSTRAINT fk_distribution_settlement_batches_site FOREIGN KEY (site_id) REFERENCES distribution_sites (id),
CONSTRAINT fk_distribution_settlement_batches_admin FOREIGN KEY (created_by) REFERENCES admin_users (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销结算批次';
CREATE TABLE distribution_settlement_items (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
batch_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
party_id BIGINT UNSIGNED NOT NULL,
amount_cent BIGINT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_distribution_settlement_item (batch_id, order_id, party_id),
KEY idx_distribution_settlement_items_order (order_id),
CONSTRAINT fk_distribution_settlement_items_batch FOREIGN KEY (batch_id) REFERENCES distribution_settlement_batches (id),
CONSTRAINT fk_distribution_settlement_items_order FOREIGN KEY (order_id) REFERENCES rental_orders (id),
CONSTRAINT fk_distribution_settlement_items_party FOREIGN KEY (party_id) REFERENCES distribution_order_parties (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销结算明细';
CREATE TABLE distribution_site_payment_channels (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
site_id BIGINT UNSIGNED NOT NULL,
config_id BIGINT UNSIGNED NOT NULL,
priority INT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_distribution_site_payment (site_id, config_id),
KEY idx_distribution_site_payment_status (site_id, status, priority),
CONSTRAINT fk_distribution_site_payment_site FOREIGN KEY (site_id) REFERENCES distribution_sites (id),
CONSTRAINT fk_distribution_site_payment_config FOREIGN KEY (config_id) REFERENCES payment_merchant_configs (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分销网站收款通道绑定';
ALTER TABLE rental_orders
ADD COLUMN distribution_site_id BIGINT UNSIGNED NULL AFTER source_channel,
ADD COLUMN distribution_mode VARCHAR(32) NOT NULL DEFAULT '' AFTER distribution_site_id,
ADD COLUMN distribution_rule_version_id BIGINT UNSIGNED NULL AFTER distribution_mode,
ADD KEY idx_rental_orders_distribution_site (distribution_site_id, distribution_mode),
ADD CONSTRAINT fk_rental_orders_distribution_site FOREIGN KEY (distribution_site_id) REFERENCES distribution_sites (id),
ADD CONSTRAINT fk_rental_orders_distribution_rule FOREIGN KEY (distribution_rule_version_id) REFERENCES distribution_rule_versions (id);
INSERT INTO permissions (code, name, resource, action) VALUES
('distribution:site_view', '查看分销网站', 'distribution', 'site_view'),
('distribution:site_manage', '管理分销网站', 'distribution', 'site_manage'),
('distribution:order_view', '查看分销订单', 'distribution', 'order_view'),
('distribution:process_view', '查看分销订单过程', 'distribution', 'process_view'),
('distribution:finance_view', '查看分销财务', 'distribution', 'finance_view'),
('distribution:settlement_view', '查看分销结算', 'distribution', 'settlement_view'),
('distribution:settlement_manage', '管理分销结算', 'distribution', 'settlement_manage'),
('distribution:rule_view', '查看分销规则', 'distribution', 'rule_view'),
('distribution:rule_manage', '管理分销规则', 'distribution', 'rule_manage'),
('distribution:account_view', '查看分销账号', 'distribution', 'account_view'),
('distribution:account_display', '管理分销账号展示', 'distribution', 'account_display'),
('distribution:payment_view', '查看分销支付通道', 'distribution', 'payment_view'),
('distribution:payment_manage', '管理分销支付通道', 'distribution', 'payment_manage'),
('distribution:chat_view', '查看分销客服消息', 'distribution', 'chat_view'),
('distribution:chat_send', '发送分销客服消息', 'distribution', 'chat_send')
ON DUPLICATE KEY UPDATE name = VALUES(name), resource = VALUES(resource), action = VALUES(action);
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r CROSS JOIN permissions p
WHERE r.code = 'super_admin' AND p.code LIKE 'distribution:%';
INSERT INTO distribution_rule_versions (
site_id, mode, version, status,
platform_receive_ratio, platform_sell_ratio,
distributor_receive_ratio, distributor_sell_ratio,
distributor_a_ratio, distributor_b_ratio,
service_fee_value, service_fee_unit, payment_fee_bps,
tax_enabled, tax_rate_bps, effective_from, created_at
) VALUES
(NULL, 'two_party_distributor_receive', 1, 'active', 0, 4, 3.5, 0, 0, 0, 2.5, 'ratio', 50, 0, 100, NOW(), NOW()),
(NULL, 'two_party_platform_receive', 1, 'active', 3.5, 0, 0, 4, 0, 0, 2.5, 'ratio', 50, 0, 100, NOW(), NOW()),
(NULL, 'three_party', 1, 'active', 0, 0, 0, 0, 3.5, 4, 2.5, 'ratio', 50, 0, 100, NOW(), NOW())
ON DUPLICATE KEY UPDATE
status = VALUES(status),
service_fee_value = VALUES(service_fee_value),
service_fee_unit = VALUES(service_fee_unit),
payment_fee_bps = VALUES(payment_fee_bps);
-- +goose Down
DROP TABLE IF EXISTS distribution_order_financials;
DROP TABLE IF EXISTS distribution_site_payment_channels;
DROP TABLE IF EXISTS distribution_settlement_items;
DROP TABLE IF EXISTS distribution_settlement_batches;
DROP TABLE IF EXISTS distribution_order_parties;
ALTER TABLE rental_orders
DROP FOREIGN KEY fk_rental_orders_distribution_rule,
DROP FOREIGN KEY fk_rental_orders_distribution_site,
DROP KEY idx_rental_orders_distribution_site,
DROP COLUMN distribution_rule_version_id,
DROP COLUMN distribution_mode,
DROP COLUMN distribution_site_id;
DROP TABLE IF EXISTS distribution_rule_versions;
DROP TABLE IF EXISTS distribution_site_accounts;
DROP TABLE IF EXISTS distribution_site_members;
DROP TABLE IF EXISTS distribution_sites;
DELETE rp FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE p.code LIKE 'distribution:%';
DELETE FROM permissions WHERE code IN (
'distribution:site_view', 'distribution:site_manage', 'distribution:order_view', 'distribution:process_view',
'distribution:finance_view', 'distribution:settlement_view', 'distribution:settlement_manage',
'distribution:rule_view', 'distribution:rule_manage', 'distribution:account_view', 'distribution:account_display',
'distribution:payment_view', 'distribution:payment_manage', 'distribution:chat_view', 'distribution:chat_send'
);