diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index 3f2b7b1..e6e4a91 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Project-Profile wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.- -wiki_revision: dd160353a57296e667c32d79d1e9b9bc07f0bb6b -synchronized_at: 2026-08-17T09:01:15Z +wiki_revision: d5b5407a09e3bcdc0bc34be2eb244e781c557420 +synchronized_at: 2026-08-17T09:15:47Z # 项目档案 @@ -66,4 +66,4 @@ synchronized_at: 2026-08-17T09:01:15Z 当前采集 MVP 的 T01~T22 和 T24 已完成实现并由用户验收;T08 只读实时屏幕已延期。T23、T25~T28 的一加真机增强、假售罄恢复、浏览器重开恢复和端口配置已经实现,其中部分工单仍等待用户验收。T24 的规格遍历已在一加和三星真机复核;三星未识别评价数量的问题由用户确认暂不处理。 -#31 PDD 商品档案原型、数据库、API、Admin 页面和采集结果写回已于 2026-08-17 通过用户验收;#45 是下一张采集域工单,独立实现 PDD 商品列表多选和批量创建采集任务。#32 采购闭环原型及后续采购工单仍按各自门禁推进。采购永不支付,真实地址修改和创建订单属于必须再次人工确认的高风险范围。 +#31 PDD 商品档案原型、数据库、API、Admin 页面和采集结果写回已于 2026-08-17 通过用户验收;#45 已按确认原型实现 PDD 商品列表当前页多选、禁用原因、批量确认、逐商品幂等创建和部分失败结果,当前等待用户验收。#32 采购闭环原型及后续采购工单仍按各自门禁推进。采购永不支付,真实地址修改和创建订单属于必须再次人工确认的高风险范围。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 1afa173..6979272 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,15 +2,15 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: a5c60ff2d9d64c824be176bb1630d884379f6c7c -synchronized_at: 2026-08-17T08:56:34Z +wiki_revision: d5b5407a09e3bcdc0bc34be2eb244e781c557420 +synchronized_at: 2026-08-17T09:15:56Z # 业务规则与术语 ## 当前范围 -当前已实施范围包含 PDD 商品最新档案、规则、采集任务、Android 执行和结构化结果。顺云宝、Shopee、采购、批量任务、规则发布流程、全局停机和实时屏幕仍由独立工单推进。 +当前已实施范围包含 PDD 商品最新档案、规则、单个及批量采集任务、Android 执行和结构化结果。顺云宝、Shopee、采购、规则发布流程、全局停机和实时屏幕仍由独立工单推进。 ## PDD 商品 @@ -50,6 +50,12 @@ synchronized_at: 2026-08-17T08:56:34Z - `completed`、`completed_partial`、`failed` 可以重置;`running` 禁止重置和删除。 - 重置保留原 URL、goods_id、规则和设备快照,在一个事务中删除旧规格/SKU、清空结果和错误,并恢复为 `pending`。 - 任务自身结果仍只在任务详情查看;任务完成后会按完整/部分完成规则更新 PDD 商品最新档案,商品页不替代任务结果审计。 +- PDD 商品列表批量操作只选择当前页,最多 100 个商品;每个商品仍创建一个独立采集任务。 +- 已停用、没有可用采集规则或已有 `pending/running` 任务的商品不能勾选,并显示普通人可理解的原因。 +- 一批商品统一选择一个规则和一个可选设备;设备留空时仍由空闲设备领取。服务端提交时逐商品重新校验。 +- 批量创建按商品分别执行现有单任务事务,允许部分成功;失败项返回稳定错误码和可读原因,不回滚已成功任务。 +- 批次 `requestId` 为每个商品派生稳定的创建请求标识,同一批次重放不会重复创建任务。 +- 批量采集不创建采购任务、PDD 订单或支付动作。 ## 采集字段 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index a3700f8..5f50049 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: a5c60ff2d9d64c824be176bb1630d884379f6c7c -synchronized_at: 2026-08-17T08:56:49Z +wiki_revision: d5b5407a09e3bcdc0bc34be2eb244e781c557420 +synchronized_at: 2026-08-17T09:16:09Z # MVP 共享 API 契约 @@ -29,7 +29,7 @@ GET /api/admin/v1/pdd-products/{productId} 新增请求只提交 `requestId` 和 `url`。服务端规范化 URL、提取 `goods_id`;无法提取时返回 `PDD_GOODS_ID_INVALID`,已存在时返回 `PDD_PRODUCT_EXISTS` 和现有商品 ID。编辑请求完整提交 URL、标题、店铺、数字销量/评价、状态和 `specs`;人工保存覆盖当前值,但不改变已有任务快照。 -`specs` 是维度数组。维度包含非空 `name`、`role`(`color` / `size` / `other`)和非空 `values`;规格值包含 `name`、`selectable`,只有颜色值允许包含非负整数分 `priceCent`。列表支持 `keyword` 搜索 goods_id、标题或店铺,并支持 `status` 筛选。商品状态为 `pending`、`active`、`disabled`。 +`specs` 是维度数组。维度包含非空 `name`、`role`(`color` / `size` / `other`)和非空 `values`;规格值包含 `name`、`selectable`,只有颜色值允许包含非负整数分 `priceCent`。列表支持 `keyword` 搜索 goods_id、标题或店铺,并支持 `status` 筛选。商品状态为 `pending`、`active`、`disabled`。列表项另返回 `collectionSelectable`、可选的 `collectionDisabledReason` 和 `activeCollectionTaskId`,供当前页批量选择;服务端提交时仍必须重新校验。 Android 提交结果的契约不增加字段:服务端在保存任务结果的同一事务更新 PDD 商品最新档案。`completed` 全量覆盖,`completed_partial` 合并明确获得的数据,`failed` 不更新;人工 `disabled` 状态不会被采集自动改回 `active`。 @@ -114,6 +114,7 @@ Android 的 `pddProductDetailV1` 采集器执行以下固定流程: ```http POST /api/admin/v1/collection-tasks +POST /api/admin/v1/collection-tasks/batch GET /api/admin/v1/collection-tasks GET /api/admin/v1/collection-tasks/{taskId} POST /api/admin/v1/collection-tasks/{taskId}/reset @@ -133,6 +134,21 @@ DELETE /api/admin/v1/collection-tasks/{taskId} 服务端在事务中复制当前 `url`、`goods_id` 和完整规则内容到任务。同一商品已有 `pending` 或 `running` 任务时返回 `PDD_PRODUCT_TASK_ACTIVE`。 +批量创建请求: + +```json +{ + "requestId": "batch-uuid", + "pddProductIds": [1, 2, 3], + "ruleId": 2, + "deviceId": null +} +``` + +`pddProductIds` 必须包含 1~100 个不重复的有效 ID。一个批次统一使用当前规则和可选设备;每个商品独立调用单任务创建事务并固化自己的 URL、goods_id、规则和设备快照。批次 `requestId` 与商品 ID 派生稳定的逐项创建标识,同一请求重放返回原任务,不重复创建。 + +HTTP 200 响应返回 `successCount`、`failureCount` 和按请求顺序排列的 `items`。成功项包含 `pddProductId`、`success: true`、`taskId` 和可选 `replayed`;失败项包含 `success: false`、稳定 `code` 和普通人可理解的 `message`。商品已停用返回 `PDD_PRODUCT_DISABLED`,活动任务冲突返回 `PDD_PRODUCT_TASK_ACTIVE`。逐项失败不回滚其它成功项;请求 JSON、批次 UUID、重复商品或数量上限无效时整体返回 `INVALID_REQUEST`。 + 重置只允许 `completed`、`completed_partial` 或 `failed`: - 保留 `pddProductId`、`deviceId`、`urlSnapshot`、`goodsIdSnapshot`、`ruleId` 和 `ruleSnapshot`。 diff --git a/docs/09-delivery-issues.md b/docs/09-delivery-issues.md index 7dde6df..1272f2a 100644 --- a/docs/09-delivery-issues.md +++ b/docs/09-delivery-issues.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Delivery-Issues wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.- -wiki_revision: dd160353a57296e667c32d79d1e9b9bc07f0bb6b -synchronized_at: 2026-08-17T09:01:43Z +wiki_revision: d5b5407a09e3bcdc0bc34be2eb244e781c557420 +synchronized_at: 2026-08-17T09:16:11Z # 当前 MVP 交付工单索引 @@ -47,7 +47,7 @@ synchronized_at: 2026-08-17T09:01:43Z ## 后续商品与采购设计工单 -以下工单保持商品域与采购域解耦。#31 PDD 商品原型已确认并进入代码待验收;#32 采购原型及采购实施继续遵守各自门禁,不得把采购能力混入 #31。 +以下工单保持商品域与采购域解耦。#31 PDD 商品档案已验收;#45 批量采集入口已实施并等待验收。#32 采购原型及采购实施继续遵守各自门禁,不得把采购能力混入采集功能。 | 顺序 | 工单 | 交付项 | 主要依赖 / 门禁 | |---|---|---|---| @@ -63,7 +63,7 @@ synchronized_at: 2026-08-17T09:01:43Z | T38 | [#40](https://git.ilapage.cn/OPC/goauto/issues/40) | Shopee 商品档案、PDD 关联与规格映射 | #31;商品域独立于采购任务 | | T39 | [#41](https://git.ilapage.cn/OPC/goauto/issues/41) | SYB 货运单商品导入与 Shopee 信息提取 | #40;源数据域独立于采购任务 | | T40 | [#42](https://git.ilapage.cn/OPC/goauto/issues/42) | Android 采购演练规则与持久执行基线 | #33、#34;只演练,不改地址、不创建订单 | -| T43 | [#45](https://git.ilapage.cn/OPC/goauto/issues/45) | Admin PDD 商品列表多选与批量采集任务创建 | #31 已验收;下一张采集域工单 | +| T43 | [#45](https://git.ilapage.cn/OPC/goauto/issues/45) | Admin PDD 商品列表多选与批量采集任务创建 | 已实施,等待用户验收 | 推荐依赖顺序:#31、#40、#41 完成商品域 → #33、#34 建立采购契约和服务端状态机 → #42 完成不下单演练 → #35 管理端人工处理 → #36 高风险真实订单动作 → #37、#38 物流闭环 → #39 真机总验收。 diff --git a/server/app/goauto/product/service.go b/server/app/goauto/product/service.go index 88c5f66..a5e056f 100644 --- a/server/app/goauto/product/service.go +++ b/server/app/goauto/product/service.go @@ -81,11 +81,18 @@ type ProductView struct { Specs []SpecDimension `json:"specs"` } +type ListProductView struct { + ProductView + CollectionSelectable bool `json:"collectionSelectable"` + CollectionDisabledReason string `json:"collectionDisabledReason,omitempty"` + ActiveCollectionTaskID *uint64 `json:"activeCollectionTaskId,omitempty"` +} + type ListResponse struct { - Items []ProductView `json:"items"` - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"pageSize"` + Items []ListProductView `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` } type SaveResponse struct { @@ -237,13 +244,50 @@ 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 := make([]ProductView, 0, len(records)) + productIDs := make([]uint64, 0, len(records)) + for _, record := range records { + productIDs = append(productIDs, record.ID) + } + type activeTaskRow struct { + PDDProductID uint64 + 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 ruleCount int64 + if err := service.DB.WithContext(ctx).Model(&models.CollectionRule{}).Count(&ruleCount).Error; err != nil { + return ListResponse{}, internalError(err) + } + items := make([]ListProductView, 0, len(records)) for _, record := range records { view, err := makeView(record) if err != nil { return ListResponse{}, err } - items = append(items, view) + item := ListProductView{ProductView: view, CollectionSelectable: true} + if record.Status == "disabled" { + item.CollectionSelectable = false + item.CollectionDisabledReason = "商品已停用" + } else if taskID, ok := activeTasks[record.ID]; ok { + item.CollectionSelectable = false + item.CollectionDisabledReason = "已有待执行或执行中的采集任务" + item.ActiveCollectionTaskID = &taskID + } else if ruleCount == 0 { + item.CollectionSelectable = false + item.CollectionDisabledReason = "没有可用采集规则" + } + items = append(items, item) } return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil } diff --git a/server/app/goauto/product/service_test.go b/server/app/goauto/product/service_test.go index 20a1d36..cc998a4 100644 --- a/server/app/goauto/product/service_test.go +++ b/server/app/goauto/product/service_test.go @@ -134,3 +134,59 @@ func TestUpdateValidatesAndPersistsProductArchive(t *testing.T) { t.Fatalf("non-color price accepted: %v", err) } } + +func TestListMarksProductsUnavailableForCollection(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: "111111", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=111111", Status: "active"}, + {GoodsID: "222222", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=222222", Status: "disabled"}, + {GoodsID: "333333", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=333333", 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 + } + if !byID[products[0].ID].CollectionSelectable { + t.Fatalf("active idle product was disabled: %+v", byID[products[0].ID]) + } + if byID[products[1].ID].CollectionDisabledReason != "商品已停用" { + t.Fatalf("disabled reason missing: %+v", byID[products[1].ID]) + } + if byID[products[2].ID].ActiveCollectionTaskID == nil || byID[products[2].ID].CollectionDisabledReason == "" { + t.Fatalf("active task reason missing: %+v", byID[products[2].ID]) + } +} + +func TestListDisablesCollectionWhenNoRuleExists(t *testing.T) { + db := openProductDatabase(t) + product := models.PDDProduct{GoodsID: "444444", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=444444", Status: "active"} + if err := db.Create(&product).Error; err != nil { + t.Fatal(err) + } + listed, err := NewService(db).List(context.Background(), ListRequest{Page: 1, PageSize: 20}) + if err != nil { + t.Fatal(err) + } + if listed.Items[0].CollectionSelectable || listed.Items[0].CollectionDisabledReason != "没有可用采集规则" { + t.Fatalf("missing-rule product remained selectable: %+v", listed.Items[0]) + } +} diff --git a/server/app/goauto/task/admin_handler.go b/server/app/goauto/task/admin_handler.go index cbe1677..8207ee8 100644 --- a/server/app/goauto/task/admin_handler.go +++ b/server/app/goauto/task/admin_handler.go @@ -60,6 +60,24 @@ func (handler Handler) AdminCreate(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 200, "data": response}) } +func (handler Handler) AdminBatchCreate(c *gin.Context) { + var request BatchCreateRequest + if err := decodeStrict(c, &request); err != nil { + writeError(c, serviceError("INVALID_REQUEST", "请求 JSON 无效")) + return + } + service, _, ok := handler.service(c) + if !ok { + return + } + response, err := service.BatchCreate(c.Request.Context(), request) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": response}) +} + func (handler Handler) AdminDetail(c *gin.Context) { id, err := taskID(c) if err != nil || id == 0 { diff --git a/server/app/goauto/task/admin_result_test.go b/server/app/goauto/task/admin_result_test.go index b6cf3b8..f1f2ea8 100644 --- a/server/app/goauto/task/admin_result_test.go +++ b/server/app/goauto/task/admin_result_test.go @@ -50,6 +50,52 @@ func TestAdminCreateSnapshotsAndRejectsActiveDuplicate(t *testing.T) { } } +func TestBatchCreateReturnsPerProductResultsAndReplays(t *testing.T) { + db := openTaskDatabase(t) + rule := models.CollectionRule{Name: "rule-v1", ContentJSON: `{"schemaVersion":1,"steps":[]}`} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + products := []models.PDDProduct{ + {GoodsID: "111111", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=111111", Status: "active"}, + {GoodsID: "222222", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=222222", Status: "active"}, + {GoodsID: "333333", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=333333", Status: "disabled"}, + } + if err := db.Create(&products).Error; err != nil { + t.Fatal(err) + } + existing := models.CollectionTask{ + PDDProductID: products[1].ID, RuleID: rule.ID, Status: models.TaskStatusPending, + URLSnapshot: products[1].URL, GoodsIDSnapshot: products[1].GoodsID, RuleSnapshot: rule.ContentJSON, + } + if err := db.Create(&existing).Error; err != nil { + t.Fatal(err) + } + request := BatchCreateRequest{RequestID: uuid.NewString(), PDDProductIDs: []uint64{products[0].ID, products[1].ID, products[2].ID}, RuleID: rule.ID} + created, err := newTaskService(db).BatchCreate(context.Background(), request) + if err != nil { + t.Fatalf("batch create: %v", err) + } + if created.SuccessCount != 1 || created.FailureCount != 2 || !created.Items[0].Success || created.Items[0].TaskID == 0 { + t.Fatalf("unexpected batch result: %+v", created) + } + if created.Items[1].Code != CodeProductTaskActive || created.Items[2].Code != CodeProductDisabled { + t.Fatalf("unexpected failure codes: %+v", created.Items) + } + replayed, err := newTaskService(db).BatchCreate(context.Background(), request) + if err != nil || !replayed.Items[0].Replayed || replayed.Items[0].TaskID != created.Items[0].TaskID { + t.Fatalf("batch replay failed: %+v %v", replayed, err) + } +} + +func TestBatchCreateRejectsDuplicateProducts(t *testing.T) { + request := BatchCreateRequest{RequestID: uuid.NewString(), PDDProductIDs: []uint64{1, 1}, RuleID: 1} + _, err := newTaskService(openTaskDatabase(t)).BatchCreate(context.Background(), request) + if taskErrorCode(t, err) != "INVALID_REQUEST" { + t.Fatalf("duplicate products accepted: %v", err) + } +} + func TestPartialProductSpecsMergeKeepsMissingExistingValues(t *testing.T) { existing := `[{ "name":"颜色分类","role":"color","values":[ diff --git a/server/app/goauto/task/admin_service.go b/server/app/goauto/task/admin_service.go index 0d39124..539c7d6 100644 --- a/server/app/goauto/task/admin_service.go +++ b/server/app/goauto/task/admin_service.go @@ -3,6 +3,7 @@ package task import ( "context" "errors" + "strconv" "strings" "go-admin/app/goauto/models" @@ -14,6 +15,7 @@ import ( const ( CodeProductTaskActive = "PDD_PRODUCT_TASK_ACTIVE" + CodeProductDisabled = "PDD_PRODUCT_DISABLED" CodeProductNotFound = "PDD_PRODUCT_NOT_FOUND" CodeRuleNotFound = "COLLECTION_RULE_NOT_FOUND" CodeDeviceNotFound = "DEVICE_NOT_FOUND" @@ -51,6 +53,28 @@ type CreateResponse struct { Replayed bool `json:"replayed,omitempty"` } +type BatchCreateRequest struct { + RequestID string `json:"requestId"` + PDDProductIDs []uint64 `json:"pddProductIds"` + RuleID uint64 `json:"ruleId"` + DeviceID *uint64 `json:"deviceId"` +} + +type BatchCreateItem struct { + PDDProductID uint64 `json:"pddProductId"` + Success bool `json:"success"` + TaskID uint64 `json:"taskId,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Replayed bool `json:"replayed,omitempty"` +} + +type BatchCreateResponse struct { + Items []BatchCreateItem `json:"items"` + SuccessCount int `json:"successCount"` + FailureCount int `json:"failureCount"` +} + func (service *Service) Create(ctx context.Context, request CreateRequest) (CreateResponse, error) { if _, err := uuid.Parse(strings.TrimSpace(request.RequestID)); err != nil || request.PDDProductID == 0 || request.RuleID == 0 { return CreateResponse{}, serviceError("INVALID_REQUEST", "requestId、pddProductId 和 ruleId 必须有效") @@ -72,6 +96,9 @@ func (service *Service) Create(ctx context.Context, request CreateRequest) (Crea } return internalError(err) } + if product.Status == "disabled" { + return serviceError(CodeProductDisabled, "商品已停用,不能创建采集任务") + } var rule models.CollectionRule if err := tx.First(&rule, request.RuleID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -111,6 +138,39 @@ func (service *Service) Create(ctx context.Context, request CreateRequest) (Crea return response, err } +func (service *Service) BatchCreate(ctx context.Context, request BatchCreateRequest) (BatchCreateResponse, error) { + batchID, err := uuid.Parse(strings.TrimSpace(request.RequestID)) + if err != nil || request.RuleID == 0 || len(request.PDDProductIDs) == 0 || len(request.PDDProductIDs) > 100 || request.DeviceID != nil && *request.DeviceID == 0 { + return BatchCreateResponse{}, serviceError("INVALID_REQUEST", "requestId、商品、规则和设备参数必须有效") + } + seen := make(map[uint64]bool, len(request.PDDProductIDs)) + for _, productID := range request.PDDProductIDs { + if productID == 0 || seen[productID] { + return BatchCreateResponse{}, serviceError("INVALID_REQUEST", "商品列表包含无效或重复商品") + } + seen[productID] = true + } + response := BatchCreateResponse{Items: make([]BatchCreateItem, 0, len(request.PDDProductIDs))} + for _, productID := range request.PDDProductIDs { + itemRequestID := uuid.NewSHA1(batchID, []byte(strconv.FormatUint(productID, 10))).String() + created, createErr := service.Create(ctx, CreateRequest{ + RequestID: itemRequestID, PDDProductID: productID, RuleID: request.RuleID, DeviceID: request.DeviceID, + }) + if createErr == nil { + response.Items = append(response.Items, BatchCreateItem{PDDProductID: productID, Success: true, TaskID: created.Task.ID, Replayed: created.Replayed}) + response.SuccessCount++ + continue + } + var target *ServiceError + if !errors.As(createErr, &target) { + target = internalError(createErr).(*ServiceError) + } + response.Items = append(response.Items, BatchCreateItem{PDDProductID: productID, Code: target.Code, Message: target.Message}) + response.FailureCount++ + } + return response, nil +} + func (service *Service) AdminList(ctx context.Context, request AdminListRequest) (AdminListResponse, error) { if request.Page < 1 { request.Page = 1 diff --git a/server/app/goauto/task/handler.go b/server/app/goauto/task/handler.go index d5a2e5c..79e24e3 100644 --- a/server/app/goauto/task/handler.go +++ b/server/app/goauto/task/handler.go @@ -169,7 +169,7 @@ func writeError(context *gin.Context, err error) { status = http.StatusNotFound case CodeTaskAlreadyClaimed, CodeTaskAssignedOther, CodeDeviceBusy, CodeDeviceOffline, CodeTaskStateConflict, CodeTaskLeaseExpired: status = http.StatusConflict - case CodeProductTaskActive: + case CodeProductTaskActive, CodeProductDisabled: status = http.StatusConflict case CodeProductNotFound, CodeRuleNotFound, CodeDeviceNotFound: status = http.StatusNotFound diff --git a/server/app/goauto/task/router.go b/server/app/goauto/task/router.go index 0fc9986..371b843 100644 --- a/server/app/goauto/task/router.go +++ b/server/app/goauto/task/router.go @@ -24,6 +24,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { admin := engine.Group("/api/admin/v1/collection-tasks").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()) admin.GET("", handler.AdminList) admin.POST("", handler.AdminCreate) + admin.POST("/batch", handler.AdminBatchCreate) admin.GET("/:taskId", handler.AdminDetail) admin.POST("/:taskId/reset", handler.AdminReset) admin.DELETE("/:taskId", handler.AdminDelete) diff --git a/web/src/api/goauto/collection-tasks.js b/web/src/api/goauto/collection-tasks.js index 36e1449..d6ec6b1 100644 --- a/web/src/api/goauto/collection-tasks.js +++ b/web/src/api/goauto/collection-tasks.js @@ -2,6 +2,7 @@ import request from '@/utils/request' export function listCollectionTasks(params) { return request({ url: '/api/admin/v1/collection-tasks', method: 'get', params }) } export function createCollectionTask(data) { return request({ url: '/api/admin/v1/collection-tasks', method: 'post', data }) } +export function batchCreateCollectionTasks(data) { return request({ url: '/api/admin/v1/collection-tasks/batch', method: 'post', data }) } export function getCollectionTask(taskId) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}`, method: 'get' }) } export function resetCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}/reset`, method: 'post', data }) } export function deleteCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}`, method: 'delete', data }) } diff --git a/web/src/views/goauto/pdd-products/index.vue b/web/src/views/goauto/pdd-products/index.vue index 19a59ee..ad15c69 100644 --- a/web/src/views/goauto/pdd-products/index.vue +++ b/web/src/views/goauto/pdd-products/index.vue @@ -11,10 +11,16 @@ 查询重置 - +
+ 已选择 {{ selectedProducts.length }} 条 + 批量创建采集任务 + 仅选择当前页;每个商品创建一个独立采集任务。 +
+ + - + @@ -30,6 +36,35 @@ + + + + + +