feat(#31): implement PDD product archive module
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user