Compare commits

..
Author SHA1 Message Date
QiuSW a16e6920b7 docs(#277): record sanitized pdd home evidence 2026-09-15 10:07:27 +08:00
QiuSW def0a86f63 fix(#277): fail image search tasks explicitly until automation is available 2026-09-15 09:59:56 +08:00
QiuSW ba7d2a6b72 feat(#280): remember image search device 2026-09-15 09:57:17 +08:00
QiuSW a3cf98940a fix(#279): only auto-link active pdd products 2026-09-15 09:51:39 +08:00
QiuSW 56706a0b45 fix(#280): show image search association marker 2026-09-15 09:45:50 +08:00
QiuSW a4874091a8 feat: prepare validated image search media asset (#277) 2026-09-15 09:42:25 +08:00
QiuSW 9e0dcc254e feat: parse image search task metadata in agent (#277) 2026-09-15 09:40:51 +08:00
QiuSW cda1978bc6 test: prevent image snapshot payload leakage (#278) 2026-09-15 09:37:01 +08:00
QiuSW c80d0c91d5 feat: add image search batch dialog to syb products (#280) 2026-09-15 09:07:45 +08:00
QiuSW be491c7692 feat: expose image search batch API to web (#280) 2026-09-15 09:02:08 +08:00
QiuSW 90c1668fe3 test: cover image search auto-link guards (#279) 2026-09-15 09:01:10 +08:00
QiuSW 804448ede0 feat: show image search link marker in shopee detail (#279) 2026-09-15 08:59:37 +08:00
QiuSW 5a7e219230 feat: add image-search linked filter to shopee list (#279) 2026-09-15 08:58:35 +08:00
QiuSW 36d1f82ce2 feat: filter image-search linked shopee products (#279) 2026-09-15 08:53:37 +08:00
QiuSW 1c998911be feat: auto link completed image search results (#279) 2026-09-15 08:50:19 +08:00
QiuSW 45437a9214 feat: record cross-currency image search price guard (#278) 2026-09-15 08:44:39 +08:00
QiuSW 42b5312198 feat: support image search identity handoff and safe payload (#278) 2026-09-15 08:42:11 +08:00
QiuSW 7b540f3163 feat: create and dispatch image search tasks (#278) 2026-09-15 08:41:42 +08:00
QiuSW ffa5fc0710 fix: add idempotent image snapshot column migration (#278) 2026-09-14 18:02:22 +08:00
QiuSW 66ab36c88c feat: expose image search snapshot in task payload (#278) 2026-09-14 16:49:35 +08:00
QiuSW 35b69fc25a fix: narrow image search migration (#278) 2026-09-14 16:31:07 +08:00
QiuSW b4dc7fc9ae feat: track image search links and preserve manual overrides (#279) 2026-09-14 16:08:55 +08:00
QiuSW baa9408a36 feat: allow image search collection task source (#278) 2026-09-14 16:05:01 +08:00
QiuSW e01fcd9860 feat: request media permission for image search (#276) 2026-09-14 16:03:21 +08:00
QiuSW be03972779 merge: #283 purchase readiness consistency 2026-09-14 14:48:13 +08:00
25 changed files with 755 additions and 14 deletions
+2
View File
@@ -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
}
}
@@ -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(
+8
View File
@@ -0,0 +1,8 @@
# PDD 首页控件摘要(设备 192.168.0.9:35727)
- 采集时间:2026-09-15
- 包名:`com.xunmeng.pinduoduo`
- 发现语义入口:`content-desc=拍照搜索`
- 发现搜索入口:`content-desc=搜索`
- 结论:PDD 首页存在稳定的“拍照搜索”无障碍语义入口,可作为 #277 的第一步入口探测。
- 未保存坐标、完整控件树、截图、账号或个人数据。
+1
View File
@@ -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},
+1
View File
@@ -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},
+5 -2
View File
@@ -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)
+6 -2
View File
@@ -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)
+19
View File
@@ -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 {
+2 -2
View File
@@ -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 {
+373
View File
@@ -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(&current, 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)
}
}
+3
View File
@@ -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
+1
View File
@@ -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)
+18 -4
View File
@@ -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
})
}
+1
View File
@@ -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>
+33 -1
View File
@@ -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>