| @ -0,0 +1,174 @@ | |||||
| package com.inspect.agg.support; | |||||
| import java.nio.charset.StandardCharsets; | |||||
| import java.util.regex.Pattern; | |||||
| import org.springframework.stereotype.Component; | |||||
| import org.springframework.util.StringUtils; | |||||
| import com.inspect.agg.config.AggProperties; | |||||
| import lombok.RequiredArgsConstructor; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| /** | |||||
| * 将站端 SFTP 文件相对路径改写为集控 nginx 代理地址。 | |||||
| * 图片:{nginxBase}/img/{stationCode}/{原始路径};文档(巡视报告等):{nginxBase}/file/{stationCode}/{原始路径}。 | |||||
| * 集控 nginx 按 stationCode 转发到站端 nginx,由站端规则补全/剥除固定前缀后转到文件/图片服务。 | |||||
| */ | |||||
| @Slf4j | |||||
| @Component | |||||
| @RequiredArgsConstructor | |||||
| public class StationFileUrlResolver { | |||||
| /** 图片扩展名 */ | |||||
| private static final Pattern IMAGE_EXT = Pattern.compile( | |||||
| "(?i).+\\.(jpg|jpeg|png|gif|bmp|webp)(\\?.*)?$"); | |||||
| /** 可下载的文档/附件扩展名 */ | |||||
| private static final Pattern DOC_EXT = Pattern.compile( | |||||
| "(?i).+\\.(doc|docx|xls|xlsx|ppt|pptx|pdf|zip|rar|7z|txt|csv)(\\?.*)?$"); | |||||
| /** | |||||
| * 站端存储内部路径形态,第一段为存储实例编号,如: | |||||
| * 图片 /1/2026/09/29/1796/CCD/x.jpg,报告 /1/Model/xxx.docx。 | |||||
| */ | |||||
| private static final Pattern STORE_PATH = Pattern.compile( | |||||
| "(?i)^/?\\d{1,4}/.+\\.[0-9a-z]+(\\?.*)?$"); | |||||
| /** 字段名命中即视为图片字段 */ | |||||
| private static final Pattern IMAGE_FIELD = Pattern.compile( | |||||
| "(?i).*(img|image|photo|pic|picture).*"); | |||||
| /** 字段名命中即视为文件字段 */ | |||||
| private static final Pattern FILE_FIELD = Pattern.compile( | |||||
| "(?i).*(file|filepath|fileurl|attach|reporturl).*"); | |||||
| private final AggProperties props; | |||||
| /** 明确是图片字段的出参(缺陷列表的 img 等)。支持逗号分隔多图。 */ | |||||
| public String resolveImage(String stationCode, String value) { | |||||
| return rewrite(stationCode, null, value, true, false); | |||||
| } | |||||
| /** 明确是文件字段的出参(巡视报告 filePath 等)。 */ | |||||
| public String resolveFile(String stationCode, String value) { | |||||
| return rewrite(stationCode, null, value, false, true); | |||||
| } | |||||
| /** | |||||
| * 通用代理响应的不确定字段:仅当值看起来是站端存储的图片/文档路径时才改写, | |||||
| * 避免误伤地图 XML 等无关 filePath。 | |||||
| */ | |||||
| public String rewriteIfStationFile(String stationCode, String fieldName, String value) { | |||||
| return rewrite(stationCode, fieldName, value, false, false); | |||||
| } | |||||
| private String rewrite(String stationCode, String fieldName, String value, | |||||
| boolean knownImageField, boolean knownFileField) { | |||||
| if (!StringUtils.hasText(value)) { | |||||
| return value; | |||||
| } | |||||
| String[] tokens = value.split(","); | |||||
| boolean changed = false; | |||||
| for (int i = 0; i < tokens.length; i++) { | |||||
| String rewritten = rewriteOne(stationCode, fieldName, tokens[i], | |||||
| knownImageField, knownFileField); | |||||
| if (rewritten != null && !rewritten.equals(tokens[i])) { | |||||
| tokens[i] = rewritten; | |||||
| changed = true; | |||||
| } | |||||
| } | |||||
| return changed ? String.join(",", tokens) : value; | |||||
| } | |||||
| private String rewriteOne(String stationCode, String fieldName, String raw, | |||||
| boolean knownImageField, boolean knownFileField) { | |||||
| if (raw == null) { | |||||
| return null; | |||||
| } | |||||
| String path = raw.trim(); | |||||
| if (path.isEmpty() | |||||
| || path.startsWith("http://") || path.startsWith("https://") | |||||
| || path.startsWith("data:") || path.startsWith("//")) { | |||||
| return raw; | |||||
| } | |||||
| boolean image = IMAGE_EXT.matcher(path).matches(); | |||||
| boolean doc = DOC_EXT.matcher(path).matches(); | |||||
| boolean storePath = STORE_PATH.matcher(path).matches(); | |||||
| String field = StringUtils.hasText(fieldName) ? fieldName : ""; | |||||
| boolean imageField = IMAGE_FIELD.matcher(field).matches(); | |||||
| boolean fileField = FILE_FIELD.matcher(field).matches(); | |||||
| boolean isImage = image && (knownImageField || storePath || imageField); | |||||
| boolean isDoc = doc && (knownFileField || storePath || fileField); | |||||
| if (!isImage && !isDoc) { | |||||
| return raw; | |||||
| } | |||||
| if (!path.startsWith("/")) { | |||||
| path = "/" + path; | |||||
| } | |||||
| String segment = isImage ? "img" : "file"; | |||||
| log.info("mediaBase: {}", mediaBase()); | |||||
| return mediaBase() + "/" + segment + "/" + encodeSegment(stationCode) + encodePathSegments(path); | |||||
| } | |||||
| /** | |||||
| * 对存储路径逐段做百分号编码(保留 /),防止中文、空格(如报告文件名中的空格) | |||||
| * 导致 nginx 400。nginx 转发到上游时保持编码形式,由文件服务解码为真实文件名。 | |||||
| * 已包含百分号编码(%XX)的段视为已编码,不重复处理;查询串原样保留。 | |||||
| */ | |||||
| private static String encodePathSegments(String pathAndQuery) { | |||||
| int queryIndex = pathAndQuery.indexOf('?'); | |||||
| String path = queryIndex >= 0 ? pathAndQuery.substring(0, queryIndex) : pathAndQuery; | |||||
| String query = queryIndex >= 0 ? pathAndQuery.substring(queryIndex) : ""; | |||||
| String[] segments = path.split("/", -1); | |||||
| StringBuilder sb = new StringBuilder(); | |||||
| for (int i = 0; i < segments.length; i++) { | |||||
| if (i > 0) { | |||||
| sb.append('/'); | |||||
| } | |||||
| if (!segments[i].isEmpty()) { | |||||
| sb.append(encodeSegment(segments[i])); | |||||
| } | |||||
| } | |||||
| return sb.append(query).toString(); | |||||
| } | |||||
| /** | |||||
| * 只编码必须编码的字符:空格和非 ASCII(中文等),其余 RFC 3986 路径允许的字符 | |||||
| * (含冒号)保持原样,与浏览器地址栏行为一致。 | |||||
| * 不能用 URLEncoder:它会把冒号编成 %3A,经过 nginx 变量 proxy_pass 原样透传后, | |||||
| * 上游文件服务会返回 400。已包含 %XX 的段视为已编码,不重复处理。 | |||||
| */ | |||||
| private static String encodeSegment(String segment) { | |||||
| if (segment.contains("%")) { | |||||
| return segment; | |||||
| } | |||||
| StringBuilder sb = new StringBuilder(segment.length() + 16); | |||||
| for (int i = 0; i < segment.length(); ) { | |||||
| int cp = segment.codePointAt(i); | |||||
| if (cp <= 0x20 || cp > 0x7E) { | |||||
| byte[] bytes = new String(Character.toChars(cp)).getBytes(StandardCharsets.UTF_8); | |||||
| for (byte b : bytes) { | |||||
| sb.append('%').append(String.format("%02X", b & 0xFF)); | |||||
| } | |||||
| } else { | |||||
| sb.append((char) cp); | |||||
| } | |||||
| i += Character.charCount(cp); | |||||
| } | |||||
| return sb.toString(); | |||||
| } | |||||
| /** | |||||
| * 配置为空时返回空串,出参为相对地址 /img、/file,由前端拼接 /app/htjc6/htjcImage 前缀; | |||||
| * 显式配置 agg.file.nginx-base-url 时才输出绝对地址。 | |||||
| */ | |||||
| private String mediaBase() { | |||||
| String configured = props.getFile().getNginxBaseUrl(); | |||||
| return StringUtils.hasText(configured) ? configured.trim().replaceAll("/+$", "") : ""; | |||||
| } | |||||
| } | |||||
| @ -1,33 +0,0 @@ | |||||
| package com.inspect.agg.video; | |||||
| import lombok.Data; | |||||
| /** | |||||
| * 汇聚实时流与站端实时流之间的绑定关系。 | |||||
| */ | |||||
| @Data | |||||
| public class RealtimeStreamBinding { | |||||
| /** 站点编码。 */ | |||||
| private String stationCode; | |||||
| /** 原始摄像机编码,保留 cameraCode 中 # 后的附加信息。 */ | |||||
| private String cameraCode; | |||||
| /** 汇聚 ZLMediaKit 中使用的确定性流 ID。 */ | |||||
| private String aggStreamId; | |||||
| /** 站端实时流 ID;直连 RTSP 模式为空。 */ | |||||
| private String stationStreamId; | |||||
| /** 汇聚 ZLMediaKit 拉取的源 RTSP 地址。 */ | |||||
| private String sourceRtsp; | |||||
| /** 流类型,透传给站端 stop 接口。 */ | |||||
| private Integer streamType; | |||||
| /** 是否由站端启动和停止;false 表示调用方直接传入 RTSP。 */ | |||||
| private boolean stationManaged = true; | |||||
| private long createdAt = System.currentTimeMillis(); | |||||
| } | |||||
| @ -1,65 +0,0 @@ | |||||
| package com.inspect.agg.video; | |||||
| import java.util.Map; | |||||
| import java.util.concurrent.ConcurrentHashMap; | |||||
| import org.springframework.stereotype.Component; | |||||
| import org.springframework.util.StringUtils; | |||||
| /** | |||||
| * 实时流绑定注册表:按站点 + 摄像机维护汇聚流与站端流的关系。 | |||||
| */ | |||||
| @Component | |||||
| public class RealtimeStreamRegistry { | |||||
| private final Map<String, RealtimeStreamBinding> byCamera = new ConcurrentHashMap<>(); | |||||
| private final Map<String, String> aggStreamIdToCameraKey = new ConcurrentHashMap<>(); | |||||
| public static String cameraKey(String stationCode, String cameraCode) { | |||||
| String base = cameraCode; | |||||
| if (base != null && base.contains("#")) { | |||||
| base = base.substring(0, base.indexOf('#')); | |||||
| } | |||||
| return stationCode + "|" + base; | |||||
| } | |||||
| public void put(RealtimeStreamBinding binding) { | |||||
| if (binding == null | |||||
| || !StringUtils.hasText(binding.getStationCode()) | |||||
| || !StringUtils.hasText(binding.getCameraCode()) | |||||
| || !StringUtils.hasText(binding.getAggStreamId())) { | |||||
| throw new IllegalArgumentException("实时流绑定信息不完整"); | |||||
| } | |||||
| String key = cameraKey(binding.getStationCode(), binding.getCameraCode()); | |||||
| byCamera.put(key, binding); | |||||
| aggStreamIdToCameraKey.put(binding.getAggStreamId(), key); | |||||
| } | |||||
| public RealtimeStreamBinding getByCamera(String stationCode, String cameraCode) { | |||||
| if (!StringUtils.hasText(stationCode) || !StringUtils.hasText(cameraCode)) { | |||||
| return null; | |||||
| } | |||||
| return byCamera.get(cameraKey(stationCode, cameraCode)); | |||||
| } | |||||
| public RealtimeStreamBinding getByAggStreamId(String aggStreamId) { | |||||
| String cameraKey = aggStreamIdToCameraKey.get(aggStreamId); | |||||
| return cameraKey == null ? null : byCamera.get(cameraKey); | |||||
| } | |||||
| public RealtimeStreamBinding removeByAggStreamId(String aggStreamId) { | |||||
| String cameraKey = aggStreamIdToCameraKey.remove(aggStreamId); | |||||
| if (cameraKey == null) { | |||||
| return null; | |||||
| } | |||||
| RealtimeStreamBinding binding = byCamera.remove(cameraKey); | |||||
| if (binding == null) { | |||||
| return null; | |||||
| } | |||||
| if (!aggStreamId.equals(binding.getAggStreamId())) { | |||||
| // 正常不会发生;若同摄像机绑定已被替换,恢复反向索引,避免误删新绑定。 | |||||
| aggStreamIdToCameraKey.put(binding.getAggStreamId(), cameraKey); | |||||
| } | |||||
| return binding; | |||||
| } | |||||
| } | |||||
| @ -1,340 +0,0 @@ | |||||
| package com.inspect.agg.video; | |||||
| import java.net.MalformedURLException; | |||||
| import java.net.URL; | |||||
| import java.util.List; | |||||
| import java.util.Map; | |||||
| import java.util.concurrent.ConcurrentHashMap; | |||||
| import org.springframework.stereotype.Component; | |||||
| import org.springframework.util.StringUtils; | |||||
| import com.fasterxml.jackson.databind.JsonNode; | |||||
| import com.inspect.agg.dto.VideoRequest; | |||||
| import com.inspect.agg.entity.AggStation; | |||||
| import com.inspect.agg.service.StationRegistry; | |||||
| import com.inspect.agg.station.StationApiClient; | |||||
| import lombok.Data; | |||||
| import lombok.RequiredArgsConstructor; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| /** | |||||
| * 实时流生命周期服务:统一处理站端启动、汇聚代理、观看引用计数和停止清理。 | |||||
| */ | |||||
| @Slf4j | |||||
| @Component | |||||
| @RequiredArgsConstructor | |||||
| public class RealtimeStreamService { | |||||
| private final StationRegistry stationRegistry; | |||||
| private final StationApiClient stationApiClient; | |||||
| private final ZlmApiClient zlmApiClient; | |||||
| private final RealtimeViewerRegistry viewerRegistry; | |||||
| private final RealtimeStreamRegistry streamRegistry; | |||||
| /** 单实例部署下按相机串行化 start/stop,避免并发重复创建代理。 */ | |||||
| private final Map<String, Object> cameraLocks = new ConcurrentHashMap<>(); | |||||
| public StartResult start(String stationCode, VideoRequest request) { | |||||
| if (request == null | |||||
| || !StringUtils.hasText(stationCode) | |||||
| || !StringUtils.hasText(request.getCameraCode())) { | |||||
| throw new IllegalArgumentException("stationCode / cameraCode 不能为空"); | |||||
| } | |||||
| String cameraCode = request.getCameraCode(); | |||||
| Integer streamType = request.getStreamType() == null ? 2 : request.getStreamType(); | |||||
| String directRtsp = request.getRtspUrl(); | |||||
| AggStation station = stationRegistry.requireByCode(stationCode); | |||||
| String aggStreamId = aggStreamId(stationCode, cameraCode); | |||||
| synchronized (cameraLock(stationCode, cameraCode)) { | |||||
| boolean mediaActive = zlmApiClient.streamExists(aggStreamId); | |||||
| JsonNode existingProxy = zlmApiClient.findStreamProxy(aggStreamId); | |||||
| boolean proxyActive = existingProxy != null; | |||||
| RealtimeStreamBinding binding = streamRegistry.getByCamera(stationCode, cameraCode); | |||||
| boolean stationStartAttempted = false; | |||||
| try { | |||||
| if (!mediaActive && !proxyActive) { | |||||
| // 既无在线媒体也无拉流代理:调站端启动并新建汇聚代理。 | |||||
| if (StringUtils.hasText(directRtsp)) { | |||||
| binding = directBinding(stationCode, cameraCode, aggStreamId, directRtsp, streamType); | |||||
| } else { | |||||
| binding = startStationStream(station, cameraCode, aggStreamId, streamType); | |||||
| stationStartAttempted = true; | |||||
| } | |||||
| // addStreamProxy 已对 already exists 幂等处理,兜底并发/残留同名代理。 | |||||
| zlmApiClient.addStreamProxy(aggStreamId, binding.getSourceRtsp()); | |||||
| } else if (binding == null) { | |||||
| // ZLM 已有在线媒体或拉流代理,但本机绑定丢失(例如服务重启后)。 | |||||
| // 不再重复调用站端 start(避免站端重复生成一路流),改用代理源地址补登绑定。 | |||||
| if (StringUtils.hasText(directRtsp)) { | |||||
| binding = directBinding(stationCode, cameraCode, aggStreamId, directRtsp, streamType); | |||||
| } else { | |||||
| String sourceRtsp = existingProxy == null ? null : existingProxy.path("url").asText(null); | |||||
| binding = recoveredBinding(stationCode, cameraCode, aggStreamId, streamType, sourceRtsp); | |||||
| } | |||||
| } else { | |||||
| binding.setAggStreamId(aggStreamId); | |||||
| } | |||||
| streamRegistry.put(binding); | |||||
| viewerRegistry.add(aggStreamId); | |||||
| // 媒体已注册可播放返回 PLAYING;代理已存在但尚未出流返回 STARTING。 | |||||
| return new StartResult(aggStreamId, mediaActive); | |||||
| } catch (RuntimeException e) { | |||||
| if (stationStartAttempted) { | |||||
| stopStationQuietly(station, binding, cameraCode, streamType, null); | |||||
| } | |||||
| throw e; | |||||
| } | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 停止一个观看者。 | |||||
| * | |||||
| * @return -1 没有观看票据;0 最后一个观看者已退出并停止流;正数表示剩余观看者数量 | |||||
| */ | |||||
| public int stop(String stationCode, VideoRequest request) { | |||||
| if (request == null | |||||
| || !StringUtils.hasText(stationCode) | |||||
| || !StringUtils.hasText(request.getCameraCode())) { | |||||
| throw new IllegalArgumentException("stationCode / cameraCode 不能为空"); | |||||
| } | |||||
| String cameraCode = request.getCameraCode(); | |||||
| Integer streamType = request.getStreamType() == null ? 2 : request.getStreamType(); | |||||
| AggStation station = stationRegistry.requireByCode(stationCode); | |||||
| String aggStreamId = aggStreamId(stationCode, cameraCode); | |||||
| synchronized (cameraLock(stationCode, cameraCode)) { | |||||
| int remaining = viewerRegistry.remove(aggStreamId); | |||||
| if (remaining != 0) { | |||||
| return remaining; | |||||
| } | |||||
| boolean deleted; | |||||
| try { | |||||
| deleted = zlmApiClient.delStreamProxy(aggStreamId); | |||||
| } catch (RuntimeException e) { | |||||
| viewerRegistry.add(aggStreamId); | |||||
| throw e; | |||||
| } | |||||
| if (!deleted) { | |||||
| boolean stillExists; | |||||
| try { | |||||
| stillExists = zlmApiClient.streamExists(aggStreamId); | |||||
| } catch (RuntimeException e) { | |||||
| viewerRegistry.add(aggStreamId); | |||||
| throw e; | |||||
| } | |||||
| if (stillExists) { | |||||
| viewerRegistry.add(aggStreamId); | |||||
| throw new IllegalStateException("汇聚 ZLMediaKit 流仍存在,停止失败"); | |||||
| } | |||||
| } | |||||
| RealtimeStreamBinding binding = streamRegistry.removeByAggStreamId(aggStreamId); | |||||
| viewerRegistry.clear(aggStreamId); | |||||
| stopStationQuietly(station, binding, cameraCode, streamType, request.getRtspUrl()); | |||||
| return 0; | |||||
| } | |||||
| } | |||||
| /** 定时清理过期观看票据;仅清理没有活跃观看者的汇聚流,并尽力停止对应站端流。 */ | |||||
| public void cleanupExpired() { | |||||
| long now = System.currentTimeMillis(); | |||||
| List<String> expiredStreams = viewerRegistry.expire(now); | |||||
| for (String aggStreamId : expiredStreams) { | |||||
| RealtimeStreamBinding existing = streamRegistry.getByAggStreamId(aggStreamId); | |||||
| String lockKey = existing == null | |||||
| ? "expired:" + aggStreamId | |||||
| : RealtimeStreamRegistry.cameraKey(existing.getStationCode(), existing.getCameraCode()); | |||||
| synchronized (cameraLocks.computeIfAbsent(lockKey, ignored -> new Object())) { | |||||
| if (viewerRegistry.count(aggStreamId) > 0) { | |||||
| continue; | |||||
| } | |||||
| boolean deleted; | |||||
| try { | |||||
| deleted = zlmApiClient.delStreamProxy(aggStreamId); | |||||
| } catch (Exception e) { | |||||
| log.warn("delete expired agg stream failed, stream={} err={}", aggStreamId, e.getMessage()); | |||||
| continue; | |||||
| } | |||||
| if (!deleted) { | |||||
| try { | |||||
| if (zlmApiClient.streamExists(aggStreamId)) { | |||||
| continue; | |||||
| } | |||||
| } catch (Exception e) { | |||||
| log.warn("check expired agg stream failed, stream={} err={}", aggStreamId, e.getMessage()); | |||||
| continue; | |||||
| } | |||||
| } | |||||
| RealtimeStreamBinding binding = streamRegistry.removeByAggStreamId(aggStreamId); | |||||
| viewerRegistry.clear(aggStreamId); | |||||
| RealtimeStreamBinding target = binding != null ? binding : existing; | |||||
| if (target == null) { | |||||
| continue; | |||||
| } | |||||
| try { | |||||
| AggStation station = stationRegistry.requireByCode(target.getStationCode()); | |||||
| stopStationQuietly(station, target, target.getCameraCode(), target.getStreamType(), null); | |||||
| log.info("realtime viewer tickets expired, close stream={}", aggStreamId); | |||||
| } catch (Exception e) { | |||||
| log.warn("stop expired station stream failed, stream={} err={}", aggStreamId, e.getMessage()); | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| private RealtimeStreamBinding startStationStream(AggStation station, String cameraCode, | |||||
| String aggStreamId, Integer streamType) { | |||||
| JsonNode root = stationApiClient.fetchRealtimeStart(station, cameraCode, streamType); | |||||
| JsonNode payload = successPayload(root); | |||||
| String stationStreamId = textAnyDeep(payload, "streamId", "StreamID", "streamID", "id"); | |||||
| String sourceRtsp = textAnyDeep(payload, "RTSP", "rtspURL", "rtspUrl", "rtsp"); | |||||
| if (!StringUtils.hasText(sourceRtsp)) { | |||||
| String stationIp = extractIp(station.getBaseUrl()); | |||||
| if (!StringUtils.hasText(stationStreamId) || !StringUtils.hasText(stationIp)) { | |||||
| throw new IllegalStateException("站端未返回有效 streamId / RTSP 地址"); | |||||
| } | |||||
| sourceRtsp = String.format("rtsp://%s:8554/live/%s", stationIp, stationStreamId); | |||||
| } | |||||
| RealtimeStreamBinding binding = new RealtimeStreamBinding(); | |||||
| binding.setStationCode(station.getStationCode()); | |||||
| binding.setCameraCode(cameraCode); | |||||
| binding.setAggStreamId(aggStreamId); | |||||
| binding.setStationStreamId(stationStreamId); | |||||
| binding.setSourceRtsp(sourceRtsp); | |||||
| binding.setStreamType(streamType); | |||||
| binding.setStationManaged(true); | |||||
| return binding; | |||||
| } | |||||
| private RealtimeStreamBinding directBinding(String stationCode, String cameraCode, | |||||
| String aggStreamId, String rtspUrl, Integer streamType) { | |||||
| RealtimeStreamBinding binding = new RealtimeStreamBinding(); | |||||
| binding.setStationCode(stationCode); | |||||
| binding.setCameraCode(cameraCode); | |||||
| binding.setAggStreamId(aggStreamId); | |||||
| binding.setSourceRtsp(rtspUrl); | |||||
| binding.setStreamType(streamType); | |||||
| binding.setStationManaged(false); | |||||
| return binding; | |||||
| } | |||||
| /** | |||||
| * 服务重启后依据 ZLM 已有代理恢复内存绑定。 | |||||
| * 此时不掌握站端 streamId,stop 时仅靠 cameraCode 尽力通知站端停止。 | |||||
| */ | |||||
| private RealtimeStreamBinding recoveredBinding(String stationCode, String cameraCode, | |||||
| String aggStreamId, Integer streamType, String sourceRtsp) { | |||||
| RealtimeStreamBinding binding = new RealtimeStreamBinding(); | |||||
| binding.setStationCode(stationCode); | |||||
| binding.setCameraCode(cameraCode); | |||||
| binding.setAggStreamId(aggStreamId); | |||||
| binding.setSourceRtsp(sourceRtsp); | |||||
| binding.setStreamType(streamType); | |||||
| binding.setStationManaged(true); | |||||
| return binding; | |||||
| } | |||||
| private void stopStationQuietly(AggStation station, RealtimeStreamBinding binding, | |||||
| String fallbackCameraCode, Integer fallbackStreamType, | |||||
| String directRtsp) { | |||||
| if (binding != null && !binding.isStationManaged()) { | |||||
| return; | |||||
| } | |||||
| if (binding == null && StringUtils.hasText(directRtsp)) { | |||||
| return; | |||||
| } | |||||
| String cameraCode = binding != null && StringUtils.hasText(binding.getCameraCode()) | |||||
| ? binding.getCameraCode() : fallbackCameraCode; | |||||
| String stationStreamId = binding == null ? null : binding.getStationStreamId(); | |||||
| Integer streamType = binding != null && binding.getStreamType() != null | |||||
| ? binding.getStreamType() : fallbackStreamType; | |||||
| if (!StringUtils.hasText(cameraCode)) { | |||||
| return; | |||||
| } | |||||
| try { | |||||
| JsonNode root = stationApiClient.fetchRealtimeStop(station, stationStreamId, cameraCode, streamType); | |||||
| if (root.has("code") && root.path("code").asInt(-1) != 200) { | |||||
| log.warn("stop station realtime stream returned non-success, station={} camera={} resp={}", | |||||
| station.getStationCode(), cameraCode, root); | |||||
| } | |||||
| } catch (Exception e) { | |||||
| log.warn("stop station realtime stream failed, station={} camera={} stationStreamId={} err={}", | |||||
| station.getStationCode(), cameraCode, stationStreamId, e.getMessage()); | |||||
| } | |||||
| } | |||||
| private Object cameraLock(String stationCode, String cameraCode) { | |||||
| String key = RealtimeStreamRegistry.cameraKey(stationCode, cameraCode); | |||||
| return cameraLocks.computeIfAbsent(key, ignored -> new Object()); | |||||
| } | |||||
| private static JsonNode successPayload(JsonNode root) { | |||||
| if (root.has("code") && root.path("code").asInt(-1) != 200) { | |||||
| String message = root.path("msg").asText(root.path("message").asText("站端接口返回失败")); | |||||
| throw new IllegalStateException(message); | |||||
| } | |||||
| JsonNode payload = root.path("data"); | |||||
| return payload.isObject() ? payload : root; | |||||
| } | |||||
| private static String textAnyDeep(JsonNode node, String... fields) { | |||||
| String value = textAny(node, fields); | |||||
| if (!StringUtils.hasText(value) && node.path("data").isObject()) { | |||||
| value = textAny(node.path("data"), fields); | |||||
| } | |||||
| return value; | |||||
| } | |||||
| private static String textAny(JsonNode node, String... fields) { | |||||
| if (node == null || fields == null) { | |||||
| return null; | |||||
| } | |||||
| for (String field : fields) { | |||||
| String value = text(node, field); | |||||
| if (StringUtils.hasText(value)) { | |||||
| return value; | |||||
| } | |||||
| } | |||||
| return null; | |||||
| } | |||||
| private static String text(JsonNode node, String field) { | |||||
| JsonNode value = node == null ? null : node.get(field); | |||||
| return value == null || value.isNull() ? null : value.asText(); | |||||
| } | |||||
| /** 汇聚实时流 ID:站点 + 摄像机;去除 cameraCode 中 # 后的附属信息。 */ | |||||
| private static String aggStreamId(String stationCode, String cameraCode) { | |||||
| String base = cameraCode.contains("#") ? cameraCode.substring(0, cameraCode.indexOf('#')) : cameraCode; | |||||
| return (stationCode + "_" + base).replaceAll("[^A-Za-z0-9_]", "_"); | |||||
| } | |||||
| private static String extractIp(String urlString) { | |||||
| try { | |||||
| return new URL(urlString).getHost(); | |||||
| } catch (MalformedURLException e) { | |||||
| return null; | |||||
| } | |||||
| } | |||||
| @Data | |||||
| public static class StartResult { | |||||
| private final String streamId; | |||||
| private final boolean existing; | |||||
| public StartResult(String streamId, boolean existing) { | |||||
| this.streamId = streamId; | |||||
| this.existing = existing; | |||||
| } | |||||
| } | |||||
| } | |||||
| @ -1,105 +0,0 @@ | |||||
| package com.inspect.agg.video; | |||||
| import java.util.ArrayList; | |||||
| import java.util.List; | |||||
| import java.util.Map; | |||||
| import java.util.concurrent.ConcurrentHashMap; | |||||
| import org.springframework.stereotype.Component; | |||||
| /** | |||||
| * 实时流观看会话注册表:一个 streamId 对应多个观看票据。 | |||||
| * start 增加一个票据,stop 只减少一个票据,最后一个观看者退出时才允许删除 ZLM 流。 | |||||
| */ | |||||
| @Component | |||||
| public class RealtimeViewerRegistry { | |||||
| /** 观看票据过期时间;当前没有心跳接口,暂用 24 小时作为兜底清理。 */ | |||||
| private static final long TICKET_TTL_MILLIS = 24L * 60 * 60 * 1000; | |||||
| private final Map<String, List<Long>> tickets = new ConcurrentHashMap<>(); | |||||
| private final Map<String, Long> lastActivity = new ConcurrentHashMap<>(); | |||||
| public void add(String streamId) { | |||||
| tickets.compute(streamId, (key, current) -> { | |||||
| List<Long> list = current == null ? new ArrayList<>() : current; | |||||
| synchronized (list) { | |||||
| list.add(System.currentTimeMillis()); | |||||
| } | |||||
| return list; | |||||
| }); | |||||
| } | |||||
| /** | |||||
| * 移除一个观看票据。 | |||||
| * | |||||
| * @return -1 表示没有票据;0 表示本次移除后无人观看;大于 0 表示仍有 N 个观看者 | |||||
| */ | |||||
| public int remove(String streamId) { | |||||
| final int[] remaining = {-2}; | |||||
| tickets.compute(streamId, (key, current) -> { | |||||
| if (current == null || current.isEmpty()) { | |||||
| remaining[0] = -1; | |||||
| return null; | |||||
| } | |||||
| synchronized (current) { | |||||
| current.remove(current.size() - 1); | |||||
| if (current.isEmpty()) { | |||||
| remaining[0] = 0; | |||||
| lastActivity.remove(streamId); | |||||
| return null; | |||||
| } | |||||
| remaining[0] = current.size(); | |||||
| return current; | |||||
| } | |||||
| }); | |||||
| return remaining[0]; | |||||
| } | |||||
| public void touch(String streamId) { | |||||
| if (streamId != null) { | |||||
| lastActivity.put(streamId, System.currentTimeMillis()); | |||||
| } | |||||
| } | |||||
| public long lastActivityAge(String streamId, long now) { | |||||
| Long last = lastActivity.get(streamId); | |||||
| return last == null ? Long.MAX_VALUE : now - last; | |||||
| } | |||||
| public void clear(String streamId) { | |||||
| tickets.remove(streamId); | |||||
| lastActivity.remove(streamId); | |||||
| } | |||||
| public int count(String streamId) { | |||||
| List<Long> current = tickets.get(streamId); | |||||
| if (current == null) { | |||||
| return 0; | |||||
| } | |||||
| synchronized (current) { | |||||
| return current.size(); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * 清理过期票据,返回因此没有观看者的 streamId 列表。 | |||||
| */ | |||||
| public List<String> expire(long now) { | |||||
| List<String> closed = new ArrayList<>(); | |||||
| for (String streamId : new ArrayList<>(tickets.keySet())) { | |||||
| tickets.computeIfPresent(streamId, (key, current) -> { | |||||
| synchronized (current) { | |||||
| current.removeIf(ticketTime -> now - ticketTime > TICKET_TTL_MILLIS); | |||||
| if (current.isEmpty()) { | |||||
| closed.add(streamId); | |||||
| lastActivity.remove(streamId); | |||||
| return null; | |||||
| } | |||||
| return current; | |||||
| } | |||||
| }); | |||||
| } | |||||
| return closed; | |||||
| } | |||||
| } | |||||
| @ -1,18 +0,0 @@ | |||||
| package com.inspect.agg.video; | |||||
| import lombok.Data; | |||||
| /** | |||||
| * 站端转流信息(来自 /ivs/api/v1/stream/start)。 | |||||
| */ | |||||
| @Data | |||||
| public class StationStream { | |||||
| private String stationCode; | |||||
| /** 站端 StreamID */ | |||||
| private String stationStreamId; | |||||
| /** 站端 ZLM 的 HTTP-FLV / HLS 地址 */ | |||||
| private String flvUrl; | |||||
| private String hlsUrl; | |||||
| private long createdAt = System.currentTimeMillis(); | |||||
| } | |||||
| @ -1,35 +0,0 @@ | |||||
| package com.inspect.agg.video; | |||||
| import java.util.Map; | |||||
| import java.util.concurrent.ConcurrentHashMap; | |||||
| import org.springframework.stereotype.Component; | |||||
| import org.springframework.util.StringUtils; | |||||
| /** | |||||
| * 会话内的站端流信息注册表(内存,进程重启后失效,前端重新调用 /play 即可)。 | |||||
| */ | |||||
| @Component | |||||
| public class StationStreamRegistry { | |||||
| private final Map<String, StationStream> streams = new ConcurrentHashMap<>(); | |||||
| public static String key(String stationCode, String stationStreamId) { | |||||
| return stationCode + "/" + stationStreamId; | |||||
| } | |||||
| public void put(StationStream stream) { | |||||
| streams.put(key(stream.getStationCode(), stream.getStationStreamId()), stream); | |||||
| } | |||||
| public StationStream get(String stationCode, String streamId) { | |||||
| if (!StringUtils.hasText(stationCode) || !StringUtils.hasText(streamId)) { | |||||
| return null; | |||||
| } | |||||
| return streams.get(key(stationCode, streamId)); | |||||
| } | |||||
| public void remove(String stationCode, String streamId) { | |||||
| streams.remove(key(stationCode, streamId)); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,100 @@ | |||||
| package com.inspect.agg.video.direct; | |||||
| import java.util.ArrayList; | |||||
| import java.util.List; | |||||
| import java.util.Map; | |||||
| import java.util.concurrent.ConcurrentHashMap; | |||||
| import org.springframework.stereotype.Component; | |||||
| import org.springframework.util.StringUtils; | |||||
| import lombok.Data; | |||||
| /** | |||||
| * 站端直连实时流注册表:按站点 + 摄像机维护站端 streamId 和观看票据。 | |||||
| */ | |||||
| @Component | |||||
| public class StationDirectStreamRegistry { | |||||
| private static final long TICKET_TTL_MILLIS = 24L * 60 * 60 * 1000; | |||||
| private final Map<String, Session> sessions = new ConcurrentHashMap<>(); | |||||
| private final Map<String, Object> locks = new ConcurrentHashMap<>(); | |||||
| public static String cameraKey(String stationCode, String cameraCode) { | |||||
| String base = cameraCode; | |||||
| if (base != null && base.contains("#")) { | |||||
| base = base.substring(0, base.indexOf('#')); | |||||
| } | |||||
| return stationCode + "|" + base; | |||||
| } | |||||
| public Object lock(String stationCode, String cameraCode) { | |||||
| return locks.computeIfAbsent(cameraKey(stationCode, cameraCode), ignored -> new Object()); | |||||
| } | |||||
| public Session get(String stationCode, String cameraCode) { | |||||
| if (!StringUtils.hasText(stationCode) || !StringUtils.hasText(cameraCode)) { | |||||
| return null; | |||||
| } | |||||
| return sessions.get(cameraKey(stationCode, cameraCode)); | |||||
| } | |||||
| public Session create(String stationCode, String cameraCode, String stationStreamId, Integer streamType) { | |||||
| Session session = new Session(); | |||||
| session.setStationCode(stationCode); | |||||
| session.setCameraCode(cameraCode); | |||||
| session.setStationStreamId(stationStreamId); | |||||
| session.setStreamType(streamType); | |||||
| sessions.put(cameraKey(stationCode, cameraCode), session); | |||||
| return session; | |||||
| } | |||||
| public Session remove(String stationCode, String cameraCode) { | |||||
| return sessions.remove(cameraKey(stationCode, cameraCode)); | |||||
| } | |||||
| public List<Session> expiredSessions(long now) { | |||||
| List<Session> expired = new ArrayList<>(); | |||||
| for (Session session : new ArrayList<>(sessions.values())) { | |||||
| if (session.expireViewers(now) == 0) { | |||||
| expired.add(session); | |||||
| } | |||||
| } | |||||
| return expired; | |||||
| } | |||||
| @Data | |||||
| public static class Session { | |||||
| private String stationCode; | |||||
| private String cameraCode; | |||||
| private String stationStreamId; | |||||
| private String hlsPath; | |||||
| private String flvPath; | |||||
| private Integer streamType; | |||||
| private long createdAt = System.currentTimeMillis(); | |||||
| private final List<Long> viewers = new ArrayList<>(); | |||||
| public synchronized int addViewer() { | |||||
| viewers.add(System.currentTimeMillis()); | |||||
| return viewers.size(); | |||||
| } | |||||
| public synchronized int removeViewer() { | |||||
| if (viewers.isEmpty()) { | |||||
| return -1; | |||||
| } | |||||
| viewers.remove(viewers.size() - 1); | |||||
| return viewers.size(); | |||||
| } | |||||
| public synchronized int viewerCount() { | |||||
| return viewers.size(); | |||||
| } | |||||
| public synchronized int expireViewers(long now) { | |||||
| viewers.removeIf(ticketTime -> now - ticketTime > TICKET_TTL_MILLIS); | |||||
| return viewers.size(); | |||||
| } | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,314 @@ | |||||
| package com.inspect.agg.video.direct; | |||||
| import java.net.HttpURLConnection; | |||||
| import java.net.URI; | |||||
| import java.net.URL; | |||||
| import java.util.Map; | |||||
| import java.util.concurrent.ConcurrentHashMap; | |||||
| import org.springframework.stereotype.Component; | |||||
| import org.springframework.util.StringUtils; | |||||
| import org.springframework.web.context.request.RequestContextHolder; | |||||
| import org.springframework.web.context.request.ServletRequestAttributes; | |||||
| import javax.servlet.http.HttpServletRequest; | |||||
| import com.fasterxml.jackson.databind.JsonNode; | |||||
| import com.inspect.agg.config.AggProperties; | |||||
| import com.inspect.agg.dto.VideoRequest; | |||||
| import com.inspect.agg.entity.AggStation; | |||||
| import com.inspect.agg.service.StationRegistry; | |||||
| import com.inspect.agg.station.StationApiClient; | |||||
| import com.inspect.agg.video.direct.StationDirectStreamRegistry.Session; | |||||
| import lombok.Data; | |||||
| import lombok.RequiredArgsConstructor; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| /** | |||||
| * 站端直连实时流:启流和停流都调用站端业务接口,媒体流经集控 nginx 反代站端 ZLM。 | |||||
| */ | |||||
| @Slf4j | |||||
| @Component | |||||
| @RequiredArgsConstructor | |||||
| public class StationDirectStreamService { | |||||
| private final StationRegistry stationRegistry; | |||||
| private final StationApiClient stationApiClient; | |||||
| private final StationDirectStreamRegistry streamRegistry; | |||||
| private final AggProperties props; | |||||
| private final Map<String, Object> cleanupLocks = new ConcurrentHashMap<>(); | |||||
| private static final int PROBE_CONNECT_TIMEOUT_MS = 2000; | |||||
| private static final int PROBE_READ_TIMEOUT_MS = 2000; | |||||
| public StartResult start(String stationCode, VideoRequest request) { | |||||
| if (request == null | |||||
| || !StringUtils.hasText(stationCode) | |||||
| || !StringUtils.hasText(request.getCameraCode())) { | |||||
| throw new IllegalArgumentException("stationCode / cameraCode 不能为空"); | |||||
| } | |||||
| String cameraCode = request.getCameraCode(); | |||||
| Integer streamType = request.getStreamType() == null ? 2 : request.getStreamType(); | |||||
| AggStation station = stationRegistry.requireByCode(stationCode); | |||||
| synchronized (streamRegistry.lock(stationCode, cameraCode)) { | |||||
| Session existing = streamRegistry.get(stationCode, cameraCode); | |||||
| if (existing != null) { | |||||
| if (isStreamLive(existing)) { | |||||
| existing.addViewer(); | |||||
| return result(existing, true); | |||||
| } | |||||
| log.warn("cached station stream offline, drop session and restart: station={} camera={} streamId={}", | |||||
| stationCode, cameraCode, existing.getStationStreamId()); | |||||
| streamRegistry.remove(stationCode, cameraCode); | |||||
| } | |||||
| JsonNode root = stationApiClient.fetchRealtimeStart(station, cameraCode, streamType); | |||||
| log.info("root: {}", root); | |||||
| JsonNode payload = successPayload(root); | |||||
| String stationStreamId = textAnyDeep(payload, "streamId", "StreamID", "streamID", "id"); | |||||
| String stationHlsUrl = textAnyDeep(payload, "hlsUrl", "hlsURL", "hls", "HLS"); | |||||
| String stationFlvUrl = textAnyDeep(payload, "flvUrl", "flvURL", "flv", "FLV"); | |||||
| if (!StringUtils.hasText(stationStreamId)) { | |||||
| stationStreamId = extractStreamId(stationHlsUrl, stationFlvUrl, | |||||
| textAnyDeep(payload, "rtspUrl", "rtspURL", "RTSP")); | |||||
| } | |||||
| if (!StringUtils.hasText(stationStreamId)) { | |||||
| throw new IllegalStateException("站端未返回有效 streamId"); | |||||
| } | |||||
| Session session = streamRegistry.create(stationCode, cameraCode, stationStreamId, streamType); | |||||
| session.setHlsPath(mediaPath(stationHlsUrl, "/live/" + stationStreamId + "/hls.m3u8")); | |||||
| session.setFlvPath(mediaPath(stationFlvUrl, "/live/" + stationStreamId + ".live.flv")); | |||||
| session.addViewer(); | |||||
| return result(session, false); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * @return -1 没有本地观看票据但已按 cameraCode 尽力停止;0 最后一个观看者已停止;正数为剩余观看者数量 | |||||
| */ | |||||
| public int stop(String stationCode, VideoRequest request) { | |||||
| if (request == null | |||||
| || !StringUtils.hasText(stationCode) | |||||
| || !StringUtils.hasText(request.getCameraCode())) { | |||||
| throw new IllegalArgumentException("stationCode / cameraCode 不能为空"); | |||||
| } | |||||
| String cameraCode = request.getCameraCode(); | |||||
| Integer streamType = request.getStreamType() == null ? 2 : request.getStreamType(); | |||||
| AggStation station = stationRegistry.requireByCode(stationCode); | |||||
| synchronized (streamRegistry.lock(stationCode, cameraCode)) { | |||||
| Session session = streamRegistry.get(stationCode, cameraCode); | |||||
| if (session == null) { | |||||
| stopStation(station, null, cameraCode, streamType); | |||||
| return -1; | |||||
| } | |||||
| int remaining = session.removeViewer(); | |||||
| if (remaining > 0) { | |||||
| return remaining; | |||||
| } | |||||
| stopStation(station, session.getStationStreamId(), cameraCode, session.getStreamType()); | |||||
| streamRegistry.remove(stationCode, cameraCode); | |||||
| return 0; | |||||
| } | |||||
| } | |||||
| /** 定时清理超过 24 小时未刷新的观看票据;无剩余观看者时通知站端停流。 */ | |||||
| public void cleanupExpired() { | |||||
| long now = System.currentTimeMillis(); | |||||
| for (Session session : streamRegistry.expiredSessions(now)) { | |||||
| String key = StationDirectStreamRegistry.cameraKey(session.getStationCode(), session.getCameraCode()); | |||||
| synchronized (cleanupLocks.computeIfAbsent(key, ignored -> new Object())) { | |||||
| try { | |||||
| if (session.expireViewers(now) > 0) { | |||||
| continue; | |||||
| } | |||||
| AggStation station = stationRegistry.requireByCode(session.getStationCode()); | |||||
| stopStation(station, session.getStationStreamId(), session.getCameraCode(), session.getStreamType()); | |||||
| streamRegistry.remove(session.getStationCode(), session.getCameraCode()); | |||||
| log.info("station direct realtime tickets expired, close stream: station={} camera={} streamId={}", | |||||
| session.getStationCode(), session.getCameraCode(), session.getStationStreamId()); | |||||
| } catch (Exception e) { | |||||
| log.warn("cleanup expired station direct stream failed, station={} camera={} streamId={} err={}", | |||||
| session.getStationCode(), session.getCameraCode(), session.getStationStreamId(), e.getMessage()); | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| private void stopStation(AggStation station, String stationStreamId, String cameraCode, Integer streamType) { | |||||
| JsonNode root = stationApiClient.fetchRealtimeStop(station, stationStreamId, cameraCode, streamType); | |||||
| successPayload(root); | |||||
| } | |||||
| private StartResult result(Session session, boolean existing) { | |||||
| String hlsUrl = mediaBase(session.getStationCode()) + session.getHlsPath() | |||||
| + (session.getHlsPath().contains("?") ? "&" : "?") + "_ts=" + System.currentTimeMillis(); | |||||
| String flvUrl = mediaBase(session.getStationCode()) + session.getFlvPath(); | |||||
| return new StartResult(session.getStationStreamId(), existing, hlsUrl, flvUrl); | |||||
| } | |||||
| private String mediaBase(String stationCode) { | |||||
| String configured = props.getVideo().getNginxBaseUrl(); | |||||
| String base = StringUtils.hasText(configured) | |||||
| ? configured.trim().replaceAll("/+$", "") | |||||
| : currentRequestOrigin(); | |||||
| return base + "/video/" + stationCode; | |||||
| } | |||||
| /** | |||||
| * Probe the HLS playlist through the aggregation nginx: ZLM returns 200 while the | |||||
| * upstream stream is online and 404 when the stream does not exist. Any error is | |||||
| * treated as offline so the caller falls back to a fresh station start. | |||||
| */ | |||||
| private boolean isStreamLive(Session session) { | |||||
| String probeUrl = mediaBase(session.getStationCode()) + session.getHlsPath(); | |||||
| HttpURLConnection connection = null; | |||||
| try { | |||||
| connection = (HttpURLConnection) new URL(probeUrl).openConnection(); | |||||
| connection.setRequestMethod("GET"); | |||||
| connection.setConnectTimeout(PROBE_CONNECT_TIMEOUT_MS); | |||||
| connection.setReadTimeout(PROBE_READ_TIMEOUT_MS); | |||||
| connection.setInstanceFollowRedirects(false); | |||||
| connection.setRequestProperty("Connection", "close"); | |||||
| int status = connection.getResponseCode(); | |||||
| boolean live = status >= 200 && status < 300; | |||||
| log.info("station stream liveness probe: station={} streamId={} status={} live={}", | |||||
| session.getStationCode(), session.getStationStreamId(), status, live); | |||||
| return live; | |||||
| } catch (Exception e) { | |||||
| log.warn("station stream liveness probe failed, treat as offline: station={} streamId={} error={}", | |||||
| session.getStationCode(), session.getStationStreamId(), e.getMessage()); | |||||
| return false; | |||||
| } finally { | |||||
| if (connection != null) { | |||||
| connection.disconnect(); | |||||
| } | |||||
| } | |||||
| } | |||||
| private String currentRequestOrigin() { | |||||
| try { | |||||
| ServletRequestAttributes attributes = | |||||
| (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); | |||||
| if (attributes == null) { | |||||
| return ""; | |||||
| } | |||||
| HttpServletRequest request = attributes.getRequest(); | |||||
| String forwardedHost = firstForwardedValue(request.getHeader("X-Forwarded-Host")); | |||||
| String host = StringUtils.hasText(forwardedHost) | |||||
| ? forwardedHost | |||||
| : request.getHeader("Host"); | |||||
| if (!StringUtils.hasText(host)) { | |||||
| host = request.getServerName(); | |||||
| int port = request.getServerPort(); | |||||
| if (port > 0 && port != 80 && port != 443) { | |||||
| host = host + ":" + port; | |||||
| } | |||||
| } | |||||
| String forwardedProto = firstForwardedValue(request.getHeader("X-Forwarded-Proto")); | |||||
| String scheme = StringUtils.hasText(forwardedProto) | |||||
| ? forwardedProto | |||||
| : request.getScheme(); | |||||
| return scheme + "://" + host; | |||||
| } catch (Exception e) { | |||||
| log.warn("resolve current media origin failed, fallback to relative url: {}", e.getMessage()); | |||||
| return ""; | |||||
| } | |||||
| } | |||||
| private static String firstForwardedValue(String headerValue) { | |||||
| if (!StringUtils.hasText(headerValue)) { | |||||
| return null; | |||||
| } | |||||
| return headerValue.split(",")[0].trim(); | |||||
| } | |||||
| private static String mediaPath(String stationMediaUrl, String fallback) { | |||||
| if (StringUtils.hasText(stationMediaUrl)) { | |||||
| try { | |||||
| String path = URI.create(stationMediaUrl.trim()).getPath(); | |||||
| if (StringUtils.hasText(path) && path.startsWith("/live/")) { | |||||
| return path; | |||||
| } | |||||
| } catch (Exception ignored) { | |||||
| // 使用站端标准路径兜底 | |||||
| } | |||||
| } | |||||
| return fallback; | |||||
| } | |||||
| private JsonNode successPayload(JsonNode root) { | |||||
| if (root.path("code").asInt(-1) != 200) { | |||||
| String message = root.path("msg").asText(root.path("message").asText("站端接口返回失败")); | |||||
| throw new IllegalStateException(message); | |||||
| } | |||||
| JsonNode payload = root.path("data"); | |||||
| return payload.isObject() ? payload : root; | |||||
| } | |||||
| private static String textAnyDeep(JsonNode node, String... fields) { | |||||
| String value = textAny(node, fields); | |||||
| if (!StringUtils.hasText(value) && node.path("data").isObject()) { | |||||
| value = textAny(node.path("data"), fields); | |||||
| } | |||||
| return value; | |||||
| } | |||||
| private static String textAny(JsonNode node, String... fields) { | |||||
| if (node == null || fields == null) { | |||||
| return null; | |||||
| } | |||||
| for (String field : fields) { | |||||
| JsonNode value = node.get(field); | |||||
| if (value != null && !value.isNull() && StringUtils.hasText(value.asText())) { | |||||
| return value.asText(); | |||||
| } | |||||
| } | |||||
| return null; | |||||
| } | |||||
| private static String extractStreamId(String... mediaUrls) { | |||||
| for (String mediaUrl : mediaUrls) { | |||||
| if (!StringUtils.hasText(mediaUrl)) { | |||||
| continue; | |||||
| } | |||||
| try { | |||||
| String path = URI.create(mediaUrl.trim()).getPath(); | |||||
| int marker = path.indexOf("/live/"); | |||||
| if (marker < 0) { | |||||
| continue; | |||||
| } | |||||
| String id = path.substring(marker + "/live/".length()).replaceAll("/+$", ""); | |||||
| if (id.endsWith("/hls.m3u8")) { | |||||
| id = id.substring(0, id.length() - "/hls.m3u8".length()); | |||||
| } else { | |||||
| id = id.replaceAll("\\.m3u8$", "").replaceAll("\\.live\\.flv$", ""); | |||||
| } | |||||
| if (id.contains("/")) { | |||||
| id = id.substring(0, id.indexOf('/')); | |||||
| } | |||||
| if (StringUtils.hasText(id)) { | |||||
| return id; | |||||
| } | |||||
| } catch (Exception ignored) { | |||||
| // 继续尝试其他地址 | |||||
| } | |||||
| } | |||||
| return null; | |||||
| } | |||||
| @Data | |||||
| public static class StartResult { | |||||
| private final String streamId; | |||||
| private final boolean existing; | |||||
| private final String hlsUrl; | |||||
| private final String flvUrl; | |||||
| } | |||||
| } | |||||
| @ -1,358 +0,0 @@ | |||||
| package com.inspect.agg.web; | |||||
| import java.io.InputStream; | |||||
| import java.io.OutputStream; | |||||
| import java.net.HttpURLConnection; | |||||
| import java.net.URI; | |||||
| import java.net.URL; | |||||
| import java.net.URLEncoder; | |||||
| import java.nio.charset.StandardCharsets; | |||||
| import java.util.LinkedHashMap; | |||||
| import java.util.Map; | |||||
| import java.util.regex.Matcher; | |||||
| import java.util.regex.Pattern; | |||||
| import org.springframework.http.HttpHeaders; | |||||
| import org.springframework.http.MediaType; | |||||
| import org.springframework.http.ResponseEntity; | |||||
| import org.springframework.util.StringUtils; | |||||
| import org.springframework.web.bind.annotation.GetMapping; | |||||
| import org.springframework.web.bind.annotation.RequestMapping; | |||||
| import org.springframework.web.bind.annotation.RequestParam; | |||||
| import org.springframework.web.bind.annotation.RestController; | |||||
| import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; | |||||
| import com.fasterxml.jackson.databind.JsonNode; | |||||
| import com.inspect.agg.config.AggProperties; | |||||
| import com.inspect.agg.entity.AggStation; | |||||
| import com.inspect.agg.service.StationRegistry; | |||||
| import com.inspect.agg.station.StationApiClient; | |||||
| import com.inspect.agg.video.StationStream; | |||||
| import com.inspect.agg.video.StationStreamRegistry; | |||||
| import lombok.RequiredArgsConstructor; | |||||
| import lombok.extern.slf4j.Slf4j; | |||||
| /** | |||||
| * 方案 B:集控纯 HTTP 反向代理(不拉流、不转码)。 | |||||
| * 办公端只访问集控地址;集控把站端 HLS/FLV 流式转发,并重写 m3u8 中的分片地址。 | |||||
| * | |||||
| * GET /agg/video/play?stationCode=&cameraCode= 启动站端转流并返回集控播放地址 | |||||
| * GET /agg/video/flv?stationCode=&streamId= HTTP-FLV 流式代理 | |||||
| * GET /agg/video/hls?stationCode=&streamId= HLS 清单(重写分片地址) | |||||
| * GET /agg/video/segment?stationCode=&streamId=&u= HLS 分片流式代理 | |||||
| * GET /agg/video/proxy/stop?stationCode=&streamId= 停止站端转流 | |||||
| */ | |||||
| @Slf4j | |||||
| @RestController | |||||
| @RequestMapping("/agg/video/stream") | |||||
| @RequiredArgsConstructor | |||||
| public class VideoProxyController { | |||||
| private static final Pattern TAG_URI = Pattern.compile("URI=\"([^\"]+)\""); | |||||
| private final StationRegistry stationRegistry; | |||||
| private final StationApiClient stationApiClient; | |||||
| private final StationStreamRegistry streamRegistry; | |||||
| private final AggProperties props; | |||||
| /** 启动播放:调站端 /v1/stream/start,返回经集控代理的 HLS/FLV 地址 */ | |||||
| @GetMapping("/play") | |||||
| public Map<String, Object> play(@RequestParam String stationCode, | |||||
| @RequestParam String cameraCode) { | |||||
| if (!StringUtils.hasText(stationCode) || !StringUtils.hasText(cameraCode)) { | |||||
| return error(400, "stationCode / cameraCode 不能为空"); | |||||
| } | |||||
| AggStation station; | |||||
| try { | |||||
| station = stationRegistry.requireByCode(stationCode); | |||||
| } catch (IllegalArgumentException e) { | |||||
| return error(404, e.getMessage()); | |||||
| } | |||||
| try { | |||||
| JsonNode root = stationApiClient.fetchStreamStart(station, mainCode(cameraCode), 2); | |||||
| if (!StringUtils.hasText(text(root, "rtspURL")) && !StringUtils.hasText(text(root, "FLV")) | |||||
| && !StringUtils.hasText(text(root, "HLS"))) { | |||||
| root = stationApiClient.fetchStreamStart(station, cameraCode, 2); | |||||
| } | |||||
| String stationStreamId = text(root, "StreamID"); | |||||
| String flv = text(root, "FLV"); | |||||
| String hls = text(root, "HLS"); | |||||
| if (!StringUtils.hasText(stationStreamId)) { | |||||
| return error(502, "站端未返回 StreamID: " + root); | |||||
| } | |||||
| StationStream stream = new StationStream(); | |||||
| stream.setStationCode(stationCode); | |||||
| stream.setStationStreamId(stationStreamId); | |||||
| stream.setFlvUrl(flv); | |||||
| stream.setHlsUrl(hls); | |||||
| streamRegistry.put(stream); | |||||
| Map<String, Object> data = new LinkedHashMap<>(); | |||||
| data.put("stationCode", stationCode); | |||||
| data.put("cameraCode", cameraCode); | |||||
| data.put("streamId", stationStreamId); | |||||
| data.put("mode", "HTTP_PROXY"); | |||||
| if (StringUtils.hasText(hls)) { | |||||
| data.put("hls", "/api/agg/video/hls?stationCode=" + enc(stationCode) + "&streamId=" + enc(stationStreamId)); | |||||
| } | |||||
| if (StringUtils.hasText(flv)) { | |||||
| data.put("flv", "/api/agg/video/flv?stationCode=" + enc(stationCode) + "&streamId=" + enc(stationStreamId)); | |||||
| } | |||||
| Map<String, Object> m = new LinkedHashMap<>(); | |||||
| m.put("code", 200); | |||||
| m.put("data", data); | |||||
| return m; | |||||
| } catch (Exception e) { | |||||
| log.error("video play failed: station={} camera={} err={}", stationCode, cameraCode, e.getMessage()); | |||||
| return error(502, "启动视频流失败: " + e.getMessage()); | |||||
| } | |||||
| } | |||||
| /** 停止站端转流 */ | |||||
| @GetMapping("/stop") | |||||
| public Map<String, Object> stop(@RequestParam String stationCode, @RequestParam String streamId) { | |||||
| AggStation station; | |||||
| try { | |||||
| station = stationRegistry.requireByCode(stationCode); | |||||
| } catch (IllegalArgumentException e) { | |||||
| return error(404, e.getMessage()); | |||||
| } | |||||
| try { | |||||
| stationApiClient.fetchStreamStop(station, streamId); | |||||
| streamRegistry.remove(stationCode, streamId); | |||||
| Map<String, Object> m = new LinkedHashMap<>(); | |||||
| m.put("code", 200); | |||||
| m.put("data", true); | |||||
| return m; | |||||
| } catch (Exception e) { | |||||
| return error(502, "停止视频流失败: " + e.getMessage()); | |||||
| } | |||||
| } | |||||
| /** HTTP-FLV 流式代理(不缓冲,边收边发) */ | |||||
| @GetMapping("/flv") | |||||
| public ResponseEntity<StreamingResponseBody> flv(@RequestParam String stationCode, | |||||
| @RequestParam String streamId) { | |||||
| StationStream stream = streamRegistry.get(stationCode, streamId); | |||||
| if (stream == null || !StringUtils.hasText(stream.getFlvUrl())) { | |||||
| return ResponseEntity.status(404).build(); | |||||
| } | |||||
| return streaming(stream.getFlvUrl(), "video/x-flv"); | |||||
| } | |||||
| /** HLS 清单:拉取站端 m3u8 并重写分片地址到集控代理 */ | |||||
| @GetMapping("/hls") | |||||
| public ResponseEntity<byte[]> hls(@RequestParam String stationCode, @RequestParam String streamId) { | |||||
| StationStream stream = streamRegistry.get(stationCode, streamId); | |||||
| if (stream == null || !StringUtils.hasText(stream.getHlsUrl())) { | |||||
| return ResponseEntity.status(404).build(); | |||||
| } | |||||
| try { | |||||
| PlaylistResult pr = readHlsWithFallback(stream.getHlsUrl()); | |||||
| if (!pr.url.equals(stream.getHlsUrl())) { | |||||
| // 记录实际生效的清单地址,后续分片按它的目录解析 | |||||
| stream.setHlsUrl(pr.url); | |||||
| } | |||||
| String rewritten = rewritePlaylist(pr.content, stationCode, streamId, pr.url); | |||||
| return ResponseEntity.ok() | |||||
| .header(HttpHeaders.CONTENT_TYPE, "application/vnd.apple.mpegurl") | |||||
| .header(HttpHeaders.CACHE_CONTROL, "no-store") | |||||
| .body(rewritten.getBytes(StandardCharsets.UTF_8)); | |||||
| } catch (Exception e) { | |||||
| log.error("hls proxy failed: stream={} err={}", streamId, e.getMessage()); | |||||
| return ResponseEntity.status(502).build(); | |||||
| } | |||||
| } | |||||
| /** HLS 分片流式代理 */ | |||||
| @GetMapping("/segment") | |||||
| public ResponseEntity<StreamingResponseBody> segment(@RequestParam String stationCode, | |||||
| @RequestParam String streamId, | |||||
| @RequestParam("u") String uri) { | |||||
| StationStream stream = streamRegistry.get(stationCode, streamId); | |||||
| if (stream == null) { | |||||
| return ResponseEntity.status(404).build(); | |||||
| } | |||||
| String target = resolve(stream.getHlsUrl(), uri); | |||||
| if (target == null) { | |||||
| return ResponseEntity.status(403).build(); | |||||
| } | |||||
| return streaming(target, null); | |||||
| } | |||||
| /** 把 m3u8 里所有 URI(分片/密钥/初始化段)改写成集控代理地址 */ | |||||
| private String rewritePlaylist(String playlist, String stationCode, String streamId, String hlsUrl) { | |||||
| StringBuilder sb = new StringBuilder(playlist.length() + 256); | |||||
| for (String line : playlist.split("\n")) { | |||||
| String trimmed = line.trim(); | |||||
| if (trimmed.isEmpty()) { | |||||
| continue; | |||||
| } | |||||
| if (trimmed.startsWith("#")) { | |||||
| Matcher matcher = TAG_URI.matcher(trimmed); | |||||
| if (matcher.find()) { | |||||
| String rewritten = proxyUri(stationCode, streamId, matcher.group(1)); | |||||
| sb.append(trimmed, 0, matcher.start(1)).append(rewritten).append(trimmed.substring(matcher.end(1))); | |||||
| } else { | |||||
| sb.append(trimmed); | |||||
| } | |||||
| } else { | |||||
| sb.append(proxyUri(stationCode, streamId, trimmed)); | |||||
| } | |||||
| sb.append('\n'); | |||||
| } | |||||
| return sb.toString(); | |||||
| } | |||||
| private String proxyUri(String stationCode, String streamId, String uri) { | |||||
| return "/api/agg/video/segment?stationCode=" + enc(stationCode) | |||||
| + "&streamId=" + enc(streamId) + "&u=" + enc(uri); | |||||
| } | |||||
| /** 解析分片绝对地址(仅允许与站端 HLS 同 host:port,避免开放代理) */ | |||||
| private String resolve(String hlsUrl, String uri) { | |||||
| try { | |||||
| URI base = URI.create(hlsUrl); | |||||
| String baseDir = hlsUrl.substring(0, hlsUrl.lastIndexOf('/') + 1); | |||||
| String target = uri.startsWith("http://") || uri.startsWith("https://") ? uri | |||||
| : uri.startsWith("/") ? base.getScheme() + "://" + base.getAuthority() + uri | |||||
| : baseDir + uri; | |||||
| URI t = URI.create(target); | |||||
| if (!base.getAuthority().equalsIgnoreCase(t.getAuthority())) { | |||||
| log.warn("拒绝跨主机分片代理: {}", target); | |||||
| return null; | |||||
| } | |||||
| return target; | |||||
| } catch (Exception e) { | |||||
| return null; | |||||
| } | |||||
| } | |||||
| /** 流式转发:不缓冲,边读边写 */ | |||||
| private ResponseEntity<StreamingResponseBody> streaming(String url, String contentType) { | |||||
| StreamingResponseBody body = outputStream -> { | |||||
| HttpURLConnection conn = null; | |||||
| try { | |||||
| conn = (HttpURLConnection) new URL(url).openConnection(); | |||||
| conn.setConnectTimeout(props.getHttp().getConnectTimeoutMs()); | |||||
| conn.setReadTimeout(props.getHttp().getReadTimeoutMs()); | |||||
| conn.setInstanceFollowRedirects(true); | |||||
| conn.setRequestProperty("User-Agent", "inspect-aggregation-proxy"); | |||||
| if (StringUtils.hasText(contentType)) { | |||||
| conn.setRequestProperty(HttpHeaders.ACCEPT, contentType); | |||||
| } | |||||
| try (InputStream in = conn.getInputStream()) { | |||||
| copy(in, outputStream); | |||||
| } | |||||
| } catch (Exception e) { | |||||
| log.warn("stream proxy interrupted: url={} err={}", url, e.getMessage()); | |||||
| } finally { | |||||
| if (conn != null) { | |||||
| conn.disconnect(); | |||||
| } | |||||
| } | |||||
| }; | |||||
| ResponseEntity.BodyBuilder builder = ResponseEntity.ok() | |||||
| .header(HttpHeaders.CACHE_CONTROL, "no-store") | |||||
| .header("X-Accel-Buffering", "no"); | |||||
| builder.header(HttpHeaders.CONTENT_TYPE, | |||||
| StringUtils.hasText(contentType) ? contentType : MediaType.APPLICATION_OCTET_STREAM_VALUE); | |||||
| return builder.body(body); | |||||
| } | |||||
| /** | |||||
| * 兼容站端 HLS 路径风格差异:站端 start 接口可能返回 /live/{id}.m3u8(旧), | |||||
| * 实际可用为 /live/{id}/hls.m3u8,反之亦然。 | |||||
| */ | |||||
| private PlaylistResult readHlsWithFallback(String hlsUrl) throws Exception { | |||||
| try { | |||||
| return new PlaylistResult(readString(hlsUrl), hlsUrl); | |||||
| } catch (Exception first) { | |||||
| for (String alt : hlsAlternatives(hlsUrl)) { | |||||
| try { | |||||
| log.info("HLS 主地址不可用,尝试备用地址: {}", alt); | |||||
| return new PlaylistResult(readString(alt), alt); | |||||
| } catch (Exception ignore) { | |||||
| // 继续尝试 | |||||
| } | |||||
| } | |||||
| throw first; | |||||
| } | |||||
| } | |||||
| /** 清单内容 + 实际生效的清单地址 */ | |||||
| private static final class PlaylistResult { | |||||
| private final String content; | |||||
| private final String url; | |||||
| PlaylistResult(String content, String url) { | |||||
| this.content = content; | |||||
| this.url = url; | |||||
| } | |||||
| } | |||||
| private static java.util.List<String> hlsAlternatives(String hlsUrl) { | |||||
| java.util.List<String> list = new java.util.ArrayList<>(); | |||||
| if (hlsUrl == null) { | |||||
| return list; | |||||
| } | |||||
| if (hlsUrl.endsWith("/hls.m3u8")) { | |||||
| // /live/{id}/hls.m3u8 -> /live/{id}.m3u8 | |||||
| String base = hlsUrl.substring(0, hlsUrl.length() - "/hls.m3u8".length()); | |||||
| list.add(base + ".m3u8"); | |||||
| } else if (hlsUrl.endsWith(".m3u8")) { | |||||
| // /live/{id}.m3u8 -> /live/{id}/hls.m3u8 | |||||
| String base = hlsUrl.substring(0, hlsUrl.length() - ".m3u8".length()); | |||||
| list.add(base + "/hls.m3u8"); | |||||
| } | |||||
| return list; | |||||
| } | |||||
| private String readString(String url) throws Exception { | |||||
| HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); | |||||
| conn.setConnectTimeout(props.getHttp().getConnectTimeoutMs()); | |||||
| conn.setReadTimeout(props.getHttp().getReadTimeoutMs()); | |||||
| conn.setRequestProperty("User-Agent", "inspect-aggregation-proxy"); | |||||
| try (InputStream in = conn.getInputStream()) { | |||||
| java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); | |||||
| copy(in, bos); | |||||
| return new String(bos.toByteArray(), StandardCharsets.UTF_8); | |||||
| } finally { | |||||
| conn.disconnect(); | |||||
| } | |||||
| } | |||||
| private static void copy(InputStream in, OutputStream out) throws Exception { | |||||
| byte[] buf = new byte[8192]; | |||||
| int len; | |||||
| while ((len = in.read(buf)) != -1) { | |||||
| out.write(buf, 0, len); | |||||
| out.flush(); | |||||
| } | |||||
| } | |||||
| private static String mainCode(String cameraCode) { | |||||
| return cameraCode.contains("#") ? cameraCode.substring(0, cameraCode.indexOf('#')) : cameraCode; | |||||
| } | |||||
| private static String enc(String s) { | |||||
| try { | |||||
| return URLEncoder.encode(s, "UTF-8"); | |||||
| } catch (Exception e) { | |||||
| return s; | |||||
| } | |||||
| } | |||||
| private static String text(JsonNode node, String field) { | |||||
| JsonNode v = node == null ? null : node.get(field); | |||||
| return v == null || v.isNull() ? null : v.asText(); | |||||
| } | |||||
| private static Map<String, Object> error(int code, String msg) { | |||||
| Map<String, Object> m = new LinkedHashMap<>(); | |||||
| m.put("code", code); | |||||
| m.put("msg", msg); | |||||
| return m; | |||||
| } | |||||
| } | |||||