Compare commits

..
Author SHA1 Message Date
QiuSW fcb7dad878 docs: record reveal swipe result policy and version boundary (#348) 2026-09-29 17:52:32 +08:00
QiuSW 6550b9fd45 fix(android): skip reveal swipe without a scroll target (#348) 2026-09-29 17:43:46 +08:00
QiuSW 7a2c2d1eac merge: SYB excluded-product marking and recompute (#340) 2026-09-29 16:32:35 +08:00
QiuSW d01540b9fd fix(web): label SYB sync filter counts as hits (#340) 2026-09-29 16:31:54 +08:00
QiuSWandClaude Opus 5.5 ec010b42fc docs: SYB excluded-product marking and recompute contract (#340)
Mirrors of Wiki revisions Architecture-and-Code-Map 4bf8482e,
Business-Rules-and-Glossary c0b1a842, SYB-ERP-Interface-Contract 5979a142,
Android-Agent-API-Contract 92e9f856: filter hits are stored and marked
(first creation only), PDD isolation, 无需采购 stage position, purchaseType
list filter, recompute preview/execute with fingerprint and 409, hit vs
markedCount wording. SYB-ERP page also restores #343's unified-session
paragraph (was only in the mirror) and corrects the structure rule to #286's
single `-#` all-characters rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-29 16:21:47 +08:00
QiuSWandClaude Opus 5.5 a56df9d908 fix(syb): #340 phase 4 — fingerprint includes rule evidence + MySQL IT
1. recomputeFingerprint now hashes a JSON-serialized (not naive string-
   concatenated, to avoid delimiter-collision) sorted list of
   {id, direction, ruleId, ruleKind, ruleKeyword} per planned change — the
   rule kind/keyword are exactly what gets written into
   excluded_rule_kind/excluded_rule_keyword, so a plan that affects the
   same ids/directions via a since-edited rule must now be rejected as
   stale, not silently accepted. New tests:
   TestRecomputeFingerprintChangesWhenRuleEvidenceChanges (edits the rule
   row directly between preview and execute, since the API has no edit
   endpoint, and asserts RECOMPUTE_PREVIEW_STALE with nothing written and
   no log row) and TestRecomputeFingerprintStableAcrossUnchangedPreviews
   (two previews of the same data yield the same fingerprint and execute
   succeeds).

2. New server/app/goauto/sybproductfilter/recompute_mysql_integration_test.go,
   gated on GOAUTO_IT_MYSQL_DSN (t.Skip when unset, so `go test` is
   unaffected normally). It creates a uniquely named throwaway database
   (zz_goauto_it_340_<ts>), migrates it, and drops it in t.Cleanup — never
   touches an existing database. Two real-MySQL, two-connection scenarios
   reproduce the exact race the phase-3 fix closes: connection A takes its
   REPEATABLE-READ snapshot via the planning step, connection B takes the
   row's FOR UPDATE lock and holds it (confirmed via a channel) while A's
   write phase is proven to actually block on that same lock (asserted via
   a wait window), B then inserts a purchase_task / active return_match
   and commits, and A is asserted to unblock, see it, and skip the row.
   Verified locally against the dev MySQL server (this session never
   printed the password: read via a shell one-liner into an env var,
   exported only for the go test invocation): both tests PASS with the
   phase-3 fix in place. Temporarily reverted purchaseTaskLockedQuery to a
   plain (non-locking) read (not committed) and reran —
   TestRecomputeConcurrentPurchaseTaskUnderRealMySQL correctly FAILED
   ("expected A to skip the row ... got {PDDToExcluded:1 SkippedHasTask:0}"),
   proving the test is meaningful; restored and diffed byte-identical
   against a backup before rerunning to confirm both tests pass again.
   Confirmed via `SHOW DATABASES LIKE 'zz_goauto_it_%'` (empty) that every
   throwaway database, across all these runs, was actually dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-29 16:05:06 +08:00
QiuSWandClaude Opus 5.5 cd4bd6acb1 fix(syb): #340 phase 3 review — recheck reads must be locking, not plain
writeRecomputeChanges' purchase_task/return_match rechecks (added in the
previous phase 3 commit) used plain SELECT COUNT(*). On MySQL 8.4 under
REPEATABLE-READ, a plain read inside a transaction reuses the snapshot
taken at that transaction's first read (recomputeChanges' own SELECT), so
a purchase_task or return_match row committed by another connection AFTER
that snapshot was invisible to the recheck even after the syb_product row's
FOR UPDATE lock was granted — the row lock only serializes writers against
each other, it doesn't force a later plain read to see newer committed
data. Confirmed against a real local MySQL 8.4 server with two connections:
after the other transaction committed a task, plain COUNT(*) returned 0
while COUNT(*) ... FOR SHARE correctly returned 1. Every existing test
passed anyway because this package's tests run on SQLite, which has no
multi-connection snapshot isolation to reproduce the race at all.

Fix: both rechecks now use clause.Locking{Strength: "SHARE"} (a locking/
"current" read is enough since they only need to observe committed rows,
not lock them further). Split the three per-row queries (row FOR UPDATE,
purchase_task FOR SHARE, return_match FOR SHARE) into small query-builder
helpers (sybProductRowLockQuery / purchaseTaskLockedQuery /
returnMatchLockedQuery) so writeRecomputeChanges consumes them and a test
can independently assert their generated SQL.

New test: TestRecheckQueriesUseLockingReads opens a DryRun gorm session
against the MySQL dialector (mysql.New with SkipInitializeWithVersion,
DisableAutomaticPing — no real network connection is ever made) and pins
the exact SQL shape via db.ToSQL:
  SELECT * FROM `syb_product` WHERE id = 1 FOR UPDATE
  SELECT count(*) FROM `purchase_task` WHERE syb_product_id = 1 FOR SHARE
  SELECT count(*) FROM `return_match` WHERE syb_product_id = 1
    AND active_syb_product_id IS NOT NULL FOR SHARE
The test's own comment documents why SQLite cannot reproduce this race
(gorm's SQLite driver drops clause.Locking entirely; SQLite also has no
multi-connection REPEATABLE-READ snapshot semantics to begin with).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-29 15:43:37 +08:00
QiuSWandClaude Opus 5.5 baa20af530 fix(syb): #340 phase 3 — recompute race protection, fingerprint, wording
Merge origin/main (1e582cd, #350/#351 inner-code fixes) — no conflicts.

sybproductfilter/recompute.go:
1. Row-level protection in RecomputeExecute: the write phase is split out
   into writeRecomputeChanges(ctx, tx, planned), independently testable.
   For every planned change it takes the same clause.Locking{Strength:
   "UPDATE"} row lock purchase.Service.create and returnmatch's
   matchOneWithLock take, then re-checks under that lock: a purchase task
   or active return match that appeared after planning skips the row
   (counted), and a row already at its target mark is left alone. The
   execute response and audit log now report ACTUAL writes/skips
   (plan-time skips + write-time skips), not the initial plan.
2. Preview/execute binding: RecomputePreview returns a `fingerprint`
   (sha256 over the sorted id:direction:ruleId list). RecomputeExecute now
   requires it, recomputes the plan inside the same transaction and
   compares before writing; a mismatch returns RECOMPUTE_PREVIEW_STALE
   (HTTP 409, "数据或规则已变化,请重新预览后再执行") and writes nothing.
   Frontend passes the preview's fingerprint to execute and re-previews
   automatically on that error.
3. syb-product-filters List gains a per-rule `markedCount` (one grouped
   COUNT(*)...GROUP BY excluded_rule_id query, no N+1): the REAL current
   count of syb_product rows marked by that rule. The disable-structure-
   rule confirm dialog now quotes this instead of the stale lastHitCount
   sync snapshot; the 上次同步命中 column still shows lastHitCount.
4. recomputeChanges now selects only id/order_code/shopee_item_id/
   raw_json/pdd_purchase_excluded instead of full syb_product rows.

Wording (user-approved deviation from the prototype text):
- syb-sync-runs detail: 其中无需采购 N 条 -> 其中本次规则命中 N 条.
- syb-product-filters: 上次同步标记 reverted back to 上次同步命中 (both
  tables); the confirm-dialog text now cites markedCount, not lastHitCount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-29 15:34:20 +08:00
QiuSW f54ace7ccf Merge remote-tracking branch 'origin/main' into feat/340-syb-excluded-products 2026-09-29 15:25:46 +08:00
QiuSW 1e582cd868 merge: filter and freeze eligible inner-code apply selection (#351) 2026-09-29 15:00:04 +08:00
QiuSW d72d77ddf4 docs: bind mixed-selection apply confirmation rules (#351) 2026-09-29 14:59:57 +08:00
QiuSW ee8f80f987 fix: submit only frozen eligible inner-code apply selections (#351) 2026-09-29 14:57:23 +08:00
QiuSW 4959f3d17f merge: inner-code layout comparison regression fix (#350) 2026-09-29 14:15:12 +08:00
QiuSW 15d331bee9 fix: normalize layout controls in inner-code match evidence (#350) 2026-09-29 14:15:04 +08:00
QiuSW 897c1817d2 merge: restore inner-code matching and batch retry (#350) 2026-09-29 12:02:14 +08:00
QiuSW 5729ba048f docs: bind inner-code matching recovery contract #350 2026-09-29 11:49:32 +08:00
QiuSW 9c5e20e249 fix: recover inner-code matching and enable batch rematch #350 2026-09-29 11:45:46 +08:00
QiuSWandClaude Opus 5.5 8c95790135 feat(syb): #340 sync-run detail wording + excluded count (phase 2 cont.)
Rewords 结构过滤命中/关键词过滤命中 -> 结构过滤标记/关键词过滤标记 on the
sync-run detail (SYB 同步记录), and shows "其中无需采购 N 条" next to 商品明细,
computed client-side as charFilterSkipped + keywordFilterSkipped (exactly
the rows this run marked pdd_purchase_excluded) — no backend field needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-29 11:00:15 +08:00
QiuSWandClaude Opus 5.5 316d5aa412 feat(syb): #340 phase 2 — merge main, recompute samples, frontend UI
Merge origin/main (through #345/e7c049d) into feat/340-syb-excluded-products.
Resolved conflicts in sybimport/service.go, handler.go, service_test.go
(kept #342's createdFrom/createdTo AND #340's purchaseType, all combined
with processStage), and took origin/main's syb-products/index.vue as the
base for the new UI work below. Renamed the migration version file from
1789801100000 to 1789801500000 (next free slot after main's highest,
1789801400000) — content unchanged, version comes from the filename.

Backend: sybproductfilter recompute preview now also returns up to 20
sample rows (order code, shopee item id, change direction, matched rule)
alongside the existing counts; execute stays count + audit-log only.

Frontend (feat/340 issue "## 设计证据"/"## 页面", prototype v1):
- SYB 订单商品页: 采购类型 filter (需 PDD 采购 default / 无需 PDD 采购 / 全部),
  处理阶段 gains 无需采购 (pdd_excluded); selecting 退货待确认/已用退货/无需采购
  auto-switches 采购类型 to 全部; pdd_excluded rows are tickable only for
  匹配退货 (never collection/purchase/AI-match/image-search); rows show the
  stage tag plus 规则:<kind> <keyword>; detail drawer shows 采购类型 and rule.
- SYB 过滤规则页: 跳过导入 -> 标记为无需 PDD 采购 wording, 上次同步命中 ->
  上次同步标记, rewritten scope note, admin-only 按当前规则重算 button with
  preview dialog (4 counts + up to 20 samples) -> confirm -> execute.
- SYB 同步记录: 结构/关键词过滤命中 -> 结构/关键词过滤标记 (see #340 point 8;
  the run-level "其中无需采购 N 条" count is derivable client-side from the
  existing char/keyword counts, no backend field added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-29 10:59:12 +08:00
QiuSW e55645ff66 Merge remote-tracking branch 'origin/main' into feat/340-syb-excluded-products
# Conflicts:
#	server/app/goauto/sybimport/handler.go
#	server/app/goauto/sybimport/service.go
#	server/app/goauto/sybimport/service_test.go
#	web/src/views/goauto/syb-products/index.vue
2026-09-29 10:50:54 +08:00
QiuSW dd9160ec42 merge: batch cancel and use return matches (#347 #349) 2026-09-29 10:30:30 +08:00
QiuSW 14231c62a7 docs: record batch return use contract and verification #349 2026-09-29 10:24:54 +08:00
QiuSW 4280115e11 feat: batch use pending return matches #349 2026-09-29 10:21:28 +08:00
QiuSW a4c7b5e97b feat: add pending return match batch cancellation #347 2026-09-29 09:40:16 +08:00
QiuSW e7c049dbd6 merge: Yeeke reshipped return sync (#345) 2026-09-28 16:33:05 +08:00
QiuSW 3b95d4e879 feat(yeeke): sync reshipped returns and exclude new matching (#345) 2026-09-28 16:30:37 +08:00
QiuSW bce4897390 merge: serve Admin SPA at / and document nginx deployment (#346) 2026-09-28 16:16:20 +08:00
QiuSWandClaude Opus 5.5 c13dcd3ebc docs: record nginx 9527 config, content-based checks and migration (#346)
Mirror of Wiki Deployment-and-Operations revision 3a76e16: current host
122.228.200.167, the standard 9527 vhost (nginx serves dist, / returns
index.html) and why, release verification by content rather than status
code, a server-migration checklist, and the 2026-09-28 migration fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-28 16:14:43 +08:00
QiuSWandClaude Opus 5.5 0b01b92c98 fix(server): serve the Admin SPA at / when dist exists (#346)
go-admin registered its welcome page on GET / in every non-prod mode, so a
reverse proxy that forwarded / to the server showed 「GO-ADMIN欢迎您」
instead of the Admin (happened after the 2026-09-28 server migration).
When dist/index.html exists, / now returns it; without a dist (vite
development) the previous welcome/prod behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-28 16:07:49 +08:00
QiuSWandClaude Opus 5.5 9dace6ac89 fix(sybinnercode): add identity/stall/id guards to #289 assignment (#344)
Port the remaining consistency guards from cmautobuy's
planExistingMatchedInnerCodeItems into assignExistingBoundItems:

- Reject the whole multi-piece group if any candidate's raw ProductSpec,
  sku or variationSku differs from the lowest-ID candidate. Candidates
  are matched via NormalizeSpecKey, so raw values can legitimately differ
  even when normalized keys agree; auto-assigning across genuinely
  different items must be blocked.
- Re-verify each candidate against stallMatches when record.Stall is
  non-empty, since the no-SKU fallback path in matchEvidence can hand
  back candidates that were never stall-checked.
- Reject candidates with a non-positive or duplicate detail ID.

Added one regression test per guard plus a happy-path test confirming
legitimate multi-piece assignment still succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-28 10:35:16 +08:00
QiuSWandClaude Opus 5.5 e99bbc2327 fix(sybinnercode): align stall article matching with cmautobuy rules (#344)
Port cmautobuy's innerCodeStallMatches rule set (#259/#273 fixes) into
strictStall's underlying match: split stall on the last '#', compare the
article only against alphanumeric tokens, require leading-zero equivalence
plus stall-name confirmation for numeric articles, exact token match for
non-numeric articles, and a ProductSpec-prefix rule. This replaces the old
plain substring containment that could bind an inbound code to the wrong
SYB product detail (weight numbers mistaken for articles, short numeric
articles matching inside long codes, 067/67 not aligning, stall names
containing '#' splitting incorrectly).

Also fixes #289: when N single-piece inbound codes are matched against N
existing qty=1 SYB details and some details already carry a correctly
bound code out of ID order, planRecord now preserves those existing
bindings (matching by code value first via assignExistingBoundItems) and
only assigns the remaining blank details to the missing codes, instead of
reassigning by index/ID order and overwriting a correct binding.

Added regression tests for both fixes, including an end-to-end
RunMatchJob test reproducing the #289 bug against the pre-fix assignment
(verified to fail on the old code, pass on the new code).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-28 10:31:11 +08:00
QiuSW abdf90775c docs: record unified SYB writeback sessions (#343) 2026-09-27 23:00:46 +08:00
QiuSW 934a7beabc fix(purchase): unify SYB session refresh for writeback (#343) 2026-09-27 22:56:54 +08:00
QiuSW 24100f04d4 merge: place return matching after purchase (#342) 2026-09-27 17:24:06 +08:00
QiuSW 266418ac47 fix(syb): place return matching after purchase (#342) 2026-09-27 17:23:57 +08:00
QiuSW 5e5fdd797e merge: arrange SYB filter row (#342) 2026-09-27 17:19:45 +08:00
QiuSW 58c8c077ad fix(syb): arrange created-time filter row (#342) 2026-09-27 17:19:35 +08:00
QiuSW 9f679f6403 merge: SYB created-time filter and page size (#342) 2026-09-27 16:59:54 +08:00
QiuSW 977ac235e2 feat(syb): filter products by created time (#342) 2026-09-27 16:59:22 +08:00
QiuSWandClaude Opus 5.5 573f5b8cf3 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-27 16:52:09 +08:00
QiuSW 1491a9677a fix(access): allow after-sales purchase readiness (#341) 2026-09-27 11:59:31 +08:00
QiuSW d4d32c6e89 docs: restore wiki mirrors after return-match update (#338) 2026-09-27 11:46:28 +08:00
QiuSW b7038633f0 test(returnmatch): cover after-sales access and migration repair (#341) 2026-09-27 11:38:43 +08:00
QiuSW 844f611d77 fix(access): repair return-match API catalog migration (#341) 2026-09-27 11:25:01 +08:00
QiuSW d415971268 feat(returnmatch): publish return matching and after-sales access (#338 #341) 2026-09-27 11:16:18 +08:00
QiuSW 6fe588d5e3 fix(web): show after-sales GoAuto actions (#341) 2026-09-27 11:04:25 +08:00
QiuSW 17cbfaec85 feat(access): add after-sales role and accounts (#341) 2026-09-27 10:49:34 +08:00
QiuSWandClaude Opus 5.5 30c6b2ab50 feat(syb): hard-isolate pdd_purchase_excluded rows from PDD flows (#340)
SYB rows that hit a product filter rule are no longer skipped at import;
they are stored with a pdd_purchase_excluded mark (rule snapshot fixed at
row-creation time, never changed by a later resync), get a new
pdd_excluded process stage ("无需采购"), are hard-rejected by every PDD
entry point (purchase create/batch create/batch preview, collection and
AI-match eligibility, image search batch collection, quick-replace via
the new stage), and can still participate in #338 return matching.

Adds an admin-only recompute preview/execute pair in sybproductfilter to
re-derive marks against the current rules, skipping any row with a
purchase task or an active return match, with an audit log row per run.

sybimport.List gets a purchaseType filter (pdd/excluded/all, AND with
processStage).

Migration: cmd/migrate/migration/version-local/1789801100000 (additive
columns/table, all existing rows default to "needs PDD purchase").

Web UI is out of scope, pending prototype approval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 17:06:43 +08:00
QiuSW 27d9560f0a Merge remote-tracking branch 'origin/feat/338-return-match' into feat/339-syb-page-size 2026-09-24 16:27:43 +08:00
QiuSWandClaude Opus 5.5 b72fa105a8 feat(web): flag matched returns past their destroy deadline (#338)
A match whose return has passed its destroy deadline keeps blocking
purchase (user decision: remind, don't auto-cancel). Show 「退货已过销毁截止」
in the SYB products match column and compare dialog, and 「已过销毁截止」
under the occupying SYB product on the yeeke returns page. Uses the live
package deadline, so a resync that moves the deadline updates the flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-24 16:27:38 +08:00
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
141 changed files with 14014 additions and 240 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 76
versionName = "0.9.63"
versionCode = 77
versionName = "0.9.64"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -468,17 +468,27 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
}
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
val root = rootInActiveWindow ?: return false
return swipePurchaseWithEvidence(direction, durationMs).result == PurchaseSwipeResult.SUCCESS
}
override fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome {
// Count and resolve against the same live root: a missing root or a
// failed dispatch must never masquerade as an empty scrollable list.
val root = rootInActiveWindow ?: return PurchaseSwipeOutcome(PurchaseSwipeResult.ROOT_UNAVAILABLE, 0)
val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node }
if (candidates.isEmpty()) return PurchaseSwipeOutcome(PurchaseSwipeResult.NO_SCROLLABLE_TARGET, 0)
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
val directional = candidates.filter { candidate ->
Rect().also(candidate::getBoundsInScreen).let { if (horizontal) it.width() > it.height() else it.height() >= it.width() }
}
val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate ->
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() }
} ?: return false
return swipeNode(target, direction, durationMs, preferScrollAction = false)
} ?: return PurchaseSwipeOutcome(PurchaseSwipeResult.FAILED, candidates.size)
val result = if (swipeNode(target, direction, durationMs, preferScrollAction = false)) {
PurchaseSwipeResult.SUCCESS
} else PurchaseSwipeResult.FAILED
return PurchaseSwipeOutcome(result, candidates.size)
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
@@ -3,6 +3,10 @@ package cn.ilapage.goauto.agent.automation
import java.net.URI
import java.net.URLDecoder
enum class PurchaseSwipeResult { SUCCESS, NO_SCROLLABLE_TARGET, ROOT_UNAVAILABLE, FAILED }
data class PurchaseSwipeOutcome(val result: PurchaseSwipeResult, val visibleScrollableCount: Int)
interface PurchaseUiDriver {
fun capture(): UiSnapshot
fun clickFresh(target: SnapshotNode): FreshActionResult
@@ -32,6 +36,18 @@ interface PurchaseUiDriver {
fun specRowSwipeFailureReason(): String = "gestureFailed"
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
/** Detailed result only for the rule's reversible spec-panel reveal swipe. */
fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome {
val snapshot = capture()
val count = snapshot.nodes.count { it.visible && it.scrollable }
val result = when {
snapshot.nodes.isEmpty() -> PurchaseSwipeResult.ROOT_UNAVAILABLE
count == 0 -> PurchaseSwipeResult.NO_SCROLLABLE_TARGET
swipePurchase(direction, durationMs) -> PurchaseSwipeResult.SUCCESS
else -> PurchaseSwipeResult.FAILED
}
return PurchaseSwipeOutcome(result, count)
}
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550)
fun backPurchase(): Boolean
@@ -1150,17 +1166,34 @@ class PurchaseRehearsalExecutor(
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
action.swipeAfter?.let { swipe ->
// The stock purchase rule asks to reveal additional selector rows after
// opening the sheet. A fully-evidenced non-scrollable selector has no
// scroll target, and treating that absence as an action failure blocks
// an otherwise safe exact-spec flow. Keep all other configured swipes
// mandatory; this exception is limited to that confirmed panel state.
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL &&
currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION
) return null
val reveal = action.type == PurchaseActionType.OPEN_SPEC_PANEL
val initialScreen = if (reveal) currentScreen(input) else null
if (initialScreen?.specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION) {
val count = initialScreen.sourceNodes.count { it.visible && it.scrollable }
panelDiagnostic("ruleSwipe;outcome=skipped_confirmed_panel;panel=${initialScreen.specPanelType};scrollables=$count;reveal=true")
return null
}
repeat(swipe.count) { index ->
if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) {
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败")
// Keep other actions on the original Boolean path. Only the
// post-open reveal may treat an absent scroll target as a no-op.
val outcome = if (reveal) {
driver.swipePurchaseWithEvidence(swipe.direction, swipe.durationMs)
} else if (driver.swipePurchase(swipe.direction, swipe.durationMs)) {
PurchaseSwipeOutcome(PurchaseSwipeResult.SUCCESS, 0)
} else {
val count = driver.capture().nodes.count { it.visible && it.scrollable }
PurchaseSwipeOutcome(PurchaseSwipeResult.FAILED, count)
}
if (outcome.result != PurchaseSwipeResult.SUCCESS) {
val screen = currentScreen(input)
val evidence = "panel=${screen.specPanelType};scrollables=${outcome.visibleScrollableCount};" +
"reveal=$reveal;reason=${outcome.result.name.lowercase()}"
if (reveal && outcome.result == PurchaseSwipeResult.NO_SCROLLABLE_TARGET) {
panelDiagnostic("ruleSwipe;outcome=skipped_no_scroll_target;$evidence")
return null
}
panelDiagnostic("ruleSwipe;outcome=failed;$evidence")
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败 [$evidence]")
}
if (index < swipe.count - 1 && swipe.intervalMs > 0) pause(swipe.intervalMs)
}
@@ -9,6 +9,8 @@ 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.PurchaseSpecGesturePolicy
import cn.ilapage.goauto.agent.automation.PurchaseSwipeOutcome
import cn.ilapage.goauto.agent.automation.PurchaseSwipeResult
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
import cn.ilapage.goauto.agent.automation.RuleValidationException
import cn.ilapage.goauto.agent.automation.SnapshotNode
@@ -766,11 +768,118 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(0, driver.swipeCount)
assertTrue(diagnostics.any { it.contains("skipped_confirmed_panel;panel=NON_SCROLLABLE_CONFIRMATION;scrollables=0;reveal=true") })
}
@Test
fun `open spec reveal skips missing scroll target regardless of panel classification`() {
val driver = FakePurchaseDriver(
panelOverride = SpecPanelFixtures.liveShapedSheet(),
purchaseSwipeSucceeds = false,
)
var probes = 0
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(
driver, { driver.browser = true; true }, { probes++; "{}" },
pause = {}, panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "spec_probe_completed", outcome.resultType)
assertEquals(1, probes)
assertEquals(0, driver.swipeCount)
assertTrue(diagnostics.any { it.contains("panel=REQUIRED_EVIDENCE;scrollables=0;reveal=true") })
}
@Test
fun `open spec reveal with a scroll target still fails on gesture failure`() {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
var probes = 0
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(
driver, { driver.browser = true; true }, { probes++; "{}" },
pause = {}, panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertEquals(0, probes)
assertEquals(1, driver.swipeCount)
assertTrue(outcome.message.contains("panel=NORMAL_SCROLLABLE;scrollables=1;reveal=true;reason=failed"))
assertTrue(diagnostics.any { it.startsWith("ruleSwipe;outcome=failed;") })
assertFalse(outcome.message.contains("黑色"))
assertFalse(outcome.message.contains(input().goodsId))
}
@Test
fun `open spec reveal does not skip a missing accessibility root`() {
val driver = FakePurchaseDriver(revealSwipeOutcomes = mutableListOf(
PurchaseSwipeOutcome(PurchaseSwipeResult.ROOT_UNAVAILABLE, 0),
))
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertEquals(0, probes)
assertTrue(outcome.message.contains("scrollables=0;reveal=true;reason=root_unavailable"))
}
@Test
fun `open spec reveal stops remaining swipes after target disappears`() {
val driver = FakePurchaseDriver(revealSwipeOutcomes = mutableListOf(
PurchaseSwipeOutcome(PurchaseSwipeResult.SUCCESS, 1),
PurchaseSwipeOutcome(PurchaseSwipeResult.NO_SCROLLABLE_TARGET, 0),
))
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals(1, driver.swipeCount)
assertEquals(1, pauses.count { it == 1000L })
}
@Test
fun `scrollable spec reveal preserves configured swipe count and interval`() {
val driver = FakePurchaseDriver()
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals(2, driver.swipeCount)
assertEquals(1, pauses.count { it == 1000L })
}
@Test
fun `missing scroll target outside spec reveal remains a mandatory action failure`() {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false)
val configured = rule().replace(
"\"waitAfterMs\":700}",
"\"waitAfterMs\":700,\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500,\"intervalMs\":0}}",
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(outcome.message.contains("scrollables=0;reveal=false"))
assertFalse(driver.panel)
}
@Test
fun `skipping a reveal does not skip a failed specification probe`() {
val driver = FakePurchaseDriver(panelOverride = SpecPanelFixtures.liveShapedSheet())
var probes = 0
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; null }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_NOT_MATCHED", outcome.errorCode)
assertEquals(1, probes)
}
@Test
@@ -1249,6 +1358,8 @@ class PurchaseRehearsalExecutorTest {
private val nonScrollablePanel: Boolean = false,
private val unrecognizedPanel: Boolean = false,
private val purchaseSwipeSucceeds: Boolean = true,
private val panelOverride: UiSnapshot? = null,
private val revealSwipeOutcomes: MutableList<PurchaseSwipeOutcome> = mutableListOf(),
initiallyInAgent: Boolean = false,
private val panelBecomesUnknownAfterSizeProof: Boolean = false,
) : PurchaseUiDriver {
@@ -1351,6 +1462,7 @@ class PurchaseRehearsalExecutorTest {
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
return UiSnapshot(PDD, ACTIVITY, nodes)
}
panelOverride?.let { return it }
if (unrecognizedPanel) {
return UiSnapshot(PDD, ACTIVITY, listOf(
node("content", "", 0, 0, 1080, 2200),
@@ -1532,6 +1644,13 @@ class PurchaseRehearsalExecutorTest {
return purchaseSwipeSucceeds
}
override fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome {
if (revealSwipeOutcomes.isEmpty()) return super.swipePurchaseWithEvidence(direction, durationMs)
val outcome = revealSwipeOutcomes.removeAt(0)
if (outcome.result == PurchaseSwipeResult.SUCCESS) swipePurchase(direction, durationMs)
return outcome
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
swipeInPaths += target.path
if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {
+78
View File
@@ -1,3 +1,19 @@
<!-- gitea-wiki-mirror:start -->
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: 88ed799e92f64a73b3bd9472ab6c0b06fa080b37
synchronized_at: 2026-09-29T09:48:30Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
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: c021b5b8251fecaf1cfa4b96d731964f6b04ca52
synchronized_at: 2026-09-27T02:47:22Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
@@ -349,6 +365,8 @@ PddProductDetailCollector
## 采购规格面板预滑动兼容(#238)
> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。
代码基线 `58a6c1c`,Android 0.9.60 / versionCode 73(构建完成不等同于已安装/发布)。`PurchaseRehearsalExecutor.applyPostAction` 对 `openSpecPanel.swipeAfter` 只兼容解析、不执行机械预滑动,`waitAfterMs` 保留;首趟继续原 `probeSpecs` 遍历,第二趟继续原 `selectSpec` 精确查找与容器内有界滚动。其他动作的后置滑动仍沿用既有执行语义,失败不会被统一忽略。
`GoAutoAccessibilityService.swipePurchase` 的失败分类由 `PurchaseSwipeFailureReason` 枚举提供;共享 `swipeNode` 仅增加可选分类回调,不改变手势目标、轨迹、1500ms 回调等待或其他调用者行为。`GoAutoPurchasePanel` 日志经 `AgentForegroundService` 关联 task、attempt、device 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。
@@ -520,3 +538,63 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- `automation/PurchaseRehearsalExecutor.kt`:`waitForSpecPanel` 对回退类型要求连续两次结构一致;`selectionFailureEvidence` 选中失败诊断。
- `automation/PurchaseLiveAutomation.kt`:`waitForSettledPanelAfterAddressSave` 保存地址后稳定等待(不按返回键);`finalSubmitTargets` 拒绝零尺寸文字/容器;新增构造参数 `panelDiagnostic`(仅布尔/计数)。
- 测试:`SpecPanelRecognitionTest`、`SpecOptionDedupTest`、`SpecPanelFixtures`(脱敏构造样本)。
## GoAuto 售后角色与退货模块权限(#341)
- 追加迁移 `1789801100000_after_sales_role.go` 创建固定角色 `after_sales`(显示名“售后”)。
- 售后角色幂等复制当前采购员角色的菜单绑定与 Casbin API 策略,因此包含采购业务权限以及“yeeke 退货包裹”“yeeke 同步记录”两个退货模块;初始密码仅通过迁移时环境变量注入,不进入代码、日志或文档。
- 迁移创建四个内部账号并绑定售后角色;已存在账号不会重置密码或静默改绑角色。
## #338 退货匹配(2026-09-27)
新增 `server/app/goauto/returnmatch` 模块及 `/api/admin/v1/return-matches` 路由,支持批量匹配、列表/详情、确认、取消和备注;相关表由版本化迁移创建,采购创建在服务端拦截有效退货匹配,售后角色可执行这些接口。
## Yeeke 已重出同步与匹配边界(#345)
- server/app/goauto/yeekeclient/client.go:List 保留默认待出货兼容调用;ListStatus 仅接受状态 1/2,复用 relation/list 请求及原过滤条件。
- server/app/goauto/yeeke/sync.go:每轮顺序遍历两种状态,分页指纹各自独立;共享包裹键处理跨状态重复,状态 2 优先。TotalPages 汇总实际取得的两组页数。两组完成且无写入错误才执行既有 markMissing;不完整分页明确返回失败。
- server/app/goauto/returnmatch/service.go:候选池过滤待出货、已识别与同步可用状态;匹配事务按包裹再明细顺序沿用行锁,并在插入前再次校验。状态变化的候选记为跳过,不撤销既有匹配。
- web/src/views/goauto/yeeke-returns/index.vue:复用原状态筛选和标签组件,增加已重出,待出货使用明确业务名称;接口、数据库结构及权限不变。
- 回归覆盖双状态请求、跨页/跨状态重复与身份保持、第二状态网络失败不标 missing、已重出候选排除和最终校验、Web 状态标签及重置。未调用真实 Yeeke,也未执行线上数据库或发布。
## SYB 批量取消退货匹配(#347)
- Web `syb-products/return-cancel.js` 与列表组件复用单条取消接口;冻结当前页勾选的 SYB 商品 ID 与原匹配 ID,一次确认后串行请求,独立汇总成功、跳过及失败。取消结果不会驱动已有采购或采集任务。
- `returnmatch.Service.Cancel` 在锁定原匹配记录的事务中校验可选状态前提,再释放有效占用指针并写取消审计;事务内审计失败则整体回滚。无额外数据库迁移、权限、批量后台任务或外部写入。
- 元数据加载失败时禁止批量取消;执行时互斥匹配、确认、取消。网络不确定时只读回原匹配,不自动重复写入;登录失效停止后续请求。列表刷新与失败项勾选恢复受查询 generation 约束,不跨页、不覆盖较新的筛选结果。
## SYB 当前页批量使用退货(#349)
实现绑定 4280115(2026-09-29),feat/349-batch-return-use;以 #347 a4c7b5e 为基线,未合并 main、未发布或真实业务验收。
- Web syb-products/index.vue 新增“使用退货 N”,紧跟“取消匹配”;return-confirm.js 冻结当前页原 SYB / match 身份,去重后一次确认、串行复用既有 confirm API,结果独立汇总。return-cancel.js 和单条操作接入使用期间互斥,不重构取消逻辑。
- return-matches.js 的 confirmReturnMatch 增加可选客户端请求选项,仅用于批量汇总时抑制逐项错误弹窗;无新增 HTTP 字段。Server 生产代码、确认锁内 matched 检查、ConfirmedBy/ConfirmedAt、审计和占用指针不变,无迁移或权限变化。
- 未知写结果只读回原 match,回读确认不宣称为本次成功;登录失效停止余项。刷新保持筛选与页码,失败选择恢复受 generation、当前页及原 matched 身份约束。未调用 Yeeke 发货或修改采购任务。
- 回归入口:Web tests/unit/syb-return-confirm.test.cjs、tests/e2e/syb-return-confirm.spec.ts;Server returnmatch/confirm_batch_test.go。测试使用合成数据和隔离 SQLite,不代表生产 MySQL 并发或真实退货验证。
## 档口入库码匹配容错与手动批量重匹配(#350)
- `sybinnercode/match.go` 在 SKU/档口证据筛选与分配结果为空时明确受限,不访问空数组;只读单条规划通过 `safePlanRecord` 隔离异常,下一条继续处理,不调用远端写接口。
- `failMatchJob` 用独立 10 秒清理上下文收敛中断批次及其 pending/matching 记录;成功计划和回写记录保留。`cmd/api/server.go` 在服务启动时调用 `RecoverInterruptedMatches`,不自动重跑匹配或写入。
- rematch 在原行锁事务内检查资格和活动批次占用,排队记录改为 matching,阻止重叠请求;matching 记录不可删除。
- 原页面默认用户浏览器本地今天范围,工具栏“匹配 N”复用现有 rematch 和任务进度;只处理本页合格项,同日提交,原回写需独立确认。
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
## SYB 过滤商品标记与规则重算(#340)
- 数据:`syb_product` 新增 `pdd_purchase_excluded`(索引)、`excluded_rule_id`、`excluded_rule_kind`、`excluded_rule_keyword`、`excluded_at`;新表 `syb_product_filter_recompute_log`(操作人、两方向变更数、两类跳过数、时间)。迁移 `1789801500000_syb_pdd_purchase_excluded`,只新增,存量行默认需 PDD 采购。
- 入库:`sybimport/sync.go applyStockDetail` 命中规则不再 `continue`,把规则快照随明细交给 `apply.go ApplyDetail`;仅新建分支写标记,更新分支保留原标记。
- 阶段与隔离:`purchase/process_stage.go` 新增 `pdd_excluded`(「无需采购」);`purchase/return_match_block.go rejectIfPDDExcluded`(`service.create`,覆盖单条/批量)、`batch.go` 预检、`collection_eligibility.go`、`ai_match_eligibility.go`、`task/image_search.go` 统一拒绝;`returnmatch` 的参与阶段包含 `pdd_excluded`。
- 列表:`sybimport/service.go List` 的 `purchaseType`(`pdd` 默认 / `excluded` / `all`),与 `processStage`、`createdFrom/To` 为 AND。
- 重算:`sybproductfilter/recompute.go`——`recomputeChanges` 规划(只读所需列);`recomputeFingerprint` 对排序后的(id、方向、规则 ID、类型、关键词)JSON 做 sha256;`writeRecomputeChanges` 逐行 `FOR UPDATE` 锁 `syb_product`,并以 `FOR SHARE` 加锁读复查 `purchase_task` / `return_match`(MySQL REPEATABLE-READ 下普通读看不到快照后的提交)。`recompute_mysql_integration_test.go` 在设置 `GOAUTO_IT_MYSQL_DSN` 时于临时库做双连接并发验证。
- Web:`syb-products/index.vue`(采购类型筛选、阶段自动切换、规则显示、按钮计数)、`syb-product-filters/index.vue`(命中/标记口径、重算预览与执行)、`syb-sync-runs/index.vue`(「其中本次规则命中 N 条」)。
## 采购规格面板揭示滑动无目标兼容(#348)
实现绑定 `6550b9f`,Android 0.9.64 / versionCode 77;基于 main `7a2c2d1`,当前仅分支实现,未合并、未发布、未安装真机。
- `PurchaseUiDriver.swipePurchaseWithEvidence` 返回 `PurchaseSwipeOutcome`(结果枚举与可见可滚动节点数)。生产 `GoAutoAccessibilityService` 从同一个实时无障碍 root 统计并定位目标;SUCCESS、NO_SCROLLABLE_TARGET、ROOT_UNAVAILABLE、FAILED 明确区分。原 Boolean swipePurchase 返回语义、目标选择与 swipeNode 不变,不修改采集器。
- `PurchaseRehearsalExecutor.applyPostAction` 仅在 openSpecPanel 的揭示滑动遇到 NO_SCROLLABLE_TARGET 时跳过剩余次数;保留 NON_SCROLLABLE_CONFIRMATION 原例外。其他动作仍走原 Boolean 必需滑动路径;root 不可用或有目标而手势失败仍 RULE_ACTION_FAILED。
- 失败/跳过通过既有 panelDiagnostic 回调写 GoAutoPurchasePanel 结构日志,仅含面板枚举、scrollables 计数、reveal 布尔与固定结果原因;失败摘要带同样标量并沿原任务结果提交。没有节点文字、坐标、树或截图。
- probeSpecs、selectSpec、最终复核、规则 JSON、能力、服务端、Admin、数据库、任务租约及下单/支付边界不变。无滑动不代表规格探测成功。
+90 -2
View File
@@ -1,3 +1,19 @@
<!-- gitea-wiki-mirror:start -->
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: 5d9810286d76c52a90902c27a5316f39164f36e0
synchronized_at: 2026-09-29T09:48:45Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
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: 25e61fdbdfe1b3e70fec6d353620170ce14eb31f
synchronized_at: 2026-09-27T02:47:26Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
@@ -59,6 +75,7 @@ synchronized_at: 2026-09-22T02:56:27Z
- SYB 商品页保留“解析状态”,另按 SYB、蝦皮、PDD、采集任务和采购任务的当前事实动态派生唯一“处理阶段”;不在 `syb_product` 保存容易过期的阶段字段。
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、规格待匹配、可创建采购、已创建任务、采购成功、待人工核对。
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 规格待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
- #338/#340 后在「已创建任务」之后、「待人工处理」之前依次插入:有效退货匹配(退货待确认 / 已用退货)→ 无需采购(`pdd_purchase_excluded`)。即进行中、已成功及结果不明的采购任务仍优先显示;失败/取消的旧任务不占主阶段时,标记为无需 PDD 采购的行显示「无需采购」。
- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。
- 一个 PDD 订单号只能属于一个采购任务,该唯一性在采购任务保存路径上全局强制(#241)。人工处理结果未知、取消及 lifecycle 保存路径撞号时返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`,提示订单号已属于哪个任务,由采购员人工核对,不静默覆盖原值。
- 不可逆边界之后的 `order_created` 结果回传是上述规则的例外:此时 PDD 真单已创建,发现订单号已属于其他任务时不回滚、不判失败,而是把任务降级为 `order_result_unknown`,冲突订单号以「读到订单号 X,但该号已属于任务 CG-yy」保存在任务与 attempt 的 `error_message`,`pdd_order_no` 留空以维持唯一性,保留下单时间与不可逆时间,进入既有人工处理结果未知通道。首要目标是保住「真单已存在」这一事实,不制造无记录的真实订单。
@@ -319,11 +336,11 @@ synchronized_at: 2026-09-22T02:56:27Z
## SYB 档口入库码导入、匹配与回写
- 只接受不超过 10 MB、最多 5000 条非空数据行且包含“标签入库码映射”工作表的 Excel。一次文件只能包含一个营业日期;同日单件入库码必须唯一,同一业务键按营业日期、订单号、档口和规范化规格合并并保留 Excel 顺序。
- 导入事务提交后自动创建并启动只读匹配任务,不提供独立“匹配”按钮。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。
- 导入事务提交后自动创建并启动只读匹配任务,手动批量重匹配入口见 #350 的版本绑定规则。再次导入同日数据只允许整体替换仍处于等待匹配的数据;存在匹配或回写证据时拒绝覆盖。
- 匹配必须按精确订单号取得唯一货运单,再以规格、原始 SKU 和档口证据确定唯一商品;歧义、冲突或数量不一致均不猜测。匹配阶段不能调用任何 SYB 写接口。
- 回写前弹窗展示业务记录数、入库码总数、预计占位明细数和替换旧码数。只有 `ready` 记录可提交;所有服务实例共用数据库租约全局串行执行。
- 每个远端写动作只发送一次,动作前重读整张货运单并校验匹配计划未漂移;每件写入后重读确认目标码唯一位于预期明细。超时、5xx、响应无法确认或服务在写入期间重启时转为 `needs_check`,禁止自动重试;“只读复核”只能读取远端状态。
- 列表支持勾选后批量物理删除。删除必须再次确认;选中项包含 `queued`、`applying` 或 `needs_check` 时整批拒绝,不做部分删除。其余选中业务记录、逐件码、计划和终态执行证据在同一事务中物理删除;已写入 SYB 的远端值不会撤销。
- 列表支持勾选后批量物理删除。删除必须再次确认;选中项包含 `matching`、`queued`、`applying` 或 `needs_check` 时整批拒绝,不做部分删除。其余选中业务记录、逐件码、计划和终态执行证据在同一事务中物理删除;已写入 SYB 的远端值不会撤销。
## PDD 失效或售罄商品替换(#129)
@@ -472,6 +489,8 @@ synchronized_at: 2026-09-22T02:56:27Z
## 打开采购规格面板后按需滚动(#238)
> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。
- Android 0.9.60 / versionCode 73,代码 `58a6c1c` 起,规则 `openSpecPanel.swipeAfter` 保留格式校验与旧快照兼容,但不执行打开面板后的固定次数预滑动;不以“必须滑两次成功”作为进入规格探测/选择的条件。动作后的 `waitAfterMs` 仍生效。
- 首趟规格探测和第二趟精确选择仍使用各自既有的按需横向/纵向、有界与稳定终止策略。取消预滑动不等于不探测隐藏规格,也不等于只看首屏。目标不存在、歧义、页面证据不足或必要的有界查找失败时仍明确失败。
- 此调整覆盖所有已经安全识别打开的面板,不再仅特判 NON_SCROLLABLE_CONFIRMATION;不弱化面板验证、精确选中、地址、价格、任务租约、创建订单边界或禁止支付规则。
@@ -680,3 +699,72 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
### 采购手机环境前提
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
## 管理端售后角色(#341)
- 管理端固定增加 `after_sales`(显示名“售后”)业务角色。售后账号拥有采购员当前权限,并额外可访问“yeeke 退货包裹”和“yeeke 同步记录”两个模块及其接口。
- 售后角色与账号由版本化迁移幂等维护;创建初始账号时由部署环境临时提供密码,迁移不输出或持久化明文密码。已存在账号若绑定其他角色则迁移失败并要求人工处理。
- 本角色不获得管理员专属的用户、角色、设备凭据、规则配置或 AI Provider 管理权限,也不新增支付能力。
## 退货匹配(#338)
退货匹配只由人工勾选 SYB 商品后触发。按虾皮商品 ID 与归一化规格匹配,状态为“退货待确认”或“已用退货”时拦截新采购,取消后恢复可采购;已有采购任务不受影响。售后与采购员可操作匹配、确认、取消和备注。
## Yeeke 双状态退货同步(#345)
- 同一次同步顺序拉取待出货(status=1)和已重出(status=2);保持 claimFlag=1、relationFlag=1 和创建时间倒序。两种业务状态均是已识别状态,列表及详情分别展示并可按状态筛选。
- 两组分页独立判重;仅两组均自然完整结束且无写入失败时,才沿用既有安全阀标记未见数据为 missing。重复页、页数上限、网络或写入错误均记录失败,已成功写入的页保留,不误报完整成功。
- 沿用既有包裹和明细标识幂等更新;同一轮跨状态重复时已重出优先,不新增副本。
- 新退货匹配仅使用状态为待出货、已识别且同步可用的包裹和明细;落库前再次校验。已重出不参与新匹配,不自动取消已有匹配。
- 本次无新增字段、迁移、权限或自动采购动作;真实 Yeeke 数据及发布后页面效果仍需人工验收。
## SYB 当前页批量取消匹配(#347)
- 管理员、采购员、售后可在 SYB 商品列表“匹配退货”后使用“取消匹配 N”。仅处理当前页勾选且处理阶段为 `return_pending`、原匹配仍为 `matched` 的明细;`return_used` / `confirmed` 不参与批量取消。新增勾选资格不扩大采购、采集、图搜或 AI 匹配候选范围。
- 一次确认后按原匹配编号逐条取消。并发确认、已经取消等状态冲突跳过;其他错误保留失败。网络异常先回读原记录,不盲目重试,不追随后来重新匹配的新记录;登录失效停止剩余写入。
- 成功后重新计算采购准备状态,并按当前退货状态与同步可用性判断退货池资格,不保证所有商品立即可采购,不改变既有采购或采集任务。保持查询条件刷新;仅保留仍在当前页、仍为原匹配且可取消的失败项勾选,其余失败项保留在结果表。
- 原有单条确认及取消兼容:未携带状态前提的单条取消仍允许取消已确认匹配;本次不新增权限或迁移,不自动创建采购或执行付款。
## SYB 当前页批量使用退货(#349)
实现绑定 4280115(2026-09-29),尚未合并/发布,待验收。
- 管理员、采购员及售后沿用既有权限,工具栏为“匹配退货 → 取消匹配 → 使用退货”。仅当前页勾选且处理阶段 return_pending、原有效匹配 matched 的明细计入使用数量;已用退货与未匹配行不参与。
- 一次确认后逐条标记 confirmed / 已用退货,保留有效占用;不是 Yeeke 发货或重出,不修改已有采购/采集任务,不自动创建采购或支付,不扩大原候选资格。
- 只操作冻结的原 match ID。并发确认或取消冲突跳过,不追随后来的替代匹配。其他错误失败,各项不互相回滚;登录失效停止后续写入并明确未执行。
- 网络结果未知先回读原记录:已确认/已取消显示回读状态而不是本次成功;仍为原 matched 可人工重试;回读失败保留不确定结果,不自动重复提交。
- 完成保持筛选、页码和结果明细。成功项在退货待确认筛选下移出;仅当前页仍为原 matched 的可重试失败项恢复勾选,离页结果不丢失、不跨页操作。操作中互斥匹配、使用、取消及单条操作,无新增审批或权限。
## 档口入库码手动匹配与中断恢复(#350)
- 页面首次进入默认本地今天~今天;清除筛选明确取消日期限制。可勾选当前页 pending/failed/skipped 记录点击“匹配 N”;混选只提交合格项,跨营业日期提示分日期处理。加载/列表失败/提交/活动匹配期间禁用匹配。
- 手动匹配仍按原精确订单、规格、SKU/档口和数量证据,不可强行选近似商品。空候选明确 skipped;单条规划异常 failed 并继续后续记录;匹配成功仅 ready,不自动回写。
- 原 rematch 可接受同日 pending/failed/skipped;已有活动匹配任务占用或其他状态均拒绝;同 requestId 幂等重放,同记录排队为 matching。matching 期间不可删除,成功/回写/需复核记录不能重新匹配。
- 超时/中断以及启动恢复把未完成 pending/matching 改为明确失败并将旧批次结束;既有 ready/updated/回写证据和范围外记录不修改,用户可手动重试。恢复不调用 SYB 写接口,不自动回写、不删除成功结果。
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
### 档口入库码排版字符兼容(#350 v2)
匹配比较时,双方原始 SKU、variationSku 及档口名称仅去除 CR/LF/TAB 排版控制字符,并沿用首尾空白 trim;普通内部空格、大小写、标点与货号不变。导入原始值和回写计划中的 SYB 原始证据保持原样。归一化后重复候选、SKU/档口冲突仍拒绝,原精确规格、货号、数量与占用校验不放宽。成功匹配只形成可回写计划,不自动执行 SYB 写入。
### 档口入库码混选回写范围(#351)
列表“回写 N”仅统计当前页勾选且 ready 的唯一记录,混选其他状态不阻塞合法项。打开确认弹窗时冻结本次选中数量及合格 ID;只预览、确认提交这些 ID,不随列表刷新或勾选变化扩大范围。弹窗明确显示选中/可回写/跳过数量,入库码、占位及替换旧码指标仅针对待提交记录。
预览发现候选状态变化时剔除 blocked 项,最多再进行一次只读预览以刷新指标;无合格项、预览失败/范围不一致或再次变化时禁用确认,提示重新预览。确认提交中防重复,未知提交结果不自动重试,沿用批次查询与只读复核。服务端原 ready 状态、唯一匹配计划及执行校验不变;仅用户人工确认后才进入回写,不自动写入。
## SYB 过滤商品入库与「无需采购」(#340)
- 命中 SYB 过滤规则(结构 `-#` 或关键词)的订单明细不再跳过,而是入库并标记「无需 PDD 采购」,同时保存命中规则快照;店铺过滤不变。
- 标记只在明细首次入库时确定;重复同步不改标记,规则启停或删除也不自动改变已有标记。历史被跳过的明细不自动补同步。
- 带标记的商品**硬性隔离所有 PDD 流程**:创建采购(单条、批量)、采购预检可采购结果、创建采集资格、AI 规格匹配、图搜采集均由服务端拒绝或判为不可用,错误码 `PURCHASE_PDD_EXCLUDED`;前端隐藏只是辅助。
- 带标记的商品可参与退货匹配:匹配后「退货待确认」,确认后「已用退货」,取消后回到「无需采购」。
- SYB 商品页新增「采购类型」筛选:需 PDD 采购(默认、重置后同)/ 无需 PDD 采购 / 全部,与处理阶段、创建时间等筛选同时生效;选择「退货待确认」「已用退货」「无需采购」阶段时自动切到「全部」,可手动改回。无需采购行显示命中规则,可勾选但只计入「匹配退货」,不显示单条「采购」。
- 管理员「按当前规则重算」:先预览(两个方向的变更数、因已有采购任务 / 有效退货匹配跳过的数量、最多 20 条样例)再确认执行。执行必须携带预览指纹,数据、规则或其证据变化时拒绝并提示重新预览,不静默执行另一批。执行时逐行加锁并以加锁读复查:该商品有任何采购任务或有效退货匹配则跳过;审计日志与返回数量按实际写入/跳过计。规则变更从不自动重算。
- 统计口径:同步记录与规则列表的「命中」是本次同步命中规则的次数(含已存在明细);「当前带此规则标记的明细 N 条」按数据库实际统计。两者不得混用。
## 采购规格面板揭示滑动无目标兼容(#348)
Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装/发布)。已安全打开规格面板后,规则 openSpecPanel.swipeAfter 的某次揭示滑动若没有任何可见可滚动目标,则无需滑动,跳过本动作剩余次数并记录诊断;不再因缺少滚动节点而失败。保留 NON_SCROLLABLE_CONFIRMATION 原跳过行为。
有可滚动目标却派发失败、被取消或等待失败,以及无障碍 root 不可用,仍明确失败;其他动作的必需滑动不放宽。有可滚动目标时保留规则 count、direction、durationMs、intervalMs 与 waitAfterMs。后续规格探测、精确选择与最终复核全部照常,缺规格或探测无结果仍失败,不猜测、不新增点击目标或支付。
+56 -7
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:15:05Z
wiki_revision: 18260ec2725eaeb4dcf2543422d58ee4ede8b981
synchronized_at: 2026-09-29T09:50:08Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -87,7 +87,7 @@ PATCH /api/admin/v1/syb-products/{productId}/correction
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `partial_success` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`),不含原始 JSON;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`,#340 起另有 `pddPurchaseExcluded`、`excludedRuleId`、`excludedRuleKind`、`excludedRuleKeyword`、`excludedAt`),不含原始 JSON;`purchaseType`(#340)取 `pdd`(默认)/ `excluded` / `all`,其他值 422,与 `processStage`、`createdFrom`/`createdTo` 同时生效;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
`reparse` 与 `reparse-batch` 只读取已保存的 `rawJson` 重新执行解析规则,不请求 SYB 接口;请求体 `force` 为 `true` 时才覆盖 `manuallyConfirmed` 的行,默认跳过并在批量结果中标记 `skipped_manual`。批量结果逐条返回 `outcome`(`reparsed` / `skipped_manual` / `unchanged`)与解析状态变化,`ids` 中任何一个不存在都会使整个请求返回错误(与虾皮商品批量删除的"部分成功"语义不同:ID 不存在通常是操作员选错了页面)。
@@ -115,9 +115,11 @@ GET /api/admin/v1/syb-product-filters
POST /api/admin/v1/syb-product-filters
PATCH /api/admin/v1/syb-product-filters/{id}/enabled
DELETE /api/admin/v1/syb-product-filters/{id}
GET /api/admin/v1/syb-product-filters/recompute/preview
POST /api/admin/v1/syb-product-filters/recompute/execute
```
同步时按 `variationSku` 决定明细是否入库。规则分两类,由 `kind` 区分:`char` 是结构判据
同步时按 `variationSku` 判定明细是否「无需 PDD 采购」(#340 起命中者照常入库并打标记,不再跳过)。规则分两类,由 `kind` 区分:`char` 是结构判据
(`-`、`#`),`keyword` 是关键词清单。列表支持 `kind`、`keyword` 和分页,返回 `keyword`、
`normalizedKeyword`、`enabled`、`lastHitCount`、`lastHitAt`、`note`,`char` 另有
`lastChangedBy`、`lastChangedAt`。
@@ -126,9 +128,20 @@ DELETE /api/admin/v1/syb-product-filters/{id}
`FORBIDDEN`(`结构过滤不可新增` / `结构过滤不可删除`),不得只依赖前端隐藏按钮。
`kind=char` 的 `PATCH enabled` 允许,并记录操作人与时间。
`[必须]` `lastHitCount` 是每条规则各自的命中数,由同步结束时回写;未命中的规则写 0,
从未同步过为 `null`。停用 `kind=char` 的二次确认提示条数必须取该行实时的 `lastHitCount`,
不得写死——写死的数字在数据变化后会给出错误警告。
`[必须]` `lastHitCount` 是每条规则各自在上次同步中的**命中次数**(新建与已存在明细都计),
由同步结束时回写;未命中的规则写 0,从未同步过为 `null`。页面称「上次同步命中」。
列表另返回 `markedCount`(#340):查询时按数据库实际统计的「当前 `pdd_purchase_excluded=true`
且 `excluded_rule_id` 为该规则」的明细数,一次分组查询得出。停用 `kind=char` 的二次确认提示
条数必须取实时的 `markedCount`,不得写死,也不得用 `lastHitCount` 冒充已标记数量。
`recompute/preview`、`recompute/execute` 仅管理员可用(其他角色 403)。预览只读,返回
`excludedToPdd`、`pddToExcluded`、`skippedHasTask`、`skippedReturnMatch`、最多 20 条
`samples`(`orderCode`、`shopeeItemId`、`direction`=`excluded_to_pdd`/`pdd_to_excluded`、
`ruleKind`、`ruleKeyword`)及 `fingerprint`。执行请求体 `{"fingerprint": "..."}` 必填(空值 422);
服务端在事务内重新规划并比对指纹(涵盖商品、方向、规则 ID、类型、关键词),不一致返回
`409 RECOMPUTE_PREVIEW_STALE`(「数据或规则已变化,请重新预览后再执行」)且不写入。
执行逐行加锁复查,有采购任务或有效退货匹配的商品跳过;返回实际写入/跳过数量与 `operator`,
并写 `syb_product_filter_recompute_log`。
关键词归一化与店铺一致:去首尾空白、全角/半角统一、忽略大小写;**不做简繁转换**,SYB 数据
为繁体。唯一性按 (`kind`, `normalizedKeyword`) 判定,重复添加返回
@@ -1166,6 +1179,8 @@ Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口
### openSpecPanel 后置滑动兼容与诊断(#238)
> 历史分支版本说明:本节绑定的 `58a6c1c` 不在本次核验的 main `7a2c2d1` 祖先中,不能据此宣称当前 main 或已安装 APK 已取消全部预滑动。#348 的窄范围实现与版本边界见本页对应补充。
版本边界:Android 0.9.60 / versionCode 73,代码 `58a6c1c`。不修改 JSON schema、能力标识、任务接口或已有快照哈希。`openSpecPanel.swipeAfter` 仍按 direction/count/durationMs/intervalMs 原约束校验;解析成功后不执行该准备性滑动,`waitAfterMs` 保留。其他动作后置滑动沿用旧执行语义。旧 Server 可继续下发原快照;旧 APK 仍按原策略执行,不能将本契约描述当作旧设备已获得兼容。
规格探测与精确选择自行负责按需有界滚动,原始候选、精确点击和选中复核不变。跳过预滑动不作为规格探测成功或订单创建证据。
@@ -1399,3 +1414,37 @@ Android 仅人工“回填”扫描识别单节点“实付”标签,兼容实
GET /api/admin/v1/purchase-tasks 的既有 status 参数新增查询专用值 syb_writeback_succeeded。该值按独立 purchase_order_writeback.status=succeeded 且 purchase_task_id 对应当前采购任务过滤,不查询旧物流 writeback_status,不新增或改写任务状态。原 status 合法值及其他参数行为保持不变。
匹配成功回填的历史任务,不额外要求当前采购状态为 order_created;返回的 task.status 仍为真实采购状态。无单号队列及 pending/running/failed/unknown/conflict 不命中。count及分页共用子查询过滤,可与 taskId、executionMode、taskType、sybProductId、shopeeOrderNo、pddOrderNo 组合;status为空恢复不限制状态。权限与返回信封不变,不写入数据、不触发采购或SYB请求。先发布Server再发布新增筛选值的Web,无数据库迁移或Android变更。
## 管理端退货匹配取消前提(#347)
此为 Admin 共享管理接口补充,不改变 Android Agent 契约。
`POST /api/admin/v1/return-matches/:id/cancel` 可携带 `{"expectedStatus":"matched"}`。仅允许此值;其他值(含空字符串、null)或错误 JSON 返回 HTTP 400 / `INVALID_REQUEST`。省略请求体或属性兼容旧单条取消。
携带前提时,服务端在锁定 `:id` 原记录的事务中检查状态仍为 `matched` 且有效占用指针存在,否则 HTTP 409 / `STATE_CONFLICT`,不取消已确认或已取消的匹配。不存在返回 HTTP 404 / `NOT_FOUND`。成功仍为 `{code:200,data:{item:ReturnMatch}}`,释放占用并记录 CancelledBy / CancelledAt 和审计日志。权限沿用 admin / purchaser / after_sales,没有新增批量接口或权限点。
Web 批量入口冻结原匹配 ID 并顺序调用;HTTP 409 状态冲突跳过,权限/网络错误失败,HTTP 401 或登录失效停止后续操作。写入结果未知时只允许只读回读原记录,不自动重发取消;不以 SYB ID 重新查询替代匹配并取消。
## Admin 批量使用退货复用确认接口(#349)
实现绑定 4280115(2026-09-29),未合并/发布;仅补充管理端调用规则,Android Agent 契约不变。
- Web 当前页批量使用复用 POST /api/admin/v1/return-matches/:id/confirm,无新增请求体、批量接口、状态或权限。Server 沿用锁定原记录后仅接受 matched;成功为 {code:200,data:{item:ReturnMatch}},写 confirmed、确认人/时间及审计,保留 active 占用。
- HTTP 409 / STATE_CONFLICT 跳过,HTTP 404 / NOT_FOUND 或其他处理错误明确失败。沿用 admin / purchaser / after_sales 权限;HTTP 401、业务认证错误及旧拦截器 false 结果均停止余项,不能误计为成功。
- 写结果不明确时仅 GET /api/admin/v1/return-matches/:id 回读冻结原记录,验证 match 身份;已 confirmed/cancelled 显示回读已处理,仍 matched 才允许手动重试。不得自动重发 POST 或改查新匹配后使用。
- 无数据库、Server 生产行为、Agent 协议或外部 Yeeke 写入变化。真实业务确认及部署另需授权。
## Admin 档口入库码 rematch 补充(#350,Agent 接口不变)
- `POST /api/admin/v1/syb-inner-codes/rematch` 保留 `{requestId, ids}` 与响应 `{matchJobId, queued}`;原权限与客户端密钥 match 功能权限不变,不新增权限项或迁移。
- 同日 pending/failed/skipped 可以排队;matching、ready、updated、already_filled、queued、applying、needs_check 和活动匹配占用拒绝 409。排队事务记录 matching,避免新 requestId 重叠提交;同 requestId 返回原结果。
- 任务正常结束沿用 succeeded + ready/failed 分项计数;批次中断/服务启动恢复为 failed 并提供安全说明与 finishedAt,未完成记录 failed 可重新匹配,已完成结果不回滚。匹配仅只读 SYB,成功需另行 apply 确认。
- batch-delete 在既有阻塞状态基础上增加 matching,409 整批拒绝;避免删除正在匹配的记录。Android/采购/付款流程无变化。
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
### openSpecPanel 无滚动目标兼容与诊断(#348)
版本绑定 `6550b9f`,Android 0.9.64 / versionCode 77(分支实现,未合并、安装或发布)。本次没有 JSON schema、动作参数、能力标识、任务接口或规则快照哈希变化。旧 Server 继续下发相同快照;旧 APK 不因本文更新而改变行为。
仅 openSpecPanel.swipeAfter 的 NO_SCROLLABLE_TARGET 可跳过剩余揭示滑动;NON_SCROLLABLE_CONFIRMATION 原例外保持。存在目标而手势失败、窗口 root 不可用,以及其他动作的必需滑动失败,仍返回 RULE_ACTION_FAILED;后续 probeSpecs/selectSpec/最终复核不跳过。
既有本地 GoAutoPurchasePanel 回调增加 ruleSwipe 事件:outcome=skipped_confirmed_panel|skipped_no_scroll_target|failed;panel 为面板枚举,scrollables 为非负计数,reveal 为布尔,失败/无目标 reason=success|no_scrollable_target|root_unavailable|failed 中实际适用的固定值。失败 error message 增加这些标量,原 task/attempt/device/规则快照绑定与结果提交字段不变。不记录节点文本、规格、地址、订单、坐标、树、整屏截图或凭据。
+25 -7
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: SYB-ERP-Interface-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
synchronized_at: 2026-09-21T08:15:30Z
wiki_revision: 5979a14250c96c3c92348a947c2bc8d3a078c9f4
synchronized_at: 2026-09-29T08:18:40Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
@@ -496,12 +496,14 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
店铺过滤只决定明细获取和入库,不能减少原始列表完整性校验范围;已保存不代表整日完整。
`[必须]` **店铺准入之后再按 `variationSku` 过滤商品(#269)。** 顺序固定为:店铺准入 →
结构过滤 → 关键词过滤 → 入库。两类规则都存在 `syb_product_filter`,由 `kind` 区分:
结构过滤 → 关键词过滤 → 入库。两类规则都存在 `syb_product_filter`,由 `kind` 区分。
自 #340 起命中规则的明细**同样入库**,只在 `syb_product` 上标记「无需 PDD 采购」,不再跳过(见本节末尾):
- `kind=char`:`-` 和 `#` 两条,命中任意一条即跳过(**OR,不是 AND**)。这两个字符是
「档口-供应商#货号」编码格式的判据。2026-09-11 核验线上 13010 行明细:含 `#` 8498 行、
含 `-` 6392 行、两者都含 6386 行、任一 8504 行(65%)。用 AND 只命中 6386 行,会漏掉
`DD#004`、`300斤牛奶絲圓領#A057` 这类只含 `#` 的 2118 行。
- `kind=char`:当前只有一条结构规则 `-#`,`variationSku` 须**同时包含** `-` 和 `#` 才命中
(#286 起合并为一条、要求全部字符;代码见 `sybproductfilter.RuleSet.Match` 的 `containsAllRunes`)。
这是「档口-供应商#货号」编码格式的判据。`[历史]` #269 初版曾是 `-`、`#` 两条、任一命中
(2026-09-11 核验线上 13010 行:含 `#` 8498、含 `-` 6392、两者都含 6386);#286 改为
同时包含,只含 `#` 的编码(如 `DD#004`)不再命中结构规则。
- `kind=keyword`:关键词清单,匹配 `variationSku`,**不匹配 `productTitle`**。同一次核验
中六条初始关键词在 `productTitle` 上命中为 0,且全部已被结构过滤覆盖,净增为 0;保留它
是为档口改用不含 `#` 的编码时兜底。
@@ -516,6 +518,15 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
`[必须]` `kind=char` 不可新增、不可删除,只能停用,且停用需要二次确认并记录操作人与时间。
判据本身需要变更(例如档口改用 `/` 或 `@`)属于范围变化,应另建工单评估。
`[必须]` **命中规则 = 标记无需 PDD 采购,不再跳过入库(#340)。** 启用店铺内命中结构或关键词规则的
明细照常写入 `syb_product`,并记录 `pdd_purchase_excluded=true` 与命中规则快照
(`excluded_rule_id`、`excluded_rule_kind`、`excluded_rule_keyword`、`excluded_at`)。
标记**只在该明细首次创建时**按当次启用规则确定;之后重复同步同一明细不改变标记,
规则启停或删除也不会自动改变已有标记,只能由管理员「按当前规则重算」变更。
历史上被跳过的明细不自动补同步;需要时按日期手动导入即可补入并打标记。
`char_filter_skipped` / `keyword_filter_skipped` 与每条规则的 `last_hit_count` 仍是**本次命中次数**
(新建与已存在明细都计),不是「本次被标记的数量」,页面文案须称「命中」。
`[必须]` 同步开始时只读取一次启用店铺,整次运行使用同一个快照。列表允许但明细
响应中的 `shopName` 变为空或非允许店铺时再次拦截。没有启用店铺时在会话/OCR/
验证码等任何顺运宝请求之前停止,并且不推进覆盖游标。该过滤只影响后续入库,
@@ -525,6 +536,13 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
它是反复启动的一次性脚本,进程间要传会话;GoAuto 服务端是常驻进程,没有这个需求,
持久化只为重启后免登录,复用现有数据库即可。表见 `syb_session`。
`[必须]` 同步和采购订单回填共用统一会话获取器:先校验 `syb_session`,只有明确
会话失效或不存在时才进入登录。登录由 `syb_session_auth_lease` 的单账号租约串行化,
租约内其他任务等待新会话,不重复请求验证码;登录成功后原回填任务重新执行并回读确认。
网络超时、5xx 或 OCR 不可用不得清除有效会话,必须保留结构化失败状态供人工重试。
回填终态同时同步到 `purchase_task.writeback_status/writeback_at`,页面不得继续显示旧的
`not_selected`。
`[决定已变更]` ~~不引入 OCR 服务。~~ 这条判断在上游工单 #47 里被推翻了,
原文和推翻理由都留在这里,方便后来人知道这个决定变过、为什么变:
+96 -3
View File
@@ -1,3 +1,19 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 6d3c599a45813ad844395e93a02832f0d28dd3f5
synchronized_at: 2026-09-29T03:47:47Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 1195a0fab799c064c7e279443dd57977da63c94b
synchronized_at: 2026-09-27T02:47:34Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
@@ -10,13 +26,47 @@ synchronized_at: 2026-09-22T02:56:36Z
## 当前线上拓扑
- 外部入口:`http://185.216.248.75:9527`,Nginx 同时承载 Admin 静态资源并反向代理 GoAuto API。
- 外部入口:`http://122.228.200.167:9527`(2026-09-28 起;此前为 `185.216.248.75:9527`,旧机 `goauto.service` 已停止)。Nginx 在 9527 **直接提供 Admin 静态资源**,未命中静态文件的请求反向代理到 GoAuto API,配置见下节「Nginx 入口(9527)」。
- GoAuto 服务监听:`127.0.0.1:8010`。
- 常驻服务:systemd `goauto.service`。
- 常驻服务:systemd `goauto.service`(新机依赖 `mysql84-cmhub.service`)。
- 工作目录:`/home/goauto/current`,指向 `/home/goauto/releases/<发布标识>`。
- 服务配置:`/home/goauto/current/config/settings.yml`;敏感环境变量由 `/etc/goauto/goauto.env` 提供,不写入 Git、Wiki、工单或日志。
- Agent APK 私有目录:相对工作目录的 `var/goauto-agent-releases`;下载必须通过已认证接口。
## Nginx 入口(9527)
线上 vhost:`/www/server/panel/vhost/nginx/goauto-9527.conf`(宝塔面板目录)。标准配置:
```nginx
server {
listen 9527 default_server;
server_name 122.228.200.167 _;
root /home/goauto/current/dist;
index index.html;
client_max_body_size 100m;
allow all;
location = / {
try_files /index.html =404;
}
location / {
try_files $uri @goauto_backend;
}
location @goauto_backend {
proxy_pass http://127.0.0.1:8010;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
}
}
```
`[必须]` 不得把 9527 写成「全部 `proxy_pass` 到 8010」:GoAuto 服务端(go-admin)在 `GET /` 注册了「GO-ADMIN欢迎您」欢迎页,前端 SPA 只通过未匹配路由兜底提供,全部转发时首页就会显示欢迎页而不是 Admin 后台(2026-09-28 迁移时发生过,见 #346)。`location = /` 必须由 Nginx 返回 `dist/index.html`;其余路径先找静态文件,找不到再交给后端(后端对非 API 的 GET 返回 index.html 以支持前端路由,对 `/api/` 等返回真实结果)。修改后先 `nginx -t` 再 `nginx -s reload`,改前备份原文件。
## Agent HTTP 例外
服务端生产模式默认要求 Agent 使用 HTTPS。当前线上入口只有 HTTP,因此经 #181 用户明确确认,在 `/etc/goauto/goauto.env` 设置:
@@ -33,7 +83,13 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
1. 在本地完成服务端测试/构建和 Android 单测/APK 构建,记录提交、versionCode、SHA-256 与大小。
2. 创建新的 `/home/goauto/releases/<发布标识>`,复制服务端二进制、Web 静态资源和非敏感配置;保留旧发布目录用于回滚。
3. 原子切换 `/home/goauto/current` 后重启 `goauto.service`,确认 `systemctl is-active goauto.service` 为 `active`,并从外部入口验证 API。
3. 原子切换 `/home/goauto/current` 后重启 `goauto.service`,确认 `systemctl is-active goauto.service` 为 `active`,再从外部入口**按内容**验收(只看 HTTP 200 不算通过——欢迎页、错误页也可能是 200):
- `GET /`:返回 HTML,包含 `id="app"`,且**不包含**「GO-ADMIN欢迎您」;
- 前端路由(如 `GET /login`):同样返回 Admin 的 index.html;
- `index.html` 引用的 `/js/…`、`/css/…` 资源:HTTP 200;
- `GET /api/v1/captcha`:`application/json` 且 `code=200`;
- 任一业务接口未登录访问(如 `GET /api/admin/v1/yeeke-returns`):JSON 业务码 401;
- 结构日志无 panic/fatal/1146/1054。
4. 上传 APK 到 Admin Agent 版本并按需设为当前;服务端解析 Manifest,校验 versionCode 唯一性并保存 SHA-256。
5. 真机安装前确认设备没有运行中的任务。Android 系统安装确认仍由人工完成,Agent 不静默安装。
@@ -45,6 +101,19 @@ Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允
Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。
## 服务器迁移清单
更换线上服务器时逐项核对,每项都要在新机上回读确认:
1. **Nginx**:9527 vhost 按上节标准配置写入(root 指向 `/home/goauto/current/dist`,`location = /` 返回 index.html),`nginx -t` 通过后 reload;
2. **发布目录**:`/home/goauto/releases/<发布标识>` 含二进制、`dist/index.html`、`config/settings.yml`(含 `extend.syb`、`extend.yeeke` 段);`current` 软链接指向它;静态目录与 `var`(APK 私有目录)随迁;属主 `goauto`;
3. **环境变量**:`/etc/goauto/goauto.env` 与旧机字段一致(`GOAUTO_DB_*`、`GOAUTO_SERVER_PORT=8010`、`GOAUTO_WEB_DIST`、`GOAUTO_CONFIG`、SYB/yeeke 账号、Agent HTTP 例外等),权限 600 `root:goauto`;含中文等非 ASCII 值时按字节核对,不能只比长度;
4. **systemd**:`goauto.service` 的 `WorkingDirectory`、`EnvironmentFile`、`ExecStart` 与依赖的数据库服务;
5. **数据库**:数据完整迁移后,`sys_migration` 最新版本与旧机一致;
6. **定时任务**:`sys_job` 启用状态与旧机一致,避免新旧两机同时执行同一定时任务(旧机须停服);
7. **外部依赖**:SYB、yeeke、OCR 服务可达,先用手动同步验证登录;
8. 按「发布与验证」第 3 步做按内容验收;更新本页「当前线上拓扑」。
## 回滚
服务异常时把 `/home/goauto/current` 切回上一已验证发布目录并重启 `goauto.service`,随后复核服务状态和 Agent 接口。不要删除当前或历史 APK/发布目录来代替回滚;数据库变化如需回退必须单独评估。
@@ -153,3 +222,27 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
5. GoAuto Agent 已启动,无障碍服务已开启,Admin 设备列表显示在线并已归属对应采购员账号。
任一项不满足时采购会明确失败并提示缺失项(如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”);Agent 不代为登录、切换支付方式或填写地址。安装新版 Agent 后需手动启动一次 App,心跳恢复后设备才显示在线。
## #341 售后角色与账号迁移
- 发布前需单独授权执行 `server/cmd/migrate/migration/version-local/1789801100000_after_sales_role.go`。迁移会创建/维护 `after_sales` 角色、复制采购员当前菜单/API 授权,并创建四个售后账号;不会重置已存在账号密码。
- 首次创建账号时仅在当前迁移进程设置 `GOAUTO_AFTER_SALES_INITIAL_PASSWORD`,执行完成后立即清除;不得把密码写入仓库、日志、工单或 Wiki。
- 本地和线上均执行迁移后回读角色、四个账号的角色绑定、退货接口策略及幂等结果,再按常规流程重启 GoAuto API。
## #338 退货匹配发布
发布时执行退货匹配及售后权限迁移,切换 Server/Web release,重启 `goauto.service` 并 reload Nginx;发布后验证健康接口、Web 首页、售后登录及退货匹配只读接口,不用真实商品提交作为健康检查。
## 2026-09-28 迁移到 122.228.200.167 与首页修复(#346)
- 线上服务由 185.216.248.75 迁移到 122.228.200.167(发布目录 `20260928-344-9dace6a`)。
- 迁移后 `http://122.228.200.167:9527/` 显示 go-admin 欢迎页:新机 9527 vhost 写成了全部 `proxy_pass`,缺少 `root /home/goauto/current/dist` 与 `location = /`。已按「Nginx 入口(9527)」标准配置修复(原文件备份为 `goauto-9527.conf.bak-20260928150822`),`nginx -t` 通过后 reload;按内容验收首页、前端路由、静态资源、验证码与未登录接口均通过。
- 代码侧根治见 #346:存在 dist 时服务端 `GET /` 也返回 SPA index.html,即使 Nginx 误配为全部转发也不再出现欢迎页。
## 档口入库码匹配中断排错与恢复(#350)
- 匹配后台总时限仍为 20 分钟,SYB 客户端单请求 60 秒;候选数组越界造成的进程退出不能通过增大时间解决。受控日志查看运行服务的 panic/退出/重启时间,结合匹配 processed/total 与记录状态判断中断;不导出原始生产记录。
- 修复版本启动时在接受新任务前将旧 pending/running 匹配批次终结,未完成 pending/matching 记录设为失败,已成功结果保留;仅状态恢复,不自动匹配或 SYB 回写。
- 旧版本出现崩溃现场,必须先确认原 worker 已退出、目标记录没有匹配计划和远端写执行证据,再按授权做精确条件事务收敛;禁止无条件整日重置或重试仍会崩溃的旧接口。修复上线后手动勾选失败项匹配,ready 后另行确认回写。
实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。
+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)
}
+34 -6
View File
@@ -25,12 +25,8 @@ const SPADirEnv = "GOAUTO_WEB_DIST"
// dist; a NoRoute handler installed anyway would turn every genuine 404 into
// an HTML page, which is far more confusing than a plain 404.
func InitSPARouter(engine *gin.Engine) {
dist := strings.TrimSpace(os.Getenv(SPADirEnv))
if dist == "" {
dist = "dist"
}
index := filepath.Join(dist, "index.html")
if _, err := os.Stat(index); err != nil {
dist, index, ok := spaIndex()
if !ok {
return
}
@@ -56,6 +52,38 @@ func InitSPARouter(engine *gin.Engine) {
})
}
// spaIndex resolves the built frontend directory and reports whether its
// index.html exists.
func spaIndex() (dist, index string, ok bool) {
dist = strings.TrimSpace(os.Getenv(SPADirEnv))
if dist == "" {
dist = "dist"
}
index = filepath.Join(dist, "index.html")
if _, err := os.Stat(index); err != nil {
return dist, index, false
}
return dist, index, true
}
// registerRootRoute decides what `GET /` returns (#346).
//
// `[必须]` When the built frontend exists, `/` must be the Admin SPA. go-admin's
// welcome page used to own `/` in every non-prod mode, so any reverse proxy
// that forwarded `/` to this server (instead of serving dist itself) showed
// "GO-ADMIN欢迎您" instead of the Admin — which is exactly what happened after
// the 2026-09-28 server migration. The welcome page is kept only for
// development without a dist, where the frontend runs under vite.
func registerRootRoute(r gin.IRoutes, mode string, welcome gin.HandlerFunc) {
if _, index, ok := spaIndex(); ok {
r.GET("/", func(c *gin.Context) { c.File(index) })
return
}
if mode != "prod" {
r.GET("/", welcome)
}
}
// isAPIPath reports whether a path belongs to the server rather than the SPA.
func isAPIPath(path string) bool {
for _, prefix := range []string{"/api/", "/swagger/", "/static/", "/form-generator/", "/ws/", "/wslogout/", "/info"} {
+43
View File
@@ -91,3 +91,46 @@ func TestWithoutDistNoFallbackIsInstalled(t *testing.T) {
t.Fatalf("没有 dist 时接口仍应正常: %d", response.Code)
}
}
// #346: with a built frontend, `/` must be the Admin SPA — never go-admin's
// welcome page, even in non-prod modes where the welcome page used to own `/`.
func TestRootServesSPAWhenDistExists(t *testing.T) {
gin.SetMode(gin.TestMode)
dist := filepath.Join(t.TempDir(), "dist")
if err := os.MkdirAll(dist, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("<!doctype html>SPA"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv(SPADirEnv, dist)
for _, mode := range []string{"dev", "test", "prod"} {
engine := gin.New()
registerRootRoute(engine, mode, func(c *gin.Context) { c.String(http.StatusOK, "GO-ADMIN欢迎您") })
InitSPARouter(engine)
response := do(engine, http.MethodGet, "/")
if response.Code != http.StatusOK || response.Body.String() != "<!doctype html>SPA" {
t.Fatalf("mode=%s: / should serve index.html, got %d %q", mode, response.Code, response.Body.String())
}
}
}
// Without a dist (development under vite) the previous behaviour is kept:
// welcome page outside prod, nothing registered in prod.
func TestRootWithoutDistKeepsPreviousBehaviour(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv(SPADirEnv, filepath.Join(t.TempDir(), "missing-dist"))
welcome := func(c *gin.Context) { c.String(http.StatusOK, "GO-ADMIN欢迎您") }
dev := gin.New()
registerRootRoute(dev, "dev", welcome)
if response := do(dev, http.MethodGet, "/"); response.Code != http.StatusOK || response.Body.String() != "GO-ADMIN欢迎您" {
t.Fatalf("dev without dist should keep the welcome page, got %d %q", response.Code, response.Body.String())
}
prod := gin.New()
registerRootRoute(prod, "prod", welcome)
if response := do(prod, http.MethodGet, "/"); response.Code != http.StatusNotFound {
t.Fatalf("prod without dist should not register /, got %d", response.Code)
}
}
+1 -3
View File
@@ -40,9 +40,7 @@ func sysBaseRouter(r *gin.RouterGroup) {
go ws.WebsocketManager.SendService()
go ws.WebsocketManager.SendAllService()
if config.ApplicationConfig.Mode != "prod" {
r.GET("/", apis.GoAdmin)
}
registerRootRoute(r, config.ApplicationConfig.Mode, apis.GoAdmin)
r.GET("/info", handler.Ping)
}
+14
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,14 @@ 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
case strings.HasPrefix(path, "/api/admin/v1/return-matches"):
return ModuleSYBProducts
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)
+20
View File
@@ -3,6 +3,9 @@ package access
// RolePurchaser is the fixed role key used by the GoAuto purchaser account.
const RolePurchaser = "purchaser"
// RoleAfterSales is the fixed role key for the internal after-sales users.
const RoleAfterSales = "after_sales"
// APIPermission describes one admin API known to GoAuto. Purchaser marks the
// APIs that the purchaser role may call; every other API remains admin-only.
type APIPermission struct {
@@ -124,6 +127,23 @@ 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},
{"查看退货匹配", "/api/admin/v1/return-matches", "GET", true},
{"查看退货匹配批次", "/api/admin/v1/return-matches/batches", "GET", true},
{"查看退货匹配批次详情", "/api/admin/v1/return-matches/batches/:batchId", "GET", true},
{"查看退货匹配详情", "/api/admin/v1/return-matches/:id", "GET", true},
{"批量匹配退货", "/api/admin/v1/return-matches/batch-match", "POST", true},
{"确认退货匹配", "/api/admin/v1/return-matches/:id/confirm", "POST", true},
{"取消退货匹配", "/api/admin/v1/return-matches/:id/cancel", "POST", true},
{"备注退货匹配", "/api/admin/v1/return-matches/:id/remark", "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()
+9
View File
@@ -41,9 +41,15 @@ func MigratedModels() []any {
&models.SYBSpecAIParseRun{},
&models.SYBSpecAIParseWorkItem{},
&models.SYBSession{},
&models.SYBSessionAuthLease{},
&models.SYBShop{},
&models.SYBProductFilter{},
&models.SYBProductFilterRecomputeLog{},
&models.SYBSyncRun{},
&models.YeekeSession{},
&models.YeekeReturnPackage{},
&models.YeekeReturnItem{},
&models.YeekeSyncRun{},
&models.SYBInnerCodeRecord{},
&models.SYBInnerCodeItem{},
&models.SYBInnerCodeApplyBatch{},
@@ -75,6 +81,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" }
+44
View File
@@ -504,6 +504,17 @@ type SYBSession struct {
UpdatedAt time.Time `json:"updatedAt"`
}
// SYBSessionAuthLease serializes re-authentication across sync and writeback
// workers. Cookies and passwords never live in this table.
type SYBSessionAuthLease struct {
ID uint64 `gorm:"primaryKey"`
Username string `gorm:"size:128;not null;uniqueIndex"`
Owner string `gorm:"size:36;not null"`
ExpiresAt time.Time `gorm:"not null"`
}
func (SYBSessionAuthLease) TableName() string { return "syb_session_auth_lease" }
// SYBProduct is one SYB (顺云宝 ERP) shipment detail line: one order can carry
// several Shopee product lines, and the same Shopee product can appear more
// than once within one order at different colors/sizes/quantities — each such
@@ -568,12 +579,45 @@ type SYBProduct struct {
// parse-rule change, only the derived fields above may change.
RawJSON string `json:"-" gorm:"type:json;not null"`
// PDDExcluded marks that this row hit an enabled product filter
// rule (char/keyword) at the moment it was first created by the sync
// (#340). It replaces the old "skip on filter hit" behaviour: a filtered
// row is still stored, but every PDD purchase/collection/AI-match entry
// point must hard-reject it. The mark is decided once, at creation time
// (sybimport.applyStockDetail / ApplyDetail); a later sync of the SAME
// existing row never changes it, even if the enabled rules changed in
// the meantime — only the admin "按当前规则重新计算" action
// (sybproductfilter recompute) may flip it, and only for rows with no
// purchase task and no active return match.
PDDExcluded bool `json:"pddPurchaseExcluded" gorm:"column:pdd_purchase_excluded;not null;default:false;index"`
// ExcludedRuleID/Kind/Keyword are a snapshot of the rule that matched at
// mark time, kept even if the rule is later edited or deleted, so the
// mark stays explainable in the UI and in return matching (#340).
ExcludedRuleID *uint64 `json:"excludedRuleId,omitempty"`
ExcludedRuleKind string `json:"excludedRuleKind,omitempty" gorm:"size:16;not null;default:''"`
ExcludedRuleKeyword string `json:"excludedRuleKeyword,omitempty" gorm:"size:200;not null;default:''"`
ExcludedAt *time.Time `json:"excludedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (SYBProduct) TableName() string { return "syb_product" }
// SYBProductFilterRecomputeLog audits the admin-only "按当前规则重新计算"
// action (#340 decision 7). Every execute run writes exactly one row here.
type SYBProductFilterRecomputeLog struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
Operator string `json:"operator" gorm:"size:128;not null;default:''"`
ExcludedToPDD int `json:"excludedToPdd" gorm:"not null;default:0"`
PDDToExcluded int `json:"pddToExcluded" gorm:"not null;default:0"`
SkippedHasTask int `json:"skippedHasTask" gorm:"not null;default:0"`
SkippedReturnMatch int `json:"skippedReturnMatch" gorm:"not null;default:0"`
CreatedAt time.Time `json:"createdAt"`
}
func (SYBProductFilterRecomputeLog) TableName() string { return "syb_product_filter_recompute_log" }
func (SYBSession) TableName() string { return "syb_session" }
// SYBShop is the list of SYB shops whose shipment orders are imported (#49).
+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" }
@@ -131,6 +131,9 @@ func aiMatchQualificationForDataset(id uint64, dataset batchPreviewDataset) aiMa
if !found {
return disabled("SYB 商品不存在或已删除")
}
if syb.PDDExcluded {
return disabled("该商品已标记为无需 PDD 采购")
}
if strings.TrimSpace(syb.TargetColor) == "" && strings.TrimSpace(syb.TargetSize) == "" {
return disabled("未解析出需要采购的颜色或尺码")
}
+26
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
}
@@ -381,6 +399,14 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
}
item.OrderCode, item.ShopeeProductID, item.ShopeeItemID = syb.OrderCode, syb.ShopeeProductID, syb.ShopeeItemID
item.ProductTitle, item.TargetColor, item.TargetSize, item.Quantity = syb.ProductTitle, syb.TargetColor, syb.TargetSize, syb.Quantity
// #340: hard-exclude before anything else in the pipeline. This preview
// is the single computation shared by BatchPreview, BatchCreate's
// pre-check and quick-replace's ValidateQuickReplacement, so this one
// check covers all of them.
if syb.PDDExcluded {
item.ReasonCode, item.Reason, item.NextAction = CodePDDExcluded, "该商品已标记为无需 PDD 采购", ""
return item
}
if !sybSpecsTrusted(syb) {
item.ReasonCode, item.NextAction = "SYB_PARSE_FAILED", "reparse"
if syb.ParseStatus == models.SYBParseStatusUncertain {
+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)
@@ -9,6 +9,10 @@ func (item *BatchPreviewItem) applyCollectionEligibility(id uint64, dataset batc
item.CollectionDisabledReason = "SYB 商品不存在或已删除"
return
}
if syb.PDDExcluded {
item.CollectionDisabledReason = "该商品已标记为无需 PDD 采购"
return
}
if syb.ShopeeProductID == nil {
item.CollectionDisabledReason = "尚未关联蝦皮商品"
return
+2 -2
View File
@@ -475,10 +475,10 @@ func allowedOperator(c *gin.Context) bool {
return true
}
role, _ := jwt.ExtractClaims(c)["rolekey"].(string)
if role == "admin" || role == "purchaser" {
if role == "admin" || role == "purchaser" || role == "after_sales" {
return true
}
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以操作采购任务"})
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员、采购员或售后可以操作采购任务"})
c.Abort()
return false
}
@@ -0,0 +1,26 @@
package purchase
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func TestAllowedOperatorIncludesAfterSales(t *testing.T) {
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
t.Run(role, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
want := role == "admin" || role == "purchaser" || role == "after_sales"
if got := allowedOperator(c); got != want {
t.Fatalf("role %q allowed=%v want %v", role, got, want)
}
if !want && w.Code != 403 {
t.Fatalf("denied role status=%d", w.Code)
}
})
}
}
@@ -96,7 +96,7 @@ func TestOrderBackfillMixedBatchAndReplay(t *testing.T) {
if saved.StatusVersion != a.StatusVersion+1 || saved.ErrorCode != nil || saved.ErrorMessage != nil || saved.DeviceRunSlot != nil || saved.AccountRunSlot != nil || saved.ActiveSlot == nil || saved.Status != models.PurchaseTaskStatusOrderCreated {
t.Fatalf("state metadata: %+v", saved)
}
if saved.PaymentReviewStatus != a.PaymentReviewStatus || saved.LogisticsStatus != a.LogisticsStatus || saved.WritebackStatus != a.WritebackStatus || saved.RuleSnapshot != a.RuleSnapshot {
if saved.PaymentReviewStatus != a.PaymentReviewStatus || saved.LogisticsStatus != a.LogisticsStatus || saved.WritebackStatus != models.PurchaseWritebackStatusPending || saved.RuleSnapshot != a.RuleSnapshot {
t.Fatal("unrelated business facts changed")
}
for _, replayID := range []string{rid, uuid.NewString()} {
@@ -60,7 +60,10 @@ func ensureOrderWriteback(tx *gorm.DB, t models.PurchaseTask) error {
return nil
}
row := models.PurchaseOrderWriteback{PurchaseTaskID: t.ID, StockID: int64(syb.StockID), DetailID: int64(syb.DetailID), OrderNo: *t.PDDOrderNo, Status: "pending"}
return tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
if err := tx.Session(&gorm.Session{Logger: logger.Default.LogMode(logger.Silent)}).Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return err
}
return tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ? AND writeback_status = ?", t.ID, models.PurchaseWritebackStatusNotSelected).Update("writeback_status", models.PurchaseWritebackStatusPending).Error
}
func (s *Service) OrderWritebackViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]OrderWritebackView, error) {
@@ -116,7 +116,11 @@ func TestOrderWritebackRemoteOutcomes(t *testing.T) {
t.Fatal("automatically repeated write")
}
after := loadBackfillTask(t, s.DB, task.ID)
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != task.WritebackStatus || after.StatusVersion != task.StatusVersion {
wantTaskWriteback := models.PurchaseWritebackStatusFailed
if tc.want == "succeeded" {
wantTaskWriteback = models.PurchaseWritebackStatusSucceeded
}
if after.PaymentReviewStatus != task.PaymentReviewStatus || after.WritebackStatus != wantTaskWriteback || after.StatusVersion != task.StatusVersion {
t.Fatal("changed purchase/payment/logistics facts")
}
})
@@ -84,38 +84,16 @@ func sessionUnavailableMessage(err error) string {
return "SYB会话不可用(" + category + "),将自动重试;如持续失败请恢复登录后重试"
}
// restoreOrderWritebackClient rebuilds a SYB client from the cached session
// only. It never logs in, never triggers OCR and never deletes the cached
// session (that stays the exclusive responsibility of sybimport.Connect's
// login/refresh path) — it only reports whether the cached cookies still
// work, via CheckSession, so the caller can classify the failure (#330).
// restoreOrderWritebackClient uses the same session acquisition path as SYB
// sync. A valid cached session is reused; an explicitly invalid session is
// refreshed under the shared database auth lease so concurrent workers do not
// request multiple captcha codes.
func restoreOrderWritebackClient(ctx context.Context, db *gorm.DB) (OrderNumberClient, error) {
cfg := config.ExtConfig.SYB.Resolved()
session, err := sybclient.NewSessionStore(db).Load(ctx, cfg.Username, time.Now())
if err != nil {
return nil, err
}
if session.UserID <= 0 {
return nil, errSessionUserIDMissing
}
c, err := sybclient.New(cfg.BaseURL)
if err != nil {
return nil, err
}
if err = c.ImportCookiesJSON(session.CookiesJSON); err != nil {
return nil, err
}
// Active probe (#330 修订1): without this, a remotely-expired cookie jar
// imports cleanly and only fails later inside read(), which would record
// it as SYB_READ_FAILED instead of the retryable session-class outcome.
// Any error here — ErrSessionInvalid or network/format — is treated as
// session-class; only ErrSessionInvalid is a confirmed logout, but a
// network/format error is not confirmed-valid either, so it is still
// retried rather than attempted as a write.
if err = c.CheckSession(ctx, session.UserID, cfg.Username); err != nil {
return nil, err
}
return c, nil
return sybclient.AcquireSession(ctx, db, sybclient.LoginConfig{
BaseURL: cfg.BaseURL, Username: cfg.Username, Password: cfg.Password,
OcrURL: cfg.OcrURL, OcrMaxAttempts: cfg.OcrMaxAttempts,
})
}
// One short-lived claim at a time across processes. No business writes occur
@@ -184,7 +162,20 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
if status == "succeeded" {
updates["completed_at"] = w.Now()
}
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error; err != nil {
return err
}
// Keep the admin task state aligned with the authoritative writeback
// record. Conflicts and unknown outcomes remain failed until a human
// resolves them; they must never appear as successful.
taskStatus := models.PurchaseWritebackStatusFailed
if status == "succeeded" {
taskStatus = models.PurchaseWritebackStatusSucceeded
}
return db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", item.PurchaseTaskID).Updates(map[string]any{
"writeback_status": taskStatus,
"writeback_at": gorm.Expr("CASE WHEN ? = 'succeeded' THEN ? ELSE writeback_at END", status, w.Now()),
}).Error
}
// finishSessionUnavailable is the bounded-retry counterpart of finish for
// SYB_SESSION_UNAVAILABLE: instead of clearing the lease, it schedules the
@@ -201,7 +192,10 @@ func (w *OrderWritebackWorker) RunOnce(ctx context.Context) (bool, error) {
} else {
updates["lease_expires_at"] = nil
}
return db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error
if err := db.Model(&models.PurchaseOrderWriteback{}).Where("id = ? AND status = 'running' AND lease_owner = ?", item.ID, owner).Updates(updates).Error; err != nil {
return err
}
return db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", item.PurchaseTaskID).Update("writeback_status", models.PurchaseWritebackStatusFailed).Error
}
var task models.PurchaseTask
if err = db.First(&task, item.PurchaseTaskID).Error; err != nil {
@@ -0,0 +1,152 @@
package purchase
import (
"context"
"testing"
"time"
"go-admin/app/goauto/models"
"github.com/google/uuid"
)
// #340: a SYB product marked pdd_purchase_excluded must be hard-rejected by
// every PDD entry point, mirroring #338's return-match block tests above.
func TestCreate_RejectsWhenPDDExcluded(t *testing.T) {
db := testDB(t)
s := testService(db)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
Update("pdd_purchase_excluded", true).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 != CodePDDExcluded {
t.Fatalf("expected CodePDDExcluded, got %v", err)
}
}
func TestBatchPreview_ExcludedRowReportsNotEligible(t *testing.T) {
db := testDB(t)
s := testService(db)
f := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f.pdd.ID)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
Update("pdd_purchase_excluded", true).Error; err != nil {
t.Fatal(err)
}
resp, err := s.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID})
if err != nil {
t.Fatalf("preview call itself must not fail: %v", err)
}
if len(resp.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(resp.Items))
}
item := resp.Items[0]
if item.Eligible {
t.Fatalf("excluded row must not be eligible: %+v", item)
}
if item.CollectionEligible {
t.Fatalf("excluded row must not be collection-eligible: %+v", item)
}
if item.AIMatchEligible {
t.Fatalf("excluded row must not be AI-match-eligible: %+v", item)
}
if item.ReasonCode != CodePDDExcluded {
t.Fatalf("expected CodePDDExcluded reason, got %+v", item)
}
if item.ProcessStage != ProcessStagePDDExcluded {
t.Fatalf("expected pdd_excluded stage, got %+v", item)
}
}
func TestBatchCreate_SkipsExcludedRowNotWholeBatch(t *testing.T) {
db := testDB(t)
s := testService(db)
f1 := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, f1.pdd.ID)
syb2 := models.SYBProduct{OrderCode: "SYB-EXCL-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)
}
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f1.syb.ID).
Update("pdd_purchase_excluded", true).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 && item.Created {
t.Fatalf("excluded row must not be created: %+v", item)
}
if item.SYBProductID == syb2.ID && !item.Created {
t.Fatalf("clean row must still be created: %+v", item)
}
}
}
// TestProcessStage_ExcludedRowStage covers priority: active return match wins
// over the exclusion mark; once the match is cancelled the stage falls back
// to pdd_excluded (#340 decision 3).
func TestProcessStage_ExcludedRowStagePriority(t *testing.T) {
db := testDB(t)
s := testService(db)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).
Update("pdd_purchase_excluded", true).Error; err != nil {
t.Fatal(err)
}
stages, err := s.ProcessStages(context.Background(), []uint64{f.syb.ID})
if err != nil {
t.Fatal(err)
}
if stages[f.syb.ID].Stage != ProcessStagePDDExcluded {
t.Fatalf("expected pdd_excluded, got %+v", stages[f.syb.ID])
}
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)
}
stages, err = s.ProcessStages(context.Background(), []uint64{f.syb.ID})
if err != nil {
t.Fatal(err)
}
if stages[f.syb.ID].Stage != ProcessStageReturnPending {
t.Fatalf("active return match must win over exclusion mark, got %+v", stages[f.syb.ID])
}
// Cancel: no more active match -> falls back to pdd_excluded, not the
// normal pipeline.
if err := db.Model(&models.ReturnMatch{}).Where("id = ?", match.ID).
Updates(map[string]any{"active_syb_product_id": nil, "status": models.ReturnMatchStatusCancelled}).Error; err != nil {
t.Fatal(err)
}
stages, err = s.ProcessStages(context.Background(), []uint64{f.syb.ID})
if err != nil {
t.Fatal(err)
}
if stages[f.syb.ID].Stage != ProcessStagePDDExcluded {
t.Fatalf("after cancel, stage should return to pdd_excluded, got %+v", stages[f.syb.ID])
}
}
+33 -1
View File
@@ -21,7 +21,18 @@ 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"
// ProcessStagePDDExcluded is #340's stage for a SYB product marked
// pdd_purchase_excluded: it never needs a PDD purchase. It only yields to
// an active return match (checked first, same priority order #338 uses
// for the pipeline below it) — see processStageFromDataset.
ProcessStagePDDExcluded = "pdd_excluded"
processActionOpenPDDLink = "open_pdd_link"
)
var processStageLabels = map[string]string{
@@ -35,6 +46,9 @@ var processStageLabels = map[string]string{
ProcessStageTaskCreated: "已创建任务",
ProcessStagePurchaseSucceeded: "采购成功",
ProcessStageOrderReview: "待人工核对",
ProcessStageReturnPending: "退货待确认",
ProcessStageReturnUsed: "已用退货",
ProcessStagePDDExcluded: "无需采购",
}
type ProcessStageResult struct {
@@ -132,6 +146,24 @@ 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")
}
// #340: an excluded row's mark also only overrides after the active-task
// and active-return-match checks above, and before every other pipeline
// branch below.
if syb, ok := dataset.sybByID[id]; ok && syb.PDDExcluded {
return stage(ProcessStagePDDExcluded, "顺云宝导入时已按过滤规则标记为无需 PDD 采购", "")
}
syb, ok := dataset.sybByID[id]
if !ok {
return stage(ProcessStageManualAction, "SYB 商品不存在或已删除", "refresh")
@@ -0,0 +1,41 @@
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)
}
// rejectIfPDDExcluded implements #340's hard purchase-creation block: a SYB
// product marked pdd_purchase_excluded must never get a purchase task,
// single or batch (BatchCreate calls s.Create per row, so this one check
// point covers both, mirroring rejectIfActiveReturnMatch above).
func rejectIfPDDExcluded(syb models.SYBProduct) error {
if syb.PDDExcluded {
return fail(CodePDDExcluded, "该商品已标记为无需 PDD 采购,不能创建采购任务")
}
return nil
}
@@ -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
}
+6
View File
@@ -178,6 +178,12 @@ 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 err := rejectIfPDDExcluded(syb); err != nil {
return err
}
if syb.ShopeeProductID == nil {
return fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品")
}
+13 -1
View File
@@ -36,7 +36,19 @@ 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"
// CodePDDExcluded is returned by every PDD entry point (purchase create,
// collection task creation, AI spec match, image search collection,
// quick-replace, ...) when the SYB product is marked
// pdd_purchase_excluded (#340): it hit a product filter rule at import
// time and is recorded as 「无需 PDD 采购」, so it must never start any
// PDD flow, regardless of the rest of its pipeline state.
CodePDDExcluded = "PURCHASE_PDD_EXCLUDED"
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)
}
}
@@ -0,0 +1,120 @@
package returnmatch
import (
"context"
"errors"
"fmt"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func seedCancelMatch(t *testing.T, db *gorm.DB, status string) models.ReturnMatch {
t.Helper()
syb := seedSYB(t, db, "CANCEL-TEST", 1, "白色", "L", time.Now())
item := seedReturn(t, db, "白色,L", nil)
m := models.ReturnMatch{SYBProductID: syb.ID, YeekeReturnItemID: item.ID, ActiveSYBProductID: &syb.ID, ActiveYeekeReturnItemID: &item.ID, Status: status, MatchedAt: time.Now()}
if err := db.Create(&m).Error; err != nil {
t.Fatal(err)
}
return m
}
func TestCancelExpectedStatusHTTP(t *testing.T) {
for _, tc := range []struct {
name, status, body, role string
code int
}{
{"matched", "matched", `{"expectedStatus":"matched"}`, "purchaser", 200},
{"confirmed_conflict", "confirmed", `{"expectedStatus":"matched"}`, "after_sales", 409},
{"cancelled_conflict", "cancelled", `{"expectedStatus":"matched"}`, "admin", 409},
{"legacy_empty", "confirmed", "", "after_sales", 200},
{"legacy_object", "confirmed", `{}`, "admin", 200},
{"invalid_status", "matched", `{"expectedStatus":"confirmed"}`, "admin", 400},
{"invalid_empty", "matched", `{"expectedStatus":""}`, "admin", 400},
{"invalid_null", "matched", `{"expectedStatus":null}`, "admin", 400},
{"malformed", "matched", `{`, "admin", 400},
{"forbidden", "matched", `{"expectedStatus":"matched"}`, "other", 403},
} {
t.Run(tc.name, func(t *testing.T) {
db := testDB(t)
m := seedCancelMatch(t, db, tc.status)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", fmt.Sprintf("/return-matches/%d/cancel", m.ID), strings.NewReader(tc.body))
c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(m.ID)}}
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": tc.role, "username": "cancel-tester"})
Handler{DB: db}.Cancel(c)
if w.Code != tc.code {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var saved models.ReturnMatch
db.First(&saved, m.ID)
var logs int64
db.Model(&models.ReturnMatchLog{}).Where("match_id = ?", m.ID).Count(&logs)
if tc.code == 200 {
if saved.Status != "cancelled" || saved.ActiveSYBProductID != nil || saved.ActiveYeekeReturnItemID != nil || saved.CancelledBy != "cancel-tester" || saved.CancelledAt == nil || logs != 1 {
t.Fatalf("cancel or audit incomplete: %+v logs=%d", saved, logs)
}
_, err := NewService(db).Cancel(context.Background(), m.ID, "repeat", "matched")
if err != errStateConflict {
t.Fatalf("repeat err=%v", err)
}
} else if saved.Status != tc.status || logs != 0 {
t.Fatalf("rejected transition mutated state: %+v logs=%d", saved, logs)
}
})
}
}
func TestCancelExpectedStatusMissingAndConfirmedRace(t *testing.T) {
db := testDB(t)
s := NewService(db)
if _, err := s.Cancel(context.Background(), 99999, "tester", "matched"); err != gorm.ErrRecordNotFound {
t.Fatalf("missing err=%v", err)
}
m := seedCancelMatch(t, db, "matched")
if _, err := s.Confirm(context.Background(), m.ID, "reviewer"); err != nil {
t.Fatal(err)
}
// A batch holding the earlier matched snapshot must not undo a completed confirmation.
if _, err := s.Cancel(context.Background(), m.ID, "batch", "matched"); err != errStateConflict {
t.Fatalf("stale batch err=%v", err)
}
var saved models.ReturnMatch
db.First(&saved, m.ID)
if saved.Status != "confirmed" || saved.ActiveSYBProductID == nil {
t.Fatalf("confirmation lost: %+v", saved)
}
if _, err := s.Cancel(context.Background(), m.ID, "legacy"); err != nil {
t.Fatalf("legacy confirmed cancellation: %v", err)
}
}
func TestCancelAuditFailureRollsBackTransition(t *testing.T) {
db := testDB(t)
m := seedCancelMatch(t, db, "matched")
if err := db.Callback().Create().Before("gorm:create").Register("test:reject_cancel_log", func(tx *gorm.DB) {
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "ReturnMatchLog" {
tx.AddError(errors.New("synthetic audit failure"))
}
}); err != nil {
t.Fatal(err)
}
if _, err := NewService(db).Cancel(context.Background(), m.ID, "tester", "matched"); err == nil {
t.Fatal("expected audit failure")
}
var saved models.ReturnMatch
if err := db.First(&saved, m.ID).Error; err != nil {
t.Fatal(err)
}
if saved.Status != "matched" || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil || saved.CancelledAt != nil {
t.Fatalf("failed audit did not roll back: %+v", saved)
}
}
+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])
}
}
@@ -0,0 +1,103 @@
package returnmatch
import (
"context"
"errors"
"fmt"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
// #349 reuses the existing single-record transaction; no production batch API.
func TestConfirmBatchExistingHTTPContract(t *testing.T) {
for _, tc := range []struct {
name, status, role string
code int
}{
{"admin", "matched", "admin", 200},
{"purchaser", "matched", "purchaser", 200},
{"after_sales", "matched", "after_sales", 200},
{"already_confirmed", "confirmed", "after_sales", 409},
{"cancelled", "cancelled", "purchaser", 409},
{"forbidden", "matched", "viewer", 403},
} {
t.Run(tc.name, func(t *testing.T) {
db := testDB(t)
m := seedCancelMatch(t, db, tc.status)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", fmt.Sprintf("/return-matches/%d/confirm", m.ID), nil)
c.Params = gin.Params{{Key: "id", Value: fmt.Sprint(m.ID)}}
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": tc.role, "username": "confirm-tester"})
Handler{DB: db}.Confirm(c)
if w.Code != tc.code {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var saved models.ReturnMatch
db.First(&saved, m.ID)
var logs int64
db.Model(&models.ReturnMatchLog{}).Where("match_id = ?", m.ID).Count(&logs)
if tc.code == 200 {
if saved.Status != "confirmed" || saved.ConfirmedBy != "confirm-tester" || saved.ConfirmedAt == nil || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil || logs != 1 {
t.Fatalf("confirmation audit or occupancy lost: %+v logs=%d", saved, logs)
}
} else if saved.Status != tc.status || logs != 0 {
t.Fatalf("rejected confirm changed state: %+v logs=%d", saved, logs)
}
})
}
}
func TestConfirmBatchOriginalRecordStateChanges(t *testing.T) {
db := testDB(t)
s := NewService(db)
if _, err := s.Confirm(context.Background(), 99999, "tester"); err != gorm.ErrRecordNotFound {
t.Fatalf("missing=%v", err)
}
m := seedCancelMatch(t, db, "matched")
if _, err := s.Cancel(context.Background(), m.ID, "other"); err != nil {
t.Fatal(err)
}
// Simulate a later rematch with the same product/return, but a new identity.
replacement := m
replacement.ID = 0
replacement.CancelledAt = nil
replacement.CancelledBy = ""
if err := db.Create(&replacement).Error; err != nil {
t.Fatal(err)
}
if _, err := s.Confirm(context.Background(), m.ID, "batch"); err != errStateConflict {
t.Fatalf("stale original=%v", err)
}
if _, err := s.Confirm(context.Background(), replacement.ID, "reviewer"); err != nil {
t.Fatal(err)
}
if _, err := s.Confirm(context.Background(), replacement.ID, "repeat"); err != errStateConflict {
t.Fatalf("duplicate=%v", err)
}
}
func TestConfirmBatchAuditFailureRollsBack(t *testing.T) {
db := testDB(t)
m := seedCancelMatch(t, db, "matched")
if err := db.Callback().Create().Before("gorm:create").Register("test:reject_confirm_log", func(tx *gorm.DB) {
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "ReturnMatchLog" {
tx.AddError(errors.New("synthetic audit failure"))
}
}); err != nil {
t.Fatal(err)
}
if _, err := NewService(db).Confirm(context.Background(), m.ID, "tester"); err == nil {
t.Fatal("expected audit failure")
}
var saved models.ReturnMatch
db.First(&saved, m.ID)
if saved.Status != "matched" || saved.ConfirmedAt != nil || saved.ActiveSYBProductID == nil || saved.ActiveYeekeReturnItemID == nil {
t.Fatalf("confirm did not roll back: %+v", saved)
}
}
+281
View File
@@ -0,0 +1,281 @@
package returnmatch
import (
"encoding/json"
"errors"
"io"
"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/after-sales 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" && role != "after_sales" {
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) {
var body map[string]json.RawMessage
if err := ctx.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
return models.ReturnMatch{}, errInvalidExpectedStatus
}
raw, supplied := body["expectedStatus"]
if !supplied {
return s.Cancel(ctx.Request.Context(), id, operator)
}
var expectedStatus string
if err := json.Unmarshal(raw, &expectedStatus); err != nil {
return models.ReturnMatch{}, errInvalidExpectedStatus
}
return s.Cancel(ctx.Request.Context(), id, operator, expectedStatus)
})
}
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, errInvalidExpectedStatus) {
c.JSON(http.StatusBadRequest, gin.H{"code": "INVALID_REQUEST", "message": "expectedStatus 只允许 matched,或省略请求体"})
return
}
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,26 @@
package returnmatch
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
)
func TestReturnMatchWriteRoles(t *testing.T) {
for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} {
t.Run(role, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role})
want := role == "admin" || role == "purchaser" || role == "after_sales"
if got := requireCanPurchase(c); got != want {
t.Fatalf("role %q allowed=%v want %v", role, got, want)
}
if !want && w.Code != 403 {
t.Fatalf("denied role status=%d", w.Code)
}
})
}
}
@@ -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)
}
}
}
@@ -0,0 +1,33 @@
package returnmatch
import (
"context"
"testing"
"time"
"go-admin/app/goauto/models"
)
// #340: a pdd_purchase_excluded SYB product still needs no PDD purchase but
// must remain eligible to take part in return matching.
func TestBatchMatch_ExcludedStageParticipates(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-EXCL-1", 1, "白色", "L", time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
if err := db.Model(&models.SYBProduct{}).Where("id = ?", syb.ID).
Update("pdd_purchase_excluded", true).Error; err != nil {
t.Fatal(err)
}
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("excluded-stage row must still be eligible for return matching: %+v", resp)
}
}
@@ -0,0 +1,38 @@
package returnmatch
import (
"context"
"errors"
"testing"
"time"
"go-admin/app/goauto/models"
)
func TestReshippedReturnExcludedAndRecheckedBeforeInsert(t *testing.T) {
db := testDB(t)
s := NewService(db)
deadline := time.Now().Add(24 * time.Hour)
ret := seedReturn(t, db, "红色", &deadline)
sy := seedSYB(t, db, "TEST", 1, "红色", "", time.Now())
pool, err := s.availableReturnPool(context.Background())
if err != nil || len(pool) != 1 {
t.Fatalf("waiting pool=%v err=%v", pool, err)
}
if err := db.Model(&models.YeekeReturnPackage{}).Where("id = ?", ret.PackageID).Update("claim_status", "2").Error; err != nil {
t.Fatal(err)
}
pool, err = s.availableReturnPool(context.Background())
if err != nil || len(pool) != 0 {
t.Fatalf("reshipped pool=%v err=%v", pool, err)
}
_, err = s.matchOneWithLock(context.Background(), sy.ID, MatchOutcome{ReturnItemID: ret.ID, DestroyDeadline: deadline}, "test")
if !errors.Is(err, errReturnNoLongerEligible) {
t.Fatalf("final recheck=%v", err)
}
var count int64
db.Model(&models.ReturnMatch{}).Count(&count)
if count != 0 {
t.Fatal("reshipped item was allocated")
}
}
+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/after-sales 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)
}
+685
View File
@@ -0,0 +1,685 @@
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,
// #340: a pdd_purchase_excluded row still needs no PDD purchase and can
// still take part in return matching — matching it just confirms there is
// nothing left to buy back. After a cancel it returns to pdd_excluded,
// which stays true here (see processStageFromDataset), so the row is
// eligible again the same way any other participating stage is.
purchase.ProcessStagePDDExcluded: 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, errReturnNoLongerEligible) {
resp.Items = append(resp.Items, BatchMatchItem{SYBProductID: id, ReasonCode: ReasonNoCandidate, Reason: "退货商品已重出或不再可用"})
resp.SkippedCount++
continue
}
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").
Where("p.claim_status = ? AND p.status_unrecognized = ?", "1", false).
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")
var errReturnNoLongerEligible = errors.New("yeeke return is no longer waiting to ship")
// 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
}
var pkg models.YeekeReturnPackage
if err := tx.Clauses(clauseLockUpdate()).First(&pkg, returnItem.PackageID).Error; err != nil {
return err
}
// Same package -> item lock order as sync upsert; avoid a lock cycle.
if err := tx.Clauses(clauseLockUpdate()).First(&returnItem, outcome.ReturnItemID).Error; err != nil {
return err
}
if pkg.ClaimStatus != "1" || pkg.StatusUnrecognized || pkg.SyncStatus != "ok" || returnItem.SyncStatus != "ok" {
return errReturnNoLongerEligible
}
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 releases the active SYB product and return item pointers; actual
// purchase readiness and return-pool eligibility are then re-evaluated.
// Without a precondition it accepts either matched or confirmed state,
// per issue #338 rule: 取消匹配后再次点击「匹配退货」若配回同一对,允许.
var errInvalidExpectedStatus = errors.New("expectedStatus must be matched")
// Optional matched precondition is checked under the same row lock as cancellation.
// Legacy callers without it retain the confirmed-to-cancelled transition.
func (s *Service) Cancel(ctx context.Context, matchID uint64, operator string, expectedStatus ...string) (models.ReturnMatch, error) {
if len(expectedStatus) > 1 || (len(expectedStatus) == 1 && expectedStatus[0] != models.ReturnMatchStatusMatched) {
return models.ReturnMatch{}, errInvalidExpectedStatus
}
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
}
if len(expectedStatus) == 1 && (match.Status != expectedStatus[0] || match.ActiveSYBProductID == nil || match.ActiveYeekeReturnItemID == nil) {
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,486 @@
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{
ClaimStatus: "1",
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)
}
}
+4
View File
@@ -30,6 +30,10 @@ type SessionStore struct{ db *gorm.DB }
func NewSessionStore(db *gorm.DB) *SessionStore { return &SessionStore{db: db} }
// DB exposes the store connection to the shared session manager; callers do
// not receive any session data through this accessor.
func (s *SessionStore) DB() *gorm.DB { return s.db }
// Session is one cached SYB login.
type Session struct {
Username string
@@ -0,0 +1,147 @@
package sybclient
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// LoginConfig contains the non-secret connection settings and credentials
// needed to acquire a SYB session. Password is used only during LoginWithOCR.
type LoginConfig struct {
BaseURL string
Username string
Password string
OcrURL string
OcrMaxAttempts int
}
// AcquireSession reuses a valid cached session and performs at most one
// re-login per account at a time. Callers waiting for another process to
// refresh the session never request another captcha.
func AcquireSession(ctx context.Context, db *gorm.DB, cfg LoginConfig) (*Client, error) {
if cfg.Username == "" {
return nil, fmt.Errorf("顺云宝账号未配置")
}
client, err := New(cfg.BaseURL)
if err != nil {
return nil, err
}
store := NewSessionStore(db)
if cached, loadErr := store.Load(ctx, cfg.Username, time.Now()); loadErr == nil {
if importErr := client.ImportCookiesJSON(cached.CookiesJSON); importErr != nil {
return nil, importErr
}
checkErr := client.CheckSession(ctx, cached.UserID, cfg.Username)
if checkErr == nil {
return client, nil
}
if !errors.Is(checkErr, ErrSessionInvalid) {
return nil, checkErr
}
if cfg.Password == "" {
return nil, checkErr
}
} else if !errors.Is(loadErr, ErrNoSession) {
return nil, loadErr
} else if cfg.Password == "" {
return nil, loadErr
}
owner := uuid.NewString()
if claimed, err := claimAuthLease(ctx, db, cfg.Username, owner, 2*time.Minute); err != nil {
return nil, err
} else if claimed {
defer releaseAuthLease(context.Background(), db, cfg.Username, owner)
// Another worker may have completed login between our first probe and
// acquiring the lease; always re-check before requesting a captcha.
if c, ok := validCachedSession(ctx, client, store, cfg.Username); ok {
return c, nil
}
if err := refreshSession(ctx, db, client, store, cfg); err != nil {
return nil, err
}
return validSessionOrError(ctx, client, store, cfg.Username)
}
// A different worker owns the login lease. Wait for its session, bounded by
// the caller's context; do not trigger a second login.
for {
if c, ok := validCachedSession(ctx, client, store, cfg.Username); ok {
return c, nil
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("等待顺云宝会话刷新超时: %w", err)
}
select {
case <-time.After(500 * time.Millisecond):
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
func validSessionOrError(ctx context.Context, client *Client, store *SessionStore, username string) (*Client, error) {
s, err := store.Load(ctx, username, time.Now())
if err != nil {
return nil, err
}
if err := client.ImportCookiesJSON(s.CookiesJSON); err != nil {
return nil, err
}
if err := client.CheckSession(ctx, s.UserID, username); err != nil {
return nil, err
}
return client, nil
}
func validCachedSession(ctx context.Context, client *Client, store *SessionStore, username string) (*Client, bool) {
s, err := store.Load(ctx, username, time.Now())
if err != nil {
return nil, false
}
if err := client.ImportCookiesJSON(s.CookiesJSON); err != nil {
return nil, false
}
if err := client.CheckSession(ctx, s.UserID, username); err != nil {
// Network failures are not treated as logout, but they also do not
// authorize a write; the caller will retry through the normal worker.
return nil, false
}
return client, true
}
func refreshSession(ctx context.Context, db *gorm.DB, client *Client, store *SessionStore, cfg LoginConfig) error {
ocr, err := NewOcrClient(cfg.OcrURL, 0)
if err != nil {
return fmt.Errorf("顺云宝验证码识别服务不可用: %w", err)
}
result, reason := client.LoginWithOCR(ctx, ocr, cfg.Username, cfg.Password, cfg.OcrMaxAttempts)
if result == nil {
return fmt.Errorf("顺云宝自动登录失败,需要手工输入验证码: %s", reason)
}
cookies, err := client.ExportCookiesJSON()
if err != nil {
return err
}
return store.Save(ctx, Session{Username: cfg.Username, UserID: result.User.ID, CookiesJSON: cookies, ExpiresAt: result.ExpiresAt})
}
func claimAuthLease(ctx context.Context, db *gorm.DB, username, owner string, ttl time.Duration) (bool, error) {
now := time.Now().UTC()
if err := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&models.SYBSessionAuthLease{ID: 1, Username: username, Owner: "", ExpiresAt: now.Add(-time.Second)}).Error; err != nil {
return false, err
}
r := db.WithContext(ctx).Model(&models.SYBSessionAuthLease{}).Where("id = 1 AND username = ? AND (expires_at <= ? OR owner = '')", username, now).Updates(map[string]any{"owner": owner, "expires_at": now.Add(ttl)})
return r.RowsAffected == 1, r.Error
}
func releaseAuthLease(ctx context.Context, db *gorm.DB, username, owner string) {
_ = db.WithContext(ctx).Model(&models.SYBSessionAuthLease{}).Where("id = 1 AND username = ? AND owner = ?", username, owner).Updates(map[string]any{"owner": "", "expires_at": time.Now().UTC().Add(-time.Second)})
}
@@ -0,0 +1,35 @@
package sybclient
import (
"context"
"testing"
"time"
"go-admin/app/goauto/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestAuthLeaseAllowsOnlyOneRefreshOwner(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:syb-auth-lease?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&models.SYBSessionAuthLease{}); err != nil {
t.Fatal(err)
}
ctx := context.Background()
first, err := claimAuthLease(ctx, db, "operator", "first", time.Minute)
if err != nil || !first {
t.Fatalf("first owner should claim: %v %v", first, err)
}
second, err := claimAuthLease(ctx, db, "operator", "second", time.Minute)
if err != nil || second {
t.Fatalf("second owner must wait: %v %v", second, err)
}
releaseAuthLease(ctx, db, "operator", "first")
second, err = claimAuthLease(ctx, db, "operator", "second", time.Minute)
if err != nil || !second {
t.Fatalf("lease should be reusable: %v %v", second, err)
}
}
+33
View File
@@ -8,6 +8,7 @@ import (
"math"
"strconv"
"strings"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
@@ -41,6 +42,18 @@ type DetailInput struct {
ProductTitle string
ProductThumb uint64
Raw json.RawMessage
// Excluded and the ExcludedRule* fields are #340's product-filter mark:
// Excluded reports whether this line hit an enabled filter rule during
// THIS sync's matching, and the ExcludedRule* fields are a snapshot of
// that rule. They are only applied when ApplyDetail is about to CREATE a
// new syb_product row (decision 4: a later sync of an existing row must
// never change its existing mark, even if the rules or the match outcome
// changed since).
Excluded bool
ExcludedRuleID *uint64
ExcludedRuleKind string
ExcludedRuleKeyword string
}
// ApplyResult reports what ApplyDetail actually did, for the import-result
@@ -138,12 +151,32 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
err = tx.Where("order_code = ? AND detail_id = ?", order.Code, detail.ID).First(&existing).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
// #340 decision 4: the mark is set only when the row is first
// created, from this sync's own filter match — never on update.
if detail.Excluded {
now := time.Now().UTC()
record.PDDExcluded = true
record.ExcludedRuleID = detail.ExcludedRuleID
record.ExcludedRuleKind = detail.ExcludedRuleKind
record.ExcludedRuleKeyword = detail.ExcludedRuleKeyword
record.ExcludedAt = &now
}
if err := tx.Create(&record).Error; err != nil {
return err
}
result.Outcome = OutcomeCreated
case err == nil:
record.ID = existing.ID
// #340 decision 4: the exclusion mark is decided once, at row
// creation. A resync of an already-existing row must never
// change it (nor is it included in `updates` below), but the
// returned/in-memory record must still reflect the existing
// mark rather than the zero value this fresh struct starts with.
record.PDDExcluded = existing.PDDExcluded
record.ExcludedRuleID = existing.ExcludedRuleID
record.ExcludedRuleKind = existing.ExcludedRuleKind
record.ExcludedRuleKeyword = existing.ExcludedRuleKeyword
record.ExcludedAt = existing.ExcludedAt
// Human-confirmed target values are authoritative and survive every
// source re-import. ParseStatus/ParseNote below still record what the
// current deterministic parser observed for audit.
+1 -1
View File
@@ -47,7 +47,7 @@ func (handler Handler) List(c *gin.Context) {
return
}
response, err := service.List(c.Request.Context(), ListRequest{
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")),
Page: page, PageSize: pageSize, ShopName: c.Query("shopName"), OrderCodes: []string{c.Query("orderCodes")}, ParseStatus: strings.TrimSpace(c.Query("parseStatus")), ProcessStage: strings.TrimSpace(c.Query("processStage")), CreatedFrom: strings.TrimSpace(c.Query("createdFrom")), CreatedTo: strings.TrimSpace(c.Query("createdTo")), PurchaseType: strings.TrimSpace(c.Query("purchaseType")),
})
if err != nil {
writeError(c, err)
@@ -0,0 +1,71 @@
package sybimport_test
import (
"context"
"testing"
"go-admin/app/goauto/sybimport"
)
// #340: List's purchaseType filter combines with processStage as AND.
func TestServiceListPurchaseType(t *testing.T) {
db := openTestDB(t)
pdd, err := sybimport.ApplyDetail(context.Background(), db, realOrder(), realDetailA())
if err != nil {
t.Fatalf("apply pdd row: %v", err)
}
excludedOrder := realOrder()
excludedOrder.Code = "260728EXCL"
excludedOrder.StockID++
excludedDetail := realDetailB()
excludedDetail.ID++
excludedDetail.Excluded = true
ruleID := uint64(1)
excludedDetail.ExcludedRuleID = &ruleID
excludedDetail.ExcludedRuleKind = "keyword"
excludedDetail.ExcludedRuleKeyword = "档口"
excluded, err := sybimport.ApplyDetail(context.Background(), db, excludedOrder, excludedDetail)
if err != nil {
t.Fatalf("apply excluded row: %v", err)
}
if !excluded.SYBProduct.PDDExcluded {
t.Fatalf("seed row was not marked excluded")
}
service := sybimport.NewService(db)
defaultResp, err := service.List(context.Background(), sybimport.ListRequest{})
if err != nil {
t.Fatal(err)
}
if defaultResp.Total != 1 || defaultResp.Items[0].ID != pdd.SYBProduct.ID {
t.Fatalf("default purchaseType must show only pdd rows: %+v", defaultResp)
}
pddResp, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: sybimport.PurchaseTypePDD})
if err != nil {
t.Fatal(err)
}
if pddResp.Total != 1 || pddResp.Items[0].ID != pdd.SYBProduct.ID {
t.Fatalf("explicit pdd purchaseType mismatch: %+v", pddResp)
}
excludedResp, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: sybimport.PurchaseTypeExcluded})
if err != nil {
t.Fatal(err)
}
if excludedResp.Total != 1 || excludedResp.Items[0].ID != excluded.SYBProduct.ID {
t.Fatalf("excluded purchaseType mismatch: %+v", excludedResp)
}
allResp, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: sybimport.PurchaseTypeAll})
if err != nil {
t.Fatal(err)
}
if allResp.Total != 2 {
t.Fatalf("all purchaseType must show both rows, got %+v", allResp)
}
if _, err := service.List(context.Background(), sybimport.ListRequest{PurchaseType: "bogus"}); err == nil {
t.Fatalf("invalid purchaseType must be rejected")
}
}
@@ -0,0 +1,104 @@
package sybimport_test
import (
"context"
"encoding/json"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybimport"
)
// #340: applyStockDetail no longer skips a filter-hit row; it stores it and
// marks it. These tests exercise the same public entry point (ApplyDetail)
// applyStockDetail calls, with the Excluded fields it now always passes in.
func TestApplyDetailMarksExcludedRowOnCreate(t *testing.T) {
db := openTestDB(t)
order := sybimport.OrderInput{Code: "EXCL-ORDER", StockID: 1, ShopName: "测试店铺"}
ruleID := uint64(9)
raw, _ := json.Marshal(map[string]any{"variationSku": "档口-123"})
result, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{
ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t",
Raw: raw,
Excluded: true,
ExcludedRuleID: &ruleID,
ExcludedRuleKind: "keyword",
ExcludedRuleKeyword: "档口",
})
if err != nil {
t.Fatalf("ApplyDetail: %v", err)
}
if !result.SYBProduct.PDDExcluded {
t.Fatalf("expected row to be marked excluded")
}
if result.SYBProduct.ExcludedRuleID == nil || *result.SYBProduct.ExcludedRuleID != ruleID {
t.Fatalf("expected excluded rule id snapshot %d, got %v", ruleID, result.SYBProduct.ExcludedRuleID)
}
if result.SYBProduct.ExcludedRuleKind != "keyword" || result.SYBProduct.ExcludedRuleKeyword != "档口" {
t.Fatalf("unexpected rule snapshot: %+v", result.SYBProduct)
}
if result.SYBProduct.ExcludedAt == nil {
t.Fatalf("expected excludedAt to be set")
}
}
func TestApplyDetailNonHitRowIsNotMarked(t *testing.T) {
db := openTestDB(t)
order := sybimport.OrderInput{Code: "OK-ORDER", StockID: 1, ShopName: "测试店铺"}
raw, _ := json.Marshal(map[string]any{"variationSku": ""})
result, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{
ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t",
Raw: raw,
})
if err != nil {
t.Fatalf("ApplyDetail: %v", err)
}
if result.SYBProduct.PDDExcluded {
t.Fatalf("expected row not to be marked excluded")
}
if result.SYBProduct.ExcludedRuleID != nil || result.SYBProduct.ExcludedAt != nil {
t.Fatalf("expected no rule snapshot on a non-hit row: %+v", result.SYBProduct)
}
}
// TestApplyDetailResyncKeepsMarkEvenIfRulesChanged is decision 4: the mark
// is fixed at row-creation time. A later sync of the SAME existing row must
// not flip it even when it is re-applied with a different Excluded value
// (representing a rule that started/stopped matching since).
func TestApplyDetailResyncKeepsMarkEvenIfRulesChanged(t *testing.T) {
db := openTestDB(t)
order := sybimport.OrderInput{Code: "RESYNC-ORDER", StockID: 1, ShopName: "测试店铺"}
ruleID := uint64(1)
raw, _ := json.Marshal(map[string]any{"variationSku": "档口-123"})
first, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{
ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t",
Raw: raw, Excluded: true, ExcludedRuleID: &ruleID, ExcludedRuleKind: "keyword", ExcludedRuleKeyword: "档口",
})
if err != nil || !first.SYBProduct.PDDExcluded {
t.Fatalf("seed create failed: %v %+v", err, first.SYBProduct)
}
// Re-sync the same detail, this time with the rule disabled (no hit).
second, err := sybimport.ApplyDetail(context.Background(), db, order, sybimport.DetailInput{
ID: 1, ProductID: 100, ProductQty: 1, ProductPrice: 10, ProductSpec: "黑色,L", ProductTitle: "t",
Raw: raw, Excluded: false,
})
if err != nil {
t.Fatalf("ApplyDetail update: %v", err)
}
if second.Outcome != sybimport.OutcomeUpdated {
t.Fatalf("expected update outcome, got %s", second.Outcome)
}
if !second.SYBProduct.PDDExcluded {
t.Fatalf("expected existing mark to be preserved across resync, got unmarked: %+v", second.SYBProduct)
}
var stored models.SYBProduct
if err := db.Where("order_code = ? AND detail_id = ?", order.Code, uint64(1)).First(&stored).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if !stored.PDDExcluded || stored.ExcludedRuleKeyword != "档口" {
t.Fatalf("mark was changed by resync: %+v", stored)
}
}
+75 -2
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchase"
@@ -44,8 +45,23 @@ type ListRequest struct {
OrderCodes []string
ParseStatus string
ProcessStage string
CreatedFrom string
CreatedTo string
// PurchaseType is #340's list-side isolation filter: "pdd" (default when
// empty) shows only rows that still need a PDD purchase,
// "excluded" shows only pdd_purchase_excluded rows, "all" shows both. It
// combines with ProcessStage and the created-time range as AND; the
// auto-switch to 全部 mentioned in the issue is a front-end behaviour,
// not a server default.
PurchaseType string
}
const (
PurchaseTypePDD = "pdd"
PurchaseTypeExcluded = "excluded"
PurchaseTypeAll = "all"
)
type ListResponse struct {
Items []models.SYBProduct `json:"items"`
Total int64 `json:"total"`
@@ -65,10 +81,20 @@ 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{})
createdFrom, createdTo, err := createdAtRange(request.CreatedFrom, request.CreatedTo)
if err != nil {
return ListResponse{}, err
}
if createdFrom != nil {
query = query.Where("created_at >= ?", *createdFrom)
}
if createdTo != nil {
query = query.Where("created_at < ?", *createdTo)
}
if request.ShopName = strings.TrimSpace(request.ShopName); request.ShopName != "" {
if len([]rune(request.ShopName)) > 255 {
return ListResponse{}, invalidRequest("店铺名称不能超过 255 个字符")
@@ -92,6 +118,16 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
if !purchase.ValidProcessStage(request.ProcessStage) {
return ListResponse{}, invalidRequest("processStage 无效")
}
request.PurchaseType = strings.TrimSpace(request.PurchaseType)
switch request.PurchaseType {
case "", PurchaseTypePDD:
query = query.Where("pdd_purchase_excluded = ?", false)
case PurchaseTypeExcluded:
query = query.Where("pdd_purchase_excluded = ?", true)
case PurchaseTypeAll:
default:
return ListResponse{}, invalidRequest("purchaseType 无效")
}
if request.ProcessStage != "" {
var candidates []models.SYBProduct
if err := query.Order("updated_at DESC, id DESC").Find(&candidates).Error; err != nil {
@@ -142,6 +178,43 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
return ListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil
}
// createdAtRange turns inclusive YYYY-MM-DD bounds into a half-open time
// range. The bounds are interpreted in the server's local timezone, matching
// the timestamps written by GORM for this service.
func createdAtRange(from, to string) (*time.Time, *time.Time, error) {
from = strings.TrimSpace(from)
to = strings.TrimSpace(to)
if from == "" && to == "" {
return nil, nil, nil
}
parse := func(value, label string) (*time.Time, error) {
if value == "" {
return nil, nil
}
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
if err != nil {
return nil, invalidRequest(label + " 必须是 YYYY-MM-DD")
}
return &parsed, nil
}
start, err := parse(from, "createdFrom")
if err != nil {
return nil, nil, err
}
endDay, err := parse(to, "createdTo")
if err != nil {
return nil, nil, err
}
if start != nil && endDay != nil && start.After(*endDay) {
return nil, nil, invalidRequest("createdFrom 不能晚于 createdTo")
}
if endDay != nil {
end := endDay.AddDate(0, 0, 1)
endDay = &end
}
return start, endDay, nil
}
func normalizeOrderCodes(raw []string) ([]string, error) {
seen := make(map[string]bool, len(raw))
result := make([]string, 0, len(raw))
@@ -7,6 +7,7 @@ import (
"fmt"
"strings"
"testing"
"time"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybimport"
@@ -93,6 +94,76 @@ 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 TestServiceListFiltersByCreatedDateInclusive(t *testing.T) {
db := openTestDB(t)
order := realOrder()
for i, day := range []string{"2026-09-01", "2026-09-02", "2026-09-03"} {
rowOrder := order
rowOrder.Code = fmt.Sprintf("CREATED-%d", i)
rowOrder.StockID += uint64(i)
detail := realDetailA()
detail.ID += uint64(i)
result, err := sybimport.ApplyDetail(context.Background(), db, rowOrder, detail)
if err != nil {
t.Fatal(err)
}
created, err := time.ParseInLocation("2006-01-02", day, time.Local)
if err != nil {
t.Fatal(err)
}
if err := db.Model(&models.SYBProduct{}).Where("id = ?", result.SYBProduct.ID).Update("created_at", created).Error; err != nil {
t.Fatal(err)
}
}
service := sybimport.NewService(db)
between, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-01", CreatedTo: "2026-09-02"})
if err != nil || between.Total != 2 {
t.Fatalf("inclusive created date range should return two rows, total=%d err=%v", between.Total, err)
}
fromOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-03"})
if err != nil || fromOnly.Total != 1 {
t.Fatalf("created-from filter should return one row, total=%d err=%v", fromOnly.Total, err)
}
toOnly, err := service.List(context.Background(), sybimport.ListRequest{CreatedTo: "2026-09-01"})
if err != nil || toOnly.Total != 1 {
t.Fatalf("created-to filter should return one row, total=%d err=%v", toOnly.Total, err)
}
if _, err := service.List(context.Background(), sybimport.ListRequest{CreatedFrom: "2026-09-04", CreatedTo: "2026-09-01"}); serviceErrCode(t, err) != sybimport.CodeInvalidRequest {
t.Fatalf("reversed created date range should be rejected: %v", err)
}
}
func TestServiceListRejectsInvalidParseStatus(t *testing.T) {
db := openTestDB(t)
service := sybimport.NewService(db)
+21 -54
View File
@@ -395,12 +395,16 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow,
}
for _, item := range detail.Details {
variation := stringField(item.Raw, "variationSku")
// `[必须]` #340: a filter hit no longer skips the row. It is still
// stored, marked with a rule snapshot, and left to the hard PDD
// exclusion guards in purchase/task/returnmatch. Char/keyword hit
// counts and per-rule hits (#269) keep the same meaning — "marked",
// not "skipped" — so the disable-confirmation dialog's figures are
// still accurate.
var excluded bool
var excludedRuleID *uint64
var excludedRuleKind, excludedRuleKeyword string
if rule := filters.Match(variation); rule != nil {
// `[必须]` Count the two kinds separately and per rule. A combined
// total would hide a structural rule that stopped matching because
// 档口 changed its code format, and a per-kind total would make the
// disable-confirmation dialog quote the same figure for "#" and "-"
// even though they match very different numbers of rows (#269).
if rule.Kind == "char" {
report.CharFilterSkipped++
} else {
@@ -409,7 +413,11 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow,
if report.filterHits != nil {
report.filterHits.Add(rule)
}
continue
excluded = true
ruleID := rule.ID
excludedRuleID = &ruleID
excludedRuleKind = rule.Kind
excludedRuleKeyword = rule.Keyword
}
raw, err := json.Marshal(item.Raw)
if err != nil {
@@ -424,6 +432,9 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow,
ProductTitle: item.ProductTitle,
ProductThumb: uint64(item.ProductThumb),
Raw: raw,
Excluded: excluded, ExcludedRuleID: excludedRuleID,
ExcludedRuleKind: excludedRuleKind, ExcludedRuleKeyword: excludedRuleKeyword,
})
if err != nil {
return fmt.Errorf("写入货运单 %s 明细 %d 失败(本次同步停止;已写入的数据保留): %w",
@@ -480,54 +491,10 @@ func splitDateRange(dateFrom, dateTo string) ([]string, error) {
// blip as a logout would trigger needless logins and could throw away a
// perfectly good session.
func Connect(ctx context.Context, store *sybclient.SessionStore, cfg ConnectConfig) (*sybclient.Client, error) {
if cfg.Username == "" || cfg.Password == "" {
return nil, errors.New("顺云宝账号或密码未配置,请设置 GOAUTO_SYB_USERNAME 和 GOAUTO_SYB_PASSWORD")
}
client, err := sybclient.New(cfg.BaseURL)
if err != nil {
return nil, err
}
cached, err := store.Load(ctx, cfg.Username, time.Now())
switch {
case err == nil:
if importErr := client.ImportCookiesJSON(cached.CookiesJSON); importErr == nil {
if checkErr := client.CheckSession(ctx, cached.UserID, cfg.Username); checkErr == nil {
return client, nil
} else if errors.Is(checkErr, sybclient.ErrSessionInvalid) {
if delErr := store.Delete(ctx, cfg.Username); delErr != nil {
return nil, delErr
}
}
}
case errors.Is(err, sybclient.ErrNoSession):
// Nothing cached; fall through to a fresh login.
default:
return nil, err
}
if cfg.OcrURL == "" {
return nil, errors.New("顺云宝会话已失效,且未配置验证码识别服务;请配置 extend.syb.ocrurl 或改用手工登录")
}
ocr, err := sybclient.NewOcrClient(cfg.OcrURL, 0)
if err != nil {
return nil, err
}
result, reason := client.LoginWithOCR(ctx, ocr, cfg.Username, cfg.Password, cfg.OcrMaxAttempts)
if result == nil {
return nil, fmt.Errorf("顺云宝自动登录失败,需要手工输入验证码: %s", reason)
}
jar, err := client.ExportCookiesJSON()
if err != nil {
return nil, err
}
if err := store.Save(ctx, sybclient.Session{
Username: cfg.Username, UserID: result.User.ID,
CookiesJSON: jar, ExpiresAt: result.ExpiresAt,
}); err != nil {
return nil, err
}
return client, nil
return sybclient.AcquireSession(ctx, store.DB(), sybclient.LoginConfig{
BaseURL: cfg.BaseURL, Username: cfg.Username, Password: cfg.Password,
OcrURL: cfg.OcrURL, OcrMaxAttempts: cfg.OcrMaxAttempts,
})
}
// ConnectConfig carries the login settings. The password is passed through and
+65 -1
View File
@@ -68,6 +68,9 @@ type fakeSYB struct {
// 用于构造「列表说是 A 店、明细说是 B 店」的不一致。
detailShopName string
blankDetailShopName bool
// detailVariationSku, when non-empty, is used as every detail's
// variationSku (#340 filter-marking tests).
detailVariationSku string
}
func (f *fakeSYB) shopFor(i int) string {
@@ -142,7 +145,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
"shopName": detailShopName,
"details": []any{map[string]any{
"id": float64(id*10 + 1), "productId": float64(9001),
"productTitle": "测试商品", "productSpec": "白色,L",
"productTitle": "测试商品", "productSpec": "白色,L", "variationSku": f.detailVariationSku,
"productQty": float64(2), "productPrice": 39.5, "productThumb": float64(77),
}},
})
@@ -466,3 +469,64 @@ func TestSyncRejectsBlankShopOnDetailResponse(t *testing.T) {
report.DetailCount, report.AcceptedCount, report.ShopSkipped)
}
}
// #340: a filter hit no longer skips the row — it is still stored, counted
// (as "marked", same counters as before) and left for the hard PDD exclusion
// guards elsewhere in the pipeline to enforce.
func TestSyncStoresAndMarksFilterHitRowsInsteadOfSkipping(t *testing.T) {
db := newSyncTestDB(t)
if err := db.Create(&models.SYBProductFilter{
Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true,
}).Error; err != nil {
t.Fatalf("seed filter: %v", err)
}
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 2}, detailVariationSku: "档口-99"}
report, err := Sync(context.Background(), db, newSyncClient(t, f),
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
if err != nil {
t.Fatalf("同步失败: %v", err)
}
if report.KeywordFilterSkipped != 2 {
t.Fatalf("expected 2 marked hits, got %d", report.KeywordFilterSkipped)
}
if report.DetailCount != 2 || report.Created != 2 {
t.Fatalf("filter hits must still be stored: detailCount=%d created=%d", report.DetailCount, report.Created)
}
var count int64
db.Model(&models.SYBProduct{}).Where("pdd_purchase_excluded = ?", true).Count(&count)
if count != 2 {
t.Fatalf("expected 2 rows marked excluded, got %d", count)
}
var filter models.SYBProductFilter
if err := db.Where("keyword = ?", "档口").First(&filter).Error; err != nil {
t.Fatalf("reload filter: %v", err)
}
if filter.LastHitCount == nil || *filter.LastHitCount != 2 {
t.Fatalf("expected rule's own hit count to be 2, got %v", filter.LastHitCount)
}
}
func TestSyncNonHitRowsAreNotMarked(t *testing.T) {
db := newSyncTestDB(t)
if err := db.Create(&models.SYBProductFilter{
Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true,
}).Error; err != nil {
t.Fatalf("seed filter: %v", err)
}
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 1}, detailVariationSku: "普通-1"}
report, err := Sync(context.Background(), db, newSyncClient(t, f),
SyncConfig{PageSize: 10, MaxMatches: 1000}, "2026-08-01", "2026-08-01")
if err != nil {
t.Fatalf("同步失败: %v", err)
}
if report.KeywordFilterSkipped != 0 {
t.Fatalf("unexpected marked hits: %d", report.KeywordFilterSkipped)
}
var count int64
db.Model(&models.SYBProduct{}).Where("pdd_purchase_excluded = ?", true).Count(&count)
if count != 0 {
t.Fatalf("no rows should be marked excluded, got %d", count)
}
}
+293 -14
View File
@@ -10,12 +10,14 @@ import (
"sort"
"strings"
"time"
"unicode"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybclient"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type MatchReader interface {
@@ -44,12 +46,19 @@ func (m Matcher) runBackground(db *gorm.DB, jobID string) {
err = RunMatchJob(ctx, db, reader, jobID)
}
if err != nil {
now := time.Now().UTC()
_ = db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status IN ?", jobID, []string{"pending", "running"}).Updates(map[string]any{"status": "failed", "error_message": compact(err.Error(), 1000), "finished_at": now}).Error
_ = failMatchJob(db, jobID, "匹配任务中断,请重新匹配")
}
}
func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID string) error {
func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID string) (runErr error) {
claimedJob := false
defer func() {
if runErr != nil && claimedJob {
if err := failMatchJob(db, jobID, "匹配任务中断,请重新匹配"); err != nil {
runErr = errors.Join(runErr, err)
}
}
}()
now := time.Now().UTC()
claimed := db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status = ?", jobID, "pending").Updates(map[string]any{"status": "running", "started_at": now})
if claimed.Error != nil {
@@ -65,6 +74,7 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
}
return conflict("匹配任务状态不允许执行")
}
claimedJob = true
var job models.SYBInnerCodeMatchJob
if err := db.WithContext(ctx).First(&job, "id = ?", jobID).Error; err != nil {
return err
@@ -74,7 +84,7 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
return fmt.Errorf("匹配任务记录范围无效")
}
var records []models.SYBInnerCodeRecord
if err := db.WithContext(ctx).Preload("Items", func(q *gorm.DB) *gorm.DB { return q.Order("ordinal ASC") }).Where("id IN ? AND business_date = ? AND status IN ?", recordIDs, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}).Order("source_row,id").Find(&records).Error; err != nil {
if err := db.WithContext(ctx).Preload("Items", func(q *gorm.DB) *gorm.DB { return q.Order("ordinal ASC") }).Where("id IN ? AND business_date = ? AND status IN ?", recordIDs, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeMatching, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}).Order("source_row,id").Find(&records).Error; err != nil {
return err
}
used, err := loadReservedDetails(ctx, db, records)
@@ -83,7 +93,10 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
}
ready, failed := 0, 0
for _, record := range records {
plan, status, message, planErr := planRecord(ctx, reader, record, used)
if err := ctx.Err(); err != nil {
return err
}
plan, status, message, planErr := safePlanRecord(ctx, reader, record, used)
if planErr != nil {
status = models.SYBInnerCodeFailed
message = "读取 SYB 失败:" + compact(planErr.Error(), 900)
@@ -110,12 +123,62 @@ func RunMatchJob(ctx context.Context, db *gorm.DB, reader MatchReader, jobID str
} else {
failed++
}
db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", jobID).Updates(map[string]any{"processed": gorm.Expr("processed + 1"), "ready": ready, "failed": failed})
if err := db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", jobID).Updates(map[string]any{"processed": gorm.Expr("processed + 1"), "ready": ready, "failed": failed}).Error; err != nil {
return err
}
}
finished := time.Now().UTC()
return db.WithContext(ctx).Model(&models.SYBInnerCodeMatchJob{}).Where("id = ? AND status = ?", jobID, "running").Updates(map[string]any{"status": "succeeded", "finished_at": finished, "ready": ready, "failed": failed}).Error
}
// Only the read/plan step is isolated: no remote write is retried here.
func safePlanRecord(ctx context.Context, reader MatchReader, record models.SYBInnerCodeRecord, used map[int64]bool) (plan *models.SYBInnerCodePlan, status, message string, err error) {
defer func() {
if recover() != nil {
plan, status, message = nil, models.SYBInnerCodeFailed, "匹配处理异常,请重新匹配"
err = nil
}
}()
return planRecord(ctx, reader, record, used)
}
// Cleanup must not inherit an expired job or HTTP request context.
func failMatchJob(db *gorm.DB, jobID, message string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var job models.SYBInnerCodeMatchJob
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&job, "id = ?", jobID).Error; err != nil {
return err
}
if job.Status != "pending" && job.Status != "running" {
return nil
}
var ids []uint64
if err := json.Unmarshal([]byte(job.RecordIDsJSON), &ids); err != nil {
return err
}
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ? AND business_date = ? AND status IN ?", ids, job.BusinessDate, []string{models.SYBInnerCodePending, models.SYBInnerCodeMatching}).Updates(map[string]any{"status": models.SYBInnerCodeFailed, "result_message": message}).Error; err != nil {
return err
}
return tx.Model(&job).Updates(map[string]any{"status": "failed", "error_message": message, "finished_at": time.Now().UTC()}).Error
})
}
// Called at startup before new jobs can be submitted; never resumes remote writes.
func RecoverInterruptedMatches(db *gorm.DB) error {
var jobs []models.SYBInnerCodeMatchJob
if err := db.Where("status IN ?", []string{"pending", "running"}).Find(&jobs).Error; err != nil {
return err
}
for _, job := range jobs {
if err := failMatchJob(db, job.ID, "服务重启,匹配任务中断,请重新匹配"); err != nil {
return err
}
}
return nil
}
func loadReservedDetails(ctx context.Context, db *gorm.DB, selected []models.SYBInnerCodeRecord) (map[int64]bool, error) {
selectedIDs := map[uint64]bool{}
for _, r := range selected {
@@ -185,6 +248,9 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
if reason != "" {
return nil, models.SYBInnerCodeSkipped, reason, nil
}
if len(matches) == 0 {
return nil, models.SYBInnerCodeSkipped, "相同规格候选的原始 SKU 或档口货号未匹配", nil
}
count := len(record.Items)
if count == 0 {
return nil, models.SYBInnerCodeSkipped, "记录没有入库码", nil
@@ -193,18 +259,28 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
if len(matches) == 1 && matches[0].ProductQty == count {
chosen = []sybclient.DetailItem{matches[0]}
} else if len(matches) == count && count > 1 {
sort.Slice(matches, func(i, j int) bool { return matches[i].ID < matches[j].ID })
for _, item := range matches {
if item.ProductQty != 1 {
return nil, models.SYBInnerCodeSkipped, "相同规格候选数量不明确,不能自动分配", nil
}
}
chosen = matches
codes := make([]string, count)
for i, it := range record.Items {
codes[i] = it.Code
}
assigned, reason := assignExistingBoundItems(record.Stall, codes, matches)
if reason != "" {
return nil, models.SYBInnerCodeSkipped, reason, nil
}
chosen = assigned
} else if len(matches) > 1 {
return nil, models.SYBInnerCodeSkipped, "同一订单存在多条相同规格候选商品,不能自动选择", nil
} else {
return nil, models.SYBInnerCodeSkipped, fmt.Sprintf("SYB 商品数量与入库码数量不一致(%d/%d)", matches[0].ProductQty, count), nil
}
if len(chosen) == 0 {
return nil, models.SYBInnerCodeSkipped, "未形成唯一的商品分配,不能自动选择", nil
}
primary := chosen[0]
items := make([]plannedRemoteItem, 0, count)
placeholder := 0
@@ -246,6 +322,83 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC
return plan, models.SYBInnerCodeReady, "唯一匹配,等待确认回写", nil
}
// assignExistingBoundItems 把 N 个待写入入库码按顺序分配给 N 个数量为 1 的候选商品明细。
// 修复 #289:候选明细的匹配顺序(按 ID 排序)未必与目标码顺序一致,若单纯按下标
// 对应,会把已经正确绑定某个目标码的明细错误地重新分配给另一个码。这里先按“候选
// 明细已有的入库码值”精确匹配对应的目标码,保留既有正确绑定不动;再把剩余尚未
// 写入任何码的空白明细(按 ID 排序)依次填充给还没有候选的目标码位置。
// 移植自 cmautobuy `planExistingMatchedInnerCodeItems` 的一致性护栏(代码评审补充):
// 候选来自 NormalizeSpecKey 归一化匹配,原始 ProductSpec/sku/variationSku 可能在
// 归一化后相同但原始值不同,必须逐一比对最低 ID 候选,避免跨真正不同商品自动分配;
// 无 SKU 回退路径可能返回从未做过档口校验的候选,这里逐一重新校验;同时拒绝无效
// 或重复的商品明细 ID。
func assignExistingBoundItems(stall string, codes []string, matches []sybclient.DetailItem) ([]sybclient.DetailItem, string) {
sorted := append([]sybclient.DetailItem(nil), matches...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
seenIDs := make(map[int64]bool, len(sorted))
for _, item := range sorted {
if item.ID <= 0 || seenIDs[item.ID] {
return nil, "重复候选包含无效或重复的商品明细 ID,不能自动逐件分配"
}
seenIDs[item.ID] = true
}
first := sorted[0]
wantSpec := first.ProductSpec
wantSKU := rawText(first.Raw["sku"])
wantVariationSKU := rawText(first.Raw["variationSku"])
for _, item := range sorted {
if item.ProductSpec != wantSpec || rawText(item.Raw["sku"]) != wantSKU || rawText(item.Raw["variationSku"]) != wantVariationSKU {
return nil, "重复候选的规格或 SKU 身份不一致,不能自动逐件分配"
}
}
if strings.TrimSpace(stall) != "" {
for _, item := range sorted {
if !stallMatches(stall, item) {
return nil, "重复候选的档口及货号不一致,不能自动逐件分配"
}
}
}
assigned := make([]sybclient.DetailItem, len(codes))
taken := make([]bool, len(codes))
codeIndex := make(map[string]int, len(codes))
for i, code := range codes {
codeIndex[code] = i
}
blanks := make([]int, 0, len(sorted))
for si, item := range sorted {
remote := rawText(item.Raw["innerExpCode"])
if remote == "" {
blanks = append(blanks, si)
continue
}
idx, ok := codeIndex[remote]
if !ok {
return nil, "候选商品明细存在非目标入库码,不能自动逐件分配"
}
if taken[idx] {
return nil, "同一入库码在候选商品中出现多次,不能自动逐件分配"
}
assigned[idx] = item
taken[idx] = true
}
bi := 0
for i := range assigned {
if taken[i] {
continue
}
if bi >= len(blanks) {
return nil, "现成空白明细不足,不能完成逐件分配"
}
assigned[i] = sorted[blanks[bi]]
taken[i] = true
bi++
}
if bi != len(blanks) {
return nil, "现成空白明细多于待写入入库码,不能自动逐件分配"
}
return assigned, ""
}
func matchSpec(spec string, items []sybclient.DetailItem) []sybclient.DetailItem {
result := []sybclient.DetailItem{}
for _, item := range items {
@@ -264,12 +417,18 @@ func matchSpec(spec string, items []sybclient.DetailItem) []sybclient.DetailItem
}
return result
}
// Compare layout-only controls without changing stored evidence or ordinary spaces.
func normalizeMatchLayout(value string) string {
return strings.TrimSpace(strings.NewReplacer("\r", "", "\n", "", "\t", "").Replace(value))
}
func matchEvidence(stall, sourceSKU string, items []sybclient.DetailItem) ([]sybclient.DetailItem, string) {
sourceSKU = strings.TrimSpace(sourceSKU)
sourceSKU = normalizeMatchLayout(sourceSKU)
if sourceSKU != "" {
skuMatches := []sybclient.DetailItem{}
for _, item := range items {
if strings.TrimSpace(rawText(item.Raw["sku"])) == sourceSKU || strings.TrimSpace(rawText(item.Raw["variationSku"])) == sourceSKU {
if normalizeMatchLayout(rawText(item.Raw["sku"])) == sourceSKU || normalizeMatchLayout(rawText(item.Raw["variationSku"])) == sourceSKU {
skuMatches = append(skuMatches, item)
}
}
@@ -300,20 +459,140 @@ func matchEvidence(stall, sourceSKU string, items []sybclient.DetailItem) ([]syb
return fallback, ""
}
func strictStall(stall string, items []sybclient.DetailItem) []sybclient.DetailItem {
stall = strings.TrimSpace(stall)
stall = normalizeMatchLayout(stall)
if stall == "" {
return nil
}
result := []sybclient.DetailItem{}
name, article, has := strings.Cut(stall, "#")
for _, item := range items {
blob := rawText(item.Raw["sku"]) + " " + rawText(item.Raw["variationSku"]) + " " + item.ProductSpec
if strings.Contains(blob, stall) || (has && strings.Contains(blob, strings.TrimSpace(name)) && strings.Contains(blob, strings.TrimSpace(article))) {
if stallMatches(stall, item) {
result = append(result, item)
}
}
return result
}
// stallMatches 移植自 cmautobuy `innerCodeStallMatches`(#259/#273 修复):
// 档口名与货号以最后一个 `#` 切分;货号只与字母数字 token 比较;纯数字货号要求
// 候选中同时包含档口名才允许前导零等价(如 "067"≡"67");非数字货号要求精确
// token 匹配;ProductSpec 只在以货号开头时才算命中;货号为空时回退为档口名包含判断。
func stallMatches(stall string, item sybclient.DetailItem) bool {
stall = normalizeMatchLayout(stall)
if stall == "" {
return false
}
sku := normalizeMatchLayout(rawText(item.Raw["sku"]))
variation := normalizeMatchLayout(rawText(item.Raw["variationSku"]))
blob := sku + " " + variation + " " + item.ProductSpec
if strings.Contains(blob, stall) {
return true
}
name, article, hasArticle := splitStall(stall)
if !hasArticle {
return false
}
if article == "" {
return name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name))
}
if isNumericArticle(article) {
nameMatches := name != "" && (strings.Contains(sku, name) || strings.Contains(variation, name))
if !nameMatches {
return false
}
return textHasNumericArticle(sku, article) ||
textHasNumericArticle(variation, article) ||
productSpecStartsWithArticle(item.ProductSpec, article, true)
}
return textHasExactArticle(sku, article) ||
textHasExactArticle(variation, article) ||
productSpecStartsWithArticle(item.ProductSpec, article, false)
}
// splitStall 从档口名称#货号取最后一个 #,避免档口名称本身含 # 时截错。
func splitStall(stall string) (name, article string, ok bool) {
stall = strings.TrimSpace(stall)
separator := strings.LastIndex(stall, "#")
if separator < 0 {
return stall, "", false
}
return strings.TrimSpace(stall[:separator]), strings.TrimSpace(stall[separator+1:]), true
}
func isNumericArticle(article string) bool {
if article == "" {
return false
}
for _, char := range article {
if !unicode.IsDigit(char) {
return false
}
}
return true
}
func textHasNumericArticle(text, article string) bool {
target := normalizeNumericArticle(article)
for _, token := range articleTokens(text) {
if isNumericArticle(token) && normalizeNumericArticle(token) == target {
return true
}
}
return false
}
func normalizeNumericArticle(article string) string {
normalized := strings.TrimLeft(article, "0")
if normalized == "" {
return "0"
}
return normalized
}
func textHasExactArticle(text, article string) bool {
for _, token := range articleTokens(text) {
if token == article {
return true
}
}
return false
}
func productSpecStartsWithArticle(productSpec, article string, numeric bool) bool {
productSpec = strings.TrimSpace(productSpec)
separator := strings.IndexAny(productSpec, " ,,")
if separator <= 0 {
return false
}
prefix := strings.TrimSpace(productSpec[:separator])
if numeric {
return isNumericArticle(prefix) && normalizeNumericArticle(prefix) == normalizeNumericArticle(article)
}
return prefix == article
}
// articleTokens 只把连续字母或数字视为货号候选,标点、【】、#、横线、空格自然成为
// 边界:能识别 "067【档口】",又不会把 "PDD256437" 中间的数字误认为独立货号。
func articleTokens(text string) []string {
tokens := make([]string, 0)
start := -1
runes := []rune(text)
for index, char := range runes {
if unicode.IsLetter(char) || unicode.IsDigit(char) {
if start < 0 {
start = index
}
continue
}
if start >= 0 {
tokens = append(tokens, string(runes[start:index]))
start = -1
}
}
if start >= 0 {
tokens = append(tokens, string(runes[start:]))
}
return tokens
}
func rawText(value any) string {
switch v := value.(type) {
case string:
@@ -0,0 +1,68 @@
package sybinnercode
import (
"context"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybclient"
)
func TestMatchEvidenceNormalizesLayoutOnBothSides(t *testing.T) {
for _, layout := range []string{"\n", "\r", "\r\n", "\t"} {
for _, sourceHasLayout := range []bool{true, false} {
source, candidate := "市场-档口甲#731", "市场-档口甲#731"
if sourceHasLayout {
source = "市场-" + layout + "档口甲#731"
} else {
candidate = "市场-" + layout + "档口甲#731"
}
item := detail(1, "黑色,L", 1, "", candidate, "")
matches, reason := matchEvidence(source, source, []sybclient.DetailItem{item})
if reason != "" || len(matches) != 1 || !stallMatches(source, item) {
t.Fatalf("layout=%q sourceLayout=%v: matches=%v reason=%s", layout, sourceHasLayout, matches, reason)
}
if item.Raw["variationSku"] != candidate {
t.Fatal("comparison modified raw candidate evidence")
}
}
}
item := detail(1, "黑色,L", 1, "SKU-\n1", "", "")
if matches, reason := matchEvidence("", "SKU-1", []sybclient.DetailItem{item}); reason != "" || len(matches) != 1 {
t.Fatalf("raw sku layout did not match: %v %s", matches, reason)
}
}
func TestMatchLayoutPreservesIdentityAndAmbiguityChecks(t *testing.T) {
for _, candidate := range []string{"市场-档口乙#731", "市场-档口甲#732", "市场-档 口甲#731", "市场-档口甲#PDD7319"} {
item := detail(1, "黑色,L", 1, "", candidate, "")
matches, _ := matchEvidence("市场-\n档口甲#731", "市场-\n档口甲#731", []sybclient.DetailItem{item})
if len(matches) != 0 {
t.Fatalf("different identity unexpectedly matched: %q", candidate)
}
}
items := []sybclient.DetailItem{detail(1, "黑色,L", 1, "SKU-1", "", ""), detail(2, "黑色,L", 1, "SKU-\n1", "", "")}
if matches, reason := matchEvidence("", "SKU-1", items); len(matches) != 0 || reason == "" {
t.Fatal("layout-equivalent duplicate SKUs must remain ambiguous")
}
items = []sybclient.DetailItem{detail(1, "黑色,L", 1, "SKU-1", "市场-档口乙#732", ""), detail(2, "黑色,L", 1, "OTHER", "市场-档口甲#731", "")}
if matches, reason := matchEvidence("市场-\n档口甲#731", "SKU-\t1", items); len(matches) != 0 || reason == "" {
t.Fatal("SKU/stall conflict must remain rejected")
}
if normalizeMatchLayout("A B") == normalizeMatchLayout("AB") || normalizeMatchLayout("a") == normalizeMatchLayout("A") {
t.Fatal("ordinary spaces and case must remain significant")
}
}
func TestPlanRecordWithLayoutEvidenceKeepsOriginalValues(t *testing.T) {
record := matchRecord("ORDER-1", "市场-\n档口甲#731", "市场-\n档口甲#731", "IC-1")
item := detail(20, "黑色,L", 1, "", "市场-档口甲#731", "")
reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Details: []sybclient.DetailItem{item}}}}
plan, status, reason, err := planRecord(context.Background(), reader, record, map[int64]bool{})
if err != nil || status != models.SYBInnerCodeReady || plan == nil || plan.DetailID != 20 {
t.Fatalf("expected unique ready plan, got %v %s %s %v", plan, status, reason, err)
}
if record.Stall != "市场-\n档口甲#731" || record.SourceSKURaw != record.Stall || plan.SYBVariationSKU != "市场-档口甲#731" {
t.Fatal("original import and plan evidence must remain unchanged")
}
}
@@ -0,0 +1,181 @@
package sybinnercode
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybclient"
)
type interruptingReader struct {
*fakeMatchReader
panicOrder string
cancel context.CancelFunc
}
func (r interruptingReader) ListByOrderNumber(ctx context.Context, order string) ([]sybclient.StockRow, error) {
if order == r.panicOrder {
panic("must not escape or be persisted")
}
if r.cancel != nil {
r.cancel()
return nil, ctx.Err()
}
return r.fakeMatchReader.ListByOrderNumber(ctx, order)
}
func TestEmptyEvidenceDoesNotPanicAndNextRecordContinues(t *testing.T) {
db := testDB(t)
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("BAD", "NO-SKU", "NO#9", "IC-1"), matchRecord("GOOD", "SKU-A", "A#1", "IC-2")})
r := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"BAD": {{ID: 10}}, "GOOD": {{ID: 11}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Details: []sybclient.DetailItem{detail(20, "黑色,L", 1, "SKU-A", "A#1", "")}}, 11: {ID: 11, Details: []sybclient.DetailItem{detail(21, "黑色,L", 1, "SKU-A", "A#1", "")}}}}
if err := RunMatchJob(context.Background(), db, r, job); err != nil {
t.Fatal(err)
}
var rows []models.SYBInnerCodeRecord
db.Order("id").Find(&rows)
if rows[0].Status != models.SYBInnerCodeSkipped || rows[1].Status != models.SYBInnerCodeReady {
t.Fatal("empty evidence must be limited, next row ready")
}
var plans int64
db.Model(&models.SYBInnerCodePlan{}).Count(&plans)
if plans != 1 {
t.Fatalf("unexpected plans=%d", plans)
}
var j models.SYBInnerCodeMatchJob
db.First(&j, "id = ?", job)
if j.Processed != 2 || j.Ready != 1 || j.Failed != 1 || j.Status != "succeeded" {
t.Fatalf("counts=%+v", j)
}
}
func TestSingleRecordPanicIsIsolated(t *testing.T) {
db := testDB(t)
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("PANIC", "S", "A", "IC-1"), matchRecord("NEXT", "S", "A", "IC-2")})
r := interruptingReader{fakeMatchReader: &fakeMatchReader{rows: map[string][]sybclient.StockRow{}}, panicOrder: "PANIC"}
if err := RunMatchJob(context.Background(), db, r, job); err != nil {
t.Fatal(err)
}
var rows []models.SYBInnerCodeRecord
db.Order("id").Find(&rows)
if rows[0].Status != models.SYBInnerCodeFailed || rows[0].ResultMessage != "匹配处理异常,请重新匹配" || rows[1].Status != models.SYBInnerCodeFailed {
t.Fatal("panic was not safely persisted or next row not processed")
}
var j models.SYBInnerCodeMatchJob
db.First(&j, "id = ?", job)
if j.Processed != 2 {
t.Fatal("remaining row was not processed")
}
}
func TestCancelledMatchPersistsFailureWithIndependentContext(t *testing.T) {
db := testDB(t)
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("A", "S", "A", "IC-1"), matchRecord("B", "S", "B", "IC-2")})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := RunMatchJob(ctx, db, interruptingReader{fakeMatchReader: &fakeMatchReader{}, cancel: cancel}, job)
if !errors.Is(err, context.Canceled) {
t.Fatalf("err=%v", err)
}
var rows []models.SYBInnerCodeRecord
db.Find(&rows)
for _, r := range rows {
if r.Status != models.SYBInnerCodeFailed {
t.Fatal("cancel left pending row")
}
}
var j models.SYBInnerCodeMatchJob
db.First(&j, "id = ?", job)
if j.Status != "failed" || j.FinishedAt == nil {
t.Fatal("cancel left running job")
}
}
func TestRestartRecoveryPreservesSuccessfulRecordsAndScope(t *testing.T) {
db := testDB(t)
job := createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("A", "S", "A", "IC-1"), matchRecord("B", "S", "B", "IC-2")})
db.Model(&models.SYBInnerCodeMatchJob{}).Where("id = ?", job).Update("status", "running")
db.Model(&models.SYBInnerCodeRecord{}).Where("order_number = ?", "A").Update("status", models.SYBInnerCodeUpdated)
outside := matchRecord("OUTSIDE", "S", "C", "IC-3")
db.Create(&outside)
if err := RecoverInterruptedMatches(db); err != nil {
t.Fatal(err)
}
if err := RecoverInterruptedMatches(db); err != nil {
t.Fatal(err)
}
var rows []models.SYBInnerCodeRecord
db.Order("id").Find(&rows)
if rows[0].Status != models.SYBInnerCodeUpdated || rows[1].Status != models.SYBInnerCodeFailed || rows[2].Status != models.SYBInnerCodePending {
t.Fatal("recovery altered success or outside scope")
}
var j models.SYBInnerCodeMatchJob
db.First(&j, "id = ?", job)
if j.Status != "failed" || j.FinishedAt == nil {
t.Fatal("recovery left running job")
}
}
func TestBatchRematchClaimsPendingFailedSkippedAndIsIdempotent(t *testing.T) {
db := testDB(t)
statuses := []string{models.SYBInnerCodePending, models.SYBInnerCodeFailed, models.SYBInnerCodeSkipped}
ids := []uint64{}
for i, status := range statuses {
r := matchRecord(string(rune('A'+i)), "S", "A", string(rune('X'+i)))
r.Status = status
if err := db.Create(&r).Error; err != nil {
t.Fatal(err)
}
ids = append(ids, r.ID)
}
s := NewService(db)
req := RematchRequest{RequestID: uuid.NewString(), IDs: ids}
result, err := s.QueueRematch(context.Background(), req)
if err != nil || result.Queued != 3 {
t.Fatalf("result=%+v err=%v", result, err)
}
replay, err := s.QueueRematch(context.Background(), req)
if err != nil || replay.MatchJobID != result.MatchJobID {
t.Fatal("idempotent replay failed")
}
if _, err := s.QueueRematch(context.Background(), RematchRequest{RequestID: uuid.NewString(), IDs: ids}); err == nil {
t.Fatal("overlapping job accepted")
}
var rows []models.SYBInnerCodeRecord
db.Find(&rows)
for _, r := range rows {
if r.Status != models.SYBInnerCodeMatching {
t.Fatal("not claimed")
}
}
if _, err := s.Delete(context.Background(), 1, DeleteRequest{RequestID: uuid.NewString(), IDs: ids}); err == nil {
t.Fatal("matching records can be deleted during execution")
}
if err := RunMatchJob(context.Background(), db, &fakeMatchReader{rows: map[string][]sybclient.StockRow{}}, result.MatchJobID); err != nil {
t.Fatal(err)
}
var job models.SYBInnerCodeMatchJob
db.First(&job, "id = ?", result.MatchJobID)
if job.Processed != 3 || job.Status != "succeeded" {
t.Fatal("claimed matching records were not executed")
}
}
func TestRematchRejectsPendingOwnedByImportAndProtectedStates(t *testing.T) {
db := testDB(t)
createMatchJob(t, db, []models.SYBInnerCodeRecord{matchRecord("A", "S", "A", "X")})
var r models.SYBInnerCodeRecord
db.First(&r)
s := NewService(db)
if _, err := s.QueueRematch(context.Background(), RematchRequest{RequestID: uuid.NewString(), IDs: []uint64{r.ID}}); err == nil {
t.Fatal("active import overlapped")
}
for _, status := range []string{models.SYBInnerCodeReady, models.SYBInnerCodeUpdated, models.SYBInnerCodeAlreadyFilled, models.SYBInnerCodeQueued, models.SYBInnerCodeApplying, models.SYBInnerCodeNeedsCheck} {
db.Model(&r).Update("status", status)
if _, err := s.QueueRematch(context.Background(), RematchRequest{RequestID: uuid.NewString(), IDs: []uint64{r.ID}}); err == nil {
t.Fatalf("protected status accepted: %s", status)
}
}
}
@@ -147,6 +147,209 @@ func TestRunMatchJobReservesDifferentDetailsForRecordsInOneBatch(t *testing.T) {
}
}
// --- #259/#273/#289 stall-matching regression tests (ported from cmautobuy) ---
func TestStallMatchesIgnoresWeightLikeNumberAsArticle(t *testing.T) {
// #273: "50公斤" must not be treated as if the article were the bare number 50.
item := detail(1, "50公斤,黑色", 1, "SKU-X", "", "")
if stallMatches("档口甲#50", item) {
t.Fatalf("weight-like text must not match numeric article 50")
}
}
func TestStallMatchesRejectsSubstringInsideLongCode(t *testing.T) {
// #273: a long code with internal digits (PDD256437) must not spuriously
// match a short numeric article (256) via substring containment.
item := detail(1, "黑色,L", 1, "PDD256437", "档口甲", "")
if stallMatches("档口甲#256", item) {
t.Fatalf("long code must not match numeric article 256 via substring")
}
}
func TestStallMatchesLeadingZeroEquivalenceRequiresStallName(t *testing.T) {
// #259/#273: "067" and "67" are equivalent articles only when the stall
// name also matches; a different stall name must not match.
sameStall := detail(1, "黑色,L", 1, "档口甲-067", "", "")
if !stallMatches("档口甲#67", sameStall) {
t.Fatalf("067 should be treated as equivalent to 67 when stall name matches")
}
differentStall := detail(2, "黑色,L", 1, "档口乙-067", "", "")
if stallMatches("档口甲#67", differentStall) {
t.Fatalf("067 must not match 67 when the stall name differs")
}
}
func TestStallMatchesNonNumericArticleRequiresExactToken(t *testing.T) {
item := detail(1, "黑色,L", 1, "ABC12", "", "")
if stallMatches("档口甲#AB", item) {
t.Fatalf("non-numeric article must require an exact token match, not substring")
}
exact := detail(2, "黑色,L", 1, "AB", "", "")
if !stallMatches("档口甲#AB", exact) {
t.Fatalf("exact non-numeric token should match")
}
}
func TestStallMatchesHandlesHashInsideStallName(t *testing.T) {
// Splits on the LAST '#' so a stall name that itself contains '#' still
// yields the correct article.
item := detail(1, "黑色,L", 1, "档口#甲-67", "", "")
if !stallMatches("档口#甲#67", item) {
t.Fatalf("stall name containing '#' should still resolve article via last '#'")
}
}
func TestStallMatchesProductSpecPrefixMatchesArticle(t *testing.T) {
// Non-numeric article: ProductSpec prefix match does not additionally
// require the stall name to appear in sku/variationSku.
item := detail(1, "AB 黑色,L", 1, "", "", "")
if !stallMatches("档口甲#AB", item) {
t.Fatalf("ProductSpec starting with the article should match")
}
notPrefix := detail(2, "黑色,ABL", 1, "", "", "")
if stallMatches("档口甲#AB", notPrefix) {
t.Fatalf("article appearing mid-spec (not as prefix) must not match")
}
}
func TestStallMatchesEmptyArticleFallsBackToStallName(t *testing.T) {
item := detail(1, "黑色,L", 1, "档口甲专柜", "", "")
if !stallMatches("档口甲#", item) {
t.Fatalf("empty article should fall back to stall-name containment")
}
}
// --- #289: existing-binding-preserving multi-piece assignment ---
func TestAssignExistingBoundItemsPreservesOutOfOrderBindings(t *testing.T) {
// Two single-piece candidates already carry codes, but the previously
// bound code (IC-2) sits on the LOWER-ID detail while the target order
// expects it second. A naive ID-order/index assignment would strip the
// existing correct binding from detail 20 and try to overwrite it.
d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2")
d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
assigned, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
if reason != "" {
t.Fatalf("unexpected reason: %s", reason)
}
if assigned[1].ID != 20 {
t.Fatalf("expected detail 20 (already bound to IC-2) preserved at index 1, got %+v", assigned[1])
}
if assigned[0].ID != 21 {
t.Fatalf("expected the blank detail 21 filled in at index 0, got %+v", assigned[0])
}
}
func TestAssignExistingBoundItemsRejectsForeignCode(t *testing.T) {
d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-OTHER")
d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
_, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
if reason == "" {
t.Fatalf("expected rejection for detail already holding a non-target code")
}
}
func TestAssignExistingBoundItemsRejectsInconsistentIdentity(t *testing.T) {
// Candidates can share a NormalizeSpecKey-normalized spec while their raw
// ProductSpec/sku/variationSku differ; auto-assignment across genuinely
// different items must be rejected.
d20 := detail(20, "黑色, L", 1, "SKU-1", "A#1", "")
d21 := detail(21, "黑色,L", 1, "SKU-2", "A#1", "")
_, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
if reason != "重复候选的规格或 SKU 身份不一致,不能自动逐件分配" {
t.Fatalf("expected identity-mismatch rejection, got %q", reason)
}
}
func TestAssignExistingBoundItemsRejectsCandidateFailingStallCheck(t *testing.T) {
// The no-SKU fallback path in matchEvidence can hand back candidates that
// were never checked against the stall at all. Both candidates share an
// identical identity (so the identity guard passes) but neither one's
// sku/spec actually satisfies the record's stall/article requirement.
d20 := detail(20, "黑色,L", 1, "SKU-1", "ZZZ", "")
d21 := detail(21, "黑色,L", 1, "SKU-1", "ZZZ", "")
_, reason := assignExistingBoundItems("甲档口#88", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
if reason != "重复候选的档口及货号不一致,不能自动逐件分配" {
t.Fatalf("expected stall-mismatch rejection, got %q", reason)
}
}
func TestAssignExistingBoundItemsRejectsInvalidOrDuplicateDetailID(t *testing.T) {
invalidID := detail(0, "黑色,L", 1, "SKU-1", "A#1", "")
valid := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
if _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{invalidID, valid}); reason == "" {
t.Fatalf("expected rejection for non-positive detail ID")
}
dup1 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "")
dup2 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "")
if _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{dup1, dup2}); reason == "" {
t.Fatalf("expected rejection for duplicate detail IDs")
}
}
func TestAssignExistingBoundItemsHappyPathStillAssignsWithGuards(t *testing.T) {
// Both candidates share identical raw spec/sku/variationSku and both
// individually satisfy the stall check; the guards must not block the
// legitimate happy path.
d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2")
d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "")
assigned, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
if reason != "" {
t.Fatalf("unexpected reason: %s", reason)
}
if assigned[0].ID != 21 || assigned[1].ID != 20 {
t.Fatalf("assigned=%+v", assigned)
}
}
func TestRunMatchJobPreservesExistingBindingWhenCandidateOrderDiffers(t *testing.T) {
// End-to-end regression for #289: N=2 single-piece candidates already
// carrying one previously bound code, with the bound detail's ID not
// matching sequential/ID order relative to the target codes. The plan
// must leave the already-correct binding untouched and only place the
// missing code onto the still-blank detail.
db := testDB(t)
// SourceSKURaw is intentionally left blank: with the same source SKU on
// both candidate rows the source-SKU path would reject as "duplicate"
// before ever reaching the stall-based multi-item assignment being
// regression-tested here.
record := models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: "ORDER-1", Stall: "A#1", SpecKey: "黑色,L", SpecRaw: "黑色,L", Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: "IC-1", Ordinal: 1, SourceRow: 2}, {BusinessDate: "2026-08-28", Code: "IC-2", Ordinal: 2, SourceRow: 3}}}
records := []models.SYBInnerCodeRecord{record}
jobID := createMatchJob(t, db, records)
recordID := records[0].ID
// Detail 20 (lower ID) already carries IC-2 (bound out of sequence);
// detail 21 (higher ID) is still blank and should receive IC-1.
stock := sybclient.StockDetail{ID: 10, Details: []sybclient.DetailItem{
detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2"),
detail(21, "黑色,L", 1, "SKU-1", "A#1", ""),
}}
reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10, Code: "ORDER-1"}}}, stocks: map[int64]sybclient.StockDetail{10: stock}}
if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil {
t.Fatal(err)
}
var plan models.SYBInnerCodePlan
if err := db.First(&plan, "record_id = ?", recordID).Error; err != nil {
t.Fatal(err)
}
var items []plannedRemoteItem
if err := json.Unmarshal([]byte(plan.RemoteItemsJSON), &items); err != nil {
t.Fatal(err)
}
if len(items) != 2 {
t.Fatalf("items=%+v", items)
}
byCode := map[string]plannedRemoteItem{}
for _, item := range items {
byCode[item.Code] = item
}
if byCode["IC-2"].DetailID != 20 {
t.Fatalf("existing binding for IC-2 must stay on detail 20, got %+v", byCode["IC-2"])
}
if byCode["IC-1"].DetailID != 21 {
t.Fatalf("missing IC-1 should be assigned to the blank detail 21, got %+v", byCode["IC-1"])
}
}
type captureStarter struct {
jobID string
committed bool
+24 -5
View File
@@ -200,8 +200,8 @@ func (s *Service) QueueRematch(ctx context.Context, request RematchRequest) (Rem
return conflict("部分记录不存在")
}
for _, record := range records {
if record.Status != models.SYBInnerCodeFailed && record.Status != models.SYBInnerCodeSkipped {
return conflict("只有读取失败或匹配受限记录可以重新匹配")
if record.Status != models.SYBInnerCodePending && record.Status != models.SYBInnerCodeFailed && record.Status != models.SYBInnerCodeSkipped {
return conflict("只有待匹配、读取失败或匹配受限记录可以重新匹配")
}
if date == "" {
date = record.BusinessDate
@@ -209,10 +209,29 @@ func (s *Service) QueueRematch(ctx context.Context, request RematchRequest) (Rem
return conflict("重新匹配记录必须属于同一营业日期")
}
}
var activeJobs []models.SYBInnerCodeMatchJob
if err := tx.Where("business_date = ? AND status IN ?", date, []string{"pending", "running"}).Find(&activeJobs).Error; err != nil {
return err
}
selected := make(map[uint64]bool, len(ids))
for _, id := range ids {
selected[id] = true
}
for _, job := range activeJobs {
var jobIDs []uint64
if err := json.Unmarshal([]byte(job.RecordIDsJSON), &jobIDs); err != nil {
return err
}
for _, id := range jobIDs {
if selected[id] {
return conflict("选中记录已有匹配任务,请等待任务结束")
}
}
}
if err := tx.Where("record_id IN ?", ids).Delete(&models.SYBInnerCodePlan{}).Error; err != nil {
return err
}
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ?", ids).Updates(map[string]any{"status": models.SYBInnerCodePending, "result_message": "等待重新匹配"}).Error; err != nil {
if err := tx.Model(&models.SYBInnerCodeRecord{}).Where("id IN ?", ids).Updates(map[string]any{"status": models.SYBInnerCodeMatching, "result_message": "等待重新匹配"}).Error; err != nil {
return err
}
recordIDsJSON, _ := json.Marshal(ids)
@@ -267,13 +286,13 @@ func (s *Service) Delete(ctx context.Context, actor uint64, request DeleteReques
return conflict("部分记录不存在,未删除任何数据")
}
for _, record := range records {
if record.Status == models.SYBInnerCodeQueued || record.Status == models.SYBInnerCodeApplying || record.Status == models.SYBInnerCodeNeedsCheck {
if record.Status == models.SYBInnerCodeMatching || record.Status == models.SYBInnerCodeQueued || record.Status == models.SYBInnerCodeApplying || record.Status == models.SYBInnerCodeNeedsCheck {
result.Blocked = append(result.Blocked, BlockedRecord{ID: record.ID, Status: record.Status})
}
}
if len(result.Blocked) > 0 {
sort.Slice(result.Blocked, func(i, j int) bool { return result.Blocked[i].ID < result.Blocked[j].ID })
return &ServiceError{Code: CodeConflict, Message: "选中记录包含排队中、回写中或需复核状态,未删除任何数据", Details: map[string]any{"blocked": result.Blocked}}
return &ServiceError{Code: CodeConflict, Message: "选中记录包含匹配中、排队中、回写中或需复核状态,未删除任何数据", Details: map[string]any{"blocked": result.Blocked}}
}
// The state gate above is the dynamic restriction for active writeback
// evidence. Terminal evidence belongs to imported data and is physically
@@ -77,6 +77,46 @@ func (h Handler) SetEnabled(c *gin.Context) {
}
c.JSON(200, gin.H{"code": 200, "data": x})
}
// RecomputePreview and RecomputeExecute implement #340 decision 7's admin-only
// "按当前规则重新计算" action. Both are gated by middleware.RequireRoleKey
// ("admin") at the router, the same admin gate this package already uses for
// Create/SetEnabled/Delete.
func (h Handler) RecomputePreview(c *gin.Context) {
s, ok := h.service(c)
if !ok {
return
}
counts, e := s.RecomputePreview(c.Request.Context())
if e != nil {
writeError(c, e)
return
}
c.JSON(200, gin.H{"code": 200, "data": counts})
}
type recomputeExecuteBody struct {
Fingerprint string `json:"fingerprint"`
}
func (h Handler) RecomputeExecute(c *gin.Context) {
var body recomputeExecuteBody
if !decode(c, &body) {
return
}
s, ok := h.service(c)
if !ok {
return
}
claims := jwt.ExtractClaims(c)
name, _ := claims["nice"].(string)
result, e := s.RecomputeExecute(c.Request.Context(), name, body.Fingerprint)
if e != nil {
writeError(c, e)
return
}
c.JSON(200, gin.H{"code": 200, "data": result})
}
func (h Handler) Delete(c *gin.Context) {
id, ok := idParam(c)
if !ok {
@@ -120,6 +160,8 @@ func writeError(c *gin.Context, e error) {
status = 422
case CodeConflict:
status = 409
case CodeRecomputeStale:
status = 409
case CodeNotFound:
status = 404
case CodeForbidden:
@@ -0,0 +1,423 @@
package sybproductfilter
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"strings"
"time"
"go-admin/app/goauto/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RecomputeCounts is shared by preview and execute (#340 decision 7). The
// preview's counts are a PLAN computed against an unlocked read; the
// execute's counts are what was ACTUALLY written, re-checked row by row
// under lock (phase 3 review fix) — the two normally agree, but execute's
// counts are authoritative when something changed concurrently between the
// two calls (a purchase task created, a return matched, or the same row
// recomputed by another admin).
type RecomputeCounts struct {
ExcludedToPDD int `json:"excludedToPdd"`
PDDToExcluded int `json:"pddToExcluded"`
SkippedHasTask int `json:"skippedHasTask"`
SkippedReturnMatch int `json:"skippedReturnMatch"`
}
type recomputeChange struct {
id uint64
orderCode string
shopeeItemID string
toExcluded bool // true: pdd -> excluded; false: excluded -> pdd
ruleID *uint64
ruleKind, ruleKeyword string
}
// recomputeSourceRow selects only the columns recomputeChanges needs (phase 3
// review item 4) instead of the full syb_product row.
type recomputeSourceRow struct {
ID uint64 `gorm:"column:id"`
OrderCode string `gorm:"column:order_code"`
ShopeeItemID string `gorm:"column:shopee_item_id"`
RawJSON string `gorm:"column:raw_json"`
PDDExcluded bool `gorm:"column:pdd_purchase_excluded"`
}
// RecomputeSample is one preview row (#340 phase 2): up to maxRecomputeSamples
// changes are shown so an admin can sanity-check the run before executing it.
type RecomputeSample struct {
OrderCode string `json:"orderCode"`
ShopeeItemID string `json:"shopeeItemId"`
// Direction is "excluded_to_pdd" or "pdd_to_excluded".
Direction string `json:"direction"`
// RuleKind/RuleKeyword are empty when Direction is excluded_to_pdd (no
// rule matches any more).
RuleKind string `json:"ruleKind,omitempty"`
RuleKeyword string `json:"ruleKeyword,omitempty"`
}
const (
DirectionExcludedToPDD = "excluded_to_pdd"
DirectionPDDToExcluded = "pdd_to_excluded"
maxRecomputeSamples = 20
)
// recomputeChanges computes, against the CURRENT enabled rules, every
// syb_product row whose mark should flip, skipping any row that has ever had
// a purchase task or currently has an active return match — those never
// change (#340 decision 7). This is a PLAN: preview uses it directly, and
// execute uses it as the candidate list, re-checking each row under lock in
// writeRecomputeChanges before actually writing (phase 3 review item 1).
func recomputeChanges(ctx context.Context, tx *gorm.DB) (RecomputeCounts, []recomputeChange, error) {
filters, err := LoadEnabled(ctx, tx)
if err != nil {
return RecomputeCounts{}, nil, err
}
var rows []recomputeSourceRow
if err := tx.WithContext(ctx).Model(&models.SYBProduct{}).
Select("id, order_code, shopee_item_id, raw_json, pdd_purchase_excluded").
Order("id ASC").Find(&rows).Error; err != nil {
return RecomputeCounts{}, nil, err
}
hasTask := make(map[uint64]bool)
var taskSYBIDs []uint64
if err := tx.WithContext(ctx).Table("purchase_task").Distinct("syb_product_id").
Where("syb_product_id IS NOT NULL").Pluck("syb_product_id", &taskSYBIDs).Error; err != nil {
return RecomputeCounts{}, nil, err
}
for _, id := range taskSYBIDs {
hasTask[id] = true
}
activeReturnMatch := make(map[uint64]bool)
var matchedSYBIDs []uint64
if err := tx.WithContext(ctx).Table("return_match").Where("active_syb_product_id IS NOT NULL").Pluck("syb_product_id", &matchedSYBIDs).Error; err != nil {
return RecomputeCounts{}, nil, err
}
for _, id := range matchedSYBIDs {
activeReturnMatch[id] = true
}
counts := RecomputeCounts{}
changes := make([]recomputeChange, 0)
for _, row := range rows {
variation := recomputeVariationSku(row.RawJSON)
rule := filters.Match(variation)
wouldExclude := rule != nil
if wouldExclude == row.PDDExcluded {
continue
}
if hasTask[row.ID] {
counts.SkippedHasTask++
continue
}
if activeReturnMatch[row.ID] {
counts.SkippedReturnMatch++
continue
}
change := recomputeChange{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: wouldExclude}
if wouldExclude {
ruleID := rule.ID
change.ruleID, change.ruleKind, change.ruleKeyword = &ruleID, rule.Kind, rule.Keyword
counts.PDDToExcluded++
} else {
counts.ExcludedToPDD++
}
changes = append(changes, change)
}
return counts, changes, nil
}
// recomputeVariationSku mirrors sybimport.stringField(item.Raw, "variationSku")
// without importing that package (sybimport already imports this one).
func recomputeVariationSku(rawJSON string) string {
if strings.TrimSpace(rawJSON) == "" {
return ""
}
var raw map[string]any
if json.Unmarshal([]byte(rawJSON), &raw) != nil {
return ""
}
value, _ := raw["variationSku"].(string)
return value
}
// recomputeFingerprintEntry is one change's canonical, unambiguous
// representation for hashing (#340 phase 4 review item 1). It is JSON, not
// naive string concatenation: a naive "id:direction:ruleId" (or any other
// delimiter-joined string) can collide between two different plans whenever
// a field's own text can contain the delimiter or vary in length — e.g. a
// rule keyword containing ":" or newlines could make two distinct plans hash
// identically. encoding/json's field ordering for a fixed struct is stable,
// so this is both deterministic and injective for our purposes.
type recomputeFingerprintEntry struct {
ID uint64 `json:"id"`
Direction string `json:"direction"`
RuleID uint64 `json:"ruleId"`
RuleKind string `json:"ruleKind"`
RuleKeyword string `json:"ruleKeyword"`
}
// recomputeFingerprint binds a preview to the exact plan it showed, INCLUDING
// the rule evidence that will be written to excluded_rule_kind/
// excluded_rule_keyword (#340 phase 4 review item 1): two plans that flip the
// exact same id+direction but via a different (or since-edited) rule must
// hash differently, because RecomputeExecute is about to persist exactly
// this rule kind/keyword as this row's excluded_rule_* snapshot — a
// fingerprint that ignored them could let a stale plan through unnoticed
// whenever a rule's keyword/kind changed between preview and execute but the
// set of affected ids/directions happened to stay the same. For the
// excluded_to_pdd direction there is no rule (the row is losing its mark),
// so RuleID/RuleKind/RuleKeyword are left at their zero values, matching what
// gets written (nil/""/"").
//
// It is a sha256 over the JSON-encoded, sorted (by id, then direction) list
// of recomputeFingerprintEntry — sorting the decoded entries themselves
// (not pre-serialized strings) keeps the ordering rule obviously correct
// regardless of how any field is later escaped.
func recomputeFingerprint(changes []recomputeChange) string {
entries := make([]recomputeFingerprintEntry, 0, len(changes))
for _, change := range changes {
entry := recomputeFingerprintEntry{ID: change.id}
if change.toExcluded {
entry.Direction = DirectionPDDToExcluded
if change.ruleID != nil {
entry.RuleID = *change.ruleID
}
entry.RuleKind = change.ruleKind
entry.RuleKeyword = change.ruleKeyword
} else {
entry.Direction = DirectionExcludedToPDD
}
entries = append(entries, entry)
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].ID != entries[j].ID {
return entries[i].ID < entries[j].ID
}
return entries[i].Direction < entries[j].Direction
})
// Marshal errors are impossible here (every field is a plain string/uint64
// with no cycles), so it is safe to ignore the error and hash whatever
// was produced rather than plumb an error return through every caller.
payload, _ := json.Marshal(entries)
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:])
}
// RecomputePreviewResult is preview-only: it carries the same RecomputeCounts
// execute would produce absent any race, plus up to maxRecomputeSamples
// sample rows, and the Fingerprint execute requires to prove nothing moved
// between preview and execute.
type RecomputePreviewResult struct {
RecomputeCounts
Samples []RecomputeSample `json:"samples"`
Fingerprint string `json:"fingerprint"`
}
// RecomputePreview is read-only.
func (s *Service) RecomputePreview(ctx context.Context) (RecomputePreviewResult, error) {
counts, changes, err := recomputeChanges(ctx, s.DB)
if err != nil {
return RecomputePreviewResult{}, internal(err)
}
samples := make([]RecomputeSample, 0, maxRecomputeSamples)
for _, change := range changes {
if len(samples) >= maxRecomputeSamples {
break
}
sample := RecomputeSample{OrderCode: change.orderCode, ShopeeItemID: change.shopeeItemID}
if change.toExcluded {
sample.Direction = DirectionPDDToExcluded
sample.RuleKind, sample.RuleKeyword = change.ruleKind, change.ruleKeyword
} else {
sample.Direction = DirectionExcludedToPDD
}
samples = append(samples, sample)
}
return RecomputePreviewResult{RecomputeCounts: counts, Samples: samples, Fingerprint: recomputeFingerprint(changes)}, nil
}
type RecomputeExecuteResult struct {
RecomputeCounts
Operator string `json:"operator"`
}
// countPurchaseTasksLocked and countActiveReturnMatchesLocked are the two
// rechecks writeRecomputeChanges runs after taking the syb_product row lock.
//
// `[必须]` They MUST use a locking read (FOR SHARE), not a plain COUNT(*).
// Production and local are MySQL 8.4 under REPEATABLE-READ, where a plain
// read inside a transaction reuses the snapshot taken at that transaction's
// FIRST read (here, recomputeChanges' own SELECT) — so a purchase_task or
// return_match row committed by another connection AFTER that snapshot is
// invisible to a plain COUNT(*) even after this code has waited for and
// obtained the syb_product row's FOR UPDATE lock. The row lock only
// serializes writers against each other; it does not by itself make a later
// plain read see newer committed data under REPEATABLE-READ. A locking read
// (FOR SHARE is enough since these two only need to observe committed rows,
// not lock them for update) forces MySQL to use a fresh "current read"
// instead of the snapshot, which is exactly what closes the race (verified
// against real MySQL 8.4 with two connections: after the other transaction
// committed a task, plain COUNT returned 0 while COUNT ... FOR SHARE
// correctly returned 1). SQLite — used by this package's tests — drops
// locking clauses entirely (gorm.io/driver/sqlite's "FOR" clause builder is
// a no-op) and has no multi-connection snapshot semantics to reproduce this
// race in the first place, so no SQLite-backed test can catch a regression
// here; see TestRecheckQueriesUseLockingReads below for the SQL-shape test
// that does.
// sybProductRowLockQuery, purchaseTaskLockedQuery and returnMatchLockedQuery
// build (but do not execute) the three locking reads writeRecomputeChanges
// runs per row. They are split out from the count*/lock helpers below purely
// so a test can call db.ToSQL against the exact same query construction the
// production code runs, and assert the FOR UPDATE / FOR SHARE clause is
// actually present in the generated SQL (#340 phase 3 review follow-up).
func sybProductRowLockQuery(tx *gorm.DB) *gorm.DB {
return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate})
}
func purchaseTaskLockedQuery(tx *gorm.DB, sybID uint64) *gorm.DB {
return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthShare}).
Model(&models.PurchaseTask{}).Where("syb_product_id = ?", sybID)
}
func returnMatchLockedQuery(tx *gorm.DB, sybID uint64) *gorm.DB {
return tx.Clauses(clause.Locking{Strength: clause.LockingStrengthShare}).
Table("return_match").Where("syb_product_id = ? AND active_syb_product_id IS NOT NULL", sybID)
}
func countPurchaseTasksLocked(ctx context.Context, tx *gorm.DB, sybID uint64) (int64, error) {
var count int64
err := purchaseTaskLockedQuery(tx.WithContext(ctx), sybID).Count(&count).Error
return count, err
}
func countActiveReturnMatchesLocked(ctx context.Context, tx *gorm.DB, sybID uint64) (int64, error) {
var count int64
err := returnMatchLockedQuery(tx.WithContext(ctx), sybID).Count(&count).Error
return count, err
}
// writeRecomputeChanges is the write phase, kept separate from planning so it
// is independently testable (#340 phase 3 review item 1): for every planned
// change it takes the SAME row lock purchase.Service.create and returnmatch's
// matchOneWithLock take (clause.Locking{Strength:"UPDATE"}), then re-checks
// under that lock — a purchase task or active return match created for the
// row after planning skips it (counted the same as a plan-time skip), and a
// row whose mark already matches the target (e.g. flipped by a concurrent
// recompute) is left alone and not counted at all. Only rows that still need
// the change are written. The returned counts are what was ACTUALLY done.
func writeRecomputeChanges(ctx context.Context, tx *gorm.DB, planned []recomputeChange) (RecomputeCounts, error) {
actual := RecomputeCounts{}
now := time.Now().UTC()
for _, change := range planned {
var locked models.SYBProduct
if err := sybProductRowLockQuery(tx.WithContext(ctx)).First(&locked, change.id).Error; err != nil {
return RecomputeCounts{}, err
}
taskCount, err := countPurchaseTasksLocked(ctx, tx, change.id)
if err != nil {
return RecomputeCounts{}, err
}
if taskCount > 0 {
actual.SkippedHasTask++
continue
}
matchCount, err := countActiveReturnMatchesLocked(ctx, tx, change.id)
if err != nil {
return RecomputeCounts{}, err
}
if matchCount > 0 {
actual.SkippedReturnMatch++
continue
}
if locked.PDDExcluded == change.toExcluded {
// Already at the target (e.g. another recompute got there first
// under its own lock) — nothing to do, and this is not a
// "skipped" outcome in the reported sense since there was never
// a real conflict blocking it.
continue
}
updates := map[string]any{"pdd_purchase_excluded": change.toExcluded}
if change.toExcluded {
updates["excluded_rule_id"] = change.ruleID
updates["excluded_rule_kind"] = change.ruleKind
updates["excluded_rule_keyword"] = change.ruleKeyword
updates["excluded_at"] = now
} else {
updates["excluded_rule_id"] = nil
updates["excluded_rule_kind"] = ""
updates["excluded_rule_keyword"] = ""
updates["excluded_at"] = nil
}
if err := tx.WithContext(ctx).Model(&models.SYBProduct{}).Where("id = ?", change.id).Updates(updates).Error; err != nil {
return RecomputeCounts{}, err
}
if change.toExcluded {
actual.PDDToExcluded++
} else {
actual.ExcludedToPDD++
}
}
return actual, nil
}
// RecomputeExecute re-validates the plan against a fingerprint the caller
// must have gotten from a RecomputePreview call (#340 phase 3 review item 2):
// if the current plan's fingerprint no longer matches — the underlying data
// or the enabled rules changed since that preview — nothing is written and
// CodeRecomputeStale is returned. Otherwise every planned change is written
// through writeRecomputeChanges under its own per-row lock, and one audit log
// row is written in the same transaction using the ACTUAL counts.
func (s *Service) RecomputeExecute(ctx context.Context, operator, fingerprint string) (RecomputeExecuteResult, error) {
fingerprint = strings.TrimSpace(fingerprint)
if fingerprint == "" {
return RecomputeExecuteResult{}, invalid("fingerprint 不能为空,请先调用预览接口")
}
var result RecomputeExecuteResult
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
planCounts, changes, err := recomputeChanges(ctx, tx)
if err != nil {
return err
}
if recomputeFingerprint(changes) != fingerprint {
return &ServiceError{Code: CodeRecomputeStale, Message: "数据或规则已变化,请重新预览后再执行"}
}
written, err := writeRecomputeChanges(ctx, tx, changes)
if err != nil {
return err
}
// The ACTUAL, reported counts combine what planning already knew was
// ineligible (rows never even handed to the write phase) with what
// the write phase itself caught under lock — so a plan-time skip and
// a write-time race both show up, and Excluded/PDD counts are always
// exactly what got written (#340 phase 3 review item 1c).
actual := RecomputeCounts{
ExcludedToPDD: written.ExcludedToPDD,
PDDToExcluded: written.PDDToExcluded,
SkippedHasTask: planCounts.SkippedHasTask + written.SkippedHasTask,
SkippedReturnMatch: planCounts.SkippedReturnMatch + written.SkippedReturnMatch,
}
log := models.SYBProductFilterRecomputeLog{
Operator: strings.TrimSpace(operator), ExcludedToPDD: actual.ExcludedToPDD,
PDDToExcluded: actual.PDDToExcluded, SkippedHasTask: actual.SkippedHasTask,
SkippedReturnMatch: actual.SkippedReturnMatch,
}
if err := tx.Create(&log).Error; err != nil {
return err
}
result = RecomputeExecuteResult{RecomputeCounts: actual, Operator: log.Operator}
return nil
})
if err != nil {
if se, ok := err.(*ServiceError); ok {
return RecomputeExecuteResult{}, se
}
return RecomputeExecuteResult{}, internal(err)
}
return result, nil
}
@@ -0,0 +1,89 @@
package sybproductfilter
import (
"strings"
"testing"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// mysqlDryRunDB opens a gorm session against the MySQL dialector with
// DryRun+DisableAutomaticPing, so no real network connection is ever made
// (sql.Open is lazy and gorm skips the startup ping) but the SQL gorm would
// send to a real MySQL 8.4 server can still be inspected via db.ToSQL.
//
// `[必须]` This must be the MySQL dialector, not SQLite: gorm.io/driver/
// sqlite's own "FOR" clause builder silently drops clause.Locking entirely
// (SQLite has no row-level locking), so a SQLite-backed test would show
// these queries with no FOR clause at all regardless of whether the
// production code asks for one — it would pass even with the bug this test
// exists to catch. Only a MySQL-dialect SQL string proves the FOR UPDATE /
// FOR SHARE clauses are actually being sent.
func mysqlDryRunDB(t *testing.T) *gorm.DB {
t.Helper()
// SkipInitializeWithVersion is required, not just DisableAutomaticPing:
// gorm's MySQL dialector otherwise runs `SELECT VERSION()` against the
// DSN's ConnPool during Initialize (Open), before DryRun/ping settings
// even come into play, to decide version-gated feature flags such as
// DontSupportForShareClause. With it set, sql.Open's lazy connection
// pool is never dialed at all.
db, err := gorm.Open(mysql.New(mysql.Config{
DSN: "user:pass@tcp(127.0.0.1:3306)/goauto_test?parseTime=true", SkipInitializeWithVersion: true,
}), &gorm.Config{DryRun: true, DisableAutomaticPing: true})
if err != nil {
t.Fatalf("open dry-run mysql session: %v", err)
}
return db
}
// TestRecheckQueriesUseLockingReads pins the exact SQL shape behind #340
// phase 3's real fix: writeRecomputeChanges' syb_product row lock must be
// FOR UPDATE, and its two rechecks (purchase_task, return_match) must be
// FOR SHARE — a plain COUNT(*) for either recheck is invisible to a
// transaction's already-taken REPEATABLE-READ snapshot on real MySQL 8.4
// even after the row's FOR UPDATE lock is granted, which is exactly the race
// this test guards against ever regressing to (verified against a real
// MySQL 8.4 server with two connections: a purchase_task committed by the
// other connection after the snapshot was invisible to plain COUNT(*), but
// visible to COUNT(*) ... FOR SHARE). SQLite, which every other test in this
// package runs against, cannot reproduce any of this: it has no
// multi-connection snapshot isolation and gorm's SQLite driver drops locking
// clauses outright, so this test intentionally talks MySQL SQL shape only,
// never a real database.
func TestRecheckQueriesUseLockingReads(t *testing.T) {
db := mysqlDryRunDB(t)
rowLockSQL := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
var dest map[string]any
return sybProductRowLockQuery(tx).Table("syb_product").Where("id = ?", uint64(1)).Find(&dest)
})
t.Logf("ROW LOCK SQL: %s", rowLockSQL)
if !strings.Contains(rowLockSQL, "FOR UPDATE") {
t.Fatalf("expected the syb_product row lock to be FOR UPDATE, got SQL: %s", rowLockSQL)
}
var taskCount int64
taskSQL := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
return purchaseTaskLockedQuery(tx, 1).Count(&taskCount)
})
t.Logf("PURCHASE TASK RECHECK SQL: %s", taskSQL)
if !strings.Contains(taskSQL, "FOR SHARE") {
t.Fatalf("expected the purchase_task recheck to be a locking (FOR SHARE) read, got SQL: %s", taskSQL)
}
if !strings.Contains(taskSQL, "syb_product_id") {
t.Fatalf("expected the purchase_task recheck to filter by syb_product_id, got SQL: %s", taskSQL)
}
var matchCount int64
matchSQL := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
return returnMatchLockedQuery(tx, 1).Count(&matchCount)
})
t.Logf("RETURN MATCH RECHECK SQL: %s", matchSQL)
if !strings.Contains(matchSQL, "FOR SHARE") {
t.Fatalf("expected the return_match recheck to be a locking (FOR SHARE) read, got SQL: %s", matchSQL)
}
if !strings.Contains(matchSQL, "active_syb_product_id IS NOT NULL") {
t.Fatalf("expected the return_match recheck to filter on an active match, got SQL: %s", matchSQL)
}
}
@@ -0,0 +1,351 @@
package sybproductfilter
import (
"context"
"database/sql"
"fmt"
"os"
"regexp"
"testing"
"time"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
_ "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
// dsnPathReplacer swaps the database name in a Go MySQL DSN of the form
// user:pass@tcp(host:port)/dbname?params — used to connect first to the
// server (no specific throwaway database yet) and then to the freshly
// created throwaway database.
var dsnPathReplacer = regexp.MustCompile(`^(.*/)([^/?]*)(\?.*)?$`)
func dsnWithDatabase(dsn, dbName string) string {
if dsnPathReplacer.MatchString(dsn) {
return dsnPathReplacer.ReplaceAllString(dsn, "${1}"+dbName+"${3}")
}
return dsn
}
// setupMySQLIntegrationDB is #340 phase 4 review item 2's throwaway-database
// harness: it never touches an existing database. GOAUTO_IT_MYSQL_DSN must
// point at a MySQL SERVER (any connectable path, e.g. the system "mysql"
// database) with permission to CREATE/DROP DATABASE; a uniquely named
// zz_goauto_it_340_<random> database is created, migrated, and guaranteed
// dropped via t.Cleanup even if the test fails or panics.
func setupMySQLIntegrationDB(t *testing.T) (dsn string, dbName string) {
t.Helper()
baseDSN := os.Getenv("GOAUTO_IT_MYSQL_DSN")
if baseDSN == "" {
t.Skip("GOAUTO_IT_MYSQL_DSN not set; skipping MySQL concurrency integration test")
}
admin, err := sql.Open("mysql", baseDSN)
if err != nil {
t.Fatalf("open admin connection: %v", err)
}
if err := admin.Ping(); err != nil {
admin.Close()
t.Fatalf("ping MySQL server: %v", err)
}
dbName = fmt.Sprintf("zz_goauto_it_340_%d", time.Now().UnixNano())
if _, err := admin.Exec("CREATE DATABASE `" + dbName + "`"); err != nil {
admin.Close()
t.Fatalf("create throwaway database %s: %v", dbName, err)
}
t.Cleanup(func() {
defer admin.Close()
if _, err := admin.Exec("DROP DATABASE IF EXISTS `" + dbName + "`"); err != nil {
t.Errorf("failed to drop throwaway database %s (manual cleanup required): %v", dbName, err)
}
})
dsn = dsnWithDatabase(baseDSN, dbName)
gdb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open throwaway database: %v", err)
}
if err := migrations.Migrate(gdb); err != nil {
t.Fatalf("migrate throwaway database: %v", err)
}
return dsn, dbName
}
func newMySQLIntegrationConn(t *testing.T, dsn string) *gorm.DB {
t.Helper()
conn, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open MySQL connection: %v", err)
}
return conn
}
// TestRecomputeConcurrentPurchaseTaskUnderRealMySQL is #340 phase 4 review
// item 2: it reproduces, against a real MySQL server under REPEATABLE-READ,
// the exact race writeRecomputeChanges' locking rechecks exist to close.
//
// Timeline:
// 1. Connection A begins a transaction and runs the planning step
// (recomputeChanges) — this is A's FIRST read, so it fixes A's
// REPEATABLE-READ snapshot with zero purchase_task rows.
// 2. Connection B, concurrently, takes the SAME row's FOR UPDATE lock,
// confirmed via a channel before A is allowed to proceed.
// 3. A's write phase (writeRecomputeChanges) is started in a goroutine; it
// must BLOCK trying to take the same FOR UPDATE lock B already holds —
// the test asserts A has NOT finished after a wait window, proving a
// real block happened (not just a fast, uncontended lock grant).
// 4. B inserts a purchase_task for the row and commits, releasing the lock.
// 5. A's write phase unblocks, re-checks purchase_task under lock, and must
// see B's now-committed row and skip — this only holds because the
// recheck is a locking (FOR SHARE) read; a plain COUNT(*) would still be
// bound to A's step-1 snapshot (zero rows) and would wrongly write.
func TestRecomputeConcurrentPurchaseTaskUnderRealMySQL(t *testing.T) {
dsn, _ := setupMySQLIntegrationDB(t)
seedConn := newMySQLIntegrationConn(t, dsn)
rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}
if err := seedConn.Create(&rule).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{
OrderCode: "ORD-IT-TASK", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`,
}
if err := seedConn.Create(&row).Error; err != nil {
t.Fatal(err)
}
// purchase_task.pdd_product_id has a real FK (unlike this package's
// SQLite-backed tests, which don't enable foreign key enforcement) —
// MySQL requires an actual pdd_product row to reference.
pdd := models.PDDProduct{GoodsID: "IT-PDD-1", URL: "https://example.invalid/it", SpecsJSON: "[]"}
if err := seedConn.Create(&pdd).Error; err != nil {
t.Fatal(err)
}
connA := newMySQLIntegrationConn(t, dsn)
connB := newMySQLIntegrationConn(t, dsn)
ctx := context.Background()
txA := connA.Begin()
// Guard against ANY early return (t.Fatalf, panic) leaving txA open: an
// abandoned open transaction holds a connection into this throwaway
// database and blocks the DROP DATABASE cleanup indefinitely. Rollback
// on an already-committed transaction is a harmless no-op error, which
// is why the plain Commit() path below intentionally does not disable
// this cleanup.
t.Cleanup(func() { txA.Rollback() })
_, planned, err := recomputeChanges(ctx, txA)
if err != nil {
t.Fatalf("plan: %v", err)
}
if len(planned) != 1 || planned[0].id != row.ID {
t.Fatalf("expected exactly the seeded row to be planned, got %+v", planned)
}
txB := connB.Begin()
t.Cleanup(func() { txB.Rollback() })
bHoldingLock := make(chan struct{})
bCanCommit := make(chan struct{})
bDone := make(chan error, 1)
go func() {
var locked models.SYBProduct
if err := txB.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
First(&locked, row.ID).Error; err != nil {
bDone <- fmt.Errorf("B lock row: %w", err)
return
}
close(bHoldingLock)
<-bCanCommit
task := models.PurchaseTask{
SYBProductID: &row.ID, PDDProductID: pdd.ID, Quantity: 1, CreateRequestID: "it-race-task",
Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive,
TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}",
}
if err := txB.Create(&task).Error; err != nil {
bDone <- fmt.Errorf("B insert task: %w", err)
return
}
bDone <- txB.Commit().Error
}()
select {
case <-bHoldingLock:
case err := <-bDone:
t.Fatalf("B failed before taking the row lock: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for B to take the row lock")
}
aDone := make(chan struct{})
var aActual RecomputeCounts
var aErr error
go func() {
aActual, aErr = writeRecomputeChanges(ctx, txA, planned)
close(aDone)
}()
// A must still be blocked on B's row lock at this point — this is the
// test's proof that a real MySQL row lock, not just program logic, is
// what's being exercised.
select {
case <-aDone:
t.Fatal("A's write phase returned before B committed — it should have blocked on the row's FOR UPDATE lock")
case <-time.After(300 * time.Millisecond):
}
close(bCanCommit)
if err := <-bDone; err != nil {
t.Fatalf("B failed: %v", err)
}
select {
case <-aDone:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for A's write phase to unblock after B committed")
}
if aErr != nil {
t.Fatalf("A's write phase failed: %v", aErr)
}
if err := txA.Commit().Error; err != nil {
t.Fatalf("commit A: %v", err)
}
if aActual.SkippedHasTask != 1 || aActual.PDDToExcluded != 0 {
t.Fatalf("expected A to skip the row for the concurrently-created task, got %+v", aActual)
}
var reloaded models.SYBProduct
if err := seedConn.First(&reloaded, row.ID).Error; err != nil {
t.Fatal(err)
}
if reloaded.PDDExcluded {
t.Fatalf("row must not have been marked excluded — the concurrent task should have blocked it: %+v", reloaded)
}
}
// TestRecomputeConcurrentReturnMatchUnderRealMySQL is the same scenario as
// TestRecomputeConcurrentPurchaseTaskUnderRealMySQL, with an active
// return_match row instead of a purchase_task as B's concurrent write.
func TestRecomputeConcurrentReturnMatchUnderRealMySQL(t *testing.T) {
dsn, _ := setupMySQLIntegrationDB(t)
seedConn := newMySQLIntegrationConn(t, dsn)
rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}
if err := seedConn.Create(&rule).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{
OrderCode: "ORD-IT-MATCH", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`,
}
if err := seedConn.Create(&row).Error; err != nil {
t.Fatal(err)
}
yeekeItem := models.YeekeReturnItem{PackageID: 0, ExternalKey: "it-race-return", ItemID: "1", VariationName: "档口-1", LastSyncedAt: time.Now()}
// A package row is required by the return_match/yeeke schema's foreign
// key; seed a minimal one.
pkg := models.YeekeReturnPackage{ExternalID: "it-race-pkg", OrderSN: "IT-ORD", TrackingNo: "IT-TRK", LastSyncedAt: time.Now()}
if err := seedConn.Create(&pkg).Error; err != nil {
t.Fatal(err)
}
yeekeItem.PackageID = pkg.ID
if err := seedConn.Create(&yeekeItem).Error; err != nil {
t.Fatal(err)
}
connA := newMySQLIntegrationConn(t, dsn)
connB := newMySQLIntegrationConn(t, dsn)
ctx := context.Background()
txA := connA.Begin()
// See TestRecomputeConcurrentPurchaseTaskUnderRealMySQL for why this
// unconditional cleanup is necessary regardless of the Commit() below.
t.Cleanup(func() { txA.Rollback() })
_, planned, err := recomputeChanges(ctx, txA)
if err != nil {
t.Fatalf("plan: %v", err)
}
if len(planned) != 1 || planned[0].id != row.ID {
t.Fatalf("expected exactly the seeded row to be planned, got %+v", planned)
}
txB := connB.Begin()
t.Cleanup(func() { txB.Rollback() })
bHoldingLock := make(chan struct{})
bCanCommit := make(chan struct{})
bDone := make(chan error, 1)
go func() {
var locked models.SYBProduct
if err := txB.Clauses(clause.Locking{Strength: clause.LockingStrengthUpdate}).
First(&locked, row.ID).Error; err != nil {
bDone <- fmt.Errorf("B lock row: %w", err)
return
}
close(bHoldingLock)
<-bCanCommit
match := models.ReturnMatch{
SYBProductID: row.ID, YeekeReturnItemID: yeekeItem.ID,
ActiveSYBProductID: &row.ID, Status: models.ReturnMatchStatusMatched, MatchedAt: time.Now(),
}
if err := txB.Create(&match).Error; err != nil {
bDone <- fmt.Errorf("B insert match: %w", err)
return
}
bDone <- txB.Commit().Error
}()
select {
case <-bHoldingLock:
case err := <-bDone:
t.Fatalf("B failed before taking the row lock: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for B to take the row lock")
}
aDone := make(chan struct{})
var aActual RecomputeCounts
var aErr error
go func() {
aActual, aErr = writeRecomputeChanges(ctx, txA, planned)
close(aDone)
}()
select {
case <-aDone:
t.Fatal("A's write phase returned before B committed — it should have blocked on the row's FOR UPDATE lock")
case <-time.After(300 * time.Millisecond):
}
close(bCanCommit)
if err := <-bDone; err != nil {
t.Fatalf("B failed: %v", err)
}
select {
case <-aDone:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for A's write phase to unblock after B committed")
}
if aErr != nil {
t.Fatalf("A's write phase failed: %v", aErr)
}
if err := txA.Commit().Error; err != nil {
t.Fatalf("commit A: %v", err)
}
if aActual.SkippedReturnMatch != 1 || aActual.PDDToExcluded != 0 {
t.Fatalf("expected A to skip the row for the concurrently-created return match, got %+v", aActual)
}
var reloaded models.SYBProduct
if err := seedConn.First(&reloaded, row.ID).Error; err != nil {
t.Fatal(err)
}
if reloaded.PDDExcluded {
t.Fatalf("row must not have been marked excluded — the concurrent return match should have blocked it: %+v", reloaded)
}
}
@@ -0,0 +1,496 @@
package sybproductfilter
import (
"context"
"fmt"
"testing"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
func TestRecomputePreviewMatchesExecute(t *testing.T) {
db := testDB(t)
// A row that currently needs a PDD purchase but now matches a keyword rule.
pddToExcluded := models.SYBProduct{
OrderCode: "ORD-1", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`,
}
if err := db.Create(&pddToExcluded).Error; err != nil {
t.Fatal(err)
}
// A row currently marked excluded whose rule no longer matches.
excludedToPDD := models.SYBProduct{
OrderCode: "ORD-2", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"普通-2"}`,
PDDExcluded: true,
}
if err := db.Create(&excludedToPDD).Error; err != nil {
t.Fatal(err)
}
// A row that should flip, but has a purchase task -> must be skipped.
hasTask := models.SYBProduct{
OrderCode: "ORD-3", DetailID: 3, StockID: 3, ShopeeItemID: "3", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-3"}`,
}
if err := db.Create(&hasTask).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.PurchaseTask{SYBProductID: &hasTask.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "req-3", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil {
t.Fatal(err)
}
// A row that should flip, but has an active return match -> must be skipped.
hasMatch := models.SYBProduct{
OrderCode: "ORD-4", DetailID: 4, StockID: 4, ShopeeItemID: "4", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-4"}`,
}
if err := db.Create(&hasMatch).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.ReturnMatch{SYBProductID: hasMatch.ID, YeekeReturnItemID: 1, ActiveSYBProductID: &hasMatch.ID, Status: models.ReturnMatchStatusMatched}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
preview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if preview.PDDToExcluded != 1 || preview.ExcludedToPDD != 1 || preview.SkippedHasTask != 1 || preview.SkippedReturnMatch != 1 {
t.Fatalf("unexpected preview counts: %+v", preview)
}
result, err := s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint)
if err != nil {
t.Fatal(err)
}
if result.RecomputeCounts != preview.RecomputeCounts {
t.Fatalf("execute counts must match preview: preview=%+v execute=%+v", preview.RecomputeCounts, result.RecomputeCounts)
}
if len(preview.Samples) != 2 {
t.Fatalf("expected 2 sample rows, got %+v", preview.Samples)
}
var sawPDDToExcluded, sawExcludedToPDD bool
for _, sample := range preview.Samples {
switch sample.Direction {
case DirectionPDDToExcluded:
sawPDDToExcluded = true
if sample.OrderCode != "ORD-1" || sample.RuleKeyword != "档口" {
t.Fatalf("unexpected pdd_to_excluded sample: %+v", sample)
}
case DirectionExcludedToPDD:
sawExcludedToPDD = true
if sample.OrderCode != "ORD-2" || sample.RuleKind != "" || sample.RuleKeyword != "" {
t.Fatalf("unexpected excluded_to_pdd sample: %+v", sample)
}
default:
t.Fatalf("unexpected direction: %+v", sample)
}
}
if !sawPDDToExcluded || !sawExcludedToPDD {
t.Fatalf("expected both directions represented: %+v", preview.Samples)
}
var reloadedPDDToExcluded, reloadedExcludedToPDD, reloadedHasTask, reloadedHasMatch models.SYBProduct
db.First(&reloadedPDDToExcluded, pddToExcluded.ID)
db.First(&reloadedExcludedToPDD, excludedToPDD.ID)
db.First(&reloadedHasTask, hasTask.ID)
db.First(&reloadedHasMatch, hasMatch.ID)
if !reloadedPDDToExcluded.PDDExcluded || reloadedPDDToExcluded.ExcludedRuleKeyword != "档口" {
t.Fatalf("expected row 1 to become excluded: %+v", reloadedPDDToExcluded)
}
if reloadedExcludedToPDD.PDDExcluded || reloadedExcludedToPDD.ExcludedRuleID != nil {
t.Fatalf("expected row 2 to become un-excluded: %+v", reloadedExcludedToPDD)
}
if reloadedHasTask.PDDExcluded {
t.Fatalf("row with a purchase task must never change: %+v", reloadedHasTask)
}
if reloadedHasMatch.PDDExcluded {
t.Fatalf("row with an active return match must never change: %+v", reloadedHasMatch)
}
var logs []models.SYBProductFilterRecomputeLog
if err := db.Find(&logs).Error; err != nil {
t.Fatal(err)
}
if len(logs) != 1 || logs[0].Operator != "admin1" || logs[0].PDDToExcluded != 1 || logs[0].ExcludedToPDD != 1 {
t.Fatalf("expected exactly one audit log row matching the counts: %+v", logs)
}
// Preview and execute must still agree on a no-op run.
secondPreview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if secondPreview.PDDToExcluded != 0 || secondPreview.ExcludedToPDD != 0 {
t.Fatalf("expected a no-op second preview, got %+v", secondPreview)
}
}
func TestRecomputePreviewCapsSamplesAt20(t *testing.T) {
db := testDB(t)
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 25; i++ {
row := models.SYBProduct{
OrderCode: fmt.Sprintf("ORD-CAP-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1),
ShopeeItemID: fmt.Sprintf("%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess,
RawJSON: `{"variationSku":"档口-x"}`,
}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
s := NewService(db)
preview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if preview.PDDToExcluded != 25 {
t.Fatalf("expected 25 counted changes, got %d", preview.PDDToExcluded)
}
if len(preview.Samples) != 20 {
t.Fatalf("expected samples capped at 20, got %d", len(preview.Samples))
}
}
func TestRecomputeExecuteRejectsStaleFingerprint(t *testing.T) {
db := testDB(t)
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{OrderCode: "ORD-STALE", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
preview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if preview.PDDToExcluded != 1 {
t.Fatalf("unexpected preview: %+v", preview)
}
// Data changes after the preview: a second row appears that would also
// flip, so the plan's fingerprint is now stale.
row2 := models.SYBProduct{OrderCode: "ORD-STALE-2", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-2"}`}
if err := db.Create(&row2).Error; err != nil {
t.Fatal(err)
}
_, err = s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint)
if err == nil {
t.Fatalf("expected stale fingerprint to be rejected")
}
se, ok := err.(*ServiceError)
if !ok || se.Code != CodeRecomputeStale {
t.Fatalf("expected CodeRecomputeStale, got %v", err)
}
var reloaded, reloaded2 models.SYBProduct
db.First(&reloaded, row.ID)
db.First(&reloaded2, row2.ID)
if reloaded.PDDExcluded || reloaded2.PDDExcluded {
t.Fatalf("stale execute must write nothing: %+v %+v", reloaded, reloaded2)
}
var logCount int64
db.Model(&models.SYBProductFilterRecomputeLog{}).Count(&logCount)
if logCount != 0 {
t.Fatalf("stale execute must not write an audit log, got %d", logCount)
}
freshPreview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if freshPreview.PDDToExcluded != 2 {
t.Fatalf("expected fresh preview to see both rows, got %+v", freshPreview)
}
if _, err := s.RecomputeExecute(context.Background(), "admin1", freshPreview.Fingerprint); err != nil {
t.Fatalf("fresh fingerprint should be accepted: %v", err)
}
}
func TestRecomputeExecuteRejectsEmptyFingerprint(t *testing.T) {
db := testDB(t)
s := NewService(db)
if _, err := s.RecomputeExecute(context.Background(), "admin1", ""); err == nil {
t.Fatalf("expected empty fingerprint to be rejected")
}
}
// TestWriteRecomputeChangesSkipsRowThatGotAPurchaseTaskConcurrently is #340
// phase 3 review item 1(a): a purchase task created for a planned row AFTER
// planning but BEFORE the write phase must cause that row to be skipped
// (counted as SkippedHasTask), not written.
func TestWriteRecomputeChangesSkipsRowThatGotAPurchaseTaskConcurrently(t *testing.T) {
db := testDB(t)
row := models.SYBProduct{OrderCode: "ORD-RACE-TASK", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
ruleID := uint64(9)
planned := []recomputeChange{{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"}}
if err := db.Create(&models.PurchaseTask{SYBProductID: &row.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "race-task", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil {
t.Fatal(err)
}
var actual RecomputeCounts
err := db.Transaction(func(tx *gorm.DB) error {
var writeErr error
actual, writeErr = writeRecomputeChanges(context.Background(), tx, planned)
return writeErr
})
if err != nil {
t.Fatal(err)
}
if actual.SkippedHasTask != 1 || actual.PDDToExcluded != 0 {
t.Fatalf("expected the row to be skipped for having a task, got %+v", actual)
}
var reloaded models.SYBProduct
db.First(&reloaded, row.ID)
if reloaded.PDDExcluded {
t.Fatalf("row must not have been marked excluded: %+v", reloaded)
}
}
// TestWriteRecomputeChangesSkipsRowWithConcurrentReturnMatch is item 1(b):
// same race, but with an active return match instead of a purchase task.
func TestWriteRecomputeChangesSkipsRowWithConcurrentReturnMatch(t *testing.T) {
db := testDB(t)
row := models.SYBProduct{OrderCode: "ORD-RACE-MATCH", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
ruleID := uint64(9)
planned := []recomputeChange{{id: row.ID, orderCode: row.OrderCode, shopeeItemID: row.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"}}
if err := db.Create(&models.ReturnMatch{SYBProductID: row.ID, YeekeReturnItemID: 1, ActiveSYBProductID: &row.ID, Status: models.ReturnMatchStatusMatched}).Error; err != nil {
t.Fatal(err)
}
var actual RecomputeCounts
err := db.Transaction(func(tx *gorm.DB) error {
var writeErr error
actual, writeErr = writeRecomputeChanges(context.Background(), tx, planned)
return writeErr
})
if err != nil {
t.Fatal(err)
}
if actual.SkippedReturnMatch != 1 || actual.PDDToExcluded != 0 {
t.Fatalf("expected the row to be skipped for an active return match, got %+v", actual)
}
}
// TestWriteRecomputeChangesReturnsCountsMatchingActualWrites is item 1(c):
// given a plan with one row that should write cleanly and one that races
// with a concurrently-created purchase task, writeRecomputeChanges' returned
// counts must equal exactly what it actually wrote — a real update for the
// clean row, and a skip (not a write) for the raced one. This is the same
// property RecomputeExecute relies on for its response and audit log
// (verified end-to-end, without a race, by TestRecomputePreviewMatchesExecute
// above; a race inside one execute call always changes the plan itself, so
// it surfaces as a fingerprint mismatch — see
// TestRecomputeExecuteRejectsStaleFingerprint — rather than reaching this
// path in a real request).
func TestWriteRecomputeChangesReturnsCountsMatchingActualWrites(t *testing.T) {
db := testDB(t)
rowA := models.SYBProduct{OrderCode: "ORD-ACTUAL-A", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
rowB := models.SYBProduct{OrderCode: "ORD-ACTUAL-B", DetailID: 2, StockID: 2, ShopeeItemID: "2", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-2"}`}
if err := db.Create(&rowA).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&rowB).Error; err != nil {
t.Fatal(err)
}
ruleID := uint64(9)
planned := []recomputeChange{
{id: rowA.ID, orderCode: rowA.OrderCode, shopeeItemID: rowA.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"},
{id: rowB.ID, orderCode: rowB.OrderCode, shopeeItemID: rowB.ShopeeItemID, toExcluded: true, ruleID: &ruleID, ruleKind: "keyword", ruleKeyword: "档口"},
}
// Simulate a purchase task created for rowB strictly between planning
// and this write phase call.
if err := db.Create(&models.PurchaseTask{SYBProductID: &rowB.ID, PDDProductID: 1, Quantity: 1, CreateRequestID: "actual-task", Status: models.PurchaseTaskStatusFailed, ExecutionMode: models.PurchaseExecutionModeLive, TaskType: models.PurchaseTaskTypeSYBOrder, RuleSnapshot: "{}"}).Error; err != nil {
t.Fatal(err)
}
var actual RecomputeCounts
err := db.Transaction(func(tx *gorm.DB) error {
var writeErr error
actual, writeErr = writeRecomputeChanges(context.Background(), tx, planned)
return writeErr
})
if err != nil {
t.Fatal(err)
}
if actual.PDDToExcluded != 1 || actual.SkippedHasTask != 1 {
t.Fatalf("expected one written and one skipped, got %+v", actual)
}
var reloadedA, reloadedB models.SYBProduct
db.First(&reloadedA, rowA.ID)
db.First(&reloadedB, rowB.ID)
if !reloadedA.PDDExcluded {
t.Fatalf("rowA should have been marked excluded")
}
if reloadedB.PDDExcluded {
t.Fatalf("rowB must not have been marked excluded (has a task)")
}
}
func TestMarkedCountsByRule(t *testing.T) {
db := testDB(t)
ruleA := models.SYBProductFilter{Kind: "keyword", Keyword: "档口A", NormalizedKeyword: "档口a", Enabled: true}
ruleB := models.SYBProductFilter{Kind: "keyword", Keyword: "档口B", NormalizedKeyword: "档口b", Enabled: true}
if err := db.Create(&ruleA).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&ruleB).Error; err != nil {
t.Fatal(err)
}
ruleAID, ruleBID := ruleA.ID, ruleB.ID
for i := 0; i < 3; i++ {
row := models.SYBProduct{OrderCode: fmt.Sprintf("ORD-MARKED-A-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1), ShopeeItemID: fmt.Sprintf("a%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}", PDDExcluded: true, ExcludedRuleID: &ruleAID}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
row := models.SYBProduct{OrderCode: "ORD-MARKED-B-0", DetailID: 100, StockID: 100, ShopeeItemID: "b0", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}", PDDExcluded: true, ExcludedRuleID: &ruleBID}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
unmarked := models.SYBProduct{OrderCode: "ORD-MARKED-NONE", DetailID: 200, StockID: 200, ShopeeItemID: "none", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: "{}"}
if err := db.Create(&unmarked).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
resp, err := s.List(context.Background(), ListRequest{Page: 1, PageSize: 50})
if err != nil {
t.Fatal(err)
}
byID := make(map[uint64]int64, len(resp.Items))
for _, item := range resp.Items {
byID[item.ID] = item.MarkedCount
}
if byID[ruleAID] != 3 {
t.Fatalf("expected rule A marked count 3, got %d", byID[ruleAID])
}
if byID[ruleBID] != 1 {
t.Fatalf("expected rule B marked count 1, got %d", byID[ruleBID])
}
}
// TestRecomputeFingerprintChangesWhenRuleEvidenceChanges is #340 phase 4
// review item 1: the fingerprint must depend on the rule's kind/keyword, not
// just its id — because those are exactly what RecomputeExecute is about to
// write into excluded_rule_kind/excluded_rule_keyword. Same rule id, same
// affected product, same direction, but the rule's own keyword changed
// between preview and execute (simulated by editing the row directly since
// the API has no edit endpoint) must be rejected as stale.
func TestRecomputeFingerprintChangesWhenRuleEvidenceChanges(t *testing.T) {
db := testDB(t)
rule := models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{OrderCode: "ORD-RULE-EDIT", DetailID: 1, StockID: 1, ShopeeItemID: "1", Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-1"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
s := NewService(db)
preview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if preview.PDDToExcluded != 1 || len(preview.Samples) != 1 || preview.Samples[0].RuleKeyword != "档口" {
t.Fatalf("unexpected preview: %+v", preview)
}
// The rule's own keyword and normalized_keyword change (same id, same
// kind, still matches the same variationSku prefix) — the plan's set of
// affected ids/directions is unchanged, but the evidence that would be
// written is not.
if err := db.Model(&models.SYBProductFilter{}).Where("id = ?", rule.ID).
Updates(map[string]any{"keyword": "档口新", "normalized_keyword": "档口"}).Error; err != nil {
t.Fatal(err)
}
_, err = s.RecomputeExecute(context.Background(), "admin1", preview.Fingerprint)
if err == nil {
t.Fatalf("expected the changed rule evidence to be rejected as stale")
}
se, ok := err.(*ServiceError)
if !ok || se.Code != CodeRecomputeStale {
t.Fatalf("expected CodeRecomputeStale, got %v", err)
}
var reloaded models.SYBProduct
db.First(&reloaded, row.ID)
if reloaded.PDDExcluded {
t.Fatalf("nothing should have been written: %+v", reloaded)
}
var logCount int64
db.Model(&models.SYBProductFilterRecomputeLog{}).Count(&logCount)
if logCount != 0 {
t.Fatalf("no audit log row should have been written, got %d", logCount)
}
// A fresh preview reflects the new keyword and executes normally.
freshPreview, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if freshPreview.Samples[0].RuleKeyword != "档口新" {
t.Fatalf("expected fresh preview to see the new keyword, got %+v", freshPreview.Samples)
}
if _, err := s.RecomputeExecute(context.Background(), "admin1", freshPreview.Fingerprint); err != nil {
t.Fatalf("fresh fingerprint should be accepted: %v", err)
}
db.First(&reloaded, row.ID)
if !reloaded.PDDExcluded || reloaded.ExcludedRuleKeyword != "档口新" {
t.Fatalf("expected the row to be excluded with the new keyword snapshot: %+v", reloaded)
}
}
// TestRecomputeFingerprintStableAcrossUnchangedPreviews is the companion
// regression: an unchanged dataset must give the SAME fingerprint on two
// consecutive previews (map/slice iteration order must never leak into the
// hash), and that fingerprint must still execute successfully.
func TestRecomputeFingerprintStableAcrossUnchangedPreviews(t *testing.T) {
db := testDB(t)
if err := db.Create(&models.SYBProductFilter{Kind: "keyword", Keyword: "档口", NormalizedKeyword: "档口", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 5; i++ {
row := models.SYBProduct{OrderCode: fmt.Sprintf("ORD-STABLE-%d", i), DetailID: uint64(i + 1), StockID: uint64(i + 1), ShopeeItemID: fmt.Sprintf("s%d", i), Quantity: 1, ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"variationSku":"档口-x"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
s := NewService(db)
first, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
second, err := s.RecomputePreview(context.Background())
if err != nil {
t.Fatal(err)
}
if first.Fingerprint == "" || first.Fingerprint != second.Fingerprint {
t.Fatalf("expected a stable, non-empty fingerprint across two previews of the same data: %q vs %q", first.Fingerprint, second.Fingerprint)
}
if _, err := s.RecomputeExecute(context.Background(), "admin1", second.Fingerprint); err != nil {
t.Fatalf("unchanged-data fingerprint should execute successfully: %v", err)
}
}
@@ -12,4 +12,6 @@ func InitRouter(e *gin.Engine, a *jwt.GinJWTMiddleware) {
g.POST("", middleware.RequireRoleKey("admin"), Handler{}.Create)
g.PATCH("/:filterId/enabled", middleware.RequireRoleKey("admin"), Handler{}.SetEnabled)
g.DELETE("/:filterId", middleware.RequireRoleKey("admin"), Handler{}.Delete)
g.GET("/recompute/preview", middleware.RequireRoleKey("admin"), Handler{}.RecomputePreview)
g.POST("/recompute/execute", middleware.RequireRoleKey("admin"), Handler{}.RecomputeExecute)
}
+53 -7
View File
@@ -30,6 +30,10 @@ const (
CodeNotFound = "SYB_PRODUCT_FILTER_NOT_FOUND"
CodeConflict = "SYB_PRODUCT_FILTER_DUPLICATE"
CodeForbidden = "FORBIDDEN"
// CodeRecomputeStale is returned by RecomputeExecute when the plan its
// fingerprint describes no longer matches the current data/rules (#340
// phase 3 review item 2): the caller must re-preview before executing.
CodeRecomputeStale = "RECOMPUTE_PREVIEW_STALE"
)
func invalid(m string) error { return &ServiceError{Code: CodeInvalidRequest, Message: m} }
@@ -46,12 +50,24 @@ type ListRequest struct {
Kind, Keyword string
EnabledOnly bool
}
// FilterItem adds MarkedCount to the stored rule: the REAL, live count of
// syb_product rows currently marked pdd_purchase_excluded by this rule
// (#340 phase 3 review item 3), as opposed to LastHitCount which is a
// point-in-time snapshot written back by the most recent sync run and never
// updated by a recompute. It is deliberately not a model field — it is
// derived, never stored.
type FilterItem struct {
models.SYBProductFilter
MarkedCount int64 `json:"markedCount"`
}
type ListResponse struct {
Items []models.SYBProductFilter `json:"items"`
Total int64 `json:"total"`
EnabledCount int64 `json:"enabledCount"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Items []FilterItem `json:"items"`
Total int64 `json:"total"`
EnabledCount int64 `json:"enabledCount"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
func (s *Service) List(ctx context.Context, r ListRequest) (ListResponse, error) {
@@ -75,16 +91,46 @@ func (s *Service) List(ctx context.Context, r ListRequest) (ListResponse, error)
if err := q.Count(&total).Error; err != nil {
return ListResponse{}, internal(err)
}
items := make([]models.SYBProductFilter, 0, r.PageSize)
if err := q.Order("kind ASC, id ASC").Offset((r.Page - 1) * r.PageSize).Limit(r.PageSize).Find(&items).Error; err != nil {
rows := make([]models.SYBProductFilter, 0, r.PageSize)
if err := q.Order("kind ASC, id ASC").Offset((r.Page - 1) * r.PageSize).Limit(r.PageSize).Find(&rows).Error; err != nil {
return ListResponse{}, internal(err)
}
if err := s.DB.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("enabled = ?", true).Count(&enabled).Error; err != nil {
return ListResponse{}, internal(err)
}
marked, err := s.markedCountsByRule(ctx)
if err != nil {
return ListResponse{}, internal(err)
}
items := make([]FilterItem, 0, len(rows))
for _, row := range rows {
items = append(items, FilterItem{SYBProductFilter: row, MarkedCount: marked[row.ID]})
}
return ListResponse{Items: items, Total: total, EnabledCount: enabled, Page: r.Page, PageSize: r.PageSize}, nil
}
// markedCountsByRule is the single grouped query (no N+1) behind MarkedCount:
// COUNT(*) FROM syb_product WHERE pdd_purchase_excluded = 1, grouped by
// excluded_rule_id, covering every rule at once regardless of page size.
func (s *Service) markedCountsByRule(ctx context.Context) (map[uint64]int64, error) {
type row struct {
ExcludedRuleID uint64
Count int64
}
var rows []row
if err := s.DB.WithContext(ctx).Model(&models.SYBProduct{}).
Select("excluded_rule_id, COUNT(*) AS count").
Where("pdd_purchase_excluded = ? AND excluded_rule_id IS NOT NULL", true).
Group("excluded_rule_id").Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint64]int64, len(rows))
for _, r := range rows {
result[r.ExcludedRuleID] = r.Count
}
return result, nil
}
type CreateRequest struct {
Kind string `json:"kind"`
Keyword string `json:"keyword"`
+5
View File
@@ -158,6 +158,11 @@ func (service *Service) BatchCreateImageSearch(ctx context.Context, request Imag
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "SYB_PRODUCT_UNAVAILABLE", Message: "SYB 商品不存在或未关联蝦皮商品"})
continue
}
// #340: hard-exclude before grouping/creating any image search task.
if row.PDDExcluded {
response.Items = append(response.Items, ImageSearchBatchItem{SYBProductIDs: []uint64{id}, Code: "PURCHASE_PDD_EXCLUDED", Message: "该商品已标记为无需 PDD 采购"})
continue
}
if index, ok := groups[*row.ShopeeProductID]; ok {
response.Items[index].SYBProductIDs = append(response.Items[index].SYBProductIDs, id)
} else {
@@ -0,0 +1,44 @@
package task
import (
"context"
"testing"
"go-admin/app/goauto/models"
"github.com/google/uuid"
)
// #340: image search collection is a PDD entry point keyed by sybProductIds;
// an excluded row must be rejected without touching the batch's other rows.
func TestBatchCreateImageSearchRejectsExcludedRow(t *testing.T) {
db := openTaskDatabase(t)
rule := models.CollectionRule{Name: "image-search-excluded", ContentJSON: v2TaskRuleSnapshot()}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
shopee := models.ShopeeProduct{ShopeeItemID: "excl-1", Title: "t", Currency: "CNY", SpecsJSON: "[]"}
if err := db.Create(&shopee).Error; err != nil {
t.Fatal(err)
}
syb := models.SYBProduct{OrderCode: "ORD-EXCL", DetailID: 1, StockID: 1, ShopeeItemID: shopee.ShopeeItemID, ShopeeProductID: &shopee.ID, Quantity: 1, UnitPriceCent: 100, ImageURL: "https://example.invalid/excl.jpg", ParseStatus: "success", RawJSON: "{}", PDDExcluded: true}
if err := db.Create(&syb).Error; err != nil {
t.Fatal(err)
}
service := NewService(db)
service.FetchImageSearchImage = func(ctx context.Context, url string) (ImageSearchImage, error) {
t.Fatalf("must not fetch image for an excluded row")
return ImageSearchImage{}, nil
}
request := ImageSearchBatchRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{syb.ID}, RuleID: rule.ID}
response, err := service.BatchCreateImageSearch(context.Background(), request)
if err != nil {
t.Fatalf("batch call itself must not fail: %v", err)
}
if response.SuccessCount != 0 || len(response.Items) != 1 {
t.Fatalf("expected the excluded row to fail, got %+v", response)
}
if response.Items[0].Code != "PURCHASE_PDD_EXCLUDED" {
t.Fatalf("expected PURCHASE_PDD_EXCLUDED, got %+v", response.Items[0])
}
}
+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)
}
+112
View File
@@ -0,0 +1,112 @@
package yeeke
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/yeekeclient"
)
func TestSyncBothStatusesPreservesIdentityAndAvailability(t *testing.T) {
db := testDB(t)
phase := 0
var calls []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Status string `json:"status"`
Page int `json:"pageNo"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Error(err)
return
}
calls = append(calls, fmt.Sprintf("%s/%d", req.Status, req.Page))
w.Header().Set("Content-Type", "application/json")
if phase == 0 && req.Status == "2" {
fmt.Fprint(w, page(nil, 0, 0))
return
}
if req.Status == "1" {
if req.Page == 1 {
fmt.Fprint(w, page([]string{record("p1", "i1", "v1", 1)}, 2, 2))
} else {
fmt.Fprint(w, page([]string{record("p2", "i2", "v2", 1)}, 2, 2))
}
} else {
fmt.Fprint(w, page([]string{record("p1", "i1", "v1", 2)}, 1, 1))
}
}))
defer srv.Close()
c, _ := yeekeclient.New(srv.URL)
s := NewService(db, c, Config{PageSize: 1})
if _, err := s.Sync(context.Background(), "manual"); err != nil {
t.Fatal(err)
}
var original models.YeekeReturnPackage
db.Where("external_id = ?", "p1").First(&original)
phase = 1
calls = nil
for run := 0; run < 2; run++ {
rep, err := s.Sync(context.Background(), "manual")
if err != nil || rep.Status != "succeeded" || rep.MissingMarked != 0 {
t.Fatalf("rep=%+v err=%v", rep, err)
}
}
if fmt.Sprint(calls) != "[1/1 1/2 2/1 1/1 1/2 2/1]" {
t.Fatalf("independent pagination: %v", calls)
}
var current models.YeekeReturnPackage
db.First(&current, original.ID)
if current.ClaimStatus != "2" || current.StatusUnrecognized || current.SyncStatus != "ok" {
t.Fatalf("current=%+v", current)
}
var count int64
db.Model(&models.YeekeReturnPackage{}).Count(&count)
if count != 2 {
t.Fatalf("packages=%d", count)
}
db.Model(&models.YeekeReturnItem{}).Count(&count)
if count != 2 {
t.Fatalf("items=%d", count)
}
}
func TestReshipPageFailureDoesNotMarkMissing(t *testing.T) {
db := testDB(t)
old := models.YeekeReturnPackage{ExternalID: "old", ClaimStatus: "2", SyncStatus: "ok"}
if err := db.Create(&old).Error; err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Status string `json:"status"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.Status == "2" {
w.WriteHeader(http.StatusBadGateway)
return
}
fmt.Fprint(w, page([]string{record("new", "i", "v", 1)}, 1, 1))
}))
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 || rep.Status != "failed" || rep.Created != 1 || rep.MissingMarked != 0 {
t.Fatalf("rep=%+v err=%v", rep, err)
}
db.First(&old, old.ID)
if old.SyncStatus != "ok" {
t.Fatal("incomplete combined sync marked reshipped package missing")
}
var run models.YeekeSyncRun
db.First(&run, rep.RunID)
if run.ErrorMessage == "" {
t.Fatal("missing state/page failure diagnostic")
}
}
+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
}
+452
View File
@@ -0,0 +1,452 @@
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: waiting to ship (1) and reshipped (2), confirmed by HAR.
var knownClaimStatuses = map[string]bool{"1": true, "2": 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)
}()
var firstWriteErr error
seenPackages := map[string]string{}
// 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 := true
for _, status := range []string{"1", "2"} {
seen := map[string]bool{}
statusComplete := false
for page := 1; page <= s.cfg.MaxPages; page++ {
var p yeekeclient.ReturnPage
var e error
for a := 0; ; a++ {
p, e = s.client.ListStatus(ctx, page, s.cfg.PageSize, status)
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(fmt.Sprintf("状态 %s 第 %d 页拉取失败:%v", status, page, e))
return rep, runErr
}
rep.TotalPages++
if len(p.Records) == 0 {
statusComplete = true
break
}
finger := pageFingerprint(p)
if seen[finger] {
rep.Skipped += len(p.Records)
break
}
seen[finger] = true
for _, x := range p.Records {
key, currentStatus := packageKey(x), external(x.Status)
if previous, ok := seenPackages[key]; ok && (previous == "2" || previous == currentStatus) {
rep.Skipped++
continue
}
created, updated, recovered, err := s.upsert(ctx, x)
seenPackages[key] = currentStatus
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 {
statusComplete = true
break
}
if p.Pages > 0 && page >= p.Pages {
statusComplete = true
break
}
}
if !statusComplete {
complete = false
errMsg = fmt.Sprintf("状态 %s 分页未完整结束(重复页或达到页数上限),未执行缺失标记", status)
break
}
}
if !complete {
runErr = errors.New(errMsg)
return rep, runErr
}
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))
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, runErr
}
// 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,400 @@
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 || rep.Status != "failed" || rep.MissingMarked != 0 {
t.Fatalf("rep=%+v err=%v (duplicate-fingerprint stop is not complete)", rep, err)
}
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 || rep.Status != "failed" || rep.MissingMarked != 0 {
t.Fatalf("rep=%+v err=%v (MaxPages exhaustion is not complete)", rep, err)
}
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)
}
}
+776
View File
@@ -0,0 +1,776 @@
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 || rep.Status != "failed" {
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 != 2 || 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)
}
}
+393
View File
@@ -0,0 +1,393 @@
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) {
return c.ListStatus(ctx, pageNo, pageSize, "1")
}
// ListStatus reads only the two HAR-confirmed return statuses.
func (c *Client) ListStatus(ctx context.Context, pageNo, pageSize int, status string) (ReturnPage, error) {
if status != "1" && status != "2" {
return ReturnPage{}, fmt.Errorf("unsupported yeeke return status")
}
// 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": status, "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)
}
}
@@ -0,0 +1,36 @@
package yeekeclient
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestListStatusUsesConfirmedHARFilters(t *testing.T) {
var statuses []string
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
if r.Method != http.MethodPost || r.URL.Path != "/agent-foreign/packageClaimRec/relation/list" || body["claimFlag"] != "1" || body["relationFlag"] != "1" || body["column"] != "createTime" || body["order"] != "desc" {
t.Error("HAR filters changed")
}
statuses = append(statuses, body["status"].(string))
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":true,"result":{"records":[],"pages":0,"total":0}}`))
}))
defer s.Close()
c, _ := New(s.URL)
for _, status := range []string{"1", "2"} {
if _, err := c.ListStatus(context.Background(), 1, 20, status); err != nil {
t.Fatal(err)
}
}
if _, err := c.ListStatus(context.Background(), 1, 20, "3"); err == nil {
t.Fatal("unsupported status allowed")
}
if len(statuses) != 2 || statuses[0] != "1" || statuses[1] != "2" {
t.Fatal(statuses)
}
}
+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{},
// ...
}
}
+3
View File
@@ -117,6 +117,9 @@ func run() error {
if err := goautosybinnercode.RecoverInterrupted(db); err != nil {
return fmt.Errorf("recover interrupted SYB inner-code writes: %w", err)
}
if err := goautosybinnercode.RecoverInterruptedMatches(db); err != nil {
return fmt.Errorf("recover interrupted SYB inner-code matches: %w", err)
}
goautoreplacement.RecoverMatching(db)
goautopurchase.RecoverPurchaseMatching(db)
goautopurchase.RecoverOrderWritebacks(db)

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