Files
zyniaoren 469b2ee072 feat: 后台商品列表新增上架状态筛选
管理页商品列表原本只能按搜索、库存状态、分类、批发价过滤,缺少上下架状态
维度,无法快速定位未上架商品。

功能:
- 新增 is_active 三态查询参数(缺省/all 不限、1 仅已上架、0 仅已下架),按既有
  筛选分层落位:contract 增加 ListFilter.IsActive,store 拼接 is_active 条件,
  application 透传,transport 解析入参。
- IsActive 与公开侧的 OnlyActive 语义不同,后者是强制上架开关;两者同时存在时
  不叠加 IsActive,避免组合出必然为空的查询。
- 原 parseWholesaleFilter 重命名为 parseTriStateBoolFilter,由 wholesale 与
  is_active 共用,避免复制一份真/假值解析逻辑。
- 管理页筛选栏新增「上架状态」下拉,与其余四个条件按 AND 组合,重置按钮一并
  复位;三语文案补齐。上架状态筛选生效时,行内切换上下架后重新拉取列表,
  避免该行与筛选结果不符。

影响范围:仅后台商品列表的读取链路,未改动公开商品接口与商品写入逻辑。

测试:
- 仓储层 3 个用例:三态筛选、与 search/stock_status/wholesale 的 AND 组合、
  OnlyActive 优先级;集成层 1 个用例覆盖 HTTP 到持久化的完整链路。
- 补充 PostgreSQL 覆盖,验证 IsActive 与本地化搜索(ILIKE + jsonb ->>)、
  批发价(jsonb_array_length)按 AND 组合的行为与 SQLite 一致。
- 顺带修复该文件的 PostgreSQL 测试夹具:setupPostgresIntegrationDB 未迁移
  product_skus,而商品搜索条件含有对 product_skus 的 EXISTS 子查询,导致既有的
  TestPostgresLocalizedJSONSearchRepositories 与 TestPostgresDashboardQueries
  在真实 PostgreSQL 上一律报 relation "product_skus" does not exist。已在 main
  上复现同样的失败,确认属既有问题,与本次功能改动无关。

验证:SQLite 下 go test ./... 187 个包全部通过;在隔离的 PostgreSQL 17.9 实例上
go test -tags integration ./... -count=1 -p 1 共 188 个包全部通过,其中 7 个
PostgreSQL 用例实际执行;gofmt 与 go vet 无告警;admin 端 vue-tsc 构建与 28 项
单测通过。
2026-09-12 13:43:23 +08:00

496 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build integration
// +build integration
package integrationtest
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
paymentdomain "github.com/dujiao-next/internal/modules/payment/domain"
orderdomain "github.com/dujiao-next/internal/modules/order/domain"
productcontract "github.com/dujiao-next/internal/modules/catalog/product/contract"
productdomain "github.com/dujiao-next/internal/modules/catalog/product/domain"
categorydomain "github.com/dujiao-next/internal/modules/catalog/category/domain"
"github.com/dujiao-next/internal/constants"
productgormstore "github.com/dujiao-next/internal/modules/catalog/product/store/gormstore"
contentcontract "github.com/dujiao-next/internal/modules/content/contract"
contentdomain "github.com/dujiao-next/internal/modules/content/domain"
contentgormstore "github.com/dujiao-next/internal/modules/content/infrastructure/gormstore"
dashboardgormstore "github.com/dujiao-next/internal/modules/dashboard/infrastructure/gormstore"
"github.com/dujiao-next/internal/shared/jsonmap"
"github.com/dujiao-next/internal/shared/money"
"github.com/shopspring/decimal"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// setupPostgresIntegrationDB 初始化 PostgreSQL 集成测试数据库。
func setupPostgresIntegrationDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := strings.TrimSpace(os.Getenv("TEST_POSTGRES_DSN"))
if dsn == "" {
t.Skip("skip postgres integration test: TEST_POSTGRES_DSN is empty")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("open postgres failed: %v", err)
}
cleanupModels := []interface{}{
&orderdomain.OrderItem{},
&paymentdomain.Payment{},
&orderdomain.Order{},
&contentdomain.PostProduct{},
&productdomain.ProductSKU{},
&productdomain.Product{},
&categorydomain.Category{},
&contentdomain.Banner{},
&contentdomain.Post{},
}
_ = db.Migrator().DropTable(cleanupModels...)
// product_skus 必须建表:商品搜索条件里含有对 product_skus 的 EXISTS 子查询
if err := db.AutoMigrate(
&categorydomain.Category{},
&productdomain.Product{},
&productdomain.ProductSKU{},
&contentdomain.Post{},
&contentdomain.PostProduct{},
&contentdomain.Banner{},
&orderdomain.Order{},
&orderdomain.OrderItem{},
&paymentdomain.Payment{},
); err != nil {
t.Fatalf("migrate postgres models failed: %v", err)
}
t.Cleanup(func() {
_ = db.Migrator().DropTable(cleanupModels...)
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
return db
}
func TestPostgresLocalizedJSONSearchRepositories(t *testing.T) {
db := setupPostgresIntegrationDB(t)
ctx := context.Background()
category := &categorydomain.Category{
Slug: "pg-category",
NameJSON: jsonmap.JSON{"zh-CN": "Postgres 分类"},
}
if err := db.Create(category).Error; err != nil {
t.Fatalf("create category failed: %v", err)
}
productRepo := productgormstore.NewProductStore(db)
product := &productdomain.Product{
CategoryID: category.ID,
Slug: "pg-product-rocket",
TitleJSON: jsonmap.JSON{"zh-CN": "火箭会员"},
DescriptionJSON: jsonmap.JSON{"en-US": "rocket booster package"},
PriceAmount: money.FromDecimal(decimal.NewFromInt(99)),
PurchaseType: constants.ProductPurchaseMember,
FulfillmentType: constants.FulfillmentTypeManual,
ManualStockTotal: 10,
IsActive: true,
}
if err := productRepo.Create(product); err != nil {
t.Fatalf("create product failed: %v", err)
}
productRows, productTotal, err := productRepo.List(productcontract.ListFilter{
Page: 1,
Search: "火箭",
})
if err != nil {
t.Fatalf("product list search zh-CN failed: %v", err)
}
if productTotal != 1 || len(productRows) != 1 {
t.Fatalf("product list search zh-CN want 1 got total=%d len=%d", productTotal, len(productRows))
}
productRows, productTotal, err = productRepo.List(productcontract.ListFilter{
Page: 1,
Search: "booster",
})
if err != nil {
t.Fatalf("product list search en-US failed: %v", err)
}
if productTotal != 1 || len(productRows) != 1 {
t.Fatalf("product list search en-US want 1 got total=%d len=%d", productTotal, len(productRows))
}
postStore := contentgormstore.NewPostStore(db)
post := &contentdomain.Post{
Slug: "pg-post-release",
Type: "notice",
TitleJSON: jsonmap.JSON{"en-US": "Release Notes"},
IsPublished: true,
}
if err := postStore.Create(ctx, post); err != nil {
t.Fatalf("create post failed: %v", err)
}
postRows, postTotal, err := postStore.List(ctx, contentcontract.PostQuery{
Page: 1,
Search: "Release",
Order: contentcontract.PostOrderCreatedDesc,
})
if err != nil {
t.Fatalf("post list search failed: %v", err)
}
if postTotal != 1 || len(postRows) != 1 {
t.Fatalf("post list search want 1 got total=%d len=%d", postTotal, len(postRows))
}
bannerStore := contentgormstore.NewBannerStore(db)
banner := &contentdomain.Banner{
Name: "pg-home-banner",
Position: "home",
TitleJSON: jsonmap.JSON{"zh-CN": "春季大促"},
Image: "/banner.png",
LinkType: "none",
IsActive: true,
}
if err := bannerStore.Create(ctx, banner); err != nil {
t.Fatalf("create banner failed: %v", err)
}
bannerRows, bannerTotal, err := bannerStore.List(ctx, contentcontract.BannerQuery{
Page: 1,
Search: "春季",
})
if err != nil {
t.Fatalf("banner list search failed: %v", err)
}
if bannerTotal != 1 || len(bannerRows) != 1 {
t.Fatalf("banner list search want 1 got total=%d len=%d", bannerTotal, len(bannerRows))
}
}
func TestPostgresContentGormStoresPreserveQuerySemantics(t *testing.T) {
db := setupPostgresIntegrationDB(t)
ctx := context.Background()
now := time.Now().UTC()
older := now.Add(-2 * time.Hour)
newer := now.Add(-time.Hour)
posts := []contentdomain.Post{
{Slug: "pg-content-older", Type: constants.PostTypeBlog, TitleJSON: jsonmap.JSON{"zh-CN": "模块化指南"}, IsPublished: true, PublishedAt: &older},
{Slug: "pg-content-newer", Type: constants.PostTypeBlog, TitleJSON: jsonmap.JSON{"zh-CN": "模块化指南新版"}, IsPublished: true, PublishedAt: &newer},
{Slug: "pg-content-draft", Type: constants.PostTypeBlog, TitleJSON: jsonmap.JSON{"zh-CN": "模块化指南草稿"}, IsPublished: false},
}
for index := range posts {
if err := db.Create(&posts[index]).Error; err != nil {
t.Fatalf("create postgres content post %q: %v", posts[index].Slug, err)
}
}
postStore := contentgormstore.NewPostStore(db)
modularPosts, modularTotal, err := postStore.List(ctx, contentcontract.PostQuery{
Page: 1,
PageSize: 20,
Type: constants.PostTypeBlog,
Search: "模块化",
OnlyPublished: true,
Order: contentcontract.PostOrderPublishedDesc,
})
if err != nil {
t.Fatalf("modular postgres post query: %v", err)
}
if modularTotal != 2 || len(modularPosts) != 2 {
t.Fatalf("postgres post query should return two published rows, total=%d rows=%#v", modularTotal, modularPosts)
}
if modularPosts[0].Slug != "pg-content-newer" || modularPosts[1].Slug != "pg-content-older" {
t.Fatalf("postgres post publication order mismatch: %#v", modularPosts)
}
rollbackPost := &contentdomain.Post{
Slug: "pg-content-rollback",
Type: constants.PostTypeBlog,
TitleJSON: jsonmap.JSON{"zh-CN": "事务回滚"},
}
forcedRollback := errors.New("forced content transaction rollback")
err = postStore.WithinPostWriteTransaction(ctx, func(posts contentcontract.PostStore, relations contentcontract.PostProductRelationStore) error {
if createErr := posts.Create(ctx, rollbackPost); createErr != nil {
return createErr
}
if relationErr := relations.SetRelatedProductIDs(ctx, rollbackPost.ID, []uint{42}); relationErr != nil {
return relationErr
}
return forcedRollback
})
if !errors.Is(err, forcedRollback) {
t.Fatalf("postgres content transaction should return callback error, got %v", err)
}
var rollbackPostCount int64
if err := db.Model(&contentdomain.Post{}).Where("slug = ?", rollbackPost.Slug).Count(&rollbackPostCount).Error; err != nil {
t.Fatalf("count rolled back postgres content post: %v", err)
}
if rollbackPostCount != 0 {
t.Fatalf("postgres content transaction should roll back post, count=%d", rollbackPostCount)
}
banner := contentdomain.Banner{
Name: "pg-content-banner",
Position: constants.BannerPositionHomeHero,
TitleJSON: jsonmap.JSON{"en-US": "Modular launch"},
Image: "/pg-content.png",
LinkType: constants.BannerLinkTypeNone,
IsActive: true,
}
if err := db.Create(&banner).Error; err != nil {
t.Fatalf("create postgres content banner: %v", err)
}
bannerStore := contentgormstore.NewBannerStore(db)
modularBanners, modularBannerTotal, err := bannerStore.List(ctx, contentcontract.BannerQuery{Page: 1, PageSize: 20, Search: "Modular"})
if err != nil {
t.Fatalf("modular postgres banner query: %v", err)
}
if modularBannerTotal != 1 || len(modularBanners) != 1 || modularBanners[0].ID != banner.ID {
t.Fatalf("postgres banner localized search mismatch total=%d rows=%#v", modularBannerTotal, modularBanners)
}
inactiveBanner := &contentdomain.Banner{
Name: "pg-content-inactive-banner",
Position: constants.BannerPositionHomeHero,
Image: "/pg-content-inactive.png",
LinkType: constants.BannerLinkTypeNone,
IsActive: false,
}
if err := bannerStore.Create(ctx, inactiveBanner); err != nil {
t.Fatalf("create inactive postgres content banner: %v", err)
}
var reloadedInactiveBanner contentdomain.Banner
if err := db.First(&reloadedInactiveBanner, inactiveBanner.ID).Error; err != nil {
t.Fatalf("reload inactive postgres content banner: %v", err)
}
if inactiveBanner.IsActive || reloadedInactiveBanner.IsActive {
t.Fatalf("explicit inactive postgres banner should stay inactive, returned=%t stored=%t", inactiveBanner.IsActive, reloadedInactiveBanner.IsActive)
}
}
// 后台商品列表的上架状态筛选在 PostgreSQL 上需与 SQLite 语义一致:
// IsActive 三态过滤必须与 search(PostgreSQL 走 ILIKE + jsonb ->>)、
// wholesale(PostgreSQL 走 jsonb_array_length)按 AND 正确组合。
func TestPostgresProductListActiveStateFilter(t *testing.T) {
db := setupPostgresIntegrationDB(t)
category := &categorydomain.Category{
Slug: "pg-active-state-category",
NameJSON: jsonmap.JSON{"zh-CN": "上架状态分类"},
}
if err := db.Create(category).Error; err != nil {
t.Fatalf("create category failed: %v", err)
}
productRepo := productgormstore.NewProductStore(db)
seed := func(slug string, isActive bool, withWholesale bool) *productdomain.Product {
product := &productdomain.Product{
CategoryID: category.ID,
Slug: slug,
TitleJSON: jsonmap.JSON{"zh-CN": "上架状态商品"},
PriceAmount: money.FromDecimal(decimal.NewFromInt(50)),
PurchaseType: constants.ProductPurchaseMember,
FulfillmentType: constants.FulfillmentTypeManual,
ManualStockTotal: 5,
IsActive: isActive,
}
if withWholesale {
product.WholesalePrices = productdomain.WholesalePriceTiers{
{MinQuantity: 5, UnitPrice: money.FromDecimal(decimal.NewFromInt(40))},
}
}
if err := productRepo.Create(product); err != nil {
t.Fatalf("create postgres product %q failed: %v", slug, err)
}
// is_active 带 default:false,需确认下架商品在 PostgreSQL 上确实落库为 false
var stored productdomain.Product
if err := db.First(&stored, product.ID).Error; err != nil {
t.Fatalf("reload postgres product %q failed: %v", slug, err)
}
if stored.IsActive != isActive {
t.Fatalf("postgres product %q stored is_active=%t want %t", slug, stored.IsActive, isActive)
}
return product
}
active := seed("pg-state-active", true, true)
inactive := seed("pg-state-inactive", false, true)
inactiveWithoutWholesale := seed("pg-state-inactive-plain", false, false)
listSlugs := func(filter productcontract.ListFilter) map[string]bool {
t.Helper()
filter.Page = 1
filter.PageSize = 50
rows, _, err := productRepo.List(filter)
if err != nil {
t.Fatalf("postgres product list failed: %v", err)
}
got := make(map[string]bool, len(rows))
for _, row := range rows {
got[row.Slug] = true
}
return got
}
onlyActive := true
onlyInactive := false
withWholesale := true
got := listSlugs(productcontract.ListFilter{IsActive: &onlyActive})
if !got[active.Slug] || got[inactive.Slug] {
t.Fatalf("postgres IsActive=true should return only active rows, got %+v", got)
}
got = listSlugs(productcontract.ListFilter{IsActive: &onlyInactive})
if !got[inactive.Slug] || !got[inactiveWithoutWholesale.Slug] || got[active.Slug] {
t.Fatalf("postgres IsActive=false should return only inactive rows, got %+v", got)
}
got = listSlugs(productcontract.ListFilter{IsActive: &onlyInactive, Search: "上架状态"})
if !got[inactive.Slug] || got[active.Slug] {
t.Fatalf("postgres IsActive=false + localized search mismatch, got %+v", got)
}
got = listSlugs(productcontract.ListFilter{IsActive: &onlyInactive, HasWholesalePrices: &withWholesale})
if !got[inactive.Slug] || got[inactiveWithoutWholesale.Slug] || got[active.Slug] {
t.Fatalf("postgres IsActive=false + wholesale mismatch, got %+v", got)
}
got = listSlugs(productcontract.ListFilter{IsActive: &onlyInactive, Search: "绝不匹配的关键词"})
if len(got) != 0 {
t.Fatalf("postgres IsActive=false + non-matching search should be empty, got %+v", got)
}
}
func TestPostgresDashboardQueries(t *testing.T) {
db := setupPostgresIntegrationDB(t)
repo := dashboardgormstore.New(db)
now := time.Now().UTC().Truncate(time.Second)
category := &categorydomain.Category{
Slug: "pg-dashboard-category",
NameJSON: jsonmap.JSON{"zh-CN": "仪表盘分类"},
}
if err := db.Create(category).Error; err != nil {
t.Fatalf("create category failed: %v", err)
}
product := &productdomain.Product{
CategoryID: category.ID,
Slug: "pg-dashboard-product",
TitleJSON: jsonmap.JSON{"zh-CN": "仪表盘商品"},
PriceAmount: money.FromDecimal(decimal.NewFromInt(120)),
PurchaseType: constants.ProductPurchaseMember,
FulfillmentType: constants.FulfillmentTypeManual,
IsActive: true,
}
if err := db.Create(product).Error; err != nil {
t.Fatalf("create product failed: %v", err)
}
order := &orderdomain.Order{
OrderNo: "PG-ORDER-001",
UserID: 1,
Status: constants.OrderStatusPaid,
Currency: "USD",
OriginalAmount: money.FromDecimal(decimal.NewFromInt(120)),
DiscountAmount: money.FromDecimal(decimal.Zero),
TotalAmount: money.FromDecimal(decimal.NewFromInt(120)),
CreatedAt: now,
}
if err := db.Create(order).Error; err != nil {
t.Fatalf("create order failed: %v", err)
}
orderItem := &orderdomain.OrderItem{
OrderID: order.ID,
ProductID: product.ID,
TitleJSON: jsonmap.JSON{"zh-CN": "仪表盘商品"},
UnitPrice: money.FromDecimal(decimal.NewFromInt(120)),
Quantity: 2,
TotalPrice: money.FromDecimal(decimal.NewFromInt(240)),
CouponDiscount: money.FromDecimal(decimal.NewFromInt(20)),
PromotionDiscount: money.FromDecimal(decimal.NewFromInt(10)),
FulfillmentType: constants.FulfillmentTypeManual,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(orderItem).Error; err != nil {
t.Fatalf("create order item failed: %v", err)
}
payment := &paymentdomain.Payment{
OrderID: order.ID,
ChannelID: 1,
ProviderType: constants.PaymentProviderOfficial,
ChannelType: constants.PaymentChannelTypeAlipay,
InteractionMode: constants.PaymentInteractionRedirect,
Amount: money.FromDecimal(decimal.NewFromInt(120)),
FeeRate: money.FromDecimal(decimal.Zero),
FeeAmount: money.FromDecimal(decimal.Zero),
Currency: "USD",
Status: constants.PaymentStatusSuccess,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(payment).Error; err != nil {
t.Fatalf("create payment failed: %v", err)
}
startAt := now.Add(-time.Hour)
endAt := now.Add(time.Hour)
topProducts, err := repo.GetTopProducts(startAt, endAt, 5)
if err != nil {
t.Fatalf("get top products failed: %v", err)
}
if len(topProducts) != 1 {
t.Fatalf("top products len want 1 got %d", len(topProducts))
}
if topProducts[0].Title != "仪表盘商品" {
t.Fatalf("top product title want 仪表盘商品 got %s", topProducts[0].Title)
}
orderTrends, err := repo.GetOrderTrends(startAt, endAt)
if err != nil {
t.Fatalf("get order trends failed: %v", err)
}
if len(orderTrends) == 0 {
t.Fatalf("order trends should not be empty")
}
if strings.TrimSpace(orderTrends[0].Day) == "" {
t.Fatalf("order trend day should not be empty")
}
paymentTrends, err := repo.GetPaymentTrends(startAt, endAt)
if err != nil {
t.Fatalf("get payment trends failed: %v", err)
}
if len(paymentTrends) == 0 {
t.Fatalf("payment trends should not be empty")
}
if strings.TrimSpace(paymentTrends[0].Day) == "" {
t.Fatalf("payment trend day should not be empty")
}
}