Compare commits

..
135 changed files with 4086 additions and 3774 deletions
+23 -5
View File
@@ -1,11 +1,17 @@
# Agent 开发规则
本仓库采用精简的单人 DevHarness 工作流:Gitea 工单是单次任务需求、变化、实现、测试、提交和验收的唯一事实来源;Gitea Wiki 只维护长期产品、架构、契约、业务规则、安全边界和运行说明;Git 保存源码、迁移、测试、版本绑定资料和 Wiki 的本地镜像。`docs/` 中显式映射的 Markdown 是 Wiki 只读镜像;既有 Wiki 任务归档和 `docs/task/` 仅作历史兼容,只有用户明确要求专项快照时才创建或导出。
本仓库采用轻量治理的精简单人 DevHarness 工作流:需要工单的任务以 Gitea 工单作为单次需求、变化、实现、测试、提交和验收的事实来源;Gitea Wiki 只维护长期产品、架构、契约、业务规则、安全边界和运行说明;Git 保存源码、迁移、测试、版本绑定资料和 Wiki 的本地镜像。`docs/` 中显式映射的 Markdown 是 Wiki 只读镜像;既有 Wiki 任务归档和 `docs/task/` 仅作历史兼容,只有用户明确要求专项快照时才创建或导出。
当前文档规则参考 DevHarness 提交 `4bbacf4d7fb265984396bb5589c544105043fa0b`,但所有模板内容都必须按 GoAuto 事实改写。开始工作前阅读任务涉及目录中的 `AGENTS.md`。不同交付单元规则不同时,在 `server/`、`web/` 或 `android/` 下增加更具体的 `AGENTS.md`;目录越深的规则越具体,但不得削弱上级安全规则。
当前文档规则参考 DevHarness 提交 `ecab899`,但所有模板内容都必须按 GoAuto 事实改写。开始工作前阅读任务涉及目录中的 `AGENTS.md`。不同交付单元规则不同时,在 `server/`、`web/` 或 `android/` 下增加更具体的 `AGENTS.md`;目录越深的规则越具体,但不得削弱上级安全规则。
[项目档案](docs/00-project-profile.md) 按需阅读,不作为每次任务的固定前置。出现下列情况之一时必须读:需要环境、配置或凭据来源;需要确认目录边界;需要判断子项目与交付单元划分;需要 DevHarness 来源与基线;需要项目专用验收要求。只为查命令不必打开项目档案。涉及采集、采购或设备行为时另读 `docs/03-business-rules-and-glossary.md` 和当前工单。
## 语言与术语
- 用户可以使用中文、英文或合理的中英混合语言交流;默认使用中文分析、回复、编写工单和维护内部项目文档。
- 代码标识符、命令、参数、路径、文件名、API 名称、协议名、日志和错误原文保持原样;必要时补充简短中文解释。
- 用户明确要求某次回复或交付物使用其他语言时,按该次要求执行,不改写接口契约或影响搜索和执行的原文。
## 常用命令
所有命令默认从仓库根目录执行。
@@ -16,6 +22,7 @@
| 检查模板结构 | `python dev_scripts/harness.py check --strict` |
| 导出核心 Wiki 镜像 | `python dev_scripts/harness.py sync` |
| 检查核心 Wiki 镜像 | `python dev_scripts/harness.py sync --check` |
| 深度检查核心 Wiki 镜像 | `python dev_scripts/harness.py sync --deep-check` |
| 导出并完整校验 | `python dev_scripts/harness.py sync --verify` |
| 创建可选任务快照 | `python dev_scripts/harness.py archive 123 "修复登录超时"` |
| 增量导出已有快照 | `python dev_scripts/harness.py export` |
@@ -40,18 +47,28 @@
- 测试结果必须真实;未执行或无法覆盖的真机、多设备、云环境和高风险行为必须明确记录。
- 高风险修改必须停止并等待人工确认:创建订单、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作。
### 项目治理模式与明确授权后的执行
- GoAuto 默认采用轻量治理:文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及涉及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建单。
- 采购、创建订单、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作按高风险任务处理;未取得有效人工授权时必须停止。
- 当前聊天中用户给出的明确指令,或 Gitea 工单中能够归属于有权人工的明确授权,可以作为执行依据,不要求把同一授权重复复制到工单后再次确认。
- 授权必须能识别操作、对象和范围;Agent 自动生成的工单、草稿、摘要或对用户意图的转述不能单独构成人工授权。
- 获得有效授权后,只核对准确目标、授权范围和当前状态等最小必要前提,不得仅因操作不可逆而重复询问或拒绝。
- 授权不自动覆盖相邻对象或后续任务;环境、对象、范围或影响发生实质变化时必须重新确认。平台自身强制的审批、安全策略或权限限制继续有效。
## 2. 哪些改动需要工单
新功能、缺陷修复、重构,以及接口、数据库、权限、并发、状态机、安全或用户界面变化必须先有单元工单。
完整独立需求、新页面、跨模块功能、范围不明确的变化,以及接口、数据库、权限、并发、状态机或安全边界变化必须先有单元工单。采购、创建订单、数据库迁移、删除数据、发布和其他不可逆操作无论规模大小都必须建单。
以下小改动只有在范围明确、容易回退且不涉及上面的必须建单项时才可以直接提交:
以下低风险改动在范围明确、容易回退、不改变接口、数据结构、权限或安全边界,且不属于上述高风险事项时可以直接提交:
- 只改错别字、注释或文档措辞;
- 只做格式化、导入排序或不跨文件的内部变量改名;
- 补充类型标注或文档字符串且不改变行为;
- 补充不改变产品行为的测试;
- 删除已经确认无人使用的死代码;
- 修复单文件、低风险且只恢复已有明确行为的缺陷;
- 修复预期行为明确、影响局限于单个模块的低风险缺陷;
- 不改变对外行为、入口、配置和验证方式的局部内部重构;
- 只修改用户看到的界面显示文案,并且满足本文件「工单与设计证据双门禁」的全部豁免条件。
直接提交仍须保护无关改动、执行受影响范围的最小验证并写清提交说明。有任何不确定,或涉及接口、数据库、状态、权限、安全、并发、用户界面时,必须退回单元工单流程。代码组件名、类名、变量、国际化键、API 字段和数据库字段不是显示文案,不适用豁免。
@@ -184,6 +201,7 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明
- 提交只包含当前工单相关文件,提交信息引用工单号。
- 优先运行项目档案记录的格式、单元、契约和集成测试。
- Windows 环境优先使用当前已配置的 PowerShell;可选择时优先 PowerShell 7 `pwsh.exe`,不得仅为设置编码重复启动一层 PowerShell。
- Windows 命令不得默认套用 Bash 语法。复杂正则优先使用变量或 `rg -e`;包含引号和换行的脚本正文优先使用单引号 PowerShell here-string;`foreach`、`if` 等语句块保持在同一个 PowerShell 解析上下文中;`rg` 使用真实目录配合 `-g/--glob`,不要把 Bash 风格通配路径作为目录参数。
- 文本文件读写在命令支持时显式指定 UTF-8;文件解码和控制台输出分别处理,只有出现真实乱码或已知宿主非 UTF-8 时才设置当前进程的输出编码或 Python UTF-8 环境变量。
- 不得默认使用 `-ExecutionPolicy Bypass`;只有可信 `.ps1` 确实被执行策略阻止且没有更小替代方案时,才对该次进程使用并在工单记录原因。
- 涉及创建订单、权限、安全、并发、迁移和删除数据属于高风险,真机或正式实施前必须再次等待人工确认。
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 59
versionName = "0.9.46"
versionCode = 51
versionName = "0.9.38"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -49,7 +49,6 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
screenPolicyHandler.removeCallbacks(screenPolicyRefresh)
screenPolicyHandler.post(screenPolicyRefresh)
}
@@ -18,8 +18,6 @@ import android.util.Log
import android.view.Display
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import cn.ilapage.goauto.agent.ui.PurchaseResultBubbleController
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePresentation
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@@ -34,9 +32,6 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
ActivityEvidenceTracker { packageName, className -> isDeclaredActivity(packageName, className) }
}
private var accessibilityButtonCallback: AccessibilityButtonController.AccessibilityButtonCallback? = null
private val purchaseResultBubble by lazy {
PurchaseResultBubbleController(this) { currentPackage() == PDD_PACKAGE }
}
override fun onServiceConnected() {
serviceInfo = serviceInfo.apply {
@@ -53,19 +48,18 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
val foregroundPackage = event.packageName?.toString()
if (foregroundPackage != null && foregroundPackage != lastForegroundPackage) {
lastForegroundPackage = foregroundPackage
val packageName = event.packageName?.toString()
if (packageName != null && packageName != lastForegroundPackage) {
lastForegroundPackage = packageName
foregroundRevision.incrementAndGet()
}
if (foregroundPackage == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime())
activityTracker.observe(foregroundPackage, event.className?.toString())
if (packageName == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime())
activityTracker.observe(packageName, event.className?.toString())
}
}
override fun onInterrupt() = Unit
override fun onDestroy() {
dismissPurchaseResultBubble()
unregisterAccessibilityButton()
if (instance === this) instance = null
super.onDestroy()
@@ -77,7 +71,6 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
runCatching {
val callback = object : AccessibilityButtonController.AccessibilityButtonCallback() {
override fun onClicked(controller: AccessibilityButtonController) {
dismissPurchaseResultBubble()
AccessibilityButtonPolicy.handleClick(::openAgentPreservingTab)
}
}
@@ -122,7 +115,6 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
).restore(packageName, timeoutMillis)
fun openAgentPreservingTab(): Boolean = runCatching {
dismissPurchaseResultBubble()
startActivity(
android.content.Intent(this, cn.ilapage.goauto.agent.MainActivity::class.java).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP or android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP)
@@ -131,14 +123,6 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
true
}.getOrDefault(false)
fun showPurchaseResultBubble(presentation: PurchaseResultBubblePresentation) {
purchaseResultBubble.show(presentation)
}
fun dismissPurchaseResultBubble() {
purchaseResultBubble.dismiss()
}
override fun visibleTexts(): List<String> {
val root = rootInActiveWindow ?: return emptyList()
val texts = mutableListOf<String>()
@@ -349,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 {
@@ -424,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)
@@ -634,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 {
@@ -36,49 +36,6 @@ class PurchaseLiveAutomation(
private var submitAttempted = false
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
private set
/**
* Advances an already verified spec selector to the order confirmation
* page. Only the selector's unique exact confirm button is clickable; an
* order-submit or payment control can never satisfy this transition.
*/
fun advanceToOrderConfirmation() {
var snapshot = driver.capture()
pageProblem(snapshot)
if (orderConfirmationReady(snapshot)) return
if (snapshot.packageName != PDD_PACKAGE) {
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前不是拼多多规格页面,未创建订单")
}
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
if (screen.specPanelType !in setOf(SpecPanelType.NORMAL_SCROLLABLE, SpecPanelType.NON_SCROLLABLE_CONFIRMATION)) {
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前规格面板不能安全确认,未创建订单")
}
val aliases = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR.textAliases.specPanel.confirmAliases
.map { it.replace(" ", "") }
.toSet()
val targets = snapshot.nodes.filter { node ->
node.visible && node.enabled && node.clickable && node.label.replace(" ", "") in aliases
}.distinctBy { it.path }
if (targets.isEmpty()) {
fail("PURCHASE_SPEC_CONFIRM_TARGET_MISSING", "没有找到唯一的规格确认按钮,未创建订单")
}
if (targets.size > 1) {
fail("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", "规格确认按钮不唯一,未创建订单")
}
when (driver.clickFresh(targets.single())) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", "规格确认按钮不唯一,未创建订单")
else -> fail("PURCHASE_SPEC_CONFIRM_CLICK_FAILED", "规格确认按钮点击失败,未创建订单")
}
repeat(SPEC_CONFIRMATION_MAX_SAMPLES) {
pause(SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS)
snapshot = driver.capture()
pageProblem(snapshot)
if (orderConfirmationReady(snapshot)) return
}
fail("PURCHASE_SPEC_CONFIRMATION_UNCONFIRMED", "规格确认后没有进入订单确认页,未创建订单")
}
fun updateShippingAddress(addressSuffix: String): ShippingAddressProof {
if (!addressSuffix.matches(Regex("^_cg[1-9][0-9]*$"))) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "采购任务的地址标记无效")
var snapshot = driver.capture()
@@ -160,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 ->
@@ -207,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)
@@ -450,21 +371,6 @@ class PurchaseLiveAutomation(
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
)
private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean {
if (snapshot.packageName != PDD_PACKAGE) return false
if (snapshot.nodes.any { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) }) return true
return PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null).specPanelType ==
SpecPanelType.ORDER_CONFIRMATION
}
/** 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) }
@@ -515,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})?)")
@@ -530,10 +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
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
const val SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS = 100L
}
}
@@ -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 {
@@ -83,10 +61,6 @@ class PurchaseRehearsalExecutor(
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
val live = PurchaseLiveAutomation(driver, pause)
for (action in rule.actions) {
// spec_probe already opened this task's URL and the server reserves
// the device while matching. Skip the whole navigation action,
// including its configured wait/swipe hooks, in phase two.
if (input.phase == "purchase" && action.type == PurchaseActionType.OPEN_PRODUCT) continue
stepChanged(action.type.wireName)
val failure = when (action.type) {
PurchaseActionType.OPEN_PRODUCT -> openProduct(input, action)
@@ -105,7 +79,6 @@ class PurchaseRehearsalExecutor(
null
}
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
live.advanceToOrderConfirmation()
addressProof = live.updateShippingAddress(input.addressSuffix)
null
} catch (error: PurchaseLiveException) {
@@ -150,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)
@@ -213,20 +186,10 @@ class PurchaseRehearsalExecutor(
var clickAttempted = false
var nextClickPoll = 0
var lastClickReason = FreshClickReason.UNKNOWN
var stableEvidenceReads = 0
var pddForegroundObserved = false
repeat(OPEN_PRODUCT_POLL_LIMIT) { poll ->
val snapshot = driver.capture()
pageProblem(snapshot)?.let { return it }
if (snapshot.packageName == PDD_PACKAGE) {
pddForegroundObserved = true
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
stableEvidenceReads = if (screen.hasPurchaseProductEvidence()) stableEvidenceReads + 1 else 0
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) return null
pause(OPEN_PRODUCT_POLL_MILLIS)
return@repeat
}
stableEvidenceReads = 0
if (snapshot.packageName == PDD_PACKAGE) return null
val candidates = snapshot.nodes.filter { it.visible && it.enabled && it.label in aliases }
if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
if (candidates.size == 1 && poll >= nextClickPoll) {
@@ -244,9 +207,6 @@ class PurchaseRehearsalExecutor(
}
pause(OPEN_PRODUCT_POLL_MILLIS)
}
if (pddForegroundObserved) {
return failure("PDD_DETAIL_ENTRY_FAILED", "打开拼多多后未识别到稳定商品页面")
}
if (clickAttempted) {
val message = when (lastClickReason) {
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> "打开拼多多入口发生变化"
@@ -260,20 +220,13 @@ class PurchaseRehearsalExecutor(
}
private fun verifyProduct(input: PurchaseExecutionInput): PurchaseExecutionOutcome? {
var stableEvidenceReads = 0
repeat(PRODUCT_PAGE_POLL_LIMIT) {
repeat(50) {
val snapshot = driver.capture()
pageProblem(snapshot)?.let { return it }
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
if (screen.hasPurchaseProductEvidence()) {
stableEvidenceReads++
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) {
return recoverSoldOut(input, screen)
}
} else {
stableEvidenceReads = 0
if (snapshot.packageName == PDD_PACKAGE && snapshot.nodes.any { it.visible }) {
return recoverSoldOut(input, PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null))
}
pause(OPEN_PRODUCT_POLL_MILLIS)
pause(100)
}
return failure("PDD_DETAIL_ENTRY_FAILED", "没有进入拼多多商品页面")
}
@@ -312,148 +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) 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) {
FreshActionResult.AMBIGUOUS -> return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一")
FreshActionResult.SUCCESS -> Unit
else -> return failure(SPEC_ENTRY_CLICK_FAILED, click.reason.specEntrySubreason())
}
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
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)
pause(100)
}
val beforeSignature = specActionSignature(screen)
val click = driver.clickFreshDetailed(requireNotNull(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.SUCCESS -> Unit
else -> return failure(
SPEC_ENTRY_CLICK_FAILED,
click.reason.specEntrySubreasonAfterGestureFailure(),
)
}
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)}]",
)
}
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,12 +323,6 @@ class PurchaseRehearsalExecutor(
else -> "unknown"
}
private fun FreshClickReason.specEntrySubreasonAfterGestureFailure(): String = when (this) {
FreshClickReason.SUCCESS -> "gesture_failed_after_action_click_no_effect"
FreshClickReason.UNKNOWN -> "gesture_failed_after_unclassified_action_result"
else -> specEntrySubreason()
}
private fun selectSpecs(
input: PurchaseExecutionInput,
rule: PurchaseRule,
@@ -514,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
@@ -556,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
@@ -608,11 +417,7 @@ class PurchaseRehearsalExecutor(
return matchingCandidates.size == 1 && matchingCandidates.single() == target
}
private data class ExactSpecSelectionProof(
val dimension: String,
val target: String,
val panelType: SpecPanelType,
)
private data class ExactSpecSelectionProof(val dimension: String, val target: String)
private fun exactSpecSelectionProof(
screen: ParsedPddScreen,
@@ -624,7 +429,7 @@ class PurchaseRehearsalExecutor(
val tokenCandidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { candidate ->
if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token
}
return ExactSpecSelectionProof(dimension, target, screen.specPanelType).takeIf {
return ExactSpecSelectionProof(dimension, target).takeIf {
tokenCandidates.size == 1 && tokenCandidates.single().text == target
}
}
@@ -635,9 +440,6 @@ class PurchaseRehearsalExecutor(
val summaryTokenMatched: Boolean,
val candidateCount: Int,
val proofPresent: Boolean,
val targetMatchCount: Int = 0,
val relocationAttempted: Boolean = false,
val relocationSwipes: Int = 0,
)
private fun verifyExactSpecSelection(
@@ -648,21 +450,15 @@ class PurchaseRehearsalExecutor(
): FinalSpecVerification {
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
val matchingNodes = candidates.filter { it.text == target }
val proofPresent = proof?.dimension == dimension && proof.target == target && proof.panelType == screen.specPanelType
val proofPresent = proof?.dimension == dimension && proof.target == target
if (matchingNodes.size == 1 && (matchingNodes.single().node.selected || matchingNodes.single().node.checked)) {
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofPresent, matchingNodes.size)
}
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) {
return FinalSpecVerification(false, "visible_selected_conflict", false, candidates.size, proofPresent, matchingNodes.size)
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofPresent)
}
val token = if (dimension == "size") SpecValueNormalizer.primarySizeToken(target) else target.trim()
val summaryTokenMatched = !token.isNullOrBlank() && SpecValueNormalizer.summaryHasExactToken(screen.selectedSummary, token)
if (!summaryTokenMatched) {
if (screen.selectedSummary == null && screen.specPanelOpen && proofPresent) {
return FinalSpecVerification(true, "attempt_selection_state", false, candidates.size, true, matchingNodes.size)
}
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofPresent, matchingNodes.size)
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofPresent)
}
// PDD can rerender or scroll the previously selected dimension out of
// the current viewport while the selected summary remains visible. A
@@ -673,83 +469,18 @@ class PurchaseRehearsalExecutor(
if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token
}
if (visibleTokenMatches.size == 1 && visibleTokenMatches.single().text == target) {
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofPresent, matchingNodes.size)
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofPresent)
}
if (visibleTokenMatches.isNotEmpty()) {
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofPresent, matchingNodes.size)
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofPresent)
}
return if (proofPresent) {
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true, matchingNodes.size)
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true)
} else {
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, false, matchingNodes.size)
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, false)
}
}
private fun verifyExactSpecSelectionWithRelocation(
input: PurchaseExecutionInput,
dimension: String,
target: String,
proof: ExactSpecSelectionProof?,
): FinalSpecVerification {
var screen = currentScreen(input)
screen.problem?.let {
return FinalSpecVerification(false, it.code, false, 0, proof != null)
}
var verification = verifyExactSpecSelection(screen, dimension, target, proof)
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
if (!screen.specPanelOpen) return verification.copy(reason = "relocation_panel_closed", relocationAttempted = true)
var signature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
}
var swipes = 0
for ((direction, limit) in listOf(SwipeDirection.DOWN to 3, SwipeDirection.UP to 6)) {
for (attempt in 0 until limit) {
val container = screen.specPanelContainer
?: return verification.copy(
reason = "relocation_container_missing",
relocationAttempted = true,
relocationSwipes = swipes,
)
if (!driver.swipePurchaseIn(container, direction, 350)) {
return verification.copy(
reason = "relocation_swipe_failed",
relocationAttempted = true,
relocationSwipes = swipes,
)
}
swipes++
pause(300)
screen = currentScreen(input)
screen.problem?.let {
return verification.copy(
reason = it.code,
relocationAttempted = true,
relocationSwipes = swipes,
)
}
if (!screen.specPanelOpen) {
return verification.copy(
reason = "relocation_panel_closed",
relocationAttempted = true,
relocationSwipes = swipes,
)
}
verification = verifyExactSpecSelection(screen, dimension, target, proof).copy(
relocationAttempted = true,
relocationSwipes = swipes,
)
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
val refreshedSignature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
}
if (refreshedSignature == signature) break
signature = refreshedSignature
}
}
return verification.copy(relocationAttempted = true, relocationSwipes = swipes)
}
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
/**
@@ -760,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, "有界搜索后未找到精确规格"))
@@ -837,26 +566,23 @@ class PurchaseRehearsalExecutor(
observedPrice: Long?,
selectionProofs: Map<String, ExactSpecSelectionProof>,
): PurchaseExecutionOutcome? {
val screen = currentScreen(input)
screen.problem?.let { return failure(it.code, it.message) }
val selected = listOf("color" to input.mappedColor, "size" to input.mappedSize).filter { it.second.isNotBlank() }.map { (dimension, rawTarget) ->
dimension to (normalizedTarget(dimension, rawTarget)
?: return failure(SPEC_SAFE_TARGET_MISSING, "下发规格无法安全规范化"))
}
selected.forEach { (dimension, target) ->
val verification = verifyExactSpecSelectionWithRelocation(input, dimension, target, selectionProofs[dimension])
val verification = verifyExactSpecSelection(screen, dimension, target, selectionProofs[dimension])
if (!verification.confirmed) {
val screen = currentScreen(input)
val panel = screen.specPanelType.name.lowercase()
val diagnostic = "dimension=$dimension,reason=${verification.reason},panel=$panel," +
"summary=${screen.selectedSummary != null},tokenMatched=${verification.summaryTokenMatched}," +
"candidates=${verification.candidateCount},targetMatches=${verification.targetMatchCount}," +
"proof=${verification.proofPresent}," +
"relocated=${verification.relocationAttempted},relocationSwipes=${verification.relocationSwipes}"
"candidates=${verification.candidateCount},proof=${verification.proofPresent}"
return failure(SPEC_SELECTION_UNCONFIRMED, "最终规格复核未能确认精确选中状态 [$diagnostic]")
}
}
if (readQuantity() != input.quantity) return failure("PURCHASE_QUANTITY_MISMATCH", "最终数量复核失败")
val screen = currentScreen(input)
screen.problem?.let { return failure(it.code, it.message) }
val price = screen.priceCent ?: observedPrice ?: return failure("RULE_NOT_MATCHED", "最终价格复核失败")
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) {
return failure("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
@@ -864,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", "规则要求的有限滑动失败")
@@ -923,14 +641,10 @@ class PurchaseRehearsalExecutor(
companion object {
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
private const val OPEN_PRODUCT_POLL_LIMIT = 150
private const val PRODUCT_PAGE_POLL_LIMIT = 150
private const val PRODUCT_PAGE_STABLE_READS = 2
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
@@ -939,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) {
@@ -57,7 +57,6 @@ import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.Executors
@@ -188,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) { "服务端设备身份与本地不一致" }
}
@@ -256,20 +254,13 @@ class AgentForegroundService : Service() {
flushPurchaseOutbox(api, token)
val collectionCooldown = activeCollectionCooldown()
val purchaseTask = api.nextPurchaseTask(token)
when (TaskDispatchPolicy.decide(purchaseTask?.status, collectionCooldown != null)) {
when (TaskDispatchPolicy.decide(purchaseTask != null, collectionCooldown != null)) {
TaskDispatchDecision.RUN_PURCHASE -> {
cancelIdleReturn("收到新的采购任务")
releaseCollectionCooldownWakeLock()
schedulePurchaseTask(api, requireNotNull(purchaseTask), token)
return MANUAL_PURCHASE_TASK
}
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH -> {
val waitingTask = requireNotNull(purchaseTask)
cancelIdleReturn("等待采购规格匹配")
stateStore.update("ONLINE", "采购任务 #${waitingTask.taskId} 正在匹配规格", tokenStored = true)
updateNotification("采购任务 #${waitingTask.taskId} 等待规格匹配")
return MANUAL_PURCHASE_MATCH_PENDING
}
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN -> {
val ticket = requireNotNull(collectionCooldown)
showCollectionCooldown(ticket)
@@ -442,10 +433,8 @@ class AgentForegroundService : Service() {
}
private fun executePurchaseTask(api: AgentApiClient, initial: PurchaseAgentTask, token: String) {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
var resultSafelyStored = false
val lastStep = AtomicReference("started")
try {
val claimed = if (initial.status == "pending") {
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
@@ -482,11 +471,7 @@ class AgentForegroundService : Service() {
driver = accessibility,
openLink = { PddLinkLauncher(this).open(it) },
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
stepChanged = { step ->
lastStep.set(step)
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
},
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
stepChanged = { step -> purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) },
beforeOrderSubmit = { evidence ->
val boundaryRequestId = UUID.randomUUID().toString()
val finalEvidence = JSONObject()
@@ -526,14 +511,6 @@ class AgentForegroundService : Service() {
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
resultSafelyStored = true
PurchaseResultBubblePolicy.create(
taskId = task.taskId,
resultType = outcome.resultType,
lastStep = lastStep.get(),
resultMessage = outcome.message,
)?.let { presentation ->
GoAutoAccessibilityService.instance?.showPurchaseResultBubble(presentation)
}
beginIdleReturnCooldown()
flushPurchaseOutbox(api, token)
val message = if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
@@ -632,7 +609,6 @@ class AgentForegroundService : Service() {
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
): TaskExecutionSummary {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
return try {
executeTaskWhileAwake(api, initialTask, token)
@@ -1081,7 +1057,6 @@ class AgentForegroundService : Service() {
const val MANUAL_EMPTY = "empty"
const val MANUAL_COLLECTION_TASK = "collection_task"
const val MANUAL_PURCHASE_TASK = "purchase_task"
const val MANUAL_PURCHASE_MATCH_PENDING = "purchase_match_pending"
const val MANUAL_BUSY = "busy"
const val MANUAL_CONFIG_REQUIRED = "config_required"
const val MANUAL_AUTH_ERROR = "auth_error"
@@ -99,15 +99,13 @@ internal object CollectionCooldownPolicy {
internal enum class TaskDispatchDecision {
RUN_PURCHASE,
WAIT_FOR_PURCHASE_MATCH,
WAIT_FOR_COLLECTION_COOLDOWN,
CHECK_COLLECTION,
}
internal object TaskDispatchPolicy {
fun decide(purchaseStatus: String?, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
purchaseStatus == "spec_probe_pending" -> TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH
purchaseStatus != null -> TaskDispatchDecision.RUN_PURCHASE
fun decide(purchaseAvailable: Boolean, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
purchaseAvailable -> TaskDispatchDecision.RUN_PURCHASE
collectionCooldownActive -> TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN
else -> TaskDispatchDecision.CHECK_COLLECTION
}
@@ -1,114 +0,0 @@
package cn.ilapage.goauto.agent.ui
import android.content.Context
import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.TextView
import cn.ilapage.goauto.agent.R
class PurchaseResultBubbleController(
private val context: Context,
private val canShow: () -> Boolean,
) {
private val mainHandler = Handler(Looper.getMainLooper())
private val windowManager = context.getSystemService(WindowManager::class.java)
private val session = PurchaseResultBubbleSession()
private var bubbleView: View? = null
fun show(presentation: PurchaseResultBubblePresentation) {
val revision = session.replace()
mainHandler.post {
if (!session.isCurrent(revision)) return@post
removeCurrentView()
if (!canShow()) return@post
val view = buildView(presentation)
runCatching { windowManager.addView(view, layoutParams()) }
.onSuccess {
bubbleView = view
mainHandler.postDelayed({
if (session.isCurrent(revision)) {
session.dismiss()
removeCurrentView()
}
}, presentation.durationMillis)
}
}
}
fun dismiss() {
session.dismiss()
mainHandler.post(::removeCurrentView)
}
private fun buildView(presentation: PurchaseResultBubblePresentation): View {
val horizontalPadding = context.dp(16)
val verticalPadding = context.dp(12)
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding)
elevation = context.dp(8).toFloat()
background = GradientDrawable().apply {
cornerRadius = context.dp(16).toFloat()
setColor(context.getColor(if (presentation.isFailure) R.color.purchase_result_failure_background else R.color.purchase_result_success_background))
}
contentDescription = "${presentation.title}。${presentation.message}"
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
addView(TextView(context).apply {
text = presentation.title
setTextColor(context.getColor(R.color.purchase_result_text))
textSize = 14f
setTypeface(typeface, android.graphics.Typeface.BOLD)
maxLines = 2
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
addView(TextView(context).apply {
text = presentation.message
setTextColor(context.getColor(R.color.purchase_result_text_secondary))
textSize = 13f
maxLines = 3
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
topMargin = context.dp(4)
})
}
}
private fun layoutParams(): WindowManager.LayoutParams {
val availableWidth = context.resources.displayMetrics.widthPixels - context.dp(32)
return WindowManager.LayoutParams(
availableWidth.coerceAtMost(context.dp(360)),
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT,
).apply {
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
y = context.statusBarHeight() + context.dp(8)
windowAnimations = android.R.style.Animation_Toast
}
}
private fun removeCurrentView() {
val view = bubbleView ?: return
bubbleView = null
runCatching { windowManager.removeViewImmediate(view) }
}
private fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
@Suppress("DiscouragedApi")
private fun Context.statusBarHeight(): Int {
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else dp(24)
}
}
@@ -1,86 +0,0 @@
package cn.ilapage.goauto.agent.ui
data class PurchaseResultBubblePresentation(
val title: String,
val message: String,
val isFailure: Boolean,
val durationMillis: Long,
)
object PurchaseResultBubblePolicy {
const val SUCCESS_DURATION_MILLIS = 3_000L
const val FAILURE_DURATION_MILLIS = 8_000L
private const val MAX_MESSAGE_LENGTH = 96
private val stepLabels = mapOf(
"started" to "准备采购",
"openProduct" to "打开商品",
"verifyProduct" to "核对商品",
"openSpecPanel" to "打开规格面板",
"selectSpec" to "选择颜色与尺码",
"setQuantity" to "设置数量",
"verifyUnitPrice" to "核对价格",
"verifyOrderSummary" to "核对订单",
"probeSpecs" to "采集规格",
"updateShippingAddress" to "更新收货地址",
"createOrder" to "创建待付款订单",
"order_submit_started" to "创建待付款订单",
"readOrderResult" to "读取订单编号和下单时间",
)
fun create(
taskId: Long,
resultType: String,
lastStep: String?,
resultMessage: String?,
): PurchaseResultBubblePresentation? {
if (resultType == "spec_probe_completed") return null
val failure = resultType == "failed" || resultType == "order_result_unknown"
if (failure) {
val label = stepLabels[lastStep] ?: "处理采购任务"
return PurchaseResultBubblePresentation(
title = if (resultType == "order_result_unknown") "CG-$taskId 待人工核对|$label" else "CG-$taskId 失败于:$label",
message = sanitize(resultMessage, "采购失败,请返回 Agent 查看详情"),
isFailure = true,
durationMillis = FAILURE_DURATION_MILLIS,
)
}
val successMessage = when (resultType) {
"order_created" -> "已获取订单编号和下单时间"
"rehearsal_completed" -> "商品、规格、数量和价格复核完成"
else -> sanitize(resultMessage, "采购结果已安全保存")
}
return PurchaseResultBubblePresentation(
title = "CG-$taskId 采购完成",
message = successMessage,
isFailure = false,
durationMillis = SUCCESS_DURATION_MILLIS,
)
}
private fun sanitize(value: String?, fallback: String): String {
val normalized = value.orEmpty()
.replace(Regex("https?://\\S+", RegexOption.IGNORE_CASE), "[链接已隐藏]")
.replace(Regex("(?i)(token|authorization|cookie)\\s*[:=]\\s*\\S+")) { match ->
"${match.groupValues[1]}=[已隐藏]"
}
.replace(Regex("[\\r\\n\\t]+"), " ")
.replace(Regex("\\s{2,}"), " ")
.trim()
.ifBlank { fallback }
return normalized.take(MAX_MESSAGE_LENGTH)
}
}
class PurchaseResultBubbleSession {
private var revision = 0L
@Synchronized
fun replace(): Long = ++revision
@Synchronized
fun dismiss(): Long = ++revision
@Synchronized
fun isCurrent(candidate: Long): Boolean = candidate == revision
}
@@ -36,6 +36,8 @@ import cn.ilapage.goauto.agent.network.HistoryPage
import cn.ilapage.goauto.agent.network.HistoryColorImage
import cn.ilapage.goauto.agent.network.PurchaseHistoryDetail
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
import cn.ilapage.goauto.agent.network.PurchaseResetResult
import cn.ilapage.goauto.agent.network.PurchaseRetryResult
import cn.ilapage.goauto.agent.persistence.TaskHistoryCache
import cn.ilapage.goauto.agent.service.AgentForegroundService
import cn.ilapage.goauto.agent.service.AgentSettingsStore
@@ -86,7 +88,7 @@ internal object PurchaseRetryPolicy {
fun confirmationMessage(continuing: Boolean = false): String = if (continuing) {
"替代商品已完成匹配。系统会保留原任务并创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
} else {
"系统会保留原任务,并根据当前商品档案和最新采购规则创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
"将使用服务端最新采购规则重跑当前任务,任务号和商品、规格、价格快照不变;可能创建拼多多待付款订单,但不会支付。"
}
}
@@ -746,7 +748,7 @@ class TaskHistoryFragment : Fragment() {
contentDescription = "重试采购任务 CG-${task.taskId}"
setOnClickListener { confirmPurchaseRetry(task) }
}, collectionCardParams())
resultColumn.addView(context.centeredMessage("重试边界", "保留当前失败任务并创建新任务;新任务读取当前商品档案和采购规则,不会执行支付。"))
resultColumn.addView(context.centeredMessage("重试边界", "复用当前任务并刷新采购规则;不会执行支付。"))
} else if (!replacementInProgress && task.status == "failed") {
val reason = task.retryDisabledReason?.takeIf(String::isNotBlank) ?: "请在管理端核对任务状态。"
resultColumn.addView(context.centeredMessage("不可重试", reason))
@@ -867,13 +869,17 @@ class TaskHistoryFragment : Fragment() {
runCatching {
val client = AgentApiClient(serverUrl)
val requestId = UUID.randomUUID().toString()
client.retryPurchaseTask(taskId, requestId, credentials.token)
if (continuing) client.retryPurchaseTask(taskId, requestId, credentials.token)
else client.resetPurchaseTask(taskId, requestId, credentials.token)
}
.onSuccess { result ->
resultColumn.post {
if (!isAdded || generation != requestGeneration) return@post
AgentForegroundService.start(requireContext())
showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
when (result) {
is PurchaseRetryResult -> showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
is PurchaseResetResult -> showPurchaseResetSuccess(result.taskNo, result.taskId, result.attemptNumber)
}
}
}
.onFailure { error ->
@@ -904,6 +910,24 @@ class TaskHistoryFragment : Fragment() {
}, collectionCardParams())
}
private fun showPurchaseResetSuccess(taskNo: String, taskId: Long, attemptNumber: Int) {
resultColumn.removeAllViews()
resultColumn.addView(requireContext().centeredMessage(
"已加入重试队列 $taskNo",
"任务号保持不变,将使用最新采购规则进行第 $attemptNumber 次尝试;系统不会支付。",
))
resultColumn.addView(MaterialButton(requireContext()).apply {
text = "查看当前任务"
minimumHeight = requireContext().dp(48)
setOnClickListener { loadPurchaseDetail(taskId) }
}, collectionCardParams())
resultColumn.addView(MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
text = "返回采购记录"
minimumHeight = requireContext().dp(48)
setOnClickListener { page = 1; load() }
}, collectionCardParams())
}
private fun addPagination(currentPage: Int, pageSize: Int) {
if (total <= pageSize && currentPage == 1) return
val context = requireContext()
@@ -8,8 +8,4 @@
<color name="agent_text_muted">#CBD5E1</color>
<color name="agent_warning">#FBBF24</color>
<color name="agent_error">#F87171</color>
<color name="purchase_result_success_background">#166534</color>
<color name="purchase_result_failure_background">#991B1B</color>
<color name="purchase_result_text">#FFFFFF</color>
<color name="purchase_result_text_secondary">#F1F5F9</color>
</resources>
@@ -86,19 +86,15 @@ class CollectionCooldownPolicyTest {
fun `purchase keeps priority while collection waits for cooldown`() {
assertEquals(
TaskDispatchDecision.RUN_PURCHASE,
TaskDispatchPolicy.decide(purchaseStatus = "pending", collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH,
TaskDispatchPolicy.decide(purchaseStatus = "spec_probe_pending", collectionCooldownActive = true),
TaskDispatchPolicy.decide(purchaseAvailable = true, collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN,
TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = true),
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.CHECK_COLLECTION,
TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = false),
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = false),
)
}
}
@@ -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(
@@ -16,46 +16,6 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseLiveAutomationTest {
@Test
fun `verified spec panel advances through one exact confirm target`() {
val driver = SpecConfirmationDriver()
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation()
assertEquals(listOf("确定"), driver.clicked)
assertEquals("order", driver.page)
}
@Test
fun `ambiguous missing and unchanged spec confirmation stop safely`() {
val ambiguous = SpecConfirmationDriver(confirmLabels = listOf("确定", "确认"))
val ambiguousError = runCatching { PurchaseLiveAutomation(ambiguous, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", ambiguousError.code)
assertTrue(ambiguous.clicked.isEmpty())
val missing = SpecConfirmationDriver(confirmLabels = emptyList(), specPanelScrollable = true)
val missingError = runCatching { PurchaseLiveAutomation(missing, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRM_TARGET_MISSING", missingError.code)
assertTrue(missing.clicked.isEmpty())
val unchanged = SpecConfirmationDriver(advanceAfterClick = false)
val unchangedError = runCatching { PurchaseLiveAutomation(unchanged, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRMATION_UNCONFIRMED", unchangedError.code)
assertEquals(listOf("确定"), unchanged.clicked)
}
@Test
fun `existing order confirmation does not click submit or payment controls`() {
val driver = SpecConfirmationDriver(startOnOrderPage = true)
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation()
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `address is retagged verified and final order button can only be clicked once`() {
val driver = LiveDriver()
@@ -160,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)
@@ -453,68 +297,6 @@ class PurchaseLiveAutomationTest {
addressSuffix = "_cg11",
)
private class SpecConfirmationDriver(
private val confirmLabels: List<String> = listOf("确定"),
private val advanceAfterClick: Boolean = true,
private val specPanelScrollable: Boolean = false,
startOnOrderPage: Boolean = false,
) : PurchaseUiDriver {
var page = if (startOnOrderPage) "order" else "spec"
val clicked = mutableListOf<String>()
override fun capture(): UiSnapshot = if (page == "order") {
snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
node("scroll", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
node("summary", "已选 黑色 均码"),
node("quantity", "1", className = "android.widget.EditText"),
node("phone", "138****5678"),
node("submit", "提交订单", clickable = true),
node("payment", "微信支付"),
))
} else {
snapshot(buildList {
add(node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)))
if (specPanelScrollable) {
add(node("scroll", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 1500)))
}
add(node("title", "确认款式"))
add(node("summary", "已选 黑色 均码"))
val parent = "scroll".takeIf { specPanelScrollable }
val prefix = if (specPanelScrollable) "scroll/" else ""
add(node("${prefix}color-heading", "颜色分类", parentPath = parent))
add(node("${prefix}color", "黑色", clickable = true, parentPath = parent))
add(node("${prefix}size-heading", "尺码", parentPath = parent))
add(node("${prefix}size", "均码", clickable = true, parentPath = parent))
add(node("quantity", "1", className = "android.widget.EditText"))
confirmLabels.forEachIndexed { index, label -> add(node("confirm-$index", label, clickable = true)) }
})
}
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
if (advanceAfterClick && target.label in confirmLabels) page = "order"
return FreshActionResult.SUCCESS
}
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
override fun backPurchase() = false
private fun snapshot(nodes: List<SnapshotNode>) = UiSnapshot(PDD, ACTIVITY, nodes)
private fun node(
path: String,
text: String,
clickable: Boolean = false,
scrollable: Boolean = false,
className: String = "android.widget.TextView",
bounds: NodeBounds = NodeBounds(20, 100, 900, 180),
parentPath: String? = null,
) = SnapshotNode(path, parentPath, text, null, null, className, bounds, clickable, scrollable, false, false, true, true)
}
private class LiveDriver(
private val addressClipped: Boolean = false,
private val duplicatePanels: Boolean = false,
@@ -528,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"
@@ -548,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
@@ -582,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", "微信分身"),
))
@@ -645,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
}
@@ -669,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
@@ -684,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")) {
@@ -81,12 +64,12 @@ class PurchaseRehearsalExecutorTest {
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2_000L, outcome.actualUnitPriceCent)
assertEquals(0, openCount)
assertEquals(1, openCount)
assertEquals(2, driver.swipeCount)
assertEquals(2L, driver.quantity)
assertTrue(driver.clicked.containsAll(listOf("选择规格", "黑色", "XL")))
assertTrue(driver.clicked.containsAll(listOf("打开", "选择规格", "黑色", "XL")))
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
assertFalse(pauses.contains(700))
assertTrue(pauses.contains(700))
}
@Test
@@ -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斤】"
@@ -233,7 +201,7 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `final verification keeps exact attempt selection when summary is no longer visible`() {
fun `final verification rejects prior proof when selected summary is no longer visible`() {
val driver = FakePurchaseDriver(
hideColorAfterQuantitySet = true,
hideSelectedSummaryAfterQuantitySet = true,
@@ -241,42 +209,11 @@ class PurchaseRehearsalExecutorTest {
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.clicked.count { it == "黑色" })
}
@Test
fun `final verification reuses exact attempt state without scrolling back`() {
val driver = FakePurchaseDriver(
hideColorAfterQuantitySet = true,
hideSelectedSummaryAfterQuantitySet = true,
restoreHiddenColorOnDownSwipe = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertFalse(driver.swipeInPaths.contains("scroll"))
assertEquals(1, driver.clicked.count { it == "黑色" })
assertEquals(1, driver.clicked.count { it == "XL" })
}
@Test
fun `final verification rejects explicit conflicting selected color`() {
val driver = FakePurchaseDriver(
colors = listOf("黑色", "白色"),
hideSelectedSummaryAfterQuantitySet = true,
selectedColorOverrideAfterQuantitySet = "白色",
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
assertTrue(outcome.message.contains("dimension=color"))
assertTrue(outcome.message.contains("reason=visible_selected_conflict"))
assertTrue(outcome.message.contains("targetMatches=1"))
assertEquals(1, driver.clicked.count { it == "黑色" })
assertTrue(outcome.message.contains("reason=summary_token_missing"))
assertTrue(outcome.message.contains("proof=true"))
}
@Test
@@ -343,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)
}
@@ -435,8 +371,8 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `ambiguous browser target stops safely`() {
val driver = FakePurchaseDriver(duplicateOpen = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_AMBIGUOUS", outcome.errorCode)
assertTrue(driver.clicked.isEmpty())
}
@@ -447,10 +383,10 @@ class PurchaseRehearsalExecutorTest {
openClickResults = mutableListOf(FreshActionResult.FAILED),
openPddOnFailedClick = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, driver.openClickCount)
}
@@ -460,10 +396,10 @@ class PurchaseRehearsalExecutorTest {
openClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS),
)
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2, driver.openClickCount)
assertTrue(pauses.size <= 50)
}
@@ -471,165 +407,15 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `persistent open click failure is bounded and remains safely failed`() {
val driver = FakePurchaseDriver(
openClickResults = MutableList(20) { FreshActionResult.FAILED },
openClickResults = MutableList(10) { FreshActionResult.FAILED },
)
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(driver.openClickCount in 1..15)
assertEquals(150, pauses.size)
}
@Test
fun `five second browser interstitial can still reach a stable product page`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(browserOpenVisibleAfterCaptures = 50)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals(1, driver.openClickCount)
assertTrue(pauses.count { it == 100L } in 52..60)
}
@Test
fun `pdd foreground without stable product evidence times out at fifteen seconds`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(loadingPddCaptures = 200)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertEquals("打开拼多多后未识别到稳定商品页面", outcome.message)
assertEquals(150, pauses.count { it == 100L })
}
@Test
fun `explicit pdd login page fails before the product timeout`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(pddProblemLabels = listOf("手机号登录", "登录后继续"))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_LOGIN_REQUIRED", outcome.errorCode)
assertTrue(pauses.count { it == 100L } < 5)
}
@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 `failed spec entry gesture preserves the action click no effect reason`() {
val driver = FakePurchaseDriver(
entryActionHasEffect = false,
specTapResult = FreshActionResult.FAILED,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_FAILED", outcome.errorCode)
assertEquals("gesture_failed_after_action_click_no_effect", outcome.message)
assertFalse(outcome.message.contains("unknown"))
}
@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(),
)
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(2, pauses.count { it == 100L })
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(driver.openClickCount in 1..5)
assertEquals(50, pauses.size)
}
@Test
@@ -696,22 +482,11 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `live rule spec probe ignores historical mapping and returns before address or order actions`() {
fun `live rule spec probe returns before address or order actions`() {
val driver = FakePurchaseDriver()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {})
.execute(
input().copy(
executionMode = "live",
phase = "spec_probe",
mappedColor = "历史颜色",
mappedSize = "历史尺码",
addressSuffix = "_cg42",
),
PurchaseRuleParser.parse(liveRule()),
PurchaseAgentCapabilities.supported,
)
.execute(input().copy(executionMode = "live", phase = "spec_probe", addressSuffix = "_cg42"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals("{\"dimensions\":[]}", outcome.probedSpecs)
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("地址") || it.contains("支付") })
}
@@ -786,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"}]}"""
@@ -957,11 +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 browserOpenVisibleAfterCaptures: Int = 0,
private val pddProblemLabels: List<String> = emptyList(),
private val includeReviewEntry: Boolean = false,
private val openReviewOnBottomClick: Boolean = false,
private val reviewBackSucceeds: Boolean = true,
@@ -971,18 +718,12 @@ 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,
private val hideColorAfterQuantitySet: Boolean = false,
private val hideSizeAfterQuantitySet: Boolean = false,
private val hideSelectedSummaryAfterQuantitySet: Boolean = false,
private val restoreHiddenColorOnDownSwipe: Boolean = false,
private val selectedColorOverrideAfterQuantitySet: String? = null,
private val selectedSummaryOverrideAfterQuantitySet: String? = null,
private val finalSizesAfterQuantitySet: List<String>? = null,
soldOut: Boolean = false,
@@ -991,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
@@ -1006,38 +744,20 @@ 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
private var browserCaptureCount = 0
private var hiddenColorRestored = false
val clicked = mutableListOf<String>()
val clickedPaths = mutableListOf<String>()
override fun capture(): UiSnapshot {
if (browser && !panel && color == null && size == null) {
browserCaptureCount++
if (browserCaptureCount <= browserOpenVisibleAfterCaptures) {
return UiSnapshot("com.heytap.browser", "BrowserActivity", listOf(node("content", "", 0, 0, 1080, 2200)))
}
val openNodes = mutableListOf(node("open", "打开", 0, 100, 300, 180, clickable = true))
if (duplicateOpen) openNodes += node("open2", "打开", 400, 100, 700, 180, clickable = true)
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 (pddProblemLabels.isNotEmpty()) {
return UiSnapshot(PDD, ACTIVITY, pddProblemLabels.mapIndexed { index, label ->
node("problem-$index", label, 20, 100 + index * 100, 900, 180 + index * 100)
})
}
if (!panel) {
if (reviewPage) {
return UiSnapshot(PDD, ACTIVITY, listOf(
@@ -1053,33 +773,24 @@ 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 && !hiddenColorRestored
val hideColor = hideColorAfterQuantitySet && quantity == 2L
val hideSize = hideSizeAfterQuantitySet && quantity == 2L
val hideSummary = hideSelectedSummaryAfterQuantitySet && quantity == 2L
val displayedSummary = if (quantity == 2L && selectedSummaryOverrideAfterQuantitySet != null) {
@@ -1091,27 +802,15 @@ 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)
}
if (!hideColor) {
nodes += node("scroll/color-heading", "颜色分类", 20, 410, 300, 450, parentPath = "scroll")
val selectedColor = if (quantity == 2L) selectedColorOverrideAfterQuantitySet ?: color else color
colors.forEachIndexed { index, value ->
nodes += node(
"scroll/color-$index",
value,
20 + index * 220,
470,
200 + index * 220,
540,
clickable = true,
selected = selectedColor == value,
enabled = !allSpecsUnavailable,
parentPath = "scroll",
)
nodes += node("scroll/color-$index", value, 20 + index * 220, 470, 200 + index * 220, 540, clickable = true, selected = color == value, enabled = !allSpecsUnavailable, parentPath = "scroll")
}
}
if (!hideSize) {
@@ -1149,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) {
@@ -1161,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
@@ -1207,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
@@ -1226,16 +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
if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {
hiddenColorRestored = true
}
return swipePurchase(direction, durationMs)
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean =
swipePurchase(direction, durationMs)
override fun pullDownGoodsPage(): Boolean {
pullDownCount++
@@ -1,72 +0,0 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy
import cn.ilapage.goauto.agent.ui.PurchaseResultBubbleSession
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseResultBubblePolicyTest {
@Test
fun `spec probe does not display a final result bubble`() {
assertNull(PurchaseResultBubblePolicy.create(69, "spec_probe_completed", "probeSpecs", "规格已回传"))
}
@Test
fun `failure includes the real step and sanitized reason for eight seconds`() {
val result = requireNotNull(PurchaseResultBubblePolicy.create(
69,
"failed",
"selectSpec",
"没有找到精确规格\n请重试 token=secret-value",
))
assertEquals("CG-69 失败于:选择颜色与尺码", result.title)
assertEquals("没有找到精确规格 请重试 token=[已隐藏]", result.message)
assertTrue(result.isFailure)
assertEquals(8_000L, result.durationMillis)
}
@Test
fun `unknown order result is presented as a failure requiring manual check`() {
val result = requireNotNull(PurchaseResultBubblePolicy.create(
70,
"order_result_unknown",
"readOrderResult",
"无法确认订单是否创建,请人工检查",
))
assertEquals("CG-70 待人工核对|读取订单编号和下单时间", result.title)
assertTrue(result.isFailure)
assertEquals(8_000L, result.durationMillis)
}
@Test
fun `successful order uses a short factual summary for three seconds`() {
val result = requireNotNull(PurchaseResultBubblePolicy.create(
71,
"order_created",
"readOrderResult",
"ignored",
))
assertEquals("CG-71 采购完成", result.title)
assertEquals("已获取订单编号和下单时间", result.message)
assertFalse(result.isFailure)
assertEquals(3_000L, result.durationMillis)
}
@Test
fun `new result and dismiss invalidate an older scheduled bubble`() {
val session = PurchaseResultBubbleSession()
val first = session.replace()
val second = session.replace()
assertFalse(session.isCurrent(first))
assertTrue(session.isCurrent(second))
session.dismiss()
assertFalse(session.isCurrent(second))
}
}
@@ -16,12 +16,11 @@ class PurchaseRetryPolicyTest {
}
@Test
fun `retry confirmation explains new task current archive and no payment`() {
fun `retry confirmation explains same task latest rule and no payment`() {
val message = PurchaseRetryPolicy.confirmationMessage()
assertTrue(message.contains("保留原任务"))
assertTrue(message.contains("当前商品档案"))
assertTrue(message.contains("当前任务"))
assertTrue(message.contains("最新采购规则"))
assertTrue(message.contains("新采购任务"))
assertFalse(message.contains("新采购任务"))
assertTrue(message.contains("待付款订单"))
assertTrue(message.contains("不会支付"))
}
+59 -20
View File
@@ -67,6 +67,7 @@ CORE_DOCUMENT_REQUIREMENTS = {
),
"docs/00-project-profile.md": (
"## 基本信息",
"## 项目治理模式",
"## 建设基线",
"## 交付单元",
"## 文档事实来源",
@@ -74,6 +75,7 @@ CORE_DOCUMENT_REQUIREMENTS = {
"## 当前阶段",
),
"docs/01-workflow.md": (
"## 语言与术语",
"## 事实来源",
"## 权威源与事实边界",
"## Gitea 交互与工单最小读取",
@@ -102,6 +104,7 @@ CORE_DOCUMENT_REQUIREMENTS = {
),
"docs/04-local-development-and-verification.md": (
"## Windows PowerShell 与 UTF-8",
"### PowerShell 语法与外部命令",
"## 通用检查",
"## 服务端验证",
"## Web 验证",
@@ -271,9 +274,14 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
"#### 明确停止条件",
"单元任务是唯一正式实施单位",
"高风险修改必须停止",
"### 项目治理模式与明确授权后的执行",
"默认采用轻量治理",
"不能单独构成人工授权",
"不得仅因操作不可逆而重复询问或拒绝",
"平台自身强制的审批、安全策略或权限限制继续有效",
"用户没有明确验收通过前不得关闭",
"只有长期事实变化时才更新 Wiki",
"Gitea 工单是单次任务需求、变化、实现、测试、提交和验收的唯一事实来源",
"需要工单的任务以 Gitea 工单作为单次需求、变化、实现、测试、提交和验收的事实来源",
"Wiki 同步由长期事实变化触发,不由任务完成触发",
"标准流程不创建 Wiki 任务归档",
"工单默认只在开始实施、集中回写待验收、验收关闭三个节点更新",
@@ -289,6 +297,11 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
"不得把模板自带的本地 `docs/` 当作新项目 Wiki 已初始化的证据",
"提交只包含当前工单相关文件",
"不得仅为设置编码重复启动一层 PowerShell",
"不得默认套用 Bash",
"复杂正则优先使用变量或 `rg -e`",
"单引号 PowerShell here-string",
"`foreach`、`if` 等语句块",
"使用真实目录配合 `-g/--glob`",
"文件解码和控制台输出分别处理",
"不得默认使用 `-ExecutionPolicy Bypass`",
"### 工单与设计证据双门禁",
@@ -316,6 +329,9 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
"不得臆造用户原话",
"不复制完整聊天",
"Gitea 工单全文不导出到仓库",
"用户可以使用中文、英文或合理的中英混合语言交流",
"默认使用中文分析、回复、编写工单和维护内部项目文档",
"日志和错误原文保持原样",
)
for section in missing_sections(content, required):
errors.append(f"AGENTS.md 缺少:{section}")
@@ -562,32 +578,47 @@ def run_check(args: argparse.Namespace) -> int:
def run_sync(args: argparse.Namespace) -> int:
"""--verify 依次执行导出、结构检查和一致性校验,替代原来的三条命令。"""
"""同步核心镜像;--verify 复用本轮完整读取完成初始化验证。"""
if args.verify:
steps = (
("同步", lambda: run_sync(
argparse.Namespace(check=False, verify=False, config=args.config))),
("结构检查", lambda: run_check(argparse.Namespace(strict=True))),
("一致性校验", lambda: run_sync(
argparse.Namespace(check=True, verify=False, config=args.config))),
)
for name, step in steps:
code = step()
if code != 0:
print(f"错误:{name}未通过,已停止")
return code
try:
config = load_config(Path(args.config).resolve())
messages = sync_all(
config, WikiClient(config), check=False, deep_check=True
)
except WikiDocsError as exc:
print(f"错误:{exc}")
return 1
for message in messages:
print(message)
code = run_check(argparse.Namespace(strict=True))
if code != 0:
print("错误:结构检查未通过,已停止")
return code
print("Wiki 镜像初始化验证通过")
return 0
try:
config = load_config(Path(args.config).resolve())
messages = sync_all(config, WikiClient(config), check=args.check)
deep_check = getattr(args, "deep_check", False)
messages = sync_all(
config,
WikiClient(config),
check=args.check or deep_check,
deep_check=deep_check,
)
except WikiDocsError as exc:
print(f"错误:{exc}")
return 1
for message in messages:
print(message)
print("Wiki 镜像检查通过" if args.check else "Wiki 镜像同步完成")
print(
"Wiki 镜像深度检查通过"
if deep_check
else "Wiki 镜像检查通过"
if args.check
else "Wiki 镜像同步完成"
)
return 0
@@ -652,13 +683,21 @@ def main() -> int:
p_check.set_defaults(func=run_check)
p_sync = sub.add_parser("sync", help="从 Gitea Wiki 单向同步核心 docs 镜像")
p_sync.add_argument(
"--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件"
sync_mode = p_sync.add_mutually_exclusive_group()
sync_mode.add_argument(
"--check",
action="store_true",
help="按 revision 快速检查 Wiki 与镜像,不写文件",
)
p_sync.add_argument(
sync_mode.add_argument(
"--deep-check",
action="store_true",
help="下载全部 Wiki 正文并逐页检查镜像,不写文件",
)
sync_mode.add_argument(
"--verify",
action="store_true",
help="依次执行导出、check --strict 和一致性校验",
help="完整读取并导出 Wiki,再执行 check --strict 初始化验证",
)
p_sync.add_argument(
"--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件"
+137 -14
View File
@@ -8,6 +8,7 @@ import os
import re
import subprocess
import tempfile
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
@@ -26,6 +27,7 @@ HEADER_PATTERN = re.compile(
rf"{re.escape(MIRROR_END)}\n\n(?P<body>.*)\Z",
re.DOTALL,
)
RATE_LIMIT_RETRY_DELAYS = (1.0, 2.0, 4.0)
class WikiDocsError(RuntimeError):
@@ -142,11 +144,33 @@ class WikiClient:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(url, data=data, headers=headers, method=method)
try:
with urlopen(request, timeout=30) as response:
body = response.read()
except HTTPError as exc:
if self.token and method == "GET" and exc.code in {401, 403, 404}:
request_error: HTTPError | URLError | None = None
body = b""
for attempt in range(len(RATE_LIMIT_RETRY_DELAYS) + 1):
try:
with urlopen(request, timeout=30) as response:
body = response.read()
request_error = None
break
except HTTPError as exc:
if (
method == "GET"
and exc.code == 429
and attempt < len(RATE_LIMIT_RETRY_DELAYS)
):
exc.close()
time.sleep(RATE_LIMIT_RETRY_DELAYS[attempt])
continue
request_error = exc
break
except URLError as exc:
request_error = exc
break
else: # pragma: no cover - for 循环必定通过成功或异常分支退出
raise WikiDocsError(f"Gitea API {method} {api_path} 请求失败")
if isinstance(request_error, HTTPError):
if self.token and method == "GET" and request_error.code in {401, 403, 404}:
# 公共仓库可能可匿名读取,而当前 shell 中的通用令牌属于
# 另一个实例或已失效。只对只读请求安全降级为匿名访问。
anonymous_headers = {"Accept": "application/json"}
@@ -167,12 +191,13 @@ class WikiClient:
f"无法连接 Gitea:{anonymous_exc.reason}"
) from anonymous_exc
else:
detail = exc.read().decode("utf-8", errors="replace")
detail = request_error.read().decode("utf-8", errors="replace")
raise WikiDocsError(
f"Gitea API {method} {api_path} 返回 {exc.code}: {detail}"
) from exc
except URLError as exc:
raise WikiDocsError(f"无法连接 Gitea:{exc.reason}") from exc
f"Gitea API {method} {api_path} 返回 "
f"{request_error.code}: {detail}"
) from request_error
elif isinstance(request_error, URLError):
raise WikiDocsError(f"无法连接 Gitea:{request_error.reason}") from request_error
if not body:
return None
try:
@@ -381,8 +406,73 @@ def check_mirror(mapping: Mapping, page: WikiPage, path: Path) -> list[str]:
return errors
def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list[str]:
"""检查或写入所有显式映射;绝不处理映射外的文件。"""
def _metadata_revision(metadata: dict[str, Any]) -> str | None:
last_commit = metadata.get("last_commit")
revision = last_commit.get("sha") if isinstance(last_commit, dict) else None
return revision if isinstance(revision, str) and revision else None
def _find_page_metadata(
pages: list[dict[str, Any]], page_name: str
) -> dict[str, Any]:
metadata = next(
(
item
for item in pages
if item.get("title") == page_name or item.get("sub_url") == page_name
),
None,
)
if metadata is None:
raise WikiDocsError(
f"Wiki 页面不存在:{page_name};不会自动删除或重命名本地镜像"
)
return metadata
def _metadata_identity(
config: Config, metadata: dict[str, Any], fallback_title: str
) -> tuple[str, str] | None:
title = metadata.get("title")
sub_url = metadata.get("sub_url")
if not isinstance(title, str) or not title:
title = fallback_title
if not isinstance(sub_url, str) or not sub_url:
return None
url = (
f"{config.gitea_url}/{quote(config.owner, safe='')}/"
f"{quote(config.repository, safe='')}/wiki/{quote(sub_url, safe='%')}"
)
return title, url
def _local_revision(
path: Path, *, expected_title: str, expected_url: str
) -> str | None:
if not path.is_file():
return None
try:
metadata, _body = parse_mirror(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, WikiDocsError):
return None
if (
metadata.get("wiki_page") != expected_title
or metadata.get("wiki_url") != expected_url
or not metadata.get("synchronized_at")
):
return None
revision = metadata.get("wiki_revision")
return revision if revision else None
def sync_all(
config: Config,
client: WikiClient,
*,
check: bool = False,
deep_check: bool = False,
) -> list[str]:
"""检查或写入所有显式映射;写入前先完成全部远端读取。"""
if not check:
dirty = dirty_mirror_paths(config)
@@ -392,10 +482,43 @@ def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list
"已映射的本地镜像存在未提交改动,已停止以防覆盖:\n" + details
)
messages: list[str] = []
pages = client.list_pages()
resolved: list[tuple[Mapping, Path, WikiPage | None, str | None]] = []
for mapping in config.mappings:
page = client.get_page(mapping.page)
metadata = _find_page_metadata(pages, mapping.page)
target = ROOT / PurePosixPath(mapping.path)
remote_revision = _metadata_revision(metadata)
identity = _metadata_identity(config, metadata, mapping.page)
local_revision = (
_local_revision(
target, expected_title=identity[0], expected_url=identity[1]
)
if identity is not None
else None
)
if not deep_check and remote_revision and local_revision == remote_revision:
action = "一致" if check else "无变化"
resolved.append(
(
mapping,
target,
None,
f"{action}:{mapping.path} <- "
f"{mapping.page}@{remote_revision[:12]}",
)
)
continue
page = client.get_page_from_metadata(metadata, mapping.page)
resolved.append((mapping, target, page, None))
messages: list[str] = []
for mapping, target, page, skip_message in resolved:
if skip_message is not None:
messages.append(skip_message)
continue
if page is None: # pragma: no cover - resolved 元组由上面的单一路径构造
raise WikiDocsError(f"Wiki 页面未解析:{mapping.page}")
if check:
errors = check_mirror(mapping, page, target)
if errors:
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
wiki_revision: 7468b9fbdd4d0bbbb9a73580c22ec868b3085753
synchronized_at: 2026-09-05T07:16:39Z
synchronized_at: 2026-09-05T04:06:47Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Development-Workflow
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.-
wiki_revision: 62ddbe4469740c02ce4a6ca2fd1966a89a79322f
synchronized_at: 2026-09-05T07:16:44Z
synchronized_at: 2026-09-05T04:06:53Z
<!-- gitea-wiki-mirror:end -->
# 开发工作流
+1 -1
View File
@@ -3,7 +3,7 @@ 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: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:29:50Z
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
+2 -4
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: 21ad1681d12a40e7042567f03716d899d49b737e
synchronized_at: 2026-09-05T07:16:58Z
wiki_revision: f8a2e64acaf245066d55d4a0f1e928d672712270
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -429,5 +429,3 @@ synchronized_at: 2026-09-05T07:16:58Z
- 管理员可在设备管理对未停用设备发起“重置设备身份”。此动作不删除设备记录、不改变设备 ID、能力或已绑定的待领取任务;它立即使旧 Token 无效,并只生成一次、有效期 10 分钟的恢复码。
- 恢复码仅显示给发起操作的管理员一次,服务端只保存摘要;不得进入列表、日志、任务记录、Android 持久化或普通接口。手机操作员必须在同一安装实例的 Agent 设置中手动输入。
- 服务端只接受同一 `installId`、未过期且尚未使用的恢复码完成重新注册,成功后签发新 Token 并使恢复码失效。过期、重复使用、installId 不符或停用均明确失败;不能通过清空数据、直接改库或“吊销 Token”恢复原任务归属。
- 自 #223 起,任务创建时把与冻结 SYB 目标对应的已确认商品规格映射保存为不可变的探测指导快照,但仍不得跳过首趟真机探测。探测完成后,每个角色先验证快照映射能否按既有规范化规则唯一对应当次候选,能对应时固化当次候选原文;不能对应时只对该未解决角色执行确定性匹配,仍无结果才调用 AI。已解决角色不重复交给 AI,任一最终值仍必须逐字属于当次候选;历史映射失效、规范化后歧义或角色不符时不得复用。候选完整但无法决策时提示“已采集到当前规格,但未能确定颜色或尺码映射”,不再误报候选不存在。
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
wiki_revision: 835494c4a63a1494601658561fde1ab76657be3d
synchronized_at: 2026-09-05T07:17:05Z
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Common-Changes
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Common-Changes.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:12Z
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 常见修改指南
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:16Z
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:20Z
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览与当前 MVP
+2 -4
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: e5a443e16e288143c2c890c674038c531abe3f16
synchronized_at: 2026-09-05T07:17:37Z
wiki_revision: 08a65eb49d3d1091f0be30e6f6d1a9372f6e5b37
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -865,5 +865,3 @@ X-GoAuto-Device-Recovery-Code: <one-time-code>
仅管理员可以对未停用的既有设备发起身份重置。服务端立即使旧 Device Token 无效,并生成 10 分钟内仅能使用一次的恢复码;恢复码只在该管理员操作的响应中返回一次,服务端仅保存不可逆摘要,管理端设备列表、日志、任务接口和 Android 本地持久化均不得保存或返回原文。管理员将恢复码经受控人工渠道输入同一安装实例的 Agent 设置页。
Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口。服务端必须同时校验同一 `installId`、未停用状态、恢复码摘要、未过期和未使用;成功后使用原 `deviceId` 写入新 Token 摘要并返回一次新 Token,清除恢复码摘要和有效期。旧 Token 与恢复码都立即失效,已分配的 pending 采集或采购任务保持原 `deviceId`,不创建替代设备记录。缺少或错误恢复码仍为 `DEVICE_INSTALL_ID_CONFLICT`;过期码为 `DEVICE_RECOVERY_EXPIRED`;停用设备为 `DEVICE_DISABLED`。
自 #223 起,新建 SYB 任务在保持 `mappedColor` / `mappedSize` 为空和首趟 `spec_probe` 不变的同时,把创建时与目标规格对应的 confirmed 商品映射冻结为仅供服务端决策的指导快照。服务端收到当次候选后按角色验证该快照:只有规范化后唯一对应当次候选时才复用,并固化当次候选原文;否则该角色继续执行确定性匹配,仍未解决才把该角色及其封闭候选交给 AI。已解决角色不得重复发送给 AI,最终颜色和尺码仍须逐字属于各自当次候选。任务决策快照通过 `roleSources` 记录每个角色的 `manual_mapping` / `exact_match` / `ai_match` 来源;任务级 `specSource` 使用现有枚举汇总,不新增 Agent 决策权限。
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Delivery-Issues
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:28Z
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 当前 MVP 交付工单索引
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: OnePlus-Real-Device-Acceptance
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:32Z
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 一加真机验收记录
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: PDD-Detail-Rule-Migration-Analysis
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/PDD-Detail-Rule-Migration-Analysis.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:37Z
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# PDD 商品详情采集规则迁移分析
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: SYB-ERP-Interface-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
wiki_revision: ad9adc22e69e48c9a8fd87d7121066eecca55fd6
synchronized_at: 2026-09-04T11:30:50Z
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:08Z
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Home
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:29:34Z
synchronized_at: 2026-09-05T04:06:45Z
<!-- gitea-wiki-mirror:end -->
# GoAuto 文档中心
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-Template
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-Template.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:41Z
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
# 部署文档模板
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-Archive-Template
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Task-Archive-Template.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:46Z
synchronized_at: 2026-09-05T04:13:06Z
<!-- gitea-wiki-mirror:end -->
> 本模板只用于用户明确要求的专项历史快照或读取既有归档,不属于标准任务闭环。单次任务的唯一事实来源是 Gitea 工单;不要为了完成普通任务创建本页面,也不要自动导出到 `docs/task/`。
+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) }
+23 -6
View File
@@ -29,18 +29,35 @@ 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"`
}
type DecisionSnapshot struct {
Source string `json:"source"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Candidates CandidateSnapshot `json:"candidates"`
RoleSources map[string]string `json:"roleSources,omitempty"`
Matched struct {
Source string `json:"source"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Candidates CandidateSnapshot `json:"candidates"`
Matched struct {
Color string `json:"color,omitempty"`
Size string `json:"size,omitempty"`
} `json:"matched"`
+1 -1
View File
@@ -18,7 +18,7 @@ func TestParseBuiltAgentAPKWhenAvailable(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if metadata.VersionCode != 54 || metadata.VersionName != "0.9.41" {
if metadata.VersionCode != 49 || metadata.VersionName != "0.9.36" {
t.Fatalf("metadata=%+v", metadata)
}
}
+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" }
+6 -13
View File
@@ -2,6 +2,7 @@ package purchase
import (
"context"
"strings"
"testing"
"time"
@@ -71,11 +72,6 @@ func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) {
setCollectedPDDPrice(t, db, f.pdd.ID)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
"target_color": "当前黑色", "target_size": "均碼",
}).Error; err != nil {
t.Fatal(err)
}
history, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token)
if err != nil || !history.Task.Retryable || history.Task.RetryDisabledReason != "" {
@@ -97,9 +93,6 @@ func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) {
if oldTask.Status != models.PurchaseTaskStatusFailed || newTask.Status != models.PurchaseTaskStatusPending || newTask.DeviceID == nil || *newTask.DeviceID != f.device.ID || newTask.AddressSuffix == oldTask.AddressSuffix {
t.Fatalf("retry did not preserve old task or fix new task to device: old=%+v new=%+v", oldTask, newTask)
}
if newTask.TargetColorSnapshot != "当前黑色" || newTask.TargetSizeSnapshot != "均碼" || newTask.SpecSource != "unresolved" || purchaseAttemptPhase(newTask) != models.PurchaseAttemptPhaseSpecProbe {
t.Fatalf("retry did not use current SYB targets and a fresh probe: %+v", newTask)
}
replay, err := service.AgentRetry(context.Background(), failed.ID, request, f.token)
if err != nil || !replay.Replayed || replay.TaskID != first.TaskID {
@@ -165,7 +158,7 @@ func TestMatchedReplacementExposesContinueAndReusesAgentRetry(t *testing.T) {
}
}
func TestMatchedReplacementAllowsRetryWithoutPersistedMappingAndDoesNotCallAI(t *testing.T) {
func TestMatchedReplacementRejectsClearedMappingWithoutCallingAI(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f.pdd.ID)
@@ -179,11 +172,11 @@ func TestMatchedReplacementAllowsRetryWithoutPersistedMappingAndDoesNotCallAI(t
service.Matcher = matcher
detail, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token)
if err != nil || !detail.ContinuePurchaseEligible || detail.ContinuePurchaseDisabledReason != "" || matcher.calls != 0 {
if err != nil || detail.ContinuePurchaseEligible || !strings.Contains(detail.ContinuePurchaseDisabledReason, "规格匹配已失效") || matcher.calls != 0 {
t.Fatalf("cleared mapping qualification mismatch: detail=%+v calls=%d error=%v", detail, matcher.calls, err)
}
created, err := service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || created.TaskID == 0 || matcher.calls != 0 {
t.Fatalf("cleared mapping retry did not create probe task: result=%+v calls=%d error=%v", created, matcher.calls, err)
_, err = service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token)
if code(err) != CodeMappingRequired || matcher.calls != 0 {
t.Fatalf("cleared mapping reached AI or retry: code=%s calls=%d error=%v", code(err), matcher.calls, err)
}
}
@@ -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}
}
}
+78 -5
View File
@@ -105,7 +105,8 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba
items := make([]BatchPreviewItem, 0, len(ids))
eligible := 0
for _, id := range ids {
item := s.previewFromDataset(id, dataset, guard)
item := s.previewFromDataset(ctx, id, dataset, false, guard)
item.enforcePersistedMatch(id, dataset)
item.applyCollectionEligibility(id, dataset)
qualification := aiMatchQualificationForDataset(id, dataset)
item.AIMatchEligible = qualification.Eligible
@@ -330,6 +331,22 @@ func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64, rul
return ensureCapabilities(record, purchasecontract.RequiredCapabilities(rule))
}
func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem {
_, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
if ruleErr != nil {
item := BatchPreviewItem{SYBProductID: id}
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(ruleErr)
return item
}
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
if err != nil {
item := BatchPreviewItem{SYBProductID: id}
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
return item
}
return s.previewFromDataset(ctx, id, dataset, true, purchasecontract.EffectivePriceGuard(currentRule))
}
func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard purchasecontract.PriceLimits) BatchPreviewItem {
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
if err != nil {
@@ -337,7 +354,8 @@ func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
return item
}
item := s.previewFromDataset(id, dataset, guard)
item := s.previewFromDataset(ctx, id, dataset, false, guard)
item.enforcePersistedMatch(id, dataset)
return item
}
@@ -361,7 +379,7 @@ func taskPointerValue(value *uint64) uint64 {
return *value
}
func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, guard purchasecontract.PriceLimits) BatchPreviewItem {
func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool, guard purchasecontract.PriceLimits) BatchPreviewItem {
item := BatchPreviewItem{SYBProductID: id}
syb, found := dataset.sybByID[id]
if !found {
@@ -388,6 +406,10 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
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
@@ -405,15 +427,26 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
}
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
item.MappedColor, item.MappedSize = mappedColor, mappedSize
candidates, _ := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
// #190:失效的已保存映射不再拦截,降级为 unresolved,与创建路径保持一致。
if source != "unresolved" && !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
mappedColor, mappedSize, source = "", "", "unresolved"
item.MappedColor, item.MappedSize = "", ""
}
if source == "unresolved" {
if !archiveUsable {
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "关联的 PDD 商品没有完整可选规格", "open_pdd"
return item
}
request := aimatching.MatchRequest{TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
if matched, ok := aimatching.DeterministicMatch(request); ok {
if allowAI {
matched, matchErr := s.matcher().Resolve(ctx, request)
if matchErr != nil {
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(purchaseMatchError(matchErr))
return item
}
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
} else if matched, ok := aimatching.DeterministicMatch(request); ok {
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
}
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
@@ -447,6 +480,46 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
return item
}
// enforcePersistedMatch is the #188 gate for the SYB list and batch-create
// workflow. A transient deterministic result is not purchase readiness: the
// color/size mapping must already be confirmed on the Shopee product and must
// still identify one complete, available SKU combination from the latest
// successful collection.
func (item *BatchPreviewItem) enforcePersistedMatch(id uint64, dataset batchPreviewDataset) {
if item.ReasonCode != "" && item.ReasonCode != CodeMappingRequired {
return
}
syb, ok := dataset.sybByID[id]
if !ok || syb.ShopeeProductID == nil {
return
}
shopee, ok := dataset.shopeeByID[*syb.ShopeeProductID]
if !ok || shopee.PDDProductID == nil {
return
}
pdd, ok := dataset.pddByID[*shopee.PDDProductID]
if !ok || pdd.Status != "active" {
return
}
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
item.MappedColor, item.MappedSize = mappedColor, mappedSize
if source == "unresolved" {
item.Eligible = false
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "规格尚未匹配并保存,请先完成 AI 匹配或人工确认", "open_mapping"
return
}
combinations := dataset.skuCombinationsByPDD[pdd.ID]
if len(combinations) == 0 {
item.Eligible = false
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "缺少当前 PDD 商品的完整可售 SKU 组合,请先重新采集", "open_pdd"
return
}
if !validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
item.Eligible = false
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "已保存规格映射不属于当前可售的 PDD 规格组合,请重新匹配", "open_mapping"
}
}
func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceLimits) (reference, minPrice, maxPrice int64, err error) {
var dimensions []productspec.Dimension
if json.Unmarshal([]byte(raw), &dimensions) != nil {
@@ -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)
@@ -9,6 +9,7 @@ import (
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -136,14 +137,18 @@ func exactBatchSpecFixture(t *testing.T) (*Service, fixture) {
return testService(db), f
}
func TestBatchSpecMatchPersistsExactMatchButPurchaseDoesNotWaitForIt(t *testing.T) {
func TestBatchSpecMatchPersistsExactMatchBeforePurchaseCreation(t *testing.T) {
service, f := exactBatchSpecFixture(t)
matcher := &batchSpecMatcher{}
service.Matcher = matcher
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 != ProcessStagePurchaseReady {
t.Fatalf("live-probe purchase was incorrectly gated by persisted mapping: %+v err=%v", before, err)
if err != nil || len(before.Items) != 1 || before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
t.Fatalf("unpersisted exact match was not gated: %+v err=%v", before, err)
}
blocked, err := service.BatchCreate(context.Background(), BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID})
if err != nil || blocked.CreatedCount != 0 || blocked.FailedCount != 1 || blocked.Items[0].ReasonCode != CodeMappingRequired {
t.Fatalf("batch create bypassed persisted mapping gate: %+v err=%v", blocked, err)
}
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
@@ -170,10 +175,10 @@ func TestBatchSpecMatchPersistsExactMatchButPurchaseDoesNotWaitForIt(t *testing.
}
}
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)
}
@@ -181,16 +186,16 @@ func TestExtractedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T) {
service.Matcher = matcher
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 != ProcessStagePurchaseReady {
t.Fatalf("extracted uncertain specs were not purchase-ready for live probing: %+v err=%v", before, err)
if err != nil || len(before.Items) != 1 || before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
t.Fatalf("manually confirmed specs did not enter matching: %+v err=%v", before, err)
}
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)
}
}
@@ -231,7 +236,7 @@ func TestBatchPreviewExposesExplicitAIMatchEligibility(t *testing.T) {
t.Fatalf("preview failed: %+v err=%v", preview, err)
}
item := preview.Items[0]
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || !item.Eligible || item.ProcessStage != ProcessStagePurchaseReady {
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || item.Eligible || item.ProcessStage != ProcessStageColorMapping {
t.Fatalf("explicit AI eligibility mismatch: %+v", item)
}
}
@@ -260,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)
}
}
+20 -35
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")
}
}
@@ -288,24 +289,8 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
if queries != 7 {
t.Fatalf("batch preview used %d queries, want 7 bounded queries including collection eligibility and current purchase rule", queries)
}
if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 {
t.Fatalf("unresolved rows must be ready for mandatory live probing: %+v", response)
}
}
func TestBatchPreviewAllowsIncompleteArchivedCandidatesForMandatoryLiveProbe(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", fixture.pdd.ID).Update(
"specs_json",
`[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]}]`,
).Error; err != nil {
t.Fatal(err)
}
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}})
if err != nil || len(response.Items) != 1 || !response.Items[0].Eligible || response.Items[0].ProcessStage != ProcessStagePurchaseReady {
t.Fatalf("incomplete archive blocked live probe task creation: %+v err=%v", response, err)
if len(response.Items) != 2 || response.Items[0].Eligible || response.Items[1].Eligible || response.Items[0].ReasonCode != CodeMappingRequired {
t.Fatalf("unresolved rows must not be reported as purchase-ready: %+v", response)
}
}
+21 -44
View File
@@ -33,19 +33,6 @@ func (s *Service) Next(ctx context.Context, token string) (*TaskPayload, error)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, internal(err)
}
// A completed probe reserves the device's purchase flow while the server
// resolves the exact specs. Returning that task as a waiting payload keeps
// the Agent from claiming another purchase or collection task and preserves
// the PDD page that the probe just inspected.
var waiting models.PurchaseTask
if err = s.DB.WithContext(ctx).
Where("device_id = ? AND status = ?", d.ID, models.PurchaseTaskStatusSpecProbePending).
Order("created_at, id").
First(&waiting).Error; err == nil {
return s.payload(waiting, nil, false)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, internal(err)
}
now := s.Now()
var candidates []models.PurchaseTask
if err = s.DB.WithContext(ctx).Where("status IN ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?) AND (device_id IS NULL OR device_id = ?)", []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now, d.ID).Order("CASE WHEN device_id IS NULL THEN 1 ELSE 0 END, created_at, id").Limit(100).Find(&candidates).Error; err != nil {
@@ -199,14 +186,17 @@ func (s *Service) Start(ctx context.Context, taskID uint64, req ActionRequest, t
if e = ensureAccountFree(tx, t.PDDAccountID, t.ID, s.Now()); e != nil {
return e
}
phase := purchaseAttemptPhase(t)
phase := models.PurchaseAttemptPhasePurchase
if t.SpecSource == "unresolved" {
phase = models.PurchaseAttemptPhaseSpecProbe
}
ruleSnapshotHash := purchaseRuleSnapshotHash(t.RuleSnapshot)
now := s.Now()
var a models.PurchaseTaskAttempt
if e = tx.Where("task_id = ? AND status = ?", t.ID, models.PurchaseAttemptStatusPending).Order("attempt_number DESC, id DESC").First(&a).Error; e == nil {
if a.DeviceID == nil || *a.DeviceID != d.ID || a.RuleSnapshotHash != ruleSnapshotHash || a.Phase != phase {
if a.DeviceID == nil || *a.DeviceID != d.ID || a.RuleSnapshotHash != ruleSnapshotHash {
failureCode := CodeStateConflict
message := "采购任务执行快照校验失败,请重新创建任务"
message := "采购规则快照校验失败,请重新重试任务"
a.Status = models.PurchaseAttemptStatusFailed
a.ErrorCode = &failureCode
a.ErrorMessage = &message
@@ -276,9 +266,6 @@ func (s *Service) MarkOrderSubmitStarted(ctx context.Context, taskID uint64, req
if t.ExecutionMode != models.PurchaseExecutionModeLive {
return TaskPayload{}, fail(CodeStateConflict, "演练任务不能创建订单")
}
if a.Phase != models.PurchaseAttemptPhasePurchase {
return TaskPayload{}, fail(CodeStateConflict, "规格探测阶段不能进入创建订单边界")
}
if _, e := uuid.Parse(req.RequestID); e != nil {
return TaskPayload{}, fail(CodeInvalidRequest, "requestId 无效")
}
@@ -549,29 +536,19 @@ func (s *Service) resolveProbedSpecs(ctx context.Context, taskID uint64, attempt
}
decision.NoMatch, decision.Decision = true, snapshot
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码"
} else {
matched, matchErr := s.resolveProbedMatch(ctx, task, request)
valid := matchErr == nil && (matched.Source == "manual_mapping" || matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) &&
matchCandidateValid(request.TargetColor, matched.MappedColor, request.Colors) &&
matchCandidateValid(request.TargetSize, matched.MappedSize, request.Sizes)
if valid {
snapshot, marshalErr := json.Marshal(matched.Decision)
if marshalErr != nil {
return TaskPayload{}, internal(marshalErr)
}
decision.MappedColor, decision.MappedSize, decision.Source, decision.Decision = matched.MappedColor, matched.MappedSize, matched.Source, snapshot
} else {
reason := purchaseMatchReason(matchErr)
if matchErr == nil {
reason = "AI 规格匹配结果不属于当次 PDD 候选"
}
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, reason))
if marshalErr != nil {
return TaskPayload{}, internal(marshalErr)
}
decision.NoMatch, decision.Decision = true, snapshot
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", reason
} else if matched, matchErr := s.matcher().Resolve(ctx, request); matchErr == nil {
snapshot, marshalErr := json.Marshal(matched.Decision)
if marshalErr != nil {
return TaskPayload{}, internal(marshalErr)
}
decision.MappedColor, decision.MappedSize, decision.Source, decision.Decision = matched.MappedColor, matched.MappedSize, matched.Source, snapshot
} else {
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, purchaseMatchReason(matchErr)))
if marshalErr != nil {
return TaskPayload{}, internal(marshalErr)
}
decision.NoMatch, decision.Decision = true, snapshot
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", purchaseMatchReason(matchErr)
}
updated, _, err := s.applySpecDecision(ctx, taskID, decision, nil)
if err != nil {
@@ -590,7 +567,7 @@ func purchaseMatchReason(err error) string {
return "AI 规格匹配暂时不可用,请稍后重新创建采购任务"
}
}
return "已采集到当前规格,但未能确定颜色或尺码映射"
return "没有找到可采购的 PDD 颜色或尺码"
}
type probedSpecCandidates struct {
@@ -672,7 +649,7 @@ func (s *Service) withRunning(ctx context.Context, taskID uint64, token string,
func ensureDeviceFree(tx *gorm.DB, deviceID, taskID uint64, now time.Time) error {
var count int64
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", taskID, deviceID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending}, models.PurchaseTaskStatusPending, now).Count(&count).Error; e != nil {
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", taskID, deviceID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).Count(&count).Error; e != nil {
return internal(e)
}
if count > 0 {
@@ -691,7 +668,7 @@ func ensureAccountFree(tx *gorm.DB, accountID *uint64, taskID uint64, now time.T
return nil
}
var count int64
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND pdd_account_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", taskID, *accountID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending}, models.PurchaseTaskStatusPending, now).Count(&count).Error; e != nil {
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND pdd_account_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", taskID, *accountID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).Count(&count).Error; e != nil {
return internal(e)
}
if count > 0 {
@@ -40,19 +40,6 @@ func queuedMatchTask(t *testing.T) (*gorm.DB, fixture, models.PurchaseTask) {
if err != nil {
t.Fatal(err)
}
fingerprint, snapshot, err := currentPurchaseMatchInput(db, task)
if err != nil {
t.Fatal(err)
}
work := models.PurchaseSpecMatchWorkItem{
PurchaseTaskID: task.ID,
Status: models.PurchaseMatchPending,
InputFingerprint: fingerprint,
InputSnapshotJSON: snapshot,
}
if err := db.Create(&work).Error; err != nil {
t.Fatal(err)
}
return db, f, task
}
@@ -1,207 +0,0 @@
package purchase
import (
"context"
"encoding/json"
"strings"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
)
// resolveProbedMatch keeps the live probe as the source of executable labels.
// Confirmed product mappings may guide the decision only when they still map
// uniquely to a label observed by this attempt.
func (s *Service) resolveProbedMatch(ctx context.Context, task models.PurchaseTask, request aimatching.MatchRequest) (aimatching.MatchResult, error) {
resolvedColor, resolvedSize := "", ""
roleSources := map[string]string{}
mappings := probeGuidanceFromSnapshot(task.SpecDecisionSnapshot)
if candidate, ok := currentMappedCandidate(mappings.color.value, request.Colors); ok {
resolvedColor, roleSources[shopeeproduct.RoleColor] = candidate, mappings.color.source
}
if candidate, ok := currentMappedCandidate(mappings.size.value, request.Sizes); ok {
resolvedSize, roleSources[shopeeproduct.RoleSize] = candidate, mappings.size.source
}
if task.TargetColorSnapshot != "" && resolvedColor == "" {
if exact, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, Colors: request.Colors}); ok {
resolvedColor, roleSources[shopeeproduct.RoleColor] = exact.MappedColor, aimatching.SourceExact
}
}
if task.TargetSizeSnapshot != "" && resolvedSize == "" {
if exact, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetSize: task.TargetSizeSnapshot, Sizes: request.Sizes}); ok {
resolvedSize, roleSources[shopeeproduct.RoleSize] = exact.MappedSize, aimatching.SourceExact
}
}
missingColor := task.TargetColorSnapshot != "" && resolvedColor == ""
missingSize := task.TargetSizeSnapshot != "" && resolvedSize == ""
var providerDecision aimatching.DecisionSnapshot
if missingColor || missingSize {
remaining := aimatching.MatchRequest{}
if missingColor {
remaining.TargetColor, remaining.Colors = task.TargetColorSnapshot, request.Colors
}
if missingSize {
remaining.TargetSize, remaining.Sizes = task.TargetSizeSnapshot, request.Sizes
}
matched, err := s.matcher().Resolve(ctx, remaining)
if err != nil {
return aimatching.MatchResult{}, err
}
if matched.Source != aimatching.SourceExact && matched.Source != aimatching.SourceAI {
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "规格匹配来源无效"}
}
providerDecision = matched.Decision
if missingColor {
if !matchCandidateValid(remaining.TargetColor, matched.MappedColor, remaining.Colors) {
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "AI 返回的颜色不属于当次候选"}
}
resolvedColor, roleSources[shopeeproduct.RoleColor] = matched.MappedColor, matched.Source
}
if missingSize {
if !matchCandidateValid(remaining.TargetSize, matched.MappedSize, remaining.Sizes) {
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "AI 返回的尺码不属于当次候选"}
}
resolvedSize, roleSources[shopeeproduct.RoleSize] = matched.MappedSize, matched.Source
}
}
source := probeSourceSummary(roleSources)
reason := "当次候选完成确定性匹配"
if containsRoleSource(roleSources, "manual_mapping") || containsRoleSource(roleSources, aimatching.SourceAI) {
reason = "已确认映射经当次候选验证,未解决规格按确定性或 AI 匹配"
}
result := aimatching.RecordedMatch(request, source, resolvedColor, resolvedSize, reason)
result.Decision.RoleSources = roleSources
result.Decision.Provider = providerDecision.Provider
result.Decision.Model = providerDecision.Model
result.Decision.Confidence = providerDecision.Confidence
return result, nil
}
type confirmedProbeMapping struct {
value string
source string
invalid bool
}
type confirmedProbeMappingSet struct {
color confirmedProbeMapping
size confirmedProbeMapping
}
type probeGuidanceSnapshot struct {
ConfirmedMappings map[string]probeGuidanceMapping `json:"confirmedMappings,omitempty"`
}
type probeGuidanceMapping struct {
Value string `json:"value"`
Source string `json:"source"`
}
func newProbeGuidanceSnapshot(raw, targetColor, targetSize string) (string, error) {
mappings := confirmedProbeMappings(raw, targetColor, targetSize)
snapshot := probeGuidanceSnapshot{ConfirmedMappings: map[string]probeGuidanceMapping{}}
if mappings.color.value != "" {
snapshot.ConfirmedMappings[shopeeproduct.RoleColor] = probeGuidanceMapping{Value: mappings.color.value, Source: mappings.color.source}
}
if mappings.size.value != "" {
snapshot.ConfirmedMappings[shopeeproduct.RoleSize] = probeGuidanceMapping{Value: mappings.size.value, Source: mappings.size.source}
}
encoded, err := json.Marshal(snapshot)
return string(encoded), err
}
func probeGuidanceFromSnapshot(raw string) confirmedProbeMappingSet {
var snapshot probeGuidanceSnapshot
if json.Unmarshal([]byte(raw), &snapshot) != nil {
return confirmedProbeMappingSet{}
}
result := confirmedProbeMappingSet{}
if mapping, ok := snapshot.ConfirmedMappings[shopeeproduct.RoleColor]; ok {
result.color = confirmedProbeMapping{value: strings.TrimSpace(mapping.Value), source: mapping.Source}
}
if mapping, ok := snapshot.ConfirmedMappings[shopeeproduct.RoleSize]; ok {
result.size = confirmedProbeMapping{value: strings.TrimSpace(mapping.Value), source: mapping.Source}
}
return result
}
func confirmedProbeMappings(raw, targetColor, targetSize string) confirmedProbeMappingSet {
var specs []shopeeproduct.SpecDimension
if json.Unmarshal([]byte(raw), &specs) != nil {
return confirmedProbeMappingSet{}
}
result := confirmedProbeMappingSet{}
for _, dimension := range specs {
for _, value := range dimension.Values {
if value.Mapping == nil || value.Mapping.Status != shopeeproduct.MappingStatusConfirmed {
continue
}
mapping := confirmedProbeMapping{value: strings.TrimSpace(value.Mapping.PDDValue), source: mapSource(value.Mapping.Source)}
switch {
case dimension.Role == shopeeproduct.RoleColor && value.Name == targetColor:
result.color = mergeConfirmedProbeMapping(result.color, mapping)
case dimension.Role == shopeeproduct.RoleSize && value.Name == targetSize:
result.size = mergeConfirmedProbeMapping(result.size, mapping)
}
}
}
return result
}
func mergeConfirmedProbeMapping(current, incoming confirmedProbeMapping) confirmedProbeMapping {
if current.invalid {
return current
}
if current.value == "" {
return incoming
}
if current.value != incoming.value {
return confirmedProbeMapping{invalid: true}
}
return current
}
func currentMappedCandidate(mapped string, candidates []string) (string, bool) {
mapped = strings.TrimSpace(mapped)
if mapped == "" {
return "", false
}
matches := make([]string, 0, 1)
seen := map[string]bool{}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" || seen[candidate] || aimatching.Normalize(candidate) != aimatching.Normalize(mapped) {
continue
}
seen[candidate] = true
matches = append(matches, candidate)
}
if len(matches) != 1 {
return "", false
}
return matches[0], true
}
func probeSourceSummary(roleSources map[string]string) string {
if containsRoleSource(roleSources, "manual_mapping") {
return "manual_mapping"
}
if containsRoleSource(roleSources, aimatching.SourceAI) {
return aimatching.SourceAI
}
return aimatching.SourceExact
}
func containsRoleSource(roleSources map[string]string, target string) bool {
for _, source := range roleSources {
if source == target {
return true
}
}
return false
}
+3 -2
View File
@@ -69,7 +69,8 @@ func (s *Service) ProcessStages(ctx context.Context, ids []uint64) (map[uint64]P
return nil, err
}
for _, id := range ids {
preview := s.previewFromDataset(id, dataset, guard)
preview := s.previewFromDataset(ctx, id, dataset, false, guard)
preview.enforcePersistedMatch(id, dataset)
qualification := aiMatchQualificationForDataset(id, dataset)
preview.AIMatchEligible = qualification.Eligible
preview.AIMatchDisabledReason = qualification.DisabledReason
@@ -136,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 {
+4 -16
View File
@@ -87,9 +87,6 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
if err := validatePurchaseResetState(tx, task); err != nil {
return err
}
if task.SpecSource != "unresolved" && !hasCompletePurchaseSpec(task) {
return fail(CodeMappingRequired, "任务没有完整的精确商品规格,请创建新采购任务")
}
deviceRecord, err := lockPurchaseResetDevice(tx, task, authenticated)
if err != nil {
return err
@@ -133,7 +130,10 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
return conflictOrInternal(err)
}
phase := purchaseAttemptPhase(task)
phase := models.PurchaseAttemptPhasePurchase
if task.SpecSource == "unresolved" {
phase = models.PurchaseAttemptPhaseSpecProbe
}
// MySQL normalizes values written to a JSON column. Reload the task before
// hashing so the pending attempt uses the exact representation Start will
// read later, rather than the pre-persistence DefaultLiveRule bytes.
@@ -155,18 +155,6 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
return response, err
}
func purchaseAttemptPhase(task models.PurchaseTask) string {
if task.SpecSource == "unresolved" || !hasCompletePurchaseSpec(task) {
return models.PurchaseAttemptPhaseSpecProbe
}
return models.PurchaseAttemptPhasePurchase
}
func hasCompletePurchaseSpec(task models.PurchaseTask) bool {
return (strings.TrimSpace(task.TargetColorSnapshot) == "" || strings.TrimSpace(task.MappedColorSnapshot) != "") &&
(strings.TrimSpace(task.TargetSizeSnapshot) == "" || strings.TrimSpace(task.MappedSizeSnapshot) != "")
}
func validatePurchaseResetState(tx *gorm.DB, task models.PurchaseTask) error {
switch task.Status {
case models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderCreated, models.PurchaseTaskStatusOrderResultUnknown:
-30
View File
@@ -108,36 +108,6 @@ func TestPurchaseResetReusesTaskRefreshesRuleAndIsIdempotent(t *testing.T) {
}
}
func TestPurchaseResetRejectsResolvedDecisionWithoutExactSpec(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
decisionID := uuid.NewString()
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Updates(map[string]any{
"spec_source": "ai_match", "spec_decision_request_id": decisionID,
"mapped_color_snapshot": "", "mapped_size_snapshot": "",
}).Error; err != nil {
t.Fatal(err)
}
_, err := service.ResetForDevice(context.Background(), failed.ID, PurchaseResetRequest{RequestID: uuid.NewString()}, f.token)
if code(err) != CodeMappingRequired {
t.Fatalf("resolved decision without exact spec was reset: %v", err)
}
var task models.PurchaseTask
if queryErr := db.First(&task, failed.ID).Error; queryErr != nil {
t.Fatal(queryErr)
}
if task.Status != models.PurchaseTaskStatusFailed {
t.Fatalf("rejected reset changed task status: %+v", task)
}
var attemptCount int64
if queryErr := db.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", failed.ID).Count(&attemptCount).Error; queryErr != nil || attemptCount != 0 {
t.Fatalf("rejected reset created attempt: count=%d err=%v", attemptCount, queryErr)
}
}
func TestPurchaseStartPersistsPendingAttemptSnapshotMismatchAsFailure(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
+15 -14
View File
@@ -218,12 +218,7 @@ func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask
return decision
}
_, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
if ruleErr != nil {
code, message, _ := serviceErrorFields(ruleErr)
return retryDecision{ReasonCode: code, Reason: message}
}
preview := s.previewOneDeterministic(ctx, *task.SYBProductID, purchasecontract.EffectivePriceGuard(currentRule))
preview := s.previewOne(ctx, *task.SYBProductID)
if !preview.Eligible {
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
@@ -235,9 +230,10 @@ func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask
}
// retryQueryEligibility is deliberately limited to local task and device
// safety checks. The batch-retry command performs deterministic archive and
// price validation, but specification resolution belongs to the new task's
// mandatory live probe and never calls AI before task creation.
// safety checks. Admin list/detail queries must never call previewOne because
// previewOne can fall back to the external AI provider for unresolved specs.
// The batch-retry command runs retryEligibility and performs that complete,
// current-archive validation only after the operator submits the retry.
func (s *Service) retryQueryEligibility(ctx context.Context, task models.PurchaseTask, checkDeviceBusy bool) retryDecision {
decision := s.retryStateEligibility(ctx, task)
if !decision.Allowed {
@@ -267,20 +263,25 @@ func (s *Service) continuePurchaseEligibility(ctx context.Context, task models.P
guard := purchasecontract.EffectivePriceGuard(currentRule)
syb, found := dataset.sybByID[*task.SYBProductID]
if !found || syb.ShopeeProductID == nil {
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
shopee, found := dataset.shopeeByID[*syb.ShopeeProductID]
if !found || shopee.PDDProductID == nil {
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
_, found = dataset.pddByID[*shopee.PDDProductID]
pdd, found := dataset.pddByID[*shopee.PDDProductID]
if !found {
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
candidates, _ := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
if source == "unresolved" || !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
return retryDecision{ReasonCode: CodeMappingRequired, Reason: "规格匹配已失效,请在 Admin 重新确认"}
}
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
if !preview.Eligible {
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
+3 -3
View File
@@ -223,7 +223,7 @@ func TestAdminListRetryEligibilityRejectsBusyOriginalDevice(t *testing.T) {
}
}
func TestAdminQueriesAndBatchRetryDoNotResolveArchivedSpecs(t *testing.T) {
func TestAdminQueriesDoNotResolveSpecsButBatchRetryStillDoes(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
service := testService(db)
@@ -253,8 +253,8 @@ func TestAdminQueriesAndBatchRetryDoNotResolveArchivedSpecs(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if matcher.calls != 0 || retry.CreatedCount != 1 || retry.FailedCount != 0 {
t.Fatalf("batch retry did not create a mandatory probe task: result=%+v matcherCalls=%d", retry, matcher.calls)
if matcher.calls != 1 || retry.CreatedCount != 0 || retry.FailedCount != 1 || retry.Items[0].ReasonCode != CodeMappingRequired {
t.Fatalf("batch retry skipped full matching: result=%+v matcherCalls=%d", retry, matcher.calls)
}
}
+75 -13
View File
@@ -54,6 +54,11 @@ type SpecMatcher interface {
Resolve(context.Context, aimatching.MatchRequest) (aimatching.MatchResult, error)
}
type externalMatchPlan struct {
fingerprint string
snapshot string
}
type matchFingerprint struct {
SYBProductID uint64 `json:"sybProductId"`
TargetColor string `json:"targetColor"`
@@ -91,6 +96,51 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas
return call.task, call.replayed, call.err
}
func (s *Service) prepareExternalMatch(ctx context.Context, req CreateRequest) (*externalMatchPlan, error) {
if req.ExecutionMode != models.PurchaseExecutionModeLive || req.SYBProductID == nil {
return nil, nil
}
var syb models.SYBProduct
if err := s.DB.WithContext(ctx).First(&syb, *req.SYBProductID).Error; err != nil {
return nil, notFound(err, "顺云宝商品不存在")
}
if syb.ShopeeProductID == nil {
return nil, fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品")
}
var shopee models.ShopeeProduct
if err := s.DB.WithContext(ctx).First(&shopee, *syb.ShopeeProductID).Error; err != nil {
return nil, notFound(err, "蝦皮商品不存在")
}
if shopee.PDDProductID == nil {
return nil, fail(CodeInvalidRequest, "该蝦皮商品尚未关联拼多多商品")
}
var pdd models.PDDProduct
if err := s.DB.WithContext(ctx).First(&pdd, *shopee.PDDProductID).Error; err != nil {
return nil, notFound(err, "拼多多商品不存在")
}
targetColor, targetSize := strings.TrimSpace(req.TargetColor), strings.TrimSpace(req.TargetSize)
if targetColor == "" {
targetColor = syb.TargetColor
}
if targetSize == "" {
targetSize = syb.TargetSize
}
_, _, source := confirmedMappings(shopee.SpecsJSON, targetColor, targetSize)
candidates, usable := archiveCandidates(pdd.SpecsJSON, targetColor, targetSize)
if pdd.Status != "active" || source != "unresolved" || !usable {
return nil, nil
}
request := aimatching.MatchRequest{TargetColor: targetColor, TargetSize: targetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
if _, deterministic := aimatching.DeterministicMatch(request); deterministic {
return nil, nil
}
fingerprint, snapshot, err := creationMatchInput(syb, shopee, pdd, request)
if err != nil {
return nil, internal(err)
}
return &externalMatchPlan{fingerprint: fingerprint, snapshot: snapshot}, nil
}
func creationMatchInput(syb models.SYBProduct, shopee models.ShopeeProduct, pdd models.PDDProduct, request aimatching.MatchRequest) (string, string, error) {
canonical := func(raw string) (json.RawMessage, error) {
var value any
@@ -144,6 +194,10 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return models.PurchaseTask{}, false, internal(err)
}
externalPlan, err := s.prepareExternalMatch(ctx, req)
if err != nil {
return models.PurchaseTask{}, false, err
}
replayed := false
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("create_request_id = ?", req.RequestID).First(&out).Error; err == nil {
@@ -280,25 +334,22 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
if currency == "" {
currency = shopee.Currency
}
// Every new SYB purchase must use the candidates observed on the
// current PDD page. Freeze confirmed mappings only as guidance for
// validation against that future probe; they never skip the probe.
mappedColor, mappedSize, specSource = "", "", "unresolved"
guidance, marshalErr := newProbeGuidanceSnapshot(shopee.SpecsJSON, targetColor, targetSize)
if marshalErr != nil {
return internal(marshalErr)
}
decisionSnapshot = guidance
mappedColor, mappedSize, specSource = confirmedMappings(shopee.SpecsJSON, targetColor, targetSize)
}
candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, targetColor, targetSize)
matchRequest := aimatching.MatchRequest{TargetColor: targetColor, TargetSize: targetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
if externalPlan != nil {
fingerprint, fingerprintErr := creationMatchFingerprint(syb, shopee, pdd, matchRequest)
if fingerprintErr != nil {
return internal(fingerprintErr)
}
if fingerprint != externalPlan.fingerprint {
return fail(CodeMappingRequired, "规格匹配输入已变化,请重新创建采购任务")
}
}
if taskType == models.PurchaseTaskTypeStock {
// Direct stock selection was already validated above. It never enters
// mapping, deterministic matching, or the external AI fallback.
} else if req.ExecutionMode == models.PurchaseExecutionModeLive {
// The first attempt is always spec_probe for an SYB purchase. The
// exact task-level decision is frozen only from that probe result.
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if pdd.Status != "active" {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if specSource == "manual_mapping" || specSource == "exact_match" || specSource == "ai_match" {
@@ -314,6 +365,8 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
}
} else if !archiveUsable {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if externalPlan != nil {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if match, matched := aimatching.DeterministicMatch(matchRequest); !matched {
// #190:确定性匹配失败不再拒绝,降级为 unresolved 交给规格探测。
mappedColor, mappedSize, specSource = "", "", "unresolved"
@@ -351,8 +404,17 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
if err := tx.Model(&out).Update("address_suffix", out.AddressSuffix).Error; err != nil {
return internal(err)
}
if externalPlan != nil {
work := models.PurchaseSpecMatchWorkItem{PurchaseTaskID: out.ID, Status: models.PurchaseMatchPending, InputFingerprint: externalPlan.fingerprint, InputSnapshotJSON: externalPlan.snapshot}
if err := tx.Create(&work).Error; err != nil {
return internal(err)
}
}
return nil
})
if err == nil && !replayed && externalPlan != nil && s.StartMatching != nil {
s.StartMatching(s.DB)
}
return out, replayed, err
}
+25 -321
View File
@@ -9,7 +9,6 @@ import (
"testing"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/device"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
@@ -30,19 +29,6 @@ type fixture struct {
token string
}
type liveProbeMatcher struct {
result aimatching.MatchResult
err error
calls int
request aimatching.MatchRequest
}
func (matcher *liveProbeMatcher) Resolve(_ context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, error) {
matcher.calls++
matcher.request = request
return matcher.result, matcher.err
}
func testDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on", strings.ReplaceAll(t.Name(), "/", "_"))
@@ -121,30 +107,6 @@ func createLive(t *testing.T, s *Service, f fixture) (models.PurchaseTask, error
r, _, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)})
return r, err
}
func startLivePurchaseAfterProbe(t *testing.T, s *Service, f fixture, task models.PurchaseTask) TaskPayload {
t.Helper()
if _, err := s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
probeAttempt, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || probeAttempt.Phase != models.PurchaseAttemptPhaseSpecProbe {
t.Fatalf("probe start: %+v %v", probeAttempt, err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: probeAttempt.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
if _, err = s.SubmitResult(context.Background(), task.ID, probe, f.token); err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
purchaseAttempt, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || purchaseAttempt.Phase != models.PurchaseAttemptPhasePurchase {
t.Fatalf("purchase start: %+v %v", purchaseAttempt, err)
}
return purchaseAttempt
}
func code(err error) string {
var e *ServiceError
if errors.As(err, &e) {
@@ -262,23 +224,9 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T)
if err != nil {
t.Fatal(err)
}
if start.Phase != models.PurchaseAttemptPhaseSpecProbe {
if start.Phase != models.PurchaseAttemptPhasePurchase {
t.Fatalf("unexpected phase %s", start.Phase)
}
if _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeStateConflict {
t.Fatalf("probe entered order boundary: %v", err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: start.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
if _, err = s.SubmitResult(context.Background(), task.ID, probe, f.token); err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
start, err = s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || start.Phase != models.PurchaseAttemptPhasePurchase {
t.Fatalf("purchase start: %+v %v", start, err)
}
if _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
@@ -295,7 +243,7 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T)
}
var count int64
db.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", task.ID).Count(&count)
if count != 2 || first.Status != models.PurchaseTaskStatusOrderCreated {
if count != 1 || first.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatalf("duplicate attempt or wrong status: %d %+v", count, first)
}
var stored models.PurchaseTask
@@ -396,198 +344,6 @@ func TestSlowPathUsesTwoAttemptsAndFreezesDecision(t *testing.T) {
}
}
func TestLiveProbeCallsAIOnlyAfterCandidatesAreReturned(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil {
t.Fatal(err)
}
request := aimatching.MatchRequest{TargetColor: "象牙白", TargetSize: "XL", Colors: []string{"米白色", "白色"}, Sizes: []string{"XL"}}
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(request, aimatching.SourceAI, "米白色", "XL", "在当次候选中唯一匹配")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil || matcher.calls != 0 || task.SpecSource != "unresolved" {
t.Fatalf("task creation called matcher or skipped probe: task=%+v calls=%d err=%v", task, matcher.calls, err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe {
t.Fatalf("probe start: %+v %v", first, err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["米白色","白色"]},{"key":"size","values":["XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || matcher.calls != 1 || resolved.Status != models.PurchaseTaskStatusPending || resolved.MappedColor != "米白色" || resolved.MappedSize != "XL" {
t.Fatalf("probe AI result: %+v calls=%d err=%v", resolved, matcher.calls, err)
}
}
func TestLiveProbeReusesConfirmedColorAndResolvesCurrentSizeWithoutAI(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
"target_color": "薑黃色",
"target_size": "5XL",
}).Error; err != nil {
t.Fatal(err)
}
specs := []shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "薑黃色", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "黄色", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "5XL", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "XXXXXL", Source: shopeeproduct.MappingSourceAIMatch, Status: shopeeproduct.MappingStatusConfirmed, Reason: "历史档案原文"}}}},
}
raw, _ := json.Marshal(specs)
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", string(raw)).Error; err != nil {
t.Fatal(err)
}
matcher := &liveProbeMatcher{err: errors.New("AI should not be called")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
t.Fatalf("live task skipped mandatory probe: %+v err=%v", task, err)
}
if err = db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", `[]`).Error; err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["白色","黄色","黑色"]},{"key":"size","values":["4XL","5XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || matcher.calls != 0 || resolved.Status != models.PurchaseTaskStatusPending {
t.Fatalf("confirmed mapping resolution failed: %+v calls=%d err=%v", resolved, matcher.calls, err)
}
if resolved.MappedColor != "黄色" || resolved.MappedSize != "5XL" {
t.Fatalf("task did not freeze current probe labels: %+v", resolved)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil {
t.Fatal(err)
}
var decision struct {
RoleSources map[string]string `json:"roleSources"`
}
if err = json.Unmarshal([]byte(saved.SpecDecisionSnapshot), &decision); err != nil {
t.Fatal(err)
}
if saved.SpecSource != "manual_mapping" || decision.RoleSources[shopeeproduct.RoleColor] != "manual_mapping" || decision.RoleSources[shopeeproduct.RoleSize] != aimatching.SourceExact {
t.Fatalf("mixed role sources were not audited: task=%+v decision=%+v", saved, decision)
}
}
func TestLiveProbeCallsAIOnlyForRoleWithoutCurrentConfirmedMapping(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "薑黃色").Error; err != nil {
t.Fatal(err)
}
specs := []shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "薑黃色", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "旧黄色", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "XL", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "XL", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
}
raw, _ := json.Marshal(specs)
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", string(raw)).Error; err != nil {
t.Fatal(err)
}
aiRequest := aimatching.MatchRequest{TargetColor: "薑黃色", Colors: []string{"黄色"}}
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(aiRequest, aimatching.SourceAI, "黄色", "", "当前颜色候选匹配")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黄色"]},{"key":"size","values":["XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || matcher.calls != 1 || resolved.MappedColor != "黄色" || resolved.MappedSize != "XL" {
t.Fatalf("partial AI resolution failed: %+v request=%+v err=%v", resolved, matcher.request, err)
}
if matcher.request.TargetColor != "薑黃色" || matcher.request.TargetSize != "" || len(matcher.request.Colors) != 1 || len(matcher.request.Sizes) != 0 {
t.Fatalf("resolved size was unnecessarily sent to AI: %+v", matcher.request)
}
}
func TestLiveProbeRejectsMatcherValueOutsideCurrentCandidates(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙黑").Error; err != nil {
t.Fatal(err)
}
request := aimatching.MatchRequest{TargetColor: "象牙黑", Colors: []string{"黑色"}}
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(request, aimatching.SourceAI, "候选外颜色", "XL", "无效返回")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || resolved.Status != models.PurchaseTaskStatusFailed || resolved.MappedColor != "" || resolved.MappedSize != "" {
t.Fatalf("out-of-candidate result was accepted: %+v err=%v", resolved, err)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil || saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_NOT_MATCHED" {
t.Fatalf("outside-candidate failure was not persisted: %+v err=%v", saved, err)
}
if saved.ErrorMessage == nil || *saved.ErrorMessage != "已采集到当前规格,但未能确定颜色或尺码映射" {
t.Fatalf("outside-candidate failure reason was not preserved: %+v", saved)
}
}
func TestLiveProbeUsesAccurateMessageWhenCompleteCandidatesCannotBeMatched(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "薑黃色").Error; err != nil {
t.Fatal(err)
}
s := testService(db)
s.Matcher = &liveProbeMatcher{err: &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "no match"}}
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黄色"]},{"key":"size","values":["XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || resolved.Status != models.PurchaseTaskStatusFailed {
t.Fatalf("no-match probe did not converge: %+v err=%v", resolved, err)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil {
t.Fatal(err)
}
if saved.ErrorMessage == nil || *saved.ErrorMessage != "已采集到当前规格,但未能确定颜色或尺码映射" {
t.Fatalf("complete candidates used misleading failure reason: %+v", saved)
}
}
func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
@@ -672,7 +428,7 @@ func TestSpecResolutionAllowedMatrix(t *testing.T) {
}
}
func TestLiveProbeUsesDeterministicTraditionalAndWeightMatching(t *testing.T) {
func TestCreateUsesDeterministicTraditionalAndWeightMatching(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"浅蓝色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"4XL 160-170斤","selectable":true}]}]`).Error; err != nil {
@@ -681,26 +437,12 @@ func TestLiveProbeUsesDeterministicTraditionalAndWeightMatching(t *testing.T) {
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{"target_color": "淺藍色", "target_size": "4XL 80-85公斤"}).Error; err != nil {
t.Fatal(err)
}
s := testService(db)
task, err := createLive(t, s, f)
if err != nil || task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
t.Fatalf("live task skipped probe: %+v %v", task, err)
task, err := createLive(t, testService(db), f)
if err != nil || task.SpecSource != "exact_match" || task.MappedColorSnapshot != "浅蓝色" || task.MappedSizeSnapshot != "4XL 160-170斤" {
t.Fatalf("deterministic match failed: %+v %v", task, err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe {
t.Fatalf("probe start: %+v %v", first, err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["浅蓝色"]},{"key":"size","values":["4XL 160-170斤"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || resolved.MappedColor != "浅蓝色" || resolved.MappedSize != "4XL 160-170斤" {
t.Fatalf("deterministic probe match failed: %+v %v", resolved, err)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil || strings.Contains(saved.SpecDecisionSnapshot, "apiKey") || !strings.Contains(saved.SpecDecisionSnapshot, "exact_match") {
t.Fatalf("task decision snapshot is unsafe or incomplete: %s err=%v", saved.SpecDecisionSnapshot, err)
if strings.Contains(task.SpecDecisionSnapshot, "apiKey") || !strings.Contains(task.SpecDecisionSnapshot, "exact_match") {
t.Fatalf("task decision snapshot is unsafe or incomplete: %s", task.SpecDecisionSnapshot)
}
}
@@ -715,12 +457,12 @@ func TestCreateDoesNotTrustMappedLabelsFromRequest(t *testing.T) {
SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MappedColor: "白色", MappedSize: "XL",
MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true),
})
if err != nil || task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
t.Fatalf("request-provided mapping must not skip live probe: %+v %v", task, err)
if err != nil || task.SpecSource != "exact_match" || task.MappedColorSnapshot != "黑色" || task.MappedSizeSnapshot != "XL" {
t.Fatalf("request-provided mapping must not override server match: %+v %v", task, err)
}
}
func TestCreateDoesNotQueueArchiveMatchingBeforeLiveProbe(t *testing.T) {
func TestCreateQueuesUsableArchiveWhenDeterministicMatchFails(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"L","selectable":true}]}]`).Error; err != nil {
@@ -733,12 +475,12 @@ func TestCreateDoesNotQueueArchiveMatchingBeforeLiveProbe(t *testing.T) {
t.Fatalf("task=%+v err=%v", task, err)
}
var count int64
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 0 {
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("work count=%d err=%v", count, err)
}
}
func TestCreateDispatchesProbeInsteadOfExternalArchiveMatching(t *testing.T) {
func TestCreateQueuesExternalMatchingWithoutCallingProvider(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000},{"name":"米白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
@@ -753,48 +495,12 @@ func TestCreateDispatchesProbeInsteadOfExternalArchiveMatching(t *testing.T) {
if err != nil || task.ID == 0 || task.SpecSource != "unresolved" {
t.Fatalf("queued task=%+v err=%v", task, err)
}
var count int64
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("unexpected archive match work count=%d err=%v", count, err)
var work models.PurchaseSpecMatchWorkItem
if err := db.Where("purchase_task_id = ?", task.ID).First(&work).Error; err != nil || work.Status != models.PurchaseMatchPending || work.InputFingerprint == "" {
t.Fatalf("work=%+v err=%v", work, err)
}
if next, err := service.Next(context.Background(), f.token); err != nil || next == nil || next.MappedColor != "" || next.MappedSize != "" || !next.SpecResolutionAllowed {
t.Fatalf("probe task was not dispatchable: %+v %v", next, err)
}
}
func TestNextReturnsAssignedProbeWaitingTaskBeforeOtherWork(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1200}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
t.Fatal(err)
}
service := testService(db)
request := StockCreateRequest{
RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive,
PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL",
Quantity: 1, MinUnitPriceCent: 900, MaxUnitPriceCent: 1500,
}
waiting, _, err := service.CreateStock(context.Background(), request)
if err != nil {
t.Fatal(err)
}
request.RequestID = uuid.NewString()
other, _, err := service.CreateStock(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", waiting.ID).Updates(map[string]any{
"status": models.PurchaseTaskStatusSpecProbePending, "mapped_color_snapshot": "", "mapped_size_snapshot": "",
}).Error; err != nil {
t.Fatal(err)
}
next, err := service.Next(context.Background(), f.token)
if err != nil || next == nil || next.TaskID != waiting.ID || next.Status != models.PurchaseTaskStatusSpecProbePending {
t.Fatalf("waiting probe task not reserved: next=%+v other=%d err=%v", next, other.ID, err)
}
if _, err = service.Claim(context.Background(), other.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeDeviceBusy {
t.Fatalf("other purchase claimed while probe waits: %v", err)
if next, err := service.Next(context.Background(), f.token); err != nil || next != nil {
t.Fatalf("matching task dispatched: %+v %v", next, err)
}
}
@@ -803,10 +509,9 @@ func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) {
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, _ := createLive(t, s, f)
started := startLivePurchaseAfterProbe(t, s, f, task)
if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
started, _ := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
actual := int64(1999)
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
@@ -822,7 +527,7 @@ func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) {
t.Fatalf("unknown task evidence not preserved: %+v", saved)
}
var attempt models.PurchaseTaskAttempt
if err := db.Where("task_id = ?", task.ID).Order("attempt_number DESC").First(&attempt).Error; err != nil {
if err := db.Where("task_id = ?", task.ID).First(&attempt).Error; err != nil {
t.Fatal(err)
}
if attempt.ErrorCode == nil || *attempt.ErrorCode != CodeOrderTimeMissing || attempt.ErrorMessage == nil {
@@ -839,10 +544,9 @@ func TestOrderUnknownRejectsOrderFieldsAndUnapprovedFailureCode(t *testing.T) {
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, _ := createLive(t, s, f)
started := startLivePurchaseAfterProbe(t, s, f, task)
if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
started, _ := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
@@ -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:
+7 -26
View File
@@ -13,13 +13,6 @@ import (
var bracketPattern = regexp.MustCompile(`【[^】]*】`)
// explicitSizePattern recognizes only values whose spelling carries a strong
// size signal. SYB has now been observed returning both "color,size" and
// "size,color". A color dictionary would inevitably guess at product-specific
// labels, so role reversal is allowed only when exactly one side matches this
// deliberately narrow pattern.
var explicitSizePattern = regexp.MustCompile(`(?i)^(?:均(?:码|碼|号|號)|one\s*size|free\s*size|x{0,4}[sml]|[2-9]xl|(?:加大|大|中|小)(?:码|碼|号|號)|\d+(?:\.\d+)?(?:cm|mm|m|码|碼|号|號|公分)|\d+(?:\.\d+)?(?:[-~~至到]\d+(?:\.\d+)?)?(?:斤|公斤|千克|kg))$`)
// ambiguousPattern flags leftover separators or multi-token noise after
// bracket stripping — the signal that a "clean" split still isn't reliable.
// Observed in the real SYB sample: "黑色+白色【純棉兩件裝】 簡約親膚" strips
@@ -36,8 +29,8 @@ type ParseResult struct {
Note string
}
// Parse implements the #41/#216 rule: split on the last comma, strip 【...】
// annotations, and classify the result. It never guesses a
// Parse implements the #41 rule: split on the last comma into color/size,
// strip 【...】 annotations, and classify the result. It never guesses a
// missing value and never invents a color or size that is not literally
// present in the input.
//
@@ -48,13 +41,9 @@ type ParseResult struct {
// brackets stripped, becomes the size candidate; color stays empty. A
// single token with no separator cannot be split into two dimensions
// without guessing which one it is.
// - comma present and exactly one side has an explicit size signal -> that
// side is size and the other side is color. This supports both observed
// SYB orders without allowing AI or a fuzzy color dictionary to swap roles.
// - comma present and neither side has an explicit size signal -> retain the
// established SYB color,size contract for backward compatibility.
// - comma present and both sides have explicit size signals -> uncertain;
// there is no safe color decision.
// - comma present, last segment (size) and first segment(s) (color) both
// non-empty after stripping brackets, and the color candidate has no
// leftover separator/whitespace -> success.
// - comma present but either side is empty after stripping, or the color
// candidate still carries a leftover '+' or internal whitespace -> the
// split happened but is not trustworthy -> uncertain.
@@ -76,20 +65,12 @@ func Parse(raw string) ParseResult {
return ParseResult{Size: size, Status: models.SYBParseStatusUncertain, Note: "无分隔符,仅识别出单一规格值"}
}
firstPart := stripBrackets(trimmed[:lastComma])
secondPart := stripBrackets(trimmed[lastComma+1:])
colorPart, sizePart := firstPart, secondPart
colorPart := stripBrackets(trimmed[:lastComma])
sizePart := stripBrackets(trimmed[lastComma+1:])
if colorPart == "" || sizePart == "" {
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "按逗号拆分后有一侧为空"}
}
firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart)
if firstIsSize && secondIsSize {
return ParseResult{Color: firstPart, Size: secondPart, Status: models.SYBParseStatusUncertain, Note: "逗号两侧均具有尺码特征,无法安全识别颜色"}
}
if firstIsSize {
colorPart, sizePart = secondPart, firstPart
}
if ambiguousPattern.MatchString(colorPart) {
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "颜色部分含备注文本或多个分隔符,拆分结果可能不准确"}
}
-21
View File
@@ -19,27 +19,6 @@ func TestParseRealSample_CleanColorAndSize(t *testing.T) {
}
}
func TestParseReversedSizeAndColorWhenOnlyFirstHasExplicitSizeEvidence(t *testing.T) {
result := sybimport.Parse("均碼,黑色")
if result.Color != "黑色" || result.Size != "均碼" || result.Status != models.SYBParseStatusSuccess {
t.Fatalf("expected reversed role parse 黑色/均碼 success, got %+v", result)
}
}
func TestParseKeepsTraditionalColorAndSizeOrderWhenSecondHasSizeEvidence(t *testing.T) {
result := sybimport.Parse("黑色,均码")
if result.Color != "黑色" || result.Size != "均码" || result.Status != models.SYBParseStatusSuccess {
t.Fatalf("expected normal role parse 黑色/均码 success, got %+v", result)
}
}
func TestParseTwoExplicitSizesIsUncertainInsteadOfSwapping(t *testing.T) {
result := sybimport.Parse("M,L")
if result.Status != models.SYBParseStatusUncertain || result.Color != "M" || result.Size != "L" {
t.Fatalf("expected conflicting size evidence to stay uncertain, got %+v", result)
}
}
func TestParseRealSample_AmbiguousColorWithAnnotationAndSeparator(t *testing.T) {
result := sybimport.Parse("黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】")
if result.Status != models.SYBParseStatusUncertain {
+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.
-164
View File
@@ -1,164 +0,0 @@
package sybimport
import (
"context"
"encoding/json"
"sort"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"gorm.io/gorm"
)
// SpecRoleRepairReport contains only aggregate migration evidence. It does not
// expose order numbers, raw source payloads, or specification text.
type SpecRoleRepairReport struct {
SYBRows int
ShopeeProducts int
}
type specRoleChange struct {
OldColor string
OldSize string
}
// RepairReversedSpecRoles corrects only the historical shape that #216 can
// prove was role-reversed: the newly parsed color/size are exactly the old
// size/color. Human-confirmed rows are never overwritten. RawJSON is read for
// reparsing and deliberately excluded from every update.
func RepairReversedSpecRoles(ctx context.Context, db *gorm.DB) (SpecRoleRepairReport, error) {
report := SpecRoleRepairReport{}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var records []models.SYBProduct
if err := tx.Where("manually_confirmed = ?", false).Find(&records).Error; err != nil {
return err
}
changesByProduct := map[uint64][]specRoleChange{}
for _, record := range records {
var raw rawDetailSpec
if json.Unmarshal([]byte(record.RawJSON), &raw) != nil {
continue
}
parsed := Parse(raw.ProductSpec)
if parsed.Status == models.SYBParseStatusFailed || parsed.Color == record.TargetColor ||
parsed.Color != record.TargetSize || parsed.Size != record.TargetColor {
continue
}
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(map[string]any{
"target_color": parsed.Color,
"target_size": parsed.Size,
"parse_status": parsed.Status,
"parse_note": parsed.Note,
}).Error; err != nil {
return err
}
report.SYBRows++
if record.ShopeeProductID != nil {
productID := *record.ShopeeProductID
changesByProduct[productID] = append(changesByProduct[productID], specRoleChange{
OldColor: record.TargetColor,
OldSize: record.TargetSize,
})
}
}
for productID, changes := range changesByProduct {
changed, err := reconcileRepairedShopeeSpecs(tx, productID, changes)
if err != nil {
return err
}
if changed {
report.ShopeeProducts++
}
}
return nil
})
return report, err
}
func reconcileRepairedShopeeSpecs(tx *gorm.DB, productID uint64, changes []specRoleChange) (bool, error) {
var product models.ShopeeProduct
if err := tx.First(&product, productID).Error; err != nil {
return false, err
}
existing, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
return false, err
}
var rows []models.SYBProduct
if err := tx.Where("shopee_product_id = ? AND (parse_status = ? OR manually_confirmed = ?)",
productID, models.SYBParseStatusSuccess, true).Find(&rows).Error; err != nil {
return false, err
}
usedColors, usedSizes := map[string]bool{}, map[string]bool{}
for _, row := range rows {
if row.TargetColor != "" {
usedColors[row.TargetColor] = true
}
if row.TargetSize != "" {
usedSizes[row.TargetSize] = true
}
}
oldColors, oldSizes := map[string]bool{}, map[string]bool{}
for _, change := range changes {
oldColors[change.OldColor] = true
oldSizes[change.OldSize] = true
}
cleaned := make([]shopeeproduct.SpecDimension, 0, len(existing))
for _, dimension := range existing {
values := make([]shopeeproduct.SpecValue, 0, len(dimension.Values))
for _, value := range dimension.Values {
remove := value.Source == shopeeproduct.ValueSourceImport && value.Mapping == nil
switch dimension.Role {
case shopeeproduct.RoleColor:
remove = remove && oldColors[value.Name] && !usedColors[value.Name]
case shopeeproduct.RoleSize:
remove = remove && oldSizes[value.Name] && !usedSizes[value.Name]
default:
remove = false
}
if !remove {
values = append(values, value)
}
}
dimension.Values = values
cleaned = append(cleaned, dimension)
}
incoming := make([]shopeeproduct.SpecDimension, 0, 2)
if values := sortedImportValues(usedColors); len(values) > 0 {
incoming = append(incoming, shopeeproduct.SpecDimension{Name: "颜色", Role: shopeeproduct.RoleColor, Values: values})
}
if values := sortedImportValues(usedSizes); len(values) > 0 {
incoming = append(incoming, shopeeproduct.SpecDimension{Name: "尺码", Role: shopeeproduct.RoleSize, Values: values})
}
reconciled := shopeeproduct.Merge(cleaned, incoming)
if err := shopeeproduct.Validate(reconciled); err != nil {
return false, err
}
encoded, err := shopeeproduct.Marshal(reconciled)
if err != nil {
return false, err
}
if encoded == product.SpecsJSON {
return false, nil
}
return true, tx.Model(&models.ShopeeProduct{}).Where("id = ?", productID).Update("specs_json", encoded).Error
}
func sortedImportValues(set map[string]bool) []shopeeproduct.SpecValue {
names := make([]string, 0, len(set))
for name := range set {
names = append(names, name)
}
sort.Strings(names)
values := make([]shopeeproduct.SpecValue, 0, len(names))
for _, name := range names {
values = append(values, shopeeproduct.SpecValue{Name: name, Source: shopeeproduct.ValueSourceImport})
}
return values
}
@@ -1,126 +0,0 @@
package sybimport
import (
"context"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
)
func TestRepairReversedSpecRolesIsSafeAndIdempotent(t *testing.T) {
db := newSyncTestDB(t)
manualMapping := &shopeeproduct.Mapping{PDDValue: "藏青", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{
{Name: "均碼", Source: shopeeproduct.ValueSourceImport},
{Name: "手工蓝", Source: shopeeproduct.ValueSourceManual, Mapping: manualMapping},
}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}}},
})
if err != nil {
t.Fatal(err)
}
product := models.ShopeeProduct{ShopeeItemID: "ROLE-REPAIR", Title: "测试", ShopName: "测试店铺", Currency: "TWD", SpecsJSON: specs}
if err := db.Create(&product).Error; err != nil {
t.Fatal(err)
}
raw := `{"productSpec":"均碼,黑色"}`
row := models.SYBProduct{OrderCode: "ROLE-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: product.ShopeeItemID,
ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "均碼", TargetSize: "黑色", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: raw}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
manual := models.SYBProduct{OrderCode: "MANUAL-ORDER", DetailID: 2, StockID: 2, ShopeeItemID: product.ShopeeItemID,
ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "人工颜色", TargetSize: "人工尺码", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, ManuallyConfirmed: true, RawJSON: raw}
if err := db.Create(&manual).Error; err != nil {
t.Fatal(err)
}
report, err := RepairReversedSpecRoles(context.Background(), db)
if err != nil || report.SYBRows != 1 || report.ShopeeProducts != 1 {
t.Fatalf("unexpected repair report=%+v err=%v", report, err)
}
var repaired, untouched models.SYBProduct
db.First(&repaired, row.ID)
db.First(&untouched, manual.ID)
if repaired.TargetColor != "黑色" || repaired.TargetSize != "均碼" || repaired.RawJSON != raw {
t.Fatalf("repaired row is invalid: %+v", repaired)
}
if untouched.TargetColor != "人工颜色" || untouched.TargetSize != "人工尺码" {
t.Fatalf("manual row was overwritten: %+v", untouched)
}
db.First(&product, product.ID)
reconciled, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
t.Fatal(err)
}
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "均碼", false)
assertSpecValue(t, reconciled, shopeeproduct.RoleSize, "黑色", false)
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "黑色", true)
assertSpecValue(t, reconciled, shopeeproduct.RoleSize, "均碼", true)
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "手工蓝", true)
replay, err := RepairReversedSpecRoles(context.Background(), db)
if err != nil || replay.SYBRows != 0 || replay.ShopeeProducts != 0 {
t.Fatalf("repair was not idempotent: report=%+v err=%v", replay, err)
}
}
func TestRepairReversedSpecRolesKeepsAmbiguousColorUncertain(t *testing.T) {
db := newSyncTestDB(t)
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "均碼", Source: shopeeproduct.ValueSourceImport}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "黑色+白色", Source: shopeeproduct.ValueSourceImport}}},
})
if err != nil {
t.Fatal(err)
}
product := models.ShopeeProduct{ShopeeItemID: "ROLE-UNCERTAIN", Title: "测试", ShopName: "测试店铺", Currency: "TWD", SpecsJSON: specs}
if err := db.Create(&product).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{OrderCode: "UNCERTAIN-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: product.ShopeeItemID,
ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "均碼", TargetSize: "黑色+白色", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"productSpec":"均碼,黑色+白色"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
report, err := RepairReversedSpecRoles(context.Background(), db)
if err != nil || report.SYBRows != 1 || report.ShopeeProducts != 1 {
t.Fatalf("unexpected repair report=%+v err=%v", report, err)
}
db.First(&row, row.ID)
if row.TargetColor != "黑色+白色" || row.TargetSize != "均碼" || row.ParseStatus != models.SYBParseStatusUncertain {
t.Fatalf("ambiguous color role/status was not preserved safely: %+v", row)
}
db.First(&product, product.ID)
reconciled, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
t.Fatal(err)
}
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "均碼", false)
assertSpecValue(t, reconciled, shopeeproduct.RoleSize, "黑色+白色", false)
}
func assertSpecValue(t *testing.T, specs []shopeeproduct.SpecDimension, role, name string, want bool) {
t.Helper()
found := false
for _, dimension := range specs {
if dimension.Role != role {
continue
}
for _, value := range dimension.Values {
if value.Name == name {
found = true
}
}
}
if found != want {
t.Fatalf("role=%s value=%q found=%v want=%v specs=%+v", role, name, found, want, specs)
}
}
+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)
}
}
+3 -3
View File
@@ -182,9 +182,9 @@ func ensureDeviceIdleForCurrentPage(tx *gorm.DB, deviceID uint64, now time.Time)
return serviceError(CodeDeviceBusy, "设备正在执行任务,请稍后再试")
}
if err := tx.Model(&models.PurchaseTask{}).
Where("device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", deviceID,
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending},
models.PurchaseTaskStatusPending, now).
Where("device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", deviceID,
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted},
[]string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).
Count(&busy).Error; err != nil {
return internalError(err)
}
+3 -3
View File
@@ -246,9 +246,9 @@ func ensureDeviceIdleForReset(tx *gorm.DB, deviceID, taskID uint64, now time.Tim
return serviceError(CodeDeviceBusy, "设备正在执行其他任务")
}
if err := tx.Model(&models.PurchaseTask{}).
Where("device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", deviceID,
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending},
models.PurchaseTaskStatusPending, now).
Where("device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", deviceID,
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted},
[]string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).
Count(&busy).Error; err != nil {
return internalError(err)
}
+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))
}

Some files were not shown because too many files have changed in this diff Show More