mateclaw/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java

680 lines
30 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.wiki.service;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.system.featureflag.FeatureFlagService;
import vip.mate.tool.builtin.DocumentExtractTool;
import vip.mate.tool.image.vision.ImageVisionService;
import vip.mate.tool.image.vision.VisionRequest;
import vip.mate.tool.image.vision.VisionResult;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.event.WikiProcessingEvent;
import vip.mate.wiki.model.WikiRawMaterialEntity;
import vip.mate.wiki.repository.WikiRawMaterialMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* Wiki 原始材料服务
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WikiRawMaterialService {
private static final String VISION_FLAG_KEY = "wiki.ocr.enabled";
private final WikiRawMaterialMapper rawMapper;
private final WikiKnowledgeBaseService kbService;
private final WikiProperties properties;
private final ApplicationEventPublisher eventPublisher;
private final DocumentExtractTool documentExtractTool;
/** Optional — wired by Spring; null in minimal test harnesses where the cascade path is exercised separately. */
@org.springframework.beans.factory.annotation.Autowired(required = false)
private WikiPageService pageService;
/** RFC-013删除时级联清理 chunk */
private final WikiChunkService chunkService;
private final ImageVisionService imageVisionService;
private final PdfImageExtractor pdfImageExtractor;
private final FeatureFlagService featureFlagService;
/**
* RFC-012 follow-up #3从 partial 状态触发的 reprocess 会在此 set 中打标,
* 供 {@link vip.mate.wiki.service.WikiProcessingService#processRawMaterial(Long, boolean)}
* 在 claim 之前消费,从而决定是否保留已生成的 exclusive page续传语义
* <p>
* 内存态server 重启会丢,但原 raw 的 status 已被 reprocess 改为 pending
* 重启后按正常 pending 流程跑(退化为「不删旧页的全量重跑」,功能不丢失只是
* 没有走 route 的 "update" 识别路径)。
*/
private final Set<Long> partialResumeIds = ConcurrentHashMap.newKeySet();
public List<WikiRawMaterialEntity> listByKbId(Long kbId) {
List<WikiRawMaterialEntity> list = rawMapper.selectList(
new LambdaQueryWrapper<WikiRawMaterialEntity>()
.eq(WikiRawMaterialEntity::getKbId, kbId)
.orderByDesc(WikiRawMaterialEntity::getCreateTime));
// 不返回大文本字段
list.forEach(r -> {
r.setOriginalContent(null);
r.setExtractedText(null);
});
return list;
}
public WikiRawMaterialEntity getById(Long id) {
return rawMapper.selectById(id);
}
public WikiRawMaterialEntity findBySourcePath(Long kbId, String sourcePath) {
return rawMapper.selectOne(
new LambdaQueryWrapper<WikiRawMaterialEntity>()
.eq(WikiRawMaterialEntity::getKbId, kbId)
.eq(WikiRawMaterialEntity::getSourcePath, sourcePath));
}
public List<WikiRawMaterialEntity> listPending(Long kbId) {
return rawMapper.selectList(
new LambdaQueryWrapper<WikiRawMaterialEntity>()
.eq(WikiRawMaterialEntity::getKbId, kbId)
.eq(WikiRawMaterialEntity::getProcessingStatus, "pending"));
}
/**
* 添加文本类型的原始材料
*/
@Transactional
public WikiRawMaterialEntity addText(Long kbId, String title, String content) {
String hash = computeHash(content);
// Dedup: reuse any existing row with the same hash in this KB (any status)
WikiRawMaterialEntity existing = rawMapper.selectOne(
new LambdaQueryWrapper<WikiRawMaterialEntity>()
.eq(WikiRawMaterialEntity::getKbId, kbId)
.eq(WikiRawMaterialEntity::getContentHash, hash)
.last("LIMIT 1"));
if (existing != null) {
return handleDuplicate(existing);
}
WikiRawMaterialEntity entity = new WikiRawMaterialEntity();
entity.setKbId(kbId);
entity.setTitle(title);
entity.setSourceType("text");
entity.setOriginalContent(content);
entity.setFileSize((long) content.getBytes(StandardCharsets.UTF_8).length);
entity.setContentHash(hash);
entity.setProcessingStatus("pending");
rawMapper.insert(entity);
kbService.incrementRawCount(kbId);
if (properties.isAutoProcessOnUpload()) {
eventPublisher.publishEvent(new WikiProcessingEvent(this, entity.getId(), kbId));
}
log.info("[Wiki] Raw material added: id={}, kbId={}, title={}", entity.getId(), kbId, title);
return entity;
}
/**
* Adds a file-type raw material (PDF / DOCX / image / ...).
*
* <p>Backwards-compatible overload that omits the MIME type. Callers
* with the upload Content-Type in hand should prefer the four-argument
* variant — image-routing in particular needs an authoritative MIME so
* downstream vision providers know what they are decoding.
*/
@Transactional
public WikiRawMaterialEntity addFile(Long kbId, String title, String sourceType,
String sourcePath, long fileSize) {
return addFile(kbId, title, sourceType, null, sourcePath, fileSize);
}
/**
* Adds a file-type raw material with explicit MIME type.
*
* @param mimeType Content-Type string from the upload (e.g. {@code image/png});
* may be null if unknown
*/
@Transactional
public WikiRawMaterialEntity addFile(Long kbId, String title, String sourceType,
String mimeType, String sourcePath, long fileSize) {
WikiRawMaterialEntity entity = new WikiRawMaterialEntity();
entity.setKbId(kbId);
entity.setTitle(title);
entity.setSourceType(sourceType);
entity.setMimeType(capMimeType(mimeType));
entity.setSourcePath(sourcePath);
entity.setFileSize(fileSize);
entity.setProcessingStatus("pending");
// Compute hash of original upload bytes (for dedup). RFC-051: hash raw bytes
// directly — the previous `new String(bytes, UTF_8)` round-trip produced unstable
// hashes for binary files (PDF/Office) because invalid UTF-8 sequences become
// replacement characters, collapsing distinct files into the same hash.
try {
byte[] bytes = java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(sourcePath));
entity.setContentHash(computeHashOfBytes(bytes));
} catch (Exception e) {
log.warn("[Wiki] Could not compute file hash for dedup: {}", e.getMessage());
}
// Dedup: reuse any existing row with the same hash in this KB (any status)
if (entity.getContentHash() != null) {
WikiRawMaterialEntity existing = rawMapper.selectOne(
new LambdaQueryWrapper<WikiRawMaterialEntity>()
.eq(WikiRawMaterialEntity::getKbId, kbId)
.eq(WikiRawMaterialEntity::getContentHash, entity.getContentHash())
.last("LIMIT 1"));
if (existing != null) {
// Clean up the newly uploaded file — we won't use it
cleanupFile(sourcePath);
return handleDuplicate(existing);
}
}
rawMapper.insert(entity);
kbService.incrementRawCount(kbId);
if (properties.isAutoProcessOnUpload()) {
eventPublisher.publishEvent(new WikiProcessingEvent(this, entity.getId(), kbId));
}
log.info("[Wiki] Raw file added: id={}, kbId={}, type={}", entity.getId(), kbId, sourceType);
return entity;
}
/**
* Defensive cap on the persisted Content-Type so a long upload header
* never blocks the insert. The column itself is wide (V84 → VARCHAR(255))
* but capping at the service layer keeps the schema and the writer in
* lockstep — we'd rather drop the rare overlong header (chrome-derived
* Content-Type with charset / boundary parameters) than fail the upload.
*/
private static final int MIME_TYPE_MAX_CHARS = 255;
private static String capMimeType(String mimeType) {
if (mimeType == null) return null;
if (mimeType.length() <= MIME_TYPE_MAX_CHARS) return mimeType;
log.warn("[Wiki] Truncating Content-Type ({} chars) to fit storage column: {}",
mimeType.length(), mimeType.substring(0, 60) + "");
return mimeType.substring(0, MIME_TYPE_MAX_CHARS);
}
/**
* CAS 式抢占:仅当当前状态为 pending 时才更新为 processing。
*
* @return true 表示抢占成功false 表示已被其他线程处理
*/
@Transactional
public boolean claimForProcessing(Long id) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null || !"pending".equals(entity.getProcessingStatus())) {
return false;
}
entity.setProcessingStatus("processing");
entity.setErrorMessage(null);
// RFC-012 M2 v2 UI新一轮处理开始清掉上次遗留的进度显示
entity.setProgressPhase(null);
entity.setProgressTotal(0);
entity.setProgressDone(0);
// Fresh start clears any stale cancel request from a previous run.
entity.setCancelRequested(Boolean.FALSE);
rawMapper.updateById(entity);
return true;
}
/**
* Mark a raw material for cancellation. Only valid while it is currently
* being processed; for any other status this is a no-op so the call is
* idempotent and safe to retry from the UI.
*
* @return {@code true} if the flag was set, {@code false} otherwise
*/
@Transactional
public boolean requestCancel(Long id) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) {
return false;
}
if (!"processing".equals(entity.getProcessingStatus())) {
return false;
}
if (Boolean.TRUE.equals(entity.getCancelRequested())) {
// Already requested; treat as success without redundant write.
return true;
}
entity.setCancelRequested(Boolean.TRUE);
rawMapper.updateById(entity);
return true;
}
/**
* Returns {@code true} if the user has asked to cancel this raw material's
* current processing run. Used by abort checkpoints inside the processing
* pipeline to bail out early.
*/
public boolean isCancelRequested(Long id) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
return entity != null && Boolean.TRUE.equals(entity.getCancelRequested());
}
/**
* RFC-012 M2 v2 UI更新 wiki 两阶段消化的进度字段。
* <p>
* 在 {@code WikiProcessingService.processChunkTwoPhase} 的四个节点被调用:
* <ul>
* <li>方法开头 → {@code phase="route"}, done=0, total=0进度条显示 indeterminate</li>
* <li>route 返回后 → {@code phase="phase-b"}, done=0, total=N+M切换到 determinate</li>
* <li>每页 create/merge 成功 → done +1</li>
* <li>方法结束 → {@code phase="done"}UI 会随 status 变成 completed 自动隐藏进度条)</li>
* </ul>
*/
@Transactional
public void updateProgress(Long id, String phase, int done, int total) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) return;
entity.setProgressPhase(phase);
entity.setProgressDone(done);
entity.setProgressTotal(total);
rawMapper.updateById(entity);
}
@Transactional
public void updateProcessingStatus(Long id, String status, String errorMessage) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) return;
entity.setProcessingStatus(status);
entity.setErrorMessage(errorMessage);
if ("completed".equals(status)) {
entity.setLastProcessedAt(java.time.LocalDateTime.now());
}
// Cancellation flag is only meaningful while a row is being processed.
// Any transition out of 'processing' clears it so the field reflects
// an idle row's true state and the next reprocess starts clean.
if (!"processing".equals(status)) {
entity.setCancelRequested(Boolean.FALSE);
}
rawMapper.updateById(entity);
}
/**
* Cache the extracted text for a raw material.
* <p>
* RFC-051: this method no longer touches {@code contentHash}. The previous
* behavior overwrote the original-upload hash with an extracted-text hash,
* which broke upload dedup (re-uploading the same file would compute a hash
* over raw bytes but find a row whose hash had been replaced with extracted
* text). The {@code contentHash} field is now an immutable identity for the
* uploaded artifact; downstream short-circuiting uses {@code lastProcessedHash}.
*/
@Transactional
public void updateExtractedText(Long id, String extractedText) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) return;
entity.setExtractedText(extractedText);
rawMapper.updateById(entity);
}
/**
* 记录本次成功处理时的 content_hashRFC-012 Change 5 的短路依据)。
*/
@Transactional
public void setLastProcessedHash(Long id, String hash) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) return;
entity.setLastProcessedHash(hash);
rawMapper.updateById(entity);
}
/**
* 重新处理:重置状态为 pending 并发布事件。
* <p>
* 如果之前状态是 {@code partial},把 rawId 加入 {@link #partialResumeIds}
* 下游的 WikiProcessingService 会据此决定是否保留已生成的 exclusive page续传语义
*/
@Transactional
public void reprocess(Long id) {
WikiRawMaterialEntity entity = rawMapper.selectById(id);
if (entity == null) {
throw new IllegalArgumentException("Raw material not found: " + id);
}
boolean wasPartial = "partial".equals(entity.getProcessingStatus());
entity.setProcessingStatus("pending");
entity.setErrorMessage(null);
rawMapper.updateById(entity);
if (wasPartial) {
partialResumeIds.add(id);
log.info("[Wiki] Raw material queued for PARTIAL RESUME: id={} (existing pages will be kept)", id);
} else {
log.info("[Wiki] Raw material queued for reprocessing: id={}", id);
}
eventPublisher.publishEvent(new WikiProcessingEvent(this, entity.getId(), entity.getKbId()));
}
/**
* 消费 partial resume 标记:若存在则返回 true 并从 set 中移除(一次性)。
* <p>
* 必须在 {@link #claimForProcessing(Long)} 之前调用claim 会把 status 改成 processing
* 此时已无法区分 raw 原本是从 partial 还是从 failed/pending 过来的。
*/
public boolean consumePartialResumeFlag(Long id) {
return partialResumeIds.remove(id);
}
@Transactional
public void delete(Long id) {
// Load the entity first so we know which KB the cascade lives in.
// Once the raw row is gone we'd lose kb_id and couldn't run the
// page cleanup; do it before the deleteById.
WikiRawMaterialEntity entity = rawMapper.selectById(id);
// Cascade-delete pages this raw was the sole source of, and strip
// the raw_id reference from multi-source pages — same semantics
// reprocess uses (WikiProcessingService line ~257). Without this,
// pages survive the raw delete and become orphans: search keeps
// returning them, the page list is polluted, citations dangle.
if (entity != null && entity.getKbId() != null && pageService != null) {
try {
int cleaned = pageService.deleteExclusiveBySourceRawId(entity.getKbId(), id);
if (cleaned > 0) {
log.info("[Wiki] Cascade-deleted {} exclusive page(s) for raw={}", cleaned, id);
}
} catch (Exception e) {
log.warn("[Wiki] Failed to cascade-delete pages for raw={}: {}", id, e.getMessage());
}
}
rawMapper.deleteById(id);
// Cascade-clean chunks so semantic search doesn't hit orphan rows.
try {
if (chunkService != null) {
chunkService.deleteByRawId(id);
}
} catch (Exception e) {
log.warn("[Wiki] Failed to cascade-delete chunks for raw={}: {}", id, e.getMessage());
}
// Source file last — DB pointer is gone, no other row references this
// path (each upload gets a timestamp-prefixed unique name), so
// leaving it on disk would just accumulate as the upload tree grows.
// Failure here is soft-logged and non-blocking — operator can run a
// sweep later if disk usage matters more than the delete RTT.
if (entity != null) {
cleanupFile(entity.getSourcePath());
}
}
/**
* 获取可用文本内容
* <p>
* 优先级:已缓存的 extractedText → 原始文本 → 调用 DocumentExtractTool 提取二进制文件
*/
public String getTextContent(WikiRawMaterialEntity entity) {
// 已有缓存的提取文本
if (entity.getExtractedText() != null && !entity.getExtractedText().isBlank()) {
return entity.getExtractedText();
}
// 文本类型直接返回原始内容
if ("text".equals(entity.getSourceType())) {
return entity.getOriginalContent();
}
// Image source: route through the vision-in pipeline. Failures (feature
// flag off, no provider configured, all providers failed) degrade to an
// empty caption rather than blocking the upload — the user keeps the
// raw row and can retry once vision is configured.
if ("image".equals(entity.getSourceType())) {
return extractTextFromImage(entity);
}
// 二进制文件:调用 DocumentExtractTool 提取
if (entity.getSourcePath() != null && !entity.getSourcePath().isBlank()) {
try {
String result = documentExtractTool.extract_document_text(entity.getSourcePath(), null);
JSONObject json = JSONUtil.parseObj(result);
if (json.getBool("success", false)) {
String text = json.getStr("text");
if (text != null && !text.isBlank()) {
boolean truncated = json.getBool("truncated", false);
// Append inline-image captions for PDFs so chunk-level search
// hits chart/diagram contents that the text extractor missed.
// Failures are non-fatal: the body text is still returned.
String enriched = appendPdfImageCaptions(entity, text);
if (truncated) {
// Truncated results are not cached so we don't lose the tail
// permanently — we still return the text for chunking use.
log.warn("[Wiki] Extracted text truncated at {} chars for: {} (full document may be larger)",
text.length(), entity.getSourcePath());
} else {
updateExtractedText(entity.getId(), enriched);
}
log.info("[Wiki] Extracted text from {}: {} chars (text) → {} chars (enriched), method={}, truncated={}, cached={}",
entity.getSourcePath(), text.length(), enriched.length(),
json.getStr("method"), truncated, !truncated);
return enriched;
}
}
log.warn("[Wiki] Document extraction returned no text for: {}", entity.getSourcePath());
} catch (Exception e) {
log.error("[Wiki] Document extraction failed for {}: {}", entity.getSourcePath(), e.getMessage());
}
}
return entity.getOriginalContent();
}
/**
* For PDF raw materials, walks the inline images and appends a section of
* {@code [图 P{n}#{m}]: <caption>} markers so downstream chunking and
* search can index image contents.
*
* <p>When {@link #VISION_FLAG_KEY} is off, returns the body unchanged
* without re-parsing the PDF — the operator's "off = ignore images"
* intent. Flipping the flag on later requires a manual reprocess for
* existing rows to pick up captions.
*/
private String appendPdfImageCaptions(WikiRawMaterialEntity entity, String body) {
if (!"pdf".equals(entity.getSourceType()) || pdfImageExtractor == null) {
return body;
}
if (featureFlagService == null || !featureFlagService.isEnabled(VISION_FLAG_KEY)) {
return body;
}
java.nio.file.Path pdfPath = java.nio.file.Paths.get(entity.getSourcePath());
try {
List<String> snippets = pdfImageExtractor.captionInlineImages(pdfPath);
if (snippets.isEmpty()) {
return body;
}
StringBuilder sb = new StringBuilder(body);
sb.append("\n\n--- Inline images ---\n");
for (String snippet : snippets) {
sb.append(snippet).append('\n');
}
return sb.toString();
} catch (Exception e) {
log.warn("[Wiki] PDF inline-image captioning failed for id={}: {}",
entity.getId(), e.getMessage());
return body;
}
}
/**
* Routes an image-typed raw material through the vision-in pipeline,
* caches the resulting caption into {@code extracted_text} so the next
* call short-circuits, and degrades gracefully on failure.
*
* <p>Failure modes (feature flag off, no provider, all providers failed,
* IO errors reading the image bytes) are intentionally swallowed and
* surfaced as the empty string. The upload still succeeded; the raw
* row remains and downstream code is expected to tolerate "no
* extracted text yet" — calling this method again later (e.g. after
* an operator enables the feature flag) re-runs the pipeline.
*/
private String extractTextFromImage(WikiRawMaterialEntity entity) {
if (entity.getSourcePath() == null || entity.getSourcePath().isBlank()) {
log.warn("[Wiki] Image raw material missing sourcePath: id={}", entity.getId());
return "";
}
if (featureFlagService == null || !featureFlagService.isEnabled(VISION_FLAG_KEY)) {
log.info("[Wiki] Image raw id={} skipped: {} is disabled",
entity.getId(), VISION_FLAG_KEY);
return "";
}
byte[] imageBytes;
try {
imageBytes = java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(entity.getSourcePath()));
} catch (Exception e) {
log.error("[Wiki] Failed to read image bytes for id={}: {}", entity.getId(), e.getMessage());
return "";
}
VisionRequest request = VisionRequest.builder()
.imageBytes(imageBytes)
.mimeType(resolveMimeType(entity))
.build();
VisionResult result;
try {
result = imageVisionService.caption(request);
} catch (Exception e) {
log.warn("[Wiki] Vision pipeline failed for raw id={}: {}", entity.getId(), e.getMessage());
return "";
}
StringBuilder text = new StringBuilder(result.getCaption() == null ? "" : result.getCaption());
if (result.getVisibleText() != null && !result.getVisibleText().isBlank()) {
text.append("\n\n--- Visible text ---\n").append(result.getVisibleText());
}
String combined = text.toString();
updateExtractedText(entity.getId(), combined);
log.info("[Wiki] Image vision captioned raw id={} provider={} model={} chars={}",
entity.getId(), result.getProviderId(), result.getModel(), combined.length());
return combined;
}
/** Best-effort MIME resolution: prefer the persisted column, fall back to file extension. */
private static String resolveMimeType(WikiRawMaterialEntity entity) {
if (entity.getMimeType() != null && !entity.getMimeType().isBlank()) {
return entity.getMimeType();
}
String path = entity.getSourcePath() == null ? "" : entity.getSourcePath().toLowerCase();
int dot = path.lastIndexOf('.');
if (dot < 0) return "application/octet-stream";
String ext = path.substring(dot + 1);
return switch (ext) {
case "png" -> "image/png";
case "jpg", "jpeg" -> "image/jpeg";
case "webp" -> "image/webp";
case "gif" -> "image/gif";
case "bmp" -> "image/bmp";
case "tiff", "tif" -> "image/tiff";
default -> "application/octet-stream";
};
}
/**
* Recover raw materials stuck in 'processing' status after a server restart.
* Resets them to 'pending', clears stale progress fields, and optionally
* fires processing events so they get picked up automatically.
*
* @return number of recovered rows
*/
@Transactional
public int recoverStuckRawMaterialsOnStartup() {
List<WikiRawMaterialEntity> stuck = rawMapper.selectList(
new LambdaQueryWrapper<WikiRawMaterialEntity>()
.eq(WikiRawMaterialEntity::getProcessingStatus, "processing"));
if (stuck.isEmpty()) return 0;
for (WikiRawMaterialEntity raw : stuck) {
raw.setProcessingStatus("pending");
raw.setProgressPhase(null);
raw.setProgressTotal(0);
raw.setProgressDone(0);
raw.setErrorMessage(null);
rawMapper.updateById(raw);
if (properties.isAutoProcessOnUpload()) {
eventPublisher.publishEvent(new WikiProcessingEvent(this, raw.getId(), raw.getKbId()));
}
log.info("[Wiki] Recovered stuck processing raw material: id={}, kbId={}", raw.getId(), raw.getKbId());
}
return stuck.size();
}
/**
* Handle a duplicate upload: decide what to do based on the existing row's status.
* - completed → return as-is (no reprocessing needed)
* - partial / failed → reprocess (partial enters resume branch)
* - pending / processing → return as-is (already queued or running)
*/
private WikiRawMaterialEntity handleDuplicate(WikiRawMaterialEntity existing) {
String prevStatus = existing.getProcessingStatus();
log.info("[Wiki] Duplicate file detected, reusing id={}, prevStatus={}", existing.getId(), prevStatus);
if ("partial".equals(prevStatus) || "failed".equals(prevStatus)) {
reprocess(existing.getId());
}
// completed / pending / processing → return as-is
return existing;
}
/**
* Best-effort delete of an upload-tree file. Used both when a fresh
* upload turns out to be a duplicate (the new file is redundant) and
* when a raw material row is deleted (its source file becomes a
* disk orphan with no DB pointer to it). Idempotent — silently
* succeeds when the path is null or the file is already gone.
*/
private void cleanupFile(String path) {
if (path == null || path.isBlank()) return;
try {
java.nio.file.Files.deleteIfExists(java.nio.file.Paths.get(path));
} catch (Exception e) {
log.warn("[Wiki] Failed to clean up upload file {}: {}", path, e.getMessage());
}
}
private String computeHash(String content) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(content.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (Exception e) {
log.warn("[Wiki] Failed to compute content hash: {}", e.getMessage());
return null;
}
}
/**
* SHA-256 over raw bytes. Used for file uploads so that PDF/Office binaries
* produce a stable identity hash regardless of UTF-8 round-tripping.
*/
private String computeHashOfBytes(byte[] bytes) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(bytes);
return HexFormat.of().formatHex(hash);
} catch (Exception e) {
log.warn("[Wiki] Failed to compute byte hash: {}", e.getMessage());
return null;
}
}
}