package jnpf.base.service; import com.baomidou.dynamic.datasource.tx.TransactionContext; import jnpf.base.ActionResult; import jnpf.exception.DataException; import jnpf.util.JsonUtil; import jnpf.util.TicketUtil; import jnpf.util.UserProvider; import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.context.request.async.DeferredResult; import java.util.*; /** Authorized, content-free change signals. Polling remains authoritative across nodes/restarts. */ @Component public class UploadEditNotifications { private final Map versions = new LinkedHashMap<>(); private final Set waiters = new HashSet<>(); private long sequence = System.currentTimeMillis(); private record Waiter(Map versions, DeferredResult> result) { } public synchronized long version(String tenant, String draft) { return versions.getOrDefault(tenant + ":" + draft, 0L); } public String ticket(Map observed) { Map proof = new LinkedHashMap<>(); proof.put("purpose", "upload-edit-change-watch"); proof.put("tenant", UploadEditDraftService.tenant(UserProvider.getUser())); proof.put("user", UserProvider.getLoginUserId()); proof.put("expires", System.currentTimeMillis() + 60_000L); proof.put("versions", observed); return TicketUtil.createTicket(JsonUtil.getObjectToString(proof), 60L); } public DeferredResult> watch(String ticket) { String raw = ticket == null ? null : TicketUtil.parseTicket(ticket); Map proof = raw == null ? Map.of() : JsonUtil.getJsonToBean(raw, Map.class); String tenant = UploadEditDraftService.tenant(UserProvider.getUser()); if (!"upload-edit-change-watch".equals(proof.get("purpose")) || !tenant.equals(proof.get("tenant")) || !Objects.equals(UserProvider.getLoginUserId(), proof.get("user")) || !(proof.get("expires") instanceof Number expiry) || expiry.longValue() < System.currentTimeMillis() || !(proof.get("versions") instanceof Map observed) || observed.isEmpty() || observed.size() > 200) throw new DataException("附件同步凭据已失效,请重新读取共享状态"); Map expected = new LinkedHashMap<>(); for (var entry : observed.entrySet()) { if (!(entry.getValue() instanceof Number number)) throw new DataException("附件同步凭据无效"); expected.put(tenant + ":" + entry.getKey(), number.longValue()); } DeferredResult> result = new DeferredResult<>(25_000L, ActionResult.success(false)); Waiter waiter = new Waiter(expected, result); synchronized (this) { if (changed(expected)) result.setResult(ActionResult.success(true)); else if (waiters.size() < 1024) waiters.add(waiter); else result.setResult(ActionResult.success(false)); } result.onCompletion(() -> { synchronized (this) { waiters.remove(waiter); } }); return result; } public void changedAfterCommit(String tenant, String draft) { Runnable publish = () -> publish(tenant + ":" + draft); TransactionSynchronization sync = new TransactionSynchronization() { @Override public void afterCommit() { publish.run(); } }; if (TransactionContext.getXID() != null) TransactionContext.registerSynchronization(sync); else if (TransactionSynchronizationManager.isSynchronizationActive()) TransactionSynchronizationManager.registerSynchronization(sync); else publish.run(); } private synchronized boolean changed(Map expected) { return expected.entrySet().stream().anyMatch(e -> !Objects.equals(versions.getOrDefault(e.getKey(), 0L), e.getValue())); } private synchronized void publish(String key) { versions.put(key, ++sequence); if (versions.size() > 4096) versions.remove(versions.keySet().iterator().next()); // Completion callbacks may run immediately; iterate a snapshot. for (Waiter waiter : new ArrayList<>(waiters)) if (changed(waiter.versions())) { waiters.remove(waiter); waiter.result().setResult(ActionResult.success(true)); } } }