feat: 支持蝦皮批量创建 PDD 采集任务 (#185)
This commit is contained in:
+44
-15
@@ -75,6 +75,12 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw,
|
|||||||
"读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
"读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
assignableClients, err := service.ListAssignableClients(h.db, currentUser(c), h.onlineThreshold)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError,
|
||||||
|
"读取可选客户端失败,数据没有被改动。刷新页面重试。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
status := msg
|
status := msg
|
||||||
if status == "" {
|
if status == "" {
|
||||||
@@ -99,21 +105,22 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw,
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{
|
c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{
|
||||||
"Keyword": keyword,
|
"Keyword": keyword,
|
||||||
"ShopNameKeyword": filter.ShopName,
|
"ShopNameKeyword": filter.ShopName,
|
||||||
"StatusFilter": filter.Status,
|
"StatusFilter": filter.Status,
|
||||||
"StatusOptions": service.ShopeeStatusOptions(),
|
"StatusOptions": service.ShopeeStatusOptions(),
|
||||||
"ShopFilter": filter.Shop,
|
"ShopFilter": filter.Shop,
|
||||||
"ShopOptions": service.ShopeePresenceOptions("有店铺", "无店铺"),
|
"ShopOptions": service.ShopeePresenceOptions("有店铺", "无店铺"),
|
||||||
"ImageFilter": filter.Image,
|
"ImageFilter": filter.Image,
|
||||||
"ImageOptions": service.ShopeePresenceOptions("有图片", "无图片"),
|
"ImageOptions": service.ShopeePresenceOptions("有图片", "无图片"),
|
||||||
"Rows": result.Rows,
|
"Rows": result.Rows,
|
||||||
"Status": status,
|
"Status": status,
|
||||||
"HasAnyProducts": result.HasAnyProducts,
|
"HasAnyProducts": result.HasAnyProducts,
|
||||||
"IsFiltered": result.IsFiltered,
|
"IsFiltered": result.IsFiltered,
|
||||||
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
|
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
|
||||||
"CurrentPage": result.Page,
|
"CurrentPage": result.Page,
|
||||||
"DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page),
|
"DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page),
|
||||||
|
"AssignableClients": assignableClients,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +187,28 @@ func (h *Handler) ShopeeCollect(c *gin.Context) {
|
|||||||
h.shopeeRedirect(c, service.FormatCollectTaskMessage(result))
|
h.shopeeRedirect(c, service.FormatCollectTaskMessage(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShopeeCollectBatch 为勾选的蝦皮商品按当前 PDD 关联批量创建采集任务。
|
||||||
|
func (h *Handler) ShopeeCollectBatch(c *gin.Context) {
|
||||||
|
ids := c.PostFormArray("ids")
|
||||||
|
if len(ids) == 0 {
|
||||||
|
h.shopeeRedirect(c, "没有勾选任何蝦皮商品,没有创建任务")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := service.CreateShopeePddCollectTasksForUser(
|
||||||
|
h.db, currentUser(c), ids, c.PostForm("client_id"))
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
message := "创建采集任务失败,系统没有改动这一批数据。请稍后重试。"
|
||||||
|
if service.IsValidationError(err) {
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
message = err.Error() + "。这一批任务整体没有创建,请重新选择。"
|
||||||
|
}
|
||||||
|
fail(c, status, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.shopeeRedirect(c, service.FormatCollectTaskMessage(result))
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) shopeeRedirect(c *gin.Context, msg string) {
|
func (h *Handler) shopeeRedirect(c *gin.Context, msg string) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
if value := strings.TrimSpace(c.PostForm("list_goods_id")); value != "" {
|
if value := strings.TrimSpace(c.PostForm("list_goods_id")); value != "" {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
|
|||||||
pages.POST("/shopee/save", h.ShopeeSave)
|
pages.POST("/shopee/save", h.ShopeeSave)
|
||||||
pages.POST("/shopee/delete", h.ShopeeDelete)
|
pages.POST("/shopee/delete", h.ShopeeDelete)
|
||||||
pages.POST("/shopee/collect", h.ShopeeCollect)
|
pages.POST("/shopee/collect", h.ShopeeCollect)
|
||||||
|
pages.POST("/shopee/collect-batch", h.ShopeeCollectBatch)
|
||||||
|
|
||||||
// 2. PDD 商品
|
// 2. PDD 商品
|
||||||
pages.GET("/pdd", h.PddList)
|
pages.GET("/pdd", h.PddList)
|
||||||
|
|||||||
@@ -234,6 +234,24 @@ func TestShopeePage_移除Excel入口且保留目录记录入口(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShopeePage_批量创建Pdd采集任务入口(t *testing.T) {
|
||||||
|
content, err := os.ReadFile("templates/shopee/list.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
page := string(content)
|
||||||
|
for _, want := range []string{
|
||||||
|
`action="/shopee/collect-batch"`, `data-modal-open="shopee-collect-modal"`,
|
||||||
|
`data-collect-form="shopee-collect-form"`, `form="shopee-collect-form"`,
|
||||||
|
`<label for="shopee-collect-client">执行客户端</label>`,
|
||||||
|
`不指定(任意客户端可领取)`, `.AssignableClients`, `data-collect-submit`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(page, want) {
|
||||||
|
t.Errorf("蝦皮批量采集入口缺少 %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestShopeeDetail_区分顺运宝观测与正式SKU(t *testing.T) {
|
func TestShopeeDetail_区分顺运宝观测与正式SKU(t *testing.T) {
|
||||||
content, err := os.ReadFile("templates/shopee/detail_modal.html")
|
content, err := os.ReadFile("templates/shopee/detail_modal.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -301,6 +301,37 @@ func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct,
|
|||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListShopeePddLinksByGoodsIDs 批量读取蝦皮商品当前关联的 PDD 商品 ID。
|
||||||
|
// 返回结果只包含仍存在的蝦皮商品;值为空表示商品存在但尚未关联 PDD。
|
||||||
|
func ListShopeePddLinksByGoodsIDs(q Execer, goodsIDs []string) (map[string]string, error) {
|
||||||
|
links := make(map[string]string, len(goodsIDs))
|
||||||
|
if len(goodsIDs) == 0 {
|
||||||
|
return links, nil
|
||||||
|
}
|
||||||
|
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(goodsIDs)), ",")
|
||||||
|
args := make([]any, len(goodsIDs))
|
||||||
|
for i, goodsID := range goodsIDs {
|
||||||
|
args[i] = goodsID
|
||||||
|
}
|
||||||
|
rows, err := q.Query(`SELECT goods_id, COALESCE(pdd_goods_id, '')
|
||||||
|
FROM shopee_products WHERE goods_id IN (`+placeholders+`)`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("批量查询蝦皮商品 PDD 关联失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var shopeeGoodsID, pddGoodsID string
|
||||||
|
if err := rows.Scan(&shopeeGoodsID, &pddGoodsID); err != nil {
|
||||||
|
return nil, fmt.Errorf("读取蝦皮商品 PDD 关联失败: %w", err)
|
||||||
|
}
|
||||||
|
links[shopeeGoodsID] = pddGoodsID
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("遍历蝦皮商品 PDD 关联失败: %w", err)
|
||||||
|
}
|
||||||
|
return links, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateShopeePddLink 更新蝦皮商品当前关联的 PDD 商品。
|
// UpdateShopeePddLink 更新蝦皮商品当前关联的 PDD 商品。
|
||||||
// 调用方必须先在同一事务里保证 PDD 商品存在,避免留下悬空关联。
|
// 调用方必须先在同一事务里保证 PDD 商品存在,避免留下悬空关联。
|
||||||
func UpdateShopeePddLink(q Execer, shopeeGoodsID, pddGoodsID, pddURL string) error {
|
func UpdateShopeePddLink(q Execer, shopeeGoodsID, pddGoodsID, pddURL string) error {
|
||||||
|
|||||||
+12
-4
@@ -543,9 +543,11 @@ func DeletePddProducts(db *sql.DB, goodsIDs []string) (int64, error) {
|
|||||||
type CollectTaskResult struct {
|
type CollectTaskResult struct {
|
||||||
Created int
|
Created int
|
||||||
|
|
||||||
SkippedCollecting int // 正在采集且**没超时**,得等客户端来领/提交
|
SkippedShopeeMissing int // 浏览器提交的蝦皮商品已不存在
|
||||||
SkippedCollected int // 已经采集完成,本来就不需要重新采集
|
SkippedUnlinked int // 蝦皮商品尚未关联 PDD
|
||||||
SkippedDeleted int // 商品不存在或已被软删除
|
SkippedCollecting int // 正在采集且**没超时**,得等客户端来领/提交
|
||||||
|
SkippedCollected int // 已经采集完成,本来就不需要重新采集
|
||||||
|
SkippedDeleted int // 商品不存在或已被软删除
|
||||||
|
|
||||||
// RetryWaitText 是 SkippedCollecting 里最快能重试的还需等待时长,
|
// RetryWaitText 是 SkippedCollecting 里最快能重试的还需等待时长,
|
||||||
// 形如 "12 分钟";SkippedCollecting 为 0 时是空串。
|
// 形如 "12 分钟";SkippedCollecting 为 0 时是空串。
|
||||||
@@ -555,7 +557,7 @@ type CollectTaskResult struct {
|
|||||||
// Skipped 是跳过总数,供测试和粗粒度统计用;
|
// Skipped 是跳过总数,供测试和粗粒度统计用;
|
||||||
// 界面提示要按原因分开说,见 FormatCollectTaskMessage,不要直接显示这个数。
|
// 界面提示要按原因分开说,见 FormatCollectTaskMessage,不要直接显示这个数。
|
||||||
func (r CollectTaskResult) Skipped() int {
|
func (r CollectTaskResult) Skipped() int {
|
||||||
return r.SkippedCollecting + r.SkippedCollected + r.SkippedDeleted
|
return r.SkippedShopeeMissing + r.SkippedUnlinked + r.SkippedCollecting + r.SkippedCollected + r.SkippedDeleted
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePddCollectTasks 为勾选的 PDD 商品创建不指定客户端的采集任务。
|
// CreatePddCollectTasks 为勾选的 PDD 商品创建不指定客户端的采集任务。
|
||||||
@@ -784,6 +786,12 @@ func formatRetryWait(remain time.Duration) string {
|
|||||||
// (比如还要等多久),见 #24。
|
// (比如还要等多久),见 #24。
|
||||||
func FormatCollectTaskMessage(r CollectTaskResult) string {
|
func FormatCollectTaskMessage(r CollectTaskResult) string {
|
||||||
var reasons []string
|
var reasons []string
|
||||||
|
if r.SkippedShopeeMissing > 0 {
|
||||||
|
reasons = append(reasons, fmt.Sprintf("%d 个蝦皮商品已不存在", r.SkippedShopeeMissing))
|
||||||
|
}
|
||||||
|
if r.SkippedUnlinked > 0 {
|
||||||
|
reasons = append(reasons, fmt.Sprintf("%d 个未关联 PDD", r.SkippedUnlinked))
|
||||||
|
}
|
||||||
if r.SkippedCollecting > 0 {
|
if r.SkippedCollecting > 0 {
|
||||||
reason := fmt.Sprintf("%d 个正在采集中", r.SkippedCollecting)
|
reason := fmt.Sprintf("%d 个正在采集中", r.SkippedCollecting)
|
||||||
if r.Created > 0 {
|
if r.Created > 0 {
|
||||||
|
|||||||
@@ -112,6 +112,43 @@ func CreateShopeePddCollectTaskForUser(db *sql.DB, actor *model.User, shopeeGood
|
|||||||
return CreatePddCollectTasksForUser(db, actor, []string{product.PddGoodsID}, "")
|
return CreatePddCollectTasksForUser(db, actor, []string{product.PddGoodsID}, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateShopeePddCollectTasksForUser 把勾选的蝦皮商品转换成当前 PDD 商品后批量建采集任务。
|
||||||
|
// 蝦皮与 PDD 可以多对一,因此交给统一 PDD 服务前先按 PDD goods_id 去重;
|
||||||
|
// 商品不存在和未关联按原因跳过,其余状态、防重和客户端权限仍由统一服务处理。
|
||||||
|
func CreateShopeePddCollectTasksForUser(db *sql.DB, actor *model.User, shopeeGoodsIDs []string, clientID string) (CollectTaskResult, error) {
|
||||||
|
shopeeGoodsIDs = dedupe(shopeeGoodsIDs)
|
||||||
|
if len(shopeeGoodsIDs) == 0 {
|
||||||
|
return CollectTaskResult{}, invalidInput("没有选择蝦皮商品")
|
||||||
|
}
|
||||||
|
links, err := repository.ListShopeePddLinksByGoodsIDs(db, shopeeGoodsIDs)
|
||||||
|
if err != nil {
|
||||||
|
return CollectTaskResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var skipped CollectTaskResult
|
||||||
|
pddGoodsIDs := make([]string, 0, len(shopeeGoodsIDs))
|
||||||
|
for _, shopeeGoodsID := range shopeeGoodsIDs {
|
||||||
|
pddGoodsID, exists := links[shopeeGoodsID]
|
||||||
|
if !exists {
|
||||||
|
skipped.SkippedShopeeMissing++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(pddGoodsID) == "" {
|
||||||
|
skipped.SkippedUnlinked++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pddGoodsIDs = append(pddGoodsIDs, pddGoodsID)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := CreatePddCollectTasksForUser(db, actor, dedupe(pddGoodsIDs), clientID)
|
||||||
|
if err != nil {
|
||||||
|
return CollectTaskResult{}, err
|
||||||
|
}
|
||||||
|
result.SkippedShopeeMissing = skipped.SkippedShopeeMissing
|
||||||
|
result.SkippedUnlinked = skipped.SkippedUnlinked
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AssociateSybPdd 从顺运宝明细解析出可信的蝦皮商品,再复用统一关联逻辑。
|
// AssociateSybPdd 从顺运宝明细解析出可信的蝦皮商品,再复用统一关联逻辑。
|
||||||
func AssociateSybPdd(db *sql.DB, sybID, rawURL string, confirmReplace bool) (string, error) {
|
func AssociateSybPdd(db *sql.DB, sybID, rawURL string, confirmReplace bool) (string, error) {
|
||||||
context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID))
|
context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -120,6 +121,92 @@ func TestCreateShopeePddCollectTaskForUser_记录当前用户(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateShopeePddCollectTasksForUser_按Pdd去重并分类跳过(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
admin, _, _ := insertTaskUsers(t, db)
|
||||||
|
for _, goodsID := range []string{"S-A", "S-B", "S-UNLINKED", "S-DELETED"} {
|
||||||
|
seedShopeeProduct(t, db, goodsID, goodsID)
|
||||||
|
}
|
||||||
|
for _, goodsID := range []string{"S-A", "S-B"} {
|
||||||
|
if _, err := AssociateShopeePdd(db, goodsID, pddURLA, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := AssociateShopeePdd(db, "S-DELETED", pddURLB, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repository.SoftDeletePddProduct(db, "937122477375"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := CreateShopeePddCollectTasksForUser(db, admin,
|
||||||
|
[]string{"S-A", "S-B", "S-A", "S-UNLINKED", "S-DELETED", "S-MISSING"}, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Created != 1 || result.SkippedUnlinked != 1 || result.SkippedShopeeMissing != 1 || result.SkippedDeleted != 1 {
|
||||||
|
t.Fatalf("批量分类或 PDD 去重错误: %+v", result)
|
||||||
|
}
|
||||||
|
var taskCount int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='collect'`).Scan(&taskCount); err != nil || taskCount != 1 {
|
||||||
|
t.Fatalf("相同 PDD 商品只应创建一条任务,count=%d err=%v", taskCount, err)
|
||||||
|
}
|
||||||
|
message := FormatCollectTaskMessage(result)
|
||||||
|
for _, want := range []string{"已创建 1 个", "1 个蝦皮商品已不存在", "1 个未关联 PDD", "1 个已删除的"} {
|
||||||
|
if !strings.Contains(message, want) {
|
||||||
|
t.Errorf("批量结果提示 %q 缺少 %q", message, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateShopeePddCollectTasksForUser_已采集和正常采集中分别跳过(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
admin, _, _ := insertTaskUsers(t, db)
|
||||||
|
seedShopeeProduct(t, db, "S-COLLECTED", "已采集")
|
||||||
|
seedShopeeProduct(t, db, "S-COLLECTING", "采集中")
|
||||||
|
AssociateShopeePdd(db, "S-COLLECTED", pddURLA, false)
|
||||||
|
AssociateShopeePdd(db, "S-COLLECTING", pddURLB, false)
|
||||||
|
if err := repository.SetCollectResult(db, "737116531267", "商品", "店铺", collectedThreeDimensions); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result, err := CreatePddCollectTasksForUser(db, admin, []string{"937122477375"}, ""); err != nil || result.Created != 1 {
|
||||||
|
t.Fatalf("准备采集中任务失败: result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := CreateShopeePddCollectTasksForUser(db, admin,
|
||||||
|
[]string{"S-COLLECTED", "S-COLLECTING"}, "")
|
||||||
|
if err != nil || result.Created != 0 || result.SkippedCollected != 1 || result.SkippedCollecting != 1 {
|
||||||
|
t.Fatalf("状态分类错误: result=%+v err=%v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateShopeePddCollectTasksForUser_拒绝不可见客户端且整批不创建(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db)
|
||||||
|
if err := RegisterClient(db, model.Client{ClientID: "client-batch-b", Name: "B 的客户端"}, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := AssignClient(db, admin, "client-batch-b", buyerB.UserID, time.Now()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seedShopeeProduct(t, db, "S-PERM", "权限商品")
|
||||||
|
AssociateShopeePdd(db, "S-PERM", pddURLA, false)
|
||||||
|
|
||||||
|
_, err := CreateShopeePddCollectTasksForUser(db, buyerA, []string{"S-PERM"}, "client-batch-b")
|
||||||
|
if !IsValidationError(err) {
|
||||||
|
t.Fatalf("伪造他人客户端应返回校验错误,实际 %v", err)
|
||||||
|
}
|
||||||
|
var taskCount int
|
||||||
|
db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_type='collect'`).Scan(&taskCount)
|
||||||
|
if taskCount != 0 {
|
||||||
|
t.Fatalf("权限失败后不应创建任务,实际 %d", taskCount)
|
||||||
|
}
|
||||||
|
product, _ := repository.GetPddProductByGoodsID(db, "737116531267")
|
||||||
|
if product.CollectStatus != model.CollectPending {
|
||||||
|
t.Fatalf("权限失败后商品状态应保持 pending,实际 %s", product.CollectStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateSybPddCollectTasksForUser_同一PDD商品去重(t *testing.T) {
|
func TestCreateSybPddCollectTasksForUser_同一PDD商品去重(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
admin, _, _ := insertTaskUsers(t, db)
|
admin, _, _ := insertTaskUsers(t, db)
|
||||||
|
|||||||
@@ -4,6 +4,16 @@
|
|||||||
{{/* ── 第一段:顶部工具条 ────────────────────────────── */}}
|
{{/* ── 第一段:顶部工具条 ────────────────────────────── */}}
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
{{if .CurrentUser.IsAdmin}}<a class="button" href="/integrations/catalog">导入记录</a>{{end}}
|
{{if .CurrentUser.IsAdmin}}<a class="button" href="/integrations/catalog">导入记录</a>{{end}}
|
||||||
|
<form id="shopee-collect-form" method="post" action="/shopee/collect-batch" hidden></form>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" form="shopee-collect-form">
|
||||||
|
<input type="hidden" name="list_goods_id" value="{{.Keyword}}" form="shopee-collect-form">
|
||||||
|
<input type="hidden" name="list_shop_name" value="{{.ShopNameKeyword}}" form="shopee-collect-form">
|
||||||
|
<input type="hidden" name="status" value="{{.StatusFilter}}" form="shopee-collect-form">
|
||||||
|
<input type="hidden" name="shop" value="{{.ShopFilter}}" form="shopee-collect-form">
|
||||||
|
<input type="hidden" name="image" value="{{.ImageFilter}}" form="shopee-collect-form">
|
||||||
|
<input type="hidden" name="page" value="{{.CurrentPage}}" form="shopee-collect-form">
|
||||||
|
<button type="button" data-modal-open="shopee-collect-modal" data-collect-open
|
||||||
|
data-collect-form="shopee-collect-form" data-need-checked>创建 PDD 采集任务</button>
|
||||||
<form class="inline grow" method="get" action="/shopee">
|
<form class="inline grow" method="get" action="/shopee">
|
||||||
{{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花,
|
{{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花,
|
||||||
见 docs/admin/05-ui-specification.md §4.1、工单 #43。
|
见 docs/admin/05-ui-specification.md §4.1、工单 #43。
|
||||||
@@ -77,7 +87,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range .Rows}}
|
{{range .Rows}}
|
||||||
<tr data-detail-id="{{.GoodsID}}"{{if gt .PendingCount 0}} class="row-warn"{{end}}>
|
<tr data-detail-id="{{.GoodsID}}"{{if gt .PendingCount 0}} class="row-warn"{{end}}>
|
||||||
<td class="col-check"><input type="checkbox" name="ids" value="{{.GoodsID}}"></td>
|
<td class="col-check"><input type="checkbox" name="ids" value="{{.GoodsID}}" form="shopee-collect-form"></td>
|
||||||
<td>{{.GoodsID}}</td>
|
<td>{{.GoodsID}}</td>
|
||||||
<td>{{if .ImageURL}}<button type="button" class="thumb-action" data-image-preview-url="{{.ImageURL}}" data-image-preview-title="{{.Title}}" title="查看蝦皮主图"><img src="{{.ImageURL}}" alt="{{.Title}} 缩略图" class="thumb" width="32" height="32" loading="lazy" referrerpolicy="no-referrer" data-image-thumb><span class="thumb-fallback" hidden data-image-thumb-fallback>加载失败</span></button>{{else}}<span class="thumb-placeholder">无图</span>{{end}}</td>
|
<td>{{if .ImageURL}}<button type="button" class="thumb-action" data-image-preview-url="{{.ImageURL}}" data-image-preview-title="{{.Title}}" title="查看蝦皮主图"><img src="{{.ImageURL}}" alt="{{.Title}} 缩略图" class="thumb" width="32" height="32" loading="lazy" referrerpolicy="no-referrer" data-image-thumb><span class="thumb-fallback" hidden data-image-thumb-fallback>加载失败</span></button>{{else}}<span class="thumb-placeholder">无图</span>{{end}}</td>
|
||||||
{{/* 商品名称列收窄,用独立的 .col-title 类设 max-width(具体数值见 app.css),
|
{{/* 商品名称列收窄,用独立的 .col-title 类设 max-width(具体数值见 app.css),
|
||||||
@@ -111,6 +121,32 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="shopee-collect-modal" hidden>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="shopee-collect-modal-title">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2 id="shopee-collect-modal-title">创建 PDD 采集任务</h2>
|
||||||
|
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>将按当前 PDD 关联处理已选择的 <strong data-collect-count>0</strong> 个蝦皮商品。</p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="shopee-collect-client">执行客户端</label>
|
||||||
|
<select id="shopee-collect-client" name="client_id" form="shopee-collect-form" autofocus>
|
||||||
|
<option value="">不指定(任意客户端可领取)</option>
|
||||||
|
{{range .AssignableClients}}
|
||||||
|
<option value="{{.ClientID}}">{{.Name}}({{.ClientID}},{{.Status}})</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="hint">相同 PDD 商品只创建一个任务;未关联、已删除、已采集和正在采集的商品会按原因统计并跳过。</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button type="button" data-modal-close>取消</button>
|
||||||
|
<button type="submit" form="shopee-collect-form" class="primary" data-collect-submit>确认创建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-backdrop" id="image-preview-modal" hidden><div class="modal image-preview-modal" role="dialog" aria-modal="true" aria-labelledby="image-preview-title"><div class="modal-head"><h2 id="image-preview-title" data-image-preview-title>蝦皮商品主图</h2><button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button></div><div class="modal-body image-preview-body"><p class="hint image-preview-status" data-image-preview-status role="status">正在加载原图…</p><img data-image-preview-image alt="" hidden></div><div class="modal-foot"><a class="button-link" data-image-preview-link href="#" target="_blank" rel="noopener noreferrer">新窗口打开原图</a><button type="button" data-modal-close>关闭</button></div></div></div>
|
<div class="modal-backdrop" id="image-preview-modal" hidden><div class="modal image-preview-modal" role="dialog" aria-modal="true" aria-labelledby="image-preview-title"><div class="modal-head"><h2 id="image-preview-title" data-image-preview-title>蝦皮商品主图</h2><button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button></div><div class="modal-body image-preview-body"><p class="hint image-preview-status" data-image-preview-status role="status">正在加载原图…</p><img data-image-preview-image alt="" hidden></div><div class="modal-foot"><a class="button-link" data-image-preview-link href="#" target="_blank" rel="noopener noreferrer">新窗口打开原图</a><button type="button" data-modal-close>关闭</button></div></div></div>
|
||||||
|
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
|||||||
|
|
||||||
### 4.1 蝦皮数据模块
|
### 4.1 蝦皮数据模块
|
||||||
|
|
||||||
**顶部工具条:** 导入按钮、状态筛选(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、
|
**顶部工具条:** 导入按钮、批量创建 PDD 采集任务按钮、状态筛选
|
||||||
商品 ID 搜索框、搜索按钮、删除按钮。采集从商品详情中发起,避免脱离商品上下文。
|
(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、商品 ID 搜索框、搜索按钮、删除按钮。
|
||||||
|
|
||||||
`[必须]` 状态筛选是**刚需**,不是锦上添花(工单 #43):本页的主要用途是维护
|
`[必须]` 状态筛选是**刚需**,不是锦上添花(工单 #43):本页的主要用途是维护
|
||||||
(找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID 搜索的话,
|
(找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID 搜索的话,
|
||||||
@@ -100,7 +100,7 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
|||||||
|
|
||||||
| 列 | 说明 |
|
| 列 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 勾选 | 支持批量删除 |
|
| 勾选 | 支持批量删除、批量创建 PDD 采集任务 |
|
||||||
| 商品 ID | 蝦皮商品编号 |
|
| 商品 ID | 蝦皮商品编号 |
|
||||||
| 商品名称 | |
|
| 商品名称 | |
|
||||||
| 颜色 / 尺码 / 建议 | 从规格原文解析,解析不出来留空并标记 |
|
| 颜色 / 尺码 / 建议 | 从规格原文解析,解析不出来留空并标记 |
|
||||||
@@ -114,7 +114,11 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
|||||||
- 保存链接时从完整链接解析 `goods_id`,在同一事务内复用或复活 PDD 商品档案并更新当前关联;
|
- 保存链接时从完整链接解析 `goods_id`,在同一事务内复用或复活 PDD 商品档案并更新当前关联;
|
||||||
- 更换成不同 `goods_id` 时必须明确勾选确认,旧规格映射保留但不会被当前关联查询误用;
|
- 更换成不同 `goods_id` 时必须明确勾选确认,旧规格映射保留但不会被当前关联查询误用;
|
||||||
- 弹窗内提供【创建采集任务】按钮,复用 PDD 商品模块的幂等与超时重试规则;
|
- 弹窗内提供【创建采集任务】按钮,复用 PDD 商品模块的幂等与超时重试规则;
|
||||||
- `[必须]` 采集按钮**在本页只出现在弹窗里**,不要放在每个表格行——
|
- 蝦皮列表支持勾选商品后批量创建 PDD 采集任务。系统按蝦皮商品当前关联取得
|
||||||
|
PDD 商品并按 PDD 商品 ID 去重,可选当前账号可见客户端;未关联、PDD 已删除、
|
||||||
|
已采集和正常采集中的商品按原因统计并跳过。采集失败、采集超时或采集中但已无
|
||||||
|
有效任务的商品可以恢复创建。该入口不修改 PDD 关联,不替代商品详情里的单商品入口;
|
||||||
|
- `[必须]` 采集操作**只出现在顶部批量入口和商品详情弹窗里**,不要放在每个表格行——
|
||||||
一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。
|
一个商品有 N 个 SKU 行,放行上就是 N 个按钮干同一件事,还会建出 N 个重复任务。
|
||||||
|
|
||||||
`[注意]` PDD 商品模块(§4.2)继续作为商品档案、采集状态和故障诊断页;
|
`[注意]` PDD 商品模块(§4.2)继续作为商品档案、采集状态和故障诊断页;
|
||||||
@@ -527,7 +531,7 @@ MVP 之后:
|
|||||||
- PDD 链接**人工填写**,不自动抓取。
|
- PDD 链接**人工填写**,不自动抓取。
|
||||||
- PDD 商品是**独立模块**,创建商品和创建采集任务都在那里;
|
- PDD 商品是**独立模块**,创建商品和创建采集任务都在那里;
|
||||||
蝦皮数据模块的编辑弹窗负责的是「这个蝦皮商品对应哪个 PDD 商品」。
|
蝦皮数据模块的编辑弹窗负责的是「这个蝦皮商品对应哪个 PDD 商品」。
|
||||||
- **采集任务不指定客户端**,谁领到算谁的;采购任务仍可分配,也允许留空。
|
- 采集任务和采购任务都允许指定当前账号可见的客户端,也允许留空;留空时由符合条件的客户端领取。
|
||||||
- 任务**分配给指定客户端**,Client 只领分给自己的。
|
- 任务**分配给指定客户端**,Client 只领分给自己的。
|
||||||
- **不加心跳接口**;设置页显式登记,领取接口保留兼容登记,在线状态由最近活动时间派生。
|
- **不加心跳接口**;设置页显式登记,领取接口保留兼容登记,在线状态由最近活动时间派生。
|
||||||
- 规格映射**独立成表且可复用**,按 `(蝦皮商品 ID, spec_key, PDD 商品 ID)` 隔离;换 PDD 商品不会误用旧映射,换回时可继续复用。
|
- 规格映射**独立成表且可复用**,按 `(蝦皮商品 ID, spec_key, PDD 商品 ID)` 隔离;换 PDD 商品不会误用旧映射,换回时可继续复用。
|
||||||
|
|||||||
@@ -136,7 +136,7 @@
|
|||||||
### 4.1 工具条
|
### 4.1 工具条
|
||||||
|
|
||||||
```text
|
```text
|
||||||
[导入记录] 状态[全部▾] 店铺[全部▾] 图片[全部▾] 商品 ID / 名称 [____] 店铺名称 [____] [搜索] [删除]
|
[导入记录] [创建 PDD 采集任务] 状态[全部▾] 店铺[全部▾] 图片[全部▾] 商品 ID / 名称 [____] 店铺名称 [____] [搜索] [删除]
|
||||||
```
|
```
|
||||||
|
|
||||||
- **导入记录**:管理员进入统一商品目录导入记录页;采购员无系统级审计入口。
|
- **导入记录**:管理员进入统一商品目录导入记录页;采购员无系统级审计入口。
|
||||||
@@ -166,6 +166,9 @@
|
|||||||
- `[必须]` 店铺、图片使用独立三态下拉框,不使用含义不清的复选框;两个条件可互相
|
- `[必须]` 店铺、图片使用独立三态下拉框,不使用含义不清的复选框;两个条件可互相
|
||||||
组合,也可与状态和商品 ID/标题搜索叠加。未知参数按“全部”处理,翻页、详情弹窗
|
组合,也可与状态和商品 ID/标题搜索叠加。未知参数按“全部”处理,翻页、详情弹窗
|
||||||
和写操作返回时保留全部条件。工具条在窄窗口沿用公共 `flex-wrap` 自然换行。
|
和写操作返回时保留全部条件。工具条在窄窗口沿用公共 `flex-wrap` 自然换行。
|
||||||
|
- **创建 PDD 采集任务**(工单 #185):勾选蝦皮商品后打开批量确认弹窗,显示选择
|
||||||
|
数量及带可见标签的“执行客户端”,默认不指定。服务端按当前 PDD 关联和 PDD 商品 ID
|
||||||
|
去重;未关联、已删除、已采集、正常采集中分别统计并跳过。商品详情的单商品入口保留。
|
||||||
|
|
||||||
### 4.2 表格列
|
### 4.2 表格列
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user