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