diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index e27a4ce..9b0cd21 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -155,7 +155,7 @@ spring.data: # 数据库索引 database: 0 # redis 密码必须配置 - password: 123456 + #password: 123456 # 连接超时时间 timeout: 10s # 是否开启ssl @@ -215,15 +215,11 @@ gmail: port: 465 auth: true # 发信账号(Gmail 地址),由环境变量注入 - user: ${GMAIL_USER:huzuming2@gmail.com} -# user: ${GMAIL_USER:} -# user: "chinarzcui@gmail.com" + user: ${GMAIL_USER:} # 发信凭据:Gmail 16 位「应用专用密码」(非登录密码),由环境变量注入 - pass: ${GMAIL_PASS:lajl mccm ycqi uekj} -# pass: ${GMAIL_PASS:} + pass: ${GMAIL_PASS:} # 发件人,留空回落到 user;Gmail 要求发件人与认证账号一致 - from: ${GMAIL_FROM:huzuming2@gmail.com} -# from: ${GMAIL_FROM:} + from: ${GMAIL_FROM:} sslEnable: true starttlsEnable: false # smtp.gmail.com 在国内机房通常不可达,快速失败避免挂死请求线程 @@ -239,21 +235,6 @@ device: # 是否仅打印日志而不真实发信:开发环境默认 true,便于在无 SMTP 条件下联调。 # 【生产环境(prod)必须为 false】——否则应用拒绝启动,防止验证码明文写入日志。 mock: true - # 谷歌邮箱 Gmail SMTP - host: ${DEVICE_APP_EMAIL_HOST:smtp.gmail.com} - # 465 走 SSL;若改用 587 端口,请同时设 ssl-enable=false 且 starttls-enable=true - port: ${DEVICE_APP_EMAIL_PORT:465} - # 发信账号(Gmail 地址)。凭据一律由环境变量注入,禁止写入本文件 - user: ${DEVICE_APP_EMAIL_USER:} - # 发信凭据:Gmail 的 16 位「应用专用密码」(非账号登录密码),由环境变量注入 - pass: ${DEVICE_APP_EMAIL_PASS:} - # 发件人,留空则回落到 user。Gmail 要求发件人与认证账号一致,否则拒收 - from: ${DEVICE_APP_EMAIL_FROM:} - ssl-enable: true - starttls-enable: false - # smtp.gmail.com 在国内机房通常不可达,必须快速失败,避免挂死 HTTP 请求线程 - connection-timeout: 5000 - timeout: 10000 # 验证码位数 / 有效期(分钟)/ 重发间隔(秒) code-length: 6 expiry-minutes: 5 @@ -261,6 +242,15 @@ device: # 允许注册的邮箱域名白名单,留空(或不配)表示不限制域名。 # 若产品要求「只允许谷歌邮箱注册」,取消下行注释即可,无需改代码: # allowed-domains: gmail.com,googlemail.com + sms: + # 开发默认 mock;真发国际/国内短信时置 false,并配好 sms.blends 与模板 id + mock: true + blend: config1 + template-id: ${DEVICE_APP_SMS_TEMPLATE_ID:} + +mjpeg: + # 开发环境默认使用局域网地址;生产环境由 MJPEG_PUBLIC_HOST 显式注入。 + public-host: ${MJPEG_PUBLIC_HOST:http://192.168.1.217:8081} --- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商 # https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用 diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml index cf737f7..ac5ddba 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -218,12 +218,11 @@ gmail: port: 465 auth: true # 发信账号(Gmail 地址),由环境变量注入 - user: ${GMAIL_USER:huzuming2@gmail.com} - # user: "chinarzcui@gmail.com" + user: ${GMAIL_USER:} # 发信凭据:Gmail 16 位「应用专用密码」(非登录密码),由环境变量注入 - pass: ${GMAIL_PASS:qbjo lajl mccm ycqi uekj} + pass: ${GMAIL_PASS:} # 发件人,留空回落到 user;Gmail 要求发件人与认证账号一致 - from: ${GMAIL_FROM:huzuming2@gmail.com} + from: ${GMAIL_FROM:} sslEnable: true starttlsEnable: false # smtp.gmail.com 在国内机房通常不可达,快速失败避免挂死请求线程 @@ -239,22 +238,6 @@ device: # 【必须为 false】生产环境开启 mock 会把邮箱验证码明文写入日志, # 此时 org.dromara.device.config.AppEmailProdGuard 将直接拒绝启动(属预期保护)。 mock: false - # 谷歌邮箱 Gmail SMTP - host: ${DEVICE_APP_EMAIL_HOST:smtp.gmail.com} - # 465 走 SSL;若改用 587 端口,请同时设 ssl-enable=false 且 starttls-enable=true - port: ${DEVICE_APP_EMAIL_PORT:465} - # 发信账号(Gmail 地址)。凭据一律由环境变量注入,禁止写入本文件 - user: ${DEVICE_APP_EMAIL_USER:} - # 发信凭据:Gmail 的 16 位「应用专用密码」(非账号登录密码),由环境变量注入 - pass: ${DEVICE_APP_EMAIL_PASS:} - # 发件人,留空则回落到 user。Gmail 要求发件人与认证账号一致,否则拒收 - from: ${DEVICE_APP_EMAIL_FROM:} - ssl-enable: true - starttls-enable: false - # smtp.gmail.com 在国内机房通常不可达:若部署在国内,请评估改用国内 SMTP, - # 或确认服务器具备出海能力后再上线邮箱注册功能。 - connection-timeout: 5000 - timeout: 10000 # 验证码位数 / 有效期(分钟)/ 重发间隔(秒) code-length: 6 expiry-minutes: 5 @@ -262,6 +245,23 @@ device: # 允许注册的邮箱域名白名单,留空(或不配)表示不限制域名。 # 若产品要求「只允许谷歌邮箱注册」,取消下行注释即可,无需改代码: # allowed-domains: gmail.com,googlemail.com + sms: + mock: false + blend: config1 + template-id: ${DEVICE_APP_SMS_TEMPLATE_ID:} + +mjpeg: + # 生产环境不提供私网默认值,必须注入可达地址。 + public-host: ${MJPEG_PUBLIC_HOST:} + +--- # 阿里云国际短信(与 application-dev.yml 同通道) +aliyun: + international-sms: + enabled: true + access-key-id: ${ALIYUN_INTL_SMS_AK:} + access-key-secret: ${ALIYUN_INTL_SMS_SK:} + endpoint: dysmsapi.ap-southeast-1.aliyuncs.com + sender-id: '' --- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商 # https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用 diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 5760717..bdddb99 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -25,6 +25,14 @@ zlm: push-protocol: rtmp play-expire-seconds: 120 stream-timeout: 30 # 流超时秒数 +mjpeg: + enabled: true + # 生产环境必须显式注入可被设备与客户端访问的地址,未配置时接口会拒绝生成播放地址。 + public-host: ${MJPEG_PUBLIC_HOST:} + fps: 5 + frame-ttl-seconds: 8 + idle-stop-seconds: 30 + max-frame-bytes: 1048576 captcha: # 是否启用验证码校验 enable: true @@ -77,9 +85,12 @@ spring: servlet: multipart: # 单个文件大小 - max-file-size: 10MB - # 设置总上传的文件大小 - max-request-size: 20MB + # APP 客户端安装包(apk)体积通常数十 MB,10MB 会被 Spring 直接拒绝,故放大到 200MB。 + # 注意:若 application-dev.yml / -prod.yml / -local.yml 另配了同名项会**就近覆盖**本值, + # 修改本项后须同步确认这三份 profile 配置无覆盖(当前均无覆盖,仅 prod 配了 multipart.location)。 + max-file-size: 200MB + # 设置总上传的文件大小(= 单文件上限 + form 其它字段冗余) + max-request-size: 210MB mvc: # 设置静态资源路径 防止所有请求都去查静态资源 static-path-pattern: /static/** @@ -122,6 +133,7 @@ security: - /snail-chat/** - /api/snail/chat/** - /zlm/hook/** + - /mjpeg/** # MyBatisPlus配置 # https://baomidou.com/config/ @@ -328,6 +340,9 @@ device.mqtt: low-battery-latch-ttl: 30d # 告警去重窗口:同一 设备+告警类型 在窗口内只落一条 bm_device_alert alert-dedup-window: 30m + # APP 告警消息写入失败后的 outbox 补偿周期与单轮上限 + app-message-retry-interval: 1m + app-message-retry-batch-size: 100 # 眼罩指令回执等待超时(协议 Q3 待硬件确认动作到位时长) hood-timeout: 5s # 待回执指令标记的 Redis TTL(仅用于事后定位「回执晚到」) diff --git a/ruoyi-admin/src/main/resources/i18n/messages.properties b/ruoyi-admin/src/main/resources/i18n/messages.properties index d4120c7..1d0467b 100644 --- a/ruoyi-admin/src/main/resources/i18n/messages.properties +++ b/ruoyi-admin/src/main/resources/i18n/messages.properties @@ -48,6 +48,15 @@ rate.limiter.message=访问过于频繁,请稍候再试 sms.code.not.blank=短信验证码不能为空 sms.code.retry.limit.count=短信验证码输入错误{0}次 sms.code.retry.limit.exceed=短信验证码输入错误{0}次,账户锁定{1}分钟 +sms.code.format.invalid=验证码格式不正确 +app.sms.scene.not.blank=验证码场景不能为空 +app.sms.scene.invalid=验证码场景不合法 +app.sms.send.too.frequent=验证码发送过于频繁,请稍后再试 +app.sms.send.failed=验证码短信发送失败 +app.sms.code.sent=验证码已发送 +app.sms.verify.too.many=验证码错误次数过多,请稍后再试 +app.sms.code.error=验证码错误 +app.sms.code.expired=验证码已过期 email.code.not.blank=邮箱验证码不能为空 email.code.retry.limit.count=邮箱验证码输入错误{0}次 email.code.retry.limit.exceed=邮箱验证码输入错误{0}次,账户锁定{1}分钟 diff --git a/ruoyi-admin/src/main/resources/i18n/messages_en_US.properties b/ruoyi-admin/src/main/resources/i18n/messages_en_US.properties index f627198..0d18b12 100644 --- a/ruoyi-admin/src/main/resources/i18n/messages_en_US.properties +++ b/ruoyi-admin/src/main/resources/i18n/messages_en_US.properties @@ -48,6 +48,15 @@ rate.limiter.message=Visit too frequently, please try again later sms.code.not.blank=Sms code cannot be blank sms.code.retry.limit.count=Sms code input error {0} times sms.code.retry.limit.exceed=Sms code input error {0} times, account locked for {1} minutes +sms.code.format.invalid=Invalid sms code format +app.sms.scene.not.blank=Sms scene cannot be blank +app.sms.scene.invalid=Invalid sms scene +app.sms.send.too.frequent=Verification code sent too frequently, please try again later +app.sms.send.failed=Failed to send sms verification code +app.sms.code.sent=Verification code sent +app.sms.verify.too.many=Too many incorrect attempts, please try again later +app.sms.code.error=Incorrect verification code +app.sms.code.expired=Verification code expired email.code.not.blank=Email code cannot be blank email.code.retry.limit.count=Email code input error {0} times email.code.retry.limit.exceed=Email code input error {0} times, account locked for {1} minutes diff --git a/ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties b/ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties index d4120c7..1d0467b 100644 --- a/ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties +++ b/ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties @@ -48,6 +48,15 @@ rate.limiter.message=访问过于频繁,请稍候再试 sms.code.not.blank=短信验证码不能为空 sms.code.retry.limit.count=短信验证码输入错误{0}次 sms.code.retry.limit.exceed=短信验证码输入错误{0}次,账户锁定{1}分钟 +sms.code.format.invalid=验证码格式不正确 +app.sms.scene.not.blank=验证码场景不能为空 +app.sms.scene.invalid=验证码场景不合法 +app.sms.send.too.frequent=验证码发送过于频繁,请稍后再试 +app.sms.send.failed=验证码短信发送失败 +app.sms.code.sent=验证码已发送 +app.sms.verify.too.many=验证码错误次数过多,请稍后再试 +app.sms.code.error=验证码错误 +app.sms.code.expired=验证码已过期 email.code.not.blank=邮箱验证码不能为空 email.code.retry.limit.count=邮箱验证码输入错误{0}次 email.code.retry.limit.exceed=邮箱验证码输入错误{0}次,账户锁定{1}分钟 diff --git a/ruoyi-api/src/main/java/org/dromara/system/api/OssService.java b/ruoyi-api/src/main/java/org/dromara/system/api/OssService.java index a09e5fd..f0e541e 100644 --- a/ruoyi-api/src/main/java/org/dromara/system/api/OssService.java +++ b/ruoyi-api/src/main/java/org/dromara/system/api/OssService.java @@ -2,6 +2,7 @@ package org.dromara.system.api; import org.dromara.system.api.domain.OssDTO; +import java.time.Duration; import java.util.List; /** @@ -26,4 +27,16 @@ public interface OssService { * @return 列表 */ List selectByIds(String ossIds); + + /** + * 根据 OSS 主键生成可直接下载的地址。 + *

+ * 公有桶返回稳定地址;私有桶按调用方给定的有效期重新签名,避免把上传接口返回的 + * 短期签名地址持久化后继续下发。 + * + * @param ossId OSS 主键 + * @param validTime 私有桶签名有效期 + * @return 下载地址;记录不存在时返回 {@code null} + */ + String selectDownloadUrlById(Long ossId, Duration validTime); } diff --git a/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/core/AliyunInternationalSmsService.java b/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/core/AliyunInternationalSmsService.java index 948c9ae..ec478ea 100644 --- a/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/core/AliyunInternationalSmsService.java +++ b/ruoyi-common/ruoyi-common-sms/src/main/java/org/dromara/common/sms/core/AliyunInternationalSmsService.java @@ -8,6 +8,7 @@ import com.aliyun.teautil.models.RuntimeOptions; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.utils.DesensitizedUtils; import org.dromara.common.sms.config.AliyunInternationalSmsProperties; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -33,7 +34,9 @@ public class AliyunInternationalSmsService { * @return 发送结果 */ public SendMessageToGlobeResponseBody sendMessageToGlobe(String to, String message) { - log.info("国际短信发送开始, 手机号={}, message={}", to, message); + String maskedTo = DesensitizedUtils.maskHighSecurity(to, 3, 2); + // 国际短信正文包含验证码,日志只保留脱敏后的目标号码,不记录验证码。 + log.info("国际短信发送开始, 手机号={}", maskedTo); SendMessageToGlobeRequest request = new SendMessageToGlobeRequest() .setTo(to) .setMessage("您的验证码为:" + message + ",请勿泄露于他人!"); @@ -45,15 +48,19 @@ public class AliyunInternationalSmsService { .sendMessageToGlobeWithOptions(request, new RuntimeOptions()); SendMessageToGlobeResponseBody body = response.getBody(); if (body == null || !"OK".equalsIgnoreCase(body.getResponseCode())) { - log.error("国际短信发送失败, to={}, body={}", to, body); + log.error("国际短信发送失败, to={}, responseCode={}", maskedTo, + body == null ? null : body.getResponseCode()); throw new ServiceException("国际短信发送失败:" + (body == null ? "无响应" : body.getResponseCode())); } return body; } catch (ServiceException e) { throw e; } catch (Exception e) { - log.error("国际短信发送异常, to={}", to, e); - throw new ServiceException("国际短信发送异常:" + e.getMessage()); + // 供应商异常可能回显请求参数,日志不输出异常原文或堆栈。 + log.error("国际短信发送异常, to={}, error={}", + maskedTo, e.getClass().getSimpleName()); + // 底层异常可能回显请求参数,不向客户端透传供应商原文。 + throw new ServiceException("国际短信发送异常"); } } diff --git a/ruoyi-modules/ruoyi-device/pom.xml b/ruoyi-modules/ruoyi-device/pom.xml index eeefd68..2c789e7 100644 --- a/ruoyi-modules/ruoyi-device/pom.xml +++ b/ruoyi-modules/ruoyi-device/pom.xml @@ -113,6 +113,13 @@ ruoyi-common-mail + + + org.springframework.boot + spring-boot-starter-test + test + + diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/AppEmailProdGuard.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/AppEmailProdGuard.java index 0ff6361..bb0115f 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/AppEmailProdGuard.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/config/AppEmailProdGuard.java @@ -2,6 +2,7 @@ package org.dromara.device.config; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.dromara.common.mail.config.properties.GmailProperties; import org.springframework.beans.factory.InitializingBean; import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; @@ -10,7 +11,7 @@ import org.springframework.stereotype.Component; * APP 邮箱验证码生产环境守卫(口径镜像 {@link AppSmsProdGuard})。 *

* 背景:{@code device.app.email.mock} 默认 {@code true},会把邮箱验证码明文写入应用日志。 - * 开发/联调期这是便利(SMTP 凭据与出海网络尚未就绪),但生产环境误开 mock 等价于把验证码落盘。 + * 开发/联调期这是便利(Gmail 凭据与出海网络尚未就绪),但生产环境误开 mock 等价于把验证码落盘。 *

* 策略: *

* @@ -40,6 +41,7 @@ import java.util.LinkedHashMap; public class AppSmsService { private final AppProperties appProperties; + private final AliyunInternationalSmsService aliyunInternationalSmsService; /** * 生成并按场景发送短信验证码(含 60 秒重发限流)。 @@ -50,15 +52,16 @@ public class AppSmsService { */ public String sendCode(String phoneNumber, String scene) { if (!SmsSceneEnum.isValid(scene)) { - throw new ServiceException(AppMsgConstants.SMS_SCENE_INVALID); + throw new ServiceException(MessageUtils.message("app.sms.scene.invalid")); } + phoneNumber = AppPhoneNumbers.canonical(phoneNumber); AppProperties.Sms sms = appProperties.getSms(); // 60 秒重发限流:利用「不存在才写入」的原子性,写入成功即取得发送资格 String limitKey = AppConstants.SMS_LIMIT_KEY_PREFIX + scene + ":" + phoneNumber; boolean acquired = RedisUtils.setObjectIfAbsent(limitKey, System.currentTimeMillis(), Duration.ofSeconds(sms.getResendSeconds())); if (!acquired) { - throw new ServiceException(AppMsgConstants.SMS_SEND_TOO_FREQUENT); + throw new ServiceException(MessageUtils.message("app.sms.send.too.frequent")); } String code = RandomUtil.randomNumbers(sms.getCodeLength()); // 先落库缓存,再发送:即使发送异常,限流 key 已存在,避免短信通道故障时的刷量 @@ -88,10 +91,11 @@ public class AppSmsService { * @param code 用户输入的验证码 */ public void verifyCode(String phoneNumber, String scene, String code) { + phoneNumber = AppPhoneNumbers.canonical(phoneNumber); String codeKey = AppConstants.SMS_CODE_KEY_PREFIX + scene + ":" + phoneNumber; String cached = RedisUtils.getCacheObject(codeKey); if (StringUtils.isBlank(cached)) { - throw new ServiceException(DeviceMsgConstants.SMS_CODE_EXPIRED); + throw new ServiceException(MessageUtils.message("app.sms.code.expired")); } // ① 先判「正确」:正确码永远放行(在限速之前),故攻击者无法阻断受害者; // 一次性使用,成功后清零失败计数。 @@ -108,38 +112,51 @@ public class AppSmsService { RedisUtils.expire(failKey, Duration.ofSeconds(AppConstants.SMS_VERIFY_FAIL_WINDOW_SECONDS)); } if (attempts > AppConstants.SMS_MAX_VERIFY_FAIL) { - throw new ServiceException(AppMsgConstants.SMS_VERIFY_TOO_MANY); + throw new ServiceException(MessageUtils.message("app.sms.verify.too.many")); } - throw new ServiceException(DeviceMsgConstants.SMS_CODE_ERROR); + throw new ServiceException(MessageUtils.message("app.sms.code.error")); } /** - * 实际发送验证码:mock 模式打印日志,否则走 sms4j。 + * 实际发送验证码:mock 打日志;国内 sms4j;国际走阿里云 {@code SendMessageToGlobe}。 * * @param phoneNumber 手机号 * @param code 验证码 */ private void send(String phoneNumber, String code) { AppProperties.Sms sms = appProperties.getSms(); + boolean china = AppPhoneNumbers.chinaMainland(phoneNumber); + String to = AppPhoneNumbers.forSms(phoneNumber); if (sms.isMock()) { - log.info("[APP短信-模拟发送] 手机号={},验证码={},有效期={}分钟(开发环境未接入短信通道,Q5)。", - phoneNumber, code, sms.getExpiryMinutes()); + log.info("[APP短信-模拟发送] 手机号={},通道号码={},{},验证码={},有效期={}分钟。", + DesensitizedUtils.maskHighSecurity(phoneNumber, 3, 2), + DesensitizedUtils.maskHighSecurity(to, 3, 2), + china ? "国内" : "国际", code, sms.getExpiryMinutes()); + return; + } + if (!china) { + aliyunInternationalSmsService.sendMessageToGlobe(to, code); return; } try { SmsBlend smsBlend = SmsFactory.getSmsBlend(sms.getBlend()); LinkedHashMap params = new LinkedHashMap<>(1); params.put("code", code); - SmsResponse response = smsBlend.sendMessage(phoneNumber, sms.getTemplateId(), params); + SmsResponse response = smsBlend.sendMessage(to, sms.getTemplateId(), params); if (!response.isSuccess()) { - log.error("APP 短信验证码发送失败 phone={} response={}", phoneNumber, response); - throw new ServiceException(AppMsgConstants.SMS_SEND_FAILED); + // 通道响应可能包含目标号码或请求参数,日志仅保留脱敏号码和配置标识。 + log.error("验证码短信发送失败 phone={} configId={}", + DesensitizedUtils.maskHighSecurity(phoneNumber, 3, 2), response.getConfigId()); + throw new ServiceException(MessageUtils.message("app.sms.send.failed")); } } catch (ServiceException e) { throw e; } catch (Exception e) { - log.error("APP 短信验证码发送异常 phone={}", phoneNumber, e); - throw new ServiceException(AppMsgConstants.SMS_SEND_FAILED); + // 通道异常可能携带短信请求参数,日志仅保留异常类型。 + log.error("APP 短信验证码发送异常 phone={}, error={}", + DesensitizedUtils.maskHighSecurity(phoneNumber, 3, 2), + e.getClass().getSimpleName()); + throw new ServiceException(MessageUtils.message("app.sms.send.failed")); } } diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/stream/MjpegStreamBackend.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/stream/MjpegStreamBackend.java new file mode 100644 index 0000000..f3625b9 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/stream/MjpegStreamBackend.java @@ -0,0 +1,120 @@ +package org.dromara.device.stream; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.device.config.MjpegProperties; +import org.dromara.device.config.ZlmProperties; +import org.dromara.device.constant.DeviceMsgConstants; +import org.dromara.device.domain.BmDevice; +import org.dromara.device.domain.vo.AppStreamPlayUrlsVo; +import org.dromara.device.mjpeg.MjpegFrameHub; +import org.dromara.device.mjpeg.MjpegIngestTokenService; +import org.dromara.device.mjpeg.MjpegLifecycleCoordinator; +import org.dromara.device.mqtt.DeviceCommandPublisher; +import org.dromara.device.mqtt.payload.StreamCommandPayload; +import org.springframework.stereotype.Component; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * HTTP MJPEG 直播 adapter。 + * + * @author Kou + * @date 2026-09-18 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MjpegStreamBackend implements StreamBackend { + + private final MjpegProperties properties; + private final ZlmProperties zlmProperties; + private final MjpegFrameHub frameHub; + private final MjpegIngestTokenService ingestTokenService; + private final MjpegLifecycleCoordinator lifecycleCoordinator; + private final DeviceCommandPublisher commandPublisher; + + @Override + public void start(BmDevice device, String streamId) { + lifecycleCoordinator.execute(streamId, () -> startLocked(device, streamId)); + } + + /** + * 在设备生命周期锁内执行开播,防止旧会话回收误伤本次新会话。 + */ + private void startLocked(BmDevice device, String streamId) { + if (online(streamId)) { + return; + } + // 先校验公网地址,避免配置缺失时留下无效 token 或空会话。 + String ingestUrl = buildIngestUrl(streamId, null); + String ingestToken = ingestTokenService.issue(streamId); + frameHub.open(streamId, ingestToken); + StreamCommandPayload payload = StreamCommandPayload.start( + DeviceCommandPublisher.newMsgId(), streamId, zlmProperties.getApp(), "mjpeg", + appendToken(ingestUrl, ingestToken)); + if (!commandPublisher.publishStream(device.getDeviceNo(), payload)) { + // 指令发送失败时回滚本地状态,避免空会话触发延迟停推。 + ingestTokenService.revoke(streamId); + frameHub.clear(streamId); + throw new ServiceException(DeviceMsgConstants.COMMAND_SEND_FAILED); + } + log.info("[MJPEG] 已通知设备开始推流 deviceNo={}", device.getDeviceNo()); + } + + @Override + public void stop(BmDevice device, String streamId) { + lifecycleCoordinator.execute(streamId, () -> { + StreamCommandPayload payload = StreamCommandPayload.stop( + DeviceCommandPublisher.newMsgId(), streamId, zlmProperties.getApp()); + commandPublisher.publishStream(device.getDeviceNo(), payload); + closeLocked(streamId); + }); + } + + @Override + public void close(String streamId) { + lifecycleCoordinator.execute(streamId, () -> closeLocked(streamId)); + } + + /** + * 清理当前设备的推流凭证和内存会话,调用方必须持有生命周期锁。 + */ + private void closeLocked(String streamId) { + ingestTokenService.revoke(streamId); + frameHub.clear(streamId); + } + + @Override + public boolean online(String streamId) { + return frameHub.online(streamId, properties.getFrameTtlSeconds()); + } + + @Override + public Endpoint endpoint(String streamId, String playToken) { + AppStreamPlayUrlsVo urls = new AppStreamPlayUrlsVo(); + urls.setMjpeg(appendToken(publicBase() + "/mjpeg/play/" + streamId, playToken)); + return new Endpoint(buildIngestUrl(streamId, null), urls); + } + + private String buildIngestUrl(String streamId, String ingestToken) { + String url = publicBase() + "/mjpeg/ingest/" + streamId; + return ingestToken == null ? url : appendToken(url, ingestToken); + } + + private String publicBase() { + String host = properties.getPublicHost(); + if (host == null || host.isBlank()) { + throw new ServiceException(DeviceMsgConstants.MJPEG_NOT_CONFIGURED); + } + return host.endsWith("/") ? host.substring(0, host.length() - 1) : host; + } + + private String appendToken(String url, String token) { + String separator = url.contains("?") ? "&" : "?"; + return url + separator + "token=" + URLEncoder.encode(token, StandardCharsets.UTF_8); + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/stream/StreamBackend.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/stream/StreamBackend.java new file mode 100644 index 0000000..5115a22 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/stream/StreamBackend.java @@ -0,0 +1,63 @@ +package org.dromara.device.stream; + +import org.dromara.device.domain.BmDevice; +import org.dromara.device.domain.vo.AppStreamPlayUrlsVo; + +/** + * 直播传输后端接口,统一封装协议差异、设备指令与资源清理。 + * + * @author Kou + * @date 2026-09-18 + */ +public interface StreamBackend { + + /** + * 流不在线时通知设备开始推流。 + * + * @param device 设备 + * @param streamId 流标识 + */ + void start(BmDevice device, String streamId); + + /** + * 通知设备停止推流并清理服务端资源。 + * + * @param device 设备 + * @param streamId 流标识 + */ + void stop(BmDevice device, String streamId); + + /** + * 仅清理服务端资源,不通知设备。 + * + * @param streamId 流标识 + */ + void close(String streamId); + + /** + * 查询流是否在线。 + * + * @param streamId 流标识 + * @return 是否在线 + */ + boolean online(String streamId); + + /** + * 生成推流入口和带播放 token 的播放地址。 + * + * @param streamId 流标识 + * @param playToken 播放 token + * @return 地址信息 + */ + Endpoint endpoint(String streamId, String playToken); + + /** + * 直播地址集合。 + * + * @param pushUrl 设备推流入口(不含内部 ingest token) + * @param playUrls 客户端播放地址 + */ + record Endpoint(String pushUrl, AppStreamPlayUrlsVo playUrls) { + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/stream/ZlmStreamBackend.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/stream/ZlmStreamBackend.java new file mode 100644 index 0000000..bbfa447 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/stream/ZlmStreamBackend.java @@ -0,0 +1,131 @@ +package org.dromara.device.stream; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.device.config.ZlmProperties; +import org.dromara.device.constant.DeviceMsgConstants; +import org.dromara.device.domain.BmDevice; +import org.dromara.device.domain.vo.AppStreamPlayUrlsVo; +import org.dromara.device.mqtt.DeviceCommandPublisher; +import org.dromara.device.mqtt.payload.StreamCommandPayload; +import org.dromara.device.zlm.stream.ZlmClient; +import org.springframework.stereotype.Component; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * ZLMediaKit 直播 adapter。 + * + * @author Kou + * @date 2026-09-18 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ZlmStreamBackend implements StreamBackend { + + private final ZlmClient zlmClient; + private final ZlmProperties properties; + private final DeviceCommandPublisher commandPublisher; + + @Override + public void start(BmDevice device, String streamId) { + if (online(streamId)) { + return; + } + StreamCommandPayload payload = StreamCommandPayload.start( + DeviceCommandPublisher.newMsgId(), streamId, properties.getApp(), + properties.getPushProtocol(), buildPushUrl(streamId)); + if (!commandPublisher.publishStream(device.getDeviceNo(), payload)) { + throw new ServiceException(DeviceMsgConstants.COMMAND_SEND_FAILED); + } + log.info("[ZLM] 已通知设备开始推流 deviceNo={} streamId={}", device.getDeviceNo(), streamId); + } + + @Override + public void stop(BmDevice device, String streamId) { + StreamCommandPayload payload = StreamCommandPayload.stop( + DeviceCommandPublisher.newMsgId(), streamId, properties.getApp()); + commandPublisher.publishStream(device.getDeviceNo(), payload); + zlmClient.closeStreams(streamId); + } + + @Override + public void close(String streamId) { + zlmClient.delStreamProxy(streamId); + zlmClient.closeStreams(streamId); + } + + @Override + public boolean online(String streamId) { + return zlmClient.isStreamOnline(streamId); + } + + @Override + public Endpoint endpoint(String streamId, String playToken) { + String base = trimTrailingSlash(properties.getHost()) + "/" + properties.getApp() + "/" + streamId; + AppStreamPlayUrlsVo urls = new AppStreamPlayUrlsVo(); + urls.setFlv(appendToken(base + ".live.flv", playToken)); + urls.setHls(appendToken(base + "/hls.m3u8", playToken)); + urls.setRtmp(appendToken(buildRtmpUrl(streamId), playToken)); + urls.setRtsp(appendToken(buildRtspUrl(streamId), playToken)); + urls.setWebrtc(appendToken(buildWebrtcUrl(streamId), playToken)); + return new Endpoint(buildPushUrl(streamId), urls); + } + + private String buildPushUrl(String streamId) { + return "rtsp".equalsIgnoreCase(properties.getPushProtocol()) + ? buildRtspUrl(streamId) : buildRtmpUrl(streamId); + } + + private String buildRtmpUrl(String streamId) { + return toProtocol(resolveProtocolHost(properties.getRtmpHost(), properties.getHost(), "1935"), "rtmp") + + "/" + properties.getApp() + "/" + streamId; + } + + private String buildRtspUrl(String streamId) { + return toProtocol(resolveProtocolHost(properties.getRtspHost(), properties.getHost(), "554"), "rtsp") + + "/" + properties.getApp() + "/" + streamId; + } + + private String buildWebrtcUrl(String streamId) { + return toProtocol(resolveProtocolHost(properties.getWebrtcHost(), properties.getHost(), null), "webrtc") + + "/" + properties.getApp() + "/" + streamId; + } + + private String toProtocol(String host, String protocol) { + String url = trimTrailingSlash(host); + int index = url.indexOf("://"); + return index > -1 ? protocol + url.substring(index) : protocol + "://" + url; + } + + private String appendToken(String url, String token) { + String separator = url.contains("?") ? "&" : "?"; + return url + separator + "token=" + URLEncoder.encode(token, StandardCharsets.UTF_8); + } + + private String trimTrailingSlash(String value) { + if (value == null || value.isBlank()) { + throw new ServiceException(DeviceMsgConstants.ZLM_NOT_CONFIGURED); + } + return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; + } + + private String resolveProtocolHost(String configuredHost, String fallbackHost, String defaultPort) { + if (configuredHost != null && !configuredHost.isBlank()) { + return configuredHost; + } + String host = trimTrailingSlash(fallbackHost); + int protocolIndex = host.indexOf("://"); + String protocol = protocolIndex > -1 ? host.substring(0, protocolIndex + 3) : ""; + String rest = protocolIndex > -1 ? host.substring(protocolIndex + 3) : host; + int slashIndex = rest.indexOf('/'); + String authority = slashIndex > -1 ? rest.substring(0, slashIndex) : rest; + int colonIndex = authority.lastIndexOf(':'); + String hostname = colonIndex > -1 ? authority.substring(0, colonIndex) : authority; + return defaultPort == null ? protocol + authority : protocol + hostname + ":" + defaultPort; + } + +} diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/controller/AppStreamController.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/controller/AppStreamController.java index 07c6504..402ed5b 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/controller/AppStreamController.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/controller/AppStreamController.java @@ -73,6 +73,8 @@ public class AppStreamController extends BaseController { * @return 播放信息 */ @GetMapping("/play-url/{deviceNo}") + @RateLimiter(key = "@streamRateLimitKey.control(#deviceNo)", time = 60, count = 60, + limitType = LimitType.IP, message = DeviceMsgConstants.STREAM_TOO_FREQUENT) public R playUrl(@PathVariable String deviceNo) { return R.ok(appStreamService.playUrl(deviceNo, currentUserId())); } @@ -111,6 +113,8 @@ public class AppStreamController extends BaseController { * @return 状态 */ @GetMapping("/status/{deviceNo}") + @RateLimiter(key = "@streamRateLimitKey.control(#deviceNo)", time = 60, count = 60, + limitType = LimitType.IP, message = DeviceMsgConstants.STREAM_TOO_FREQUENT) public R status(@PathVariable String deviceNo) { return R.ok(appStreamService.status(deviceNo, currentUserId())); } diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/controller/ZlmHookController.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/controller/ZlmHookController.java index bbb8f8d..0badfef 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/controller/ZlmHookController.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/controller/ZlmHookController.java @@ -52,7 +52,7 @@ public class ZlmHookController { log.warn("[ZLM-HOOK] on_publish 未通过 secret 校验, IP={}", request.getRemoteAddr()); return reject("unauthorized"); } - log.info("[ZLM-HOOK] on_publish {}", body); + logHook("on_publish", body); return ok(); } @@ -69,7 +69,7 @@ public class ZlmHookController { log.warn("[ZLM-HOOK] on_play 未通过 secret 校验, IP={}", request.getRemoteAddr()); return reject("unauthorized"); } - log.info("[ZLM-HOOK] on_play {}", body); + logHook("on_play", body); String streamId = asString(body.get("stream")); String token = extractToken(body); String clientIp = asString(body.get("ip")); @@ -92,7 +92,7 @@ public class ZlmHookController { log.warn("[ZLM-HOOK] on_stream_changed 未通过 secret 校验, IP={}", request.getRemoteAddr()); return reject("unauthorized"); } - log.info("[ZLM-HOOK] on_stream_changed {}", body); + logHook("on_stream_changed", body); return ok(); } @@ -109,7 +109,7 @@ public class ZlmHookController { log.warn("[ZLM-HOOK] on_stream_none_reader 未通过 secret 校验, IP={}", request.getRemoteAddr()); return reject("unauthorized"); } - log.info("[ZLM-HOOK] on_stream_none_reader {}", body); + logHook("on_stream_none_reader", body); Object stream = body.get("stream"); if (stream instanceof String streamId && !streamId.isBlank()) { appStreamService.closeZlmStream(streamId); @@ -149,6 +149,18 @@ public class ZlmHookController { return false; } + /** + * WebHook 请求体含 secret、播放 token 等敏感字段,日志只记录排障所需的白名单字段。 + */ + private void logHook(String hookName, Map body) { + log.info("[ZLM-HOOK] {} app={} stream={} schema={} ip={}", + hookName, + asString(body.get("app")), + asString(body.get("stream")), + asString(body.get("schema")), + asString(body.get("ip"))); + } + private Map ok() { Map result = new LinkedHashMap<>(); result.put("code", 0); diff --git a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/stream/ZlmPlayAuthService.java b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/stream/ZlmPlayAuthService.java index bf077fb..5d38772 100644 --- a/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/stream/ZlmPlayAuthService.java +++ b/ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/stream/ZlmPlayAuthService.java @@ -76,7 +76,8 @@ public class ZlmPlayAuthService { Map parsed = JsonUtils.parseObject(json, Map.class); payload = parsed; } catch (Exception e) { - log.warn("[ZLM-AUTH] token payload 解析失败 token={}", token, e); + log.warn("[ZLM-AUTH] token payload 解析失败 streamId={}, error={}", + streamId, e.getClass().getSimpleName()); return false; } if (payload == null) { @@ -92,8 +93,7 @@ public class ZlmPlayAuthService { String issuedIp = Objects.toString(payload.get("clientIp"), ""); if (StringUtils.isNotBlank(issuedIp) && StringUtils.isNotBlank(clientIp) && !Objects.equals(issuedIp, clientIp)) { - log.warn("[ZLM-AUTH] ip 不匹配 tokenIp={} requestIp={} streamId={}", - issuedIp, clientIp, streamId); + log.warn("[ZLM-AUTH] ip 不匹配 streamId={}", streamId); return false; } return true; diff --git a/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmAppMessageMapper.xml b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmAppMessageMapper.xml new file mode 100644 index 0000000..f539ea9 --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmAppMessageMapper.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmAppVersionMapper.xml b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmAppVersionMapper.xml new file mode 100644 index 0000000..9b4c3cc --- /dev/null +++ b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmAppVersionMapper.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceAlertMapper.xml b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceAlertMapper.xml index 0c0d987..d565fa0 100644 --- a/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceAlertMapper.xml +++ b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceAlertMapper.xml @@ -4,6 +4,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> - + + diff --git a/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceMapper.xml b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceMapper.xml index 815c98b..692cd7e 100644 --- a/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceMapper.xml +++ b/ruoyi-modules/ruoyi-device/src/main/resources/mapper/device/BmDeviceMapper.xml @@ -36,10 +36,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 离线扫描候选:del_flag='0' + status='1' + 已有心跳且已超时。 刻意排除 last_online_time IS NULL:绑定成功即置「在线」但尚无心跳的设备不算超时, 否则用户刚添加设备就会被误判离线(详见 BmDeviceMapper#selectStaleOnlineDevices 注释)。 - 只取三列足够裁决,避免整行回传。 + 同时取 bind_user_id 作为告警发生时的消息收件人快照,避免告警落库后再次查询导致换绑串号。 -->