feat(#31): implement PDD product archive module
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 项目档案
|
||||
@@ -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 采购闭环原型及后续采购工单仍按各自门禁推进。采购永不支付,真实地址修改和创建订单属于必须再次人工确认的高风险范围。
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -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/` |
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
|
||||
## 当前范围
|
||||
|
||||
当前 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 商品最新档案,商品页不替代任务结果审计。
|
||||
|
||||
## 采集字段
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 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`。
|
||||
|
||||
## 管理端:采集规则
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 当前 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 真机总验收。
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", "失败请求字段无效")
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -1,70 +1,66 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="product-card" shadow="never">
|
||||
<el-card class="page-card" shadow="never">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h1>PDD 商品</h1>
|
||||
<p>人工添加商品链接,系统会提取 goods_id 并统一链接格式。</p>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">添加 PDD 链接</el-button>
|
||||
<div><h1>PDD 商品</h1><p>维护可重复采集和采购的商品资料;添加时只需填写包含 goods_id 的链接。</p></div>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">添加 PDD 商品</el-button>
|
||||
</div>
|
||||
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="handleQuery">
|
||||
<el-form-item label="Goods ID">
|
||||
<el-input v-model="query.goodsId" placeholder="输入完整或部分 goods_id" clearable @keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
|
||||
<el-button :icon="RefreshLeft" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
|
||||
<el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="goods_id、标题或店铺" clearable @keyup.enter="search" /></el-form-item>
|
||||
<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 商品,请先添加链接">
|
||||
<el-table-column label="ID" prop="id" width="90" />
|
||||
<el-table-column label="Goods ID" prop="goodsId" min-width="180" />
|
||||
<el-table-column label="当前链接" min-width="420">
|
||||
<template #default="{ row }">
|
||||
<a class="product-link" :href="row.url" target="_blank" rel="noopener noreferrer">{{ row.url }}</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更新时间" width="180">
|
||||
<template #default="{ row }">{{ parseTime(row.updatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openEdit(row)">修改链接</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table v-loading="loading" :data="products" border stripe empty-text="暂无 PDD 商品,请先添加商品链接">
|
||||
<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="销量 / 评价" 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>
|
||||
<el-table-column label="更新时间" width="170"><template #default="{ row }">{{ parseTime(row.updatedAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">查看</el-button><el-button type="primary" link @click="openEdit(row.id)">编辑</el-button><el-button type="primary" link tag="a" :href="row.url" target="_blank" rel="noopener noreferrer">打开链接</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.pageSize"
|
||||
:total="total"
|
||||
@pagination="getList"
|
||||
/>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialog.open" :title="dialog.productId ? '修改 PDD 链接' : '添加 PDD 链接'" width="640px" :close-on-click-modal="false">
|
||||
<el-alert
|
||||
title="链接必须包含数字 goods_id;保存后会移除无关追踪参数。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="dialog-alert"
|
||||
/>
|
||||
<el-form ref="productForm" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="PDD 商品链接" prop="url">
|
||||
<el-input v-model="form.url" type="textarea" :rows="4" placeholder="https://mobile.yangkeduo.com/goods.html?goods_id=..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialog.open = false">取消</el-button>
|
||||
<el-button type="primary" :loading="dialog.saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
<el-dialog v-model="createDialog.open" title="添加 PDD 商品" width="640px" :close-on-click-modal="false">
|
||||
<el-alert title="只需填写商品链接。系统会提取 goods_id,新商品先显示为“待完善”。" type="info" :closable="false" show-icon class="notice" />
|
||||
<el-form ref="createForm" :model="createData" :rules="urlRules" label-position="top"><el-form-item label="PDD 商品链接" prop="url"><el-input v-model="createData.url" type="textarea" :rows="4" placeholder="https://mobile.yangkeduo.com/goods.html?goods_id=..." /></el-form-item></el-form>
|
||||
<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-drawer v-model="detail.open" title="PDD 商品详情" size="720px">
|
||||
<div v-loading="detail.loading" class="drawer-body">
|
||||
<template v-if="detail.product">
|
||||
<div class="drawer-actions"><el-tag :type="statusMeta(detail.product.status).type">{{ statusMeta(detail.product.status).label }}</el-tag><el-button type="primary" @click="openEdit(detail.product.id)">编辑商品</el-button></div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="goods_id">{{ detail.product.goodsId }}</el-descriptions-item><el-descriptions-item label="店铺">{{ detail.product.shopName || '未填写' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="标题" :span="2">{{ detail.product.title || '未填写' }}</el-descriptions-item><el-descriptions-item label="销量">{{ countText(detail.product.salesCount) }}</el-descriptions-item><el-descriptions-item label="评价数量">{{ countText(detail.product.reviewCount) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品链接" :span="2"><a class="link" :href="detail.product.url" target="_blank" rel="noopener noreferrer">{{ detail.product.url }}</a></el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h3 class="section-title">商品规格</h3><el-empty v-if="!detail.product.specs.length" description="尚未维护规格" />
|
||||
<div v-for="dimension in detail.product.specs" :key="dimension.name" class="spec-block"><div class="spec-title">{{ dimension.name }} <el-tag size="small" effect="plain">{{ roleLabel(dimension.role) }}</el-tag></div><el-table :data="dimension.values" border size="small"><el-table-column label="规格值" prop="name" /><el-table-column label="是否可选择" width="120"><template #default="{ row }">{{ row.selectable ? '可选择' : '不可选择' }}</template></el-table-column><el-table-column v-if="dimension.role === 'color'" label="价格" width="130"><template #default="{ row }">{{ priceText(row.priceCent) }}</template></el-table-column></el-table></div>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="edit.open" title="编辑 PDD 商品" size="820px" :close-on-click-modal="false">
|
||||
<div v-loading="edit.loading" class="drawer-body">
|
||||
<el-alert title="保存会完整覆盖商品资料;以后采集成功时也会更新这里的最新资料。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-form ref="editForm" :model="editData" :rules="urlRules" label-position="top">
|
||||
<el-form-item label="PDD 商品链接" prop="url"><el-input v-model="editData.url" type="textarea" :rows="2" /></el-form-item>
|
||||
<div class="form-grid"><el-form-item label="商品标题"><el-input v-model="editData.title" maxlength="500" /></el-form-item><el-form-item label="店铺名"><el-input v-model="editData.shopName" maxlength="255" /></el-form-item><el-form-item label="销量"><el-input-number v-model="editData.salesCount" :min="0" :precision="0" /></el-form-item><el-form-item label="评价数量"><el-input-number v-model="editData.reviewCount" :min="0" :precision="0" /></el-form-item><el-form-item label="商品状态"><el-radio-group v-model="editData.status"><el-radio value="active">正常</el-radio><el-radio value="disabled">已停用</el-radio></el-radio-group></el-form-item></div>
|
||||
<div class="section-heading"><h3>规格维度</h3><el-button :icon="Plus" @click="addDimension">添加规格维度</el-button></div><el-empty v-if="!editData.specs.length" description="尚未添加规格,可直接保存基础资料" />
|
||||
<el-card v-for="(dimension, di) in editData.specs" :key="dimension.localId" shadow="never" class="dimension-card">
|
||||
<div class="dimension-header"><el-input v-model="dimension.name" placeholder="例如:颜色分类、尺码" maxlength="120" /><el-select v-model="dimension.role" style="width: 130px"><el-option label="颜色" value="color" /><el-option label="尺码" value="size" /><el-option label="其他" value="other" /></el-select><el-button :disabled="di === 0" @click="move(editData.specs, di, -1)">上移</el-button><el-button :disabled="di === editData.specs.length - 1" @click="move(editData.specs, di, 1)">下移</el-button><el-button type="danger" plain @click="editData.specs.splice(di, 1)">删除</el-button></div>
|
||||
<el-table :data="dimension.values" border size="small" empty-text="请添加规格值"><el-table-column label="规格值" min-width="180"><template #default="{ row }"><el-input v-model="row.name" maxlength="255" placeholder="规格值名称" /></template></el-table-column><el-table-column label="可选择" width="100" align="center"><template #default="{ row }"><el-switch v-model="row.selectable" /></template></el-table-column><el-table-column v-if="dimension.role === 'color'" label="价格(元)" width="170"><template #default="{ row }"><el-input-number v-model="row.priceYuan" :min="0" :precision="2" :step="0.01" /></template></el-table-column><el-table-column label="排序" width="135"><template #default="{ $index }"><el-button link :disabled="$index === 0" @click="move(dimension.values, $index, -1)">上移</el-button><el-button link :disabled="$index === dimension.values.length - 1" @click="move(dimension.values, $index, 1)">下移</el-button></template></el-table-column><el-table-column label="操作" width="75"><template #default="{ $index }"><el-button type="danger" link @click="dimension.values.splice($index, 1)">删除</el-button></template></el-table-column></el-table>
|
||||
<el-button class="add-value" :icon="Plus" @click="addValue(dimension)">添加规格值</el-button>
|
||||
</el-card>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer><el-button @click="edit.open = false">取消</el-button><el-button type="primary" :loading="edit.saving" @click="saveEdit">保存修改</el-button></template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
@@ -72,76 +68,33 @@
|
||||
<script>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { createPddProduct, listPddProducts, updatePddProduct } from '@/api/goauto/pdd-products'
|
||||
import { createPddProduct, getPddProduct, listPddProducts, updatePddProduct } from '@/api/goauto/pdd-products'
|
||||
|
||||
let uid = 0
|
||||
export default {
|
||||
name: 'GoAutoPddProducts',
|
||||
setup() { return { Plus, RefreshLeft, Search } },
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
products: [],
|
||||
total: 0,
|
||||
query: { page: 1, pageSize: 20, goodsId: '' },
|
||||
dialog: { open: false, saving: false, productId: null },
|
||||
form: { url: '' },
|
||||
rules: {
|
||||
url: [
|
||||
{ required: true, message: '请输入 PDD 商品链接', trigger: 'blur' },
|
||||
{ max: 4096, message: '链接不能超过 4096 个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
created() { this.getList() },
|
||||
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' }] }} },
|
||||
created() { this.load() },
|
||||
methods: {
|
||||
async getList() {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await listPddProducts(this.query)
|
||||
this.products = response.data.items
|
||||
this.total = response.data.total
|
||||
} finally { this.loading = false }
|
||||
},
|
||||
handleQuery() { this.query.page = 1; this.getList() },
|
||||
resetQuery() { this.query = { page: 1, pageSize: 20, goodsId: '' }; this.getList() },
|
||||
openCreate() {
|
||||
this.dialog = { open: true, saving: false, productId: null }
|
||||
this.form = { url: '' }
|
||||
this.$nextTick(() => this.$refs.productForm?.clearValidate())
|
||||
},
|
||||
openEdit(row) {
|
||||
this.dialog = { open: true, saving: false, productId: row.id }
|
||||
this.form = { url: row.url }
|
||||
this.$nextTick(() => this.$refs.productForm?.clearValidate())
|
||||
},
|
||||
async save() {
|
||||
const valid = await this.$refs.productForm.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
this.dialog.saving = true
|
||||
const payload = { requestId: window.crypto.randomUUID(), url: this.form.url.trim() }
|
||||
try {
|
||||
if (this.dialog.productId) await updatePddProduct(this.dialog.productId, payload)
|
||||
else await createPddProduct(payload)
|
||||
ElMessage.success(this.dialog.productId ? '商品链接已更新' : 'PDD 商品已添加')
|
||||
this.dialog.open = false
|
||||
await this.getList()
|
||||
} finally { this.dialog.saving = false }
|
||||
}
|
||||
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 } },
|
||||
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)}` },
|
||||
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 } },
|
||||
async openEdit(id) { this.detail.open = false; this.edit = { open: true, loading: true, saving: false, productId: id }; try { const r = await getPddProduct(id); const p = r.data.product; this.editData = { url: p.url, title: p.title || '', shopName: p.shopName || '', salesCount: p.salesCount, reviewCount: p.reviewCount, status: p.status === 'disabled' ? 'disabled' : 'active', specs: (p.specs || []).map(d => ({ ...d, localId: ++uid, values: d.values.map(v => ({ ...v, localId: ++uid, priceYuan: v.priceCent === null || v.priceCent === undefined ? null : v.priceCent / 100 })) })) } } finally { this.edit.loading = false } },
|
||||
addDimension() { this.editData.specs.push({ localId: ++uid, name: '', role: 'other', values: [] }) }, addValue(d) { d.values.push({ localId: ++uid, name: '', selectable: true, priceYuan: null }) },
|
||||
move(items, index, offset) { const target = index + offset; if (target < 0 || target >= items.length) return; const [item] = items.splice(index, 1); items.splice(target, 0, item) },
|
||||
specError() { const dimensions = new Set(); for (const d of this.editData.specs) { const name = d.name.trim(); if (!name) return '请填写规格维度名称'; if (dimensions.has(name)) return `规格维度“${name}”重复`; dimensions.add(name); if (!d.values.length) return `请为“${name}”添加规格值`; const values = new Set(); for (const v of d.values) { const n = v.name.trim(); if (!n) return `请填写“${name}”中的规格值名称`; if (values.has(n)) return `“${name}”中存在重复规格值“${n}”`; values.add(n) } } return '' },
|
||||
async saveEdit() { if (!await this.$refs.editForm.validate().catch(() => false)) return; const error = this.specError(); if (error) { ElMessage.warning(error); return } this.edit.saving = true; const id = this.edit.productId; try { const data = this.editData; await updatePddProduct(id, { requestId: crypto.randomUUID(), url: data.url.trim(), title: data.title.trim(), shopName: data.shopName.trim(), salesCount: data.salesCount, reviewCount: data.reviewCount, status: data.status, specs: data.specs.map(d => ({ name: d.name.trim(), role: d.role, values: d.values.map(v => ({ name: v.name.trim(), selectable: v.selectable, ...(d.role === 'color' && v.priceYuan !== null ? { priceCent: Math.round(v.priceYuan * 100) } : {}) })) })) }); ElMessage.success('PDD 商品资料已保存'); this.edit.open = false; await this.load(); await this.openDetail(id) } finally { this.edit.saving = false } }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.product-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; color: #1f2937; font-size: 24px; line-height: 1.35; }
|
||||
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; }
|
||||
.product-link { color: #1677ff; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.product-link:focus-visible { outline: 2px solid #1677ff; outline-offset: 2px; }
|
||||
.dialog-alert { margin-bottom: 20px; }
|
||||
@media (max-width: 768px) { .page-heading { align-items: stretch; flex-direction: column; } }
|
||||
.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}}
|
||||
</style>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user