Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd013c634b | ||
|
|
57eaad8414 | ||
|
|
13b1580fd5 | ||
|
|
043d71da64 | ||
|
|
58e058f8ac | ||
|
|
2d6d244d8a | ||
|
|
73cb94e073 | ||
|
|
bd4bc5f4f0 | ||
|
|
415d1ff2ad | ||
|
|
a8e01b9809 | ||
|
|
74f55f43cc | ||
|
|
2c263e687e | ||
|
|
13164adde0 | ||
|
|
379a83fe94 | ||
|
|
fc28632d48 | ||
|
|
e85f237f09 | ||
|
|
4f64c074dd | ||
|
|
4eac72919d | ||
|
|
31c32d6a86 | ||
|
|
55c2b6b254 | ||
|
|
16c9216a5a |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 51
|
||||
versionName = "0.9.38"
|
||||
versionCode = 52
|
||||
versionName = "0.9.39"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+67
-11
@@ -123,6 +123,9 @@ data class ParsedPddScreen(
|
||||
val hasSelectionSummary: Boolean,
|
||||
val hasQuantityControls: Boolean,
|
||||
val hasOrderSubmitAction: Boolean,
|
||||
val explicitSpecEntryCount: Int,
|
||||
val nestedSpecEntryCount: Int,
|
||||
val bottomPurchaseEntryCount: Int,
|
||||
val problem: PageProblem?,
|
||||
val sourceNodes: List<SnapshotNode>,
|
||||
) {
|
||||
@@ -306,6 +309,14 @@ object PddScreenParser {
|
||||
orderConfirmationEvidence -> SpecPanelType.ORDER_CONFIRMATION
|
||||
panelScrollable != null && (hasSelectionSummary || hasSubmitHint || (hasPanelTitle && hasPanelAction)) -> SpecPanelType.NORMAL_SCROLLABLE
|
||||
hasSelectionSummary && hasPanelTitle && hasPanelAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
// Some PDD builds expose the complete selector as non-scrollable
|
||||
// before a value is selected, so an "已选" summary is absent. Two
|
||||
// parsed dimensions with selectable values plus the unique quantity
|
||||
// controls and lower-page order action remain required; generic
|
||||
// product/review pages cannot satisfy this combined evidence.
|
||||
panelScrollable == null && headings.size >= 2 && dimensions.size >= 2 &&
|
||||
dimensions.sumOf { it.values.size } >= 2 &&
|
||||
hasQuantityControls && hasOrderSubmitAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
else -> SpecPanelType.UNKNOWN
|
||||
}
|
||||
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
|
||||
@@ -314,18 +325,28 @@ object PddScreenParser {
|
||||
.filter { it.bounds.top < firstHeadingTop }
|
||||
.mapNotNull { node -> pricePattern.find(node.label)?.groupValues?.get(1)?.let(::priceCent) }
|
||||
.firstOrNull()
|
||||
val explicitSpecEntry = if (panelOpen) null else visible
|
||||
val explicitSpecEntries = if (panelOpen) emptyList() else visible
|
||||
.filter { it.clickable && isSpecEntry(it.label, config) && !hasReviewContext(it, visibleNodes, config) }
|
||||
.maxByOrNull { it.bounds.top }
|
||||
val bottomSpecEntry = if (panelOpen || explicitSpecEntry != null) null else safeBottomSpecEntry(visibleNodes, visible, config)
|
||||
val candidateSpecEntry = explicitSpecEntry ?: bottomSpecEntry?.anchor
|
||||
val candidateClickTarget = explicitSpecEntry ?: bottomSpecEntry?.clickTarget
|
||||
val explicitSpecEntry = explicitSpecEntries.maxByOrNull { it.bounds.top }
|
||||
val nestedSpecEntries = if (panelOpen || explicitSpecEntry != null) emptyList() else
|
||||
safeNestedSpecEntries(visibleNodes, visible, config)
|
||||
// A nested selection row is accepted only when it is the single safe
|
||||
// candidate. This covers PDD layouts that split “请选择” and the
|
||||
// dimension name across child nodes of one clickable parent, without
|
||||
// turning arbitrary page text into a click target.
|
||||
val nestedSpecEntry = nestedSpecEntries.singleOrNull()
|
||||
val bottomSpecEntries = if (panelOpen || explicitSpecEntry != null || nestedSpecEntry != null) emptyList() else
|
||||
safeBottomSpecEntries(visibleNodes, visible, config)
|
||||
val bottomSpecEntry = bottomSpecEntries.singleOrNull()
|
||||
val candidateSpecEntry = explicitSpecEntry ?: nestedSpecEntry?.anchor ?: bottomSpecEntry?.anchor
|
||||
val candidateClickTarget = explicitSpecEntry ?: nestedSpecEntry?.clickTarget ?: bottomSpecEntry?.clickTarget
|
||||
val reviewPageOpen = isReviewPage(visibleNodes, visible, screenHeight, candidateSpecEntry, config)
|
||||
val specEntry = candidateSpecEntry.takeUnless { reviewPageOpen }
|
||||
val specEntryClickTarget = candidateClickTarget.takeUnless { reviewPageOpen }
|
||||
val specEntrySource = when {
|
||||
reviewPageOpen -> null
|
||||
explicitSpecEntry != null -> "explicit_selection"
|
||||
nestedSpecEntry != null -> "nested_selection"
|
||||
bottomSpecEntry != null -> "bottom_purchase"
|
||||
else -> null
|
||||
}
|
||||
@@ -370,6 +391,9 @@ object PddScreenParser {
|
||||
hasSelectionSummary = hasSelectionSummary,
|
||||
hasQuantityControls = hasQuantityControls,
|
||||
hasOrderSubmitAction = hasOrderSubmitAction,
|
||||
explicitSpecEntryCount = explicitSpecEntries.size,
|
||||
nestedSpecEntryCount = nestedSpecEntries.size,
|
||||
bottomPurchaseEntryCount = bottomSpecEntries.size,
|
||||
problem = problem,
|
||||
sourceNodes = visibleNodes,
|
||||
)
|
||||
@@ -458,10 +482,40 @@ object PddScreenParser {
|
||||
return hasSpecWord && config.textAliases.selection.specEntryPrefixes.any(compact::startsWith)
|
||||
}
|
||||
|
||||
private fun safeBottomSpecEntry(source: List<SnapshotNode>, visible: List<SnapshotNode>, config: PddCollectorConfig): SafeSpecEntry? {
|
||||
val screenWidth = source.maxOfOrNull { it.bounds.right } ?: return null
|
||||
val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return null
|
||||
if (screenWidth <= 0 || screenHeight <= 0) return null
|
||||
private fun safeNestedSpecEntries(source: List<SnapshotNode>, visible: List<SnapshotNode>, config: PddCollectorConfig): List<SafeSpecEntry> {
|
||||
val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return emptyList()
|
||||
if (screenHeight <= 0) return emptyList()
|
||||
return source.asSequence()
|
||||
.filter { it.visible && it.enabled && it.clickable && it.bounds.width > 0 && it.bounds.height > 0 }
|
||||
// The fixed purchase bar begins at the lower fifth of the screen.
|
||||
// A specs row has no reason to be inside that action-only zone.
|
||||
.filter { it.bounds.centerY.toDouble() < screenHeight * 0.8 }
|
||||
.mapNotNull { candidate ->
|
||||
val context = (listOf(candidate.label) + descendants(candidate, source).map(SnapshotNode::label))
|
||||
.joinToString("") { it.replace(Regex("\\s+"), "") }
|
||||
if (!isSpecEntryContext(context, config) ||
|
||||
nonConfigurableClickDenylist.any(context::contains) ||
|
||||
hasReviewContext(candidate, source, config)
|
||||
) return@mapNotNull null
|
||||
val anchor = visible.firstOrNull { it.path == candidate.path } ?: candidate
|
||||
SafeSpecEntry(anchor, candidate)
|
||||
}
|
||||
.distinctBy { it.clickTarget.path }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun isSpecEntryContext(compact: String, config: PddCollectorConfig): Boolean {
|
||||
if (config.textAliases.review.entryAliases.any(compact::contains)) return false
|
||||
val hasSpecWord = (config.colorAliases + config.sizeAliases +
|
||||
config.textAliases.dimension.exactNames + config.textAliases.dimension.adaptiveAliases)
|
||||
.any(compact::contains)
|
||||
return hasSpecWord && config.textAliases.selection.specEntryPrefixes.any(compact::contains)
|
||||
}
|
||||
|
||||
private fun safeBottomSpecEntries(source: List<SnapshotNode>, visible: List<SnapshotNode>, config: PddCollectorConfig): List<SafeSpecEntry> {
|
||||
val screenWidth = source.maxOfOrNull { it.bounds.right } ?: return emptyList()
|
||||
val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return emptyList()
|
||||
if (screenWidth <= 0 || screenHeight <= 0) return emptyList()
|
||||
val byPath = source.associateBy(SnapshotNode::path)
|
||||
val normalizedByPath = visible.associateBy(SnapshotNode::path)
|
||||
val buyWords = config.textAliases.purchase.buyWords
|
||||
@@ -485,8 +539,10 @@ object PddScreenParser {
|
||||
val normalizedTarget = normalizedByPath[clickTarget.path] ?: return@mapNotNull null
|
||||
SafeSpecEntry(normalizedAnchor, normalizedTarget) to clickTarget.bounds.width.toLong() * clickTarget.bounds.height
|
||||
}
|
||||
.minWithOrNull(compareBy<Pair<SafeSpecEntry, Long>> { it.second }.thenByDescending { it.first.clickTarget.bounds.centerX })
|
||||
?.first
|
||||
.sortedWith(compareBy<Pair<SafeSpecEntry, Long>> { it.second }.thenByDescending { it.first.clickTarget.bounds.centerX })
|
||||
.map { it.first }
|
||||
.distinctBy { it.clickTarget.path }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun hasReviewContext(node: SnapshotNode, source: List<SnapshotNode>, config: PddCollectorConfig): Boolean {
|
||||
|
||||
+47
-12
@@ -121,6 +121,7 @@ class PurchaseLiveAutomation(
|
||||
var backedOutOfChooser = false
|
||||
var restoredFromWechat = false
|
||||
var pddObservedAfterWechatRestore = false
|
||||
var orderDetailEntryOpened = false
|
||||
var wechatRestorePendingSamples = 0
|
||||
var consecutiveEmptySnapshots = 0
|
||||
repeat(ORDER_RESULT_MAX_SAMPLES) { index ->
|
||||
@@ -162,24 +163,48 @@ class PurchaseLiveAutomation(
|
||||
return unknown("PURCHASE_ORDER_UNEXPECTED_APP", "核单期间出现未授权应用")
|
||||
}
|
||||
if (restoredFromWechat) pddObservedAfterWechatRestore = true
|
||||
val paymentVisible = isKnownPddPaymentActivity(snapshot) ||
|
||||
currentLabels.any { label -> PAYMENT_MARKERS.any(label::contains) }
|
||||
val orderContextVisible = currentLabels.any { label -> ORDER_CONTEXT_MARKERS.any(label::contains) }
|
||||
if (!paymentVisible && !orderContextVisible) {
|
||||
val unpaidContextVisible = currentLabels.any { label -> UNPAID_MARKERS.any(label::contains) }
|
||||
// PDD can reuse its payment Activity for a read-only unpaid order page.
|
||||
// Visible order-result evidence takes precedence over the Activity name:
|
||||
// it permits only bounded reading gestures below, never a payment click.
|
||||
val paymentVisible = isKnownPddPaymentActivity(snapshot) && !orderContextVisible && !unpaidContextVisible
|
||||
if (paymentVisible) {
|
||||
if (!backedOutOfPayment) {
|
||||
backedOutOfPayment = true
|
||||
if (!driver.backPurchase()) {
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情")
|
||||
}
|
||||
pause(500)
|
||||
} else {
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_REPEATED", "支付页重复出现,已停止自动核单")
|
||||
}
|
||||
return@repeat
|
||||
}
|
||||
if (!orderContextVisible && !unpaidContextVisible) {
|
||||
val entries = orderDetailEntryTargets(snapshot)
|
||||
if (entries.size > 1) {
|
||||
return unknown("PURCHASE_ORDER_DETAIL_ENTRY_AMBIGUOUS", "订单详情入口不唯一,已停止只读核单")
|
||||
}
|
||||
if (entries.size == 1) {
|
||||
if (orderDetailEntryOpened) {
|
||||
return unknown("PURCHASE_ORDER_DETAIL_ENTRY_TIMEOUT", "进入订单详情后页面未出现可验证证据")
|
||||
}
|
||||
when (driver.clickFresh(entries.single())) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
FreshActionResult.AMBIGUOUS -> return unknown("PURCHASE_ORDER_DETAIL_ENTRY_AMBIGUOUS", "订单详情入口不唯一,已停止只读核单")
|
||||
else -> return unknown("PURCHASE_ORDER_DETAIL_ENTRY_FAILED", "订单详情入口点击失败,已停止只读核单")
|
||||
}
|
||||
orderDetailEntryOpened = true
|
||||
pause(500)
|
||||
return@repeat
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
return@repeat
|
||||
}
|
||||
currentLabels.forEach(labels::add)
|
||||
parseOrderEvidence(labels)?.let { return it }
|
||||
if (paymentVisible && !backedOutOfPayment) {
|
||||
backedOutOfPayment = true
|
||||
if (!driver.backPurchase()) {
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情")
|
||||
}
|
||||
pause(500)
|
||||
} else if (paymentVisible) {
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_REPEATED", "支付页重复出现,已停止自动核单")
|
||||
} else if (index > 0 && index % 15 == 0) {
|
||||
if (index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
|
||||
driver.swipePurchase(SwipeDirection.UP, 400)
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
@@ -371,6 +396,14 @@ class PurchaseLiveAutomation(
|
||||
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
|
||||
)
|
||||
|
||||
/** A post-submit navigation target is allowed only when it is one exact, non-payment PDD order-detail entry. */
|
||||
private fun orderDetailEntryTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
|
||||
snapshot,
|
||||
snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && node.label in ORDER_DETAIL_ENTRY_MARKERS
|
||||
},
|
||||
)
|
||||
|
||||
private fun pageProblem(snapshot: UiSnapshot) {
|
||||
val labels = snapshot.nodes.filter { it.visible }.map { it.label }
|
||||
PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels)?.let { fail(it.code, it.message) }
|
||||
@@ -421,6 +454,7 @@ class PurchaseLiveAutomation(
|
||||
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
|
||||
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
|
||||
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
|
||||
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
|
||||
val ORDER_CONTEXT_MARKERS = listOf("订单编号", "订单号", "下单时间", "创建时间")
|
||||
val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})")
|
||||
val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20[0-9]{2}[-/.年][0-9]{1,2}[-/.月][0-9]{1,2}日?\\s+[0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)")
|
||||
@@ -435,6 +469,7 @@ class PurchaseLiveAutomation(
|
||||
const val ORDER_RESULT_MAX_SAMPLES = 60
|
||||
const val ORDER_RESULT_MAX_EMPTY_SAMPLES = 15
|
||||
const val ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES = 15
|
||||
const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15
|
||||
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
|
||||
}
|
||||
}
|
||||
|
||||
+32
-5
@@ -51,6 +51,7 @@ class PurchaseRehearsalExecutor(
|
||||
private val probeSpecs: () -> String?,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
private val stepChanged: (String) -> Unit = {},
|
||||
private val panelDiagnostic: (String) -> Unit = {},
|
||||
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
|
||||
) {
|
||||
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
|
||||
@@ -123,7 +124,7 @@ class PurchaseRehearsalExecutor(
|
||||
}
|
||||
return failure
|
||||
}
|
||||
applyPostAction(action)?.let { return it }
|
||||
applyPostAction(input, action)?.let { return it }
|
||||
}
|
||||
if (input.phase == "spec_probe") return failure("PURCHASE_RULE_INVALID", "规格探测任务缺少 probeSpecs 动作")
|
||||
return if (input.executionMode == "rehearsal") PurchaseExecutionOutcome("rehearsal_completed", message = "商品、规格、数量和价格复核完成,已在下单前安全停止", actualUnitPriceCent = observedPrice)
|
||||
@@ -278,8 +279,14 @@ class PurchaseRehearsalExecutor(
|
||||
val candidates = action.textAliases?.let { aliases ->
|
||||
safeCandidates.filter { (anchor, _) -> specEntryMatchesAliases(screen, anchor, aliases) }
|
||||
} ?: safeCandidates
|
||||
if (candidates.size > 1) return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一")
|
||||
val target = candidates.singleOrNull()?.second ?: return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口")
|
||||
if (candidates.size > 1) {
|
||||
panelDiagnostic(specEntryEvidence(screen, candidates.size))
|
||||
return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一")
|
||||
}
|
||||
val target = candidates.singleOrNull()?.second ?: run {
|
||||
panelDiagnostic(specEntryEvidence(screen, 0))
|
||||
return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口")
|
||||
}
|
||||
val click = driver.clickFreshDetailed(target)
|
||||
when (click.result) {
|
||||
FreshActionResult.AMBIGUOUS -> return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一")
|
||||
@@ -288,14 +295,26 @@ class PurchaseRehearsalExecutor(
|
||||
}
|
||||
repeat(30) {
|
||||
screen = currentScreen(input)
|
||||
panelDiagnostic(panelEvidence(screen))
|
||||
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (screen.specPanelOpen) return null
|
||||
pause(100)
|
||||
}
|
||||
return failure(SPEC_PANEL_NOT_OPENED, "点击后未识别到商品规格面板")
|
||||
return failure(SPEC_PANEL_NOT_OPENED, "点击后未识别到商品规格面板 [${panelEvidence(screen)}]")
|
||||
}
|
||||
|
||||
private fun panelEvidence(screen: ParsedPddScreen): String =
|
||||
"type=${screen.specPanelType};scrollables=${screen.panelScrollableCount};headings=${screen.panelHeadingCount};" +
|
||||
"options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};" +
|
||||
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched}"
|
||||
|
||||
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int): String =
|
||||
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
|
||||
"nested=${screen.nestedSpecEntryCount};bottomPurchase=${screen.bottomPurchaseEntryCount};" +
|
||||
"panelAlreadyOpen=${screen.specPanelOpen};reviewPage=${screen.reviewPageOpen};" +
|
||||
"pageEvidence=${screen.pageEvidenceMatched}"
|
||||
|
||||
private fun specEntryMatchesAliases(screen: ParsedPddScreen, candidate: SnapshotNode, aliases: List<String>): Boolean {
|
||||
val prefix = "${candidate.path}/"
|
||||
return (sequenceOf(candidate) + screen.sourceNodes.asSequence().filter { it.path.startsWith(prefix) })
|
||||
@@ -590,9 +609,17 @@ class PurchaseRehearsalExecutor(
|
||||
return null
|
||||
}
|
||||
|
||||
private fun applyPostAction(action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
|
||||
action.swipeAfter?.let { swipe ->
|
||||
// The stock purchase rule asks to reveal additional selector rows after
|
||||
// opening the sheet. A fully-evidenced non-scrollable selector has no
|
||||
// scroll target, and treating that absence as an action failure blocks
|
||||
// an otherwise safe exact-spec flow. Keep all other configured swipes
|
||||
// mandatory; this exception is limited to that confirmed panel state.
|
||||
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL &&
|
||||
currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
) return null
|
||||
repeat(swipe.count) { index ->
|
||||
if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) {
|
||||
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败")
|
||||
|
||||
@@ -226,6 +226,26 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
)
|
||||
}
|
||||
|
||||
fun recoverRegistration(info: DeviceInfo, token: String?, recoveryCode: String): RegistrationResult {
|
||||
require(recoveryCode.isNotBlank()) { "请输入管理员恢复码" }
|
||||
val payload = JSONObject()
|
||||
.put("requestId", UUID.randomUUID().toString())
|
||||
.put("installId", info.installId)
|
||||
.put("name", info.name)
|
||||
.put("manufacturer", info.manufacturer)
|
||||
.put("model", info.model)
|
||||
.put("androidVersion", info.androidVersion)
|
||||
.put("agentVersion", info.agentVersion)
|
||||
.put("pddVersion", info.pddVersion)
|
||||
.put("capabilities", JSONArray(info.capabilities))
|
||||
val data = post("/api/agent/v1/register", payload, token, recoveryCode).getJSONObject("data")
|
||||
return RegistrationResult(
|
||||
deviceId = data.getLong("deviceId"),
|
||||
deviceToken = data.optString("deviceToken").takeIf { it.isNotBlank() },
|
||||
heartbeatIntervalSeconds = data.optInt("heartbeatIntervalSeconds", 15),
|
||||
)
|
||||
}
|
||||
|
||||
fun heartbeat(token: String, currentTaskId: Long?, capabilities: List<String> = emptyList()): HeartbeatResult {
|
||||
val payload = JSONObject()
|
||||
.put("requestId", UUID.randomUUID().toString())
|
||||
@@ -590,11 +610,11 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
leaseVersion = data.getLong("leaseVersion"),
|
||||
)
|
||||
|
||||
private fun post(path: String, payload: JSONObject, token: String?): JSONObject {
|
||||
return requireNotNull(request("POST", path, payload, token))
|
||||
private fun post(path: String, payload: JSONObject, token: String?, recoveryCode: String? = null): JSONObject {
|
||||
return requireNotNull(request("POST", path, payload, token, recoveryCode))
|
||||
}
|
||||
|
||||
private fun request(method: String, path: String, payload: JSONObject?, token: String?): JSONObject? {
|
||||
private fun request(method: String, path: String, payload: JSONObject?, token: String?, recoveryCode: String? = null): JSONObject? {
|
||||
val connection = (URL(serverUrl + path).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = method
|
||||
connectTimeout = 10_000
|
||||
@@ -605,6 +625,7 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
setRequestProperty("Accept", "application/json")
|
||||
setRequestProperty("Cache-Control", "no-store")
|
||||
if (!token.isNullOrBlank()) setRequestProperty("Authorization", "Bearer $token")
|
||||
if (!recoveryCode.isNullOrBlank()) setRequestProperty("X-GoAuto-Device-Recovery-Code", recoveryCode)
|
||||
}
|
||||
try {
|
||||
if (payload != null) {
|
||||
|
||||
@@ -187,11 +187,12 @@ class AgentForegroundService : Service() {
|
||||
|
||||
if (!registeredThisProcess.get()) {
|
||||
val registration = api.register(deviceInfo(), credentials?.token)
|
||||
if (credentials == null) {
|
||||
if (registration.deviceToken != null) {
|
||||
val issuedToken = registration.deviceToken
|
||||
?: error("注册请求已处理,但未返回新 Token;请联系管理员重新签发")
|
||||
identityStore.saveCredentials(registration.deviceId, issuedToken)
|
||||
credentials = identityStore.credentials() ?: error("设备凭据保存失败")
|
||||
} else if (credentials == null) {
|
||||
error("注册请求已处理,但未返回新 Token;请联系管理员重新签发")
|
||||
} else {
|
||||
check(registration.deviceId == credentials.deviceId) { "服务端设备身份与本地不一致" }
|
||||
}
|
||||
@@ -472,6 +473,7 @@ class AgentForegroundService : Service() {
|
||||
openLink = { PddLinkLauncher(this).open(it) },
|
||||
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
|
||||
stepChanged = { step -> purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) },
|
||||
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
|
||||
beforeOrderSubmit = { evidence ->
|
||||
val boundaryRequestId = UUID.randomUUID().toString()
|
||||
val finalEvidence = JSONObject()
|
||||
|
||||
@@ -571,6 +571,74 @@ class PddProductDetailCollectorTest {
|
||||
assertTrue(parsed.dimensions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonScrollableSelectorWithTwoDimensionsDoesNotRequireInitialSelectedSummary() {
|
||||
val snapshot = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("color-heading", "颜色", 20, 420, 300, 470),
|
||||
node("color", "豹纹", 20, 490, 300, 550, clickable = true),
|
||||
node("size-heading", "尺码", 20, 650, 300, 700),
|
||||
node("size", "均码", 20, 720, 300, 780, clickable = true),
|
||||
node("quantity", "1", 480, 1400, 600, 1480, className = "android.widget.EditText"),
|
||||
node("decrease", "减少数量", 360, 1400, 470, 1480, clickable = true),
|
||||
node("increase", "增加数量", 610, 1400, 720, 1480, clickable = true),
|
||||
node("order", "提交订单", 20, 1900, 1060, 2100, clickable = true),
|
||||
),
|
||||
)
|
||||
|
||||
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
|
||||
|
||||
assertTrue(parsed.specPanelOpen)
|
||||
assertEquals(SpecPanelType.NON_SCROLLABLE_CONFIRMATION, parsed.specPanelType)
|
||||
assertFalse(parsed.hasSelectionSummary)
|
||||
assertEquals(2, parsed.dimensions.size)
|
||||
assertEquals(2, parsed.panelOptionCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nestedSelectionRowCombinesPrefixAndDimensionBeforeChoosingClickableParent() {
|
||||
val snapshot = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("selection-row", "", 20, 720, 1060, 860, clickable = true),
|
||||
node("selection-row/prefix", "请选择", 48, 750, 220, 810, parentPath = "selection-row"),
|
||||
node("selection-row/dimension", "颜色分类", 240, 750, 480, 810, parentPath = "selection-row"),
|
||||
),
|
||||
)
|
||||
|
||||
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
|
||||
|
||||
assertEquals("selection-row", parsed.specEntry?.path)
|
||||
assertEquals("selection-row", parsed.specEntryClickTarget?.path)
|
||||
assertEquals("nested_selection", parsed.specEntrySource)
|
||||
assertEquals(1, parsed.nestedSpecEntryCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nestedSelectionSemanticsNeverAcceptsOrderOrPaymentContainer() {
|
||||
val snapshot = UiSnapshot(
|
||||
PDD_PACKAGE,
|
||||
ACTIVITY,
|
||||
listOf(
|
||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||
node("unsafe", "", 20, 720, 1060, 860, clickable = true),
|
||||
node("unsafe/prefix", "请选择", 48, 750, 220, 810, parentPath = "unsafe"),
|
||||
node("unsafe/dimension", "颜色", 240, 750, 480, 810, parentPath = "unsafe"),
|
||||
node("unsafe/order", "提交订单", 700, 750, 1020, 810, parentPath = "unsafe"),
|
||||
),
|
||||
)
|
||||
|
||||
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
|
||||
|
||||
assertEquals(null, parsed.specEntry)
|
||||
assertEquals(0, parsed.nestedSpecEntryCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun genericQuantityAndBuyControlsDoNotProveQuickConfirmation() {
|
||||
val snapshot = UiSnapshot(
|
||||
|
||||
@@ -120,6 +120,99 @@ class PurchaseLiveAutomationTest {
|
||||
assertFalse(driver.clicked.any { it.startsWith("微信") || it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `payment page returns to folded unpaid order detail and scrolls to read evidence`() {
|
||||
val driver = LiveDriver(chooserAfterSubmit = true, orderEvidenceBelowFold = true)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg54")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg54"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
val order = automation.readOrderResult()
|
||||
|
||||
assertEquals("PDD-202608210001", order?.orderNo)
|
||||
assertEquals(2, driver.postSubmitBackCount)
|
||||
assertEquals(1, driver.genericSwipes)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `successful order page enters the unique order detail before reading the result`() {
|
||||
val driver = LiveDriver(orderDetailEntryAfterSubmit = true)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg56")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg56"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
val order = automation.readOrderResult()
|
||||
|
||||
assertEquals("PDD-202608210001", order?.orderNo)
|
||||
assertEquals(1, driver.orderDetailEntryClicks)
|
||||
assertEquals("order", driver.currentPage)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `order detail entered from success page scrolls only to reveal folded result evidence`() {
|
||||
val driver = LiveDriver(orderDetailEntryAfterSubmit = true, orderDetailEvidenceBelowFold = true)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg58")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg58"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
val order = automation.readOrderResult()
|
||||
|
||||
assertEquals("PDD-202608210001", order?.orderNo)
|
||||
assertEquals(1, driver.orderDetailEntryClicks)
|
||||
assertEquals(1, driver.genericSwipes)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unpaid order evidence on reused payment activity scrolls read only instead of backing out`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = listOf("order-folded-payment-activity"))
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg59")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg59"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
val order = automation.readOrderResult()
|
||||
|
||||
assertEquals("PDD-202608210001", order?.orderNo)
|
||||
assertEquals(0, driver.postSubmitBackCount)
|
||||
assertEquals(1, driver.genericSwipes)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ambiguous order detail entries stop without navigating`() {
|
||||
val driver = LiveDriver(orderDetailEntryAfterSubmit = true, duplicateOrderDetailEntry = true)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg57")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg57"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_DETAIL_ENTRY_AMBIGUOUS", automation.lastOrderReadFailure?.code)
|
||||
assertEquals(0, driver.orderDetailEntryClicks)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated known payment activity still stops without scrolling or payment clicks`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = listOf("payment", "payment"))
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg55")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg55"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_PAYMENT_REPEATED", automation.lastOrderReadFailure?.code)
|
||||
assertEquals(1, driver.postSubmitBackCount)
|
||||
assertEquals(0, driver.genericSwipes)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WeChat login is not touched and PDD is restored once before reading order detail`() {
|
||||
val driver = LiveDriver(wechatLoginAfterSubmit = true)
|
||||
@@ -310,6 +403,10 @@ class PurchaseLiveAutomationTest {
|
||||
private val savedTransitionHidesSuffix: Boolean = false,
|
||||
private val wechatLoginAfterSubmit: Boolean = false,
|
||||
private val wechatRestoreStuck: Boolean = false,
|
||||
private val orderEvidenceBelowFold: Boolean = false,
|
||||
private val orderDetailEntryAfterSubmit: Boolean = false,
|
||||
private val orderDetailEvidenceBelowFold: Boolean = false,
|
||||
private val duplicateOrderDetailEntry: Boolean = false,
|
||||
postSubmitCaptureSequence: List<String> = emptyList(),
|
||||
) : PurchaseUiDriver {
|
||||
private var page = "confirmation"
|
||||
@@ -326,6 +423,7 @@ class PurchaseLiveAutomationTest {
|
||||
var backCount = 0
|
||||
var postSubmitBackCount = 0
|
||||
var pddRestoreCount = 0
|
||||
var orderDetailEntryClicks = 0
|
||||
var postSubmitCaptureCount = 0
|
||||
val currentPage: String get() = page
|
||||
|
||||
@@ -359,7 +457,20 @@ class PurchaseLiveAutomationTest {
|
||||
node("address-summary", if (savedTransitionHidesSuffix) "已保存的收货信息" else address.substring(address.lastIndexOf("_cg"))),
|
||||
))
|
||||
"order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true)))
|
||||
"order-folded" -> snapshot(listOf(node("status", "待付款"), node("pay", "立即支付", clickable = true)))
|
||||
"order-folded-payment-activity" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(
|
||||
node("status", "待付款"), node("pay", "立即支付", clickable = true),
|
||||
))
|
||||
"order-no-time" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("pay", "去支付", clickable = true)))
|
||||
"success" -> snapshot(buildList {
|
||||
add(node("success-title", "购买成功"))
|
||||
add(node("detail-parent", "", clickable = true))
|
||||
add(node("detail", "查看订单", parentPath = "detail-parent"))
|
||||
if (duplicateOrderDetailEntry) {
|
||||
add(node("detail-parent-2", "", clickable = true))
|
||||
add(node("detail-2", "订单详情", parentPath = "detail-parent-2"))
|
||||
}
|
||||
})
|
||||
"chooser" -> UiSnapshot(if (trustedChooser) "android" else "example.untrusted", "com.android.internal.app.ChooserActivity", listOf(
|
||||
node("chooser-title", "选择要使用的应用"), node("wechat-1", "微信"), node("wechat-2", "微信分身"),
|
||||
))
|
||||
@@ -409,9 +520,14 @@ class PurchaseLiveAutomationTest {
|
||||
page = when {
|
||||
chooserAfterSubmit -> "chooser"
|
||||
wechatLoginAfterSubmit -> "wechat-login"
|
||||
orderDetailEntryAfterSubmit -> "success"
|
||||
else -> "order"
|
||||
}
|
||||
}
|
||||
"查看订单", "订单详情" -> {
|
||||
orderDetailEntryClicks++
|
||||
page = if (orderDetailEvidenceBelowFold) "order-folded" else "order"
|
||||
}
|
||||
}
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
@@ -428,7 +544,11 @@ class PurchaseLiveAutomationTest {
|
||||
address = value
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { genericSwipes++; return true }
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
genericSwipes++
|
||||
if (page in setOf("order-folded", "order-folded-payment-activity") && direction == SwipeDirection.UP) page = "order"
|
||||
return true
|
||||
}
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
scopedSwipes++
|
||||
if (target.path == "panel" && direction == SwipeDirection.DOWN) addressVisible = true
|
||||
@@ -439,7 +559,7 @@ class PurchaseLiveAutomationTest {
|
||||
if (page == "chooser" || page == "payment") postSubmitBackCount++
|
||||
page = when (page) {
|
||||
"chooser" -> "payment"
|
||||
"payment" -> "order"
|
||||
"payment" -> if (orderEvidenceBelowFold) "order-folded" else "order"
|
||||
else -> "confirmation"
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -418,6 +418,50 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals(50, pauses.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
|
||||
val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrecognized opened panel returns only scalar panel evidence`() {
|
||||
val driver = FakePurchaseDriver(unrecognizedPanel = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_NOT_OPENED", outcome.errorCode)
|
||||
assertEquals(
|
||||
"点击后未识别到商品规格面板 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true]",
|
||||
outcome.message,
|
||||
)
|
||||
assertFalse(outcome.message.orEmpty().contains("确认款式"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing spec entry emits scalar source counts without node text`() {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val driver = FakePurchaseDriver(missingSpecEntry = true)
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
driver,
|
||||
{ driver.browser = true; true },
|
||||
{ null },
|
||||
pause = {},
|
||||
panelDiagnostic = diagnostics::add,
|
||||
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
|
||||
assertEquals(
|
||||
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true",
|
||||
diagnostics.single(),
|
||||
)
|
||||
assertFalse(diagnostics.single().contains("选择规格"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transient sold out page recovers before opening specs`() {
|
||||
val driver = FakePurchaseDriver(soldOut = true, recoverSoldOutAfterPull = true)
|
||||
@@ -709,6 +753,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val priceCent: Long = 2_000,
|
||||
private val duplicateOpen: Boolean = false,
|
||||
private val bottomPurchaseEntry: Boolean = false,
|
||||
private val missingSpecEntry: Boolean = false,
|
||||
private val includeReviewEntry: Boolean = false,
|
||||
private val openReviewOnBottomClick: Boolean = false,
|
||||
private val reviewBackSucceeds: Boolean = true,
|
||||
@@ -732,6 +777,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val loseEvidenceAfterPull: Boolean = false,
|
||||
private val unavailableSizes: Set<String> = emptySet(),
|
||||
allSpecsUnavailable: Boolean = false,
|
||||
private val nonScrollablePanel: Boolean = false,
|
||||
private val unrecognizedPanel: Boolean = false,
|
||||
private val purchaseSwipeSucceeds: Boolean = true,
|
||||
) : PurchaseUiDriver {
|
||||
var browser = false
|
||||
var panel = false
|
||||
@@ -784,12 +832,18 @@ class PurchaseRehearsalExecutorTest {
|
||||
nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true)
|
||||
nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy")
|
||||
nodes += node("buy/label", "免拼购买", 780, 1840, 1040, 1910, parentPath = "buy")
|
||||
} else {
|
||||
} else if (!missingSpecEntry) {
|
||||
nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true)
|
||||
}
|
||||
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
|
||||
return UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
}
|
||||
if (unrecognizedPanel) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
node("panel-title", "确认款式", 20, 396, 300, 430),
|
||||
))
|
||||
}
|
||||
val hideColor = hideColorAfterQuantitySet && quantity == 2L
|
||||
val hideSize = hideSizeAfterQuantitySet && quantity == 2L
|
||||
val hideSummary = hideSelectedSummaryAfterQuantitySet && quantity == 2L
|
||||
@@ -802,8 +856,8 @@ class PurchaseRehearsalExecutorTest {
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
node("price", "¥${priceCent / 100}.${(priceCent % 100).toString().padStart(2, '0')}", 20, 300, 300, 360),
|
||||
node("title", "确认款式", 20, 396, 300, 430),
|
||||
node("scroll", "", 0, 400, 1080, 950, scrollable = true),
|
||||
)
|
||||
if (!nonScrollablePanel) nodes += node("scroll", "", 0, 400, 1080, 950, scrollable = true)
|
||||
if (!hideSummary) {
|
||||
nodes += node("selected", "已选 $displayedSummary", 20, 365, 700, 395)
|
||||
}
|
||||
@@ -912,7 +966,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
swipeCount++
|
||||
if (direction == SwipeDirection.UP) upSwipeCount++
|
||||
return true
|
||||
return purchaseSwipeSucceeds
|
||||
}
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean =
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 3e4f463cfa026a09ba125f2eb376bd42ef41c880
|
||||
synchronized_at: 2026-09-01T08:03:37Z
|
||||
wiki_revision: 9b195f3c578f980b808e6eb28f36e7bf5294e75b
|
||||
synchronized_at: 2026-09-03T01:55:28Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -31,9 +31,9 @@ synchronized_at: 2026-09-01T08:03:37Z
|
||||
- 关联 PDD 商品时校验目标存在且非 `disabled`;不提供手工输入商品 ID 的入口,只能通过搜索选择。
|
||||
- 参考售价 `sale_price_cent` 为整数分,`currency` 为 ISO 4217 代码;币种取系统配置默认值,不逐商品选择,缺省回退为 `TWD`。
|
||||
- 规格值来源分 `import`(SYB 导入,不可人工删除,只能清除映射)与 `manual`(人工添加,可删除);导入与人工值按「维度 + 名称」合并,不重复创建。
|
||||
- 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称标准化后唯一一致)、`ai_match`(AI 建议)。通用 `SetMapping` 入口仍将 `exact_match` 与 `ai_match` 一律写为 `pending`,必须人工确认后才生效;#188 的 SYB 商品页批量 AI 匹配是独立例外:只有 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,并且最近一次成功或部分成功采集提供完整可售 SKU 组合证据时才进入批量匹配;未人工确认的 `parse_status=uncertain` 必须先人工修正;`manually_confirmed=true` 与 `parse_status=success` 同等视为可信规格。唯一确定性 `exact_match` 不调用外部 AI,可直接保存为 `confirmed`;其余结果只有在 `ai_match` 置信度存在且达到服务端阈值、理由非空、返回值属于当前可选候选并命中同一个可售颜色+尺码组合时,才可直接持久化为 `confirmed`。低置信度、无组合证据、无效组合或 Provider 异常不得改写现有映射。
|
||||
- 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称标准化后唯一一致)、`ai_match`(AI 建议)。通用 `SetMapping` 入口仍将 `exact_match` 与 `ai_match` 一律写为 `pending`,必须人工确认后才生效;#188 的 SYB 商品页批量 AI 匹配与 #194 的 Admin 蝦皮商品详情一键匹配是两个独立例外。#188 的进入条件已由 #190 放开:只要蝦皮与 PDD 关联完整、PDD 当前为 `active` 且能给出可选颜色/尺码候选、并且已解析出至少一个目标颜色或尺码,即可进入批量匹配。解析状态(含 `parse_status=uncertain` 与 `failed`)、PDD 含颜色尺码之外的可选规格、蝦皮档案中未找到目标颜色或尺码、缺少完整可售 SKU 组合证据,自 #190 起都不再阻断匹配;本项目为内部系统,由此产生的“以错误目标规格进行匹配并保存映射”的风险由人工承担。唯一确定性 `exact_match` 不调用外部 AI,可直接保存为 `confirmed`;SYB 商品页批量 `ai_match` 只要 Provider 成功返回规格结果且理由非空、返回值属于当前可选候选即可直接持久化为 `confirmed`,置信度仅记录供审计、不作为放行门槛。若当前 PDD 档案存在完整可售颜色+尺码 SKU 组合证据,结果还必须命中其中同一个组合;人工录入或外部导入导致组合证据缺失时不阻断保存或创建采购。无结果、已有组合证据中的无效组合或 Provider 异常不得改写现有映射。
|
||||
- 颜色映射只能从关联 PDD 商品当前可选颜色中选择,不允许自由输入;未使用颜色优先显示,已被其他蝦皮颜色使用的颜色仍可选择并显示占用者,因此支持多对一。
|
||||
- 尺码“一键自动匹配”只生成可审阅草稿:繁简体、首尾/重复空格、大小写和全半角统一后只有唯一结果才预填;没有唯一结果时保持待人工选择。保存只改映射,不改蝦皮或 PDD 原始规格。
|
||||
- Admin 蝦皮商品详情只保留一个“一键匹配颜色和尺码”入口。已确认且目标仍存在的映射保留;名称标准化后的唯一确定结果直接写为 `exact_match + confirmed`;其余只有在 AI 置信度达到服务端当前阈值、理由非空、返回值仍属于当前可选候选时才写为 `ai_match + confirmed`。低置信度或无结果保持未匹配;Provider 失败、AI 未启用、关联或规格上下文变化时本次不写入。操作前有未保存的人工修改时禁用一键匹配;保存只改映射,不改蝦皮或 PDD 原始规格。
|
||||
- PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
|
||||
- 批量软删除逐条校验引用(虾皮商品被 SYB 明细或采购任务引用时不可删除)并逐条返回结果;已删除商品默认不出现在列表,可筛选查看并恢复。
|
||||
|
||||
@@ -150,7 +150,7 @@ synchronized_at: 2026-09-01T08:03:37Z
|
||||
- 采购规格由服务端按顺序决策:先使用已确认的人工映射;否则仅在同一规格角色的可选 PDD 原始标签中做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算);仍无唯一结果才调用已启用的服务端 AI。AI 必须返回候选集中的原始标签,候选不完整、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。
|
||||
- Agent 选择服务端下发的精确颜色或尺码时,只能在已确认打开的规格面板内有限纵向滑动、每次重新读取可选节点并按完整原始文字点击;连续没有新证据或达到上限即停止,不得点击相近规格。第一趟探测并固化规格后,第二趟仍无法精确选择而再次提交探测时,服务端必须明确失败并释放活动槽,保留第一次决策证据,禁止清空决策、循环派发或进入地址与创建订单动作。
|
||||
- 采购 Agent 在确认进入 PDD 商品页后、打开规格前,复用采集侧的假售罄识别;规格面板已打开且当前解析到的规格值全部不可选时也按售罄处理。两种情况都只允许关闭规格面板后对商品页执行一次有界恢复(默认下拉 2 次、间隔 1000 毫秒、等待 2000 毫秒),恢复动作失败或恢复后仍售罄时返回 `PDD_GOODS_SOLD_OUT`,恢复后商品页证据丢失时按页面规则不匹配失败;不得继续选择规格、修改地址或创建订单。该失败码继续进入既有替代商品资格判定。
|
||||
- 规格映射不完整、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。Android 不自行匹配或猜测;其只接收服务端已经固化的精确原始规格标签。
|
||||
- 规格映射不完整、已保存的确认映射对当前 PDD 档案失效、确定性匹配失败、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;自 #190 起前两种情况不再拒绝创建,而是把 `spec_source` 降级为 `unresolved` 并交由规格探测解析,规则不具备该能力时仍然拒绝创建;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。Android 不自行匹配或猜测;其只接收服务端已经固化的精确原始规格标签。
|
||||
- Agent 提交的相同 attempt 最终结果只能写入一次;相同请求重放返回原事实,不同内容拒绝覆盖。`order_result_unknown` 不参与自动派发,只能人工解除。
|
||||
- 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。
|
||||
- 人工回填候选只允许从已支付订单选择;同一 SYB 明细后来选择的订单覆盖旧候选,但不删除旧订单事实。
|
||||
@@ -393,3 +393,19 @@ synchronized_at: 2026-09-01T08:03:37Z
|
||||
- 规格映射摘要使用 `specMappingStatus=complete|incomplete|not_required`、确认数和总数。`not_required` 只表示虾皮商品没有颜色或尺码维度;商品摘要不替代订单行采购资格。
|
||||
- “关联订单继续采购”只处理已有 SYB 订单;“创建备货采购”不关联 SYB 订单。两者入口和文案必须明确区分。继续采购复用既有批量预检和批量创建,价格、映射、并发、订单和不可逆门禁不变。
|
||||
- SYB 订单号、目标颜色/尺码、数量和当前采购任务状态只允许管理员与采购员读取;其他角色由服务端拒绝。查询路径不调用 AI Provider。
|
||||
|
||||
## 定时与手动蝦皮规格自动匹配(#195)
|
||||
|
||||
- 系统只扫描存活且已关联 `active` PDD 商品、两边至少共享颜色或尺码角色、并且至少存在一个未确认或已失效映射的蝦皮商品。
|
||||
- 每个商品复用详情页“一键匹配颜色和尺码”的规则:保留当前仍有效的 `confirmed` 映射;唯一确定匹配和达到服务端阈值、理由非空、候选仍有效的 AI 结果直接保存为 `confirmed`;低置信度、无结果、Provider 异常或上下文漂移不猜测、不写入错误映射。
|
||||
- 定时与管理员手动执行共用全局活动槽和逐商品工作状态。单批默认最多 20 个商品;同一规格上下文与 AI 设置更新时间未变化时,已完成、低置信度或无结果商品不重复调用 AI。
|
||||
- Provider 临时失败最多尝试 3 次,间隔至少 60 分钟;输入变化后重新计算指纹并允许重新处理。运行记录只保存结构化计数和脱敏限长错误,不保存 API Key、Provider 原始响应、商品原始 JSON 或个人数据。
|
||||
- 系统定时任务迁移后默认关闭,须由管理员明确启用。该能力仅维护蝦皮与 PDD 颜色/尺码映射,不创建采购任务、PDD 订单,不触发 Agent,也不执行付款。
|
||||
|
||||
## SYB 异常采购规格 AI 解析(#198)
|
||||
|
||||
- SYB 采购规格采用两阶段流程:先把货运单明细 `productSpec` 解析为目标颜色/尺码,再由 #195 把蝦皮规格匹配到 PDD 规格;两阶段不得混为同一匹配事实。
|
||||
- 定时任务只处理未人工确认且确定性解析为 `uncertain` / `failed` 的明细。每条先重跑确定性解析;空 `productSpec`、无关联蝦皮商品、无颜色/尺码候选或同一角色存在多个候选维度时不调用 AI,继续人工处理。
|
||||
- AI 只在关联蝦皮商品的封闭候选集合中返回原始颜色/尺码,并必须提供达到当前自动确认阈值的置信度和非空理由;集合外值、缺失角色、低置信度、歧义、无结果和输入漂移都不得确认。
|
||||
- `parse_status` 保留确定性解析器结论;AI 与人工确认分别记录,人工优先级最高。重复同步不得覆盖人工值;完全相同输入保留 AI 值,来源或关联变化会清除旧 AI 确认。
|
||||
- 同一输入的低置信度或无结果不重复调用 Provider;临时故障至少 60 分钟后重试,最多 3 次。任务不创建采集/采购任务、订单,不执行 Android 动作或付款。
|
||||
|
||||
@@ -47,6 +47,7 @@ var AdminAPIs = []APIPermission{
|
||||
{"预览尺码匹配", "/api/admin/v1/shopee-products/:productId/specs/mapping/preview-auto-size", "POST", true},
|
||||
{"AI 建议颜色映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-colors", "POST", true},
|
||||
{"AI 建议尺码映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-sizes", "POST", true},
|
||||
{"一键匹配并确认颜色尺码", "/api/admin/v1/shopee-products/:productId/specs/mapping/auto-match", "POST", true},
|
||||
|
||||
{"查看 SYB 商品", "/api/admin/v1/syb-products", "GET", true},
|
||||
{"查看 SYB 商品详情", "/api/admin/v1/syb-products/:productId", "GET", true},
|
||||
|
||||
@@ -62,3 +62,12 @@ func TestPurchaserMayRunBatchSpecMatch(t *testing.T) {
|
||||
}
|
||||
t.Fatal("missing purchaser batch spec match permission")
|
||||
}
|
||||
|
||||
func TestPurchaserMayAutoMatchShopeeProductSpecs(t *testing.T) {
|
||||
for _, permission := range PurchaserAPIs() {
|
||||
if permission.Method == "POST" && permission.Path == "/api/admin/v1/shopee-products/:productId/specs/mapping/auto-match" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("missing purchaser shopee auto-match permission")
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (handler Handler) Register(context *gin.Context) {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
response, err := NewService(db).Register(context.Request.Context(), request, bearerToken(context.GetHeader("Authorization")))
|
||||
response, err := NewService(db).Register(context.Request.Context(), request, bearerToken(context.GetHeader("Authorization")), strings.TrimSpace(context.GetHeader("X-GoAuto-Device-Recovery-Code")))
|
||||
if err != nil {
|
||||
writeError(context, err)
|
||||
return
|
||||
@@ -84,6 +84,26 @@ func (handler Handler) RevokeToken(context *gin.Context) {
|
||||
handler.adminAction(context, (*Service).RevokeToken)
|
||||
}
|
||||
|
||||
func (handler Handler) ResetIdentity(context *gin.Context) {
|
||||
deviceID, err := strconv.ParseUint(context.Param("deviceId"), 10, 64)
|
||||
if err != nil || deviceID == 0 {
|
||||
writeError(context, invalidRequest("deviceId 无效"))
|
||||
return
|
||||
}
|
||||
db, err := handler.database(context)
|
||||
if err != nil {
|
||||
writeError(context, internalError(err))
|
||||
return
|
||||
}
|
||||
response, err := NewService(db).ResetIdentity(context.Request.Context(), deviceID)
|
||||
if err != nil {
|
||||
writeError(context, err)
|
||||
return
|
||||
}
|
||||
context.Header("Cache-Control", "no-store")
|
||||
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) adminAction(context *gin.Context, action func(*Service, stdcontext.Context, uint64) error) {
|
||||
deviceID, err := strconv.ParseUint(context.Param("deviceId"), 10, 64)
|
||||
if err != nil || deviceID == 0 {
|
||||
|
||||
@@ -22,5 +22,6 @@ func InitRouter(engine *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
admin := engine.Group("/api/admin/v1/devices").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
admin.GET("", handler.List)
|
||||
admin.POST("/:deviceId/disable", middleware.RequireRoleKey("admin"), handler.Disable)
|
||||
admin.POST("/:deviceId/identity-reset", middleware.RequireRoleKey("admin"), handler.ResetIdentity)
|
||||
admin.POST("/:deviceId/token/revoke", middleware.RequireRoleKey("admin"), handler.RevokeToken)
|
||||
}
|
||||
|
||||
@@ -28,8 +28,12 @@ const (
|
||||
CodeDeviceTaskMismatch = "DEVICE_TASK_MISMATCH"
|
||||
CodeDeviceNotFound = "DEVICE_NOT_FOUND"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
CodeRecoveryInvalid = "DEVICE_RECOVERY_INVALID"
|
||||
CodeRecoveryExpired = "DEVICE_RECOVERY_EXPIRED"
|
||||
)
|
||||
|
||||
const deviceRecoveryLifetime = 10 * time.Minute
|
||||
|
||||
type ServiceError struct {
|
||||
Code string
|
||||
Message string
|
||||
@@ -67,6 +71,11 @@ type RegisterResponse struct {
|
||||
Replayed bool `json:"replayed,omitempty"`
|
||||
}
|
||||
|
||||
type ResetIdentityResponse struct {
|
||||
DeviceID uint64 `json:"deviceId"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Now func() time.Time
|
||||
@@ -108,6 +117,10 @@ func tokenMatches(token, digest string) bool {
|
||||
return subtle.ConstantTimeCompare(got[:], want) == 1
|
||||
}
|
||||
|
||||
func digestMatches(value string, digest *string) bool {
|
||||
return digest != nil && tokenMatches(value, *digest)
|
||||
}
|
||||
|
||||
// Authenticate returns the active device represented by a bearer token.
|
||||
// Agent feature packages use this method so token verification stays in one
|
||||
// place and raw tokens never leave request memory.
|
||||
@@ -131,7 +144,11 @@ func (service *Service) Authenticate(ctx context.Context, token string) (models.
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (service *Service) Register(ctx context.Context, request RegisterRequest, presentedToken string) (RegisterResponse, error) {
|
||||
func (service *Service) Register(ctx context.Context, request RegisterRequest, presentedToken string, recoveryCodes ...string) (RegisterResponse, error) {
|
||||
recoveryCode := ""
|
||||
if len(recoveryCodes) > 0 {
|
||||
recoveryCode = recoveryCodes[0]
|
||||
}
|
||||
request = normalizeRegisterRequest(request)
|
||||
if err := validateRegisterRequest(request); err != nil {
|
||||
return RegisterResponse{}, err
|
||||
@@ -162,9 +179,15 @@ func (service *Service) Register(ctx context.Context, request RegisterRequest, p
|
||||
if existing.Status == models.DeviceStatusDisabled {
|
||||
return RegisterResponse{}, &ServiceError{Code: CodeDeviceDisabled, Message: "设备已停用", Retryable: false}
|
||||
}
|
||||
if !tokenMatches(presentedToken, existing.TokenDigest) {
|
||||
return RegisterResponse{}, &ServiceError{
|
||||
Code: CodeInstallIDConflict, Message: "installId 已注册,需要该设备的有效 Token", Retryable: false,
|
||||
usingRecovery := !tokenMatches(presentedToken, existing.TokenDigest)
|
||||
if usingRecovery {
|
||||
validCode := recoveryCode != "" && digestMatches(recoveryCode, existing.RecoveryCodeDigest)
|
||||
autoRecovery := recoveryCode == "" && existing.RecoveryCodeDigest == nil && existing.RecoveryExpiresAt != nil && existing.RecoveryUsedAt == nil
|
||||
if !validCode && !autoRecovery {
|
||||
return RegisterResponse{}, &ServiceError{Code: CodeInstallIDConflict, Message: "installId 已注册,需要该设备的有效 Token", Retryable: false}
|
||||
}
|
||||
if existing.RecoveryExpiresAt == nil || !service.Now().Before(*existing.RecoveryExpiresAt) {
|
||||
return RegisterResponse{}, &ServiceError{Code: CodeRecoveryExpired, Message: "设备身份重置窗口已过期,请在后台重新操作", Retryable: false}
|
||||
}
|
||||
}
|
||||
updates := map[string]any{
|
||||
@@ -175,6 +198,20 @@ func (service *Service) Register(ctx context.Context, request RegisterRequest, p
|
||||
if request.Capabilities != nil {
|
||||
updates["capabilities_json"] = encodeCapabilities(request.Capabilities)
|
||||
}
|
||||
if usingRecovery {
|
||||
newToken, generateErr := service.GenerateToken()
|
||||
if generateErr != nil {
|
||||
return RegisterResponse{}, internalError(generateErr)
|
||||
}
|
||||
now := service.Now()
|
||||
updates["token_digest"] = tokenDigest(newToken)
|
||||
updates["token_issued_at"] = now
|
||||
updates["recovery_code_digest"] = nil
|
||||
updates["recovery_expires_at"] = nil
|
||||
updates["recovery_used_at"] = now
|
||||
updates["status"] = models.DeviceStatusOnline
|
||||
response.DeviceToken = newToken
|
||||
}
|
||||
result := db.Model(&models.AgentDevice{}).
|
||||
Where("id = ? AND token_digest = ? AND token_revoked_at IS NULL", existing.ID, existing.TokenDigest).
|
||||
Updates(updates)
|
||||
@@ -222,6 +259,42 @@ func (service *Service) Register(ctx context.Context, request RegisterRequest, p
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ResetIdentity invalidates the current token and opens a short-lived automatic
|
||||
// re-registration window for the same installId. It retains the device row and
|
||||
// task bindings, so no recovery code needs to leave the Admin workflow.
|
||||
func (service *Service) ResetIdentity(ctx context.Context, deviceID uint64) (ResetIdentityResponse, error) {
|
||||
if service.DB == nil {
|
||||
return ResetIdentityResponse{}, internalError(errors.New("database is nil"))
|
||||
}
|
||||
now := service.Now()
|
||||
expiresAt := now.Add(deviceRecoveryLifetime)
|
||||
response := ResetIdentityResponse{DeviceID: deviceID, ExpiresAt: expiresAt}
|
||||
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var device models.AgentDevice
|
||||
if err := tx.First(&device, deviceID).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &ServiceError{Code: CodeDeviceNotFound, Message: "设备不存在", Retryable: false}
|
||||
} else if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if device.Status == models.DeviceStatusDisabled {
|
||||
return &ServiceError{Code: CodeDeviceDisabled, Message: "设备已停用", Retryable: false}
|
||||
}
|
||||
invalidatedToken, generateErr := service.GenerateToken()
|
||||
if generateErr != nil {
|
||||
return internalError(generateErr)
|
||||
}
|
||||
return tx.Model(&models.AgentDevice{}).Where("id = ?", deviceID).Updates(map[string]any{
|
||||
"token_digest": tokenDigest(invalidatedToken), "token_issued_at": now,
|
||||
"recovery_code_digest": nil, "recovery_expires_at": expiresAt,
|
||||
"recovery_used_at": nil, "status": models.DeviceStatusOffline,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return ResetIdentityResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (service *Service) Disable(ctx context.Context, deviceID uint64) error {
|
||||
return service.deactivate(ctx, deviceID, false)
|
||||
}
|
||||
|
||||
@@ -185,6 +185,72 @@ func TestDisableAndRevokePreventFurtherAuthentication(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetIdentityRecoversSameDeviceAndRotatesToken(t *testing.T) {
|
||||
db := openTestDatabase(t)
|
||||
service := newTestService(t, db)
|
||||
tokens := []string{"original-token", "invalidated-token", "replacement-token"}
|
||||
service.GenerateToken = func() (string, error) {
|
||||
if len(tokens) == 0 {
|
||||
t.Fatal("unexpected token generation")
|
||||
}
|
||||
value := tokens[0]
|
||||
tokens = tokens[1:]
|
||||
return value, nil
|
||||
}
|
||||
request := validRegisterRequest()
|
||||
registered, err := service.Register(context.Background(), request, "")
|
||||
if err != nil {
|
||||
t.Fatalf("first registration: %v", err)
|
||||
}
|
||||
recovery, err := service.ResetIdentity(context.Background(), registered.DeviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("reset identity: %v", err)
|
||||
}
|
||||
if !recovery.ExpiresAt.After(service.Now()) {
|
||||
t.Fatalf("unexpected recovery response: %+v", recovery)
|
||||
}
|
||||
request.RequestID = uuid.NewString()
|
||||
recovered, err := service.Register(context.Background(), request, "original-token")
|
||||
if err != nil {
|
||||
t.Fatalf("recover registration: %v", err)
|
||||
}
|
||||
if recovered.DeviceID != registered.DeviceID || recovered.DeviceToken != "replacement-token" {
|
||||
t.Fatalf("unexpected recovery result: %+v", recovered)
|
||||
}
|
||||
var stored models.AgentDevice
|
||||
if err := db.First(&stored, registered.DeviceID).Error; err != nil {
|
||||
t.Fatalf("load recovered device: %v", err)
|
||||
}
|
||||
if stored.InstallID != strings.ToLower(request.InstallID) || stored.Status != models.DeviceStatusOnline || stored.RecoveryCodeDigest != nil || stored.RecoveryUsedAt == nil {
|
||||
t.Fatalf("recovery did not retain device safely: %+v", stored)
|
||||
}
|
||||
request.RequestID = uuid.NewString()
|
||||
if _, err := service.Register(context.Background(), request, "original-token"); serviceErrorCode(t, err) != CodeInstallIDConflict {
|
||||
t.Fatal("automatic recovery window was reusable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetIdentityRejectsExpiredRecoveryCode(t *testing.T) {
|
||||
db := openTestDatabase(t)
|
||||
service := newTestService(t, db)
|
||||
tokens := []string{"original-token", "invalidated-token"}
|
||||
service.GenerateToken = func() (string, error) { value := tokens[0]; tokens = tokens[1:]; return value, nil }
|
||||
request := validRegisterRequest()
|
||||
registered, err := service.Register(context.Background(), request, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recovery, err := service.ResetIdentity(context.Background(), registered.DeviceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.Now = func() time.Time { return recovery.ExpiresAt.Add(time.Second) }
|
||||
request.RequestID = uuid.NewString()
|
||||
if _, err := service.Register(context.Background(), request, ""); serviceErrorCode(t, err) != CodeRecoveryExpired {
|
||||
t.Fatalf("expected expired recovery code, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationValidationRejectsInvalidUUIDs(t *testing.T) {
|
||||
service := newTestService(t, openTestDatabase(t))
|
||||
request := validRegisterRequest()
|
||||
|
||||
@@ -39,6 +39,9 @@ type AgentDevice struct {
|
||||
TokenDigest string `json:"-" gorm:"size:64;not null;uniqueIndex:ux_agent_device_token_digest"`
|
||||
TokenIssuedAt time.Time `json:"tokenIssuedAt" gorm:"not null"`
|
||||
TokenRevokedAt *time.Time `json:"tokenRevokedAt" gorm:"index"`
|
||||
RecoveryCodeDigest *string `json:"-" gorm:"size:64;index"`
|
||||
RecoveryExpiresAt *time.Time `json:"-" gorm:"index"`
|
||||
RecoveryUsedAt *time.Time `json:"-"`
|
||||
LastRegisterRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_agent_device_last_register_request_id"`
|
||||
LastHeartbeatRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_agent_device_last_heartbeat_request_id"`
|
||||
LastHeartbeatAt *time.Time `json:"lastHeartbeatAt" gorm:"index"`
|
||||
|
||||
@@ -2,13 +2,11 @@ package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/productspec"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
)
|
||||
|
||||
@@ -34,7 +32,10 @@ type skuCombinationRow struct {
|
||||
}
|
||||
|
||||
func sybSpecsTrusted(syb models.SYBProduct) bool {
|
||||
return syb.ParseStatus == models.SYBParseStatusSuccess || syb.ManuallyConfirmed
|
||||
if syb.ParseStatus == models.SYBParseStatusFailed {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(syb.TargetColor) != "" || strings.TrimSpace(syb.TargetSize) != ""
|
||||
}
|
||||
|
||||
func (s *Service) loadLatestSKUCombinations(ctx context.Context, pddIDs []uint64, dataset *batchPreviewDataset) error {
|
||||
@@ -96,12 +97,6 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
if !found {
|
||||
return disabled("SYB 商品不存在或已删除")
|
||||
}
|
||||
if !sybSpecsTrusted(syb) {
|
||||
if syb.ParseStatus == models.SYBParseStatusUncertain {
|
||||
return disabled("采购规格解析存疑,请先人工修正并确认")
|
||||
}
|
||||
return disabled("采购规格解析失败,请先重新解析或人工修正")
|
||||
}
|
||||
if strings.TrimSpace(syb.TargetColor) == "" && strings.TrimSpace(syb.TargetSize) == "" {
|
||||
return disabled("未解析出需要采购的颜色或尺码")
|
||||
}
|
||||
@@ -119,9 +114,6 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
if !found || pdd.Status != "active" {
|
||||
return disabled("关联的 PDD 商品尚未采集完成或已停用")
|
||||
}
|
||||
if hasSelectableOtherDimension(pdd.SpecsJSON) {
|
||||
return disabled("PDD 商品包含颜色、尺码之外的可选规格,请人工处理")
|
||||
}
|
||||
candidates, usable := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
if !usable {
|
||||
return disabled("关联的 PDD 商品没有完整可选规格")
|
||||
@@ -139,18 +131,9 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
request := aimatching.MatchRequest{Colors: candidates.Colors, Sizes: candidates.Sizes}
|
||||
if mappedColor == "" {
|
||||
request.TargetColor = syb.TargetColor
|
||||
if !shopeeHasTarget(shopee.SpecsJSON, shopeeproduct.RoleColor, syb.TargetColor) {
|
||||
return disabled("蝦皮商品中未找到目标颜色,请先修正商品规格")
|
||||
}
|
||||
}
|
||||
if mappedSize == "" {
|
||||
request.TargetSize = syb.TargetSize
|
||||
if !shopeeHasTarget(shopee.SpecsJSON, shopeeproduct.RoleSize, syb.TargetSize) {
|
||||
return disabled("蝦皮商品中未找到目标尺码,请先修正商品规格")
|
||||
}
|
||||
}
|
||||
if len(dataset.skuCombinationsByPDD[pdd.ID]) == 0 {
|
||||
return disabled("缺少当前 PDD 商品的完整可售 SKU 组合,请先重新采集")
|
||||
}
|
||||
if matched, ok := aimatching.DeterministicMatch(request); ok {
|
||||
color, size := mappedColor, mappedSize
|
||||
@@ -160,7 +143,10 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
|
||||
if request.TargetSize != "" {
|
||||
size = matched.MappedSize
|
||||
}
|
||||
if validSKUCombination(dataset.skuCombinationsByPDD[pdd.ID], syb.TargetColor, syb.TargetSize, color, size) {
|
||||
// 人工录入或外部导入的 PDD 档案可以没有采集任务 SKU 证据。没有
|
||||
// 证据时只确认候选值;一旦有证据,仍必须命中同一个可售组合。
|
||||
combinations := dataset.skuCombinationsByPDD[pdd.ID]
|
||||
if len(combinations) == 0 || validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, color, size) {
|
||||
return aiMatchQualification{Eligible: true, Request: request, MappedColor: mappedColor, MappedSize: mappedSize, Deterministic: &matched}
|
||||
}
|
||||
}
|
||||
@@ -181,42 +167,3 @@ func validSKUCombination(combinations []pddSKUCombination, targetColor, targetSi
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shopeeHasTarget(raw, role, target string) bool {
|
||||
if strings.TrimSpace(target) == "" {
|
||||
return true
|
||||
}
|
||||
specs, err := shopeeproduct.Unmarshal(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != role {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
if value.Name == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSelectableOtherDimension(raw string) bool {
|
||||
var dimensions []productspec.Dimension
|
||||
if json.Unmarshal([]byte(raw), &dimensions) != nil {
|
||||
return false
|
||||
}
|
||||
for _, dimension := range dimensions {
|
||||
if dimension.Role != "other" {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
if value.Selectable {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -424,9 +424,10 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
|
||||
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
item.MappedColor, item.MappedSize = mappedColor, mappedSize
|
||||
candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
// #190:失效的已保存映射不再拦截,降级为 unresolved,与创建路径保持一致。
|
||||
if source != "unresolved" && !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "规格匹配已失效,请重新选择 PDD 规格", "open_mapping"
|
||||
return item
|
||||
mappedColor, mappedSize, source = "", "", "unresolved"
|
||||
item.MappedColor, item.MappedSize = "", ""
|
||||
}
|
||||
if source == "unresolved" {
|
||||
if !archiveUsable {
|
||||
@@ -443,10 +444,8 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
|
||||
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
|
||||
} else if matched, ok := aimatching.DeterministicMatch(request); ok {
|
||||
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
|
||||
} else {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "规格映射不完整,需要 AI 匹配或人工确认", "open_mapping"
|
||||
return item
|
||||
}
|
||||
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
|
||||
}
|
||||
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor, guard)
|
||||
if err != nil {
|
||||
@@ -480,8 +479,9 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
|
||||
// enforcePersistedMatch is the #188 gate for the SYB list and batch-create
|
||||
// workflow. A transient deterministic result is not purchase readiness: the
|
||||
// color/size mapping must already be confirmed on the Shopee product and must
|
||||
// still identify one complete, available SKU combination from the latest
|
||||
// successful collection.
|
||||
// still identify one complete, available SKU combination when collection
|
||||
// evidence exists. Manually entered or externally imported PDD products may
|
||||
// legitimately have no such collection record.
|
||||
func (item *BatchPreviewItem) enforcePersistedMatch(id uint64, dataset batchPreviewDataset) {
|
||||
if item.ReasonCode != "" && item.ReasonCode != CodeMappingRequired {
|
||||
return
|
||||
@@ -506,12 +506,7 @@ func (item *BatchPreviewItem) enforcePersistedMatch(id uint64, dataset batchPrev
|
||||
return
|
||||
}
|
||||
combinations := dataset.skuCombinationsByPDD[pdd.ID]
|
||||
if len(combinations) == 0 {
|
||||
item.Eligible = false
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "缺少当前 PDD 商品的完整可售 SKU 组合,请先重新采集", "open_pdd"
|
||||
return
|
||||
}
|
||||
if !validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
|
||||
if len(combinations) > 0 && !validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
|
||||
item.Eligible = false
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "已保存规格映射不属于当前可售的 PDD 规格组合,请重新匹配", "open_mapping"
|
||||
}
|
||||
|
||||
@@ -50,11 +50,6 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
|
||||
if err != nil {
|
||||
return BatchSpecMatchResponse{}, internal(err)
|
||||
}
|
||||
settings, err := aimatching.NewService(s.DB).Settings(ctx)
|
||||
if err != nil {
|
||||
return BatchSpecMatchResponse{}, internal(err)
|
||||
}
|
||||
|
||||
response := BatchSpecMatchResponse{Items: make([]BatchSpecMatchItem, 0, len(ids))}
|
||||
for _, id := range ids {
|
||||
item := BatchSpecMatchItem{SYBProductID: id, Status: BatchSpecMatchFailed}
|
||||
@@ -87,9 +82,11 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
|
||||
}
|
||||
}
|
||||
item.Source, item.Confidence = matched.Source, matched.Decision.Confidence
|
||||
autoConfirm := qualification.Deterministic != nil || (matched.Source == aimatching.SourceAI && matched.Decision.Confidence != nil && *matched.Decision.Confidence >= settings.AutoConfirmMinConfidence && strings.TrimSpace(matched.Decision.Reason) != "")
|
||||
// #200:在 SYB 批量入口,AI 只要返回了可保存的规格结果,就由后续的
|
||||
// 候选与可售 SKU 组合校验决定是否放行;置信度仅保留为审计信息。
|
||||
autoConfirm := qualification.Deterministic != nil || (matched.Source == aimatching.SourceAI && strings.TrimSpace(matched.Decision.Reason) != "")
|
||||
if !autoConfirm {
|
||||
item.Status, item.Reason = BatchSpecMatchPending, "匹配结果未达到自动确认阈值,请人工确认"
|
||||
item.Status, item.Reason = BatchSpecMatchPending, "AI 未返回可用规格结果,请人工确认"
|
||||
if strings.TrimSpace(matched.Decision.Reason) != "" {
|
||||
item.Reason += ":" + strings.TrimSpace(matched.Decision.Reason)
|
||||
}
|
||||
@@ -104,7 +101,8 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
|
||||
if request.TargetSize != "" {
|
||||
mappedSize = matched.MappedSize
|
||||
}
|
||||
if !mappingTargetsValid(specCandidates{Colors: request.Colors, Sizes: request.Sizes}, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) || !validSKUCombination(dataset.skuCombinationsByPDD[pdd.ID], syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
|
||||
combinations := dataset.skuCombinationsByPDD[pdd.ID]
|
||||
if !mappingTargetsValid(specCandidates{Colors: request.Colors, Sizes: request.Sizes}, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) || (len(combinations) > 0 && !validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize)) {
|
||||
item.Status, item.Reason = BatchSpecMatchPending, "AI 结果不是当前可售的 PDD 规格组合,请人工确认"
|
||||
response.PendingCount++
|
||||
response.Items = append(response.Items, item)
|
||||
@@ -117,7 +115,7 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
|
||||
response.Items = append(response.Items, item)
|
||||
continue
|
||||
}
|
||||
if _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopee.ID, uuid.NewString(), settings.AutoConfirmMinConfidence, writes); err != nil {
|
||||
if _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopee.ID, uuid.NewString(), writes); err != nil {
|
||||
item.Reason = "规格映射保存失败,请刷新后重试"
|
||||
response.FailedCount++
|
||||
response.Items = append(response.Items, item)
|
||||
|
||||
@@ -175,10 +175,10 @@ func TestBatchSpecMatchPersistsExactMatchBeforePurchaseCreation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManuallyConfirmedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T) {
|
||||
func TestExtractedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T) {
|
||||
service, f := exactBatchSpecFixture(t)
|
||||
if err := service.DB.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
|
||||
"parse_status": models.SYBParseStatusUncertain, "manually_confirmed": true,
|
||||
"parse_status": models.SYBParseStatusUncertain, "manually_confirmed": false,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -187,15 +187,15 @@ func TestManuallyConfirmedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T)
|
||||
|
||||
before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || len(before.Items) != 1 || before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
|
||||
t.Fatalf("manually confirmed specs did not enter matching: %+v err=%v", before, err)
|
||||
t.Fatalf("extracted uncertain specs did not enter matching: %+v err=%v", before, err)
|
||||
}
|
||||
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || matched.AutoConfirmedCount != 1 || matcher.calls != 0 {
|
||||
t.Fatalf("manual correction did not allow exact match: %+v calls=%d err=%v", matched, matcher.calls, err)
|
||||
t.Fatalf("extracted uncertain specs did not allow exact match: %+v calls=%d err=%v", matched, matcher.calls, err)
|
||||
}
|
||||
after, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || !after.Items[0].Eligible || after.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
t.Fatalf("saved mapping did not unlock manual correction: %+v err=%v", after, err)
|
||||
t.Fatalf("saved mapping did not unlock extracted uncertain specs: %+v err=%v", after, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,14 +241,16 @@ func TestBatchPreviewExposesExplicitAIMatchEligibility(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreviewRejectsUncertainParseAndMissingSKUCombination(t *testing.T) {
|
||||
// #190 放开了 AI 匹配的解析状态与可售 SKU 组合前置:两者都不再阻断匹配,
|
||||
// 采购侧自身的解析门禁(previewFromDataset)不在本次放开范围内,仍然生效。
|
||||
func TestBatchPreviewAllowsUncertainParseAndMissingSKUCombination(t *testing.T) {
|
||||
service, f := unresolvedBatchSpecFixture(t)
|
||||
if err := service.DB.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("parse_status", models.SYBParseStatusUncertain).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "采购规格解析存疑,请先人工修正并确认" || preview.Items[0].ProcessStage != ProcessStageManualAction {
|
||||
t.Fatalf("uncertain parse entered AI matching: %+v err=%v", preview, err)
|
||||
if err != nil || !preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "" {
|
||||
t.Fatalf("uncertain parse still blocked AI matching: %+v err=%v", preview, err)
|
||||
}
|
||||
|
||||
if err := service.DB.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("parse_status", models.SYBParseStatusSuccess).Error; err != nil {
|
||||
@@ -258,26 +260,43 @@ func TestBatchPreviewRejectsUncertainParseAndMissingSKUCombination(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err = service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "缺少当前 PDD 商品的完整可售 SKU 组合,请先重新采集" {
|
||||
t.Fatalf("missing SKU combination entered AI matching: %+v err=%v", preview, err)
|
||||
if err != nil || !preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "" {
|
||||
t.Fatalf("missing SKU combination still blocked AI matching: %+v err=%v", preview, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchSpecMatchLeavesLowConfidenceForManualHandling(t *testing.T) {
|
||||
func TestBatchSpecMatchAndPreviewAllowMissingSKUCombinationEvidence(t *testing.T) {
|
||||
service, f := unresolvedBatchSpecFixture(t)
|
||||
if err := service.DB.Where("pdd_product_id = ?", f.pdd.ID).Delete(&models.CollectionTask{}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.Matcher = &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.6)}}
|
||||
|
||||
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || matched.AutoConfirmedCount != 1 || matched.PendingCount != 0 {
|
||||
t.Fatalf("AI result without SKU evidence was not confirmed: %+v err=%v", matched, err)
|
||||
}
|
||||
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || len(preview.Items) != 1 || !preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
t.Fatalf("missing SKU evidence unexpectedly blocked purchase: %+v err=%v", preview, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchSpecMatchAutoConfirmsReturnedAIMatchRegardlessOfConfidence(t *testing.T) {
|
||||
service, f := unresolvedBatchSpecFixture(t)
|
||||
service.Matcher = &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.6)}}
|
||||
|
||||
response, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || response.PendingCount != 1 || response.AutoConfirmedCount != 0 {
|
||||
t.Fatalf("unexpected low-confidence result: %+v err=%v", response, err)
|
||||
if err != nil || response.PendingCount != 0 || response.AutoConfirmedCount != 1 {
|
||||
t.Fatalf("low-confidence AI result was not auto-confirmed: %+v err=%v", response, err)
|
||||
}
|
||||
mapping := savedColorMapping(t, service, f.shopee.ID)
|
||||
if mapping == nil || mapping.PDDValue != "旧白色" || mapping.Status != shopeeproduct.MappingStatusConfirmed {
|
||||
t.Fatalf("low-confidence result changed the saved mapping: %+v", mapping)
|
||||
if mapping == nil || mapping.PDDValue != "米白色" || mapping.Status != shopeeproduct.MappingStatusConfirmed || mapping.Confidence == nil || *mapping.Confidence != 0.6 {
|
||||
t.Fatalf("AI result was not saved as confirmed: %+v", mapping)
|
||||
}
|
||||
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStageColorMapping {
|
||||
t.Fatalf("pending mapping unexpectedly became purchase-ready: %+v err=%v", preview, err)
|
||||
if err != nil || !preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
t.Fatalf("saved AI result did not become purchase-ready: %+v err=%v", preview, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,27 @@ func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSybSpecsTrustedOnlyBlocksFailedOrEmptyExtraction(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
syb models.SYBProduct
|
||||
trust bool
|
||||
}{
|
||||
{"success", models.SYBProduct{ParseStatus: models.SYBParseStatusSuccess, TargetColor: "黑色", TargetSize: "XL"}, true},
|
||||
{"uncertain with color", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetColor: "套装"}, true},
|
||||
{"uncertain with size", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, TargetSize: "均码"}, true},
|
||||
{"failed with values", models.SYBProduct{ParseStatus: models.SYBParseStatusFailed, TargetColor: "黑色"}, false},
|
||||
{"uncertain without values", models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sybSpecsTrusted(tt.syb); got != tt.trust {
|
||||
t.Fatalf("sybSpecsTrusted=%v, want %v", got, tt.trust)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreviewExposesIndependentCollectionEligibility(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), true)
|
||||
|
||||
@@ -353,23 +353,24 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
|
||||
} else if pdd.Status != "active" {
|
||||
mappedColor, mappedSize, specSource = "", "", "unresolved"
|
||||
} else if specSource == "manual_mapping" || specSource == "exact_match" || specSource == "ai_match" {
|
||||
// #190:失效的映射不再拒绝,降级为 unresolved 交给规格探测。
|
||||
if !mappingTargetsValid(candidates, targetColor, targetSize, mappedColor, mappedSize) {
|
||||
return fail(CodeMappingRequired, "规格匹配已失效,请重新选择 PDD 规格")
|
||||
mappedColor, mappedSize, specSource = "", "", "unresolved"
|
||||
} else {
|
||||
decision, marshalErr := json.Marshal(aimatching.RecordedMatch(matchRequest, specSource, mappedColor, mappedSize, "已使用人工确认的规格映射").Decision)
|
||||
if marshalErr != nil {
|
||||
return internal(marshalErr)
|
||||
}
|
||||
decisionSnapshot = string(decision)
|
||||
}
|
||||
decision, marshalErr := json.Marshal(aimatching.RecordedMatch(matchRequest, specSource, mappedColor, mappedSize, "已使用人工确认的规格映射").Decision)
|
||||
if marshalErr != nil {
|
||||
return internal(marshalErr)
|
||||
}
|
||||
decisionSnapshot = string(decision)
|
||||
} else if !archiveUsable {
|
||||
mappedColor, mappedSize, specSource = "", "", "unresolved"
|
||||
} else if externalPlan != nil {
|
||||
mappedColor, mappedSize, specSource = "", "", "unresolved"
|
||||
} else if match, matched := aimatching.DeterministicMatch(matchRequest); !matched {
|
||||
// #190:确定性匹配失败不再拒绝,降级为 unresolved 交给规格探测。
|
||||
mappedColor, mappedSize, specSource = "", "", "unresolved"
|
||||
} else {
|
||||
match, matched := aimatching.DeterministicMatch(matchRequest)
|
||||
if !matched {
|
||||
return fail(CodeMappingRequired, "规格映射不完整,需要异步规格匹配")
|
||||
}
|
||||
mappedColor, mappedSize, specSource = match.MappedColor, match.MappedSize, match.Source
|
||||
decision, marshalErr := json.Marshal(match.Decision)
|
||||
if marshalErr != nil {
|
||||
|
||||
@@ -176,15 +176,20 @@ func TestCreateLiveSnapshotsShopeeOrderNumber(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateLiveRejectsExpiredConfirmedMapping(t *testing.T) {
|
||||
// #190 起失效的已确认映射不再拒绝创建,而是降级为 unresolved 并交由规格探测解析。
|
||||
// 规则不支持 spec-probe 时仍然拒绝,那道门不在本次放开范围内。
|
||||
func TestCreateLiveDowngradesExpiredConfirmedMappingToSpecProbe(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := createLive(t, testService(db), f)
|
||||
if code(err) != CodeMappingRequired {
|
||||
t.Fatalf("expected invalid mapping rejection, got %v", err)
|
||||
task, err := createLive(t, testService(db), f)
|
||||
if err != nil {
|
||||
t.Fatalf("expired mapping should downgrade, not reject: %v", err)
|
||||
}
|
||||
if task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
|
||||
t.Fatalf("expired mapping was not downgraded: source=%q color=%q size=%q", task.SpecSource, task.MappedColorSnapshot, task.MappedSizeSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type AutoMatchRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
SpecContextVersion string `json:"specContextVersion"`
|
||||
}
|
||||
|
||||
type AutoMatchItem struct {
|
||||
Dimension string `json:"dimension"`
|
||||
Role string `json:"role"`
|
||||
ValueName string `json:"valueName"`
|
||||
PDDValue string `json:"pddValue,omitempty"`
|
||||
Status string `json:"status"` // confirmed | preserved | unmatched
|
||||
Source string `json:"source,omitempty"`
|
||||
Confidence *float64 `json:"confidence,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type AutoMatchResponse struct {
|
||||
Product ProductView `json:"product"`
|
||||
Items []AutoMatchItem `json:"items"`
|
||||
ConfirmedCount int `json:"confirmedCount"`
|
||||
PreservedCount int `json:"preservedCount"`
|
||||
UnmatchedCount int `json:"unmatchedCount"`
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
type autoMatchRef struct {
|
||||
dimension string
|
||||
valueName string
|
||||
}
|
||||
|
||||
// AutoMatchMappings resolves both color and size values, then persists every
|
||||
// accepted result as confirmed in one transaction. Provider calls happen
|
||||
// before the transaction; the transaction rechecks the complete spec context
|
||||
// and PDD candidate set so a stale decision can never be written.
|
||||
func (service *Service) AutoMatchMappings(ctx context.Context, id uint64, request AutoMatchRequest) (AutoMatchResponse, error) {
|
||||
requestID := strings.TrimSpace(request.RequestID)
|
||||
if _, err := uuid.Parse(requestID); err != nil {
|
||||
return AutoMatchResponse{}, invalidRequest("requestId 必须是 UUID")
|
||||
}
|
||||
requestVersion := strings.TrimSpace(request.SpecContextVersion)
|
||||
if requestVersion == "" {
|
||||
return AutoMatchResponse{}, contextVersionStale()
|
||||
}
|
||||
|
||||
db := service.DB.WithContext(ctx)
|
||||
var replay models.ShopeeProduct
|
||||
if err := db.Where("id = ? AND last_update_request_id = ?", id, requestID).First(&replay).Error; err == nil {
|
||||
return service.autoMatchReplay(ctx, id)
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
|
||||
var shopee models.ShopeeProduct
|
||||
if err := db.First(&shopee, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return AutoMatchResponse{}, productNotFound()
|
||||
}
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
if shopee.PDDProductID == nil {
|
||||
return AutoMatchResponse{}, &ServiceError{Code: CodePDDProductNotFound, Message: "请先关联 PDD 商品"}
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := db.First(&pdd, *shopee.PDDProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return AutoMatchResponse{}, &ServiceError{Code: CodePDDProductNotFound, Message: "关联的 PDD 商品不存在"}
|
||||
}
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
if computeSpecContextVersion(shopee.PDDProductID, shopee.SpecsJSON, pdd.SpecsJSON) != requestVersion {
|
||||
return AutoMatchResponse{}, contextVersionStale()
|
||||
}
|
||||
initialSpecs, err := Unmarshal(shopee.SpecsJSON)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
|
||||
items := make([]AutoMatchItem, 0)
|
||||
for _, role := range []string{RoleColor, RoleSize} {
|
||||
refs := autoMatchRefs(initialSpecs, role)
|
||||
if len(refs) == 0 {
|
||||
continue
|
||||
}
|
||||
preview, err := service.suggestMappings(ctx, id, requestVersion, role)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
if len(preview.Items) != len(refs) {
|
||||
return AutoMatchResponse{}, internalError(errors.New("AI 匹配结果与规格输入数量不一致"))
|
||||
}
|
||||
for index, suggestion := range preview.Items {
|
||||
item := AutoMatchItem{
|
||||
Dimension: refs[index].dimension,
|
||||
Role: role,
|
||||
ValueName: refs[index].valueName,
|
||||
PDDValue: suggestion.PDDValue,
|
||||
Confidence: suggestion.Confidence,
|
||||
Reason: strings.TrimSpace(suggestion.Reason),
|
||||
Status: "unmatched",
|
||||
}
|
||||
switch suggestion.Status {
|
||||
case "preserved":
|
||||
item.Status = "preserved"
|
||||
case "matched":
|
||||
item.Status = "confirmed"
|
||||
item.Source = MappingSourceExactMatch
|
||||
case "ai_matched":
|
||||
if suggestion.Confidence != nil && item.Reason != "" {
|
||||
item.Status = "confirmed"
|
||||
item.Source = MappingSourceAIMatch
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
|
||||
replayed := false
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
var current models.ShopeeProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return productNotFound()
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if current.LastUpdateRequestID != nil && *current.LastUpdateRequestID == requestID {
|
||||
replayed = true
|
||||
return nil
|
||||
}
|
||||
if current.PDDProductID == nil || *current.PDDProductID != pdd.ID {
|
||||
return contextVersionStale()
|
||||
}
|
||||
var currentPDD models.PDDProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤tPDD, pdd.ID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return contextVersionStale()
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if computeSpecContextVersion(current.PDDProductID, current.SpecsJSON, currentPDD.SpecsJSON) != requestVersion {
|
||||
return contextVersionStale()
|
||||
}
|
||||
|
||||
currentSpecs, err := Unmarshal(current.SpecsJSON)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
candidates := map[string]map[string]bool{}
|
||||
for _, role := range []string{RoleColor, RoleSize} {
|
||||
values, err := selectablePDDValues(currentPDD.SpecsJSON, role)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
candidates[role] = values
|
||||
}
|
||||
|
||||
needsAISettings := false
|
||||
for _, item := range items {
|
||||
if item.Status == "confirmed" && item.Source == MappingSourceAIMatch {
|
||||
needsAISettings = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needsAISettings {
|
||||
settings, err := aimatching.NewService(tx).Settings(ctx)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if !settings.Enabled {
|
||||
return aiUnavailable("AI 匹配未启用,请先在设置中配置并启用")
|
||||
}
|
||||
for index := range items {
|
||||
item := &items[index]
|
||||
if item.Status == "confirmed" && item.Source == MappingSourceAIMatch &&
|
||||
(item.Confidence == nil || *item.Confidence < settings.AutoConfirmMinConfidence || strings.TrimSpace(item.Reason) == "") {
|
||||
item.Status, item.Source = "unmatched", ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plans := make(map[string]AutoMatchItem, len(items))
|
||||
for _, item := range items {
|
||||
plans[item.Dimension+"\x00"+item.ValueName] = item
|
||||
}
|
||||
for dimensionIndex, dimension := range currentSpecs {
|
||||
if dimension.Role != RoleColor && dimension.Role != RoleSize {
|
||||
continue
|
||||
}
|
||||
for valueIndex, value := range dimension.Values {
|
||||
item, ok := plans[dimension.Name+"\x00"+value.Name]
|
||||
if !ok || item.Status != "confirmed" {
|
||||
continue
|
||||
}
|
||||
if !candidates[dimension.Role][item.PDDValue] {
|
||||
return contextVersionStale()
|
||||
}
|
||||
currentSpecs[dimensionIndex].Values[valueIndex].Mapping = &Mapping{
|
||||
PDDValue: item.PDDValue, Source: item.Source, Status: MappingStatusConfirmed,
|
||||
Confidence: item.Confidence, Reason: item.Reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := Validate(currentSpecs); err != nil {
|
||||
return invalidRequest(err.Error())
|
||||
}
|
||||
specsJSON, err := Marshal(currentSpecs)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if err := tx.Model(&models.ShopeeProduct{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"specs_json": specsJSON, "last_update_request_id": requestID,
|
||||
}).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if err := syncReplacementItemAfterManualEdit(tx, id, currentSpecs); err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
if replayed {
|
||||
return service.autoMatchReplay(ctx, id)
|
||||
}
|
||||
response, err := service.autoMatchResponse(ctx, id, items)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func autoMatchRefs(specs []SpecDimension, role string) []autoMatchRef {
|
||||
refs := make([]autoMatchRef, 0)
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != role {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
refs = append(refs, autoMatchRef{dimension: dimension.Name, valueName: value.Name})
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchResponse(ctx context.Context, id uint64, items []AutoMatchItem) (AutoMatchResponse, error) {
|
||||
detail, err := service.Detail(ctx, id)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
response := AutoMatchResponse{Product: detail.Product, Items: items}
|
||||
for _, item := range items {
|
||||
switch item.Status {
|
||||
case "confirmed":
|
||||
response.ConfirmedCount++
|
||||
case "preserved":
|
||||
response.PreservedCount++
|
||||
default:
|
||||
response.UnmatchedCount++
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchReplay(ctx context.Context, id uint64) (AutoMatchResponse, error) {
|
||||
detail, err := service.Detail(ctx, id)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
response := AutoMatchResponse{Product: detail.Product, Items: []AutoMatchItem{}, Replayed: true}
|
||||
for _, dimension := range detail.Product.Specs {
|
||||
if dimension.Role != RoleColor && dimension.Role != RoleSize {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
item := AutoMatchItem{Dimension: dimension.Name, Role: dimension.Role, ValueName: value.Name, Status: "unmatched"}
|
||||
if value.Mapping != nil && value.Mapping.Status == MappingStatusConfirmed {
|
||||
item.PDDValue, item.Status, item.Source = value.Mapping.PDDValue, "preserved", value.Mapping.Source
|
||||
item.Confidence, item.Reason = value.Mapping.Confidence, value.Mapping.Reason
|
||||
response.PreservedCount++
|
||||
} else {
|
||||
response.UnmatchedCount++
|
||||
}
|
||||
response.Items = append(response.Items, item)
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestAutoMatchMappingsConfirmsColorAndSizeInOneRequest(t *testing.T) {
|
||||
provider := httptest.NewServer(chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"颜色含义唯一一致"}]}`))
|
||||
defer provider.Close()
|
||||
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{
|
||||
RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-BOTH", PDDProductID: &pdd.ID,
|
||||
Specs: []SpecDimension{
|
||||
{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}},
|
||||
{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: " xl ", Source: ValueSourceImport}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requestID := uuid.NewString()
|
||||
response, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: requestID, SpecContextVersion: created.Product.SpecContextVersion})
|
||||
if err != nil {
|
||||
t.Fatalf("auto match: %v", err)
|
||||
}
|
||||
if response.ConfirmedCount != 2 || response.PreservedCount != 0 || response.UnmatchedCount != 0 {
|
||||
t.Fatalf("unexpected counts: %+v", response)
|
||||
}
|
||||
color := response.Product.Specs[0].Values[0].Mapping
|
||||
size := response.Product.Specs[1].Values[0].Mapping
|
||||
if color == nil || color.Status != MappingStatusConfirmed || color.Source != MappingSourceAIMatch || color.PDDValue != "黑色" {
|
||||
t.Fatalf("color not auto-confirmed: %+v", color)
|
||||
}
|
||||
if size == nil || size.Status != MappingStatusConfirmed || size.Source != MappingSourceExactMatch || size.PDDValue != "XL" {
|
||||
t.Fatalf("size not auto-confirmed: %+v", size)
|
||||
}
|
||||
|
||||
replayed, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: requestID, SpecContextVersion: created.Product.SpecContextVersion})
|
||||
if err != nil || !replayed.Replayed || replayed.PreservedCount != 2 {
|
||||
t.Fatalf("idempotent replay failed: response=%+v err=%v", replayed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchMappingsLeavesLowConfidenceUnmatched(t *testing.T) {
|
||||
provider := httptest.NewServer(chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.4,"reason":"只能判断为相近颜色"}]}`))
|
||||
defer provider.Close()
|
||||
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{
|
||||
RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-LOW", PDDProductID: &pdd.ID,
|
||||
Specs: []SpecDimension{
|
||||
{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "浅黑", Source: ValueSourceImport}}},
|
||||
{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "XL", Source: ValueSourceImport}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.ConfirmedCount != 1 || response.UnmatchedCount != 1 || response.Product.Specs[0].Values[0].Mapping != nil {
|
||||
t.Fatalf("low confidence color was persisted: %+v", response)
|
||||
}
|
||||
if response.Product.Specs[1].Values[0].Mapping == nil || response.Product.Specs[1].Values[0].Mapping.Status != MappingStatusConfirmed {
|
||||
t.Fatalf("deterministic size was not confirmed: %+v", response.Product.Specs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchMappingsProviderFailureDoesNotWrite(t *testing.T) {
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusBadGateway)
|
||||
}))
|
||||
defer provider.Close()
|
||||
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-FAIL", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}); err == nil || errCode(t, err) != CodeAIUnavailable {
|
||||
t.Fatalf("expected AI unavailable, got %v", err)
|
||||
}
|
||||
detail, err := service.Detail(context.Background(), created.Product.ID)
|
||||
if err != nil || detail.Product.Specs[0].Values[0].Mapping != nil {
|
||||
t.Fatalf("provider failure changed mappings: detail=%+v err=%v", detail, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchMappingsRejectsContextDriftBeforeAtomicWrite(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
call := calls.Add(1)
|
||||
if call == 2 {
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true},{"name":"灰色","selectable":true}]},{"name":"尺码","role":"size","values":[{"name":"M","selectable":true}]}]`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []map[string]any{{"message": map[string]any{"content": `{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.98,"reason":"唯一可靠候选"}]}`}}}})
|
||||
}))
|
||||
defer provider.Close()
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{
|
||||
RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-STALE", PDDProductID: &pdd.ID,
|
||||
Specs: []SpecDimension{
|
||||
{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}},
|
||||
{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "小号", Source: ValueSourceImport}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}); err == nil || errCode(t, err) != CodeSpecContextStale {
|
||||
t.Fatalf("expected stale context, got %v", err)
|
||||
}
|
||||
detail, err := service.Detail(context.Background(), created.Product.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, dimension := range detail.Product.Specs {
|
||||
for _, value := range dimension.Values {
|
||||
if value.Mapping != nil {
|
||||
t.Fatalf("context drift caused partial write: %+v", detail.Product.Specs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import (
|
||||
|
||||
// ResolvedMappingItem is one mapping produced by the narrowly scoped SYB
|
||||
// batch-match entry point. Confirmed writes are restricted to auditable exact
|
||||
// matches and high-confidence AI decisions; SetMapping remains pending-first.
|
||||
// matches and AI decisions with a returned match reason; SetMapping remains
|
||||
// pending-first.
|
||||
type ResolvedMappingItem struct {
|
||||
Dimension string
|
||||
ValueName string
|
||||
@@ -20,14 +21,11 @@ type ResolvedMappingItem struct {
|
||||
|
||||
// ApplyResolvedMappings atomically applies the color/size mappings needed by
|
||||
// one SYB detail row. It is independent from SetMapping so #188's explicit
|
||||
// high-confidence exception cannot change existing callers' pending semantics.
|
||||
func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, requestID string, minimumConfidence float64, items []ResolvedMappingItem) (SaveResponse, error) {
|
||||
// confirmed-match exception cannot change existing callers' pending semantics.
|
||||
func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, requestID string, items []ResolvedMappingItem) (SaveResponse, error) {
|
||||
if len(items) == 0 || len(items) > 2 {
|
||||
return SaveResponse{}, invalidRequest("必须包含 1 至 2 个待写入规格映射")
|
||||
}
|
||||
if minimumConfidence < 0 || minimumConfidence > 1 {
|
||||
return SaveResponse{}, invalidRequest("自动确认阈值无效")
|
||||
}
|
||||
seen := make(map[string]bool, len(items))
|
||||
for _, item := range items {
|
||||
key := strings.TrimSpace(item.Dimension) + "\x00" + strings.TrimSpace(item.ValueName)
|
||||
@@ -41,9 +39,6 @@ func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, re
|
||||
if item.Status != MappingStatusConfirmed || strings.TrimSpace(item.Reason) == "" {
|
||||
return SaveResponse{}, invalidRequest("自动确认映射必须包含确认状态和匹配理由")
|
||||
}
|
||||
if item.Source == MappingSourceAIMatch && (item.Confidence == nil || *item.Confidence < minimumConfidence) {
|
||||
return SaveResponse{}, invalidRequest("AI 自动确认必须达到置信度阈值")
|
||||
}
|
||||
if err := service.validatePDDMappingTarget(ctx, id, item.Dimension, item.ValueName, strings.TrimSpace(item.PDDValue)); err != nil {
|
||||
return SaveResponse{}, err
|
||||
}
|
||||
|
||||
@@ -278,6 +278,28 @@ func (handler Handler) SuggestSizeMappings(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) AutoMatchMappings(c *gin.Context) {
|
||||
id, ok := handler.pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request AutoMatchRequest
|
||||
if err := decodeJSON(c, &request); err != nil {
|
||||
writeError(c, invalidRequest("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.AutoMatchMappings(c.Request.Context(), id, request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) BatchDelete(c *gin.Context) {
|
||||
var request BatchDeleteRequest
|
||||
if err := decodeJSON(c, &request); err != nil {
|
||||
|
||||
@@ -26,4 +26,5 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
admin.POST("/:productId/specs/mapping/preview-auto-size", handler.PreviewAutoSizeMatches)
|
||||
admin.POST("/:productId/specs/mapping/suggest-colors", handler.SuggestColorMappings)
|
||||
admin.POST("/:productId/specs/mapping/suggest-sizes", handler.SuggestSizeMappings)
|
||||
admin.POST("/:productId/specs/mapping/auto-match", handler.AutoMatchMappings)
|
||||
}
|
||||
|
||||
@@ -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), migrateDeviceIdentityRecovery)
|
||||
}
|
||||
|
||||
func migrateDeviceIdentityRecovery(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
|
||||
})
|
||||
}
|
||||
@@ -21,3 +21,10 @@ export function revokeDeviceToken(deviceId) {
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
export function resetDeviceIdentity(deviceId) {
|
||||
return request({
|
||||
url: `/api/admin/v1/devices/${deviceId}/identity-reset`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,6 +66,10 @@ export function suggestShopeeSizeMappings(productId, data) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/suggest-sizes`, method: 'post', data, timeout: aiSuggestTimeoutMs })
|
||||
}
|
||||
|
||||
export function autoMatchShopeeSpecMappings(productId, data) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/auto-match`, method: 'post', data, timeout: aiSuggestTimeoutMs })
|
||||
}
|
||||
|
||||
export function batchDeleteShopeeProducts(data) {
|
||||
return request({ url: '/api/admin/v1/shopee-products/batch-delete', method: 'post', data })
|
||||
}
|
||||
|
||||
@@ -68,8 +68,14 @@
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isAdmin" label="操作" width="190" fixed="right">
|
||||
<el-table-column v-if="isAdmin" label="操作" width="270" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="warning"
|
||||
link
|
||||
:disabled="row.status === 'disabled'"
|
||||
@click="confirmIdentityReset(row)"
|
||||
>重置设备身份</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
@@ -116,7 +122,7 @@
|
||||
<script>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { disableDevice, listDevices, revokeDeviceToken } from '@/api/goauto/devices'
|
||||
import { disableDevice, listDevices, resetDeviceIdentity, revokeDeviceToken } from '@/api/goauto/devices'
|
||||
import { downloadAgentAppRelease, listAgentAppReleases, setCurrentAgentAppRelease, uploadAgentAppRelease } from '@/api/goauto/agent-app-releases'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
|
||||
@@ -191,6 +197,16 @@ export default {
|
||||
await revokeDeviceToken(row.id)
|
||||
ElMessage.success('设备 Token 已吊销')
|
||||
await this.getList()
|
||||
},
|
||||
async confirmIdentityReset(row) {
|
||||
await ElMessageBox.confirm(
|
||||
`将立即使“${row.name}”当前 Token 失效,并开启 10 分钟自动重新注册窗口。手机 Agent 保持运行时会自动恢复原设备 #${row.id},已分配的待领取任务保持不变。`,
|
||||
'确认重置设备身份',
|
||||
{ type: 'warning', confirmButtonText: '确认重置', cancelButtonText: '取消', distinguishCancelAndClose: true }
|
||||
)
|
||||
const response = await resetDeviceIdentity(row.id)
|
||||
ElMessage.success(`已开启自动重新注册窗口,有效至 ${new Date(response.data.expiresAt).toLocaleString()}`)
|
||||
await this.getList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,13 +23,12 @@
|
||||
</el-descriptions>
|
||||
<p v-if="detail.product.pddProductId && detail.product.sharedByPddCount > 1" class="muted shared-note">该 PDD 商品当前被 {{ detail.product.sharedByPddCount }} 个虾皮商品共用,允许共用。</p>
|
||||
|
||||
<div class="section-heading"><h3>规格匹配</h3><span class="mapping-progress">{{ mappingProgressText }}</span></div>
|
||||
<el-alert title="只保存虾皮规格与 PDD 规格的对应关系,不会修改两端的原始颜色和尺码。" type="info" :closable="false" show-icon class="notice" />
|
||||
<div class="section-heading"><h3>规格匹配</h3><div class="mapping-heading-actions"><span class="mapping-progress">{{ mappingProgressText }}</span><el-button type="primary" :loading="detail.aiMatching" :disabled="!canAutoMatch" :title="hasMappingChanges ? '请先保存或撤销当前人工修改' : ''" @click="autoMatchMappings">一键匹配颜色和尺码</el-button></div></div>
|
||||
<el-alert title="成功匹配会直接保存并可用于采购;未通过置信度或候选校验的项目保持未匹配。不会修改蝦皮或 PDD 的原始颜色和尺码。" type="info" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-if="!detail.product.pddProductId" title="请先关联 PDD 商品,再进行规格匹配。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-else-if="detail.pddLoaded && !pddSpecValues('color').length && !pddSpecValues('size').length" title="关联的 PDD 商品暂无可用规格,请先完成商品采集。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-tabs v-model="detail.activeSpecTab" class="mapping-tabs">
|
||||
<el-tab-pane label="颜色匹配" name="color">
|
||||
<div class="tab-actions"><span class="muted">AI 只生成未保存的建议,置信度不足不会预填,均需人工确认后保存。</span><el-button type="primary" plain :loading="detail.aiSuggesting.color" :disabled="!canAIMatchColor" @click="previewAISuggestions('color')">AI 匹配</el-button></div>
|
||||
<el-empty v-if="!specRows('color').length" description="虾皮商品暂无颜色规格" />
|
||||
<el-table v-else :data="specRows('color')" :row-class-name="mappingRowClass" border size="small">
|
||||
<el-table-column label="虾皮颜色" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
|
||||
@@ -39,7 +38,6 @@
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="尺码匹配" name="size">
|
||||
<div class="tab-actions"><span class="muted">先按格式统一自动匹配;未匹配的项再用 AI 生成建议,置信度不足不会预填。</span><el-button type="primary" plain :loading="detail.aiSuggesting.size" :disabled="!canAIMatchSize" @click="previewAISuggestions('size')">AI 匹配</el-button></div>
|
||||
<el-empty v-if="!specRows('size').length" description="虾皮商品暂无尺码规格" />
|
||||
<el-table v-else :data="specRows('size')" border size="small">
|
||||
<el-table-column label="虾皮尺码" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
|
||||
@@ -79,7 +77,7 @@ import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getShopeeProduct, updateShopeeProduct, linkShopeeProductPdd, addShopeeSpecValue,
|
||||
setShopeeSpecMapping, clearShopeeSpecMapping, confirmShopeeSpecMapping,
|
||||
suggestShopeeColorMappings, suggestShopeeSizeMappings
|
||||
autoMatchShopeeSpecMappings
|
||||
} from '@/api/goauto/shopee-products'
|
||||
import { getPddProduct, listPddProducts } from '@/api/goauto/pdd-products'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
@@ -107,8 +105,7 @@ export default {
|
||||
allSpecRows() { return this.detail.product ? this.detail.product.specs.flatMap(d => d.values) : [] },
|
||||
hasMappingChanges() { return this.allSpecRows.some(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')) },
|
||||
mappingProgressText() { const rows = this.allSpecRows.filter(row => row.role === 'color' || row.role === 'size'); const valid = rows.filter(row => row.pddValueDraft && this.pddValueExists(row.role, row.pddValueDraft)).length; return rows.length ? `${valid}/${rows.length} 已匹配` : '暂无可匹配规格' },
|
||||
canAIMatchColor() { return this.detail.pddLoaded && this.specRows('color').length > 0 && this.pddSpecValues('color').length > 0 },
|
||||
canAIMatchSize() { return this.detail.pddLoaded && this.specRows('size').length > 0 && this.pddSpecValues('size').length > 0 }
|
||||
canAutoMatch() { return this.detail.pddLoaded && !this.hasMappingChanges && ['color', 'size'].some(role => this.specRows(role).length > 0 && this.pddSpecValues(role).length > 0) }
|
||||
},
|
||||
watch: {
|
||||
modelValue: { immediate: true, handler(open) { if (open) this.requestSwitch(this.productId); else this.closeInternals() } },
|
||||
@@ -117,7 +114,7 @@ export default {
|
||||
action(value) { if (value === 'link_pdd' && this.detail.product && !this.pddPicker.open) this.openPddPicker() }
|
||||
},
|
||||
methods: {
|
||||
emptyDetail() { return { loading: false, saving: false, aiSuggesting: { color: false, size: false }, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } },
|
||||
emptyDetail() { return { loading: false, saving: false, aiMatching: false, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } },
|
||||
validProductId(id) { return Number.isInteger(Number(id)) && Number(id) > 0 },
|
||||
isCurrent(generation, id) { return this.modelValue && generation === this.requestGeneration && Number(this.productId) === Number(id) },
|
||||
async confirmDiscard() { if (!this.hasMappingChanges) return true; return this.$confirm('当前有未保存的规格匹配,确定放弃吗?', '放弃修改?', { type: 'warning', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }).then(() => true).catch(() => false) },
|
||||
@@ -164,7 +161,7 @@ export default {
|
||||
markManual(row) { row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
|
||||
clearDraft(row) { row.pddValueDraft = ''; row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
|
||||
mappingRowClass({ row }) { return this.detail.highlightColor && row.role === 'color' && row.name === this.detail.highlightColor ? 'mapping-row-highlight' : '' },
|
||||
async previewAISuggestions(role) { const id = this.loadedProductId; const generation = this.requestGeneration; this.detail.aiSuggesting[role] = true; try { const suggest = role === 'color' ? suggestShopeeColorMappings : suggestShopeeSizeMappings; const r = await suggest(id, { specContextVersion: this.detail.product.specContextVersion }); if (!this.isCurrent(generation, id)) return; const byName = new Map(r.data.items.map(item => [item.valueName, item])); this.specRows(role).forEach(row => { const item = byName.get(row.name); if (!item) return; row.previewReason = item.reason || ''; row.draftConfidence = item.confidence ?? null; if (item.apply && item.status !== 'preserved') { row.pddValueDraft = item.pddValue || ''; row.draftSource = item.status === 'matched' ? 'exact_match' : 'ai_match' } }); const suggestedCount = r.data.items.filter(item => item.status === 'ai_suggested').length; ElMessage.success(`匹配 ${r.data.matchedCount} 项,AI 建议但置信度不足 ${suggestedCount} 项,待人工选择 ${r.data.pendingCount} 项`) } finally { if (this.isCurrent(generation, id)) this.detail.aiSuggesting[role] = false } },
|
||||
async autoMatchMappings() { const id = this.loadedProductId; const generation = this.requestGeneration; this.detail.aiMatching = true; try { const r = await autoMatchShopeeSpecMappings(id, { requestId: createRequestId(), specContextVersion: this.detail.product.specContextVersion }); if (!this.isCurrent(generation, id)) return; const confirmed = Number(r.data.confirmedCount || 0); const preserved = Number(r.data.preservedCount || 0); const unmatched = Number(r.data.unmatchedCount || 0); await this.refreshDetail(); const message = `新匹配并确认 ${confirmed} 项,保留已确认 ${preserved} 项,未匹配 ${unmatched} 项`; if (unmatched > 0) ElMessage.warning(message); else ElMessage.success(message) } finally { if (this.isCurrent(generation, id)) this.detail.aiMatching = false } },
|
||||
async confirmPendingMapping(row) { await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); await this.refreshDetail() },
|
||||
async saveAllMappings() { const changes = this.allSpecRows.filter(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')); if (!changes.length) return; const invalid = changes.find(row => row.pddValueDraft && !this.pddValueExists(row.role, row.pddValueDraft)); if (invalid) { ElMessage.warning(`PDD 规格“${invalid.pddValueDraft}”已不存在,请重新选择`); return } this.detail.saving = true; try { for (const row of changes) { if (!row.pddValueDraft) { if (row.originalPddValue) await clearShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); continue } const source = ['exact_match', 'ai_match'].includes(row.draftSource) ? row.draftSource : 'manual'; const payload = { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name, pddValue: row.pddValueDraft, source }; if (source === 'ai_match') { if (row.draftConfidence !== null && row.draftConfidence !== undefined) payload.confidence = row.draftConfidence; if (row.previewReason) payload.reason = row.previewReason } await setShopeeSpecMapping(this.loadedProductId, payload); if (source === 'exact_match') await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }) } ElMessage.success('规格匹配已保存'); await this.refreshDetail() } finally { this.detail.saving = false } },
|
||||
async submitAddValue() { const dimension = this.addValueForm.dimension.trim(); const name = this.addValueForm.name.trim(); if (!dimension || !name) { ElMessage.warning('请填写维度名称和规格值'); return } await addShopeeSpecValue(this.loadedProductId, { requestId: createRequestId(), dimension, role: this.addValueForm.role, name }); this.addValueForm = { dimension: '', role: 'other', name: '' }; await this.refreshDetail() },
|
||||
@@ -175,5 +172,5 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drawer-body{padding:0 4px 24px}.drawer-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.link{color:#1677ff;cursor:pointer}.notice{margin-bottom:16px}.shared-note{margin:8px 0 0}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:24px 0 12px}.section-heading h3{margin:0}.mapping-progress{font-size:13px;color:#606266}.mapping-tabs{margin-top:4px}.mapping-select{width:100%}.tab-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:4px 0 12px}.preview-reason{margin-top:4px}:deep(.mapping-row-highlight td){background:#fff7d6!important}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.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}.edit-help{margin-left:8px}@media(max-width:768px){.tab-actions{align-items:flex-start;flex-direction:column}}
|
||||
.drawer-body{padding:0 4px 24px}.drawer-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.link{color:#1677ff;cursor:pointer}.notice{margin-bottom:16px}.shared-note{margin:8px 0 0}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:24px 0 12px}.section-heading h3{margin:0}.mapping-heading-actions{display:flex;align-items:center;gap:12px}.mapping-progress{font-size:13px;color:#606266}.mapping-tabs{margin-top:4px}.mapping-select{width:100%}.preview-reason{margin-top:4px}:deep(.mapping-row-highlight td){background:#fff7d6!important}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.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}.edit-help{margin-left:8px}@media(max-width:768px){.section-heading,.mapping-heading-actions{align-items:flex-start;flex-direction:column}}
|
||||
</style>
|
||||
|
||||
@@ -51,22 +51,26 @@ test('PDD 选择器默认最近五条并支持搜索与清空回退', async({ pa
|
||||
expect(listURLs.at(-1)?.searchParams.get('status')).toBe('active')
|
||||
})
|
||||
|
||||
test('虾皮详情原地叠加 PDD 抽屉且低置信度 AI 建议不预填', async({ page, context }) => {
|
||||
test('虾皮详情一键匹配颜色尺码后直接使用并可原地叠加 PDD 抽屉', async({ page, context }) => {
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
|
||||
let autoMatched = false
|
||||
let autoMatchBody: Record<string, unknown> | null = null
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname.startsWith('/src/api/')) return route.continue()
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '采购员', avatar: '', introduction: '', permissions: [] }}})
|
||||
if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: routes }})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'syb', mapping: null }] }] }], total: 1, page: 1, pageSize: 20 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9/specs/mapping/suggest-colors')) {
|
||||
return route.fulfill({ json: { code: 200, data: { items: [{ valueName: '藏青色', pddValue: '黑色', status: 'ai_suggested', apply: false, confidence: 0.45, reason: '置信度不足,请人工选择' }], matchedCount: 0, pendingCount: 1 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9/specs/mapping/auto-match')) {
|
||||
autoMatchBody = route.request().postDataJSON()
|
||||
autoMatched = true
|
||||
return route.fulfill({ json: { code: 200, data: { confirmedCount: 2, preservedCount: 0, unmatchedCount: 0, items: [] }}})
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9')) {
|
||||
return route.fulfill({ json: { code: 200, data: { product: { id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specContextVersion: 3, specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'syb', mapping: null }] }] }}}})
|
||||
return route.fulfill({ json: { code: 200, data: { product: { id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specContextVersion: 'context-3', specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'import', mapping: autoMatched ? { pddValue: '黑色', source: 'ai_match', status: 'confirmed', confidence: 0.96, reason: '颜色含义唯一一致' } : null }] }, { name: '尺码', role: 'size', values: [{ name: 'XL', source: 'import', mapping: autoMatched ? { pddValue: 'XL', source: 'exact_match', status: 'confirmed', reason: '格式统一后唯一匹配' } : null }] }] }}}})
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17/related-syb-products')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17')) return route.fulfill({ json: { code: 200, data: { product: { id: 17, goodsId: 'P17', title: 'PDD 叠加详情', shopName: 'PDD 店', status: 'active', url: 'https://mobile.yangkeduo.com/goods.html?goods_id=17', specs: [{ name: '颜色分类', role: 'color', values: [{ name: '黑色', selectable: true, priceCent: 1299 }] }], colorImages: [] }, relatedShopeeProducts: [] }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17')) return route.fulfill({ json: { code: 200, data: { product: { id: 17, goodsId: 'P17', title: 'PDD 叠加详情', shopName: 'PDD 店', status: 'active', url: 'https://mobile.yangkeduo.com/goods.html?goods_id=17', specs: [{ name: '颜色分类', role: 'color', values: [{ name: '黑色', selectable: true, priceCent: 1299 }] }, { name: '尺码', role: 'size', values: [{ name: 'XL', selectable: true }] }], colorImages: [] }, relatedShopeeProducts: [] }}})
|
||||
return route.fulfill({ json: { code: 200, data: [] }})
|
||||
})
|
||||
|
||||
@@ -75,15 +79,19 @@ test('虾皮详情原地叠加 PDD 抽屉且低置信度 AI 建议不预填', as
|
||||
page.on('popup', () => { popupCount += 1 })
|
||||
await page.getByRole('button', { name: '详情', exact: true }).click()
|
||||
await expect(page.getByText('虾皮商品详情', { exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'AI 匹配', exact: true }).click()
|
||||
await expect(page.getByText('置信度不足,请人工选择', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.el-drawer:visible .mapping-select input').first()).toHaveValue('')
|
||||
await expect(page.getByRole('button', { name: '一键匹配颜色和尺码', exact: true })).toHaveCount(1)
|
||||
await expect(page.getByRole('button', { name: 'AI 匹配', exact: true })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '一键匹配颜色和尺码', exact: true }).click()
|
||||
await expect(page.locator('.el-drawer:visible').getByText('黑色 · ¥12.99', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('可采购', { exact: true })).toBeVisible()
|
||||
expect(autoMatchBody?.specContextVersion).toBe('context-3')
|
||||
expect(String(autoMatchBody?.requestId || '')).toMatch(/^[0-9a-f-]{36}$/)
|
||||
await page.getByLabel('虾皮商品详情').getByText('PDD-17 ↗', { exact: true }).click()
|
||||
await expect(page.locator('.el-drawer:visible').getByText('PDD 商品详情', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('PDD 叠加详情', { exact: true })).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByText('PDD 叠加详情', { exact: true })).toBeHidden()
|
||||
await expect(page.getByText('虾皮商品详情', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('置信度不足,请人工选择', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.el-drawer:visible').getByText('黑色 · ¥12.99', { exact: true })).toBeVisible()
|
||||
expect(popupCount).toBe(0)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user