Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a16e6920b7 | ||
|
|
def0a86f63 | ||
|
|
ba7d2a6b72 | ||
|
|
a3cf98940a | ||
|
|
56706a0b45 | ||
|
|
a4874091a8 | ||
|
|
9e0dcc254e | ||
|
|
cda1978bc6 | ||
|
|
c80d0c91d5 | ||
|
|
be491c7692 | ||
|
|
90c1668fe3 | ||
|
|
804448ede0 | ||
|
|
5a7e219230 | ||
|
|
36d1f82ce2 | ||
|
|
1c998911be | ||
|
|
45437a9214 | ||
|
|
42b5312198 | ||
|
|
7b540f3163 | ||
|
|
ffa5fc0710 | ||
|
|
66ab36c88c | ||
|
|
35b69fc25a | ||
|
|
b4dc7fc9ae | ||
|
|
baa9408a36 | ||
|
|
e01fcd9860 | ||
|
|
be03972779 |
@@ -7,6 +7,8 @@
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
|
||||
<queries>
|
||||
<package android:name="com.xunmeng.pinduoduo" />
|
||||
|
||||
@@ -38,6 +38,7 @@ class MainActivity : AppCompatActivity() {
|
||||
)
|
||||
setContentView(buildContent())
|
||||
requestNotificationPermission()
|
||||
requestMediaPermission()
|
||||
if (AgentSettingsStore(this).serverUrl().isNotBlank()) AgentForegroundService.start(this)
|
||||
AgentAppUpdateManager.checkOnceAtStartup(this)
|
||||
}
|
||||
@@ -127,6 +128,14 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestMediaPermission() {
|
||||
val permission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES
|
||||
else Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(permission), MEDIA_PERMISSION_REQUEST)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyScreenPolicy() {
|
||||
val keepScreenOn = cn.ilapage.goauto.agent.service.AgentStateStore(this).read().keepScreenOn
|
||||
if (keepScreenOn) {
|
||||
@@ -145,5 +154,6 @@ class MainActivity : AppCompatActivity() {
|
||||
private const val TAB_PREFERENCES = "goauto_agent_ui"
|
||||
private const val LAST_SELECTED_TAB = "last_selected_tab"
|
||||
private const val STATE_SELECTED_TAB = "selected_tab"
|
||||
private const val MEDIA_PERMISSION_REQUEST = 101
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import cn.ilapage.goauto.agent.network.ImageSearchImage
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
|
||||
data class PreparedImageSearchAsset(val uri: Uri)
|
||||
|
||||
object ImageSearchAssetPolicy {
|
||||
private const val MAX_BYTES = 10 * 1024 * 1024
|
||||
fun valid(bytes: ByteArray, reference: ImageSearchImage): Boolean {
|
||||
if (bytes.isEmpty() || bytes.size.toLong() != reference.sizeBytes || bytes.size > MAX_BYTES) return false
|
||||
if (reference.mediaType != "image/jpeg" && reference.mediaType != "image/png") return false
|
||||
return sha256(bytes) == reference.sha256.lowercase()
|
||||
}
|
||||
fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
class PinduoduoImageSearchAssetStore(context: Context) {
|
||||
private val resolver = context.applicationContext.contentResolver
|
||||
|
||||
fun prepare(bytes: ByteArray, reference: ImageSearchImage): PreparedImageSearchAsset? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || !ImageSearchAssetPolicy.valid(bytes, reference)) return null
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, "goauto-search-${UUID.randomUUID()}.jpg")
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/GoAutoSearch")
|
||||
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||
}
|
||||
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return null
|
||||
return try {
|
||||
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: error("image output unavailable")
|
||||
resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null)
|
||||
PreparedImageSearchAsset(uri)
|
||||
} catch (_: Exception) { resolver.delete(uri, null, null); null }
|
||||
}
|
||||
|
||||
fun delete(asset: PreparedImageSearchAsset?) { asset?.let { resolver.delete(it.uri, null, null) } }
|
||||
}
|
||||
@@ -60,6 +60,14 @@ data class AgentTask(
|
||||
val status: String,
|
||||
val source: String,
|
||||
val replacementOriginType: String,
|
||||
val imageSearch: ImageSearchImage?,
|
||||
)
|
||||
|
||||
data class ImageSearchImage(
|
||||
val imageUrl: String,
|
||||
val mediaType: String,
|
||||
val sizeBytes: Long,
|
||||
val sha256: String,
|
||||
)
|
||||
|
||||
data class CurrentPageIdentity(
|
||||
@@ -584,6 +592,7 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
status = data.getString("status"),
|
||||
source = data.optString("source", "admin"),
|
||||
replacementOriginType = data.optString("replacementOriginType"),
|
||||
imageSearch = data.optJSONObject("imageSearch")?.let { image -> ImageSearchImage(image.getString("imageUrl"), image.getString("mediaType"), image.getLong("sizeBytes"), image.getString("sha256")) },
|
||||
)
|
||||
|
||||
private fun purchaseTask(data: JSONObject) = PurchaseAgentTask(
|
||||
|
||||
@@ -708,6 +708,8 @@ class AgentForegroundService : Service() {
|
||||
).collect(identity.goodsId, rule)
|
||||
if (!collection.successful) throw TaskFailure(collection.code, collection.message)
|
||||
CollectionExecution(requireNotNull(collection.payload), collection.colorImages)
|
||||
} else if (task.source == "image_search") {
|
||||
throw TaskFailure("IMAGE_SEARCH_AUTOMATION_UNAVAILABLE", "当前 Agent 版本尚未接入 PDD 图搜自动化,请使用普通采集任务")
|
||||
} else if (rule.schemaVersion == 2) {
|
||||
val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) }
|
||||
val collection = PddDetailEntryRunner(
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# PDD 首页控件摘要(设备 192.168.0.9:35727)
|
||||
|
||||
- 采集时间:2026-09-15
|
||||
- 包名:`com.xunmeng.pinduoduo`
|
||||
- 发现语义入口:`content-desc=拍照搜索`
|
||||
- 发现搜索入口:`content-desc=搜索`
|
||||
- 结论:PDD 首页存在稳定的“拍照搜索”无障碍语义入口,可作为 #277 的第一步入口探测。
|
||||
- 未保存坐标、完整控件树、截图、账号或个人数据。
|
||||
@@ -98,6 +98,7 @@ var AdminAPIs = []APIPermission{
|
||||
{"查看采集任务", "/api/admin/v1/collection-tasks", "GET", true},
|
||||
{"创建采集任务", "/api/admin/v1/collection-tasks", "POST", true},
|
||||
{"批量创建采集任务", "/api/admin/v1/collection-tasks/batch", "POST", true},
|
||||
{"批量创建图搜采集任务", "/api/admin/v1/collection-tasks/image-search/batch", "POST", true},
|
||||
{"查看采集任务详情", "/api/admin/v1/collection-tasks/:taskId", "GET", true},
|
||||
{"重置采集任务", "/api/admin/v1/collection-tasks/:taskId/reset", "POST", true},
|
||||
{"删除采集任务", "/api/admin/v1/collection-tasks/:taskId", "DELETE", true},
|
||||
|
||||
@@ -95,6 +95,7 @@ func Inventory() []Endpoint {
|
||||
{"GET", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "read", t.AdminDetail},
|
||||
{"POST", "/collection-tasks", access.ModuleCollectionTasks, "collect", t.AdminCreate},
|
||||
{"POST", "/collection-tasks/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreate},
|
||||
{"POST", "/collection-tasks/image-search/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreateImageSearch},
|
||||
{"POST", "/collection-tasks/:taskId/reset", access.ModuleCollectionTasks, "collect", t.AdminReset},
|
||||
{"DELETE", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "delete", t.AdminDelete},
|
||||
{"GET", "/purchase-tasks", access.ModulePurchaseTasks, "read", buy.AdminList},
|
||||
|
||||
@@ -20,6 +20,7 @@ const (
|
||||
|
||||
CollectionTaskSourceAdmin = "admin"
|
||||
CollectionTaskSourceAgentCurrentPage = "agent_current_page"
|
||||
CollectionTaskSourceImageSearch = "image_search"
|
||||
)
|
||||
|
||||
// AgentDevice identifies one Agent installation. InstallID is generated by the
|
||||
@@ -181,13 +182,14 @@ type CollectionTask struct {
|
||||
Rule CollectionRule `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
|
||||
DeviceID *uint64 `json:"deviceId" gorm:"index;uniqueIndex:ux_collection_task_running_device,priority:1"`
|
||||
Device *AgentDevice `json:"-"`
|
||||
Source string `json:"source" gorm:"size:32;not null;default:admin;index;check:ck_collection_task_source,source IN ('admin','agent_current_page')"`
|
||||
Source string `json:"source" gorm:"size:32;not null;default:admin;index;check:ck_collection_task_source,source IN ('admin','agent_current_page','image_search')"`
|
||||
Status string `json:"status" gorm:"size:24;not null;index;check:ck_collection_task_status,status IN ('pending','running','completed','completed_partial','failed')"`
|
||||
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_active_product,priority:2;check:ck_collection_task_active_slot,(status IN ('pending','running') AND active_slot = 1) OR (status NOT IN ('pending','running') AND active_slot IS NULL)"`
|
||||
DeviceRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_running_device,priority:2;check:ck_collection_task_device_run_slot,(status = 'running' AND device_id IS NOT NULL AND device_run_slot = 1) OR (status <> 'running' AND device_run_slot IS NULL)"`
|
||||
URLSnapshot string `json:"urlSnapshot" gorm:"type:text;not null"`
|
||||
GoodsIDSnapshot string `json:"goodsIdSnapshot" gorm:"size:32;not null;index"`
|
||||
RuleSnapshot string `json:"ruleSnapshot" gorm:"type:text;not null"`
|
||||
ImageSearchSnapshot *string `json:"-" gorm:"type:text"`
|
||||
AttemptNumber int `json:"attemptNumber" gorm:"not null;default:1;check:ck_collection_task_attempt_number,attempt_number >= 1"`
|
||||
LeaseExpiresAt *time.Time `json:"leaseExpiresAt" gorm:"index"`
|
||||
LeaseVersion uint64 `json:"leaseVersion" gorm:"not null;default:0"`
|
||||
@@ -416,7 +418,8 @@ type ShopeeProduct struct {
|
||||
// PDDProductID stays nullable and must NOT be unique: one Shopee product maps
|
||||
// to at most one PDD product, but several Shopee products may share the same
|
||||
// PDD product.
|
||||
PDDProductID *uint64 `json:"pddProductId" gorm:"index"`
|
||||
PDDProductID *uint64 `json:"pddProductId" gorm:"index"`
|
||||
ImageSearchLinked bool `json:"imageSearchLinked" gorm:"not null;default:false;index"`
|
||||
// ImageURL holds the SYB-provided reference image URL. It is written by the
|
||||
// #41 import and may be overridden manually; the product domain never joins
|
||||
// syb_products at read time.
|
||||
|
||||
@@ -34,8 +34,18 @@ func (handler Handler) List(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var imageSearchLinked *bool
|
||||
if raw := strings.TrimSpace(c.Query("imageSearchLinked")); raw != "" {
|
||||
value, parseErr := strconv.ParseBool(raw)
|
||||
if parseErr != nil {
|
||||
writeError(c, invalidRequest("imageSearchLinked 必须是布尔值"))
|
||||
return
|
||||
}
|
||||
imageSearchLinked = &value
|
||||
}
|
||||
response, err := service.List(c.Request.Context(), ListRequest{
|
||||
Page: page, PageSize: pageSize, Keyword: c.Query("keyword"), Status: strings.TrimSpace(c.Query("status")),
|
||||
ImageSearchLinked: imageSearchLinked,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
|
||||
@@ -231,7 +231,8 @@ type ListRequest struct {
|
||||
// Status filters by lifecycle: "" (default) live only, "deleted" soft-deleted
|
||||
// only. There is no "all" value: mixing live and deleted rows in one page
|
||||
// would defeat the point of the filter (#40 prototype: 全部(不含已删除)).
|
||||
Status string
|
||||
Status string
|
||||
ImageSearchLinked *bool
|
||||
}
|
||||
|
||||
func (service *Service) List(ctx context.Context, request ListRequest) (ListResponse, error) {
|
||||
@@ -257,6 +258,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
like := "%" + request.Keyword + "%"
|
||||
query = query.Where("shopee_item_id LIKE ? OR title LIKE ? OR shop_name LIKE ?", like, like, like)
|
||||
}
|
||||
if request.ImageSearchLinked != nil {
|
||||
query = query.Where("image_search_linked = ?", *request.ImageSearchLinked)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return ListResponse{}, internalError(err)
|
||||
@@ -303,7 +307,7 @@ func (service *Service) LinkPDD(ctx context.Context, id uint64, request LinkPDDR
|
||||
return SaveResponse{}, err
|
||||
}
|
||||
result := db.Model(&models.ShopeeProduct{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"pdd_product_id": request.PDDProductID, "last_update_request_id": request.RequestID,
|
||||
"pdd_product_id": request.PDDProductID, "image_search_linked": false, "last_update_request_id": request.RequestID,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return SaveResponse{}, internalError(result.Error)
|
||||
|
||||
@@ -78,6 +78,25 @@ func (handler Handler) AdminBatchCreate(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) AdminBatchCreateImageSearch(c *gin.Context) {
|
||||
var request ImageSearchBatchRequest
|
||||
if err := decodeStrict(c, &request); err != nil {
|
||||
writeError(c, serviceError("INVALID_REQUEST", "请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
service, _, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.BatchCreateImageSearch(c.Request.Context(), request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) AdminDetail(c *gin.Context) {
|
||||
id, err := taskID(c)
|
||||
if err != nil || id == 0 {
|
||||
|
||||
@@ -211,7 +211,7 @@ func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64,
|
||||
}
|
||||
return CurrentPageIdentifyResponse{}, internalError(err)
|
||||
}
|
||||
if existing.Source != models.CollectionTaskSourceAgentCurrentPage || existing.DeviceID == nil || *existing.DeviceID != deviceRecord.ID {
|
||||
if (existing.Source != models.CollectionTaskSourceAgentCurrentPage && existing.Source != models.CollectionTaskSourceImageSearch) || existing.DeviceID == nil || *existing.DeviceID != deviceRecord.ID {
|
||||
return CurrentPageIdentifyResponse{}, serviceError(CodeTaskNotFound, "任务不存在")
|
||||
}
|
||||
if existing.IdentifyRequestID != nil && *existing.IdentifyRequestID == request.RequestID && existing.PDDProductID != nil {
|
||||
@@ -240,7 +240,7 @@ func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64,
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if record.Source != models.CollectionTaskSourceAgentCurrentPage || record.DeviceID == nil || *record.DeviceID != deviceRecord.ID {
|
||||
if (record.Source != models.CollectionTaskSourceAgentCurrentPage && record.Source != models.CollectionTaskSourceImageSearch) || record.DeviceID == nil || *record.DeviceID != deviceRecord.ID {
|
||||
return serviceError(CodeTaskNotFound, "任务不存在")
|
||||
}
|
||||
if record.IdentifyRequestID != nil && *record.IdentifyRequestID == request.RequestID && record.PDDProductID != nil {
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const ImageSearchCapability = "pdd.image-search.v1"
|
||||
const imageSearchMaxBytes int64 = 10 << 20
|
||||
|
||||
func validImageMetadata(value ImageSearchImage) bool {
|
||||
if value.SizeBytes <= 0 || value.SizeBytes > imageSearchMaxBytes || len(value.SHA256) != 64 || value.SHA256 != strings.ToLower(value.SHA256) || value.ImageURL == "" {
|
||||
return false
|
||||
}
|
||||
return value.MediaType == "image/jpeg" || value.MediaType == "image/png"
|
||||
}
|
||||
|
||||
func fetchImageSearchImage(ctx context.Context, rawURL string) (ImageSearchImage, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return ImageSearchImage{}, err
|
||||
}
|
||||
response, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return ImageSearchImage{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return ImageSearchImage{}, fmt.Errorf("reference image status %d", response.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, imageSearchMaxBytes+1))
|
||||
if err != nil || int64(len(data)) > imageSearchMaxBytes {
|
||||
return ImageSearchImage{}, errors.New("reference image exceeds limit")
|
||||
}
|
||||
config, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil || config.Width < 1 || config.Height < 1 {
|
||||
return ImageSearchImage{}, errors.New("unsupported image format")
|
||||
}
|
||||
media := "image/" + format
|
||||
if format == "jpeg" {
|
||||
media = "image/jpeg"
|
||||
}
|
||||
hash := sha256.Sum256(data)
|
||||
return ImageSearchImage{ImageURL: rawURL, MediaType: media, SizeBytes: int64(len(data)), SHA256: hex.EncodeToString(hash[:])}, nil
|
||||
}
|
||||
|
||||
// Only this subset is sent to Agent. Association and price snapshots stay on Server.
|
||||
type ImageSearchImage struct {
|
||||
ImageURL string `json:"imageUrl"`
|
||||
MediaType string `json:"mediaType"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type ImageSearchSnapshot struct {
|
||||
ImageSearchImage
|
||||
ShopeeProductID uint64 `json:"shopeeProductId"`
|
||||
SYBProductIDs []uint64 `json:"sybProductIds"`
|
||||
RepresentativeSYBProductID uint64 `json:"representativeSybProductId"`
|
||||
OriginalPDDProductID *uint64 `json:"originalPddProductId"`
|
||||
ReferencePriceCent int64 `json:"referencePriceCent"`
|
||||
ReferenceCurrency string `json:"referenceCurrency"`
|
||||
MaxPriceRatio int64 `json:"maxPriceRatio"`
|
||||
PriceGuardSkipped bool `json:"priceGuardSkipped"`
|
||||
PriceGuardSkipReason string `json:"priceGuardSkipReason,omitempty"`
|
||||
RequestFingerprint string `json:"requestFingerprint"`
|
||||
}
|
||||
|
||||
type ImageSearchBatchRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
SYBProductIDs []uint64 `json:"sybProductIds"`
|
||||
RuleID uint64 `json:"ruleId"`
|
||||
DeviceID *uint64 `json:"deviceId"`
|
||||
OverwriteLinked bool `json:"overwriteLinked"`
|
||||
}
|
||||
|
||||
type ImageSearchBatchItem struct {
|
||||
ShopeeProductID uint64 `json:"shopeeProductId"`
|
||||
SYBProductIDs []uint64 `json:"sybProductIds"`
|
||||
Success bool `json:"success"`
|
||||
TaskID uint64 `json:"taskId,omitempty"`
|
||||
Replayed bool `json:"replayed,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type ImageSearchBatchResponse struct {
|
||||
Items []ImageSearchBatchItem `json:"items"`
|
||||
SuccessCount int `json:"successCount"`
|
||||
FailureCount int `json:"failureCount"`
|
||||
SkippedCount int `json:"skippedCount"`
|
||||
}
|
||||
|
||||
func (service *Service) BatchCreateImageSearch(ctx context.Context, request ImageSearchBatchRequest) (ImageSearchBatchResponse, error) {
|
||||
batchID, err := uuid.Parse(strings.TrimSpace(request.RequestID))
|
||||
if err != nil || request.RuleID == 0 || len(request.SYBProductIDs) == 0 || len(request.SYBProductIDs) > 100 || request.DeviceID != nil && *request.DeviceID == 0 {
|
||||
return ImageSearchBatchResponse{}, serviceError("INVALID_REQUEST", "批次、SYB 商品、规则和设备参数无效(最多 100 条)")
|
||||
}
|
||||
seen := map[uint64]bool{}
|
||||
for _, id := range request.SYBProductIDs {
|
||||
if id == 0 || seen[id] {
|
||||
return ImageSearchBatchResponse{}, serviceError("INVALID_REQUEST", "SYB 商品列表包含无效或重复 ID")
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
// Bound the complete synchronous batch, not just each individual download.
|
||||
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
defer cancel()
|
||||
var rows []models.SYBProduct
|
||||
if err := service.DB.WithContext(ctx).Where("id IN ?", request.SYBProductIDs).Find(&rows).Error; err != nil {
|
||||
return ImageSearchBatchResponse{}, internalError(err)
|
||||
}
|
||||
byID := map[uint64]models.SYBProduct{}
|
||||
for _, row := range rows {
|
||||
byID[row.ID] = row
|
||||
}
|
||||
response := ImageSearchBatchResponse{Items: []ImageSearchBatchItem{}}
|
||||
groups := map[uint64]int{}
|
||||
for _, id := range request.SYBProductIDs {
|
||||
row, found := byID[id]
|
||||
if !found || row.ShopeeProductID == nil || *row.ShopeeProductID == 0 {
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "SYB_PRODUCT_UNAVAILABLE", Message: "SYB 商品不存在或未关联蝦皮商品"})
|
||||
continue
|
||||
}
|
||||
if index, ok := groups[*row.ShopeeProductID]; ok {
|
||||
response.Items[index].SYBProductIDs = append(response.Items[index].SYBProductIDs, id)
|
||||
} else {
|
||||
groups[*row.ShopeeProductID] = len(response.Items)
|
||||
response.Items = append(response.Items, ImageSearchBatchItem{ShopeeProductID: *row.ShopeeProductID, SYBProductIDs: []uint64{id}})
|
||||
}
|
||||
}
|
||||
for index := range response.Items {
|
||||
item := &response.Items[index]
|
||||
if item.Code == "" {
|
||||
itemRequestID := uuid.NewSHA1(batchID, []byte(strconv.FormatUint(item.ShopeeProductID, 10))).String()
|
||||
id, replayed, createErr := service.createImageSearch(ctx, request, *item, itemRequestID)
|
||||
if createErr == nil {
|
||||
item.Success, item.TaskID, item.Replayed = true, id, replayed
|
||||
} else {
|
||||
var target *ServiceError
|
||||
if !errors.As(createErr, &target) {
|
||||
target = internalError(createErr).(*ServiceError)
|
||||
}
|
||||
item.Code, item.Message = target.Code, target.Message
|
||||
}
|
||||
}
|
||||
if item.Success {
|
||||
response.SuccessCount++
|
||||
} else if item.Code == "IMAGE_SEARCH_ALREADY_LINKED" {
|
||||
response.SkippedCount++
|
||||
} else {
|
||||
response.FailureCount++
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func imageRequestFingerprint(request ImageSearchBatchRequest, item ImageSearchBatchItem) string {
|
||||
// Include group membership so a reused request ID cannot silently change intent.
|
||||
request.RequestID = ""
|
||||
request.SYBProductIDs = item.SYBProductIDs
|
||||
raw, _ := json.Marshal(request)
|
||||
hash := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func loadImageSearchSnapshot(record models.CollectionTask) (ImageSearchSnapshot, error) {
|
||||
var snapshot ImageSearchSnapshot
|
||||
if record.ImageSearchSnapshot == nil || json.Unmarshal([]byte(*record.ImageSearchSnapshot), &snapshot) != nil || snapshot.ShopeeProductID == 0 || snapshot.RepresentativeSYBProductID == 0 || snapshot.MaxPriceRatio < 1 || !validImageMetadata(snapshot.ImageSearchImage) {
|
||||
return snapshot, serviceError("IMAGE_SEARCH_SNAPSHOT_INVALID", "图搜任务快照无效")
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// ImageSearchPriceAllowed deliberately refuses to compare unlike currencies.
|
||||
// A skipped check is returned as comparable=false so callers must require
|
||||
// manual confirmation before automatic association.
|
||||
func ImageSearchPriceAllowed(snapshot ImageSearchSnapshot, pddCurrency string, priceCent int64) (allowed bool, comparable bool) {
|
||||
if priceCent < 0 || snapshot.ReferencePriceCent < 0 || snapshot.ReferencePriceCent == 0 || snapshot.MaxPriceRatio < 1 {
|
||||
return false, false
|
||||
}
|
||||
if strings.ToUpper(strings.TrimSpace(snapshot.ReferenceCurrency)) != strings.ToUpper(strings.TrimSpace(pddCurrency)) {
|
||||
return false, false
|
||||
}
|
||||
return priceCent <= snapshot.ReferencePriceCent*snapshot.MaxPriceRatio, true
|
||||
}
|
||||
|
||||
func (service *Service) createImageSearch(ctx context.Context, request ImageSearchBatchRequest, item ImageSearchBatchItem, requestID string) (uint64, bool, error) {
|
||||
fingerprint := imageRequestFingerprint(request, item)
|
||||
replay := func(db *gorm.DB) (uint64, bool, error) {
|
||||
var record models.CollectionTask
|
||||
err := db.Where("create_request_id = ?", requestID).First(&record).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, internalError(err)
|
||||
}
|
||||
snapshot, err := loadImageSearchSnapshot(record)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if record.Source != models.CollectionTaskSourceImageSearch || snapshot.RequestFingerprint != fingerprint {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_REQUEST_CONFLICT", "同一请求 ID 的参数已变化")
|
||||
}
|
||||
return record.ID, true, nil
|
||||
}
|
||||
db := service.DB.WithContext(ctx)
|
||||
if id, found, err := replay(db); found || err != nil {
|
||||
return id, found, err
|
||||
}
|
||||
var shopee models.ShopeeProduct
|
||||
if err := db.First(&shopee, item.ShopeeProductID).Error; err != nil {
|
||||
return 0, false, serviceError("SHOPEE_PRODUCT_NOT_FOUND", "蝦皮商品不存在")
|
||||
}
|
||||
if shopee.PDDProductID != nil && !request.OverwriteLinked {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_ALREADY_LINKED", "商品已关联 PDD,已跳过")
|
||||
}
|
||||
var rule models.CollectionRule
|
||||
if err := db.First(&rule, request.RuleID).Error; err != nil {
|
||||
return 0, false, serviceError(CodeRuleNotFound, "采集规则不存在")
|
||||
}
|
||||
if err := ensureCurrentPageRule(rule.ContentJSON); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if request.DeviceID != nil {
|
||||
var target models.AgentDevice
|
||||
if err := db.First(&target, *request.DeviceID).Error; err != nil || target.Status == models.DeviceStatusDisabled {
|
||||
return 0, false, serviceError(CodeDeviceNotFound, "设备不存在或已停用")
|
||||
}
|
||||
if err := ensureTaskCompatible(target, models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, RuleSnapshot: rule.ContentJSON}); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
}
|
||||
ratio := int64(3)
|
||||
if value := strings.TrimSpace(os.Getenv("GOAUTO_IMAGE_SEARCH_MAX_PRICE_RATIO")); value != "" {
|
||||
var err error
|
||||
ratio, err = strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || ratio < 1 || ratio > 100 {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_CONFIG_INVALID", "图搜价格倍数配置须为 1~100 的整数")
|
||||
}
|
||||
}
|
||||
var representative models.SYBProduct
|
||||
var image ImageSearchImage
|
||||
fetch := service.FetchImageSearchImage
|
||||
if fetch == nil {
|
||||
fetch = fetchImageSearchImage
|
||||
}
|
||||
for _, id := range item.SYBProductIDs {
|
||||
var row models.SYBProduct
|
||||
if err := db.First(&row, id).Error; err != nil {
|
||||
return 0, false, internalError(err)
|
||||
}
|
||||
if row.ShopeeProductID == nil || *row.ShopeeProductID != shopee.ID {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_INPUT_CHANGED", "SYB 商品关联已变化,请刷新后重试")
|
||||
}
|
||||
if strings.TrimSpace(row.ImageURL) == "" {
|
||||
continue
|
||||
}
|
||||
var err error
|
||||
image, err = fetch(ctx, row.ImageURL)
|
||||
if err == nil && validImageMetadata(image) {
|
||||
representative = row
|
||||
break
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if representative.ID == 0 {
|
||||
return 0, false, serviceError("IMAGE_SEARCH_IMAGE_INVALID", "参考图下载失败、格式不支持或超过限制")
|
||||
}
|
||||
referenceCurrency := strings.ToUpper(strings.TrimSpace(shopee.Currency))
|
||||
priceSkipped := referenceCurrency != "CNY"
|
||||
snapshot := ImageSearchSnapshot{ImageSearchImage: image, ShopeeProductID: shopee.ID, SYBProductIDs: item.SYBProductIDs, RepresentativeSYBProductID: representative.ID,
|
||||
OriginalPDDProductID: shopee.PDDProductID, ReferencePriceCent: representative.UnitPriceCent, ReferenceCurrency: referenceCurrency, MaxPriceRatio: ratio, RequestFingerprint: fingerprint,
|
||||
PriceGuardSkipped: priceSkipped}
|
||||
if priceSkipped {
|
||||
snapshot.PriceGuardSkipReason = "CROSS_CURRENCY_TWD_PDD_CNY"
|
||||
}
|
||||
raw, _ := json.Marshal(snapshot)
|
||||
stored := string(raw)
|
||||
var taskID uint64
|
||||
var replayed bool
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var current models.ShopeeProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, shopee.ID).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if id, found, err := replay(tx); found || err != nil {
|
||||
taskID, replayed = id, found
|
||||
return err
|
||||
}
|
||||
if !sameImageSearchLink(current.PDDProductID, shopee.PDDProductID) || current.Currency != shopee.Currency {
|
||||
return serviceError("IMAGE_SEARCH_INPUT_CHANGED", "蝦皮商品关联或币种已变化,请刷新后重试")
|
||||
}
|
||||
for _, id := range item.SYBProductIDs {
|
||||
var row models.SYBProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if row.ShopeeProductID == nil || *row.ShopeeProductID != shopee.ID || id == representative.ID && (row.ImageURL != representative.ImageURL || row.UnitPriceCent != representative.UnitPriceCent) {
|
||||
return serviceError("IMAGE_SEARCH_INPUT_CHANGED", "参考图片或价格已变化,请刷新后重试")
|
||||
}
|
||||
}
|
||||
var active []models.CollectionTask
|
||||
if err := tx.Where("source = ? AND status IN ?", models.CollectionTaskSourceImageSearch, []string{models.TaskStatusPending, models.TaskStatusRunning}).Find(&active).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
for _, candidate := range active {
|
||||
other, err := loadImageSearchSnapshot(candidate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if other.ShopeeProductID == shopee.ID {
|
||||
return serviceError("IMAGE_SEARCH_TASK_ACTIVE", "该蝦皮商品已有未结束的图搜任务")
|
||||
}
|
||||
}
|
||||
record := models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, Status: models.TaskStatusPending, RuleID: rule.ID, RuleSnapshot: rule.ContentJSON, DeviceID: request.DeviceID, CreateRequestID: &requestID, ImageSearchSnapshot: &stored}
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
taskID = record.ID
|
||||
return nil
|
||||
})
|
||||
return taskID, replayed, err
|
||||
}
|
||||
|
||||
func sameImageSearchLink(left, right *uint64) bool {
|
||||
return left == nil && right == nil || left != nil && right != nil && *left == *right
|
||||
}
|
||||
|
||||
func taskCompatible(record models.AgentDevice, task models.CollectionTask) (bool, error) {
|
||||
if task.Source == models.CollectionTaskSourceImageSearch {
|
||||
ok, err := device.Supports(record, []string{ImageSearchCapability})
|
||||
if err != nil || !ok {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return ruleCompatible(record, task.RuleSnapshot)
|
||||
}
|
||||
|
||||
func ensureTaskCompatible(record models.AgentDevice, task models.CollectionTask) error {
|
||||
ok, err := taskCompatible(record, task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return serviceError(CodeDeviceCapabilityMismatch, "设备不支持任务所需能力,请更新 Agent")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// autoLinkImageSearch is deliberately a narrow post-result hook. It only
|
||||
// writes an association when the task was a complete collection, the frozen
|
||||
// original association is unchanged, and the target product is active. Manual
|
||||
// changes made while the Agent was running win by the CAS predicate.
|
||||
func (service *Service) autoLinkImageSearch(ctx context.Context, taskID uint64) error {
|
||||
var task models.CollectionTask
|
||||
if err := service.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if task.Source != models.CollectionTaskSourceImageSearch || task.Status != models.TaskStatusCompleted || task.PDDProductID == nil || task.ImageSearchSnapshot == nil {
|
||||
return nil
|
||||
}
|
||||
snapshot, err := loadImageSearchSnapshot(task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if snapshot.PriceGuardSkipped {
|
||||
return nil
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
if err := service.DB.WithContext(ctx).First(&product, snapshot.ShopeeProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
// PDD products are RMB in the current contract; unlike-currency snapshots
|
||||
// must remain available for manual review and can never auto-link.
|
||||
if strings.ToUpper(strings.TrimSpace(product.Currency)) != "CNY" {
|
||||
return nil
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := service.DB.WithContext(ctx).Select("id", "status").First(&pdd, *task.PDDProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if pdd.Status != "active" {
|
||||
return nil
|
||||
}
|
||||
result := service.DB.WithContext(ctx).Model(&models.ShopeeProduct{}).
|
||||
Where("id = ? AND pdd_product_id IS NULL AND image_search_linked = ?", product.ID, false).
|
||||
Updates(map[string]any{"pdd_product_id": *task.PDDProductID, "image_search_linked": true})
|
||||
if result.Error != nil {
|
||||
return internalError(result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
func TestImageSearchPriceAllowedRefusesCrossCurrency(t *testing.T) {
|
||||
snapshot := ImageSearchSnapshot{ReferencePriceCent: 1000, ReferenceCurrency: "TWD", MaxPriceRatio: 3}
|
||||
if allowed, comparable := ImageSearchPriceAllowed(snapshot, "CNY", 2000); allowed || comparable {
|
||||
t.Fatalf("cross-currency price must not be compared: allowed=%v comparable=%v", allowed, comparable)
|
||||
}
|
||||
if allowed, comparable := ImageSearchPriceAllowed(snapshot, "TWD", 3000); !allowed || !comparable {
|
||||
t.Fatalf("same-currency price within ratio should pass: allowed=%v comparable=%v", allowed, comparable)
|
||||
}
|
||||
if allowed, comparable := ImageSearchPriceAllowed(snapshot, "TWD", 3001); allowed || !comparable {
|
||||
t.Fatalf("same-currency price over ratio should fail: allowed=%v comparable=%v", allowed, comparable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageSearchPayloadOnlyContainsImageMetadata(t *testing.T) {
|
||||
secret := `{"shopeeProductId":99,"representativeSybProductId":1,"maxPriceRatio":3,"referenceCurrency":"TWD","imageUrl":"https://example.invalid/ref.jpg","mediaType":"image/jpeg","sizeBytes":10,"sha256":"0123456789012345678901234567890101234567890123456789012345678901"}`
|
||||
record := models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, RuleSnapshot: `{}`, ImageSearchSnapshot: &secret}
|
||||
payload, err := NewService(nil).payload(record, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := string(payload.ImageSearch)
|
||||
if raw == "" || containsAny(raw, "shopeeProductId", "referenceCurrency") {
|
||||
t.Fatalf("internal snapshot fields leaked in Agent payload: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func containsAny(value string, needles ...string) bool {
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(value, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAutoLinkImageSearchDoesNotOverwriteManualAssociation(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
manualPDD := models.PDDProduct{GoodsID: "manual-pdd", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=manual-pdd", Status: "active"}
|
||||
autoPDD := models.PDDProduct{GoodsID: "auto-pdd", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=auto-pdd", Status: "active"}
|
||||
if err := db.Create(&manualPDD).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&autoPDD).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shopee := models.ShopeeProduct{ShopeeItemID: "auto-link-test", Title: "test", Currency: "CNY", SpecsJSON: "[]", PDDProductID: &manualPDD.ID}
|
||||
if err := db.Create(&shopee).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := models.CollectionRule{Name: "image-search-test", ContentJSON: "{}"}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot := ImageSearchSnapshot{ShopeeProductID: shopee.ID, RepresentativeSYBProductID: 1, ReferenceCurrency: "CNY", ReferencePriceCent: 100, MaxPriceRatio: 3, ImageSearchImage: ImageSearchImage{ImageURL: "https://example.invalid/a.jpg", MediaType: "image/jpeg", SizeBytes: 1, SHA256: "0123456789012345678901234567890101234567890123456789012345678901"}}
|
||||
raw, _ := json.Marshal(snapshot)
|
||||
task := models.CollectionTask{Source: models.CollectionTaskSourceImageSearch, Status: models.TaskStatusCompleted, PDDProductID: &autoPDD.ID, RuleID: rule.ID, RuleSnapshot: "{}", ImageSearchSnapshot: func() *string { v := string(raw); return &v }()}
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewService(db).autoLinkImageSearch(context.Background(), task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved models.ShopeeProduct
|
||||
if err := db.First(&saved, shopee.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.PDDProductID == nil || *saved.PDDProductID != manualPDD.ID || saved.ImageSearchLinked {
|
||||
t.Fatalf("manual association was overwritten: %+v", saved)
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,9 @@ func (service *Service) SubmitResult(ctx context.Context, taskID uint64, request
|
||||
if err := service.activatePersistedReplacement(ctx, taskID); err != nil {
|
||||
return DetailResponse{}, err
|
||||
}
|
||||
if err := service.autoLinkImageSearch(ctx, taskID); err != nil {
|
||||
return DetailResponse{}, err
|
||||
}
|
||||
detail, err := service.Detail(ctx, taskID)
|
||||
detail.Replayed = replayed
|
||||
return detail, err
|
||||
|
||||
@@ -31,6 +31,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
admin.GET("", handler.AdminList)
|
||||
admin.POST("", handler.AdminCreate)
|
||||
admin.POST("/batch", handler.AdminBatchCreate)
|
||||
admin.POST("/image-search/batch", handler.AdminBatchCreateImageSearch)
|
||||
admin.GET("/:taskId", handler.AdminDetail)
|
||||
admin.POST("/:taskId/reset", handler.AdminReset)
|
||||
admin.DELETE("/:taskId", handler.AdminDelete)
|
||||
|
||||
@@ -60,6 +60,7 @@ type TaskPayload struct {
|
||||
ReplacementOriginType string `json:"replacementOriginType,omitempty"`
|
||||
RuleID uint64 `json:"ruleId"`
|
||||
RuleSnapshot json.RawMessage `json:"ruleSnapshot"`
|
||||
ImageSearch json.RawMessage `json:"imageSearch,omitempty"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"`
|
||||
LeaseVersion uint64 `json:"leaseVersion"`
|
||||
@@ -74,6 +75,7 @@ type Service struct {
|
||||
TaskTimeout int
|
||||
ResolveCurrentPageShare func(context.Context, string) (ResolvedPDDShare, error)
|
||||
ColorImageDirectory string
|
||||
FetchImageSearchImage func(context.Context, string) (ImageSearchImage, error)
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service {
|
||||
@@ -107,7 +109,7 @@ func (service *Service) Next(ctx context.Context, token string) (*TaskPayload, e
|
||||
Where("device_id = ? AND status = ? AND lease_expires_at > ?", deviceRecord.ID, models.TaskStatusPending, now).
|
||||
Order("created_at ASC, id ASC").First(&record).Error
|
||||
if err == nil {
|
||||
if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil {
|
||||
if err := ensureTaskCompatible(deviceRecord, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, payloadErr := service.payload(record, false)
|
||||
@@ -124,7 +126,7 @@ func (service *Service) Next(ctx context.Context, token string) (*TaskPayload, e
|
||||
return nil, internalError(err)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
compatible, compatibilityErr := ruleCompatible(deviceRecord, candidate.RuleSnapshot)
|
||||
compatible, compatibilityErr := taskCompatible(deviceRecord, candidate)
|
||||
if compatibilityErr != nil {
|
||||
return nil, compatibilityErr
|
||||
}
|
||||
@@ -167,7 +169,7 @@ func (service *Service) Claim(ctx context.Context, taskID uint64, request Action
|
||||
if record.Status != models.TaskStatusPending {
|
||||
return serviceError(CodeTaskStateConflict, "只有待执行任务可以领取")
|
||||
}
|
||||
if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil {
|
||||
if err := ensureTaskCompatible(deviceRecord, record); err != nil {
|
||||
return err
|
||||
}
|
||||
now := service.Now()
|
||||
@@ -235,7 +237,7 @@ func (service *Service) Start(ctx context.Context, taskID uint64, request Action
|
||||
if record.Status != models.TaskStatusPending {
|
||||
return serviceError(CodeTaskStateConflict, "任务当前状态不能开始")
|
||||
}
|
||||
if err := ensureRuleCompatible(deviceRecord, record.RuleSnapshot); err != nil {
|
||||
if err := ensureTaskCompatible(deviceRecord, record); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.DeviceID == nil || *record.DeviceID != deviceRecord.ID {
|
||||
@@ -286,6 +288,17 @@ func (service *Service) payload(record models.CollectionTask, replayed bool) (Ta
|
||||
if !json.Valid(rule) {
|
||||
return TaskPayload{}, internalError(errors.New("stored rule snapshot is invalid JSON"))
|
||||
}
|
||||
var imageSearch json.RawMessage
|
||||
if record.ImageSearchSnapshot != nil && *record.ImageSearchSnapshot != "" {
|
||||
snapshot, snapshotErr := loadImageSearchSnapshot(record)
|
||||
if snapshotErr != nil {
|
||||
return TaskPayload{}, snapshotErr
|
||||
}
|
||||
imageSearch, _ = json.Marshal(snapshot.ImageSearchImage)
|
||||
if !json.Valid(imageSearch) {
|
||||
return TaskPayload{}, internalError(errors.New("stored image search snapshot is invalid JSON"))
|
||||
}
|
||||
}
|
||||
timeout := service.TaskTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultTaskTimeout
|
||||
@@ -295,6 +308,7 @@ func (service *Service) payload(record models.CollectionTask, replayed bool) (Ta
|
||||
URLSnapshot: record.URLSnapshot, GoodsIDSnapshot: record.GoodsIDSnapshot,
|
||||
Source: record.Source, ReplacementOriginType: pointerValue(record.ReplacementOriginType),
|
||||
RuleID: record.RuleID, RuleSnapshot: rule, TimeoutSeconds: timeout,
|
||||
ImageSearch: imageSearch,
|
||||
LeaseExpiresAt: record.LeaseExpiresAt, LeaseVersion: record.LeaseVersion,
|
||||
Status: record.Status, Replayed: replayed,
|
||||
}, nil
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateImageSearchCollection)
|
||||
}
|
||||
|
||||
// #278 permits image-search tasks while preserving existing task rows.
|
||||
func migrateImageSearchCollection(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if !tx.Migrator().HasColumn(&models.CollectionTask{}, "ImageSearchSnapshot") {
|
||||
if err := tx.Migrator().AddColumn(&models.CollectionTask{}, "ImageSearchSnapshot"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tx.Dialector.Name() == "mysql" {
|
||||
// MySQL does not support ALTER CHECK in place. The migration runner
|
||||
// records this version once, so dropping/recreating the named check is
|
||||
// deterministic and does not trigger a whole-schema AutoMigrate.
|
||||
_ = tx.Exec("ALTER TABLE collection_task DROP CHECK ck_collection_task_source").Error
|
||||
if err := tx.Exec("ALTER TABLE collection_task ADD CONSTRAINT ck_collection_task_source CHECK (source IN ('admin','agent_current_page','image_search'))").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
goautomigrations "go-admin/app/goauto/migrations"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateImageSearchLinkMarker)
|
||||
}
|
||||
|
||||
// #279 stores whether the current Shopee→PDD link was created by image search.
|
||||
func migrateImageSearchLinkMarker(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := goautomigrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import request from '@/utils/request'
|
||||
export function listCollectionTasks(params) { return request({ url: '/api/admin/v1/collection-tasks', method: 'get', params }) }
|
||||
export function createCollectionTask(data) { return request({ url: '/api/admin/v1/collection-tasks', method: 'post', data }) }
|
||||
export function batchCreateCollectionTasks(data) { return request({ url: '/api/admin/v1/collection-tasks/batch', method: 'post', data }) }
|
||||
export function batchCreateImageSearchCollectionTasks(data) { return request({ url: '/api/admin/v1/collection-tasks/image-search/batch', method: 'post', data }) }
|
||||
export function getCollectionTask(taskId) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}`, method: 'get' }) }
|
||||
export function resetCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}/reset`, method: 'post', data }) }
|
||||
export function deleteCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}`, method: 'delete', data }) }
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<template #default><el-button link type="primary" @click="reload">重新加载</el-button></template>
|
||||
</el-alert>
|
||||
<template v-if="detail.product">
|
||||
<div class="drawer-actions"><el-tag :type="mappingMeta(detail.product).type">{{ mappingMeta(detail.product).label }}</el-tag><el-button type="primary" :disabled="detail.aiMatching" @click="openEdit">编辑档案</el-button></div>
|
||||
<div class="drawer-actions"><div class="drawer-status"><el-tag :type="mappingMeta(detail.product).type">{{ mappingMeta(detail.product).label }}</el-tag><el-tag v-if="detail.product.imageSearchLinked" type="success" size="small">图搜自动关联</el-tag></div><el-button type="primary" :disabled="detail.aiMatching" @click="openEdit">编辑档案</el-button></div>
|
||||
<el-descriptions :column="2" border :inert="detail.aiMatching">
|
||||
<el-descriptions-item label="虾皮商品ID">{{ detail.product.shopeeItemId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="店铺">{{ detail.product.shopName || '未填写' }}</el-descriptions-item>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search"><el-form-item><el-button type="primary" :icon="Plus" @click="openCreate">添加</el-button></el-form-item><el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="虾皮商品ID、标题或店铺" clearable @keyup.enter="search" /></el-form-item><el-form-item label="范围"><el-select v-model="query.status" style="width:150px"><el-option label="全部(不含已删除)" value="" /><el-option label="已删除" value="deleted" /></el-select></el-form-item><el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item><el-form-item><el-button :icon="Delete" type="danger" plain :disabled="selectedProducts.length === 0" @click="openBatchDelete">删除{{ selectedProducts.length ? ` (${selectedProducts.length})` : '' }}</el-button></el-form-item><el-form-item v-if="isAdmin"><el-button type="success" plain :loading="autoMatchRun.submitting" :disabled="autoMatchRun.latest?.status === 'running'" @click="startAutoMatchRun">{{ autoMatchRun.latest?.status === 'running' ? '自动匹配进行中' : '执行规格自动匹配' }}</el-button><span class="run-summary">{{ autoMatchRunSummary }}</span></el-form-item></el-form>
|
||||
<el-alert v-if="query.status === 'deleted'" title="当前显示已删除商品,可逐条恢复。恢复后原有 PDD 关联与规格映射保持不变。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无虾皮商品" @selection-change="handleSelectionChange"><el-table-column v-if="query.status !== 'deleted'" type="selection" width="48" /><el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column><el-table-column label="虾皮商品ID" prop="shopeeItemId" min-width="140" /><el-table-column label="标题 / 店铺" min-width="220"><template #default="{ row }"><div class="primary">{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '尚未填写店铺' }}</div></template></el-table-column><el-table-column label="售价" width="120"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column><el-table-column label="PDD 商品" min-width="160"><template #default="{ row }"><a v-if="row.pddProductId" class="link" href="javascript:void(0)" @click="openPddDetail(row.pddProductId)">PDD-{{ row.pddProductId }} ↗</a><span v-else class="muted">未关联</span></template></el-table-column><el-table-column label="映射状态" min-width="180"><template #default="{ row }"><el-tag :type="mappingMeta(row).type">{{ mappingMeta(row).label }}</el-tag></template></el-table-column><el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="row.deleted" type="primary" link @click="restore(row)">恢复</el-button><el-button v-else type="primary" link @click="openDetail(row.id)">详情</el-button></template></el-table-column></el-table>
|
||||
<div class="image-search-filter"><span class="muted">图搜关联</span><el-select v-model="query.imageSearchLinked" size="small" style="width:150px" @change="search"><el-option label="全部" :value="null" /><el-option label="仅图搜关联" :value="true" /><el-option label="非图搜关联" :value="false" /></el-select></div>
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无虾皮商品" @selection-change="handleSelectionChange"><el-table-column v-if="query.status !== 'deleted'" type="selection" width="48" /><el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column><el-table-column label="虾皮商品ID" prop="shopeeItemId" min-width="140" /><el-table-column label="标题 / 店铺" min-width="220"><template #default="{ row }"><div class="primary">{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '尚未填写店铺' }}</div></template></el-table-column><el-table-column label="售价" width="120"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column><el-table-column label="PDD 商品" min-width="160"><template #default="{ row }"><a v-if="row.pddProductId" class="link" href="javascript:void(0)" @click="openPddDetail(row.pddProductId)">PDD-{{ row.pddProductId }} ↗</a><span v-else class="muted">未关联</span></template></el-table-column><el-table-column label="图搜" width="90"><template #default="{ row }"><el-tag v-if="row.imageSearchLinked" type="success" size="small">已自动关联</el-tag><span v-else class="muted">—</span></template></el-table-column><el-table-column label="映射状态" min-width="180"><template #default="{ row }"><el-tag :type="mappingMeta(row).type">{{ mappingMeta(row).label }}</el-tag></template></el-table-column><el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="row.deleted" type="primary" link @click="restore(row)">恢复</el-button><el-button v-else type="primary" link @click="openDetail(row.id)">详情</el-button></template></el-table-column></el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" /><p class="scope-note">本页不支持按虾皮订单号搜索——商品档案不含订单数据;订单相关字段在 SYB 商品模块与采购任务模块查看。</p>
|
||||
</el-card>
|
||||
|
||||
@@ -29,7 +30,7 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoShopeeProducts', components: { ShopeeProductDetailDrawer, PddProductDetailDrawer }, setup() { return { Plus, RefreshLeft, Search, Delete } },
|
||||
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, createDialog: { open: false, saving: false }, createData: this.emptyCreate(), createRules: { shopeeItemId: [{ required: true, message: '请输入虾皮商品ID', trigger: 'blur' }, { max: 64, message: '不能超过 64 个字符', trigger: 'blur' }] }, quickColor: '', quickSize: '', colorValues: [], sizeValues: [], pddPicker: { open: false, loading: false, keyword: '', items: [] }, batchDelete: this.emptyBatchDelete(), detail: { open: false, productId: null, targetColor: '', action: '' }, pddDetail: { open: false, productId: null }, autoMatchRun: { submitting: false, latest: null }, autoMatchPollTimer: null } },
|
||||
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '', imageSearchLinked: null }, createDialog: { open: false, saving: false }, createData: this.emptyCreate(), createRules: { shopeeItemId: [{ required: true, message: '请输入虾皮商品ID', trigger: 'blur' }, { max: 64, message: '不能超过 64 个字符', trigger: 'blur' }] }, quickColor: '', quickSize: '', colorValues: [], sizeValues: [], pddPicker: { open: false, loading: false, keyword: '', items: [] }, batchDelete: this.emptyBatchDelete(), detail: { open: false, productId: null, targetColor: '', action: '' }, pddDetail: { open: false, productId: null }, autoMatchRun: { submitting: false, latest: null }, autoMatchPollTimer: null } },
|
||||
computed: {
|
||||
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
|
||||
autoMatchRunSummary() { const run = this.autoMatchRun.latest; if (!run) return '最近一次:暂无'; if (run.status === 'running') return `最近一次:运行中,已处理 ${run.processedCount || 0}`; const status = run.status === 'completed' ? '完成' : run.status === 'completed_partial' ? '部分完成' : '失败'; return `最近一次:${status},处理 ${run.processedCount || 0},确认 ${run.confirmedCount || 0},未匹配 ${run.unmatchedCount || 0}` }
|
||||
@@ -53,3 +54,4 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
.page-card{min-height:calc(100vh - 124px)}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.run-summary{margin-left:10px;font-size:12px;color:#606266}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.thumb{width:48px;height:48px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.link{color:#1677ff;cursor:pointer}.scope-note{margin-top:12px;font-size:12px;color:#b91c1c}.notice{margin-bottom:16px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.spec-quick-add{display:flex;flex-direction:column;gap:8px;width:100%}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.chips{display:flex;flex-wrap:wrap;gap:6px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.create-help{margin-left:8px}@media(max-width:768px){.form-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
|
||||
<el-button v-if="canPurchase" type="success" plain :disabled="imageSearchRows.length === 0" @click="openImageSearchBatch">图搜采集 <span class="action-count">{{ imageSearchRows.length }}</span></el-button>
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
|
||||
<el-table-column v-if="canPurchase" type="selection" width="48" :selectable="rowSelectable" />
|
||||
<el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column>
|
||||
@@ -115,6 +116,12 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量创建 PDD 采集任务 -->
|
||||
<el-dialog v-model="imageSearchBatch.open" :title="imageSearchBatch.step === 'confirm' ? '批量图搜采集' : '图搜采集结果'" width="760px" :close-on-click-modal="false" @closed="resetImageSearchBatch">
|
||||
<template v-if="imageSearchBatch.step === 'confirm'"><el-alert title="每个蝦皮商品只创建一个图搜采集任务;参考图由服务端校验,已关联商品默认跳过。" type="info" :closable="false" show-icon class="notice" /><el-form label-position="top"><el-form-item label="采集规则"><el-select v-model="imageSearchBatch.ruleId" filterable style="width:100%" placeholder="请选择采集规则"><el-option v-for="item in imageSearchBatch.options.rules" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="Android 设备"><el-select v-model="imageSearchBatch.deviceId" clearable style="width:100%" placeholder="不指定,由支持图搜的空闲设备领取"><el-option v-for="item in imageSearchBatch.options.devices" :key="item.id" :label="`${item.name} · ${item.model}`" :value="item.id" /></el-select></el-form-item><el-checkbox v-model="imageSearchBatch.overwriteLinked">覆盖已有关联</el-checkbox></el-form><el-table :data="imageSearchBatch.rows" border size="small" max-height="300"><el-table-column label="订单号" prop="orderCode" width="160" /><el-table-column label="蝦皮商品" prop="shopeeItemId" min-width="180" /><el-table-column label="参考图" min-width="260"><template #default="{ row }"><el-image :src="row.imageUrl" fit="cover" style="width:48px;height:48px" /><span class="muted">{{ row.imageUrl }}</span></template></el-table-column></el-table></template>
|
||||
<template v-else><el-alert :title="`完成:成功 ${imageSearchBatch.successCount},失败 ${imageSearchBatch.failureCount},跳过 ${imageSearchBatch.skippedCount}`" :type="imageSearchBatch.failureCount ? 'warning' : 'success'" :closable="false" show-icon class="notice" /><el-table :data="imageSearchBatch.results" border size="small" max-height="340"><el-table-column label="蝦皮商品" prop="shopeeProductId" width="130" /><el-table-column label="结果" width="100"><template #default="{ row }"><el-tag :type="row.success ? 'success' : row.code === 'IMAGE_SEARCH_ALREADY_LINKED' ? 'info' : 'danger'">{{ row.success ? '已创建' : row.code === 'IMAGE_SEARCH_ALREADY_LINKED' ? '已跳过' : '失败' }}</el-tag></template></el-table-column><el-table-column label="任务 / 原因" min-width="260"><template #default="{ row }">{{ row.success ? `任务 #${row.taskId}` : row.message }}</template></el-table-column></el-table></template>
|
||||
<template #footer><el-button @click="imageSearchBatch.open = false">{{ imageSearchBatch.step === 'confirm' ? '取消' : '关闭' }}</el-button><el-button v-if="imageSearchBatch.step === 'confirm'" type="primary" :loading="imageSearchBatch.saving" :disabled="!imageSearchBatch.ruleId || !imageSearchBatch.rows.length" @click="submitImageSearchBatch">创建图搜任务</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="collectionBatch.open" :title="collectionBatch.step === 'confirm' ? '批量创建 PDD 采集任务' : '批量创建结果'" width="780px" :close-on-click-modal="false" @closed="resetCollectionBatch">
|
||||
<template v-if="collectionBatch.step === 'confirm'">
|
||||
<el-alert title="每个去重后的 PDD 商品创建一个独立采集任务,不会创建采购任务或订单。" type="info" :closable="false" show-icon class="notice" />
|
||||
@@ -219,7 +226,7 @@ import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct }
|
||||
import { listSybShops } from '@/api/goauto/syb-shops'
|
||||
import { listDevices } from '@/api/goauto/devices'
|
||||
import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
|
||||
import { batchCreateCollectionTasks } from '@/api/goauto/collection-tasks'
|
||||
import { batchCreateCollectionTasks, batchCreateImageSearchCollectionTasks } from '@/api/goauto/collection-tasks'
|
||||
import { listCollectionRules } from '@/api/goauto/collection-rules'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference'
|
||||
@@ -242,6 +249,7 @@ export default {
|
||||
purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
|
||||
specMatchResult: { open: false, items: [], selectedIDs: [], autoConfirmedCount: 0, pendingCount: 0, failedCount: 0, skippedCount: 0 },
|
||||
collectionBatch: this.emptyCollectionBatch(),
|
||||
imageSearchBatch: this.emptyImageSearchBatch(),
|
||||
collectionBatchData: { ruleId: null, deviceId: null },
|
||||
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
|
||||
query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
|
||||
@@ -266,6 +274,7 @@ export default {
|
||||
purchaseCandidates() { return this.selectedProducts.filter(row => this.isPurchaseCandidate(row)) },
|
||||
collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) },
|
||||
collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] },
|
||||
imageSearchRows() { return this.selectedProducts.filter(row => row.shopeeProductId && row.imageUrl) },
|
||||
firstCreatedTaskId() { return this.purchaseResult.items.find(item => item.taskId)?.taskId || null },
|
||||
formattedRaw() {
|
||||
if (!this.detail.item) return ''
|
||||
@@ -459,6 +468,28 @@ export default {
|
||||
|
||||
// ---------------- 批量创建 PDD 采集任务 ----------------
|
||||
emptyCollectionBatch() { return { open: false, saving: false, step: 'confirm', products: [], options: { rules: [], devices: [] }, results: [], successCount: 0, failureCount: 0 } },
|
||||
emptyImageSearchBatch() { return { open: false, saving: false, step: 'confirm', rows: [], options: { rules: [], devices: [] }, ruleId: null, deviceId: null, overwriteLinked: false, results: [], successCount: 0, failureCount: 0, skippedCount: 0 } },
|
||||
async openImageSearchBatch() {
|
||||
if (!this.imageSearchRows.length) return
|
||||
this.imageSearchBatch = { ...this.emptyImageSearchBatch(), open: true, rows: [...this.imageSearchRows], deviceId: readPurchaseDevice(this.$store.getters.userId) }
|
||||
const [rules, devices] = await Promise.all([listCollectionRules({ page: 1, pageSize: 100 }), listDevices({ page: 1, pageSize: 100 })])
|
||||
this.imageSearchBatch.options = { rules: rules.data.items || [], devices: devices.data.items || [] }
|
||||
},
|
||||
resetImageSearchBatch() { this.imageSearchBatch = this.emptyImageSearchBatch() },
|
||||
async submitImageSearchBatch() {
|
||||
if (this.imageSearchBatch.saving || !this.imageSearchBatch.ruleId) return
|
||||
this.imageSearchBatch.saving = true
|
||||
try {
|
||||
if (this.imageSearchBatch.deviceId) rememberPurchaseDevice(this.$store.getters.userId, this.imageSearchBatch.deviceId)
|
||||
const response = await batchCreateImageSearchCollectionTasks({ requestId: createRequestId(), sybProductIds: this.imageSearchBatch.rows.map(row => row.id), ruleId: this.imageSearchBatch.ruleId, deviceId: this.imageSearchBatch.deviceId || null, overwriteLinked: this.imageSearchBatch.overwriteLinked })
|
||||
this.imageSearchBatch.results = response.data.items || []
|
||||
this.imageSearchBatch.successCount = response.data.successCount || 0
|
||||
this.imageSearchBatch.failureCount = response.data.failureCount || 0
|
||||
this.imageSearchBatch.skippedCount = response.data.skippedCount || 0
|
||||
this.imageSearchBatch.step = 'result'
|
||||
await this.load()
|
||||
} finally { this.imageSearchBatch.saving = false }
|
||||
},
|
||||
collectionBatchProducts() {
|
||||
const products = new Map()
|
||||
for (const row of this.collectionCandidateRows) {
|
||||
@@ -575,3 +606,4 @@ export default {
|
||||
@media(max-width:1280px){.search-form{flex-wrap:wrap}}
|
||||
@media(max-width:900px){.split{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user