feat(#45): add batch collection task creation

This commit is contained in:
QiuSW
2026-08-17 17:17:20 +08:00
parent f6bada9f86
commit ceecb5855e
14 changed files with 390 additions and 25 deletions
+3 -3
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 项目档案
@@ -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 采购闭环原型及后续采购工单仍按各自门禁推进。采购永不支付,真实地址修改和创建订单属于必须再次人工确认的高风险范围。
+9 -3
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
## 当前范围
当前已实施范围包含 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 订单或支付动作。
## 采集字段
+19 -3
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 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`。
+4 -4
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 当前 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 真机总验收。
+50 -6
View File
@@ -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
}
+56
View File
@@ -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])
}
}
+18
View File
@@ -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 {
@@ -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":[
+60
View File
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -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)
+1
View File
@@ -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 }) }
+82 -5
View File
@@ -11,10 +11,16 @@
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width: 150px"><el-option label="待完善" value="pending" /><el-option label="正常" value="active" /><el-option label="已停用" value="disabled" /></el-select></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
</el-form>
<el-table v-loading="loading" :data="products" border stripe empty-text="暂无 PDD 商品,请先添加商品链接">
<div class="batch-toolbar" aria-live="polite">
<span>已选择 <strong>{{ selectedProducts.length }}</strong> 条</span>
<el-button type="primary" :disabled="selectedProducts.length === 0" @click="openBatch">批量创建采集任务</el-button>
<span class="muted">仅选择当前页;每个商品创建一个独立采集任务。</span>
</div>
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 PDD 商品,请先添加商品链接" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="48" :selectable="isProductSelectable" />
<el-table-column label="goods_id" prop="goodsId" min-width="150" />
<el-table-column label="商品 / 店铺" min-width="240"><template #default="{ row }"><div class="primary">{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '尚未填写店铺' }}</div></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag></template></el-table-column>
<el-table-column label="状态 / 采集任务" min-width="190"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag><div :class="row.collectionSelectable ? 'collection-ready' : 'muted'">{{ collectionStateText(row) }}</div></template></el-table-column>
<el-table-column label="销量 / 评价" width="140"><template #default="{ row }"><div>销量 {{ countText(row.salesCount) }}</div><div class="muted">评价 {{ countText(row.reviewCount) }}</div></template></el-table-column>
<el-table-column label="规格" min-width="180"><template #default="{ row }">{{ specsSummary(row.specs) }}</template></el-table-column>
<el-table-column label="颜色价格" min-width="130"><template #default="{ row }">{{ priceSummary(row.specs) }}</template></el-table-column>
@@ -30,6 +36,35 @@
<template #footer><el-button @click="createDialog.open = false">取消</el-button><el-button type="primary" :loading="createDialog.saving" @click="createProduct">添加商品</el-button></template>
</el-dialog>
<el-dialog v-model="batch.open" :title="batch.step === 'confirm' ? '批量创建采集任务' : '批量创建结果'" width="780px" :close-on-click-modal="false" @closed="resetBatch">
<template v-if="batch.step === 'confirm'">
<el-alert title="每个 PDD 商品会创建一个独立采集任务,不会创建采购任务或订单。" type="info" :closable="false" show-icon class="notice" />
<div class="batch-summary"><el-tag effect="plain">已选择 {{ batch.products.length }} 个商品</el-tag><span>提交前服务端会再次检查,部分商品失败不会影响其他商品。</span></div>
<el-form ref="batchForm" :model="batchData" :rules="batchRules" label-position="top" class="batch-form">
<el-form-item label="采集规则" prop="ruleId"><el-select v-model="batchData.ruleId" filterable style="width:100%" placeholder="请选择采集规则"><el-option v-for="item in batch.options.rules" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
<el-form-item label="Android 设备"><el-select v-model="batchData.deviceId" clearable filterable style="width:100%" placeholder="不指定,由空闲设备领取"><el-option v-for="item in batch.options.devices" :key="item.id" :disabled="!item.selectable" :label="`${item.name} · ${item.selectable ? '在线空闲' : '不可用'}`" :value="item.id" /></el-select></el-form-item>
</el-form>
<el-table :data="batch.products" border size="small" max-height="300" empty-text="没有可创建的商品">
<el-table-column label="goods_id" prop="goodsId" width="160" />
<el-table-column label="商品"><template #default="{ row }">{{ row.title || '资料待完善' }}</template></el-table-column>
<el-table-column label="检查结果" width="130"><template #default><el-tag type="success" effect="plain">可创建</el-tag></template></el-table-column>
</el-table>
</template>
<template v-else>
<el-alert :title="`创建完成:成功 ${batch.successCount} 个,失败 ${batch.failureCount} 个`" :type="batch.failureCount ? 'warning' : 'success'" :closable="false" show-icon class="notice" />
<el-table :data="batch.results" border size="small" max-height="360">
<el-table-column label="goods_id" prop="goodsId" width="160" />
<el-table-column label="商品"><template #default="{ row }">{{ row.title || '资料待完善' }}</template></el-table-column>
<el-table-column label="结果" width="110"><template #default="{ row }"><el-tag :type="row.success ? 'success' : 'danger'">{{ row.success ? '已创建' : '未创建' }}</el-tag></template></el-table-column>
<el-table-column label="任务 / 原因" min-width="230"><template #default="{ row }">{{ row.success ? `采集任务 #${row.taskId}` : row.message }}</template></el-table-column>
</el-table>
</template>
<template #footer>
<template v-if="batch.step === 'confirm'"><el-button @click="batch.open = false">取消</el-button><el-button type="primary" :loading="batch.saving" :disabled="batch.products.length === 0" @click="submitBatch">创建 {{ batch.products.length }} 个采集任务</el-button></template>
<el-button v-else type="primary" @click="batch.open = false">关闭</el-button>
</template>
</el-dialog>
<el-drawer v-model="detail.open" title="PDD 商品详情" size="720px">
<div v-loading="detail.loading" class="drawer-body">
<template v-if="detail.product">
@@ -69,20 +104,62 @@
import { ElMessage } from 'element-plus'
import { Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
import { createPddProduct, getPddProduct, listPddProducts, updatePddProduct } from '@/api/goauto/pdd-products'
import { batchCreateCollectionTasks } from '@/api/goauto/collection-tasks'
import { listCollectionRules } from '@/api/goauto/collection-rules'
import { listDevices } from '@/api/goauto/devices'
let uid = 0
export default {
name: 'GoAutoPddProducts', setup() { return { Plus, RefreshLeft, Search } },
data() { return { loading: false, products: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, createDialog: { open: false, saving: false }, createData: { url: '' }, detail: { open: false, loading: false, product: null }, edit: { open: false, loading: false, saving: false, productId: null }, editData: this.emptyEdit(), urlRules: { url: [{ required: true, message: '请输入 PDD 商品链接', trigger: 'blur' }, { max: 4096, message: '链接不能超过 4096 个字符', trigger: 'blur' }] }} },
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, createDialog: { open: false, saving: false }, createData: { url: '' }, batch: this.emptyBatch(), batchData: { ruleId: null, deviceId: null }, batchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] }, detail: { open: false, loading: false, product: null }, edit: { open: false, loading: false, saving: false, productId: null }, editData: this.emptyEdit(), urlRules: { url: [{ required: true, message: '请输入 PDD 商品链接', trigger: 'blur' }, { max: 4096, message: '链接不能超过 4096 个字符', trigger: 'blur' }] }} },
created() { this.load() },
methods: {
emptyEdit() { return { url: '', title: '', shopName: '', salesCount: null, reviewCount: null, status: 'active', specs: [] } },
async load() { this.loading = true; try { const r = await listPddProducts(this.query); this.products = r.data.items; this.total = r.data.total } finally { this.loading = false } },
emptyBatch() { return { open: false, saving: false, step: 'confirm', products: [], options: { rules: [], devices: [] }, results: [], successCount: 0, failureCount: 0 } },
async load() { this.loading = true; this.selectedProducts = []; this.$refs.productTable?.clearSelection(); try { const r = await listPddProducts(this.query); this.products = r.data.items; this.total = r.data.total } finally { this.loading = false } },
search() { this.query.page = 1; this.load() }, reset() { this.query = { page: 1, pageSize: 20, keyword: '', status: '' }; this.load() },
statusMeta(status) { return { pending: { label: '待完善', type: 'warning' }, active: { label: '正常', type: 'success' }, disabled: { label: '已停用', type: 'info' }}[status] || { label: status || '-', type: 'info' } },
countText(value) { return value === null || value === undefined ? '-' : Number(value).toLocaleString('zh-CN') }, roleLabel(role) { return { color: '颜色', size: '尺码', other: '其他' }[role] || role }, priceText(value) { return value === null || value === undefined ? '未填写' : `¥${(value / 100).toFixed(2)}` },
specsSummary(specs = []) { return specs.length ? specs.map(x => `${x.name} ${x.values.length}项`).join(';') : '待完善' },
priceSummary(specs = []) { const p = specs.filter(x => x.role === 'color').flatMap(x => x.values.map(v => v.priceCent).filter(v => v !== null && v !== undefined)); if (!p.length) return '-'; const min = Math.min(...p); const max = Math.max(...p); return min === max ? this.priceText(min) : `${this.priceText(min)}~${this.priceText(max)}` },
isProductSelectable(row) { return row.collectionSelectable === true },
collectionStateText(row) { return row.collectionSelectable ? '可创建采集任务' : (row.collectionDisabledReason || '当前不可创建采集任务') },
handleSelectionChange(rows) { this.selectedProducts = rows },
async openBatch() {
if (!this.selectedProducts.length) return
this.batch = { ...this.emptyBatch(), open: true, products: this.selectedProducts.map(item => ({ ...item })) }
this.batchData = { ruleId: null, deviceId: null }
try {
const [rules, devices] = await Promise.all([listCollectionRules({ page: 1, pageSize: 100 }), listDevices({ page: 1, pageSize: 100 })])
this.batch.options = { rules: rules.data.items, devices: devices.data.items }
if (!this.batch.options.rules.length) ElMessage.warning('没有可用采集规则,请先创建采集规则')
} catch (error) {
this.batch.open = false
throw error
}
this.$nextTick(() => this.$refs.batchForm?.clearValidate())
},
resetBatch() { this.batch = this.emptyBatch(); this.batchData = { ruleId: null, deviceId: null } },
async submitBatch() {
if (!await this.$refs.batchForm.validate().catch(() => false)) return
this.batch.saving = true
try {
const response = await batchCreateCollectionTasks({ requestId: crypto.randomUUID(), pddProductIds: this.batch.products.map(item => item.id), ruleId: this.batchData.ruleId, deviceId: this.batchData.deviceId || null })
const byID = new Map(this.batch.products.map(item => [item.id, item]))
this.batch.results = response.data.items.map(item => ({ ...byID.get(item.pddProductId), ...item }))
this.batch.successCount = response.data.successCount
this.batch.failureCount = response.data.failureCount
this.batch.step = 'result'
if (this.batch.failureCount) {
ElMessage.warning(`已创建 ${this.batch.successCount} 个,${this.batch.failureCount} 个未创建`)
} else {
ElMessage.success(`已创建 ${this.batch.successCount} 个采集任务`)
}
await this.load()
} finally {
this.batch.saving = false
}
},
openCreate() { this.createDialog = { open: true, saving: false }; this.createData = { url: '' }; this.$nextTick(() => this.$refs.createForm?.clearValidate()) },
async createProduct() { if (!await this.$refs.createForm.validate().catch(() => false)) return; this.createDialog.saving = true; try { const r = await createPddProduct({ requestId: crypto.randomUUID(), url: this.createData.url.trim() }); ElMessage.success('PDD 商品已添加'); this.createDialog.open = false; await this.load(); await this.openDetail(r.data.product.id) } finally { this.createDialog.saving = false } },
async openDetail(id) { this.detail = { open: true, loading: true, product: null }; try { const r = await getPddProduct(id); this.detail.product = r.data.product } finally { this.detail.loading = false } },
@@ -96,5 +173,5 @@ export default {
</script>
<style lang="scss" scoped>
.page-card { min-height: calc(100vh - 124px); }.page-heading { display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:24px;h1{margin:0 0 6px;font-size:24px;color:#1f2937}p{margin:0;color:#606266;line-height:1.5}}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.notice{margin-bottom:20px}.drawer-body{padding:0 4px 24px}.drawer-actions,.section-heading,.dimension-header{display:flex;align-items:center;gap:12px}.drawer-actions,.section-heading{justify-content:space-between}.section-title{margin:28px 0 14px}.spec-block{margin-bottom:20px}.spec-title{display:flex;align-items:center;gap:8px;margin-bottom:8px;font-weight:600}.link{color:#1677ff;overflow-wrap:anywhere}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.section-heading{margin:12px 0;h3{margin:0}}.dimension-card{margin-bottom:16px}.dimension-header{margin-bottom:14px}.dimension-header .el-input{flex:1}.add-value{margin-top:12px}@media(max-width:768px){.page-heading,.dimension-header{align-items:stretch;flex-direction:column}.form-grid{grid-template-columns:1fr}}
.page-card { min-height: calc(100vh - 124px); }.page-heading { display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:24px;h1{margin:0 0 6px;font-size:24px;color:#1f2937}p{margin:0;color:#606266;line-height:1.5}}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.batch-toolbar{display:flex;align-items:center;gap:12px;min-height:48px;padding:8px 12px;margin-bottom:12px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.batch-toolbar strong{font-variant-numeric:tabular-nums;color:#1e40af}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.collection-ready{margin-top:4px;font-size:12px;color:#1677ff}.notice{margin-bottom:20px}.batch-summary{display:flex;align-items:center;gap:12px;margin-bottom:16px;color:#606266}.batch-form{display:grid;grid-template-columns:1fr 1fr;gap:20px}.drawer-body{padding:0 4px 24px}.drawer-actions,.section-heading,.dimension-header{display:flex;align-items:center;gap:12px}.drawer-actions,.section-heading{justify-content:space-between}.section-title{margin:28px 0 14px}.spec-block{margin-bottom:20px}.spec-title{display:flex;align-items:center;gap:8px;margin-bottom:8px;font-weight:600}.link{color:#1677ff;overflow-wrap:anywhere}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.section-heading{margin:12px 0;h3{margin:0}}.dimension-card{margin-bottom:16px}.dimension-header{margin-bottom:14px}.dimension-header .el-input{flex:1}.add-value{margin-top:12px}@media(max-width:768px){.page-heading,.dimension-header{align-items:stretch;flex-direction:column}.batch-toolbar,.batch-summary{align-items:flex-start;flex-direction:column}.batch-form,.form-grid{grid-template-columns:1fr}}
</style>
+40
View File
@@ -9,6 +9,7 @@ const product = {
salesCount: 1200,
reviewCount: 36,
status: 'active',
collectionSelectable: true,
specs: [
{ name: '颜色分类', role: 'color', values: [{ name: '黑色', selectable: true, priceCent: 2590 }] },
{ name: '尺码', role: 'size', values: [{ name: 'XL', selectable: true }] },
@@ -62,3 +63,42 @@ test('PDD 商品列表、详情和编辑流程符合已确认原型', async ({ p
await expect.poll(() => updatePayload?.title).toBe('夏季防晒袖套(人工确认)');
expect(updatePayload.specs[0].values[0].priceCent).toBe(2590);
});
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: '商品已停用' };
let batchPayload: any;
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')) return route.fulfill({ json: { code: 200, data: { items: [product, second, disabled], total: 3, page: 1, pageSize: 20 } } });
if (url.pathname.endsWith('/api/admin/v1/collection-rules')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 9, name: 'PDD 商品详情采集' }], total: 1 } } });
if (url.pathname.endsWith('/api/admin/v1/devices')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 4, name: 'samsung-SM-G9700', selectable: true }], total: 1 } } });
if (url.pathname.endsWith('/api/admin/v1/collection-tasks/batch')) {
batchPayload = route.request().postDataJSON();
return route.fulfill({ json: { code: 200, data: { successCount: 1, failureCount: 1, items: [{ pddProductId: 1, success: true, taskId: 101 }, { pddProductId: 2, success: false, code: 'PDD_PRODUCT_TASK_ACTIVE', message: '该商品已有待执行或执行中的任务' }] } } });
}
return route.fulfill({ json: { code: 200, data: [] } });
});
await page.goto('http://localhost:9527/#/pdd-products/index');
const rows = page.locator('.el-table__body-wrapper tbody tr');
await rows.nth(0).locator('.el-checkbox').click();
await rows.nth(1).locator('.el-checkbox').click();
await expect(rows.nth(2).getByRole('checkbox')).toBeDisabled();
await expect(page.getByText('已选择 2 条')).toBeVisible();
await page.getByRole('button', { name: '批量创建采集任务' }).click();
await page.getByLabel('采集规则').click();
await page.getByRole('option', { name: 'PDD 商品详情采集' }).click();
await page.getByRole('button', { name: '创建 2 个采集任务' }).click();
await expect.poll(() => batchPayload?.pddProductIds).toEqual([1, 2]);
expect(batchPayload.ruleId).toBe(9);
expect(batchPayload.deviceId).toBeNull();
await expect(page.getByText('创建完成:成功 1 个,失败 1 个')).toBeVisible();
await expect(page.getByText('采集任务 #101')).toBeVisible();
await expect(page.getByText('该商品已有待执行或执行中的任务')).toBeVisible();
});