feat(syb): link latest device collection and match specs #253

This commit is contained in:
QiuSW
2026-09-10 11:34:22 +08:00
parent 08d939a9b8
commit b1594dc7bd
10 changed files with 720 additions and 11 deletions
+7 -1
View File
@@ -96,6 +96,12 @@ func (handler Handler) LinkPDD(c *gin.Context) {
writeError(c, invalidRequest("请求 JSON 无效"))
return
}
// This composite workflow reads SYB and device context. It must not inherit
// a client key's Shopee-only write grant through the shared handler.
if request.LatestCollection != nil && !strings.HasPrefix(c.FullPath(), "/api/admin/v1/") {
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "一键关联仅限 Admin 入口"})
return
}
service, ok := handler.service(c)
if !ok {
return
@@ -467,7 +473,7 @@ func writeError(c *gin.Context, err error) {
status = http.StatusConflict
case CodeProductNotFound, CodePDDProductNotFound, CodeMappingNotFound, CodeValueNotFound:
status = http.StatusNotFound
case CodePDDProductDisabled, CodeSpecContextStale:
case CodePDDProductDisabled, CodeSpecContextStale, CodeLatestCollectionUnavailable, CodeLinkConflict:
status = http.StatusConflict
case CodeAIUnavailable:
status = http.StatusServiceUnavailable
@@ -0,0 +1,126 @@
package shopeeproduct
import (
"context"
"errors"
"go-admin/app/goauto/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const CodeLatestCollectionUnavailable = "LATEST_COLLECTION_UNAVAILABLE"
const CodeLinkConflict = "PDD_LINK_CONFLICT"
type LatestCollectionRequest struct {
SYBProductID uint64 `json:"sybProductId"`
DeviceID uint64 `json:"deviceId"`
SpecContextVersion string `json:"specContextVersion"`
}
type LatestCollectionSource struct {
TaskID uint64 `json:"taskId"`
DeviceID uint64 `json:"deviceId"`
PDDProductID uint64 `json:"pddProductId"`
RequiresSpecConfirmation bool `json:"requiresSpecConfirmation"`
}
func latestUnavailable(message string) error {
return &ServiceError{Code: CodeLatestCollectionUnavailable, Message: message}
}
// Resolve the candidate exactly once under short row locks; never hold a
// transaction while calling AI. The returned context belongs to this write,
// not a later GET that could silently adopt another operator's association.
func (service *Service) linkLatestCollection(ctx context.Context, id uint64, request LinkPDDRequest) (SaveResponse, error) {
r := request.LatestCollection
if request.PDDProductID != 0 || r.DeviceID == 0 || r.SYBProductID == 0 || r.SpecContextVersion == "" {
return SaveResponse{}, invalidRequest("一键关联需要 SYB、设备及规格版本,不能同时指定 PDD 商品")
}
var response SaveResponse
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var syb models.SYBProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, r.SYBProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("SYB 明细不存在,请刷新列表")
}
return err
}
if syb.ShopeeProductID == nil || *syb.ShopeeProductID != id {
return contextVersionStale()
}
var shopee models.ShopeeProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shopee, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return productNotFound()
}
return err
}
if shopee.PDDProductID != nil {
return &ServiceError{Code: CodeLinkConflict, Message: "商品已有关联,请刷新后查看;一键关联不会覆盖"}
}
if computeSpecContextVersion(nil, shopee.SpecsJSON, "") != r.SpecContextVersion {
return contextVersionStale()
}
var device models.AgentDevice
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, r.DeviceID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("手机已不存在,请重新选择")
}
return err
}
if device.Status != models.DeviceStatusOnline && device.Status != models.DeviceStatusOffline {
return latestUnavailable("手机已停用,请重新选择")
}
var task models.CollectionTask
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("device_id = ? AND source = ? AND status IN ?", r.DeviceID, models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}).Order("finished_at DESC, id DESC").First(&task).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("这台手机暂无成功的临时采集,请先采集商品")
}
if err != nil {
return err
}
if task.Status != models.TaskStatusCompleted || task.FinishedAt == nil || task.PDDProductID == nil {
return latestUnavailable("最近一次临时采集不完整,请先补采;不会回退关联更早的商品")
}
var pdd models.PDDProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pdd, *task.PDDProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return latestUnavailable("最近采集的 PDD 商品已不存在")
}
return err
}
if pdd.Status != "active" {
return latestUnavailable("最近采集的 PDD 商品尚不可用,请先检查采集结果")
}
color, err := selectablePDDValues(pdd.SpecsJSON, RoleColor)
if err != nil {
return latestUnavailable("最近采集的 PDD 规格无效,请先补采")
}
size, err := selectablePDDValues(pdd.SpecsJSON, RoleSize)
if err != nil || len(color)+len(size) == 0 {
return latestUnavailable("最近采集的 PDD 缺少可用规格,请先补采")
}
result := tx.Model(&models.ShopeeProduct{}).Where("id = ? AND pdd_product_id IS NULL", id).Updates(map[string]any{"pdd_product_id": pdd.ID, "last_update_request_id": request.RequestID})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return contextVersionStale()
}
response, err = NewService(tx).Detail(ctx, id)
if err != nil {
return err
}
response.CollectionSource = &LatestCollectionSource{TaskID: task.ID, DeviceID: device.ID, PDDProductID: pdd.ID, RequiresSpecConfirmation: syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed}
return nil
})
if err != nil {
var domain *ServiceError
if errors.As(err, &domain) {
return SaveResponse{}, err
}
return SaveResponse{}, internalError(err)
}
return response, nil
}
@@ -0,0 +1,194 @@
package shopeeproduct
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func latestFixture(t *testing.T) (*Service, uint64, LinkPDDRequest, models.CollectionTask) {
t.Helper()
db := openTestDB(t)
device := models.AgentDevice{InstallID: uuid.NewString(), TokenDigest: uuid.NewString(), Status: models.DeviceStatusOffline}
if err := db.Create(&device).Error; err != nil {
t.Fatal(err)
}
rule := models.CollectionRule{Name: "synthetic", ContentJSON: "{}"}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
pdd := seedPDDProduct(t, db, "active")
s := NewService(db)
created, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: uuid.NewString(), Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "黑色", Source: ValueSourceManual}}}}})
if err != nil {
t.Fatal(err)
}
syb := models.SYBProduct{OrderCode: "synthetic", DetailID: 1, Quantity: 1, ParseStatus: "success", ShopeeProductID: &created.Product.ID}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
task := models.CollectionTask{DeviceID: &device.ID, RuleID: rule.ID, PDDProductID: &pdd.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted, RuleSnapshot: "{}", FinishedAt: &now}
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
return s, created.Product.ID, LinkPDDRequest{RequestID: uuid.NewString(), LatestCollection: &LatestCollectionRequest{DeviceID: device.ID, SYBProductID: syb.ID, SpecContextVersion: created.Product.SpecContextVersion}}, task
}
func TestLatestCollectionOfflineAndOnlyIfUnlinked(t *testing.T) {
s, id, req, task := latestFixture(t)
result, err := s.LinkPDD(context.Background(), id, req)
if err != nil {
t.Fatal(err)
}
if result.CollectionSource.TaskID != task.ID || *result.Product.PDDProductID != *task.PDDProductID || result.Product.SpecContextVersion == req.LatestCollection.SpecContextVersion {
t.Fatalf("invalid frozen response: %+v", result)
}
// Another request (or an ambiguous network retry) cannot overwrite the link.
req.RequestID = uuid.NewString()
if _, err := s.LinkPDD(context.Background(), id, req); err == nil || errCode(t, err) != CodeLinkConflict {
t.Fatalf("expected conflict: %v", err)
}
other := seedPDDProduct(t, s.DB, "active")
if _, err := s.LinkPDD(context.Background(), id, LinkPDDRequest{RequestID: uuid.NewString(), PDDProductID: other.ID}); err != nil {
t.Fatalf("manual replacement regressed: %v", err)
}
}
func TestLatestCollectionRejectsInvalidContextWithoutWrite(t *testing.T) {
cases := []struct {
name string
change func(*gorm.DB, *LinkPDDRequest, models.CollectionTask)
}{
{"partial", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&task).Update("status", "completed_partial")
}},
{"disabled-device", func(db *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
db.Model(&models.AgentDevice{}).Where("id = ?", r.LatestCollection.DeviceID).Update("status", "disabled")
}},
{"missing-device", func(_ *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) { r.LatestCollection.DeviceID += 100 }},
{"admin-source", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&task).Update("source", "admin")
}},
{"disabled-pdd", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("status", "disabled")
}},
{"empty-specs", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", "[]")
}},
{"stale-version", func(_ *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
r.LatestCollection.SpecContextVersion = "stale"
}},
{"syb-link-changed", func(db *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
db.Model(&models.SYBProduct{}).Where("id = ?", r.LatestCollection.SYBProductID).Update("shopee_product_id", nil)
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s, id, r, task := latestFixture(t)
tc.change(s.DB, &r, task)
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
t.Fatal("unexpected successful write")
}
var product models.ShopeeProduct
s.DB.First(&product, id)
if product.PDDProductID != nil {
t.Fatal("rejected request wrote association")
}
})
}
}
func TestLatestCollectionOrdersByFinishThenIDAndDoesNotSkipPartial(t *testing.T) {
s, id, r, first := latestFixture(t)
newer := first
newer.ID = 0
newer.Status = models.TaskStatusCompletedPartial
if err := s.DB.Create(&newer).Error; err != nil {
t.Fatal(err)
}
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
t.Fatal("skipped latest partial")
}
// A newer ID with an older completion time must not win.
older := newer
older.ID = 0
older.Status = models.TaskStatusCompleted
ts := first.FinishedAt.Add(-time.Hour)
older.FinishedAt = &ts
if err := s.DB.Create(&older).Error; err != nil {
t.Fatal(err)
}
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
t.Fatal("sorted by id instead of completion")
}
if err := s.DB.Model(&newer).Update("status", models.TaskStatusCompleted).Error; err != nil {
t.Fatal(err)
}
result, err := s.LinkPDD(context.Background(), id, r)
if err != nil || result.CollectionSource.TaskID != newer.ID {
t.Fatalf("wrong latest result: %+v %v", result, err)
}
}
func TestLatestCollectionKeepsParseConfirmationBoundary(t *testing.T) {
for _, status := range []string{"success", "uncertain", "failed"} {
t.Run(status, func(t *testing.T) {
s, id, r, _ := latestFixture(t)
s.DB.Model(&models.SYBProduct{}).Where("id = ?", r.LatestCollection.SYBProductID).Update("parse_status", status)
result, err := s.LinkPDD(context.Background(), id, r)
if err != nil || result.CollectionSource.RequiresSpecConfirmation != (status == "failed") {
t.Fatalf("parse boundary: %+v %v", result, err)
}
})
}
}
func TestClientKeyCannotUseAdminCompositeLink(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/api/client/v1/shopee-products/:productId/link-pdd", Handler{}.LinkPDD)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/client/v1/shopee-products/1/link-pdd", strings.NewReader(`{"latestCollection":{"deviceId":1,"sybProductId":1,"specContextVersion":"x"}}`)))
if w.Code != http.StatusForbidden {
t.Fatalf("unexpected status: %d %s", w.Code, w.Body.String())
}
}
// SQLite has no row-level FOR UPDATE. A single test connection serializes
// transactions to exercise concurrent callers; production MySQL locking must
// additionally be verified in its deployment environment.
func TestLatestCollectionConcurrentCallersOnlyOneWins(t *testing.T) {
s, id, r, _ := latestFixture(t)
sqlDB, err := s.DB.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
errors := make(chan error, 2)
start := make(chan struct{})
for range 2 {
go func() { <-start; _, err := s.LinkPDD(context.Background(), id, r); errors <- err }()
}
close(start)
wins := 0
for range 2 {
err := <-errors
if err == nil {
wins++
} else if errCode(t, err) != CodeLinkConflict {
t.Fatal(err)
}
}
if wins != 1 {
t.Fatalf("expected one write, got %d", wins)
}
}
+10 -5
View File
@@ -76,9 +76,10 @@ type ProductView struct {
}
type SaveResponse struct {
Product ProductView `json:"product"`
Replayed bool `json:"replayed,omitempty"`
AutoMatchTimeoutSeconds int `json:"autoMatchTimeoutSeconds,omitempty"`
Product ProductView `json:"product"`
Replayed bool `json:"replayed,omitempty"`
AutoMatchTimeoutSeconds int `json:"autoMatchTimeoutSeconds,omitempty"`
CollectionSource *LatestCollectionSource `json:"collectionSource,omitempty"`
}
type ListItemView struct {
@@ -273,8 +274,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
// ---------------------------------------------------------------- link PDD
type LinkPDDRequest struct {
RequestID string `json:"requestId"`
PDDProductID uint64 `json:"pddProductId"`
RequestID string `json:"requestId"`
PDDProductID uint64 `json:"pddProductId"`
LatestCollection *LatestCollectionRequest `json:"latestCollection,omitempty"`
}
// LinkPDD sets or changes the linked PDD product via the search-and-select
@@ -285,6 +287,9 @@ func (service *Service) LinkPDD(ctx context.Context, id uint64, request LinkPDDR
if _, err := uuid.Parse(strings.TrimSpace(request.RequestID)); err != nil {
return SaveResponse{}, invalidRequest("requestId 必须是 UUID")
}
if request.LatestCollection != nil {
return service.linkLatestCollection(ctx, id, request)
}
if request.PDDProductID == 0 {
return SaveResponse{}, invalidRequest("pddProductId 必填")
}
+2 -2
View File
@@ -16,8 +16,8 @@ export function updateShopeeProduct(productId, data) {
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'patch', data })
}
export function linkShopeeProductPdd(productId, data) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/link-pdd`, method: 'post', data })
export function linkShopeeProductPdd(productId, data, options = {}) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/link-pdd`, method: 'post', data, ...options })
}
export function restoreShopeeProduct(productId, data) {
@@ -0,0 +1,24 @@
// Separate from the purchase-device preference; browser origin provides the
// outer isolation and API environment + user identify the inner namespace.
function key(userId) {
const id = Number(userId)
return Number.isSafeInteger(id) && id > 0 ? `goauto:quick-link-device:${encodeURIComponent(process.env.VUE_APP_BASE_API || '/')}:user:${id}` : null
}
export function readQuickLinkDevice(userId) {
try {
const name = key(userId)
if (!name) return null
const id = JSON.parse(localStorage.getItem(name) || 'null')
return Number.isSafeInteger(id) && id > 0 ? id : null
} catch { return null }
}
export function rememberQuickLinkDevice(userId, deviceId) {
try {
const name = key(userId)
if (!name || !Number.isSafeInteger(deviceId) || deviceId <= 0) return false
localStorage.setItem(name, JSON.stringify(deviceId))
return true
} catch { return false }
}
+48 -3
View File
@@ -25,12 +25,54 @@
<el-table-column label="数量" prop="quantity" width="56" />
<el-table-column label="售价" width="110"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column>
<el-table-column label="解析状态" width="80"><template #default="{ row }"><el-tag :type="parseMeta(row.parseStatus).type">{{ parseMeta(row.parseStatus).label }}</el-tag><el-tag v-if="row.manuallyConfirmed" size="small" type="success" effect="plain" style="margin-left:4px">人工已确认</el-tag></template></el-table-column>
<el-table-column v-if="canPurchase" label="处理阶段" min-width="210"><template #default="{ row }"><span v-if="purchaseReadinessLoading" class="muted">正在检查</span><template v-else><el-tag :type="processMeta(purchaseReady(row).processStage).type">{{ purchaseReady(row).processStageLabel || processMeta(purchaseReady(row).processStage).label }}</el-tag><div class="purchase-reason">{{ purchaseReady(row).processStageReason || purchaseReady(row).reason || '请刷新后重试' }}</div><div v-if="purchaseReady(row).processStage === 'purchase_ready'" class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div><el-button v-if="purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction })" type="primary" link @click="runPurchaseNextAction(row, { ...purchaseReady(row), nextAction: purchaseReady(row).processNextAction })">{{ purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction }) }}</el-button></template></template></el-table-column>
<el-table-column v-if="canPurchase" label="处理阶段" min-width="210">
<template #default="{ row }">
<span v-if="purchaseReadinessLoading" class="muted">正在检查</span>
<template v-else>
<el-tag :type="processMeta(purchaseReady(row).processStage).type">{{ purchaseReady(row).processStageLabel || processMeta(purchaseReady(row).processStage).label }}</el-tag>
<div class="purchase-reason">{{ purchaseReady(row).processStageReason || purchaseReady(row).reason || '请刷新后重试' }}</div>
<div v-if="purchaseReady(row).processStage === 'purchase_ready'" class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div>
<div class="quick-link-actions">
<el-button v-if="purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction })" type="primary" link @click="runPurchaseNextAction(row, { ...purchaseReady(row), nextAction: purchaseReady(row).processNextAction })">{{ purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction }) }}</el-button>
<el-button-group v-if="quickCanLink(row)">
<el-button size="small" type="primary" plain :loading="quickBusyId === row.id" :disabled="!!quickBusyId" @click="quickStart(row)">一键关联</el-button>
<el-dropdown trigger="click" @command="quickOpenPicker()">
<el-button size="small" type="primary" plain aria-label="一键关联使用的手机" :icon="ArrowDown" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item disabled>当前手机:{{ quickDeviceLabel() }}</el-dropdown-item>
<el-dropdown-item command="switch">切换手机</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-button-group>
</div>
</template>
<div v-if="quickBusyId === row.id || quickMessages[row.id]" class="muted" aria-live="polite">{{ quickBusyId === row.id ? quickPhase : quickMessages[row.id] }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && isPurchaseCandidate(row)" type="primary" link @click="openSinglePurchase(row)">采购</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" />
</el-card>
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
<p>{{ quickPicker.row ? '选择后继续关联刚才的商品。' : '只修改各行共用的手机,不启动关联,也不影响正在进行的操作。' }}</p>
<el-form label-width="80px">
<el-form-item label="采集手机">
<el-select v-model="quickPicker.deviceId" :loading="quickDeviceLoading" placeholder="请选择手机" style="width:100%">
<el-option v-for="device in quickDevices" :key="device.id" :value="device.id" :label="`${device.name}(#${device.id} · ${device.status === 'online' ? '在线' : '离线'})`" />
</el-select>
</el-form-item>
</el-form>
<p class="muted">使用这台手机最近一次成功的临时采集;离线也可使用已有结果。关联会作用于共用该蝦皮商品的 SYB 明细。</p>
<p v-if="quickDeviceError" role="alert">{{ quickDeviceError }} <el-button link type="primary" @click="quickOpenPicker(quickPicker.row)">重试</el-button></p>
<template #footer>
<el-button @click="quickCancelPicker">取消</el-button>
<el-button type="primary" :disabled="quickDeviceLoading || !!quickDeviceError || !quickDevices.some(d => d.id === quickPicker.deviceId)" @click="quickSaveDevice">{{ quickPicker.row ? '选择并继续' : '保存选择' }}</el-button>
</template>
</el-dialog>
<!-- 批量创建采购任务确认 -->
<el-dialog v-model="purchaseDialog.open" title="批量创建采购任务" width="900px" :close-on-click-modal="false">
<div v-loading="purchaseDialog.loading">
@@ -171,7 +213,8 @@
<script>
import { ElMessage } from 'element-plus'
import { Search, RefreshLeft } from '@element-plus/icons-vue'
import { Search, RefreshLeft, ArrowDown } from '@element-plus/icons-vue'
import quickLink from './quick-link'
import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products'
import { listSybShops } from '@/api/goauto/syb-shops'
import { listDevices } from '@/api/goauto/devices'
@@ -186,7 +229,8 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
export default {
name: 'GoAutoSybProducts',
components: { ShopeeProductDetailDrawer, PddProductDetailDrawer },
setup() { return { Search, RefreshLeft } },
mixins: [quickLink],
setup() { return { Search, RefreshLeft, ArrowDown } },
data() {
return {
loading: false, products: [], selectedProducts: [], total: 0,
@@ -513,6 +557,7 @@ export default {
.selection-count strong{margin:0 4px;font-variant-numeric:tabular-nums;color:#1e40af}
.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}
.missing{color:#b91c1c}
.quick-link-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0}
.thumb{width:44px;height:44px;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}
.shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -0,0 +1,121 @@
import { ElMessage } from 'element-plus'
import { listDevices } from '@/api/goauto/devices'
import { getShopeeProduct, linkShopeeProductPdd, autoMatchShopeeSpecMappings } from '@/api/goauto/shopee-products'
import { previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
import { createRequestId } from '@/utils/request-id'
import { readQuickLinkDevice, rememberQuickLinkDevice } from '@/utils/quick-link-device-preference'
const readOptions = { timeout: 10000, suppressErrorMessage: true }
export default {
data() {
return {
quickDeviceId: null, quickDevices: [], quickDeviceError: '', quickDeviceLoading: false,
quickPicker: { open: false, deviceId: null, row: null },
quickBusyId: null, quickPhase: '', quickMessages: {}, quickGeneration: 0, quickPickerGeneration: 0
}
},
created() { this.quickDeviceId = readQuickLinkDevice(this.$store.getters.userId) },
beforeUnmount() { this.quickInvalidate() },
deactivated() { this.quickInvalidate() },
methods: {
quickInvalidate() {
this.quickGeneration++
this.quickPickerGeneration++
if (this.quickBusyId) this.quickMessages[this.quickBusyId] = '操作结果待确认,请刷新核对;离开页面不代表服务端已取消'
this.quickBusyId = null
this.quickPhase = ''
this.quickPicker.open = false
},
quickCanLink(row) {
const ready = this.purchaseReady(row)
return this.canPurchase && !!row.shopeeProductId && ready.processNextAction === 'open_pdd_link' && !ready.pddProductId
},
quickDeviceLabel() {
const device = this.quickDevices.find(d => d.id === this.quickDeviceId)
return device ? `${device.name}(#${device.id})` : this.quickDeviceId ? `手机 #${this.quickDeviceId}` : '尚未选择'
},
async quickOpenPicker(row = null) {
const generation = ++this.quickPickerGeneration
this.quickPicker = { open: true, deviceId: this.quickDeviceId, row: row ? { ...row } : null }
this.quickDeviceLoading = true
this.quickDeviceError = ''
try {
// Include offline phones: historical collection does not require a live phone.
const response = await listDevices({ page: 1, pageSize: 100 })
if (generation !== this.quickPickerGeneration) return
this.quickDevices = response.data.items.filter(d => d.status === 'online' || d.status === 'offline')
if (!this.quickDevices.length) this.quickDeviceError = '暂无可选手机,请先在设备列表中登记或启用手机'
} catch { if (generation === this.quickPickerGeneration) this.quickDeviceError = '手机列表加载失败,请重试' } finally { if (generation === this.quickPickerGeneration) this.quickDeviceLoading = false }
},
quickCancelPicker() { this.quickPickerGeneration++; this.quickPicker.open = false },
quickSaveDevice() {
const id = this.quickPicker.deviceId
if (this.quickDeviceLoading || this.quickDeviceError || !this.quickDevices.some(d => d.id === id)) return
const row = this.quickPicker.row
this.quickDeviceId = id
if (!rememberQuickLinkDevice(this.$store.getters.userId, id)) ElMessage.warning('本次选择已生效,但浏览器未能保存;下次可能需要重新选择')
this.quickCancelPicker()
if (row) this.quickStart(row)
},
async quickStart(row) {
if (!this.quickCanLink(row) || this.quickBusyId) return
if (!this.quickDeviceId) return this.quickOpenPicker(row)
const frozen = { sybProductId: row.id, productId: row.shopeeProductId, deviceId: this.quickDeviceId }
const generation = ++this.quickGeneration
const current = () => generation === this.quickGeneration
this.quickBusyId = row.id
this.quickMessages[row.id] = ''
this.quickPhase = '正在关联'
let wrote = false
try {
const detail = (await getShopeeProduct(frozen.productId, readOptions)).data
if (!current()) return
if (detail.product.pddProductId) throw new Error('该商品已经关联,请查看现有关联')
const linked = (await linkShopeeProductPdd(frozen.productId, {
requestId: createRequestId(),
latestCollection: { sybProductId: frozen.sybProductId, deviceId: frozen.deviceId, specContextVersion: detail.product.specContextVersion }
}, readOptions)).data
wrote = true
if (!current()) return
const ready = (await previewPurchaseTasks({ sybProductIds: [frozen.sybProductId] }, readOptions)).data.items[0]
if (!current()) return
if (linked.collectionSource.requiresSpecConfirmation || !ready?.aiMatchEligible) {
this.quickMessages[row.id] = ready?.eligible ? '已关联,当前明细可创建采购' : '已关联;请按处理阶段提示继续人工处理'
} else {
this.quickPhase = '正在匹配并保存'
const result = (await autoMatchShopeeSpecMappings(frozen.productId, {
requestId: createRequestId(), specContextVersion: linked.product.specContextVersion
}, detail.autoMatchTimeoutSeconds)).data
if (!current()) return
this.quickMessages[row.id] = result.unmatchedCount ? '已关联并保存有效匹配;剩余规格请去匹配处理' : '已关联并保存匹配;以最新处理阶段为准'
}
} catch (error) {
if (!current()) return
const reason = error?.response?.data?.message || error?.message || '请求失败'
// A definite validation/conflict rejection is not an unknown write.
if (!wrote && [403, 404, 409, 422].includes(error?.response?.status)) {
this.quickMessages[row.id] = reason
return
}
this.quickPhase = '正在核对结果'
try {
const saved = (await getShopeeProduct(frozen.productId, readOptions)).data.product
if (!current()) return
this.quickMessages[row.id] = saved.pddProductId
? `当前已有关联,匹配结果请去匹配查看。${reason}`
: `当前未查到关联,操作结果未确认,请刷新核对后再操作。${reason}`
} catch {
if (current()) this.quickMessages[row.id] = `${wrote ? '关联已保存;' : ''}结果未确认,请刷新核对,不要连续重复点击。${reason}`
}
} finally {
if (current()) {
this.quickBusyId = null
this.quickPhase = ''
// Read-only refresh, never automatically resubmit a failed write.
void this.loadPurchaseReadiness(this.products.map(item => item.id))
}
}
}
}
}
+99
View File
@@ -0,0 +1,99 @@
import { expect, test } from '@playwright/test'
async function setup(page: any) {
const state = { userId: 1, role: 'purchaser', links: [] as any[], matches: [] as any[], linked: new Set<number>(), matched: new Set<number>(), aiFailure: false }
await page.context().addCookies([{ name: 'Admin-Token', value: 'mock-token', domain: 'localhost', path: '/' }])
await page.route('**/api/**', async (route: any) => {
const url = new URL(route.request().url()), path = url.pathname
const ok = (data: any) => route.fulfill({ json: { code: 200, data } })
if (path.startsWith('/src/api/')) return route.continue()
if (path.endsWith('/getinfo')) return ok({ userId: state.userId, roles: [state.role], name: '合成测试', avatar: '', permissions: [] })
if (path.endsWith('/menurole')) return ok([{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }])
if (path.endsWith('/syb-products')) return ok({ items: [1, 2].map(id => ({ id, orderCode: `TEST-${id}`, shopeeItemId: `100${id}`, shopeeProductId: id + 10, productTitle: '测试商品', targetColor: '黑色', targetSize: 'XL', quantity: 1, parseStatus: 'success' })), total: 2 })
if (path.endsWith('/devices')) return ok({ items: [{ id: 10, name: '手机A', status: 'offline' }, { id: 20, name: '手机B', status: 'online' }], total: 2 })
if (path.endsWith('/batch-preview')) return ok({ items: route.request().postDataJSON().sybProductIds.map((id: number) => ({ sybProductId: id, pddProductId: state.linked.has(id) ? 55 : null, eligible: state.matched.has(id), aiMatchEligible: state.linked.has(id) && !state.matched.has(id), processStage: state.matched.has(id) ? 'purchase_ready' : state.linked.has(id) ? 'color_mapping' : 'pdd_unlinked', processNextAction: state.linked.has(id) ? 'open_mapping' : 'open_pdd_link' })) })
const match = path.match(/shopee-products\/(\d+)(.*)/)
if (match) {
const productId = Number(match[1]), id = productId - 10
const product = { id: productId, pddProductId: state.linked.has(id) ? 55 : null, specContextVersion: state.linked.has(id) ? 'after' : 'before', specs: [] }
if (match[2] === '/link-pdd') {
const body = route.request().postDataJSON(); state.links.push(body); state.linked.add(id)
return ok({ product: { ...product, pddProductId: 55, specContextVersion: 'after' }, collectionSource: { taskId: 7, deviceId: body.latestCollection.deviceId, pddProductId: 55 } })
}
if (match[2] === '/specs/mapping/auto-match') {
state.matches.push(route.request().postDataJSON())
if (state.aiFailure) return route.fulfill({ status: 503, json: { code: 'AI_MATCHING_UNAVAILABLE', message: 'AI 匹配服务暂时不可用' } })
state.matched.add(id); return ok({ product, unmatchedCount: 0 })
}
return ok({ product, autoMatchTimeoutSeconds: 370 })
}
return ok([])
})
await page.goto('/#/syb-products')
await expect(page.getByRole('button', { name: '一键关联', exact: true })).toHaveCount(2)
return state
}
async function choose(page: any, name: string, button: string) {
const dialog = page.getByRole('dialog')
await dialog.locator('.el-select').click()
await page.getByRole('option', { name: new RegExp(name) }).click()
await dialog.getByRole('button', { name: button, exact: true }).click()
await expect(dialog).toBeHidden()
}
test('first choose continues clicked row; saves match and real stage; preference survives refresh', async ({ page }) => {
const state = await setup(page)
await page.getByRole('button', { name: '一键关联', exact: true }).nth(1).click()
await choose(page, '手机A', '选择并继续')
await expect.poll(() => state.matches.length).toBe(1)
expect(state.links[0].latestCollection).toMatchObject({ sybProductId: 2, deviceId: 10 })
await expect(page.locator('tbody').getByText('可创建采购', { exact: true })).toBeVisible()
await page.reload()
await page.getByRole('button', { name: '一键关联', exact: true }).click()
await expect.poll(() => state.links.length).toBe(2)
expect(state.links[1].latestCollection.deviceId).toBe(10)
await expect(page.getByRole('dialog')).toHaveCount(0)
})
test('arrow first-use saves only; switch from either row affects all rows without linking', async ({ page }) => {
const state = await setup(page)
await page.getByRole('button', { name: '一键关联使用的手机' }).first().click()
await page.getByRole('menuitem', { name: '切换手机' }).click()
await choose(page, '手机A', '保存选择')
expect(state.links).toHaveLength(0)
await page.getByRole('button', { name: '一键关联使用的手机' }).nth(1).click()
await expect(page.getByRole('menuitem', { name: /当前手机:手机A/ })).toBeVisible()
await page.getByRole('menuitem', { name: '切换手机' }).click()
await choose(page, '手机B', '保存选择')
expect(state.links).toHaveLength(0)
await page.getByRole('button', { name: '一键关联', exact: true }).first().click()
await expect.poll(() => state.links.length).toBe(1)
expect(state.links[0].latestCollection.deviceId).toBe(20)
})
test('AI failure retains association, ends waiting and offers manual matching', async ({ page }) => {
const state = await setup(page); state.aiFailure = true
await page.getByRole('button', { name: '一键关联', exact: true }).first().click()
await choose(page, '手机A', '选择并继续')
await expect(page.getByText(/当前已有关联,匹配结果请去匹配查看/)).toBeVisible()
await expect(page.getByRole('button', { name: '去匹配', exact: true })).toBeVisible()
expect(state.links).toHaveLength(1); expect(state.matches).toHaveLength(1)
await expect(page.getByRole('button', { name: '一键关联', exact: true })).toBeEnabled()
})
test('cancel, different user and no purchaser role do not create links', async ({ page }) => {
const state = await setup(page)
await page.getByRole('button', { name: '一键关联', exact: true }).first().click()
await page.getByRole('dialog').getByRole('button', { name: '取消', exact: true }).click()
expect(state.links).toHaveLength(0)
await page.getByRole('button', { name: '一键关联使用的手机' }).first().click()
await page.getByRole('menuitem', { name: '切换手机' }).click()
await choose(page, '手机A', '保存选择')
state.userId = 2; await page.reload()
await page.getByRole('button', { name: '一键关联', exact: true }).first().click()
await expect(page.getByRole('dialog', { name: '选择采集手机' })).toBeVisible()
state.role = 'viewer'; await page.reload()
await expect(page.getByRole('button', { name: '一键关联', exact: true })).toHaveCount(0)
expect(state.links).toHaveLength(0)
})
+89
View File
@@ -0,0 +1,89 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const source = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products/quick-link.js'), 'utf8').replace(/import[^\n]+\n/g, '').replace('export default', 'return')
const row = { id: 1, shopeeProductId: 11 }
const tick = () => new Promise(resolve => setImmediate(resolve))
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } }
function instance(overrides = {}) {
const calls = { link: [], match: [], reads: 0, saved: [], refresh: 0 }
const dependencies = {
ElMessage: { warning() {} },
listDevices: async () => ({ data: { items: [{ id: 10, status: 'offline', name: 'A' }, { id: 20, status: 'online', name: 'B' }] } }),
getShopeeProduct: async id => { calls.reads++; return { data: { product: { id, specContextVersion: 'before', pddProductId: calls.link.length ? 55 : null }, autoMatchTimeoutSeconds: 370 } } },
linkShopeeProductPdd: async (id, body) => { calls.link.push(body); return { data: { product: { id, specContextVersion: 'after' }, collectionSource: { taskId: 9, pddProductId: 55 } } } },
autoMatchShopeeSpecMappings: async (id, body, seconds) => { calls.match.push({ id, body, seconds }); return { data: { unmatchedCount: 0 } } },
previewPurchaseTasks: async () => ({ data: { items: [{ aiMatchEligible: true }] } }),
createRequestId: () => 'request-id', readQuickLinkDevice: () => null,
rememberQuickLinkDevice: (user, id) => { calls.saved.push(id); return true }, ...overrides
}
const options = new Function(...Object.keys(dependencies), source)(...Object.values(dependencies))
const vm = { ...options.methods, ...options.data(), canPurchase: true, products: [row], $store: { getters: { userId: 1 } }, purchaseReady: () => ({ processNextAction: 'open_pdd_link' }), loadPurchaseReadiness: async () => { calls.refresh++ } }
return { vm, calls, options }
}
test('first selection continues original row and forwards frozen context / #254 budget', async () => {
const { vm, calls } = instance()
await vm.quickStart(row); assert.equal(calls.link.length, 0)
vm.quickPicker.deviceId = 10; vm.quickSaveDevice(); await tick()
assert.equal(calls.link.length, 1); assert.equal(calls.link[0].latestCollection.sybProductId, 1)
assert.equal(calls.link[0].latestCollection.deviceId, 10)
assert.equal(calls.match[0].body.specContextVersion, 'after'); assert.equal(calls.match[0].seconds, 370)
assert.equal(vm.quickBusyId, null)
})
test('switch saves shared preference only; cancel changes nothing', async () => {
const { vm, calls } = instance(); vm.quickDeviceId = 10
await vm.quickOpenPicker(); vm.quickPicker.deviceId = 20; vm.quickCancelPicker()
assert.equal(vm.quickDeviceId, 10); assert.equal(calls.saved.length, 0)
await vm.quickOpenPicker(); vm.quickPicker.deviceId = 20; vm.quickSaveDevice()
assert.equal(vm.quickDeviceId, 20); assert.equal(calls.link.length, 0)
})
test('in-flight device is frozen and double click cannot duplicate POST', async () => {
const wait = deferred(); let links = 0, device
const { vm } = instance({ getShopeeProduct: () => wait.promise, linkShopeeProductPdd: async (id, body) => { links++; device = body.latestCollection.deviceId; return { data: { product: { specContextVersion: 'after' }, collectionSource: {} } } } })
vm.quickDeviceId = 10; const running = vm.quickStart(row)
vm.quickDeviceId = 20; await vm.quickStart(row)
wait.resolve({ data: { product: { specContextVersion: 'before' }, autoMatchTimeoutSeconds: 130 } }); await running
assert.equal(links, 1); assert.equal(device, 10)
})
test('leaving during prepare prevents later association / matching', async () => {
const wait = deferred(); const { vm, calls, options } = instance({ getShopeeProduct: () => wait.promise })
vm.quickDeviceId = 10; const running = vm.quickStart(row); options.beforeUnmount.call(vm)
wait.resolve({ data: { product: { specContextVersion: 'before' } } }); await running
assert.equal(calls.link.length, 0); assert.equal(calls.match.length, 0)
})
for (const error of ['timeout', 'Network Error', 'AI 匹配服务暂时不可用']) {
test(`${error}: finite readback, end loading, no automatic write retry`, async () => {
let matches = 0
const { vm, calls } = instance({ autoMatchShopeeSpecMappings: async () => { matches++; throw new Error(error) } })
vm.quickDeviceId = 10; await vm.quickStart(row)
assert.equal(calls.link.length, 1); assert.equal(matches, 1); assert.equal(calls.reads, 2)
assert.equal(vm.quickBusyId, null); assert.match(vm.quickMessages[1], /当前已有关联/)
})
}
test('failed recovery reports unknown and clears busy', async () => {
const { vm } = instance({ getShopeeProduct: async () => { throw new Error('offline') } })
vm.quickDeviceId = 10; await vm.quickStart(row)
assert.equal(vm.quickBusyId, null); assert.match(vm.quickMessages[1], /结果未确认/)
})
test('definite candidate rejection explains cause without AI or a write retry', async () => {
let links = 0
const { vm, calls } = instance({ linkShopeeProductPdd: async () => { links++; throw { response: { status: 409, data: { message: '最近一次临时采集不完整,请先补采' } } } } })
vm.quickDeviceId = 10; await vm.quickStart(row)
assert.equal(links, 1); assert.equal(calls.match.length, 0); assert.equal(calls.reads, 1)
assert.equal(vm.quickBusyId, null); assert.equal(vm.quickMessages[1], '最近一次临时采集不完整,请先补采')
})
test('manual-required readiness retains association without AI call', async () => {
const { vm, calls } = instance({ previewPurchaseTasks: async () => ({ data: { items: [{ aiMatchEligible: false, eligible: false }] } }) })
vm.quickDeviceId = 10; await vm.quickStart(row)
assert.equal(calls.link.length, 1); assert.equal(calls.match.length, 0); assert.match(vm.quickMessages[1], /人工处理/)
})
test('preference isolates user and API environment, handles storage failure', () => {
const source = fs.readFileSync(path.join(__dirname, '../../src/utils/quick-link-device-preference.js'), 'utf8').replace(/export function/g, 'function')
const map = new Map(); const process = { env: { VUE_APP_BASE_API: '/a' } }
const storage = { getItem: k => map.get(k), setItem: (k, v) => map.set(k, v) }
const p = new Function('localStorage', 'process', source + '; return {readQuickLinkDevice,rememberQuickLinkDevice}')(storage, process)
p.rememberQuickLinkDevice(1, 10); assert.equal(p.readQuickLinkDevice(1), 10); assert.equal(p.readQuickLinkDevice(2), null)
process.env.VUE_APP_BASE_API = '/b'; assert.equal(p.readQuickLinkDevice(1), null)
storage.setItem = () => { throw new Error('denied') }; assert.equal(p.rememberQuickLinkDevice(1, 20), false)
})