Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-22 10:08:25 +08:00
89 changed files with 10482 additions and 133 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"
@@ -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
@@ -523,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)
@@ -540,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(
@@ -598,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
}
@@ -614,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 =
@@ -716,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
@@ -793,9 +1002,27 @@ 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("订单编号", "订单号", "下单时间", "创建时间")
@@ -100,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
@@ -394,7 +394,7 @@ class PurchaseRehearsalExecutor(
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
)
}
@@ -415,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(
@@ -446,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)
}
@@ -478,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};" +
@@ -574,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) {
@@ -589,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,
@@ -1175,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)
@@ -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
@@ -197,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)
@@ -872,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
@@ -911,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)
@@ -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,
)
}
}
+9 -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: 8e2cfa74bc7cf228278221e0f7ef488ac1b596d3
synchronized_at: 2026-09-21T08:14:31Z
wiki_revision: 504e1ffaa2cf1989f55048ac7067d8af50fe3bf3
synchronized_at: 2026-09-22T02:56:22Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -513,3 +513,10 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 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`(脱敏构造样本)。
+16 -2
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: 6cb34c99ccdcf4b64b01cb9a25ff1549d8aeeec9
synchronized_at: 2026-09-21T08:14:37Z
wiki_revision: 7bb3997cfbe96343f63d4f6ef80201146c27c142
synchronized_at: 2026-09-22T02:56:27Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -666,3 +666,17 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
实现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 明确失败,不自动登录、不切换支付方式、不填写地址。
+13 -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: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:14:47Z
wiki_revision: cc642ebfa65f429100779e40560a59174ab877a0
synchronized_at: 2026-09-22T02:56:36Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -142,3 +142,14 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 管理员为密钥显式勾选“采购管理 → 回填(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,心跳恢复后设备才显示在线。
+4
View File
@@ -14,6 +14,7 @@ import (
goautopurchase "go-admin/app/goauto/purchase"
goautopurchaserule "go-admin/app/goauto/purchaserule"
goautoreplacement "go-admin/app/goauto/replacement"
goautoreturnmatch "go-admin/app/goauto/returnmatch"
goautorule "go-admin/app/goauto/rule"
goautoshopeeproduct "go-admin/app/goauto/shopeeproduct"
goautosybimport "go-admin/app/goauto/sybimport"
@@ -21,6 +22,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 +71,6 @@ func InitRouter() {
goautosybinnercode.InitRouter(r, authMiddleware)
goautosybshop.InitRouter(r, authMiddleware)
goautosybproductfilter.InitRouter(r, authMiddleware)
goautoyeeke.InitRouter(r, authMiddleware)
goautoreturnmatch.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},
+1 -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()
+14 -9
View File
@@ -47,19 +47,24 @@ func (handler Handler) List(context *gin.Context) {
}
func currentUserID(c *gin.Context) uint64 {
value, ok := c.Get("userId")
if !ok {
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)
}
return 0
}
@@ -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))
}
}
+7
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{},
@@ -75,6 +79,9 @@ func MigratedModels() []any {
&models.PDDProductReplacement{},
&models.PDDProductReplacementItem{},
&models.PDDProductReplacementWorkerLease{},
&models.ReturnMatch{},
&models.ReturnMatchLog{},
&models.ReturnMatchBatch{},
}
}
+71
View File
@@ -0,0 +1,71 @@
package models
import "time"
// #338: ReturnMatch links one syb_product row to one yeeke_return_item row so
// the return can be reused instead of buying the item again on PDD. A match
// is "active" (still blocking purchase creation and occupying the return)
// while Status is matched or confirmed; cancelling clears both Active*
// columns so the unique index frees up both sides for a future rematch
// (issue #338, business rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许).
//
// ActiveSYBProductID/ActiveYeekeReturnItemID follow the same nullable-unique-
// column pattern already used by YeekeSyncRun.ActiveSlot in this codebase:
// MySQL unique indexes allow any number of NULL rows, so only the currently
// active match for a given side occupies the slot, and a concurrent second
// insert for the same side fails the unique constraint instead of double-
// booking it.
const (
ReturnMatchStatusMatched = "matched"
ReturnMatchStatusConfirmed = "confirmed"
ReturnMatchStatusCancelled = "cancelled"
)
type ReturnMatch struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
SYBProductID uint64 `json:"sybProductId" gorm:"not null;index"`
YeekeReturnItemID uint64 `json:"yeekeReturnItemId" gorm:"not null;index"`
// ActiveSYBProductID/ActiveYeekeReturnItemID mirror the ID columns above
// while Status is matched/confirmed, and are cleared to NULL on cancel.
ActiveSYBProductID *uint64 `json:"-" gorm:"uniqueIndex:ux_return_match_active_syb"`
ActiveYeekeReturnItemID *uint64 `json:"-" gorm:"uniqueIndex:ux_return_match_active_return"`
Status string `json:"status" gorm:"size:16;not null;index;check:ck_return_match_status,status IN ('matched','confirmed','cancelled')"`
// Snapshots of the normalized spec text on both sides at match time, kept
// for audit and for the compare screen even if the source rows change
// later.
SYBSpecText string `json:"sybSpecText" gorm:"size:255;not null;default:''"`
YeekeSpecText string `json:"yeekeSpecText" gorm:"size:255;not null;default:''"`
NormalizedKey string `json:"normalizedKey" gorm:"size:255;not null;default:'';index"`
// PreviousProcessStage records what the SYB product's computed process
// stage was immediately before this match, so cancel can report/restore
// context; the actual restore is a pure recomputation (no stage is
// stored on syb_product), this is for audit/display only.
PreviousProcessStage string `json:"previousProcessStage" gorm:"size:32;not null;default:''"`
DestroyDeadlineSnapshot *time.Time `json:"destroyDeadlineSnapshot,omitempty"`
Remark string `json:"remark" gorm:"size:500;not null;default:''"`
MatchedBy string `json:"matchedBy" gorm:"size:64;not null;default:''"`
MatchedAt time.Time `json:"matchedAt"`
ConfirmedBy string `json:"confirmedBy,omitempty" gorm:"size:64;not null;default:''"`
ConfirmedAt *time.Time `json:"confirmedAt,omitempty"`
CancelledBy string `json:"cancelledBy,omitempty" gorm:"size:64;not null;default:''"`
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ReturnMatch) TableName() string { return "return_match" }
// IsActive reports whether the match currently blocks purchase creation and
// occupies its return item.
func (m ReturnMatch) IsActive() bool {
return m.Status == ReturnMatchStatusMatched || m.Status == ReturnMatchStatusConfirmed
}
@@ -0,0 +1,25 @@
package models
import "time"
// ReturnMatchBatch records one click of 「匹配退货」 (#338): who submitted it,
// which SYB products were submitted and the per-product outcome, so a later
// question like "why did only N of my selection match" can be answered from
// data instead of reconstructing the page selection. ItemsJSON holds the
// []BatchMatchItem result array (sybProductId/matched/reasonCode/reason/
// matchId) in submission order.
type ReturnMatchBatch struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Operator string `json:"operator" gorm:"size:64;not null;default:'';index"`
SubmittedCount int `json:"submittedCount" gorm:"not null;default:0"`
MatchedCount int `json:"matchedCount" gorm:"not null;default:0"`
SkippedCount int `json:"skippedCount" gorm:"not null;default:0"`
ItemsJSON string `json:"-" gorm:"type:longtext;not null"`
// Error is non-empty when the batch aborted part-way; ItemsJSON then
// holds only the rows processed before the failure.
Error string `json:"error" gorm:"size:500;not null;default:''"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
}
func (ReturnMatchBatch) TableName() string { return "return_match_batch" }
@@ -0,0 +1,28 @@
package models
import "time"
// ReturnMatchLog is #338's dedicated operation log for the return-matching
// feature. The framework's generic sys_opera_log records raw HTTP
// request/response per call and is not queryable per match id nor does it
// carry a stable action/operator/detail shape, so this table is written
// explicitly, in the same transaction as the action it records (match /
// confirm / cancel / remark).
const (
ReturnMatchLogActionMatched = "matched"
ReturnMatchLogActionConfirmed = "confirmed"
ReturnMatchLogActionCancelled = "cancelled"
ReturnMatchLogActionRemark = "remark"
)
type ReturnMatchLog struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
MatchID uint64 `json:"matchId" gorm:"not null;index"`
Action string `json:"action" gorm:"size:16;not null;index;check:ck_return_match_log_action,action IN ('matched','confirmed','cancelled','remark')"`
Operator string `json:"operator" gorm:"size:64;not null;default:''"`
Detail string `json:"detail" gorm:"size:1000;not null;default:''"`
CreatedAt time.Time `json:"createdAt"`
}
func (ReturnMatchLog) TableName() string { return "return_match_log" }
+100
View File
@@ -0,0 +1,100 @@
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 is "ok" while the package still appears in a COMPLETE yeeke
// sync; #338 sets it to "missing" (with MissingSince stamped) once a
// completed sync no longer sees it, so return matching stops using it.
// It is never deleted or marked "已销毁" — only flagged unavailable.
SyncStatus string `gorm:"size:32;not null;default:'ok';index"`
MissingSince *time.Time
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/MissingSince mirror YeekeReturnPackage's fields (#338): once
// a COMPLETE yeeke sync no longer sees this item it is flagged "missing"
// so returnmatch.availableReturnPool stops offering it, without ever
// deleting the row.
SyncStatus string `gorm:"size:32;not null;default:'ok';index"`
MissingSince *time.Time
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"`
// #338: items flipped to "missing" / back to "ok" by this run.
MissingMarkedCount int `gorm:"not null;default:0"`
RecoveredCount 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" }
+18
View File
@@ -142,6 +142,11 @@ type batchPreviewDataset struct {
// 见 #289 与 sybimport.RawSpecHalves。
collapsedColorByShopee map[uint64]map[string]bool
collapsedSizeByShopee map[uint64]map[string]bool
// activeReturnMatchBySYB is #338's return-match stage input: a non-zero
// entry means the SYB product currently has an active (matched or
// confirmed) return_match row, which forces the 退货待确认/已用退货
// stages regardless of what the rest of the pipeline would compute.
activeReturnMatchBySYB map[uint64]models.ReturnMatch
}
// loadBatchPreviewDataset keeps the read-only preview on bounded bulk queries.
@@ -159,6 +164,7 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba
skuCombinationsByPDD: make(map[uint64][]pddSKUCombination),
collapsedColorByShopee: make(map[uint64]map[string]bool),
collapsedSizeByShopee: make(map[uint64]map[string]bool),
activeReturnMatchBySYB: make(map[uint64]models.ReturnMatch),
}
var sybProducts []models.SYBProduct
if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil {
@@ -248,6 +254,18 @@ func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (ba
dataset.latestTaskBySYB[*task.SYBProductID] = task
}
}
// #338: load active return matches for these SYB products. This query
// only ever ADDS a stage override on top of the pre-#338 computation; an
// empty result set (the common case for products never matched) leaves
// every other branch of processStageFromDataset byte-for-byte unchanged
// (issue #338 acceptance item 11 regression).
var activeMatches []models.ReturnMatch
if err := s.DB.WithContext(ctx).Where("syb_product_id IN ? AND active_syb_product_id IS NOT NULL", ids).Find(&activeMatches).Error; err != nil {
return dataset, err
}
for _, m := range activeMatches {
dataset.activeReturnMatchBySYB[m.SYBProductID] = m
}
return dataset, nil
}
+5 -4
View File
@@ -311,10 +311,11 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
t.Fatalf("read-only preview called AI matcher %d times", matcher.calls)
}
// #289 新增一次有界批量查询(按蕃皮商品拉全部明细用于塔缩检测),
// 因此从 7 变为 8。这条断言守的是“不得出现 N+1”,不是具体数字;
// 只有新增的查询确实有界时才允许上调。
if queries != 8 {
t.Fatalf("batch preview used %d queries, want 8 bounded queries including collection eligibility, current purchase rule and collapsed spec keys", queries)
// 因此从 7 变为 8;#338 再新增一次有界批量查询(按 SYB 商品ID拉活跃
// 退货匹配),因此从 8 变为 9。这条断言守的是“不得出现 N+1”,不是具体
// 数字;只有新增的查询确实有界时才允许上调。
if queries != 9 {
t.Fatalf("batch preview used %d queries, want 9 bounded queries including collection eligibility, current purchase rule, collapsed spec keys and #338 active return matches", queries)
}
if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 {
t.Fatalf("unresolved rows remain eligible for live probing but are not purchase-ready: %+v", response)
+15 -2
View File
@@ -647,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) &&
+20 -1
View File
@@ -21,7 +21,13 @@ const (
ProcessStageTaskCreated = "task_created"
ProcessStagePurchaseSucceeded = "purchase_succeeded"
ProcessStageOrderReview = "order_review"
processActionOpenPDDLink = "open_pdd_link"
// ProcessStageReturnPending/ProcessStageReturnUsed are the two new stages
// added by issue #338: a SYB product with an active return_match blocks
// purchase creation regardless of what stage it would otherwise compute
// to (see rejectIfActiveReturnMatch and processStageFromDataset below).
ProcessStageReturnPending = "return_pending"
ProcessStageReturnUsed = "return_used"
processActionOpenPDDLink = "open_pdd_link"
)
var processStageLabels = map[string]string{
@@ -35,6 +41,8 @@ var processStageLabels = map[string]string{
ProcessStageTaskCreated: "已创建任务",
ProcessStagePurchaseSucceeded: "采购成功",
ProcessStageOrderReview: "待人工核对",
ProcessStageReturnPending: "退货待确认",
ProcessStageReturnUsed: "已用退货",
}
type ProcessStageResult struct {
@@ -132,6 +140,17 @@ func processStageFromDataset(id uint64, dataset batchPreviewDataset, preview Bat
}
}
// #338: an active return match overrides the normal computation below —
// it only ever fires for a SYB product that has one, so with zero
// matches (the pre-#338 default) this branch is a no-op and every stage
// below is unchanged (acceptance item 11 regression).
if match, ok := dataset.activeReturnMatchBySYB[id]; ok {
if match.Status == models.ReturnMatchStatusConfirmed {
return stage(ProcessStageReturnUsed, "已用退货冲抵,无需采购", "open_return_match")
}
return stage(ProcessStageReturnPending, "已匹配退货待人工确认,暂不能创建采购任务", "open_return_match")
}
syb, ok := dataset.sybByID[id]
if !ok {
return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh")
@@ -0,0 +1,30 @@
package purchase
import (
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
// rejectIfActiveReturnMatch implements issue #338's purchase-creation block:
// a SYB product with an active (matched or confirmed) return_match row must
// not get a NEW purchase task created for it (single or batch — BatchCreate
// calls s.Create per row, so this single check point covers both paths and
// batch creation simply skips the row via the normal createErr handling,
// issue #338 acceptance item 5). Resuming/reparsing an existing task is a
// different code path and is not affected.
//
// On any query error this returns the error (never nil) so a DB failure can
// never silently allow a purchase for an already-matched product (issue
// #338 acceptance item 11).
func rejectIfActiveReturnMatch(tx *gorm.DB, sybProductID uint64) error {
var match models.ReturnMatch
err := tx.Where("active_syb_product_id = ?", sybProductID).First(&match).Error
if err == nil {
return fail(CodeReturnMatched, "该商品已匹配退货,需先在退货匹配中取消才能创建采购任务")
}
if err == gorm.ErrRecordNotFound {
return nil
}
return internal(err)
}
@@ -0,0 +1,145 @@
package purchase
import (
"context"
"testing"
"time"
"go-admin/app/goauto/models"
"github.com/google/uuid"
)
// #338 acceptance item 5/6/11: a SYB product with an active return match
// must be rejected for NEW single-task creation, batch creation must skip
// only that row (not fail the whole batch), and with zero matches (the
// default) behavior must be byte-for-byte unchanged.
func TestCreate_RejectsWhenActiveReturnMatch(t *testing.T) {
db := testDB(t)
s := testService(db)
f := seed(t, db, liveCaps(), true)
match := models.ReturnMatch{
SYBProductID: f.syb.ID, YeekeReturnItemID: 1,
ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusMatched,
MatchedAt: time.Now(),
}
if err := db.Create(&match).Error; err != nil {
t.Fatal(err)
}
_, err := createLive(t, s, f)
if err == nil {
t.Fatalf("expected rejection, got success")
}
if se, ok := asServiceError(err); !ok || se.Code != CodeReturnMatched {
t.Fatalf("expected CodeReturnMatched, got %v", err)
}
}
func TestCreate_ConfirmedReturnMatchAlsoRejects(t *testing.T) {
db := testDB(t)
s := testService(db)
f := seed(t, db, liveCaps(), true)
match := models.ReturnMatch{
SYBProductID: f.syb.ID, YeekeReturnItemID: 1,
ActiveSYBProductID: &f.syb.ID, Status: models.ReturnMatchStatusConfirmed,
MatchedAt: time.Now(),
}
if err := db.Create(&match).Error; err != nil {
t.Fatal(err)
}
_, err := createLive(t, s, f)
if err == nil {
t.Fatalf("expected rejection for confirmed match too")
}
if se, ok := asServiceError(err); !ok || se.Code != CodeReturnMatched {
t.Fatalf("expected CodeReturnMatched, got %v", err)
}
}
func TestCreate_CancelledReturnMatchDoesNotBlock(t *testing.T) {
db := testDB(t)
s := testService(db)
f := seed(t, db, liveCaps(), true)
match := models.ReturnMatch{
SYBProductID: f.syb.ID, YeekeReturnItemID: 1,
ActiveSYBProductID: nil, Status: models.ReturnMatchStatusCancelled,
MatchedAt: time.Now(),
}
if err := db.Create(&match).Error; err != nil {
t.Fatal(err)
}
if _, err := createLive(t, s, f); err != nil {
t.Fatalf("cancelled match must not block creation: %v", err)
}
}
// TestBatchCreate_SkipsOnlyBlockedRowNotWholeBatch is the acceptance-item-5
// "batch creation must skip just those rows" regression: one blocked SYB
// product and one clean one in the same batch call.
func TestBatchCreate_SkipsOnlyBlockedRowNotWholeBatch(t *testing.T) {
db := testDB(t)
s := testService(db)
f1 := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f1.pdd.ID)
// second, independent eligible SYB product sharing the same shopee/pdd
// chain shape but its own row.
syb2 := models.SYBProduct{OrderCode: "SYB-2", DetailID: 2, StockID: 3, ShopeeItemID: f1.shopee.ShopeeItemID, ShopeeProductID: &f1.shopee.ID, ProductTitle: f1.shopee.Title, TargetColor: "黑色", TargetSize: "XL", Quantity: 1, UnitPriceCent: 2000, ImageURL: "", ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`}
if err := db.Create(&syb2).Error; err != nil {
t.Fatal(err)
}
match := models.ReturnMatch{
SYBProductID: f1.syb.ID, YeekeReturnItemID: 1,
ActiveSYBProductID: &f1.syb.ID, Status: models.ReturnMatchStatusMatched,
MatchedAt: time.Now(),
}
if err := db.Create(&match).Error; err != nil {
t.Fatal(err)
}
resp, err := s.BatchCreate(context.Background(), BatchCreateRequest{
RequestID: uuid.NewString(), SYBProductIDs: []uint64{f1.syb.ID, syb2.ID}, DeviceID: &f1.device.ID,
})
if err != nil {
t.Fatalf("batch call itself must not fail: %v", err)
}
if resp.CreatedCount != 1 || resp.FailedCount != 1 {
t.Fatalf("expected 1 created + 1 failed, got created=%d failed=%d items=%+v", resp.CreatedCount, resp.FailedCount, resp.Items)
}
for _, item := range resp.Items {
if item.SYBProductID == f1.syb.ID {
if item.Created {
t.Fatalf("matched row must not be created: %+v", item)
}
if item.ReasonCode != CodeReturnMatched {
t.Fatalf("expected CodeReturnMatched reason, got %+v", item)
}
}
if item.SYBProductID == syb2.ID && !item.Created {
t.Fatalf("clean row must still be created: %+v", item)
}
}
}
// TestCreate_NoReturnMatchTableRowsUnaffected is the acceptance-item-11
// regression: with zero return_match rows anywhere, creation behaves exactly
// as it did before #338.
func TestCreate_NoReturnMatchTableRowsUnaffected(t *testing.T) {
db := testDB(t)
s := testService(db)
f := seed(t, db, liveCaps(), true)
if _, err := createLive(t, s, f); err != nil {
t.Fatalf("zero-match baseline must succeed unchanged: %v", err)
}
}
func asServiceError(err error) (*ServiceError, bool) {
se, ok := err.(*ServiceError)
return se, ok
}
+3
View File
@@ -178,6 +178,9 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, *req.SYBProductID).Error; err != nil {
return notFound(err, "顺云宝商品不存在")
}
if err := rejectIfActiveReturnMatch(tx, syb.ID); err != nil {
return err
}
if syb.ShopeeProductID == nil {
return fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品")
}
@@ -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)
+6 -1
View File
@@ -36,7 +36,12 @@ const (
CodeOrderTimeMissing = "PURCHASE_ORDER_TIME_MISSING"
CodeOrderTimeInvalid = "PURCHASE_ORDER_TIME_INVALID"
CodeOrderUnpaidMissing = "PURCHASE_ORDER_UNPAID_EVIDENCE_MISSING"
CodeInternal = "INTERNAL_ERROR"
// CodeReturnMatched is returned when a SYB product has an active (matched
// or confirmed) return match: issue #338 blocks creating NEW purchase
// tasks for such a product until the match is cancelled. Resuming or
// reparsing an existing task is not affected.
CodeReturnMatched = "PURCHASE_RETURN_MATCHED"
CodeInternal = "INTERNAL_ERROR"
)
type ServiceError struct {
@@ -0,0 +1,80 @@
package returnmatch
import (
"context"
"testing"
"time"
"go-admin/app/goauto/models"
)
// #338: every 「匹配退货」 click is recorded with the submitted SYB products in
// order and each one's outcome, so "why did only some of my selection match"
// can be answered from data.
func TestBatchMatch_RecordsSubmittedProductsAndOutcomes(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
hit := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 2, 0, 0, 0, 0, time.UTC))
miss := seedSYB(t, db, "SYB-2", 2, "黑色", "XL", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L", &deadline)
missingID := uint64(999999)
submitted := []uint64{miss.ID, hit.ID, missingID}
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: submitted, Operator: "caigou1"})
if err != nil {
t.Fatal(err)
}
batches, err := s.ListBatches(context.Background(), 10, true)
if err != nil {
t.Fatal(err)
}
if len(batches) != 1 {
t.Fatalf("want exactly one batch record, got %d", len(batches))
}
b := batches[0]
if b.Operator != "caigou1" || b.SubmittedCount != 3 || b.MatchedCount != 1 || b.SkippedCount != 2 || b.Error != "" {
t.Fatalf("unexpected batch header: %+v", b.ReturnMatchBatch)
}
if len(b.Items) != len(submitted) {
t.Fatalf("want %d items, got %+v", len(submitted), b.Items)
}
for i, id := range submitted {
if b.Items[i].SYBProductID != id {
t.Fatalf("item %d: want submitted order id %d, got %d", i, id, b.Items[i].SYBProductID)
}
}
if b.Items[0].ReasonCode != ReasonNoCandidate || !b.Items[1].Matched || b.Items[1].MatchID != resp.Items[1].MatchID || b.Items[2].ReasonCode != ReasonStageIneligible {
t.Fatalf("unexpected recorded outcomes: %+v", b.Items)
}
got, err := s.GetBatch(context.Background(), b.ID)
if err != nil {
t.Fatal(err)
}
if len(got.Items) != 3 {
t.Fatalf("GetBatch items: %+v", got.Items)
}
// The list without items stays small.
light, err := s.ListBatches(context.Background(), 10, false)
if err != nil || len(light) != 1 || light[0].Items != nil {
t.Fatalf("list without items: %+v err=%v", light, err)
}
}
// An empty submission is rejected before matching and leaves no record.
func TestBatchMatch_EmptySubmissionNotRecorded(t *testing.T) {
db := testDB(t)
if _, err := NewService(db).BatchMatch(context.Background(), BatchMatchRequest{Operator: "x"}); err != nil {
t.Fatal(err)
}
var n int64
db.Model(&models.ReturnMatchBatch{}).Count(&n)
if n != 0 {
t.Fatalf("empty submission recorded %d batches", n)
}
}
+112
View File
@@ -0,0 +1,112 @@
package returnmatch
import (
"sort"
"time"
)
// SYBCandidate is the pure, DB-free view of one selected SYB order product
// eligible to participate in this batch's matching (stage filter and "no
// active match" filter are applied by the caller before building this list;
// see issue #338 rule 1).
type SYBCandidate struct {
SYBProductID uint64
ShopeeItemID string
TargetColor string
TargetSize string
CreatedAt time.Time
}
// ReturnCandidate is the pure, DB-free view of one available yeeke return
// item (no active match, non-nil destroy deadline; the "deadline later than
// now" filter is applied by the caller — see BuildReturnPool below, or by
// the caller directly when it already filters in SQL).
type ReturnCandidate struct {
ReturnItemID uint64
ItemID string
VariationName string
DestroyDeadline time.Time
}
// SkipReason enumerates why a selected SYB candidate was not matched in this
// batch, for the batch-result dialog's per-row reasons (issue #338 prototype
// screen 2).
type SkipReason string
const (
SkipReasonNoCandidate SkipReason = "no_candidate"
)
// MatchOutcome is one row of the pure matching result: either matched to a
// return item, or skipped with a reason.
type MatchOutcome struct {
SYBProductID uint64
Matched bool
ReturnItemID uint64
SkipReason SkipReason
NormalizedKey string
DestroyDeadline time.Time
}
// SelectMatches implements issue #338 rules 2-6 purely in memory:
// - products are processed in the order given by the caller, which MUST be
// SYB created_at DESC (rule 4); this function does not itself sort by
// CreatedAt so a caller can supply a stable pre-sorted/tie-broken order.
// - a return is eligible only while its deadline is strictly after `now`
// (rule 2), matched on shopeeItemId + normalized spec text (rule 3);
// - once a return is used within this run it cannot be reused by a later
// product in the same run, on top of whatever was already occupied
// before the run started (rule 4, "已被占用的退货商品不再参与本轮后续
// 及以后的匹配");
// - when several candidates match the same product, the one with the
// earliest destroy deadline is chosen (rule 5); quantity is ignored
// entirely (rule 6).
func SelectMatches(products []SYBCandidate, returns []ReturnCandidate, now time.Time) []MatchOutcome {
// Group available (deadline > now) returns by matchKey = itemId + "|" +
// normalized spec text, sorted by deadline ascending so the first unused
// entry in each bucket is always the earliest deadline.
buckets := make(map[string][]ReturnCandidate)
for _, r := range returns {
if !r.DestroyDeadline.After(now) {
continue
}
key := matchKey(r.ItemID, trimCommas(Normalize(r.VariationName)))
buckets[key] = append(buckets[key], r)
}
for key := range buckets {
bucket := buckets[key]
sort.Slice(bucket, func(i, j int) bool {
return bucket[i].DestroyDeadline.Before(bucket[j].DestroyDeadline)
})
buckets[key] = bucket
}
used := make(map[uint64]bool)
outcomes := make([]MatchOutcome, 0, len(products))
for _, p := range products {
key := matchKey(p.ShopeeItemID, trimCommas(Normalize(SYBSpecText(p.TargetColor, p.TargetSize))))
var picked *ReturnCandidate
for i := range buckets[key] {
cand := buckets[key][i]
if used[cand.ReturnItemID] {
continue
}
picked = &buckets[key][i]
break
}
if picked == nil {
outcomes = append(outcomes, MatchOutcome{SYBProductID: p.SYBProductID, Matched: false, SkipReason: SkipReasonNoCandidate, NormalizedKey: key})
continue
}
used[picked.ReturnItemID] = true
outcomes = append(outcomes, MatchOutcome{
SYBProductID: p.SYBProductID, Matched: true, ReturnItemID: picked.ReturnItemID,
NormalizedKey: key, DestroyDeadline: picked.DestroyDeadline,
})
}
return outcomes
}
func matchKey(itemID, normalizedSpec string) string {
return itemID + "|" + normalizedSpec
}
@@ -0,0 +1,149 @@
package returnmatch
import (
"testing"
"time"
)
func t1(offsetDays int) time.Time {
base := time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC)
return base.AddDate(0, 0, offsetDays)
}
func TestSelectMatches_BasicMatch(t *testing.T) {
now := t1(0)
products := []SYBCandidate{
{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "2XL", CreatedAt: t1(-1)},
}
returns := []ReturnCandidate{
{ReturnItemID: 900, ItemID: "100", VariationName: "白色,2XL【建議65-75公斤】", DestroyDeadline: t1(10)},
}
out := SelectMatches(products, returns, now)
if len(out) != 1 || !out[0].Matched || out[0].ReturnItemID != 900 {
t.Fatalf("unexpected outcome: %+v", out)
}
}
// TestSelectMatches_MultiColourSameOrderCrossPairing reproduces issue #338's
// documented real-world case: the same order buys 2+ colour variants of the
// same Shopee item, and each SYB row must pair with the return item of its
// OWN colour, never grab whichever candidate is available first.
func TestSelectMatches_MultiColourSameOrderCrossPairing(t *testing.T) {
now := t1(0)
products := []SYBCandidate{
{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "2XL", CreatedAt: t1(-1)},
{SYBProductID: 2, ShopeeItemID: "100", TargetColor: "紫色", TargetSize: "M", CreatedAt: t1(-2)},
}
returns := []ReturnCandidate{
{ReturnItemID: 901, ItemID: "100", VariationName: "紫色,M【建議43-53公斤】", DestroyDeadline: t1(5)},
{ReturnItemID: 900, ItemID: "100", VariationName: "白色,2XL【建議65-75公斤】", DestroyDeadline: t1(10)},
}
out := SelectMatches(products, returns, now)
got := map[uint64]uint64{}
for _, o := range out {
if !o.Matched {
t.Fatalf("expected all matched, got skip: %+v", o)
}
got[o.SYBProductID] = o.ReturnItemID
}
if got[1] != 900 {
t.Fatalf("white/2XL SYB product should match return 900 (white/2XL), got %d", got[1])
}
if got[2] != 901 {
t.Fatalf("purple/M SYB product should match return 901 (purple/M), got %d", got[2])
}
}
func TestSelectMatches_ExpiredDeadlineExcluded(t *testing.T) {
now := t1(0)
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}}
returns := []ReturnCandidate{
{ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(0)}, // not after now -> excluded
{ReturnItemID: 901, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(-1)}, // already past -> excluded
}
out := SelectMatches(products, returns, now)
if out[0].Matched {
t.Fatalf("expected no match for expired-only candidates, got %+v", out[0])
}
if out[0].SkipReason != SkipReasonNoCandidate {
t.Fatalf("unexpected skip reason: %+v", out[0])
}
}
func TestSelectMatches_EarliestDeadlineFirst(t *testing.T) {
now := t1(0)
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}}
returns := []ReturnCandidate{
{ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(30)},
{ReturnItemID: 901, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(3)}, // soonest to expire, must win
{ReturnItemID: 902, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(10)},
}
out := SelectMatches(products, returns, now)
if !out[0].Matched || out[0].ReturnItemID != 901 {
t.Fatalf("expected earliest-deadline candidate 901, got %+v", out[0])
}
}
func TestSelectMatches_OccupiedWithinRunNotReused(t *testing.T) {
now := t1(0)
// Two products with the identical spec, only one return item available:
// processed in the given (already created_at DESC) order, the second
// product must NOT be able to reuse the same return item.
products := []SYBCandidate{
{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}, // newer, processed first (DESC)
{SYBProductID: 2, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-5)},
}
returns := []ReturnCandidate{
{ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(10)},
}
out := SelectMatches(products, returns, now)
if !out[0].Matched || out[0].ReturnItemID != 900 {
t.Fatalf("first (newer) product should win the only candidate: %+v", out[0])
}
if out[1].Matched {
t.Fatalf("second product must not reuse the already-occupied return: %+v", out[1])
}
}
func TestSelectMatches_QuantityIgnored(t *testing.T) {
// SYBCandidate/ReturnCandidate deliberately carry no quantity field at
// all: this test documents that omission is intentional (issue #338
// rule 6, quantity never participates in matching).
now := t1(0)
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}}
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "白色,L", DestroyDeadline: t1(10)}}
out := SelectMatches(products, returns, now)
if !out[0].Matched {
t.Fatalf("expected match regardless of any quantity mismatch: %+v", out[0])
}
}
func TestSelectMatches_ColorOnlySYBSpecMatchesPlainYeekeText(t *testing.T) {
now := t1(0)
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "黑色", TargetSize: "", CreatedAt: t1(-1)}}
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "黑色", DestroyDeadline: t1(10)}}
out := SelectMatches(products, returns, now)
if !out[0].Matched || out[0].ReturnItemID != 900 {
t.Fatalf("color-only spec should still match: %+v", out[0])
}
}
func TestSelectMatches_SizeOnlySYBSpecMatchesPlainYeekeText(t *testing.T) {
now := t1(0)
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "", TargetSize: "L", CreatedAt: t1(-1)}}
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "100", VariationName: "L", DestroyDeadline: t1(10)}}
out := SelectMatches(products, returns, now)
if !out[0].Matched || out[0].ReturnItemID != 900 {
t.Fatalf("size-only spec should still match: %+v", out[0])
}
}
func TestSelectMatches_DifferentItemIDNeverMatches(t *testing.T) {
now := t1(0)
products := []SYBCandidate{{SYBProductID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", CreatedAt: t1(-1)}}
returns := []ReturnCandidate{{ReturnItemID: 900, ItemID: "200", VariationName: "白色,L", DestroyDeadline: t1(10)}}
out := SelectMatches(products, returns, now)
if out[0].Matched {
t.Fatalf("different shopee item id must never match: %+v", out[0])
}
}
+263
View File
@@ -0,0 +1,263 @@
package returnmatch
import (
"errors"
"net/http"
"strconv"
"go-admin/app/goauto/models"
"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"
)
// Handler exposes the #338 return-matching admin surface: manual batch
// match, list/filter (shared by the SYB product page and the yeeke returns
// page), detail, confirm, cancel and remark. Everything here only reads or
// writes GoAuto's own database — no yeeke or SYB write call is made.
type Handler struct {
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
}
func operatorFromContext(c *gin.Context) (string, string) {
claims := jwt.ExtractClaims(c)
role, _ := claims["rolekey"].(string)
username, _ := claims["username"].(string)
if username == "" {
username, _ = claims["userName"].(string)
}
return role, username
}
// requireCanPurchase mirrors the admin/purchaser write gate this codebase
// already uses for other manual-trigger actions (yeeke.Handler.TriggerSync,
// sybimport.Handler.Import): trigger match, confirm, cancel and remark are
// writes and require it; the two list/detail read endpoints do not.
func requireCanPurchase(c *gin.Context) bool {
role, _ := operatorFromContext(c)
if role != "admin" && role != "purchaser" {
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以操作退货匹配"})
return false
}
return true
}
type batchMatchBody struct {
SYBProductIDs []uint64 `json:"sybProductIds"`
}
// BatchMatch is the「匹配退货」button: manual-only trigger for ticked rows.
func (h Handler) BatchMatch(c *gin.Context) {
if !requireCanPurchase(c) {
return
}
db, ok := h.db(c)
if !ok {
return
}
var body batchMatchBody
if err := c.ShouldBindJSON(&body); err != nil || len(body.SYBProductIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "sybProductIds 不能为空"})
return
}
_, operator := operatorFromContext(c)
resp, err := NewService(db).BatchMatch(c.Request.Context(), BatchMatchRequest{SYBProductIDs: body.SYBProductIDs, Operator: operator})
if errors.Is(err, ErrTooManyItems) {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": ErrTooManyItems.Error()})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": resp})
}
// List backs both the SYB product page's匹配列/筛选 and the yeeke returns
// page's占用状态筛选/列 (issue #338 pages section).
func (h Handler) List(c *gin.Context) {
db, ok := h.db(c)
if !ok {
return
}
filter := ListFilter{Status: c.Query("status")}
if raw := c.QueryArray("sybProductId"); len(raw) > 0 {
filter.SYBProductIDs = parseUint64List(raw)
}
if raw := c.QueryArray("yeekeReturnItemId"); len(raw) > 0 {
filter.YeekeReturnItemIDs = parseUint64List(raw)
}
rows, err := NewService(db).List(c.Request.Context(), filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"items": rows}})
}
func (h Handler) Detail(c *gin.Context) {
db, ok := h.db(c)
if !ok {
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"})
return
}
detail, err := NewService(db).Detail(c.Request.Context(), id)
if 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": detail})
}
func (h Handler) Confirm(c *gin.Context) {
h.transition(c, func(s *Service, ctx *gin.Context, id uint64, operator string) (models.ReturnMatch, error) {
return s.Confirm(ctx.Request.Context(), id, operator)
})
}
func (h Handler) Cancel(c *gin.Context) {
h.transition(c, func(s *Service, ctx *gin.Context, id uint64, operator string) (models.ReturnMatch, error) {
return s.Cancel(ctx.Request.Context(), id, operator)
})
}
func (h Handler) transition(c *gin.Context, fn func(*Service, *gin.Context, uint64, string) (models.ReturnMatch, error)) {
if !requireCanPurchase(c) {
return
}
db, ok := h.db(c)
if !ok {
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"})
return
}
_, operator := operatorFromContext(c)
match, err := fn(NewService(db), c, id, operator)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"})
return
}
if errors.Is(err, errStateConflict) {
c.JSON(http.StatusConflict, gin.H{"code": "STATE_CONFLICT", "message": "匹配状态已变化,请刷新后重试"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": match}})
}
type remarkBody struct {
Remark string `json:"remark"`
}
func (h Handler) Remark(c *gin.Context) {
if !requireCanPurchase(c) {
return
}
db, ok := h.db(c)
if !ok {
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "id 无效"})
return
}
var body remarkBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "请求体无效"})
return
}
_, operator := operatorFromContext(c)
match, err := NewService(db).Remark(c.Request.Context(), id, operator, body.Remark)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND", "message": "匹配记录不存在"})
return
}
if errors.Is(err, errRemarkTooLong) {
c.JSON(http.StatusBadRequest, gin.H{"code": "REMARK_TOO_LONG", "message": "备注不能超过 500 字"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"item": match}})
}
func parseUint64List(raw []string) []uint64 {
out := make([]uint64, 0, len(raw))
for _, v := range raw {
if id, err := strconv.ParseUint(v, 10, 64); err == nil {
out = append(out, id)
}
}
return out
}
// ListBatches returns recent 「匹配退货」 batch records (who, when, submitted
// and matched counts); pass withItems=1 to include each row's outcome.
func (h Handler) ListBatches(c *gin.Context) {
db, ok := h.db(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.Query("limit"))
rows, err := NewService(db).ListBatches(c.Request.Context(), limit, c.Query("withItems") == "1")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"items": rows}})
}
// GetBatch returns one batch record with every submitted SYB product's outcome.
func (h Handler) GetBatch(c *gin.Context) {
db, ok := h.db(c)
if !ok {
return
}
id, err := strconv.ParseUint(c.Param("batchId"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "batchId 无效"})
return
}
rec, err := NewService(db).GetBatch(c.Request.Context(), id)
if 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": rec})
}
@@ -0,0 +1,158 @@
package returnmatch
import (
"context"
"testing"
"time"
"go-admin/app/goauto/models"
)
// TestBatchMatch_MissingItemNotMatched: a return item whose sync_status is
// "missing" (#338: dropped from a COMPLETE yeeke sync) must never be offered
// to matching, even though it otherwise satisfies every other rule (an
// eligible SYB candidate, a future destroy deadline, no active match).
func TestBatchMatch_MissingItemNotMatched(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
now := time.Now().UTC()
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID).
Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; err != nil {
t.Fatal(err)
}
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
if err != nil {
t.Fatal(err)
}
if resp.MatchedCount != 0 || resp.SkippedCount != 1 {
t.Fatalf("expected the missing item to be skipped, not matched: %+v", resp)
}
if resp.Items[0].ReasonCode != ReasonNoCandidate {
t.Fatalf("expected no_candidate (the only candidate is missing): %+v", resp.Items[0])
}
}
// TestBatchMatch_MissingPackageNotMatched: same as above but the ITEM itself
// is still "ok" while its PACKAGE is "missing" — availableReturnPool must
// exclude it too, since #338's rule is "an item, or an item whose package,
// is no longer ok".
func TestBatchMatch_MissingPackageNotMatched(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
now := time.Now().UTC()
if err := db.Model(&models.YeekeReturnPackage{}).Where("id = ?", ret.PackageID).
Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; err != nil {
t.Fatal(err)
}
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
if err != nil {
t.Fatal(err)
}
if resp.MatchedCount != 0 || resp.SkippedCount != 1 {
t.Fatalf("expected the item to be skipped because its package is missing: %+v", resp)
}
}
// TestActiveMatchSurvivesItemGoingMissing: issue #338's rule 6 — an existing
// active match is NOT auto-cancelled when its return item later becomes
// missing. Detail() must still report it (SYB side unaffected) and surface
// the yeeke side's syncStatus="missing" so the UI can show the warning.
func TestActiveMatchSurvivesItemGoingMissing(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
if err != nil {
t.Fatal(err)
}
if resp.MatchedCount != 1 {
t.Fatalf("expected a match before the item goes missing: %+v", resp)
}
matchID := resp.Items[0].MatchID
// The return item now drops out of a COMPLETE yeeke sync.
missingSince := time.Now().UTC()
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID).
Updates(map[string]any{"sync_status": "missing", "missing_since": missingSince}).Error; err != nil {
t.Fatal(err)
}
var match models.ReturnMatch
if err := db.First(&match, matchID).Error; err != nil {
t.Fatal(err)
}
if match.Status != models.ReturnMatchStatusMatched || match.ActiveYeekeReturnItemID == nil {
t.Fatalf("existing match must stay active when its return item goes missing, got %+v", match)
}
detail, err := s.Detail(context.Background(), matchID)
if err != nil {
t.Fatal(err)
}
if detail.Yeeke == nil {
t.Fatal("Detail must still resolve the yeeke side (the row was never deleted)")
}
if detail.Yeeke.SyncStatus != "missing" || detail.Yeeke.MissingSince == nil {
t.Fatalf("Detail must report the return item's syncStatus=missing, got %+v", detail.Yeeke)
}
if detail.SYB == nil || detail.SYB.SYBProductID != syb.ID {
t.Fatalf("SYB side must be unaffected by the return item going missing, got %+v", detail.SYB)
}
}
// TestList_ReportsMissingReturnSyncStatus: the SYB products match column and
// the compare dialog read List()'s syncStatus/missingSince fields (#338); a
// match whose return item is missing must carry them through.
func TestList_ReportsMissingReturnSyncStatus(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
if err != nil {
t.Fatal(err)
}
if resp.MatchedCount != 1 {
t.Fatalf("expected a match: %+v", resp)
}
missingSince := time.Now().UTC()
if err := db.Model(&models.YeekeReturnItem{}).Where("id = ?", ret.ID).
Updates(map[string]any{"sync_status": "missing", "missing_since": missingSince}).Error; err != nil {
t.Fatal(err)
}
items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}})
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Fatalf("expected one list row, got %d", len(items))
}
if items[0].SyncStatus != "missing" || items[0].MissingSince == nil {
t.Fatalf("List() must surface the missing return item's status, got %+v", items[0])
}
}
+119
View File
@@ -0,0 +1,119 @@
// Package returnmatch implements the #338 return-matching algorithm: pairing
// a SYB order product with an available yeeke return item that is the same
// Shopee item with the same spec, so the return can be reused instead of
// buying the item again. This file holds only pure, DB-free functions so the
// matching rules can be unit tested directly against the 840 real pairs used
// to design the normalization rule (see issue #338 "当前事实").
package returnmatch
import "strings"
// bracketPairs lists every bracket style issue #338 requires stripped, along
// with its contained content: 【】()() and [].
var bracketPairs = []struct{ open, close rune }{
{'【', '】'},
{'(', ')'},
{'(', ')'},
{'[', ']'},
}
// stripBracketedContent removes every bracket-delimited span (any style in
// bracketPairs) and its contents. Unmatched opening brackets discard the
// remainder of the string from that point, which is safe here because SYB
// and yeeke free-text specs never rely on a trailing unmatched bracket to
// carry meaningful spec content.
func stripBracketedContent(s string) string {
var b strings.Builder
depth := 0
for _, r := range s {
isOpen, isClose := false, false
for _, pair := range bracketPairs {
if r == pair.open {
isOpen = true
}
if r == pair.close {
isClose = true
}
}
switch {
case isOpen:
depth++
case isClose:
if depth > 0 {
depth--
}
case depth == 0:
b.WriteRune(r)
}
}
return b.String()
}
// fullwidthToHalfwidth converts fullwidth ASCII forms (U+FF01-U+FF5E) and the
// fullwidth space (U+3000) to their halfwidth equivalents, leaving CJK
// characters (colors like 紫色) untouched.
func fullwidthToHalfwidth(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r == ' ':
b.WriteRune(' ')
case r >= '!' && r <= '~':
b.WriteRune(r - 0xFEE0)
default:
b.WriteRune(r)
}
}
return b.String()
}
// Normalize implements issue #338's spec-text normalization rule: strip
// bracketed remarks (【】()()[] and their content), strip whitespace,
// convert fullwidth characters to halfwidth, and lowercase. It is used on
// both the SYB side (target_color + "," + target_size) and the yeeke side
// (variation_name) so the two can be compared for equality.
func Normalize(raw string) string {
s := stripBracketedContent(raw)
s = fullwidthToHalfwidth(s)
s = strings.ToLower(s)
var b strings.Builder
for _, r := range s {
if !isSpace(r) {
b.WriteRune(r)
}
}
return b.String()
}
func isSpace(r rune) bool {
switch r {
case ' ', '\t', '\n', '\r', '\f', '\v', ' ':
return true
}
return false
}
// SYBSpecText builds the SYB-side comparable spec text from the parser's
// target_color/target_size fields, per issue #338's data design. Only
// non-empty parts are joined so a single-dimension spec (color-only or
// size-only) doesn't pick up a stray leading/trailing comma that would
// prevent it from normalizing equal to the yeeke side's single-value
// variation_name (e.g. SYBSpecText("黑色", "") must be "黑色", not "黑色,").
func SYBSpecText(targetColor, targetSize string) string {
parts := make([]string, 0, 2)
if targetColor != "" {
parts = append(parts, targetColor)
}
if targetSize != "" {
parts = append(parts, targetSize)
}
return strings.Join(parts, ",")
}
// trimCommas removes leading/trailing commas left over after normalization
// (e.g. a source value that itself started or ended with a comma). Used only
// when building the matching key, so exact-text comparisons/tests elsewhere
// are unaffected.
func trimCommas(s string) string {
return strings.Trim(s, ",")
}
@@ -0,0 +1,78 @@
package returnmatch
import "testing"
func TestNormalize(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"square brackets with content", "白色,2XL【建議65-75公斤】", "白色,2xl"},
{"chinese parens", "紫色,M(建議43-53公斤)", "紫色,m"},
{"ascii parens", "紫色,M (建議43-53公斤)", "紫色,m"},
{"latin square brackets", "紫色,M [建議43-53公斤]", "紫色,m"},
{"internal whitespace stripped", "紫色, M 【建議43-53公斤】", "紫色,m"},
{"fullwidth digits and letters", "白色,2XL", "白色,2xl"},
{"no bracket, plain", "黑色,L", "黑色,l"},
{"empty", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := Normalize(tc.in)
if got != tc.want {
t.Fatalf("Normalize(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestNormalizeMatchesRealPairSample(t *testing.T) {
// Representative real pairs from issue #338's local 840-pair backtest
// note: SYB target_color+","+target_size vs yeeke variation_name should
// normalize equal once bracketed remarks are stripped.
pairs := []struct{ syb, yeeke string }{
{SYBSpecText("白色", "2XL"), "白色,2XL【建議65-75公斤】"},
{SYBSpecText("紫色", "M"), "紫色,M【建議43-53公斤】"},
{SYBSpecText("黑色", "L"), "黑色,L"},
}
for _, p := range pairs {
if Normalize(p.syb) != Normalize(p.yeeke) {
t.Fatalf("expected normalized equality: syb=%q (%q) yeeke=%q (%q)",
p.syb, Normalize(p.syb), p.yeeke, Normalize(p.yeeke))
}
}
}
func TestSYBSpecText(t *testing.T) {
if got := SYBSpecText("白色", "2XL"); got != "白色,2XL" {
t.Fatalf("got %q", got)
}
}
func TestSYBSpecText_SingleDimension(t *testing.T) {
if got := SYBSpecText("黑色", ""); got != "黑色" {
t.Fatalf("color-only: got %q, want %q (no trailing comma)", got, "黑色")
}
if got := SYBSpecText("", "L"); got != "L" {
t.Fatalf("size-only: got %q, want %q (no leading comma)", got, "L")
}
if got := SYBSpecText("", ""); got != "" {
t.Fatalf("both empty: got %q, want empty", got)
}
}
func TestTrimCommas(t *testing.T) {
cases := map[string]string{
"黑色,": "黑色",
",黑色": "黑色",
",黑色,": "黑色",
"黑色": "黑色",
"": "",
}
for in, want := range cases {
if got := trimCommas(in); got != want {
t.Fatalf("trimCommas(%q) = %q, want %q", in, got, want)
}
}
}
+24
View File
@@ -0,0 +1,24 @@
package returnmatch
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
// InitRouter mounts the #338 return-matching admin surface. List/detail are
// readable by any authenticated admin user; the write actions (batch match,
// confirm, cancel, remark) additionally require admin/purchaser via
// requireCanPurchase, same gate as yeeke.Handler.TriggerSync.
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
handler := Handler{}
group := engine.Group("/api/admin/v1/return-matches").Use(auth.MiddlewareFunc())
group.GET("", handler.List)
// Static batch routes are registered before /:id so they never parse as a match id.
group.GET("/batches", handler.ListBatches)
group.GET("/batches/:batchId", handler.GetBatch)
group.GET("/:id", handler.Detail)
group.POST("/batch-match", handler.BatchMatch)
group.POST("/:id/confirm", handler.Confirm)
group.POST("/:id/cancel", handler.Cancel)
group.POST("/:id/remark", handler.Remark)
}
+651
View File
@@ -0,0 +1,651 @@
package returnmatch
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
"unicode/utf8"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchase"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func clauseLockUpdate() clause.Locking { return clause.Locking{Strength: "UPDATE"} }
// participatingStages is issue #338 rule 1's "待采购范围": 待人工处理 is
// explicitly excluded (already matched, per the confirmed rule), as are the
// stages that mean a task is already in flight or a match already exists.
var participatingStages = map[string]bool{
purchase.ProcessStagePDDUnlinked: true,
purchase.ProcessStagePDDPending: true,
purchase.ProcessStagePDDCollecting: true,
purchase.ProcessStagePDDCollectionFail: true,
purchase.ProcessStageColorMapping: true,
purchase.ProcessStagePurchaseReady: true,
}
type Service struct {
DB *gorm.DB
Now func() time.Time
}
func NewService(db *gorm.DB) *Service {
return &Service{DB: db, Now: time.Now}
}
// BatchMatchRequest/Result mirror the prototype's batch result dialog
// (screen 2): per-SYB-product outcome plus a reason bucket count.
type BatchMatchRequest struct {
SYBProductIDs []uint64
Operator string
}
type BatchMatchItem struct {
SYBProductID uint64 `json:"sybProductId"`
Matched bool `json:"matched"`
ReasonCode string `json:"reasonCode,omitempty"`
Reason string `json:"reason,omitempty"`
MatchID uint64 `json:"matchId,omitempty"`
}
type BatchMatchResponse struct {
Items []BatchMatchItem `json:"items"`
ProcessedCount int `json:"processedCount"`
MatchedCount int `json:"matchedCount"`
SkippedCount int `json:"skippedCount"`
}
const (
ReasonStageIneligible = "stage_ineligible"
ReasonNoCandidate = "no_candidate"
ReasonConflict = "conflict"
)
// maxBatchMatchItems caps a single「匹配退货」submission (#339: SYB list page
// size can now go up to 500/page, so the button's selection can exceed the
// previously-unbounded batch-match request size).
const maxBatchMatchItems = 500
// ErrTooManyItems is returned by BatchMatch when the caller submits more than
// maxBatchMatchItems SYB product ids; the handler turns this into an
// INVALID_REQUEST response instead of a 500.
var ErrTooManyItems = errors.New("sybProductIds 一次最多 500 条")
// BatchMatch implements issue #338's manual "匹配退货" trigger. It is only
// ever called from the batch-match button (ticked rows) — no scheduler, no
// yeeke-sync/SYB-import hook calls this (rule: 手动触发, 无定时任务).
func (s *Service) BatchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) {
if len(req.SYBProductIDs) > maxBatchMatchItems {
return BatchMatchResponse{}, ErrTooManyItems
}
resp, err := s.batchMatch(ctx, req)
if len(req.SYBProductIDs) > 0 {
// The batch record is written after the per-row transactions have
// committed, so a failure here must not turn an already-applied
// match into an error response; it is logged instead.
if logErr := s.recordBatch(ctx, req, resp, err); logErr != nil {
log.Printf("return match batch record failed: operator=%s submitted=%d: %v", req.Operator, len(req.SYBProductIDs), logErr)
}
}
return resp, err
}
// recordBatch stores one ReturnMatchBatch row describing this click of
// 「匹配退货」: submitted SYB products in order and each one's outcome.
func (s *Service) recordBatch(ctx context.Context, req BatchMatchRequest, resp BatchMatchResponse, batchErr error) error {
items := resp.Items
if items == nil {
items = []BatchMatchItem{}
}
raw, err := json.Marshal(items)
if err != nil {
return err
}
row := models.ReturnMatchBatch{
Operator: req.Operator,
SubmittedCount: len(req.SYBProductIDs),
MatchedCount: resp.MatchedCount,
SkippedCount: resp.SkippedCount,
ItemsJSON: string(raw),
}
if batchErr != nil {
row.Error = truncateRunes(batchErr.Error(), 500)
}
return s.DB.WithContext(ctx).Create(&row).Error
}
func truncateRunes(v string, max int) string {
r := []rune(v)
if len(r) <= max {
return v
}
return string(r[:max])
}
// BatchRecord is the API view of one ReturnMatchBatch row.
type BatchRecord struct {
models.ReturnMatchBatch
Items []BatchMatchItem `json:"items,omitempty"`
}
// ListBatches returns the most recent batch records, newest first; items are
// only expanded when withItems is true to keep the list response small.
func (s *Service) ListBatches(ctx context.Context, limit int, withItems bool) ([]BatchRecord, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
var rows []models.ReturnMatchBatch
if err := s.DB.WithContext(ctx).Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
out := make([]BatchRecord, 0, len(rows))
for _, row := range rows {
rec := BatchRecord{ReturnMatchBatch: row}
if withItems {
if err := json.Unmarshal([]byte(row.ItemsJSON), &rec.Items); err != nil {
return nil, err
}
}
out = append(out, rec)
}
return out, nil
}
// GetBatch returns one batch record with its items expanded.
func (s *Service) GetBatch(ctx context.Context, id uint64) (BatchRecord, error) {
var row models.ReturnMatchBatch
if err := s.DB.WithContext(ctx).First(&row, id).Error; err != nil {
return BatchRecord{}, err
}
rec := BatchRecord{ReturnMatchBatch: row}
if err := json.Unmarshal([]byte(row.ItemsJSON), &rec.Items); err != nil {
return BatchRecord{}, err
}
return rec, nil
}
func (s *Service) batchMatch(ctx context.Context, req BatchMatchRequest) (BatchMatchResponse, error) {
resp := BatchMatchResponse{Items: make([]BatchMatchItem, 0, len(req.SYBProductIDs))}
if len(req.SYBProductIDs) == 0 {
return resp, nil
}
var sybProducts []models.SYBProduct
if err := s.DB.WithContext(ctx).Where("id IN ?", req.SYBProductIDs).Find(&sybProducts).Error; err != nil {
return resp, err
}
sybByID := make(map[uint64]models.SYBProduct, len(sybProducts))
for _, p := range sybProducts {
sybByID[p.ID] = p
}
stages, err := purchase.NewService(s.DB).ProcessStages(ctx, req.SYBProductIDs)
if err != nil {
return resp, err
}
now := s.Now()
eligible := make([]SYBCandidate, 0, len(req.SYBProductIDs))
skipped := make(map[uint64]string)
for _, id := range req.SYBProductIDs {
syb, ok := sybByID[id]
if !ok {
skipped[id] = "SYB 商品不存在或已删除"
continue
}
stage := stages[id]
if !participatingStages[stage.Stage] {
skipped[id] = "该处理阶段不参与匹配:" + stage.Label
continue
}
eligible = append(eligible, SYBCandidate{
SYBProductID: syb.ID, ShopeeItemID: syb.ShopeeItemID,
TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, CreatedAt: syb.CreatedAt,
})
}
// Rule 4: process in SYB created_at DESC order.
sortSYBCandidatesDesc(eligible)
returns, err := s.availableReturnPool(ctx)
if err != nil {
return resp, err
}
outcomes := SelectMatches(eligible, returns, now)
outcomeBySYB := make(map[uint64]MatchOutcome, len(outcomes))
for _, o := range outcomes {
outcomeBySYB[o.SYBProductID] = o
}
for _, id := range req.SYBProductIDs {
if reason, isSkip := skipped[id]; isSkip {
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonStageIneligible, Reason: reason})
resp.SkippedCount++
continue
}
outcome, ok := outcomeBySYB[id]
if !ok || !outcome.Matched {
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonNoCandidate, Reason: "没有满足条件的退货商品"})
resp.SkippedCount++
continue
}
match, insertErr := s.matchOneWithLock(ctx, id, outcome, req.Operator)
if insertErr != nil {
if errors.Is(insertErr, errStageNoLongerEligible) {
// #338 review fix: the stage was re-checked under the same
// FOR UPDATE lock purchase.create takes, right before insert.
// A purchase task could have been created for this row
// between the outer screening pass above and this point;
// when that happens the product is no longer in a
// participating stage and this row is skipped, not matched.
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonStageIneligible, Reason: "处理阶段已变化,不再参与匹配"})
resp.SkippedCount++
continue
}
if isUniqueConstraintErr(insertErr) {
// Rule 7 / acceptance item 9: a concurrent insert lost the
// race for either side — skip this row and report it, never
// fail the whole batch.
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: false, ReasonCode: ReasonConflict, Reason: "并发匹配冲突,该商品或退货商品已被占用"})
resp.SkippedCount++
continue
}
return resp, insertErr
}
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, Matched: true, MatchID: match.ID})
resp.MatchedCount++
}
resp.ProcessedCount = len(resp.Items)
return resp, nil
}
func sortSYBCandidatesDesc(items []SYBCandidate) {
for i := 1; i < len(items); i++ {
for j := i; j > 0 && items[j].CreatedAt.After(items[j-1].CreatedAt); j-- {
items[j], items[j-1] = items[j-1], items[j]
}
}
}
// availableReturnPool loads every yeeke return item with no active match and
// a destroy deadline (rule 2: empty deadline is unavailable, handled by the
// NULL exclusion below; "later than now" is enforced by SelectMatches).
// #338 scope addition: an item, or an item whose package, is no longer
// sync_status="ok" (i.e. a COMPLETE yeeke sync stopped seeing it) is
// excluded here so matching never offers a return yeeke has dropped.
func (s *Service) availableReturnPool(ctx context.Context) ([]ReturnCandidate, error) {
var rows []struct {
ID uint64
ItemID string
VariationName string
DestroyDeadLine *time.Time
}
err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i").
Select("i.id AS id, i.item_id AS item_id, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line").
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
Joins("LEFT JOIN return_match AS m ON m.active_yeeke_return_item_id = i.id").
Where("m.id IS NULL AND i.sync_status = ? AND p.sync_status = ?", "ok", "ok").
Find(&rows).Error
if err != nil {
return nil, err
}
out := make([]ReturnCandidate, 0, len(rows))
for _, r := range rows {
if r.DestroyDeadLine == nil {
continue
}
out = append(out, ReturnCandidate{ReturnItemID: r.ID, ItemID: r.ItemID, VariationName: r.VariationName, DestroyDeadline: *r.DestroyDeadLine})
}
return out, nil
}
// errStageNoLongerEligible is returned by matchOneWithLock when the SYB
// product's process stage, re-checked under lock immediately before insert,
// is no longer in participatingStages — e.g. a purchase task was created for
// it between BatchMatch's outer screening pass and this point (#338 review
// fix: race between matching and purchase creation).
var errStageNoLongerEligible = errors.New("syb product stage no longer participates in matching")
// matchOneWithLock takes the SAME row lock purchase.Service.create takes on
// syb_product (clause.Locking{Strength: "UPDATE"}) and re-computes the
// process stage inside that transaction via purchase.NewService(tx) before
// inserting the match, so a purchase task creation racing with this batch
// match can never both succeed: whichever gets the row lock first commits,
// and the other sees the now-current state (an active return_match row, or
// a task_created/order_review/purchase_succeeded stage) and is rejected/
// skipped instead of double-committing an inconsistent state.
func (s *Service) matchOneWithLock(ctx context.Context, sybID uint64, outcome MatchOutcome, operator string) (models.ReturnMatch, error) {
var match models.ReturnMatch
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var syb models.SYBProduct
if err := tx.Clauses(clauseLockUpdate()).First(&syb, sybID).Error; err != nil {
return err
}
stages, err := purchase.NewService(tx).ProcessStages(ctx, []uint64{sybID})
if err != nil {
return err
}
stage := stages[sybID]
if !participatingStages[stage.Stage] {
return errStageNoLongerEligible
}
var returnItem models.YeekeReturnItem
if err := tx.First(&returnItem, outcome.ReturnItemID).Error; err != nil {
return err
}
sybIDCopy := syb.ID
returnIDCopy := outcome.ReturnItemID
deadline := outcome.DestroyDeadline
now := s.Now()
match = models.ReturnMatch{
SYBProductID: sybIDCopy, YeekeReturnItemID: returnIDCopy,
ActiveSYBProductID: &sybIDCopy, ActiveYeekeReturnItemID: &returnIDCopy,
Status: models.ReturnMatchStatusMatched,
SYBSpecText: SYBSpecText(syb.TargetColor, syb.TargetSize),
YeekeSpecText: returnItem.VariationName,
NormalizedKey: outcome.NormalizedKey,
PreviousProcessStage: stage.Stage,
MatchedBy: operator,
MatchedAt: now,
DestroyDeadlineSnapshot: &deadline,
}
if err := tx.Create(&match).Error; err != nil {
return err
}
logRow := models.ReturnMatchLog{
MatchID: match.ID, Action: models.ReturnMatchLogActionMatched, Operator: operator,
Detail: fmt.Sprintf("匹配退货商品 #%d(%s),此前处理阶段:%s", returnIDCopy, returnItem.VariationName, stage.Label),
}
return tx.Create(&logRow).Error
})
return match, err
}
func isUniqueConstraintErr(err error) bool {
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unique") || strings.Contains(msg, "duplicate")
}
// Confirm/Cancel/Remark implement the human-review half of issue #338.
func (s *Service) Confirm(ctx context.Context, matchID uint64, operator string) (models.ReturnMatch, error) {
var match models.ReturnMatch
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clauseLockUpdate()).First(&match, matchID).Error; err != nil {
return err
}
if match.Status != models.ReturnMatchStatusMatched {
return errStateConflict
}
now := s.Now()
match.Status = models.ReturnMatchStatusConfirmed
match.ConfirmedBy = operator
match.ConfirmedAt = &now
if err := tx.Save(&match).Error; err != nil {
return err
}
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionConfirmed, Operator: operator, Detail: "确认匹配,商品状态变为已用退货"}).Error
})
return match, err
}
// Cancel restores the SYB product to purchasable (by clearing
// ActiveSYBProductID) and returns the return item to the eligible pool (by
// clearing ActiveYeekeReturnItemID), from either matched or confirmed state,
// per issue #338 rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许.
func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string) (models.ReturnMatch, error) {
var match models.ReturnMatch
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clauseLockUpdate()).First(&match, matchID).Error; err != nil {
return err
}
if match.Status == models.ReturnMatchStatusCancelled {
return errStateConflict
}
now := s.Now()
match.Status = models.ReturnMatchStatusCancelled
match.ActiveSYBProductID = nil
match.ActiveYeekeReturnItemID = nil
match.CancelledBy = operator
match.CancelledAt = &now
if err := tx.Save(&match).Error; err != nil {
return err
}
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionCancelled, Operator: operator, Detail: "取消匹配,SYB 商品恢复可采购,退货商品回到可用池"}).Error
})
return match, err
}
// errRemarkTooLong is returned instead of silently truncating; remark is a
// varchar(500) column and MySQL VARCHAR length is a character count, so this
// checks runes, not bytes.
var errRemarkTooLong = errors.New("remark exceeds 500 characters")
const maxRemarkLength = 500
func (s *Service) Remark(ctx context.Context, matchID uint64, operator, remark string) (models.ReturnMatch, error) {
if utf8.RuneCountInString(remark) > maxRemarkLength {
return models.ReturnMatch{}, errRemarkTooLong
}
var match models.ReturnMatch
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.First(&match, matchID).Error; err != nil {
return err
}
match.Remark = remark
if err := tx.Save(&match).Error; err != nil {
return err
}
return tx.Create(&models.ReturnMatchLog{MatchID: match.ID, Action: models.ReturnMatchLogActionRemark, Operator: operator, Detail: remark}).Error
})
return match, err
}
// List supports both admin pages' filter/column needs: by SYB product ids,
// by yeeke return item ids, or by status.
type ListFilter struct {
SYBProductIDs []uint64
YeekeReturnItemIDs []uint64
Status string
}
// ListItem is one row of List's result: the match itself plus a few display
// fields the SYB product page's match column needs (thumbnail, return order
// sn, spec) so it never has to issue a second request per row.
type ListItem struct {
models.ReturnMatch
OrderSN string `json:"orderSn,omitempty"`
Image string `json:"image,omitempty"`
VariationName string `json:"variationName,omitempty"`
DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"`
// SyncStatus/MissingSince (#338) surface the yeeke return item's own
// current availability ("ok"/"missing") so the SYB products match column
// and the compare dialog can warn even on an existing, already-matched
// pair whose return later dropped out of a COMPLETE yeeke sync.
SyncStatus string `json:"syncStatus,omitempty"`
MissingSince *time.Time `json:"missingSince,omitempty"`
}
func (s *Service) List(ctx context.Context, filter ListFilter) ([]ListItem, error) {
q := s.DB.WithContext(ctx).Model(&models.ReturnMatch{})
if len(filter.SYBProductIDs) > 0 {
q = q.Where("syb_product_id IN ?", filter.SYBProductIDs)
}
if len(filter.YeekeReturnItemIDs) > 0 {
q = q.Where("yeeke_return_item_id IN ?", filter.YeekeReturnItemIDs)
}
if filter.Status != "" {
q = q.Where("status = ?", filter.Status)
}
var rows []models.ReturnMatch
if err := q.Order("id DESC").Find(&rows).Error; err != nil {
return nil, err
}
items := make([]ListItem, len(rows))
returnIDs := make([]uint64, 0, len(rows))
for i, r := range rows {
items[i] = ListItem{ReturnMatch: r}
returnIDs = append(returnIDs, r.YeekeReturnItemID)
}
if len(returnIDs) == 0 {
return items, nil
}
// One bounded join query for every return item referenced on this page —
// never a per-row lookup.
var joined []struct {
ID uint64
OrderSN string
Image string
VariationName string
DestroyDeadLine *time.Time
SyncStatus string
MissingSince *time.Time
PackageSyncStat string `gorm:"column:package_sync_status"`
PackageMissingAt *time.Time `gorm:"column:package_missing_since"`
}
if err := s.DB.WithContext(ctx).Table("yeeke_return_item AS i").
Select("i.id AS id, p.order_sn AS order_sn, i.image AS image, i.variation_name AS variation_name, p.destroy_dead_line AS destroy_dead_line, "+
"i.sync_status AS sync_status, i.missing_since AS missing_since, p.sync_status AS package_sync_status, p.missing_since AS package_missing_since").
Joins("JOIN yeeke_return_package AS p ON p.id = i.package_id").
Where("i.id IN ?", returnIDs).
Find(&joined).Error; err != nil {
return nil, err
}
type extraFields struct {
OrderSN string
Image string
VariationName string
DestroyDeadLine *time.Time
SyncStatus string
MissingSince *time.Time
}
byID := make(map[uint64]extraFields, len(joined))
for _, j := range joined {
// A missing package makes its items unavailable too (#338), even
// if the item row itself is still "ok" — surface the package's
// missing_since in that case since it is the more accurate reason.
syncStatus, missingSince := j.SyncStatus, j.MissingSince
if j.PackageSyncStat != "ok" {
syncStatus, missingSince = j.PackageSyncStat, j.PackageMissingAt
}
byID[j.ID] = extraFields{j.OrderSN, j.Image, j.VariationName, j.DestroyDeadLine, syncStatus, missingSince}
}
for i := range items {
if extra, ok := byID[items[i].YeekeReturnItemID]; ok {
items[i].OrderSN = extra.OrderSN
items[i].Image = extra.Image
items[i].VariationName = extra.VariationName
items[i].DestroyDeadline = extra.DestroyDeadLine
items[i].SyncStatus = extra.SyncStatus
items[i].MissingSince = extra.MissingSince
}
}
return items, nil
}
// SYBDetailView/YeekeDetailView/MatchDetail back the prototype's compare
// screen (screen 3): both sides' fields and images side by side, normalized
// spec text for both, and the operation log.
type SYBDetailView struct {
SYBProductID uint64 `json:"sybProductId"`
OrderCode string `json:"orderCode"`
ShopeeItemID string `json:"shopeeItemId"`
ShopName string `json:"shopName"`
ProductTitle string `json:"productTitle"`
TargetColor string `json:"targetColor"`
TargetSize string `json:"targetSize"`
Quantity int64 `json:"quantity"`
ImageURL string `json:"imageUrl,omitempty"`
CurrentStage string `json:"currentStage"`
CurrentStageLabel string `json:"currentStageLabel"`
}
type YeekeDetailView struct {
YeekeReturnItemID uint64 `json:"yeekeReturnItemId"`
OrderSN string `json:"orderSn"`
ItemID string `json:"itemId"`
VariationID string `json:"variationId"`
ShopName string `json:"shopName"`
ItemName string `json:"itemName"`
VariationName string `json:"variationName"`
Quantity int64 `json:"quantity"`
Image string `json:"image,omitempty"`
DestroyDeadline *time.Time `json:"destroyDeadline,omitempty"`
// SyncStatus/MissingSince (#338): "missing" when either the item or its
// package fell out of a COMPLETE yeeke sync, so the compare dialog can
// show the warning even on a match made before the item went missing.
SyncStatus string `json:"syncStatus"`
MissingSince *time.Time `json:"missingSince,omitempty"`
}
type MatchDetail struct {
Match models.ReturnMatch `json:"match"`
SYB *SYBDetailView `json:"syb,omitempty"`
Yeeke *YeekeDetailView `json:"yeeke,omitempty"`
NormalizedSYBSpec string `json:"normalizedSybSpec"`
NormalizedYeekeSpec string `json:"normalizedYeekeSpec"`
Logs []models.ReturnMatchLog `json:"logs"`
}
func (s *Service) Detail(ctx context.Context, matchID uint64) (MatchDetail, error) {
var match models.ReturnMatch
if err := s.DB.WithContext(ctx).First(&match, matchID).Error; err != nil {
return MatchDetail{}, err
}
detail := MatchDetail{Match: match}
var syb models.SYBProduct
if err := s.DB.WithContext(ctx).First(&syb, match.SYBProductID).Error; err == nil {
stages, stageErr := purchase.NewService(s.DB).ProcessStages(ctx, []uint64{syb.ID})
var stage purchase.ProcessStageResult
if stageErr == nil {
stage = stages[syb.ID]
}
detail.SYB = &SYBDetailView{
SYBProductID: syb.ID, OrderCode: syb.OrderCode, ShopeeItemID: syb.ShopeeItemID,
ShopName: syb.ShopName, ProductTitle: syb.ProductTitle,
TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Quantity: syb.Quantity,
ImageURL: syb.ImageURL, CurrentStage: stage.Stage, CurrentStageLabel: stage.Label,
}
detail.NormalizedSYBSpec = trimCommas(Normalize(SYBSpecText(syb.TargetColor, syb.TargetSize)))
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return detail, err
}
var item models.YeekeReturnItem
if err := s.DB.WithContext(ctx).First(&item, match.YeekeReturnItemID).Error; err == nil {
var pkg models.YeekeReturnPackage
if pkgErr := s.DB.WithContext(ctx).First(&pkg, item.PackageID).Error; pkgErr != nil && !errors.Is(pkgErr, gorm.ErrRecordNotFound) {
return detail, pkgErr
}
syncStatus, missingSince := item.SyncStatus, item.MissingSince
if pkg.SyncStatus != "" && pkg.SyncStatus != "ok" {
syncStatus, missingSince = pkg.SyncStatus, pkg.MissingSince
}
detail.Yeeke = &YeekeDetailView{
YeekeReturnItemID: item.ID, OrderSN: pkg.OrderSN, ItemID: item.ItemID, VariationID: item.VariationID,
ShopName: pkg.ShopName, ItemName: item.ItemName, VariationName: item.VariationName,
Quantity: item.Quantity, Image: item.Image, DestroyDeadline: pkg.DestroyDeadLine,
SyncStatus: syncStatus, MissingSince: missingSince,
}
detail.NormalizedYeekeSpec = trimCommas(Normalize(item.VariationName))
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return detail, err
}
var logs []models.ReturnMatchLog
if err := s.DB.WithContext(ctx).Where("match_id = ?", matchID).Order("id DESC").Find(&logs).Error; err != nil {
return detail, err
}
detail.Logs = logs
return detail, nil
}
var errStateConflict = errors.New("return match state conflict")
@@ -0,0 +1,485 @@
package returnmatch
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func testDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
if err = migrations.Migrate(db); err != nil {
t.Fatal(err)
}
return db
}
// shopeeLinkForItem memoizes one shopee_product row per (db, item id) test
// case so multiple SYB rows for the same order/item share the link, same as
// real data, while still landing on the PDD-unlinked stage (which
// participates in matching per issue #338 rule 1) without needing the full
// PDD/price fixture the purchase package's own tests use.
func shopeeLinkForItem(t *testing.T, db *gorm.DB, itemID string) uint64 {
t.Helper()
var existing models.ShopeeProduct
if err := db.Where("shopee_item_id = ?", itemID).First(&existing).Error; err == nil {
return existing.ID
}
sp := models.ShopeeProduct{ShopeeItemID: itemID, Title: "蝦皮商品", ShopName: "测试店", SpecsJSON: `[]`, Currency: "CNY"}
if err := db.Create(&sp).Error; err != nil {
t.Fatal(err)
}
return sp.ID
}
func seedSYB(t *testing.T, db *gorm.DB, orderCode string, detailID uint64, color, size string, createdAt time.Time) models.SYBProduct {
t.Helper()
shopeeID := shopeeLinkForItem(t, db, "100")
syb := models.SYBProduct{
OrderCode: orderCode, DetailID: detailID, StockID: detailID, ShopeeItemID: "100", ShopeeProductID: &shopeeID,
TargetColor: color, TargetSize: size, Quantity: 1, UnitPriceCent: 1000,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{}`,
}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("created_at", createdAt).Error; err != nil {
t.Fatal(err)
}
syb.CreatedAt = createdAt
return syb
}
func seedReturn(t *testing.T, db *gorm.DB, variationName string, deadline *time.Time) models.YeekeReturnItem {
t.Helper()
pkg := models.YeekeReturnPackage{
ExternalID: "pkg-" + variationName + fmt.Sprint(time.Now().UnixNano()), OrderSN: "ORD1", TrackingNo: "TRK1",
DestroyDeadLine: deadline, LastSyncedAt: time.Now(),
}
if err := db.Create(&pkg).Error; err != nil {
t.Fatal(err)
}
item := models.YeekeReturnItem{
PackageID: pkg.ID, ExternalKey: "key-" + variationName + fmt.Sprint(time.Now().UnixNano()),
ItemID: "100", VariationName: variationName, LastSyncedAt: time.Now(),
}
if err := db.Create(&item).Error; err != nil {
t.Fatal(err)
}
return item
}
func TestBatchMatch_EndToEnd(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
if err != nil {
t.Fatal(err)
}
if resp.MatchedCount != 1 || len(resp.Items) != 1 || !resp.Items[0].Matched {
t.Fatalf("expected one match: %+v", resp)
}
var match models.ReturnMatch
if err := db.First(&match, resp.Items[0].MatchID).Error; err != nil {
t.Fatal(err)
}
if match.Status != models.ReturnMatchStatusMatched || match.ActiveSYBProductID == nil || *match.ActiveSYBProductID != syb.ID {
t.Fatalf("unexpected match row: %+v", match)
}
}
func TestBatchMatch_RejectsMoreThan500Items(t *testing.T) {
db := testDB(t)
s := NewService(db)
ids := make([]uint64, maxBatchMatchItems+1)
for i := range ids {
ids[i] = uint64(i + 1)
}
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: ids, Operator: "tester"})
if !errors.Is(err, ErrTooManyItems) {
t.Fatalf("expected ErrTooManyItems, got %v", err)
}
if len(resp.Items) != 0 {
t.Fatalf("expected empty response on rejection, got %+v", resp)
}
var batchCount int64
if err := db.Model(&models.ReturnMatchBatch{}).Count(&batchCount).Error; err != nil {
t.Fatal(err)
}
if batchCount != 0 {
t.Fatalf("rejected oversized batch must not be recorded, got %d rows", batchCount)
}
}
func TestBatchMatch_ExpiredDeadlineNotMatched(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
past := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L", &past)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
if err != nil {
t.Fatal(err)
}
if resp.MatchedCount != 0 || resp.SkippedCount != 1 {
t.Fatalf("expected skip due to expired deadline: %+v", resp)
}
}
func TestBatchMatch_MultiColourSameOrderCrossPairing(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
white := seedSYB(t, db, "SYB-1", 1, "白色", "2XL", time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC))
purple := seedSYB(t, db, "SYB-1", 2, "紫色", "M", time.Date(2026, 9, 1, 11, 0, 0, 0, time.UTC))
whiteReturn := seedReturn(t, db, "白色,2XL【建議65-75公斤】", &deadline)
purpleReturn := seedReturn(t, db, "紫色,M【建議43-53公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{white.ID, purple.ID}})
if err != nil {
t.Fatal(err)
}
if resp.MatchedCount != 2 {
t.Fatalf("expected both to match: %+v", resp)
}
var matches []models.ReturnMatch
if err := db.Find(&matches).Error; err != nil {
t.Fatal(err)
}
got := map[uint64]uint64{}
for _, m := range matches {
got[m.SYBProductID] = m.YeekeReturnItemID
}
if got[white.ID] != whiteReturn.ID {
t.Fatalf("white SYB product should pair with white return, got %+v", got)
}
if got[purple.ID] != purpleReturn.ID {
t.Fatalf("purple SYB product should pair with purple return, got %+v", got)
}
}
func TestBatchMatch_ManualActionStageDoesNotParticipate(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
// A parse-failed SYB product computes to 待人工处理, which the confirmed
// rule says must NOT participate even though a candidate return exists.
syb := models.SYBProduct{OrderCode: "SYB-1", DetailID: 1, StockID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", Quantity: 1, UnitPriceCent: 1000, ParseStatus: models.SYBParseStatusFailed, RawJSON: `{}`}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
seedReturn(t, db, "白色,L", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
if err != nil {
t.Fatal(err)
}
if resp.MatchedCount != 0 || resp.Items[0].ReasonCode != ReasonStageIneligible {
t.Fatalf("expected 待人工处理 to be excluded from matching: %+v", resp)
}
}
func TestConfirmThenCancel_RestoresAvailability(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
if err != nil || resp.MatchedCount != 1 {
t.Fatalf("setup match failed: %v %+v", err, resp)
}
matchID := resp.Items[0].MatchID
if _, err := s.Confirm(context.Background(), matchID, "op1"); err != nil {
t.Fatal(err)
}
var confirmed models.ReturnMatch
db.First(&confirmed, matchID)
if confirmed.Status != models.ReturnMatchStatusConfirmed {
t.Fatalf("expected confirmed: %+v", confirmed)
}
if _, err := s.Cancel(context.Background(), matchID, "op2"); err != nil {
t.Fatal(err)
}
var cancelled models.ReturnMatch
db.First(&cancelled, matchID)
if cancelled.Status != models.ReturnMatchStatusCancelled || cancelled.ActiveSYBProductID != nil || cancelled.ActiveYeekeReturnItemID != nil {
t.Fatalf("expected cancel to clear active columns: %+v", cancelled)
}
// Rematch must be able to pick the same pair again (issue #338 rule 7).
resp2, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
if err != nil {
t.Fatal(err)
}
if resp2.MatchedCount != 1 {
t.Fatalf("expected rematch after cancel to succeed: %+v", resp2)
}
}
// TestBatchMatch_SkipsWhenStageRacesToTaskCreatedUnderLock is the #338
// review fix-2 regression: a purchase task gets created for the SYB product
// (moving its stage to task_created, which does not participate) AFTER
// BatchMatch's outer screening pass already judged it eligible but BEFORE
// matchOneWithLock's own re-check runs. The row must be skipped with
// stage_ineligible, not matched.
func TestMatchOneWithLock_SkipsWhenStageNoLongerParticipatesUnderLock(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
ret := seedReturn(t, db, "白色,L", &deadline)
// Simulate the exact race the review flagged: BatchMatch's outer
// screening pass already computed `outcome` while the product was still
// participating (pdd_unlinked). Before matchOneWithLock's own re-check
// runs (which takes the same row lock purchase.create takes and
// recomputes the stage inside that transaction), something else moves
// the product out of a participating stage — here simulated directly by
// flipping it to a parse-failed/manual_action state, same effect as a
// purchase task having been created for it in the meantime.
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).Update("parse_status", models.SYBParseStatusFailed).Error; err != nil {
t.Fatal(err)
}
outcome := MatchOutcome{SYBProductID: syb.ID, Matched: true, ReturnItemID: ret.ID, NormalizedKey: "100|白色,l"}
_, err := s.matchOneWithLock(context.Background(), syb.ID, outcome, "tester")
if !errors.Is(err, errStageNoLongerEligible) {
t.Fatalf("expected errStageNoLongerEligible, got %v", err)
}
var count int64
db.Model(&models.ReturnMatch{}).Where("syb_product_id = ?", syb.ID).Count(&count)
if count != 0 {
t.Fatalf("no return_match row should have been created when the stage race is caught: count=%d", count)
}
}
func TestBatchMatch_WritesLogRowInSameTransaction(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "tester"})
if err != nil || resp.MatchedCount != 1 {
t.Fatalf("setup match failed: %v %+v", err, resp)
}
var logs []models.ReturnMatchLog
if err := db.Where("match_id = ?", resp.Items[0].MatchID).Find(&logs).Error; err != nil {
t.Fatal(err)
}
if len(logs) != 1 || logs[0].Action != models.ReturnMatchLogActionMatched || logs[0].Operator != "tester" {
t.Fatalf("expected one matched log row: %+v", logs)
}
}
func TestConfirmCancelRemark_EachWritesOwnLogRow(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L", &deadline)
resp, _ := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "matcher"})
matchID := resp.Items[0].MatchID
if _, err := s.Remark(context.Background(), matchID, "reviewer", "看起来没问题"); err != nil {
t.Fatal(err)
}
if _, err := s.Confirm(context.Background(), matchID, "reviewer"); err != nil {
t.Fatal(err)
}
if _, err := s.Cancel(context.Background(), matchID, "reviewer2"); err != nil {
t.Fatal(err)
}
var logs []models.ReturnMatchLog
if err := db.Where("match_id = ?", matchID).Order("id ASC").Find(&logs).Error; err != nil {
t.Fatal(err)
}
wantActions := []string{models.ReturnMatchLogActionMatched, models.ReturnMatchLogActionRemark, models.ReturnMatchLogActionConfirmed, models.ReturnMatchLogActionCancelled}
if len(logs) != len(wantActions) {
t.Fatalf("expected %d log rows, got %d: %+v", len(wantActions), len(logs), logs)
}
for i, action := range wantActions {
if logs[i].Action != action {
t.Fatalf("log[%d].Action = %q, want %q (%+v)", i, logs[i].Action, action, logs)
}
}
if logs[1].Detail != "看起来没问题" {
t.Fatalf("remark log must carry the remark text: %+v", logs[1])
}
}
func TestRemark_RejectsOverLongInputInsteadOfTruncating(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L", &deadline)
resp, _ := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
matchID := resp.Items[0].MatchID
tooLong := strings.Repeat("字", 501)
if _, err := s.Remark(context.Background(), matchID, "op", tooLong); !errors.Is(err, errRemarkTooLong) {
t.Fatalf("expected errRemarkTooLong, got %v", err)
}
var match models.ReturnMatch
db.First(&match, matchID)
if match.Remark != "" {
t.Fatalf("rejected remark must not be saved (partially or fully): %q", match.Remark)
}
exactly500 := strings.Repeat("字", 500)
if _, err := s.Remark(context.Background(), matchID, "op", exactly500); err != nil {
t.Fatalf("exactly 500 runes must be accepted: %v", err)
}
}
func TestDetail_ReturnsBothSidesNormalizedSpecsAndLogs(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
ret := seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}, Operator: "matcher"})
if err != nil || resp.MatchedCount != 1 {
t.Fatalf("setup failed: %v %+v", err, resp)
}
matchID := resp.Items[0].MatchID
if _, err := s.Remark(context.Background(), matchID, "op", "备注文字"); err != nil {
t.Fatal(err)
}
detail, err := s.Detail(context.Background(), matchID)
if err != nil {
t.Fatal(err)
}
if detail.SYB == nil || detail.SYB.SYBProductID != syb.ID || detail.SYB.OrderCode != "SYB-1" {
t.Fatalf("missing/wrong SYB detail: %+v", detail.SYB)
}
if detail.Yeeke == nil || detail.Yeeke.YeekeReturnItemID != ret.ID || detail.Yeeke.OrderSN != "ORD1" {
t.Fatalf("missing/wrong yeeke detail: %+v", detail.Yeeke)
}
if detail.Yeeke.DestroyDeadline == nil || !detail.Yeeke.DestroyDeadline.Equal(deadline) {
t.Fatalf("expected destroy deadline to be carried through: %+v", detail.Yeeke)
}
if detail.NormalizedSYBSpec == "" || detail.NormalizedSYBSpec != detail.NormalizedYeekeSpec {
t.Fatalf("expected both sides to normalize equal: syb=%q yeeke=%q", detail.NormalizedSYBSpec, detail.NormalizedYeekeSpec)
}
if len(detail.Logs) != 2 {
t.Fatalf("expected 2 log rows (matched + remark), got %+v", detail.Logs)
}
if detail.Logs[0].Action != models.ReturnMatchLogActionRemark {
t.Fatalf("expected newest-first ordering (remark first): %+v", detail.Logs)
}
if detail.Match.YeekeSpecText == "" || detail.Match.PreviousProcessStage == "" {
t.Fatalf("insertMatch must fill YeekeSpecText and PreviousProcessStage: %+v", detail.Match)
}
}
// TestBatchMatch_ConcurrentInsertSkipsConflictRow exercises the DB unique
// constraint directly: two goroutines racing to insert an active match for
// the exact same SYB product must have exactly one winner, and the loser
// must not error out the whole call (acceptance item 9).
func TestBatchMatch_ConcurrentInsertSkipsConflictRow(t *testing.T) {
db := testDB(t)
sybID := uint64(1)
returnAID := uint64(101)
returnBID := uint64(102)
var wg sync.WaitGroup
results := make([]error, 2)
for i, retID := range []uint64{returnAID, returnBID} {
wg.Add(1)
go func(i int, retID uint64) {
defer wg.Done()
match := models.ReturnMatch{
SYBProductID: sybID, YeekeReturnItemID: retID,
ActiveSYBProductID: &sybID, ActiveYeekeReturnItemID: &retID,
Status: models.ReturnMatchStatusMatched, MatchedAt: time.Now(),
}
results[i] = db.Create(&match).Error
}(i, retID)
}
wg.Wait()
successCount := 0
conflictCount := 0
for _, err := range results {
if err == nil {
successCount++
} else if isUniqueConstraintErr(err) {
conflictCount++
} else {
t.Fatalf("unexpected error: %v", err)
}
}
if successCount != 1 || conflictCount != 1 {
t.Fatalf("expected exactly one winner and one unique-constraint conflict, got success=%d conflict=%d results=%v", successCount, conflictCount, results)
}
}
func TestList_EnrichesWithYeekeReturnDisplayFields(t *testing.T) {
db := testDB(t)
s := NewService(db)
s.Now = func() time.Time { return time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) }
deadline := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
syb := seedSYB(t, db, "SYB-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
seedReturn(t, db, "白色,L【建議65-75公斤】", &deadline)
resp, err := s.BatchMatch(context.Background(), BatchMatchRequest{SYBProductIDs: []uint64{syb.ID}})
if err != nil || resp.MatchedCount != 1 {
t.Fatalf("setup failed: %v %+v", err, resp)
}
items, err := s.List(context.Background(), ListFilter{SYBProductIDs: []uint64{syb.ID}})
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].OrderSN != "ORD1" || items[0].DestroyDeadline == nil {
t.Fatalf("expected enriched list item: %+v", items)
}
}
@@ -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)
}
}
}
+15 -11
View File
@@ -427,22 +427,26 @@ 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 {
+2 -2
View File
@@ -65,8 +65,8 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
if request.PageSize < 1 {
request.PageSize = 20
}
if request.PageSize > 100 {
request.PageSize = 100
if request.PageSize > 500 {
request.PageSize = 500
}
query := service.DB.WithContext(ctx).Model(&models.SYBProduct{})
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" {
@@ -93,6 +93,36 @@ func TestServiceListRejectsTooManyOrTooLongOrderCodes(t *testing.T) {
}
}
func TestServiceListCapsPageSizeAt500(t *testing.T) {
db := openTestDB(t)
order := realOrder()
for i := 0; i < 3; i++ {
detail := realDetailA()
detail.ID += uint64(i)
order.Code = fmt.Sprintf("260728TB95MJTQ-%d", i)
if _, err := sybimport.ApplyDetail(context.Background(), db, order, detail); err != nil {
t.Fatalf("apply detail %d: %v", i, err)
}
}
service := sybimport.NewService(db)
overLimit, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 600})
if err != nil {
t.Fatalf("list with oversized page size: %v", err)
}
if overLimit.PageSize != 500 {
t.Fatalf("expected page size capped at 500, got %d", overLimit.PageSize)
}
within, err := service.List(context.Background(), sybimport.ListRequest{Page: 1, PageSize: 500})
if err != nil {
t.Fatalf("list at exactly 500: %v", err)
}
if within.PageSize != 500 {
t.Fatalf("expected page size of exactly 500 to pass through unchanged, got %d", within.PageSize)
}
}
func TestServiceListRejectsInvalidParseStatus(t *testing.T) {
db := openTestDB(t)
service := sybimport.NewService(db)
+212
View File
@@ -0,0 +1,212 @@
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"`
MissingMarkedCount int `json:"missingMarkedCount"`
RecoveredCount int `json:"recoveredCount"`
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,
MissingMarkedCount: r.MissingMarkedCount, RecoveredCount: r.RecoveredCount, 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
}
+487
View File
@@ -0,0 +1,487 @@
package yeeke
import (
"errors"
"net/http"
"sort"
"strconv"
"strings"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchase"
"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"`
SyncStatus string `json:"syncStatus"`
MissingSince *string `json:"missingSince,omitempty"`
}
// 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"`
MissingSince *string `json:"missingSince,omitempty"`
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"`
// SyncStatus/MissingSince (#338 scope addition): "missing" when this
// item, or its package, has fallen out of a COMPLETE yeeke sync — the
// row is never dropped from this list, only flagged unavailable.
SyncStatus string `json:"syncStatus,omitempty"`
MissingSince *string `json:"missingSince,omitempty"`
// #338: match status/占用信息, joined from return_match (active match
// only). MatchStatus is "unmatched" when there is no active match.
MatchID uint64 `json:"matchId,omitempty"`
MatchStatus string `json:"matchStatus"`
OccupyingSYBProductID uint64 `json:"occupyingSybProductId,omitempty"`
OccupyingSYBOrderCode string `json:"occupyingSybOrderCode,omitempty"`
OccupyingSYBStage string `json:"occupyingSybStage,omitempty"`
OccupyingSYBStageLabel string `json:"occupyingSybStageLabel,omitempty"`
}
const (
ReturnMatchFilterUnmatched = "unmatched"
ReturnMatchFilterMatched = "matched"
ReturnMatchFilterConfirmed = "confirmed"
)
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,
MissingSince: formatTimePtr(p.MissingSince),
}
}
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,
SyncStatus: i.SyncStatus, MissingSince: formatTimePtr(i.MissingSince),
}
}
// 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
// #338 scope addition: item's own and its package's sync_status/
// missing_since, so the row can be flagged unavailable regardless of
// which side dropped out of a COMPLETE yeeke sync.
ItemSyncStatus *string `gorm:"column:item_sync_status"`
ItemMissingSince *time.Time `gorm:"column:item_missing_since"`
PackageSyncStatus string `gorm:"column:package_sync_status"`
PackageMissingSince *time.Time `gorm:"column:package_missing_since"`
// #338 match columns, from the LEFT JOIN onto return_match/syb_product.
MatchID *uint64 `gorm:"column:match_id"`
MatchStatus *string `gorm:"column:match_status"`
OccupyingSYBProductID *uint64 `gorm:"column:occupying_syb_product_id"`
OccupyingSYBOrderCode *string `gorm:"column:occupying_syb_order_code"`
}
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")
}
// #338: the package being "missing" makes the item unavailable too,
// even if the item row itself is still "ok" — its reason/timestamp wins.
syncStatus, missingSince := "ok", r.ItemMissingSince
if r.ItemSyncStatus != nil {
syncStatus = *r.ItemSyncStatus
}
if r.PackageSyncStatus != "" && r.PackageSyncStatus != "ok" {
syncStatus, missingSince = r.PackageSyncStatus, r.PackageMissingSince
}
dto.SyncStatus = syncStatus
dto.MissingSince = formatTimePtr(missingSince)
dto.MatchStatus = ReturnMatchFilterUnmatched
if r.MatchID != nil {
dto.MatchID = *r.MatchID
if r.MatchStatus != nil {
switch *r.MatchStatus {
case models.ReturnMatchStatusConfirmed:
dto.MatchStatus = ReturnMatchFilterConfirmed
default:
dto.MatchStatus = ReturnMatchFilterMatched
}
}
if r.OccupyingSYBProductID != nil {
dto.OccupyingSYBProductID = *r.OccupyingSYBProductID
}
if r.OccupyingSYBOrderCode != nil {
dto.OccupyingSYBOrderCode = *r.OccupyingSYBOrderCode
}
}
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").
// #338: single bounded LEFT JOIN onto the active match (if any) and
// the SYB product it occupies, so 匹配状态 filtering/column never
// needs a per-row lookup.
Joins("LEFT JOIN return_match m ON m.active_yeeke_return_item_id = i.id").
Joins("LEFT JOIN syb_product sp ON sp.id = m.syb_product_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)
}
switch matchStatus := strings.TrimSpace(c.Query("matchStatus")); matchStatus {
case "":
// no filter
case ReturnMatchFilterUnmatched:
base = base.Where("m.id IS NULL")
case ReturnMatchFilterMatched:
base = base.Where("m.status = ?", models.ReturnMatchStatusMatched)
case ReturnMatchFilterConfirmed:
base = base.Where("m.status = ?", models.ReturnMatchStatusConfirmed)
default:
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "matchStatus 无效"})
return
}
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, " +
"i.sync_status as item_sync_status, i.missing_since as item_missing_since, " +
"p.sync_status as package_sync_status, p.missing_since as package_missing_since, " +
"m.id as match_id, m.status as match_status, sp.id as occupying_syb_product_id, sp.order_code as occupying_syb_order_code",
).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
}
// #338: one bounded ProcessStages call for the distinct occupying SYB
// product ids on this page (never per-row), so the 「被占用 SYB 订单
// 商品」 column can also show its current stage.
sybIDs := make([]uint64, 0, len(rows))
seenSYB := make(map[uint64]bool, len(rows))
for _, r := range rows {
if r.OccupyingSYBProductID != nil && !seenSYB[*r.OccupyingSYBProductID] {
seenSYB[*r.OccupyingSYBProductID] = true
sybIDs = append(sybIDs, *r.OccupyingSYBProductID)
}
}
var stages map[uint64]purchase.ProcessStageResult
if len(sybIDs) > 0 {
var stageErr error
stages, stageErr = purchase.NewService(db).ProcessStages(c.Request.Context(), sybIDs)
if stageErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": "INTERNAL", "message": "服务端处理失败"})
return
}
}
items := make([]ReturnItemRowDTO, 0, len(rows))
for _, r := range rows {
dto := toItemRowDTO(r)
if r.OccupyingSYBProductID != nil {
if stage, ok := stages[*r.OccupyingSYBProductID]; ok {
dto.OccupyingSYBStage = stage.Stage
dto.OccupyingSYBStageLabel = stage.Label
}
}
items = append(items, dto)
}
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}})
}
+113
View File
@@ -0,0 +1,113 @@
package yeeke
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"go-admin/app/goauto/models"
"github.com/gin-gonic/gin"
)
// TestListReturnItems_MatchStatusFilterAndOccupyingColumn is the #338 web-UI
// support test: the yeeke returns list must be able to filter by match
// status and show which SYB order product occupies a matched return, using
// the single bounded LEFT JOIN added to ListReturnPackages (no N+1).
func TestListReturnItems_MatchStatusFilterAndOccupyingColumn(t *testing.T) {
gin.SetMode(gin.TestMode)
db := testDB(t)
now := time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC)
deadline := now.Add(30 * 24 * time.Hour)
pkg := models.YeekeReturnPackage{ExternalID: "pkg-m1", OrderSN: "ORD-M1", TrackingNo: "T1", DestroyDeadLine: &deadline, LastSyncedAt: now, CreateTime: &now}
if err := db.Create(&pkg).Error; err != nil {
t.Fatal(err)
}
matchedItem := models.YeekeReturnItem{PackageID: pkg.ID, ExternalKey: "k-matched", ItemID: "100", VariationName: "白色,L", LastSyncedAt: now}
unmatchedItem := models.YeekeReturnItem{PackageID: pkg.ID, ExternalKey: "k-unmatched", ItemID: "100", VariationName: "黑色,M", LastSyncedAt: now}
if err := db.Create(&matchedItem).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&unmatchedItem).Error; err != nil {
t.Fatal(err)
}
syb := models.SYBProduct{OrderCode: "SYB-OCC-1", DetailID: 1, StockID: 1, ShopeeItemID: "100", TargetColor: "白色", TargetSize: "L", Quantity: 1, UnitPriceCent: 100, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}"}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
match := models.ReturnMatch{
SYBProductID: syb.ID, YeekeReturnItemID: matchedItem.ID,
ActiveSYBProductID: &syb.ID, ActiveYeekeReturnItemID: &matchedItem.ID,
Status: models.ReturnMatchStatusMatched, MatchedAt: now,
}
if err := db.Create(&match).Error; err != nil {
t.Fatal(err)
}
handler := Handler{DB: db}
engine := gin.New()
engine.GET("/api/admin/v1/yeeke-returns", handler.ListReturnPackages)
do := func(req *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
return w
}
// No filter: both rows present, matched row carries occupying info.
w := do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns", nil))
var resp itemRowListResp
decodeJSON(t, w.Body.Bytes(), &resp)
if resp.Data.Total != 2 {
t.Fatalf("expected 2 rows, got %+v", resp.Data)
}
byKey := map[string]ReturnItemRowDTO{}
for _, item := range resp.Data.Items {
byKey[item.Key] = item
}
matchedDTO := byKey[strconv.FormatUint(matchedItem.ID, 10)]
if matchedDTO.MatchStatus != ReturnMatchFilterMatched {
t.Fatalf("expected matched status: %+v", matchedDTO)
}
if matchedDTO.OccupyingSYBOrderCode != "SYB-OCC-1" || matchedDTO.OccupyingSYBProductID != syb.ID {
t.Fatalf("expected occupying SYB info: %+v", matchedDTO)
}
if matchedDTO.OccupyingSYBStageLabel == "" {
t.Fatalf("expected occupying SYB stage label to be filled: %+v", matchedDTO)
}
unmatchedDTO := byKey[strconv.FormatUint(unmatchedItem.ID, 10)]
if unmatchedDTO.MatchStatus != ReturnMatchFilterUnmatched || unmatchedDTO.OccupyingSYBProductID != 0 {
t.Fatalf("expected unmatched status with no occupying info: %+v", unmatchedDTO)
}
// matchStatus=unmatched filters down to just the unmatched row.
w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=unmatched", nil))
decodeJSON(t, w.Body.Bytes(), &resp)
if resp.Data.Total != 1 || len(resp.Data.Items) != 1 || resp.Data.Items[0].Key != strconv.FormatUint(unmatchedItem.ID, 10) {
t.Fatalf("expected only the unmatched row: %+v", resp.Data)
}
// matchStatus=matched filters down to just the matched row.
w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=matched", nil))
decodeJSON(t, w.Body.Bytes(), &resp)
if resp.Data.Total != 1 || len(resp.Data.Items) != 1 || resp.Data.Items[0].Key != strconv.FormatUint(matchedItem.ID, 10) {
t.Fatalf("expected only the matched row: %+v", resp.Data)
}
// Invalid value is rejected, not silently ignored.
w = do(httptest.NewRequest(http.MethodGet, "/api/admin/v1/yeeke-returns?matchStatus=bogus", nil))
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid matchStatus, got %d", w.Code)
}
}
func decodeJSON(t *testing.T, body []byte, v any) {
t.Helper()
if err := json.Unmarshal(body, v); err != nil {
t.Fatalf("decode response: %v (%s)", err, body)
}
}
+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
}
+437
View File
@@ -0,0 +1,437 @@
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
// MissingMarked is the number of yeeke_return_item rows flipped from
// "ok" to "missing" by this run's completion (#338). It is only ever
// non-zero on a run whose page walk finished naturally, wrote no
// failures, and stayed under the 20% safety-valve threshold. Persisted
// as yeeke_sync_run.missing_marked_count; when the safety valve skips
// marking it stays 0 and the reason goes to error_message.
MissingMarked int
// Recovered is the number of yeeke_return_item rows that were "missing"
// before this run and reappeared in it, flipping back to "ok" (#338).
// Persisted as yeeke_sync_run.recovered_count.
Recovered int
}
// 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, "missing_marked_count": rep.MissingMarked, "recovered_count": rep.Recovered, "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
// complete tracks whether the page walk ended NATURALLY (empty page,
// short page, or reaching p.Pages) as opposed to the duplicate-
// fingerprint break or MaxPages exhaustion (#338): only a naturally
// complete run is trusted to mark absent items/packages "missing" below,
// since a duplicate/MaxPages stop means the walk never actually finished
// seeing everything yeeke currently has.
complete := false
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 {
complete = true
break
}
finger := pageFingerprint(p)
if seen[finger] {
rep.Skipped += len(p.Records)
break
}
seen[finger] = true
for _, x := range p.Records {
created, updated, recovered, err := s.upsert(ctx, x)
if err != nil {
rep.Failed++
if firstWriteErr == nil {
firstWriteErr = err
}
continue
}
rep.Read++
rep.Recovered += recovered
if created {
rep.Created++
} else if updated {
rep.Updated++
} else {
rep.Skipped++
}
}
if len(p.Records) < s.cfg.PageSize {
complete = true
break
}
if p.Pages > 0 && page >= p.Pages {
complete = true
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)
}
}
// #338: only a naturally complete run with zero write failures is
// trusted to mark items/packages the sync no longer sees as "missing".
if complete && rep.Failed == 0 && rep.Status == "succeeded" {
marked, valveReason, mErr := s.markMissing(ctx, r)
switch {
case mErr != nil:
errMsg = truncateRunError(fmt.Sprintf("标记 yeeke 退货不可用失败:%v", mErr))
case valveReason != "":
errMsg = truncateRunError(valveReason)
default:
rep.MissingMarked = marked
}
}
return rep, nil
}
// markMissing implements #338's completion-triggered availability flip: any
// yeeke_return_item/yeeke_return_package row still marked sync_status="ok"
// but whose last_synced_at predates this run's start was not touched by
// this (complete, failure-free) run's upsert calls, meaning yeeke no longer
// reports it. Both tables are flipped to "missing" with missing_since
// stamped, in one transaction, so a reader never observes the item flipped
// without its package (or vice versa). Rows are never deleted.
//
// Safety valve: if the number of items that would be marked exceeds 20% of
// the items currently "ok", nothing is marked and valveReason explains why
// (the run itself still finishes as "succeeded" — this is a caution, not a
// sync failure).
func (s *Service) markMissing(ctx context.Context, r *models.YeekeSyncRun) (marked int, valveReason string, err error) {
var totalOkItems int64
if e := s.db.WithContext(ctx).Model(&models.YeekeReturnItem{}).Where("sync_status = ?", "ok").Count(&totalOkItems).Error; e != nil {
return 0, "", e
}
var candidateItems int64
if e := s.db.WithContext(ctx).Model(&models.YeekeReturnItem{}).
Where("sync_status = ? AND last_synced_at < ?", "ok", r.StartedAt).
Count(&candidateItems).Error; e != nil {
return 0, "", e
}
if candidateItems == 0 {
return 0, "", nil
}
// candidateItems/totalOkItems > 20% <=> candidateItems*5 > totalOkItems
// (integer-only, avoids float rounding).
if totalOkItems > 0 && candidateItems*5 > totalOkItems {
return 0, fmt.Sprintf(
"未标记 yeeke 退货不可用:待标记 %d 条超过当前可用退货商品 %d 条的 20%% 安全阈值,需人工核查后再处理",
candidateItems, totalOkItems,
), nil
}
now := time.Now().UTC()
txErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if e := tx.Model(&models.YeekeReturnItem{}).
Where("sync_status = ? AND last_synced_at < ?", "ok", r.StartedAt).
Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error; e != nil {
return e
}
return tx.Model(&models.YeekeReturnPackage{}).
Where("sync_status = ? AND last_synced_at < ?", "ok", r.StartedAt).
Updates(map[string]any{"sync_status": "missing", "missing_since": now}).Error
})
if txErr != nil {
return 0, "", txErr
}
return int(candidateItems), "", 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[:])
}
// upsert writes one package and its items. recovered counts items that were
// marked "missing" before and reappeared in this run (#338).
func (s *Service) upsert(ctx context.Context, p yeekeclient.ReturnPackage) (created bool, updated bool, recovered int, err 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 {
// #338: missing_since is reset to NULL whenever a package
// reappears in a sync so it recovers cleanly, whatever its prior
// sync_status was.
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", "missing_since": (*time.Time)(nil)}
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:
if existing.SyncStatus == "missing" {
recovered++
}
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", "missing_since": (*time.Time)(nil)}
if e = tx.Model(&existing).Updates(iv).Error; e != nil {
return e
}
}
}
return nil
})
if err != nil {
return false, false, 0, err
}
return isNew, !isNew, recovered, nil
}
@@ -0,0 +1,406 @@
package yeeke
import (
"context"
"fmt"
"go-admin/app/goauto/models"
"go-admin/app/goauto/yeekeclient"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// fivePackagesFirstReport builds a first-sync page of 5 distinct packages
// (p1..p5), so a follow-up run dropping exactly one of them (1/5 = 20%,
// the safety-valve boundary, which is not ">20%") still marks it missing.
func fivePackagesFirstReport() []string {
recs := make([]string, 0, 5)
for i := 1; i <= 5; i++ {
id := fmt.Sprintf("p%d", i)
recs = append(recs, record(id, "i", "v"+id, 1))
}
return recs
}
// TestCompleteRunMarksAbsentItemsAndPackagesMissing: p1 exists from an
// earlier sync of 5 packages; a later, naturally complete run only reports
// the other 4 (p2..p5). p1's item and package must both flip to
// sync_status="missing" with missing_since set; p2..p5 must stay "ok".
func TestCompleteRunMarksAbsentItemsAndPackagesMissing(t *testing.T) {
db := testDB(t)
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 10})
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1))
}))
s.client, _ = yeekeclient.New(srv1.URL)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("first sync: %v", err)
}
srv1.Close()
time.Sleep(1100 * time.Millisecond) // ensure StartedAt of run 2 is strictly later
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p2", "i", "vp2", 1), record("p3", "i", "vp3", 1), record("p4", "i", "vp4", 1), record("p5", "i", "vp5", 1)}, 4, 1))
}))
defer srv2.Close()
s.client, _ = yeekeclient.New(srv2.URL)
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("second sync: %v", err)
}
if rep.Status != "succeeded" {
t.Fatalf("rep=%+v", rep)
}
if rep.MissingMarked != 1 {
t.Fatalf("MissingMarked=%d, want 1", rep.MissingMarked)
}
var p1 models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil {
t.Fatal(e)
}
if p1.SyncStatus != "missing" || p1.MissingSince == nil {
t.Fatalf("p1 package=%+v, want sync_status=missing with missing_since set", p1)
}
var i1 models.YeekeReturnItem
if e := db.Where("package_id = ?", p1.ID).First(&i1).Error; e != nil {
t.Fatal(e)
}
if i1.SyncStatus != "missing" || i1.MissingSince == nil {
t.Fatalf("p1 item=%+v, want sync_status=missing with missing_since set", i1)
}
var p2 models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil {
t.Fatal(e)
}
if p2.SyncStatus != "ok" || p2.MissingSince != nil {
t.Fatalf("p2 package=%+v, want sync_status=ok with no missing_since", p2)
}
}
// TestFailedPageStopsMarking: page 2 always fails, so the run ends "failed".
// Nothing must be marked missing even though p1 (from an earlier run) is
// absent from this run's (incomplete) output.
func TestFailedPageStopsMarking(t *testing.T) {
db := testDB(t)
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 1, Retry: 0})
srv1 := 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))
}))
s.client, _ = yeekeclient.New(srv1.URL)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("first sync: %v", err)
}
srv1.Close()
var calls int32
srv2 := 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("p2", "i", "v2", 1)}, 2, 2))
return
}
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer srv2.Close()
s.client, _ = yeekeclient.New(srv2.URL)
if _, err := s.Sync(context.Background(), "manual"); err == nil {
t.Fatal("expected the second run (failed page 2) to error")
}
var p1 models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil {
t.Fatal(e)
}
if p1.SyncStatus != "ok" || p1.MissingSince != nil {
t.Fatalf("p1 must stay ok after a failed page, got %+v", p1)
}
}
// TestCtxCancelStopsMarking: the context is cancelled mid-walk. The run ends
// with an error and must not mark anything missing.
func TestCtxCancelStopsMarking(t *testing.T) {
db := testDB(t)
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 1, Retry: 0})
srv1 := 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))
}))
s.client, _ = yeekeclient.New(srv1.URL)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("first sync: %v", err)
}
srv1.Close()
ctx, cancel := context.WithCancel(context.Background())
var calls int32
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&calls, 1)
if n == 1 {
cancel()
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 2, 2))
return
}
http.Error(w, "should not be reached", http.StatusInternalServerError)
}))
defer srv2.Close()
s.client, _ = yeekeclient.New(srv2.URL)
if _, err := s.Sync(ctx, "manual"); err == nil {
t.Fatal("expected a context-cancellation error")
}
var p1 models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&p1).Error; e != nil {
t.Fatal(e)
}
if p1.SyncStatus != "ok" || p1.MissingSince != nil {
t.Fatalf("p1 must stay ok after a context-cancelled run, got %+v", p1)
}
}
// TestDuplicateFingerprintStopsMarking: the walk ends via the
// duplicate-page break, not a natural stop, so nothing must be marked even
// though only p1 (not p2 from an earlier sync) is ever reported.
func TestDuplicateFingerprintStopsMarking(t *testing.T) {
db := testDB(t)
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 1})
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 1, 1))
}))
s.client, _ = yeekeclient.New(srv1.URL)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("first sync (seeds p2): %v", err)
}
srv1.Close()
// Second run: page 1 returns p1, but the server (mis)reports pages=5 and
// then serves the exact same page again, triggering the duplicate break
// before ever reaching a natural stop.
body := page([]string{record("p1", "i", "v1", 1)}, 10, 5)
var calls int32
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, body)
}))
defer srv2.Close()
s.client, _ = yeekeclient.New(srv2.URL)
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("second sync: %v", err)
}
if rep.MissingMarked != 0 {
t.Fatalf("MissingMarked=%d, want 0 (duplicate-fingerprint stop is not complete)", rep.MissingMarked)
}
var p2 models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil {
t.Fatal(e)
}
if p2.SyncStatus != "ok" || p2.MissingSince != nil {
t.Fatalf("p2 must stay ok after a duplicate-fingerprint stop, got %+v", p2)
}
}
// TestMaxPagesExhaustionStopsMarking: MaxPages is reached without any
// natural stop condition being hit, so nothing must be marked.
func TestMaxPagesExhaustionStopsMarking(t *testing.T) {
db := testDB(t)
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 1})
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p2", "i", "v2", 1)}, 1, 1))
}))
s.client, _ = yeekeclient.New(srv1.URL)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("first sync (seeds p2): %v", err)
}
srv1.Close()
// Second run: every page returns a distinct full page (never short,
// never empty, pages always reported far beyond MaxPages), so the walk
// only stops because MaxPages is exhausted.
var n int32
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
k := atomic.AddInt32(&n, 1)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record(fmt.Sprintf("p1-%d", k), "i", "v1", 1)}, 1000, 1000))
}))
defer srv2.Close()
s.client, _ = yeekeclient.New(srv2.URL)
s.cfg.MaxPages = 2
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("second sync: %v", err)
}
if rep.MissingMarked != 0 {
t.Fatalf("MissingMarked=%d, want 0 (MaxPages exhaustion is not complete)", rep.MissingMarked)
}
var p2 models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p2").First(&p2).Error; e != nil {
t.Fatal(e)
}
if p2.SyncStatus != "ok" || p2.MissingSince != nil {
t.Fatalf("p2 must stay ok after MaxPages exhaustion, got %+v", p2)
}
}
// TestSafetyValveSkipsMarkingWhenOverThreshold: 3 packages are "ok"; a
// complete follow-up run only reports 1 of them (2 of 3 = 67% would be
// marked, well over the 20% threshold). Nothing must be marked, and the
// run's error_message must explain why.
func TestSafetyValveSkipsMarkingWhenOverThreshold(t *testing.T) {
db := testDB(t)
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 10})
srv1 := 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), record("p3", "i", "v3", 1)}, 3, 1))
}))
s.client, _ = yeekeclient.New(srv1.URL)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("first sync: %v", err)
}
srv1.Close()
time.Sleep(1100 * time.Millisecond)
srv2 := 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 srv2.Close()
s.client, _ = yeekeclient.New(srv2.URL)
rep, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("second sync: %v", err)
}
if rep.Status != "succeeded" {
t.Fatalf("safety valve must not fail the run, got status=%q", rep.Status)
}
if rep.MissingMarked != 0 {
t.Fatalf("MissingMarked=%d, want 0 (over the 20%% safety valve)", rep.MissingMarked)
}
var run models.YeekeSyncRun
if e := db.First(&run, rep.RunID).Error; e != nil {
t.Fatal(e)
}
if !strings.Contains(run.ErrorMessage, "20%") {
t.Fatalf("run.ErrorMessage=%q, want an explanation mentioning the 20%% safety valve", run.ErrorMessage)
}
for _, ext := range []string{"p2", "p3"} {
var p models.YeekeReturnPackage
if e := db.Where("external_id = ?", ext).First(&p).Error; e != nil {
t.Fatal(e)
}
if p.SyncStatus != "ok" || p.MissingSince != nil {
t.Fatalf("%s must stay ok when the safety valve trips, got %+v", ext, p)
}
}
}
// TestReappearingRecordRecoversFromMissing: a package/item marked missing by
// an earlier complete run reappears in a later sync and must recover to
// sync_status="ok" with missing_since cleared.
func TestReappearingRecordRecoversFromMissing(t *testing.T) {
db := testDB(t)
c, _ := yeekeclient.New("http://unused.invalid")
s := NewService(db, c, Config{PageSize: 10})
srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1))
}))
s.client, _ = yeekeclient.New(srv1.URL)
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatalf("run 1: %v", err)
}
srv1.Close()
time.Sleep(1100 * time.Millisecond)
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page([]string{record("p2", "i", "vp2", 1), record("p3", "i", "vp3", 1), record("p4", "i", "vp4", 1), record("p5", "i", "vp5", 1)}, 4, 1))
}))
s.client, _ = yeekeclient.New(srv2.URL)
rep2, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("run 2: %v", err)
}
if rep2.MissingMarked != 1 {
t.Fatalf("run2 MissingMarked=%d, want 1", rep2.MissingMarked)
}
srv2.Close()
var p1 models.YeekeReturnPackage
db.Where("external_id = ?", "p1").First(&p1)
if p1.SyncStatus != "missing" || p1.MissingSince == nil {
t.Fatalf("p1 must be missing before recovery, got %+v", p1)
}
srv3 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, page(fivePackagesFirstReport(), 5, 1))
}))
defer srv3.Close()
s.client, _ = yeekeclient.New(srv3.URL)
rep3, err := s.Sync(context.Background(), "manual")
if err != nil {
t.Fatalf("run 3 (recovery): %v", err)
}
if rep3.Recovered != 1 || rep3.MissingMarked != 0 {
t.Fatalf("run3 Recovered=%d MissingMarked=%d, want 1 and 0", rep3.Recovered, rep3.MissingMarked)
}
// Both counters are persisted on the run rows shown by the sync-runs page.
var run2, run3 models.YeekeSyncRun
if e := db.First(&run2, rep2.RunID).Error; e != nil {
t.Fatal(e)
}
if e := db.First(&run3, rep3.RunID).Error; e != nil {
t.Fatal(e)
}
if run2.MissingMarkedCount != 1 || run2.RecoveredCount != 0 || run3.MissingMarkedCount != 0 || run3.RecoveredCount != 1 {
t.Fatalf("persisted counts run2=(%d,%d) run3=(%d,%d), want (1,0) and (0,1)", run2.MissingMarkedCount, run2.RecoveredCount, run3.MissingMarkedCount, run3.RecoveredCount)
}
// A fresh variable is used here (not the p1 declared above): GORM's Scan
// does not reset an already non-nil pointer field to nil when the new
// row's column is NULL, so reusing the earlier struct would misreport a
// stale MissingSince even though the row itself recovered correctly.
var recovered models.YeekeReturnPackage
if e := db.Where("external_id = ?", "p1").First(&recovered).Error; e != nil {
t.Fatal(e)
}
if recovered.SyncStatus != "ok" || recovered.MissingSince != nil {
t.Fatalf("p1 must recover to ok with missing_since cleared, got %+v", recovered)
}
var i1 models.YeekeReturnItem
if e := db.Where("package_id = ?", recovered.ID).First(&i1).Error; e != nil {
t.Fatal(e)
}
if i1.SyncStatus != "ok" || i1.MissingSince != nil {
t.Fatalf("p1's item must recover to ok with missing_since cleared, got %+v", i1)
}
}
+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
}
@@ -0,0 +1,260 @@
package yeekeclient
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCaptchaLoginAndReadOnlyList(t *testing.T) {
s := 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=", "key": "k"}})
case "/agent-foreign/sys/login":
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"token": "opaque", "userInfo": map[string]any{"id": "u"}}})
case "/agent-foreign/packageClaimRec/relation/list":
if r.Header.Get("X-Access-Token") != "opaque" {
t.Errorf("X-Access-Token header missing")
}
if r.URL.Query().Get("token") != "" {
t.Errorf("token must not be sent in the URL")
}
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if body["column"] != "createTime" || body["claimFlag"] != "1" || body["status"] != "1" || body["relationFlag"] != "1" || body["order"] != "desc" {
t.Errorf("list body shape differs from the web client: %v", body)
}
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"records": []any{}, "total": 0}})
default:
http.NotFound(w, r)
}
}))
defer s.Close()
c, _ := New(s.URL)
cap, e := c.FetchCaptcha(context.Background())
if e != nil || string(cap.Image) != "Hi" || cap.CheckKey != "k" {
t.Fatalf("captcha=%+v err=%v", cap, e)
}
if _, e = c.Login(context.Background(), "u", "p", "1234", "k"); e != nil {
t.Fatal(e)
}
if _, e = c.List(context.Background(), 1, 10); e != nil {
t.Fatal(e)
}
}
// The live randomImage response names the captcha key "key" (HAR evidence for
// #336). A client that only read "checkKey" sent an empty checkKey, never
// reached the login endpoint, and every OCR attempt "failed". This pins the
// real response shape end to end: the key from randomImage must arrive as the
// login body's checkKey, together with remember_me.
func TestLoginWithOCRSendsCaptchaKeyFromRandomImage(t *testing.T) {
var loginBody map[string]any
s := 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, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "0123456789abcdef0123456789abcdef"}})
case "/agent-foreign/sys/login":
_ = json.NewDecoder(r.Body).Decode(&loginBody)
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 200, "result": map[string]any{"token": "opaque", "userInfo": map[string]any{"id": "u"}}})
default:
http.NotFound(w, r)
}
}))
defer s.Close()
c, _ := New(s.URL)
if _, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "p", 3); e != nil {
t.Fatalf("login: %v", e)
}
if loginBody == nil {
t.Fatal("login endpoint was never called")
}
if loginBody["checkKey"] != "0123456789abcdef0123456789abcdef" {
t.Fatalf("checkKey = %v, want the randomImage key", loginBody["checkKey"])
}
if loginBody["remember_me"] != true || loginBody["captcha"] != "abcd" || loginBody["agentCode"] != "mmt" {
t.Fatalf("unexpected login body shape: %v", keysOnly(loginBody))
}
}
func TestFetchCaptchaRejectsResponseWithoutKey(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]string{"image": "data:image/jpg;base64,SGk="}})
}))
defer s.Close()
c, _ := New(s.URL)
if _, e := c.FetchCaptcha(context.Background()); e == nil {
t.Fatal("expected an explicit error when the captcha key is missing")
}
}
type fixedOCR string
func (f fixedOCR) Recognize(context.Context, []byte) (string, error) { return string(f), nil }
func keysOnly(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// Mirrors demo/yeeke_demo.py and the HAR: browser user agent on every call and
// a _t cache-buster on randomImage.
func TestRequestsCarryBrowserUserAgentAndCaptchaTimestamp(t *testing.T) {
var captchaQuery, captchaUA, loginUA string
s := 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":
captchaQuery, captchaUA = r.URL.RawQuery, r.UserAgent()
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}})
case "/agent-foreign/sys/login":
loginUA = r.UserAgent()
json.NewEncoder(w).Encode(map[string]any{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}})
}
}))
defer s.Close()
c, _ := New(s.URL)
if _, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "p", 3); e != nil {
t.Fatal(e)
}
if !strings.HasPrefix(captchaQuery, "_t=") {
t.Fatalf("randomImage query = %q, want _t timestamp", captchaQuery)
}
for _, ua := range []string{captchaUA, loginUA} {
if !strings.Contains(ua, "Chrome/") {
t.Fatalf("user agent = %q, want browser UA", ua)
}
}
}
// A wrong captcha is retried with a fresh image; a wrong password is not.
func TestLoginWithOCRRetriesOnlyCaptchaRejections(t *testing.T) {
cases := []struct {
name string
responses []map[string]any
wantOK bool
wantLogins int
wantErrSubstr string
}{
{
name: "captcha wrong then ok",
responses: []map[string]any{
{"success": false, "code": 500, "message": "验证码错误"},
{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}},
},
wantOK: true, wantLogins: 2,
},
{
name: "wrong password stops immediately",
responses: []map[string]any{
{"success": false, "code": 500, "message": "用户名或密码错误"},
{"success": true, "code": 200, "result": map[string]any{"token": "opaque"}},
},
wantLogins: 1, wantErrSubstr: "用户名或密码错误",
},
{
name: "captcha always wrong",
responses: []map[string]any{
{"success": false, "code": 500, "message": "验证码错误"},
{"success": false, "code": 500, "message": "验证码错误"},
{"success": false, "code": 500, "message": "验证码错误"},
},
wantLogins: 3, wantErrSubstr: "captcha_rejected=3",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
logins := 0
s := 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, "code": 0, "result": map[string]string{"image": "data:image/jpg;base64,SGk=", "key": "k"}})
case "/agent-foreign/sys/login":
resp := tc.responses[logins]
logins++
json.NewEncoder(w).Encode(resp)
}
}))
defer s.Close()
c, _ := New(s.URL)
_, e := c.LoginWithOCR(context.Background(), fixedOCR("abcd"), "u", "S3cr3t-Do-Not-Leak", 3)
if (e == nil) != tc.wantOK {
t.Fatalf("err = %v, wantOK = %v", e, tc.wantOK)
}
if logins != tc.wantLogins {
t.Fatalf("login calls = %d, want %d", logins, tc.wantLogins)
}
if e != nil {
if !strings.Contains(e.Error(), tc.wantErrSubstr) {
t.Fatalf("err = %q, want it to contain %q", e, tc.wantErrSubstr)
}
if strings.Contains(e.Error(), "S3cr3t-Do-Not-Leak") || strings.Contains(e.Error(), "abcd") {
t.Fatalf("error leaks credentials or captcha text: %q", e)
}
}
})
}
}
func TestAPIErrorMapsExpiredLoginToSessionInvalid(t *testing.T) {
for msg, want := range map[string]bool{"token失效,请重新登录": true, "未登录": true, "登录已失效": true, "验证码错误": false, "用户名或密码错误": false} {
if got := errors.Is(&APIError{Code: 500, Message: msg}, ErrSessionInvalid); got != want {
t.Fatalf("%q: errors.Is(ErrSessionInvalid) = %v, want %v", msg, got, want)
}
}
if !errors.Is(&APIError{Code: 401}, ErrSessionInvalid) {
t.Fatal("code 401 must map to ErrSessionInvalid")
}
}
// Field types taken from the HAR list response (values are fake): quantity is
// a numeric string, ids are strings, many fields are null, and times are
// naive Beijing wall-clock strings.
func TestListDecodesWebClientResponseShape(t *testing.T) {
const page = `{"success":true,"code":200,"message":"操作成功!","result":{
"records":[{"id":"1000000000000000001","ordersn":"FAKE-ORDER-1","trackingNo":"FAKE-TRACK-1",
"shopId":"123","shopName":"示例店铺","wareCode":"W1","wareHouse":"A-01","wareName":"示例仓",
"status":"1","claimTime":"2026-09-21 14:13:29","createTime":"2026-09-21 14:13:29",
"updateTime":"2026-09-21 14:13:29","destroyDeadLine":"2026-10-21 14:13:29",
"airwayBill":null,"secondSaleOrdersn":null,"photoList":null,
"items":[{"id":null,"itemId":"48800000000","variationId":"435000000000","itemName":"示例商品",
"variationName":"黑色,L","image":"https://example.invalid/a.jpg","variationQuantityPurchased":"2",
"aneroidMarkup":false,"num":null},
{"id":null,"itemId":"48800000000","variationId":"435000000001","itemName":"示例商品",
"variationName":"黑色,XL","image":"","variationQuantityPurchased":null}]}],
"total":1,"size":20,"current":1,"pages":1,"searchCount":true}}`
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(page))
}))
defer s.Close()
c, _ := New(s.URL)
c.SetToken("opaque")
p, e := c.List(context.Background(), 1, 20)
if e != nil {
t.Fatalf("list: %v", e)
}
if len(p.Records) != 1 || len(p.Records[0].Items) != 2 || p.Total != 1 || p.Pages != 1 {
t.Fatalf("unexpected page: %+v", p)
}
if p.Records[0].Items[0].Quantity != 2 || p.Records[0].Items[1].Quantity != 0 {
t.Fatalf("quantities = %d, %d", p.Records[0].Items[0].Quantity, p.Records[0].Items[1].Quantity)
}
got := p.Records[0].CreateTime.Time.UTC().Format(time.RFC3339)
if got != "2026-09-21T06:13:29Z" {
t.Fatalf("createTime = %s, want Beijing 14:13:29 = 06:13:29Z", got)
}
}
+42
View File
@@ -0,0 +1,42 @@
package yeekeclient
import (
"context"
"errors"
"time"
)
type Credentials struct{ Username, Password string }
// Connect restores and validates a cached session. Only an explicit invalid
// response deletes it; timeouts and 5xx preserve the usable cache.
func Connect(ctx context.Context, store *SessionStore, creds Credentials, baseURL string, ocr OCR, maxLogin int) (*Client, error) {
c, e := New(baseURL)
if e != nil {
return nil, e
}
s, e := store.Load(ctx, creds.Username, time.Now().UTC())
if e == nil {
if e = c.ImportCookiesJSON(s.CookiesJSON); e == nil {
c.SetToken(s.Token)
if e = c.CheckSession(ctx); e == nil {
return c, nil
} else if errors.Is(e, ErrSessionInvalid) {
_ = store.Delete(ctx, creds.Username)
} else {
return nil, e
}
}
} else if !errors.Is(e, ErrNoSession) {
return nil, e
}
r, e := c.LoginWithOCR(ctx, ocr, creds.Username, creds.Password, maxLogin)
if e != nil {
return nil, e
}
cookies, _ := c.ExportCookiesJSON()
if e = store.Save(ctx, Session{Username: creds.Username, Token: r.Token, UserID: r.UserID, CookiesJSON: cookies, ExpiresAt: r.ExpiresAt}); e != nil {
return nil, e
}
return c, nil
}
@@ -0,0 +1,178 @@
package yeekeclient
import (
"context"
"encoding/json"
"go-admin/app/goauto/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func newDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&models.YeekeSession{}); err != nil {
t.Fatal(err)
}
return db
}
type stubOCR struct {
codes []string
calls int
}
func (s *stubOCR) Recognize(context.Context, []byte) (string, error) {
if s.calls >= len(s.codes) {
return "", nil
}
c := s.codes[s.calls]
s.calls++
return c, nil
}
// server builds a fake yeeke backend. loginOK controls whether /login accepts
// the submitted captcha; sessionValid controls whether /userInfo (used by
// CheckSession) reports the cached token as still good.
func fakeServer(t *testing.T, loginOK func(captcha string) bool, sessionValid func(token string) bool) *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=", "key": "k"}})
case "/agent-foreign/sys/login":
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
captcha, _ := body["captcha"].(string)
if loginOK(captcha) {
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"token": "tok-" + captcha, "userInfo": map[string]any{"id": "u1", "username": "u"}}})
return
}
json.NewEncoder(w).Encode(map[string]any{"success": false, "code": 1, "message": "验证码错误"})
case "/agent-foreign/shopee/user/info":
token := r.Header.Get("X-Access-Token")
if sessionValid(token) {
json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{}})
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{"success": false, "code": 401, "message": "登录已失效"})
default:
http.NotFound(w, r)
}
}))
}
// TestConnectLoginsOnceThenReusesSession: a fresh Connect performs exactly one
// login, and a second Connect call with the cached session valid performs no
// login at all (token reuse, no re-login when session valid).
func TestConnectLoginsOnceThenReusesSession(t *testing.T) {
db := newDB(t)
loginCalls := 0
srv := fakeServer(t,
func(captcha string) bool { loginCalls++; return captcha == "abcd" },
func(token string) bool { return token == "tok-abcd" },
)
defer srv.Close()
store := NewSessionStore(db)
ocr := &stubOCR{codes: []string{"abcd"}}
client, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, ocr, 3)
if err != nil {
t.Fatal(err)
}
if client.Token() != "tok-abcd" {
t.Fatalf("token=%q", client.Token())
}
if loginCalls != 1 {
t.Fatalf("loginCalls=%d, want 1", loginCalls)
}
// Second connect: session is cached and still valid, so this must not
// touch OCR or /login again.
ocr2 := &stubOCR{codes: []string{"should-not-be-used"}}
client2, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, ocr2, 3)
if err != nil {
t.Fatal(err)
}
if client2.Token() != "tok-abcd" {
t.Fatalf("reused token=%q", client2.Token())
}
if loginCalls != 1 {
t.Fatalf("loginCalls after reuse=%d, want still 1", loginCalls)
}
if ocr2.calls != 0 {
t.Fatalf("OCR must not be called when the cached session is valid")
}
}
// TestConnectReLoginsAfterSessionExpiredAndIsBounded: when the cached session
// is explicitly rejected (ErrSessionInvalid), Connect re-logs in — but only
// up to maxLogin captcha attempts, never looping forever.
func TestConnectReLoginsAfterSessionExpiredAndIsBounded(t *testing.T) {
db := newDB(t)
store := NewSessionStore(db)
// Seed an already-cached, not-yet-expired session so Connect's Load finds
// it and only CheckSession decides it is dead.
if err := store.Save(context.Background(), Session{
Username: "u", Token: "stale", CookiesJSON: `[]`, UserID: "u1",
ExpiresAt: time.Now().UTC().Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
loginAttempts := 0
srv := fakeServer(t,
func(captcha string) bool { loginAttempts++; return false }, // every captcha rejected
func(token string) bool { return false }, // cached session always invalid
)
defer srv.Close()
ocr := &stubOCR{codes: []string{"1", "2", "3", "4", "5", "6"}} // more codes than maxLogin allows
_, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, ocr, 3)
if err == nil {
t.Fatal("expected login failure")
}
if loginAttempts != 3 {
t.Fatalf("loginAttempts=%d, want exactly maxLogin=3 (bounded, not endless)", loginAttempts)
}
// The rejected cached session must have been deleted, not left in place.
if _, loadErr := store.Load(context.Background(), "u", time.Now().UTC()); loadErr != ErrNoSession {
t.Fatalf("expired/invalid session should have been deleted: %v", loadErr)
}
}
// TestConnectKeepsCachedSessionOnTimeoutOrServerError: a network-level error
// checking the session (not an explicit "invalid") must not discard a
// possibly-still-good cached session.
func TestConnectKeepsCachedSessionOnTimeoutOrServerError(t *testing.T) {
db := newDB(t)
store := NewSessionStore(db)
if err := store.Save(context.Background(), Session{
Username: "u", Token: "tok", CookiesJSON: `[]`, UserID: "u1",
ExpiresAt: time.Now().UTC().Add(time.Hour),
}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
_, err := Connect(context.Background(), store, Credentials{Username: "u", Password: "p"}, srv.URL, &stubOCR{}, 3)
if err == nil {
t.Fatal("expected a propagated 5xx error")
}
if _, loadErr := store.Load(context.Background(), "u", time.Now().UTC()); loadErr != nil {
t.Fatalf("a 5xx must not discard the cached session: %v", loadErr)
}
}
+37
View File
@@ -0,0 +1,37 @@
package yeekeclient
import (
"context"
"errors"
"go-admin/app/goauto/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"strings"
"time"
)
type SessionStore struct{ db *gorm.DB }
func NewSessionStore(db *gorm.DB) *SessionStore { return &SessionStore{db: db} }
func (s *SessionStore) Save(ctx context.Context, x Session) error {
if strings.TrimSpace(x.Username) == "" || x.Token == "" || x.ExpiresAt.IsZero() {
return errors.New("invalid yeeke session")
}
return s.db.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "username"}}, DoUpdates: clause.AssignmentColumns([]string{"token", "cookies_json", "user_id", "expires_at", "updated_at"})}).Create(&models.YeekeSession{Username: strings.TrimSpace(x.Username), Token: x.Token, CookiesJSON: x.CookiesJSON, UserID: x.UserID, ExpiresAt: x.ExpiresAt.UTC()}).Error
}
func (s *SessionStore) Load(ctx context.Context, user string, now time.Time) (Session, error) {
var r models.YeekeSession
if e := s.db.WithContext(ctx).Where("username = ?", strings.TrimSpace(user)).First(&r).Error; e != nil {
if errors.Is(e, gorm.ErrRecordNotFound) {
return Session{}, ErrNoSession
}
return Session{}, e
}
if !now.UTC().Before(r.ExpiresAt) {
return Session{}, ErrNoSession
}
return Session{Username: r.Username, Token: r.Token, CookiesJSON: r.CookiesJSON, UserID: r.UserID, ExpiresAt: r.ExpiresAt}, nil
}
func (s *SessionStore) Delete(ctx context.Context, user string) error {
return s.db.WithContext(ctx).Where("username = ?", strings.TrimSpace(user)).Delete(&models.YeekeSession{}).Error
}
+2
View File
@@ -6,6 +6,7 @@ import (
"go-admin/app/goauto/shopeeproduct"
"go-admin/app/goauto/sybimport"
"go-admin/app/goauto/yeeke"
)
// InitJob
@@ -17,6 +18,7 @@ func InitJob() {
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
sybimport.SpecAIParseInvokeTarget: sybimport.ScheduledSpecAIParseJob{},
shopeeproduct.SpecAutoMatchInvokeTarget: shopeeproduct.ScheduledAutoMatchJob{},
yeeke.ReturnSyncInvokeTarget: yeeke.ReturnSyncJob{},
// ...
}
}
@@ -0,0 +1,22 @@
package version_local
import (
"go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
"runtime"
)
func init() {
_, f, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(f), migrateYeekeReturnSync)
}
func migrateYeekeReturnSync(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := migrations.Migrate(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,49 @@
package version_local
import (
"errors"
"runtime"
"go-admin/app/goauto/yeeke"
jobsmodels "go-admin/app/jobs/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateYeekeReturnSyncJob)
}
// migrateYeekeReturnSyncJob seeds the scheduled yeeke return sync job row,
// disabled by default (#336 requirement: 默认关闭定时任务,管理员手动开启).
// It follows 1786701600000_syb_hourly_sync_job.go exactly: Status 2 keeps the
// row out of the running cron set (see app/jobs/service/sys_job.go, which
// only adds Status == 1 jobs to the cron at startup).
func migrateYeekeReturnSyncJob(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := ensureYeekeReturnSyncJob(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func ensureYeekeReturnSyncJob(db *gorm.DB) error {
var existing jobsmodels.SysJob
err := db.Where("invoke_target = ?", yeeke.ReturnSyncInvokeTarget).First(&existing).Error
if err == nil {
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return db.Create(&jobsmodels.SysJob{
JobName: "yeeke 退货包裹同步", JobGroup: "GoAuto", JobType: 2,
CronExpression: "0 20 * * * *", InvokeTarget: yeeke.ReturnSyncInvokeTarget,
Args: "",
MisfirePolicy: 1, Concurrent: 1, Status: 2,
}).Error
}
@@ -0,0 +1,90 @@
package version_local
import (
"fmt"
"runtime"
"go-admin/app/goauto/access"
"go-admin/cmd/migrate/migration"
migrationmodels "go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, file, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(file), migrateYeekeAdminMenu)
}
// migrateYeekeAdminMenu adds the 「yeeke 退货包裹」 and 「yeeke 同步记录」 menu
// entries for #337. #336 registered access.ModuleYeekeReturns and shipped the
// sync backend, but never wrote its sys_menu row or web page, so Admin had no
// entry point; #337 also splits the sync-run history into its own module
// (access.ModuleYeekeSyncRuns), matching the existing SYB 商品 / SYB 同步记录
// split.
//
// `[必须]` Adding a module to access.GoAutoModules() is not enough to make its
// page reachable. Menus are written by the versioned migration that first
// created them, and that version is already recorded in sys_migration on every
// existing database — it never runs again. Startup only reconciles the casbin
// API matrix (access.ReconcilePurchaserPermissions), not sys_menu. Without this
// migration the pages exist, the APIs answer and the routes resolve, yet
// nobody can navigate to them on an already-migrated database. This mirrors
// 1789113000000_syb_product_filter_menu.go exactly.
func migrateYeekeAdminMenu(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
// Both pages belong beside the SYB modules under 采集采购, where the
// admin already manages inbound collection/purchase data sources.
var parent migrationmodels.SysMenu
if err := tx.Where("menu_name = ?", "GoAutoCollectionPurchase").First(&parent).Error; err != nil {
return err
}
for _, key := range []string{access.ModuleYeekeReturns, access.ModuleYeekeSyncRuns} {
module, ok := goAutoModuleByKey(key)
if !ok {
return fmt.Errorf("module %q is not registered in access.GoAutoModules()", key)
}
child, _, err := upsertGoAutoMenu(tx, migrationmodels.SysMenu{
MenuName: module.RouteName,
Title: module.Title,
Icon: module.Icon,
Path: module.Path + "/index",
MenuType: "C",
Action: "无",
ParentId: parent.MenuId,
Component: module.Component,
Sort: module.Sort,
Visible: "0",
IsFrame: "1",
})
if err != nil {
return err
}
if err = tx.Model(&child).Update("paths", fmt.Sprintf("/0/%d/%d", parent.MenuId, child.MenuId)).Error; err != nil {
return err
}
// Bind to both roles the module declares. 采购员 needs it because the
// module is PurchaserDefault; 管理员 needs it so it can always see and
// manually trigger the read-only sync surface.
roleKeys := []string{"admin"}
if module.PurchaserDefault {
roleKeys = append(roleKeys, access.RolePurchaser)
}
for _, roleKey := range roleKeys {
var role migrationmodels.SysRole
if err = tx.Where("role_key = ?", roleKey).First(&role).Error; err != nil {
return err
}
if err = tx.Model(&role).Association("SysMenu").Append(&child); err != nil {
return err
}
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,25 @@
package version_local
import (
"go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
"runtime"
)
// #338: creates return_match and return_match_log (registered in
// migrations.MigratedModels) on databases whose earlier versions are already
// recorded in sys_migration.
func init() {
_, f, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMatch)
}
func migrateReturnMatch(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := migrations.Migrate(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,25 @@
package version_local
import (
"go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
"runtime"
)
// #338: creates return_match_batch (registered in
// migrations.MigratedModels) on databases whose earlier versions are already
// recorded in sys_migration.
func init() {
_, f, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMatchBatch)
}
func migrateReturnMatchBatch(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := migrations.Migrate(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,28 @@
package version_local
import (
"go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
"runtime"
)
// #338 scope addition: adds the nullable missing_since column to
// yeeke_return_package and yeeke_return_item (models.YeekeReturnPackage /
// models.YeekeReturnItem, registered in migrations.MigratedModels) on
// databases whose earlier versions are already recorded in sys_migration.
// AutoMigrate alone never reaches an existing database without a version
// file like this one being run by the migrate command.
func init() {
_, f, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(f), migrateReturnMissing)
}
func migrateReturnMissing(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := migrations.Migrate(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,25 @@
package version_local
import (
"go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
"runtime"
)
// #338: adds yeeke_sync_run.missing_marked_count and recovered_count (registered in
// migrations.MigratedModels) on databases whose earlier versions are already
// recorded in sys_migration.
func init() {
_, f, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(f), migrateYeekeSyncRunMissingCounts)
}
func migrateYeekeSyncRunMissingCounts(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := migrations.Migrate(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
+69 -2
View File
@@ -18,8 +18,9 @@ var ExtConfig Extend
//
// 使用方法: config.ExtConfig......即可!!
type Extend struct {
AMap AMap // 这里配置对应配置文件的结构即可
SYB SYB
AMap AMap // 这里配置对应配置文件的结构即可
SYB SYB
Yeeke Yeeke
}
type AMap struct {
@@ -89,6 +90,65 @@ func (s SYB) HasCredentials() bool {
return strings.TrimSpace(s.Username) != "" && s.Password != ""
}
// Yeeke holds the mmt.yeeke.com 退货包裹只读对接 connection settings (#336).
//
// `[必须]` Username and Password are NOT read from settings.yml — same rule as
// SYB above — they come from GOAUTO_YEEKE_USERNAME / GOAUTO_YEEKE_PASSWORD (or
// config.yaml's yeeke: section) so no credential ever lands in a tracked file.
type Yeeke struct {
BaseURL string
Username string
Password string
PageSize int
MaxPages int
Retry int
// OcrURL is the captcha recognition service shared with SYB (#336, approved
// 2026-09-23). Empty disables OCR; there is no manual-entry fallback here
// because this is a server-side scheduled/triggered flow, not an interactive
// login form, so a disabled OCR simply makes sync fail with a clear error.
OcrURL string
OcrMaxAttempts int
}
// YeekeDefaults are the values used when settings.yml leaves a field blank.
const (
DefaultYeekeBaseURL = "https://mmt.yeeke.com"
DefaultYeekePageSize = 100
DefaultYeekeMaxPages = 10000
DefaultYeekeRetry = 2
DefaultYeekeOcrMaxAttempts = 5
)
// Resolved returns the Yeeke settings with blanks replaced by defaults. It
// never defaults Username or Password: missing credentials must surface as an
// error at the call site, not as an attempt to log in as nobody.
func (y Yeeke) Resolved() Yeeke {
if strings.TrimSpace(y.BaseURL) == "" {
y.BaseURL = DefaultYeekeBaseURL
}
if y.PageSize <= 0 {
y.PageSize = DefaultYeekePageSize
}
if y.MaxPages <= 0 {
y.MaxPages = DefaultYeekeMaxPages
}
if y.Retry < 0 {
y.Retry = DefaultYeekeRetry
}
if y.OcrMaxAttempts <= 0 {
y.OcrMaxAttempts = DefaultYeekeOcrMaxAttempts
}
y.Username = strings.TrimSpace(y.Username)
y.BaseURL = strings.TrimRight(strings.TrimSpace(y.BaseURL), "/")
y.OcrURL = strings.TrimSpace(y.OcrURL)
return y
}
// HasCredentials reports whether both account fields were supplied.
func (y Yeeke) HasCredentials() bool {
return strings.TrimSpace(y.Username) != "" && y.Password != ""
}
// ApplyEnvironment replaces tracked defaults with process-local runtime values.
// Credentials stay outside tracked configuration files. GOAUTO_DB_DRIVER
// defaults to mysql when GOAUTO_DB_DSN is present.
@@ -104,6 +164,13 @@ func ApplyEnvironment() {
if password := os.Getenv("GOAUTO_SYB_PASSWORD"); password != "" {
ExtConfig.SYB.Password = password
}
if username := strings.TrimSpace(os.Getenv("GOAUTO_YEEKE_USERNAME")); username != "" {
ExtConfig.Yeeke.Username = username
}
// `[必须]` Taken verbatim, same reasoning as GOAUTO_SYB_PASSWORD above.
if password := os.Getenv("GOAUTO_YEEKE_PASSWORD"); password != "" {
ExtConfig.Yeeke.Password = password
}
dsn := strings.TrimSpace(os.Getenv("GOAUTO_DB_DSN"))
if dsn == "" {
+40
View File
@@ -62,5 +62,45 @@ func TestSettingsFilesCarryNoSYBCredentials(t *testing.T) {
if file.Settings.Extend.SYB.Username != "" || file.Settings.Extend.SYB.Password != "" {
t.Fatalf("%s 里出现了顺云宝凭据,凭据必须走环境变量", name)
}
if file.Settings.Extend.Yeeke.Username != "" || file.Settings.Extend.Yeeke.Password != "" {
t.Fatalf("%s 里出现了 yeeke 凭据,凭据必须走环境变量 (#336)", name)
}
}
}
// settings.yml 里的 extend.yeeke 必须真的能绑进 ExtConfig.Yeeke,同样的道理
// 见上面 TestSYBSettingsInRepoBindToExtendStruct 的注释 (#336)。
func TestYeekeSettingsInRepoBindToExtendStruct(t *testing.T) {
raw, err := os.ReadFile("settings.yml")
if err != nil {
t.Fatalf("读取 settings.yml 失败: %v", err)
}
var file struct {
Settings struct {
Extend Extend `yaml:"extend"`
} `yaml:"settings"`
}
if err := yaml.Unmarshal(raw, &file); err != nil {
t.Fatalf("解析 settings.yml 失败: %v", err)
}
yeeke := file.Settings.Extend.Yeeke
if yeeke.BaseURL != "https://mmt.yeeke.com" {
t.Fatalf("baseurl 没有绑定成功: %q", yeeke.BaseURL)
}
if yeeke.PageSize != 100 {
t.Fatalf("pagesize 没有绑定成功: %d", yeeke.PageSize)
}
if yeeke.MaxPages != 10000 {
t.Fatalf("maxpages 没有绑定成功: %d", yeeke.MaxPages)
}
if yeeke.Retry != 2 {
t.Fatalf("retry 没有绑定成功: %d", yeeke.Retry)
}
if yeeke.OcrURL == "" {
t.Fatal("ocrurl 没有绑定成功")
}
if yeeke.OcrMaxAttempts != 5 {
t.Fatalf("ocrmaxattempts 没有绑定成功: %d", yeeke.OcrMaxAttempts)
}
}
+55
View File
@@ -74,6 +74,61 @@ func TestApplyEnvironmentLoadsSYBCredentials(t *testing.T) {
}
}
// 同上,yeeke 凭据也只能来自环境变量 (#336)。
func TestApplyEnvironmentLoadsYeekeCredentials(t *testing.T) {
original := ExtConfig.Yeeke
t.Cleanup(func() { ExtConfig.Yeeke = original })
t.Setenv("GOAUTO_YEEKE_USERNAME", " operator ")
t.Setenv("GOAUTO_YEEKE_PASSWORD", " se cret ")
ApplyEnvironment()
if ExtConfig.Yeeke.Username != "operator" {
t.Fatalf("账号应去掉首尾空白: %q", ExtConfig.Yeeke.Username)
}
if ExtConfig.Yeeke.Password != " se cret " {
t.Fatalf("密码不应被修改: %q", ExtConfig.Yeeke.Password)
}
}
func TestYeekeResolvedFillsBlanksButNeverInventsCredentials(t *testing.T) {
resolved := Yeeke{}.Resolved()
if resolved.BaseURL != DefaultYeekeBaseURL {
t.Fatalf("BaseURL 默认值不对: %q", resolved.BaseURL)
}
if resolved.PageSize != DefaultYeekePageSize || resolved.MaxPages != DefaultYeekeMaxPages {
t.Fatalf("分页默认值不对: %+v", resolved)
}
if resolved.OcrMaxAttempts != DefaultYeekeOcrMaxAttempts {
t.Fatalf("OCR 重试次数默认值不对: %d", resolved.OcrMaxAttempts)
}
if resolved.Username != "" || resolved.Password != "" {
t.Fatal("Resolved 不得给账号密码编造默认值")
}
if resolved.OcrURL != "" {
t.Fatalf("空 OcrURL 不应被填充: %q", resolved.OcrURL)
}
}
func TestYeekeHasCredentialsRequiresBothFields(t *testing.T) {
for _, c := range []struct {
name string
yeeke Yeeke
want bool
}{
{"都有", Yeeke{Username: "a", Password: "b"}, true},
{"缺密码", Yeeke{Username: "a"}, false},
{"缺账号", Yeeke{Password: "b"}, false},
{"账号只有空白", Yeeke{Username: " ", Password: "b"}, false},
{"都没有", Yeeke{}, false},
} {
if got := c.yeeke.HasCredentials(); got != c.want {
t.Fatalf("%s: 期望 %v,实际 %v", c.name, c.want, got)
}
}
}
func TestSYBResolvedFillsBlanksButNeverInventsCredentials(t *testing.T) {
resolved := SYB{}.Resolved()
+42
View File
@@ -65,6 +65,7 @@ type localFile struct {
Database map[string]any `yaml:"database"`
Ports map[string]any `yaml:"ports"`
SYB map[string]any `yaml:"syb"`
Yeeke map[string]any `yaml:"yeeke"`
}
// ApplyLocalConfig loads config.yaml, if one is present, over the values
@@ -96,6 +97,7 @@ func ApplyLocalConfig() {
applyLocalDatabase(file.Database)
applyLocalPorts(file.Ports)
ApplyLocalSYB(file.SYB)
ApplyLocalYeeke(file.Yeeke)
logInfo("本地配置:已加载 %s", path)
}
@@ -115,6 +117,17 @@ func LogEffectiveConfig() {
}
logInfo("顺云宝配置:凭据=%v(来源:%s)base_url=%s 验证码识别=%v",
syb.HasCredentials(), source, syb.BaseURL, syb.OcrURL != "")
yeeke := ExtConfig.Yeeke.Resolved()
yeekeSource := "未配置"
switch {
case strings.TrimSpace(os.Getenv("GOAUTO_YEEKE_USERNAME")) != "":
yeekeSource = "环境变量"
case yeeke.HasCredentials():
yeekeSource = LocalConfigName
}
logInfo("yeeke 配置:凭据=%v(来源:%s)base_url=%s 验证码识别=%v",
yeeke.HasCredentials(), yeekeSource, yeeke.BaseURL, yeeke.OcrURL != "")
}
func applyLocalDatabase(database map[string]any) {
@@ -177,6 +190,35 @@ func ApplyLocalSYB(syb map[string]any) {
}
}
// ApplyLocalYeeke folds a config.yaml `yeeke:` section into ExtConfig, mirroring
// ApplyLocalSYB above (#336).
func ApplyLocalYeeke(yeeke map[string]any) {
if username := strings.TrimSpace(scalar(yeeke, "username")); username != "" {
ExtConfig.Yeeke.Username = username
}
if password := scalar(yeeke, "password"); password != "" {
ExtConfig.Yeeke.Password = password
}
if baseURL := strings.TrimSpace(scalar(yeeke, "base_url")); baseURL != "" {
ExtConfig.Yeeke.BaseURL = baseURL
}
if ocrURL := strings.TrimSpace(scalar(yeeke, "ocr_url")); ocrURL != "" {
ExtConfig.Yeeke.OcrURL = ocrURL
}
if pageSize, err := strconv.Atoi(scalar(yeeke, "page_size")); err == nil && pageSize > 0 {
ExtConfig.Yeeke.PageSize = pageSize
}
if maxPages, err := strconv.Atoi(scalar(yeeke, "max_pages")); err == nil && maxPages > 0 {
ExtConfig.Yeeke.MaxPages = maxPages
}
if retry, err := strconv.Atoi(scalar(yeeke, "retry")); err == nil && retry >= 0 {
ExtConfig.Yeeke.Retry = retry
}
if attempts, err := strconv.Atoi(scalar(yeeke, "ocr_max_attempts")); err == nil && attempts > 0 {
ExtConfig.Yeeke.OcrMaxAttempts = attempts
}
}
// logInfo writes an informational startup line to stdout.
//
// `[必须]` Not stderr. The development launcher pipes the server through
+11
View File
@@ -61,6 +61,17 @@ settings:
# `[必须]` 验证码图片会被发送到这个地址;换成别人运营的服务前要重新评估。
ocrurl: https://ocr.ilapage.cn/ocr
ocrmaxattempts: 5
# yeeke(mmt.yeeke.com)退货包裹只读对接,见 #336。
# `[必须]` 账号密码不在这里,走环境变量 GOAUTO_YEEKE_USERNAME / GOAUTO_YEEKE_PASSWORD,
# 以免凭据进 Git。
yeeke:
baseurl: https://mmt.yeeke.com
pagesize: 100
maxpages: 10000
retry: 2
# 验证码识别服务,与 SYB 共用(#336 已批准)。
ocrurl: https://ocr.ilapage.cn/ocr
ocrmaxattempts: 5
cache:
# redis:
# addr: 127.0.0.1:6379
+25
View File
@@ -0,0 +1,25 @@
import request from '@/utils/request'
export function listReturnMatches(params, options = {}) {
return request({ url: '/api/admin/v1/return-matches', method: 'get', params, ...options })
}
export function getReturnMatch(matchId, options = {}) {
return request({ url: `/api/admin/v1/return-matches/${matchId}`, method: 'get', ...options })
}
export function batchMatchReturns(data) {
return request({ url: '/api/admin/v1/return-matches/batch-match', method: 'post', data })
}
export function confirmReturnMatch(matchId) {
return request({ url: `/api/admin/v1/return-matches/${matchId}/confirm`, method: 'post' })
}
export function cancelReturnMatch(matchId) {
return request({ url: `/api/admin/v1/return-matches/${matchId}/cancel`, method: 'post' })
}
export function remarkReturnMatch(matchId, data) {
return request({ url: `/api/admin/v1/return-matches/${matchId}/remark`, method: 'post', data })
}
+29
View File
@@ -0,0 +1,29 @@
import request from '@/utils/request'
export function listYeekeReturnPackages(params, options = {}) {
return request({ url: '/api/admin/v1/yeeke-returns', method: 'get', params, ...options })
}
export function getYeekeReturnPackage(packageId) {
return request({ url: `/api/admin/v1/yeeke-returns/${packageId}`, method: 'get' })
}
export function listYeekeReturnShops(options = {}) {
return request({ url: '/api/admin/v1/yeeke-returns/shops', method: 'get', ...options })
}
export function getYeekeReturnItem(itemId) {
return request({ url: `/api/admin/v1/yeeke-returns/items/${itemId}`, method: 'get' })
}
export function listYeekeSyncRuns(params, options = {}) {
return request({ url: '/api/admin/v1/yeeke-returns/sync-runs', method: 'get', params, ...options })
}
export function getYeekeSyncRun(runId) {
return request({ url: `/api/admin/v1/yeeke-returns/sync-runs/${runId}`, method: 'get' })
}
export function triggerYeekeSync() {
return request({ url: '/api/admin/v1/yeeke-returns/sync', method: 'post' })
}
+258 -12
View File
@@ -12,7 +12,7 @@
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-button v-if="canPurchase" type="success" plain :disabled="imageSearchRows.length === 0" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></el-form-item>
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0 || aiMatchButtonOverLimit" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="imageSearchButtonReason" :disabled="!imageSearchButtonReason" placement="top"><span class="action-button-wrap"><el-button type="success" plain :disabled="imageSearchRows.length === 0 || imageSearchButtonOverLimit" @click="openImageSearchBatch">图搜采集<span class="action-count">{{ imageSearchRows.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="collectionButtonReason" :disabled="!collectionButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0 || collectionButtonOverLimit" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="purchaseButtonReason" :disabled="!purchaseButtonReason" placement="top"><span class="action-button-wrap"><el-button type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0 || purchaseButtonOverLimit" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button></span></el-tooltip><el-tooltip v-if="canPurchase" :content="returnMatchButtonReason" :disabled="!returnMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="returnMatchBatchLoading" :disabled="purchaseReadinessLoading || returnMatchBatchLoading || returnMatchCandidateIds.length === 0 || returnMatchButtonOverLimit" @click="runBatchMatchReturns">匹配退货<span class="action-count">{{ returnMatchCandidateIds.length }}</span></el-button></span></el-tooltip></el-form-item>
</el-form>
<el-alert v-if="canPurchase" title="先完成并保存规格匹配,商品才可以创建采购;AI 匹配只处理已解析规格、已关联 PDD 且具备完整可售 SKU 组合的明细。表头全选仅作用于当前页。" type="info" :closable="false" show-icon class="notice compact-notice" />
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
@@ -51,9 +51,30 @@
<div v-if="quickBusyId === row.id || quickMessages[row.id]" class="muted" aria-live="polite">{{ quickBusyId === row.id ? quickPhase : quickMessages[row.id] }}</div>
</template>
</el-table-column>
<el-table-column label="匹配到的退货商品" min-width="200">
<template #default="{ row }">
<template v-if="returnMatchByProductId[row.id]">
<div class="return-match-cell">
<el-image v-if="returnMatchByProductId[row.id].image" :src="returnMatchByProductId[row.id].image" fit="cover" class="thumb" preview-teleported />
<div>
<div>{{ returnMatchByProductId[row.id].orderSn || '—' }}</div>
<div class="muted">{{ returnMatchByProductId[row.id].variationName || '—' }}</div>
<div class="muted">销毁截止:{{ formatMatchDeadline(returnMatchByProductId[row.id].destroyDeadline) }}</div>
</div>
</div>
<el-alert v-if="returnMatchByProductId[row.id].syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="return-missing-alert" />
<div class="quick-link-actions">
<el-button type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id)">查看对比</el-button>
<el-button v-if="canPurchase" type="primary" link @click="openMatchDetail(returnMatchByProductId[row.id].id, true)">备注</el-button>
<el-button v-if="canPurchase" type="danger" link @click="quickCancelMatch(returnMatchByProductId[row.id].id)">取消匹配</el-button>
</div>
</template>
<span v-else class="muted">未匹配</span>
</template>
</el-table-column>
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && isPurchaseCandidate(row)" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :page-sizes="[20, 50, 100, 200, 500]" :total="total" @pagination="load" />
</el-card>
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
@@ -162,6 +183,81 @@
<template #footer><el-button type="primary" @click="specMatchResult.open = false">关闭</el-button></template>
</el-dialog>
<!-- 匹配退货批量结果 -->
<el-dialog v-model="returnMatchBatchResult.open" title="批量匹配退货结果" width="820px">
<div class="purchase-summary" aria-live="polite">
<span class="success-text">成功匹配 <strong>{{ returnMatchBatchResult.matchedCount }}</strong> 条</span>
<span>无候选 <strong>{{ returnMatchBatchResult.noCandidateCount }}</strong> 条</span>
<span>阶段不参与跳过 <strong>{{ returnMatchBatchResult.stageIneligibleCount }}</strong> 条</span>
<span v-if="returnMatchBatchResult.conflictCount" class="danger-text">并发冲突跳过 <strong>{{ returnMatchBatchResult.conflictCount }}</strong> 条</span>
</div>
<el-table :data="returnMatchBatchResult.items" border size="small" max-height="420" empty-text="没有匹配结果">
<el-table-column label="SYB 商品 ID" prop="sybProductId" width="120" />
<el-table-column label="结果" width="100"><template #default="{ row }"><el-tag :type="row.matched ? 'success' : 'warning'">{{ row.matched ? '已匹配' : '跳过' }}</el-tag></template></el-table-column>
<el-table-column label="原因" min-width="260"><template #default="{ row }">{{ row.matched ? '—' : (row.reason || matchReasonMeta(row.reasonCode)) }}</template></el-table-column>
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button v-if="row.matched && row.matchId" type="primary" link @click="openMatchDetail(row.matchId)">查看对比</el-button></template></el-table-column>
</el-table>
<template #footer><el-button type="primary" @click="returnMatchBatchResult.open = false">关闭</el-button></template>
</el-dialog>
<!-- 匹配详情对比 -->
<el-dialog v-model="matchDetail.open" title="退货匹配详情" width="900px" :close-on-click-modal="false">
<div v-loading="matchDetail.loading">
<template v-if="matchDetail.data">
<el-alert :title="'当前状态:' + matchStatusLabel(matchDetail.data.match.status)" :type="matchDetail.data.match.status === 'confirmed' ? 'success' : matchDetail.data.match.status === 'cancelled' ? 'info' : 'warning'" :closable="false" show-icon class="notice" />
<div class="split">
<div class="split-col">
<h3 class="section-title">SYB 商品</h3>
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.syb">
<el-descriptions-item label="订单号">{{ matchDetail.data.syb.orderCode }}</el-descriptions-item>
<el-descriptions-item label="虾皮商品ID">{{ matchDetail.data.syb.shopeeItemId }}</el-descriptions-item>
<el-descriptions-item label="店铺">{{ matchDetail.data.syb.shopName }}</el-descriptions-item>
<el-descriptions-item label="标题">{{ matchDetail.data.syb.productTitle }}</el-descriptions-item>
<el-descriptions-item label="目标颜色/尺码">{{ matchDetail.data.syb.targetColor || '—' }} / {{ matchDetail.data.syb.targetSize || '—' }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ matchDetail.data.syb.quantity }}</el-descriptions-item>
<el-descriptions-item label="当前阶段"><el-tag :type="processMeta(matchDetail.data.syb.currentStage).type">{{ matchDetail.data.syb.currentStageLabel }}</el-tag></el-descriptions-item>
<el-descriptions-item label="归一化规格文字">{{ matchDetail.data.normalizedSybSpec }}</el-descriptions-item>
</el-descriptions>
<p v-else class="muted">SYB 商品不存在或已删除</p>
</div>
<div class="split-col">
<h3 class="section-title">yeeke 退货商品</h3>
<el-alert v-if="matchDetail.data.yeeke && matchDetail.data.yeeke.syncStatus === 'missing'" title="退货已不在 yeeke 列表" type="error" :closable="false" show-icon class="notice" />
<el-descriptions :column="1" border size="small" v-if="matchDetail.data.yeeke">
<el-descriptions-item label="退货订单号">{{ matchDetail.data.yeeke.orderSn }}</el-descriptions-item>
<el-descriptions-item label="商品ID">{{ matchDetail.data.yeeke.itemId }}</el-descriptions-item>
<el-descriptions-item label="规格ID">{{ matchDetail.data.yeeke.variationId }}</el-descriptions-item>
<el-descriptions-item label="店铺">{{ matchDetail.data.yeeke.shopName }}</el-descriptions-item>
<el-descriptions-item label="商品名">{{ matchDetail.data.yeeke.itemName }}</el-descriptions-item>
<el-descriptions-item label="规格文字">{{ matchDetail.data.yeeke.variationName }}</el-descriptions-item>
<el-descriptions-item label="数量">{{ matchDetail.data.yeeke.quantity }}</el-descriptions-item>
<el-descriptions-item label="销毁截止">{{ formatMatchDeadline(matchDetail.data.yeeke.destroyDeadline) }}</el-descriptions-item>
<el-descriptions-item label="归一化规格文字">{{ matchDetail.data.normalizedYeekeSpec }}</el-descriptions-item>
</el-descriptions>
<p v-else class="muted">退货商品不存在或已删除</p>
</div>
</div>
<h3 class="section-title">备注</h3>
<el-input ref="matchRemarkInput" v-model="matchDetail.remarkText" type="textarea" :rows="2" maxlength="500" show-word-limit :disabled="!canPurchase" />
<div class="drawer-actions">
<el-button v-if="canPurchase" :loading="matchDetail.saving" @click="saveMatchRemark">保存备注</el-button>
</div>
<h3 class="section-title">操作日志</h3>
<el-table :data="matchDetail.data.logs" border size="small" max-height="220" empty-text="暂无日志">
<el-table-column label="时间" width="170"><template #default="{ row }">{{ formatMatchDeadline(row.createdAt) }}</template></el-table-column>
<el-table-column label="动作" width="90" prop="action" />
<el-table-column label="操作人" width="100" prop="operator" />
<el-table-column label="详情" min-width="200" prop="detail" />
</el-table>
</template>
</div>
<template #footer>
<el-button @click="matchDetail.open = false">关闭</el-button>
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status === 'matched'" type="success" @click="confirmMatchDetail">确认</el-button>
<el-button v-if="canPurchase && matchDetail.data && matchDetail.data.match.status !== 'cancelled'" type="danger" @click="cancelMatchDetail">取消匹配</el-button>
</template>
</el-dialog>
<!-- 详情 -->
<el-drawer v-model="detail.open" title="SYB 商品明细详情" size="960px">
<div v-loading="detail.loading" class="drawer-body">
@@ -227,6 +323,7 @@ import { listDevices } from '@/api/goauto/devices'
import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
import { batchCreateCollectionTasks, batchCreateImageSearchCollectionTasks } from '@/api/goauto/collection-tasks'
import { listCollectionRules } from '@/api/goauto/collection-rules'
import { listReturnMatches, batchMatchReturns, getReturnMatch, confirmReturnMatch, cancelReturnMatch, remarkReturnMatch } from '@/api/goauto/return-matches'
import { createRequestId } from '@/utils/request-id'
import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference'
import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue'
@@ -234,6 +331,12 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
// 与服务端 image_search.go 的 imageSearchMaxBatchTasks 保持一致(单批去重后任务数上限,#280)。
const IMAGE_SEARCH_MAX_BATCH_TASKS = 50
// #339: 各批量按钮的服务端上限,用于勾选数超限时禁用按钮并提示原因。
// 创建采购 / AI 匹配:purchase/batch.go maxBatchPurchaseItems;创建采集(去重后 PDD 商品数):
// task/admin_service.go BatchCreate 的 100 上限;匹配退货:returnmatch/service.go maxBatchMatchItems。
const PURCHASE_BATCH_MAX = 100
const COLLECTION_BATCH_MAX = 100
const RETURN_MATCH_BATCH_MAX = 500
export default {
name: 'GoAutoSybProducts',
@@ -254,7 +357,12 @@ export default {
imageSearchBatch: this.emptyImageSearchBatch(),
collectionBatchData: { ruleId: null, deviceId: null },
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
query: { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
query: { page: 1, pageSize: 100, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' },
returnMatchByProductId: {},
returnMatchLoading: false,
returnMatchBatchLoading: false,
returnMatchBatchResult: { open: false, items: [], matchedCount: 0, noCandidateCount: 0, stageIneligibleCount: 0, conflictCount: 0 },
matchDetail: { open: false, loading: false, saving: false, id: null, data: null, remarkText: '' },
detail: { open: false, loading: false, item: null },
correct: { open: false, saving: false, productId: null, targetColor: '', targetSize: '' },
shopeeDetail: { open: false, productId: null, targetColor: '', action: '' },
@@ -265,22 +373,37 @@ export default {
purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
purchaseImageSearchLinkedCount() { return this.purchaseDialog.items.filter(item => item.imageSearchLinked).length },
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }] },
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }, { value: 'return_pending', label: '退货待确认' }, { value: 'return_used', label: '已用退货' }] },
returnMatchCandidateIds() { return this.selectedProducts.filter(row => this.isReturnMatchCandidate(row)).map(row => row.id) },
aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) },
aiMatchButtonOverLimit() { return this.aiMatchCandidates.length > PURCHASE_BATCH_MAX },
aiMatchButtonReason() {
if (this.purchaseReadinessLoading) return '正在检查 AI 匹配资格'
if (!this.selectedProducts.length) return '请先勾选当前页中可处理的明细'
if (this.aiMatchButtonOverLimit) return `一次最多 ${PURCHASE_BATCH_MAX} 条`
if (this.aiMatchCandidates.length) return ''
const reasons = [...new Set(this.selectedProducts.map(row => this.purchaseReady(row).aiMatchDisabledReason).filter(Boolean))]
return reasons[0] || '所选明细不满足 AI 匹配前提'
},
purchaseCandidates() { return this.selectedProducts.filter(row => this.isPurchaseCandidate(row)) },
// #339: 创建采购上限保持 100(服务端 maxBatchPurchaseItems 不放宽),超出勾选数时禁用并提示。
purchaseButtonOverLimit() { return this.purchaseCandidates.length > PURCHASE_BATCH_MAX },
purchaseButtonReason() { return this.purchaseButtonOverLimit ? `一次最多 ${PURCHASE_BATCH_MAX} 条` : '' },
collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) },
collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] },
// #339: 创建采集按去重后的 PDD 商品数计数,与服务端 task.BatchCreate 的 100 上限口径一致。
collectionButtonOverLimit() { return this.collectionCandidates.length > COLLECTION_BATCH_MAX },
collectionButtonReason() { return this.collectionButtonOverLimit ? `一次最多 ${COLLECTION_BATCH_MAX} 个 PDD 商品` : '' },
imageSearchRows() { return this.selectedProducts.filter(row => row.shopeeProductId && row.imageUrl) },
// 服务端按去重后的蝦皮商品数创建任务(见 image_search.go 的 imageSearchMaxBatchTasks),
// 这里用相同口径预估,避免提交后才发现超限。
imageSearchMaxBatchTasks() { return IMAGE_SEARCH_MAX_BATCH_TASKS },
imageSearchButtonTaskCount() { return new Set(this.imageSearchRows.map(row => row.shopeeProductId)).size },
imageSearchButtonOverLimit() { return this.imageSearchButtonTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
imageSearchButtonReason() { return this.imageSearchButtonOverLimit ? `一次最多 ${IMAGE_SEARCH_MAX_BATCH_TASKS} 个蝦皮商品` : '' },
// #339: 匹配退货服务端上限(returnmatch.maxBatchMatchItems),超出直接勾选数即禁用。
returnMatchButtonOverLimit() { return this.returnMatchCandidateIds.length > RETURN_MATCH_BATCH_MAX },
returnMatchButtonReason() { return this.returnMatchButtonOverLimit ? `一次最多 ${RETURN_MATCH_BATCH_MAX} 条` : '' },
imageSearchBatchTaskCount() { return new Set(this.imageSearchBatch.rows.map(row => row.shopeeProductId)).size },
imageSearchBatchOverLimit() { return this.imageSearchBatchTaskCount > IMAGE_SEARCH_MAX_BATCH_TASKS },
imageSearchBatchDurationText() {
@@ -313,6 +436,7 @@ export default {
this.total = r.data.total
this.loading = false
void this.loadPurchaseReadiness(this.products.map(item => item.id), requestOptions, generation)
void this.loadReturnMatches(this.products.map(item => item.id), generation)
} catch (error) {
if (generation !== this.loadGeneration) return
if (allowNetworkRetry && error?.message === 'Network Error' && !error?.response) {
@@ -326,7 +450,7 @@ export default {
}
},
search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 20, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() },
reset() { this.query = { page: 1, pageSize: 100, shopName: '', orderCodesText: '', parseStatus: '', processStage: '' }; this.load() },
normalizeShopName(value) { return String(value || '').normalize('NFKC').trim().toLocaleLowerCase() },
async ensureShopOptions() {
if (this.shopOptionsLoaded) return
@@ -357,24 +481,43 @@ export default {
// 不能要求已关联——另外三个判据都要求,于是图搜入口对它最该服务的那类商品
// 一直不可达(#291)。这里与 imageSearchRows 的过滤条件保持一致。
isImageSearchCandidate(row) { return Boolean(row.shopeeProductId && row.imageUrl) },
// #338: 参与匹配退货的处理阶段范围(服务端 returnmatch.participatingStages 同一口径)。
isReturnMatchCandidate(row) {
const participating = new Set(['pdd_unlinked', 'pdd_pending', 'pdd_collecting', 'pdd_collection_failed', 'color_mapping', 'purchase_ready'])
return participating.has(this.purchaseReady(row).processStage)
},
isActivelyReturnMatched(row) { const stage = this.purchaseReady(row).processStage; return stage === 'return_pending' || stage === 'return_used' },
// `[必须]` 可勾选不等于可采购。放宽的只有勾选:purchaseCandidates / collectionCandidates
// / aiMatchCandidates 仍各自用自己的判据过滤,采购门禁不受影响。
isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) },
// / aiMatchCandidates / returnMatch 仍各自用自己的判据过滤,采购门禁不受影响。
isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) || this.isImageSearchCandidate(row) || this.isReturnMatchCandidate(row) },
rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && this.isSelectableCandidate(row) },
purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } },
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }}[stage] || { label: '待人工处理', type: 'warning' } },
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '规格待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }, return_pending: { label: '退货待确认', type: 'warning' }, return_used: { label: '已用退货', type: 'info' }}[stage] || { label: '待人工处理', type: 'warning' } },
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
// #339: the page can now show up to 500 rows, but the preview endpoint
// (previewPurchaseTasks -> purchase/batch.go maxBatchPurchaseItems) still
// caps a single request at 100 ids, so this splits the current page into
// sequential chunks of <=100 and merges their results. Any chunk failing
// falls back to the same page-wide failure state the single-request path
// used before, so rows never end up in a partially-updated mix of real
// and failed readiness.
async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) {
if (generation !== this.loadGeneration) return
const selectedIDs = selectedIDsOverride || new Set(this.selectedProducts.map(row => row.id))
this.purchaseReadiness = {}
if (!this.canPurchase || !ids.length) { this.purchaseReadinessLoading = false; return }
this.purchaseReadinessLoading = true
const chunkSize = 100
const merged = {}
try {
const r = await previewPurchaseTasks({ sybProductIds: ids }, { ...requestOptions, suppressErrorMessage: true })
if (generation !== this.loadGeneration) return
this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item]))
for (let start = 0; start < ids.length; start += chunkSize) {
const chunk = ids.slice(start, start + chunkSize)
const r = await previewPurchaseTasks({ sybProductIds: chunk }, { ...requestOptions, suppressErrorMessage: true })
if (generation !== this.loadGeneration) return
for (const item of r.data.items) merged[item.sybProductId] = item
}
this.purchaseReadiness = merged
await this.$nextTick()
const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row))
this.$refs.productTable?.clearSelection()
@@ -428,6 +571,107 @@ export default {
},
formatConfidence(value) { return Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—' },
// ---------------- 退货匹配 (#338) ----------------
formatMatchDeadline(value) { return value ? new Date(value).toLocaleString() : '—' },
matchStatusLabel(status) { return { matched: '待确认', confirmed: '已确认', cancelled: '已取消' }[status] || status },
async loadReturnMatches(ids, generation = this.loadGeneration) {
this.returnMatchByProductId = {}
if (!ids.length) return
this.returnMatchLoading = true
try {
const r = await listReturnMatches({ sybProductId: ids }, { suppressErrorMessage: true })
if (generation !== this.loadGeneration) return
const map = {}
for (const item of (r.data?.items || [])) {
if (item.status === 'cancelled') continue
if (!map[item.sybProductId]) map[item.sybProductId] = item
}
this.returnMatchByProductId = map
} catch {
// 展示失败不影响主列表;用户可重新查询重试。
} finally {
if (generation === this.loadGeneration) this.returnMatchLoading = false
}
},
matchReasonMeta(code) {
return {
stage_ineligible: '阶段不参与',
no_candidate: '无候选',
conflict: '并发冲突'
}[code] || code
},
async runBatchMatchReturns() {
const ids = this.returnMatchCandidateIds
if (!ids.length || this.returnMatchBatchLoading) return
this.returnMatchBatchLoading = true
try {
const r = await batchMatchReturns({ sybProductIds: ids })
const items = r.data.items || []
this.returnMatchBatchResult = {
open: true, items,
matchedCount: r.data.matchedCount || 0,
noCandidateCount: items.filter(i => i.reasonCode === 'no_candidate').length,
stageIneligibleCount: items.filter(i => i.reasonCode === 'stage_ineligible').length,
conflictCount: items.filter(i => i.reasonCode === 'conflict').length
}
await this.load()
} finally {
this.returnMatchBatchLoading = false
}
},
async openMatchDetail(matchId, focusRemark = false) {
this.matchDetail = { open: true, loading: true, saving: false, id: matchId, data: null, remarkText: '' }
try {
const r = await getReturnMatch(matchId)
this.matchDetail.data = r.data
this.matchDetail.remarkText = r.data.match.remark || ''
} finally {
this.matchDetail.loading = false
}
if (focusRemark) this.$nextTick(() => this.$refs.matchRemarkInput?.focus())
},
async refreshMatchDetail() {
const r = await getReturnMatch(this.matchDetail.id)
this.matchDetail.data = r.data
this.matchDetail.remarkText = r.data.match.remark || ''
await this.load()
},
async saveMatchRemark() {
this.matchDetail.saving = true
try {
await remarkReturnMatch(this.matchDetail.id, { remark: this.matchDetail.remarkText })
ElMessage.success('已保存备注')
await this.refreshMatchDetail()
} finally {
this.matchDetail.saving = false
}
},
async confirmMatchDetail() {
try {
await this.$confirm('确认后该 SYB 商品状态变为「已用退货」,仍不能创建采购任务。确定继续?', '确认匹配', { type: 'warning' })
} catch { return }
await confirmReturnMatch(this.matchDetail.id)
ElMessage.success('已确认匹配')
await this.refreshMatchDetail()
},
async cancelMatchDetail() {
try {
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
} catch { return }
await cancelReturnMatch(this.matchDetail.id)
ElMessage.success('已取消匹配')
this.matchDetail.open = false
await this.load()
},
async quickCancelMatch(matchId) {
try {
await this.$confirm('取消后该 SYB 商品恢复可创建采购,对应退货商品回到可用池。确定取消匹配?', '取消匹配', { type: 'warning', confirmButtonClass: 'el-button--danger' })
} catch { return }
await cancelReturnMatch(matchId)
ElMessage.success('已取消匹配')
await this.load()
},
// ---------------- 批量创建采购任务 ----------------
async openSinglePurchase(row) {
this.$refs.productTable?.clearSelection()
@@ -611,7 +855,7 @@ export default {
.search-form{display:flex;flex-wrap:nowrap;align-items:center;column-gap:8px;padding:12px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
.search-form :deep(.el-form-item){margin-right:0;margin-bottom:12px}
.search-form :deep(.el-form-item__content){flex-wrap:nowrap}
.shop-input{width:130px}.order-codes-input{width:210px}.unlinked-tag{margin-left:8px}
.shop-input{width:130px}.order-codes-input{width:168px}.unlinked-tag{margin-left:8px}
.shop-option{display:flex;align-items:center;justify-content:space-between;gap:12px}
.shop-option-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.toolbar-actions :deep(.el-form-item__content){gap:8px}
@@ -625,9 +869,11 @@ export default {
.missing{color:#b91c1c}
.quick-link-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0}
.thumb{width:44px;height:44px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}
.return-match-cell{display:flex;gap:8px;align-items:flex-start}
.link{color:#1677ff;cursor:pointer}
.shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.notice{margin-bottom:16px}
.return-missing-alert{margin:6px 0}
.compact-notice{margin-bottom:12px}
.purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.warning-text{color:#b45309}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.image-search-tag{margin-left:6px;vertical-align:middle}
.spec-match-summary{gap:8px 24px}
@@ -0,0 +1,280 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="page-card" shadow="never">
<el-alert v-if="summaryError" :title="summaryError" type="warning" show-icon :closable="false" class="notice" />
<div v-else class="summary-bar" aria-live="polite">
<span>最后成功同步:<strong>{{ formatTime(summary.lastSuccessAt) }}</strong></span>
<span v-if="summary.lastRun">读取 {{ summary.lastRun.readCount }} / 新增 {{ summary.lastRun.createdCount }} / 更新 {{ summary.lastRun.updatedCount }} / 跳过 {{ summary.lastRun.skippedCount }} / 失败 {{ summary.lastRun.failedCount }}</span>
<span v-if="summary.lastRun"><el-tag :type="statusMeta(summary.lastRun.status).type" size="small">{{ statusMeta(summary.lastRun.status).label }}</el-tag></span>
</div>
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<el-form-item label="订单号"><el-input v-model="query.orderSn" clearable placeholder="订单号" style="width:150px" /></el-form-item>
<el-form-item label="店铺">
<el-select v-model="query.shop" clearable filterable placeholder="全部店铺" style="width:150px">
<el-option v-for="name in shopOptions" :key="name" :label="name" :value="name" />
</el-select>
</el-form-item>
<el-form-item label="认领状态">
<el-select v-model="query.claimStatus" clearable placeholder="全部" style="width:130px">
<el-option v-for="item in claimStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="创建时间"><el-date-picker v-model="createTimeRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" style="width:230px" /></el-form-item>
<el-form-item label="匹配状态">
<el-select v-model="query.matchStatus" clearable placeholder="全部" style="width:130px">
<el-option v-for="item in matchStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
</el-form>
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="notice"><template #default><el-button link type="primary" @click="load">重新加载</el-button></template></el-alert>
<el-table v-loading="loading" :data="items" border stripe row-key="key" empty-text="暂无退货商品">
<el-table-column label="订单号" prop="orderSn" min-width="130" />
<el-table-column label="商品参考图" width="76">
<template #default="{ row }">
<el-image v-if="row.hasItem && row.image" :src="row.image" fit="cover" class="thumb" :preview-src-list="[row.image]" preview-teleported />
<div v-else-if="row.hasItem" class="thumb placeholder">无图</div>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="商品标题" min-width="220">
<template #default="{ row }">
<template v-if="row.hasItem">{{ row.itemName || '—' }}</template>
<span v-else class="muted">(无商品明细)</span>
</template>
</el-table-column>
<el-table-column label="蝦皮id / sku_id" min-width="140">
<template #default="{ row }">
<template v-if="row.hasItem">
{{ row.shopeeItemId || '—' }}
<div class="muted">{{ row.variationId || '—' }}</div>
</template>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="数量" width="70">
<template #default="{ row }">{{ row.hasItem ? row.quantity : '—' }}</template>
</el-table-column>
<el-table-column label="店铺" min-width="120"><template #default="{ row }">{{ row.shopName || '—' }}</template></el-table-column>
<el-table-column label="规格数据" min-width="180"><template #default="{ row }">{{ row.variationName || '—' }}</template></el-table-column>
<el-table-column label="认领状态" min-width="120">
<template #default="{ row }">
<el-tag v-if="row.statusUnrecognized" type="danger" effect="plain">未知:{{ row.claimStatus || '—' }}</el-tag>
<el-tag v-else :type="claimStatusMeta(row.claimStatus).type">{{ claimStatusMeta(row.claimStatus).label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="yeeke 可用性" min-width="150">
<template #default="{ row }">
<el-tag v-if="row.syncStatus === 'missing'" type="danger" effect="plain">不可用(yeeke 列表中已不存在)</el-tag>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="上架时间" min-width="150"><template #default="{ row }">{{ formatTime(row.claimTime) }}</template></el-table-column>
<el-table-column label="销毁截止" min-width="150"><template #default="{ row }">{{ formatTime(row.destroyDeadLine) }}</template></el-table-column>
<el-table-column label="最近同步" min-width="150"><template #default="{ row }">{{ formatTime(row.lastSyncedAt) }}</template></el-table-column>
<el-table-column label="匹配状态" min-width="100">
<template #default="{ row }"><el-tag :type="matchStatusMeta(row.matchStatus).type" effect="plain">{{ matchStatusMeta(row.matchStatus).label }}</el-tag></template>
</el-table-column>
<el-table-column label="被占用 SYB 订单商品" min-width="180">
<template #default="{ row }">
<template v-if="row.occupyingSybProductId">
<a class="link" href="javascript:void(0)" @click="openSybProduct(row.occupyingSybProductId)">{{ row.occupyingSybOrderCode }} ↗</a>
<div class="muted">{{ row.occupyingSybStageLabel }}</div>
</template>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row)">详情</el-button></template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
</el-card>
<el-drawer v-model="detail.open" title="退货商品详情" size="720px">
<div v-loading="detail.loading" class="drawer-body">
<template v-if="detail.placeholder && detail.pkg">
<el-alert title="该包裹无商品明细" type="info" show-icon :closable="false" class="notice" />
<h3 class="section-title">所属退货包裹</h3>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="订单号">{{ detail.pkg.orderSn }}</el-descriptions-item>
<el-descriptions-item label="店铺">{{ detail.pkg.shopName || detail.pkg.shopId || '—' }}</el-descriptions-item>
<el-descriptions-item label="仓库/库位">{{ detail.pkg.wareHouse || detail.pkg.wareCode || '—' }}</el-descriptions-item>
<el-descriptions-item label="认领状态">
<el-tag v-if="detail.pkg.statusUnrecognized" type="danger" effect="plain">未知:{{ detail.pkg.claimStatus || '—' }}</el-tag>
<el-tag v-else :type="claimStatusMeta(detail.pkg.claimStatus).type">{{ claimStatusMeta(detail.pkg.claimStatus).label }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="认领时间">{{ formatTime(detail.pkg.claimTime) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detail.pkg.createTime) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTime(detail.pkg.updateTime) }}</el-descriptions-item>
<el-descriptions-item label="销毁截止">{{ formatTime(detail.pkg.destroyDeadLine) }}</el-descriptions-item>
<el-descriptions-item label="最近同步">{{ formatTime(detail.pkg.lastSyncedAt) }}</el-descriptions-item>
</el-descriptions>
</template>
<template v-else-if="detail.item">
<div class="item-hero">
<el-image v-if="detail.item.image" :src="detail.item.image" fit="cover" class="thumb-lg" :preview-src-list="[detail.item.image]" preview-teleported />
<div v-else class="thumb-lg placeholder">无图</div>
<div class="item-hero-info">
<div class="item-title">{{ detail.item.itemName || '—' }}</div>
<div class="muted">{{ detail.item.variationName || '—' }}</div>
<div class="muted">商品ID:{{ detail.item.itemId || '—' }} / 规格ID:{{ detail.item.variationId || '—' }}</div>
<div class="muted">退货数量:{{ detail.item.quantity }}</div>
</div>
</div>
<h3 class="section-title">所属退货包裹</h3>
<el-descriptions v-if="detail.pkg" :column="2" border size="small">
<el-descriptions-item label="订单号">{{ detail.pkg.orderSn }}</el-descriptions-item>
<el-descriptions-item label="店铺">{{ detail.pkg.shopName || detail.pkg.shopId || '—' }}</el-descriptions-item>
<el-descriptions-item label="仓库/库位">{{ detail.pkg.wareHouse || detail.pkg.wareCode || '—' }}</el-descriptions-item>
<el-descriptions-item label="认领状态">
<el-tag v-if="detail.pkg.statusUnrecognized" type="danger" effect="plain">未知:{{ detail.pkg.claimStatus || '—' }}</el-tag>
<el-tag v-else :type="claimStatusMeta(detail.pkg.claimStatus).type">{{ claimStatusMeta(detail.pkg.claimStatus).label }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="认领时间">{{ formatTime(detail.pkg.claimTime) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(detail.pkg.createTime) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTime(detail.pkg.updateTime) }}</el-descriptions-item>
<el-descriptions-item label="销毁截止">{{ formatTime(detail.pkg.destroyDeadLine) }}</el-descriptions-item>
<el-descriptions-item label="最近同步">{{ formatTime(detail.pkg.lastSyncedAt) }}</el-descriptions-item>
</el-descriptions>
<h3 class="section-title">同包裹其他商品</h3>
<el-table :data="detail.siblings" border size="small" empty-text="无其他商品">
<el-table-column label="标题/规格" min-width="220"><template #default="{ row }">{{ row.itemName || '—' }}<div class="muted">{{ row.variationName || '—' }}</div></template></el-table-column>
<el-table-column label="数量" prop="quantity" width="80" />
</el-table>
</template>
</div>
</el-drawer>
</template>
</BasicLayout>
</template>
<script>
import { Search, RefreshLeft } from '@element-plus/icons-vue'
import { listYeekeReturnPackages, getYeekeReturnPackage, getYeekeReturnItem, listYeekeReturnShops, listYeekeSyncRuns } from '@/api/goauto/yeeke-returns'
export default {
name: 'GoAutoYeekeReturns',
setup() { return { Search, RefreshLeft } },
data() {
return {
loading: false, loadError: '', items: [], total: 0, createTimeRange: null,
summary: { lastSuccessAt: '', lastRun: null }, summaryError: '',
shopOptions: [],
query: { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '', matchStatus: '' },
detail: { open: false, loading: false, placeholder: false, item: null, pkg: null, siblings: [] },
// #338: 匹配状态是只读信息筛选,未勾选/无写操作,按钮门禁规则里
// 「纯只读筛选可以照常展示」的部分——不需要 canPurchase 判断。
matchStatusOptions: [
{ label: '未匹配', value: 'unmatched' },
{ label: '退货待确认', value: 'matched' },
{ label: '已用退货', value: 'confirmed' }
],
// yeeke's only confirmed claim-status value is the raw string "1"
// (HAR evidence, #336 requirement doc). Any other value the sync sees
// is preserved verbatim and flagged status_unrecognized — never
// guessed into a label here. "unknown" is a server-side pseudo-filter
// matching status_unrecognized=true, not a real yeeke value.
claimStatusOptions: [
{ label: '已认领', value: '1' },
{ label: '未知(原样展示)', value: 'unknown' }
]
}
},
created() { this.load(); this.loadSummary(); this.loadShops() },
methods: {
// "1" is yeeke's only confirmed known status; anything else reaching
// this function (status_unrecognized=false from the server) is an
// unmapped value we have not seen yet — show it as-is rather than a
// bare digit or a guessed Chinese label.
claimStatusMeta(status) { return status === '1' ? { label: '已认领', type: 'success' } : { label: status ? `其他:${status}` : '—', type: 'info' } },
matchStatusMeta(status) { return { unmatched: { label: '未匹配', type: 'info' }, matched: { label: '退货待确认', type: 'warning' }, confirmed: { label: '已用退货', type: 'success' }}[status] || { label: status || '—', type: 'info' } },
openSybProduct(sybProductId) { this.$router.push({ path: '/syb-products/index', query: { sybProductId } }) },
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
async load() {
this.loading = true; this.loadError = ''
try {
const params = { ...this.query }
const r = await listYeekeReturnPackages(params, { suppressNetworkError: true })
this.items = r.data.items
this.total = r.data.total
} catch (error) {
this.loadError = error?.response?.data?.message || error?.message || '退货商品加载失败'
} finally {
this.loading = false
}
},
async loadShops() {
try {
const r = await listYeekeReturnShops({ suppressNetworkError: true, suppressErrorMessage: true })
this.shopOptions = r.data.shops || []
} catch (error) {
// 店铺下拉加载失败时不阻塞列表,筛选项保持为空。
this.shopOptions = []
}
},
async loadSummary() {
this.summaryError = ''
try {
const r = await listYeekeSyncRuns({ page: 1, pageSize: 1 }, { suppressNetworkError: true, suppressErrorMessage: true })
this.summary = { lastSuccessAt: r.data.lastSuccessAt, lastRun: r.data.items[0] || null }
} catch (error) {
// 同步失败时仍显示上次已加载的商品列表,只在摘要条提示。
this.summaryError = '同步状态摘要加载失败,仍显示已有退货商品数据'
}
},
search() {
this.query.page = 1
const [from, to] = this.createTimeRange || ['', '']
this.query.createTimeFrom = from
this.query.createTimeTo = to
this.load()
},
reset() {
this.createTimeRange = null
this.query = { page: 1, pageSize: 20, orderSn: '', shop: '', claimStatus: '', createTimeFrom: '', createTimeTo: '', matchStatus: '' }
this.load()
},
async openDetail(row) {
this.detail = { open: true, loading: true, placeholder: !row.hasItem, item: null, pkg: null, siblings: [] }
try {
if (!row.hasItem) {
const r = await getYeekeReturnPackage(row.packageId)
this.detail.pkg = r.data.item
} else {
const r = await getYeekeReturnItem(row.itemId)
this.detail.item = r.data.item
this.detail.pkg = r.data.package
this.detail.siblings = r.data.siblings || []
}
} finally {
this.detail.loading = false
}
}
}
}
</script>
<style lang="scss" scoped>
.page-card{min-height:calc(100vh - 124px)}
.page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px}
.page-heading h1{margin:0 0 6px;font-size:24px;color:#1f2937}
.page-heading p{margin:0;color:#606266;line-height:1.5}
.heading-actions{display:flex;gap:8px;flex-shrink:0}
.summary-bar{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:10px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc;color:#334155}
.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
.notice{margin-bottom:16px}
.drawer-body{padding:0 4px 24px}
.section-title{margin:24px 0 12px}
.thumb{width:44px;height:44px;border-radius:4px;object-fit:cover}
.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}
.thumb-lg{width:96px;height:96px;border-radius:6px;object-fit:cover;flex-shrink:0}
.thumb-lg.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:12px}
.item-hero{display:flex;gap:16px}
.item-hero-info{flex:1;min-width:0}
.item-title{font-size:15px;font-weight:600;color:#1f2937;margin-bottom:4px}
.muted{font-size:12px;color:#909399}
@media(max-width:800px){.page-heading{flex-direction:column}}
</style>
@@ -0,0 +1,132 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="page-card" shadow="never">
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search">
<el-form-item label="触发方式">
<el-select v-model="query.trigger" clearable placeholder="全部" style="width:120px">
<el-option label="手动" value="manual" />
<el-option label="定时" value="scheduled" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" clearable placeholder="全部" style="width:140px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="开始时间"><el-date-picker v-model="startedRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
<el-form-item class="sync-action">
<el-button
v-if="canSync"
type="primary"
:loading="manualSyncStarting"
:disabled="manualSyncStarting"
@click="startManualSync"
>手动同步</el-button>
</el-form-item>
</el-form>
<el-alert v-if="loadError" :title="loadError" type="error" show-icon :closable="false" class="notice"><template #default><el-button link type="primary" @click="load">重新加载</el-button></template></el-alert>
<div class="table-wrap">
<el-table v-loading="loading" :data="items" border stripe height="100%" empty-text="暂无同步记录">
<el-table-column label="运行ID" prop="id" width="90" />
<el-table-column label="触发方式" width="100"><template #default="{ row }">{{ triggerLabel(row.trigger) }}</template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusMeta(row.status).type">{{ statusMeta(row.status).label }}</el-tag></template></el-table-column>
<el-table-column label="开始时间" min-width="160"><template #default="{ row }">{{ formatTime(row.startedAt) }}</template></el-table-column>
<el-table-column label="结束时间" min-width="160"><template #default="{ row }">{{ formatTime(row.finishedAt) }}</template></el-table-column>
<el-table-column label="页数" prop="totalPages" width="70" />
<el-table-column label="读取" prop="readCount" width="70" />
<el-table-column label="新增" prop="createdCount" width="70" />
<el-table-column label="更新" prop="updatedCount" width="70" />
<el-table-column label="跳过" prop="skippedCount" width="70" />
<el-table-column label="失败" prop="failedCount" width="70" />
<el-table-column label="标记不可用" prop="missingMarkedCount" width="96" />
<el-table-column label="恢复可用" prop="recoveredCount" width="84" />
<el-table-column label="脱敏原因" min-width="200"><template #default="{ row }">{{ row.errorMessage || '—' }}</template></el-table-column>
</el-table>
</div>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
</el-card>
</template>
</BasicLayout>
</template>
<script>
import { Search, RefreshLeft } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { listYeekeSyncRuns, triggerYeekeSync } from '@/api/goauto/yeeke-returns'
export default {
name: 'GoAutoYeekeSyncRuns',
setup() { return { Search, RefreshLeft } },
data() {
return {
loading: false, loadError: '', items: [], total: 0, startedRange: null, manualSyncStarting: false,
query: { page: 1, pageSize: 20, trigger: '', status: '', startedFrom: '', startedTo: '' },
statusOptions: [
{ label: '执行中', value: 'running' }, { label: '成功', value: 'succeeded' },
{ label: '失败', value: 'failed' }, { label: '已中断', value: 'interrupted' }
]
}
},
computed: {
canSync() { return (this.$store.getters.roles || []).some(role => role === 'admin' || role === 'purchaser') },
hasRunningSync() { return this.items.some(item => item.status === 'running') }
},
created() { this.load() },
methods: {
triggerLabel(trigger) { return { manual: '手动', scheduled: '定时' }[trigger] || trigger || '—' },
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
async load() {
this.loading = true; this.loadError = ''
try {
const r = await listYeekeSyncRuns(this.query, { suppressNetworkError: true })
this.items = r.data.items
this.total = r.data.total
} catch (error) {
this.loadError = error?.response?.data?.message || error?.message || '同步记录加载失败'
} finally {
this.loading = false
}
},
search() {
this.query.page = 1
const [from, to] = this.startedRange || ['', '']
this.query.startedFrom = from
this.query.startedTo = to
this.load()
},
reset() {
this.startedRange = null
this.query = { page: 1, pageSize: 20, trigger: '', status: '', startedFrom: '', startedTo: '' }
this.load()
},
async startManualSync() {
if (!this.canSync || this.manualSyncStarting || this.hasRunningSync) return
this.manualSyncStarting = true
try {
await triggerYeekeSync()
ElMessage.success('已开始同步')
await this.load()
} catch (error) {
ElMessage.error(error?.response?.data?.message || error?.message || '同步启动失败')
} finally {
this.manualSyncStarting = false
}
}
}
}
</script>
<style lang="scss" scoped>
// The card fills the viewport and the table takes all remaining height, so
// the pagination sits at the bottom instead of floating mid-page.
.page-card{height:calc(100vh - 124px);display:flex;flex-direction:column}
.page-card :deep(.el-card__body){flex:1;min-height:0;display:flex;flex-direction:column}
.search-form{display:flex;flex-wrap:wrap;align-items:center;padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}
.sync-action{margin-left:auto}
.notice{margin-bottom:16px}
.table-wrap{flex:1;min-height:240px}
</style>