Compare commits

..
Author SHA1 Message Date
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
121 changed files with 9241 additions and 210 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"
@@ -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"
@@ -19,7 +19,7 @@ data class FinalConfirmationEvidence(
val activityName: String,
)
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String)
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String, val pddOrderAmountCent: Long? = null)
data class PurchaseOrderReadFailure(
val code: String,
val message: String,
@@ -40,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
@@ -362,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++
@@ -430,8 +442,16 @@ class PurchaseLiveAutomation(
return@repeat
}
consecutivePaymentSamplesAfterBack = 0
if (unpaidContextVisible) paymentPageObserved = true
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", "订单详情入口不唯一,已停止只读核单")
@@ -454,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)
@@ -499,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)
@@ -516,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(
@@ -574,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
}
@@ -590,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 =
@@ -692,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
@@ -728,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 {
@@ -758,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")
@@ -777,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
@@ -79,6 +79,7 @@ data class PurchaseExecutionOutcome(
val actualUnitPriceCent: Long? = null,
// 仅 order_result_unknown 有意义:见 PurchaseOrderReadFailure 的说明(#302)。
val paymentPageObserved: Boolean = false,
val pddOrderAmountCent: Long? = null,
)
class PurchaseRehearsalExecutor(
@@ -99,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
@@ -154,7 +155,7 @@ 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",
@@ -393,7 +394,7 @@ class PurchaseRehearsalExecutor(
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
)
}
@@ -414,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(
@@ -445,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)
}
@@ -477,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};" +
@@ -573,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) {
@@ -588,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,
@@ -1174,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)
}
@@ -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,
@@ -38,6 +38,7 @@ 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
@@ -591,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
@@ -652,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)
@@ -691,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)
@@ -724,7 +735,7 @@ class AgentForegroundService : Service() {
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))
@@ -24,5 +24,8 @@ internal fun purchaseResultPayload(
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()
@@ -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)
@@ -358,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("支付") })
@@ -774,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
@@ -813,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,
)
}
}
+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 -->
# 开发工作流
+37 -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: c8dd6b1f25bf367d065e0969a4de858d857a3181
synchronized_at: 2026-09-18T02:18:10Z
wiki_revision: 504e1ffaa2cf1989f55048ac7067d8af50fe3bf3
synchronized_at: 2026-09-22T02:56:22Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -485,3 +485,38 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 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`(脱敏构造样本)。
+53 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: f353b476add5081d4d7a1493e5454b6400518923
synchronized_at: 2026-09-18T02:18:13Z
wiki_revision: 7bb3997cfbe96343f63d4f6ef80201146c27c142
synchronized_at: 2026-09-22T02:56:27Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -332,7 +332,7 @@ synchronized_at: 2026-09-18T02:18:13Z
- 一个 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)
@@ -630,3 +630,53 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
订单回填状态独立于采购成功、支付复核及物流 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 -->
# 常见修改指南
+2 -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: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
synchronized_at: 2026-09-11T09:10:00Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:14:54Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
+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
+43 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 5a61e4f2cf2bb33b824ca722e9cee03071e1dffd
synchronized_at: 2026-09-18T02:18:33Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:15:05Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -1350,7 +1350,7 @@ Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实
实现绑定 e89de1a(2026-09-18,feat/305-syb-order-writeback),已完成代码及隔离测试;未合并、未部署、未执行业务库迁移或真实 SYB 写入。
- 普通 Agent order_created 结果和人工 order-backfill 首次成功/同单号 already_backfilled 共用事务内 ensureOrderWriteback。仅 live + syb_order + order_created + 有效 PDD 单号与 SYB stock/detail 关联时建立唯一 pending 记录;网络写入由事务外 worker 执行。不改变 Agent 请求/响应字段;金额缺失/冲突不阻止单号入队,不依赖 paymentReviewStatus。
- 普通 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,不能以重放响应判断最终成功。
@@ -1359,3 +1359,43 @@ Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实
- 批量勾选取“可采购重试 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 商品详情采集规则迁移分析
+2 -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: 432e392ebe428ea19c0aa260f8e5938f36e4c3f0
synchronized_at: 2026-09-18T02:18:53Z
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:15:30Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
+31 -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: a630bab8f1c775ce9ed29cac990777b6760eeda4
synchronized_at: 2026-09-18T02:18:19Z
wiki_revision: cc642ebfa65f429100779e40560a59174ab877a0
synchronized_at: 2026-09-22T02:56:36Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -124,3 +124,32 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
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 文档中心
+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/`。
+2
View File
@@ -21,6 +21,7 @@ import (
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"
)
@@ -69,4 +70,5 @@ func InitRouter() {
goautosybinnercode.InitRouter(r, authMiddleware)
goautosybshop.InitRouter(r, authMiddleware)
goautosybproductfilter.InitRouter(r, authMiddleware)
goautoyeeke.InitRouter(r, authMiddleware)
}
+12
View File
@@ -16,6 +16,8 @@ const (
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,
@@ -67,6 +69,8 @@ var goAutoMenuGroupMetadata = []MenuGroupDefinition{
ModulePDDProducts,
ModuleCollectionTasks,
ModulePurchaseTasks,
ModuleYeekeReturns,
ModuleYeekeSyncRuns,
},
},
{
@@ -101,6 +105,8 @@ var goAutoModuleMetadata = []ModuleDefinition{
{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
@@ -161,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 ""
}
+29 -5
View File
@@ -10,8 +10,8 @@ import (
func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
modules := GoAutoModules()
if len(modules) != 13 {
t.Fatalf("got %d modules, want 13", 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 != 62 {
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..62", 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,7 +95,7 @@ func TestGoAutoMenuGroupsCoverModulesExactlyOnce(t *testing.T) {
}
wantOrder := [][]string{
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks},
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks, ModuleYeekeReturns, ModuleYeekeSyncRuns},
{ModuleSYBShops, ModuleSYBProductFilters, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
}
seen := make(map[string]int)
+8
View File
@@ -124,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},
+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) != 13 {
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)
+1
View File
@@ -105,6 +105,7 @@ func Inventory() []Endpoint {
{"POST", "/purchase-tasks/batch", access.ModulePurchaseTasks, "purchase", buy.AdminBatchCreate},
{"POST", "/purchase-tasks/batch-retry", access.ModulePurchaseTasks, "purchase", buy.AdminBatchRetry},
{"POST", "/purchase-tasks/stock", access.ModulePurchaseTasks, "purchase", buy.AdminCreateStock},
{"POST", "/purchase-tasks/order-backfill", access.ModulePurchaseTasks, "writeback", buy.ClientBackfillOrders},
{"GET", "/ai-matching-settings", access.ModuleAIMatching, "read", func(c *gin.Context) {
db, err := pkg.GetOrm(c)
if err != nil {
+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)
+4
View File
@@ -44,6 +44,10 @@ func MigratedModels() []any {
&models.SYBShop{},
&models.SYBProductFilter{},
&models.SYBSyncRun{},
&models.YeekeSession{},
&models.YeekeReturnPackage{},
&models.YeekeReturnItem{},
&models.YeekeSyncRun{},
&models.SYBInnerCodeRecord{},
&models.SYBInnerCodeItem{},
&models.SYBInnerCodeApplyBatch{},
+1
View File
@@ -38,6 +38,7 @@ type AgentDevice struct {
AndroidVersion string `json:"androidVersion" gorm:"size:32;not null"`
AgentVersion string `json:"agentVersion" gorm:"size:32;not null"`
PDDVersion string `json:"pddVersion" gorm:"size:32;not null"`
OwnerUserID *uint64 `json:"ownerUserId" gorm:"column:owner_user_id;index"`
CapabilitiesJSON string `json:"-" gorm:"size:4096;not null;default:'[]'"`
Status string `json:"status" gorm:"size:16;not null;index;check:ck_agent_device_status,status IN ('online','offline','disabled')"`
TokenDigest string `json:"-" gorm:"size:64;not null;uniqueIndex:ux_agent_device_token_digest"`
+87
View File
@@ -0,0 +1,87 @@
package models
import "time"
// YeekeSession stores only the opaque session material; credentials are kept
// outside the application database and supplied by the administrator at run time.
type YeekeSession struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
Username string `gorm:"size:128;not null;uniqueIndex:ux_yeeke_session_username"`
Token string `json:"-" gorm:"type:text;not null"`
CookiesJSON string `json:"-" gorm:"type:text;not null"`
UserID string `gorm:"size:128;not null;default:''"`
ExpiresAt time.Time `gorm:"not null;index"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (YeekeSession) TableName() string { return "yeeke_session" }
type YeekeReturnPackage struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
ExternalID string `gorm:"size:128;not null;uniqueIndex:ux_yeeke_return_package_external"`
OrderSN string `gorm:"size:128;not null;index"`
TrackingNo string `gorm:"size:128;not null;index"`
ShopID string `gorm:"size:128;not null;default:''"`
ShopName string `gorm:"size:255;not null;default:''"`
WareCode string `gorm:"size:128;not null;default:''"`
WareHouse string `gorm:"size:255;not null;default:''"`
WareName string `gorm:"size:255;not null;default:''"`
ClaimStatus string `gorm:"size:64;not null;default:''"`
// StatusUnrecognized is set when ClaimStatus is not one of the values the
// sync code currently understands. It is never bucketed into a known
// status silently (#336): the raw value is still kept in ClaimStatus, and
// this flag lets an operator find and review these rows.
StatusUnrecognized bool `gorm:"not null;default:false;index"`
ClaimTime *time.Time
CreateTime *time.Time
UpdateTime *time.Time
DestroyDeadLine *time.Time
LastSyncedAt time.Time `gorm:"not null;index"`
SyncStatus string `gorm:"size:32;not null;default:'ok'"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (YeekeReturnPackage) TableName() string { return "yeeke_return_package" }
type YeekeReturnItem struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
PackageID uint64 `gorm:"not null;uniqueIndex:ux_yeeke_return_item_key,priority:1;index"`
ExternalKey string `gorm:"size:512;not null;uniqueIndex:ux_yeeke_return_item_key,priority:2"`
ItemID string `gorm:"size:128;not null;index"`
VariationID string `gorm:"size:128;not null;default:''"`
ItemName string `gorm:"size:500;not null;default:''"`
VariationName string `gorm:"size:500;not null;default:''"`
Image string `gorm:"type:text;not null"`
Quantity int64 `gorm:"not null;default:0"`
LastSyncedAt time.Time `gorm:"not null;index"`
SyncStatus string `gorm:"size:32;not null;default:'ok'"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (YeekeReturnItem) TableName() string { return "yeeke_return_item" }
type YeekeSyncRun struct {
ID uint64 `gorm:"primaryKey;autoIncrement"`
Status string `gorm:"size:32;not null;index"`
Trigger string `gorm:"size:32;not null;index"`
TotalPages int `gorm:"not null;default:0"`
ReadCount int `gorm:"not null;default:0"`
CreatedCount int `gorm:"not null;default:0"`
UpdatedCount int `gorm:"not null;default:0"`
SkippedCount int `gorm:"not null;default:0"`
FailedCount int `gorm:"not null;default:0"`
ErrorMessage string `gorm:"size:1000;not null;default:''"`
StartedAt time.Time `gorm:"not null"`
FinishedAt *time.Time
LastSuccessAt *time.Time
ActiveSlot *uint8 `gorm:"uniqueIndex:ux_yeeke_sync_run_active_slot"`
LeaseOwner string `gorm:"size:128;not null;default:''"`
LeaseExpiresAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
func (YeekeSyncRun) TableName() string { return "yeeke_sync_run" }
+10 -2
View File
@@ -10,6 +10,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"
)
@@ -30,9 +31,16 @@ func (handler Handler) List(c *gin.Context) {
if !ok {
return
}
response, err := service.List(c.Request.Context(), ListRequest{
request := ListRequest{
Page: page, PageSize: pageSize, GoodsID: c.Query("goodsId"), Keyword: c.Query("keyword"), Status: strings.TrimSpace(c.Query("status")),
})
}
claims := jwt.ExtractClaims(c)
if role, _ := claims["rolekey"].(string); role != "admin" {
id, _ := claims["identity"].(float64)
owner := uint64(id)
request.CollectionOwnerUserID = &owner
}
response, err := service.List(c.Request.Context(), request)
if err != nil {
writeError(c, err)
return
+8 -4
View File
@@ -64,10 +64,11 @@ type UpdateRequest struct {
}
type ListRequest struct {
Page, PageSize int
Keyword string
GoodsID string
Status string
Page, PageSize int
Keyword string
GoodsID string
Status string
CollectionOwnerUserID *uint64
}
type ProductView struct {
@@ -266,6 +267,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
}
query = query.Where("status = ?", request.Status)
}
if request.CollectionOwnerUserID != nil {
query = query.Where("EXISTS (SELECT 1 FROM collection_task ct JOIN agent_device ad ON ad.id = ct.device_id WHERE ct.pdd_product_id = pdd_product.id AND ct.source = ? AND ct.status IN ? AND ad.owner_user_id = ?)", models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}, *request.CollectionOwnerUserID)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return ListResponse{}, internalError(err)
+38
View File
@@ -205,6 +205,44 @@ func TestListMarksProductsUnavailableForCollection(t *testing.T) {
}
}
func TestListFiltersManualAssociationProductsByOwnedCollectionDevice(t *testing.T) {
db := openProductDatabase(t)
service := NewService(db)
rule := models.CollectionRule{Name: "owned-rule", ContentJSON: `{}`}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
products := []models.PDDProduct{{GoodsID: "910001", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=910001", Status: "active"}, {GoodsID: "910002", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=910002", Status: "active"}, {GoodsID: "910003", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=910003", Status: "active"}}
if err := db.Create(&products).Error; err != nil {
t.Fatal(err)
}
owned := models.AgentDevice{InstallID: "owned-manual-list", Name: "owned", Manufacturer: "test", Model: "test", AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", Status: models.DeviceStatusOffline, TokenDigest: "owned-digest", TokenIssuedAt: time.Now(), OwnerUserID: ptrUint64(41)}
other := models.AgentDevice{InstallID: "other-manual-list", Name: "other", Manufacturer: "test", Model: "test", AndroidVersion: "14", AgentVersion: "1", PDDVersion: "1", Status: models.DeviceStatusOffline, TokenDigest: "other-digest", TokenIssuedAt: time.Now(), OwnerUserID: ptrUint64(42)}
if err := db.Create(&owned).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&other).Error; err != nil {
t.Fatal(err)
}
for _, task := range []models.CollectionTask{{PDDProductID: &products[0].ID, DeviceID: &owned.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted}, {PDDProductID: &products[1].ID, DeviceID: &other.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted}, {PDDProductID: &products[2].ID, DeviceID: &owned.ID, Source: models.CollectionTaskSourceAdmin, Status: models.TaskStatusCompleted}} {
task.RuleID = rule.ID
task.URLSnapshot = products[0].URL
task.RuleSnapshot = rule.ContentJSON
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
}
got, err := service.List(context.Background(), ListRequest{Page: 1, PageSize: 20, CollectionOwnerUserID: ptrUint64(41)})
if err != nil {
t.Fatal(err)
}
if len(got.Items) != 1 || got.Items[0].GoodsID != products[0].GoodsID {
t.Fatalf("unexpected owned products: %+v", got.Items)
}
}
func ptrUint64(value uint64) *uint64 { return &value }
func TestListDisablesCollectionWhenNoRuleExists(t *testing.T) {
db := openProductDatabase(t)
product := models.PDDProduct{GoodsID: "444444", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=444444", Status: "active"}
+7 -2
View File
@@ -109,6 +109,9 @@ type AdminDetailResponse struct {
Matching MatchingView `json:"matching"`
}
// Query-only value: never a PurchaseTask status or a logistics writeback state.
const adminStatusSYBWritebackSucceeded = "syb_writeback_succeeded"
func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminListResponse, error) {
if req.Page < 1 {
req.Page = 1
@@ -119,7 +122,7 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if req.PageSize > 100 {
req.PageSize = 100
}
if req.Status != "" && !validPurchaseStatus(req.Status) {
if req.Status != "" && req.Status != adminStatusSYBWritebackSucceeded && !validPurchaseStatus(req.Status) {
return AdminListResponse{}, fail(CodeInvalidRequest, "status 无效")
}
if req.ExecutionMode != "" && req.ExecutionMode != models.PurchaseExecutionModeRehearsal && req.ExecutionMode != models.PurchaseExecutionModeLive {
@@ -132,7 +135,9 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if req.TaskID > 0 {
query = query.Where("id = ?", req.TaskID)
}
if req.Status != "" {
if req.Status == adminStatusSYBWritebackSucceeded {
query = query.Where("id IN (?)", s.DB.WithContext(ctx).Model(&models.PurchaseOrderWriteback{}).Select("purchase_task_id").Where("status = ?", "succeeded"))
} else if req.Status != "" {
query = query.Where("status = ?", req.Status)
}
if req.ExecutionMode != "" {
@@ -105,6 +105,63 @@ func TestAdminQueryValidationAndNotFound(t *testing.T) {
}
}
func TestAdminListSYBWritebackSucceededFilter(t *testing.T) {
s, first := orderWritebackFixture(t)
db := s.DB
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", first.ID).Update("status", "succeeded").Error; err != nil {
t.Fatal(err)
}
var successIDs []uint64
successIDs = append(successIDs, first.ID)
for _, state := range []string{"succeeded", "pending", "running", "failed", "unknown", "conflict", "none"} {
task := first
task.ID = 0
task.Status = models.PurchaseTaskStatusCancelled
task.PDDOrderNo = nil
task.WritebackStatus = models.PurchaseWritebackStatusSucceeded // old logistics must not match
task.CreateRequestID = uuid.NewString()
task.UnknownResolveRequestID = nil
if err := db.Create(&task).Error; err != nil {
t.Fatal(err)
}
if state != "none" {
if err := db.Create(&models.PurchaseOrderWriteback{PurchaseTaskID: task.ID, StockID: 2, DetailID: 1, OrderNo: "FILTER-DEMO", Status: state}).Error; err != nil {
t.Fatal(err)
}
}
if state == "succeeded" {
successIDs = append(successIDs, task.ID)
}
}
for page := 1; page <= 2; page++ {
out, err := s.AdminList(context.Background(), AdminListRequest{Status: adminStatusSYBWritebackSucceeded, Page: page, PageSize: 1})
if err != nil || out.Total != 2 || len(out.Items) != 1 || out.Items[0].ID != successIDs[2-page] {
t.Fatalf("page %d: %+v, %v", page, out, err)
}
}
for _, req := range []AdminListRequest{
{Status: adminStatusSYBWritebackSucceeded, TaskID: first.ID},
{Status: models.PurchaseTaskStatusOrderCreated},
{Status: adminStatusSYBWritebackSucceeded, PDDOrderNo: *first.PDDOrderNo, ExecutionMode: "live", TaskType: "syb_order", SYBProductID: *first.SYBProductID},
} {
out, err := s.AdminList(context.Background(), req)
if err != nil || out.Total != 1 || len(out.Items) != 1 || out.Items[0].ID != first.ID {
t.Fatalf("combined filter: %+v %v", out, err)
}
}
out, err := s.AdminList(context.Background(), AdminListRequest{})
if err != nil || out.Total != 8 {
t.Fatalf("clear filter: %+v %v", out, err)
}
out, err = s.AdminList(context.Background(), AdminListRequest{Status: adminStatusSYBWritebackSucceeded, PDDOrderNo: "not-found"})
if err != nil || out.Total != 0 {
t.Fatalf("empty filter: %+v %v", out, err)
}
if validPurchaseStatus(adminStatusSYBWritebackSucceeded) {
t.Fatal("filter became task state")
}
}
func TestAdminQueryHandlersRequireOperatorRole(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, path := range []string{"/api/admin/v1/purchase-tasks", "/api/admin/v1/purchase-tasks/1"} {
+21 -2
View File
@@ -341,6 +341,9 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
if req.ActualUnitPriceCent != nil && *req.ActualUnitPriceCent < 0 {
return TaskPayload{}, fail(CodeInvalidRequest, "实际单价无效")
}
if req.PDDOrderAmountCent != nil && (*req.PDDOrderAmountCent < 0 || req.ResultType != "order_created") {
return TaskPayload{}, fail(CodeInvalidRequest, "订单应付金额仅可随已创建订单提交且不能为负数")
}
now := s.Now()
next := ""
switch req.ResultType {
@@ -396,6 +399,9 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
a.Status = models.PurchaseAttemptStatusFailed
a.ErrorCode, a.ErrorMessage = &failureCode, &message
}
if next == models.PurchaseTaskStatusOrderCreated && req.PDDOrderAmountCent != nil && t.PDDOrderAmountCent == nil {
t.PDDOrderAmountCent = req.PDDOrderAmountCent
}
case "order_result_unknown":
if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusOrderSubmitStarted {
return TaskPayload{}, fail(CodeStateConflict, "当前任务不能标记订单结果未知")
@@ -641,12 +647,25 @@ func (s *Service) resolveProbedSpecs(ctx context.Context, taskID uint64, attempt
request := aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, TargetSize: task.TargetSizeSnapshot, Colors: candidates.Colors, Sizes: candidates.Sizes}
decision := SpecDecisionRequest{RequestID: uuid.NewString(), TaskAttemptID: attemptID, Source: aimatching.SourceAI}
if !complete {
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, "规格探测结果没有包含所需的可选颜色或尺码"))
// #334: a probe that read zero colors and zero sizes (both dimensions
// empty) is a distinct, more specific failure than "candidates present
// but none matched the target" — it usually means the spec panel was
// over-swiped off the device screen. The Agent now fails this case
// explicitly before submitting, but resolveProbedSpecs keeps the same
// explicit code as a defense in depth for any spec_probe_completed
// result that still arrives empty (e.g. older Agent builds).
reason := "规格探测结果没有包含所需的可选颜色或尺码"
code, message := "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码"
if len(candidates.Colors) == 0 && len(candidates.Sizes) == 0 {
reason = "规格探测未读取到任何颜色或尺码"
code, message = "PURCHASE_SPEC_PROBE_EMPTY", "规格探测未读取到任何颜色或尺码"
}
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, reason))
if marshalErr != nil {
return TaskPayload{}, internal(marshalErr)
}
decision.NoMatch, decision.Decision = true, snapshot
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码"
decision.FailureCode, decision.FailureMessage = code, message
} else {
matched, matchErr := s.resolveProbedMatch(ctx, task, request)
valid := matchErr == nil && (matched.Source == "manual_mapping" || matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) &&
+28 -5
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"unicode/utf8"
"go-admin/app/goauto/models"
"gorm.io/gorm"
@@ -115,19 +116,31 @@ func (s *Service) Cancel(ctx context.Context, id uint64, req ManualRequest) (mod
func (s *Service) ResolveUnknown(ctx context.Context, id uint64, req ManualRequest) (models.PurchaseTask, bool, error) {
return s.manual(ctx, id, req, func(t *models.PurchaseTask) error {
if t.Status != models.PurchaseTaskStatusOrderResultUnknown {
return fail(CodeStateConflict, "任务不是订单结果未知状态")
if t.ExecutionMode != models.PurchaseExecutionModeLive || (t.Status != models.PurchaseTaskStatusOrderResultUnknown && t.Status != models.PurchaseTaskStatusOrderCreated) {
return fail(CodeStateConflict, "仅正式采购的未知结果或已创建订单允许补录")
}
if t.Status == models.PurchaseTaskStatusOrderCreated && req.Status != models.PurchaseTaskStatusOrderCreated {
return fail(CodeStateConflict, "已创建订单仅允许补全订单信息")
}
if req.Status != models.PurchaseTaskStatusOrderCreated && req.Status != models.PurchaseTaskStatusCancelled {
return fail(CodeInvalidRequest, "人工处理结果只支持已创建订单或已取消")
}
now := s.Now()
if req.Status == models.PurchaseTaskStatusOrderCreated {
if strings.TrimSpace(req.PDDOrderNo) == "" || req.OrderSubmittedAt == nil {
if strings.TrimSpace(req.PDDOrderNo) == "" || strings.TrimSpace(req.PDDOrderNo) != req.PDDOrderNo || utf8.RuneCountInString(req.PDDOrderNo) > 100 || strings.ContainsAny(req.PDDOrderNo, "\r\n\t") || req.OrderSubmittedAt == nil || req.OrderSubmittedAt.IsZero() || req.OrderSubmittedAt.Year() < 1000 || req.OrderSubmittedAt.Year() > 9999 {
return fail(CodeInvalidRequest, "请填写订单号和下单时间")
}
if req.PDDOrderAmountCent != nil && *req.PDDOrderAmountCent < 0 {
return fail(CodeInvalidRequest, "订单金额不能为负数")
}
if (t.PDDOrderNo != nil && *t.PDDOrderNo != "" && *t.PDDOrderNo != req.PDDOrderNo) || (t.OrderSubmittedAt != nil && !t.OrderSubmittedAt.Equal(*req.OrderSubmittedAt)) || (t.PDDOrderAmountCent != nil && req.PDDOrderAmountCent != nil && *t.PDDOrderAmountCent != *req.PDDOrderAmountCent) {
return fail(CodeResultConflict, "已有不同订单信息,本次未覆盖")
}
t.PDDOrderNo = &req.PDDOrderNo
t.OrderSubmittedAt = req.OrderSubmittedAt
if req.PDDOrderAmountCent != nil {
t.PDDOrderAmountCent = req.PDDOrderAmountCent
}
} else {
t.CancelledAt = &now
t.CancelledBy = &req.OperatorID
@@ -140,12 +153,13 @@ func (s *Service) ResolveUnknown(ctx context.Context, id uint64, req ManualReque
t.StatusVersion++
t.StatusChangedAt = now
return nil
})
}, true)
}
func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, apply func(*models.PurchaseTask) error) (models.PurchaseTask, bool, error) {
func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, apply func(*models.PurchaseTask) error, orderInfo ...bool) (models.PurchaseTask, bool, error) {
var out models.PurchaseTask
replayed := false
isOrderInfo := len(orderInfo) > 0 && orderInfo[0]
if strings.TrimSpace(req.RequestID) == "" || req.OperatorID == 0 {
return out, false, fail(CodeInvalidRequest, "requestId 或操作人无效")
}
@@ -157,6 +171,12 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl
return internal(e)
}
if requestAlreadyApplied(out, req.RequestID) {
if isOrderInfo && out.Status != req.Status {
return fail(CodeResultConflict, "同一requestId不能改变处理结果")
}
if isOrderInfo && req.Status == models.PurchaseTaskStatusOrderCreated && (out.PDDOrderNo == nil || *out.PDDOrderNo != req.PDDOrderNo || out.OrderSubmittedAt == nil || req.OrderSubmittedAt == nil || !out.OrderSubmittedAt.Equal(*req.OrderSubmittedAt) || (req.PDDOrderAmountCent != nil && (out.PDDOrderAmountCent == nil || *out.PDDOrderAmountCent != *req.PDDOrderAmountCent))) {
return fail(CodeResultConflict, "同一requestId不能改变订单信息")
}
replayed = true
return nil
}
@@ -172,6 +192,9 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl
if e := tx.Save(&out).Error; e != nil {
return conflictOrInternal(e)
}
if isOrderInfo && req.Status == models.PurchaseTaskStatusOrderCreated {
return ensureOrderWriteback(tx, out)
}
return nil
})
return out, replayed, err
+55 -11
View File
@@ -61,18 +61,43 @@ type OrderBackfillResponse struct {
Items []OrderBackfillResult `json:"items"`
}
type orderBackfillScope struct{ deviceID *uint64 }
func agentBackfillScope(deviceID uint64) orderBackfillScope {
return orderBackfillScope{deviceID: &deviceID}
}
func clientBackfillScope() orderBackfillScope { return orderBackfillScope{} }
func (scope orderBackfillScope) allows(task models.PurchaseTask) bool {
return scope.deviceID == nil || (task.DeviceID != nil && *task.DeviceID == *scope.deviceID)
}
func (scope orderBackfillScope) readable(db *gorm.DB, taskID uint64, saved *models.PurchaseTask) bool {
query := db.Where("id = ?", taskID)
if scope.deviceID != nil {
query = query.Where("device_id = ?", *scope.deviceID)
}
return query.First(saved).Error == nil
}
func (s *Service) BackfillOrders(ctx context.Context, req OrderBackfillRequest, token string) (OrderBackfillResponse, error) {
out := OrderBackfillResponse{RequestID: req.RequestID}
d, err := device.NewService(s.DB).Authenticate(ctx, token)
if err != nil {
return out, err
}
return s.backfillOrders(ctx, req, agentBackfillScope(d.ID))
}
func (s *Service) BackfillOrdersForClient(ctx context.Context, req OrderBackfillRequest) (OrderBackfillResponse, error) {
return s.backfillOrders(ctx, req, clientBackfillScope())
}
func (s *Service) backfillOrders(ctx context.Context, req OrderBackfillRequest, scope orderBackfillScope) (OrderBackfillResponse, error) {
out := OrderBackfillResponse{RequestID: req.RequestID}
if _, err := uuid.Parse(req.RequestID); err != nil || len(req.Items) == 0 || len(req.Items) > MaxOrderBackfillItems {
return out, fail(CodeInvalidRequest, "requestId 必须为 UUID,items 必须包含 1 到 50 条")
}
ids := make([]uint64, len(req.Items))
orders := make(map[uint64]string)
conflicts := make(map[uint64]bool)
orders, conflicts := make(map[uint64]string), make(map[uint64]bool)
for i, item := range req.Items {
id, err := purchasecontract.ParseAddressSuffix(item.AddressSuffix)
if err != nil {
@@ -90,7 +115,7 @@ func (s *Service) BackfillOrders(ctx context.Context, req OrderBackfillRequest,
if ids[i] == 0 {
r.Code = CodeBackfillSuffix
} else {
r = s.backfillOrder(ctx, d.ID, ids[i], req.RequestID, item, conflicts[ids[i]])
r = s.backfillOrder(ctx, scope, ids[i], req.RequestID, item, conflicts[ids[i]])
r.Index = i
}
out.Items[i] = r
@@ -98,7 +123,7 @@ func (s *Service) BackfillOrders(ctx context.Context, req OrderBackfillRequest,
return out, nil
}
func (s *Service) backfillOrder(ctx context.Context, deviceID, taskID uint64, requestID string, item OrderBackfillItem, batchConflict bool) OrderBackfillResult {
func (s *Service) backfillOrder(ctx context.Context, scope orderBackfillScope, taskID uint64, requestID string, item OrderBackfillItem, batchConflict bool) OrderBackfillResult {
r := OrderBackfillResult{TaskID: taskID, Result: "failed"}
var task models.PurchaseTask
// SQL errors must not print bound order numbers or the task's address snapshot.
@@ -107,7 +132,7 @@ func (s *Service) backfillOrder(ctx context.Context, deviceID, taskID uint64, re
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, taskID).Error; err != nil {
return purchaseNotFound(err)
}
if task.DeviceID == nil || *task.DeviceID != deviceID {
if !scope.allows(task) {
return fail(CodeBackfillDevice, "任务不属于当前设备")
}
if batchConflict {
@@ -133,18 +158,37 @@ func (s *Service) backfillOrder(ctx context.Context, deviceID, taskID uint64, re
if task.PDDOrderNo == nil || *task.PDDOrderNo != item.PDDOrderNo {
return fail(CodeStateConflict, "已创建订单缺少匹配订单号")
}
changed := false
filledSubmittedAt := false
if task.OrderSubmittedAt == nil && item.OrderSubmittedAt != nil {
submitted, parseErr := time.Parse(time.RFC3339Nano, *item.OrderSubmittedAt)
if parseErr != nil || submitted.IsZero() || submitted.Year() < 1000 || submitted.Year() > 9999 {
return fail(CodeOrderTimeInvalid, "下单时间必须为 RFC3339")
}
submitted = submitted.UTC()
task.OrderSubmittedAt = &submitted
changed = true
filledSubmittedAt = true
}
if item.PDDOrderAmountCent != nil {
if task.PDDOrderAmountCent == nil {
task.PDDOrderAmountCent = item.PDDOrderAmountCent
task.StatusVersion++
task.StatusChangedAt = s.Now()
if err := tx.Save(&task).Error; err != nil {
return err
}
changed = true
} else if *task.PDDOrderAmountCent != *item.PDDOrderAmountCent {
r.WarningCode, r.WarningMessage = CodeBackfillAmountConflict, "已有不同实付金额,本次未覆盖"
}
}
if changed {
if filledSubmittedAt {
marker := "backfill:page:" + uuid.NewSHA1(uuid.NameSpaceOID, []byte(requestID+":"+item.AddressSuffix)).String()
task.UnknownResolveRequestID = &marker
}
task.StatusVersion++
task.StatusChangedAt = s.Now()
if err := tx.Save(&task).Error; err != nil {
return err
}
}
r.Result, r.Code = "already_backfilled", "ALREADY_BACKFILLED"
return ensureOrderWriteback(tx, task)
}
@@ -208,7 +252,7 @@ func (s *Service) backfillOrder(ctx context.Context, deviceID, taskID uint64, re
readable := err == nil
if !readable {
saved = models.PurchaseTask{}
readable = db.Where("id = ? AND device_id = ?", taskID, deviceID).First(&saved).Error == nil
readable = scope.readable(db, taskID, &saved)
}
if readable {
r.Status, r.StatusVersion = saved.Status, saved.StatusVersion
@@ -1,6 +1,7 @@
package purchase
import (
"go-admin/common/clientprincipal"
"net/http"
"github.com/gin-gonic/gin"
@@ -23,3 +24,25 @@ func (h Handler) BackfillOrders(c *gin.Context) {
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, gin.H{"data": out})
}
func (h Handler) ClientBackfillOrders(c *gin.Context) {
if _, ok := clientprincipal.Get(c); !ok {
c.JSON(http.StatusUnauthorized, gin.H{"code": http.StatusUnauthorized, "message": "需要客户端身份"})
return
}
var req OrderBackfillRequest
if !decode(c, &req) {
return
}
s, ok := h.service(c)
if !ok {
return
}
out, err := s.BackfillOrdersForClient(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, gin.H{"data": out})
}
@@ -402,6 +402,55 @@ func TestOrderBackfillHTTPBoundary(t *testing.T) {
}
}
func TestClientOrderBackfillDoesNotRequireDeviceOwnership(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
if err := db.Model(&task).Update("device_id", nil).Error; err != nil {
t.Fatal(err)
}
out, err := testService(db).BackfillOrdersForClient(context.Background(), OrderBackfillRequest{
RequestID: uuid.NewString(), Items: []OrderBackfillItem{backfillItem(task.ID, "CLIENT-ORDER")},
})
if err != nil || len(out.Items) != 1 || out.Items[0].Code != "BACKFILLED" {
t.Fatalf("client backfill: %+v %v", out, err)
}
}
func TestClientOrderBackfillFillsOnlyMissingSubmittedTime(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderCreated)
order := "EXISTING-ORDER"
task.PDDOrderNo, task.OrderSubmittedAt = &order, nil
if err := db.Save(&task).Error; err != nil {
t.Fatal(err)
}
first := "2026-09-18T10:00:00+08:00"
item := backfillItem(task.ID, order)
item.OrderSubmittedAt = &first
out, err := s.BackfillOrdersForClient(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{item}})
if err != nil || out.Items[0].Code != "ALREADY_BACKFILLED" || out.Items[0].TimeSource != "page" {
t.Fatalf("fill time: %+v %v", out, err)
}
want, _ := time.Parse(time.RFC3339, first)
got := loadBackfillTask(t, db, task.ID)
if got.OrderSubmittedAt == nil || !got.OrderSubmittedAt.Equal(want) {
t.Fatalf("missing time not filled: %+v", got.OrderSubmittedAt)
}
second := "2026-09-19T10:00:00+08:00"
item.OrderSubmittedAt = &second
out, err = s.BackfillOrdersForClient(context.Background(), OrderBackfillRequest{RequestID: uuid.NewString(), Items: []OrderBackfillItem{item}})
if err != nil || out.Items[0].TimeSource != "page" {
t.Fatalf("preserve source: %+v %v", out, err)
}
got = loadBackfillTask(t, db, task.ID)
if got.OrderSubmittedAt == nil || !got.OrderSubmittedAt.Equal(want) {
t.Fatalf("existing time overwritten: %+v", got.OrderSubmittedAt)
}
}
func TestOrderBackfillConcurrentSameOrderDifferentTasks(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
@@ -0,0 +1,171 @@
package purchase
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func TestOrderInformationManualCompletion(t *testing.T) {
for _, amount := range []int64{0, 1300} {
t.Run(taskNumber(uint64(amount)), func(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
task.TaskType, task.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
if err := db.Save(&task).Error; err != nil {
t.Fatal(err)
}
req := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "DEMO-MANUAL", OrderSubmittedAt: task.IrreversibleAt}
got, _, err := s.ResolveUnknown(context.Background(), task.ID, req)
if err != nil || got.Status != models.PurchaseTaskStatusOrderCreated || got.PDDOrderAmountCent != nil {
t.Fatalf("legacy save: %v", err)
}
row := loadOrderWriteback(t, db, task.ID)
if row.Status != "pending" {
t.Fatal(row.Status)
}
req.RequestID = uuid.NewString()
req.PDDOrderAmountCent = &amount
got, replay, err := s.ResolveUnknown(context.Background(), task.ID, req)
if err != nil || replay || got.PDDOrderAmountCent == nil || *got.PDDOrderAmountCent != amount {
t.Fatalf("fill: %v", err)
}
if got.PaymentReviewStatus != task.PaymentReviewStatus {
t.Fatal("payment status changed")
}
if _, replay, err = s.ResolveUnknown(context.Background(), task.ID, req); err != nil || !replay {
t.Fatalf("replay: %v", err)
}
req.Status = models.PurchaseTaskStatusCancelled
if _, _, err = s.ResolveUnknown(context.Background(), task.ID, req); code(err) != CodeResultConflict {
t.Fatalf("changed replay status: %v", err)
}
req.Status = models.PurchaseTaskStatusOrderCreated
changed := amount + 1
req.PDDOrderAmountCent = &changed
if _, _, err = s.ResolveUnknown(context.Background(), task.ID, req); code(err) != CodeResultConflict {
t.Fatalf("changed replay: %v", err)
}
req.RequestID = uuid.NewString()
if _, _, err = s.ResolveUnknown(context.Background(), task.ID, req); code(err) != CodeResultConflict {
t.Fatalf("overwrite: %v", err)
}
var count int64
db.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", task.ID).Count(&count)
if count != 1 {
t.Fatalf("duplicate queue: %d", count)
}
})
}
}
func TestOrderInformationQueueFailureRollsBack(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
task.TaskType, task.SYBProductID = models.PurchaseTaskTypeSYBOrder, &f.syb.ID
if err := db.Save(&task).Error; err != nil {
t.Fatal(err)
}
if err := db.Callback().Create().Before("gorm:create").Register("326_queue_failure", func(tx *gorm.DB) {
if _, ok := tx.Statement.Dest.(*models.PurchaseOrderWriteback); ok {
tx.AddError(errors.New("synthetic queue failure"))
}
}); err != nil {
t.Fatal(err)
}
defer db.Callback().Create().Remove("326_queue_failure")
amount := int64(100)
_, _, err := s.ResolveUnknown(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "DEMO-ROLLBACK", OrderSubmittedAt: task.IrreversibleAt, PDDOrderAmountCent: &amount})
if err == nil {
t.Fatal("expected failure")
}
got := loadBackfillTask(t, db, task.ID)
if got.PDDOrderNo != nil || got.PDDOrderAmountCent != nil || got.Status != task.Status {
t.Fatal("partial transaction saved")
}
}
func TestOrderWritebackFieldBasedEligibility(t *testing.T) {
for _, status := range []string{models.PurchaseTaskStatusOrderCreated, models.PurchaseTaskStatusFailed, models.PurchaseTaskStatusCancelled, models.PurchaseTaskStatusOrderResultUnknown} {
t.Run(status, func(t *testing.T) {
s, task := orderWritebackFixture(t)
task.Status = status
task.OrderSubmittedAt = nil
task.PDDOrderAmountCent = nil
if err := s.DB.Save(&task).Error; err != nil {
t.Fatal(err)
}
// Existing queued items must also pass the same relaxed worker check.
fake := &fakeOrderNumberClient{apply: true}
if _, err := wbWorker(s, fake).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if fake.writes != 1 || loadOrderWriteback(t, s.DB, task.ID).Status != "succeeded" {
t.Fatal("status/time/amount incorrectly gated worker")
}
// A failed writeback is selectable for manual compensation.
s.DB.Model(&models.PurchaseOrderWriteback{}).Where("purchase_task_id = ?", task.ID).Update("status", "failed")
views, err := s.OrderWritebackViews(context.Background(), []models.PurchaseTask{task})
if err != nil || !views[task.ID].CanSubmit {
t.Fatalf("view: %v", err)
}
result, err := s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
if err != nil || result.Items[0].Result != "pending" {
t.Fatalf("request: %v", err)
}
s.DB.Model(&models.SYBProduct{}).Where("id = ?", *task.SYBProductID).Update("detail_id", 0)
views, err = s.OrderWritebackViews(context.Background(), []models.PurchaseTask{task})
if err != nil || views[task.ID].CanSubmit {
t.Fatal("invalid target selectable")
}
result, err = s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}})
if err != nil || result.Items[0].Result != "skipped" {
t.Fatal("invalid target accepted")
}
})
}
}
func TestOrderInformationRejectInvalidInputs(t *testing.T) {
for _, name := range []string{"negative", "zero_time", "blank_order", "running", "rehearsal"} {
t.Run(name, func(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task := backfillTask(t, db, f, models.PurchaseTaskStatusOrderResultUnknown)
amount := int64(100)
stamp := *task.IrreversibleAt
req := ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchaseTaskStatusOrderCreated, PDDOrderNo: "DEMO-INVALID", OrderSubmittedAt: &stamp, PDDOrderAmountCent: &amount}
switch name {
case "negative":
amount = -1
case "zero_time":
stamp = time.Time{}
case "blank_order":
req.PDDOrderNo = " "
case "running":
task.Status = models.PurchaseTaskStatusRunning
case "rehearsal":
task.ExecutionMode = models.PurchaseExecutionModeRehearsal
}
if err := db.Save(&task).Error; err != nil {
t.Fatal(err)
}
if _, _, err := s.ResolveUnknown(context.Background(), task.ID, req); err == nil {
t.Fatal("invalid input accepted")
}
if loadBackfillTask(t, db, task.ID).PDDOrderNo != nil {
t.Fatal("invalid input persisted")
}
})
}
}
@@ -0,0 +1,88 @@
package purchase
import (
"context"
"testing"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func TestOrderResultPayableAmount(t *testing.T) {
ptr := func(n int64) *int64 { return &n }
for _, tc := range []struct {
name string
amount, existing, want *int64
result string
invalid bool
}{
{"payable distinct from unit price", ptr(1300), nil, ptr(1300), "order_created", false},
{"zero", ptr(0), nil, ptr(0), "order_created", false},
{"old agent omitted", nil, nil, nil, "order_created", false},
{"omitted preserves existing", nil, ptr(1200), ptr(1200), "order_created", false},
{"does not overwrite existing", ptr(1300), ptr(1200), ptr(1200), "order_created", false},
{"negative", ptr(-1), nil, nil, "order_created", true},
{"unknown cannot attach amount", ptr(1300), nil, nil, "order_result_unknown", true},
{"failed cannot attach amount", ptr(1300), nil, nil, "failed", true},
} {
t.Run(tc.name, func(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
started := startLivePurchaseAfterProbe(t, s, f, task)
if _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
if tc.existing != nil {
if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Update("pdd_order_amount_cent", *tc.existing).Error; err != nil {
t.Fatal(err)
}
}
now := s.Now()
req := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID,
ResultType: tc.result, PDDOrderNo: "PDD-DEMO-325", OrderSubmittedAt: &now,
ActualUnitPriceCent: ptr(1600), PDDOrderAmountCent: tc.amount}
_, err = s.SubmitResult(context.Background(), task.ID, req, f.token)
if tc.invalid {
if code(err) != CodeInvalidRequest {
t.Fatalf("want invalid request, got %v", err)
}
} else {
if err != nil {
t.Fatal(err)
}
replay, e := s.SubmitResult(context.Background(), task.ID, req, f.token)
if e != nil || !replay.Replayed {
t.Fatalf("replay failed: %v", e)
}
req.PDDOrderAmountCent = ptr(999)
if _, e = s.SubmitResult(context.Background(), task.ID, req, f.token); code(e) != CodeResultConflict {
t.Fatalf("changed amount replay accepted: %v", e)
}
queued := loadOrderWriteback(t, db, task.ID)
if queued.OrderNo != "PDD-DEMO-325" {
t.Fatal("SYB order number queue missing")
}
}
var saved models.PurchaseTask
if e := db.First(&saved, task.ID).Error; e != nil {
t.Fatal(e)
}
if (saved.PDDOrderAmountCent == nil) != (tc.want == nil) || (tc.want != nil && *saved.PDDOrderAmountCent != *tc.want) {
t.Fatal("unexpected saved payable amount")
}
if tc.invalid {
if saved.Status != models.PurchaseTaskStatusOrderSubmitStarted {
t.Fatal("invalid result changed status")
}
} else if saved.ActualUnitPriceCent == nil || *saved.ActualUnitPriceCent != 1600 {
t.Fatal("unit price changed")
}
})
}
}
+52 -10
View File
@@ -37,7 +37,11 @@ type OrderWritebackResponse struct {
}
func orderWritebackEligible(t models.PurchaseTask) bool {
return t.ExecutionMode == models.PurchaseExecutionModeLive && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.Status == models.PurchaseTaskStatusOrderCreated && t.SYBProductID != nil && t.PDDOrderNo != nil && strings.TrimSpace(*t.PDDOrderNo) != ""
return t.ExecutionMode == models.PurchaseExecutionModeLive && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.SYBProductID != nil && t.PDDOrderNo != nil && strings.TrimSpace(*t.PDDOrderNo) != ""
}
func validOrderWritebackTarget(syb models.SYBProduct) bool {
return syb.StockID > 0 && syb.DetailID > 0 && syb.StockID <= 1<<63-1 && syb.DetailID <= 1<<63-1
}
// Called within the same transaction as the order fact. Never contacts SYB.
@@ -52,10 +56,7 @@ func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
}
return err
}
if syb.StockID <= 0 || syb.DetailID <= 0 {
return nil
}
if syb.StockID > 1<<63-1 || syb.DetailID > 1<<63-1 {
if !validOrderWritebackTarget(syb) {
return nil
}
row := models.PurchaseOrderWriteback{PurchaseTaskID: t.ID, StockID: int64(syb.StockID), DetailID: int64(syb.DetailID), OrderNo: *t.PDDOrderNo, Status: "pending"}
@@ -65,11 +66,31 @@ func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]OrderWritebackView, error) {
out := map[uint64]OrderWritebackView{}
ids := make([]uint64, 0, len(tasks))
var sybIDs []uint64
for _, t := range tasks {
if orderWritebackEligible(t) {
sybIDs = append(sybIDs, *t.SYBProductID)
}
}
validTargets := map[uint64]bool{}
if len(sybIDs) > 0 {
var targets []models.SYBProduct
if err := s.DB.WithContext(ctx).Select("id", "stock_id", "detail_id").Where("id IN ?", sybIDs).Find(&targets).Error; err != nil {
return nil, internal(err)
}
for _, target := range targets {
validTargets[target.ID] = validOrderWritebackTarget(target)
}
}
for _, t := range tasks {
ids = append(ids, t.ID)
v := OrderWritebackView{Status: "not_applicable", Reason: "不符合正式SYB订单回填条件"}
v := OrderWritebackView{Status: "not_applicable", Reason: "需要正式SYB采购任务、订单号及有效SYB明细"}
if orderWritebackEligible(t) {
v = OrderWritebackView{Status: "not_started", CanSubmit: true}
if validTargets[*t.SYBProductID] {
v = OrderWritebackView{Status: "not_started", CanSubmit: true}
} else {
v.Reason = "SYB明细关联已失效"
}
}
out[t.ID] = v
}
@@ -83,9 +104,16 @@ func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.Purcha
for _, r := range rows {
v := out[r.PurchaseTaskID]
v.Status = r.Status
v.Reason = r.ErrorMessage
if r.ErrorMessage != "" || v.CanSubmit || r.Status == "succeeded" {
v.Reason = r.ErrorMessage
}
v.CompletedAt = r.CompletedAt
v.CanSubmit = v.CanSubmit && (r.Status == "failed" || r.Status == "unknown") && (r.LeaseExpiresAt == nil || !r.LeaseExpiresAt.After(s.Now()))
// A session-class failure's LeaseExpiresAt is the automatic-retry backoff
// deadline (#330 修订2, order_writeback_worker.go finishSessionUnavailable),
// not an in-flight write lease — manual "resubmit" must stay available
// during that window instead of being hidden until it expires.
sessionBackoff := r.Status == "failed" && r.ErrorCode == "SYB_SESSION_UNAVAILABLE"
v.CanSubmit = v.CanSubmit && (r.Status == "failed" || r.Status == "unknown") && (sessionBackoff || r.LeaseExpiresAt == nil || !r.LeaseExpiresAt.After(s.Now()))
out[r.PurchaseTaskID] = v
}
return out, nil
@@ -141,6 +169,15 @@ func (s *Service) RequestOrderWriteback(ctx context.Context, req OrderWritebackR
out.Items = append(out.Items, a)
continue
}
var target models.SYBProduct
if err := tx.First(&target, *task.SYBProductID).Error; err != nil || !validOrderWritebackTarget(target) {
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
a.Reason = "SYB明细关联已失效"
out.Items = append(out.Items, a)
continue
}
if err := ensureOrderWriteback(tx, task); err != nil {
return err
}
@@ -165,7 +202,12 @@ func (s *Service) RequestOrderWriteback(ctx context.Context, req OrderWritebackR
case row.Status == "pending":
a.Result, a.Reason = "pending", "已加入回填"
default:
if err := tx.Model(&row).Updates(map[string]any{"status": "pending", "write_started": false, "lease_owner": "", "lease_expires_at": nil, "error_code": "", "error_message": ""}).Error; err != nil {
// Manual resubmit resets attempt_count to 0 (#330 修订3) so a stale
// history of automatic session-class retries never eats into a
// fresh manual attempt budget, and clears lease_expires_at so the
// worker's bounded auto-retry claim (which requires it non-nil)
// does not race a double-claim against this manual pending row.
if err := tx.Model(&row).Updates(map[string]any{"status": "pending", "write_started": false, "lease_owner": "", "lease_expires_at": nil, "error_code": "", "error_message": "", "attempt_count": 0}).Error; err != nil {
return err
}
a.Result, a.Reason = "pending", "已加入回填,将先回读SYB"
@@ -0,0 +1,391 @@
package purchase
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybclient"
"go-admin/config"
"gorm.io/gorm"
)
// wbFactoryWorker builds a worker whose Factory itself fails, exercising the
// restoreOrderWritebackClient failure path (session unavailable) rather than
// a remote read/write failure on an otherwise-working client.
func wbFactoryWorker(s *Service, err error) *OrderWritebackWorker {
return &OrderWritebackWorker{DB: s.DB, Now: s.Now, Factory: func(context.Context, *gorm.DB) (OrderNumberClient, error) { return nil, err }}
}
func TestOrderWritebackSessionFailureSchedulesBoundedRetry(t *testing.T) {
s, task := orderWritebackFixture(t)
if ok, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil || !ok {
t.Fatalf("run %v %v", ok, err)
}
row := loadOrderWriteback(t, s.DB, task.ID)
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
}
if row.ErrorMessage == "" || len(row.ErrorMessage) > 300 {
t.Fatalf("error message not recorded safely: %q", row.ErrorMessage)
}
if !strings.Contains(row.ErrorMessage, "会话缺失/已过期") {
t.Fatalf("category missing from message: %q", row.ErrorMessage)
}
if !strings.Contains(row.ErrorMessage, "将自动重试") || !strings.Contains(row.ErrorMessage, "恢复登录") {
t.Fatalf("message is not actionable: %q", row.ErrorMessage)
}
if row.LeaseExpiresAt == nil || !row.LeaseExpiresAt.After(s.Now()) {
t.Fatal("no backoff scheduled for first session-class failure")
}
if row.AttemptCount != 1 {
t.Fatalf("attempt_count=%d", row.AttemptCount)
}
}
// TestSessionRetryBackoffTotalExceedsHourlySyncWindow guards the ticket's
// blocker: the cumulative auto-retry window must outlast one hourly sync
// period (up to ~60 minutes from failure to the refresh that fixes it),
// otherwise attempts run out before the session has a chance to recover.
func TestSessionRetryBackoffTotalExceedsHourlySyncWindow(t *testing.T) {
if len(sessionRetryBackoff) != maxSessionRetryAttempts-1 {
t.Fatalf("expected %d backoff steps for %d attempts, got %d", maxSessionRetryAttempts-1, maxSessionRetryAttempts, len(sessionRetryBackoff))
}
var total time.Duration
for _, d := range sessionRetryBackoff {
total += d
}
if total <= time.Hour {
t.Fatalf("total backoff %s must exceed one hourly sync period", total)
}
}
func TestOrderWritebackSessionFailureNotReclaimedBeforeBackoffExpires(t *testing.T) {
s, _ := orderWritebackFixture(t)
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
f := &fakeOrderNumberClient{apply: true}
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || ok {
t.Fatalf("claimed before backoff expired: ok=%v err=%v", ok, err)
}
if f.writes != 0 {
t.Fatal("wrote while still inside backoff window")
}
}
// loadOrderWriteback in order_writeback_test.go takes (t, db, id); provide a
// small adapter so this file reads naturally when task id is already in hand.
func loadOrderWritebackByTask(t *testing.T, s *Service, id uint64) models.PurchaseOrderWriteback {
return loadOrderWriteback(t, s.DB, id)
}
func TestOrderWritebackSessionFailureReclaimedAfterBackoffExpires(t *testing.T) {
s, task := orderWritebackFixture(t)
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
row := loadOrderWritebackByTask(t, s, task.ID)
s.Now = func() time.Time { return row.LeaseExpiresAt.Add(time.Second) }
f := &fakeOrderNumberClient{apply: true}
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || !ok {
t.Fatalf("not reclaimed after backoff expired: ok=%v err=%v", ok, err)
}
if f.writes != 1 {
t.Fatal("did not write after successful reclaim")
}
after := loadOrderWritebackByTask(t, s, task.ID)
if after.Status != "succeeded" {
t.Fatalf("status=%s", after.Status)
}
}
func TestOrderWritebackSessionFailureStopsRetryingAtMaxAttempts(t *testing.T) {
s, task := orderWritebackFixture(t)
now := s.Now()
for i := 0; i < maxSessionRetryAttempts; i++ {
s.Now = func() time.Time { return now }
if ok, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil || !ok {
t.Fatalf("attempt %d: ok=%v err=%v", i+1, ok, err)
}
row := loadOrderWritebackByTask(t, s, task.ID)
if row.AttemptCount != i+1 {
t.Fatalf("attempt %d: attempt_count=%d", i+1, row.AttemptCount)
}
if i+1 < maxSessionRetryAttempts {
if row.LeaseExpiresAt == nil {
t.Fatalf("attempt %d: no backoff scheduled", i+1)
}
now = row.LeaseExpiresAt.Add(time.Second)
} else {
if row.LeaseExpiresAt != nil {
t.Fatal("lease still scheduled at max attempts")
}
}
}
// One more tick past any plausible backoff: the claim query must exclude
// attempt_count >= maxSessionRetryAttempts, so nothing is claimed.
s.Now = func() time.Time { return now.Add(24 * time.Hour) }
f := &fakeOrderNumberClient{apply: true}
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || ok {
t.Fatalf("claimed a row past max attempts: ok=%v err=%v", ok, err)
}
}
func TestOrderWritebackCheckSessionInvalidIsSessionClassAndNeverDeletesSession(t *testing.T) {
s, task := orderWritebackFixture(t)
seedWritebackSession(t, s)
if ok, err := wbFactoryWorker(s, sybclient.ErrSessionInvalid).RunOnce(context.Background()); err != nil || !ok {
t.Fatalf("run %v %v", ok, err)
}
row := loadOrderWritebackByTask(t, s, task.ID)
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
}
if row.LeaseExpiresAt == nil {
t.Fatal("ErrSessionInvalid was not scheduled for retry")
}
assertWritebackSessionUntouched(t, s)
}
func TestOrderWritebackCheckSessionNetworkErrorIsSessionClassAndNeverDeletesSession(t *testing.T) {
s, task := orderWritebackFixture(t)
seedWritebackSession(t, s)
if ok, err := wbFactoryWorker(s, errors.New("dial tcp: i/o timeout")).RunOnce(context.Background()); err != nil || !ok {
t.Fatalf("run %v %v", ok, err)
}
row := loadOrderWritebackByTask(t, s, task.ID)
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
}
if row.LeaseExpiresAt == nil {
t.Fatal("network error was not scheduled for retry")
}
assertWritebackSessionUntouched(t, s)
}
func seedWritebackSession(t *testing.T, s *Service) {
t.Helper()
store := sybclient.NewSessionStore(s.DB)
// restoreOrderWritebackClient's SessionStore.Load compares against real
// wall-clock time.Now(), not the service's mocked s.Now (which fixtures
// pin to a fixed past date) — so the session must expire relative to the
// real clock or Load reports ErrNoSession even though a row exists.
if err := store.Save(context.Background(), sybclient.Session{
Username: "syb-writeback-test", UserID: 555, CookiesJSON: `[{"name":"SESSION","value":"x"}]`, ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
}
func assertWritebackSessionUntouched(t *testing.T, s *Service) {
t.Helper()
var count int64
if err := s.DB.Model(&models.SYBSession{}).Where("username = ?", "syb-writeback-test").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatal("writeback worker deleted or otherwise removed the cached SYB session")
}
}
func TestOrderWritebackCanSubmitDuringSessionBackoff(t *testing.T) {
s, task := orderWritebackFixture(t)
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
updated := loadBackfillTask(t, s.DB, task.ID)
views, err := s.OrderWritebackViews(context.Background(), []models.PurchaseTask{updated})
if err != nil {
t.Fatal(err)
}
v := views[task.ID]
if v.Status != "failed" || !v.CanSubmit {
t.Fatalf("expected resubmit available during backoff: %+v", v)
}
}
func TestOrderWritebackManualResubmitResetsAttemptCountAndLease(t *testing.T) {
s, task := orderWritebackFixture(t)
now := s.Now()
for i := 0; i < 3; i++ {
s.Now = func() time.Time { return now }
if _, err := wbFactoryWorker(s, sybclient.ErrNoSession).RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
row := loadOrderWritebackByTask(t, s, task.ID)
now = row.LeaseExpiresAt.Add(time.Second)
}
before := loadOrderWritebackByTask(t, s, task.ID)
if before.AttemptCount != 3 {
t.Fatalf("attempt_count=%d", before.AttemptCount)
}
if _, err := s.RequestOrderWriteback(context.Background(), OrderWritebackRequest{uuid.NewString(), []uint64{task.ID}}); err != nil {
t.Fatal(err)
}
after := loadOrderWritebackByTask(t, s, task.ID)
if after.Status != "pending" || after.AttemptCount != 0 || after.LeaseExpiresAt != nil {
t.Fatalf("manual resubmit did not reset state: %+v", after)
}
f := &fakeOrderNumberClient{apply: true}
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || !ok || f.writes != 1 {
t.Fatalf("worker could not process post-resubmit row: ok=%v err=%v writes=%d", ok, err, f.writes)
}
}
func TestOrderWritebackOtherFailureCodesAreNotAutoRetried(t *testing.T) {
s, task := orderWritebackFixture(t)
f := &fakeOrderNumberClient{readErr: errors.New("offline")}
if ok, err := wbWorker(s, f).RunOnce(context.Background()); err != nil || !ok {
t.Fatalf("run %v %v", ok, err)
}
row := loadOrderWritebackByTask(t, s, task.ID)
if row.Status != "failed" || row.ErrorCode != "SYB_READ_FAILED" {
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
}
if row.LeaseExpiresAt != nil {
t.Fatal("non-session failure code must not be scheduled for automatic retry")
}
s.Now = func() time.Time { return row.CreatedAt.Add(24 * time.Hour) }
again := &fakeOrderNumberClient{apply: true}
if ok, err := wbWorker(s, again).RunOnce(context.Background()); err != nil || ok {
t.Fatalf("a non-session failure code was auto-reclaimed: ok=%v err=%v", ok, err)
}
}
// --- restoreOrderWritebackClient against a real sybclient.Client + emulated
// SYB /am/user/get, so the CheckSession probe added by #330 is actually
// exercised end to end instead of only through a fake Factory. ---
// sybUserGetServer emulates the one endpoint restoreOrderWritebackClient's
// CheckSession call depends on, using the real envelope shape documented in
// sybclient/client.go's `envelope` type and asserted against in
// sybclient/client_test.go.
func sybUserGetServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request)) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/am/user/get", handler)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// withWritebackSYBConfig points config.ExtConfig.SYB at the given test
// server for the duration of the test, restoring the previous value
// afterwards so other tests (and any parallel config reads) are unaffected.
func withWritebackSYBConfig(t *testing.T, baseURL, username string) {
t.Helper()
prev := config.ExtConfig.SYB
config.ExtConfig.SYB = config.SYB{BaseURL: baseURL, Username: username}
t.Cleanup(func() { config.ExtConfig.SYB = prev })
}
func envelopeOK(w http.ResponseWriter, data any) {
body, _ := json.Marshal(data)
env, _ := json.Marshal(map[string]any{"status": true, "msg": "获取成功", "data": json.RawMessage(body), "code": nil})
w.Header().Set("Content-Type", "application/json")
w.Write(env)
}
func TestRestoreOrderWritebackClientValidSessionReturnsClient(t *testing.T) {
s, _ := orderWritebackFixture(t)
seedWritebackSession(t, s)
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("id") != strconv.FormatInt(555, 10) {
t.Fatalf("unexpected id query: %s", r.URL.RawQuery)
}
envelopeOK(w, map[string]any{"id": 555, "username": "syb-writeback-test"})
})
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
client, err := restoreOrderWritebackClient(context.Background(), s.DB)
if err != nil || client == nil {
t.Fatalf("expected a usable client, got client=%v err=%v", client, err)
}
assertWritebackSessionUntouched(t, s)
}
func TestRestoreOrderWritebackClientMismatchedUsernameIsSessionInvalid(t *testing.T) {
s, _ := orderWritebackFixture(t)
seedWritebackSession(t, s)
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
// SYB says the cookie now belongs to a different account (12 §3.5):
// treated the same as an explicit logout.
envelopeOK(w, map[string]any{"id": 555, "username": "somebody-else"})
})
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
_, err := restoreOrderWritebackClient(context.Background(), s.DB)
if !errors.Is(err, sybclient.ErrSessionInvalid) {
t.Fatalf("expected ErrSessionInvalid, got %v", err)
}
assertWritebackSessionUntouched(t, s)
}
func TestRestoreOrderWritebackClient500IsNotSessionInvalid(t *testing.T) {
s, _ := orderWritebackFixture(t)
seedWritebackSession(t, s)
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
_, err := restoreOrderWritebackClient(context.Background(), s.DB)
if err == nil {
t.Fatal("expected an error for a 5xx response")
}
if errors.Is(err, sybclient.ErrSessionInvalid) {
t.Fatalf("a 5xx must not be classified as a confirmed logout, got %v", err)
}
assertWritebackSessionUntouched(t, s)
}
func TestRestoreOrderWritebackClientTimeoutIsNotSessionInvalid(t *testing.T) {
s, _ := orderWritebackFixture(t)
seedWritebackSession(t, s)
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done() // never respond; the client-side ctx timeout fires first
})
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
_, err := restoreOrderWritebackClient(ctx, s.DB)
if err == nil {
t.Fatal("expected an error for a request that never completes")
}
if errors.Is(err, sybclient.ErrSessionInvalid) {
t.Fatalf("a timeout must not be classified as a confirmed logout, got %v", err)
}
assertWritebackSessionUntouched(t, s)
}
// TestOrderWritebackWorkerWithRealFactoryOnInvalidSession is the requested
// end-to-end case: the worker's actual Factory (restoreOrderWritebackClient)
// against a server that reports the cached session invalid. It must record
// SYB_SESSION_UNAVAILABLE with a scheduled backoff and must not touch the
// cached session row.
func TestOrderWritebackWorkerWithRealFactoryOnInvalidSession(t *testing.T) {
s, task := orderWritebackFixture(t)
seedWritebackSession(t, s)
srv := sybUserGetServer(t, func(w http.ResponseWriter, r *http.Request) {
envelopeOK(w, map[string]any{"id": 555, "username": "somebody-else"})
})
withWritebackSYBConfig(t, srv.URL, "syb-writeback-test")
w := &OrderWritebackWorker{DB: s.DB, Now: s.Now, Factory: restoreOrderWritebackClient}
if ok, err := w.RunOnce(context.Background()); err != nil || !ok {
t.Fatalf("run %v %v", ok, err)
}
row := loadOrderWritebackByTask(t, s, task.ID)
if row.Status != "failed" || row.ErrorCode != "SYB_SESSION_UNAVAILABLE" {
t.Fatalf("status=%s code=%s", row.Status, row.ErrorCode)
}
if row.LeaseExpiresAt == nil {
t.Fatal("no backoff scheduled")
}
assertWritebackSessionUntouched(t, s)
}
@@ -223,7 +223,7 @@ func TestOrderWritebackEnqueueRollbackAndSameOrderReplay(t *testing.T) {
var dev models.AgentDevice
s.DB.First(&dev, *task.DeviceID)
// Directly exercise the already_backfilled branch without needing a raw token.
r := s.backfillOrder(context.Background(), dev.ID, task.ID, uuid.NewString(), backfillItem(task.ID, *task.PDDOrderNo), false)
r := s.backfillOrder(context.Background(), agentBackfillScope(dev.ID), task.ID, uuid.NewString(), backfillItem(task.ID, *task.PDDOrderNo), false)
if r.Result != "already_backfilled" {
t.Fatal(r.Code)
}
@@ -24,12 +24,80 @@ type OrderWritebackWorker struct {
Factory func(context.Context, *gorm.DB) (OrderNumberClient, error)
}
// errSessionUserIDMissing marks a cached session whose UserID column is not a
// positive SYB account id. SessionStore.Save (session.go) rejects UserID<=0
// before it is ever persisted, so this should be unreachable in practice; it
// exists so a corrupted/legacy row fails loudly and safely instead of calling
// CheckSession with id=0 (#330 修订1).
var errSessionUserIDMissing = errors.New("SYB 会话记录缺少有效 user id")
// Bounded auto-retry for session-class writeback failures (#330). A session
// outage self-heals once GoAutoSYBHourlySync refreshes syb_session, but that
// refresh only happens once per hour (at :05) and only fires the run *after*
// the session is found dead — so the wait from failure to refresh can be
// close to a full hour. The backoff schedule below sums to ~90 minutes
// (5+10+15+30+30) across maxSessionRetryAttempts=6 attempts, deliberately
// longer than one hourly sync period so a session recovered by "the next"
// hourly run is still caught automatically instead of exhausting attempts
// first. maxSessionRetryAttempts caps the automatic attempts so a session
// that never recovers still lands back in "failed" for a human instead of
// retrying forever.
const maxSessionRetryAttempts = 6
var sessionRetryBackoff = []time.Duration{
5 * time.Minute,
10 * time.Minute,
15 * time.Minute,
30 * time.Minute,
30 * time.Minute,
}
// sessionRetryDelay returns the backoff before the next automatic attempt,
// given the attempt number (1-based, i.e. the count already recorded for the
// attempt that just failed).
func sessionRetryDelay(attempt int) time.Duration {
idx := attempt - 1
if idx < 0 {
idx = 0
}
if idx >= len(sessionRetryBackoff) {
idx = len(sessionRetryBackoff) - 1
}
return sessionRetryBackoff[idx]
}
// sessionUnavailableMessage classifies why the cached SYB session could not
// be used, without ever including cookies, tokens or other credential
// material (#330 修订1点3). The category — not the raw error text — is what
// gets persisted to error_message, wrapped in a fixed, actionable template
// that stays well under the 300-char column limit.
func sessionUnavailableMessage(err error) string {
category := "会话恢复失败(网络/其他)"
switch {
case errors.Is(err, sybclient.ErrNoSession):
category = "会话缺失/已过期"
case errors.Is(err, errSessionUserIDMissing):
category = "会话记录异常,缺少 user id"
case errors.Is(err, sybclient.ErrSessionInvalid):
category = "会话校验失效"
}
return "SYB会话不可用(" + category + "),将自动重试;如持续失败请恢复登录后重试"
}
// restoreOrderWritebackClient rebuilds a SYB client from the cached session
// only. It never logs in, never triggers OCR and never deletes the cached
// session (that stays the exclusive responsibility of sybimport.Connect's
// login/refresh path) — it only reports whether the cached cookies still
// work, via CheckSession, so the caller can classify the failure (#330).
func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberClient, error) {
cfg := config.ExtConfig.SYB.Resolved()
session, err := sybclient.NewSessionStore(db).Load(ctx, cfg.Username, time.Now())
if err != nil {
return nil, err
}
if session.UserID <= 0 {
return nil, errSessionUserIDMissing
}
c, err := sybclient.New(cfg.BaseURL)
if err != nil {
return nil, err
@@ -37,6 +105,16 @@ func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberC
if err = c.ImportCookiesJSON(session.CookiesJSON); err != nil {
return nil, err
}
// Active probe (#330 修订1): without this, a remotely-expired cookie jar
// imports cleanly and only fails later inside read(), which would record
// it as SYB_READ_FAILED instead of the retryable session-class outcome.
// Any error here — ErrSessionInvalid or network/format — is treated as
// session-class; only ErrSessionInvalid is a confirmed logout, but a
// network/format error is not confirmed-valid either, so it is still
// retried rather than attempted as a write.
if err = c.CheckSession(ctx, session.UserID, cfg.Username); err != nil {
return nil, err
}
return c, nil
}
@@ -78,7 +156,10 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
var item models.PurchaseOrderWriteback
recovering := false
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("status = ? OR (status = ? AND lease_expires_at <= ?)", "pending", "running", now).Order("id").First(&item).Error; err != nil {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where(
"status = ? OR (status = ? AND lease_expires_at <= ?) OR (status = ? AND error_code = ? AND lease_expires_at IS NOT NULL AND lease_expires_at <= ? AND attempt_count < ?)",
"pending", "running", now, "failed", "SYB_SESSION_UNAVAILABLE", now, maxSessionRetryAttempts,
).Order("id").First(&item).Error; err != nil {
return err
}
recovering = item.Status == "running"
@@ -90,6 +171,11 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
if err != nil {
return false, err
}
// tx.Model(&item).Updates used gorm.Expr("attempt_count + 1") above, which
// GORM does not read back into the struct; sync it here so downstream
// bounded-retry math (finishSessionUnavailable) sees the true post-claim
// count instead of being off by one.
item.AttemptCount++
finish := func(status, code, message string) error {
updates := map[string]any{"status": status, "error_code": code, "error_message": message, "lease_owner": ""}
if status != "unknown" {
@@ -100,6 +186,23 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
}
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
}
// finishSessionUnavailable is the bounded-retry counterpart of finish for
// SYB_SESSION_UNAVAILABLE: instead of clearing the lease, it schedules the
// next automatic attempt (item.AttemptCount was already incremented by the
// claim above) until maxSessionRetryAttempts is reached, at which point it
// behaves like finish("failed", ...) and stops retrying (#330).
finishSessionUnavailable := func(err error) error {
updates := map[string]any{
"status": "failed", "error_code": "SYB_SESSION_UNAVAILABLE",
"error_message": sessionUnavailableMessage(err), "lease_owner": "",
}
if item.AttemptCount < maxSessionRetryAttempts {
updates["lease_expires_at"] = w.Now().Add(sessionRetryDelay(item.AttemptCount))
} else {
updates["lease_expires_at"] = nil
}
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
}
var task models.PurchaseTask
if err = db.First(&task, item.PurchaseTaskID).Error; err != nil {
return true, finish("failed", "TASK_UNAVAILABLE", "采购任务不可用,请人工核对")
@@ -108,14 +211,14 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
if !orderWritebackEligible(task) || *task.PDDOrderNo != item.OrderNo {
return true, finish("conflict", "ORDER_FACT_CHANGED", "采购订单事实已变化,请人工核对")
}
if err = db.First(&syb, *task.SYBProductID).Error; err != nil || int64(syb.StockID) != item.StockID || int64(syb.DetailID) != item.DetailID {
if err = db.First(&syb, *task.SYBProductID).Error; err != nil || !validOrderWritebackTarget(syb) || int64(syb.StockID) != item.StockID || int64(syb.DetailID) != item.DetailID {
return true, finish("conflict", "SYB_TARGET_CHANGED", "SYB商品明细关联已变化")
}
callCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
client, err := w.Factory(callCtx, db)
cancel()
if err != nil {
return true, finish("failed", "SYB_SESSION_UNAVAILABLE", "SYB会话不可用,请恢复登录后重试")
return true, finishSessionUnavailable(err)
}
read := func() (string, string, error) {
readCtx, stop := context.WithTimeout(ctx, 20*time.Second)
@@ -592,6 +592,47 @@ func TestLiveProbeUsesAccurateMessageWhenCompleteCandidatesCannotBeMatched(t *te
}
}
// #334: a probe with zero colors and zero sizes is a distinct, more specific
// failure ("规格探测未读取到任何颜色或尺码") than the generic "candidates
// present but none matched" message, so operators can tell an over-swiped
// device probe apart from an ordinary spec mismatch.
func TestLiveProbeWithNoDimensionsFailsWithExplicitEmptyProbeCode(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil {
t.Fatal(err)
}
matcher := &liveProbeMatcher{err: errors.New("AI should not be called for an empty probe")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || matcher.calls != 0 || resolved.Status != models.PurchaseTaskStatusFailed {
t.Fatalf("empty probe did not fail closed without calling AI: %+v calls=%d err=%v", resolved, matcher.calls, err)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil {
t.Fatal(err)
}
if saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_PROBE_EMPTY" {
t.Fatalf("empty probe did not use the explicit empty-probe code: %+v", saved)
}
if saved.ErrorMessage == nil || *saved.ErrorMessage != "规格探测未读取到任何颜色或尺码" {
t.Fatalf("empty probe did not use the explicit empty-probe message: %+v", saved)
}
}
func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
+8 -6
View File
@@ -157,6 +157,7 @@ type ResultRequest struct {
PDDOrderNo string `json:"pddOrderNo,omitempty"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
ActualUnitPriceCent *int64 `json:"actualUnitPriceCent,omitempty"`
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
ProbedSpecs json.RawMessage `json:"probedSpecs,omitempty"`
@@ -179,10 +180,11 @@ type SpecDecisionRequest struct {
}
type ManualRequest struct {
RequestID string `json:"requestId"`
OperatorID uint64 `json:"operatorId"`
Reason string `json:"reason,omitempty"`
Status string `json:"status,omitempty"`
PDDOrderNo string `json:"pddOrderNo,omitempty"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
RequestID string `json:"requestId"`
OperatorID uint64 `json:"operatorId"`
Reason string `json:"reason,omitempty"`
Status string `json:"status,omitempty"`
PDDOrderNo string `json:"pddOrderNo,omitempty"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
PDDOrderAmountCent *int64 `json:"pddOrderAmountCent,omitempty"`
}
@@ -0,0 +1,31 @@
package shopeeproduct
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
// #333: the real middleware sets userId=0 on every request; the operator used
// for owned-device collection lookup and audit must come from the JWT identity.
func TestCurrentUserIDReadsJWTIdentityNotUserIDKey(t *testing.T) {
gin.SetMode(gin.TestMode)
cases := []struct {
claims jwt.MapClaims
want uint64
}{
{jwt.MapClaims{"rolekey": "purchaser", "identity": float64(3)}, 3},
{jwt.MapClaims{"rolekey": "purchaser"}, 0},
{jwt.MapClaims{"rolekey": "purchaser", "identity": float64(-1)}, 0},
}
for _, tc := range cases {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set(jwt.JwtPayloadKey, tc.claims)
c.Set("userId", 0)
if got := currentUserID(c); got != tc.want {
t.Fatalf("claims %v: got %d want %d", tc.claims, got, tc.want)
}
}
}
+25 -11
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"
)
@@ -120,6 +121,8 @@ func (handler Handler) LinkPDD(c *gin.Context) {
return
}
service.ReplacementEligibility = handler.ReplacementEligibility
service.OperatorUserID = currentUserID(c)
service.OperatorIsAdmin = currentRole(c) == "admin"
response, err := service.LinkPDD(c.Request.Context(), id, request)
respond(c, response, err)
}
@@ -424,22 +427,31 @@ func (handler Handler) service(c *gin.Context) (*Service, bool) {
// purchasing roles per #40; role membership itself is enforced by the router
// middleware, not here.
func currentUserID(c *gin.Context) uint64 {
value, exists := c.Get("userId")
if !exists {
return 0
}
switch id := value.(type) {
// #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:
return uint64(id)
if id > 0 {
return uint64(id)
}
case int64:
return uint64(id)
if id > 0 {
return uint64(id)
}
case uint64:
return id
case float64:
return uint64(id)
default:
return 0
}
return 0
}
func currentRole(c *gin.Context) string {
value, _ := jwt.ExtractClaims(c)["rolekey"].(string)
return value
}
func respond(c *gin.Context, response SaveResponse, err error) {
@@ -489,6 +501,8 @@ func writeError(c *gin.Context, err error) {
status = http.StatusNotFound
case CodePDDProductDisabled, CodeSpecContextStale, CodeLatestCollectionUnavailable, CodeLinkConflict:
status = http.StatusConflict
case CodeDeviceOwnershipForbidden:
status = http.StatusForbidden
case CodeAIUnavailable:
status = http.StatusServiceUnavailable
}
@@ -11,6 +11,7 @@ import (
const CodeLatestCollectionUnavailable = "LATEST_COLLECTION_UNAVAILABLE"
const CodeLinkConflict = "PDD_LINK_CONFLICT"
const CodeDeviceOwnershipForbidden = "DEVICE_OWNERSHIP_FORBIDDEN"
type LatestCollectionRequest struct {
SYBProductID uint64 `json:"sybProductId"`
@@ -105,6 +106,9 @@ func (service *Service) linkLatestCollection(ctx context.Context, id uint64, req
if device.Status != models.DeviceStatusOnline && device.Status != models.DeviceStatusOffline {
return latestUnavailable("手机已停用,请重新选择")
}
if service.OperatorUserID > 0 && !service.OperatorIsAdmin && (device.OwnerUserID == nil || *device.OwnerUserID != service.OperatorUserID) {
return &ServiceError{Code: CodeDeviceOwnershipForbidden, Message: "该手机不属于当前采购员,无法使用其采集记录", Retryable: false}
}
var task models.CollectionTask
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("device_id = ? AND source = ? AND status IN ?", r.DeviceID, models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial})
if replacement != nil && !replacement.Preview {
@@ -63,6 +63,21 @@ func TestLatestCollectionOfflineAndOnlyIfUnlinked(t *testing.T) {
}
}
func TestLatestCollectionRejectsDeviceOwnedByAnotherUser(t *testing.T) {
s, id, req, _ := latestFixture(t)
owner := uint64(21)
s.DB.Model(&models.AgentDevice{}).Where("id = ?", req.LatestCollection.DeviceID).Update("owner_user_id", owner)
s.OperatorUserID = 22
if _, err := s.LinkPDD(context.Background(), id, req); err == nil || errCode(t, err) != CodeDeviceOwnershipForbidden {
t.Fatalf("expected ownership rejection: %v", err)
}
var product models.ShopeeProduct
s.DB.First(&product, id)
if product.PDDProductID != nil {
t.Fatal("ownership rejection wrote association")
}
}
func TestLatestCollectionRejectsInvalidContextWithoutWrite(t *testing.T) {
cases := []struct {
name string
@@ -62,6 +62,8 @@ type ReplacementEligibility func(context.Context, *gorm.DB, uint64) error
type Service struct {
DB *gorm.DB
ReplacementEligibility ReplacementEligibility
OperatorUserID uint64
OperatorIsAdmin bool
}
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
+209
View File
@@ -0,0 +1,209 @@
package yeeke
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"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"
)
// Handler exposes the yeeke read-only admin surface: manual sync trigger and
// sync run history/summary. It never returns credentials, tokens, captcha
// text or a full raw yeeke response — only the counters already stored on
// models.YeekeSyncRun (#336 requirement #7).
type Handler struct {
// DB lets tests inject a database directly; production requests resolve
// it from the gin context via pkg.GetOrm, same as sybimport.Handler.
DB *gorm.DB
}
func (h Handler) db(c *gin.Context) (*gorm.DB, bool) {
db := h.DB
var err error
if db == nil {
db, err = pkg.GetOrm(c)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return nil, false
}
return db, true
}
// SyncRunDTO is the read-only shape returned to admin/purchaser. It embeds
// only the summary fields already computed by the sync run itself; it never
// carries yeeke_session (token/cookies) or a raw page response.
type SyncRunDTO struct {
ID uint64 `json:"id"`
Status string `json:"status"`
Trigger string `json:"trigger"`
TotalPages int `json:"totalPages"`
ReadCount int `json:"readCount"`
CreatedCount int `json:"createdCount"`
UpdatedCount int `json:"updatedCount"`
SkippedCount int `json:"skippedCount"`
FailedCount int `json:"failedCount"`
ErrorMessage string `json:"errorMessage"`
StartedAt string `json:"startedAt"`
FinishedAt *string `json:"finishedAt"`
LastSuccessAt *string `json:"lastSuccessAt"`
}
func toDTO(r models.YeekeSyncRun) SyncRunDTO {
dto := SyncRunDTO{
ID: r.ID, Status: r.Status, Trigger: r.Trigger, TotalPages: r.TotalPages,
ReadCount: r.ReadCount, CreatedCount: r.CreatedCount, UpdatedCount: r.UpdatedCount,
SkippedCount: r.SkippedCount, FailedCount: r.FailedCount, ErrorMessage: r.ErrorMessage,
StartedAt: r.StartedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
if r.FinishedAt != nil {
s := r.FinishedAt.UTC().Format("2006-01-02T15:04:05Z")
dto.FinishedAt = &s
}
if r.LastSuccessAt != nil {
s := r.LastSuccessAt.UTC().Format("2006-01-02T15:04:05Z")
dto.LastSuccessAt = &s
}
return dto
}
// ListSyncRuns returns the most recent sync runs, newest first, optionally
// filtered by trigger, status and started-at range (#337 backward-compatible
// extension: all filters are optional query params, existing callers with no
// filters keep the #336 behavior). Visible to admin and purchaser alike
// (#336 requirement #7); it is mounted without Casbin role gating, mirroring
// sybimport's /sync-runs.
func (h Handler) ListSyncRuns(c *gin.Context) {
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil || page < 1 {
page = 1
}
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if err != nil || pageSize < 1 || pageSize > 100 {
pageSize = 20
}
db, ok := h.db(c)
if !ok {
return
}
query := db.Model(&models.YeekeSyncRun{})
if v := strings.TrimSpace(c.Query("trigger")); v != "" {
query = query.Where("trigger = ?", v)
}
if v := strings.TrimSpace(c.Query("status")); v != "" {
query = query.Where("status = ?", v)
}
if v := strings.TrimSpace(c.Query("startedFrom")); v != "" {
t, perr := parseFilterTime(v)
if perr != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "startedFrom 格式无效"})
return
}
query = query.Where("started_at >= ?", t)
}
if v := strings.TrimSpace(c.Query("startedTo")); v != "" {
t, perr := parseFilterTime(v)
if perr != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "startedTo 格式无效"})
return
}
query = query.Where("started_at <= ?", t)
}
var total int64
if err := query.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
var rows []models.YeekeSyncRun
if err := query.Order("id desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
items := make([]SyncRunDTO, 0, len(rows))
for _, row := range rows {
items = append(items, toDTO(row))
}
var last models.YeekeSyncRun
lastSuccessAt := ""
if err := db.Where("status = ?", "succeeded").Order("id desc").First(&last).Error; err == nil && last.LastSuccessAt != nil {
lastSuccessAt = last.LastSuccessAt.UTC().Format("2006-01-02T15:04:05Z")
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{
"items": items, "total": total, "page": page, "pageSize": pageSize,
"lastSuccessAt": lastSuccessAt,
}})
}
// SyncRunDetail returns one run's summary.
func (h Handler) SyncRunDetail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("runId"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "runId 无效"})
return
}
db, ok := h.db(c)
if !ok {
return
}
var row models.YeekeSyncRun
if err := db.First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "同步记录不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": toDTO(row)}})
}
// TriggerSync starts a manual sync run. Only admin and purchaser may call it,
// same as SYB's manual Import (sybimport.Handler.Import) — this mirrors that
// role check exactly.
func (h Handler) TriggerSync(c *gin.Context) {
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
if role != "admin" && role != "purchaser" {
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以开始同步"})
return
}
db, ok := h.db(c)
if !ok {
return
}
result, err := StartSync(c.Request.Context(), db, nil, "manual", false)
if err != nil {
if errors.Is(err, ErrAlreadyRunning) {
c.JSON(http.StatusConflict, gin.H{"code": "ALREADY_RUNNING", "message": err.Error()})
return
}
// `[必须]` err here is only ever a config/connect-stage message built in
// start.go/yeekeclient — never a raw yeeke response, never a token or
// credential. api.GetRequestLogger keeps the same text out of the HTTP
// body while still recording it server-side for operators.
api.GetRequestLogger(c).Errorf("yeeke manual sync failed to start: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"code": "SYNC_START_FAILED", "message": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"runId": result.RunID, "skipped": result.Skipped}})
}
// parseFilterTime accepts either a full RFC3339-ish "2006-01-02T15:04:05Z" or
// a plain "2006-01-02" date, matching the query-string shapes used by both
// the return-package and sync-run filters.
func parseFilterTime(v string) (time.Time, error) {
if t, err := time.Parse("2006-01-02T15:04:05Z", v); err == nil {
return t, nil
}
return time.Parse("2006-01-02", v)
}
+35
View File
@@ -0,0 +1,35 @@
package yeeke
import (
"context"
"errors"
"gorm.io/gorm"
)
// ReturnSyncInvokeTarget is the go-admin job invoke_target key for the
// scheduled yeeke return sync (#336). The job row itself is seeded disabled
// (Status: 2) by migrations/version-local; an admin turns it on explicitly.
const ReturnSyncInvokeTarget = "GoAutoYeekeReturnSync"
// ReturnSyncJob is registered in go-admin's ExecJob map (app/jobs/examples.go).
// ExecWithDB is the production path; Exec exists only to satisfy the legacy
// jobs.JobExec interface and fails closed if an older caller forgets to
// provide the current database.
type ReturnSyncJob struct{}
func (ReturnSyncJob) Exec(_ interface{}) error {
return errors.New("yeeke 定时同步缺少数据库连接")
}
// ExecWithDB starts a sync sharing the same StartSync entry point, and hence
// the same syncGate/active_slot lease, as the manual admin trigger — a
// scheduled tick that lands while a manual run (or a previous tick) is still
// in progress is skipped rather than queued or run concurrently.
func (ReturnSyncJob) ExecWithDB(db *gorm.DB, _ interface{}) error {
_, err := StartSync(context.Background(), db, nil, "scheduled", true)
if errors.Is(err, ErrAlreadyRunning) {
return nil
}
return err
}
+368
View File
@@ -0,0 +1,368 @@
package yeeke
import (
"errors"
"net/http"
"sort"
"strconv"
"strings"
"time"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ReturnItemDTO is the read-only shape of one item inside a return package.
// It never carries anything beyond what #336's sync already stores, and the
// image field is always the external yeeke URL (never downloaded/proxied,
// per #337 non-goal).
type ReturnItemDTO struct {
ID uint64 `json:"id"`
ItemID string `json:"itemId"`
VariationID string `json:"variationId"`
ItemName string `json:"itemName"`
VariationName string `json:"variationName"`
Image string `json:"image"`
Quantity int64 `json:"quantity"`
}
// ReturnPackageDTO is the read-only shape of one return package returned to
// admin/purchaser. StatusUnrecognized is surfaced verbatim (#337 #4 / #336):
// an unknown ClaimStatus is never bucketed into a known value.
type ReturnPackageDTO struct {
ID uint64 `json:"id"`
OrderSN string `json:"orderSn"`
TrackingNo string `json:"trackingNo"`
ShopID string `json:"shopId"`
ShopName string `json:"shopName"`
WareCode string `json:"wareCode"`
WareHouse string `json:"wareHouse"`
WareName string `json:"wareName"`
ClaimStatus string `json:"claimStatus"`
StatusUnrecognized bool `json:"statusUnrecognized"`
ItemCount int `json:"itemCount"`
ClaimTime *string `json:"claimTime"`
CreateTime *string `json:"createTime"`
UpdateTime *string `json:"updateTime"`
DestroyDeadLine *string `json:"destroyDeadLine"`
LastSyncedAt string `json:"lastSyncedAt"`
SyncStatus string `json:"syncStatus"`
Items []ReturnItemDTO `json:"items,omitempty"`
}
// ReturnItemRowDTO is one row of the #337 item-level admin list: one row per
// yeeke_return_item, joined with its parent package. A package with no items
// still produces exactly one placeholder row (HasItem=false, item fields
// empty/zero) so it is never silently dropped from the list.
type ReturnItemRowDTO struct {
Key string `json:"key"`
PackageID uint64 `json:"packageId"`
ItemID uint64 `json:"itemId"`
OrderSN string `json:"orderSn"`
Image string `json:"image"`
ItemName string `json:"itemName"`
VariationName string `json:"variationName"`
ShopeeItemID string `json:"shopeeItemId"`
VariationID string `json:"variationId"`
Quantity int64 `json:"quantity"`
ShopName string `json:"shopName"`
WareHouse string `json:"wareHouse"`
WareCode string `json:"wareCode"`
ClaimStatus string `json:"claimStatus"`
StatusUnrecognized bool `json:"statusUnrecognized"`
ClaimTime *string `json:"claimTime"`
DestroyDeadLine *string `json:"destroyDeadLine"`
LastSyncedAt string `json:"lastSyncedAt"`
HasItem bool `json:"hasItem"`
}
func formatTimePtr(t *time.Time) *string {
if t == nil {
return nil
}
s := t.UTC().Format("2006-01-02T15:04:05Z")
return &s
}
func toPackageDTO(p models.YeekeReturnPackage, itemCount int) ReturnPackageDTO {
return ReturnPackageDTO{
ID: p.ID, OrderSN: p.OrderSN, TrackingNo: p.TrackingNo, ShopID: p.ShopID, ShopName: p.ShopName,
WareCode: p.WareCode, WareHouse: p.WareHouse, WareName: p.WareName, ClaimStatus: p.ClaimStatus,
StatusUnrecognized: p.StatusUnrecognized, ItemCount: itemCount,
ClaimTime: formatTimePtr(p.ClaimTime), CreateTime: formatTimePtr(p.CreateTime),
UpdateTime: formatTimePtr(p.UpdateTime), DestroyDeadLine: formatTimePtr(p.DestroyDeadLine),
LastSyncedAt: p.LastSyncedAt.UTC().Format("2006-01-02T15:04:05Z"), SyncStatus: p.SyncStatus,
}
}
func toItemDTO(i models.YeekeReturnItem) ReturnItemDTO {
return ReturnItemDTO{
ID: i.ID, ItemID: i.ItemID, VariationID: i.VariationID, ItemName: i.ItemName,
VariationName: i.VariationName, Image: i.Image, Quantity: i.Quantity,
}
}
// itemRow mirrors one row of the LEFT JOIN query in ListReturnItemRows: every
// package column plus every (nullable) item column.
type itemRow struct {
PackageID uint64
OrderSN string
ShopName string
WareHouse string
WareCode string
ClaimStatus string
StatusUnrecognized bool
ClaimTime *time.Time
DestroyDeadLine *time.Time
PackageLastSynced time.Time
ItemID *uint64
ItemExternalItem *string
ItemVariationID *string
ItemName *string
ItemVariationName *string
ItemImage *string
ItemQuantity *int64
ItemLastSynced *time.Time
}
func toItemRowDTO(r itemRow) ReturnItemRowDTO {
dto := ReturnItemRowDTO{
PackageID: r.PackageID, OrderSN: r.OrderSN, ShopName: r.ShopName,
WareHouse: r.WareHouse, WareCode: r.WareCode, ClaimStatus: r.ClaimStatus,
StatusUnrecognized: r.StatusUnrecognized,
ClaimTime: formatTimePtr(r.ClaimTime),
DestroyDeadLine: formatTimePtr(r.DestroyDeadLine),
LastSyncedAt: r.PackageLastSynced.UTC().Format("2006-01-02T15:04:05Z"),
}
if r.ItemID == nil || *r.ItemID == 0 {
dto.Key = "p" + strconv.FormatUint(r.PackageID, 10)
dto.HasItem = false
return dto
}
dto.Key = strconv.FormatUint(*r.ItemID, 10)
dto.ItemID = *r.ItemID
dto.HasItem = true
if r.ItemExternalItem != nil {
dto.ShopeeItemID = *r.ItemExternalItem
}
if r.ItemVariationID != nil {
dto.VariationID = *r.ItemVariationID
}
if r.ItemName != nil {
dto.ItemName = *r.ItemName
}
if r.ItemVariationName != nil {
dto.VariationName = *r.ItemVariationName
}
if r.ItemImage != nil {
dto.Image = *r.ItemImage
}
if r.ItemQuantity != nil {
dto.Quantity = *r.ItemQuantity
}
if r.ItemLastSynced != nil {
dto.LastSyncedAt = r.ItemLastSynced.UTC().Format("2006-01-02T15:04:05Z")
}
return dto
}
// ListReturnPackages returns a paginated, filtered list of return packages.
// Filters: order sn, tracking no, shop (id or name, partial match), itemId /
// variationId (matched against yeeke_return_item, requires a join), claim
// status (including the literal "unknown" pseudo-value for
// StatusUnrecognized rows), and createTime range. Visible to admin and
// purchaser alike, same gating as ListSyncRuns.
func (h Handler) ListReturnPackages(c *gin.Context) {
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil || page < 1 {
page = 1
}
pageSize, err := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if err != nil || pageSize < 1 || pageSize > 100 {
pageSize = 20
}
db, ok := h.db(c)
if !ok {
return
}
base := db.Table("yeeke_return_package p").
Joins("LEFT JOIN yeeke_return_item i ON i.package_id = p.id")
if v := strings.TrimSpace(c.Query("orderSn")); v != "" {
base = base.Where("p.order_sn LIKE ?", "%"+v+"%")
}
if v := strings.TrimSpace(c.Query("trackingNo")); v != "" {
base = base.Where("p.tracking_no LIKE ?", "%"+v+"%")
}
// #337 #1: 店铺 filter switched from free-text partial match to an exact
// match against p.shop_name, matching the dropdown surfaced by /shops.
if v := strings.TrimSpace(c.Query("shop")); v != "" {
base = base.Where("p.shop_name = ?", v)
}
claimStatus := strings.TrimSpace(c.Query("claimStatus"))
switch claimStatus {
case "":
// no filter
case "unknown":
base = base.Where("p.status_unrecognized = ?", true)
default:
base = base.Where("p.claim_status = ? AND p.status_unrecognized = ?", claimStatus, false)
}
if v := strings.TrimSpace(c.Query("createTimeFrom")); v != "" {
t, perr := parseFilterTime(v)
if perr != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "createTimeFrom 格式无效"})
return
}
base = base.Where("p.create_time >= ?", t)
}
if v := strings.TrimSpace(c.Query("createTimeTo")); v != "" {
t, perr := parseFilterTime(v)
if perr != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "createTimeTo 格式无效"})
return
}
base = base.Where("p.create_time <= ?", t)
}
// itemId/variationId kept for backward compatibility (#337): now filter
// the item-level join directly instead of a package sub-select.
if v := strings.TrimSpace(c.Query("itemId")); v != "" {
base = base.Where("i.item_id = ?", v)
}
if v := strings.TrimSpace(c.Query("variationId")); v != "" {
base = base.Where("i.variation_id = ?", v)
}
var total int64
if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
var rows []itemRow
q := base.Session(&gorm.Session{}).Select(
"p.id as package_id, p.order_sn, p.shop_name, p.ware_house, p.ware_code, p.claim_status, " +
"p.status_unrecognized, p.claim_time, p.destroy_dead_line as destroy_dead_line, p.last_synced_at as package_last_synced, " +
"i.id as item_id, i.item_id as item_external_item, i.variation_id as item_variation_id, i.item_name as item_name, " +
"i.variation_name as item_variation_name, i.image as item_image, i.quantity as item_quantity, i.last_synced_at as item_last_synced",
).Order("p.create_time desc, i.id asc").
Offset((page - 1) * pageSize).Limit(pageSize)
if err := q.Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
items := make([]ReturnItemRowDTO, 0, len(rows))
for _, r := range rows {
items = append(items, toItemRowDTO(r))
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{
"items": items, "total": total, "page": page, "pageSize": pageSize,
}})
}
// ListReturnShops returns the distinct, non-empty shop names known from
// yeeke_return_package, sorted ascending, for the #337 shop-name dropdown
// filter. It must be registered before the /:packageId wildcard route so it
// is never swallowed by it.
func (h Handler) ListReturnShops(c *gin.Context) {
db, ok := h.db(c)
if !ok {
return
}
var shops []string
if err := db.Model(&models.YeekeReturnPackage{}).
Where("shop_name <> ''").
Distinct("shop_name").
Order("shop_name asc").
Pluck("shop_name", &shops).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
sort.Strings(shops)
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"shops": shops}})
}
// ReturnItemDetail returns one item, its parent package, and the sibling
// items of the same package (excluding itself), for the #337 "退货商品详情"
// drawer. It must be registered before the /:packageId wildcard route so
// /yeeke-returns/items/:itemId is never swallowed by it.
func (h Handler) ReturnItemDetail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("itemId"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "itemId 无效"})
return
}
db, ok := h.db(c)
if !ok {
return
}
var item models.YeekeReturnItem
if err := db.First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "退货商品不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
var pkg models.YeekeReturnPackage
if err := db.First(&pkg, item.PackageID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "所属退货包裹不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
var siblingRows []models.YeekeReturnItem
if err := db.Where("package_id = ? AND id <> ?", item.PackageID, item.ID).Order("id asc").Find(&siblingRows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
siblings := make([]ReturnItemDTO, 0, len(siblingRows))
for _, s := range siblingRows {
siblings = append(siblings, toItemDTO(s))
}
pkgDTO := toPackageDTO(pkg, len(siblingRows)+1)
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{
"item": toItemDTO(item), "package": pkgDTO, "siblings": siblings,
}})
}
// ReturnPackageDetail returns one package with its items.
func (h Handler) ReturnPackageDetail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("packageId"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "packageId 无效"})
return
}
db, ok := h.db(c)
if !ok {
return
}
var row models.YeekeReturnPackage
if err := db.First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "退货包裹不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
var itemRows []models.YeekeReturnItem
if err := db.Where("package_id = ?", row.ID).Order("id asc").Find(&itemRows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
dto := toPackageDTO(row, len(itemRows))
dto.Items = make([]ReturnItemDTO, 0, len(itemRows))
for _, i := range itemRows {
dto.Items = append(dto.Items, toItemDTO(i))
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": dto}})
}
+439
View File
@@ -0,0 +1,439 @@
package yeeke
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
)
func setupListTestDB(t *testing.T) (*gin.Engine, func(req *http.Request) *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
db := testDB(t)
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
older := now.Add(-48 * time.Hour)
if err := db.Create(&models.YeekeReturnPackage{
ExternalID: "p1", OrderSN: "ORDER-1", TrackingNo: "TRACK-1", ShopID: "s1", ShopName: "店铺一",
ClaimStatus: "claimed", StatusUnrecognized: false, CreateTime: &now, LastSyncedAt: now, SyncStatus: "ok",
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.YeekeReturnPackage{
ExternalID: "p2", OrderSN: "ORDER-2", TrackingNo: "TRACK-2", ShopID: "s2", ShopName: "店铺二",
ClaimStatus: "weird_status", StatusUnrecognized: true, CreateTime: &older, LastSyncedAt: older, SyncStatus: "ok",
}).Error; err != nil {
t.Fatal(err)
}
// p3 has no items at all: must still produce exactly one placeholder row.
if err := db.Create(&models.YeekeReturnPackage{
ExternalID: "p3", OrderSN: "ORDER-3", TrackingNo: "TRACK-3", ShopID: "s3", ShopName: "店铺三",
ClaimStatus: "pending", StatusUnrecognized: false, CreateTime: &older, LastSyncedAt: older, SyncStatus: "ok",
}).Error; err != nil {
t.Fatal(err)
}
var pkg1, pkg2 models.YeekeReturnPackage
db.Where("external_id = ?", "p1").First(&pkg1)
db.Where("external_id = ?", "p2").First(&pkg2)
if err := db.Create(&models.YeekeReturnItem{
PackageID: pkg1.ID, ExternalKey: "k1", ItemID: "item1", VariationID: "v1", ItemName: "商品1", VariationName: "红色",
Image: "https://cdn.example.com/a.jpg", Quantity: 2, LastSyncedAt: now, SyncStatus: "ok",
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.YeekeReturnItem{
PackageID: pkg1.ID, ExternalKey: "k2", ItemID: "item1", VariationID: "v2", ItemName: "商品1", VariationName: "蓝色",
Image: "https://cdn.example.com/b.jpg", Quantity: 1, LastSyncedAt: now, SyncStatus: "ok",
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.YeekeReturnItem{
PackageID: pkg2.ID, ExternalKey: "k3", ItemID: "item2", VariationID: "v3", ItemName: "商品2", VariationName: "默认",
Image: "https://cdn.example.com/c.jpg", Quantity: 3, LastSyncedAt: older, SyncStatus: "ok",
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.YeekeSyncRun{
Status: "succeeded", Trigger: "manual", StartedAt: now,
}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.YeekeSyncRun{
Status: "failed", Trigger: "scheduled", StartedAt: older,
}).Error; err != nil {
t.Fatal(err)
}
handler := Handler{DB: db}
engine := gin.New()
engine.GET("/api/admin/v1/yeeke-returns", handler.ListReturnPackages)
engine.GET("/api/admin/v1/yeeke-returns/shops", handler.ListReturnShops)
engine.GET("/api/admin/v1/yeeke-returns/items/:itemId", handler.ReturnItemDetail)
engine.GET("/api/admin/v1/yeeke-returns/:packageId", handler.ReturnPackageDetail)
engine.GET("/api/admin/v1/yeeke-returns/sync-runs", handler.ListSyncRuns)
do := func(req *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
return w
}
return engine, do
}
type itemRowListResp struct {
Data struct {
Items []ReturnItemRowDTO `json:"items"`
Total int64 `json:"total"`
} `json:"data"`
}
func TestListReturnItemsFiltersByOrderSn(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp itemRowListResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 2 || len(resp.Data.Items) != 2 {
t.Fatalf("expected 2 item rows for a 2-item package, got: %+v", resp.Data)
}
for _, row := range resp.Data.Items {
if row.OrderSN != "ORDER-1" || !row.HasItem {
t.Fatalf("unexpected row: %+v", row)
}
}
}
func TestListReturnItemsPlaceholderRowForItemlessPackage(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-3", nil))
var resp itemRowListResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 1 || len(resp.Data.Items) != 1 {
t.Fatalf("expected exactly one placeholder row, got: %+v", resp.Data)
}
row := resp.Data.Items[0]
if row.HasItem {
t.Fatalf("expected hasItem=false for item-less package, got: %+v", row)
}
if row.ItemID != 0 || row.ItemName != "" {
t.Fatalf("expected empty/zero item fields on placeholder row, got: %+v", row)
}
}
func TestListReturnItemsPaginationAndCountOverItemRows(t *testing.T) {
_, do := setupListTestDB(t)
// total rows across all packages: 2 (p1) + 1 (p2) + 1 placeholder (p3) = 4
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?pageSize=2&page=1", nil))
var resp itemRowListResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 4 {
t.Fatalf("expected total=4 item-level rows, got %d", resp.Data.Total)
}
if len(resp.Data.Items) != 2 {
t.Fatalf("expected page size 2 applied to item-level rows, got %d", len(resp.Data.Items))
}
}
func TestListReturnItemsShopFilterIsExactMatch(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?shop=%E5%BA%97", nil)) // partial "店" must not match
var resp itemRowListResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 0 {
t.Fatalf("expected no matches for a partial shop name (exact match required), got %d", resp.Data.Total)
}
w2 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?shop=%E5%BA%97%E9%93%BA%E4%B8%80", nil)) // "店铺一"
var resp2 itemRowListResp
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
t.Fatal(err)
}
if resp2.Data.Total != 2 {
t.Fatalf("expected exact shop match to find package p1's 2 item rows, got %d", resp2.Data.Total)
}
}
func TestListReturnItemsUnknownClaimStatusFilter(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?claimStatus=unknown", nil))
var resp itemRowListResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 1 || resp.Data.Items[0].OrderSN != "ORDER-2" {
t.Fatalf("unexpected result: %+v", resp.Data)
}
if !resp.Data.Items[0].StatusUnrecognized {
t.Fatal("expected StatusUnrecognized=true, unknown status must be shown verbatim, not bucketed")
}
if resp.Data.Items[0].ClaimStatus != "weird_status" {
t.Fatalf("unknown claim status must be shown verbatim, got %q", resp.Data.Items[0].ClaimStatus)
}
}
func TestListReturnItemsFiltersByItemAndVariationBackwardCompat(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?itemId=item1&variationId=v2", nil))
var resp itemRowListResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 1 || resp.Data.Items[0].OrderSN != "ORDER-1" || resp.Data.Items[0].VariationID != "v2" {
t.Fatalf("unexpected result: %+v", resp.Data)
}
}
func TestListReturnItemsFiltersByCreateTimeRange(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?createTimeFrom=2026-09-19&createTimeTo=2026-09-21", nil))
var resp itemRowListResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 2 || resp.Data.Items[0].OrderSN != "ORDER-1" {
t.Fatalf("unexpected result: %+v", resp.Data)
}
}
func TestListReturnShopsReturnsDistinctSortedNames(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/shops", nil))
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp struct {
Data struct {
Shops []string `json:"shops"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
want := []string{"店铺一", "店铺三", "店铺二"}
if len(resp.Data.Shops) != len(want) {
t.Fatalf("expected %d distinct shops, got %+v", len(want), resp.Data.Shops)
}
for i := range want {
if resp.Data.Shops[i] != want[i] {
t.Fatalf("expected sorted shops %+v, got %+v", want, resp.Data.Shops)
}
}
}
func TestReturnItemDetailReturnsSiblingsExcludingSelf(t *testing.T) {
_, do := setupListTestDB(t)
listW := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
var listResp itemRowListResp
if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil {
t.Fatal(err)
}
if len(listResp.Data.Items) != 2 {
t.Fatalf("expected 2 item rows to pick from, got %+v", listResp.Data.Items)
}
target := listResp.Data.Items[0]
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/items/"+itoa(target.ItemID), nil))
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp struct {
Data struct {
Item ReturnItemDTO `json:"item"`
Package ReturnPackageDTO `json:"package"`
Siblings []ReturnItemDTO `json:"siblings"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Item.ID != target.ItemID {
t.Fatalf("expected item id %d, got %d", target.ItemID, resp.Data.Item.ID)
}
if resp.Data.Package.OrderSN != "ORDER-1" {
t.Fatalf("expected parent package ORDER-1, got %+v", resp.Data.Package)
}
if len(resp.Data.Siblings) != 1 {
t.Fatalf("expected exactly 1 sibling (the other item in the same package), got %+v", resp.Data.Siblings)
}
if resp.Data.Siblings[0].ID == resp.Data.Item.ID {
t.Fatal("siblings must exclude the current item")
}
}
func TestReturnItemDetailNotFound(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/items/999999", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}
func TestYeekeReturnsRoutesDoNotCollide(t *testing.T) {
// Routing test proving /yeeke-returns/shops and /yeeke-returns/items/:id
// are not swallowed by the /yeeke-returns/:packageId wildcard route.
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/shops", nil))
if w.Code != http.StatusOK {
t.Fatalf("/shops should hit ListReturnShops, got status=%d body=%s", w.Code, w.Body.String())
}
var shopsResp struct {
Data struct {
Shops []string `json:"shops"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &shopsResp); err != nil {
t.Fatal(err)
}
if len(shopsResp.Data.Shops) == 0 {
t.Fatal("/shops must hit ListReturnShops, not ReturnPackageDetail (which would 400/404 on a non-numeric id)")
}
listW := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
var listResp itemRowListResp
if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil {
t.Fatal(err)
}
itemID := listResp.Data.Items[0].ItemID
w2 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/items/"+itoa(itemID), nil))
if w2.Code != http.StatusOK {
t.Fatalf("/items/:itemId should hit ReturnItemDetail, got status=%d body=%s", w2.Code, w2.Body.String())
}
var itemResp struct {
Data struct {
Item ReturnItemDTO `json:"item"`
} `json:"data"`
}
if err := json.Unmarshal(w2.Body.Bytes(), &itemResp); err != nil {
t.Fatal(err)
}
if itemResp.Data.Item.ID != itemID {
t.Fatalf("/items/:itemId must hit ReturnItemDetail, got item=%+v", itemResp.Data.Item)
}
// The plain numeric package-id route must still work unchanged.
w3 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/999999", nil))
if w3.Code != http.StatusNotFound {
t.Fatalf("/:packageId route must still work for a numeric id, got status=%d", w3.Code)
}
}
func TestReturnPackageDetailIncludesSeparateItemRows(t *testing.T) {
_, do := setupListTestDB(t)
listW := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?orderSn=ORDER-1", nil))
var listResp itemRowListResp
if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil {
t.Fatal(err)
}
id := listResp.Data.Items[0].PackageID
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/"+itoa(id), nil))
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp struct {
Data struct {
Item ReturnPackageDTO `json:"item"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Data.Item.Items) != 2 {
t.Fatalf("expected 2 separate item rows for multiple variations, got %d", len(resp.Data.Item.Items))
}
variations := map[string]bool{}
for _, item := range resp.Data.Item.Items {
variations[item.VariationID] = true
}
if !variations["v1"] || !variations["v2"] {
t.Fatalf("expected both variations present as separate rows: %+v", resp.Data.Item.Items)
}
}
func TestReturnPackageDetailNotFound(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/999999", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}
func TestListSyncRunsFiltersByTriggerStatusAndStartedRange(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/sync-runs?trigger=manual&status=succeeded", nil))
var resp struct {
Data struct {
Items []SyncRunDTO `json:"items"`
Total int64 `json:"total"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 1 || resp.Data.Items[0].Trigger != "manual" || resp.Data.Items[0].Status != "succeeded" {
t.Fatalf("unexpected result: %+v", resp.Data)
}
w2 := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/sync-runs?startedFrom=2026-09-19&startedTo=2026-09-21", nil))
var resp2 struct {
Data struct {
Items []SyncRunDTO `json:"items"`
Total int64 `json:"total"`
} `json:"data"`
}
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
t.Fatal(err)
}
if resp2.Data.Total != 1 || resp2.Data.Items[0].Trigger != "manual" {
t.Fatalf("unexpected started-range result: %+v", resp2.Data)
}
}
func TestListSyncRunsWithNoFiltersStaysBackwardCompatible(t *testing.T) {
_, do := setupListTestDB(t)
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns/sync-runs", nil))
var resp struct {
Data struct {
Items []SyncRunDTO `json:"items"`
Total int64 `json:"total"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Data.Total != 2 {
t.Fatalf("expected both runs with no filters, got total=%d", resp.Data.Total)
}
}
func itoa(v uint64) string {
if v == 0 {
return "0"
}
digits := []byte{}
for v > 0 {
digits = append([]byte{byte('0' + v%10)}, digits...)
v /= 10
}
return string(digits)
}
+29
View File
@@ -0,0 +1,29 @@
package yeeke
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
// InitRouter mounts the yeeke read-only admin surface (#336): manual sync
// trigger plus sync run history/summary. Every route here only reads from or
// writes to GoAuto's own database and, for the trigger, starts a read-only
// yeeke sync — no yeeke write endpoint is ever called.
//
// `[必须]` These routes are authenticated but intentionally not Casbin-gated
// (middleware.AuthCheckRole), same as sybimport's /sync-runs: #336 requires
// both admin and purchaser to see the summary, and TriggerSync does its own
// admin/purchaser role check inline (mirroring sybimport.Handler.Import).
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
handler := Handler{}
group := engine.Group("/api/admin/v1/yeeke-returns").Use(auth.MiddlewareFunc())
group.GET("", handler.ListReturnPackages)
// #337: static-prefixed routes must be registered before the
// /:packageId wildcard route below so they are never swallowed by it.
group.GET("/shops", handler.ListReturnShops)
group.GET("/items/:itemId", handler.ReturnItemDetail)
group.GET("/:packageId", handler.ReturnPackageDetail)
group.GET("/sync-runs", handler.ListSyncRuns)
group.GET("/sync-runs/:runId", handler.SyncRunDetail)
group.POST("/sync", handler.TriggerSync)
}
+110
View File
@@ -0,0 +1,110 @@
package yeeke
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"go-admin/app/goauto/sybclient"
"go-admin/app/goauto/yeekeclient"
"go-admin/config"
"gorm.io/gorm"
)
// syncTimeout bounds one sync run. It exists so a stalled yeeke response
// cannot pin a background goroutine forever.
const syncTimeout = 30 * time.Minute
// syncGate makes sync runs mutually exclusive within this process, same
// reasoning as sybimport.importGate: manual trigger and the scheduled job
// must never run concurrently (#336 requirement #5). The database-level
// unique active_slot column on yeeke_sync_run is the durable backstop (it
// also covers the unlikely case of two processes sharing one database); this
// in-memory gate exists to fail fast with a clear message before even
// touching the network or the OCR service.
var syncGate = struct {
sync.Mutex
running bool
}{}
// ErrAlreadyRunning is returned by StartSync when a sync is already in
// progress, whether it was started manually or by the scheduled job.
var ErrAlreadyRunning = errors.New("已有 yeeke 退货包裹同步正在执行,请等它结束后再试")
// OCR is satisfied by sybclient.OcrClient; yeeke reuses the same self-hosted
// captcha OCR service already approved for SYB (#336).
type OCR = yeekeclient.OCR
// StartResult reports what StartSync did without exposing any run internals
// that should not cross the HTTP boundary.
type StartResult struct {
RunID uint64
Skipped bool
}
// StartSync is the single entry point shared by the authenticated admin
// handler and the scheduled job, so a scheduled run can never bypass the same
// concurrency guard as a manual run.
//
// `[必须]` This only ever reads from yeeke. Credentials are resolved by the
// caller (config.ExtConfig.Yeeke) and never logged here.
func StartSync(ctx context.Context, db *gorm.DB, ocr OCR, trigger string, skipIfRunning bool) (StartResult, error) {
settings := config.ExtConfig.Yeeke.Resolved()
if !settings.HasCredentials() {
return StartResult{}, fmt.Errorf("yeeke 账号未配置:请设置 GOAUTO_YEEKE_USERNAME / GOAUTO_YEEKE_PASSWORD,或在 config.yaml 的 yeeke 段填写 username/password,然后重启服务端")
}
if ocr == nil {
if strings.TrimSpace(settings.OcrURL) == "" {
return StartResult{}, fmt.Errorf("yeeke 验证码识别服务未配置:请设置 extend.yeeke.ocrurl")
}
// Reuse SYB's OcrClient implementation as-is (#336): independent
// http.Client, no shared cookie jar, captcha bytes stay in memory. See
// sybclient/ocr.go's package comment for why that isolation matters.
client, err := sybclient.NewOcrClient(settings.OcrURL, 0)
if err != nil {
return StartResult{}, err
}
ocr = client
}
syncGate.Lock()
if syncGate.running {
syncGate.Unlock()
if skipIfRunning {
return StartResult{Skipped: true}, nil
}
return StartResult{}, ErrAlreadyRunning
}
syncGate.running = true
syncGate.Unlock()
release := func() {
syncGate.Lock()
syncGate.running = false
syncGate.Unlock()
}
client, err := yeekeclient.Connect(ctx, yeekeclient.NewSessionStore(db), yeekeclient.Credentials{
Username: settings.Username, Password: settings.Password,
}, settings.BaseURL, ocr, settings.OcrMaxAttempts)
if err != nil {
release()
return StartResult{}, err
}
svc := NewService(db, client, Config{PageSize: settings.PageSize, MaxPages: settings.MaxPages, Retry: settings.Retry})
// SyncAsync acquires the DB lease synchronously (so the caller gets a run
// id right away and the active_slot lease is held before this function
// returns) then walks pages in the background, bound to its own timeout
// independent of the HTTP request context. syncGate is released once that
// background walk finishes, not when this function returns.
runID, err := svc.SyncAsync(ctx, trigger, release)
if err != nil {
release()
return StartResult{}, err
}
return StartResult{RunID: runID}, nil
}
+342
View File
@@ -0,0 +1,342 @@
package yeeke
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"go-admin/app/goauto/models"
"go-admin/app/goauto/yeekeclient"
"gorm.io/gorm"
"strconv"
"strings"
"time"
)
type Config struct {
PageSize, MaxPages, Retry int
Lease time.Duration
}
func (c Config) norm() Config {
if c.PageSize <= 0 || c.PageSize > 500 {
c.PageSize = 100
}
if c.MaxPages <= 0 || c.MaxPages > 10000 {
c.MaxPages = 10000
}
if c.Retry < 0 || c.Retry > 5 {
c.Retry = 2
}
if c.Lease <= 0 {
c.Lease = 30 * time.Minute
}
return c
}
type Report struct {
RunID uint64
TotalPages, Read, Created, Updated, Skipped, Failed int
Status string
}
// knownClaimStatuses lists the status values the sync code currently
// understands. The list surface (POST .../relation/list) is queried with
// status=1, so "1" is the only value observed in practice; anything else is
// flagged rather than silently accepted or rejected (#336).
var knownClaimStatuses = map[string]bool{"1": true}
func external(v any) string { return fmt.Sprint(v) }
func stamp(t *yeekeclient.Timestamp) *time.Time {
if t == nil || t.IsZero() {
return nil
}
x := t.Time
return &x
}
func packageKey(p yeekeclient.ReturnPackage) string {
if x := external(p.ID); x != "<nil>" && x != "" {
return x
}
return p.Ordersn + "/" + p.TrackingNo + "/" + external(p.ShopID) + "/" + p.CreateTime.String()
}
// itemKey builds the stable per-item identity used to upsert
// models.YeekeReturnItem without duplicating rows across syncs. It prefers
// the yeeke-issued identifiers (i.ID, then i.ItemID/i.VariationID) because
// those stay the same regardless of the order the API returns items in
// within a package; the positional index n is only used as a last resort
// when none of those identifiers are present, since in that case the index
// is the sole thing distinguishing items of the same package (#336).
func itemKey(p yeekeclient.ReturnPackage, i yeekeclient.ReturnItem, n int) string {
id, itemID, variationID := external(i.ID), external(i.ItemID), external(i.VariationID)
if isEmptyExternal(id) && isEmptyExternal(itemID) && isEmptyExternal(variationID) {
return packageKey(p) + "/" + id + "/" + itemID + "/" + variationID + "/" + strconv.Itoa(n)
}
return packageKey(p) + "/" + id + "/" + itemID + "/" + variationID
}
// isEmptyExternal reports whether external() produced a value that carries
// no real identity: either the field was unset (formatted as "<nil>" by
// fmt.Sprint on a nil/zero value) or it was an explicit empty string.
func isEmptyExternal(v string) bool { return v == "" || v == "<nil>" }
// takeoverStaleLease reclaims a run whose lease has expired, e.g. because the
// process crashed or was restarted mid-sync. It matches the
// lease-with-expiry-takeover idiom used by
// app/goauto/purchase/order_writeback_worker.go: a single conditional UPDATE
// guarded by "status = running AND lease_expires_at <= now" flips the stale
// row to a terminal status and frees active_slot in one statement, so it is
// atomic without a separate row lock. The stale row is never deleted — it is
// left in place with status "failed" and an error_message explaining why, so
// history stays auditable. If two callers race this same UPDATE, only the
// first to reach the database actually changes any row; the second's WHERE
// clause no longer matches (status is no longer "running") and it affects
// zero rows, which is a harmless no-op. Whichever caller then wins the
// subsequent Create (see acquire) is arbitrated by the ux_yeeke_sync_run_active_slot
// unique index, exactly as it already is for two brand-new concurrent runs.
func (s *Service) takeoverStaleLease(ctx context.Context) error {
now := time.Now().UTC()
return s.db.WithContext(ctx).Model(&models.YeekeSyncRun{}).
Where("status = ? AND active_slot = ? AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?", "running", 1, now).
Updates(map[string]any{
"status": "failed",
"active_slot": nil,
"lease_owner": "",
"error_message": "lease expired: run interrupted, likely a process restart mid-sync (stale lease takeover)",
"lease_expires_at": nil,
"finished_at": now,
}).Error
}
func (s *Service) acquire(ctx context.Context, trigger string) (*models.YeekeSyncRun, error) {
if e := s.takeoverStaleLease(ctx); e != nil {
return nil, e
}
now := time.Now().UTC()
owner := fmt.Sprintf("%d", now.UnixNano())
slot := uint8(1)
exp := now.Add(s.cfg.Lease)
r := &models.YeekeSyncRun{Status: "running", Trigger: trigger, StartedAt: now, ActiveSlot: &slot, LeaseOwner: owner, LeaseExpiresAt: &exp}
if e := s.db.WithContext(ctx).Create(r).Error; e != nil {
return nil, e
}
return r, nil
}
type Service struct {
db *gorm.DB
client *yeekeclient.Client
cfg Config
}
func NewService(db *gorm.DB, c *yeekeclient.Client, cfg Config) *Service {
return &Service{db: db, client: c, cfg: cfg.norm()}
}
// Sync acquires the shared lease, runs the page walk synchronously and
// returns the final report. Tests use this directly; StartSync (start.go)
// uses SyncAsync instead so an HTTP request does not block for the whole
// run.
func (s *Service) Sync(ctx context.Context, trigger string) (Report, error) {
r, e := s.acquire(ctx, trigger)
if e != nil {
return Report{}, e
}
return s.run(ctx, r)
}
// SyncAsync acquires the lease synchronously (so the caller gets a run id
// immediately, and the unique active_slot lease is held before returning)
// and continues the page walk in a background goroutine bound to its own
// timeout, independent of the caller's request context.
// onDone, when non-nil, runs after the background page walk finishes
// (success or failure) — StartSync uses it to release the in-memory
// concurrency gate at the right time instead of when this function returns.
func (s *Service) SyncAsync(ctx context.Context, trigger string, onDone func()) (uint64, error) {
r, e := s.acquire(ctx, trigger)
if e != nil {
return 0, e
}
go func() {
bg, cancel := context.WithTimeout(context.Background(), syncTimeout)
defer cancel()
_, _ = s.run(bg, r)
if onDone != nil {
onDone()
}
}()
return r.ID, nil
}
func (s *Service) run(ctx context.Context, r *models.YeekeSyncRun) (Report, error) {
rep := Report{RunID: r.ID, Status: "failed"}
var errMsg string
var runErr error
defer func() {
now := time.Now().UTC()
updates := map[string]any{"status": rep.Status, "total_pages": rep.TotalPages, "read_count": rep.Read, "created_count": rep.Created, "updated_count": rep.Updated, "skipped_count": rep.Skipped, "failed_count": rep.Failed, "error_message": errMsg, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "finished_at": now}
if rep.Status == "succeeded" {
updates["last_success_at"] = now
}
s.db.Model(r).Updates(updates)
}()
seen := map[string]bool{}
var firstWriteErr error
for page := 1; page <= s.cfg.MaxPages; page++ {
var p yeekeclient.ReturnPage
var e error
for a := 0; ; a++ {
p, e = s.client.List(ctx, page, s.cfg.PageSize)
if e == nil || a >= s.cfg.Retry {
break
}
select {
case <-ctx.Done():
runErr = ctx.Err()
errMsg = truncateRunError(runErr.Error())
return rep, runErr
case <-time.After(time.Duration(a+1) * 100 * time.Millisecond):
}
}
if e != nil {
// A failed page never overwrites what earlier pages already wrote
// (#336): the run simply stops here and everything upserted so far
// stays as-is, reported through Read/Created/Updated above.
runErr = e
errMsg = truncateRunError(e.Error())
return rep, runErr
}
rep.TotalPages = page
if len(p.Records) == 0 {
break
}
finger := pageFingerprint(p)
if seen[finger] {
rep.Skipped += len(p.Records)
break
}
seen[finger] = true
for _, x := range p.Records {
created, updated, err := s.upsert(ctx, x)
if err != nil {
rep.Failed++
if firstWriteErr == nil {
firstWriteErr = err
}
continue
}
rep.Read++
if created {
rep.Created++
} else if updated {
rep.Updated++
} else {
rep.Skipped++
}
}
if len(p.Records) < s.cfg.PageSize {
break
}
if p.Pages > 0 && page >= p.Pages {
break
}
}
rep.Status = "succeeded"
if rep.Failed > 0 && firstWriteErr != nil {
// Surface why records failed instead of a bare counter.
errMsg = truncateRunError(fmt.Sprintf("%d 条写入失败,首个原因:%v", rep.Failed, firstWriteErr))
if rep.Read == 0 {
rep.Status = "failed"
runErr = errors.New(errMsg)
}
}
return rep, nil
}
// truncateRunError keeps error_message inside the column's size limit. It
// never includes request bodies or headers, so it cannot leak a captcha,
// token or credential: every error path above passes only Go error text from
// HTTP status/timeout/JSON-decoding failures.
func truncateRunError(value string) string {
const limit = 1000
runes := []rune(strings.TrimSpace(value))
if len(runes) <= limit {
return string(runes)
}
return string(runes[:limit])
}
func pageFingerprint(p yeekeclient.ReturnPage) string {
b, _ := json.Marshal(p.Records)
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (bool, bool, error) {
now := time.Now().UTC()
key := packageKey(p)
status := external(p.Status)
var isNew bool
// One transaction per package: a failing item never leaves a package row
// behind without its items. Rows are always inserted fully populated —
// MySQL strict mode (NO_ZERO_DATE) rejects the zero last_synced_at an
// empty placeholder insert would carry, which failed every record.
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var row models.YeekeReturnPackage
e := tx.Where("external_id = ?", key).First(&row).Error
isNew = errors.Is(e, gorm.ErrRecordNotFound)
if e != nil && !isNew {
return e
}
fields := models.YeekeReturnPackage{
ExternalID: key, OrderSN: p.Ordersn, TrackingNo: p.TrackingNo,
ShopID: external(p.ShopID), ShopName: p.ShopName,
WareCode: p.WareCode, WareHouse: p.WareHouse, WareName: p.WareName,
ClaimStatus: status, StatusUnrecognized: !knownClaimStatuses[status],
ClaimTime: stamp(p.ClaimTime), CreateTime: stamp(p.CreateTime),
UpdateTime: stamp(p.UpdateTime), DestroyDeadLine: stamp(p.DestroyDeadLine),
LastSyncedAt: now, SyncStatus: "ok",
}
if isNew {
row = fields
if e = tx.Create(&row).Error; e != nil {
return e
}
} else {
vals := map[string]any{"order_sn": fields.OrderSN, "tracking_no": fields.TrackingNo, "shop_id": fields.ShopID, "shop_name": fields.ShopName, "ware_code": fields.WareCode, "ware_house": fields.WareHouse, "ware_name": fields.WareName, "claim_status": fields.ClaimStatus, "status_unrecognized": fields.StatusUnrecognized, "claim_time": fields.ClaimTime, "create_time": fields.CreateTime, "update_time": fields.UpdateTime, "destroy_dead_line": fields.DestroyDeadLine, "last_synced_at": now, "sync_status": "ok"}
if e = tx.Model(&row).Updates(vals).Error; e != nil {
return e
}
}
for n, i := range p.Items {
ik := itemKey(p, i, n)
item := models.YeekeReturnItem{
PackageID: row.ID, ExternalKey: ik,
ItemID: external(i.ItemID), VariationID: external(i.VariationID),
ItemName: i.ItemName, VariationName: i.VariationName, Image: i.Image,
Quantity: int64(i.Quantity), LastSyncedAt: now, SyncStatus: "ok",
}
var existing models.YeekeReturnItem
ie := tx.Where("package_id = ? AND external_key = ?", row.ID, ik).First(&existing).Error
switch {
case errors.Is(ie, gorm.ErrRecordNotFound):
if e = tx.Create(&item).Error; e != nil {
return e
}
case ie != nil:
return ie
default:
iv := map[string]any{"item_id": item.ItemID, "variation_id": item.VariationID, "item_name": item.ItemName, "variation_name": item.VariationName, "image": item.Image, "quantity": item.Quantity, "last_synced_at": now, "sync_status": "ok"}
if e = tx.Model(&existing).Updates(iv).Error; e != nil {
return e
}
}
}
return nil
})
if err != nil {
return false, false, err
}
return isNew, !isNew, nil
}
+779
View File
@@ -0,0 +1,779 @@
package yeeke
import (
"context"
"encoding/json"
"fmt"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
"go-admin/app/goauto/yeekeclient"
"go-admin/config"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
type stubOCR struct{ codes []string }
func (s *stubOCR) Recognize(context.Context, []byte) (string, error) {
if len(s.codes) == 0 {
return "", nil
}
c := s.codes[0]
s.codes = s.codes[1:]
return c, nil
}
func testDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := migrations.Migrate(db); err != nil {
t.Fatal(err)
}
return db
}
func record(id, itemID, variationID string, status any) string {
b, _ := json.Marshal(map[string]any{
"id": id, "ordersn": "o-" + id, "trackingNo": "t-" + id, "status": status,
"items": []map[string]any{{
"id": id + "-i1", "itemId": itemID, "variationId": variationID,
"itemName": "n", "variationName": "v", "variationQuantityPurchased": 1,
}},
})
return string(b)
}
func page(records []string, total, pages int) string {
return fmt.Sprintf(`{"success":true,"result":{"records":[%s],"total":%d,"pages":%d}}`, strings.Join(records, ","), total, pages)
}
// TestPagingSurvivesTotalChangingMidRun: page 1 reports one total/pages, page
// 2 reports a different total/pages (the underlying data changed between the
// two requests). The walk must still finish using what each page returned.
func TestPagingSurvivesTotalChangingMidRun(t *testing.T) {
db := testDB(t)
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
switch n {
case 1:
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 100, 2))
case 2:
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 50, 1))
default:
fmt.Fprint(w, page(nil, 0, 0))
}
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 1})
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatal(err)
}
if rep.Status != "succeeded" || rep.Read != 2 {
t.Fatalf("rep=%+v", rep)
}
}
// TestPagingSkipsARepeatedDuplicatePage: the server returns the exact same
// page twice in a row (e.g. a retried request landed after all). The second
// occurrence must be recognized as a duplicate and stop the walk instead of
// looping or double counting.
func TestPagingSkipsARepeatedDuplicatePage(t *testing.T) {
db := testDB(t)
var calls int32
body := page([]string{record("p1", "i", "v1", 1)}, 10, 5)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
if n <= 2 {
fmt.Fprint(w, body) // identical page served twice
return
}
fmt.Fprint(w, page(nil, 10, 5))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 1})
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatal(err)
}
if rep.Status != "succeeded" {
t.Fatalf("rep=%+v", rep)
}
var n int64
db.Model(&models.YeekeReturnPackage{}).Count(&n)
if n != 1 {
t.Fatalf("packages=%d, want 1 (duplicate page must not double-insert)", n)
}
if calls != 2 {
t.Fatalf("calls=%d, want exactly 2 (stop right after recognizing the duplicate)", calls)
}
}
// TestPagingStopsOnEmptyPage confirms an empty page ends the walk cleanly.
func TestPagingStopsOnEmptyPage(t *testing.T) {
db := testDB(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page(nil, 0, 0))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatal(err)
}
if rep.Status != "succeeded" || rep.TotalPages != 1 || rep.Read != 0 {
t.Fatalf("rep=%+v", rep)
}
}
// TestPagingTimeoutFailsRunButKeepsEarlierPages: page 1 succeeds and is
// persisted; page 2 always times out. The run must end as "failed" (after
// retrying up to cfg.Retry times) but page 1's row must remain intact — a
// failed page must never roll back or overwrite valid prior data.
func TestPagingTimeoutFailsRunButKeepsEarlierPages(t *testing.T) {
db := testDB(t)
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
if n == 1 {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 10, 5))
return
}
// Simulate a slow/timed-out request: this response is deliberately
// slower than the client's own context deadline below, so the client
// side must give up on its own rather than waiting for us.
time.Sleep(2 * time.Second)
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 1, Retry: 1})
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
rep, err := s.Sync(ctx, "manual")
if err == nil {
t.Fatal("expected the timed-out page to surface an error")
}
if rep.Status != "failed" || rep.RunID == 0 {
t.Fatalf("rep=%+v", rep)
}
var row models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&row).Error; e != nil {
t.Fatalf("page 1's row must survive a later page's failure: %v", e)
}
if row.ClaimStatus != "1" {
t.Fatalf("page 1's row must be unmodified: %+v", row)
}
var run models.YeekeSyncRun
if e := db.First(&run, rep.RunID).Error; e != nil {
t.Fatal(e)
}
if run.Status != "failed" || run.ErrorMessage == "" {
t.Fatalf("run=%+v", run)
}
}
// TestSyncResumeAfterSimulatedRestart: a first run fails partway through
// (simulating the process being interrupted after committing page 1). A
// second, full run afterwards must succeed and must not duplicate the row
// page 1 already wrote — it converges to exactly one package row.
func TestSyncResumeAfterSimulatedRestart(t *testing.T) {
db := testDB(t)
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
if n == 1 {
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 10, 2))
return
}
if n == 2 {
w.WriteHeader(http.StatusInternalServerError)
return
}
// Full second run: both pages succeed this time.
if n == 3 {
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 1)}, 10, 2))
return
}
fmt.Fprint(w, page(nil, 10, 2))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 1, Retry: 0})
if _, err := s.Sync(context.Background(), "manual"); err == nil {
t.Fatal("expected the first ('interrupted') run to fail")
}
rep2, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatal(err)
}
if rep2.Status != "succeeded" {
t.Fatalf("rep2=%+v", rep2)
}
var n int64
db.Model(&models.YeekeReturnPackage{}).Count(&n)
if n != 1 {
t.Fatalf("packages=%d, want 1 (resume must not duplicate p1)", n)
}
}
// TestIdempotentStatusUpdateInPlace: syncing the same package twice with a
// different status the second time updates the existing row rather than
// creating a second one.
func TestIdempotentStatusUpdateInPlace(t *testing.T) {
db := testDB(t)
var status int32 = 1
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p1", "i", "v1", atomic.LoadInt32(&status))}, 1, 1))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
atomic.StoreInt32(&status, 9) // an unrecognized status the second time
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
var n int64
db.Model(&models.YeekeReturnPackage{}).Count(&n)
if n != 1 {
t.Fatalf("packages=%d, want 1 (status change must update in place)", n)
}
var row models.YeekeReturnPackage
db.Where("external_id = ?", "p1").First(&row)
if row.ClaimStatus != "9" || !row.StatusUnrecognized {
t.Fatalf("row=%+v, want claim_status=9 flagged unrecognized", row)
}
}
// TestUnknownStatusIsPreservedVerbatimAndFlagged: an unrecognized status value
// is kept as-is (never remapped into a known bucket) and the row is flagged.
func TestUnknownStatusIsPreservedVerbatimAndFlagged(t *testing.T) {
db := testDB(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p1", "i", "v1", 7)}, 1, 1))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
var row models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&row).Error; e != nil {
t.Fatal(e)
}
if row.ClaimStatus != "7" {
t.Fatalf("claim_status=%q, want the raw value 7 preserved verbatim", row.ClaimStatus)
}
if !row.StatusUnrecognized {
t.Fatal("an unknown status must be flagged, not silently accepted")
}
}
// TestActiveSlotLeaseRejectsConcurrentRuns exercises the same DB-level
// uniqueness the manual trigger and the scheduled job both rely on
// (models.YeekeSyncRun.ActiveSlot): two Sync calls racing against the same
// database must not both hold the lease at once.
func TestActiveSlotLeaseRejectsConcurrentRuns(t *testing.T) {
db := testDB(t)
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-release
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page(nil, 0, 0))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
started := make(chan struct{})
var firstErr, secondErr error
go func() {
close(started)
_, firstErr = s.Sync(context.Background(), "manual")
}()
<-started
time.Sleep(50 * time.Millisecond) // let the first Sync acquire its lease row
_, secondErr = s.Sync(context.Background(), "scheduled")
close(release)
time.Sleep(50 * time.Millisecond)
if secondErr == nil {
t.Fatal("a second concurrent Sync must be rejected by the active_slot lease")
}
_ = firstErr
}
// TestStartSyncGateRejectsConcurrentTriggers exercises StartSync itself (the
// entry point manual and scheduled triggers actually share): a second call
// while one is in flight is rejected, and with skipIfRunning it is reported
// as skipped instead of erroring — this is what the scheduled job uses so a
// tick landing during a manual run does not surface as a failure.
func TestStartSyncGateRejectsConcurrentTriggers(t *testing.T) {
db := testDB(t)
restore := setTestYeekeConfig(t, "op", "secret-pw")
defer restore()
release := make(chan struct{})
var loginCalls, listCalls int32
srv := fakeYeekeServer(t, &loginCalls, &listCalls, release)
defer srv.Close()
restoreURL := setTestYeekeBaseURL(t, srv.URL)
defer restoreURL()
var wg sync.WaitGroup
wg.Add(1)
var firstErr error
go func() {
defer wg.Done()
_, firstErr = StartSync(context.Background(), db, &stubOCR{codes: []string{"abcd"}}, "manual", false)
}()
// Give the first call time to acquire syncGate and the DB lease before
// the second one is attempted.
time.Sleep(150 * time.Millisecond)
_, err := StartSync(context.Background(), db, &stubOCR{}, "scheduled", true)
if err != nil {
t.Fatalf("skipIfRunning=true must not error, got %v", err)
}
_, err2 := StartSync(context.Background(), db, &stubOCR{}, "manual", false)
if err2 == nil || err2 != ErrAlreadyRunning {
t.Fatalf("skipIfRunning=false must report ErrAlreadyRunning, got %v", err2)
}
close(release)
wg.Wait()
if firstErr != nil {
t.Fatalf("first StartSync should have completed cleanly: %v", firstErr)
}
}
// TestLoginFailureMessageNeverLeaksCredentialsOrCaptcha: whatever StartSync
// or the underlying client return as an error, the credential, password and
// recognized captcha text must never appear in it, since that text ends up
// in server logs and (truncated) in yeeke_sync_run.error_message.
func TestLoginFailureMessageNeverLeaksCredentialsOrCaptcha(t *testing.T) {
db := testDB(t)
const secretPassword = "S3cr3t-Do-Not-Leak"
const secretCaptcha = "zZqQ9x"
restore := setTestYeekeConfig(t, "leak-user", secretPassword)
defer restore()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/agent-foreign/sys/randomImage":
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "checkKey": "k"}})
case "/agent-foreign/sys/login":
json.NewEncoder(w).Encode(map[string]any{"success": false, "code": 1, "message": "验证码错误"})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
restoreURL := setTestYeekeBaseURL(t, srv.URL)
defer restoreURL()
_, err := StartSync(context.Background(), db, &stubOCR{codes: []string{secretCaptcha}}, "manual", false)
if err == nil {
t.Fatal("expected a login failure")
}
msg := err.Error()
if strings.Contains(msg, secretPassword) || strings.Contains(msg, secretCaptcha) || strings.Contains(msg, "leak-user") {
t.Fatalf("error message leaked a credential or captcha text: %q", msg)
}
var run models.YeekeSyncRun
// StartSync failed before acquiring a run row here (Connect failed first),
// so there should be no run row at all to check — that is itself part of
// the guarantee: a failed login never gets far enough to write a summary
// row that could carry sensitive text.
if e := db.Order("id desc").First(&run).Error; e == nil {
if strings.Contains(run.ErrorMessage, secretPassword) || strings.Contains(run.ErrorMessage, secretCaptcha) {
t.Fatalf("run.ErrorMessage leaked a credential or captcha text: %q", run.ErrorMessage)
}
}
}
// fakeYeekeServer serves a minimal login+list surface. It blocks the *list*
// call on release, so a test can hold StartSync's background goroutine open
// long enough to exercise the concurrency gate.
func fakeYeekeServer(t *testing.T, loginCalls, listCalls *int32, release chan struct{}) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/agent-foreign/sys/randomImage":
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "checkKey": "k"}})
case "/agent-foreign/sys/login":
atomic.AddInt32(loginCalls, 1)
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"token": "tok", "userInfo": map[string]any{"id": "u1", "username": "op"}}})
case "/agent-foreign/packageClaimRec/relation/list":
atomic.AddInt32(listCalls, 1)
<-release
fmt.Fprint(w, page(nil, 0, 0))
default:
http.NotFound(w, r)
}
}))
}
// The next two helpers isolate StartSync's config.ExtConfig.Yeeke dependency
// for tests, restoring it afterwards so other tests are unaffected.
func setTestYeekeConfig(t *testing.T, username, password string) func() {
t.Helper()
before := config.ExtConfig.Yeeke
config.ExtConfig.Yeeke.Username = username
config.ExtConfig.Yeeke.Password = password
config.ExtConfig.Yeeke.OcrURL = "http://unused.invalid/ocr"
return func() { config.ExtConfig.Yeeke = before }
}
func setTestYeekeBaseURL(t *testing.T, url string) func() {
t.Helper()
before := config.ExtConfig.Yeeke
config.ExtConfig.Yeeke.BaseURL = url
return func() { config.ExtConfig.Yeeke = before }
}
// packageWithItems builds a raw list-page record for one package carrying an
// arbitrary, caller-ordered set of items, so tests can reorder items between
// two syncs of the same package.
func packageWithItems(pkgID string, items ...map[string]any) string {
b, _ := json.Marshal(map[string]any{
"id": pkgID, "ordersn": "o-" + pkgID, "trackingNo": "t-" + pkgID, "status": 1,
"items": items,
})
return string(b)
}
func item(id, itemID, variationID string) map[string]any {
return map[string]any{"id": id, "itemId": itemID, "variationId": variationID, "itemName": "n", "variationName": "v", "variationQuantityPurchased": 1}
}
// itemNoIDs builds an item carrying no yeeke-issued identifiers at all
// (id/itemId/variationId all empty), the case itemKey's positional-index
// fallback exists for.
func itemNoIDs() map[string]any {
return map[string]any{"id": "", "itemId": "", "variationId": "", "itemName": "n", "variationName": "v", "variationQuantityPurchased": 1}
}
// --- Defect 1 (#336): stale lease takeover -------------------------------
// TestStaleLeaseIsTakenOverOnNextAcquire simulates a crash: a "running" row
// is left behind with an active_slot and a lease that has already expired
// (as if the process died mid-sync, long before the lease's normal
// duration). The very next Sync call — scheduled or manual — must reclaim
// the slot rather than being permanently blocked, and the abandoned row must
// end up in a clear terminal state (not silently deleted) recording why.
func TestStaleLeaseIsTakenOverOnNextAcquire(t *testing.T) {
db := testDB(t)
slot := uint8(1)
past := time.Now().UTC().Add(-time.Hour)
stale := models.YeekeSyncRun{
Status: "running", Trigger: "scheduled", StartedAt: past.Add(-time.Minute),
ActiveSlot: &slot, LeaseOwner: "dead-process", LeaseExpiresAt: &past,
}
if e := db.Create(&stale).Error; e != nil {
t.Fatal(e)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page(nil, 0, 0))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("resume after a stale lease must succeed, got err=%v", err)
}
if rep.Status != "succeeded" {
t.Fatalf("rep=%+v", rep)
}
var reclaimed models.YeekeSyncRun
if e := db.First(&reclaimed, stale.ID).Error; e != nil {
t.Fatal(e)
}
if reclaimed.Status != "failed" {
t.Fatalf("stale run status=%q, want a terminal status (not silently left running or deleted)", reclaimed.Status)
}
if reclaimed.ErrorMessage == "" {
t.Fatal("stale run must record why it was taken over")
}
if reclaimed.ActiveSlot != nil {
t.Fatal("stale run must release active_slot on takeover")
}
// The new run's own row clears active_slot on completion just like any
// other successful run (see run()'s defer), so what proves the takeover
// happened is that a second, distinct run row now exists alongside the
// reclaimed stale one.
var totalRuns int64
db.Model(&models.YeekeSyncRun{}).Count(&totalRuns)
if totalRuns != 2 {
t.Fatalf("expected the stale row plus exactly one new run after takeover, got %d run rows", totalRuns)
}
if rep.RunID == stale.ID {
t.Fatal("the new run must not reuse the stale run's row")
}
}
// TestActiveSlotLeaseRejectsConcurrentRuns above must still pass unmodified:
// a lease that has NOT expired must keep blocking a second run. This test
// pins that same guarantee at the acquire() level directly.
func TestValidLeaseIsNotTakenOver(t *testing.T) {
db := testDB(t)
slot := uint8(1)
future := time.Now().UTC().Add(time.Hour)
holding := models.YeekeSyncRun{
Status: "running", Trigger: "manual", StartedAt: time.Now().UTC(),
ActiveSlot: &slot, LeaseOwner: "still-alive", LeaseExpiresAt: &future,
}
if e := db.Create(&holding).Error; e != nil {
t.Fatal(e)
}
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 10})
if _, e := s.acquire(context.Background(), "scheduled"); e == nil {
t.Fatal("a still-valid lease must not be taken over or bypassed")
}
var row models.YeekeSyncRun
if e := db.First(&row, holding.ID).Error; e != nil {
t.Fatal(e)
}
if row.Status != "running" || row.ActiveSlot == nil {
t.Fatalf("holder must be untouched: %+v", row)
}
}
// TestConcurrentTakeoverExactlyOneWins races two acquire() calls against the
// same stale, expired-lease row. Both attempt the takeover UPDATE and then a
// Create; the takeover UPDATE is idempotent (the loser affects zero rows
// since the row's status is no longer "running" by the time it runs), and
// the ux_yeeke_sync_run_active_slot unique index arbitrates the Create race
// the same way it already does for two brand-new concurrent runs. Exactly
// one goroutine must come away holding the slot.
func TestConcurrentTakeoverExactlyOneWins(t *testing.T) {
db := testDB(t)
slot := uint8(1)
past := time.Now().UTC().Add(-time.Hour)
stale := models.YeekeSyncRun{
Status: "running", Trigger: "scheduled", StartedAt: past.Add(-time.Minute),
ActiveSlot: &slot, LeaseOwner: "dead-process", LeaseExpiresAt: &past,
}
if e := db.Create(&stale).Error; e != nil {
t.Fatal(e)
}
// SQLite only allows one writer at a time; serialize connections through
// the Go pool (same pattern as app/goauto/purchase/order_backfill_test.go
// and friends) so the race is decided by acquire()'s own logic rather
// than by spurious "database is locked" errors.
if sqlDB, e := db.DB(); e == nil {
sqlDB.SetMaxOpenConns(1)
}
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 10})
const n = 8
var wg sync.WaitGroup
oks := make([]bool, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, e := s.acquire(context.Background(), "manual")
oks[i] = e == nil
}(i)
}
wg.Wait()
winners := 0
for _, ok := range oks {
if ok {
winners++
}
}
if winners != 1 {
t.Fatalf("winners=%d, want exactly 1 (active_slot must arbitrate concurrent takeover attempts)", winners)
}
var holders int64
db.Model(&models.YeekeSyncRun{}).Where("active_slot = ?", 1).Count(&holders)
if holders != 1 {
t.Fatalf("holders=%d, want exactly 1 row holding active_slot after the race", holders)
}
}
// --- Defect 2 (#336): itemKey must not depend on item order --------------
// TestItemKeyStableAcrossReorder syncs the same package twice with its two
// items in reversed order the second time. Reordering must not create new
// rows: each item's identity must key off its own IDs, not its position.
func TestItemKeyStableAcrossReorder(t *testing.T) {
db := testDB(t)
var call int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
n := atomic.AddInt32(&call, 1)
if n == 1 {
fmt.Fprint(w, page([]string{packageWithItems("p1", item("i1", "item-a", "var-a"), item("i2", "item-b", "var-b"))}, 1, 1))
return
}
if n == 2 {
// Same package, items reordered.
fmt.Fprint(w, page([]string{packageWithItems("p1", item("i2", "item-b", "var-b"), item("i1", "item-a", "var-a"))}, 1, 1))
return
}
fmt.Fprint(w, page(nil, 0, 0))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
var pkg models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&pkg).Error; e != nil {
t.Fatal(e)
}
var n int64
db.Model(&models.YeekeReturnItem{}).Where("package_id = ?", pkg.ID).Count(&n)
if n != 2 {
t.Fatalf("items=%d, want 2 (reordering the same items must not duplicate rows)", n)
}
}
// TestItemKeyIndexFallbackForItemsLackingAllIDs covers a package whose items
// carry no yeeke-issued identifiers at all: the positional index is the only
// thing that can distinguish them, so the fallback must still apply and keep
// them as separate rows.
func TestItemKeyIndexFallbackForItemsLackingAllIDs(t *testing.T) {
db := testDB(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{packageWithItems("p1", itemNoIDs(), itemNoIDs())}, 1, 1))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
var pkg models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&pkg).Error; e != nil {
t.Fatal(e)
}
var n int64
db.Model(&models.YeekeReturnItem{}).Where("package_id = ?", pkg.ID).Count(&n)
if n != 2 {
t.Fatalf("items=%d, want 2 (items lacking all IDs must still be distinguished by position)", n)
}
}
// TestItemKeyDistinctVariationsOfSameItemID pins existing behavior: two
// items sharing the same itemID but different variationIDs are, and must
// remain, two distinct rows.
func TestItemKeyDistinctVariationsOfSameItemID(t *testing.T) {
db := testDB(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{packageWithItems("p1", item("i1", "item-a", "var-1"), item("i2", "item-a", "var-2"))}, 1, 1))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 10})
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
var pkg models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&pkg).Error; e != nil {
t.Fatal(e)
}
var n int64
db.Model(&models.YeekeReturnItem{}).Where("package_id = ?", pkg.ID).Count(&n)
if n != 2 {
t.Fatalf("items=%d, want 2 (same itemID with different variationID must stay distinct)", n)
}
}
// TestResyncUpdatesClaimTimeAndDestroyDeadLine: yeeke may correct a package's
// claim time or destroy deadline between two syncs of the same package. The
// second sync must overwrite the stored values, not just refresh
// last_synced_at.
func TestResyncUpdatesClaimTimeAndDestroyDeadLine(t *testing.T) {
db := testDB(t)
recordWithTimes := func(claimTime, destroyDeadLine string) string {
b, _ := json.Marshal(map[string]any{
"id": "p1", "ordersn": "o-p1", "trackingNo": "t-p1", "status": "1",
"claimTime": claimTime, "destroyDeadLine": destroyDeadLine,
"items": []map[string]any{{
"id": "p1-i1", "itemId": "i", "variationId": "v1",
"itemName": "n", "variationName": "v", "variationQuantityPurchased": "1",
}},
})
return string(b)
}
var body string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, body)
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 20})
body = page([]string{recordWithTimes("2026-09-01 10:00:00", "2026-10-01 10:00:00")}, 1, 1)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("first sync: %v", err)
}
var row models.YeekeReturnPackage
db.Where("external_id = ?", "p1").First(&row)
if got := row.ClaimTime.Format("2006-01-02"); got != "2026-09-01" {
t.Fatalf("claim_time after first sync = %s", got)
}
body = page([]string{recordWithTimes("2026-09-15 08:00:00", "2026-10-15 08:00:00")}, 1, 1)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("second sync: %v", err)
}
db.Where("external_id = ?", "p1").First(&row)
if got := row.ClaimTime.Format("2006-01-02"); got != "2026-09-15" {
t.Fatalf("claim_time after resync = %s, want updated value", got)
}
if got := row.DestroyDeadLine.Format("2006-01-02"); got != "2026-10-15" {
t.Fatalf("destroy_dead_line after resync = %s, want updated value", got)
}
}
+101
View File
@@ -0,0 +1,101 @@
package yeeke
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/yeekeclient"
"gorm.io/gorm"
)
// rejectZeroLastSyncedAt emulates MySQL strict mode (NO_ZERO_DATE) in SQLite:
// an INSERT carrying a zero last_synced_at is refused, exactly as production
// MySQL refused the old placeholder insert and failed every record.
func rejectZeroLastSyncedAt(t *testing.T, db *gorm.DB) {
t.Helper()
err := db.Callback().Create().Before("gorm:create").Register("test:strict_no_zero_date", func(tx *gorm.DB) {
switch v := tx.Statement.Dest.(type) {
case *models.YeekeReturnPackage:
if v.LastSyncedAt.IsZero() {
_ = tx.AddError(errors.New("Error 1292: Incorrect datetime value: '0000-00-00' for column 'last_synced_at'"))
}
case *models.YeekeReturnItem:
if v.LastSyncedAt.IsZero() {
_ = tx.AddError(errors.New("Error 1292: Incorrect datetime value: '0000-00-00' for column 'last_synced_at'"))
}
}
})
if err != nil {
t.Fatal(err)
}
}
func TestSyncWritesUnderStrictDatetimeMode(t *testing.T) {
db := testDB(t)
rejectZeroLastSyncedAt(t, db)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p1", "i", "v1", "1"), record("p2", "i", "v2", "1")}, 2, 1))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
rep, err := NewService(db, c, Config{PageSize: 20}).Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("sync: %v (rep=%+v)", err, rep)
}
if rep.Failed != 0 || rep.Created != 2 {
t.Fatalf("rep=%+v, want 2 created and 0 failed", rep)
}
var pkgs, items int64
db.Model(&models.YeekeReturnPackage{}).Count(&pkgs)
db.Model(&models.YeekeReturnItem{}).Count(&items)
if pkgs != 2 || items != 2 {
t.Fatalf("stored packages=%d items=%d", pkgs, items)
}
var p models.YeekeReturnPackage
db.First(&p)
if p.LastSyncedAt.IsZero() || time.Since(p.LastSyncedAt) > time.Minute {
t.Fatalf("last_synced_at not set: %v", p.LastSyncedAt)
}
}
// When records fail to write, the run must say why and must not claim success
// when nothing was stored.
func TestSyncReportsWriteFailureReason(t *testing.T) {
db := testDB(t)
if err := db.Callback().Create().Before("gorm:create").Register("test:fail_items", func(tx *gorm.DB) {
if _, ok := tx.Statement.Dest.(*models.YeekeReturnItem); ok {
_ = tx.AddError(errors.New("simulated item write failure"))
}
}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p1", "i", "v1", "1")}, 1, 1))
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
rep, _ := NewService(db, c, Config{PageSize: 20}).Sync(context.Background(), "manual")
if rep.Status != "failed" || rep.Failed != 1 {
t.Fatalf("rep=%+v, want failed with 1 failure", rep)
}
var run models.YeekeSyncRun
db.Order("id DESC").First(&run)
if !strings.Contains(run.ErrorMessage, "1 条写入失败") || !strings.Contains(run.ErrorMessage, "simulated item write failure") {
t.Fatalf("error_message = %q", run.ErrorMessage)
}
// The package insert shares the transaction, so no half-written package.
var pkgs int64
db.Model(&models.YeekeReturnPackage{}).Count(&pkgs)
if pkgs != 0 {
t.Fatalf("package rows = %d, want 0 after rolled-back item failure", pkgs)
}
}
+42
View File
@@ -0,0 +1,42 @@
package yeeke
import (
"context"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
"go-admin/app/goauto/yeekeclient"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"net/http"
"net/http/httptest"
"testing"
)
func TestSyncIsIdempotentAndKeepsVariationsSeparate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":true,"result":{"records":[{"id":"p1","ordersn":"o","trackingNo":"t","status":1,"items":[{"id":"a","itemId":"i","variationId":"v1","itemName":"n","variationName":"red","variationQuantityPurchased":1},{"id":"b","itemId":"i","variationId":"v2","itemName":"n","variationName":"blue","variationQuantityPurchased":1}]}],"total":1,"pages":1}}`))
}))
defer server.Close()
db, _ := gorm.Open(sqlite.Open("file:yeeke-sync?mode=memory&cache=shared"), &gorm.Config{})
if e := migrations.Migrate(db); e != nil {
t.Fatal(e)
}
c, _ := yeekeclient.New(server.URL)
s := NewService(db, c, Config{PageSize: 10})
if _, e := s.Sync(context.Background(), "manual"); e != nil {
t.Fatal(e)
}
if _, e := s.Sync(context.Background(), "manual"); e != nil {
t.Fatal(e)
}
var n int64
db.Model(&models.YeekeReturnPackage{}).Count(&n)
if n != 1 {
t.Fatalf("packages=%d", n)
}
db.Model(&models.YeekeReturnItem{}).Count(&n)
if n != 2 {
t.Fatalf("items=%d", n)
}
}
+385
View File
@@ -0,0 +1,385 @@
package yeekeclient
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"time"
)
var ErrSessionInvalid = errors.New("yeeke session invalid")
var ErrNoSession = errors.New("yeeke session unavailable")
type Session struct {
Username, Token, CookiesJSON, UserID string
ExpiresAt time.Time
}
type Captcha struct {
Image []byte
CheckKey string
ContentType string
}
type Client struct {
baseURL string
http *http.Client
jar *cookiejar.Jar
token string
retry int
}
func New(baseURL string) (*Client, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return nil, fmt.Errorf("yeeke base_url required")
}
j, e := cookiejar.New(nil)
if e != nil {
return nil, e
}
return &Client{baseURL: baseURL, jar: j, http: &http.Client{Jar: j, Timeout: 60 * time.Second}}, nil
}
func (c *Client) SetToken(t string) { c.token = t }
func (c *Client) Token() string { return c.token }
type cookieDTO struct{ Name, Value, Path string }
func (c *Client) ExportCookiesJSON() (string, error) {
u, e := url.Parse(c.baseURL)
if e != nil {
return "", e
}
a := []cookieDTO{}
for _, x := range c.jar.Cookies(u) {
a = append(a, cookieDTO{x.Name, x.Value, x.Path})
}
b, e := json.Marshal(a)
return string(b), e
}
func (c *Client) ImportCookiesJSON(s string) error {
var a []cookieDTO
if e := json.Unmarshal([]byte(s), &a); e != nil {
return e
}
u, e := url.Parse(c.baseURL)
if e != nil {
return e
}
cs := []*http.Cookie{}
for _, x := range a {
if x.Name != "" {
p := x.Path
if p == "" {
p = "/"
}
cs = append(cs, &http.Cookie{Name: x.Name, Value: x.Value, Path: p})
}
}
c.jar.SetCookies(u, cs)
return nil
}
func (c *Client) do(ctx context.Context, method, path string, body any, query url.Values) (json.RawMessage, error) {
b := io.Reader(nil)
if body != nil {
x, e := json.Marshal(body)
if e != nil {
return nil, e
}
b = bytes.NewReader(x)
}
u := c.baseURL + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, e := http.NewRequestWithContext(ctx, method, u, b)
if e != nil {
return nil, e
}
req.Header.Set("Accept", "application/json")
// yeeke is driven through its web front end; a Go default user agent is
// treated differently from the browser (see demo/yeeke_demo.py and the HAR).
req.Header.Set("User-Agent", browserUserAgent)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
// The yeeke web client authenticates with the X-Access-Token header (HAR);
// keeping the token out of the URL also keeps it out of any access log.
if c.token != "" {
req.Header.Set("X-Access-Token", c.token)
}
resp, e := c.http.Do(req)
if e != nil {
return nil, e
}
defer resp.Body.Close()
raw, e := io.ReadAll(resp.Body)
if e != nil {
return nil, e
}
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return nil, ErrSessionInvalid
}
if resp.StatusCode >= 500 {
return nil, fmt.Errorf("yeeke http %d", resp.StatusCode)
}
var env struct {
Success bool `json:"success"`
Code int `json:"code"`
Message string `json:"message"`
Result json.RawMessage `json:"result"`
}
if e = json.Unmarshal(raw, &env); e != nil {
return nil, e
}
if !env.Success {
return nil, &APIError{Code: env.Code, Message: env.Message}
}
return env.Result, nil
}
// browserUserAgent mirrors the desktop Chrome the yeeke web client runs in.
const browserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
// APIError is a yeeke business failure (success=false). Message is yeeke's own
// short text (e.g. "验证码错误"); it never contains our credentials or token.
type APIError struct {
Code int
Message string
}
func (e *APIError) Error() string {
return fmt.Sprintf("yeeke request failed code=%d: %s", e.Code, safeMessage(e.Message))
}
// Is keeps the historical contract: an expired or missing login is reported as
// ErrSessionInvalid so callers can re-login.
func (e *APIError) Is(target error) bool {
if target != ErrSessionInvalid {
return false
}
return e.Code == 401 || strings.Contains(e.Message, "token") || strings.Contains(e.Message, "登录已过期") || strings.Contains(e.Message, "未登录") || strings.Contains(e.Message, "重新登录") || (strings.Contains(e.Message, "登录") && strings.Contains(e.Message, "失效"))
}
// isCaptchaRejected reports a wrong captcha, the only login failure worth
// retrying with a fresh image.
func (e *APIError) isCaptchaRejected() bool { return strings.Contains(e.Message, "验证码") }
func safeMessage(m string) string {
r := []rune(strings.TrimSpace(m))
if len(r) > 60 {
r = r[:60]
}
return string(r)
}
func (c *Client) FetchCaptcha(ctx context.Context) (*Captcha, error) {
q := url.Values{"_t": {strconv.FormatInt(time.Now().UnixMilli(), 10)}}
raw, e := c.do(ctx, http.MethodGet, "/agent-foreign/sys/randomImage", nil, q)
if e != nil {
return nil, e
}
// The live randomImage response carries the captcha key as "key" (seen in
// the HAR evidence for #336); "checkKey" is only the name the login body
// uses. Accept both so a future rename on either side still works.
var p struct {
Image string `json:"image"`
Key string `json:"key"`
CheckKey string `json:"checkKey"`
}
if e = json.Unmarshal(raw, &p); e != nil {
return nil, e
}
key := p.Key
if key == "" {
key = p.CheckKey
}
if key == "" {
return nil, fmt.Errorf("yeeke captcha response missing key")
}
s := p.Image
if i := strings.Index(s, ","); i >= 0 {
s = s[i+1:]
}
img, e := base64.StdEncoding.DecodeString(s)
if e != nil {
return nil, e
}
return &Captcha{Image: img, CheckKey: key, ContentType: "image/jpeg"}, nil
}
type LoginResult struct {
Token, UserID, Username string
ExpiresAt time.Time
}
type OCR interface {
Recognize(context.Context, []byte) (string, error)
}
// LoginWithOCR keeps captcha bytes in memory and never includes credentials or
// recognized text in returned errors. A fresh image is fetched for every try.
func (c *Client) LoginWithOCR(ctx context.Context, ocr OCR, username, password string, maxAttempts int) (*LoginResult, error) {
if ocr == nil {
return nil, fmt.Errorf("yeeke OCR unavailable")
}
if maxAttempts <= 0 || maxAttempts > 5 {
maxAttempts = 3
}
emptyOCR, captchaRejected := 0, 0
for i := 0; i < maxAttempts; i++ {
cap, e := c.FetchCaptcha(ctx)
if e != nil {
return nil, fmt.Errorf("yeeke 获取验证码失败: %w", e)
}
code, e := ocr.Recognize(ctx, cap.Image)
if e != nil {
return nil, fmt.Errorf("yeeke OCR unavailable")
}
code = strings.TrimSpace(code)
if code == "" {
emptyOCR++
continue
}
out, e := c.Login(ctx, username, password, code, cap.CheckKey)
if e == nil {
return out, nil
}
var apiErr *APIError
if errors.As(e, &apiErr) && apiErr.isCaptchaRejected() {
captchaRejected++
continue
}
// Anything else (wrong account/password, locked account, network) will
// not be fixed by another captcha; stop instead of burning attempts.
if errors.As(e, &apiErr) {
return nil, fmt.Errorf("yeeke 登录被拒绝: %s", safeMessage(apiErr.Message))
}
return nil, fmt.Errorf("yeeke 登录请求失败: %w", e)
}
return nil, fmt.Errorf("yeeke login failed after limited captcha attempts (attempts=%d, captcha_rejected=%d, ocr_empty=%d)", maxAttempts, captchaRejected, emptyOCR)
}
func (c *Client) Login(ctx context.Context, username, password, captcha, checkKey string) (*LoginResult, error) {
if username == "" || password == "" || captcha == "" || checkKey == "" {
return nil, fmt.Errorf("login fields required")
}
raw, e := c.do(ctx, http.MethodPost, "/agent-foreign/sys/login", map[string]any{"username": username, "password": password, "captcha": captcha, "checkKey": checkKey, "agentCode": "mmt", "remember_me": true}, nil)
if e != nil {
return nil, e
}
var p struct {
Token string `json:"token"`
UserInfo struct {
ID any `json:"id"`
Username string `json:"username"`
} `json:"userInfo"`
}
if e = json.Unmarshal(raw, &p); e != nil {
return nil, e
}
if p.Token == "" {
return nil, fmt.Errorf("yeeke login response missing token")
}
c.token = p.Token
return &LoginResult{Token: p.Token, UserID: fmt.Sprint(p.UserInfo.ID), Username: p.UserInfo.Username, ExpiresAt: time.Now().UTC().Add(24 * time.Hour)}, nil
}
func (c *Client) CheckSession(ctx context.Context) error {
// /shopee/user/info is what the web client itself calls after login (HAR);
// the earlier /sys/userInfo path does not exist and answered HTTP 500.
q := url.Values{"_t": {strconv.FormatInt(time.Now().UnixMilli(), 10)}}
_, e := c.do(ctx, http.MethodGet, "/agent-foreign/shopee/user/info", nil, q)
return e
}
type ReturnPage struct {
Records []ReturnPackage `json:"records"`
Total int `json:"total"`
Pages int `json:"pages"`
}
type Timestamp struct{ time.Time }
func (t *Timestamp) UnmarshalJSON(b []byte) error {
var s string
if json.Unmarshal(b, &s) != nil || s == "" {
return nil
}
for _, f := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
// yeeke returns naive Beijing wall-clock times ("2026-09-21 14:13:29");
// reading them as UTC would shift every time by eight hours.
if x, e := time.ParseInLocation(f, s, beijing); e == nil {
t.Time = x
return nil
}
}
return nil
}
type ReturnPackage struct {
ID any `json:"id"`
Ordersn string `json:"ordersn"`
TrackingNo string `json:"trackingNo"`
ShopID any `json:"shopId"`
ShopName string `json:"shopName"`
WareCode string `json:"wareCode"`
WareHouse string `json:"wareHouse"`
WareName string `json:"wareName"`
Status any `json:"status"`
ClaimTime *Timestamp `json:"claimTime"`
CreateTime *Timestamp `json:"createTime"`
UpdateTime *Timestamp `json:"updateTime"`
DestroyDeadLine *Timestamp `json:"destroyDeadLine"`
Items []ReturnItem `json:"items"`
}
type ReturnItem struct {
ID any `json:"id"`
ItemID any `json:"itemId"`
VariationID any `json:"variationId"`
ItemName string `json:"itemName"`
VariationName string `json:"variationName"`
Image string `json:"image"`
Quantity FlexInt `json:"variationQuantityPurchased"`
}
// beijing is yeeke's server wall clock (fixed UTC+8, no DST).
var beijing = time.FixedZone("CST", 8*3600)
// FlexInt accepts a JSON number or a numeric string; yeeke sends
// variationQuantityPurchased as a string (e.g. "2", see HAR). Empty, null or
// non-numeric values decode as 0 instead of failing the whole page.
type FlexInt int64
func (f *FlexInt) UnmarshalJSON(b []byte) error {
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
if s == "" || s == "null" {
*f = 0
return nil
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
*f = 0
return nil
}
*f = FlexInt(n)
return nil
}
func (c *Client) List(ctx context.Context, pageNo, pageSize int) (ReturnPage, error) {
// Same shape the web client posts (HAR): sort via column/order, filters as strings.
body := map[string]any{"pageNo": pageNo, "pageSize": pageSize, "claimFlag": "1", "status": "1", "relationFlag": "1", "column": "createTime", "order": "desc"}
raw, e := c.do(ctx, http.MethodPost, "/agent-foreign/packageClaimRec/relation/list", body, nil)
if e != nil {
return ReturnPage{}, e
}
var p ReturnPage
if e = json.Unmarshal(raw, &p); e != nil {
return p, e
}
return p, nil
}

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