区分线下提号账号来源

This commit is contained in:
yml2213
2026-07-27 11:13:31 +08:00
parent 8af7333969
commit 66af387b4e
11 changed files with 483 additions and 36 deletions
+2
View File
@@ -18,6 +18,8 @@ type AdminPickup struct {
AdminID uint64 `gorm:"not null" json:"admin_id"`
Platform string `gorm:"size:32;not null;default:''" json:"platform"`
ShopName string `gorm:"size:100;not null;default:'';index" json:"shop_name"`
AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"`
SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"`
ListingPriceCent int64 `gorm:"not null;default:0" json:"listing_price_cent"`
OwnerPriceCent int64 `gorm:"not null;default:0" json:"owner_price_cent"`
WebsiteProfitCent int64 `gorm:"not null;default:0" json:"website_profit_cent"`
+25 -5
View File
@@ -12,6 +12,11 @@ const (
StatusPickingUp = "picking_up"
StatusCompleted = "completed"
StatusCancelled = "cancelled"
AccountSourceInternal = "internal"
AccountSourceExternalPlatformManaged = "external_platform_managed"
ListingSourceExternal = "external"
ListingSourceInternal = "internal"
)
var (
@@ -38,6 +43,8 @@ type PickupDTO struct {
AdminID uint64 `json:"admin_id"`
Platform string `json:"platform"`
ShopName string `json:"shop_name"`
AccountSource string `json:"account_source"`
SourceChannel string `json:"source_channel"`
ListingPriceCent int64 `json:"listing_price_cent"`
OwnerPriceCent int64 `json:"owner_price_cent"`
WebsiteProfitCent int64 `json:"website_profit_cent"`
@@ -78,11 +85,12 @@ type CancelRequest struct {
}
type AdminPickupQuery struct {
Page int
PageSize int
Keyword string
Status string
ShopName string
Page int
PageSize int
Keyword string
Status string
ShopName string
AccountSource string
}
type SellerPickupQuery struct {
@@ -108,6 +116,18 @@ type AvailableListingDTO struct {
WebsiteProfitCent int64 `json:"website_profit_cent"`
SellerRatio float64 `json:"seller_ratio"`
BuyerRatio float64 `json:"buyer_ratio"`
AccountSource string `json:"account_source"`
IsExternalUpload bool `json:"is_external_upload"`
SourceChannel string `json:"source_channel"`
HandoffMode string `json:"handoff_mode"`
SettlementMode string `json:"settlement_mode"`
}
type AvailableListingQuery struct {
Page int
PageSize int
Keyword string
SourceType string
}
type PaginatedResult struct {
+6 -2
View File
@@ -148,9 +148,13 @@ func (h *Handler) Detail(c *gin.Context) {
// AvailableListings 可提号的 listing 搜索(管理员)
func (h *Handler) AvailableListings(c *gin.Context) {
keyword := strings.TrimSpace(c.Query("keyword"))
page, pageSize := parsePagination(c)
result, err := h.service.ListAvailableListings(c.Request.Context(), keyword, page, pageSize)
result, err := h.service.ListAvailableListings(c.Request.Context(), AvailableListingQuery{
Keyword: strings.TrimSpace(c.Query("keyword")),
SourceType: strings.TrimSpace(c.Query("source_type")),
Page: page,
PageSize: pageSize,
})
if err != nil {
writePickupError(c, err)
return
@@ -54,11 +54,12 @@ func parsePagination(c *gin.Context) (int, int) {
func parseAdminQuery(c *gin.Context) AdminPickupQuery {
page, pageSize := parsePagination(c)
return AdminPickupQuery{
Page: page,
PageSize: pageSize,
Keyword: c.Query("keyword"),
Status: c.Query("status"),
ShopName: c.Query("shop_name"),
Page: page,
PageSize: pageSize,
Keyword: c.Query("keyword"),
Status: c.Query("status"),
ShopName: c.Query("shop_name"),
AccountSource: c.Query("account_source"),
}
}
+64 -9
View File
@@ -67,6 +67,10 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, listing.AccountID).Error; err != nil {
return err
}
accountSource, sourceChannel, err := resolvePickupAccountSource(tx, listing.ID)
if err != nil {
return err
}
priceSnapshot := buildPickupPriceSnapshot(listing, account)
accountSnapshot, err := makePickupAccountSnapshot(account, listing)
if err != nil {
@@ -85,6 +89,8 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint
AdminID: adminID,
Platform: strings.TrimSpace(req.Platform),
ShopName: strings.TrimSpace(req.ShopName),
AccountSource: accountSource,
SourceChannel: sourceChannel,
ListingPriceCent: priceSnapshot.ListingPriceCent,
OwnerPriceCent: priceSnapshot.OwnerPriceCent,
WebsiteProfitCent: priceSnapshot.WebsiteProfitCent,
@@ -143,11 +149,13 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint
BizID: &bid,
Meta: meta,
Detail: map[string]any{
"pickup_no": pickupNo,
"listing_id": listing.ID,
"platform": pickup.Platform,
"shop_name": pickup.ShopName,
"profit_cent": pickup.ProfitAmountCent,
"pickup_no": pickupNo,
"listing_id": listing.ID,
"platform": pickup.Platform,
"shop_name": pickup.ShopName,
"account_source": pickup.AccountSource,
"source_channel": pickup.SourceChannel,
"profit_cent": pickup.ProfitAmountCent,
},
}); err != nil {
return err
@@ -422,6 +430,9 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*Pa
if shopName := strings.TrimSpace(query.ShopName); shopName != "" {
db = db.Where("p.shop_name = ?", shopName)
}
if accountSource := strings.TrimSpace(query.AccountSource); accountSource != "" {
db = db.Where("p.account_source = ?", accountSource)
}
if kw := strings.TrimSpace(query.Keyword); kw != "" {
like := "%" + kw + "%"
db = db.Where("p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ?", like, like, like)
@@ -476,6 +487,7 @@ func (r *Repository) paginatePickups(db *gorm.DB, page, pageSize int, sellerView
item.ProfitAmountCent = 0
item.BuyerRatio = 0
item.ShopName = ""
item.SourceChannel = ""
}
items = append(items, item)
}
@@ -483,25 +495,41 @@ func (r *Repository) paginatePickups(db *gorm.DB, page, pageSize int, sellerView
}
// ListAvailableListings 可提号的 listing:已发布、已审核、未在交易中。
func (r *Repository) ListAvailableListings(ctx context.Context, keyword string, page, pageSize int) (*AvailableListingsResult, error) {
func (r *Repository) ListAvailableListings(ctx context.Context, query AvailableListingQuery) (*AvailableListingsResult, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
latestUploads := r.db.WithContext(ctx).Table("listing_uploads").
Select("listing_id, MAX(id) AS upload_id").
Where("listing_id IS NOT NULL").
Group("listing_id")
db := r.db.WithContext(ctx).Table("rental_listings AS l").
Select("l.id, l.listing_no, l.account_id, l.owner_id, l.price_cent AS listing_price_cent, a.title AS account_title, a.server_region, a.login_platform, a.haf_coin_amount, a.asset_summary, owner.phone AS owner_phone").
Select(`l.id, l.listing_no, l.account_id, l.owner_id, l.price_cent AS listing_price_cent,
a.title AS account_title, a.server_region, a.login_platform, a.haf_coin_amount, a.asset_summary,
owner.phone AS owner_phone, l.handoff_mode, l.settlement_mode,
CASE WHEN lu.id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
COALESCE(lu.source_channel, '') AS source_channel`).
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
Joins("JOIN users AS owner ON owner.id = l.owner_id").
Joins("LEFT JOIN (?) AS latest_upload ON latest_upload.listing_id = l.id", latestUploads).
Joins("LEFT JOIN listing_uploads AS lu ON lu.id = latest_upload.upload_id").
Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false)
if kw := strings.TrimSpace(keyword); kw != "" {
if kw := strings.TrimSpace(query.Keyword); kw != "" {
like := "%" + kw + "%"
db = db.Where("l.listing_no LIKE ? OR a.title LIKE ?", like, like)
}
switch strings.TrimSpace(query.SourceType) {
case ListingSourceExternal:
db = db.Where("lu.id IS NOT NULL")
case ListingSourceInternal:
db = db.Where("lu.id IS NULL")
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, err
}
page, pageSize = normalizePaging(page, pageSize)
page, pageSize := normalizePaging(query.Page, query.PageSize)
var rows []availableListingRow
if err := db.Order("l.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
return nil, err
@@ -545,6 +573,8 @@ func (row pickupRow) toDTO() PickupDTO {
AdminID: row.AdminID,
Platform: row.Platform,
ShopName: row.ShopName,
AccountSource: row.AccountSource,
SourceChannel: row.SourceChannel,
ListingPriceCent: row.ListingPriceCent,
OwnerPriceCent: row.OwnerPriceCent,
WebsiteProfitCent: row.WebsiteProfitCent,
@@ -574,6 +604,10 @@ type availableListingRow struct {
ListingPriceCent int64
HafCoinAmount int64
AssetSummary datatypes.JSON
IsExternalUpload bool
SourceChannel string
HandoffMode string
SettlementMode string
}
func (row availableListingRow) toDTO() AvailableListingDTO {
@@ -581,6 +615,10 @@ func (row availableListingRow) toDTO() AvailableListingDTO {
model.RentalListing{PriceCent: row.ListingPriceCent},
model.GameAccount{HafCoinAmount: row.HafCoinAmount, AssetSummary: row.AssetSummary},
)
accountSource := AccountSourceInternal
if row.IsExternalUpload {
accountSource = AccountSourceExternalPlatformManaged
}
return AvailableListingDTO{
ID: row.ID,
ListingNo: row.ListingNo,
@@ -598,9 +636,26 @@ func (row availableListingRow) toDTO() AvailableListingDTO {
WebsiteProfitCent: snapshot.WebsiteProfitCent,
SellerRatio: snapshot.SellerRatio,
BuyerRatio: snapshot.BuyerRatio,
AccountSource: accountSource,
IsExternalUpload: row.IsExternalUpload,
SourceChannel: row.SourceChannel,
HandoffMode: row.HandoffMode,
SettlementMode: row.SettlementMode,
}
}
func resolvePickupAccountSource(tx *gorm.DB, listingID uint64) (string, string, error) {
var upload model.ListingUpload
err := tx.Where("listing_id = ?", listingID).Order("id DESC").First(&upload).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return AccountSourceInternal, "", nil
}
if err != nil {
return "", "", err
}
return AccountSourceExternalPlatformManaged, strings.TrimSpace(upload.SourceChannel), nil
}
func makePickupAccountSnapshot(account model.GameAccount, listing model.RentalListing) (datatypes.JSON, error) {
payload := map[string]any{
"listing_id": listing.ID,
@@ -181,6 +181,103 @@ func TestRepositoryListShopNamesReturnsDistinctPlatformOptions(t *testing.T) {
}
}
func TestRepositoryListAvailableListingsFiltersAccountSource(t *testing.T) {
repo, db := newPickupTestRepo(t)
internal := seedAvailableListing(t, db, "LST-SOURCE-INTERNAL", "站内账号", false)
external := seedAvailableListing(t, db, "LST-SOURCE-EXTERNAL", "外部账号", true)
if err := db.Create(&model.ListingUpload{
UploaderName: "外部号主",
SourceChannel: "淘宝",
ListingID: &external.ID,
Status: "listing_created",
}).Error; err != nil {
t.Fatalf("创建外部上传记录失败: %v", err)
}
all, err := repo.ListAvailableListings(t.Context(), AvailableListingQuery{Page: 1, PageSize: 20})
if err != nil {
t.Fatalf("ListAvailableListings() error = %v", err)
}
if all.Total != 2 || len(all.Items) != 2 {
t.Fatalf("全部来源结果 = total %d, items %d, want 2", all.Total, len(all.Items))
}
externalResult, err := repo.ListAvailableListings(t.Context(), AvailableListingQuery{
SourceType: ListingSourceExternal,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("筛选外部上传 error = %v", err)
}
if externalResult.Total != 1 || len(externalResult.Items) != 1 {
t.Fatalf("外部上传结果 = total %d, items %d, want 1", externalResult.Total, len(externalResult.Items))
}
externalItem := externalResult.Items[0]
if externalItem.ID != external.ID || !externalItem.IsExternalUpload {
t.Fatalf("外部上传账号 = id %d, external %t, want id %d", externalItem.ID, externalItem.IsExternalUpload, external.ID)
}
if externalItem.AccountSource != AccountSourceExternalPlatformManaged || externalItem.SourceChannel != "淘宝" {
t.Fatalf("外部上传来源 = %q/%q", externalItem.AccountSource, externalItem.SourceChannel)
}
if externalItem.HandoffMode != "platform" || externalItem.SettlementMode != "platform_managed" {
t.Fatalf("外部上传代管模式 = %q/%q", externalItem.HandoffMode, externalItem.SettlementMode)
}
internalResult, err := repo.ListAvailableListings(t.Context(), AvailableListingQuery{
SourceType: ListingSourceInternal,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("筛选站内上传 error = %v", err)
}
if internalResult.Total != 1 || len(internalResult.Items) != 1 {
t.Fatalf("站内上传结果 = total %d, items %d, want 1", internalResult.Total, len(internalResult.Items))
}
internalItem := internalResult.Items[0]
if internalItem.ID != internal.ID || internalItem.IsExternalUpload || internalItem.AccountSource != AccountSourceInternal {
t.Fatalf("站内上传账号 = id %d, external %t, source %q", internalItem.ID, internalItem.IsExternalUpload, internalItem.AccountSource)
}
}
func TestRepositoryCreateSnapshotsExternalAccountSource(t *testing.T) {
repo, db := newPickupTestRepo(t)
listing := seedAvailableListing(t, db, "LST-SOURCE-CREATE", "待提号外部账号", true)
upload := model.ListingUpload{
UploaderName: "外部号主",
SourceChannel: "闲鱼",
ListingID: &listing.ID,
Status: "listing_created",
}
if err := db.Create(&upload).Error; err != nil {
t.Fatalf("创建外部上传记录失败: %v", err)
}
item, err := repo.Create(t.Context(), CreateRequest{
ListingID: listing.ID,
Platform: "微信",
ProfitAmountCent: 1200,
}, 99, auditlog.Meta{RequestID: "req-source"})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if item.AccountSource != AccountSourceExternalPlatformManaged || item.SourceChannel != "闲鱼" {
t.Fatalf("创建来源快照 = %q/%q", item.AccountSource, item.SourceChannel)
}
if err := db.Delete(&upload).Error; err != nil {
t.Fatalf("删除上传关联失败: %v", err)
}
stored, err := repo.FindByID(t.Context(), item.ID)
if err != nil {
t.Fatalf("FindByID() error = %v", err)
}
if stored.AccountSource != AccountSourceExternalPlatformManaged || stored.SourceChannel != "闲鱼" {
t.Fatalf("关联删除后的来源快照 = %q/%q", stored.AccountSource, stored.SourceChannel)
}
}
func assertInt64(t *testing.T, name string, got, want int64) {
t.Helper()
if got != want {
@@ -208,14 +305,53 @@ func newPickupTestRepo(t *testing.T) (*Repository, *gorm.DB) {
&model.User{},
&model.GameAccount{},
&model.RentalListing{},
&model.ListingUpload{},
&model.AdminPickup{},
&model.AuditLog{},
&model.Notification{},
&model.ListingStatusEvent{},
); err != nil {
t.Fatalf("迁移测试表失败: %v", err)
}
return NewRepository(db), db
}
func seedAvailableListing(t *testing.T, db *gorm.DB, listingNo, title string, external bool) model.RentalListing {
t.Helper()
owner := model.User{Phone: "188" + listingNo}
if err := db.Create(&owner).Error; err != nil {
t.Fatalf("创建候选号主失败: %v", err)
}
account := model.GameAccount{
OwnerID: owner.ID,
ServerRegion: "测试区",
LoginPlatform: "微信",
Title: title,
Status: "published",
}
if err := db.Create(&account).Error; err != nil {
t.Fatalf("创建候选账号失败: %v", err)
}
listing := model.RentalListing{
ListingNo: listingNo,
AccountID: account.ID,
OwnerID: owner.ID,
PriceCent: 10000,
Status: "published",
ReviewStatus: "approved",
HandoffMode: "owner",
SettlementMode: "owner_wallet",
}
if external {
listing.HandoffMode = "platform"
listing.SettlementMode = "platform_managed"
}
if err := db.Create(&listing).Error; err != nil {
t.Fatalf("创建候选上架记录失败: %v", err)
}
return listing
}
func seedPickup(t *testing.T, db *gorm.DB, status string) model.AdminPickup {
t.Helper()
owner := model.User{Phone: "18800000000"}
+2 -2
View File
@@ -91,9 +91,9 @@ func (s *Service) ListForSeller(ctx context.Context, ownerID uint64, query Selle
return s.repo.ListForSeller(ctx, ownerID, query)
}
func (s *Service) ListAvailableListings(ctx context.Context, keyword string, page, pageSize int) (*AvailableListingsResult, error) {
func (s *Service) ListAvailableListings(ctx context.Context, query AvailableListingQuery) (*AvailableListingsResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListAvailableListings(ctx, keyword, page, pageSize)
return s.repo.ListAvailableListings(ctx, query)
}
@@ -0,0 +1,33 @@
-- +goose Up
ALTER TABLE admin_pickups
ADD COLUMN account_source VARCHAR(32) NOT NULL DEFAULT 'internal' COMMENT '账号来源快照: internal站内上传/external_platform_managed外部上传平台代管' AFTER shop_name,
ADD COLUMN source_channel VARCHAR(32) NOT NULL DEFAULT '' COMMENT '外部上传来源渠道快照' AFTER account_source,
ADD KEY idx_admin_pickups_account_source (account_source, created_at);
UPDATE admin_pickups AS p
LEFT JOIN (
SELECT uploads.listing_id, uploads.source_channel
FROM listing_uploads AS uploads
INNER JOIN (
SELECT listing_id, MAX(id) AS upload_id
FROM listing_uploads
WHERE listing_id IS NOT NULL
GROUP BY listing_id
) AS latest ON latest.upload_id = uploads.id
) AS lu ON lu.listing_id = p.listing_id
SET p.account_source = CASE
WHEN lu.listing_id IS NULL THEN 'internal'
ELSE 'external_platform_managed'
END,
p.source_channel = CASE
WHEN lu.listing_id IS NULL THEN ''
ELSE COALESCE(lu.source_channel, '')
END;
-- +goose Down
ALTER TABLE admin_pickups
DROP KEY idx_admin_pickups_account_source,
DROP COLUMN source_channel,
DROP COLUMN account_source;