Files
hfb_sys/backend/internal/modules/distribution/repository.go
T
yml2213 22fcaa5945 feat(distribution): 分站收号压价解耦买家价(方案 B 新口径)
核心语义反转:receive_cost_delta 为压价点数,收号比例 = 主站参考比例 R + 点数,
点数越大号主收号价越低;买家售价锚定主站参考比例推导,不再随收号价联动。

- 迁移 000072:distribution_sites 新增 receive_cost_delta(旧 receive_ratio_delta 保留只读)
- receive_pricing:新增 Normalize/ApplySiteReceiveCostDelta;分站定价买家侧改用
  R 锚点(referenceSellerCoinBasePrice),快照写入 site_receive_cost_delta 与 pricing_model 标记
- site_receive:新增 resolveSiteReceiveCostDelta 读取链路,旧 ResolveReceiveRatioDelta 保留
- 分成不变:压价差经结算层 profit 自动进入 35/40/25 三方分成池;
  货架自主加价仍 100% 归分站,两条边界均以主站为锚
- 权限对齐旧字段:主站全局管理员与分站操作员均可设置
- 前端三端同步:主站 pricing.ts/usePricingCalculator 买卖解耦试算、
  主站后台弹窗改「收号压价」价格语义文案、分销后台 SiteSettings 同步
- 测试:单测锚定 R=43 时 cost_delta=3 → 收号 217.4 / 卖价 263.2 不变 / markup 池 30.6→54.9
2026-09-17 15:54:56 +08:00

3096 lines
105 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
)
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))
for _, row := range rows {
items = append(items, siteDTO(row))
}
return items, nil
}
func (r *Repository) FindSite(ctx context.Context, adminID, id uint64) (*SiteDTO, error) {
var row model.DistributionSite
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSiteNotFound
}
return nil, err
}
if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil {
return nil, err
}
item := siteDTO(row)
return &item, nil
}
// 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)
}
if err := r.db.WithContext(ctx).Create(&row).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 {
row.Domain = strings.TrimSpace(*req.Domain)
}
if req.Status != nil {
row.Status = strings.TrimSpace(*req.Status)
}
if req.DisplayAccountEnabled != nil {
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
}
if req.DefaultSupportGroupID != nil {
row.DefaultSupportGroupID = req.DefaultSupportGroupID
}
if req.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)
return &item, nil
}
func (r *Repository) ListRules(ctx context.Context, adminID uint64, siteID *uint64, mode string) ([]RuleDTO, error) {
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).
Order("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,
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)
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
for _, l := range candidates {
if seenAccount[l.AccountID] {
continue
}
seenAccount[l.AccountID] = true
if ea, exists := existingMap[l.AccountID]; exists {
if ea.ListingID != l.ID {
_ = r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).
Where("id = ?", ea.ID).Update("listing_id", l.ID).Error
updatedCount++
}
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)
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 AND l.status = 'published' AND l.review_status = 'approved'").
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]).
Update("display_status", "disabled").Error
}
disabledStaleCount = int64(len(staleIDs))
}
}
return &SyncSiteAccountsResult{
SyncedCount: int64(len(toInsert)),
SkippedCount: skippedCount,
UpdatedCount: updatedCount,
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,
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 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.Update("display_status", 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 {
maskedPhone := r.OwnerPhone
if len(maskedPhone) >= 7 {
maskedPhone = maskedPhone[:3] + "****" + maskedPhone[len(maskedPhone)-4:]
}
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: maskedPhone,
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,
}
return r.db.WithContext(ctx).Create(&row).Error
}
if err != nil {
return err
}
return r.db.WithContext(ctx).Model(&row).Update("display_status", 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 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,
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
}