diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLintJobService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLintJobService.java
index fac8eb48..7cf2b3a2 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLintJobService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiLintJobService.java
@@ -269,15 +269,24 @@ public class WikiLintJobService {
* broken_links_scanned_at. Kept in this service so the scan loop above
* is unambiguously per-page transactional without polluting the larger
* WikiPageService API.
+ *
+ * Uses {@link com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper}
+ * with explicit {@code set()} calls instead of {@code updateById(partial entity)}.
+ * Several columns on {@link WikiPageEntity} (content, summary,
+ * outgoing_links, broken_links) carry {@code FieldStrategy.ALWAYS} so an
+ * entity-style update with those fields left null would generate
+ * {@code SET content = NULL, summary = NULL} and wipe the page body.
+ * The wrapper-based update only emits SET clauses for the three columns
+ * we mean to touch.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void rewriteBrokenLinks(Long pageId, String outgoingLinksJson, String brokenLinksJson) {
- WikiPageEntity update = new WikiPageEntity();
- update.setId(pageId);
- update.setOutgoingLinks(outgoingLinksJson);
- update.setBrokenLinks(brokenLinksJson);
- update.setBrokenLinksScannedAt(LocalDateTime.now());
- pageMapper.updateById(update);
+ pageMapper.update(null,
+ new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper()
+ .eq(WikiPageEntity::getId, pageId)
+ .set(WikiPageEntity::getOutgoingLinks, outgoingLinksJson)
+ .set(WikiPageEntity::getBrokenLinks, brokenLinksJson)
+ .set(WikiPageEntity::getBrokenLinksScannedAt, LocalDateTime.now()));
}
private void updateJob(Long kbId, String jobId, java.util.function.Function fn) {
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java
index 231d60ec..80e946e1 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java
@@ -679,13 +679,19 @@ public class WikiPageService {
}
WikiLinkService.LinkAnalysis a = linkService.analyze(rewritten, activeForThisReferrer);
- WikiPageEntity update = new WikiPageEntity();
- update.setId(referrer.getId());
- update.setContent(rewritten);
- update.setOutgoingLinks(linkService.toJsonArray(a.outgoingLinks()));
- update.setBrokenLinks(linkService.toJsonArray(a.brokenLinks()));
- update.setBrokenLinksScannedAt(LocalDateTime.now());
- pageMapper.updateById(update);
+ // LambdaUpdateWrapper — content, summary, outgoing_links and
+ // broken_links all carry FieldStrategy.ALWAYS on WikiPageEntity,
+ // so a partial-entity updateById would generate SET summary=NULL
+ // (and clear any other ALWAYS column we didn't explicitly set).
+ // The wrapper-based update only writes the four columns we mean
+ // to touch, leaving summary and the rest intact.
+ pageMapper.update(null,
+ new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper()
+ .eq(WikiPageEntity::getId, referrer.getId())
+ .set(WikiPageEntity::getContent, rewritten)
+ .set(WikiPageEntity::getOutgoingLinks, linkService.toJsonArray(a.outgoingLinks()))
+ .set(WikiPageEntity::getBrokenLinks, linkService.toJsonArray(a.brokenLinks()))
+ .set(WikiPageEntity::getBrokenLinksScannedAt, LocalDateTime.now()));
affected.add(referrer.getId());
}
return affected;
@@ -804,13 +810,16 @@ public class WikiPageService {
}
WikiLinkService.LinkAnalysis a = linkService.analyze(rewritten, activeForThisReferrer);
- WikiPageEntity update = new WikiPageEntity();
- update.setId(referrer.getId());
- update.setContent(rewritten);
- update.setOutgoingLinks(linkService.toJsonArray(a.outgoingLinks()));
- update.setBrokenLinks(linkService.toJsonArray(a.brokenLinks()));
- update.setBrokenLinksScannedAt(LocalDateTime.now());
- pageMapper.updateById(update);
+ // LambdaUpdateWrapper to avoid the FieldStrategy.ALWAYS-induced
+ // null overwrite on summary (and other ALWAYS columns we don't
+ // touch in a rename).
+ pageMapper.update(null,
+ new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper()
+ .eq(WikiPageEntity::getId, referrer.getId())
+ .set(WikiPageEntity::getContent, rewritten)
+ .set(WikiPageEntity::getOutgoingLinks, linkService.toJsonArray(a.outgoingLinks()))
+ .set(WikiPageEntity::getBrokenLinks, linkService.toJsonArray(a.brokenLinks()))
+ .set(WikiPageEntity::getBrokenLinksScannedAt, LocalDateTime.now()));
affected.add(referrer.getId());
}
return affected;
diff --git a/mateclaw-server/src/test/resources/e2e/wiki-link-overhaul-verification.md b/mateclaw-server/src/test/resources/e2e/wiki-link-overhaul-verification.md
index 7250c662..92b3b547 100644
--- a/mateclaw-server/src/test/resources/e2e/wiki-link-overhaul-verification.md
+++ b/mateclaw-server/src/test/resources/e2e/wiki-link-overhaul-verification.md
@@ -400,3 +400,78 @@ End-to-end "user reports broken link → lint reveals all → delete or
rename a page → cascade clears the dangling tokens" flow is reproducible
on a clean dev box in under three minutes (KB create + ingest + verify).
+---
+
+## 8. Second pass — multi-referrer / code-block / round-trip (2026-05-28)
+
+Extended e2e with deeper scenarios. **Caught and fixed one data-loss bug**
+before publishing the pass report.
+
+### 8.0 Setup
+
+Fresh KB `E2E-RFC55-StressKB`. Ingested a 5-entity team handbook (Alice
+Chen, Bob Patel, Carol Liu, Crawler subsystem, Indexing project) and
+manually edited `overview` to fan in references to all five plus a
+fenced-code block + inline-code block both containing literal
+`[[alice-chen]]` examples.
+
+### 8.1 Scenarios run
+
+| Scenario | What it covers | Result |
+|---|---|---|
+| **B. Multi-referrer cascade delete** | Delete `carol-liu` with 2 referrers (`indexing-project` + `overview`); only non-empty content gets rewritten | ✅ overview's `[[carol-liu]]` (1×) demoted to plain "Carol Liu"; outgoing updated; audit `affectedPageIds:[overview_id]` |
+| **C. Code-block protection during cascade** | Delete `alice-chen`; overview has `[[alice-chen]]` 2× in prose AND 2× in fenced/inline-code blocks | ✅ Prose `[[alice-chen]]` and `[[alice-chen\|Alice]]` demoted to `Alice Chen` / `Alice`; **code block byte-for-byte preserved**: ```` ```markdown\nUse [[alice-chen]] or [[alice-chen\|some alias]] to link to a teammate.\n``` ```` |
+| **D. Multi-referrer cascade rename + alias preservation** | Rename `bob-patel` → `robert-patel` with 2 referrers (overview has `[[bob-patel\|Bob the pair-programmer]]`, log has `[[bob-patel]]` + `[[bob-patel\|Bob]]`) | ✅ All 3 occurrences across both pages rewrite to `robert-patel`, aliases preserved; old slug → 404; audit `affectedPageIds=[overview_id, log_id]` |
+| **E. Break-then-fix round trip** | PUT log with `[[nonexistent-1]] [[also-fake]] [[robert-patel]]` → scan → fix via PUT with valid slugs only → re-scan | ✅ Break: `broken_links=["nonexistent-1","also-fake"]` synchronously, scan aggregate shows 2 refs across 1 page. Fix: `broken_links=[]` synchronously, scan aggregate clean |
+| **F. Archive + scan interaction** | Archive `crawler-subsystem`; refs index excludes it by default, includes with `?includeArchived=true` (with `archived:true`) | ✅ Default refs hides; `?includeArchived=true` returns it with the flag |
+
+### 8.2 Bug found and fixed mid-run
+
+While re-reading the multi-referrer cascade output, noticed that
+`indexing-project` had `content_len=0` even though it had been a referrer
+to `carol-liu`. Tracing down: every page in the KB had `content` and
+`summary` set to `NULL` after any of the following ran:
+
+1. `WikiLintJobService.rewriteBrokenLinks` — runs on every KB-wide scan
+2. `WikiPageService.cascadeStripReferrers` — runs on every cascade delete
+3. `WikiPageService.cascadeRenameReferrers` — runs on every cascade rename
+
+All three built a partial `WikiPageEntity` setting only the fields they
+intended to update (`id` + `outgoing_links` + `broken_links` + `broken_links_scanned_at`),
+then called `pageMapper.updateById(partialEntity)`. But `WikiPageEntity`
+declares `FieldStrategy.ALWAYS` on `content`, `summary`, `outgoingLinks`,
+and `brokenLinks`, so MyBatis-Plus generated `UPDATE ... SET content =
+NULL, summary = NULL, ...` — silently destroying the body of every page
+the cascade or scan touched.
+
+**Fix**: replace `updateById(partialEntity)` with
+`update(null, new LambdaUpdateWrapper().eq(...).set(col, val))`
+in all three sites. The wrapper-based path emits SET clauses only for
+explicit `.set()` calls, so unmentioned columns are untouched regardless
+of their `FieldStrategy`.
+
+### 8.3 Post-fix verification
+
+After restart with the fixed jar:
+
+- Scan x 3 on a page with 113-char content + summary → both **unchanged**
+ (length stable at 113, summary string identical).
+- Cascade delete of `alice` with `bob` as referrer → bob's content went
+ 200 → 192 chars (the `[[alice]]` → `Alice` rewrite, ~8-char shrink as
+ expected), summary fully preserved.
+- Cascade rename of `carol` → `caroline` with `dave` as referrer →
+ dave's summary preserved verbatim.
+
+The same `WikiEnrichmentApplierTest` + `WikiLinkServiceCascadeTest` +
+`WikiPageServiceTest` suites still pass; the bug was strictly in the
+write-back path that those tests didn't exercise (the cascade tests
+operate on pure-string helpers; the page-service test mocks the mapper
+so the actual SQL generated doesn't matter).
+
+### 8.4 Follow-up
+
+A regression-locking integration test (real Spring + H2) for "scan must
+not null content/summary" is worth adding in a separate PR — would have
+caught this class of bug at the boundary between MyBatis-Plus field
+strategy and partial-entity update calls. Tracked.
+