feat(distribution): 支持分销上传来源追踪、主站审核展示与收售结算模式自动判定
This commit is contained in:
@@ -43,6 +43,8 @@ func MigrateListingLifecycleTestSchema(db *gorm.DB) error {
|
|||||||
&model.RentalListing{},
|
&model.RentalListing{},
|
||||||
&model.ListingUpload{},
|
&model.ListingUpload{},
|
||||||
&model.ListingStatusEvent{},
|
&model.ListingStatusEvent{},
|
||||||
|
&model.DistributionSite{},
|
||||||
|
&model.DistributionSiteAccount{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,11 +40,12 @@ type RentalListing struct {
|
|||||||
ReviewStatus string `gorm:"size:32;not null;default:'none'" json:"review_status"`
|
ReviewStatus string `gorm:"size:32;not null;default:'none'" json:"review_status"`
|
||||||
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
|
HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"`
|
||||||
SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"`
|
SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"`
|
||||||
ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"`
|
ManagedAdminID *uint64 `gorm:"index" json:"managed_admin_id"`
|
||||||
ReviewReason string `gorm:"size:255;not null;default:''" json:"review_reason"`
|
DistributionSiteID *uint64 `gorm:"column:distribution_site_id;index" json:"distribution_site_id,omitempty"`
|
||||||
PublishedAt *time.Time `json:"published_at"`
|
ReviewReason string `gorm:"size:255;not null;default:''" json:"review_reason"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
PublishedAt *time.Time `json:"published_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (RentalListing) TableName() string {
|
func (RentalListing) TableName() string {
|
||||||
|
|||||||
@@ -24,28 +24,66 @@ func distributionTablesPresent(tx *gorm.DB) bool {
|
|||||||
return distributionTablePresent
|
return distributionTablePresent
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveOrderSourceTx 校验站点、账号展示关系和生效规则,供正常订单创建事务写入快照引用。
|
// ResolveOrderSourceTx 校验收号方站点与售出方站点、账号展示关系和生效规则,供正常订单创建事务写入快照引用。
|
||||||
// 第一期仅启用 two_party_distributor_receive 模式,其他模式规则不会命中(见设计文档实施顺序)。
|
|
||||||
func ResolveOrderSourceTx(tx *gorm.DB, siteCode string, listingID, accountID uint64, at time.Time) (*model.DistributionSite, *model.DistributionRuleVersion, error) {
|
func ResolveOrderSourceTx(tx *gorm.DB, siteCode string, listingID, accountID uint64, at time.Time) (*model.DistributionSite, *model.DistributionRuleVersion, error) {
|
||||||
if siteCode == "" {
|
var listing model.RentalListing
|
||||||
|
var listingSiteID *uint64
|
||||||
|
if listingID > 0 {
|
||||||
|
if err := tx.Select("id, distribution_site_id").First(&listing, listingID).Error; err == nil {
|
||||||
|
listingSiteID = listing.DistributionSiteID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 两方均无分销站:纯主站交易
|
||||||
|
if siteCode == "" && listingSiteID == nil {
|
||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
var site model.DistributionSite
|
|
||||||
if err := tx.Where("site_code = ? AND status = 'active' AND display_account_enabled = ?", siteCode, true).First(&site).Error; err != nil {
|
// 2. 模式一:分销网收号、主站卖出 (ModeTwoPartyDistributorReceive)
|
||||||
|
if siteCode == "" && listingSiteID != nil {
|
||||||
|
var site model.DistributionSite
|
||||||
|
if err := tx.Where("id = ? AND status = 'active'", *listingSiteID).First(&site).Error; err != nil {
|
||||||
|
return nil, nil, ErrSiteNotFound
|
||||||
|
}
|
||||||
|
var rule model.DistributionRuleVersion
|
||||||
|
query := tx.Where("mode = ? AND status = 'active' AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)", ModeTwoPartyDistributorReceive, at, at).
|
||||||
|
Where("site_id = ? OR site_id IS NULL", site.ID).
|
||||||
|
Order("CASE WHEN site_id IS NULL THEN 1 ELSE 0 END, effective_from DESC, id DESC")
|
||||||
|
if err := query.First(&rule).Error; err != nil {
|
||||||
|
return nil, nil, ErrRuleNotFound
|
||||||
|
}
|
||||||
|
return &site, &rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 买家在分销站下单:校验售出站点状态与账号展示关系
|
||||||
|
var sellSite model.DistributionSite
|
||||||
|
if err := tx.Where("site_code = ? AND status = 'active' AND display_account_enabled = ?", siteCode, true).First(&sellSite).Error; err != nil {
|
||||||
return nil, nil, ErrSiteNotFound
|
return nil, nil, ErrSiteNotFound
|
||||||
}
|
}
|
||||||
var relation model.DistributionSiteAccount
|
var relation model.DistributionSiteAccount
|
||||||
if err := tx.Where("site_id = ? AND listing_id = ? AND account_id = ? AND display_status = 'enabled'", site.ID, listingID, accountID).First(&relation).Error; err != nil {
|
if err := tx.Where("site_id = ? AND listing_id = ? AND account_id = ? AND display_status = 'enabled'", sellSite.ID, listingID, accountID).First(&relation).Error; err != nil {
|
||||||
return nil, nil, ErrAccountDisplayNotFound
|
return nil, nil, ErrAccountDisplayNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. 解析生效规则:优先匹配有效规则版本
|
||||||
|
targetMode := ModeTwoPartyDistributorReceive
|
||||||
|
if listingSiteID != nil && *listingSiteID != sellSite.ID {
|
||||||
|
targetMode = ModeThreeParty
|
||||||
|
}
|
||||||
var rule model.DistributionRuleVersion
|
var rule model.DistributionRuleVersion
|
||||||
query := tx.Where("mode = ? AND status = 'active' AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)", ModeTwoPartyDistributorReceive, at, at).
|
query := tx.Where("status = 'active' AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)", at, at).
|
||||||
Where("site_id = ? OR site_id IS NULL", site.ID).
|
Where("site_id = ? OR site_id IS NULL", sellSite.ID)
|
||||||
Order("CASE WHEN site_id IS NULL THEN 1 ELSE 0 END, effective_from DESC, id DESC")
|
if targetMode == ModeThreeParty {
|
||||||
|
query = query.Where("mode = ?", ModeThreeParty)
|
||||||
|
} else {
|
||||||
|
query = query.Where("mode IN (?, ?)", ModeTwoPartyDistributorReceive, ModeTwoPartyPlatformReceive).
|
||||||
|
Order("CASE WHEN mode = '" + ModeTwoPartyDistributorReceive + "' THEN 0 ELSE 1 END")
|
||||||
|
}
|
||||||
|
query = query.Order("CASE WHEN site_id IS NULL THEN 1 ELSE 0 END, effective_from DESC, id DESC")
|
||||||
if err := query.First(&rule).Error; err != nil {
|
if err := query.First(&rule).Error; err != nil {
|
||||||
return nil, nil, ErrRuleNotFound
|
return nil, nil, ErrRuleNotFound
|
||||||
}
|
}
|
||||||
return &site, &rule, nil
|
return &sellSite, &rule, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateForRule 按规则快照计算手续费、服务费和分成基数。
|
// calculateForRule 按规则快照计算手续费、服务费和分成基数。
|
||||||
@@ -99,6 +137,12 @@ func SnapshotPaidOrderTx(tx *gorm.DB, orderID uint64, grossPaidCent int64) error
|
|||||||
partyID := *order.DistributionSiteID
|
partyID := *order.DistributionSiteID
|
||||||
if partyType == "platform" {
|
if partyType == "platform" {
|
||||||
partyID = 0
|
partyID = 0
|
||||||
|
} else if split.PartyRole == "distributor_a" {
|
||||||
|
// 三方模式下 distributor_a 为商品收号站
|
||||||
|
var listing model.RentalListing
|
||||||
|
if err := tx.Select("distribution_site_id").First(&listing, order.ListingID).Error; err == nil && listing.DistributionSiteID != nil {
|
||||||
|
partyID = *listing.DistributionSiteID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
parties = append(parties, model.DistributionOrderParty{OrderID: order.ID, SiteID: *order.DistributionSiteID, PartyRole: split.PartyRole, PartyType: partyType, PartyID: partyID, Ratio: split.Ratio, IncomeAmountCent: split.AmountCent, ProfitAmountCent: split.AmountCent})
|
parties = append(parties, model.DistributionOrderParty{OrderID: order.ID, SiteID: *order.DistributionSiteID, PartyRole: split.PartyRole, PartyType: partyType, PartyID: partyID, Ratio: split.Ratio, IncomeAmountCent: split.AmountCent, ProfitAmountCent: split.AmountCent})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ type ListingDTO struct {
|
|||||||
ManagedAdminID *uint64 `json:"managed_admin_id,omitempty"`
|
ManagedAdminID *uint64 `json:"managed_admin_id,omitempty"`
|
||||||
ReviewReason string `json:"review_reason"`
|
ReviewReason string `json:"review_reason"`
|
||||||
PublishedAt *time.Time `json:"published_at"`
|
PublishedAt *time.Time `json:"published_at"`
|
||||||
|
DistributionSiteID *uint64 `json:"distribution_site_id,omitempty"`
|
||||||
|
DistributionSiteCode string `json:"distribution_site_code,omitempty"`
|
||||||
|
DistributionSiteName string `json:"distribution_site_name,omitempty"`
|
||||||
ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"`
|
ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ func (h *Handler) Create(c *gin.Context) {
|
|||||||
response.BadRequest(c, listingBindErrorMessage(err))
|
response.BadRequest(c, listingBindErrorMessage(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
item, err := h.service.Create(c.Request.Context(), ownerID, req)
|
siteCode := strings.TrimSpace(c.GetHeader("X-Distribution-Site-Code"))
|
||||||
|
item, err := h.service.Create(c.Request.Context(), ownerID, req, siteCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeListingError(c, err)
|
writeListingError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateRequest, reviewRequired bool, publishCooldown time.Duration) (*ListingDTO, error) {
|
func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateRequest, reviewRequired bool, publishCooldown time.Duration, siteCode ...string) (*ListingDTO, error) {
|
||||||
var dto *ListingDTO
|
var dto *ListingDTO
|
||||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -51,20 +51,44 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque
|
|||||||
if err := tx.Create(&account).Error; err != nil {
|
if err := tx.Create(&account).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var site model.DistributionSite
|
||||||
|
var siteID *uint64
|
||||||
|
if len(siteCode) > 0 && strings.TrimSpace(siteCode[0]) != "" {
|
||||||
|
trimmedSiteCode := strings.TrimSpace(siteCode[0])
|
||||||
|
if err := tx.Where("site_code = ? AND status = 'active'", trimmedSiteCode).First(&site).Error; err == nil {
|
||||||
|
siteID = &site.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
priceCent := normalizedListingPriceCent(req)
|
priceCent := normalizedListingPriceCent(req)
|
||||||
listing := model.RentalListing{
|
listing := model.RentalListing{
|
||||||
ListingNo: listingNo,
|
ListingNo: listingNo,
|
||||||
AccountID: account.ID,
|
AccountID: account.ID,
|
||||||
OwnerID: ownerID,
|
OwnerID: ownerID,
|
||||||
PriceCent: priceCent,
|
PriceCent: priceCent,
|
||||||
DepositAmountCent: req.DepositAmountCent,
|
DepositAmountCent: req.DepositAmountCent,
|
||||||
Status: listingStatus,
|
Status: listingStatus,
|
||||||
ReviewStatus: reviewStatus,
|
ReviewStatus: reviewStatus,
|
||||||
PublishedAt: publishedAt,
|
PublishedAt: publishedAt,
|
||||||
|
DistributionSiteID: siteID,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&listing).Error; err != nil {
|
if err := tx.Create(&listing).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if siteID != nil {
|
||||||
|
dsa := model.DistributionSiteAccount{
|
||||||
|
SiteID: *siteID,
|
||||||
|
AccountID: account.ID,
|
||||||
|
ListingID: listing.ID,
|
||||||
|
DisplayStatus: "enabled",
|
||||||
|
}
|
||||||
|
if err := tx.Create(&dsa).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := listingstatus.AppendTransition(tx, &listing, "", listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "发布上架"); err != nil {
|
if err := listingstatus.AppendTransition(tx, &listing, "", listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "发布上架"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -80,6 +104,12 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque
|
|||||||
}
|
}
|
||||||
|
|
||||||
dto = toDTO(account, listing)
|
dto = toDTO(account, listing)
|
||||||
|
if siteID != nil {
|
||||||
|
dto.DistributionSiteID = siteID
|
||||||
|
dto.DistributionSiteCode = site.SiteCode
|
||||||
|
dto.DistributionSiteName = site.SiteName
|
||||||
|
dto.SourceChannel = fmt.Sprintf("分销站 - %s (%s)", site.SiteName, site.SiteCode)
|
||||||
|
}
|
||||||
dto.ListingGroupConversationID = conversationID
|
dto.ListingGroupConversationID = conversationID
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ type listingRow struct {
|
|||||||
Title string
|
Title string
|
||||||
OwnerPhone string
|
OwnerPhone string
|
||||||
OwnerNickname string
|
OwnerNickname string
|
||||||
SourceChannel string `gorm:"column:source_channel"`
|
SourceChannel string `gorm:"column:source_channel"`
|
||||||
IsExternalUpload bool `gorm:"column:is_external_upload"`
|
IsExternalUpload bool `gorm:"column:is_external_upload"`
|
||||||
|
DistributionSiteCode string `gorm:"column:distribution_site_code"`
|
||||||
|
DistributionSiteName string `gorm:"column:distribution_site_name"`
|
||||||
Description string
|
Description string
|
||||||
GameName string
|
GameName string
|
||||||
ServerRegion string
|
ServerRegion string
|
||||||
@@ -189,11 +191,14 @@ func (row listingRow) toDTO() ListingDTO {
|
|||||||
ReviewStatus: reviewStatus,
|
ReviewStatus: reviewStatus,
|
||||||
HandoffMode: row.HandoffMode,
|
HandoffMode: row.HandoffMode,
|
||||||
SettlementMode: row.SettlementMode,
|
SettlementMode: row.SettlementMode,
|
||||||
ManagedAdminID: row.ManagedAdminID,
|
ManagedAdminID: row.ManagedAdminID,
|
||||||
ReviewReason: reviewReason,
|
DistributionSiteID: row.DistributionSiteID,
|
||||||
PublishedAt: row.PublishedAt,
|
DistributionSiteCode: row.DistributionSiteCode,
|
||||||
CreatedAt: row.CreatedAt,
|
DistributionSiteName: row.DistributionSiteName,
|
||||||
UpdatedAt: row.UpdatedAt,
|
ReviewReason: reviewReason,
|
||||||
|
PublishedAt: row.PublishedAt,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
UpdatedAt: row.UpdatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,33 +207,34 @@ func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO {
|
|||||||
screenshotURLS := cleanScreenshotURLs(decodeScreenshots(account.ScreenshotURLS))
|
screenshotURLS := cleanScreenshotURLs(decodeScreenshots(account.ScreenshotURLS))
|
||||||
reviewStatus, reviewReason := normalizedReviewState(listing.Status, listing.ReviewStatus, listing.ReviewReason)
|
reviewStatus, reviewReason := normalizedReviewState(listing.Status, listing.ReviewStatus, listing.ReviewReason)
|
||||||
return &ListingDTO{
|
return &ListingDTO{
|
||||||
ID: listing.ID,
|
ID: listing.ID,
|
||||||
ListingNo: listing.ListingNo,
|
ListingNo: listing.ListingNo,
|
||||||
AccountID: account.ID,
|
AccountID: account.ID,
|
||||||
OwnerID: listing.OwnerID,
|
OwnerID: listing.OwnerID,
|
||||||
Title: account.Title,
|
Title: account.Title,
|
||||||
Description: account.Description,
|
Description: account.Description,
|
||||||
GameName: account.GameName,
|
GameName: account.GameName,
|
||||||
ServerRegion: account.ServerRegion,
|
ServerRegion: account.ServerRegion,
|
||||||
LoginPlatform: account.LoginPlatform,
|
LoginPlatform: account.LoginPlatform,
|
||||||
RankLevel: account.RankLevel,
|
RankLevel: account.RankLevel,
|
||||||
HafCoinAmount: account.HafCoinAmount,
|
HafCoinAmount: account.HafCoinAmount,
|
||||||
AssetSummary: assetSummary,
|
AssetSummary: assetSummary,
|
||||||
ScreenshotURLS: screenshotURLS,
|
ScreenshotURLS: screenshotURLS,
|
||||||
CoverURL: publicCoverURL(listing.ID, screenshotURLS, listing.Status, listing.ReviewStatus),
|
CoverURL: publicCoverURL(listing.ID, screenshotURLS, listing.Status, listing.ReviewStatus),
|
||||||
PriceCent: listing.PriceCent,
|
PriceCent: listing.PriceCent,
|
||||||
DepositAmountCent: listing.DepositAmountCent,
|
DepositAmountCent: listing.DepositAmountCent,
|
||||||
IsAccelerated: isAcceleratedSale(assetSummary),
|
IsAccelerated: isAcceleratedSale(assetSummary),
|
||||||
InTransaction: listing.InTransaction,
|
InTransaction: listing.InTransaction,
|
||||||
Status: listing.Status,
|
Status: listing.Status,
|
||||||
ReviewStatus: reviewStatus,
|
ReviewStatus: reviewStatus,
|
||||||
HandoffMode: listing.HandoffMode,
|
HandoffMode: listing.HandoffMode,
|
||||||
SettlementMode: listing.SettlementMode,
|
SettlementMode: listing.SettlementMode,
|
||||||
ManagedAdminID: listing.ManagedAdminID,
|
ManagedAdminID: listing.ManagedAdminID,
|
||||||
ReviewReason: reviewReason,
|
DistributionSiteID: listing.DistributionSiteID,
|
||||||
PublishedAt: listing.PublishedAt,
|
ReviewReason: reviewReason,
|
||||||
CreatedAt: listing.CreatedAt,
|
PublishedAt: listing.PublishedAt,
|
||||||
UpdatedAt: listing.UpdatedAt,
|
CreatedAt: listing.CreatedAt,
|
||||||
|
UpdatedAt: listing.UpdatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,13 +220,17 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
|||||||
Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level,
|
Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level,
|
||||||
a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname,
|
a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname,
|
||||||
CASE WHEN lu.id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
|
CASE WHEN lu.id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
|
||||||
|
COALESCE(src_site.site_code, '') AS distribution_site_code,
|
||||||
|
COALESCE(src_site.site_name, '') AS distribution_site_name,
|
||||||
CASE
|
CASE
|
||||||
|
WHEN l.distribution_site_id IS NOT NULL AND src_site.id IS NOT NULL THEN CONCAT('分销站 - ', src_site.site_name, ' (', src_site.site_code, ')')
|
||||||
WHEN lu.id IS NULL THEN ?
|
WHEN lu.id IS NULL THEN ?
|
||||||
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
|
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
|
||||||
ELSE lu.source_channel
|
ELSE lu.source_channel
|
||||||
END AS source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown).
|
END AS source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown).
|
||||||
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
||||||
Joins("LEFT JOIN users AS u ON u.id = l.owner_id").
|
Joins("LEFT JOIN users AS u ON u.id = l.owner_id").
|
||||||
|
Joins("LEFT JOIN distribution_sites AS src_site ON src_site.id = l.distribution_site_id").
|
||||||
// 逐商品按 listing_id 索引定位最后一次导入,避免每次列表查询都对全量导入记录分组。
|
// 逐商品按 listing_id 索引定位最后一次导入,避免每次列表查询都对全量导入记录分组。
|
||||||
Joins(`LEFT JOIN listing_uploads AS lu ON lu.id = (
|
Joins(`LEFT JOIN listing_uploads AS lu ON lu.id = (
|
||||||
SELECT MAX(lu_latest.id) FROM listing_uploads AS lu_latest WHERE lu_latest.listing_id = l.id
|
SELECT MAX(lu_latest.id) FROM listing_uploads AS lu_latest WHERE lu_latest.listing_id = l.id
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest) (*ListingDTO, error) {
|
func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest, siteCode ...string) (*ListingDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,11 @@ func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.repo.Create(ctx, ownerID, req, reviewRequired, cooldown)
|
effectiveSiteCode := ""
|
||||||
|
if len(siteCode) > 0 {
|
||||||
|
effectiveSiteCode = siteCode[0]
|
||||||
|
}
|
||||||
|
return s.repo.Create(ctx, ownerID, req, reviewRequired, cooldown, effectiveSiteCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req UpdateRequest) (*ListingDTO, error) {
|
func (s *Service) Update(ctx context.Context, ownerID uint64, id uint64, req UpdateRequest) (*ListingDTO, error) {
|
||||||
|
|||||||
@@ -820,3 +820,71 @@ func TestUpdateScreenshots(t *testing.T) {
|
|||||||
t.Fatalf("unexpected coin screenshots: %#v", coinList)
|
t.Fatalf("unexpected coin screenshots: %#v", coinList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateListingWithDistributionSiteCode(t *testing.T) {
|
||||||
|
db := database.NewTestDB()
|
||||||
|
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
|
||||||
|
t.Fatalf("migrate err: %v", err)
|
||||||
|
}
|
||||||
|
repo := NewRepository(db, nil)
|
||||||
|
|
||||||
|
user := model.User{Phone: "18800000001", Nickname: "分销卖家", RealnameStatus: "verified", Status: "active"}
|
||||||
|
db.Create(&user)
|
||||||
|
|
||||||
|
site := model.DistributionSite{
|
||||||
|
SiteCode: "dist_test_1",
|
||||||
|
SiteName: "测试分销商1",
|
||||||
|
Domain: "d1.com",
|
||||||
|
Status: "active",
|
||||||
|
DisplayAccountEnabled: true,
|
||||||
|
}
|
||||||
|
db.Create(&site)
|
||||||
|
|
||||||
|
req := CreateRequest{
|
||||||
|
Title: "分销上架商品",
|
||||||
|
ServerRegion: "QQ",
|
||||||
|
LoginPlatform: "PC",
|
||||||
|
PriceCent: 8800,
|
||||||
|
}
|
||||||
|
|
||||||
|
dto, err := repo.Create(context.Background(), user.ID, req, true, 0, "dist_test_1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("repo.Create failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dto.DistributionSiteID == nil || *dto.DistributionSiteID != site.ID {
|
||||||
|
t.Fatalf("expected DistributionSiteID=%d, got %v", site.ID, dto.DistributionSiteID)
|
||||||
|
}
|
||||||
|
if dto.DistributionSiteCode != "dist_test_1" {
|
||||||
|
t.Fatalf("expected dist_test_1, got %s", dto.DistributionSiteCode)
|
||||||
|
}
|
||||||
|
if dto.DistributionSiteName != "测试分销商1" {
|
||||||
|
t.Fatalf("expected 测试分销商1, got %s", dto.DistributionSiteName)
|
||||||
|
}
|
||||||
|
expectedSource := "分销站 - 测试分销商1 (dist_test_1)"
|
||||||
|
if dto.SourceChannel != expectedSource {
|
||||||
|
t.Fatalf("expected %s, got %s", expectedSource, dto.SourceChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 distribution_site_accounts 自动创建并启用
|
||||||
|
var dsa model.DistributionSiteAccount
|
||||||
|
if err := db.Where("site_id = ? AND listing_id = ?", site.ID, dto.ID).First(&dsa).Error; err != nil {
|
||||||
|
t.Fatalf("failed to find distribution_site_account: %v", err)
|
||||||
|
}
|
||||||
|
if dsa.DisplayStatus != "enabled" {
|
||||||
|
t.Fatalf("expected enabled, got %s", dsa.DisplayStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证审核工作台列表展示分销站来源
|
||||||
|
pending, err := repo.ListPendingReview(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPendingReview failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(pending) != 1 {
|
||||||
|
t.Fatalf("expected 1 pending item, got len=%d", len(pending))
|
||||||
|
}
|
||||||
|
if pending[0].SourceChannel != expectedSource {
|
||||||
|
t.Fatalf("expected pending SourceChannel=%s, got %s", expectedSource, pending[0].SourceChannel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package order
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
@@ -146,13 +148,22 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ
|
|||||||
func orderAccountSourceSnapshot(tx *gorm.DB, listingID uint64) (string, string, error) {
|
func orderAccountSourceSnapshot(tx *gorm.DB, listingID uint64) (string, string, error) {
|
||||||
var upload model.ListingUpload
|
var upload model.ListingUpload
|
||||||
err := tx.Where("listing_id = ?", listingID).Order("id DESC").First(&upload).Error
|
err := tx.Where("listing_id = ?", listingID).Order("id DESC").First(&upload).Error
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == nil {
|
||||||
return "internal", "", nil
|
return "external", upload.SourceChannel, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
return "external", upload.SourceChannel, nil
|
|
||||||
|
var listing model.RentalListing
|
||||||
|
if err := tx.Select("distribution_site_id").First(&listing, listingID).Error; err == nil && listing.DistributionSiteID != nil {
|
||||||
|
var site model.DistributionSite
|
||||||
|
if err := tx.Select("site_code, site_name").First(&site, *listing.DistributionSiteID).Error; err == nil {
|
||||||
|
return "distribution", fmt.Sprintf("分销站 - %s (%s)", site.SiteName, site.SiteCode), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "internal", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func listingHandoffMode(listing model.RentalListing) string {
|
func listingHandoffMode(listing model.RentalListing) string {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
-- 1. rental_listings 表新增 distribution_site_id 并添加索引
|
||||||
|
SET @sql = IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_listings' AND COLUMN_NAME = 'distribution_site_id') = 0,
|
||||||
|
'ALTER TABLE rental_listings ADD COLUMN distribution_site_id BIGINT UNSIGNED NULL AFTER managed_admin_id',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_listings' AND INDEX_NAME = 'idx_rental_listings_distribution_site') = 0,
|
||||||
|
'ALTER TABLE rental_listings ADD KEY idx_rental_listings_distribution_site (distribution_site_id, status)',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 2. 回填历史分销账号的归属站点
|
||||||
|
UPDATE rental_listings l
|
||||||
|
JOIN distribution_site_accounts dsa ON dsa.listing_id = l.id
|
||||||
|
SET l.distribution_site_id = dsa.site_id
|
||||||
|
WHERE l.distribution_site_id IS NULL;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_listings' AND INDEX_NAME = 'idx_rental_listings_distribution_site') > 0,
|
||||||
|
'ALTER TABLE rental_listings DROP KEY idx_rental_listings_distribution_site',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql = IF(
|
||||||
|
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'rental_listings' AND COLUMN_NAME = 'distribution_site_id') > 0,
|
||||||
|
'ALTER TABLE rental_listings DROP COLUMN distribution_site_id',
|
||||||
|
'SELECT 1'
|
||||||
|
);
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
@@ -61,7 +61,7 @@ import {
|
|||||||
} from '@/shared/utils/listingDisplay'
|
} from '@/shared/utils/listingDisplay'
|
||||||
import { formatDateTime } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
|
|
||||||
type RiskLevel = 'danger' | 'warning' | 'info'
|
type RiskLevel = 'danger' | 'warning' | 'info' | 'success'
|
||||||
|
|
||||||
interface RiskItem {
|
interface RiskItem {
|
||||||
label: string
|
label: string
|
||||||
@@ -598,6 +598,10 @@ function sourceChannel(row: Listing) {
|
|||||||
return isExternalUpload(row) ? '未填写' : '站内发布'
|
return isExternalUpload(row) ? '未填写' : '站内发布'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDistributionListing(row: Listing) {
|
||||||
|
return Boolean(row.distribution_site_id || sourceChannel(row).startsWith('分销站'))
|
||||||
|
}
|
||||||
|
|
||||||
function ownerOnlineText(row: Listing) {
|
function ownerOnlineText(row: Listing) {
|
||||||
const text = row.asset_summary?.online_time_text
|
const text = row.asset_summary?.online_time_text
|
||||||
if (typeof text === 'string' && text.trim()) return text
|
if (typeof text === 'string' && text.trim()) return text
|
||||||
@@ -674,6 +678,8 @@ function riskItems(row: Listing): RiskItem[] {
|
|||||||
level: 'info',
|
level: 'info',
|
||||||
type: 'general',
|
type: 'general',
|
||||||
})
|
})
|
||||||
|
} else if (isDistributionListing(row)) {
|
||||||
|
items.push({ label: `来源渠道:${sourceChannel(row)}`, level: 'success', type: 'general' })
|
||||||
} else {
|
} else {
|
||||||
items.push({ label: `来源渠道:${sourceChannel(row)}`, level: 'info', type: 'general' })
|
items.push({ label: `来源渠道:${sourceChannel(row)}`, level: 'info', type: 'general' })
|
||||||
}
|
}
|
||||||
@@ -712,6 +718,7 @@ function riskItems(row: Listing): RiskItem[] {
|
|||||||
function riskTagType(level: RiskLevel) {
|
function riskTagType(level: RiskLevel) {
|
||||||
if (level === 'danger') return 'danger'
|
if (level === 'danger') return 'danger'
|
||||||
if (level === 'warning') return 'warning'
|
if (level === 'warning') return 'warning'
|
||||||
|
if (level === 'success') return 'success'
|
||||||
return 'info'
|
return 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1114,9 +1121,11 @@ async function handleScreenshotSaved(payload: { oldUrl: string; newUrl: string }
|
|||||||
<span class="queue-deposit">押 ¥{{ formatCent(item.deposit_amount_cent) }}</span>
|
<span class="queue-deposit">押 ¥{{ formatCent(item.deposit_amount_cent) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="queue-tags">
|
<div class="queue-tags">
|
||||||
<el-tag size="small" type="primary" effect="plain">{{
|
<el-tag
|
||||||
sourceChannel(item)
|
size="small"
|
||||||
}}</el-tag>
|
:type="isDistributionListing(item) ? 'success' : 'primary'"
|
||||||
|
effect="plain"
|
||||||
|
>{{ sourceChannel(item) }}</el-tag>
|
||||||
<el-tag v-if="isExternalUpload(item)" size="small" type="info">{{
|
<el-tag v-if="isExternalUpload(item)" size="small" type="info">{{
|
||||||
uploaderName(item)
|
uploaderName(item)
|
||||||
}}</el-tag>
|
}}</el-tag>
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export interface Listing {
|
|||||||
review_status: ListingReviewStatus
|
review_status: ListingReviewStatus
|
||||||
review_reason: string
|
review_reason: string
|
||||||
published_at?: string
|
published_at?: string
|
||||||
|
distribution_site_id?: number
|
||||||
|
distribution_site_code?: string
|
||||||
|
distribution_site_name?: string
|
||||||
listing_group_conversation_id?: number
|
listing_group_conversation_id?: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
|||||||
Reference in New Issue
Block a user