feat(goauto): 完善替代采集与颜色图片详情 (#184 #185)

This commit is contained in:
QiuSW
2026-09-01 09:55:27 +08:00
parent c07c3451f1
commit 3aab1f013d
13 changed files with 537 additions and 41 deletions
+23 -9
View File
@@ -12,8 +12,7 @@ import (
const (
CodeOriginNotEligible = "REPLACEMENT_ORIGIN_NOT_ELIGIBLE"
ErrorPDDLinkInvalid = "PDD_LINK_INVALID"
ErrorPDDGoodsSoldOut = "PDD_GOODS_SOLD_OUT"
collectionPurchaseInProgressReason = "该商品有采购任务正在执行或结果待核对,暂不能替换,请稍后重试"
)
type OriginInspection struct {
@@ -32,8 +31,8 @@ func (service *Service) InspectOrigin(ctx context.Context, originType string, ta
return OriginInspection{}, fail(CodeInvalidRequest, "替换来源无效")
}
result := OriginInspection{}
var status string
var errorCode *string
var purchaseStatus string
var purchaseErrorCode *string
switch originType {
case models.ReplacementOriginCollection:
var task models.CollectionTask
@@ -45,7 +44,6 @@ func (service *Service) InspectOrigin(ctx context.Context, originType string, ta
if task.PDDProductID != nil {
result.SourceProductID = *task.PDDProductID
}
status, errorCode = task.Status, task.ErrorCode
case models.ReplacementOriginPurchase:
var task models.PurchaseTask
if err := service.DB.WithContext(ctx).Where("id = ? AND device_id = ?", taskID, deviceID).First(&task).Error; errors.Is(err, gorm.ErrRecordNotFound) {
@@ -61,7 +59,7 @@ func (service *Service) InspectOrigin(ctx context.Context, originType string, ta
if task.ShopeeProductID != nil {
result.ShopeeProductID = *task.ShopeeProductID
}
status, errorCode = task.Status, task.ErrorCode
purchaseStatus, purchaseErrorCode = task.Status, task.ErrorCode
}
if result.SourceProductID == 0 {
result.DisabledReason = "原任务没有可替换的拼多多商品"
@@ -101,12 +99,28 @@ func (service *Service) InspectOrigin(ctx context.Context, originType string, ta
}
result.CorrectionReplacementID = correctionID
}
failed := status == models.TaskStatusFailed || status == models.PurchaseTaskStatusFailed
if !failed {
if originType == models.ReplacementOriginCollection {
var count int64
if err := service.DB.WithContext(ctx).Model(&models.PurchaseTask{}).
Where("pdd_product_id = ? AND status IN ?", result.SourceProductID, []string{
models.PurchaseTaskStatusRunning,
models.PurchaseTaskStatusOrderSubmitStarted,
models.PurchaseTaskStatusOrderResultUnknown,
}).Count(&count).Error; err != nil {
return OriginInspection{}, internal(err)
}
if count > 0 {
result.DisabledReason = collectionPurchaseInProgressReason
return result, nil
}
result.Eligible = true
return result, nil
}
if purchaseStatus != models.PurchaseTaskStatusFailed {
result.DisabledReason = "只有失败任务可以替换商品"
return result, nil
}
if errorCode == nil || (*errorCode != ErrorPDDLinkInvalid && *errorCode != ErrorPDDGoodsSoldOut) {
if purchaseErrorCode == nil || (*purchaseErrorCode != "PDD_LINK_INVALID" && *purchaseErrorCode != "PDD_GOODS_SOLD_OUT") {
result.DisabledReason = "当前失败原因不属于商品失效或售罄"
return result, nil
}
+94 -12
View File
@@ -99,10 +99,11 @@ func replacementCode(err error) string {
return ""
}
func TestInspectOriginUsesExactFailureCodesAndDeviceBoundary(t *testing.T) {
func TestInspectCollectionOriginAllowsAnyTaskStatusAndFailureCode(t *testing.T) {
fixture := seedReplacementFixture(t)
linkInvalid := ErrorPDDLinkInvalid
if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", fixture.origin.ID).Update("error_code", linkInvalid).Error; err != nil {
genericFailure := "RULE_NOT_MATCHED"
if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", fixture.origin.ID).
Updates(map[string]any{"status": models.TaskStatusCompleted, "error_code": genericFailure}).Error; err != nil {
t.Fatal(err)
}
@@ -111,15 +112,6 @@ func TestInspectOriginUsesExactFailureCodesAndDeviceBoundary(t *testing.T) {
t.Fatalf("eligible collection origin: inspection=%+v error=%v", inspection, err)
}
notExact := "PDD_LINK_INVALID_RETRY"
if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", fixture.origin.ID).Update("error_code", notExact).Error; err != nil {
t.Fatal(err)
}
inspection, err = fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginCollection, fixture.origin.ID, fixture.device.ID)
if err != nil || inspection.Eligible || inspection.DisabledReason == "" {
t.Fatalf("non-exact code must be disabled: inspection=%+v error=%v", inspection, err)
}
otherDevice := fixture.device
otherDevice.ID = 0
otherDevice.InstallID = uuid.NewString()
@@ -133,6 +125,96 @@ func TestInspectOriginUsesExactFailureCodesAndDeviceBoundary(t *testing.T) {
}
}
func TestInspectCollectionOriginBlocksPurchasesInProgress(t *testing.T) {
blockedStatuses := []string{
models.PurchaseTaskStatusRunning,
models.PurchaseTaskStatusOrderSubmitStarted,
models.PurchaseTaskStatusOrderResultUnknown,
}
for _, status := range blockedStatuses {
t.Run(status, func(t *testing.T) {
fixture := seedReplacementFixture(t)
shopee := models.ShopeeProduct{ShopeeItemID: "SP-BLOCKED", PDDProductID: &fixture.source.ID, SpecsJSON: "[]", Currency: "CNY"}
if err := fixture.db.Create(&shopee).Error; err != nil {
t.Fatal(err)
}
syb := models.SYBProduct{OrderCode: "SYB-BLOCKED", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, Quantity: 1, UnitPriceCent: 100, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`}
if err := fixture.db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
purchase := models.PurchaseTask{
SYBProductID: &syb.ID, ShopeeProductID: &shopee.ID, PDDProductID: fixture.source.ID, DeviceID: &fixture.device.ID,
ExecutionMode: models.PurchaseExecutionModeLive, Status: status,
ShopeeItemIDSnapshot: "SP-BLOCKED", PDDURLSnapshot: fixture.source.URL, PDDGoodsIDSnapshot: fixture.source.GoodsID,
SpecDecisionSnapshot: `{}`, Quantity: 1, Currency: "CNY", RuleType: "pddPurchase", RuleSchemaVersion: 1,
RequiredCapabilitiesJSON: `[]`, RuleSnapshot: `{}`, CreateRequestID: uuid.NewString(),
PaymentReviewStatus: models.PurchasePaymentReviewPending, LogisticsStatus: models.PurchaseLogisticsStatusPending,
WritebackStatus: models.PurchaseWritebackStatusNotSelected,
}
if err := fixture.db.Create(&purchase).Error; err != nil {
t.Fatal(err)
}
inspection, err := fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginCollection, fixture.origin.ID, fixture.device.ID)
if err != nil || inspection.Eligible || inspection.DisabledReason != collectionPurchaseInProgressReason {
t.Fatalf("status=%s inspection=%+v error=%v", status, inspection, err)
}
if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&purchase).Update("status", models.PurchaseTaskStatusFailed).Error; err != nil {
t.Fatal(err)
}
inspection, err = fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginCollection, fixture.origin.ID, fixture.device.ID)
if err != nil || !inspection.Eligible {
t.Fatalf("terminal purchase must restore eligibility: inspection=%+v error=%v", inspection, err)
}
})
}
}
func TestInspectPurchaseOriginKeepsFailureStatusAndCodeRequirements(t *testing.T) {
fixture := seedReplacementFixture(t)
shopee := models.ShopeeProduct{ShopeeItemID: "SP-PURCHASE", PDDProductID: &fixture.source.ID, SpecsJSON: "[]", Currency: "CNY"}
if err := fixture.db.Create(&shopee).Error; err != nil {
t.Fatal(err)
}
syb := models.SYBProduct{OrderCode: "SYB-PURCHASE", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, Quantity: 1, UnitPriceCent: 100, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`}
if err := fixture.db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
errorCode := "PDD_LINK_INVALID"
purchase := models.PurchaseTask{
SYBProductID: &syb.ID, ShopeeProductID: &shopee.ID, PDDProductID: fixture.source.ID, DeviceID: &fixture.device.ID,
ExecutionMode: models.PurchaseExecutionModeLive, Status: models.PurchaseTaskStatusFailed, ErrorCode: &errorCode,
ShopeeItemIDSnapshot: shopee.ShopeeItemID, PDDURLSnapshot: fixture.source.URL, PDDGoodsIDSnapshot: fixture.source.GoodsID,
SpecDecisionSnapshot: `{}`, Quantity: 1, Currency: "CNY", RuleType: "pddPurchase", RuleSchemaVersion: 1,
RequiredCapabilitiesJSON: `[]`, RuleSnapshot: `{}`, CreateRequestID: uuid.NewString(),
PaymentReviewStatus: models.PurchasePaymentReviewPending, LogisticsStatus: models.PurchaseLogisticsStatusPending,
WritebackStatus: models.PurchaseWritebackStatusNotSelected,
}
if err := fixture.db.Create(&purchase).Error; err != nil {
t.Fatal(err)
}
inspection, err := fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginPurchase, purchase.ID, fixture.device.ID)
if err != nil || !inspection.Eligible {
t.Fatalf("eligible purchase origin: inspection=%+v error=%v", inspection, err)
}
if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&purchase).Updates(map[string]any{"error_code": "RULE_NOT_MATCHED"}).Error; err != nil {
t.Fatal(err)
}
inspection, err = fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginPurchase, purchase.ID, fixture.device.ID)
if err != nil || inspection.Eligible || inspection.DisabledReason != "当前失败原因不属于商品失效或售罄" {
t.Fatalf("purchase failure code boundary changed: inspection=%+v error=%v", inspection, err)
}
if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&purchase).Updates(map[string]any{"status": models.PurchaseTaskStatusOrderCreated, "error_code": errorCode}).Error; err != nil {
t.Fatal(err)
}
inspection, err = fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginPurchase, purchase.ID, fixture.device.ID)
if err != nil || inspection.Eligible || inspection.DisabledReason != "只有失败任务可以替换商品" {
t.Fatalf("purchase status boundary changed: inspection=%+v error=%v", inspection, err)
}
}
func TestRegisterAndQueryCollectionOrigin(t *testing.T) {
fixture := seedReplacementFixture(t)
request := fixture.request()
+17 -1
View File
@@ -54,6 +54,7 @@ type AgentCollectionDetail struct {
ReviewCount *int64 `json:"reviewCount,omitempty"`
Dimensions []DetailDimension `json:"dimensions"`
ColorPrices []AgentColorPrice `json:"colorPrices"`
ColorImages []AgentColorImage `json:"colorImages"`
SKUs []AgentCollectionSKU `json:"skus"`
Missing []string `json:"missing"`
ReplacementEligible bool `json:"replacementEligible"`
@@ -79,6 +80,13 @@ type AgentColorPrice struct {
PriceCent int64 `json:"priceCent"`
}
type AgentColorImage struct {
Color string `json:"color"`
ImagePath string `json:"imagePath"`
Width int `json:"width"`
Height int `json:"height"`
}
type AgentCollectionSKU struct {
Specs map[string]string `json:"specs"`
PriceCent int64 `json:"priceCent"`
@@ -163,6 +171,14 @@ func (service *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, t
for _, value := range detail.ColorPrices {
colorPrices = append(colorPrices, AgentColorPrice{Color: value.Color, PriceCent: value.PriceCent})
}
var imageRows []models.PDDProductColorImage
if err := service.DB.WithContext(ctx).Where("source_task_id = ?", taskID).Order("color ASC").Find(&imageRows).Error; err != nil {
return AgentCollectionDetail{}, internalError(err)
}
colorImages := make([]AgentColorImage, 0, len(imageRows))
for _, value := range imageRows {
colorImages = append(colorImages, AgentColorImage{Color: value.Color, ImagePath: value.ImagePath, Width: value.Width, Height: value.Height})
}
skus := make([]AgentCollectionSKU, 0, len(detail.SKUs))
for _, value := range detail.SKUs {
skus = append(skus, AgentCollectionSKU{Specs: value.Specs, PriceCent: value.PriceCent, Available: value.Available, Complete: value.Complete})
@@ -177,7 +193,7 @@ func (service *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, t
}
return AgentCollectionDetail{
Task: agentCollectionItem(record), Attempts: attempts, ShopName: record.ShopName, SalesText: record.SalesText,
ReviewCount: record.ReviewCount, Dimensions: detail.Dimensions, ColorPrices: colorPrices,
ReviewCount: record.ReviewCount, Dimensions: detail.Dimensions, ColorPrices: colorPrices, ColorImages: colorImages,
SKUs: skus, Missing: detail.Missing,
ReplacementEligible: inspection.Eligible, ReplacementDisabledReason: inspection.DisabledReason,
ReplacementMappingStatus: inspection.MappingStatus, ReplacementActivationStatus: inspection.ActivationStatus,
@@ -95,3 +95,40 @@ func TestAgentHistoryDetailReturnsArrayForLegacyNullMissing(t *testing.T) {
t.Fatalf("history detail did not preserve array contract: %s %v", raw, err)
}
}
func TestAgentHistoryDetailReturnsOnlyImagesOwnedByTask(t *testing.T) {
db := openTaskDatabase(t)
deviceRecord, token := registerTaskDevice(t, db, "history-color-images")
taskA := createTask(t, db, &deviceRecord.ID)
taskB := createTask(t, db, &deviceRecord.ID)
if taskA.PDDProductID == nil || taskB.PDDProductID == nil {
t.Fatal("collection tasks must reference products")
}
images := []models.PDDProductColorImage{
{PDDProductID: *taskA.PDDProductID, Color: "红色", ImagePath: "/static/uploadfile/goauto-color/red.jpg", ContentType: "image/jpeg", ByteSize: 120, Width: 300, Height: 300, SourceTaskID: taskA.ID, SourceDeviceID: deviceRecord.ID},
{PDDProductID: *taskB.PDDProductID, Color: "蓝色", ImagePath: "/static/uploadfile/goauto-color/blue.jpg", ContentType: "image/jpeg", ByteSize: 140, Width: 320, Height: 280, SourceTaskID: taskB.ID, SourceDeviceID: deviceRecord.ID},
}
if err := db.Create(&images).Error; err != nil {
t.Fatal(err)
}
detail, err := newTaskService(db).AgentHistoryDetail(context.Background(), taskA.ID, token)
if err != nil {
t.Fatal(err)
}
if len(detail.ColorImages) != 1 || detail.ColorImages[0].Color != "红色" || detail.ColorImages[0].ImagePath != images[0].ImagePath {
t.Fatalf("unexpected task images: %#v", detail.ColorImages)
}
if err := db.Model(&images[0]).Update("source_task_id", taskB.ID).Error; err != nil {
t.Fatal(err)
}
detail, err = newTaskService(db).AgentHistoryDetail(context.Background(), taskA.ID, token)
if err != nil || detail.ColorImages == nil || len(detail.ColorImages) != 0 {
t.Fatalf("overwritten image must disappear from old task: %#v %v", detail.ColorImages, err)
}
raw, err := json.Marshal(detail)
if err != nil || !strings.Contains(string(raw), `"colorImages":[]`) {
t.Fatalf("empty image array contract changed: %s %v", raw, err)
}
}