From fd4e490ed3be1354e00fe8dd669fc791754398d7 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 8 Sep 2026 15:25:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(distribution):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=88=86=E9=94=80=E7=AB=99=E4=B8=8E=E4=B8=BB=E7=AB=99=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E6=95=B0=E6=8D=AE=E5=8F=8A=E6=9D=83=E7=9B=8A=E9=A3=8E?= =?UTF-8?q?=E6=8E=A7=E9=9A=94=E7=A6=BB=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=95=86=E5=93=81=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/modules/listing/handler_user.go | 3 +- backend/internal/modules/listing/query.go | 16 +++-- .../internal/modules/listing/service_query.go | 4 +- .../internal/modules/listing/service_test.go | 60 +++++++++++++++++++ .../modules/order/checkout_finalize.go | 6 +- backend/internal/modules/order/dto.go | 1 + .../internal/modules/order/handler_user.go | 6 +- backend/internal/modules/order/lifecycle.go | 17 +++++- backend/internal/modules/order/queries.go | 20 +++++-- ...3_fix_distribution_listing_attribution.sql | 11 ++++ .../features/auth/views/MobileProfileView.css | 9 +++ .../features/auth/views/MobileProfileView.vue | 15 ++++- .../composables/useListingCollections.ts | 21 +++++-- .../seller/views/SellerPickupsView.vue | 12 +++- frontend/src/layouts/AppLayout.css | 14 +++++ frontend/src/layouts/AppLayout.vue | 41 ++++++++++--- 16 files changed, 221 insertions(+), 35 deletions(-) create mode 100644 backend/migrations/000073_fix_distribution_listing_attribution.sql diff --git a/backend/internal/modules/listing/handler_user.go b/backend/internal/modules/listing/handler_user.go index 86d5d8a..992057d 100644 --- a/backend/internal/modules/listing/handler_user.go +++ b/backend/internal/modules/listing/handler_user.go @@ -103,7 +103,8 @@ func (h *Handler) ListMine(c *gin.Context) { response.Unauthorized(c, "缺少用户上下文") return } - items, err := h.service.ListMine(c.Request.Context(), ownerID) + siteCode := strings.TrimSpace(c.GetHeader("X-Distribution-Site-Code")) + items, err := h.service.ListMine(c.Request.Context(), ownerID, siteCode) if err != nil { writeListingError(c, err) return diff --git a/backend/internal/modules/listing/query.go b/backend/internal/modules/listing/query.go index 89c0836..a1a86a9 100644 --- a/backend/internal/modules/listing/query.go +++ b/backend/internal/modules/listing/query.go @@ -170,12 +170,18 @@ func (r *Repository) FindAdmin(ctx context.Context, listingID uint64) (*ListingD return r.findDTO(ctx, "l.id = ?", listingID) } -func (r *Repository) ListMine(ctx context.Context, ownerID uint64) ([]ListingDTO, error) { +func (r *Repository) ListMine(ctx context.Context, ownerID uint64, siteCode ...string) ([]ListingDTO, error) { var rows []listingRow - err := r.baseQuery(ctx). - Where("l.owner_id = ?", ownerID). - Order("l.id DESC"). - Scan(&rows).Error + base := r.baseQuery(ctx).Where("l.owner_id = ?", ownerID) + if len(siteCode) > 0 { + code := strings.TrimSpace(siteCode[0]) + if code != "" { + base = base.Joins("INNER JOIN distribution_sites AS my_ds ON my_ds.id = l.distribution_site_id AND my_ds.site_code = ?", code) + } else { + base = base.Where("l.distribution_site_id IS NULL OR l.distribution_site_id = 0") + } + } + err := base.Order("l.id DESC").Scan(&rows).Error if err != nil { return nil, err } diff --git a/backend/internal/modules/listing/service_query.go b/backend/internal/modules/listing/service_query.go index 92c7bed..7f748c1 100644 --- a/backend/internal/modules/listing/service_query.go +++ b/backend/internal/modules/listing/service_query.go @@ -11,11 +11,11 @@ func (s *Service) ListPublic(ctx context.Context, query PublicListQuery) (*Publi return s.repo.ListPublic(ctx, query) } -func (s *Service) ListMine(ctx context.Context, ownerID uint64) ([]ListingDTO, error) { +func (s *Service) ListMine(ctx context.Context, ownerID uint64, siteCode ...string) ([]ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.ListMine(ctx, ownerID) + return s.repo.ListMine(ctx, ownerID, siteCode...) } func (s *Service) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) { diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index 21c2ac0..cb45c13 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -888,3 +888,63 @@ func TestCreateListingWithDistributionSiteCode(t *testing.T) { } } +func TestListMineSiteIsolation(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: "18800000002", Nickname: "多站卖家", RealnameStatus: "verified", Status: "active"} + db.Create(&user) + + site := model.DistributionSite{ + SiteCode: "dist_x", + SiteName: "分销X", + Domain: "x.com", + Status: "active", + DisplayAccountEnabled: true, + } + db.Create(&site) + + req1 := CreateRequest{Title: "主站商品", ServerRegion: "QQ", LoginPlatform: "PC", PriceCent: 1000} + l1, err := repo.Create(context.Background(), user.ID, req1, false, 0) + if err != nil { + t.Fatalf("Create main failed: %v", err) + } + + req2 := CreateRequest{Title: "分销商品", ServerRegion: "QQ", LoginPlatform: "PC", PriceCent: 2000} + l2, err := repo.Create(context.Background(), user.ID, req2, false, 0, "dist_x") + if err != nil { + t.Fatalf("Create dist failed: %v", err) + } + + // 1. 分销站查询只返回分销站商品 + distItems, err := repo.ListMine(context.Background(), user.ID, "dist_x") + if err != nil { + t.Fatalf("ListMine dist_x err: %v", err) + } + if len(distItems) != 1 || distItems[0].ID != l2.ID { + t.Fatalf("expected only dist listing %d, got %v", l2.ID, distItems) + } + + // 2. 主站查询只返回主站商品 + mainItems, err := repo.ListMine(context.Background(), user.ID, "") + if err != nil { + t.Fatalf("ListMine main err: %v", err) + } + if len(mainItems) != 1 || mainItems[0].ID != l1.ID { + t.Fatalf("expected only main listing %d, got %v", l1.ID, mainItems) + } + + // 3. 不传站点时兼容返回全部 + allItems, err := repo.ListMine(context.Background(), user.ID) + if err != nil { + t.Fatalf("ListMine all err: %v", err) + } + if len(allItems) != 2 { + t.Fatalf("expected 2 listings, got %d", len(allItems)) + } +} + + diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index 081affe..c070690 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -47,8 +47,10 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che if err := appendCheckoutCompletedNotifications(tx, order, renterContent); err != nil { return nil, err } - if err := rentergrowth.AwardOrderCompleted(tx, order, settlement.PureCoinAmountCent, rentergrowth.SourceOrderCompleted); err != nil { - return nil, err + if order.DistributionSiteID == nil || *order.DistributionSiteID == 0 { + if err := rentergrowth.AwardOrderCompleted(tx, order, settlement.PureCoinAmountCent, rentergrowth.SourceOrderCompleted); err != nil { + return nil, err + } } if err := saveFinalizedCheckout(tx, order, checkout, listing, account); err != nil { return nil, err diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index ff48958..4684416 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -228,6 +228,7 @@ type SellerHandoffQuery struct { Page int PageSize int Status string + SiteCode string } // SellerHandoffMetrics 为号主待办页提供跨分页的状态统计。 diff --git a/backend/internal/modules/order/handler_user.go b/backend/internal/modules/order/handler_user.go index 6ece1a0..b9aea93 100644 --- a/backend/internal/modules/order/handler_user.go +++ b/backend/internal/modules/order/handler_user.go @@ -34,7 +34,7 @@ func (h *Handler) List(c *gin.Context) { return } page, pageSize := parsePagination(c) - siteCode := strings.TrimSpace(firstNonEmpty(c.Query("distribution_site_code"), c.Query("site_code"))) + siteCode := strings.TrimSpace(firstNonEmpty(c.GetHeader("X-Distribution-Site-Code"), 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) @@ -50,10 +50,12 @@ func (h *Handler) ListSellerHandoffs(c *gin.Context) { return } page, pageSize := parsePagination(c) + siteCode := strings.TrimSpace(firstNonEmpty(c.GetHeader("X-Distribution-Site-Code"), c.Query("distribution_site_code"), c.Query("site_code"))) items, err := h.service.ListSellerHandoffs(c.Request.Context(), userID, SellerHandoffQuery{ Page: page, PageSize: pageSize, Status: c.Query("status"), + SiteCode: siteCode, }) if err != nil { writeOrderError(c, err) @@ -72,7 +74,7 @@ func (h *Handler) Detail(c *gin.Context) { if !ok { return } - siteCode := strings.TrimSpace(firstNonEmpty(c.Query("distribution_site_code"), c.Query("site_code"))) + siteCode := strings.TrimSpace(firstNonEmpty(c.GetHeader("X-Distribution-Site-Code"), 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) diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index f27a88d..8798a9c 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -63,9 +63,20 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ return err } rentOriginalAmountCent := pricing.RentAmountCent - growthSnapshot, err := rentergrowth.SnapshotForUser(tx, renterID) - if err != nil { - return err + var growthSnapshot rentergrowth.DiscountSnapshot + if distributionSite == nil { + snap, err := rentergrowth.SnapshotForUser(tx, renterID) + if err != nil { + return err + } + growthSnapshot = snap + } else { + // 分销站点订单隔离主站成长等级免押与折扣,防止主站免押坏账与折扣侵蚀分销收益 + growthSnapshot = rentergrowth.DiscountSnapshot{ + LevelCode: rentergrowth.DefaultLevelCode, + LevelName: rentergrowth.DefaultLevelName, + DiscountBps: rentergrowth.DefaultDiscountBps, + } } rentDiscountAmountCent := rentergrowth.CalculateDiscountCent(pricing.PureCoinAmountCent, pricing.PureCoinPlatformFeeCent, growthSnapshot.DiscountBps) if rentDiscountAmountCent > 0 { diff --git a/backend/internal/modules/order/queries.go b/backend/internal/modules/order/queries.go index 5ed55f9..ca069fc 100644 --- a/backend/internal/modules/order/queries.go +++ b/backend/internal/modules/order/queries.go @@ -63,6 +63,11 @@ func (r *Repository) ListSellerHandoffs(ctx context.Context, userID uint64, quer if query.Status != "" { base = base.Where("o.status = ?", query.Status) } + if query.SiteCode != "" { + base = base.Joins("INNER JOIN distribution_sites AS ds ON ds.id = o.distribution_site_id AND ds.site_code = ?", query.SiteCode) + } else { + base = base.Where("o.distribution_site_id IS NULL OR o.distribution_site_id = 0") + } var total int64 if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil { @@ -77,7 +82,7 @@ func (r *Repository) ListSellerHandoffs(ctx context.Context, userID uint64, quer for _, row := range rows { items = append(items, row.toUserListItem(userID, nil)) } - metrics, err := r.sellerHandoffMetrics(ctx, userID) + metrics, err := r.sellerHandoffMetrics(ctx, userID, query.SiteCode) if err != nil { return nil, err } @@ -90,16 +95,21 @@ func (r *Repository) ListSellerHandoffs(ctx context.Context, userID uint64, quer }, nil } -func (r *Repository) sellerHandoffMetrics(ctx context.Context, userID uint64) (SellerHandoffMetrics, error) { +func (r *Repository) sellerHandoffMetrics(ctx context.Context, userID uint64, siteCode ...string) (SellerHandoffMetrics, error) { base := r.db.WithContext(ctx).Table("rental_orders AS o").Where("o.owner_id = ?", userID) + if len(siteCode) > 0 && siteCode[0] != "" { + base = base.Joins("INNER JOIN distribution_sites AS ds ON ds.id = o.distribution_site_id AND ds.site_code = ?", siteCode[0]) + } else if len(siteCode) > 0 { + base = base.Where("o.distribution_site_id IS NULL OR o.distribution_site_id = 0") + } var metrics SellerHandoffMetrics - if err := base.Where("o.status = ? AND o.handoff_status = ?", orderStatusPendingHandoff, handoffStatusPendingOwner).Count(&metrics.PendingHandoff).Error; err != nil { + if err := base.Session(&gorm.Session{}).Where("o.status = ? AND o.handoff_status = ?", orderStatusPendingHandoff, handoffStatusPendingOwner).Count(&metrics.PendingHandoff).Error; err != nil { return SellerHandoffMetrics{}, err } - if err := r.db.WithContext(ctx).Table("rental_orders AS o").Where("o.owner_id = ? AND o.status = ?", userID, orderStatusPendingCheckoutConfirm).Count(&metrics.PendingCheckout).Error; err != nil { + if err := base.Session(&gorm.Session{}).Where("o.status = ?", orderStatusPendingCheckoutConfirm).Count(&metrics.PendingCheckout).Error; err != nil { return SellerHandoffMetrics{}, err } - if err := r.db.WithContext(ctx).Table("rental_orders AS o").Where("o.owner_id = ? AND o.status IN ?", userID, sellerHandoffAbnormalStatuses()).Count(&metrics.Abnormal).Error; err != nil { + if err := base.Session(&gorm.Session{}).Where("o.status IN ?", sellerHandoffAbnormalStatuses()).Count(&metrics.Abnormal).Error; err != nil { return SellerHandoffMetrics{}, err } return metrics, nil diff --git a/backend/migrations/000073_fix_distribution_listing_attribution.sql b/backend/migrations/000073_fix_distribution_listing_attribution.sql new file mode 100644 index 0000000..5bfc672 --- /dev/null +++ b/backend/migrations/000073_fix_distribution_listing_attribution.sql @@ -0,0 +1,11 @@ +-- +goose Up + +-- 修复 000072 中误将展示货架 (distribution_site_accounts) 历史商品回填为分销站点来源的问题 +-- 历史主站商品(在分销上传功能上线前创建的商品)来源应保持 distribution_site_id = NULL +UPDATE rental_listings l +JOIN distribution_site_accounts dsa ON dsa.listing_id = l.id +SET l.distribution_site_id = NULL +WHERE l.created_at < '2026-09-08 14:00:00'; + +-- +goose Down +SELECT 1; diff --git a/frontend/src/features/auth/views/MobileProfileView.css b/frontend/src/features/auth/views/MobileProfileView.css index 0f2c930..85c9772 100644 --- a/frontend/src/features/auth/views/MobileProfileView.css +++ b/frontend/src/features/auth/views/MobileProfileView.css @@ -135,6 +135,15 @@ font-weight: 900; } +.growth-card.is-distribution > div { + background: #f0fdf4; + border-color: rgba(167, 243, 208, 0.6); +} + +.growth-card.is-distribution strong { + color: #059669; +} + /* ========== Balance Card ========== */ .balance-card { margin: 12px 16px 14px; diff --git a/frontend/src/features/auth/views/MobileProfileView.vue b/frontend/src/features/auth/views/MobileProfileView.vue index 83c8efa..ed8761d 100644 --- a/frontend/src/features/auth/views/MobileProfileView.vue +++ b/frontend/src/features/auth/views/MobileProfileView.vue @@ -18,7 +18,10 @@ import { uploadFile } from '@/shared/api/files' import { formatCent } from '@/shared/utils/money' import { setPassword } from '@/features/auth/api/auth' +import { useDistributionSiteStore } from '@/stores/distributionSite' + const session = useSessionStore() +const siteStore = useDistributionSiteStore() const router = useRouter() const route = useRoute() const { unreadCount: notificationUnreadCount, unreadLabel: notificationUnreadLabel } = @@ -353,7 +356,17 @@ function resolveAvatarURL(url: string | undefined | null) { -
+
+
+ 所属商城 + {{ siteStore.siteName || '专属分销商城' }} +
+
+ 会员特权 + 渠道直租专享 +
+
+
租客成长等级 {{ session.renterGrowthLevelName }} diff --git a/frontend/src/features/listings/composables/useListingCollections.ts b/frontend/src/features/listings/composables/useListingCollections.ts index fdd4e9f..edd5100 100644 --- a/frontend/src/features/listings/composables/useListingCollections.ts +++ b/frontend/src/features/listings/composables/useListingCollections.ts @@ -1,9 +1,18 @@ import { computed, ref } from 'vue' import type { PublicListingItem } from '@/features/listings/api/listings' +import { getEffectiveSiteCode } from '@/stores/distributionSite' + +function getHistoryKey() { + const code = getEffectiveSiteCode() + return code ? `hfb.listing.view.history.${code}` : 'hfb.listing.view.history' +} + +function getFavoriteKey() { + const code = getEffectiveSiteCode() + return code ? `hfb.listing.favorites.${code}` : 'hfb.listing.favorites' +} -const historyKey = 'hfb.listing.view.history' -const favoriteKey = 'hfb.listing.favorites' const maxHistoryItems = 80 export interface ListingCollectionItem { @@ -12,10 +21,12 @@ export interface ListingCollectionItem { favorited_at?: string } -const historyItems = ref(readItems(historyKey)) -const favoriteItems = ref(readItems(favoriteKey)) - export function useListingCollections() { + const historyKey = getHistoryKey() + const favoriteKey = getFavoriteKey() + const historyItems = ref(readItems(historyKey)) + const favoriteItems = ref(readItems(favoriteKey)) + const history = computed(() => historyItems.value) const favorites = computed(() => favoriteItems.value) const favoriteIDs = computed(() => new Set(favoriteItems.value.map(item => item.listing.id))) diff --git a/frontend/src/features/seller/views/SellerPickupsView.vue b/frontend/src/features/seller/views/SellerPickupsView.vue index b5ba4bd..35c2cb1 100644 --- a/frontend/src/features/seller/views/SellerPickupsView.vue +++ b/frontend/src/features/seller/views/SellerPickupsView.vue @@ -1,17 +1,27 @@