Compare commits

...
Author SHA1 Message Date
QiuSW 8d9c3d47e0 docs: define successful SYB order writeback query filter (#327) 2026-09-19 15:40:52 +08:00
QiuSW 368f2c2357 feat: filter purchase tasks by successful SYB order writeback (#327) 2026-09-19 15:38:42 +08:00
QiuSW f66952f640 docs: record order information completion and SYB eligibility (#326) 2026-09-19 15:19:27 +08:00
QiuSW 3a2472dd20 feat: complete purchase order information and simplify SYB writeback (#326) 2026-09-19 15:16:39 +08:00
QiuSW 741e4bf680 docs: record bounded order reading and payable result contract (#325) 2026-09-19 11:53:43 +08:00
QiuSW b76fb73e12 fix: wait for unpaid order evidence and report payable total (#325) 2026-09-19 11:52:53 +08:00
QiuSW 10be37498d feat: add Chrome order backfill workflow (#316) 2026-09-18 16:03:27 +08:00
QiuSW 65d1f34865 docs: record production release and migrations (#305 #306) 2026-09-18 10:34:09 +08:00
QiuSW 7e257ca153 docs: sync SYB order writeback contracts (#305) 2026-09-18 10:20:03 +08:00
QiuSW e89de1a085 feat: queue and reconcile SYB purchase order numbers (#305) 2026-09-18 10:13:20 +08:00
QiuSW 07a3817591 test: verify optional order amount in purchase detail (#306) 2026-09-18 09:30:20 +08:00
QiuSW d35d7354d6 docs: sync optional order amount contracts (#306) 2026-09-18 09:26:45 +08:00
QiuSW 8c01329f97 feat: capture optional PDD order amount in manual backfill (#306) 2026-09-18 09:22:12 +08:00
QiuSW 9194fd664f fix(ops): allow local Admin LAN access (#304) 2026-09-17 17:02:35 +08:00
QiuSWandClaude Opus 5 c2426e2671 merge: #242 Agent 采购记录页回填入口与 PDD 订单扫描
合并 feat/242-agent-order-backfill(edd1cb1)。

Android 代码自动合并无冲突,与主线此后的 Agent 改动(#276/#277 图搜、#292
归位、#294 失败原因、#302 支付页标记)共存;全量单测 390 通过(含本单新增
16 个),assembleDebug 成功。

docs/03 镜像冲突取主线版本(2026-09-11 Wiki 同步版,已含 #242 规则)。

注意:本次合入的是 2026-09-08 的完整版(扫描 + 调用 #241 上传),不是
2026-09-10 讨论的「第一阶段只写本地」版本。页面识别判据仍未经真机验证。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-17 15:54:59 +08:00
QiuSWandClaude Opus 5 8c1cec4430 merge: #241 服务端按地址后缀批量回填订单号与下单时间
合并 feat/241-order-backfill-endpoint(290a17e、72b8b5d)。

代码自动合并无冲突;与今天 #302(PaymentPageObservedAt)、#303(原地重试)
同文件的改动经 purchase / purchasecontract / task 测试验证无语义冲突。

docs 三个镜像冲突取主线版本:主线镜像于 2026-09-11 同步 Wiki,已包含
#241/#242 说明及其后 #254、#271 内容;分支侧为 2026-09-08 旧快照。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-17 15:49:30 +08:00
QiuSWandClaude Opus 5 60c75261d3 fix: 采购管理批量重试改为原地重试选中任务,不再新建 (#303)
采购员勾选失败任务点重试,要的是这条任务本身再跑一次。原先 BatchRetry 一律
调用 Create 新建(CG-224 → CG-234),任务号变化,同一 SYB 明细的多次尝试分散在
多个任务上。

- BatchRetry 改为逐条调用既有 Reset:任务号不变,状态回到 pending,本次执行记入
  purchase_task_attempt。Reset 的全部保护原样沿用——碰过下单边界、同一明细已有
  更新任务、规格快照不完整均拒绝并返回原因,拒绝后不退回新建。
- 先识别重放再做资格预检:首次重试后任务已是 pending,先预检会把同一 requestId
  的重复提交判为「只有失败任务可以重试」,破坏幂等。测试抓到后修正。
- 资格预检关闭设备占用检查,同一设备上勾选的多条可排队;真正的占用判断在 Reset
  事务内,ensureDeviceFree 不把租约为空的 pending 计为占用。
- AgentRetry(替代商品已匹配、继续采购)改走 batchRetryCreate,保持新建:商品已
  替换,原任务的商品与规格快照不能复用。
- 前端文案改为「已重试 / 第 N 次执行」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-17 14:50:05 +08:00
QiuSWandClaude Sonnet 5 f634996dad fix: order_result_unknown 补充"是否见过支付/待付款页"诊断标记 (#302)
order_result_unknown 目前是全有或全无:parseOrderEvidence 要求订单号、下单
时间、待付款/支付文案同时命中才算 order_created,任何一项缺失就落进同一个
order_result_unknown,无法区分"确实到过支付页只是没读全证据"和"根本没到
那一步"——前者大概率已在 PDD 建了真实订单。

Agent:readOrderResult 采样循环中,只要命中过支付页 Activity 或
unpaidContextVisible(待付款/待支付/去支付文案),记 paymentPageObserved,
与订单号是否解析成功无关,随 order_result_unknown 一起上报。

服务端:PurchaseTask 新增 PaymentPageObservedAt,仅在请求带
paymentPageObserved=true 时写入服务端当前时间;不回填既有 107 笔历史记录,
无法从历史数据反推当时是否见过支付页。

不改判定结果本身,order_created 的四项条件、批量重试逻辑均未动。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-17 14:25:47 +08:00
QiuSWandClaude Opus 5 57f6cec803 fix(server): 档案合并改为无条件,既有数据可自愈 (#301)
只在键发生变化时才补档案是不够的:既有数据的键早已被前一次重解析改对了,档案
却还是空的,那样永远补不上——线上 215 条全部返回 unchanged,档案一条都没补进去。

mergeParsedSpec 对已存在的值幂等,无条件合并让这条路径能自愈。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-17 09:44:48 +08:00
QiuSWandClaude Opus 5 0e41000f62 fix(server): 消歧后的新键补进档案 (#301)
resyncShopeeProductKeys 改了兄弟明细的 target_color,却没把新键写进档案;只有被
直接重解析的那一条走了 mergeParsedSpec。

线上 2026-09-17 的后果:明细已是 `黑色【長袖】`,档案里还是旧的 `黑色`,采购查
映射查不到,明细永远停在「规格待匹配」;而采购员点「一键匹配」匹到的是档案里
剩下的旧键,看起来成功了却没有任何明细在用它。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-17 09:42:15 +08:00
QiuSWandClaude Opus 5 c1bd39496d fix(server): 规格键消歧,剥离 【...】 不再让不同商品塌缩成同一键 (#301)
`黑色【短袖】` 与 `黑色【長袖】` 剥离后都成了 `黑色`,档案里只有一个条目,两个
不同商品共用一份映射,必然有一半买错。#289 因此拦截,但人工匹配救不了——坏的是
键本身。线上 59 个塌缩键、45 个商品、344 条明细被卡住。

新增 sybspec.ResolveKeys:只在会产生歧义时保留括号内容。不塌缩的键与今天逐字
一致,线上一万四千多条明细中的绝大多数不受影响。

- 消歧需要同组全部原始规格,单条明细判断不了自己是否安全,因此在拿到
  shopeeProduct 之后、写档案之前做,并回写键发生变化的兄弟明细:新明细的到来
  可能让原本安全的键变成歧义,不同步会造成同组一半旧键一半新键。
- 不碰人工或 AI 已确认的明细,与 Reparse 不带 force 时的规则一致。
- 重解析同样走消歧,否则它会把键写回塌缩形式、悄悄撤销导入时的拆分。
- 键比较与产出统一去空白:SYB 对括号前的空格写法不一致,否则同一规格的两种写法
  会被误判为歧义。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-17 09:19:35 +08:00
QiuSWandClaude Opus 5 87a532f3bb fix(server): 图搜后的规格匹配脱离 Agent 请求 ctx (#300)
匹配挂在 Agent 提交采集结果那个 HTTP 请求的 context 上。AI 匹配要几十秒到几分钟,
Agent 先超时断开,ctx 被取消,匹配当场中断,采购员还得手动点一次「一键匹配」。

线上 2026-09-16 实测六个商品里四个是这样死的(#294 的日志第一次派上用场):
  shopee 28111/8544/5250/5259: archive spec match failed: context canceled
  shopee 9214/26680: 成功——只是 Agent 尚未超时

- 用 context.WithoutCancel 派生,保留请求携带的值(trace 不断链),只切断取消
  信号,再加 10 分钟上限兜底。
- 新增 specMatchAborted,与 unavailable 分开。ctx 取消是本端调度问题不是 Provider
  故障,重试用的是同一个已死的 ctx,毫无意义。此前被归成「AI 匹配服务暂时不可用」,
  线上出现过 unavailable=7 而 AI 服务完全正常,会一直误导排查。
- 全档案匹配遇到 aborted 立即早停,不再刷出一串同样的失败。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-16 17:20:11 +08:00
QiuSWandClaude Opus 5 8f337d5248 fix(server): 取消状态的 CHECK 约束改由 version-local 迁移应用 (#297)
约束修复原本放在 goautomigrations.Migrate 里,而 Migrate 只被 version-local 下的
迁移文件调用——那些文件在既有库上都已应用、会被跳过,于是修复永远不执行。

线上 2026-09-16 发布后实测:服务起来了、新代码在跑,ck_collection_task_status
却仍只认旧五个状态,一点取消就会被数据库拒绝。verify.go 里早有同样的警告:
「只把模型加进 migrations.Migrate 对已有数据库无效」。

单测能过是因为测试库是新建的,GORM 按模型标签直接建出含 cancelled 的约束;
既有库拿不到。

新增 1789700000000_collection_task_cancelled.go,并把约束函数导出供其调用。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-16 16:48:41 +08:00
QiuSWandClaude Opus 5 03647dd7dd feat(web): 采集任务页增加来源筛选与「取消未开始的任务」按钮 (#298)
页面混显三种来源却只有 goodsId 和状态两个筛选,批量取消时无法限定范围。

- 服务端 AdminList 增加 source 过滤,非法值报参数错误而非静默忽略。
- 前端增加来源筛选与来源列;状态补 cancelled(info 色,取消不是错误,不与
  failed 共用红色)。
- 「取消未开始的任务」按钮带实时数量,确认框列出将被取消的任务,并写明范围是
  整个筛选条件而非当前页。hasMore 时提示还有未处理的任务。

`[必须]` 取消范围包含 goodsId。少了这一维,按 goods_id 筛出两条、按钮却取消
三十几条——那正是当初放弃「两个固定按钮」、改用「筛选 + 一个按钮」想避免的事。
BatchCancel 的 goodsId 与 AdminList 用同一种匹配方式,否则两边范围会悄悄错开。

实施:sonnet 子代理;goodsId 范围一致性由复核补入。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-16 16:39:07 +08:00
QiuSWandClaude Opus 5 7c43dc6029 feat(server): 放开未开始采集任务的取消 (#297)
批量建单后无法中途叫停:删除只放行 failed,pending 一条都撤不掉,只能等设备
逐个跑完。图搜成功率并不高,批量越大越需要叫停。

新增 cancelled 终态,与 PurchaseTaskStatusCancelled 的既有约定对齐。

- pending → cancelled;running 拒绝取消。running 正在设备上操作 PDD,中途打断
  后页面停在哪一步不可控,会影响下一个任务归位(#292 已为此付过代价)。语义是
  「停止后续,当前这个跑完」。
- 取消与 Claim 的互斥点是同一条件更新。关键:Claim 领取时并不改 status,只写
  device_id 和租约,因此条件里必须带 lease_expires_at,否则会把刚被领走的任务
  误取消。
- 批量逐条更新、不包在一个事务里:一条因并发领取而跳过,不应回滚已成功取消的
  其它任务。超出单批上限时以 HasMore 如实上报,不静默截断。
- status 的 CHECK 约束只认旧五值,GORM 在 MySQL 上不改写既有 CHECK,按同文件
  ensureMySQLDirectSelectConstraint 的手法补幂等 DROP/ADD。cancelled 并入
  syncGuardSlots 终态分支以满足 active_slot / device_run_slot 两个约束。

实施:sonnet 子代理,改动经独立复核与重跑验证。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-16 16:28:07 +08:00
QiuSWandClaude Opus 5 9cdc9f325c fix(server): 「一键匹配颜色和尺码」未拿到结论的规格值重问一次 (#299)
规格匹配有三条路径,#295 只覆盖了采购侧两条。虾皮商品详情页「一键匹配」走的是
shopeeproduct.suggestMappings,仍然一次失败即放弃。实测虾皮 1528 的「黑色」在
PDD 8514 里唯一对应(黑色-冰块猫,9 个在售组合),却被判为「AI 未给出可靠建议」。

这条路径是批量调用,不能照搬 #295 的逐值重试:

- 只把没拿到可靠结论的 source 组成第二次 SuggestBatch,已有结论的不重问。
- 第二次沿用同一份 candidates。
- SuggestBatch 整体报错时也重试一次;两条重试路径互斥,总调用严格不超过 2 次。

判据一律未动:候选集校验、candidateValues 的 defensive 检查、
AutoConfirmMinConfidence 门槛、preserved 与确定性匹配分支全部保持原样。

实施:grok-4.6(派单试点),改动经独立复核与重跑验证。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-16 15:46:08 +08:00
QiuSWandClaude Opus 5 08d77bd982 fix(server): 规格匹配的 AI 调用重试一次,并区分无匹配与服务不可用 (#295)
AI 对同一输入会给出不同答案(deepseek-v4-flash 在 Temperature=0 下仍如此:
紫色/S 第一次答「未找到可靠的 PDD 规格」,第二次答「唯一匹配」),而匹配一个值
只调一次、失败即放弃,于是本该匹上的值因一次抽风永久留空。

- resolveSpecMatch 重试一次,上限 2 次调用。真失败(PDD 没有该颜色、白色有歧义)
  每次都会失败,重试更多只是浪费调用——实测连续三轮失败数稳定在 12。
- 区分 AI 明确「无匹配」与服务不可用,两者处置不同。此前都记成 failed,排查时
  分不开(#294 的遗留问题)。
- BatchSpecMatch 与 MatchArchiveSpecs 两条路径都接上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-16 14:59:16 +08:00
QiuSWandClaude Opus 5 b2b8c0d1e0 fix(ops): 记录图搜规格匹配的逐条结果,保留 Agent 原始失败原因 (#294)
排查时两次卡在缺记录上,只能靠手工重放接口和按失败耗时反推。

服务端:BatchSpecMatch 的返回值此前用 `_` 丢弃,23 条明细因 AI 服务 503 全部
失败时日志一个字都没有。改为记录四个计数并附前几条阻塞原因。

Agent:IMAGE_SEARCH_ENTRY_NOT_FOUND 有「找不到入口」和「归位失败」两个来源,
统一文案把两者抹平。改为在用户文案后括注内部原因,写法与候选点击失败一致。

两处都只记录错误码、计数与规格层面的原因,不含凭据、账号、订单和个人数据。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-16 11:53:43 +08:00
QiuSWandClaude Opus 5 1274161a1d fix(server): 规格同步后匹配档案里所有未映射的规格值 (#293)
BatchSpecMatch 遍历的是 SYB 明细,只匹配明细需要的值。#290 把虾皮完整颜色尺码
同步进档案后,还没有订单的值一个都不会被尝试——等订单真来了仍要人工点一次匹配,
正是 #290 想消除的动作。

新增 MatchArchiveSpecs:对档案里每个未确认映射的值逐个匹配,确定性优先、AI 兜底。

- 只写映射,不创建采购。人工检查点不变。
- 逐值匹配没有另一半规格,无法校验完整可售组合,因此至少要求该值出现在某个在售
  SKU 里;完整组合仍由采购预检把关。
- 没有 SKU 证据时放行,与 aiMatchQualificationForDataset 的既有口径一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-16 11:53:43 +08:00
QiuSWandClaude Opus 5 bb097a6a15 fix(android): 图搜归位的返回动作等页面稳定,推不动时清栈回首页 (#292)
BACK 分支按完就走、固定 300ms 盲等,不够 PDD 完成一次页面切换:抓到的是切换
动画中间态,判据全不命中,被归为 OTHER_PDD 后继续按。50 次预算在 15 秒内空转
耗尽,App 可能一次完整返回都没做完(真机任务 156)。

CLICK_CAMERA 分支早有 awaitPage,BACK 是唯一一个动作后不等待的分支,同一个坑
只修了一半。

- BACK 后等页面形态真的变化再判下一步。
- 连续 3 次推不动,改用 FLAG_ACTIVITY_CLEAR_TOP 清栈回首页。
- resetPddToHome 与 launchPddToForeground 分开:后者要保留深层页面,采集当前页
  的流程正需要那个行为。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-16 11:46:04 +08:00
QiuSWandClaude Opus 5 2f1e88ea04 fix(web): 未关联 PDD 的 SYB 明细可勾选,打通图搜采集入口 (#291)
勾选框的 isSelectableCandidate 只有采购、采集、AI 匹配三个判据,三个都要求
虾皮商品已关联 PDD。图搜采集的用途恰恰是给未关联的商品找 PDD 商品,于是
入口对它最该服务的那类商品一直不可达,按钮计数恒为 0。

增加 isImageSearchCandidate,与 imageSearchRows 的过滤条件一致(有虾皮商品
且有参考图)。只放宽勾选,三个按钮的候选集合各自的判据不变,采购门禁不受影响。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-16 10:59:43 +08:00
QiuSWandClaude Opus 5 5b3c8df3bc feat(server): 同步虾皮完整颜色尺码并一次性匹配 (#290)
图搜采集成功并自动关联后,拉取该虾皮商品的完整颜色尺码清单合并进档案,
再触发一次匹配。此后同一虾皮商品的其它颜色 SYB 订单无需再采集、再点匹配。

- 新增 shopeespec 叶子包,凭据只从 GOAUTO_ERPGO_BASE_URL/GOAUTO_ERPGO_APIKEY
  读取;错误文本不携带 URL,避免 apikey 流进日志。
- 写入档案前经 sybspec.StripAnnotations 剥离 【...】,与 SYB 明细同源,
  否则 confirmedMappings 查不到、映射全部落空。
- 标记记录同步时对着哪个 PDD 商品(spec_sync_pdd_product_id),不是布尔值,
  重新关联时自动失效。
- 已完整同步的商品再次图搜时返回 IMAGE_SEARCH_SPEC_SYNCED。
- 迁移只加列不回填:既有档案仍是从 SYB 明细增量累积的,不能假装已同步。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-16 10:33:53 +08:00
QiuSWandClaude Opus 5 a27db5f960 fix(server): 检出 SYB 规格塌缩并明确失败,不再静默共用同一映射 (#289)
sybimport.Parse 剥离 【...】,而括号里经常是真正的规格标识而非备注:
款号 白色【207A】、色号 黑色【M0059C1】、颜色组合 【深灰+淺灰】。剥离后
不同的虾皮规格塌缩成同一个 target_color,而它正是采购查映射的键,同键
即同映射,Agent 会为不同规格点击同一个 PDD 值。

线上实测(2026-09-16):颜色塌缩键 78 个、涉及 61 个商品、190 个原始
规格;尺码塌缩键 24 个;受影响明细 407 条。落在塌缩键上的采购任务 3 笔,
order_created 0 笔——至今没出事是因为每个塌缩键目前只有一条规格真正
下过单,不是设计上有保证。最高危的 6 个键全部尚未采购。

本次不改解析契约、不迁移数据,只让它明确失败:采购预览命中塌缩键时返回
SPEC_KEY_AMBIGUOUS 并提前返回,不再落到就绪判定;process_stage 一并拦截,
因为塌缩的键不能走 AI 匹配那条路——再匹配一次也只会为同一个键写一份映射,
而问题恰恰是多个规格共用这个键。

`[必须]` 解析原语下沉到新的叶子包 sybspec。sybimport 已依赖 purchase,
采购侧直接引用会成环;而两边各写一份镜像实现必然漂移,届时塌缩检测会拿错
半边去比,结论反而不可信。sybimport 保留别名转发,调用方无需改动。

`[必须]` 检测按虾皮商品加载全部明细,不是本次请求的那几条:塌缩是商品级
属性,只看请求内的明细会漏判。

TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher 的查询数由 7 改为 8,新增
的是一次有界批量查询。该断言守的是不得出现 N+1,不是具体数字。

测试样本全部取自线上真实数据。app/goauto/sybimport 的 12 个失败先于本次
存在(#285),未新增。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-16 10:20:09 +08:00
QiuSWandClaude Opus 5 41996f33b2 feat(server): 图搜采集成功后自动触发规格匹配 (#287)
图搜是异步的:Agent 在手机上跑几分钟,采购员必须离开页面、稍后回来、
重新勾选、再点 AI 匹配。旧流程虽然全手工但一气呵成,图搜把它切成了
两段,中间靠人记着回来。本次在关联写入之后接一个钩子消除这个断层。

`[必须]` 只能接在这里。图搜批量结果对话框在任务创建后立刻弹出,那时
Agent 一个都还没执行,无从匹配。

autoLinkImageSearch 改为返回是否确实写入关联及受影响的 SYB 明细。
RowsAffected 为 0 表示乐观并发谓词拒写——任务创建后有人改过这条关联,
目标商品已不是我们找到的那个,此时匹配上去只会把错误结果写进档案,
因此不触发。

匹配失败只记录不冒泡:采集结果已经提交完成,不能因为这个可选增强让
SubmitResult 失败、让 Agent 以为采集没成功。

不自动创建采购。匹配只把明细推到“采购就绪”,创建采购仍由人点击——那是
整条链上唯一的人工检查点,因为图搜找商品、自动关联、AI 匹配规格三步
都没有人看过,而 #200 已取消 SYB 批量入口的置信度门槛,不能以“AI 没
把握会停下”来兜底。

标记文案相应扩展为“商品由图搜找到、规格由 AI 自动匹配,均未经人工
确认”,覆盖 SYB 商品页、采购任务列表与详情、虾皮商品详情抽屉。

app/goauto/sybimport 的 12 个失败先于本次存在(#285),未新增。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 17:51:42 +08:00
QiuSWandClaude Opus 5 2193762cbd fix(agent): 相册最新项判定改用经典 sortOrder,修复 Android 12 (#277)
三星 SM-G9700(Android 12 / SDK 31)上图搜任务持续以
IMAGE_SEARCH_ASSET_NOT_LATEST 失败,而设备时钟正常、READ_EXTERNAL_STORAGE
已授予、相册里最新的照片还是两天前的——我们刚写入的图确实是最新的。

根因是查询参数形式。isMostRecent 用的是 Bundle 的
QUERY_ARG_SORT_COLUMNS / QUERY_ARG_LIMIT,老版本 MediaProvider 不认:
排序被忽略后 LIMIT=5 取回任意 5 行,我们刚写入的图不在其中,maxWith
自然不等于 ownId。同一台设备上用经典 sortOrder 字符串查询排序正常
(content query --sort 验证过),OnePlus PLY110(Android 16 / SDK 36)
两种形式都认,所以此前没暴露。

改用经典 sortOrder "date_added DESC, _id DESC",按游标读前
RECENCY_QUERY_LIMIT 行。LIMIT 不能写进 sortOrder:Android 11 起
MediaProvider 会以 Invalid token LIMIT 拒绝。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 17:51:27 +08:00
QiuSWandClaude Opus 5 174d92a1c9 fix(server): SYB 结构过滤合并为一条,要求同时包含 - 和 # (#286)
结构过滤原为两条 char 规则(- 和 #),RuleSet.Match 逐条 Contains、
命中即返回,是或关系。线上最近一次完整同步(syb_sync_run 541)里
- 命中 384、# 命中 148,合计 532 条被过滤;因 - 先判且命中即返回,
那 148 条是含 # 但不含 - 的。

改为一条,keyword 存必需字符集合 -#,匹配要求集合中每个字符都出现:

- 同时含 - 和 # → 过滤
- 只含其一 → 放行
- 都不含 → 放行

已向用户说明每次同步至少多放行 148 条,用户确认后实施。

`[必须]` migrations/migrate.go 的种子必须同步改成一条。该函数每次启动
都会 FirstOrCreate,若继续播种旧的两条,下次重启就会把迁移
1789500000000 合并掉的行重新建回来,过滤静默退回或关系。已加
TestFreshDatabaseSeedsOneStructuralRule 锁住。

关键词过滤不变,仍是子串匹配,并加测试防止字符集合语义误用到关键词上。

连带效果:真实样本 300斤牛奶絲圓領#A057 只含 #,不再被结构过滤命中后
落到关键词规则上仍被过滤——#269 把关键词称为 forward safety net,这是
它第一次真的接住东西。realworld 用例的统计由 char=5/keyword=0/kept=6
变为 char=3/keyword=1/kept=7。

app/goauto/sybimport 的 12 个失败先于本次存在(#285),未新增。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 17:15:40 +08:00
QiuSWandClaude Opus 5 95b5f05ae8 fix(#277): 图搜候选点击改瞄有文案子节点,关联不再被跨币种判据挡死
真机连续排查出三处缺陷,均已实测确认:

1) 候选卡片点不动。快照里 SnapshotNode.label 只取节点自身的 text/
   contentDescription,而 clickFreshDetailed 重新定位时用
   preferredOrDescendantLabel()(会往子孙里挖)。候选卡片是自身无文案的
   FrameLayout,两者算出的 label 一个是空串一个是标题文字,永远对不上,
   重找直接 TARGET_NOT_FOUND。改为瞄准卡片内最靠上的有文案子节点,由
   clickFreshDetailed 既有的向上走到可点祖先逻辑落到卡片容器上。
   卡片内文案的归属同时从 path 前缀改为几何包含——path 父子语义从未核对,
   几何包含已用真机 dump 直接验证。

2) 错误码拆分。IMAGE_SEARCH_NO_CANDIDATES 此前同时表示“结果页没出现”和
   “卡片点不动”,定位问题时完全分不清。拆为 RESULTS_NOT_READY /
   NO_CANDIDATES / CANDIDATE_CLICK_FAILED,并把 clickFreshDetailed 的
   result 与 reason 带进失败消息——这两个值本来就有,此前被丢弃。

3) 自动关联从上线起一次都没执行过。snapshot.PriceGuardSkipped 与
   shopee_product.currency != "CNY" 两处各自 return nil,而 priceSkipped
   的唯一来源就是 referenceCurrency != "CNY",两者是同一个条件;线上 26902
   个虾皮商品全部是 TWD。表现为图搜任务 completed、PDD 商品采集完整,但
   关联停在旧值,采购因此无法创建(订单 26091536MJHXG8 即如此)。
   币种不同只说明价格没法比,不说明关联不该建立,两件事已解耦。

   补偿控制是 image_search_linked 标记,已显示在商品页与采购视图。
   ImageSearchPriceAllowed 零调用方,加 [未接线] 标注,避免再被当成生效的
   防线;待引入可配汇率后再接。

测试夹具默认币种由 CNY 改为 TWD(线上真实币种),并新增跨币种必须关联、
同币种行为不变两个用例。此前夹具用 CNY,所以整套用例全绿而线上从未成功。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 16:27:15 +08:00
QiuSWandClaude Opus 5 750b8763dc feat(#280): 图搜确认后覆盖已有关联,去掉覆盖复选框
用户 2026-09-15 选定行为:去掉「覆盖已有关联」复选框,点击创建时若
所选商品中存在已关联的,弹窗说明将被覆盖,确认后覆盖。

原复选框是误导的:它只放行任务创建,落库时的 CAS 谓词仍要求
pdd_product_id IS NULL,所以勾上之后设备白跑 20-40 秒、关联一点不变,
界面还显示任务已完成。

服务端把关联写入从「必须为空」改为乐观并发:比对快照里的
OriginalPDDProductID,只要任务创建后没人动过这条关联就写入。直接去掉
谓词会让 Agent 执行的 20-40 秒变成静默吞掉人工改动的窗口;改为乐观并发
既满足覆盖需求,又让并发的人工改动继续胜出。该快照字段与
sameImageSearchLink 早已存在,此前未被关联路径使用。

前端确认框只在确实存在已关联商品时弹出并给出条数,一条都没有时不弹,
避免变成每次都要点掉的噪声;取消则整批不创建。

测试重写为四个用例:未变更时覆盖、任务创建后被改则放弃、创建时无关联
但执行中被抢先关联则放弃、常规无关联路径正常写入。原
TestAutoLinkImageSearchDoesNotOverwriteManualAssociation 断言的是被本次
取代的旧契约,已移除。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 15:23:34 +08:00
QiuSWandClaude Opus 5 b4393dac84 fix(web): SYB 商品页按钮归位为 图搜采集 | 创建采集 | 创建采购 (#280)
图搜采集原先是个游离按钮,挂在工具栏下方的提示条之后,与另外两个
批量操作分离。按用户 2026-09-15 的要求移入工具栏,并把三个批量操作
按从轻到重排列:图搜采集 -> 创建采集 -> 创建采购。

只调整按钮位置与顺序,不改动 v-if、disabled 条件、点击处理和计数。
顺带去掉图搜按钮文案与计数之间多余的空格,与相邻两个按钮一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 15:07:04 +08:00
QiuSWandClaude Opus 5 c1ed3750d2 fix(agent): 声明 pdd.image-search.v1 能力 (#277)
服务端 taskCompatible 对 source='image_search' 的任务按该能力过滤,
但 AgentCapabilities.supported 从未包含它。设备不声明时图搜任务
永远不会被领取,而且是静默的——任务停在 pending、不失败也不报错。

线上设备 #7「采购2」的 capabilities_json 实测确实没有这一项,装机前
核对时发现。

新增 AgentCapabilitiesTest 锁住与服务端 task.ImageSearchCapability
的一致性,并检查清单无重复。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 14:50:59 +08:00
QiuSW 1d01186b8e merge: SYB 图搜采集 (#275~#280) 与 SYB 规格解析修复 (#284)
图搜:批量创建(按 shopee_product 去重、上限 50)、任务下发与身份回填、
自动关联与跨币种/价格兜底、image_search_linked 只读标记(含采购视图)、
采集 > 图搜 的服务端排序、Agent 相册权限与图搜执行器。

#284:空格分隔的颜色尺码按逗号同规则拆分,修复线上 152 条 success 但
尺码为空的数据质量缺陷。

app/goauto/sybimport 的 12 个失败用例先于本次合并存在(#272/#274 遗留),
已记录为 #285,本次未新增。
2026-09-15 14:23:33 +08:00
QiuSWandClaude Opus 5 93a175d04f fix(syb): 空格分隔的颜色尺码按逗号同规则拆分 (#284)
#274 删除 ambiguousPattern 以接受描述性颜色(黑色+白色 簡約親膚),
意图正确,但副作用是「黑色 XL」也被整条当成颜色、尺码丢失且判为
success。success 的行不进 AI 解析队列(ai_parse_batch.go 只选
uncertain/failed),因此没有任何机制会纠正它们。2026-09-15 线上有
152 条 parse_status='success' 且 target_size='' 的明细。

不恢复被删的旧闸——那会退回 #274 之前重新拒绝描述性颜色。改为把
空格当作与逗号同等的分隔符,复用逗号分支已有的规则:恰好一个空格
分隔 token 带明确尺码特征时才拆分,不构成猜测。

- 多于一个 token 命中尺码特征时不拆,对齐逗号分支「两侧均像尺码则
  无法安全识别颜色」。
- 没有尺码 token 时原样保留为颜色,#274 的意图不受影响。
- 一并修正 parse.go 中停留在旧 uncertain 描述的文档注释。

新增 parse_whitespace_split_test.go 锁住完整对照表,含 #274 描述性
颜色不被退回的回归。

`[必须]` app/goauto/sybimport 存在 12 个先于本工单的失败用例,来自
#272/#274 改变契约后未更新的旧断言,已记录为 #285。本次提交前后失败
集合逐条比对完全一致,未新增。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 14:23:02 +08:00
QiuSWandClaude Opus 5 12e9aa8591 docs(#278): 同步 SYB 商品 PDD 图搜采集契约镜像
线上 Wiki 页面 Android-Agent-API-Contract 已更新并回读,
revision 4d1ba4423229;本次提交为 harness sync 生成的本地镜像。

新增章节记录:批量创建接口与去重、批次上限 50(作用于去重后条数、
不可 env 覆盖及其原因)、设备能力 pdd.image-search.v1、任务载荷只下发
imageUrl/mediaType/sizeBytes/sha256、复用 identify 回填身份、自动关联的
CAS 谓词与跨币种拒绝、image_search_linked 标记只读出现在采购视图且不参与
门禁、采集 > 图搜的 ORDER BY 及其必须落在服务端的原因、管理端与 Agent
两侧错误码表、Agent 执行边界(禁 OCR/VLM、订单确认页只返回、相册最新项
须跨图片与视频比较),以及 2026-09-15 真机核对的 PDD 界面判据与已知偏差。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 11:12:17 +08:00
QiuSWandClaude Opus 5 c42e32d49d fix(#277): 首页判定必须由可点相机入口证明
真机核对(2026-09-15)发现个人中心 tab 的根节点同样带
content-desc='拍照搜索',覆盖全屏 [0,0][1080,2354] 且 clickable=false;
而 GoAutoAccessibilityService.visibleTexts() 会把 contentDescription 当
文本收集。原实现只按文案判 HOME,于是个人中心被判成首页,找不到可点入口
后走几何兜底,点到该页顶部最靠右的可点节点——实测那是「设置」按钮。

两处收紧:

- classify 增加 hasClickableCameraEntry,HOME 必须由可点入口证明。
- 几何兜底增加搜索栏证据(顶部区域 content-desc='搜索' 的节点),
  没有搜索栏就返回 null 而不是交出"最靠右的可点节点"。

补充回归测试:文案存在但无可点节点时必须判为 OTHER_PDD。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 11:04:03 +08:00
QiuSWandClaude Opus 5 95be6e7569 feat(agent): 接入 PDD 图搜自动化状态机 (#277)
- 新增 PinduoduoImageSearchAutomation:归位状态机(首页/图搜入口/结果页/
  重试弹窗/订单确认页/其它页面 -> 拍照搜索/返回/拉起应用),候选卡片挑选
  (跳过广告、容忍图片未加载),均以纯函数实现并覆盖单测;订单确认页只
  允许按返回归位,不点击任何支付/下单控件。
- 修复 PinduoduoImageSearchAssetStore.isMostRecent 只比较 jpeg 的问题:
  相册"最近项目"网格实测同时含图片和视频,现在查询 MediaStore.Files
  (图片+视频)交给 ImageSearchMediaRecencyPolicy 判定,若有更新的媒体
  排在前面则明确失败,不猜测、不往后找。
- AgentForegroundService 接入完整执行链路:下载参考图 -> 校验/放大 ->
  写入相册 -> 复核最新一项 -> 归位入口 -> 选相册第一张 -> 等结果页 ->
  跳过广告打开候选 -> 复用现有 CurrentPageIdentityRunner 分享链接识别 +
  PddProductDetailCollector 采集;任务结束(成功/失败)都清理写入的图片。
- 新增错误码 IMAGE_SEARCH_ASSET_NOT_LATEST 及对应人话文案。

真机点击序列、重试弹窗分支、结果页广告识别未经真机验证,仅纯判定逻辑
(ImageSearchRecoveryPolicy / ImageSearchCandidatePolicy /
ImageSearchMediaRecencyPolicy)有 JVM 单测覆盖。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 11:01:00 +08:00
QiuSWandClaude Opus 5 c30b8c4624 fix(#277): 调度垫底改由服务端排序实现,并记录真机判据核对结果
客户端 TaskDispatchPolicy 无法实现「采集 > 图搜」:图搜任务就是
source='image_search' 的普通采集任务,由同一个 /tasks/next 端点下发,
Agent 调用时服务端已经选好了行。原先新增的 hasImageSearchWaiting 参数
和 CHECK_IMAGE_SEARCH 分支从未被传入 true,是不可达的死代码,且注释
声称的优先级与代码顺序相反。

改为在 task.Service.Next 的两处 ORDER BY 加入来源降级:

- 设备专属队列:来源降级作为主键。
- 共享候选池:来源降级排在 device_id 之后——指派设备是人的显式选择,
  必须继续优先于来源降级,否则未指派的采集任务会抢在运营明确路由到
  本机的图搜任务前面。

另外:

- #280 的批次上限去掉 env 覆盖。确认对话框要在提交前禁用按钮,前端
  必然持有同一个数字,env 覆盖只会让两侧静默不一致。
- #277 判据常量按 2026-09-15 真机 dump 核对:首页入口、图搜页四段文案、
  最近项目网格几何、结果页判据全部命中;已记录「唯一可点」过滤为必需
  (个人中心根节点带同一 content-desc 且覆盖全屏)、取景提示文案与移植
  来源不符、相册网格含视频、以及重试弹窗未验证。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 10:51:10 +08:00
QiuSW ec2d86b1a8 Merge branch 'fix/c277-imagesearch' into integrate/imagesearch 2026-09-15 10:43:08 +08:00
QiuSWandClaude Opus 5 51265681c2 feat(android): 图搜任务资产准备、页面判据与调度垫底 #277
- PinduoduoImageSearchAssetStore 补全 sha256/大小/mediaType 校验之外的四点:
  写入前清理自身历史图片、最小边不足 960 时放大、写入后等待媒体库索引并做
  JPEG 头尾标记校验、isMostRecent() 判定自己是否为相册最新一张(不识别图片
  内容,只保证相册第一张即是本次图搜参考图)。纯逻辑部分拆到
  ImageSearchAssetPolicy,JVM 单测覆盖校验/放大边界。
- 新增 PinduoduoImageSearchCriteria:图搜入口页/结果页/"再试一次"弹窗/四列
  最近项目网格的纯文本判据,从同作者已在真机走通的旧项目移植常量,注释标注
  "待真机核对",集中存放便于后续按 dump 结果调整。
- TaskDispatchPolicy.decide 新增可选参数 hasImageSearchWaiting 与
  CHECK_IMAGE_SEARCH 分支,采购 > 采集 > 图搜;默认值保证既有四条分支行为
  逐字节不变,回归测试见 TaskDispatchPolicyTest。
- AgentForegroundService 的 image_search 分支:真实校验相册权限并给出
  IMAGE_SEARCH_PERMISSION_REQUIRED 等人话失败文案;点击入口/选图/等待结果页/
  打开候选的真实自动化状态机本次未接入执行路径,原因见工单——真机 dump 无
  障碍树前置门禁未执行,移植的选择器常量未经核对,不贸然接线。
- TaskHistoryFragment 采集列表行为 source == "image_search" 的任务加一行
  "来源:图搜自动匹配" 标记(#279),不改动既有 collection 布尔分叉结构。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 10:39:41 +08:00
QiuSWandClaude Opus 5 c1eb23e9b1 fix(android): 补充相册权限重新授权入口 #276
MainActivity.onCreate 里的相册权限申请是唯一入口,用户拒绝(尤其"不再询问")后
没有任何补救路径。新增 MediaPermissionPolicy(状态查询 + 跳转应用详情页),并
在"状态"页加相册权限卡片,显示当前状态并提供重新授权/跳转系统设置的入口,供
#277 图搜执行器复用同一查询。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 10:39:27 +08:00
QiuSW 2ddfd52f5d Merge branch 'fix/b280-imagesearch' into integrate/imagesearch 2026-09-15 10:36:34 +08:00
QiuSWandClaude Opus 5 c2c0bae056 feat(purchase): surface image-search-linked marker in purchase views (#279)
Item 5 of #279 required bringing the image-search auto-link annotation
into the purchase-facing views, display-only, without gating anything.

- BatchPreviewItem and AdminTaskItem now carry imageSearchLinked, sourced
  read-only from shopee_product.image_search_linked (already loaded in
  the batch preview dataset; bulk-loaded for the task list/detail views).
- Batch purchase preview dialog and purchase task list/detail now show a
  "图搜未核" tag plus a restrained summary hint when applicable.
- Does not touch Eligible/ReasonCode/Reason/NextAction or the #283
  mappingTargetsValid gate; regression test confirms an ineligible row's
  outcome is unchanged when imageSearchLinked is true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 10:35:22 +08:00
QiuSWandClaude Opus 5 b7babfba7e feat(#280): cap image-search batch size and warn about device time
Server: BatchCreateImageSearch now rejects a batch whose deduplicated
task count (one task per shopee_product, after merging SYB detail
rows) exceeds imageSearchMaxBatchTasks (50), returning
IMAGE_SEARCH_BATCH_TOO_LARGE with a readable Chinese message instead
of silently truncating. The limit is overridable via
GOAUTO_IMAGE_SEARCH_MAX_BATCH_TASKS. #277's scheduling floor (采购 >
采集 > 图搜) isn't implemented yet, so this cap is the only guard
against a large image-search batch starving manually started
collection/purchase tasks.

Web: the batch confirm dialog now shows the deduplicated task count
and an estimated device-occupation time range (20-40s/task), and
disables the submit button with an explanation when the batch would
exceed the same limit, instead of letting the request fail after
submit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-15 10:35:21 +08:00
QiuSWandClaude Opus 5 ba0e48bcea docs: sync agent backfill scanning rules for #242
线上 Wiki Business-Rules-and-Glossary 已更新并回读,revision
1eb380d07183157a8430c7ced868479e72fbdfe3。镜像由 harness.py sync
从线上重建,sync --check 通过,未反向覆盖 Wiki。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-08 15:55:41 +08:00
QiuSW edd1cb15df feat(android): add read-only order backfill with bounded scanning (#242) 2026-09-08 15:45:06 +08:00
QiuSWandClaude Opus 5 482ba3408a docs: sync business rules and code map mirrors for #241
线上 Wiki 已更新并回读:
Business-Rules-and-Glossary  afb3eb896e41d9aea0f15ad2682ec70b7a3924ea
Architecture-and-Code-Map    d547c17924ac53422232ad9d6c55a34c8cd8d63c
镜像由 harness.py sync 从线上重建,sync --check 通过,未反向覆盖 Wiki。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-08 15:24:11 +08:00
QiuSWandClaude Opus 5 ca7f768a79 docs: sync agent API contract mirror for #241
线上 Wiki 页 Android-Agent-API-Contract 已更新并回读,revision
1f5ee1b29c66773fa571d862241b63f02dae283b。本提交只同步镜像头,
正文由 harness.py sync 从线上重建,未反向覆盖 Wiki。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-08 15:14:10 +08:00
QiuSW 72b8b5d4d0 fix(purchase): preserve conflicting order results for manual resolution (#241) 2026-09-08 15:02:51 +08:00
QiuSW 290a17ea15 feat(purchase): add agent order backfill endpoint (#241) 2026-09-08 14:41:56 +08:00
152 changed files with 10922 additions and 378 deletions
@@ -128,9 +128,11 @@ class MainActivity : AppCompatActivity() {
}
}
// 首次启动时仍然弹一次系统授权框(体验最佳位置),但这不是唯一入口:拒绝后
// (包括"不再询问")用户可以在"状态"页的相册权限卡片里重新发起授权或跳转应用详情页,
// 见 cn.ilapage.goauto.agent.ui.MediaPermissionPolicy / AgentStatusFragment。
private fun requestMediaPermission() {
val permission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES
else Manifest.permission.READ_EXTERNAL_STORAGE
val permission = cn.ilapage.goauto.agent.ui.MediaPermissionPolicy.requiredPermission()
if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(permission), MEDIA_PERMISSION_REQUEST)
}
@@ -194,6 +194,39 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
return swipeNode(node, direction)
}
/** Backfill never uses ancestor clicks or coordinate/gesture fallbacks. */
fun clickBackfill(target: SnapshotNode): Boolean {
val page = capture()
BackfillPagePolicy.validate(page)
val fresh = page.nodes.singleOrNull { it.path == target.path && it.label == target.label &&
it.bounds == target.bounds && it.className == target.className } ?: return false
val allowed = BackfillPagePolicy.cards(page).any { it.path == fresh.path } ||
BackfillPagePolicy.expansion(page)?.path == fresh.path ||
(fresh.label == "全部" && page.nodes.any { it.label in setOf("我的订单", "全部订单") })
if (!allowed || !BackfillPagePolicy.safe(page, fresh)) return false
val root = rootInActiveWindow ?: return false
if (root.packageName?.toString() != BackfillPagePolicy.PDD) return false
var node = root
for (index in fresh.path.split('/').drop(1)) node = node.getChild(index.toInt()) ?: return false
val bounds = Rect().also(node::getBoundsInScreen)
if (!node.isClickable || !node.isEnabled || !node.isVisibleToUser ||
NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom) != fresh.bounds ||
(node.text?.toString()?.trim().takeUnless { it.isNullOrEmpty() } ?: node.contentDescription?.toString()?.trim().orEmpty()) != fresh.label) return false
return node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
}
fun scrollBackfill(): Boolean {
BackfillPagePolicy.validate(capture())
val root = rootInActiveWindow ?: return false
val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { if (it.isScrollable && it.isVisibleToUser && it.isEnabled) candidates += it }
// Prefer the unique largest vertical viewport. Ambiguous panes fail closed.
val areas = candidates.map { it to Rect().also(it::getBoundsInScreen) }
val maxArea = areas.maxOfOrNull { it.second.width().toLong() * it.second.height() } ?: return false
val target = areas.filter { it.second.width().toLong() * it.second.height() == maxArea }.singleOrNull() ?: return false
return target.first.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)
}
override fun capture(): UiSnapshot {
val root = rootInActiveWindow ?: return UiSnapshot(null, null, emptyList())
val rootPackage = root.packageName?.toString()
@@ -0,0 +1,119 @@
package cn.ilapage.goauto.agent.automation
import java.math.BigInteger
import java.math.BigDecimal
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
/** Runtime evidence only: a sorted prefix cannot prove the unvisited tail is sorted. */
class OrderBackfillWindow(days: String, val confirmedAt: Long) {
val cutoff: Long
var checked = 0
private set
var nonDescending = false
private set
private var previous: Long? = null
private var missingTime = false
private var timed = 0
init {
require(days.matches(Regex("[0-9]+")) && BigInteger(days) > BigInteger.ZERO)
cutoff = BigInteger.valueOf(confirmedAt).subtract(BigInteger(days).multiply(BigInteger.valueOf(86_400_000)))
.max(BigInteger.valueOf(Long.MIN_VALUE)).toLong()
}
fun observe(time: Long?): Boolean {
checked++
if (time == null) missingTime = true else {
if (previous != null && time > previous!!) nonDescending = true
previous = time
timed++
}
// Check at least five details before using a sampled ordering assumption.
return !nonDescending && !missingTime && timed >= ORDERING_SAMPLE && time != null && time < cutoff
}
fun includes(time: Long?): Boolean = time == null || time in cutoff..confirmedAt
companion object {
const val ORDERING_SAMPLE = 5
const val MAX_ORDERS = 200
const val MAX_DURATION_MS = 10 * 60_000L
const val UNORDERED = "列表非严格倒序,已改为有界扫描,可能未覆盖全部"
}
}
data class BackfillItem(
val addressSuffix: String,
val pddOrderNo: String,
val orderSubmittedAt: String?,
val pddOrderAmountCent: Long? = null,
)
data class BackfillDetail(val item: BackfillItem?, val timeMillis: Long?)
/** Per-detail accumulator. Never retains raw text, addresses, names or phone numbers. */
class BackfillDetailReader(private val zone: TimeZone = TimeZone.getDefault()) {
private val suffixes = mutableSetOf<String>()
private val orders = mutableSetOf<String>()
private val times = mutableSetOf<Long>()
private val amounts = mutableSetOf<Long>()
private var invalidAmount = false
fun accept(text: String) {
SUFFIX.findAll(text).forEach { suffixes += it.value }
ORDER_NO.findAll(text).forEach { orders += it.groupValues[1] }
ORDER_TIME.findAll(text).forEach { match -> parseTime(match.groupValues[1])?.let(times::add) }
// Treat each accessibility node label independently: a label and an
// unrelated price in the next node must never be joined into evidence.
text.lineSequence().forEach(::acceptAmountLabel)
}
fun finish(): BackfillDetail {
val time = times.singleOrNull()
val item = if (suffixes.size == 1 && orders.size == 1 && times.size <= 1) {
BackfillItem(suffixes.single(), orders.single(), time?.let {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.ROOT).apply { timeZone = zone }.format(it)
}, amounts.singleOrNull()?.takeUnless { invalidAmount })
} else null
suffixes.clear()
orders.clear()
times.clear()
amounts.clear()
invalidAmount = false
return BackfillDetail(item, time)
}
private fun acceptAmountLabel(label: String) {
var start = label.indexOf("实付")
while (start >= 0) {
val ignored = label.substring(0, start).trimEnd().let { it.endsWith("未") || it.endsWith("预计") }
if (!ignored) {
val candidate = label.substring(start)
val match = PAID_AMOUNT.find(candidate)
if (match == null || match.range.first != 0) invalidAmount = true
else try {
amounts += BigDecimal(match.groupValues[1]).movePointRight(2).toBigIntegerExact().longValueExact()
} catch (_: ArithmeticException) {
invalidAmount = true
}
}
start = label.indexOf("实付", start + 2)
}
}
private fun parseTime(raw: String): Long? {
val normalized = raw.replace('年', '-').replace('月', '-').replace("日", "").replace('/', '-').replace('.', '-')
val pattern = if (normalized.count { it == ':' } == 2) "yyyy-M-d H:mm:ss" else "yyyy-M-d H:mm"
val position = java.text.ParsePosition(0)
val date = SimpleDateFormat(pattern, Locale.ROOT).apply { isLenient = false; timeZone = zone }.parse(normalized, position)
return date?.time?.takeIf { position.index == normalized.length }
}
companion object {
val ORDER_NO = Regex("(?:订单编号|订单号)\\s*[::]?\\s*([A-Za-z0-9-]{6,64})")
val ORDER_TIME = Regex("(?:下单时间|创建时间)\\s*[::]?\\s*(20\\d{2}[-/.年]\\d{1,2}[-/.月]\\d{1,2}日?\\s+\\d{1,2}:\\d{2}(?::\\d{2})?)")
private val PAID_AMOUNT = Regex("实付[ \\t::,,]*(\\d+(?:\\.\\d{1,2})?)[ \\t]*元(?![0-9.])")
private val SUFFIX = Regex("_cg[1-9][0-9]*(?![0-9A-Za-z_0-9])")
}
}
@@ -0,0 +1,140 @@
package cn.ilapage.goauto.agent.automation
import java.security.MessageDigest
object BackfillPagePolicy {
const val PDD = "com.xunmeng.pinduoduo"
val forbidden = listOf("确认收货", "申请退款", "催发货", "去支付", "立即支付", "提交订单", "付款", "退款", "取消订单", "再次购买", "删除订单")
private val risk = listOf("验证码", "安全验证", "人机验证", "登录", "账号异常", "风险验证", "拖动滑块")
fun validate(page: UiSnapshot) {
require(page.packageName == PDD && page.activityName?.startsWith(PDD) == true) { "PDD 页面身份不符" }
require(page.nodes.none { it.visible && risk.any { word -> it.label.contains(word) } }) { "遇到登录或安全验证,已停止" }
}
private fun subtree(page: UiSnapshot, node: SnapshotNode) = page.nodes.filter { it.path == node.path || it.path.startsWith(node.path + "/") }
fun safe(page: UiSnapshot, node: SnapshotNode): Boolean = node.visible && node.enabled && node.clickable &&
node.bounds.width > 0 && node.bounds.height > 0 &&
subtree(page, node).none { child -> forbidden.any { child.label.contains(it) } } &&
page.nodes.none { other -> other.visible && forbidden.any { other.label.contains(it) } &&
other.bounds.left < node.bounds.right && other.bounds.right > node.bounds.left &&
other.bounds.top < node.bounds.bottom && other.bounds.bottom > node.bounds.top }
fun list(page: UiSnapshot): Boolean = page.nodes.any { it.visible && it.label == "全部" && it.selected } &&
page.nodes.any { it.visible && it.label in setOf("我的订单", "全部订单") }
fun detail(page: UiSnapshot): Boolean = page.nodes.any { it.visible && (it.label == "订单详情" || it.label.contains("订单编号")) }
fun expansion(page: UiSnapshot): SnapshotNode? {
if (!detail(page) || page.nodes.none { it.visible && it.label.contains("订单编号") }) return null
val order = page.nodes.first { it.visible && it.label.contains("订单编号") }
val snapshot = page.nodes.filter { it.visible && it.label == "商品快照" }.singleOrNull() ?: return null
return page.nodes.filter { it.label == "展开" && safe(page, it) && it.bounds.top >= order.bounds.top &&
it.bounds.top < snapshot.bounds.bottom && it.bounds.bottom > snapshot.bounds.top }.singleOrNull()
}
fun cards(page: UiSnapshot): List<SnapshotNode> {
if (!list(page)) return emptyList()
return page.nodes.filter { node ->
if (!safe(page, node)) return@filter false
if (node.label in setOf("订单详情", "查看详情")) return@filter true
val children = subtree(page, node)
val product = children.any { it.className?.endsWith("ImageView") == true } && children.any { it.label.length >= 4 }
val parent = page.nodes.firstOrNull { it.path == node.parentPath } ?: return@filter false
val context = subtree(page, parent)
product && context.any { it.label in setOf("查看物流", "确认收货", "去支付", "待发货", "待收货", "交易成功", "再次购买") }
}.sortedBy { it.bounds.top }.let { candidates ->
candidates.filter { node -> candidates.none { it !== node && it.path.startsWith(node.path + "/") } }
}
}
fun fingerprint(page: UiSnapshot, card: SnapshotNode): String {
val labels = subtree(page, card).joinToString("|") { it.label }
return MessageDigest.getInstance("SHA-256").digest(labels.toByteArray()).joinToString("") { "%02x".format(it) }
}
}
interface BackfillDriver {
fun openOrders()
fun capture(): UiSnapshot
fun click(node: SnapshotNode): Boolean
fun scroll(): Boolean
fun back(): Boolean
fun pause()
}
class OrderBackfillScanner(
private val driver: BackfillDriver,
private val window: OrderBackfillWindow,
private val checkActive: () -> Unit,
private val submit: (BackfillItem) -> Unit,
private val progress: (Int) -> Unit,
) {
fun scan(): String {
checkActive()
driver.openOrders()
driver.pause()
var page = read()
if (!BackfillPagePolicy.list(page)) {
val tab = page.nodes.filter { it.label == "全部" && BackfillPagePolicy.safe(page, it) }.singleOrNull()
check(page.nodes.any { it.label in setOf("我的订单", "全部订单") } && tab != null) { "未识别我的订单-全部" }
act { driver.click(tab) }
page = read()
check(BackfillPagePolicy.list(page)) { "无法确认全部订单标签" }
}
if (page.nodes.any { it.visible && it.label == "暂无订单" }) return "扫描完成,未发现订单"
val seenCards = mutableSetOf<String>()
val seenOrders = mutableSetOf<String>()
var noProgress = 0
while (window.checked < OrderBackfillWindow.MAX_ORDERS) {
checkActive()
check(BackfillPagePolicy.list(page)) { "返回后未识别全部订单列表" }
val card = BackfillPagePolicy.cards(page).firstOrNull { BackfillPagePolicy.fingerprint(page, it) !in seenCards }
if (card == null) {
if (++noProgress >= 3) return finish("列表无进展或卡片无法安全识别,未完整扫描")
checkActive()
if (!driver.scroll()) return finish("列表滚动结束或不可滚动,未完整扫描")
driver.pause()
page = read()
continue
}
noProgress = 0
seenCards += BackfillPagePolicy.fingerprint(page, card)
act { driver.click(card) }
var detail = read()
check(BackfillPagePolicy.detail(detail)) { "点击后未识别订单详情" }
val reader = BackfillDetailReader()
var expanded = false
// Limited detail scrolling; only this accumulator associates fields across these frames.
for (step in 0 until 6) {
reader.accept(detail.nodes.filter { it.visible }.joinToString("\n") { it.label })
val expand = if (expanded) null else BackfillPagePolicy.expansion(detail)
if (expand != null) {
act { driver.click(expand) }
expanded = true
} else {
checkActive()
if (!driver.scroll()) break
driver.pause()
}
detail = read()
check(BackfillPagePolicy.detail(detail)) { "滚动后无法确认订单详情,未完整扫描" }
}
reader.accept(detail.nodes.filter { it.visible }.joinToString("\n") { it.label })
val found = reader.finish()
val stopForTime = window.observe(found.timeMillis)
progress(window.checked)
found.item?.takeIf { window.includes(found.timeMillis) && seenOrders.add(it.pddOrderNo) }?.let(submit)
if (stopForTime) return "已达指定天数(已读序列倒序);未完整扫描,后续列表时序未经验证"
act { driver.back() }
page = read()
}
return finish("达到 200 单内部上限,未完整扫描")
}
private fun finish(reason: String): String = reason + if (window.nonDescending) "\n${OrderBackfillWindow.UNORDERED}" else ""
private fun read(): UiSnapshot {
checkActive()
return driver.capture().also(BackfillPagePolicy::validate)
}
private fun act(action: () -> Boolean) {
checkActive()
check(action()) { "安全页面操作失败,未完整扫描" }
driver.pause()
}
}
@@ -1,24 +1,65 @@
package cn.ilapage.goauto.agent.automation
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import cn.ilapage.goauto.agent.network.ImageSearchImage
import java.io.ByteArrayOutputStream
import java.security.MessageDigest
import java.util.UUID
data class PreparedImageSearchAsset(val uri: Uri)
/**
* 图搜参考图的纯校验与缩放决策逻辑(#277),与 Android 框架解耦以便 JVM 单测覆盖。
* MediaStore 读写、真机相册顺序等只能真机验证,见 [PinduoduoImageSearchAssetStore]。
*/
object ImageSearchAssetPolicy {
private const val MAX_BYTES = 10 * 1024 * 1024
const val MAX_BYTES = 20 * 1024 * 1024
const val MIN_SEARCH_IMAGE_EDGE = 960
const val MAX_SEARCH_IMAGE_EDGE = 4_096
const val SEARCH_JPEG_QUALITY = 95
fun valid(bytes: ByteArray, reference: ImageSearchImage): Boolean {
if (bytes.isEmpty() || bytes.size.toLong() != reference.sizeBytes || bytes.size > MAX_BYTES) return false
if (reference.mediaType != "image/jpeg" && reference.mediaType != "image/png") return false
if (reference.mediaType == "image/jpeg" && !hasJpegMarkers(bytes)) return false
return sha256(bytes) == reference.sha256.lowercase()
}
fun sha256(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
fun hasJpegMarkers(bytes: ByteArray): Boolean =
bytes.size >= 4 &&
bytes[0] == 0xff.toByte() &&
bytes[1] == 0xd8.toByte() &&
bytes[bytes.size - 2] == 0xff.toByte() &&
bytes[bytes.size - 1] == 0xd9.toByte()
fun sha256(bytes: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
/**
* 目标缩放尺寸,纯整数运算,便于单测覆盖临界值。
* 已经不小于 [MIN_SEARCH_IMAGE_EDGE] 时返回 null(原图直接使用)。
* 放大后超过 [MAX_SEARCH_IMAGE_EDGE] 时也返回 null(放弃放大,交由调用方决定是否使用原图或失败)。
*/
fun targetScaledSize(width: Int, height: Int): Pair<Int, Int>? {
if (width <= 0 || height <= 0) return null
if (width >= MIN_SEARCH_IMAGE_EDGE && height >= MIN_SEARCH_IMAGE_EDGE) return null
val scale = maxOf(
MIN_SEARCH_IMAGE_EDGE.toDouble() / width,
MIN_SEARCH_IMAGE_EDGE.toDouble() / height,
)
val targetWidth = (width * scale).toInt()
val targetHeight = (height * scale).toInt()
if (targetWidth > MAX_SEARCH_IMAGE_EDGE || targetHeight > MAX_SEARCH_IMAGE_EDGE) return null
return targetWidth to targetHeight
}
}
class PinduoduoImageSearchAssetStore(context: Context) {
@@ -26,19 +67,122 @@ class PinduoduoImageSearchAssetStore(context: Context) {
fun prepare(bytes: ByteArray, reference: ImageSearchImage): PreparedImageSearchAsset? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || !ImageSearchAssetPolicy.valid(bytes, reference)) return null
deleteStalePreparedImages()
val searchBytes = prepareSearchCopy(bytes) ?: return null
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "goauto-search-${UUID.randomUUID()}.jpg")
put(MediaStore.Images.Media.DISPLAY_NAME, "$DISPLAY_NAME_PREFIX${UUID.randomUUID()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/GoAutoSearch")
put(MediaStore.Images.Media.RELATIVE_PATH, MEDIA_DIRECTORY)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return null
return try {
resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: error("image output unavailable")
val wrote = resolver.openOutputStream(uri)?.use { it.write(searchBytes) } != null
if (!wrote) {
resolver.delete(uri, null, null)
return null
}
resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null)
resolver.notifyChange(uri, null)
Thread.sleep(MEDIA_INDEX_SETTLE_MILLIS)
PreparedImageSearchAsset(uri)
} catch (_: Exception) { resolver.delete(uri, null, null); null }
} catch (_: Exception) {
resolver.delete(uri, null, null)
null
}
}
fun delete(asset: PreparedImageSearchAsset?) { asset?.let { resolver.delete(it.uri, null, null) } }
/**
* 本方案不做图像识别,只保证自己写入的图片是相册中最新的一项,从而在 PDD 图搜页"最近项目"
* 网格里稳定命中第一格。
*
* `[坑②修复]` 真机实测该网格同时包含图片和视频(观察到带时长文案的视频格子)。早前实现只在
* `MediaStore.Images` 里比较 jpeg,若相册里有更新的视频,网格第一格就不是我们写入的图,会导致
* 点错图。这里改为查询 `MediaStore.Files`(图片 + 视频),把结果交给纯函数
* [ImageSearchMediaRecencyPolicy] 判定;只要存在按 (DATE_ADDED, _ID) 排序更靠前的记录,
* 就返回 false(调用方据此明确失败,不猜测、不往后找)。真机验证未执行。
*/
/**
* 确认我们写入的图是相册中最新的一项(图片与视频一起比较)。
*
* `[必须]` 用经典的 sortOrder 字符串,不要用 Bundle 的 QUERY_ARG_SORT_COLUMNS /
* QUERY_ARG_LIMIT。2026-09-15 真机对比:OnePlus PLY110(Android 16 / SDK 36)
* 认 Bundle 参数,三星 SM-G9700(Android 12 / SDK 31)不认——排序被忽略后
* LIMIT 取回的是任意 5 行,我们刚写入的图根本不在其中,于是判定"不是最新"
* 并以 IMAGE_SEARCH_ASSET_NOT_LATEST 失败,实际相册里最新的照片还是两天前的。
*
* LIMIT 不能写进 sortOrder:Android 11 起 MediaProvider 会以
* "Invalid token LIMIT" 拒绝。改为按排序游标读前 [RECENCY_QUERY_LIMIT] 行。
*/
fun isMostRecent(asset: PreparedImageSearchAsset): Boolean {
val ownId = runCatching { ContentUris.parseId(asset.uri) }.getOrNull() ?: return false
val filesUri = MediaStore.Files.getContentUri("external")
val selection = "${MediaStore.Files.FileColumns.MEDIA_TYPE} IN (?, ?)"
val selectionArgs = arrayOf(
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString(),
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO.toString(),
)
val sortOrder =
"${MediaStore.Files.FileColumns.DATE_ADDED} DESC, ${MediaStore.Files.FileColumns._ID} DESC"
val entries = runCatching {
resolver.query(
filesUri,
arrayOf(MediaStore.Files.FileColumns._ID, MediaStore.Files.FileColumns.DATE_ADDED),
selection,
selectionArgs,
sortOrder,
)?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
val dateIndex = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_ADDED)
val result = mutableListOf<ImageSearchMediaEntry>()
while (result.size < RECENCY_QUERY_LIMIT && cursor.moveToNext()) {
result += ImageSearchMediaEntry(cursor.getLong(idIndex), cursor.getLong(dateIndex))
}
result
}
}.getOrNull() ?: return false
return ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId)
}
fun delete(asset: PreparedImageSearchAsset?) {
asset ?: return
runCatching { resolver.delete(asset.uri, null, null) }
}
private fun deleteStalePreparedImages() {
runCatching {
resolver.delete(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
"${MediaStore.Images.Media.RELATIVE_PATH} = ? AND ${MediaStore.Images.Media.DISPLAY_NAME} LIKE ?",
arrayOf("$MEDIA_DIRECTORY/", "$DISPLAY_NAME_PREFIX%.jpg"),
)
}
}
private fun prepareSearchCopy(source: ByteArray): ByteArray? {
val bitmap = BitmapFactory.decodeByteArray(source, 0, source.size) ?: return null
val target = ImageSearchAssetPolicy.targetScaledSize(bitmap.width, bitmap.height)
if (target == null) {
bitmap.recycle()
return source
}
val (targetWidth, targetHeight) = target
val scaled = Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true)
if (scaled !== bitmap) bitmap.recycle()
return try {
ByteArrayOutputStream().use { output ->
if (!scaled.compress(Bitmap.CompressFormat.JPEG, ImageSearchAssetPolicy.SEARCH_JPEG_QUALITY, output)) return null
output.toByteArray()
}
} finally {
scaled.recycle()
}
}
private companion object {
const val MEDIA_DIRECTORY = "Pictures/GoAutoSearch"
const val DISPLAY_NAME_PREFIX = "goauto-search-"
const val MEDIA_INDEX_SETTLE_MILLIS = 750L
const val RECENCY_QUERY_LIMIT = 5
}
}
@@ -0,0 +1,491 @@
package cn.ilapage.goauto.agent.automation
/**
* PDD 图搜状态机(#277)。
*
* 拆成两层:
* - 本文件顶部的纯判定对象([ImageSearchRecoveryPolicy]、[ImageSearchCandidatePolicy]、
* [ImageSearchMediaRecencyPolicy])不依赖 AccessibilityNodeInfo/MediaStore,可在 JVM
* 单测中直接覆盖。
* - [PinduoduoImageSearchAutomation] 是实际驱动 [PddCollectorDriver] 的执行器,只能真机验证。
*
* 归位状态机思路参考本机 D:\chengma\cmroubao_old 的
* PinduoduoImageSearchAutomation.kt(未整包复制,只借鉴"归位到入口,上限次数内不断重试"的结构)。
* OCR/VLM 相关文件(PinduoduoGoodsIdOcr / PinduoduoCheckoutOcr / PinduoduoSpecificationVlm)
* 未参考、未移植。
*/
enum class ImageSearchPageKind {
HOME,
IMAGE_SEARCH_ENTRY,
IMAGE_SEARCH_RESULTS,
RETRY_DIALOG,
ORDER_CONFIRM,
OTHER_PDD,
NON_PDD,
}
enum class ImageSearchRecoveryAction {
/** 已经在图搜入口页,无需再动作。 */
NONE,
/** 在首页,点击"拍照搜索"入口(唯一可点节点)。 */
CLICK_CAMERA,
/**
* 关闭"再试一次"弹窗。
*
* `[必须]` 这个弹窗按返回键**无效**——2026-09-15 真机实测,按 BACK 前后两次
* uiautomator dump 逐字节相同。唯一出口是点"取消",而点"取消"会退出图搜、
* 回到拼多多首页,所以关闭之后仍要重新走一遍入口。
*/
DISMISS_RETRY_DIALOG,
/** 在结果页/订单确认页/其它拼多多页面,按返回逐步归位。订单确认页只允许返回,
* 绝不点击任何支付/下单控件。 */
BACK,
/** 完全不在拼多多前台,需要先拉起应用。 */
LAUNCH_PDD,
}
/**
* 归位状态机的纯判定逻辑:给定当前页面的包名、可见文案、排序控件数量,判断处于哪种页面形态,
* 以及归位到图搜入口还需要做什么动作。
*/
object ImageSearchRecoveryPolicy {
/** 归位动作上限,参考 cmroubao_old 的经验值,防止死循环。 */
const val MAX_RECOVERY_ACTIONS = 50
/**
* 允许关闭"再试一次"弹窗并重新进入的次数上限。
*
* `[必须]` 进入图搜页后拼多多的摄像头会实时识别,识别不到东西约 4 秒后自己弹出
* 该弹窗(2026-09-15 真机实测:+3s 无弹窗、+5s 有)。关闭后会回到首页,重新进入
* 又会撞上同一个窗口,因此必须单独设上限,否则会在通用的 50 次预算里空转数分钟。
*/
const val MAX_RETRY_DIALOG_RECOVERIES = 3
/**
* 首页搜索栏的 content-desc,真机实测 `[381,166][765,223]` TextView 携带。
* 只用于证明"当前确实在带搜索栏的首页",不作为点击目标。
*/
const val SEARCH_BAR_CONTENT_DESCRIPTION = "搜索"
fun classify(
packageName: String?,
visibleTexts: Collection<String>,
sortControlCount: Int,
hasClickableCameraEntry: Boolean,
): ImageSearchPageKind {
if (packageName != PDD_PACKAGE) return ImageSearchPageKind.NON_PDD
if (PinduoduoImageSearchCriteria.isRetryDialog(visibleTexts)) return ImageSearchPageKind.RETRY_DIALOG
if (PinduoduoImageSearchCriteria.isImageSearchResultsPage(visibleTexts, sortControlCount)) {
return ImageSearchPageKind.IMAGE_SEARCH_RESULTS
}
if (PinduoduoImageSearchCriteria.isImageSearchEntryPage(visibleTexts)) return ImageSearchPageKind.IMAGE_SEARCH_ENTRY
if (visibleTexts.any { text -> ORDER_CONFIRM_MARKERS.any(text::contains) }) return ImageSearchPageKind.ORDER_CONFIRM
// `[必须]` HOME 只能由**可点**的相机入口证明,不能只看文案。真机核对
// (2026-09-15)发现个人中心 tab 的根节点同样带 content-desc='拍照搜索',
// 覆盖全屏且 clickable=false,而 visibleTexts() 会把 contentDescription
// 当文本收集。只看文案会把个人中心判成首页,随后几何兜底就会点到该页
// 顶部最靠右的可点节点——实测那是「设置」按钮。
if (hasClickableCameraEntry) return ImageSearchPageKind.HOME
return ImageSearchPageKind.OTHER_PDD
}
fun nextAction(kind: ImageSearchPageKind): ImageSearchRecoveryAction = when (kind) {
ImageSearchPageKind.IMAGE_SEARCH_ENTRY -> ImageSearchRecoveryAction.NONE
ImageSearchPageKind.HOME -> ImageSearchRecoveryAction.CLICK_CAMERA
ImageSearchPageKind.NON_PDD -> ImageSearchRecoveryAction.LAUNCH_PDD
ImageSearchPageKind.RETRY_DIALOG -> ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG
ImageSearchPageKind.IMAGE_SEARCH_RESULTS,
ImageSearchPageKind.ORDER_CONFIRM,
ImageSearchPageKind.OTHER_PDD,
-> ImageSearchRecoveryAction.BACK
}
fun exceedsRecoveryBudget(actionCount: Int): Boolean = actionCount >= MAX_RECOVERY_ACTIONS
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
// 只读识别信号:用于判断当前是否停在订单确认形态的页面,从而选择"按返回"这一条归位路径。
// 这里的任何文案都不会被配置成点击目标。
private val ORDER_CONFIRM_MARKERS = listOf(
"提交订单", "确认购买", "现在买,仅",
"待付款", "待支付", "去支付", "立即支付", "确认支付",
)
}
/** 图搜结果页里的一张候选卡片,供纯函数挑选。 */
data class ImageSearchResultCard(
val top: Int,
val left: Int,
val width: Int,
val height: Int,
val texts: List<String>,
/** 卡片内图片是否已加载完成;不作为过滤条件,仅用于诊断/测试,容忍未加载完成的卡片。 */
val imageLoaded: Boolean = true,
)
/**
* 结果页候选卡片挑选:取第一个(先按 top 再按 left),跳过带"广告"文案的卡片。
* 不检查 [ImageSearchResultCard.imageLoaded]——参考项目 T-271 曾因为"图片未加载完时卡片读不出来"
* 在真机失败过,这里刻意不把加载状态当作过滤条件。
*/
object ImageSearchCandidatePolicy {
private const val AD_MARKER = "广告"
/** 节点是否完全落在卡片矩形内。 */
fun inside(card: ImageSearchResultCard, bounds: NodeBounds): Boolean =
bounds.left >= card.left && bounds.top >= card.top &&
bounds.right <= card.left + card.width && bounds.bottom <= card.top + card.height
/**
* 卡片内最靠上的有文案节点,作为点击目标。
*
* `[必须]` 点击目标不能是卡片容器本身。快照里 SnapshotNode.label 只取节点**自身**的
* text/contentDescription,而 clickFreshDetailed 重新定位时用 preferredOrDescendantLabel()
* ——会往子孙里挖。候选卡片是自身无文案的 FrameLayout,两者算出的 label 一个是空串、
* 一个是标题文字,永远对不上,重找直接返回 TARGET_NOT_FOUND。改为瞄准有文案的子节点,
* 由 clickFreshDetailed 既有的"向上走到可点祖先"逻辑落到卡片容器上。
*/
fun clickTargetInside(card: ImageSearchResultCard, nodes: List<SnapshotNode>): SnapshotNode? =
nodes.filter { it.label.isNotEmpty() && inside(card, it.bounds) }.minByOrNull { it.bounds.top }
fun firstOpenableCandidate(cards: List<ImageSearchResultCard>): ImageSearchResultCard? =
cards
.filter { card -> card.texts.none { it.contains(AD_MARKER) } }
.sortedWith(compareBy({ it.top }, { it.left }))
.firstOrNull()
}
/** 相册里的一条媒体记录(图片或视频),仅取判定所需字段。 */
data class ImageSearchMediaEntry(val id: Long, val dateAdded: Long)
/**
* 坑②:现有 [PinduoduoImageSearchAssetStore.isMostRecent] 只比较 jpeg,若相册里有更新的视频,
* 网格第一格就不是我们写入的图。这里把"最新一项"的判定扩大到全部媒体(图片 + 视频),
* 并入 [PinduoduoImageSearchAssetStore] 时以 MediaStore.Files 同时查询两种类型。
* 找不到自己、或者存在按 (dateAdded, id) 排序更靠前的记录时,一律返回 false(明确失败,不往后找)。
*/
object ImageSearchMediaRecencyPolicy {
fun isMostRecent(entries: List<ImageSearchMediaEntry>, ownId: Long): Boolean {
if (entries.isEmpty()) return false
val latest = entries.maxWithOrNull(compareBy({ it.dateAdded }, { it.id })) ?: return false
return latest.id == ownId
}
}
data class ImageSearchAutomationResult(val successful: Boolean, val code: String, val message: String)
/**
* 图搜状态机执行器:驱动 [PddCollectorDriver] 完成"归位到图搜入口 -> 选相册第一张 -> 等结果页 ->
* 打开第一个候选"。goods_id 识别与商品采集接回现有的 [CurrentPageIdentityRunner] +
* [PddProductDetailCollector] 链路(在 AgentForegroundService 里完成),本类只负责把
* 拼多多带到"候选商品详情页已打开"这一步。
*
* `[未验证]` 全部真机点击序列均未在真机执行,只能保证纯判定逻辑(上面三个 object)经过单测覆盖。
*/
class PinduoduoImageSearchAutomation(
private val driver: PddCollectorDriver,
private val currentPackage: () -> String?,
private val pause: (Long) -> Unit = Thread::sleep,
) {
/**
* 归位到图搜入口页并在同一次迭代内选中相册第一张。
*
* `[必须]` 归位与选图必须合成一个循环,不能拆成 restoreToEntry() + selectFirstRecentImage()
* 两次调用。2026-09-15 真机实测:进入图搜页后拼多多的摄像头开始实时识别,识别不到东西
* 约 4 秒后自己弹出"再试一次"弹窗,把相册网格整个遮住;而该弹窗是独立 window,
* rootInActiveWindow 只能看到弹窗的 13 个节点,底下的图搜页完全不可见。
*
* 拆成两次调用时,中间要多一次完整的无障碍树抓取(图搜页节点很多,开销不小),
* 等第二次抓取回来时弹窗往往已经弹出,于是找不到"最近项目"锚点并报
* IMAGE_SEARCH_ENTRY_NOT_FOUND——线上任务 262 连续四次都是这样,每次约 3 秒。
* 手动实测在 1.8 秒内点中第一格可以干净进入结果页,所以余量存在,只是不能浪费。
*
* 结构参考同作者的 cmroubao_old(android-buyer PinduoduoImageSearchAutomation):
* 检测到图搜页的那一刻就在当前快照上选图,并且每个动作之后等待预期页面出现,
* 而不是盲目重判——页面加载中时判据一个都不命中,会被误判成其它页面而按下返回键。
*/
fun enterAndSelectImage(
launchPdd: () -> PddLaunchResult,
resetPddToHome: () -> PddLaunchResult = launchPdd,
): ImageSearchAutomationResult {
var actions = 0
var dialogRecoveries = 0
var stalledBacks = 0
while (true) {
val snapshot = driver.capture()
val visible = snapshot.nodes.filter { it.visible }
val texts = visible.map { it.label }
PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, texts)?.let {
return ImageSearchAutomationResult(false, it.code, it.message)
}
val sortControlCount = visible.count { it.label in SORT_CONTROL_LABELS }
val cameraEntry = uniqueCameraEntry(visible)
val kind = ImageSearchRecoveryPolicy.classify(
currentPackage(), texts, sortControlCount, hasClickableCameraEntry = cameraEntry != null,
)
when (ImageSearchRecoveryPolicy.nextAction(kind)) {
ImageSearchRecoveryAction.NONE -> {
// 已在图搜入口页:就用刚抓到的这份快照选图,不再返回调用方、不再重新抓树。
if (selectFirstRecentImageFrom(visible)) {
return ImageSearchAutomationResult(true, "OK", "已选中相册第一张")
}
// 选不中通常是网格还没渲染出来,等一轮重来;弹窗弹出时会在下一轮被识别并关闭。
}
ImageSearchRecoveryAction.CLICK_CAMERA -> {
val target = cameraEntry
?: return ImageSearchAutomationResult(false, "IMAGE_SEARCH_ENTRY_NOT_FOUND", "找不到唯一可点的拍照搜索入口")
driver.clickFreshDetailed(target)
// `[必须]` 点完入口后等页面稳定再决策。图搜页加载中时判据一个都不命中,
// 会被判成 OTHER_PDD 并按下返回键,等于自己把刚打开的图搜页退掉。
val settled = awaitPage(ENTRY_SETTLE_TIMEOUT_MILLIS) {
it == ImageSearchPageKind.IMAGE_SEARCH_ENTRY || it == ImageSearchPageKind.RETRY_DIALOG
}
if (settled != null && selectFirstRecentImageFrom(settled)) {
return ImageSearchAutomationResult(true, "OK", "已选中相册第一张")
}
}
ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG -> {
dialogRecoveries++
if (dialogRecoveries > ImageSearchRecoveryPolicy.MAX_RETRY_DIALOG_RECOVERIES) {
return ImageSearchAutomationResult(
false, "IMAGE_SEARCH_CAMERA_BLOCKED",
"拼多多摄像头识别反复失败,图搜页始终被重试弹窗占据",
)
}
if (!dismissRetryDialog(visible)) {
return ImageSearchAutomationResult(
false, "IMAGE_SEARCH_CAMERA_BLOCKED",
"无法关闭重试弹窗,请人工处理后重试",
)
}
}
ImageSearchRecoveryAction.BACK -> {
driver.back()
// `[必须]` 按完返回要等页面真的换掉再判下一步。固定 300ms 盲等不够
// PDD 完成一次切换,抓到的是动画中间态:判据一个都不命中,被归为
// OTHER_PDD,于是再按一次——50 次预算 15 秒空转耗尽,App 可能一次
// 完整返回都没做完(#292 真机任务 156)。CLICK_CAMERA 分支早就这么
// 等了,这里是同一个坑漏掉的另一半。
val moved = awaitPage(BACK_SETTLE_TIMEOUT_MILLIS) { it != kind } != null
stalledBacks = if (moved) 0 else stalledBacks + 1
if (stalledBacks >= MAX_STALLED_BACKS) {
// 返回键推不动就别再按了:把 PDD 的返回栈清到根活动,直接回首页。
stalledBacks = 0
when (resetPddToHome()) {
PddLaunchResult.INTENT_MISSING -> return ImageSearchAutomationResult(false, "PDD_APP_UNAVAILABLE", "没有找到拼多多应用,请确认已安装")
PddLaunchResult.FAILED -> return ImageSearchAutomationResult(false, "PDD_FOREGROUND_LAUNCH_FAILED", "无法打开拼多多,请手动打开商品页后重试")
PddLaunchResult.STARTED -> awaitPage(HOME_SETTLE_TIMEOUT_MILLIS) { it == ImageSearchPageKind.HOME }
}
}
}
ImageSearchRecoveryAction.LAUNCH_PDD -> {
when (launchPdd()) {
PddLaunchResult.INTENT_MISSING -> return ImageSearchAutomationResult(false, "PDD_APP_UNAVAILABLE", "没有找到拼多多应用,请确认已安装")
PddLaunchResult.FAILED -> return ImageSearchAutomationResult(false, "PDD_FOREGROUND_LAUNCH_FAILED", "无法打开拼多多,请手动打开商品页后重试")
PddLaunchResult.STARTED -> Unit
}
}
}
actions++
if (ImageSearchRecoveryPolicy.exceedsRecoveryBudget(actions)) {
return ImageSearchAutomationResult(false, "IMAGE_SEARCH_ENTRY_NOT_FOUND", "归位到图搜入口并选中参考图失败,请人工检查")
}
pause(RECOVERY_STEP_MILLIS)
}
}
/**
* 轮询直到页面稳定为 [accept] 接受的形态,命中时返回**那一次抓取**的可见节点,
* 供调用方直接使用,避免为了拿到同一个页面再抓一次树。超时返回 null。
*/
private fun awaitPage(
timeoutMillis: Long,
accept: (ImageSearchPageKind) -> Boolean,
): List<SnapshotNode>? {
val deadline = System.currentTimeMillis() + timeoutMillis
while (System.currentTimeMillis() <= deadline) {
val snapshot = driver.capture()
val visible = snapshot.nodes.filter { it.visible }
val texts = visible.map { it.label }
val sortControlCount = visible.count { it.label in SORT_CONTROL_LABELS }
val kind = ImageSearchRecoveryPolicy.classify(
currentPackage(), texts, sortControlCount,
hasClickableCameraEntry = uniqueCameraEntry(visible) != null,
)
if (accept(kind)) return visible
pause(POLL_INTERVAL_MILLIS)
}
return null
}
/**
* 点"取消"关闭"再试一次"弹窗。
*
* `[必须]` 不用 BACK:真机实测 BACK 对该弹窗完全无效。也不点"再试一次"——那会
* 重新启动摄像头识别,几秒后再弹一次,形成死循环。
*/
private fun dismissRetryDialog(visible: List<SnapshotNode>): Boolean {
val cancel = visible.firstOrNull {
it.enabled && it.clickable && it.label == PinduoduoImageSearchCriteria.RETRY_DIALOG_CANCEL_TEXT
} ?: return false
return driver.clickFreshDetailed(cancel).result == FreshActionResult.SUCCESS
}
/** 只保留唯一同时满足 content-desc 和 clickable 的节点;否则按几何兜底(顶部区域最靠右的可点节点)。 */
private fun uniqueCameraEntry(visible: List<SnapshotNode>): SnapshotNode? {
val byText = visible.filter {
it.enabled && it.clickable && it.contentDescription == PinduoduoImageSearchCriteria.CAMERA_ENTRY_CONTENT_DESCRIPTION
}
byText.singleOrNull()?.let { return it }
if (byText.isNotEmpty()) return null
// `[必须]` 几何兜底只在确实看得到顶部搜索栏时才允许。否则任何 PDD 页面都会
// 交出"顶部最靠右的可点节点",在个人中心那是「设置」。实测首页搜索栏含
// content-desc='搜索' 的节点,个人中心没有。
val hasSearchBar = visible.any {
it.visible && it.bounds.top <= PinduoduoImageSearchCriteria.CAMERA_ENTRY_FALLBACK_MAX_TOP_PX &&
it.contentDescription?.trim() == ImageSearchRecoveryPolicy.SEARCH_BAR_CONTENT_DESCRIPTION
}
if (!hasSearchBar) return null
val fallback = visible.filter {
it.enabled && it.clickable && it.bounds.top <= PinduoduoImageSearchCriteria.CAMERA_ENTRY_FALLBACK_MAX_TOP_PX
}
return fallback.maxByOrNull { it.bounds.right }
}
/**
* 在**已经抓好**的快照上定位"最近项目"锚点下方的网格并点击第一格。
*
* `[必须]` 接收 visible 而不是自己 capture:见 [enterAndSelectImage] 的说明,
* 多一次抓取就可能错过 4 秒窗口。
*/
fun selectFirstRecentImageFrom(visible: List<SnapshotNode>): Boolean {
val anchor = visible.firstOrNull { it.label.contains(PinduoduoImageSearchCriteria.RECENT_ITEMS_LABEL) } ?: return false
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return false
val cells = visible
.filter { it.clickable && it.bounds.top >= anchor.bounds.bottom }
.map { PinduoduoImageSearchCriteria.GridCandidate(it.bounds.top, it.bounds.left, it.bounds.width, it.bounds.height) }
val target = PinduoduoImageSearchCriteria.firstRecentImageGridCell(cells, screenWidth) ?: return false
val node = visible.firstOrNull {
it.clickable && it.bounds.top == target.top && it.bounds.left == target.left &&
it.bounds.width == target.width && it.bounds.height == target.height
} ?: return false
return driver.clickFreshDetailed(node).result == FreshActionResult.SUCCESS
}
/**
* 轮询直到进入结果页。
*
* `[必须]` 出现"再试一次"弹窗时**不点"再试一次"**:那会重启摄像头识别、几秒后再弹一次。
* 这里直接判失败交回调用方,由 restoreToEntry 走关闭并重新进入的路径(有独立次数上限)。
*/
fun waitForResultsPage(timeoutMillis: Long = DEFAULT_RESULTS_TIMEOUT_MILLIS): Boolean {
// 失败由调用方映射为 IMAGE_SEARCH_RESULTS_NOT_READY,与"结果页出来了但候选点不动"
// 区分开——两者此前共用 IMAGE_SEARCH_NO_CANDIDATES,定位问题时完全分不清。
val deadline = System.currentTimeMillis() + timeoutMillis
while (System.currentTimeMillis() <= deadline) {
val snapshot = driver.capture()
val visible = snapshot.nodes.filter { it.visible }
val texts = visible.map { it.label }
val sortControlCount = visible.count { it.label in SORT_CONTROL_LABELS }
if (PinduoduoImageSearchCriteria.isImageSearchResultsPage(texts, sortControlCount)) return true
if (PinduoduoImageSearchCriteria.isRetryDialog(texts)) return false
pause(RECOVERY_STEP_MILLIS)
}
return false
}
/** 打开结果页第一个非广告候选卡片;容忍卡片内图片尚未加载完成。 */
fun openFirstCandidate(): ImageSearchAutomationResult {
val snapshot = driver.capture()
val visible = snapshot.nodes.filter { it.visible }
val cardContainers = visible.filter { node ->
node.clickable &&
node.bounds.width in CARD_WIDTH_RANGE &&
node.bounds.height in CARD_HEIGHT_RANGE
}
if (cardContainers.isEmpty()) {
return ImageSearchAutomationResult(
false, "IMAGE_SEARCH_NO_CANDIDATES", "结果页没有可识别的商品卡片",
)
}
// `[必须]` 卡片内文案按**几何包含**判定,不用 path 前缀。path 的父子语义从未核对过,
// 而 bounds 包含在 2026-09-15 的真机 dump 上直接验证过:四张 535x764 卡片各自的
// 标题、价格、店铺文案都能正确归位。
val cards = cardContainers.map { container ->
ImageSearchResultCard(
top = container.bounds.top,
left = container.bounds.left,
width = container.bounds.width,
height = container.bounds.height,
texts = visible.filter {
it.label.isNotEmpty() && ImageSearchCandidatePolicy.inside(
ImageSearchResultCard(container.bounds.top, container.bounds.left, container.bounds.width, container.bounds.height, emptyList()),
it.bounds,
)
}.map { it.label },
)
}
val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards)
?: return ImageSearchAutomationResult(
false, "IMAGE_SEARCH_NO_CANDIDATES",
"结果页 ${cards.size} 张卡片全部是广告,没有可采集的候选",
)
val container = cardContainers.firstOrNull {
it.bounds.top == chosen.top && it.bounds.left == chosen.left &&
it.bounds.width == chosen.width && it.bounds.height == chosen.height
} ?: return ImageSearchAutomationResult(
false, "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED", "选中的候选卡片在点击前消失",
)
// `[必须]` 不能直接点卡片容器。快照里 SnapshotNode.label 只取节点**自身**的
// text/contentDescription,而 clickFreshDetailed 重新定位时用的是
// preferredOrDescendantLabel()——会往子孙里挖。候选卡片是自身无文案的
// FrameLayout,两者算出的 label 一个是空串、一个是标题文字,永远对不上,
// 重找直接返回 TARGET_NOT_FOUND(2026-09-15 真机:任务 262 卡在这里)。
// 改为瞄准卡片内有文案的子节点,由 clickFreshDetailed 既有的"向上走到可点祖先"
// 逻辑落到卡片容器上。
val labelled = ImageSearchCandidatePolicy.clickTargetInside(chosen, visible)
?: return ImageSearchAutomationResult(
false, "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED", "候选卡片内没有可定位的文案节点",
)
val outcome = driver.clickFreshDetailed(labelled)
if (outcome.result != FreshActionResult.SUCCESS) {
return ImageSearchAutomationResult(
false, "IMAGE_SEARCH_CANDIDATE_CLICK_FAILED",
"候选卡片点击失败[result=${outcome.result};reason=${outcome.reason}]",
)
}
return ImageSearchAutomationResult(true, "OK", "已打开第一个候选")
}
private fun SnapshotNode.width6xHeight(): Boolean = bounds.width > 0 && bounds.height > 0
private companion object {
const val RECOVERY_STEP_MILLIS = 300L
/** 动作之间的轮询间隔,取自 cmroubao_old 的实测值。 */
const val POLL_INTERVAL_MILLIS = 200L
/**
* 点击图搜入口后等待页面稳定的上限。
*
* `[必须]` 必须明显短于摄像头自动弹出重试弹窗的约 4 秒,否则等到的就是弹窗而不是图搜页。
*/
const val ENTRY_SETTLE_TIMEOUT_MILLIS = 2_500L
/** 按下返回后等待页面形态变化的上限。 */
const val BACK_SETTLE_TIMEOUT_MILLIS = 1_500L
/** 连续多少次返回推不动页面后,改用清栈重启回首页。 */
const val MAX_STALLED_BACKS = 3
/** 清栈回首页后等待首页出现的上限。 */
const val HOME_SETTLE_TIMEOUT_MILLIS = 5_000L
const val DEFAULT_RESULTS_TIMEOUT_MILLIS = 15_000L
val SORT_CONTROL_LABELS = setOf("综合", "销量", "价格", "品牌")
val CARD_WIDTH_RANGE = 400..700
val CARD_HEIGHT_RANGE = 600..950
}
}
@@ -0,0 +1,102 @@
package cn.ilapage.goauto.agent.automation
/**
* PDD 图搜相关页面判据的纯函数实现(#277)。
*
* 常量来源:移植自同作者另一个项目(本机 D:\chengma\cmroubao_old),并已于
* 2026-09-15 在真机(1080x2354,PDD HomeActivity / NewPageActivity)dump 无障碍
* 树逐条核对,三处判据全部命中:
*
* 1. 首页搜索框右侧 `[941,161][1080,228] ImageView clickable=true`
* content-desc 正是 [CAMERA_ENTRY_CONTENT_DESCRIPTION]。
* `[必须]` 必须同时要求 clickable:个人中心 tab 的**根节点**也带着同一个
* content-desc 且覆盖全屏、clickable=false,只按文案匹配会点到整屏。
* 实测首页该文案共 2 个节点、仅 1 个可点,"唯一可点"过滤是必需的。
* 2. 图搜页 [isImageSearchEntryPage] 的四段文案齐备;`最近项目` 锚点
* `[0,1892][210,1898]`,其下网格每格 267px,267*4=1068,屏宽 1080,
* 落在 ±[GRID_WIDTH_TOLERANCE_PX] 容差内。
* 3. 结果页 `搜图片同款` 出现,排序控件为 综合/销量/价格/品牌 共 4 个(>=3)。
*
* 已知与移植来源的差异,以及尚未验证的部分,见下方各常量注释。
*/
object PinduoduoImageSearchCriteria {
/** 首页/搜索结果页上,图搜入口按钮的 contentDescription。要求唯一匹配后才可点击。 */
const val CAMERA_ENTRY_CONTENT_DESCRIPTION = "拍照搜索"
/**
* 几何兜底(用户 2026-09-15 提供):若按 content-desc 找不到入口,首页顶部
* 区域最靠右的可点节点即是相机图标。实测 `[941,161][1080,228]` 紧贴右边缘。
* 仅在文案匹配失败时使用,且必须限制在顶部搜索栏所在的纵向范围内。
*/
const val CAMERA_ENTRY_FALLBACK_MAX_TOP_PX = 320
/**
* "再试一次"弹窗文案。
*
* `[已验证]` 2026-09-15 真机复现,三个常量全部命中。进入图搜页后摄像头会
* 实时识别,识别不到东西约 4 秒后拼多多**自己**弹出该弹窗(+3s 无、+5s 有),
* 不需要我们点任何东西。它会遮住相册网格。
*
* `[必须]` 按 BACK 对该弹窗**完全无效**(前后两次 dump 逐字节相同);点「取消」
* 可以关闭,但会退出图搜、回到拼多多首页。实测在 1.8 秒内点中相册第一格
* 可以赶在弹窗之前干净进入结果页。
*/
const val RETRY_DIALOG_CANCEL_TEXT = "取消"
const val RETRY_DIALOG_CONFIRM_TEXT = "再试一次"
const val RETRY_DIALOG_HINT_TEXT = "请对准商品或码,保持手机稳定"
/**
* 相册网格锚点。
*
* `[注意]` 真机实测该网格同时包含**视频**(观察到带时长文案 03:02 的格子)。
* 资产准备侧的"自己是否为相册最新一张"只比较 jpeg,若有更新的视频排在前面,
* 第一格就不是我们写入的图。接线时必须复核这一点,必要时改为按 uri 定位。
*/
const val RECENT_ITEMS_LABEL = "最近项目"
private const val GRID_COLUMNS = 4
private const val GRID_WIDTH_TOLERANCE_PX = 24
/** 图搜首页(选择"最近项目"/拍照)判据:需同时出现相册、最近搜索、历史浏览等文案,且有拍照引导。 */
fun isImageSearchEntryPage(texts: Collection<String>): Boolean {
val hasAlbum = texts.any { it.contains("我的相册") }
val hasRecentSearch = texts.any { it.contains("最近搜索") }
val hasHistory = texts.any { it.contains("历史浏览") }
val hasCameraHint = texts.any {
// 真机实测取景提示是「对准商品/条形码/二维码,自动识别」,移植来源的
// 「即可进行自动识别」不命中;靠「点击拍照」兜住。三者保留为或关系,
// 以覆盖未授予相机权限等其它形态。
it.contains("点击拍照") || it.contains("开启相机权限") || it.contains("即可进行自动识别")
}
return hasAlbum && hasRecentSearch && hasHistory && hasCameraHint
}
/** 图搜结果页判据:出现"搜图片同款"标题,且排序/筛选控件数量达到阈值。 */
fun isImageSearchResultsPage(texts: Collection<String>, sortControlCount: Int): Boolean =
texts.any { it.contains("搜图片同款") } && sortControlCount >= 3
/** "再试一次"弹窗判据。 */
fun isRetryDialog(texts: Collection<String>): Boolean {
val hasHint = texts.any { it.contains(RETRY_DIALOG_HINT_TEXT) }
val hasCancel = texts.any { it == RETRY_DIALOG_CANCEL_TEXT }
val hasRetry = texts.any { it == RETRY_DIALOG_CONFIRM_TEXT }
return hasHint && hasCancel && hasRetry
}
data class GridCandidate(val top: Int, val left: Int, val width: Int, val height: Int)
/**
* 在"最近项目"标签下方的候选矩形里,挑出 4 列等宽网格(宽度 * 4 约等于屏宽,容差
* [GRID_WIDTH_TOLERANCE_PX]px;高度 >= 宽度/2)里最靠左上的一个(先按 top 再按 left 排序)。
* 找不到符合条件的候选时返回 null。
*/
fun firstRecentImageGridCell(candidates: List<GridCandidate>, screenWidth: Int): GridCandidate? {
val fourColumns = candidates.filter { cell ->
cell.width > 0 &&
cell.height >= cell.width / 2 &&
kotlin.math.abs(cell.width * GRID_COLUMNS - screenWidth) <= GRID_WIDTH_TOLERANCE_PX
}
return fourColumns.sortedWith(compareBy({ it.top }, { it.left })).firstOrNull()
}
}
@@ -19,8 +19,16 @@ data class FinalConfirmationEvidence(
val activityName: String,
)
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String)
data class PurchaseOrderReadFailure(val code: String, val message: String)
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String, val pddOrderAmountCent: Long? = null)
data class PurchaseOrderReadFailure(
val code: String,
val message: String,
// `[必须]` 与是否读到订单号无关:只要采样期间出现过待付款/支付相关文案,
// 就说明大概率已经点通了下单、PDD 侧可能已经建了订单,只是没能读全证据
// (比如返回窗口太短、错过了订单详情页)。给 order_result_unknown 分优先级用,
// 不改变判定结果本身(#302)。
val paymentPageObserved: Boolean = false,
)
class PurchaseLiveException(val code: String, message: String, val actualUnitPriceCent: Long? = null) : IllegalStateException(message)
@@ -340,8 +348,9 @@ class PurchaseLiveAutomation(
/** Reads the order flow with bounded chooser/payment Back actions and one verified PDD foreground request. */
fun readOrderResult(): PurchaseOrderEvidence? {
lastOrderReadFailure = null
var paymentPageObserved = false
fun unknown(code: String, message: String): PurchaseOrderEvidence? {
lastOrderReadFailure = PurchaseOrderReadFailure(code, message)
lastOrderReadFailure = PurchaseOrderReadFailure(code, message, paymentPageObserved)
return null
}
val labels = linkedSetOf<String>()
@@ -353,7 +362,17 @@ class PurchaseLiveAutomation(
var orderDetailEntryOpened = false
var wechatRestorePendingSamples = 0
var consecutiveEmptySnapshots = 0
repeat(ORDER_RESULT_MAX_SAMPLES) { index ->
var unpaidStartSample: Int? = null
var unpaidSwipes = 0
repeat(ORDER_RESULT_MAX_SAMPLES + ORDER_RESULT_UNPAID_MAX_SAMPLES) { index ->
// The first unpaid page receives its own bounded budget, even after a long handoff.
// Never reset it on repeated labels or navigation back to payment.
val unpaidStart = unpaidStartSample
if ((unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES) ||
(unpaidStart != null && index - unpaidStart >= ORDER_RESULT_UNPAID_MAX_SAMPLES)) {
val failure = orderEvidenceFailure(labels)
return unknown(failure.code, failure.message)
}
val snapshot = driver.capture()
if (snapshot.packageName.isNullOrBlank()) {
consecutiveEmptySnapshots++
@@ -399,6 +418,7 @@ class PurchaseLiveAutomation(
// it permits only bounded reading gestures below, never a payment click.
val paymentVisible = isKnownPddPaymentActivity(snapshot) && !orderContextVisible && !unpaidContextVisible
if (paymentVisible) {
paymentPageObserved = true
if (paymentBackAttempts == 0) {
paymentBackAttempts++
if (!driver.backPurchase()) {
@@ -420,7 +440,16 @@ class PurchaseLiveAutomation(
return@repeat
}
consecutivePaymentSamplesAfterBack = 0
if (unpaidContextVisible) {
paymentPageObserved = true
if (unpaidStartSample == null) unpaidStartSample = index
}
if (!orderContextVisible && !unpaidContextVisible) {
if (unpaidStartSample != null) {
// After reaching the unpaid page only observe; never click a newly exposed control.
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
return@repeat
}
val entries = orderDetailEntryTargets(snapshot)
if (entries.size > 1) {
return unknown("PURCHASE_ORDER_DETAIL_ENTRY_AMBIGUOUS", "订单详情入口不唯一,已停止只读核单")
@@ -443,7 +472,13 @@ class PurchaseLiveAutomation(
}
currentLabels.forEach(labels::add)
parseOrderEvidence(labels)?.let { return it }
if (index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
if (unpaidStartSample != null && unpaidSwipes < ORDER_RESULT_UNPAID_MAX_SWIPES) {
unpaidSwipes++
driver.swipePurchase(SwipeDirection.UP, 400)
pause(ORDER_RESULT_UNPAID_SETTLE_MS)
return@repeat
}
if (unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
driver.swipePurchase(SwipeDirection.UP, 400)
}
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
@@ -452,6 +487,9 @@ class PurchaseLiveAutomation(
return unknown(failure.code, failure.message)
}
// orderEvidenceFailure 之外的路径(选择器卡住、微信恢复超时等)同样要带上
// 已经采集到的 paymentPageObserved,不能让 unknown() 之外的 return 漏标。
private fun isKnownAndroidWechatChooser(snapshot: UiSnapshot, labels: Collection<String>): Boolean {
val packageKnown = snapshot.packageName in ANDROID_CHOOSER_PACKAGES
val activity = snapshot.activityName.orEmpty()
@@ -714,7 +752,18 @@ class PurchaseLiveAutomation(
runCatching { SimpleDateFormat(pattern, Locale.ROOT).apply { isLenient = false; timeZone = TimeZone.getTimeZone("Asia/Shanghai") }.parse(normalized) }.getOrNull()
} ?: return null
val output = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(parsed)
return PurchaseOrderEvidence(orderNumbers.single(), output)
return PurchaseOrderEvidence(orderNumbers.single(), output, parsePayableAmount(labels))
}
private fun parsePayableAmount(labels: Collection<String>): Long? {
val amounts = labels.flatMap { label ->
PAYABLE_AMOUNT.findAll(label).map { match ->
runCatching { match.groupValues[1].toBigDecimal().movePointRight(2).longValueExact() }.getOrNull()
}.toList()
}
// Repeated parent/child labels are fine; conflicting/overflow amounts are omitted.
if (amounts.any { it == null }) return null
return amounts.distinct().singleOrNull()
}
private fun orderEvidenceFailure(labels: Collection<String>): PurchaseOrderReadFailure {
@@ -750,6 +799,7 @@ class PurchaseLiveAutomation(
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
val ORDER_CONTEXT_MARKERS = listOf("订单编号", "订单号", "下单时间", "创建时间")
val PAYABLE_AMOUNT = Regex("(?<![\\p{L}])应付[ \\t::,,]*[¥¥]?[ \\t]*(\\d+(?:\\.\\d{1,2})?)[ \\t]*元(?![0-9.])")
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})?)")
val ANDROID_CHOOSER_PACKAGES = setOf("android", "com.android.intentresolver")
@@ -763,7 +813,10 @@ 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_PAYMENT_POST_BACK_MAX_SAMPLES = 25
const val ORDER_RESULT_UNPAID_MAX_SAMPLES = 30
const val ORDER_RESULT_UNPAID_MAX_SWIPES = 4
const val ORDER_RESULT_UNPAID_SETTLE_MS = 500L
const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
@@ -77,6 +77,9 @@ data class PurchaseExecutionOutcome(
val pddOrderNo: String? = null,
val orderSubmittedAt: String? = null,
val actualUnitPriceCent: Long? = null,
// 仅 order_result_unknown 有意义:见 PurchaseOrderReadFailure 的说明(#302)。
val paymentPageObserved: Boolean = false,
val pddOrderAmountCent: Long? = null,
)
class PurchaseRehearsalExecutor(
@@ -140,6 +143,8 @@ class PurchaseRehearsalExecutor(
live.submitOrderOnce()
null
} catch (error: PurchaseLiveException) {
// paymentPageObserved 留默认 false:此处是 submitOrderOnce() 本身失败
// (提交按钮点击不明确),readOrderResult 还没跑过,谈不上见没见过支付页。
if (irreversibleStarted) PurchaseExecutionOutcome(
"order_result_unknown",
"PURCHASE_ORDER_RESULT_UNKNOWN",
@@ -150,13 +155,14 @@ class PurchaseRehearsalExecutor(
}
PurchaseActionType.READ_ORDER_RESULT -> {
if (!irreversibleStarted) failure("PURCHASE_RULE_INVALID", "尚未进入创建订单边界")
else live.readOrderResult()?.let { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = it.orderNo, orderSubmittedAt = it.submittedAt, actualUnitPriceCent = observedPrice) }
else live.readOrderResult()?.let { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = it.orderNo, orderSubmittedAt = it.submittedAt, actualUnitPriceCent = observedPrice, pddOrderAmountCent = it.pddOrderAmountCent) }
?: live.lastOrderReadFailure.let { readFailure ->
PurchaseExecutionOutcome(
"order_result_unknown",
readFailure?.code ?: "PURCHASE_ORDER_RESULT_UNKNOWN",
readFailure?.message ?: "无法确认订单是否创建,请人工检查",
actualUnitPriceCent = observedPrice,
paymentPageObserved = readFailure?.paymentPageObserved ?: false,
)
}
}
@@ -150,11 +150,22 @@ object AgentCapabilities {
const val PDD_PRODUCT_DETAIL_V1 = "collector.pdd.product-detail.v1"
const val PDD_CURRENT_PAGE_SHARE_V1 = "collector.pdd.current-page-share.v1"
/**
* 图搜采集能力(#277)。
*
* `[必须]` 必须与服务端 `task.ImageSearchCapability` 逐字一致。
* `taskCompatible` 对 source='image_search' 的任务按该能力过滤:
* 设备不声明则图搜任务**永远不会被领取**,且是静默的——
* 任务停在 pending、不失败也不报错。
*/
const val PDD_IMAGE_SEARCH_V1 = "pdd.image-search.v1"
val supported: List<String> = listOf(
SCHEMA_V2,
SWIPE_V1,
PDD_PRODUCT_DETAIL_V1,
PDD_CURRENT_PAGE_SHARE_V1,
PDD_IMAGE_SEARCH_V1,
PurchaseAgentCapabilities.REHEARSAL_V1,
PurchaseAgentCapabilities.LIVE_V1,
PurchaseAgentCapabilities.SPEC_PROBE_V1,
@@ -211,6 +211,9 @@ class AgentApiException(
) : Exception(message)
class AgentApiClient(private val serverUrl: String) {
fun backfillOrders(requestId: String, items: List<cn.ilapage.goauto.agent.automation.BackfillItem>, token: String): List<BackfillResult> =
parseBackfillResults(post("/api/agent/v1/purchase-tasks/order-backfill", backfillPayload(requestId, items), token))
fun testConnection() {
requireNotNull(request("GET", "/api/v1/health", null, null))
}
@@ -334,6 +337,32 @@ class AgentApiClient(private val serverUrl: String) {
}
}
/**
* 下载图搜参考图字节内容(#277)。只做网络传输,不做任何校验——校验交给
* `ImageSearchAssetPolicy.valid`(sha256/大小/JPEG 标记),保持单一职责。
*/
fun downloadImageSearchReferenceImage(reference: ImageSearchImage, token: String): ByteArray {
val url = if (reference.imageUrl.startsWith("http")) reference.imageUrl else serverUrl + reference.imageUrl
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 10_000
readTimeout = 20_000
useCaches = false
setRequestProperty("Authorization", "Bearer $token")
}
return try {
val status = connection.responseCode
if (status !in 200..299) {
val body = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
val json = runCatching { JSONObject(body) }.getOrElse { JSONObject() }
throw AgentApiException(status, json.optString("code", "HTTP_$status"), json.optString("message", "图搜参考图下载失败"), status >= 500)
}
connection.inputStream.use { it.readBytes() }
} finally {
connection.disconnect()
}
}
fun nextTask(token: String): AgentTask? {
val response = request("GET", "/api/agent/v1/tasks/next", null, token) ?: return null
return task(response.getJSONObject("data"))
@@ -0,0 +1,98 @@
package cn.ilapage.goauto.agent.network
import cn.ilapage.goauto.agent.automation.BackfillItem
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.util.UUID
data class BackfillResult(
val index: Int, val taskId: Long, val result: String, val code: String,
val status: String, val statusVersion: Long, val pddOrderNo: String?,
val orderSubmittedAt: String?, val timeSource: String,
val pddOrderAmountCent: Long? = null, val warningCode: String? = null, val warningMessage: String? = null,
) {
val success get() = result in setOf("backfilled", "already_backfilled")
val needsReview get() = !success && code != "INTERNAL_ERROR" && code != "NETWORK_ERROR"
fun display(): String = if (success) {
val source = when (timeSource) {
"page" -> "页面下单时间"
"irreversible_at" -> "估算时间(提交订单时刻)"
else -> "已有时间(来源未知)"
}
"CG-$taskId:${if (result == "already_backfilled") "已回填" else "成功"};$source ${orderSubmittedAt.orEmpty()}" +
(warningCode?.let { ";需人工检查($it${warningMessage?.let { message -> ":$message" }.orEmpty()})" } ?: "")
} else "${if (taskId > 0) "CG-$taskId" else "条目 ${index + 1}"}:${if (needsReview) "需人工检查" else "重试耗尽,未确认"}($code)"
}
internal fun backfillPayload(requestId: String, items: List<BackfillItem>): JSONObject {
require(items.size in 1..50)
return JSONObject().put("requestId", requestId).put("items", JSONArray().apply {
items.forEach { item -> put(JSONObject().put("addressSuffix", item.addressSuffix).put("pddOrderNo", item.pddOrderNo).apply {
item.orderSubmittedAt?.let { put("orderSubmittedAt", it) }
item.pddOrderAmountCent?.let { put("pddOrderAmountCent", it) }
}) }
})
}
internal fun parseBackfillResults(data: JSONObject): List<BackfillResult> {
val items = data.getJSONArray("items")
return (0 until items.length()).map { index -> items.getJSONObject(index).let {
BackfillResult(it.getInt("index"), it.optLong("taskId"), it.getString("result"), it.getString("code"),
it.optString("status"), it.optLong("statusVersion"),
if (it.isNull("pddOrderNo")) null else it.optString("pddOrderNo"),
if (it.isNull("orderSubmittedAt")) null else it.optString("orderSubmittedAt"), it.optString("timeSource"),
if (it.isNull("pddOrderAmountCent")) null else it.optLong("pddOrderAmountCent"),
if (it.isNull("warningCode")) null else it.optString("warningCode"),
if (it.isNull("warningMessage")) null else it.optString("warningMessage"))
} }
}
/** Separate from the purchase outbox: only transport failures / INTERNAL_ERROR retry, three attempts total. */
class OrderBackfillUpload(
private val submit: (String, List<BackfillItem>) -> List<BackfillResult>,
private val checkActive: () -> Unit,
private val pause: (Long) -> Unit = Thread::sleep,
) {
fun upload(items: List<BackfillItem>, confirmed: (BackfillResult) -> Unit) {
var pending = items.mapIndexed { index, item -> index to item }
var requestId = UUID.randomUUID().toString()
repeat(3) { attempt ->
checkActive()
val results = try {
submit(requestId, pending.map { it.second }).also { values ->
check(values.size == pending.size && values.map { it.index }.toSet() == pending.indices.toSet())
values.filter { it.success }.forEach { value ->
val input = pending[value.index].second
check(value.taskId.toString() == input.addressSuffix.removePrefix("_cg") && value.pddOrderNo == input.pddOrderNo)
check(value.status.isNotBlank())
}
}
} catch (error: Exception) {
val code = when (error) {
is AgentApiException -> error.code
is IOException -> "NETWORK_ERROR"
else -> throw error
}
if (code in setOf("INTERNAL_ERROR", "NETWORK_ERROR") && attempt < 2) {
pause((attempt + 1) * 1_000L)
return@repeat // same request ID for an unknown transport result
}
pending.forEach { (index, _) -> confirmed(BackfillResult(index, 0, "failed", code, "", 0, null, null, "")) }
return
}
// A completed HTTP response is evidence, even when cancellation arrived while waiting.
val retry = mutableListOf<Pair<Int, BackfillItem>>()
results.forEach { result ->
val original = pending[result.index]
if (!result.success && result.code == "INTERNAL_ERROR" && attempt < 2) retry += original
else confirmed(result.copy(index = original.first))
}
if (retry.isEmpty()) return
pending = retry
requestId = UUID.randomUUID().toString() // payload changed after partial acknowledgement
checkActive()
pause((attempt + 1) * 1_000L)
}
}
}
@@ -11,6 +11,34 @@ import org.json.JSONObject
class TaskHistoryCache(context: Context) {
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
/** Only acknowledged server facts may overwrite cached task facts. Never store scanned candidates. */
fun applyBackfill(result: cn.ilapage.goauto.agent.network.BackfillResult, environment: String) = synchronized(BACKFILL_LOCK) {
if (!result.success) {
if (result.needsReview) check(preferences.edit().putString("backfill_review_${result.taskId}_${result.code}",
JSONObject().put("taskId", result.taskId).put("code", result.code).put("environment", environment).toString()).commit())
return@synchronized
}
val values = JSONArray(preferences.getString(PURCHASE, "[]"))
for (index in 0 until values.length()) {
applyBackfillFacts(values.getJSONObject(index), result)
}
// Also retain the small acknowledged summary when this task is absent from downloaded history.
val confirmed = JSONObject().put("taskId", result.taskId).put("status", result.status)
.put("statusVersion", result.statusVersion).putNullable("pddOrderNo", result.pddOrderNo)
.putNullable("orderSubmittedAt", result.orderSubmittedAt).put("timeSource", result.timeSource)
.putNullable("pddOrderAmountCent", result.pddOrderAmountCent)
.putNullable("warningCode", result.warningCode).putNullable("warningMessage", result.warningMessage)
.put("environment", environment)
check(preferences.edit().putString(PURCHASE, values.toString())
.putString("backfill_confirmed_${result.taskId}", confirmed.toString()).commit())
}
fun backfillTimeSource(taskId: Long, environment: String): String? {
val raw = preferences.getString("backfill_confirmed_$taskId", null) ?: return null
val confirmed = runCatching { JSONObject(raw) }.getOrNull() ?: return null
return confirmed.optString("timeSource").takeIf { confirmed.optString("environment") == environment }
}
fun saveCollection(days: Int, items: List<CollectionHistoryItem>) = save(COLLECTION, days, JSONArray().apply {
items.forEach { item -> put(JSONObject()
.put("taskId", item.taskId).put("attemptNumber", item.attemptNumber).put("status", item.status).put("source", item.source).put("goodsId", item.goodsId)
@@ -90,9 +118,21 @@ class TaskHistoryCache(context: Context) {
private fun JSONObject.nullableLong(key: String): Long? = if (isNull(key)) null else optLong(key)
private companion object {
val BACKFILL_LOCK = Any()
const val PREFERENCES = "goauto_task_history_cache"
const val COLLECTION = "collection"
const val PURCHASE = "purchase"
const val PAGE_SIZE = 20
}
}
internal fun applyBackfillFacts(task: JSONObject, result: cn.ilapage.goauto.agent.network.BackfillResult) {
if (!result.success || task.optLong("taskId") != result.taskId) return
task.put("status", result.status).put("pddOrderNo", result.pddOrderNo ?: JSONObject.NULL)
.put("orderSubmittedAt", result.orderSubmittedAt ?: JSONObject.NULL)
.put("pddOrderAmountCent", result.pddOrderAmountCent ?: JSONObject.NULL)
.put("backfillWarningCode", result.warningCode ?: JSONObject.NULL)
.put("backfillWarningMessage", result.warningMessage ?: JSONObject.NULL)
.put("errorCode", JSONObject.NULL).put("errorMessage", JSONObject.NULL)
.put("retryable", false).put("retryDisabledReason", JSONObject.NULL)
}
@@ -31,6 +31,9 @@ import cn.ilapage.goauto.agent.automation.PddLaunchResult
import cn.ilapage.goauto.agent.automation.PddLinkLauncher
import cn.ilapage.goauto.agent.automation.PddDetailEntryRunner
import cn.ilapage.goauto.agent.automation.PddProductDetailCollector
import cn.ilapage.goauto.agent.automation.PinduoduoImageSearchAssetStore
import cn.ilapage.goauto.agent.automation.PinduoduoImageSearchAutomation
import cn.ilapage.goauto.agent.automation.PreparedImageSearchAsset
import cn.ilapage.goauto.agent.automation.PageEvidence
import cn.ilapage.goauto.agent.automation.NodeSelector
import cn.ilapage.goauto.agent.automation.CollectionRule
@@ -75,6 +78,7 @@ class AgentForegroundService : Service() {
private val taskExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val diagnosticExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val taskMutex = TaskExecutionMutex()
private val backfillGuard = OrderBackfillGuard(taskMutex)
private val runningTaskId = AtomicReference<Long?>(null)
private val working = AtomicBoolean(false)
private val manualCheckRequested = AtomicBoolean(false)
@@ -132,6 +136,14 @@ class AgentForegroundService : Service() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_BACKFILL_STOP) {
backfillGuard.cancelled.set(true)
return START_STICKY
}
if (intent?.action == ACTION_BACKFILL_START) {
requestOrderBackfill(intent.getStringExtra("days").orEmpty(), intent.getLongExtra("confirmedAt", 0))
return START_STICKY
}
if (intent?.action == ACTION_RECONNECT) registeredThisProcess.set(false)
if (intent?.action == ACTION_CHECK_NOW) manualCheckRequested.set(true)
if (intent?.action == ACTION_CURRENT_PAGE_COLLECTION) {
@@ -147,6 +159,7 @@ class AgentForegroundService : Service() {
}
override fun onDestroy() {
backfillGuard.cancelled.set(true)
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
cancelIdleReturn("服务已停止")
collectionCooldownFuture.getAndSet(null)?.cancel(false)
@@ -165,7 +178,7 @@ class AgentForegroundService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
private fun triggerSync() {
if (!working.compareAndSet(false, true)) return
if (!synchronized(taskMutex) { working.compareAndSet(false, true) }) return
executor.execute {
val manualCheck = manualCheckRequested.getAndSet(false)
try {
@@ -307,6 +320,98 @@ class AgentForegroundService : Service() {
})
}
private fun publishBackfill(state: OrderBackfillState) {
backfillState = state
sendBroadcast(Intent(ACTION_BACKFILL_STATE).setPackage(packageName))
updateNotification(if (state.running) "订单回填 · 已检查 ${state.checked}" else "订单回填已停止,请查看采购页结果")
}
private fun requestOrderBackfill(days: String, confirmedAt: Long) {
// Refuse while polling/dispatch is in flight too: never queue behind another PDD operation.
val acquired = synchronized(taskMutex) {
!working.get() && purchaseStore.activeTaskId() == null && backfillGuard.tryAcquire()
}
if (!acquired) {
android.widget.Toast.makeText(this, "设备忙碌,请稍后操作", android.widget.Toast.LENGTH_SHORT).show()
return
}
try {
val window = cn.ilapage.goauto.agent.automation.OrderBackfillWindow(days, confirmedAt)
val server = settingsStore.serverUrl()
val credentials = identityStore.credentials() ?: error("设备尚未注册")
val accessibility = GoAutoAccessibilityService.instance ?: error("请先启用无障碍服务")
val started = SystemClock.elapsedRealtime()
cancelIdleReturn("人工订单回填")
publishBackfill(OrderBackfillState(running = true, message = "正在检查订单列表时序…"))
taskExecutor.execute {
var state = backfillState
fun checkActive() {
check(!backfillGuard.cancelled.get() && !Thread.currentThread().isInterrupted) { "用户停止,未完整扫描" }
check(SystemClock.elapsedRealtime() - started < cn.ilapage.goauto.agent.automation.OrderBackfillWindow.MAX_DURATION_MS) { "达到 10 分钟内部上限,未完整扫描" }
check(settingsStore.serverUrl() == server && identityStore.credentials() == credentials) { "服务器或设备身份已变化,未完整扫描" }
}
try {
acquireTaskWakeLock()
val api = AgentApiClient(server)
val uploader = cn.ilapage.goauto.agent.network.OrderBackfillUpload(
submit = { id, items -> api.backfillOrders(id, items, credentials.token) },
checkActive = ::checkActive,
)
val driver = object : cn.ilapage.goauto.agent.automation.BackfillDriver {
override fun openOrders() {
checkActive()
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://mobile.yangkeduo.com/orders.html"))
.setPackage(cn.ilapage.goauto.agent.automation.BackfillPagePolicy.PDD).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
override fun capture() = accessibility.capture()
override fun click(node: cn.ilapage.goauto.agent.automation.SnapshotNode): Boolean { checkActive(); return accessibility.clickBackfill(node) }
override fun scroll(): Boolean { checkActive(); return accessibility.scrollBackfill() }
override fun back(): Boolean {
checkActive()
cn.ilapage.goauto.agent.automation.BackfillPagePolicy.validate(capture())
return accessibility.back()
}
override fun pause() { repeat(10) { checkActive(); Thread.sleep(100) } }
}
val reason = cn.ilapage.goauto.agent.automation.OrderBackfillScanner(driver, window, ::checkActive,
submit = { item -> uploader.upload(listOf(item)) { result ->
state = state.copy(success = state.success + if (result.result == "backfilled") 1 else 0,
already = state.already + if (result.result == "already_backfilled") 1 else 0,
failed = state.failed + if (!result.success) 1 else 0,
evidence = state.evidence + result.display())
// The frozen API receives all submissions. Never write an old environment's response into a new cache.
if (settingsStore.serverUrl() == server && identityStore.credentials() == credentials) {
cn.ilapage.goauto.agent.persistence.TaskHistoryCache(this).applyBackfill(result, "$server|${credentials.deviceId}")
}
publishBackfill(state)
check(result.success || result.needsReview) { "网络或服务端瞬时错误重试耗尽,未完整扫描" }
} },
progress = { checked ->
state = state.copy(checked = checked, message = if (window.nonDescending) cn.ilapage.goauto.agent.automation.OrderBackfillWindow.UNORDERED else "正在扫描(已读 $checked 单)…")
publishBackfill(state)
}).scan()
state = state.copy(message = reason)
} catch (error: Exception) {
// Never echo raw page text, HTTP bodies or credentials in UI/logs.
val reason = if (error is IllegalStateException || error is IllegalArgumentException) error.message else null
val message = reason?.takeIf { it.length < 100 } ?: "网络或页面异常"
state = state.copy(message = message + if (message.contains("未完整扫描")) "" else ",未完整扫描")
} finally {
val warning = if (window.nonDescending && !state.message.contains(cn.ilapage.goauto.agent.automation.OrderBackfillWindow.UNORDERED))
"\n${cn.ilapage.goauto.agent.automation.OrderBackfillWindow.UNORDERED}" else ""
try {
publishBackfill(state.copy(running = false, message = state.message + warning))
} finally {
try { releaseTaskWakeLock() } finally { backfillGuard.release() }
}
}
}
} catch (error: Exception) {
backfillGuard.release()
publishBackfill(OrderBackfillState(message = "回填未启动,请检查天数、设备连接与无障碍服务"))
}
}
private fun requestCurrentPageCollection(
requestId: String,
replacementOriginType: String?,
@@ -414,6 +519,31 @@ class AgentForegroundService : Service() {
})
}
/**
* 把采购员看得懂的文案和内部原因拼在一起:文案在前,原始说法括注在后。
*
* 内部原因只描述页面形态与归位结果,不含凭据、账号、订单和个人数据(#294)。
*/
private fun imageSearchFailureMessage(code: String, internal: String): String {
if (!code.startsWith("IMAGE_SEARCH")) return internal
val user = imageSearchUserMessage(code)
return if (internal.isBlank() || internal == user) user else "$user[$internal]"
}
/** #277:图搜任务失败码到人话文案的映射,采购员看得懂即可,不暴露内部状态机细节。 */
private fun imageSearchUserMessage(code: String): String = when (code) {
"IMAGE_SEARCH_PERMISSION_REQUIRED" -> "未授予相册权限,无法准备图搜参考图,请到“状态”页开启相册权限后重试。"
"IMAGE_SEARCH_ENTRY_NOT_FOUND" -> "打不开拍照搜索入口,请检查 PDD App 版本或手动确认后重试。"
"IMAGE_SEARCH_RESULTS_NOT_READY" -> "图搜结果页没有如期出现,请稍后重试。"
"IMAGE_SEARCH_CANDIDATE_CLICK_FAILED" -> "搜到了商品但打不开,请稍后重试或人工关联。"
"IMAGE_SEARCH_NO_CANDIDATES" -> "没有搜到相似商品,请确认参考图清晰或换一张图后重试。"
"IMAGE_SEARCH_CAMERA_BLOCKED" -> "拼多多摄像头一直识别不到东西,图搜页反复弹出重试提示。请把手机对着有内容的画面后重试。"
"IMAGE_SEARCH_ASSET_INVALID" -> "图片准备失败,请重新发起图搜任务。"
"IMAGE_SEARCH_ASSET_NOT_LATEST" -> "相册里有更新的照片或视频,无法确认图搜会用到我们准备的图片,请清理相册后重试。"
"IMAGE_SEARCH_AUTOMATION_UNAVAILABLE" -> "当前 Agent 版本尚未接入 PDD 图搜自动化,请使用普通采集任务。"
else -> "图搜任务失败,请稍后重试。"
}
private fun currentPageUserMessage(code: String, fallback: String): String = when (code) {
"DEVICE_BUSY" -> "设备正在执行任务,请稍后再试。"
"AGENT_MANUAL_RULE_NOT_CONFIGURED" -> "请先配置 Agent 手动采集规则。"
@@ -591,9 +721,10 @@ class AgentForegroundService : Service() {
"order_result_unknown",
readFailure?.code ?: "PURCHASE_ORDER_RESULT_UNKNOWN",
readFailure?.message ?: "无法确认订单是否创建,请人工检查",
paymentPageObserved = readFailure?.paymentPageObserved ?: false,
)
} else {
PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt)
PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt, pddOrderAmountCent = evidence.pddOrderAmountCent)
}
val requestId = UUID.randomUUID().toString()
purchaseStore.completeAndEnqueue(interrupted.taskId, interrupted.attemptId, requestId, purchaseResultPayload(requestId, interrupted.attemptId, outcome))
@@ -709,7 +840,88 @@ class AgentForegroundService : Service() {
if (!collection.successful) throw TaskFailure(collection.code, collection.message)
CollectionExecution(requireNotNull(collection.payload), collection.colorImages)
} else if (task.source == "image_search") {
throw TaskFailure("IMAGE_SEARCH_AUTOMATION_UNAVAILABLE", "当前 Agent 版本尚未接入 PDD 图搜自动化,请使用普通采集任务")
// #277:相册权限是图搜可执行的前置条件。
if (cn.ilapage.goauto.agent.ui.MediaPermissionPolicy.current(this) ==
cn.ilapage.goauto.agent.ui.MediaPermissionReadiness.NOT_GRANTED
) {
throw TaskFailure("IMAGE_SEARCH_PERMISSION_REQUIRED", imageSearchUserMessage("IMAGE_SEARCH_PERMISSION_REQUIRED"))
}
if (rule.schemaVersion != 2) {
throw TaskFailure("RULE_INVALID", "图搜采集只支持新版采集规则")
}
val reference = task.imageSearch
?: throw TaskFailure("IMAGE_SEARCH_ASSET_INVALID", imageSearchUserMessage("IMAGE_SEARCH_ASSET_INVALID"))
val assetStore = PinduoduoImageSearchAssetStore(this)
var prepared: PreparedImageSearchAsset? = null
try {
when (accessibility.restoreRecentPddPage(::launchPddToForeground)) {
PddForegroundRestoreResult.READY -> Unit
PddForegroundRestoreResult.INTENT_MISSING ->
throw TaskFailure("PDD_APP_UNAVAILABLE", "没有找到拼多多应用,请确认已安装")
PddForegroundRestoreResult.LAUNCH_FAILED ->
throw TaskFailure("PDD_FOREGROUND_LAUNCH_FAILED", "无法打开拼多多,请手动打开商品页后重试")
PddForegroundRestoreResult.TIMEOUT ->
throw TaskFailure("CURRENT_PDD_PAGE_NOT_FOUND", "拼多多未能切换到前台")
}
val bytes = try {
api.downloadImageSearchReferenceImage(reference, token)
} catch (error: AgentApiException) {
throw TaskFailure("IMAGE_SEARCH_ASSET_INVALID", imageSearchUserMessage("IMAGE_SEARCH_ASSET_INVALID"))
}
prepared = assetStore.prepare(bytes, reference)
?: throw TaskFailure("IMAGE_SEARCH_ASSET_INVALID", imageSearchUserMessage("IMAGE_SEARCH_ASSET_INVALID"))
if (!assetStore.isMostRecent(prepared)) {
throw TaskFailure("IMAGE_SEARCH_ASSET_NOT_LATEST", imageSearchUserMessage("IMAGE_SEARCH_ASSET_NOT_LATEST"))
}
val imageSearch = PinduoduoImageSearchAutomation(accessibility, accessibility::currentPackage)
// `[必须]` 归位与选图是同一个调用:中间多一次无障碍树抓取就可能错过
// 拼多多摄像头自动弹出重试弹窗前的约 4 秒窗口,详见 enterAndSelectImage。
val entry = imageSearch.enterAndSelectImage(::launchPddToForeground, ::resetPddToHome)
// `[必须]` 保留归位状态机的原始说法。IMAGE_SEARCH_ENTRY_NOT_FOUND 有两个
// 来源——「找不到唯一可点的拍照搜索入口」和「归位失败(预算耗尽)」——
// 统一文案会把两者抹平,排查任务 156 时只能靠失败耗时反推(#294)。
// 括注写法与下面候选点击失败一致。
if (!entry.successful) throw TaskFailure(entry.code, imageSearchFailureMessage(entry.code, entry.message))
if (!imageSearch.waitForResultsPage()) {
throw TaskFailure("IMAGE_SEARCH_RESULTS_NOT_READY", imageSearchUserMessage("IMAGE_SEARCH_RESULTS_NOT_READY"))
}
val candidate = imageSearch.openFirstCandidate()
if (!candidate.successful) {
// 保留执行器给出的具体原因(含 result/reason),排查时不必再猜卡在哪一步。
throw TaskFailure(candidate.code, "${imageSearchUserMessage(candidate.code)}[${candidate.message}]")
}
// 接回现有的分享链接识别 + 商品详情采集链路(与 agent_current_page 完全一致)。
val identityResult = CurrentPageIdentityRunner(
driver = accessibility,
readFreshClipboard = { copiedAt, timeout ->
ClipboardRelayActivity.readFresh(this, copiedAt, timeout)
},
taskId = task.taskId,
diagnostic = diagnosticRecorder::record,
).identify(rule)
if (!identityResult.successful) throw TaskFailure(identityResult.code, identityResult.message)
val identity = try {
api.identifyCurrentPageCollectionTask(
task.taskId,
UUID.randomUUID().toString(),
requireNotNull(identityResult.shareUrl),
token,
)
} catch (error: AgentApiException) {
throw TaskFailure(error.code, error.message)
}
val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) }
val collection = PddProductDetailCollector(
accessibility,
trace = trace,
taskId = task.taskId,
diagnostic = diagnosticRecorder::record,
).collect(identity.goodsId, rule)
if (!collection.successful) throw TaskFailure(collection.code, collection.message)
CollectionExecution(requireNotNull(collection.payload), collection.colorImages)
} finally {
assetStore.delete(prepared)
}
} else if (rule.schemaVersion == 2) {
val trace: (String) -> Unit = { message -> Log.i("GoAutoCollector", message) }
val collection = PddDetailEntryRunner(
@@ -1040,10 +1252,15 @@ class AgentForegroundService : Service() {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
if (backfillState.running) {
val stop = PendingIntent.getService(this, 242, Intent(this, AgentForegroundService::class.java).setAction(ACTION_BACKFILL_STOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
builder.addAction(Notification.Action.Builder(null, "停止回填", stop).build())
}
return builder
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle(getString(R.string.app_name))
.setContentText(content)
.setContentText(if (backfillState.running) "订单回填 · 已检查 ${backfillState.checked}" else content)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
@@ -1054,6 +1271,27 @@ class AgentForegroundService : Service() {
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(content))
}
/**
* 把拼多多的返回栈清到根活动,等于回首页。
*
* `[必须]` 不能复用 [launchPddToForeground]:那个只带 FLAG_ACTIVITY_NEW_TASK,
* 会把已有任务**连同深层页面**一起恢复到前台,采集当前页的流程正需要这个行为。
* 归位要的是相反的效果,所以单独加 FLAG_ACTIVITY_CLEAR_TOP,两条路径各自保留(#292)。
*/
private fun resetPddToHome(): PddLaunchResult {
val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE)
?: return PddLaunchResult.INTENT_MISSING
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
return runCatching { startActivity(launchIntent) }
.fold(
onSuccess = { PddLaunchResult.STARTED },
onFailure = {
Log.w("GoAutoAgent", "failed to reset PDD to home", it)
PddLaunchResult.FAILED
},
)
}
private fun launchPddToForeground(): PddLaunchResult {
val launchIntent = packageManager.getLaunchIntentForPackage(PDD_PACKAGE)
?: return PddLaunchResult.INTENT_MISSING
@@ -1069,6 +1307,11 @@ class AgentForegroundService : Service() {
}
companion object {
const val ACTION_BACKFILL_START = "cn.ilapage.goauto.agent.BACKFILL_START"
const val ACTION_BACKFILL_STOP = "cn.ilapage.goauto.agent.BACKFILL_STOP"
const val ACTION_BACKFILL_STATE = "cn.ilapage.goauto.agent.BACKFILL_STATE"
@Volatile var backfillState = OrderBackfillState()
private set
const val ACTION_RECONNECT = "cn.ilapage.goauto.agent.RECONNECT"
const val ACTION_CHECK_NOW = "cn.ilapage.goauto.agent.CHECK_NOW"
const val ACTION_CHECK_RESULT = "cn.ilapage.goauto.agent.CHECK_RESULT"
@@ -104,6 +104,10 @@ internal enum class TaskDispatchDecision {
CHECK_COLLECTION,
}
// 采集 > 图搜的优先级不在这里实现,也无法在这里实现:图搜任务就是 source =
// "image_search" 的普通采集任务,由同一个 /tasks/next 端点下发,Agent 调用时
// 服务端已经选好了行。该排序落在服务端 task.Service.Next 的 ORDER BY 里(#277)。
internal object TaskDispatchPolicy {
fun decide(purchaseStatus: String?, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
purchaseStatus == "spec_probe_pending" -> TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH
@@ -0,0 +1,22 @@
package cn.ilapage.goauto.agent.service
import java.util.concurrent.atomic.AtomicBoolean
class OrderBackfillGuard(private val mutex: TaskExecutionMutex) {
private val active = AtomicBoolean(false)
val cancelled = AtomicBoolean(false)
fun tryAcquire(): Boolean {
if (!active.compareAndSet(false, true)) return false
if (!mutex.tryAcquire(RESERVATION)) {
active.set(false)
return false
}
cancelled.set(false)
return true
}
fun release() {
mutex.release(RESERVATION)
active.set(false)
}
companion object { const val RESERVATION = Long.MAX_VALUE - 1 }
}
@@ -0,0 +1,14 @@
package cn.ilapage.goauto.agent.service
data class OrderBackfillState(
val running: Boolean = false,
val checked: Int = 0,
val success: Int = 0,
val already: Int = 0,
val failed: Int = 0,
val message: String = "",
val evidence: List<String> = emptyList(),
) {
fun text(): String = "已检查 $checked · 成功 $success · 已回填 $already · 冲突/失败 $failed\n$message" +
if (evidence.isEmpty()) "" else "\n" + evidence.joinToString("\n")
}
@@ -16,9 +16,16 @@ internal fun purchaseResultPayload(
put("errorCode", outcome.errorCode ?: "AGENT_EXECUTION_ERROR")
put("errorMessage", outcome.message.take(1000))
}
// 仅 order_result_unknown 有意义;服务端只在这个结果类型下读取,其它类型忽略。
if (outcome.resultType == "order_result_unknown" && outcome.paymentPageObserved) {
put("paymentPageObserved", true)
}
outcome.probedSpecs?.let { put("probedSpecs", JSONObject(it)) }
outcome.pddOrderNo?.let { put("pddOrderNo", it) }
outcome.orderSubmittedAt?.let { put("orderSubmittedAt", it) }
outcome.actualUnitPriceCent?.let { put("actualUnitPriceCent", it) }
if (outcome.resultType == "order_created") {
outcome.pddOrderAmountCent?.let { put("pddOrderAmountCent", it) }
}
}
.toString()
@@ -1,7 +1,12 @@
package cn.ilapage.goauto.agent.ui
import android.Manifest
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService
@@ -51,3 +56,36 @@ object SettingsAvailabilityResolver {
fun editable(stateCode: String, currentTaskId: Long?, testing: Boolean): Boolean =
!testing && stateCode != "BUSY" && currentTaskId == null
}
/**
* 相册权限状态(#276):`MainActivity.onCreate` 只在首次无条件弹一次系统授权框,
* 用户拒绝(尤其"不再询问")后没有任何补救入口。这里补一个可复用的状态查询 +
* 引导跳转,供设置/状态页和图搜执行器共用,模式与 [AccessibilityReadinessDetector] 保持一致。
*/
enum class MediaPermissionReadiness {
GRANTED,
NOT_GRANTED,
}
object MediaPermissionPolicy {
/** 供图搜执行器调用的只读查询:未授权时应返回明确失败原因,不弹窗、不静默失败。 */
fun current(context: Context): MediaPermissionReadiness {
val permission = requiredPermission()
val granted = context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
return if (granted) MediaPermissionReadiness.GRANTED else MediaPermissionReadiness.NOT_GRANTED
}
fun requiredPermission(): String =
if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES
else Manifest.permission.READ_EXTERNAL_STORAGE
/**
* "不再询问"之后系统弹窗不会再出现,只能引导用户去应用详情页手动开启。
* 调用方在 `shouldShowRequestPermissionRationale` 返回 false 且权限仍未授予时使用本方法。
*/
fun appDetailsSettingsIntent(context: Context): Intent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
@@ -70,8 +70,17 @@ class AgentStatusFragment : Fragment() {
private lateinit var taskText: TextView
private lateinit var accessibilityTitle: TextView
private lateinit var accessibilityDetail: TextView
private lateinit var mediaPermissionTitle: TextView
private lateinit var mediaPermissionDetail: TextView
private lateinit var mediaPermissionAction: android.widget.Button
private lateinit var deviceText: TextView
private val mediaPermissionRequestLauncher by lazy {
registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.RequestPermission()) {
refreshStatus()
}
}
private var receiverRegistered = false
private var mediaPermissionRequestedOnce = false
private val refresh = object : Runnable {
override fun run() {
@@ -144,6 +153,21 @@ class AgentStatusFragment : Fragment() {
addView(accessibilityTitle)
addView(accessibilityDetail)
}))
addView(context.card(context.cardColumn().apply {
addView(context.label("相册权限(图搜用)", 14f, context.getColor(R.color.agent_text_muted)))
mediaPermissionTitle = context.label("正在检查", 18f, context.getColor(R.color.agent_warning), true)
mediaPermissionDetail = context.label("—", 14f, context.getColor(R.color.agent_text_muted)).apply {
setPadding(0, context.dp(6), 0, 0)
}
addView(mediaPermissionTitle)
addView(mediaPermissionDetail)
mediaPermissionAction = android.widget.Button(context).apply {
text = "重新授权"
setPadding(0, context.dp(8), 0, 0)
setOnClickListener { requestMediaPermission() }
}
addView(mediaPermissionAction)
}))
}
refreshLayout = SwipeRefreshLayout(context).apply {
setColorSchemeResources(R.color.agent_primary_light)
@@ -258,10 +282,44 @@ class AgentStatusFragment : Fragment() {
}
accessibilityDetail.text = AgentStatusTextPolicy.accessibilityDetail(readiness).orEmpty()
accessibilityDetail.visibility = if (accessibilityDetail.text.isEmpty()) View.GONE else View.VISIBLE
val mediaReadiness = MediaPermissionPolicy.current(context)
when (mediaReadiness) {
MediaPermissionReadiness.GRANTED -> {
mediaPermissionTitle.text = "已授权"
mediaPermissionTitle.setTextColor(context.getColor(R.color.agent_primary_light))
mediaPermissionDetail.text = "图搜任务可以正常准备参考图"
mediaPermissionAction.visibility = View.GONE
}
MediaPermissionReadiness.NOT_GRANTED -> {
mediaPermissionTitle.text = "未授权"
mediaPermissionTitle.setTextColor(context.getColor(R.color.agent_error))
mediaPermissionDetail.text = "未授权时图搜任务会直接失败,不影响普通采集/采购"
mediaPermissionAction.visibility = View.VISIBLE
}
}
val displayName = settingsStore.deviceName().ifBlank { "本机" }
deviceText.text = "$displayName · ${state.deviceId.takeIf { it > 0 }?.let { "#$it" } ?: "未注册"}"
}
private fun requestMediaPermission() {
val context = requireContext()
val permission = MediaPermissionPolicy.requiredPermission()
if (shouldShowRequestPermissionRationale(permission)) {
mediaPermissionRequestLauncher.launch(permission)
return
}
// 首次请求,或用户已选择"不再询问":先尝试系统弹窗;若此前已经拒绝过一次且
// rationale 也不再展示(即"不再询问"状态),跳转到应用详情页由用户手动开启。
if (context.checkSelfPermission(permission) != android.content.pm.PackageManager.PERMISSION_GRANTED &&
!mediaPermissionRequestedOnce
) {
mediaPermissionRequestedOnce = true
mediaPermissionRequestLauncher.launch(permission)
} else {
startActivity(MediaPermissionPolicy.appDetailsSettingsIntent(context))
}
}
private fun registerCheckReceiver() {
if (receiverRegistered) return
val filter = IntentFilter(AgentForegroundService.ACTION_CHECK_RESULT)
@@ -141,8 +141,16 @@ class TaskHistoryFragment : Fragment() {
private val imageLoader = HistoryImageLoader()
private val imageRequests = mutableListOf<HistoryImageRequest>()
private var currentPageReceiverRegistered = false
private var backfillPanel: LinearLayout? = null
private val currentPageReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AgentForegroundService.ACTION_BACKFILL_STATE) {
renderBackfill()
if (!collection && isResumed && !AgentForegroundService.backfillState.running) {
detailState.taskId?.let(::loadPurchaseDetail) ?: load()
}
return
}
if (intent?.action != AgentForegroundService.ACTION_CURRENT_PAGE_RESULT) return
val message = intent.getStringExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_MESSAGE).orEmpty()
val taskId = intent.getLongExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_TASK_ID, 0L)
@@ -182,6 +190,11 @@ class TaskHistoryFragment : Fragment() {
pageColumn = context.column()
pageColumn.addView(context.screenTitle(if (collection) "采集记录" else "采购记录"))
pageColumn.addView(buildSearch())
if (!collection) {
backfillPanel = context.column(0)
pageColumn.addView(backfillPanel)
renderBackfill()
}
pageColumn.addView(buildFilters())
resultColumn = context.column(0).apply { setPadding(0, context.dp(12), 0, 0) }
pageColumn.addView(resultColumn, resultColumn.fullWidth())
@@ -207,6 +220,7 @@ class TaskHistoryFragment : Fragment() {
}
override fun onDestroyView() {
backfillPanel = null
requestGeneration++
cancelImageRequests()
super.onDestroyView()
@@ -273,6 +287,14 @@ class TaskHistoryFragment : Fragment() {
contentDescription = "采集当前拼多多商品"
setOnClickListener { confirmCurrentPageCollection() }
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
} else {
row.addView(MaterialButton(context).apply {
text = "回填"
textSize = 14f
minimumHeight = context.dp(48)
contentDescription = "回填拼多多订单号和下单时间"
setOnClickListener { showBackfillInput() }
}, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48)).apply { marginStart = context.dp(8) })
}
addView(row, row.fullWidth())
}).apply {
@@ -280,6 +302,75 @@ class TaskHistoryFragment : Fragment() {
}
}
private fun showBackfillInput() {
if (AgentForegroundService.backfillState.running) {
toast("设备忙碌,请稍后操作")
return
}
val context = requireContext()
val input = TextInputEditText(context).apply {
setText("2")
inputType = android.text.InputType.TYPE_CLASS_NUMBER
minimumHeight = context.dp(48)
contentDescription = "回填天数"
selectAll()
}
val field = TextInputLayout(context).apply {
hint = "天数"
helperText = "从确认时刻往前 N×24 小时"
addView(input)
}
val dialog = MaterialAlertDialogBuilder(context).setTitle("回填订单")
.setView(context.cardColumn().apply { addView(field) })
.setNegativeButton("取消", null).setPositiveButton("确认", null).create()
dialog.setOnShowListener {
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val days = input.text.toString().trim()
val now = System.currentTimeMillis()
if (runCatching { cn.ilapage.goauto.agent.automation.OrderBackfillWindow(days, now) }.isFailure) {
field.error = "请输入正整数天数"
return@setOnClickListener
}
val intent = Intent(context, AgentForegroundService::class.java)
.setAction(AgentForegroundService.ACTION_BACKFILL_START).putExtra("days", days).putExtra("confirmedAt", now)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent) else context.startService(intent)
dialog.dismiss()
}
}
dialog.show()
}
private fun renderBackfill() {
val panel = backfillPanel ?: return
val context = context ?: return
val state = AgentForegroundService.backfillState
panel.removeAllViews()
if (state.message.isBlank()) return
panel.addView(context.card(context.cardColumn().apply {
addView(context.label(if (state.running) "正在回填订单" else "回填结果", 16f))
addView(context.label(state.copy(evidence = emptyList()).text(), 14f))
if (state.running) {
addView(MaterialButton(context).apply {
text = "停止"
minimumHeight = context.dp(48)
setOnClickListener {
context.startService(Intent(context, AgentForegroundService::class.java).setAction(AgentForegroundService.ACTION_BACKFILL_STOP))
isEnabled = false
text = "正在停止…"
}
})
}
if (state.evidence.isNotEmpty()) addView(MaterialButton(context).apply {
text = "查看逐条结果"
minimumHeight = context.dp(48)
setOnClickListener {
MaterialAlertDialogBuilder(context).setTitle("回填明细")
.setMessage(state.evidence.joinToString("\n")).setPositiveButton("关闭", null).show()
}
})
}))
}
private fun buildFilters(): View {
val context = requireContext()
val statuses = if (collection) {
@@ -427,6 +518,11 @@ class TaskHistoryFragment : Fragment() {
return context.card(context.cardColumn().apply {
val goodsLabel = item.goodsId.ifBlank { "未识别商品" }
addView(context.label("#${item.taskId} · 第 ${item.attemptNumber} 次 · $goodsLabel", 17f, context.getColor(R.color.agent_text), true))
// #279:图搜任务和普通采集混在同一列表里,加一个行内标记让采购员看出这条商品是机器图搜找到的,
// 不改变既有 `collection` 布尔分叉的界面结构。
if (item.source == "image_search") {
addView(context.label("来源:图搜自动匹配", 13f, context.getColor(R.color.agent_text_muted)).apply { setPadding(0, context.dp(2), 0, 0) })
}
addView(context.label("$status · ${formatTime(item.finishedAt ?: item.createdAt)}", 13f, statusColor(item.status)).apply { setPadding(0, context.dp(4), 0, 0) })
addView(context.label(summary, 14f, context.getColor(R.color.agent_text_muted)).apply { setPadding(0, context.dp(8), 0, 0) })
if (CollectionResetPolicy.showsListAction(item.status)) {
@@ -632,6 +728,7 @@ class TaskHistoryFragment : Fragment() {
private fun registerCurrentPageReceiver() {
if (currentPageReceiverRegistered) return
val filter = IntentFilter(AgentForegroundService.ACTION_CURRENT_PAGE_RESULT)
filter.addAction(AgentForegroundService.ACTION_BACKFILL_STATE)
if (Build.VERSION.SDK_INT >= 33) {
requireContext().registerReceiver(currentPageReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@@ -724,6 +821,12 @@ class TaskHistoryFragment : Fragment() {
append("实际单价:${money(task.actualUnitPriceCent, task.currency)}\n")
append("PDD 订单号:${task.pddOrderNo ?: "—"}\n")
append("下单时间:${task.orderSubmittedAt?.let(::formatTime) ?: "—"}")
val environment = "${AgentSettingsStore(context).serverUrl()}|${runCatching { SecureDeviceStore(context).credentials()?.deviceId }.getOrNull()}"
when (TaskHistoryCache(context).backfillTimeSource(task.taskId, environment)) {
"page" -> append("(页面读取)")
"irreversible_at" -> append("(估算:提交订单时刻)")
"existing_unknown" -> append("(已有值,来源未知)")
}
}
resultColumn.addView(context.card(context.cardColumn().apply {
addView(context.label("CG-${task.taskId}", 20f, context.getColor(R.color.agent_text), true))
@@ -0,0 +1,27 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.AgentCapabilities
import org.junit.Assert.assertTrue
import org.junit.Test
class AgentCapabilitiesTest {
// #277: 服务端 taskCompatible 对 source='image_search' 的任务按这个能力过滤。
// 漏声明时图搜任务永远不会被领取,而且是静默的:任务停在 pending、
// 不失败也不报错。字符串必须与 task.ImageSearchCapability 逐字一致。
@Test
fun `supported capabilities include the image search capability the server gates on`() {
assertTrue(
"AgentCapabilities.supported 缺少 pdd.image-search.v1,图搜任务将无法被领取",
AgentCapabilities.supported.contains("pdd.image-search.v1"),
)
}
@Test
fun `supported capabilities have no duplicates`() {
assertTrue(
"能力清单出现重复项: " + AgentCapabilities.supported,
AgentCapabilities.supported.size == AgentCapabilities.supported.toSet().size,
)
}
}
@@ -0,0 +1,292 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import cn.ilapage.goauto.agent.network.*
import cn.ilapage.goauto.agent.service.*
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Test
import java.io.IOException
import java.util.TimeZone
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
class OrderBackfillTest {
private val zone = TimeZone.getTimeZone("Asia/Shanghai")
private fun time(raw: String): Long = BackfillDetailReader(zone).apply { accept("下单时间:$raw") }.finish().timeMillis!!
private val now = time("2026-09-08 12:00:00")
@Test fun `expanded detail reads order time not group time across frames`() {
val reader = BackfillDetailReader(zone)
reader.accept("测试地址_cg7\n订单编号:TEST-000007\n展开")
reader.accept("下单时间:2026-09-08 10:50:29\n拼单时间:2026-09-08 11:20:35")
val detail = reader.finish()
assertEquals("2026-09-08T10:50:29+08:00", detail.item!!.orderSubmittedAt)
assertEquals("_cg7", detail.item!!.addressSuffix)
assertNull(reader.finish().item)
}
@Test fun `paid amount accepts exact label formats and ignores unrelated prices`() {
for ((label, cents) in listOf("实付:,22.98元,(免运费)" to 2298L, "实付: 22元" to 2200L, "实付,22.9 元" to 2290L)) {
val detail = BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n¥999.00\n$label") }.finish()
assertEquals(cents, detail.item!!.pddOrderAmountCent)
}
}
@Test fun `paid amount ambiguity or malformed candidate omits only amount`() {
for (labels in listOf(
"实付:22.98元\n实付:23.00元",
"实付:22.98元\n实付:坏值元",
"实付:22.345元",
"实付:\n22.98元",
"未实付:99.00元\n预计实付:88.00元",
)) {
val detail = BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n下单时间:2026-09-08 10:00:00\n$labels") }.finish()
assertNotNull(detail.item)
assertNull(labels, detail.item!!.pddOrderAmountCent)
assertEquals("TEST-000007", detail.item!!.pddOrderNo)
}
val duplicate = BackfillDetailReader(zone).apply {
accept("_cg7\n订单号:TEST-000007\n实付:22.98元")
accept("实付:22.98元")
}.finish()
assertEquals(2298L, duplicate.item!!.pddOrderAmountCent)
val reused = BackfillDetailReader(zone)
reused.accept("_cg7\n订单号:TEST-000007\n实付:1.00元")
assertEquals(100L, reused.finish().item!!.pddOrderAmountCent)
reused.accept("_cg8\n订单号:TEST-000008\n实付:0元")
assertEquals(0L, reused.finish().item!!.pddOrderAmountCent)
reused.accept("_cg9\n订单号:TEST-000009\n实付:999999999999999999999999.99元")
assertNull(reused.finish().item!!.pddOrderAmountCent)
}
@Test fun `untagged order never becomes retained or uploaded candidate`() {
val reader = BackfillDetailReader(zone)
reader.accept("个人订单\n订单编号:PERSONAL-1\n下单时间:2026-09-08 10:00:00")
assertNull(reader.finish().item)
reader.accept("测试地址_cg7")
assertNull(reader.finish().item) // previous order number has been cleared
val uploaded = mutableListOf<BackfillItem>()
scanner(FakeDriver(tagged = false), uploaded).scan()
assertTrue(uploaded.isEmpty())
}
@Test fun `ambiguous and noncanonical suffixes are skipped and missing time remains optional`() {
for (suffix in listOf("_cg07", "_cg+7", "_cg7", "_cg7abc", "_cg7 _cg8")) {
assertNull(BackfillDetailReader(zone).apply { accept("$suffix\n订单号:TEST-000007") }.finish().item)
}
assertNull(BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n订单号:TEST-000008") }.finish().item)
val missing = BackfillDetailReader(zone).apply { accept("_cg7\n订单号:TEST-000007\n拼单时间:2026-09-08 10:00:00") }.finish()
assertNotNull(missing.item)
assertNull(missing.item!!.orderSubmittedAt)
}
@Test fun `rolling hours boundary and sampled descending cutoff`() {
val window = OrderBackfillWindow("2", now)
assertEquals(now - 48 * 3_600_000L, window.cutoff)
assertTrue(window.includes(window.cutoff))
assertFalse(window.includes(window.cutoff - 1))
repeat(4) { assertFalse(window.observe(window.cutoff + 10 - it)) }
assertTrue(window.observe(window.cutoff - 1))
assertEquals(Long.MIN_VALUE, OrderBackfillWindow("99999999999999999999999999", now).cutoff)
}
@Test fun `time reversal and missing times disable early stop`() {
val window = OrderBackfillWindow("2", now)
assertFalse(window.observe(now - 100))
assertFalse(window.observe(now))
repeat(10) { assertFalse(window.observe(window.cutoff - it - 1)) }
assertTrue(window.nonDescending)
val unknown = OrderBackfillWindow("2", now)
unknown.observe(null)
repeat(10) { assertFalse(unknown.observe(unknown.cutoff - it - 1)) }
}
@Test fun `scanner expands and stops after validated prefix passes rolling cutoff`() {
val driver = FakeDriver()
val items = mutableListOf<BackfillItem>()
val result = scanner(driver, items).scan()
assertEquals(5, driver.opened)
assertEquals(5, driver.expansions)
assertTrue(result.contains("已达指定天数"))
assertTrue(result.contains("未完整扫描"))
assertTrue(items.all { !it.orderSubmittedAt.orEmpty().contains("11:20") })
}
@Test fun `unordered list scans to internal cap and reports incomplete`() {
val window = OrderBackfillWindow("2", now)
val driver = FakeDriver(unordered = true)
val result = OrderBackfillScanner(driver, window, {}, {}, {}).scan()
assertTrue(window.nonDescending)
assertEquals(200, driver.opened)
assertTrue(result.contains("未完整扫描"))
assertTrue(result.contains(OrderBackfillWindow.UNORDERED))
}
@Test fun `mutex occupied rejects and simultaneous double tap has one winner and finally releases`() {
val mutex = TaskExecutionMutex()
val guard = OrderBackfillGuard(mutex)
mutex.tryAcquire(7)
assertFalse(guard.tryAcquire())
mutex.release(7)
val start = CountDownLatch(1)
val complete = CountDownLatch(2)
val won = AtomicInteger()
val pool = Executors.newFixedThreadPool(2)
repeat(2) { pool.execute { start.await(); if (guard.tryAcquire()) won.incrementAndGet(); complete.countDown() } }
start.countDown()
complete.await()
pool.shutdownNow()
assertEquals(1, won.get())
assertFalse(mutex.tryAcquire(8))
try { guard.cancelled.set(true); throw IllegalStateException("cancelled") } catch (_: IllegalStateException) { } finally { guard.release() }
assertNull(mutex.currentTaskId())
assertTrue(guard.tryAcquire())
assertFalse(guard.cancelled.get())
guard.release()
}
@Test fun `permanent business errors never retry regardless of server retryable flag`() {
val codes = listOf("PURCHASE_BACKFILL_SUFFIX_INVALID", "PURCHASE_TASK_NOT_FOUND", "PURCHASE_BACKFILL_DEVICE_MISMATCH",
"PURCHASE_STATE_CONFLICT", "PURCHASE_INVALID_REQUEST", "PURCHASE_ORDER_TIME_INVALID", "PURCHASE_ORDER_TIME_MISSING",
"PURCHASE_BACKFILL_ORDER_CONFLICT", "PURCHASE_BACKFILL_BATCH_CONFLICT", "PURCHASE_BACKFILL_ORDER_ALREADY_USED")
codes.forEach { code ->
var calls = 0
val results = mutableListOf<BackfillResult>()
OrderBackfillUpload({ _, _ -> calls++; listOf(failure(code)) }, {}, {}).upload(listOf(item), results::add)
assertEquals(1, calls)
assertTrue(results.single().display().contains("需人工检查"))
}
var calls = 0
OrderBackfillUpload({ _, _ -> calls++; throw AgentApiException(409, codes.first(), "hidden", true) }, {}, {})
.upload(listOf(item)) { assertTrue(it.needsReview) }
assertEquals(1, calls)
}
@Test fun `network and internal errors retry bounded with stable transport request id`() {
val ids = mutableListOf<String>()
val results = mutableListOf<BackfillResult>()
OrderBackfillUpload({ id, _ -> ids += id; throw IOException("private body") }, {}, {})
.upload(listOf(item), results::add)
assertEquals(3, ids.size)
assertEquals(1, ids.toSet().size)
assertFalse(results.single().needsReview)
assertFalse(results.single().display().contains("private"))
var calls = 0
OrderBackfillUpload({ _, _ -> calls++; listOf(failure("INTERNAL_ERROR")) }, {}, {})
.upload(listOf(item)) { assertTrue(it.display().contains("重试耗尽")) }
assertEquals(3, calls)
}
@Test fun `partial response only retries transient items and preserves acknowledged success`() {
val sizes = mutableListOf<Int>()
val results = mutableListOf<BackfillResult>()
val second = item.copy(addressSuffix = "_cg8", pddOrderNo = "TEST-000008")
OrderBackfillUpload({ _, items ->
sizes += items.size
if (items.size == 2) listOf(success(), failure("INTERNAL_ERROR").copy(index = 1))
else listOf(failure("PURCHASE_STATE_CONFLICT"))
}, {}, {}).upload(listOf(item, second), results::add)
assertEquals(listOf(2, 1), sizes)
assertTrue(results.first().success)
assertEquals(1, results.last().index)
}
@Test fun `cancellation prevents upload and invalid response cannot confirm cache`() {
var calls = 0
try {
OrderBackfillUpload({ _, _ -> calls++; listOf(success()) }, { error("stopped") }, {}).upload(listOf(item)) { fail() }
fail()
} catch (_: IllegalStateException) { }
assertEquals(0, calls)
try {
OrderBackfillUpload({ _, _ -> listOf(success().copy(taskId = 99)) }, {}, {}).upload(listOf(item)) { fail() }
fail()
} catch (_: IllegalStateException) { }
}
@Test fun `payload contains only suffix order and optional RFC3339 time`() {
val payload = backfillPayload("test", listOf(item))
assertEquals(setOf("requestId", "items"), payload.keySet())
assertEquals(setOf("addressSuffix", "pddOrderNo"), payload.getJSONArray("items").getJSONObject(0).keySet())
val parsed = parseBackfillResults(JSONObject("""{"items":[{"index":0,"taskId":7,"result":"backfilled","code":"BACKFILLED","status":"order_created","statusVersion":3,"pddOrderNo":"TEST-000007","orderSubmittedAt":"2026-09-08T10:00:00+08:00","timeSource":"irreversible_at","retryable":false}]}"""))
assertTrue(parsed.single().display().contains("估算"))
assertNull(parsed.single().pddOrderAmountCent)
assertNull(parsed.single().warningCode)
assertTrue(parsed.single().copy(timeSource = "page").display().contains("页面下单时间"))
val warning = parseBackfillResults(JSONObject("""{"items":[{"index":0,"taskId":7,"result":"already_backfilled","code":"ALREADY_BACKFILLED","status":"order_created","statusVersion":4,"pddOrderNo":"TEST-000007","pddOrderAmountCent":2298,"timeSource":"page","warningCode":"PURCHASE_BACKFILL_AMOUNT_CONFLICT","warningMessage":"已有不同实付金额,本次未覆盖","retryable":false}]}""")).single()
assertEquals(2298L, warning.pddOrderAmountCent)
assertTrue(warning.display().contains("PURCHASE_BACKFILL_AMOUNT_CONFLICT"))
val amountPayload = backfillPayload("test", listOf(item.copy(pddOrderAmountCent = 2298)))
assertEquals(2298L, amountPayload.getJSONArray("items").getJSONObject(0).getLong("pddOrderAmountCent"))
}
@Test fun `cache updates confirmed server facts only and clears old errors`() {
val task = JSONObject().put("taskId", 7).put("status", "order_result_unknown").put("errorCode", "OLD").put("errorMessage", "old")
cn.ilapage.goauto.agent.persistence.applyBackfillFacts(task, failure("PURCHASE_BACKFILL_ORDER_CONFLICT"))
assertEquals("order_result_unknown", task.getString("status"))
assertEquals("OLD", task.getString("errorCode"))
cn.ilapage.goauto.agent.persistence.applyBackfillFacts(task, success())
assertEquals("order_created", task.getString("status"))
assertEquals(item.pddOrderNo, task.getString("pddOrderNo"))
assertTrue(task.isNull("errorCode"))
assertTrue(task.isNull("errorMessage"))
assertFalse(task.getBoolean("retryable"))
val warning = success().copy(pddOrderAmountCent = 2298, warningCode = "PURCHASE_BACKFILL_AMOUNT_CONFLICT", warningMessage = "conflict")
cn.ilapage.goauto.agent.persistence.applyBackfillFacts(task, warning)
assertEquals(2298L, task.getLong("pddOrderAmountCent"))
assertEquals("PURCHASE_BACKFILL_AMOUNT_CONFLICT", task.getString("backfillWarningCode"))
assertTrue(task.isNull("errorCode"))
}
@Test fun `expansion requires order information and product snapshot row`() {
val expand = node("0/1", "展开")
assertNull(BackfillPagePolicy.expansion(page(node("0/0", "订单编号:TEST-000007"), expand)))
assertEquals(expand, BackfillPagePolicy.expansion(page(node("0/0", "订单编号:TEST-000007"), expand, node("0/2", "商品快照"))))
}
@Test fun `dangerous nodes ancestors and overlapping actions never become click targets`() {
BackfillPagePolicy.forbidden.forEach { label ->
val dangerous = node("0/0", label)
assertFalse(BackfillPagePolicy.safe(page(dangerous), dangerous))
val parent = node("0", "查看详情")
assertFalse(BackfillPagePolicy.safe(page(parent, dangerous), parent))
val adjacent = node("0/1", "查看详情")
assertFalse(BackfillPagePolicy.safe(page(adjacent, dangerous), adjacent))
}
}
private val item = BackfillItem("_cg7", "TEST-000007", null)
private fun failure(code: String) = BackfillResult(0, 7, "failed", code, "", 0, null, null, "")
private fun success() = BackfillResult(0, 7, "backfilled", "BACKFILLED", "order_created", 3, item.pddOrderNo, null, "page")
private fun scanner(driver: FakeDriver, items: MutableList<BackfillItem>) = OrderBackfillScanner(driver, OrderBackfillWindow("2", now), {}, items::add, {})
private fun node(path: String, label: String, selected: Boolean = false) = SnapshotNode(path, path.substringBeforeLast('/'), label, null, null, "View",
NodeBounds(0, 0, 200, 60), true, false, selected, false, true, true)
private fun page(vararg nodes: SnapshotNode) = UiSnapshot(BackfillPagePolicy.PDD, "com.xunmeng.pinduoduo.activity.NewPageActivity", nodes.toList())
private inner class FakeDriver(val tagged: Boolean = true, val unordered: Boolean = false) : BackfillDriver {
var opened = 0
var expansions = 0
var index = 0
var inDetail = false
var expanded = false
override fun openOrders() = Unit
override fun pause() = Unit
override fun capture(): UiSnapshot {
if (!inDetail) return page(node("0/0", "我的订单"), node("0/1", "全部", true), node("0/2", "查看详情"), node("0/2/0", "合成卡片 $index"))
val date = if (unordered && index == 1) "2026-09-08 11:00:00" else if (index < 4) "2026-09-08 10:00:00" else "2026-09-05 10:00:00"
return page(node("0/0", "订单详情"), node("0/1", "订单编号:TEST-${100000 + index}"),
node("0/2", if (tagged) "合成地址_cg${index + 1}" else "无后缀合成地址"),
node("0/4", "商品快照"),
node("0/3", if (expanded) "下单时间:$date\n拼单时间:2026-09-08 11:20:00" else "展开"))
}
override fun click(node: SnapshotNode): Boolean {
if (node.label == "展开") { expanded = true; expansions++ } else { inDetail = true; opened++ }
return true
}
override fun scroll() = false
override fun back(): Boolean { inDetail = false; expanded = false; index++; return true }
}
}
@@ -18,6 +18,104 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseLiveAutomationTest {
@Test
fun `two second payment transition and two needed scrolls yield order and payable amount without clicks`() {
val driver = ReadOnlyOrderDriver(paymentMs = 2500)
val order = PurchaseLiveAutomation(driver, pause = { driver.elapsed += it }).readOrderResult()
assertEquals("PDD-DEMO-325", order?.orderNo)
assertEquals("2026-09-19T03:21:43Z", order?.submittedAt)
assertEquals(1300L, order?.pddOrderAmountCent)
assertEquals(1, driver.backs)
assertEquals(2, driver.swipes)
assertEquals(0, driver.clicks)
}
@Test
fun `unpaid page arriving near original limit gets independent read budget`() {
val driver = ReadOnlyOrderDriver(preludeSamples = 58)
val order = PurchaseLiveAutomation(driver, pause = { driver.elapsed += it }).readOrderResult()
assertEquals("PDD-DEMO-325", order?.orderNo)
assertTrue(driver.captures > 60)
assertEquals(2, driver.swipes)
assertEquals(0, driver.clicks)
}
@Test
fun `unpaid scrolling is immediate and bounded when order never becomes visible`() {
val driver = ReadOnlyOrderDriver(requiredSwipes = 100)
val automation = PurchaseLiveAutomation(driver, pause = { driver.elapsed += it })
assertEquals(null, automation.readOrderResult())
assertEquals(4, driver.swipes)
assertEquals(0L, driver.firstSwipeAt)
assertTrue(driver.captures <= 30)
assertEquals(0, driver.clicks)
}
@Test
fun `already visible order never scrolls and missing or conflicting payable amount never blocks order`() {
val cases = listOf(
emptyList<String>() to null,
listOf("拼单价¥16", "平台优惠-¥3", "实付:13元") to null,
listOf("应付:13元", "应付:14元") to null,
listOf("应付:-13元", "应付:13.123元", "预计应付:13元") to null,
listOf("应付:999999999999999999999元") to null,
listOf("应付:13.20元", "应付:13.20元") to 1320L,
listOf("应付:0元") to 0L,
)
for ((amounts, expected) in cases) {
val driver = ReadOnlyOrderDriver(requiredSwipes = 0, amounts = amounts)
val order = PurchaseLiveAutomation(driver, pause = {}).readOrderResult()
assertEquals("PDD-DEMO-325", order?.orderNo)
assertEquals(expected, order?.pddOrderAmountCent)
assertEquals(0, driver.swipes)
assertEquals(0, driver.clicks)
}
}
private class ReadOnlyOrderDriver(
val paymentMs: Long = 0,
val preludeSamples: Int = 0,
val requiredSwipes: Int = 2,
val amounts: List<String> = listOf("拼单价¥16", "平台优惠-¥3", "已优惠3元使用3元平台无门槛券,应付:,13元,(免运费)"),
) : PurchaseUiDriver by LiveDriver() {
var elapsed = 0L
var captures = 0
var backs = 0
var swipes = 0
var clicks = 0
var firstSwipeAt: Long? = null
override fun capture(): UiSnapshot {
captures++
if (captures <= preludeSamples) return UiSnapshot(PDD, ACTIVITY, emptyList())
if (elapsed < paymentMs) return UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(node("立即支付")))
return UiSnapshot(PDD, ACTIVITY, buildList {
add(node("待付款"))
// Amount scrolls away before the order fields become visible.
if (swipes == 0) amounts.forEach { add(node(it)) }
add(node("订单号:PDD-DEMO-325", swipes >= requiredSwipes))
add(node("下单时间:2026-09-19 11:21:43", swipes >= requiredSwipes))
add(node("去支付"))
})
}
override fun backPurchase(): Boolean { backs++; return true }
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
assertEquals(SwipeDirection.UP, direction)
assertTrue(elapsed >= paymentMs)
if (firstSwipeAt == null) firstSwipeAt = elapsed
swipes++
return true
}
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicks++
throw AssertionError("Read-only unpaid branch must never click")
}
private fun node(text: String, visible: Boolean = true) = SnapshotNode(
text, null, text, null, null, "android.widget.TextView",
NodeBounds(0, 100, 500, if (visible) 180 else 100),
false, false, false, false, true, visible,
)
}
@Test
fun `verified spec panel advances through one exact confirm target`() {
val driver = SpecConfirmationDriver()
@@ -317,6 +415,35 @@ class PurchaseLiveAutomationTest {
assertFalse(driver.clicked.any { it.contains("支付") })
}
// `[必须]` #302:卡在支付页放弃时,paymentPageObserved 必须为 true——这正是
// 「大概率已经下单只是没读到证据」的信号,区分于真的什么都没发生的失败。
@Test
fun `payment repeated failure marks paymentPageObserved so it can be triaged later`() {
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)
assertTrue(automation.lastOrderReadFailure?.paymentPageObserved ?: false)
}
// 从未出现过支付/待付款文案的失败不该被标记,否则这个字段就没有区分价值了。
@Test
fun `a failure that never saw the payment page leaves paymentPageObserved false`() {
val driver = LiveDriver(postSubmitCaptureSequence = List(15) { "empty" })
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg30")
automation.finalConfirmation(input().copy(addressSuffix = "_cg30"), address)
automation.submitOrderOnce()
assertEquals(null, automation.readOrderResult())
assertEquals("PURCHASE_ORDER_EMPTY_TIMEOUT", automation.lastOrderReadFailure?.code)
assertFalse(automation.lastOrderReadFailure?.paymentPageObserved ?: true)
}
@Test
fun `continuous payment activity still stops at bounded post back samples without payment clicks`() {
val driver = LiveDriver(postSubmitCaptureSequence = List(4) { "payment" })
@@ -329,10 +456,10 @@ class PurchaseLiveAutomationTest {
assertEquals("PURCHASE_ORDER_PAYMENT_REPEATED", automation.lastOrderReadFailure?.code)
assertEquals(
"支付页安全返回后持续无订单证据,已停止自动核单" +
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=3]",
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=25]",
automation.lastOrderReadFailure?.message,
)
assertEquals(4, driver.postSubmitCaptureCount)
assertEquals(26, driver.postSubmitCaptureCount)
assertEquals(1, driver.postSubmitBackCount)
assertEquals(0, driver.genericSwipes)
assertFalse(driver.clicked.any { it.contains("支付") })
@@ -8,6 +8,16 @@ import org.junit.Assert.assertFalse
import org.junit.Test
class PurchaseResultPayloadTest {
@Test
fun `created order serializes optional payable total separately from unit price`() {
val outcome = PurchaseExecutionOutcome("order_created", message = "test", actualUnitPriceCent = 1600, pddOrderAmountCent = 1300)
val payload = JSONObject(purchaseResultPayload("r", "a", outcome))
assertEquals(1300L, payload.getLong("pddOrderAmountCent"))
assertEquals(1600L, payload.getLong("actualUnitPriceCent"))
assertFalse(JSONObject(purchaseResultPayload("r", "a", outcome.copy(pddOrderAmountCent = null))).has("pddOrderAmountCent"))
assertFalse(JSONObject(purchaseResultPayload("r", "a", outcome.copy(resultType = "order_result_unknown"))).has("pddOrderAmountCent"))
}
@Test
fun `unknown order result keeps stable failure and verified price without order fields`() {
val payload = JSONObject(purchaseResultPayload(
@@ -0,0 +1,127 @@
package cn.ilapage.goauto.agent.automation
import cn.ilapage.goauto.agent.network.ImageSearchImage
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 ImageSearchAssetPolicyTest {
private fun jpegBytes(payloadSize: Int = 10): ByteArray {
val body = ByteArray(payloadSize) { 0x11 }
return byteArrayOf(0xff.toByte(), 0xd8.toByte()) + body + byteArrayOf(0xff.toByte(), 0xd9.toByte())
}
@Test
fun `valid returns true for matching jpeg with correct markers and hash`() {
val bytes = jpegBytes()
val reference = ImageSearchImage(
imageUrl = "https://example.com/a.jpg",
mediaType = "image/jpeg",
sizeBytes = bytes.size.toLong(),
sha256 = ImageSearchAssetPolicy.sha256(bytes),
)
assertTrue(ImageSearchAssetPolicy.valid(bytes, reference))
}
@Test
fun `valid rejects jpeg missing markers`() {
val bytes = ByteArray(20) { 0x11 } // no ff d8 / ff d9
val reference = ImageSearchImage(
imageUrl = "https://example.com/a.jpg",
mediaType = "image/jpeg",
sizeBytes = bytes.size.toLong(),
sha256 = ImageSearchAssetPolicy.sha256(bytes),
)
assertFalse(ImageSearchAssetPolicy.valid(bytes, reference))
}
@Test
fun `valid rejects size mismatch`() {
val bytes = jpegBytes()
val reference = ImageSearchImage(
imageUrl = "https://example.com/a.jpg",
mediaType = "image/jpeg",
sizeBytes = bytes.size.toLong() + 1,
sha256 = ImageSearchAssetPolicy.sha256(bytes),
)
assertFalse(ImageSearchAssetPolicy.valid(bytes, reference))
}
@Test
fun `valid rejects hash mismatch`() {
val bytes = jpegBytes()
val reference = ImageSearchImage(
imageUrl = "https://example.com/a.jpg",
mediaType = "image/jpeg",
sizeBytes = bytes.size.toLong(),
sha256 = "0".repeat(64),
)
assertFalse(ImageSearchAssetPolicy.valid(bytes, reference))
}
@Test
fun `valid rejects unsupported media type`() {
val bytes = jpegBytes()
val reference = ImageSearchImage(
imageUrl = "https://example.com/a.gif",
mediaType = "image/gif",
sizeBytes = bytes.size.toLong(),
sha256 = ImageSearchAssetPolicy.sha256(bytes),
)
assertFalse(ImageSearchAssetPolicy.valid(bytes, reference))
}
@Test
fun `valid rejects oversized payload`() {
val bytes = jpegBytes(ImageSearchAssetPolicy.MAX_BYTES)
val reference = ImageSearchImage(
imageUrl = "https://example.com/a.jpg",
mediaType = "image/jpeg",
sizeBytes = bytes.size.toLong(),
sha256 = ImageSearchAssetPolicy.sha256(bytes),
)
assertFalse(ImageSearchAssetPolicy.valid(bytes, reference))
}
@Test
fun `hasJpegMarkers detects ff d8 ff d9 boundaries`() {
assertTrue(ImageSearchAssetPolicy.hasJpegMarkers(jpegBytes()))
assertFalse(ImageSearchAssetPolicy.hasJpegMarkers(byteArrayOf(1, 2, 3)))
}
@Test
fun `targetScaledSize returns null when already above minimum edge`() {
assertNull(ImageSearchAssetPolicy.targetScaledSize(1200, 1200))
assertNull(ImageSearchAssetPolicy.targetScaledSize(960, 960))
}
@Test
fun `targetScaledSize scales up small square image to minimum edge`() {
val (w, h) = ImageSearchAssetPolicy.targetScaledSize(480, 480)!!
assertEquals(960, w)
assertEquals(960, h)
}
@Test
fun `targetScaledSize preserves aspect ratio for narrow image`() {
// width limits: scale = 960/100 = 9.6 -> height 200*9.6=1920
val (w, h) = ImageSearchAssetPolicy.targetScaledSize(100, 200)!!
assertEquals(960, w)
assertEquals(1920, h)
}
@Test
fun `targetScaledSize gives up when scaling would exceed max edge`() {
// width 10 -> scale 96 -> height 10*96=960 within max, but width*96=960... choose extreme
assertNull(ImageSearchAssetPolicy.targetScaledSize(1, 5000))
}
@Test
fun `targetScaledSize rejects non positive dimensions`() {
assertNull(ImageSearchAssetPolicy.targetScaledSize(0, 100))
assertNull(ImageSearchAssetPolicy.targetScaledSize(100, 0))
}
}
@@ -0,0 +1,90 @@
package cn.ilapage.goauto.agent.automation
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* #292:返回键推不动页面时不得空转到预算耗尽。
*
* 真机任务 156(2026-09-16)停在 PDD 深层页面,归位状态机 15 秒内按满 50 次返回仍未回到
* 首页,报 IMAGE_SEARCH_ENTRY_NOT_FOUND。根因是 BACK 分支按完就走、固定 300ms 盲等,
* 抓到的是切换动画中间态,被归为 OTHER_PDD 后继续按。
*/
class ImageSearchBackRecoveryTest {
/** 一个永远停在「其它拼多多页面」的驱动:返回键按多少次都不改变页面形态。 */
private class StuckDriver : PddCollectorDriver {
var backCount = 0
override fun capture(): UiSnapshot = UiSnapshot(
packageName = "com.xunmeng.pinduoduo",
activityName = "com.xunmeng.pinduoduo.activity.NewPageActivity",
nodes = listOf(node("某个商品页")),
)
override fun clickFresh(target: SnapshotNode) = FreshActionResult.SUCCESS
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false
override fun pullDownGoodsPage() = false
override fun back(): Boolean { backCount++; return true }
}
private companion object {
fun node(text: String) = SnapshotNode(
path = "0", parentPath = null, text = text, contentDescription = null,
resourceId = null, className = "android.widget.TextView",
bounds = NodeBounds(0, 900, 200, 960),
clickable = false, scrollable = false, selected = false,
checked = false, enabled = true, visible = true,
)
}
@Test
fun `stalled backs trigger a clear-top relaunch instead of burning the budget`() {
val driver = StuckDriver()
var resets = 0
val automation = PinduoduoImageSearchAutomation(
driver = driver,
currentPackage = { "com.xunmeng.pinduoduo" },
pause = { /* 测试里不真的睡 */ },
)
val result = automation.enterAndSelectImage(
launchPdd = { PddLaunchResult.STARTED },
resetPddToHome = { resets++; PddLaunchResult.STARTED },
)
// 页面始终推不动,最终仍然明确失败——不静默成功、不无限重试。
assertEquals("IMAGE_SEARCH_ENTRY_NOT_FOUND", result.code)
// 关键:不再是「按满 50 次返回」,每 MAX_STALLED_BACKS 次就改用清栈重启。
assertTrue("返回推不动时应当改用清栈重启,实际重启 $resets 次", resets > 0)
}
@Test
fun `a back that does move the page does not count as stalled`() {
// 能推动页面时不应触发清栈重启:驱动第一次返回后就变成首页形态。
var moved = false
val driver = object : PddCollectorDriver {
override fun capture(): UiSnapshot = UiSnapshot(
packageName = "com.xunmeng.pinduoduo",
activityName = "com.xunmeng.pinduoduo.ui.activity.MainFrameActivity",
nodes = if (moved) {
listOf(
node("拍照搜索").copy(
clickable = true,
bounds = NodeBounds(900, 100, 1000, 200),
contentDescription = "拍照搜索",
),
)
} else {
listOf(node("某个商品页"))
},
)
override fun clickFresh(target: SnapshotNode) = FreshActionResult.SUCCESS
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false
override fun pullDownGoodsPage() = false
override fun back(): Boolean { moved = true; return true }
}
var resets = 0
PinduoduoImageSearchAutomation(driver, { "com.xunmeng.pinduoduo" }, pause = {})
.enterAndSelectImage({ PddLaunchResult.STARTED }, { resets++; PddLaunchResult.STARTED })
assertEquals("页面被返回键推动了,不该触发清栈重启", 0, resets)
}
}
@@ -0,0 +1,260 @@
package cn.ilapage.goauto.agent.automation
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 ImageSearchRecoveryPolicyTest {
private val entryTexts = listOf("我的相册", "最近搜索", "历史浏览", "点击拍照", "最近项目")
private val resultsTexts = listOf("搜图片同款", "综合", "销量", "价格", "品牌")
private val retryTexts = listOf("请对准商品或码,保持手机稳定", "取消", "再试一次")
private val homeTexts = listOf("拍照搜索", "首页", "推荐")
@Test
fun `classify recognizes non-pdd package regardless of texts`() {
val kind = ImageSearchRecoveryPolicy.classify("com.android.launcher", entryTexts, 0, hasClickableCameraEntry = false)
assertEquals(ImageSearchPageKind.NON_PDD, kind)
}
@Test
fun `classify recognizes image search entry page`() {
val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", entryTexts, 0, hasClickableCameraEntry = false)
assertEquals(ImageSearchPageKind.IMAGE_SEARCH_ENTRY, kind)
}
@Test
fun `classify recognizes results page over other markers`() {
val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", resultsTexts, 4, hasClickableCameraEntry = false)
assertEquals(ImageSearchPageKind.IMAGE_SEARCH_RESULTS, kind)
}
@Test
fun `classify recognizes retry dialog`() {
val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", retryTexts, 0, hasClickableCameraEntry = false)
assertEquals(ImageSearchPageKind.RETRY_DIALOG, kind)
}
@Test
fun `classify recognizes order confirm page by read-only markers without treating them as click targets`() {
val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", listOf("提交订单", "收货地址"), 0, hasClickableCameraEntry = false)
assertEquals(ImageSearchPageKind.ORDER_CONFIRM, kind)
// order confirm only ever maps to BACK -- never a click action.
assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(kind))
}
@Test
fun `classify recognizes home page only when a clickable camera entry exists`() {
val kind = ImageSearchRecoveryPolicy.classify(
"com.xunmeng.pinduoduo", homeTexts, 0, hasClickableCameraEntry = true,
)
assertEquals(ImageSearchPageKind.HOME, kind)
}
// 真机核对(2026-09-15):个人中心 tab 的根节点也带 content-desc='拍照搜索',
// 覆盖全屏且不可点,而 visibleTexts() 会把 contentDescription 当文本收集。
// 只看文案会把个人中心判成首页,几何兜底随后会点到「设置」。
@Test
fun `classify refuses home when camera text is present but nothing clickable carries it`() {
val kind = ImageSearchRecoveryPolicy.classify(
"com.xunmeng.pinduoduo", homeTexts, 0, hasClickableCameraEntry = false,
)
assertEquals(ImageSearchPageKind.OTHER_PDD, kind)
}
@Test
fun `classify falls back to other pdd page`() {
val kind = ImageSearchRecoveryPolicy.classify("com.xunmeng.pinduoduo", listOf("商品详情", "加入购物车"), 0, hasClickableCameraEntry = false)
assertEquals(ImageSearchPageKind.OTHER_PDD, kind)
}
@Test
fun `nextAction maps each page kind to the expected recovery action`() {
assertEquals(ImageSearchRecoveryAction.NONE, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.IMAGE_SEARCH_ENTRY))
assertEquals(ImageSearchRecoveryAction.CLICK_CAMERA, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.HOME))
assertEquals(ImageSearchRecoveryAction.LAUNCH_PDD, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.NON_PDD))
assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.IMAGE_SEARCH_RESULTS))
// RETRY_DIALOG 不再是 BACK,理由见下方 retry dialog must be dismissed by cancel。
assertEquals(ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.RETRY_DIALOG))
assertEquals(ImageSearchRecoveryAction.BACK, ImageSearchRecoveryPolicy.nextAction(ImageSearchPageKind.OTHER_PDD))
}
@Test
fun `recovery budget caps at the documented limit`() {
assertFalse(ImageSearchRecoveryPolicy.exceedsRecoveryBudget(ImageSearchRecoveryPolicy.MAX_RECOVERY_ACTIONS - 1))
assertTrue(ImageSearchRecoveryPolicy.exceedsRecoveryBudget(ImageSearchRecoveryPolicy.MAX_RECOVERY_ACTIONS))
}
// `[必须]` 2026-09-15 真机实测:按 BACK 对这个弹窗完全无效,前后两次 uiautomator
// dump 逐字节相同;唯一出口是点「取消」。此前把它映射成 BACK,导致任务在弹窗上
// 空转直到预算耗尽、报 IMAGE_SEARCH_ENTRY_NOT_FOUND,而弹窗一直留在屏幕上。
@Test
fun `retry dialog must be dismissed by cancel, never by back`() {
val kind = ImageSearchRecoveryPolicy.classify(
"com.xunmeng.pinduoduo", retryTexts, 0, hasClickableCameraEntry = false,
)
assertEquals(ImageSearchPageKind.RETRY_DIALOG, kind)
assertEquals(
ImageSearchRecoveryAction.DISMISS_RETRY_DIALOG,
ImageSearchRecoveryPolicy.nextAction(kind),
)
}
// 其余需要归位的页面仍然用返回,不受上面那条影响。
@Test
fun `other pages still recover by back`() {
for (kind in listOf(
ImageSearchPageKind.IMAGE_SEARCH_RESULTS,
ImageSearchPageKind.ORDER_CONFIRM,
ImageSearchPageKind.OTHER_PDD,
)) {
assertEquals(
"$kind 的归位动作被改动了",
ImageSearchRecoveryAction.BACK,
ImageSearchRecoveryPolicy.nextAction(kind),
)
}
}
// 关闭弹窗会退出图搜回到首页,重新进入又会撞上同一个约 4 秒的摄像头识别窗口,
// 因此必须有独立且很小的上限,不能落在通用的 50 次预算里空转数分钟。
@Test
fun `retry dialog recovery budget is separate and small`() {
assertTrue(
"弹窗恢复上限必须显著小于通用恢复预算",
ImageSearchRecoveryPolicy.MAX_RETRY_DIALOG_RECOVERIES in
1 until ImageSearchRecoveryPolicy.MAX_RECOVERY_ACTIONS / 5,
)
}
}
class ImageSearchCandidatePolicyTest {
private fun card(top: Int, left: Int, ad: Boolean = false, loaded: Boolean = true) =
ImageSearchResultCard(top = top, left = left, width = 535, height = 764, texts = if (ad) listOf("广告", "¥19.9") else listOf("¥19.9"), imageLoaded = loaded)
@Test
fun `picks the first card in reading order`() {
val cards = listOf(card(top = 1085, left = 0), card(top = 312, left = 0), card(top = 312, left = 545))
val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards)
assertEquals(312, chosen?.top)
assertEquals(0, chosen?.left)
}
@Test
fun `skips ad cards and takes the next candidate`() {
val cards = listOf(
card(top = 312, left = 0),
card(top = 312, left = 545, ad = true),
card(top = 1085, left = 0, ad = true),
card(top = 1085, left = 545),
)
val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards)
assertEquals(312, chosen?.top)
assertEquals(0, chosen?.left)
}
@Test
fun `tolerates a candidate whose image has not finished loading`() {
val cards = listOf(card(top = 312, left = 0, loaded = false))
val chosen = ImageSearchCandidatePolicy.firstOpenableCandidate(cards)
assertEquals(312, chosen?.top)
}
@Test
fun `returns null when every candidate is an ad`() {
val cards = listOf(card(top = 312, left = 0, ad = true), card(top = 312, left = 545, ad = true))
assertNull(ImageSearchCandidatePolicy.firstOpenableCandidate(cards))
}
@Test
fun `returns null for an empty candidate list`() {
assertNull(ImageSearchCandidatePolicy.firstOpenableCandidate(emptyList()))
}
}
class ImageSearchClickTargetTest {
private fun node(top: Int, left: Int, right: Int, bottom: Int, text: String?) = SnapshotNode(
path = "0/$top/$left", parentPath = "0", text = text, contentDescription = null,
resourceId = null, className = "android.widget.TextView",
bounds = NodeBounds(left, top, right, bottom),
clickable = false, scrollable = false, selected = false, checked = false,
enabled = true, visible = true,
)
// 真机 dump(2026-09-15)的第一张卡片:[0,600] 535x764。
private val card = ImageSearchResultCard(top = 600, left = 0, width = 535, height = 764, texts = emptyList())
// `[必须]` 卡片容器自身无文案,label 为空串;clickFreshDetailed 重找时用的是
// preferredOrDescendantLabel()(会挖子孙),两者永远对不上,直接 TARGET_NOT_FOUND。
// 所以点击目标必须是卡片内有文案的子节点。
@Test
fun `click target is the topmost labelled node inside the card`() {
val nodes = listOf(
node(600, 0, 535, 1364, null), // 卡片容器本身,无文案
node(1100, 20, 300, 1140, "¥21.8"),
node(1000, 20, 500, 1060, "港风时尚"), // 最靠上的有文案节点
)
val target = ImageSearchCandidatePolicy.clickTargetInside(card, nodes)
assertEquals("港风时尚", target?.label)
}
// 相邻卡片的文案不得被当成本卡片的内容。真机上右侧卡片从 x=545 开始。
@Test
fun `nodes outside the card bounds are ignored`() {
val nodes = listOf(node(1000, 565, 1040, 1060, "森系假两件"))
assertNull(ImageSearchCandidatePolicy.clickTargetInside(card, nodes))
}
@Test
fun `card without any labelled child yields no target`() {
assertNull(ImageSearchCandidatePolicy.clickTargetInside(card, listOf(node(700, 10, 500, 900, null))))
}
}
class ImageSearchMediaRecencyPolicyTest {
@Test
fun `is most recent when our entry sorts first`() {
val entries = listOf(
ImageSearchMediaEntry(id = 100, dateAdded = 2_000),
ImageSearchMediaEntry(id = 90, dateAdded = 1_000),
)
assertTrue(ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId = 100))
}
@Test
fun `fails when a newer video sorts ahead of our image`() {
// Regression for pitfall 2: the recent-items grid mixes photos and videos; if a
// freshly-recorded video is newer than the jpeg we wrote, we must not claim first slot.
val entries = listOf(
ImageSearchMediaEntry(id = 101, dateAdded = 3_000), // newer video, not ours
ImageSearchMediaEntry(id = 100, dateAdded = 2_000), // our prepared jpeg
)
assertFalse(ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId = 100))
}
@Test
fun `fails when our own entry is missing from the query result`() {
val entries = listOf(ImageSearchMediaEntry(id = 5, dateAdded = 1_000))
assertFalse(ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId = 100))
}
@Test
fun `fails on an empty media list rather than guessing`() {
assertFalse(ImageSearchMediaRecencyPolicy.isMostRecent(emptyList(), ownId = 100))
}
@Test
fun `ties break by id so a same-timestamp newer insert still wins`() {
val entries = listOf(
ImageSearchMediaEntry(id = 101, dateAdded = 2_000),
ImageSearchMediaEntry(id = 100, dateAdded = 2_000),
)
assertFalse(ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId = 100))
assertTrue(ImageSearchMediaRecencyPolicy.isMostRecent(entries, ownId = 101))
}
}
@@ -0,0 +1,64 @@
package cn.ilapage.goauto.agent.automation
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PinduoduoImageSearchCriteriaTest {
@Test
fun `isImageSearchEntryPage requires all four signals`() {
val complete = listOf("我的相册", "最近搜索", "历史浏览", "点击拍照即可开始识别")
assertTrue(PinduoduoImageSearchCriteria.isImageSearchEntryPage(complete))
val missingHint = listOf("我的相册", "最近搜索", "历史浏览")
assertFalse(PinduoduoImageSearchCriteria.isImageSearchEntryPage(missingHint))
val alternateHint = listOf("我的相册", "最近搜索", "历史浏览", "开启相机权限")
assertTrue(PinduoduoImageSearchCriteria.isImageSearchEntryPage(alternateHint))
val thirdHintVariant = listOf("我的相册", "最近搜索", "历史浏览", "拍摄清晰照片即可进行自动识别")
assertTrue(PinduoduoImageSearchCriteria.isImageSearchEntryPage(thirdHintVariant))
}
@Test
fun `isImageSearchResultsPage requires title and enough sort controls`() {
assertTrue(PinduoduoImageSearchCriteria.isImageSearchResultsPage(listOf("搜图片同款"), sortControlCount = 3))
assertFalse(PinduoduoImageSearchCriteria.isImageSearchResultsPage(listOf("搜图片同款"), sortControlCount = 2))
assertFalse(PinduoduoImageSearchCriteria.isImageSearchResultsPage(listOf("其他标题"), sortControlCount = 5))
}
@Test
fun `isRetryDialog requires hint text plus both buttons`() {
val texts = listOf("请对准商品或码,保持手机稳定", "取消", "再试一次")
assertTrue(PinduoduoImageSearchCriteria.isRetryDialog(texts))
assertFalse(PinduoduoImageSearchCriteria.isRetryDialog(listOf("请对准商品或码,保持手机稳定", "取消")))
assertFalse(PinduoduoImageSearchCriteria.isRetryDialog(listOf("取消", "再试一次")))
}
@Test
fun `firstRecentImageGridCell picks topmost then leftmost four column cell`() {
val screenWidth = 1080
val cellWidth = screenWidth / 4 // 270
val candidates = listOf(
PinduoduoImageSearchCriteria.GridCandidate(top = 200, left = cellWidth, width = cellWidth, height = cellWidth),
PinduoduoImageSearchCriteria.GridCandidate(top = 100, left = cellWidth * 2, width = cellWidth, height = cellWidth),
PinduoduoImageSearchCriteria.GridCandidate(top = 100, left = 0, width = cellWidth, height = cellWidth),
// not a 4-column cell (too narrow height ratio)
PinduoduoImageSearchCriteria.GridCandidate(top = 50, left = 0, width = cellWidth, height = 10),
)
val result = PinduoduoImageSearchCriteria.firstRecentImageGridCell(candidates, screenWidth)
assertTrue(result != null)
assertTrue(result!!.top == 100 && result.left == 0)
}
@Test
fun `firstRecentImageGridCell returns null when nothing matches grid shape`() {
val screenWidth = 1080
val candidates = listOf(
PinduoduoImageSearchCriteria.GridCandidate(top = 0, left = 0, width = 1080, height = 100),
)
assertNull(PinduoduoImageSearchCriteria.firstRecentImageGridCell(candidates, screenWidth))
}
}
@@ -0,0 +1,50 @@
package cn.ilapage.goauto.agent.service
import org.junit.Assert.assertEquals
import org.junit.Test
class TaskDispatchPolicyTest {
// 四条既有路径的回归。图搜任务由服务端 ORDER BY 垫底(见
// task.Service.Next),客户端这里不参与来源优先级,保持四分支不变。
@Test
fun `spec probe pending waits for purchase match regardless of cooldown`() {
assertEquals(
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH,
TaskDispatchPolicy.decide("spec_probe_pending", collectionCooldownActive = false),
)
assertEquals(
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH,
TaskDispatchPolicy.decide("spec_probe_pending", collectionCooldownActive = true),
)
}
@Test
fun `any other purchase status runs purchase`() {
assertEquals(
TaskDispatchDecision.RUN_PURCHASE,
TaskDispatchPolicy.decide("matched", collectionCooldownActive = false),
)
assertEquals(
TaskDispatchDecision.RUN_PURCHASE,
TaskDispatchPolicy.decide("matched", collectionCooldownActive = true),
)
}
@Test
fun `no purchase status and cooldown active waits for cooldown`() {
assertEquals(
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN,
TaskDispatchPolicy.decide(null, collectionCooldownActive = true),
)
}
@Test
fun `no purchase status and no cooldown checks collection`() {
assertEquals(
TaskDispatchDecision.CHECK_COLLECTION,
TaskDispatchPolicy.decide(null, collectionCooldownActive = false),
)
}
}
+1
View File
@@ -0,0 +1 @@
node_modules/
+7
View File
@@ -0,0 +1,7 @@
# GoAuto PDD 订单回填扩展
Manifest V3 轻量扩展。用户在已登录的 PDD 我的订单页手动开始;扩展读取必要订单事实,先完成本次范围内的 CG 冲突聚合,再通过已授权 Admin Origin 的客户端密钥接口分批提交。
密钥只保存在扩展可信上下文,内容脚本不能读取;网络请求禁止重定向且不携带 Cookie。当前自动化测试只使用合成文本与合成 DOM fixture,不包含真实订单、地址、Cookie 或网页 HTML。
开发验证:`npm test`。解压安装、真实 PDD 遍历和真实 Admin 上传必须另行授权,本仓库验证不会执行这些动作。
+9
View File
@@ -0,0 +1,9 @@
"use strict";const Core=self.GoAutoBackfillCore,SAFE=/^(订单详情|查看详情)$/,RISK=/(付款|支付|确认收货|退款|售后|取消订单|更多)/,END=/^(没有更多了|没有更多|已经到底了)$/,LOGIN=/(请登录|登录后查看|验证码|安全验证)/;
const txt=e=>(e?.innerText||e?.textContent||"").trim(),vis=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0},clickable=e=>e.closest("button,a,[role=button],[tabindex]");
function entries(){const explicit=[...document.querySelectorAll("button,a,[role=button],[tabindex],div,span")].filter(e=>vis(e)&&SAFE.test(txt(e))).map(clickable).filter(Boolean).filter(e=>!RISK.test(txt(e)));if(explicit.length)return[...new Set(explicit)];return[...document.querySelectorAll(".react-base-list > div")].map(card=>[...card.children].slice(1).find(row=>vis(row)&&row.querySelector("img")&&!RISK.test(txt(row)))).filter(Boolean)}
function summary(){const b=txt(document.body);return{ok:true,entryCount:entries().length,endEvidence:[...document.querySelectorAll("div,span,p")].some(e=>vis(e)&&END.test(txt(e))),loginExpired:LOGIN.test(b),empty:/^(暂无订单|没有订单)$/m.test(b),scrollY,scrollHeight:document.documentElement.scrollHeight}}
function labelValue(labels){for(const n of document.querySelectorAll("div,span,p,li")){const own=txt(n).replace(/[::]$/u,"").trim();if(!labels.includes(own))continue;const row=n.parentElement,parts=[...row.children].filter(x=>x!==n).map(x=>txt(x).replace(/复制\s*$/u,"").trim()).filter(Boolean);if(parts.length)return parts.join("")}return""}
function paidValue(){for(const n of document.querySelectorAll("div,span,p,li")){if(!["实付","实际支付"].includes(txt(n).replace(/[::]$/u,"").trim()))continue;for(const x of n.parentElement.children){const value=txt(x);if(x!==n&&Core.moneyCent(value)!=null)return value}}return""}
async function detail(){const controls=[...new Set([...document.querySelectorAll("button,[role=button],[tabindex],div,span")].filter(e=>vis(e)&&/^(展开|展开收货信息|展开订单信息|查看完整信息|订单信息)$/.test(txt(e))).map(clickable).filter(Boolean).filter(e=>!RISK.test(txt(e))))];for(const control of controls.slice(0,2)){control.click();await new Promise(r=>setTimeout(r,100))}let stable=0,last="",until=Date.now()+5000;while(Date.now()<until){await new Promise(r=>setTimeout(r,250));const order=labelValue(["订单号"]),time=labelValue(["下单时间","创建时间"]),v=`${order}|${time}`;stable=order&&time&&v===last?stable+1:0;last=v;if(stable>=2)break}return{address:txt(document.body),orderNo:labelValue(["订单号"]),submittedAt:labelValue(["下单时间","创建时间"]),paidAmount:paidValue()}}
chrome.runtime.onMessage.addListener((m,_s,reply)=>{if(m?.source!=="goauto-service"||!["LIST","SCROLL","OPEN_ENTRY","DETAIL","RESTORE"].includes(m.type))return;(async()=>{if(m.type==="LIST")return summary();if(m.type==="SCROLL"){scrollTo({top:document.documentElement.scrollHeight,behavior:"instant"});return summary()}if(m.type==="RESTORE"){scrollTo({top:Number(m.scrollY)||0,behavior:"instant"});return summary()}if(m.type==="OPEN_ENTRY"){const e=entries();if(!e[m.index])throw Error("安全的订单详情入口不存在");e[m.index].click();return{ok:true}}if(LOGIN.test(txt(document.body)))return{ok:false,loginExpired:true};return{ok:true,classified:Core.classifyDetail(await detail())}})().then(reply).catch(e=>reply({ok:false,error:e.message}));return true});
if(typeof module!=="undefined")module.exports={entries,summary,labelValue,paidValue,detail};
+15
View File
@@ -0,0 +1,15 @@
(function(root){"use strict";
const MAX_BATCH=50,CG=/_cg([1-9]\d*)\b/ig,TERMINAL=new Set(["completed","incomplete","stopped","failed"]);
function normalizeOrigin(raw){const u=new URL(String(raw||"").trim());if(!/^https?:$/.test(u.protocol)||u.username||u.password||u.pathname!=="/"||u.search||u.hash)throw Error("Admin Origin 必须是 http(s) 根地址");return u.origin}
function addressSuffixes(v){return[...new Set([...String(v||"").matchAll(CG)].map(m=>`_cg${m[1]}`))]}
function addressSuffix(v){const a=addressSuffixes(v);return a.length===1?a[0]:""}
function moneyCent(v){const m=String(v||"").trim().replace(/,/g,"").match(/^(?:¥|¥)?\s*(0|[1-9]\d*)(?:\.(\d{1,2}))?\s*元?$/);return m?Number(m[1])*100+Number((m[2]||"").padEnd(2,"0")):null}
function normalizeOrderNo(v){const s=String(v||"").replace(/\s*复制\s*$/u,"").trim();return/^[A-Za-z0-9-]{1,100}$/.test(s)?s:""}
function normalizeTime(v){const m=String(v||"").trim().match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/);if(!m)return null;const p=n=>String(n).padStart(2,"0"),d=new Date(`${m[1]}-${p(m[2])}-${p(m[3])}T${p(m[4])}:${m[5]}:${p(m[6]||0)}+08:00`);return Number.isNaN(d.valueOf())?null:d.toISOString()}
function classifyDetail(f){const s=addressSuffixes(f.address||""),item={addressSuffix:s.length===1?s[0]:"",pddOrderNo:normalizeOrderNo(f.orderNo),orderSubmittedAt:normalizeTime(f.submittedAt),pddOrderAmountCent:f.paidAmount==null||f.paidAmount===""?null:moneyCent(f.paidAmount)};if(s.length>1)return{kind:"error",code:"CG_AMBIGUOUS",item};if(!s.length)return{kind:"skip",code:"CG_MISSING",item};if(!item.pddOrderNo)return{kind:"error",code:"ORDER_NO_INVALID",item};if(!item.orderSubmittedAt)return{kind:"error",code:"ORDER_TIME_INVALID",item};if(f.paidAmount!=null&&f.paidAmount!==""&&item.pddOrderAmountCent==null)return{kind:"error",code:"ORDER_AMOUNT_INVALID",item};return{kind:"candidate",code:"",item}}
function aggregate(rows){const cs=rows.filter(x=>x.kind==="candidate").map(x=>x.item),cg=new Map,orders=new Map,uniq=new Map;for(const x of cs){if(!cg.has(x.addressSuffix))cg.set(x.addressSuffix,new Set);if(!orders.has(x.pddOrderNo))orders.set(x.pddOrderNo,new Set);cg.get(x.addressSuffix).add(x.pddOrderNo);orders.get(x.pddOrderNo).add(x.addressSuffix);uniq.set(`${x.addressSuffix}\0${x.pddOrderNo}`,x)}const cc=new Set([...cg].filter(([,v])=>v.size>1).map(([k])=>k)),oc=new Set([...orders].filter(([,v])=>v.size>1).map(([k])=>k));return{ready:[...uniq.values()].filter(x=>!cc.has(x.addressSuffix)&&!oc.has(x.pddOrderNo)),cgConflicts:[...cc],orderConflicts:[...oc],skippedNoCG:rows.filter(x=>x.code==="CG_MISSING").length,localErrors:rows.filter(x=>x.kind==="error")}}
function batches(a,n=MAX_BATCH){const o=[];for(let i=0;i<a.length;i+=n)o.push(a.slice(i,i+n));return o}
function inWindow(x,start,days){return new Date(x.orderSubmittedAt).valueOf()>=new Date(start).valueOf()-Number(days)*86400000}
function validPddURL(v){try{const u=new URL(v);return u.protocol==="https:"&&u.hostname==="mobile.yangkeduo.com"}catch(_){return false}}
function trustedUISender(s,id){try{const u=new URL(s.url);return s?.id===id&&!s.tab&&u.protocol==="chrome-extension:"&&u.hostname===id}catch(_){return false}}
const api={MAX_BATCH,normalizeOrigin,addressSuffixes,addressSuffix,moneyCent,normalizeOrderNo,normalizeTime,classifyDetail,aggregate,batches,inWindow,validPddURL,trustedUISender,terminal:p=>TERMINAL.has(p)};if(typeof module!=="undefined")module.exports=api;root.GoAutoBackfillCore=api})(typeof self!=="undefined"?self:globalThis);
+16
View File
@@ -0,0 +1,16 @@
{
"manifest_version": 3,
"name": "GoAuto PDD 订单回填",
"version": "0.1.0",
"description": "手动扫描最近 PDD 订单并将必要订单事实回填到已授权的 GoAuto Admin。",
"permissions": ["storage", "tabs"],
"host_permissions": ["https://mobile.yangkeduo.com/*"],
"optional_host_permissions": ["https://*/*", "http://*/*"],
"background": {"service_worker": "service-worker.js"},
"action": {"default_popup": "popup.html"},
"content_scripts": [{
"matches": ["https://mobile.yangkeduo.com/*"],
"js": ["core.js", "content.js"],
"run_at": "document_idle"
}]
}
+545
View File
@@ -0,0 +1,545 @@
{
"name": "goauto-pdd-order-backfill",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goauto-pdd-order-backfill",
"version": "0.1.0",
"devDependencies": {
"jsdom": "^26.1.0"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/css-calc": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/cssstyle": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nwsapi": {
"version": "2.2.27",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.27.tgz",
"integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==",
"dev": true,
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/rrweb-cssom": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tldts": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dev": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
}
}
}
+1
View File
@@ -0,0 +1 @@
{"name":"goauto-pdd-order-backfill","private":true,"version":"0.1.0","scripts":{"test":"node --test test/*.test.js"},"devDependencies":{"jsdom":"^26.1.0"}}
+1
View File
@@ -0,0 +1 @@
:root{font-family:Arial,sans-serif;color:#134e4a;background:#f0fdfa}body{margin:0;width:390px}main{padding:20px}h1{font-size:24px;margin:0 0 6px}.muted,.help{color:#64748b;font-size:12px}label{display:block;font-weight:700;margin-top:14px}input{box-sizing:border-box;width:100%;height:42px;margin-top:6px;border:1px solid #cbd5e1;border-radius:8px;padding:0 12px;font-size:14px}.notice{margin:18px 0;padding:12px;border:1px solid #99f6e4;border-radius:10px;background:#fff;font-size:12px;line-height:1.5}button{width:100%;height:46px;border:0;border-radius:10px;background:#0d9488;color:#fff;font-weight:700;font-size:15px;cursor:pointer;margin-top:10px}.secondary{background:#fff;color:#0d9488;border:1px solid #0d9488}button:disabled,input:disabled{opacity:.55;cursor:not-allowed}#error{color:#b91c1c;font-size:12px}#status{white-space:pre-line;font-size:13px;line-height:1.6;margin-top:12px}
+9
View File
@@ -0,0 +1,9 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><link rel="stylesheet" href="popup.css"><title>PDD 订单回填</title></head><body>
<main><h1>PDD 订单回填</h1><p class="muted">扫描最近订单,聚合冲突后分批上传 Admin。</p>
<label>Admin Origin<input id="origin" type="url" placeholder="https://admin.example.internal"></label>
<label>客户端密钥<input id="key" type="password" autocomplete="off" placeholder="仅保存在扩展可信上下文"></label>
<p class="help">密钥需授权:采购管理 → 回填。</p>
<label>最近天数<input id="days" type="number" min="1" max="30" value="2"></label>
<div class="notice">开始后冻结配置与时间窗口;完成扫描并聚合冲突后才上传。</div>
<button id="start">开始扫描并上传</button><button id="stop" class="secondary">停止</button>
<p id="error" role="alert"></p><section id="status" aria-live="polite"></section><button id="retry" class="secondary" hidden>重试可重试项</button><ol id="results"></ol></main><script src="core.js"></script><script src="popup.js"></script></body></html>
+6
View File
@@ -0,0 +1,6 @@
"use strict";const Core=self.GoAutoBackfillCore,$=id=>document.getElementById(id);let timer;
const command=(type,data={})=>new Promise(resolve=>chrome.runtime.sendMessage({source:"goauto-popup",type,...data},resolve));
async function load(){const c=await chrome.storage.local.get(["adminOrigin","clientKey","days"]);$("origin").value=c.adminOrigin||"";$("key").value=c.clientKey||"";$("days").value=c.days||2;await refresh();timer=setInterval(refresh,750)}
async function refresh(){const q=await command("STATUS"),r=q?.run;if(!r){$("status").textContent="空闲";return}const busy=!Core.terminal(r.phase),complete=r.scanComplete?"已确认列表末尾":"未完整扫描";$("status").textContent=`状态:${r.phase}(${complete})\n已读 ${r.stats.read} · 可提交 ${r.stats.ready} · 已写入 ${r.stats.written}\n冲突 ${r.stats.conflicts} · 无 CG 跳过 ${r.stats.skippedNoCG}${r.incompleteReason?`\n${r.incompleteReason}`:""}${r.error?`\n${r.error.message}`:""}`;for(const id of["origin","key","days","start"])$(id).disabled=busy;$("stop").disabled=!busy;$("retry").hidden=!(r.phase==="incomplete"&&r.batches?.some(b=>b.status==="retryable"));const pending=(r.batches||[]).filter(b=>b.status==="retryable").flatMap(b=>b.results||[]),errors=(r.localErrors||[]).map((x,i)=>({index:i,result:"local_error",code:x.code}));$("results").replaceChildren(...[...(r.results||[]),...pending,...errors].map(x=>{const li=document.createElement("li");li.textContent=`第 ${Number(x.index)+1} 项:${x.result} / ${x.code}`;return li}))}
$("start").addEventListener("click",async()=>{try{$("error").textContent="";const origin=Core.normalizeOrigin($("origin").value),clientKey=$("key").value.trim(),days=Number($("days").value);if(!clientKey||!Number.isInteger(days)||days<1||days>30)throw Error("请填写密钥,最近天数须为 1–30");if(!await chrome.permissions.request({origins:[`${origin}/*`]}))throw Error("未授予此 Admin Origin 的访问权限");await chrome.storage.local.setAccessLevel({accessLevel:"TRUSTED_CONTEXTS"});await chrome.storage.local.set({adminOrigin:origin,clientKey,days});const[tab]=await chrome.tabs.query({active:true,currentWindow:true}),res=await command("START",{tabId:tab.id,clientKey,frozen:{origin,days,startedAt:new Date().toISOString()}});if(!res?.ok)throw Error(res?.error||"启动失败");await refresh()}catch(e){$("error").textContent=e.message}});
$("stop").addEventListener("click",async()=>{await command("STOP");await refresh()});$("retry").addEventListener("click",async()=>{await command("RETRY");await refresh()});addEventListener("unload",()=>clearInterval(timer));load();
+24
View File
@@ -0,0 +1,24 @@
"use strict";importScripts("core.js");const Core=self.GoAutoBackfillCore,STATE="orderBackfillRun",SECRET="orderBackfillSecret";let active=null,starting=false;
const delay=ms=>new Promise(r=>setTimeout(r,ms)),uuid=()=>crypto.randomUUID();
async function secure(){await chrome.storage.local.setAccessLevel({accessLevel:"TRUSTED_CONTEXTS"});await chrome.storage.session.setAccessLevel({accessLevel:"TRUSTED_CONTEXTS"})}
chrome.runtime.onInstalled.addListener(secure);chrome.runtime.onStartup.addListener(()=>{secure();resume()});secure();
async function getRun(){return(await chrome.storage.session.get(STATE))[STATE]||null}async function getSecret(){return(await chrome.storage.session.get(SECRET))[SECRET]||null}
async function write(run){const latest=await getRun();if(latest&&latest.id!==run.id)throw Error("运行已被替换");if(latest?.stopRequested)run.stopRequested=true;await chrome.storage.session.set({[STATE]:run})}
async function cancelled(run){const latest=await getRun();return!latest||latest.id!==run.id||latest.stopRequested}
async function send(run,type,extra={}){if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});let done=false,value,error;chrome.tabs.sendMessage(run.tabId,{source:"goauto-service",type,...extra}).then(v=>{done=true;value=v},e=>{done=true;error=e});const end=Date.now()+(self.__GOAUTO_TEST_TIMEOUT__||15000);while(!done&&Date.now()<end){if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});await delay(100)}if(!done){if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});throw Object.assign(Error("页面响应超时"),{code:"CONTENT_TIMEOUT"})}if(error)throw error;return value}
async function loaded(tabId,timeout=15000){const end=Date.now()+timeout;while(Date.now()<end){if((await chrome.tabs.get(tabId)).status==="complete"){await delay(300);return}await delay(200)}throw Object.assign(Error("页面加载超时"),{code:"PAGE_TIMEOUT"})}
async function navigated(run,from,timeout=15000){const end=Date.now()+timeout;while(Date.now()<end){if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});const tab=await chrome.tabs.get(run.tabId);if(tab.url!==from&&tab.status==="complete"){await delay(300);return}await delay(150)}throw Object.assign(Error("订单详情未打开"),{code:"DETAIL_NOT_OPENED"})}
async function returnList(run){await chrome.tabs.goBack(run.tabId);await loaded(run.tabId);const tab=await chrome.tabs.get(run.tabId);if(!tab.url.startsWith(run.listURL.split("?")[0]))await chrome.tabs.update(run.tabId,{url:run.listURL});await loaded(run.tabId);await send(run,"RESTORE",{scrollY:run.scrollY})}
function isListURL(raw){try{return new URL(raw).pathname==="/orders.html"}catch(_){return false}}
function prepare(run){const inRange=run.rows.filter(x=>x.kind!=="candidate"||Core.inWindow(x.item,run.frozen.startedAt,run.frozen.days));const grouped=Core.aggregate(inRange);run.localErrors=grouped.localErrors;run.stats.ready=grouped.ready.length;run.stats.conflicts=grouped.cgConflicts.length+grouped.orderConflicts.length;run.stats.skippedNoCG=grouped.skippedNoCG;run.batches=Core.batches(grouped.ready).map((items,i)=>({index:i,requestId:uuid(),status:"pending",items,results:[],attempts:0}));}
function validate(payload,count){const items=payload?.data?.items;if(!Array.isArray(items)||items.length!==count)throw Object.assign(Error("服务端返回结果不完整"),{code:"INVALID_RESPONSE"});const seen=new Set;for(const x of items){if(!Number.isInteger(x.index)||x.index<0||x.index>=count||seen.has(x.index)||(x.taskId!=null&&(!Number.isInteger(x.taskId)||x.taskId<0))||typeof x.result!=="string"||typeof x.code!=="string"||typeof x.retryable!=="boolean")throw Object.assign(Error("服务端返回结果索引或字段无效"),{code:"INVALID_RESPONSE"});seen.add(x.index)}return items.sort((a,b)=>a.index-b.index)}
async function post(run,batch){const secret=await getSecret();if(!secret||secret.runId!==run.id||secret.origin!==run.frozen.origin)throw Error("冻结的运行凭据不可用");const ctl=new AbortController,t=setTimeout(()=>ctl.abort(),self.__GOAUTO_TEST_TIMEOUT__||15000);try{const res=await fetch(`${run.frozen.origin}/api/client/v1/purchase-tasks/order-backfill`,{method:"POST",redirect:"error",cache:"no-store",credentials:"omit",signal:ctl.signal,headers:{Authorization:`Bearer ${secret.clientKey}`,"Content-Type":"application/json"},body:JSON.stringify({requestId:batch.requestId,items:batch.items})});if(!res.ok)throw Object.assign(Error(res.status===401?"客户端密钥无效":res.status===403?"密钥未授权采购管理 → 回填":`提交失败 (${res.status})`),{code:`HTTP_${res.status}`,retryable:res.status>=500});return validate(await res.json(),batch.items.length)}catch(e){if(e.name==="AbortError")throw Object.assign(Error("提交超时,可用相同批次标识重试"),{code:"TIMEOUT",retryable:true});throw e}finally{clearTimeout(t)}}
async function execute(run){try{
if(run.phase==="scanning"){let stalled=0;while(run.entryCount<500){const view=await send(run,"LIST");if(view.loginExpired)throw Object.assign(Error("PDD 登录失效或出现安全验证"),{code:"LOGIN_EXPIRED"});if(view.empty){run.scanComplete=true;break}run.entryCount=Math.max(run.entryCount,view.entryCount);run.scrollY=view.scrollY;if(view.endEvidence){run.scanComplete=true;break}const before=run.entryCount;await send(run,"SCROLL");await delay(500);const after=await send(run,"LIST");if(after.loginExpired)throw Object.assign(Error("PDD 登录失效或出现安全验证"),{code:"LOGIN_EXPIRED"});run.entryCount=Math.max(run.entryCount,after.entryCount);run.scrollY=after.scrollY;if(after.endEvidence){run.scanComplete=true;break}stalled=run.entryCount===before?stalled+1:0;await write(run);if(stalled>=3){run.incompleteReason="未看到列表末尾标记";break}}if(run.entryCount>=500)run.incompleteReason="达到单次 500 条有界扫描上限";run.phase="reading";run.detailState="list";await write(run)}
if(run.phase==="reading"){while(run.cursor<run.entryCount){if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});let tab=await chrome.tabs.get(run.tabId);if(isListURL(tab.url)&&run.detailState==="returning"){run.cursor++;run.detailState="list";await write(run);continue}if(isListURL(tab.url)){await send(run,"RESTORE",{scrollY:run.scrollY});run.detailState="opening";await write(run);await send(run,"OPEN_ENTRY",{index:run.cursor});await navigated(run,tab.url);run.detailState="detail";await write(run)}else if(run.detailState==="list"){await chrome.tabs.update(run.tabId,{url:run.listURL});await loaded(run.tabId);continue}if(run.detailState!=="returning"){const d=await send(run,"DETAIL");if(d.loginExpired)throw Object.assign(Error("PDD 登录失效或出现安全验证"),{code:"LOGIN_EXPIRED"});if(!d.ok)throw Error(d.error||"订单详情读取失败");run.rows.push(d.classified);run.stats.read++;run.detailState="returning";await write(run)}await returnList(run);run.cursor++;run.detailState="list";await write(run)}run.phase="aggregating";await write(run)}
if(run.phase==="aggregating"){prepare(run);run.phase="submitting";await write(run)}
if(run.phase==="submitting"){for(const b of run.batches){if(b.status==="confirmed")continue;if(await cancelled(run))throw Object.assign(Error("已停止"),{code:"STOPPED"});b.status="sending";b.attempts++;await write(run);try{b.results=await post(run,b);if(b.results.some(x=>x.retryable)){b.status="retryable";b.error={code:"ITEM_RETRYABLE",message:"服务端返回可重试项"};run.phase="incomplete";run.incompleteReason=b.error.message;await write(run);return}b.status="confirmed";run.results.push(...b.results);run.stats.written+=b.results.filter(x=>x.result==="backfilled"||x.result==="already_backfilled").length}catch(e){b.status=e.retryable?"retryable":"failed";b.error={code:e.code||"SUBMIT_FAILED",message:e.message};run.phase=e.retryable?"incomplete":"failed";run.incompleteReason=e.retryable?e.message:run.incompleteReason;await write(run);return}await write(run)}run.phase=run.scanComplete?"completed":"incomplete";await write(run)}
}catch(e){run.phase=e.code==="STOPPED"?"stopped":e.code==="LOGIN_EXPIRED"?"failed":"failed";run.error={code:e.code||"RUN_FAILED",message:e.message};if(e.code==="LOGIN_EXPIRED")run.incompleteReason=e.message;await write(run)}finally{active=null}}
function resume(){if(active)return;active=(async()=>{const r=await getRun();if(r&&!Core.terminal(r.phase))await execute(r)})().finally(()=>active=null)}
async function start(tabId,frozen,key){const old=await getRun();if(old&&!Core.terminal(old.phase))throw Error("已有回填任务正在运行");const tab=await chrome.tabs.get(tabId);if(!Core.validPddURL(tab.url)||!/\/orders\.html/.test(new URL(tab.url).pathname))throw Error("请在 PDD 我的订单页开始");const run={id:uuid(),tabId,listURL:tab.url,phase:"scanning",frozen:{origin:Core.normalizeOrigin(frozen.origin),days:frozen.days,startedAt:frozen.startedAt},entryCount:0,cursor:0,scrollY:0,rows:[],results:[],batches:[],localErrors:[],stats:{read:0,ready:0,written:0,conflicts:0,skippedNoCG:0},scanComplete:false,incompleteReason:"",stopRequested:false,error:null};await chrome.storage.session.set({[SECRET]:{runId:run.id,origin:run.frozen.origin,clientKey:key}});await chrome.storage.session.set({[STATE]:run});resume();return run}
chrome.runtime.onMessage.addListener((m,s,reply)=>{if(m?.source!=="goauto-popup")return;(async()=>{if(!Core.trustedUISender(s,chrome.runtime.id))throw Error("拒绝非扩展界面的控制消息");if(m.type==="STATUS")return{ok:true,run:await getRun()};if(m.type==="START"){if(starting)throw Error("已有回填任务正在启动");starting=true;try{return{ok:true,run:await start(m.tabId,m.frozen,m.clientKey)}}finally{starting=false}}if(m.type==="STOP"){const r=await getRun();if(r&&!Core.terminal(r.phase)){r.stopRequested=true;await write(r)}return{ok:true,run:await getRun()}}if(m.type==="RETRY"){const r=await getRun();if(!r||r.phase!=="incomplete"||!r.batches?.some(b=>b.status==="retryable"))throw Error("没有可重试批次");r.stopRequested=false;r.phase="submitting";await write(r);resume();return{ok:true,run:r}}throw Error("未知命令")})().then(reply).catch(e=>reply({ok:false,error:e.message}));return true});resume();
+9
View File
@@ -0,0 +1,9 @@
const test=require('node:test'),assert=require('node:assert/strict'),c=require('../core');
test('requires one unambiguous strict CG suffix',()=>{assert.deepEqual(c.addressSuffixes('A_cg123'),['_cg123']);assert.deepEqual(c.addressSuffixes('_cg123 / _cg456'),['_cg123','_cg456']);assert.equal(c.addressSuffix('_cg01'),'')});
test('amount accepts exact value and rejects truncation negative or estimate text',()=>{assert.equal(c.moneyCent('¥22.34'),2234);assert.equal(c.moneyCent('22.345'),null);assert.equal(c.moneyCent('-22.34'),null);assert.equal(c.moneyCent('预估实付 22.34'),null)});
test('order number removes only copy tail and rejects prose',()=>{assert.equal(c.normalizeOrderNo('ABC-123 复制'),'ABC-123');assert.equal(c.normalizeOrderNo('ABC-123 其他'),'')});
test('classification counts no CG before time validation',()=>{const x=c.classifyDetail({address:'普通地址',orderNo:'A1',submittedAt:'',paidAmount:''});assert.deepEqual([x.kind,x.code],['skip','CG_MISSING'])});
test('CG without order number is explicit error',()=>{const x=c.classifyDetail({address:'地址_cg123',orderNo:'',submittedAt:'2026-09-18 10:00',paidAmount:''});assert.equal(x.code,'ORDER_NO_INVALID')});
test('aggregation detects CG and reused-order conflicts before dedupe',()=>{const f=(cg,no)=>({kind:'candidate',item:{addressSuffix:cg,pddOrderNo:no,orderSubmittedAt:'2026-09-18T02:00:00.000Z',pddOrderAmountCent:null}}),x=c.aggregate([f('_cg123','A'),f('_cg123','B'),f('_cg456','C'),f('_cg789','C')]);assert.deepEqual(x.cgConflicts,['_cg123']);assert.deepEqual(x.orderConflicts,['C']);assert.equal(x.ready.length,0)});
test('batches never exceed fifty',()=>assert.deepEqual(c.batches(Array.from({length:101}),50).map(x=>x.length),[50,50,1]));
test('trusted UI sender cannot be PDD content sender',()=>{assert.equal(c.trustedUISender({id:'id',url:'chrome-extension://id/popup.html'},'id'),true);assert.equal(c.trustedUISender({id:'id',url:'https://mobile.yangkeduo.com/orders.html',tab:{}},'id'),false)});
+5
View File
@@ -0,0 +1,5 @@
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),{JSDOM}=require('jsdom'),Core=require('../core');
function load(name){const d=new JSDOM(fs.readFileSync(path.join(__dirname,'fixtures',name),'utf8'),{url:'https://mobile.yangkeduo.com/orders.html'});global.document=d.window.document;global.window=d.window;global.scrollY=0;global.scrollTo=()=>{};d.window.HTMLElement.prototype.getBoundingClientRect=()=>({width:100,height:20});global.self={GoAutoBackfillCore:Core};global.chrome={runtime:{onMessage:{addListener(){}}}};delete require.cache[require.resolve('../content')];return require('../content')}
test('synthetic list exposes one product row, excluding header and payment',()=>{const x=load('list.html');assert.equal(x.entries().length,1);assert.equal(x.entries()[0].className,'product');assert.equal(x.summary().endEvidence,true)});
test('synthetic detail parses label siblings, not estimated-payment prose',async()=>{const x=load('detail.html'),f=await x.detail(),r=Core.classifyDetail(f);assert.equal(r.kind,'candidate');assert.equal(r.item.addressSuffix,'_cg123');assert.equal(r.item.pddOrderNo,'ABC-123');assert.equal(r.item.pddOrderAmountCent,2234)});
test('detail expands address and order controls and waits for delayed fields',async()=>{const x=load('detail-delayed.html');document.getElementById('address').onclick=()=>setTimeout(()=>document.getElementById('address-fields').innerHTML='<div><span>收货地址</span><span>合成路_cg123</span></div>',150);document.getElementById('order').onclick=()=>setTimeout(()=>document.getElementById('order-fields').innerHTML='<div><span>订单号:</span><span>ABC-123</span><i>复制</i></div><div><span>下单时间:</span><span>2026-09-18 10:00:00</span></div>',300);const r=Core.classifyDetail(await x.detail());assert.equal(r.kind,'candidate');assert.equal(r.item.pddOrderNo,'ABC-123')});
+1
View File
@@ -0,0 +1 @@
<!doctype html><body><button id="address">展开收货信息</button><button id="order">展开订单信息</button><section id="address-fields"></section><section id="order-fields"></section></body>
+1
View File
@@ -0,0 +1 @@
<!doctype html><body><button>展开收货信息</button><section><div><span>收货地址</span><span>合成路_cg123</span></div><div><span>订单号</span><span>ABC-123</span><i>复制</i></div><div><span>下单时间</span><span>2026-09-18 10:00:00</span></div><div><span>实付</span><span>22.34</span></div></section></body>
+1
View File
@@ -0,0 +1 @@
<!doctype html><body><div class="react-base-list"><div class="synthetic-card"><div class="shop"><img alt="合成店铺"></div><div class="product"><img alt="合成商品"></div><div>实付</div><div>立即付款</div></div></div><p>没有更多了</p></body>
+12
View File
@@ -0,0 +1,12 @@
const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm'),path=require('node:path'),{webcrypto}=require('node:crypto'),Core=require('../core');
const source=fs.readFileSync(path.join(__dirname,'..','service-worker.js'),'utf8');
function harness({initial={},content,fetcher,initialURL='https://mobile.yangkeduo.com/orders.html'}={}){const session={...initial},listeners=[];let tabURL=initialURL;const area={async get(k){if(Array.isArray(k))return Object.fromEntries(k.map(x=>[x,session[x]]));return{[k]:session[k]}},async set(v){Object.assign(session,v)},async setAccessLevel(){}};const events=()=>({addListener(){}});const chrome={runtime:{id:'extid',onInstalled:events(),onStartup:events(),onMessage:{addListener(fn){listeners.push(fn)}}},storage:{session:area,local:area},tabs:{async get(){return{id:7,url:tabURL,status:'complete'}},async sendMessage(_id,m){return content?content(m):{ok:true,empty:true,entryCount:0,endEvidence:true,scrollY:0}},async update(_id,v){if(v.url)tabURL=v.url},async goBack(){tabURL='https://mobile.yangkeduo.com/orders.html'}}};const ctx={self:null,chrome,URL,AbortController,setTimeout,clearTimeout,fetch:fetcher||global.fetch,crypto:webcrypto,console,__GOAUTO_TEST_TIMEOUT__:5};ctx.self=ctx;ctx.importScripts=()=>ctx.self.GoAutoBackfillCore=Core;vm.runInNewContext(source,ctx);const send=(m,s={id:'extid',url:'chrome-extension://extid/popup.html'})=>new Promise(resolve=>listeners[0](m,s,resolve));return{session,send}}
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
test('rejects content sender, serializes START, and persists stopped terminal',async()=>{const h=harness({content:async()=>new Promise(()=>{})});let r=await h.send({source:'goauto-popup',type:'START',tabId:7,clientKey:'k',frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()}},{id:'extid',url:'https://mobile.yangkeduo.com/orders.html',tab:{}});assert.equal(r.ok,false);r=await h.send({source:'goauto-popup',type:'START',tabId:7,clientKey:'k',frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()}});assert.equal(r.ok,true);const second=await h.send({source:'goauto-popup',type:'START',tabId:7,clientKey:'k2',frozen:{origin:'https://other.test',days:2,startedAt:new Date().toISOString()}});assert.equal(second.ok,false);await h.send({source:'goauto-popup',type:'STOP'});await sleep(120);assert.equal(h.session.orderBackfillRun.phase,'stopped')});
test('runtime error persists failed terminal state',async()=>{const h=harness({content:async()=>{throw Error('synthetic failure')}});await h.send({source:'goauto-popup',type:'START',tabId:7,clientKey:'k',frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()}});await sleep(120);assert.equal(h.session.orderBackfillRun.phase,'failed');assert.equal(h.session.orderBackfillRun.error.message,'synthetic failure')});
test('restart resumes stable batch with frozen origin/key and validates response',async()=>{let call;const stable='00000000-0000-4000-8000-000000000316',item={addressSuffix:'_cg123',pddOrderNo:'A',orderSubmittedAt:'2026-09-18T02:00:00Z',pddOrderAmountCent:null},run={id:'run1',tabId:7,listURL:'https://mobile.yangkeduo.com/orders.html',phase:'submitting',frozen:{origin:'https://frozen.test',days:2,startedAt:'2026-09-18T03:00:00Z'},batches:[{index:0,requestId:stable,status:'sending',items:[item],results:[],attempts:1}],results:[],rows:[],localErrors:[],stats:{read:1,ready:1,written:0,conflicts:0,skippedNoCG:0},scanComplete:true,stopRequested:false};const h=harness({initial:{orderBackfillRun:run,orderBackfillSecret:{runId:'run1',origin:'https://frozen.test',clientKey:'secret'}},fetcher:async(url,o)=>{call={url,auth:o.headers.Authorization,body:JSON.parse(o.body)};return{ok:true,async json(){return{data:{items:[{index:0,taskId:123,result:'backfilled',code:'BACKFILLED',retryable:false}]}}}}}});await sleep(10);assert.equal(call.url,'https://frozen.test/api/client/v1/purchase-tasks/order-backfill');assert.equal(call.auth,'Bearer secret');assert.equal(call.body.requestId,stable);assert.equal(h.session.orderBackfillRun.phase,'completed')});
test('timeout remains retryable and does not confirm batch',async()=>{const item={addressSuffix:'_cg123',pddOrderNo:'A'},run={id:'run2',tabId:7,listURL:'https://mobile.yangkeduo.com/orders.html',phase:'submitting',frozen:{origin:'https://frozen.test',days:2,startedAt:'2026-09-18T03:00:00Z'},batches:[{index:0,requestId:'run2:batch:0',status:'pending',items:[item],results:[],attempts:0}],results:[],rows:[],localErrors:[],stats:{read:1,ready:1,written:0,conflicts:0,skippedNoCG:0},scanComplete:true,stopRequested:false};const h=harness({initial:{orderBackfillRun:run,orderBackfillSecret:{runId:'run2',origin:'https://frozen.test',clientKey:'secret'}},fetcher:(_u,o)=>new Promise((_r,reject)=>o.signal.addEventListener('abort',()=>reject(Object.assign(Error('abort'),{name:'AbortError'}))))});h.session.__unused=1;await sleep(30);assert.notEqual(h.session.orderBackfillRun.batches[0].status,'confirmed')});
test('retryable item response remains resumable and unconfirmed',async()=>{const item={addressSuffix:'_cg123',pddOrderNo:'A'},run={id:'run3',tabId:7,listURL:'https://mobile.yangkeduo.com/orders.html',phase:'submitting',frozen:{origin:'https://frozen.test',days:2,startedAt:'2026-09-18T03:00:00Z'},batches:[{index:0,requestId:'00000000-0000-4000-8000-000000000317',status:'pending',items:[item],results:[],attempts:0}],results:[],rows:[],localErrors:[],stats:{read:1,ready:1,written:0,conflicts:0,skippedNoCG:0},scanComplete:true,stopRequested:false};const h=harness({initial:{orderBackfillRun:run,orderBackfillSecret:{runId:'run3',origin:'https://frozen.test',clientKey:'secret'}},fetcher:async()=>({ok:true,async json(){return{data:{items:[{index:0,taskId:123,result:'failed',code:'INTERNAL',retryable:true}]}}}})});await sleep(15);assert.equal(h.session.orderBackfillRun.phase,'incomplete');assert.equal(h.session.orderBackfillRun.batches[0].status,'retryable');assert.equal(h.session.orderBackfillRun.results.length,0)});
test('scan compares after lazy load and reaches explicit end evidence',async()=>{let count=1;const h=harness({content:async m=>{if(m.type==='SCROLL'){setTimeout(()=>count++,20);return{ok:true}}if(m.type==='LIST')return{ok:true,entryCount:count,endEvidence:count>=4,scrollY:count*100};return new Promise(()=>{})}});await h.send({source:'goauto-popup',type:'START',tabId:7,clientKey:'k',frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()}});await sleep(3200);assert.equal(h.session.orderBackfillRun.entryCount,4);assert.equal(h.session.orderBackfillRun.scanComplete,true);assert.notEqual(h.session.orderBackfillRun.incompleteReason,'未看到列表末尾标记')});
test('reading restart after saved row on list advances cursor without duplicate',async()=>{const saved={kind:'skip',code:'CG_MISSING',item:{}},run={id:'read1',tabId:7,listURL:'https://mobile.yangkeduo.com/orders.html',phase:'reading',detailState:'returning',entryCount:1,cursor:0,scrollY:0,rows:[saved],results:[],batches:[],localErrors:[],stats:{read:1,ready:0,written:0,conflicts:0,skippedNoCG:0},frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()},scanComplete:true,stopRequested:false};const h=harness({initial:{orderBackfillRun:run,orderBackfillSecret:{runId:'read1',origin:'https://admin.test',clientKey:'k'}}});await sleep(20);assert.equal(h.session.orderBackfillRun.cursor,1);assert.equal(h.session.orderBackfillRun.rows.length,1);assert.equal(h.session.orderBackfillRun.phase,'completed')});
test('reading restart on detail finishes current row then returns to list',async()=>{const run={id:'read2',tabId:7,listURL:'https://mobile.yangkeduo.com/orders.html',phase:'reading',detailState:'detail',entryCount:1,cursor:0,scrollY:200,rows:[],results:[],batches:[],localErrors:[],stats:{read:0,ready:0,written:0,conflicts:0,skippedNoCG:0},frozen:{origin:'https://admin.test',days:2,startedAt:new Date().toISOString()},scanComplete:true,stopRequested:false};const h=harness({initialURL:'https://mobile.yangkeduo.com/order.html',initial:{orderBackfillRun:run,orderBackfillSecret:{runId:'read2',origin:'https://admin.test',clientKey:'k'}},content:async m=>m.type==='DETAIL'?{ok:true,classified:{kind:'skip',code:'CG_MISSING',item:{}}}:{ok:true}});await sleep(1100);assert.equal(h.session.orderBackfillRun.cursor,1);assert.equal(h.session.orderBackfillRun.rows.length,1);assert.equal(h.session.orderBackfillRun.phase,'completed')});
+49 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 029dc3e155e1828484c62efd0ac804616fcbc714
synchronized_at: 2026-09-11T09:09:07Z
wiki_revision: 46067d78327476f14e6cfd202458a53f295bf763
synchronized_at: 2026-09-19T07:18:28Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -460,3 +460,50 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 有交接时,执行器恢复限定的purchaseEntryContext,最多10次、间隔200ms新读取(捕获耗时另计);连续两次相同PDD Activity且具备既有商品/面板证据才跳过openProduct。空树可有限等待,其他应用/Activity改变/页面风险立即拒绝复用。没有交接时保留原一次判断路径;不把UNKNOWN当成功,不跳过正式精确规格/数量/价格/地址或下单验证。
- 私有诊断新增handoffPresent、productReused布尔值,reusePolls整数及reuseReason固定枚举legacy_evidence/page_problem/page_changed/handoff_ready/evidence_timeout。仍绑定task/attempt/device/ruleHash,无敏感文本。
- 无Server/Web、共享API或数据库变化,不需重启服务器。只有合成测试验证;原CG107/108日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
## 人工订单回填金额扩展(#306)
实现绑定 8c01329(2026-09-18),分支 feat/306-order-paid-amount;待验收、未部署。复用 #241/#242 链路,不改普通采购提交和扫描器导航。
- Android automation/OrderBackfillPolicy.kt:BackfillDetailReader 在订单内收集唯一“实付”金额,使用 BigDecimal 精确转换为人民币分;BackfillItem 增加可选 pddOrderAmountCent。
- Android network/OrderBackfillUpload.kt 与 persistence/TaskHistoryCache.kt:上传可选金额,读取并缓存 Server 确认金额和独立警告;不把成功警告改为任务失败。
- Server models/purchase.go:purchase_task.pdd_order_amount_cent 为 nullable BIGINT、非负;与 actual_unit_price_cent 独立。追加迁移 1789800100000_purchase_order_amount.go 使用版本内的局部表模型,仅扩展 purchase_task;历史值 NULL,不回填猜测金额。
- Server purchase/order_backfill.go:原有逐条事务/设备与订单号保护内补齐空金额;相同值幂等,异值保留并警告;已 order_created 同号任务也能补写。purchase/admin_query.go 输出金额。
- Web views/goauto/purchase-tasks/index.vue:复用现有订单 state-card 显示 CNY 实付价格或“尚未获取”;列表结构不变。
- 本次不修改 SYB 客户端或 #305,金额不进入 SYB 请求。
验证覆盖 Android 解析/载荷/回执缓存、Server 幂等/冲突/旧请求和局部 SQLite 迁移、Web 金额/空值展示;真机端到端、实际 MySQL 迁移和发布须另行授权,不能以本地测试替代。
## SYB 单号回填持久队列(#305)
实现绑定 e89de1a(2026-09-18,未部署),仅 Server/Web;Android 复用现有普通结果与人工订单回填。
- purchase/lifecycle.go、purchase/order_backfill.go → ensureOrderWriteback:与订单事实同事务建立 pending,purchase_task_id 唯一;外部失败不撤销已提交的订单事实。
- models/purchase_order_writeback.go:purchase_order_writeback 保存不可变 stock/detail/单号快照、状态、attempt_count、write_started、租约、脱敏错误和完成时间;purchase_order_writeback_command 保存 requestId/输入摘要/逐项接受结果;purchase_order_writeback_lease 保存全局单例租约。追加迁移 1789800200000,不改旧物流字段,不批量排入历史订单。
- purchase/order_writeback.go:批量命令事务/幂等/条件校验和 Admin 状态投影;order_writeback_handler.go/router.go 暴露 Admin 补偿;access/purchaser.go 纳入原采购权限启动对账目录。
- purchase/order_writeback_worker.go:由 cmd/api/server.go 每数据库启动一个3秒轮询 worker;数据库2分钟单例租约使跨进程及不同任务的相同SYB明细写入串行。写前核对当前任务/关联快照,持久化写入起始标记,并验证租约所有者/剩余时间;不持有数据库事务跨网络请求。失联running恢复只回读,unknown不自动调度;其他同目标未确认写入阻止新写。
- sybclient/order_number.go:一次 POST updateDetailPurchaseCode,不跟随重定向、不使用读接口重试策略。会话仅从现有 SessionStore 恢复,不在 worker 自动登录/OCR;外部原始错误不写入任务原因。
- Web purchase-tasks API/页面:共享勾选、按动作筛选、独立状态列/详情及逐项接受结果;付款和物流流程保持原样。
- 验证入口:go test ./app/goauto/purchase ./app/goauto/sybclient ./app/goauto/access ./app/goauto/migrations ./cmd/migrate/migration/version-local;Web tests/e2e/purchase-order-writeback.spec.ts。测试只使用隔离SQLite和fake/httptest,不代表MySQL多实例或真实SYB验收。
## Chrome PDD 订单回填扩展(#316)
- `chrome-extension/` 是独立 Manifest V3 交付单元:popup 只负责配置、启动/停止、状态轮询和逐项结果;content script 只在 `mobile.yangkeduo.com` 的隔离世界中按可见 DOM 串行读取;service worker 负责持久运行状态、聚合冲突和分批提交。
- 列表遍历限定虚拟列表订单卡片的商品行,排除店铺头和支付、确认收货、退款、售后、取消、更多等动作区;详情只展开收货/订单信息。未看到明确列表末尾证据时必须标记未完整扫描,不得宣称全量。
- 客户端密钥与冻结的 Admin Origin 只保存在扩展可信上下文;content script 无权读取。请求禁用 Cookie 与重定向,批次最多 50 条,稳定 UUID 可在 service worker 重启或超时后重放;已确认批次不重复计数。
- 服务端 `POST /api/client/v1/purchase-tasks/order-backfill` 复用 Agent 回填事务校验,但使用独立 client principal,不伪造 Device Token 或 deviceId。Agent 路径仍严格保持设备归属校验及失败后按设备范围回读。
## 下单结果读取及应付总额链路(#325)
分支 fix/325-order-result,2026-09-19,尚未发布及真实下单验收。PurchaseLiveAutomation.readOrderResult 采用25次支付页返回后采样、首次待付款页独立30次采样/最多4次按需滑动;原过渡60次与总90次采样上限保留有界终止。parsePayableAmount只从本次只读标签集合提取明确应付总额。
数据链路:PurchaseOrderEvidence.pddOrderAmountCent → PurchaseExecutionOutcome → service/PurchaseResultPayload.kt → purchase.ResultRequest → lifecycle.go 的 order_created 分支 → 既有 purchase_task.pdd_order_amount_cent。重启只读核单也沿用同一可选字段。复用已有结果Outbox、幂等哈希和订单号唯一性;不新增表、配置、任务或SYB金额字段。未修改我的订单回填、Chrome扩展和Web,不能将旧入口的实付含义描述为本次已改成应付。
## Admin 订单资料补全与一致回填资格(#326)
实现3a2472d(2026-09-19),Server/Web,未合并发布;无迁移、无Android/插件改动。
- purchase/types.go ManualRequest 增加可选 pddOrderAmountCent;manual.go ResolveUnknown 复用既有行锁事务和模型单号保护,扩展为未知结果确认或已创建订单补缺。仅此操作启用新增重放内容校验和事务内 ensureOrderWriteback,其他人工付款/取消/授权流程不新增入队副作用。
- order_writeback.go 的任务资格去掉 order_created 限制;validOrderWritebackTarget 统一有效 stock/detail 边界。OrderWritebackViews 一次批量读取关联而非逐行查询,RequestOrderWriteback 和 worker 执行前再次检查。队列结构、租约、外部写入协议不变。
- web/src/views/goauto/purchase-tasks/index.vue 沿用原详情/弹窗/resolve-unknown API,新增缺字段补录入口、整数分转换、完整性展示及失败保留输入,不增加数据库状态或新的权限资源。旧未知结果请求不带金额继续兼容。
- 回归:server go test -p 1 ./app/goauto/purchase ./app/goauto/clientapi ./app/goauto/sybclient;Web 生产构建及 tests/e2e/purchase-order-writeback.spec.ts 的6项隔离浏览器测试。真实SYB接口用fake/httptest替代,未执行付款、采购或线上写入。
+55 -3
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: d8aeff02fba57cba0b53fb22cdb524fd5cc0cb3e
synchronized_at: 2026-09-11T09:09:16Z
wiki_revision: b6df3e2d3d497827c9f3cc5b5ec859d5bda3c1f2
synchronized_at: 2026-09-19T07:39:56Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -66,7 +66,7 @@ synchronized_at: 2026-09-11T09:09:16Z
- Agent 侧回填为**人工触发的只读扫描**(#242):入口在采购记录页搜索按钮右侧,输入天数(默认 2)后确认启动。天数口径为滚动 N×24 小时,基准是页面读到的下单时间。采集、采购、回填三者互斥,复用既有任务互斥锁并加原子防重入,忙碌时拒绝启动而不排队、不抢占。
- 回填扫描不假设订单列表有序:只有连续观察到至少 5 单且下单时间严格递减、期间无缺失时间时,才允许「超过指定时间即停止」;一旦出现时间回升或缺失,改为扫至内部上限并明确标记「未完整扫描」,不得把不完整结果显示为已扫完。内部上限用于限制设备占用,不作为用户可配置的门禁。
- 回填扫描全程只读:确认收货、申请退款、催发货、去支付、立即支付、提交订单、付款、退款、取消订单、再次购买、删除订单永久排除为点击目标;点击目标必须自身可点、子树不含上述词,且不与任何含上述词的可见控件几何重叠;不得按固定坐标盲点。遇登录、验证码或风控立即停止并报告。
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号与可选下单时间。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号、可选下单时间,以及 #306 的可选实付金额。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。
- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。
@@ -610,3 +610,55 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 有交接时,执行器恢复限定的purchaseEntryContext,最多10次、间隔200ms新读取(捕获耗时另计);连续两次相同PDD Activity且具备既有商品/面板证据才跳过openProduct。空树可有限等待,其他应用/Activity改变/页面风险立即拒绝复用。没有交接时保留原一次判断路径;不把UNKNOWN当成功,不跳过正式精确规格/数量/价格/地址或下单验证。
- 私有诊断新增handoffPresent、productReused布尔值,reusePolls整数及reuseReason固定枚举legacy_evidence/page_problem/page_changed/handoff_ready/evidence_timeout。仍绑定task/attempt/device/ruleHash,无敏感文本。
- 无Server/Web、共享API或数据库变化,不需重启服务器。只有合成测试验证;原CG107/108日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
## PDD 订单实付价格(#306)
实现绑定 8c01329(2026-09-18),待验收,尚未部署。人工“我的订单”回填除订单号和下单时间外,可提交唯一、合法的“实付”价格。它是订单展示总额,人民币分,独立存为 purchase_task.pdd_order_amount_cent;actual_unit_price_cent 仍是下单前观察的商品单价,二者不互相推算。
不判断是否付款,不改变人工支付复核状态。没看到实付、格式异常或多个不同实付值时,仅跳过金额,订单号和时间照常按原规则回填。采购员可稍后再点既有“回填”,为空金额补写;已有相同值幂等,不同值保留旧值并返回金额冲突提示,不撤销正确订单事实。只允许当前设备自己的、原有允许状态的任务;不新增 attempt 或采购动作。
金额仅保存 Admin;#305 订单号回写 SYB 的数据流不得使用该字段或将其映射为 cost,金额缺失/冲突不得成为订单号回写的前置条件。本单不代表 #305 已实现。Admin 现有订单详情卡片增加“实付价格”,缺失显示“尚未获取”,不新增列表列、入口或支付动作。安全点击集合及不保存原始控件树/个人信息的规则不变。
## PDD 订单号自动回填 SYB(#305)
实现绑定 e89de1a(2026-09-18),代码与隔离测试已完成,真实迁移/发布/SYB写入尚未验证。以下补充 #306 的单号后续流程,不改变其金额规则。
正式 SYB 订单采购取得 PDD 单号(普通结果或人工遍历回填)后,在订单事实的同一个数据库事务中保存独立回填记录,后台异步写入对应 stock_id + detail_id。已人工回填相同单号也幂等确保记录存在;不重新采购,不等待支付复核。备货、演练、未取得单号或关联失效不回填;不自动扫描历史单据,历史补偿由采购管理勾选发起。
写前精确回读唯一目标明细:同单号且 purchasePlatform=pdd 直接成功;非空不同单号/不兼容平台标记 conflict,不覆盖。远端为空才允许一次写请求,写后回读确认才成功。未知结果不得自动重复写;重启恢复的 running 只回读,不能因租约过期盲重发。失败/unknown 可人工补偿,仍先回读,且在途租约未到期时不能再发起。
订单回填状态独立于采购成功、支付复核及物流 writeback_status。实付金额只存 Admin,SYB cost=0 为接口固定参数,不以金额推断付款。SYB 接口会同时更新采购状态/平台/时间,不能视作纯展示修改。
采购管理增加独立状态列、批量回填和详情补偿;复用既有访问权限,不增支付确认或审批。批量受理与最终成功分开展示;重试采购和回填分别筛选勾选项。远端无原子CAS,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
## Agent 回填订单入口兼容(#307)
- 打开我的订单后最多采样8次,每次间隔1秒,连续两次订单导航结构一致才继续;登录/风控仍立即停止,超时给出明确原因。
- 保留全部标签selected=true的原路径。PDD不提供selected属性时,要求订单标题、唯一全部标签以及同排至少两个订单状态标签;仅该安全导航标签允许一次中心手势,不扩大到订单卡片、地址、支付或其他操作。
- 本轮主动点击全部后复核订单导航结构;本轮上下文不能跨扫描复用,出现其他已选中订单状态时停止。卡片识别与支付禁令保持不变。
- 回填结束/失败通过现有通知与Toast显示脱敏原因;详细结果仍在Agent采购页。本变更不调整服务端回填接口、采集或采购流程。
## 下单后待付款核单与应付金额(#325)
实现分支 fix/325-order-result,2026-09-19;未部署、未真机验收。下单点击及不可逆边界不改。已知 PayActivity 允许一次原有 Back,随后从3次放宽为25次200ms采样(约5秒等待,不含页面捕获耗时),持续无证据仍为 PURCHASE_ORDER_PAYMENT_REPEATED。微信恢复、系统选择器、空窗口和陌生应用的既有边界保留。
过渡阶段最多60次采样。首次识别到待付款证据后单独分配30次采样预算,不被之前绕路消耗,不因文字反复出现重置;核单总采样上限90次。缺少唯一订单号或时间时立即向上滑动读取下方内容,最多4次、手势400ms、每次后等500ms;读全立即停止,已有完整字段不滑动。该待付款读取分支不点击任何控件,也不点击付款。纯支付页不使用该滑动分支;出现可读待付款证据优先于复用的 Activity 名称。按采样间隔约18秒加有限手势/额外等待,实际耗时还含capture等调用;未改变2分钟任务租约或5分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。
应付金额从同一订单的可见标签集合中读取,兼容“应付:,13元”等标点;不跨无关联节点拼接,不读拼单价、优惠或实付。父子重复同金额去重,多值/缺失/无效/溢出省略,不因金额失败丢弃订单号与时间。仅存Admin,不推断支付、不改SYB金额、不修改地址行为。历史回填及界面语义未在本单统一,详见API契约#325补充。
## 采购订单资料完整性及简化单号回填(#326)
用户2026-09-19确认内部系统简化流程,原型v1通过。实现3a2472d,尚未发布或真实SYB验收。
采购任务保存 PDD 订单号、下单时间和订单金额即为资料完整,不检查是否已付款;金额0元有效。复用 pddOrderNo/orderSubmittedAt/pddOrderAmountCent,金额以人民币分存储。order_created 不改义,仍是已创建订单事实;缺金额展示“订单信息待补全”,不能当作采购失败再次下单。页面统一称“订单金额”,其来源可能是#325应付或既有回填实付,不能将历史值统一称已付金额。
Admin 复用未知结果处理弹窗增加金额输入,并为 live 已创建且缺资料任务提供“补全订单信息”;已有字段只读、缺项可填。保存中禁用提交,失败保留输入;沿用既有权限,不增加审批。非执行中的未知结果人工确认与已有订单资料补全都会事务内保证单号回填记录,不修改支付复核或触发设备操作。
SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有效明细即可入队,不要求 order_created 状态、时间、金额、付款确认或地址后缀。只有订单号上传SYB;沿用同号成功、异号冲突不覆盖及既有在途防重复规则,不倒扫历史任务。历史补偿仍手动勾选,已成功或在途任务不可重复发起。
自动下单读取和Admin补录是后续目标来源;本单尚未删除插件或Agent我的订单回填,不能宣称上游地址后缀已取消或历史错误归属已修复。金额缺失不得自动采购,订单号唯一归属保护和永久不付款规则不变。
## 采购管理已回填 SYB 筛选(#327)
实现368f2c2,2026-09-19,尚未发布。采购管理现有“状态”下拉框增加“已回填 SYB”,用户选择后点击原查询按钮,按PDD单号回填队列成功记录筛选;不是新的采购任务状态,也不表示物流回填成功或已付款。
筛选后仍展示原采购状态与独立SYB单号回填状态,已成功任务不因查询而允许重复回填。清空或重置恢复原列表,查询回到第一页,其他条件继续叠加。沿用既有下拉框、空/加载/失败状态及权限,不新增页面、审批、数据字段或外部动作。
+17 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
wiki_revision: 835494c4a63a1494601658561fde1ab76657be3d
synchronized_at: 2026-09-05T07:17:05Z
wiki_revision: f8996a09b2158253553c9e929eab72e1e53e8f61
synchronized_at: 2026-09-18T07:57:06Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -335,3 +335,18 @@ adb shell am start -n cn.ilapage.goauto.agent/.MainActivity
- APK 文件目录使用 `GOAUTO_AGENT_RELEASE_DIR`;缺省为服务端工作目录下 `var/goauto-agent-releases`,不得映射成公开静态目录。部署需让 Admin/API 进程对该目录具有创建、写入、读取和删除临时失败文件的权限。
- 真机发布验证前确认设备空闲,再由管理员上传一个签名一致、versionCode 更高的 APK并显式设为当前。依次验证启动静默提示、设置页手动检查、下载进度/取消、断网、哈希不一致删除、未知来源引导和系统确认安装。任务执行中全部更新动作必须被阻止,心跳与前台服务继续运行。
- 上传或设为当前属于发布动作,安装会改变设备应用版本;没有用户独立授权时不得执行。安装后系统可能关闭无障碍服务,只能由用户在系统设置重新开启。
## Chrome 订单回填扩展验证(#316)
从仓库根目录执行:
```powershell
cd chrome-extension
npm install
npm test
node --check service-worker.js
node --check content.js
node --check popup.js
```
自动化测试只使用合成 DOM 与 Chrome API mock,覆盖列表商品行负向选择、双展开与延迟字段、解析/冲突、启动互斥、停止、错误终态、扫描延迟增长、详情恢复、提交恢复、冻结配置、超时和可重试项。真实 PDD 浏览器点击、扩展安装和真实 Admin 上传不属于自动化验证,必须取得独立授权并在结果中明确区分。
+230
View File
@@ -1,3 +1,11 @@
<!-- gitea-wiki-mirror:start -->
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: 275caa306765440b0888dea701198d45481bfb40
synchronized_at: 2026-09-19T07:40:19Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
@@ -819,6 +827,158 @@ Content-Type: application/json
- Admin 的 BatchRetry 接口和行为不变。相同 `requestId` 重放返回同一新任务;不同 requestId 再点由最新任务门禁拒绝。
## SYB 商品 PDD 图搜采集(#275~#280)
图搜任务**就是采集任务**:同一张 `collection_task` 表、`source = 'image_search'`、同一个
`/api/agent/v1/tasks/*` 端点。与 `agent_current_page` 的唯一区别是**谁打开那个商品页**——
后者由采购员手动打开,图搜由 Agent 用 PDD 的拍照搜索找到并打开。打开之后的链路
(分享链接 → goods_id → 身份回填 → 采集 → 提交结果)完全复用,没有新的采集接口。
### 管理端:批量创建
```http
POST /api/admin/v1/collection-tasks/image-search/batch
Content-Type: application/json
{"requestId":"<uuid>","sybProductIds":[1,2,3],"ruleId":3,"deviceId":null,"overwriteLinked":false}
```
- **按 `shopee_product` 去重**:多条共用同一张参考图的 SYB 明细合并为一个任务。请求最多
100 个 `sybProductIds`。
- **批次上限作用于去重后的任务数**,不是入参明细数,上限 50(约 17~33 分钟设备占用,
按单次图搜 20~40 秒估算)。超限整批拒绝,不部分创建。该值是服务端常量,**不可由环境
变量覆盖**:确认对话框必须在提交前禁用按钮,前端持有同一个数字,可覆盖只会让两侧静默
不一致。改动时两侧必须同改。
- `deviceId` 可空,为空由具备能力的空闲设备领取。`overwriteLinked` 为 false 时已关联 PDD
的蝦皮商品返回 `IMAGE_SEARCH_ALREADY_LINKED` 并跳过。
- 响应逐条返回 `shopeeProductId` / `sybProductIds` / `taskId` 或 `code` + `message`。
### 设备能力
图搜任务要求设备声明 `pdd.image-search.v1`。不具备该能力的设备在领取阶段跳过图搜任务,
不会失败。
### 任务下发
`GET /api/agent/v1/tasks/next` 与 `claim` 返回的载荷结构对所有来源一致,图搜任务额外携带
`imageSearch`:
```json
{"imageUrl":"https://...","mediaType":"image/jpeg","sizeBytes":81234,"sha256":"<64 位小写 hex>"}
```
- **只下发这四个字段。** 关联关系、参考价、价格倍数、去重明细等快照留在服务端的
`image_search_snapshot`,不下发给 Agent。
- 服务端不下发图片字节;Agent 自行下载 `imageUrl` 并按 `sizeBytes` 与 `sha256` 校验。
参考图上限 10MB。
- 身份回填前 `pddProductId` 为 `null`,`urlSnapshot` 与 `goodsIdSnapshot` 为空字符串,
与 `agent_current_page` 相同。
### 身份回填
图搜复用 `POST /api/agent/v1/current-page-collection-tasks/{taskId}/identify`,该接口已同时
接受 `agent_current_page` 与 `image_search` 两种来源。
- `[已核对]` 该接口要求任务已绑定 `device_id`。批量创建允许 `deviceId` 为空,但
`Claim` 在事务中写入 `device_id = <领取设备>`,因此 Agent 调用 identify 时设备必定已绑定。
- 回填之后的结果提交、失败上报、reset 重采与 `agent_current_page` 完全一致。
**`ruleSnapshot` 结构与普通采集任务同构**,图搜不引入第二种规则形态。
### 结果自动关联与价格兜底
任务完成后服务端尝试建立 shopee↔PDD 关联,条件全部满足才写入:
- 任务 `source = 'image_search'`、状态 `completed`、已回填 `pddProductId`;
- 目标蝦皮商品 `pdd_product_id IS NULL AND image_search_linked = false`(CAS 谓词:Agent
执行期间发生的人工改动胜出,不被覆盖);
- 蝦皮商品币种为 CNY。**非 CNY 一律不自动关联**,快照记 `priceGuardSkipped` 与
`CROSS_CURRENCY_TWD_PDD_CNY`,留给人工复核;
- 候选价与 `syb_product.unit_price_cent` 的偏离在 `maxPriceRatio`(1~100 的整数)之内。
自动建立的关联置 `shopee_product.image_search_linked = true`,含义是**「机器找的,没人核过」**。
采购员在虾皮商品页手动关联(`LinkPDD`)时置回 false。该标记只读地出现在批量采购预览
(`BatchPreviewItem.imageSearchLinked`)与采购任务列表,**不参与任何采购门禁、资格判定或
下单条件**。
### 调度:采集 > 图搜
图搜任务在领取队列中垫底,由 `task.Service.Next` 的 `ORDER BY` 实现:
```sql
-- 共享候选池
CASE WHEN device_id IS NULL THEN 1 ELSE 0 END,
CASE WHEN source = 'image_search' THEN 1 ELSE 0 END,
created_at ASC, id ASC
```
- `[必须]` 该排序只能落在服务端。图搜任务是同一端点下发的普通采集任务,Agent 调用
`next` 时服务端已经选好了行,客户端无从推翻。
- `[必须]` 来源降级排在 `device_id` **之后**:指派设备是人的显式选择,必须继续优先于来源
降级,否则未指派的采集任务会抢在运营明确路由到本机的图搜任务前面。
- 采购任务不需要此处排序:它们在独立表、独立端点,Agent 已在检查采集之前处理。
### 错误码
管理端(批量创建):
| 错误码 | 含义 |
|---|---|
| `IMAGE_SEARCH_BATCH_TOO_LARGE` | 去重后任务数超过单批上限 |
| `IMAGE_SEARCH_ALREADY_LINKED` | 蝦皮商品已关联 PDD,逐条跳过 |
| `IMAGE_SEARCH_TASK_ACTIVE` | 该蝦皮商品已有未结束的图搜任务 |
| `IMAGE_SEARCH_IMAGE_INVALID` | 参考图下载失败、格式不支持或超过限制 |
| `IMAGE_SEARCH_INPUT_CHANGED` | 关联、参考图、价格或币种在请求期间变化 |
| `IMAGE_SEARCH_REQUEST_CONFLICT` | 同一 `requestId` 的参数已变化 |
| `IMAGE_SEARCH_CONFIG_INVALID` | 价格倍数配置不是 1~100 的整数 |
| `IMAGE_SEARCH_SNAPSHOT_INVALID` | 任务快照无效 |
Agent 上报(`POST /api/agent/v1/tasks/{taskId}/fail`):
| 错误码 | 含义 |
|---|---|
| `IMAGE_SEARCH_PERMISSION_REQUIRED` | 未授予相册权限 |
| `IMAGE_SEARCH_ASSET_INVALID` | 参考图下载、校验或写入相册失败 |
| `IMAGE_SEARCH_ASSET_NOT_LATEST` | 相册中存在更新的图片或视频,无法确认会选中我们准备的图 |
| `IMAGE_SEARCH_ENTRY_NOT_FOUND` | 找不到唯一可点的拍照搜索入口,或归位失败 |
| `IMAGE_SEARCH_NO_CANDIDATES` | 结果页没有可用候选(含全部为广告) |
| `IMAGE_SEARCH_AUTOMATION_UNAVAILABLE` | Agent 版本尚未接入图搜自动化 |
### Agent 执行边界
- 只使用无障碍树,**禁止 OCR / VLM**。
- 相册权限:Android 13+ `READ_MEDIA_IMAGES`,10~12 `READ_EXTERNAL_STORAGE`
(`maxSdkVersion=32`)。这是本功能新增的权限,既有设备升级后需重新授权。
- 参考图写入设备相册后,Agent 先确认它是相册中**最新的一项(图片与视频一起比较)**,
再点击网格第一格;不是最新一项时以 `IMAGE_SEARCH_ASSET_NOT_LATEST` 明确失败,
不往后找、不猜测。任务结束(成功或失败)都删除本次写入的图片。
- 归位状态机可从商品详情、规格弹层、订单确认页、结果页、重试弹窗回到图搜入口,上限
50 次动作。**订单确认页只执行返回**;支付与下单文案仅作只读识别信号,任何情况下都不是
点击目标。
- 结果页默认打开第一个候选,跳过带「广告」标记的卡片。
- 「重新采集」对图搜任务的语义是**重采同一个商品**,不重新搜索;搜错了由采购员在管理端
手动改关联(该动作同时清除图搜标记)。
### PDD 界面判据(2026-09-15 真机核对,设备 1080×2354)
常量集中在 `PinduoduoImageSearchCriteria`。以下为核对结论,PDD 版本更新后需要重新 dump:
| 位置 | 判据 | 状态 |
|---|---|---|
| 首页入口 | `content-desc='拍照搜索'` 且 **clickable** 的唯一节点,实测 `[941,161][1080,228]` | 命中 |
| 图搜页 | `我的相册` + `最近搜索` + `历史浏览` + `点击拍照` | 命中 |
| 选图网格 | `最近项目` 锚点下方 4 列等宽网格,每格 267px,屏宽 1080,容差 ±24 | 命中 |
| 结果页 | `搜图片同款` 且排序控件 ≥ 3(实测 综合/销量/价格/品牌) | 命中 |
| 重试弹窗 | `请对准商品或码,保持手机稳定` + `取消` + `再试一次` | **未验证** |
`[必须]` 入口判定必须同时要求 clickable:个人中心 tab 的**根节点**也带
`content-desc='拍照搜索'`,覆盖全屏且不可点,而 `visibleTexts()` 会把 `contentDescription`
当文本收集。只按文案匹配会把个人中心判成首页,随后几何兜底会点到该页顶部最靠右的可点
节点——实测那是「设置」按钮。
已知偏差:取景提示真机为「对准商品/条形码/二维码,自动识别」,历史常量
`即可进行自动识别` 不命中,靠 `点击拍照` 兜住。相册网格同时包含视频(实测见到带时长的
格子),这是上面「最新一项须跨图片与视频比较」的由来。
## 采集颜色图片上传(#133)
结构化采集结果成功提交后,Agent 可为任务已采集的颜色逐张调用:
@@ -1169,3 +1329,73 @@ requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有
- Agent 在当前PDD规格面板重新读取候选并提交同一采购任务的 `spec_rematch_completed` 结果;Server复用现有确定性优先/必要时AI匹配,结果必须属于当次候选。匹配成功后同task重新领取正式attempt,Agent沿用当前页面继续正式规格、数量、价格、地址和下单校验,不新建任务、不重新打开浏览器链接;页面证据失效时仍按原安全路径处理。
- 每个采购任务最多一次;结果attempt和服务端状态原子记录,旧决策指针清除后只允许最新attempt固化新决策。重复请求、无资格、已有不可逆证据或旧attempt覆盖均拒绝;不新增数据库列,依赖现有attempt result_type审计。
- 新共享字段 `specRematchAllowed` 和结果类型仅供现有Agent采购接口;旧Agent缺省false,旧 `spec_probe_completed` 重复探测拒绝规则不变。未改变设备租约、并发、支付禁止和任务重试语义。
## 人工订单回填的可选实付金额(#306)
实现绑定:8c01329(2026-09-18,feat/306-order-paid-amount,待验收,尚未合并/部署/执行业务库迁移)。本节补充并覆盖 #241 中“相同订单号一律无写入”的金额相关描述;订单号、时间、设备归属和状态校验保持原契约。
POST /api/agent/v1/purchase-tasks/order-backfill 的 items 可增加 pddOrderAmountCent:可空/可省略的 int64,单位人民币分,表示订单详情明确“实付”标签的整单金额。缺失或 null 保留原值;负整数局部忽略,不影响订单号和时间回填。字符串、非整数或 int64 溢出属于 JSON 契约错误,仍按既有解码器返回批级 422;Agent 对识别异常只省略字段,不能发送异常值。
- unknown 首次回填:可同时填充空金额;不会改变支付、物流或 SYB 回写字段。
- order_created 且订单号相同:原金额为空时可补写,statusVersion 增加一次;订单号、下单时间不变。result/code 仍为 already_backfilled/ALREADY_BACKFILLED。
- 同值或本次缺失金额:幂等,不增加版本;已有不同金额:保留原金额和正确订单事实,成功结果附 warningCode=PURCHASE_BACKFILL_AMOUNT_CONFLICT、warningMessage。不触发重新采购。
- 条目响应可增加 pddOrderAmountCent(已提交数据库值)及上述 warning 字段。跨设备/不存在任务仍不返回业务字段。Android 缓存仅采纳服务端确认值,成功 warning 单独保存,不写为任务 errorCode。
- Admin 采购任务查询/详情提供同名可选字段,详情按 CNY 展示,空值显示“尚未获取”。旧 Agent 省略字段仍可使用新 Server。
Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实付:,金额元,(免运费)及中英文冒号/逗号/空格,金额整数或最多两位小数,以十进制定点转换。不使用裸人民币数字、不跨节点拼金额;缺失、无效、溢出或多个不同金额时只省略金额。同一订单跨视口去重,切换订单清空。并不据此判断已付款。
追加迁移:1789800100000_purchase_order_amount.go,仅扩展 purchase_task 的可空非负 pdd_order_amount_cent;旧行 NULL,不推算单价×数量。先授权迁移并发布新 Server,再升级 Agent;旧 Server 严格拒绝未知字段,不能先装新 Agent 后向旧 Server 发送金额。本单不扩展普通采购 readOrderResult,也不增加 SYB cost 数据流。
## SYB 采购单号回填(#305)
实现绑定 e89de1a(2026-09-18,feat/305-syb-order-writeback),已完成代码及隔离测试;未合并、未部署、未执行业务库迁移或真实 SYB 写入。
- 普通 Agent order_created 结果和人工 order-backfill 首次成功/同单号 already_backfilled 共用事务内 ensureOrderWriteback。仅 live + syb_order + 有效 PDD 单号与 SYB stock/detail 关联时建立唯一 pending 记录(#326 起不限制任务状态);网络写入由事务外 worker 执行。不改变 Agent 请求/响应字段;金额缺失/冲突不阻止单号入队,不依赖 paymentReviewStatus。
- POST /api/admin/v1/purchase-tasks/syb-order-writeback:沿用管理员/采购员采购管理权限;body 为 {requestId: UUID, purchaseTaskIds: 正整数数组},1~100条,不允许重复ID。单条补偿也使用本接口。
- 返回 HTTP 200、{code:200,data:{items:[{taskId,result,reason?}]}};result 为 pending/succeeded/skipped/conflict。pending 仅表示受理,不代表远端已写入。无资格或不存在的条目独立 skipped,其他条目继续;数据库事务故障整次命令回滚。服务端校验与冲突错误沿用采购错误信封。
- requestId 持久幂等:对排序后的 ID 集合取摘要,相同 requestId/集合返回原接受结果,即使后台状态已推进;改变集合返回422。查询当前完成状态应重新GET,不能以重放响应判断最终成功。
- Admin 列表/详情 task 新增 orderWriteback={status,reason?,completedAt?,canSubmit}。无队列时符合资格显示 not_started,否则 not_applicable;队列状态 pending/running/succeeded/failed/conflict/unknown。仅未发起、failed/unknown 且任务仍有资格、租约已过期时 canSubmit=true;running/pending/succeeded/conflict 不提供重复发起。
- 回填只写 PDD 订单号;cost 固定0,created为空,绝不传 pddOrderAmountCent 或 actualUnitPriceCent。旧 writebackStatus/支付/物流字段不复用,旧物流 paid 规则不扩展到本接口。
- 批量勾选取“可采购重试 OR 可单号回填”的并集,两个按钮各自过滤子集;不得把 order_created 任务送入采购重试。冲突只查看,不提供覆盖。unknown 人工补偿仍先回读,成功仅指回读确认相同单号与 pdd 平台。
追加迁移 1789800200000_purchase_order_writeback.go 建立独立记录、命令和单例租约表。先授权迁移及权限对账后再运行新版 Server/Web;本实现基于 #306,发布时同时核验其金额列迁移依赖。历史记录不在迁移时自动入队。
## Chrome 客户端采购订单回填(#316)
`POST /api/client/v1/purchase-tasks/order-backfill`
- 认证:`Authorization: Bearer <客户端密钥>`;模块 `purchase_tasks`,必须显式具有 action `writeback`。模块 read、通用 write 或 purchase action 均不隐含 writeback;不接受 Admin Cookie/JWT、Device Token、URL 凭据或客户端指定 deviceId。
- 请求和逐项响应沿用本文“Agent 采购订单批量回填”契约,`requestId` 为 UUID、每批 1~50 条。客户端先扫描并按 CG 与订单号双向聚合全部冲突,再分批提交;金额缺失省略,不能发送 0 代替。
- 客户端入口不要求任务归属某一 Android 设备,但仍复用任务状态、订单号唯一、同批冲突、金额和时间校验。Agent 入口继续要求认证设备归属,两个入口的错误回读范围不得互相扩大。
- `order_created` 且订单号相同只补空金额和空下单时间;已有金额或时间不覆盖。补入页面时间时保存 `backfill:page:` 来源标记并返回 `timeSource=page`;历史已有时间且无来源仍返回 `existing_unknown`。
- 扩展必须冻结启动时的 Origin、客户端身份和天数窗口;禁止 content script 接触密钥。网络超时或服务端逐项 `retryable=true` 时使用原稳定批次 UUID 重试,只有通过数量、索引和字段完整性校验的响应才确认批次。
## 下单结果可选应付总额(#325)
实现分支 fix/325-order-result,2026-09-19;未部署、未真机下单验收。POST /api/agent/v1/purchase-tasks/:taskId/result 的 resultType=order_created 新增可省略/可空 int64 字段 pddOrderAmountCent,人民币分,语义为本次待付款页明确“应付”的订单总额。actualUnitPriceCent 仍为商品单价。仅在唯一订单号和下单时间完整时随成功结果发送,金额缺失/冲突/无效/溢出时 Agent 省略金额,不影响订单事实。服务端拒绝负数或其他 resultType 携带非空金额,字符串/小数/溢出按现有严格 JSON 解码失败。
服务端只为已成功确认且无订单号占用冲突的任务补写空 pdd_order_amount_cent;省略、null 或已有非空值均不清空/覆盖。字段纳入现有结果哈希,相同 requestId/attempt/载荷幂等,不同载荷拒绝。不改支付状态,不传金额到 SYB。原列已存在,无追加迁移;新 Agent 上报前须先发布此 Server,旧 Server 不接受新结果字段。
本单不修改 #306 人工订单回填、#316 Chrome 回填、Web“实付价格”显示或历史值:这些路径仍沿用原语义,不能据此宣称全局金额已统一为应付。此节仅定义新增采购结果入口。
## Admin 订单资料补全与字段型 SYB 入队(#326)
实现提交 3a2472d,2026-09-19,feat/326-order-information;隔离测试及构建通过,未合并 main、未发布、未执行真实 SYB 写入。
复用 POST /api/admin/v1/purchase-tasks/:id/resolve-unknown 及其原有管理员/采购员权限:live 的 order_result_unknown 可人工确认订单或取消;live 的 order_created 可补全订单信息,但不能通过此接口改成 cancelled。status=order_created 时沿用 pddOrderNo、orderSubmittedAt,增加可选非负整数分 pddOrderAmountCent,复用原数据库列。订单号非空、无首尾空白/换行且不超过100字符,时间须为有效非零时间;旧客户端省略金额仍接受并保留已有值。新 Admin 补录表单要求金额,0元合法,最多两位小数,转换为整数分后提交。
已保存非空字段只补缺,不覆盖不同单号、时间或金额;订单号唯一归属仍由原模型保护。相同 requestId/已保存内容重放不重复保存,改变处理结果或已保存字段返回原采购冲突错误。前端网络失败保留输入及相同载荷的 requestId;修改载荷使用新 requestId。复用原审计字段 UnknownResolveRequestID,无新增表或持久化状态。
资料完整性由订单号、有效下单时间和非空非负订单金额推导,页面显示“信息完整”或“订单信息待补全”。order_created 仍表示已创建订单,缺金额不转 failed,不代表已付款,不重新采购。
人工确认/补录订单在同一事务中 ensureOrderWriteback;原 Agent order_created 与插件/Agent order-backfill 保持兼容。SYB 回填仅要求 live + syb_order + 非空订单号 + 有效关联 stock/detail;不检查任务状态、时间、金额、支付复核或地址后缀。列表批量读取有效关联,提交与 worker 校验相同目标范围。既有 pending/running/succeeded 不重复入队,conflict 不覆盖,unknown 保留原回读及租约规则。字段补全不恢复已失败队列,失败队列仍通过原“回填 SYB 单号”补偿操作处理。
仅回填单号,cost=0/created为空;金额、时间只存 Admin。旧物流 paid 候选流程不改,Android 与 Chrome 上传定位契约未改;今后停用插件和 Agent 回填须另行实施。本次无需数据库迁移;先发布新 Server 再发布发送金额字段的新 Web。
## Admin 采购列表已回填单号筛选(#327)
实现368f2c2(2026-09-19),feat/327-syb-writeback-filter;测试和构建通过,未合并发布。
GET /api/admin/v1/purchase-tasks 的既有 status 参数新增查询专用值 syb_writeback_succeeded。该值按独立 purchase_order_writeback.status=succeeded 且 purchase_task_id 对应当前采购任务过滤,不查询旧物流 writeback_status,不新增或改写任务状态。原 status 合法值及其他参数行为保持不变。
匹配成功回填的历史任务,不额外要求当前采购状态为 order_created;返回的 task.status 仍为真实采购状态。无单号队列及 pending/running/failed/unknown/conflict 不命中。count及分页共用子查询过滤,可与 taskId、executionMode、taskType、sybProductId、shopeeOrderNo、pddOrderNo 组合;status为空恢复不限制状态。权限与返回信封不变,不写入数据、不触发采购或SYB请求。先发布Server再发布新增筛选值的Web,无数据库迁移或Android变更。
+14 -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: f1792b7fe35925480c25b890e85fa5e551744812
synchronized_at: 2026-09-11T09:11:21Z
wiki_revision: 432e392ebe428ea19c0aa260f8e5938f36e4c3f0
synchronized_at: 2026-09-18T02:18:53Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
@@ -679,3 +679,15 @@ settings:
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
## PDD 采购单号写入(#305)
实现绑定 e89de1a(2026-09-18,未部署)。协议依据用户本地 update_syb_pdd_order_number.har 单次样本;HAR/真实标识/Cookie不进入源码、Wiki或工单。本节不宣称远端幂等或原子比较写入。
POST /am/stock/detail/updateDetailPurchaseCode,无JSON body;query:id=<stock.id>、detailId=<details[].id>、code=<PDD单号>、type=pdd、created=""、cost=0。ID必须为正整数,单号非空、无首尾空白/控制换行、最多100字符。cost是抓包确认的固定参数,不是实际金额;不得传Agent实付价格。
样本成功信封 HTTP200、status=true;随后 listByStock?hist=0 对应明细 purchaseCode 等于提交单号、purchasePlatform=pdd、purchaseStatus=1,purchaseTime由SYB生成。更新存在采购状态/时间副作用。
客户端每次处理最多一次写请求,并拒绝HTTP重定向重发;超时/连接中断/5xx/损坏响应作为结果未知。无论写响应成功或未知,都须按 stockId + detailId 唯一回读目标;相同单号+pdd才确认为成功,不同已有单号或其他非空平台记冲突且不覆盖。写前为空才写;目标缺失/重复/字段类型异常不得猜测选择。未知且未回读确认时持久化unknown,人工补偿前再次回读,不使用自动写重试。
GoAuto自身通过持久租约串行,但SYB没有CAS证据,无法原子隔离系统外客户端;不能保证任意迟到请求已终止。真实接口写入需另行明确授权;当前验证仅fake/httptest。
+40 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 933eaf6cc02fed843a6898269fdb5f6848ac2a8a
synchronized_at: 2026-09-10T01:42:05Z
wiki_revision: c208aeeeb4baa3ae2da29f13f3651910c500e360
synchronized_at: 2026-09-18T07:57:16Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -104,3 +104,41 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 建议API写超时至少为 2T+30 秒,且仍满足原最低620秒;代理读/发送超时需留足对应窗口。T=60/180时原620秒足够容纳应用预算;T=600时至少需要1230秒API写超时及适当更长的代理窗口。不得仅设置较大的AI超时却忽略传输层更早截断。
- 上述是发布核对要求,不是本单已修改/验证线上代理的结论。超大T带来长连接占用,不能用关闭超时来代替正确预算。新增失败回读不代表必须延长任何线上请求。
- 必须配套发布 Server/Web;仅上新版Web连接旧Server时,因缺失派生预算会提示等待时间不可用并停止匹配。原Android不需升级。
## 本地 Supervisor 与局域网访问
本机开发/联调环境由 `D:\supervisor\programs\goauto.conf` 分别启动 GoAuto API 与 Admin Web;实例配置只负责调用项目脚本,端口读取 `D:\OPC\goauto\config.yaml`。
- API 由 `scripts/start-server.ps1` 启动并监听所有网卡的 `ports.server`。
- Admin Web 由 `scripts/start-web.ps1` 启动,监听 `0.0.0.0` 的 `ports.web`,因此同一局域网设备可通过 `http://<本机局域网IP>:<web端口>/` 访问。
- 浏览器使用同源相对 API;Vite 将 `/api` 与 `/static` 代理到本机 `127.0.0.1:<server端口>`,避免局域网客户端错误访问自身的 `127.0.0.1`。
- 修改脚本或配置后,只需重启 Supervisor 的 `goauto-admin-ui`;API 未变化时无需重启 `goauto-admin-api`。若局域网仍无法连接,检查 Windows 防火墙是否允许 `ports.web` 的 TCP 入站。
## SYB 订单号回填 worker 部署与排错(#305)
代码绑定 e89de1a(2026-09-18),基于 #306 的07a3817;本节是部署要求,不表示已发布或业务库已迁移。
1. 另行取得明确目标环境的数据库迁移、权限对账和发布授权。核对 #306 的1789800100000金额列前置;本单追加1789800200000_purchase_order_writeback.go,建立purchase_order_writeback、purchase_order_writeback_command、purchase_order_writeback_lease三表和单例行,不改任务/付款事实,不排入历史回填、不修改定时任务开关。
2. 配套构建并发布Server/Web,沿用现有启动权限对账为管理员/采购员登记POST /api/admin/v1/purchase-tasks/syb-order-writeback。不新增角色、菜单或支付门槛。旧常驻实例使用SkipMigration时,仅重启并不能替代授权迁移。
3. API启动后每数据库恢复一个3秒轮询的持久队列worker,独立于Admin定时任务列表;只处理已排入pending及租约过期running。正式采购结果、人工订单回填成功/同号回放会排队;历史补偿仅用户勾选发起。部署重启后已有pending会执行真实SYB写入,必须包含在发布授权范围内。
4. 全局租约2分钟;会话恢复预算25秒,每次外部读/写20秒(只读客户端重试亦受该context限制),写前再次核验租约。崩溃恢复只回读,不盲目重写;unknown不自动重试,等租约到期再人工核对/补偿。同目标其他未确认写入先解决,不能删除记录规避保护。
5. 排错先看采购管理的“SYB 单号回填”状态、脱敏原因、完成时间;需要结构诊断时查询记录的taskId/status/attempt_count/error_code/lease_expires_at,不输出单号、Cookie或完整原始响应。SYB会话失败应先恢复既有登录,再勾选失败任务补偿;按钮受理不等于SYB成功。conflict只核对,不强制覆盖。
6. 不执行真实支付;实付金额只存Admin,SYB请求cost固定0。停止新版API可停止新轮询,但不保证已发送远端请求取消;回滚保留追加表和未知状态,不删除记录、不回退采购事实。迁移/多实例MySQL/真实SYB端到端仍需授权验收。
### #305/#306 线上发布验证(2026-09-18)
用户授权合并main、迁移并发布线上;main已快进至7e257ca(包含#306及#305),Server/Web从该提交重新构建。此前线上RELEASE记录60c7526;新发布目录为/home/goauto/releases/20260918-7e257ca-305,current已原子切换,goauto.service重启、Nginx校验及reload成功。先前各节“未部署”是对应提交阶段记录,本节为后续发布事实。
- 迁移前51个版本已应用,唯一缺失为1789800100000和1789800200000;服务器内受限备份/home/goauto/backups/20260918-305/database.sql(目录0700、文件0600),不下载生产数据。执行后53版本齐全,purchase_task.pdd_order_amount_cent为nullable BIGINT,独立三表和单例租约行已建立,历史回填队列0条。
- 启动对账已登记新POST接口及purchaser权限,管理员沿用原授权;已登录采购列表返回orderWriteback,空批次请求返回422且不入队。未执行真实订单回填或付款,不将空队列健康验证宣称为SYB端到端验收。
- 保持sys_job原启停值不变(任务1/2为1,3/4/5为2),没有更改Cron或主动触发同步。发布前采集/采购运行数均0。
- 原20260916目录的static/var与20260914目录互相循环,不能继续通过旧current间接链接。新release分别直接引用已确认真实存在的/home/goauto/releases/20260907-29ba16e-236/static和/home/goauto/releases/20260903-2d6d244-r1/var;不删除或改写历史资源,不修改其他站点Nginx配置。后续清理旧release前必须检查这些被引用的数据目录。
- 公网入口http://185.216.248.75:9527首页和10项JS/CSS资源HTTP200,/api/v1/health HTTP200;未认证采购接口业务码401。二进制SHA256=4833d779843f0613455ea2b56cadff6efd88d2b96c13e00cb4423cac02e72cb7;发布后结构日志检查无panic/fatal/1146/1054。
- 回滚代码目标保留/home/goauto/releases/20260916-03647dd,追加数据库表/列保留;该旧目录原有static/var循环是已知问题,回退不能宣称资源链自动恢复。不要删除新队列记录或恢复旧数据覆盖后续业务;停止服务亦不保证已发送远端请求取消。
## Chrome 订单回填扩展交付与运行(#316)
- 扩展以 `chrome-extension/manifest.json` 为入口,不随 Server 自动部署。解压安装、授予 Admin Origin、创建或修改客户端密钥授权、真实 PDD 遍历及订单回填都必须按目标环境另行授权;发布 Server 不等于安装或启用扩展。
- 管理员为密钥显式勾选“采购管理 → 回填(writeback)”。建议 HTTPS;若使用项目既有客户端密钥 HTTP 例外,密钥和订单事实会明文传输,风险边界不扩大到 Cookie、JWT 或 Device Token。
- popup 关闭不停止运行;重新打开可查看持久状态。设置在运行时只读。停止会保留已确认批次并阻止后续请求;重启或超时仅以原批次 UUID 重放未确认批次。
- 排错只查看阶段、已读、可提交、已写入、冲突、未完整扫描原因、脱敏错误码和批次状态;不得记录客户端密钥、地址全文、订单号、Cookie、原始 HTML 或真实订单载荷。没有明确列表末尾证据时只能报告未完整扫描。
+36
View File
@@ -97,6 +97,31 @@ function Read-DatabaseConfig {
}
}
function Read-ErpGoConfig {
param([string]$Path)
# Optional section. Absent or incomplete means the Shopee spec sync stays
# off; the server treats that as "skip", not as an error (#290).
$values = @{}
$insideSection = $false
foreach ($line in Get-Content -LiteralPath $Path) {
if ($line -match '^\s*(#.*)?$') {
continue
}
if ($line -match '^erpgo\s*:\s*$') {
$insideSection = $true
continue
}
if ($insideSection -and $line -match '^\S') {
break
}
if ($insideSection -and $line -match '^\s+(baseUrl|apikey)\s*:\s*(.*?)\s*$') {
$values[$Matches[1]] = ConvertFrom-YamlScalar $Matches[2]
}
}
return $values
}
function Read-PortConfig {
param([string]$Path)
@@ -169,6 +194,7 @@ try {
$ConfigPath = [IO.Path]::GetFullPath($ConfigPath)
$databaseConfig = Read-DatabaseConfig $ConfigPath
$portConfig = Read-PortConfig $ConfigPath
$erpgoConfig = Read-ErpGoConfig $ConfigPath
if (-not $PSBoundParameters.ContainsKey('DatabaseHost')) {
$DatabaseHost = $databaseConfig.Host
@@ -219,6 +245,14 @@ try {
$env:GOAUTO_DB_DRIVER = "mysql"
$env:GOAUTO_DB_DSN = "${DatabaseUser}:${plainPassword}@tcp(${DatabaseHost}:${DatabasePort})/${DatabaseName}?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s"
$env:GOAUTO_SERVER_PORT = [string]$portConfig.Server
# Shopee spec service. The key never reaches the repo: config.yaml is
# gitignored and the server only ever reads the environment (#290).
if ($erpgoConfig.ContainsKey('baseUrl') -and -not [string]::IsNullOrWhiteSpace([string]$erpgoConfig.baseUrl)) {
$env:GOAUTO_ERPGO_BASE_URL = [string]$erpgoConfig.baseUrl
}
if ($erpgoConfig.ContainsKey('apikey') -and -not [string]::IsNullOrWhiteSpace([string]$erpgoConfig.apikey)) {
$env:GOAUTO_ERPGO_APIKEY = [string]$erpgoConfig.apikey
}
Push-Location $serverDirectory
try {
@@ -272,5 +306,7 @@ finally {
Remove-Item Env:GOAUTO_DB_DRIVER -ErrorAction SilentlyContinue
Remove-Item Env:GOAUTO_SERVER_PORT -ErrorAction SilentlyContinue
Remove-Item Env:GOAUTO_CONFIG -ErrorAction SilentlyContinue
Remove-Item Env:GOAUTO_ERPGO_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:GOAUTO_ERPGO_APIKEY -ErrorAction SilentlyContinue
$plainPassword = $null
}
+7 -3
View File
@@ -168,18 +168,22 @@ try {
}
}
$env:VUE_APP_BASE_API = "http://127.0.0.1:$($ports.Server)"
Write-Host "Starting GoAuto web UI at http://127.0.0.1:$($ports.Web)" -ForegroundColor Green
# Keep browser requests same-origin. Vite proxies API/static requests to the
# local backend, so a LAN client never tries to call its own 127.0.0.1.
$env:VUE_APP_BASE_API = ""
$env:VITE_DEV_PROXY_TARGET = "http://127.0.0.1:$($ports.Server)"
Write-Host "Starting GoAuto web UI at http://0.0.0.0:$($ports.Web)" -ForegroundColor Green
$viteCli = Join-Path $webDirectory "node_modules\vite\bin\vite.js"
if (-not (Test-Path -LiteralPath $viteCli -PathType Leaf)) {
throw "Vite was not found after dependency installation: $viteCli"
}
& $nodePath $viteCli --host 127.0.0.1 --port $ports.Web --strictPort
& $nodePath $viteCli --host 0.0.0.0 --port $ports.Web --strictPort
if ($LASTEXITCODE -ne 0) {
throw "Frontend exited with code $LASTEXITCODE."
}
}
finally {
Remove-Item Env:VUE_APP_BASE_API -ErrorAction SilentlyContinue
Remove-Item Env:VITE_DEV_PROXY_TARGET -ErrorAction SilentlyContinue
Pop-Location
}
+3
View File
@@ -101,6 +101,8 @@ var AdminAPIs = []APIPermission{
{"批量创建图搜采集任务", "/api/admin/v1/collection-tasks/image-search/batch", "POST", true},
{"查看采集任务详情", "/api/admin/v1/collection-tasks/:taskId", "GET", true},
{"重置采集任务", "/api/admin/v1/collection-tasks/:taskId/reset", "POST", true},
{"取消采集任务", "/api/admin/v1/collection-tasks/:taskId/cancel", "POST", true},
{"批量取消采集任务", "/api/admin/v1/collection-tasks/batch-cancel", "POST", true},
{"删除采集任务", "/api/admin/v1/collection-tasks/:taskId", "DELETE", true},
{"查看采购任务", "/api/admin/v1/purchase-tasks", "GET", true},
@@ -108,6 +110,7 @@ var AdminAPIs = []APIPermission{
{"批量 AI 匹配采购规格", "/api/admin/v1/purchase-tasks/batch-spec-match", "POST", true},
{"批量创建采购任务", "/api/admin/v1/purchase-tasks/batch", "POST", true},
{"批量重试采购任务", "/api/admin/v1/purchase-tasks/batch-retry", "POST", true},
{"回填SYB采购单号", "/api/admin/v1/purchase-tasks/syb-order-writeback", "POST", true},
{"创建备货采购任务", "/api/admin/v1/purchase-tasks/stock", "POST", true},
{"查看采购任务详情", "/api/admin/v1/purchase-tasks/:taskId", "GET", true},
{"创建采购任务", "/api/admin/v1/purchase-tasks", "POST", true},
@@ -2,6 +2,15 @@ package access
import "testing"
func TestPurchaserMayWritebackOrderNumber(t *testing.T) {
for _, permission := range PurchaserAPIs() {
if permission.Method == "POST" && permission.Path == "/api/admin/v1/purchase-tasks/syb-order-writeback" {
return
}
}
t.Fatal("missing purchaser order number writeback permission")
}
func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
seen := map[string]bool{}
for _, permission := range AdminAPIs {
@@ -91,6 +91,43 @@ func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
}
}
func keyFor(e Endpoint) string { return e.Method + " " + e.Path }
func TestOrderBackfillRequiresExplicitWritebackAction(t *testing.T) {
db, s := fixture(t)
e := Endpoint{Method: "POST", Path: "/purchase-tasks/order-backfill", Module: "purchase_tasks", Capability: "writeback"}
router := gin.New()
called := 0
router.POST("/api/client/v1"+e.Path, Gate(db, e), func(c *gin.Context) { called++; c.JSON(200, gin.H{"data": "ok"}) })
send := func(token string) int {
req := httptest.NewRequest("POST", "https://example.test/api/client/v1"+e.Path, strings.NewReader(`{"requestId":"00000000-0000-4000-8000-000000000316","items":[]}`))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr.Code
}
if got := send(""); got != 401 {
t.Fatalf("missing key status %d", got)
}
if (clientkey.Key{GrantsJSON: `[{"module":"purchase_tasks","write":true}]`}).Allows("purchase_tasks", "writeback") {
t.Fatal("generic write must not imply writeback")
}
_, writeOnly, err := s.Create(context.Background(), "purchase-only", []clientkey.Grant{{Module: "purchase_tasks", Actions: []string{"purchase"}}}, 1)
if err != nil {
t.Fatal(err)
}
if got := send(writeOnly); got != 403 {
t.Fatalf("generic write unexpectedly granted writeback: %d", got)
}
_, authorized, err := s.Create(context.Background(), "writeback", []clientkey.Grant{{Module: "purchase_tasks", Actions: []string{"writeback"}}}, 1)
if err != nil {
t.Fatal(err)
}
if got := send(authorized); got != 200 || called != 1 {
t.Fatalf("explicit writeback status=%d called=%d", got, called)
}
}
func TestRevocationTransportRedactionAndAudit(t *testing.T) {
db, s := fixture(t)
v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products"}}, 1)
+1
View File
@@ -105,6 +105,7 @@ func Inventory() []Endpoint {
{"POST", "/purchase-tasks/batch", access.ModulePurchaseTasks, "purchase", buy.AdminBatchCreate},
{"POST", "/purchase-tasks/batch-retry", access.ModulePurchaseTasks, "purchase", buy.AdminBatchRetry},
{"POST", "/purchase-tasks/stock", access.ModulePurchaseTasks, "purchase", buy.AdminCreateStock},
{"POST", "/purchase-tasks/order-backfill", access.ModulePurchaseTasks, "writeback", buy.ClientBackfillOrders},
{"GET", "/ai-matching-settings", access.ModuleAIMatching, "read", func(c *gin.Context) {
db, err := pkg.GetOrm(c)
if err != nil {
+44 -2
View File
@@ -56,6 +56,9 @@ func MigratedModels() []any {
&models.PDDAccount{},
&models.PurchaseTask{},
&models.PurchaseTaskAttempt{},
&models.PurchaseOrderWriteback{},
&models.PurchaseOrderWritebackLease{},
&models.PurchaseOrderWritebackCommand{},
&models.PurchaseSpecMatchWorkItem{},
&models.CollectionRule{},
&models.PurchaseRule{},
@@ -88,7 +91,12 @@ func Migrate(db *gorm.DB) error {
return err
}
// #269: seed immutable structure判据 and the initial keyword safety net.
seed := []struct{ kind, keyword string }{{"char", "-"}, {"char", "#"}, {"keyword", "CVC短袖220斤"}, {"keyword", "180克純棉圓領短袖"}, {"keyword", "拼色純棉短袖"}, {"keyword", "300斤牛奶絲圓領"}, {"keyword", "印花大學T"}, {"keyword", "印花帽T"}}
//
// `[必须]` 结构过滤自 #286 起只有**一条**,keyword 存的是必需字符集合
// `-#`,意思是“同时包含 - 和 #”。这里不能再播种旧的两条:本函数
// 每次启动都会 FirstOrCreate,播了就会把迁移 1789500000000 合并掉的
// `-` 和 `#` 重新建回来,过滤静默退回或关系。
seed := []struct{ kind, keyword string }{{"char", "-#"}, {"keyword", "CVC短袖220斤"}, {"keyword", "180克純棉圓領短袖"}, {"keyword", "拼色純棉短袖"}, {"keyword", "300斤牛奶絲圓領"}, {"keyword", "印花大學T"}, {"keyword", "印花帽T"}}
for _, item := range seed {
norm := sybshop.Normalize(item.keyword)
row := models.SYBProductFilter{Kind: item.kind, Keyword: item.keyword, NormalizedKeyword: norm, Enabled: true}
@@ -96,7 +104,41 @@ func Migrate(db *gorm.DB) error {
return err
}
}
return ensureMySQLDirectSelectConstraint(db)
if err := ensureMySQLDirectSelectConstraint(db); err != nil {
return err
}
return EnsureCollectionTaskCancelledConstraint(db)
}
// ensureMySQLCollectionTaskCancelledConstraint 放开 collection_task.status 的
// CHECK 约束以接受 'cancelled'(#297)。GORM 的 AutoMigrate 在 MySQL 上不会
// 改写已存在的 CHECK 约束(同 ensureMySQLDirectSelectConstraint 的已知限制),
// 所以新增取消状态必须像 direct_select 那次一样手动 DROP/ADD,否则老库的约束
// 仍然只认旧的五个状态,取消写入会被数据库直接拒绝。
// EnsureCollectionTaskCancelledConstraint is exported so the version-local
// migration can call it: that file is the only path that reaches an existing
// database (see 1789700000000_collection_task_cancelled.go).
func EnsureCollectionTaskCancelledConstraint(db *gorm.DB) error {
if db.Dialector.Name() != "mysql" {
return nil
}
const name = "ck_collection_task_status"
var constraints []struct {
CheckClause string `gorm:"column:check_clause"`
}
if err := db.Raw(mysqlCheckConstraintQuery, "collection_task", name).Scan(&constraints).Error; err != nil {
return err
}
if len(constraints) > 0 && strings.Contains(strings.ToLower(constraints[0].CheckClause), "cancelled") {
return nil
}
if len(constraints) > 0 {
if err := db.Exec("ALTER TABLE collection_task DROP CHECK " + name).Error; err != nil {
return err
}
}
return db.Exec("ALTER TABLE collection_task ADD CONSTRAINT " + name +
" CHECK (status IN ('pending','running','completed','completed_partial','failed','cancelled'))").Error
}
func ensureMySQLDirectSelectConstraint(db *gorm.DB) error {
+20 -7
View File
@@ -138,12 +138,17 @@ type PurchaseTask struct {
StatusVersion uint64 `json:"statusVersion" gorm:"not null;default:1"`
StatusChangedAt time.Time `json:"statusChangedAt" gorm:"not null"`
PDDOrderNo *string `json:"pddOrderNo" gorm:"size:100;index"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt"`
IrreversibleAt *time.Time `json:"irreversibleAt"`
PaymentReviewStatus string `json:"paymentReviewStatus" gorm:"size:16;not null;default:pending;check:ck_purchase_task_payment_review,payment_review_status IN ('pending','paid','unpaid')"`
PaymentReviewedAt *time.Time `json:"paymentReviewedAt"`
PaymentReviewedBy *uint64 `json:"paymentReviewedBy"`
PDDOrderNo *string `json:"pddOrderNo" gorm:"size:100;index"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt"`
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty" gorm:"check:ck_purchase_task_order_amount,pdd_order_amount_cent IS NULL OR pdd_order_amount_cent >= 0"`
IrreversibleAt *time.Time `json:"irreversibleAt"`
// PaymentPageObservedAt 仅在 order_result_unknown 时可能有值:采样期间见过
// 待付款/支付相关文案,说明大概率已经在 PDD 建了订单,只是没能读全证据。
// 用于给积压的 order_result_unknown 记录分优先级,不改变判定结果本身(#302)。
PaymentPageObservedAt *time.Time `json:"paymentPageObservedAt"`
PaymentReviewStatus string `json:"paymentReviewStatus" gorm:"size:16;not null;default:pending;check:ck_purchase_task_payment_review,payment_review_status IN ('pending','paid','unpaid')"`
PaymentReviewedAt *time.Time `json:"paymentReviewedAt"`
PaymentReviewedBy *uint64 `json:"paymentReviewedBy"`
TrackingNo *string `json:"trackingNo" gorm:"size:120"`
TrackingCollectedAt *time.Time `json:"trackingCollectedAt"`
@@ -202,7 +207,15 @@ func (task *PurchaseTask) BeforeCreate(_ *gorm.DB) error {
return task.syncPurchaseGuardSlots()
}
func (task *PurchaseTask) BeforeSave(_ *gorm.DB) error { return task.syncPurchaseGuardSlots() }
func (task *PurchaseTask) BeforeSave(tx *gorm.DB) error {
if err := task.syncPurchaseGuardSlots(); err != nil {
return err
}
if task.PDDOrderNo != nil && *task.PDDOrderNo != "" {
return CheckPurchaseOrderNumber(tx, task.ID, *task.PDDOrderNo)
}
return nil
}
func (task *PurchaseTask) SetStatus(status string) error {
task.Status = status
@@ -0,0 +1,41 @@
package models
import (
"errors"
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var ErrPurchaseOrderNumberUsed = errors.New("purchase order number belongs to another task")
type PurchaseOrderNumberUsedError struct {
TaskID uint64
}
func (e *PurchaseOrderNumberUsedError) Error() string {
return fmt.Sprintf("订单号已属于任务 CG-%d", e.TaskID)
}
func (e *PurchaseOrderNumberUsedError) Unwrap() error { return ErrPurchaseOrderNumberUsed }
// CheckPurchaseOrderNumber must run inside the caller's write transaction.
// The existing singleton setting row serializes order assignments across
// processes, including an absent order number, without relying on gap locks or
// a new schema constraint. Locking reads see the latest committed assignment.
// A missing singleton fails closed. Deadlocks roll back the losing transaction.
func CheckPurchaseOrderNumber(tx *gorm.DB, taskID uint64, orderNo string) error {
var setting PurchaseRuleSetting
if err := tx.Session(&gorm.Session{NewDB: true}).Clauses(clause.Locking{Strength: "UPDATE"}).First(&setting, 1).Error; err != nil {
return err
}
var others []PurchaseTask
if err := tx.Session(&gorm.Session{NewDB: true}).Select("id").Clauses(clause.Locking{Strength: "UPDATE"}).Where("pdd_order_no = ? AND id <> ?", orderNo, taskID).Find(&others).Error; err != nil {
return err
}
if len(others) != 0 {
return &PurchaseOrderNumberUsedError{TaskID: others[0].ID}
}
return nil
}
@@ -0,0 +1,43 @@
package models
import "time"
// PurchaseOrderWriteback is independent of the legacy logistics writeback fields.
type PurchaseOrderWriteback struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
PurchaseTaskID uint64 `gorm:"not null;uniqueIndex"`
StockID int64 `gorm:"not null;index:idx_order_writeback_target,priority:1"`
DetailID int64 `gorm:"not null;index:idx_order_writeback_target,priority:2"`
OrderNo string `json:"-" gorm:"size:100;not null"`
Status string `gorm:"size:24;not null;index"`
AttemptCount int `gorm:"not null;default:0"`
WriteStarted bool `gorm:"not null;default:false"`
LeaseOwner string `gorm:"size:36"`
LeaseExpiresAt *time.Time
ErrorCode string `gorm:"size:80"`
ErrorMessage string `gorm:"size:300"`
CompletedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
func (PurchaseOrderWriteback) TableName() string { return "purchase_order_writeback" }
// A singleton lease serializes all SYB order-number writers, including different
// tasks that resolve to the same remote stock/detail identity.
type PurchaseOrderWritebackLease struct {
ID uint64 `gorm:"primaryKey"`
Owner string `gorm:"size:36"`
ExpiresAt *time.Time
}
func (PurchaseOrderWritebackLease) TableName() string { return "purchase_order_writeback_lease" }
type PurchaseOrderWritebackCommand struct {
RequestID string `gorm:"primaryKey;size:36"`
InputHash string `gorm:"size:64;not null"`
ResultJSON string `json:"-" gorm:"type:text;not null"`
CreatedAt time.Time
}
func (PurchaseOrderWritebackCommand) TableName() string { return "purchase_order_writeback_command" }
+14 -2
View File
@@ -17,6 +17,9 @@ const (
TaskStatusCompleted = "completed"
TaskStatusCompletedPartial = "completed_partial"
TaskStatusFailed = "failed"
// TaskStatusCancelled 与 PurchaseTaskStatusCancelled 的既有约定对齐(#297):
// 只有 pending 任务允许取消,取消后是终态,不再被任何设备领取。
TaskStatusCancelled = "cancelled"
CollectionTaskSourceAdmin = "admin"
CollectionTaskSourceAgentCurrentPage = "agent_current_page"
@@ -183,7 +186,7 @@ type CollectionTask struct {
DeviceID *uint64 `json:"deviceId" gorm:"index;uniqueIndex:ux_collection_task_running_device,priority:1"`
Device *AgentDevice `json:"-"`
Source string `json:"source" gorm:"size:32;not null;default:admin;index;check:ck_collection_task_source,source IN ('admin','agent_current_page','image_search')"`
Status string `json:"status" gorm:"size:24;not null;index;check:ck_collection_task_status,status IN ('pending','running','completed','completed_partial','failed')"`
Status string `json:"status" gorm:"size:24;not null;index;check:ck_collection_task_status,status IN ('pending','running','completed','completed_partial','failed','cancelled')"`
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_active_product,priority:2;check:ck_collection_task_active_slot,(status IN ('pending','running') AND active_slot = 1) OR (status NOT IN ('pending','running') AND active_slot IS NULL)"`
DeviceRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_running_device,priority:2;check:ck_collection_task_device_run_slot,(status = 'running' AND device_id IS NOT NULL AND device_run_slot = 1) OR (status <> 'running' AND device_run_slot IS NULL)"`
URLSnapshot string `json:"urlSnapshot" gorm:"type:text;not null"`
@@ -277,7 +280,7 @@ func (task *CollectionTask) syncGuardSlots() error {
}
task.ActiveSlot = &one
task.DeviceRunSlot = &one
case TaskStatusCompleted, TaskStatusCompletedPartial, TaskStatusFailed:
case TaskStatusCompleted, TaskStatusCompletedPartial, TaskStatusFailed, TaskStatusCancelled:
task.ActiveSlot = nil
task.DeviceRunSlot = nil
default:
@@ -420,6 +423,15 @@ type ShopeeProduct struct {
// PDD product.
PDDProductID *uint64 `json:"pddProductId" gorm:"index"`
ImageSearchLinked bool `json:"imageSearchLinked" gorm:"not null;default:false;index"`
// SpecSyncPDDProductID records which PDD product the full Shopee spec list
// was synced and matched against (#290).
//
// `[必须]` 存 PDD 商品 id 而不是布尔值:映射是对着某个 PDD 商品的规格值建的,
// 一旦重新关联到别的 PDD 商品,旧映射全部失效。跟踪具体是哪个商品,则跳过
// 判据变成 SpecSyncPDDProductID == PDDProductID,重新关联时标记自动失效,
// 不依赖“记得去清”。
SpecSyncPDDProductID *uint64 `json:"specSyncPddProductId,omitempty" gorm:"index"`
SpecSyncAt *time.Time `json:"specSyncAt,omitempty"`
// ImageURL holds the SYB-provided reference image URL. It is written by the
// #41 import and may be overridden manually; the product domain never joins
// syb_products at read time.
+112 -54
View File
@@ -25,57 +25,60 @@ type AdminListRequest struct {
}
type AdminTaskItem struct {
ID uint64 `json:"id"`
TaskType string `json:"taskType"`
ExecutionMode string `json:"executionMode"`
Status string `json:"status"`
SYBProductID *uint64 `json:"sybProductId,omitempty"`
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
PDDProductID uint64 `json:"pddProductId"`
DeviceID *uint64 `json:"deviceId,omitempty"`
DeviceName string `json:"deviceName,omitempty"`
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"`
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"`
PDDTitleSnapshot string `json:"pddTitleSnapshot"`
TargetColorSnapshot string `json:"targetColorSnapshot"`
TargetSizeSnapshot string `json:"targetSizeSnapshot"`
MappedColorSnapshot string `json:"mappedColorSnapshot"`
MappedSizeSnapshot string `json:"mappedSizeSnapshot"`
SpecSource string `json:"specSource"`
Quantity int64 `json:"quantity"`
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"`
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
Currency string `json:"currency"`
PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"`
AddressSuffix string `json:"addressSuffix"`
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"`
PaymentReviewStatus string `json:"paymentReviewStatus"`
PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"`
TrackingNo *string `json:"trackingNo,omitempty"`
TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"`
LogisticsStatus string `json:"logisticsStatus"`
WritebackStatus string `json:"writebackStatus"`
WritebackAt *time.Time `json:"writebackAt,omitempty"`
RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"`
RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"`
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
CancelReason *string `json:"cancelReason,omitempty"`
ErrorCode *string `json:"errorCode,omitempty"`
ErrorMessage *string `json:"errorMessage,omitempty"`
Retryable bool `json:"retryable"`
RetryDisabledCode string `json:"retryDisabledCode,omitempty"`
RetryDisabledReason string `json:"retryDisabledReason,omitempty"`
StatusVersion uint64 `json:"statusVersion"`
StatusChangedAt time.Time `json:"statusChangedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Matching MatchingView `json:"matching"`
OrderWriteback OrderWritebackView `json:"orderWriteback"`
ID uint64 `json:"id"`
TaskType string `json:"taskType"`
ExecutionMode string `json:"executionMode"`
Status string `json:"status"`
SYBProductID *uint64 `json:"sybProductId,omitempty"`
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
PDDProductID uint64 `json:"pddProductId"`
DeviceID *uint64 `json:"deviceId,omitempty"`
DeviceName string `json:"deviceName,omitempty"`
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"`
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"`
PDDTitleSnapshot string `json:"pddTitleSnapshot"`
TargetColorSnapshot string `json:"targetColorSnapshot"`
TargetSizeSnapshot string `json:"targetSizeSnapshot"`
MappedColorSnapshot string `json:"mappedColorSnapshot"`
MappedSizeSnapshot string `json:"mappedSizeSnapshot"`
SpecSource string `json:"specSource"`
Quantity int64 `json:"quantity"`
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"`
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
Currency string `json:"currency"`
PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"`
AddressSuffix string `json:"addressSuffix"`
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"`
PaymentReviewStatus string `json:"paymentReviewStatus"`
PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"`
TrackingNo *string `json:"trackingNo,omitempty"`
TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"`
LogisticsStatus string `json:"logisticsStatus"`
WritebackStatus string `json:"writebackStatus"`
WritebackAt *time.Time `json:"writebackAt,omitempty"`
RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"`
RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"`
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
CancelReason *string `json:"cancelReason,omitempty"`
ErrorCode *string `json:"errorCode,omitempty"`
ErrorMessage *string `json:"errorMessage,omitempty"`
Retryable bool `json:"retryable"`
RetryDisabledCode string `json:"retryDisabledCode,omitempty"`
RetryDisabledReason string `json:"retryDisabledReason,omitempty"`
StatusVersion uint64 `json:"statusVersion"`
StatusChangedAt time.Time `json:"statusChangedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Matching MatchingView `json:"matching"`
ImageSearchLinked bool `json:"imageSearchLinked,omitempty"`
}
type AdminAttemptItem struct {
@@ -106,6 +109,9 @@ type AdminDetailResponse struct {
Matching MatchingView `json:"matching"`
}
// Query-only value: never a PurchaseTask status or a logistics writeback state.
const adminStatusSYBWritebackSucceeded = "syb_writeback_succeeded"
func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminListResponse, error) {
if req.Page < 1 {
req.Page = 1
@@ -116,7 +122,7 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if req.PageSize > 100 {
req.PageSize = 100
}
if req.Status != "" && !validPurchaseStatus(req.Status) {
if req.Status != "" && req.Status != adminStatusSYBWritebackSucceeded && !validPurchaseStatus(req.Status) {
return AdminListResponse{}, fail(CodeInvalidRequest, "status 无效")
}
if req.ExecutionMode != "" && req.ExecutionMode != models.PurchaseExecutionModeRehearsal && req.ExecutionMode != models.PurchaseExecutionModeLive {
@@ -129,7 +135,9 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if req.TaskID > 0 {
query = query.Where("id = ?", req.TaskID)
}
if req.Status != "" {
if req.Status == adminStatusSYBWritebackSucceeded {
query = query.Where("id IN (?)", s.DB.WithContext(ctx).Model(&models.PurchaseOrderWriteback{}).Select("purchase_task_id").Where("status = ?", "succeeded"))
} else if req.Status != "" {
query = query.Where("status = ?", req.Status)
}
if req.ExecutionMode != "" {
@@ -163,15 +171,53 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if err != nil {
return AdminListResponse{}, err
}
imageSearchLinked, err := s.loadImageSearchLinked(ctx, tasks)
if err != nil {
return AdminListResponse{}, err
}
items := make([]AdminTaskItem, 0, len(tasks))
writebacks, err := s.OrderWritebackViews(ctx, tasks)
if err != nil {
return AdminListResponse{}, err
}
for _, task := range tasks {
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
item.OrderWriteback = writebacks[task.ID]
item.Matching = matching[task.ID]
if task.ShopeeProductID != nil {
item.ImageSearchLinked = imageSearchLinked[*task.ShopeeProductID]
}
items = append(items, item)
}
return AdminListResponse{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, nil
}
// loadImageSearchLinked bulk-loads the read-only "linked via image search, not
// manually verified" flag for purchase-task list/detail views (#279 item 5).
// It is display-only and must never influence eligibility or matching.
func (s *Service) loadImageSearchLinked(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]bool, error) {
result := make(map[uint64]bool)
ids := make([]uint64, 0, len(tasks))
seen := make(map[uint64]bool)
for _, task := range tasks {
if task.ShopeeProductID != nil && !seen[*task.ShopeeProductID] {
ids = append(ids, *task.ShopeeProductID)
seen[*task.ShopeeProductID] = true
}
}
if len(ids) == 0 {
return result, nil
}
var shopeeProducts []models.ShopeeProduct
if err := s.DB.WithContext(ctx).Select("id, image_search_linked").Where("id IN ?", ids).Find(&shopeeProducts).Error; err != nil {
return nil, internal(err)
}
for _, shopee := range shopeeProducts {
result[shopee.ID] = shopee.ImageSearchLinked
}
return result, nil
}
func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailResponse, error) {
var task models.PurchaseTask
if err := s.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
@@ -202,7 +248,19 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
return AdminDetailResponse{}, matchErr
}
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
writebacks, wbErr := s.OrderWritebackViews(ctx, []models.PurchaseTask{task})
if wbErr != nil {
return AdminDetailResponse{}, wbErr
}
item.OrderWriteback = writebacks[task.ID]
item.Matching = matching
imageSearchLinked, err := s.loadImageSearchLinked(ctx, []models.PurchaseTask{task})
if err != nil {
return AdminDetailResponse{}, err
}
if task.ShopeeProductID != nil {
item.ImageSearchLinked = imageSearchLinked[*task.ShopeeProductID]
}
return AdminDetailResponse{Task: item, Attempts: items, Matching: matching}, nil
}
@@ -241,7 +299,7 @@ func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string, retr
SpecSource: task.SpecSource, Quantity: task.Quantity, ReferenceUnitPriceCent: task.ReferenceUnitPriceCent,
MinUnitPriceCent: task.MinUnitPriceCent, MaxUnitPriceCent: task.MaxUnitPriceCent, Currency: task.Currency,
PDDAccountRefSnapshot: task.PDDAccountRefSnapshot, AddressSuffix: task.AddressSuffix,
PDDOrderNo: task.PDDOrderNo, OrderSubmittedAt: task.OrderSubmittedAt, IrreversibleAt: task.IrreversibleAt,
PDDOrderNo: task.PDDOrderNo, OrderSubmittedAt: task.OrderSubmittedAt, PDDOrderAmountCent: task.PDDOrderAmountCent, IrreversibleAt: task.IrreversibleAt,
PaymentReviewStatus: task.PaymentReviewStatus, PaymentReviewedAt: task.PaymentReviewedAt,
TrackingNo: task.TrackingNo, TrackingCollectedAt: task.TrackingCollectedAt,
LogisticsStatus: task.LogisticsStatus, WritebackStatus: task.WritebackStatus, WritebackAt: task.WritebackAt,
+58 -1
View File
@@ -105,6 +105,63 @@ func TestAdminQueryValidationAndNotFound(t *testing.T) {
}
}
func TestAdminListSYBWritebackSucceededFilter(t *testing.T) {
s, first := orderWritebackFixture(t)
db := s.DB
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", first.ID).Update("status", "succeeded").Error; err != nil {
t.Fatal(err)
}
var successIDs []uint64
successIDs = append(successIDs, first.ID)
for _, state := range []string{"succeeded", "pending", "running", "failed", "unknown", "conflict", "none"} {
task := first
task.ID = 0
task.Status = models.PurchaseTaskStatusCancelled
task.PDDOrderNo = nil
task.WritebackStatus = models.PurchaseWritebackStatusSucceeded // old logistics must not match
task.CreateRequestID = uuid.NewString()
task.UnknownResolveRequestID = nil
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
if state != "none" {
if err := db.Create(&models.PurchaseOrderWriteback{PurchaseTaskID: task.ID, StockID: 2, DetailID: 1, OrderNo: "FILTER-DEMO", Status: state}).Error; err != nil {
t.Fatal(err)
}
}
if state == "succeeded" {
successIDs = append(successIDs, task.ID)
}
}
for page := 1; page <= 2; page++ {
out, err := s.AdminList(context.Background(), AdminListRequest{Status: adminStatusSYBWritebackSucceeded, Page: page, PageSize: 1})
if err != nil || out.Total != 2 || len(out.Items) != 1 || out.Items[0].ID != successIDs[2-page] {
t.Fatalf("page %d: %+v, %v", page, out, err)
}
}
for _, req := range []AdminListRequest{
{Status: adminStatusSYBWritebackSucceeded, TaskID: first.ID},
{Status: models.PurchaseTaskStatusOrderCreated},
{Status: adminStatusSYBWritebackSucceeded, PDDOrderNo: *first.PDDOrderNo, ExecutionMode: "live", TaskType: "syb_order", SYBProductID: *first.SYBProductID},
} {
out, err := s.AdminList(context.Background(), req)
if err != nil || out.Total != 1 || len(out.Items) != 1 || out.Items[0].ID != first.ID {
t.Fatalf("combined filter: %+v %v", out, err)
}
}
out, err := s.AdminList(context.Background(), AdminListRequest{})
if err != nil || out.Total != 8 {
t.Fatalf("clear filter: %+v %v", out, err)
}
out, err = s.AdminList(context.Background(), AdminListRequest{Status: adminStatusSYBWritebackSucceeded, PDDOrderNo: "not-found"})
if err != nil || out.Total != 0 {
t.Fatalf("empty filter: %+v %v", out, err)
}
if validPurchaseStatus(adminStatusSYBWritebackSucceeded) {
t.Fatal("filter became task state")
}
}
func TestAdminQueryHandlersRequireOperatorRole(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, path := range []string{"/api/admin/v1/purchase-tasks", "/api/admin/v1/purchase-tasks/1"} {
@@ -125,7 +182,7 @@ func TestAdminQueryHandlersRequireOperatorRole(t *testing.T) {
func TestAdminBatchHandlersRequireOperatorRole(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, handler := range []func(*gin.Context){(Handler{}).AdminBatchPreview, (Handler{}).AdminBatchCreate, (Handler{}).AdminBatchRetry} {
for _, handler := range []func(*gin.Context){(Handler{}).AdminBatchPreview, (Handler{}).AdminBatchCreate, (Handler{}).AdminBatchRetry, (Handler{}).AdminOrderWriteback} {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/admin/v1/purchase-tasks/batch", strings.NewReader(`{}`))
@@ -0,0 +1,177 @@
package purchase
import (
"context"
"strings"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"github.com/google/uuid"
)
// ArchiveSpecMatchResult counts what one archive-wide matching pass did.
//
// NoMatch 与 Unavailable 分开计数:前者是 AI 明确说没有可靠对应(PDD 确实没有该
// 颜色,或候选有歧义),后者是服务不可用。两者的处置完全不同,此前都记成 failed,
// 排查时分不开(#295)。
type ArchiveSpecMatchResult struct {
Confirmed int
Skipped int
NoMatch int
Unavailable int
// Aborted 是本端 context 取消或超时导致的中断,与 Provider 故障无关(#300)。
Aborted int
}
// Failed is every value that ended without a mapping, whatever the reason.
func (r ArchiveSpecMatchResult) Failed() int { return r.NoMatch + r.Unavailable + r.Aborted }
// MatchArchiveSpecs matches every still-unmapped value in a Shopee product's
// archive against its linked PDD product (#293).
//
// 存在的理由:BatchSpecMatch 遍历的是 SYB 明细,只匹配明细需要的值。#290 把虾皮
// 的完整颜色尺码同步进档案后,那些还没有订单的值一个都不会被尝试——等订单真来了
// 仍然要人工点一次匹配,正是 #290 想消除的动作。
//
// `[必须]` 这里只写规格映射,不创建任何采购。匹配只把明细推到“采购就绪”,创建
// 采购仍由人点击——那是整条链上唯一的人工检查点。
func (s *Service) MatchArchiveSpecs(ctx context.Context, shopeeProductID uint64) (ArchiveSpecMatchResult, error) {
var result ArchiveSpecMatchResult
var shopee models.ShopeeProduct
if err := s.DB.WithContext(ctx).First(&shopee, shopeeProductID).Error; err != nil {
return result, internal(err)
}
if shopee.PDDProductID == nil {
return result, nil
}
var pdd models.PDDProduct
if err := s.DB.WithContext(ctx).First(&pdd, *shopee.PDDProductID).Error; err != nil {
return result, internal(err)
}
if pdd.Status != "active" {
return result, nil
}
candidates, usable := archiveCandidates(pdd.SpecsJSON, "", "")
if !usable {
return result, nil
}
combinations, err := s.archiveSKUCombinations(ctx, pdd.ID)
if err != nil {
return result, internal(err)
}
specs, err := shopeeproduct.Unmarshal(shopee.SpecsJSON)
if err != nil {
return result, nil
}
for _, dimension := range specs {
if dimension.Role != shopeeproduct.RoleColor && dimension.Role != shopeeproduct.RoleSize {
continue
}
for _, value := range dimension.Values {
if value.Mapping != nil && value.Mapping.Status == shopeeproduct.MappingStatusConfirmed {
result.Skipped++
continue
}
switch s.matchOneArchiveValue(ctx, shopeeProductID, dimension, value.Name, candidates, combinations) {
case specMatchAborted:
// ctx 已死,后面的值一个也做不成,早停以免刷出一串同样的失败。
result.Aborted++
return result, nil
case specMatchUnavailable:
result.Unavailable++
case specMatchNoMatch:
result.NoMatch++
default:
result.Confirmed++
}
}
}
return result, nil
}
// matchOneArchiveValue resolves a single archive value and persists it when the
// answer survives every check. An empty return means a mapping was written.
func (s *Service) matchOneArchiveValue(
ctx context.Context,
shopeeProductID uint64,
dimension shopeeproduct.SpecDimension,
valueName string,
candidates specCandidates,
combinations []pddSKUCombination,
) specMatchFailure {
isColor := dimension.Role == shopeeproduct.RoleColor
request := aimatching.MatchRequest{Colors: candidates.Colors, Sizes: candidates.Sizes}
if isColor {
request.TargetColor = valueName
} else {
request.TargetSize = valueName
}
matched, ok := aimatching.DeterministicMatch(request)
if !ok {
resolved, failure, err := s.resolveSpecMatch(ctx, request)
if err != nil {
return failure
}
// #200 的口径:AI 只要给出带理由的结果就可保存,是否放行交给下面的候选与
// 可售组合校验;置信度只作审计信息。
if resolved.Source != aimatching.SourceAI || strings.TrimSpace(resolved.Decision.Reason) == "" {
return specMatchNoMatch
}
matched = resolved
}
pddValue := matched.MappedSize
allowed := candidates.Sizes
if isColor {
pddValue, allowed = matched.MappedColor, candidates.Colors
}
pddValue = strings.TrimSpace(pddValue)
if pddValue == "" || !containsValue(allowed, pddValue) {
return specMatchNoMatch
}
// `[必须]` 逐值匹配没有另一半规格,无法校验完整的可售组合。至少要求这个值真的
// 出现在某个在售 SKU 里,否则会把一个“看起来合理但根本买不到”的值写成确认映射。
// 完整组合仍由采购预检把关。
if !archiveValueIsSellable(combinations, isColor, pddValue) {
return specMatchNoMatch
}
item := shopeeproduct.ResolvedMappingItem{
Dimension: dimension.Name, ValueName: valueName, PDDValue: pddValue,
Source: matched.Source, Status: shopeeproduct.MappingStatusConfirmed,
Confidence: matched.Decision.Confidence, Reason: strings.TrimSpace(matched.Decision.Reason),
}
if _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopeeProductID, uuid.NewString(), []shopeeproduct.ResolvedMappingItem{item}); err != nil {
return specMatchUnavailable
}
return ""
}
// archiveValueIsSellable reports whether the PDD value appears in at least one
// available SKU combination. No combinations means no evidence either way, so
// the value passes — hand-entered or imported PDD archives have no SKU rows.
func archiveValueIsSellable(combinations []pddSKUCombination, isColor bool, pddValue string) bool {
if len(combinations) == 0 {
return true
}
for _, combination := range combinations {
if isColor && combination.Color == pddValue {
return true
}
if !isColor && combination.Size == pddValue {
return true
}
}
return false
}
func (s *Service) archiveSKUCombinations(ctx context.Context, pddProductID uint64) ([]pddSKUCombination, error) {
dataset := batchPreviewDataset{skuCombinationsByPDD: make(map[uint64][]pddSKUCombination)}
if err := s.loadLatestSKUCombinations(ctx, []uint64{pddProductID}, &dataset); err != nil {
return nil, err
}
return dataset.skuCombinationsByPDD[pddProductID], nil
}
@@ -0,0 +1,39 @@
package purchase
import "testing"
// `[必须]` 逐值匹配没有另一半规格,无法校验完整的可售组合,所以至少要求这个值真的
// 出现在某个在售 SKU 里。否则会把“看起来合理但根本买不到”的值写成确认映射(#293)。
func TestArchiveValueMustAppearInASellableSKU(t *testing.T) {
combinations := []pddSKUCombination{
{Color: "紫色-冰块猫", Size: "M 建议86-95斤"},
{Color: "黑色-冰块猫", Size: "L 建议96-105斤"},
}
cases := []struct {
name string
isColor bool
pddValue string
want bool
}{
{"在售颜色", true, "黑色-冰块猫", true},
{"不在售颜色", true, "奶黄-百花", false},
{"在售尺码", false, "L 建议96-105斤", true},
{"不在售尺码", false, "4XL 建议136-150斤", false},
{"颜色值不能拿去顶尺码", false, "黑色-冰块猫", false},
}
for _, item := range cases {
t.Run(item.name, func(t *testing.T) {
if got := archiveValueIsSellable(combinations, item.isColor, item.pddValue); got != item.want {
t.Fatalf("got %v, want %v", got, item.want)
}
})
}
}
// 人工录入或外部导入的 PDD 档案没有采集 SKU 证据。没有证据时不能一律拒绝,
// 否则这些商品永远匹配不上——与 aiMatchQualificationForDataset 的既有口径一致。
func TestArchiveValuePassesWhenThereIsNoSKUEvidence(t *testing.T) {
if !archiveValueIsSellable(nil, true, "任意颜色") {
t.Fatal("没有 SKU 证据时不应拒绝")
}
}
+27 -7
View File
@@ -40,6 +40,7 @@ type BatchPreviewItem struct {
PDDProductID *uint64 `json:"pddProductId,omitempty"`
PDDGoodsID string `json:"pddGoodsId,omitempty"`
PDDTitle string `json:"pddTitle,omitempty"`
ImageSearchLinked bool `json:"imageSearchLinked,omitempty"`
TargetColor string `json:"targetColor,omitempty"`
TargetSize string `json:"targetSize,omitempty"`
MappedColor string `json:"mappedColor,omitempty"`
@@ -136,6 +137,11 @@ type batchPreviewDataset struct {
activeCollectionByPDD map[uint64]uint64
collectionRuleAvailable bool
skuCombinationsByPDD map[uint64][]pddSKUCombination
// collapsedKeysByShopee 记录该虾皮商品下哪些 target_color / target_size
// 是不安全的键——多个不同的虾皮原始规格剥离 【...】 后塌缩到了同一个值。
// 见 #289 与 sybimport.RawSpecHalves。
collapsedColorByShopee map[uint64]map[string]bool
collapsedSizeByShopee map[uint64]map[string]bool
}
// loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries.
@@ -144,13 +150,15 @@ type batchPreviewDataset struct {
// Creation still revalidates each item.
func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (batchPreviewDataset, error) {
dataset := batchPreviewDataset{
sybByID: make(map[uint64]models.SYBProduct, len(ids)),
shopeeByID: make(map[uint64]models.ShopeeProduct),
pddByID: make(map[uint64]models.PDDProduct),
latestTaskBySYB: make(map[uint64]models.PurchaseTask),
latestCollectionByPDD: make(map[uint64]models.CollectionTask),
activeCollectionByPDD: make(map[uint64]uint64),
skuCombinationsByPDD: make(map[uint64][]pddSKUCombination),
sybByID: make(map[uint64]models.SYBProduct, len(ids)),
shopeeByID: make(map[uint64]models.ShopeeProduct),
pddByID: make(map[uint64]models.PDDProduct),
latestTaskBySYB: make(map[uint64]models.PurchaseTask),
latestCollectionByPDD: make(map[uint64]models.CollectionTask),
activeCollectionByPDD: make(map[uint64]uint64),
skuCombinationsByPDD: make(map[uint64][]pddSKUCombination),
collapsedColorByShopee: make(map[uint64]map[string]bool),
collapsedSizeByShopee: make(map[uint64]map[string]bool),
}
var sybProducts []models.SYBProduct
if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil {
@@ -164,6 +172,9 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba
}
}
if len(shopeeIDs) > 0 {
if err := s.loadCollapsedSpecKeys(ctx, shopeeIDs, &dataset); err != nil {
return dataset, err
}
var shopeeProducts []models.ShopeeProduct
if err := s.DB.WithContext(ctx).Where("id IN ?", shopeeIDs).Find(&shopeeProducts).Error; err != nil {
return dataset, err
@@ -388,6 +399,7 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee"
return item
}
item.ImageSearchLinked = shopee.ImageSearchLinked
if !aiConfirmedSpecsCurrent(syb, shopee) {
item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "AI 解析依据已变化,请等待重新解析或人工修正", "reparse"
return item
@@ -422,6 +434,14 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
}
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
}
// `[必须]` 塌缩的键不是这条明细独有的:同一个虾皮商品下,多个不同的虾皮原始
// 规格剥离 【...】 后落到了同一个 target_color / target_size,于是共用同一份
// 映射,Agent 会为不同规格点击同一个 PDD 值。此时必须明确失败,不能让它显示
// 为采购就绪——静默买错比不能采购严重得多(#289)。
if reason := collapsedSpecReason(dataset, syb); reason != "" {
item.ReasonCode, item.Reason, item.NextAction = CodeSpecKeyAmbiguous, reason, "open_mapping"
return item
}
// Readiness must agree with the mapping shown in the product detail. A
// deterministic suggestion is only a preview; it is not a persisted,
// confirmed mapping and must not make the row appear purchase-ready.
@@ -73,7 +73,8 @@ func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest)
matched = *qualification.Deterministic
} else {
var matchErr error
matched, matchErr = s.matcher().Resolve(ctx, request)
// 一次抽风不该让这条明细永久留空,重试一次(#295)。
matched, _, matchErr = s.resolveSpecMatch(ctx, request)
if matchErr != nil {
item.Reason = batchSpecMatchErrorMessage(matchErr)
response.FailedCount++
+90 -2
View File
@@ -310,8 +310,11 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
if matcher.calls != 0 {
t.Fatalf("read-only preview called AI matcher %d times", matcher.calls)
}
if queries != 7 {
t.Fatalf("batch preview used %d queries, want 7 bounded queries including collection eligibility and current purchase rule", queries)
// #289 新增一次有界批量查询(按蕃皮商品拉全部明细用于塔缩检测),
// 因此从 7 变为 8。这条断言守的是“不得出现 N+1”,不是具体数字;
// 只有新增的查询确实有界时才允许上调。
if queries != 8 {
t.Fatalf("batch preview used %d queries, want 8 bounded queries including collection eligibility, current purchase rule and collapsed spec keys", queries)
}
if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 {
t.Fatalf("unresolved rows remain eligible for live probing but are not purchase-ready: %+v", response)
@@ -334,6 +337,91 @@ func TestBatchPreviewAllowsIncompleteArchivedCandidatesForMandatoryLiveProbe(t *
}
}
// #279 item 5: purchase preview must surface (read-only) that a shopee<->PDD
// link was established by image search and not yet human-verified, without
// changing eligibility, reason codes or next actions in any way.
func TestBatchPreviewMarksImageSearchLinkedRowsWithoutAffectingEligibility(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, fixture.pdd.ID)
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", fixture.shopee.ID).Update("image_search_linked", true).Error; err != nil {
t.Fatal(err)
}
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID,
})
if err != nil {
t.Fatal(err)
}
if len(response.Items) != 1 {
t.Fatalf("unexpected items: %+v", response)
}
item := response.Items[0]
if !item.ImageSearchLinked {
t.Fatalf("expected imageSearchLinked=true for a link created by image search: %+v", item)
}
if !item.Eligible || item.ReasonCode != "" || item.Reason != "" || item.NextAction != "" {
t.Fatalf("imageSearchLinked marker must be display-only and must not change eligibility: %+v", item)
}
}
func TestBatchPreviewDoesNotMarkManuallyLinkedRows(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, fixture.pdd.ID)
// image_search_linked defaults to false for manually established links.
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID,
})
if err != nil {
t.Fatal(err)
}
if len(response.Items) != 1 {
t.Fatalf("unexpected items: %+v", response)
}
if response.Items[0].ImageSearchLinked {
t.Fatalf("manual link must not be marked imageSearchLinked: %+v", response.Items[0])
}
if !response.Items[0].Eligible {
t.Fatalf("manual link must remain eligible: %+v", response.Items[0])
}
}
// Regression: the imageSearchLinked flag must not change any existing
// eligibility outcome, including the well-covered ineligible paths.
func TestBatchPreviewImageSearchLinkedDoesNotChangeIneligibleOutcome(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, liveCaps(), true)
failed := fixture.syb
failed.ID = 0
failed.OrderCode = "SYB-FAILED-IMG"
failed.DetailID++
failed.ParseStatus = models.SYBParseStatusFailed
failed.TargetColor, failed.TargetSize = "", ""
if err := db.Create(&failed).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", fixture.shopee.ID).Update("image_search_linked", true).Error; err != nil {
t.Fatal(err)
}
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
SYBProductIDs: []uint64{failed.ID}, DeviceID: &fixture.device.ID,
})
if err != nil {
t.Fatal(err)
}
if len(response.Items) != 1 {
t.Fatalf("unexpected items: %+v", response)
}
item := response.Items[0]
if item.Eligible || item.ReasonCode != "SYB_PARSE_FAILED" || item.NextAction != "reparse" {
t.Fatalf("image search linkage must not alter the existing ineligible outcome: %+v", item)
}
}
func setCollectedPDDPrice(t *testing.T, db *gorm.DB, productID uint64) {
t.Helper()
if err := db.Model(&models.PDDProduct{}).Where("id = ?", productID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
@@ -0,0 +1,95 @@
package purchase
import (
"context"
"encoding/json"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybspec"
)
// CodeSpecKeyAmbiguous marks a detail whose target colour or size is shared by
// several different Shopee specs, so the stored mapping cannot say which one
// the agent should click.
const CodeSpecKeyAmbiguous = "SPEC_KEY_AMBIGUOUS"
// loadCollapsedSpecKeys finds, per Shopee product, the target colours and sizes
// that more than one raw Shopee spec collapses onto (#289).
//
// `[必须]` 必须按虾皮商品加载**全部**明细,而不是本次请求的那几条:塌缩是商品级
// 的属性,只看请求内的明细会漏判——另一条同键不同原文的明细可能不在本次选择里。
func (s *Service) loadCollapsedSpecKeys(ctx context.Context, shopeeIDs []uint64, dataset *batchPreviewDataset) error {
var rows []models.SYBProduct
if err := s.DB.WithContext(ctx).
Select("id", "shopee_product_id", "target_color", "target_size", "raw_json").
Where("shopee_product_id IN ?", shopeeIDs).
Find(&rows).Error; err != nil {
return err
}
colorRaws := map[uint64]map[string][]string{}
sizeRaws := map[uint64]map[string][]string{}
for _, row := range rows {
if row.ShopeeProductID == nil {
continue
}
rawColor, rawSize := sybspec.RawSpecHalves(rawProductSpec(row))
collect(colorRaws, *row.ShopeeProductID, row.TargetColor, rawColor)
collect(sizeRaws, *row.ShopeeProductID, row.TargetSize, rawSize)
}
dataset.collapsedColorByShopee = collapsedKeys(colorRaws)
dataset.collapsedSizeByShopee = collapsedKeys(sizeRaws)
return nil
}
// rawProductSpec 取出存储的 SYB 原始 productSpec。解不开时返回空串,
// 由调用方当作“无原文可比”处理,而不是报错阻断整个预览。
func rawProductSpec(row models.SYBProduct) string {
var payload struct {
ProductSpec string `json:"productSpec"`
}
if err := json.Unmarshal([]byte(row.RawJSON), &payload); err != nil {
return ""
}
return payload.ProductSpec
}
func collect(into map[uint64]map[string][]string, shopeeID uint64, key, raw string) {
if key == "" || raw == "" {
return
}
if into[shopeeID] == nil {
into[shopeeID] = map[string][]string{}
}
into[shopeeID][key] = append(into[shopeeID][key], raw)
}
func collapsedKeys(raws map[uint64]map[string][]string) map[uint64]map[string]bool {
result := make(map[uint64]map[string]bool, len(raws))
for shopeeID, byKey := range raws {
for key, halves := range byKey {
if !sybspec.CollapsedSpecKey(halves) {
continue
}
if result[shopeeID] == nil {
result[shopeeID] = map[string]bool{}
}
result[shopeeID][key] = true
}
}
return result
}
// collapsedSpecReason returns a human-readable reason when this detail's colour
// or size key is shared by several different Shopee specs, or "" when it is safe.
func collapsedSpecReason(dataset batchPreviewDataset, syb models.SYBProduct) string {
if syb.ShopeeProductID == nil {
return ""
}
if dataset.collapsedColorByShopee[*syb.ShopeeProductID][syb.TargetColor] {
return "同一蝦皮商品下有多个不同规格的颜色被识别成「" + syb.TargetColor + "」,无法确定该采购哪一个,请人工确认规格"
}
if dataset.collapsedSizeByShopee[*syb.ShopeeProductID][syb.TargetSize] {
return "同一蝦皮商品下有多个不同规格的尺码被识别成「" + syb.TargetSize + "」,无法确定该采购哪一个,请人工确认规格"
}
return ""
}
@@ -0,0 +1,104 @@
package purchase
import (
"context"
"testing"
"go-admin/app/goauto/models"
)
// `[必须]` 塌缩的键不得显示为采购就绪。剥离 【...】 后两个不同的虾皮规格落到同一个
// target_color,就会共用同一份映射,Agent 会为不同规格点击同一个 PDD 值。线上真实
// 样本:虾皮 1355 的 6 个白色(白色【207A】/【209A】/…)全部塌缩成「白色」(#289)。
func TestBatchPreviewRefusesCollapsedSpecKey(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
// 第一条明细的原文带款号;既有 fixture 的 RawJSON 是 {},先补成真实形态。
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
Update("raw_json", `{"productSpec":"黑色【204A】,XL"}`).Error; err != nil {
t.Fatal(err)
}
// 同一虾皮商品的第二条明细:不同款号,剥离后同样是「黑色」。
sibling := f.syb
sibling.ID = 0
sibling.OrderCode = "SYB-2"
sibling.DetailID = 2
sibling.RawJSON = `{"productSpec":"黑色【208A】,XL"}`
if err := db.Create(&sibling).Error; err != nil {
t.Fatal(err)
}
response, err := NewService(db).BatchPreview(context.Background(),
BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil {
t.Fatal(err)
}
if len(response.Items) != 1 {
t.Fatalf("expected one preview item, got %+v", response.Items)
}
item := response.Items[0]
if item.ReasonCode != CodeSpecKeyAmbiguous {
t.Fatalf("collapsed key must be refused, got code=%q reason=%q eligible=%v",
item.ReasonCode, item.Reason, item.Eligible)
}
if item.Eligible {
t.Fatal("a detail whose spec key is ambiguous must not stay eligible")
}
}
// 同一条规格重复出现(同商品多条订单买同一个规格)不算塌缩,行为必须不变。
func TestBatchPreviewAllowsRepeatedIdenticalSpec(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
Update("raw_json", `{"productSpec":"黑色【204A】,XL"}`).Error; err != nil {
t.Fatal(err)
}
sibling := f.syb
sibling.ID = 0
sibling.OrderCode = "SYB-2"
sibling.DetailID = 2
sibling.RawJSON = `{"productSpec":"黑色【204A】,XL"}`
if err := db.Create(&sibling).Error; err != nil {
t.Fatal(err)
}
response, err := NewService(db).BatchPreview(context.Background(),
BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil {
t.Fatal(err)
}
if response.Items[0].ReasonCode == CodeSpecKeyAmbiguous {
t.Fatalf("identical repeated specs are not a collapse: %+v", response.Items[0])
}
}
// 尺码塌缩同样要检出(线上 24 个键)。
func TestBatchPreviewRefusesCollapsedSizeKey(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
Update("raw_json", `{"productSpec":"黑色,XL【建議60-65公斤】"}`).Error; err != nil {
t.Fatal(err)
}
sibling := f.syb
sibling.ID = 0
sibling.OrderCode = "SYB-2"
sibling.DetailID = 2
sibling.RawJSON = `{"productSpec":"黑色,XL【建議65-70公斤】"}`
if err := db.Create(&sibling).Error; err != nil {
t.Fatal(err)
}
response, err := NewService(db).BatchPreview(context.Background(),
BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
if err != nil {
t.Fatal(err)
}
if response.Items[0].ReasonCode != CodeSpecKeyAmbiguous {
t.Fatalf("collapsed size key must be refused: %+v", response.Items[0])
}
}
+1 -1
View File
@@ -542,7 +542,7 @@ func writeError(c *gin.Context, err error) {
status = http.StatusForbidden
case CodeTaskNotFound:
status = http.StatusNotFound
case CodeStateConflict, CodeCapabilityMismatch, CodeDeviceBusy, CodeTaskClaimed, CodeLeaseExpired, CodeMappingRequired, CodeResultConflict, CodeRePurchaseRequired:
case CodeStateConflict, CodeCapabilityMismatch, CodeDeviceBusy, CodeTaskClaimed, CodeLeaseExpired, CodeMappingRequired, CodeResultConflict, CodeRePurchaseRequired, CodeOrderNumberUsed:
status = http.StatusConflict
}
c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable})
+37 -1
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"unicode"
@@ -340,6 +341,9 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
if req.ActualUnitPriceCent != nil && *req.ActualUnitPriceCent < 0 {
return TaskPayload{}, fail(CodeInvalidRequest, "实际单价无效")
}
if req.PDDOrderAmountCent != nil && (*req.PDDOrderAmountCent < 0 || req.ResultType != "order_created") {
return TaskPayload{}, fail(CodeInvalidRequest, "订单应付金额仅可随已创建订单提交且不能为负数")
}
now := s.Now()
next := ""
switch req.ResultType {
@@ -379,6 +383,25 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
t.PDDOrderNo = &req.PDDOrderNo
t.OrderSubmittedAt = req.OrderSubmittedAt
t.ActualUnitPriceCent = req.ActualUnitPriceCent
// Keep the assignment lock until commit. A conflicting observation
// after the irreversible boundary must reach manual resolution,
// not roll back the result or claim another task's order number.
if e := models.CheckPurchaseOrderNumber(tx, t.ID, req.PDDOrderNo); e != nil {
var conflict *models.PurchaseOrderNumberUsedError
if !errors.As(e, &conflict) {
return TaskPayload{}, conflictOrInternal(e)
}
next = models.PurchaseTaskStatusOrderResultUnknown
t.PDDOrderNo = nil
failureCode := CodeOrderNumberUsed
message := fmt.Sprintf("读到订单号 %s,但该号已属于任务 %s", req.PDDOrderNo, taskNumber(conflict.TaskID))
t.ErrorCode, t.ErrorMessage = &failureCode, &message
a.Status = models.PurchaseAttemptStatusFailed
a.ErrorCode, a.ErrorMessage = &failureCode, &message
}
if next == models.PurchaseTaskStatusOrderCreated && req.PDDOrderAmountCent != nil && t.PDDOrderAmountCent == nil {
t.PDDOrderAmountCent = req.PDDOrderAmountCent
}
case "order_result_unknown":
if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusOrderSubmitStarted {
return TaskPayload{}, fail(CodeStateConflict, "当前任务不能标记订单结果未知")
@@ -395,6 +418,9 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
a.ErrorCode, a.ErrorMessage = &failureCode, &failureMessage
t.ErrorCode, t.ErrorMessage = &failureCode, &failureMessage
t.ActualUnitPriceCent = req.ActualUnitPriceCent
if req.PaymentPageObserved {
t.PaymentPageObservedAt = &now
}
case "failed":
next = models.PurchaseTaskStatusFailed
a.Status = models.PurchaseAttemptStatusFailed
@@ -428,6 +454,9 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
if e := tx.Save(t).Error; e != nil {
return TaskPayload{}, conflictOrInternal(e)
}
if e := ensureOrderWriteback(tx, *t); e != nil {
return TaskPayload{}, internal(e)
}
return valuePayload(s, t, a, false)
})
if err != nil || req.ResultType != "spec_probe_completed" || payload.Replayed || payload.Status != models.PurchaseTaskStatusSpecProbePending {
@@ -597,7 +626,10 @@ func (s *Service) applySpecDecision(ctx context.Context, taskID uint64, req Spec
}
t.StatusVersion++
t.StatusChangedAt = s.Now()
return tx.Save(&t).Error
if e := tx.Save(&t).Error; e != nil {
return conflictOrInternal(e)
}
return nil
})
return t, replayed, err
}
@@ -800,6 +832,10 @@ func purchaseNotFound(err error) error {
return internal(err)
}
func conflictOrInternal(err error) error {
var conflict *models.PurchaseOrderNumberUsedError
if errors.As(err, &conflict) {
return fail(CodeOrderNumberUsed, conflict.Error())
}
if isDuplicate(err) {
return fail(CodeDeviceBusy, "设备或拼多多账号已有运行任务")
}
+36 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"unicode/utf8"
"go-admin/app/goauto/models"
"gorm.io/gorm"
@@ -80,7 +81,10 @@ func (s *Service) SelectWriteback(ctx context.Context, id uint64, req ManualRequ
}
out.WritebackStatus = models.PurchaseWritebackStatusPending
out.WritebackSelectRequestID = &req.RequestID
return tx.Save(&out).Error
if e := tx.Save(&out).Error; e != nil {
return conflictOrInternal(e)
}
return nil
})
return out, replayed, err
}
@@ -112,19 +116,31 @@ func (s *Service) Cancel(ctx context.Context, id uint64, req ManualRequest) (mod
func (s *Service) ResolveUnknown(ctx context.Context, id uint64, req ManualRequest) (models.PurchaseTask, bool, error) {
return s.manual(ctx, id, req, func(t *models.PurchaseTask) error {
if t.Status != models.PurchaseTaskStatusOrderResultUnknown {
return fail(CodeStateConflict, "任务不是订单结果未知状态")
if t.ExecutionMode != models.PurchaseExecutionModeLive || (t.Status != models.PurchaseTaskStatusOrderResultUnknown && t.Status != models.PurchaseTaskStatusOrderCreated) {
return fail(CodeStateConflict, "仅正式采购的未知结果或已创建订单允许补录")
}
if t.Status == models.PurchaseTaskStatusOrderCreated && req.Status != models.PurchaseTaskStatusOrderCreated {
return fail(CodeStateConflict, "已创建订单仅允许补全订单信息")
}
if req.Status != models.PurchaseTaskStatusOrderCreated && req.Status != models.PurchaseTaskStatusCancelled {
return fail(CodeInvalidRequest, "人工处理结果只支持已创建订单或已取消")
}
now := s.Now()
if req.Status == models.PurchaseTaskStatusOrderCreated {
if strings.TrimSpace(req.PDDOrderNo) == "" || req.OrderSubmittedAt == nil {
if strings.TrimSpace(req.PDDOrderNo) == "" || strings.TrimSpace(req.PDDOrderNo) != req.PDDOrderNo || utf8.RuneCountInString(req.PDDOrderNo) > 100 || strings.ContainsAny(req.PDDOrderNo, "\r\n\t") || req.OrderSubmittedAt == nil || req.OrderSubmittedAt.IsZero() || req.OrderSubmittedAt.Year() < 1000 || req.OrderSubmittedAt.Year() > 9999 {
return fail(CodeInvalidRequest, "请填写订单号和下单时间")
}
if req.PDDOrderAmountCent != nil && *req.PDDOrderAmountCent < 0 {
return fail(CodeInvalidRequest, "订单金额不能为负数")
}
if (t.PDDOrderNo != nil && *t.PDDOrderNo != "" && *t.PDDOrderNo != req.PDDOrderNo) || (t.OrderSubmittedAt != nil && !t.OrderSubmittedAt.Equal(*req.OrderSubmittedAt)) || (t.PDDOrderAmountCent != nil && req.PDDOrderAmountCent != nil && *t.PDDOrderAmountCent != *req.PDDOrderAmountCent) {
return fail(CodeResultConflict, "已有不同订单信息,本次未覆盖")
}
t.PDDOrderNo = &req.PDDOrderNo
t.OrderSubmittedAt = req.OrderSubmittedAt
if req.PDDOrderAmountCent != nil {
t.PDDOrderAmountCent = req.PDDOrderAmountCent
}
} else {
t.CancelledAt = &now
t.CancelledBy = &req.OperatorID
@@ -137,12 +153,13 @@ func (s *Service) ResolveUnknown(ctx context.Context, id uint64, req ManualReque
t.StatusVersion++
t.StatusChangedAt = now
return nil
})
}, true)
}
func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, apply func(*models.PurchaseTask) error) (models.PurchaseTask, bool, error) {
func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, apply func(*models.PurchaseTask) error, orderInfo ...bool) (models.PurchaseTask, bool, error) {
var out models.PurchaseTask
replayed := false
isOrderInfo := len(orderInfo) > 0 && orderInfo[0]
if strings.TrimSpace(req.RequestID) == "" || req.OperatorID == 0 {
return out, false, fail(CodeInvalidRequest, "requestId 或操作人无效")
}
@@ -154,6 +171,12 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl
return internal(e)
}
if requestAlreadyApplied(out, req.RequestID) {
if isOrderInfo && out.Status != req.Status {
return fail(CodeResultConflict, "同一requestId不能改变处理结果")
}
if isOrderInfo && req.Status == models.PurchaseTaskStatusOrderCreated && (out.PDDOrderNo == nil || *out.PDDOrderNo != req.PDDOrderNo || out.OrderSubmittedAt == nil || req.OrderSubmittedAt == nil || !out.OrderSubmittedAt.Equal(*req.OrderSubmittedAt) || (req.PDDOrderAmountCent != nil && (out.PDDOrderAmountCent == nil || *out.PDDOrderAmountCent != *req.PDDOrderAmountCent))) {
return fail(CodeResultConflict, "同一requestId不能改变订单信息")
}
replayed = true
return nil
}
@@ -166,7 +189,13 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl
return internal(e)
}
}
return tx.Save(&out).Error
if e := tx.Save(&out).Error; e != nil {
return conflictOrInternal(e)
}
if isOrderInfo && req.Status == models.PurchaseTaskStatusOrderCreated {
return ensureOrderWriteback(tx, out)
}
return nil
})
return out, replayed, err
}
@@ -0,0 +1,273 @@
package purchase
import (
"context"
"errors"
"strings"
"time"
"unicode/utf8"
"go-admin/app/goauto/device"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchasecontract"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
const (
MaxOrderBackfillItems = 50
CodeBackfillSuffix = "PURCHASE_BACKFILL_SUFFIX_INVALID"
CodeBackfillDevice = "PURCHASE_BACKFILL_DEVICE_MISMATCH"
CodeBackfillOrderConflict = "PURCHASE_BACKFILL_ORDER_CONFLICT"
CodeBackfillBatchConflict = "PURCHASE_BACKFILL_BATCH_CONFLICT"
CodeBackfillOrderUsed = "PURCHASE_BACKFILL_ORDER_ALREADY_USED"
CodeBackfillAmountConflict = "PURCHASE_BACKFILL_AMOUNT_CONFLICT"
)
type OrderBackfillRequest struct {
RequestID string `json:"requestId"`
Items []OrderBackfillItem `json:"items"`
}
type OrderBackfillItem struct {
AddressSuffix string `json:"addressSuffix"`
PDDOrderNo string `json:"pddOrderNo"`
// A string keeps an invalid page timestamp local to this item.
OrderSubmittedAt *string `json:"orderSubmittedAt,omitempty"`
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
}
type OrderBackfillResult struct {
Index int `json:"index"`
TaskID uint64 `json:"taskId,omitempty"`
Result string `json:"result"`
Code string `json:"code"`
Status string `json:"status,omitempty"`
StatusVersion uint64 `json:"statusVersion,omitempty"`
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
TimeSource string `json:"timeSource,omitempty"`
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
WarningCode string `json:"warningCode,omitempty"`
WarningMessage string `json:"warningMessage,omitempty"`
Retryable bool `json:"retryable"`
}
type OrderBackfillResponse struct {
RequestID string `json:"requestId"`
Items []OrderBackfillResult `json:"items"`
}
type orderBackfillScope struct{ deviceID *uint64 }
func agentBackfillScope(deviceID uint64) orderBackfillScope {
return orderBackfillScope{deviceID: &deviceID}
}
func clientBackfillScope() orderBackfillScope { return orderBackfillScope{} }
func (scope orderBackfillScope) allows(task models.PurchaseTask) bool {
return scope.deviceID == nil || (task.DeviceID != nil && *task.DeviceID == *scope.deviceID)
}
func (scope orderBackfillScope) readable(db *gorm.DB, taskID uint64, saved *models.PurchaseTask) bool {
query := db.Where("id = ?", taskID)
if scope.deviceID != nil {
query = query.Where("device_id = ?", *scope.deviceID)
}
return query.First(saved).Error == nil
}
func (s *Service) BackfillOrders(ctx context.Context, req OrderBackfillRequest, token string) (OrderBackfillResponse, error) {
out := OrderBackfillResponse{RequestID: req.RequestID}
d, err := device.NewService(s.DB).Authenticate(ctx, token)
if err != nil {
return out, err
}
return s.backfillOrders(ctx, req, agentBackfillScope(d.ID))
}
func (s *Service) BackfillOrdersForClient(ctx context.Context, req OrderBackfillRequest) (OrderBackfillResponse, error) {
return s.backfillOrders(ctx, req, clientBackfillScope())
}
func (s *Service) backfillOrders(ctx context.Context, req OrderBackfillRequest, scope orderBackfillScope) (OrderBackfillResponse, error) {
out := OrderBackfillResponse{RequestID: req.RequestID}
if _, err := uuid.Parse(req.RequestID); err != nil || len(req.Items) == 0 || len(req.Items) > MaxOrderBackfillItems {
return out, fail(CodeInvalidRequest, "requestId 必须为 UUID,items 必须包含 1 到 50 条")
}
ids := make([]uint64, len(req.Items))
orders, conflicts := make(map[uint64]string), make(map[uint64]bool)
for i, item := range req.Items {
id, err := purchasecontract.ParseAddressSuffix(item.AddressSuffix)
if err != nil {
continue
}
ids[i] = id
if previous, ok := orders[id]; ok && previous != item.PDDOrderNo {
conflicts[id] = true
}
orders[id] = item.PDDOrderNo
}
out.Items = make([]OrderBackfillResult, len(req.Items))
for i, item := range req.Items {
r := OrderBackfillResult{Index: i, TaskID: ids[i], Result: "failed"}
if ids[i] == 0 {
r.Code = CodeBackfillSuffix
} else {
r = s.backfillOrder(ctx, scope, ids[i], req.RequestID, item, conflicts[ids[i]])
r.Index = i
}
out.Items[i] = r
}
return out, nil
}
func (s *Service) backfillOrder(ctx context.Context, scope orderBackfillScope, taskID uint64, requestID string, item OrderBackfillItem, batchConflict bool) OrderBackfillResult {
r := OrderBackfillResult{TaskID: taskID, Result: "failed"}
var task models.PurchaseTask
// SQL errors must not print bound order numbers or the task's address snapshot.
db := s.DB.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).WithContext(ctx)
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, taskID).Error; err != nil {
return purchaseNotFound(err)
}
if !scope.allows(task) {
return fail(CodeBackfillDevice, "任务不属于当前设备")
}
if batchConflict {
return fail(CodeBackfillBatchConflict, "同批任务有不同订单号")
}
if task.ExecutionMode != models.PurchaseExecutionModeLive || (task.Status != models.PurchaseTaskStatusOrderResultUnknown && task.Status != models.PurchaseTaskStatusOrderCreated) {
return fail(CodeStateConflict, "当前任务不允许回填")
}
if item.PDDOrderNo == "" || strings.TrimSpace(item.PDDOrderNo) != item.PDDOrderNo || utf8.RuneCountInString(item.PDDOrderNo) > 100 || strings.ContainsAny(item.PDDOrderNo, "\r\n\t") {
return fail(CodeInvalidRequest, "订单号无效")
}
if task.PDDOrderNo != nil && *task.PDDOrderNo != "" && *task.PDDOrderNo != item.PDDOrderNo {
return fail(CodeBackfillOrderConflict, "已有不同订单号")
}
// The shared model guard also protects manual resolution and late results.
if err := models.CheckPurchaseOrderNumber(tx, taskID, item.PDDOrderNo); err != nil {
return err
}
if item.PDDOrderAmountCent != nil && *item.PDDOrderAmountCent < 0 {
item.PDDOrderAmountCent = nil
}
if task.Status == models.PurchaseTaskStatusOrderCreated {
if task.PDDOrderNo == nil || *task.PDDOrderNo != item.PDDOrderNo {
return fail(CodeStateConflict, "已创建订单缺少匹配订单号")
}
changed := false
filledSubmittedAt := false
if task.OrderSubmittedAt == nil && item.OrderSubmittedAt != nil {
submitted, parseErr := time.Parse(time.RFC3339Nano, *item.OrderSubmittedAt)
if parseErr != nil || submitted.IsZero() || submitted.Year() < 1000 || submitted.Year() > 9999 {
return fail(CodeOrderTimeInvalid, "下单时间必须为 RFC3339")
}
submitted = submitted.UTC()
task.OrderSubmittedAt = &submitted
changed = true
filledSubmittedAt = true
}
if item.PDDOrderAmountCent != nil {
if task.PDDOrderAmountCent == nil {
task.PDDOrderAmountCent = item.PDDOrderAmountCent
changed = true
} else if *task.PDDOrderAmountCent != *item.PDDOrderAmountCent {
r.WarningCode, r.WarningMessage = CodeBackfillAmountConflict, "已有不同实付金额,本次未覆盖"
}
}
if changed {
if filledSubmittedAt {
marker := "backfill:page:" + uuid.NewSHA1(uuid.NameSpaceOID, []byte(requestID+":"+item.AddressSuffix)).String()
task.UnknownResolveRequestID = &marker
}
task.StatusVersion++
task.StatusChangedAt = s.Now()
if err := tx.Save(&task).Error; err != nil {
return err
}
}
r.Result, r.Code = "already_backfilled", "ALREADY_BACKFILLED"
return ensureOrderWriteback(tx, task)
}
var submitted time.Time
source := "page"
if item.OrderSubmittedAt != nil {
var err error
submitted, err = time.Parse(time.RFC3339Nano, *item.OrderSubmittedAt)
if err != nil || submitted.IsZero() || submitted.Year() < 1000 || submitted.Year() > 9999 {
return fail(CodeOrderTimeInvalid, "下单时间必须为 RFC3339")
}
} else {
if task.IrreversibleAt == nil || task.IrreversibleAt.IsZero() {
return fail(CodeOrderTimeMissing, "下单时间和不可逆时间均缺失")
}
submitted, source = *task.IrreversibleAt, "irreversible_at"
}
submitted = submitted.UTC()
task.PDDOrderNo, task.OrderSubmittedAt = &item.PDDOrderNo, &submitted
if item.PDDOrderAmountCent != nil {
if task.PDDOrderAmountCent == nil {
task.PDDOrderAmountCent = item.PDDOrderAmountCent
} else if *task.PDDOrderAmountCent != *item.PDDOrderAmountCent {
r.WarningCode, r.WarningMessage = CodeBackfillAmountConflict, "已有不同实付金额,本次未覆盖"
}
}
if err := task.SetStatus(models.PurchaseTaskStatusOrderCreated); err != nil {
return internal(err)
}
task.StatusVersion++
task.StatusChangedAt = s.Now()
task.ErrorCode, task.ErrorMessage = nil, nil
task.LeaseExpiresAt = nil
// Reuse the existing resolution request slot. Scope a batch UUID to a
// task, and retain provenance without a schema change or replay cache.
marker := "backfill:" + source + ":" + uuid.NewSHA1(uuid.NameSpaceOID, []byte(requestID+":"+item.AddressSuffix)).String()
task.UnknownResolveRequestID = &marker
if err := tx.Save(&task).Error; err != nil {
return err
}
r.Result, r.Code = "backfilled", "BACKFILLED"
return ensureOrderWriteback(tx, task)
})
if err != nil {
r.Result, r.Code = "failed", CodeInternal
r.Retryable = true
var se *ServiceError
if errors.As(err, &se) {
r.Code, r.Retryable = se.Code, se.Retryable
}
if errors.Is(err, models.ErrPurchaseOrderNumberUsed) {
r.Code, r.Retryable = CodeBackfillOrderUsed, false
}
if r.Code == CodeBackfillBatchConflict || r.Code == CodeBackfillOrderConflict || r.Code == CodeBackfillOrderUsed {
r.Result = "conflict"
}
}
// Return only this device's committed facts, including on a rejected item.
// Never return in-memory changes from a rolled back transaction.
saved := task
readable := err == nil
if !readable {
saved = models.PurchaseTask{}
readable = scope.readable(db, taskID, &saved)
}
if readable {
r.Status, r.StatusVersion = saved.Status, saved.StatusVersion
r.PDDOrderNo, r.OrderSubmittedAt, r.PDDOrderAmountCent = saved.PDDOrderNo, saved.OrderSubmittedAt, saved.PDDOrderAmountCent
if saved.OrderSubmittedAt != nil {
r.TimeSource = "existing_unknown"
if saved.UnknownResolveRequestID != nil {
if strings.HasPrefix(*saved.UnknownResolveRequestID, "backfill:page:") {
r.TimeSource = "page"
}
if strings.HasPrefix(*saved.UnknownResolveRequestID, "backfill:irreversible_at:") {
r.TimeSource = "irreversible_at"
}
}
}
}
return r
}
@@ -0,0 +1,48 @@
package purchase
import (
"go-admin/common/clientprincipal"
"net/http"
"github.com/gin-gonic/gin"
)
func (h Handler) BackfillOrders(c *gin.Context) {
var req OrderBackfillRequest
if !decode(c, &req) {
return
}
s, ok := h.service(c)
if !ok {
return
}
out, err := s.BackfillOrders(c.Request.Context(), req, bearer(c.GetHeader("Authorization")))
if err != nil {
writeError(c, err)
return
}
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, gin.H{"data": out})
}
func (h Handler) ClientBackfillOrders(c *gin.Context) {
if _, ok := clientprincipal.Get(c); !ok {
c.JSON(http.StatusUnauthorized, gin.H{"code": http.StatusUnauthorized, "message": "需要客户端身份"})
return
}
var req OrderBackfillRequest
if !decode(c, &req) {
return
}
s, ok := h.service(c)
if !ok {
return
}
out, err := s.BackfillOrdersForClient(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, gin.H{"data": out})
}
@@ -0,0 +1,619 @@
package purchase
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync"
"testing"
"time"
"go-admin/app/goauto/device"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchasecontract"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
func backfillTask(t *testing.T, db *gorm.DB, f fixture, status string) models.PurchaseTask {
t.Helper()
now := testService(db).Now()
task := models.PurchaseTask{TaskType: models.PurchaseTaskTypeStock, ExecutionMode: models.PurchaseExecutionModeLive,
Status: status, DeviceID: &f.device.ID, PDDProductID: f.pdd.ID, Quantity: 1, Currency: "CNY",
CreateRequestID: uuid.NewString(), RuleSnapshot: string(purchasecontract.DefaultLiveRule()),
SpecDecisionSnapshot: `{}`, RequiredCapabilitiesJSON: `[]`, IrreversibleAt: &now,
ErrorCode: strptr("ORIGINAL_ERROR"), ErrorMessage: strptr("original failure")}
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
return task
}
func strptr(s string) *string { return &s }
func backfillItem(id uint64, order string) OrderBackfillItem {
return OrderBackfillItem{AddressSuffix: purchasecontract.AddressSuffix(id), PDDOrderNo: order}
}
func runBackfill(t *testing.T, s *Service, token, requestID string, items ...OrderBackfillItem) []OrderBackfillResult {
t.Helper()
out, err := s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: requestID, Items: items}, token)
if err != nil {
t.Fatal(err)
}
if len(out.Items) != len(items) || out.RequestID != requestID {
t.Fatalf("bad envelope: %+v", out)
}
return out.Items
}
func loadBackfillTask(t *testing.T, db *gorm.DB, id uint64) models.PurchaseTask {
t.Helper()
var task models.PurchaseTask
if err := db.First(&task, id).Error; err != nil {
t.Fatal(err)
}
return task
}
func TestOrderBackfillMixedBatchAndReplay(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
a.TaskType, a.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
if err := db.Save(&a).Error; err != nil {
t.Fatal(err)
}
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
c := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
if err := db.Model(&c).Update("irreversible_at", nil).Error; err != nil {
t.Fatal(err)
}
page := backfillItem(b.ID, "ORDER-B")
page.OrderSubmittedAt = strptr("2026-09-08T20:30:00+08:00")
rid := uuid.NewString()
items := []OrderBackfillItem{backfillItem(a.ID, "ORDER-A"), {AddressSuffix: "_cg0", PDDOrderNo: "bad"}, page, backfillItem(c.ID, "ORDER-C"), backfillItem(99999, "missing")}
results := runBackfill(t, s, f.token, rid, items...)
want := []string{"BACKFILLED", CodeBackfillSuffix, "BACKFILLED", CodeOrderTimeMissing, CodeTaskNotFound}
for i, r := range results {
if r.Code != want[i] || r.Index != i {
t.Fatalf("item %d: %+v", i, r)
}
}
if results[0].TimeSource != "irreversible_at" || !results[0].OrderSubmittedAt.Equal(*a.IrreversibleAt) {
t.Fatalf("fallback: %+v", results[0])
}
if results[2].TimeSource != "page" || results[2].OrderSubmittedAt.Format(time.RFC3339) != "2026-09-08T12:30:00Z" {
t.Fatalf("page: %+v", results[2])
}
saved := loadBackfillTask(t, db, a.ID)
if saved.StatusVersion != a.StatusVersion+1 || saved.ErrorCode != nil || saved.ErrorMessage != nil || saved.DeviceRunSlot != nil || saved.AccountRunSlot != nil || saved.ActiveSlot == nil || saved.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatalf("state metadata: %+v", saved)
}
if saved.PaymentReviewStatus != a.PaymentReviewStatus || saved.LogisticsStatus != a.LogisticsStatus || saved.WritebackStatus != a.WritebackStatus || saved.RuleSnapshot != a.RuleSnapshot {
t.Fatal("unrelated business facts changed")
}
for _, replayID := range []string{rid, uuid.NewString()} {
item := items[0]
item.OrderSubmittedAt = strptr("2026-09-09T00:00:00Z")
r := runBackfill(t, s, f.token, replayID, item)[0]
if r.Result != "already_backfilled" || r.TimeSource != "irreversible_at" {
t.Fatalf("replay: %+v", r)
}
if got := loadBackfillTask(t, db, a.ID); !reflect.DeepEqual(saved, got) {
t.Fatal("replay changed persisted task")
}
}
if got := loadBackfillTask(t, db, c.ID); got.PDDOrderNo != nil || got.StatusVersion != c.StatusVersion {
t.Fatal("missing time wrote data")
}
}
func TestOrderBackfillAmountFillIdempotencyAndConflictWarning(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
amount := int64(2298)
item := backfillItem(task.ID, "AMOUNT-ORDER")
item.PDDOrderAmountCent = &amount
first := runBackfill(t, s, f.token, uuid.NewString(), item)[0]
if first.Code != "BACKFILLED" || first.PDDOrderAmountCent == nil || *first.PDDOrderAmountCent != amount {
t.Fatalf("first: %+v", first)
}
saved := loadBackfillTask(t, db, task.ID)
version := saved.StatusVersion
same := runBackfill(t, s, f.token, uuid.NewString(), item)[0]
if same.Result != "already_backfilled" || same.WarningCode != "" {
t.Fatalf("same: %+v", same)
}
if got := loadBackfillTask(t, db, task.ID); got.StatusVersion != version {
t.Fatal("same amount changed version")
}
different := int64(2300)
item.PDDOrderAmountCent = &different
conflict := runBackfill(t, s, f.token, uuid.NewString(), item)[0]
if conflict.Result != "already_backfilled" || conflict.WarningCode != CodeBackfillAmountConflict || conflict.PDDOrderAmountCent == nil || *conflict.PDDOrderAmountCent != amount {
t.Fatalf("conflict: %+v", conflict)
}
got := loadBackfillTask(t, db, task.ID)
if got.StatusVersion != version || got.PDDOrderAmountCent == nil || *got.PDDOrderAmountCent != amount || got.PDDOrderNo == nil || *got.PDDOrderNo != "AMOUNT-ORDER" {
t.Fatalf("conflict changed facts: %+v", got)
}
}
func TestOrderBackfillAddsMissingAmountToExistingOrder(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
base := backfillItem(task.ID, "LATE-AMOUNT")
runBackfill(t, s, f.token, uuid.NewString(), base)
before := loadBackfillTask(t, db, task.ID)
amount := int64(100)
base.PDDOrderAmountCent = &amount
result := runBackfill(t, s, f.token, uuid.NewString(), base)[0]
if result.Result != "already_backfilled" || result.WarningCode != "" || result.PDDOrderAmountCent == nil || *result.PDDOrderAmountCent != amount {
t.Fatalf("late fill: %+v", result)
}
after := loadBackfillTask(t, db, task.ID)
if after.StatusVersion != before.StatusVersion+1 || after.PDDOrderAmountCent == nil || *after.PDDOrderAmountCent != amount || !after.OrderSubmittedAt.Equal(*before.OrderSubmittedAt) {
t.Fatalf("late fill changed wrong facts: %+v", after)
}
}
func TestOrderBackfillIgnoresNegativeOptionalAmount(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
negative := int64(-1)
item := backfillItem(task.ID, "NEGATIVE-AMOUNT")
item.PDDOrderAmountCent = &negative
result := runBackfill(t, testService(db), f.token, uuid.NewString(), item)[0]
if result.Code != "BACKFILLED" || result.PDDOrderAmountCent != nil {
t.Fatalf("negative amount affected order backfill: %+v", result)
}
got := loadBackfillTask(t, db, task.ID)
if got.PDDOrderNo == nil || *got.PDDOrderNo != "NEGATIVE-AMOUNT" || got.PDDOrderAmountCent != nil {
t.Fatalf("unexpected persisted facts: %+v", got)
}
}
func TestOrderBackfillRejectsOwnershipStatesAndInvalidTime(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
for _, status := range []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending, models.PurchaseTaskStatusFailed, models.PurchaseTaskStatusCancelled, models.PurchaseTaskStatusRehearsalCompleted} {
task := backfillTask(t, db, f, status)
before := loadBackfillTask(t, db, task.ID)
r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]
if r.Code != CodeStateConflict {
t.Fatalf("%s: %+v", status, r)
}
if got := loadBackfillTask(t, db, task.ID); !reflect.DeepEqual(got, before) {
t.Fatal("rejection wrote data")
}
// Release the fixture's device slot before testing the next running state.
if err := task.SetStatus(models.PurchaseTaskStatusCancelled); err != nil {
t.Fatal(err)
}
if err := db.Save(&task).Error; err != nil {
t.Fatal(err)
}
}
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
if err := db.Model(&task).Update("device_id", nil).Error; err != nil {
t.Fatal(err)
}
r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]
if r.Code != CodeBackfillDevice || r.Status != "" || r.PDDOrderNo != nil {
t.Fatalf("ownership leaked: %+v", r)
}
other, err := device.NewService(db).Register(context.Background(), device.RegisterRequest{RequestID: uuid.NewString(), InstallID: uuid.NewString(), Name: "Other", Manufacturer: "Test", Model: "Test", AndroidVersion: "15", AgentVersion: "1", PDDVersion: "7", Capabilities: liveCaps()}, "")
if err != nil {
t.Fatal(err)
}
if err := db.Model(&task).Update("device_id", other.DeviceID).Error; err != nil {
t.Fatal(err)
}
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]; r.Code != CodeBackfillDevice {
t.Fatalf("cross device: %+v", r)
}
if err := db.Model(&task).Updates(map[string]any{"device_id": f.device.ID, "execution_mode": models.PurchaseExecutionModeRehearsal}).Error; err != nil {
t.Fatal(err)
}
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "ORDER"))[0]; r.Code != CodeStateConflict {
t.Fatalf("rehearsal: %+v", r)
}
if err := db.Model(&task).Update("execution_mode", models.PurchaseExecutionModeLive).Error; err != nil {
t.Fatal(err)
}
for _, raw := range []string{"", "2026-09-08 12:00:00", "0001-01-01T00:00:00Z", "garbage"} {
item := backfillItem(task.ID, "ORDER")
item.OrderSubmittedAt = &raw
if r := runBackfill(t, s, f.token, uuid.NewString(), item)[0]; r.Code != CodeOrderTimeInvalid {
t.Fatalf("invalid time: %+v", r)
}
}
}
func TestOrderBackfillConflictsNeverOverwrite(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
rid := uuid.NewString()
r := runBackfill(t, s, f.token, rid, backfillItem(a.ID, "A"), backfillItem(a.ID, "B"), backfillItem(b.ID, "B"))
if r[0].Code != CodeBackfillBatchConflict || r[1].Code != CodeBackfillBatchConflict || r[2].Code != "BACKFILLED" {
t.Fatalf("batch: %+v", r)
}
r = runBackfill(t, s, f.token, rid, backfillItem(a.ID, "B"), backfillItem(b.ID, "C"))
if r[0].Code != CodeBackfillOrderUsed || r[1].Code != CodeBackfillOrderConflict {
t.Fatalf("changed requestId payload bypassed checks: %+v", r)
}
if got := loadBackfillTask(t, db, b.ID); *got.PDDOrderNo != "B" || got.StatusVersion != b.StatusVersion+1 {
t.Fatal("conflict overwrote")
}
if got := loadBackfillTask(t, db, a.ID); got.PDDOrderNo != nil {
t.Fatal("conflict wrote data")
}
// Even an unknown task with an existing conflicting value must preserve it.
a.PDDOrderNo = strptr("OLD")
if err := db.Save(&a).Error; err != nil {
t.Fatal(err)
}
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(a.ID, "NEW"))[0]; r.Code != CodeBackfillOrderConflict {
t.Fatalf("unknown existing: %+v", r)
}
}
func TestOrderBackfillConcurrentResolveUnknown(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
// SQLite serializes transactions through one connection. These concurrent
// service calls verify both winner orders; they do not certify MySQL locks.
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)
for i := 0; i < 12; i++ {
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
var out OrderBackfillResponse
var backErr, manualErr error
go func() {
defer wg.Done()
<-start
out, backErr = s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(task.ID, "BACK-"+purchasecontract.AddressSuffix(task.ID))}}, f.token)
}()
go func() {
defer wg.Done()
<-start
_, _, manualErr = s.ResolveUnknown(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "MANUAL-" + purchasecontract.AddressSuffix(task.ID), OrderSubmittedAt: task.IrreversibleAt})
}()
close(start)
wg.Wait()
if backErr != nil {
t.Fatal(backErr)
}
got := loadBackfillTask(t, db, task.ID)
if got.StatusVersion != task.StatusVersion+1 || got.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatal("competing writes changed version twice")
}
if manualErr == nil {
if out.Items[0].Code != CodeBackfillOrderConflict || !strings.HasPrefix(*got.PDDOrderNo, "MANUAL-") {
t.Fatalf("manual winner: %+v", out)
}
} else if code(manualErr) != CodeStateConflict || out.Items[0].Code != "BACKFILLED" || !strings.HasPrefix(*got.PDDOrderNo, "BACK-") {
t.Fatalf("backfill winner: %+v %v", out, manualErr)
}
}
}
func TestOrderBackfillConcurrentLateResultAndOtherTask(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
attempt := models.PurchaseTaskAttempt{TaskID: a.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusFailed, DeviceID: &f.device.ID, RuleSnapshotHash: purchaseRuleSnapshotHash(a.RuleSnapshot), SpecDecisionSnapshot: `{}`}
if err := db.Omit("Task").Create(&attempt).Error; err != nil {
t.Fatal(err)
}
if err := db.First(&attempt, attempt.ID).Error; err != nil {
t.Fatal(err)
}
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
var out OrderBackfillResponse
var backErr, lateErr error
go func() {
defer wg.Done()
<-start
out, backErr = s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(a.ID, "BACK")}}, f.token)
}()
go func() {
defer wg.Done()
<-start
_, lateErr = s.SubmitResult(context.Background(), a.ID, ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: attempt.AttemptID, ResultType: "order_created", PDDOrderNo: "LATE", OrderSubmittedAt: a.IrreversibleAt}, f.token)
}()
close(start)
wg.Wait()
if backErr != nil || out.Items[0].Code != "BACKFILLED" || code(lateErr) != CodeStateConflict {
t.Fatalf("late race: %+v %v %v", out, backErr, lateErr)
}
var savedAttempt models.PurchaseTaskAttempt
if err := db.First(&savedAttempt, attempt.ID).Error; err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(savedAttempt, attempt) {
t.Fatal("backfill rewrote attempt")
}
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
_, _, err := s.ResolveUnknown(context.Background(), b.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "BACK", OrderSubmittedAt: b.IrreversibleAt})
if err == nil {
t.Fatal("manual path assigned another task's order")
}
if got := loadBackfillTask(t, db, b.ID); got.PDDOrderNo != nil || got.StatusVersion != b.StatusVersion {
t.Fatal("other task changed on conflict")
}
}
func TestOrderBackfillHTTPBoundary(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/order-backfill", device.RequireAgentHTTPS(false, false), (Handler{DB: db}).BackfillOrders)
body, _ := json.Marshal(OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(task.ID, "HTTP")}})
for _, test := range []struct {
body, token string
status int
}{
{string(body), "", http.StatusUnauthorized},
{`{"requestId":"bad","items":[]}`, f.token, http.StatusUnprocessableEntity},
{`{"requestId":"x","address":"forbidden"}`, f.token, http.StatusUnprocessableEntity},
{string(body), f.token, http.StatusOK},
} {
req := httptest.NewRequest(http.MethodPost, "/order-backfill", strings.NewReader(test.body))
req.Header.Set("Authorization", "Bearer "+test.token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != test.status {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
}
_, err := testService(db).BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: make([]OrderBackfillItem, 51)}, f.token)
if code(err) != CodeInvalidRequest {
t.Fatalf("batch limit: %v", err)
}
}
func TestClientOrderBackfillDoesNotRequireDeviceOwnership(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
if err := db.Model(&task).Update("device_id", nil).Error; err != nil {
t.Fatal(err)
}
out, err := testService(db).BackfillOrdersForClient(context.Background(), OrderBackfillRequest{
RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(task.ID, "CLIENT-ORDER")},
})
if err != nil || len(out.Items) != 1 || out.Items[0].Code != "BACKFILLED" {
t.Fatalf("client backfill: %+v %v", out, err)
}
}
func TestClientOrderBackfillFillsOnlyMissingSubmittedTime(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderCreated)
order := "EXISTING-ORDER"
task.PDDOrderNo, task.OrderSubmittedAt = &order, nil
if err := db.Save(&task).Error; err != nil {
t.Fatal(err)
}
first := "2026-09-18T10:00:00+08:00"
item := backfillItem(task.ID, order)
item.OrderSubmittedAt = &first
out, err := s.BackfillOrdersForClient(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{item}})
if err != nil || out.Items[0].Code != "ALREADY_BACKFILLED" || out.Items[0].TimeSource != "page" {
t.Fatalf("fill time: %+v %v", out, err)
}
want, _ := time.Parse(time.RFC3339, first)
got := loadBackfillTask(t, db, task.ID)
if got.OrderSubmittedAt == nil || !got.OrderSubmittedAt.Equal(want) {
t.Fatalf("missing time not filled: %+v", got.OrderSubmittedAt)
}
second := "2026-09-19T10:00:00+08:00"
item.OrderSubmittedAt = &second
out, err = s.BackfillOrdersForClient(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{item}})
if err != nil || out.Items[0].TimeSource != "page" {
t.Fatalf("preserve source: %+v %v", out, err)
}
got = loadBackfillTask(t, db, task.ID)
if got.OrderSubmittedAt == nil || !got.OrderSubmittedAt.Equal(want) {
t.Fatalf("existing time overwritten: %+v", got.OrderSubmittedAt)
}
}
func TestOrderBackfillConcurrentSameOrderDifferentTasks(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
start := make(chan struct{})
results := make(chan OrderBackfillResponse, 2)
errors := make(chan error, 2)
for _, id := range []uint64{a.ID, b.ID} {
go func(id uint64) {
<-start
out, err := s.BackfillOrders(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(id, "SAME")}}, f.token)
results <- out
errors <- err
}(id)
}
close(start)
codes := make(map[string]int)
for i := 0; i < 2; i++ {
out := <-results
if err := <-errors; err != nil {
t.Fatal(err)
}
codes[out.Items[0].Code]++
}
if codes["BACKFILLED"] != 1 || codes[CodeBackfillOrderUsed] != 1 {
t.Fatalf("concurrent assignments: %+v", codes)
}
var count int64
if err := db.Model(&models.PurchaseTask{}).Where("pdd_order_no = ?", "SAME").Count(&count).Error; err != nil || count != 1 {
t.Fatalf("duplicate order: %d %v", count, err)
}
}
func TestOrderBackfillRejectsLateAssignmentOfSameOrder(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
a := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(a.ID, "SHARED"))[0]; r.Code != "BACKFILLED" {
t.Fatal(r)
}
b := backfillTask(t, db, f, models.PurchaseTaskStatusOrderSubmitStarted)
lease := s.Now().Add(time.Minute)
b.LeaseExpiresAt = &lease
if err := db.Save(&b).Error; err != nil {
t.Fatal(err)
}
attempt := models.PurchaseTaskAttempt{TaskID: b.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusRunning, DeviceID: &f.device.ID, RuleSnapshotHash: purchaseRuleSnapshotHash(b.RuleSnapshot), SpecDecisionSnapshot: `{}`}
if err := db.Omit("Task").Create(&attempt).Error; err != nil {
t.Fatal(err)
}
req := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: attempt.AttemptID, ResultType: "order_created", PDDOrderNo: "SHARED", OrderSubmittedAt: b.IrreversibleAt}
out, err := s.SubmitResult(context.Background(), b.ID, req, f.token)
if err != nil || out.Status != models.PurchaseTaskStatusOrderResultUnknown {
t.Fatalf("conflicting result must commit as unknown: %+v %v", out, err)
}
got := loadBackfillTask(t, db, b.ID)
if got.Status != models.PurchaseTaskStatusOrderResultUnknown || got.StatusVersion != b.StatusVersion+1 || got.PDDOrderNo != nil {
t.Fatalf("duplicate assignment was not safely downgraded: %+v", got)
}
wantMessage := "读到订单号 SHARED,但该号已属于任务 " + taskNumber(a.ID)
if got.ErrorCode == nil || *got.ErrorCode != CodeOrderNumberUsed || got.ErrorMessage == nil || *got.ErrorMessage != wantMessage {
t.Fatalf("conflict evidence missing: %+v", got)
}
if got.OrderSubmittedAt == nil || !got.OrderSubmittedAt.Equal(*req.OrderSubmittedAt) || got.IrreversibleAt == nil || got.LeaseExpiresAt != nil || got.DeviceRunSlot != nil || got.AccountRunSlot != nil {
t.Fatalf("boundary evidence or released lease missing: %+v", got)
}
var saved models.PurchaseTaskAttempt
if err := db.First(&saved, attempt.ID).Error; err != nil {
t.Fatal(err)
}
if saved.Status != models.PurchaseAttemptStatusFailed || saved.ResultRequestID == nil || *saved.ResultRequestID != req.RequestID || saved.ResultHash == nil || saved.ResultType == nil || *saved.ResultType != "order_created" || saved.FinishedAt == nil || saved.ErrorCode == nil || *saved.ErrorCode != CodeOrderNumberUsed || saved.ErrorMessage == nil || *saved.ErrorMessage != wantMessage {
t.Fatalf("attempt result and conflict evidence missing: %+v", saved)
}
out, err = s.SubmitResult(context.Background(), b.ID, req, f.token)
if err != nil || !out.Replayed || out.Status != models.PurchaseTaskStatusOrderResultUnknown || loadBackfillTask(t, db, b.ID).StatusVersion != got.StatusVersion {
t.Fatalf("unknown result replay failed: %+v %v", out, err)
}
owner := loadBackfillTask(t, db, a.ID)
if owner.PDDOrderNo == nil || *owner.PDDOrderNo != "SHARED" || owner.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatalf("existing owner changed: %+v", owner)
}
var count int64
if err := db.Model(&models.PurchaseTask{}).Where("pdd_order_no = ?", "SHARED").Count(&count).Error; err != nil || count != 1 {
t.Fatalf("duplicate order: %d %v", count, err)
}
resolved, _, err := s.ResolveUnknown(context.Background(), b.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "CORRECTED", OrderSubmittedAt: req.OrderSubmittedAt})
if err != nil || resolved.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatalf("manual resolution unavailable: %+v %v", resolved, err)
}
}
func TestOrderBackfillHTTPTransportPolicy(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, allow := range []string{"false", "true"} {
t.Setenv("GOAUTO_ALLOW_INSECURE_AGENT_HTTP", allow)
r := gin.New()
r.POST("/order-backfill", device.RequireAgentHTTPS(true, false), (Handler{}).BackfillOrders)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/order-backfill", strings.NewReader(`{}`)))
if allow == "false" && w.Code != http.StatusUpgradeRequired {
t.Fatalf("HTTPS bypass: %d", w.Code)
}
if allow == "true" && w.Code == http.StatusUpgradeRequired {
t.Fatal("HTTP compatibility broken")
}
}
}
func TestOrderBackfillMultiConnectionResolveRace(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(4)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
var back OrderBackfillResponse
var backErr, manualErr error
req := OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(task.ID, "BACK")}}
go func() { defer wg.Done(); <-start; back, backErr = s.BackfillOrders(context.Background(), req, f.token) }()
go func() {
defer wg.Done()
<-start
_, _, manualErr = s.ResolveUnknown(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "MANUAL", OrderSubmittedAt: task.IrreversibleAt})
}()
close(start)
wg.Wait()
// SQLite returns table-lock errors rather than waiting on FOR UPDATE.
// Only that documented DB contention or a domain conflict is acceptable;
// after the competing calls finish, replay must converge without overwrite.
if backErr != nil && !strings.Contains(backErr.Error(), "locked") {
t.Fatal(backErr)
}
if manualErr != nil && code(manualErr) != CodeStateConflict && !strings.Contains(manualErr.Error(), "locked") {
t.Fatal(manualErr)
}
if backErr == nil && back.Items[0].Code != "BACKFILLED" && back.Items[0].Code != CodeBackfillOrderConflict && !(back.Items[0].Code == CodeInternal && back.Items[0].Retryable) {
t.Fatalf("unexpected race result: %+v", back)
}
before := loadBackfillTask(t, db, task.ID)
replay := runBackfill(t, s, f.token, req.RequestID, req.Items...)[0]
after := loadBackfillTask(t, db, task.ID)
if before.PDDOrderNo != nil && !reflect.DeepEqual(before, after) {
t.Fatal("replay overwrote the concurrent winner")
}
if after.StatusVersion != task.StatusVersion+1 || after.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatal("race did not converge to a single transition")
}
if manualErr == nil {
if *after.PDDOrderNo != "MANUAL" || replay.Code != CodeBackfillOrderConflict {
t.Fatal("manual winner overwritten")
}
} else if *after.PDDOrderNo != "BACK" || (replay.Code != "BACKFILLED" && replay.Code != "ALREADY_BACKFILLED") {
t.Fatalf("backfill did not converge: %+v", replay)
}
}
@@ -0,0 +1,74 @@
package purchase
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
func TestOrderNumberConflictBusinessErrors(t *testing.T) {
for _, path := range []string{"resolve_unknown", "cancel", "lifecycle"} {
t.Run(path, func(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
owner := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
if r := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(owner.ID, "SHARED"))[0]; r.Code != "BACKFILLED" {
t.Fatal(r)
}
status := models.PurchaseTaskStatusOrderResultUnknown
if path == "lifecycle" {
status = models.PurchaseTaskStatusRunning
}
task := backfillTask(t, db, f, status)
if path != "resolve_unknown" {
// Model legacy duplicate data predating the global save guard.
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Updates(map[string]any{"pdd_order_no": "SHARED", "lease_expires_at": s.Now().Add(time.Minute)}).Error; err != nil {
t.Fatal(err)
}
}
req := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "SHARED", OrderSubmittedAt: task.IrreversibleAt, Reason: "人工取消"}
var err error
switch path {
case "resolve_unknown":
_, _, err = s.ResolveUnknown(context.Background(), task.ID, req)
case "cancel":
_, _, err = s.Cancel(context.Background(), task.ID, req)
case "lifecycle":
attempt := models.PurchaseTaskAttempt{TaskID: task.ID, AttemptID: uuid.NewString(), AttemptNumber: 1, Phase: models.PurchaseAttemptPhasePurchase, Status: models.PurchaseAttemptStatusRunning, DeviceID: &f.device.ID, RuleSnapshotHash: purchaseRuleSnapshotHash(task.RuleSnapshot), SpecDecisionSnapshot: `{}`}
if e := db.Omit("Task").Create(&attempt).Error; e != nil {
t.Fatal(e)
}
_, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: req.RequestID}, f.token)
}
want := "订单号已属于任务 " + taskNumber(owner.ID)
if code(err) != CodeOrderNumberUsed || err.Error() != want {
t.Fatalf("unmapped conflict: %v", err)
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
writeError(c, err)
var body struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
if e := json.Unmarshal(w.Body.Bytes(), &body); e != nil || w.Code != http.StatusConflict || body.Code != CodeOrderNumberUsed || body.Message != want || body.Retryable {
t.Fatalf("unexpected HTTP error: %d %s (%v)", w.Code, w.Body.String(), e)
}
got := loadBackfillTask(t, db, task.ID)
if got.Status != status || got.StatusVersion != task.StatusVersion || got.UnknownResolveRequestID != nil || got.CancelRequestID != nil || got.OrderSubmitRequestID != nil {
t.Fatalf("rejected mutation persisted: %+v", got)
}
})
}
}
@@ -153,3 +153,57 @@ func TestSubmitFailedResultRecordsErrorOnAttempt(t *testing.T) {
t.Fatalf("attempt error message not recorded: %+v", attempt.ErrorMessage)
}
}
// `[必须]` 采样期间见过待付款/支付页时必须落库,这是后续按优先级处理积压的
// order_result_unknown 记录的唯一依据(#302)。
func TestSubmitOrderResultUnknownRecordsPaymentPageObserved(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, _ := createLive(t, s, f)
started := startLivePurchaseAfterProbe(t, s, f, task)
if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
ErrorCode: CodeOrderPaymentRepeat, ErrorMessage: paymentRepeatAgentMessage,
PaymentPageObserved: true,
}, f.token); err != nil {
t.Fatal(err)
}
var saved models.PurchaseTask
if err := db.First(&saved, task.ID).Error; err != nil {
t.Fatal(err)
}
if saved.PaymentPageObservedAt == nil {
t.Fatal("paymentPageObserved=true 却没有落库时间戳")
}
}
// 没见过支付页时不能编造一个时间戳——这个字段的价值就在于它只在真见过时才有值。
func TestSubmitOrderResultUnknownLeavesPaymentPageObservedEmptyByDefault(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, _ := createLive(t, s, f)
started := startLivePurchaseAfterProbe(t, s, f, task)
if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
ErrorCode: CodeOrderPaymentRepeat, ErrorMessage: paymentRepeatAgentMessage,
}, f.token); err != nil {
t.Fatal(err)
}
var saved models.PurchaseTask
if err := db.First(&saved, task.ID).Error; err != nil {
t.Fatal(err)
}
if saved.PaymentPageObservedAt != nil {
t.Fatal("未上报 paymentPageObserved 时不该写入时间戳")
}
}
@@ -0,0 +1,171 @@
package purchase
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func TestOrderInformationManualCompletion(t *testing.T) {
for _, amount := range []int64{0, 1300} {
t.Run(taskNumber(uint64(amount)), func(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
task.TaskType, task.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
if err := db.Save(&task).Error; err != nil {
t.Fatal(err)
}
req := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "DEMO-MANUAL", OrderSubmittedAt: task.IrreversibleAt}
got, _, err := s.ResolveUnknown(context.Background(), task.ID, req)
if err != nil || got.Status != models.PurchaseTaskStatusOrderCreated || got.PDDOrderAmountCent != nil {
t.Fatalf("legacy save: %v", err)
}
row := loadOrderWriteback(t, db, task.ID)
if row.Status != "pending" {
t.Fatal(row.Status)
}
req.RequestID = uuid.NewString()
req.PDDOrderAmountCent = &amount
got, replay, err := s.ResolveUnknown(context.Background(), task.ID, req)
if err != nil || replay || got.PDDOrderAmountCent == nil || *got.PDDOrderAmountCent != amount {
t.Fatalf("fill: %v", err)
}
if got.PaymentReviewStatus != task.PaymentReviewStatus {
t.Fatal("payment status changed")
}
if _, replay, err = s.ResolveUnknown(context.Background(), task.ID, req); err != nil || !replay {
t.Fatalf("replay: %v", err)
}
req.Status = models.PurchaseTaskStatusCancelled
if _, _, err = s.ResolveUnknown(context.Background(), task.ID, req); code(err) != CodeResultConflict {
t.Fatalf("changed replay status: %v", err)
}
req.Status = models.PurchaseTaskStatusOrderCreated
changed := amount + 1
req.PDDOrderAmountCent = &changed
if _, _, err = s.ResolveUnknown(context.Background(), task.ID, req); code(err) != CodeResultConflict {
t.Fatalf("changed replay: %v", err)
}
req.RequestID = uuid.NewString()
if _, _, err = s.ResolveUnknown(context.Background(), task.ID, req); code(err) != CodeResultConflict {
t.Fatalf("overwrite: %v", err)
}
var count int64
db.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", task.ID).Count(&count)
if count != 1 {
t.Fatalf("duplicate queue: %d", count)
}
})
}
}
func TestOrderInformationQueueFailureRollsBack(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
task.TaskType, task.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
if err := db.Save(&task).Error; err != nil {
t.Fatal(err)
}
if err := db.Callback().Create().Before("gorm:create").Register("326_queue_failure", func(tx *gorm.DB) {
if _, ok := tx.Statement.Dest.(*models.PurchaseOrderWriteback); ok {
tx.AddError(errors.New("synthetic queue failure"))
}
}); err != nil {
t.Fatal(err)
}
defer db.Callback().Create().Remove("326_queue_failure")
amount := int64(100)
_, _, err := s.ResolveUnknown(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "DEMO-ROLLBACK", OrderSubmittedAt: task.IrreversibleAt, PDDOrderAmountCent: &amount})
if err == nil {
t.Fatal("expected failure")
}
got := loadBackfillTask(t, db, task.ID)
if got.PDDOrderNo != nil || got.PDDOrderAmountCent != nil || got.Status != task.Status {
t.Fatal("partial transaction saved")
}
}
func TestOrderWritebackFieldBasedEligibility(t *testing.T) {
for _, status := range []string{models.PurchaseTaskStatusOrderCreated, models.PurchaseTaskStatusFailed, models.PurchaseTaskStatusCancelled, models.PurchaseTaskStatusOrderResultUnknown} {
t.Run(status, func(t *testing.T) {
s, task := orderWritebackFixture(t)
task.Status = status
task.OrderSubmittedAt = nil
task.PDDOrderAmountCent = nil
if err := s.DB.Save(&task).Error; err != nil {
t.Fatal(err)
}
// Existing queued items must also pass the same relaxed worker check.
fake := &fakeOrderNumberClient{apply: true}
if _, err := wbWorker(s, fake).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if fake.writes != 1 || loadOrderWriteback(t, s.DB, task.ID).Status != "succeeded" {
t.Fatal("status/time/amount incorrectly gated worker")
}
// A failed writeback is selectable for manual compensation.
s.DB.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", task.ID).Update("status", "failed")
views, err := s.OrderWritebackViews(context.Background(), []models.PurchaseTask{task})
if err != nil || !views[task.ID].CanSubmit {
t.Fatalf("view: %v", err)
}
result, err := s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
if err != nil || result.Items[0].Result != "pending" {
t.Fatalf("request: %v", err)
}
s.DB.Model(&models.SYBProduct{}).Where("id = ?", *task.SYBProductID).Update("detail_id", 0)
views, err = s.OrderWritebackViews(context.Background(), []models.PurchaseTask{task})
if err != nil || views[task.ID].CanSubmit {
t.Fatal("invalid target selectable")
}
result, err = s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
if err != nil || result.Items[0].Result != "skipped" {
t.Fatal("invalid target accepted")
}
})
}
}
func TestOrderInformationRejectInvalidInputs(t *testing.T) {
for _, name := range []string{"negative", "zero_time", "blank_order", "running", "rehearsal"} {
t.Run(name, func(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
amount := int64(100)
stamp := *task.IrreversibleAt
req := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "DEMO-INVALID", OrderSubmittedAt: &stamp, PDDOrderAmountCent: &amount}
switch name {
case "negative":
amount = -1
case "zero_time":
stamp = time.Time{}
case "blank_order":
req.PDDOrderNo = " "
case "running":
task.Status = models.PurchaseTaskStatusRunning
case "rehearsal":
task.ExecutionMode = models.PurchaseExecutionModeRehearsal
}
if err := db.Save(&task).Error; err != nil {
t.Fatal(err)
}
if _, _, err := s.ResolveUnknown(context.Background(), task.ID, req); err == nil {
t.Fatal("invalid input accepted")
}
if loadBackfillTask(t, db, task.ID).PDDOrderNo != nil {
t.Fatal("invalid input persisted")
}
})
}
}
@@ -0,0 +1,88 @@
package purchase
import (
"context"
"testing"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func TestOrderResultPayableAmount(t *testing.T) {
ptr := func(n int64) *int64 { return &n }
for _, tc := range []struct {
name string
amount, existing, want *int64
result string
invalid bool
}{
{"payable distinct from unit price", ptr(1300), nil, ptr(1300), "order_created", false},
{"zero", ptr(0), nil, ptr(0), "order_created", false},
{"old agent omitted", nil, nil, nil, "order_created", false},
{"omitted preserves existing", nil, ptr(1200), ptr(1200), "order_created", false},
{"does not overwrite existing", ptr(1300), ptr(1200), ptr(1200), "order_created", false},
{"negative", ptr(-1), nil, nil, "order_created", true},
{"unknown cannot attach amount", ptr(1300), nil, nil, "order_result_unknown", true},
{"failed cannot attach amount", ptr(1300), nil, nil, "failed", true},
} {
t.Run(tc.name, func(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
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 tc.existing != nil {
if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Update("pdd_order_amount_cent", *tc.existing).Error; err != nil {
t.Fatal(err)
}
}
now := s.Now()
req := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID,
ResultType: tc.result, PDDOrderNo: "PDD-DEMO-325", OrderSubmittedAt: &now,
ActualUnitPriceCent: ptr(1600), PDDOrderAmountCent: tc.amount}
_, err = s.SubmitResult(context.Background(), task.ID, req, f.token)
if tc.invalid {
if code(err) != CodeInvalidRequest {
t.Fatalf("want invalid request, got %v", err)
}
} else {
if err != nil {
t.Fatal(err)
}
replay, e := s.SubmitResult(context.Background(), task.ID, req, f.token)
if e != nil || !replay.Replayed {
t.Fatalf("replay failed: %v", e)
}
req.PDDOrderAmountCent = ptr(999)
if _, e = s.SubmitResult(context.Background(), task.ID, req, f.token); code(e) != CodeResultConflict {
t.Fatalf("changed amount replay accepted: %v", e)
}
queued := loadOrderWriteback(t, db, task.ID)
if queued.OrderNo != "PDD-DEMO-325" {
t.Fatal("SYB order number queue missing")
}
}
var saved models.PurchaseTask
if e := db.First(&saved, task.ID).Error; e != nil {
t.Fatal(e)
}
if (saved.PDDOrderAmountCent == nil) != (tc.want == nil) || (tc.want != nil && *saved.PDDOrderAmountCent != *tc.want) {
t.Fatal("unexpected saved payable amount")
}
if tc.invalid {
if saved.Status != models.PurchaseTaskStatusOrderSubmitStarted {
t.Fatal("invalid result changed status")
}
} else if saved.ActualUnitPriceCent == nil || *saved.ActualUnitPriceCent != 1600 {
t.Fatal("unit price changed")
}
})
}
}
@@ -0,0 +1,218 @@
package purchase
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"sort"
"strings"
"time"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
type OrderWritebackView struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
CanSubmit bool `json:"canSubmit"`
}
type OrderWritebackRequest struct {
RequestID string `json:"requestId"`
PurchaseTaskIDs []uint64 `json:"purchaseTaskIds"`
}
type OrderWritebackAcceptance struct {
TaskID uint64 `json:"taskId"`
Result string `json:"result"`
Reason string `json:"reason,omitempty"`
}
type OrderWritebackResponse struct {
Items []OrderWritebackAcceptance `json:"items"`
}
func orderWritebackEligible(t models.PurchaseTask) bool {
return t.ExecutionMode == models.PurchaseExecutionModeLive && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.SYBProductID != nil && t.PDDOrderNo != nil && strings.TrimSpace(*t.PDDOrderNo) != ""
}
func validOrderWritebackTarget(syb models.SYBProduct) bool {
return syb.StockID > 0 && syb.DetailID > 0 && syb.StockID <= 1<<63-1 && syb.DetailID <= 1<<63-1
}
// Called within the same transaction as the order fact. Never contacts SYB.
func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
if !orderWritebackEligible(t) {
return nil
}
var syb models.SYBProduct
if err := tx.First(&syb, *t.SYBProductID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if !validOrderWritebackTarget(syb) {
return nil
}
row := models.PurchaseOrderWriteback{PurchaseTaskID: t.ID, StockID: int64(syb.StockID), DetailID: int64(syb.DetailID), OrderNo: *t.PDDOrderNo, Status: "pending"}
return tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
}
func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]OrderWritebackView, error) {
out := map[uint64]OrderWritebackView{}
ids := make([]uint64, 0, len(tasks))
var sybIDs []uint64
for _, t := range tasks {
if orderWritebackEligible(t) {
sybIDs = append(sybIDs, *t.SYBProductID)
}
}
validTargets := map[uint64]bool{}
if len(sybIDs) > 0 {
var targets []models.SYBProduct
if err := s.DB.WithContext(ctx).Select("id", "stock_id", "detail_id").Where("id IN ?", sybIDs).Find(&targets).Error; err != nil {
return nil, internal(err)
}
for _, target := range targets {
validTargets[target.ID] = validOrderWritebackTarget(target)
}
}
for _, t := range tasks {
ids = append(ids, t.ID)
v := OrderWritebackView{Status: "not_applicable", Reason: "需要正式SYB采购任务、订单号及有效SYB明细"}
if orderWritebackEligible(t) {
if validTargets[*t.SYBProductID] {
v = OrderWritebackView{Status: "not_started", CanSubmit: true}
} else {
v.Reason = "SYB明细关联已失效"
}
}
out[t.ID] = v
}
if len(ids) == 0 {
return out, nil
}
var rows []models.PurchaseOrderWriteback
if err := s.DB.WithContext(ctx).Where("purchase_task_id IN ?", ids).Find(&rows).Error; err != nil {
return nil, internal(err)
}
for _, r := range rows {
v := out[r.PurchaseTaskID]
v.Status = r.Status
if r.ErrorMessage != "" || v.CanSubmit || r.Status == "succeeded" {
v.Reason = r.ErrorMessage
}
v.CompletedAt = r.CompletedAt
v.CanSubmit = v.CanSubmit && (r.Status == "failed" || r.Status == "unknown") && (r.LeaseExpiresAt == nil || !r.LeaseExpiresAt.After(s.Now()))
out[r.PurchaseTaskID] = v
}
return out, nil
}
func (s *Service) RequestOrderWriteback(ctx context.Context, req OrderWritebackRequest) (OrderWritebackResponse, error) {
out := OrderWritebackResponse{Items: []OrderWritebackAcceptance{}}
if uuid.Validate(req.RequestID) != nil || len(req.PurchaseTaskIDs) == 0 || len(req.PurchaseTaskIDs) > 100 {
return out, fail(CodeInvalidRequest, "请选择1~100条采购任务")
}
ids := append([]uint64(nil), req.PurchaseTaskIDs...)
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for i, id := range ids {
if id == 0 || (i > 0 && id == ids[i-1]) {
return out, fail(CodeInvalidRequest, "任务编号无效或重复")
}
}
data, _ := json.Marshal(ids)
sum := sha256.Sum256(data)
fingerprint := hex.EncodeToString(sum[:])
db := s.DB.WithContext(ctx).Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)})
err := db.Transaction(func(tx *gorm.DB) error {
// Serializes batch command replay without holding a lock across remote IO.
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.PurchaseOrderWritebackLease{ID: 1}).Error; err != nil {
return err
}
var guard models.PurchaseOrderWritebackLease
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&guard, 1).Error; err != nil {
return err
}
var command models.PurchaseOrderWritebackCommand
err := tx.First(&command, "request_id = ?", req.RequestID).Error
if err == nil {
if command.InputHash != fingerprint {
return fail(CodeInvalidRequest, "同一requestId不能改变任务集合")
}
return json.Unmarshal([]byte(command.ResultJSON), &out)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
for _, id := range ids {
a := OrderWritebackAcceptance{TaskID: id, Result: "skipped", Reason: "任务不存在或不符合回填条件"}
var task models.PurchaseTask
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, id).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
out.Items = append(out.Items, a)
continue
}
if !orderWritebackEligible(task) {
out.Items = append(out.Items, a)
continue
}
var target models.SYBProduct
if err := tx.First(&target, *task.SYBProductID).Error; err != nil || !validOrderWritebackTarget(target) {
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
a.Reason = "SYB明细关联已失效"
out.Items = append(out.Items, a)
continue
}
if err := ensureOrderWriteback(tx, task); err != nil {
return err
}
var row models.PurchaseOrderWriteback
if err := tx.Where("purchase_task_id = ?", id).First(&row).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
a.Reason = "SYB明细关联已失效"
out.Items = append(out.Items, a)
continue
}
switch {
case row.OrderNo != *task.PDDOrderNo:
a.Result, a.Reason = "conflict", "订单号快照不一致,请人工核对"
case row.Status == "succeeded":
a.Result, a.Reason = "succeeded", "已回填"
case row.Status == "conflict":
a.Result, a.Reason = "conflict", row.ErrorMessage
case row.Status == "running" || (row.LeaseExpiresAt != nil && row.LeaseExpiresAt.After(s.Now())):
a.Reason = "正在回填或等待在途请求结束"
case row.Status == "pending":
a.Result, a.Reason = "pending", "已加入回填"
default:
if err := tx.Model(&row).Updates(map[string]any{"status": "pending", "write_started": false, "lease_owner": "", "lease_expires_at": nil, "error_code": "", "error_message": ""}).Error; err != nil {
return err
}
a.Result, a.Reason = "pending", "已加入回填,将先回读SYB"
}
out.Items = append(out.Items, a)
}
result, _ := json.Marshal(out)
return tx.Create(&models.PurchaseOrderWritebackCommand{RequestID: req.RequestID, InputHash: fingerprint, ResultJSON: string(result)}).Error
})
if err != nil {
var e *ServiceError
if errors.As(err, &e) {
return out, err
}
return out, internal(err)
}
return out, nil
}
@@ -0,0 +1,26 @@
package purchase
import (
"github.com/gin-gonic/gin"
)
func (h Handler) AdminOrderWriteback(c *gin.Context) {
if !allowedOperator(c) {
return
}
var req OrderWritebackRequest
if !decode(c, &req) {
return
}
s, ok := h.service(c)
if !ok {
return
}
out, err := s.RequestOrderWriteback(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
c.Header("Cache-Control", "no-store")
writeAdminData(c, out)
}
@@ -0,0 +1,285 @@
package purchase
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybclient"
"gorm.io/gorm"
)
type fakeOrderNumberClient struct {
code, platform string
reads, writes int
writeErr, readErr error
apply bool
duplicate bool
beforeWrite func()
}
func (f *fakeOrderNumberClient) DetailListByStock(_ context.Context, ids []int64) ([]sybclient.StockDetail, error) {
f.reads++
if f.readErr != nil {
return nil, f.readErr
}
d := sybclient.DetailItem{ID: 1, Raw: map[string]any{"purchaseCode": f.code, "purchasePlatform": f.platform}}
details := []sybclient.DetailItem{{ID: 99, Raw: map[string]any{"purchaseCode": "UNRELATED", "purchasePlatform": "pdd"}}, d}
if f.duplicate {
details = append(details, d)
}
return []sybclient.StockDetail{{ID: ids[0], Details: details}}, nil
}
func (f *fakeOrderNumberClient) UpdateDetailPurchaseCode(_ context.Context, stock, detail int64, code string) error {
if stock != 2 || detail != 1 {
panic("wrong remote identity")
}
f.writes++
if f.beforeWrite != nil {
f.beforeWrite()
}
if f.apply {
f.code, f.platform = code, "pdd"
}
return f.writeErr
}
func orderWritebackFixture(t *testing.T) (*Service, models.PurchaseTask) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
task.TaskType, task.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
if err := db.Save(&task).Error; err != nil {
t.Fatal(err)
}
result := runBackfill(t, s, f.token, uuid.NewString(), backfillItem(task.ID, "EXAMPLE-ORDER"))[0]
if result.Code != "BACKFILLED" {
t.Fatalf("backfill=%s", result.Code)
}
return s, loadBackfillTask(t, db, task.ID)
}
func loadOrderWriteback(t *testing.T, db *gorm.DB, id uint64) models.PurchaseOrderWriteback {
t.Helper()
var r models.PurchaseOrderWriteback
if err := db.Where("purchase_task_id = ?", id).First(&r).Error; err != nil {
t.Fatal(err)
}
return r
}
func wbWorker(s *Service, f *fakeOrderNumberClient) *OrderWritebackWorker {
return &OrderWritebackWorker{DB: s.DB, Now: s.Now, Factory: func(context.Context, *gorm.DB) (OrderNumberClient, error) { return f, nil }}
}
func TestOrderWritebackRemoteOutcomes(t *testing.T) {
for _, tc := range []struct {
name string
fake fakeOrderNumberClient
want string
writes int
}{
{"write_and_verify", fakeOrderNumberClient{apply: true}, "succeeded", 1},
{"same_value", fakeOrderNumberClient{code: "EXAMPLE-ORDER", platform: "pdd"}, "succeeded", 0},
{"different_value", fakeOrderNumberClient{code: "OTHER", platform: "pdd"}, "conflict", 0},
{"different_platform", fakeOrderNumberClient{code: "EXAMPLE-ORDER", platform: "other"}, "conflict", 0},
{"empty_code_other_platform", fakeOrderNumberClient{platform: "other"}, "conflict", 0},
{"unknown_applied", fakeOrderNumberClient{apply: true, writeErr: sybclient.ErrWriteResultUnknown}, "succeeded", 1},
{"unknown_unapplied", fakeOrderNumberClient{writeErr: sybclient.ErrWriteResultUnknown}, "unknown", 1},
{"success_not_visible", fakeOrderNumberClient{}, "unknown", 1},
{"explicit_rejection", fakeOrderNumberClient{writeErr: sybclient.ErrSessionInvalid}, "failed", 1},
{"read_failure", fakeOrderNumberClient{readErr: errors.New("offline")}, "failed", 0},
{"ambiguous_target", fakeOrderNumberClient{duplicate: true}, "failed", 0},
} {
t.Run(tc.name, func(t *testing.T) {
s, task := orderWritebackFixture(t)
f := tc.fake
w := wbWorker(s, &f)
if ok, err := w.RunOnce(context.Background()); err != nil || !ok {
t.Fatalf("run %v %v", ok, err)
}
row := loadOrderWriteback(t, s.DB, task.ID)
if row.Status != tc.want || f.writes != tc.writes {
t.Fatalf("status=%s writes=%d", row.Status, f.writes)
}
if _, err := w.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if f.writes != tc.writes {
t.Fatal("automatically repeated write")
}
after := loadBackfillTask(t, s.DB, task.ID)
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != task.WritebackStatus || after.StatusVersion != task.StatusVersion {
t.Fatal("changed purchase/payment/logistics facts")
}
})
}
}
func TestOrderWritebackRecoveryNeverBlindWrites(t *testing.T) {
s, task := orderWritebackFixture(t)
row := loadOrderWriteback(t, s.DB, task.ID)
expired := s.Now().Add(-time.Minute)
if err := s.DB.Model(&row).Updates(map[string]any{"status": "running", "write_started": true, "lease_owner": "crashed", "lease_expires_at": expired}).Error; err != nil {
t.Fatal(err)
}
f := &fakeOrderNumberClient{apply: true}
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "unknown" || f.writes != 0 {
t.Fatal("crash recovery wrote remotely")
}
// Manual compensation remains disabled while the previous lease could be alive.
r, err := s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
if err != nil || r.Items[0].Result != "skipped" {
t.Fatalf("in-flight manual retry: %v %+v", err, r)
}
s.Now = func() time.Time { return expired.Add(5 * time.Minute) }
r, err = s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
if err != nil || r.Items[0].Result != "pending" {
t.Fatal("manual compensation rejected")
}
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "succeeded" || f.writes != 1 {
t.Fatal("manual compensation failed")
}
}
func TestOrderWritebackBatchReplayAndPartialAcceptance(t *testing.T) {
s, task := orderWritebackFixture(t)
req := OrderWritebackRequest{uuid.NewString(), []uint64{task.ID, 9999}}
first, err := s.RequestOrderWriteback(context.Background(), req)
if err != nil {
t.Fatal(err)
}
if first.Items[0].Result != "pending" || first.Items[1].Result != "skipped" {
t.Fatalf("%+v", first)
}
f := &fakeOrderNumberClient{apply: true}
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
again, err := s.RequestOrderWriteback(context.Background(), req)
if err != nil || again.Items[0].Result != "pending" || f.writes != 1 {
t.Fatal("replay changed acceptance or wrote")
}
req.PurchaseTaskIDs = []uint64{task.ID}
if _, err = s.RequestOrderWriteback(context.Background(), req); err == nil {
t.Fatal("changed requestId content accepted")
}
views, err := s.OrderWritebackViews(context.Background(), []models.PurchaseTask{task})
if err != nil || views[task.ID].Status != "succeeded" || views[task.ID].CanSubmit {
t.Fatal("incorrect admin view")
}
}
func TestOrderWritebackGlobalClaimSerializesWriters(t *testing.T) {
s, task := orderWritebackFixture(t)
f := &fakeOrderNumberClient{apply: true}
other := &fakeOrderNumberClient{apply: true}
f.beforeWrite = func() {
if ok, err := wbWorker(s, other).RunOnce(context.Background()); err != nil || ok {
t.Fatalf("parallel claim %v %v", ok, err)
}
}
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if f.writes != 1 || other.writes != 0 || loadOrderWriteback(t, s.DB, task.ID).Status != "succeeded" {
t.Fatal("concurrent writer")
}
}
func TestOrderWritebackEnqueueRollbackAndSameOrderReplay(t *testing.T) {
s, task := orderWritebackFixture(t)
if err := s.DB.Where("purchase_task_id = ?", task.ID).Delete(&models.PurchaseOrderWriteback{}).Error; err != nil {
t.Fatal(err)
}
wantErr := errors.New("rollback")
err := s.DB.Transaction(func(tx *gorm.DB) error {
if e := ensureOrderWriteback(tx, task); e != nil {
return e
}
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatal(err)
}
var count int64
s.DB.Model(&models.PurchaseOrderWriteback{}).Count(&count)
if count != 0 {
t.Fatal("queue escaped transaction")
}
var dev models.AgentDevice
s.DB.First(&dev, *task.DeviceID)
// Directly exercise the already_backfilled branch without needing a raw token.
r := s.backfillOrder(context.Background(), agentBackfillScope(dev.ID), task.ID, uuid.NewString(), backfillItem(task.ID, *task.PDDOrderNo), false)
if r.Result != "already_backfilled" {
t.Fatal(r.Code)
}
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "pending" {
t.Fatal("same-order replay did not ensure queue")
}
}
func TestOrderWritebackAdminEnvelopeAndOperator(t *testing.T) {
gin.SetMode(gin.TestMode)
s, task := orderWritebackFixture(t)
for _, role := range []string{"admin", "purchaser"} {
body, _ := json.Marshal(OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
r := httptest.NewRecorder()
c, _ := gin.CreateTestContext(r)
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
c.Request = httptest.NewRequest(http.MethodPost, "/api/admin/v1/purchase-tasks/syb-order-writeback", strings.NewReader(string(body)))
(Handler{DB: s.DB}).AdminOrderWriteback(c)
var response struct {
Code int `json:"code"`
Data OrderWritebackResponse `json:"data"`
}
if err := json.Unmarshal(r.Body.Bytes(), &response); err != nil || r.Code != 200 || response.Code != 200 || len(response.Data.Items) != 1 || response.Data.Items[0].Result != "pending" {
t.Fatalf("admin contract rejected role=%s status=%d", role, r.Code)
}
if r.Header().Get("Cache-Control") != "no-store" {
t.Fatal("missing cache policy")
}
}
}
func TestOrderWritebackOtherUnknownTargetBlocksNewWrite(t *testing.T) {
s, task := orderWritebackFixture(t)
other := models.PurchaseOrderWriteback{PurchaseTaskID: task.ID + 100, StockID: 2, DetailID: 1, OrderNo: "OTHER", Status: "unknown", WriteStarted: true}
if err := s.DB.Create(&other).Error; err != nil {
t.Fatal(err)
}
f := &fakeOrderNumberClient{apply: true}
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "unknown" || got.ErrorCode != "SYB_TARGET_IN_FLIGHT" || f.writes != 0 {
t.Fatal("another unresolved target was overwritten")
}
}
func TestOrderWritebackSnapshotChangeDoesNotWrite(t *testing.T) {
s, task := orderWritebackFixture(t)
if err := s.DB.Model(&task).Update("pdd_order_no", "CHANGED").Error; err != nil {
t.Fatal(err)
}
f := &fakeOrderNumberClient{apply: true}
if _, err := wbWorker(s, f).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if got := loadOrderWriteback(t, s.DB, task.ID); got.Status != "conflict" || f.writes != 0 {
t.Fatal("changed snapshot written")
}
}
@@ -0,0 +1,213 @@
package purchase
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybclient"
"go-admin/config"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
type OrderNumberClient interface {
DetailListByStock(context.Context, []int64) ([]sybclient.StockDetail, error)
UpdateDetailPurchaseCode(context.Context, int64, int64, string) error
}
type OrderWritebackWorker struct {
DB *gorm.DB
Now func() time.Time
Factory func(context.Context, *gorm.DB) (OrderNumberClient, error)
}
func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberClient, error) {
cfg := config.ExtConfig.SYB.Resolved()
session, err := sybclient.NewSessionStore(db).Load(ctx, cfg.Username, time.Now())
if err != nil {
return nil, err
}
c, err := sybclient.New(cfg.BaseURL)
if err != nil {
return nil, err
}
if err = c.ImportCookiesJSON(session.CookiesJSON); err != nil {
return nil, err
}
return c, nil
}
// One short-lived claim at a time across processes. No business writes occur
// during startup itself; only explicitly persisted pending records are handled.
func RecoverOrderWritebacks(db *gorm.DB) {
go func() {
w := OrderWritebackWorker{DB: db, Now: func() time.Time { return time.Now().UTC() }, Factory: restoreOrderWritebackClient}
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for range ticker.C {
_, _ = w.RunOnce(context.Background())
}
}()
}
func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
if w.Now == nil {
w.Now = func() time.Time { return time.Now().UTC() }
}
if w.Factory == nil {
w.Factory = restoreOrderWritebackClient
}
db := w.DB.WithContext(ctx).Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)})
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&models.PurchaseOrderWritebackLease{ID: 1}).Error; err != nil {
return false, err
}
owner := uuid.NewString()
now := w.Now()
expires := now.Add(2 * time.Minute)
claim := db.Model(&models.PurchaseOrderWritebackLease{}).Where("id = 1 AND (expires_at IS NULL OR expires_at <= ?)", now).Updates(map[string]any{"owner": owner, "expires_at": expires})
if claim.Error != nil {
return false, claim.Error
}
if claim.RowsAffected != 1 {
return false, nil
}
defer db.Model(&models.PurchaseOrderWritebackLease{}).Where("id = 1 AND owner = ?", owner).Updates(map[string]any{"owner": "", "expires_at": nil})
var item models.PurchaseOrderWriteback
recovering := false
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("status = ? OR (status = ? AND lease_expires_at <= ?)", "pending", "running", now).Order("id").First(&item).Error; err != nil {
return err
}
recovering = item.Status == "running"
return tx.Model(&item).Updates(map[string]any{"status": "running", "attempt_count": gorm.Expr("attempt_count + 1"), "lease_owner": owner, "lease_expires_at": expires}).Error
})
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
finish := func(status, code, message string) error {
updates := map[string]any{"status": status, "error_code": code, "error_message": message, "lease_owner": ""}
if status != "unknown" {
updates["lease_expires_at"] = nil
}
if status == "succeeded" {
updates["completed_at"] = w.Now()
}
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
}
var task models.PurchaseTask
if err = db.First(&task, item.PurchaseTaskID).Error; err != nil {
return true, finish("failed", "TASK_UNAVAILABLE", "采购任务不可用,请人工核对")
}
var syb models.SYBProduct
if !orderWritebackEligible(task) || *task.PDDOrderNo != item.OrderNo {
return true, finish("conflict", "ORDER_FACT_CHANGED", "采购订单事实已变化,请人工核对")
}
if err = db.First(&syb, *task.SYBProductID).Error; err != nil || !validOrderWritebackTarget(syb) || int64(syb.StockID) != item.StockID || int64(syb.DetailID) != item.DetailID {
return true, finish("conflict", "SYB_TARGET_CHANGED", "SYB商品明细关联已变化")
}
callCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
client, err := w.Factory(callCtx, db)
cancel()
if err != nil {
return true, finish("failed", "SYB_SESSION_UNAVAILABLE", "SYB会话不可用,请恢复登录后重试")
}
read := func() (string, string, error) {
readCtx, stop := context.WithTimeout(ctx, 20*time.Second)
defer stop()
rows, e := client.DetailListByStock(readCtx, []int64{item.StockID})
if e != nil {
return "", "", e
}
found := 0
code, platform := "", ""
for _, stock := range rows {
if stock.ID != item.StockID {
continue
}
for _, detail := range stock.Details {
if detail.ID == item.DetailID {
found++
var ok bool
code, ok = detail.Raw["purchaseCode"].(string)
if !ok && detail.Raw["purchaseCode"] != nil {
return "", "", errors.New("invalid purchase code")
}
platform, ok = detail.Raw["purchasePlatform"].(string)
if !ok && detail.Raw["purchasePlatform"] != nil {
return "", "", errors.New("invalid platform")
}
}
}
}
if found != 1 {
return "", "", errors.New("ambiguous remote detail")
}
return code, platform, nil
}
code, platform, err := read()
if err != nil {
status := "failed"
if recovering || item.WriteStarted {
status = "unknown"
}
return true, finish(status, "SYB_READ_FAILED", "无法回读SYB目标明细,请恢复连接后重试")
}
if code == item.OrderNo && platform == "pdd" {
return true, finish("succeeded", "", "")
}
if code != "" || (platform != "" && platform != "pdd") {
return true, finish("conflict", "SYB_ORDER_CONFLICT", "SYB已有不同单号或平台,未覆盖,请人工核对")
}
if recovering || item.WriteStarted {
return true, finish("unknown", "SYB_WRITE_UNCONFIRMED", "上次写入结果未确认,未自动重发,请人工核对后重试")
}
var blocked int64
if err = db.Model(&models.PurchaseOrderWriteback{}).Where("id <> ? AND stock_id = ? AND detail_id = ? AND write_started = ? AND status IN ?", item.ID, item.StockID, item.DetailID, true, []string{"running", "unknown"}).Count(&blocked).Error; err != nil {
return true, err
}
if blocked > 0 {
return true, finish("unknown", "SYB_TARGET_IN_FLIGHT", "同一SYB明细有其他未确认写入,请先核对该记录")
}
// Fence immediately before the sole external write. A recovered owner never
// writes; a durable marker survives crashes between request and acknowledgment.
err = db.Transaction(func(tx *gorm.DB) error {
var lease models.PurchaseOrderWritebackLease
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&lease, 1).Error; e != nil {
return e
}
if lease.Owner != owner || lease.ExpiresAt == nil || !lease.ExpiresAt.After(w.Now().Add(25*time.Second)) {
return errors.New("write lease lost")
}
r := tx.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND lease_owner = ? AND status = 'running'", item.ID, owner).Update("write_started", true)
if r.Error != nil {
return r.Error
}
if r.RowsAffected != 1 {
return errors.New("item lease lost")
}
return nil
})
if err != nil {
return true, err
}
writeCtx, stop := context.WithTimeout(ctx, 20*time.Second)
writeErr := client.UpdateDetailPurchaseCode(writeCtx, item.StockID, item.DetailID, item.OrderNo)
stop()
code, platform, err = read()
if err == nil && code == item.OrderNo && platform == "pdd" {
return true, finish("succeeded", "", "")
}
if err == nil && (code != "" || (platform != "" && platform != "pdd")) {
return true, finish("conflict", "SYB_ORDER_CONFLICT", "SYB单号或平台与目标不一致,未覆盖")
}
if writeErr != nil && !errors.Is(writeErr, sybclient.ErrWriteResultUnknown) {
return true, finish("failed", "SYB_WRITE_REJECTED", "SYB拒绝回填,请检查会话与明细后重试")
}
return true, finish("unknown", "SYB_WRITE_UNCONFIRMED", "写入后尚未回读确认,请人工核对后重试")
}
@@ -167,6 +167,11 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
}
return stage(ProcessStagePDDPending, "拼多多商品尚未采集完成", "open_pdd")
}
// `[必须]` 塔缩的键不能走 AI 匹配这条路:再匹配一次也只会为同一个键写一份
// 映射,而问题恰恰是多个不同规格共用了这个键。必须人工处理(#289)。
if preview.ReasonCode == CodeSpecKeyAmbiguous {
return stage(ProcessStageManualAction, preview.Reason, preview.NextAction)
}
if preview.ReasonCode == CodeMappingRequired {
if preview.AIMatchEligible {
return stage(ProcessStageColorMapping, "采购规格尚未匹配并保存", "open_mapping")
+67 -1
View File
@@ -32,6 +32,8 @@ type BatchRetryItem struct {
Replayed bool `json:"replayed,omitempty"`
ReasonCode string `json:"reasonCode,omitempty"`
Reason string `json:"reason,omitempty"`
// AttemptNumber 仅原地重试时有值:该任务本次是第几次执行(#303)。
AttemptNumber int `json:"attemptNumber,omitempty"`
}
type BatchRetryResponse struct {
@@ -69,7 +71,71 @@ type preparedRetry struct {
// BatchRetry creates new pending purchase tasks from failed task identities.
// The failed rows remain immutable history; the current product, mapping,
// price guard and server-owned rule are resolved again at retry time.
// BatchRetry re-executes the selected failed tasks in place (#303).
//
// `[必须]` 不新建任务。采购员在管理页勾选失败任务点「重试」,要的是这条任务本身
// 再跑一次:任务号不变,尝试记入 purchase_task_attempt。安全判据完全复用 Reset——
// 碰过下单边界、同一 SYB 明细已有更新任务、规格快照不完整的都会被拒绝并返回原因,
// 拒绝后也不退回新建,否则等于绕过了 Reset 的保护。
func (s *Service) BatchRetry(ctx context.Context, req BatchRetryRequest) (BatchRetryResponse, error) {
if _, err := uuid.Parse(strings.TrimSpace(req.RequestID)); err != nil {
return BatchRetryResponse{}, fail(CodeInvalidRequest, "requestId 无效")
}
ids, err := validateRetryTaskIDs(req.TaskIDs)
if err != nil {
return BatchRetryResponse{}, err
}
response := BatchRetryResponse{Items: make([]BatchRetryItem, len(ids))}
for index, taskID := range ids {
item := BatchRetryItem{SourceTaskID: taskID, SourceTaskNo: taskNumber(taskID)}
var source models.PurchaseTask
if queryErr := s.DB.WithContext(ctx).First(&source, taskID).Error; queryErr != nil {
if !errors.Is(queryErr, gorm.ErrRecordNotFound) {
return BatchRetryResponse{}, internal(queryErr)
}
item.ReasonCode, item.Reason = CodeTaskNotFound, "采购任务不存在或已被删除"
response.Items[index] = item
response.FailedCount++
continue
}
itemRequestID := retryItemRequestID(req.RequestID, taskID)
// `[必须]` 先识别重放,再做资格预检。第一次重试后任务已是 pending,
// 若先预检会被判「只有失败任务可以重试」拒掉,同一 requestId 重复提交就
// 不再幂等。已有对应尝试记录时直接交给 Reset,由它返回 Replayed。
var replayAttempt models.PurchaseTaskAttempt
replayErr := s.DB.WithContext(ctx).Where("attempt_id = ?", purchaseResetAttemptID(itemRequestID, taskID)).First(&replayAttempt).Error
if replayErr != nil && !errors.Is(replayErr, gorm.ErrRecordNotFound) {
return BatchRetryResponse{}, internal(replayErr)
}
isReplay := replayErr == nil
// 预检与原批量入口一致(状态、档案可采购性、设备),但 checkDeviceBusy 关闭:
// 同一设备上勾选的多条任务要能排队,真正的占用判断在 Reset 的事务里做。
if decision := s.retryEligibility(ctx, source, false); !isReplay && !decision.Allowed {
item.ReasonCode, item.Reason = decision.ReasonCode, decision.Reason
response.Items[index] = item
response.FailedCount++
continue
}
reset, resetErr := s.Reset(ctx, taskID, PurchaseResetRequest{RequestID: itemRequestID})
if resetErr != nil {
item.ReasonCode, item.Reason, _ = serviceErrorFields(resetErr)
response.Items[index] = item
response.FailedCount++
continue
}
sameID := taskID
item.Created, item.TaskID, item.TaskNo, item.Replayed = true, &sameID, taskNumber(taskID), reset.Replayed
item.AttemptNumber = reset.AttemptNumber
response.Items[index] = item
response.CreatedCount++
}
return response, nil
}
// batchRetryCreate creates replacement tasks. Only AgentRetry uses it: once a
// replacement product has been matched the original task cannot be reused,
// because its product and specification snapshots describe the old product.
func (s *Service) batchRetryCreate(ctx context.Context, req BatchRetryRequest) (BatchRetryResponse, error) {
if _, err := uuid.Parse(strings.TrimSpace(req.RequestID)); err != nil {
return BatchRetryResponse{}, fail(CodeInvalidRequest, "requestId 无效")
}
@@ -179,7 +245,7 @@ func (s *Service) AgentRetry(ctx context.Context, taskID uint64, req AgentRetryR
return AgentRetryResponse{}, fail(decision.ReasonCode, decision.Reason)
}
}
result, err := s.BatchRetry(ctx, BatchRetryRequest{RequestID: req.RequestID, TaskIDs: []uint64{taskID}})
result, err := s.batchRetryCreate(ctx, BatchRetryRequest{RequestID: req.RequestID, TaskIDs: []uint64{taskID}})
if err != nil {
return AgentRetryResponse{}, err
}
+54 -17
View File
@@ -36,13 +36,16 @@ func failedLiveTask(t *testing.T, db *gorm.DB, service *Service, f fixture) mode
return task
}
func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) {
// `[必须]` #303:采购管理页勾选失败任务点重试,要的是这条任务本身再跑一次——
// 任务号不变、不产生新任务,本次执行记入 purchase_task_attempt。
func TestBatchRetryReexecutesSelectedTaskInPlaceAndReplays(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
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).Update("order_code", "SYB-CHANGED").Error; err != nil {
var before int64
if err := db.Model(&models.PurchaseTask{}).Count(&before).Error; err != nil {
t.Fatal(err)
}
request := BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}}
@@ -51,36 +54,69 @@ func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if first.CreatedCount != 1 || first.FailedCount != 0 || len(first.Items) != 1 || !first.Items[0].Created || first.Items[0].TaskID == nil {
if first.CreatedCount != 1 || first.FailedCount != 0 || len(first.Items) != 1 || first.Items[0].TaskID == nil {
t.Fatalf("unexpected retry result: %+v", first)
}
if *first.Items[0].TaskID == failed.ID {
t.Fatalf("retry reused failed task: %+v", first.Items[0])
if *first.Items[0].TaskID != failed.ID {
t.Fatalf("retry must reuse the selected task, got %+v", first.Items[0])
}
var oldTask, newTask models.PurchaseTask
if err = db.First(&oldTask, failed.ID).Error; err != nil {
var after int64
if err := db.Model(&models.PurchaseTask{}).Count(&after).Error; err != nil {
t.Fatal(err)
}
if err = db.First(&newTask, *first.Items[0].TaskID).Error; err != nil {
if after != before {
t.Fatalf("retry created a new purchase task: before=%d after=%d", before, after)
}
var task models.PurchaseTask
if err = db.First(&task, failed.ID).Error; err != nil {
t.Fatal(err)
}
if oldTask.Status != models.PurchaseTaskStatusFailed || newTask.Status != models.PurchaseTaskStatusPending || newTask.AddressSuffix == failed.AddressSuffix || newTask.DeviceID == nil || *newTask.DeviceID != f.device.ID {
t.Fatalf("retry facts mismatch: old=%+v new=%+v", oldTask, newTask)
if task.Status != models.PurchaseTaskStatusPending || task.ErrorCode != nil || task.AddressSuffix != failed.AddressSuffix {
t.Fatalf("task was not reset in place: %+v", task)
}
if newTask.ShopeeOrderNoSnapshot != failed.ShopeeOrderNoSnapshot {
t.Fatalf("retry changed order snapshot: old=%q new=%q", failed.ShopeeOrderNoSnapshot, newTask.ShopeeOrderNoSnapshot)
if first.Items[0].AttemptNumber < 1 {
t.Fatalf("attempt number missing: %+v", first.Items[0])
}
replay, err := service.BatchRetry(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if replay.CreatedCount != 1 || !replay.Items[0].Replayed || replay.Items[0].TaskID == nil || *replay.Items[0].TaskID != newTask.ID {
if replay.CreatedCount != 1 || !replay.Items[0].Replayed || *replay.Items[0].TaskID != failed.ID || replay.Items[0].AttemptNumber != first.Items[0].AttemptNumber {
t.Fatalf("retry replay was not stable: first=%+v replay=%+v", first, replay)
}
var count int64
if err = db.Model(&models.PurchaseTask{}).Where("syb_product_id = ?", f.syb.ID).Count(&count).Error; err != nil || count != 2 {
t.Fatalf("retry created duplicate tasks: count=%d err=%v", count, err)
}
// `[必须]` 碰过下单边界的失败任务可能已在 PDD 建了订单,必须拒绝,而且拒绝后
// 不能退回新建——否则等于绕过了 Reset 的保护,重复下单。
func TestBatchRetryRefusesTaskPastOrderBoundaryWithoutCreating(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f.pdd.ID)
service := testService(db)
failed := failedLiveTask(t, db, service, f)
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Update("irreversible_at", service.Now()).Error; err != nil {
t.Fatal(err)
}
var before int64
db.Model(&models.PurchaseTask{}).Count(&before)
result, err := service.BatchRetry(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}})
if err != nil {
t.Fatal(err)
}
if result.CreatedCount != 0 || result.FailedCount != 1 || result.Items[0].ReasonCode == "" {
t.Fatalf("task past the order boundary must be refused: %+v", result)
}
var after int64
db.Model(&models.PurchaseTask{}).Count(&after)
if after != before {
t.Fatalf("refused retry must not create a task: before=%d after=%d", before, after)
}
var task models.PurchaseTask
db.First(&task, failed.ID)
if task.Status != models.PurchaseTaskStatusFailed {
t.Fatalf("refused task changed status: %q", task.Status)
}
}
@@ -174,7 +210,8 @@ func TestAdminListRetryEligibilityRejectsStaleAndOfflineFailures(t *testing.T) {
t.Fatal(err)
}
created, err := service.BatchRetry(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}})
// 用仍会新建的替代路径制造「同一明细已有更新任务」的前提。
created, err := service.batchRetryCreate(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{failed.ID}})
if err != nil || created.CreatedCount != 1 {
t.Fatalf("create newer task: %+v %v", created, err)
}
+2
View File
@@ -18,6 +18,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
agent := engine.Group("/api/agent/v1/purchase-tasks").Use(device.RequireAgentHTTPS(config.ApplicationConfig.Mode == "prod", trust))
agent.GET("", h.AgentHistory)
agent.GET("/next", h.Next)
agent.POST("/order-backfill", h.BackfillOrders)
agent.GET("/:taskId", h.AgentHistoryDetail)
agent.POST("/:taskId/retry", h.AgentRetry)
agent.POST("/:taskId/reset", h.AgentReset)
@@ -31,6 +32,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
admin.POST("/batch-spec-match", h.AdminBatchSpecMatch)
admin.POST("/batch", h.AdminBatchCreate)
admin.POST("/batch-retry", h.AdminBatchRetry)
admin.POST("/syb-order-writeback", h.AdminOrderWriteback)
admin.POST("/stock", h.AdminCreateStock)
admin.GET("/:taskId", h.AdminDetail)
admin.POST("", h.AdminCreate)
@@ -289,6 +289,10 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T)
if err != nil {
t.Fatal(err)
}
queued := loadOrderWriteback(t, db, task.ID)
if queued.Status != "pending" || queued.OrderNo != "PDD-1" {
t.Fatalf("order result did not enqueue writeback: status=%s", queued.Status)
}
replay, err := s.SubmitResult(context.Background(), task.ID, req, f.token)
if err != nil || !replay.Replayed {
t.Fatalf("result replay failed: %+v %v", replay, err)
@@ -0,0 +1,67 @@
package purchase
import (
"context"
"errors"
"go-admin/app/goauto/aimatching"
)
// specMatchFailure says why one AI matching attempt did not produce a mapping.
type specMatchFailure string
const (
// specMatchNoMatch: AI 明确回答「没有可靠对应」。同样的输入下次多半还是这个答案,
// 但实测并非一定——见 retryableSpecMatch 的说明。
specMatchNoMatch specMatchFailure = "no_match"
// specMatchUnavailable: Provider 不可用、配置缺失等外部故障。
specMatchUnavailable specMatchFailure = "unavailable"
// specMatchAborted: 本端 context 被取消或超时。
//
// `[必须]` 必须与 unavailable 分开。它是本端调度问题而非 Provider 故障,重试
// 毫无意义(同一个 ctx 已经死了),把它读成「AI 服务不可用」会一直误导排查——
// 线上 2026-09-16 就出现过 unavailable=7 而 Provider 完全正常的情况(#300)。
specMatchAborted specMatchFailure = "aborted"
)
// specMatchAttempts is the total number of AI calls allowed for one value.
//
// `[必须]` 只重试一次,不要更多。AI 对同一输入会给出不同答案(实测 deepseek-v4-flash
// 在 Temperature=0 下仍然如此:紫色/S 第一次答「未找到可靠的 PDD 规格」,第二次答
// 「唯一匹配」),所以一次重试很值。但真失败——PDD 确实没有该颜色、或 `白色-冰块猫`
// 与 `白色-空白版` 有歧义——每次都会失败,重试只是浪费调用和采购员的时间。实测连续
// 三轮全量匹配,失败数稳定在 12,正是这两类(#295)。
const specMatchAttempts = 2
// resolveSpecMatch calls the matcher, retrying once so a single flaky answer
// does not leave a value permanently unmapped.
//
// 返回的 failure 只在 err != nil 时有意义,供调用方区分「AI 说没有」和「AI 没答上来」——
// 此前两者都记成 failed,排查时分不开(#294 的遗留问题,本次一并修)。
func (s *Service) resolveSpecMatch(ctx context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, specMatchFailure, error) {
var lastErr error
var lastFailure specMatchFailure
for attempt := 0; attempt < specMatchAttempts; attempt++ {
matched, err := s.matcher().Resolve(ctx, request)
if err == nil {
return matched, "", nil
}
lastErr, lastFailure = err, classifySpecMatchFailure(err)
if ctx.Err() != nil {
// ctx 已死,重试用的是同一个 ctx,再试一次只是多一条误导性日志。
return aimatching.MatchResult{}, specMatchAborted, lastErr
}
}
return aimatching.MatchResult{}, lastFailure, lastErr
}
func classifySpecMatchFailure(err error) specMatchFailure {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return specMatchAborted
}
var matchErr *aimatching.Error
if errors.As(err, &matchErr) && matchErr.Code == aimatching.CodeNoMatch {
return specMatchNoMatch
}
return specMatchUnavailable
}
@@ -0,0 +1,102 @@
package purchase
import (
"context"
"errors"
"testing"
"go-admin/app/goauto/aimatching"
)
type scriptedMatcher struct {
calls int
answers []error
}
func (m *scriptedMatcher) Resolve(ctx context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, error) {
index := m.calls
m.calls++
if index < len(m.answers) && m.answers[index] != nil {
return aimatching.MatchResult{}, m.answers[index]
}
return aimatching.MatchResult{Source: aimatching.SourceAI, MappedColor: "紫色-冰块猫"}, nil
}
func noMatch() error {
return &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "没有找到可采购的 PDD 颜色或尺码"}
}
// `[必须]` 实测同一输入第一次答「未找到」、第二次答「唯一匹配」(#295)。一次抽风
// 不该让这个值永久留空。
func TestFlakyFirstAnswerIsRetriedOnce(t *testing.T) {
matcher := &scriptedMatcher{answers: []error{noMatch()}}
service := &Service{Matcher: matcher}
matched, failure, err := service.resolveSpecMatch(context.Background(), aimatching.MatchRequest{TargetColor: "紫色"})
if err != nil {
t.Fatalf("重试后应当成功,实际 %v", err)
}
if failure != "" || matched.MappedColor != "紫色-冰块猫" {
t.Fatalf("failure=%q mapped=%q", failure, matched.MappedColor)
}
if matcher.calls != 2 {
t.Fatalf("调用了 %d 次,应为 2", matcher.calls)
}
}
// `[必须]` 真失败每次都会失败——PDD 确实没有该颜色,或候选有歧义。重试次数必须有
// 上限,否则只是浪费调用和采购员的时间。
func TestARealNoMatchIsNotRetriedForever(t *testing.T) {
matcher := &scriptedMatcher{answers: []error{noMatch(), noMatch(), noMatch()}}
service := &Service{Matcher: matcher}
_, failure, err := service.resolveSpecMatch(context.Background(), aimatching.MatchRequest{TargetColor: "墨綠色"})
if err == nil {
t.Fatal("应当失败")
}
if failure != specMatchNoMatch {
t.Fatalf("failure=%q,应为 no_match", failure)
}
if matcher.calls != specMatchAttempts {
t.Fatalf("调用了 %d 次,应为上限 %d", matcher.calls, specMatchAttempts)
}
}
// 服务不可用与「AI 说没有」的处置完全不同,必须分得开(#294 的遗留问题)。
func TestTransportFailureIsNotReportedAsNoMatch(t *testing.T) {
matcher := &scriptedMatcher{answers: []error{errors.New("dial tcp: connection refused"), errors.New("dial tcp: connection refused")}}
service := &Service{Matcher: matcher}
_, failure, err := service.resolveSpecMatch(context.Background(), aimatching.MatchRequest{TargetColor: "紫色"})
if err == nil {
t.Fatal("应当失败")
}
if failure != specMatchUnavailable {
t.Fatalf("failure=%q,应为 unavailable", failure)
}
}
// `[必须]` ctx 被取消是本端调度问题,不是 Provider 故障。归成 unavailable 会让
// 排查一直看错方向——线上 2026-09-16 就出现过 unavailable=7 而 AI 服务完全正常,
// 真正的原因是匹配挂在 Agent 提交请求的 ctx 上、请求断开即中断(#300)。
func TestCancelledContextIsAbortedNotUnavailable(t *testing.T) {
matcher := &scriptedMatcher{answers: []error{context.Canceled, context.Canceled}}
service := &Service{Matcher: matcher}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, failure, err := service.resolveSpecMatch(ctx, aimatching.MatchRequest{TargetColor: "桔紅色"})
if err == nil {
t.Fatal("应当失败")
}
if failure != specMatchAborted {
t.Fatalf("failure=%q,应为 aborted", failure)
}
// ctx 已死,重试用的是同一个 ctx,不该再问一次。
if matcher.calls != 1 {
t.Fatalf("调用了 %d 次,ctx 已取消时不应重试", matcher.calls)
}
}
func TestDeadlineExceededIsAlsoAborted(t *testing.T) {
if got := classifySpecMatchFailure(context.DeadlineExceeded); got != specMatchAborted {
t.Fatalf("got %q, want aborted", got)
}
}
+12 -6
View File
@@ -22,6 +22,7 @@ const (
CodeRetryStale = "PURCHASE_RETRY_STALE"
CodeSpecReprobeRejected = "PURCHASE_SPEC_REPROBE_REJECTED"
CodeOrderResultUnknown = "PURCHASE_ORDER_RESULT_UNKNOWN"
CodeOrderNumberUsed = "PURCHASE_ORDER_NUMBER_ALREADY_USED"
CodeOrderEmptyTimeout = "PURCHASE_ORDER_EMPTY_TIMEOUT"
CodeOrderChooserBack = "PURCHASE_ORDER_CHOOSER_BACK_FAILED"
CodeOrderWechatRestore = "PURCHASE_ORDER_WECHAT_RESTORE_FAILED"
@@ -156,9 +157,13 @@ type ResultRequest struct {
PDDOrderNo string `json:"pddOrderNo,omitempty"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
ActualUnitPriceCent *int64 `json:"actualUnitPriceCent,omitempty"`
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
ProbedSpecs json.RawMessage `json:"probedSpecs,omitempty"`
// PaymentPageObserved 仅 order_result_unknown 使用;见 models.PurchaseTask
// 的 PaymentPageObservedAt 说明(#302)。
PaymentPageObserved bool `json:"paymentPageObserved,omitempty"`
}
type SpecDecisionRequest struct {
@@ -175,10 +180,11 @@ type SpecDecisionRequest struct {
}
type ManualRequest struct {
RequestID string `json:"requestId"`
OperatorID uint64 `json:"operatorId"`
Reason string `json:"reason,omitempty"`
Status string `json:"status,omitempty"`
PDDOrderNo string `json:"pddOrderNo,omitempty"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
RequestID string `json:"requestId"`
OperatorID uint64 `json:"operatorId"`
Reason string `json:"reason,omitempty"`
Status string `json:"status,omitempty"`
PDDOrderNo string `json:"pddOrderNo,omitempty"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
}
@@ -0,0 +1,17 @@
package purchasecontract
import "testing"
func TestParseAddressSuffix(t *testing.T) {
for _, id := range []uint64{7, 72, ^uint64(0)} {
got, err := ParseAddressSuffix(AddressSuffix(id))
if err != nil || got != id {
t.Fatalf("id=%d got=%d err=%v", id, got, err)
}
}
for _, raw := range []string{"", "_cg", "_cg0", "_cg00", "_cg07", "_cg+7", "_cg-7", "_cg18446744073709551616", "_CG7", "_cg7x", "_cg7_cg72", "address_cg7", " _cg7", "_cg7 ", "_cg7", "_cg7\n"} {
if id, err := ParseAddressSuffix(raw); err == nil || id != 0 {
t.Errorf("accepted %q: %d", raw, id)
}
}
}
@@ -9,6 +9,7 @@ import (
"math"
"regexp"
"sort"
"strconv"
"strings"
"unicode/utf8"
)
@@ -360,6 +361,18 @@ func RequiredCapabilities(rule RuleSnapshot) []string {
func AddressSuffix(taskID uint64) string { return fmt.Sprintf("_cg%d", taskID) }
// ParseAddressSuffix accepts only the exact canonical suffix, never an address.
func ParseAddressSuffix(suffix string) (uint64, error) {
if !strings.HasPrefix(suffix, "_cg") {
return 0, errors.New("invalid address suffix")
}
id, err := strconv.ParseUint(strings.TrimPrefix(suffix, "_cg"), 10, 64)
if err != nil || id == 0 || AddressSuffix(id) != suffix {
return 0, errors.New("invalid address suffix")
}
return id, nil
}
func ensureEOF(decoder *json.Decoder) error {
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
+46 -4
View File
@@ -190,17 +190,48 @@ func (service *Service) suggestMappings(ctx context.Context, id uint64, requestC
return AISuggestResponse{}, aiUnavailable("AI 匹配未启用,请先在设置中配置并启用")
}
result, err := aiService.SuggestBatch(ctx, aimatching.SuggestRequest{
suggestReq := aimatching.SuggestRequest{
Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title,
Sources: sources, Candidates: candidates,
})
}
result, err := aiService.SuggestBatch(ctx, suggestReq)
suggestCalls := 1
if err != nil {
return AISuggestResponse{}, aiUnavailable(aiSuggestErrorMessage(err))
result, err = aiService.SuggestBatch(ctx, suggestReq)
suggestCalls++
if err != nil {
return AISuggestResponse{}, aiUnavailable(aiSuggestErrorMessage(err))
}
}
decisions := result.Decisions
if decisions == nil {
decisions = map[string]aimatching.SuggestDecision{}
}
var retrySources []aimatching.SuggestSource
for _, source := range sources {
decision, ok := decisions[source.ID]
if unreliableSuggestDecision(decision, ok, candidateLabelByID, candidateValues) {
retrySources = append(retrySources, source)
}
}
if len(retrySources) > 0 && suggestCalls < 2 {
retryResult, retryErr := aiService.SuggestBatch(ctx, aimatching.SuggestRequest{
Dimension: role, ShopeeTitle: shopee.Title, PDDTitle: pdd.Title,
Sources: retrySources, Candidates: candidates,
})
if retryErr == nil {
for _, source := range retrySources {
if decision, ok := retryResult.Decisions[source.ID]; ok {
decisions[source.ID] = decision
}
}
}
}
for i, p := range placeholders {
sourceID := fmt.Sprintf("s%d", i+1)
decision, ok := result.Decisions[sourceID]
decision, ok := decisions[sourceID]
item := &response.Items[p.itemIndex]
if !ok || decision.CandidateID == "" {
item.Reason = "AI 未给出可靠建议,请人工选择"
@@ -252,6 +283,17 @@ func deterministicColorMatch(value string, candidates []string) string {
return match.MappedColor
}
// unreliableSuggestDecision is true when SuggestBatch gave no usable pick:
// missing decision, empty candidate, or a candidate that cannot be translated
// back onto the live PDD selectable set.
func unreliableSuggestDecision(decision aimatching.SuggestDecision, ok bool, candidateLabelByID map[string]string, candidateValues map[string]bool) bool {
if !ok || decision.CandidateID == "" {
return true
}
pddValue, ok := candidateLabelByID[decision.CandidateID]
return !ok || !candidateValues[pddValue]
}
// aiSuggestErrorMessage turns an aimatching error into a message safe to
// return to the admin UI (no credential, no raw provider body).
func aiSuggestErrorMessage(err error) string {
@@ -3,10 +3,13 @@ package shopeeproduct
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"github.com/google/uuid"
@@ -25,6 +28,49 @@ func seedEnabledAISetting(t *testing.T, baseURL string, minConfidence float64) m
return models.AIMatchingSetting{ID: 1, Enabled: true, Provider: "openai_compatible", BaseURL: baseURL, Model: "test-model", APIKey: "test-key", TimeoutSeconds: 5, AutoConfirmMinConfidence: minConfidence}
}
type capturedSuggestPrompt struct {
Sources []struct {
ID string `json:"id"`
Label string `json:"label"`
} `json:"sources"`
Candidates []struct {
ID string `json:"id"`
Label string `json:"label"`
} `json:"candidates"`
}
func parseSuggestPrompt(t *testing.T, body []byte) capturedSuggestPrompt {
t.Helper()
var payload struct {
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode chat request: %v", err)
}
var user string
for _, message := range payload.Messages {
if message.Role == "user" {
user = message.Content
}
}
var prompt capturedSuggestPrompt
if err := json.Unmarshal([]byte(user), &prompt); err != nil {
t.Fatalf("decode suggest prompt: %v", err)
}
return prompt
}
func sourceIDsOf(prompt capturedSuggestPrompt) []string {
ids := make([]string, len(prompt.Sources))
for i, source := range prompt.Sources {
ids[i] = source.ID
}
return ids
}
func TestSuggestColorMappingsRejectsStaleContextVersion(t *testing.T) {
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
@@ -158,3 +204,213 @@ func TestSuggestMappingsSkipConfirmedValuesStillValid(t *testing.T) {
t.Fatalf("expected preserved confirmed mapping without calling AI: %+v", preview.Items)
}
}
func TestSuggestMappingsRetriesOnlyUnreliableSources(t *testing.T) {
var mu sync.Mutex
var prompts []capturedSuggestPrompt
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
http.Error(w, "read", http.StatusInternalServerError)
return
}
prompt := parseSuggestPrompt(t, body)
mu.Lock()
call := len(prompts)
prompts = append(prompts, prompt)
mu.Unlock()
content := `{"suggestions":[
{"sourceId":"s1","candidateId":"c1","confidence":0.95,"reason":"first call match"},
{"sourceId":"s2","candidateId":"","confidence":0,"reason":"no pick"}
]}`
if call >= 1 {
content = `{"suggestions":[
{"sourceId":"s2","candidateId":"c1","confidence":0.96,"reason":"retry match"}
]}`
}
chatCompletionResponder(content)(w, r)
}))
defer server.Close()
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
setting := seedEnabledAISetting(t, server.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-RETRY-PARTIAL", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{
{Name: "深黑", Source: ValueSourceImport},
{Name: "浅黑", Source: ValueSourceImport},
}}}})
if err != nil {
t.Fatal(err)
}
preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(prompts) != 2 {
t.Fatalf("expected exactly 2 SuggestBatch calls, got %d", len(prompts))
}
if got := sourceIDsOf(prompts[0]); len(got) != 2 || got[0] != "s1" || got[1] != "s2" {
t.Fatalf("first call sources: %v", got)
}
if got := sourceIDsOf(prompts[1]); len(got) != 1 || got[0] != "s2" {
t.Fatalf("second call must retry only the unreliable source, got %v", got)
}
if len(prompts[0].Candidates) == 0 {
t.Fatal("first call missing candidates")
}
firstCandidates, _ := json.Marshal(prompts[0].Candidates)
secondCandidates, _ := json.Marshal(prompts[1].Candidates)
if string(firstCandidates) != string(secondCandidates) {
t.Fatalf("retry must reuse the same candidates\nfirst=%s\nsecond=%s", firstCandidates, secondCandidates)
}
if len(preview.Items) != 2 {
t.Fatalf("expected 2 items, got %+v", preview.Items)
}
if preview.Items[0].Status != "ai_matched" || preview.Items[0].PDDValue != "黑色" || preview.Items[0].Reason != "first call match" {
t.Fatalf("concluded source must keep first-call result: %+v", preview.Items[0])
}
if preview.Items[1].Status != "ai_matched" || preview.Items[1].PDDValue != "黑色" || preview.Items[1].Reason != "retry match" {
t.Fatalf("unreliable source must be filled from retry: %+v", preview.Items[1])
}
if preview.MatchedCount != 2 || preview.PendingCount != 0 {
t.Fatalf("unexpected counts: %+v", preview)
}
}
func TestSuggestMappingsRetriesSuggestBatchErrorOnce(t *testing.T) {
var mu sync.Mutex
var prompts []capturedSuggestPrompt
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
http.Error(w, "read", http.StatusInternalServerError)
return
}
prompt := parseSuggestPrompt(t, body)
mu.Lock()
call := len(prompts)
prompts = append(prompts, prompt)
mu.Unlock()
if call == 0 {
http.Error(w, "provider down", http.StatusBadGateway)
return
}
chatCompletionResponder(`{"suggestions":[
{"sourceId":"s1","candidateId":"c1","confidence":0.97,"reason":"recovered"}
]}`)(w, r)
}))
defer server.Close()
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
setting := seedEnabledAISetting(t, server.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-RETRY-ERR", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{
{Name: "深黑", Source: ValueSourceImport},
}}}})
if err != nil {
t.Fatal(err)
}
preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
if err != nil {
t.Fatalf("second SuggestBatch success should recover: %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(prompts) != 2 {
t.Fatalf("expected exactly 2 SuggestBatch calls, got %d", len(prompts))
}
firstCandidates, _ := json.Marshal(prompts[0].Candidates)
secondCandidates, _ := json.Marshal(prompts[1].Candidates)
if string(firstCandidates) != string(secondCandidates) {
t.Fatalf("error retry must reuse the same candidates\nfirst=%s\nsecond=%s", firstCandidates, secondCandidates)
}
if got, want := sourceIDsOf(prompts[0]), sourceIDsOf(prompts[1]); len(got) != len(want) || (len(got) > 0 && got[0] != want[0]) {
t.Fatalf("error retry must resend the same sources: first=%v second=%v", got, want)
}
if len(preview.Items) != 1 || preview.Items[0].Status != "ai_matched" || preview.Items[0].PDDValue != "黑色" {
t.Fatalf("unexpected preview: %+v", preview.Items)
}
}
func TestSuggestMappingsDoesNotExceedTwoSuggestBatchCalls(t *testing.T) {
var mu sync.Mutex
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
calls++
mu.Unlock()
chatCompletionResponder(`{"suggestions":[
{"sourceId":"s1","candidateId":"","confidence":0,"reason":"none"},
{"sourceId":"s2","candidateId":"","confidence":0,"reason":"none"}
]}`)(w, r)
}))
defer server.Close()
db := openTestDB(t)
pdd := seedPDDProduct(t, db, "active")
setting := seedEnabledAISetting(t, server.URL, 0.9)
if err := db.Create(&setting).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-RETRY-CAP", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{
{Name: "深黑", Source: ValueSourceImport},
{Name: "浅黑", Source: ValueSourceImport},
}}}})
if err != nil {
t.Fatal(err)
}
preview, err := service.SuggestColorMappings(context.Background(), created.Product.ID, created.Product.SpecContextVersion)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
mu.Lock()
defer mu.Unlock()
if calls != 2 {
t.Fatalf("SuggestBatch must be called at most twice, got %d", calls)
}
if preview.PendingCount != 2 || preview.MatchedCount != 0 {
t.Fatalf("still-unreliable after retry should stay pending: %+v", preview)
}
for _, item := range preview.Items {
if item.Reason != "AI 未给出可靠建议,请人工选择" || item.Status != "pending" {
t.Fatalf("unexpected pending item: %+v", item)
}
}
}
func TestUnreliableSuggestDecision(t *testing.T) {
labels := map[string]string{"c1": "黑色"}
values := map[string]bool{"黑色": true}
if !unreliableSuggestDecision(aimatching.SuggestDecision{}, false, labels, values) {
t.Fatal("missing decision must be retried")
}
if !unreliableSuggestDecision(aimatching.SuggestDecision{CandidateID: ""}, true, labels, values) {
t.Fatal("empty candidate must be retried")
}
if !unreliableSuggestDecision(aimatching.SuggestDecision{CandidateID: "c9"}, true, labels, values) {
t.Fatal("unknown candidate id must be retried")
}
if !unreliableSuggestDecision(aimatching.SuggestDecision{CandidateID: "c1"}, true, map[string]string{"c1": "红色"}, values) {
t.Fatal("label outside candidate set must be retried")
}
if unreliableSuggestDecision(aimatching.SuggestDecision{CandidateID: "c1"}, true, labels, values) {
t.Fatal("in-set candidate must not be retried")
}
}
+119
View File
@@ -0,0 +1,119 @@
// Package shopeespec fetches a Shopee product's complete colour and size list
// from the ERP-Go side service (#290).
//
// 存在的理由:虾皮档案里的颜色尺码是从 SYB 明细增量累积的(sybimport.mergeParsedSpec),
// SYB 送来什么才有什么,因此永远滞后于真实商品。新订单带来新组合时又变成“未匹配”,
// 采购员得反复去点 AI 匹配。拉取完整清单后可以一次性匹配完,之后新订单直接可采购。
package shopeespec
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const (
// 返回体是颜色与尺码两个字符串数组,实测 462 字节;上限留足冗余即可。
maxResponseBytes = 1 << 20
requestTimeout = 10 * time.Second
)
// ErrNotConfigured means the base URL or API key is absent, so the caller
// should skip the sync instead of failing the surrounding operation.
var ErrNotConfigured = errors.New("shopee spec service is not configured")
// Spec is the complete colour and size list of one Shopee product, exactly as
// the platform spells them — 【...】 annotations included.
type Spec struct {
Colors []string
Sizes []string
}
// Client reads its endpoint and credential from the environment.
//
// `[必须]` 凭据只从环境变量读取,不接受参数传入、不落日志。本地由
// scripts/start-server.ps1 从 config.yaml 的 erpgo 段转换;线上在
// /etc/goauto/goauto.env 配置。
type Client struct {
HTTP *http.Client
}
func NewClient() *Client { return &Client{HTTP: &http.Client{Timeout: requestTimeout}} }
// Fetch returns the full spec list for one Shopee item id.
func (c *Client) Fetch(ctx context.Context, shopeeItemID string) (Spec, error) {
shopeeItemID = strings.TrimSpace(shopeeItemID)
if shopeeItemID == "" {
return Spec{}, errors.New("shopee item id is empty")
}
base := strings.TrimSpace(os.Getenv("GOAUTO_ERPGO_BASE_URL"))
key := strings.TrimSpace(os.Getenv("GOAUTO_ERPGO_APIKEY"))
if base == "" || key == "" {
return Spec{}, ErrNotConfigured
}
endpoint, err := url.Parse(strings.TrimRight(base, "/") + "/api/v1/shopee/product/spec/" + url.PathEscape(shopeeItemID))
if err != nil {
return Spec{}, err
}
query := endpoint.Query()
query.Set("apikey", key)
endpoint.RawQuery = query.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return Spec{}, err
}
client := c.HTTP
if client == nil {
client = &http.Client{Timeout: requestTimeout}
}
response, err := client.Do(request)
if err != nil {
// `[必须]` 不要把 err 直接往外带:net/url 的错误会把完整 URL(含 apikey)
// 写进错误文本,那会让凭据流进日志和工单。
return Spec{}, fmt.Errorf("shopee spec request failed for item %s", shopeeItemID)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return Spec{}, fmt.Errorf("shopee spec service returned %d for item %s", response.StatusCode, shopeeItemID)
}
body, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes))
if err != nil {
return Spec{}, fmt.Errorf("shopee spec response unreadable for item %s", shopeeItemID)
}
var payload struct {
Data struct {
Color []string `json:"color"`
Size []string `json:"size"`
} `json:"data"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return Spec{}, fmt.Errorf("shopee spec response is not decodable for item %s", shopeeItemID)
}
spec := Spec{Colors: clean(payload.Data.Color), Sizes: clean(payload.Data.Size)}
if len(spec.Colors) == 0 && len(spec.Sizes) == 0 {
return Spec{}, fmt.Errorf("shopee spec service returned no specs for item %s", shopeeItemID)
}
return spec, nil
}
func clean(values []string) []string {
result := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}

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