实现分销网后台基础功能与独立前端
This commit is contained in:
@@ -85,3 +85,4 @@ npm run dev
|
||||
- [数据库设计](docs/database.md)
|
||||
- [接口规划](docs/api.md)
|
||||
- [业务规则](docs/business-rules.md)
|
||||
- [分销网管理后台设计方案](docs/分销网管理后台设计方案.md)
|
||||
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
@@ -68,6 +68,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
# 分销管理后台使用独立 React + Ant Design 静态资源,API 与主站复用 backend。
|
||||
distribution.{$CADDY_DOMAIN} {
|
||||
encode zstd gzip
|
||||
import public_api
|
||||
handle {
|
||||
root * /srv/distribution-admin
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
|
||||
# 选号网(hfb_show):仅当 CADDY_SHOW_DOMAIN 有值时由部署脚本启用 conf.d/show.caddy。
|
||||
# 未配置时 conf.d 为空,不影响主站。
|
||||
import /etc/caddy/conf.d/*.caddy
|
||||
@@ -86,3 +97,8 @@ monitor.{$CADDY_DOMAIN} {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# gitea 自托管代码库
|
||||
gitea.hao858.com {
|
||||
reverse_proxy gitea:3000
|
||||
}
|
||||
|
||||
+11
-1
@@ -10,11 +10,21 @@ RUN npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
# 第二阶段:Caddy 作为公网入口,托管主站 + 选号网静态资源
|
||||
# 第二套独立分销管理后台(React + Ant Design)
|
||||
FROM node:24-alpine AS distribution-build
|
||||
WORKDIR /src
|
||||
COPY distribution-admin/package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --prefer-offline --no-audit --fund=false
|
||||
COPY distribution-admin/ .
|
||||
RUN npm run build
|
||||
|
||||
# 第三阶段:Caddy 作为公网入口,托管主站、分销后台和选号网静态资源
|
||||
FROM caddy:2.10-alpine
|
||||
|
||||
COPY --from=build /src/dist /srv/dist
|
||||
# show-dist 由 deploy-prod.sh 在 build 前准备(hfb_show 构建产物或占位页)
|
||||
COPY deploy/caddy/show-dist /srv/show-dist
|
||||
COPY --from=distribution-build /src/dist /srv/distribution-admin
|
||||
|
||||
EXPOSE 80 443
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>分销网管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
location /api/ { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
|
||||
location / { try_files $uri /index.html; }
|
||||
}
|
||||
Generated
+3353
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "hfb-distribution-admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"test": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"antd": "^5.27.6",
|
||||
"axios": "^1.13.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App, Card, ConfigProvider, Layout, Menu, Space, Statistic, Table, Tag, Typography, message } from 'antd'
|
||||
import { AccountBookOutlined, ApiOutlined, DashboardOutlined, FileSearchOutlined, SettingOutlined, ShopOutlined } from '@ant-design/icons'
|
||||
import axios from 'axios'
|
||||
import './style.css'
|
||||
|
||||
const api = axios.create({ baseURL: '/api' })
|
||||
type Site = { id: number; site_name: string; site_code: string; domain: string; status: string; display_account_enabled: boolean; tax_enabled: boolean; tax_rate_bps: number }
|
||||
type Order = { order_id: number; order_no: string; site_id: number; status: string; settlement_status: string; net_paid_amount_cent: number; payment_fee_cent: number; service_fee_cent: number; tax_amount_cent: number; created_at: string }
|
||||
|
||||
function Dashboard() {
|
||||
const [sites, setSites] = useState<Site[]>([])
|
||||
const [orders, setOrders] = useState<Order[]>([])
|
||||
useEffect(() => { Promise.all([api.get('/admin/distribution/sites'), api.get('/admin/distribution/orders?kind=all')]).then(([a, b]) => { setSites(a.data.data.items ?? []); setOrders(b.data.data.items ?? []) }).catch(() => message.error('数据加载失败')) }, [])
|
||||
const total = orders.reduce((sum, item) => sum + item.net_paid_amount_cent, 0)
|
||||
return <Space direction="vertical" size="large" className="page"><Typography.Title level={3}>分销运营概览</Typography.Title><div className="stats"><Card><Statistic title="分销网站" value={sites.length} /></Card><Card><Statistic title="正常订单" value={orders.length} /></Card><Card><Statistic title="净支付金额" value={(total / 100).toFixed(2)} suffix="元" /></Card></div><Card title="最近订单"><Table rowKey="order_id" dataSource={orders.slice(0, 10)} pagination={false} columns={[{ title: '订单号', dataIndex: 'order_no' }, { title: '状态', dataIndex: 'status', render: (v) => <Tag color={v === 'completed' ? 'green' : 'blue'}>{v}</Tag> }, { title: '净支付', dataIndex: 'net_paid_amount_cent', render: (v) => `¥${(v / 100).toFixed(2)}` }, { title: '创建时间', dataIndex: 'created_at' }]} /></Card></Space>
|
||||
}
|
||||
|
||||
function Sites() {
|
||||
const [items, setItems] = useState<Site[]>([])
|
||||
useEffect(() => { api.get('/admin/distribution/sites').then((r) => setItems(r.data.data.items ?? [])).catch(() => message.error('站点加载失败')) }, [])
|
||||
return <Space direction="vertical" className="page"><Typography.Title level={3}>分销网站</Typography.Title><Card><Table rowKey="id" dataSource={items} columns={[{ title: '名称', dataIndex: 'site_name' }, { title: '编码', dataIndex: 'site_code' }, { title: '域名', dataIndex: 'domain' }, { title: '状态', dataIndex: 'status' }, { title: '账号展示', dataIndex: 'display_account_enabled', render: (v) => v ? <Tag color="green">开启</Tag> : <Tag>关闭</Tag> }, { title: '税费', render: (_, r) => r.tax_enabled ? `${r.tax_rate_bps / 100}%` : '关闭' }]} /></Card></Space>
|
||||
}
|
||||
|
||||
function Orders({ kind }: { kind: 'sold' | 'receive' }) {
|
||||
const [items, setItems] = useState<Order[]>([])
|
||||
useEffect(() => { api.get(`/admin/distribution/orders?kind=${kind}`).then((r) => setItems(r.data.data.items ?? [])).catch(() => message.error('订单加载失败')) }, [kind])
|
||||
return <Space direction="vertical" className="page"><Typography.Title level={3}>{kind === 'sold' ? '售出账号订单' : '分销收号订单'}</Typography.Title><Card><Table rowKey="order_id" dataSource={items} columns={[{ title: '订单号', dataIndex: 'order_no' }, { title: '订单状态', dataIndex: 'status' }, { title: '净支付', dataIndex: 'net_paid_amount_cent', render: (v) => `¥${(v / 100).toFixed(2)}` }, { title: '手续费', dataIndex: 'payment_fee_cent', render: (v) => `¥${(v / 100).toFixed(2)}` }, { title: '服务费', dataIndex: 'service_fee_cent', render: (v) => `¥${(v / 100).toFixed(2)}` }, { title: '结算', dataIndex: 'settlement_status' }]} /></Card></Space>
|
||||
}
|
||||
|
||||
function Placeholder({ title }: { title: string }) { return <Space direction="vertical" className="page"><Typography.Title level={3}>{title}</Typography.Title><Card><Typography.Text type="secondary">该模块已预留接口入口,可在后台继续配置。</Typography.Text></Card></Space> }
|
||||
|
||||
function Shell() {
|
||||
const [key, setKey] = useState('dashboard')
|
||||
const content = key === 'dashboard' ? <Dashboard /> : key === 'sites' ? <Sites /> : key === 'sold' ? <Orders kind="sold" /> : key === 'receive' ? <Orders kind="receive" /> : <Placeholder title={({ settlements: '结算管理', payments: '支付通道', accounts: '账号展示', rules: '比例与税费规则', qrcodes: '客服组与二维码', chats: '客服消息' } as Record<string, string>)[key] ?? '分销后台'} />
|
||||
return <Layout className="app"><Layout.Sider breakpoint="lg" collapsedWidth="0"><div className="brand">HFB 分销后台</div><Menu theme="dark" selectedKeys={[key]} onClick={(e) => setKey(e.key)} items={[{ key: 'dashboard', icon: <DashboardOutlined />, label: '仪表盘' }, { key: 'sites', icon: <ShopOutlined />, label: '分销网站' }, { key: 'sold', icon: <FileSearchOutlined />, label: '售出账号' }, { key: 'receive', icon: <AccountBookOutlined />, label: '分销收号' }, { key: 'settlements', icon: <AccountBookOutlined />, label: '结算管理' }, { key: 'payments', icon: <ApiOutlined />, label: '支付通道' }, { key: 'accounts', icon: <SettingOutlined />, label: '账号展示' }, { key: 'rules', icon: <SettingOutlined />, label: '比例与税费' }, { key: 'qrcodes', icon: <SettingOutlined />, label: '客服二维码' }, { key: 'chats', icon: <SettingOutlined />, label: '客服消息' }]} /></Layout.Sider><Layout><Layout.Header className="header"><Typography.Text>主站 hao858.com · 分销子域名 distribution.hao858.com</Typography.Text></Layout.Header><Layout.Content>{content}</Layout.Content></Layout></Layout>
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<React.StrictMode><ConfigProvider theme={{ token: { colorPrimary: '#1677ff', borderRadius: 6 } }}><App><Shell /></App></ConfigProvider></React.StrictMode>)
|
||||
@@ -0,0 +1,9 @@
|
||||
:root { font-family: Inter, "PingFang SC", sans-serif; color: #1f2937; background: #f5f7fa; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
.app { min-height: 100vh; }
|
||||
.brand { height: 64px; padding: 20px 18px; color: #fff; font-weight: 600; letter-spacing: 0; }
|
||||
.header { display: flex; align-items: center; background: #fff; padding: 0 24px; border-bottom: 1px solid #edf0f5; }
|
||||
.page { width: 100%; padding: 24px; }
|
||||
.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; width: 100%; }
|
||||
@media (max-width: 720px) { .page { padding: 16px; } .stats { grid-template-columns: 1fr; } }
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 5190, proxy: { '/api': 'http://localhost:8080' } },
|
||||
})
|
||||
@@ -0,0 +1,776 @@
|
||||
# 分销网管理后台设计方案
|
||||
|
||||
> 状态:草案 v0.2
|
||||
> 更新时间:2026-09-04
|
||||
> 适用范围:分销网站正常租赁订单管理、订单分账、分销客服与支付配置
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
本文档记录分销网管理后台的产品计划、领域模型、分账口径、接口边界、部署方案和实施顺序。
|
||||
|
||||
本文档是持续维护的设计基线。业务规则尚未确认的部分使用“待确认”标记,进入开发前必须补齐;已创建的订单必须使用规则和金额快照,不能因后续规则变更而重算历史账目。
|
||||
|
||||
## 2. 现有系统基础
|
||||
|
||||
当前 HFB Sys 是 Go + Gin + GORM + MySQL + Redis 后端,Vue 3 + Element Plus 用户端/运营后台前端,生产通过 Caddy 托管静态文件并反代单个 backend 服务。
|
||||
|
||||
可复用的现有领域:
|
||||
|
||||
- 用户:`users`、实名认证、用户状态。
|
||||
- 商品和账号:`rental_listings`、`game_accounts`、账号资产快照。
|
||||
- 正常订单:`rental_orders`、交接、归还、结账、退款和订单过程事件。
|
||||
- 订单来源:`rental_orders` 的正常租赁交易;分销网只处理卖家和租客都在网站内完成的正常订单。
|
||||
- 资金:`wallet_accounts`、`wallet_ledger`、`withdrawal_requests`。
|
||||
- 支付:`payment_merchant_configs`、支付单、退款单和支付配置使用日志。
|
||||
- 客服:`chat_conversations`、`chat_messages`、客服组和二维码池。
|
||||
- 管理权限:`admin_users`、`roles`、`permissions`、角色权限关联和审计日志。
|
||||
|
||||
现有系统的价格快照已包含 `seller_ratio`、`buyer_ratio`、`seller_total_price`、`buyer_total_price` 和 `platform_markup_amount`。但现有默认语义是平台加价归平台,分销计划要求销售加价归卖方,因此分销必须使用独立的分账快照,不能直接改变旧字段含义。
|
||||
|
||||
## 3. 总体架构
|
||||
|
||||
### 3.1 独立前端、复用后端
|
||||
|
||||
分销网管理后台独立为新的前端工程,推荐 React + TypeScript + Vite + Ant Design。现有 Vue + Element Plus 用户端和运营后台保持不变。
|
||||
|
||||
```text
|
||||
主站:https://hao858.com/
|
||||
现有运营后台:https://hao858.com/manage-xxx
|
||||
分销后台:暂定使用主域名下的独立子域名,例如 `distribution.hao858.com`
|
||||
```
|
||||
|
||||
分销后台通过独立域名提供静态资源,`/api/*` 反代到现有 Go backend:
|
||||
|
||||
```text
|
||||
distribution.hao858.com(暂定)
|
||||
├── / 独立 Ant Design 静态资源
|
||||
└── /api/* reverse_proxy backend:8080
|
||||
```
|
||||
|
||||
这样可以避免在同一个 Vue 应用中同时引入 Element Plus 和 Ant Design,也避免跨域 Cookie 和 CORS 配置。
|
||||
|
||||
### 3.2 后端暂不拆服务
|
||||
|
||||
第一阶段不新建独立分销后端服务。分销模块继续使用现有 MySQL、Redis、钱包、订单、支付、客服和权限体系,以便在同一事务中处理支付、退款、分成、税费和钱包流水。
|
||||
|
||||
建议新增后端目录:
|
||||
|
||||
```text
|
||||
backend/internal/modules/distribution/
|
||||
```
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
dto.go
|
||||
handler.go
|
||||
service.go
|
||||
repository.go
|
||||
query.go
|
||||
mutation.go
|
||||
pricing.go
|
||||
settlement.go
|
||||
payment.go
|
||||
qrcode.go
|
||||
chat.go
|
||||
presenter.go
|
||||
audit.go
|
||||
```
|
||||
|
||||
## 4. 业务目标与后台功能
|
||||
|
||||
### 4.1 分销后台功能
|
||||
|
||||
第一版需要覆盖(仅限正常租赁订单):
|
||||
|
||||
1. 显示正常订单中已售出账号的详情。
|
||||
2. 显示分销网站自己通过正常订单收购/接收的账号详情(卖家在分销网站上架,租客完成交易)。
|
||||
3. 配置分销网站在正常订单中的收号比例;主站卖出比例统一或按规则版本管理。
|
||||
4. 分销网站使用指定客服组和指定二维码池的拉群码。
|
||||
5. 查看手续费、服务费、加价、税费、分成和结算金额。
|
||||
6. 配置分销网站是否展示账号,并可单独关闭某个账号。
|
||||
7. 配置分销网站可使用的一个或多个收款支付渠道。
|
||||
8. 分销客服只读订单信息和过程记录,并可按权限处理客服消息。
|
||||
9. 主站查看所有分销网站的收售订单、收款金额和结算数据。
|
||||
|
||||
### 4.2 建议页面
|
||||
|
||||
```text
|
||||
仪表盘
|
||||
分销网站管理
|
||||
正常订单售出账号
|
||||
分销收售订单
|
||||
佣金/分成明细
|
||||
结算管理
|
||||
比例与税费规则
|
||||
支付通道
|
||||
客服组与二维码
|
||||
账号展示管理
|
||||
报表
|
||||
操作日志
|
||||
```
|
||||
|
||||
## 5. 业务主体和权限
|
||||
|
||||
### 5.1 分销网站
|
||||
|
||||
建议使用独立主体表 `distribution_sites`。分销网站不是普通用户,也不直接等同于平台管理员。
|
||||
|
||||
分销网站至少需要保存:
|
||||
|
||||
- 编码、名称、域名和状态。
|
||||
- 联系人信息。
|
||||
- 是否允许自动展示账号。
|
||||
- 默认客服组和专属二维码池。
|
||||
- 税费开关和税率。
|
||||
- 创建、停用和更新时间。
|
||||
|
||||
### 5.2 分销网站成员
|
||||
|
||||
建议使用 `distribution_site_members` 将现有 `admin_users` 映射到一个或多个分销网站。
|
||||
|
||||
成员类型建议包括:
|
||||
|
||||
- `operator`:查看业务和账号展示。
|
||||
- `finance`:查看分成、结算和收款金额。
|
||||
- `customer_service`:查看订单、过程记录和客服消息。
|
||||
|
||||
分销网站成员只能看到所属站点的数据。前端传入的 `site_id` 不能扩大数据范围,后端必须从当前管理员成员关系中推导并校验。
|
||||
|
||||
### 5.3 权限码
|
||||
|
||||
建议新增:
|
||||
|
||||
```text
|
||||
distribution:site_view
|
||||
distribution:site_manage
|
||||
distribution:account_view
|
||||
distribution:account_display
|
||||
distribution:order_view
|
||||
distribution:process_view
|
||||
distribution:finance_view
|
||||
distribution:settlement_view
|
||||
distribution:settlement_manage
|
||||
distribution:rule_view
|
||||
distribution:rule_manage
|
||||
distribution:payment_view
|
||||
distribution:payment_manage
|
||||
distribution:qrcode_view
|
||||
distribution:qrcode_manage
|
||||
distribution:chat_view
|
||||
distribution:chat_send
|
||||
distribution:all_view
|
||||
```
|
||||
|
||||
分销客服默认只拥有:
|
||||
|
||||
```text
|
||||
distribution:order_view
|
||||
distribution:process_view
|
||||
distribution:chat_view
|
||||
distribution:chat_send
|
||||
```
|
||||
|
||||
客服禁止修改比例、支付配置、展示状态、退款、订单状态和结算结果。
|
||||
|
||||
## 6. 订单和账号展示
|
||||
|
||||
### 6.1 售出账号详情
|
||||
|
||||
分销后台需要显示:
|
||||
|
||||
- 订单号、商品编号、账号基本信息和账号快照。
|
||||
- 分销网站、收号方、售出方和租客。
|
||||
- 收号比例、销售比例、销售基础价、销售加价和实际支付金额。
|
||||
- 退款、手续费、服务费、税费、各方收入和结算状态。
|
||||
- 交接、租用、归还、结账、退款、仲裁等过程事件。
|
||||
|
||||
账号密码、实名信息等敏感字段必须按照现有后台脱敏和权限规则返回。是否允许分销成员查看完整登录信息:待确认。
|
||||
|
||||
### 6.2 分销网站正常订单收号详情
|
||||
|
||||
分销网的收号不是线下提号记录,而是正常租赁订单中的一方角色:卖家在分销网站上架账号,租客在分销网站完成支付、交接、租用和归还。该类记录应从 `rental_orders` 及分销订单参与方快照查询,不能使用 `admin_pickups` 作为数据来源。
|
||||
|
||||
正常订单需要保存分销站点和参与方快照,例如:
|
||||
|
||||
```text
|
||||
distribution_site_id
|
||||
seller_party_id
|
||||
renter_party_id
|
||||
receive_ratio
|
||||
receive_amount_cent
|
||||
rule_version
|
||||
```
|
||||
|
||||
不新增独立的线下收号流程;如需展示“分销网收号记录”,通过正常订单中的 `distributor_receive` 角色和订单状态筛选实现。
|
||||
|
||||
### 6.3 账号展示开关
|
||||
|
||||
网站级配置:
|
||||
|
||||
```text
|
||||
distribution_sites.display_account_enabled
|
||||
```
|
||||
|
||||
账号级配置:
|
||||
|
||||
```text
|
||||
distribution_site_accounts.display_status
|
||||
```
|
||||
|
||||
账号状态建议为:
|
||||
|
||||
```text
|
||||
enabled
|
||||
disabled
|
||||
sold
|
||||
offline
|
||||
```
|
||||
|
||||
关闭展示不删除账号、不影响历史订单,只阻止新展示或新订单,并写入审计日志。
|
||||
|
||||
一个账号是否允许同时挂载多个分销网站:待确认。第一版建议一个账号在同一时刻只归属一个可售分销网站,避免重复出售和归因冲突。
|
||||
|
||||
### 6.4 不纳入分销网的业务
|
||||
|
||||
以下业务不属于分销网正常订单,不进入分销订单分账:
|
||||
|
||||
- 平台或管理员线下提号(`admin_pickups`)。
|
||||
- 平台代管账号及线下交接、线下结算。
|
||||
- 撞车商品及撞车订单(`mohong`/`crash`)。
|
||||
- 分销网站之外的外部导入、私下交易或人工补录订单。
|
||||
|
||||
分销网订单必须同时满足:订单来源站点是分销网站、卖家和租客均属于该正常订单链路、支付和订单状态在平台系统内可追踪。
|
||||
|
||||
## 7. 比例、费用和分账口径
|
||||
|
||||
### 7.1 初步默认规则(可后台调整)
|
||||
|
||||
第一版先按以下规则建立默认配置,后续由平台后台调整。默认配置不是写死在代码中的常量,必须保存为可启用/停用、可版本化的后台规则:
|
||||
|
||||
```text
|
||||
两方:分销网收 3.5,主站卖出 4,服务费 2.5
|
||||
两方:分销网卖 4,主站收 3.5,服务费 2.5
|
||||
三方:主站服务费 2.5,分销 A 上架 3.5,分销 B 卖出 4
|
||||
```
|
||||
|
||||
初步配置含义:
|
||||
|
||||
| 模式 | 参与方 | 初始比例/费用 |
|
||||
| --- | --- | --- |
|
||||
| 两方-分销网收号 | 分销网收号、主站卖出 | 分销网 `3.5`,主站 `4`,服务费 `2.5` |
|
||||
| 两方-主站收号 | 分销网卖出、主站收号 | 分销网 `4`,主站 `3.5`,服务费 `2.5` |
|
||||
| 三方 | 主站服务费、分销 A 上架、分销 B 卖出 | 主站服务费 `2.5`,分销 A `3.5`,分销 B `4` |
|
||||
|
||||
以上是当前业务的初始值。后台需要允许平台管理员按分销网站、交易模式和生效时间修改比例及费用;新订单读取当前生效规则并写入快照,历史订单不跟随后台修改。
|
||||
|
||||
现有系统的比例语义是“哈夫币数量 / 比例 = 价格”,比例越大,价格越低。3.5 和 4 在分销规则中先分别作为“收号/上架方比例”和“卖出方比例”保存;如果后续确认比例含义需要调整,只影响规则解释和新订单,已结算订单仍以金额快照为准。
|
||||
|
||||
### 7.2 必须确认的口径
|
||||
|
||||
以下项目在 v0.2 中分为“已有初始值”和“仍待确认”:
|
||||
|
||||
| 项目 | 待确认内容 |
|
||||
| --- | --- |
|
||||
| 两方/三方初始比例 | 已按 7.1 建立默认值,后台可调整 |
|
||||
| 分销网站收号比例 | 已确定可按站点调整;是否设置上下限待确认 |
|
||||
| 主站统一卖出比例 | 初步按规则版本配置;是否全站固定待确认 |
|
||||
| 三方分销 | 已确定保留 A 上架、B 卖出角色;结算顺序和亏损承担待确认 |
|
||||
| 3.5 和 4 的最终业务含义 | 初步按收号/上架方与卖出方比例保存,最终文字口径待确认 |
|
||||
| 服务费 2.5 | 百分比、金额,还是比例值 |
|
||||
| 销售加价 | 是否计入服务费和税费基数 |
|
||||
| 税费税基 | 毛利、收入,还是分成后金额 |
|
||||
| 退款处理 | 重算剩余分成,还是生成反向冲正流水 |
|
||||
| 四舍五入尾差 | 统一归主站还是按最大分成方承担 |
|
||||
|
||||
### 7.3 后台调整要求
|
||||
|
||||
后台调整规则时必须:
|
||||
|
||||
1. 只允许具备 `distribution:rule_manage` 权限的管理员操作。
|
||||
2. 新增规则版本,不直接覆盖已生效版本的历史内容。
|
||||
3. 支持按分销网站、交易模式配置收号比例、卖出比例、服务费、手续费和税费开关。
|
||||
4. 支持设置生效时间;同一站点同一模式同一时刻只能有一个生效版本。
|
||||
5. 修改前展示影响范围和新旧值,确认后写入审计日志。
|
||||
6. 已创建、已支付或已结算订单继续使用其规则快照。
|
||||
7. 比例、费率和金额必须由后端校验范围,前端输入不能绕过校验。
|
||||
|
||||
### 7.4 推荐的金额模型
|
||||
|
||||
比例只负责生成金额,最终结算全部以整数分金额为准。每笔订单保存:
|
||||
|
||||
```text
|
||||
ratio_snapshot
|
||||
amount_snapshot
|
||||
rule_version
|
||||
```
|
||||
|
||||
建议所有金额字段使用 `*_cent BIGINT`,所有费率使用基点 `*_bps INT`:
|
||||
|
||||
```text
|
||||
0.5% = 50 bps
|
||||
1% = 100 bps
|
||||
```
|
||||
|
||||
### 7.5 手续费
|
||||
|
||||
计划要求手续费在分成前扣除,按退款后的实际支付金额计算,费率为 0.5%,结果四舍五入。
|
||||
|
||||
推荐公式:
|
||||
|
||||
```text
|
||||
实际支付金额 = 支付金额 - 成功退款金额
|
||||
手续费 = round_half_up(实际支付金额 × 50 / 10000)
|
||||
分成基数 = 实际支付金额 - 手续费
|
||||
```
|
||||
|
||||
“服务费 2.5”是否属于同一手续费层级:待确认。实现时必须将 `payment_fee`、`service_fee` 分成独立字段,不能合并为一个不可解释的费用。
|
||||
|
||||
### 7.6 销售加价
|
||||
|
||||
计划要求商家加价销售时,加价全部归卖方。
|
||||
|
||||
建议保存:
|
||||
|
||||
```text
|
||||
markup_amount_cent
|
||||
markup_beneficiary_type
|
||||
markup_beneficiary_id
|
||||
```
|
||||
|
||||
建议第一版按以下方式实现:
|
||||
|
||||
```text
|
||||
销售总价 - 销售基础价 = 销售加价
|
||||
销售加价全部计入卖方收入
|
||||
```
|
||||
|
||||
加价是否计入服务费和税费基数:待确认。无明确规则前,结算引擎必须分别展示基础分成和加价,避免隐含扣费。
|
||||
|
||||
### 7.7 税费
|
||||
|
||||
计划要求对分销所得利益按毛利的 1% 计税,并支持开关和可调税率;每个主体承担自己的分成税费。
|
||||
|
||||
推荐配置:
|
||||
|
||||
```text
|
||||
tax_enabled
|
||||
tax_rate_bps
|
||||
```
|
||||
|
||||
推荐公式:
|
||||
|
||||
```text
|
||||
毛利 = 该主体收入 - 该主体收号成本 - 该主体承担的费用
|
||||
税额 = round_half_up(毛利 × tax_rate_bps / 10000)
|
||||
```
|
||||
|
||||
每个主体分别计算税费,主站、分销 A、分销 B 不合并税基。毛利为负时税额暂按 0 处理,亏损如何结转:待确认。
|
||||
|
||||
### 7.8 账务不变量
|
||||
|
||||
每笔分销订单的结算必须满足:
|
||||
|
||||
```text
|
||||
实际支付金额
|
||||
= 手续费
|
||||
+ 平台服务费
|
||||
+ 各参与方收入
|
||||
+ 税费
|
||||
+ 固定规则下的舍入尾差
|
||||
```
|
||||
|
||||
退款、拒付、仲裁和结算撤销必须生成反向明细,不能直接覆盖原结算记录。
|
||||
|
||||
## 8. 建议数据模型
|
||||
|
||||
### 8.1 分销网站
|
||||
|
||||
```text
|
||||
distribution_sites
|
||||
```
|
||||
|
||||
建议字段:
|
||||
|
||||
```text
|
||||
id, site_code, site_name, domain, status
|
||||
display_account_enabled
|
||||
default_support_group_id
|
||||
tax_enabled, tax_rate_bps
|
||||
created_at, updated_at
|
||||
```
|
||||
|
||||
### 8.2 成员关系
|
||||
|
||||
```text
|
||||
distribution_site_members
|
||||
```
|
||||
|
||||
```text
|
||||
id, site_id, admin_user_id, member_type, status, data_scope
|
||||
created_at, updated_at
|
||||
```
|
||||
|
||||
### 8.3 账号展示关系
|
||||
|
||||
```text
|
||||
distribution_site_accounts
|
||||
```
|
||||
|
||||
```text
|
||||
id, site_id, account_id, listing_id, display_status
|
||||
display_started_at, display_ended_at
|
||||
created_at, updated_at
|
||||
```
|
||||
|
||||
### 8.4 规则版本
|
||||
|
||||
```text
|
||||
distribution_rules
|
||||
distribution_rule_versions
|
||||
```
|
||||
|
||||
规则需要保存:
|
||||
|
||||
```text
|
||||
site_id
|
||||
mode: two_party / three_party
|
||||
platform_receive_ratio
|
||||
platform_sell_ratio
|
||||
distributor_receive_ratio
|
||||
distributor_sell_ratio
|
||||
distributor_a_ratio
|
||||
distributor_b_ratio
|
||||
service_fee_value
|
||||
service_fee_unit: percent / fixed_amount / ratio
|
||||
payment_fee_bps
|
||||
tax_enabled
|
||||
tax_rate_bps
|
||||
effective_from, effective_to
|
||||
version, status
|
||||
```
|
||||
|
||||
首批默认规则按第 7.1 节初始化:
|
||||
|
||||
```text
|
||||
two_party_distributor_receive: distributor_receive_ratio=3.5, platform_sell_ratio=4, service_fee_value=2.5
|
||||
two_party_platform_receive: platform_receive_ratio=3.5, distributor_sell_ratio=4, service_fee_value=2.5
|
||||
three_party: distributor_a_ratio=3.5, distributor_b_ratio=4, service_fee_value=2.5
|
||||
```
|
||||
|
||||
其中 `two_party_platform_receive` 的参与方方向为分销网卖出、主站收号;比例初始值为分销网 `4`、主站 `3.5`。规则字段使用参与方角色命名,避免只写 `receive_ratio`/`sell_ratio` 造成方向歧义。上面的服务费 `2.5` 先作为规则配置值保存,具体单位由后台规则字段和后续口径确认决定。默认规则必须通过迁移或初始化数据写入,不写死在前端或结算代码中。
|
||||
|
||||
### 8.5 订单参与方
|
||||
|
||||
```text
|
||||
distribution_order_parties
|
||||
```
|
||||
|
||||
```text
|
||||
order_id, site_id, party_role, user_id
|
||||
ratio, base_amount_cent, markup_amount_cent
|
||||
gross_amount_cent, fee_amount_cent, tax_amount_cent
|
||||
net_amount_cent, status
|
||||
```
|
||||
|
||||
`party_role` 建议支持:
|
||||
|
||||
```text
|
||||
platform
|
||||
distributor_a
|
||||
distributor_b
|
||||
distributor_receive
|
||||
seller
|
||||
buyer
|
||||
```
|
||||
|
||||
### 8.6 订单财务快照
|
||||
|
||||
```text
|
||||
distribution_order_financials
|
||||
```
|
||||
|
||||
```text
|
||||
order_id UNIQUE
|
||||
gross_paid_amount_cent
|
||||
refund_amount_cent
|
||||
net_paid_amount_cent
|
||||
payment_fee_bps, payment_fee_cent
|
||||
service_fee_bps, service_fee_cent
|
||||
markup_amount_cent
|
||||
tax_enabled, tax_rate_bps, tax_amount_cent
|
||||
split_base_amount_cent
|
||||
settlement_status
|
||||
rule_version
|
||||
created_at, settled_at
|
||||
```
|
||||
|
||||
### 8.7 结算批次和明细
|
||||
|
||||
```text
|
||||
distribution_settlements
|
||||
distribution_settlement_items
|
||||
```
|
||||
|
||||
结算批次保存周期、状态、审核人、付款时间和失败原因;明细保存每个站点、参与方、收入、成本、毛利、手续费、税费和钱包流水关联。
|
||||
|
||||
### 8.8 支付配置绑定
|
||||
|
||||
```text
|
||||
distribution_site_payment_configs
|
||||
```
|
||||
|
||||
一个分销网站可以绑定一个或多个现有 `payment_merchant_configs`。支付下单时保存 `site_id` 和 `payment_config_id` 快照,回调不重新依赖当前配置。
|
||||
|
||||
### 8.9 二维码和客服组
|
||||
|
||||
建议给二维码池或关联表增加:
|
||||
|
||||
```text
|
||||
distribution_site_id
|
||||
support_group_id
|
||||
```
|
||||
|
||||
取码优先匹配“分销网站 + 客服组”的专属二维码池。站点已配置专属池时,不回退到其他站点二维码。
|
||||
|
||||
## 9. API 规划
|
||||
|
||||
### 9.1 平台管理员
|
||||
|
||||
```text
|
||||
GET /api/admin/distribution/sites
|
||||
POST /api/admin/distribution/sites
|
||||
GET /api/admin/distribution/sites/:id
|
||||
PUT /api/admin/distribution/sites/:id
|
||||
POST /api/admin/distribution/sites/:id/enable
|
||||
POST /api/admin/distribution/sites/:id/disable
|
||||
|
||||
GET /api/admin/distribution/sold-orders
|
||||
GET /api/admin/distribution/sold-orders/:id
|
||||
GET /api/admin/distribution/receive-orders
|
||||
GET /api/admin/distribution/receive-orders/:id
|
||||
|
||||
GET /api/admin/distribution/rules
|
||||
POST /api/admin/distribution/rules
|
||||
PUT /api/admin/distribution/rules/:id
|
||||
|
||||
GET /api/admin/distribution/settlements
|
||||
GET /api/admin/distribution/settlements/:id
|
||||
POST /api/admin/distribution/settlements/:id/approve
|
||||
POST /api/admin/distribution/settlements/:id/reject
|
||||
|
||||
GET /api/admin/distribution/reports/summary
|
||||
GET /api/admin/distribution/reports/orders
|
||||
GET /api/admin/distribution/reports/finance
|
||||
```
|
||||
|
||||
### 9.2 分销网站成员
|
||||
|
||||
```text
|
||||
GET /api/admin/distribution/me
|
||||
GET /api/admin/distribution/orders
|
||||
GET /api/admin/distribution/orders/:id
|
||||
GET /api/admin/distribution/receive-orders
|
||||
GET /api/admin/distribution/chats
|
||||
GET /api/admin/distribution/chats/:id/messages
|
||||
POST /api/admin/distribution/chats/:id/messages
|
||||
```
|
||||
|
||||
同一套后端接口通过成员关系限制 `site_id`。平台管理员可以显式选择站点或查看全部;分销成员不能自行传入其他站点 ID。
|
||||
|
||||
## 10. 支付和域名上下文
|
||||
|
||||
生产环境建议通过请求 Host 解析分销网站:
|
||||
|
||||
```text
|
||||
请求域名 -> 服务端解析 site_id -> 校验站点状态 -> 选择绑定支付配置
|
||||
```
|
||||
|
||||
开发环境可以临时使用 `site_code`,但生产支付下单不能完全信任前端提交的站点参数。
|
||||
|
||||
支付配置选择规则:
|
||||
|
||||
1. 只读取当前站点启用的支付配置。
|
||||
2. 支持一个或多个配置,按优先级或健康状态选择。
|
||||
3. 支付单保存站点和支付配置快照。
|
||||
4. 回调、查询和退款使用支付单快照,不因后台配置变更而切换通道。
|
||||
|
||||
## 11. 独立前端工程
|
||||
|
||||
建议目录:
|
||||
|
||||
```text
|
||||
distribution-admin/
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
├── Dockerfile
|
||||
└── src/
|
||||
├── main.tsx
|
||||
├── router/
|
||||
├── layouts/
|
||||
├── stores/
|
||||
├── services/
|
||||
├── types/
|
||||
└── features/
|
||||
├── dashboard/
|
||||
├── sites/
|
||||
├── sold-orders/
|
||||
├── receive-orders/
|
||||
├── settlements/
|
||||
├── payment-configs/
|
||||
├── qrcodes/
|
||||
├── chats/
|
||||
└── reports/
|
||||
```
|
||||
|
||||
建议继续复用现有 API 响应格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "ok",
|
||||
"message": "ok",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
独立后台第一阶段可复用现有管理员登录接口和 HttpOnly Cookie,但不与现有前端共享 localStorage。是否建设分销员独立登录:待确认;如果分销员不是平台管理员,应新增独立主体和认证体系。
|
||||
|
||||
## 12. 部署方案
|
||||
|
||||
新增独立前端构建单元:
|
||||
|
||||
```text
|
||||
distribution-admin/Dockerfile
|
||||
```
|
||||
|
||||
Caddy 增加独立子域名站点。子域名名称暂定,最终以 DNS、证书和运营规划确认的名称为准:
|
||||
|
||||
```text
|
||||
distribution.hao858.com {
|
||||
handle /api/* {
|
||||
reverse_proxy backend:8080
|
||||
}
|
||||
|
||||
handle {
|
||||
root * /srv/distribution-admin
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
主站、现有运营后台和分销后台分别构建、分别发布。数据库迁移仍由现有生产发布流程执行,后端和前端发布需要保持兼容窗口。
|
||||
|
||||
## 13. 实施阶段
|
||||
|
||||
### 阶段 0:确认业务口径
|
||||
|
||||
- 确认 3.5、4、2.5 的单位和方向。
|
||||
- 确认两方、三方参与方及亏损承担。
|
||||
- 确认加价是否计入服务费和税费。
|
||||
- 确认税费毛利计算口径。
|
||||
- 确认退款采用重算还是冲正。
|
||||
- 确认分销员和分销客服的身份模型。
|
||||
- 确认账号是否允许多站点展示。
|
||||
|
||||
### 阶段 1:数据库和计算引擎
|
||||
|
||||
新增 Goose 迁移,建议从 `000062_distribution_init.sql` 开始,包含站点、成员、展示关系、默认规则版本、订单参与方、财务快照、结算、支付绑定、二维码绑定和权限初始化。默认分账值先按第 7.1 节写入,后续通过后台规则页面调整。
|
||||
|
||||
先完成纯函数计算和单元测试,不接真实订单。
|
||||
|
||||
### 阶段 2:订单生命周期接入
|
||||
|
||||
接入:
|
||||
|
||||
```text
|
||||
支付成功
|
||||
退款成功
|
||||
订单完成
|
||||
订单仲裁
|
||||
订单取消
|
||||
```
|
||||
|
||||
要求支付回调、退款回调、订单完成和结算操作幂等;规则修改不影响已有订单;钱包流水和分销结算明细金额一致。
|
||||
|
||||
### 阶段 3:后台 API
|
||||
|
||||
完成站点、售出订单、正常订单收号记录、比例规则、结算、支付通道、二维码客服和账号展示接口,并补齐站点数据隔离测试。
|
||||
|
||||
### 阶段 4:Ant Design 前端
|
||||
|
||||
开发顺序:登录和权限、仪表盘、售出订单、正常订单收号记录、站点配置、规则、结算、支付、客服二维码、报表。
|
||||
|
||||
### 阶段 5:独立生产部署
|
||||
|
||||
接入独立子域名、DNS、Caddy 配置、前端镜像和发布脚本,完成健康检查、登录、订单查询、支付配置和客服消息冒烟测试。
|
||||
|
||||
## 14. 测试和验收标准
|
||||
|
||||
### 权限隔离
|
||||
|
||||
- 分销网站 A 不能查询分销网站 B。
|
||||
- 分销客服不能修改比例、支付配置、订单状态和结算。
|
||||
- 主站财务可查看全部站点资金数据。
|
||||
- 超级管理员可管理全部站点。
|
||||
|
||||
### 账务正确性
|
||||
|
||||
- 手续费按退款后的实际支付金额计算。
|
||||
- 手续费先扣除,再进行分成。
|
||||
- 0.5% 使用四舍五入并以分保存。
|
||||
- 销售加价归指定卖方。
|
||||
- 各主体独立计算税费。
|
||||
- 重复支付、退款和结算请求不会重复入账。
|
||||
- 分销结算明细与钱包流水一致。
|
||||
|
||||
### 业务正确性
|
||||
|
||||
- 售出订单和正常订单收号记录均可查询。
|
||||
- 线下提号、平台代管和撞车订单不会进入分销订单列表或分账。
|
||||
- 账号关闭展示后不影响历史订单。
|
||||
- 规则修改只影响新订单。
|
||||
- 支付只使用当前分销网站绑定的通道。
|
||||
- 分销网站使用指定客服组和二维码池。
|
||||
- 客服消息按站点隔离。
|
||||
|
||||
### 发布标准
|
||||
|
||||
- `./scripts/check.sh` 通过。
|
||||
- 新迁移具备 Up/Down 并通过 SQL 门禁。
|
||||
- 金额、退款、分账、税费和状态机变更包含回归测试。
|
||||
- 生产环境完成独立域名、后台登录、订单查询和支付配置冒烟验证。
|
||||
|
||||
## 15. 待确认清单
|
||||
|
||||
以下问题确认后,更新本文档版本并进入详细表结构和 API 契约设计:
|
||||
|
||||
1. 分销网站是平台合作方,还是普通用户下的分销身份?
|
||||
2. 分销客服是否使用现有 `admin_users`?
|
||||
3. 3.5、4、2.5 的确切单位和计算方向是什么?
|
||||
4. 服务费和支付手续费是否都按实际支付净额计算?
|
||||
5. 加价是否计入服务费、税费和退款基数?
|
||||
6. 税费是否只对正毛利计提?亏损是否结转?
|
||||
7. 退款后佣金是重算剩余金额,还是追加负向冲正?
|
||||
8. 一个账号能否同时展示在多个分销网站?
|
||||
9. 分销后台子域名最终使用哪个名称(当前暂定 `distribution.hao858.com`)?
|
||||
10. 分销网站是否需要独立登录入口和独立账号体系?
|
||||
11. 分销网站是否能查看完整账号登录资料?
|
||||
|
||||
---
|
||||
|
||||
后续每次确认业务规则后,优先更新第 7、15 节,再同步更新第 8、9、13、14 节,确保规则、数据、接口、实施和验收标准保持一致。
|
||||
|
||||
## 16. 当前实现基线(v0.3)
|
||||
|
||||
本分支已落地第一批可运行功能,主站域名为 `https://hao858.com/`,独立后台工程位于 `distribution-admin/`,子域名暂定 `https://distribution.hao858.com/`:
|
||||
|
||||
- Go 后端新增 `distribution_sites`、成员、账号展示、规则版本、订单财务快照、订单参与方、结算批次/明细、支付通道绑定表。
|
||||
- `rental_orders` 增加分销站点、模式和规则版本字段;只有正常租赁订单才允许写入这些字段。
|
||||
- 分账计算引擎已实现退款后净支付、0.5% 基点手续费先扣、服务费三种单位、加价单独展示、各参与方毛利税费和四舍五入。
|
||||
- 管理 API 已覆盖站点、规则、售出/收号订单查询、财务快照、账号展示开关、支付通道绑定和结算批次。
|
||||
- 后台权限使用 `distribution:*` 权限码;后续接入成员站点数据范围校验时,不能使用请求参数扩大范围。
|
||||
- `distribution-admin` 使用 React + TypeScript + Vite + Ant Design,已提供仪表盘、站点、售出订单、收号订单及结算/支付/账号/规则/客服入口和 Docker 镜像。
|
||||
|
||||
当前仍需在下一迭代接入真实支付回调、退款/订单完成结算、钱包流水、客服二维码池和成员级站点隔离;在这些链路接通前,财务快照接口只作为后台受控的幂等写入入口。
|
||||
Reference in New Issue
Block a user