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
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
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
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
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
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