|
|
@@ -0,0 +1,192 @@
|
|
|
+package com.payment.platform.module.payment.stream;
|
|
|
+
|
|
|
+import jakarta.annotation.PostConstruct;
|
|
|
+import jakarta.annotation.PreDestroy;
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
+import org.springframework.stereotype.Component;
|
|
|
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
|
|
+
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.concurrent.ConcurrentHashMap;
|
|
|
+import java.util.concurrent.CopyOnWriteArrayList;
|
|
|
+import java.util.concurrent.Executors;
|
|
|
+import java.util.concurrent.ScheduledExecutorService;
|
|
|
+import java.util.concurrent.ScheduledFuture;
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 订单实时信号的进程内广播器(部署形态是单实例,见 spec)。
|
|
|
+ *
|
|
|
+ * <p>桶模型:超管一桶 + 租户各一桶。超管**只进超管桶** —— 同一条连接收两份帧既是浪费、
|
|
|
+ * 又会让页面同一秒重拉两次。非超管且 tenantId 为 null:不进任何桶(保守,宁可少推)。
|
|
|
+ *
|
|
|
+ * <p>线程模型:对 emitter 的**每一次写**(注册时的 retry 帧、广播帧、心跳 ping)都提交到
|
|
|
+ * 同一条名为 {@code order-sse} 的守护线程上串行执行 —— {@code SseEmitter} 不允许多线程
|
|
|
+ * 并发 send。这条线程是自建的,**不是** Spring 的调度池:{@code spring.task.scheduling.pool.size}
|
|
|
+ * 默认 1、支付对账调度器就挂在上面,占用它会把对账挤停。
|
|
|
+ */
|
|
|
+@Slf4j
|
|
|
+@Component
|
|
|
+public class OrderEventHub {
|
|
|
+
|
|
|
+ /** emitter 超时:显式 1 小时。不赌 0L 的语义;到点自然断,前端重连 + 补拉接住。 */
|
|
|
+ public static final long TIMEOUT_MILLIS = TimeUnit.HOURS.toMillis(1);
|
|
|
+ /** 心跳间隔 20s:nginx proxy_read_timeout 默认 60s,留 3 倍余量。 */
|
|
|
+ public static final long PING_INTERVAL_SECONDS = 20;
|
|
|
+ /** 重连提示:浏览器断线后 3s 再来(首次连接不受影响)。 */
|
|
|
+ public static final long RECONNECT_TIME_MILLIS = 3000L;
|
|
|
+
|
|
|
+ private final List<SseEmitter> superAdmins = new CopyOnWriteArrayList<>();
|
|
|
+ private final Map<Long, List<SseEmitter>> byTenant = new ConcurrentHashMap<>();
|
|
|
+
|
|
|
+ private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r -> {
|
|
|
+ Thread t = new Thread(r, "order-sse");
|
|
|
+ t.setDaemon(true);
|
|
|
+ return t;
|
|
|
+ });
|
|
|
+ private ScheduledFuture<?> heartbeat;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 注册一条连接。返回的 emitter 交给 Spring MVC 接管响应。
|
|
|
+ *
|
|
|
+ * @param tenantId 登录用户的租户;可为 null
|
|
|
+ * @param superAdmin is_superuser 开关(与 TenantInnerInterceptor 用的是同一个)
|
|
|
+ */
|
|
|
+ public SseEmitter register(Long tenantId, boolean superAdmin) {
|
|
|
+ SseEmitter emitter = new SseEmitter(TIMEOUT_MILLIS);
|
|
|
+ List<SseEmitter> bucket = superAdmin ? superAdmins
|
|
|
+ : (tenantId == null ? null : byTenant.computeIfAbsent(tenantId, k -> new CopyOnWriteArrayList<>()));
|
|
|
+ if (bucket == null) {
|
|
|
+ // 无租户的非超管:收不到任何信号,立刻正常结束,别让客户端挂满 1 小时
|
|
|
+ log.info("[订单SSE] 无租户的非超管登录,不建连接");
|
|
|
+ emitter.complete();
|
|
|
+ return emitter;
|
|
|
+ }
|
|
|
+ // ★ spec 的「实现前必须先验证的小项」里有一条:非超管 + tenantId=null 在
|
|
|
+ // TenantInnerInterceptor 下到底看不看得见那些行。**这一条没有做**,如实记在这里:
|
|
|
+ // 它改不了本通道的行为 —— 不入桶就是不推,而这位用户取数一直走列表接口,
|
|
|
+ // 加通道前后完全一致(不回归、也不泄露)。哪天真要给他推,改的是上面这个分支 + 一条用例。
|
|
|
+ // 别把它写成「已验证」。
|
|
|
+ emitter.onCompletion(() -> remove(bucket, emitter));
|
|
|
+ emitter.onTimeout(() -> remove(bucket, emitter));
|
|
|
+ emitter.onError(e -> remove(bucket, emitter));
|
|
|
+ bucket.add(emitter);
|
|
|
+ // retry 帧在 emitter 初始化前 send 会进容器的 earlySendAttempts 队列、由 initialize 时补发;
|
|
|
+ // 仍排到同一条线程上,保持「所有写都在 order-sse」这条不变式
|
|
|
+ executor.execute(() -> sendRetry(emitter));
|
|
|
+ return emitter;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 同步广播:调用线程直接发。生产别用(见 {@link #submitBroadcast}),测试用。 */
|
|
|
+ public void broadcast(String channel, Long tenantId) {
|
|
|
+ deliver(superAdmins, channel);
|
|
|
+ if (tenantId != null) {
|
|
|
+ List<SseEmitter> bucket = byTenant.get(tenantId);
|
|
|
+ if (bucket != null) {
|
|
|
+ deliver(bucket, channel);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 生产路径:提交到 order-sse 单线程上执行,与心跳串行。 */
|
|
|
+ public void submitBroadcast(String channel, Long tenantId) {
|
|
|
+ executor.execute(() -> broadcast(channel, tenantId));
|
|
|
+ }
|
|
|
+
|
|
|
+ private void deliver(List<SseEmitter> bucket, String channel) {
|
|
|
+ for (SseEmitter emitter : bucket) {
|
|
|
+ if (!sendFrame(emitter, channel)) {
|
|
|
+ remove(bucket, emitter);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void remove(List<SseEmitter> bucket, SseEmitter emitter) {
|
|
|
+ bucket.remove(emitter);
|
|
|
+ if (bucket != superAdmins && bucket.isEmpty()) {
|
|
|
+ // 空桶删 key:否则每个登录过的租户都留一个空 List 到进程结束
|
|
|
+ byTenant.values().remove(bucket);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 帧格式:有名事件 + 一个 JSON 对象。**不带数据** —— 页面拿信号去重拉它自己的列表接口。 */
|
|
|
+ protected boolean sendFrame(SseEmitter emitter, String channel) {
|
|
|
+ try {
|
|
|
+ emitter.send(SseEmitter.event().name("changed").data(Map.of("channel", channel)));
|
|
|
+ return true;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.debug("[订单SSE] 发送失败,摘除连接: channel={}, err={}", channel, e.getMessage());
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ protected void sendRetry(SseEmitter emitter) {
|
|
|
+ try {
|
|
|
+ emitter.send(SseEmitter.event().reconnectTime(RECONNECT_TIME_MILLIS));
|
|
|
+ } catch (Exception e) {
|
|
|
+ // 注册瞬间客户端可能已经走了:忽略,onError/onCompletion 会清
|
|
|
+ log.debug("[订单SSE] 重连提示发送失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 心跳:有名事件 ping(注释帧 JS 看不见,前端看门狗就废了)。 */
|
|
|
+ protected boolean sendPing(SseEmitter emitter) {
|
|
|
+ try {
|
|
|
+ emitter.send(SseEmitter.event().name("ping").data("{}"));
|
|
|
+ return true;
|
|
|
+ } catch (Exception e) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @PostConstruct
|
|
|
+ void startHeartbeat() {
|
|
|
+ heartbeat = executor.scheduleAtFixedRate(this::pingAll,
|
|
|
+ PING_INTERVAL_SECONDS, PING_INTERVAL_SECONDS, TimeUnit.SECONDS);
|
|
|
+ log.info("[订单SSE] 广播器就绪(单线程 order-sse,心跳 {}s)", PING_INTERVAL_SECONDS);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void pingAll() {
|
|
|
+ pingBucket(superAdmins);
|
|
|
+ for (List<SseEmitter> bucket : byTenant.values()) {
|
|
|
+ pingBucket(bucket);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void pingBucket(List<SseEmitter> bucket) {
|
|
|
+ for (SseEmitter emitter : bucket) {
|
|
|
+ if (!sendPing(emitter)) {
|
|
|
+ remove(bucket, emitter);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @PreDestroy
|
|
|
+ void shutdown() {
|
|
|
+ if (heartbeat != null) {
|
|
|
+ heartbeat.cancel(false);
|
|
|
+ }
|
|
|
+ executor.shutdownNow();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 仅测试用:当前连接总数。 */
|
|
|
+ int connectionCount() {
|
|
|
+ int n = superAdmins.size();
|
|
|
+ for (List<SseEmitter> bucket : byTenant.values()) {
|
|
|
+ n += bucket.size();
|
|
|
+ }
|
|
|
+ return n;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 仅测试用。 */
|
|
|
+ int tenantConnectionCount(long tenantId) {
|
|
|
+ List<SseEmitter> bucket = byTenant.get(tenantId);
|
|
|
+ return bucket == null ? 0 : bucket.size();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 仅测试用:还留着几个租户桶(空桶应被删掉)。 */
|
|
|
+ int tenantBucketCount() {
|
|
|
+ return byTenant.size();
|
|
|
+ }
|
|
|
+}
|