Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e91fc0cc28 | ||
|
|
003ab38a36 | ||
|
|
81c1a7ead2 | ||
|
|
aa2ecc6ef7 | ||
|
|
f27ec16307 | ||
|
|
1d029d34c7 | ||
|
|
b12460825c | ||
|
|
d7924619b6 | ||
|
|
041cd8d03f |
@@ -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` 确实被执行策略阻止且没有更小替代方案时,才对该次进程使用并在工单记录原因。
|
||||
- 涉及创建订单、权限、安全、并发、迁移和删除数据属于高风险,真机或正式实施前必须再次等待人工确认。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+29
-68
@@ -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) }
|
||||
|
||||
+12
-69
@@ -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 {
|
||||
|
||||
+13
-112
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+94
-381
@@ -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) {
|
||||
|
||||
+4
-29
@@ -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
|
||||
}
|
||||
|
||||
-114
@@ -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
|
||||
|
||||
+37
-356
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 -->
|
||||
|
||||
# 开发工作流
|
||||
|
||||
@@ -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,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 -->
|
||||
|
||||
# 本地开发与验证
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
# 常见修改指南
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
# 故障排查
|
||||
|
||||
@@ -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,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 决策权限。
|
||||
|
||||
@@ -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 交付工单索引
|
||||
|
||||
@@ -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 商品详情采集规则迁移分析
|
||||
|
||||
@@ -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 接口契约
|
||||
|
||||
@@ -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
@@ -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 文档中心
|
||||
|
||||
Vendored
+1
-1
@@ -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 -->
|
||||
|
||||
# 部署文档模板
|
||||
|
||||
Vendored
+1
-1
@@ -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/`。
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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" }
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: "颜色部分含备注文本或多个分隔符,拆分结果可能不准确"}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{},
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user