feat(distribution): 完善分销收号资产双视图、商户提现、自主加价、品牌装修与数据大盘

This commit is contained in:
yml2213
2026-09-08 16:05:30 +08:00
parent fd4e490ed3
commit 2c4e87475a
19 changed files with 3145 additions and 108 deletions
+6
View File
@@ -10,6 +10,11 @@ type DistributionSite struct {
Domain string `gorm:"size:255;not null;uniqueIndex" json:"domain"`
Status string `gorm:"size:16;not null;default:'active';index" json:"status"`
DisplayAccountEnabled bool `gorm:"not null;default:true" json:"display_account_enabled"`
LogoURL string `gorm:"size:512;not null;default:''" json:"logo_url"`
FaviconURL string `gorm:"size:512;not null;default:''" json:"favicon_url"`
BannersJSON string `gorm:"type:text" json:"banners_json"`
AnnouncementsJSON string `gorm:"type:text" json:"announcements_json"`
FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"`
DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"`
TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"`
TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"`
@@ -40,6 +45,7 @@ type DistributionSiteAccount struct {
AccountID uint64 `gorm:"not null;uniqueIndex:uk_distribution_site_account;index" json:"account_id"`
ListingID uint64 `gorm:"not null;index" json:"listing_id"`
DisplayStatus string `gorm:"size:16;not null;default:'enabled';index" json:"display_status"`
MarkupAmountCent int64 `gorm:"not null;default:0" json:"markup_amount_cent"`
DisplayStartedAt *time.Time `json:"display_started_at,omitempty"`
DisplayEndedAt *time.Time `json:"display_ended_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
@@ -9,6 +9,11 @@ type SiteDTO struct {
Domain string `json:"domain"`
Status string `json:"status"`
DisplayAccountEnabled bool `json:"display_account_enabled"`
LogoURL string `json:"logo_url"`
FaviconURL string `json:"favicon_url"`
BannersJSON string `json:"banners_json"`
AnnouncementsJSON string `json:"announcements_json"`
FooterInfoJSON string `json:"footer_info_json"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
TaxEnabled bool `json:"tax_enabled"`
TaxRateBPS int64 `json:"tax_rate_bps"`
@@ -34,6 +39,11 @@ type UpdateSiteRequest struct {
DefaultSupportGroupID *uint64 `json:"default_support_group_id"`
TaxEnabled *bool `json:"tax_enabled"`
TaxRateBPS *int64 `json:"tax_rate_bps"`
LogoURL *string `json:"logo_url"`
FaviconURL *string `json:"favicon_url"`
BannersJSON *string `json:"banners_json"`
AnnouncementsJSON *string `json:"announcements_json"`
FooterInfoJSON *string `json:"footer_info_json"`
}
type RuleDTO struct {
@@ -126,6 +136,7 @@ type AccountDisplayDTO struct {
AccountID uint64 `json:"account_id"`
ListingID uint64 `json:"listing_id"`
DisplayStatus string `json:"display_status"`
MarkupAmountCent int64 `json:"markup_amount_cent"`
ListingNo string `json:"listing_no,omitempty"`
Title string `json:"title,omitempty"`
GameName string `json:"game_name,omitempty"`
@@ -157,6 +168,7 @@ type PublicDistributionListingDTO struct {
HafCoinAmount int64 `json:"haf_coin_amount"`
PriceCent int64 `json:"price_cent"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
MarkupAmountCent int64 `json:"markup_amount_cent"`
InTransaction bool `json:"in_transaction"`
SiteCode string `json:"site_code"`
SiteName string `json:"site_name"`
@@ -175,6 +187,11 @@ type PublicSiteInfoDTO struct {
Domain string `json:"domain"`
Status string `json:"status"`
DisplayAccountEnabled bool `json:"display_account_enabled"`
LogoURL string `json:"logo_url"`
FaviconURL string `json:"favicon_url"`
BannersJSON string `json:"banners_json"`
AnnouncementsJSON string `json:"announcements_json"`
FooterInfoJSON string `json:"footer_info_json"`
DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"`
}
@@ -283,3 +300,135 @@ type OnboardSiteResponse struct {
Member DistributionCurrentMemberDTO `json:"member"`
}
// ReceivedListingDTO 是分销站点收到的账号资产信息
type ReceivedListingDTO struct {
ListingID uint64 `json:"listing_id"`
ListingNo string `json:"listing_no"`
SiteID uint64 `json:"site_id"`
AccountID uint64 `json:"account_id"`
OwnerID uint64 `json:"owner_id"`
OwnerPhone string `json:"owner_phone"`
OwnerNickname string `json:"owner_nickname"`
Title string `json:"title"`
GameName string `json:"game_name"`
ServerRegion string `json:"server_region"`
LoginPlatform string `json:"login_platform"`
RankLevel string `json:"rank_level"`
HafCoinAmount int64 `json:"haf_coin_amount"`
PriceCent int64 `json:"price_cent"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
Status string `json:"status"`
ReviewStatus string `json:"review_status"`
ReviewReason string `json:"review_reason"`
PublishedAt *time.Time `json:"published_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type ReceivedListingQuery struct {
SiteID uint64
Status string
ReviewStatus string
GameName string
Keyword string
Page int
PageSize int
Size int
}
type ReceivedListingPage struct {
Items []ReceivedListingDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type UpdateReceivedListingStatusRequest struct {
Status string `json:"status" binding:"required,oneof=published offline"`
}
// 站点品牌与装修 DTO
type UpdateSiteBrandingRequest struct {
LogoURL string `json:"logo_url"`
FaviconURL string `json:"favicon_url"`
BannersJSON string `json:"banners_json"`
AnnouncementsJSON string `json:"announcements_json"`
FooterInfoJSON string `json:"footer_info_json"`
}
// 自主加价 DTO
type UpdateAccountMarkupRequest struct {
MarkupAmountCent int64 `json:"markup_amount_cent"`
}
type BatchUpdateAccountMarkupRequest struct {
ListingIDs []uint64 `json:"listing_ids" binding:"required"`
MarkupAmountCent int64 `json:"markup_amount_cent"`
}
// 商户钱包与提现 DTO
type SiteWalletDTO struct {
SiteID uint64 `json:"site_id"`
AvailableBalanceCent int64 `json:"available_balance_cent"`
FrozenBalanceCent int64 `json:"frozen_balance_cent"`
TotalSettledCent int64 `json:"total_settled_cent"`
TotalWithdrawnCent int64 `json:"total_withdrawn_cent"`
}
type ApplySiteWithdrawalRequest struct {
SiteID uint64 `json:"site_id" binding:"required"`
AmountCent int64 `json:"amount_cent" binding:"required,gt=0"`
AccountType string `json:"account_type" binding:"required,oneof=alipay bank"`
AccountName string `json:"account_name" binding:"required"`
AccountNo string `json:"account_no" binding:"required"`
BankName string `json:"bank_name"`
BankBranch string `json:"bank_branch"`
}
type SiteWithdrawalDTO struct {
ID uint64 `json:"id"`
WithdrawNo string `json:"withdraw_no"`
SiteID uint64 `json:"site_id"`
AmountCent int64 `json:"amount_cent"`
FeeCent int64 `json:"fee_cent"`
ActualAmountCent int64 `json:"actual_amount_cent"`
AccountType string `json:"account_type"`
AccountName string `json:"account_name"`
AccountNo string `json:"account_no"`
BankName string `json:"bank_name"`
BankBranch string `json:"bank_branch"`
Status string `json:"status"`
ReviewRemark string `json:"review_remark"`
PaymentProofURL string `json:"payment_proof_url"`
PaymentRemark string `json:"payment_remark"`
PaidAt *time.Time `json:"paid_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type SiteWithdrawalPage struct {
Items []SiteWithdrawalDTO `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// 运营大盘统计 DTO
type DistributionAnalyticsDTO struct {
TodayReceivedCount int64 `json:"today_received_count"`
TotalReceivedCount int64 `json:"total_received_count"`
ActiveListingCount int64 `json:"active_listing_count"`
TodayOrderCount int64 `json:"today_order_count"`
TodayGMVCent int64 `json:"today_gmv_cent"`
TodayProfitCent int64 `json:"today_profit_cent"`
MonthProfitCent int64 `json:"month_profit_cent"`
AvailableBalanceCent int64 `json:"available_balance_cent"`
TrendDays []DistributionTrendPoint `json:"trend_days"`
}
type DistributionTrendPoint struct {
Date string `json:"date"`
ReceivedCount int64 `json:"received_count"`
OrderCount int64 `json:"order_count"`
GMVCent int64 `json:"gmv_cent"`
ProfitCent int64 `json:"profit_cent"`
}
@@ -486,6 +486,219 @@ func (h *Handler) OnboardSite(c *gin.Context) {
response.Created(c, resp)
}
func (h *Handler) resolveSiteID(c *gin.Context) (uint64, error) {
adminID, _ := currentAdminID(c)
var requested uint64
if raw := c.Param("id"); raw != "" {
if id, err := strconv.ParseUint(raw, 10, 64); err == nil && id > 0 {
requested = id
}
}
if requested == 0 {
if q := parseQueryUint(c, "site_id"); q > 0 {
requested = uint64(q)
}
}
return h.service.ScopeSiteID(c.Request.Context(), adminID, requested)
}
func (h *Handler) UpdateSiteBranding(c *gin.Context) {
siteID, err := h.resolveSiteID(c)
if err != nil {
writeError(c, err)
return
}
if siteID == 0 {
response.BadRequest(c, "未指定分销站点")
return
}
var req UpdateSiteBrandingRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "品牌装修参数格式不正确")
return
}
adminID, _ := currentAdminID(c)
dto, err := h.service.UpdateSiteBranding(c.Request.Context(), adminID, siteID, req)
if err != nil {
writeError(c, err)
return
}
response.OK(c, dto)
}
func (h *Handler) UpdateAccountMarkup(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateAccountMarkupRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "加价参数格式不正确")
return
}
adminID, _ := currentAdminID(c)
dto, err := h.service.UpdateAccountMarkup(c.Request.Context(), adminID, id, req.MarkupAmountCent)
if err != nil {
writeError(c, err)
return
}
response.OK(c, dto)
}
func (h *Handler) BatchUpdateAccountMarkup(c *gin.Context) {
siteID, err := h.resolveSiteID(c)
if err != nil {
writeError(c, err)
return
}
var req BatchUpdateAccountMarkupRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "批量加价参数格式不正确")
return
}
if siteID == 0 {
response.BadRequest(c, "未指定分销站点")
return
}
adminID, _ := currentAdminID(c)
if err := h.service.BatchUpdateAccountMarkup(c.Request.Context(), adminID, siteID, req); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"updated": true})
}
func (h *Handler) ListReceivedListings(c *gin.Context) {
siteID, err := h.resolveSiteID(c)
if err != nil {
writeError(c, err)
return
}
if siteID == 0 {
response.BadRequest(c, "未指定分销站点")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
q := ReceivedListingQuery{
Page: page,
PageSize: pageSize,
Status: c.Query("status"),
ReviewStatus: c.Query("review_status"),
GameName: c.Query("game_name"),
Keyword: c.Query("keyword"),
}
adminID, _ := currentAdminID(c)
resp, err := h.service.ListReceivedListings(c.Request.Context(), adminID, siteID, q)
if err != nil {
writeError(c, err)
return
}
response.OK(c, resp)
}
func (h *Handler) UpdateReceivedListingStatus(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateReceivedListingStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "状态参数格式不正确")
return
}
adminID, _ := currentAdminID(c)
if err := h.service.UpdateReceivedListingStatus(c.Request.Context(), adminID, id, req.Status); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"updated": true})
}
func (h *Handler) GetSiteWallet(c *gin.Context) {
siteID, err := h.resolveSiteID(c)
if err != nil {
writeError(c, err)
return
}
if siteID == 0 {
response.BadRequest(c, "未指定分销站点")
return
}
adminID, _ := currentAdminID(c)
walletDTO, err := h.service.GetSiteWallet(c.Request.Context(), adminID, siteID)
if err != nil {
writeError(c, err)
return
}
response.OK(c, walletDTO)
}
func (h *Handler) ApplySiteWithdrawal(c *gin.Context) {
siteID, err := h.resolveSiteID(c)
if err != nil {
writeError(c, err)
return
}
if siteID == 0 {
response.BadRequest(c, "未指定分销站点")
return
}
var req ApplySiteWithdrawalRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "提现申请参数格式不正确")
return
}
adminID, _ := currentAdminID(c)
dto, err := h.service.ApplySiteWithdrawal(c.Request.Context(), adminID, siteID, req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, dto)
}
func (h *Handler) ListSiteWithdrawals(c *gin.Context) {
siteID, err := h.resolveSiteID(c)
if err != nil {
writeError(c, err)
return
}
if siteID == 0 {
response.BadRequest(c, "未指定分销站点")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
adminID, _ := currentAdminID(c)
pageResult, err := h.service.ListSiteWithdrawals(c.Request.Context(), adminID, siteID, page, pageSize)
if err != nil {
writeError(c, err)
return
}
response.OK(c, pageResult)
}
func (h *Handler) GetDashboardAnalytics(c *gin.Context) {
siteID, err := h.resolveSiteID(c)
if err != nil {
writeError(c, err)
return
}
if siteID == 0 {
response.BadRequest(c, "未指定分销站点")
return
}
adminID, _ := currentAdminID(c)
analytics, err := h.service.GetDashboardAnalytics(c.Request.Context(), adminID, siteID)
if err != nil {
writeError(c, err)
return
}
response.OK(c, analytics)
}
func isHTTPSRequest(c *gin.Context) bool {
if c.Request.TLS != nil {
return true
@@ -2,6 +2,8 @@ package distribution
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -10,6 +12,7 @@ import (
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/wallet"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
@@ -187,6 +190,21 @@ func (r *Repository) UpdateSite(ctx context.Context, adminID, id uint64, req Upd
if req.TaxRateBPS != nil {
row.TaxRateBPS = *req.TaxRateBPS
}
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 := r.db.WithContext(ctx).Save(&row).Error; err != nil {
return nil, err
}
@@ -284,7 +302,24 @@ func (r *Repository) UpdateRuleStatus(ctx context.Context, adminID, id uint64, s
}
func siteDTO(row model.DistributionSite) SiteDTO {
return SiteDTO{ID: row.ID, SiteCode: row.SiteCode, SiteName: row.SiteName, Domain: row.Domain, Status: row.Status, DisplayAccountEnabled: row.DisplayAccountEnabled, DefaultSupportGroupID: row.DefaultSupportGroupID, TaxEnabled: row.TaxEnabled, TaxRateBPS: row.TaxRateBPS, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
return SiteDTO{
ID: row.ID,
SiteCode: row.SiteCode,
SiteName: row.SiteName,
Domain: row.Domain,
Status: row.Status,
DisplayAccountEnabled: row.DisplayAccountEnabled,
DefaultSupportGroupID: row.DefaultSupportGroupID,
TaxEnabled: row.TaxEnabled,
TaxRateBPS: row.TaxRateBPS,
LogoURL: row.LogoURL,
FaviconURL: row.FaviconURL,
BannersJSON: row.BannersJSON,
AnnouncementsJSON: row.AnnouncementsJSON,
FooterInfoJSON: row.FooterInfoJSON,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
func ruleDTO(row model.DistributionRuleVersion) RuleDTO {
@@ -352,6 +387,7 @@ func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, sta
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"`
ListingNo string `gorm:"column:listing_no"`
Title string `gorm:"column:title"`
GameName string `gorm:"column:game_name"`
@@ -374,6 +410,7 @@ func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, sta
dsa.account_id,
dsa.listing_id,
dsa.display_status,
COALESCE(dsa.markup_amount_cent, 0) AS markup_amount_cent,
COALESCE(l.listing_no, '') AS listing_no,
COALESCE(ga.title, '') AS title,
COALESCE(ga.game_name, '') AS game_name,
@@ -404,6 +441,7 @@ func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, sta
AccountID: row.AccountID,
ListingID: row.ListingID,
DisplayStatus: row.DisplayStatus,
MarkupAmountCent: row.MarkupAmountCent,
ListingNo: row.ListingNo,
Title: row.Title,
GameName: row.GameName,
@@ -678,6 +716,18 @@ func (r *Repository) MarkSettlementBatch(ctx context.Context, adminID, id uint64
Updates(map[string]any{"settlement_status": "settled", "settled_at": now}).Error; 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
@@ -1113,6 +1163,7 @@ func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string
HafCoinAmount int64 `gorm:"column:haf_coin_amount"`
PriceCent int64 `gorm:"column:price_cent"`
DepositAmountCent int64 `gorm:"column:deposit_amount_cent"`
MarkupAmountCent int64 `gorm:"column:markup_amount_cent"`
InTransaction bool `gorm:"column:in_transaction"`
}
@@ -1139,8 +1190,9 @@ func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string
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.price_cent, 0) + COALESCE(dsa.markup_amount_cent, 0) AS price_cent,
COALESCE(l.deposit_amount_cent, 0) AS deposit_amount_cent,
COALESCE(dsa.markup_amount_cent, 0) AS markup_amount_cent,
COALESCE(l.in_transaction, 0) AS in_transaction
`).
Order("dsa.id DESC").
@@ -1165,6 +1217,7 @@ func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string
HafCoinAmount: row.HafCoinAmount,
PriceCent: row.PriceCent,
DepositAmountCent: row.DepositAmountCent,
MarkupAmountCent: row.MarkupAmountCent,
InTransaction: row.InTransaction,
SiteCode: site.SiteCode,
SiteName: site.SiteName,
@@ -1211,5 +1264,557 @@ func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string)
Status: site.Status,
DisplayAccountEnabled: site.DisplayAccountEnabled,
DefaultSupportGroupID: site.DefaultSupportGroupID,
LogoURL: site.LogoURL,
FaviconURL: site.FaviconURL,
BannersJSON: site.BannersJSON,
AnnouncementsJSON: site.AnnouncementsJSON,
FooterInfoJSON: site.FooterInfoJSON,
}, 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
}
site.LogoURL = strings.TrimSpace(req.LogoURL)
site.FaviconURL = strings.TrimSpace(req.FaviconURL)
site.BannersJSON = strings.TrimSpace(req.BannersJSON)
site.AnnouncementsJSON = strings.TrimSpace(req.AnnouncementsJSON)
site.FooterInfoJSON = strings.TrimSpace(req.FooterInfoJSON)
if err := r.db.WithContext(ctx).Model(&site).Updates(map[string]any{
"logo_url": site.LogoURL,
"favicon_url": site.FaviconURL,
"banners_json": site.BannersJSON,
"announcements_json": site.AnnouncementsJSON,
"footer_info_json": site.FooterInfoJSON,
}).Error; err != nil {
return nil, err
}
dto := siteDTO(site)
return &dto, nil
}
func (r *Repository) UpdateAccountMarkup(ctx context.Context, adminID, id uint64, markupCent int64) (*AccountDisplayDTO, error) {
if markupCent < 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("markup_amount_cent", markupCent).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
}
if req.MarkupAmountCent < 0 {
return ErrInvalidAmount
}
query := r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}).Where("site_id = ?", siteID)
if len(req.ListingIDs) > 0 {
query = query.Where("listing_id IN (?)", req.ListingIDs)
}
return query.Update("markup_amount_cent", req.MarkupAmountCent).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").
Where("rl.distribution_site_id = ?", siteID)
if q.ReviewStatus != "" {
base = base.Where("rl.review_status = ?", q.ReviewStatus)
}
if q.Status != "" {
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"`
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"`
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,
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,
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:]
}
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,
PriceCent: r.PriceCent,
DepositAmountCent: r.DepositAmountCent,
Status: r.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 (r *Repository) UpdateReceivedListingStatus(ctx context.Context, adminID, listingID uint64, status string) error {
if listingID == 0 {
return errors.New("invalid listing id")
}
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
}
if err := r.ensureSiteAccess(ctx, adminID, *listing.DistributionSiteID); err != nil {
return err
}
if status != "published" && status != "inactive" && status != "closed" {
return errors.New("invalid listing status")
}
return r.db.WithContext(ctx).Model(&listing).Update("status", status).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 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 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,
TotalSettledCent: totalSettled,
TotalWithdrawnCent: totalWithdrawn,
}, 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 errors.New("可提现余额不足")
}
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
}
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).Model(&model.RentalOrder{}).
Where("distribution_site_id = ? AND created_at >= ?", 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 = ? AND ro.created_at >= ?", 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 = ? AND created_at >= ?", 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 = ? AND created_at >= ?", 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"`
Received int64 `gorm:"column:received"`
Orders int64 `gorm:"column:orders"`
GMV int64 `gorm:"column:gmv"`
Profit int64 `gorm:"column:profit"`
}
var tRows []trendRow
startDate := time.Now().AddDate(0, 0, -6).Format("2006-01-02")
dateFormatExpr := "DATE_FORMAT(ro.created_at, '%Y-%m-%d')"
if r.db != nil && r.db.Dialector != nil && r.db.Dialector.Name() == "sqlite" {
dateFormatExpr = "strftime('%Y-%m-%d', ro.created_at)"
}
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 = ? AND ro.created_at >= ?", siteID, startDate+" 00:00:00").
Select(selectTrendSQL).
Group(dateFormatExpr).
Order("date_str ASC").
Scan(&tRows).Error
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")
if tr, ok := tRowMap[dStr]; ok {
trendDays = append(trendDays, DistributionTrendPoint{
Date: dStr,
ReceivedCount: tr.Received,
OrderCount: tr.Orders,
GMVCent: tr.GMV,
ProfitCent: tr.Profit,
})
} else {
trendDays = append(trendDays, DistributionTrendPoint{
Date: dStr,
ReceivedCount: 0,
OrderCount: 0,
GMVCent: 0,
ProfitCent: 0,
})
}
}
return &DistributionAnalyticsDTO{
TodayReceivedCount: todayReceived,
TotalReceivedCount: totalReceived,
ActiveListingCount: activeListings,
TodayOrderCount: todayOrders,
TodayGMVCent: todayGMV,
TodayProfitCent: todayProfit,
MonthProfitCent: monthProfit,
AvailableBalanceCent: wallet.AvailableBalanceCent,
TrendDays: trendDays,
}, nil
}
@@ -23,7 +23,8 @@ func distributionTestDB(t *testing.T) *gorm.DB {
&model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{},
&model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{},
&model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{},
&model.RentalListing{}, &model.GameAccount{},
&model.RentalListing{}, &model.GameAccount{}, &model.User{},
&model.WalletAccount{}, &model.WalletLedger{}, &model.WithdrawalRequest{},
); err != nil {
t.Fatal(err)
}
@@ -390,3 +391,228 @@ func TestGetPublicSiteInfo(t *testing.T) {
}
}
func TestDistributionBrandingAndMarkup(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
site := model.DistributionSite{
SiteCode: "brand_site",
SiteName: "品牌测试站",
Domain: "brand.example.com",
Status: "active",
DisplayAccountEnabled: true,
}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
// 1. Update branding
updated, err := repo.UpdateSiteBranding(t.Context(), 0, site.ID, UpdateSiteBrandingRequest{
LogoURL: "https://cdn.example.com/logo.png",
FaviconURL: "https://cdn.example.com/favicon.ico",
BannersJSON: `[{"image_url":"https://cdn.example.com/b1.jpg"}]`,
AnnouncementsJSON: `[{"title":"开业大吉"}]`,
FooterInfoJSON: `{"copyright":"2026 Brand"}`,
})
if err != nil {
t.Fatalf("UpdateSiteBranding failed: %v", err)
}
if updated.LogoURL != "https://cdn.example.com/logo.png" || updated.FaviconURL != "https://cdn.example.com/favicon.ico" {
t.Fatalf("unexpected branding: %+v", updated)
}
// Verify PublicSiteInfo contains branding
pubInfo, err := repo.GetPublicSiteInfo(t.Context(), "brand_site", "")
if err != nil {
t.Fatalf("GetPublicSiteInfo failed: %v", err)
}
if pubInfo.LogoURL != "https://cdn.example.com/logo.png" {
t.Fatalf("expected LogoURL, got: %+v", pubInfo)
}
// 2. Account Markup
dsa := model.DistributionSiteAccount{
SiteID: site.ID,
AccountID: 101,
ListingID: 201,
DisplayStatus: "enabled",
}
if err := db.Create(&dsa).Error; err != nil {
t.Fatal(err)
}
display, err := repo.UpdateAccountMarkup(t.Context(), 0, dsa.ID, 500)
if err != nil {
t.Fatalf("UpdateAccountMarkup failed: %v", err)
}
if display.MarkupAmountCent != 500 {
t.Fatalf("expected markup 500, got %d", display.MarkupAmountCent)
}
// Batch update markup
if err := repo.BatchUpdateAccountMarkup(t.Context(), 0, site.ID, BatchUpdateAccountMarkupRequest{
ListingIDs: []uint64{201},
MarkupAmountCent: 800,
}); err != nil {
t.Fatalf("BatchUpdateAccountMarkup failed: %v", err)
}
displays, err := repo.ListAccountDisplays(t.Context(), site.ID, "")
if err != nil || len(displays) == 0 || displays[0].MarkupAmountCent != 800 {
t.Fatalf("expected markup 800 after batch, got: %+v", displays)
}
}
func TestDistributionReceivedListingsAndWallet(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
site := model.DistributionSite{
SiteCode: "recv_site",
SiteName: "收号测试站",
Domain: "recv.example.com",
Status: "active",
DisplayAccountEnabled: true,
}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
user := model.User{
Phone: "13800001234",
Nickname: "seller01",
}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
ga := model.GameAccount{
OwnerID: user.ID,
GameName: "delta_force",
ServerRegion: "cn",
LoginPlatform: "pc",
Title: "满配三角洲账号",
}
if err := db.Create(&ga).Error; err != nil {
t.Fatal(err)
}
listing := model.RentalListing{
ListingNo: "RL-99001",
AccountID: ga.ID,
OwnerID: user.ID,
PriceCent: 2000,
DepositAmountCent: 5000,
Status: "published",
ReviewStatus: "approved",
DistributionSiteID: &site.ID,
}
if err := db.Create(&listing).Error; err != nil {
t.Fatal(err)
}
// 1. List received listings
recPage, err := repo.ListReceivedListings(t.Context(), 0, site.ID, ReceivedListingQuery{
Page: 1,
PageSize: 10,
})
if err != nil {
t.Fatalf("ListReceivedListings failed: %v", err)
}
if recPage.Total != 1 || len(recPage.Items) != 1 {
t.Fatalf("expected 1 received listing, got %d", recPage.Total)
}
item := recPage.Items[0]
if item.ListingNo != "RL-99001" || item.GameName != "delta_force" || item.OwnerNickname != "seller01" {
t.Fatalf("unexpected received listing: %+v", item)
}
// Update received listing status
if err := repo.UpdateReceivedListingStatus(t.Context(), 0, listing.ID, "inactive"); err != nil {
t.Fatalf("UpdateReceivedListingStatus failed: %v", err)
}
// 2. Settlement batch settles and credits wallet
rule := model.DistributionRuleVersion{SiteID: &site.ID, Mode: ModeTwoPartyDistributorReceive, Version: 1, Status: "active", EffectiveFrom: time.Now()}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
batch := model.DistributionSettlementBatch{
SiteID: site.ID,
BatchNo: "B-WALLET-01",
PeriodStart: time.Now().Add(-24 * time.Hour),
PeriodEnd: time.Now(),
TotalAmountCent: 10000,
Status: "pending",
}
if err := db.Create(&batch).Error; err != nil {
t.Fatal(err)
}
batchItem := model.DistributionSettlementItem{
BatchID: batch.ID,
OrderID: 888,
PartyID: site.ID,
AmountCent: 10000,
Status: "pending",
}
if err := db.Create(&batchItem).Error; err != nil {
t.Fatal(err)
}
// Mark batch settled -> auto-credits merchant wallet
if _, err := repo.MarkSettlementBatch(t.Context(), 0, batch.ID); err != nil {
t.Fatalf("MarkSettlementBatch failed: %v", err)
}
walletDTO, err := repo.GetSiteWallet(t.Context(), 0, site.ID)
if err != nil {
t.Fatalf("GetSiteWallet failed: %v", err)
}
if walletDTO.AvailableBalanceCent != 10000 || walletDTO.TotalSettledCent != 10000 {
t.Fatalf("expected available balance 10000, got: %+v", walletDTO)
}
// 3. Apply withdrawal
wd, err := repo.ApplySiteWithdrawal(t.Context(), 0, site.ID, ApplySiteWithdrawalRequest{
SiteID: site.ID,
AmountCent: 4000,
AccountType: "alipay",
AccountName: "分销商李四",
AccountNo: "13800005678",
})
if err != nil {
t.Fatalf("ApplySiteWithdrawal failed: %v", err)
}
if wd.AmountCent != 4000 || wd.Status != "pending" {
t.Fatalf("unexpected withdrawal: %+v", wd)
}
// Check balance after freeze
walletAfterWD, err := repo.GetSiteWallet(t.Context(), 0, site.ID)
if err != nil {
t.Fatalf("GetSiteWallet after WD failed: %v", err)
}
if walletAfterWD.AvailableBalanceCent != 6000 || walletAfterWD.FrozenBalanceCent != 4000 {
t.Fatalf("expected available 6000 frozen 4000, got: %+v", walletAfterWD)
}
// 4. List withdrawals
wdPage, err := repo.ListSiteWithdrawals(t.Context(), 0, site.ID, 1, 10)
if err != nil {
t.Fatalf("ListSiteWithdrawals failed: %v", err)
}
if wdPage.Total != 1 || len(wdPage.Items) != 1 {
t.Fatalf("expected 1 withdrawal record, got %d", wdPage.Total)
}
// 5. Dashboard analytics
analytics, err := repo.GetDashboardAnalytics(t.Context(), 0, site.ID)
if err != nil {
t.Fatalf("GetDashboardAnalytics failed: %v", err)
}
if analytics.TotalReceivedCount != 1 || analytics.AvailableBalanceCent != 6000 {
t.Fatalf("unexpected analytics: %+v", analytics)
}
}
@@ -239,4 +239,68 @@ func (s *Service) GetPublicSiteInfo(ctx context.Context, code, domain string) (*
return s.repo.GetPublicSiteInfo(ctx, code, domain)
}
func (s *Service) UpdateSiteBranding(ctx context.Context, adminID, siteID uint64, req UpdateSiteBrandingRequest) (*SiteDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateSiteBranding(ctx, adminID, siteID, req)
}
func (s *Service) UpdateAccountMarkup(ctx context.Context, adminID, id uint64, markupCent int64) (*AccountDisplayDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.UpdateAccountMarkup(ctx, adminID, id, markupCent)
}
func (s *Service) BatchUpdateAccountMarkup(ctx context.Context, adminID, siteID uint64, req BatchUpdateAccountMarkupRequest) error {
if err := s.available(); err != nil {
return err
}
return s.repo.BatchUpdateAccountMarkup(ctx, adminID, siteID, req)
}
func (s *Service) ListReceivedListings(ctx context.Context, adminID, siteID uint64, q ReceivedListingQuery) (*ReceivedListingPage, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListReceivedListings(ctx, adminID, siteID, q)
}
func (s *Service) UpdateReceivedListingStatus(ctx context.Context, adminID, listingID uint64, status string) error {
if err := s.available(); err != nil {
return err
}
return s.repo.UpdateReceivedListingStatus(ctx, adminID, listingID, status)
}
func (s *Service) GetSiteWallet(ctx context.Context, adminID, siteID uint64) (*SiteWalletDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.GetSiteWallet(ctx, adminID, siteID)
}
func (s *Service) ApplySiteWithdrawal(ctx context.Context, adminID, siteID uint64, req ApplySiteWithdrawalRequest) (*SiteWithdrawalDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ApplySiteWithdrawal(ctx, adminID, siteID, req)
}
func (s *Service) ListSiteWithdrawals(ctx context.Context, adminID, siteID uint64, page, size int) (*SiteWithdrawalPage, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.ListSiteWithdrawals(ctx, adminID, siteID, page, size)
}
func (s *Service) GetDashboardAnalytics(ctx context.Context, adminID, siteID uint64) (*DistributionAnalyticsDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.GetDashboardAnalytics(ctx, adminID, siteID)
}
+18
View File
@@ -608,6 +608,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
distributionProtectedRoutes.GET("/auth/me", distributionHandler.Me)
distributionProtectedRoutes.POST("/auth/logout", distributionHandler.Logout)
distributionProtectedRoutes.POST("/sites/:id/sync-accounts", distributionHandler.SyncSiteAccounts)
distributionProtectedRoutes.PUT("/sites/:id/branding", distributionHandler.UpdateSiteBranding)
distributionProtectedRoutes.GET("/sites/:id/wallet", distributionHandler.GetSiteWallet)
distributionProtectedRoutes.POST("/sites/:id/withdrawals", distributionHandler.ApplySiteWithdrawal)
distributionProtectedRoutes.GET("/sites/:id/withdrawals", distributionHandler.ListSiteWithdrawals)
distributionProtectedRoutes.GET("/sites/:id/analytics", distributionHandler.GetDashboardAnalytics)
distributionProtectedRoutes.GET("/received-listings", distributionHandler.ListReceivedListings)
distributionProtectedRoutes.PUT("/received-listings/:id/status", distributionHandler.UpdateReceivedListingStatus)
distributionProtectedRoutes.PUT("/accounts/:id/markup", distributionHandler.UpdateAccountMarkup)
distributionProtectedRoutes.POST("/accounts/batch-markup", distributionHandler.BatchUpdateAccountMarkup)
}
adminRoutes := api.Group("/admin", requireAdmin, middleware.RequireAdminPasswordChanged())
@@ -625,6 +634,13 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.POST("/distribution/sites/onboard", requirePerm("distribution:site_manage"), distributionHandler.OnboardSite)
adminRoutes.POST("/distribution/sites/:id/sync-accounts", requirePerm("distribution:account_display"), distributionHandler.SyncSiteAccounts)
adminRoutes.PUT("/distribution/sites/:id", requirePerm("distribution:site_manage"), distributionHandler.UpdateSite)
adminRoutes.PUT("/distribution/sites/:id/branding", requirePerm("distribution:site_manage"), distributionHandler.UpdateSiteBranding)
adminRoutes.GET("/distribution/sites/:id/wallet", requirePerm("distribution:settlement_view"), distributionHandler.GetSiteWallet)
adminRoutes.POST("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_manage"), distributionHandler.ApplySiteWithdrawal)
adminRoutes.GET("/distribution/sites/:id/withdrawals", requirePerm("distribution:settlement_view"), distributionHandler.ListSiteWithdrawals)
adminRoutes.GET("/distribution/sites/:id/analytics", requirePerm("distribution:site_view"), distributionHandler.GetDashboardAnalytics)
adminRoutes.GET("/distribution/received-listings", requirePerm("distribution:account_view"), distributionHandler.ListReceivedListings)
adminRoutes.PUT("/distribution/received-listings/:id/status", requirePerm("distribution:account_display"), distributionHandler.UpdateReceivedListingStatus)
adminRoutes.GET("/distribution/rules", requirePerm("distribution:rule_view"), distributionHandler.ListRules)
adminRoutes.POST("/distribution/rules", requirePerm("distribution:rule_manage"), distributionHandler.CreateRule)
adminRoutes.PUT("/distribution/rules/:id/status", requirePerm("distribution:rule_manage"), distributionHandler.UpdateRuleStatus)
@@ -633,6 +649,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.POST("/distribution/orders/:id/financial-snapshot", requirePerm("distribution:settlement_manage"), distributionHandler.CreateFinancialSnapshot)
adminRoutes.GET("/distribution/accounts", requirePerm("distribution:account_view"), distributionHandler.ListAccountDisplays)
adminRoutes.PUT("/distribution/accounts/:id/display", requirePerm("distribution:account_display"), distributionHandler.UpdateAccountDisplay)
adminRoutes.PUT("/distribution/accounts/:id/markup", requirePerm("distribution:account_display"), distributionHandler.UpdateAccountMarkup)
adminRoutes.POST("/distribution/accounts/batch-markup", requirePerm("distribution:account_display"), distributionHandler.BatchUpdateAccountMarkup)
adminRoutes.GET("/distribution/payment-channels", requirePerm("distribution:payment_view"), distributionHandler.ListPaymentChannels)
adminRoutes.POST("/distribution/payment-channels", requirePerm("distribution:payment_manage"), distributionHandler.BindPaymentChannel)
adminRoutes.DELETE("/distribution/payment-channels/:id", requirePerm("distribution:payment_manage"), distributionHandler.UnbindPaymentChannel)