Files
goauto/server/app/goauto/purchase/batch.go
T
QiuSWandClaude Opus 5 08d77bd982 fix(server): 规格匹配的 AI 调用重试一次,并区分无匹配与服务不可用 (#295)
AI 对同一输入会给出不同答案(deepseek-v4-flash 在 Temperature=0 下仍如此:
紫色/S 第一次答「未找到可靠的 PDD 规格」,第二次答「唯一匹配」),而匹配一个值
只调一次、失败即放弃,于是本该匹上的值因一次抽风永久留空。

- resolveSpecMatch 重试一次,上限 2 次调用。真失败(PDD 没有该颜色、白色有歧义)
  每次都会失败,重试更多只是浪费调用——实测连续三轮失败数稳定在 12。
- 区分 AI 明确「无匹配」与服务不可用,两者处置不同。此前都记成 failed,排查时
  分不开(#294 的遗留问题)。
- BatchSpecMatch 与 MatchArchiveSpecs 两条路径都接上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-16 14:59:16 +08:00

544 lines
22 KiB
Go

package purchase
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"go-admin/app/goauto/productspec"
"go-admin/app/goauto/purchasecontract"
"go-admin/app/goauto/purchaserule"
"github.com/google/uuid"
"gorm.io/gorm"
)
const maxBatchPurchaseItems = 100
type BatchPreviewRequest struct {
SYBProductIDs []uint64 `json:"sybProductIds"`
DeviceID *uint64 `json:"deviceId,omitempty"`
}
type BatchCreateRequest struct {
RequestID string `json:"requestId"`
SYBProductIDs []uint64 `json:"sybProductIds"`
DeviceID *uint64 `json:"deviceId,omitempty"`
}
type BatchPreviewItem struct {
SYBProductID uint64 `json:"sybProductId"`
OrderCode string `json:"orderCode,omitempty"`
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
ShopeeItemID string `json:"shopeeItemId,omitempty"`
ProductTitle string `json:"productTitle,omitempty"`
PDDProductID *uint64 `json:"pddProductId,omitempty"`
PDDGoodsID string `json:"pddGoodsId,omitempty"`
PDDTitle string `json:"pddTitle,omitempty"`
ImageSearchLinked bool `json:"imageSearchLinked,omitempty"`
TargetColor string `json:"targetColor,omitempty"`
TargetSize string `json:"targetSize,omitempty"`
MappedColor string `json:"mappedColor,omitempty"`
MappedSize string `json:"mappedSize,omitempty"`
Quantity int64 `json:"quantity,omitempty"`
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent,omitempty"`
MinUnitPriceCent int64 `json:"minUnitPriceCent,omitempty"`
MaxUnitPriceCent int64 `json:"maxUnitPriceCent,omitempty"`
Currency string `json:"currency,omitempty"`
Eligible bool `json:"eligible"`
ReasonCode string `json:"reasonCode,omitempty"`
Reason string `json:"reason,omitempty"`
NextAction string `json:"nextAction,omitempty"`
ActiveTaskID *uint64 `json:"activeTaskId,omitempty"`
ProcessStage string `json:"processStage,omitempty"`
ProcessStageLabel string `json:"processStageLabel,omitempty"`
ProcessStageReason string `json:"processStageReason,omitempty"`
ProcessNextAction string `json:"processNextAction,omitempty"`
CollectionEligible bool `json:"collectionEligible"`
CollectionDisabledReason string `json:"collectionDisabledReason,omitempty"`
AIMatchEligible bool `json:"aiMatchEligible"`
AIMatchDisabledReason string `json:"aiMatchDisabledReason,omitempty"`
}
type BatchPreviewResponse struct {
Items []BatchPreviewItem `json:"items"`
EligibleCount int `json:"eligibleCount"`
SkippedCount int `json:"skippedCount"`
}
type BatchCreateItem struct {
BatchPreviewItem
Created bool `json:"created"`
TaskID *uint64 `json:"taskId,omitempty"`
TaskNo string `json:"taskNo,omitempty"`
Replayed bool `json:"replayed,omitempty"`
}
type BatchCreateResponse struct {
Items []BatchCreateItem `json:"items"`
CreatedCount int `json:"createdCount"`
FailedCount int `json:"failedCount"`
}
func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (BatchPreviewResponse, error) {
ids, err := validateBatchIDs(req.SYBProductIDs)
if err != nil {
return BatchPreviewResponse{}, err
}
dataset, err := s.loadBatchPreviewDataset(ctx, ids)
if err != nil {
return BatchPreviewResponse{}, internal(err)
}
_, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
guard, ruleReason, err := readOnlyRuleResult(currentRule, ruleErr)
if err != nil {
return BatchPreviewResponse{}, err
}
var deviceErr error
if ruleReason == "" {
deviceErr = s.validateBatchDevice(ctx, req.DeviceID, currentRule)
}
items := make([]BatchPreviewItem, 0, len(ids))
eligible := 0
for _, id := range ids {
item := s.previewFromDataset(id, dataset, guard)
item.applyCollectionEligibility(id, dataset)
qualification := aiMatchQualificationForDataset(id, dataset)
item.AIMatchEligible = qualification.Eligible
item.AIMatchDisabledReason = qualification.DisabledReason
if ruleReason != "" && item.Eligible {
item.Eligible = false
item.ReasonCode, item.Reason, item.NextAction = purchaserule.CodeCurrentRuleMissing, ruleReason, "refresh"
}
item.applyProcessStage(processStageFromDataset(id, dataset, item))
if item.Eligible && deviceErr != nil {
item.Eligible = false
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(deviceErr)
}
if item.Eligible {
eligible++
}
items = append(items, item)
}
return BatchPreviewResponse{Items: items, EligibleCount: eligible, SkippedCount: len(items) - eligible}, nil
}
type batchPreviewDataset struct {
sybByID map[uint64]models.SYBProduct
shopeeByID map[uint64]models.ShopeeProduct
pddByID map[uint64]models.PDDProduct
latestTaskBySYB map[uint64]models.PurchaseTask
latestCollectionByPDD map[uint64]models.CollectionTask
activeCollectionByPDD map[uint64]uint64
collectionRuleAvailable bool
skuCombinationsByPDD map[uint64][]pddSKUCombination
// collapsedKeysByShopee 记录该虾皮商品下哪些 target_color / target_size
// 是不安全的键——多个不同的虾皮原始规格剥离 【...】 后塌缩到了同一个值。
// 见 #289 与 sybimport.RawSpecHalves。
collapsedColorByShopee map[uint64]map[string]bool
collapsedSizeByShopee map[uint64]map[string]bool
}
// loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries.
// Collection eligibility adds bounded active-task and rule queries; pending
// products additionally load their latest task for the user-facing stage.
// Creation still revalidates each item.
func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (batchPreviewDataset, error) {
dataset := batchPreviewDataset{
sybByID: make(map[uint64]models.SYBProduct, len(ids)),
shopeeByID: make(map[uint64]models.ShopeeProduct),
pddByID: make(map[uint64]models.PDDProduct),
latestTaskBySYB: make(map[uint64]models.PurchaseTask),
latestCollectionByPDD: make(map[uint64]models.CollectionTask),
activeCollectionByPDD: make(map[uint64]uint64),
skuCombinationsByPDD: make(map[uint64][]pddSKUCombination),
collapsedColorByShopee: make(map[uint64]map[string]bool),
collapsedSizeByShopee: make(map[uint64]map[string]bool),
}
var sybProducts []models.SYBProduct
if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil {
return dataset, err
}
shopeeIDs := make([]uint64, 0, len(sybProducts))
for _, syb := range sybProducts {
dataset.sybByID[syb.ID] = syb
if syb.ShopeeProductID != nil {
shopeeIDs = append(shopeeIDs, *syb.ShopeeProductID)
}
}
if len(shopeeIDs) > 0 {
if err := s.loadCollapsedSpecKeys(ctx, shopeeIDs, &dataset); err != nil {
return dataset, err
}
var shopeeProducts []models.ShopeeProduct
if err := s.DB.WithContext(ctx).Where("id IN ?", shopeeIDs).Find(&shopeeProducts).Error; err != nil {
return dataset, err
}
pddIDs := make([]uint64, 0, len(shopeeProducts))
for _, shopee := range shopeeProducts {
dataset.shopeeByID[shopee.ID] = shopee
if shopee.PDDProductID != nil {
pddIDs = append(pddIDs, *shopee.PDDProductID)
}
}
if len(pddIDs) > 0 {
var pddProducts []models.PDDProduct
if err := s.DB.WithContext(ctx).Where("id IN ?", pddIDs).Find(&pddProducts).Error; err != nil {
return dataset, err
}
for _, pdd := range pddProducts {
dataset.pddByID[pdd.ID] = pdd
}
if err := s.loadLatestSKUCombinations(ctx, pddIDs, &dataset); err != nil {
return dataset, err
}
var activeCollectionTasks []models.CollectionTask
if err := s.DB.WithContext(ctx).
Where("pdd_product_id IN ? AND status IN ?", pddIDs, []string{models.TaskStatusPending, models.TaskStatusRunning}).
Find(&activeCollectionTasks).Error; err != nil {
return dataset, err
}
for _, task := range activeCollectionTasks {
if task.PDDProductID != nil {
dataset.activeCollectionByPDD[*task.PDDProductID] = task.ID
}
}
var collectionRuleCount int64
if err := s.DB.WithContext(ctx).Model(&models.CollectionRule{}).Count(&collectionRuleCount).Error; err != nil {
return dataset, err
}
dataset.collectionRuleAvailable = collectionRuleCount > 0
pendingPDDIDs := make([]uint64, 0)
for _, pdd := range pddProducts {
if pdd.Status == "pending" {
pendingPDDIDs = append(pendingPDDIDs, pdd.ID)
}
}
if len(pendingPDDIDs) > 0 {
var collectionTasks []models.CollectionTask
if err := s.DB.WithContext(ctx).
Where("pdd_product_id IN ? AND id = (SELECT MAX(ct2.id) FROM collection_task ct2 WHERE ct2.pdd_product_id = collection_task.pdd_product_id AND ct2.deleted_at IS NULL)", pendingPDDIDs).
Find(&collectionTasks).Error; err != nil {
return dataset, err
}
for _, task := range collectionTasks {
if task.PDDProductID != nil {
dataset.latestCollectionByPDD[*task.PDDProductID] = task
}
}
}
}
}
var tasks []models.PurchaseTask
if err := s.DB.WithContext(ctx).
Where("syb_product_id IN ? AND id = (SELECT MAX(pt2.id) FROM purchase_task pt2 WHERE pt2.syb_product_id = purchase_task.syb_product_id)", ids).
Find(&tasks).Error; err != nil {
return dataset, err
}
for _, task := range tasks {
if task.SYBProductID == nil {
continue
}
if _, exists := dataset.latestTaskBySYB[*task.SYBProductID]; !exists {
dataset.latestTaskBySYB[*task.SYBProductID] = task
}
}
return dataset, nil
}
func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (BatchCreateResponse, error) {
if _, err := uuid.Parse(strings.TrimSpace(req.RequestID)); err != nil {
return BatchCreateResponse{}, fail(CodeInvalidRequest, "requestId 无效")
}
ids, err := validateBatchIDs(req.SYBProductIDs)
if err != nil {
return BatchCreateResponse{}, err
}
_, rawRule, currentRule, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
if err != nil {
return BatchCreateResponse{}, err
}
deviceErr := s.validateBatchDevice(ctx, req.DeviceID, currentRule)
result := BatchCreateResponse{Items: make([]BatchCreateItem, 0, len(ids))}
for _, id := range ids {
itemRequestID := batchItemRequestID(req.RequestID, id)
var replay models.PurchaseTask
if err := s.DB.WithContext(ctx).Where("create_request_id = ?", itemRequestID).First(&replay).Error; err == nil {
taskID := replay.ID
preview := replayBatchPreview(replay)
preview.Eligible, preview.ReasonCode, preview.Reason, preview.NextAction, preview.ActiveTaskID = true, "", "", "", nil
result.Items = append(result.Items, BatchCreateItem{BatchPreviewItem: preview, Created: true, TaskID: &taskID, TaskNo: taskNumber(taskID), Replayed: true})
result.CreatedCount++
continue
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return BatchCreateResponse{}, internal(err)
}
preview := s.previewOneDeterministic(ctx, id, purchasecontract.EffectivePriceGuard(currentRule))
if preview.Eligible && deviceErr != nil {
preview.Eligible = false
preview.ReasonCode, preview.Reason, preview.NextAction = serviceErrorFields(deviceErr)
}
if !preview.Eligible {
result.Items = append(result.Items, BatchCreateItem{BatchPreviewItem: preview})
result.FailedCount++
continue
}
sybID := id
task, replayed, createErr := s.Create(ctx, CreateRequest{
RequestID: itemRequestID, ExecutionMode: models.PurchaseExecutionModeLive,
SYBProductID: &sybID, DeviceID: req.DeviceID,
ReferenceUnitPriceCent: preview.ReferenceUnitPriceCent,
MinUnitPriceCent: preview.MinUnitPriceCent, MaxUnitPriceCent: preview.MaxUnitPriceCent,
Currency: preview.Currency, RuleSnapshot: rawRule,
})
if createErr != nil {
preview.Eligible = false
preview.ReasonCode, preview.Reason, preview.NextAction = serviceErrorFields(createErr)
result.Items = append(result.Items, BatchCreateItem{BatchPreviewItem: preview})
result.FailedCount++
continue
}
taskID := task.ID
result.Items = append(result.Items, BatchCreateItem{BatchPreviewItem: preview, Created: true, TaskID: &taskID, TaskNo: taskNumber(taskID), Replayed: replayed})
result.CreatedCount++
}
return result, nil
}
func validateBatchIDs(raw []uint64) ([]uint64, error) {
if len(raw) == 0 || len(raw) > maxBatchPurchaseItems {
return nil, fail(CodeInvalidRequest, "sybProductIds 必须包含 1 至 100 条当前页商品")
}
seen := make(map[uint64]bool, len(raw))
ids := make([]uint64, 0, len(raw))
for _, id := range raw {
if id == 0 || seen[id] {
return nil, fail(CodeInvalidRequest, "sybProductIds 包含无效或重复的商品")
}
seen[id] = true
ids = append(ids, id)
}
return ids, nil
}
func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64, rule purchasecontract.RuleSnapshot) error {
if deviceID == nil {
return nil
}
var record models.AgentDevice
if err := s.DB.WithContext(ctx).First(&record, *deviceID).Error; err != nil {
return notFound(err, "设备不存在")
}
if record.Status != models.DeviceStatusOnline || record.TokenRevokedAt != nil {
return fail(CodeInvalidRequest, "所选设备当前不可用,请选择在线设备或留空")
}
return ensureCapabilities(record, purchasecontract.RequiredCapabilities(rule))
}
func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard purchasecontract.PriceLimits) BatchPreviewItem {
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
if err != nil {
item := BatchPreviewItem{SYBProductID: id}
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
return item
}
item := s.previewFromDataset(id, dataset, guard)
return item
}
func replayBatchPreview(task models.PurchaseTask) BatchPreviewItem {
return BatchPreviewItem{
SYBProductID: taskPointerValue(task.SYBProductID), ShopeeProductID: task.ShopeeProductID,
PDDProductID: &task.PDDProductID, ProductTitle: task.ShopeeTitleSnapshot,
PDDGoodsID: task.PDDGoodsIDSnapshot, PDDTitle: task.PDDTitleSnapshot,
TargetColor: task.TargetColorSnapshot, TargetSize: task.TargetSizeSnapshot,
MappedColor: task.MappedColorSnapshot, MappedSize: task.MappedSizeSnapshot,
Quantity: task.Quantity, ReferenceUnitPriceCent: task.ReferenceUnitPriceCent,
MinUnitPriceCent: task.MinUnitPriceCent, MaxUnitPriceCent: task.MaxUnitPriceCent,
Currency: task.Currency, Eligible: true,
}
}
func taskPointerValue(value *uint64) uint64 {
if value == nil {
return 0
}
return *value
}
func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, guard purchasecontract.PriceLimits) BatchPreviewItem {
item := BatchPreviewItem{SYBProductID: id}
syb, found := dataset.sybByID[id]
if !found {
item.ReasonCode, item.Reason, item.NextAction = "SYB_PRODUCT_NOT_FOUND", "SYB 商品不存在或已被删除", "refresh"
return item
}
item.OrderCode, item.ShopeeProductID, item.ShopeeItemID = syb.OrderCode, syb.ShopeeProductID, syb.ShopeeItemID
item.ProductTitle, item.TargetColor, item.TargetSize, item.Quantity = syb.ProductTitle, syb.TargetColor, syb.TargetSize, syb.Quantity
if !sybSpecsTrusted(syb) {
item.ReasonCode, item.NextAction = "SYB_PARSE_FAILED", "reparse"
if syb.ParseStatus == models.SYBParseStatusUncertain {
item.Reason = "采购规格解析存疑,请先人工修正并确认"
} else {
item.Reason = "解析失败,请先重新解析或人工修正"
}
return item
}
if syb.ShopeeProductID == nil {
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_LINKED", "尚未关联蝦皮商品,请先重新导入或处理商品档案", "refresh"
return item
}
shopee, found := dataset.shopeeByID[*syb.ShopeeProductID]
if !found {
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee"
return item
}
item.ImageSearchLinked = shopee.ImageSearchLinked
if !aiConfirmedSpecsCurrent(syb, shopee) {
item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "AI 解析依据已变化,请等待重新解析或人工修正", "reparse"
return item
}
if shopee.PDDProductID == nil {
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_LINKED", "尚未关联 PDD 商品,请先关联", processActionOpenPDDLink
return item
}
item.PDDProductID = shopee.PDDProductID
pdd, found := dataset.pddByID[*shopee.PDDProductID]
if !found {
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_FOUND", "关联的 PDD 商品不存在,请重新关联", processActionOpenPDDLink
return item
}
item.PDDGoodsID, item.PDDTitle = pdd.GoodsID, pdd.Title
if pdd.Status != "active" {
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_READY", "拼多多商品尚未采集完成或已停用", "open_pdd"
return item
}
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
item.MappedColor, item.MappedSize = mappedColor, mappedSize
candidates, _ := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
// #190:失效的已保存映射不再拦截,降级为 unresolved,与创建路径保持一致。
if source != "unresolved" && !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
mappedColor, mappedSize, source = "", "", "unresolved"
item.MappedColor, item.MappedSize = "", ""
}
if source == "unresolved" {
request := aimatching.MatchRequest{TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
if matched, ok := aimatching.DeterministicMatch(request); ok {
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
}
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
}
// `[必须]` 塌缩的键不是这条明细独有的:同一个虾皮商品下,多个不同的虾皮原始
// 规格剥离 【...】 后落到了同一个 target_color / target_size,于是共用同一份
// 映射,Agent 会为不同规格点击同一个 PDD 值。此时必须明确失败,不能让它显示
// 为采购就绪——静默买错比不能采购严重得多(#289)。
if reason := collapsedSpecReason(dataset, syb); reason != "" {
item.ReasonCode, item.Reason, item.NextAction = CodeSpecKeyAmbiguous, reason, "open_mapping"
return item
}
// Readiness must agree with the mapping shown in the product detail. A
// deterministic suggestion is only a preview; it is not a persisted,
// confirmed mapping and must not make the row appear purchase-ready.
if source == "unresolved" || !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, item.MappedColor, item.MappedSize) {
// Keep Eligible for the existing live-probe path, but expose the
// unresolved state so the UI cannot label the row purchase-ready.
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "采购规格尚未匹配并保存", "open_mapping"
}
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor, guard)
if err != nil {
item.ReasonCode, item.Reason, item.NextAction = "PDD_PRICE_MISSING", err.Error(), "open_pdd"
return item
}
item.ReferenceUnitPriceCent, item.MinUnitPriceCent, item.MaxUnitPriceCent, item.Currency = reference, minPrice, maxPrice, "CNY"
if previous, found := dataset.latestTaskBySYB[syb.ID]; found {
item.ActiveTaskID = &previous.ID
switch previous.Status {
case models.PurchaseTaskStatusFailed, models.PurchaseTaskStatusCancelled, models.PurchaseTaskStatusRehearsalCompleted:
case models.PurchaseTaskStatusOrderCreated:
if previous.PaymentReviewStatus == models.PurchasePaymentReviewPaid {
item.ReasonCode, item.Reason, item.NextAction = "PAID_ORDER_EXISTS", "该商品已有已支付订单,不能再次创建", "open_task"
return item
}
if previous.RePurchaseAuthorizedAt == nil || previous.RePurchaseConsumedAt != nil {
item.ReasonCode, item.Reason, item.NextAction = CodeRePurchaseRequired, "该商品已经采购成功,请先在采购任务中允许重新采购", "open_task"
return item
}
default:
item.ReasonCode, item.Reason, item.NextAction = CodeStateConflict, "该商品已有未结束的采购任务", "open_task"
return item
}
}
item.ActiveTaskID = nil
item.Eligible = true
return item
}
func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceLimits) (reference, minPrice, maxPrice int64, err error) {
var dimensions []productspec.Dimension
if json.Unmarshal([]byte(raw), &dimensions) != nil {
return 0, 0, 0, errors.New("拼多多商品规格数据无效,请先重新采集")
}
prices := make([]int64, 0)
for _, dimension := range dimensions {
if dimension.Role != "color" {
continue
}
for _, value := range dimension.Values {
if !value.Selectable || value.PriceCent == nil || *value.PriceCent < 0 {
continue
}
if mappedColor == "" || value.Name == mappedColor {
prices = append(prices, *value.PriceCent)
}
}
}
if len(prices) == 0 {
if mappedColor != "" {
return 0, 0, 0, fmt.Errorf("拼多多颜色“%s”没有可用价格,请先检查商品规格", mappedColor)
}
return 0, 0, 0, errors.New("拼多多商品没有可用的颜色价格,请先采集或录入")
}
sort.Slice(prices, func(i, j int) bool { return prices[i] < prices[j] })
low, high := prices[0], prices[len(prices)-1]
if !guard.Enabled {
return high, 0, guard.AbsoluteMaxUnitPriceCent, nil
}
minRatio, maxRatio := int64(guard.MinRatio), int64(guard.MaxRatio)
return high, low * minRatio / purchasecontract.RatioScale, (high*maxRatio + purchasecontract.RatioScale - 1) / purchasecontract.RatioScale, nil
}
func batchItemRequestID(batchRequestID string, sybProductID uint64) string {
return uuid.NewSHA1(uuid.NameSpaceOID, []byte(fmt.Sprintf("%s:%d", batchRequestID, sybProductID))).String()
}
func taskNumber(id uint64) string { return fmt.Sprintf("CG-%d", id) }
func serviceErrorFields(err error) (string, string, string) {
var serviceErr *ServiceError
if errors.As(err, &serviceErr) {
next := "retry"
switch serviceErr.Code {
case CodeMappingRequired:
next = "open_mapping"
case CodeStateConflict, CodeRePurchaseRequired:
next = "open_task"
case CodeCapabilityMismatch:
next = "select_device"
case CodeInvalidRequest:
if strings.Contains(serviceErr.Message, "设备") {
next = "select_device"
} else {
next = "refresh"
}
}
return serviceErr.Code, serviceErr.Message, next
}
return CodeInternal, "服务端处理失败", "retry"
}