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

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