Compare commits

..
Author SHA1 Message Date
QiuSW 644e7642ee fix(android): settle before spec gesture retry (#268) 2026-09-11 11:44:40 +08:00
QiuSW a0a25298c3 fix(android): align purchase spec entry diagnostics (#267) 2026-09-11 11:24:30 +08:00
QiuSW c548285d59 fix(android): settle product page before spec entry (#266) 2026-09-11 10:51:51 +08:00
QiuSW b0256190a5 feat(android): persist and export purchase diagnostics (#264) 2026-09-11 10:23:27 +08:00
QiuSW 5f4d69b3ae fix(syb): recover bounded today pagination overlaps (#235) 2026-09-07 14:27:17 +08:00
QiuSW 1900dab32e feat(web): remember batch purchase device per user (#233) 2026-09-07 11:25:12 +08:00
QiuSW 0cb36b1e73 fix(agent): retain purchase panel recognition after heading scroll (#231) 2026-09-07 11:05:24 +08:00
QiuSW 3efe4f64a5 docs: sync purchase direct-link entry contract (#232) 2026-09-07 10:55:26 +08:00
QiuSW 486dff29fd fix(agent): launch purchase deep links with fresh PDD task (#232) 2026-09-07 10:51:24 +08:00
QiuSW c8e5b99b0c fix(agent): recognize selected spec panels without summary prefix (#231) 2026-09-07 10:40:12 +08:00
QiuSW 666d19ad66 fix(agent): distinguish nested purchase scroll containers (#230) 2026-09-07 10:21:36 +08:00
QiuSW c2c1044dbb fix(agent): recover vertical size grids after horizontal search failure (#230) 2026-09-07 10:07:39 +08:00
QiuSW 4e6afc2d25 fix(agent): search horizontally for exact purchase size (#230) 2026-09-07 09:55:07 +08:00
QiuSW 7b9fcfb8c1 fix(agent): search horizontally for exact purchase color (#230) 2026-09-07 09:41:54 +08:00
QiuSW 1f40a7fcb1 fix(agent): resolve duplicated semantic address cards (#229) 2026-09-05 18:28:47 +08:00
QiuSW c9aaade6e9 fix(agent): deduplicate address entry nodes (#229) 2026-09-05 18:17:54 +08:00
QiuSW 5ee3b62906 fix(agent): retain exact specs across panel transitions (#228) 2026-09-05 17:58:52 +08:00
QiuSW b829a203dc fix(agent): retain purchase panel after address save (#227) 2026-09-05 17:30:40 +08:00
QiuSW 9320e5528c docs(purchase): sync retry page entry contract (#226) 2026-09-05 17:08:20 +08:00
QiuSW 41fe461f94 fix(android): reopen PDD for manual purchase retries (#226) 2026-09-05 17:00:25 +08:00
QiuSW b306f417d5 docs(agent): sync in-place retry contract (#225) 2026-09-05 16:50:01 +08:00
QiuSW 8f5ff4525e docs(purchase): sync retry business rule (#225) 2026-09-05 16:50:00 +08:00
QiuSW 9124e92ed6 fix(agent): retry purchases in place (#225) 2026-09-05 16:45:52 +08:00
QiuSW 095dbfacbf fix(android): retain verified spec selections (#224) 2026-09-05 16:33:24 +08:00
QiuSW cdcb6930d0 fix(android): revalidate offscreen selected specs (#224) 2026-09-05 16:21:01 +08:00
QiuSW d84f6ddf21 fix(purchase): reuse validated probe mappings (#223) 2026-09-05 15:21:17 +08:00
QiuSW f40331189b fix(android): wait for stable PDD product page (#222) 2026-09-05 12:40:19 +08:00
QiuSW 2895f3d72d fix(purchase): advance past spec confirmation (#220) 2026-09-05 10:54:02 +08:00
QiuSW e4051ed8df feat(purchase): reuse probed PDD page (#219) 2026-09-05 10:34:35 +08:00
QiuSW 5859a819c8 feat(agent): 显示采购结果气泡 (#218) 2026-09-05 10:01:21 +08:00
QiuSW 8d2e61bb72 fix(purchase): 重建 Agent 重试任务 (#217) 2026-09-05 09:06:41 +08:00
QiuSW 96cc2ab700 docs(purchase): 更新 Agent 重试契约 (#217) 2026-09-05 09:03:07 +08:00
QiuSW bd386cfae4 fix(syb): 修正规格颜色尺码反序 (#216) 2026-09-04 19:33:35 +08:00
QiuSW c09ddc8474 feat(purchase): 强制真机探测规格后匹配 (#215) 2026-09-04 19:03:16 +08:00
QiuSW 683aa9c671 fix(agent): 验证采购规格点击并锁定面板滚动 (#214) 2026-09-04 18:23:47 +08:00
QiuSW 1ac50064bd fix(syb): 回填历史店铺标准化键 (#213) 2026-09-04 10:43:36 +08:00
QiuSW 8c76e0932b fix(syb): 冻结同步店铺快照 (#212) 2026-09-04 10:00:48 +08:00
QiuSW 22755e05e3 fix(agent): 有界等待采购规格入口就绪并收紧商品页证据放行 (#211) 2026-09-03 17:53:25 +08:00
QiuSW 494969d4f8 fix(android): bound payment transition sampling (#210)
Allow up to two consecutive evidence-free PayActivity transition samples after the single safe Back; fail on the third post-Back sample (200ms cadence) to preserve a finite no-click payment boundary.
2026-09-03 17:16:20 +08:00
QiuSWandClaude Opus 5 9e800ce023 fix(agent): 区分规格入口点击目标不唯一 (#209)
解析阶段已收敛到唯一语义候选,点击阶段的歧义来自控件树对该目标的
重复匹配。原文案沿用「候选不唯一」,与同串中 specEntryCandidates=1
的证据自相矛盾,不利于事后排查,改为「点击目标不唯一」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-03 16:54:51 +08:00
QiuSW ac6a57e4f3 fix(agent): 底部规格入口最右优先 (#209) 2026-09-03 16:50:29 +08:00
QiuSW fd013c634b fix(agent): 兼容嵌套规格入口 (#208) 2026-09-03 16:26:17 +08:00
QiuSW 57eaad8414 fix(agent): 识别无初始摘要规格面板 (#207) 2026-09-03 16:11:54 +08:00
QiuSW 13b1580fd5 fix(agent): 回传规格面板失败证据 (#206) 2026-09-03 16:01:40 +08:00
QiuSW 043d71da64 fix(purchase): 放行已提取规格的存疑明细 (#205) 2026-09-03 15:20:57 +08:00
QiuSW 58e058f8ac feat: 一键匹配并确认蝦皮颜色尺码 (#194)
(cherry picked from commit 041cd8d03f)
2026-09-03 15:05:53 +08:00
QiuSW 2d6d244d8a fix(agent): 读取待付款订单结果 (#204) 2026-09-03 14:18:01 +08:00
QiuSW 73cb94e073 fix(agent): 进入订单详情读取结果 (#203) 2026-09-03 12:01:38 +08:00
QiuSW bd4bc5f4f0 fix(agent): 支持不可滚动规格面板 (#202) 2026-09-03 11:44:04 +08:00
QiuSW 415d1ff2ad fix(agent): 自动保存身份恢复 Token (#201) 2026-09-03 11:17:57 +08:00
QiuSW a8e01b9809 fix(device): 限定自动恢复窗口 (#201) 2026-09-03 11:17:02 +08:00
QiuSW 74f55f43cc fix(device): 修复自动恢复编译错误 (#201) 2026-09-03 11:16:26 +08:00
QiuSW 2c263e687e feat(device): 后台自动恢复设备身份 (#201) 2026-09-03 11:16:03 +08:00
QiuSW 13164adde0 fix(migrate): 补齐设备身份恢复字段 (#201) 2026-09-03 11:12:07 +08:00
QiuSW 379a83fe94 feat(device): 安全恢复原设备身份 (#201) 2026-09-03 10:41:20 +08:00
QiuSW fc28632d48 fix(purchase): 允许无 SKU 采集证据创建采购 (#200) 2026-09-03 09:56:38 +08:00
QiuSW e85f237f09 feat(purchase): 放行有结果的 AI 规格匹配 (#200) 2026-09-03 09:34:13 +08:00
QiuSW 4f64c074dd fix(android): read folded PDD order details (#196) 2026-09-02 09:55:41 +08:00
150 changed files with 5832 additions and 4171 deletions
+5 -23
View File
@@ -1,17 +1,11 @@
# 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 提交 `ecab899`,但所有模板内容都必须按 GoAuto 事实改写。开始工作前阅读任务涉及目录中的 `AGENTS.md`。不同交付单元规则不同时,在 `server/`、`web/` 或 `android/` 下增加更具体的 `AGENTS.md`;目录越深的规则越具体,但不得削弱上级安全规则。
当前文档规则参考 DevHarness 提交 `4bbacf4d7fb265984396bb5589c544105043fa0b`,但所有模板内容都必须按 GoAuto 事实改写。开始工作前阅读任务涉及目录中的 `AGENTS.md`。不同交付单元规则不同时,在 `server/`、`web/` 或 `android/` 下增加更具体的 `AGENTS.md`;目录越深的规则越具体,但不得削弱上级安全规则。
[项目档案](docs/00-project-profile.md) 按需阅读,不作为每次任务的固定前置。出现下列情况之一时必须读:需要环境、配置或凭据来源;需要确认目录边界;需要判断子项目与交付单元划分;需要 DevHarness 来源与基线;需要项目专用验收要求。只为查命令不必打开项目档案。涉及采集、采购或设备行为时另读 `docs/03-business-rules-and-glossary.md` 和当前工单。
## 语言与术语
- 用户可以使用中文、英文或合理的中英混合语言交流;默认使用中文分析、回复、编写工单和维护内部项目文档。
- 代码标识符、命令、参数、路径、文件名、API 名称、协议名、日志和错误原文保持原样;必要时补充简短中文解释。
- 用户明确要求某次回复或交付物使用其他语言时,按该次要求执行,不改写接口契约或影响搜索和执行的原文。
## 常用命令
所有命令默认从仓库根目录执行。
@@ -22,7 +16,6 @@
| 检查模板结构 | `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` |
@@ -47,28 +40,18 @@
- 测试结果必须真实;未执行或无法覆盖的真机、多设备、云环境和高风险行为必须明确记录。
- 高风险修改必须停止并等待人工确认:创建订单、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作。
### 项目治理模式与明确授权后的执行
- GoAuto 默认采用轻量治理:文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及涉及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建单。
- 采购、创建订单、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作按高风险任务处理;未取得有效人工授权时必须停止。
- 当前聊天中用户给出的明确指令,或 Gitea 工单中能够归属于有权人工的明确授权,可以作为执行依据,不要求把同一授权重复复制到工单后再次确认。
- 授权必须能识别操作、对象和范围;Agent 自动生成的工单、草稿、摘要或对用户意图的转述不能单独构成人工授权。
- 获得有效授权后,只核对准确目标、授权范围和当前状态等最小必要前提,不得仅因操作不可逆而重复询问或拒绝。
- 授权不自动覆盖相邻对象或后续任务;环境、对象、范围或影响发生实质变化时必须重新确认。平台自身强制的审批、安全策略或权限限制继续有效。
## 2. 哪些改动需要工单
完整独立需求、新页面、跨模块功能、范围不明确的变化,以及接口、数据库、权限、并发、状态机或安全边界变化必须先有单元工单。采购、创建订单、数据库迁移、删除数据、发布和其他不可逆操作无论规模大小都必须建单。
新功能、缺陷修复、重构,以及接口、数据库、权限、并发、状态机、安全或用户界面变化必须先有单元工单。
以下低风险改动在范围明确、容易回退、不改变接口、数据结构、权限或安全边界,且不属于上述高风险事项时可以直接提交:
以下小改动只有在范围明确、容易回退且不涉及上面的必须建单项时才可以直接提交:
- 只改错别字、注释或文档措辞;
- 只做格式化、导入排序或不跨文件的内部变量改名;
- 补充类型标注或文档字符串且不改变行为;
- 补充不改变产品行为的测试;
- 删除已经确认无人使用的死代码;
- 修复预期行为明确、影响局限于单个模块的低风险缺陷;
- 不改变对外行为、入口、配置和验证方式的局部内部重构;
- 修复单文件、低风险且只恢复已有明确行为的缺陷;
- 只修改用户看到的界面显示文案,并且满足本文件「工单与设计证据双门禁」的全部豁免条件。
直接提交仍须保护无关改动、执行受影响范围的最小验证并写清提交说明。有任何不确定,或涉及接口、数据库、状态、权限、安全、并发、用户界面时,必须退回单元工单流程。代码组件名、类名、变量、国际化键、API 字段和数据库字段不是显示文案,不适用豁免。
@@ -201,7 +184,6 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明
- 提交只包含当前工单相关文件,提交信息引用工单号。
- 优先运行项目档案记录的格式、单元、契约和集成测试。
- Windows 环境优先使用当前已配置的 PowerShell;可选择时优先 PowerShell 7 `pwsh.exe`,不得仅为设置编码重复启动一层 PowerShell。
- Windows 命令不得默认套用 Bash 语法。复杂正则优先使用变量或 `rg -e`;包含引号和换行的脚本正文优先使用单引号 PowerShell here-string;`foreach`、`if` 等语句块保持在同一个 PowerShell 解析上下文中;`rg` 使用真实目录配合 `-g/--glob`,不要把 Bash 风格通配路径作为目录参数。
- 文本文件读写在命令支持时显式指定 UTF-8;文件解码和控制台输出分别处理,只有出现真实乱码或已知宿主非 UTF-8 时才设置当前进程的输出编码或 Python UTF-8 环境变量。
- 不得默认使用 `-ExecutionPolicy Bypass`;只有可信 `.ps1` 确实被执行策略阻止且没有更小替代方案时,才对该次进程使用并在工单记录原因。
- 涉及创建订单、权限、安全、并发、迁移和删除数据属于高风险,真机或正式实施前必须再次等待人工确认。
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 51
versionName = "0.9.38"
versionCode = 72
versionName = "0.9.59"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -49,6 +49,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
screenPolicyHandler.removeCallbacks(screenPolicyRefresh)
screenPolicyHandler.post(screenPolicyRefresh)
}
@@ -18,6 +18,8 @@ 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
@@ -32,6 +34,9 @@ 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 {
@@ -48,18 +53,19 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
val packageName = event.packageName?.toString()
if (packageName != null && packageName != lastForegroundPackage) {
lastForegroundPackage = packageName
val foregroundPackage = event.packageName?.toString()
if (foregroundPackage != null && foregroundPackage != lastForegroundPackage) {
lastForegroundPackage = foregroundPackage
foregroundRevision.incrementAndGet()
}
if (packageName == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime())
activityTracker.observe(packageName, event.className?.toString())
if (foregroundPackage == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime())
activityTracker.observe(foregroundPackage, event.className?.toString())
}
}
override fun onInterrupt() = Unit
override fun onDestroy() {
dismissPurchaseResultBubble()
unregisterAccessibilityButton()
if (instance === this) instance = null
super.onDestroy()
@@ -71,6 +77,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
runCatching {
val callback = object : AccessibilityButtonController.AccessibilityButtonCallback() {
override fun onClicked(controller: AccessibilityButtonController) {
dismissPurchaseResultBubble()
AccessibilityButtonPolicy.handleClick(::openAgentPreservingTab)
}
}
@@ -115,6 +122,7 @@ 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)
@@ -123,6 +131,14 @@ 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>()
@@ -314,6 +330,42 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
}
}
override fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome {
val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE)
val indexes = target.path.split('/').mapNotNull(String::toIntOrNull)
if (indexes.isEmpty() || indexes.first() != 0 || indexes.size != target.path.split('/').size) {
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
var node = root
for (index in indexes.drop(1)) {
node = node.getChild(index)
?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
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
) {
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
var ancestorDepth = 0
while (!node.isClickable) {
node = node.parent ?: return FreshClickOutcome(
FreshActionResult.FAILED,
FreshClickReason.NO_CLICKABLE_ANCESTOR,
clickableAncestorDepth = ancestorDepth,
)
ancestorDepth++
}
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, 1, ancestorDepth)
} else {
FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.ACTION_CLICK_FALSE, 1, ancestorDepth)
}
}
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND
val candidates = mutableListOf<AccessibilityNodeInfo>()
@@ -333,31 +385,32 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return 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()
}
return if (dispatchCenterTap(bounds)) FreshActionResult.SUCCESS else FreshActionResult.FAILED
}
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
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
}
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 {
@@ -397,17 +450,27 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
val root = rootInActiveWindow ?: return false
val matches = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node ->
val bounds = Rect().also(node::getBoundsInScreen)
if (node.isVisibleToUser && node.isEnabled && node.isScrollable &&
node.className?.toString() == target.className &&
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) <= 32 &&
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32
) matches += node
val candidates = mutableListOf<PurchaseScrollCandidate>()
val liveNodes = mutableMapOf<String, AccessibilityNodeInfo>()
fun visit(node: AccessibilityNodeInfo, path: String) {
if (node.isVisibleToUser && node.isEnabled && node.isScrollable) {
val bounds = Rect().also(node::getBoundsInScreen)
candidates += PurchaseScrollCandidate(
path, node.className?.toString(),
NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom),
)
liveNodes[path] = node
}
for (index in 0 until node.childCount) {
node.getChild(index)?.let { visit(it, "$path/$index") }
}
}
if (matches.size != 1) return false
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = false)
visit(root, "0")
val resolved = PurchaseScrollLocator.locate(
PurchaseScrollCandidate(target.path, target.className, target.bounds), candidates,
) ?: return false
val current = liveNodes[resolved.path] ?: return false
return swipeNode(current, direction, durationMs, preferScrollAction = true)
}
override fun backPurchase(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK)
@@ -421,9 +484,17 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
}.getOrDefault(false)
}
private var lastSpecRowSwipeFailure = "none"
override fun specRowSwipeFailureReason(): String = lastSpecRowSwipeFailure
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
lastSpecRowSwipeFailure = "none"
if (anchor == null) return swipe(SemanticTarget.SPEC_PANEL, direction)
val root = rootInActiveWindow ?: return false
val root = rootInActiveWindow ?: run {
lastSpecRowSwipeFailure = "windowMissing"
return false
}
val matches = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node ->
val bounds = Rect().also(node::getBoundsInScreen)
@@ -441,6 +512,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
if (matches.size != 1) {
if (!SpecSwipeSafety.allowGlobalFallback(direction)) {
lastSpecRowSwipeFailure = if (matches.isEmpty()) "anchorMissing" else "anchorAmbiguous"
Log.i("GoAutoCollector", "swipe direction=$direction result=blocked reason=anchor-not-unique matches=${matches.size}")
return false
}
@@ -464,19 +536,27 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
if (anchoredTarget != null) {
val bounds = Rect().also(anchoredTarget::getBoundsInScreen)
Log.i("GoAutoCollector", "swipe direction=$direction anchored=true bounds=$bounds class=${anchoredTarget.className}")
return swipeNode(
val success = swipeNode(
anchoredTarget,
direction,
preferScrollAction = SpecSwipeSafety.preferAccessibilityScrollAction(direction),
)
if (!success) lastSpecRowSwipeFailure = "gestureFailed"
return success
}
if (!SpecSwipeSafety.allowGlobalFallback(direction)) {
lastSpecRowSwipeFailure = "horizontalContainerMissing"
Log.i("GoAutoCollector", "swipe direction=$direction result=blocked reason=no-anchored-horizontal-container")
return false
}
return swipe(SemanticTarget.SPEC_PANEL, direction)
}
override fun swipeSpecRow(target: SnapshotNode, direction: SwipeDirection): Boolean {
if (direction != SwipeDirection.LEFT && direction != SwipeDirection.RIGHT) return false
return swipeSpec(direction, target)
}
override fun pullDownSpecPanel(anchor: SnapshotNode): Boolean {
if (!anchor.scrollable) return false
val root = rootInActiveWindow ?: return false
@@ -617,6 +697,28 @@ 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) }
@@ -0,0 +1,8 @@
package cn.ilapage.goauto.agent.automation
internal object PddLaunchFallback {
fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean): Boolean {
if (preferDirect && runCatching(direct).getOrDefault(false)) return true
return runCatching(browser).getOrDefault(false)
}
}
@@ -31,11 +31,21 @@ object PddPageClassifier {
}
class PddLinkLauncher(private val context: Context) {
fun open(url: String): Boolean {
fun open(url: String, preferDirect: Boolean = false): Boolean {
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
if (uri.scheme !in setOf("http", "https") || !isPddHost(uri.host) || uri.getQueryParameter("goods_id").isNullOrBlank()) {
return false
}
return PddLaunchFallback.open(preferDirect, direct = {
val direct = Intent(Intent.ACTION_VIEW, uri)
.setPackage("com.xunmeng.pinduoduo")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(direct)
true
}, browser = { openBrowser(uri) })
}
private fun openBrowser(uri: Uri): Boolean {
val base = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val browser = BROWSER_PACKAGES.firstOrNull { packageName ->
runCatching { context.packageManager.getPackageInfo(packageName, 0) }.isSuccess
@@ -99,6 +99,8 @@ object PddSoldOutRecoveryDefaults {
const val SETTLE_MILLIS = 2_000L
}
data class PurchasePanelContext(val container: SnapshotNode, val exactColor: String)
data class ParsedPddScreen(
val summary: ProductSummary,
val dimensions: List<VisibleDimension>,
@@ -123,8 +125,15 @@ 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>,
val isPddPackage: Boolean,
val hasCloseControl: Boolean = false,
val hasPaymentArea: Boolean = false,
val purchaseContextMatched: Boolean = false,
) {
fun isTransientSoldOut(
exactText: String,
@@ -159,6 +168,7 @@ data class ParsedPddScreen(
}
object PddScreenParser {
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
private data class SafeSpecEntry(val anchor: SnapshotNode, val clickTarget: SnapshotNode)
private val pricePattern = Regex("[¥¥]\\s*([0-9]+(?:\\.[0-9]{1,2})?)")
private val salesPattern = Regex("已拼\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*(?:件|人)?")
@@ -176,7 +186,7 @@ object PddScreenParser {
// purchase/order/payment controls must never become collection click targets.
private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款")
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?): ParsedPddScreen {
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null): ParsedPddScreen {
val visibleNodes = snapshot.nodes.filter { it.visible }
val visible = visibleNodes.mapNotNull { node ->
val descendants = descendants(node, visibleNodes)
@@ -301,32 +311,88 @@ object PddScreenParser {
// Opening evidence is intentionally independent from whether the current
// viewport still exposes a clickable spec value. PDD may hide the only
// selected value or restore a previously scrolled confirmation panel.
val continuedPurchasePanel = snapshot.packageName == PDD_PACKAGE && problem == null &&
purchaseContext != null && headedPanelScrollable != null &&
headedPanelScrollable.path == purchaseContext.container.path &&
headedPanelScrollable.className == purchaseContext.container.className &&
kotlin.math.abs(headedPanelScrollable.bounds.left - purchaseContext.container.bounds.left) <= 32 &&
kotlin.math.abs(headedPanelScrollable.bounds.right - purchaseContext.container.bounds.right) <= 32 &&
minOf(headedPanelScrollable.bounds.bottom, purchaseContext.container.bounds.bottom) >
maxOf(headedPanelScrollable.bounds.top, purchaseContext.container.bounds.top)
val structuredSelectionPanel = headedPanelScrollable != null &&
(headings.size >= 2 || (continuedPurchasePanel && headings.isNotEmpty())) &&
dimensions.any { it.values.isNotEmpty() } && hasClose &&
hasQuantityControls && hasPaymentArea && hasOrderSubmitAction
// Some selected-spec panels omit both the "已选" prefix and a confirm
// button. Keep the same structural evidence required for checkout.
val specPanelType = when {
quickConfirmationEvidence -> SpecPanelType.QUICK_CONFIRMATION
orderConfirmationEvidence -> SpecPanelType.ORDER_CONFIRMATION
structuredSelectionPanel -> SpecPanelType.NORMAL_SCROLLABLE
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
val unprefixedSummary = if (structuredSelectionPanel) {
val quantity = quantityInputs.single()
val knownColors = (dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text } +
listOfNotNull(purchaseContext?.exactColor?.takeIf { continuedPurchasePanel && it.isNotBlank() })).distinct()
val byPath = visibleNodes.associateBy { it.path }
var region = quantity.parentPath?.let(byPath::get)
var summary: String? = null
while (region != null && region.bounds.bottom <= headedPanelScrollable!!.bounds.top) {
val candidates = visible.filter { node ->
node.path.startsWith("${region!!.path}/") && !node.clickable &&
node.className?.endsWith("TextView") == true &&
node.bounds.bottom <= quantity.bounds.top && node.label.length <= 160 &&
knownColors.any { SpecValueNormalizer.summaryHasExactToken(node.label, it) }
}.map { it.label }.distinct()
if (candidates.isNotEmpty()) {
summary = candidates.singleOrNull()
break
}
region = region.parentPath?.let(byPath::get)
}
summary
} else null
val firstHeadingTop = headings.firstOrNull()?.bounds?.top ?: Int.MAX_VALUE
val price = visible.asSequence()
.filter { it.bounds.top < firstHeadingTop }
.mapNotNull { node -> pricePattern.find(node.label)?.groupValues?.get(1)?.let(::priceCent) }
.firstOrNull()
val explicitSpecEntry = if (panelOpen) null else visible
val explicitSpecEntries = if (panelOpen) emptyList() else visible
.filter { it.clickable && isSpecEntry(it.label, config) && !hasReviewContext(it, visibleNodes, config) }
.maxByOrNull { it.bounds.top }
val bottomSpecEntry = if (panelOpen || explicitSpecEntry != null) null else safeBottomSpecEntry(visibleNodes, visible, config)
val candidateSpecEntry = explicitSpecEntry ?: bottomSpecEntry?.anchor
val candidateClickTarget = explicitSpecEntry ?: bottomSpecEntry?.clickTarget
val explicitSpecEntry = explicitSpecEntries.maxByOrNull { it.bounds.top }
val nestedSpecEntries = if (panelOpen || explicitSpecEntry != null) emptyList() else
safeNestedSpecEntries(visibleNodes, visible, config)
// A nested selection row is accepted only when it is the single safe
// candidate. This covers PDD layouts that split “请选择” and the
// dimension name across child nodes of one clickable parent, without
// turning arbitrary page text into a click target.
val nestedSpecEntry = nestedSpecEntries.singleOrNull()
val bottomSpecEntries = if (panelOpen || explicitSpecEntry != null || nestedSpecEntry != null) emptyList() else
safeBottomSpecEntries(visibleNodes, visible, config)
val bottomSpecEntry = bottomSpecEntries.firstOrNull()
val candidateSpecEntry = explicitSpecEntry ?: nestedSpecEntry?.anchor ?: bottomSpecEntry?.anchor
val candidateClickTarget = explicitSpecEntry ?: nestedSpecEntry?.clickTarget ?: bottomSpecEntry?.clickTarget
val reviewPageOpen = isReviewPage(visibleNodes, visible, screenHeight, candidateSpecEntry, config)
val specEntry = candidateSpecEntry.takeUnless { reviewPageOpen }
val specEntryClickTarget = candidateClickTarget.takeUnless { reviewPageOpen }
val specEntrySource = when {
reviewPageOpen -> null
explicitSpecEntry != null -> "explicit_selection"
bottomSpecEntry != null -> "bottom_purchase"
nestedSpecEntry != null -> "nested_selection"
bottomSpecEntries.size == 1 -> "bottom_purchase"
bottomSpecEntry != null -> "bottom_purchase_rightmost"
else -> null
}
val quickConfirmationEntry = if (quickConfirmationEvidence) quickConfirmationSpecEntry(visibleNodes, visible, config) else null
@@ -349,7 +415,7 @@ object PddScreenParser {
selectedSummary = labels.firstOrNull {
val compact = it.replace(" ", "")
textAliases.selection.selectedPrefixes.any(compact::startsWith)
},
} ?: unprefixedSummary,
priceCent = price,
specPanelOpen = panelOpen,
specPanelType = specPanelType,
@@ -368,10 +434,17 @@ object PddScreenParser {
panelHeadingCount = headings.size,
panelOptionCount = dimensions.sumOf { it.values.size },
hasSelectionSummary = hasSelectionSummary,
hasCloseControl = hasClose,
hasPaymentArea = hasPaymentArea,
purchaseContextMatched = continuedPurchasePanel,
hasQuantityControls = hasQuantityControls,
hasOrderSubmitAction = hasOrderSubmitAction,
explicitSpecEntryCount = explicitSpecEntries.size,
nestedSpecEntryCount = nestedSpecEntries.size,
bottomPurchaseEntryCount = bottomSpecEntries.size,
problem = problem,
sourceNodes = visibleNodes,
isPddPackage = snapshot.packageName == PDD_PACKAGE,
)
}
@@ -458,10 +531,40 @@ object PddScreenParser {
return hasSpecWord && config.textAliases.selection.specEntryPrefixes.any(compact::startsWith)
}
private fun safeBottomSpecEntry(source: List<SnapshotNode>, visible: List<SnapshotNode>, config: PddCollectorConfig): SafeSpecEntry? {
val screenWidth = source.maxOfOrNull { it.bounds.right } ?: return null
val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return null
if (screenWidth <= 0 || screenHeight <= 0) return null
private fun safeNestedSpecEntries(source: List<SnapshotNode>, visible: List<SnapshotNode>, config: PddCollectorConfig): List<SafeSpecEntry> {
val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return emptyList()
if (screenHeight <= 0) return emptyList()
return source.asSequence()
.filter { it.visible && it.enabled && it.clickable && it.bounds.width > 0 && it.bounds.height > 0 }
// The fixed purchase bar begins at the lower fifth of the screen.
// A specs row has no reason to be inside that action-only zone.
.filter { it.bounds.centerY.toDouble() < screenHeight * 0.8 }
.mapNotNull { candidate ->
val context = (listOf(candidate.label) + descendants(candidate, source).map(SnapshotNode::label))
.joinToString("") { it.replace(Regex("\\s+"), "") }
if (!isSpecEntryContext(context, config) ||
nonConfigurableClickDenylist.any(context::contains) ||
hasReviewContext(candidate, source, config)
) return@mapNotNull null
val anchor = visible.firstOrNull { it.path == candidate.path } ?: candidate
SafeSpecEntry(anchor, candidate)
}
.distinctBy { it.clickTarget.path }
.toList()
}
private fun isSpecEntryContext(compact: String, config: PddCollectorConfig): Boolean {
if (config.textAliases.review.entryAliases.any(compact::contains)) return false
val hasSpecWord = (config.colorAliases + config.sizeAliases +
config.textAliases.dimension.exactNames + config.textAliases.dimension.adaptiveAliases)
.any(compact::contains)
return hasSpecWord && config.textAliases.selection.specEntryPrefixes.any(compact::contains)
}
private fun safeBottomSpecEntries(source: List<SnapshotNode>, visible: List<SnapshotNode>, config: PddCollectorConfig): List<SafeSpecEntry> {
val screenWidth = source.maxOfOrNull { it.bounds.right } ?: return emptyList()
val screenHeight = source.maxOfOrNull { it.bounds.bottom } ?: return emptyList()
if (screenWidth <= 0 || screenHeight <= 0) return emptyList()
val byPath = source.associateBy(SnapshotNode::path)
val normalizedByPath = visible.associateBy(SnapshotNode::path)
val buyWords = config.textAliases.purchase.buyWords
@@ -485,8 +588,10 @@ object PddScreenParser {
val normalizedTarget = normalizedByPath[clickTarget.path] ?: return@mapNotNull null
SafeSpecEntry(normalizedAnchor, normalizedTarget) to clickTarget.bounds.width.toLong() * clickTarget.bounds.height
}
.minWithOrNull(compareBy<Pair<SafeSpecEntry, Long>> { it.second }.thenByDescending { it.first.clickTarget.bounds.centerX })
?.first
.sortedWith(compareByDescending<Pair<SafeSpecEntry, Long>> { it.first.clickTarget.bounds.centerX }.thenBy { it.second })
.map { it.first }
.distinctBy { it.clickTarget.path }
.toList()
}
private fun hasReviewContext(node: SnapshotNode, source: List<SnapshotNode>, config: PddCollectorConfig): Boolean {
@@ -36,6 +36,58 @@ 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(allowUnclassifiedPanelWithSelectionProof: Boolean = false) {
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)
val unclassifiedSelectionPanelWithProof = allowUnclassifiedPanelWithSelectionProof &&
screen.specPanelType == SpecPanelType.UNKNOWN &&
screen.priceCent != null &&
snapshot.nodes.count {
it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label.toLongOrNull() != null
} == 1
if (
screen.specPanelType !in setOf(SpecPanelType.NORMAL_SCROLLABLE, SpecPanelType.NON_SCROLLABLE_CONFIRMATION) &&
!unclassifiedSelectionPanelWithProof
) {
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()
@@ -43,12 +95,31 @@ class PurchaseLiveAutomation(
var unchangedCount = 0
for (attempt in 0 until 5) {
pageProblem(snapshot)
val entries = mergedDirect(snapshot.nodes.filter { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) })
if (entries.size == 1) {
when (driver.tapPurchaseFresh(entries.single())) {
val resolution = resolveAddressEntries(snapshot)
if (resolution.addressCardCount > 1 || resolution.tapTargetCount > 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${resolution.diagnostic()}]")
}
if (resolution.target != null) {
pause(ADDRESS_ENTRY_STABLE_INTERVAL_MS)
val stableSnapshot = driver.capture()
pageProblem(stableSnapshot)
val stableResolution = resolveAddressEntries(stableSnapshot)
if (stableResolution.addressCardCount > 1 || stableResolution.tapTargetCount > 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${stableResolution.diagnostic()}]")
}
if (stableResolution.target == null || stableResolution.fingerprint() != resolution.fingerprint()) {
snapshot = stableSnapshot
continue
}
val target = stableResolution.target
val result = when (target.activation) {
AddressEntryActivation.CLICK -> driver.clickAddressEntryFresh(target.node).result
AddressEntryActivation.TAP -> driver.tapPurchaseFresh(target.node)
}
when (result) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
else -> fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口点击失败,未创建订单")
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${stableResolution.diagnostic()}]")
else -> fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口点击失败,未创建订单 [${stableResolution.diagnostic()}]")
}
pause(1_000)
snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") {
@@ -56,7 +127,9 @@ class PurchaseLiveAutomation(
}
return editAndVerifyAddress(snapshot, addressSuffix)
}
if (entries.size > 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
if (resolution.phoneNodeCount > 0) {
fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口无法安全定位,未创建订单 [${resolution.diagnostic()}]")
}
val panel = purchasePanelScrollTargets(snapshot)
if (panel.size != 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "没有找到唯一的规格面板滚动区域,未创建订单")
val signature = viewportSignature(snapshot, panel.single())
@@ -72,6 +145,161 @@ class PurchaseLiveAutomation(
fail("PURCHASE_ADDRESS_PANEL_TIMEOUT", "规格面板下拉后仍未找到收货地址,未创建订单")
}
private enum class AddressEntryActivation { CLICK, TAP }
private data class AddressEntryTarget(
val node: SnapshotNode,
val activation: AddressEntryActivation,
val cardKey: String,
)
private data class AddressCardCandidate(
val phone: SnapshotNode,
val clickableCard: SnapshotNode?,
val semanticKey: String?,
)
private data class AddressEntryResolution(
val phoneNodeCount: Int,
val addressCardCount: Int,
val tapTargetCount: Int,
val target: AddressEntryTarget?,
) {
fun diagnostic(): String =
"phoneNodes=$phoneNodeCount;addressCards=$addressCardCount;tapTargets=$tapTargetCount"
fun fingerprint(): String? = target?.let {
listOf(
it.activation.name,
it.cardKey,
it.node.label,
it.node.className.orEmpty(),
it.node.bounds.left,
it.node.bounds.top,
it.node.bounds.right,
it.node.bounds.bottom,
).joinToString(":")
}
}
private fun resolveAddressEntries(snapshot: UiSnapshot): AddressEntryResolution {
val phones = snapshot.nodes.filter {
it.visible && it.enabled && maskedPhone(it.label) != null
}.distinctBy { it.path }
if (phones.isEmpty()) return AddressEntryResolution(0, 0, 0, null)
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
val candidates = phones.map { phone ->
val card = nearestClickableAncestor(phone, byPath)
AddressCardCandidate(phone, card, card?.let { addressCardSemanticKey(it, snapshot) })
}
val groups = mutableListOf<MutableList<AddressCardCandidate>>()
candidates.forEach { candidate ->
val matching = groups.filter { group -> group.any { sameLogicalAddress(it, candidate) } }
if (matching.isEmpty()) {
groups += mutableListOf(candidate)
} else {
val primary = matching.first()
primary += candidate
matching.drop(1).forEach { duplicate ->
primary += duplicate
groups.remove(duplicate)
}
}
}
val targets = groups.mapIndexed { index, group ->
val clickable = group.filter { it.clickableCard != null }
.minWithOrNull(compareBy<AddressCardCandidate>({ nodeArea(requireNotNull(it.clickableCard)) }, { it.phone.path }))
if (clickable != null) {
AddressEntryTarget(clickable.phone, AddressEntryActivation.CLICK, logicalAddressKey(group, index))
} else {
AddressEntryTarget(group.minBy { nodeArea(it.phone) }.phone, AddressEntryActivation.TAP, logicalAddressKey(group, index))
}
}
val actionable = targets.filter { it.node.bounds.width >= 2 && it.node.bounds.height >= 2 }
return AddressEntryResolution(
phoneNodeCount = phones.size,
addressCardCount = targets.size,
tapTargetCount = actionable.size,
target = actionable.singleOrNull(),
)
}
private fun nearestClickableAncestor(
source: SnapshotNode,
byPath: Map<String, SnapshotNode>,
): SnapshotNode? {
var current: SnapshotNode? = source
while (current != null) {
if (current.clickable && current.visible && current.enabled) return current
current = current.parentPath?.let(byPath::get)
}
return null
}
private fun addressCardSemanticKey(card: SnapshotNode, snapshot: UiSnapshot): String? {
val labels = snapshot.nodes.asSequence()
.filter { it.visible && (it.path == card.path || it.path.startsWith("${card.path}/")) }
.map { normalizeAddressSemantic(it.label) }
.filter { it.isNotBlank() && maskedPhone(it) == null && it !in ADDRESS_CARD_GENERIC_LABELS }
.distinct()
.sorted()
.toList()
if (labels.none { it.length >= ADDRESS_SEMANTIC_MIN_LENGTH }) return null
return labels.joinToString("|")
}
private fun normalizeAddressSemantic(value: String): String = value
.replace(Regex("\\s+"), "")
.replace(",", ",")
.replace(":", ":")
.lowercase()
private fun sameLogicalAddress(first: AddressCardCandidate, second: AddressCardCandidate): Boolean {
if (maskedPhone(first.phone.label) != maskedPhone(second.phone.label)) return false
if (first.clickableCard?.path != null && first.clickableCard.path == second.clickableCard?.path) return true
if (sameAddressRegion(first.phone, second.phone)) return true
val firstCard = first.clickableCard
val secondCard = second.clickableCard
if (firstCard != null && secondCard != null && boundsRepresentSameRegion(firstCard.bounds, secondCard.bounds)) return true
return first.semanticKey != null && first.semanticKey == second.semanticKey
}
private fun boundsRepresentSameRegion(first: NodeBounds, second: NodeBounds): Boolean {
val overlapWidth = (minOf(first.right, second.right) - maxOf(first.left, second.left)).coerceAtLeast(0)
val overlapHeight = (minOf(first.bottom, second.bottom) - maxOf(first.top, second.top)).coerceAtLeast(0)
val overlap = overlapWidth.toLong() * overlapHeight
val smaller = minOf(first.width.toLong() * first.height, second.width.toLong() * second.height)
return smaller > 0 && overlap * 100 >= smaller * ADDRESS_CARD_OVERLAP_PERCENT
}
private fun logicalAddressKey(group: List<AddressCardCandidate>, index: Int): String {
val semantic = group.mapNotNull(AddressCardCandidate::semanticKey).distinct().singleOrNull()
return semantic?.let { "semantic:$it" } ?: "geometry:$index:${group.minOf { it.phone.bounds.top }}"
}
private fun sameAddressRegion(first: SnapshotNode, second: SnapshotNode): Boolean {
if (maskedPhone(first.label) != maskedPhone(second.label)) return false
val overlapWidth = (minOf(first.bounds.right, second.bounds.right) - maxOf(first.bounds.left, second.bounds.left)).coerceAtLeast(0)
val overlapHeight = (minOf(first.bounds.bottom, second.bounds.bottom) - maxOf(first.bounds.top, second.bounds.top)).coerceAtLeast(0)
val overlap = overlapWidth.toLong() * overlapHeight
val smaller = minOf(nodeArea(first), nodeArea(second))
val containsCenter = (
first.bounds.centerX in second.bounds.left..second.bounds.right &&
first.bounds.centerY in second.bounds.top..second.bounds.bottom
) || (
second.bounds.centerX in first.bounds.left..first.bounds.right &&
second.bounds.centerY in first.bounds.top..first.bounds.bottom
)
return (smaller > 0 && overlap * 100 >= smaller * 50) || containsCenter
}
private fun maskedPhone(value: String): String? = MASKED_PHONE.find(value)?.value
private fun nodeArea(node: SnapshotNode): Long = node.bounds.width.toLong() * node.bounds.height
fun finalConfirmation(input: PurchaseExecutionInput, address: ShippingAddressProof): FinalConfirmationEvidence {
val snapshot = driver.capture()
pageProblem(snapshot)
@@ -117,10 +345,12 @@ class PurchaseLiveAutomation(
return null
}
val labels = linkedSetOf<String>()
var backedOutOfPayment = false
var paymentBackAttempts = 0
var consecutivePaymentSamplesAfterBack = 0
var backedOutOfChooser = false
var restoredFromWechat = false
var pddObservedAfterWechatRestore = false
var orderDetailEntryOpened = false
var wechatRestorePendingSamples = 0
var consecutiveEmptySnapshots = 0
repeat(ORDER_RESULT_MAX_SAMPLES) { index ->
@@ -162,24 +392,58 @@ class PurchaseLiveAutomation(
return unknown("PURCHASE_ORDER_UNEXPECTED_APP", "核单期间出现未授权应用")
}
if (restoredFromWechat) pddObservedAfterWechatRestore = true
val paymentVisible = isKnownPddPaymentActivity(snapshot) ||
currentLabels.any { label -> PAYMENT_MARKERS.any(label::contains) }
val orderContextVisible = currentLabels.any { label -> ORDER_CONTEXT_MARKERS.any(label::contains) }
if (!paymentVisible && !orderContextVisible) {
val unpaidContextVisible = currentLabels.any { label -> UNPAID_MARKERS.any(label::contains) }
// PDD can reuse its payment Activity for a read-only unpaid order page.
// Visible order-result evidence takes precedence over the Activity name:
// it permits only bounded reading gestures below, never a payment click.
val paymentVisible = isKnownPddPaymentActivity(snapshot) && !orderContextVisible && !unpaidContextVisible
if (paymentVisible) {
if (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
}
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
return@repeat
}
currentLabels.forEach(labels::add)
parseOrderEvidence(labels)?.let { return it }
if (paymentVisible && !backedOutOfPayment) {
backedOutOfPayment = true
if (!driver.backPurchase()) {
return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情")
}
pause(500)
} else if (paymentVisible) {
return unknown("PURCHASE_ORDER_PAYMENT_REPEATED", "支付页重复出现,已停止自动核单")
} else if (index > 0 && index % 15 == 0) {
if (index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
driver.swipePurchase(SwipeDirection.UP, 400)
}
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
@@ -223,14 +487,62 @@ class PurchaseLiveAutomation(
click(save.single(), "保存地址")
val savedEvidence = waitForStableAddressEditorExit()
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix)
if (isPurchaseConfirmationPanel(savedEvidence)) {
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
} else {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix)
}
}
}
return ShippingAddressProof(expected, suffix)
}
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
return screen.specPanelType in setOf(
SpecPanelType.NORMAL_SCROLLABLE,
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
SpecPanelType.ORDER_CONFIRMATION,
)
}
private fun restoreFinalEvidenceInCurrentPanel(
initial: UiSnapshot,
expected: String,
suffix: String,
) {
var snapshot = initial
var previousSignature: String? = null
repeat(ADDRESS_CONFIRMATION_SCROLL_LIMIT) { attempt ->
if (hasFinalSavedAddressEvidence(snapshot, expected, suffix)) return
if (!isPurchaseConfirmationPanel(snapshot)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后采购面板发生变化,未创建订单")
}
val panels = purchasePanelScrollTargets(snapshot)
if (panels.size != 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后没有找到唯一的规格面板滚动区域,未创建订单")
}
val panel = panels.single()
val signature = viewportSignature(snapshot, panel)
if (signature == previousSignature) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
}
previousSignature = signature
if (!driver.swipePurchaseIn(panel, SwipeDirection.DOWN, 350)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板无法定位订单确认信息,未创建订单")
}
if (attempt < ADDRESS_CONFIRMATION_SCROLL_LIMIT - 1) pause(ADDRESS_CONFIRMATION_SCROLL_INTERVAL_MS)
snapshot = driver.capture()
pageProblem(snapshot)
}
if (!hasFinalSavedAddressEvidence(snapshot, expected, suffix)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
}
}
private fun hasSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean {
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
val visible = snapshot.nodes.filter { it.visible }
@@ -293,7 +605,7 @@ class PurchaseLiveAutomation(
private fun viewportSignature(snapshot: UiSnapshot, panel: SnapshotNode): String = snapshot.nodes
.filter { node -> node.visible && inside(node.bounds, panel.bounds) }
.joinToString("|") { node ->
listOf(node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":")
listOf(node.path, node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":")
}
private fun inside(child: NodeBounds, parent: NodeBounds): Boolean =
@@ -371,6 +683,21 @@ 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) }
@@ -421,6 +748,7 @@ class PurchaseLiveAutomation(
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
val ORDER_CONTEXT_MARKERS = listOf("订单编号", "订单号", "下单时间", "创建时间")
val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})")
val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20[0-9]{2}[-/.年][0-9]{1,2}[-/.月][0-9]{1,2}日?\\s+[0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)")
@@ -435,6 +763,16 @@ 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
const val ADDRESS_ENTRY_STABLE_INTERVAL_MS = 200L
const val ADDRESS_CARD_OVERLAP_PERCENT = 70
const val ADDRESS_SEMANTIC_MIN_LENGTH = 4
val ADDRESS_CARD_GENERIC_LABELS = setOf("收货地址", "修改", "默认地址", "默认")
const val ADDRESS_CONFIRMATION_SCROLL_LIMIT = 5
const val ADDRESS_CONFIRMATION_SCROLL_INTERVAL_MS = 500L
}
}
@@ -0,0 +1,24 @@
package cn.ilapage.goauto.agent.automation
internal data class PurchaseScrollCandidate(
val path: String,
val className: String?,
val bounds: NodeBounds,
)
internal object PurchaseScrollLocator {
fun locate(target: PurchaseScrollCandidate, candidates: List<PurchaseScrollCandidate>): PurchaseScrollCandidate? {
val compatible = candidates.filter {
it.className == target.className && boundsMatch(it.bounds, target.bounds)
}
val samePath = compatible.filter { it.path == target.path }
if (samePath.isNotEmpty()) return samePath.singleOrNull()
return compatible.singleOrNull()
}
private fun boundsMatch(a: NodeBounds, b: NodeBounds): Boolean =
kotlin.math.abs(a.left - b.left) <= 32 &&
kotlin.math.abs(a.top - b.top) <= 32 &&
kotlin.math.abs(a.right - b.right) <= 32 &&
kotlin.math.abs(a.bottom - b.bottom) <= 32
}
@@ -73,6 +73,7 @@ data class CurrentPageIdentity(
data class PurchaseAgentTask(
val taskId: Long,
val taskAttemptId: String,
val attemptNumber: Int,
val phase: String,
val executionMode: String,
val status: String,
@@ -226,6 +227,26 @@ class AgentApiClient(private val serverUrl: String) {
)
}
fun recoverRegistration(info: DeviceInfo, token: String?, recoveryCode: String): RegistrationResult {
require(recoveryCode.isNotBlank()) { "请输入管理员恢复码" }
val payload = JSONObject()
.put("requestId", UUID.randomUUID().toString())
.put("installId", info.installId)
.put("name", info.name)
.put("manufacturer", info.manufacturer)
.put("model", info.model)
.put("androidVersion", info.androidVersion)
.put("agentVersion", info.agentVersion)
.put("pddVersion", info.pddVersion)
.put("capabilities", JSONArray(info.capabilities))
val data = post("/api/agent/v1/register", payload, token, recoveryCode).getJSONObject("data")
return RegistrationResult(
deviceId = data.getLong("deviceId"),
deviceToken = data.optString("deviceToken").takeIf { it.isNotBlank() },
heartbeatIntervalSeconds = data.optInt("heartbeatIntervalSeconds", 15),
)
}
fun heartbeat(token: String, currentTaskId: Long?, capabilities: List<String> = emptyList()): HeartbeatResult {
val payload = JSONObject()
.put("requestId", UUID.randomUUID().toString())
@@ -569,6 +590,7 @@ class AgentApiClient(private val serverUrl: String) {
private fun purchaseTask(data: JSONObject) = PurchaseAgentTask(
taskId = data.getLong("taskId"),
taskAttemptId = data.optString("taskAttemptId"),
attemptNumber = data.optInt("attemptNumber", 1),
phase = data.optString("phase"),
executionMode = data.getString("executionMode"),
status = data.getString("status"),
@@ -590,11 +612,11 @@ class AgentApiClient(private val serverUrl: String) {
leaseVersion = data.getLong("leaseVersion"),
)
private fun post(path: String, payload: JSONObject, token: String?): JSONObject {
return requireNotNull(request("POST", path, payload, token))
private fun post(path: String, payload: JSONObject, token: String?, recoveryCode: String? = null): JSONObject {
return requireNotNull(request("POST", path, payload, token, recoveryCode))
}
private fun request(method: String, path: String, payload: JSONObject?, token: String?): JSONObject? {
private fun request(method: String, path: String, payload: JSONObject?, token: String?, recoveryCode: String? = null): JSONObject? {
val connection = (URL(serverUrl + path).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = 10_000
@@ -605,6 +627,7 @@ class AgentApiClient(private val serverUrl: String) {
setRequestProperty("Accept", "application/json")
setRequestProperty("Cache-Control", "no-store")
if (!token.isNullOrBlank()) setRequestProperty("Authorization", "Bearer $token")
if (!recoveryCode.isNullOrBlank()) setRequestProperty("X-GoAuto-Device-Recovery-Code", recoveryCode)
}
try {
if (payload != null) {
@@ -1,7 +1,7 @@
package cn.ilapage.goauto.agent.persistence
internal object AgentDiagnosticSchema {
const val VERSION = 2
const val VERSION = 3
val colorDiagnosticColumns = linkedMapOf(
"color_row_count" to "INTEGER",
@@ -14,6 +14,15 @@ internal object AgentDiagnosticSchema {
"horizontal_swipe_count" to "INTEGER",
)
val purchaseFailureColumns = linkedMapOf(
"attempt_id" to "TEXT",
"device_id" to "INTEGER",
"error_code" to "TEXT",
"failure_message" to "TEXT",
"page_evidence" to "TEXT",
"last_step" to "TEXT",
)
val createTableSql =
"""CREATE TABLE agent_diagnostic (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -42,14 +51,33 @@ internal object AgentDiagnosticSchema {
initial_selected_size_count INTEGER,
selected_summary_present INTEGER,
horizontal_swipe_count INTEGER,
attempt_id TEXT,
device_id INTEGER,
error_code TEXT,
failure_message TEXT,
page_evidence TEXT,
last_step TEXT,
agent_version TEXT NOT NULL,
created_at INTEGER NOT NULL
)""".trimIndent()
fun v2MigrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> {
if (oldVersion >= 2 || newVersion < 2) return emptyList()
return colorDiagnosticColumns.mapNotNull { (name, definition) ->
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
fun migrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> {
if (oldVersion >= newVersion) return emptyList()
val statements = mutableListOf<String>()
if (oldVersion < 2 && newVersion >= 2) {
colorDiagnosticColumns.forEach { (name, definition) ->
if (name !in existingColumns) statements += "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
}
}
if (oldVersion < 3 && newVersion >= 3) {
purchaseFailureColumns.forEach { (name, definition) ->
if (name !in existingColumns) statements += "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
}
}
return statements
}
// Kept for existing migration tests and callers; new code should use migrationStatements.
fun v2MigrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> =
migrationStatements(oldVersion, newVersion, existingColumns)
}
@@ -4,6 +4,8 @@ import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import org.json.JSONArray
import org.json.JSONObject
import cn.ilapage.goauto.agent.BuildConfig
enum class AgentDiagnosticStage {
@@ -71,6 +73,7 @@ enum class AgentDiagnosticReason {
LINK_AMBIGUOUS,
LINK_NOT_FOUND,
UNKNOWN,
PURCHASE_FAILURE,
}
data class AgentDiagnosticEvent(
@@ -99,6 +102,12 @@ data class AgentDiagnosticEvent(
val initialSelectedSizeCount: Int? = null,
val selectedSummaryPresent: Boolean? = null,
val horizontalSwipeCount: Int? = null,
val attemptId: String? = null,
val deviceId: Long? = null,
val errorCode: String? = null,
val failureMessage: String? = null,
val pageEvidence: String? = null,
val lastStep: String? = null,
val createdAt: Long = System.currentTimeMillis(),
)
@@ -118,7 +127,7 @@ internal object AgentDiagnosticRetentionPolicy {
fun cutoff(createdAt: Long): Long = createdAt - RETENTION_MILLIS
}
class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
class AgentDiagnosticStore(private val context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(AgentDiagnosticSchema.createTableSql)
db.execSQL("CREATE INDEX idx_agent_diagnostic_task ON agent_diagnostic(task_id, id)")
@@ -126,7 +135,7 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
val existing = columnNames(db)
AgentDiagnosticSchema.v2MigrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL)
AgentDiagnosticSchema.migrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL)
}
private fun columnNames(db: SQLiteDatabase): Set<String> =
@@ -171,6 +180,12 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
putNullableInt("initial_selected_size_count", event.initialSelectedSizeCount)
putNullableBoolean("selected_summary_present", event.selectedSummaryPresent)
putNullableInt("horizontal_swipe_count", event.horizontalSwipeCount)
event.attemptId?.let { put("attempt_id", it.take(MAX_TEXT_CHARS)) }
putNullableLong("device_id", event.deviceId)
event.errorCode?.let { put("error_code", it.take(MAX_ERROR_CODE_CHARS)) }
event.failureMessage?.let { put("failure_message", sanitizeText(it)) }
event.pageEvidence?.let { put("page_evidence", sanitizeText(it)) }
event.lastStep?.let { put("last_step", it.take(MAX_TEXT_CHARS)) }
put("agent_version", BuildConfig.VERSION_NAME)
put("created_at", event.createdAt)
}
@@ -186,6 +201,53 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
}
}
/** Returns only the allow-listed, structured fields; raw accessibility data is never exported. */
@Synchronized
fun exportTaskJson(taskId: Long): String {
require(taskId > 0)
val events = JSONArray()
readableDatabase.query(
"agent_diagnostic",
arrayOf(
"task_id", "stage", "reason", "attempt", "elapsed_ms", "attempt_id", "device_id",
"error_code", "failure_message", "page_evidence", "last_step", "agent_version", "created_at",
),
"task_id = ?",
arrayOf(taskId.toString()),
null,
null,
"id ASC",
).use { cursor ->
while (cursor.moveToNext()) {
events.put(JSONObject().apply {
put("taskId", cursor.getLong(0))
put("stage", cursor.getString(1))
put("reason", cursor.getString(2))
put("attempt", cursor.getInt(3))
put("elapsedMs", cursor.getLong(4))
putNullable("attemptId", cursor, 5)
if (!cursor.isNull(6)) put("deviceId", cursor.getLong(6))
putNullable("errorCode", cursor, 7)
putNullable("message", cursor, 8)
putNullable("pageEvidence", cursor, 9)
putNullable("lastStep", cursor, 10)
put("agentVersion", cursor.getString(11))
put("createdAt", cursor.getLong(12))
})
}
}
return JSONObject().put("taskId", taskId).put("events", events).toString()
}
/** Writes a task export into app-specific external storage so it can be pulled over USB. */
@Synchronized
fun exportTaskJsonFile(taskId: Long, fileName: String = "task-$taskId.json"): java.io.File {
val safeName = fileName.replace(Regex("[^A-Za-z0-9._-]"), "_").take(96)
val directory = context.getExternalFilesDir("diagnostics") ?: error("诊断导出目录不可用")
if (!directory.exists() && !directory.mkdirs()) error("诊断导出目录创建失败")
return java.io.File(directory, safeName).apply { writeText(exportTaskJson(taskId), Charsets.UTF_8) }
}
private fun ContentValues.putNullableBoolean(key: String, value: Boolean?) {
value?.let { put(key, if (it) 1 else 0) }
}
@@ -194,11 +256,25 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
value?.let { put(key, it.coerceAtLeast(0)) }
}
private fun ContentValues.putNullableLong(key: String, value: Long?) {
value?.let { put(key, it) }
}
private fun JSONObject.putNullable(key: String, cursor: android.database.Cursor, index: Int) {
if (!cursor.isNull(index)) put(key, cursor.getString(index))
}
private fun sanitizeText(value: String): String = value
.replace(Regex("(?i)(addressSuffix|收货地址|详细地址)\\s*[:=:][^;,,\\]]+"), "[REDACTED]")
.take(MAX_TEXT_CHARS)
companion object {
private const val DATABASE_NAME = "goauto_diagnostics.db"
private const val DATABASE_VERSION = AgentDiagnosticSchema.VERSION
private const val MAX_CLASS_NAME_CHARS = 160
private const val MAX_ROW_VALUE_COUNTS_CHARS = 160
private const val MAX_TEXT_CHARS = 512
private const val MAX_ERROR_CODE_CHARS = 96
private val ROW_VALUE_COUNTS_PATTERN = Regex("[0-9]+(?:,[0-9]+)*")
private val ALLOWED_ZONES = setOf("top-left", "top-center", "top-right", "middle", "bottom")
}
@@ -57,6 +57,7 @@ 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
@@ -187,11 +188,12 @@ class AgentForegroundService : Service() {
if (!registeredThisProcess.get()) {
val registration = api.register(deviceInfo(), credentials?.token)
if (credentials == null) {
if (registration.deviceToken != null) {
val issuedToken = registration.deviceToken
?: error("注册请求已处理,但未返回新 Token;请联系管理员重新签发")
identityStore.saveCredentials(registration.deviceId, issuedToken)
credentials = identityStore.credentials() ?: error("设备凭据保存失败")
} else if (credentials == null) {
error("注册请求已处理,但未返回新 Token;请联系管理员重新签发")
} else {
check(registration.deviceId == credentials.deviceId) { "服务端设备身份与本地不一致" }
}
@@ -254,13 +256,20 @@ class AgentForegroundService : Service() {
flushPurchaseOutbox(api, token)
val collectionCooldown = activeCollectionCooldown()
val purchaseTask = api.nextPurchaseTask(token)
when (TaskDispatchPolicy.decide(purchaseTask != null, collectionCooldown != null)) {
when (TaskDispatchPolicy.decide(purchaseTask?.status, 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)
@@ -433,8 +442,13 @@ class AgentForegroundService : Service() {
}
private fun executePurchaseTask(api: AgentApiClient, initial: PurchaseAgentTask, token: String) {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
var resultSafelyStored = false
var diagnosticRecorded = false
var activeTask = initial
val lastStep = AtomicReference("started")
val lastPanelEvidence = AtomicReference<String?>(null)
try {
val claimed = if (initial.status == "pending") {
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
@@ -442,6 +456,7 @@ class AgentForegroundService : Service() {
val task = if (claimed.status == "pending") {
api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
} else claimed
activeTask = task
check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" }
val snapshotHashValid = task.ruleSnapshotHash.matches(Regex("^[0-9a-f]{64}$"))
val snapshotHash = task.ruleSnapshotHash.takeIf { snapshotHashValid } ?: "0".repeat(64)
@@ -469,9 +484,16 @@ class AgentForegroundService : Service() {
} else {
PurchaseRehearsalExecutor(
driver = accessibility,
openLink = { PddLinkLauncher(this).open(it) },
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
stepChanged = { step -> purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) },
stepChanged = { step ->
lastStep.set(step)
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
},
panelDiagnostic = { evidence ->
lastPanelEvidence.set(evidence)
Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence")
},
beforeOrderSubmit = { evidence ->
val boundaryRequestId = UUID.randomUUID().toString()
val finalEvidence = JSONObject()
@@ -511,14 +533,42 @@ class AgentForegroundService : Service() {
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
resultSafelyStored = true
if (outcome.resultType == "failed") {
recordPurchaseDiagnostic(task, outcome, lastStep.get(), lastPanelEvidence.get())
diagnosticRecorded = 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
stateStore.update(if (outcome.resultType == "failed") "TASK_ERROR" else "ONLINE", message, tokenStored = true)
updateNotification(if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交")
} catch (error: AgentApiException) {
if (!diagnosticRecorded) {
recordPurchaseDiagnostic(
activeTask,
PurchaseExecutionOutcome("failed", error.code, error.message ?: "采购接口调用失败"),
lastStep.get(),
lastPanelEvidence.get(),
)
}
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
} catch (error: Exception) {
if (!diagnosticRecorded) {
recordPurchaseDiagnostic(
activeTask,
PurchaseExecutionOutcome("failed", "AGENT_PURCHASE_EXCEPTION", error.message ?: "采购执行异常"),
lastStep.get(),
lastPanelEvidence.get(),
)
}
stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true)
} finally {
if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存")
@@ -526,6 +576,45 @@ class AgentForegroundService : Service() {
}
}
private fun recordPurchaseDiagnostic(
task: PurchaseAgentTask,
outcome: PurchaseExecutionOutcome,
lastStep: String,
panelEvidence: String?,
) {
if (task.taskId <= 0L) return
val deviceId = runCatching { identityStore.credentials()?.deviceId }.getOrNull()
val event = AgentDiagnosticEvent(
taskId = task.taskId,
stage = purchaseDiagnosticStage(lastStep),
reason = AgentDiagnosticReason.PURCHASE_FAILURE,
attempt = task.attemptNumber,
attemptId = task.taskAttemptId.takeIf { it.isNotBlank() },
deviceId = deviceId,
errorCode = outcome.errorCode,
failureMessage = outcome.message,
pageEvidence = panelEvidence,
lastStep = lastStep,
)
diagnosticExecutor.execute {
runCatching {
diagnosticStore.record(event)
val export = diagnosticStore.exportTaskJsonFile(task.taskId)
Log.i("GoAutoDiagnostic", "purchase diagnostic exported task=${task.taskId};file=${export.absolutePath}")
}.onFailure { error ->
Log.w("GoAutoDiagnostic", "purchase diagnostic export failed: ${error.javaClass.simpleName}")
}
}
}
private fun purchaseDiagnosticStage(step: String): AgentDiagnosticStage = when (step) {
"openProduct" -> AgentDiagnosticStage.DETAIL_ENTRY
"openSpecPanel" -> AgentDiagnosticStage.SPEC_PANEL_ENTRY
"selectSpec" -> AgentDiagnosticStage.SIZE_DISCOVERY
"verifyUnitPrice", "verifyOrderSummary" -> AgentDiagnosticStage.PAGE_STABILITY
else -> AgentDiagnosticStage.DETAIL_ENTRY
}
private fun collectPurchaseProbe(accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule): String? {
val snapshot = accessibility.capture()
val activity = snapshot.activityName ?: return null
@@ -609,6 +698,7 @@ class AgentForegroundService : Service() {
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
): TaskExecutionSummary {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
return try {
executeTaskWhileAwake(api, initialTask, token)
@@ -1057,6 +1147,7 @@ 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,13 +99,15 @@ internal object CollectionCooldownPolicy {
internal enum class TaskDispatchDecision {
RUN_PURCHASE,
WAIT_FOR_PURCHASE_MATCH,
WAIT_FOR_COLLECTION_COOLDOWN,
CHECK_COLLECTION,
}
internal object TaskDispatchPolicy {
fun decide(purchaseAvailable: Boolean, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
purchaseAvailable -> TaskDispatchDecision.RUN_PURCHASE
fun decide(purchaseStatus: String?, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
purchaseStatus == "spec_probe_pending" -> TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH
purchaseStatus != null -> TaskDispatchDecision.RUN_PURCHASE
collectionCooldownActive -> TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN
else -> TaskDispatchDecision.CHECK_COLLECTION
}
@@ -0,0 +1,114 @@
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)
}
}
@@ -0,0 +1,86 @@
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,8 +36,6 @@ 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
@@ -85,10 +83,12 @@ internal object CollectionResetPolicy {
internal object PurchaseRetryPolicy {
fun showsAction(status: String, retryable: Boolean): Boolean = status == "failed" && retryable
fun usesInPlaceReset(continuing: Boolean): Boolean = !continuing
fun confirmationMessage(continuing: Boolean = false): String = if (continuing) {
"替代商品已完成匹配。系统会保留原任务并创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
} else {
"将使用服务端最新采购规则重跑当前任务,任务号和商品、规格、价格快照不变;可能创建拼多多待付款订单,但不会支付。"
"系统会复用原采购任务并开始新一次执行,使用当前有效采购规则,商品和规格等任务快照保持不变;可能创建拼多多待付款订单,但不会支付。"
}
}
@@ -748,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))
@@ -866,22 +866,25 @@ class TaskHistoryFragment : Fragment() {
val generation = ++requestGeneration
showLoading(if (continuing) "正在提交继续采购请求…" else "正在提交重试请求…")
Thread {
runCatching {
runCatching<Unit> {
val client = AgentApiClient(serverUrl)
val requestId = UUID.randomUUID().toString()
if (continuing) client.retryPurchaseTask(taskId, requestId, credentials.token)
else client.resetPurchaseTask(taskId, requestId, credentials.token)
}
.onSuccess { result ->
if (PurchaseRetryPolicy.usesInPlaceReset(continuing)) {
val result = client.resetPurchaseTask(taskId, requestId, credentials.token)
resultColumn.post {
if (!isAdded || generation != requestGeneration) return@post
AgentForegroundService.start(requireContext())
when (result) {
is PurchaseRetryResult -> showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
is PurchaseResetResult -> showPurchaseResetSuccess(result.taskNo, result.taskId, result.attemptNumber)
}
showPurchaseResetSuccess(result.taskNo, result.taskId, result.attemptNumber)
}
} else {
val result = client.retryPurchaseTask(taskId, requestId, credentials.token)
resultColumn.post {
if (!isAdded || generation != requestGeneration) return@post
AgentForegroundService.start(requireContext())
showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
}
}
}
.onFailure { error ->
resultColumn.post {
if (isAdded && generation == requestGeneration) {
@@ -892,14 +895,14 @@ class TaskHistoryFragment : Fragment() {
}.start()
}
private fun showPurchaseRetrySuccess(sourceTaskNo: String, taskNo: String, taskId: Long) {
private fun showPurchaseResetSuccess(taskNo: String, taskId: Long, attemptNumber: Int) {
resultColumn.removeAllViews()
resultColumn.addView(requireContext().centeredMessage(
"已创建新任务 $taskNo",
"旧任务 $sourceTaskNo 已保留。新任务将由当前设备按正常队列执行;系统不会支付。",
"$taskNo 已进入第 $attemptNumber 次执行",
"原采购任务已复用,并将由当前设备按正常队列执行;系统不会支付。",
))
resultColumn.addView(MaterialButton(requireContext()).apply {
text = "查看新任务"
text = "查看当前任务"
minimumHeight = requireContext().dp(48)
setOnClickListener { loadPurchaseDetail(taskId) }
}, collectionCardParams())
@@ -910,14 +913,14 @@ class TaskHistoryFragment : Fragment() {
}, collectionCardParams())
}
private fun showPurchaseResetSuccess(taskNo: String, taskId: Long, attemptNumber: Int) {
private fun showPurchaseRetrySuccess(sourceTaskNo: String, taskNo: String, taskId: Long) {
resultColumn.removeAllViews()
resultColumn.addView(requireContext().centeredMessage(
"已加入重试队列 $taskNo",
"任务号保持不变,将使用最新采购规则进行第 $attemptNumber 次尝试;系统不会支付。",
"已创建新任务 $taskNo",
"旧任务 $sourceTaskNo 已保留。新任务将由当前设备按正常队列执行;系统不会支付。",
))
resultColumn.addView(MaterialButton(requireContext()).apply {
text = "查看当前任务"
text = "查看新任务"
minimumHeight = requireContext().dp(48)
setOnClickListener { loadPurchaseDetail(taskId) }
}, collectionCardParams())
@@ -8,4 +8,8 @@
<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,15 +86,19 @@ class CollectionCooldownPolicyTest {
fun `purchase keeps priority while collection waits for cooldown`() {
assertEquals(
TaskDispatchDecision.RUN_PURCHASE,
TaskDispatchPolicy.decide(purchaseAvailable = true, collectionCooldownActive = true),
TaskDispatchPolicy.decide(purchaseStatus = "pending", collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH,
TaskDispatchPolicy.decide(purchaseStatus = "spec_probe_pending", collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN,
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = true),
TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.CHECK_COLLECTION,
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = false),
TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = false),
)
}
}
@@ -0,0 +1,25 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.PddLaunchFallback
import org.junit.Assert.*
import org.junit.Test
class PddLaunchFallbackTest {
@Test fun directSuccessDoesNotStartBrowser() {
assertTrue(PddLaunchFallback.open(true, { true }, { error("browser must not start") }))
}
@Test fun missingHandlerFallsBackOnce() {
var browserCalls = 0
assertTrue(PddLaunchFallback.open(true, { throw IllegalStateException("no handler") }, { browserCalls++; true }))
assertEquals(1, browserCalls)
}
@Test fun rejectedDirectLaunchFallsBack() {
assertTrue(PddLaunchFallback.open(true, { false }, { true }))
assertFalse(PddLaunchFallback.open(true, { false }, { false }))
}
@Test fun collectionKeepsBrowserEntry() {
var directCalls = 0
assertTrue(PddLaunchFallback.open(false, { directCalls++; true }, { true }))
assertEquals(0, directCalls)
}
}
@@ -11,6 +11,7 @@ import cn.ilapage.goauto.agent.automation.PddCollectorConfig
import cn.ilapage.goauto.agent.automation.PddCollectorDriver
import cn.ilapage.goauto.agent.automation.PddProductDetailCollector
import cn.ilapage.goauto.agent.automation.PddScreenParser
import cn.ilapage.goauto.agent.automation.PurchasePanelContext
import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.SpecPanelType
import cn.ilapage.goauto.agent.automation.SwipeDirection
@@ -26,6 +27,62 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PddProductDetailCollectorTest {
private fun prefixlessPanel(): UiSnapshot = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
node("root", "", 0, 0, 1080, 2376),
node("close", "关闭", 970, 270, 1050, 350, clickable = true),
node("info", "", 0, 770, 1080, 1157),
node("info/summary", "米白色(有里布) 2XL 建议131到150斤", 396, 878, 1053, 992, parentPath = "info"),
node("info/quantity", "1", 480, 1046, 561, 1121, className = "android.widget.EditText", parentPath = "info"),
node("minus", "减少数量", 396, 1046, 470, 1121, clickable = true),
node("plus", "增加数量", 570, 1046, 645, 1121, clickable = true),
node("scroll", "", 0, 1294, 1080, 2079, scrollable = true),
node("scroll/color-title", "颜色分类", 36, 1319, 216, 1380, parentPath = "scroll"),
node("scroll/color", "米白色(有里布)", 36, 1405, 352, 1808, clickable = true, selected = true, parentPath = "scroll"),
node("scroll/size-title", "尺码", 36, 1858, 126, 1911, parentPath = "scroll"),
node("scroll/size", "XL 建议111到130斤", 36, 1930, 440, 2015, clickable = true, parentPath = "scroll"),
node("payment", "微信支付", 112, 2100, 929, 2157),
node("submit", "提交订单", 375, 2225, 705, 2284, clickable = true),
))
@Test fun `prefixless selected panel retains structural recognition and product summary`() {
val parsed = PddScreenParser.parse(prefixlessPanel(), config(), GOODS_ID, evidence())
assertEquals(SpecPanelType.NORMAL_SCROLLABLE, parsed.specPanelType)
assertEquals("米白色(有里布) 2XL 建议131到150斤", parsed.selectedSummary)
}
@Test fun `prefixless panel requires quantity and order evidence`() {
for (missing in listOf("plus", "submit", "payment")) {
val snapshot = prefixlessPanel()
val parsed = PddScreenParser.parse(snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == missing }), config(), GOODS_ID, evidence())
assertFalse(parsed.specPanelOpen)
assertEquals(null, parsed.selectedSummary)
}
}
@Test fun `ambiguous product summaries are not used`() {
val snapshot = prefixlessPanel()
val extra = node("info/other", "米白色(有里布) XL 建议111到130斤", 396, 800, 1053, 870, parentPath = "info")
val parsed = PddScreenParser.parse(snapshot.copy(nodes = snapshot.nodes + extra), config(), GOODS_ID, evidence())
assertEquals(null, parsed.selectedSummary)
}
@Test fun `scrolling away color heading and options preserves known purchase panel and summary`() {
val initial = prefixlessPanel()
val opened = PddScreenParser.parse(initial, config(), GOODS_ID, evidence())
val context = PurchasePanelContext(requireNotNull(opened.specPanelContainer), "米白色(有里布)")
val scrolled = initial.copy(nodes = initial.nodes.filterNot { it.path.startsWith("scroll/color") })
val continued = PddScreenParser.parse(scrolled, config(), GOODS_ID, evidence(), context)
assertEquals(1, continued.panelHeadingCount)
assertTrue(continued.specPanelOpen)
assertEquals("米白色(有里布) 2XL 建议131到150斤", continued.selectedSummary)
assertTrue(continued.dimensions.none { it.key == "color" })
assertFalse(PddScreenParser.parse(scrolled, config(), GOODS_ID, evidence()).specPanelOpen)
assertFalse(PddScreenParser.parse(scrolled, config(), GOODS_ID, evidence(),
context.copy(container = context.container.copy(path = "other"))).specPanelOpen)
assertFalse(PddScreenParser.parse(scrolled.copy(nodes = scrolled.nodes.filterNot { it.path == "submit" }),
config(), GOODS_ID, evidence(), context).specPanelOpen)
}
@Test
fun `parser removes only trailing size price and keeps raw evidence`() {
val snapshot = UiSnapshot(
@@ -367,6 +424,64 @@ 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(
@@ -571,6 +686,74 @@ class PddProductDetailCollectorTest {
assertTrue(parsed.dimensions.isEmpty())
}
@Test
fun nonScrollableSelectorWithTwoDimensionsDoesNotRequireInitialSelectedSummary() {
val snapshot = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("color-heading", "颜色", 20, 420, 300, 470),
node("color", "豹纹", 20, 490, 300, 550, clickable = true),
node("size-heading", "尺码", 20, 650, 300, 700),
node("size", "均码", 20, 720, 300, 780, clickable = true),
node("quantity", "1", 480, 1400, 600, 1480, className = "android.widget.EditText"),
node("decrease", "减少数量", 360, 1400, 470, 1480, clickable = true),
node("increase", "增加数量", 610, 1400, 720, 1480, clickable = true),
node("order", "提交订单", 20, 1900, 1060, 2100, clickable = true),
),
)
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
assertTrue(parsed.specPanelOpen)
assertEquals(SpecPanelType.NON_SCROLLABLE_CONFIRMATION, parsed.specPanelType)
assertFalse(parsed.hasSelectionSummary)
assertEquals(2, parsed.dimensions.size)
assertEquals(2, parsed.panelOptionCount)
}
@Test
fun nestedSelectionRowCombinesPrefixAndDimensionBeforeChoosingClickableParent() {
val snapshot = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("selection-row", "", 20, 720, 1060, 860, clickable = true),
node("selection-row/prefix", "请选择", 48, 750, 220, 810, parentPath = "selection-row"),
node("selection-row/dimension", "颜色分类", 240, 750, 480, 810, parentPath = "selection-row"),
),
)
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
assertEquals("selection-row", parsed.specEntry?.path)
assertEquals("selection-row", parsed.specEntryClickTarget?.path)
assertEquals("nested_selection", parsed.specEntrySource)
assertEquals(1, parsed.nestedSpecEntryCount)
}
@Test
fun nestedSelectionSemanticsNeverAcceptsOrderOrPaymentContainer() {
val snapshot = UiSnapshot(
PDD_PACKAGE,
ACTIVITY,
listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
node("unsafe", "", 20, 720, 1060, 860, clickable = true),
node("unsafe/prefix", "请选择", 48, 750, 220, 810, parentPath = "unsafe"),
node("unsafe/dimension", "颜色", 240, 750, 480, 810, parentPath = "unsafe"),
node("unsafe/order", "提交订单", 700, 750, 1020, 810, parentPath = "unsafe"),
),
)
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence())
assertEquals(null, parsed.specEntry)
assertEquals(0, parsed.nestedSpecEntryCount)
}
@Test
fun genericQuantityAndBuyControlsDoNotProveQuickConfirmation() {
val snapshot = UiSnapshot(
@@ -1,6 +1,8 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.FreshActionResult
import cn.ilapage.goauto.agent.automation.FreshClickOutcome
import cn.ilapage.goauto.agent.automation.FreshClickReason
import cn.ilapage.goauto.agent.automation.NodeBounds
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
@@ -16,6 +18,81 @@ 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 `recorded exact selections allow one unique confirm on an unclassified pdd panel`() {
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true)
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation(
allowUnclassifiedPanelWithSelectionProof = true,
)
assertEquals(listOf("确定"), driver.clicked)
assertEquals("order", driver.page)
}
@Test
fun `unclassified pdd panel without recorded selections remains blocked`() {
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRMATION_NOT_READY", error.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `recorded selections never authorize a generic unclassified pdd dialog`() {
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true, unclassifiedHasPurchaseEvidence = false)
val error = runCatching {
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation(
allowUnclassifiedPanelWithSelectionProof = true,
)
}.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRMATION_NOT_READY", error.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `address is retagged verified and final order button can only be clicked once`() {
val driver = LiveDriver()
@@ -64,6 +141,31 @@ class PurchaseLiveAutomationTest {
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address returning to spec panel is recovered without pressing back`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true)
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg91")
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg91"), address)
assertEquals("_cg91", final.addressSuffix)
assertEquals(0, driver.backCount)
assertEquals(1, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address stuck on spec panel fails without backing out or submitting`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedSpecPanelRecoveryStuck = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg92") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(0, driver.backCount)
assertEquals(0, driver.submitClicks)
}
@Test
fun `edit field containing suffix cannot impersonate post save evidence`() {
val driver = LiveDriver(saveStaysInEdit = true)
@@ -120,6 +222,122 @@ 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)
@@ -283,6 +501,52 @@ class PurchaseLiveAutomationTest {
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `duplicate masked phone nodes in one clickable address card are activated once`() {
val driver = LiveDriver(duplicatePhoneNodesSameCard = true)
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94")
assertEquals(1, driver.clicked.count { it == "138****5678" })
assertEquals(0, driver.addressTaps)
}
@Test
fun `duplicate semantic address cards in separate accessibility trees are activated once`() {
val driver = LiveDriver(duplicateSemanticAddressCards = true)
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94")
assertEquals(1, driver.addressPathClicks)
assertEquals(1, driver.clicked.count { it == "138****5678" })
assertEquals(0, driver.addressTaps)
}
@Test
fun `two independent address cards remain ambiguous without any click`() {
val driver = LiveDriver(duplicateAddressCards = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg95") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
assertTrue(error.message.orEmpty().contains("phoneNodes=2;addressCards=2;tapTargets=2"))
assertFalse(error.message.orEmpty().contains("138"))
assertTrue(driver.clicked.isEmpty())
assertEquals(0, driver.addressTaps)
}
@Test
fun `address entry path drift fails without fallback tap`() {
val driver = LiveDriver(duplicatePhoneNodesSameCard = true, addressEntryPathUnavailable = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_NOT_READY", error.code)
assertTrue(driver.clicked.isEmpty())
assertEquals(1, driver.addressPathClicks)
assertEquals(0, driver.addressTaps)
}
private fun input() = PurchaseExecutionInput(
taskId = 11,
executionMode = "live",
@@ -297,6 +561,78 @@ class PurchaseLiveAutomationTest {
addressSuffix = "_cg11",
)
private class SpecConfirmationDriver(
private val confirmLabels: List<String> = listOf("确定"),
private val advanceAfterClick: Boolean = true,
private val specPanelScrollable: Boolean = false,
private val unclassifiedSpecPanel: Boolean = false,
private val unclassifiedHasPurchaseEvidence: Boolean = true,
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 (unclassifiedSpecPanel) {
if (unclassifiedHasPurchaseEvidence) {
add(node("price", "¥20.00"))
add(node("quantity", "1", className = "android.widget.EditText"))
}
confirmLabels.forEachIndexed { index, label -> add(node("confirm-$index", label, clickable = true)) }
return@buildList
}
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,
@@ -308,8 +644,18 @@ class PurchaseLiveAutomationTest {
private val saveStaysInEdit: Boolean = false,
private val savedTransitionWithoutLegacyContext: Boolean = false,
private val savedTransitionHidesSuffix: Boolean = false,
private val saveReturnsToSpecPanel: Boolean = false,
private val savedSpecPanelRecoveryStuck: Boolean = false,
private val duplicatePhoneNodesSameCard: Boolean = false,
private val duplicateSemanticAddressCards: Boolean = false,
private val duplicateAddressCards: Boolean = false,
private val addressEntryPathUnavailable: 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"
@@ -322,10 +668,12 @@ class PurchaseLiveAutomationTest {
var scopedSwipes = 0
var genericSwipes = 0
var addressTaps = 0
var addressPathClicks = 0
var lastInputTargetPath: String? = null
var backCount = 0
var postSubmitBackCount = 0
var pddRestoreCount = 0
var orderDetailEntryClicks = 0
var postSubmitCaptureCount = 0
val currentPage: String get() = page
@@ -358,8 +706,36 @@ class PurchaseLiveAutomationTest {
node("transition-title", "选择收货信息"),
node("address-summary", if (savedTransitionHidesSuffix) "已保存的收货信息" else address.substring(address.lastIndexOf("_cg"))),
))
"post-save-spec" -> snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
node("panel-title", "确认款式", bounds = NodeBounds(20, 396, 300, 430)),
node("panel/price", "¥20.00", parentPath = "panel", bounds = NodeBounds(20, 460, 300, 520)),
node("panel/selected", "已选 黑色 XL", parentPath = "panel", bounds = NodeBounds(20, 540, 700, 600)),
node("panel/color-heading", "颜色分类", parentPath = "panel", bounds = NodeBounds(20, 650, 300, 700)),
node("panel/color", "黑色", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 720, 220, 790)),
node("panel/size-heading", "尺码", parentPath = "panel", bounds = NodeBounds(20, 850, 300, 900)),
node("panel/size", "XL", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 920, 220, 990)),
node("panel/quantity", "2", className = "android.widget.EditText", parentPath = "panel", bounds = NodeBounds(400, 1050, 600, 1120)),
node("panel/confirm", "确定", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 1200, 500, 1280)),
node("submit-parent", "", clickable = true, bounds = NodeBounds(20, 1900, 1000, 2100)),
node("submit", "提交订单", parentPath = "submit-parent", bounds = NodeBounds(520, 1940, 980, 2040)),
))
"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", "微信分身"),
))
@@ -378,7 +754,30 @@ class PurchaseLiveAutomationTest {
)
if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000))
if (addressVisible) {
nodes += node("phone", "138****5678")
when {
duplicateSemanticAddressCards -> {
nodes += node("address-layer-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 840))
nodes += node("address-layer-a/phone", "138****5678", parentPath = "address-layer-a", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-layer-a/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-a", bounds = NodeBounds(20, 720, 900, 790))
nodes += node("address-layer-b", "", clickable = true, bounds = NodeBounds(0, 900, 1080, 1120))
nodes += node("address-layer-b/phone", "138****5678", parentPath = "address-layer-b", bounds = NodeBounds(20, 930, 400, 990))
nodes += node("address-layer-b/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-b", bounds = NodeBounds(20, 1000, 900, 1070))
}
duplicateAddressCards -> {
nodes += node("address-card-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 820))
nodes += node("address-card-a/phone", "138****5678", parentPath = "address-card-a", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-card-a/detail", "广东省广州市天园街道一号", parentPath = "address-card-a", bounds = NodeBounds(20, 720, 900, 780))
nodes += node("address-card-b", "", clickable = true, bounds = NodeBounds(0, 840, 1080, 1040))
nodes += node("address-card-b/phone", "138****5678", parentPath = "address-card-b", bounds = NodeBounds(20, 870, 400, 930))
nodes += node("address-card-b/detail", "广东省广州市天园街道二号", parentPath = "address-card-b", bounds = NodeBounds(20, 940, 900, 1000))
}
duplicatePhoneNodesSameCard -> {
nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 900))
nodes += node("address-card/phone-a", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-card/phone-b", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 720, 440, 790))
}
else -> nodes += node("phone", "138****5678")
}
val suffixStart = address.lastIndexOf("_cg")
val addressBody = if (suffixStart >= 0) address.substring(0, suffixStart) else address
val addressSuffix = if (suffixStart >= 0) address.substring(suffixStart) else ""
@@ -403,19 +802,38 @@ class PurchaseLiveAutomationTest {
when (target.label) {
"138****5678" -> page = "panel"
"修改" -> page = "edit"
"保存" -> if (!saveStaysInEdit) page = if (savedTransitionWithoutLegacyContext) "saved-transition" else "panel"
"保存" -> if (!saveStaysInEdit) {
page = when {
saveReturnsToSpecPanel -> "post-save-spec"
savedTransitionWithoutLegacyContext -> "saved-transition"
else -> "panel"
}
}
"提交订单" -> {
submitClicks++
page = when {
chooserAfterSubmit -> "chooser"
wechatLoginAfterSubmit -> "wechat-login"
orderDetailEntryAfterSubmit -> "success"
else -> "order"
}
}
"查看订单", "订单详情" -> {
orderDetailEntryClicks++
page = if (orderDetailEvidenceBelowFold) "order-folded" else "order"
}
}
return FreshActionResult.SUCCESS
}
override fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome {
addressPathClicks++
if (addressEntryPathUnavailable) {
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
return FreshClickOutcome(clickFresh(target), FreshClickReason.SUCCESS, 1, 1)
}
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
addressTaps++
clicked += target.label
@@ -428,10 +846,15 @@ class PurchaseLiveAutomationTest {
address = value
return FreshActionResult.SUCCESS
}
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { genericSwipes++; return true }
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
genericSwipes++
if (page in setOf("order-folded", "order-folded-payment-activity") && direction == SwipeDirection.UP) page = "order"
return true
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
scopedSwipes++
if (target.path == "panel" && direction == SwipeDirection.DOWN) addressVisible = true
if (page == "post-save-spec" && direction == SwipeDirection.DOWN && !savedSpecPanelRecoveryStuck) page = "confirmation"
return true
}
override fun backPurchase(): Boolean {
@@ -439,7 +862,7 @@ class PurchaseLiveAutomationTest {
if (page == "chooser" || page == "payment") postSubmitBackCount++
page = when (page) {
"chooser" -> "payment"
"payment" -> "order"
"payment" -> if (orderEvidenceBelowFold) "order-folded" else "order"
else -> "confirmation"
}
return true
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
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,18 +16,22 @@ class PurchaseRetryPolicyTest {
}
@Test
fun `retry confirmation explains same task latest rule and no payment`() {
fun `ordinary retry uses same task new attempt and no payment`() {
val message = PurchaseRetryPolicy.confirmationMessage()
assertTrue(message.contains("当前任务"))
assertTrue(message.contains("最新采购规则"))
assertFalse(message.contains("新采购任务"))
assertTrue(PurchaseRetryPolicy.usesInPlaceReset(continuing = false))
assertTrue(message.contains("复用原采购任务"))
assertTrue(message.contains("新一次执行"))
assertTrue(message.contains("当前有效采购规则"))
assertTrue(message.contains("任务快照保持不变"))
assertTrue(message.contains("待付款订单"))
assertTrue(message.contains("不会支付"))
assertFalse(message.contains("新采购任务"))
}
@Test
fun `continue confirmation remains a new purchase task`() {
val message = PurchaseRetryPolicy.confirmationMessage(continuing = true)
assertFalse(PurchaseRetryPolicy.usesInPlaceReset(continuing = true))
assertTrue(message.contains("新采购任务"))
assertTrue(message.contains("替代商品"))
assertTrue(message.contains("不会支付"))
@@ -0,0 +1,39 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.NodeBounds
import cn.ilapage.goauto.agent.automation.PurchaseScrollCandidate
import cn.ilapage.goauto.agent.automation.PurchaseScrollLocator
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PurchaseScrollLocatorTest {
private val outer = PurchaseScrollCandidate("0/1", "RecyclerView", NodeBounds(0, 1038, 1080, 2079))
private val inner = PurchaseScrollCandidate("0/1/0", "RecyclerView", NodeBounds(36, 1149, 1080, 2007))
@Test fun nestedContainersWithNearbyCentersResolveOuter() {
assertEquals(outer, PurchaseScrollLocator.locate(outer, listOf(inner, outer)))
}
@Test fun pathDisambiguatesIdenticalBounds() {
val nested = outer.copy(path = "0/1/0")
assertEquals(outer, PurchaseScrollLocator.locate(outer, listOf(nested, outer)))
}
@Test fun changedPathRequiresUniqueFullBoundsMatch() {
val moved = outer.copy(path = "0/2")
assertEquals(moved, PurchaseScrollLocator.locate(outer, listOf(inner, moved)))
assertNull(PurchaseScrollLocator.locate(outer, listOf(moved, moved.copy(path = "0/3"))))
}
@Test fun reusedPathWithDifferentGeometryIsRejected() {
assertNull(PurchaseScrollLocator.locate(outer, listOf(inner.copy(path = outer.path))))
}
@Test fun classAndAllEdgesAreValidated() {
assertNull(PurchaseScrollLocator.locate(outer, listOf(outer.copy(className = "ScrollView"))))
assertNull(PurchaseScrollLocator.locate(outer, listOf(
outer.copy(bounds = NodeBounds(0, 1138, 1080, 1979)),
)))
}
}
@@ -83,6 +83,24 @@ class AgentDiagnosticStoreMigrationTest {
assertEquals(1, rowCount(db))
}
@Test
fun v2MigrationAddsPurchaseFailureColumnsWithoutDroppingRows() = withDatabase { db ->
db.createStatement().use { statement ->
statement.execute(CREATE_V1_TABLE_SQL)
statement.execute(
"INSERT INTO agent_diagnostic " +
"(task_id, stage, reason, attempt, elapsed_ms, agent_version, created_at) " +
"VALUES (153, 'SPEC_PANEL_ENTRY', 'PURCHASE_FAILURE', 2, 120, '0.9.99', 1000)",
)
}
AgentDiagnosticSchema.migrationStatements(1, 3, columnNames(db)).forEach { sql ->
db.createStatement().use { it.execute(sql) }
}
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.purchaseFailureColumns.keys))
assertEquals(1, rowCount(db))
assertTrue(AgentDiagnosticSchema.migrationStatements(3, 3, columnNames(db)).isEmpty())
}
private fun migrateV1ToV2(db: Connection) {
AgentDiagnosticSchema.v2MigrationStatements(1, 2, columnNames(db)).forEach { sql ->
db.createStatement().use { it.execute(sql) }
+20 -59
View File
@@ -67,7 +67,6 @@ CORE_DOCUMENT_REQUIREMENTS = {
),
"docs/00-project-profile.md": (
"## 基本信息",
"## 项目治理模式",
"## 建设基线",
"## 交付单元",
"## 文档事实来源",
@@ -75,7 +74,6 @@ CORE_DOCUMENT_REQUIREMENTS = {
"## 当前阶段",
),
"docs/01-workflow.md": (
"## 语言与术语",
"## 事实来源",
"## 权威源与事实边界",
"## Gitea 交互与工单最小读取",
@@ -104,7 +102,6 @@ CORE_DOCUMENT_REQUIREMENTS = {
),
"docs/04-local-development-and-verification.md": (
"## Windows PowerShell 与 UTF-8",
"### PowerShell 语法与外部命令",
"## 通用检查",
"## 服务端验证",
"## Web 验证",
@@ -274,14 +271,9 @@ def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
"#### 明确停止条件",
"单元任务是唯一正式实施单位",
"高风险修改必须停止",
"### 项目治理模式与明确授权后的执行",
"默认采用轻量治理",
"不能单独构成人工授权",
"不得仅因操作不可逆而重复询问或拒绝",
"平台自身强制的审批、安全策略或权限限制继续有效",
"用户没有明确验收通过前不得关闭",
"只有长期事实变化时才更新 Wiki",
"需要工单的任务以 Gitea 工单作为单次需求、变化、实现、测试、提交和验收的事实来源",
"Gitea 工单是单次任务需求、变化、实现、测试、提交和验收的唯一事实来源",
"Wiki 同步由长期事实变化触发,不由任务完成触发",
"标准流程不创建 Wiki 任务归档",
"工单默认只在开始实施、集中回写待验收、验收关闭三个节点更新",
@@ -297,11 +289,6 @@ 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`",
"### 工单与设计证据双门禁",
@@ -329,9 +316,6 @@ 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}")
@@ -578,47 +562,32 @@ def run_check(args: argparse.Namespace) -> int:
def run_sync(args: argparse.Namespace) -> int:
"""同步核心镜像;--verify 复用本轮完整读取完成初始化验证。"""
"""--verify 依次执行导出、结构检查和一致性校验,替代原来的三条命令。"""
if args.verify:
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 镜像初始化验证通过")
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
return 0
try:
config = load_config(Path(args.config).resolve())
deep_check = getattr(args, "deep_check", False)
messages = sync_all(
config,
WikiClient(config),
check=args.check or deep_check,
deep_check=deep_check,
)
messages = sync_all(config, WikiClient(config), check=args.check)
except WikiDocsError as exc:
print(f"错误:{exc}")
return 1
for message in messages:
print(message)
print(
"Wiki 镜像深度检查通过"
if deep_check
else "Wiki 镜像检查通过"
if args.check
else "Wiki 镜像同步完成"
)
print("Wiki 镜像检查通过" if args.check else "Wiki 镜像同步完成")
return 0
@@ -683,21 +652,13 @@ def main() -> int:
p_check.set_defaults(func=run_check)
p_sync = sub.add_parser("sync", help="从 Gitea Wiki 单向同步核心 docs 镜像")
sync_mode = p_sync.add_mutually_exclusive_group()
sync_mode.add_argument(
"--check",
action="store_true",
help="按 revision 快速检查 Wiki 与镜像,不写文件",
p_sync.add_argument(
"--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件"
)
sync_mode.add_argument(
"--deep-check",
action="store_true",
help="下载全部 Wiki 正文并逐页检查镜像,不写文件",
)
sync_mode.add_argument(
p_sync.add_argument(
"--verify",
action="store_true",
help="完整读取并导出 Wiki,再执行 check --strict 初始化验证",
help="依次执行导出、check --strict 和一致性校验",
)
p_sync.add_argument(
"--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件"
+15 -138
View File
@@ -8,7 +8,6 @@ import os
import re
import subprocess
import tempfile
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
@@ -27,7 +26,6 @@ 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):
@@ -144,33 +142,11 @@ 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)
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}:
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}:
# 公共仓库可能可匿名读取,而当前 shell 中的通用令牌属于
# 另一个实例或已失效。只对只读请求安全降级为匿名访问。
anonymous_headers = {"Accept": "application/json"}
@@ -191,13 +167,12 @@ class WikiClient:
f"无法连接 Gitea:{anonymous_exc.reason}"
) from anonymous_exc
else:
detail = request_error.read().decode("utf-8", errors="replace")
detail = exc.read().decode("utf-8", errors="replace")
raise WikiDocsError(
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
f"Gitea API {method} {api_path} 返回 {exc.code}: {detail}"
) from exc
except URLError as exc:
raise WikiDocsError(f"无法连接 Gitea:{exc.reason}") from exc
if not body:
return None
try:
@@ -406,73 +381,8 @@ def check_mirror(mapping: Mapping, page: WikiPage, path: Path) -> list[str]:
return errors
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]:
"""检查或写入所有显式映射;写入前先完成全部远端读取。"""
def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list[str]:
"""检查或写入所有显式映射;绝不处理映射外的文件。"""
if not check:
dirty = dirty_mirror_paths(config)
@@ -482,43 +392,10 @@ def sync_all(
"已映射的本地镜像存在未提交改动,已停止以防覆盖:\n" + details
)
pages = client.list_pages()
resolved: list[tuple[Mapping, Path, WikiPage | None, str | None]] = []
for mapping in config.mappings:
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}")
for mapping in config.mappings:
page = client.get_page(mapping.page)
target = ROOT / PurePosixPath(mapping.path)
if check:
errors = check_mirror(mapping, page, target)
if errors:
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
wiki_revision: 7468b9fbdd4d0bbbb9a73580c22ec868b3085753
synchronized_at: 2026-09-05T04:06:47Z
synchronized_at: 2026-09-05T07:16:39Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Development-Workflow
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.-
wiki_revision: 62ddbe4469740c02ce4a6ca2fd1966a89a79322f
synchronized_at: 2026-09-05T04:06:53Z
synchronized_at: 2026-09-05T07:16:44Z
<!-- gitea-wiki-mirror:end -->
# 开发工作流
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
synchronized_at: 2026-09-04T11:29:50Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
+24 -9
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: f8a2e64acaf245066d55d4a0f1e928d672712270
synchronized_at: 2026-09-05T04:13:06Z
wiki_revision: 670a5592a6db8301cd115295bf820f7f4b6e06d7
synchronized_at: 2026-09-07T03:21:47Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -257,13 +257,13 @@ synchronized_at: 2026-09-05T04:13:06Z
## Agent 受控重试采购
- 当前设备只可重试自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选、批量或自动重试。
- 普通“重试采购”调用既有 `AgentRetry → BatchRetry → Create`:原失败任务及其商品、目标规格、执行规格、价格和执行记录保持不可变;服务端根据当前 SYB、虾皮/PDD 档案、当前采购规则和当前设备创建不同 `purchase_task.id` 的新任务。
- 新 SYB 采购任务继续遵循 #215 的强制当次规格探测,首趟不得直接使用历史任务的规格决策;当前档案、规则、价格、设备或能力门禁不通过时拒绝创建,旧任务保持失败状态。
- 普通“重试采购”调用就地 `/reset`:复用原 `purchase_task.id`,只新增 attempt;新 attempt 使用当前有效采购规则和最新版 Agent 代码,商品、目标规格、执行规格、数量、价格、地址及既有规格决策等业务快照保持不变。
- 就地重试不恢复已经消耗的真机规格探测资格;目标规格存在但对应执行规格为空时必须拒绝,不能进入正式采购阶段。需要按替代商品或已变化业务规格重新决策时,必须走明确的新任务流程。
- 已出现 `order_submit_started` 证据,或存在不可逆时间、订单提交请求、PDD 订单号、下单时间的任务一律拒绝重试,并提示走既有“授权重新采购”流程,防止重复下单。
- `requestId` 按“来源任务 + 请求”幂等;相同请求重放返回同一新任务,不重复创建。
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认和成功反馈必须说明旧任务保留、新任务读取当前档案和规则、可能创建待付款订单且系统不会支付。
- 历史兼容的就地 `/reset` 服务端入口不得把“目标规格存在但对应执行规格为空”的任务恢复到正式采购阶段;此类异常快照必须拒绝,并提示创建新任务。Android 普通重试不再调用该入口。
- 替代商品匹配完成后的“继续采购”和 Admin 批量重试继续使用同一新任务语义;取消订单、修改既有订单和支付仍禁止。真机重试可能进入创建待付款订单流程,执行前必须再次取得人工授权。
- `requestId` 按“任务 + 重置请求”幂等;相同请求重放返回同一 task ID 和 attempt,不重复递增。
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认和成功反馈必须说明复用原任务、新增一次执行、使用当前规则但业务快照不变、可能创建待付款订单且系统不会支付。
- `/reset` 不得把“目标规格存在但对应执行规格为空”的任务恢复到正式采购阶段;此类异常快照必须拒绝,并提示使用明确的新任务流程。
- 替代商品匹配完成后的“继续采购”继续调用 `AgentRetry → BatchRetry → Create`,按当前档案创建不同 task ID;Admin 批量重试也保持新任务语义。取消订单、修改既有订单和支付仍禁止。真机重试可能进入创建待付款订单流程,执行前必须再次取得人工授权。
## Agent 状态页手动检查任务
@@ -341,7 +341,7 @@ synchronized_at: 2026-09-05T04:13:06Z
## SYB 采购强制当次规格探测(#215)
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务;第二趟复用 PDD 当前页,不再次打开链接,也不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务。连续进入第二趟且当前仍有 PDD 商品页或规格面板强证据时复用当前页、不再次打开链接;手动同任务重试,或当前处于 Agent、其他应用及缺少上述强证据时,重新打开任务固化的商品 URL。复用或重开均不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。
- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。
@@ -429,3 +429,18 @@ synchronized_at: 2026-09-05T04:13:06Z
- 管理员可在设备管理对未停用设备发起“重置设备身份”。此动作不删除设备记录、不改变设备 ID、能力或已绑定的待领取任务;它立即使旧 Token 无效,并只生成一次、有效期 10 分钟的恢复码。
- 恢复码仅显示给发起操作的管理员一次,服务端只保存摘要;不得进入列表、日志、任务记录、Android 持久化或普通接口。手机操作员必须在同一安装实例的 Agent 设置中手动输入。
- 服务端只接受同一 `installId`、未过期且尚未使用的恢复码完成重新注册,成功后签发新 Token 并使恢复码失效。过期、重复使用、installId 不符或停用均明确失败;不能通过清空数据、直接改库或“吊销 Token”恢复原任务归属。
- 自 #223 起,任务创建时把与冻结 SYB 目标对应的已确认商品规格映射保存为不可变的探测指导快照,但仍不得跳过首趟真机探测。探测完成后,每个角色先验证快照映射能否按既有规范化规则唯一对应当次候选,能对应时固化当次候选原文;不能对应时只对该未解决角色执行确定性匹配,仍无结果才调用 AI。已解决角色不重复交给 AI,任一最终值仍必须逐字属于当次候选;历史映射失效、规范化后歧义或角色不符时不得复用。候选完整但无法决策时提示“已采集到当前规格,但未能确定颜色或尺码映射”,不再误报候选不存在。
## 采购商品深链入口(#232)
- Android 采购新任务(含探测与人工重试)打开商品时,优先使用指定 PDD 包名的 ACTION_VIEW,并设置 NEW_TASK 与 CLEAR_TASK,以清理旧任务栈后交付该任务链接。清理的是 Activity 返回历史,不是清除应用数据或强制停止进程。
- 直接启动失败(包括无可处理 Activity)时回退既有浏览器入口;启动请求被接受后仍由执行器验证稳定商品页面,停留首页不会仅因启动成功而判定完成。
- 清栈不用于改地址后返回、下单后回到 PDD、订单核查;采集入口继续使用既有浏览器流程,避免绕过规则中的浏览器步骤。
- 当前通用商品页面结构验证不能独立证明页面 goods_id;解析结果携带的任务 goodsId 不是页面回读证据。首次直接深链上线仍需以实际目标商品真机核对,ADB 实验不替代 Agent 上下文验证。
## SYB 批量采购设备偏好(#233)
- SYB 商品页“批量创建采购任务”按当前登录用户 ID、当前浏览器来源与 API 环境记住最近一次设备选择;主动清空也记忆为不指定设备。只保存设备 ID,不保存 Token、账号凭据或订单信息。
- 弹窗打开后先按现有在线/可选/采购能力条件加载设备,再恢复选择并以相同设备预检。记忆设备当前不可用时保留偏好并提示用户重新选择或明确清空,不静默切换设备或自动领取。
- 预检加载中、失败或记忆设备不可用时不能提交;过期预检结果不覆盖新的设备选择。服务端原有设备及采购资格校验不变。
- 偏好只作用于此入口,不影响采集、其他创建入口和采购重试。浏览器存储失败时仍允许手动操作;刷新或关闭再打开浏览器可恢复,清理浏览器数据或沿用现有退出登录清理存储行为后需重新选择。
@@ -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-05T04:13:06Z
synchronized_at: 2026-09-05T07:17:05Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Common-Changes
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Common-Changes.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
synchronized_at: 2026-09-04T11:30:12Z
<!-- gitea-wiki-mirror:end -->
# 常见修改指南
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
synchronized_at: 2026-09-04T11:30:16Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
synchronized_at: 2026-09-04T11:30:20Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览与当前 MVP
+12 -10
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 08a65eb49d3d1091f0be30e6f6d1a9372f6e5b37
synchronized_at: 2026-09-05T04:13:06Z
wiki_revision: f3242938de4ac55c47f5c6eebd69184024e6a0ea
synchronized_at: 2026-09-05T09:04:42Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -535,11 +535,11 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务和 `spec_probe` attempt 永远拒绝 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 |
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。第二阶段直接复用首趟保留的 PDD 页面,不再次打开浏览器链接,也不以标题、goodsId 或页面指纹做严格同页校验;仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。连续第二阶段仅在当前 PDD 页面仍有商品页或规格面板强证据时复用首趟页面、不再次打开浏览器链接;手动同任务重试,或当前为 Agent、其他应用及缺少上述强证据时,Android 重新打开任务 `urlSnapshot`。两种路径都不以标题、goodsId 或页面指纹做严格同页校验,仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放数据库租约和已知账号运行守卫并进入 `spec_probe_pending`,但服务端调度与 Agent 必须把当前设备保留给同一采购流程:`next` 返回该等待任务,Agent 只轮询等待,不领取其他采购或采集任务。服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 复用首趟仍打开的页面,只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。历史兼容的就地 `/reset` 保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。普通 Agent 重试创建新任务并重新取得一次探测资格。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。普通 Agent 重试使用就地 `/reset`,保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。只有替代商品“继续采购”或 Admin 批量重试创建的新任务才按 #215 重新取得一次探测资格。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。
Android 规格失败使用五个稳定阶段:`PURCHASE_SPEC_TARGET_NOT_VISIBLE`、`PURCHASE_SPEC_TARGET_AMBIGUOUS`、`PURCHASE_SPEC_SAFE_TARGET_MISSING`、`PURCHASE_SPEC_CLICK_FAILED` 和 `PURCHASE_SPEC_SELECTION_UNCONFIRMED`。`PURCHASE_SPEC_CLICK_FAILED` 的 `errorMessage` 只允许稳定子原因 `root_unavailable`、`target_stale`、`no_clickable_ancestor`、`action_click_false` 或 `unknown`;其他阶段的消息不得包含规格原文、坐标、控件树或截图。只有 `PURCHASE_SPEC_TARGET_NOT_VISIBLE && specResolutionAllowed=true` 可以提交规格探测,其他四态直接提交真实失败,服务端原样保留稳定阶段/子原因。旧 Agent 在资格已用尽后再次提交 `spec_probe_completed` 时,服务端以 `PURCHASE_SPEC_REPROBE_REJECTED` fail-closed,释放租约并保留第一次规格决策,不再冒充新的选择根因或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。
@@ -617,9 +617,9 @@ Content-Type: application/json
- 响应返回 `taskId`、`attemptNumber`、`status` 和可选的 `replayed`,不返回规则快照、URL、Token、控件树或截图。
- 设备离线、任务非终态、设备忙、规则不可用或同商品存在活动任务时返回明确冲突,不支持离线排队。
## Agent 受控采购重试(#95、#157、#217)
## Agent 受控采购重试(#95、#157、#217、#225)
普通失败任务的“重试采购”和替代商品匹配完成后的“继续采购”统一调用新任务接口:
替代商品匹配完成后的“继续采购”调用新任务接口;普通失败任务的“重试采购”使用下方同任务重置接口:
```http
POST /api/agent/v1/purchase-tasks/{taskId}/retry
@@ -647,11 +647,11 @@ Content-Type: application/json
- 来源任务不得存在 `irreversibleAt`、`orderSubmitRequestId`、PDD 订单号或下单时间。已有任何不可逆证据时返回 `PURCHASE_RETRY_UNSAFE`,提示走“授权重新采购”,不得创建新任务。
- `AgentRetry → BatchRetry → Create` 保留来源失败任务并创建不同 `purchase_task.id` 的新任务;新任务重新读取当前 SYB、虾皮/PDD 档案、当前采购规则、价格保护和设备能力,重新生成地址后缀,不继承来源任务的旧规格决策。
- 新 SYB 任务按 #215 固定从 `spec_probe` 开始。相同 `requestId` 重放返回同一新任务且 `replayed=true`;不同 requestId 再次请求受同一 SYB 商品最新任务和设备并发门禁约束。
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”;确认文案和成功反馈必须说明原任务保留、新任务使用当前档案与规则、可能产生待付款订单且系统不会支付。
- 本新任务接口只供替代商品“继续采购”;确认和成功反馈必须说明保留来源任务、按当前替代商品档案创建新任务、可能产生待付款订单且系统不会支付。
- 规则无效、当前档案或价格不合格、设备离线/忙、能力不匹配、任务状态变化或同一 SYB 商品已有更新任务时,服务端明确拒绝且不得部分创建。
- Admin 批量重试继续使用相同的新任务语义;替代商品“继续采购”仍在 AgentRetry 前额外验证替换分项与继续采购资格。
历史兼容的就地重置接口仍保留,但 Android 普通重试不再调用:
普通失败任务的“重试采购”调用同任务重置接口:
```http
POST /api/agent/v1/purchase-tasks/{taskId}/reset
@@ -661,8 +661,8 @@ Content-Type: application/json
{"requestId":"<uuid>"}
```
- `/reset` 只允许安全失败、无不可逆证据且不存在更新任务的原任务;它保留原业务快照并刷新当前规则。
- 目标颜色存在但映射颜色为空,或目标尺码存在但映射尺码为空时,必须返回 `PURCHASE_SPEC_MAPPING_REQUIRED`,不得创建 `purchase` attempt 或下发正式采购 payload。
- `/reset` 只允许当前设备的安全失败任务且不得存在不可逆证据或更新任务;它复用原 `purchase_task.id`、新增 attempt、刷新当前有效规则,并保持商品、目标/执行规格、数量、价格、地址和规格决策等业务快照不变。
- 目标颜色存在但映射颜色为空,或目标尺码存在但映射尺码为空时,必须返回 `PURCHASE_SPEC_MAPPING_REQUIRED`,不得创建 `purchase` attempt 或下发正式采购 payload。相同 `requestId` 重放返回同一 task ID 和 attempt,不重复递增;Android 必须明确提示复用原任务和新的 attempt 序号。
- 两个入口都不执行支付。真机调用可能创建待付款订单,必须先取得人工授权。
## Agent 任务记录范围与同步(#99)
@@ -865,3 +865,5 @@ X-GoAuto-Device-Recovery-Code: <one-time-code>
仅管理员可以对未停用的既有设备发起身份重置。服务端立即使旧 Device Token 无效,并生成 10 分钟内仅能使用一次的恢复码;恢复码只在该管理员操作的响应中返回一次,服务端仅保存不可逆摘要,管理端设备列表、日志、任务接口和 Android 本地持久化均不得保存或返回原文。管理员将恢复码经受控人工渠道输入同一安装实例的 Agent 设置页。
Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口。服务端必须同时校验同一 `installId`、未停用状态、恢复码摘要、未过期和未使用;成功后使用原 `deviceId` 写入新 Token 摘要并返回一次新 Token,清除恢复码摘要和有效期。旧 Token 与恢复码都立即失效,已分配的 pending 采集或采购任务保持原 `deviceId`,不创建替代设备记录。缺少或错误恢复码仍为 `DEVICE_INSTALL_ID_CONFLICT`;过期码为 `DEVICE_RECOVERY_EXPIRED`;停用设备为 `DEVICE_DISABLED`。
自 #223 起,新建 SYB 任务在保持 `mappedColor` / `mappedSize` 为空和首趟 `spec_probe` 不变的同时,把创建时与目标规格对应的 confirmed 商品映射冻结为仅供服务端决策的指导快照。服务端收到当次候选后按角色验证该快照:只有规范化后唯一对应当次候选时才复用,并固化当次候选原文;否则该角色继续执行确定性匹配,仍未解决才把该角色及其封闭候选交给 AI。已解决角色不得重复发送给 AI,最终颜色和尺码仍须逐字属于各自当次候选。任务决策快照通过 `roleSources` 记录每个角色的 `manual_mapping` / `exact_match` / `ai_match` 来源;任务级 `specSource` 使用现有枚举汇总,不新增 Agent 决策权限。
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Delivery-Issues
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
synchronized_at: 2026-09-04T11:30:28Z
<!-- gitea-wiki-mirror:end -->
# 当前 MVP 交付工单索引
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: OnePlus-Real-Device-Acceptance
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
synchronized_at: 2026-09-04T11:30:32Z
<!-- 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-05T04:13:06Z
synchronized_at: 2026-09-04T11:30:37Z
<!-- gitea-wiki-mirror:end -->
# PDD 商品详情采集规则迁移分析
+10 -2
View File
@@ -2,8 +2,8 @@
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-05T04:13:06Z
wiki_revision: df0fe874c7f3f3e9c031f2f793f8f6a0511ace25
synchronized_at: 2026-09-07T06:25:27Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
@@ -228,6 +228,14 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整
读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成
稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`;
网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。
`[必须,#235]` 当天跨页重复 ID 纳入上述最多 3 次列表快照尝试(含首次),
不增加另一层重试次数。发现跨页重复后丢弃本次列表,从预检总数和第一页重新开始,
使用全新 ID 集合;最后一次仍重复时直接失败,不得去重后按成功或降级数据保存。
已经完成的历史日期保持其已有结果,不重复拉取;历史日期重复不适用此恢复。
每页先检查非法 ID 和页内重复,再检查跨页重叠;同页同时存在页内重复和跨页重叠时
仍作为硬错误停止。原有总数漂移/短页的合法唯一列表降级保存条件保持不变。
重复诊断只记录日期、当天尝试序号、首次/当前页码与行号、start、pageSize、
expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
### 4.4 统一日期范围同步与覆盖游标
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
synchronized_at: 2026-09-04T11:30:08Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Home
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:06:45Z
synchronized_at: 2026-09-04T11:29:34Z
<!-- gitea-wiki-mirror:end -->
# GoAuto 文档中心
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-Template
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-Template.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
synchronized_at: 2026-09-04T11:30:41Z
<!-- gitea-wiki-mirror:end -->
# 部署文档模板
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-Archive-Template
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Task-Archive-Template.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-05T04:13:06Z
synchronized_at: 2026-09-04T11:30:46Z
<!-- gitea-wiki-mirror:end -->
> 本模板只用于用户明确要求的专项历史快照或读取既有归档,不属于标准任务闭环。单次任务的唯一事实来源是 Gitea 工单;不要为了完成普通任务创建本页面,也不要自动导出到 `docs/task/`。
-2
View File
@@ -136,8 +136,6 @@ func moduleKeyForAPI(path string) string {
return ModulePDDProducts
case strings.HasPrefix(path, "/api/admin/v1/shopee-products"):
return ModuleShopeeProducts
case strings.HasPrefix(path, "/api/admin/v1/shopee-spec-auto-match"):
return ModuleShopeeProducts
case strings.HasPrefix(path, "/api/admin/v1/syb-products/sync-runs"):
return ModuleSYBSyncRuns
case strings.HasPrefix(path, "/api/admin/v1/syb-products"):
-2
View File
@@ -48,8 +48,6 @@ 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},
+4 -6
View File
@@ -15,12 +15,10 @@ 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/shopee-spec-auto-match/runs": true,
"GET /api/admin/v1/shopee-spec-auto-match/runs/latest": 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,
}
for _, permission := range PurchaserAPIs() {
if denied[permission.Method+" "+permission.Path] {
+2 -96
View File
@@ -25,28 +25,12 @@ 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
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
DB *gorm.DB
HTTPClient *http.Client
}
func NewService(db *gorm.DB) *Service {
@@ -195,58 +179,6 @@ 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 {
@@ -362,22 +294,6 @@ 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 == "" {
@@ -400,16 +316,6 @@ 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,7 +2,6 @@ package aimatching
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
@@ -136,61 +135,3 @@ 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)
}
}
}
+1 -28
View File
@@ -10,9 +10,6 @@ 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
@@ -98,26 +95,18 @@ 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 {
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)})
if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
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 建议响应无效")
}
@@ -156,22 +145,6 @@ 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,9 +6,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-admin/app/goauto/models"
@@ -136,93 +134,3 @@ 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) }
+6 -23
View File
@@ -29,35 +29,18 @@ 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"`
Matched 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 {
Color string `json:"color,omitempty"`
Size string `json:"size,omitempty"`
} `json:"matched"`
+1 -1
View File
@@ -18,7 +18,7 @@ func TestParseBuiltAgentAPKWhenAvailable(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if metadata.VersionCode != 49 || metadata.VersionName != "0.9.36" {
if metadata.VersionCode != 54 || metadata.VersionName != "0.9.41" {
t.Fatalf("metadata=%+v", metadata)
}
}
+21 -1
View File
@@ -48,7 +48,7 @@ func (handler Handler) Register(context *gin.Context) {
writeError(context, internalError(err))
return
}
response, err := NewService(db).Register(context.Request.Context(), request, bearerToken(context.GetHeader("Authorization")))
response, err := NewService(db).Register(context.Request.Context(), request, bearerToken(context.GetHeader("Authorization")), strings.TrimSpace(context.GetHeader("X-GoAuto-Device-Recovery-Code")))
if err != nil {
writeError(context, err)
return
@@ -84,6 +84,26 @@ func (handler Handler) RevokeToken(context *gin.Context) {
handler.adminAction(context, (*Service).RevokeToken)
}
func (handler Handler) ResetIdentity(context *gin.Context) {
deviceID, err := strconv.ParseUint(context.Param("deviceId"), 10, 64)
if err != nil || deviceID == 0 {
writeError(context, invalidRequest("deviceId 无效"))
return
}
db, err := handler.database(context)
if err != nil {
writeError(context, internalError(err))
return
}
response, err := NewService(db).ResetIdentity(context.Request.Context(), deviceID)
if err != nil {
writeError(context, err)
return
}
context.Header("Cache-Control", "no-store")
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": response})
}
func (handler Handler) adminAction(context *gin.Context, action func(*Service, stdcontext.Context, uint64) error) {
deviceID, err := strconv.ParseUint(context.Param("deviceId"), 10, 64)
if err != nil || deviceID == 0 {
+1
View File
@@ -22,5 +22,6 @@ func InitRouter(engine *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) {
admin := engine.Group("/api/admin/v1/devices").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
admin.GET("", handler.List)
admin.POST("/:deviceId/disable", middleware.RequireRoleKey("admin"), handler.Disable)
admin.POST("/:deviceId/identity-reset", middleware.RequireRoleKey("admin"), handler.ResetIdentity)
admin.POST("/:deviceId/token/revoke", middleware.RequireRoleKey("admin"), handler.RevokeToken)
}
+77 -4
View File
@@ -28,8 +28,12 @@ const (
CodeDeviceTaskMismatch = "DEVICE_TASK_MISMATCH"
CodeDeviceNotFound = "DEVICE_NOT_FOUND"
CodeInternal = "INTERNAL_ERROR"
CodeRecoveryInvalid = "DEVICE_RECOVERY_INVALID"
CodeRecoveryExpired = "DEVICE_RECOVERY_EXPIRED"
)
const deviceRecoveryLifetime = 10 * time.Minute
type ServiceError struct {
Code string
Message string
@@ -67,6 +71,11 @@ type RegisterResponse struct {
Replayed bool `json:"replayed,omitempty"`
}
type ResetIdentityResponse struct {
DeviceID uint64 `json:"deviceId"`
ExpiresAt time.Time `json:"expiresAt"`
}
type Service struct {
DB *gorm.DB
Now func() time.Time
@@ -108,6 +117,10 @@ func tokenMatches(token, digest string) bool {
return subtle.ConstantTimeCompare(got[:], want) == 1
}
func digestMatches(value string, digest *string) bool {
return digest != nil && tokenMatches(value, *digest)
}
// Authenticate returns the active device represented by a bearer token.
// Agent feature packages use this method so token verification stays in one
// place and raw tokens never leave request memory.
@@ -131,7 +144,11 @@ func (service *Service) Authenticate(ctx context.Context, token string) (models.
return result, nil
}
func (service *Service) Register(ctx context.Context, request RegisterRequest, presentedToken string) (RegisterResponse, error) {
func (service *Service) Register(ctx context.Context, request RegisterRequest, presentedToken string, recoveryCodes ...string) (RegisterResponse, error) {
recoveryCode := ""
if len(recoveryCodes) > 0 {
recoveryCode = recoveryCodes[0]
}
request = normalizeRegisterRequest(request)
if err := validateRegisterRequest(request); err != nil {
return RegisterResponse{}, err
@@ -162,9 +179,15 @@ func (service *Service) Register(ctx context.Context, request RegisterRequest, p
if existing.Status == models.DeviceStatusDisabled {
return RegisterResponse{}, &ServiceError{Code: CodeDeviceDisabled, Message: "设备已停用", Retryable: false}
}
if !tokenMatches(presentedToken, existing.TokenDigest) {
return RegisterResponse{}, &ServiceError{
Code: CodeInstallIDConflict, Message: "installId 已注册,需要该设备的有效 Token", Retryable: false,
usingRecovery := !tokenMatches(presentedToken, existing.TokenDigest)
if usingRecovery {
validCode := recoveryCode != "" && digestMatches(recoveryCode, existing.RecoveryCodeDigest)
autoRecovery := recoveryCode == "" && existing.RecoveryCodeDigest == nil && existing.RecoveryExpiresAt != nil && existing.RecoveryUsedAt == nil
if !validCode && !autoRecovery {
return RegisterResponse{}, &ServiceError{Code: CodeInstallIDConflict, Message: "installId 已注册,需要该设备的有效 Token", Retryable: false}
}
if existing.RecoveryExpiresAt == nil || !service.Now().Before(*existing.RecoveryExpiresAt) {
return RegisterResponse{}, &ServiceError{Code: CodeRecoveryExpired, Message: "设备身份重置窗口已过期,请在后台重新操作", Retryable: false}
}
}
updates := map[string]any{
@@ -175,6 +198,20 @@ func (service *Service) Register(ctx context.Context, request RegisterRequest, p
if request.Capabilities != nil {
updates["capabilities_json"] = encodeCapabilities(request.Capabilities)
}
if usingRecovery {
newToken, generateErr := service.GenerateToken()
if generateErr != nil {
return RegisterResponse{}, internalError(generateErr)
}
now := service.Now()
updates["token_digest"] = tokenDigest(newToken)
updates["token_issued_at"] = now
updates["recovery_code_digest"] = nil
updates["recovery_expires_at"] = nil
updates["recovery_used_at"] = now
updates["status"] = models.DeviceStatusOnline
response.DeviceToken = newToken
}
result := db.Model(&models.AgentDevice{}).
Where("id = ? AND token_digest = ? AND token_revoked_at IS NULL", existing.ID, existing.TokenDigest).
Updates(updates)
@@ -222,6 +259,42 @@ func (service *Service) Register(ctx context.Context, request RegisterRequest, p
return response, nil
}
// ResetIdentity invalidates the current token and opens a short-lived automatic
// re-registration window for the same installId. It retains the device row and
// task bindings, so no recovery code needs to leave the Admin workflow.
func (service *Service) ResetIdentity(ctx context.Context, deviceID uint64) (ResetIdentityResponse, error) {
if service.DB == nil {
return ResetIdentityResponse{}, internalError(errors.New("database is nil"))
}
now := service.Now()
expiresAt := now.Add(deviceRecoveryLifetime)
response := ResetIdentityResponse{DeviceID: deviceID, ExpiresAt: expiresAt}
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var device models.AgentDevice
if err := tx.First(&device, deviceID).Error; errors.Is(err, gorm.ErrRecordNotFound) {
return &ServiceError{Code: CodeDeviceNotFound, Message: "设备不存在", Retryable: false}
} else if err != nil {
return internalError(err)
}
if device.Status == models.DeviceStatusDisabled {
return &ServiceError{Code: CodeDeviceDisabled, Message: "设备已停用", Retryable: false}
}
invalidatedToken, generateErr := service.GenerateToken()
if generateErr != nil {
return internalError(generateErr)
}
return tx.Model(&models.AgentDevice{}).Where("id = ?", deviceID).Updates(map[string]any{
"token_digest": tokenDigest(invalidatedToken), "token_issued_at": now,
"recovery_code_digest": nil, "recovery_expires_at": expiresAt,
"recovery_used_at": nil, "status": models.DeviceStatusOffline,
}).Error
})
if err != nil {
return ResetIdentityResponse{}, err
}
return response, nil
}
func (service *Service) Disable(ctx context.Context, deviceID uint64) error {
return service.deactivate(ctx, deviceID, false)
}
+66
View File
@@ -185,6 +185,72 @@ func TestDisableAndRevokePreventFurtherAuthentication(t *testing.T) {
}
}
func TestResetIdentityRecoversSameDeviceAndRotatesToken(t *testing.T) {
db := openTestDatabase(t)
service := newTestService(t, db)
tokens := []string{"original-token", "invalidated-token", "replacement-token"}
service.GenerateToken = func() (string, error) {
if len(tokens) == 0 {
t.Fatal("unexpected token generation")
}
value := tokens[0]
tokens = tokens[1:]
return value, nil
}
request := validRegisterRequest()
registered, err := service.Register(context.Background(), request, "")
if err != nil {
t.Fatalf("first registration: %v", err)
}
recovery, err := service.ResetIdentity(context.Background(), registered.DeviceID)
if err != nil {
t.Fatalf("reset identity: %v", err)
}
if !recovery.ExpiresAt.After(service.Now()) {
t.Fatalf("unexpected recovery response: %+v", recovery)
}
request.RequestID = uuid.NewString()
recovered, err := service.Register(context.Background(), request, "original-token")
if err != nil {
t.Fatalf("recover registration: %v", err)
}
if recovered.DeviceID != registered.DeviceID || recovered.DeviceToken != "replacement-token" {
t.Fatalf("unexpected recovery result: %+v", recovered)
}
var stored models.AgentDevice
if err := db.First(&stored, registered.DeviceID).Error; err != nil {
t.Fatalf("load recovered device: %v", err)
}
if stored.InstallID != strings.ToLower(request.InstallID) || stored.Status != models.DeviceStatusOnline || stored.RecoveryCodeDigest != nil || stored.RecoveryUsedAt == nil {
t.Fatalf("recovery did not retain device safely: %+v", stored)
}
request.RequestID = uuid.NewString()
if _, err := service.Register(context.Background(), request, "original-token"); serviceErrorCode(t, err) != CodeInstallIDConflict {
t.Fatal("automatic recovery window was reusable")
}
}
func TestResetIdentityRejectsExpiredRecoveryCode(t *testing.T) {
db := openTestDatabase(t)
service := newTestService(t, db)
tokens := []string{"original-token", "invalidated-token"}
service.GenerateToken = func() (string, error) { value := tokens[0]; tokens = tokens[1:]; return value, nil }
request := validRegisterRequest()
registered, err := service.Register(context.Background(), request, "")
if err != nil {
t.Fatal(err)
}
recovery, err := service.ResetIdentity(context.Background(), registered.DeviceID)
if err != nil {
t.Fatal(err)
}
service.Now = func() time.Time { return recovery.ExpiresAt.Add(time.Second) }
request.RequestID = uuid.NewString()
if _, err := service.Register(context.Background(), request, ""); serviceErrorCode(t, err) != CodeRecoveryExpired {
t.Fatalf("expected expired recovery code, got %v", err)
}
}
func TestRegistrationValidationRejectsInvalidUUIDs(t *testing.T) {
service := newTestService(t, openTestDatabase(t))
request := validRegisterRequest()
-4
View File
@@ -34,11 +34,7 @@ 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{},
+3 -9
View File
@@ -39,6 +39,9 @@ type AgentDevice struct {
TokenDigest string `json:"-" gorm:"size:64;not null;uniqueIndex:ux_agent_device_token_digest"`
TokenIssuedAt time.Time `json:"tokenIssuedAt" gorm:"not null"`
TokenRevokedAt *time.Time `json:"tokenRevokedAt" gorm:"index"`
RecoveryCodeDigest *string `json:"-" gorm:"size:64;index"`
RecoveryExpiresAt *time.Time `json:"-" gorm:"index"`
RecoveryUsedAt *time.Time `json:"-"`
LastRegisterRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_agent_device_last_register_request_id"`
LastHeartbeatRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_agent_device_last_heartbeat_request_id"`
LastHeartbeatAt *time.Time `json:"lastHeartbeatAt" gorm:"index"`
@@ -533,15 +536,6 @@ 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: "适用于解析规则更新后批量重跑,只读取已保存的原始
@@ -1,56 +0,0 @@
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"
}
@@ -1,48 +0,0 @@
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" }
+13 -6
View File
@@ -2,7 +2,6 @@ package purchase
import (
"context"
"strings"
"testing"
"time"
@@ -72,6 +71,11 @@ 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 != "" {
@@ -93,6 +97,9 @@ 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 {
@@ -158,7 +165,7 @@ func TestMatchedReplacementExposesContinueAndReusesAgentRetry(t *testing.T) {
}
}
func TestMatchedReplacementRejectsClearedMappingWithoutCallingAI(t *testing.T) {
func TestMatchedReplacementAllowsRetryWithoutPersistedMappingAndDoesNotCallAI(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f.pdd.ID)
@@ -172,11 +179,11 @@ func TestMatchedReplacementRejectsClearedMappingWithoutCallingAI(t *testing.T) {
service.Matcher = matcher
detail, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token)
if err != nil || detail.ContinuePurchaseEligible || !strings.Contains(detail.ContinuePurchaseDisabledReason, "规格匹配已失效") || matcher.calls != 0 {
if err != nil || !detail.ContinuePurchaseEligible || detail.ContinuePurchaseDisabledReason != "" || matcher.calls != 0 {
t.Fatalf("cleared mapping qualification mismatch: detail=%+v calls=%d error=%v", detail, 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)
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)
}
}
@@ -32,41 +32,10 @@ type skuCombinationRow struct {
}
func sybSpecsTrusted(syb models.SYBProduct) bool {
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 {
if syb.ParseStatus == models.SYBParseStatusFailed {
return false
}
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
return strings.TrimSpace(syb.TargetColor) != "" || strings.TrimSpace(syb.TargetSize) != ""
}
func (s *Service) loadLatestSKUCombinations(ctx context.Context, pddIDs []uint64, dataset *batchPreviewDataset) error {
@@ -174,7 +143,10 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
if request.TargetSize != "" {
size = matched.MappedSize
}
if validSKUCombination(dataset.skuCombinationsByPDD[pdd.ID], syb.TargetColor, syb.TargetSize, color, size) {
// 人工录入或外部导入的 PDD 档案可以没有采集任务 SKU 证据。没有
// 证据时只确认候选值;一旦有证据,仍必须命中同一个可售组合。
combinations := dataset.skuCombinationsByPDD[pdd.ID]
if len(combinations) == 0 || validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, color, size) {
return aiMatchQualification{Eligible: true, Request: request, MappedColor: mappedColor, MappedSize: mappedSize, Deterministic: &matched}
}
}
+5 -78
View File
@@ -105,8 +105,7 @@ 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(ctx, id, dataset, false, guard)
item.enforcePersistedMatch(id, dataset)
item := s.previewFromDataset(id, dataset, guard)
item.applyCollectionEligibility(id, dataset)
qualification := aiMatchQualificationForDataset(id, dataset)
item.AIMatchEligible = qualification.Eligible
@@ -331,22 +330,6 @@ 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 {
@@ -354,8 +337,7 @@ func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
return item
}
item := s.previewFromDataset(ctx, id, dataset, false, guard)
item.enforcePersistedMatch(id, dataset)
item := s.previewFromDataset(id, dataset, guard)
return item
}
@@ -379,7 +361,7 @@ func taskPointerValue(value *uint64) uint64 {
return *value
}
func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool, guard purchasecontract.PriceLimits) BatchPreviewItem {
func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, guard purchasecontract.PriceLimits) BatchPreviewItem {
item := BatchPreviewItem{SYBProductID: id}
syb, found := dataset.sybByID[id]
if !found {
@@ -406,10 +388,6 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee"
return item
}
if !aiConfirmedSpecsCurrent(syb, shopee) {
item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "AI 解析依据已变化,请等待重新解析或人工修正", "reparse"
return item
}
if shopee.PDDProductID == nil {
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_LINKED", "尚未关联 PDD 商品,请先关联", processActionOpenPDDLink
return item
@@ -427,26 +405,15 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
}
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
item.MappedColor, item.MappedSize = mappedColor, mappedSize
candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
candidates, _ := 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 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 {
if matched, ok := aimatching.DeterministicMatch(request); ok {
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
}
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
@@ -480,46 +447,6 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
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,11 +50,6 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
if err != nil {
return BatchSpecMatchResponse{}, internal(err)
}
settings, err := aimatching.NewService(s.DB).Settings(ctx)
if err != nil {
return BatchSpecMatchResponse{}, internal(err)
}
response := BatchSpecMatchResponse{Items: make([]BatchSpecMatchItem, 0, len(ids))}
for _, id := range ids {
item := BatchSpecMatchItem{SYBProductID: id, Status: BatchSpecMatchFailed}
@@ -87,9 +82,11 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
}
}
item.Source, item.Confidence = matched.Source, matched.Decision.Confidence
autoConfirm := qualification.Deterministic != nil || (matched.Source == aimatching.SourceAI && matched.Decision.Confidence != nil && *matched.Decision.Confidence >= settings.AutoConfirmMinConfidence && strings.TrimSpace(matched.Decision.Reason) != "")
// #200:在 SYB 批量入口,AI 只要返回了可保存的规格结果,就由后续的
// 候选与可售 SKU 组合校验决定是否放行;置信度仅保留为审计信息。
autoConfirm := qualification.Deterministic != nil || (matched.Source == aimatching.SourceAI && strings.TrimSpace(matched.Decision.Reason) != "")
if !autoConfirm {
item.Status, item.Reason = BatchSpecMatchPending, "匹配结果未达到自动确认阈值,请人工确认"
item.Status, item.Reason = BatchSpecMatchPending, "AI 未返回可用规格结果,请人工确认"
if strings.TrimSpace(matched.Decision.Reason) != "" {
item.Reason += ":" + strings.TrimSpace(matched.Decision.Reason)
}
@@ -104,7 +101,8 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
if request.TargetSize != "" {
mappedSize = matched.MappedSize
}
if !mappingTargetsValid(specCandidates{Colors: request.Colors, Sizes: request.Sizes}, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) || !validSKUCombination(dataset.skuCombinationsByPDD[pdd.ID], syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
combinations := dataset.skuCombinationsByPDD[pdd.ID]
if !mappingTargetsValid(specCandidates{Colors: request.Colors, Sizes: request.Sizes}, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) || (len(combinations) > 0 && !validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize)) {
item.Status, item.Reason = BatchSpecMatchPending, "AI 结果不是当前可售的 PDD 规格组合,请人工确认"
response.PendingCount++
response.Items = append(response.Items, item)
@@ -117,7 +115,7 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
response.Items = append(response.Items, item)
continue
}
if _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopee.ID, uuid.NewString(), settings.AutoConfirmMinConfidence, writes); err != nil {
if _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopee.ID, uuid.NewString(), writes); err != nil {
item.Reason = "规格映射保存失败,请刷新后重试"
response.FailedCount++
response.Items = append(response.Items, item)
@@ -9,7 +9,6 @@ import (
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -137,18 +136,14 @@ func exactBatchSpecFixture(t *testing.T) (*Service, fixture) {
return testService(db), f
}
func TestBatchSpecMatchPersistsExactMatchBeforePurchaseCreation(t *testing.T) {
func TestBatchSpecMatchPersistsExactMatchButPurchaseDoesNotWaitForIt(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 != 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)
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)
}
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
@@ -175,10 +170,10 @@ func TestBatchSpecMatchPersistsExactMatchBeforePurchaseCreation(t *testing.T) {
}
}
func TestManuallyConfirmedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T) {
func TestExtractedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T) {
service, f := exactBatchSpecFixture(t)
if err := service.DB.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
"parse_status": models.SYBParseStatusUncertain, "manually_confirmed": true,
"parse_status": models.SYBParseStatusUncertain, "manually_confirmed": false,
}).Error; err != nil {
t.Fatal(err)
}
@@ -186,16 +181,16 @@ func TestManuallyConfirmedUncertainSpecsCanBeMatchedBeforePurchase(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 != ProcessStageColorMapping {
t.Fatalf("manually confirmed specs did not enter matching: %+v err=%v", before, err)
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)
}
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil || matched.AutoConfirmedCount != 1 || matcher.calls != 0 {
t.Fatalf("manual correction did not allow exact match: %+v calls=%d err=%v", matched, matcher.calls, err)
t.Fatalf("extracted uncertain specs did not allow exact match: %+v calls=%d err=%v", matched, matcher.calls, err)
}
after, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil || !after.Items[0].Eligible || after.Items[0].ProcessStage != ProcessStagePurchaseReady {
t.Fatalf("saved mapping did not unlock manual correction: %+v err=%v", after, err)
t.Fatalf("saved mapping did not unlock extracted uncertain specs: %+v err=%v", after, err)
}
}
@@ -236,7 +231,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 != ProcessStageColorMapping {
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || !item.Eligible || item.ProcessStage != ProcessStagePurchaseReady {
t.Fatalf("explicit AI eligibility mismatch: %+v", item)
}
}
@@ -265,21 +260,38 @@ func TestBatchPreviewAllowsUncertainParseAndMissingSKUCombination(t *testing.T)
}
}
func TestBatchSpecMatchLeavesLowConfidenceForManualHandling(t *testing.T) {
func TestBatchSpecMatchAndPreviewAllowMissingSKUCombinationEvidence(t *testing.T) {
service, f := unresolvedBatchSpecFixture(t)
if err := service.DB.Where("pdd_product_id = ?", f.pdd.ID).Delete(&models.CollectionTask{}).Error; err != nil {
t.Fatal(err)
}
service.Matcher = &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.6)}}
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil || matched.AutoConfirmedCount != 1 || matched.PendingCount != 0 {
t.Fatalf("AI result without SKU evidence was not confirmed: %+v err=%v", matched, err)
}
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil || len(preview.Items) != 1 || !preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStagePurchaseReady {
t.Fatalf("missing SKU evidence unexpectedly blocked purchase: %+v err=%v", preview, err)
}
}
func TestBatchSpecMatchAutoConfirmsReturnedAIMatchRegardlessOfConfidence(t *testing.T) {
service, f := unresolvedBatchSpecFixture(t)
service.Matcher = &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.6)}}
response, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil || response.PendingCount != 1 || response.AutoConfirmedCount != 0 {
t.Fatalf("unexpected low-confidence result: %+v err=%v", response, err)
if err != nil || response.PendingCount != 0 || response.AutoConfirmedCount != 1 {
t.Fatalf("low-confidence AI result was not auto-confirmed: %+v err=%v", response, err)
}
mapping := savedColorMapping(t, service, f.shopee.ID)
if mapping == nil || mapping.PDDValue != "旧白色" || mapping.Status != shopeeproduct.MappingStatusConfirmed {
t.Fatalf("low-confidence result changed the saved mapping: %+v", mapping)
if mapping == nil || mapping.PDDValue != "米白色" || mapping.Status != shopeeproduct.MappingStatusConfirmed || mapping.Confidence == nil || *mapping.Confidence != 0.6 {
t.Fatalf("AI result was not saved as confirmed: %+v", mapping)
}
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil || preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStageColorMapping {
t.Fatalf("pending mapping unexpectedly became purchase-ready: %+v err=%v", preview, err)
if err != nil || !preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStagePurchaseReady {
t.Fatalf("saved AI result did not become purchase-ready: %+v err=%v", preview, err)
}
}
+35 -20
View File
@@ -44,25 +44,24 @@ func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) {
}
}
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")
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},
}
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")
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)
}
})
}
}
@@ -289,8 +288,24 @@ 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.Items[0].ReasonCode != CodeMappingRequired {
t.Fatalf("unresolved rows must not be reported as purchase-ready: %+v", response)
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)
}
}
+43 -20
View File
@@ -33,6 +33,19 @@ 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 {
@@ -186,17 +199,14 @@ 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 := models.PurchaseAttemptPhasePurchase
if t.SpecSource == "unresolved" {
phase = models.PurchaseAttemptPhaseSpecProbe
}
phase := purchaseAttemptPhase(t)
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 {
if a.DeviceID == nil || *a.DeviceID != d.ID || a.RuleSnapshotHash != ruleSnapshotHash || a.Phase != phase {
failureCode := CodeStateConflict
message := "采购规则快照校验失败,请重新重试任务"
message := "采购任务执行快照校验失败,请重新创建任务"
a.Status = models.PurchaseAttemptStatusFailed
a.ErrorCode = &failureCode
a.ErrorMessage = &message
@@ -266,6 +276,9 @@ 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 无效")
}
@@ -536,19 +549,29 @@ 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 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)
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
}
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 {
@@ -567,7 +590,7 @@ func purchaseMatchReason(err error) string {
return "AI 规格匹配暂时不可用,请稍后重新创建采购任务"
}
}
return "没有找到可采购的 PDD 颜色或尺码"
return "已采集到当前规格,但未能确定颜色或尺码映射"
}
type probedSpecCandidates struct {
@@ -649,7 +672,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 IN ? AND lease_expires_at > ?))", taskID, deviceID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).Count(&count).Error; e != nil {
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 {
return internal(e)
}
if count > 0 {
@@ -668,7 +691,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 IN ? AND lease_expires_at > ?))", taskID, *accountID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).Count(&count).Error; e != nil {
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 {
return internal(e)
}
if count > 0 {
@@ -40,6 +40,19 @@ 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
}
@@ -0,0 +1,207 @@
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
}
+2 -3
View File
@@ -69,8 +69,7 @@ func (s *Service) ProcessStages(ctx context.Context, ids []uint64) (map[uint64]P
return nil, err
}
for _, id := range ids {
preview := s.previewFromDataset(ctx, id, dataset, false, guard)
preview.enforcePersistedMatch(id, dataset)
preview := s.previewFromDataset(id, dataset, guard)
qualification := aiMatchQualificationForDataset(id, dataset)
preview.AIMatchEligible = qualification.Eligible
preview.AIMatchDisabledReason = qualification.DisabledReason
@@ -137,7 +136,7 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
if !ok {
return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh")
}
if syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed {
if syb.ParseStatus == models.SYBParseStatusFailed {
return stage(ProcessStageManualAction, "解析失败,请先处理", "reparse")
}
if syb.ShopeeProductID == nil {
+16 -4
View File
@@ -87,6 +87,9 @@ 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
@@ -130,10 +133,7 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
return conflictOrInternal(err)
}
phase := models.PurchaseAttemptPhasePurchase
if task.SpecSource == "unresolved" {
phase = models.PurchaseAttemptPhaseSpecProbe
}
phase := purchaseAttemptPhase(task)
// 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,6 +155,18 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
return response, err
}
func purchaseAttemptPhase(task models.PurchaseTask) string {
if task.SpecSource == "unresolved" || !hasCompletePurchaseSpec(task) {
return models.PurchaseAttemptPhaseSpecProbe
}
return models.PurchaseAttemptPhasePurchase
}
func hasCompletePurchaseSpec(task models.PurchaseTask) bool {
return (strings.TrimSpace(task.TargetColorSnapshot) == "" || strings.TrimSpace(task.MappedColorSnapshot) != "") &&
(strings.TrimSpace(task.TargetSizeSnapshot) == "" || strings.TrimSpace(task.MappedSizeSnapshot) != "")
}
func validatePurchaseResetState(tx *gorm.DB, task models.PurchaseTask) error {
switch task.Status {
case models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderCreated, models.PurchaseTaskStatusOrderResultUnknown:
+30
View File
@@ -108,6 +108,36 @@ 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)
+14 -15
View File
@@ -218,7 +218,12 @@ func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask
return decision
}
preview := s.previewOne(ctx, *task.SYBProductID)
_, _, 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))
if !preview.Eligible {
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
@@ -230,10 +235,9 @@ func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask
}
// retryQueryEligibility is deliberately limited to local task and device
// 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.
// 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.
func (s *Service) retryQueryEligibility(ctx context.Context, task models.PurchaseTask, checkDeviceBusy bool) retryDecision {
decision := s.retryStateEligibility(ctx, task)
if !decision.Allowed {
@@ -263,25 +267,20 @@ 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(ctx, *task.SYBProductID, dataset, false, guard)
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
shopee, found := dataset.shopeeByID[*syb.ShopeeProductID]
if !found || shopee.PDDProductID == nil {
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
pdd, found := dataset.pddByID[*shopee.PDDProductID]
_, found = dataset.pddByID[*shopee.PDDProductID]
if !found {
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
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)
preview := s.previewFromDataset(*task.SYBProductID, dataset, guard)
if !preview.Eligible {
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
}
+3 -3
View File
@@ -223,7 +223,7 @@ func TestAdminListRetryEligibilityRejectsBusyOriginalDevice(t *testing.T) {
}
}
func TestAdminQueriesDoNotResolveSpecsButBatchRetryStillDoes(t *testing.T) {
func TestAdminQueriesAndBatchRetryDoNotResolveArchivedSpecs(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
service := testService(db)
@@ -253,8 +253,8 @@ func TestAdminQueriesDoNotResolveSpecsButBatchRetryStillDoes(t *testing.T) {
if err != nil {
t.Fatal(err)
}
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)
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)
}
}
+13 -75
View File
@@ -54,11 +54,6 @@ 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"`
@@ -96,51 +91,6 @@ 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
@@ -194,10 +144,6 @@ 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 {
@@ -334,22 +280,25 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
if currency == "" {
currency = shopee.Currency
}
mappedColor, mappedSize, specSource = confirmedMappings(shopee.SpecsJSON, targetColor, targetSize)
// 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
}
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" {
@@ -365,8 +314,6 @@ 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"
@@ -404,17 +351,8 @@ 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
}
+321 -25
View File
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/device"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
@@ -29,6 +30,19 @@ 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(), "/", "_"))
@@ -107,6 +121,30 @@ 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) {
@@ -224,9 +262,23 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T)
if err != nil {
t.Fatal(err)
}
if start.Phase != models.PurchaseAttemptPhasePurchase {
if start.Phase != models.PurchaseAttemptPhaseSpecProbe {
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)
}
@@ -243,7 +295,7 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T)
}
var count int64
db.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", task.ID).Count(&count)
if count != 1 || first.Status != models.PurchaseTaskStatusOrderCreated {
if count != 2 || first.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatalf("duplicate attempt or wrong status: %d %+v", count, first)
}
var stored models.PurchaseTask
@@ -344,6 +396,198 @@ 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)
@@ -428,7 +672,7 @@ func TestSpecResolutionAllowedMatrix(t *testing.T) {
}
}
func TestCreateUsesDeterministicTraditionalAndWeightMatching(t *testing.T) {
func TestLiveProbeUsesDeterministicTraditionalAndWeightMatching(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 {
@@ -437,12 +681,26 @@ func TestCreateUsesDeterministicTraditionalAndWeightMatching(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)
}
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)
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)
}
if strings.Contains(task.SpecDecisionSnapshot, "apiKey") || !strings.Contains(task.SpecDecisionSnapshot, "exact_match") {
t.Fatalf("task decision snapshot is unsafe or incomplete: %s", task.SpecDecisionSnapshot)
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)
}
}
@@ -457,12 +715,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 != "exact_match" || task.MappedColorSnapshot != "黑色" || task.MappedSizeSnapshot != "XL" {
t.Fatalf("request-provided mapping must not override server match: %+v %v", task, err)
if err != nil || task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
t.Fatalf("request-provided mapping must not skip live probe: %+v %v", task, err)
}
}
func TestCreateQueuesUsableArchiveWhenDeterministicMatchFails(t *testing.T) {
func TestCreateDoesNotQueueArchiveMatchingBeforeLiveProbe(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 {
@@ -475,12 +733,12 @@ func TestCreateQueuesUsableArchiveWhenDeterministicMatchFails(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 != 1 {
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("work count=%d err=%v", count, err)
}
}
func TestCreateQueuesExternalMatchingWithoutCallingProvider(t *testing.T) {
func TestCreateDispatchesProbeInsteadOfExternalArchiveMatching(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 {
@@ -495,12 +753,48 @@ func TestCreateQueuesExternalMatchingWithoutCallingProvider(t *testing.T) {
if err != nil || task.ID == 0 || task.SpecSource != "unresolved" {
t.Fatalf("queued task=%+v err=%v", task, 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)
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)
}
if next, err := service.Next(context.Background(), f.token); err != nil || next != nil {
t.Fatalf("matching task dispatched: %+v %v", next, 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)
}
}
@@ -509,9 +803,10 @@ func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) {
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, _ := createLive(t, s, f)
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)
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)
}
actual := int64(1999)
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
@@ -527,7 +822,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).First(&attempt).Error; err != nil {
if err := db.Where("task_id = ?", task.ID).Order("attempt_number DESC").First(&attempt).Error; err != nil {
t.Fatal(err)
}
if attempt.ErrorCode == nil || *attempt.ErrorCode != CodeOrderTimeMissing || attempt.ErrorMessage == nil {
@@ -544,9 +839,10 @@ func TestOrderUnknownRejectsOrderFieldsAndUnapprovedFailureCode(t *testing.T) {
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, _ := createLive(t, s, f)
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)
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)
}
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
@@ -48,9 +48,6 @@ 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")
@@ -1,360 +0,0 @@
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)
}
@@ -1,86 +0,0 @@
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,13 +5,11 @@ 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"
)
@@ -118,20 +116,6 @@ 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,7 +7,8 @@ import (
// ResolvedMappingItem is one mapping produced by the narrowly scoped SYB
// batch-match entry point. Confirmed writes are restricted to auditable exact
// matches and high-confidence AI decisions; SetMapping remains pending-first.
// matches and AI decisions with a returned match reason; SetMapping remains
// pending-first.
type ResolvedMappingItem struct {
Dimension string
ValueName string
@@ -20,14 +21,11 @@ type ResolvedMappingItem struct {
// ApplyResolvedMappings atomically applies the color/size mappings needed by
// one SYB detail row. It is independent from SetMapping so #188's explicit
// high-confidence exception cannot change existing callers' pending semantics.
func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, requestID string, minimumConfidence float64, items []ResolvedMappingItem) (SaveResponse, error) {
// confirmed-match exception cannot change existing callers' pending semantics.
func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, requestID string, items []ResolvedMappingItem) (SaveResponse, error) {
if len(items) == 0 || len(items) > 2 {
return SaveResponse{}, invalidRequest("必须包含 1 至 2 个待写入规格映射")
}
if minimumConfidence < 0 || minimumConfidence > 1 {
return SaveResponse{}, invalidRequest("自动确认阈值无效")
}
seen := make(map[string]bool, len(items))
for _, item := range items {
key := strings.TrimSpace(item.Dimension) + "\x00" + strings.TrimSpace(item.ValueName)
@@ -41,9 +39,6 @@ func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, re
if item.Status != MappingStatusConfirmed || strings.TrimSpace(item.Reason) == "" {
return SaveResponse{}, invalidRequest("自动确认映射必须包含确认状态和匹配理由")
}
if item.Source == MappingSourceAIMatch && (item.Confidence == nil || *item.Confidence < minimumConfidence) {
return SaveResponse{}, invalidRequest("AI 自动确认必须达到置信度阈值")
}
if err := service.validatePDDMappingTarget(ctx, id, item.Dimension, item.ValueName, strings.TrimSpace(item.PDDValue)); err != nil {
return SaveResponse{}, err
}
@@ -1,7 +1,6 @@
package shopeeproduct
import (
"context"
"encoding/json"
"errors"
"io"
@@ -301,45 +300,6 @@ 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,9 +10,6 @@ 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)
@@ -1,489 +0,0 @@
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)
}
@@ -1,204 +0,0 @@
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)
}
}
+2 -33
View File
@@ -130,37 +130,13 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
result.Outcome = OutcomeCreated
case err == nil:
record.ID = existing.ID
// 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{
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", existing.ID).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,
}
// 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 {
}).Error; err != nil {
return err
}
result.Outcome = OutcomeUpdated
@@ -185,13 +161,6 @@ 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
+13 -4
View File
@@ -85,7 +85,8 @@ 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),
OperatorID: actor.ID, OperatorName: strings.TrimSpace(actor.Name),
ShopFilterSnapshot: cloneShopSnapshot(enabled),
OperatorID: actor.ID, OperatorName: strings.TrimSpace(actor.Name),
})
if err != nil {
importGate.Unlock()
@@ -97,7 +98,7 @@ func StartImport(ctx context.Context, db *gorm.DB, request ImportRequest, actor
importGate.running = true
importGate.Unlock()
go runImport(db, run.ID, request, settings)
go runImport(db, run.ID, request, settings, cloneShopSnapshot(enabled))
return StartImportResult{RunID: run.ID, Status: run.Status}, nil
}
@@ -145,7 +146,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) {
func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config.SYB, allowed map[string]string) {
defer func() { importGate.Lock(); importGate.running = false; importGate.Unlock() }()
ctx, cancel := context.WithTimeout(context.Background(), importTimeout)
defer cancel()
@@ -156,7 +157,7 @@ func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config
OcrURL: settings.OcrURL, OcrMaxAttempts: settings.OcrMaxAttempts,
})
if err == nil {
report, err = SyncWithProgress(ctx, db, client, SyncConfig{PageSize: settings.PageSize, MaxMatches: settings.MaxMatches}, request.DateFrom, request.DateTo,
report, err = SyncWithShopSnapshot(ctx, db, client, SyncConfig{PageSize: settings.PageSize, MaxMatches: settings.MaxMatches}, request.DateFrom, request.DateTo, allowed,
func(progress SyncProgress) error { return runs.UpdateProgress(ctx, runID, progress) })
}
status := SyncRunSucceeded
@@ -168,6 +169,14 @@ 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:
@@ -0,0 +1,101 @@
package sybimport
import (
"context"
"strings"
"testing"
"time"
)
func freezePaginationToday(t *testing.T) {
t.Helper()
previous := syncNow
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
t.Cleanup(func() { syncNow = previous })
}
func TestTodayCrossPageOverlapRestartsWithIndependentIDs(t *testing.T) {
freezePaginationToday(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 4}}
f.pageIDs = func(_ string, start, call int) []int64 {
if call == 2 {
return []int64{1001, 1002}
}
if call > 2 {
return []int64{int64(2000 + start), int64(2001 + start)}
}
return nil
}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 2, 4, 100)
if err != nil || len(rows) != 4 || f.listTotalCalls != 2 {
t.Fatalf("rows=%d totals=%d err=%v", len(rows), f.listTotalCalls, err)
}
if got := strings.Join(f.listCalls, ","); got != "2026-08-29:0,2026-08-29:2,2026-08-29:0,2026-08-29:2" {
t.Fatalf("did not restart at first page: %s", got)
}
for index, row := range rows {
if row.ID != int64(2000+index) {
t.Fatal("rows leaked from abandoned attempt")
}
}
}
func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testing.T) {
freezePaginationToday(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 2, "2026-08-29": 4}}
f.pageIDs = func(date string, start, _ int) []int64 {
if date == "2026-08-29" && start == 2 {
return []int64{1001, 1002}
}
return nil
}
db := newSyncTestDB(t)
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-28", "2026-08-29")
if err == nil {
t.Fatal("overlap was treated as successful sync")
}
for _, token := range []string{"连续 3 次", "跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
if !strings.Contains(err.Error(), token) {
t.Fatalf("missing %s in %v", token, err)
}
}
if strings.Contains(err.Error(), "1001") || strings.Contains(err.Error(), "已保存") {
t.Fatalf("unsafe diagnosis/degraded save: %v", err)
}
if len(f.listCalls) != 7 || f.detailCalls != 1 || report.OrderCount != 2 {
t.Fatalf("unexpected retry/import boundary: pages=%d details=%d orders=%d", len(f.listCalls), f.detailCalls, report.OrderCount)
}
var count int64
if e := db.Table("syb_product").Count(&count).Error; e != nil || count != 2 {
t.Fatalf("yesterday not preserved: count=%d err=%v", count, e)
}
}
func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
freezePaginationToday(t)
for _, tc := range []struct {
name, date, message string
page int
ids []int64
calls int
}{
{"same page", "2026-08-29", "同页重复", 0, []int64{1000, 1000}, 1},
{"mixed overlap and same page", "2026-08-29", "同页重复", 2, []int64{1001, 1001}, 2},
{"overlap and invalid ID", "2026-08-29", "非法 id", 2, []int64{1001, 0}, 2},
{"historical overlap", "2026-08-28", "跨页重复", 2, []int64{1001, 1002}, 2},
} {
t.Run(tc.name, func(t *testing.T) {
f := &fakeSYB{perDay: map[string]int{tc.date: 4}}
f.pageIDs = func(_ string, start, _ int) []int64 {
if start == tc.page {
return tc.ids
}
return nil
}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), tc.date, 2, 4, 100)
if rows != nil || err == nil || !strings.Contains(err.Error(), tc.message) || len(f.listCalls) != tc.calls || f.listTotalCalls != 0 {
t.Fatalf("unexpected recovery: rows=%d pages=%d totals=%d err=%v", len(rows), len(f.listCalls), f.listTotalCalls, err)
}
})
}
}
+26 -7
View File
@@ -13,6 +13,13 @@ 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
@@ -29,8 +36,8 @@ type ParseResult struct {
Note string
}
// Parse implements the #41 rule: split on the last comma into color/size,
// strip 【...】 annotations, and classify the result. It never guesses a
// Parse implements the #41/#216 rule: split on the last comma, 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.
//
@@ -41,9 +48,13 @@ 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, 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 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 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.
@@ -65,12 +76,20 @@ func Parse(raw string) ParseResult {
return ParseResult{Size: size, Status: models.SYBParseStatusUncertain, Note: "无分隔符,仅识别出单一规格值"}
}
colorPart := stripBrackets(trimmed[:lastComma])
sizePart := stripBrackets(trimmed[lastComma+1:])
firstPart := stripBrackets(trimmed[:lastComma])
secondPart := stripBrackets(trimmed[lastComma+1:])
colorPart, sizePart := firstPart, secondPart
if colorPart == "" || sizePart == "" {
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "按逗号拆分后有一侧为空"}
}
firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart)
if firstIsSize && secondIsSize {
return ParseResult{Color: firstPart, Size: secondPart, Status: models.SYBParseStatusUncertain, Note: "逗号两侧均具有尺码特征,无法安全识别颜色"}
}
if firstIsSize {
colorPart, sizePart = secondPart, firstPart
}
if ambiguousPattern.MatchString(colorPart) {
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "颜色部分含备注文本或多个分隔符,拆分结果可能不准确"}
}
+21
View File
@@ -19,6 +19,27 @@ 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 {
+3 -18
View File
@@ -28,7 +28,7 @@ type rawDetailSpec struct {
// result page's per-line feedback.
type ReparseOutcome struct {
SYBProductID uint64 `json:"sybProductId"`
Outcome string `json:"outcome"` // reparsed | skipped_manual | skipped_ai | unchanged
Outcome string `json:"outcome"` // reparsed | skipped_manual | unchanged
OldStatus string `json:"oldStatus"`
NewStatus string `json:"newStatus"`
}
@@ -36,7 +36,6 @@ type ReparseOutcome struct {
const (
ReparseOutcomeReparsed = "reparsed"
ReparseOutcomeSkippedManual = "skipped_manual"
ReparseOutcomeSkippedAI = "skipped_ai"
ReparseOutcomeUnchanged = "unchanged"
)
@@ -66,11 +65,6 @@ 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 {
@@ -82,11 +76,8 @@ 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, 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 {
record.ManuallyConfirmed = false
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Update("manually_confirmed", false).Error; err != nil {
return err
}
}
@@ -96,8 +87,6 @@ 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
@@ -151,12 +140,8 @@ func ManualCorrect(ctx context.Context, db *gorm.DB, sybProductID uint64, color,
return err
}
record.TargetColor, record.TargetSize, record.ManuallyConfirmed = color, size, true
record.AIConfirmed, record.AIConfidence, record.AIReason = false, nil, ""
record.AIConfirmedAt, record.AIInputFingerprint = nil, ""
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", sybProductID).Updates(map[string]any{
"target_color": color, "target_size": size, "manually_confirmed": true,
"ai_confirmed": false, "ai_confidence": nil, "ai_reason": "",
"ai_confirmed_at": nil, "ai_input_fingerprint": "",
}).Error; err != nil {
return err
}
-106
View File
@@ -60,35 +60,6 @@ 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)
@@ -180,83 +151,6 @@ func TestManualCorrectMergesIntoArchiveLikeASuccessfulParse(t *testing.T) {
}
}
func TestManualCorrectSupersedesAIConfirmation(t *testing.T) {
db := openTestDB(t)
applied, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailB())
if err != nil {
t.Fatal(err)
}
confidence := 0.96
if err := db.Model(&models.SYBProduct{}).Where("id = ?", applied.SYBProduct.ID).Updates(map[string]any{
"ai_confirmed": true, "ai_confidence": confidence, "ai_reason": "旧 AI 结果", "ai_input_fingerprint": strings.Repeat("a", 64),
}).Error; err != nil {
t.Fatal(err)
}
corrected, err := sybimport.ManualCorrect(context.Background(), db, applied.SYBProduct.ID, "人工颜色", "人工尺码")
if err != nil {
t.Fatal(err)
}
if !corrected.ManuallyConfirmed || corrected.AIConfirmed || corrected.AIConfidence != nil || corrected.AIReason != "" || corrected.AIInputFingerprint != "" {
t.Fatalf("human correction did not supersede AI state: %+v", corrected)
}
}
func TestReimportPreservesIdenticalAIInputAndInvalidatesChangedSource(t *testing.T) {
db := openTestDB(t)
order, detail := realOrder(), realDetailB()
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
confidence := 0.95
if err := db.Model(&models.SYBProduct{}).Where("id = ?", first.SYBProduct.ID).Updates(map[string]any{
"target_color": "AI颜色", "target_size": "AI尺码", "ai_confirmed": true,
"ai_confidence": confidence, "ai_reason": "已确认", "ai_input_fingerprint": strings.Repeat("b", 64),
}).Error; err != nil {
t.Fatal(err)
}
same, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if !same.SYBProduct.AIConfirmed {
t.Fatal("identical re-import must preserve AI confirmation")
}
if same.SYBProduct.TargetColor != "AI颜色" || same.SYBProduct.TargetSize != "AI尺码" {
t.Fatal("identical re-import must preserve AI-confirmed target values")
}
detail.ProductSpec += " 新备注"
detail.Raw = []byte(`{"productSpec":"changed"}`)
changed, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if changed.SYBProduct.AIConfirmed || changed.SYBProduct.AIConfidence != nil || changed.SYBProduct.AIReason != "" {
t.Fatalf("changed source retained stale AI state: %+v", changed.SYBProduct)
}
}
func TestReimportNeverOverwritesManualTargetValues(t *testing.T) {
db := openTestDB(t)
order, detail := realOrder(), realDetailB()
first, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if _, err := sybimport.ManualCorrect(context.Background(), db, first.SYBProduct.ID, "人工颜色", "人工尺码"); err != nil {
t.Fatal(err)
}
detail.ProductSpec = "来源新颜色,来源新尺码"
detail.Raw = []byte(`{"productSpec":"来源新颜色,来源新尺码"}`)
updated, err := sybimport.ApplyDetail(context.Background(), db, order, detail)
if err != nil {
t.Fatal(err)
}
if !updated.SYBProduct.ManuallyConfirmed || updated.SYBProduct.TargetColor != "人工颜色" || updated.SYBProduct.TargetSize != "人工尺码" {
t.Fatalf("re-import overwrote human decision: %+v", updated.SYBProduct)
}
}
// Regression test: ReparseOutcome originally had no json tags at all, so Go's
// default marshaling produced PascalCase keys ("SYBProductID", "OldStatus")
// instead of the camelCase the rest of this API and the admin frontend use.
+164
View File
@@ -0,0 +1,164 @@
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
}
@@ -0,0 +1,126 @@
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)
}
}
+51 -19
View File
@@ -59,12 +59,16 @@ type SyncReport struct {
// it a renamed or newly opened shop is never noticed (#50).
ShopBreakdown map[string]ShopBreakdown `json:"shopBreakdown"`
ShopFilterHash string `json:"shopFilterHash"`
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"`
// 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"`
}
type ShopBreakdown struct {
@@ -120,19 +124,24 @@ 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{}}
ShopBreakdown: map[string]ShopBreakdown{}, ShopFilterSnapshot: shopSnapshotNames(allowed)}
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
}
@@ -283,6 +292,15 @@ func SyncWithProgress(ctx context.Context, db *gorm.DB, client *sybclient.Client
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 {
@@ -340,7 +358,7 @@ func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, da
}
var drift *snapshotDriftError
if !errors.As(err, &drift) {
return nil, err
return nil, fmt.Errorf("今天第 %d/%d 次拉取失败: %w", attempt, maxTodaySnapshotAttempts, err)
}
last = drift
}
@@ -352,7 +370,8 @@ func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, da
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int) ([]sybclient.StockRow, error) {
rows := make([]sybclient.StockRow, 0, expectedTotal)
seen := make(map[int64]struct{}, expectedTotal)
type position struct{ page, row int }
seen := make(map[int64]position, expectedTotal)
for start := 0; start < expectedTotal; start += pageSize {
pageIndex := start/pageSize + 1
@@ -368,14 +387,27 @@ func loadDailyList(ctx context.Context, client *sybclient.Client, date string, p
if pageCount != len(page) {
return nil, fmt.Errorf("%s 货运单列表第 %d 页响应条数不自洽:total=%d,list=%d", date, pageIndex, pageCount, len(page))
}
for _, row := range page {
// Validate the whole page first: a same-page duplicate must not be
// hidden behind an earlier, recoverable cross-page overlap.
pageSeen := make(map[int64]int, len(page))
for index, row := range page {
if row.ID <= 0 {
return nil, fmt.Errorf("%s 货运单列表包含非法 id=%d", date, row.ID)
}
if _, duplicate := seen[row.ID]; duplicate {
return nil, fmt.Errorf("%s 货运单列表重复返回 id=%d", date, row.ID)
if firstRow, duplicate := pageSeen[row.ID]; duplicate {
return nil, fmt.Errorf("%s 货运单列表同页重复 [firstPage=%d,firstRow=%d,page=%d,row=%d,start=%d,pageSize=%d,expectedTotal=%d,unique=%d]",
date, pageIndex, firstRow, pageIndex, index+1, start, pageSize, expectedTotal, len(rows))
}
seen[row.ID] = struct{}{}
pageSeen[row.ID] = index + 1
}
for index, row := range page {
if first, duplicate := seen[row.ID]; duplicate {
// No rows/valid flag: overlapping pages are never eligible for
// the existing final-attempt degraded save, even after deduping.
return nil, &snapshotDriftError{message: fmt.Sprintf("%s 货运单列表跨页重复 [firstPage=%d,firstRow=%d,page=%d,row=%d,start=%d,pageSize=%d,expectedTotal=%d,unique=%d]",
date, first.page, first.row, pageIndex, index+1, start, pageSize, expectedTotal, len(rows))}
}
seen[row.ID] = position{page: pageIndex, row: index + 1}
rows = append(rows, row)
}
if len(page) != expectedPageCount {
+57 -10
View File
@@ -28,6 +28,7 @@ func NewSyncRunService(db *gorm.DB) *SyncRunService { return &SyncRunService{db:
type CreateSyncRunInput struct {
DateFrom, DateTo, ShopFilterHash string
ShopFilterSnapshot map[string]string
OperatorID uint64
OperatorName string
}
@@ -38,10 +39,14 @@ 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: "{}", OperatorID: input.OperatorID,
ShopBreakdownJSON: payload, OperatorID: input.OperatorID,
OperatorName: strings.TrimSpace(input.OperatorName), StartedAt: time.Now().UTC(),
}
if err := s.db.WithContext(ctx).Create(run).Error; err != nil {
@@ -55,7 +60,7 @@ func (s *SyncRunService) Create(ctx context.Context, input CreateSyncRunInput) (
}
func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress SyncProgress) error {
breakdown, err := json.Marshal(progress.Report.ShopBreakdown)
payload, err := marshalSyncRunPayload(progress.Report.ShopBreakdown, progress.Report.ShopFilterSnapshot)
if err != nil {
return err
}
@@ -65,7 +70,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": string(breakdown),
"shop_filter_hash": progress.Report.ShopFilterHash, "shop_breakdown_json": payload,
}).Error
}
@@ -73,7 +78,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)
}
breakdown, err := json.Marshal(report.ShopBreakdown)
payload, err := marshalSyncRunPayload(report.ShopBreakdown, report.ShopFilterSnapshot)
if err != nil {
return err
}
@@ -87,7 +92,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": string(breakdown),
"shop_breakdown_json": payload,
}
if report.ShopFilterHash != "" {
updates["shop_filter_hash"] = report.ShopFilterHash
@@ -146,8 +151,10 @@ type ShopBreakdownItem struct {
}
type SyncRunDTO struct {
models.SYBSyncRun
ShopBreakdown []ShopBreakdownItem `json:"shopBreakdown"`
ProgressPercent int `json:"progressPercent"`
ShopBreakdown []ShopBreakdownItem `json:"shopBreakdown"`
ShopFilterSnapshot []string `json:"shopFilterSnapshot"`
ShopFilterSnapshotPresent bool `json:"shopFilterSnapshotPresent"`
ProgressPercent int `json:"progressPercent"`
}
func (s *SyncRunService) List(ctx context.Context, request SyncRunListRequest) (SyncRunListResponse, error) {
@@ -188,8 +195,7 @@ func (s *SyncRunService) Detail(ctx context.Context, id uint64) (SyncRunDTO, err
}
func syncRunDTO(row models.SYBSyncRun) SyncRunDTO {
var values map[string]ShopBreakdown
_ = json.Unmarshal([]byte(row.ShopBreakdownJSON), &values)
values, snapshot, snapshotPresent := parseSyncRunPayload(row.ShopBreakdownJSON)
names := make([]string, 0, len(values))
for name := range values {
names = append(names, name)
@@ -210,7 +216,48 @@ func syncRunDTO(row models.SYBSyncRun) SyncRunDTO {
if row.Status == SyncRunSucceeded {
percent = 100
}
return SyncRunDTO{SYBSyncRun: row, ShopBreakdown: items, ProgressPercent: percent}
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
}
func truncateRunError(value string, limit int) string {

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