package distribution import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "slices" "strings" "sync" "time" "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" listingpkg "hfb_sys/backend/internal/modules/listing" "hfb_sys/backend/internal/modules/systemconfig" "hfb_sys/backend/internal/modules/wallet" "hfb_sys/backend/pkg/crypto" "github.com/redis/go-redis/v9" "golang.org/x/crypto/bcrypt" "gorm.io/datatypes" "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") ErrTooManySyncListings = errors.New("too many listings to sync") ErrSyncListingNotFound = errors.New("sync listing not found") ErrEmptyListingIDs = errors.New("listing ids required") ErrInvalidListingStatus = errors.New("invalid listing status") ErrInvalidSettlementHold = errors.New("settlement hold days must be between 0 and 365") ErrBatchNotCancellable = errors.New("distribution settlement batch cannot be cancelled") ErrSettlementBatchStale = errors.New("distribution settlement batch stale") ErrAnnouncementNotFound = errors.New("distribution site announcement not found") ErrInvalidAnnouncementMode = errors.New("invalid announcement mode") ) var settlementReadyOrderStatuses = []string{"completed", "closed"} const maxSyncListingIDs = 200 // normalizeSettlementHoldDays 校验站点结算冷却天数,允许 0(订单完成即释放)到 365 天。 func normalizeSettlementHoldDays(days int) (int, error) { if days < 0 || days > 365 { return 0, ErrInvalidSettlementHold } return days, nil } func normalizeReceiveRatioDelta(delta float64) (float64, error) { if delta < 0 { return 0, ErrInvalidAmount } return listingpkg.NormalizeSiteReceiveRatioDelta(delta), nil } func normalizeReceiveCostDelta(delta float64) (float64, error) { if delta < 0 { return 0, ErrInvalidAmount } return listingpkg.NormalizeSiteReceiveCostDelta(delta), nil } type Repository struct { db *gorm.DB redis *redis.Client encryptor crypto.Encryptor allowMockSMS bool siteSyncLocks sync.Map } 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) SetEncryptor(enc crypto.Encryptor) { if r != nil { r.encryptor = enc } } // SetAllowMockSMS 设置是否允许使用 mock 短信服务商,仅非生产环境应开启。 func (r *Repository) SetAllowMockSMS(allow bool) { if r != nil { r.allowMockSMS = allow } } // validateSMSProvider 校验并规范化短信服务商;mock 仅在显式允许时可用(非生产环境)。 func (r *Repository) validateSMSProvider(provider string) (string, error) { normalized := strings.ToLower(strings.TrimSpace(provider)) if normalized == "" { return "aliyun", nil } switch normalized { case "aliyun": return normalized, nil case "mock": if r != nil && r.allowMockSMS { return normalized, nil } return "", fmt.Errorf("%w: 当前环境不允许使用 mock 短信服务商", ErrSMSConfigInvalid) default: return "", fmt.Errorf("%w: 不支持的短信服务商 %q", ErrSMSConfigInvalid, provider) } } func (r *Repository) encryptSecret(plaintext string) (string, error) { plaintext = strings.TrimSpace(plaintext) if plaintext == "" { return "", nil } if r != nil && r.encryptor != nil { return r.encryptor.Encrypt(plaintext) } return plaintext, nil } // decryptSecret 解密访问密钥。解密失败时返回错误,避免把密文当明文密钥继续使用。 func (r *Repository) decryptSecret(ciphertext string) (string, error) { ciphertext = strings.TrimSpace(ciphertext) if ciphertext == "" { return "", nil } if r != nil && r.encryptor != nil { plain, err := r.encryptor.Decrypt(ciphertext) if err != nil { return "", fmt.Errorf("解密短信访问密钥失败: %w", err) } return plain, nil } return ciphertext, nil } func (r *Repository) hasGlobalDistributionAccess(ctx context.Context, adminID uint64) (bool, error) { return r.hasGlobalDistributionAccessWithDB(ctx, r.db, adminID) } func (r *Repository) hasGlobalDistributionAccessWithDB(ctx context.Context, db *gorm.DB, adminID uint64) (bool, error) { if adminID == 0 { return true, nil } if db == nil { return false, nil } var n int64 if err := db.WithContext(ctx).Table("roles"). Joins("JOIN admin_user_roles aur ON aur.role_id = roles.id"). Where("aur.admin_user_id = ? AND roles.code = ?", adminID, "super_admin"). Count(&n).Error; err != nil { return false, err } if n > 0 { return true, nil } if err := db.WithContext(ctx).Table("permissions"). 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 = ? AND permissions.code = ?", adminID, "distribution:all_view"). Count(&n).Error; err != nil { return false, err } return n > 0, nil } func (r *Repository) listActiveMemberSiteIDs(ctx context.Context, adminID uint64) ([]uint64, error) { 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 } ids := make([]uint64, 0, len(members)) for _, member := range members { ids = append(ids, member.SiteID) } return ids, nil } func (r *Repository) ScopeSiteID(ctx context.Context, adminID, requested uint64) (uint64, error) { if adminID == 0 { return requested, nil } global, err := r.hasGlobalDistributionAccess(ctx, adminID) if err != nil { return 0, err } if global { return requested, nil } siteIDs, err := r.listActiveMemberSiteIDs(ctx, adminID) if err != nil { return 0, err } if len(siteIDs) == 0 { return 0, ErrSiteAccessDenied } if requested > 0 { for _, siteID := range siteIDs { if siteID == requested { return requested, nil } } return 0, ErrSiteAccessDenied } if len(siteIDs) == 1 { return siteIDs[0], nil } return 0, nil } // ensureSiteAccess 校验管理员可访问指定站点。 // 仅超级管理员或持有 distribution:all_view 的账号可跨站;无成员记录不再视为超管。 // adminRef 把 adminID=0(代表系统自动操作)映射为 NULL。 // // 分销规则与结算批次的 created_by 都带指向 admin_users 的外键, // 直接写 0 会因找不到对应管理员而违反外键约束;NULL 才是"无具体操作人"的正确表示。 func adminRef(adminID uint64) *uint64 { if adminID == 0 { return nil } return &adminID } func (r *Repository) ensureSiteAccess(ctx context.Context, adminID, siteID uint64) error { return r.ensureSiteAccessWithDB(ctx, r.db, adminID, siteID) } func (r *Repository) ensureSiteAccessWithDB(ctx context.Context, db *gorm.DB, adminID, siteID uint64) error { if adminID == 0 { return nil } if db == nil { db = r.db } global, err := r.hasGlobalDistributionAccessWithDB(ctx, db, adminID) if err != nil { return err } if global { return nil } if siteID == 0 { return ErrSiteAccessDenied } var allowed int64 if err := 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)) if len(rows) == 0 { return items, nil } siteIDs := make([]uint64, len(rows)) for i, row := range rows { siteIDs[i] = row.ID items = append(items, siteDTO(row)) } type memberRow struct { SiteID uint64 `gorm:"column:site_id"` AdminUserID uint64 `gorm:"column:admin_user_id"` Username string `gorm:"column:username"` Nickname string `gorm:"column:nickname"` MemberType string `gorm:"column:member_type"` } var memberRows []memberRow _ = r.db.WithContext(ctx).Table("distribution_site_members"). Select("distribution_site_members.site_id, distribution_site_members.admin_user_id, admin_users.username, admin_users.nickname, distribution_site_members.member_type"). Joins("JOIN admin_users ON admin_users.id = distribution_site_members.admin_user_id"). Where("distribution_site_members.site_id IN ?", siteIDs). Scan(&memberRows).Error memberMap := make(map[uint64]memberRow, len(memberRows)) for _, m := range memberRows { if _, exists := memberMap[m.SiteID]; !exists { memberMap[m.SiteID] = m } } type countRow struct { SiteID uint64 `gorm:"column:distribution_site_id"` Cnt int64 `gorm:"column:cnt"` } var userCounts []countRow _ = r.db.WithContext(ctx).Table("users"). Select("distribution_site_id, COUNT(*) AS cnt"). Where("distribution_site_id IN ?", siteIDs). Group("distribution_site_id").Scan(&userCounts).Error userCountMap := make(map[uint64]int64, len(userCounts)) for _, c := range userCounts { userCountMap[c.SiteID] = c.Cnt } type orderCountRow struct { SiteID uint64 `gorm:"column:distribution_site_id"` Cnt int64 `gorm:"column:cnt"` } var orderUserCounts []orderCountRow _ = r.db.WithContext(ctx).Table("rental_orders"). Select("distribution_site_id, COUNT(DISTINCT renter_id) AS cnt"). Where("distribution_site_id IN ? AND status NOT IN ('cancelled', 'closed')", siteIDs). Group("distribution_site_id").Scan(&orderUserCounts).Error orderCountMap := make(map[uint64]int64, len(orderUserCounts)) for _, c := range orderUserCounts { orderCountMap[c.SiteID] = c.Cnt } for i := range items { if m, ok := memberMap[items[i].ID]; ok { items[i].AdminUserID = &m.AdminUserID items[i].AdminUsername = m.Username items[i].AdminNickname = m.Nickname items[i].MemberType = m.MemberType } items[i].UserCount = userCountMap[items[i].ID] items[i].OrderUserCount = orderCountMap[items[i].ID] } 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) var m struct { AdminUserID uint64 `gorm:"column:admin_user_id"` Username string `gorm:"column:username"` Nickname string `gorm:"column:nickname"` MemberType string `gorm:"column:member_type"` } if err := r.db.WithContext(ctx).Table("distribution_site_members"). Select("distribution_site_members.admin_user_id, admin_users.username, admin_users.nickname, distribution_site_members.member_type"). Joins("JOIN admin_users ON admin_users.id = distribution_site_members.admin_user_id"). Where("distribution_site_members.site_id = ?", row.ID). First(&m).Error; err == nil { item.AdminUserID = &m.AdminUserID item.AdminUsername = m.Username item.AdminNickname = m.Nickname item.MemberType = m.MemberType } return &item, nil } // resolveDefaultSettlementHoldDays 读取系统配置里的新建站点默认结算冷却天数,缺失或异常时回落到内置默认值。 func (r *Repository) resolveDefaultSettlementHoldDays(ctx context.Context) int { if r == nil || r.db == nil { return systemconfig.DefaultDistributionSettlementHoldDays } var row model.SystemConfig if err := r.db.WithContext(ctx).Where("`key` = ?", systemconfig.DistributionSettlementHoldDaysKey).First(&row).Error; err != nil { return systemconfig.DefaultDistributionSettlementHoldDays } return systemconfig.ParseDistributionSettlementHoldDays(row.Value) } // DefaultSettlementHoldDays 暴露当前有效的默认结算冷却天数,供前端表单使用。 func (r *Repository) DefaultSettlementHoldDays(ctx context.Context) int { return r.resolveDefaultSettlementHoldDays(ctx) } 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.ListingQrcodeURL != nil { row.ListingQrcodeURL = strings.TrimSpace(*req.ListingQrcodeURL) } if req.ReceiveRatioDelta != nil { delta, err := normalizeReceiveRatioDelta(*req.ReceiveRatioDelta) if err != nil { return nil, err } row.ReceiveRatioDelta = delta } if req.ReceiveCostDelta != nil { delta, err := normalizeReceiveCostDelta(*req.ReceiveCostDelta) if err != nil { return nil, err } row.ReceiveCostDelta = delta } if req.TaxEnabled != nil { row.TaxEnabled = *req.TaxEnabled } if req.TaxRateBPS != nil { row.TaxRateBPS = *req.TaxRateBPS } if req.SettlementHoldDays != nil { holdDays, err := normalizeSettlementHoldDays(*req.SettlementHoldDays) if err != nil { return nil, err } row.SettlementHoldDays = holdDays } else { row.SettlementHoldDays = r.resolveDefaultSettlementHoldDays(ctx) } // 模型上带 default 标签的布尔/整型字段(display_account_enabled、tax_enabled、 // settlement_hold_days)在零值(false/0)时会被 GORM 忽略并回落数据库默认值, // 因此显式传 false/0 的配置必须在插入后补写,否则会被静默改掉。 if err := r.db.WithContext(ctx).Create(&row).Error; err != nil { return nil, err } if explicitZeros := explicitZeroSiteColumns(req); len(explicitZeros) > 0 { if err := r.db.WithContext(ctx).Model(&model.DistributionSite{}).Where("id = ?", row.ID). Updates(explicitZeros).Error; err != nil { return nil, err } if err := r.db.WithContext(ctx).First(&row, row.ID).Error; err != nil { return nil, err } } if err := wallet.EnsureAccountTx(r.db.WithContext(ctx), 0, row.ID); 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 err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.First(&row, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrSiteNotFound } return err } if err := r.ensureSiteAccessWithDB(ctx, tx, adminID, row.ID); err != nil { return err } globalAccess := adminID == 0 if !globalAccess { global, accessErr := r.hasGlobalDistributionAccessWithDB(ctx, tx, adminID) if accessErr != nil { return accessErr } globalAccess = global } if req.ReceiveRatioDelta != nil { delta, err := normalizeReceiveRatioDelta(*req.ReceiveRatioDelta) if err != nil { return err } row.ReceiveRatioDelta = delta } if req.ReceiveCostDelta != nil { delta, err := normalizeReceiveCostDelta(*req.ReceiveCostDelta) if err != nil { return err } row.ReceiveCostDelta = delta } if !globalAccess { if err := tx.Save(&row).Error; err != nil { return err } return nil } if req.SiteName != nil { row.SiteName = strings.TrimSpace(*req.SiteName) } if req.Domain != nil { newDomain := strings.TrimSpace(*req.Domain) if newDomain != "" && newDomain != row.Domain { var count int64 if err := tx.Model(&model.DistributionSite{}).Where("domain = ? AND id != ?", newDomain, row.ID).Count(&count).Error; err != nil { return err } if count > 0 { return errors.New("专属域名已被其他分销站点占用") } row.Domain = newDomain } } if req.Status != nil { status := strings.TrimSpace(*req.Status) if status == "active" || status == "disabled" { row.Status = status } } if req.DisplayAccountEnabled != nil { row.DisplayAccountEnabled = *req.DisplayAccountEnabled } if req.DefaultSupportGroupID != nil { row.DefaultSupportGroupID = req.DefaultSupportGroupID } if req.ListingQrcodeURL != nil { row.ListingQrcodeURL = strings.TrimSpace(*req.ListingQrcodeURL) } if req.TaxEnabled != nil { row.TaxEnabled = *req.TaxEnabled } if req.TaxRateBPS != nil { row.TaxRateBPS = *req.TaxRateBPS } holdDaysChanged := false if req.SettlementHoldDays != nil { holdDays, err := normalizeSettlementHoldDays(*req.SettlementHoldDays) if err != nil { return err } holdDaysChanged = holdDays != row.SettlementHoldDays row.SettlementHoldDays = holdDays } if req.LogoURL != nil { row.LogoURL = strings.TrimSpace(*req.LogoURL) } if req.FaviconURL != nil { row.FaviconURL = strings.TrimSpace(*req.FaviconURL) } if req.BannersJSON != nil { row.BannersJSON = *req.BannersJSON } if req.AnnouncementsJSON != nil { row.AnnouncementsJSON = *req.AnnouncementsJSON } if req.FooterInfoJSON != nil { row.FooterInfoJSON = *req.FooterInfoJSON } if err := tx.Save(&row).Error; err != nil { return err } if holdDaysChanged { return rewritePendingReleaseAt(tx, row.ID) } return nil }) if err != nil { return nil, err } r.evictSiteInfoCache(ctx, &row) item := siteDTO(row) var m struct { AdminUserID uint64 `gorm:"column:admin_user_id"` Username string `gorm:"column:username"` Nickname string `gorm:"column:nickname"` MemberType string `gorm:"column:member_type"` } if err := r.db.WithContext(ctx).Table("distribution_site_members"). Select("distribution_site_members.admin_user_id, admin_users.username, admin_users.nickname, distribution_site_members.member_type"). Joins("JOIN admin_users ON admin_users.id = distribution_site_members.admin_user_id"). Where("distribution_site_members.site_id = ?", row.ID). First(&m).Error; err == nil { item.AdminUserID = &m.AdminUserID item.AdminUsername = m.Username item.AdminNickname = m.Nickname item.MemberType = m.MemberType } return &item, nil } // DeleteSite 删除分销站点。 // 若分销站点已产生业务订单、财务流水或结算记录,则拒绝物理删除,保护数据与审计链路; // 对于未产生业务的测试站点,将级联清理成员关联、专属分销商账号、规则快照、上架关系与钱包账户。 func (r *Repository) DeleteSite(ctx context.Context, adminID, siteID uint64) error { if r == nil || r.db == nil { return ErrDependencyUnavailable } global, err := r.hasGlobalDistributionAccess(ctx, adminID) if err != nil { return err } if !global && adminID != 0 { return ErrSiteAccessDenied } var site model.DistributionSite if err := r.db.WithContext(ctx).First(&site, siteID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrSiteNotFound } return err } // 1. 检查是否存在订单 var orderCount int64 if err := r.db.WithContext(ctx).Model(&model.RentalOrder{}). Where("distribution_site_id = ? OR markup_site_id = ?", siteID, siteID). Count(&orderCount).Error; err != nil { return err } if orderCount > 0 { return fmt.Errorf("分销站点【%s】已产生 %d 笔业务订单,为保障财务数据完整性无法删除;请将站点状态修改为「已停用」", site.SiteName, orderCount) } // 2. 检查是否存在财务快照流水 var financialCount int64 if err := r.db.WithContext(ctx).Model(&model.DistributionOrderFinancial{}). Where("site_id = ?", siteID). Count(&financialCount).Error; err != nil { return err } if financialCount > 0 { return fmt.Errorf("分销站点【%s】已存在分账财务流水,无法直接删除;请将站点状态修改为「已停用」", site.SiteName) } // 3. 检查是否存在结算批次 var settlementCount int64 if err := r.db.WithContext(ctx).Model(&model.DistributionSettlementBatch{}). Where("site_id = ?", siteID). Count(&settlementCount).Error; err != nil { return err } if settlementCount > 0 { return fmt.Errorf("分销站点【%s】已存在结算批次记录,无法直接删除", site.SiteName) } // 4. 检查钱包账户余额 var walletAccounts []model.WalletAccount if err := r.db.WithContext(ctx).Where("distribution_site_id = ?", siteID).Find(&walletAccounts).Error; err == nil { for _, wa := range walletAccounts { if wa.AvailableBalanceCent > 0 || wa.FrozenBalanceCent > 0 { return fmt.Errorf("分销站点【%s】钱包尚有未结算/未提现余额,无法删除", site.SiteName) } } } // 5. 执行事务级联清理 err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 查询关联管理员 var members []model.DistributionSiteMember if err := tx.Where("site_id = ?", siteID).Find(&members).Error; err != nil { return err } // 清理成员关系 if err := tx.Where("site_id = ?", siteID).Delete(&model.DistributionSiteMember{}).Error; err != nil { return err } // 若管理员用户仅绑定此分销站点且非系统超管,清理其账号与角色,便于用户名复用 for _, m := range members { var otherMemberCount int64 _ = tx.Model(&model.DistributionSiteMember{}).Where("admin_user_id = ?", m.AdminUserID).Count(&otherMemberCount).Error if otherMemberCount == 0 { var adminUser model.AdminUser if err := tx.First(&adminUser, m.AdminUserID).Error; err == nil { var superRoleCount int64 _ = tx.Table("admin_user_roles"). Joins("JOIN roles ON roles.id = admin_user_roles.role_id"). Where("admin_user_roles.admin_user_id = ? AND roles.code IN ('super_admin', 'admin')", m.AdminUserID). Count(&superRoleCount).Error if superRoleCount == 0 { _ = tx.Table("admin_user_roles").Where("admin_user_id = ?", m.AdminUserID).Delete(nil).Error _ = tx.Delete(&adminUser).Error } } } } // 清理规则版本 if err := tx.Where("site_id = ?", siteID).Delete(&model.DistributionRuleVersion{}).Error; err != nil { return err } // 清理上架货架 if err := tx.Where("site_id = ?", siteID).Delete(&model.DistributionSiteAccount{}).Error; err != nil { return err } // 清理支付通道绑定 if err := tx.Where("site_id = ?", siteID).Delete(&model.DistributionSitePaymentChannel{}).Error; err != nil { return err } // 清理分销钱包与钱包账户 _ = tx.Table("distribution_site_wallets").Where("site_id = ?", siteID).Delete(nil).Error _ = tx.Where("distribution_site_id = ?", siteID).Delete(&model.WalletAccount{}).Error // 删除站点主体 if err := tx.Delete(&site).Error; err != nil { return err } return nil }) if err != nil { return err } r.evictSiteInfoCache(ctx, &site) return nil } // ResetSiteAdminPassword 重置分销站点绑定的管理员密码。 func (r *Repository) ResetSiteAdminPassword(ctx context.Context, operatorAdminID, siteID uint64, newPassword string) error { if r == nil || r.db == nil { return ErrDependencyUnavailable } global, err := r.hasGlobalDistributionAccess(ctx, operatorAdminID) if err != nil { return err } if !global && operatorAdminID != 0 { return ErrSiteAccessDenied } newPassword = strings.TrimSpace(newPassword) if len(newPassword) < 6 { return errors.New("新密码长度不能少于6位") } var site model.DistributionSite if err := r.db.WithContext(ctx).First(&site, siteID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrSiteNotFound } return err } var members []model.DistributionSiteMember if err := r.db.WithContext(ctx).Where("site_id = ?", siteID).Find(&members).Error; err != nil { return err } if len(members) == 0 { return errors.New("该分销站点未绑定管理员账号") } hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { return err } adminUserIDs := make([]uint64, 0, len(members)) for _, m := range members { adminUserIDs = append(adminUserIDs, m.AdminUserID) } return r.db.WithContext(ctx).Model(&model.AdminUser{}). Where("id IN ?", adminUserIDs). Updates(map[string]interface{}{ "password_hash": string(hash), "token_version": gorm.Expr("token_version + 1"), }).Error } func (r *Repository) ListRules(ctx context.Context, adminID uint64, siteID *uint64, mode string) ([]RuleDTO, error) { db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}). Order("CASE WHEN site_id IS NULL THEN 0 ELSE 1 END ASC, site_id ASC, mode ASC, version DESC, id DESC") if siteID == nil { global, err := r.hasGlobalDistributionAccess(ctx, adminID) if err != nil { return nil, err } if !global { 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() } parsed := Rule{ 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), } normalizeRulePercents(&parsed) parsed.Mode = strings.TrimSpace(req.Mode) if err := ValidateRule(parsed); err != nil { return nil, err } row := model.DistributionRuleVersion{ SiteID: req.SiteID, Mode: strings.TrimSpace(req.Mode), Version: version, Status: "draft", PlatformReceiveRatio: parsed.PlatformReceiveRatio, PlatformSellRatio: parsed.PlatformSellRatio, DistributorReceiveRatio: parsed.DistributorReceiveRatio, DistributorSellRatio: parsed.DistributorSellRatio, DistributorARatio: parsed.DistributorARatio, DistributorBRatio: parsed.DistributorBRatio, ServiceFeeValue: parsed.ServiceFeeValue, ServiceFeeUnit: parsed.ServiceFeeUnit, PaymentFeeBPS: req.PaymentFeeBPS, TaxEnabled: req.TaxEnabled, TaxRateBPS: req.TaxRateBPS, EffectiveFrom: effectiveFrom, CreatedBy: adminRef(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, ListingQrcodeURL: row.ListingQrcodeURL, ReceiveRatioDelta: row.ReceiveRatioDelta, ReceiveCostDelta: row.ReceiveCostDelta, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, SettlementHoldDays: row.SettlementHoldDays, LogoURL: row.LogoURL, FaviconURL: row.FaviconURL, BannersJSON: row.BannersJSON, AnnouncementsJSON: row.AnnouncementsJSON, FooterInfoJSON: row.FooterInfoJSON, AWRecycleJSON: row.AWRecycleJSON, CooperationFeedbackJSON: row.CooperationFeedbackJSON, StorefrontTextJSON: row.StorefrontTextJSON, AnnouncementMode: normalizeAnnouncementModeOrInherit(row.AnnouncementMode), CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } } func (r *Repository) GetSiteSMSConfig(ctx context.Context, adminID, siteID uint64) (*SiteSMSConfigDTO, error) { cfg, err := r.GetDecryptedSiteSMSConfig(ctx, adminID, siteID) if err != nil { return nil, err } return cfg.ToDTO(), nil } func (r *Repository) GetDecryptedSiteSMSConfig(ctx context.Context, adminID, siteID uint64) (*SiteSMSConfig, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } var row model.DistributionSite if err := r.db.WithContext(ctx).First(&row, siteID).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 strings.TrimSpace(row.SMSConfigJSON) == "" { return &SiteSMSConfig{ Enabled: false, Provider: "aliyun", Endpoint: "dysmsapi.aliyuncs.com", }, nil } cfg, err := ParseSiteSMSConfig(row.SMSConfigJSON) if err != nil { return nil, err } plainSecret, err := r.decryptSecret(cfg.AccessKeySecret) if err != nil { return nil, err } cfg.AccessKeySecret = plainSecret return cfg, nil } func (r *Repository) UpdateSiteSMSConfig(ctx context.Context, adminID, siteID uint64, req UpdateSiteSMSConfigRequest) (*SiteSMSConfigDTO, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } var row model.DistributionSite if err := r.db.WithContext(ctx).First(&row, siteID).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 } cfg, err := ParseSiteSMSConfig(row.SMSConfigJSON) if err != nil { return nil, fmt.Errorf("解析现有短信配置失败: %w", err) } if cfg == nil { cfg = &SiteSMSConfig{} } existingSecret, err := r.decryptSecret(cfg.AccessKeySecret) if err != nil { return nil, err } if req.Enabled != nil { cfg.Enabled = *req.Enabled } if req.Provider != nil { cfg.Provider = strings.TrimSpace(*req.Provider) } if req.AccessKeyID != nil { cfg.AccessKeyID = strings.TrimSpace(*req.AccessKeyID) } if req.AccessKeySecret != nil && strings.TrimSpace(*req.AccessKeySecret) != "" { existingSecret = strings.TrimSpace(*req.AccessKeySecret) } if req.Endpoint != nil { cfg.Endpoint = strings.TrimSpace(*req.Endpoint) } if req.SignName != nil { cfg.SignName = strings.TrimSpace(*req.SignName) } if req.LoginTemplateCode != nil { cfg.LoginTemplateCode = strings.TrimSpace(*req.LoginTemplateCode) } // 完整性校验:必须属于合法 provider 白名单,且开启时必须提供完整凭据 providerType, err := r.validateSMSProvider(cfg.Provider) if err != nil { return nil, err } cfg.Provider = providerType if cfg.Enabled { if providerType == "aliyun" { if strings.TrimSpace(cfg.AccessKeyID) == "" || strings.TrimSpace(existingSecret) == "" || strings.TrimSpace(cfg.SignName) == "" || strings.TrimSpace(cfg.LoginTemplateCode) == "" { return nil, ErrSMSConfigInvalid } } } encSecret, err := r.encryptSecret(existingSecret) if err != nil { return nil, fmt.Errorf("加密短信密钥失败: %w", err) } cfg.AccessKeySecret = encSecret bytes, err := json.Marshal(cfg) if err != nil { return nil, err } row.SMSConfigJSON = string(bytes) if err := r.db.WithContext(ctx).Model(&row).Update("sms_config_json", row.SMSConfigJSON).Error; err != nil { return nil, err } r.evictSiteInfoCache(ctx, &row) dtoCfg := *cfg dtoCfg.AccessKeySecret = existingSecret return dtoCfg.ToDTO(), nil } 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("LEFT JOIN distribution_order_financials f ON f.order_id = ro.id"). Joins("LEFT JOIN rental_listings rl ON rl.id = ro.listing_id"). Joins("LEFT JOIN game_accounts ga ON ga.id = ro.account_id"). Joins("LEFT JOIN users u ON u.id = ro.renter_id") if query.SiteID > 0 { base = base.Where("(ro.distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND (p.site_id = ? OR p.party_id = ?)))", query.SiteID, query.SiteID, query.SiteID) } if query.Status != "" { base = base.Where("ro.status = ?", query.Status) } if mode := strings.TrimSpace(query.Mode); mode != "" { base = base.Where("ro.distribution_mode = ?", mode) } if financialStatus := strings.TrimSpace(query.FinancialStatus); financialStatus != "" { base = base.Where("f.settlement_status = ?", financialStatus) } if strings.TrimSpace(query.Keyword) != "" { kw := "%" + strings.TrimSpace(query.Keyword) + "%" base = base.Where("(ro.order_no LIKE ? OR rl.listing_no LIKE ? OR ga.title LIKE ? OR u.phone LIKE ?)", kw, kw, kw, kw) } switch strings.TrimSpace(query.Kind) { case "sold": if query.Role == "sell" { 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'))") } else if query.Role == "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'))") } else { 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','distributor_receive','distributor_a'))") } 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, COALESCE(f.gross_paid_amount_cent, 0) AS gross_paid_amount_cent, COALESCE(f.refund_amount_cent, 0) AS refund_amount_cent, COALESCE(f.net_paid_amount_cent, 0) AS net_paid_amount_cent, COALESCE(f.payment_fee_cent, 0) AS payment_fee_cent, COALESCE(f.service_fee_cent, 0) AS service_fee_cent, COALESCE(f.markup_amount_cent, 0) AS markup_amount_cent, COALESCE(f.tax_amount_cent, 0) AS tax_amount_cent, COALESCE(f.split_base_amount_cent, 0) AS split_base_amount_cent, COALESCE(f.rounding_adjustment_cent, 0) AS rounding_adjustment_cent, COALESCE(f.settlement_status, '') AS distributor_settlement_status, f.release_at AS release_at, ro.created_at, COALESCE(rl.listing_no, '') AS listing_no, COALESCE(ga.title, '') AS title, COALESCE(ga.game_name, '') AS game_name, COALESCE(ga.server_region, '') AS server_region, COALESCE(ga.login_platform, '') AS login_platform, ro.estimated_duration_hours AS rent_duration_hours, ro.rented_at AS rented_at, COALESCE(u.phone, '') AS renter_phone, COALESCE(u.nickname, '') AS renter_nickname, COALESCE(ro.owner_rent_amount_cent, 0) AS owner_rent_amount_cent, COALESCE(ro.base_price_cent, 0) AS base_price_cent, COALESCE(ro.shelf_markup_cent, 0) AS shelf_markup_cent, COALESCE(ro.deposit_amount_cent, 0) AS deposit_amount_cent ` if err := base.Select(selectSQL).Order("ro.id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error; err != nil { return nil, err } if len(items) > 0 { orderIDs := make([]uint64, len(items)) for i, it := range items { orderIDs[i] = it.OrderID } var parties []model.DistributionOrderParty pq := r.db.WithContext(ctx).Where("order_id IN ?", orderIDs) if query.SiteID > 0 { pq = pq.Where("(site_id = ? OR party_id = ?)", query.SiteID, query.SiteID) } if err := pq.Find(&parties).Error; err == nil { partiesByOrder := make(map[uint64][]model.DistributionOrderParty) for _, p := range parties { partiesByOrder[p.OrderID] = append(partiesByOrder[p.OrderID], p) } for i := range items { plist := partiesByOrder[items[i].OrderID] var totalEarning int64 var markupCent int64 var splitCent int64 var roles []string for _, p := range plist { if p.PartyType != PartyTypeSite { continue } if query.SiteID > 0 && p.PartyID > 0 && p.PartyID != query.SiteID { continue } totalEarning += p.IncomeAmountCent if p.PartyRole == "distributor_sell" || p.PartyRole == "distributor_b" { roles = append(roles, "sell") // 退款压缩加价后收入可能小于货架加价:加价部分取两者较小值, // 避免展示负的提价分成。 markup := items[i].ShelfMarkupCent if p.IncomeAmountCent < markup { markup = p.IncomeAmountCent } markupCent += markup splitCent += p.IncomeAmountCent - markup } else if p.PartyRole == "distributor_receive" || p.PartyRole == "distributor_a" { roles = append(roles, "receive") splitCent += p.IncomeAmountCent } } items[i].DistributorEarningCent = totalEarning items[i].DistributorMarkupCent = markupCent items[i].DistributorSplitCent = splitCent items[i].DistributorRole = strings.Join(roles, ",") } } } 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 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, COALESCE(f.gross_paid_amount_cent, 0) AS gross_paid_amount_cent, COALESCE(f.refund_amount_cent, 0) AS refund_amount_cent, COALESCE(f.net_paid_amount_cent, 0) AS net_paid_amount_cent, COALESCE(f.payment_fee_cent, 0) AS payment_fee_cent, COALESCE(f.service_fee_cent, 0) AS service_fee_cent, COALESCE(f.markup_amount_cent, 0) AS markup_amount_cent, COALESCE(f.tax_amount_cent, 0) AS tax_amount_cent, COALESCE(f.split_base_amount_cent, 0) AS split_base_amount_cent, COALESCE(f.rounding_adjustment_cent, 0) AS rounding_adjustment_cent, COALESCE(f.settlement_status, '') AS distributor_settlement_status, f.release_at AS release_at, ro.created_at, COALESCE(rl.listing_no, '') AS listing_no, COALESCE(ga.title, '') AS title, COALESCE(ga.game_name, '') AS game_name, COALESCE(ga.server_region, '') AS server_region, COALESCE(ga.login_platform, '') AS login_platform, ro.estimated_duration_hours AS rent_duration_hours, ro.rented_at AS rented_at, COALESCE(u.phone, '') AS renter_phone, COALESCE(u.nickname, '') AS renter_nickname, COALESCE(ro.owner_rent_amount_cent, 0) AS owner_rent_amount_cent, COALESCE(ro.base_price_cent, 0) AS base_price_cent, COALESCE(ro.shelf_markup_cent, 0) AS shelf_markup_cent, COALESCE(ro.deposit_amount_cent, 0) AS deposit_amount_cent ` err := r.db.WithContext(ctx).Table("rental_orders ro"). Joins("LEFT JOIN distribution_order_financials f ON f.order_id = ro.id"). Joins("LEFT JOIN rental_listings rl ON rl.id = ro.listing_id"). Joins("LEFT JOIN game_accounts ga ON ga.id = ro.account_id"). Joins("LEFT JOIN users u ON u.id = ro.renter_id"). Select(selectSQL).Where("ro.id = ?", id).Scan(&item).Error if err != nil { return nil, err } if item.OrderID == 0 { return nil, ErrOrderNotFound } var parties []model.DistributionOrderParty if err := r.db.WithContext(ctx).Where("order_id = ?", item.OrderID).Find(&parties).Error; err != nil { return nil, err } var siteSet map[uint64]struct{} if adminID != 0 { global, err := r.hasGlobalDistributionAccessWithDB(ctx, r.db, adminID) if err != nil { return nil, err } if !global { var adminSiteIDs []uint64 if err := r.db.WithContext(ctx).Model(&model.DistributionSiteMember{}). Where("admin_user_id = ? AND status = 'active'", adminID). Pluck("site_id", &adminSiteIDs).Error; err != nil { return nil, err } siteSet = make(map[uint64]struct{}, len(adminSiteIDs)) for _, sid := range adminSiteIDs { siteSet[sid] = struct{}{} } hasAccess := false if item.SiteID > 0 { if _, ok := siteSet[item.SiteID]; ok { hasAccess = true } } if !hasAccess { for _, p := range parties { if p.PartyType == PartyTypeSite { if p.SiteID > 0 { if _, ok := siteSet[p.SiteID]; ok { hasAccess = true break } } if p.PartyID > 0 { if _, ok := siteSet[p.PartyID]; ok { hasAccess = true break } } } } } if !hasAccess { return nil, ErrSiteAccessDenied } } } var totalEarning int64 var markupCent int64 var splitCent int64 var roles []string for _, p := range parties { if p.PartyType != PartyTypeSite { continue } if len(siteSet) > 0 { match := false if p.PartyType == PartyTypeSite && p.PartyID > 0 { if _, ok := siteSet[p.PartyID]; ok { match = true } } else if p.SiteID > 0 { if _, ok := siteSet[p.SiteID]; ok { match = true } } if !match { continue } } totalEarning += p.IncomeAmountCent if p.PartyRole == "distributor_sell" || p.PartyRole == "distributor_b" { roles = append(roles, "sell") // 退款压缩加价后收入可能小于货架加价:加价部分取两者较小值, // 避免展示负的提价分成。 markup := item.ShelfMarkupCent if p.IncomeAmountCent < markup { markup = p.IncomeAmountCent } markupCent += markup splitCent += p.IncomeAmountCent - markup } else if p.PartyRole == "distributor_receive" || p.PartyRole == "distributor_a" { roles = append(roles, "receive") splitCent += p.IncomeAmountCent } } item.DistributorEarningCent = totalEarning item.DistributorMarkupCent = markupCent item.DistributorSplitCent = splitCent item.DistributorRole = strings.Join(roles, ",") return &item, nil } func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) { type accountDisplayRow struct { ID uint64 `gorm:"column:id"` SiteID uint64 `gorm:"column:site_id"` AccountID uint64 `gorm:"column:account_id"` ListingID uint64 `gorm:"column:listing_id"` DisplayStatus string `gorm:"column:display_status"` MarkupAmountCent int64 `gorm:"column:markup_amount_cent"` ShelfRatioDelta float64 `gorm:"column:shelf_ratio_delta"` ListingNo string `gorm:"column:listing_no"` Title string `gorm:"column:title"` GameName string `gorm:"column:game_name"` ServerRegion string `gorm:"column:server_region"` LoginPlatform string `gorm:"column:login_platform"` RankLevel string `gorm:"column:rank_level"` HafCoinAmount int64 `gorm:"column:haf_coin_amount"` PriceCent int64 `gorm:"column:price_cent"` DepositAmountCent int64 `gorm:"column:deposit_amount_cent"` ListingStatus string `gorm:"column:listing_status"` InTransaction bool `gorm:"column:in_transaction"` ListingSiteID *uint64 `gorm:"column:listing_site_id"` SourceChannel string `gorm:"column:source_channel"` } var rows []accountDisplayRow query := r.db.WithContext(ctx). Table("distribution_site_accounts AS dsa"). Select(` dsa.id, dsa.site_id, dsa.account_id, dsa.listing_id, dsa.display_status, COALESCE(dsa.markup_amount_cent, 0) AS markup_amount_cent, COALESCE(dsa.shelf_ratio_delta, 0) AS shelf_ratio_delta, COALESCE(l.listing_no, '') AS listing_no, l.distribution_site_id AS listing_site_id, COALESCE(l.source_channel, '') AS source_channel, COALESCE(ga.title, '') AS title, COALESCE(ga.game_name, '') AS game_name, COALESCE(ga.server_region, '') AS server_region, COALESCE(ga.login_platform, '') AS login_platform, COALESCE(ga.rank_level, '') AS rank_level, COALESCE(ga.haf_coin_amount, 0) AS haf_coin_amount, COALESCE(l.price_cent, 0) AS price_cent, COALESCE(l.deposit_amount_cent, 0) AS deposit_amount_cent, COALESCE(l.status, '') AS listing_status, COALESCE(l.in_transaction, 0) AS in_transaction `). Joins("LEFT JOIN rental_listings AS l ON l.id = dsa.listing_id"). Joins("LEFT JOIN game_accounts AS ga ON ga.id = dsa.account_id"). Where("dsa.site_id = ?", siteID) if status != "" { query = query.Where("dsa.display_status = ?", status) } if err := query.Order("dsa.id DESC").Scan(&rows).Error; err != nil { return nil, err } items := make([]AccountDisplayDTO, 0, len(rows)) for _, row := range rows { sitePrice, baseRatio, siteRatio := listingpkg.ApplyShelfRatio(row.HafCoinAmount, row.PriceCent, row.ShelfRatioDelta) items = append(items, AccountDisplayDTO{ ID: row.ID, SiteID: row.SiteID, AccountID: row.AccountID, ListingID: row.ListingID, DisplayStatus: row.DisplayStatus, MarkupAmountCent: sitePrice - row.PriceCent, ShelfRatioDelta: row.ShelfRatioDelta, BaseRatio: baseRatio, SiteRatio: siteRatio, ListingNo: row.ListingNo, Title: row.Title, GameName: row.GameName, ServerRegion: row.ServerRegion, LoginPlatform: row.LoginPlatform, RankLevel: row.RankLevel, HafCoinAmount: row.HafCoinAmount, PriceCent: row.PriceCent, SitePriceCent: sitePrice, DepositAmountCent: row.DepositAmountCent, ListingStatus: row.ListingStatus, InTransaction: row.InTransaction, ListingSiteID: row.ListingSiteID, SourceChannel: row.SourceChannel, }) } return items, nil } func (r *Repository) ListAccountDisplaysPaged(ctx context.Context, siteID uint64, q AccountDisplayQuery) (*AccountDisplayPageDTO, error) { page := q.Page if page <= 0 { page = 1 } pageSize := q.PageSize if pageSize <= 0 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } baseQuery := r.db.WithContext(ctx). Table("distribution_site_accounts AS dsa"). Joins("LEFT JOIN rental_listings AS l ON l.id = dsa.listing_id"). Joins("LEFT JOIN game_accounts AS ga ON ga.id = dsa.account_id"). Where("dsa.site_id = ?", siteID) if q.Status != "" { baseQuery = baseQuery.Where("dsa.display_status = ?", q.Status) } keyword := strings.TrimSpace(q.Keyword) if keyword != "" { baseQuery = baseQuery.Where("(ga.title LIKE ? OR l.listing_no LIKE ? OR ga.rank_level LIKE ?)", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") } var total int64 if err := baseQuery.Count(&total).Error; err != nil { return nil, err } type accountDisplayRow struct { ID uint64 `gorm:"column:id"` SiteID uint64 `gorm:"column:site_id"` AccountID uint64 `gorm:"column:account_id"` ListingID uint64 `gorm:"column:listing_id"` DisplayStatus string `gorm:"column:display_status"` MarkupAmountCent int64 `gorm:"column:markup_amount_cent"` ShelfRatioDelta float64 `gorm:"column:shelf_ratio_delta"` ListingNo string `gorm:"column:listing_no"` Title string `gorm:"column:title"` GameName string `gorm:"column:game_name"` ServerRegion string `gorm:"column:server_region"` LoginPlatform string `gorm:"column:login_platform"` RankLevel string `gorm:"column:rank_level"` HafCoinAmount int64 `gorm:"column:haf_coin_amount"` PriceCent int64 `gorm:"column:price_cent"` DepositAmountCent int64 `gorm:"column:deposit_amount_cent"` ListingStatus string `gorm:"column:listing_status"` InTransaction bool `gorm:"column:in_transaction"` ListingSiteID *uint64 `gorm:"column:listing_site_id"` SourceChannel string `gorm:"column:source_channel"` } var rows []accountDisplayRow err := baseQuery.Select(` dsa.id, dsa.site_id, dsa.account_id, dsa.listing_id, dsa.display_status, COALESCE(dsa.markup_amount_cent, 0) AS markup_amount_cent, COALESCE(dsa.shelf_ratio_delta, 0) AS shelf_ratio_delta, COALESCE(l.listing_no, '') AS listing_no, l.distribution_site_id AS listing_site_id, COALESCE(l.source_channel, '') AS source_channel, COALESCE(ga.title, '') AS title, COALESCE(ga.game_name, '') AS game_name, COALESCE(ga.server_region, '') AS server_region, COALESCE(ga.login_platform, '') AS login_platform, COALESCE(ga.rank_level, '') AS rank_level, COALESCE(ga.haf_coin_amount, 0) AS haf_coin_amount, COALESCE(l.price_cent, 0) AS price_cent, COALESCE(l.deposit_amount_cent, 0) AS deposit_amount_cent, COALESCE(l.status, '') AS listing_status, COALESCE(l.in_transaction, 0) AS in_transaction `). Order("dsa.id DESC"). Offset((page - 1) * pageSize). Limit(pageSize). Scan(&rows).Error if err != nil { return nil, err } items := make([]AccountDisplayDTO, 0, len(rows)) for _, row := range rows { sitePrice, baseRatio, siteRatio := listingpkg.ApplyShelfRatio(row.HafCoinAmount, row.PriceCent, row.ShelfRatioDelta) items = append(items, AccountDisplayDTO{ ID: row.ID, SiteID: row.SiteID, AccountID: row.AccountID, ListingID: row.ListingID, DisplayStatus: row.DisplayStatus, MarkupAmountCent: sitePrice - row.PriceCent, ShelfRatioDelta: row.ShelfRatioDelta, BaseRatio: baseRatio, SiteRatio: siteRatio, ListingNo: row.ListingNo, Title: row.Title, GameName: row.GameName, ServerRegion: row.ServerRegion, LoginPlatform: row.LoginPlatform, RankLevel: row.RankLevel, HafCoinAmount: row.HafCoinAmount, PriceCent: row.PriceCent, SitePriceCent: sitePrice, DepositAmountCent: row.DepositAmountCent, ListingStatus: row.ListingStatus, InTransaction: row.InTransaction, ListingSiteID: row.ListingSiteID, SourceChannel: row.SourceChannel, }) } return &AccountDisplayPageDTO{ Items: items, Total: total, Page: page, PageSize: pageSize, }, 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) row.DisplayDisabledBy = displayDisabledBy(row.DisplayStatus) 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, } var listing model.RentalListing if err := r.db.WithContext(ctx).First(&listing, row.ListingID).Error; err == nil { item.ListingNo = listing.ListingNo item.PriceCent = listing.PriceCent item.DepositAmountCent = listing.DepositAmountCent item.ListingStatus = listing.Status item.InTransaction = listing.InTransaction } var account model.GameAccount if err := r.db.WithContext(ctx).First(&account, row.AccountID).Error; err == nil { item.Title = account.Title item.GameName = account.GameName item.ServerRegion = account.ServerRegion item.LoginPlatform = account.LoginPlatform item.RankLevel = account.RankLevel item.HafCoinAmount = account.HafCoinAmount } return item, nil } func uniquePositiveIDs(ids []uint64) []uint64 { if len(ids) == 0 { return nil } seen := make(map[uint64]struct{}, len(ids)) out := make([]uint64, 0, len(ids)) for _, id := range ids { if id == 0 { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } return out } func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint64, req SyncSiteAccountsRequest) (*SyncSiteAccountsResult, error) { if r.db == nil { return nil, ErrDependencyUnavailable } if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { return nil, err } var site model.DistributionSite if err := r.db.WithContext(ctx).First(&site, siteID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrSiteNotFound } return nil, err } if req.DefaultShelfRatioDelta < 0 { return nil, ErrInvalidAmount } // 1. 防重复并发锁(Redis 分布式锁 + 内存 sync.Map 双重互斥保障) lockKey := fmt.Sprintf("dist:sync_accounts:%d", siteID) if r.redis != nil { ok, err := r.redis.SetNX(ctx, lockKey, 1, 30*time.Second).Result() if err == nil { if !ok { return nil, ErrSyncInProgress } defer func() { _ = r.redis.Del(context.Background(), lockKey).Err() }() } } _, loaded := r.siteSyncLocks.LoadOrStore(siteID, struct{}{}) if loaded { return nil, ErrSyncInProgress } defer r.siteSyncLocks.Delete(siteID) selectedIDs := uniquePositiveIDs(req.ListingIDs) if len(selectedIDs) > maxSyncListingIDs { return nil, ErrTooManySyncListings } var candidates []model.RentalListing if len(selectedIDs) > 0 { var selected []model.RentalListing if err := r.db.WithContext(ctx).Where("id IN ?", selectedIDs).Find(&selected).Error; err != nil { return nil, err } if len(selected) != len(selectedIDs) { return nil, ErrSyncListingNotFound } candidates = selected } else { // 未显式指定 listingIDs 时,默认将主站自营及全网审核通过的在售商品全部同步至本分站货架 if err := r.db.WithContext(ctx). Where("status = ? AND review_status = ?", "published", "approved"). Order("id ASC"). Find(&candidates).Error; err != nil { return nil, err } } var existingAccounts []model.DistributionSiteAccount if err := r.db.WithContext(ctx). Where("site_id = ?", siteID). Find(&existingAccounts).Error; err != nil { return nil, err } existingMap := make(map[uint64]model.DistributionSiteAccount, len(existingAccounts)) for _, ea := range existingAccounts { existingMap[ea.AccountID] = ea } var toInsert []model.DistributionSiteAccount seenAccount := make(map[uint64]bool) var skippedCount int64 var updatedCount int64 var reenabledCount int64 for _, l := range candidates { if seenAccount[l.AccountID] { continue } seenAccount[l.AccountID] = true if ea, exists := existingMap[l.AccountID]; exists { updates := map[string]any{} if ea.ListingID != l.ID { updates["listing_id"] = l.ID updatedCount++ } // 系统下架(或历史无来源标记)的行随铺货恢复展示;站长人工下架的行保持不动。 if ea.DisplayStatus != "enabled" && shelfDisabledBySystem(ea.DisplayDisabledBy) { updates["display_status"] = "enabled" updates["display_disabled_by"] = "" reenabledCount++ } if len(updates) > 0 { if err := r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}). Where("id = ?", ea.ID).Updates(updates).Error; err != nil { return nil, err } } skippedCount++ } else { toInsert = append(toInsert, model.DistributionSiteAccount{ SiteID: siteID, AccountID: l.AccountID, ListingID: l.ID, DisplayStatus: "enabled", ShelfRatioDelta: req.DefaultShelfRatioDelta, }) } } // 2. 分批高效入库,防止大数据量超长 SQL 或锁表超时 if len(toInsert) > 0 { if err := r.db.WithContext(ctx).CreateInBatches(&toInsert, 500).Error; err != nil { return nil, err } } // 3. 联动下架货源已被删除的货架商品(若开启 CleanStaleListings)。 // // 这里只处理「商品记录本身已不存在」的孤儿货架行。已下架、已出租、待审核等 // 只是暂时不可售的商品必须保留展示状态:分站前台本来就要求 published+approved // 才展示,而一旦把它们的货架行置为 disabled,商品重新上架后就再也不会出现在分站。 var disabledStaleCount int64 if req.CleanStaleListings { var staleIDs []uint64 err := r.db.WithContext(ctx).Table("distribution_site_accounts AS dsa"). Joins("LEFT JOIN rental_listings AS l ON l.id = dsa.listing_id"). Where("dsa.site_id = ? AND dsa.display_status = 'enabled' AND l.id IS NULL", siteID). Pluck("dsa.id", &staleIDs).Error if err == nil && len(staleIDs) > 0 { for i := 0; i < len(staleIDs); i += 500 { end := i + 500 if end > len(staleIDs) { end = len(staleIDs) } _ = r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}). Where("id IN ?", staleIDs[i:end]). Updates(map[string]any{ "display_status": "disabled", "display_disabled_by": displayDisabledBySystem, }).Error } disabledStaleCount = int64(len(staleIDs)) } } return &SyncSiteAccountsResult{ SyncedCount: int64(len(toInsert)), SkippedCount: skippedCount, UpdatedCount: updatedCount, ReenabledCount: reenabledCount, DisabledStaleCount: disabledStaleCount, TotalActive: int64(len(candidates)), }, 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: adminRef(adminID)} if err := tx.Create(&batch).Error; err != nil { return err } // 与自动释放路径串行化:先按周期锁定待结算财务快照,再选参与方, // 避免同一订单同时被手工批次与自动释放纳入明细、重复入账。 // 分两步(先查 ID 再 FOR UPDATE)而不是 JOIN + FOR UPDATE,避免顺带锁住 rental_orders 行。 var pendingOrderIDs []uint64 if err := tx.Table("distribution_order_financials f"). Select("f.order_id"). Joins("JOIN rental_orders ro ON ro.id = f.order_id"). Where("f.settlement_status = 'pending'"). Where("ro.status IN ?", settlementReadyOrderStatuses). Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses). Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses). Where("f.created_at >= ? AND f.created_at < ?", req.PeriodStart, req.PeriodEnd). Order("f.order_id ASC"). Scan(&pendingOrderIDs).Error; err != nil { return err } if len(pendingOrderIDs) > 0 { var lockedFinancials []model.DistributionOrderFinancial if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("order_id IN ?", pendingOrderIDs). Order("id ASC").Find(&lockedFinancials).Error; err != nil { return err } } // 只收本站 site party。平台份不进批次,三方订单按 party 分别结算。 // 仅纳入已完结且未处于退款中/维权中的订单,与自动释放口径对齐。 var parties []model.DistributionOrderParty if err := tx.Where("party_type = ? AND party_id = ?", PartyTypeSite, req.SiteID). Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = distribution_order_parties.id)"). Where("order_id IN (?)", tx.Table("distribution_order_financials f"). Select("f.order_id"). Joins("JOIN rental_orders ro ON ro.id = f.order_id"). Where("f.settlement_status = 'pending'"). Where("ro.status IN ?", settlementReadyOrderStatuses). Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses). Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses). Where("f.created_at >= ? AND f.created_at < ?", req.PeriodStart, req.PeriodEnd), ). Order("id ASC"). Find(&parties).Error; err != nil { return err } total := int64(0) items := make([]model.DistributionSettlementItem, 0, len(parties)) for _, party := range parties { items = append(items, model.DistributionSettlementItem{ BatchID: batch.ID, OrderID: party.OrderID, PartyID: party.ID, AmountCent: party.IncomeAmountCent, Status: "pending", }) total += party.IncomeAmountCent } if len(items) > 0 { if err := tx.CreateInBatches(&items, 500).Error; err != nil { return err } } 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.ensureSiteAccessWithDB(ctx, tx, adminID, row.SiteID); err != nil { return err } if row.Status == "settled" { return ErrBatchAlreadySettled } if row.Status == "cancelled" { return ErrBatchNotCancellable } 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 } // 复核批次内每个参与方与订单的最新实时状态,确保未发生退款变更、无进行中退款/维权: orderIDs := uniqueSettlementOrderIDs(items) var lockedFinancials []model.DistributionOrderFinancial if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("order_id IN ?", orderIDs). Order("id ASC").Find(&lockedFinancials).Error; err != nil { return err } var orders []model.RentalOrder if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id IN ?", orderIDs). Order("id ASC").Find(&orders).Error; err != nil { return err } if len(orders) != len(orderIDs) { return ErrSettlementBatchStale } orderMap := make(map[uint64]model.RentalOrder, len(orders)) for _, o := range orders { orderMap[o.ID] = o } var disputedCount int64 if err := tx.Table("disputes"). Where("order_id IN ? AND status IN ?", orderIDs, autoReleaseBlockedDisputeStatuses). Count(&disputedCount).Error; err != nil { return err } if disputedCount > 0 { return ErrSettlementBatchStale } partyIDs := make([]uint64, 0, len(items)) for _, it := range items { partyIDs = append(partyIDs, it.PartyID) } var parties []model.DistributionOrderParty if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id IN ?", partyIDs). Order("id ASC").Find(&parties).Error; err != nil { return err } if len(parties) != len(partyIDs) { return ErrSettlementBatchStale } partyMap := make(map[uint64]model.DistributionOrderParty, len(parties)) for _, p := range parties { partyMap[p.ID] = p } for _, item := range items { order, ok := orderMap[item.OrderID] if !ok || !orderStatusAllowsRelease(order.Status) || slices.Contains(autoReleaseBlockedRefundStatuses, order.RefundStatus) { return ErrSettlementBatchStale } party, ok := partyMap[item.PartyID] if !ok || party.IncomeAmountCent != item.AmountCent { return ErrSettlementBatchStale } } now := time.Now() if err := tx.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", row.ID). Update("status", "settled").Error; err != nil { return err } if err := markFinancialsSettledIfSitePartiesDone(tx, orderIDs, now); err != nil { return err } if err := wallet.AppendEntries(tx, wallet.Entry{ UserID: 0, DistributionSiteID: row.SiteID, Direction: "in", AmountCent: row.TotalAmountCent, BalanceType: "available", BizType: "distribution_settlement", BizNo: row.BatchNo, Remark: fmt.Sprintf("分销结算批次入账 %s", row.BatchNo), }); 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) CancelSettlementBatch(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.ensureSiteAccessWithDB(ctx, tx, adminID, row.SiteID); err != nil { return err } if row.Status == "settled" { return ErrBatchAlreadySettled } if row.Status == "cancelled" { return ErrBatchNotCancellable } if err := tx.Where("batch_id = ?", row.ID).Delete(&model.DistributionSettlementItem{}).Error; err != nil { return err } row.Status = "cancelled" row.TotalAmountCent = 0 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 uniqueSettlementOrderIDs(items []model.DistributionSettlementItem) []uint64 { seen := make(map[uint64]struct{}, len(items)) ids := make([]uint64, 0, len(items)) for _, item := range items { if _, ok := seen[item.OrderID]; ok { continue } seen[item.OrderID] = struct{}{} ids = append(ids, item.OrderID) } return ids } func markFinancialsSettledIfSitePartiesDone(tx *gorm.DB, orderIDs []uint64, now time.Time) error { for _, orderID := range orderIDs { var remaining int64 if err := tx.Model(&model.DistributionOrderParty{}). Where("order_id = ? AND party_type = ?", orderID, PartyTypeSite). Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = distribution_order_parties.id AND i.status = 'settled')"). Count(&remaining).Error; err != nil { return err } if remaining > 0 { continue } if err := tx.Model(&model.DistributionOrderFinancial{}). Where("order_id = ?", orderID). Updates(map[string]any{"settlement_status": "settled", "settled_at": now, "released_at": now}).Error; err != nil { return err } } return 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 order model.RentalOrder if err := r.db.WithContext(ctx).First(&order, orderID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrOrderNotFound } return nil, err } // 手工补数只允许创建,不允许覆盖支付事务写入的真实快照。 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}) } parsed := ruleFromVersion(rule) result, err := CalculateSettlement(parsed, SettlementInput{ GrossPaidAmountCent: req.GrossPaidAmountCent, RefundAmountCent: req.RefundAmountCent, OwnerRentAmountCent: order.OwnerRentAmountCent, MarkupAmountCent: req.MarkupAmountCent, GrossProfitByParty: partyInputs, }) if err != nil { return nil, err } err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { splits := SplitSettlement(parsed, result.SplitBaseAmountCent, result.MarkupAmountCent) 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: parsed.ServiceFeeValue, ServiceFeeUnit: parsed.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: result.MarkupAmountCent, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRoundingCent(result.SplitBaseAmountCent, result.ServiceFeeCent, result.MarkupAmountCent, splits), 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 } global, err := r.hasGlobalDistributionAccess(ctx, admin.ID) if err != nil { return nil, nil, err } dto := DistributionCurrentMemberDTO{ AdminID: admin.ID, Username: admin.Username, Nickname: admin.Nickname, IsSuperadmin: global, } 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 global { 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 } global, err := r.hasGlobalDistributionAccess(ctx, adminID) if err != nil { return nil, err } dto := DistributionCurrentMemberDTO{ AdminID: admin.ID, Username: admin.Username, Nickname: admin.Nickname, IsSuperadmin: global, } 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 } holdDays := r.resolveDefaultSettlementHoldDays(ctx) if req.SettlementHoldDays != nil { normalized, err := normalizeSettlementHoldDays(*req.SettlementHoldDays) if err != nil { return err } holdDays = normalized } site := model.DistributionSite{ SiteCode: req.SiteCode, SiteName: req.SiteName, Domain: req.Domain, Status: "active", DisplayAccountEnabled: displayEnabled, TaxEnabled: taxEnabled, TaxRateBPS: taxRate, SettlementHoldDays: holdDays, } 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 } now := time.Now() receiveRule, err := createOnboardDefaultRules(tx, site.ID, taxEnabled, taxRate, now) if err != nil { return err } if err := wallet.EnsureAccountTx(tx, 0, site.ID); err != nil { return err } resp = OnboardSiteResponse{ Site: siteDTO(site), Rule: ruleDTO(receiveRule), 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 } // createOnboardDefaultRules 开通时四种模式都按默认分成直接生效,之后在规则页改即可。 func createOnboardDefaultRules(tx *gorm.DB, siteID uint64, taxEnabled bool, taxRate int64, effectiveFrom time.Time) (model.DistributionRuleVersion, error) { var receive model.DistributionRuleVersion modes := []string{ModeTwoPartyDistributorReceive, ModeTwoPartyPlatformReceive, ModeThreeParty, ModeSiteSelfSell} for _, mode := range modes { def, err := DefaultRule(mode) if err != nil { return model.DistributionRuleVersion{}, err } row := model.DistributionRuleVersion{ SiteID: &siteID, Mode: mode, Version: 1, Status: "active", PlatformReceiveRatio: def.PlatformReceiveRatio, PlatformSellRatio: def.PlatformSellRatio, DistributorReceiveRatio: def.DistributorReceiveRatio, DistributorSellRatio: def.DistributorSellRatio, DistributorARatio: def.DistributorARatio, DistributorBRatio: def.DistributorBRatio, ServiceFeeValue: def.ServiceFeeValue, ServiceFeeUnit: def.ServiceFeeUnit, PaymentFeeBPS: def.PaymentFeeBPS, TaxEnabled: taxEnabled, TaxRateBPS: taxRate, EffectiveFrom: effectiveFrom, } if err := tx.Create(&row).Error; err != nil { return model.DistributionRuleVersion{}, err } if mode == ModeTwoPartyDistributorReceive { receive = row } } return receive, nil } func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string, page, pageSize int) (*PublicDistributionListingsResult, error) { if r.db == nil { return nil, ErrDependencyUnavailable } if strings.TrimSpace(siteCode) == "" { return nil, ErrSiteNotFound } if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } var site model.DistributionSite if err := r.db.WithContext(ctx).Where("site_code = ? AND status = 'active'", siteCode).First(&site).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrSiteNotFound } return nil, err } if !site.DisplayAccountEnabled { return &PublicDistributionListingsResult{ Items: []PublicDistributionListingDTO{}, Total: 0, Page: page, PageSize: pageSize, }, nil } type publicListingRow struct { ListingID uint64 `gorm:"column:listing_id"` ListingNo string `gorm:"column:listing_no"` AccountID uint64 `gorm:"column:account_id"` Title string `gorm:"column:title"` GameName string `gorm:"column:game_name"` ServerRegion string `gorm:"column:server_region"` LoginPlatform string `gorm:"column:login_platform"` RankLevel string `gorm:"column:rank_level"` HafCoinAmount int64 `gorm:"column:haf_coin_amount"` PriceCent int64 `gorm:"column:price_cent"` DepositAmountCent int64 `gorm:"column:deposit_amount_cent"` ShelfRatioDelta float64 `gorm:"column:shelf_ratio_delta"` InTransaction bool `gorm:"column:in_transaction"` } baseQuery := r.db.WithContext(ctx). Table("distribution_site_accounts AS dsa"). Joins("INNER JOIN rental_listings AS l ON l.id = dsa.listing_id AND l.status = 'published' AND l.review_status = 'approved'"). Joins("INNER JOIN game_accounts AS ga ON ga.id = dsa.account_id"). Where("dsa.site_id = ? AND dsa.display_status = 'enabled'", site.ID) var total int64 if err := baseQuery.Count(&total).Error; err != nil { return nil, err } var rows []publicListingRow err := baseQuery. Select(` dsa.listing_id, l.listing_no, dsa.account_id, COALESCE(ga.title, '') AS title, COALESCE(ga.game_name, '') AS game_name, COALESCE(ga.server_region, '') AS server_region, COALESCE(ga.login_platform, '') AS login_platform, COALESCE(ga.rank_level, '') AS rank_level, COALESCE(ga.haf_coin_amount, 0) AS haf_coin_amount, COALESCE(l.price_cent, 0) AS price_cent, COALESCE(l.deposit_amount_cent, 0) AS deposit_amount_cent, COALESCE(dsa.shelf_ratio_delta, 0) AS shelf_ratio_delta, COALESCE(l.in_transaction, 0) AS in_transaction `). Order("dsa.id DESC"). Offset((page - 1) * pageSize). Limit(pageSize). Scan(&rows).Error if err != nil { return nil, err } items := make([]PublicDistributionListingDTO, 0, len(rows)) for _, row := range rows { sitePrice, _, _ := listingpkg.ApplyShelfRatio(row.HafCoinAmount, row.PriceCent, row.ShelfRatioDelta) items = append(items, PublicDistributionListingDTO{ ListingID: row.ListingID, ListingNo: row.ListingNo, AccountID: row.AccountID, Title: row.Title, GameName: row.GameName, ServerRegion: row.ServerRegion, LoginPlatform: row.LoginPlatform, RankLevel: row.RankLevel, HafCoinAmount: row.HafCoinAmount, PriceCent: sitePrice, DepositAmountCent: row.DepositAmountCent, MarkupAmountCent: sitePrice - row.PriceCent, ShelfRatioDelta: row.ShelfRatioDelta, InTransaction: row.InTransaction, SiteCode: site.SiteCode, SiteName: site.SiteName, }) } return &PublicDistributionListingsResult{ Items: items, Total: total, Page: page, PageSize: pageSize, }, nil } func (r *Repository) evictSiteInfoCache(ctx context.Context, site *model.DistributionSite) { if r.redis == nil || site == nil { return } keys := make([]string, 0, 2) if site.SiteCode != "" { keys = append(keys, fmt.Sprintf("dist:site_info:code:%s", site.SiteCode)) } if site.Domain != "" { keys = append(keys, fmt.Sprintf("dist:site_info:domain:%s", site.Domain)) } if len(keys) > 0 { _ = r.redis.Del(ctx, keys...).Err() } } func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string) (*PublicSiteInfoDTO, error) { if r.db == nil { return nil, ErrDependencyUnavailable } code = strings.TrimSpace(code) domain = strings.TrimSpace(domain) if code == "" && domain == "" { return nil, ErrSiteNotFound } var cacheKey string if code != "" { cacheKey = fmt.Sprintf("dist:site_info:code:%s", code) } else if domain != "" { cacheKey = fmt.Sprintf("dist:site_info:domain:%s", domain) } if cacheKey != "" && r.redis != nil { val, err := r.redis.Get(ctx, cacheKey).Result() if err == nil && val != "" { var cached PublicSiteInfoDTO if jsonErr := json.Unmarshal([]byte(val), &cached); jsonErr == nil { return &cached, nil } } } var site model.DistributionSite query := r.db.WithContext(ctx).Where("status = 'active'") if code != "" { query = query.Where("site_code = ?", code) } else { query = query.Where("domain = ?", domain) } if err := query.First(&site).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrSiteNotFound } return nil, err } res := &PublicSiteInfoDTO{ SiteCode: site.SiteCode, SiteName: site.SiteName, Domain: site.Domain, Status: site.Status, DisplayAccountEnabled: site.DisplayAccountEnabled, DefaultSupportGroupID: site.DefaultSupportGroupID, LogoURL: site.LogoURL, FaviconURL: site.FaviconURL, BannersJSON: site.BannersJSON, AnnouncementsJSON: site.AnnouncementsJSON, FooterInfoJSON: site.FooterInfoJSON, StorefrontTextJSON: site.StorefrontTextJSON, ReceiveRatioDelta: site.ReceiveRatioDelta, ReceiveCostDelta: site.ReceiveCostDelta, } if r.redis != nil { if bytes, err := json.Marshal(res); err == nil { if site.SiteCode != "" { _ = r.redis.Set(ctx, fmt.Sprintf("dist:site_info:code:%s", site.SiteCode), string(bytes), 10*time.Minute).Err() } if site.Domain != "" { _ = r.redis.Set(ctx, fmt.Sprintf("dist:site_info:domain:%s", site.Domain), string(bytes), 10*time.Minute).Err() } } } return res, nil } func generateWithdrawNo() (string, error) { buf := make([]byte, 8) if _, err := rand.Read(buf); err != nil { return "", err } return fmt.Sprintf("DW%d%s", time.Now().Unix(), hex.EncodeToString(buf)[:8]), nil } func maskAccountNo(accountNo, accountType string) string { length := len(accountNo) if length <= 4 { return accountNo } switch accountType { case "alipay", "wechat": if length == 11 { return accountNo[:3] + "****" + accountNo[7:] } return accountNo[:2] + "****" + accountNo[length-2:] case "bank": if length > 8 { return accountNo[:4] + "****" + accountNo[length-4:] } return accountNo[:2] + "****" + accountNo[length-2:] } return accountNo } func (r *Repository) UpdateSiteBranding(ctx context.Context, adminID, siteID uint64, req UpdateSiteBrandingRequest) (*SiteDTO, error) { if siteID == 0 { return nil, ErrSiteNotFound } if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { return nil, err } var site model.DistributionSite if err := r.db.WithContext(ctx).First(&site, siteID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrSiteNotFound } return nil, err } updates := map[string]any{} if req.LogoURL != nil { site.LogoURL = strings.TrimSpace(*req.LogoURL) updates["logo_url"] = site.LogoURL } if req.FaviconURL != nil { site.FaviconURL = strings.TrimSpace(*req.FaviconURL) updates["favicon_url"] = site.FaviconURL } if req.BannersJSON != nil { site.BannersJSON = strings.TrimSpace(*req.BannersJSON) updates["banners_json"] = site.BannersJSON } if req.AnnouncementsJSON != nil { site.AnnouncementsJSON = strings.TrimSpace(*req.AnnouncementsJSON) updates["announcements_json"] = site.AnnouncementsJSON } if req.FooterInfoJSON != nil { site.FooterInfoJSON = strings.TrimSpace(*req.FooterInfoJSON) updates["footer_info_json"] = site.FooterInfoJSON } if req.AWRecycleJSON != nil { site.AWRecycleJSON = strings.TrimSpace(*req.AWRecycleJSON) updates["aw_recycle_json"] = site.AWRecycleJSON } if req.CooperationFeedbackJSON != nil { site.CooperationFeedbackJSON = strings.TrimSpace(*req.CooperationFeedbackJSON) updates["cooperation_feedback_json"] = site.CooperationFeedbackJSON } if req.StorefrontTextJSON != nil { site.StorefrontTextJSON = strings.TrimSpace(*req.StorefrontTextJSON) updates["storefront_text_json"] = site.StorefrontTextJSON } if req.AnnouncementMode != nil { mode, err := normalizeAnnouncementMode(*req.AnnouncementMode) if err != nil { return nil, err } site.AnnouncementMode = mode updates["announcement_mode"] = mode } if req.ListingQrcodeURL != nil { site.ListingQrcodeURL = strings.TrimSpace(*req.ListingQrcodeURL) updates["listing_qrcode_url"] = site.ListingQrcodeURL } if len(updates) > 0 { if err := r.db.WithContext(ctx).Model(&site).Updates(updates).Error; err != nil { return nil, err } } r.evictSiteInfoCache(ctx, &site) dto := siteDTO(site) return &dto, nil } func (r *Repository) UpdateAccountMarkup(ctx context.Context, adminID, id uint64, ratioDelta float64) (*AccountDisplayDTO, error) { if ratioDelta < 0 { return nil, ErrInvalidAmount } 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 } if err := r.db.WithContext(ctx).Model(&row).Update("shelf_ratio_delta", ratioDelta).Error; err != nil { return nil, err } displays, err := r.ListAccountDisplays(ctx, row.SiteID, "") if err != nil { return nil, err } for _, d := range displays { if d.ID == id { return &d, nil } } return nil, ErrAccountDisplayNotFound } func (r *Repository) BatchUpdateAccountMarkup(ctx context.Context, adminID, siteID uint64, req BatchUpdateAccountMarkupRequest) error { if siteID == 0 { return ErrSiteNotFound } if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { return err } // 模式 A:单次提交多个商品的个性化加价列表 if len(req.Items) > 0 { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for _, item := range req.Items { if item.ShelfRatioDelta < 0 { return ErrInvalidAmount } q := tx.Model(&model.DistributionSiteAccount{}).Where("site_id = ?", siteID) if item.ID > 0 { q = q.Where("id = ?", item.ID) } else if item.ListingID > 0 { q = q.Where("listing_id = ?", item.ListingID) } else { continue } if err := q.Update("shelf_ratio_delta", item.ShelfRatioDelta).Error; err != nil { return err } } return nil }) } // 模式 B:统一加价比例 if req.ShelfRatioDelta < 0 { return ErrInvalidAmount } displayIDs := uniquePositiveIDs(req.DisplayIDs) listingIDs := uniquePositiveIDs(req.ListingIDs) if len(displayIDs) == 0 && len(listingIDs) == 0 { return ErrEmptyListingIDs } q := r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).Where("site_id = ?", siteID) if len(displayIDs) > 0 && len(listingIDs) > 0 { q = q.Where("(id IN ? OR listing_id IN ?)", displayIDs, listingIDs) } else if len(displayIDs) > 0 { q = q.Where("id IN ?", displayIDs) } else { q = q.Where("listing_id IN ?", listingIDs) } return q.Update("shelf_ratio_delta", req.ShelfRatioDelta).Error } func (r *Repository) BatchUpdateAccountDisplay(ctx context.Context, adminID, siteID uint64, req BatchUpdateAccountDisplayRequest) error { if siteID == 0 { return ErrSiteNotFound } if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { return err } targetStatus := strings.TrimSpace(req.TargetStatus) if targetStatus != "enabled" && targetStatus != "disabled" { return ErrInvalidListingStatus } displayIDs := uniquePositiveIDs(req.DisplayIDs) listingIDs := uniquePositiveIDs(req.ListingIDs) if len(displayIDs) == 0 && len(listingIDs) == 0 { return ErrEmptyListingIDs } q := r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).Where("site_id = ?", siteID) if len(displayIDs) > 0 && len(listingIDs) > 0 { q = q.Where("(id IN ? OR listing_id IN ?)", displayIDs, listingIDs) } else if len(displayIDs) > 0 { q = q.Where("id IN ?", displayIDs) } else { q = q.Where("listing_id IN ?", listingIDs) } return q.Updates(map[string]any{ "display_status": targetStatus, "display_disabled_by": displayDisabledBy(targetStatus), }).Error } func (r *Repository) ListReceivedListings(ctx context.Context, adminID, siteID uint64, q ReceivedListingQuery) (*ReceivedListingPage, error) { if siteID == 0 { return nil, ErrSiteNotFound } if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { return nil, err } page := q.Page size := q.PageSize if size <= 0 && q.Size > 0 { size = q.Size } if page < 1 { page = 1 } if size < 1 { size = 20 } if size > 100 { size = 100 } base := r.db.WithContext(ctx).Table("rental_listings rl"). Joins("JOIN game_accounts ga ON ga.id = rl.account_id"). Joins("LEFT JOIN users u ON u.id = rl.owner_id"). Joins("LEFT JOIN distribution_site_accounts dsa ON dsa.listing_id = rl.id AND dsa.site_id = ?", siteID). Where("rl.distribution_site_id = ?", siteID) if q.ReviewStatus != "" { base = base.Where("rl.review_status = ?", q.ReviewStatus) } switch strings.TrimSpace(q.Status) { case "": case "offline", "disabled": base = base.Where("dsa.display_status = ?", "disabled") case "published", "enabled": base = base.Where("dsa.id IS NULL OR dsa.display_status = ?", "enabled") default: base = base.Where("rl.status = ?", q.Status) } if q.GameName != "" { base = base.Where("ga.game_name = ?", q.GameName) } if q.Keyword != "" { kw := "%" + strings.TrimSpace(q.Keyword) + "%" base = base.Where("(rl.listing_no LIKE ? OR ga.title LIKE ? OR u.phone LIKE ? OR u.nickname LIKE ?)", kw, kw, kw, kw) } var total int64 if err := base.Session(&gorm.Session{}).Select("rl.id").Count(&total).Error; err != nil { return nil, err } type receivedListingRow struct { ID uint64 `gorm:"column:id"` ListingNo string `gorm:"column:listing_no"` AccountID uint64 `gorm:"column:account_id"` OwnerID uint64 `gorm:"column:owner_id"` OwnerUsername string `gorm:"column:owner_username"` OwnerPhone string `gorm:"column:owner_phone"` GameName string `gorm:"column:game_name"` Title string `gorm:"column:title"` ServerRegion string `gorm:"column:server_region"` LoginPlatform string `gorm:"column:login_platform"` RankLevel string `gorm:"column:rank_level"` HafCoinAmount int64 `gorm:"column:haf_coin_amount"` AssetSummary datatypes.JSON `gorm:"column:asset_summary"` PriceCent int64 `gorm:"column:price_cent"` DepositAmountCent int64 `gorm:"column:deposit_amount_cent"` Status string `gorm:"column:status"` ReviewStatus string `gorm:"column:review_status"` ReviewReason string `gorm:"column:review_reason"` InTransaction bool `gorm:"column:in_transaction"` DistributionSiteID uint64 `gorm:"column:distribution_site_id"` DisplayStatus string `gorm:"column:display_status"` CreatedAt time.Time `gorm:"column:created_at"` PublishedAt *time.Time `gorm:"column:published_at"` } var rows []receivedListingRow selectSQL := ` rl.id, rl.listing_no, rl.account_id, rl.owner_id, COALESCE(u.nickname, '') AS owner_username, COALESCE(u.phone, '') AS owner_phone, ga.game_name, ga.title, ga.server_region, ga.login_platform, ga.rank_level, ga.haf_coin_amount, ga.asset_summary, rl.price_cent, rl.deposit_amount_cent, rl.status, rl.review_status, rl.review_reason, rl.in_transaction, COALESCE(rl.distribution_site_id, 0) AS distribution_site_id, COALESCE(dsa.display_status, 'enabled') AS display_status, rl.created_at, rl.published_at ` if err := base.Select(selectSQL).Order("rl.id DESC").Offset((page - 1) * size).Limit(size).Scan(&rows).Error; err != nil { return nil, err } items := make([]ReceivedListingDTO, 0, len(rows)) for _, r := range rows { status := r.Status if r.DisplayStatus == "disabled" { status = "offline" } pricing := listingpkg.ReceivePricingFromAssetSummary(r.AssetSummary) items = append(items, ReceivedListingDTO{ ListingID: r.ID, ListingNo: r.ListingNo, SiteID: r.DistributionSiteID, AccountID: r.AccountID, OwnerID: r.OwnerID, OwnerPhone: r.OwnerPhone, OwnerNickname: r.OwnerUsername, Title: r.Title, GameName: r.GameName, ServerRegion: r.ServerRegion, LoginPlatform: r.LoginPlatform, RankLevel: r.RankLevel, HafCoinAmount: r.HafCoinAmount, SellerRatio: pricing.SellerRatio, SellerTotalPriceCent: pricing.SellerTotalPriceCent, BuyerRatio: pricing.BuyerRatio, PriceCent: r.PriceCent, DepositAmountCent: r.DepositAmountCent, Status: status, ReviewStatus: r.ReviewStatus, ReviewReason: r.ReviewReason, PublishedAt: r.PublishedAt, CreatedAt: r.CreatedAt, }) } return &ReceivedListingPage{ Items: items, Total: total, Page: page, PageSize: size, }, nil } func receivedDisplayStatus(status string) (string, error) { switch strings.TrimSpace(status) { case "published", "enabled": return "enabled", nil case "offline", "disabled": return "disabled", nil default: return "", ErrInvalidListingStatus } } func (r *Repository) UpdateReceivedListingStatus(ctx context.Context, adminID, listingID uint64, status string) error { if listingID == 0 { return errors.New("invalid listing id") } displayStatus, err := receivedDisplayStatus(status) if err != nil { return err } var listing model.RentalListing if err := r.db.WithContext(ctx).First(&listing, listingID).Error; err != nil { return err } if listing.DistributionSiteID == nil || *listing.DistributionSiteID == 0 { return ErrSiteAccessDenied } siteID := *listing.DistributionSiteID if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { return err } var row model.DistributionSiteAccount err = r.db.WithContext(ctx).Where("site_id = ? AND listing_id = ?", siteID, listing.ID).First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { row = model.DistributionSiteAccount{ SiteID: siteID, AccountID: listing.AccountID, ListingID: listing.ID, DisplayStatus: displayStatus, DisplayDisabledBy: displayDisabledBy(displayStatus), } return r.db.WithContext(ctx).Create(&row).Error } if err != nil { return err } return r.db.WithContext(ctx).Model(&row).Updates(map[string]any{ "display_status": displayStatus, "display_disabled_by": displayDisabledBy(displayStatus), }).Error } func (r *Repository) GetSiteWallet(ctx context.Context, adminID, siteID uint64) (*SiteWalletDTO, error) { if siteID == 0 { return nil, ErrSiteNotFound } if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { return nil, err } var site model.DistributionSite if err := r.db.WithContext(ctx).Select("id, settlement_hold_days").First(&site, siteID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrSiteNotFound } return nil, err } var account model.WalletAccount err := r.db.WithContext(ctx).Where("user_id = 0 AND distribution_site_id = ?", siteID).First(&account).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } var totalSettled int64 _ = r.db.WithContext(ctx).Table("distribution_settlement_items dsi"). Joins("JOIN distribution_settlement_batches dsb ON dsb.id = dsi.batch_id"). Where("dsb.site_id = ? AND dsi.status = 'settled'", siteID). Select("COALESCE(SUM(dsi.amount_cent), 0)").Scan(&totalSettled).Error // 待释放收益:订单已完成、快照待释放、且尚未进入任何结算批次的站内份额。 var pendingRelease int64 _ = r.db.WithContext(ctx).Table("distribution_order_parties p"). Joins("JOIN distribution_order_financials f ON f.order_id = p.order_id"). Joins("JOIN rental_orders ro ON ro.id = p.order_id"). Where("p.party_type = ? AND p.party_id = ?", PartyTypeSite, siteID). Where("f.settlement_status = 'pending' AND f.release_at IS NOT NULL"). Where("ro.status IN ?", settlementReadyOrderStatuses). Where("ro.refund_status NOT IN ?", autoReleaseBlockedRefundStatuses). Where("NOT EXISTS (SELECT 1 FROM disputes d WHERE d.order_id = ro.id AND d.status IN ?)", autoReleaseBlockedDisputeStatuses). Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.party_id = p.id)"). Select("COALESCE(SUM(p.income_amount_cent), 0)").Scan(&pendingRelease).Error var totalWithdrawn int64 _ = r.db.WithContext(ctx).Model(&model.WithdrawalRequest{}). Where("user_id = 0 AND distribution_site_id = ? AND status = 'completed'", siteID). Select("COALESCE(SUM(amount_cent), 0)").Scan(&totalWithdrawn).Error return &SiteWalletDTO{ SiteID: siteID, AvailableBalanceCent: account.AvailableBalanceCent, FrozenBalanceCent: account.FrozenBalanceCent, PendingReleaseCent: pendingRelease, TotalSettledCent: totalSettled, TotalWithdrawnCent: totalWithdrawn, SettlementHoldDays: site.SettlementHoldDays, }, nil } func (r *Repository) ApplySiteWithdrawal(ctx context.Context, adminID, siteID uint64, req ApplySiteWithdrawalRequest) (*SiteWithdrawalDTO, error) { if siteID == 0 { return nil, ErrSiteNotFound } if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { return nil, err } if req.AmountCent <= 0 { return nil, ErrInvalidAmount } accountType := strings.TrimSpace(req.AccountType) accountName := strings.TrimSpace(req.AccountName) accountNo := strings.TrimSpace(req.AccountNo) if accountType == "" || accountName == "" || accountNo == "" { return nil, errors.New("收款账户信息不完整") } withdrawNo, err := generateWithdrawNo() if err != nil { return nil, err } var withdrawal model.WithdrawalRequest err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var account model.WalletAccount if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("user_id = 0 AND distribution_site_id = ?", siteID).First(&account).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("钱包账户不存在或余额不足") } return err } if account.AvailableBalanceCent < req.AmountCent { return ErrInsufficientBalance } withdrawal = model.WithdrawalRequest{ WithdrawNo: withdrawNo, UserID: 0, DistributionSiteID: siteID, AmountCent: req.AmountCent, FeeCent: 0, ActualAmountCent: req.AmountCent, AccountType: accountType, AccountName: accountName, AccountNo: accountNo, BankName: strings.TrimSpace(req.BankName), BankBranch: strings.TrimSpace(req.BankBranch), Status: "pending", } if err := tx.Create(&withdrawal).Error; err != nil { return err } return wallet.AppendEntries(tx, wallet.Entry{ UserID: 0, DistributionSiteID: siteID, Direction: "out", AmountCent: req.AmountCent, BalanceType: "available", BizType: "withdraw_freeze", BizNo: withdrawNo, Remark: "分销提现申请冻结", }, wallet.Entry{ UserID: 0, DistributionSiteID: siteID, Direction: "in", AmountCent: req.AmountCent, BalanceType: "frozen", BizType: "withdraw_freeze", BizNo: withdrawNo, Remark: "分销提现申请冻结", }, ) }) if err != nil { return nil, err } return &SiteWithdrawalDTO{ ID: withdrawal.ID, WithdrawNo: withdrawal.WithdrawNo, SiteID: withdrawal.DistributionSiteID, AmountCent: withdrawal.AmountCent, FeeCent: withdrawal.FeeCent, ActualAmountCent: withdrawal.ActualAmountCent, AccountType: withdrawal.AccountType, AccountName: withdrawal.AccountName, AccountNo: maskAccountNo(withdrawal.AccountNo, withdrawal.AccountType), BankName: withdrawal.BankName, BankBranch: withdrawal.BankBranch, Status: withdrawal.Status, CreatedAt: withdrawal.CreatedAt, }, nil } func (r *Repository) ListSiteWithdrawals(ctx context.Context, adminID, siteID uint64, page, size int) (*SiteWithdrawalPage, error) { if siteID == 0 { return nil, ErrSiteNotFound } if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { return nil, err } if page < 1 { page = 1 } if size < 1 { size = 20 } if size > 100 { size = 100 } query := r.db.WithContext(ctx).Model(&model.WithdrawalRequest{}). Where("user_id = 0 AND distribution_site_id = ?", siteID) var total int64 if err := query.Count(&total).Error; err != nil { return nil, err } var rows []model.WithdrawalRequest if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { return nil, err } items := make([]SiteWithdrawalDTO, 0, len(rows)) for _, w := range rows { items = append(items, SiteWithdrawalDTO{ ID: w.ID, WithdrawNo: w.WithdrawNo, SiteID: w.DistributionSiteID, AmountCent: w.AmountCent, FeeCent: w.FeeCent, ActualAmountCent: w.ActualAmountCent, AccountType: w.AccountType, AccountName: w.AccountName, AccountNo: maskAccountNo(w.AccountNo, w.AccountType), BankName: w.BankName, BankBranch: w.BankBranch, Status: w.Status, ReviewRemark: w.ReviewRemark, PaymentProofURL: w.PaymentProofURL, PaymentRemark: w.PaymentRemark, PaidAt: w.PaidAt, CreatedAt: w.CreatedAt, }) } return &SiteWithdrawalPage{ Items: items, Total: total, Page: page, PageSize: size, }, nil } func (r *Repository) GetDashboardAnalytics(ctx context.Context, adminID, siteID uint64) (*DistributionAnalyticsDTO, error) { if siteID == 0 { return nil, ErrSiteNotFound } if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { return nil, err } cacheKey := fmt.Sprintf("dist:analytics:%d", siteID) if r.redis != nil { val, err := r.redis.Get(ctx, cacheKey).Result() if err == nil && val != "" { var cached DistributionAnalyticsDTO if jsonErr := json.Unmarshal([]byte(val), &cached); jsonErr == nil { return &cached, nil } } } todayStr := time.Now().Format("2006-01-02") monthStr := time.Now().Format("2006-01") var todayReceived int64 _ = r.db.WithContext(ctx).Model(&model.RentalListing{}). Where("distribution_site_id = ? AND created_at >= ?", siteID, todayStr+" 00:00:00").Count(&todayReceived).Error var totalReceived int64 _ = r.db.WithContext(ctx).Model(&model.RentalListing{}). Where("distribution_site_id = ?", siteID).Count(&totalReceived).Error var activeListings int64 _ = r.db.WithContext(ctx).Model(&model.RentalListing{}). Where("distribution_site_id = ? AND status = 'published' AND review_status = 'approved'", siteID).Count(&activeListings).Error var todayOrders int64 _ = r.db.WithContext(ctx).Table("rental_orders ro"). Where("(ro.distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND (p.site_id = ? OR p.party_id = ?))) AND ro.created_at >= ?", siteID, siteID, siteID, todayStr+" 00:00:00"). Count(&todayOrders).Error var todayGMV int64 _ = r.db.WithContext(ctx).Table("rental_orders ro"). Joins("JOIN distribution_order_financials f ON f.order_id = ro.id"). Where("(ro.distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND (p.site_id = ? OR p.party_id = ?))) AND ro.created_at >= ?", siteID, siteID, siteID, todayStr+" 00:00:00"). Select("COALESCE(SUM(f.gross_paid_amount_cent), 0)").Scan(&todayGMV).Error var todayProfit int64 _ = r.db.WithContext(ctx).Model(&model.DistributionOrderParty{}). Where("(site_id = ? OR party_id = ?) AND party_type = 'site' AND created_at >= ?", siteID, siteID, todayStr+" 00:00:00"). Select("COALESCE(SUM(profit_amount_cent), 0)").Scan(&todayProfit).Error var monthProfit int64 _ = r.db.WithContext(ctx).Model(&model.DistributionOrderParty{}). Where("(site_id = ? OR party_id = ?) AND party_type = 'site' AND created_at >= ?", siteID, siteID, monthStr+"-01 00:00:00"). Select("COALESCE(SUM(profit_amount_cent), 0)").Scan(&monthProfit).Error var todayUserCount int64 _ = r.db.WithContext(ctx).Model(&model.User{}). Where("distribution_site_id = ? AND created_at >= ?", siteID, todayStr+" 00:00:00").Count(&todayUserCount).Error var totalUserCount int64 _ = r.db.WithContext(ctx).Model(&model.User{}). Where("distribution_site_id = ?", siteID).Count(&totalUserCount).Error var totalOrderUserCount int64 _ = r.db.WithContext(ctx).Table("rental_orders"). Where("(distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = rental_orders.id AND (p.site_id = ? OR p.party_id = ?))) AND status NOT IN ('cancelled', 'closed')", siteID, siteID, siteID). Distinct("renter_id").Count(&totalOrderUserCount).Error var wallet model.WalletAccount _ = r.db.WithContext(ctx).Where("user_id = 0 AND distribution_site_id = ?", siteID).First(&wallet).Error trendDays := make([]DistributionTrendPoint, 0, 7) type trendRow struct { DateStr string `gorm:"column:date_str"` Orders int64 `gorm:"column:orders"` GMV int64 `gorm:"column:gmv"` } var tRows []trendRow startDate := time.Now().AddDate(0, 0, -6).Format("2006-01-02") dateFormatExpr := "DATE_FORMAT(ro.created_at, '%Y-%m-%d')" dateFormatPartyExpr := "DATE_FORMAT(created_at, '%Y-%m-%d')" selectTrendSQL := fmt.Sprintf("%s AS date_str, COUNT(ro.id) AS orders, COALESCE(SUM(f.gross_paid_amount_cent), 0) AS gmv", dateFormatExpr) _ = r.db.WithContext(ctx).Table("rental_orders ro"). Joins("JOIN distribution_order_financials f ON f.order_id = ro.id"). Where("(ro.distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND (p.site_id = ? OR p.party_id = ?))) AND ro.created_at >= ?", siteID, siteID, siteID, startDate+" 00:00:00"). Select(selectTrendSQL). Group(dateFormatExpr). Order("date_str ASC"). Scan(&tRows).Error type receivedRow struct { DateStr string `gorm:"column:date_str"` Received int64 `gorm:"column:received"` } var rRows []receivedRow dateFormatListingExpr := "DATE_FORMAT(created_at, '%Y-%m-%d')" _ = r.db.WithContext(ctx).Model(&model.RentalListing{}). Where("distribution_site_id = ? AND created_at >= ?", siteID, startDate+" 00:00:00"). Select(fmt.Sprintf("%s AS date_str, COUNT(id) AS received", dateFormatListingExpr)). Group(dateFormatListingExpr). Scan(&rRows).Error rRowMap := make(map[string]int64, len(rRows)) for _, rr := range rRows { rRowMap[rr.DateStr] = rr.Received } type profitRow struct { DateStr string `gorm:"column:date_str"` Profit int64 `gorm:"column:profit"` } var pRows []profitRow _ = r.db.WithContext(ctx).Model(&model.DistributionOrderParty{}). Where("(site_id = ? OR party_id = ?) AND party_type = 'site' AND created_at >= ?", siteID, siteID, startDate+" 00:00:00"). Select(fmt.Sprintf("%s AS date_str, COALESCE(SUM(profit_amount_cent), 0) AS profit", dateFormatPartyExpr)). Group(dateFormatPartyExpr). Scan(&pRows).Error pRowMap := make(map[string]int64, len(pRows)) for _, pr := range pRows { pRowMap[pr.DateStr] = pr.Profit } tRowMap := make(map[string]trendRow, len(tRows)) for _, tr := range tRows { tRowMap[tr.DateStr] = tr } for i := -6; i <= 0; i++ { dStr := time.Now().AddDate(0, 0, i).Format("2006-01-02") tr := tRowMap[dStr] trendDays = append(trendDays, DistributionTrendPoint{ Date: dStr, ReceivedCount: rRowMap[dStr], OrderCount: tr.Orders, GMVCent: tr.GMV, ProfitCent: pRowMap[dStr], }) } dto := &DistributionAnalyticsDTO{ TodayReceivedCount: todayReceived, TotalReceivedCount: totalReceived, ActiveListingCount: activeListings, TodayOrderCount: todayOrders, TodayGMVCent: todayGMV, TodayProfitCent: todayProfit, MonthProfitCent: monthProfit, AvailableBalanceCent: wallet.AvailableBalanceCent, TodayUserCount: todayUserCount, TotalUserCount: totalUserCount, TotalOrderUserCount: totalOrderUserCount, TrendDays: trendDays, } if r.redis != nil { if bytes, err := json.Marshal(dto); err == nil { _ = r.redis.Set(ctx, cacheKey, string(bytes), 60*time.Second).Err() } } return dto, nil } func (r *Repository) ListDistributionUsers(ctx context.Context, adminID, siteID uint64, query ListDistributionUsersQuery) (*DistributionUserPageDTO, error) { if siteID == 0 { return nil, ErrSiteNotFound } if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { return nil, err } page := query.Page if page < 1 { page = 1 } pageSize := query.PageSize if pageSize < 1 || pageSize > 100 { pageSize = 20 } offset := (page - 1) * pageSize // 统计全站总注册人数与成交客户数 var totalUsersCount int64 var orderUsersCount int64 _ = r.db.WithContext(ctx).Model(&model.User{}). Where("distribution_site_id = ?", siteID).Count(&totalUsersCount).Error _ = r.db.WithContext(ctx).Table("rental_orders"). Where("(distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = rental_orders.id AND (p.site_id = ? OR p.party_id = ?))) AND status NOT IN ('cancelled', 'closed')", siteID, siteID, siteID). Distinct("renter_id").Count(&orderUsersCount).Error orderSubquery := r.db.Table("rental_orders"). Select("renter_id, COUNT(*) AS ord_count, COALESCE(SUM(rent_amount_cent), 0) AS ord_spent, MAX(created_at) AS last_ord_at"). Where("(distribution_site_id = ? OR EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = rental_orders.id AND (p.site_id = ? OR p.party_id = ?))) AND status NOT IN ('cancelled', 'closed')", siteID, siteID, siteID). Group("renter_id") baseQuery := r.db.WithContext(ctx).Table("users AS u"). Joins("LEFT JOIN (?) AS ord ON ord.renter_id = u.id", orderSubquery) switch strings.TrimSpace(query.Source) { case "register": baseQuery = baseQuery.Where("u.distribution_site_id = ?", siteID) case "order": baseQuery = baseQuery.Where("ord.renter_id IS NOT NULL") default: baseQuery = baseQuery.Where("u.distribution_site_id = ? OR ord.renter_id IS NOT NULL", siteID) } if kw := strings.TrimSpace(query.Keyword); kw != "" { baseQuery = baseQuery.Where("u.phone LIKE ? OR u.nickname LIKE ?", "%"+kw+"%", "%"+kw+"%") } var totalFiltered int64 if err := baseQuery.Count(&totalFiltered).Error; err != nil { return nil, err } type userRow struct { ID uint64 `gorm:"column:id"` Phone string `gorm:"column:phone"` Nickname string `gorm:"column:nickname"` AvatarURL string `gorm:"column:avatar_url"` RealnameStatus string `gorm:"column:realname_status"` Status string `gorm:"column:status"` DistributionSiteID uint64 `gorm:"column:distribution_site_id"` CreatedAt time.Time `gorm:"column:created_at"` LastLoginAt *time.Time `gorm:"column:last_login_at"` OrderCount int64 `gorm:"column:ord_count"` TotalSpentCent int64 `gorm:"column:ord_spent"` LastOrderAt *time.Time `gorm:"column:last_ord_at"` } var rows []userRow if err := baseQuery. Select("u.id, u.phone, u.nickname, u.avatar_url, u.realname_status, u.status, u.distribution_site_id, u.created_at, u.last_login_at, COALESCE(ord.ord_count, 0) AS ord_count, COALESCE(ord.ord_spent, 0) AS ord_spent, ord.last_ord_at"). Order("COALESCE(ord.last_ord_at, u.created_at) DESC"). Offset(offset).Limit(pageSize). Scan(&rows).Error; err != nil { return nil, err } items := make([]DistributionUserDTO, 0, len(rows)) for _, row := range rows { source := "register" if row.DistributionSiteID == siteID && row.OrderCount > 0 { source = "both" } else if row.DistributionSiteID != siteID && row.OrderCount > 0 { source = "order" } items = append(items, DistributionUserDTO{ ID: row.ID, Phone: row.Phone, Nickname: row.Nickname, AvatarURL: row.AvatarURL, RealnameStatus: row.RealnameStatus, Status: row.Status, Source: source, OrderCount: row.OrderCount, TotalSpentCent: row.TotalSpentCent, LastOrderAt: row.LastOrderAt, CreatedAt: row.CreatedAt, LastLoginAt: row.LastLoginAt, }) } return &DistributionUserPageDTO{ Items: items, Total: totalFiltered, Page: page, PageSize: pageSize, TotalUsers: totalUsersCount, OrderUsers: orderUsersCount, }, nil }