Files
yeuimu aed9afef92 fix(api): add sync safety guards against degenerate SDS responses
Add SYNC_GUARDS thresholds so a partial/degenerate upstream response never
triggers a destructive operation:
- skip stale category deletion when the fetched tree is suspiciously small
  vs the existing SDS category count
- skip delist detection unless both leaf-category and seen-product counts
  are healthy

Verified: 77 tests pass; live SDS returns 226 categories (guard off),
incident-case ratios (2/226, 2/2) are correctly blocked.
2026-08-20 16:37:44 +08:00

87 lines
4.5 KiB
Markdown

# SDS Sync Guard Fix Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Prevent the SDS (mapi.sdspod.com) sync from deleting the category/product library when the upstream API returns a degenerate/partial response, and recover the data lost in the 2026-08-20 incident.
**Background / Root cause:**
- On 2026-08-20 06:31 a manual sync ran while `category/tree/3` returned only 2 nodes (normal: 226).
- `syncCategories` treats SDS as the single source of truth: `staleDeleted=225` deleted all previously synced SDS categories.
- With SDS categories gone, `syncProducts` had `leafCategories=0/2``delisted=523` marked 523/536 origin goods as delisted.
- Result: all 536 origin goods `delisted=true`; all 240 website `Good` rows reference delisted origin goods → `/public/goods` returns `total: 0` → product-center page appears empty.
**Architecture:** Add safety guards to `SyncService` and `SdsClientService` so a partial/failed upstream response never triggers destructive operations (`staleDeleted` / delist marking). Recovery is a data fix (re-enable delisted origin goods) followed by normal re-sync.
**Tech Stack:** NestJS, TypeScript, Prisma, Jest.
---
### Task 1: Data recovery — re-enable all origin goods
**Files:**
- Run ad-hoc prisma script (read+write to DB via apps/api @prisma/client)
- [ ] **Step 1: Reset `delisted=false` for all `originGood` rows**
All 536 rows were mis-flagged by the 08-20 partial sync. Run
`prisma.originGood.updateMany({ where: {}, data: { delisted: false } })`.
- [ ] **Step 2: Trigger category sync then product sync**
Verify `/sync/status` logs show SUCCESS with healthy numbers
(categories ≈ 225+, products ≈ 519+).
- [ ] **Step 3: Verify `/public/goods` non-empty and product-center page renders products.**
### Task 2: Hardening — category sync stale-deletion guard
**Files:**
- Modify: `apps/api/src/sync/sync.service.ts`
- Modify: `apps/api/src/sync/sync.service.spec.ts`
- [ ] **Step 1: Write failing test**
Given existing SDS-linked categories in DB, when `fetchCategoryTree` returns a
degenerate small tree (< MIN_CATEGORY_COUNT), `syncCategories` must NOT delete
existing categories (no stale deletion) and must log a warning.
- [ ] **Step 2: Run test, verify RED.**
- [ ] **Step 3: Implement guard**
Before the stale-deletion block, compute `existingSdsCount = categories with sdsCategoryId`.
Skip stale deletion when `flat.length < MIN_CATEGORY_COUNT (10)` OR
`flat.length < MIN_CATEGORY_RATIO (0.5) * existingSdsCount`. Log warning with counts.
- [ ] **Step 4: Verify GREEN + no regressions.**
### Task 3: Hardening — product sync delist guard
**Files:**
- Modify: `apps/api/src/sync/sync.service.ts`
- Modify: `apps/api/src/sync/sync.service.spec.ts`
- [ ] **Step 1: Write failing test**
When there are very few leaf categories (< MIN_LEAF_CATEGORIES) or very few seen
products (< MIN_SEEN_GOODS), `syncProducts` must skip the delist/reactivate marking
and log a warning instead of mass-delisting.
- [ ] **Step 2: Run test, verify RED.**
- [ ] **Step 3: Implement guard**
Only run delist detection when `leafRows.length >= MIN_LEAF_CATEGORIES (10)` AND
`seenSdsGoodIds.size >= MIN_SEEN_GOODS (50)`. Otherwise skip and log warning.
- [ ] **Step 4: Verify GREEN + no regressions.**
### Task 4: Hardening — SDS client response sanity validation
**Files:**
- Modify: `apps/api/src/sync/sds-client.service.ts`
- Modify: `apps/api/src/sync/sds-client.service.spec.ts` (create if absent)
- [ ] **Step 1: Write failing test**
`fetchCategoryTree` throws when the returned array is degenerate (e.g. fewer than
`MIN_SDS_CATEGORY_NODES`), so callers never operate on a bad tree.
- [ ] **Step 2: Run test, verify RED.**
- [ ] **Step 3: Implement**
If the fetched category array length < MIN_SDS_CATEGORY_NODES (10), throw an Error
with the received count so sync logs FAILED (non-destructive) instead of deleting data.
- [ ] **Step 4: Verify GREEN + no regressions.**
### Task 5: Full verification
- [ ] **Step 1:** Run `apps/api` full jest suite — all green.
- [ ] **Step 2:** Typecheck `apps/api` (`tsc -p tsconfig.json --noEmit` or nest build).
- [ ] **Step 3:** Trigger a real sync via the admin UI / HTTP; confirm sync logs show
healthy category/product counts and `/public/goods` returns data.
- [ ] **Step 4:** Confirm product-center page at `http://192.168.124.137:3000/product-center` renders products.