| @ -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; | |||
| } | |||
| } | |||