feat(#31): implement PDD product archive module

This commit is contained in:
QiuSW
2026-08-17 16:04:18 +08:00
parent 7df4546e63
commit a7a453633e
16 changed files with 648 additions and 188 deletions
+16
View File
@@ -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"`
+31 -12
View File
@@ -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) {
+1
View File
@@ -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)
}
+166 -29
View File
@@ -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}
}
+29 -1
View File
@@ -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)
+165
View File
@@ -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
})
}