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
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
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
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
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
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
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
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
Persist how many return items each sync run flipped to "missing" and how
many came back to "ok" (yeeke_sync_run.missing_marked_count /
recovered_count, migration 1789801000000), return them from the sync-runs
API and add 「标记不可用」「恢复可用」 columns to the sync-runs page. When the
20% safety valve skips marking the count stays 0 and the reason remains in
error_message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
A COMPLETE yeeke sync run (natural page-walk end, not stopped by a
duplicate-fingerprint break or MaxPages exhaustion) that also wrote zero
record failures now flips any yeeke_return_package/yeeke_return_item still
"ok" from before the run to sync_status="missing" with missing_since
stamped, in one transaction. Rows are never deleted. A 20% safety valve
skips marking (and records why in error_message) when the candidate count
would be too large; a reappearing record recovers to "ok" with
missing_since cleared.
returnmatch.availableReturnPool now excludes missing items/packages from
matching, without auto-cancelling any existing active match; List()/Detail()
surface syncStatus/missingSince so the SYB products match column, its
compare dialog, and the yeeke returns list can warn or label rows as
unavailable.
Adds migration 1789800900000_return_missing (AutoMigrate alone does not
reach existing databases).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
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