diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index 5c8b16f..81b0422 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -1,9 +1,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Project-Profile -wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.- -wiki_revision: 6d7c19ad384f94774e0e2b81dba39d607bd5e6b5 -synchronized_at: 2026-08-17T03:27:43Z +wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile +wiki_revision: a5c60ff2d9d64c824be176bb1630d884379f6c7c # 项目档案 @@ -17,8 +16,8 @@ synchronized_at: 2026-08-17T03:27:43Z | 主要使用者 | 管理员、采购人员、开发维护者 | | Gitea 仓库 | `OPC/goauto` | | 默认分支 | `main` | -| 当前已实施范围 | PDD 商品 URL、采集规则、采集任务、Android 执行、任务详情结果 | -| 后续设计范围 | PDD 正式商品档案、Shopee/SYB 商品关系、采购演练、创建待付款订单和物流回填 | +| 当前已实施范围 | PDD 正式商品档案、采集规则、采集任务、Android 执行、任务详情结果 | +| 后续设计范围 | Shopee/SYB 商品关系、采购演练、创建待付款订单和物流回填 | | 预计规模 | 20 台 Android;每天约 100 个采集任务、200 个采购任务 | ## 建设基线 @@ -66,4 +65,4 @@ synchronized_at: 2026-08-17T03:27:43Z 当前采集 MVP 的 T01~T22 已完成实现并由用户验收;T08 只读实时屏幕已延期。T23~T28 的一加真机增强、规格遍历、假售罄恢复、浏览器重开恢复和端口配置已经实现,其中部分工单仍等待用户验收。 -后续采购方向仍处于设计与工单阶段:#31 PDD 商品档案原型和 #32 采购闭环原型均为草稿、等待用户明确审核;#33~#42 不得在 #32 原型通过前进入采购业务代码实施。采购永不支付,真实地址修改和创建订单属于必须再次人工确认的高风险范围。 +#31 PDD 商品档案原型已由用户确认,数据库、API 和 Admin 商品档案页面已进入待验收;#45 继续独立承担 PDD 商品列表批量创建采集任务,未混入商品档案实现。#32 采购闭环原型及后续采购工单仍按各自门禁推进。采购永不支付,真实地址修改和创建订单属于必须再次人工确认的高风险范围。 diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 55aef80..8212b43 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -1,9 +1,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map -wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: fc96c997b9fb49dbe62798d68afddc0ba5344d72 -synchronized_at: 2026-08-17T03:27:55Z +wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map +wiki_revision: a5c60ff2d9d64c824be176bb1630d884379f6c7c # 架构与代码地图 @@ -33,7 +32,8 @@ Android Portal/Agent 3. 管理员从一个 PDD 商品创建任务,可指定设备,也可留空等待空闲设备领取。 4. 任务固化 URL、goods_id 和完整规则快照。 5. Android 串行执行并把结果写回同一条任务;规格与 SKU 写入任务结果子表。 -6. 管理员只在任务详情查看结果,并可对终态任务执行重置。 +6. 同一事务把 `completed` 结果全量覆盖到 PDD 商品最新档案,把 `completed_partial` 明确采集到的字段和规格安全合并;`failed` 不修改商品。 +7. 管理员在任务详情查看原始结构化结果,也可在 PDD 商品页面查看和人工覆盖最新商品资料。 ## 关键设计 @@ -52,7 +52,7 @@ Android Portal/Agent | 表 | 必要内容 | |---|---| | `agent_device` | 唯一 `install_id`、设备信息、状态、Token 摘要、版本化能力、最后心跳 | -| `pdd_product` | `id`、唯一 `goods_id`、当前 `url`、创建/更新时间 | +| `pdd_product` | 唯一 `goods_id`、当前 URL、标题、店铺、数字销量/评价、三态状态和通用多维 `specs_json` 最新值 | | `collection_rule` | `id`、`name`、`content_json`、创建/更新时间、`deleted_at` | | `collection_task` | 商品/设备外键、五态状态、URL/goods_id/规则快照、租约、结果摘要、错误和时间 | | `collection_dimension` | 任务、维度键、名称、排序 | @@ -77,6 +77,7 @@ Android Portal/Agent | go-admin 迁移注册 | `server/cmd/migrate/migration/version-local/1786700000000_goauto_schema.go` | | 设备注册、认证、停用与吊销 | `server/app/goauto/device/` | | PDD 商品与规则 | `server/app/goauto/product/`、`server/app/goauto/rule/` | +| PDD 商品档案增量迁移 | `server/cmd/migrate/migration/version-local/1786700500000_pdd_product_archive.go` | | 任务领取、结果、重置与删除 | `server/app/goauto/task/` | | 管理端基线 | `web/`(go-admin-ui v3.0.0) | | 管理端闭环页面 | `web/src/views/goauto/` | diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index ebc7760..28921d1 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -1,16 +1,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: 277be3ca3e95de1c70aefa40ee4474d47a09ca73 -synchronized_at: 2026-08-17T03:27:59Z +wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary +wiki_revision: a5c60ff2d9d64c824be176bb1630d884379f6c7c # 业务规则与术语 ## 当前范围 -当前 MVP 只有四个业务动作:添加 PDD 商品、创建规则、创建采集任务、在任务详情查看 Android 回传结果。顺云宝、Shopee、采购、批量任务、规则发布流程、全局停机和实时屏幕不在范围内。 +当前已实施范围包含 PDD 商品最新档案、规则、采集任务、Android 执行和结构化结果。顺云宝、Shopee、采购、批量任务、规则发布流程、全局停机和实时屏幕仍由独立工单推进。 ## PDD 商品 @@ -18,6 +17,11 @@ synchronized_at: 2026-08-17T03:27:59Z - 服务端必须规范化 URL 并提取 `goods_id`;提取失败时拒绝保存。 - `goods_id` 唯一;相同 `goods_id` 再次添加时提示“商品已存在”。 - 已存在商品允许修改当前 URL,但不自动改变已创建任务的 URL 快照。 +- 最简创建只输入 URL,新商品状态为 `pending`;人工完整保存或首次成功采集后转为 `active`,人工可设为 `disabled`。 +- 商品只保存标题、店铺、数字销量/评价和规格的最新值,不保存价格或字段变更历史。 +- `specs_json` 使用通用多维结构,角色为 `color`、`size`、`other`;只有颜色规格值保存非负整数分价格,规格值同时保存是否可选择。 +- 人工保存为完整覆盖。`completed` 采集结果全量覆盖;`completed_partial` 只合并明确采集到的字段和规格,不删除原有缺失部分;`failed` 不修改商品。 +- 人工停用的商品不会被后续采集结果自动重新启用;假售罄、临时售罄和采集失败也不会自动停用商品。 ## 采集规则 @@ -44,7 +48,7 @@ synchronized_at: 2026-08-17T03:27:59Z - 失败后允许人工重置或删除后重新创建。 - `completed`、`completed_partial`、`failed` 可以重置;`running` 禁止重置和删除。 - 重置保留原 URL、goods_id、规则和设备快照,在一个事务中删除旧规格/SKU、清空结果和错误,并恢复为 `pending`。 -- 任务结果只在任务详情查看,不回填到 PDD 商品列表或独立结果页面。 +- 任务自身结果仍只在任务详情查看;任务完成后会按完整/部分完成规则更新 PDD 商品最新档案,商品页不替代任务结果审计。 ## 采集字段 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 5c933be..bf71d1f 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -1,9 +1,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: 79fc2aa464343d61115e283cd4b1b0497b0cc5bc -synchronized_at: 2026-08-17T03:28:25Z +wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract +wiki_revision: a5c60ff2d9d64c824be176bb1630d884379f6c7c # MVP 共享 API 契约 @@ -24,9 +23,14 @@ synchronized_at: 2026-08-17T03:28:25Z POST /api/admin/v1/pdd-products PATCH /api/admin/v1/pdd-products/{productId} GET /api/admin/v1/pdd-products +GET /api/admin/v1/pdd-products/{productId} ``` -新增请求只提交 `url`。服务端展开并规范化 URL、提取 `goods_id`;无法提取时返回 `PDD_GOODS_ID_INVALID`,已存在时返回 `PDD_PRODUCT_EXISTS` 和现有商品 ID。编辑 URL 不改变已有任务快照。 +新增请求只提交 `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`。 + +Android 提交结果的契约不增加字段:服务端在保存任务结果的同一事务更新 PDD 商品最新档案。`completed` 全量覆盖,`completed_partial` 合并明确获得的数据,`failed` 不更新;人工 `disabled` 状态不会被采集自动改回 `active`。 ## 管理端:采集规则 diff --git a/docs/09-delivery-issues.md b/docs/09-delivery-issues.md index 08b275d..164e38f 100644 --- a/docs/09-delivery-issues.md +++ b/docs/09-delivery-issues.md @@ -1,9 +1,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Delivery-Issues -wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.- -wiki_revision: b1ce6001ea469be8415003f01e318eac9867c686 -synchronized_at: 2026-08-17T03:28:29Z +wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues +wiki_revision: a5c60ff2d9d64c824be176bb1630d884379f6c7c # 当前 MVP 交付工单索引 @@ -47,11 +46,11 @@ synchronized_at: 2026-08-17T03:28:29Z ## 后续商品与采购设计工单 -以下工单不属于当前采集 MVP。#31 和 #32 的 QuantUX 原型仍是草稿;用户明确审核通过前,不得把后续采购能力混入生产代码。 +以下工单保持商品域与采购域解耦。#31 PDD 商品原型已确认并进入代码待验收;#32 采购原型及采购实施继续遵守各自门禁,不得把采购能力混入 #31。 | 顺序 | 工单 | 交付项 | 主要依赖 / 门禁 | |---|---|---|---| -| T29 | [#31](https://git.ilapage.cn/OPC/goauto/issues/31) | PDD 商品采购档案与规格 JSON 管理 | 先行 #31 QuantUX 原型审核 | +| T29 | [#31](https://git.ilapage.cn/OPC/goauto/issues/31) | PDD 商品采购档案与规格 JSON 管理 | 原型已确认;代码待用户验收 | | T30 | [#32](https://git.ilapage.cn/OPC/goauto/issues/32) | 采购闭环数据关系与交互原型 | #31;#32 原型是全部采购代码门禁 | | T31 | [#33](https://git.ilapage.cn/OPC/goauto/issues/33) | 采购任务数据模型与共享 API 契约 | #31、#40、#41、#32 原型通过 | | T32 | [#34](https://git.ilapage.cn/OPC/goauto/issues/34) | 服务端采购任务、租约、幂等与状态机 | #33 | @@ -63,6 +62,7 @@ synchronized_at: 2026-08-17T03:28:29Z | 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 原型已确认;独立于商品档案实施 | 推荐依赖顺序:#31、#40、#41 完成商品域 → #33、#34 建立采购契约和服务端状态机 → #42 完成不下单演练 → #35 管理端人工处理 → #36 高风险真实订单动作 → #37、#38 物流闭环 → #39 真机总验收。 diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index 123f708..635c248 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -49,6 +49,12 @@ type PDDProduct struct { ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` GoodsID string `json:"goodsId" gorm:"size:32;not null;uniqueIndex:ux_pdd_product_goods_id"` URL string `json:"url" gorm:"type:text;not null"` + Title string `json:"title" gorm:"size:500;not null;default:''"` + ShopName string `json:"shopName" gorm:"size:255;not null;default:''"` + SalesCount *int64 `json:"salesCount" gorm:"check:ck_pdd_product_sales_count,sales_count IS NULL OR sales_count >= 0"` + ReviewCount *int64 `json:"reviewCount" gorm:"check:ck_pdd_product_review_count,review_count IS NULL OR review_count >= 0"` + Status string `json:"status" gorm:"size:16;not null;default:pending;index;check:ck_pdd_product_status,status IN ('pending','active','disabled')"` + SpecsJSON string `json:"-" gorm:"type:json;not null"` LastCreateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_pdd_product_create_request_id"` LastUpdateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_pdd_product_update_request_id"` CreatedAt time.Time `json:"createdAt"` @@ -57,6 +63,16 @@ type PDDProduct struct { func (PDDProduct) TableName() string { return "pdd_product" } +func (product *PDDProduct) BeforeCreate(_ *gorm.DB) error { + if product.SpecsJSON == "" { + product.SpecsJSON = "[]" + } + if product.Status == "" { + product.Status = "pending" + } + return nil +} + type CollectionRule struct { ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` Name string `json:"name" gorm:"size:120;not null"` diff --git a/server/app/goauto/product/handler.go b/server/app/goauto/product/handler.go index 9f57f4a..a220764 100644 --- a/server/app/goauto/product/handler.go +++ b/server/app/goauto/product/handler.go @@ -30,7 +30,9 @@ func (handler Handler) List(c *gin.Context) { if !ok { return } - response, err := service.List(c.Request.Context(), ListRequest{Page: page, PageSize: pageSize, GoodsID: c.Query("goodsId")}) + response, err := service.List(c.Request.Context(), ListRequest{ + Page: page, PageSize: pageSize, GoodsID: c.Query("goodsId"), Keyword: c.Query("keyword"), Status: strings.TrimSpace(c.Query("status")), + }) if err != nil { writeError(c, err) return @@ -39,8 +41,8 @@ func (handler Handler) List(c *gin.Context) { } func (handler Handler) Create(c *gin.Context) { - request, err := decodeSave(c) - if err != nil { + var request SaveRequest + if err := decodeJSON(c, &request); err != nil { writeError(c, invalidRequest("请求 JSON 无效")) return } @@ -62,8 +64,8 @@ func (handler Handler) Update(c *gin.Context) { writeError(c, invalidRequest("productId 无效")) return } - request, err := decodeSave(c) - if err != nil { + var request UpdateRequest + if err := decodeJSON(c, &request); err != nil { writeError(c, invalidRequest("请求 JSON 无效")) return } @@ -79,6 +81,24 @@ func (handler Handler) Update(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 200, "data": response}) } +func (handler Handler) Detail(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("productId"), 10, 64) + if err != nil || id == 0 { + writeError(c, invalidRequest("productId 无效")) + return + } + service, ok := handler.service(c) + if !ok { + return + } + response, err := service.Detail(c.Request.Context(), id) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": 200, "data": response}) +} + func (handler Handler) service(c *gin.Context) (*Service, bool) { db := handler.DB var err error @@ -92,18 +112,17 @@ func (handler Handler) service(c *gin.Context) (*Service, bool) { return NewService(db), true } -func decodeSave(c *gin.Context) (SaveRequest, error) { - var request SaveRequest - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 8<<10) +func decodeJSON(c *gin.Context, request any) error { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) decoder := json.NewDecoder(c.Request.Body) decoder.DisallowUnknownFields() - if err := decoder.Decode(&request); err != nil { - return request, err + if err := decoder.Decode(request); err != nil { + return err } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return request, errors.New("one object required") + return errors.New("one object required") } - return request, nil + return nil } func queryInt(value string, fallback int) (int, error) { diff --git a/server/app/goauto/product/router.go b/server/app/goauto/product/router.go index 2043cc0..48e4e12 100644 --- a/server/app/goauto/product/router.go +++ b/server/app/goauto/product/router.go @@ -12,5 +12,6 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { admin := engine.Group("/api/admin/v1/pdd-products").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()) admin.GET("", handler.List) admin.POST("", handler.Create) + admin.GET("/:productId", handler.Detail) admin.PATCH("/:productId", handler.Update) } diff --git a/server/app/goauto/product/service.go b/server/app/goauto/product/service.go index 13204d0..88c5f66 100644 --- a/server/app/goauto/product/service.go +++ b/server/app/goauto/product/service.go @@ -2,6 +2,7 @@ package product import ( "context" + "encoding/json" "errors" "fmt" "net/url" @@ -40,27 +41,56 @@ func (err *ServiceError) Error() string { } func (err *ServiceError) Unwrap() error { return err.Cause } +type SpecValue struct { + Name string `json:"name"` + Selectable bool `json:"selectable"` + PriceCent *int64 `json:"priceCent,omitempty"` +} + +type SpecDimension struct { + Name string `json:"name"` + Role string `json:"role"` + Values []SpecValue `json:"values"` +} + type SaveRequest struct { RequestID string `json:"requestId"` URL string `json:"url"` } +type UpdateRequest struct { + RequestID string `json:"requestId"` + URL string `json:"url"` + Title string `json:"title"` + ShopName string `json:"shopName"` + SalesCount *int64 `json:"salesCount"` + ReviewCount *int64 `json:"reviewCount"` + Status string `json:"status"` + Specs []SpecDimension `json:"specs"` +} + type ListRequest struct { - Page int - PageSize int - GoodsID string + Page, PageSize int + Keyword string + GoodsID string + Status string +} + +type ProductView struct { + models.PDDProduct + Specs []SpecDimension `json:"specs"` } type ListResponse struct { - Items []models.PDDProduct `json:"items"` - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"pageSize"` + Items []ProductView `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` } type SaveResponse struct { - Product models.PDDProduct `json:"product"` - Replayed bool `json:"replayed,omitempty"` + Product ProductView `json:"product"` + Replayed bool `json:"replayed,omitempty"` } type Service struct{ DB *gorm.DB } @@ -83,12 +113,11 @@ func NormalizeURL(raw string) (string, string, error) { if !goodsIDPattern.MatchString(goodsID) { return "", "", invalidGoodsID() } - canonical := "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID - return canonical, goodsID, nil + return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID, goodsID, nil } func (service *Service) Create(ctx context.Context, request SaveRequest) (SaveResponse, error) { - if err := validateSaveRequest(request); err != nil { + if err := validateCreateRequest(request); err != nil { return SaveResponse{}, err } canonical, goodsID, err := NormalizeURL(request.URL) @@ -98,7 +127,8 @@ func (service *Service) Create(ctx context.Context, request SaveRequest) (SaveRe db := service.DB.WithContext(ctx) var replay models.PDDProduct if err := db.Where("last_create_request_id = ?", request.RequestID).First(&replay).Error; err == nil { - return SaveResponse{Product: replay, Replayed: true}, nil + view, viewErr := makeView(replay) + return SaveResponse{Product: view, Replayed: true}, viewErr } else if !errors.Is(err, gorm.ErrRecordNotFound) { return SaveResponse{}, internalError(err) } @@ -108,34 +138,34 @@ func (service *Service) Create(ctx context.Context, request SaveRequest) (SaveRe } else if !errors.Is(err, gorm.ErrRecordNotFound) { return SaveResponse{}, internalError(err) } - record := models.PDDProduct{GoodsID: goodsID, URL: canonical, LastCreateRequestID: &request.RequestID} + record := models.PDDProduct{GoodsID: goodsID, URL: canonical, Status: "pending", SpecsJSON: "[]", LastCreateRequestID: &request.RequestID} if err := db.Create(&record).Error; err != nil { if findErr := db.Where("last_create_request_id = ?", request.RequestID).First(&replay).Error; findErr == nil { - return SaveResponse{Product: replay, Replayed: true}, nil + view, viewErr := makeView(replay) + return SaveResponse{Product: view, Replayed: true}, viewErr } if findErr := db.Where("goods_id = ?", goodsID).First(&existing).Error; findErr == nil { return SaveResponse{}, productExists(existing.ID) } return SaveResponse{}, internalError(err) } - return SaveResponse{Product: record}, nil + view, err := makeView(record) + return SaveResponse{Product: view}, err } -func (service *Service) Update(ctx context.Context, productID uint64, request SaveRequest) (SaveResponse, error) { +func (service *Service) Update(ctx context.Context, productID uint64, request UpdateRequest) (SaveResponse, error) { if productID == 0 { return SaveResponse{}, invalidRequest("productId 无效") } - if err := validateSaveRequest(request); err != nil { - return SaveResponse{}, err - } - canonical, goodsID, err := NormalizeURL(request.URL) + canonical, goodsID, specsJSON, err := validateUpdateRequest(request) if err != nil { return SaveResponse{}, err } db := service.DB.WithContext(ctx) var replay models.PDDProduct if err := db.Where("id = ? AND last_update_request_id = ?", productID, request.RequestID).First(&replay).Error; err == nil { - return SaveResponse{Product: replay, Replayed: true}, nil + view, viewErr := makeView(replay) + return SaveResponse{Product: view, Replayed: true}, viewErr } else if !errors.Is(err, gorm.ErrRecordNotFound) { return SaveResponse{}, internalError(err) } @@ -145,8 +175,14 @@ func (service *Service) Update(ctx context.Context, productID uint64, request Sa } else if !errors.Is(err, gorm.ErrRecordNotFound) { return SaveResponse{}, internalError(err) } + status := request.Status + if status != "disabled" { + status = "active" + } result := db.Model(&models.PDDProduct{}).Where("id = ?", productID).Updates(map[string]any{ - "url": canonical, "goods_id": goodsID, "last_update_request_id": request.RequestID, + "url": canonical, "goods_id": goodsID, "title": strings.TrimSpace(request.Title), "shop_name": strings.TrimSpace(request.ShopName), + "sales_count": request.SalesCount, "review_count": request.ReviewCount, "status": status, "specs_json": specsJSON, + "last_update_request_id": request.RequestID, }) if result.Error != nil { return SaveResponse{}, internalError(result.Error) @@ -154,11 +190,19 @@ func (service *Service) Update(ctx context.Context, productID uint64, request Sa if result.RowsAffected == 0 { return SaveResponse{}, &ServiceError{Code: CodeProductNotFound, Message: "PDD 商品不存在"} } - var updated models.PDDProduct - if err := db.First(&updated, productID).Error; err != nil { + return service.Detail(ctx, productID) +} + +func (service *Service) Detail(ctx context.Context, productID uint64) (SaveResponse, 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 SaveResponse{}, internalError(err) } - return SaveResponse{Product: updated}, nil + view, err := makeView(record) + return SaveResponse{Product: view}, err } func (service *Service) List(ctx context.Context, request ListRequest) (ListResponse, error) { @@ -175,18 +219,36 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp if request.GoodsID = strings.TrimSpace(request.GoodsID); request.GoodsID != "" { query = query.Where("goods_id LIKE ?", "%"+request.GoodsID+"%") } + if request.Keyword = strings.TrimSpace(request.Keyword); request.Keyword != "" { + like := "%" + request.Keyword + "%" + query = query.Where("goods_id LIKE ? OR title LIKE ? OR shop_name LIKE ?", like, like, like) + } + if request.Status != "" { + if !validStatus(request.Status) { + return ListResponse{}, invalidRequest("商品状态无效") + } + query = query.Where("status = ?", request.Status) + } var total int64 if err := query.Count(&total).Error; err != nil { return ListResponse{}, internalError(err) } - items := make([]models.PDDProduct, 0) - if err := query.Order("updated_at DESC, id DESC").Offset((request.Page - 1) * request.PageSize).Limit(request.PageSize).Find(&items).Error; err != nil { + var records []models.PDDProduct + 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)) + for _, record := range records { + view, err := makeView(record) + if err != nil { + return ListResponse{}, err + } + items = append(items, view) + } return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil } -func validateSaveRequest(request SaveRequest) error { +func validateCreateRequest(request SaveRequest) error { if _, err := uuid.Parse(strings.TrimSpace(request.RequestID)); err != nil { return invalidRequest("requestId 必须是 UUID") } @@ -195,6 +257,81 @@ func validateSaveRequest(request SaveRequest) error { } return nil } + +func validateUpdateRequest(request UpdateRequest) (string, string, string, error) { + if err := validateCreateRequest(SaveRequest{RequestID: request.RequestID, URL: request.URL}); err != nil { + return "", "", "", err + } + canonical, goodsID, err := NormalizeURL(request.URL) + if err != nil { + return "", "", "", err + } + if len([]rune(strings.TrimSpace(request.Title))) > 500 || len([]rune(strings.TrimSpace(request.ShopName))) > 255 { + return "", "", "", invalidRequest("标题或店铺名称过长") + } + if request.SalesCount != nil && *request.SalesCount < 0 || request.ReviewCount != nil && *request.ReviewCount < 0 { + return "", "", "", invalidRequest("销量和评价数量不能小于 0") + } + if request.Status != "" && !validStatus(request.Status) { + return "", "", "", invalidRequest("商品状态无效") + } + if err := ValidateSpecs(request.Specs); err != nil { + return "", "", "", err + } + raw, _ := json.Marshal(request.Specs) + return canonical, goodsID, string(raw), nil +} + +func ValidateSpecs(specs []SpecDimension) error { + if len(specs) > 20 { + return invalidRequest("规格维度不能超过 20 个") + } + dimensions := map[string]bool{} + for _, dimension := range specs { + name := strings.TrimSpace(dimension.Name) + if name == "" || len([]rune(name)) > 120 || dimensions[name] { + return invalidRequest("规格维度名称为空、重复或过长") + } + if dimension.Role != "color" && dimension.Role != "size" && dimension.Role != "other" { + return invalidRequest("规格维度角色无效") + } + dimensions[name] = true + if len(dimension.Values) == 0 || len(dimension.Values) > 200 { + return invalidRequest("每个规格维度必须包含 1 至 200 个规格值") + } + values := map[string]bool{} + for _, value := range dimension.Values { + valueName := strings.TrimSpace(value.Name) + if valueName == "" || len([]rune(valueName)) > 255 || values[valueName] { + return invalidRequest("规格值为空、重复或过长") + } + values[valueName] = true + if dimension.Role != "color" && value.PriceCent != nil { + return invalidRequest("只有颜色规格可以填写价格") + } + if value.PriceCent != nil && *value.PriceCent < 0 { + return invalidRequest("颜色价格不能小于 0") + } + } + } + return nil +} + +func makeView(record models.PDDProduct) (ProductView, error) { + view := ProductView{PDDProduct: record, Specs: []SpecDimension{}} + raw := strings.TrimSpace(record.SpecsJSON) + if raw == "" { + raw = "[]" + } + if err := json.Unmarshal([]byte(raw), &view.Specs); err != nil { + return ProductView{}, internalError(fmt.Errorf("invalid specs_json for product %d: %w", record.ID, err)) + } + return view, nil +} + +func validStatus(status string) bool { + return status == "pending" || status == "active" || status == "disabled" +} func invalidRequest(message string) error { return &ServiceError{Code: CodeInvalidRequest, Message: message} } diff --git a/server/app/goauto/product/service_test.go b/server/app/goauto/product/service_test.go index 1cbfa5b..20a1d36 100644 --- a/server/app/goauto/product/service_test.go +++ b/server/app/goauto/product/service_test.go @@ -91,7 +91,9 @@ func TestUpdateKeepsExistingTaskSnapshot(t *testing.T) { if err := db.Create(&task).Error; err != nil { t.Fatalf("create task: %v", err) } - updated, err := service.Update(context.Background(), created.Product.ID, SaveRequest{RequestID: uuid.NewString(), URL: "https://yangkeduo.com/goods.html?goods_id=684215908771"}) + updated, err := service.Update(context.Background(), created.Product.ID, UpdateRequest{ + RequestID: uuid.NewString(), URL: "https://yangkeduo.com/goods.html?goods_id=684215908771", Status: "active", Specs: []SpecDimension{}, + }) if err != nil { t.Fatalf("update: %v", err) } @@ -106,3 +108,29 @@ func TestUpdateKeepsExistingTaskSnapshot(t *testing.T) { t.Fatalf("task snapshot changed with product: %+v", storedTask) } } + +func TestUpdateValidatesAndPersistsProductArchive(t *testing.T) { + service := NewService(openProductDatabase(t)) + created, err := service.Create(context.Background(), SaveRequest{RequestID: uuid.NewString(), URL: "https://mobile.yangkeduo.com/goods.html?goods_id=729481506342"}) + if err != nil { + t.Fatalf("create: %v", err) + } + price := int64(2590) + sales := int64(1200) + reviews := int64(36) + updated, err := service.Update(context.Background(), created.Product.ID, UpdateRequest{ + RequestID: uuid.NewString(), URL: created.Product.URL, Title: "防晒袖套", ShopName: "清凉优选店", SalesCount: &sales, ReviewCount: &reviews, Status: "active", + Specs: []SpecDimension{{Name: "颜色分类", Role: "color", Values: []SpecValue{{Name: "黑色", Selectable: true, PriceCent: &price}}}, {Name: "尺码", Role: "size", Values: []SpecValue{{Name: "XL", Selectable: true}}}}, + }) + if err != nil { + t.Fatalf("update archive: %v", err) + } + if updated.Product.Title != "防晒袖套" || updated.Product.Status != "active" || len(updated.Product.Specs) != 2 { + t.Fatalf("unexpected product: %+v", updated.Product) + } + invalidPrice := int64(100) + _, err = service.Update(context.Background(), created.Product.ID, UpdateRequest{RequestID: uuid.NewString(), URL: created.Product.URL, Status: "active", Specs: []SpecDimension{{Name: "尺码", Role: "size", Values: []SpecValue{{Name: "XL", Selectable: true, PriceCent: &invalidPrice}}}}}) + if productErrorCode(t, err) != CodeInvalidRequest { + t.Fatalf("non-color price accepted: %v", err) + } +} diff --git a/server/app/goauto/task/admin_result_test.go b/server/app/goauto/task/admin_result_test.go index d494b14..b6cf3b8 100644 --- a/server/app/goauto/task/admin_result_test.go +++ b/server/app/goauto/task/admin_result_test.go @@ -2,6 +2,7 @@ package task import ( "context" + "encoding/json" "testing" "go-admin/app/goauto/models" @@ -49,6 +50,23 @@ func TestAdminCreateSnapshotsAndRejectsActiveDuplicate(t *testing.T) { } } +func TestPartialProductSpecsMergeKeepsMissingExistingValues(t *testing.T) { + existing := `[{ + "name":"颜色分类","role":"color","values":[ + {"name":"黑色","selectable":true,"priceCent":1000}, + {"name":"白色","selectable":true,"priceCent":1100} + ]},{"name":"尺码","role":"size","values":[{"name":"M","selectable":true}]}]` + price := int64(1200) + merged := mergeProductSpecs(existing, []productSpecDimension{{Name: "颜色分类", Role: "color", Values: []productSpecValue{{Name: "黑色", Selectable: true, PriceCent: &price}}}}) + raw, err := json.Marshal(merged) + if err != nil { + t.Fatal(err) + } + if string(raw) != `[{"name":"颜色分类","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1200},{"name":"白色","selectable":true,"priceCent":1100}]},{"name":"尺码","role":"size","values":[{"name":"M","selectable":true}]}]` { + t.Fatalf("unexpected merge: %s", raw) + } +} + func TestSubmitResultPersistsChildrenAndIsIdempotent(t *testing.T) { db := openTaskDatabase(t) deviceRecord, token := registerTaskDevice(t, db, "device-one") @@ -76,6 +94,13 @@ func TestSubmitResultPersistsChildrenAndIsIdempotent(t *testing.T) { if detail.Task.Status != models.TaskStatusCompleted || len(detail.SKUs) != 2 || len(detail.ColorPrices) != 1 { t.Fatalf("unexpected detail: %+v", detail) } + var updatedProduct models.PDDProduct + if err := db.First(&updatedProduct, record.PDDProductID).Error; err != nil { + t.Fatal(err) + } + if updatedProduct.Status != "active" || updatedProduct.Title != title || updatedProduct.SalesCount == nil || *updatedProduct.SalesCount != 100 || updatedProduct.SpecsJSON == "[]" { + t.Fatalf("collection result was not applied to product: %+v", updatedProduct) + } replay, err := service.SubmitResult(context.Background(), record.ID, request, token) if err != nil || !replay.Replayed { t.Fatalf("bad replay: %+v %v", replay, err) diff --git a/server/app/goauto/task/result_service.go b/server/app/goauto/task/result_service.go index 3dc2edc..f446a87 100644 --- a/server/app/goauto/task/result_service.go +++ b/server/app/goauto/task/result_service.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "errors" + "regexp" "sort" + "strconv" "strings" "time" @@ -159,6 +161,9 @@ func persistResult(tx *gorm.DB, record models.CollectionTask, request ResultRequ if len(request.Missing) > 0 { status = models.TaskStatusCompletedPartial } + if err := applyResultToProduct(tx, record.PDDProductID, request, status); err != nil { + return err + } updates := map[string]any{ "status": status, "active_slot": nil, "device_run_slot": nil, "lease_expires_at": nil, "title": cleanPointer(request.Product.Title, 500), "shop_name": cleanPointer(request.Product.ShopName, 255), @@ -172,6 +177,166 @@ func persistResult(tx *gorm.DB, record models.CollectionTask, request ResultRequ return nil } +type productSpecValue struct { + Name string `json:"name"` + Selectable bool `json:"selectable"` + PriceCent *int64 `json:"priceCent,omitempty"` +} + +type productSpecDimension struct { + Name string `json:"name"` + Role string `json:"role"` + Values []productSpecValue `json:"values"` +} + +func applyResultToProduct(tx *gorm.DB, productID uint64, request ResultRequest, status string) error { + var product models.PDDProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, productID).Error; err != nil { + return internalError(err) + } + incoming := resultProductSpecs(request) + if status == models.TaskStatusCompletedPartial { + incoming = mergeProductSpecs(product.SpecsJSON, incoming) + } + raw, _ := json.Marshal(incoming) + updates := map[string]any{"specs_json": string(raw)} + if product.Status != "disabled" { + updates["status"] = "active" + } + if status == models.TaskStatusCompleted { + updates["title"] = pointerString(request.Product.Title) + updates["shop_name"] = pointerString(request.Product.ShopName) + updates["sales_count"] = parseSalesCount(request.Product.SalesText) + updates["review_count"] = request.Product.ReviewCount + } else { + if request.Product.Title != nil { + updates["title"] = pointerString(request.Product.Title) + } + if request.Product.ShopName != nil { + updates["shop_name"] = pointerString(request.Product.ShopName) + } + if request.Product.SalesText != nil { + updates["sales_count"] = parseSalesCount(request.Product.SalesText) + } + if request.Product.ReviewCount != nil { + updates["review_count"] = request.Product.ReviewCount + } + } + if err := tx.Model(&models.PDDProduct{}).Where("id = ?", productID).Updates(updates).Error; err != nil { + return internalError(err) + } + return nil +} + +func resultProductSpecs(request ResultRequest) []productSpecDimension { + prices := map[string]int64{} + for _, entry := range request.ColorPrices { + prices[entry.Color] = entry.PriceCent + } + available := map[string]map[string]bool{} + seen := map[string]map[string]bool{} + for _, sku := range request.SKUs { + for key, value := range sku.Specs { + if seen[key] == nil { + seen[key] = map[string]bool{} + } + seen[key][value] = true + if !sku.Available { + continue + } + if available[key] == nil { + available[key] = map[string]bool{} + } + available[key][value] = true + } + } + result := make([]productSpecDimension, 0, len(request.Dimensions)) + for _, dimension := range request.Dimensions { + role := dimension.Key + if role != "color" && role != "size" { + role = "other" + } + entry := productSpecDimension{Name: dimension.Name, Role: role, Values: make([]productSpecValue, 0, len(dimension.Values))} + for _, value := range dimension.Values { + selectable := !seen[dimension.Key][value] || available[dimension.Key][value] + item := productSpecValue{Name: value, Selectable: selectable} + if role == "color" { + if price, ok := prices[value]; ok { + copied := price + item.PriceCent = &copied + } + } + entry.Values = append(entry.Values, item) + } + result = append(result, entry) + } + return result +} + +func mergeProductSpecs(existingJSON string, incoming []productSpecDimension) []productSpecDimension { + var existing []productSpecDimension + if json.Unmarshal([]byte(existingJSON), &existing) != nil { + existing = nil + } + for _, next := range incoming { + matched := -1 + for index := range existing { + if existing[index].Role == next.Role && existing[index].Name == next.Name { + matched = index + break + } + } + if matched < 0 { + existing = append(existing, next) + continue + } + values := map[string]int{} + for index, value := range existing[matched].Values { + values[value.Name] = index + } + for _, value := range next.Values { + if index, ok := values[value.Name]; ok { + existing[matched].Values[index] = value + } else { + existing[matched].Values = append(existing[matched].Values, value) + } + } + } + return existing +} + +func pointerString(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} + +var countPattern = regexp.MustCompile(`([0-9]+(?:\.[0-9]+)?)`) + +func parseSalesCount(value *string) *int64 { + if value == nil { + return nil + } + cleaned := strings.ReplaceAll(strings.TrimSpace(*value), ",", "") + match := countPattern.FindString(cleaned) + if match == "" { + return nil + } + number, err := strconv.ParseFloat(match, 64) + if err != nil { + return nil + } + if strings.Contains(cleaned, "万") { + number *= 10000 + } + if strings.Contains(strings.ToLower(cleaned), "k") { + number *= 1000 + } + result := int64(number) + return &result +} + func (service *Service) SubmitFailure(ctx context.Context, taskID uint64, request FailRequest, token string) (DetailResponse, error) { if taskID == 0 || uuid.Validate(request.RequestID) != nil || strings.TrimSpace(request.Code) == "" || len(request.Code) > 64 || strings.TrimSpace(request.Message) == "" || len(request.Message) > 1000 { return DetailResponse{}, serviceError("INVALID_REQUEST", "失败请求字段无效") diff --git a/server/cmd/migrate/migration/version-local/1786700500000_pdd_product_archive.go b/server/cmd/migrate/migration/version-local/1786700500000_pdd_product_archive.go new file mode 100644 index 0000000..dac99b4 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1786700500000_pdd_product_archive.go @@ -0,0 +1,40 @@ +package version_local + +import ( + "runtime" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +// This additive migration expands the URL-only PDD product record into the +// latest-value product archive used by collection and later purchasing flows. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migratePDDProductArchive) +} + +func migratePDDProductArchive(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if !tx.Migrator().HasColumn("pdd_product", "specs_json") { + if err := tx.Exec("ALTER TABLE pdd_product ADD specs_json JSON NULL").Error; err != nil { + return err + } + } + if err := tx.Exec("UPDATE pdd_product SET specs_json = JSON_ARRAY() WHERE specs_json IS NULL").Error; err != nil { + return err + } + if tx.Dialector.Name() == "mysql" { + if err := tx.Exec("ALTER TABLE pdd_product MODIFY specs_json JSON NOT NULL").Error; err != nil { + return err + } + } + if err := goautomigrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/web/src/api/goauto/pdd-products.js b/web/src/api/goauto/pdd-products.js index c1c9886..802ec08 100644 --- a/web/src/api/goauto/pdd-products.js +++ b/web/src/api/goauto/pdd-products.js @@ -8,6 +8,10 @@ export function createPddProduct(data) { return request({ url: '/api/admin/v1/pdd-products', method: 'post', data }) } +export function getPddProduct(productId) { + return request({ url: `/api/admin/v1/pdd-products/${productId}`, method: 'get' }) +} + export function updatePddProduct(productId, data) { return request({ url: `/api/admin/v1/pdd-products/${productId}`, method: 'patch', data }) } diff --git a/web/src/views/goauto/pdd-products/index.vue b/web/src/views/goauto/pdd-products/index.vue index ad9b9bc..19a59ee 100644 --- a/web/src/views/goauto/pdd-products/index.vue +++ b/web/src/views/goauto/pdd-products/index.vue @@ -1,70 +1,66 @@ @@ -72,76 +68,33 @@ diff --git a/web/tests/e2e/pdd-product-module.spec.ts b/web/tests/e2e/pdd-product-module.spec.ts new file mode 100644 index 0000000..5a5bf07 --- /dev/null +++ b/web/tests/e2e/pdd-product-module.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test'; + +const product = { + id: 1, + goodsId: '972800403573', + url: 'https://mobile.yangkeduo.com/goods.html?goods_id=972800403573', + title: '夏季防晒冰丝袖套', + shopName: '清凉优选店', + salesCount: 1200, + reviewCount: 36, + status: 'active', + specs: [ + { name: '颜色分类', role: 'color', values: [{ name: '黑色', selectable: true, priceCent: 2590 }] }, + { name: '尺码', role: 'size', values: [{ name: 'XL', selectable: true }] }, + ], + updatedAt: '2026-08-17T08:00:00Z', +}; + +test('PDD 商品列表、详情和编辑流程符合已确认原型', async ({ page, context }) => { + let updatePayload: 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/')) { + await route.continue(); + return; + } + if (url.pathname.endsWith('/api/v1/getinfo')) { + await route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] } } }); + return; + } + if (url.pathname.endsWith('/api/admin/v1/pdd-products/1') && route.request().method() === 'PATCH') { + updatePayload = route.request().postDataJSON(); + await route.fulfill({ json: { code: 200, data: { product: { ...product, ...updatePayload } } } }); + return; + } + if (url.pathname.endsWith('/api/admin/v1/pdd-products/1')) { + await route.fulfill({ json: { code: 200, data: { product } } }); + return; + } + if (url.pathname.endsWith('/api/admin/v1/pdd-products')) { + await route.fulfill({ json: { code: 200, data: { items: [product], total: 1, page: 1, pageSize: 20 } } }); + return; + } + await route.fulfill({ json: { code: 200, data: [] } }); + }); + + await page.goto('http://localhost:9527/#/pdd-products/index'); + await expect(page.getByRole('heading', { name: 'PDD 商品' })).toBeVisible(); + await expect(page.getByText('夏季防晒冰丝袖套')).toBeVisible(); + await expect(page.getByText('¥25.90')).toBeVisible(); + + await page.getByRole('button', { name: '查看' }).click(); + await expect(page.getByText('PDD 商品详情')).toBeVisible(); + await expect(page.locator('.spec-title').filter({ hasText: '颜色分类' })).toBeVisible(); + await expect(page.getByText('XL')).toBeVisible(); + + await page.getByRole('button', { name: '编辑商品' }).click(); + await expect(page.getByText('编辑 PDD 商品')).toBeVisible(); + await page.getByLabel('商品标题').fill('夏季防晒袖套(人工确认)'); + await page.getByRole('button', { name: '保存修改' }).click(); + await expect.poll(() => updatePayload?.title).toBe('夏季防晒袖套(人工确认)'); + expect(updatePayload.specs[0].values[0].priceCent).toBe(2590); +});