Compare commits

..
89 changed files with 3361 additions and 1822 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 52
versionName = "0.9.39"
versionCode = 51
versionName = "0.9.38"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -333,32 +333,31 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return FreshActionResult.FAILED
}
return if (dispatchCenterTap(bounds)) FreshActionResult.SUCCESS else FreshActionResult.FAILED
}
val path = Path().apply {
moveTo(bounds.centerX().toFloat(), bounds.centerY().toFloat())
}
val completed = AtomicBoolean(false)
val latch = CountDownLatch(1)
val queued = dispatchGesture(
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 80)).build(),
object : GestureResultCallback() {
override fun onCompleted(gestureDescription: GestureDescription?) {
completed.set(true)
latch.countDown()
}
override fun tapSpecFresh(target: SnapshotNode): FreshActionResult {
val width = resources.displayMetrics.widthPixels
val height = resources.displayMetrics.heightPixels
if (!PurchaseSpecGesturePolicy.isAllowed(target, width, height)) return FreshActionResult.FAILED
val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND
val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node ->
val bounds = Rect().also(node::getBoundsInScreen)
if (node.isVisibleToUser && node.isEnabled &&
node.preferredOrDescendantLabel() == target.label &&
node.className?.toString() == target.className &&
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) <= 32 &&
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32
) candidates += node
override fun onCancelled(gestureDescription: GestureDescription?) {
latch.countDown()
}
},
null,
)
if (!queued) return FreshActionResult.FAILED
return if (latch.await(1_500, TimeUnit.MILLISECONDS) && completed.get()) {
FreshActionResult.SUCCESS
} else {
FreshActionResult.FAILED
}
if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND
if (candidates.size != 1) return FreshActionResult.AMBIGUOUS
val bounds = Rect().also(candidates.single()::getBoundsInScreen)
val freshTarget = target.copy(bounds = NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom))
if (!PurchaseSpecGesturePolicy.isAllowed(freshTarget, width, height)) {
return FreshActionResult.FAILED
}
return if (dispatchCenterTap(bounds)) FreshActionResult.SUCCESS else FreshActionResult.FAILED
}
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
@@ -408,7 +407,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
) matches += node
}
if (matches.size != 1) return false
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = true)
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = false)
}
override fun backPurchase(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK)
@@ -618,28 +617,6 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
}
private fun dispatchCenterTap(bounds: Rect): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || bounds.width() < 2 || bounds.height() < 2) return false
val path = Path().apply { moveTo(bounds.centerX().toFloat(), bounds.centerY().toFloat()) }
val completed = AtomicBoolean(false)
val latch = CountDownLatch(1)
val queued = dispatchGesture(
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 80)).build(),
object : GestureResultCallback() {
override fun onCompleted(gestureDescription: GestureDescription?) {
completed.set(true)
latch.countDown()
}
override fun onCancelled(gestureDescription: GestureDescription?) {
latch.countDown()
}
},
null,
)
return queued && latch.await(1_500, TimeUnit.MILLISECONDS) && completed.get()
}
private fun walk(node: AccessibilityNodeInfo, visit: (AccessibilityNodeInfo) -> Unit) {
visit(node)
for (index in 0 until node.childCount) node.getChild(index)?.let { walk(it, visit) }
@@ -123,9 +123,6 @@ 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>,
) {
@@ -309,14 +306,6 @@ 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
@@ -325,30 +314,19 @@ object PddScreenParser {
.filter { it.bounds.top < firstHeadingTop }
.mapNotNull { node -> pricePattern.find(node.label)?.groupValues?.get(1)?.let(::priceCent) }
.firstOrNull()
val explicitSpecEntries = if (panelOpen) emptyList() else visible
val explicitSpecEntry = if (panelOpen) null else visible
.filter { it.clickable && isSpecEntry(it.label, config) && !hasReviewContext(it, visibleNodes, config) }
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.firstOrNull()
val candidateSpecEntry = explicitSpecEntry ?: nestedSpecEntry?.anchor ?: bottomSpecEntry?.anchor
val candidateClickTarget = explicitSpecEntry ?: nestedSpecEntry?.clickTarget ?: bottomSpecEntry?.clickTarget
.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 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"
bottomSpecEntries.size == 1 -> "bottom_purchase"
bottomSpecEntry != null -> "bottom_purchase_rightmost"
bottomSpecEntry != null -> "bottom_purchase"
else -> null
}
val quickConfirmationEntry = if (quickConfirmationEvidence) quickConfirmationSpecEntry(visibleNodes, visible, config) else null
@@ -392,9 +370,6 @@ object PddScreenParser {
hasSelectionSummary = hasSelectionSummary,
hasQuantityControls = hasQuantityControls,
hasOrderSubmitAction = hasOrderSubmitAction,
explicitSpecEntryCount = explicitSpecEntries.size,
nestedSpecEntryCount = nestedSpecEntries.size,
bottomPurchaseEntryCount = bottomSpecEntries.size,
problem = problem,
sourceNodes = visibleNodes,
)
@@ -483,40 +458,10 @@ object PddScreenParser {
return hasSpecWord && config.textAliases.selection.specEntryPrefixes.any(compact::startsWith)
}
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()
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
val byPath = source.associateBy(SnapshotNode::path)
val normalizedByPath = visible.associateBy(SnapshotNode::path)
val buyWords = config.textAliases.purchase.buyWords
@@ -540,10 +485,8 @@ object PddScreenParser {
val normalizedTarget = normalizedByPath[clickTarget.path] ?: return@mapNotNull null
SafeSpecEntry(normalizedAnchor, normalizedTarget) to clickTarget.bounds.width.toLong() * clickTarget.bounds.height
}
.sortedWith(compareByDescending<Pair<SafeSpecEntry, Long>> { it.first.clickTarget.bounds.centerX }.thenBy { it.second })
.map { it.first }
.distinctBy { it.clickTarget.path }
.toList()
.minWithOrNull(compareBy<Pair<SafeSpecEntry, Long>> { it.second }.thenByDescending { it.first.clickTarget.bounds.centerX })
?.first
}
private fun hasReviewContext(node: SnapshotNode, source: List<SnapshotNode>, config: PddCollectorConfig): Boolean {
@@ -117,12 +117,10 @@ class PurchaseLiveAutomation(
return null
}
val labels = linkedSetOf<String>()
var paymentBackAttempts = 0
var consecutivePaymentSamplesAfterBack = 0
var backedOutOfPayment = false
var backedOutOfChooser = false
var restoredFromWechat = false
var pddObservedAfterWechatRestore = false
var orderDetailEntryOpened = false
var wechatRestorePendingSamples = 0
var consecutiveEmptySnapshots = 0
repeat(ORDER_RESULT_MAX_SAMPLES) { index ->
@@ -164,58 +162,24 @@ 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) }
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 (paymentBackAttempts == 0) {
paymentBackAttempts++
if (!driver.backPurchase()) {
return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情")
}
pause(500)
} else {
consecutivePaymentSamplesAfterBack++
if (consecutivePaymentSamplesAfterBack >= ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES) {
return unknown(
"PURCHASE_ORDER_PAYMENT_REPEATED",
"支付页安全返回后持续无订单证据,已停止自动核单" +
"[paymentBackAttempts=$paymentBackAttempts;" +
"consecutivePaymentSamplesAfterBack=$consecutivePaymentSamplesAfterBack]",
)
}
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
}
return@repeat
}
consecutivePaymentSamplesAfterBack = 0
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
}
if (!paymentVisible && !orderContextVisible) {
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
return@repeat
}
currentLabels.forEach(labels::add)
parseOrderEvidence(labels)?.let { return it }
if (index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
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) {
driver.swipePurchase(SwipeDirection.UP, 400)
}
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
@@ -407,14 +371,6 @@ 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) }
@@ -465,7 +421,6 @@ 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})?)")
@@ -480,8 +435,6 @@ 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_PAYMENT_POST_BACK_MAX_SAMPLES = 3
const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
}
}
@@ -11,12 +11,6 @@ interface PurchaseUiDriver {
reason = FreshClickReason.UNKNOWN,
)
fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult
/**
* Reacquires and center-taps a target that the purchase parser has already
* accepted as either a safe spec entry or one exact spec value. Callers
* must still verify the semantic postcondition from a fresh snapshot.
*/
fun tapSpecFresh(target: SnapshotNode): FreshActionResult = FreshActionResult.FAILED
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
@@ -26,21 +20,6 @@ interface PurchaseUiDriver {
fun bringPddToForeground(): Boolean = false
}
internal object PurchaseSpecGesturePolicy {
private val forbiddenLabels = listOf(
"提交订单", "创建订单", "确认订单", "确认购买", "支付", "付款",
"收货地址", "修改地址", "保存地址",
)
fun isAllowed(target: SnapshotNode, screenWidth: Int, screenHeight: Int): Boolean {
val label = target.label.replace(" ", "")
return target.visible && target.enabled && label.isNotBlank() &&
forbiddenLabels.none(label::contains) &&
target.bounds.width >= 2 && target.bounds.height >= 2 &&
target.bounds.centerX in 0 until screenWidth && target.bounds.centerY in 0 until screenHeight
}
}
data class PurchaseExecutionInput(
val taskId: Long,
val executionMode: String,
@@ -72,7 +51,6 @@ 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 {
@@ -145,7 +123,7 @@ class PurchaseRehearsalExecutor(
}
return failure
}
applyPostAction(input, action)?.let { return it }
applyPostAction(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)
@@ -245,9 +223,8 @@ class PurchaseRehearsalExecutor(
repeat(50) {
val snapshot = driver.capture()
pageProblem(snapshot)?.let { return it }
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
if (screen.hasPurchaseProductEvidence()) {
return recoverSoldOut(input, screen)
if (snapshot.packageName == PDD_PACKAGE && snapshot.nodes.any { it.visible }) {
return recoverSoldOut(input, PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null))
}
pause(100)
}
@@ -288,145 +265,37 @@ class PurchaseRehearsalExecutor(
private fun openSpecPanel(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
var screen = currentScreen(input)
var entryReadyWaitPolls = 0
var target: SnapshotNode? = null
while (target == null) {
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
screen.problem?.let { return failure(it.code, it.message) }
if (screen.specPanelOpen) return null
val safeCandidates = listOfNotNull(
screen.specEntry?.let { anchor -> anchor to (screen.specEntryClickTarget ?: anchor) },
screen.quickConfirmationEntry?.let { it to it },
).distinctBy { it.second.path }
// A rule alias may only narrow the semantic candidates already accepted
// by PddScreenParser. It must never turn into a raw-page text lookup:
// review cards and unrelated controls can share arbitrary labels.
val candidates = action.textAliases?.let { aliases ->
safeCandidates.filter { (anchor, _) -> specEntryMatchesAliases(screen, anchor, aliases) }
} ?: safeCandidates
if (candidates.size > 1) {
panelDiagnostic(specEntryEvidence(screen, candidates.size, entryReadyWaitPolls))
return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一 [${specEntryEvidence(screen, candidates.size, entryReadyWaitPolls)}]")
}
target = candidates.singleOrNull()?.second
if (target != null) continue
if (entryReadyWaitPolls >= SPEC_ENTRY_READY_WAIT_POLLS) {
panelDiagnostic(specEntryEvidence(screen, 0, entryReadyWaitPolls))
return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口 [${specEntryEvidence(screen, 0, entryReadyWaitPolls)}]")
}
pause(SPEC_ENTRY_READY_POLL_MILLIS)
entryReadyWaitPolls++
screen = currentScreen(input)
}
val beforeSignature = specActionSignature(screen)
val click = driver.clickFreshDetailed(requireNotNull(target))
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
screen.problem?.let { return failure(it.code, it.message) }
if (screen.specPanelOpen) return null
val safeCandidates = listOfNotNull(
screen.specEntry?.let { anchor -> anchor to (screen.specEntryClickTarget ?: anchor) },
screen.quickConfirmationEntry?.let { it to it },
).distinctBy { it.second.path }
// A rule alias may only narrow the semantic candidates already accepted
// by PddScreenParser. It must never turn into a raw-page text lookup:
// review cards and unrelated controls can share arbitrary labels.
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, "没有找到安全的商品规格入口")
val click = driver.clickFreshDetailed(target)
when (click.result) {
// The parser already narrowed to a single semantic candidate; the
// ambiguity here comes from the live tree matching that target more
// than once at click time, so the wording must not claim otherwise.
FreshActionResult.AMBIGUOUS -> return failure(
SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口点击目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
)
else -> Unit
}
var wait = waitForSpecPanel(input, beforeSignature)
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
}
when (driver.tapSpecFresh(requireNotNull(target))) {
FreshActionResult.AMBIGUOUS -> return failure(
SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
)
FreshActionResult.AMBIGUOUS -> return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一")
FreshActionResult.SUCCESS -> Unit
else -> return failure(SPEC_ENTRY_CLICK_FAILED, click.reason.specEntrySubreason())
}
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
repeat(30) {
screen = currentScreen(input)
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_ENTRY_CLICK_NO_EFFECT,
"规格入口无障碍点击和受控手势后页面均无变化 [${panelEvidence(wait.screen)}]",
)
return failure(SPEC_PANEL_NOT_OPENED, "点击后未识别到商品规格面板")
}
private data class SpecPanelWait(
val screen: ParsedPddScreen,
val opened: Boolean,
val changed: Boolean,
val failure: PurchaseExecutionOutcome? = null,
)
private data class SpecActionNodeSignature(
val path: String,
val className: String?,
val bounds: NodeBounds,
val clickable: Boolean,
val scrollable: Boolean,
val selected: Boolean,
val checked: Boolean,
val enabled: Boolean,
val visible: Boolean,
)
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
var last = currentScreen(input)
var changed = false
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
last = currentScreen(input)
panelDiagnostic(panelEvidence(last))
if (last.reviewPageOpen) {
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
}
last.problem?.let { return SpecPanelWait(last, false, true, failure(it.code, it.message)) }
if (last.specPanelOpen) return SpecPanelWait(last, true, true)
changed = changed || specActionSignature(last) != beforeSignature
pause(SPEC_SELECTION_POLL_MILLIS)
}
return SpecPanelWait(last, false, changed)
}
/** In-memory only; the signature is never logged, persisted, or uploaded. */
private fun specActionSignature(screen: ParsedPddScreen): List<SpecActionNodeSignature> = screen.sourceNodes.map { node ->
SpecActionNodeSignature(
node.path,
node.className,
node.bounds,
node.clickable,
node.scrollable,
node.selected,
node.checked,
node.enabled,
node.visible,
)
}
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, entryReadyWaitPolls: Int = 0): String =
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
"nested=${screen.nestedSpecEntryCount};bottomPurchase=${screen.bottomPurchaseEntryCount};" +
"panelAlreadyOpen=${screen.specPanelOpen};reviewPage=${screen.reviewPageOpen};" +
"pageEvidence=${screen.pageEvidenceMatched};entryReadyWaitPolls=$entryReadyWaitPolls;" +
"entryReadyWaitMillis=${entryReadyWaitPolls * SPEC_ENTRY_READY_POLL_MILLIS}"
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) })
@@ -481,39 +350,44 @@ class PurchaseRehearsalExecutor(
target: String,
selectionProofs: MutableMap<String, ExactSpecSelectionProof>,
): PurchaseExecutionOutcome? {
val initial = currentScreen(input)
initial.problem?.let { return failure(it.code, it.message) }
if (rememberExactSelection(initial, dimension, target, selectionProofs)) return null
var lastClickReason = FreshClickReason.UNKNOWN
var clickSucceeded = false
repeat(SPEC_SELECTION_CLICK_ATTEMPTS) {
val lookup = locateExactSpec(input, dimension, target)
lookup.failure?.let { return it }
val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node))
lastClickReason = outcome.reason
if (outcome.result == FreshActionResult.AMBIGUOUS) {
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")
}
clickSucceeded = clickSucceeded || outcome.result == FreshActionResult.SUCCESS
val lookup = locateExactSpec(input, dimension, target)
lookup.failure?.let { return it }
val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node))
if (outcome.result == FreshActionResult.AMBIGUOUS) {
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")
// PDD rerenders the remaining options after a color selection. A
// stale node can report NOT_FOUND/FAILED even when the exact option
// was selected. Always verify the observable selected state before
// deciding whether to refind and retry the same exact target.
val verifyPolls = if (outcome.result == FreshActionResult.SUCCESS) {
SPEC_SELECTION_SUCCESS_VERIFY_POLLS
} else {
SPEC_SELECTION_FAILED_VERIFY_POLLS
}
repeat(verifyPolls) {
val refreshed = currentScreen(input)
refreshed.problem?.let { return failure(it.code, it.message) }
if (isExactSpecSelected(refreshed, dimension, target)) {
exactSpecSelectionProof(refreshed, dimension, target)?.let { proof ->
selectionProofs[dimension] = proof
}
return null
}
pause(SPEC_SELECTION_POLL_MILLIS)
}
}
var verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
verification.failure?.let { return it }
if (verification.confirmed) return null
// Reacquire the exact option after PDD has had a chance to rerender.
// The fallback is still bounded to one center gesture and the same
// unique, server-provided exact value.
val refreshedLookup = locateExactSpec(input, dimension, target)
refreshedLookup.failure?.let { return it }
val gesture = driver.tapSpecFresh(requireNotNull(refreshedLookup.node))
if (gesture == FreshActionResult.AMBIGUOUS) {
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格手势目标不唯一")
}
if (gesture == FreshActionResult.SUCCESS) {
verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
verification.failure?.let { return it }
if (verification.confirmed) return null
return failure(SPEC_SELECTION_UNCONFIRMED, "无障碍点击和受控手势后未能确认精确选中状态")
}
if (outcome.result == FreshActionResult.SUCCESS) {
if (clickSucceeded) {
return failure(SPEC_SELECTION_UNCONFIRMED, "点击规格后未能确认精确选中状态")
}
val subreason = when (outcome.reason) {
val subreason = when (lastClickReason) {
FreshClickReason.ROOT_UNAVAILABLE -> SPEC_CLICK_ROOT_UNAVAILABLE
FreshClickReason.TARGET_NOT_FOUND -> SPEC_CLICK_TARGET_STALE
FreshClickReason.NO_CLICKABLE_ANCESTOR -> SPEC_CLICK_NO_CLICKABLE_ANCESTOR
@@ -523,38 +397,6 @@ class PurchaseRehearsalExecutor(
return failure(SPEC_CLICK_FAILED, subreason)
}
private data class ExactSelectionWait(
val confirmed: Boolean,
val failure: PurchaseExecutionOutcome? = null,
)
private fun waitForExactSelection(
input: PurchaseExecutionInput,
dimension: String,
target: String,
selectionProofs: MutableMap<String, ExactSpecSelectionProof>,
polls: Int,
): ExactSelectionWait {
repeat(polls) {
val refreshed = currentScreen(input)
refreshed.problem?.let { return ExactSelectionWait(false, failure(it.code, it.message)) }
if (rememberExactSelection(refreshed, dimension, target, selectionProofs)) return ExactSelectionWait(true)
pause(SPEC_SELECTION_POLL_MILLIS)
}
return ExactSelectionWait(false)
}
private fun rememberExactSelection(
screen: ParsedPddScreen,
dimension: String,
target: String,
selectionProofs: MutableMap<String, ExactSpecSelectionProof>,
): Boolean {
if (!isExactSpecSelected(screen, dimension, target)) return false
exactSpecSelectionProof(screen, dimension, target)?.let { proof -> selectionProofs[dimension] = proof }
return true
}
private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean {
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true
@@ -649,40 +491,38 @@ class PurchaseRehearsalExecutor(
* that direction early.
*/
private fun locateExactSpec(input: PurchaseExecutionInput, dimension: String, target: String): SpecLookup {
data class Inspection(val lookup: SpecLookup?, val signature: String, val container: SnapshotNode?)
fun inspect(): Inspection {
fun inspect(): Pair<SpecLookup?, String> {
val screen = currentScreen(input)
screen.problem?.let { return Inspection(SpecLookup(failure = failure(it.code, it.message)), "", null) }
screen.problem?.let { return SpecLookup(failure = failure(it.code, it.message)) to "" }
if (!screen.specPanelOpen) {
return Inspection(SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")), "", null)
return SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")) to ""
}
val dimensionValues = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
val exact = dimensionValues.filter { it.text == target }
if (exact.size > 1) {
return Inspection(SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")), "", screen.specPanelContainer)
return SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")) to ""
}
if (exact.size == 1 && exact.single().available) return Inspection(SpecLookup(node = exact.single().node), "", screen.specPanelContainer)
if (exact.size == 1 && exact.single().available) return SpecLookup(node = exact.single().node) to ""
if (exact.size == 1) {
return Inspection(SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer)
return SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")) to ""
}
val signature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
}
return Inspection(null, signature, screen.specPanelContainer)
return null to signature
}
var inspected = inspect()
inspected.lookup?.let { return it }
var currentSignature = inspected.signature
inspected.first?.let { return it }
var currentSignature = inspected.second
for ((direction, limit) in listOf(SwipeDirection.DOWN to 3, SwipeDirection.UP to 6)) {
for (attempt in 0 until limit) {
val container = inspected.container ?: break
if (!driver.swipePurchaseIn(container, direction, 350)) break
if (!driver.swipePurchase(direction, 350)) break
pause(300)
inspected = inspect()
inspected.lookup?.let { return it }
if (inspected.signature == currentSignature) break
currentSignature = inspected.signature
inspected.first?.let { return it }
if (inspected.second == currentSignature) break
currentSignature = inspected.second
}
}
return SpecLookup(failure = failure(SPEC_TARGET_NOT_VISIBLE, "有界搜索后未找到精确规格"))
@@ -750,17 +590,9 @@ class PurchaseRehearsalExecutor(
return null
}
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
private fun applyPostAction(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", "规则要求的有限滑动失败")
@@ -812,9 +644,7 @@ class PurchaseRehearsalExecutor(
private const val OPEN_PRODUCT_POLL_LIMIT = 50
private const val OPEN_PRODUCT_RETRY_POLLS = 10
private const val OPEN_PRODUCT_POLL_MILLIS = 100L
private const val SPEC_ENTRY_READY_WAIT_POLLS = 20
private const val SPEC_ENTRY_READY_POLL_MILLIS = 100L
private const val SPEC_POST_CLICK_VERIFY_POLLS = 30
private const val SPEC_SELECTION_CLICK_ATTEMPTS = 3
private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
private const val SPEC_SELECTION_POLL_MILLIS = 100L
@@ -823,8 +653,7 @@ class PurchaseRehearsalExecutor(
private const val SPEC_ENTRY_TARGET_AMBIGUOUS = "PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS"
private const val SPEC_ENTRY_CLICK_FAILED = "PURCHASE_SPEC_ENTRY_CLICK_FAILED"
private const val SPEC_ENTRY_OPENED_REVIEW = "PURCHASE_SPEC_ENTRY_OPENED_REVIEW"
private const val SPEC_ENTRY_CLICK_NO_EFFECT = "PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT"
private const val SPEC_PANEL_EVIDENCE_NOT_MATCHED = "PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED"
private const val SPEC_PANEL_NOT_OPENED = "PURCHASE_SPEC_PANEL_NOT_OPENED"
private const val SPEC_TARGET_NOT_VISIBLE = "PURCHASE_SPEC_TARGET_NOT_VISIBLE"
private const val SPEC_TARGET_AMBIGUOUS = "PURCHASE_SPEC_TARGET_AMBIGUOUS"
private const val SPEC_SAFE_TARGET_MISSING = "PURCHASE_SPEC_SAFE_TARGET_MISSING"
@@ -226,26 +226,6 @@ 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())
@@ -610,11 +590,11 @@ class AgentApiClient(private val serverUrl: String) {
leaseVersion = data.getLong("leaseVersion"),
)
private fun post(path: String, payload: JSONObject, token: String?, recoveryCode: String? = null): JSONObject {
return requireNotNull(request("POST", path, payload, token, recoveryCode))
private fun post(path: String, payload: JSONObject, token: String?): JSONObject {
return requireNotNull(request("POST", path, payload, token))
}
private fun request(method: String, path: String, payload: JSONObject?, token: String?, recoveryCode: String? = null): JSONObject? {
private fun request(method: String, path: String, payload: JSONObject?, token: String?): JSONObject? {
val connection = (URL(serverUrl + path).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = 10_000
@@ -625,7 +605,6 @@ 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,12 +187,11 @@ class AgentForegroundService : Service() {
if (!registeredThisProcess.get()) {
val registration = api.register(deviceInfo(), credentials?.token)
if (registration.deviceToken != null) {
if (credentials == 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) { "服务端设备身份与本地不一致" }
}
@@ -473,7 +472,6 @@ 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()
@@ -367,64 +367,6 @@ class PddProductDetailCollectorTest {
assertEquals("bottom_purchase", parsed.specEntrySource)
}
@Test
fun bottomPurchaseEntriesPreferRightmostCandidate() {
val snapshot = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("buy-left", "单独购买", 446, 2000, 685, 2160, clickable = true),
node("buy-right", "发起拼单", 685, 2000, 1080, 2160, clickable = true),
),
)
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
assertEquals("buy-right", parsed.specEntry?.path)
assertEquals("buy-right", parsed.specEntryClickTarget?.path)
assertEquals("bottom_purchase_rightmost", parsed.specEntrySource)
assertEquals(2, parsed.bottomPurchaseEntryCount)
}
@Test
fun equallyRightmostBottomPurchaseEntriesPreferSmallerArea() {
val snapshot = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("buy-large", "发起拼单", 685, 2000, 1080, 2160, clickable = true),
node("buy-small", "立即购买", 785, 2000, 980, 2140, clickable = true),
),
)
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
assertEquals("buy-small", parsed.specEntry?.path)
assertEquals("bottom_purchase_rightmost", parsed.specEntrySource)
}
@Test
fun bottomPurchaseCandidatesStillExcludeReviewOrderAndPaymentContexts() {
val snapshot = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("reviews", "商品评价", 446, 1900, 685, 2160, clickable = true),
node("reviews/buy", "购买", 480, 2000, 650, 2100, parentPath = "reviews"),
node("order", "购买并提交订单", 685, 2000, 880, 2160, clickable = true),
node("payment", "购买后立即支付", 880, 2000, 1080, 2160, clickable = true),
),
)
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
assertEquals(null, parsed.specEntry)
assertEquals(0, parsed.bottomPurchaseEntryCount)
}
@Test
fun bottomPurchaseInsideReviewCardIsNeverSpecEntry() {
val snapshot = UiSnapshot(
@@ -629,74 +571,6 @@ 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,122 +120,6 @@ 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 `payment transition frame after safe back reaches unpaid order evidence without payment clicks`() {
val driver = LiveDriver(postSubmitCaptureSequence = listOf("payment", "payment", "order"))
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg55")
automation.finalConfirmation(input().copy(addressSuffix = "_cg55"), address)
automation.submitOrderOnce()
val order = automation.readOrderResult()
assertEquals("PDD-202608210001", order?.orderNo)
assertEquals("2026-08-21T02:30:00Z", order?.submittedAt)
assertEquals(1, driver.postSubmitBackCount)
assertEquals(0, driver.genericSwipes)
assertFalse(driver.clicked.any { it.contains("支付") })
}
@Test
fun `continuous payment activity still stops at bounded post back samples without payment clicks`() {
val driver = LiveDriver(postSubmitCaptureSequence = List(4) { "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(
"支付页安全返回后持续无订单证据,已停止自动核单" +
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=3]",
automation.lastOrderReadFailure?.message,
)
assertEquals(4, driver.postSubmitCaptureCount)
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)
@@ -426,10 +310,6 @@ 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"
@@ -446,7 +326,6 @@ class PurchaseLiveAutomationTest {
var backCount = 0
var postSubmitBackCount = 0
var pddRestoreCount = 0
var orderDetailEntryClicks = 0
var postSubmitCaptureCount = 0
val currentPage: String get() = page
@@ -480,20 +359,7 @@ 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", "微信分身"),
))
@@ -543,14 +409,9 @@ class PurchaseLiveAutomationTest {
page = when {
chooserAfterSubmit -> "chooser"
wechatLoginAfterSubmit -> "wechat-login"
orderDetailEntryAfterSubmit -> "success"
else -> "order"
}
}
"查看订单", "订单详情" -> {
orderDetailEntryClicks++
page = if (orderDetailEvidenceBelowFold) "order-folded" else "order"
}
}
return FreshActionResult.SUCCESS
}
@@ -567,11 +428,7 @@ class PurchaseLiveAutomationTest {
address = value
return FreshActionResult.SUCCESS
}
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 swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { genericSwipes++; return true }
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
scopedSwipes++
if (target.path == "panel" && direction == SwipeDirection.DOWN) addressVisible = true
@@ -582,7 +439,7 @@ class PurchaseLiveAutomationTest {
if (page == "chooser" || page == "payment") postSubmitBackCount++
page = when (page) {
"chooser" -> "payment"
"payment" -> if (orderEvidenceBelowFold) "order-folded" else "order"
"payment" -> "order"
else -> "confirmation"
}
return true
@@ -8,7 +8,6 @@ import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
import cn.ilapage.goauto.agent.automation.RuleValidationException
import cn.ilapage.goauto.agent.automation.SnapshotNode
@@ -22,22 +21,6 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseRehearsalExecutorTest {
@Test
fun `spec gesture policy excludes irreversible and out of bounds targets`() {
fun target(label: String, bounds: NodeBounds = NodeBounds(20, 100, 300, 180)) = SnapshotNode(
"target", null, label, null, null, "android.widget.TextView", bounds,
clickable = true, scrollable = false, selected = false, checked = false, enabled = true, visible = true,
)
assertTrue(PurchaseSpecGesturePolicy.isAllowed(target("选择规格"), 1080, 2200))
assertTrue(PurchaseSpecGesturePolicy.isAllowed(target("XL"), 1080, 2200))
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("提交订单"), 1080, 2200))
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("确认购买"), 1080, 2200))
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("立即支付"), 1080, 2200))
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("修改地址"), 1080, 2200))
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("XL", NodeBounds(-20, 100, -2, 180)), 1080, 2200))
}
@Test
fun `shared current live rule matches Android contract`() {
val raw = requireNotNull(javaClass.classLoader?.getResource("purchase-rule-live-v1.json")) {
@@ -147,8 +130,6 @@ class PurchaseRehearsalExecutorTest {
assertEquals("rehearsal_completed", outcome.resultType)
assertTrue(driver.clicked.contains("XL"))
assertEquals(3, driver.upSwipeCount)
assertTrue(driver.swipeInPaths.isNotEmpty())
assertTrue(driver.swipeInPaths.all { it == "scroll" })
}
@Test
@@ -166,31 +147,18 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `rerendered exact size falls back to one verified center gesture`() {
fun `rerendered exact size is refound and selected on the second attempt`() {
val driver = FakePurchaseDriver(
sizeClickResults = mutableListOf(FreshActionResult.NOT_FOUND),
specTapResult = FreshActionResult.SUCCESS,
sizeClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, driver.sizeClickCount)
assertEquals(1, driver.specTapCount)
assertEquals(2, driver.sizeClickCount)
assertEquals("XL", driver.size)
}
@Test
fun `already selected exact size is not clicked again`() {
val driver = FakePurchaseDriver(initialSize = "XL")
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.sizeClickCount)
assertEquals(0, driver.specTapCount)
}
@Test
fun `long exact size can be verified by one unambiguous short summary token`() {
val target = "XL【建议121-140斤】"
@@ -312,17 +280,16 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `persistent exact size click failure stops after one action and one bounded gesture`() {
fun `persistent exact size click failure stops after three attempts`() {
val driver = FakePurchaseDriver(
sizeClickResults = mutableListOf(FreshActionResult.FAILED),
sizeClickResults = MutableList(3) { FreshActionResult.FAILED },
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_CLICK_FAILED", outcome.errorCode)
assertEquals("action_click_false", outcome.message)
assertEquals(1, driver.sizeClickCount)
assertEquals(1, driver.specTapCount)
assertEquals(3, driver.sizeClickCount)
assertEquals(null, driver.size)
}
@@ -451,109 +418,6 @@ 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_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertEquals(
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true]",
outcome.message,
)
assertEquals(0, driver.specTapCount)
assertFalse(outcome.message.orEmpty().contains("确认款式"))
}
@Test
fun `unchanged spec entry action uses one verified center gesture`() {
val driver = FakePurchaseDriver(
entryActionHasEffect = false,
specTapResult = FreshActionResult.SUCCESS,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, driver.specTapCount)
assertTrue(driver.panel)
}
@Test
fun `unchanged spec entry after action and gesture returns no effect`() {
val driver = FakePurchaseDriver(
entryActionHasEffect = false,
specTapResult = FreshActionResult.SUCCESS,
specTapHasEffect = false,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
assertEquals(1, driver.specTapCount)
assertFalse(driver.panel)
}
@Test
fun `missing spec entry emits scalar source counts without node text`() {
val diagnostics = mutableListOf<String>()
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(missingSpecEntry = true)
val outcome = PurchaseRehearsalExecutor(
driver,
{ driver.browser = true; true },
{ null },
pause = pauses::add,
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;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
diagnostics.single(),
)
// One 100ms pause belongs to the existing open-product foreground poll;
// the diagnostic proves the entry-ready loop itself used exactly 20.
assertEquals(21, pauses.count { it == 100L })
assertFalse(diagnostics.single().contains("选择规格"))
}
@Test
fun `open spec panel waits for a late safe bottom purchase entry`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(bottomPurchaseEntry = true, specEntryVisibleAfterPddCaptures = 4)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = pauses::add)
.execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertTrue(driver.clickedPaths.contains("buy"))
// One 100ms pause belongs to the existing open-product foreground poll;
// two belong to the entry-ready wait before the bottom bar appears.
assertEquals(3, pauses.count { it == 100L })
}
@Test
fun `verify product waits for product evidence instead of a visible loading frame`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, pauses.count { it == 100L })
}
@Test
fun `transient sold out page recovers before opening specs`() {
val driver = FakePurchaseDriver(soldOut = true, recoverSoldOutAfterPull = true)
@@ -697,29 +561,6 @@ class PurchaseRehearsalExecutorTest {
assertFalse(driver.clicked.contains("商品评价"))
}
@Test
fun `spec entry failures include scalar diagnostics only`() {
val driver = FakePurchaseDriver(missingSpecEntry = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.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;entryReadyWaitPolls=20;entryReadyWaitMillis=2000]",
outcome.message,
)
val ambiguousDriver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.TARGET_AMBIGUOUS)
val ambiguous = PurchaseRehearsalExecutor(ambiguousDriver, { ambiguousDriver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS", ambiguous.errorCode)
assertEquals(
"规格入口点击目标不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=0;entryReadyWaitMillis=0]",
ambiguous.message,
)
}
@Test
fun `parser keeps type only compatibility and rejects dangerous or unknown fields`() {
val legacy = """{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct"}]}"""
@@ -868,9 +709,6 @@ 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 specEntryVisibleAfterPddCaptures: Int = 0,
private val loadingPddCaptures: Int = 0,
private val includeReviewEntry: Boolean = false,
private val openReviewOnBottomClick: Boolean = false,
private val reviewBackSucceeds: Boolean = true,
@@ -880,10 +718,6 @@ class PurchaseRehearsalExecutorTest {
private val sizeClickResults: MutableList<FreshActionResult> = mutableListOf(),
private val forcedSizeClickReason: FreshClickReason? = null,
private val forcedEntryClickReason: FreshClickReason? = null,
private val initialSize: String? = null,
private val entryActionHasEffect: Boolean = true,
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
private val specTapHasEffect: Boolean = true,
private val sizeSelectsOnFailedClick: Boolean = false,
private val selectedSizeSummaryOverride: String? = null,
private val hideSizeSelectedState: Boolean = false,
@@ -898,14 +732,11 @@ 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
var color: String? = null
var size: String? = initialSize
var size: String? = null
var quantity = 1L
var swipeCount = 0
var upSwipeCount = 0
@@ -913,13 +744,10 @@ class PurchaseRehearsalExecutorTest {
var sizeClickCount = 0
var pullDownCount = 0
var backCount = 0
var specTapCount = 0
val swipeInPaths = mutableListOf<String>()
private var soldOut = soldOut
private var allSpecsUnavailable = allSpecsUnavailable
private var productEvidenceLost = false
private var reviewPage = false
private var pddCaptureCount = 0
val clicked = mutableListOf<String>()
val clickedPaths = mutableListOf<String>()
@@ -930,10 +758,6 @@ class PurchaseRehearsalExecutorTest {
openNodes += node("content", "", 0, 0, 1080, 2200)
return UiSnapshot("com.heytap.browser", "BrowserActivity", openNodes)
}
pddCaptureCount++
if (pddCaptureCount <= loadingPddCaptures) {
return UiSnapshot(PDD, ACTIVITY, listOf(node("content", "", 0, 0, 1080, 2200)))
}
if (!panel) {
if (reviewPage) {
return UiSnapshot(PDD, ACTIVITY, listOf(
@@ -949,32 +773,23 @@ class PurchaseRehearsalExecutorTest {
if (soldOut) {
return UiSnapshot(PDD, ACTIVITY, listOf(
node("content", "", 0, 0, 1080, 2200),
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
node("sold-out", "商品已售罄", 100, 300, 900, 380),
node("similar", "相似商品", 100, 500, 900, 580),
))
}
val nodes = mutableListOf(
node("content", "", 0, 0, 1080, 2200),
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
)
val specEntryReady = pddCaptureCount > specEntryVisibleAfterPddCaptures
if (bottomPurchaseEntry && specEntryReady) {
if (bottomPurchaseEntry) {
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 if (!missingSpecEntry && specEntryReady) {
} else {
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
@@ -987,8 +802,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)
}
@@ -1033,9 +848,7 @@ class PurchaseRehearsalExecutorTest {
clicked += target.label
clickedPaths += target.path
if (target.path == "buy") {
if (entryActionHasEffect) {
if (openReviewOnBottomClick) reviewPage = true else panel = true
}
if (openReviewOnBottomClick) reviewPage = true else panel = true
return FreshActionResult.SUCCESS
}
when (target.label) {
@@ -1045,7 +858,7 @@ class PurchaseRehearsalExecutorTest {
if (result == FreshActionResult.SUCCESS || openPddOnFailedClick) browser = false
return result
}
"选择规格", "免拼购买" -> if (entryActionHasEffect) panel = true
"选择规格", "免拼购买" -> panel = true
in sizes -> {
sizeClickCount++
val result = sizeClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS
@@ -1091,17 +904,6 @@ class PurchaseRehearsalExecutorTest {
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = clickFresh(target)
override fun tapSpecFresh(target: SnapshotNode): FreshActionResult {
specTapCount++
if (specTapResult != FreshActionResult.SUCCESS || !specTapHasEffect) return specTapResult
when {
target.path in setOf("spec", "buy") -> panel = true
target.label in sizes -> size = target.label
target.label in colors -> color = target.label
}
return FreshActionResult.SUCCESS
}
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
quantity = value.toLong()
return FreshActionResult.SUCCESS
@@ -1110,13 +912,11 @@ class PurchaseRehearsalExecutorTest {
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
swipeCount++
if (direction == SwipeDirection.UP) upSwipeCount++
return purchaseSwipeSucceeds
return true
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
swipeInPaths += target.path
return swipePurchase(direction, durationMs)
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean =
swipePurchase(direction, durationMs)
override fun pullDownGoodsPage(): Boolean {
pullDownCount++
+26 -11
View File
@@ -2,16 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: de7e400503d6e7b9c335657114893de345bfead5
synchronized_at: 2026-08-31T03:33:36Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: dc5ccd2104e8ded715c0c0bb23d3827dc9a7fd7a
synchronized_at: 2026-08-29T01:39:35Z
wiki_revision: a8ea0f6ae4c4a05b10ba047516fda23cfb188d13
synchronized_at: 2026-09-02T15:05:31Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -56,7 +48,7 @@ Android Portal/Agent
- Android 本地互斥与服务端原子领取共同保证单设备串行。
- 正式采购在 Android 本地 SQLite 事务中先保存不可逆状态、稳定服务端请求 ID 和脱敏最终确认快照,再通知服务端并只允许一次创建订单点击;重启后只重放服务端标记、只读核单或上报,不再次点击。
- 原始控件树和截图不持久化;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色;尺码自动匹配只预览格式统一后的唯一确定结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色。Admin 商品详情的“一键匹配颜色和尺码”在服务端统一计算两个维度:保留当前仍有效的已确认映射,将唯一确定匹配及达到阈值、具备理由且候选仍有效的 AI 匹配,在重新校验规格上下文后于同一事务直接写为 `confirmed`,无需人工确认;低置信度或无结果保持未匹配,Provider 异常或上下文变化时不写入任何本次结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
## 最小业务数据
@@ -318,3 +310,26 @@ PddProductDetailCollector
- `GET /api/admin/v1/pdd-products/{productId}/related-syb-products` 返回统一扁平分页,参数为 `page`、`pageSize`(默认 20、最大 100)、`scope=actionable|all`(默认 actionable)和可选 `shopeeProductId`。管理员和采购员可读,其他角色由 Casbin 拒绝。
- `purchase.Service.ProcessStages` 是 SYB 页和 PDD 关联订单共用的只读阶段入口;批量加载、不调用 AI。当前采购规则缺失或无效时仍返回基础关联、采集和已有任务事实,依赖规则才能创建的行明确标记不可采购。
- Web 的 PDD 商品详情把“创建备货采购”保留在顶部,把“关联订单继续采购”放在关联商品区块;订单行使用统一分页,并复用既有 `batch-preview` / `batch` 创建路径。
## 蝦皮规格自动匹配批处理(#195)
- 服务端在 `server/app/goauto/shopeeproduct/auto_match_batch.go` 复用单商品“一键匹配颜色和尺码”原子服务,定时任务与 Admin 手动执行共用同一批处理入口。
- `shopee_spec_auto_match_run` 保存触发来源、稳定 `requestId`、运行摘要和可空唯一 `active_slot`;活动槽与租约保证多实例、定时和手动同时触发时全局最多一个运行批次。
- `shopee_spec_auto_match_work_item` 按蝦皮商品唯一保存输入指纹、尝试次数、下次尝试时间和逐商品租约。已完成或低置信度/无结果的相同输入不重复调用 AI;规格、关联或 AI 设置更新时间变化后才允许重新处理。
- 迁移 `1788290000000_shopee_spec_auto_match.go` 幂等创建两张表并写入调用目标 `GoAutoShopeeSpecAutoMatch`。系统任务默认关闭,默认 Cron 为每小时第 15 分钟、每批最多 20 个商品。
- Admin 蝦皮商品列表通过异步手动接口启动同一批次并轮询最近运行摘要;该入口仅管理员可用,不创建采购任务、订单,不调用 Android Agent,也不执行付款。
## SYB 异常规格 AI 定时解析(#198)
- `server/app/goauto/sybimport/ai_parse_batch.go` 以 SYB 明细为单位执行“确定性重解析 → 封闭候选 AI 解析”,与 #195 的“蝦皮规格 → PDD 规格”任务保持独立。
- `syb_spec_ai_parse_run` 保存全局批次、活动槽、租约与结构化计数;`syb_spec_ai_parse_work_item` 按 `syb_product_id` 唯一保存输入指纹、尝试次数、冷却和逐行租约。
- `syb_product` 的 `ai_confirmed`、置信度、限长理由、确认时间和隐藏输入指纹记录 AI 确认事实;`parse_status` 仍只记录确定性解析器结果,`manually_confirmed` 仍只代表人工决定。
- Provider 只接收单条 `productSpec` 和关联蝦皮商品的颜色/尺码候选;返回值必须逐字属于对应候选。采购可信门禁接受仍有效的 AI 确认,但候选消失后立即 fail-closed。
## 定时任务持久化执行历史(#199)
- `server/app/jobs/execution_log.go` 为 Exec 与 HTTP 两类调度入口统一记录一次执行生命周期;`server/app/jobs/models/sys_job_execution_log.go` 对应表 `sys_job_execution_log`,保存执行标识、任务/调用目标快照、定时触发类型、开始/结束时间、耗时、`running` / `succeeded` / `failed` / `interrupted` 状态及脱敏错误码和摘要。
- 服务启动时按当前“每个数据库一个调度器实例”的拓扑,把上次进程遗留的 `running` 记录安全收敛为 `interrupted`;任务删除后历史仍保留并可只读查询。
- `GET /api/v1/sysjob/:id/execution-logs` 由 `server/app/jobs/service/execution_log.go`、`apis/execution_log.go` 和 `router/sys_job.go` 提供任务级分页、状态与开始时间过滤;沿用隐藏菜单 `JobLog` 的角色菜单绑定和精确 GET 权限。Web 入口为 `web/src/views/schedule/index.vue` 的单选“日志”按钮,详情页为 `web/src/views/schedule/log.vue`。
- 执行历史明确不保存任务参数、AI Provider 地址或密钥、第三方原始响应和业务原始载荷;当前不提供删除、保留期限自动化、WebSocket 实时流或立即执行动作。
- 追加迁移为 `server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go`:创建执行历史表、登记只读 API、关联 `JobLog` 菜单,并只给迁移前已绑定该菜单的角色补充精确 Casbin 权限。
+6 -24
View File
@@ -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: c7f56f233263b3c83df7e31cf75c89c48ff24111
synchronized_at: 2026-09-04T10:22:02Z
wiki_revision: a60c48f56ec7cad6f886df78a644fbabb92d288f
synchronized_at: 2026-09-02T13:23:12Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -31,7 +31,7 @@ synchronized_at: 2026-09-04T10:22:02Z
- 关联 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 匹配与 #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 异常不得改写现有映射。
- 映射来源分 `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`;其余结果只有在 `ai_match` 置信度存在且达到服务端阈值、理由非空、返回值属于当前可选候选并命中同一个可售颜色+尺码组合时,才可直接持久化为 `confirmed`。低置信度、无组合证据、无效组合或 Provider 异常不得改写现有映射。
- 颜色映射只能从关联 PDD 商品当前可选颜色中选择,不允许自由输入;未使用颜色优先显示,已被其他蝦皮颜色使用的颜色仍可选择并显示占用者,因此支持多对一。
- Admin 蝦皮商品详情只保留一个“一键匹配颜色和尺码”入口。已确认且目标仍存在的映射保留;名称标准化后的唯一确定结果直接写为 `exact_match + confirmed`;其余只有在 AI 置信度达到服务端当前阈值、理由非空、返回值仍属于当前可选候选时才写为 `ai_match + confirmed`。低置信度或无结果保持未匹配;Provider 失败、AI 未启用、关联或规格上下文变化时本次不写入。操作前有未保存的人工修改时禁用一键匹配;保存只改映射,不改蝦皮或 PDD 原始规格。
- PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
@@ -41,8 +41,6 @@ synchronized_at: 2026-09-04T10:22:02Z
- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「蝦皮订单号 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。
- `productSpec` 自由文本解析:按最后一个逗号切分为颜色/尺码 → 剥离【】备注 → 判断残留分隔符;解析状态分 `success`/`uncertain`/`failed`,永不猜测原文中不存在的颜色或尺码。
- 采购放行不以 uncertain 单独阻断:success 与已提取出至少一个非空颜色或尺码的 uncertain 明细,都可继续进入规格匹配和采购预检;只有 failed 或颜色、尺码均为空的异常明细才要求人工处理。uncertain 状态与解析说明继续保留供审计,不会自动猜测规格。
- 只有 `success` 状态的解析结果会合并进虾皮商品档案的规格值(来源标记 `import`);`uncertain`/`failed` 只停留在明细行上供人工复核,不污染共享档案。
- 金额单位按接口分别定义:`/am/stock/detail/listByStock` 的 `productPrice` 是十进制金额,与 `/am/stock/list` 的 `amtOrder`(×100 整数)不是同一套换算,不可共用。
- 关联虾皮商品档案:命中存活记录直接关联;命中软删除记录则复活并保留原有人工映射;均不存在时创建最小档案。命中既有档案时只补写为空的参考图与售价,不覆盖人工修改过的标题、店铺和映射。
@@ -64,7 +62,6 @@ synchronized_at: 2026-09-04T10:22:02Z
## SYB 店铺过滤
- `syb_shop` 保存允许导入的 SYB 店铺;店铺名比较前统一去除首尾空白、转换全角/半角字符并忽略大小写。规范化后同名的店铺只能保留一条有效记录。
- 版本迁移会对历史存活店铺回填 `normalized_name = Normalize(display_name)`;回填只改匹配键且可重复执行。若两个存活店铺回填后会得到同一键,迁移必须整体失败并保留原数据,管理员先人工消除歧义后再执行,不能静默合并、删除或改变店铺启用状态。
- 店铺可以从 SYB 真实货运单列表发现,也允许管理员手工补充。只有管理员可以新增、改名、启停和软删除,采购员等其他角色只读。
- 没有任何启用店铺时,导入必须在读取凭据、建立会话、验证码 OCR 和任意 SYB 网络请求之前失败,并给出“请先启用店铺”的可读提示。
- 同步必须先拉取并校验当天原始全量列表的总数、分页和唯一 ID,再按本次同步开始时固定的启用店铺快照过滤;过滤不能降低完整性校验的请求范围或容量上限。
@@ -76,7 +73,7 @@ synchronized_at: 2026-09-04T10:22:02Z
- 导入由管理员创建,创建成功后立即转入后台执行;页面关闭不取消任务。采购员等其他已登录角色可以查看同步记录,不能开始导入。
- 系统同一时刻只运行一个 SYB 导入任务。`syb_sync_run` 的唯一执行槽负责跨进程互斥,内存锁减少同一进程内的竞争;服务重启后遗留的执行中记录标记为“已中断”。
- 导入失败或中断时保留已写入商品,记录明确的失败原因和已处理进度;重新导入相同范围按「订单号 + 明细 ID」覆盖,不产生重复商品。
- 创建同步记录时冻结本次启用店铺的规范化匹配集合、展示名称快照及 SHA-256 哈希;后台执行必须只使用这一份快照,不得在开始后重新读取 `syb_shop`。同步详情保存并返回快照可用标记、快照店铺名称、哈希及各店铺“已导入/已跳过”数量。#212 之前的历史记录只有哈希和统计,明确标记为无完整快照。上述记录不保存账号、密码、Cookie、Token、验证码图片或 SYB 原始响应。
- 每条同步记录保存本次启用店铺集合的 SHA-256 哈希以及各店铺“已导入/已跳过”数量,不保存账号、密码、Cookie、验证码图片或 SYB 原始响应。
- 后台任务创建前的内部失败仍向普通用户显示“服务端处理失败”,但使用稳定错误码区分阶段:店铺预检为 `SYNC_SHOP_PREFLIGHT_FAILED`,同步记录创建为 `SYNC_RUN_CREATE_FAILED`。服务端只记录阶段和经过脱敏、截断的底层原因,不记录请求体、凭据、Cookie、Token、验证码或原始响应。
## 采集规则
@@ -87,14 +84,12 @@ synchronized_at: 2026-09-04T10:22:02Z
- 删除采用软删除;删除后不能创建新任务,但已有任务继续执行自身快照。
- 商品详情步骤应声明精确 `activityName`,并与包名和唯一控件共同作为页面证据;进入 PDD 登录 Activity 必须返回 `PDD_LOGIN_REQUIRED`,不能提交采集成功。
- 唯一文字节点不可点击时,Agent 只可点击其最近的可点击父容器;不得改点兄弟节点或相似文字。
- 商品页规格入口只接受带明确选择语义的规格摘要,或底部真实“购买/拼单”文字按钮。若“请选择”和颜色/尺码语义分散在同一可点击父容器的子节点中,也只能在该容器唯一、位于非底部动作区且同时具备两类语义时作为规格入口;规则别名只能收窄已识别的安全候选,不能按页面原始文字扩张。底部购买入口在通过全部安全过滤后仍存在多个候选时,按最右优先(`centerX` 降序,并列时可点击面积升序)确定性选出唯一入口,不再因候选不唯一直接失败;该情形记为 `bottom_purchase_rightmost`,唯一候选仍记为 `bottom_purchase`。“单独购买”与“发起拼单”打开同一规格面板,但对应不同成交形态与价格,最右优先是经人工确认的取舍。评价、评论、晒单、问答、订单、提交订单、支付和付款区域及其父容器一律排除。商品评价页与详情页共用 Activity 时,首次误触只允许安全返回并重新定位一次,重复误触明确失败;点击无变化、误入评价页和面板结构无法确认使用不同错误码。
- 商品页规格入口只接受带明确选择语义的规格摘要,或底部真实“购买/拼单”文字按钮;评价、评论、晒单、问答区域及其父容器一律排除。商品评价页与详情页共用 Activity 时,首次误触只允许安全返回并重新定位一次,重复误触明确失败;点击无变化、误入评价页和面板结构无法确认使用不同错误码。
- v2 规则使用类型化动作和固定阶段钩子。已由 Agent 支持的选择器、别名、超时、滑动方向和有限次数可以只更新规则;新增动作类型或页面算法才需要升级 Agent。
- v2 任务首次未进入精确商品详情页时,可以按规则显式重开同一浏览器 URL 一次;登录、验证码、风控、无效链接和详情页内采集失败不触发该恢复。
- 假售罄恢复只使用主商品规格/购买强证据判断页面是否正常;顶部“相似商品”可以作为受控兜底证据,推荐卡片的标题、价格和销量不得冒充主商品证据。
- 商品规格遍历必须用已识别规格节点锁定横向颜色容器和纵向面板容器;颜色按视觉行蛇形遍历,滑动完成后重新读取节点,尺码只读并允许在标题滚出后沿已锁定容器续页。
- PDD 已选规格的订单确认形态只可由详情页、选择摘要、唯一数量控件、支付区、唯一底部提交动作和唯一主要滚动容器的组合证据确认;底部提交动作只读且永久禁止点击。Agent 只在该已确认容器内最多三次向下拖动回顶,每次重新读取节点;“参考分类”作为尺码的精确标题别名处理。
- PDD 完整规格选择器若无主滚动容器且尚未选择规格而没有已选摘要,仍可识别为非滚动规格面板,但必须同时出现至少两个已解析维度、至少两个可选项、唯一数量控件和唯一底部订单动作;缺少其中任一强证据时保持未识别。该订单动作仍仅作页面证据,永久禁止点击。
- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则只能使用独立审核的创建订单能力。当前项目不实现支付动作、入口或测试;支付、下单和订单相关文字可以作为只读页面证据配置,但不得成为点击目标。后续支付能力必须单独评估并至少具备显式能力位、服务端开关、单笔金额上限和人工授权。
## 采集任务
@@ -148,7 +143,7 @@ synchronized_at: 2026-09-04T10:22:02Z
- 地址编辑页可以同时存在收货人、手机号和详细地址等多个输入框;Agent 只选择与“详细地址”标签纵向重叠且位于其右侧的唯一输入框,不能用页面输入框总数或顺序猜测。
- 点击创建订单前,Agent 必须先在本地事务保存 `order_submit_started`、不可逆时间、稳定请求 ID 和不含地址全文的最终确认快照,再用同一请求 ID通知服务端;两侧成功后才允许精确点击唯一创建订单按钮一次。
- 进入不可逆边界后,进程重启、断网、点击结果不明或无法取得唯一订单号/下单时间时只允许只读核单并进入 `order_result_unknown`,禁止再次点击;任务与订单正式关联仍以完整 PDD 订单号为准。
- 创建订单后出现 Android 多微信应用选择器时,Agent 只有在系统包和 `ChooserActivity` / `ResolverActivity` 白名单、已知选择器标题、微信候选三类证据同时成立时,才允许按一次返回,绝不点击微信候选。若已进入真实微信包 `com.tencent.mm`,Agent 不点击、不输入、不登录、不支付、不强制停止微信,只允许用一次无参数 PDD 启动 Intent 把既有 PDD 任务栈拉回前台;第二次仍见微信或拉起失败即保持结果未知。回到 PDD 已知支付 Activity 或明确支付动作页面后最多再返回一次,再只读读取唯一订单号和下单时间;下单成功页仅可点击唯一、精确的“查看订单”或“订单详情”入口一次,进入 PDD 订单详情后才允许有限向上手势下翻读取折叠内容。PDD 页面出现“待付款”“待支付”“订单编号”或“下单时间”等订单结果信号时,该只读信号优先于复用的支付 Activity 名称:允许在唯一主要纵向容器内有限下翻并重新读取,仍不得点击支付;没有任何订单结果信号的支付页或未知页面不得盲目滑动。入口缺失或不唯一、点击后未出现详情证据、任一页面不在白名单、恢复动作重复或结果不唯一时保持 `order_result_unknown`,禁止重下单或支付。
- 创建订单后出现 Android 多微信应用选择器时,Agent 只有在系统包和 `ChooserActivity` / `ResolverActivity` 白名单、已知选择器标题、微信候选三类证据同时成立时,才允许按一次返回,绝不点击微信候选。若已进入真实微信包 `com.tencent.mm`,Agent 不点击、不输入、不登录、不支付、不强制停止微信,只允许用一次无参数 PDD 启动 Intent 把既有 PDD 任务栈拉回前台;第二次仍见微信或拉起失败即保持结果未知。回到 PDD 已知支付 Activity 或明确支付动作页面后最多再返回一次,再只读读取唯一订单号和下单时间;任一页面不在白名单、恢复动作重复、无法到达订单详情或结果不唯一时保持 `order_result_unknown`,禁止重下单或支付。
- Agent 本地 SQLite/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。
- 人工支付复核只记录 `paid` / `unpaid`;系统不执行或识别支付。快递单号与回填状态属于采购任务,后续物流工单实现。
- 采购任务领取时同时占用设备租约和可选 PDD 账号租约;租约过期后才可释放并重新领取。设备还存在采集任务时不能领取采购任务。
@@ -391,13 +386,6 @@ synchronized_at: 2026-09-04T10:22:02Z
- Agent APK 版本由管理员在设备页抽屉中上传、核对并显式设为当前。服务端从 APK Manifest 读取整数 `versionCode` 和 `versionName`,保存 SHA-256、大小、说明和创建人;APK 位于非公开目录,Admin 与设备下载均需认证。
- Agent 只以整数 `versionCode` 判断更新。启动时静默检查一次,设置页可手动检查、显示下载进度并取消;下载完成必须校验大小和 SHA-256。设备有活动任务时禁止检查、下载和安装;安装始终交给 Android 系统确认,项目不绕过未知来源权限。
## Android 采购规格动作安全执行(#214)
- 规格入口与精确规格选择统一按“重新定位唯一目标 → 执行一次无障碍点击 → 读取新页面验证”执行;不得复用旧无障碍节点,不选择相近规格,也不在多候选时默认点击第一个。
- 无障碍点击后页面完全无变化时,只允许对解析器已确认的安全规格入口或服务端下发且唯一命中的精确规格执行一次中心手势兜底。目标必须可见、启用、边界有效,手势后仍须以规格面板强证据或精确选中证据确认结果。
- 页面发生变化但规格面板强证据不足时,不再继续手势或猜测页面,明确失败并只记录面板类型、候选数量和证据布尔值等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
- 规格已处于精确选中状态时不得重复点击。规格查找只在解析器唯一识别的规格面板容器内有限滚动,每次滚动后重新定位容器与目标;容器缺失、歧义、到边或验证失败均 fail-closed。
- 中心手势兜底不得用于修改/保存地址、创建或提交订单、订单详情入口以及任何支付/付款目标;正式创建订单的一次性不可逆门禁与永久禁止支付规则不变。
## PDD 商品反向关联与继续订单采购(#161)
- 一个 PDD 商品可被多个虾皮商品共用。PDD 详情返回全部关联虾皮商品摘要;订单行由独立统一分页接口读取,不按虾皮商品伪造嵌套分页。
@@ -421,9 +409,3 @@ synchronized_at: 2026-09-04T10:22:02Z
- AI 只在关联蝦皮商品的封闭候选集合中返回原始颜色/尺码,并必须提供达到当前自动确认阈值的置信度和非空理由;集合外值、缺失角色、低置信度、歧义、无结果和输入漂移都不得确认。
- `parse_status` 保留确定性解析器结论;AI 与人工确认分别记录,人工优先级最高。重复同步不得覆盖人工值;完全相同输入保留 AI 值,来源或关联变化会清除旧 AI 确认。
- 同一输入的低置信度或无结果不重复调用 Provider;临时故障至少 60 分钟后重试,最多 3 次。任务不创建采集/采购任务、订单,不执行 Android 动作或付款。
## 设备身份恢复
- 管理员可在设备管理对未停用设备发起“重置设备身份”。此动作不删除设备记录、不改变设备 ID、能力或已绑定的待领取任务;它立即使旧 Token 无效,并只生成一次、有效期 10 分钟的恢复码。
- 恢复码仅显示给发起操作的管理员一次,服务端只保存摘要;不得进入列表、日志、任务记录、Android 持久化或普通接口。手机操作员必须在同一安装实例的 Agent 设置中手动输入。
- 服务端只接受同一 `installId`、未过期且尚未使用的恢复码完成重新注册,成功后签发新 Token 并使恢复码失效。过期、重复使用、installId 不符或停用均明确失败;不能通过清空数据、直接改库或“吊销 Token”恢复原任务归属。
+22 -21
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: a48b6a104c9b96834bbe122a226ba50baf8e4f93
synchronized_at: 2026-09-04T10:22:06Z
wiki_revision: 75cebdfb2408d35e59d05d5755068b6d8cd126f5
synchronized_at: 2026-09-02T13:24:29Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -49,15 +49,18 @@ DELETE /api/admin/v1/shopee-products/{productId}/specs/mapping
POST /api/admin/v1/shopee-products/{productId}/specs/mapping/confirm
POST /api/admin/v1/shopee-products/{productId}/specs/mapping/confirm-exact-matches
POST /api/admin/v1/shopee-products/{productId}/specs/mapping/preview-auto-size
POST /api/admin/v1/shopee-products/{productId}/specs/mapping/auto-match
POST /api/admin/v1/shopee-products/batch-delete
```
创建与关联/映射相关写操作均提交 `requestId` 做幂等重放;重放请求返回相同结果并标记 `replayed`。创建请求提交 `shopeeItemId`、`title`、`shopName`,可选 `pddProductId` 和 `specs`;`shopeeItemId` 重复时返回 `SHOPEE_ITEM_ID_EXISTS` 和已存在商品 ID。`link-pdd` 校验 PDD 商品存在且非 `disabled`,否则分别返回 `PDD_PRODUCT_NOT_FOUND` 或 `PDD_PRODUCT_DISABLED`;关联成功后返回值包含 `sharedByPddCount`,表示当前共用同一 PDD 商品的虾皮商品数。
`specs` 结构同 PDD 商品的维度/规格值形状,但规格值额外携带 `source`(`import` / `manual`)与可选的 `mapping`(`pddValue`、`source`、`status`、`confidence`、`reason`)。新增规格值固定为 `manual` 来源;删除规格值仅允许 `manual` 来源,`import` 来源返回 `SPEC_VALUE_NOT_MANUAL`。设置映射时,`exact_match` 与 `ai_match` 来源一律写入 `pending` 状态,与请求体中的 `status` 无关;只有 `manual` 来源可以直接写入 `confirmed`。`confirm-exact-matches` 仅确认 `source=exact_match` 且状态为 `pending` 的映射,不影响 `ai_match`。该通用入口不因 #188 改变;#188 仅通过下述采购批量规格匹配接口的独立写入路径,将唯一确定性 `exact_match` 或通过高置信度门槛的 `ai_match` 写入 `confirmed`。
`specs` 结构同 PDD 商品的维度/规格值形状,但规格值额外携带 `source`(`import` / `manual`)与可选的 `mapping`(`pddValue`、`source`、`status`、`confidence`、`reason`)。新增规格值固定为 `manual` 来源;删除规格值仅允许 `manual` 来源,`import` 来源返回 `SPEC_VALUE_NOT_MANUAL`。设置映射时,`exact_match` 与 `ai_match` 来源一律写入 `pending` 状态,与请求体中的 `status` 无关;只有 `manual` 来源可以直接写入 `confirmed`。`confirm-exact-matches` 仅确认 `source=exact_match` 且状态为 `pending` 的映射,不影响 `ai_match`。该通用入口不因 #188 或 #194 改变;两者只能通过各自独立的服务端写入路径,将唯一确定性 `exact_match` 或通过高置信度门槛的 `ai_match` 写入 `confirmed`。
`preview-auto-size` 是只读计算接口(使用 `POST` 触发计算,不写数据库),无需 `requestId`。它读取当前蝦皮尺码与关联 PDD 的可选尺码,只返回格式统一后唯一确定的匹配;响应含 `items[]`(`valueName`、可选 `pddValue`、`status=preserved|matched|pending`、`reason`)、`pddValues`、`matchedCount` 和 `pendingCount`。已确认且目标仍存在的映射标为 `preserved`;无唯一结果标为 `pending`。Admin 的显式“保存修改”仍通过既有设置/确认接口落库。
`auto-match` 是 Admin 蝦皮商品详情一键匹配颜色和尺码的独立写入接口。请求体必须提交 UUID `requestId` 和详情返回的 `specContextVersion`。服务端在事务外完成必要的 Provider 调用,入事务后重新锁定蝦皮商品与 PDD 商品,复核关联、完整规格上下文、当前可选候选、AI 开关和最新置信度阈值。保留有效的已确认映射;唯一确定结果直接保存为 `exact_match + confirmed`;置信度达标、理由非空且候选仍有效的 AI 结果保存为 `ai_match + confirmed`;其余项保持未匹配。响应含 `product`、`items[]`(`dimension`、`role`、`valueName`、可选 `pddValue`、`status=confirmed|preserved|unmatched`、可选 `source`、`confidence`、`reason`)、`confirmedCount`、`preservedCount`、`unmatchedCount` 和 `replayed`。同一 `requestId` 幂等重放;Provider 异常或 AI 未启用返回 `AI_MATCHING_UNAVAILABLE`,关联或规格变化返回 `SPEC_CONTEXT_VERSION_STALE`,两者都不写入本次结果。
设置映射时,`pddValue` 必须是关联 PDD 商品同角色下当前可选的原始规格标签,否则返回 `INVALID_REQUEST`。PDD 重新采集后旧目标消失时,Admin 标记失效;采购预检与任务创建返回 `PURCHASE_SPEC_MAPPING_REQUIRED` 和“规格匹配已失效,请重新选择 PDD 规格”,不得把旧标签下发给 Agent。
`batch-delete` 提交 `requestId` 和 `ids`(1~500 个),逐条校验引用后返回每条的 `status`(`deleted` / `skipped`)与 `reason`;引用检查覆盖 SYB 明细(#41)与采购任务(#33/#34),两张表落地前恒不阻塞删除。`restore` 恢复一条已软删除商品,恢复后原有 PDD 关联与规格映射保持不变。列表接口 `status=deleted` 筛选已删除商品,默认只返回存活商品。
@@ -493,16 +496,6 @@ POST /api/agent/v1/tasks/{taskId}/fail
演练规则必须包含 `purchase.rehearsal.v1`,并且不能包含 `updateShippingAddress`、`createOrder`、`readOrderResult`。正式规则必须包含 `purchase.live.v1`;改地址和创建订单还分别要求 `purchase.address-update.v1`、`purchase.order-create.v1`。`probeSpecs` 要求 `purchase.spec-probe.v1`。任意模式下,尚未实现的 `pay`、名称包含 `payment` 的动作以及未知动作一律拒绝;服务端不下发任意脚本。
### Android 规格动作执行边界(#214)
本工单不改变采购规则 schema、Agent HTTP 字段或能力标识,只收紧 Android 对 `openSpecPanel` 与 `selectSpec` 的执行语义:
- 每次操作从最新 `rootInActiveWindow` 重新定位;规格入口必须是解析器已接受的唯一安全候选,规格值必须逐字等于任务固化的服务端映射值且在当前维度唯一、可用。
- 动作前先检查后置条件;规格已精确选中时直接成功,不重复点击。否则最多分发一次 `ACTION_CLICK`,再从新快照验证规格面板强证据或精确选中证据。
- `ACTION_CLICK` 后页面结构完全无变化时,Android 可以重新定位同一唯一目标,并在目标可见、启用、中心点位于屏幕内且边界有效时执行一次中心手势。手势后仍未取得后置证据时明确失败,不继续点击。
- 点击后页面已变化但面板分类仍为未知时返回 `PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED`;两种点击后页面均无变化时返回 `PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT`。诊断只携带面板类型、滚动容器数、标题数、选项数及证据布尔值,不携带节点文字或完整控件树。
- 有界查找规格时只使用当前解析结果中的唯一 `specPanelContainer`;每次滚动前重新定位该容器,优先使用节点滚动动作,失败后才在容器边界内使用手势。缺少唯一容器时停止,不回退到全页面最大滚动区域。
- 受控中心手势接口固定拒绝地址、保存地址、创建/提交/确认订单、确认购买和支付/付款语义;`updateShippingAddress`、`createOrder`、`readOrderResult` 不使用该兜底。创建订单的一次性不可逆状态机与禁止支付契约不变。
### 管理端接口
| 方法 | 路径 | 幂等键 / 说明 |
@@ -856,14 +849,22 @@ file=<JPEG 二进制>
Agent 只比较整数 `versionCode`。设备有活动任务时禁止检查、下载和安装;下载到应用私有缓存并校验响应大小与 SHA-256,失败立即删除。安装使用 FileProvider 和 Android 系统安装确认页;未知来源权限必须由用户在系统设置授权,不静默安装。
## 设备身份恢复(#201)
## Admin 蝦皮规格自动匹配批次(#195)
```http
POST /api/admin/v1/devices/{deviceId}/identity-reset
POST /api/agent/v1/register
X-GoAuto-Device-Recovery-Code: <one-time-code>
```
以下接口仅管理员可用,采购员与其他角色必须拒绝;两者不属于 Android Agent 接口,不改变任何 Agent 契约。
仅管理员可以对未停用的既有设备发起身份重置。服务端立即使旧 Device Token 无效,并生成 10 分钟内仅能使用一次的恢复码;恢复码只在该管理员操作的响应中返回一次,服务端仅保存不可逆摘要,管理端设备列表、日志、任务接口和 Android 本地持久化均不得保存或返回原文。管理员将恢复码经受控人工渠道输入同一安装实例的 Agent 设置页。
### `POST /api/admin/v1/shopee-spec-auto-match/runs`
Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口。服务端必须同时校验同一 `installId`、未停用状态、恢复码摘要、未过期和未使用;成功后使用原 `deviceId` 写入新 Token 摘要并返回一次新 Token,清除恢复码摘要和有效期。旧 Token 与恢复码都立即失效,已分配的 pending 采集或采购任务保持原 `deviceId`,不创建替代设备记录。缺少或错误恢复码仍为 `DEVICE_INSTALL_ID_CONFLICT`;过期码为 `DEVICE_RECOVERY_EXPIRED`;停用设备为 `DEVICE_DISABLED`。
请求:`{ "requestId": "UUID" }`。服务端以 `requestId` 幂等,异步受理默认最多 20 个商品的手动批次,并返回 HTTP 202、`data.run` 运行摘要。若已有活动批次,不再创建第二个批次,返回当前运行并标记 `alreadyRunning=true`;相同请求重放标记 `replayed=true`。
### `GET /api/admin/v1/shopee-spec-auto-match/runs/latest`
返回 `data.run`;从未执行时为 `null`。运行摘要包含 `id/requestId/trigger/status/batchLimit/scannedCount/eligibleCount/processedCount/confirmedCount/unmatchedCount/failedCount/startedAt/finishedAt` 和可选脱敏 `errorSummary`,不得返回 API Key、Provider 原始响应或商品原始 JSON。
`status` 当前为 `running`、`completed`、`completed_partial` 或 `failed`。页面只轮询最近摘要;查询本身不调用 AI。批次只更新蝦皮商品规格映射,不创建采购任务、订单或付款动作。
## SYB 异常规格 AI 解析字段(#198)
既有 SYB 商品列表与详情响应增加以下只读字段:`aiConfirmed`、可空 `aiConfidence`、可空 `aiReason`、可空 `aiConfirmedAt`。`aiInputFingerprint` 仅用于服务端并发和漂移校验,禁止通过 API 返回。`parseStatus` / `parseNote` 继续表示确定性解析器结果,`manuallyConfirmed` 继续只表示人工确认。
采购预检把仍命中关联蝦皮当前候选的 `aiConfirmed=true` 视为可信目标规格;候选消失、关联变化或来源重导入使其失效,并返回重新解析/人工修正原因。该能力不新增 Android 接口或 Agent 字段,不创建采购任务或订单。
+30 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 80ae5c8cea87c624fff63ce3e302aa7bf204811e
synchronized_at: 2026-08-31T16:07:03Z
wiki_revision: 16d97e7596dc287786f702c71e25172b75cc4d56
synchronized_at: 2026-09-02T15:05:56Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -39,6 +39,34 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
发布、重启、设置当前 Agent 版本和真机安装都属于高风险操作,必须有对应工单和人工确认。不得在部署过程中执行付款、创建订单或清理生产数据。
### API 长请求超时约束
Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,复合的一键匹配共享 600 秒总预算,Admin 客户端等待 610 秒。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。
Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。
## 回滚
服务异常时把 `/home/goauto/current` 切回上一已验证发布目录并重启 `goauto.service`,随后复核服务状态和 Agent 接口。不要删除当前或历史 APK/发布目录来代替回滚;数据库变化如需回退必须单独评估。
## 蝦皮规格自动匹配任务运维(#195)
- 迁移 `1788290000000_shopee_spec_auto_match.go` 创建运行与工作状态表,并幂等写入 `sys_job.invoke_target=GoAutoShopeeSpecAutoMatch`。首次部署状态为关闭,迁移重跑不会覆盖管理员后来修改的 Cron、参数或启停状态。
- 默认 Cron 为 `0 15 * * * *`(每小时第 15 分钟),默认 `batchLimit` 为 20,合法范围 1~100。上线后先保持关闭,在 Admin 蝦皮商品列表手动执行小批次并核对运行摘要和映射,再由管理员在定时任务页明确启用。
- 定时和手动触发共享数据库唯一活动槽。看到“已有自动匹配正在运行”属于正常互斥结果;运行租约超过 30 分钟会在下次触发时安全释放并把旧运行标记失败。处理每个商品前会续租。
- Provider 临时失败的商品至少 60 分钟后重试,最多 3 次;低置信度或无结果在输入指纹不变时不会重复调用。排错优先查看 `shopee_spec_auto_match_run` 的结构化计数与脱敏错误,以及 `shopee_spec_auto_match_work_item` 的状态、尝试次数、下次尝试时间和租约;不得输出或复制 API Key、Provider 原始响应、商品原始 JSON 或个人数据。
- 紧急回退只需关闭系统任务并停止使用 Admin 手动入口;保留运行记录和既有已确认映射用于审计,不删除数据,也不回滚 #194。
## SYB 异常规格 AI 定时任务(#198)
- 追加迁移 `1788354164329_syb_spec_ai_parse.go` 创建 AI 确认字段、`syb_spec_ai_parse_run`、`syb_spec_ai_parse_work_item`,并幂等写入 `sys_job.invoke_target=GoAutoSYBSpecAIParse`。
- 系统任务默认关闭,默认 Cron 为每小时第 5 分钟、参数 `{"batchLimit":20}`;迁移重跑不得覆盖管理员后续修改的 Cron、参数或启停状态。建议先运行该任务,再由默认第 15 分钟的 #195 完成蝦皮到 PDD 的规格匹配。
- 多实例通过可空唯一活动槽和租约保证全局单批运行;逐行工作项按输入指纹去重。Provider 临时失败至少 60 分钟后重试且最多 3 次,低置信度/无结果在输入不变时不重复调用。
- 上线顺序为:备份数据库、执行追加迁移、发布服务并保持任务关闭、核验表/字段/任务种子,再由管理员决定是否启用。排错只查看运行计数、工作状态和脱敏错误,不输出 API Key、Provider 原始响应、完整 RawJSON 或订单数据。
## 定时任务执行历史运维(#199)
- 发布 #199 时先备份数据库,再执行追加迁移 `1788357000000_sys_job_execution_log.go`,随后发布服务与 Web。迁移创建 `sys_job_execution_log`、登记 `GET /api/v1/sysjob/:id/execution-logs`,并只为原本绑定隐藏菜单 `JobLog` 的角色写入精确读取权限;菜单缺失时迁移明确失败,不扩大角色授权。
- 服务启动会将上次进程遗留的 `running` 执行记录标记为 `interrupted`。该恢复依赖当前线上每个数据库只运行一个调度器实例;扩展为多调度器前必须另建工单引入实例租约,不能直接复用此判断。
- 排错从 Admin 定时任务页单选任务后进入“日志”,按状态和开始时间查询。记录只含稳定错误码和脱敏摘要;需要定位细节时查看受控服务日志,不得把任务参数、Provider 配置/响应、密钥或业务原始数据复制进执行历史。
- 当前没有执行历史删除接口和自动保留策略;删除定时任务不删除历史。数据库容量治理需要另建工单评估。#198 的 `GoAutoSYBSpecAIParse` 在 #199 发布和迁移后仍保持关闭,启用必须由管理员另行确认。
+2
View File
@@ -136,6 +136,8 @@ func moduleKeyForAPI(path string) string {
return ModulePDDProducts
case strings.HasPrefix(path, "/api/admin/v1/shopee-products"):
return ModuleShopeeProducts
case strings.HasPrefix(path, "/api/admin/v1/shopee-spec-auto-match"):
return ModuleShopeeProducts
case strings.HasPrefix(path, "/api/admin/v1/syb-products/sync-runs"):
return ModuleSYBSyncRuns
case strings.HasPrefix(path, "/api/admin/v1/syb-products"):
+2
View File
@@ -48,6 +48,8 @@ var AdminAPIs = []APIPermission{
{"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},
{"手动执行虾皮规格自动匹配", "/api/admin/v1/shopee-spec-auto-match/runs", "POST", false},
{"查看最近虾皮规格自动匹配", "/api/admin/v1/shopee-spec-auto-match/runs/latest", "GET", false},
{"查看 SYB 商品", "/api/admin/v1/syb-products", "GET", true},
{"查看 SYB 商品详情", "/api/admin/v1/syb-products/:productId", "GET", true},
+6 -4
View File
@@ -15,10 +15,12 @@ func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
func TestPurchaserExcludesAdministratorOperations(t *testing.T) {
denied := map[string]bool{
"POST /api/admin/v1/devices/:deviceId/disable": true,
"POST /api/admin/v1/syb-products/import": true,
"POST /api/admin/v1/collection-rules": true,
"PUT /api/admin/v1/ai-matching-settings": true,
"POST /api/admin/v1/devices/:deviceId/disable": true,
"POST /api/admin/v1/syb-products/import": true,
"POST /api/admin/v1/collection-rules": true,
"PUT /api/admin/v1/ai-matching-settings": true,
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
"GET /api/admin/v1/shopee-spec-auto-match/runs/latest": true,
}
for _, permission := range PurchaserAPIs() {
if denied[permission.Method+" "+permission.Path] {
+96 -2
View File
@@ -25,12 +25,28 @@ const (
defaultAutoConfirmMinConfidence = 0.9
)
// MaxProviderTimeout is also the total budget used by composite synchronous
// AI operations. This keeps their HTTP response inside the Admin and API
// transport windows even when an operation needs more than one provider call.
const MaxProviderTimeout = 600 * time.Second
// Service owns the internal AI Provider configuration. The API key exception
// is deliberately narrow: it is plain text only in the dedicated settings
// table and is returned only by the administrator settings handler.
type Service struct {
DB *gorm.DB
HTTPClient *http.Client
DB *gorm.DB
HTTPClient *http.Client
ProviderFailureLogger func(ProviderFailureDiagnostic)
}
// ProviderFailureDiagnostic deliberately contains no URL, model, prompt,
// candidates, response body or credential. It is safe for operational logs.
type ProviderFailureDiagnostic struct {
CallID string
Operation string
Kind string
StatusCode int
Duration time.Duration
}
func NewService(db *gorm.DB) *Service {
@@ -179,6 +195,58 @@ func (s *Service) Resolve(ctx context.Context, request MatchRequest) (MatchResul
return result, nil
}
// ResolveSYBSpec parses one SYB productSpec into the linked Shopee product's
// exact color/size labels. Unlike Resolve, this operation does not map to PDD:
// every non-empty answer must be an exact member of the supplied Shopee set.
func (s *Service) ResolveSYBSpec(ctx context.Context, request SYBSpecParseRequest) (SYBSpecParseResult, error) {
request.ProductSpec = strings.TrimSpace(request.ProductSpec)
request.Colors = usableCandidates(request.Colors)
request.Sizes = usableCandidates(request.Sizes)
if request.ProductSpec == "" || (len(request.Colors) == 0 && len(request.Sizes) == 0) {
return SYBSpecParseResult{}, fail(CodeNoMatch, "SYB 采购规格缺少可判断的原文或蝦皮候选")
}
setting, apiKey, err := s.activeSetting(ctx)
if err != nil {
return SYBSpecParseResult{}, err
}
payload := openAIChatRequest{Model: setting.Model, Temperature: 0, Messages: []openAIMessage{
{Role: "system", Content: "你只负责把一条 SYB 商品规格原文解析成给定蝦皮候选中的原始颜色和尺码。不得猜测、不得改写候选、不得返回候选外文本。只返回 JSON:{\"color\":\"颜色候选原文或空\",\"size\":\"尺码候选原文或空\",\"reason\":\"简短原因\",\"confidence\":0到1}。提供了某角色候选时必须唯一可靠地选择一个,否则对应字段留空。"},
{Role: "user", Content: sybSpecParsePrompt(request)},
}}
body, err := json.Marshal(payload)
if err != nil {
return SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 AI 解析请求生成失败", Cause: err}
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(setting.TimeoutSeconds)*time.Second)
defer cancel()
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint(setting.BaseURL, "chat/completions"), bytes.NewReader(body))
if err != nil {
return SYBSpecParseResult{}, fail(CodeInvalidSetting, "AI 服务地址无效")
}
httpRequest.Header.Set("Authorization", "Bearer "+apiKey)
httpRequest.Header.Set("Content-Type", "application/json")
response, err := s.httpClient().Do(httpRequest)
if err != nil {
return SYBSpecParseResult{}, &Error{Code: CodeProviderUnavailable, Message: "SYB 规格 AI 解析服务暂时不可用", Cause: err}
}
defer response.Body.Close()
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return SYBSpecParseResult{}, fail(CodeProviderUnavailable, "SYB 规格 AI 解析服务暂时不可用")
}
choice, err := parseProviderChoice(responseBody)
if err != nil || !validClosedChoice(choice.Color, request.Colors) || !validClosedChoice(choice.Size, request.Sizes) {
return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 未能在蝦皮候选中唯一解析采购规格")
}
if choice.Confidence == nil || *choice.Confidence < 0 || *choice.Confidence > 1 || strings.TrimSpace(choice.Reason) == "" {
return SYBSpecParseResult{}, fail(CodeNoMatch, "AI 解析结果缺少有效置信度或理由")
}
return SYBSpecParseResult{
Color: choice.Color, Size: choice.Size, Provider: ProviderOpenAICompatible,
Model: setting.Model, Reason: safeReason(choice.Reason), Confidence: choice.Confidence,
}, nil
}
func (s *Service) activeSetting(ctx context.Context) (models.AIMatchingSetting, string, error) {
setting, err := s.setting(ctx)
if errors.Is(err, gorm.ErrRecordNotFound) || !setting.Enabled {
@@ -294,6 +362,22 @@ func validChoice(target, selected string, candidates []string) bool {
return false
}
func validClosedChoice(selected string, candidates []string) bool {
selected = strings.TrimSpace(selected)
if len(candidates) == 0 {
return selected == ""
}
if selected == "" {
return false
}
for _, candidate := range candidates {
if candidate == selected {
return true
}
}
return false
}
func safeReason(reason string) string {
reason = strings.TrimSpace(reason)
if reason == "" {
@@ -316,6 +400,16 @@ func matchPrompt(request MatchRequest) string {
return string(raw)
}
func sybSpecParsePrompt(request SYBSpecParseRequest) string {
payload := struct {
ProductSpec string `json:"productSpec"`
Colors []string `json:"shopeeColorCandidates,omitempty"`
Sizes []string `json:"shopeeSizeCandidates,omitempty"`
}{request.ProductSpec, request.Colors, request.Sizes}
raw, _ := json.Marshal(payload)
return string(raw)
}
type openAIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
@@ -2,6 +2,7 @@ package aimatching
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
@@ -135,3 +136,61 @@ func TestAutoConfirmThresholdDefaultsPersistsAndValidates(t *testing.T) {
t.Fatalf("invalid threshold err=%v", err)
}
}
func TestResolveSYBSpecUsesOnlyProductSpecAndClosedShopeeCandidates(t *testing.T) {
service := matcherTestService(t)
if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil {
t.Fatal(err)
}
var sent map[string]any
service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
raw, err := io.ReadAll(request.Body)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &sent); err != nil {
t.Fatal(err)
}
body := `{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"原文对应唯一候选\",\"confidence\":0.95}"}}]}`
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
})}
result, err := service.ResolveSYBSpec(context.Background(), SYBSpecParseRequest{ProductSpec: "黑色 XL【备注】", Colors: []string{"黑色", "白色"}, Sizes: []string{"L", "XL"}})
if err != nil || result.Color != "黑色" || result.Size != "XL" || result.Confidence == nil || *result.Confidence != 0.95 {
t.Fatalf("result=%+v err=%v", result, err)
}
encoded, _ := json.Marshal(sent)
for _, forbidden := range []string{"orderCode", "address", "rawJson", "price", "test-secret"} {
if strings.Contains(string(encoded), forbidden) {
t.Fatalf("provider payload leaked forbidden field %q: %s", forbidden, encoded)
}
}
for _, required := range []string{"productSpec", "shopeeColorCandidates", "shopeeSizeCandidates"} {
if !strings.Contains(string(encoded), required) {
t.Fatalf("provider payload missing %q: %s", required, encoded)
}
}
}
func TestResolveSYBSpecRejectsCandidateOutsideClosedSetAndMissingConfidence(t *testing.T) {
service := matcherTestService(t)
if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7); err != nil {
t.Fatal(err)
}
responses := []string{
`{"choices":[{"message":{"content":"{\"color\":\"灰色\",\"size\":\"XL\",\"reason\":\"猜测\",\"confidence\":0.99}"}}]}`,
`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"候选\"}"}}]}`,
}
service.HTTPClient = &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) {
body := responses[0]
responses = responses[1:]
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil
})}
request := SYBSpecParseRequest{ProductSpec: "黑 XL", Colors: []string{"黑色"}, Sizes: []string{"XL"}}
for i := 0; i < 2; i++ {
_, err := service.ResolveSYBSpec(context.Background(), request)
var target *Error
if !errors.As(err, &target) || target.Code != CodeNoMatch {
t.Fatalf("attempt %d err=%v", i, err)
}
}
}
+28 -1
View File
@@ -10,6 +10,9 @@ import (
"net/http"
"strings"
"time"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/google/uuid"
)
// Suggestion limits are enforced defensively here too, even though callers
@@ -95,18 +98,26 @@ func (s *Service) SuggestBatch(ctx context.Context, request SuggestRequest) (Sug
}
httpRequest.Header.Set("Authorization", "Bearer "+apiKey)
httpRequest.Header.Set("Content-Type", "application/json")
callID, startedAt := uuid.NewString(), time.Now()
response, err := s.httpClient().Do(httpRequest)
if err != nil {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: providerNetworkErrorKind(err), Duration: time.Since(startedAt)})
return SuggestResult{}, &Error{Code: CodeProviderUnavailable, Message: "AI 建议服务暂时不可用", Cause: err}
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, 1<<20)
responseBody, readErr := io.ReadAll(limited)
if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
if readErr != nil {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "read_error", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议服务暂时不可用")
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "http_status", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议服务暂时不可用")
}
raw, err := parseSuggestChoices(responseBody)
if err != nil {
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "invalid_response", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议响应无效")
}
@@ -145,6 +156,22 @@ func (s *Service) SuggestBatch(ctx context.Context, request SuggestRequest) (Sug
return SuggestResult{Decisions: decisions, Provider: ProviderOpenAICompatible, Model: setting.Model}, nil
}
func (s *Service) logProviderFailure(diagnostic ProviderFailureDiagnostic) {
if s.ProviderFailureLogger != nil {
s.ProviderFailureLogger(diagnostic)
return
}
log.Warnf("AI provider call failed: call_id=%s operation=%s kind=%s status=%d duration_ms=%d",
diagnostic.CallID, diagnostic.Operation, diagnostic.Kind, diagnostic.StatusCode, diagnostic.Duration.Milliseconds())
}
func providerNetworkErrorKind(err error) string {
if errors.Is(err, context.DeadlineExceeded) {
return "timeout"
}
return "network_error"
}
func suggestSystemPrompt(dimension string) string {
noun := "颜色或尺码"
switch dimension {
@@ -6,7 +6,9 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-admin/app/goauto/models"
@@ -134,3 +136,93 @@ func TestSuggestBatchRequiresConfiguredProvider(t *testing.T) {
t.Fatalf("expected CodeNotConfigured, got %v", target.Code)
}
}
func TestSuggestBatchLogsSafeDiagnosticForProvider502(t *testing.T) {
const sensitiveBody = "api-key-and-provider-body-must-not-be-logged"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, sensitiveBody, http.StatusBadGateway)
}))
defer server.Close()
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, server.URL)
var diagnostic ProviderFailureDiagnostic
service := NewService(db)
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "sensitive-source"}},
Candidates: []SuggestCandidate{{ID: "c1", Label: "sensitive-candidate"}},
})
if err == nil {
t.Fatal("expected provider failure")
}
if diagnostic.Operation != "suggest_batch" || diagnostic.Kind != "http_status" || diagnostic.StatusCode != http.StatusBadGateway || diagnostic.CallID == "" {
t.Fatalf("unexpected diagnostic: %+v", diagnostic)
}
printed := fmt.Sprintf("%+v", diagnostic)
for _, secret := range []string{sensitiveBody, "sensitive-source", "sensitive-candidate", "test-key", server.URL} {
if strings.Contains(printed, secret) {
t.Fatalf("diagnostic leaked %q: %s", secret, printed)
}
}
}
func TestSuggestBatchClassifiesProviderTimeout(t *testing.T) {
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, "http://provider.invalid")
var diagnostic ProviderFailureDiagnostic
service := NewService(db)
service.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, context.DeadlineExceeded
})}
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
})
if err == nil || diagnostic.Kind != "timeout" || diagnostic.StatusCode != 0 {
t.Fatalf("timeout was not safely classified: diagnostic=%+v err=%v", diagnostic, err)
}
}
func TestSuggestBatchClassifiesInvalidResponseWithoutLoggingBody(t *testing.T) {
const sensitiveBody = "not-json-with-sensitive-provider-details"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(sensitiveBody))
}))
defer server.Close()
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, server.URL)
var diagnostic ProviderFailureDiagnostic
service := NewService(db)
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
})
if err == nil || diagnostic.Kind != "invalid_response" || strings.Contains(fmt.Sprintf("%+v", diagnostic), sensitiveBody) {
t.Fatalf("invalid response diagnostic is unsafe or missing: diagnostic=%+v err=%v", diagnostic, err)
}
}
func TestSuggestBatchAllowsProviderResponseAfterTwoSeconds(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2100 * time.Millisecond)
chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.95,"reason":"match"}]}`)(w, r)
}))
defer server.Close()
db := openSuggestTestDB(t)
seedEnabledSetting(t, db, server.URL)
result, err := NewService(db).SuggestBatch(context.Background(), SuggestRequest{
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
})
if err != nil || result.Decisions["s1"].CandidateID != "c1" {
t.Fatalf("delayed provider response failed: result=%+v err=%v", result, err)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) }
+18
View File
@@ -29,6 +29,24 @@ type MatchRequest struct {
Sizes []string
}
// SYBSpecParseRequest contains the only source text and closed Shopee
// candidate sets that may leave GoAuto for an AI-assisted SYB parse. It must
// never contain the shipment/order, account, address, price or full raw JSON.
type SYBSpecParseRequest struct {
ProductSpec string
Colors []string
Sizes []string
}
type SYBSpecParseResult struct {
Color string
Size string
Provider string
Model string
Reason string
Confidence *float64
}
type CandidateSnapshot struct {
Colors []string `json:"colors,omitempty"`
Sizes []string `json:"sizes,omitempty"`
+1 -21
View File
@@ -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")), strings.TrimSpace(context.GetHeader("X-GoAuto-Device-Recovery-Code")))
response, err := NewService(db).Register(context.Request.Context(), request, bearerToken(context.GetHeader("Authorization")))
if err != nil {
writeError(context, err)
return
@@ -84,26 +84,6 @@ 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 {
-1
View File
@@ -22,6 +22,5 @@ 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)
}
+4 -77
View File
@@ -28,12 +28,8 @@ 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
@@ -71,11 +67,6 @@ 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
@@ -117,10 +108,6 @@ 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.
@@ -144,11 +131,7 @@ func (service *Service) Authenticate(ctx context.Context, token string) (models.
return result, nil
}
func (service *Service) Register(ctx context.Context, request RegisterRequest, presentedToken string, recoveryCodes ...string) (RegisterResponse, error) {
recoveryCode := ""
if len(recoveryCodes) > 0 {
recoveryCode = recoveryCodes[0]
}
func (service *Service) Register(ctx context.Context, request RegisterRequest, presentedToken string) (RegisterResponse, error) {
request = normalizeRegisterRequest(request)
if err := validateRegisterRequest(request); err != nil {
return RegisterResponse{}, err
@@ -179,15 +162,9 @@ func (service *Service) Register(ctx context.Context, request RegisterRequest, p
if existing.Status == models.DeviceStatusDisabled {
return RegisterResponse{}, &ServiceError{Code: CodeDeviceDisabled, Message: "设备已停用", 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}
if !tokenMatches(presentedToken, existing.TokenDigest) {
return RegisterResponse{}, &ServiceError{
Code: CodeInstallIDConflict, Message: "installId 已注册,需要该设备的有效 Token", Retryable: false,
}
}
updates := map[string]any{
@@ -198,20 +175,6 @@ 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)
@@ -259,42 +222,6 @@ 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)
}
-66
View File
@@ -185,72 +185,6 @@ 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()
+4
View File
@@ -34,7 +34,11 @@ func MigratedModels() []any {
&models.PDDProduct{},
&models.AIMatchingSetting{},
&models.ShopeeProduct{},
&models.ShopeeSpecAutoMatchRun{},
&models.ShopeeSpecAutoMatchWorkItem{},
&models.SYBProduct{},
&models.SYBSpecAIParseRun{},
&models.SYBSpecAIParseWorkItem{},
&models.SYBSession{},
&models.SYBShop{},
&models.SYBSyncRun{},
+9 -3
View File
@@ -39,9 +39,6 @@ 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"`
@@ -536,6 +533,15 @@ type SYBProduct struct {
// recording the parser's own last output for audit even after a manual
// correction; it is not overwritten by the correction itself.
ManuallyConfirmed bool `json:"manuallyConfirmed" gorm:"not null;default:false"`
// AIConfirmed is independent of ParseStatus: ParseStatus remains the
// deterministic parser's audit result, while these fields record a closed-
// candidate, high-confidence AI decision. Human correction always clears
// and supersedes this decision.
AIConfirmed bool `json:"aiConfirmed" gorm:"not null;default:false;index"`
AIConfidence *float64 `json:"aiConfidence,omitempty"`
AIReason string `json:"aiReason,omitempty" gorm:"size:500;not null;default:''"`
AIConfirmedAt *time.Time `json:"aiConfirmedAt,omitempty"`
AIInputFingerprint string `json:"-" gorm:"size:64;not null;default:'';index"`
// RawJSON is the untouched `details[]` element as SYB returned it. It is
// what reparse (#41: "适用于解析规则更新后批量重跑,只读取已保存的原始
@@ -0,0 +1,56 @@
package models
import "time"
// ShopeeSpecAutoMatchRun is one scheduled or administrator-triggered batch.
// ActiveSlot is 1 only while running; its nullable unique index is the
// database-level cross-process mutex shared by both trigger paths.
type ShopeeSpecAutoMatchRun struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
RequestID string `json:"requestId" gorm:"size:36;not null;uniqueIndex:ux_shopee_spec_auto_match_run_request"`
Trigger string `json:"trigger" gorm:"size:16;not null;index"`
Status string `json:"status" gorm:"size:24;not null;index"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_shopee_spec_auto_match_run_active"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
RequestedBy *uint64 `json:"requestedBy,omitempty"`
BatchLimit int `json:"batchLimit" gorm:"not null;default:20"`
ScannedCount int `json:"scannedCount" gorm:"not null;default:0"`
EligibleCount int `json:"eligibleCount" gorm:"not null;default:0"`
ProcessedCount int `json:"processedCount" gorm:"not null;default:0"`
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
FailedCount int `json:"failedCount" gorm:"not null;default:0"`
ErrorSummary string `json:"errorSummary,omitempty" gorm:"size:500;not null;default:''"`
StartedAt time.Time `json:"startedAt" gorm:"not null"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ShopeeSpecAutoMatchRun) TableName() string { return "shopee_spec_auto_match_run" }
// ShopeeSpecAutoMatchWorkItem remembers the last input fingerprint and retry
// state for each product, preventing unchanged low-confidence inputs from
// repeatedly spending AI calls.
type ShopeeSpecAutoMatchWorkItem struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
ShopeeProductID uint64 `json:"shopeeProductId" gorm:"not null;uniqueIndex:ux_shopee_spec_auto_match_work_product"`
RunID *uint64 `json:"runId,omitempty" gorm:"index"`
InputFingerprint string `json:"inputFingerprint" gorm:"size:128;not null;default:'';index"`
Status string `json:"status" gorm:"size:24;not null;index"`
AttemptCount int `json:"attemptCount" gorm:"not null;default:0"`
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty" gorm:"index"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
LastErrorCode string `json:"lastErrorCode,omitempty" gorm:"size:64;not null;default:''"`
LastError string `json:"lastError,omitempty" gorm:"size:500;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ShopeeSpecAutoMatchWorkItem) TableName() string {
return "shopee_spec_auto_match_work_item"
}
@@ -0,0 +1,48 @@
package models
import "time"
// SYBSpecAIParseRun is one globally serialized scheduled batch.
type SYBSpecAIParseRun struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
RequestID string `json:"requestId" gorm:"size:36;not null;uniqueIndex:ux_syb_spec_ai_parse_run_request"`
Trigger string `json:"trigger" gorm:"size:16;not null;index"`
Status string `json:"status" gorm:"size:24;not null;index"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_spec_ai_parse_run_active"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
BatchLimit int `json:"batchLimit" gorm:"not null;default:20"`
ScannedCount int `json:"scannedCount" gorm:"not null;default:0"`
EligibleCount int `json:"eligibleCount" gorm:"not null;default:0"`
ProcessedCount int `json:"processedCount" gorm:"not null;default:0"`
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
FailedCount int `json:"failedCount" gorm:"not null;default:0"`
ErrorSummary string `json:"errorSummary,omitempty" gorm:"size:500;not null;default:''"`
StartedAt time.Time `json:"startedAt" gorm:"not null"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SYBSpecAIParseRun) TableName() string { return "syb_spec_ai_parse_run" }
// SYBSpecAIParseWorkItem prevents unchanged ambiguous input from repeatedly
// spending provider calls and owns the per-row recovery lease.
type SYBSpecAIParseWorkItem struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
SYBProductID uint64 `json:"sybProductId" gorm:"not null;uniqueIndex:ux_syb_spec_ai_parse_work_product"`
RunID *uint64 `json:"runId,omitempty" gorm:"index"`
InputFingerprint string `json:"inputFingerprint" gorm:"size:64;not null;default:'';index"`
Status string `json:"status" gorm:"size:24;not null;index"`
AttemptCount int `json:"attemptCount" gorm:"not null;default:0"`
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty" gorm:"index"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
LastErrorCode string `json:"lastErrorCode,omitempty" gorm:"size:64;not null;default:''"`
LastError string `json:"lastError,omitempty" gorm:"size:500;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SYBSpecAIParseWorkItem) TableName() string { return "syb_spec_ai_parse_work_item" }
@@ -32,10 +32,41 @@ type skuCombinationRow struct {
}
func sybSpecsTrusted(syb models.SYBProduct) bool {
if syb.ParseStatus == models.SYBParseStatusFailed {
return syb.ParseStatus == models.SYBParseStatusSuccess || syb.ManuallyConfirmed || syb.AIConfirmed
}
func aiConfirmedSpecsCurrent(syb models.SYBProduct, shopee models.ShopeeProduct) bool {
if !syb.AIConfirmed {
return true
}
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
if err != nil {
return false
}
return strings.TrimSpace(syb.TargetColor) != "" || strings.TrimSpace(syb.TargetSize) != ""
wanted := map[string]string{shopeeproduct.RoleColor: strings.TrimSpace(syb.TargetColor), shopeeproduct.RoleSize: strings.TrimSpace(syb.TargetSize)}
foundAny := false
for role, target := range wanted {
if target == "" {
continue
}
foundAny = true
found := false
for _, dimension := range specs {
if dimension.Role != role {
continue
}
for _, value := range dimension.Values {
if value.Name == target {
found = true
break
}
}
}
if !found {
return false
}
}
return foundAny
}
func (s *Service) loadLatestSKUCombinations(ctx context.Context, pddIDs []uint64, dataset *batchPreviewDataset) error {
@@ -143,10 +174,7 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
if request.TargetSize != "" {
size = matched.MappedSize
}
// 人工录入或外部导入的 PDD 档案可以没有采集任务 SKU 证据。没有
// 证据时只确认候选值;一旦有证据,仍必须命中同一个可售组合。
combinations := dataset.skuCombinationsByPDD[pdd.ID]
if len(combinations) == 0 || validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, color, size) {
if validSKUCombination(dataset.skuCombinationsByPDD[pdd.ID], syb.TargetColor, syb.TargetSize, color, size) {
return aiMatchQualification{Eligible: true, Request: request, MappedColor: mappedColor, MappedSize: mappedSize, Deterministic: &matched}
}
}
+12 -4
View File
@@ -406,6 +406,10 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee"
return item
}
if !aiConfirmedSpecsCurrent(syb, shopee) {
item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "AI 解析依据已变化,请等待重新解析或人工修正", "reparse"
return item
}
if shopee.PDDProductID == nil {
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_LINKED", "尚未关联 PDD 商品,请先关联", processActionOpenPDDLink
return item
@@ -479,9 +483,8 @@ 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 when collection
// evidence exists. Manually entered or externally imported PDD products may
// legitimately have no such collection record.
// still identify one complete, available SKU combination from the latest
// successful collection.
func (item *BatchPreviewItem) enforcePersistedMatch(id uint64, dataset batchPreviewDataset) {
if item.ReasonCode != "" && item.ReasonCode != CodeMappingRequired {
return
@@ -506,7 +509,12 @@ func (item *BatchPreviewItem) enforcePersistedMatch(id uint64, dataset batchPrev
return
}
combinations := dataset.skuCombinationsByPDD[pdd.ID]
if len(combinations) > 0 && !validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
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) {
item.Eligible = false
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "已保存规格映射不属于当前可售的 PDD 规格组合,请重新匹配", "open_mapping"
}
@@ -50,6 +50,11 @@ 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}
@@ -82,11 +87,9 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
}
}
item.Source, item.Confidence = matched.Source, matched.Decision.Confidence
// #200:在 SYB 批量入口,AI 只要返回了可保存的规格结果,就由后续的
// 候选与可售 SKU 组合校验决定是否放行;置信度仅保留为审计信息。
autoConfirm := qualification.Deterministic != nil || (matched.Source == aimatching.SourceAI && strings.TrimSpace(matched.Decision.Reason) != "")
autoConfirm := qualification.Deterministic != nil || (matched.Source == aimatching.SourceAI && matched.Decision.Confidence != nil && *matched.Decision.Confidence >= settings.AutoConfirmMinConfidence && strings.TrimSpace(matched.Decision.Reason) != "")
if !autoConfirm {
item.Status, item.Reason = BatchSpecMatchPending, "AI 未返回可用规格结果,请人工确认"
item.Status, item.Reason = BatchSpecMatchPending, "匹配结果未达到自动确认阈值,请人工确认"
if strings.TrimSpace(matched.Decision.Reason) != "" {
item.Reason += ":" + strings.TrimSpace(matched.Decision.Reason)
}
@@ -101,8 +104,7 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
if request.TargetSize != "" {
mappedSize = matched.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)) {
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) {
item.Status, item.Reason = BatchSpecMatchPending, "AI 结果不是当前可售的 PDD 规格组合,请人工确认"
response.PendingCount++
response.Items = append(response.Items, item)
@@ -115,7 +117,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(), writes); err != nil {
if _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopee.ID, uuid.NewString(), settings.AutoConfirmMinConfidence, writes); err != nil {
item.Reason = "规格映射保存失败,请刷新后重试"
response.FailedCount++
response.Items = append(response.Items, item)
@@ -175,10 +175,10 @@ func TestBatchSpecMatchPersistsExactMatchBeforePurchaseCreation(t *testing.T) {
}
}
func TestExtractedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T) {
func TestManuallyConfirmedUncertainSpecsCanBeMatchedBeforePurchase(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": false,
"parse_status": models.SYBParseStatusUncertain, "manually_confirmed": true,
}).Error; err != nil {
t.Fatal(err)
}
@@ -187,15 +187,15 @@ func TestExtractedUncertainSpecsCanBeMatchedBeforePurchase(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("extracted uncertain specs did not enter matching: %+v err=%v", before, err)
t.Fatalf("manually confirmed 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("extracted uncertain specs did not allow exact match: %+v calls=%d err=%v", matched, matcher.calls, err)
t.Fatalf("manual correction 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 extracted uncertain specs: %+v err=%v", after, err)
t.Fatalf("saved mapping did not unlock manual correction: %+v err=%v", after, err)
}
}
@@ -265,38 +265,21 @@ func TestBatchPreviewAllowsUncertainParseAndMissingSKUCombination(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) {
func TestBatchSpecMatchLeavesLowConfidenceForManualHandling(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 != 0 || response.AutoConfirmedCount != 1 {
t.Fatalf("low-confidence AI result was not auto-confirmed: %+v err=%v", response, err)
if err != nil || response.PendingCount != 1 || response.AutoConfirmedCount != 0 {
t.Fatalf("unexpected low-confidence result: %+v err=%v", response, err)
}
mapping := savedColorMapping(t, service, f.shopee.ID)
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)
if mapping == nil || mapping.PDDValue != "旧白色" || mapping.Status != shopeeproduct.MappingStatusConfirmed {
t.Fatalf("low-confidence result changed the saved mapping: %+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 != ProcessStagePurchaseReady {
t.Fatalf("saved AI result did not become purchase-ready: %+v err=%v", preview, err)
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)
}
}
+18 -17
View File
@@ -44,24 +44,25 @@ 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},
func TestSYBSpecsTrustedAcceptsAIWithoutCallingItManual(t *testing.T) {
row := models.SYBProduct{ParseStatus: models.SYBParseStatusUncertain, AIConfirmed: true}
if !sybSpecsTrusted(row) {
t.Fatal("a valid AI-confirmed parse must pass the purchase parse gate")
}
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)
}
})
if row.ManuallyConfirmed {
t.Fatal("AI confirmation must not be represented as human confirmation")
}
}
func TestAIConfirmedSpecsBecomeUntrustedWhenShopeeCandidateDisappears(t *testing.T) {
row := models.SYBProduct{TargetColor: "黑色", TargetSize: "XL", ParseStatus: models.SYBParseStatusUncertain, AIConfirmed: true}
product := models.ShopeeProduct{SpecsJSON: `[{"name":"颜色","role":"color","values":[{"name":"白色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`}
if aiConfirmedSpecsCurrent(row, product) {
t.Fatal("removed Shopee candidate must invalidate the AI parse gate")
}
product.SpecsJSON = `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`
if !aiConfirmedSpecsCurrent(row, product) {
t.Fatal("unchanged exact Shopee candidates should keep AI parse valid")
}
}
+1 -1
View File
@@ -137,7 +137,7 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
if !ok {
return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh")
}
if syb.ParseStatus == models.SYBParseStatusFailed {
if syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed {
return stage(ProcessStageManualAction, "解析失败,请先处理", "reparse")
}
if syb.ShopeeProductID == nil {
@@ -48,6 +48,9 @@ type autoMatchRef struct {
// 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) {
ctx, cancel := context.WithTimeout(ctx, aimatching.MaxProviderTimeout)
defer cancel()
requestID := strings.TrimSpace(request.RequestID)
if _, err := uuid.Parse(requestID); err != nil {
return AutoMatchResponse{}, invalidRequest("requestId 必须是 UUID")
@@ -0,0 +1,360 @@
package shopeeproduct
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"go-admin/app/goauto/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
SpecAutoMatchInvokeTarget = "GoAutoShopeeSpecAutoMatch"
defaultAutoMatchBatchLimit = 20
autoMatchLeaseDuration = 30 * time.Minute
autoMatchRetryDelay = time.Hour
maxAutoMatchAttempts = 3
)
type AutoMatchRunView struct {
models.ShopeeSpecAutoMatchRun
AlreadyRunning bool `json:"alreadyRunning,omitempty"`
Replayed bool `json:"replayed,omitempty"`
}
// StartAutoMatchRun acquires the single database-backed activity slot. A
// repeated requestId is idempotent; a concurrent trigger receives the current
// run instead of starting a second batch.
func (service *Service) StartAutoMatchRun(ctx context.Context, trigger, requestID string, requestedBy *uint64, batchLimit int) (AutoMatchRunView, bool, error) {
if _, err := uuid.Parse(strings.TrimSpace(requestID)); err != nil {
return AutoMatchRunView{}, false, invalidRequest("requestId 必须是 UUID")
}
if trigger != "manual" && trigger != "scheduled" {
return AutoMatchRunView{}, false, invalidRequest("trigger 无效")
}
if batchLimit <= 0 {
batchLimit = defaultAutoMatchBatchLimit
}
if batchLimit > 100 {
return AutoMatchRunView{}, false, invalidRequest("batchLimit 不能超过 100")
}
now := time.Now().UTC()
lease := now.Add(autoMatchLeaseDuration)
owner := uuid.NewString()
one := uint8(1)
var result models.ShopeeSpecAutoMatchRun
created := false
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.ShopeeSpecAutoMatchRun{}).
Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now).
Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now}).Error; err != nil {
return err
}
if err := tx.Where("request_id = ?", requestID).First(&result).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := tx.Where("status = ? AND active_slot = ?", "running", 1).First(&result).Error; err == nil {
result.ActiveSlot = &one
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
result = models.ShopeeSpecAutoMatchRun{RequestID: requestID, Trigger: trigger, Status: "running", ActiveSlot: &one, LeaseOwner: owner, LeaseExpiresAt: &lease, RequestedBy: requestedBy, BatchLimit: batchLimit, StartedAt: now}
if err := tx.Create(&result).Error; err != nil {
return err
}
created = true
return nil
})
if err != nil {
// A unique-slot race means another instance won after our read. Return
// its run as the stable, non-error result.
if findErr := service.DB.WithContext(ctx).Where("status = ? AND active_slot = ?", "running", 1).First(&result).Error; findErr == nil {
return AutoMatchRunView{ShopeeSpecAutoMatchRun: result, AlreadyRunning: true}, false, nil
}
return AutoMatchRunView{}, false, internalError(err)
}
view := AutoMatchRunView{ShopeeSpecAutoMatchRun: result}
if !created {
view.AlreadyRunning = result.RequestID != requestID
view.Replayed = result.RequestID == requestID
}
return view, created, nil
}
func (service *Service) LatestAutoMatchRun(ctx context.Context) (*AutoMatchRunView, error) {
var run models.ShopeeSpecAutoMatchRun
err := service.DB.WithContext(ctx).Order("id DESC").First(&run).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, internalError(err)
}
return &AutoMatchRunView{ShopeeSpecAutoMatchRun: run}, nil
}
// ProcessAutoMatchRun performs a bounded batch. It is safe to call from an
// HTTP-launched goroutine or the scheduler because only the run owning the
// active slot may update and finish itself.
func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) error {
var run models.ShopeeSpecAutoMatchRun
if err := service.DB.WithContext(ctx).First(&run, runID).Error; err != nil {
return err
}
if run.Status != "running" || run.ActiveSlot == nil || *run.ActiveSlot != 1 {
return nil
}
limit := run.BatchLimit
if limit <= 0 || limit > 100 {
limit = defaultAutoMatchBatchLimit
}
var candidates []models.ShopeeProduct
queryLimit := limit * 25
if queryLimit < 100 {
queryLimit = 100
}
if queryLimit > 1000 {
queryLimit = 1000
}
if err := service.DB.WithContext(ctx).
Joins("JOIN pdd_product ON pdd_product.id = shopee_product.pdd_product_id AND pdd_product.status = ?", "active").
Where("shopee_product.pdd_product_id IS NOT NULL").
Order("shopee_product.updated_at ASC, shopee_product.id ASC").Limit(queryLimit).Find(&candidates).Error; err != nil {
service.finishAutoMatchRun(run, "failed", 0, 0, 0, 0, 0, 1, "扫描符合条件的商品失败")
return err
}
eligible, processed, confirmed, unmatched, failed := 0, 0, 0, 0, 0
firstError := ""
for _, product := range candidates {
if processed >= limit {
break
}
fingerprint, ok, err := service.autoMatchEligibility(ctx, product)
if err != nil {
failed++
if firstError == "" {
firstError = safeBatchError(err)
}
continue
}
if !ok {
continue
}
eligible++
work, claimed, err := service.claimAutoMatchWork(ctx, run, product.ID, fingerprint)
if err != nil {
failed++
if firstError == "" {
firstError = safeBatchError(err)
}
continue
}
if !claimed {
continue
}
processed++
service.renewAutoMatchRun(run)
response, matchErr := service.AutoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]})
// fingerprint begins with the 64-character context version.
postFingerprint := fingerprint
if next, _, nextErr := service.autoMatchEligibility(ctx, product); nextErr == nil && next != "" {
postFingerprint = next
}
if matchErr != nil {
failed++
if firstError == "" {
firstError = safeBatchError(matchErr)
}
service.completeAutoMatchWork(work, postFingerprint, 0, 0, matchErr)
continue
}
confirmed += response.ConfirmedCount
unmatched += response.UnmatchedCount
service.completeAutoMatchWork(work, postFingerprint, response.ConfirmedCount, response.UnmatchedCount, nil)
}
status := "completed"
if failed > 0 {
status = "completed_partial"
}
return service.finishAutoMatchRun(run, status, len(candidates), eligible, processed, confirmed, unmatched, failed, firstError)
}
func (service *Service) autoMatchEligibility(ctx context.Context, product models.ShopeeProduct) (string, bool, error) {
if product.PDDProductID == nil {
return "", false, nil
}
var pdd models.PDDProduct
if err := service.DB.WithContext(ctx).First(&pdd, *product.PDDProductID).Error; err != nil {
return "", false, err
}
if pdd.Status != "active" {
return "", false, nil
}
shopeeSpecs, err := Unmarshal(product.SpecsJSON)
if err != nil {
return "", false, err
}
shared, needsMatch := false, false
for _, role := range []string{RoleColor, RoleSize} {
pddValues, err := selectablePDDValues(pdd.SpecsJSON, role)
if err != nil {
return "", false, err
}
if len(pddValues) == 0 {
continue
}
for _, dimension := range shopeeSpecs {
if dimension.Role != role || len(dimension.Values) == 0 {
continue
}
shared = true
for _, value := range dimension.Values {
if value.Mapping == nil || value.Mapping.Status != MappingStatusConfirmed || !pddValues[value.Mapping.PDDValue] {
needsMatch = true
}
}
}
}
if !shared || !needsMatch {
return "", false, nil
}
contextVersion := computeSpecContextVersion(product.PDDProductID, product.SpecsJSON, pdd.SpecsJSON)
var setting struct{ UpdatedAt time.Time }
_ = service.DB.WithContext(ctx).Table((models.AIMatchingSetting{}).TableName()).Select("updated_at").Where("id = ?", 1).Scan(&setting).Error
h := sha256.Sum256([]byte(contextVersion + "\x00" + setting.UpdatedAt.UTC().Format(time.RFC3339Nano)))
// Keeping the context version as a prefix lets ProcessAutoMatchRun pass the
// exact version to #194 without re-reading a potentially drifting input.
return contextVersion + hex.EncodeToString(h[:]), true, nil
}
func (service *Service) claimAutoMatchWork(ctx context.Context, run models.ShopeeSpecAutoMatchRun, productID uint64, fingerprint string) (models.ShopeeSpecAutoMatchWorkItem, bool, error) {
now := time.Now().UTC()
lease := now.Add(autoMatchLeaseDuration)
var work models.ShopeeSpecAutoMatchWorkItem
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Where("shopee_product_id = ?", productID).First(&work).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
work = models.ShopeeSpecAutoMatchWorkItem{ShopeeProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease}
return tx.Create(&work).Error
}
if err != nil {
return err
}
if work.InputFingerprint == fingerprint {
if work.Status == "completed" || work.Status == "unmatched" || work.AttemptCount >= maxAutoMatchAttempts || (work.NextAttemptAt != nil && work.NextAttemptAt.After(now)) || (work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now)) {
return errWorkNotClaimed
}
} else {
work.AttemptCount = 0
}
updates := map[string]any{"run_id": run.ID, "input_fingerprint": fingerprint, "status": "running", "attempt_count": work.AttemptCount + 1, "next_attempt_at": nil, "lease_owner": run.LeaseOwner, "lease_expires_at": lease, "last_error_code": "", "last_error": ""}
if err := tx.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error; err != nil {
return err
}
return tx.First(&work, work.ID).Error
})
if errors.Is(err, errWorkNotClaimed) {
return work, false, nil
}
return work, err == nil, err
}
var errWorkNotClaimed = errors.New("auto match work not claimed")
func (service *Service) completeAutoMatchWork(work models.ShopeeSpecAutoMatchWorkItem, fingerprint string, confirmed, unmatched int, matchErr error) {
now := time.Now().UTC()
updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil, "confirmed_count": confirmed, "unmatched_count": unmatched}
if matchErr == nil {
if unmatched > 0 {
updates["status"] = "unmatched"
} else {
updates["status"] = "completed"
}
updates["next_attempt_at"], updates["last_error_code"], updates["last_error"] = nil, "", ""
} else {
code := batchErrorCode(matchErr)
updates["status"], updates["last_error_code"], updates["last_error"] = "failed", code, safeBatchError(matchErr)
if code == CodeAIUnavailable && work.AttemptCount < maxAutoMatchAttempts {
next := now.Add(autoMatchRetryDelay)
updates["next_attempt_at"] = next
} else {
updates["next_attempt_at"] = nil
}
}
_ = service.DB.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error
}
func (service *Service) renewAutoMatchRun(run models.ShopeeSpecAutoMatchRun) {
lease := time.Now().UTC().Add(autoMatchLeaseDuration)
_ = service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error
}
func (service *Service) finishAutoMatchRun(run models.ShopeeSpecAutoMatchRun, status string, scanned, eligible, processed, confirmed, unmatched, failed int, summary string) error {
now := time.Now().UTC()
updates := map[string]any{"status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "scanned_count": scanned, "eligible_count": eligible, "processed_count": processed, "confirmed_count": confirmed, "unmatched_count": unmatched, "failed_count": failed, "error_summary": truncateBatchText(summary), "finished_at": now}
return service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(updates).Error
}
func batchErrorCode(err error) string {
var serviceErr *ServiceError
if errors.As(err, &serviceErr) {
return serviceErr.Code
}
return CodeInternal
}
func safeBatchError(err error) string {
var serviceErr *ServiceError
if errors.As(err, &serviceErr) {
return truncateBatchText(serviceErr.Message)
}
return "服务端处理失败"
}
func truncateBatchText(value string) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) > 500 {
runes = runes[:500]
}
return string(runes)
}
type scheduledAutoMatchArgs struct {
BatchLimit int `json:"batchLimit"`
}
type ScheduledAutoMatchJob struct{}
func (ScheduledAutoMatchJob) Exec(_ interface{}) error {
return errors.New("规格自动匹配定时任务缺少数据库连接")
}
func (ScheduledAutoMatchJob) ExecWithDB(db *gorm.DB, arg interface{}) error {
args := scheduledAutoMatchArgs{BatchLimit: defaultAutoMatchBatchLimit}
if raw, ok := arg.(string); ok && strings.TrimSpace(raw) != "" {
if err := json.Unmarshal([]byte(raw), &args); err != nil {
return fmt.Errorf("规格自动匹配参数不是合法 JSON: %w", err)
}
}
if args.BatchLimit < 1 || args.BatchLimit > 100 {
return errors.New("规格自动匹配 batchLimit 必须在 1 到 100 之间")
}
service := NewService(db)
run, created, err := service.StartAutoMatchRun(context.Background(), "scheduled", uuid.NewString(), nil, args.BatchLimit)
if err != nil || !created {
return err
}
return service.ProcessAutoMatchRun(context.Background(), run.ID)
}
@@ -0,0 +1,86 @@
package shopeeproduct
import (
"context"
"testing"
"go-admin/app/goauto/models"
"github.com/google/uuid"
)
func TestAutoMatchRunIsIdempotentAndGloballySerialized(t *testing.T) {
db := openTestDB(t)
service := NewService(db)
requestID := uuid.NewString()
first, created, err := service.StartAutoMatchRun(context.Background(), "manual", requestID, nil, 20)
if err != nil || !created {
t.Fatalf("first=%+v created=%v err=%v", first, created, err)
}
replay, created, err := service.StartAutoMatchRun(context.Background(), "manual", requestID, nil, 20)
if err != nil || created || !replay.Replayed || replay.ID != first.ID {
t.Fatalf("replay=%+v created=%v err=%v", replay, created, err)
}
concurrent, created, err := service.StartAutoMatchRun(context.Background(), "scheduled", uuid.NewString(), nil, 20)
if err != nil || created || !concurrent.AlreadyRunning || concurrent.ID != first.ID {
t.Fatalf("concurrent=%+v created=%v err=%v", concurrent, created, err)
}
}
func TestProcessAutoMatchRunConfirmsExactSizeAndFinishes(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
service := NewService(db)
createdProduct, err := service.Create(context.Background(), CreateRequest{
RequestID: uuid.NewString(), ShopeeItemID: "SP-BATCH-EXACT", PDDProductID: &pdd.ID,
Specs: []SpecDimension{{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: " xl ", Source: ValueSourceImport}}}},
})
if err != nil {
t.Fatal(err)
}
run, started, err := service.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
if err != nil || !started {
t.Fatalf("run=%+v started=%v err=%v", run, started, err)
}
if err := service.ProcessAutoMatchRun(context.Background(), run.ID); err != nil {
t.Fatal(err)
}
latest, err := service.LatestAutoMatchRun(context.Background())
if err != nil {
t.Fatal(err)
}
if latest == nil || latest.Status != "completed" || latest.ProcessedCount != 1 || latest.ConfirmedCount != 1 || latest.ActiveSlot != nil {
t.Fatalf("latest=%+v", latest)
}
detail, err := service.Detail(context.Background(), createdProduct.Product.ID)
if err != nil {
t.Fatal(err)
}
mapping := detail.Product.Specs[0].Values[0].Mapping
if mapping == nil || mapping.Status != MappingStatusConfirmed || mapping.PDDValue != "XL" {
t.Fatalf("mapping=%+v", mapping)
}
}
func TestUnchangedUnmatchedWorkIsNotClaimedAgain(t *testing.T) {
db := openTestDB(t)
service := NewService(db)
one := uint8(1)
run := models.ShopeeSpecAutoMatchRun{RequestID: uuid.NewString(), Trigger: "manual", Status: "running", ActiveSlot: &one, LeaseOwner: uuid.NewString(), BatchLimit: 20}
if err := db.Create(&run).Error; err != nil {
t.Fatal(err)
}
work, claimed, err := service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint")
if err != nil || !claimed {
t.Fatalf("work=%+v claimed=%v err=%v", work, claimed, err)
}
service.completeAutoMatchWork(work, "fingerprint", 0, 1, nil)
_, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint")
if err != nil || claimed {
t.Fatalf("unchanged unmatched claimed=%v err=%v", claimed, err)
}
_, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "changed")
if err != nil || !claimed {
t.Fatalf("changed input claimed=%v err=%v", claimed, err)
}
}
@@ -5,11 +5,13 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
@@ -116,6 +118,20 @@ func TestAutoMatchMappingsProviderFailureDoesNotWrite(t *testing.T) {
}
}
func TestWriteErrorMapsAIUnavailableToStructured503(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
writeError(context, aiUnavailable("AI 匹配服务暂时不可用,请稍后重试"))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), `"code":"AI_MATCHING_UNAVAILABLE"`) {
t.Fatalf("response is not structured: %s", recorder.Body.String())
}
}
func TestAutoMatchMappingsRejectsContextDriftBeforeAtomicWrite(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
@@ -7,8 +7,7 @@ import (
// ResolvedMappingItem is one mapping produced by the narrowly scoped SYB
// batch-match entry point. Confirmed writes are restricted to auditable exact
// matches and AI decisions with a returned match reason; SetMapping remains
// pending-first.
// matches and high-confidence AI decisions; SetMapping remains pending-first.
type ResolvedMappingItem struct {
Dimension string
ValueName string
@@ -21,11 +20,14 @@ 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
// confirmed-match exception cannot change existing callers' pending semantics.
func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, requestID string, items []ResolvedMappingItem) (SaveResponse, error) {
// 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) {
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)
@@ -39,6 +41,9 @@ 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
}
@@ -1,6 +1,7 @@
package shopeeproduct
import (
"context"
"encoding/json"
"errors"
"io"
@@ -300,6 +301,45 @@ func (handler Handler) AutoMatchMappings(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
}
func (handler Handler) StartAutoMatchRun(c *gin.Context) {
var request struct {
RequestID string `json:"requestId"`
}
if err := decodeJSON(c, &request); err != nil {
writeError(c, invalidRequest("请求 JSON 无效"))
return
}
service, ok := handler.service(c)
if !ok {
return
}
operator := currentUserID(c)
run, created, err := service.StartAutoMatchRun(c.Request.Context(), "manual", request.RequestID, &operator, defaultAutoMatchBatchLimit)
if err != nil {
writeError(c, err)
return
}
if created {
go func(runID uint64, db *gorm.DB) {
_ = NewService(db).ProcessAutoMatchRun(context.Background(), runID)
}(run.ID, service.DB)
}
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"run": run}})
}
func (handler Handler) LatestAutoMatchRun(c *gin.Context) {
service, ok := handler.service(c)
if !ok {
return
}
run, err := service.LatestAutoMatchRun(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"run": run}})
}
func (handler Handler) BatchDelete(c *gin.Context) {
var request BatchDeleteRequest
if err := decodeJSON(c, &request); err != nil {
@@ -10,6 +10,9 @@ import (
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
handler := Handler{}
admin := engine.Group("/api/admin/v1/shopee-products").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
adminOnlyRuns := engine.Group("/api/admin/v1/shopee-spec-auto-match/runs").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(middleware.RequireRoleKey("admin"))
adminOnlyRuns.POST("", handler.StartAutoMatchRun)
adminOnlyRuns.GET("/latest", handler.LatestAutoMatchRun)
admin.GET("", handler.List)
admin.POST("", handler.Create)
admin.POST("/batch-delete", handler.BatchDelete)
@@ -0,0 +1,489 @@
package sybimport
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
SpecAIParseInvokeTarget = "GoAutoSYBSpecAIParse"
defaultAIParseBatchLimit = 20
aiParseLeaseDuration = 30 * time.Minute
aiParseRetryDelay = time.Hour
maxAIParseAttempts = 3
)
var (
errAIParseWorkNotClaimed = errors.New("syb spec ai parse work not claimed")
errAIParseInputChanged = errors.New("syb spec ai parse input changed")
)
type aiParseInput struct {
ProductSpec string
Colors []string
Sizes []string
Fingerprint string
}
func StartSpecAIParseRun(ctx context.Context, db *gorm.DB, requestID string, batchLimit int) (models.SYBSpecAIParseRun, bool, error) {
if _, err := uuid.Parse(strings.TrimSpace(requestID)); err != nil {
return models.SYBSpecAIParseRun{}, false, fmt.Errorf("requestId 必须是 UUID")
}
if batchLimit <= 0 {
batchLimit = defaultAIParseBatchLimit
}
if batchLimit > 100 {
return models.SYBSpecAIParseRun{}, false, fmt.Errorf("batchLimit 不能超过 100")
}
now := time.Now().UTC()
lease := now.Add(aiParseLeaseDuration)
one := uint8(1)
owner := uuid.NewString()
var run models.SYBSpecAIParseRun
created := false
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.SYBSpecAIParseRun{}).
Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now).
Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now}).Error; err != nil {
return err
}
if err := tx.Where("request_id = ?", requestID).First(&run).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := tx.Where("status = ? AND active_slot = ?", "running", 1).First(&run).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
run = models.SYBSpecAIParseRun{
RequestID: requestID, Trigger: "scheduled", Status: "running", ActiveSlot: &one,
LeaseOwner: owner, LeaseExpiresAt: &lease, BatchLimit: batchLimit, StartedAt: now,
}
if err := tx.Create(&run).Error; err != nil {
return err
}
created = true
return nil
})
if err != nil {
if findErr := db.WithContext(ctx).Where("status = ? AND active_slot = ?", "running", 1).First(&run).Error; findErr == nil {
return run, false, nil
}
return models.SYBSpecAIParseRun{}, false, err
}
return run, created, nil
}
func ProcessSpecAIParseRun(ctx context.Context, db *gorm.DB, runID uint64) error {
var run models.SYBSpecAIParseRun
if err := db.WithContext(ctx).First(&run, runID).Error; err != nil {
return err
}
if run.Status != "running" || run.ActiveSlot == nil || *run.ActiveSlot != 1 {
return nil
}
limit := run.BatchLimit
if limit <= 0 || limit > 100 {
limit = defaultAIParseBatchLimit
}
queryLimit := limit * 25
if queryLimit < 100 {
queryLimit = 100
}
if queryLimit > 1000 {
queryLimit = 1000
}
var candidates []models.SYBProduct
if err := db.WithContext(ctx).
Where("manually_confirmed = ?", false).
Where("parse_status IN ?", []string{models.SYBParseStatusUncertain, models.SYBParseStatusFailed}).
Order("updated_at ASC, id ASC").Limit(queryLimit).Find(&candidates).Error; err != nil {
finishSpecAIParseRun(db, run, "failed", 0, 0, 0, 0, 0, 1, "扫描异常规格失败")
return err
}
eligible, processed, confirmed, unmatched, failed := 0, 0, 0, 0, 0
firstError := ""
for _, candidate := range candidates {
if processed >= limit {
break
}
if candidate.AIConfirmed {
current, currentErr := aiConfirmationTargetsCurrent(ctx, db, candidate)
if currentErr != nil {
failed++
continue
}
if current {
continue
}
if err := db.WithContext(ctx).Model(&models.SYBProduct{}).
Where("id = ? AND manually_confirmed = ?", candidate.ID, false).
Updates(map[string]any{"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "", "ai_confirmed_at": nil, "ai_input_fingerprint": ""}).Error; err != nil {
failed++
continue
}
candidate.AIConfirmed = false
}
outcome, err := Reparse(ctx, db, candidate.ID, false)
if err != nil {
failed++
if firstError == "" {
firstError = "确定性重新解析失败"
}
continue
}
if outcome.NewStatus == models.SYBParseStatusSuccess {
processed++
confirmed++
continue
}
if err := db.WithContext(ctx).First(&candidate, candidate.ID).Error; err != nil {
failed++
continue
}
input, ok, err := buildAIParseInput(ctx, db, candidate)
if err != nil {
failed++
if firstError == "" {
firstError = "读取 AI 解析上下文失败"
}
continue
}
if !ok {
continue
}
eligible++
work, claimed, err := claimSpecAIParseWork(ctx, db, run, candidate.ID, input.Fingerprint)
if err != nil {
failed++
continue
}
if !claimed {
continue
}
processed++
renewSpecAIParseRun(db, run)
matcher := aimatching.NewService(db)
result, matchErr := matcher.ResolveSYBSpec(ctx, aimatching.SYBSpecParseRequest{
ProductSpec: input.ProductSpec, Colors: input.Colors, Sizes: input.Sizes,
})
if matchErr == nil {
settings, settingsErr := matcher.Settings(ctx)
if settingsErr != nil {
matchErr = settingsErr
} else if result.Confidence == nil || *result.Confidence < settings.AutoConfirmMinConfidence || strings.TrimSpace(result.Reason) == "" {
unmatched++
completeSpecAIParseWork(db, work, input.Fingerprint, false, nil)
continue
} else {
matchErr = applyAIParseResult(ctx, db, candidate.ID, input.Fingerprint, result)
}
}
if matchErr != nil {
if isNoAIParseMatch(matchErr) || errors.Is(matchErr, errAIParseInputChanged) {
unmatched++
completeSpecAIParseWork(db, work, input.Fingerprint, false, nil)
continue
}
failed++
if firstError == "" {
firstError = safeAIParseError(matchErr)
}
completeSpecAIParseWork(db, work, input.Fingerprint, false, matchErr)
continue
}
confirmed++
completeSpecAIParseWork(db, work, input.Fingerprint, true, nil)
}
status := "completed"
if failed > 0 {
status = "completed_partial"
}
return finishSpecAIParseRun(db, run, status, len(candidates), eligible, processed, confirmed, unmatched, failed, firstError)
}
func buildAIParseInput(ctx context.Context, db *gorm.DB, record models.SYBProduct) (aiParseInput, bool, error) {
if record.ManuallyConfirmed || (record.ParseStatus != models.SYBParseStatusUncertain && record.ParseStatus != models.SYBParseStatusFailed) || record.ShopeeProductID == nil {
return aiParseInput{}, false, nil
}
var raw rawDetailSpec
if err := json.Unmarshal([]byte(record.RawJSON), &raw); err != nil {
return aiParseInput{}, false, err
}
raw.ProductSpec = strings.TrimSpace(raw.ProductSpec)
if raw.ProductSpec == "" {
return aiParseInput{}, false, nil
}
var product models.ShopeeProduct
if err := db.WithContext(ctx).First(&product, *record.ShopeeProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return aiParseInput{}, false, nil
}
return aiParseInput{}, false, err
}
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
return aiParseInput{}, false, err
}
colors, sizes, ambiguous := closedShopeeCandidates(specs)
if ambiguous || (len(colors) == 0 && len(sizes) == 0) {
return aiParseInput{}, false, nil
}
var setting models.AIMatchingSetting
if err := db.WithContext(ctx).First(&setting, 1).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return aiParseInput{}, false, nil
}
return aiParseInput{}, false, err
}
if !setting.Enabled || strings.TrimSpace(setting.APIKey) == "" {
return aiParseInput{}, false, nil
}
fingerprintPayload := struct {
ProductSpec string
ShopeeProductID uint64
ShopeeSpecsJSON string
SettingUpdatedAt string
}{raw.ProductSpec, product.ID, product.SpecsJSON, setting.UpdatedAt.UTC().Format(time.RFC3339Nano)}
encoded, _ := json.Marshal(fingerprintPayload)
hash := sha256.Sum256(encoded)
return aiParseInput{ProductSpec: raw.ProductSpec, Colors: colors, Sizes: sizes, Fingerprint: hex.EncodeToString(hash[:])}, true, nil
}
func aiConfirmationTargetsCurrent(ctx context.Context, db *gorm.DB, record models.SYBProduct) (bool, error) {
if !record.AIConfirmed || record.ShopeeProductID == nil {
return false, nil
}
var product models.ShopeeProduct
if err := db.WithContext(ctx).First(&product, *record.ShopeeProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
return false, err
}
colors, sizes, ambiguous := closedShopeeCandidates(specs)
if ambiguous {
return false, nil
}
return closedCandidateContains(record.TargetColor, colors) && closedCandidateContains(record.TargetSize, sizes) &&
(strings.TrimSpace(record.TargetColor) != "" || strings.TrimSpace(record.TargetSize) != ""), nil
}
func closedCandidateContains(value string, candidates []string) bool {
value = strings.TrimSpace(value)
if len(candidates) == 0 {
return value == ""
}
for _, candidate := range candidates {
if candidate == value {
return true
}
}
return false
}
func closedShopeeCandidates(specs []shopeeproduct.SpecDimension) (colors, sizes []string, ambiguous bool) {
roleDimensions := map[string]int{}
for _, dimension := range specs {
if dimension.Role != shopeeproduct.RoleColor && dimension.Role != shopeeproduct.RoleSize {
continue
}
values := make([]string, 0, len(dimension.Values))
seen := map[string]bool{}
for _, value := range dimension.Values {
name := strings.TrimSpace(value.Name)
if name != "" && !seen[name] {
seen[name] = true
values = append(values, name)
}
}
if len(values) == 0 {
continue
}
roleDimensions[dimension.Role]++
if roleDimensions[dimension.Role] > 1 {
return nil, nil, true
}
if dimension.Role == shopeeproduct.RoleColor {
colors = values
} else {
sizes = values
}
}
return colors, sizes, false
}
func applyAIParseResult(ctx context.Context, db *gorm.DB, id uint64, fingerprint string, result aimatching.SYBSpecParseResult) error {
now := time.Now().UTC()
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var record models.SYBProduct
if err := tx.First(&record, id).Error; err != nil {
return err
}
input, ok, err := buildAIParseInput(ctx, tx, record)
if err != nil {
return err
}
if !ok || input.Fingerprint != fingerprint {
return errAIParseInputChanged
}
if result.Confidence == nil || strings.TrimSpace(result.Reason) == "" {
return errAIParseInputChanged
}
write := tx.Model(&models.SYBProduct{}).Where("id = ? AND manually_confirmed = ? AND ai_confirmed = ?", id, false, false).Updates(map[string]any{
"target_color": result.Color, "target_size": result.Size,
"ai_confirmed": true, "ai_confidence": *result.Confidence, "ai_reason": truncateAIParseText(result.Reason),
"ai_confirmed_at": now, "ai_input_fingerprint": fingerprint,
})
if write.Error != nil {
return write.Error
}
if write.RowsAffected != 1 {
return errAIParseInputChanged
}
return mergeParsedSpec(tx, *record.ShopeeProductID, ParseResult{Color: result.Color, Size: result.Size, Status: models.SYBParseStatusSuccess})
})
}
func claimSpecAIParseWork(ctx context.Context, db *gorm.DB, run models.SYBSpecAIParseRun, productID uint64, fingerprint string) (models.SYBSpecAIParseWorkItem, bool, error) {
now := time.Now().UTC()
lease := now.Add(aiParseLeaseDuration)
var work models.SYBSpecAIParseWorkItem
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Where("syb_product_id = ?", productID).First(&work).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
work = models.SYBSpecAIParseWorkItem{SYBProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease}
return tx.Create(&work).Error
}
if err != nil {
return err
}
if work.InputFingerprint == fingerprint {
if work.Status == "completed" || work.Status == "unmatched" || work.AttemptCount >= maxAIParseAttempts || (work.NextAttemptAt != nil && work.NextAttemptAt.After(now)) || (work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now)) {
return errAIParseWorkNotClaimed
}
} else {
work.AttemptCount = 0
}
updates := map[string]any{"run_id": run.ID, "input_fingerprint": fingerprint, "status": "running", "attempt_count": work.AttemptCount + 1, "next_attempt_at": nil, "lease_owner": run.LeaseOwner, "lease_expires_at": lease, "last_error_code": "", "last_error": ""}
if err := tx.Model(&models.SYBSpecAIParseWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error; err != nil {
return err
}
return tx.First(&work, work.ID).Error
})
if errors.Is(err, errAIParseWorkNotClaimed) {
return work, false, nil
}
return work, err == nil, err
}
func completeSpecAIParseWork(db *gorm.DB, work models.SYBSpecAIParseWorkItem, fingerprint string, confirmed bool, parseErr error) {
now := time.Now().UTC()
updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil}
if parseErr == nil {
if confirmed {
updates["status"] = "completed"
} else {
updates["status"] = "unmatched"
}
updates["next_attempt_at"], updates["last_error_code"], updates["last_error"] = nil, "", ""
} else {
code := aiParseErrorCode(parseErr)
updates["status"], updates["last_error_code"], updates["last_error"] = "failed", code, safeAIParseError(parseErr)
if code == aimatching.CodeProviderUnavailable && work.AttemptCount < maxAIParseAttempts {
next := now.Add(aiParseRetryDelay)
updates["next_attempt_at"] = next
} else {
updates["next_attempt_at"] = nil
}
}
_ = db.Model(&models.SYBSpecAIParseWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error
}
func renewSpecAIParseRun(db *gorm.DB, run models.SYBSpecAIParseRun) {
lease := time.Now().UTC().Add(aiParseLeaseDuration)
_ = db.Model(&models.SYBSpecAIParseRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error
}
func finishSpecAIParseRun(db *gorm.DB, run models.SYBSpecAIParseRun, status string, scanned, eligible, processed, confirmed, unmatched, failed int, summary string) error {
now := time.Now().UTC()
return db.Model(&models.SYBSpecAIParseRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(map[string]any{
"status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil,
"scanned_count": scanned, "eligible_count": eligible, "processed_count": processed,
"confirmed_count": confirmed, "unmatched_count": unmatched, "failed_count": failed,
"error_summary": truncateAIParseText(summary), "finished_at": now,
}).Error
}
func isNoAIParseMatch(err error) bool { return aiParseErrorCode(err) == aimatching.CodeNoMatch }
func aiParseErrorCode(err error) string {
var target *aimatching.Error
if errors.As(err, &target) {
return target.Code
}
return CodeInternal
}
func safeAIParseError(err error) string {
var target *aimatching.Error
if errors.As(err, &target) {
return truncateAIParseText(target.Message)
}
return "服务端处理失败"
}
func truncateAIParseText(value string) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) > 500 {
runes = runes[:500]
}
return string(runes)
}
type scheduledSpecAIParseArgs struct {
BatchLimit int `json:"batchLimit"`
}
type ScheduledSpecAIParseJob struct{}
func (ScheduledSpecAIParseJob) Exec(_ interface{}) error {
return errors.New("SYB 规格 AI 解析定时任务缺少数据库连接")
}
func (ScheduledSpecAIParseJob) ExecWithDB(db *gorm.DB, arg interface{}) error {
args := scheduledSpecAIParseArgs{BatchLimit: defaultAIParseBatchLimit}
if raw, ok := arg.(string); ok && strings.TrimSpace(raw) != "" {
if err := json.Unmarshal([]byte(raw), &args); err != nil {
return fmt.Errorf("SYB 规格 AI 解析参数不是合法 JSON: %w", err)
}
}
if args.BatchLimit < 1 || args.BatchLimit > 100 {
return errors.New("SYB 规格 AI 解析 batchLimit 必须在 1 到 100 之间")
}
run, created, err := StartSpecAIParseRun(context.Background(), db, uuid.NewString(), args.BatchLimit)
if err != nil || !created {
return err
}
return ProcessSpecAIParseRun(context.Background(), db, run.ID)
}
@@ -0,0 +1,204 @@
package sybimport_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"go-admin/app/goauto/sybimport"
"github.com/google/uuid"
"gorm.io/gorm"
)
func seedAIParseCandidate(t *testing.T, db *gorm.DB, serverURL string, detailID uint64, productSpec string) models.SYBProduct {
t.Helper()
detail := realDetailA()
detail.ID = detailID
detail.ProductSpec = productSpec
detail.Raw = []byte(fmt.Sprintf(`{"id":%d,"productSpec":%q}`, detail.ID, productSpec))
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), detail)
if err != nil {
t.Fatal(err)
}
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}, {Name: "白色", Source: shopeeproduct.ValueSourceImport}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "L", Source: shopeeproduct.ValueSourceImport}, {Name: "XL", Source: shopeeproduct.ValueSourceImport}}},
})
if err != nil {
t.Fatal(err)
}
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", *applied.SYBProduct.ShopeeProductID).Update("specs_json", specs).Error; err != nil {
t.Fatal(err)
}
setting := models.AIMatchingSetting{ID: 1, Enabled: true, Provider: "openai_compatible", BaseURL: serverURL, Model: "test-model", APIKey: "test-key", TimeoutSeconds: 5, AutoConfirmMinConfidence: 0.9}
if err := db.Save(&setting).Error; err != nil {
t.Fatal(err)
}
return applied.SYBProduct
}
func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write([]byte(`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"原文对应唯一候选\",\"confidence\":0.95}"}}]}`))
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑色 XL")
rawBefore := record.RawJSON
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start run: created=%v err=%v", created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if !record.AIConfirmed || record.ManuallyConfirmed || record.ParseStatus != models.SYBParseStatusUncertain || record.TargetColor != "黑色" || record.TargetSize != "XL" || record.AIConfidence == nil || *record.AIConfidence != 0.95 || record.AIReason == "" || record.AIInputFingerprint == "" {
t.Fatalf("unexpected confirmed record: %+v", record)
}
if record.RawJSON != rawBefore {
t.Fatal("AI confirmation must not rewrite RawJSON")
}
var finished models.SYBSpecAIParseRun
if err := db.First(&finished, run.ID).Error; err != nil {
t.Fatal(err)
}
if finished.Status != "completed" || finished.ConfirmedCount != 1 || finished.ProcessedCount != 1 {
t.Fatalf("unexpected run: %+v", finished)
}
second, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("second start: created=%v err=%v", created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, second.ID); err != nil {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("unchanged confirmed input called provider %d times", calls.Load())
}
}
func TestScheduledAIParseLeavesLowConfidenceUnmatchedForSameFingerprint(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
_, _ = response.Write([]byte(`{"choices":[{"message":{"content":"{\"color\":\"黑色\",\"size\":\"XL\",\"reason\":\"仍有歧义\",\"confidence\":0.4}"}}]}`))
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19802, "黑色 XL")
for i := 0; i < 2; i++ {
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start %d: created=%v err=%v", i, created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if record.AIConfirmed || calls.Load() != 1 {
t.Fatalf("low confidence must remain unconfirmed and not repeat: confirmed=%v calls=%d", record.AIConfirmed, calls.Load())
}
var work models.SYBSpecAIParseWorkItem
if err := db.Where("syb_product_id = ?", record.ID).First(&work).Error; err != nil {
t.Fatal(err)
}
if work.Status != "unmatched" {
t.Fatalf("work status=%s", work.Status)
}
}
func TestScheduledAIParseSkipsEmptySourceAndManualConfirmation(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
response.WriteHeader(http.StatusInternalServerError)
}))
defer provider.Close()
db := openTestDB(t)
empty := seedAIParseCandidate(t, db, provider.URL, 19803, "")
manual := seedAIParseCandidate(t, db, provider.URL, 19804, "黑色 XL")
if _, err := sybimport.ManualCorrect(context.Background(), db, manual.ID, "黑色", "XL"); err != nil {
t.Fatal(err)
}
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start: created=%v err=%v", created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
if calls.Load() != 0 {
t.Fatalf("ineligible rows called provider %d times", calls.Load())
}
var workCount int64
if err := db.Model(&models.SYBSpecAIParseWorkItem{}).Where("syb_product_id IN ?", []uint64{empty.ID, manual.ID}).Count(&workCount).Error; err != nil {
t.Fatal(err)
}
if workCount != 0 {
t.Fatalf("ineligible rows created %d work items", workCount)
}
}
func TestSpecAIParseRunHasSingleGlobalActiveSlot(t *testing.T) {
db := openTestDB(t)
first, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("first: created=%v err=%v", created, err)
}
second, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || created || second.ID != first.ID {
t.Fatalf("second must reuse active run: first=%d second=%d created=%v err=%v", first.ID, second.ID, created, err)
}
}
func TestScheduledAIParseRetriesProviderFailureAtMostThreeTimes(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
response.WriteHeader(http.StatusBadGateway)
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19805, "黑色 XL")
for attempt := 1; attempt <= 4; attempt++ {
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
t.Fatalf("start %d: created=%v err=%v", attempt, created, err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
if attempt < 3 {
past := time.Now().UTC().Add(-time.Minute)
if err := db.Model(&models.SYBSpecAIParseWorkItem{}).Where("syb_product_id = ?", record.ID).Update("next_attempt_at", past).Error; err != nil {
t.Fatal(err)
}
}
}
if calls.Load() != 3 {
t.Fatalf("provider calls=%d, want 3", calls.Load())
}
var work models.SYBSpecAIParseWorkItem
if err := db.Where("syb_product_id = ?", record.ID).First(&work).Error; err != nil {
t.Fatal(err)
}
if work.AttemptCount != 3 || work.NextAttemptAt != nil {
t.Fatalf("retry state=%+v", work)
}
}
+33 -2
View File
@@ -130,13 +130,37 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
result.Outcome = OutcomeCreated
case err == nil:
record.ID = existing.ID
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).Updates(map[string]any{
// Human-confirmed target values are authoritative and survive every
// source re-import. ParseStatus/ParseNote below still record what the
// current deterministic parser observed for audit.
if existing.ManuallyConfirmed {
record.TargetColor, record.TargetSize = existing.TargetColor, existing.TargetSize
record.ManuallyConfirmed = true
}
updates := map[string]any{
"stock_id": record.StockID, "shop_name": record.ShopName, "shopee_item_id": record.ShopeeItemID,
"shopee_product_id": record.ShopeeProductID, "product_title": record.ProductTitle,
"target_color": record.TargetColor, "target_size": record.TargetSize,
"quantity": record.Quantity, "unit_price_cent": record.UnitPriceCent, "image_url": record.ImageURL,
"parse_status": record.ParseStatus, "parse_note": record.ParseNote, "raw_json": record.RawJSON,
}).Error; err != nil {
}
// An identical re-import keeps a valid AI decision. Changed source,
// link, or a newly deterministic parse invalidates it atomically.
preserveAI := existing.AIConfirmed && !existing.ManuallyConfirmed && parsed.Status != models.SYBParseStatusSuccess &&
existing.RawJSON == record.RawJSON && sameOptionalID(existing.ShopeeProductID, record.ShopeeProductID)
if preserveAI {
record.TargetColor, record.TargetSize = existing.TargetColor, existing.TargetSize
updates["target_color"], updates["target_size"] = existing.TargetColor, existing.TargetSize
record.AIConfirmed, record.AIConfidence, record.AIReason = true, existing.AIConfidence, existing.AIReason
record.AIConfirmedAt, record.AIInputFingerprint = existing.AIConfirmedAt, existing.AIInputFingerprint
} else {
updates["ai_confirmed"], updates["ai_confidence"], updates["ai_reason"] = false, nil, ""
updates["ai_confirmed_at"], updates["ai_input_fingerprint"] = nil, ""
}
if existing.ManuallyConfirmed {
updates["target_color"], updates["target_size"] = existing.TargetColor, existing.TargetSize
}
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).Updates(updates).Error; err != nil {
return err
}
result.Outcome = OutcomeUpdated
@@ -161,6 +185,13 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
return result, nil
}
func sameOptionalID(left, right *uint64) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return *left == *right
}
// findOrCreateShopeeProduct implements #40's revival rule: a live match wins,
// a soft-deleted match is revived (keeping its prior mapping), and only when
// neither exists does the import create a minimal archive. On an existing
+4 -13
View File
@@ -85,8 +85,7 @@ func StartImport(ctx context.Context, db *gorm.DB, request ImportRequest, actor
}
run, err := NewSyncRunService(db).Create(ctx, CreateSyncRunInput{
DateFrom: request.DateFrom, DateTo: request.DateTo, ShopFilterHash: enabledShopHash(enabled),
ShopFilterSnapshot: cloneShopSnapshot(enabled),
OperatorID: actor.ID, OperatorName: strings.TrimSpace(actor.Name),
OperatorID: actor.ID, OperatorName: strings.TrimSpace(actor.Name),
})
if err != nil {
importGate.Unlock()
@@ -98,7 +97,7 @@ func StartImport(ctx context.Context, db *gorm.DB, request ImportRequest, actor
importGate.running = true
importGate.Unlock()
go runImport(db, run.ID, request, settings, cloneShopSnapshot(enabled))
go runImport(db, run.ID, request, settings)
return StartImportResult{RunID: run.ID, Status: run.Status}, nil
}
@@ -146,7 +145,7 @@ func (handler Handler) Import(c *gin.Context) {
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"runId": result.RunID, "status": result.Status}})
}
func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config.SYB, allowed map[string]string) {
func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config.SYB) {
defer func() { importGate.Lock(); importGate.running = false; importGate.Unlock() }()
ctx, cancel := context.WithTimeout(context.Background(), importTimeout)
defer cancel()
@@ -157,7 +156,7 @@ func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config
OcrURL: settings.OcrURL, OcrMaxAttempts: settings.OcrMaxAttempts,
})
if err == nil {
report, err = SyncWithShopSnapshot(ctx, db, client, SyncConfig{PageSize: settings.PageSize, MaxMatches: settings.MaxMatches}, request.DateFrom, request.DateTo, allowed,
report, err = SyncWithProgress(ctx, db, client, SyncConfig{PageSize: settings.PageSize, MaxMatches: settings.MaxMatches}, request.DateFrom, request.DateTo,
func(progress SyncProgress) error { return runs.UpdateProgress(ctx, runID, progress) })
}
status := SyncRunSucceeded
@@ -169,14 +168,6 @@ func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config
_ = runs.Finish(finishCtx, runID, status, report, err)
}
func cloneShopSnapshot(source map[string]string) map[string]string {
cloned := make(map[string]string, len(source))
for normalized, display := range source {
cloned[normalized] = display
}
return cloned
}
func claimUint64(value any) uint64 {
switch typed := value.(type) {
case float64:
+18 -3
View File
@@ -28,7 +28,7 @@ type rawDetailSpec struct {
// result page's per-line feedback.
type ReparseOutcome struct {
SYBProductID uint64 `json:"sybProductId"`
Outcome string `json:"outcome"` // reparsed | skipped_manual | unchanged
Outcome string `json:"outcome"` // reparsed | skipped_manual | skipped_ai | unchanged
OldStatus string `json:"oldStatus"`
NewStatus string `json:"newStatus"`
}
@@ -36,6 +36,7 @@ type ReparseOutcome struct {
const (
ReparseOutcomeReparsed = "reparsed"
ReparseOutcomeSkippedManual = "skipped_manual"
ReparseOutcomeSkippedAI = "skipped_ai"
ReparseOutcomeUnchanged = "unchanged"
)
@@ -65,6 +66,11 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
outcome.NewStatus = record.ParseStatus
return nil
}
if record.AIConfirmed && !force {
outcome.Outcome = ReparseOutcomeSkippedAI
outcome.NewStatus = record.ParseStatus
return nil
}
var raw rawDetailSpec
if err := json.Unmarshal([]byte(record.RawJSON), &raw); err != nil {
@@ -76,8 +82,11 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
outcome.Outcome = ReparseOutcomeUnchanged
if force {
record.ManuallyConfirmed = false
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Update("manually_confirmed", false).Error; err != nil {
record.ManuallyConfirmed, record.AIConfirmed = false, false
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(map[string]any{
"manually_confirmed": false, "ai_confirmed": false, "ai_confidence": nil,
"ai_reason": "", "ai_confirmed_at": nil, "ai_input_fingerprint": "",
}).Error; err != nil {
return err
}
}
@@ -87,6 +96,8 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
updates := map[string]any{
"target_color": parsed.Color, "target_size": parsed.Size,
"parse_status": parsed.Status, "parse_note": parsed.Note, "manually_confirmed": false,
"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "",
"ai_confirmed_at": nil, "ai_input_fingerprint": "",
}
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(updates).Error; err != nil {
return err
@@ -140,8 +151,12 @@ func ManualCorrect(ctx context.Context, db *gorm.DB, sybProductID uint64, color,
return err
}
record.TargetColor, record.TargetSize, record.ManuallyConfirmed = color, size, true
record.AIConfirmed, record.AIConfidence, record.AIReason = false, nil, ""
record.AIConfirmedAt, record.AIInputFingerprint = nil, ""
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", sybProductID).Updates(map[string]any{
"target_color": color, "target_size": size, "manually_confirmed": true,
"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "",
"ai_confirmed_at": nil, "ai_input_fingerprint": "",
}).Error; err != nil {
return err
}
+106
View File
@@ -60,6 +60,35 @@ func TestReparseSkipsManuallyConfirmedRowByDefault(t *testing.T) {
}
}
func TestReparseSkipsAIConfirmationUnlessForced(t *testing.T) {
db := openTestDB(t)
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB())
if err != nil {
t.Fatal(err)
}
confidence := 0.95
if err := db.Model(&models.SYBProduct{}).Where("id = ?", applied.SYBProduct.ID).Updates(map[string]any{
"target_color": "AI颜色", "target_size": "AI尺码", "ai_confirmed": true,
"ai_confidence": confidence, "ai_reason": "AI 结果", "ai_input_fingerprint": strings.Repeat("c", 64),
}).Error; err != nil {
t.Fatal(err)
}
outcome, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, false)
if err != nil || outcome.Outcome != sybimport.ReparseOutcomeSkippedAI {
t.Fatalf("unforced outcome=%+v err=%v", outcome, err)
}
if _, err := sybimport.Reparse(context.Background(), db, applied.SYBProduct.ID, true); err != nil {
t.Fatal(err)
}
var record models.SYBProduct
if err := db.First(&record, applied.SYBProduct.ID).Error; err != nil {
t.Fatal(err)
}
if record.AIConfirmed || record.AIConfidence != nil || record.AIReason != "" || record.AIInputFingerprint != "" {
t.Fatalf("forced reparse retained AI state: %+v", record)
}
}
// 可勾选强制覆盖.
func TestReparseWithForceOverridesManualCorrection(t *testing.T) {
db := openTestDB(t)
@@ -151,6 +180,83 @@ func TestManualCorrectMergesIntoArchiveLikeASuccessfulParse(t *testing.T) {
}
}
func TestManualCorrectSupersedesAIConfirmation(t *testing.T) {
db := openTestDB(t)
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB())
if err != nil {
t.Fatal(err)
}
confidence := 0.96
if err := db.Model(&models.SYBProduct{}).Where("id = ?", applied.SYBProduct.ID).Updates(map[string]any{
"ai_confirmed": true, "ai_confidence": confidence, "ai_reason": "旧 AI 结果", "ai_input_fingerprint": strings.Repeat("a", 64),
}).Error; err != nil {
t.Fatal(err)
}
corrected, err := sybimport.ManualCorrect(context.Background(), db, applied.SYBProduct.ID, "人工颜色", "人工尺码")
if err != nil {
t.Fatal(err)
}
if !corrected.ManuallyConfirmed || corrected.AIConfirmed || corrected.AIConfidence != nil || corrected.AIReason != "" || corrected.AIInputFingerprint != "" {
t.Fatalf("human correction did not supersede AI state: %+v", corrected)
}
}
func TestReimportPreservesIdenticalAIInputAndInvalidatesChangedSource(t *testing.T) {
db := openTestDB(t)
order, detail := realOrder(), realDetailB()
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
confidence := 0.95
if err := db.Model(&models.SYBProduct{}).Where("id = ?", first.SYBProduct.ID).Updates(map[string]any{
"target_color": "AI颜色", "target_size": "AI尺码", "ai_confirmed": true,
"ai_confidence": confidence, "ai_reason": "已确认", "ai_input_fingerprint": strings.Repeat("b", 64),
}).Error; err != nil {
t.Fatal(err)
}
same, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if !same.SYBProduct.AIConfirmed {
t.Fatal("identical re-import must preserve AI confirmation")
}
if same.SYBProduct.TargetColor != "AI颜色" || same.SYBProduct.TargetSize != "AI尺码" {
t.Fatal("identical re-import must preserve AI-confirmed target values")
}
detail.ProductSpec += " 新备注"
detail.Raw = []byte(`{"productSpec":"changed"}`)
changed, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if changed.SYBProduct.AIConfirmed || changed.SYBProduct.AIConfidence != nil || changed.SYBProduct.AIReason != "" {
t.Fatalf("changed source retained stale AI state: %+v", changed.SYBProduct)
}
}
func TestReimportNeverOverwritesManualTargetValues(t *testing.T) {
db := openTestDB(t)
order, detail := realOrder(), realDetailB()
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if _, err := sybimport.ManualCorrect(context.Background(), db, first.SYBProduct.ID, "人工颜色", "人工尺码"); err != nil {
t.Fatal(err)
}
detail.ProductSpec = "来源新颜色,来源新尺码"
detail.Raw = []byte(`{"productSpec":"来源新颜色,来源新尺码"}`)
updated, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if !updated.SYBProduct.ManuallyConfirmed || updated.SYBProduct.TargetColor != "人工颜色" || updated.SYBProduct.TargetSize != "人工尺码" {
t.Fatalf("re-import overwrote human decision: %+v", updated.SYBProduct)
}
}
// Regression test: ReparseOutcome originally had no json tags at all, so Go's
// default marshaling produced PascalCase keys ("SYBProductID", "OldStatus")
// instead of the camelCase the rest of this API and the admin frontend use.
+13 -31
View File
@@ -59,16 +59,12 @@ type SyncReport struct {
// it a renamed or newly opened shop is never noticed (#50).
ShopBreakdown map[string]ShopBreakdown `json:"shopBreakdown"`
ShopFilterHash string `json:"shopFilterHash"`
// ShopFilterSnapshot is the immutable enabled-shop snapshot supplied when
// the run was created. It is intentionally a display-name list for audit;
// matching continues to use the normalized map passed to the sync.
ShopFilterSnapshot []string `json:"shopFilterSnapshot"`
OrderCount int `json:"orderCount"`
DetailCount int `json:"detailCount"`
Created int `json:"created"`
Updated int `json:"updated"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
OrderCount int `json:"orderCount"`
DetailCount int `json:"detailCount"`
Created int `json:"created"`
Updated int `json:"updated"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
}
type ShopBreakdown struct {
@@ -124,24 +120,19 @@ func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncCo
}
func SyncWithProgress(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string, progress ProgressFunc) (SyncReport, error) {
allowed, err := sybshop.EnabledNames(ctx, db)
if err != nil {
return SyncReport{From: dateFrom, To: dateTo, StartedAt: time.Now().UTC(), ShopBreakdown: map[string]ShopBreakdown{}}, err
}
return SyncWithShopSnapshot(ctx, db, client, cfg, dateFrom, dateTo, allowed, progress)
}
// SyncWithShopSnapshot runs against the immutable enabled-shop snapshot taken
// by StartImport. It must not query syb_shop again: doing so would make the
// run's effective allow-list differ from the snapshot recorded on its row.
func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string, allowed map[string]string, progress ProgressFunc) (SyncReport, error) {
report := SyncReport{From: dateFrom, To: dateTo, StartedAt: time.Now().UTC(),
ShopBreakdown: map[string]ShopBreakdown{}, ShopFilterSnapshot: shopSnapshotNames(allowed)}
ShopBreakdown: map[string]ShopBreakdown{}}
dates, err := splitDateRange(dateFrom, dateTo)
if err != nil {
return report, err
}
// `[必须]` The allow-list is read once and used for the whole run, so a shop
// enabled midway cannot make one day behave differently from the next.
allowed, err := sybshop.EnabledNames(ctx, db)
if err != nil {
return report, err
}
if len(allowed) == 0 {
return report, ErrNoEnabledShop
}
@@ -292,15 +283,6 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
return report, nil
}
func shopSnapshotNames(allowed map[string]string) []string {
names := make([]string, 0, len(allowed))
for _, display := range allowed {
names = append(names, display)
}
sort.Strings(names)
return names
}
func enabledShopHash(allowed map[string]string) string {
names := make([]string, 0, len(allowed))
for normalized := range allowed {
+10 -57
View File
@@ -28,7 +28,6 @@ func NewSyncRunService(db *gorm.DB) *SyncRunService { return &SyncRunService{db:
type CreateSyncRunInput struct {
DateFrom, DateTo, ShopFilterHash string
ShopFilterSnapshot map[string]string
OperatorID uint64
OperatorName string
}
@@ -39,14 +38,10 @@ func (s *SyncRunService) Create(ctx context.Context, input CreateSyncRunInput) (
return nil, invalidRequest(err.Error())
}
slot := uint8(1)
payload, err := marshalSyncRunPayload(nil, shopSnapshotNames(input.ShopFilterSnapshot))
if err != nil {
return nil, &ServiceError{Code: CodeSyncRunCreateFailed, Message: "服务端处理失败", Cause: err}
}
run := &models.SYBSyncRun{
DateFrom: input.DateFrom, DateTo: input.DateTo, Status: SyncRunRunning,
ActiveSlot: &slot, DaysTotal: len(dates), ShopFilterHash: input.ShopFilterHash,
ShopBreakdownJSON: payload, OperatorID: input.OperatorID,
ShopBreakdownJSON: "{}", OperatorID: input.OperatorID,
OperatorName: strings.TrimSpace(input.OperatorName), StartedAt: time.Now().UTC(),
}
if err := s.db.WithContext(ctx).Create(run).Error; err != nil {
@@ -60,7 +55,7 @@ func (s *SyncRunService) Create(ctx context.Context, input CreateSyncRunInput) (
}
func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress SyncProgress) error {
payload, err := marshalSyncRunPayload(progress.Report.ShopBreakdown, progress.Report.ShopFilterSnapshot)
breakdown, err := json.Marshal(progress.Report.ShopBreakdown)
if err != nil {
return err
}
@@ -70,7 +65,7 @@ func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress
"order_count": progress.Report.OrderCount, "detail_count": progress.Report.DetailCount,
"accepted_count": progress.Report.AcceptedCount, "shop_skipped": progress.Report.ShopSkipped,
"created_count": progress.Report.Created, "updated_count": progress.Report.Updated,
"shop_filter_hash": progress.Report.ShopFilterHash, "shop_breakdown_json": payload,
"shop_filter_hash": progress.Report.ShopFilterHash, "shop_breakdown_json": string(breakdown),
}).Error
}
@@ -78,7 +73,7 @@ func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, r
if status != SyncRunSucceeded && status != SyncRunFailed {
return fmt.Errorf("invalid terminal sync status %q", status)
}
payload, err := marshalSyncRunPayload(report.ShopBreakdown, report.ShopFilterSnapshot)
breakdown, err := json.Marshal(report.ShopBreakdown)
if err != nil {
return err
}
@@ -92,7 +87,7 @@ func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, r
"order_count": report.OrderCount, "detail_count": report.DetailCount,
"accepted_count": report.AcceptedCount, "shop_skipped": report.ShopSkipped,
"created_count": report.Created, "updated_count": report.Updated,
"shop_breakdown_json": payload,
"shop_breakdown_json": string(breakdown),
}
if report.ShopFilterHash != "" {
updates["shop_filter_hash"] = report.ShopFilterHash
@@ -151,10 +146,8 @@ type ShopBreakdownItem struct {
}
type SyncRunDTO struct {
models.SYBSyncRun
ShopBreakdown []ShopBreakdownItem `json:"shopBreakdown"`
ShopFilterSnapshot []string `json:"shopFilterSnapshot"`
ShopFilterSnapshotPresent bool `json:"shopFilterSnapshotPresent"`
ProgressPercent int `json:"progressPercent"`
ShopBreakdown []ShopBreakdownItem `json:"shopBreakdown"`
ProgressPercent int `json:"progressPercent"`
}
func (s *SyncRunService) List(ctx context.Context, request SyncRunListRequest) (SyncRunListResponse, error) {
@@ -195,7 +188,8 @@ func (s *SyncRunService) Detail(ctx context.Context, id uint64) (SyncRunDTO, err
}
func syncRunDTO(row models.SYBSyncRun) SyncRunDTO {
values, snapshot, snapshotPresent := parseSyncRunPayload(row.ShopBreakdownJSON)
var values map[string]ShopBreakdown
_ = json.Unmarshal([]byte(row.ShopBreakdownJSON), &values)
names := make([]string, 0, len(values))
for name := range values {
names = append(names, name)
@@ -216,48 +210,7 @@ func syncRunDTO(row models.SYBSyncRun) SyncRunDTO {
if row.Status == SyncRunSucceeded {
percent = 100
}
return SyncRunDTO{SYBSyncRun: row, ShopBreakdown: items, ShopFilterSnapshot: snapshot,
ShopFilterSnapshotPresent: snapshotPresent, ProgressPercent: percent}
}
const syncRunPayloadVersion = 1
type syncRunPayload struct {
Version int `json:"version"`
ShopBreakdown map[string]ShopBreakdown `json:"shopBreakdown"`
ShopFilterSnapshot []string `json:"shopFilterSnapshot"`
}
func marshalSyncRunPayload(breakdown map[string]ShopBreakdown, snapshot []string) (string, error) {
if breakdown == nil {
breakdown = map[string]ShopBreakdown{}
}
if snapshot == nil {
snapshot = []string{}
}
encoded, err := json.Marshal(syncRunPayload{
Version: syncRunPayloadVersion, ShopBreakdown: breakdown, ShopFilterSnapshot: snapshot,
})
if err != nil {
return "", err
}
return string(encoded), nil
}
func parseSyncRunPayload(raw string) (map[string]ShopBreakdown, []string, bool) {
var envelope syncRunPayload
if err := json.Unmarshal([]byte(raw), &envelope); err == nil && envelope.Version == syncRunPayloadVersion {
if envelope.ShopBreakdown == nil {
envelope.ShopBreakdown = map[string]ShopBreakdown{}
}
sort.Strings(envelope.ShopFilterSnapshot)
return envelope.ShopBreakdown, envelope.ShopFilterSnapshot, true
}
// Records written before #212 are a bare breakdown map. Preserve their
// existing statistics and state explicitly that no immutable snapshot exists.
values := map[string]ShopBreakdown{}
_ = json.Unmarshal([]byte(raw), &values)
return values, nil, false
return SyncRunDTO{SYBSyncRun: row, ShopBreakdown: items, ProgressPercent: percent}
}
func truncateRunError(value string, limit int) string {
@@ -4,8 +4,6 @@ import (
"context"
"strings"
"testing"
"go-admin/app/goauto/models"
)
func TestSyncRunSingleFlightAndTerminalRelease(t *testing.T) {
@@ -59,32 +57,3 @@ func TestSyncRunProgressDetailAndRecovery(t *testing.T) {
t.Fatalf("启动恢复状态不正确: %+v", detail)
}
}
func TestSyncRunPersistsImmutableShopSnapshotAndReadsLegacyBreakdown(t *testing.T) {
db := newSyncTestDB(t)
service := NewSyncRunService(db)
run, err := service.Create(context.Background(), CreateSyncRunInput{
DateFrom: "2026-08-01", DateTo: "2026-08-01", ShopFilterHash: "snapshot-hash",
ShopFilterSnapshot: map[string]string{"alpha": "Alpha 店", "beta": "Beta 店"},
})
if err != nil {
t.Fatal(err)
}
detail, err := service.Detail(context.Background(), run.ID)
if err != nil {
t.Fatal(err)
}
if !detail.ShopFilterSnapshotPresent || strings.Join(detail.ShopFilterSnapshot, ",") != "Alpha 店,Beta 店" {
t.Fatalf("新任务必须返回冻结快照: %+v", detail)
}
if err := db.Model(&models.SYBSyncRun{}).Where("id = ?", run.ID).Update("shop_breakdown_json", `{"旧店":{"accepted":2,"skipped":1}}`).Error; err != nil {
t.Fatal(err)
}
legacy, err := service.Detail(context.Background(), run.ID)
if err != nil {
t.Fatal(err)
}
if legacy.ShopFilterSnapshotPresent || len(legacy.ShopFilterSnapshot) != 0 || len(legacy.ShopBreakdown) != 1 || legacy.ShopBreakdown[0].Accepted != 2 {
t.Fatalf("旧记录必须保留统计且明确没有快照: %+v", legacy)
}
}
-20
View File
@@ -429,26 +429,6 @@ func TestSyncMatchesShopNamesThroughNormalization(t *testing.T) {
}
}
func TestSyncWithShopSnapshotDoesNotRereadChangedShopState(t *testing.T) {
db := newSyncTestDB(t)
allowed, err := sybshop.EnabledNames(context.Background(), db)
if err != nil {
t.Fatal(err)
}
if err := db.Model(&models.SYBShop{}).Where("normalized_name = ?", "测试店铺").Update("enabled", false).Error; err != nil {
t.Fatal(err)
}
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 2}, shopNames: []string{"测试店铺"}}
report, err := SyncWithShopSnapshot(context.Background(), db, newSyncClient(t, f),
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01", allowed, nil)
if err != nil {
t.Fatalf("冻结快照同步失败: %v", err)
}
if report.AcceptedCount != 2 || len(report.ShopFilterSnapshot) != 1 || report.ShopFilterSnapshot[0] != "测试店铺" {
t.Fatalf("后台不得重读已变化的店铺状态: %+v", report)
}
}
// 列表说属于启用店铺、明细却是另一家店时必须拦住。
func TestSyncRechecksShopOnDetailResponse(t *testing.T) {
db := newSyncTestDB(t)
-91
View File
@@ -1,91 +0,0 @@
package sybshop
import (
"context"
"fmt"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
// ReconcileNormalizedNames repairs the matching keys of live SYB shops created
// before Normalize became the single write-path rule. It changes neither the
// display name nor the enabled state. The operation is safe to repeat.
//
// A normalised-name collision is a data ambiguity. It must be resolved by an
// operator, rather than silently merging shops or choosing one arbitrarily.
func ReconcileNormalizedNames(ctx context.Context, db *gorm.DB) (int, error) {
if db == nil {
return 0, fmt.Errorf("syb shop normalized-name repair: database is nil")
}
updated := 0
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var shops []models.SYBShop
if err := tx.Order("id ASC").Find(&shops).Error; err != nil {
return err
}
desiredOwners := make(map[string]uint64, len(shops))
currentKeys := make(map[string]struct{}, len(shops))
type repair struct {
id uint64
desired string
temp string
}
repairs := make([]repair, 0)
for _, shop := range shops {
desired := Normalize(shop.DisplayName)
if desired == "" {
return fmt.Errorf("syb shop normalized-name repair: shop %d has blank display name", shop.ID)
}
if owner, exists := desiredOwners[desired]; exists && owner != shop.ID {
return fmt.Errorf("syb shop normalized-name repair: normalized key conflict between shops %d and %d", owner, shop.ID)
}
desiredOwners[desired] = shop.ID
currentKeys[shop.NormalizedName] = struct{}{}
if shop.NormalizedName != desired {
repairs = append(repairs, repair{id: shop.ID, desired: desired})
}
}
// Keys can be swapped (for example stale "a"/"b" values). Stage every
// repair through a unique temporary key so the unique index is preserved
// throughout the transaction.
reserved := make(map[string]struct{}, len(currentKeys)+len(desiredOwners))
for key := range currentKeys {
reserved[key] = struct{}{}
}
for key := range desiredOwners {
reserved[key] = struct{}{}
}
for index := range repairs {
base := fmt.Sprintf("__goauto_syb_shop_normalize_repair_%d__", repairs[index].id)
temp := base
for suffix := 1; ; suffix++ {
if _, exists := reserved[temp]; !exists {
break
}
temp = fmt.Sprintf("%s%d", base, suffix)
}
repairs[index].temp = temp
reserved[temp] = struct{}{}
}
for _, repair := range repairs {
if err := tx.Model(&models.SYBShop{}).Where("id = ?", repair.id).
UpdateColumn("normalized_name", repair.temp).Error; err != nil {
return err
}
}
for _, repair := range repairs {
if err := tx.Model(&models.SYBShop{}).Where("id = ?", repair.id).
UpdateColumn("normalized_name", repair.desired).Error; err != nil {
return err
}
}
updated = len(repairs)
return nil
})
return updated, err
}
@@ -1,93 +0,0 @@
package sybshop_test
import (
"context"
"strings"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybshop"
)
func TestReconcileNormalizedNamesRepairsLegacyKey(t *testing.T) {
db := openDB(t)
shop := models.SYBShop{DisplayName: "樂齡樂活美學館|銀髮居家熟齡悅己壯世代優雅生活", NormalizedName: "legacy-key", Enabled: true}
if err := db.Create(&shop).Error; err != nil {
t.Fatalf("创建历史店铺失败: %v", err)
}
updated, err := sybshop.ReconcileNormalizedNames(context.Background(), db)
if err != nil {
t.Fatalf("回填失败: %v", err)
}
if updated != 1 {
t.Fatalf("应修复 1 条,实际 %d", updated)
}
var reloaded models.SYBShop
if err := db.First(&reloaded, shop.ID).Error; err != nil {
t.Fatalf("读取修复结果失败: %v", err)
}
if reloaded.NormalizedName != sybshop.Normalize(shop.DisplayName) {
t.Fatalf("规范化键不正确: %q", reloaded.NormalizedName)
}
if reloaded.DisplayName != shop.DisplayName || !reloaded.Enabled {
t.Fatalf("回填不应修改展示名或启用状态: %+v", reloaded)
}
names, err := sybshop.EnabledNames(context.Background(), db)
if err != nil || names[sybshop.Normalize(shop.DisplayName)] != shop.DisplayName {
t.Fatalf("修复后启用快照未命中: names=%v err=%v", names, err)
}
updated, err = sybshop.ReconcileNormalizedNames(context.Background(), db)
if err != nil || updated != 0 {
t.Fatalf("重复回填应无变化: updated=%d err=%v", updated, err)
}
}
func TestReconcileNormalizedNamesRejectsCollisionWithoutWriting(t *testing.T) {
db := openDB(t)
first := models.SYBShop{DisplayName: "A店", NormalizedName: "legacy-one", Enabled: true}
second := models.SYBShop{DisplayName: "A店", NormalizedName: "legacy-two", Enabled: true}
if err := db.Create(&first).Error; err != nil {
t.Fatalf("创建第一条历史店铺失败: %v", err)
}
if err := db.Create(&second).Error; err != nil {
t.Fatalf("创建第二条历史店铺失败: %v", err)
}
updated, err := sybshop.ReconcileNormalizedNames(context.Background(), db)
if updated != 0 || err == nil || !strings.Contains(err.Error(), "normalized key conflict") {
t.Fatalf("冲突应失败且不报告更新: updated=%d err=%v", updated, err)
}
var rows []models.SYBShop
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
t.Fatalf("读取冲突结果失败: %v", err)
}
if len(rows) != 2 || rows[0].NormalizedName != "legacy-one" || rows[1].NormalizedName != "legacy-two" {
t.Fatalf("冲突时不应修改任何键: %+v", rows)
}
}
func TestReconcileNormalizedNamesHandlesSwappedLegacyKeys(t *testing.T) {
db := openDB(t)
first := models.SYBShop{DisplayName: "A", NormalizedName: "b", Enabled: true}
second := models.SYBShop{DisplayName: "B", NormalizedName: "a", Enabled: true}
if err := db.Create(&first).Error; err != nil {
t.Fatalf("创建第一条历史店铺失败: %v", err)
}
if err := db.Create(&second).Error; err != nil {
t.Fatalf("创建第二条历史店铺失败: %v", err)
}
updated, err := sybshop.ReconcileNormalizedNames(context.Background(), db)
if err != nil || updated != 2 {
t.Fatalf("交换键回填失败: updated=%d err=%v", updated, err)
}
var rows []models.SYBShop
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
t.Fatalf("读取回填结果失败: %v", err)
}
if rows[0].NormalizedName != "a" || rows[1].NormalizedName != "b" {
t.Fatalf("交换键未正确回填: %+v", rows)
}
}
+87
View File
@@ -0,0 +1,87 @@
package apis
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
jobservice "go-admin/app/jobs/service"
)
func (e SysJob) ListExecutionLogs(c *gin.Context) {
jobID, err := strconv.Atoi(c.Param("id"))
if err != nil || jobID < 1 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "jobId 无效"})
return
}
page, err := positiveQueryInt(c.Query("pageIndex"), 1)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "pageIndex 必须是正整数"})
return
}
pageSize, err := positiveQueryInt(c.Query("pageSize"), 20)
if err != nil || pageSize > 100 {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "pageSize 必须是 1 到 100 的整数"})
return
}
startedFrom, err := optionalTime(c.Query("startedFrom"))
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "startedFrom 必须是 RFC3339 时间"})
return
}
startedTo, err := optionalTime(c.Query("startedTo"))
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": "startedTo 必须是 RFC3339 时间"})
return
}
e.MakeContext(c)
db, err := e.GetOrm()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "服务端处理失败"})
return
}
response, err := jobservice.NewExecutionLogService(db).List(c.Request.Context(), jobID, jobservice.ExecutionLogListRequest{
Page: page, PageSize: pageSize, Status: strings.TrimSpace(c.Query("status")),
StartedFrom: startedFrom, StartedTo: startedTo,
})
if err != nil {
switch {
case errors.Is(err, jobservice.ErrExecutionLogInvalidRequest):
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": 400, "msg": err.Error()})
case errors.Is(err, jobservice.ErrExecutionLogJobNotFound):
c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "定时任务不存在"})
default:
e.GetLogger().Errorf("list scheduled job execution logs failed job_id=%d: %v", jobID, err)
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "服务端处理失败"})
}
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
}
func positiveQueryInt(value string, fallback int) (int, error) {
if strings.TrimSpace(value) == "" {
return fallback, nil
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1 {
return 0, errors.New("invalid positive integer")
}
return parsed, nil
}
func optionalTime(value string) (*time.Time, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil, err
}
return &parsed, nil
}
+5 -2
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"time"
"go-admin/app/goauto/shopeeproduct"
"go-admin/app/goauto/sybimport"
)
@@ -12,8 +13,10 @@ import (
// 字典 key 可以配置到 自动任务 调用目标 中;
func InitJob() {
jobList = map[string]JobExec{
"ExamplesOne": ExamplesOne{},
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
"ExamplesOne": ExamplesOne{},
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
sybimport.SpecAIParseInvokeTarget: sybimport.ScheduledSpecAIParseJob{},
shopeeproduct.SpecAutoMatchInvokeTarget: shopeeproduct.ScheduledAutoMatchJob{},
// ...
}
}
+132
View File
@@ -0,0 +1,132 @@
package jobs
import (
"context"
"errors"
"fmt"
"time"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/google/uuid"
"gorm.io/gorm"
"go-admin/app/jobs/models"
)
const (
executionErrorTargetMissing = "JOB_TARGET_NOT_FOUND"
executionErrorExecFailed = "JOB_EXECUTION_FAILED"
executionErrorHTTPFailed = "JOB_HTTP_FAILED"
executionErrorPanicked = "JOB_EXECUTION_PANICKED"
executionErrorInterrupted = "JOB_INTERRUPTED"
)
type executionFailure struct {
code string
message string
cause error
}
func (failure *executionFailure) Error() string {
if failure.cause != nil {
return failure.cause.Error()
}
return failure.message
}
func newExecutionFailure(code, message string, cause error) error {
return &executionFailure{code: code, message: message, cause: cause}
}
func runWithExecutionLog(db *gorm.DB, core JobCore, execute func() error) (executionErr error) {
startedAt := time.Now().UTC()
record := &models.SysJobExecutionLog{
ExecutionID: uuid.NewString(), JobID: core.JobId,
JobNameSnapshot: core.Name, InvokeTargetSnapshot: core.InvokeTarget,
TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionRunning,
StartedAt: startedAt,
}
created := false
if db != nil {
if err := db.WithContext(context.Background()).Create(record).Error; err != nil {
log.Errorf("[Job] execution log create failed job_id=%d: %v", core.JobId, err)
} else {
created = true
}
}
defer func() {
if recovered := recover(); recovered != nil {
executionErr = newExecutionFailure(executionErrorPanicked, "任务执行异常中断", nil)
if created {
finishExecutionLog(db, core.JobId, record, startedAt, executionErr)
}
return
}
if created {
finishExecutionLog(db, core.JobId, record, startedAt, executionErr)
}
}()
executionErr = execute()
return executionErr
}
func finishExecutionLog(db *gorm.DB, jobID int, record *models.SysJobExecutionLog, startedAt time.Time, executionErr error) {
finishedAt := time.Now().UTC()
updates := map[string]any{
"status": models.JobExecutionSucceeded, "finished_at": finishedAt,
"duration_ms": finishedAt.Sub(startedAt).Milliseconds(), "error_code": "", "error_message": "",
}
if executionErr != nil {
code, message := publicExecutionFailure(executionErr)
updates["status"] = models.JobExecutionFailed
updates["error_code"] = code
updates["error_message"] = message
}
if err := db.WithContext(context.Background()).Model(&models.SysJobExecutionLog{}).
Where("id = ? AND status = ?", record.ID, models.JobExecutionRunning).Updates(updates).Error; err != nil {
log.Errorf("[Job] execution log finish failed job_id=%d execution_id=%s: %v", jobID, record.ExecutionID, err)
}
}
func publicExecutionFailure(err error) (string, string) {
var failure *executionFailure
if errors.As(err, &failure) {
return failure.code, failure.message
}
return executionErrorExecFailed, "任务执行失败,请查看受控服务日志"
}
// RecoverInterruptedExecutionLogs closes invocations left running by the
// previous process. Production currently runs one scheduler per database.
func RecoverInterruptedExecutionLogs(db *gorm.DB) error {
if db == nil {
return nil
}
now := time.Now().UTC()
var records []models.SysJobExecutionLog
if err := db.WithContext(context.Background()).Where("status = ?", models.JobExecutionRunning).Find(&records).Error; err != nil {
return err
}
return db.WithContext(context.Background()).Transaction(func(tx *gorm.DB) error {
for _, record := range records {
duration := now.Sub(record.StartedAt).Milliseconds()
if duration < 0 {
duration = 0
}
if err := tx.Model(&models.SysJobExecutionLog{}).
Where("id = ? AND status = ?", record.ID, models.JobExecutionRunning).Updates(map[string]any{
"status": models.JobExecutionInterrupted, "finished_at": now,
"duration_ms": duration, "error_code": executionErrorInterrupted,
"error_message": "服务重启前任务未完成",
}).Error; err != nil {
return err
}
}
return nil
})
}
func missingExecutionTarget(target string) error {
return newExecutionFailure(executionErrorTargetMissing, "任务调用目标未注册", fmt.Errorf("job target %q is not registered", target))
}
+113
View File
@@ -0,0 +1,113 @@
package jobs
import (
"errors"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"go-admin/app/jobs/models"
)
func jobExecutionTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&models.SysJobExecutionLog{}); err != nil {
t.Fatal(err)
}
return db
}
func TestRunWithExecutionLogRecordsSuccessAndSanitizedFailure(t *testing.T) {
db := jobExecutionTestDB(t)
core := JobCore{JobId: 7, Name: "测试任务", InvokeTarget: "TestTarget"}
if err := runWithExecutionLog(db, core, func() error { return nil }); err != nil {
t.Fatal(err)
}
rawSecret := "token=secret-value productSpec=private"
if err := runWithExecutionLog(db, core, func() error { return errors.New(rawSecret) }); err == nil {
t.Fatal("failed execution must return its error")
}
var records []models.SysJobExecutionLog
if err := db.Order("id asc").Find(&records).Error; err != nil {
t.Fatal(err)
}
if len(records) != 2 || records[0].Status != models.JobExecutionSucceeded || records[1].Status != models.JobExecutionFailed {
t.Fatalf("unexpected records: %+v", records)
}
if records[1].ErrorCode != executionErrorExecFailed || records[1].ErrorMessage == rawSecret || records[1].ErrorMessage == "" {
t.Fatalf("failure was not safely summarized: %+v", records[1])
}
if records[0].FinishedAt == nil || records[1].FinishedAt == nil || records[0].ExecutionID == records[1].ExecutionID {
t.Fatal("execution lifecycle or unique IDs were not recorded")
}
}
func TestRecoverInterruptedExecutionLogs(t *testing.T) {
db := jobExecutionTestDB(t)
started := time.Now().UTC().Add(-2 * time.Second)
record := models.SysJobExecutionLog{
ExecutionID: "00000000-0000-4000-8000-000000000010", JobID: 9,
JobNameSnapshot: "中断任务", InvokeTargetSnapshot: "Interrupted",
TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionRunning, StartedAt: started,
}
if err := db.Create(&record).Error; err != nil {
t.Fatal(err)
}
if err := RecoverInterruptedExecutionLogs(db); err != nil {
t.Fatal(err)
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if record.Status != models.JobExecutionInterrupted || record.FinishedAt == nil || record.ErrorCode != executionErrorInterrupted || record.DurationMS < 1000 {
t.Fatalf("record was not safely interrupted: %+v", record)
}
}
func TestMissingExecutionTargetHasPublicSafeMessage(t *testing.T) {
code, message := publicExecutionFailure(missingExecutionTarget("SecretTarget"))
if code != executionErrorTargetMissing || message != "任务调用目标未注册" {
t.Fatalf("unexpected target failure %s %s", code, message)
}
}
func TestExecJobRecordsMissingTargetAsFailure(t *testing.T) {
db := jobExecutionTestDB(t)
previousJobList := jobList
jobList = map[string]JobExec{}
t.Cleanup(func() { jobList = previousJobList })
job := &ExecJob{JobCore: JobCore{JobId: 10, Name: "未注册任务", InvokeTarget: "MissingTarget"}, DB: db}
job.Run()
var record models.SysJobExecutionLog
if err := db.First(&record).Error; err != nil {
t.Fatal(err)
}
if record.Status != models.JobExecutionFailed || record.ErrorCode != executionErrorTargetMissing || record.ErrorMessage != "任务调用目标未注册" {
t.Fatalf("missing target was not safely recorded: %+v", record)
}
}
func TestRunWithExecutionLogSafelyRecordsPanic(t *testing.T) {
db := jobExecutionTestDB(t)
err := runWithExecutionLog(db, JobCore{JobId: 11, Name: "异常任务", InvokeTarget: "Panic"}, func() error {
panic("provider-secret")
})
if code, message := publicExecutionFailure(err); code != executionErrorPanicked || message != "任务执行异常中断" {
t.Fatalf("panic was not returned as a safe failure: %s %s", code, message)
}
var record models.SysJobExecutionLog
if err := db.First(&record).Error; err != nil {
t.Fatal(err)
}
if record.Status != models.JobExecutionFailed || record.ErrorCode != executionErrorPanicked || record.ErrorMessage != "任务执行异常中断" {
t.Fatalf("panic was not safely persisted: %+v", record)
}
}
+33 -22
View File
@@ -33,6 +33,7 @@ type JobCore struct {
// HttpJob 任务类型 http
type HttpJob struct {
JobCore
DB *gorm.DB
}
type ExecJob struct {
@@ -42,14 +43,16 @@ type ExecJob struct {
func (e *ExecJob) Run() {
startTime := time.Now()
var obj = jobList[e.InvokeTarget]
if obj == nil {
log.Warn("[Job] ExecJob Run job nil")
return
}
err := CallExecWithDB(obj.(JobExec), e.DB, e.Args)
err := runWithExecutionLog(e.DB, e.JobCore, func() error {
obj := jobList[e.InvokeTarget]
if obj == nil {
return missingExecutionTarget(e.InvokeTarget)
}
return CallExecWithDB(obj, e.DB, e.Args)
})
if err != nil {
log.Errorf("[Job] JobCore %s failed: %v", e.Name, err)
code, _ := publicExecutionFailure(err)
log.Errorf("[Job] JobCore %s failed error_code=%s", e.Name, code)
return
}
// 结束时间
@@ -68,22 +71,26 @@ func (e *ExecJob) Run() {
func (h *HttpJob) Run() {
startTime := time.Now()
var count = 0
var err error
var str string
/* 循环 */
LOOP:
if count < retryCount {
/* 跳过迭代 */
str, err = pkg.Get(h.InvokeTarget)
if err != nil {
// 如果失败暂停一段时间重试
log.Warnf("[Job] mission failed! %v", err)
log.Warnf("[Job] Retry after the task fails %d seconds! %s \n", (count+1)*5, str)
time.Sleep(time.Duration(count+1) * 5 * time.Second)
count = count + 1
goto LOOP
err := runWithExecutionLog(h.DB, h.JobCore, func() error {
var lastErr error
for count := 0; count < retryCount; count++ {
_, requestErr := pkg.Get(h.InvokeTarget)
if requestErr == nil {
return nil
}
lastErr = requestErr
log.Warnf("[Job] HTTP mission failed attempt=%d", count+1)
if count+1 < retryCount {
log.Warnf("[Job] Retry after the task fails %d seconds!\n", (count+1)*5)
time.Sleep(time.Duration(count+1) * 5 * time.Second)
}
}
return newExecutionFailure(executionErrorHTTPFailed, "HTTP 任务请求失败", lastErr)
})
if err != nil {
code, _ := publicExecutionFailure(err)
log.Errorf("[Job] JobCore %s failed error_code=%s", h.Name, code)
return
}
// 结束时间
endTime := time.Now()
@@ -102,6 +109,9 @@ func Setup(dbs map[string]*gorm.DB) {
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore Starting...")
for k, db := range dbs {
if err := RecoverInterruptedExecutionLogs(db); err != nil {
log.Errorf("[Job] recover interrupted execution logs failed: %v", err)
}
sdk.Runtime.SetCrontab(k, cronjob.NewWithSeconds())
setup(k, db)
}
@@ -127,6 +137,7 @@ func setup(key string, db *gorm.DB) {
for i := 0; i < len(jobList); i++ {
if jobList[i].JobType == 1 {
j := &HttpJob{}
j.DB = db
j.InvokeTarget = jobList[i].InvokeTarget
j.CronExpression = jobList[i].CronExpression
j.JobId = jobList[i].JobId
@@ -0,0 +1,32 @@
package models
import "time"
const (
JobExecutionRunning = "running"
JobExecutionSucceeded = "succeeded"
JobExecutionFailed = "failed"
JobExecutionInterrupted = "interrupted"
JobTriggerScheduled = "scheduled"
)
// SysJobExecutionLog stores one scheduler invocation. Job arguments and raw
// provider responses are deliberately excluded from this audit record.
type SysJobExecutionLog struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
ExecutionID string `json:"executionId" gorm:"size:36;not null;uniqueIndex:ux_sys_job_execution_id"`
JobID int `json:"jobId" gorm:"not null;index:idx_sys_job_execution_job_started,priority:1"`
JobNameSnapshot string `json:"jobName" gorm:"size:255;not null"`
InvokeTargetSnapshot string `json:"invokeTarget" gorm:"size:255;not null"`
TriggerType string `json:"triggerType" gorm:"size:16;not null"`
Status string `json:"status" gorm:"size:16;not null;index:idx_sys_job_execution_status_started,priority:1"`
StartedAt time.Time `json:"startedAt" gorm:"not null;index:idx_sys_job_execution_job_started,priority:2;index:idx_sys_job_execution_status_started,priority:2"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
DurationMS int64 `json:"durationMs" gorm:"not null;default:0"`
ErrorCode string `json:"errorCode,omitempty" gorm:"size:64;not null;default:''"`
ErrorMessage string `json:"errorMessage,omitempty" gorm:"size:500;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SysJobExecutionLog) TableName() string { return "sys_job_execution_log" }
+2
View File
@@ -24,6 +24,8 @@ func registerSysJobRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
list := make([]models2.SysJob, 0)
return &list
}))
jobAPI := apis.SysJob{}
r.GET("/:id/execution-logs", actions.PermissionAction(), jobAPI.ListExecutionLogs)
r.GET("/:id", actions.PermissionAction(), actions.ViewAction(new(dto2.SysJobById), func() interface{} {
return &dto2.SysJobItem{}
}))
+107
View File
@@ -0,0 +1,107 @@
package service
import (
"context"
"errors"
"fmt"
"time"
"gorm.io/gorm"
"go-admin/app/jobs/models"
)
var (
ErrExecutionLogInvalidRequest = errors.New("invalid execution log request")
ErrExecutionLogJobNotFound = errors.New("scheduled job not found")
)
type ExecutionLogListRequest struct {
Page int
PageSize int
Status string
StartedFrom *time.Time
StartedTo *time.Time
}
type ExecutionLogJob struct {
JobID int `json:"jobId"`
JobName string `json:"jobName"`
InvokeTarget string `json:"invokeTarget"`
Deleted bool `json:"deleted"`
}
type ExecutionLogListResponse struct {
Job ExecutionLogJob `json:"job"`
Items []models.SysJobExecutionLog `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type ExecutionLogService struct{ db *gorm.DB }
func NewExecutionLogService(db *gorm.DB) *ExecutionLogService {
return &ExecutionLogService{db: db}
}
func (service *ExecutionLogService) List(ctx context.Context, jobID int, request ExecutionLogListRequest) (ExecutionLogListResponse, error) {
if service.db == nil || jobID < 1 {
return ExecutionLogListResponse{}, fmt.Errorf("%w: jobId 无效", ErrExecutionLogInvalidRequest)
}
if request.Page < 1 {
request.Page = 1
}
if request.PageSize < 1 {
request.PageSize = 20
}
if request.PageSize > 100 {
return ExecutionLogListResponse{}, fmt.Errorf("%w: pageSize 必须是 1 到 100 的整数", ErrExecutionLogInvalidRequest)
}
if request.Status != "" && !validExecutionStatus(request.Status) {
return ExecutionLogListResponse{}, fmt.Errorf("%w: status 无效", ErrExecutionLogInvalidRequest)
}
if request.StartedFrom != nil && request.StartedTo != nil && request.StartedFrom.After(*request.StartedTo) {
return ExecutionLogListResponse{}, fmt.Errorf("%w: 开始时间范围无效", ErrExecutionLogInvalidRequest)
}
var job models.SysJob
if err := service.db.WithContext(ctx).Unscoped().First(&job, jobID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ExecutionLogListResponse{}, ErrExecutionLogJobNotFound
}
return ExecutionLogListResponse{}, err
}
query := service.db.WithContext(ctx).Model(&models.SysJobExecutionLog{}).Where("job_id = ?", jobID)
if request.Status != "" {
query = query.Where("status = ?", request.Status)
}
if request.StartedFrom != nil {
query = query.Where("started_at >= ?", request.StartedFrom.UTC())
}
if request.StartedTo != nil {
query = query.Where("started_at <= ?", request.StartedTo.UTC())
}
var total int64
if err := query.Count(&total).Error; err != nil {
return ExecutionLogListResponse{}, err
}
items := make([]models.SysJobExecutionLog, 0, request.PageSize)
if err := query.Order("started_at DESC, id DESC").Offset((request.Page - 1) * request.PageSize).Limit(request.PageSize).Find(&items).Error; err != nil {
return ExecutionLogListResponse{}, err
}
return ExecutionLogListResponse{
Job: ExecutionLogJob{JobID: job.JobId, JobName: job.JobName, InvokeTarget: job.InvokeTarget, Deleted: job.DeletedAt.Valid},
Items: items, Total: total, Page: request.Page, PageSize: request.PageSize,
}, nil
}
func validExecutionStatus(status string) bool {
switch status {
case models.JobExecutionRunning, models.JobExecutionSucceeded, models.JobExecutionFailed, models.JobExecutionInterrupted:
return true
default:
return false
}
}
@@ -0,0 +1,87 @@
package service
import (
"context"
"errors"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"go-admin/app/jobs/models"
)
func executionLogTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&models.SysJob{}, &models.SysJobExecutionLog{}); err != nil {
t.Fatal(err)
}
return db
}
func TestExecutionLogListFiltersPaginatesAndKeepsDeletedJob(t *testing.T) {
db := executionLogTestDB(t)
job := models.SysJob{JobName: "测试任务", InvokeTarget: "TestTarget"}
if err := db.Create(&job).Error; err != nil {
t.Fatal(err)
}
base := time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC)
records := []models.SysJobExecutionLog{
{ExecutionID: "00000000-0000-4000-8000-000000000001", JobID: job.JobId, JobNameSnapshot: job.JobName, InvokeTargetSnapshot: job.InvokeTarget, TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionSucceeded, StartedAt: base},
{ExecutionID: "00000000-0000-4000-8000-000000000002", JobID: job.JobId, JobNameSnapshot: job.JobName, InvokeTargetSnapshot: job.InvokeTarget, TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionFailed, StartedAt: base.Add(time.Hour)},
{ExecutionID: "00000000-0000-4000-8000-000000000003", JobID: job.JobId, JobNameSnapshot: job.JobName, InvokeTargetSnapshot: job.InvokeTarget, TriggerType: models.JobTriggerScheduled, Status: models.JobExecutionFailed, StartedAt: base.Add(2 * time.Hour)},
}
if err := db.Create(&records).Error; err != nil {
t.Fatal(err)
}
from, to := base.Add(30*time.Minute), base.Add(3*time.Hour)
result, err := NewExecutionLogService(db).List(context.Background(), job.JobId, ExecutionLogListRequest{
Page: 1, PageSize: 1, Status: models.JobExecutionFailed, StartedFrom: &from, StartedTo: &to,
})
if err != nil {
t.Fatal(err)
}
if result.Total != 2 || len(result.Items) != 1 || result.Items[0].ExecutionID != records[2].ExecutionID {
t.Fatalf("unexpected filtered page: %+v", result)
}
if err := db.Delete(&job).Error; err != nil {
t.Fatal(err)
}
deleted, err := NewExecutionLogService(db).List(context.Background(), job.JobId, ExecutionLogListRequest{Page: 1, PageSize: 20})
if err != nil {
t.Fatal(err)
}
if !deleted.Job.Deleted || deleted.Job.JobName != job.JobName || deleted.Total != 3 {
t.Fatalf("deleted job history unavailable: %+v", deleted)
}
}
func TestExecutionLogListValidationAndNotFound(t *testing.T) {
db := executionLogTestDB(t)
service := NewExecutionLogService(db)
if _, err := service.List(context.Background(), 0, ExecutionLogListRequest{}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
t.Fatalf("invalid job id error = %v", err)
}
if _, err := service.List(context.Background(), 1, ExecutionLogListRequest{PageSize: 101}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
t.Fatalf("invalid page size error = %v", err)
}
job := models.SysJob{JobName: "测试任务", InvokeTarget: "TestTarget"}
if err := db.Create(&job).Error; err != nil {
t.Fatal(err)
}
if _, err := service.List(context.Background(), job.JobId, ExecutionLogListRequest{Status: "unknown"}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
t.Fatalf("invalid status error = %v", err)
}
from, to := time.Now(), time.Now().Add(-time.Hour)
if _, err := service.List(context.Background(), job.JobId, ExecutionLogListRequest{StartedFrom: &from, StartedTo: &to}); !errors.Is(err, ErrExecutionLogInvalidRequest) {
t.Fatalf("invalid range error = %v", err)
}
if _, err := service.List(context.Background(), 999, ExecutionLogListRequest{}); !errors.Is(err, ErrExecutionLogJobNotFound) {
t.Fatalf("not found error = %v", err)
}
}
+1
View File
@@ -62,6 +62,7 @@ func (e *SysJob) StartJob(c *dto.GeneralGetDto) error {
if data.JobType == 1 {
var j = &jobs.HttpJob{}
j.DB = e.Orm.WithContext(context.Background())
j.InvokeTarget = data.InvokeTarget
j.CronExpression = data.CronExpression
j.JobId = data.JobId
+19 -1
View File
@@ -54,6 +54,12 @@ var (
}
)
// The synchronous Admin AI endpoints allow a provider timeout of up to 600
// seconds and the browser waits 610 seconds. Keep the HTTP server alive a
// little longer so it can return the domain response instead of truncating
// the connection and surfacing a proxy-level 502.
const minimumAPIWriteTimeout = 620 * time.Second
var AppRouters = make([]func(), 0)
func init() {
@@ -125,11 +131,15 @@ func run() error {
)
}
writeTimeout, err := validatedAPIWriteTimeout(config.ApplicationConfig.WriterTimeout)
if err != nil {
return err
}
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
Handler: sdk.Runtime.GetEngine(),
ReadTimeout: time.Duration(config.ApplicationConfig.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(config.ApplicationConfig.WriterTimeout) * time.Second,
WriteTimeout: writeTimeout,
}
go func() {
@@ -193,6 +203,14 @@ func run() error {
return nil
}
func validatedAPIWriteTimeout(seconds int) (time.Duration, error) {
timeout := time.Duration(seconds) * time.Second
if timeout < minimumAPIWriteTimeout {
return 0, fmt.Errorf("application writetimeout must be at least %s for synchronous AI requests", minimumAPIWriteTimeout)
}
return timeout, nil
}
type policyLoader interface {
LoadPolicy() error
}
+19
View File
@@ -0,0 +1,19 @@
package api
import (
"testing"
"time"
)
func TestValidatedAPIWriteTimeoutProtectsSynchronousAIRequests(t *testing.T) {
if _, err := validatedAPIWriteTimeout(2); err == nil {
t.Fatal("two-second write timeout must be rejected")
}
got, err := validatedAPIWriteTimeout(620)
if err != nil {
t.Fatalf("620-second write timeout should be accepted: %v", err)
}
if got != 620*time.Second {
t.Fatalf("write timeout = %s, want 620s", got)
}
}
@@ -1,25 +0,0 @@
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
})
}
@@ -1,32 +0,0 @@
package version_local
import (
"context"
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/app/goauto/sybshop"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
// This migration repairs historical syb_shop.normalized_name values written
// before all shop-name write paths used sybshop.Normalize (#213).
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBShopNormalizedNameBackfill)
}
func migrateSYBShopNormalizedNameBackfill(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
if _, err := sybshop.ReconcileNormalizedNames(context.Background(), tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -1,51 +0,0 @@
package version_local
import (
"testing"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestMigrateSYBShopNormalizedNameBackfillRepairsLegacyRows(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("打开测试数据库失败: %v", err)
}
if err := db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatalf("创建迁移记录表失败: %v", err)
}
if err := goautomigrations.Migrate(db); err != nil {
t.Fatalf("创建 GoAuto 表失败: %v", err)
}
shop := models.SYBShop{DisplayName: "ABC店", NormalizedName: "ABC店", Enabled: true}
if err := db.Create(&shop).Error; err != nil {
t.Fatalf("创建历史店铺失败: %v", err)
}
const version = "1787983900000"
if err := migrateSYBShopNormalizedNameBackfill(db, version); err != nil {
t.Fatalf("执行回填迁移失败: %v", err)
}
var reloaded models.SYBShop
if err := db.First(&reloaded, shop.ID).Error; err != nil {
t.Fatalf("读取回填结果失败: %v", err)
}
if reloaded.NormalizedName != "abc店" {
t.Fatalf("迁移未回填规范化键: %q", reloaded.NormalizedName)
}
var records int64
if err := db.Model(&common.Migration{}).Where("version = ?", version).Count(&records).Error; err != nil {
t.Fatalf("读取迁移记录失败: %v", err)
}
if records != 1 {
t.Fatalf("迁移应留下一个版本记录,实际 %d", records)
}
}
@@ -0,0 +1,47 @@
package version_local
import (
"errors"
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/app/goauto/shopeeproduct"
jobsmodels "go-admin/app/jobs/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateShopeeSpecAutoMatch)
}
func migrateShopeeSpecAutoMatch(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
if err := ensureShopeeSpecAutoMatchJob(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func ensureShopeeSpecAutoMatchJob(db *gorm.DB) error {
var existing jobsmodels.SysJob
err := db.Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).First(&existing).Error
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return db.Create(&jobsmodels.SysJob{
JobName: "蝦皮规格自动匹配", JobGroup: "GoAuto", JobType: 2,
CronExpression: "0 15 * * * *", InvokeTarget: shopeeproduct.SpecAutoMatchInvokeTarget,
Args: `{"batchLimit":20}`, MisfirePolicy: 1, Concurrent: 1, Status: 1,
}).Error
}
@@ -0,0 +1,50 @@
package version_local
import (
"testing"
"go-admin/app/goauto/shopeeproduct"
jobsmodels "go-admin/app/jobs/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestEnsureShopeeSpecAutoMatchJobIsDisabledIdempotentAndPreservesChanges(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:shopee-spec-auto-match-job?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&jobsmodels.SysJob{}); err != nil {
t.Fatal(err)
}
if err := ensureShopeeSpecAutoMatchJob(db); err != nil {
t.Fatal(err)
}
var job jobsmodels.SysJob
if err := db.Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).First(&job).Error; err != nil {
t.Fatal(err)
}
if job.Status != 1 || job.CronExpression != "0 15 * * * *" || job.Args != `{"batchLimit":20}` {
t.Fatalf("unexpected seed: %+v", job)
}
if err := db.Model(&job).Updates(map[string]any{"status": 2, "cron_expression": "0 30 * * * *", "args": `{"batchLimit":5}`}).Error; err != nil {
t.Fatal(err)
}
if err := ensureShopeeSpecAutoMatchJob(db); err != nil {
t.Fatal(err)
}
var count int64
if err := db.Model(&jobsmodels.SysJob{}).Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("count=%d", count)
}
if err := db.Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).First(&job).Error; err != nil {
t.Fatal(err)
}
if job.Status != 2 || job.CronExpression != "0 30 * * * *" || job.Args != `{"batchLimit":5}` {
t.Fatalf("admin changes overwritten: %+v", job)
}
}
@@ -0,0 +1,47 @@
package version_local
import (
"errors"
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/app/goauto/sybimport"
jobsmodels "go-admin/app/jobs/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBSpecAIParse)
}
func migrateSYBSpecAIParse(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
if err := ensureSYBSpecAIParseJob(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func ensureSYBSpecAIParseJob(db *gorm.DB) error {
var existing jobsmodels.SysJob
err := db.Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).First(&existing).Error
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return db.Create(&jobsmodels.SysJob{
JobName: "SYB 异常规格 AI 解析", JobGroup: "GoAuto", JobType: 2,
CronExpression: "0 5 * * * *", InvokeTarget: sybimport.SpecAIParseInvokeTarget,
Args: `{"batchLimit":20}`, MisfirePolicy: 1, Concurrent: 1, Status: 1,
}).Error
}
@@ -0,0 +1,50 @@
package version_local
import (
"testing"
"go-admin/app/goauto/sybimport"
jobsmodels "go-admin/app/jobs/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestEnsureSYBSpecAIParseJobIsDisabledIdempotentAndPreservesChanges(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&jobsmodels.SysJob{}); err != nil {
t.Fatal(err)
}
if err := ensureSYBSpecAIParseJob(db); err != nil {
t.Fatal(err)
}
var job jobsmodels.SysJob
if err := db.Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).First(&job).Error; err != nil {
t.Fatal(err)
}
if job.Status != 1 || job.CronExpression != "0 5 * * * *" || job.Args != `{"batchLimit":20}` {
t.Fatalf("unexpected initial job: %+v", job)
}
if err := db.Model(&job).Updates(map[string]any{"status": 2, "cron_expression": "0 7 * * * *", "args": `{"batchLimit":7}`}).Error; err != nil {
t.Fatal(err)
}
if err := ensureSYBSpecAIParseJob(db); err != nil {
t.Fatal(err)
}
var count int64
if err := db.Model(&jobsmodels.SysJob{}).Where("invoke_target = ?", sybimport.SpecAIParseInvokeTarget).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("job count=%d, want 1", count)
}
if err := db.First(&job, job.JobId).Error; err != nil {
t.Fatal(err)
}
if job.Status != 2 || job.CronExpression != "0 7 * * * *" || job.Args != `{"batchLimit":7}` {
t.Fatalf("existing administrator changes were overwritten: %+v", job)
}
}
@@ -0,0 +1,68 @@
package version_local
import (
"fmt"
"runtime"
jobsmodels "go-admin/app/jobs/models"
"go-admin/cmd/migrate/migration"
migrationmodels "go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
"gorm.io/gorm"
)
const jobExecutionLogsAPIPath = "/api/v1/sysjob/:id/execution-logs"
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSysJobExecutionLog)
}
func migrateSysJobExecutionLog(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := ensureSysJobExecutionLog(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func ensureSysJobExecutionLog(db *gorm.DB) error {
if err := db.AutoMigrate(&jobsmodels.SysJobExecutionLog{}); err != nil {
return err
}
var logMenu migrationmodels.SysMenu
if err := db.Where("menu_name = ?", "JobLog").First(&logMenu).Error; err != nil {
return fmt.Errorf("find JobLog menu: %w", err)
}
api := migrationmodels.SysApi{}
if err := db.Where(migrationmodels.SysApi{Path: jobExecutionLogsAPIPath, Action: "GET"}).
Attrs(migrationmodels.SysApi{Title: "查询定时任务执行日志", Type: "BUS"}).FirstOrCreate(&api).Error; err != nil {
return err
}
if err := db.Model(&logMenu).Association("SysApi").Append(&api); err != nil {
return err
}
var roleIDs []int
if err := db.Table("sys_role_menu").Where("menu_id = ?", logMenu.MenuId).Distinct().Pluck("role_id", &roleIDs).Error; err != nil {
return err
}
if len(roleIDs) == 0 {
return nil
}
var roles []migrationmodels.SysRole
if err := db.Where("role_id IN ?", roleIDs).Find(&roles).Error; err != nil {
return err
}
for _, role := range roles {
rule := purchaserCasbinRule{Ptype: "p", V0: role.RoleKey, V1: jobExecutionLogsAPIPath, V2: "GET"}
if err := db.Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", rule.Ptype, rule.V0, rule.V1, rule.V2).
FirstOrCreate(&rule).Error; err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,78 @@
package version_local
import (
"testing"
jobsmodels "go-admin/app/jobs/models"
migrationmodels "go-admin/cmd/migrate/migration/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestEnsureSysJobExecutionLogIsIdempotentAndGrantsOnlyBoundRoles(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(
&migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &migrationmodels.SysRole{}, &purchaserCasbinRule{},
); err != nil {
t.Fatal(err)
}
menu := migrationmodels.SysMenu{MenuName: "JobLog", Title: "日志", Path: "/schedule/log", Component: "/schedule/log"}
if err := db.Create(&menu).Error; err != nil {
t.Fatal(err)
}
bound := migrationmodels.SysRole{RoleName: "日志管理员", RoleKey: "job_logger"}
unbound := migrationmodels.SysRole{RoleName: "无日志权限", RoleKey: "no_job_logs"}
if err := db.Create(&bound).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&unbound).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&bound).Association("SysMenu").Append(&menu); err != nil {
t.Fatal(err)
}
custom := purchaserCasbinRule{Ptype: "p", V0: bound.RoleKey, V1: "/custom", V2: "GET"}
if err := db.Create(&custom).Error; err != nil {
t.Fatal(err)
}
if err := ensureSysJobExecutionLog(db); err != nil {
t.Fatal(err)
}
if err := ensureSysJobExecutionLog(db); err != nil {
t.Fatalf("migration helper must be repeatable: %v", err)
}
if !db.Migrator().HasTable(&jobsmodels.SysJobExecutionLog{}) {
t.Fatal("execution log table was not created")
}
var apiCount, menuAPI, boundPolicy, unboundPolicy, customPolicy int64
db.Model(&migrationmodels.SysApi{}).Where("path = ? AND action = ?", jobExecutionLogsAPIPath, "GET").Count(&apiCount)
var api migrationmodels.SysApi
if err := db.Where("path = ? AND action = ?", jobExecutionLogsAPIPath, "GET").First(&api).Error; err != nil {
t.Fatal(err)
}
db.Table("sys_menu_api_rule").Where("sys_menu_menu_id = ? AND sys_api_id = ?", menu.MenuId, api.Id).Count(&menuAPI)
db.Model(&purchaserCasbinRule{}).Where("v0 = ? AND v1 = ? AND v2 = ?", bound.RoleKey, jobExecutionLogsAPIPath, "GET").Count(&boundPolicy)
db.Model(&purchaserCasbinRule{}).Where("v0 = ? AND v1 = ? AND v2 = ?", unbound.RoleKey, jobExecutionLogsAPIPath, "GET").Count(&unboundPolicy)
db.Model(&purchaserCasbinRule{}).Where("v0 = ? AND v1 = ?", bound.RoleKey, "/custom").Count(&customPolicy)
if apiCount != 1 || menuAPI != 1 || boundPolicy != 1 || unboundPolicy != 0 || customPolicy != 1 {
t.Fatalf("unexpected permission state api=%d menu=%d bound=%d unbound=%d custom=%d", apiCount, menuAPI, boundPolicy, unboundPolicy, customPolicy)
}
}
func TestEnsureSysJobExecutionLogRequiresExistingMenu(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &migrationmodels.SysRole{}, &purchaserCasbinRule{}); err != nil {
t.Fatal(err)
}
if err := ensureSysJobExecutionLog(db); err == nil {
t.Fatal("missing JobLog menu must fail closed")
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ settings:
# 端口号
port: 8000 # 服务端口号
readtimeout: 1
writertimeout: 2
writertimeout: 620
# 数据权限功能开关
enabledp: false
ssl:
+2 -2
View File
@@ -9,7 +9,7 @@ settings:
# 端口号
port: 8000 # 服务端口号
readtimeout: 1
writertimeout: 2
writertimeout: 620
# 数据权限功能开关
enabledp: false
logger:
@@ -84,4 +84,4 @@ settings:
# blockingTimeout: 5
# reclaimInterval: 1
locker:
redis:
redis:
-7
View File
@@ -21,10 +21,3 @@ export function revokeDeviceToken(deviceId) {
method: 'post'
})
}
export function resetDeviceIdentity(deviceId) {
return request({
url: `/api/admin/v1/devices/${deviceId}/identity-reset`,
method: 'post'
})
}
+8
View File
@@ -70,6 +70,14 @@ 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 startShopeeSpecAutoMatchRun(data) {
return request({ url: '/api/admin/v1/shopee-spec-auto-match/runs', method: 'post', data })
}
export function getLatestShopeeSpecAutoMatchRun() {
return request({ url: '/api/admin/v1/shopee-spec-auto-match/runs/latest', method: 'get' })
}
export function batchDeleteShopeeProducts(data) {
return request({ url: '/api/admin/v1/shopee-products/batch-delete', method: 'post', data })
}
+9 -1
View File
@@ -17,6 +17,15 @@ export function getSysJob(jobId) {
})
}
// 查询指定定时任务的持久化执行历史
export function listJobExecutionLogs(jobId, query) {
return request({
url: '/api/v1/sysjob/' + jobId + '/execution-logs',
method: 'get',
params: query
})
}
// 新增SysJob
export function addSysJob(data) {
return request({
@@ -59,4 +68,3 @@ export function startJob(jobId) {
method: 'get'
})
}
+2 -18
View File
@@ -68,14 +68,8 @@
</span>
</template>
</el-table-column>
<el-table-column v-if="isAdmin" label="操作" width="270" fixed="right">
<el-table-column v-if="isAdmin" label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button
type="warning"
link
:disabled="row.status === 'disabled'"
@click="confirmIdentityReset(row)"
>重置设备身份</el-button>
<el-button
type="danger"
link
@@ -122,7 +116,7 @@
<script>
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
import { disableDevice, listDevices, resetDeviceIdentity, revokeDeviceToken } from '@/api/goauto/devices'
import { disableDevice, listDevices, revokeDeviceToken } from '@/api/goauto/devices'
import { downloadAgentAppRelease, listAgentAppReleases, setCurrentAgentAppRelease, uploadAgentAppRelease } from '@/api/goauto/agent-app-releases'
import { createRequestId } from '@/utils/request-id'
@@ -197,16 +191,6 @@ 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()
}
}
}
+12 -6
View File
@@ -1,7 +1,7 @@
<template>
<BasicLayout><template #wrapper>
<el-card class="page-card" shadow="never">
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search"><el-form-item><el-button type="primary" :icon="Plus" @click="openCreate">添加</el-button></el-form-item><el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="虾皮商品ID、标题或店铺" clearable @keyup.enter="search" /></el-form-item><el-form-item label="范围"><el-select v-model="query.status" style="width:150px"><el-option label="全部(不含已删除)" value="" /><el-option label="已删除" value="deleted" /></el-select></el-form-item><el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item><el-form-item><el-button :icon="Delete" type="danger" plain :disabled="selectedProducts.length === 0" @click="openBatchDelete">删除{{ selectedProducts.length ? ` (${selectedProducts.length})` : '' }}</el-button></el-form-item></el-form>
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search"><el-form-item><el-button type="primary" :icon="Plus" @click="openCreate">添加</el-button></el-form-item><el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="虾皮商品ID、标题或店铺" clearable @keyup.enter="search" /></el-form-item><el-form-item label="范围"><el-select v-model="query.status" style="width:150px"><el-option label="全部(不含已删除)" value="" /><el-option label="已删除" value="deleted" /></el-select></el-form-item><el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item><el-form-item><el-button :icon="Delete" type="danger" plain :disabled="selectedProducts.length === 0" @click="openBatchDelete">删除{{ selectedProducts.length ? ` (${selectedProducts.length})` : '' }}</el-button></el-form-item><el-form-item v-if="isAdmin"><el-button type="success" plain :loading="autoMatchRun.submitting" :disabled="autoMatchRun.latest?.status === 'running'" @click="startAutoMatchRun">{{ autoMatchRun.latest?.status === 'running' ? '自动匹配进行中' : '执行规格自动匹配' }}</el-button><span class="run-summary">{{ autoMatchRunSummary }}</span></el-form-item></el-form>
<el-alert v-if="query.status === 'deleted'" title="当前显示已删除商品,可逐条恢复。恢复后原有 PDD 关联与规格映射保持不变。" type="warning" :closable="false" show-icon class="notice" />
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无虾皮商品" @selection-change="handleSelectionChange"><el-table-column v-if="query.status !== 'deleted'" type="selection" width="48" /><el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column><el-table-column label="虾皮商品ID" prop="shopeeItemId" min-width="140" /><el-table-column label="标题 / 店铺" min-width="220"><template #default="{ row }"><div class="primary">{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '尚未填写店铺' }}</div></template></el-table-column><el-table-column label="售价" width="120"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column><el-table-column label="PDD 商品" min-width="160"><template #default="{ row }"><a v-if="row.pddProductId" class="link" href="javascript:void(0)" @click="openPddDetail(row.pddProductId)">PDD-{{ row.pddProductId }} ↗</a><span v-else class="muted">未关联</span></template></el-table-column><el-table-column label="映射状态" min-width="180"><template #default="{ row }"><el-tag :type="mappingMeta(row).type">{{ mappingMeta(row).label }}</el-tag></template></el-table-column><el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="row.deleted" type="primary" link @click="restore(row)">恢复</el-button><el-button v-else type="primary" link @click="openDetail(row.id)">详情</el-button></template></el-table-column></el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" /><p class="scope-note">本页不支持按虾皮订单号搜索——商品档案不含订单数据;订单相关字段在 SYB 商品模块与采购任务模块查看。</p>
@@ -19,9 +19,9 @@
</template>
<script>
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, RefreshLeft, Search, Delete } from '@element-plus/icons-vue'
import { listShopeeProducts, createShopeeProduct, restoreShopeeProduct, batchDeleteShopeeProducts } from '@/api/goauto/shopee-products'
import { listShopeeProducts, createShopeeProduct, restoreShopeeProduct, batchDeleteShopeeProducts, startShopeeSpecAutoMatchRun, getLatestShopeeSpecAutoMatchRun } from '@/api/goauto/shopee-products'
import { listPddProducts } from '@/api/goauto/pdd-products'
import { createRequestId } from '@/utils/request-id'
import ShopeeProductDetailDrawer from './ShopeeProductDetailDrawer.vue'
@@ -29,10 +29,16 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
export default {
name: 'GoAutoShopeeProducts', components: { ShopeeProductDetailDrawer, PddProductDetailDrawer }, setup() { return { Plus, RefreshLeft, Search, Delete } },
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, createDialog: { open: false, saving: false }, createData: this.emptyCreate(), createRules: { shopeeItemId: [{ required: true, message: '请输入虾皮商品ID', trigger: 'blur' }, { max: 64, message: '不能超过 64 个字符', trigger: 'blur' }] }, quickColor: '', quickSize: '', colorValues: [], sizeValues: [], pddPicker: { open: false, loading: false, keyword: '', items: [] }, batchDelete: this.emptyBatchDelete(), detail: { open: false, productId: null, targetColor: '', action: '' }, pddDetail: { open: false, productId: null }} },
watch: { '$route.query': { deep: true, handler() { this.applyRouteDetail() } }}, created() { this.load(); this.applyRouteDetail() }, activated() { this.applyRouteDetail() },
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, createDialog: { open: false, saving: false }, createData: this.emptyCreate(), createRules: { shopeeItemId: [{ required: true, message: '请输入虾皮商品ID', trigger: 'blur' }, { max: 64, message: '不能超过 64 个字符', trigger: 'blur' }] }, quickColor: '', quickSize: '', colorValues: [], sizeValues: [], pddPicker: { open: false, loading: false, keyword: '', items: [] }, batchDelete: this.emptyBatchDelete(), detail: { open: false, productId: null, targetColor: '', action: '' }, pddDetail: { open: false, productId: null }, autoMatchRun: { submitting: false, latest: null }, autoMatchPollTimer: null } },
computed: {
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
autoMatchRunSummary() { const run = this.autoMatchRun.latest; if (!run) return '最近一次:暂无'; if (run.status === 'running') return `最近一次:运行中,已处理 ${run.processedCount || 0}`; const status = run.status === 'completed' ? '完成' : run.status === 'completed_partial' ? '部分完成' : '失败'; return `最近一次:${status},处理 ${run.processedCount || 0},确认 ${run.confirmedCount || 0},未匹配 ${run.unmatchedCount || 0}` }
},
watch: { '$route.query': { deep: true, handler() { this.applyRouteDetail() } }}, created() { this.load(); this.applyRouteDetail(); if (this.isAdmin) this.loadLatestAutoMatchRun() }, activated() { this.applyRouteDetail(); if (this.isAdmin) this.loadLatestAutoMatchRun() }, beforeUnmount() { if (this.autoMatchPollTimer) clearTimeout(this.autoMatchPollTimer) },
methods: {
emptyCreate() { return { shopeeItemId: '', title: '', shopName: '', pddProductId: null } }, emptyBatchDelete() { return { open: false, saving: false, step: 'confirm', products: [], results: [], deletedCount: 0, skippedCount: 0 } },
async loadLatestAutoMatchRun() { if (!this.isAdmin) return; const response = await getLatestShopeeSpecAutoMatchRun(); this.autoMatchRun.latest = response.data.run || null; if (this.autoMatchPollTimer) clearTimeout(this.autoMatchPollTimer); if (this.autoMatchRun.latest?.status === 'running') this.autoMatchPollTimer = setTimeout(() => this.loadLatestAutoMatchRun(), 2000) },
async startAutoMatchRun() { await ElMessageBox.confirm('将在后台处理最多 20 个符合条件的商品,并可能调用 AI。不会创建采购任务或订单。是否继续?', '执行规格自动匹配', { type: 'warning', confirmButtonText: '开始执行', cancelButtonText: '取消' }); this.autoMatchRun.submitting = true; try { const response = await startShopeeSpecAutoMatchRun({ requestId: createRequestId() }); this.autoMatchRun.latest = response.data.run; ElMessage.success(response.data.run.alreadyRunning ? '已有自动匹配正在运行' : '已开始后台匹配'); await this.loadLatestAutoMatchRun() } finally { this.autoMatchRun.submitting = false } },
applyRouteDetail() { const id = Number(this.$route.query.productId); if (!Number.isInteger(id) || id <= 0) return; this.detail = { open: true, productId: id, targetColor: String(this.$route.query.targetColor || '').trim(), action: String(this.$route.query.action || '') } },
async load() { this.loading = true; this.selectedProducts = []; this.$refs.productTable?.clearSelection(); try { const r = await listShopeeProducts(this.query); this.products = r.data.items; this.total = r.data.total } finally { this.loading = false } }, search() { this.query.page = 1; this.load() }, reset() { this.query = { page: 1, pageSize: 20, keyword: '', status: '' }; this.load() }, handleSelectionChange(rows) { this.selectedProducts = rows },
priceText(row) { if (row.salePriceCent === null || row.salePriceCent === undefined) return '—'; return `${row.currency || ''} ${(row.salePriceCent / 100).toFixed(2)}` }, mappingMeta(product) { if (!product.pddProductId) return { label: '未关联 PDD 商品', type: 'info' }; const values = (product.specs || []).flatMap(d => d.values); if (!values.length) return { label: '待完善', type: 'warning' }; const confirmed = values.filter(v => v.mapping?.status === 'confirmed').length; return confirmed === values.length ? { label: '可采购', type: 'success' } : { label: `待完善 · ${confirmed}/${values.length} 已确认`, type: 'warning' } },
@@ -45,5 +51,5 @@ export default {
}
</script>
<style lang="scss" scoped>
.page-card{min-height:calc(100vh - 124px)}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.thumb{width:48px;height:48px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.link{color:#1677ff;cursor:pointer}.scope-note{margin-top:12px;font-size:12px;color:#b91c1c}.notice{margin-bottom:16px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.spec-quick-add{display:flex;flex-direction:column;gap:8px;width:100%}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.chips{display:flex;flex-wrap:wrap;gap:6px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.create-help{margin-left:8px}@media(max-width:768px){.form-grid{grid-template-columns:1fr}}
.page-card{min-height:calc(100vh - 124px)}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.run-summary{margin-left:10px;font-size:12px;color:#606266}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.thumb{width:48px;height:48px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.link{color:#1677ff;cursor:pointer}.scope-note{margin-top:12px;font-size:12px;color:#b91c1c}.notice{margin-bottom:16px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.spec-quick-add{display:flex;flex-direction:column;gap:8px;width:100%}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.chips{display:flex;flex-wrap:wrap;gap:6px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.create-help{margin-left:8px}@media(max-width:768px){.form-grid{grid-template-columns:1fr}}
</style>
+24
View File
@@ -0,0 +1,24 @@
export function jobLogRoute(selectedIds) {
if (!Array.isArray(selectedIds) || selectedIds.length !== 1) return null
const jobId = Number(selectedIds[0])
if (!Number.isInteger(jobId) || jobId < 1) return null
return { name: 'JobLog', query: { jobId: String(jobId) }}
}
export function executionStatusMeta(status) {
return {
running: { label: '执行中', type: 'primary' },
succeeded: { label: '成功', type: 'success' },
failed: { label: '失败', type: 'danger' },
interrupted: { label: '已中断', type: 'warning' }
}[status] || { label: status || '未知', type: 'info' }
}
export function executionLogQuery(query, dateRange) {
const result = { ...query }
if (Array.isArray(dateRange) && dateRange.length === 2) {
result.startedFrom = new Date(dateRange[0]).toISOString()
result.startedTo = new Date(dateRange[1]).toISOString()
}
return result
}
+4 -2
View File
@@ -55,7 +55,7 @@
<el-button v-permisaction="['job:sysJob:add']" type="primary" size="small" :icon="Plus" @click="handleAdd">新增</el-button>
<el-button v-permisaction="['job:sysJob:edit']" type="primary" size="small" :icon="Edit" :disabled="single" @click="handleUpdate">修改</el-button>
<el-button v-permisaction="['job:sysJob:remove']" type="danger" size="small" :icon="Delete" :disabled="multiple" @click="handleDelete">删除</el-button>
<el-button v-permisaction="['job:sysJob:log']" size="small" @click="handleLog">日志</el-button>
<el-button v-permisaction="['job:sysJob:log']" size="small" :disabled="single" @click="handleLog">日志</el-button>
</div>
<el-table v-loading="loading" :data="sysjobList" border stripe @selection-change="handleSelectionChange">
@@ -267,6 +267,7 @@
<script>
import { addSysJob, delSysJob, getSysJob, listSysJob, updateSysJob, removeJob, startJob } from '@/api/job/sys-job'
import { Search, Refresh, Plus, Edit, Delete } from '@element-plus/icons-vue'
import { jobLogRoute } from './execution-log'
export default {
name: 'SysJobManage',
@@ -500,7 +501,8 @@ export default {
}).catch(function() {})
},
handleLog() {
this.$router.push({ name: 'job_log', params: { }})
const route = jobLogRoute(this.ids)
if (route) this.$router.push(route)
},
handleSybSyncRuns() {
this.$router.push('/syb-sync-runs/index')
+86 -71
View File
@@ -1,101 +1,116 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<el-form>
<el-card class="page-card" shadow="never">
<div class="page-heading">
<div>
<div class="eyebrow">定时任务 / 执行日志</div>
<h1>{{ job.jobName || '定时任务执行日志' }}<span v-if="job.jobId">(#{{ job.jobId }})</span></h1>
<p>查看该任务每次调度的开始时间、结果、耗时和脱敏错误摘要。</p>
</div>
<el-button :icon="ArrowLeft" @click="backToList">返回任务列表</el-button>
</div>
<el-alert v-if="job.deleted" title="该定时任务已删除,以下为保留的历史执行记录。" type="warning" show-icon :closable="false" class="notice" role="status" />
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<el-form-item label="状态">
<el-select v-model="query.status" clearable placeholder="全部状态" style="width:150px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="开始时间">
<el-date-picker v-model="dateRange" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" />
</el-form-item>
<el-form-item>
<el-button type="success" size="mini">状态</el-button>
<el-button type="primary" size="mini">清空</el-button>
<el-button type="primary" :icon="Search" :disabled="!jobId" @click="search">查询</el-button>
<el-button :icon="RefreshLeft" :disabled="!jobId" @click="reset">重置</el-button>
</el-form-item>
</el-form>
<el-row ref="log" :gutter="10" class="mb8">
<el-scrollbar style="height:500px;background-color: black;color: cornflowerblue;">
<ul
style="line-height: 25px;padding-top: 15px;padding-bottom: 15px;min-height: 500px; margin: 0;list-style-type: none;"
>
<li v-for="(item,index) in arrs" :key="index">
{{ item }}
</li>
</ul>
</el-scrollbar>
</el-row>
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="notice" role="alert">
<template #default><el-button v-if="jobId" link type="primary" @click="load">重新加载</el-button></template>
</el-alert>
<el-table v-loading="loading" :data="items" border stripe :empty-text="jobId ? '该任务尚无执行记录' : '请从定时任务列表选择一条任务'">
<el-table-column label="执行标识" prop="executionId" min-width="205" show-overflow-tooltip />
<el-table-column label="开始时间" min-width="175"><template #default="{ row }">{{ formatTime(row.startedAt) }}</template></el-table-column>
<el-table-column label="结束时间" min-width="175"><template #default="{ row }">{{ row.status === 'running' ? '执行中' : formatTime(row.finishedAt) }}</template></el-table-column>
<el-table-column label="状态" width="105"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag></template></el-table-column>
<el-table-column label="耗时" width="105"><template #default="{ row }">{{ row.status === 'running' ? '执行中' : formatDuration(row.durationMs) }}</template></el-table-column>
<el-table-column label="触发方式" width="110"><template #default="{ row }">{{ row.triggerType === 'scheduled' ? '定时调度' : row.triggerType }}</template></el-table-column>
<el-table-column label="错误摘要" min-width="260"><template #default="{ row }"><span v-if="row.errorCode" class="error-code">{{ row.errorCode }}</span>{{ row.errorMessage || '—' }}</template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
</el-card>
</template>
</BasicLayout>
</template>
<script>
import { ArrowLeft, RefreshLeft, Search } from '@element-plus/icons-vue'
import { listJobExecutionLogs } from '@/api/job/sys-job'
import { executionLogQuery, executionStatusMeta } from './execution-log'
import { unWsLogout } from '@/api/ws'
export default {
name: 'SysJobLogManage',
setup() { return { ArrowLeft, RefreshLeft, Search } },
data() {
return {
websock: null,
arrs: [],
id: undefined,
group: undefined
loading: false, loadError: '', job: {}, items: [], total: 0, dateRange: null, pollTimer: null,
query: { pageIndex: 1, pageSize: 20, status: '' },
statusOptions: [
{ label: '执行中', value: 'running' }, { label: '成功', value: 'succeeded' },
{ label: '失败', value: 'failed' }, { label: '已中断', value: 'interrupted' }
]
}
},
computed: {
jobId() {
const value = Number(this.$route.query.jobId)
return Number.isInteger(value) && value > 0 ? value : 0
}
},
created() {
this.id = this.guid()
this.group = 'log'
this.initWebSocket()
},
unmounted() {
console.log('断开websocket连接')
this.websock.close() // 离开路由之后断开websocket连接
unWsLogout(this.id, this.group).then(response => {
console.log(response.data)
if (!this.jobId) {
this.loadError = '缺少有效的定时任务编号,请返回列表重新选择。'
return
}
)
this.load()
},
beforeUnmount() { this.stopPolling() },
methods: {
initWebSocket() { // 初始化weosocket
console.log(this.$store.state.user.token)
const wsuri = 'ws://127.0.0.1:8000/ws/' + this.id + '/' + this.group + '?token=' + this.$store.state.user.token
this.websock = new WebSocket(wsuri)
this.websock.onmessage = this.websocketonmessage
this.websock.onopen = this.websocketonopen
this.websock.onerror = this.websocketonerror
this.websock.onclose = this.websocketclose
statusMeta: executionStatusMeta,
formatTime(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' },
formatDuration(value) {
const ms = Number(value) || 0
if (ms < 1000) return `${ms}ms`
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`
},
websocketonopen() { // 连接建立之后执行send方法发送数据
console.log('连接打开')
// const actions = { 'test': '12345' }
// this.websocketsend(JSON.stringify(actions))
},
websocketonerror() { // 连接建立失败重连
this.initWebSocket()
},
websocketonmessage(e) { // 数据接收
console.log(e.data)
// console.log(this.binaryAgent(e))
// const redata = JSON.parse(e.data)
// console.log(redata)
// this.$refs.log.innerText = e.data + '\n' + this.$refs.log.innerText
this.arrs.unshift(e.data)
},
websocketsend(Data) { // 数据发送
// this.websock.send(Data)
},
websocketclose(e) { // 关闭
unWsLogout(this.id, this.group).then(response => {
console.log(response.data)
async load() {
if (!this.jobId) return
this.loading = true
this.loadError = ''
try {
const response = await listJobExecutionLogs(this.jobId, executionLogQuery(this.query, this.dateRange))
this.job = response.data.job
this.items = response.data.items
this.total = response.data.total
this.items.some(item => item.status === 'running') ? this.startPolling() : this.stopPolling()
} catch (error) {
this.loadError = error?.response?.data?.msg || error?.message || '执行日志加载失败'
} finally {
this.loading = false
}
)
console.log('断开连接', e)
},
guid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0; var v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}
search() { this.query.pageIndex = 1; this.load() },
reset() { this.dateRange = null; this.query = { pageIndex: 1, pageSize: 20, status: '' }; this.load() },
backToList() { this.$router.push('/schedule/manage') },
startPolling() { if (!this.pollTimer) this.pollTimer = window.setInterval(() => this.load(), 5000) },
stopPolling() { if (this.pollTimer) { window.clearInterval(this.pollTimer); this.pollTimer = null } }
}
}
</script>
<style lang="scss" scoped>
.page-card{min-height:calc(100vh - 124px)}.page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px}.page-heading h1{margin:4px 0 6px;color:var(--el-text-color-primary);font-size:24px}.page-heading p{margin:0;color:var(--el-text-color-regular);line-height:1.5}.eyebrow{color:var(--el-text-color-secondary);font-size:13px}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid var(--el-border-color);border-radius:8px;background:var(--el-fill-color-lighter)}.notice{margin-bottom:16px}.error-code{display:inline-block;margin-right:8px;color:var(--el-color-danger);font-family:Consolas,monospace;font-size:12px}@media(max-width:800px){.page-heading{flex-direction:column}.search-form :deep(.el-date-editor){width:100%}}
</style>
@@ -0,0 +1,39 @@
import { expect, test } from '@playwright/test'
async function authenticate(context: any) {
await context.addCookies([{ name: 'Admin-Token', value: 'job-log-test-token', domain: 'localhost', path: '/' }])
}
test('单选定时任务后进入对应的持久化执行日志', async ({ page, context }) => {
await authenticate(context)
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: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: ['job:sysJob:log'] } } })
}
if (url.pathname.endsWith('/api/v1/menurole')) {
return route.fulfill({ json: { code: 200, data: [{ path: '/schedule', component: 'Layout', visible: '0', menuName: 'Schedule', title: '定时任务', icon: 'time', children: [{ path: 'manage', component: '/schedule/index', visible: '0', menuName: 'ScheduleManage', title: '定时任务' }, { path: 'log', component: '/schedule/log', visible: '1', menuName: 'JobLog', title: '执行日志' }] }] } })
}
if (url.pathname.endsWith('/api/v1/sysjob/5/execution-logs')) {
return route.fulfill({ json: { code: 200, data: { job: { jobId: 5, jobName: 'SYB 规格 AI 修复', invokeTarget: 'GoAutoSYBSpecAIParse', deleted: false }, items: [{ id: 21, executionId: '00000000-0000-4000-8000-000000000021', jobId: 5, jobName: 'SYB 规格 AI 修复', invokeTarget: 'GoAutoSYBSpecAIParse', triggerType: 'scheduled', status: 'failed', startedAt: '2026-09-02T01:00:00Z', finishedAt: '2026-09-02T01:00:03Z', durationMs: 3000, errorCode: 'JOB_EXECUTION_FAILED', errorMessage: '任务执行失败,请查看受控服务日志' }], total: 1, page: 1, pageSize: 20 } } })
}
if (url.pathname.endsWith('/api/v1/sysjob')) {
return route.fulfill({ json: { code: 200, data: { list: [{ jobId: 5, jobName: 'SYB 规格 AI 修复', jobGroup: 'GoAuto', cronExpression: '0 0 * * * *', invokeTarget: 'GoAutoSYBSpecAIParse', status: 2, entry_id: 0 }], count: 1 } } })
}
return route.fulfill({ json: { code: 200, data: [] } })
})
await page.goto('/#/schedule/manage')
const logButton = page.getByRole('button', { name: '日志', exact: true })
await expect(page.getByText('SYB 规格 AI 修复', { exact: true })).toBeVisible()
await expect(logButton).toBeDisabled()
await page.locator('.el-table__body-wrapper .el-checkbox').first().click()
await expect(logButton).toBeEnabled()
await logButton.click()
await expect(page).toHaveURL(/#\/schedule\/log\?jobId=5$/)
await expect(page.getByRole('heading', { name: 'SYB 规格 AI 修复(#5)' })).toBeVisible()
await expect(page.getByText('JOB_EXECUTION_FAILED')).toBeVisible()
await expect(page.getByText('任务执行失败,请查看受控服务日志')).toBeVisible()
})
@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test'
const routes = [{ path: '/collection-purchase', component: 'Layout', menuName: 'GoAutoCollectionPurchase', title: '采集采购', visible: '0', children: [{ path: '/shopee-products', component: '/goauto/shopee-products/index', menuName: 'GoAutoShopeeProducts', title: '虾皮商品', visible: '0' }] }]
test('管理员可确认并手动启动规格自动匹配且查看摘要', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
let started = false
let requestBody: 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: ['admin'], 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: [], total: 0, page: 1, pageSize: 20 }}})
if (url.pathname.endsWith('/api/admin/v1/shopee-spec-auto-match/runs/latest')) {
const run = started ? { id: 8, status: 'completed', processedCount: 12, confirmedCount: 9, unmatchedCount: 3 } : null
return route.fulfill({ json: { code: 200, data: { run }}})
}
if (url.pathname.endsWith('/api/admin/v1/shopee-spec-auto-match/runs')) {
requestBody = route.request().postDataJSON()
started = true
return route.fulfill({ status: 202, json: { code: 200, data: { run: { id: 8, status: 'running', processedCount: 0 }}}})
}
return route.fulfill({ json: { code: 200, data: [] }})
})
await page.goto('/#/shopee-products')
await page.getByRole('button', { name: '执行规格自动匹配', exact: true }).click()
await expect(page.getByText('将在后台处理最多 20 个符合条件的商品,并可能调用 AI。不会创建采购任务或订单。是否继续?', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '开始执行', exact: true }).click()
await expect(page.getByText('最近一次:完成,处理 12,确认 9,未匹配 3', { exact: true })).toBeVisible()
expect(String(requestBody?.requestId || '')).toMatch(/^[0-9a-f-]{36}$/)
})
@@ -0,0 +1,26 @@
import { executionLogQuery, executionStatusMeta, jobLogRoute } from '@/views/schedule/execution-log'
describe('scheduled job execution log helpers', () => {
test('only one valid selection can navigate to JobLog', () => {
expect(jobLogRoute([])).toBeNull()
expect(jobLogRoute([1, 2])).toBeNull()
expect(jobLogRoute(['invalid'])).toBeNull()
expect(jobLogRoute([5])).toEqual({ name: 'JobLog', query: { jobId: '5' }})
})
test('builds RFC3339 date filters without mutating paging', () => {
const from = new Date('2026-09-02T10:00:00+08:00')
const to = new Date('2026-09-02T11:00:00+08:00')
expect(executionLogQuery({ pageIndex: 2, pageSize: 20, status: 'failed' }, [from, to])).toEqual({
pageIndex: 2, pageSize: 20, status: 'failed',
startedFrom: from.toISOString(), startedTo: to.toISOString()
})
})
test('maps all persisted statuses', () => {
expect(executionStatusMeta('running').label).toBe('执行中')
expect(executionStatusMeta('succeeded').type).toBe('success')
expect(executionStatusMeta('failed').type).toBe('danger')
expect(executionStatusMeta('interrupted').label).toBe('已中断')
})
})