From ecaf15417ba5f12c1437e9c1e084df5666802c25 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 7 Sep 2026 17:18:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(distribution):=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E5=88=86=E9=94=80=E5=89=8D=E5=8F=B0=E5=95=86=E5=9F=8E=E5=A4=9A?= =?UTF-8?q?=E7=A7=9F=E6=88=B7=E4=BD=93=E7=B3=BB=E3=80=81=E8=B4=A7=E6=BA=90?= =?UTF-8?q?=E4=B8=8E=E8=AE=A2=E5=8D=95=E6=95=B0=E6=8D=AE=E4=B8=A5=E6=A0=BC?= =?UTF-8?q?=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/distribution/dto.go | 61 +- .../internal/modules/distribution/handler.go | 78 ++- .../modules/distribution/repository.go | 345 ++++++++++- .../modules/distribution/repository_test.go | 215 ++++++- .../internal/modules/distribution/service.go | 22 + backend/internal/modules/listing/dto.go | 7 +- .../internal/modules/listing/handler_parse.go | 7 +- .../internal/modules/listing/public_query.go | 15 +- .../internal/modules/listing/service_test.go | 46 ++ backend/internal/modules/order/dto.go | 2 + .../internal/modules/order/handler_user.go | 8 +- backend/internal/modules/order/presenter.go | 2 + backend/internal/modules/order/queries.go | 38 +- .../order/repository_integration_test.go | 88 +++ backend/internal/modules/order/service.go | 8 +- backend/internal/router/router.go | 4 + distribution-admin/src/AppShell.tsx | 49 +- distribution-admin/src/api.ts | 60 ++ distribution-admin/src/pages/Accounts.tsx | 299 ++++++++-- distribution-admin/src/pages/Storefront.tsx | 544 ++++++++++++++++++ .../features/admin/api/adminDistribution.ts | 13 + .../views/AdminDistributionSitesView.vue | 32 +- .../src/features/listings/api/listings.ts | 8 +- .../src/features/listings/views/HomeView.vue | 161 +++++- .../listings/views/MobileHomeView.css | 59 ++ .../listings/views/MobileHomeView.vue | 37 +- frontend/src/features/orders/api/orders.ts | 36 +- .../orders/views/MobileOrdersView.vue | 73 ++- .../src/features/orders/views/OrdersView.css | 19 + .../src/features/orders/views/OrdersView.vue | 33 +- frontend/src/layouts/AppLayout.css | 20 + frontend/src/layouts/AppLayout.vue | 17 +- frontend/src/main.ts | 4 + frontend/src/stores/distributionSite.ts | 106 ++++ 34 files changed, 2397 insertions(+), 119 deletions(-) create mode 100644 distribution-admin/src/pages/Storefront.tsx create mode 100644 frontend/src/stores/distributionSite.ts diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index dddfacc..2fbdf0f 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -121,13 +121,64 @@ type DistributionOrderPage struct { } type AccountDisplayDTO struct { - ID uint64 `json:"id"` - SiteID uint64 `json:"site_id"` - AccountID uint64 `json:"account_id"` - ListingID uint64 `json:"listing_id"` - DisplayStatus string `json:"display_status"` + ID uint64 `json:"id"` + SiteID uint64 `json:"site_id"` + AccountID uint64 `json:"account_id"` + ListingID uint64 `json:"listing_id"` + DisplayStatus string `json:"display_status"` + ListingNo string `json:"listing_no,omitempty"` + Title string `json:"title,omitempty"` + GameName string `json:"game_name,omitempty"` + ServerRegion string `json:"server_region,omitempty"` + LoginPlatform string `json:"login_platform,omitempty"` + RankLevel string `json:"rank_level,omitempty"` + HafCoinAmount int64 `json:"haf_coin_amount,omitempty"` + PriceCent int64 `json:"price_cent,omitempty"` + DepositAmountCent int64 `json:"deposit_amount_cent,omitempty"` + ListingStatus string `json:"listing_status,omitempty"` + InTransaction bool `json:"in_transaction,omitempty"` } +type SyncSiteAccountsResult struct { + SyncedCount int64 `json:"synced_count"` + SkippedCount int64 `json:"skipped_count"` + TotalActive int64 `json:"total_active"` +} + +type PublicDistributionListingDTO struct { + ListingID uint64 `json:"listing_id"` + ListingNo string `json:"listing_no"` + AccountID uint64 `json:"account_id"` + 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"` + InTransaction bool `json:"in_transaction"` + SiteCode string `json:"site_code"` + SiteName string `json:"site_name"` +} + +type PublicDistributionListingsResult struct { + Items []PublicDistributionListingDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type PublicSiteInfoDTO struct { + SiteCode string `json:"site_code"` + SiteName string `json:"site_name"` + Domain string `json:"domain"` + Status string `json:"status"` + DisplayAccountEnabled bool `json:"display_account_enabled"` + DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"` +} + + type UpdateAccountDisplayRequest struct { DisplayStatus string `json:"display_status" binding:"required,oneof=enabled disabled sold offline"` } diff --git a/backend/internal/modules/distribution/handler.go b/backend/internal/modules/distribution/handler.go index bef53b7..a62ba29 100644 --- a/backend/internal/modules/distribution/handler.go +++ b/backend/internal/modules/distribution/handler.go @@ -186,10 +186,7 @@ func (h *Handler) CreateFinancialSnapshot(c *gin.Context) { func (h *Handler) ListAccountDisplays(c *gin.Context) { siteID := parseQueryUint(c, "site_id") - if siteID <= 0 { - if siteID == 0 { - response.BadRequest(c, "site_id 不正确") - } + if siteID < 0 { return } adminID, _ := currentAdminID(c) @@ -198,6 +195,10 @@ func (h *Handler) ListAccountDisplays(c *gin.Context) { writeError(c, err) return } + if scoped == 0 && siteID == 0 { + response.BadRequest(c, "site_id 不正确") + return + } items, err := h.service.ListAccountDisplays(c.Request.Context(), scoped, c.Query("status")) if err != nil { writeError(c, err) @@ -225,6 +226,75 @@ func (h *Handler) UpdateAccountDisplay(c *gin.Context) { response.OK(c, item) } +func (h *Handler) SyncSiteAccounts(c *gin.Context) { + var siteID uint64 + id, ok := parseID(c) + if ok && id > 0 { + siteID = id + } else { + qID := parseQueryUint(c, "site_id") + if qID > 0 { + siteID = uint64(qID) + } + } + adminID, _ := currentAdminID(c) + scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, siteID) + if err != nil { + writeError(c, err) + return + } + if scoped == 0 { + response.BadRequest(c, "site_id 不正确") + return + } + result, err := h.service.SyncSiteAccounts(c.Request.Context(), adminID, scoped) + if err != nil { + writeError(c, err) + return + } + response.OK(c, result) +} + +func (h *Handler) ListPublicSiteListings(c *gin.Context) { + siteCode := strings.TrimSpace(c.Param("site_code")) + if siteCode == "" { + siteCode = strings.TrimSpace(c.Query("site_code")) + } + if siteCode == "" { + response.BadRequest(c, "site_code 不能为空") + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + result, err := h.service.ListPublicSiteListings(c.Request.Context(), siteCode, page, pageSize) + if err != nil { + writeError(c, err) + return + } + response.OK(c, result) +} + +func (h *Handler) GetPublicSiteInfo(c *gin.Context) { + code := strings.TrimSpace(c.Query("code")) + if code == "" { + code = strings.TrimSpace(c.Query("site_code")) + } + domain := strings.TrimSpace(c.Query("domain")) + if code == "" && domain == "" { + host := c.Request.Host + if colon := strings.Index(host, ":"); colon != -1 { + host = host[:colon] + } + domain = host + } + info, err := h.service.GetPublicSiteInfo(c.Request.Context(), code, domain) + if err != nil { + writeError(c, err) + return + } + response.OK(c, info) +} + func (h *Handler) ListPaymentChannels(c *gin.Context) { siteID := parseQueryUint(c, "site_id") if siteID <= 0 { diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 7b0803f..1c98d2a 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -346,17 +346,76 @@ func (r *Repository) FindOrder(ctx context.Context, adminID, id uint64) (*Distri } func (r *Repository) ListAccountDisplays(ctx context.Context, siteID uint64, status string) ([]AccountDisplayDTO, error) { - var rows []model.DistributionSiteAccount - db := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("id DESC") - if status != "" { - db = db.Where("display_status = ?", status) + type accountDisplayRow struct { + ID uint64 `gorm:"column:id"` + SiteID uint64 `gorm:"column:site_id"` + AccountID uint64 `gorm:"column:account_id"` + ListingID uint64 `gorm:"column:listing_id"` + DisplayStatus string `gorm:"column:display_status"` + ListingNo string `gorm:"column:listing_no"` + Title string `gorm:"column:title"` + GameName string `gorm:"column:game_name"` + ServerRegion string `gorm:"column:server_region"` + LoginPlatform string `gorm:"column:login_platform"` + RankLevel string `gorm:"column:rank_level"` + HafCoinAmount int64 `gorm:"column:haf_coin_amount"` + PriceCent int64 `gorm:"column:price_cent"` + DepositAmountCent int64 `gorm:"column:deposit_amount_cent"` + ListingStatus string `gorm:"column:listing_status"` + InTransaction bool `gorm:"column:in_transaction"` } - if err := db.Find(&rows).Error; err != nil { + + var rows []accountDisplayRow + query := r.db.WithContext(ctx). + Table("distribution_site_accounts AS dsa"). + Select(` + dsa.id, + dsa.site_id, + dsa.account_id, + dsa.listing_id, + dsa.display_status, + COALESCE(l.listing_no, '') AS listing_no, + COALESCE(ga.title, '') AS title, + COALESCE(ga.game_name, '') AS game_name, + COALESCE(ga.server_region, '') AS server_region, + COALESCE(ga.login_platform, '') AS login_platform, + COALESCE(ga.rank_level, '') AS rank_level, + COALESCE(ga.haf_coin_amount, 0) AS haf_coin_amount, + COALESCE(l.price_cent, 0) AS price_cent, + COALESCE(l.deposit_amount_cent, 0) AS deposit_amount_cent, + COALESCE(l.status, '') AS listing_status, + COALESCE(l.in_transaction, 0) AS in_transaction + `). + Joins("LEFT JOIN rental_listings AS l ON l.id = dsa.listing_id"). + Joins("LEFT JOIN game_accounts AS ga ON ga.id = dsa.account_id"). + Where("dsa.site_id = ?", siteID) + + if status != "" { + query = query.Where("dsa.display_status = ?", status) + } + if err := query.Order("dsa.id DESC").Scan(&rows).Error; err != nil { return nil, err } items := make([]AccountDisplayDTO, 0, len(rows)) for _, row := range rows { - items = append(items, AccountDisplayDTO{ID: row.ID, SiteID: row.SiteID, AccountID: row.AccountID, ListingID: row.ListingID, DisplayStatus: row.DisplayStatus}) + items = append(items, AccountDisplayDTO{ + ID: row.ID, + SiteID: row.SiteID, + AccountID: row.AccountID, + ListingID: row.ListingID, + DisplayStatus: row.DisplayStatus, + ListingNo: row.ListingNo, + Title: row.Title, + GameName: row.GameName, + ServerRegion: row.ServerRegion, + LoginPlatform: row.LoginPlatform, + RankLevel: row.RankLevel, + HafCoinAmount: row.HafCoinAmount, + PriceCent: row.PriceCent, + DepositAmountCent: row.DepositAmountCent, + ListingStatus: row.ListingStatus, + InTransaction: row.InTransaction, + }) } return items, nil } @@ -376,10 +435,107 @@ func (r *Repository) UpdateAccountDisplay(ctx context.Context, adminID, id uint6 if err := r.db.WithContext(ctx).Save(&row).Error; err != nil { return nil, err } - item := &AccountDisplayDTO{ID: row.ID, SiteID: row.SiteID, AccountID: row.AccountID, ListingID: row.ListingID, DisplayStatus: row.DisplayStatus} + item := &AccountDisplayDTO{ + ID: row.ID, + SiteID: row.SiteID, + AccountID: row.AccountID, + ListingID: row.ListingID, + DisplayStatus: row.DisplayStatus, + } + var listing model.RentalListing + if err := r.db.WithContext(ctx).First(&listing, row.ListingID).Error; err == nil { + item.ListingNo = listing.ListingNo + item.PriceCent = listing.PriceCent + item.DepositAmountCent = listing.DepositAmountCent + item.ListingStatus = listing.Status + item.InTransaction = listing.InTransaction + } + var account model.GameAccount + if err := r.db.WithContext(ctx).First(&account, row.AccountID).Error; err == nil { + item.Title = account.Title + item.GameName = account.GameName + item.ServerRegion = account.ServerRegion + item.LoginPlatform = account.LoginPlatform + item.RankLevel = account.RankLevel + item.HafCoinAmount = account.HafCoinAmount + } return item, nil } +func (r *Repository) SyncSiteAccounts(ctx context.Context, adminID, siteID uint64) (*SyncSiteAccountsResult, error) { + if r.db == nil { + return nil, ErrDependencyUnavailable + } + if err := r.ensureSiteAccess(ctx, adminID, siteID); err != nil { + return nil, err + } + var site model.DistributionSite + if err := r.db.WithContext(ctx).First(&site, siteID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSiteNotFound + } + return nil, err + } + + var activeListings []model.RentalListing + if err := r.db.WithContext(ctx). + Where("status = ? AND review_status = ?", "published", "approved"). + Order("id ASC"). + Find(&activeListings).Error; err != nil { + return nil, err + } + + var existingAccounts []model.DistributionSiteAccount + if err := r.db.WithContext(ctx). + Where("site_id = ?", siteID). + Find(&existingAccounts).Error; err != nil { + return nil, err + } + + existingMap := make(map[uint64]model.DistributionSiteAccount, len(existingAccounts)) + for _, ea := range existingAccounts { + existingMap[ea.AccountID] = ea + } + + var toInsert []model.DistributionSiteAccount + seenAccount := make(map[uint64]bool) + var skippedCount int64 + + for _, l := range activeListings { + if seenAccount[l.AccountID] { + continue + } + seenAccount[l.AccountID] = true + + if ea, exists := existingMap[l.AccountID]; exists { + if ea.ListingID != l.ID { + _ = r.db.WithContext(ctx).Model(&model.DistributionSiteAccount{}). + Where("id = ?", ea.ID).Update("listing_id", l.ID).Error + } + skippedCount++ + } else { + toInsert = append(toInsert, model.DistributionSiteAccount{ + SiteID: siteID, + AccountID: l.AccountID, + ListingID: l.ID, + DisplayStatus: "enabled", + }) + } + } + + if len(toInsert) > 0 { + if err := r.db.WithContext(ctx).Create(&toInsert).Error; err != nil { + return nil, err + } + } + + return &SyncSiteAccountsResult{ + SyncedCount: int64(len(toInsert)), + SkippedCount: skippedCount, + TotalActive: int64(len(activeListings)), + }, nil +} + func (r *Repository) ListPaymentChannels(ctx context.Context, siteID uint64) ([]PaymentChannelDTO, error) { var rows []model.DistributionSitePaymentChannel if err := r.db.WithContext(ctx).Where("site_id = ?", siteID).Order("priority ASC, id ASC").Find(&rows).Error; err != nil { @@ -860,6 +1016,34 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (* return err } + if displayEnabled { + var activeListings []model.RentalListing + if err := tx.Where("status = ? AND review_status = ?", "published", "approved").Order("id ASC").Find(&activeListings).Error; err != nil { + return err + } + if len(activeListings) > 0 { + siteAccounts := make([]model.DistributionSiteAccount, 0, len(activeListings)) + seenAccount := make(map[uint64]bool) + for _, l := range activeListings { + if seenAccount[l.AccountID] { + continue + } + seenAccount[l.AccountID] = true + siteAccounts = append(siteAccounts, model.DistributionSiteAccount{ + SiteID: site.ID, + AccountID: l.AccountID, + ListingID: l.ID, + DisplayStatus: "enabled", + }) + } + if len(siteAccounts) > 0 { + if err := tx.Create(&siteAccounts).Error; err != nil { + return err + } + } + } + } + resp = OnboardSiteResponse{ Site: siteDTO(site), Rule: ruleDTO(rule), @@ -882,3 +1066,150 @@ func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (* } return &resp, nil } + +func (r *Repository) ListPublicSiteListings(ctx context.Context, siteCode string, page, pageSize int) (*PublicDistributionListingsResult, error) { + if r.db == nil { + return nil, ErrDependencyUnavailable + } + if strings.TrimSpace(siteCode) == "" { + return nil, ErrSiteNotFound + } + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + + var site model.DistributionSite + if err := r.db.WithContext(ctx).Where("site_code = ? AND status = 'active'", siteCode).First(&site).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSiteNotFound + } + return nil, err + } + + if !site.DisplayAccountEnabled { + return &PublicDistributionListingsResult{ + Items: []PublicDistributionListingDTO{}, + Total: 0, + Page: page, + PageSize: pageSize, + }, nil + } + + type publicListingRow struct { + ListingID uint64 `gorm:"column:listing_id"` + ListingNo string `gorm:"column:listing_no"` + AccountID uint64 `gorm:"column:account_id"` + Title string `gorm:"column:title"` + GameName string `gorm:"column:game_name"` + ServerRegion string `gorm:"column:server_region"` + LoginPlatform string `gorm:"column:login_platform"` + RankLevel string `gorm:"column:rank_level"` + HafCoinAmount int64 `gorm:"column:haf_coin_amount"` + PriceCent int64 `gorm:"column:price_cent"` + DepositAmountCent int64 `gorm:"column:deposit_amount_cent"` + InTransaction bool `gorm:"column:in_transaction"` + } + + baseQuery := r.db.WithContext(ctx). + Table("distribution_site_accounts AS dsa"). + Joins("INNER JOIN rental_listings AS l ON l.id = dsa.listing_id AND l.status = 'published' AND l.review_status = 'approved'"). + Joins("INNER JOIN game_accounts AS ga ON ga.id = dsa.account_id"). + Where("dsa.site_id = ? AND dsa.display_status = 'enabled'", site.ID) + + var total int64 + if err := baseQuery.Count(&total).Error; err != nil { + return nil, err + } + + var rows []publicListingRow + err := baseQuery. + Select(` + dsa.listing_id, + l.listing_no, + dsa.account_id, + COALESCE(ga.title, '') AS title, + COALESCE(ga.game_name, '') AS game_name, + COALESCE(ga.server_region, '') AS server_region, + COALESCE(ga.login_platform, '') AS login_platform, + COALESCE(ga.rank_level, '') AS rank_level, + COALESCE(ga.haf_coin_amount, 0) AS haf_coin_amount, + COALESCE(l.price_cent, 0) AS price_cent, + COALESCE(l.deposit_amount_cent, 0) AS deposit_amount_cent, + COALESCE(l.in_transaction, 0) AS in_transaction + `). + Order("dsa.id DESC"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Scan(&rows).Error + if err != nil { + return nil, err + } + + items := make([]PublicDistributionListingDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, PublicDistributionListingDTO{ + ListingID: row.ListingID, + ListingNo: row.ListingNo, + AccountID: row.AccountID, + Title: row.Title, + GameName: row.GameName, + ServerRegion: row.ServerRegion, + LoginPlatform: row.LoginPlatform, + RankLevel: row.RankLevel, + HafCoinAmount: row.HafCoinAmount, + PriceCent: row.PriceCent, + DepositAmountCent: row.DepositAmountCent, + InTransaction: row.InTransaction, + SiteCode: site.SiteCode, + SiteName: site.SiteName, + }) + } + + return &PublicDistributionListingsResult{ + Items: items, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string) (*PublicSiteInfoDTO, error) { + if r.db == nil { + return nil, ErrDependencyUnavailable + } + code = strings.TrimSpace(code) + domain = strings.TrimSpace(domain) + if code == "" && domain == "" { + return nil, ErrSiteNotFound + } + + var site model.DistributionSite + query := r.db.WithContext(ctx).Where("status = 'active'") + if code != "" { + query = query.Where("site_code = ?", code) + } else { + query = query.Where("domain = ?", domain) + } + + if err := query.First(&site).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSiteNotFound + } + return nil, err + } + + return &PublicSiteInfoDTO{ + SiteCode: site.SiteCode, + SiteName: site.SiteName, + Domain: site.Domain, + Status: site.Status, + DisplayAccountEnabled: site.DisplayAccountEnabled, + DefaultSupportGroupID: site.DefaultSupportGroupID, + }, nil +} diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index bb6bcaf..a569c93 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -1,6 +1,7 @@ package distribution import ( + "errors" "testing" "time" @@ -16,7 +17,14 @@ func distributionTestDB(t *testing.T) *gorm.DB { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.AdminUser{}, &model.Role{}, &model.ChatConversation{}, &model.RentalOrder{}, &model.AuditLog{}, &model.PaymentMerchantConfig{}, &model.DistributionSite{}, &model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil { + if err := db.AutoMigrate( + &model.AdminUser{}, &model.Role{}, &model.ChatConversation{}, &model.RentalOrder{}, + &model.AuditLog{}, &model.PaymentMerchantConfig{}, &model.DistributionSite{}, + &model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, + &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, + &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}, + &model.RentalListing{}, &model.GameAccount{}, + ); err != nil { t.Fatal(err) } return db @@ -177,3 +185,208 @@ func TestScopeSiteIDRestrictsBoundMember(t *testing.T) { t.Fatalf("UpdateAccountDisplay cross-site err = %v", err) } } + +func TestSyncSiteAccountsAndPublicListings(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + + site := model.DistributionSite{ + SiteCode: "sync_demo", + SiteName: "同步演示站", + Domain: "sync.example.com", + DisplayAccountEnabled: true, + Status: "active", + } + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + + account1 := model.GameAccount{ + Title: "三角洲极品金枪号", + GameName: "delta_force", + ServerRegion: "微信区", + LoginPlatform: "PC", + RankLevel: "王者", + HafCoinAmount: 500000, + Status: "active", + } + if err := db.Create(&account1).Error; err != nil { + t.Fatal(err) + } + + listing1 := model.RentalListing{ + ListingNo: "L10001", + AccountID: account1.ID, + OwnerID: 1, + PriceCent: 2500, + DepositAmountCent: 10000, + Status: "published", + ReviewStatus: "approved", + InTransaction: false, + } + if err := db.Create(&listing1).Error; err != nil { + t.Fatal(err) + } + + // 草稿状态的商品不应该被同步 + listingDraft := model.RentalListing{ + ListingNo: "L10002", + AccountID: 999, + OwnerID: 1, + PriceCent: 3000, + DepositAmountCent: 5000, + Status: "draft", + ReviewStatus: "pending", + } + if err := db.Create(&listingDraft).Error; err != nil { + t.Fatal(err) + } + + // 首次同步:应当成功新增 1 条,跳过 0 条 + res1, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID) + if err != nil { + t.Fatalf("SyncSiteAccounts err = %v", err) + } + if res1.SyncedCount != 1 || res1.SkippedCount != 0 || res1.TotalActive != 1 { + t.Fatalf("unexpected res1: %+v", res1) + } + + // 再次同步:幂等保证,跳过 1 条,新增 0 条 + res2, err := repo.SyncSiteAccounts(t.Context(), 0, site.ID) + if err != nil { + t.Fatalf("SyncSiteAccounts retry err = %v", err) + } + if res2.SyncedCount != 0 || res2.SkippedCount != 1 || res2.TotalActive != 1 { + t.Fatalf("unexpected res2: %+v", res2) + } + + // 验证管理后台列表查询携带丰富字段 + displays, err := repo.ListAccountDisplays(t.Context(), site.ID, "") + if err != nil { + t.Fatalf("ListAccountDisplays err = %v", err) + } + if len(displays) != 1 { + t.Fatalf("expected 1 display, got %d", len(displays)) + } + d := displays[0] + if d.Title != "三角洲极品金枪号" || d.GameName != "delta_force" || d.PriceCent != 2500 || d.DepositAmountCent != 10000 || d.ListingNo != "L10001" { + t.Fatalf("unexpected enriched display: %+v", d) + } + + // 验证公共端点浏览查询 + pubList, err := repo.ListPublicSiteListings(t.Context(), site.SiteCode, 1, 20) + if err != nil { + t.Fatalf("ListPublicSiteListings err = %v", err) + } + if pubList.Total != 1 || len(pubList.Items) != 1 { + t.Fatalf("expected 1 public item, got %+v", pubList) + } + if pubList.Items[0].Title != "三角洲极品金枪号" || pubList.Items[0].SiteCode != site.SiteCode { + t.Fatalf("unexpected public item: %+v", pubList.Items[0]) + } + + // 下架该展示账号后,公共端点不再返回 + if _, err := repo.UpdateAccountDisplay(t.Context(), 0, d.ID, "disabled"); err != nil { + t.Fatalf("UpdateAccountDisplay disabled err = %v", err) + } + pubList2, err := repo.ListPublicSiteListings(t.Context(), site.SiteCode, 1, 20) + if err != nil { + t.Fatalf("ListPublicSiteListings after disabled err = %v", err) + } + if pubList2.Total != 0 || len(pubList2.Items) != 0 { + t.Fatalf("expected 0 public items after disabled, got %+v", pubList2) + } +} + +func TestOnboardSiteAutoSyncsActiveListings(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + + account := model.GameAccount{ + Title: "王者荣耀国服账号", + GameName: "honor_of_kings", + ServerRegion: "手Q一区", + LoginPlatform: "iOS", + Status: "active", + } + if err := db.Create(&account).Error; err != nil { + t.Fatal(err) + } + + listing := model.RentalListing{ + ListingNo: "L20001", + AccountID: account.ID, + OwnerID: 2, + PriceCent: 5000, + Status: "published", + ReviewStatus: "approved", + } + if err := db.Create(&listing).Error; err != nil { + t.Fatal(err) + } + + dispEnabled := true + onboardResp, err := repo.OnboardSite(t.Context(), OnboardSiteRequest{ + SiteCode: "auto_sync_site", + SiteName: "自动同步站点", + Domain: "auto.example.com", + AdminUsername: "auto_admin", + AdminPassword: "securepass123", + DisplayAccountEnabled: &dispEnabled, + }) + if err != nil { + t.Fatalf("OnboardSite err = %v", err) + } + + displays, err := repo.ListAccountDisplays(t.Context(), onboardResp.Site.ID, "") + if err != nil { + t.Fatalf("ListAccountDisplays err = %v", err) + } + if len(displays) != 1 { + t.Fatalf("expected 1 auto-synced account, got %d", len(displays)) + } + if displays[0].Title != "王者荣耀国服账号" || displays[0].ListingNo != "L20001" { + t.Fatalf("unexpected auto-synced display: %+v", displays[0]) + } +} + +func TestGetPublicSiteInfo(t *testing.T) { + db := distributionTestDB(t) + repo := NewRepository(db) + + site := model.DistributionSite{ + SiteCode: "info_site", + SiteName: "信息测试站", + Domain: "info.example.com", + Status: "active", + DisplayAccountEnabled: true, + } + if err := db.Create(&site).Error; err != nil { + t.Fatal(err) + } + + // Query by code + info, err := repo.GetPublicSiteInfo(t.Context(), "info_site", "") + if err != nil { + t.Fatalf("GetPublicSiteInfo by code failed: %v", err) + } + if info.SiteName != "信息测试站" || info.SiteCode != "info_site" { + t.Fatalf("unexpected info: %+v", info) + } + + // Query by domain + infoByDomain, err := repo.GetPublicSiteInfo(t.Context(), "", "info.example.com") + if err != nil { + t.Fatalf("GetPublicSiteInfo by domain failed: %v", err) + } + if infoByDomain.SiteName != "信息测试站" { + t.Fatalf("unexpected info by domain: %+v", infoByDomain) + } + + // Query not found + _, err = repo.GetPublicSiteInfo(t.Context(), "not_found", "") + if !errors.Is(err, ErrSiteNotFound) { + t.Fatalf("expected ErrSiteNotFound, got %v", err) + } +} + diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index 787c811..6a852f7 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -218,3 +218,25 @@ func (s *Service) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*Onb return s.repo.OnboardSite(ctx, req) } +func (s *Service) SyncSiteAccounts(ctx context.Context, adminID, siteID uint64) (*SyncSiteAccountsResult, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.SyncSiteAccounts(ctx, adminID, siteID) +} + +func (s *Service) ListPublicSiteListings(ctx context.Context, siteCode string, page, pageSize int) (*PublicDistributionListingsResult, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.ListPublicSiteListings(ctx, siteCode, page, pageSize) +} + +func (s *Service) GetPublicSiteInfo(ctx context.Context, code, domain string) (*PublicSiteInfoDTO, error) { + if err := s.available(); err != nil { + return nil, err + } + return s.repo.GetPublicSiteInfo(ctx, code, domain) +} + + diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 09c3cbb..73076a0 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -175,9 +175,10 @@ type PublicListQuery struct { MaxFireLevel *float64 MinDailyLoss *float64 MaxDailyLoss *float64 - MinSecretKD *float64 - MaxSecretKD *float64 - ResourceRanges map[string]NumberRange + MinSecretKD *float64 + MaxSecretKD *float64 + ResourceRanges map[string]NumberRange + DistributionSiteCode string } type NumberRange struct { diff --git a/backend/internal/modules/listing/handler_parse.go b/backend/internal/modules/listing/handler_parse.go index 579dfb7..e6bf82a 100644 --- a/backend/internal/modules/listing/handler_parse.go +++ b/backend/internal/modules/listing/handler_parse.go @@ -116,9 +116,10 @@ func parsePublicListQuery(c *gin.Context) PublicListQuery { MaxFireLevel: parseOptionalFloat(c.Query("max_fire_level")), MinDailyLoss: parseOptionalFloat(c.Query("min_daily_loss")), MaxDailyLoss: parseOptionalFloat(c.Query("max_daily_loss")), - MinSecretKD: parseOptionalFloat(c.Query("min_secret_kd")), - MaxSecretKD: parseOptionalFloat(c.Query("max_secret_kd")), - ResourceRanges: parseResourceRanges(c), + MinSecretKD: parseOptionalFloat(c.Query("min_secret_kd")), + MaxSecretKD: parseOptionalFloat(c.Query("max_secret_kd")), + ResourceRanges: parseResourceRanges(c), + DistributionSiteCode: strings.TrimSpace(firstNonEmpty(c.Query("distribution_site_code"), c.Query("site_code"))), } if query.PageSize > 50 { query.PageSize = 50 diff --git a/backend/internal/modules/listing/public_query.go b/backend/internal/modules/listing/public_query.go index 3f21ced..02a2e3b 100644 --- a/backend/internal/modules/listing/public_query.go +++ b/backend/internal/modules/listing/public_query.go @@ -16,10 +16,16 @@ func (r *Repository) ListPublic(ctx context.Context, query PublicListQuery) (*Pu return r.listPublicPage(ctx, query, page, pageSize) } + db := r.baseQuery(ctx). + Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false) + + if query.DistributionSiteCode != "" { + db = db.Joins("INNER JOIN distribution_site_accounts AS dsa ON dsa.listing_id = l.id AND dsa.display_status = 'enabled'"). + Joins("INNER JOIN distribution_sites AS ds ON ds.id = dsa.site_id AND ds.site_code = ? AND ds.status = 'active' AND ds.display_account_enabled = ?", query.DistributionSiteCode, true) + } + var rows []listingRow - err := r.baseQuery(ctx). - Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). - Order("l.published_at DESC, l.id DESC"). + err := db.Order("l.published_at DESC, l.id DESC"). Scan(&rows).Error if err != nil { return nil, err @@ -103,6 +109,9 @@ func normalizedPublicPage(query PublicListQuery) (int, int) { } func canListPublicWithSQL(query PublicListQuery) bool { + if query.DistributionSiteCode != "" { + return false + } if query.Keyword != "" { return false } diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index 5dcf6e4..810e590 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -746,3 +746,49 @@ func containsString(values []string, target string) bool { } return false } + +func TestListPublicWithDistributionSiteCode(t *testing.T) { + db := database.NewTestDB() + if err := database.MigrateListingLifecycleTestSchema(db); err != nil { + t.Fatalf("migrate err: %v", err) + } + if err := db.AutoMigrate(&model.DistributionSite{}, &model.DistributionSiteAccount{}); err != nil { + t.Fatalf("migrate distribution err: %v", err) + } + + repo := NewRepository(db, nil) + + account1 := model.GameAccount{Title: "号1", GameName: "delta", Status: "active"} + account2 := model.GameAccount{Title: "号2", GameName: "delta", Status: "active"} + db.Create(&account1) + db.Create(&account2) + + l1 := model.RentalListing{ListingNo: "L101", AccountID: account1.ID, Status: "published", ReviewStatus: "approved", PriceCent: 1000} + l2 := model.RentalListing{ListingNo: "L102", AccountID: account2.ID, Status: "published", ReviewStatus: "approved", PriceCent: 2000} + db.Create(&l1) + db.Create(&l2) + + site := model.DistributionSite{SiteCode: "site_alpha", SiteName: "分销A", Domain: "a.com", Status: "active", DisplayAccountEnabled: true} + db.Create(&site) + + dsa := model.DistributionSiteAccount{SiteID: site.ID, AccountID: account1.ID, ListingID: l1.ID, DisplayStatus: "enabled"} + db.Create(&dsa) + + res, err := repo.ListPublic(context.Background(), PublicListQuery{ + DistributionSiteCode: "site_alpha", + }) + if err != nil { + t.Fatalf("ListPublic err: %v", err) + } + if len(res.Items) != 1 || res.Items[0].ListingNo != "L101" { + t.Fatalf("expected only L101, got %+v", res.Items) + } + + allRes, err := repo.ListPublic(context.Background(), PublicListQuery{}) + if err != nil { + t.Fatalf("ListPublic err: %v", err) + } + if len(allRes.Items) != 2 { + t.Fatalf("expected 2 listings for main site, got %d", len(allRes.Items)) + } +} diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 7a3b904..ff48958 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -75,6 +75,7 @@ type OrderDTO struct { Checkout *CheckoutDTO `json:"checkout,omitempty"` AdminActions *AdminActionsDTO `json:"admin_actions,omitempty"` PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"` + DistributionSiteID *uint64 `json:"distribution_site_id,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -95,6 +96,7 @@ type UserOrderListItemDTO struct { Status string `json:"status"` HandoffStatus string `json:"handoff_status"` PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"` + DistributionSiteID *uint64 `json:"distribution_site_id,omitempty"` CreatedAt time.Time `json:"created_at"` } diff --git a/backend/internal/modules/order/handler_user.go b/backend/internal/modules/order/handler_user.go index 747a718..6ece1a0 100644 --- a/backend/internal/modules/order/handler_user.go +++ b/backend/internal/modules/order/handler_user.go @@ -1,6 +1,8 @@ package order import ( + "strings" + "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" @@ -32,7 +34,8 @@ func (h *Handler) List(c *gin.Context) { return } page, pageSize := parsePagination(c) - items, err := h.service.ListForUser(c.Request.Context(), userID, page, pageSize) + siteCode := strings.TrimSpace(firstNonEmpty(c.Query("distribution_site_code"), c.Query("site_code"))) + items, err := h.service.ListForUser(c.Request.Context(), userID, page, pageSize, siteCode) if err != nil { writeOrderError(c, err) return @@ -69,7 +72,8 @@ func (h *Handler) Detail(c *gin.Context) { if !ok { return } - item, err := h.service.FindForUser(c.Request.Context(), userID, id) + siteCode := strings.TrimSpace(firstNonEmpty(c.Query("distribution_site_code"), c.Query("site_code"))) + item, err := h.service.FindForUser(c.Request.Context(), userID, id, siteCode) if err != nil { writeOrderError(c, err) return diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index 03e0a3b..4ba8b57 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -129,6 +129,7 @@ func (row orderRow) toAdminDTO() OrderDTO { DepositHeldAt: row.DepositHeldAt, DepositHoldReleasedAt: row.DepositHoldReleasedAt, AdminActions: adminActionsForOrder(row.RentalOrder), + DistributionSiteID: row.DistributionSiteID, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } @@ -187,6 +188,7 @@ func (row orderRow) toUserListItem(userID uint64, paymentDeadlineAt *time.Time) Status: row.Status, HandoffStatus: row.HandoffStatus, PaymentDeadlineAt: paymentDeadlineAt, + DistributionSiteID: row.DistributionSiteID, CreatedAt: row.CreatedAt, } } diff --git a/backend/internal/modules/order/queries.go b/backend/internal/modules/order/queries.go index 1672ad8..5ed55f9 100644 --- a/backend/internal/modules/order/queries.go +++ b/backend/internal/modules/order/queries.go @@ -11,13 +11,25 @@ import ( "gorm.io/gorm" ) -func (r *Repository) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*UserPaginatedResult, error) { +func (r *Repository) ListForUser(ctx context.Context, userID uint64, page, pageSize int, siteCode ...string) (*UserPaginatedResult, error) { page, pageSize = normalizePagination(page, pageSize) base := r.baseQuery(ctx).Where("o.renter_id = ? OR o.owner_id = ?", userID, userID) + countBase := r.db.WithContext(ctx).Table("rental_orders AS o").Where("o.renter_id = ? OR o.owner_id = ?", userID, userID) + + var code string + if len(siteCode) > 0 { + code = strings.TrimSpace(siteCode[0]) + } + if code != "" { + base = base.Joins("INNER JOIN distribution_sites AS ds ON ds.id = o.distribution_site_id AND ds.site_code = ?", code) + countBase = countBase.Joins("INNER JOIN distribution_sites AS ds ON ds.id = o.distribution_site_id AND ds.site_code = ?", code) + } else { + base = base.Where("o.distribution_site_id IS NULL OR o.distribution_site_id = 0") + countBase = countBase.Where("o.distribution_site_id IS NULL OR o.distribution_site_id = 0") + } + var total int64 - if err := r.db.WithContext(ctx).Table("rental_orders AS o"). - Where("o.renter_id = ? OR o.owner_id = ?", userID, userID). - Count(&total).Error; err != nil { + if err := countBase.Count(&total).Error; err != nil { return nil, err } var rows []orderRow @@ -308,12 +320,22 @@ func (r *Repository) FindLatestAdminByListing(ctx context.Context, listingID uin return &dto, nil } -func (r *Repository) FindForUser(ctx context.Context, userID uint64, orderID uint64) (*OrderDTO, error) { +func (r *Repository) FindForUser(ctx context.Context, userID uint64, orderID uint64, siteCode ...string) (*OrderDTO, error) { var row orderRow db := r.db.WithContext(ctx) - if err := r.baseQuery(ctx). - Where("o.id = ? AND (o.renter_id = ? OR o.owner_id = ?)", orderID, userID, userID). - First(&row).Error; err != nil { + query := r.baseQuery(ctx).Where("o.id = ? AND (o.renter_id = ? OR o.owner_id = ?)", orderID, userID, userID) + + var code string + if len(siteCode) > 0 { + code = strings.TrimSpace(siteCode[0]) + } + if code != "" { + query = query.Joins("INNER JOIN distribution_sites AS ds ON ds.id = o.distribution_site_id AND ds.site_code = ?", code) + } else if len(siteCode) > 0 { + query = query.Where("o.distribution_site_id IS NULL OR o.distribution_site_id = 0") + } + + if err := query.First(&row).Error; err != nil { return nil, err } dto := row.toDTOForUser(userID) diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 987f2b7..8a6f94f 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -1884,3 +1884,91 @@ func TestAdminPlatformCheckoutConfirmRequiresExplicitConfirmationForZeroOwnerInc t.Fatalf("确认零收入后订单状态 = %s, want %s", completed.Status, orderStatusCompleted) } } + +func TestListForUserAndFindForUserSiteIsolation(t *testing.T) { + db := setupOrderTestDB(t) + if err := db.AutoMigrate(&model.DistributionSite{}); err != nil { + t.Fatalf("migrate distribution_sites err: %v", err) + } + repo := NewRepository(db) + + owner := model.User{Phone: "13800000010"} + db.Create(&owner) + renter := model.User{Phone: "13800000020"} + db.Create(&renter) + + account := model.GameAccount{OwnerID: owner.ID, Title: "隔离测试账号", Status: "active"} + db.Create(&account) + listing := model.RentalListing{ListingNo: "L30001", AccountID: account.ID, OwnerID: owner.ID, Status: "published", ReviewStatus: "approved", PriceCent: 1000} + db.Create(&listing) + + site := model.DistributionSite{ + SiteCode: "site_isolate_1", + SiteName: "隔离分销站", + Domain: "isolate.com", + Status: "active", + DisplayAccountEnabled: true, + } + db.Create(&site) + + // Order 1: on main site (distribution_site_id is nil) + mainOrder := model.RentalOrder{ + OrderNo: "RO_MAIN_001", + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + RenterID: renter.ID, + Status: orderStatusCompleted, + } + db.Create(&mainOrder) + + // Order 2: on distribution site (distribution_site_id = site.ID) + distOrder := model.RentalOrder{ + OrderNo: "RO_DIST_001", + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + RenterID: renter.ID, + Status: orderStatusCompleted, + DistributionSiteID: &site.ID, + } + db.Create(&distOrder) + + ctx := context.Background() + + // 1. Query with site_code = "site_isolate_1": should return ONLY distOrder + distList, err := repo.ListForUser(ctx, renter.ID, 1, 20, "site_isolate_1") + if err != nil { + t.Fatalf("ListForUser distribution err: %v", err) + } + if len(distList.Items) != 1 || distList.Items[0].OrderNo != "RO_DIST_001" { + t.Fatalf("expected only RO_DIST_001, got %+v", distList.Items) + } + + // 2. Query without siteCode (main site mode): should return ONLY mainOrder + mainList, err := repo.ListForUser(ctx, renter.ID, 1, 20, "") + if err != nil { + t.Fatalf("ListForUser main err: %v", err) + } + if len(mainList.Items) != 1 || mainList.Items[0].OrderNo != "RO_MAIN_001" { + t.Fatalf("expected only RO_MAIN_001, got %+v", mainList.Items) + } + + // 3. FindForUser: distOrder found under "site_isolate_1" + dItem, err := repo.FindForUser(ctx, renter.ID, distOrder.ID, "site_isolate_1") + if err != nil || dItem.OrderNo != "RO_DIST_001" { + t.Fatalf("FindForUser distOrder failed: %v", err) + } + + // 4. FindForUser: mainOrder CANNOT be accessed under "site_isolate_1" + _, err = repo.FindForUser(ctx, renter.ID, mainOrder.ID, "site_isolate_1") + if err == nil { + t.Fatalf("expected error accessing mainOrder under distribution site, got nil") + } + + // 5. FindForUser: distOrder CANNOT be accessed under main site mode ("") + _, err = repo.FindForUser(ctx, renter.ID, distOrder.ID, "") + if err == nil { + t.Fatalf("expected error accessing distOrder under main site mode, got nil") + } +} diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index 6bed379..0bf949a 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -148,11 +148,11 @@ func (s *Service) AcceptCheckout(ctx context.Context, userID uint64, orderID uin return s.repo.AcceptCheckout(ctx, userID, orderID) } -func (s *Service) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*UserPaginatedResult, error) { +func (s *Service) ListForUser(ctx context.Context, userID uint64, page, pageSize int, siteCode ...string) (*UserPaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.ListForUser(ctx, userID, page, pageSize) + return s.repo.ListForUser(ctx, userID, page, pageSize, siteCode...) } func (s *Service) ListSellerHandoffs(ctx context.Context, userID uint64, query SellerHandoffQuery) (*SellerHandoffPaginatedResult, error) { @@ -377,9 +377,9 @@ func (s *Service) PendingRefundCount(ctx context.Context) (int64, error) { return s.repo.PendingRefundCount(ctx) } -func (s *Service) FindForUser(ctx context.Context, userID uint64, orderID uint64) (*OrderDTO, error) { +func (s *Service) FindForUser(ctx context.Context, userID uint64, orderID uint64, siteCode ...string) (*OrderDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.FindForUser(ctx, userID, orderID) + return s.repo.FindForUser(ctx, userID, orderID, siteCode...) } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 2e0831a..595a13b 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -414,6 +414,8 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { api.GET("/home-announcements", systemConfigHandler.HomeAnnouncements) api.GET("/mobile-home-config", systemConfigHandler.HomeConfig) api.GET("/public/files/object", fileHandler.PublicObject) + api.GET("/distribution/sites/:site_code/listings", distributionHandler.ListPublicSiteListings) + api.GET("/distribution/site-info", distributionHandler.GetPublicSiteInfo) api.POST("/payments/leshua/notify", paymentHandler.LeshuaNotify) api.POST("/payments/lakala/notify", paymentHandler.LakalaNotify) api.POST("/payments/shuncheng/notify", paymentHandler.ShunchengNotify) @@ -602,6 +604,7 @@ 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) } adminRoutes := api.Group("/admin", requireAdmin, middleware.RequireAdminPasswordChanged()) @@ -617,6 +620,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/distribution/sites/:id", requirePerm("distribution:site_view"), distributionHandler.GetSite) adminRoutes.POST("/distribution/sites", requirePerm("distribution:site_manage"), distributionHandler.CreateSite) 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.GET("/distribution/rules", requirePerm("distribution:rule_view"), distributionHandler.ListRules) adminRoutes.POST("/distribution/rules", requirePerm("distribution:rule_manage"), distributionHandler.CreateRule) diff --git a/distribution-admin/src/AppShell.tsx b/distribution-admin/src/AppShell.tsx index 66b3d9a..f6cb51b 100644 --- a/distribution-admin/src/AppShell.tsx +++ b/distribution-admin/src/AppShell.tsx @@ -5,6 +5,7 @@ import { AccountBookOutlined, ApiOutlined, DashboardOutlined, + EyeOutlined, FileSearchOutlined, LogoutOutlined, SettingOutlined, @@ -20,6 +21,7 @@ import Accounts from './pages/Accounts' import Settlements from './pages/Settlements' import Orders from './pages/Orders' import Login from './pages/Login' +import Storefront from './pages/Storefront' import './style.css' type MenuItem = Required['items'][number] & { superadminOnly?: boolean } @@ -83,6 +85,26 @@ function MainLayout({ member, onLogout }: { member: CurrentMember; onLogout: ()
+ {member.site_code && ( + + )}
@@ -127,9 +149,24 @@ function AppContent() { const navigate = useNavigate() const location = useLocation() + const isPublicStorefront = location.pathname.startsWith('/site') + useEffect(() => { let active = true const checkAuth = async () => { + // For public storefront, we do not force login + if (isPublicStorefront) { + try { + const profile = await getMe() + if (active) setMember(profile) + } catch { + // ignore auth error on public storefront + } finally { + if (active) setLoading(false) + } + return + } + // If no token exists and no session, skip getMe if (!getStoredToken() && location.pathname === '/login') { if (active) setLoading(false) @@ -158,7 +195,7 @@ function AppContent() { return () => { active = false } - }, [location.pathname, navigate]) + }, [location.pathname, navigate, isPublicStorefront]) const handleLogout = async () => { await logout() @@ -171,6 +208,16 @@ function AppContent() { navigate('/', { replace: true }) } + if (isPublicStorefront) { + return ( + + } /> + } /> + } /> + + ) + } + if (loading) { return (
diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index ba25d72..1716089 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -170,6 +170,61 @@ export interface AccountDisplay { account_id: number listing_id: number display_status: string + listing_no?: string + title?: string + game_name?: string + server_region?: string + login_platform?: string + rank_level?: string + haf_coin_amount?: number + price_cent?: number + deposit_amount_cent?: number + listing_status?: string + in_transaction?: boolean +} + +export interface SyncSiteAccountsResult { + synced_count: number + skipped_count: number + total_active: number +} + +export interface PublicDistributionListing { + listing_id: number + listing_no: string + account_id: number + title: string + game_name: string + server_region: string + login_platform: string + rank_level: string + haf_coin_amount: number + price_cent: number + deposit_amount_cent: number + in_transaction: boolean + site_code: string + site_name: string +} + +export interface PublicDistributionListingsResult { + items: PublicDistributionListing[] + total: number + page: number + page_size: number +} + +export async function fetchPublicSiteListings( + siteCode: string, + page = 1, + pageSize = 24, +): Promise { + const resp = await api.get>( + `/distribution/sites/${siteCode}/listings`, + { + params: { page, page_size: pageSize }, + }, + ) + return extract(resp) } export interface PaymentChannel { @@ -297,6 +352,11 @@ export async function updateAccountDisplay(id: number, displayStatus: string): P return extract(resp) } +export async function syncSiteAccounts(siteID: number): Promise { + const resp = await api.post>(`/admin/distribution/sites/${siteID}/sync-accounts`) + return extract(resp) +} + export async function listPaymentChannels(siteID: number): Promise { const resp = await api.get>('/admin/distribution/payment-channels', { params: { site_id: siteID }, diff --git a/distribution-admin/src/pages/Accounts.tsx b/distribution-admin/src/pages/Accounts.tsx index a4a35dd..48700f7 100644 --- a/distribution-admin/src/pages/Accounts.tsx +++ b/distribution-admin/src/pages/Accounts.tsx @@ -1,20 +1,32 @@ -import { useEffect, useState } from 'react' -import { Card, Table, Typography, Space, Select, Tag, message } from 'antd' -import { listAccountDisplays, listSites, updateAccountDisplay, type AccountDisplay, type Site } from '../api' +import { useEffect, useState, useMemo } from 'react' +import { Card, Table, Typography, Space, Select, Tag, message, Button, Input, Modal } from 'antd' +import { SyncOutlined, SearchOutlined, CheckCircleOutlined, StopOutlined } from '@ant-design/icons' +import { + listAccountDisplays, + listSites, + updateAccountDisplay, + syncSiteAccounts, + formatMoney, + type AccountDisplay, + type Site, +} from '../api' -const STATUS_LABELS: Record = { - enabled: '展示中', - disabled: '已下架', - sold: '已售出', - offline: '已离线', +const STATUS_LABELS: Record = { + enabled: { label: '展示中', color: 'success' }, + disabled: { label: '已下架', color: 'default' }, + sold: { label: '已售出', color: 'blue' }, + offline: { label: '已离线', color: 'error' }, } export default function Accounts() { const [sites, setSites] = useState([]) const [siteID, setSiteID] = useState() const [status, setStatus] = useState('') + const [keyword, setKeyword] = useState('') const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) + const [syncing, setSyncing] = useState(false) + const [selectedRowKeys, setSelectedRowKeys] = useState([]) async function load(site?: number, statusFilter?: string) { if (!site) return @@ -40,6 +52,27 @@ export default function Accounts() { .catch(() => message.error('站点加载失败')) }, []) + async function handleSync() { + if (!siteID) { + message.warning('请先选择分销站点') + return + } + setSyncing(true) + try { + const res = await syncSiteAccounts(siteID) + message.success( + `同步完成!新增可售账号 ${res.synced_count} 个,已存在 ${res.skipped_count} 个(主站当前在售总数 ${res.total_active})`, + 5, + ) + await load(siteID, status) + } catch (error: unknown) { + const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message + message.error(respMessage ?? '同步主站商品失败') + } finally { + setSyncing(false) + } + } + async function toggle(item: AccountDisplay) { const next = item.display_status === 'enabled' ? 'disabled' : 'enabled' try { @@ -52,49 +85,237 @@ export default function Accounts() { } } + async function handleBatchUpdate(targetStatus: 'enabled' | 'disabled') { + if (selectedRowKeys.length === 0) { + message.warning('请先勾选需要批量操作的账号') + return + } + const actionLabel = targetStatus === 'enabled' ? '批量恢复展示' : '批量下架' + Modal.confirm({ + title: `确认${actionLabel}?`, + content: `即将对选中的 ${selectedRowKeys.length} 个账号执行${actionLabel}操作。`, + onOk: async () => { + let successCount = 0 + for (const id of selectedRowKeys) { + try { + await updateAccountDisplay(Number(id), targetStatus) + successCount++ + } catch { + // continue next + } + } + message.success(`已完成 ${successCount}/${selectedRowKeys.length} 个账号的${actionLabel}`) + setSelectedRowKeys([]) + if (siteID) void load(siteID, status) + }, + }) + } + + const filteredItems = useMemo(() => { + const kw = keyword.trim().toLowerCase() + if (!kw) return items + return items.filter( + (item) => + (item.title && item.title.toLowerCase().includes(kw)) || + (item.listing_no && item.listing_no.toLowerCase().includes(kw)) || + (item.game_name && item.game_name.toLowerCase().includes(kw)) || + (item.server_region && item.server_region.toLowerCase().includes(kw)), + ) + }, [items, keyword]) + + const currentSite = sites.find((s) => s.id === siteID) + return ( - - 账号展示 - - ({ label, value }))} - onChange={(value) => { - setStatus(value ?? '') - void load(siteID, value ?? '') - }} - /> - + +
+
+ + 商品与账号展示 + + + 管理当前分销网站对外展示与销售的游戏账号商品,支持实时从主站同步最新上架货源。 + +
+ +
+ + + + + ({ + label: conf.label, + value, + }))} + onChange={(value) => { + setStatus(value ?? '') + void load(siteID, value ?? '') + }} + /> + } + style={{ width: 240 }} + allowClear + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + /> + + + {selectedRowKeys.length > 0 && ( + + 已勾选 {selectedRowKeys.length} 项 + + + + )} + + + `共 ${total} 个账号商品`, + showQuickJumper: true, + }} columns={[ - { title: '账号 ID', dataIndex: 'account_id' }, - { title: '商品 ID', dataIndex: 'listing_id' }, { - title: '展示状态', + title: '商品编号', + dataIndex: 'listing_no', + width: 140, + render: (v: string | undefined, row: AccountDisplay) => ( + + + {v || `#${row.listing_id}`} + + {row.in_transaction && 出租中} + + ), + }, + { + title: '账号信息', + dataIndex: 'title', + minWidth: 240, + render: (_: unknown, row: AccountDisplay) => ( +
+ + {row.title || `账号 #${row.account_id}`} + + + {row.game_name || '默认游戏'} + {row.server_region && {row.server_region}} + {row.login_platform && {row.login_platform}} + +
+ ), + }, + { + title: '段位 / 资产', + width: 140, + render: (_: unknown, row: AccountDisplay) => ( +
+
{row.rank_level ? {row.rank_level} : '-'}
+ {row.haf_coin_amount !== undefined && row.haf_coin_amount > 0 && ( + + 哈夫币: {row.haf_coin_amount.toLocaleString()} + + )} +
+ ), + }, + { + title: '租金 / 押金', + width: 160, + render: (_: unknown, row: AccountDisplay) => ( +
+ + {formatMoney(row.price_cent)} + /小时 + +
+ 押金: {formatMoney(row.deposit_amount_cent)} +
+
+ ), + }, + { + title: '主站状态', + dataIndex: 'listing_status', + width: 110, + render: (v: string | undefined) => ( + + {v === 'published' ? '主站在售' : v || '未知'} + + ), + }, + { + title: '分销展示', dataIndex: 'display_status', - render: (v: string) => {STATUS_LABELS[v] ?? v}, + width: 110, + render: (v: string) => { + const conf = STATUS_LABELS[v] ?? { label: v, color: 'default' } + return {conf.label} + }, }, { title: '操作', + width: 120, render: (_: unknown, row: AccountDisplay) => ( - toggle(row)}>{row.display_status === 'enabled' ? '下架' : '恢复展示'} + ), }, ]} diff --git a/distribution-admin/src/pages/Storefront.tsx b/distribution-admin/src/pages/Storefront.tsx new file mode 100644 index 0000000..3719a67 --- /dev/null +++ b/distribution-admin/src/pages/Storefront.tsx @@ -0,0 +1,544 @@ +import { useEffect, useState, useMemo } from 'react' +import { useParams, Link } from 'react-router-dom' +import { + Layout, + Typography, + Space, + Input, + Radio, + Card, + Tag, + Button, + Modal, + Empty, + Spin, + Badge, + message, + Descriptions, + Divider, +} from 'antd' +import { + ShopOutlined, + SearchOutlined, + SafetyCertificateOutlined, + ThunderboltOutlined, + CustomerServiceOutlined, + CopyOutlined, + CheckCircleOutlined, + RightCircleOutlined, + RocketOutlined, + FireOutlined, +} from '@ant-design/icons' +import { + fetchPublicSiteListings, + formatMoney, + type PublicDistributionListing, +} from '../api' + +export default function Storefront() { + const { siteCode = 'demo' } = useParams<{ siteCode: string }>() + const [loading, setLoading] = useState(true) + const [items, setItems] = useState([]) + const [siteName, setSiteName] = useState('') + const [keyword, setKeyword] = useState('') + const [gameFilter, setGameFilter] = useState('all') + const [sortOrder, setSortOrder] = useState<'default' | 'price_asc' | 'price_desc'>('default') + const [selectedListing, setSelectedListing] = useState(null) + const [csModalVisible, setCsModalVisible] = useState(false) + + useEffect(() => { + let active = true + setLoading(true) + fetchPublicSiteListings(siteCode, 1, 100) + .then((res) => { + if (!active) return + setItems(res.items || []) + if (res.items && res.items.length > 0) { + setSiteName(res.items[0].site_name) + } + }) + .catch(() => { + if (active) { + message.error('加载分销商城商品失败,请检查站点标识') + } + }) + .finally(() => { + if (active) setLoading(false) + }) + + return () => { + active = false + } + }, [siteCode]) + + const displayName = siteName || `${siteCode.toUpperCase()} 专属分销商城` + + const filteredItems = useMemo(() => { + let list = [...items] + if (gameFilter !== 'all') { + list = list.filter((i) => (i.game_name || '').toLowerCase() === gameFilter.toLowerCase()) + } + const kw = keyword.trim().toLowerCase() + if (kw) { + list = list.filter( + (i) => + (i.title && i.title.toLowerCase().includes(kw)) || + (i.listing_no && i.listing_no.toLowerCase().includes(kw)) || + (i.server_region && i.server_region.toLowerCase().includes(kw)) || + (i.login_platform && i.login_platform.toLowerCase().includes(kw)) || + (i.rank_level && i.rank_level.toLowerCase().includes(kw)), + ) + } + if (sortOrder === 'price_asc') { + list.sort((a, b) => a.price_cent - b.price_cent) + } else if (sortOrder === 'price_desc') { + list.sort((a, b) => b.price_cent - a.price_cent) + } + return list + }, [items, gameFilter, keyword, sortOrder]) + + function handleCopyListingNo(no: string) { + navigator.clipboard?.writeText(no) + message.success(`已复制商品编号:${no}`) + } + + function handleRentNow(listing: PublicDistributionListing) { + setSelectedListing(listing) + } + + function openMainCheckout(listing: PublicDistributionListing) { + const mainHost = window.location.hostname + // 默认主站前台运行在 5189 端口,生产环境通过域名反代 + const mainUrl = window.location.port + ? `http://${mainHost}:5189/orders/create?listing_id=${listing.listing_id}&site_code=${listing.site_code}` + : `/orders/create?listing_id=${listing.listing_id}&site_code=${listing.site_code}` + window.open(mainUrl, '_blank') + } + + return ( + + {/* 顶部导航栏 */} + + +
+ +
+
+
+ + {displayName} + + + 官方合作分销 + +
+ + 站点标识:{siteCode} · 主站直营货源保障 + +
+
+ + + + + + + +
+ + {/* Hero Banner */} +
+
+ + } style={{ padding: '2px 10px', fontSize: 12 }}> + 实时同步主站精品号源 · 一键租号 + + + 【{displayName}】精选游戏账号商城 + + + 高阶战备 · 王者段位 · 百万哈夫币 · 极速自动交接 · 押金秒退担保 + + + +
+ + 秒级自动交接上号 +
+
+ + 主站资金与资产存管 +
+
+ + 租期结束秒退押金 +
+
+
+
+
+ + {/* 筛选与内容主体 */} + + {/* 筛选栏 */} + + +
+ + setGameFilter(e.target.value)} + buttonStyle="solid" + > + 全部游戏 + 三角洲行动 + 王者荣耀 + + + setSortOrder(e.target.value)} + size="middle" + > + 默认推荐 + 租金从低到高 + 租金从高到低 + + + + } + allowClear + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + style={{ width: 260, background: '#0f172a', borderColor: 'rgba(255,255,255,0.1)', color: '#fff' }} + /> +
+
+
+ + {/* 列表区域 */} + {loading ? ( +
+ +
+ ) : filteredItems.length === 0 ? ( + + + {items.length === 0 + ? '当前分销站点暂无上架商品,站长可通过分销后台一键同步主站商品。' + : '未找到符合条件的账号商品,请更换搜索词重试。'} + + } + /> + + ) : ( +
+ {filteredItems.map((item) => ( + handleRentNow(item)} + > + {/* 卡片头部 */} +
+ + #{item.listing_no} + + {item.in_transaction ? ( + 出租中} /> + ) : ( + 可租用} /> + )} +
+ + {/* 账号标题 */} + + {item.title} + + + {/* 标签属性 */} + + + {item.game_name === 'delta_force' ? '三角洲行动' : item.game_name} + + {item.server_region && ( + + {item.server_region} + + )} + {item.login_platform && ( + + {item.login_platform} + + )} + {item.rank_level && ( + + {item.rank_level} + + )} + + + {/* 资产特色 */} + {item.haf_coin_amount > 0 && ( +
+ 🪙 + + 哈夫币:{item.haf_coin_amount.toLocaleString()} + +
+ )} + +
+
+
+
+ {formatMoney(item.price_cent)} + /时 +
+
+ 押金:{formatMoney(item.deposit_amount_cent)} +
+
+ +
+
+
+ ))} +
+ )} +
+ + {/* 商品详情 & 租号下单弹窗 */} + + + 账号租赁详情 + + } + open={Boolean(selectedListing)} + onCancel={() => setSelectedListing(null)} + footer={null} + width={620} + destroyOnClose + > + {selectedListing && ( +
+ + {selectedListing.title} + + + 商品唯一编号:{selectedListing.listing_no} · 所属分销商:{selectedListing.site_name} + + + + + {selectedListing.game_name === 'delta_force' ? '三角洲行动' : selectedListing.game_name} + + {selectedListing.server_region || '全区通用'} + {selectedListing.login_platform || 'PC端'} + {selectedListing.rank_level || '暂未定级'} + + {selectedListing.haf_coin_amount > 0 + ? `${selectedListing.haf_coin_amount.toLocaleString()} 币` + : '无'} + + 自动快速交接 + + + {formatMoney(selectedListing.price_cent)} + {' '} + / 小时 + + + {formatMoney(selectedListing.deposit_amount_cent)} + + + + + +
+ + + 租号官方保障须知 + +
    +
  • 本账号经平台安全审核,支持密码自动交接或专属上号器直连。
  • +
  • 严禁在游戏内使用任何作弊辅助、脚本外挂,违规将扣除全部押金并封停账户。
  • +
  • 租期结束并安全下线后,系统将自动结算并全额退还押金。
  • +
+
+ +
+ + +
+
+ )} +
+ + {/* 专属客服弹窗 */} + setCsModalVisible(false)} + footer={[ + , + ]} + > +
+ + + 【{displayName}】专属客服通道 + + + 如有换号、上号异常、咨询租号或批量合作需求,请联系本站值班客服。 + +
+ 分销站点标识: + {siteCode} +
+ 主站 7x24 小时技术保障支持 +
+
+
+
+
+ ) +} diff --git a/frontend/src/features/admin/api/adminDistribution.ts b/frontend/src/features/admin/api/adminDistribution.ts index 4267124..51e55b2 100644 --- a/frontend/src/features/admin/api/adminDistribution.ts +++ b/frontend/src/features/admin/api/adminDistribution.ts @@ -244,3 +244,16 @@ export async function fetchDistributionSettlementItems(batchId: number) { ) return data.data.items ?? [] } + +export interface SyncSiteAccountsResult { + synced_count: number + skipped_count: number + total_active: number +} + +export async function syncDistributionSiteAccounts(siteId: number) { + const { data } = await apiClient.post>( + `/admin/distribution/sites/${siteId}/sync-accounts` + ) + return data.data +} diff --git a/frontend/src/features/admin/views/AdminDistributionSitesView.vue b/frontend/src/features/admin/views/AdminDistributionSitesView.vue index 8e60560..384415c 100644 --- a/frontend/src/features/admin/views/AdminDistributionSitesView.vue +++ b/frontend/src/features/admin/views/AdminDistributionSitesView.vue @@ -1,10 +1,11 @@