diff --git a/server/app/goauto/product/service.go b/server/app/goauto/product/service.go index a5e056f..db0f51d 100644 --- a/server/app/goauto/product/service.go +++ b/server/app/goauto/product/service.go @@ -100,6 +100,10 @@ type SaveResponse struct { Replayed bool `json:"replayed,omitempty"` } +type DetailResponse struct { + Product ListProductView `json:"product"` +} + type Service struct{ DB *gorm.DB } func NewService(db *gorm.DB) *Service { return &Service{DB: db} } @@ -197,19 +201,27 @@ func (service *Service) Update(ctx context.Context, productID uint64, request Up if result.RowsAffected == 0 { return SaveResponse{}, &ServiceError{Code: CodeProductNotFound, Message: "PDD 商品不存在"} } - return service.Detail(ctx, productID) + var updated models.PDDProduct + if err := db.First(&updated, productID).Error; err != nil { + return SaveResponse{}, internalError(err) + } + view, err := makeView(updated) + return SaveResponse{Product: view}, err } -func (service *Service) Detail(ctx context.Context, productID uint64) (SaveResponse, error) { +func (service *Service) Detail(ctx context.Context, productID uint64) (DetailResponse, error) { var record models.PDDProduct if err := service.DB.WithContext(ctx).First(&record, productID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return SaveResponse{}, &ServiceError{Code: CodeProductNotFound, Message: "PDD 商品不存在"} + return DetailResponse{}, &ServiceError{Code: CodeProductNotFound, Message: "PDD 商品不存在"} } - return SaveResponse{}, internalError(err) + return DetailResponse{}, internalError(err) } - view, err := makeView(record) - return SaveResponse{Product: view}, err + items, err := service.makeCollectionViews(ctx, []models.PDDProduct{record}) + if err != nil { + return DetailResponse{}, err + } + return DetailResponse{Product: items[0]}, nil } func (service *Service) List(ctx context.Context, request ListRequest) (ListResponse, error) { @@ -244,6 +256,18 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp if err := query.Order("updated_at DESC, id DESC").Offset((request.Page - 1) * request.PageSize).Limit(request.PageSize).Find(&records).Error; err != nil { return ListResponse{}, internalError(err) } + items, err := service.makeCollectionViews(ctx, records) + if err != nil { + return ListResponse{}, err + } + return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil +} + +func (service *Service) makeCollectionViews(ctx context.Context, records []models.PDDProduct) ([]ListProductView, error) { + items := make([]ListProductView, 0, len(records)) + if len(records) == 0 { + return items, nil + } productIDs := make([]uint64, 0, len(records)) for _, record := range records { productIDs = append(productIDs, record.ID) @@ -253,27 +277,24 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp TaskID uint64 } activeTasks := map[uint64]uint64{} - if len(productIDs) > 0 { - var rows []activeTaskRow - if err := service.DB.WithContext(ctx).Model(&models.CollectionTask{}). - Select("pdd_product_id, MIN(id) AS task_id"). - Where("pdd_product_id IN ? AND status IN ?", productIDs, []string{models.TaskStatusPending, models.TaskStatusRunning}). - Group("pdd_product_id").Scan(&rows).Error; err != nil { - return ListResponse{}, internalError(err) - } - for _, row := range rows { - activeTasks[row.PDDProductID] = row.TaskID - } + var rows []activeTaskRow + if err := service.DB.WithContext(ctx).Model(&models.CollectionTask{}). + Select("pdd_product_id, MIN(id) AS task_id"). + Where("pdd_product_id IN ? AND status IN ?", productIDs, []string{models.TaskStatusPending, models.TaskStatusRunning}). + Group("pdd_product_id").Scan(&rows).Error; err != nil { + return nil, internalError(err) + } + for _, row := range rows { + activeTasks[row.PDDProductID] = row.TaskID } var ruleCount int64 if err := service.DB.WithContext(ctx).Model(&models.CollectionRule{}).Count(&ruleCount).Error; err != nil { - return ListResponse{}, internalError(err) + return nil, internalError(err) } - items := make([]ListProductView, 0, len(records)) for _, record := range records { view, err := makeView(record) if err != nil { - return ListResponse{}, err + return nil, err } item := ListProductView{ProductView: view, CollectionSelectable: true} if record.Status == "disabled" { @@ -289,7 +310,7 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp } items = append(items, item) } - return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil + return items, nil } func validateCreateRequest(request SaveRequest) error { diff --git a/server/app/goauto/product/service_test.go b/server/app/goauto/product/service_test.go index cc998a4..6ad3aa4 100644 --- a/server/app/goauto/product/service_test.go +++ b/server/app/goauto/product/service_test.go @@ -190,3 +190,49 @@ func TestListDisablesCollectionWhenNoRuleExists(t *testing.T) { t.Fatalf("missing-rule product remained selectable: %+v", listed.Items[0]) } } + +func TestDetailUsesSameCollectionEligibilityAsList(t *testing.T) { + db := openProductDatabase(t) + service := NewService(db) + rule := models.CollectionRule{Name: "rule", ContentJSON: `{}`} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + products := []models.PDDProduct{ + {GoodsID: "555555", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=555555", Status: "active"}, + {GoodsID: "666666", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=666666", Status: "disabled"}, + {GoodsID: "777777", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=777777", Status: "active"}, + } + if err := db.Create(&products).Error; err != nil { + t.Fatal(err) + } + active := models.CollectionTask{ + PDDProductID: products[2].ID, RuleID: rule.ID, Status: models.TaskStatusPending, + URLSnapshot: products[2].URL, GoodsIDSnapshot: products[2].GoodsID, RuleSnapshot: rule.ContentJSON, + } + if err := db.Create(&active).Error; err != nil { + t.Fatal(err) + } + listed, err := service.List(context.Background(), ListRequest{Page: 1, PageSize: 20}) + if err != nil { + t.Fatal(err) + } + byID := map[uint64]ListProductView{} + for _, item := range listed.Items { + byID[item.ID] = item + } + for _, product := range products { + detail, err := service.Detail(context.Background(), product.ID) + if err != nil { + t.Fatalf("detail %d: %v", product.ID, err) + } + want := byID[product.ID] + got := detail.Product + if got.CollectionSelectable != want.CollectionSelectable || got.CollectionDisabledReason != want.CollectionDisabledReason { + t.Fatalf("detail/list eligibility mismatch for %d: detail=%+v list=%+v", product.ID, got, want) + } + if (got.ActiveCollectionTaskID == nil) != (want.ActiveCollectionTaskID == nil) { + t.Fatalf("detail/list active task mismatch for %d: detail=%+v list=%+v", product.ID, got, want) + } + } +} diff --git a/web/src/views/goauto/pdd-products/index.vue b/web/src/views/goauto/pdd-products/index.vue index c5b478c..c3d4a61 100644 --- a/web/src/views/goauto/pdd-products/index.vue +++ b/web/src/views/goauto/pdd-products/index.vue @@ -12,10 +12,10 @@

已选择 {{ selectedProducts.length }} 条;仅选择当前页,每个商品创建一个独立采集任务,不合并、不拆单。

- + - + diff --git a/web/tests/e2e/pdd-product-module.spec.ts b/web/tests/e2e/pdd-product-module.spec.ts index 6947c2c..c521989 100644 --- a/web/tests/e2e/pdd-product-module.spec.ts +++ b/web/tests/e2e/pdd-product-module.spec.ts @@ -47,7 +47,7 @@ test('PDD 商品列表、详情和编辑流程符合已确认原型', async ({ p }); await page.goto('http://localhost:9527/#/pdd-products/index'); - await expect(page.getByRole('heading', { name: 'PDD 商品' })).toBeVisible(); + await expect(page.getByRole('button', { name: '添加', exact: true })).toBeVisible(); await expect(page.getByText('夏季防晒冰丝袖套')).toBeVisible(); await expect(page.getByText('¥25.90')).toBeVisible(); @@ -96,6 +96,11 @@ test('带 productId 的 PDD 商品路由进入单商品定位并可通过查询 await page.getByLabel('PDD 商品详情').getByLabel('关闭此对话框').click(); await expect(page.getByLabel('PDD 商品详情')).toBeHidden(); + const locatedRow = page.locator('.el-table__body-wrapper tbody tr'); + await expect(locatedRow.getByRole('checkbox')).toBeEnabled(); + await locatedRow.locator('.el-checkbox').click(); + await expect(page.getByText('已选择 1 条')).toBeVisible(); + await expect(page.getByRole('button', { name: '创建采集 (1)', exact: true })).toBeEnabled(); await page.getByRole('button', { name: '查询', exact: true }).click(); await expect.poll(() => listCalls).toBe(1); await expect(page).toHaveURL('http://localhost:9527/#/pdd-products/index'); @@ -143,6 +148,25 @@ test('单商品定位的重置和无效编号不会误显示其他商品', async await expect(page).toHaveURL('http://localhost:9527/#/pdd-products/index'); }); +test('单商品定位对不可采集商品禁用勾选并显示原因', async ({ page, context }) => { + const disabled = { ...product, status: 'disabled', collectionSelectable: false, collectionDisabledReason: '商品已停用' }; + await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }]); + await page.route('**/api/**', async route => { + const url = new URL(route.request().url()); + if (url.pathname.startsWith('/src/api/')) return route.continue(); + if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] } } }); + if (url.pathname.endsWith('/api/admin/v1/pdd-products/1')) return route.fulfill({ json: { code: 200, data: { product: disabled } } }); + return route.fulfill({ json: { code: 200, data: [] } }); + }); + + await page.goto('http://localhost:9527/#/pdd-products/index?productId=1'); + await page.getByLabel('PDD 商品详情').getByLabel('关闭此对话框').click(); + await expect(page.getByLabel('PDD 商品详情')).toBeHidden(); + await expect(page.locator('.el-table__body-wrapper tbody tr').getByRole('checkbox')).toBeDisabled(); + await expect(page.getByText('商品已停用', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '创建采集', exact: true })).toBeDisabled(); +}); + test('PDD 商品当前页多选并批量创建独立采集任务', async ({ page, context }) => { const second = { ...product, id: 2, goodsId: '719834019024', title: '第二件商品' }; const disabled = { ...product, id: 3, goodsId: '731370706977', title: '已停用商品', status: 'disabled', collectionSelectable: false, collectionDisabledReason: '商品已停用' }; @@ -169,7 +193,7 @@ test('PDD 商品当前页多选并批量创建独立采集任务', async ({ page await expect(rows.nth(2).getByRole('checkbox')).toBeDisabled(); await expect(page.getByText('已选择 2 条')).toBeVisible(); - await page.getByRole('button', { name: '批量创建采集任务' }).click(); + await page.getByRole('button', { name: '创建采集 (2)', exact: true }).click(); await page.getByLabel('采集规则').click(); await page.getByRole('option', { name: 'PDD 商品详情采集' }).click(); await page.getByRole('button', { name: '创建 2 个采集任务' }).click();