1216 lines
43 KiB
Go
1216 lines
43 KiB
Go
package distribution
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"hfb_sys/backend/internal/auditlog"
|
|
"hfb_sys/backend/internal/model"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
var (
|
|
ErrSiteNotFound = errors.New("distribution site not found")
|
|
ErrRuleNotFound = errors.New("distribution rule not found")
|
|
ErrOrderNotFound = errors.New("distribution order not found")
|
|
ErrInvalidOrderKind = errors.New("invalid distribution order kind")
|
|
ErrAccountDisplayNotFound = errors.New("distribution account display not found")
|
|
ErrPaymentChannelNotFound = errors.New("distribution payment channel not found")
|
|
ErrInvalidPeriod = errors.New("invalid distribution settlement period")
|
|
ErrSettlementBatchNotFound = errors.New("distribution settlement batch not found")
|
|
ErrBatchAlreadySettled = errors.New("distribution settlement batch already settled")
|
|
ErrSettlementBatchEmpty = errors.New("distribution settlement batch empty")
|
|
ErrSettlementTotalMismatch = errors.New("distribution settlement total mismatch")
|
|
ErrPaymentConfigNotFound = errors.New("distribution payment config not found")
|
|
ErrOrderSnapshotExists = errors.New("distribution order snapshot exists")
|
|
ErrSiteAccessDenied = errors.New("distribution site access denied")
|
|
ErrInvalidCredential = errors.New("invalid credential")
|
|
ErrAdminDisabled = errors.New("admin disabled")
|
|
ErrDuplicateSiteCodeOrDomain = errors.New("distribution site code or domain already exists")
|
|
ErrDuplicateUsername = errors.New("username already exists")
|
|
)
|
|
|
|
type Repository struct {
|
|
db *gorm.DB
|
|
redis *redis.Client
|
|
}
|
|
|
|
func NewRepository(db *gorm.DB, redis ...*redis.Client) *Repository {
|
|
r := &Repository{db: db}
|
|
if len(redis) > 0 {
|
|
r.redis = redis[0]
|
|
}
|
|
return r
|
|
}
|
|
|
|
func (r *Repository) ScopeSiteID(ctx context.Context, adminID, requested uint64) (uint64, error) {
|
|
if adminID == 0 {
|
|
return requested, nil
|
|
}
|
|
var members []model.DistributionSiteMember
|
|
if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
if len(members) == 0 {
|
|
return requested, nil
|
|
}
|
|
if requested > 0 {
|
|
for _, member := range members {
|
|
if member.SiteID == requested {
|
|
return requested, nil
|
|
}
|
|
}
|
|
return 0, ErrSiteAccessDenied
|
|
}
|
|
if len(members) == 1 {
|
|
return members[0].SiteID, nil
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
// ensureSiteAccess 校验管理员可访问指定站点:
|
|
// 无成员记录(超级管理员语义)放行,有成员记录则必须命中。
|
|
func (r *Repository) ensureSiteAccess(ctx context.Context, adminID, siteID uint64) error {
|
|
if adminID == 0 {
|
|
return nil
|
|
}
|
|
var count int64
|
|
if err := r.db.WithContext(ctx).Model(&model.DistributionSiteMember{}).
|
|
Where("admin_user_id = ? AND status = 'active'", adminID).Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return nil
|
|
}
|
|
if siteID == 0 {
|
|
return ErrSiteAccessDenied
|
|
}
|
|
var allowed int64
|
|
if err := r.db.WithContext(ctx).Model(&model.DistributionSiteMember{}).
|
|
Where("admin_user_id = ? AND site_id = ? AND status = 'active'", adminID, siteID).Count(&allowed).Error; err != nil {
|
|
return err
|
|
}
|
|
if allowed == 0 {
|
|
return ErrSiteAccessDenied
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) ListSites(ctx context.Context) ([]SiteDTO, error) {
|
|
var rows []model.DistributionSite
|
|
if err := r.db.WithContext(ctx).Order("id ASC").Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]SiteDTO, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, siteDTO(row))
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (r *Repository) FindSite(ctx context.Context, adminID, id uint64) (*SiteDTO, error) {
|
|
var row model.DistributionSite
|
|
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrSiteNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
item := siteDTO(row)
|
|
return &item, nil
|
|
}
|
|
|
|
func (r *Repository) CreateSite(ctx context.Context, req CreateSiteRequest) (*SiteDTO, error) {
|
|
row := model.DistributionSite{
|
|
SiteCode: strings.TrimSpace(req.SiteCode), SiteName: strings.TrimSpace(req.SiteName), Domain: strings.TrimSpace(req.Domain),
|
|
Status: "active", DisplayAccountEnabled: true, TaxRateBPS: 100,
|
|
}
|
|
if req.DisplayAccountEnabled != nil {
|
|
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
|
|
}
|
|
if req.DefaultSupportGroupID != nil {
|
|
row.DefaultSupportGroupID = req.DefaultSupportGroupID
|
|
}
|
|
if req.TaxEnabled != nil {
|
|
row.TaxEnabled = *req.TaxEnabled
|
|
}
|
|
if req.TaxRateBPS != nil {
|
|
row.TaxRateBPS = *req.TaxRateBPS
|
|
}
|
|
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
item := siteDTO(row)
|
|
return &item, nil
|
|
}
|
|
|
|
func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req UpdateSiteRequest) (*SiteDTO, error) {
|
|
var row model.DistributionSite
|
|
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrSiteNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if err := r.ensureSiteAccess(ctx, adminID, row.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.SiteName != nil {
|
|
row.SiteName = strings.TrimSpace(*req.SiteName)
|
|
}
|
|
if req.Domain != nil {
|
|
row.Domain = strings.TrimSpace(*req.Domain)
|
|
}
|
|
if req.Status != nil {
|
|
row.Status = strings.TrimSpace(*req.Status)
|
|
}
|
|
if req.DisplayAccountEnabled != nil {
|
|
row.DisplayAccountEnabled = *req.DisplayAccountEnabled
|
|
}
|
|
if req.DefaultSupportGroupID != nil {
|
|
row.DefaultSupportGroupID = req.DefaultSupportGroupID
|
|
}
|
|
if req.TaxEnabled != nil {
|
|
row.TaxEnabled = *req.TaxEnabled
|
|
}
|
|
if req.TaxRateBPS != nil {
|
|
row.TaxRateBPS = *req.TaxRateBPS
|
|
}
|
|
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
item := siteDTO(row)
|
|
return &item, nil
|
|
}
|
|
|
|
func (r *Repository) ListRules(ctx context.Context, adminID uint64, siteID *uint64, mode string) ([]RuleDTO, error) {
|
|
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).Order("effective_from DESC, id DESC")
|
|
if siteID == nil {
|
|
db = db.Where("site_id IS NULL")
|
|
} else {
|
|
if err := r.ensureSiteAccess(ctx, adminID, *siteID); err != nil {
|
|
return nil, err
|
|
}
|
|
db = db.Where("site_id = ?", *siteID)
|
|
}
|
|
if mode = strings.TrimSpace(mode); mode != "" {
|
|
db = db.Where("mode = ?", mode)
|
|
}
|
|
var rows []model.DistributionRuleVersion
|
|
if err := db.Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]RuleDTO, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, ruleDTO(row))
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (r *Repository) CreateRule(ctx context.Context, adminID uint64, req CreateRuleRequest) (*RuleDTO, error) {
|
|
if req.SiteID != nil {
|
|
if err := r.ensureSiteAccess(ctx, adminID, *req.SiteID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
version := 1
|
|
var maxVersion int
|
|
db := r.db.WithContext(ctx).Model(&model.DistributionRuleVersion{}).Where("mode = ?", req.Mode)
|
|
if req.SiteID == nil {
|
|
db = db.Where("site_id IS NULL")
|
|
} else {
|
|
db = db.Where("site_id = ?", *req.SiteID)
|
|
}
|
|
if err := db.Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
version = maxVersion + 1
|
|
effectiveFrom := req.EffectiveFrom
|
|
if effectiveFrom.IsZero() {
|
|
effectiveFrom = time.Now()
|
|
}
|
|
row := model.DistributionRuleVersion{
|
|
SiteID: req.SiteID, Mode: strings.TrimSpace(req.Mode), Version: version, Status: "draft",
|
|
PlatformReceiveRatio: req.PlatformReceiveRatio, PlatformSellRatio: req.PlatformSellRatio,
|
|
DistributorReceiveRatio: req.DistributorReceiveRatio, DistributorSellRatio: req.DistributorSellRatio,
|
|
DistributorARatio: req.DistributorARatio, DistributorBRatio: req.DistributorBRatio,
|
|
ServiceFeeValue: req.ServiceFeeValue, ServiceFeeUnit: strings.TrimSpace(req.ServiceFeeUnit),
|
|
PaymentFeeBPS: req.PaymentFeeBPS, TaxEnabled: req.TaxEnabled, TaxRateBPS: req.TaxRateBPS,
|
|
EffectiveFrom: effectiveFrom, CreatedBy: &adminID,
|
|
}
|
|
if row.PaymentFeeBPS == 0 {
|
|
row.PaymentFeeBPS = 50
|
|
}
|
|
if row.TaxRateBPS == 0 {
|
|
row.TaxRateBPS = 100
|
|
}
|
|
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
item := ruleDTO(row)
|
|
return &item, nil
|
|
}
|
|
|
|
func (r *Repository) UpdateRuleStatus(ctx context.Context, adminID, id uint64, status string) (*RuleDTO, error) {
|
|
var row model.DistributionRuleVersion
|
|
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrRuleNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if row.SiteID != nil {
|
|
if err := r.ensureSiteAccess(ctx, adminID, *row.SiteID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
row.Status = strings.TrimSpace(status)
|
|
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
item := ruleDTO(row)
|
|
return &item, nil
|
|
}
|
|
|
|
func siteDTO(row model.DistributionSite) SiteDTO {
|
|
return SiteDTO{ID: row.ID, SiteCode: row.SiteCode, SiteName: row.SiteName, Domain: row.Domain, Status: row.Status, DisplayAccountEnabled: row.DisplayAccountEnabled, DefaultSupportGroupID: row.DefaultSupportGroupID, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
|
}
|
|
|
|
func ruleDTO(row model.DistributionRuleVersion) RuleDTO {
|
|
return RuleDTO{ID: row.ID, SiteID: row.SiteID, Mode: row.Mode, Version: row.Version, Status: row.Status, PlatformReceiveRatio: row.PlatformReceiveRatio, PlatformSellRatio: row.PlatformSellRatio, DistributorReceiveRatio: row.DistributorReceiveRatio, DistributorSellRatio: row.DistributorSellRatio, DistributorARatio: row.DistributorARatio, DistributorBRatio: row.DistributorBRatio, ServiceFeeValue: row.ServiceFeeValue, ServiceFeeUnit: row.ServiceFeeUnit, PaymentFeeBPS: row.PaymentFeeBPS, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, EffectiveFrom: row.EffectiveFrom, EffectiveTo: row.EffectiveTo, CreatedBy: row.CreatedBy, CreatedAt: row.CreatedAt}
|
|
}
|
|
|
|
func (r *Repository) ListOrders(ctx context.Context, query DistributionOrderListQuery) (*DistributionOrderPage, error) {
|
|
page, size := query.Page, query.Size
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if size < 1 {
|
|
size = 20
|
|
}
|
|
if size > 100 {
|
|
size = 100
|
|
}
|
|
base := r.db.WithContext(ctx).Table("rental_orders ro").Joins("JOIN distribution_order_financials f ON f.order_id = ro.id")
|
|
if query.SiteID > 0 {
|
|
base = base.Where("ro.distribution_site_id = ?", query.SiteID)
|
|
}
|
|
if query.Status != "" {
|
|
base = base.Where("ro.status = ?", query.Status)
|
|
}
|
|
switch strings.TrimSpace(query.Kind) {
|
|
case "sold":
|
|
base = base.Where("EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND p.party_role IN ('distributor_sell','distributor_b'))")
|
|
case "receive":
|
|
base = base.Where("EXISTS (SELECT 1 FROM distribution_order_parties p WHERE p.order_id = ro.id AND p.party_role IN ('distributor_receive','distributor_a'))")
|
|
case "", "all":
|
|
default:
|
|
return nil, ErrInvalidOrderKind
|
|
}
|
|
var total int64
|
|
if err := base.Session(&gorm.Session{}).Select("ro.id").Count(&total).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]DistributionOrderDTO, 0)
|
|
selectSQL := "ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, f.rounding_adjustment_cent, ro.created_at"
|
|
if err := base.Select(selectSQL).Order("ro.id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &DistributionOrderPage{Items: items, Total: total, Page: page, PageSize: size}, nil
|
|
}
|
|
|
|
func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*DistributionOrderDTO, error) {
|
|
var item DistributionOrderDTO
|
|
err := r.db.WithContext(ctx).Table("rental_orders ro").Joins("JOIN distribution_order_financials f ON f.order_id = ro.id").Select("ro.id AS order_id, ro.order_no, ro.distribution_site_id AS site_id, ro.listing_id, ro.account_id, ro.owner_id, ro.renter_id, ro.status, ro.handoff_status, ro.refund_status, ro.settlement_status, ro.distribution_mode, f.gross_paid_amount_cent, f.refund_amount_cent, f.net_paid_amount_cent, f.payment_fee_cent, f.service_fee_cent, f.markup_amount_cent, f.tax_amount_cent, f.split_base_amount_cent, f.rounding_adjustment_cent, ro.created_at").Where("ro.id = ?", id).Scan(&item).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if item.OrderID == 0 {
|
|
return nil, ErrOrderNotFound
|
|
}
|
|
if err := r.ensureSiteAccess(ctx, adminID, item.SiteID); err != nil {
|
|
return nil, err
|
|
}
|
|
return &item, nil
|
|
}
|
|
|
|
func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) {
|
|
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"`
|
|
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"`
|
|
}
|
|
|
|
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(l.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,
|
|
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 {
|
|
items = append(items, AccountDisplayDTO{
|
|
ID: row.ID,
|
|
SiteID: row.SiteID,
|
|
AccountID: row.AccountID,
|
|
ListingID: row.ListingID,
|
|
DisplayStatus: row.DisplayStatus,
|
|
ListingNo: row.ListingNo,
|
|
Title: row.Title,
|
|
GameName: row.GameName,
|
|
ServerRegion: row.ServerRegion,
|
|
LoginPlatform: row.LoginPlatform,
|
|
RankLevel: row.RankLevel,
|
|
HafCoinAmount: row.HafCoinAmount,
|
|
PriceCent: row.PriceCent,
|
|
DepositAmountCent: row.DepositAmountCent,
|
|
ListingStatus: row.ListingStatus,
|
|
InTransaction: row.InTransaction,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (r *Repository) UpdateAccountDisplay(ctx context.Context, adminID, id uint64, status string) (*AccountDisplayDTO, error) {
|
|
var row model.DistributionSiteAccount
|
|
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrAccountDisplayNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if err := r.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil {
|
|
return nil, err
|
|
}
|
|
row.DisplayStatus = strings.TrimSpace(status)
|
|
if err := r.db.WithContext(ctx).Save(&row).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
item := &AccountDisplayDTO{
|
|
ID: row.ID,
|
|
SiteID: row.SiteID,
|
|
AccountID: row.AccountID,
|
|
ListingID: row.ListingID,
|
|
DisplayStatus: row.DisplayStatus,
|
|
}
|
|
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 (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint64) (*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
|
|
}
|
|
|
|
var activeListings []model.RentalListing
|
|
if err := r.db.WithContext(ctx).
|
|
Where("status = ? AND review_status = ?", "published", "approved").
|
|
Order("id ASC").
|
|
Find(&activeListings).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
|
|
|
|
for _, l := range activeListings {
|
|
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
|
|
}
|
|
skippedCount++
|
|
} else {
|
|
toInsert = append(toInsert, model.DistributionSiteAccount{
|
|
SiteID: siteID,
|
|
AccountID: l.AccountID,
|
|
ListingID: l.ID,
|
|
DisplayStatus: "enabled",
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(toInsert) > 0 {
|
|
if err := r.db.WithContext(ctx).Create(&toInsert).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return &SyncSiteAccountsResult{
|
|
SyncedCount: int64(len(toInsert)),
|
|
SkippedCount: skippedCount,
|
|
TotalActive: int64(len(activeListings)),
|
|
}, nil
|
|
}
|
|
|
|
func (r *Repository) ListPaymentChannels(ctx context.Context, siteID uint64) ([]PaymentChannelDTO, error) {
|
|
var rows []model.DistributionSitePaymentChannel
|
|
if err := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("priority ASC, id ASC").Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]PaymentChannelDTO, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, PaymentChannelDTO{ID: row.ID, SiteID: row.SiteID, ConfigID: row.ConfigID, Priority: row.Priority, Status: row.Status})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (r *Repository) BindPaymentChannel(ctx context.Context, siteID uint64, req BindPaymentChannelRequest) (*PaymentChannelDTO, error) {
|
|
var configCount int64
|
|
if err := r.db.WithContext(ctx).Model(&model.PaymentMerchantConfig{}).
|
|
Where("id = ?", req.ConfigID).Count(&configCount).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if configCount == 0 {
|
|
return nil, ErrPaymentConfigNotFound
|
|
}
|
|
row := model.DistributionSitePaymentChannel{SiteID: siteID, ConfigID: req.ConfigID, Priority: req.Priority, Status: "active"}
|
|
if err := r.db.WithContext(ctx).Where("site_id = ? AND config_id = ?", siteID, req.ConfigID).FirstOrCreate(&row).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &PaymentChannelDTO{ID: row.ID, SiteID: row.SiteID, ConfigID: row.ConfigID, Priority: row.Priority, Status: row.Status}, nil
|
|
}
|
|
|
|
func (r *Repository) UnbindPaymentChannel(ctx context.Context, adminID, id uint64) error {
|
|
var row model.DistributionSitePaymentChannel
|
|
if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrPaymentChannelNotFound
|
|
}
|
|
return err
|
|
}
|
|
if err := r.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil {
|
|
return err
|
|
}
|
|
return r.db.WithContext(ctx).Delete(&row).Error
|
|
}
|
|
|
|
func (r *Repository) ListSettlementBatches(ctx context.Context, siteID uint64) ([]SettlementBatchDTO, error) {
|
|
var rows []model.DistributionSettlementBatch
|
|
if err := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("id DESC").Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]SettlementBatchDTO, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (r *Repository) CreateSettlementBatch(ctx context.Context, adminID uint64, req CreateSettlementBatchRequest) (*SettlementBatchDTO, error) {
|
|
if req.PeriodEnd.Before(req.PeriodStart) {
|
|
return nil, ErrInvalidPeriod
|
|
}
|
|
if err := r.ensureSiteAccess(ctx, adminID, req.SiteID); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.SiteID == 0 {
|
|
return nil, ErrInvalidPeriod
|
|
}
|
|
var batch model.DistributionSettlementBatch
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
batch = model.DistributionSettlementBatch{SiteID: req.SiteID, BatchNo: "DS" + time.Now().Format("20060102150405.000000000"), PeriodStart: req.PeriodStart, PeriodEnd: req.PeriodEnd, Status: "draft", CreatedBy: &adminID}
|
|
if err := tx.Create(&batch).Error; err != nil {
|
|
return err
|
|
}
|
|
// 聚合周期内尚未进入任何批次的待结算订单财务快照。
|
|
var financials []model.DistributionOrderFinancial
|
|
if err := tx.Where("site_id = ? AND settlement_status = 'pending'", req.SiteID).
|
|
Where("created_at >= ? AND created_at < ?", req.PeriodStart, req.PeriodEnd).
|
|
Where("NOT EXISTS (SELECT 1 FROM distribution_settlement_items i WHERE i.order_id = distribution_order_financials.order_id)").
|
|
Order("id ASC").Find(&financials).Error; err != nil {
|
|
return err
|
|
}
|
|
total := int64(0)
|
|
for _, financial := range financials {
|
|
var parties []model.DistributionOrderParty
|
|
if err := tx.Where("order_id = ?", financial.OrderID).Find(&parties).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, party := range parties {
|
|
item := model.DistributionSettlementItem{BatchID: batch.ID, OrderID: financial.OrderID, PartyID: party.ID, AmountCent: party.IncomeAmountCent, Status: "pending"}
|
|
if err := tx.Create(&item).Error; err != nil {
|
|
return err
|
|
}
|
|
total += party.IncomeAmountCent
|
|
}
|
|
}
|
|
batch.TotalAmountCent = total
|
|
return tx.Model(&model.DistributionSettlementBatch{}).Where("id = ?", batch.ID).
|
|
Update("total_amount_cent", total).Error
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &SettlementBatchDTO{ID: batch.ID, SiteID: batch.SiteID, BatchNo: batch.BatchNo, PeriodStart: batch.PeriodStart, PeriodEnd: batch.PeriodEnd, TotalAmountCent: batch.TotalAmountCent, Status: batch.Status}, nil
|
|
}
|
|
|
|
func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64) (*SettlementBatchDTO, error) {
|
|
var row model.DistributionSettlementBatch
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrSettlementBatchNotFound
|
|
}
|
|
return err
|
|
}
|
|
if err := r.ensureSiteAccess(ctx, adminID, row.SiteID); err != nil {
|
|
return err
|
|
}
|
|
if row.Status == "settled" {
|
|
return ErrBatchAlreadySettled
|
|
}
|
|
var items []model.DistributionSettlementItem
|
|
if err := tx.Where("batch_id = ?", row.ID).Find(&items).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(items) == 0 {
|
|
return ErrSettlementBatchEmpty
|
|
}
|
|
var total int64
|
|
for _, item := range items {
|
|
total += item.AmountCent
|
|
}
|
|
if total != row.TotalAmountCent {
|
|
return ErrSettlementTotalMismatch
|
|
}
|
|
now := time.Now()
|
|
if err := tx.Model(&model.DistributionSettlementItem{}).Where("batch_id = ?", row.ID).
|
|
Update("status", "settled").Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&model.DistributionOrderFinancial{}).
|
|
Where("order_id IN (?)", tx.Model(&model.DistributionSettlementItem{}).
|
|
Select("order_id").Where("batch_id = ?", row.ID)).
|
|
Updates(map[string]any{"settlement_status": "settled", "settled_at": now}).Error; err != nil {
|
|
return err
|
|
}
|
|
row.Status = "settled"
|
|
row.SettledAt = &now
|
|
return tx.Save(&row).Error
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &SettlementBatchDTO{ID: row.ID, SiteID: row.SiteID, BatchNo: row.BatchNo, PeriodStart: row.PeriodStart, PeriodEnd: row.PeriodEnd, TotalAmountCent: row.TotalAmountCent, Status: row.Status}, nil
|
|
}
|
|
|
|
func (r *Repository) CreateFinancialSnapshot(ctx context.Context, adminID, orderID uint64, req CreateFinancialSnapshotRequest) (*DistributionOrderDTO, error) {
|
|
if req.SiteID == 0 || orderID == 0 {
|
|
return nil, ErrInvalidAmount
|
|
}
|
|
if err := r.ensureSiteAccess(ctx, adminID, req.SiteID); err != nil {
|
|
return nil, err
|
|
}
|
|
var rule model.DistributionRuleVersion
|
|
if err := r.db.WithContext(ctx).First(&rule, req.RuleVersionID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrRuleNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
var orderExists int64
|
|
if err := r.db.WithContext(ctx).Model(&model.RentalOrder{}).Where("id = ?", orderID).Count(&orderExists).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if orderExists == 0 {
|
|
return nil, ErrOrderNotFound
|
|
}
|
|
// 手工补数只允许创建,不允许覆盖支付事务写入的真实快照。
|
|
var existing int64
|
|
if err := r.db.WithContext(ctx).Model(&model.DistributionOrderFinancial{}).
|
|
Where("order_id = ?", orderID).Count(&existing).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if existing > 0 {
|
|
return nil, ErrOrderSnapshotExists
|
|
}
|
|
partyInputs := make([]PartyProfitInput, 0, len(req.Parties))
|
|
for _, party := range req.Parties {
|
|
partyInputs = append(partyInputs, PartyProfitInput{PartyRole: party.PartyRole, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent})
|
|
}
|
|
result, err := CalculateSettlement(Rule{Mode: rule.Mode, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, PaymentFeeBPS: rule.PaymentFeeBPS, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS}, SettlementInput{GrossPaidAmountCent: req.GrossPaidAmountCent, RefundAmountCent: req.RefundAmountCent, MarkupAmountCent: req.MarkupAmountCent, GrossProfitByParty: partyInputs})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
financial := model.DistributionOrderFinancial{OrderID: orderID, SiteID: req.SiteID, RuleVersionID: req.RuleVersionID, GrossPaidAmountCent: result.GrossPaidAmountCent, RefundAmountCent: result.RefundAmountCent, NetPaidAmountCent: result.NetPaidAmountCent, PaymentFeeBPS: rule.PaymentFeeBPS, PaymentFeeCent: result.PaymentFeeCent, ServiceFeeValue: rule.ServiceFeeValue, ServiceFeeUnit: rule.ServiceFeeUnit, ServiceFeeCent: result.ServiceFeeCent, MarkupAmountCent: result.MarkupAmountCent, TaxEnabled: rule.TaxEnabled, TaxRateBPS: rule.TaxRateBPS, TaxAmountCent: result.TaxAmountCent, SplitBaseAmountCent: result.SplitBaseAmountCent, RoundingAdjustmentCent: SplitRemainderCent(result.SplitBaseAmountCent, SplitSettlement(Rule{Mode: rule.Mode}, result.SplitBaseAmountCent, result.MarkupAmountCent)), SettlementStatus: "pending"}
|
|
if err := tx.Create(&financial).Error; err != nil {
|
|
return err
|
|
}
|
|
for idx, party := range req.Parties {
|
|
tax := int64(0)
|
|
if idx < len(result.TaxByParty) {
|
|
tax = result.TaxByParty[idx].TaxAmountCent
|
|
}
|
|
profit := party.IncomeAmountCent - party.CostAmountCent
|
|
row := model.DistributionOrderParty{OrderID: orderID, SiteID: req.SiteID, PartyRole: party.PartyRole, PartyType: party.PartyType, PartyID: party.PartyID, Ratio: party.Ratio, IncomeAmountCent: party.IncomeAmountCent, CostAmountCent: party.CostAmountCent, ProfitAmountCent: profit, TaxAmountCent: tax}
|
|
if err := tx.Create(&row).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := tx.Model(&model.RentalOrder{}).Where("id = ?", orderID).Updates(map[string]any{"distribution_site_id": req.SiteID, "distribution_rule_version_id": req.RuleVersionID}).Error; err != nil {
|
|
return err
|
|
}
|
|
return auditlog.Append(tx, auditlog.Entry{
|
|
ActorType: "admin", ActorID: adminID, Action: "distribution_financial_snapshot_create", BizType: "distribution_order", BizID: &orderID,
|
|
Detail: map[string]any{"site_id": req.SiteID, "rule_version_id": req.RuleVersionID, "gross_paid_amount_cent": req.GrossPaidAmountCent, "refund_amount_cent": req.RefundAmountCent, "parties": len(req.Parties)},
|
|
})
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return r.FindOrder(ctx, adminID, orderID)
|
|
}
|
|
|
|
func (r *Repository) ListSettlementItems(ctx context.Context, adminID, batchID uint64) ([]SettlementItemDTO, error) {
|
|
var batch model.DistributionSettlementBatch
|
|
if err := r.db.WithContext(ctx).First(&batch, batchID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrSettlementBatchNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if err := r.ensureSiteAccess(ctx, adminID, batch.SiteID); err != nil {
|
|
return nil, err
|
|
}
|
|
var rows []struct {
|
|
model.DistributionSettlementItem
|
|
PartyRole string
|
|
}
|
|
if err := r.db.WithContext(ctx).
|
|
Table("distribution_settlement_items i").
|
|
Select("i.id, i.order_id, i.amount_cent, i.status, p.party_role").
|
|
Joins("JOIN distribution_order_parties p ON p.id = i.party_id").
|
|
Where("i.batch_id = ?", batchID).
|
|
Order("i.id ASC").Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]SettlementItemDTO, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, SettlementItemDTO{ID: row.ID, OrderID: row.OrderID, PartyRole: row.PartyRole, AmountCent: row.AmountCent, Status: row.Status})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (r *Repository) LoginMember(ctx context.Context, username, password string) (*model.AdminUser, *DistributionCurrentMemberDTO, error) {
|
|
if r.db == nil {
|
|
return nil, nil, ErrDependencyUnavailable
|
|
}
|
|
var admin model.AdminUser
|
|
if err := r.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil, ErrInvalidCredential
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
if admin.Status != "active" {
|
|
return nil, nil, ErrAdminDisabled
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
|
|
return nil, nil, ErrInvalidCredential
|
|
}
|
|
|
|
var members []model.DistributionSiteMember
|
|
if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", admin.ID).Find(&members).Error; err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
dto := DistributionCurrentMemberDTO{
|
|
AdminID: admin.ID,
|
|
Username: admin.Username,
|
|
Nickname: admin.Nickname,
|
|
IsSuperadmin: len(members) == 0,
|
|
}
|
|
if len(members) > 0 {
|
|
var site model.DistributionSite
|
|
if err := r.db.WithContext(ctx).First(&site, members[0].SiteID).Error; err == nil {
|
|
dto.SiteID = &site.ID
|
|
dto.SiteCode = site.SiteCode
|
|
dto.SiteName = site.SiteName
|
|
dto.Domain = site.Domain
|
|
dto.MemberType = members[0].MemberType
|
|
}
|
|
}
|
|
|
|
now := time.Now()
|
|
admin.LastLoginAt = &now
|
|
_ = r.db.WithContext(ctx).Model(&admin).Update("last_login_at", now).Error
|
|
|
|
if r.redis != nil {
|
|
if dto.IsSuperadmin {
|
|
raw, _ := json.Marshal([]string{"*"})
|
|
_ = r.redis.Set(ctx, fmt.Sprintf("admin:perms:%d", admin.ID), string(raw), 2*time.Hour).Err()
|
|
} else {
|
|
var permCodes []string
|
|
if r.db != nil {
|
|
_ = r.db.WithContext(ctx).Table("permissions").
|
|
Select("DISTINCT permissions.code").
|
|
Joins("JOIN role_permissions rp ON rp.permission_id = permissions.id").
|
|
Joins("JOIN admin_user_roles aur ON aur.role_id = rp.role_id").
|
|
Where("aur.admin_user_id = ?", admin.ID).
|
|
Pluck("code", &permCodes).Error
|
|
}
|
|
if len(permCodes) == 0 {
|
|
permCodes = []string{
|
|
"distribution:site_view",
|
|
"distribution:rule_view",
|
|
"distribution:order_view",
|
|
"distribution:account_view",
|
|
"distribution:account_display",
|
|
"distribution:payment_view",
|
|
"distribution:payment_manage",
|
|
"distribution:settlement_view",
|
|
"distribution:settlement_manage",
|
|
}
|
|
}
|
|
raw, _ := json.Marshal(permCodes)
|
|
_ = r.redis.Set(ctx, fmt.Sprintf("admin:perms:%d", admin.ID), string(raw), 2*time.Hour).Err()
|
|
}
|
|
}
|
|
|
|
return &admin, &dto, nil
|
|
}
|
|
|
|
func (r *Repository) GetMemberProfile(ctx context.Context, adminID uint64) (*DistributionCurrentMemberDTO, error) {
|
|
if r.db == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
var admin model.AdminUser
|
|
if err := r.db.WithContext(ctx).First(&admin, adminID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var members []model.DistributionSiteMember
|
|
if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
dto := DistributionCurrentMemberDTO{
|
|
AdminID: admin.ID,
|
|
Username: admin.Username,
|
|
Nickname: admin.Nickname,
|
|
IsSuperadmin: len(members) == 0,
|
|
}
|
|
if len(members) > 0 {
|
|
var site model.DistributionSite
|
|
if err := r.db.WithContext(ctx).First(&site, members[0].SiteID).Error; err == nil {
|
|
dto.SiteID = &site.ID
|
|
dto.SiteCode = site.SiteCode
|
|
dto.SiteName = site.SiteName
|
|
dto.Domain = site.Domain
|
|
dto.MemberType = members[0].MemberType
|
|
}
|
|
}
|
|
return &dto, nil
|
|
}
|
|
|
|
func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*OnboardSiteResponse, error) {
|
|
if r.db == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
var count int64
|
|
if err := r.db.WithContext(ctx).Model(&model.DistributionSite{}).
|
|
Where("site_code = ? OR domain = ?", req.SiteCode, req.Domain).Count(&count).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if count > 0 {
|
|
return nil, ErrDuplicateSiteCodeOrDomain
|
|
}
|
|
if err := r.db.WithContext(ctx).Model(&model.AdminUser{}).
|
|
Where("username = ?", req.AdminUsername).Count(&count).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if count > 0 {
|
|
return nil, ErrDuplicateUsername
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp OnboardSiteResponse
|
|
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
displayEnabled := true
|
|
if req.DisplayAccountEnabled != nil {
|
|
displayEnabled = *req.DisplayAccountEnabled
|
|
}
|
|
taxEnabled := false
|
|
if req.TaxEnabled != nil {
|
|
taxEnabled = *req.TaxEnabled
|
|
}
|
|
taxRate := int64(100)
|
|
if req.TaxRateBPS != nil {
|
|
taxRate = *req.TaxRateBPS
|
|
}
|
|
|
|
site := model.DistributionSite{
|
|
SiteCode: req.SiteCode,
|
|
SiteName: req.SiteName,
|
|
Domain: req.Domain,
|
|
Status: "active",
|
|
DisplayAccountEnabled: displayEnabled,
|
|
TaxEnabled: taxEnabled,
|
|
TaxRateBPS: taxRate,
|
|
}
|
|
if err := tx.Create(&site).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
nickname := req.AdminNickname
|
|
if nickname == "" {
|
|
nickname = req.SiteName + "管理员"
|
|
}
|
|
adminUser := model.AdminUser{
|
|
Username: req.AdminUsername,
|
|
PasswordHash: string(hash),
|
|
Nickname: nickname,
|
|
Status: "active",
|
|
TokenVersion: 1,
|
|
PasswordMustChange: false,
|
|
}
|
|
if err := tx.Create(&adminUser).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
var roleID uint64
|
|
if err := tx.Table("roles").Where("code = ?", "ops").Pluck("id", &roleID).Error; err == nil && roleID > 0 {
|
|
_ = tx.Exec("INSERT IGNORE INTO admin_user_roles (admin_user_id, role_id) VALUES (?, ?)", adminUser.ID, roleID).Error
|
|
}
|
|
|
|
memberType := req.MemberType
|
|
if memberType == "" {
|
|
memberType = "operator"
|
|
}
|
|
member := model.DistributionSiteMember{
|
|
SiteID: site.ID,
|
|
AdminUserID: adminUser.ID,
|
|
MemberType: memberType,
|
|
Status: "active",
|
|
DataScope: "site",
|
|
}
|
|
if err := tx.Create(&member).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
distReceive := req.DistributorReceiveRatio
|
|
if distReceive == 0 {
|
|
distReceive = 3.5
|
|
}
|
|
platSell := req.PlatformSellRatio
|
|
if platSell == 0 {
|
|
platSell = 4.0
|
|
}
|
|
serviceFee := req.ServiceFeeValue
|
|
if serviceFee == 0 {
|
|
serviceFee = 2.5
|
|
}
|
|
|
|
rule := model.DistributionRuleVersion{
|
|
SiteID: &site.ID,
|
|
Mode: ModeTwoPartyDistributorReceive,
|
|
Version: 1,
|
|
Status: "active",
|
|
DistributorReceiveRatio: distReceive,
|
|
PlatformSellRatio: platSell,
|
|
ServiceFeeValue: serviceFee,
|
|
ServiceFeeUnit: ServiceFeeUnitFixedAmount,
|
|
PaymentFeeBPS: 50,
|
|
TaxEnabled: taxEnabled,
|
|
TaxRateBPS: taxRate,
|
|
EffectiveFrom: time.Now(),
|
|
}
|
|
if err := tx.Create(&rule).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if displayEnabled {
|
|
var activeListings []model.RentalListing
|
|
if err := tx.Where("status = ? AND review_status = ?", "published", "approved").Order("id ASC").Find(&activeListings).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(activeListings) > 0 {
|
|
siteAccounts := make([]model.DistributionSiteAccount, 0, len(activeListings))
|
|
seenAccount := make(map[uint64]bool)
|
|
for _, l := range activeListings {
|
|
if seenAccount[l.AccountID] {
|
|
continue
|
|
}
|
|
seenAccount[l.AccountID] = true
|
|
siteAccounts = append(siteAccounts, model.DistributionSiteAccount{
|
|
SiteID: site.ID,
|
|
AccountID: l.AccountID,
|
|
ListingID: l.ID,
|
|
DisplayStatus: "enabled",
|
|
})
|
|
}
|
|
if len(siteAccounts) > 0 {
|
|
if err := tx.Create(&siteAccounts).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
resp = OnboardSiteResponse{
|
|
Site: siteDTO(site),
|
|
Rule: ruleDTO(rule),
|
|
Member: DistributionCurrentMemberDTO{
|
|
AdminID: adminUser.ID,
|
|
Username: adminUser.Username,
|
|
Nickname: adminUser.Nickname,
|
|
IsSuperadmin: false,
|
|
SiteID: &site.ID,
|
|
SiteCode: site.SiteCode,
|
|
SiteName: site.SiteName,
|
|
Domain: site.Domain,
|
|
MemberType: member.MemberType,
|
|
},
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
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"`
|
|
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(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 {
|
|
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: row.PriceCent,
|
|
DepositAmountCent: row.DepositAmountCent,
|
|
InTransaction: row.InTransaction,
|
|
SiteCode: site.SiteCode,
|
|
SiteName: site.SiteName,
|
|
})
|
|
}
|
|
|
|
return &PublicDistributionListingsResult{
|
|
Items: items,
|
|
Total: total,
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
}, nil
|
|
}
|
|
|
|
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 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
|
|
}
|
|
|
|
return &PublicSiteInfoDTO{
|
|
SiteCode: site.SiteCode,
|
|
SiteName: site.SiteName,
|
|
Domain: site.Domain,
|
|
Status: site.Status,
|
|
DisplayAccountEnabled: site.DisplayAccountEnabled,
|
|
DefaultSupportGroupID: site.DefaultSupportGroupID,
|
|
}, nil
|
|
}
|