Commit Graph
497 Commits
Author SHA1 Message Date
QiuSWandClaude Opus 5 3e82ad6570 feat(yeeke): complete admin surface, scheduling and tests for return sync #336
Builds on cf70021, which added the read-only yeeke client/session/sync core
but left it unreachable and unconfigurable. This commit:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-21 16:05:26 +08:00
QiuSW 4261a542ca fix(#328): filter manual PDD association by owned devices 2026-09-21 11:08:41 +08:00
QiuSW beec630187 fix(#329): record device ownership migration 2026-09-21 10:38:54 +08:00
QiuSW 2491a857f7 docs: record purchaser device ownership (#328) 2026-09-21 10:26:33 +08:00
QiuSW 0661b2205f fix(#328): enforce purchaser device ownership 2026-09-21 10:13:23 +08:00
QiuSW bb1a410e8a feat(#329): add device purchaser ownership 2026-09-21 09:59:00 +08:00
QiuSW 8d9c3d47e0 docs: define successful SYB order writeback query filter (#327) 2026-09-19 15:40:52 +08:00
QiuSW 368f2c2357 feat: filter purchase tasks by successful SYB order writeback (#327) 2026-09-19 15:38:42 +08:00
QiuSW f66952f640 docs: record order information completion and SYB eligibility (#326) 2026-09-19 15:19:27 +08:00
QiuSW 3a2472dd20 feat: complete purchase order information and simplify SYB writeback (#326) 2026-09-19 15:16:39 +08:00
QiuSW 741e4bf680 docs: record bounded order reading and payable result contract (#325) 2026-09-19 11:53:43 +08:00
QiuSW b76fb73e12 fix: wait for unpaid order evidence and report payable total (#325) 2026-09-19 11:52:53 +08:00
QiuSW 10be37498d feat: add Chrome order backfill workflow (#316) 2026-09-18 16:03:27 +08:00
QiuSW 65d1f34865 docs: record production release and migrations (#305 #306) 2026-09-18 10:34:09 +08:00
QiuSW 7e257ca153 docs: sync SYB order writeback contracts (#305) 2026-09-18 10:20:03 +08:00
QiuSW e89de1a085 feat: queue and reconcile SYB purchase order numbers (#305) 2026-09-18 10:13:20 +08:00
QiuSW 07a3817591 test: verify optional order amount in purchase detail (#306) 2026-09-18 09:30:20 +08:00
QiuSW d35d7354d6 docs: sync optional order amount contracts (#306) 2026-09-18 09:26:45 +08:00
QiuSW 8c01329f97 feat: capture optional PDD order amount in manual backfill (#306) 2026-09-18 09:22:12 +08:00
QiuSW 9194fd664f fix(ops): allow local Admin LAN access (#304) 2026-09-17 17:02:35 +08:00
QiuSWandClaude Opus 5 c2426e2671 merge: #242 Agent 采购记录页回填入口与 PDD 订单扫描
合并 feat/242-agent-order-backfill(edd1cb1)。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-16 16:28:07 +08:00