Compare commits

...
Author SHA1 Message Date
QiuSW 018e3566c0 Merge remote-tracking branch 'origin/feat/338-return-match' into feat/339-syb-page-size 2026-09-24 16:15:01 +08:00
QiuSWandClaude Opus 5.5 5ac8e4c8fd feat(yeeke): show missing-marked and recovered counts per sync run (#338)
Persist how many return items each sync run flipped to "missing" and how
many came back to "ok" (yeeke_sync_run.missing_marked_count /
recovered_count, migration 1789801000000), return them from the sync-runs
API and add 「标记不可用」「恢复可用」 columns to the sync-runs page. When the
20% safety valve skips marking the count stays 0 and the reason remains in
error_message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 16:14:30 +08:00
QiuSW ddd875d989 Merge remote-tracking branch 'origin/feat/338-return-match' into feat/339-syb-page-size 2026-09-24 15:59:14 +08:00
QiuSWandClaude Opus 5.5 ff6e87649c feat(returnmatch): mark yeeke returns unavailable when a complete sync drops them (#338)
A COMPLETE yeeke sync run (natural page-walk end, not stopped by a
duplicate-fingerprint break or MaxPages exhaustion) that also wrote zero
record failures now flips any yeeke_return_package/yeeke_return_item still
"ok" from before the run to sync_status="missing" with missing_since
stamped, in one transaction. Rows are never deleted. A 20% safety valve
skips marking (and records why in error_message) when the candidate count
would be too large; a reappearing record recovers to "ok" with
missing_since cleared.

returnmatch.availableReturnPool now excludes missing items/packages from
matching, without auto-cancelling any existing active match; List()/Detail()
surface syncStatus/missingSince so the SYB products match column, its
compare dialog, and the yeeke returns list can warn or label rows as
unavailable.

Adds migration 1789800900000_return_missing (AutoMigrate alone does not
reach existing databases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 15:57:51 +08:00
QiuSWandClaude Opus 5.5 8b4db9c89e style(web): narrow SYB order-code filter input to 80% width (#339)
210px -> 168px, requested while trying the 500-rows page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 14:39:47 +08:00
QiuSWandClaude Opus 5.5 00fb274fd4 feat(syb): support 200/500 rows per page on SYB products list (#339)
Bump the syb-products page-size options to 20/50/100/200/500 with a new
100 default, cap the server-side sybimport.List page size at 500, chunk
the per-page purchase-readiness preview into <=100-id requests, and add
per-button selection limits (with disabled+tooltip) for AI 匹配, 创建采购,
创建采集, 图搜采集 and 匹配退货 so a larger page never silently exceeds a
batch endpoint's cap. 创建采购's 100-item server cap is left untouched.
Also caps returnmatch.BatchMatch at 500 ids (INVALID_REQUEST beyond that).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 14:35:53 +08:00
QiuSWandClaude Opus 5.5 1e944b9d2e feat(returnmatch): record every batch-match submission and its outcomes (#338)
Each click of 「匹配退货」 now writes a return_match_batch row: operator,
time, submitted/matched/skipped counts and the per-SYB-product result
(in submission order), plus the error if the batch aborted. Recording
happens after the per-row transactions commit, so a logging failure is
logged server-side instead of failing an already-applied match.
GET /return-matches/batches and /return-matches/batches/:batchId expose
the records. Migration version 1789800800000 creates the table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 14:22:06 +08:00
QiuSWandClaude Opus 5.5 20a8144be0 fix(db): add migration version that creates return_match tables (#338)
Registering the models in MigratedModels alone does nothing on databases
whose earlier versions are already recorded in sys_migration; the migrate
command's post-check rejected the run with "迁移后仍缺少表". Add the
1789800700000 version so existing databases create return_match and
return_match_log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 14:03:06 +08:00
QiuSWandClaude Opus 5.5 f2c8307c17 feat(web): return-matching UI on SYB products and yeeke returns pages (#338)
SYB 订单商品页 (web/src/views/goauto/syb-products/index.vue):
- 「匹配退货」按钮,作用于勾选行中处于参与匹配阶段的商品(与服务端
  returnmatch.participatingStages 同一口径),canPurchase 门禁,忙碌时
  loading,无候选时禁用。
- 批量结果对话框:成功匹配/无候选/阶段不参与跳过/并发冲突跳过四类计数
  + 逐行原因表格。
- 处理阶段筛选与展示新增「退货待确认」「已用退货」两个选项/标签(复用
  sybimport 现有的、基于 purchase.ProcessStages 的阶段筛选机制,无需
  额外服务端改动——process_stage.go 上一次提交已让这两个阶段参与其中)。
- 新增「匹配到的退货商品」列(缩略图/退货订单号/规格/销毁截止),逐行
  「查看对比」「备注」「取消匹配」(后两者仅 canPurchase 可见)。
- 已有效匹配的行不可勾选(isReturnMatchCandidate 排除 return_pending/
  return_used 两个阶段)。
- 匹配详情对话框:SYB/yeeke 双方字段与归一化规格文字并排对比、备注
  编辑、操作日志列表、确认(仅 matched 状态可用)/取消匹配(matched
  或 confirmed 可用)按钮,取消前均有二次确认。

退货商品页 (web/src/views/goauto/yeeke-returns/index.vue):
- 新增「匹配状态」筛选(全部/未匹配/退货待确认/已用退货),对所有用户
  可见(纯只读筛选,不需要 canPurchase)。
- 新增「被占用 SYB 订单商品」列(订单号 + 当前阶段,点击跳转 SYB 商品
  页)。

New file web/src/api/goauto/return-matches.js: listReturnMatches/
getReturnMatch/batchMatchReturns/confirmReturnMatch/cancelReturnMatch/
remarkReturnMatch.

Backend support for the match column: returnmatch.Service.List now
returns ListItem (match + orderSn/image/variationName/destroyDeadline)
via one bounded join over the page's yeeke_return_item ids, so the SYB
product page's match column never needs a second request per row
(service.go, service_test.go: TestList_EnrichesWithYeekeReturnDisplayFields).

采购创建拦截的 UI 呈现沿用现有机制:ProcessStageReturnPending/
ProcessStageReturnUsed 让 isPurchaseCandidate(row) 天然为 false(与其余
非 purchase_ready 阶段的既有行为一致),「采购」按钮不显示,处理阶段列
展示服务端返回的拦截原因文案;未额外改动采购创建流程/UI。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 12:03:10 +08:00
QiuSWandClaude Opus 5.5 92c42ba1fe feat(yeeke): add match-status filter and occupying-SYB column to returns list (#338)
ListReturnPackages (app/goauto/yeeke/list.go) gains a single bounded
LEFT JOIN onto return_match (active match only) and syb_product, plus
a matchStatus query param (unmatched/matched/confirmed — invalid
values are rejected, not ignored). ReturnItemRowDTO now carries
matchId/matchStatus/occupyingSybProductId/occupyingSybOrderCode/
occupyingSybStage(+Label). The occupying SYB product's current stage
label is filled from one extra bounded purchase.NewService(db).
ProcessStages call over the page's distinct occupying SYB ids — not a
per-row lookup — so the yeeke returns page's new 匹配状态 filter and
被占用 SYB 订单商品 column need no N+1 query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 11:58:24 +08:00
QiuSWandClaude Opus 5.5 fc99be0392 fix(returnmatch): dedicated log table, race-safe matching, spec/remark fixes (#338)
Review fixes on the #338 backend:

1. New table return_match_log (models/return_match_log.go, registered
   in migrations.MigratedModels()): match_id/action/operator/detail/
   created_at. sys_opera_log is a generic per-HTTP-call framework log,
   not queryable per match and not carrying operator/detail in a
   stable shape, so match/confirm/cancel/remark each write their own
   log row in the SAME transaction as the state change. Detail now
   returns the logs (newest first).

2. Race between matching and purchase creation: BatchMatch's outer
   screening pass (stage check outside any lock) is now followed by
   matchOneWithLock, which takes the same clause.Locking{Strength:
   "UPDATE"} lock on syb_product that purchase.Service.create takes,
   re-computes the stage inside that transaction via
   purchase.NewService(tx).ProcessStages, and returns
   errStageNoLongerEligible (surfaced as reasonCode
   stage_ineligible) if the product is no longer in a participating
   stage instead of inserting a stale match.
   TestMatchOneWithLock_SkipsWhenStageNoLongerParticipatesUnderLock
   covers the skip path.

3. matchOneWithLock now fills YeekeSpecText (raw variation_name) and
   PreviousProcessStage (the stage code at match time) on the
   inserted row. Remark takes an operator (for its log row) and
   rejects input over 500 runes with errRemarkTooLong instead of
   truncating (varchar(500) is a character-count limit in MySQL, so
   the check is utf8.RuneCountInString, not len()).

4. returnmatch.SYBSpecText now joins only non-empty color/size parts,
   so a single-dimension spec (e.g. color-only) no longer produces a
   stray leading/trailing comma ("黑色" instead of "黑色,"); matchKey
   additionally trims leading/trailing commas from both normalized
   sides via the new trimCommas() helper. New tests cover color-only
   and size-only matching through SelectMatches plus SYBSpecText/
   trimCommas directly.

5. Detail (service.go) now returns MatchDetail: SYBDetailView (order
   code, shopee item id, shop, title, target color/size, quantity,
   image, current computed stage+label), YeekeDetailView (return
   order sn, item id, variation id, shop, item name, variation name,
   quantity, image, destroy deadline), both sides' normalized spec
   text, the match row itself, and the operation logs — everything
   the prototype's compare screen (screen 3) needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 11:56:14 +08:00
QiuSWandClaude Opus 5.5 dec99cb81d fix(purchase): block new purchase tasks for actively return-matched SYB products (#338)
rejectIfActiveReturnMatch (app/goauto/purchase/return_match_block.go)
is called from Service.create right after the SYB row is locked: a
SYB product with an active (matched or confirmed) return_match row is
rejected with CodeReturnMatched. Since BatchCreate already calls
Service.Create per row and treats a create error as a per-row skip,
this single insertion point covers both single and batch creation —
batch creation skips only the blocked rows and reports
CodeReturnMatched, it does not fail the whole batch. A query error
here is treated as internal() and never silently allows creation.

Process stage computation (process_stage.go) gains two new stages,
ProcessStageReturnPending (退货待确认) and ProcessStageReturnUsed
(已用退货), sourced from a new bounded dataset.activeReturnMatchBySYB
query in loadBatchPreviewDataset (batch.go); with zero return_match
rows this query returns nothing and every other stage branch is
unchanged (updated the batch preview bounded-query-count assertion in
batch_test.go from 8 to 9 to reflect the new, still-bounded query).

Regression coverage (return_match_block_test.go): single create
rejected for matched and for confirmed match, cancelled match does
not block, batch create creates the clean row and skips only the
matched row with CodeReturnMatched, and a zero-return-match baseline
still succeeds unchanged (acceptance item 11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 11:49:10 +08:00
QiuSWandClaude Opus 5.5 fbde80cc20 feat(returnmatch): DB-backed batch match/confirm/cancel/remark API for #338
Service (app/goauto/returnmatch/service.go) wires the pure matching
functions to the database:
- BatchMatch: manual-only trigger (no scheduler, not called from
  yeeke sync or SYB import) for ticked SYB product rows. Filters to
  the participating process stages (待人工处理 excluded per the
  confirmed rule), loads the available return pool (no active match,
  non-nil future destroy deadline) via purchase.ProcessStages +a
  join query, runs SelectMatches, and inserts one return_match row per
  outcome. A unique-constraint violation on insert (lost race) is
  reported per-row as a conflict skip, never fails the whole batch.
- Confirm/Cancel: state transitions with row locking; Cancel clears
  both Active* columns so the same pair can be rematched later.
- Remark, List (by SYB product id / return item id / status) and
  Detail for both admin pages' filter/column needs.

Handler + router (app/goauto/returnmatch/{handler,router}.go) expose
POST /api/admin/v1/return-matches/batch-match, GET .../return-matches,
GET .../return-matches/:id, POST .../:id/{confirm,cancel,remark}.
Write actions require admin/purchaser (same requireCanPurchase gate
already used by yeeke.Handler.TriggerSync); list/detail are read-only
for any authenticated user. Registered in
app/admin/router/init_router.go.

Tests cover end-to-end batch match, expired-deadline exclusion,
multi-colour cross-pairing through the DB path, 待人工处理 exclusion,
confirm-then-cancel restoring availability and rematch-ability, and a
concurrent-insert test asserting exactly one winner against the
unique constraint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 11:48:58 +08:00
QiuSWandClaude Opus 5.5 9bebf3930d feat(returnmatch): pure spec normalization and candidate selection for #338
Package returnmatch holds only DB-free, unit-tested logic so the
matching rules can be verified directly:
- Normalize() strips 【】()()[] brackets and their content, strips
  whitespace, converts fullwidth ASCII/space to halfwidth and
  lowercases (issue #338 normalization rule), backtested against the
  local real-pair samples quoted in the issue.
- SelectMatches() implements rules 2-6: caller-ordered (SYB created_at
  DESC) processing, deadline-must-be-after-now filtering, earliest-
  deadline-first selection among same-key candidates, and same-run
  occupied-return exclusion; quantity never participates.

Covers the same-order multi-colour cross-pairing case explicitly
(TestSelectMatches_MultiColourSameOrderCrossPairing /
TestSYBSpecText) plus expired-deadline, earliest-first, occupied,
quantity-ignored and different-item-id cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 11:48:44 +08:00
QiuSWandClaude Opus 5.5 3516fb7edc feat(db): add return_match table for #338
New model models.ReturnMatch backing the SYB↔yeeke return matching
feature: matched/confirmed/cancelled status, nullable
ActiveSYBProductID/ActiveYeekeReturnItemID columns (same pattern as
YeekeSyncRun.ActiveSlot) each carrying a unique index so only one
active match can occupy either side at a time; cancel clears both to
free the slot for a rematch. Registered in migrations.MigratedModels().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 11:48:33 +08:00
QiuSWandClaude Sonnet 5 d0b86092a6 fix(web): map claim status 1 to 已认领 instead of showing the raw digit (#337)
The frontend's claimStatusMeta only mapped English pseudo-values
(pending/claimed/rejected) that never existed in yeeke's real data — the
API's confirmed status is the raw string "1" (HAR, #336 requirement
doc). Every known-status row fell through to the unstyled default and
showed a bare "1". The claimStatus filter had the same mismatch: its
options never equaled the backend's claim_status = <raw value> filter,
so filtering by 已认领/待认领/已拒绝 silently returned nothing.

Map "1" to 已认领 and drop the two never-observed pseudo-statuses from
the filter, per the project rule that unmapped values must stay visible
and flagged rather than guessed at.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 08:57:35 +08:00
QiuSWandClaude Sonnet 5 5faf870d0f refactor(web): rename returns list columns, add resync-updates-times test (#337)
商品标题/规格 -> 商品标题 (drop the spec subtitle, already shown in the
独立 规格数据 column). 认领时间 -> 上架时间 in the list. The detail drawer
still says 认领时间, unchanged per the request scope.

Also locks in existing sync behavior with a regression test: a resync of
the same package must overwrite claim_time and destroy_dead_line, not
just last_synced_at (server/.../sync.go already did this; the test only
adds coverage).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 08:52:47 +08:00
QiuSW 6773c88a64 fix(yeeke): align return item table labels (#336) 2026-09-23 17:11:37 +08:00
QiuSWandClaude Opus 5.5 248eae01ee feat(yeeke): switch returns admin list to item-level rows with shop filter (#337)
Change GET /api/admin/v1/yeeke-returns to return one row per
yeeke_return_item joined with its parent package, with a placeholder
row (hasItem:false) for packages that have no items. Add
GET /yeeke-returns/shops (distinct sorted shop names) and
GET /yeeke-returns/items/:itemId (item + parent package + sibling
items) as static-prefixed routes registered before the /:packageId
wildcard so they are not swallowed by it. Shop filter is now an exact
match instead of a partial LIKE. Remove trackingNo/itemCount from the
list projection while keeping them on package detail.

Update the admin web list to item-level rows (reference image column,
店铺 dropdown sourced from /shops) and retitle the detail drawer to
"退货商品详情", switching it to the item-detail endpoint for real rows
and the existing package-detail endpoint for placeholder rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 16:37:56 +08:00
QiuSWandClaude Opus 5.5 ac5d8336ec refactor(web): drop sync-runs footnote and let the table fill the page (#337)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 16:32:28 +08:00
QiuSWandClaude Opus 5.5 8ba3470728 fix(yeeke): insert fully populated rows so strict MySQL accepts them (#336)
Every record failed on MySQL: new packages/items were created as empty
placeholder rows and filled in afterwards, so the INSERT carried a zero
last_synced_at, which NOT NULL + NO_ZERO_DATE rejects. The failure was
only counted, never explained, and SQLite tests did not enforce it.

Insert complete rows (including last_synced_at) and write each package
with its items in one transaction. Runs now record "N 条写入失败,首个原因:…"
and are marked failed when nothing could be stored. Tests emulate strict
datetime mode in SQLite; both fail on the previous code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 16:02:10 +08:00
QiuSWandClaude Opus 5.5 b1629b900a fix(yeeke): decode string quantities and read yeeke times as Beijing time (#336)
The live list returns variationQuantityPurchased as a numeric string
("2"), which failed decoding the whole page. Decode it through a tolerant
FlexInt (number, numeric string, null or empty). yeeke times are naive
Beijing wall-clock strings; parse them in UTC+8 instead of UTC so stored
times are not shifted by eight hours. Adds a decode test built from the
HAR field types with fake values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 15:57:00 +08:00
QiuSWandClaude Opus 5.5 c33e83823a fix(yeeke): use real session-check endpoint, X-Access-Token header and web list body (#336)
Compared against the HAR: /agent-foreign/sys/userInfo does not exist and
yeeke answered HTTP 500, so every sync after the first successful login
failed at the session check. Use /agent-foreign/shopee/user/info, which
the web client calls after login. Send the token in the X-Access-Token
header like the web client (the list endpoint only accepts the header)
instead of a ?token= URL parameter, which also keeps it out of URL logs.
Post the list filters as the web client does (column/order, string flags).
Also treat "登录...失效" as an expired session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 15:52:58 +08:00
QiuSWandClaude Opus 5.5 faac8bb155 fix(yeeke): browser UA, captcha _t param and classified login failures (#336)
Align the yeeke client with the working reference demo/yeeke_demo.py and
the HAR: send a desktop Chrome User-Agent on every request and a _t
timestamp on randomImage. Business failures now surface as APIError with
yeeke's own short message; LoginWithOCR retries only captcha rejections
and stops at once on any other refusal (e.g. wrong password) instead of
burning attempts, and the final error reports how many captchas were
rejected or unreadable. Expired-login detection keeps mapping to
ErrSessionInvalid. Errors never include credentials, captcha text or token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 15:48:49 +08:00
QiuSWandClaude Opus 5.5 e845651933 fix(yeeke): read captcha key from randomImage "key" field (#336)
The live /sys/randomImage response names the captcha key "key"; the
client only read "checkKey", so it always sent an empty checkKey, the
login call returned "fields required" before reaching yeeke, and every
OCR attempt was reported as a captcha failure. Read "key" (falling back
to "checkKey"), fail explicitly when neither is present, and send
remember_me like the web client does. Test fixtures used the wrong shape,
which is why the bug was not caught; they now match the HAR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 14:56:46 +08:00
QiuSWandClaude Opus 5 3bc4784402 refactor(web): simplify yeeke returns and sync-runs pages per review (#337)
Drop the page headings and descriptions on both pages; remove the manual
sync / view sync-runs buttons and the tracking-no and item/variation
filters from the returns page; place manual sync right-aligned after
reset on the sync-runs page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 14:48:13 +08:00
QiuSWandClaude Opus 5 ab292d6a17 feat(yeeke): add admin read-only return-package/sync-run modules (#337)
Add read-only server endpoints for yeeke return packages (paginated list
with order/tracking/shop/item/variation/claim-status/create-time filters,
plus package detail with items) and extend the existing sync-runs endpoint
with trigger/status/started-at filters, backward compatible. Split the
admin menu into two modules per the ticket's confirmed scope adjustment:
yeeke_returns (packages) and the new yeeke_sync_runs, both under the
采集采购 menu group, wired via a new sys_menu migration mirroring #237's
precedent. Add the corresponding Vue pages, API wrappers and Go tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 14:23:51 +08:00
QiuSWandClaude Opus 5 02bddbf304 fix(yeeke): reclaim stale sync lease and stabilize item keys (#336)
Two reviewer-identified defects in the yeeke return sync:

- acquire() wrote LeaseExpiresAt but nothing ever read it back, so a
  crash/restart mid-run left a permanent active_slot=1 row blocking every
  future sync. acquire() now runs a conditional takeover UPDATE first
  (status=running AND lease_expires_at <= now -> failed, active_slot
  cleared, error_message recorded), following the lease-with-expiry-
  takeover idiom in order_writeback_worker.go. The takeover UPDATE is a
  single statement so it is atomic per-row, and the
  ux_yeeke_sync_run_active_slot unique index arbitrates a concurrent
  takeover race the same way it already arbitrates two brand-new runs.

- itemKey() always appended the positional index, so a package whose items
  come back in a different order on a later sync got new keys and
  duplicate rows. The index fallback is now used only when i.ID, i.ItemID
  and i.VariationID are all empty.

Added tests: TestStaleLeaseIsTakenOverOnNextAcquire,
TestValidLeaseIsNotTakenOver, TestConcurrentTakeoverExactlyOneWins,
TestItemKeyStableAcrossReorder,
TestItemKeyIndexFallbackForItemsLackingAllIDs,
TestItemKeyDistinctVariationsOfSameItemID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 11:03:48 +08:00
QiuSWandClaude Opus 5 3e82ad6570 feat(yeeke): complete admin surface, scheduling and tests for return sync #336
Builds on cf70021, which added the read-only yeeke client/session/sync core
but left it unreachable and unconfigurable. This commit:

- Wires config.ExtConfig.Yeeke (settings.yml + config.yaml + GOAUTO_YEEKE_*
  env vars), mirroring the existing SYB credential pattern exactly, with a
  dedicated OcrURL and shared OCR client from sybclient.
- Adds yeeke.StartSync as the single entry point for both a manual admin
  trigger and the scheduled job, sharing one in-memory gate plus the existing
  DB-level unique active_slot lease so they can never run concurrently.
- Fixes sync.go bugs found in review: Service.Sync always returned a nil
  error even when the run failed (start/resume semantics were untestable),
  item upserts on ctx-less s.db calls, and no error_message/last_success_at
  was ever recorded on the run row.
- Adds status_unrecognized to yeeke_return_package: an unknown claim status
  is preserved verbatim and flagged rather than silently bucketed.
- Adds the admin read-only surface (GET .../sync-runs, GET
  .../sync-runs/:runId, POST .../sync) under /api/admin/v1/yeeke-returns,
  visible to admin and purchaser per the #336 review comment, registered as
  a GoAuto access module/menu group and purchaser API.
- Registers GoAutoYeekeReturnSync in the existing job/lease framework
  (app/jobs), seeded disabled (Status 2) by a new version-local migration,
  following 1786701600000_syb_hourly_sync_job.go's pattern exactly.
- Expands tests: session reuse/bounded re-login/timeout-preserves-cache in
  yeekeclient; paging robustness (total changing mid-run, duplicate page,
  empty page, timeout, simulated restart/resume), idempotent upserts,
  unrecognized-status flagging, active_slot lease contention, StartSync gate
  contention, and a credential/captcha redaction check in yeeke; settings.yml
  binding and env var precedence in config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 11:03:46 +08:00
QiuSWandCodex GPT-6-astra b015719947 feat(yeeke): add read-only return sync #336
Co-Authored-By: Codex GPT-6-astra <noreply@openai.com>

Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 11:03:44 +08:00
QiuSWandClaude Opus 5 4caed7d2dd chore(android): bump agent to 0.9.63 for #335
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 10:57:54 +08:00
QiuSWandClaude Opus 5 e82762e6b7 fix(android): pick submit leaf only from the bottom-most row, not the whole panel (#335)
Reviewer cross-check of 2ce59eb on real dumps found a Samsung sample where
the true bottom bar is a label-less 31px FrameLayout whose only text node
is zero-size ([0,0][0,0]). A whole-panel leaf scan fell through to the
next lowest labelled leaf, which lives in the PAYMENT-METHOD row directly
above the bottom bar ("使用#微信支付,更换先用后付可0元下单"), and the
climb-to-clickable-ancestor landed the click on "change payment method"
instead of the order button. That must never happen.

finalSubmitTargets is now a strict two-step "row, then leaf" pick instead
of a single whole-panel leaf scan:

1. Row: among visible/enabled/non-zero-size CLICKABLE nodes inside the
   recognized panel's container with height <= 30% of screen height (the
   guard from 2bc624f), pick the one with the lowest bottom edge; ties go
   to the rightmost. This row, and only this row, may hold the order
   button. If its own subtree matches a payment-method alias
   (textAliases.specPanel.paymentAreaAliases: 微信支付/先用后付/支付方式) or
   SUBMIT_TARGET_BLOCKED_MARKERS, fail explicitly (outcome=row_blocked) -
   never fall back to a higher row.
2. Leaf: within that row's own subtree (or the row itself), among
   visible/non-zero-size nodes with a non-blank own label whose nearest
   clickable ancestor is EXACTLY that row, pick the bottom-right-most one
   (unchanged rationale from 2ce59eb: PurchaseUiDriver.clickFresh re-finds
   by preferredOrDescendantLabel()+className+center±32 before climbing to
   the nearest clickable ancestor, so the target must carry a real label).
   If the row has no such leaf, fail explicitly (outcome=bottom_row_unlabelled)
   - never fall back to a higher row either.

hasFinalSavedAddressEvidence, finalConfirmation and submitOrderOnce still
all consume this one finalSubmitTargets.

Tests: SpecPanelFixtures.sheet(Sheet(submit = "hidden")) already models the
Samsung shape (31px hidden bottom bar above a real payment-method row) -
added a test asserting it now fails explicitly with no click, verifying
the payment row is never touched. Added a second test where the payment
row is itself the bottom-most clickable row (no submit region at all) and
must be rejected by its own alias match. Fixed readySheet() (used by the
address-save-wait test) to stop compounding liveShapedSheet()'s own hidden
31px placeholder with a separate real submit button - the two used to tie
and could flip which one the rightmost tie-break picked; it now builds the
same shape directly with submit="none" so "r/final" is the sole,
unambiguous bottom row. Updated one diagnostic key assertion (tie= ->
rowTie=) for the row-level tie-break. Every other #335 test (legacy
提交订单, 570 structure + ReFindingDriver re-find/climb, payment-word
blocked, outside-panel-container, no-determinable-container) passed
unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 10:57:52 +08:00
QiuSWandClaude Opus 5 edc956c430 fix(android): aim submit target at labelled leaf so clickFresh can re-find it (#335)
Real-device result on build 0.9.62 (commit 34d09cd, task 594): the bottom
container was selected correctly but the click never happened.
GoAutoAccessibilityService.clickFreshDetailed re-finds its target live by
(preferredOrDescendantLabel() == target.label, className, center ±32) and
only THEN climbs to the nearest clickable ancestor and clicks it. The
previous finalSubmitTargets returned the clickable FrameLayout container
itself, whose SnapshotNode.label is "" (its text lives only in children),
while the live preferredOrDescendantLabel() digs into a child and returns
real text ("大促价,") - so the re-find always missed -> TARGET_NOT_FOUND ->
PURCHASE_ORDER_RESULT_UNKNOWN right after the irreversible boundary. No
order was created (user confirmed).

Per the user's restated strategy, finalSubmitTargets now aims at the
bottom-right-most VISIBLE, non-zero-size node that has a NON-BLANK OWN
LABEL within the recognized panel container, requiring it to have a
clickable ancestor (or be clickable itself) of non-zero size; ties go to
the rightmost. This is the same "aim at the labelled descendant, let
clickFresh climb to the clickable ancestor" convention already used by
ImageSearchCandidatePolicy.clickTargetInside for PDD image-search result
cards. The nearest clickable ancestor's subtree (not just the chosen
leaf's own subtree) must not contain SUBMIT_TARGET_BLOCKED_MARKERS, since
a payment word can live in a sibling leaf under the same clickable row.
hasFinalSavedAddressEvidence, finalConfirmation and submitOrderOnce still
all consume this one finalSubmitTargets, so the address-save evidence
check and the final click stay consistent.

Tests: added ReFindingDriver, a fake driver that emulates
clickFreshDetailed's real re-find + climb-to-clickable-ancestor semantics
against a fixed live node list, with a dedicated test on the task 570/594
structure that asserts the click actually lands on the clickable
FrameLayout container, not the labelled leaf finalSubmitTargets aimed at -
this is the class of test that would have caught the task 594 regression;
prior StaticDriver-only tests could not, since StaticDriver's clickFresh
just records target.label directly. Updated the existing 570 test and the
zero-size test to assert on the now-correct leaf-label target. Verified
every other #335 safety test (legacy 提交订单, tie/rightmost, payment-word
blocked, outside-panel-container, no-determinable-container) still passes
unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 10:57:50 +08:00
QiuSWandClaude Opus 5 1d302a9144 chore(android): bump agent to 0.9.62 for #335
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 10:57:48 +08:00
QiuSWandClaude Opus 5 18353b2381 fix(android): scope submit target to panel container, widen blocked words (#335)
Review follow-up on 2bc624f:

1. finalSubmitTargets' payment-word block only covered PAYMENT_MARKERS
   (立即支付/确认支付/输入支付密码). The ticket also requires blocking 去支付
   and 付款. These cannot join the shared PAYMENT_MARKERS list: it also
   gates the global pageProblem() payment guard, and UNPAID_MARKERS' "待付款"
   contains "付款", so adding it there would misclassify every ordinary
   unpaid-order page as a forbidden payment page. Added a separate
   SUBMIT_TARGET_BLOCKED_MARKERS list (立即支付/确认支付/去支付/付款/输入支付密码)
   used only by finalSubmitTargets' bottom-node subtree check.

2. Candidates were previously scanned across the whole snapshot, so an
   unrelated clickable node outside the recognized panel (e.g. an
   underlying goods-detail-page bottom bar still in the accessibility tree
   behind the sheet) could out-rank the real button by sitting lower on
   screen. Added panelContainerBounds(): climbs from the panel's unique
   quantity input to the largest ancestor that still does not cover the
   whole screen (the same "does not cover the whole screen" bounded notion
   #331 already uses for sharesBoundedPanelContainer/boundedScrollables),
   giving the whole bottom-sheet container. ParsedPddScreen.specPanelContainer
   (PddScreenParser's `panelScrollable`) was considered but is the wrong
   notion here: it is only the inner *scrollable dimension list* used for
   heading/option parsing, and in a real PDD sheet the address/payment/
   submit rows sit outside it as structural siblings, not descendants -
   using it would incorrectly exclude the real submit row in most panels.
   A candidate belongs to the panel when its center point falls inside the
   container's bounds (geometric containment, matching the existing
   `inside()` convention in this file, not path prefix, since PDD's own
   tree can place the submit bar as a structural sibling that is still
   visually part of the sheet). When the quantity input is missing or not
   unique, the container cannot be determined and finalSubmitTargets fails
   explicitly (no target) instead of guessing.

Tests added to SpecPanelRecognitionTest: bottom node labeled "去支付" and
"付款" are blocked (distinct from the existing PAYMENT_MARKERS-triggered
PURCHASE_PAYMENT_FORBIDDEN cases, since neither word reaches the global
guard); a clickable node outside the recognized panel container is never
chosen even though it is bottom-most on screen; a recognized panel with no
determinable quantity input/container fails explicitly. All existing
readOrderResult tests (which legitimately show "待付款"/"去支付" on the
order-result page, unrelated to finalSubmitTargets) stay green unchanged,
confirming SUBMIT_TARGET_BLOCKED_MARKERS did not leak into that path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 10:57:46 +08:00
QiuSWandClaude Opus 5 fc35d144c8 fix(android): submit target = bottom-most clickable node in spec panel (#335)
Task 570 (goods 8580) timed out because the order button's text kept
changing (促销价文案 instead of "提交订单"), so FINAL_SUBMIT_MARKERS never
matched and hasFinalSavedAddressEvidence stayed false forever. Per the
user's confirmed strategy, finalSubmitTargets now ignores button text and
instead picks the bottom-most clickable/enabled/non-zero-size node of an
already-recognized spec panel (reusing #331's PddScreenParser recognition,
including the REQUIRED_EVIDENCE fallback). Ties on the bottom edge pick the
rightmost node and are flagged in diagnostics. A node whose own label or
subtree contains a payment word (PAYMENT_MARKERS) is never a click target.
A candidate taller than 30% of screen height is excluded so a full-sheet/
full-body wrapper container can never win the tie against the real bottom
bar. The old FINAL_SUBMIT_MARKERS text match is kept only as non-required
diagnostic evidence. hasFinalSavedAddressEvidence, finalConfirmation and
submitOrderOnce all consume the same finalSubmitTargets, so the address-save
evidence check and the final click use one consistent rule.

Tests: PurchaseLiveAutomationTest's LiveDriver confirmation-page fixture now
carries a full #331 REQUIRED_EVIDENCE structure (address/payment rows nested
under the shared panel container) since finalSubmitTargets depends on panel
recognition; one assertion that encoded the old bare-tap address activation
is updated to reflect the now-wrapped clickable row. SpecPanelRecognitionTest
replaces the old text-matching submit-target test with cases for the 570
promo-price bottom row, legacy "提交订单" panels, side-by-side tie/rightmost,
payment-word blocking (visible and invisible-subtree), zero-size exclusion,
and unrecognized pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 10:57:44 +08:00
QiuSWandClaude Opus 5 47f6377095 chore(android): bump agent to 0.9.61 for #334
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 10:57:42 +08:00
QiuSWandClaude Opus 5 faad596d32 fix(collection): narrow spec-panel collapse detection, surface explicit probe failures (#334)
Review follow-up on 56f24e1:

1. moveSpecPanelToTop's collapse detection was too sensitive: a normal
   scroll-to-top on a multi-dimension panel can legitimately drop the
   heading count (a lower heading scrolls out of view) without losing
   any spec values. SPEC_PANEL_TOP_COLLAPSED is now only raised when
   every collected spec dimension value vanishes (count goes from >0
   to 0) or the spec panel is no longer recognized as open
   (!specPanelOpen or specPanelType == UNKNOWN). Heading count alone
   no longer triggers it.

2. A collector failure during the purchase spec probe (e.g.
   SPEC_PANEL_TOP_COLLAPSED) previously vanished into
   collectPurchaseProbe returning null, so probeOutcome() reported
   the generic PURCHASE_SPEC_NOT_MATCHED "商品规格探测失败" — the same
   as an ordinary spec mismatch. collectPurchaseProbe now encodes a
   failed collect() result (code + message) into the opaque probe
   JSON via PurchaseSpecProbePolicy.encodeCollectorFailure, and
   PurchaseSpecProbePolicy.demote (replacing demoteIfEmpty, kept as a
   deprecated alias) surfaces it as an explicit
   PURCHASE_SPEC_PROBE_FAILED failure with a purchaser-readable
   message ("规格探测时规格面板被拖动,规格标题消失" for
   SPEC_PANEL_TOP_COLLAPSED, a generic message naming the code
   otherwise). This keeps PurchaseRehearsalExecutor's probeSpecs
   callback contract opaque, so its existing unit tests are untouched.

Tests: PddProductDetailCollectorTest (heading count dropping while
dimensions remain present must not fail collection, still collects
colors/sizes), PurchaseSpecProbePolicyTest (encode/extract collector
failure, demote surfaces SPEC_PANEL_TOP_COLLAPSED and unrecognized
codes explicitly).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 10:57:40 +08:00
QiuSWandClaude Opus 5 4f02e4dcfa fix(collection): skip needless spec-panel top swipes and fail explicit empty probes (#334)
PddProductDetailCollector.moveSpecPanelToTop always swiped DOWN at
least once even when the panel already showed its topmost color
heading, and required two identical viewport signatures to stop. On
a real device that extra swipe could drag the bottom sheet and make
the color/size headings disappear, after which the purchase spec
probe silently reported spec_probe_completed with zero dimensions
and the server reported the generic PURCHASE_SPEC_NOT_MATCHED,
hiding the real cause (goods 8580, tasks 551/552).

- moveSpecPanelToTop now skips the restore swipe when the panel is
  already at top (the first parsed dimension is "color" with visible
  values), and stops and fails explicitly (SPEC_PANEL_TOP_COLLAPSED)
  if a restore swipe makes headings/dimensions vanish, instead of
  swiping further or returning an empty success.
- New AgentDiagnosticReason.SPEC_PANEL_TOP_ALREADY /
  SPEC_PANEL_TOP_COLLAPSED record swipe count and heading/dimension
  counts before/after (booleans/counts only, no page text).
- New PurchaseSpecProbePolicy demotes an Agent spec_probe_completed
  outcome with zero collected dimensions into an explicit
  PURCHASE_SPEC_PROBE_EMPTY failure ("规格探测未读取到任何颜色或尺码")
  before it is persisted/reported, instead of reaching the server as
  a normal empty probe.
- Server resolveProbedSpecs uses the same explicit
  PURCHASE_SPEC_PROBE_EMPTY code/message when a probe result has zero
  colors and zero sizes, as defense in depth for older Agent builds.

Tests: PddProductDetailCollectorTest (already-at-top skips the
restore swipe; not-at-top restores and still collects; vanishing
headings stop swiping and fail), PurchaseSpecProbePolicyTest, and
service_test.go TestLiveProbeWithNoDimensionsFailsWithExplicitEmptyProbeCode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 10:57:38 +08:00
QiuSWandClaude Opus 5 66301e89ce docs(rules): extend OCR exception to yeeke login and ignore HAR/device dumps (#336)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-23 09:50:30 +08:00
QiuSWandClaude Opus 5 433a254239 docs: sync wiki mirrors for #331/#332
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-22 10:58:38 +08:00
QiuSWandClaude Opus 5 36810f3a71 chore(android): bump agent to 0.9.60 for #331/#332
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-22 10:51:05 +08:00
QiuSWandClaude Opus 5 3bf428acd7 fix(server): read purchaser identity from JWT claims for owned devices (#333)
go-admin's Authorizator runs per request with the IdentityHandler map,
which carries no user entry, so c.Get("userId") was always 0 and every
purchaser got an empty device list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-22 10:48:11 +08:00
QiuSWandClaude Opus 5 d8382e6518 fix(android): recognize spec panel by address, payment and quantity input (#331)
- PddScreenParser: add REQUIRED_EVIDENCE fallback type after all existing
  branches; requires address entry (masked phone in a nearby clickable row),
  payment entry (specPanel.paymentAreaAliases in a nearby clickable row) and
  one enabled quantity EditText, plus at least one auxiliary signal
  (options, summary, close, +/- or submit action). +/- no longer required
  on this path; existing branches unchanged.
- The three required items must share one panel container: their lowest
  common ancestor must not be a window root and must not cover the whole
  screen. Items from an address dialog, a payment dialog and an unrelated
  input box are not recognized.
- Purchaser hints on SPEC_PANEL_EVIDENCE_NOT_MATCHED, the spec-panel wait
  diagnostic and the address-save timeout when a required item is missing
  (default WeChat Pay / default address / quantity input). No Alipay alias.
- Purchase spec-entry wait and post address-save wait: bounded 5000 ms,
  fallback panels need two identical structure samples, no Back while
  waiting, explicit failure on timeout; boolean/count diagnostics only.
- Final submit target additionally rejects zero-size labels/containers.
- Diagnostics: evidence flags (incl. same-container) in panel evidence and
  spec-panel entry event.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-22 10:14:03 +08:00
QiuSWandClaude Opus 5 0edc78a943 fix(android): dedupe spec option nodes and diagnose selection mismatch (#332)
- PddScreenParser: nested clickable nodes of one labelled option block
  (outer block, image, inner text with a badge such as "零差评") are
  collapsed into one spec value named by the outer block (task 535). The
  click node is still chosen by safeOptionRank; selected/checked flags are
  merged from the block members; no badge-text stripping.
- PurchaseRehearsalExecutor: on SPEC_SELECTION_UNCONFIRMED emit a
  specSelectionUnconfirmed diagnostic with the dimension, the target value
  and the parsed option texts (each truncated to 40 chars) with selected
  flags. Spec values are product attributes only.
- Tests: SpecOptionDedupTest with a sanitized task 535 fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-22 10:08:25 +08:00
QiuSWandClaude Opus 5 5e0a9d108c docs: sync wiki mirrors after restore and #330 update (#330)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-21 16:16:57 +08:00
QiuSWandClaude Opus 5 08b7095cf1 fix(purchase): widen SYB writeback backoff, cover CheckSession, improve message (#330 review)
Address review findings on 01510a8:

1. BLOCKER: sessionRetryBackoff summed to 30min, shorter than the up-to-
   ~60min gap between a session dying and the next hourly SYB sync
   refreshing it. Changed to 5m/10m/15m/30m/30m (total 90min across
   maxSessionRetryAttempts=6), updated the code comment to state the
   ~90min > one hourly sync period rationale, and added
   TestSessionRetryBackoffTotalExceedsHourlySyncWindow to guard it.

2. Test gap: the CheckSession probe added inside
   restoreOrderWritebackClient was only exercised through a fake
   Factory, never through a real sybclient.Client. Added
   httptest-backed tests that run restoreOrderWritebackClient against
   an emulated /am/user/get (matching the envelope shape in
   sybclient/client.go's `envelope` type): valid session returns a
   client, mismatched username maps to ErrSessionInvalid, 5xx/timeout
   map to a non-invalid error — each asserting the syb_session row is
   left untouched. Added an end-to-end worker test using the real
   Factory against the invalid-session server, asserting
   failed/SYB_SESSION_UNAVAILABLE with a scheduled backoff and an
   intact session row.

3. sessionUnavailableMessage: renamed the default category to
   "会话恢复失败(网络/其他)" and wrapped every category in an
   actionable template ("SYB会话不可用(<类别>),将自动重试;如持续
   失败请恢复登录后重试"), still well under the 300-char column limit
   and free of raw error text/credentials.

Tests: go vet ./app/goauto/purchase/... (clean); go test
./app/goauto/purchase/... (ok, 3.4s, includes the new httptest-backed
CheckSession coverage and the backoff-window guard).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-21 16:09:34 +08:00
QiuSWandClaude Opus 5 01510a85dc fix(purchase): bounded auto-retry for SYB writeback session failures (#330)
SYB order-number writeback silently gave up on session-class failures
(SYB_SESSION_UNAVAILABLE), requiring manual resubmit even though the
hourly sync job refreshes the session on its own. This adds a bounded,
backoff-scheduled auto-retry for that error code only:

- restoreOrderWritebackClient now actively probes the cached cookie
  jar with sybclient.CheckSession after import, so a remotely-expired
  session is classified as retryable up front instead of surfacing
  later as SYB_READ_FAILED. It never logs in, never triggers OCR and
  never deletes the cached session.
- The dropped Factory error is now categorized into a safe message
  (no cookies/tokens) and recorded in error_message.
- The worker's claim query additionally picks up failed rows with
  error_code=SYB_SESSION_UNAVAILABLE once their backoff
  (lease_expires_at) has elapsed and attempt_count is below
  maxSessionRetryAttempts=6 (1m/2m/4m/8m/15m growing backoff, chosen
  to span the hourly sync window); other failure codes are unchanged.
- CanSubmit no longer hides manual resubmit during that backoff
  window; manual resubmit resets attempt_count to 0 and clears the
  lease so the worker cannot double-claim the same row.

Diff is limited to the purchase package; sybimport/sybclient/
sybinnercode are untouched.

Tests: go test ./app/goauto/purchase/... (new
order_writeback_session_retry_test.go covers backoff scheduling,
reclaim timing, max-attempt cutoff, CheckSession invalid/network
classification with no session deletion, CanSubmit during backoff,
manual resubmit reset, and non-session codes being excluded).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-21 16:05:26 +08:00
QiuSW 4261a542ca fix(#328): filter manual PDD association by owned devices 2026-09-21 11:08:41 +08:00
QiuSW beec630187 fix(#329): record device ownership migration 2026-09-21 10:38:54 +08:00
QiuSW 2491a857f7 docs: record purchaser device ownership (#328) 2026-09-21 10:26:33 +08:00
QiuSW 0661b2205f fix(#328): enforce purchaser device ownership 2026-09-21 10:13:23 +08:00
QiuSW bb1a410e8a feat(#329): add device purchaser ownership 2026-09-21 09:59:00 +08:00
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
QiuSW a16e6920b7 docs(#277): record sanitized pdd home evidence 2026-09-15 10:07:27 +08:00
QiuSW def0a86f63 fix(#277): fail image search tasks explicitly until automation is available 2026-09-15 09:59:56 +08:00
QiuSW ba7d2a6b72 feat(#280): remember image search device 2026-09-15 09:57:17 +08:00
QiuSW a3cf98940a fix(#279): only auto-link active pdd products 2026-09-15 09:51:39 +08:00
QiuSW 56706a0b45 fix(#280): show image search association marker 2026-09-15 09:45:50 +08:00
QiuSW a4874091a8 feat: prepare validated image search media asset (#277) 2026-09-15 09:42:25 +08:00
QiuSW 9e0dcc254e feat: parse image search task metadata in agent (#277) 2026-09-15 09:40:51 +08:00
QiuSW cda1978bc6 test: prevent image snapshot payload leakage (#278) 2026-09-15 09:37:01 +08:00
QiuSW c80d0c91d5 feat: add image search batch dialog to syb products (#280) 2026-09-15 09:07:45 +08:00
QiuSW be491c7692 feat: expose image search batch API to web (#280) 2026-09-15 09:02:08 +08:00
QiuSW 90c1668fe3 test: cover image search auto-link guards (#279) 2026-09-15 09:01:10 +08:00
QiuSW 804448ede0 feat: show image search link marker in shopee detail (#279) 2026-09-15 08:59:37 +08:00
QiuSW 5a7e219230 feat: add image-search linked filter to shopee list (#279) 2026-09-15 08:58:35 +08:00
QiuSW 36d1f82ce2 feat: filter image-search linked shopee products (#279) 2026-09-15 08:53:37 +08:00
QiuSW 1c998911be feat: auto link completed image search results (#279) 2026-09-15 08:50:19 +08:00
QiuSW 45437a9214 feat: record cross-currency image search price guard (#278) 2026-09-15 08:44:39 +08:00
QiuSW 42b5312198 feat: support image search identity handoff and safe payload (#278) 2026-09-15 08:42:11 +08:00
QiuSW 7b540f3163 feat: create and dispatch image search tasks (#278) 2026-09-15 08:41:42 +08:00
QiuSW ffa5fc0710 fix: add idempotent image snapshot column migration (#278) 2026-09-14 18:02:22 +08:00
QiuSW 66ab36c88c feat: expose image search snapshot in task payload (#278) 2026-09-14 16:49:35 +08:00
QiuSW 35b69fc25a fix: narrow image search migration (#278) 2026-09-14 16:31:07 +08:00
QiuSW b4dc7fc9ae feat: track image search links and preserve manual overrides (#279) 2026-09-14 16:08:55 +08:00
QiuSW baa9408a36 feat: allow image search collection task source (#278) 2026-09-14 16:05:01 +08:00
QiuSW e01fcd9860 feat: request media permission for image search (#276) 2026-09-14 16:03:21 +08:00
QiuSW be03972779 merge: #283 purchase readiness consistency 2026-09-14 14:48:13 +08:00
QiuSW c137f5072f fix: keep purchase readiness consistent with confirmed mappings (#283) 2026-09-14 14:42:11 +08:00
QiuSW 82b4ebd406 feat(ops): add batch SYB failed reparse script (#282) 2026-09-14 11:34:36 +08:00
QiuSW b83f20c6b7 fix(syb): accept descriptive color specs (#274) 2026-09-14 10:52:02 +08:00
QiuSW e0ed05d15a fix(syb): accept reliable single spec dimension (#274) 2026-09-12 12:01:15 +08:00
QiuSW 8163dd10eb fix(ai): match size by standard token (#273) 2026-09-12 11:46:46 +08:00
QiuSW aed295c8c1 fix(syb): reduce parse status to success or failed (#272) 2026-09-12 11:36:43 +08:00
QiuSWandClaude Opus 5 c14f5f528b fix(server): keep agent diagnostics on order-check failures #271
normalizeOrderUnknownFailure replaced the agent's message with a canonical
sentence, so the diagnostic #210 added never reached the database. Across the
61 PURCHASE_ORDER_PAYMENT_REPEATED failures on production between 2026-09-07
and 09-11, not one carried paymentBackAttempts or
consecutivePaymentSamplesAfterBack, which left three very different causes
indistinguishable: the Back never left the payment activity, the page rendered
slower than the ~1.1s window the agent allows, or the page was already the
order page and no marker matched. Those need different fixes, and there was no
way to tell which one to make.

The canonical sentence stays authoritative and leads the message — agent text
is free-form and the wording per error code has to stay stable — but the agent
evidence is now appended behind it, flattened to one line (control characters
and U+FEFF removed, whitespace collapsed) and truncated to the column's 1000
runes with the canonical part kept whole. The sibling "failed" branch already
stored agent text verbatim, so this adds no new trust assumption.

That branch also only wrote the failure onto the task, never the attempt, so
every attempt it produced carried a NULL error_code and was invisible to
attempt-level statistics — the spec-panel failures on production are exactly
that. It now writes both.

This restores observability only; it does not reduce how often
PAYMENT_REPEATED happens. Tuning the agent's sampling window is deliberately
left out until the next production failure shows real values.

Wiki updated first: Android-Agent-API-Contract@ba259178.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-12 11:13:02 +08:00
QiuSW 8293b27c19 fix(syb): retry drifting sync page once (#270) 2026-09-12 09:34:44 +08:00
QiuSWandClaude Opus 5 c24e8a4439 docs: refresh stale wiki mirrors picked up by the same sync run
Unrelated to #269. These three pages were changed online by other work and
the local mirror had drifted (02 and 03 from 2026-09-10, 06 from 2026-09-04).
The sync that mirrored the #269 pages regenerates every mapping, so it brought
these along; they are committed separately to keep the ticket's own commit
limited to its files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 17:15:38 +08:00
QiuSWandClaude Opus 5 40a254dc41 docs: mirror SYB product filter rules and admin API #269
Wiki updated online first, then mirrored:
- SYB-ERP-Interface-Contract@f1792b7f — filter order after shop admission,
  OR-not-AND with the 8498/6392/6386/8504 figures from the 2026-09-11
  production check, empty variationSku kept, per-rule hit counts, and the
  no-add/no-delete rule for structural criteria.
- Android-Agent-API-Contract@38869285 — the syb-product-filters endpoints,
  the server-side refusal of char add/delete, and the live lastHitCount
  requirement for the disable confirmation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 17:15:38 +08:00
QiuSWandClaude Opus 5 503b3cbbc5 fix(goauto): create syb_product_filter on existing databases #269
Three defects that only a real MySQL run could show.

The table had no version-local migration. Adding the model to
migrations.Migrate covers databases built from scratch, but every existing
one already has the older versions recorded in sys_migration, so that list
never runs again there. cmd/migrate's post-migration table check caught it:
"迁移后仍缺少表:syb_product_filter". The new version file creates the table
and seeds the same eight rules with FirstOrCreate on the normalized key, so
running both paths against one database cannot duplicate them.

Keyword and NormalizedKeyword were typed text, which MySQL refuses to put in
a key specification without a prefix length (Error 1170), so the unique index
could not be built at all. They are sized 200 now, matching how SYBShop sizes
its own matching key. SQLite accepts text in an index, which is why the whole
test suite passed while the real migration failed.

The 只看启用 filter is removed rather than kept: hiding a disabled structural
rule would conceal the one state on this page that matters most, since a
disabled "#" means roughly 65% of 档口 rows start entering the system again.
Keyword search stays.

Verified against the local MySQL database: migration applies, eight rules
present with traditional Chinese preserved (印花大學T normalizes to 印花大學t),
menu row created under 采采管理.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 17:00:11 +08:00
QiuSWandClaude Opus 5 cad9f6d185 fix(goauto): add the missing menu migration for SYB product filters #269
Registering a module in access.GoAutoModules() does not make its page
reachable on an existing database. sys_menu rows are written by the
versioned migration that first created them, and that version is already
recorded in sys_migration everywhere — it never runs again. Startup only
reconciles the casbin API matrix, not menus.

So on a fresh database the page appeared and on every real one it did not:
the table, the API and the route were all present with no way to navigate
there. This adds a migration that upserts the entry under 采采管理 beside
「SYB 店铺」 and binds it to both 管理员 and 采购员.

Verified by deleting the row from an already-migrated database and
re-running migrate: 新执行 1 个,跳过 42 个, row restored with both roles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 16:49:56 +08:00
QiuSWandClaude Opus 5 78df997fc3 test(goauto): pin SYB filter behaviour against real variationSku samples #269
The samples are the ones the production database actually holds: 档口 codes
like "新寮-雅伊阁大码女装#301" and "DD#004" must be filtered, while own
codes like "PDD0582", "my238", "1" and "引流款" must not, and an empty
variationSku is a product without a supplier code rather than a 档口 item.

The test also asserts the two structural rules record different hit counts.
That is the property the previous aggregate-per-kind write could not hold,
and the disable-confirmation dialog depends on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 16:37:43 +08:00
QiuSWandClaude Opus 5 20f59b68bd fix(goauto): record SYB filter hits per rule, not per kind #269
UpdateHits wrote one aggregate per kind, so every structural rule stored
the same number. The disable-confirmation dialog quotes that number
("停用「#」后…约 N 条明细会恢复入库"), and on real data "#" matches 8498
rows while "-" matches 6392 — the dialog would have warned with a figure
2100 rows off for one of them.

Match now returns the rule that matched instead of a char/keyword pair,
so the caller can attribute each hit to one rule, and UpdateHits writes
every rule's own count. Rules that matched nothing are written as 0 so a
previous run's number is never left behind.

The management page is reformatted to the repo's usual Vue layout and
gains the search / 只看启用 / 重置 controls the approved prototype shows.
Cancelling the disable confirmation now just reverts the switch instead
of throwing an unhandled rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-11 16:27:13 +08:00
QiuSW 652bf30f5a feat(goauto): add SYB product variation filters (#269) 2026-09-11 16:20:26 +08:00
QiuSW 79929036bb docs: clarify metadata-only partial collection eligibility #259 2026-09-10 16:10:13 +08:00
QiuSW e4677069d7 fix(syb): allow metadata-only partial collection links #259 2026-09-10 16:07:58 +08:00
QiuSW 8cfd32babe docs: record one-click replacement contract and boundaries #258 2026-09-10 15:52:03 +08:00
QiuSW 3181302c16 test: capture stable replacement confirmation states #258 2026-09-10 15:48:17 +08:00
QiuSW 2b01974fe5 feat(syb): confirm one-click replacement from latest collection #258 2026-09-10 15:47:27 +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
277 changed files with 25338 additions and 541 deletions
+4
View File
@@ -31,3 +31,7 @@
/android/**/build/
__pycache__/
*.py[cod]
# Third-party capture and device dumps: contain credentials, tokens and personal data.
*.har
/xml_data/
+1 -1
View File
@@ -30,7 +30,7 @@
- 不保存原始控件树和整屏设备截图;允许保存按规格值节点边界裁剪的商品图片区域,且裁剪结果不得包含账号、地址、订单、支付及其他个人数据。除此之外只保存结构化任务日志、错误码、任务规则快照和采集结果。
- 一台设备同一时刻只执行一个任务;手机离线时当前采集任务失败,默认不重试、不自动换机。
- Android Agent 端:找不到控件、验证码、风控、人机验证或登录失效时明确失败,不使用 OCR/VLM。
- 服务端顺云宝(SYB)登录:允许调用配置的线上 OCR 服务识别登录验证码(见 #48)。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估。此例外只适用于 SYB 登录,不扩大到 Agent 端或任何 PDD 相关流程。
- 服务端顺云宝(SYB)登录与 yeeke(mmt.yeeke.com 对裹)登录:允许调用配置的线上自建 OCR 服务识别登录验证码(SYB 见 #48;yeeke 于 2026-09-23 经用户在 #336 明确批准)。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估;OCR 请求和日志不得记录账号、密码、验证码原文或 token。此例外只适用于上述两个服务端登录流程,不扩大到 Agent 端或任何 PDD 相关流程。
- 规格匹配:Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;人工映射缺失、商品无规格数据或目标规格定位不到时,由服务端 AI 匹配接口决策(见 #46),AI 无结果时明确失败。
- SKU 数据不完整仍须提交并允许在任务详情查看,状态记为 `completed_partial`。
- 规则创建即生效;删除后不能创建新任务,但已有任务继续使用自身规则快照。
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 72
versionName = "0.9.59"
versionCode = 76
versionName = "0.9.63"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+2
View File
@@ -7,6 +7,8 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<queries>
<package android:name="com.xunmeng.pinduoduo" />
@@ -38,6 +38,7 @@ class MainActivity : AppCompatActivity() {
)
setContentView(buildContent())
requestNotificationPermission()
requestMediaPermission()
if (AgentSettingsStore(this).serverUrl().isNotBlank()) AgentForegroundService.start(this)
AgentAppUpdateManager.checkOnceAtStartup(this)
}
@@ -127,6 +128,16 @@ class MainActivity : AppCompatActivity() {
}
}
// 首次启动时仍然弹一次系统授权框(体验最佳位置),但这不是唯一入口:拒绝后
// (包括"不再询问")用户可以在"状态"页的相册权限卡片里重新发起授权或跳转应用详情页,
// 见 cn.ilapage.goauto.agent.ui.MediaPermissionPolicy / AgentStatusFragment。
private fun requestMediaPermission() {
val permission = cn.ilapage.goauto.agent.ui.MediaPermissionPolicy.requiredPermission()
if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(permission), MEDIA_PERMISSION_REQUEST)
}
}
private fun applyScreenPolicy() {
val keepScreenOn = cn.ilapage.goauto.agent.service.AgentStateStore(this).read().keepScreenOn
if (keepScreenOn) {
@@ -145,5 +156,6 @@ class MainActivity : AppCompatActivity() {
private const val TAB_PREFERENCES = "goauto_agent_ui"
private const val LAST_SELECTED_TAB = "last_selected_tab"
private const val STATE_SELECTED_TAB = "selected_tab"
private const val MEDIA_PERMISSION_REQUEST = 101
}
}
@@ -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()
}
}
@@ -90,7 +90,28 @@ data class VisibleSpecValue(
val rawText: String = text,
)
data class VisibleDimension(val key: String, val name: String, val values: List<VisibleSpecValue>)
enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION, ORDER_CONFIRMATION }
enum class SpecPanelType {
UNKNOWN,
NORMAL_SCROLLABLE,
NON_SCROLLABLE_CONFIRMATION,
QUICK_CONFIRMATION,
ORDER_CONFIRMATION,
/**
* #331 fallback: only reached when no stronger panel type matched. It
* requires an address entry, a payment-method entry and one quantity input
* box, plus at least one auxiliary signal. It is recognition only and never
* relaxes any click target (the final order-submit click stays strict).
*/
REQUIRED_EVIDENCE,
}
/** #331 bounded wait for a spec panel that is still loading after an entry click or an address save. */
object SpecPanelStabilityPolicy {
/** Upper bound for waiting until the required evidence is present and the structure is stable. */
const val WAIT_BOUND_MS = 5_000L
/** Sampling interval used after an address save returns to the purchase panel. */
const val ADDRESS_SAVE_POLL_MS = 200L
}
object PddSoldOutRecoveryDefaults {
const val EXACT_TEXT = "商品已售罄"
@@ -134,7 +155,54 @@ data class ParsedPddScreen(
val hasCloseControl: Boolean = false,
val hasPaymentArea: Boolean = false,
val purchaseContextMatched: Boolean = false,
// #331 evidence flags. Presence only: address text is never kept here.
val hasAddressEntry: Boolean = false,
val hasPaymentEntry: Boolean = false,
val hasQuantityInput: Boolean = false,
val hasQuantityAdjustControls: Boolean = false,
val quantityInputCount: Int = 0,
/** #331: address entry, payment entry and quantity input share one bounded panel container. */
val requiredEvidenceSameContainer: Boolean = false,
) {
/** The three #331 required spec-panel items are all visible. */
val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput
/** Boolean/count-only evidence string for diagnostics; contains no page text. */
fun requiredEvidenceSummary(): String =
"addr=${hasAddressEntry.flag()};pay=${hasPaymentEntry.flag()};qin=$quantityInputCount;" +
"adj=${hasQuantityAdjustControls.flag()};summary=${hasSelectionSummary.flag()};" +
"close=${hasCloseControl.flag()};submit=${hasOrderSubmitAction.flag()};same=${requiredEvidenceSameContainer.flag()}"
/**
* #331 purchaser-readable hints for missing required items. Only emitted
* when the page shows at least one required item or parsed options, so an
* unrelated page does not produce misleading environment hints. No page
* text or personal data is included.
*/
fun requiredEvidenceHints(): List<String> {
val partial = hasAddressEntry || hasPaymentEntry || hasQuantityInput || panelOptionCount > 0
if (!partial) return emptyList()
return buildList {
if (!hasPaymentEntry) add(MISSING_PAYMENT_ENTRY_HINT)
if (!hasAddressEntry) add(MISSING_ADDRESS_ENTRY_HINT)
if (!hasQuantityInput) add(MISSING_QUANTITY_INPUT_HINT)
if (hasRequiredPanelEvidence && !requiredEvidenceSameContainer) add(NOT_SAME_CONTAINER_HINT)
}
}
/** Hints joined for a failure message with a leading separator; empty when there is nothing to hint. */
fun requiredEvidenceHintSuffix(): String =
requiredEvidenceHints().takeIf { it.isNotEmpty() }?.joinToString(";", prefix = ";") ?: ""
private fun Boolean.flag(): Int = if (this) 1 else 0
companion object {
const val MISSING_PAYMENT_ENTRY_HINT = "未找到支付入口:请确认 PDD 默认支付方式为微信支付"
const val MISSING_ADDRESS_ENTRY_HINT = "未找到收货地址入口:请确认 PDD 已设置默认收货地址"
const val MISSING_QUANTITY_INPUT_HINT = "未找到购买数量输入框"
const val NOT_SAME_CONTAINER_HINT = "收货地址入口、支付入口和购买数量输入框不在同一规格面板内"
}
fun isTransientSoldOut(
exactText: String,
fallbackTopText: String = "相似商品",
@@ -186,6 +254,13 @@ object PddScreenParser {
// purchase/order/payment controls must never become collection click targets.
private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款")
/** Masked CN mobile number shown in the purchase address row, e.g. 138****5678. Presence only. */
val MASKED_PHONE_PATTERN = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
/** An address/payment entry must be clickable itself or through a nearby row container. */
private const val ENTRY_CLICKABLE_ANCESTOR_MAX_DEPTH = 6
/** Whole-sheet containers are clickable too; a real entry row stays well below this share of the screen height. */
private const val ENTRY_ROW_MAX_HEIGHT_PERCENT = 30
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null): ParsedPddScreen {
val visibleNodes = snapshot.nodes.filter { it.visible }
val visible = visibleNodes.mapNotNull { node ->
@@ -203,6 +278,8 @@ object PddScreenParser {
}
}
val labels = visible.map(SnapshotNode::label)
val visibleByPath = visible.associateBy(SnapshotNode::path)
val sourceByPath = visibleNodes.associateBy(SnapshotNode::path)
val textAliases = config.textAliases
val problem = PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels)
val compactLabels = labels.map { it.replace(" ", "") }
@@ -240,7 +317,23 @@ object PddScreenParser {
val decreaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityDecreaseAliases }
val increaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityIncreaseAliases }
val hasQuantityControls = quantityInputs.size == 1 && decreaseControls.size == 1 && increaseControls.size == 1
// #331: the unique input box is required; the +/- buttons are only auxiliary.
val hasQuantityInput = quantityInputs.size == 1 && quantityInputs.single().enabled
val hasQuantityAdjustControls = decreaseControls.size == 1 && increaseControls.size == 1
val hasPaymentArea = compactLabels.any { label -> textAliases.specPanel.paymentAreaAliases.any(label::contains) }
val addressEntryRows = addressEntryRows(visibleNodes, screenHeight)
val paymentEntryRows = paymentEntryRows(visibleNodes, screenHeight, textAliases.specPanel.paymentAreaAliases)
val hasAddressEntry = addressEntryRows.isNotEmpty()
val hasPaymentEntry = paymentEntryRows.isNotEmpty()
// #331: the three required items must sit in one panel container, not
// merely somewhere on screen (e.g. an address dialog, a payment dialog
// and an unrelated input box). Same area bound idea as boundedScrollables.
val requiredEvidenceSameContainer = hasAddressEntry && hasPaymentEntry && hasQuantityInput &&
addressEntryRows.any { address ->
paymentEntryRows.any { payment ->
sharesBoundedPanelContainer(listOf(address, payment, quantityInputs.single()), sourceByPath, screenArea)
}
}
val hasQuickBuy = visible.any { node ->
node.label.replace(" ", "").let { compact -> textAliases.specPanel.quickBuyAliases.any(compact::contains) } &&
node.bounds.centerY.toDouble() >= screenHeight * 0.75
@@ -276,27 +369,50 @@ object PddScreenParser {
headings.forEachIndexed { index, heading ->
val lower = headings.getOrNull(index + 1)?.bounds?.top ?: Int.MAX_VALUE
val dimensionKey = dimensionKey(heading.label, config, any { it.key == "color" })
val values = panelVisible.asSequence()
.filter { it.clickable && it.bounds.top >= heading.bounds.bottom && it.bounds.bottom <= lower }
.filter { it.bounds.width > 0 && it.bounds.height > 0 && it.label.length <= 80 }
fun inValueRegion(node: SnapshotNode) =
node.bounds.top >= heading.bounds.bottom && node.bounds.bottom <= lower &&
node.bounds.width > 0 && node.bounds.height > 0 && node.label.length <= 80
val candidates = panelVisible.asSequence()
.filter { it.clickable && inValueRegion(it) }
.filterNot { isExactHeadingLabel(it.label, config) }
.filterNot { node ->
isExcludedOptionLabel(node.label) ||
descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.label) }
}
.map {
val rawText = it.label
.toList()
// #332/task 535: one option block may expose several clickable
// nodes (outer block, image, inner text with a badge such as
// "零差评"). Nodes nested in the same labelled option block are
// collapsed into one value named by the outer block. The click
// node is still chosen by safeOptionRank among the members.
val values = candidates
.groupBy { candidate ->
optionBlock(candidate, candidates, visibleByPath, sourceByPath, ::inValueRegion)?.path ?: candidate.path
}
.map { (blockPath, members) ->
val block = visibleByPath[blockPath]
val chosen = members.minBy { option -> safeOptionRank(option, visibleNodes) }
val selected = members.any(SnapshotNode::selected) || block?.selected == true
val checked = members.any(SnapshotNode::checked) || block?.checked == true
Triple(chosen.copy(selected = selected, checked = checked), block?.label ?: chosen.label, members)
}
.asSequence()
.map { (option, blockLabel, members) ->
val rawText = blockLabel
val normalizedText = when (dimensionKey) {
"color" -> SpecValueNormalizer.normalizeColor(rawText)
"size" -> SpecValueNormalizer.normalizeSize(rawText)
else -> rawText
}
val stateText = (listOf(it.label) + descendants(it, visibleNodes).map(SnapshotNode::label)).joinToString(" ")
val stateText = (listOf(option.label) + descendants(option, visibleNodes).map(SnapshotNode::label)).joinToString(" ")
VisibleSpecValue(
normalizedText,
it.enabled && !stateText.containsUnavailableWord(),
it,
if (dimensionKey == "color") colorImageBoundsForOption(it, visibleNodes) else null,
option.enabled && !stateText.containsUnavailableWord(),
option,
if (dimensionKey == "color") {
(listOf(option) + members.filter { it.path != option.path })
.firstNotNullOfOrNull { colorImageBoundsForOption(it, visibleNodes) }
} else null,
rawText,
)
}
@@ -339,6 +455,14 @@ object PddScreenParser {
panelScrollable == null && headings.size >= 2 && dimensions.size >= 2 &&
dimensions.sumOf { it.values.size } >= 2 &&
hasQuantityControls && hasOrderSubmitAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
// #331 fallback, evaluated only after every stronger branch failed.
// Required: address entry + payment entry + one enabled quantity
// input. Auxiliary (at least one): grouped options, selection
// summary, close control, +/- controls or the lower submit action.
requiredEvidenceSameContainer && (
dimensions.any { it.values.isNotEmpty() } || hasSelectionSummary || hasClose ||
hasQuantityAdjustControls || hasOrderSubmitAction
) -> SpecPanelType.REQUIRED_EVIDENCE
else -> SpecPanelType.UNKNOWN
}
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
@@ -439,6 +563,12 @@ object PddScreenParser {
purchaseContextMatched = continuedPurchasePanel,
hasQuantityControls = hasQuantityControls,
hasOrderSubmitAction = hasOrderSubmitAction,
hasAddressEntry = hasAddressEntry,
hasPaymentEntry = hasPaymentEntry,
hasQuantityInput = hasQuantityInput,
hasQuantityAdjustControls = hasQuantityAdjustControls,
quantityInputCount = quantityInputs.size,
requiredEvidenceSameContainer = requiredEvidenceSameContainer,
explicitSpecEntryCount = explicitSpecEntries.size,
nestedSpecEntryCount = nestedSpecEntries.size,
bottomPurchaseEntryCount = bottomSpecEntries.size,
@@ -453,6 +583,93 @@ object PddScreenParser {
return compact in excludedExactOptionLabels || excludedOptionWords.any { compact.contains(it) }
}
/**
* Outermost labelled clickable ancestor inside the value region whose label
* prefixes every candidate nested in it. Rows that contain several distinct
* options never qualify because their members do not share its label.
*/
private fun optionBlock(
candidate: SnapshotNode,
candidates: List<SnapshotNode>,
visibleByPath: Map<String, SnapshotNode>,
sourceByPath: Map<String, SnapshotNode>,
inValueRegion: (SnapshotNode) -> Boolean,
): SnapshotNode? {
fun compact(value: String) = value.replace(Regex("\\s+"), "")
var block: SnapshotNode? = null
var parentPath = candidate.parentPath
while (parentPath != null) {
val raw = sourceByPath[parentPath] ?: break
val resolved = visibleByPath[parentPath]
// Only a block with its own label names the option; a blank row
// container resolved from its first child never merges options.
if (raw.clickable && raw.label.isNotBlank() && resolved != null && inValueRegion(resolved)) {
val outer = compact(resolved.label)
val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") }
if (outer.isEmpty() || members.any { !compact(it.label).startsWith(outer) }) break
block = resolved
}
parentPath = raw.parentPath
}
return block
}
/** The nearby clickable row holding [node], or null when there is none or it is not a plausible entry row. */
private fun clickableEntryRow(node: SnapshotNode, sourceByPath: Map<String, SnapshotNode>, screenHeight: Int): SnapshotNode? {
var current: SnapshotNode? = node
var depth = 0
while (current != null && depth <= ENTRY_CLICKABLE_ANCESTOR_MAX_DEPTH) {
if (current.clickable) {
val plausible = current.visible && current.enabled && current.bounds.width > 0 && current.bounds.height > 0 &&
(screenHeight <= 0 || current.bounds.height * 100 <= screenHeight * ENTRY_ROW_MAX_HEIGHT_PERCENT)
return current.takeIf { plausible }
}
current = current.parentPath?.let(sourceByPath::get)
depth++
}
return null
}
/** Address rows: a masked phone inside a nearby clickable row. The address text is never read out. */
private fun addressEntryRows(source: List<SnapshotNode>, screenHeight: Int): List<SnapshotNode> {
val byPath = source.associateBy(SnapshotNode::path)
return source.filter { node ->
node.visible && node.bounds.width > 0 && node.bounds.height > 0 && MASKED_PHONE_PATTERN.containsMatchIn(node.label)
}.mapNotNull { clickableEntryRow(it, byPath, screenHeight) }.distinctBy(SnapshotNode::path)
}
/** Payment-method rows via the configurable payment aliases inside a nearby clickable row. */
private fun paymentEntryRows(source: List<SnapshotNode>, screenHeight: Int, aliases: List<String>): List<SnapshotNode> {
val byPath = source.associateBy(SnapshotNode::path)
return source.filter { node ->
node.visible && node.bounds.width > 0 && node.bounds.height > 0 &&
node.label.replace(Regex("\\s+"), "").let { compact -> aliases.any(compact::contains) }
}.mapNotNull { clickableEntryRow(it, byPath, screenHeight) }.distinctBy(SnapshotNode::path)
}
/**
* #331: true when the lowest common ancestor of [nodes] is a real panel
* container: it is not a window root (a node without parent) and, like
* boundedScrollables, it does not cover the whole screen. Items taken from
* separate dialogs/pages only meet at the root or a full-screen wrapper.
*/
private fun sharesBoundedPanelContainer(nodes: List<SnapshotNode>, sourceByPath: Map<String, SnapshotNode>, screenArea: Long): Boolean {
fun chain(node: SnapshotNode): List<SnapshotNode> {
val result = mutableListOf(node)
var current = node.parentPath?.let(sourceByPath::get)
while (current != null) {
result += current
current = current.parentPath?.let(sourceByPath::get)
}
return result
}
val others = nodes.drop(1).map { node -> chain(node).map(SnapshotNode::path).toSet() }
val common = chain(nodes.first()).firstOrNull { candidate -> others.all { candidate.path in it } } ?: return false
val parent = common.parentPath?.let(sourceByPath::get) ?: return false
if (parent.path == common.path || common.bounds.width <= 0 || common.bounds.height <= 0) return false
return screenArea == 0L || common.bounds.width.toLong() * common.bounds.height < screenArea
}
private fun orderSubmitCandidatesAncestorFree(candidate: SnapshotNode, source: List<SnapshotNode>, aliases: List<String>): Boolean {
val candidatePrefix = "${candidate.path}/"
return source.none { node ->
@@ -1385,6 +1602,41 @@ class PddProductDetailCollector(
.count()
}
/**
* #334: the panel already shows its topmost color heading (the first
* parsed dimension is "color") with values visible, so no restore swipe
* is required. Swiping an already-top panel drags the bottom sheet
* further and can make the color/size headings vanish.
*/
private fun isSpecPanelAtTop(screen: ParsedPddScreen): Boolean {
val first = screen.dimensions.firstOrNull() ?: return false
return first.key == "color" && first.values.isNotEmpty()
}
private fun recordSpecPanelTopSwipe(
reason: AgentDiagnosticReason,
screen: ParsedPddScreen,
swipeCount: Int,
headingCountBefore: Int?,
headingCountAfter: Int?,
) {
if (taskId <= 0) return
diagnostic(
AgentDiagnosticEvent(
taskId = taskId,
// A dedicated PAGE_STABILITY event on purpose: SPEC_PANEL_ENTRY
// already carries the panel-open/restore diagnostics elsewhere,
// and several tests assert a single SPEC_PANEL_ENTRY event per run.
stage = AgentDiagnosticStage.PAGE_STABILITY,
reason = reason,
attempt = swipeCount,
targetClassName = "type=${screen.specPanelType.name};headBefore=${headingCountBefore ?: -1};" +
"headAfter=${headingCountAfter ?: -1};dim=${screen.dimensions.sumOf { it.values.size }}",
clickableAncestorDepth = screen.panelHeadingCount,
),
)
}
private fun moveSpecPanelToTop(
goodsId: String,
config: PddCollectorConfig,
@@ -1394,17 +1646,40 @@ class PddProductDetailCollector(
): PddCollectorResult? {
var previous: List<String>? = null
var stable = 0
repeat(config.limits.getValue("specVerticalSwipes")) {
var priorHeadingCount = -1
var priorDimensionCount = -1
var swipes = 0
repeat(config.limits.getValue("specVerticalSwipes")) { pass ->
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
val screen = parse(goodsId, config, evidence)
screen.problem?.let { return failure(it.code, it.message) }
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
if (pass == 0 && isSpecPanelAtTop(screen)) {
recordSpecPanelTopSwipe(AgentDiagnosticReason.SPEC_PANEL_TOP_ALREADY, screen, swipes, null, screen.panelHeadingCount)
return null
}
val headingCount = screen.panelHeadingCount
val dimensionCount = screen.dimensions.sumOf { it.values.size }
// A normal scroll-to-top can legitimately drop the panel's
// heading count (lower dimensions scroll out of view) without
// losing any values, so heading count alone must never trigger
// this. Only a full loss of every collected spec value, or the
// panel no longer being recognized as open, indicates the sheet
// was dragged too far.
val panelNoLongerRecognized = !screen.specPanelOpen || screen.specPanelType == SpecPanelType.UNKNOWN
if (pass > 0 && priorDimensionCount > 0 && (dimensionCount == 0 || panelNoLongerRecognized)) {
recordSpecPanelTopSwipe(AgentDiagnosticReason.SPEC_PANEL_TOP_COLLAPSED, screen, swipes, priorHeadingCount, headingCount)
return failure("SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
}
priorHeadingCount = headingCount
priorDimensionCount = dimensionCount
val signature = viewportSignature(screen)
stable = if (previous != null && signature == previous) stable + 1 else 0
previous = signature
if (stable >= config.limits.getValue("stableEdgeReads")) return null
val anchor = screen.dimensions.flatMap { it.values }.firstOrNull()?.node ?: specPanelContainer ?: return null
if (!driver.swipeSpec(SwipeDirection.DOWN, anchor)) return null
swipes++
pause(350)
}
return null
@@ -1572,6 +1847,8 @@ class PddProductDetailCollector(
"type=${it.specPanelType.name};scroll=${it.panelScrollableCount};head=${it.panelHeadingCount};" +
"option=${it.panelOptionCount};summary=${it.hasSelectionSummary.toInt()};" +
"quantity=${it.hasQuantityControls.toInt()};submit=${it.hasOrderSubmitAction.toInt()};" +
"addr=${it.hasAddressEntry.toInt()};pay=${it.hasPaymentEntry.toInt()};qin=${it.quantityInputCount};" +
"adj=${it.hasQuantityAdjustControls.toInt()};close=${it.hasCloseControl.toInt()};" +
"changed=${screenChanged.toInt()};" +
"recovery=${when {
recoverySucceeded -> "success"
@@ -0,0 +1,188 @@
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 {
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 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) {
private val resolver = context.applicationContext.contentResolver
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, "$DISPLAY_NAME_PREFIX${UUID.randomUUID()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
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 {
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
}
}
/**
* 本方案不做图像识别,只保证自己写入的图片是相册中最新的一项,从而在 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)
@@ -32,6 +40,8 @@ class PurchaseLiveException(val code: String, message: String, val actualUnitPri
class PurchaseLiveAutomation(
private val driver: PurchaseUiDriver,
private val pause: (Long) -> Unit = Thread::sleep,
/** Boolean/count-only diagnostics; never receives address text. */
private val panelDiagnostic: (String) -> Unit = {},
) {
private var submitAttempted = false
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
@@ -340,8 +350,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 +364,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 +420,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 +442,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 +474,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 +489,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()
@@ -485,7 +525,7 @@ class PurchaseLiveAutomation(
val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" })
if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单")
click(save.single(), "保存地址")
val savedEvidence = waitForStableAddressEditorExit()
val savedEvidence = waitForStableAddressEditorExit(expected, suffix)
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
if (isPurchaseConfirmationPanel(savedEvidence)) {
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
@@ -502,11 +542,7 @@ class PurchaseLiveAutomation(
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
return screen.specPanelType in setOf(
SpecPanelType.NORMAL_SCROLLABLE,
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
SpecPanelType.ORDER_CONFIRMATION,
)
return screen.specPanelType in PURCHASE_CONFIRMATION_PANEL_TYPES
}
private fun restoreFinalEvidenceInCurrentPanel(
@@ -560,14 +596,14 @@ class PurchaseLiveAutomation(
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).size == 1
private fun waitForStableAddressEditorExit(): UiSnapshot {
private fun waitForStableAddressEditorExit(expected: String, suffix: String): UiSnapshot {
var consecutiveExits = 0
repeat(50) {
val snapshot = driver.capture()
pageProblem(snapshot)
if (snapshot.packageName == PDD_PACKAGE && shippingAddressEditors(snapshot).isEmpty()) {
consecutiveExits++
if (consecutiveExits >= 2) return snapshot
if (consecutiveExits >= 2) return waitForSettledPanelAfterAddressSave(snapshot, expected, suffix)
} else {
consecutiveExits = 0
}
@@ -576,6 +612,68 @@ class PurchaseLiveAutomation(
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存超时,未创建订单")
}
/**
* #331: after the editor closes PDD may still be rebuilding the purchase
* sheet. Accept immediately when the final evidence or a stronger panel type
* is already present. A sheet recognized only through the required-evidence
* fallback must also be structurally stable for two consecutive samples. A
* stable page without any purchase-sheet marker (quantity input or payment
* entry), e.g. the address list, keeps the existing caller path. A partially
* loaded sheet is waited for up to [SpecPanelStabilityPolicy.WAIT_BOUND_MS]
* without pressing Back and then fails explicitly.
*/
private fun waitForSettledPanelAfterAddressSave(initial: UiSnapshot, expected: String, suffix: String): UiSnapshot {
var snapshot = initial
var previousSignature: String? = null
var waitedMs = 0L
var samples = 0
while (true) {
samples++
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
val editorGone = snapshot.packageName == PDD_PACKAGE && shippingAddressEditors(snapshot).isEmpty()
val signature = structureSignature(snapshot)
val stable = editorGone && signature == previousSignature
val outcome = when {
editorGone && hasFinalSavedAddressEvidence(snapshot, expected, suffix) -> "final_evidence"
editorGone && screen.specPanelType in STRONG_PURCHASE_PANEL_TYPES -> "recognized"
stable && screen.specPanelType == SpecPanelType.REQUIRED_EVIDENCE -> "stable_required"
stable && !screen.hasQuantityInput && !screen.hasPaymentEntry -> "non_panel"
else -> null
}
if (outcome != null) {
addressSaveDiagnostic(outcome, waitedMs, samples, screen)
return snapshot
}
if (waitedMs >= SpecPanelStabilityPolicy.WAIT_BOUND_MS) {
addressSaveDiagnostic("timeout", waitedMs, samples, screen)
fail(
"PURCHASE_ADDRESS_SAVE_TIMEOUT",
"地址保存后规格面板未完整加载,未按返回键,未创建订单 [waitedMs=$waitedMs;samples=$samples;${screen.requiredEvidenceSummary()}]${screen.requiredEvidenceHintSuffix()}",
)
}
previousSignature = signature
pause(SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS)
waitedMs += SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS
snapshot = driver.capture()
pageProblem(snapshot)
}
}
private fun addressSaveDiagnostic(outcome: String, waitedMs: Long, samples: Int, screen: ParsedPddScreen) {
panelDiagnostic(
"addressSaveWait;outcome=$outcome;waitedMs=$waitedMs;samples=$samples;backPressed=false;" +
"type=${screen.specPanelType};${screen.requiredEvidenceSummary()}",
)
}
/** In-memory structure only (path, class, bounds, flags); no text is included. */
private fun structureSignature(snapshot: UiSnapshot): String = snapshot.nodes
.filter { it.visible }
.joinToString("|") { node ->
listOf(node.path, node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable, node.enabled)
.joinToString(":")
}
private fun normalizeAddressText(value: String): String = value.filterNot(Char::isWhitespace)
private fun hasExactTaskSuffix(value: String, suffix: String): Boolean =
@@ -678,10 +776,159 @@ class PurchaseLiveAutomation(
pause(500)
}
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
snapshot,
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
)
/**
* #335 (3rd revision, reviewer cross-check on real dumps — Samsung sample): a
* two-step "row, then leaf" pick, not a single whole-panel leaf scan. A whole-panel
* scan can pick a labelled leaf that lives in a DIFFERENT clickable row than the real
* bottom bar — on the Samsung sample the bottom bar is a label-less 31px FrameLayout
* whose only text node is zero-size, so a naive scan falls through to the next
* lowest labelled leaf, which sits in the PAYMENT-METHOD row above it, and the climb
* from there lands the click on "change payment method" instead of the order button.
*
* Step 1 — row: among visible/enabled/non-zero-size CLICKABLE nodes inside the
* recognized panel's container (#331 judgment) with height ≤ [SUBMIT_ROW_MAX_HEIGHT_PERCENT]
* of screen height (same guard as 2bc624f, keeps a full-sheet/full-body wrapper from
* ever being "the row"), pick the one with the lowest bottom edge; ties go to the
* rightmost. This row — and only this row — may hold the order button. If its own
* subtree matches a payment-method alias (`textAliases.specPanel.paymentAreaAliases`,
* e.g. 微信支付/先用后付/支付方式) or [SUBMIT_TARGET_BLOCKED_MARKERS], fail explicitly;
* never fall back to a higher row — a higher row is never the order button either.
*
* Step 2 — leaf: within that row's own subtree (or the row itself), among
* visible/non-zero-size nodes with a NON-BLANK OWN LABEL whose nearest clickable
* ancestor is EXACTLY that row (not some nested sub-button, and not a higher row),
* pick the bottom-right-most one. [PurchaseUiDriver.clickFresh] re-finds its target
* live by `preferredOrDescendantLabel() == target.label && className && center±32`
* and only then climbs to the nearest clickable ancestor and clicks it (see
* `GoAutoAccessibilityService.clickFreshDetailed`); a label-less container's
* [SnapshotNode.label] is `""` but its live `preferredOrDescendantLabel()` digs into a
* child, so the re-find would always miss (task 594: TARGET_NOT_FOUND right after the
* irreversible boundary). Aiming at the labelled leaf — the same convention already
* used by `ImageSearchCandidatePolicy.clickTargetInside` — lets clickFresh's own
* climb-to-ancestor logic land the tap back on the row. If the row has no such leaf
* (Samsung: only a zero-size text node), fail explicitly (`bottom_row_unlabelled`) —
* never fall back to a higher row.
*/
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> {
fun hasArea(node: SnapshotNode) = node.bounds.width > 0 && node.bounds.height > 0
val byPath = snapshot.nodes.associateBy { it.path }
fun clickableAncestor(node: SnapshotNode): SnapshotNode? {
var current: SnapshotNode? = node
while (current != null && !current.clickable) current = current.parentPath?.let(byPath::get)
return current
}
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
if (screen.specPanelType !in PURCHASE_CONFIRMATION_PANEL_TYPES) {
panelDiagnostic("bottomSubmit;outcome=not_panel;type=${screen.specPanelType}")
return emptyList()
}
val container = panelContainerBounds(snapshot)
if (container == null) {
panelDiagnostic("bottomSubmit;outcome=no_container;type=${screen.specPanelType}")
return emptyList()
}
val screenHeight = snapshot.nodes.filter { it.visible }.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
// Step 1: the bottom-most clickable row.
val rowCandidates = snapshot.nodes.filter { node ->
node.visible && node.enabled && node.clickable && hasArea(node) &&
node.bounds.height.toLong() * 100 <= screenHeight.toLong() * SUBMIT_ROW_MAX_HEIGHT_PERCENT &&
inside(node.bounds, container)
}.distinctBy { it.path }
if (rowCandidates.isEmpty()) {
panelDiagnostic("bottomSubmit;outcome=no_rows;type=${screen.specPanelType}")
return emptyList()
}
val rowMaxBottom = rowCandidates.maxOf { it.bounds.bottom }
val rowBottomTied = rowCandidates.filter { it.bounds.bottom == rowMaxBottom }
val rowTie = rowBottomTied.size > 1
val row = rowBottomTied.maxBy { it.bounds.right }
val rowSubtreeLabels = (listOf(row.label) + subtreeDescendants(row, snapshot).map(SnapshotNode::label)).joinToString("")
val paymentAreaAliases = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR.textAliases.specPanel.paymentAreaAliases
if (paymentAreaAliases.any(rowSubtreeLabels::contains) || SUBMIT_TARGET_BLOCKED_MARKERS.any(rowSubtreeLabels::contains)) {
panelDiagnostic(
"bottomSubmit;outcome=row_blocked;tie=${rowTie.diagFlag()};class=${row.className};" +
"w=${row.bounds.width};h=${row.bounds.height}",
)
return emptyList()
}
// Step 2: the bottom-right-most labelled leaf whose nearest clickable ancestor is exactly this row.
val leafCandidates = (listOf(row) + subtreeDescendants(row, snapshot)).filter { node ->
node.visible && node.enabled && hasArea(node) && node.label.isNotEmpty() &&
clickableAncestor(node)?.path == row.path
}.distinctBy { it.path }
if (leafCandidates.isEmpty()) {
panelDiagnostic(
"bottomSubmit;outcome=bottom_row_unlabelled;tie=${rowTie.diagFlag()};class=${row.className};" +
"w=${row.bounds.width};h=${row.bounds.height}",
)
return emptyList()
}
val leafMaxBottom = leafCandidates.maxOf { it.bounds.bottom }
val leafBottomTied = leafCandidates.filter { it.bounds.bottom == leafMaxBottom }
val leafTie = leafBottomTied.size > 1
val chosen = leafBottomTied.maxBy { it.bounds.right }
val hasPrice = PRICE_PRESENCE.containsMatchIn(rowSubtreeLabels)
val legacyMarker = FINAL_SUBMIT_MARKERS.any { rowSubtreeLabels == it || rowSubtreeLabels.startsWith(it) }
panelDiagnostic(
"bottomSubmit;outcome=ok;rowTie=${rowTie.diagFlag()};leafTie=${leafTie.diagFlag()};" +
"leafClass=${chosen.className};rowClass=${row.className};w=${row.bounds.width};h=${row.bounds.height};" +
"price=${hasPrice.diagFlag()};legacyMarker=${legacyMarker.diagFlag()};type=${screen.specPanelType}",
)
return listOf(chosen)
}
private fun subtreeDescendants(node: SnapshotNode, snapshot: UiSnapshot): List<SnapshotNode> {
val prefix = "${node.path}/"
return snapshot.nodes.filter { it.path.startsWith(prefix) }
}
/**
* #335 follow-up: the submit click target must belong to the recognized spec panel,
* not just be somewhere on screen (e.g. an underlying goods-detail-page bottom bar
* that is still in the accessibility tree behind the panel). [ParsedPddScreen
* .specPanelContainer] (PddScreenParser's `panelScrollable`) is not usable for this:
* it is the inner *scrollable dimension list* used only for heading/option parsing,
* and in a real PDD sheet the address/payment/submit rows sit OUTSIDE it as
* structural siblings, not descendants. Instead this climbs from the panel's unique
* quantity input — present for every recognized #331 panel type used here — to the
* LARGEST ancestor that still does not cover the whole screen, i.e. the whole
* bottom-sheet container. That is the same "does not cover the whole screen" bounded
* notion #331 already uses for `sharesBoundedPanelContainer`/`boundedScrollables`,
* just walked from the quantity input instead of recomputed from private internals.
* A candidate then belongs to the panel when its center point falls inside that
* container's bounds (geometric containment, not path prefix, since PDD's own
* accessibility tree can place the submit bar as a structural sibling of the panel
* container that is still visually part of the sheet). Returns null (no target) when
* the quantity input is missing or not unique, so the caller fails explicitly instead
* of guessing a container.
*/
private fun panelContainerBounds(snapshot: UiSnapshot): NodeBounds? {
val byPath = snapshot.nodes.associateBy { it.path }
val visible = snapshot.nodes.filter { it.visible }
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
val screenArea = screenWidth.toLong() * screenHeight
val quantity = snapshot.nodes.singleOrNull { node ->
node.visible && node.enabled && node.className == "android.widget.EditText" &&
node.label.toIntOrNull()?.let { it > 0 } == true
} ?: return null
var lastBounded: SnapshotNode? = null
var current: SnapshotNode = quantity
while (true) {
val parent = current.parentPath?.let(byPath::get) ?: break
val parentArea = parent.bounds.width.toLong() * parent.bounds.height
if (parentArea <= 0 || parentArea >= screenArea) break
lastBounded = parent
current = parent
}
return lastBounded?.bounds
}
private fun Boolean.diagFlag(): Int = if (this) 1 else 0
private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean {
if (snapshot.packageName != PDD_PACKAGE) return false
@@ -714,7 +961,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 {
@@ -744,12 +1002,31 @@ class PurchaseLiveAutomation(
private companion object {
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
const val WECHAT_PACKAGE = "com.tencent.mm"
val MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
val MASKED_PHONE = PddScreenParser.MASKED_PHONE_PATTERN
val STRONG_PURCHASE_PANEL_TYPES = setOf(
SpecPanelType.NORMAL_SCROLLABLE,
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
SpecPanelType.ORDER_CONFIRMATION,
)
val PURCHASE_CONFIRMATION_PANEL_TYPES = STRONG_PURCHASE_PANEL_TYPES + SpecPanelType.REQUIRED_EVIDENCE
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
// #335: presence-only signal for diagnostics; never records the actual price digits.
val PRICE_PRESENCE = Regex("[¥¥][0-9]")
// #335: a bottom-edge candidate taller than this share of screen height is a
// whole-sheet/whole-body wrapper, never the order button itself.
const val SUBMIT_ROW_MAX_HEIGHT_PERCENT = 30
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
// #335: broader than PAYMENT_MARKERS on purpose — this list is used ONLY by
// finalSubmitTargets' bottom-node subtree check, never by pageProblem/order-result
// parsing. "付款" alone must NOT join PAYMENT_MARKERS: UNPAID_MARKERS' "待付款"
// contains "付款" and PAYMENT_MARKERS gates the global pageProblem() payment guard,
// so adding it there would misclassify every ordinary unpaid-order page as a
// forbidden payment page.
val SUBMIT_TARGET_BLOCKED_MARKERS = listOf("立即支付", "确认支付", "去支付", "付款", "输入支付密码")
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 +1040,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(
@@ -97,7 +100,7 @@ class PurchaseRehearsalExecutor(
var addressProof: ShippingAddressProof? = null
var irreversibleStarted = false
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
val live = PurchaseLiveAutomation(driver, pause)
val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic)
for (action in rule.actions) {
// The immediate phase-two handoff can reuse the PDD page retained by
// spec_probe. A later manual retry may start from Agent (or another
@@ -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,
)
}
}
@@ -388,7 +394,7 @@ class PurchaseRehearsalExecutor(
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
)
}
@@ -409,7 +415,7 @@ class PurchaseRehearsalExecutor(
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
)
}
return failure(
@@ -440,17 +446,51 @@ class PurchaseRehearsalExecutor(
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
var last = currentScreen(input)
var changed = false
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
var polls = 0
var waitedMs = 0L
var requiredEvidenceSeen = false
var previousStructure: List<SpecActionNodeSignature>? = null
fun waitDiagnostic(outcome: String) {
if (requiredEvidenceSeen) {
panelDiagnostic(
"specPanelWait;outcome=$outcome;waitedMs=$waitedMs;samples=$polls;backPressed=false;" +
last.requiredEvidenceSummary() + last.requiredEvidenceHintSuffix(),
)
}
}
while (true) {
last = currentScreen(input)
panelDiagnostic(panelEvidence(last))
if (last.reviewPageOpen) {
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
}
last.problem?.let { return SpecPanelWait(last, false, true, failure(it.code, it.message)) }
if (last.specPanelOpen) return SpecPanelWait(last, true, true)
changed = changed || specActionSignature(last) != beforeSignature
val structure = specActionSignature(last)
// Stronger panel types keep their immediate acceptance. The #331
// fallback type additionally needs two identical consecutive
// structures so a still-loading sheet is not acted on too early.
if (last.specPanelOpen && (last.specPanelType != SpecPanelType.REQUIRED_EVIDENCE || structure == previousStructure)) {
waitDiagnostic("opened")
return SpecPanelWait(last, true, true)
}
changed = changed || structure != beforeSignature
requiredEvidenceSeen = requiredEvidenceSeen ||
last.hasAddressEntry || last.hasPaymentEntry || last.hasQuantityInput
previousStructure = structure
pause(SPEC_SELECTION_POLL_MILLIS)
waitedMs += SPEC_SELECTION_POLL_MILLIS
polls++
// A partially loaded purchase sheet may take longer than the plain
// entry verification window; it gets the bounded #331 wait. No Back
// is pressed while waiting; the caller fails explicitly on timeout.
val limitReached = if (requiredEvidenceSeen) {
waitedMs >= SpecPanelStabilityPolicy.WAIT_BOUND_MS
} else {
polls >= SPEC_POST_CLICK_VERIFY_POLLS
}
if (limitReached) break
}
waitDiagnostic("timeout")
return SpecPanelWait(last, false, changed)
}
@@ -472,7 +512,8 @@ class PurchaseRehearsalExecutor(
private fun panelEvidence(screen: ParsedPddScreen): String =
"type=${screen.specPanelType};scrollables=${screen.panelScrollableCount};headings=${screen.panelHeadingCount};" +
"options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};" +
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched}"
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched};" +
screen.requiredEvidenceSummary()
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String =
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
@@ -568,9 +609,11 @@ class PurchaseRehearsalExecutor(
verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
verification.failure?.let { return it }
if (verification.confirmed) return null
panelDiagnostic(selectionFailureEvidence(currentScreen(input), dimension, target))
return failure(SPEC_SELECTION_UNCONFIRMED, "无障碍点击和受控手势后未能确认精确选中状态")
}
if (outcome.result == FreshActionResult.SUCCESS) {
panelDiagnostic(selectionFailureEvidence(currentScreen(input), dimension, target))
return failure(SPEC_SELECTION_UNCONFIRMED, "点击规格后未能确认精确选中状态")
}
val subreason = when (outcome.reason) {
@@ -583,6 +626,19 @@ class PurchaseRehearsalExecutor(
return failure(SPEC_CLICK_FAILED, subreason)
}
/**
* #332/task 535: parsed option texts and selected flags of one dimension.
* Spec values are product attributes, not personal data; each is truncated.
*/
private fun selectionFailureEvidence(screen: ParsedPddScreen, dimension: String, target: String): String {
fun clip(value: String) = value.replace(Regex("[\r\n\t;|]+"), " ").take(SELECTION_DIAGNOSTIC_TEXT_CHARS)
val values = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
return "specSelectionUnconfirmed;dimension=$dimension;target=${clip(target)};valueCount=${values.size};values=" +
values.joinToString("|") { value ->
"${clip(value.text)}:${(value.node.selected || value.node.checked).toString().first()}"
}
}
private data class ExactSelectionWait(
val confirmed: Boolean,
val failure: PurchaseExecutionOutcome? = null,
@@ -1169,6 +1225,7 @@ class PurchaseRehearsalExecutor(
private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
private const val SPEC_SELECTION_POLL_MILLIS = 100L
private const val SELECTION_DIAGNOSTIC_TEXT_CHARS = 40
private const val SOLD_OUT_PANEL_CLOSE_MILLIS = 300L
private const val SPEC_ENTRY_NOT_FOUND = "PURCHASE_SPEC_ENTRY_NOT_FOUND"
private const val SPEC_ENTRY_TARGET_AMBIGUOUS = "PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS"
@@ -0,0 +1,95 @@
package cn.ilapage.goauto.agent.automation
import org.json.JSONObject
/**
* #334: a spec probe that produced zero color/size dimensions, or that never
* collected any dimensions because the collector itself failed (e.g. an
* over-swiped spec panel losing its headings — see
* [PddProductDetailCollector.moveSpecPanelToTop]), must fail explicitly
* rather than being reported as a normal `spec_probe_completed` empty
* result or a generic "no matchable spec" outcome.
*
* This lives outside [PurchaseRehearsalExecutor] on purpose: the executor's
* `probeSpecs` callback is opaque by contract (any JSON payload the caller
* wants to hand to the server), and its own unit tests exercise that
* contract with canned payloads unrelated to this device bug. The checks
* instead run once, at the point where
* [cn.ilapage.goauto.agent.service.AgentForegroundService] turns the real
* collector result into the outcome that gets persisted and reported.
* `collectPurchaseProbe` encodes a failed collector run as a
* `probeFailureCode`/`probeFailureMessage` JSON payload (still routed
* through the same opaque `String?` probe callback) instead of `null`, so
* [demote] can tell it apart from a genuinely empty-but-successful probe.
*/
object PurchaseSpecProbePolicy {
const val EMPTY_PROBE_CODE = "PURCHASE_SPEC_PROBE_EMPTY"
const val EMPTY_PROBE_MESSAGE = "规格探测未读取到任何颜色或尺码"
const val FAILED_PROBE_CODE = "PURCHASE_SPEC_PROBE_FAILED"
/** JSON field names used by [collectPurchaseProbeFailurePayload]-style encodings. */
const val PROBE_FAILURE_CODE_FIELD = "probeFailureCode"
const val PROBE_FAILURE_MESSAGE_FIELD = "probeFailureMessage"
/** Purchaser-readable messages for known collector failure codes; anything else falls back to a generic message that still includes the raw code. */
private val knownCollectorFailureMessages = mapOf(
"SPEC_PANEL_TOP_COLLAPSED" to "规格探测时规格面板被拖动,规格标题消失",
)
/** True when the probe JSON's `dimensions` array has no entries, or entries with no values. */
fun isDimensionsEmpty(probedSpecsJson: String?): Boolean {
if (probedSpecsJson.isNullOrBlank()) return true
return try {
val dimensions = JSONObject(probedSpecsJson).optJSONArray("dimensions") ?: return true
var total = 0
for (i in 0 until dimensions.length()) {
total += dimensions.optJSONObject(i)?.optJSONArray("values")?.length() ?: 0
}
total == 0
} catch (_: Exception) {
// Malformed payload cannot be trusted as a real, non-empty probe result.
true
}
}
/** Extracts a collector failure code from a probe JSON payload built by [encodeCollectorFailure], or null when the payload is not a failure encoding. */
fun extractCollectorFailureCode(probedSpecsJson: String?): String? {
if (probedSpecsJson.isNullOrBlank()) return null
return try {
JSONObject(probedSpecsJson).optString(PROBE_FAILURE_CODE_FIELD, "").takeIf(String::isNotBlank)
} catch (_: Exception) {
null
}
}
/** Builds the opaque probe JSON payload used to carry an explicit collector failure through the `probeSpecs: () -> String?` callback. */
fun encodeCollectorFailure(goodsId: String, collectorCode: String, collectorMessage: String): String =
JSONObject()
.put("goodsId", goodsId)
.put(PROBE_FAILURE_CODE_FIELD, collectorCode)
.put(PROBE_FAILURE_MESSAGE_FIELD, collectorMessage)
.toString()
private fun failureMessageFor(collectorCode: String): String =
knownCollectorFailureMessages[collectorCode] ?: "商品规格探测失败:$collectorCode"
/**
* Demotes a `spec_probe_completed` outcome that either carries an
* encoded collector failure, or collected zero dimensions, into an
* explicit failure. Any other outcome (including real failures reported
* some other way, or a probe that did collect dimensions) is returned
* unchanged.
*/
fun demote(outcome: PurchaseExecutionOutcome): PurchaseExecutionOutcome {
if (outcome.resultType != "spec_probe_completed") return outcome
extractCollectorFailureCode(outcome.probedSpecs)?.let { collectorCode ->
return PurchaseExecutionOutcome("failed", FAILED_PROBE_CODE, failureMessageFor(collectorCode))
}
if (!isDimensionsEmpty(outcome.probedSpecs)) return outcome
return PurchaseExecutionOutcome("failed", EMPTY_PROBE_CODE, EMPTY_PROBE_MESSAGE)
}
/** @see demote */
@Deprecated("Use demote(outcome), which also handles encoded collector failures.", ReplaceWith("demote(outcome)"))
fun demoteIfEmpty(outcome: PurchaseExecutionOutcome): PurchaseExecutionOutcome = demote(outcome)
}
@@ -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,
@@ -60,6 +60,14 @@ data class AgentTask(
val status: String,
val source: String,
val replacementOriginType: String,
val imageSearch: ImageSearchImage?,
)
data class ImageSearchImage(
val imageUrl: String,
val mediaType: String,
val sizeBytes: Long,
val sha256: String,
)
data class CurrentPageIdentity(
@@ -203,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))
}
@@ -326,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"))
@@ -584,6 +621,7 @@ class AgentApiClient(private val serverUrl: String) {
status = data.getString("status"),
source = data.optString("source", "admin"),
replacementOriginType = data.optString("replacementOriginType"),
imageSearch = data.optJSONObject("imageSearch")?.let { image -> ImageSearchImage(image.getString("imageUrl"), image.getString("mediaType"), image.getLong("sizeBytes"), image.getString("sha256")) },
)
private fun purchaseTask(data: JSONObject) = PurchaseAgentTask(
@@ -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)
}
}
}
@@ -30,6 +30,10 @@ enum class AgentDiagnosticReason {
SPEC_PANEL_RESTORE_LIMIT,
SPEC_PANEL_RESTORE_CONTAINER_UNAVAILABLE,
SPEC_PANEL_RESTORE_GESTURE_FAILED,
/** #334: the spec panel already showed the topmost color heading; the top-restore swipe was skipped. */
SPEC_PANEL_TOP_ALREADY,
/** #334: a top-restore swipe made spec headings or dimensions vanish (the bottom sheet was dragged); swiping stopped. */
SPEC_PANEL_TOP_COLLAPSED,
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
SPEC_ENTRY_CLICK_NO_EFFECT,
SIZE_FOUND,
@@ -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,10 +31,14 @@ 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
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
@@ -75,6 +79,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 +137,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 +160,7 @@ class AgentForegroundService : Service() {
}
override fun onDestroy() {
backfillGuard.cancelled.set(true)
runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) }
cancelIdleReturn("服务已停止")
collectionCooldownFuture.getAndSet(null)?.cancel(false)
@@ -165,7 +179,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 +321,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 +520,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 手动采集规则。"
@@ -461,7 +592,7 @@ class AgentForegroundService : Service() {
stateStore.update("BUSY", "正在执行${taskLabel}任务 #${task.taskId}", tokenStored = true)
updateNotification("$taskLabel #${task.taskId}")
val outcome = if (!snapshotHashValid) {
val rawOutcome = if (!snapshotHashValid) {
PurchaseExecutionOutcome("failed", "PURCHASE_RULE_INVALID", "采购规则快照哈希无效")
} else {
var parseFailure: PurchaseExecutionOutcome? = null
@@ -522,6 +653,9 @@ class AgentForegroundService : Service() {
}
}
}
// #334: a spec probe that read zero colors/sizes must fail explicitly
// instead of being reported as a normal, empty spec_probe_completed.
val outcome = PurchaseSpecProbePolicy.demote(rawOutcome)
val requestId = UUID.randomUUID().toString()
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
@@ -561,6 +695,13 @@ class AgentForegroundService : Service() {
collector = collector,
)
val result = PddProductDetailCollector(accessibility).collect(task.pddGoodsId, rule)
// #334: a collector failure (e.g. SPEC_PANEL_TOP_COLLAPSED) must not
// collapse into a generic "spec probe failed" outcome. Carry the
// real code/message through the opaque probeSpecs callback so
// PurchaseSpecProbePolicy.demote can surface it explicitly.
if (!result.successful) {
return PurchaseSpecProbePolicy.encodeCollectorFailure(task.pddGoodsId, result.code, result.message)
}
val payload = result.payload ?: return null
return JSONObject()
.put("goodsId", task.pddGoodsId)
@@ -591,9 +732,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))
@@ -708,6 +850,89 @@ class AgentForegroundService : Service() {
).collect(identity.goodsId, rule)
if (!collection.successful) throw TaskFailure(collection.code, collection.message)
CollectionExecution(requireNotNull(collection.payload), collection.colorImages)
} else if (task.source == "image_search") {
// #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(
@@ -1038,10 +1263,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()
@@ -1052,6 +1282,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
@@ -1067,6 +1318,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 }
}
}
@@ -974,6 +974,95 @@ class PddProductDetailCollectorTest {
assertTrue(driver.captureCount > driver.clickCount)
}
@Test
fun `spec panel already at top skips the restore swipe and still collects colors and sizes`() {
// Default fixture state already renders the color heading first (at
// top). #334: moveSpecPanelToTop must not issue any DOWN swipe in
// that case, since dragging an already-top panel can lose headings
// on a real device.
val driver = FakeCollectorDriver(
colorPages = listOf(listOf("红色", "蓝色")),
sizePages = listOf(listOf("S", "M")),
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
assertEquals(0, driver.swipes.count { it.first == SwipeDirection.DOWN })
}
@Test
fun `spec panel not at top restores with the original swipe behavior before collecting`() {
// The color heading is hidden until one DOWN (top-restore) swipe has
// happened, simulating a panel scrolled past its headings.
val driver = FakeCollectorDriver(
colorPages = listOf(listOf("红色", "蓝色")),
sizePages = listOf(listOf("S", "M")),
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
specPanelHidesColorInitially = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
assertTrue(driver.swipes.count { it.first == SwipeDirection.DOWN } >= 1)
}
@Test
fun `spec headings vanishing after a restore swipe stops swiping and fails explicitly`() {
// The panel starts not-at-top (color heading hidden) so the restore
// swipe runs; after that swipe every heading and value disappears,
// simulating the bottom sheet being dragged off screen. #334
// requires the collector to stop and fail instead of reporting an
// empty successful result.
val driver = FakeCollectorDriver(
colorPages = listOf(listOf("红色", "蓝色")),
sizePages = listOf(listOf("S", "M")),
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
specPanelHidesColorInitially = true,
specPanelCollapsesAfterTopSwipe = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertFalse(result.successful)
assertEquals("SPEC_PANEL_TOP_COLLAPSED", result.code)
assertEquals(1, driver.swipes.count { it.first == SwipeDirection.DOWN })
}
@Test
fun `a lower heading scrolling out of view after a restore swipe does not fail collection`() {
// Three-dimension panel not at top (color heading is present but has
// no values yet, so it does not become dimensions.first()). After the
// restore swipe, color values return but the third, lower "容量"
// heading scrolls out of view — a normal scroll-to-top effect. Total
// heading count drops, but color and size values are present the
// whole time, so this must NOT be treated as a collapsed panel.
val driver = FakeCollectorDriver(
colorPages = listOf(listOf("红色", "蓝色")),
sizePages = listOf(listOf("S", "M")),
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
extraDimension = true,
specPanelHidesColorInitially = true,
specPanelDropsExtraDimensionAfterTopSwipe = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
assertTrue(driver.swipes.count { it.first == SwipeDirection.DOWN } >= 1)
}
@Test
fun `duplicate sizes after price cleanup reject the whole size dimension`() {
val driver = FakeCollectorDriver(
@@ -1044,7 +1133,9 @@ class PddProductDetailCollectorTest {
assertTrue(result.successful)
assertEquals(listOf("A色", "B色", "C色", "F色", "E色", "D色"), driver.clickedLabels)
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
assertTrue(driver.swipes.any { it.first == SwipeDirection.DOWN && it.second != null })
// #334: the panel already shows the color heading at top, so
// moveSpecPanelToTop must not issue a needless DOWN restore swipe.
assertTrue(driver.swipes.none { it.first == SwipeDirection.DOWN })
assertTrue(driver.swipes.any { it.first == SwipeDirection.RIGHT && it.second != null })
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second != null })
}
@@ -1757,6 +1848,21 @@ class PddProductDetailCollectorTest {
private val selectedSummaryPrefix: String = "已选",
private val imageColorCards: Boolean = false,
private val navigateAwayAfterColorClick: Boolean = false,
// #334: simulates a spec panel that is not scrolled to top yet: the
// color heading is present but has no values yet (so it is not the
// first parsed dimension), until one DOWN (top-restore) swipe has
// happened, after which color values render normally.
private val specPanelHidesColorInitially: Boolean = false,
// #334: simulates a bottom sheet dragged past its headings by a
// restore swipe — once at least one DOWN swipe has happened, every
// spec heading and value disappears from the panel.
private val specPanelCollapsesAfterTopSwipe: Boolean = false,
// #334: simulates a normal scroll-to-top on a multi-dimension panel
// where a lower, non-spec-value-bearing heading (here the extra
// "容量" dimension) scrolls out of view once the color heading is
// restored to top. Heading count drops but color/size values are
// untouched, so this must NOT be treated as a collapsed panel.
private val specPanelDropsExtraDimensionAfterTopSwipe: Boolean = false,
) : PddCollectorDriver {
var captureCount = 0
var clickCount = 0
@@ -1770,6 +1876,7 @@ class PddProductDetailCollectorTest {
private var previousSelected: String? = null
private var horizontalPage = 0
private var verticalPage = 0
private var downSwipeCount = 0
private var priceRead = 0
private var panelOpen = !startWithPanelClosed
private var reviewOpen = false
@@ -1832,12 +1939,16 @@ class PddProductDetailCollectorTest {
node("scroll", "", 0, 380, 1080, 1900, scrollable = scrollablePanel),
)
val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0
val panelCollapsedNow = specPanelCollapsesAfterTopSwipe && downSwipeCount >= 1
val hideColorNow = specPanelHidesColorInitially && downSwipeCount == 0
val visibleColors = colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex))
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
if (!continuationPage) {
if (!continuationPage && !panelCollapsedNow) {
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
}
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
visibleColors
.filterNot { hideSelectedColorOption && it == selected }
.forEachIndexed { index, color ->
@@ -1877,21 +1988,23 @@ class PddProductDetailCollectorTest {
)
}
}
if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, sizeHeadingTop, 300, sizeHeadingTop + 50, parentPath = "scroll")
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
nodes += node(
"scroll/size-$size-$captureCount",
size,
30 + index * 230,
sizeHeadingTop + 70,
220 + index * 230,
sizeHeadingTop + 140,
clickable = true,
selected = size == initialSelectedSize,
parentPath = "scroll",
)
if (!continuationPage && !panelCollapsedNow) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, sizeHeadingTop, 300, sizeHeadingTop + 50, parentPath = "scroll")
if (!panelCollapsedNow) {
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
nodes += node(
"scroll/size-$size-$captureCount",
size,
30 + index * 230,
sizeHeadingTop + 70,
220 + index * 230,
sizeHeadingTop + 140,
clickable = true,
selected = size == initialSelectedSize,
parentPath = "scroll",
)
}
}
if (extraDimension) {
if (extraDimension && !(specPanelDropsExtraDimensionAfterTopSwipe && downSwipeCount >= 1)) {
nodes += node("scroll/capacity-heading", "容量", 20, 900, 300, 950, parentPath = "scroll")
nodes += node("scroll/capacity", "大容量", 30, 970, 220, 1040, clickable = true, parentPath = "scroll")
}
@@ -1966,7 +2079,10 @@ class PddProductDetailCollectorTest {
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
SwipeDirection.UP -> verticalPage = (verticalPage + 1)
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
SwipeDirection.DOWN -> verticalPage = (verticalPage - 1).coerceAtLeast(0)
SwipeDirection.DOWN -> {
verticalPage = (verticalPage - 1).coerceAtLeast(0)
downSwipeCount++
}
else -> Unit
}
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()
@@ -99,7 +197,12 @@ class PurchaseLiveAutomationTest {
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg11")
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg11", address.expectedAddress)
assertEquals(1, driver.addressTaps)
// #335: the confirmation-page fixture now wraps the masked phone in a clickable
// row (required so the page satisfies #331 REQUIRED_EVIDENCE panel recognition,
// which finalSubmitTargets depends on). That changes address entry activation
// from a bare TAP to a CLICK through the row's path, same as production PDD rows.
assertEquals(0, driver.addressTaps)
assertEquals(1, driver.addressPathClicks)
assertEquals("editor-address", driver.lastInputTargetPath)
val final = automation.finalConfirmation(input(), address)
assertEquals("_cg11", final.addressSuffix)
@@ -317,6 +420,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 +461,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("支付") })
@@ -745,38 +877,50 @@ class PurchaseLiveAutomationTest {
"unknown" -> UiSnapshot("example.untrusted", "example.untrusted.UnknownActivity", emptyList())
"pdd-home" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.ui.activity.MainFrameActivity", listOf(node("home", "拼多多首页")))
else -> {
// #335: the confirmation page must satisfy PddScreenParser's spec-panel
// recognition (#331 REQUIRED_EVIDENCE fallback: address entry + payment
// entry + one quantity input, all sharing the "panel" container) so the
// new bottom-most-clickable-node submit rule has a recognized panel to
// operate on. "panel/submit" keeps the legacy exact "提交订单" label so
// existing click-driven state transitions below stay unchanged.
val nodes = mutableListOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
node("price", "¥20.00"), node("selected", "已选 黑色 XL"),
node("quantity", "2", className = "android.widget.EditText"),
node("submit-parent", "", clickable = true), node("submit", "提交订单", parentPath = "submit-parent"),
node("panel", "", scrollable = true, parentPath = "root", bounds = NodeBounds(0, 400, 1080, 2100)),
node("panel/price", "¥20.00", parentPath = "panel", bounds = NodeBounds(20, 420, 300, 470)),
node("panel/selected", "已选 黑色 XL", parentPath = "panel", bounds = NodeBounds(20, 480, 700, 530)),
node("panel/quantity", "2", className = "android.widget.EditText", parentPath = "panel", bounds = NodeBounds(400, 560, 600, 620)),
node("panel/payment-row", "", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 640, 1060, 710)),
node("panel/payment-row/label", "微信支付", parentPath = "panel/payment-row", bounds = NodeBounds(40, 650, 300, 700)),
node("panel/submit", "提交订单", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 1900, 1060, 2080)),
)
if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000))
if (addressVisible) {
when {
duplicateSemanticAddressCards -> {
nodes += node("address-layer-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 840))
nodes += node("address-layer-a/phone", "138****5678", parentPath = "address-layer-a", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-layer-a/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-a", bounds = NodeBounds(20, 720, 900, 790))
nodes += node("address-layer-b", "", clickable = true, bounds = NodeBounds(0, 900, 1080, 1120))
nodes += node("address-layer-b/phone", "138****5678", parentPath = "address-layer-b", bounds = NodeBounds(20, 930, 400, 990))
nodes += node("address-layer-b/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-b", bounds = NodeBounds(20, 1000, 900, 1070))
nodes += node("panel/address-layer-a", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
nodes += node("panel/address-layer-a/phone", "138****5678", parentPath = "panel/address-layer-a", bounds = NodeBounds(20, 830, 400, 860))
nodes += node("panel/address-layer-a/detail", "广东省广州市天园街道骏景花园", parentPath = "panel/address-layer-a", bounds = NodeBounds(20, 862, 900, 898))
nodes += node("panel/address-layer-b", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 910, 1080, 990))
nodes += node("panel/address-layer-b/phone", "138****5678", parentPath = "panel/address-layer-b", bounds = NodeBounds(20, 920, 400, 950))
nodes += node("panel/address-layer-b/detail", "广东省广州市天园街道骏景花园", parentPath = "panel/address-layer-b", bounds = NodeBounds(20, 952, 900, 988))
}
duplicateAddressCards -> {
nodes += node("address-card-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 820))
nodes += node("address-card-a/phone", "138****5678", parentPath = "address-card-a", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-card-a/detail", "广东省广州市天园街道一号", parentPath = "address-card-a", bounds = NodeBounds(20, 720, 900, 780))
nodes += node("address-card-b", "", clickable = true, bounds = NodeBounds(0, 840, 1080, 1040))
nodes += node("address-card-b/phone", "138****5678", parentPath = "address-card-b", bounds = NodeBounds(20, 870, 400, 930))
nodes += node("address-card-b/detail", "广东省广州市天园街道二号", parentPath = "address-card-b", bounds = NodeBounds(20, 940, 900, 1000))
nodes += node("panel/address-card-a", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
nodes += node("panel/address-card-a/phone", "138****5678", parentPath = "panel/address-card-a", bounds = NodeBounds(20, 830, 400, 860))
nodes += node("panel/address-card-a/detail", "广东省广州市天园街道一号", parentPath = "panel/address-card-a", bounds = NodeBounds(20, 862, 900, 898))
nodes += node("panel/address-card-b", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 910, 1080, 990))
nodes += node("panel/address-card-b/phone", "138****5678", parentPath = "panel/address-card-b", bounds = NodeBounds(20, 920, 400, 950))
nodes += node("panel/address-card-b/detail", "广东省广州市天园街道二号", parentPath = "panel/address-card-b", bounds = NodeBounds(20, 952, 900, 988))
}
duplicatePhoneNodesSameCard -> {
nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 900))
nodes += node("address-card/phone-a", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-card/phone-b", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 720, 440, 790))
nodes += node("panel/address-card", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
nodes += node("panel/address-card/phone-a", "138****5678", parentPath = "panel/address-card", bounds = NodeBounds(20, 830, 400, 860))
nodes += node("panel/address-card/phone-b", "138****5678", parentPath = "panel/address-card", bounds = NodeBounds(20, 862, 440, 892))
}
else -> {
nodes += node("panel/address-row", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
nodes += node("panel/address-row/phone", "138****5678", parentPath = "panel/address-row", bounds = NodeBounds(20, 830, 400, 890))
}
else -> nodes += node("phone", "138****5678")
}
val suffixStart = address.lastIndexOf("_cg")
val addressBody = if (suffixStart >= 0) address.substring(0, suffixStart) else address
@@ -784,10 +928,10 @@ class PurchaseLiveAutomationTest {
when {
hideConfirmationSuffix -> nodes += node("address", addressBody)
splitConfirmationAddress -> {
nodes += node("address-body", addressBody, bounds = NodeBounds(20, 700, 900, 780))
nodes += node("address-suffix", addressSuffix, bounds = NodeBounds(20, 780, 300, 840))
nodes += node("address-body", addressBody, bounds = NodeBounds(20, 1000, 900, 1080))
nodes += node("address-suffix", addressSuffix, bounds = NodeBounds(20, 1090, 300, 1150))
if (duplicateConfirmationSuffix) {
nodes += node("address-suffix-2", addressSuffix, bounds = NodeBounds(500, 780, 780, 840))
nodes += node("address-suffix-2", addressSuffix, bounds = NodeBounds(500, 1090, 780, 1150))
}
}
else -> nodes += node("address", address)
@@ -781,7 +781,8 @@ class PurchaseRehearsalExecutorTest {
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertEquals(
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true]",
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true;" +
"addr=0;pay=0;qin=0;adj=0;summary=0;close=0;submit=0;same=0]",
outcome.message,
)
assertEquals(0, driver.specTapCount)
@@ -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,120 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseSpecProbePolicyTest {
@Test
fun `zero dimensions is treated as empty`() {
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty("""{"dimensions":[]}"""))
}
@Test
fun `dimensions with no values is treated as empty`() {
assertTrue(
PurchaseSpecProbePolicy.isDimensionsEmpty(
"""{"dimensions":[{"key":"color","name":"颜色","values":[]},{"key":"size","name":"尺码","values":[]}]}""",
),
)
}
@Test
fun `null blank or malformed payload is treated as empty`() {
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty(null))
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty(""))
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty("not json"))
}
@Test
fun `dimensions with at least one value is not empty`() {
assertFalse(
PurchaseSpecProbePolicy.isDimensionsEmpty(
"""{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
),
)
}
@Test
fun `demote replaces an empty spec_probe_completed outcome with an explicit failure`() {
val outcome = PurchaseExecutionOutcome(
"spec_probe_completed",
message = "商品规格已回传,等待服务端匹配",
probedSpecs = """{"dimensions":[]}""",
)
val demoted = PurchaseSpecProbePolicy.demote(outcome)
assertEquals("failed", demoted.resultType)
assertEquals("PURCHASE_SPEC_PROBE_EMPTY", demoted.errorCode)
assertEquals("规格探测未读取到任何颜色或尺码", demoted.message)
}
@Test
fun `demote leaves a non-empty probe result unchanged`() {
val outcome = PurchaseExecutionOutcome(
"spec_probe_completed",
message = "商品规格已回传,等待服务端匹配",
probedSpecs = """{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
)
val demoted = PurchaseSpecProbePolicy.demote(outcome)
assertEquals(outcome, demoted)
}
@Test
fun `demote leaves other outcome types unchanged`() {
val outcome = PurchaseExecutionOutcome("failed", "PURCHASE_SPEC_NOT_MATCHED", "商品规格探测失败")
val demoted = PurchaseSpecProbePolicy.demote(outcome)
assertEquals(outcome, demoted)
}
@Test
fun `extractCollectorFailureCode reads the encoded collector failure code`() {
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
assertEquals("SPEC_PANEL_TOP_COLLAPSED", PurchaseSpecProbePolicy.extractCollectorFailureCode(payload))
}
@Test
fun `extractCollectorFailureCode returns null for a normal probe payload`() {
assertEquals(
null,
PurchaseSpecProbePolicy.extractCollectorFailureCode(
"""{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
),
)
assertEquals(null, PurchaseSpecProbePolicy.extractCollectorFailureCode(null))
}
@Test
fun `demote surfaces an encoded SPEC_PANEL_TOP_COLLAPSED collector failure with a purchaser-readable message`() {
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
val outcome = PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = payload)
val demoted = PurchaseSpecProbePolicy.demote(outcome)
assertEquals("failed", demoted.resultType)
assertEquals("PURCHASE_SPEC_PROBE_FAILED", demoted.errorCode)
assertEquals("规格探测时规格面板被拖动,规格标题消失", demoted.message)
}
@Test
fun `demote falls back to a generic message for an unrecognized collector failure code`() {
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
val outcome = PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = payload)
val demoted = PurchaseSpecProbePolicy.demote(outcome)
assertEquals("failed", demoted.resultType)
assertEquals("PURCHASE_SPEC_PROBE_FAILED", demoted.errorCode)
assertTrue(demoted.message.contains("RULE_NOT_MATCHED"))
}
}
@@ -0,0 +1,86 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.FreshActionResult
import cn.ilapage.goauto.agent.automation.PddScreenParser
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.SwipeDirection
import cn.ilapage.goauto.agent.automation.UiSnapshot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/** #332 spec-option dedup (task 535) and the selection-failure diagnostic. */
class SpecOptionDedupTest {
private fun parse(snapshot: UiSnapshot) =
PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "719834019024", null)
@Test
fun `nested clickable nodes of one option block form one value`() {
val screen = parse(SpecPanelFixtures.taskOptionDedupSheet())
val colors = screen.dimensions.single { it.key == "color" }.values
val sizes = screen.dimensions.single { it.key == "size" }.values
assertEquals(listOf("兰条纹", "白条纹"), colors.map { it.text })
assertTrue(colors.single { it.text == "兰条纹" }.node.selected)
assertFalse(colors.single { it.text == "白条纹" }.node.selected)
assertEquals(5, sizes.size)
assertEquals(listOf("2XL建议130-150斤"), sizes.filter { it.node.selected }.map { it.rawText })
}
@Test
fun `unconfirmed selection reports parsed values selected flags and target`() {
// The click never changes the page, so the white option stays unselected.
val driver = object : PurchaseUiDriver {
var opened = false
override fun capture(): UiSnapshot =
if (opened) SpecPanelFixtures.taskOptionDedupSheet() else SpecPanelFixtures.productDetailPage()
override fun clickFresh(target: SnapshotNode): FreshActionResult {
if (target.label.startsWith("请选择")) opened = true
return FreshActionResult.SUCCESS
}
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
override fun backPurchase() = false
}
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
val evidence = diagnostics.last { it.startsWith("specSelectionUnconfirmed;") }
assertTrue(evidence, evidence.contains("dimension=color;target=白条纹;valueCount=2;values=兰条纹:t|白条纹:f"))
assertFalse(evidence.contains("****"))
}
private fun input() = PurchaseExecutionInput(
taskId = 332,
executionMode = "rehearsal",
phase = "purchase",
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
goodsId = "719834019024",
mappedColor = "白条纹",
mappedSize = "",
quantity = 1,
minUnitPriceCent = 1_000,
maxUnitPriceCent = 3_000,
)
private fun rule() = """{
"schemaVersion":1,
"ruleType":"pddPurchase",
"requiredCapabilities":["purchase.rehearsal.v1"],
"actions":[
{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}
]
}"""
}
@@ -0,0 +1,317 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.NodeBounds
import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.UiSnapshot
/**
* Sanitized spec-panel fixtures (#332 option dedup, #331 recognition). The layout mirrors the structure of real
* PDD purchase sheets, but every name, phone number and address is fake.
*/
internal object SpecPanelFixtures {
const val PDD = "com.xunmeng.pinduoduo"
const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"
const val FAKE_PHONE = "139****0000"
class Tree(private val screenBottom: Int) {
val nodes = mutableListOf<SnapshotNode>()
init {
add("r", "", NodeBounds(0, 0, 1080, screenBottom), className = "android.widget.FrameLayout")
}
fun add(
path: String,
text: String,
bounds: NodeBounds,
className: String = "android.widget.TextView",
clickable: Boolean = false,
scrollable: Boolean = false,
selected: Boolean = false,
visible: Boolean = true,
enabled: Boolean = true,
description: String? = null,
) {
val parent = path.substringBeforeLast('/', "").ifEmpty { null }
nodes += SnapshotNode(
path, parent, text, description, null, className, bounds,
clickable, scrollable, selected, false, enabled, visible,
)
}
fun snapshot(packageName: String = PDD, activity: String = ACTIVITY) = UiSnapshot(packageName, activity, nodes.toList())
}
/** Options for the purchase sheet shaped like the two unselected real samples. */
data class Sheet(
val screenBottom: Int = 2216,
val address: Boolean = true,
val payment: Boolean = true,
val quantityInput: Boolean = true,
val adjustButtons: Boolean = true,
val close: Boolean = true,
val summary: Boolean = true,
val sizeDimension: Boolean = true,
val listScrollable: Boolean = true,
/** "visible" = normal submit bar; "hidden" = Samsung-like 31px bar whose [0,0][0,0] label is not visible; "none". */
val submit: String = "visible",
val offset: Int = 0,
val colorLabels: List<String> = listOf("黑色 示例款", "卡其色 示例款"),
val sizeLabels: List<String> = listOf("S 建议75-90斤", "M 建议90-105斤", "L 建议105-115斤"),
)
fun sheet(options: Sheet = Sheet()): UiSnapshot {
val o = options.offset
val t = Tree(options.screenBottom)
t.add("r/sheet", "", NodeBounds(0, 84, 1080, options.screenBottom), "android.widget.LinearLayout", clickable = true)
t.add("r/sheet/top", "", NodeBounds(0, 219, 1080, 330), "android.widget.ViewSwitcher", clickable = true)
t.add("r/sheet/top/tags", "#示例标签#七天退换", NodeBounds(193, 240, 886, 308))
if (options.close) {
t.add("r/sheet/close", "", NodeBounds(975, 237, 1050, 312), "android.widget.ImageView", clickable = true, description = "关闭")
}
t.add("r/sheet/body", "", NodeBounds(0, 330, 1080, options.screenBottom), "android.view.ViewGroup", clickable = true)
if (options.address) {
// Address row: clickable row five levels above the masked phone text.
t.add("r/sheet/body/addr", "", NodeBounds(0, 330 + o, 1080, 484 + o), "android.view.ViewGroup", clickable = true)
t.add("r/sheet/body/addr/a", "", NodeBounds(0, 340 + o, 1080, 470 + o), "android.widget.LinearLayout")
t.add("r/sheet/body/addr/a/b", "", NodeBounds(36, 340 + o, 1044, 400 + o), "android.widget.LinearLayout")
t.add("r/sheet/body/addr/a/b/c", "", NodeBounds(36, 340 + o, 1044, 400 + o), "android.widget.LinearLayout")
t.add("r/sheet/body/addr/a/b/c/tag", "7天无理由退货", NodeBounds(138, 348 + o, 394, 394 + o))
t.add("r/sheet/body/addr/a/b/c/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(412, 346 + o, 993, 395 + o))
t.add("r/sheet/body/addr/a/detail", "示例区示例街道示例路1号", NodeBounds(132, 408 + o, 937, 457 + o))
}
t.add("r/sheet/body/image", "", NodeBounds(36, 638, 360, 962), "android.widget.FrameLayout", clickable = true, description = "商品主图")
t.add("r/sheet/body/price", "¥19.6", NodeBounds(384, 640, 523, 707))
if (options.summary) t.add("r/sheet/body/summary", "请选择: 颜色分类 尺码", NodeBounds(384, 791, 1068, 852))
t.add("r/sheet/body/qty", "", NodeBounds(384, 888, 633, 963), "android.widget.LinearLayout")
if (options.adjustButtons) {
t.add("r/sheet/body/qty/dec", "", NodeBounds(384, 888, 462, 963), "android.widget.ImageView", clickable = true, description = "减少数量")
t.add("r/sheet/body/qty/inc", "", NodeBounds(555, 888, 633, 963), "android.widget.ImageView", clickable = true, description = "增加数量")
}
if (options.quantityInput) {
t.add("r/sheet/body/qty/input", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
}
val list = "r/sheet/body/list"
t.add(list, "", NodeBounds(0, 1136, 1080, 2036), "androidx.recyclerview.widget.RecyclerView", scrollable = options.listScrollable)
t.add("$list/color", "", NodeBounds(0, 1136, 1080, 1686), "android.widget.LinearLayout")
t.add("$list/color/h", "颜色分类", NodeBounds(36, 1161, 216, 1222))
options.colorLabels.forEachIndexed { index, label ->
val step = if (options.colorLabels.size > 3) 258 else 346
val w = if (options.colorLabels.size > 3) 240 else 316
val left = 36 + index * step
val block = "$list/color/o$index"
t.add(block, "", NodeBounds(left, 1247, left + w, 1650), "android.view.ViewGroup", clickable = true, description = label)
t.add("$block/img", "", NodeBounds(left, 1247, left + w, 1563), "android.widget.ImageView", clickable = true, description = label)
t.add("$block/big", "", NodeBounds(left, 1247, left + 111, 1358), "android.widget.ImageView", clickable = true, description = "打开大图")
t.add("$block/l", "", NodeBounds(left, 1563, left + w, 1650), "android.widget.LinearLayout")
t.add("$block/l/t", label, NodeBounds(left, 1563, left + w, 1650), clickable = true)
}
if (options.sizeDimension) {
t.add("$list/size", "", NodeBounds(0, 1686, 1080, 2036), "android.widget.LinearLayout")
t.add("$list/size/row", "", NodeBounds(36, 1686, 1044, 1767), "android.widget.LinearLayout", clickable = true)
t.add("$list/size/row/h", "尺码", NodeBounds(36, 1700, 126, 1753))
t.add("$list/size/row/hint", "查看尺码建议", NodeBounds(150, 1702, 402, 1751))
options.sizeLabels.forEachIndexed { index, label ->
val top = 1779 + (index / 2) * 85
val left = 36 + (index % 2) * 420
t.add("$list/size/o$index", "", NodeBounds(left, top, left + 364, top + 80), "android.view.ViewGroup", clickable = true)
t.add("$list/size/o$index/t", label, NodeBounds(left, top, left + 364, top + 80), clickable = true)
}
}
if (options.payment) {
t.add("r/sheet/pay", "", NodeBounds(0, 2036, 1080, 2135), "android.view.ViewGroup", clickable = true)
t.add("r/sheet/pay/l", "", NodeBounds(94, 2057, 930, 2114), "android.widget.LinearLayout")
t.add("r/sheet/pay/l/t", "使用#微信支付,更换先用后付可0元下单", NodeBounds(112, 2057, 930, 2114))
}
when (options.submit) {
"visible" -> {
t.add("r/sheet/submit", "", NodeBounds(0, 2135, 1080, options.screenBottom), "android.widget.FrameLayout", clickable = true)
t.add("r/sheet/submit/l", "", NodeBounds(145, 2135, 934, options.screenBottom), "android.widget.LinearLayout")
t.add("r/sheet/submit/l/t", "选择颜色分类及尺码后,提交订单", NodeBounds(157, 2179, 922, options.screenBottom))
}
"hidden" -> {
t.add("r/sheet/submit", "", NodeBounds(0, options.screenBottom - 31, 1080, options.screenBottom), "android.widget.FrameLayout", clickable = true)
t.add("r/sheet/submit/l", "", NodeBounds(0, 0, 0, 0), "android.widget.LinearLayout", visible = false)
t.add("r/sheet/submit/l/t", "选择颜色分类及尺码后,提交订单", NodeBounds(0, 0, 0, 0), visible = false)
}
}
return t.snapshot()
}
/**
* Minimal fallback sheet: no bounded scrollable, a single heading, no
* summary, no +/- and no readable submit. Only the #331 required-evidence
* fallback can recognize it. (Not the goods 8580 shape; see [sample8580ShapedSheet].)
*/
fun liveShapedSheet(options: Sheet = Sheet()): UiSnapshot = sheet(
options.copy(summary = false, listScrollable = false, sizeDimension = false, adjustButtons = false, submit = "hidden"),
)
/**
* Shape of the 13 goods 8580 failures reported in #331:
* `type=UNKNOWN;scrollables=1;headings=2;options=9;summary=false;quantity=true;orderAction=false;pageEvidenceMatched=true`.
* One bounded scrollable, two headings, nine options, +/- and input present,
* but no selection summary and no readable submit action.
*/
fun sample8580ShapedSheet(options: Sheet = Sheet()): UiSnapshot = sheet(
options.copy(
summary = false,
submit = "hidden",
colorLabels = listOf("黑色 示例款", "卡其色 示例款", "白色 示例款", "灰色 示例款"),
sizeLabels = listOf("S 示例", "M 示例", "L 示例", "XL 示例", "2XL 示例"),
),
)
/**
* #331 counter-example: a masked-phone row inside an address dialog, a
* payment row inside a separate payment dialog and a quantity input in a
* third place, together with a parsed option group. Every item exists but
* they do not share one panel container.
*/
fun scatteredRequiredItemsPage(fullScreenWrapper: Boolean = false): UiSnapshot {
val t = Tree(2216)
val root = if (fullScreenWrapper) {
t.add("r/content", "", NodeBounds(0, 0, 1080, 2216), "android.widget.FrameLayout")
"r/content"
} else "r"
t.add("$root/addrDialog", "", NodeBounds(60, 200, 1020, 700), "android.widget.FrameLayout")
t.add("$root/addrDialog/title", "选择收货地址", NodeBounds(360, 220, 720, 290))
t.add("$root/addrDialog/row", "", NodeBounds(60, 320, 1020, 470), "android.view.ViewGroup", clickable = true)
t.add("$root/addrDialog/row/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(100, 340, 980, 400))
t.add("$root/payDialog", "", NodeBounds(60, 800, 1020, 1200), "android.widget.FrameLayout")
t.add("$root/payDialog/row", "", NodeBounds(60, 900, 1020, 1010), "android.view.ViewGroup", clickable = true)
t.add("$root/payDialog/row/t", "微信支付", NodeBounds(120, 920, 400, 990))
t.add("$root/other", "", NodeBounds(0, 1250, 1080, 2216), "android.widget.LinearLayout")
t.add("$root/other/close", "", NodeBounds(975, 1260, 1050, 1335), "android.widget.ImageView", clickable = true, description = "关闭")
t.add("$root/other/input", "1", NodeBounds(468, 1350, 549, 1425), "android.widget.EditText", clickable = true)
t.add("$root/other/h", "颜色分类", NodeBounds(36, 1500, 216, 1560))
t.add("$root/other/o0", "黑色 示例款", NodeBounds(36, 1600, 352, 1700), clickable = true)
t.add("$root/other/o1", "白色 示例款", NodeBounds(382, 1600, 698, 1700), clickable = true)
return t.snapshot()
}
/** Task 535 layout: the selected option block exposes an outer block, an image and an inner text with a badge. */
fun taskOptionDedupSheet(): UiSnapshot {
val t = Tree(2328)
t.add("r/sheet", "", NodeBounds(0, 120, 1080, 2328), "android.widget.LinearLayout", clickable = true)
t.add("r/sheet/close", "", NodeBounds(975, 273, 1050, 348), "android.widget.ImageView", clickable = true, description = "关闭")
t.add("r/sheet/body", "", NodeBounds(0, 366, 1080, 2328), "android.view.ViewGroup", clickable = true)
t.add("r/sheet/body/addr", "", NodeBounds(0, 366, 1080, 520), "android.view.ViewGroup", clickable = true)
t.add("r/sheet/body/addr/a", "", NodeBounds(0, 376, 1080, 510), "android.widget.LinearLayout")
t.add("r/sheet/body/addr/a/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(412, 382, 993, 431))
t.add("r/sheet/body/addr/a/detail", "示例区示例路2号", NodeBounds(132, 444, 993, 493))
t.add("r/sheet/body/price", "快抢光 ¥23.99", NodeBounds(396, 568, 722, 635))
t.add("r/sheet/body/selected", "#快抢光#兰条纹 2XL建议130-150斤", NodeBounds(396, 719, 1053, 781))
t.add("r/sheet/body/qty", "", NodeBounds(396, 817, 614, 895), "android.widget.LinearLayout")
t.add("r/sheet/body/qty/dec", "", NodeBounds(396, 817, 462, 895), "android.widget.ImageView", clickable = true, description = "减少数量")
t.add("r/sheet/body/qty/input", "1", NodeBounds(463, 817, 547, 895), "android.widget.EditText", clickable = true)
t.add("r/sheet/body/qty/inc", "", NodeBounds(548, 817, 614, 895), "android.widget.ImageView", clickable = true, description = "增加数量")
val list = "r/sheet/body/list"
t.add(list, "", NodeBounds(0, 933, 1080, 2079), "androidx.recyclerview.widget.RecyclerView", scrollable = true)
t.add("$list/color", "", NodeBounds(0, 933, 1080, 1483), "android.widget.LinearLayout")
t.add("$list/color/h", "颜色分类", NodeBounds(36, 958, 216, 1019))
val blue = "$list/color/o0"
t.add(blue, "", NodeBounds(36, 1044, 352, 1447), "android.view.ViewGroup", clickable = true, selected = true, description = " 兰条纹")
t.add("$blue/img", "", NodeBounds(36, 1044, 352, 1360), "android.widget.ImageView", clickable = true, selected = true, description = " 兰条纹")
t.add("$blue/big", "", NodeBounds(36, 1044, 147, 1155), "android.widget.ImageView", clickable = true, selected = true, description = "打开大图")
t.add("$blue/l", "", NodeBounds(36, 1339, 352, 1447), "android.widget.LinearLayout", selected = true)
t.add("$blue/l/t", " 兰条纹\n 零差评", NodeBounds(36, 1339, 352, 1447), clickable = true, selected = true)
val white = "$list/color/o1"
t.add(white, "", NodeBounds(382, 1044, 698, 1447), "android.view.ViewGroup", clickable = true, description = "白条纹")
t.add("$white/img", "", NodeBounds(382, 1044, 698, 1360), "android.widget.ImageView", clickable = true, description = "白条纹")
t.add("$white/big", "", NodeBounds(382, 1044, 493, 1155), "android.widget.ImageView", clickable = true, description = "打开大图")
t.add("$white/badge", "快要抢光", NodeBounds(521, 1044, 698, 1093))
t.add("$white/l", "", NodeBounds(382, 1339, 698, 1447), "android.widget.LinearLayout")
t.add("$white/l/t", "白条纹", NodeBounds(382, 1339, 698, 1447), clickable = true)
t.add("$list/size", "", NodeBounds(0, 1483, 1080, 1938), "android.widget.LinearLayout")
t.add("$list/size/row", "", NodeBounds(36, 1483, 1044, 1564), "android.widget.LinearLayout", clickable = true)
t.add("$list/size/row/h", "尺码", NodeBounds(36, 1497, 126, 1550))
listOf(
Triple("L建议100-115斤", NodeBounds(36, 1576, 371, 1661), false),
Triple("XL建议115-130斤", NodeBounds(401, 1576, 762, 1661), false),
Triple("2XL建议130-150斤", NodeBounds(36, 1691, 430, 1776), true),
Triple("3XL建议150-170斤", NodeBounds(460, 1691, 853, 1776), false),
Triple("4XL建议170-190斤", NodeBounds(36, 1806, 431, 1891), false),
).forEachIndexed { index, (label, bounds, selected) ->
t.add("$list/size/o$index", "", bounds, "android.view.ViewGroup", clickable = true)
t.add("$list/size/o$index/t", label, bounds, clickable = true, selected = selected)
}
t.add("r/sheet/pay", "", NodeBounds(0, 2079, 1080, 2178), "android.view.ViewGroup", clickable = true)
t.add("r/sheet/pay/t", "使用#微信支付,更换先用后付可0元下单", NodeBounds(112, 2100, 930, 2157))
t.add("r/sheet/submit", "", NodeBounds(0, 2181, 1080, 2328), "android.widget.FrameLayout", clickable = true)
t.add("r/sheet/submit/t", "提交订单 ¥23.99", NodeBounds(365, 2225, 715, 2284))
return t.snapshot()
}
fun productDetailPage(): UiSnapshot {
val t = Tree(2216)
t.add("r/pager", "示例商品标题连衣裙夏季新款示例", NodeBounds(0, 84, 1080, 1100), "androidx.viewpager.widget.ViewPager")
t.add("r/price", "¥19.6", NodeBounds(36, 1120, 300, 1200))
t.add("r/sales", "已拼1万+件", NodeBounds(700, 1120, 1044, 1200))
t.add("r/deliver", "配送至 示例省示例市", NodeBounds(36, 1300, 1044, 1360))
t.add("r/spec", "请选择 颜色分类 尺码", NodeBounds(36, 1400, 1044, 1480), clickable = true)
t.add("r/review", "商品评价(2000+)", NodeBounds(36, 1500, 1044, 1580), clickable = true)
t.add("r/single", "单独购买", NodeBounds(420, 2080, 720, 2200), clickable = true)
t.add("r/group", "发起拼单", NodeBounds(740, 2080, 1060, 2200), clickable = true)
return t.snapshot()
}
fun reviewPage(): UiSnapshot {
val t = Tree(2216)
t.add("r/title", "商品评价", NodeBounds(400, 120, 680, 200))
t.add("r/all", "全部", NodeBounds(36, 260, 150, 320), clickable = true)
t.add("r/media", "有图/视频", NodeBounds(170, 260, 360, 320), clickable = true)
t.add("r/body", "质量不错,微信支付很方便", NodeBounds(36, 400, 1044, 500))
return t.snapshot()
}
fun addressListPage(): UiSnapshot {
val t = Tree(2216)
t.add("r/title", "收货地址", NodeBounds(400, 120, 680, 200))
listOf(0, 1).forEach { index ->
val top = 300 + index * 260
t.add("r/card$index", "", NodeBounds(0, top, 1080, top + 240), "android.view.ViewGroup", clickable = true)
t.add("r/card$index/phone", "测试 $FAKE_PHONE", NodeBounds(36, top + 20, 700, top + 80))
t.add("r/card$index/detail", "示例省示例市示例路${index + 1}号", NodeBounds(36, top + 100, 900, top + 160))
t.add("r/card$index/edit", "修改", NodeBounds(950, top + 20, 1044, top + 80), clickable = true)
}
return t.snapshot()
}
fun addressEditPage(): UiSnapshot {
val t = Tree(2216)
t.add("r/title", "修改收货地址", NodeBounds(360, 120, 720, 200))
t.add("r/name", "测试收货人", NodeBounds(180, 190, 900, 270), "android.widget.EditText", clickable = true)
t.add("r/phone", "13900000000", NodeBounds(180, 290, 900, 370), "android.widget.EditText", clickable = true)
t.add("r/detailLabel", "详细地址", NodeBounds(20, 400, 160, 460))
t.add("r/detail", "示例路1号", NodeBounds(180, 390, 900, 480), "android.widget.EditText", clickable = true)
t.add("r/save", "保存", NodeBounds(36, 2080, 1044, 2180), clickable = true)
return t.snapshot()
}
fun paymentMethodDialog(): UiSnapshot {
val t = Tree(2216)
t.add("r/title", "选择支付方式", NodeBounds(360, 1200, 720, 1280))
t.add("r/wechat", "", NodeBounds(0, 1300, 1080, 1420), "android.view.ViewGroup", clickable = true)
t.add("r/wechat/t", "微信支付", NodeBounds(120, 1330, 400, 1390))
t.add("r/later", "", NodeBounds(0, 1440, 1080, 1560), "android.view.ViewGroup", clickable = true)
t.add("r/later/t", "先用后付", NodeBounds(120, 1470, 400, 1530))
t.add("r/ok", "确定", NodeBounds(36, 2080, 1044, 2180), clickable = true)
return t.snapshot()
}
fun postSubmitPaymentPage(): UiSnapshot {
val t = Tree(2216)
t.add("r/amount", "¥19.60", NodeBounds(360, 300, 720, 400))
t.add("r/wechat", "", NodeBounds(0, 600, 1080, 720), "android.view.ViewGroup", clickable = true)
t.add("r/wechat/t", "微信支付", NodeBounds(120, 630, 400, 690))
t.add("r/pay", "立即支付", NodeBounds(36, 2080, 1044, 2180), clickable = true)
return t.snapshot(activity = "com.xunmeng.pinduoduo.app_pay.core.PayActivity")
}
fun quantityOnlyPage(): UiSnapshot {
val t = Tree(2216)
t.add("r/input", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
return t.snapshot()
}
}
@@ -0,0 +1,879 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.SpecPanelFixtures.Sheet
import cn.ilapage.goauto.agent.automation.FreshActionResult
import cn.ilapage.goauto.agent.automation.NodeBounds
import cn.ilapage.goauto.agent.automation.ParsedPddScreen
import cn.ilapage.goauto.agent.automation.PddScreenParser
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
import cn.ilapage.goauto.agent.automation.PurchaseLiveException
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.SpecPanelStabilityPolicy
import cn.ilapage.goauto.agent.automation.SpecPanelType
import cn.ilapage.goauto.agent.automation.SwipeDirection
import cn.ilapage.goauto.agent.automation.UiSnapshot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** #331 spec-panel recognition, same-container check, hints, bounded waits and strict submit targets. */
class SpecPanelRecognitionTest {
private fun parse(snapshot: UiSnapshot) =
PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "719834019024", null)
// --- Recognition -------------------------------------------------------
@Test
fun `both sanitized unselected sheet samples are recognized as spec panels`() {
val first = parse(SpecPanelFixtures.sheet())
val samsung = parse(SpecPanelFixtures.sheet(Sheet(screenBottom = 2020, submit = "hidden")))
listOf(first, samsung).forEach { screen ->
assertTrue(screen.specPanelOpen)
assertTrue(screen.hasAddressEntry)
assertTrue(screen.hasPaymentEntry)
assertTrue(screen.hasQuantityInput)
}
// The zero-size submit label is not visible, so the submit action is absent.
assertFalse(samsung.hasOrderSubmitAction)
}
@Test
fun `live shaped samples are recognized only through the required evidence fallback`() {
listOf(2216, 2020).forEach { bottom ->
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = bottom)))
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
assertTrue(screen.specPanelOpen)
assertEquals(0, screen.panelScrollableCount)
assertFalse(screen.hasSelectionSummary)
assertFalse(screen.hasQuantityControls)
assertFalse(screen.hasQuantityAdjustControls)
assertFalse(screen.hasOrderSubmitAction)
assertEquals(listOf("黑色 示例款", "卡其色 示例款"), screen.dimensions.single().values.map { it.text })
}
}
@Test
fun `zero size submit label sample is still recognized because submit is auxiliary`() {
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2020)))
assertFalse(screen.hasOrderSubmitAction)
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
}
@Test
fun `missing address entry is not recognized`() {
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(address = false)))
assertFalse(screen.hasAddressEntry)
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
}
@Test
fun `missing payment entry is not recognized`() {
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(payment = false)))
assertFalse(screen.hasPaymentEntry)
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
}
@Test
fun `missing quantity input is not recognized`() {
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(quantityInput = false)))
assertFalse(screen.hasQuantityInput)
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
}
@Test
fun `required items without any auxiliary evidence are not recognized`() {
val t = SpecPanelFixtures.Tree(2216)
t.add("r/panel", "", NodeBounds(0, 300, 1080, 2216), "android.widget.LinearLayout")
t.add("r/panel/addr", "", NodeBounds(0, 330, 1080, 484), "android.view.ViewGroup", clickable = true)
t.add("r/panel/addr/phone", "测试,${SpecPanelFixtures.FAKE_PHONE}", NodeBounds(412, 346, 993, 395))
t.add("r/panel/qty", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
t.add("r/panel/pay", "", NodeBounds(0, 2036, 1080, 2135), "android.view.ViewGroup", clickable = true)
t.add("r/panel/pay/t", "使用微信支付", NodeBounds(112, 2057, 930, 2114))
val screen = parse(t.snapshot())
assertTrue(screen.hasRequiredPanelEvidence)
assertTrue(screen.requiredEvidenceSameContainer)
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
}
@Test
fun `quantity input alone is not recognized`() {
val screen = parse(SpecPanelFixtures.quantityOnlyPage())
assertTrue(screen.hasQuantityInput)
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
assertFalse(screen.specPanelOpen)
}
@Test
fun `non panel pages are never recognized as spec panels`() {
mapOf(
"product" to SpecPanelFixtures.productDetailPage(),
"review" to SpecPanelFixtures.reviewPage(),
"addressList" to SpecPanelFixtures.addressListPage(),
"addressEdit" to SpecPanelFixtures.addressEditPage(),
"paymentDialog" to SpecPanelFixtures.paymentMethodDialog(),
"paymentPage" to SpecPanelFixtures.postSubmitPaymentPage(),
).forEach { (name, snapshot) ->
val screen = parse(snapshot)
assertEquals(name, SpecPanelType.UNKNOWN, screen.specPanelType)
assertFalse(name, screen.specPanelOpen)
}
}
@Test
fun `whole sheet clickable containers do not count as an address row`() {
val t = SpecPanelFixtures.Tree(2216)
t.add("r/sheet", "", NodeBounds(0, 84, 1080, 2216), "android.widget.LinearLayout", clickable = true)
t.add("r/sheet/phone", "测试,${SpecPanelFixtures.FAKE_PHONE}", NodeBounds(412, 346, 993, 395))
assertFalse(parse(t.snapshot()).hasAddressEntry)
}
@Test
fun `single dimension sheet without adjust buttons is recognized`() {
val screen = parse(SpecPanelFixtures.sheet(Sheet(sizeDimension = false, adjustButtons = false, summary = false, listScrollable = false, submit = "none")))
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
assertEquals(listOf("color"), screen.dimensions.map { it.key })
}
@Test
fun `required evidence diagnostics hold booleans and counts only`() {
val screen = parse(SpecPanelFixtures.liveShapedSheet())
val summary = screen.requiredEvidenceSummary()
assertEquals("addr=1;pay=1;qin=1;adj=0;summary=0;close=1;submit=0;same=1", summary)
assertFalse(summary.contains("****"))
assertFalse(summary.contains("示例"))
}
@Test
fun `goods 8580 shaped sheet is recognized through the required evidence fallback`() {
val screen = parse(SpecPanelFixtures.sample8580ShapedSheet())
// Shape of the 13 live failures: scrollables=1;headings=2;options=9;
// summary=false;quantity=true;orderAction=false (was UNKNOWN before #331).
assertEquals(1, screen.panelScrollableCount)
assertEquals(2, screen.panelHeadingCount)
assertEquals(9, screen.panelOptionCount)
assertFalse(screen.hasSelectionSummary)
assertTrue(screen.hasQuantityControls)
assertFalse(screen.hasOrderSubmitAction)
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
assertTrue(screen.requiredEvidenceSameContainer)
}
// --- Same panel container --------------------------------------------
@Test
fun `required items inside one panel container are recognized`() {
listOf(SpecPanelFixtures.liveShapedSheet(), SpecPanelFixtures.sample8580ShapedSheet()).forEach { snapshot ->
val screen = parse(snapshot)
assertTrue(screen.requiredEvidenceSameContainer)
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
}
}
@Test
fun `address dialog payment dialog and a separate quantity input are not a spec panel`() {
listOf(false, true).forEach { wrapper ->
val screen = parse(SpecPanelFixtures.scatteredRequiredItemsPage(fullScreenWrapper = wrapper))
// Every required item exists somewhere on screen, plus auxiliary
// options and a close control, but not inside one panel container.
assertTrue(screen.hasAddressEntry)
assertTrue(screen.hasPaymentEntry)
assertTrue(screen.hasQuantityInput)
assertTrue(screen.dimensions.any { it.values.isNotEmpty() })
assertFalse(screen.requiredEvidenceSameContainer)
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
assertFalse(screen.specPanelOpen)
assertEquals(listOf(ParsedPddScreen.NOT_SAME_CONTAINER_HINT), screen.requiredEvidenceHints())
}
}
// --- Purchaser hints for missing required items --------------------------
@Test
fun `missing required items produce purchaser hints`() {
val noPay = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(payment = false)))
val noAddr = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(address = false)))
val noQty = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(quantityInput = false)))
assertEquals(listOf("未找到支付入口:请确认 PDD 默认支付方式为微信支付"), noPay.requiredEvidenceHints())
assertEquals(listOf("未找到收货地址入口:请确认 PDD 已设置默认收货地址"), noAddr.requiredEvidenceHints())
assertEquals(listOf("未找到购买数量输入框"), noQty.requiredEvidenceHints())
assertTrue(parse(SpecPanelFixtures.sample8580ShapedSheet()).requiredEvidenceHints().isEmpty())
// Unrelated pages without any required item or option do not get environment hints.
assertTrue(parse(SpecPanelFixtures.reviewPage()).requiredEvidenceHints().isEmpty())
}
@Test
fun `spec panel evidence failure message carries the missing payment hint`() {
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sample8580ShapedSheet(Sheet(payment = false))))
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertTrue(outcome.message.orEmpty().contains("未找到支付入口:请确认 PDD 默认支付方式为微信支付"))
assertFalse(outcome.message.orEmpty().contains("****"))
assertEquals(0, driver.backs)
}
@Test
fun `spec panel evidence failure message carries the missing address and quantity hints`() {
mapOf(
Sheet(address = false) to "未找到收货地址入口:请确认 PDD 已设置默认收货地址",
Sheet(quantityInput = false) to "未找到购买数量输入框",
).forEach { (options, hint) ->
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sample8580ShapedSheet(options)))
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertTrue(outcome.message.orEmpty(), outcome.message.orEmpty().contains(hint))
}
}
@Test
fun `address save timeout message carries the missing address hint`() {
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
val driver = AddressSaveDriver(listOf(loading)) { loading }
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg35") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertTrue(error.message.orEmpty().contains("未找到收货地址入口:请确认 PDD 已设置默认收货地址"))
}
// --- Bounded wait after the first spec-entry click -----------------------
@Test
fun `loading sheet is accepted after required evidence becomes complete and stable without back`() {
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
val ready = SpecPanelFixtures.liveShapedSheet()
val driver = SpecEntryDriver(listOf(loading, loading, loading, ready, ready))
val diagnostics = mutableListOf<String>()
val steps = mutableListOf<String>()
PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, stepChanged = steps::add, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertTrue(steps.contains("selectSpec"))
assertEquals(0, driver.backs)
assertTrue(diagnostics.any { it.startsWith("specPanelWait;outcome=opened;") && it.contains("backPressed=false") })
}
@Test
fun `always incomplete sheet fails explicitly at the wait bound without back`() {
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
val driver = SpecEntryDriver(listOf(loading))
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertEquals(0, driver.backs)
val timeout = diagnostics.single { it.startsWith("specPanelWait;outcome=timeout;") }
assertTrue(timeout.contains("waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};"))
}
@Test
fun `fallback sheet whose structure keeps changing is never accepted early`() {
val first = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2216))
val second = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2200))
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, parse(second).specPanelType)
val driver = SpecEntryDriver(listOf(first, second), cycle = true)
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertEquals(0, driver.backs)
assertFalse(diagnostics.any { it.startsWith("specPanelWait;outcome=opened") })
assertTrue(diagnostics.any { it.contains("waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};") })
}
@Test
fun `stronger panel type is still accepted immediately`() {
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sheet()))
val diagnostics = mutableListOf<String>()
val steps = mutableListOf<String>()
PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, stepChanged = steps::add, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertTrue(steps.contains("selectSpec"))
assertFalse(diagnostics.any { it.startsWith("specPanelWait;") })
}
// --- Bounded wait after the address save ---------------------------------
@Test
fun `address save waits for the loading sheet without pressing back`() {
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
val driver = AddressSaveDriver(listOf(loading, loading, loading)) { expected -> readySheet(expected) }
val diagnostics = mutableListOf<String>()
val proof = PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg31")
assertEquals("_cg31", proof.suffix)
assertEquals(0, driver.backs)
val done = diagnostics.single { it.startsWith("addressSaveWait;") }
assertTrue(done.contains("outcome=final_evidence"))
assertTrue(done.contains("waitedMs=${2 * SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS};"))
assertTrue(done.contains("backPressed=false"))
assertFalse(diagnostics.any { it.contains("_cg31") || it.contains("示例") || it.contains("****") })
}
@Test
fun `address save with a sheet that never completes fails at the bound without back`() {
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
val driver = AddressSaveDriver(listOf(loading)) { loading }
val diagnostics = mutableListOf<String>()
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg32") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(0, driver.backs)
assertTrue(diagnostics.single().contains("outcome=timeout;waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};"))
assertFalse(error.message.orEmpty().contains("示例"))
}
@Test
fun `address save with an unstable fallback sheet does not decide early`() {
val first = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2216))
val second = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2200))
val driver = AddressSaveDriver(listOf(first, second), cycle = true) { first }
val diagnostics = mutableListOf<String>()
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg33") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(0, driver.backs)
assertTrue(diagnostics.single().contains("outcome=timeout"))
}
@Test
fun `address save with a stable fallback sheet is accepted as purchase panel without back`() {
val sheet = SpecPanelFixtures.liveShapedSheet()
val driver = AddressSaveDriver(listOf(sheet)) { sheet }
val diagnostics = mutableListOf<String>()
// The stable sheet has no saved suffix and no scroll region, so the
// existing in-panel restore path fails closed; it must not press Back.
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg34") }
.exceptionOrNull() as PurchaseLiveException
assertEquals(0, driver.backs)
assertTrue(diagnostics.single().contains("outcome=stable_required;waitedMs=${SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS};"))
assertNotEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
}
// --- Strict final submit click -------------------------------------------
// #335: the order button's text keeps changing across PDD builds (促销文案/价格文案),
// so the click target is now the bottom-most clickable, enabled, non-zero-size node of
// a *recognized* spec panel, not a text match. These fixtures build on
// [SpecPanelFixtures.sheet] (address + payment + quantity present, so #331 recognizes
// the panel) with `submit = "none"` and then splice in the bottom-row scenario under
// test, mirroring how [readySheet] already extends a base fixture elsewhere in this file.
@Test
fun `an unrecognized page never yields a submit target`() {
// #335: the button's own text no longer has to match any known marker; this used
// to fail as "labelMismatch" and must now succeed instead (see the dedicated
// wording tests below). Only "the page is not a recognized spec panel" still blocks.
val driver = StaticDriver(SpecPanelFixtures.addressListPage())
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `a bottom node whose own label carries a payment word is never a click target`() {
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val snapshot = recognizedBase.copy(
nodes = recognizedBase.nodes + node("r/submit", "立即支付", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
)
val driver = StaticDriver(snapshot)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
// The project-wide page-problem guard (pageProblem) rejects any visible payment
// wording before the submit-target logic even runs; either way, nothing is clicked.
assertEquals("PURCHASE_PAYMENT_FORBIDDEN", error?.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `a bottom node whose subtree carries a payment word is never a click target`() {
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val snapshot = recognizedBase.copy(
nodes = recognizedBase.nodes + listOf(
node("r/submit", "", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
node("r/submit/t", "确认支付 ¥23.99", NodeBounds(157, 2150, 922, 2210), parent = "r/submit"),
),
)
val driver = StaticDriver(snapshot)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_PAYMENT_FORBIDDEN", error?.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `a bottom node whose own label is go-pay is never a click target`() {
// "去支付" is not in PAYMENT_MARKERS (so the global pageProblem guard does not
// trip on it — a real 待付款/去支付 order-result page must stay usable), but it
// must still block the submit-target logic via SUBMIT_TARGET_BLOCKED_MARKERS.
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val snapshot = recognizedBase.copy(
nodes = recognizedBase.nodes + node("r/submit", "去支付", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
)
val driver = StaticDriver(snapshot)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `a bottom node whose own label is pay-fee is never a click target`() {
// "付款" alone is not in PAYMENT_MARKERS either (UNPAID_MARKERS' "待付款" contains
// it, so adding it to PAYMENT_MARKERS would misclassify every unpaid-order page),
// but it must still block the submit-target logic here.
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val snapshot = recognizedBase.copy(
nodes = recognizedBase.nodes + node("r/submit", "付款", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
)
val driver = StaticDriver(snapshot)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `an invisible payment word in the bottom node subtree still blocks the click`() {
// The project-wide pageProblem guard only scans *visible* labels; an invisible
// descendant would slip past it, so the submit-target logic must catch it on its
// own (#335 safety condition 3 covers "自身或子树文字" regardless of visibility).
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val hiddenPaymentText = SnapshotNode(
"r/submit/hidden", "r/submit", "输入支付密码", null, null, "android.widget.TextView",
NodeBounds(0, 0, 0, 0), false, false, false, false, true, false,
)
val snapshot = recognizedBase.copy(
nodes = recognizedBase.nodes + listOf(
node("r/submit", "", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
node("r/submit/t", "提交订单", NodeBounds(157, 2150, 922, 2210), parent = "r/submit"),
hiddenPaymentText,
),
)
val driver = StaticDriver(snapshot)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `zero size bottom node is ignored in favor of the next non-zero labelled node`() {
// The click target is the labelled LEAF node (#335 2nd revision); the button
// carries its own label directly here (no separate text child) to make the
// assertion check the real click target.
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val snapshot = base.copy(
nodes = base.nodes + listOf(
node("r/real-submit", "选择颜色分类及尺码后,提交订单", NodeBounds(0, 2135, 1080, 2200), clickable = true, parent = "r"),
// Zero-*area* (right==left) and lower on screen: must never win despite
// having its own non-blank label and a clickable ancestor.
node("r/decoy", "¥0.0", NodeBounds(500, 2200, 500, 2216), clickable = true, parent = "r"),
),
)
val driver = StaticDriver(snapshot)
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
assertEquals(listOf("选择颜色分类及尺码后,提交订单"), driver.clicked)
}
@Test
fun `two side by side bottom buttons pick the rightmost and are reported as a tie`() {
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val diagnostics = mutableListOf<String>()
val snapshot = base.copy(
nodes = base.nodes + listOf(
// Avoid quickBuyAliases ("现在买") in either label: it would flip specPanelType
// to QUICK_CONFIRMATION, which is intentionally outside the recognized set here.
node("r/a", "限时优惠 ¥19.6", NodeBounds(0, 2000, 540, 2216), clickable = true, parent = "r"),
node("r/b", "大促价,¥16.9", NodeBounds(540, 2000, 1080, 2216), clickable = true, parent = "r"),
),
)
val driver = StaticDriver(snapshot)
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).submitOrderOnce()
// r/b is the rightmost of the tied bottom row, so it is the one clicked.
assertEquals(listOf("大促价,¥16.9"), driver.clicked)
assertTrue(diagnostics.any { it.contains("outcome=ok") && it.contains("rowTie=1") })
}
@Test
fun `unique visible submit button is still clickable exactly once regardless of its wording`() {
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val snapshot = base.copy(
nodes = base.nodes + node("r/submit", "提交订单", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
)
val driver = StaticDriver(snapshot)
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
assertEquals(listOf("提交订单"), driver.clicked)
}
@Test
fun `task 570 promo priced bottom row picks the bottom-right-most labelled leaf`() {
// #335 origin: goods 8580 task 570, the bottom button's text was split across
// three sibling text nodes ("大促价," / "仅 ¥30.8" / "¥39.9") instead of one
// "提交订单"-prefixed label. This checks finalSubmitTargets' own choice at the
// SnapshotNode level (fast, no re-find simulation); the full live re-find +
// climb-to-clickable-ancestor path is covered separately below with
// ReFindingDriver, which is what actually caught the task 594 regression.
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val snapshot = base.copy(
nodes = base.nodes + listOf(
node("r/submit", "", NodeBounds(0, 2181, 1080, 2216), clickable = true, parent = "r"),
node("r/submit/l1", "大促价,", NodeBounds(40, 2190, 300, 2210), parent = "r/submit"),
node("r/submit/l2", "仅 ¥30.8", NodeBounds(320, 2190, 600, 2210), parent = "r/submit"),
node("r/submit/l3", "¥39.9", NodeBounds(620, 2190, 780, 2210), parent = "r/submit"),
),
)
val driver = StaticDriver(snapshot)
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
// #335 2nd revision: the target is the labelled leaf (bottom edge tied across
// l1/l2/l3, rightmost wins), NOT the label-less "r/submit" container — a
// label-less target can never be re-found live (task 594).
assertEquals(listOf("¥39.9"), driver.clicked)
}
@Test
fun `task 570 leaf target is re-found live and the click lands on the clickable container`() {
// #335 2nd revision (task 594 real-device failure): StaticDriver's clickFresh just
// records target.label directly, so it cannot catch a target that a REAL driver
// could never re-find. ReFindingDriver instead emulates
// GoAutoAccessibilityService.clickFreshDetailed's real semantics: find the live
// node by (label, className, center within ±32), then climb to the nearest live
// clickable ancestor and click THAT.
val base = SpecPanelFixtures.sheet(Sheet(screenBottom = 2400, submit = "none"))
val snapshot = base.copy(
nodes = base.nodes + listOf(
node("r/sheet/submit", "", NodeBounds(0, 2181, 1080, 2328), clickable = true, parent = "r/sheet"),
node("r/sheet/submit/l1", "大促价,", NodeBounds(364, 2225, 568, 2284), parent = "r/sheet/submit"),
node("r/sheet/submit/l2", "仅 ¥30.8", NodeBounds(568, 2225, 748, 2284), parent = "r/sheet/submit"),
node("r/sheet/submit/l3", "¥39.9", NodeBounds(748, 2222, 896, 2287), parent = "r/sheet/submit"),
),
)
val driver = ReFindingDriver(snapshot)
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
// finalSubmitTargets aims at "r/sheet/submit/l3" ("¥39.9", the bottom-right-most
// labelled leaf); the real click must land on its clickable ancestor, the
// FrameLayout "r/sheet/submit" — not on the leaf itself.
assertEquals("r/sheet/submit", driver.clickedPath)
}
@Test
fun `Samsung 31px bottom bar with only a zero-size label fails explicitly instead of clicking the payment row above it`() {
// #335 (3rd revision): reviewer cross-check on real dumps found that on this
// Samsung sample the bottom-most row is a 31px FrameLayout whose only text node
// is [0,0][0,0] (invisible), while the payment-method row directly above it has a
// real, visible label ("使用#微信支付,更换先用后付可0元下单"). A naive whole-panel
// leaf scan falls through to that payment row's text and the climb lands the click
// on "change payment method" — which must never happen. SpecPanelFixtures'
// `submit = "hidden"` branch already models exactly this shape.
val snapshot = SpecPanelFixtures.sheet(Sheet(submit = "hidden"))
val diagnostics = mutableListOf<String>()
val driver = StaticDriver(snapshot)
val error = runCatching {
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).submitOrderOnce()
}.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
assertTrue(driver.clicked.isEmpty())
assertTrue(diagnostics.any { it.contains("outcome=bottom_row_unlabelled") })
}
@Test
fun `a payment-method row as the bottom-most clickable row is never a submit target`() {
// Without a submit region at all, the payment-method row ("使用#微信支付,更换先用
// 后付可0元下单") is itself the bottom-most clickable row. It must be rejected by
// its own alias match, not merely skipped in favor of something else.
val snapshot = SpecPanelFixtures.sheet(Sheet(submit = "none"))
val diagnostics = mutableListOf<String>()
val driver = StaticDriver(snapshot)
val error = runCatching {
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).submitOrderOnce()
}.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
assertTrue(driver.clicked.isEmpty())
assertTrue(diagnostics.any { it.contains("outcome=row_blocked") })
}
@Test
fun `a clickable node outside the recognized panel container is never chosen even if it is bottom most`() {
// A hand-built #331 REQUIRED_EVIDENCE panel ("r/panel", bounded, not full-screen)
// with its own valid submit row, plus an unrelated clickable bar further down the
// same accessibility tree but OUTSIDE "r/panel" — e.g. an underlying goods-detail
// page's "单独购买" bar still present behind the sheet. It is visually and
// structurally lower on screen (bottom=2400 vs the panel submit's bottom=2100),
// so a naive whole-screen "bottom-most clickable node" scan would wrongly pick it.
val t = SpecPanelFixtures.Tree(2400)
t.add("r/panel", "", NodeBounds(0, 300, 1080, 2100), "android.view.ViewGroup")
t.add("r/panel/close", "", NodeBounds(975, 320, 1050, 390), "android.widget.ImageView", clickable = true, description = "关闭")
t.add("r/panel/addr", "", NodeBounds(0, 340, 1080, 460), "android.view.ViewGroup", clickable = true)
t.add("r/panel/addr/phone", "测试,${SpecPanelFixtures.FAKE_PHONE},示例省示例市", NodeBounds(40, 360, 900, 440))
t.add("r/panel/pay", "", NodeBounds(0, 500, 1080, 600), "android.view.ViewGroup", clickable = true)
t.add("r/panel/pay/t", "微信支付", NodeBounds(40, 520, 400, 580))
t.add("r/panel/qty", "1", NodeBounds(400, 650, 600, 720), "android.widget.EditText", clickable = true)
t.add("r/panel/submit", "提交订单", NodeBounds(0, 2020, 1080, 2100), "android.widget.FrameLayout", clickable = true)
// Outside "r/panel": lower on screen, but not part of the recognized panel.
t.add("r/outsideBar", "单独购买", NodeBounds(0, 2200, 1080, 2400), "android.widget.FrameLayout", clickable = true)
val snapshot = t.snapshot()
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
val driver = StaticDriver(snapshot)
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
assertEquals(listOf("提交订单"), driver.clicked)
}
@Test
fun `no determinable panel container fails explicitly instead of guessing`() {
// Recognized (NORMAL_SCROLLABLE, via the headed-scrollable + selection-summary
// branch, which does not require a quantity input at all) but with NO quantity
// input anywhere: panelContainerBounds cannot determine a container, so
// finalSubmitTargets must fail explicitly rather than fall back to a whole-screen
// scan that could pick an unrelated bottom-most clickable node.
val recognizedNoQuantity = SpecPanelFixtures.sheet(
Sheet(quantityInput = false, adjustButtons = false, submit = "none"),
)
val screen = PddScreenParser.parse(recognizedNoQuantity, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
assertEquals(SpecPanelType.NORMAL_SCROLLABLE, screen.specPanelType)
val snapshot = recognizedNoQuantity.copy(
nodes = recognizedNoQuantity.nodes + node("r/submit", "提交订单", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
)
val driver = StaticDriver(snapshot)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
assertTrue(driver.clicked.isEmpty())
}
// --- Helpers -------------------------------------------------------------
private fun readySheet(expected: String): UiSnapshot {
// #335 (3rd revision): [SpecPanelFixtures.liveShapedSheet] hardcodes a "hidden"
// 31px submit placeholder (its own separate `submit`-shape fixture concern,
// unrelated to this address-save-wait test). With the row-then-leaf submit-target
// rule that placeholder would tie with "r/final" on the bottom edge and can win
// the rightmost tie-break, so this helper builds the same shape directly with
// `submit = "none"` instead, leaving "r/final" as the only, unambiguous bottom row.
val base = SpecPanelFixtures.sheet(
Sheet(summary = false, listScrollable = false, sizeDimension = false, adjustButtons = false, submit = "none"),
)
val extra = listOf(
node("r/sheet/body/addr/a/saved", expected, NodeBounds(132, 460, 937, 480), parent = "r/sheet/body/addr/a"),
node("r/final", "", NodeBounds(600, 2150, 1060, 2216), clickable = true, parent = "r"),
node("r/final/t", "提交订单", NodeBounds(620, 2160, 1040, 2210), parent = "r/final"),
)
return base.copy(nodes = base.nodes + extra)
}
private fun input() = PurchaseExecutionInput(
taskId = 331,
executionMode = "rehearsal",
phase = "purchase",
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
goodsId = "719834019024",
mappedColor = "黑色 示例款",
mappedSize = "",
quantity = 1,
minUnitPriceCent = 1_000,
maxUnitPriceCent = 3_000,
)
private fun rule() = """{
"schemaVersion":1,
"ruleType":"pddPurchase",
"requiredCapabilities":["purchase.rehearsal.v1"],
"actions":[
{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}
]
}"""
private open class BaseDriver : PurchaseUiDriver {
var backs = 0
val clicked = mutableListOf<String>()
override fun capture(): UiSnapshot = UiSnapshot(null, null, emptyList())
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
return FreshActionResult.SUCCESS
}
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
override fun backPurchase(): Boolean {
backs++
return false
}
}
private class StaticDriver(private val snapshot: UiSnapshot) : BaseDriver() {
override fun capture() = snapshot
}
/**
* #335 (2nd revision): emulates `GoAutoAccessibilityService.clickFreshDetailed`'s real
* re-find semantics against a fixed, static "live" node list — match by (own label,
* className, center within ±32 — every fixture node here already carries its own
* label so this is equivalent to `preferredOrDescendantLabel()`), then climb to the
* nearest clickable ancestor and click THAT, exactly like the real accessibility
* service. Exposes the PATH of the node that actually received the click, so a test
* can assert the tap landed on the container, not the labelled leaf `finalSubmitTargets`
* aimed at.
*/
private class ReFindingDriver(private val liveSnapshot: UiSnapshot) : BaseDriver() {
var clickedPath: String? = null
override fun capture() = liveSnapshot
override fun clickFresh(target: SnapshotNode): FreshActionResult {
val byPath = liveSnapshot.nodes.associateBy { it.path }
val matches = liveSnapshot.nodes.filter { candidate ->
candidate.label == target.label && candidate.className == target.className &&
kotlin.math.abs(candidate.bounds.centerX - target.bounds.centerX) <= 32 &&
kotlin.math.abs(candidate.bounds.centerY - target.bounds.centerY) <= 32
}
if (matches.isEmpty()) return FreshActionResult.NOT_FOUND
if (matches.size != 1) return FreshActionResult.AMBIGUOUS
var node = matches.single()
if (target.clickable && !node.clickable) return FreshActionResult.NOT_FOUND
while (!node.clickable) {
node = node.parentPath?.let(byPath::get) ?: return FreshActionResult.FAILED
}
clickedPath = node.path
clicked += node.label
return FreshActionResult.SUCCESS
}
}
/** Product page first; after the spec-entry click it serves [panels] in order (the last repeats unless [cycle]). */
private class SpecEntryDriver(private val panels: List<UiSnapshot>, private val cycle: Boolean = false) : BaseDriver() {
private var opened = false
var panelCaptures = 0
override fun capture(): UiSnapshot {
if (!opened) return SpecPanelFixtures.productDetailPage()
val index = if (cycle) panelCaptures % panels.size else minOf(panelCaptures, panels.lastIndex)
panelCaptures++
return panels[index]
}
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
if (target.label.startsWith("请选择")) opened = true
return FreshActionResult.SUCCESS
}
}
/** Address flow driver: sheet -> address list -> editor -> [afterSave] samples -> [settled]. */
private class AddressSaveDriver(
private val afterSave: List<UiSnapshot>,
private val cycle: Boolean = false,
private val settled: (String) -> UiSnapshot,
) : BaseDriver() {
private var page = "sheet"
private var address = "示例路1号-old"
private var afterSaveCaptures = 0
override fun capture(): UiSnapshot = when (page) {
"sheet" -> SpecPanelFixtures.sheet()
"list" -> SpecPanelFixtures.Tree(2216).apply {
add("r/title", "收货地址", NodeBounds(400, 120, 680, 200))
add("r/modify", "", NodeBounds(900, 300, 1044, 380), "android.view.ViewGroup", clickable = true)
add("r/modify/t", "修改", NodeBounds(920, 310, 1030, 370))
}.snapshot()
"edit" -> SpecPanelFixtures.Tree(2216).apply {
add("r/title", "修改收货地址", NodeBounds(360, 120, 720, 200))
add("r/detailLabel", "详细地址", NodeBounds(20, 400, 160, 460))
add("r/detail", address, NodeBounds(180, 390, 900, 480), "android.widget.EditText", clickable = true)
add("r/save", "", NodeBounds(36, 2080, 1044, 2180), "android.view.ViewGroup", clickable = true)
add("r/save/t", "保存", NodeBounds(400, 2100, 700, 2160))
}.snapshot()
else -> {
val index = afterSaveCaptures++
when {
cycle -> afterSave[index % afterSave.size]
index < afterSave.size -> afterSave[index]
else -> settled(address)
}
}
}
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
when (target.label) {
"修改" -> page = "edit"
"保存" -> page = "saved"
}
return FreshActionResult.SUCCESS
}
override fun clickAddressEntryFresh(target: SnapshotNode): cn.ilapage.goauto.agent.automation.FreshClickOutcome {
page = "list"
return cn.ilapage.goauto.agent.automation.FreshClickOutcome(
FreshActionResult.SUCCESS,
cn.ilapage.goauto.agent.automation.FreshClickReason.SUCCESS,
1,
1,
)
}
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
address = value
return FreshActionResult.SUCCESS
}
}
private companion object {
fun node(path: String, text: String, bounds: NodeBounds, clickable: Boolean = false, parent: String?) = SnapshotNode(
path, parent, text, null, null, "android.widget.TextView", bounds, clickable, false, false, false, true, true,
)
}
}
@@ -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')});
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
wiki_revision: 3b78360779ae520f1ff9e51be3118a04cd549f51
synchronized_at: 2026-09-07T09:27:40Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:14:21Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Development-Workflow
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.-
wiki_revision: 62ddbe4469740c02ce4a6ca2fd1966a89a79322f
synchronized_at: 2026-09-05T07:16:44Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:14:27Z
<!-- gitea-wiki-mirror:end -->
# 开发工作流
+111 -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: 46a5f8234be2d55298779532058a0490a94d3167
synchronized_at: 2026-09-10T03:36:58Z
wiki_revision: 504e1ffaa2cf1989f55048ac7067d8af50fe3bf3
synchronized_at: 2026-09-22T02:56:22Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -411,3 +411,112 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 关联后读取现有采购准备;需要人工确认或不具备AI资格时停止自动链路并保留关联,其他情况调用原 AutoMatchMappings,沿用 #254 动态预算及原事务保存/上下文冲突保护。最后刷新实际处理阶段,不调用创建采购/订单接口。
- 运行期间冻结行、商品、手机;前端重复主动作禁用,切换偏好仍只影响未来操作。卸载或 keep-alive deactivated 令旧操作失效,禁止旧回调继续下一步;不声称已经取消服务端在途写入。异常仅有限GET回读和刷新,不自动重发POST。
- 验证命令:Server `go test -p 1 ./...`、`go build ./...`;Web `node --test tests/unit/syb-quick-link.test.cjs tests/unit/shopee-match-lifecycle.test.cjs`、`pnpm exec playwright test tests/e2e/syb-quick-link.spec.ts tests/e2e/shopee-match-lifecycle.spec.ts --workers=1`、受影响文件 ESLint、`pnpm build:prod`。SQLite 单连接并发测试不代替生产 MySQL 行锁验收;真实AI、生产数据、跨设备人工闭环待验证。
## SYB 行内一键替换(#258)
实现绑定 `2b01974`;部署与验收见 #258 发布证据。本节补充 #253,不改变原未关联入口。
- `syb-products/quick-link.js` 复用分体按钮、设备偏好和匹配/回读流程;已关联且允许处理阶段时同位置显示一键替换。先只读预览,固定任务、目标商品及规格版本;不同商品一次确认,同商品不重绑。
- `shopeeproduct/latest_collection.go` 在原 latestCollection 增加可选 replacement 模式。预览和提交均短事务锁定 SYB、Shopee、原PDD、设备、来源采集与目标PDD,核对旧关联及上下文。提交按预览 taskId 读取,不重新挑选后来采集的商品。
- Admin 路由注入 `purchase.ValidateQuickReplacement`,在事务内复用采购准备数据集及处理阶段计算,不调用 AI。缺少校验函数拒绝替换;客户端密钥不注入且仍拒绝 latestCollection。无新路由、权限、表或迁移。
- 关联完成后才执行现有 AI 匹配,不在数据库事务中等待 Provider;同一 SYB 正式采购创建与替换共用 SYB 行锁。历史采购快照不变。SQLite 合成测试覆盖冲突与串行并发请求,不能替代生产 MySQL 多连接锁验收。
## 辅助信息缺失的采集关联(#259)
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
## 采购单标题面板与地址就绪等待(#260、#261)
实现绑定 #260 `3c1ffa4`(执行器回归测试 `7de4318`)、#261 `5ea07f1`,Android 0.9.74 / versionCode87,基于0.9.73。安装及真实采购验收以工单证据为准;不代表 main 的 Android 已整合这些分支。
- 采购仅在安全规格入口点击返回SUCCESS后,记录本次页面包名/Activity作为内存上下文;开始新执行或页面身份改变时清除。`PddScreenParser` 的新增 purchaseEntryContext 默认关闭,仅该采购执行器传入;普通解析和采集原判定保留。
- 当前仍为同一PDD Activity、商品ID非空、没有页面风险、没有选中摘要,且唯一有标题规格滚动区、一个维度有值,同时具有关闭、数量、支付展示区域与下单操作证据时,单标题可识别为NORMAL_SCROLLABLE。只是允许进入既有精确规格探测/选择,不执行下单或付款,不全局认可UNKNOWN,不增加入口重复点击或滑动。
- 地址入口点击原有1000ms保留;地址列表等待增加唯一修改目标要求。点击修改和保存由500ms改为1000ms,其他通用动作间隔不变,无新配置项。
- 修改后不再只见“详细地址”文字就立即写入。最多50次、每次间隔200ms读取(不含读取耗时),检查PDD页面风险;唯一可见启用有内容的详细地址EditText,连续两次路径、边界、内容及Activity相同才继续原输入流程。始终零个/多个或不稳定时失败,不取第一个候选。
- 输入内容生成、输入后回读、保存后退出及返回规格面板、最终复核和单次下单边界不变。新增本机脱敏诊断 addressEditorCandidates / addressEditorPolls 两个整数,与task/attempt/device/ruleHash关联;不记录地址内容、原始树或整屏截图。
- 仅Android;无Server/Web、共享API字段、数据库迁移或权限变化。真实采购、地址写入及订单创建不作为自动安装验证,不执行付款。
### v2 商品入口补点与地址返回等待(#260、#261)
2026-09-10 实现绑定 #260 `713111d`、#261 `d0a82f3`,Android 0.9.75 / versionCode88;真实采购效果以工单验收为准,非 main 合并声明。
- 入口恢复不再把全页面支付方式展示文案单独视为支付页。当前包名/Activity、商品标题和安全入口必须与点击前一致,入口唯一且可用;已打开面板、关闭控件、数量、选中摘要、订单操作、风险/评价页面仍拒绝恢复。支付 Activity 或明确支付、订单、地址文本继续阻止补点。仅允许既有一次重新定位后的规格入口中心手势,随后仍验证面板,不循环、不扩展到地址保存或下单。
- 保存地址后的确认读取、必要时一次返回后的确认读取,增加最多50次、间隔200ms的有界就绪检查,页面捕获耗时另计。连续两次同一Activity满足原地址回读与确认面板条件后继续;完整信息不要求滚动区域。已明确到地址列表时保留原返回路径,不等待列表出现下单按钮;返回后增加1000ms缓冲。
- 就绪检查不修改原地址回读和结构下单入口规则;超时后只对既有识别的面板、唯一可用滚动区域执行原有有限滚动。零/多个区域仍失败,不凭地址入口或未知底部节点授权下单。最终精确规格、数量、价格、地址与单次下单边界不变。
- 私有采购诊断新增布尔 savedAddressMatched、savedPanelMatched;整数 savedLegacySubmitCandidates、savedAddressCards、savedAddressTargets、savedScrollCandidates、savedConfirmationPolls。只记录标量并绑定task/attempt/device/ruleHash,不保存地址、原始树或整屏图。
- CG109 v1现场读取未达到空闲状态,不能确认原失败具体缺哪项证据;v2覆盖迟到确认节点及可观测性,不将合成测试通过冒充真机修复验收。无Server/Web/API/迁移/权限变化,不需要服务器重启。
## 探测到正式采购的单次面板交接(#262)
2026-09-10 实现绑定 `2ea32fd`,Android0.9.76/code89;基于#260/#261,未声明合并main或真实采购验收通过。
- AgentForegroundService通过PurchaseProbeHandoff只在成功spec_probe且结束后新快照仍识别PDD规格面板时保留一次性内存交接。仅taskId/goodsId/ruleHash/deviceId、Activity、前台revision和单调时钟时间,不保留控件节点、地址或选中状态,不落盘;有效期180秒。
- 正式阶段消费时检查同任务/商品/规则/设备、前台revision及时间,消费后即清除。失配/超时/无任务或采集任务调度/结果异常/进程重启均不能沿用;普通手动再试不会继承已消费交接。现有服务端租约、互斥和匹配等待策略不变。
- 有交接时,执行器恢复限定的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验收。
### 会话类失败自动重试(#330)
- purchase/order_writeback_worker.go:restoreOrderWritebackClient 在 ImportCookiesJSON 后调用 sybclient.CheckSession,UserID<=0 显式判不可用;finishSessionUnavailable 复用 lease_expires_at 作为退避到期时间(maxSessionRetryAttempts=6,sessionRetryBackoff 5/10/15/30/30m),领取条件增加 failed+SYB_SESSION_UNAVAILABLE+到期+未达上限。无迁移。
- purchase/order_writeback.go:会话类失败的 CanSubmit 不受退避租约限制;手工重新提交 attempt_count 置 0。
- 验证:go test ./app/goauto/purchase(含 httptest 模拟 /am/user/get 与断言 syb_session 未删除)。
## 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替代,未执行付款、采购或线上写入。
## 规格面板识别回退与选项去重(#331/#332)
- `automation/PddProductDetailCollector.kt`:`SpecPanelType.REQUIRED_EVIDENCE` 回退分支(地址入口/支付入口/唯一数量输入框 + 同一面板容器 + ≥1 辅助证据);`ParsedPddScreen` 增加 `hasAddressEntry`、`hasPaymentEntry`、`hasQuantityInput`、`hasQuantityAdjustControls`、`quantityInputCount` 与 `requiredEvidenceSummary()`;`SpecPanelStabilityPolicy`(等待上限 5000ms,地址返回采样 200ms);规格值按选项块合并(`optionBlock`)。
- `automation/PurchaseRehearsalExecutor.kt`:`waitForSpecPanel` 对回退类型要求连续两次结构一致;`selectionFailureEvidence` 选中失败诊断。
- `automation/PurchaseLiveAutomation.kt`:`waitForSettledPanelAfterAddressSave` 保存地址后稳定等待(不按返回键);`finalSubmitTargets` 拒绝零尺寸文字/容器;新增构造参数 `panelDiagnostic`(仅布尔/计数)。
- 测试:`SpecPanelRecognitionTest`、`SpecOptionDedupTest`、`SpecPanelFixtures`(脱敏构造样本)。
+124 -4
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 31d2dbd24acd8ac119dfd0d538c12d0525a2764f
synchronized_at: 2026-09-10T06:54:35Z
wiki_revision: 7bb3997cfbe96343f63d4f6ef80201146c27c142
synchronized_at: 2026-09-22T02:56:27Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -66,7 +66,7 @@ synchronized_at: 2026-09-10T06:54:35Z
- Agent 侧回填为**人工触发的只读扫描**(#242):入口在采购记录页搜索按钮右侧,输入天数(默认 2)后确认启动。天数口径为滚动 N×24 小时,基准是页面读到的下单时间。采集、采购、回填三者互斥,复用既有任务互斥锁并加原子防重入,忙碌时拒绝启动而不排队、不抢占。
- 回填扫描不假设订单列表有序:只有连续观察到至少 5 单且下单时间严格递减、期间无缺失时间时,才允许「超过指定时间即停止」;一旦出现时间回升或缺失,改为扫至内部上限并明确标记「未完整扫描」,不得把不完整结果显示为已扫完。内部上限用于限制设备占用,不作为用户可配置的门禁。
- 回填扫描全程只读:确认收货、申请退款、催发货、去支付、立即支付、提交订单、付款、退款、取消订单、再次购买、删除订单永久排除为点击目标;点击目标必须自身可点、子树不含上述词,且不与任何含上述词的可见控件几何重叠;不得按固定坐标盲点。遇登录、验证码或风控立即停止并报告。
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号与可选下单时间。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号、可选下单时间,以及 #306 的可选实付金额。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。
- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。
@@ -332,7 +332,7 @@ synchronized_at: 2026-09-10T06:54:35Z
- 一个 PDD 商品可能被多个虾皮商品共用,因此规格匹配按受影响的每个虾皮商品分项记录。主表只表达总体进度;Agent 展示和“继续采购”资格必须读取当前任务对应虾皮商品的分项状态。
- 选错替代商品时,连续执行 B→C 不等于撤销 A→B,因为 B 可能还关联其他虾皮商品。正确纠错语义是把原 A→B 记录置为 `superseded`,再建立 A→C,并只处理原记录分项中冻结的影响集合。
- 创建请求按 `create_request_id` 幂等;重放时源商品、替代商品、来源类型、来源任务、采集证据和发起设备必须一致,否则返回幂等冲突,不能静默覆盖。
- `created_by_device_id` 只代表 Agent 设备。当前系统没有设备到采购员账号的绑定,多人多机场景若需要个人责任追踪,必须另建工单实现设备绑定操作员。
- `created_by_device_id` 只代表 Agent 设备。设备可由管理员绑定到一个采购员账号;一个采购员可拥有多台设备,一台设备最多归属一个采购员,也允许暂不归属。SYB 商品一键关联/替换按当前采购员选择的归属设备读取该设备最新临时采集,不使用其他账号或其他设备的全局最新记录。
## PDD 商品替换生效与规格匹配(#131)
@@ -560,3 +560,123 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 先保存关联,再复用现有颜色/尺码自动匹配及保存。有效已确认映射保留;普通部分匹配保存合格结果,AI故障不提交本轮新映射,但已保存关联保留。后续可从“去匹配”或详情人工继续。failed 且无人工/AI确认时不能自动跳过人工解析;uncertain 不统一拦截,仍以原采购准备规则为准。
- 操作完成不保证立即可采购,必须刷新现有处理阶段;缺规格、缺映射、解析需确认或其他原资格不满足时继续显示真实待处理阶段。不自动创建采购任务、不下单、不支付。
- 请求超时/网络中断只做有限回读,不重复提交;读回失败明确结果未确认,避免无限转圈。离开页面不代表后端停止或回滚。普通用户沿用现有采购员权限,无新管理员专属开关。
## SYB 已关联商品的一键替换(#258)
实现绑定 `2b01974`;部署与验收见 #258 发布证据。
- 复用一键关联及小箭头的位置,保留原查看 PDD 商品。PDD 待采集、采集失败、停用或缺价格的对应处理阶段可替换;采集中、已创建/运行采购、成功或结果未知不开放。服务端提交时再次检查,不能只相信按钮。
- 复用每用户/浏览器/API环境的手机偏好;首次选择继续当前行,小箭头仅切换偏好。预览仅选所选手机最新 completed/completed_partial 临时采集,部分成功或不可用不回退旧商品。确认后固定来源任务和目标,不改用新采集;来源状态、设备、目标规格或旧关联改变时拒绝。
- 不同商品显示一次确认,包含旧/新商品、手机和共享蝦皮档案影响;取消不写入。同商品不弹替换确认且不重写关联,只继续匹配。共享该蝦皮档案的 SYB 明细使用新关联,历史采购任务快照不改。
- 沿用既有更换关联和有效映射检查,再复用自动匹配保存。部分匹配、AI失败或解析需人工处理时保留已保存关联,显示真实处理阶段,不保证立即可采购、不自动创建采购。
- 网络异常只有限回读,区分目标商品、仍为原商品和其他关联,不把“已有某个关联”误报为本次替换成功;不自动重发写请求。没有新增后台作业、订单或付款操作。
## 辅助信息缺失的采集关联(#259)
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
## 采购单标题面板与地址就绪等待(#260、#261)
实现绑定 #260 `3c1ffa4`(执行器回归测试 `7de4318`)、#261 `5ea07f1`,Android 0.9.74 / versionCode87,基于0.9.73。安装及真实采购验收以工单证据为准;不代表 main 的 Android 已整合这些分支。
- 采购仅在安全规格入口点击返回SUCCESS后,记录本次页面包名/Activity作为内存上下文;开始新执行或页面身份改变时清除。`PddScreenParser` 的新增 purchaseEntryContext 默认关闭,仅该采购执行器传入;普通解析和采集原判定保留。
- 当前仍为同一PDD Activity、商品ID非空、没有页面风险、没有选中摘要,且唯一有标题规格滚动区、一个维度有值,同时具有关闭、数量、支付展示区域与下单操作证据时,单标题可识别为NORMAL_SCROLLABLE。只是允许进入既有精确规格探测/选择,不执行下单或付款,不全局认可UNKNOWN,不增加入口重复点击或滑动。
- 地址入口点击原有1000ms保留;地址列表等待增加唯一修改目标要求。点击修改和保存由500ms改为1000ms,其他通用动作间隔不变,无新配置项。
- 修改后不再只见“详细地址”文字就立即写入。最多50次、每次间隔200ms读取(不含读取耗时),检查PDD页面风险;唯一可见启用有内容的详细地址EditText,连续两次路径、边界、内容及Activity相同才继续原输入流程。始终零个/多个或不稳定时失败,不取第一个候选。
- 输入内容生成、输入后回读、保存后退出及返回规格面板、最终复核和单次下单边界不变。新增本机脱敏诊断 addressEditorCandidates / addressEditorPolls 两个整数,与task/attempt/device/ruleHash关联;不记录地址内容、原始树或整屏截图。
- 仅Android;无Server/Web、共享API字段、数据库迁移或权限变化。真实采购、地址写入及订单创建不作为自动安装验证,不执行付款。
### v2 商品入口补点与地址返回等待(#260、#261)
2026-09-10 实现绑定 #260 `713111d`、#261 `d0a82f3`,Android 0.9.75 / versionCode88;真实采购效果以工单验收为准,非 main 合并声明。
- 入口恢复不再把全页面支付方式展示文案单独视为支付页。当前包名/Activity、商品标题和安全入口必须与点击前一致,入口唯一且可用;已打开面板、关闭控件、数量、选中摘要、订单操作、风险/评价页面仍拒绝恢复。支付 Activity 或明确支付、订单、地址文本继续阻止补点。仅允许既有一次重新定位后的规格入口中心手势,随后仍验证面板,不循环、不扩展到地址保存或下单。
- 保存地址后的确认读取、必要时一次返回后的确认读取,增加最多50次、间隔200ms的有界就绪检查,页面捕获耗时另计。连续两次同一Activity满足原地址回读与确认面板条件后继续;完整信息不要求滚动区域。已明确到地址列表时保留原返回路径,不等待列表出现下单按钮;返回后增加1000ms缓冲。
- 就绪检查不修改原地址回读和结构下单入口规则;超时后只对既有识别的面板、唯一可用滚动区域执行原有有限滚动。零/多个区域仍失败,不凭地址入口或未知底部节点授权下单。最终精确规格、数量、价格、地址与单次下单边界不变。
- 私有采购诊断新增布尔 savedAddressMatched、savedPanelMatched;整数 savedLegacySubmitCandidates、savedAddressCards、savedAddressTargets、savedScrollCandidates、savedConfirmationPolls。只记录标量并绑定task/attempt/device/ruleHash,不保存地址、原始树或整屏图。
- CG109 v1现场读取未达到空闲状态,不能确认原失败具体缺哪项证据;v2覆盖迟到确认节点及可观测性,不将合成测试通过冒充真机修复验收。无Server/Web/API/迁移/权限变化,不需要服务器重启。
## 探测到正式采购的单次面板交接(#262)
2026-09-10 实现绑定 `2ea32fd`,Android0.9.76/code89;基于#260/#261,未声明合并main或真实采购验收通过。
- AgentForegroundService通过PurchaseProbeHandoff只在成功spec_probe且结束后新快照仍识别PDD规格面板时保留一次性内存交接。仅taskId/goodsId/ruleHash/deviceId、Activity、前台revision和单调时钟时间,不保留控件节点、地址或选中状态,不落盘;有效期180秒。
- 正式阶段消费时检查同任务/商品/规则/设备、前台revision及时间,消费后即清除。失配/超时/无任务或采集任务调度/结果异常/进程重启均不能沿用;普通手动再试不会继承已消费交接。现有服务端租约、互斥和匹配等待策略不变。
- 有交接时,执行器恢复限定的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,对系统外人工并发修改/超长延迟请求不能承诺绝对互斥;有冲突应人工核对,禁止强制覆盖。
### SYB 会话类失败的有界自动重试(#330)
实现 01510a8/08b7095(2026-09-21,已合并 main,未部署、未生产验证)。回填 worker 从缓存会话恢复客户端后调用 SYB 会话校验;会话缺失/过期、串号失效、校验网络错误等均记为 `SYB_SESSION_UNAVAILABLE`,error_message 只记录类别和“将自动重试;如持续失败请恢复登录后重试”,不含原始错误。该类失败发生在任何写入之前,最多自动重试 6 次,退避 5/10/15/30/30 分钟(约 90 分钟,大于一个整点同步周期),达上限保持 failed 等人工。会话仍只由每小时 SYB 同步刷新;回填不登录、不 OCR、不删除或写入会话。其他失败码仍不自动重试。退避期内可手工重新回填,手工提交重置尝试次数。历史失败记录不会被自动领取。
## 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单号回填状态,已成功任务不因查询而允许重复回填。清空或重置恢复原列表,查询回到第一页,其他条件继续叠加。沿用既有下拉框、空/加载/失败状态及权限,不新增页面、审批、数据字段或外部动作。
## 规格面板识别与选项去重(#331/#332)
实现 `0edc78a`(#332)、`d8382e6`(#331),2026-09-22 已合并 main 并发布 Server(Android 0.9.60 仅安装到设备 8);真机采购效果未验证。
- 规格面板识别在原有判定之后新增回退类型 `REQUIRED_EVIDENCE`:**必须**同时存在收货地址入口(打码手机号所在可点击行)、支付方式修改入口(支付别名所在可点击行)和唯一可用的数量输入框,三者须位于同一规格面板容器内;另需至少一项辅助证据(规格选项、已选/请选择摘要、关闭、加减数量、提交订单)。加减按钮不再是必备条件。原有已识别的面板分类不变。
- 地址入口只判断存在与否,不读取、不记录、不上传地址内容;诊断只输出布尔值与计数。
- 点击规格入口后、保存地址返回后,若面板仅靠回退类型识别,须连续两次采样结构一致才继续,上限 5 秒;等待期间不按返回键,超时明确失败。稳定且明显不是采购面板的页面(如地址列表)沿用原返回路径。
- 提交订单点击保持严格并进一步收紧:必须唯一、可点击、文字匹配,且文字节点及其可点击容器尺寸非零。
- 规格选项解析:同一选项块(外层可点击块与其内部文字)只计一个值,文字优先取外层块;不做“零差评/快要抢光”等标签清洗。选中确认失败时诊断列出该维度解析值(截断)、选中状态和目标值。
- 缺少必备证据时,失败信息附带可读提示,例如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”“未找到收货地址入口:请确认 PDD 已设置默认收货地址”“未找到购买数量输入框”。
### 采购手机环境前提
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
+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: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:14:42Z
<!-- 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 上传不属于自动化验证,必须取得独立授权并在结果中明确区分。
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Common-Changes
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Common-Changes.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:12Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:14:50Z
<!-- gitea-wiki-mirror:end -->
# 常见修改指南
+21 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:16Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:14:54Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -85,3 +85,22 @@ sqlite3 -readonly agent-diagnostics.db "SELECT task_id,reason,color_row_count,co
```
读取时记录设备、Agent 版本、任务号和规则快照;工单只回写查询得到的脱敏聚合数值。读取完成后删除本地导出副本。正式 APK 若不允许 `run-as`,停止排查并确认安全的只读诊断出口,不通过放宽应用安全配置或上传完整数据库绕过。
## Android 采购规格入口本地诊断(#249)
实现绑定 `99faf5a`,Agent `0.9.67`(versionCode 80)。单元测试与 Debug 构建已通过,新增版本真机留存/读取尚待验证;本节不属于上文既有实测结论。
采购执行器启动后,在应用私有 `files/purchase_diagnostics/<taskId>_<attemptId>.jsonl` 保存白名单结构化入口诊断。元数据包含 taskId、attemptId、deviceId、agentVersion、phase、ruleHash(当前任务规则快照 SHA-256)、timestamp 和 elapsedMs。现有任务接口只有 attemptId,没有 attemptNumber;用 attemptId 对照服务端历史,不推算重试序号。
证据仅包含入口来源枚举、候选数、点击结果/原因枚举、等待轮数、面板结构布尔值/计数,以及受控恢复是否执行及固定拒绝原因。没有商品标题、规格原文、地址、手机号、Cookie、Token、链接、原始控件树或截图。entryWaitMillis 是该等待循环累计的计划等待量;elapsedMs 才是自本次采购执行器诊断起点计量的实际耗时,timestamp 是后台写入时间。
复用现有单线程诊断队列异步写入,写入/排队失败不改变采购结果;不新增点击、滑动、等待或采购门禁。最多保留最近 5 个 attempt 文件、每文件最后 128 条事件,超过 7 天的文件在服务启动或下次写入时清理。异常断电或存储故障仍可能丢失诊断,不能将日志缺失当成动作未执行;不上传 Admin,不增加服务端接口。
仅对允许 run-as 的 Debug APK,在设备已连接且选定准确序列号后只读提取:
```powershell
adb -s <device-serial> shell run-as cn.ilapage.goauto.agent ls files/purchase_diagnostics
adb -s <device-serial> exec-out run-as cn.ilapage.goauto.agent cat files/purchase_diagnostics/<taskId>_<attemptId>.jsonl
```
先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:20Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:15:00Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览与当前 MVP
+309 -3
View File
@@ -2,8 +2,16 @@
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: 23160c055e8f6185a4108e284afc8216b41373e5
synchronized_at: 2026-09-10T03:37:18Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:15:05Z
<!-- gitea-wiki-mirror:end -->
<!-- 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: ba259178cf6514d5b48ea425005dfd366ba803a3
synchronized_at: 2026-09-12T03:09:46Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -100,6 +108,33 @@ POST /api/admin/v1/syb-shops/discover
`discover` 提交日期范围,从 SYB 真实货运单列表发现店铺,返回 `shopName` 和 `status`(`new` / `exists`);发现只读,不自动新增。店铺比较固定执行首尾去空白、全角/半角统一和忽略大小写。同步先完成原始列表完整性校验,再按一次性启用店铺快照过滤;明细响应中的店铺名为空或不在快照内时不得写入。
## 管理端:SYB 商品过滤(#269)
```http
GET /api/admin/v1/syb-product-filters
POST /api/admin/v1/syb-product-filters
PATCH /api/admin/v1/syb-product-filters/{id}/enabled
DELETE /api/admin/v1/syb-product-filters/{id}
```
同步时按 `variationSku` 决定明细是否入库。规则分两类,由 `kind` 区分:`char` 是结构判据
(`-`、`#`),`keyword` 是关键词清单。列表支持 `kind`、`keyword` 和分页,返回 `keyword`、
`normalizedKeyword`、`enabled`、`lastHitCount`、`lastHitAt`、`note`,`char` 另有
`lastChangedBy`、`lastChangedAt`。
`[必须]` 服务端必须拒绝对 `kind=char` 行的 `POST` 新增与 `DELETE` 删除,分别返回
`FORBIDDEN`(`结构过滤不可新增` / `结构过滤不可删除`),不得只依赖前端隐藏按钮。
`kind=char` 的 `PATCH enabled` 允许,并记录操作人与时间。
`[必须]` `lastHitCount` 是每条规则各自的命中数,由同步结束时回写;未命中的规则写 0,
从未同步过为 `null`。停用 `kind=char` 的二次确认提示条数必须取该行实时的 `lastHitCount`,
不得写死——写死的数字在数据变化后会给出错误警告。
关键词归一化与店铺一致:去首尾空白、全角/半角统一、忽略大小写;**不做简繁转换**,SYB 数据
为繁体。唯一性按 (`kind`, `normalizedKeyword`) 判定,重复添加返回
`SYB_PRODUCT_FILTER_DUPLICATE`。写操作由 go-admin/Casbin 仅授权管理员,其他角色只读。
删除为软删除,不清理已经入库的商品明细。
## 管理端:采集规则
```http
@@ -556,7 +591,15 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem
创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。
创建订单后若出现 Android 多微信应用选择器,只读核单器必须同时确认前台包为 `android` / `com.android.intentresolver`、Activity 为白名单 `ChooserActivity` / `ResolverActivity`、页面出现已知系统选择器标题且至少一个候选以“微信”开头,才允许执行一次系统返回;不得点击任何微信候选。若前台已经是精确微信包 `com.tencent.mm`,只读核单器不得点击、输入、登录、支付或强制停止微信,只允许执行一次无参数 PDD 启动 Intent 并等待既有 PDD 任务栈回到前台;`startActivity()` 成功只表示恢复请求已发起。请求后在固定最多 15 次、每次 200ms 的宽限期内允许微信或空窗口短暂残留,不执行点击、返回、输入或滑动;观察到 PDD 后结束宽限,宽限超时、已经观察到 PDD 后再次进入微信或出现稳定未知应用时返回未知结果。随后若前台为 PDD `com.xunmeng.pinduoduo.app_pay.core.PayActivity` 或当前页面出现支付动作文字,最多再返回一次。返回后只读解析唯一订单号和下单时间;选择器或支付页重复出现、白名单不成立、恢复动作重复、无法到达订单详情、结果不唯一或超时均返回未知结果,禁止再次点击创建订单、取消订单或支付。
Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单并按错误码写入固定提示,不信任或保存页面原文;阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单错误码,并按错误码写入固定提示作为面向人的主文案;不采信 Agent 文本作为权威描述,也不保存原始控件树或截图。阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
#271 修订:`errorMessage` 的语义由「仅固定提示」改为「**固定提示 + Agent 诊断证据**」。固定提示仍由错误码唯一决定并排在最前,Agent 提交的脱敏诊断追加在其后。
`[必须]` 追加前压平为单行:去除控制字符与零宽不换行空格、连续空白合并为一个空格;按 `error_message` 列宽 1000 截断,且**固定提示优先完整保留**,被截断的证据以 `…` 结尾。证据与固定提示相同时不重复拼接。Agent 文本为空或错误码不在白名单时行为不变,仍返回 `INVALID_REQUEST`。
改动原因:原做法丢弃了 Agent 已经算出并上报的诊断。`#210` 为 `PURCHASE_ORDER_PAYMENT_REPEATED` 计算 `paymentBackAttempts` 与 `consecutivePaymentSamplesAfterBack`,但 2026-09-07 至 09-11 线上 61 次该错误的 `error_message` 无一携带这两个值,导致「返回动作未生效」「页面渲染慢于容忍窗口」「页面已是订单页但关键词未命中」三种情形无法区分。相邻的 `failed` 结果分支本就原样保存 Agent 文本,因此本修订不引入新的信任假设。
`[必须]` `failed` 结果分支同样把错误码与消息写入 attempt,不再只写任务。此前该路径产生的 attempt 行 `error_code` 为 NULL,按 attempt 统计失败时这些记录是隐形的。
#241 追加:全局订单号唯一性校验在人工处理结果未知、取消及 lifecycle 保存路径返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`(HTTP 409、`retryable=false`),提示“订单号已属于任务 CG-任务ID”;批量回填继续使用原有 `PURCHASE_BACKFILL_ORDER_ALREADY_USED`。不可逆边界后的 `order_created` 结果回传为例外:发现该号已属于其他任务时成功受理结果,将 `order_submit_started` 降级为 `order_result_unknown`,不回滚或自动重派。冲突订单号不写入 `pdd_order_no`,而以“读到订单号 X,但该号已属于任务 CG-yy”保存到任务和 attempt 的 `error_message`,两者 `error_code` 均为 `PURCHASE_ORDER_NUMBER_ALREADY_USED`;保留下单时间、不可逆时间及实际单价。attempt 以 failed 结束并保留原始 `order_created` 结果类型、请求 ID 和摘要,重复提交按原幂等协议返回;任务释放租约和运行槽,进入既有人工处理结果未知通道,权限不变。
@@ -784,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 可为任务已采集的颜色逐张调用:
@@ -1093,3 +1288,114 @@ requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有
- `/api/client/v1/.../link-pdd` 带 latestCollection 时返回403 FORBIDDEN,不扩大API Key授权模块;原手动 pddProductId 模式行为不变。
- Web 先GET详情及 #254 autoMatchTimeoutSeconds,再提交关联;关联响应中的冻结版本交给现有 auto-match。其匹配仍自动保存,无额外“保存修改”写请求。普通请求/回读每次10秒,auto-match 单独使用派生前端预算2T+20秒;业务拒绝直接提示,网络/匹配失败至多一次详情回读,再刷新准备状态,禁止自动POST重试。
- Server/Web 需配套更新并包含 #254;无需迁移/Android升级。现有人工关联、匹配阈值、规格/采购资格、任务与支付边界不变。
## Admin 一键替换最近采集候选(#258)
实现绑定 `2b01974`;仅补充 Admin,Android/API Key 不变。沿用 `POST /api/admin/v1/shopee-products/{productId}/link-pdd` 和原权限,不新增路由或迁移。
先请求只读预览(仍需 UUID requestId):
```json
{"requestId":"UUID","latestCollection":{"sybProductId":1,"deviceId":2,"specContextVersion":"当前详情版本","replacement":{"preview":true,"expectedPddProductId":3}}}
```
响应 `product` 保持原关联,`collectionSource` 返回 taskId、deviceId、pddProductId、requiresSpecConfirmation,并增加 targetVersion、title、goodsId、previousTitle、previousGoodsId、deviceName。预览没有关联写入。
确认提交沿用同一 SYB/设备/详情版本与原商品ID,replacement 改为:
```json
{"preview":false,"expectedPddProductId":3,"taskId":4,"targetPddProductId":5,"targetVersion":"预览返回值"}
```
- 提交按指定 taskId 核对临时采集来源、设备、completed/finishedAt、目标ID、可用规格及 targetVersion;不重新选择最新任务。旧关联、旧/目标规格版本变更返回现有409上下文冲突;阶段不再允许返回409 PDD_LINK_CONFLICT;来源不可用409 LATEST_COLLECTION_UNAVAILABLE;缺字段422。
- 不同商品条件更新旧关联,返回更新后的 product.specContextVersion;同商品不更新关联及 last_update_request_id,但仍返回用于后续匹配的当前版本。请求不是持久化重放协议;客户端超时只回读、不自动重发。
- 预览与提交都校验当前允许阶段;采集中拒绝。复用现有人工更换关联的映射语义,后续 auto-match 自动保存及预算沿用 #254。部分匹配/AI故障不回滚已提交关联。
- 不提供 replacement 时 #253 行为不变;`/api/client/v1` 携带 latestCollection(包括 replacement)仍403,不扩大客户端密钥授权。
## 辅助信息缺失的采集关联(#259)
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
## 采购规格现场一次重新匹配(#263)
2026-09-11 实现绑定 `c6ee6c0`,Server采购与Android Agent;未发布、未安装、未完成真实采购验收。
- 正式采购阶段仅在服务端下发 `specRematchAllowed=true`、Android 返回稳定的 `PURCHASE_SPEC_TARGET_NOT_VISIBLE` 且尚未进入地址/下单阶段时触发;颜色/尺码选中复核失败、售罄、点击失败、歧义、价格、地址和订单错误不触发。
- 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变更。
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Delivery-Issues
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:28Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:15:09Z
<!-- gitea-wiki-mirror:end -->
# 当前 MVP 交付工单索引
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: OnePlus-Real-Device-Acceptance
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:32Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:15:13Z
<!-- gitea-wiki-mirror:end -->
# 一加真机验收记录
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: PDD-Detail-Rule-Migration-Analysis
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/PDD-Detail-Rule-Migration-Analysis.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:37Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:15:17Z
<!-- gitea-wiki-mirror:end -->
# PDD 商品详情采集规则迁移分析
+35 -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: 214485db630141aeb03d4b49b22a65e572fb592a
synchronized_at: 2026-09-08T02:24:10Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:15:30Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
@@ -495,6 +495,27 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
`shopName` 去除首尾空白后与启用店铺精确匹配 → 只为接受的货运单请求明细和入库。
店铺过滤只决定明细获取和入库,不能减少原始列表完整性校验范围;已保存不代表整日完整。
`[必须]` **店铺准入之后再按 `variationSku` 过滤商品(#269)。** 顺序固定为:店铺准入 →
结构过滤 → 关键词过滤 → 入库。两类规则都存在 `syb_product_filter`,由 `kind` 区分:
- `kind=char`:`-` 和 `#` 两条,命中任意一条即跳过(**OR,不是 AND**)。这两个字符是
「档口-供应商#货号」编码格式的判据。2026-09-11 核验线上 13010 行明细:含 `#` 8498 行、
含 `-` 6392 行、两者都含 6386 行、任一 8504 行(65%)。用 AND 只命中 6386 行,会漏掉
`DD#004`、`300斤牛奶絲圓領#A057` 这类只含 `#` 的 2118 行。
- `kind=keyword`:关键词清单,匹配 `variationSku`,**不匹配 `productTitle`**。同一次核验
中六条初始关键词在 `productTitle` 上命中为 0,且全部已被结构过滤覆盖,净增为 0;保留它
是为档口改用不含 `#` 的编码时兜底。
`[必须]` 空 `variationSku` **不过滤**。核验时 2724 行(21%)为空,分布在正常启用店铺且持续
产生,属于尚未填写供应商编码的正常商品,不是档口货。
`[必须]` 两类命中数分别统计并分别回写到每条规则自己的 `last_hit_count`,不得合并成单一
「过滤总数」,也不得按 `kind` 写同一个汇总值。前者会使结构判据失效不可观测(关键词净增为 0
时无法区分「没漏网」和「规则没生效」);后者会让停用确认对 `#` 和 `-` 提示同一个数字。
`[必须]` `kind=char` 不可新增、不可删除,只能停用,且停用需要二次确认并记录操作人与时间。
判据本身需要变更(例如档口改用 `/` 或 `@`)属于范围变化,应另建工单评估。
`[必须]` 同步开始时只读取一次启用店铺,整次运行使用同一个快照。列表允许但明细
响应中的 `shopName` 变为空或非允许店铺时再次拦截。没有启用店铺时在会话/OCR/
验证码等任何顺运宝请求之前停止,并且不推进覆盖游标。该过滤只影响后续入库,
@@ -658,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。
+51 -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: cc642ebfa65f429100779e40560a59174ab877a0
synchronized_at: 2026-09-22T02:56:36Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -104,3 +104,52 @@ 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 或真实订单载荷。没有明确列表末尾证据时只能报告未完整扫描。
## 采购手机准备清单(#331)
采购员配置或更换采购用 Android 手机时逐项确认:
1. 拼多多 App 已登录;
2. 微信已登录;
3. 拼多多已设置默认收货地址;
4. 拼多多默认支付方式为微信支付;
5. GoAuto Agent 已启动,无障碍服务已开启,Admin 设备列表显示在线并已归属对应采购员账号。
任一项不满足时采购会明确失败并提示缺失项(如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”);Agent 不代为登录、切换支付方式或填写地址。安装新版 Agent 后需手动启动一次 App,心跳恢复后设备才显示在线。
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Home
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:29:34Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:14:18Z
<!-- gitea-wiki-mirror:end -->
# GoAuto 文档中心
+8
View File
@@ -0,0 +1,8 @@
# PDD 首页控件摘要(设备 192.168.0.9:35727)
- 采集时间:2026-09-15
- 包名:`com.xunmeng.pinduoduo`
- 发现语义入口:`content-desc=拍照搜索`
- 发现搜索入口:`content-desc=搜索`
- 结论:PDD 首页存在稳定的“拍照搜索”无障碍语义入口,可作为 #277 的第一步入口探测。
- 未保存坐标、完整控件树、截图、账号或个人数据。
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-Template
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-Template.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:41Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:15:22Z
<!-- gitea-wiki-mirror:end -->
# 部署文档模板
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-Archive-Template
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Task-Archive-Template.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:46Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:15:26Z
<!-- gitea-wiki-mirror:end -->
> 本模板只用于用户明确要求的专项历史快照或读取既有归档,不属于标准任务闭环。单次任务的唯一事实来源是 Gitea 工单;不要为了完成普通任务创建本页面,也不要自动导出到 `docs/task/`。
+38
View File
@@ -0,0 +1,38 @@
param(
[string]$BaseUrl = $env:GOAUTO_ADMIN_BASE_URL,
[int]$BatchSize = 100,
[int]$PageSize = 500,
[int]$SleepSeconds = 1,
[switch]$Force,
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($BaseUrl) -or [string]::IsNullOrWhiteSpace($env:GOAUTO_ADMIN_TOKEN)) {
throw '请设置 GOAUTO_ADMIN_BASE_URL 与 GOAUTO_ADMIN_TOKEN 环境变量'
}
if ($BatchSize -lt 1 -or $BatchSize -gt 500) { throw 'BatchSize 必须在 1 到 500 之间' }
$headers = @{ Authorization = "Bearer $($env:GOAUTO_ADMIN_TOKEN)" }
$ids = [System.Collections.Generic.List[UInt64]]::new()
for ($page = 1; ; $page++) {
$uri = "$($BaseUrl.TrimEnd('/'))/api/admin/v1/syb-products?parseStatus=failed&page=$page&pageSize=$PageSize"
$response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get
$items = if ($response.data.items) { $response.data.items } elseif ($response.data.list) { $response.data.list } else { @() }
foreach ($item in $items) { if ($item.id) { [void]$ids.Add([UInt64]$item.id) } }
if ($items.Count -lt $PageSize) { break }
}
Write-Host "发现失败记录 $($ids.Count) 条"
if ($DryRun -or $ids.Count -eq 0) { exit 0 }
$ok = 0; $failed = 0
for ($i = 0; $i -lt $ids.Count; $i += $BatchSize) {
$end = [Math]::Min($i + $BatchSize, $ids.Count)
$body = @{ ids = @($ids[$i..($end - 1)]); force = [bool]$Force } | ConvertTo-Json -Compress
try {
$result = Invoke-RestMethod -Uri "$($BaseUrl.TrimEnd('/'))/api/admin/v1/syb-products/reparse-batch" -Headers ($headers + @{ 'Content-Type' = 'application/json' }) -Method Post -Body $body
$rows = @($result.data.results)
$ok += @($rows | Where-Object { $_.newStatus -eq 'success' }).Count
$failed += @($rows | Where-Object { $_.newStatus -ne 'success' }).Count
Write-Host "已处理 $end/$($ids.Count),成功 $ok,仍失败 $failed"
} catch { Write-Warning "批次 $($i + 1)-$end 请求失败:$($_.Exception.Message)" }
if ($SleepSeconds -gt 0 -and $end -lt $ids.Count) { Start-Sleep -Seconds $SleepSeconds }
}
+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
}
+7 -1
View File
@@ -14,12 +14,15 @@ import (
goautopurchase "go-admin/app/goauto/purchase"
goautopurchaserule "go-admin/app/goauto/purchaserule"
goautoreplacement "go-admin/app/goauto/replacement"
goautoreturnmatch "go-admin/app/goauto/returnmatch"
goautorule "go-admin/app/goauto/rule"
goautoshopeeproduct "go-admin/app/goauto/shopeeproduct"
goautosybimport "go-admin/app/goauto/sybimport"
goautosybinnercode "go-admin/app/goauto/sybinnercode"
goautosybproductfilter "go-admin/app/goauto/sybproductfilter"
goautosybshop "go-admin/app/goauto/sybshop"
goautotask "go-admin/app/goauto/task"
goautoyeeke "go-admin/app/goauto/yeeke"
common "go-admin/common/middleware"
)
@@ -63,8 +66,11 @@ func InitRouter() {
goautoproduct.InitRouter(r, authMiddleware)
goautoreplacement.InitRouter(r, authMiddleware)
goautorule.InitRouter(r, authMiddleware)
goautoshopeeproduct.InitRouter(r, authMiddleware)
goautoshopeeproduct.InitRouter(r, authMiddleware, goautopurchase.ValidateQuickReplacement)
goautosybimport.InitRouter(r, authMiddleware)
goautosybinnercode.InitRouter(r, authMiddleware)
goautosybshop.InitRouter(r, authMiddleware)
goautosybproductfilter.InitRouter(r, authMiddleware)
goautoyeeke.InitRouter(r, authMiddleware)
goautoreturnmatch.InitRouter(r, authMiddleware)
}
+36 -19
View File
@@ -3,18 +3,21 @@ package access
import "strings"
const (
ModuleDevices = "devices"
ModulePDDProducts = "pdd_products"
ModuleShopeeProducts = "shopee_products"
ModuleSYBProducts = "syb_products"
ModuleSYBShops = "syb_shops"
ModuleSYBSyncRuns = "syb_sync_runs"
ModuleSYBInnerCodes = "syb_inner_codes"
ModuleCollectionRules = "collection_rules"
ModulePurchaseRules = "purchase_rules"
ModuleCollectionTasks = "collection_tasks"
ModulePurchaseTasks = "purchase_tasks"
ModuleAIMatching = "ai_matching"
ModuleDevices = "devices"
ModulePDDProducts = "pdd_products"
ModuleShopeeProducts = "shopee_products"
ModuleSYBProducts = "syb_products"
ModuleSYBShops = "syb_shops"
ModuleSYBProductFilters = "syb_product_filters"
ModuleSYBSyncRuns = "syb_sync_runs"
ModuleSYBInnerCodes = "syb_inner_codes"
ModuleCollectionRules = "collection_rules"
ModulePurchaseRules = "purchase_rules"
ModuleCollectionTasks = "collection_tasks"
ModulePurchaseTasks = "purchase_tasks"
ModuleAIMatching = "ai_matching"
ModuleYeekeReturns = "yeeke_returns"
ModuleYeekeSyncRuns = "yeeke_sync_runs"
)
// ModuleDefinition is the single source of truth shared by menu migration,
@@ -66,6 +69,8 @@ var goAutoMenuGroupMetadata = []MenuGroupDefinition{
ModulePDDProducts,
ModuleCollectionTasks,
ModulePurchaseTasks,
ModuleYeekeReturns,
ModuleYeekeSyncRuns,
},
},
{
@@ -77,6 +82,7 @@ var goAutoMenuGroupMetadata = []MenuGroupDefinition{
Sort: 51,
ModuleKeys: []string{
ModuleSYBShops,
ModuleSYBProductFilters,
ModuleCollectionRules,
ModulePurchaseRules,
ModuleDevices,
@@ -91,13 +97,16 @@ var goAutoModuleMetadata = []ModuleDefinition{
{Key: ModuleShopeeProducts, Title: "虾皮商品", Path: "/shopee-products", RouteName: "GoAutoShopeeProducts", Component: "/goauto/shopee-products/index", Icon: "goods", Sort: 52, PurchaserDefault: true},
{Key: ModuleSYBProducts, Title: "SYB 商品", Path: "/syb-products", RouteName: "GoAutoSybProducts", Component: "/goauto/syb-products/index", Icon: "tickets", Sort: 53, PurchaserDefault: true},
{Key: ModuleSYBShops, Title: "SYB 店铺", Path: "/syb-shops", RouteName: "GoAutoSybShops", Component: "/goauto/syb-shops/index", Icon: "shopping", Sort: 54, PurchaserDefault: true},
{Key: ModuleSYBSyncRuns, Title: "SYB 同步记录", Path: "/syb-sync-runs", RouteName: "GoAutoSybSyncRuns", Component: "/goauto/syb-sync-runs/index", Icon: "time", Sort: 55, PurchaserDefault: true},
{Key: ModuleSYBInnerCodes, Title: "档口入库码", Path: "/syb-inner-codes", RouteName: "GoAutoSybInnerCodes", Component: "/goauto/syb-inner-codes/index", Icon: "list", Sort: 56, PurchaserDefault: true},
{Key: ModuleCollectionRules, Title: "采集规则", Path: "/collection-rules", RouteName: "GoAutoCollectionRules", Component: "/goauto/collection-rules/index", Icon: "code", Sort: 57, PurchaserDefault: true},
{Key: ModulePurchaseRules, Title: "采购规则", Path: "/purchase-rules", RouteName: "GoAutoPurchaseRules", Component: "/goauto/purchase-rules/index", Icon: "setting", Sort: 58, PurchaserHardHidden: true},
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 59, PurchaserDefault: true},
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 60, PurchaserDefault: true},
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 61, PurchaserHardHidden: true},
{Key: ModuleSYBProductFilters, Title: "SYB 商品过滤", Path: "/syb-product-filters", RouteName: "GoAutoSybProductFilters", Component: "/goauto/syb-product-filters/index", Icon: "filter", Sort: 55, PurchaserDefault: true},
{Key: ModuleSYBSyncRuns, Title: "SYB 同步记录", Path: "/syb-sync-runs", RouteName: "GoAutoSybSyncRuns", Component: "/goauto/syb-sync-runs/index", Icon: "time", Sort: 56, PurchaserDefault: true},
{Key: ModuleSYBInnerCodes, Title: "档口入库码", Path: "/syb-inner-codes", RouteName: "GoAutoSybInnerCodes", Component: "/goauto/syb-inner-codes/index", Icon: "list", Sort: 57, PurchaserDefault: true},
{Key: ModuleCollectionRules, Title: "采集规则", Path: "/collection-rules", RouteName: "GoAutoCollectionRules", Component: "/goauto/collection-rules/index", Icon: "code", Sort: 58, PurchaserDefault: true},
{Key: ModulePurchaseRules, Title: "采购规则", Path: "/purchase-rules", RouteName: "GoAutoPurchaseRules", Component: "/goauto/purchase-rules/index", Icon: "setting", Sort: 59, PurchaserHardHidden: true},
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 60, PurchaserDefault: true},
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 61, PurchaserDefault: true},
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 62, PurchaserHardHidden: true},
{Key: ModuleYeekeReturns, Title: "yeeke 退货包裹", Path: "/yeeke-returns", RouteName: "GoAutoYeekeReturns", Component: "/goauto/yeeke-returns/index", Icon: "goods", Sort: 63, PurchaserDefault: true},
{Key: ModuleYeekeSyncRuns, Title: "yeeke 同步记录", Path: "/yeeke-sync-runs", RouteName: "GoAutoYeekeSyncRuns", Component: "/goauto/yeeke-sync-runs/index", Icon: "time", Sort: 64, PurchaserDefault: true},
}
// GoAutoModules returns independent copies so callers cannot mutate the
@@ -144,6 +153,8 @@ func moduleKeyForAPI(path string) string {
return ModuleSYBProducts
case strings.HasPrefix(path, "/api/admin/v1/syb-shops"):
return ModuleSYBShops
case strings.HasPrefix(path, "/api/admin/v1/syb-product-filters"):
return ModuleSYBProductFilters
case strings.HasPrefix(path, "/api/admin/v1/syb-inner-codes"):
return ModuleSYBInnerCodes
case strings.HasPrefix(path, "/api/admin/v1/collection-rules"):
@@ -156,6 +167,12 @@ func moduleKeyForAPI(path string) string {
return ModulePurchaseTasks
case strings.HasPrefix(path, "/api/admin/v1/ai-matching-settings"):
return ModuleAIMatching
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns/sync-runs"):
return ModuleYeekeSyncRuns
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns/sync"):
return ModuleYeekeSyncRuns
case strings.HasPrefix(path, "/api/admin/v1/yeeke-returns"):
return ModuleYeekeReturns
default:
return ""
}
+30 -6
View File
@@ -10,8 +10,8 @@ import (
func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
modules := GoAutoModules()
if len(modules) != 12 {
t.Fatalf("got %d modules, want 12", len(modules))
if len(modules) != 15 {
t.Fatalf("got %d modules, want 15", len(modules))
}
owners := make(map[string]int)
@@ -31,6 +31,30 @@ func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
}
}
func TestYeekeSyncRunsModuleIsRegisteredAndPurchaserVisible(t *testing.T) {
module, ok := ModuleByRouteName("GoAutoYeekeSyncRuns")
if !ok {
t.Fatal("yeeke sync-runs module not found")
}
if module.Key != ModuleYeekeSyncRuns {
t.Fatalf("unexpected key: %s", module.Key)
}
if module.Path != "/yeeke-sync-runs" || module.Component != "/goauto/yeeke-sync-runs/index" {
t.Fatalf("unexpected route wiring: %#v", module)
}
if !module.PurchaserDefault {
t.Fatal("yeeke sync-runs module must be purchaser-visible per #337")
}
if len(module.APIs) == 0 {
t.Fatal("yeeke sync-runs module must own its APIs")
}
for _, permission := range module.APIs {
if permission.Path == "/api/admin/v1/yeeke-returns" && permission.Method == "GET" {
t.Fatal("the return-package list API belongs to yeeke_returns, not yeeke_sync_runs")
}
}
}
func TestAIMatchingIsHardHiddenFromPurchaser(t *testing.T) {
module, ok := ModuleByRouteName("GoAutoAiMatchingSettings")
if !ok {
@@ -56,8 +80,8 @@ func TestGoAutoMenuSortsFitMySQLSignedTinyInt(t *testing.T) {
}
previous = module.Sort
}
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 61 {
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..61", modules[0].Sort, modules[len(modules)-1].Sort)
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 64 {
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..64", modules[0].Sort, modules[len(modules)-1].Sort)
}
}
@@ -71,8 +95,8 @@ func TestGoAutoMenuGroupsCoverModulesExactlyOnce(t *testing.T) {
}
wantOrder := [][]string{
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks},
{ModuleSYBShops, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks, ModuleYeekeReturns, ModuleYeekeSyncRuns},
{ModuleSYBShops, ModuleSYBProductFilters, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
}
seen := make(map[string]int)
for groupIndex, group := range groups {
+16
View File
@@ -77,6 +77,10 @@ var AdminAPIs = []APIPermission{
{"启停 SYB 店铺", "/api/admin/v1/syb-shops/:shopId/enabled", "PATCH", false},
{"删除 SYB 店铺", "/api/admin/v1/syb-shops/:shopId", "DELETE", false},
{"从 SYB 发现店铺", "/api/admin/v1/syb-shops/discover", "POST", false},
{"查看 SYB 商品过滤", "/api/admin/v1/syb-product-filters", "GET", true},
{"新增 SYB 商品过滤", "/api/admin/v1/syb-product-filters", "POST", false},
{"启停 SYB 商品过滤", "/api/admin/v1/syb-product-filters/:filterId/enabled", "PATCH", false},
{"删除 SYB 商品过滤", "/api/admin/v1/syb-product-filters/:filterId", "DELETE", false},
{"查看采集规则模板", "/api/admin/v1/collection-rules/templates/:templateId", "GET", true},
{"查看采集规则", "/api/admin/v1/collection-rules", "GET", true},
@@ -94,8 +98,11 @@ var AdminAPIs = []APIPermission{
{"查看采集任务", "/api/admin/v1/collection-tasks", "GET", true},
{"创建采集任务", "/api/admin/v1/collection-tasks", "POST", true},
{"批量创建采集任务", "/api/admin/v1/collection-tasks/batch", "POST", true},
{"批量创建图搜采集任务", "/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},
@@ -103,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},
@@ -116,6 +124,14 @@ var AdminAPIs = []APIPermission{
{"取消采购任务", "/api/admin/v1/purchase-tasks/:taskId/cancel", "POST", true},
{"处理结果不明确任务", "/api/admin/v1/purchase-tasks/:taskId/resolve-unknown", "POST", true},
{"查看 yeeke 退货包裹列表", "/api/admin/v1/yeeke-returns", "GET", true},
{"查看 yeeke 退货店铺列表", "/api/admin/v1/yeeke-returns/shops", "GET", true},
{"查看 yeeke 退货商品详情", "/api/admin/v1/yeeke-returns/items/:itemId", "GET", true},
{"查看 yeeke 退货包裹详情", "/api/admin/v1/yeeke-returns/:packageId", "GET", true},
{"查看 yeeke 同步记录", "/api/admin/v1/yeeke-returns/sync-runs", "GET", true},
{"查看 yeeke 同步详情", "/api/admin/v1/yeeke-returns/sync-runs/:runId", "GET", true},
{"手动触发 yeeke 同步", "/api/admin/v1/yeeke-returns/sync", "POST", true},
{"查看 AI 匹配状态", "/api/admin/v1/ai-matching-settings", "GET", true},
{"保存 AI 匹配设置", "/api/admin/v1/ai-matching-settings", "PUT", false},
{"测试 AI 服务连接", "/api/admin/v1/ai-matching-settings/test", "POST", false},
+17 -6
View File
@@ -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 {
@@ -15,12 +24,14 @@ func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
func TestPurchaserExcludesAdministratorOperations(t *testing.T) {
denied := map[string]bool{
"POST /api/admin/v1/devices/:deviceId/disable": true,
"POST /api/admin/v1/syb-shops/discover": true,
"POST /api/admin/v1/collection-rules": true,
"PUT /api/admin/v1/ai-matching-settings": true,
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
"GET /api/admin/v1/shopee-spec-auto-match/runs/latest": true,
"POST /api/admin/v1/devices/:deviceId/disable": true,
"POST /api/admin/v1/syb-shops/discover": true,
"POST /api/admin/v1/syb-product-filters": true,
"PATCH /api/admin/v1/syb-product-filters/:filterId/enabled": true,
"POST /api/admin/v1/collection-rules": true,
"PUT /api/admin/v1/ai-matching-settings": true,
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
"GET /api/admin/v1/shopee-spec-auto-match/runs/latest": true,
}
for _, permission := range PurchaserAPIs() {
if denied[permission.Method+" "+permission.Path] {
+23 -1
View File
@@ -15,6 +15,7 @@ var (
traditionalToSimplified *gocc.OpenCC
traditionalErr error
weightPattern = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*(?:[-~~至到]\s*(\d+(?:\.\d+)?))?\s*(公斤|千克|kg|斤)`)
sizeTokenPattern = regexp.MustCompile(`(?i)^(?:xxxs|xxl|xxxl|xxxxl|[2-9]xl|xs|s|m|l|xl|\d+xl)\b`)
)
// Normalize makes a deterministic comparison key. It intentionally removes
@@ -80,7 +81,7 @@ func normalizeWeight(raw string) string {
// deliberately treated as no match instead of picking the first label.
func DeterministicMatch(request MatchRequest) (MatchResult, bool) {
color, colorOK := exactCandidate(request.TargetColor, request.Colors)
size, sizeOK := exactCandidate(request.TargetSize, request.Sizes)
size, sizeOK := exactSizeCandidate(request.TargetSize, request.Sizes)
if !colorOK || !sizeOK {
return MatchResult{}, false
}
@@ -88,6 +89,27 @@ func DeterministicMatch(request MatchRequest) (MatchResult, bool) {
return result, true
}
func exactSizeCandidate(target string, candidates []string) (string, bool) {
key := sizeTokenPattern.FindString(strings.ToLower(Normalize(target)))
if key == "" {
return exactCandidate(target, candidates)
}
matches := make([]string, 0, 1)
seen := map[string]bool{}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" || sizeTokenPattern.FindString(strings.ToLower(Normalize(candidate))) != key || seen[candidate] {
continue
}
seen[candidate] = true
matches = append(matches, candidate)
}
if len(matches) == 1 {
return matches[0], true
}
return "", false
}
// ManualMatch records an already-confirmed mapping without reinterpreting it.
// The caller owns the human-confirmation check; this helper only creates the
// same bounded task snapshot shape used by automatic decisions.
+38 -1
View File
@@ -33,7 +33,7 @@ func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
db, s := fixture(t)
routes := Inventory()
if len(s.Modules) != 12 {
if len(s.Modules) != 15 {
t.Fatal("menu groups lost")
}
router := gin.New()
@@ -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)
+2
View File
@@ -95,6 +95,7 @@ func Inventory() []Endpoint {
{"GET", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "read", t.AdminDetail},
{"POST", "/collection-tasks", access.ModuleCollectionTasks, "collect", t.AdminCreate},
{"POST", "/collection-tasks/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreate},
{"POST", "/collection-tasks/image-search/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreateImageSearch},
{"POST", "/collection-tasks/:taskId/reset", access.ModuleCollectionTasks, "collect", t.AdminReset},
{"DELETE", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "delete", t.AdminDelete},
{"GET", "/purchase-tasks", access.ModulePurchaseTasks, "read", buy.AdminList},
@@ -104,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 {
+71
View File
@@ -11,6 +11,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"gorm.io/gorm"
)
@@ -29,6 +30,14 @@ func (handler Handler) List(context *gin.Context) {
writeError(context, internalError(err))
return
}
if role, _ := jwt.ExtractClaims(context)["rolekey"].(string); role != "admin" {
id := currentUserID(context)
if id > 0 {
request.OwnerUserID = &id
} else {
request.OwnerUserID = new(uint64)
}
}
response, err := NewService(db).List(context.Request.Context(), request)
if err != nil {
writeError(context, err)
@@ -37,6 +46,68 @@ func (handler Handler) List(context *gin.Context) {
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": response})
}
func currentUserID(c *gin.Context) uint64 {
// #333: go-admin's Authorizator runs on every request with the
// IdentityHandler map, which has no "user" entry, so c.Get("userId") is
// always 0 there. The JWT "identity" claim is the authenticated user id.
switch id := jwt.ExtractClaims(c)["identity"].(type) {
case float64:
if id > 0 {
return uint64(id)
}
case int:
if id > 0 {
return uint64(id)
}
case int64:
if id > 0 {
return uint64(id)
}
case uint64:
return id
}
return 0
}
func (handler Handler) Owners(context *gin.Context) {
db, err := handler.database(context)
if err != nil {
writeError(context, internalError(err))
return
}
rows, err := NewService(db).Owners(context.Request.Context())
if err != nil {
writeError(context, err)
return
}
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": rows})
}
func (handler Handler) SetOwner(context *gin.Context) {
deviceID, err := strconv.ParseUint(context.Param("deviceId"), 10, 64)
if err != nil || deviceID == 0 {
writeError(context, invalidRequest("deviceId 无效"))
return
}
var req struct {
OwnerUserID *uint64 `json:"ownerUserId"`
}
if err := decodeJSON(context, &req); err != nil {
writeError(context, invalidRequest("请求 JSON 无效"))
return
}
db, err := handler.database(context)
if err != nil {
writeError(context, internalError(err))
return
}
if err := NewService(db).SetOwner(context.Request.Context(), deviceID, req.OwnerUserID); err != nil {
writeError(context, err)
return
}
context.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": gin.H{"deviceId": deviceID, "ownerUserId": req.OwnerUserID}})
}
func (handler Handler) Register(context *gin.Context) {
request, err := decodeRegisterRequest(context)
if err != nil {
+12 -6
View File
@@ -13,10 +13,11 @@ import (
)
type ListRequest struct {
Page int
PageSize int
Name string
Status string
Page int
PageSize int
Name string
Status string
OwnerUserID *uint64
}
type DeviceListItem struct {
@@ -36,6 +37,8 @@ type DeviceListItem struct {
LastHeartbeatAt *time.Time `json:"lastHeartbeatAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
OwnerUserID *uint64 `json:"ownerUserId"`
OwnerName string `json:"ownerName"`
}
type DeviceListResponse struct {
@@ -68,6 +71,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (DeviceLi
if request.Status != "" {
query = query.Where("status = ?", request.Status)
}
if request.OwnerUserID != nil {
query = query.Where("owner_user_id = ?", *request.OwnerUserID)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return DeviceListResponse{}, internalError(err)
@@ -100,8 +106,8 @@ func (service *Service) List(ctx context.Context, request ListRequest) (DeviceLi
AndroidVersion: record.AndroidVersion, AgentVersion: record.AgentVersion, PDDVersion: record.PDDVersion,
Capabilities: capabilities,
Status: record.Status, LastHeartbeatAt: record.LastHeartbeatAt,
TokenRevoked: record.TokenRevokedAt != nil,
CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt,
TokenRevoked: record.TokenRevokedAt != nil, OwnerUserID: record.OwnerUserID,
CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt,
}
if taskID, busy := currentTasks[record.ID]; busy {
item.Busy = true
@@ -0,0 +1,84 @@
package device
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/goauto/models"
)
// #333: mirrors the real go-admin middleware, whose Authorizator sets
// userId to 0 on every request while the JWT claims carry the identity.
func listDevicesAs(t *testing.T, handler Handler, claims jwt.MapClaims) []DeviceListItem {
t.Helper()
gin.SetMode(gin.TestMode)
engine := gin.New()
engine.GET("/devices", func(c *gin.Context) {
c.Set(jwt.JwtPayloadKey, claims)
c.Set("userId", 0)
c.Next()
}, handler.List)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/devices?page=1&pageSize=20", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var body struct {
Data DeviceListResponse `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
return body.Data.Items
}
func seedOwnedDevice(t *testing.T, handler Handler, name string, owner *uint64) models.AgentDevice {
t.Helper()
device := models.AgentDevice{
InstallID: "install-" + name, Name: name, Manufacturer: "test", Model: "test",
AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", CapabilitiesJSON: "[]",
Status: models.DeviceStatusOnline, TokenDigest: fmt.Sprintf("digest-%s", name),
TokenIssuedAt: time.Now(), OwnerUserID: owner,
}
if err := handler.DB.Create(&device).Error; err != nil {
t.Fatalf("create device: %v", err)
}
return device
}
func deviceNames(items []DeviceListItem) map[string]bool {
names := map[string]bool{}
for _, item := range items {
names[item.Name] = true
}
return names
}
func TestDeviceListPurchaserSeesOnlyOwnDevicesFromJWTIdentity(t *testing.T) {
handler := Handler{DB: openTestDatabase(t)}
two, three := uint64(2), uint64(3)
seedOwnedDevice(t, handler, "caigou1-phone", &two)
seedOwnedDevice(t, handler, "caigou2-phone", &three)
seedOwnedDevice(t, handler, "unowned-phone", nil)
names := deviceNames(listDevicesAs(t, handler, jwt.MapClaims{"rolekey": "purchaser", "identity": float64(3)}))
if len(names) != 1 || !names["caigou2-phone"] {
t.Fatalf("purchaser 3 should see only own device, got %v", names)
}
all := deviceNames(listDevicesAs(t, handler, jwt.MapClaims{"rolekey": "admin", "identity": float64(1)}))
if len(all) != 3 {
t.Fatalf("admin should see all devices, got %v", all)
}
none := listDevicesAs(t, handler, jwt.MapClaims{"rolekey": "purchaser"})
if len(none) != 0 {
t.Fatalf("purchaser without identity must see no devices, got %d", len(none))
}
}
+63
View File
@@ -0,0 +1,63 @@
package device
import (
"context"
"fmt"
"strconv"
"strings"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
type DeviceOwnerItem struct {
UserID uint64 `json:"userId"`
Username string `json:"username"`
NickName string `json:"nickName"`
}
func (service *Service) Owners(ctx context.Context) ([]DeviceOwnerItem, error) {
var rows []DeviceOwnerItem
if err := service.DB.WithContext(ctx).Table("sys_user u").Select("u.user_id AS user_id, u.username, u.nick_name").Joins("JOIN sys_role r ON r.role_id = u.role_id").Where("u.status <> ? AND r.role_key <> ?", "1", "admin").Order("u.user_id").Scan(&rows).Error; err != nil {
return nil, internalError(err)
}
return rows, nil
}
func (service *Service) SetOwner(ctx context.Context, deviceID uint64, owner *uint64) error {
if deviceID == 0 {
return invalidRequest("deviceId 无效")
}
if owner != nil && *owner == 0 {
return invalidRequest("ownerUserId 无效")
}
if owner != nil {
var count int64
if err := service.DB.WithContext(ctx).Table("sys_user u").Joins("JOIN sys_role r ON r.role_id = u.role_id").Where("u.user_id = ? AND u.status <> ? AND r.role_key <> ?", *owner, "1", "admin").Count(&count).Error; err != nil {
return internalError(err)
}
if count != 1 {
return invalidRequest("采购员不存在或不可分配")
}
}
result := service.DB.WithContext(ctx).Model(&models.AgentDevice{}).Where("id = ?", deviceID).Update("owner_user_id", owner)
if result.Error != nil {
return internalError(result.Error)
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func parseOwnerID(value string) (*uint64, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
id, err := strconv.ParseUint(value, 10, 64)
if err != nil || id == 0 {
return nil, fmt.Errorf("ownerUserId 无效")
}
return &id, nil
}
+2
View File
@@ -21,6 +21,8 @@ func InitRouter(engine *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) {
admin := engine.Group("/api/admin/v1/devices").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
admin.GET("", handler.List)
admin.GET("/owners", middleware.RequireRoleKey("admin"), handler.Owners)
admin.PATCH("/:deviceId/owner", middleware.RequireRoleKey("admin"), handler.SetOwner)
admin.POST("/:deviceId/disable", middleware.RequireRoleKey("admin"), handler.Disable)
admin.POST("/:deviceId/identity-reset", middleware.RequireRoleKey("admin"), handler.ResetIdentity)
admin.POST("/:deviceId/token/revoke", middleware.RequireRoleKey("admin"), handler.RevokeToken)
+61 -1
View File
@@ -4,6 +4,7 @@ import (
"strings"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybshop"
"gorm.io/gorm"
)
@@ -41,7 +42,12 @@ func MigratedModels() []any {
&models.SYBSpecAIParseWorkItem{},
&models.SYBSession{},
&models.SYBShop{},
&models.SYBProductFilter{},
&models.SYBSyncRun{},
&models.YeekeSession{},
&models.YeekeReturnPackage{},
&models.YeekeReturnItem{},
&models.YeekeSyncRun{},
&models.SYBInnerCodeRecord{},
&models.SYBInnerCodeItem{},
&models.SYBInnerCodeApplyBatch{},
@@ -54,6 +60,9 @@ func MigratedModels() []any {
&models.PDDAccount{},
&models.PurchaseTask{},
&models.PurchaseTaskAttempt{},
&models.PurchaseOrderWriteback{},
&models.PurchaseOrderWritebackLease{},
&models.PurchaseOrderWritebackCommand{},
&models.PurchaseSpecMatchWorkItem{},
&models.CollectionRule{},
&models.PurchaseRule{},
@@ -70,6 +79,9 @@ func MigratedModels() []any {
&models.PDDProductReplacement{},
&models.PDDProductReplacementItem{},
&models.PDDProductReplacementWorkerLease{},
&models.ReturnMatch{},
&models.ReturnMatchLog{},
&models.ReturnMatchBatch{},
}
}
@@ -85,7 +97,55 @@ func Migrate(db *gorm.DB) error {
Update("task_type", models.PurchaseTaskTypeSYBOrder).Error; err != nil {
return err
}
return ensureMySQLDirectSelectConstraint(db)
// #269: seed immutable structure判据 and the initial keyword safety net.
//
// `[必须]` 结构过滤自 #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}
if err := db.Where("kind = ? AND normalized_keyword = ?", item.kind, norm).FirstOrCreate(&row).Error; err != nil {
return err
}
}
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" }
+71
View File
@@ -0,0 +1,71 @@
package models
import "time"
// #338: ReturnMatch links one syb_product row to one yeeke_return_item row so
// the return can be reused instead of buying the item again on PDD. A match
// is "active" (still blocking purchase creation and occupying the return)
// while Status is matched or confirmed; cancelling clears both Active*
// columns so the unique index frees up both sides for a future rematch
// (issue #338, business rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许).
//
// ActiveSYBProductID/ActiveYeekeReturnItemID follow the same nullable-unique-
// column pattern already used by YeekeSyncRun.ActiveSlot in this codebase:
// MySQL unique indexes allow any number of NULL rows, so only the currently
// active match for a given side occupies the slot, and a concurrent second
// insert for the same side fails the unique constraint instead of double-
// booking it.
const (
ReturnMatchStatusMatched = "matched"
ReturnMatchStatusConfirmed = "confirmed"
ReturnMatchStatusCancelled = "cancelled"
)
type ReturnMatch struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
SYBProductID uint64 `json:"sybProductId" gorm:"not null;index"`
YeekeReturnItemID uint64 `json:"yeekeReturnItemId" gorm:"not null;index"`
// ActiveSYBProductID/ActiveYeekeReturnItemID mirror the ID columns above
// while Status is matched/confirmed, and are cleared to NULL on cancel.
ActiveSYBProductID *uint64 `json:"-" gorm:"uniqueIndex:ux_return_match_active_syb"`
ActiveYeekeReturnItemID *uint64 `json:"-" gorm:"uniqueIndex:ux_return_match_active_return"`
Status string `json:"status" gorm:"size:16;not null;index;check:ck_return_match_status,status IN ('matched','confirmed','cancelled')"`
// Snapshots of the normalized spec text on both sides at match time, kept
// for audit and for the compare screen even if the source rows change
// later.
SYBSpecText string `json:"sybSpecText" gorm:"size:255;not null;default:''"`
YeekeSpecText string `json:"yeekeSpecText" gorm:"size:255;not null;default:''"`
NormalizedKey string `json:"normalizedKey" gorm:"size:255;not null;default:'';index"`
// PreviousProcessStage records what the SYB product's computed process
// stage was immediately before this match, so cancel can report/restore
// context; the actual restore is a pure recomputation (no stage is
// stored on syb_product), this is for audit/display only.
PreviousProcessStage string `json:"previousProcessStage" gorm:"size:32;not null;default:''"`
DestroyDeadlineSnapshot *time.Time `json:"destroyDeadlineSnapshot,omitempty"`
Remark string `json:"remark" gorm:"size:500;not null;default:''"`
MatchedBy string `json:"matchedBy" gorm:"size:64;not null;default:''"`
MatchedAt time.Time `json:"matchedAt"`
ConfirmedBy string `json:"confirmedBy,omitempty" gorm:"size:64;not null;default:''"`
ConfirmedAt *time.Time `json:"confirmedAt,omitempty"`
CancelledBy string `json:"cancelledBy,omitempty" gorm:"size:64;not null;default:''"`
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ReturnMatch) TableName() string { return "return_match" }
// IsActive reports whether the match currently blocks purchase creation and
// occupies its return item.
func (m ReturnMatch) IsActive() bool {
return m.Status == ReturnMatchStatusMatched || m.Status == ReturnMatchStatusConfirmed
}
@@ -0,0 +1,25 @@
package models
import "time"
// ReturnMatchBatch records one click of 「匹配退货」 (#338): who submitted it,
// which SYB products were submitted and the per-product outcome, so a later
// question like "why did only N of my selection match" can be answered from
// data instead of reconstructing the page selection. ItemsJSON holds the
// []BatchMatchItem result array (sybProductId/matched/reasonCode/reason/
// matchId) in submission order.
type ReturnMatchBatch struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Operator string `json:"operator" gorm:"size:64;not null;default:'';index"`
SubmittedCount int `json:"submittedCount" gorm:"not null;default:0"`
MatchedCount int `json:"matchedCount" gorm:"not null;default:0"`
SkippedCount int `json:"skippedCount" gorm:"not null;default:0"`
ItemsJSON string `json:"-" gorm:"type:longtext;not null"`
// Error is non-empty when the batch aborted part-way; ItemsJSON then
// holds only the rows processed before the failure.
Error string `json:"error" gorm:"size:500;not null;default:''"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
}
func (ReturnMatchBatch) TableName() string { return "return_match_batch" }

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