Persist how many return items each sync run flipped to "missing" and how
many came back to "ok" (yeeke_sync_run.missing_marked_count /
recovered_count, migration 1789801000000), return them from the sync-runs
API and add 「标记不可用」「恢复可用」 columns to the sync-runs page. When the
20% safety valve skips marking the count stays 0 and the reason remains in
error_message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
A COMPLETE yeeke sync run (natural page-walk end, not stopped by a
duplicate-fingerprint break or MaxPages exhaustion) that also wrote zero
record failures now flips any yeeke_return_package/yeeke_return_item still
"ok" from before the run to sync_status="missing" with missing_since
stamped, in one transaction. Rows are never deleted. A 20% safety valve
skips marking (and records why in error_message) when the candidate count
would be too large; a reappearing record recovers to "ok" with
missing_since cleared.
returnmatch.availableReturnPool now excludes missing items/packages from
matching, without auto-cancelling any existing active match; List()/Detail()
surface syncStatus/missingSince so the SYB products match column, its
compare dialog, and the yeeke returns list can warn or label rows as
unavailable.
Adds migration 1789800900000_return_missing (AutoMigrate alone does not
reach existing databases).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
Each click of 「匹配退货」 now writes a return_match_batch row: operator,
time, submitted/matched/skipped counts and the per-SYB-product result
(in submission order), plus the error if the batch aborted. Recording
happens after the per-row transactions commit, so a logging failure is
logged server-side instead of failing an already-applied match.
GET /return-matches/batches and /return-matches/batches/:batchId expose
the records. Migration version 1789800800000 creates the table.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
Registering the models in MigratedModels alone does nothing on databases
whose earlier versions are already recorded in sys_migration; the migrate
command's post-check rejected the run with "迁移后仍缺少表". Add the
1789800700000 version so existing databases create return_match and
return_match_log.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
ListReturnPackages (app/goauto/yeeke/list.go) gains a single bounded
LEFT JOIN onto return_match (active match only) and syb_product, plus
a matchStatus query param (unmatched/matched/confirmed — invalid
values are rejected, not ignored). ReturnItemRowDTO now carries
matchId/matchStatus/occupyingSybProductId/occupyingSybOrderCode/
occupyingSybStage(+Label). The occupying SYB product's current stage
label is filled from one extra bounded purchase.NewService(db).
ProcessStages call over the page's distinct occupying SYB ids — not a
per-row lookup — so the yeeke returns page's new 匹配状态 filter and
被占用 SYB 订单商品 column need no N+1 query.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
Review fixes on the #338 backend:
1. New table return_match_log (models/return_match_log.go, registered
in migrations.MigratedModels()): match_id/action/operator/detail/
created_at. sys_opera_log is a generic per-HTTP-call framework log,
not queryable per match and not carrying operator/detail in a
stable shape, so match/confirm/cancel/remark each write their own
log row in the SAME transaction as the state change. Detail now
returns the logs (newest first).
2. Race between matching and purchase creation: BatchMatch's outer
screening pass (stage check outside any lock) is now followed by
matchOneWithLock, which takes the same clause.Locking{Strength:
"UPDATE"} lock on syb_product that purchase.Service.create takes,
re-computes the stage inside that transaction via
purchase.NewService(tx).ProcessStages, and returns
errStageNoLongerEligible (surfaced as reasonCode
stage_ineligible) if the product is no longer in a participating
stage instead of inserting a stale match.
TestMatchOneWithLock_SkipsWhenStageNoLongerParticipatesUnderLock
covers the skip path.
3. matchOneWithLock now fills YeekeSpecText (raw variation_name) and
PreviousProcessStage (the stage code at match time) on the
inserted row. Remark takes an operator (for its log row) and
rejects input over 500 runes with errRemarkTooLong instead of
truncating (varchar(500) is a character-count limit in MySQL, so
the check is utf8.RuneCountInString, not len()).
4. returnmatch.SYBSpecText now joins only non-empty color/size parts,
so a single-dimension spec (e.g. color-only) no longer produces a
stray leading/trailing comma ("黑色" instead of "黑色,"); matchKey
additionally trims leading/trailing commas from both normalized
sides via the new trimCommas() helper. New tests cover color-only
and size-only matching through SelectMatches plus SYBSpecText/
trimCommas directly.
5. Detail (service.go) now returns MatchDetail: SYBDetailView (order
code, shopee item id, shop, title, target color/size, quantity,
image, current computed stage+label), YeekeDetailView (return
order sn, item id, variation id, shop, item name, variation name,
quantity, image, destroy deadline), both sides' normalized spec
text, the match row itself, and the operation logs — everything
the prototype's compare screen (screen 3) needs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
rejectIfActiveReturnMatch (app/goauto/purchase/return_match_block.go)
is called from Service.create right after the SYB row is locked: a
SYB product with an active (matched or confirmed) return_match row is
rejected with CodeReturnMatched. Since BatchCreate already calls
Service.Create per row and treats a create error as a per-row skip,
this single insertion point covers both single and batch creation —
batch creation skips only the blocked rows and reports
CodeReturnMatched, it does not fail the whole batch. A query error
here is treated as internal() and never silently allows creation.
Process stage computation (process_stage.go) gains two new stages,
ProcessStageReturnPending (退货待确认) and ProcessStageReturnUsed
(已用退货), sourced from a new bounded dataset.activeReturnMatchBySYB
query in loadBatchPreviewDataset (batch.go); with zero return_match
rows this query returns nothing and every other stage branch is
unchanged (updated the batch preview bounded-query-count assertion in
batch_test.go from 8 to 9 to reflect the new, still-bounded query).
Regression coverage (return_match_block_test.go): single create
rejected for matched and for confirmed match, cancelled match does
not block, batch create creates the clean row and skips only the
matched row with CodeReturnMatched, and a zero-return-match baseline
still succeeds unchanged (acceptance item 11).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
Service (app/goauto/returnmatch/service.go) wires the pure matching
functions to the database:
- BatchMatch: manual-only trigger (no scheduler, not called from
yeeke sync or SYB import) for ticked SYB product rows. Filters to
the participating process stages (待人工处理 excluded per the
confirmed rule), loads the available return pool (no active match,
non-nil future destroy deadline) via purchase.ProcessStages +a
join query, runs SelectMatches, and inserts one return_match row per
outcome. A unique-constraint violation on insert (lost race) is
reported per-row as a conflict skip, never fails the whole batch.
- Confirm/Cancel: state transitions with row locking; Cancel clears
both Active* columns so the same pair can be rematched later.
- Remark, List (by SYB product id / return item id / status) and
Detail for both admin pages' filter/column needs.
Handler + router (app/goauto/returnmatch/{handler,router}.go) expose
POST /api/admin/v1/return-matches/batch-match, GET .../return-matches,
GET .../return-matches/:id, POST .../:id/{confirm,cancel,remark}.
Write actions require admin/purchaser (same requireCanPurchase gate
already used by yeeke.Handler.TriggerSync); list/detail are read-only
for any authenticated user. Registered in
app/admin/router/init_router.go.
Tests cover end-to-end batch match, expired-deadline exclusion,
multi-colour cross-pairing through the DB path, 待人工处理 exclusion,
confirm-then-cancel restoring availability and rematch-ability, and a
concurrent-insert test asserting exactly one winner against the
unique constraint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
Package returnmatch holds only DB-free, unit-tested logic so the
matching rules can be verified directly:
- Normalize() strips 【】()()[] brackets and their content, strips
whitespace, converts fullwidth ASCII/space to halfwidth and
lowercases (issue #338 normalization rule), backtested against the
local real-pair samples quoted in the issue.
- SelectMatches() implements rules 2-6: caller-ordered (SYB created_at
DESC) processing, deadline-must-be-after-now filtering, earliest-
deadline-first selection among same-key candidates, and same-run
occupied-return exclusion; quantity never participates.
Covers the same-order multi-colour cross-pairing case explicitly
(TestSelectMatches_MultiColourSameOrderCrossPairing /
TestSYBSpecText) plus expired-deadline, earliest-first, occupied,
quantity-ignored and different-item-id cases.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
New model models.ReturnMatch backing the SYB↔yeeke return matching
feature: matched/confirmed/cancelled status, nullable
ActiveSYBProductID/ActiveYeekeReturnItemID columns (same pattern as
YeekeSyncRun.ActiveSlot) each carrying a unique index so only one
active match can occupy either side at a time; cancel clears both to
free the slot for a rematch. Registered in migrations.MigratedModels().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
商品标题/规格 -> 商品标题 (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
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
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
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
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
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
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
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
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
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
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
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
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