package distribution import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" "github.com/redis/go-redis/v9" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "gorm.io/gorm/clause" ) 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") ErrSettlementBatchEmpty = errors.New("distribution settlement batch empty") ErrSettlementTotalMismatch = errors.New("distribution settlement total mismatch") ErrPaymentConfigNotFound = errors.New("distribution payment config not found") ErrOrderSnapshotExists = errors.New("distribution order snapshot exists") ErrSiteAccessDenied = errors.New("distribution site access denied") ErrInvalidCredential = errors.New("invalid credential") ErrAdminDisabled = errors.New("admin disabled") ErrDuplicateSiteCodeOrDomain = errors.New("distribution site code or domain already exists") ErrDuplicateUsername = errors.New("username already exists") ) type Repository struct { db *gorm.DB redis *redis.Client } func NewRepository(db *gorm.DB, redis ...*redis.Client) *Repository { r := &Repository{db: db} if len(redis) > 0 { r.redis = redis[0] } return r } func (r *Repository) ScopeSiteID(ctx context.Context, adminID, requested uint64) (uint64, error) { if adminID == 0 { return requested, nil } var members []model.DistributionSiteMember if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil { return 0, err } if len(members) == 0 { return requested, nil } if requested > 0 { for _, member := range members { if member.SiteID == requested { return requested, nil } } return 0, ErrSiteAccessDenied } if len(members) == 1 { return members[0].SiteID, nil } return 0, nil } // ensureSiteAccess 校验管理员可访问指定站点: // 无成员记录(超级管理员语义)放行,有成员记录则必须命中。 func (r *Repository) ensureSiteAccess(ctx context.Context, adminID, siteID uint64) error { if adminID == 0 { return nil } var count int64 if err := r.db.WithContext(ctx).Model(&model.DistributionSiteMember{}). Where("admin_user_id = ? AND status = 'active'", adminID).Count(&count).Error; err != nil { return err } if count == 0 { return nil } if siteID == 0 { return ErrSiteAccessDenied } var allowed int64 if err := r.db.WithContext(ctx).Model(&model.DistributionSiteMember{}). Where("admin_user_id = ? AND site_id = ? AND status = 'active'", adminID, siteID).Count(&allowed).Error; err != nil { return err } if allowed == 0 { return ErrSiteAccessDenied } return nil } func (r *Repository) ListSites(ctx context.Context) ([]SiteDTO, error) { var rows []model.DistributionSite if err := r.db.WithContext(ctx).Order("id ASC").Find(&rows).Error; err != nil { 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, adminID, 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 } if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil { 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, adminID, 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 err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil { 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, adminID uint64, 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 { if err := r.ensureSiteAccess(ctx, adminID, *siteID); err != nil { return nil, err } 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) { if req.SiteID != nil { if err := r.ensureSiteAccess(ctx, adminID, *req.SiteID); err != nil { return nil, err } } 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, adminID, 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 } if row.SiteID != nil { if err := r.ensureSiteAccess(ctx, adminID, *row.SiteID); err != nil { 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, f.rounding_adjustment_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, adminID, 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, f.rounding_adjustment_cent, ro.created_at").Where("ro.id = ?", id).Scan(&item).Error if err != nil { return nil, err } if item.OrderID == 0 { return nil, ErrOrderNotFound } if err := r.ensureSiteAccess(ctx, adminID, item.SiteID); err != nil { return nil, err } 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, adminID, 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 } if err := r.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil { 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) { var configCount int64 if err := r.db.WithContext(ctx).Model(&model.PaymentMerchantConfig{}). Where("id = ?", req.ConfigID).Count(&configCount).Error; err != nil { return nil, err } if configCount == 0 { return nil, ErrPaymentConfigNotFound } 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, adminID, id uint64) error { var row model.DistributionSitePaymentChannel if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrPaymentChannelNotFound } return err } if err := r.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil { return err } return r.db.WithContext(ctx).Delete(&row).Error } 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 } if err := r.ensureSiteAccess(ctx, adminID, req.SiteID); err != nil { return nil, err } if req.SiteID == 0 { return nil, ErrInvalidPeriod } var batch model.DistributionSettlementBatch err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { batch = model.DistributionSettlementBatch{SiteID: req.SiteID, BatchNo: "DS" + time.Now().Format("20060102150405.000000000"), PeriodStart: req.PeriodStart, PeriodEnd: req.PeriodEnd, Status: "draft", CreatedBy: &adminID} if err := tx.Create(&batch).Error; err != nil { return err } // 聚合周期内尚未进入任何批次的待结算订单财务快照。 var financials []model.DistributionOrderFinancial if err := tx.Where("site_id = ? AND settlement_status = 'pending'", req.SiteID). Where("created_at >= ? AND created_at < ?", req.PeriodStart, req.PeriodEnd). Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.order_id = distribution_order_financials.order_id)"). Order("id ASC").Find(&financials).Error; err != nil { return err } total := int64(0) for _, financial := range financials { var parties []model.DistributionOrderParty if err := tx.Where("order_id = ?", financial.OrderID).Find(&parties).Error; err != nil { return err } for _, party := range parties { item := model.DistributionSettlementItem{BatchID: batch.ID, OrderID: financial.OrderID, PartyID: party.ID, AmountCent: party.IncomeAmountCent, Status: "pending"} if err := tx.Create(&item).Error; err != nil { return err } total += party.IncomeAmountCent } } batch.TotalAmountCent = total return tx.Model(&model.DistributionSettlementBatch{}).Where("id = ?", batch.ID). Update("total_amount_cent", total).Error }) if err != nil { return nil, err } return &SettlementBatchDTO{ID: batch.ID, SiteID: batch.SiteID, BatchNo: batch.BatchNo, PeriodStart: batch.PeriodStart, PeriodEnd: batch.PeriodEnd, TotalAmountCent: batch.TotalAmountCent, Status: batch.Status}, nil } func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) { var row model.DistributionSettlementBatch err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrSettlementBatchNotFound } return err } if err := r.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil { return err } if row.Status == "settled" { return ErrBatchAlreadySettled } var items []model.DistributionSettlementItem if err := tx.Where("batch_id = ?", row.ID).Find(&items).Error; err != nil { return err } if len(items) == 0 { return ErrSettlementBatchEmpty } var total int64 for _, item := range items { total += item.AmountCent } if total != row.TotalAmountCent { return ErrSettlementTotalMismatch } now := time.Now() if err := tx.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", row.ID). Update("status", "settled").Error; err != nil { return err } if err := tx.Model(&model.DistributionOrderFinancial{}). Where("order_id IN (?)", tx.Model(&model.DistributionSettlementItem{}). Select("order_id").Where("batch_id = ?", row.ID)). Updates(map[string]any{"settlement_status": "settled", "settled_at": now}).Error; err != nil { return err } row.Status = "settled" row.SettledAt = &now return tx.Save(&row).Error }) if 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, adminID, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) { if req.SiteID == 0 || orderID == 0 { return nil, ErrInvalidAmount } if err := r.ensureSiteAccess(ctx, adminID, req.SiteID); err != nil { return nil, err } 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 } var orderExists int64 if err := r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Count(&orderExists).Error; err != nil { return nil, err } if orderExists == 0 { return nil, ErrOrderNotFound } // 手工补数只允许创建,不允许覆盖支付事务写入的真实快照。 var existing int64 if err := r.db.WithContext(ctx).Model(&model.DistributionOrderFinancial{}). Where("order_id = ?", orderID).Count(&existing).Error; err != nil { return nil, err } if existing > 0 { return nil, ErrOrderSnapshotExists } 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{Mode: rule.Mode, 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, RoundingAdjustmentCent: SplitRemainderCent(result.SplitBaseAmountCent, SplitSettlement(Rule{Mode: rule.Mode}, result.SplitBaseAmountCent, result.MarkupAmountCent)), SettlementStatus: "pending"} if err := tx.Create(&financial).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 } } if err := tx.Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{"distribution_site_id": req.SiteID, "distribution_rule_version_id": req.RuleVersionID}).Error; err != nil { return err } return auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", ActorID: adminID, Action: "distribution_financial_snapshot_create", BizType: "distribution_order", BizID: &orderID, Detail: map[string]any{"site_id": req.SiteID, "rule_version_id": req.RuleVersionID, "gross_paid_amount_cent": req.GrossPaidAmountCent, "refund_amount_cent": req.RefundAmountCent, "parties": len(req.Parties)}, }) }) if err != nil { return nil, err } return r.FindOrder(ctx, adminID, orderID) } func (r *Repository) ListSettlementItems(ctx context.Context, adminID, batchID uint64) ([]SettlementItemDTO, error) { var batch model.DistributionSettlementBatch if err := r.db.WithContext(ctx).First(&batch, batchID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrSettlementBatchNotFound } return nil, err } if err := r.ensureSiteAccess(ctx, adminID, batch.SiteID); err != nil { return nil, err } var rows []struct { model.DistributionSettlementItem PartyRole string } if err := r.db.WithContext(ctx). Table("distribution_settlement_items i"). Select("i.id, i.order_id, i.amount_cent, i.status, p.party_role"). Joins("JOIN distribution_order_parties p ON p.id = i.party_id"). Where("i.batch_id = ?", batchID). Order("i.id ASC").Scan(&rows).Error; err != nil { return nil, err } items := make([]SettlementItemDTO, 0, len(rows)) for _, row := range rows { items = append(items, SettlementItemDTO{ID: row.ID, OrderID: row.OrderID, PartyRole: row.PartyRole, AmountCent: row.AmountCent, Status: row.Status}) } return items, nil } func (r *Repository) LoginMember(ctx context.Context, username, password string) (*model.AdminUser, *DistributionCurrentMemberDTO, error) { if r.db == nil { return nil, nil, ErrDependencyUnavailable } var admin model.AdminUser if err := r.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, ErrInvalidCredential } return nil, nil, err } if admin.Status != "active" { return nil, nil, ErrAdminDisabled } if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil { return nil, nil, ErrInvalidCredential } var members []model.DistributionSiteMember if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", admin.ID).Find(&members).Error; err != nil { return nil, nil, err } dto := DistributionCurrentMemberDTO{ AdminID: admin.ID, Username: admin.Username, Nickname: admin.Nickname, IsSuperadmin: len(members) == 0, } if len(members) > 0 { var site model.DistributionSite if err := r.db.WithContext(ctx).First(&site, members[0].SiteID).Error; err == nil { dto.SiteID = &site.ID dto.SiteCode = site.SiteCode dto.SiteName = site.SiteName dto.Domain = site.Domain dto.MemberType = members[0].MemberType } } now := time.Now() admin.LastLoginAt = &now _ = r.db.WithContext(ctx).Model(&admin).Update("last_login_at", now).Error if r.redis != nil { if dto.IsSuperadmin { raw, _ := json.Marshal([]string{"*"}) _ = r.redis.Set(ctx, fmt.Sprintf("admin:perms:%d", admin.ID), string(raw), 2*time.Hour).Err() } else { var permCodes []string if r.db != nil { _ = r.db.WithContext(ctx).Table("permissions"). Select("DISTINCT permissions.code"). Joins("JOIN role_permissions rp ON rp.permission_id = permissions.id"). Joins("JOIN admin_user_roles aur ON aur.role_id = rp.role_id"). Where("aur.admin_user_id = ?", admin.ID). Pluck("code", &permCodes).Error } if len(permCodes) == 0 { permCodes = []string{ "distribution:site_view", "distribution:rule_view", "distribution:order_view", "distribution:account_view", "distribution:account_display", "distribution:payment_view", "distribution:payment_manage", "distribution:settlement_view", "distribution:settlement_manage", } } raw, _ := json.Marshal(permCodes) _ = r.redis.Set(ctx, fmt.Sprintf("admin:perms:%d", admin.ID), string(raw), 2*time.Hour).Err() } } return &admin, &dto, nil } func (r *Repository) GetMemberProfile(ctx context.Context, adminID uint64) (*DistributionCurrentMemberDTO, error) { if r.db == nil { return nil, ErrDependencyUnavailable } var admin model.AdminUser if err := r.db.WithContext(ctx).First(&admin, adminID).Error; err != nil { return nil, err } var members []model.DistributionSiteMember if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil { return nil, err } dto := DistributionCurrentMemberDTO{ AdminID: admin.ID, Username: admin.Username, Nickname: admin.Nickname, IsSuperadmin: len(members) == 0, } if len(members) > 0 { var site model.DistributionSite if err := r.db.WithContext(ctx).First(&site, members[0].SiteID).Error; err == nil { dto.SiteID = &site.ID dto.SiteCode = site.SiteCode dto.SiteName = site.SiteName dto.Domain = site.Domain dto.MemberType = members[0].MemberType } } return &dto, nil } func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*OnboardSiteResponse, error) { if r.db == nil { return nil, ErrDependencyUnavailable } var count int64 if err := r.db.WithContext(ctx).Model(&model.DistributionSite{}). Where("site_code = ? OR domain = ?", req.SiteCode, req.Domain).Count(&count).Error; err != nil { return nil, err } if count > 0 { return nil, ErrDuplicateSiteCodeOrDomain } if err := r.db.WithContext(ctx).Model(&model.AdminUser{}). Where("username = ?", req.AdminUsername).Count(&count).Error; err != nil { return nil, err } if count > 0 { return nil, ErrDuplicateUsername } hash, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost) if err != nil { return nil, err } var resp OnboardSiteResponse err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { displayEnabled := true if req.DisplayAccountEnabled != nil { displayEnabled = *req.DisplayAccountEnabled } taxEnabled := false if req.TaxEnabled != nil { taxEnabled = *req.TaxEnabled } taxRate := int64(100) if req.TaxRateBPS != nil { taxRate = *req.TaxRateBPS } site := model.DistributionSite{ SiteCode: req.SiteCode, SiteName: req.SiteName, Domain: req.Domain, Status: "active", DisplayAccountEnabled: displayEnabled, TaxEnabled: taxEnabled, TaxRateBPS: taxRate, } if err := tx.Create(&site).Error; err != nil { return err } nickname := req.AdminNickname if nickname == "" { nickname = req.SiteName + "管理员" } adminUser := model.AdminUser{ Username: req.AdminUsername, PasswordHash: string(hash), Nickname: nickname, Status: "active", TokenVersion: 1, PasswordMustChange: false, } if err := tx.Create(&adminUser).Error; err != nil { return err } var roleID uint64 if err := tx.Table("roles").Where("code = ?", "ops").Pluck("id", &roleID).Error; err == nil && roleID > 0 { _ = tx.Exec("INSERT IGNORE INTO admin_user_roles (admin_user_id, role_id) VALUES (?, ?)", adminUser.ID, roleID).Error } memberType := req.MemberType if memberType == "" { memberType = "operator" } member := model.DistributionSiteMember{ SiteID: site.ID, AdminUserID: adminUser.ID, MemberType: memberType, Status: "active", DataScope: "site", } if err := tx.Create(&member).Error; err != nil { return err } distReceive := req.DistributorReceiveRatio if distReceive == 0 { distReceive = 3.5 } platSell := req.PlatformSellRatio if platSell == 0 { platSell = 4.0 } serviceFee := req.ServiceFeeValue if serviceFee == 0 { serviceFee = 2.5 } rule := model.DistributionRuleVersion{ SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", DistributorReceiveRatio: distReceive, PlatformSellRatio: platSell, ServiceFeeValue: serviceFee, ServiceFeeUnit: ServiceFeeUnitFixedAmount, PaymentFeeBPS: 50, TaxEnabled: taxEnabled, TaxRateBPS: taxRate, EffectiveFrom: time.Now(), } if err := tx.Create(&rule).Error; err != nil { return err } resp = OnboardSiteResponse{ Site: siteDTO(site), Rule: ruleDTO(rule), Member: DistributionCurrentMemberDTO{ AdminID: adminUser.ID, Username: adminUser.Username, Nickname: adminUser.Nickname, IsSuperadmin: false, SiteID: &site.ID, SiteCode: site.SiteCode, SiteName: site.SiteName, Domain: site.Domain, MemberType: member.MemberType, }, } return nil }) if err != nil { return nil, err } return &resp, nil }