新视频推送接收(未测) app升级 告警 国际短信跟谷歌邮箱接入
This commit is contained in:
@@ -113,6 +113,13 @@
|
||||
<artifactId>ruoyi-common-mail</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 设备域回归测试 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -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})。
|
||||
* <p>
|
||||
* <b>背景</b>:{@code device.app.email.mock} 默认 {@code true},会把邮箱验证码<b>明文写入应用日志</b>。
|
||||
* 开发/联调期这是便利(SMTP 凭据与出海网络尚未就绪),但生产环境误开 mock 等价于把验证码落盘。
|
||||
* 开发/联调期这是便利(Gmail 凭据与出海网络尚未就绪),但生产环境误开 mock 等价于把验证码落盘。
|
||||
* <p>
|
||||
* <b>策略</b>:
|
||||
* <ul>
|
||||
@@ -34,10 +35,11 @@ public class AppEmailProdGuard implements InitializingBean {
|
||||
private static final String PROD_PROFILE = "prod";
|
||||
|
||||
private final AppProperties appProperties;
|
||||
private final GmailProperties gmailProperties;
|
||||
private final Environment environment;
|
||||
|
||||
/**
|
||||
* 校验「生产环境不得开启邮箱 mock」且「真实发信凭据已配置」,否则拒绝启动。
|
||||
* 校验「生产环境不得开启邮箱 mock」并检查 Gmail 真实发信配置。
|
||||
*/
|
||||
@Override
|
||||
public void afterPropertiesSet() {
|
||||
@@ -53,20 +55,20 @@ public class AppEmailProdGuard implements InitializingBean {
|
||||
+ "生产环境(prod)将因此拒绝启动。");
|
||||
return;
|
||||
}
|
||||
// 已关闭 mock(走真实 SMTP):此处**只告警不阻断启动**。
|
||||
// 已关闭 mock(走真实 Gmail):此处**只告警不阻断启动**。
|
||||
// 取舍说明:mock 开关误开会导致验证码明文落盘(安全风险)→ 必须拒绝启动;
|
||||
// 而凭据缺失只影响「邮件发不出去」这一个功能(可用性风险),若也拒绝启动,
|
||||
// 会让「邮箱功能未配置」升级为「整个服务起不来」,把一个局部问题放大成全站故障。
|
||||
// 故降级为醒目告警,把失败推迟到发信那一刻,并由 AppEmailService 返回受控文案。
|
||||
if (!email.isConfigured()) {
|
||||
log.warn("[APP邮箱] 已关闭 mock(要求真实发信)但发信账号或凭据未配置——"
|
||||
+ "邮箱验证码将无法送达!请通过环境变量 DEVICE_APP_EMAIL_USER / DEVICE_APP_EMAIL_PASS "
|
||||
+ "注入 Gmail 地址与 16 位应用专用密码。");
|
||||
if (!Boolean.TRUE.equals(gmailProperties.getEnabled()) || !gmailProperties.isConfigured()) {
|
||||
log.warn("[APP邮箱] 已关闭 mock(要求真实发信)但 Gmail 功能未启用或账号/凭据未配置——"
|
||||
+ "邮箱验证码将无法送达!请通过环境变量 GMAIL_USER / GMAIL_PASS 注入 Gmail 地址与 16 位应用专用密码。");
|
||||
return;
|
||||
}
|
||||
log.info("[APP邮箱] 真实发信已启用:host={}:{},from={},ssl={},starttls={},连接超时={}ms。",
|
||||
email.getHost(), email.getPort(), email.getFrom(), email.isSslEnable(),
|
||||
email.isStarttlsEnable(), email.getConnectionTimeout());
|
||||
log.info("[APP邮箱] Gmail 真实发信已启用:host={}:{},from={},ssl={},starttls={},连接超时={}ms。",
|
||||
gmailProperties.getHost(), gmailProperties.getPort(), gmailProperties.getFrom(),
|
||||
gmailProperties.getSslEnable(), gmailProperties.getStarttlsEnable(),
|
||||
gmailProperties.getConnectionTimeout());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.dromara.device.config;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -15,10 +14,10 @@ import java.util.List;
|
||||
* <p>
|
||||
* 全部字段均带默认值,未在 {@code application.yml} 显式配置时按默认值生效,因此本迭代无需改动任何 yml。
|
||||
* <p>
|
||||
* <b>客户端身份默认值口径</b>:与框架 {@code sys_client} 中 {@code client_key = 'app'} 的记录保持镜像
|
||||
* ({@code device_type = 'android'}、{@code access_path = '/app/**'}、{@code timeout = 604800}、
|
||||
* {@code activeTimeout = 1800})。原因:设备域({@code ruoyi-device})不依赖 {@code ruoyi-system},
|
||||
* 无法读取 {@code sys_client};若运维调整了 {@code sys_client} 的 app 记录,请同步此处配置。
|
||||
* <b>客户端身份默认值口径</b>:身份与访问范围参考框架 {@code sys_client} 中
|
||||
* {@code client_key = 'app'} 的记录({@code device_type = 'android'}、{@code access_path = '/app/**'})。
|
||||
* 设备域({@code ruoyi-device})不依赖 {@code ruoyi-system},无法读取 {@code sys_client},因此令牌固定有效期和
|
||||
* 活跃超时由本配置独立控制,默认均为 2592000 秒(30 天)。
|
||||
* <p>
|
||||
* <b>已知偏差</b>:{@link Client#clientId} 的取值口径与 PC 端 {@code sys_client.client_id} 不同,
|
||||
* 详见 {@link Client#clientId} 字段注释与《05-交付说明-迭代I2》§七。
|
||||
@@ -42,7 +41,7 @@ public class AppProperties {
|
||||
private Sms sms = new Sms();
|
||||
|
||||
/**
|
||||
* APP 邮箱验证码策略(SMTP 发信)。
|
||||
* APP 邮箱验证码策略;真实发信统一由 common-mail 的 {@code gmail.*} 配置负责。
|
||||
*/
|
||||
private Email email = new Email();
|
||||
|
||||
@@ -87,14 +86,14 @@ public class AppProperties {
|
||||
private String deviceType = "android";
|
||||
|
||||
/**
|
||||
* token 固定超时时间(秒)。
|
||||
* token 固定超时时间(秒),默认 30 天;活跃超时仍由 {@link #activeTimeout} 独立控制。
|
||||
*/
|
||||
private Long timeout = 604800L;
|
||||
private Long timeout = 2592000L;
|
||||
|
||||
/**
|
||||
* token 活跃超时时间(秒)。
|
||||
* token 活跃超时时间(秒),默认 30 天;启用动态活跃超时时会在请求后续期。
|
||||
*/
|
||||
private Long activeTimeout = 1800L;
|
||||
private Long activeTimeout = 2592000L;
|
||||
|
||||
}
|
||||
|
||||
@@ -119,7 +118,7 @@ public class AppProperties {
|
||||
private String blend = "config1";
|
||||
|
||||
/**
|
||||
* sms4j 模板 id(mock=false 时使用)。
|
||||
* sms4j 模板 id(mock=false 时使用,国内短信)。
|
||||
*/
|
||||
private String templateId = "";
|
||||
|
||||
@@ -141,11 +140,10 @@ public class AppProperties {
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱验证码策略配置(SMTP 发信)。
|
||||
* 邮箱验证码策略配置。
|
||||
* <p>
|
||||
* <b>凭据口径</b>:{@link #user} 与 {@link #pass} 在代码与 {@code application.yml} 中<b>均不预置真实值</b>,
|
||||
* 一律通过环境变量注入({@code DEVICE_APP_EMAIL_USER} / {@code DEVICE_APP_EMAIL_PASS}),
|
||||
* 避免重蹈「明文 broker 凭据硬编码」的覆辙。
|
||||
* <b>发信口径</b>:真实邮件统一调用 {@code GmailCodeService},账号、凭据、SMTP 主机和超时从
|
||||
* {@code gmail.*} 读取;本类中的旧 SMTP 字段仅为兼容历史配置保留,APP 业务不再读取。
|
||||
* <p>
|
||||
* <b>谷歌邮箱(Gmail)口径</b>:主机固定 {@code smtp.gmail.com}、端口 465(SSL)。
|
||||
* Gmail 已停用「低安全性应用访问」,{@link #pass} 必须填 Google 账号生成的
|
||||
@@ -168,54 +166,34 @@ public class AppProperties {
|
||||
*/
|
||||
private boolean mock = true;
|
||||
|
||||
/**
|
||||
* Gmail SMTP 主机。
|
||||
*/
|
||||
/** 旧 SMTP 主机配置,仅为兼容历史配置保留,业务不再读取。 */
|
||||
private String host = "smtp.gmail.com";
|
||||
|
||||
/**
|
||||
* Gmail SMTP 端口:465(SSL)或 587(STARTTLS)。
|
||||
*/
|
||||
/** 旧 SMTP 端口配置,仅为兼容历史配置保留,业务不再读取。 */
|
||||
private int port = 465;
|
||||
|
||||
/**
|
||||
* 发信账号(Gmail 地址),由环境变量注入。
|
||||
*/
|
||||
/** 旧发信账号配置,仅为兼容历史配置保留,业务不再读取。 */
|
||||
private String user = "";
|
||||
|
||||
/**
|
||||
* 发信凭据:Gmail 的 16 位<b>应用专用密码</b>,由环境变量注入。
|
||||
*/
|
||||
/** 旧发信凭据配置,仅为兼容历史配置保留,业务不再读取。 */
|
||||
private String pass = "";
|
||||
|
||||
/**
|
||||
* 发件人(须与 {@link #user} 一致,可写为 {@code 名称 <user@gmail.com>})。
|
||||
*/
|
||||
/** 旧发件人配置,仅为兼容历史配置保留,业务不再读取。 */
|
||||
private String from = "";
|
||||
|
||||
/**
|
||||
* 使用 SSL 安全连接(465 端口用 true)。
|
||||
*/
|
||||
/** 旧 SSL 配置,仅为兼容历史配置保留,业务不再读取。 */
|
||||
private boolean sslEnable = true;
|
||||
|
||||
/**
|
||||
* 使用 STARTTLS 升级连接(587 端口用 true,与 {@link #sslEnable} 互斥使用)。
|
||||
*/
|
||||
/** 旧 STARTTLS 配置,仅为兼容历史配置保留,业务不再读取。 */
|
||||
private boolean starttlsEnable = false;
|
||||
|
||||
/**
|
||||
* SMTP 连接超时(毫秒):避免 Gmail 不可达时长时间占用请求线程。
|
||||
*/
|
||||
/** 旧连接超时配置,仅为兼容历史配置保留,业务不再读取。 */
|
||||
private int connectionTimeout = 5000;
|
||||
|
||||
/**
|
||||
* SMTP 读写超时(毫秒)。
|
||||
*/
|
||||
/** 旧读写超时配置,仅为兼容历史配置保留,业务不再读取。 */
|
||||
private int timeout = 10000;
|
||||
|
||||
/**
|
||||
* 邮件主题。
|
||||
*/
|
||||
/** 旧邮件主题配置,仅为兼容历史配置保留,业务不再读取。 */
|
||||
private String subject = "智能飞禽物联平台 验证码";
|
||||
|
||||
/**
|
||||
@@ -240,15 +218,6 @@ public class AppProperties {
|
||||
*/
|
||||
private List<String> allowedDomains = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 判断是否已具备真实发信条件(发信账号与凭据均已配置)。
|
||||
*
|
||||
* @return true 已配置;false 未配置
|
||||
*/
|
||||
public boolean isConfigured() {
|
||||
return StringUtils.isNotBlank(user) && StringUtils.isNotBlank(pass);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,6 +87,18 @@ public class DeviceMqttProperties {
|
||||
*/
|
||||
private Duration alertDedupWindow = Duration.ofMinutes(30);
|
||||
|
||||
/**
|
||||
* APP 告警消息 outbox 补偿间隔。
|
||||
* <p>
|
||||
* 消息同步写入失败后,告警事实仍会保留;补偿任务按该间隔扫描并重试。
|
||||
*/
|
||||
private Duration appMessageRetryInterval = Duration.ofMinutes(1);
|
||||
|
||||
/**
|
||||
* APP 告警消息单轮补偿上限,避免数据库恢复后瞬间形成写入洪峰。
|
||||
*/
|
||||
private int appMessageRetryBatchSize = 100;
|
||||
|
||||
/**
|
||||
* 眼罩指令回执等待超时。
|
||||
* <p>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.dromara.device.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* MJPEG 直传直播配置(前缀 {@code mjpeg})。
|
||||
* <p>
|
||||
* 开启后设备按 HTTP POST JPEG 推流,APP 按 multipart MJPEG 拉流,不再经 ZLM。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "mjpeg")
|
||||
public class MjpegProperties {
|
||||
|
||||
/**
|
||||
* 是否启用 MJPEG 直传。关闭时直播仍走 ZLM RTMP。
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* 设备与播放端可访问的公网基址,例如 {@code http://47.104.136.221:8081}
|
||||
*/
|
||||
private String publicHost;
|
||||
|
||||
/**
|
||||
* 播放端推帧频率
|
||||
*/
|
||||
private int fps = 5;
|
||||
|
||||
/**
|
||||
* 超过该秒数未收到新帧则视为不在线
|
||||
*/
|
||||
private int frameTtlSeconds = 8;
|
||||
|
||||
/**
|
||||
* 开播后持续无人观看超过该秒数,则 MQTT 通知设备停推
|
||||
*/
|
||||
private int idleStopSeconds = 30;
|
||||
|
||||
/**
|
||||
* 单帧 JPEG 上限(字节)
|
||||
*/
|
||||
private int maxFrameBytes = 1_048_576;
|
||||
|
||||
}
|
||||
@@ -14,9 +14,9 @@ public final class AppConstants {
|
||||
// ---------------- 校验正则 ----------------
|
||||
|
||||
/**
|
||||
* 手机号(中国大陆 11 位)。
|
||||
* 手机号:大陆 11 位,或 {@code +}/{@code 00} + 国家码 + 号码(国际短信)。
|
||||
*/
|
||||
public static final String PHONE_REGEX = "^1[3-9]\\d{9}$";
|
||||
public static final String PHONE_REGEX = "^(?:(?:\\+|00)86)?1[3-9]\\d{9}$|^(?:\\+|00)[1-9]\\d{6,14}$";
|
||||
|
||||
/**
|
||||
* 密码:8-20 位,必须同时包含大写字母、小写字母和数字(不强制特殊字符,与原型一致)。
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.dromara.device.constant;
|
||||
|
||||
/**
|
||||
* APP 客户端版本模块常量的集中管理。
|
||||
* <p>
|
||||
* 命名与文案对齐架构设计 §9.4。前 8 个为「错误 / 提示文案」,后两个为「强制更新取值常量」
|
||||
* (无字典、页面本地映射,见架构 §9.1)。新增 {@code VERSION_NAME_REQUIRED} /
|
||||
* {@code RECORD_NOT_EXIST} 两条为服务端侧的必要校验文案(设计文档未显式列出,已在此统一维护)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
public final class AppVersionMsgConstants {
|
||||
|
||||
private AppVersionMsgConstants() {
|
||||
}
|
||||
|
||||
// ---------------- 校验 / 提示文案 ----------------
|
||||
|
||||
/**
|
||||
* 平台参数不合法(APP 检查更新 / 后台登记 均适用)。
|
||||
*/
|
||||
public static final String PLATFORM_INVALID = "平台参数不合法";
|
||||
|
||||
/**
|
||||
* 版本号(构建号)不合法(非正整数)。
|
||||
*/
|
||||
public static final String VERSION_CODE_INVALID = "版本号不合法";
|
||||
|
||||
/**
|
||||
* 展示版本号不能为空(后台登记 / 修改必填)。
|
||||
*/
|
||||
public static final String VERSION_NAME_REQUIRED = "版本号不能为空";
|
||||
|
||||
/**
|
||||
* 同平台构建号已存在。
|
||||
* <p>对齐 DB 唯一键 {@code uk_platform_version_code (platform, version_code)}(<b>不区分逻辑删除行</b>)。
|
||||
*/
|
||||
public static final String VERSION_CODE_DUPLICATE = "该平台下构建号已存在";
|
||||
|
||||
/**
|
||||
* 同平台展示版本号已存在(应用层预检,避免两个构建号挂同一展示版本号)。
|
||||
*/
|
||||
public static final String VERSION_NAME_DUPLICATE = "该平台下版本号已存在";
|
||||
|
||||
/**
|
||||
* 构建号必须大于该平台已有最大值,{@code %d} 为当前最大值。
|
||||
*/
|
||||
public static final String VERSION_CODE_NOT_GREATER = "构建号必须大于该平台已有最大值(当前 %d)";
|
||||
|
||||
/**
|
||||
* 版本必须上传安装包(Android / iOS 两平台一致)。
|
||||
*/
|
||||
public static final String PACKAGE_REQUIRED = "版本必须上传安装包";
|
||||
|
||||
/**
|
||||
* 平台不可修改(修改时请求平台与库中不一致)。
|
||||
*/
|
||||
public static final String PLATFORM_IMMUTABLE = "平台不可修改";
|
||||
|
||||
/**
|
||||
* 版本记录不存在(修改前置校验)。
|
||||
*/
|
||||
public static final String RECORD_NOT_EXIST = "版本记录不存在";
|
||||
|
||||
// ---------------- 取值常量(非文案) ----------------
|
||||
|
||||
/**
|
||||
* 强制更新:是({@code force_update='1'},无字典,页面本地映射)。
|
||||
*/
|
||||
public static final String FORCE_UPDATE_YES = "1";
|
||||
|
||||
/**
|
||||
* 强制更新:否({@code force_update='0'},无字典,页面本地映射)。
|
||||
*/
|
||||
public static final String FORCE_UPDATE_NO = "0";
|
||||
|
||||
}
|
||||
@@ -27,6 +27,11 @@ public final class DeviceMqttConstants {
|
||||
*/
|
||||
public static final String ONLINE_SCAN_LOCK = "bm:mqtt:online-scan:lock";
|
||||
|
||||
/**
|
||||
* APP 告警消息 outbox 补偿互斥锁,多实例同一时刻只执行一轮扫描。
|
||||
*/
|
||||
public static final String APP_MESSAGE_RETRY_LOCK = "bm:mqtt:app-message-retry:lock";
|
||||
|
||||
/**
|
||||
* 告警去重窗口占位,完整 Key:{@code bm:mqtt:alert:dedup:{deviceNo}:{alertType}}。
|
||||
* <p>
|
||||
@@ -48,6 +53,11 @@ public final class DeviceMqttConstants {
|
||||
*/
|
||||
public static final String ZLM_PLAY_TOKEN_PREFIX = "bm:zlm:play:token:";
|
||||
|
||||
/**
|
||||
* MJPEG 设备推流 token,完整 Key:{@code bm:mjpeg:ingest:{deviceNo}}。
|
||||
*/
|
||||
public static final String MJPEG_INGEST_TOKEN_PREFIX = "bm:mjpeg:ingest:";
|
||||
|
||||
/**
|
||||
* 待回执指令标记,完整 Key:{@code bm:mqtt:ack:pending:{msgId}},值为目标 {@code deviceNo}。
|
||||
* <p>
|
||||
@@ -60,10 +70,27 @@ public final class DeviceMqttConstants {
|
||||
// ---------------- 协议固定码 ----------------
|
||||
|
||||
/**
|
||||
* 告警处理状态:未处理(一期只读展示,不做处理流程)
|
||||
* 告警处理状态:未处理
|
||||
*/
|
||||
public static final String ALERT_STATUS_UNHANDLED = "0";
|
||||
|
||||
/**
|
||||
* 告警处理状态:已处理
|
||||
* <p>
|
||||
* 由后台「告警记录」页标记(单条或批量),与 {@link #ALERT_STATUS_UNHANDLED} 成对使用。
|
||||
*/
|
||||
public static final String ALERT_STATUS_HANDLED = "1";
|
||||
|
||||
/**
|
||||
* APP 消息已读标记:未读
|
||||
*/
|
||||
public static final String MESSAGE_READ_FLAG_UNREAD = "0";
|
||||
|
||||
/**
|
||||
* APP 消息已读标记:已读
|
||||
*/
|
||||
public static final String MESSAGE_READ_FLAG_READ = "1";
|
||||
|
||||
/**
|
||||
* 注册结果码:成功
|
||||
*/
|
||||
|
||||
@@ -144,6 +144,11 @@ public final class DeviceMsgConstants {
|
||||
*/
|
||||
public static final String ZLM_NOT_CONFIGURED = "ZLM 地址未配置";
|
||||
|
||||
/**
|
||||
* MJPEG 公网基址未配置
|
||||
*/
|
||||
public static final String MJPEG_NOT_CONFIGURED = "MJPEG 直播地址未配置";
|
||||
|
||||
/**
|
||||
* 客户端自定义拉流已停用
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package org.dromara.device.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.domain.PageResult;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.log.annotation.Log;
|
||||
import org.dromara.common.log.enums.BusinessType;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.dromara.device.domain.bo.AlertBo;
|
||||
import org.dromara.device.domain.bo.AlertHandleBo;
|
||||
import org.dromara.device.domain.vo.AlertStatsVo;
|
||||
import org.dromara.device.domain.vo.AlertVo;
|
||||
import org.dromara.device.service.IAlertService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 后台告警记录 Controller(前缀 {@code /device/alert})。
|
||||
* <p>
|
||||
* 接口清单:
|
||||
* <ul>
|
||||
* <li>{@code GET /device/alert/list} —— 分页列表(含设备名称回填)</li>
|
||||
* <li>{@code GET /device/alert/stats} —— 顶部统计(总数 / 未处理 / 已处理)</li>
|
||||
* <li>{@code PUT /device/alert/handle} —— 标记已处理(单条与批量共用,幂等)</li>
|
||||
* <li>{@code DELETE /device/alert/{ids}} —— 批量删除(逻辑删除,保留审计痕迹)</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* <b>本页只管理「设备事实」,不管理「消息推送」</b>:告警的 APP 消息推送副本记录在
|
||||
* {@code bm_app_message},由 APP 侧 {@code /app/message/**} 读取,
|
||||
* 后台<b>不提供</b>消息的重发/撤回能力。二者是「事实」与「通知」的关系,不互相替代。
|
||||
* <p>
|
||||
* ⚠️ <b>与设备模块一致:本组接口没有部门数据权限隔离</b>——
|
||||
* {@code BmDeviceAlertMapper} 未标注 {@code @DataPermission},
|
||||
* 因此任何持有 {@code device:alert:list} 的账号看到的都是<b>全量告警</b>。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/device/alert")
|
||||
public class AlertController extends BaseController {
|
||||
|
||||
private final IAlertService alertService;
|
||||
|
||||
/**
|
||||
* 分页查询告警记录。
|
||||
* <p>
|
||||
* 默认排序为「未处理优先 + 告警时间倒序」。
|
||||
*/
|
||||
@SaCheckPermission("device:alert:list")
|
||||
@GetMapping("/list")
|
||||
public R<PageResult<AlertVo>> list(@Validated AlertBo bo) {
|
||||
return R.ok(alertService.queryPageList(bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计告警总数 / 未处理 / 已处理(顶部统计条)。
|
||||
*/
|
||||
@SaCheckPermission("device:alert:list")
|
||||
@GetMapping("/stats")
|
||||
public R<AlertStatsVo> stats() {
|
||||
return R.ok(alertService.stats());
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记告警为已处理。
|
||||
* <p>
|
||||
* 单条与批量共用本接口:标记单条时 {@code alertIds} 长度为 1。
|
||||
* 幂等——重复点击不报错,也不会覆盖首次的处理人与处理时间。
|
||||
*/
|
||||
@SaCheckPermission("device:alert:edit")
|
||||
@Log(title = "告警记录", businessType = BusinessType.UPDATE)
|
||||
@PutMapping("/handle")
|
||||
public R<Void> handle(@Validated @RequestBody AlertHandleBo bo) {
|
||||
alertService.handle(bo);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除告警记录(逻辑删除)。
|
||||
*/
|
||||
@SaCheckPermission("device:alert:remove")
|
||||
@Log(title = "告警记录", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{ids}")
|
||||
public R<Void> remove(@PathVariable("ids") Long[] ids) {
|
||||
alertService.remove(Arrays.asList(ids));
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package org.dromara.device.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.domain.PageResult;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.log.annotation.Log;
|
||||
import org.dromara.common.log.enums.BusinessType;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.dromara.device.domain.bo.AppVersionBo;
|
||||
import org.dromara.device.domain.vo.AppVersionVo;
|
||||
import org.dromara.device.service.IAppVersionService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* APP 客户端版本 Controller(后台,前缀 {@code /device/app-version})。
|
||||
* <p>
|
||||
* 接口清单(架构 §6.1):
|
||||
* <ul>
|
||||
* <li>{@code GET /device/app-version/list} —— 版本列表(默认按构建号倒序)</li>
|
||||
* <li>{@code GET /device/app-version/{versionId}} —— 版本详情</li>
|
||||
* <li>{@code POST /device/app-version} —— 登记版本</li>
|
||||
* <li>{@code PUT /device/app-version} —— 修改版本(含启用 / 停用;平台不可改)</li>
|
||||
* <li>{@code DELETE /device/app-version/{ids}} —— 删除版本(逻辑删除)</li>
|
||||
* </ul>
|
||||
* 后台接口一律 {@code @SaCheckPermission},变更类加 {@code @Log} 进操作日志。
|
||||
* 上传安装包走框架 {@code POST /resource/oss/upload}(权限字 {@code system:oss:upload}),本控制器不承担上传。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/device/app-version")
|
||||
public class AppVersionController extends BaseController {
|
||||
|
||||
private final IAppVersionService appVersionService;
|
||||
|
||||
/**
|
||||
* 查询版本列表。
|
||||
*
|
||||
* @param bo 查询条件(keyword / platform / status / forceUpdate / 发布时间区间)
|
||||
* @param pageQuery 分页参数
|
||||
* @return 版本分页结果
|
||||
*/
|
||||
@SaCheckPermission("device:appversion:list")
|
||||
@GetMapping("/list")
|
||||
public R<PageResult<AppVersionVo>> list(AppVersionBo bo, PageQuery pageQuery) {
|
||||
return R.ok(appVersionService.queryPageList(bo, pageQuery));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询版本详情。
|
||||
*
|
||||
* @param versionId 版本主键
|
||||
* @return 版本视图对象(不存在返回 {@code null})
|
||||
*/
|
||||
@SaCheckPermission("device:appversion:query")
|
||||
@GetMapping("/{versionId}")
|
||||
public R<AppVersionVo> getInfo(@PathVariable("versionId") Long versionId) {
|
||||
AppVersionVo vo = appVersionService.queryById(versionId);
|
||||
return R.ok(vo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记版本。
|
||||
*
|
||||
* @param bo 版本业务对象
|
||||
* @return 操作结果
|
||||
*/
|
||||
@SaCheckPermission("device:appversion:add")
|
||||
@Log(title = "APP版本", businessType = BusinessType.INSERT)
|
||||
@PostMapping
|
||||
public R<Void> add(@RequestBody AppVersionBo bo) {
|
||||
return toAjax(appVersionService.insertByBo(bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改版本(含启用 / 停用;平台不可改)。
|
||||
*
|
||||
* @param bo 版本业务对象(含 versionId)
|
||||
* @return 操作结果
|
||||
*/
|
||||
@SaCheckPermission("device:appversion:edit")
|
||||
@Log(title = "APP版本", businessType = BusinessType.UPDATE)
|
||||
@PutMapping
|
||||
public R<Void> edit(@RequestBody AppVersionBo bo) {
|
||||
return toAjax(appVersionService.updateByBo(bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除版本(逻辑删除)。
|
||||
* <p>
|
||||
* 唯一键不区分逻辑删除,删除后同平台同构建号<b>不可复用</b>(构建号填错走编辑修正)。
|
||||
*
|
||||
* @param ids 版本主键数组(路径中以逗号分隔,如 {@code /device/app-version/1,2,3})
|
||||
* @return 操作结果
|
||||
*/
|
||||
@SaCheckPermission("device:appversion:remove")
|
||||
@Log(title = "APP版本", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{ids}")
|
||||
public R<Void> remove(@PathVariable("ids") Long[] ids) {
|
||||
return toAjax(appVersionService.deleteByIds(Arrays.asList(ids)));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.dromara.device.controller.app;
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.utils.MessageUtils;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.dromara.device.constant.AppMsgConstants;
|
||||
import org.dromara.device.domain.bo.AppEmailCodeBo;
|
||||
@@ -48,7 +49,7 @@ public class AppAuthController extends BaseController {
|
||||
public R<Void> sendSmsCode(@Validated @RequestBody AppSmsCodeBo bo) {
|
||||
String code = appAuthService.sendSmsCode(bo);
|
||||
// 开发环境 mock 模式暂将验证码放入 msg,接入真实短信后仍返回标准成功文案
|
||||
return R.ok(code == null ? AppMsgConstants.SMS_CODE_SENT : code);
|
||||
return R.ok(code == null ? MessageUtils.message("app.sms.code.sent") : code);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.dromara.device.controller.app;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.domain.PageResult;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.dromara.device.domain.bo.AppMessageQueryBo;
|
||||
import org.dromara.device.domain.vo.AppMessageVo;
|
||||
import org.dromara.device.service.IAppMessageService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* APP 消息 Controller(前缀 {@code /app/message})。
|
||||
* <p>
|
||||
* 接口清单:
|
||||
* <ul>
|
||||
* <li>{@code GET /app/message/list} —— 消息分页列表(按产生时间倒序)</li>
|
||||
* <li>{@code GET /app/message/unread-count} —— 未读数(供消息角标)</li>
|
||||
* <li>{@code PUT /app/message/{messageId}/read} —— 标记单条已读</li>
|
||||
* <li>{@code PUT /app/message/read-all} —— 全部标记已读</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 本组接口需登录态,当前用户取自 {@code LoginHelper.getUserId()},
|
||||
* <b>仅能读写本人消息</b>,不做后台数据权限、不加 {@code @SaCheckPermission}
|
||||
* (口径与 {@code /app/device/**}、{@code /app/user/**} 一致)。
|
||||
* <p>
|
||||
* 一期消息内容<b>只有设备告警</b>(开发计划 D8 修订):离线、头套低电、脚环低电。
|
||||
* 不具备「已送达/已撤回」等推送状态,也不支持后台重发。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/app/message")
|
||||
public class AppMessageController extends BaseController {
|
||||
|
||||
private final IAppMessageService appMessageService;
|
||||
|
||||
/**
|
||||
* 分页查询本人消息(按产生时间倒序)。
|
||||
* <p>
|
||||
* 未传 {@code pageNum} / {@code pageSize} 时服务端兜底为每页 20 条,
|
||||
* 上限 100 条(详见 {@code AppMessageServiceImpl})。
|
||||
*
|
||||
* @param bo 查询条件({@code readFlag} 可筛选未读 / 已读,留空返回全部)
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
public R<PageResult<AppMessageVo>> list(@Validated AppMessageQueryBo bo) {
|
||||
return R.ok(appMessageService.queryPageList(bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询本人未读消息数(供消息角标)。
|
||||
*/
|
||||
@GetMapping("/unread-count")
|
||||
public R<Long> unreadCount() {
|
||||
return R.ok(appMessageService.unreadCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记单条消息为已读。
|
||||
* <p>
|
||||
* 幂等:重复调用不报错。消息不属于本人时同样返回成功(不做存在性暴露)。
|
||||
*/
|
||||
@PutMapping("/{messageId}/read")
|
||||
public R<Void> markRead(@PathVariable("messageId") Long messageId) {
|
||||
appMessageService.markRead(messageId);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把本人所有未读消息标记为已读。
|
||||
*/
|
||||
@PutMapping("/read-all")
|
||||
public R<Void> markAllRead() {
|
||||
appMessageService.markAllRead();
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.dromara.device.controller.app;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.dromara.device.domain.bo.AppVersionCheckBo;
|
||||
import org.dromara.device.domain.vo.AppVersionLatestVo;
|
||||
import org.dromara.device.service.IAppVersionService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* APP 客户端版本 Controller(APP 端,前缀 {@code /app/version})。
|
||||
* <p>
|
||||
* 接口清单(架构 §6.2):
|
||||
* <ul>
|
||||
* <li>{@code GET /app/version/latest} —— 冷启动检查更新(<b>免登录</b>)</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* {@link SaIgnore} 放行登录校验:版本检查发生在 APP 冷启动最早阶段,此时用户可能尚未登录,
|
||||
* 且强制更新必须在登录前就能拦住,否则未登录用户可绕过强更。实现侧({@code checkLatest})
|
||||
* <b>不读取登录态</b>(不得使用 {@code LoginHelper}),否则免登录会 NPE。
|
||||
* <p>
|
||||
* APP 请求头 {@code clientid} 必须为 {@code app}(框架与 token 内扩展做等值比对)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@SaIgnore
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/app/version")
|
||||
public class AppVersionAppController extends BaseController {
|
||||
|
||||
private final IAppVersionService appVersionService;
|
||||
|
||||
/**
|
||||
* 检查更新。
|
||||
*
|
||||
* @param bo 入参(platform / versionCode 必填,其余回显)
|
||||
* @return 检查更新结果(三态:无更新 / 可选更新 / 强制更新)
|
||||
*/
|
||||
@GetMapping("/latest")
|
||||
public R<AppVersionLatestVo> latest(@Validated AppVersionCheckBo bo) {
|
||||
return R.ok(appVersionService.checkLatest(bo));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package org.dromara.device.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* APP 消息对象 bm_app_message
|
||||
* <p>
|
||||
* 一期仅承载「设备告警」向 APP 用户的推送副本(开发计划 D8 修订)。
|
||||
* <p>
|
||||
* 与 {@link BmDeviceAlert} 的分工:<b>告警是设备事实</b>(含后台处理流程),
|
||||
* <b>消息是推送副本</b>(含收件人与已读状态)。同一设备解绑/换绑后,
|
||||
* 历史消息仍归属告警发生时的收件人({@code user_id} 为快照),不会被后来者看到。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@TableName("bm_app_message")
|
||||
public class BmAppMessage implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键(雪花)
|
||||
*/
|
||||
@TableId(value = "message_id")
|
||||
private Long messageId;
|
||||
|
||||
/**
|
||||
* 接收用户(sys_user.user_id,APP 用户)
|
||||
*/
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 关联设备主键
|
||||
*/
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 关联设备编号(冗余,供展示与跳转)
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 关联告警主键(bm_device_alert)
|
||||
*/
|
||||
private Long alertId;
|
||||
|
||||
/**
|
||||
* 告警类型(offline / low_battery_hood / low_battery_ring)
|
||||
*/
|
||||
private String alertType;
|
||||
|
||||
/**
|
||||
* 告警级别(0 提示 1 警告 2 严重)
|
||||
*/
|
||||
private String alertLevel;
|
||||
|
||||
/**
|
||||
* 消息标题
|
||||
*/
|
||||
private String title;
|
||||
|
||||
/**
|
||||
* 消息内容
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 已读标记(0 未读 1 已读)
|
||||
*/
|
||||
private String readFlag;
|
||||
|
||||
/**
|
||||
* 已读时间
|
||||
*/
|
||||
private LocalDateTime readTime;
|
||||
|
||||
/**
|
||||
* 产生时间
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package org.dromara.device.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* APP 客户端版本对象 bm_app_version
|
||||
* <p>
|
||||
* 后台维护、APP 冷启动检查更新({@code GET /app/version/latest})的数据源。
|
||||
* 业务上与设备 OTA 固件升级<b>完全独立</b>(不共表、不共权限字、不共页面),二者仅共用「上传包到 OSS」这一技术手段。
|
||||
* <p>
|
||||
* 关键类型口径:
|
||||
* <ul>
|
||||
* <li>{@code versionCode} 为 {@link Integer}(DB {@code int},32 位正整数)——唯一比较依据。
|
||||
* 用 {@code Integer} 而非 {@code Long}:框架把 {@code Long} 序列化为<b>字符串</b>、把
|
||||
* {@code Integer} 序列化为<b>数字</b>,而 APP 契约要求 {@code latestVersionCode} /
|
||||
* {@code currentVersionCode} 为 JSON 数字。⚠️ 构建号人工递增,<b>禁止时间戳式构建号</b>(会溢出)。</li>
|
||||
* <li>{@code ossId} / {@code fileSize} 为 {@link Long}(雪花主键 / 字节数),JSON 中表现为字符串。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("bm_app_version")
|
||||
public class BmAppVersion extends BaseEntity {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键(雪花)
|
||||
*/
|
||||
@TableId(value = "version_id")
|
||||
private Long versionId;
|
||||
|
||||
/**
|
||||
* 平台(android / ios,字典 bm_app_platform)
|
||||
*/
|
||||
private String platform;
|
||||
|
||||
/**
|
||||
* 展示版本号,如 1.0.10(仅展示,不参与比较)
|
||||
*/
|
||||
private String versionName;
|
||||
|
||||
/**
|
||||
* 构建号,32 位正整数、单调递增、平台内唯一(唯一比较依据)
|
||||
*/
|
||||
private Integer versionCode;
|
||||
|
||||
/**
|
||||
* 强制更新(0 否 1 是;无字典,页面本地映射)
|
||||
*/
|
||||
private String forceUpdate;
|
||||
|
||||
/**
|
||||
* 关联 sys_oss.oss_id(安装包,两平台均上传)
|
||||
*/
|
||||
private Long ossId;
|
||||
|
||||
/**
|
||||
* 安装包 URL(两平台均上传;冗余自 sys_oss,供 APP 直接下载)
|
||||
*/
|
||||
private String fileUrl;
|
||||
|
||||
/**
|
||||
* 安装包文件名(两平台均上传)
|
||||
*/
|
||||
private String fileName;
|
||||
|
||||
/**
|
||||
* 安装包字节数(两平台均上传)
|
||||
*/
|
||||
private Long fileSize;
|
||||
|
||||
/**
|
||||
* 安装包校验值 MD5(预留,本期不填,不参与校验)
|
||||
*/
|
||||
private String md5;
|
||||
|
||||
/**
|
||||
* App Store 引导地址(选填)
|
||||
*/
|
||||
private String storeUrl;
|
||||
|
||||
/**
|
||||
* 更新说明(纯文本)
|
||||
*/
|
||||
private String changelog;
|
||||
|
||||
/**
|
||||
* 状态(0 停用 1 启用,字典 bm_app_version_status)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 发布时间(首次置为启用时写入)
|
||||
*/
|
||||
private LocalDateTime publishTime;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 逻辑删除(0 存在 1 删除)
|
||||
*/
|
||||
@TableLogic
|
||||
private String delFlag;
|
||||
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.dromara.device.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
@@ -60,13 +61,39 @@ public class BmDeviceAlert implements Serializable {
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 处理状态(0 未处理 1 已处理,一期只读展示)
|
||||
* 处理状态(0 未处理 1 已处理)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 处理人(sys_user.user_id,后台账号)
|
||||
*/
|
||||
private Long handleBy;
|
||||
|
||||
/**
|
||||
* 处理时间
|
||||
*/
|
||||
private LocalDateTime handleTime;
|
||||
|
||||
/**
|
||||
* 告警发生时的 APP 收件人快照。
|
||||
* <p>
|
||||
* 该字段也是消息补偿的 outbox 载荷:消息表中不存在对应 {@code alert_id} 时,后台任务按此用户重试,
|
||||
* 不再查询可能已经发生解绑或换绑的设备当前归属。
|
||||
*/
|
||||
private Long messageUserId;
|
||||
|
||||
/**
|
||||
* 告警时间
|
||||
*/
|
||||
private LocalDateTime alertTime;
|
||||
|
||||
/**
|
||||
* 逻辑删除(0 存在 1 删除)
|
||||
* <p>
|
||||
* 后台「删除」走逻辑删除,保留告警审计痕迹(与 {@code bm_device} 同款口径)。
|
||||
*/
|
||||
@TableLogic
|
||||
private String delFlag;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 设备告警查询入参({@code GET /device/alert/list})。
|
||||
* <p>
|
||||
* 分页字段({@code pageNum} / {@code pageSize} / {@code orderByColumn} / {@code isAsc})
|
||||
* 继承自 {@link PageQuery},与框架其余列表页口径一致。
|
||||
* <p>
|
||||
* <b>日期区间经 {@link #params} 传递</b>:前端用 {@code useDateRangeQuery} hook
|
||||
* (内部 {@code utils/ruoyi.ts#addDateRange})把区间写为 {@code params[beginTime]} /
|
||||
* {@code params[endTime]},与框架既有约定一致。区间按 {@code alert_time} 过滤。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AlertBo extends PageQuery {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 设备编号(精确匹配;告警表内为冗余快照,不随设备改名变化)
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 告警类型(offline / low_battery_hood / low_battery_ring,精确匹配)
|
||||
*/
|
||||
private String alertType;
|
||||
|
||||
/**
|
||||
* 告警级别(0 提示 1 警告 2 严重,精确匹配)
|
||||
*/
|
||||
private String alertLevel;
|
||||
|
||||
/**
|
||||
* 处理状态(0 未处理 1 已处理,精确匹配)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 关键词:模糊匹配设备编号 / 告警内容(两字段 OR)
|
||||
*/
|
||||
private String keyword;
|
||||
|
||||
/**
|
||||
* 扩展查询参数(约定键 {@code beginTime} / {@code endTime}:告警时间区间)
|
||||
*/
|
||||
private Map<String, Object> params = new HashMap<>();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 告警标记处理入参({@code PUT /device/alert/handle})。
|
||||
* <p>
|
||||
* 单条与批量共用同一入参:标记单条时列表长度即为 1。
|
||||
* 之所以不拆两个接口,是因为二者语义完全一致(把 {@code status} 由 0 置 1),
|
||||
* 拆开只会产生两份重复的校验与落库逻辑。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Data
|
||||
public class AlertHandleBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 待处理的告警主键集合
|
||||
*/
|
||||
@NotEmpty(message = "请至少选择一条告警")
|
||||
private List<Long> alertIds;
|
||||
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public class AppLoginBo implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 手机号(大陆 11 位)。
|
||||
* 手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
|
||||
*/
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
/**
|
||||
* APP 消息查询入参({@code GET /app/message/list})。
|
||||
* <p>
|
||||
* 分页字段继承自 {@link PageQuery}。与后台列表不同,<b>APP 端不传分页参数时由服务端兜底为
|
||||
* 每页 20 条</b>(而非框架默认的「查全部」),理由见 {@code AppMessageServiceImpl#buildPage}:
|
||||
* 消息是持续累积的数据,APP 首屏一次拉 1000 条既浪费流量也拖慢渲染。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AppMessageQueryBo extends PageQuery {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 已读标记筛选(0 未读 1 已读);留空返回全部
|
||||
*/
|
||||
private String readFlag;
|
||||
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public class AppPhoneBo implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 新手机号(大陆 11 位)。
|
||||
* 新手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
|
||||
*/
|
||||
@NotBlank(message = "新手机号不能为空")
|
||||
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
|
||||
|
||||
@@ -26,7 +26,7 @@ public class AppRegisterBo implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 手机号(大陆 11 位)。
|
||||
* 手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
|
||||
*/
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
|
||||
|
||||
@@ -22,7 +22,7 @@ public class AppResetPwdBo implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 手机号(大陆 11 位)。
|
||||
* 手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
|
||||
*/
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
|
||||
|
||||
@@ -21,16 +21,16 @@ public class AppSmsCodeBo implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 手机号(大陆 11 位)。
|
||||
* 手机号:大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码。
|
||||
*/
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
|
||||
@NotBlank(message = "{user.phonenumber.not.blank}")
|
||||
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "{user.mobile.phone.number.not.valid}")
|
||||
private String phoneNumber;
|
||||
|
||||
/**
|
||||
* 场景:login / register / resetPwd / rebind。
|
||||
*/
|
||||
@NotBlank(message = "验证码场景不能为空")
|
||||
@NotBlank(message = "{app.sms.scene.not.blank}")
|
||||
private String scene;
|
||||
|
||||
}
|
||||
|
||||
@@ -21,17 +21,17 @@ public class AppSmsLoginBo implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 手机号(大陆 11 位)。
|
||||
* 手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
|
||||
*/
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
|
||||
@NotBlank(message = "{user.phonenumber.not.blank}")
|
||||
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "{user.mobile.phone.number.not.valid}")
|
||||
private String phoneNumber;
|
||||
|
||||
/**
|
||||
* 短信验证码(6 位数字)。
|
||||
*/
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
|
||||
@NotBlank(message = "{sms.code.not.blank}")
|
||||
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "{sms.code.format.invalid}")
|
||||
private String code;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.domain.BmAppVersion;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* APP 客户端版本业务对象 bm_app_version
|
||||
* <p>
|
||||
* 后台「APP版本管理」的增 / 改 / 查询入参。与实体 {@link BmAppVersion} 自动映射
|
||||
* ({@code @AutoMapper}),字段名与前端 {@code types.ts} 一一对齐。
|
||||
* <p>
|
||||
* ⚠️ <b>写模型铁律</b>:本 BO 仅承载「运营可提交」的字段,<b>绝不夹带运行态 / 只读字段</b>
|
||||
* (如 {@code publishTime} / {@code delFlag} / 审计字段 / 详情回显字段)。
|
||||
* 原因:{@code updateByBo} 走 MyBatis-Plus 默认 {@code NOT_NULL} 策略,空串会被当作有效值写库,
|
||||
* 历史上曾因此把在线设备静默改成未激活。只读字段一律由服务端自行维护。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Data
|
||||
@AutoMapper(target = BmAppVersion.class, reverseConvertGenerate = false)
|
||||
public class AppVersionBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键(新增留空;修改必填)
|
||||
*/
|
||||
private Long versionId;
|
||||
|
||||
/**
|
||||
* 平台(android / ios);新增必填,修改时<b>不可变更</b>
|
||||
*/
|
||||
private String platform;
|
||||
|
||||
/**
|
||||
* 展示版本号,如 1.0.10(必填,≤32 字符)
|
||||
*/
|
||||
private String versionName;
|
||||
|
||||
/**
|
||||
* 构建号,32 位正整数;必须大于同平台已有最大值(含停用记录)
|
||||
*/
|
||||
private Integer versionCode;
|
||||
|
||||
/**
|
||||
* 强制更新(0 否 1 是)
|
||||
*/
|
||||
private String forceUpdate;
|
||||
|
||||
/**
|
||||
* 关联 sys_oss.oss_id(安装包,两平台均必传)
|
||||
*/
|
||||
private Long ossId;
|
||||
|
||||
/**
|
||||
* 安装包 URL(两平台均必传)
|
||||
*/
|
||||
private String fileUrl;
|
||||
|
||||
/**
|
||||
* 安装包文件名(两平台均上传)
|
||||
*/
|
||||
private String fileName;
|
||||
|
||||
/**
|
||||
* 安装包字节数(两平台均上传;由前端从本地文件对象 file.raw.size 提交)
|
||||
*/
|
||||
private Long fileSize;
|
||||
|
||||
/**
|
||||
* 安装包校验值 MD5(预留,本期不填)
|
||||
*/
|
||||
private String md5;
|
||||
|
||||
/**
|
||||
* App Store 引导地址(选填)
|
||||
*/
|
||||
private String storeUrl;
|
||||
|
||||
/**
|
||||
* 更新说明(纯文本)
|
||||
*/
|
||||
private String changelog;
|
||||
|
||||
/**
|
||||
* 状态(0 停用 1 启用)
|
||||
*/
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 关键字(模糊匹配 versionName,查询用)
|
||||
*/
|
||||
private String keyword;
|
||||
|
||||
/**
|
||||
* 扩展查询参数(约定键 {@code beginTime} / {@code endTime}:发布时间区间)
|
||||
*/
|
||||
private Map<String, Object> params = new HashMap<>();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.dromara.device.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 检查更新入参({@code GET /app/version/latest})。
|
||||
* <p>
|
||||
* 仅 {@code platform} 与 {@code versionCode} 为判定核心输入,其余为回显型(本期<b>不落库、不埋点</b>)。
|
||||
* 校验注解的 {@code message} 刻意与 {@code AppVersionMsgConstants} 的文案逐字一致,
|
||||
* 使「Controller 层 {@code @Validated} 拦下」与「Service 层再判」两种路径返回同一提示。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Data
|
||||
public class AppVersionCheckBo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 平台(android / ios,必填)
|
||||
*/
|
||||
@NotBlank(message = "平台参数不合法")
|
||||
private String platform;
|
||||
|
||||
/**
|
||||
* APP 当前构建号(必填,正整数)
|
||||
*/
|
||||
@NotNull(message = "版本号不合法")
|
||||
@Positive(message = "版本号不合法")
|
||||
private Integer versionCode;
|
||||
|
||||
/**
|
||||
* APP 当前展示版本号(可选,本期仅回显)
|
||||
*/
|
||||
private String versionName;
|
||||
|
||||
/**
|
||||
* 机型(可选,本期仅回显)
|
||||
*/
|
||||
private String deviceModel;
|
||||
|
||||
/**
|
||||
* 系统版本(可选,本期仅回显)
|
||||
*/
|
||||
private String osVersion;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 告警统计视图对象({@code GET /device/alert/stats})
|
||||
* <p>
|
||||
* 供后台列表页顶部统计条使用。三个计数口径互相独立:
|
||||
* {@code total} 为该筛选条件下的全量,{@code unhandled + handled} 应恒等于 {@code total}。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Data
|
||||
public class AlertStatsVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 告警总数
|
||||
*/
|
||||
private Long total;
|
||||
|
||||
/**
|
||||
* 未处理数(status = 0)
|
||||
*/
|
||||
private Long unhandled;
|
||||
|
||||
/**
|
||||
* 已处理数(status = 1)
|
||||
*/
|
||||
private Long handled;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated;
|
||||
import org.apache.fesod.sheet.annotation.ExcelProperty;
|
||||
import org.apache.fesod.sheet.annotation.format.DateTimeFormat;
|
||||
import org.dromara.common.translation.annotation.Translation;
|
||||
import org.dromara.common.translation.constant.TransConstant;
|
||||
import org.dromara.device.domain.BmDeviceAlert;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 设备告警视图对象 bm_device_alert
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Data
|
||||
@ExcelIgnoreUnannotated
|
||||
@AutoMapper(target = BmDeviceAlert.class)
|
||||
public class AlertVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@ExcelProperty(value = "告警主键")
|
||||
private Long alertId;
|
||||
|
||||
/**
|
||||
* 设备主键
|
||||
*/
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 设备编号
|
||||
*/
|
||||
@ExcelProperty(value = "设备编号")
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 设备名称
|
||||
* <p>
|
||||
* 冗余自 {@code bm_device.name}(告警表本身不存名称),由服务层按 {@code deviceNo} 批量回填。
|
||||
* 设备已被物理删除或名称缺失时为 {@code null},前端降级展示设备编号。
|
||||
*/
|
||||
@ExcelProperty(value = "设备名称")
|
||||
private String deviceName;
|
||||
|
||||
/**
|
||||
* 告警类型(offline / low_battery_hood / low_battery_ring)
|
||||
*/
|
||||
@ExcelProperty(value = "告警类型")
|
||||
private String alertType;
|
||||
|
||||
/**
|
||||
* 告警级别(0 提示 1 警告 2 严重)
|
||||
* <p>
|
||||
* 与 {@code alertType} 不同,本字段**无字典**(DB char(1) 直存),前端本地映射文案。
|
||||
*/
|
||||
@ExcelProperty(value = "告警级别")
|
||||
private String alertLevel;
|
||||
|
||||
/**
|
||||
* 触发值(如电量)
|
||||
*/
|
||||
@ExcelProperty(value = "触发值")
|
||||
private String alertValue;
|
||||
|
||||
/**
|
||||
* 告警文案
|
||||
*/
|
||||
@ExcelProperty(value = "告警内容")
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 处理状态(0 未处理 1 已处理)
|
||||
*/
|
||||
@ExcelProperty(value = "处理状态")
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 处理人(后台账号主键)
|
||||
*/
|
||||
private Long handleBy;
|
||||
|
||||
/**
|
||||
* 处理人账号名
|
||||
*/
|
||||
@Translation(type = TransConstant.USER_ID_TO_NAME, mapper = "handleBy")
|
||||
@ExcelProperty(value = "处理人")
|
||||
private String handleUserName;
|
||||
|
||||
/**
|
||||
* 处理时间
|
||||
*/
|
||||
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
|
||||
@ExcelProperty(value = "处理时间")
|
||||
private LocalDateTime handleTime;
|
||||
|
||||
/**
|
||||
* 告警时间
|
||||
*/
|
||||
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
|
||||
@ExcelProperty(value = "告警时间")
|
||||
private LocalDateTime alertTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.dromara.device.domain.BmAppMessage;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* APP 消息视图对象({@code GET /app/message/list})。
|
||||
* <p>
|
||||
* 供 APP 消息列表与未读角标使用。{@code deviceNo} 一并返回,使 APP 可在用户点击消息时
|
||||
* 直接跳转设备详情({@code /app/device/{deviceNo}}),无需再查一次告警。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Data
|
||||
@AutoMapper(target = BmAppMessage.class)
|
||||
public class AppMessageVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 消息主键
|
||||
*/
|
||||
private Long messageId;
|
||||
|
||||
/**
|
||||
* 关联设备编号(供 APP 跳转设备详情)
|
||||
*/
|
||||
private String deviceNo;
|
||||
|
||||
/**
|
||||
* 关联告警主键
|
||||
*/
|
||||
private Long alertId;
|
||||
|
||||
/**
|
||||
* 告警类型(offline / low_battery_hood / low_battery_ring)
|
||||
*/
|
||||
private String alertType;
|
||||
|
||||
/**
|
||||
* 告警级别(0 提示 1 警告 2 严重)
|
||||
*/
|
||||
private String alertLevel;
|
||||
|
||||
/**
|
||||
* 消息标题
|
||||
*/
|
||||
private String title;
|
||||
|
||||
/**
|
||||
* 消息内容
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 已读标记(0 未读 1 已读)
|
||||
*/
|
||||
private String readFlag;
|
||||
|
||||
/**
|
||||
* 已读时间
|
||||
*/
|
||||
private LocalDateTime readTime;
|
||||
|
||||
/**
|
||||
* 产生时间
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
|
||||
}
|
||||
@@ -42,4 +42,9 @@ public class AppStreamPlayUrlsVo implements Serializable {
|
||||
*/
|
||||
private String webrtc;
|
||||
|
||||
/**
|
||||
* MJPEG(HTTP multipart,直传 JPEG)
|
||||
*/
|
||||
private String mjpeg;
|
||||
|
||||
}
|
||||
|
||||
@@ -28,12 +28,12 @@ public class AppStreamPlayVo implements Serializable {
|
||||
private String streamId;
|
||||
|
||||
/**
|
||||
* ZLM app
|
||||
* 流媒体应用名
|
||||
*/
|
||||
private String app;
|
||||
|
||||
/**
|
||||
* 当前是否已在 ZLM 上在线
|
||||
* 当前推流是否在线
|
||||
*/
|
||||
private Boolean online;
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* APP 检查更新响应视图对象({@code GET /app/version/latest} 的响应体)。
|
||||
* <p>
|
||||
* 由 {@code AppVersionServiceImpl#checkLatest} <b>手工组装</b>(不与实体自动映射):
|
||||
* 字段是「三态表达 + 安装包信息」的组合语义;Android 与 iOS 下发口径一致(storeUrl 选填)。
|
||||
* <p>
|
||||
* <b>三态表达(APP 侧判定口径)</b>
|
||||
* <table border="1">
|
||||
* <tr><th>判定</th><th>条件</th><th>APP 行为</th></tr>
|
||||
* <tr><td>无更新</td><td>{@code hasUpdate=false}</td><td>不弹窗({@code forceUpdate} 恒 false)</td></tr>
|
||||
* <tr><td>可选更新</td><td>{@code hasUpdate=true 且 forceUpdate=false}</td><td>可关闭弹窗:立即更新 / 稍后再说</td></tr>
|
||||
* <tr><td>强制更新</td><td>{@code hasUpdate=true 且 forceUpdate=true}</td><td>不可关闭弹窗(iOS 仅能引导 App Store,无法真正拦截)</td></tr>
|
||||
* </table>
|
||||
* <p>
|
||||
* ⚠️ 类型序列化:{@code latestVersionCode} / {@code currentVersionCode} 为 {@code Integer} → JSON <b>数字</b>;
|
||||
* {@code fileSize} 为 {@code Long} → JSON <b>字符串</b>(APP 需 {@code Number()} 处理后展示)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Data
|
||||
public class AppVersionLatestVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 平台(回显入参)
|
||||
*/
|
||||
private String platform;
|
||||
|
||||
/**
|
||||
* 是否有新版本(三态判定主开关)
|
||||
*/
|
||||
private Boolean hasUpdate;
|
||||
|
||||
/**
|
||||
* 是否强制更新;{@code hasUpdate=false} 时恒为 false
|
||||
*/
|
||||
private Boolean forceUpdate;
|
||||
|
||||
/**
|
||||
* 最新构建号(Integer → JSON 数字;无启用记录或已是最新时为 null)
|
||||
*/
|
||||
private Integer latestVersionCode;
|
||||
|
||||
/**
|
||||
* 最新展示版本号(弹窗标题用)
|
||||
*/
|
||||
private String latestVersionName;
|
||||
|
||||
/**
|
||||
* 更新说明
|
||||
*/
|
||||
private String changelog;
|
||||
|
||||
/**
|
||||
* 安装包字节数(Long → JSON 字符串;两平台均下发)
|
||||
*/
|
||||
private Long fileSize;
|
||||
|
||||
/**
|
||||
* 安装包校验值 MD5(本期多为 null;两平台均下发)
|
||||
*/
|
||||
private String md5;
|
||||
|
||||
/**
|
||||
* 安装包 OSS 地址(两平台均下发;有值即下发)
|
||||
*/
|
||||
private String downloadUrl;
|
||||
|
||||
/**
|
||||
* App Store 引导地址(选填;有值即下发)
|
||||
*/
|
||||
private String storeUrl;
|
||||
|
||||
/**
|
||||
* 回显入参,便于 APP 自校验
|
||||
*/
|
||||
private Integer currentVersionCode;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package org.dromara.device.domain.vo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated;
|
||||
import org.apache.fesod.sheet.annotation.ExcelProperty;
|
||||
import org.dromara.device.domain.BmAppVersion;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* APP 客户端版本视图对象 bm_app_version
|
||||
* <p>
|
||||
* 后台列表 / 详情回显。与实体 {@link BmAppVersion} 自动映射({@code @AutoMapper})。
|
||||
* <p>
|
||||
* ⚠️ 前端字段类型约定(框架序列化):{@code versionId} / {@code ossId} / {@code fileSize}({@code Long})
|
||||
* 在 JSON 中为<b>字符串</b>;{@code versionCode}({@code Integer})为<b>数字</b>。前端据此兼容处理。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Data
|
||||
@ExcelIgnoreUnannotated
|
||||
@AutoMapper(target = BmAppVersion.class)
|
||||
public class AppVersionVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@ExcelProperty(value = "版本主键")
|
||||
private Long versionId;
|
||||
|
||||
/**
|
||||
* 平台(android / ios)
|
||||
*/
|
||||
@ExcelProperty(value = "平台")
|
||||
private String platform;
|
||||
|
||||
/**
|
||||
* 展示版本号,如 1.0.10
|
||||
*/
|
||||
@ExcelProperty(value = "展示版本号")
|
||||
private String versionName;
|
||||
|
||||
/**
|
||||
* 构建号(唯一比较依据)
|
||||
*/
|
||||
@ExcelProperty(value = "构建号")
|
||||
private Integer versionCode;
|
||||
|
||||
/**
|
||||
* 强制更新(0 否 1 是)
|
||||
*/
|
||||
@ExcelProperty(value = "强制更新")
|
||||
private String forceUpdate;
|
||||
|
||||
/**
|
||||
* 关联 sys_oss.oss_id
|
||||
*/
|
||||
private Long ossId;
|
||||
|
||||
/**
|
||||
* 安装包 URL(两平台均上传)
|
||||
*/
|
||||
private String fileUrl;
|
||||
|
||||
/**
|
||||
* 安装包文件名(两平台均上传)
|
||||
*/
|
||||
@ExcelProperty(value = "安装包文件名")
|
||||
private String fileName;
|
||||
|
||||
/**
|
||||
* 安装包字节数(两平台均上传)
|
||||
*/
|
||||
private Long fileSize;
|
||||
|
||||
/**
|
||||
* 安装包校验值 MD5(预留,本期多为空)
|
||||
*/
|
||||
private String md5;
|
||||
|
||||
/**
|
||||
* App Store 引导地址(选填)
|
||||
*/
|
||||
private String storeUrl;
|
||||
|
||||
/**
|
||||
* 更新说明
|
||||
*/
|
||||
@ExcelProperty(value = "更新说明")
|
||||
private String changelog;
|
||||
|
||||
/**
|
||||
* 状态(0 停用 1 启用)
|
||||
*/
|
||||
@ExcelProperty(value = "状态")
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 发布时间
|
||||
*/
|
||||
private LocalDateTime publishTime;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
private String remark;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
package org.dromara.device.email;
|
||||
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import cn.hutool.extra.mail.MailAccount;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.mail.core.MailBuilder;
|
||||
import org.dromara.common.mail.core.GmailCodeService;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.dromara.device.config.AppProperties;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
@@ -41,6 +40,7 @@ import java.time.Duration;
|
||||
public class AppEmailService {
|
||||
|
||||
private final AppProperties appProperties;
|
||||
private final GmailCodeService gmailCodeService;
|
||||
|
||||
/**
|
||||
* 生成并按场景发送邮箱验证码(含 60 秒重发限流与域名白名单校验)。
|
||||
@@ -164,50 +164,16 @@ public class AppEmailService {
|
||||
email, scene, code, cfg.getExpiryMinutes());
|
||||
return;
|
||||
}
|
||||
String from = StringUtils.isBlank(cfg.getFrom()) ? cfg.getUser() : cfg.getFrom();
|
||||
try {
|
||||
MailAccount account = new MailAccount();
|
||||
account.setHost(cfg.getHost());
|
||||
account.setPort(cfg.getPort());
|
||||
account.setAuth(true);
|
||||
account.setUser(cfg.getUser());
|
||||
account.setPass(cfg.getPass());
|
||||
account.setFrom(from);
|
||||
account.setSslEnable(cfg.isSslEnable());
|
||||
account.setStarttlsEnable(cfg.isStarttlsEnable());
|
||||
account.setSocketFactoryPort(cfg.getPort());
|
||||
account.setConnectionTimeout(cfg.getConnectionTimeout());
|
||||
account.setTimeout(cfg.getTimeout());
|
||||
|
||||
MailBuilder.of(account)
|
||||
.to(email)
|
||||
.subject(cfg.getSubject())
|
||||
.html(buildHtml(code, cfg.getExpiryMinutes()))
|
||||
.send();
|
||||
// 真实发信统一复用刚接入的 Gmail 服务,确保 APP 与后台使用同一套账号、超时和发信配置。
|
||||
gmailCodeService.sendVerifyCode(email, code, cfg.getExpiryMinutes());
|
||||
log.info("[APP邮箱] 验证码已发送:收件邮箱={},场景={}", email, scene);
|
||||
} catch (Exception e) {
|
||||
// 不记录验证码本身,避免真实发信失败时把验证码写进日志
|
||||
log.error("APP 邮箱验证码发送异常 email={} host={}:{}(若为连接超时,请确认服务器可访问 smtp.gmail.com)",
|
||||
email, cfg.getHost(), cfg.getPort(), e);
|
||||
log.error("APP 邮箱验证码发送异常 email={}(若为连接超时,请确认服务器可访问 smtp.gmail.com)",
|
||||
email, e);
|
||||
throw new ServiceException(AppMsgConstants.EMAIL_SEND_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造验证码邮件正文(纯 HTML,无外部资源,避免被邮件客户端拦截)。
|
||||
*
|
||||
* @param code 验证码
|
||||
* @param expiryMinutes 有效期(分钟)
|
||||
* @return HTML 正文
|
||||
*/
|
||||
private String buildHtml(String code, int expiryMinutes) {
|
||||
return "<div style=\"font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;"
|
||||
+ "font-size:14px;line-height:1.6;color:#2c2c2a\">"
|
||||
+ "<p>您正在使用智能飞禽物联平台,本次操作的验证码为:</p>"
|
||||
+ "<p style=\"font-size:24px;font-weight:500;letter-spacing:4px;margin:16px 0\">" + code + "</p>"
|
||||
+ "<p>验证码 " + expiryMinutes + " 分钟内有效,请勿泄露给他人。</p>"
|
||||
+ "<p style=\"color:#5f5e5a\">若非本人操作,请忽略本邮件。</p>"
|
||||
+ "</div>";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.dromara.device.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* APP 平台枚举
|
||||
* <p>
|
||||
* 对应字典 {@code bm_app_platform}:{@code android} 安卓 / {@code ios} iOS。
|
||||
* 取值与表 {@code bm_app_version.platform}、APP 检查更新入参 {@code platform} 完全一致。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum AppPlatformEnum {
|
||||
|
||||
/**
|
||||
* 安卓
|
||||
*/
|
||||
ANDROID("android", "安卓"),
|
||||
|
||||
/**
|
||||
* iOS
|
||||
*/
|
||||
IOS("ios", "iOS");
|
||||
|
||||
/**
|
||||
* 平台编码
|
||||
*/
|
||||
private final String code;
|
||||
|
||||
/**
|
||||
* 平台文案
|
||||
*/
|
||||
private final String label;
|
||||
|
||||
/**
|
||||
* 判定平台编码是否合法(取值严格等于枚举定义,不接受大小写或别名变体)。
|
||||
*
|
||||
* @param code 待校验的平台编码
|
||||
* @return {@code true} 合法;{@code false} 非法(含 {@code null})
|
||||
*/
|
||||
public static boolean isValid(String code) {
|
||||
if (code == null) {
|
||||
return false;
|
||||
}
|
||||
for (AppPlatformEnum item : values()) {
|
||||
if (item.code.equals(code)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.dromara.device.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* APP 版本状态枚举
|
||||
* <p>
|
||||
* 对应字典 {@code bm_app_version_status}:{@code 0} 停用 / {@code 1} 启用。
|
||||
* 逻辑删除取值口径另见 {@code common-mybatis.yml}({@code logicDeleteValue=1})。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum AppVersionStatusEnum {
|
||||
|
||||
/**
|
||||
* 停用
|
||||
*/
|
||||
DISABLED("0", "停用"),
|
||||
|
||||
/**
|
||||
* 启用
|
||||
*/
|
||||
ENABLED("1", "启用");
|
||||
|
||||
/**
|
||||
* 状态编码
|
||||
*/
|
||||
private final String code;
|
||||
|
||||
/**
|
||||
* 状态文案
|
||||
*/
|
||||
private final String label;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.dromara.device.mapper;
|
||||
|
||||
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.dromara.device.domain.BmAppMessage;
|
||||
import org.dromara.device.domain.vo.AppMessageVo;
|
||||
|
||||
/**
|
||||
* APP 消息 Mapper 接口
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
public interface BmAppMessageMapper extends BaseMapperPlus<BmAppMessage, AppMessageVo> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.dromara.device.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.dromara.device.domain.BmAppVersion;
|
||||
import org.dromara.device.domain.vo.AppVersionVo;
|
||||
|
||||
/**
|
||||
* APP 客户端版本 Mapper 接口
|
||||
* <p>
|
||||
* 基础 CRUD 走 {@link BaseMapperPlus},自定义 SQL 预留(见 {@code BmAppVersionMapper.xml})。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
public interface BmAppVersionMapper extends BaseMapperPlus<BmAppVersion, AppVersionVo> {
|
||||
|
||||
/**
|
||||
* 查询平台构建号历史高水位,包含已逻辑删除记录。
|
||||
*
|
||||
* @param platform 平台
|
||||
* @param excludeVersionId 修改时排除自身;新增时为 {@code null}
|
||||
* @return 历史最大构建号;没有记录时返回 {@code null}
|
||||
*/
|
||||
Integer selectMaxVersionCodeIncludingDeleted(@Param("platform") String platform,
|
||||
@Param("excludeVersionId") Long excludeVersionId);
|
||||
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package org.dromara.device.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
import org.dromara.device.domain.BmDeviceAlert;
|
||||
import org.dromara.device.domain.vo.AlertVo;
|
||||
|
||||
/**
|
||||
* 设备告警 Mapper 接口
|
||||
@@ -9,6 +11,14 @@ import org.dromara.device.domain.BmDeviceAlert;
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
*/
|
||||
public interface BmDeviceAlertMapper extends BaseMapperPlus<BmDeviceAlert, BmDeviceAlert> {
|
||||
public interface BmDeviceAlertMapper extends BaseMapperPlus<BmDeviceAlert, AlertVo> {
|
||||
|
||||
/**
|
||||
* 查询尚未生成 APP 消息的告警 outbox 记录。
|
||||
*
|
||||
* @param page 只用于数据库方言分页,调用方关闭总数查询
|
||||
* @return 待补偿告警页
|
||||
*/
|
||||
Page<BmDeviceAlert> selectPendingMessageAlerts(Page<BmDeviceAlert> page);
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
package org.dromara.device.mjpeg;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 内存中的 MJPEG 帧中转:每台设备保留最新一帧和观看人数。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Component
|
||||
public class MjpegFrameHub {
|
||||
|
||||
private final ConcurrentHashMap<String, Session> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 开播时建立会话,供空闲停推计时。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
*/
|
||||
public void open(String deviceNo) {
|
||||
open(deviceNo, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立或刷新推流会话,并绑定本次签发的推流凭证。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @param ingestToken 本次推流凭证;仅播放测试场景可为空
|
||||
*/
|
||||
public void open(String deviceNo, String ingestToken) {
|
||||
long now = System.currentTimeMillis();
|
||||
sessions.compute(deviceNo, (key, session) -> {
|
||||
if (session == null || session.closed || ingestToken != null) {
|
||||
// 新 token 代表新一轮推流;替换会话可立即切断旧播放连接。
|
||||
return new Session(now, ingestToken);
|
||||
}
|
||||
synchronized (session) {
|
||||
session.ingestToken = ingestToken;
|
||||
session.frame = null;
|
||||
session.lastPushAt = 0L;
|
||||
// 重复开播且当前无人观看时,从本次开播重新计算空闲窗口。
|
||||
if (session.viewers.get() == 0) {
|
||||
session.idleSince.set(now);
|
||||
}
|
||||
}
|
||||
return session;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入最新一帧。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @param jpeg JPEG 字节
|
||||
*/
|
||||
public boolean publish(String deviceNo, String ingestToken, byte[] jpeg) {
|
||||
AtomicBoolean published = new AtomicBoolean();
|
||||
// 只允许写入已由 open 建立的会话,避免停播与在途推帧竞争时复活会话。
|
||||
sessions.computeIfPresent(deviceNo, (key, session) -> {
|
||||
if (!session.closed && Objects.equals(session.ingestToken, ingestToken)) {
|
||||
session.frame = jpeg;
|
||||
session.lastPushAt = System.currentTimeMillis();
|
||||
published.set(true);
|
||||
}
|
||||
return session;
|
||||
});
|
||||
return published.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容仅用于无 token 测试会话的旧调用形式。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @param jpeg JPEG 字节
|
||||
* @return 是否写入
|
||||
*/
|
||||
public boolean publish(String deviceNo, byte[] jpeg) {
|
||||
return publish(deviceNo, null, jpeg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 最新一帧,没有则 {@code null}。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @return JPEG 或 {@code null}
|
||||
*/
|
||||
public byte[] latest(String deviceNo) {
|
||||
Session session = sessions.get(deviceNo);
|
||||
return session == null || session.closed ? null : session.frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取观看连接所属会话的最新帧,避免停播重开后旧连接串到新会话。
|
||||
*
|
||||
* @param lease 观看租约
|
||||
* @return JPEG 或 {@code null}
|
||||
*/
|
||||
public byte[] latest(ViewerLease lease) {
|
||||
Session session = activeSession(lease);
|
||||
return session == null ? null : session.frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* 最近 {@code ttlSeconds} 秒内是否收到过帧。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @param ttlSeconds 帧过期秒数
|
||||
* @return 是否在线
|
||||
*/
|
||||
public boolean online(String deviceNo, int ttlSeconds) {
|
||||
Session session = sessions.get(deviceNo);
|
||||
if (session == null || session.closed || session.lastPushAt == 0L) {
|
||||
return false;
|
||||
}
|
||||
return System.currentTimeMillis() - session.lastPushAt <= ttlSeconds * 1000L;
|
||||
}
|
||||
|
||||
/**
|
||||
* 增加一名观看者。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
*/
|
||||
public ViewerLease addViewer(String deviceNo) {
|
||||
Session session = sessions.get(deviceNo);
|
||||
if (session == null) {
|
||||
return ViewerLease.invalid(deviceNo);
|
||||
}
|
||||
synchronized (session) {
|
||||
// 开播替换或停播清理可能已把该对象移出当前会话,不能把观看者挂到旧对象上。
|
||||
if (session.closed || sessions.get(deviceNo) != session) {
|
||||
return ViewerLease.invalid(deviceNo);
|
||||
}
|
||||
session.viewers.incrementAndGet();
|
||||
session.idleSince.set(0L);
|
||||
}
|
||||
return new ViewerLease(deviceNo, session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 减少一名观看者。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
*/
|
||||
public void removeViewer(String deviceNo) {
|
||||
Session session = sessions.get(deviceNo);
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
decrementViewer(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放指定观看连接的租约,操作具备幂等性。
|
||||
*
|
||||
* @param lease 观看租约
|
||||
*/
|
||||
public void removeViewer(ViewerLease lease) {
|
||||
if (lease == null || !lease.released.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
Session current = sessions.get(lease.deviceNo);
|
||||
if (current == lease.session && !current.closed) {
|
||||
decrementViewer(current);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断观看连接是否仍属于当前活动会话。
|
||||
*
|
||||
* @param lease 观看租约
|
||||
* @return 是否有效
|
||||
*/
|
||||
public boolean active(ViewerLease lease) {
|
||||
return activeSession(lease) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前观看人数。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @return 人数
|
||||
*/
|
||||
public int viewerCount(String deviceNo) {
|
||||
Session session = sessions.get(deviceNo);
|
||||
return session == null || session.closed ? 0 : session.viewers.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 空闲计时起点;无会话返回 0。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @return epoch 毫秒
|
||||
*/
|
||||
public long idleSince(String deviceNo) {
|
||||
Session session = sessions.get(deviceNo);
|
||||
return session == null || session.closed ? 0L : session.idleSince.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子地清理达到空闲阈值的会话,避免扫描后新观看者加入仍被误停推。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @param expectedIdleAt 扫描时读取的空闲起点
|
||||
* @param now 当前时间
|
||||
* @param idleMs 空闲阈值
|
||||
* @return 是否成功清理
|
||||
*/
|
||||
public boolean clearIfIdle(String deviceNo, long expectedIdleAt, long now, long idleMs) {
|
||||
final boolean[] cleared = {false};
|
||||
sessions.computeIfPresent(deviceNo, (key, session) -> {
|
||||
synchronized (session) {
|
||||
if (!session.closed && session.viewers.get() == 0
|
||||
&& session.idleSince.get() == expectedIdleAt
|
||||
&& expectedIdleAt > 0L && now - expectedIdleAt >= idleMs) {
|
||||
session.closed = true;
|
||||
session.frame = null;
|
||||
cleared[0] = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return session;
|
||||
});
|
||||
return cleared[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前仍在中转的设备编号。
|
||||
*
|
||||
* @return 设备编号集合
|
||||
*/
|
||||
public Set<String> deviceNos() {
|
||||
return Set.copyOf(sessions.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* 清掉该设备的帧与观看计数。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
*/
|
||||
public void clear(String deviceNo) {
|
||||
Session session = sessions.remove(deviceNo);
|
||||
if (session != null) {
|
||||
synchronized (session) {
|
||||
session.closed = true;
|
||||
session.frame = null;
|
||||
session.viewers.set(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Session {
|
||||
private final AtomicLong idleSince;
|
||||
private volatile byte[] frame;
|
||||
private volatile long lastPushAt;
|
||||
private volatile String ingestToken;
|
||||
private final AtomicInteger viewers = new AtomicInteger();
|
||||
private volatile boolean closed;
|
||||
|
||||
private Session(long createdAt, String ingestToken) {
|
||||
this.idleSince = new AtomicLong(createdAt);
|
||||
this.ingestToken = ingestToken;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将观看人数减一,并在最后一名观看者离开时记录空闲起点。
|
||||
*/
|
||||
private void decrementViewer(Session session) {
|
||||
synchronized (session) {
|
||||
session.viewers.updateAndGet(count -> Math.max(0, count - 1));
|
||||
if (session.viewers.get() == 0) {
|
||||
session.idleSince.compareAndSet(0L, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Session activeSession(ViewerLease lease) {
|
||||
if (lease == null || lease.released.get()) {
|
||||
return null;
|
||||
}
|
||||
Session session = sessions.get(lease.deviceNo);
|
||||
return session == lease.session && !session.closed ? session : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个 HTTP 播放连接持有的会话租约。
|
||||
*/
|
||||
public static final class ViewerLease {
|
||||
private final String deviceNo;
|
||||
private final Session session;
|
||||
private final AtomicBoolean released;
|
||||
|
||||
private ViewerLease(String deviceNo, Session session) {
|
||||
this.deviceNo = deviceNo;
|
||||
this.session = session;
|
||||
this.released = new AtomicBoolean();
|
||||
}
|
||||
|
||||
private static ViewerLease invalid(String deviceNo) {
|
||||
ViewerLease lease = new ViewerLease(deviceNo, null);
|
||||
lease.released.set(true);
|
||||
return lease;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package org.dromara.device.mjpeg;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.device.config.MjpegProperties;
|
||||
import org.dromara.device.config.ZlmProperties;
|
||||
import org.dromara.device.mqtt.DeviceCommandPublisher;
|
||||
import org.dromara.device.mqtt.payload.StreamCommandPayload;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 开播后持续无人观看则通知设备停推,避免摄像头空转耗电。
|
||||
* <p>
|
||||
* 不用 {@code @Scheduled}:与 {@code DeviceOnlineMonitor} 相同,SnailJob 关闭时注解任务不会跑。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MjpegIdleMonitor {
|
||||
|
||||
private final MjpegProperties properties;
|
||||
private final MjpegFrameHub hub;
|
||||
private final MjpegIngestTokenService ingestTokenService;
|
||||
private final DeviceCommandPublisher commandPublisher;
|
||||
private final ZlmProperties zlmProperties;
|
||||
private final MjpegLifecycleCoordinator lifecycleCoordinator;
|
||||
private final ScheduledExecutorService scheduledExecutorService;
|
||||
|
||||
private volatile ScheduledFuture<?> scanFuture;
|
||||
|
||||
/**
|
||||
* 启动空闲扫描。
|
||||
*/
|
||||
@PostConstruct
|
||||
public void start() {
|
||||
if (!properties.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
scanFuture = scheduledExecutorService.scheduleWithFixedDelay(
|
||||
this::scanSafely, 5, 2, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止扫描。
|
||||
*/
|
||||
@PreDestroy
|
||||
public void stop() {
|
||||
ScheduledFuture<?> future = scanFuture;
|
||||
if (future != null) {
|
||||
future.cancel(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描入口隔离异常,避免单台设备状态异常导致定时任务永久停止。
|
||||
*/
|
||||
private void scanSafely() {
|
||||
try {
|
||||
scan();
|
||||
} catch (Exception e) {
|
||||
log.warn("[MJPEG] 空闲扫描失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 遍历活动会话并对达到空闲阈值的设备发送停推指令。
|
||||
*/
|
||||
private void scan() {
|
||||
// 空闲时间从最后一名观看者离开时开始,而不是从开播时刻开始。
|
||||
long idleMs = properties.getIdleStopSeconds() * 1000L;
|
||||
long now = System.currentTimeMillis();
|
||||
for (String deviceNo : hub.deviceNos()) {
|
||||
lifecycleCoordinator.execute(deviceNo, () -> reclaimIfIdle(deviceNo, now, idleMs));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在设备生命周期锁内复核并回收空闲会话,避免与重新开播交错。
|
||||
*/
|
||||
private void reclaimIfIdle(String deviceNo, long now, long idleMs) {
|
||||
long idleSince = hub.idleSince(deviceNo);
|
||||
if (idleSince == 0L || !hub.clearIfIdle(deviceNo, idleSince, now, idleMs)) {
|
||||
return;
|
||||
}
|
||||
log.info("[MJPEG] 无人观看,通知设备停推 deviceNo={}", deviceNo);
|
||||
commandPublisher.publishStream(deviceNo, StreamCommandPayload.stop(
|
||||
DeviceCommandPublisher.newMsgId(), deviceNo, zlmProperties.getApp()));
|
||||
ingestTokenService.revoke(deviceNo);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.dromara.device.mjpeg;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.dromara.device.constant.DeviceMqttConstants;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 设备 MJPEG 推流 token(按 deviceNo 存放,开播签发、停播删除)。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MjpegIngestTokenService {
|
||||
|
||||
private static final Duration TTL = Duration.ofHours(2);
|
||||
|
||||
/**
|
||||
* 为设备签发推流 token,覆盖旧值。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @return token
|
||||
*/
|
||||
public String issue(String deviceNo) {
|
||||
String token = IdUtil.fastSimpleUUID();
|
||||
RedisUtils.setCacheObject(key(deviceNo), token, TTL);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验推流 token。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @param token 请求携带的 token
|
||||
* @return 是否通过
|
||||
*/
|
||||
public boolean validate(String deviceNo, String token) {
|
||||
if (StringUtils.isBlank(deviceNo) || StringUtils.isBlank(token)) {
|
||||
return false;
|
||||
}
|
||||
String cached = RedisUtils.getCacheObject(key(deviceNo));
|
||||
return Objects.equals(cached, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到合法帧后延长 token。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
*/
|
||||
public void touch(String deviceNo) {
|
||||
// 只延长现有 key 的 TTL,不重新写入值,避免停播撤销与在途帧竞争时复活 token。
|
||||
RedisUtils.expire(key(deviceNo), TTL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停播时删除 token。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
*/
|
||||
public void revoke(String deviceNo) {
|
||||
RedisUtils.deleteObject(key(deviceNo));
|
||||
}
|
||||
|
||||
private String key(String deviceNo) {
|
||||
return DeviceMqttConstants.MJPEG_INGEST_TOKEN_PREFIX + deviceNo;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.dromara.device.mjpeg;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/**
|
||||
* MJPEG 会话生命周期协调器。
|
||||
* <p>
|
||||
* 同一设备的开播、停播与空闲回收必须串行,避免旧会话的延迟清理误删新签发的 token。
|
||||
* 使用固定数量的分段锁,既限制内存占用,也允许不同设备并行处理。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Component
|
||||
public class MjpegLifecycleCoordinator {
|
||||
|
||||
private static final int STRIPE_COUNT = 64;
|
||||
|
||||
private final ReentrantLock[] locks = new ReentrantLock[STRIPE_COUNT];
|
||||
|
||||
public MjpegLifecycleCoordinator() {
|
||||
for (int index = 0; index < locks.length; index++) {
|
||||
locks[index] = new ReentrantLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在设备对应的生命周期锁内执行操作。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @param action 生命周期操作
|
||||
*/
|
||||
public void execute(String deviceNo, Runnable action) {
|
||||
ReentrantLock lock = locks[Math.floorMod(deviceNo.hashCode(), locks.length)];
|
||||
lock.lock();
|
||||
try {
|
||||
action.run();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package org.dromara.device.mjpeg;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.utils.ServletUtils;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.device.config.MjpegProperties;
|
||||
import org.dromara.device.zlm.stream.ZlmPlayAuthService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* MJPEG 推流入口与播放入口。
|
||||
* <p>
|
||||
* 免登录,靠 query {@code token} 鉴权:推流用开播签发的 ingest token,播放用 APP play token。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Slf4j
|
||||
@SaIgnore
|
||||
@RestController
|
||||
@RequestMapping("/mjpeg")
|
||||
@RequiredArgsConstructor
|
||||
public class MjpegStreamController {
|
||||
|
||||
private static final byte[] JPEG_SOI = {(byte) 0xFF, (byte) 0xD8};
|
||||
private static final String BOUNDARY = "frame";
|
||||
private static final long TOKEN_RECHECK_INTERVAL_MS = 1_000L;
|
||||
|
||||
private final MjpegFrameHub hub;
|
||||
private final MjpegIngestTokenService ingestTokenService;
|
||||
private final MjpegProperties properties;
|
||||
private final ZlmPlayAuthService playAuthService;
|
||||
|
||||
/**
|
||||
* 设备推一帧 JPEG。固件按约 5fps 循环 POST。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @param token 开播指令 pushUrl 上的 token
|
||||
* @param body JPEG 字节
|
||||
* @return 成功或失败
|
||||
*/
|
||||
@PostMapping(value = "/ingest/{deviceNo}",
|
||||
consumes = {MediaType.IMAGE_JPEG_VALUE, MediaType.APPLICATION_OCTET_STREAM_VALUE, MediaType.ALL_VALUE})
|
||||
public R<Void> ingest(@PathVariable String deviceNo,
|
||||
@RequestParam String token,
|
||||
@RequestBody byte[] body) {
|
||||
if (!properties.isEnabled()) {
|
||||
return R.fail("MJPEG 未启用");
|
||||
}
|
||||
if (!ingestTokenService.validate(deviceNo, token)) {
|
||||
return R.fail("推流凭证无效");
|
||||
}
|
||||
if (body == null || body.length < 2) {
|
||||
return R.fail("空帧");
|
||||
}
|
||||
if (body.length > properties.getMaxFrameBytes()) {
|
||||
return R.fail("帧过大");
|
||||
}
|
||||
if (body[0] != JPEG_SOI[0] || body[1] != JPEG_SOI[1]) {
|
||||
return R.fail("不是 JPEG");
|
||||
}
|
||||
if (!hub.publish(deviceNo, token, body)) {
|
||||
return R.fail("推流会话已结束");
|
||||
}
|
||||
ingestTokenService.touch(deviceNo);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 最新一帧,便于浏览器直接打开验证。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @param token 播放 token
|
||||
* @param response HTTP 响应
|
||||
*/
|
||||
@GetMapping(value = "/play/{deviceNo}/snapshot", produces = MediaType.IMAGE_JPEG_VALUE)
|
||||
public void snapshot(@PathVariable String deviceNo,
|
||||
@RequestParam String token,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) throws Exception {
|
||||
String clientIp = ServletUtils.getClientIP(request);
|
||||
if (!allowPlay(deviceNo, token, clientIp)) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
return;
|
||||
}
|
||||
byte[] frame = hub.latest(deviceNo);
|
||||
if (frame == null) {
|
||||
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
response.setContentType(MediaType.IMAGE_JPEG_VALUE);
|
||||
response.setContentLength(frame.length);
|
||||
response.getOutputStream().write(frame);
|
||||
}
|
||||
|
||||
/**
|
||||
* multipart MJPEG 播放流,APP / {@code <img>} 可持续拉。
|
||||
*
|
||||
* @param deviceNo 设备编号
|
||||
* @param token 播放 token
|
||||
* @param response HTTP 响应
|
||||
*/
|
||||
@GetMapping("/play/{deviceNo}")
|
||||
public void play(@PathVariable String deviceNo,
|
||||
@RequestParam String token,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) throws Exception {
|
||||
String clientIp = ServletUtils.getClientIP(request);
|
||||
if (!allowPlay(deviceNo, token, clientIp)) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
return;
|
||||
}
|
||||
long intervalMs = Math.max(40L, 1000L / Math.max(1, properties.getFps()));
|
||||
MjpegFrameHub.ViewerLease lease = hub.addViewer(deviceNo);
|
||||
if (!hub.active(lease)) {
|
||||
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
response.setStatus(HttpServletResponse.SC_OK);
|
||||
response.setContentType("multipart/x-mixed-replace;boundary=" + BOUNDARY);
|
||||
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
response.setHeader("Pragma", "no-cache");
|
||||
response.setHeader("Connection", "close");
|
||||
try (OutputStream out = response.getOutputStream()) {
|
||||
long nextTokenCheckAt = System.currentTimeMillis() + TOKEN_RECHECK_INTERVAL_MS;
|
||||
while (!Thread.currentThread().isInterrupted() && hub.active(lease)) {
|
||||
// 长连接定期复核 token,确保撤销或过期后及时释放 Jetty 工作线程。
|
||||
long now = System.currentTimeMillis();
|
||||
if (now >= nextTokenCheckAt) {
|
||||
if (!allowPlay(deviceNo, token, clientIp)) {
|
||||
break;
|
||||
}
|
||||
nextTokenCheckAt = now + TOKEN_RECHECK_INTERVAL_MS;
|
||||
}
|
||||
byte[] frame = hub.latest(lease);
|
||||
if (frame != null) {
|
||||
String header = "\r\n--" + BOUNDARY + "\r\n"
|
||||
+ "Content-Type: image/jpeg\r\n"
|
||||
+ "Content-Length: " + frame.length + "\r\n\r\n";
|
||||
out.write(header.getBytes(StandardCharsets.US_ASCII));
|
||||
out.write(frame);
|
||||
out.flush();
|
||||
}
|
||||
Thread.sleep(intervalMs);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
// 保留中断标记,便于容器在线程回收时正确识别连接已结束。
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
log.debug("[MJPEG] 播放连接结束 deviceNo={}", deviceNo);
|
||||
} finally {
|
||||
hub.removeViewer(lease);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean allowPlay(String deviceNo, String token, String clientIp) {
|
||||
if (!properties.isEnabled() || StringUtils.isBlank(token)) {
|
||||
return false;
|
||||
}
|
||||
return playAuthService.validate(token, deviceNo, clientIp);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.dromara.device.mqtt;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.dromara.device.config.DeviceMqttProperties;
|
||||
import org.dromara.device.constant.DeviceMqttConstants;
|
||||
import org.dromara.device.mqtt.support.DeviceAlertWriter;
|
||||
import org.redisson.api.RLock;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* APP 告警消息 outbox 补偿任务。
|
||||
* <p>
|
||||
* 项目的 Spring Scheduling 受 SnailJob 开关控制,因此沿用设备离线扫描的显式调度方式;
|
||||
* 多实例通过 Redis 锁互斥,消息表的 {@code alert_id} 唯一键再做最终幂等兜底。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AppMessageRetryMonitor {
|
||||
|
||||
private final DeviceMqttProperties properties;
|
||||
private final DeviceAlertWriter alertWriter;
|
||||
private final ScheduledExecutorService scheduledExecutorService;
|
||||
|
||||
private volatile ScheduledFuture<?> retryFuture;
|
||||
|
||||
/** 启动补偿调度,首轮延迟一个周期,避免与应用启动抢占数据库连接。 */
|
||||
@PostConstruct
|
||||
public void start() {
|
||||
long intervalSeconds = Math.max(1L, properties.getAppMessageRetryInterval().toSeconds());
|
||||
retryFuture = scheduledExecutorService.scheduleWithFixedDelay(
|
||||
this::retrySafely, intervalSeconds, intervalSeconds, TimeUnit.SECONDS);
|
||||
log.info("APP 告警消息补偿已启动 => 间隔: {}s, 单轮上限: {}",
|
||||
intervalSeconds, properties.getAppMessageRetryBatchSize());
|
||||
}
|
||||
|
||||
/** 停止补偿调度。 */
|
||||
@PreDestroy
|
||||
public void stop() {
|
||||
ScheduledFuture<?> future = retryFuture;
|
||||
if (future != null) {
|
||||
future.cancel(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获单轮所有异常,避免 ScheduledExecutorService 因一次失败永久取消后续任务。
|
||||
*/
|
||||
private void retrySafely() {
|
||||
RLock lock = null;
|
||||
boolean locked = false;
|
||||
try {
|
||||
// 取得客户端和锁对象也可能因 Redis 短暂不可用而失败,必须一并纳入本轮异常兜底。
|
||||
lock = RedisUtils.getClient().getLock(DeviceMqttConstants.APP_MESSAGE_RETRY_LOCK);
|
||||
locked = lock.tryLock(0, TimeUnit.SECONDS);
|
||||
if (!locked) {
|
||||
return;
|
||||
}
|
||||
alertWriter.retryPendingMessages(properties.getAppMessageRetryBatchSize());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
log.error("APP 告警消息补偿异常,本轮跳过", e);
|
||||
} finally {
|
||||
if (locked && lock != null) {
|
||||
try {
|
||||
lock.unlock();
|
||||
} catch (Exception e) {
|
||||
// Redis 短暂不可用时不能让异常逃出,否则定时线程会永久停止后续补偿。
|
||||
log.warn("APP 告警消息补偿锁释放失败,将依赖锁租期自动释放", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,14 +125,43 @@ public class DeviceCommandPublisher {
|
||||
}
|
||||
String json = JsonUtils.toJsonString(payload);
|
||||
boolean ok = client.publish(topic, json.getBytes(StandardCharsets.UTF_8), MqttQoS.QOS1);
|
||||
String safePayload = payloadForLog(payload, json);
|
||||
if (ok) {
|
||||
log.info("MQTT 下行 => identity: {}, topic: {}, msgId: {}, payload: {}",
|
||||
identity, topic, msgId, json);
|
||||
identity, topic, msgId, safePayload);
|
||||
} else {
|
||||
log.error("MQTT 下行失败 => identity: {}, topic: {}, msgId: {}, payload: {}",
|
||||
identity, topic, msgId, json);
|
||||
identity, topic, msgId, safePayload);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* 流指令的推流地址可能携带临时令牌,日志仅保留地址主体并隐藏查询参数。
|
||||
*/
|
||||
private String payloadForLog(Object payload, String json) {
|
||||
if (!(payload instanceof StreamCommandPayload streamPayload)) {
|
||||
return json;
|
||||
}
|
||||
|
||||
StreamCommandPayload safePayload = new StreamCommandPayload();
|
||||
safePayload.setMsgId(streamPayload.getMsgId());
|
||||
safePayload.setCmd(streamPayload.getCmd());
|
||||
safePayload.setAction(streamPayload.getAction());
|
||||
safePayload.setStreamId(streamPayload.getStreamId());
|
||||
safePayload.setApp(streamPayload.getApp());
|
||||
safePayload.setProtocol(streamPayload.getProtocol());
|
||||
safePayload.setPushUrl(redactQuery(streamPayload.getPushUrl()));
|
||||
safePayload.setTs(streamPayload.getTs());
|
||||
return JsonUtils.toJsonString(safePayload);
|
||||
}
|
||||
|
||||
private String redactQuery(String url) {
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
int queryIndex = url.indexOf('?');
|
||||
return queryIndex < 0 ? url : url.substring(0, queryIndex) + "?***";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -109,9 +109,11 @@ public class DefaultDeviceUplinkHandler implements DeviceUplinkHandler {
|
||||
telemetryArchiver.archive(device, payload, receivedAt);
|
||||
// 低电判定用「该设备自己配的阈值」,头套与脚环各自独立
|
||||
alertWriter.applyLowBattery(device.getDeviceId(), deviceNo,
|
||||
AlertTypeEnum.LOW_BATTERY_HOOD, payload.getHoodBattery(), device.getHoodBatteryThreshold());
|
||||
AlertTypeEnum.LOW_BATTERY_HOOD, payload.getHoodBattery(), device.getHoodBatteryThreshold(),
|
||||
device.getBindUserId());
|
||||
alertWriter.applyLowBattery(device.getDeviceId(), deviceNo,
|
||||
AlertTypeEnum.LOW_BATTERY_RING, payload.getRingBattery(), device.getRingBatteryThreshold());
|
||||
AlertTypeEnum.LOW_BATTERY_RING, payload.getRingBattery(), device.getRingBatteryThreshold(),
|
||||
device.getBindUserId());
|
||||
} catch (Exception e) {
|
||||
log.error("遥测处理异常 => deviceNo: {}", deviceNo, e);
|
||||
}
|
||||
@@ -142,9 +144,9 @@ public class DefaultDeviceUplinkHandler implements DeviceUplinkHandler {
|
||||
case OFFLINE -> presenceWriter.markOffline(device);
|
||||
case OTA_PROGRESS -> updateOtaProgress(device, payload);
|
||||
case LOW_BATTERY_HOOD -> alertWriter.writeLowBatteryHint(device.getDeviceId(), deviceNo,
|
||||
AlertTypeEnum.LOW_BATTERY_HOOD, payload.getValue());
|
||||
AlertTypeEnum.LOW_BATTERY_HOOD, payload.getValue(), device.getBindUserId());
|
||||
case LOW_BATTERY_RING -> alertWriter.writeLowBatteryHint(device.getDeviceId(), deviceNo,
|
||||
AlertTypeEnum.LOW_BATTERY_RING, payload.getValue());
|
||||
AlertTypeEnum.LOW_BATTERY_RING, payload.getValue(), device.getBindUserId());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("事件处理异常 => deviceNo: {}, eventType: {}", deviceNo, payload.getEventType(), e);
|
||||
|
||||
@@ -62,7 +62,7 @@ public class StreamCommandPayload implements Serializable {
|
||||
private String app;
|
||||
|
||||
/**
|
||||
* 推流协议:rtmp / rtsp
|
||||
* 推流协议:rtmp / rtsp / mjpeg
|
||||
*/
|
||||
private String protocol;
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package org.dromara.device.mqtt.support;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.dromara.device.config.DeviceMqttProperties;
|
||||
import org.dromara.device.constant.DeviceMqttConstants;
|
||||
import org.dromara.device.constant.DeviceMsgConstants;
|
||||
import org.dromara.device.domain.BmAppMessage;
|
||||
import org.dromara.device.domain.BmDeviceAlert;
|
||||
import org.dromara.device.enums.AlertLevelEnum;
|
||||
import org.dromara.device.enums.AlertTypeEnum;
|
||||
import org.dromara.device.mapper.BmAppMessageMapper;
|
||||
import org.dromara.device.mapper.BmDeviceAlertMapper;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
@@ -34,6 +38,12 @@ import java.time.LocalDateTime;
|
||||
* <p>
|
||||
* 与离线告警的区别:离线是「状态迁移」,由 {@code DevicePresenceWriter#markOffline} 的条件更新
|
||||
* 保证一次迁移只触发一次,因此<b>不走去重窗口</b>——否则设备 30 分钟内掉线两次,第二次会被误吞。
|
||||
* <p>
|
||||
* <b>本类同时是 APP 消息的唯一写入入口</b>:告警落库时一并保存设备绑定用户快照,
|
||||
* 若快照非空,则额外写一条 {@link BmAppMessage} 通知副本给该用户
|
||||
* (开发计划 D8 修订:一期 APP 提供消息接口,仅供设备告警使用)。
|
||||
* 告警行同时承担 outbox:消息写入失败不回滚告警,由后台补偿任务按收件人快照重试,
|
||||
* {@code bm_app_message.alert_id} 唯一键保证多实例与重复重试幂等。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-16
|
||||
@@ -44,6 +54,7 @@ import java.time.LocalDateTime;
|
||||
public class DeviceAlertWriter {
|
||||
|
||||
private final BmDeviceAlertMapper alertMapper;
|
||||
private final BmAppMessageMapper messageMapper;
|
||||
private final DeviceMqttProperties properties;
|
||||
|
||||
/**
|
||||
@@ -56,9 +67,9 @@ public class DeviceAlertWriter {
|
||||
* @param deviceNo 设备编号
|
||||
* @return 是否成功落库
|
||||
*/
|
||||
public boolean writeOffline(Long deviceId, String deviceNo) {
|
||||
public boolean writeOffline(Long deviceId, String deviceNo, Long messageUserId) {
|
||||
return insert(deviceId, deviceNo, AlertTypeEnum.OFFLINE, AlertLevelEnum.CRITICAL,
|
||||
null, DeviceMsgConstants.DEVICE_OFFLINE);
|
||||
null, DeviceMsgConstants.DEVICE_OFFLINE, messageUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +94,7 @@ public class DeviceAlertWriter {
|
||||
* @param deviceThreshold 设备上配置的触发阈值({@code null} 表示未配置,走工程默认)
|
||||
*/
|
||||
public void applyLowBattery(Long deviceId, String deviceNo, AlertTypeEnum type,
|
||||
Integer battery, Integer deviceThreshold) {
|
||||
Integer battery, Integer deviceThreshold, Long messageUserId) {
|
||||
if (battery == null || !isLowBatteryType(type)) {
|
||||
return;
|
||||
}
|
||||
@@ -102,7 +113,7 @@ public class DeviceAlertWriter {
|
||||
// 先置闩锁再落库:即使落库被去重窗口挡住,也不会造成「每条遥测都重试落库」
|
||||
RedisUtils.setCacheObject(latchKey, Boolean.TRUE, properties.getLowBatteryLatchTtl());
|
||||
writeWithDedup(deviceId, deviceNo, type, AlertLevelEnum.WARN,
|
||||
String.valueOf(battery), lowBatteryContent(type, battery));
|
||||
String.valueOf(battery), lowBatteryContent(type, battery), messageUserId);
|
||||
return;
|
||||
}
|
||||
if (battery >= recover && latched) {
|
||||
@@ -125,13 +136,14 @@ public class DeviceAlertWriter {
|
||||
* @param battery 设备上报的触发值
|
||||
* @return 是否成功落库
|
||||
*/
|
||||
public boolean writeLowBatteryHint(Long deviceId, String deviceNo, AlertTypeEnum type, Integer battery) {
|
||||
public boolean writeLowBatteryHint(Long deviceId, String deviceNo, AlertTypeEnum type,
|
||||
Integer battery, Long messageUserId) {
|
||||
if (!isLowBatteryType(type)) {
|
||||
return false;
|
||||
}
|
||||
return writeWithDedup(deviceId, deviceNo, type, AlertLevelEnum.WARN,
|
||||
battery == null ? null : String.valueOf(battery),
|
||||
battery == null ? type.getLabel() : lowBatteryContent(type, battery));
|
||||
battery == null ? type.getLabel() : lowBatteryContent(type, battery), messageUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,14 +158,14 @@ public class DeviceAlertWriter {
|
||||
* @return 是否成功落库(窗口内被去重或落库失败均返回 {@code false})
|
||||
*/
|
||||
private boolean writeWithDedup(Long deviceId, String deviceNo, AlertTypeEnum type,
|
||||
AlertLevelEnum level, String value, String content) {
|
||||
AlertLevelEnum level, String value, String content, Long messageUserId) {
|
||||
String dedupKey = DeviceMqttConstants.ALERT_DEDUP_PREFIX + deviceNo + ":" + type.getType();
|
||||
Duration window = properties.getAlertDedupWindow();
|
||||
if (!RedisUtils.setObjectIfAbsent(dedupKey, LocalDateTime.now().toString(), window)) {
|
||||
log.debug("告警命中去重窗口,已跳过 => deviceNo: {}, type: {}", deviceNo, type.getType());
|
||||
return false;
|
||||
}
|
||||
if (insert(deviceId, deviceNo, type, level, value, content)) {
|
||||
if (insert(deviceId, deviceNo, type, level, value, content, messageUserId)) {
|
||||
return true;
|
||||
}
|
||||
// 落库失败必须释放占位,否则整个窗口内这条告警再也补不上
|
||||
@@ -173,7 +185,7 @@ public class DeviceAlertWriter {
|
||||
* @return 是否成功落库
|
||||
*/
|
||||
private boolean insert(Long deviceId, String deviceNo, AlertTypeEnum type,
|
||||
AlertLevelEnum level, String value, String content) {
|
||||
AlertLevelEnum level, String value, String content, Long messageUserId) {
|
||||
try {
|
||||
BmDeviceAlert alert = new BmDeviceAlert();
|
||||
alert.setDeviceId(deviceId);
|
||||
@@ -183,10 +195,13 @@ public class DeviceAlertWriter {
|
||||
alert.setAlertValue(value);
|
||||
alert.setContent(content);
|
||||
alert.setStatus(DeviceMqttConstants.ALERT_STATUS_UNHANDLED);
|
||||
alert.setMessageUserId(messageUserId);
|
||||
alert.setAlertTime(LocalDateTime.now());
|
||||
alertMapper.insert(alert);
|
||||
log.info("设备告警 => deviceNo: {}, type: {}, level: {}, value: {}",
|
||||
deviceNo, type.getType(), level.getCode(), value);
|
||||
// 告警已落库,再补一条面向绑定用户的通知副本(失败不影响上面的结果)
|
||||
pushAppMessage(alert);
|
||||
return true;
|
||||
} catch (RuntimeException e) {
|
||||
// 告警落库失败不得中断遥测/事件处理链路,只记错误,由监控告警兜底
|
||||
@@ -195,6 +210,88 @@ public class DeviceAlertWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写一条 APP 消息通知副本(告警 → 发生时的绑定用户快照)。
|
||||
* <p>
|
||||
* <b>本方法的普通失败一律吞掉</b>,理由有二:
|
||||
* <ol>
|
||||
* <li>告警此时<b>已经落库成功</b>,若让消息异常冒泡,调用方会把整条告警误判为失败并返回
|
||||
* {@code false},进而触发「释放去重窗口占位」——结果是同一告警在下一轮被重复写入。</li>
|
||||
* <li>消息是通知副本,失败后由 outbox 补偿任务重试,不影响告警事实本身。</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* <b>设备未绑定(收件人快照为空)时静默跳过</b>:这是正常业务态
|
||||
* (设备已建档但尚未被 APP 绑定,或刚被解绑),不是异常,不应产生告警日志噪音。
|
||||
*
|
||||
* @param alert 已落库的告警实体({@code alertId} 由 MP 回填)
|
||||
* @return 是否已经存在或成功写入消息
|
||||
*/
|
||||
private boolean pushAppMessage(BmDeviceAlert alert) {
|
||||
try {
|
||||
Long userId = alert.getMessageUserId();
|
||||
if (userId == null) {
|
||||
return true;
|
||||
}
|
||||
BmAppMessage message = new BmAppMessage();
|
||||
message.setUserId(userId);
|
||||
message.setDeviceId(alert.getDeviceId());
|
||||
message.setDeviceNo(alert.getDeviceNo());
|
||||
message.setAlertId(alert.getAlertId());
|
||||
message.setAlertType(alert.getAlertType());
|
||||
message.setAlertLevel(alert.getAlertLevel());
|
||||
message.setTitle(resolveAlertTitle(alert.getAlertType()));
|
||||
message.setContent(alert.getContent());
|
||||
message.setReadFlag(DeviceMqttConstants.MESSAGE_READ_FLAG_UNREAD);
|
||||
message.setCreateTime(alert.getAlertTime() == null ? LocalDateTime.now() : alert.getAlertTime());
|
||||
messageMapper.insert(message);
|
||||
log.info("APP 告警消息已写入 => userId: {}, deviceNo: {}, type: {}",
|
||||
userId, alert.getDeviceNo(), alert.getAlertType());
|
||||
return true;
|
||||
} catch (DuplicateKeyException e) {
|
||||
// alert_id 唯一键命中代表其他实例或上一轮补偿已经完成,按幂等成功处理。
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("APP 告警消息写入失败,等待后台补偿 => alertId: {}, deviceNo: {}, type: {}",
|
||||
alert.getAlertId(),
|
||||
alert.getDeviceNo(), alert.getAlertType(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 补偿尚未写入 APP 消息的告警 outbox。
|
||||
*
|
||||
* @param batchSize 单轮上限
|
||||
* @return 本轮成功补偿数
|
||||
*/
|
||||
public int retryPendingMessages(int batchSize) {
|
||||
int safeBatchSize = Math.max(1, batchSize);
|
||||
Page<BmDeviceAlert> page = alertMapper.selectPendingMessageAlerts(
|
||||
new Page<>(1, safeBatchSize, false));
|
||||
int success = 0;
|
||||
for (BmDeviceAlert alert : page.getRecords()) {
|
||||
if (pushAppMessage(alert)) {
|
||||
success++;
|
||||
}
|
||||
}
|
||||
if (!page.getRecords().isEmpty()) {
|
||||
log.info("APP 告警消息补偿完成 => 待补偿: {}, 成功: {}", page.getRecords().size(), success);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据告警编码恢复消息标题;未知编码保留通用标题,避免补偿任务因脏数据中断。
|
||||
*/
|
||||
private String resolveAlertTitle(String alertType) {
|
||||
for (AlertTypeEnum candidate : AlertTypeEnum.values()) {
|
||||
if (candidate.getType().equals(alertType)) {
|
||||
return candidate.getLabel();
|
||||
}
|
||||
}
|
||||
return "设备告警";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为低电类告警(避免被误用到离线等类型上)。
|
||||
*
|
||||
|
||||
@@ -86,7 +86,8 @@ public class DevicePresenceWriter {
|
||||
return false;
|
||||
}
|
||||
log.info("设备离线 => deviceNo: {}, lastOnlineTime: {}", device.getDeviceNo(), device.getLastOnlineTime());
|
||||
alertWriter.writeOffline(device.getDeviceId(), device.getDeviceNo());
|
||||
// 收件人取状态迁移时已读取的设备快照,避免告警落库后换绑导致消息串给新用户。
|
||||
alertWriter.writeOffline(device.getDeviceId(), device.getDeviceNo(), device.getBindUserId());
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.dromara.device.service;
|
||||
|
||||
import org.dromara.common.core.domain.PageResult;
|
||||
import org.dromara.device.domain.bo.AlertBo;
|
||||
import org.dromara.device.domain.bo.AlertHandleBo;
|
||||
import org.dromara.device.domain.vo.AlertStatsVo;
|
||||
import org.dromara.device.domain.vo.AlertVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 设备告警服务接口(后台管理端)。
|
||||
* <p>
|
||||
* 告警的<b>写入</b>不在本接口内——唯一写入入口是
|
||||
* {@code org.dromara.device.mqtt.support.DeviceAlertWriter}(含去重窗口与低电滞回闩锁)。
|
||||
* 本接口只负责后台侧的<b>查询与处置</b>,避免出现第二条写入路径。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
public interface IAlertService {
|
||||
|
||||
/**
|
||||
* 分页查询告警记录。
|
||||
*
|
||||
* @param bo 查询条件(分页参数继承自 {@code PageQuery},随本对象一并传入)
|
||||
* @return 分页结果
|
||||
*/
|
||||
PageResult<AlertVo> queryPageList(AlertBo bo);
|
||||
|
||||
/**
|
||||
* 统计告警总数 / 未处理 / 已处理。
|
||||
*
|
||||
* @return 统计结果
|
||||
*/
|
||||
AlertStatsVo stats();
|
||||
|
||||
/**
|
||||
* 标记告警为已处理(单条与批量共用)。
|
||||
* <p>
|
||||
* <b>幂等</b>:只更新当前仍为「未处理」的记录,已是「已处理」的直接跳过,
|
||||
* 因此重复点击不会报错,也不会覆盖首次处理人与处理时间。
|
||||
*
|
||||
* @param bo 待处理的告警主键集合
|
||||
*/
|
||||
void handle(AlertHandleBo bo);
|
||||
|
||||
/**
|
||||
* 删除告警记录(逻辑删除,保留审计痕迹)。
|
||||
*
|
||||
* @param alertIds 告警主键集合
|
||||
*/
|
||||
void remove(List<Long> alertIds);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.dromara.device.service;
|
||||
|
||||
import org.dromara.common.core.domain.PageResult;
|
||||
import org.dromara.device.domain.bo.AppMessageQueryBo;
|
||||
import org.dromara.device.domain.vo.AppMessageVo;
|
||||
|
||||
/**
|
||||
* APP 消息服务接口。
|
||||
* <p>
|
||||
* 全部方法<b>只作用于当前登录 APP 用户本人</b>的消息({@code user_id = LoginHelper.getUserId()}),
|
||||
* 不接受任何「指定用户」入参——从接口形状上杜绝越权读取他人消息的可能。
|
||||
* <p>
|
||||
* 消息的<b>写入</b>不在本接口内,唯一来源是告警落库后的推送副本
|
||||
* ({@code DeviceAlertWriter} → {@code BmAppMessage})。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
public interface IAppMessageService {
|
||||
|
||||
/**
|
||||
* 分页查询当前用户的消息(按产生时间倒序)。
|
||||
*
|
||||
* @param bo 查询条件(分页参数继承自 {@code PageQuery})
|
||||
* @return 分页结果
|
||||
*/
|
||||
PageResult<AppMessageVo> queryPageList(AppMessageQueryBo bo);
|
||||
|
||||
/**
|
||||
* 统计当前用户的未读消息数(供 APP 消息角标)。
|
||||
*
|
||||
* @return 未读数
|
||||
*/
|
||||
Long unreadCount();
|
||||
|
||||
/**
|
||||
* 标记单条消息为已读。
|
||||
* <p>
|
||||
* 消息不属于当前用户时静默成功(更新 0 行),不抛出「消息不存在」——
|
||||
* 否则接口会变成一个探测他人消息 ID 是否存在的旁路。
|
||||
*
|
||||
* @param messageId 消息主键
|
||||
*/
|
||||
void markRead(Long messageId);
|
||||
|
||||
/**
|
||||
* 把当前用户所有未读消息标记为已读。
|
||||
*/
|
||||
void markAllRead();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.dromara.device.service;
|
||||
|
||||
import org.dromara.common.core.domain.PageResult;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.device.domain.bo.AppVersionBo;
|
||||
import org.dromara.device.domain.bo.AppVersionCheckBo;
|
||||
import org.dromara.device.domain.vo.AppVersionLatestVo;
|
||||
import org.dromara.device.domain.vo.AppVersionVo;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* APP 客户端版本服务接口。
|
||||
* <p>
|
||||
* 后台版本管理(前缀 {@code /device/app-version})5 法 + APP 检查更新 1 法。
|
||||
* <p>
|
||||
* 唯一性口径对齐 DB 唯一键 {@code uk_platform_version_code (platform, version_code)}——该索引
|
||||
* <b>不区分逻辑删除行</b>,因此同平台同构建号即使被逻辑删除也仍占用取值;实现侧对「已存在」与
|
||||
* 「唯一键冲突」两种情况都归一为 {@code AppVersionMsgConstants.VERSION_CODE_DUPLICATE}。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
public interface IAppVersionService {
|
||||
|
||||
/**
|
||||
* 查询版本详情。
|
||||
*
|
||||
* @param versionId 版本主键
|
||||
* @return 版本视图对象;不存在返回 {@code null}(对齐项目口径)
|
||||
*/
|
||||
AppVersionVo queryById(Long versionId);
|
||||
|
||||
/**
|
||||
* 分页查询版本列表。
|
||||
*
|
||||
* @param bo 查询条件(keyword / platform / status / forceUpdate / params 时间区间)
|
||||
* @param pageQuery 分页参数
|
||||
* @return 分页结果(默认按构建号倒序)
|
||||
*/
|
||||
PageResult<AppVersionVo> queryPageList(AppVersionBo bo, PageQuery pageQuery);
|
||||
|
||||
/**
|
||||
* 登记(新增)版本。
|
||||
* <p>
|
||||
* {@code status} 留空时默认「停用」({@code 0},与 DB 默认值同口径;页面表单则默认勾选「启用」);
|
||||
* 首次置为启用时写入 {@code publish_time}。
|
||||
*
|
||||
* @param bo 新增业务对象
|
||||
* @return 是否新增成功
|
||||
*/
|
||||
Boolean insertByBo(AppVersionBo bo);
|
||||
|
||||
/**
|
||||
* 修改版本(含启用 / 停用)。
|
||||
* <p>
|
||||
* {@code platform} 不可变更;校验同新增(唯一性与构建号递增均排除自身)。
|
||||
*
|
||||
* @param bo 编辑业务对象(含 versionId)
|
||||
* @return 是否修改成功
|
||||
*/
|
||||
Boolean updateByBo(AppVersionBo bo);
|
||||
|
||||
/**
|
||||
* 批量删除版本(逻辑删除)。
|
||||
* <p>
|
||||
* 逻辑删除后同平台同构建号<b>不可复用</b>(构建号填错走编辑修正)。
|
||||
*
|
||||
* @param ids 版本主键集合
|
||||
* @return 是否删除成功
|
||||
*/
|
||||
Boolean deleteByIds(Collection<Long> ids);
|
||||
|
||||
/**
|
||||
* APP 检查更新(免登录)。
|
||||
* <p>
|
||||
* 判定按「构建号」:候选集 = {@code platform + status='1' + del_flag='0'},取 {@code version_code} 最大;
|
||||
* 上报 {@code >=} 最新 → {@code hasUpdate=false}(<b>不提示降级</b>);上报 {@code <} 最新 → 按该记录
|
||||
* {@code force_update} 决定是否强制更新。本方法不依赖登录态({@code @SaIgnore} 放行)。
|
||||
*
|
||||
* @param bo 检查更新入参(platform / versionCode 必填)
|
||||
* @return 检查更新响应(三态表达 + 安装包信息;两平台口径一致,storeUrl 有值才下发)
|
||||
*/
|
||||
AppVersionLatestVo checkLatest(AppVersionCheckBo bo);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package org.dromara.device.service.impl;
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.domain.PageResult;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.dromara.device.constant.DeviceMqttConstants;
|
||||
import org.dromara.device.domain.BmDevice;
|
||||
import org.dromara.device.domain.BmDeviceAlert;
|
||||
import org.dromara.device.domain.bo.AlertBo;
|
||||
import org.dromara.device.domain.bo.AlertHandleBo;
|
||||
import org.dromara.device.domain.vo.AlertStatsVo;
|
||||
import org.dromara.device.domain.vo.AlertVo;
|
||||
import org.dromara.device.mapper.BmDeviceAlertMapper;
|
||||
import org.dromara.device.mapper.BmDeviceMapper;
|
||||
import org.dromara.device.service.IAlertService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 设备告警服务实现(后台管理端)。
|
||||
* <p>
|
||||
* 只读 + 处置,不含写入:告警的唯一写入入口是
|
||||
* {@code org.dromara.device.mqtt.support.DeviceAlertWriter}。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class AlertServiceImpl implements IAlertService {
|
||||
|
||||
private final BmDeviceAlertMapper alertMapper;
|
||||
private final BmDeviceMapper deviceMapper;
|
||||
|
||||
@Override
|
||||
public PageResult<AlertVo> queryPageList(AlertBo bo) {
|
||||
AlertBo condition = bo == null ? new AlertBo() : bo;
|
||||
Page<AlertVo> result = alertMapper.selectVoPage(condition.build(), buildQueryWrapper(condition));
|
||||
fillDeviceName(result.getRecords());
|
||||
return PageResult.build(result.getRecords(), result.getTotal());
|
||||
}
|
||||
|
||||
@Override
|
||||
public AlertStatsVo stats() {
|
||||
AlertStatsVo vo = new AlertStatsVo();
|
||||
// 必须显式给出实体泛型:Wrappers.lambdaQuery() 在链式调用下无法推断出 BmDeviceAlert,
|
||||
// 会导致 BmDeviceAlert::getStatus 这类方法引用编译失败。
|
||||
Long total = alertMapper.selectCount(Wrappers.<BmDeviceAlert>lambdaQuery());
|
||||
Long unhandled = alertMapper.selectCount(Wrappers.<BmDeviceAlert>lambdaQuery()
|
||||
.eq(BmDeviceAlert::getStatus, DeviceMqttConstants.ALERT_STATUS_UNHANDLED));
|
||||
vo.setTotal(total);
|
||||
vo.setUnhandled(unhandled);
|
||||
vo.setHandled(total - unhandled);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(AlertHandleBo bo) {
|
||||
// 条件里带上 status = 未处理,靠「影响行数」天然实现幂等:
|
||||
// 重复点击时第二次匹配不到任何行,既不会报错,也不会把首次的处理人与处理时间覆盖掉。
|
||||
LambdaUpdateWrapper<BmDeviceAlert> luw = Wrappers.lambdaUpdate();
|
||||
luw.in(BmDeviceAlert::getAlertId, bo.getAlertIds())
|
||||
.eq(BmDeviceAlert::getStatus, DeviceMqttConstants.ALERT_STATUS_UNHANDLED)
|
||||
.set(BmDeviceAlert::getStatus, DeviceMqttConstants.ALERT_STATUS_HANDLED)
|
||||
.set(BmDeviceAlert::getHandleBy, LoginHelper.getUserId())
|
||||
.set(BmDeviceAlert::getHandleTime, LocalDateTime.now());
|
||||
alertMapper.update(null, luw);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(List<Long> alertIds) {
|
||||
if (alertIds == null || alertIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// 走 MP 逻辑删除(@TableLogic),保留审计痕迹:后台可查操作日志定位「谁删了什么」。
|
||||
alertMapper.delete(Wrappers.<BmDeviceAlert>lambdaQuery()
|
||||
.in(BmDeviceAlert::getAlertId, alertIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建告警动态查询条件。
|
||||
* <p>
|
||||
* 默认排序为「未处理优先 + 告警时间倒序」:现场处置时最关心的是新产生的未处理告警,
|
||||
* 而不是历史已处理记录。
|
||||
*
|
||||
* @param bo 查询条件
|
||||
* @return 查询条件包装器
|
||||
*/
|
||||
private LambdaQueryWrapper<BmDeviceAlert> buildQueryWrapper(AlertBo bo) {
|
||||
LambdaQueryWrapper<BmDeviceAlert> lqw = Wrappers.lambdaQuery();
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getDeviceNo()), BmDeviceAlert::getDeviceNo, bo.getDeviceNo());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getAlertType()), BmDeviceAlert::getAlertType, bo.getAlertType());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getAlertLevel()), BmDeviceAlert::getAlertLevel, bo.getAlertLevel());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getStatus()), BmDeviceAlert::getStatus, bo.getStatus());
|
||||
if (StringUtils.isNotBlank(bo.getKeyword())) {
|
||||
String keyword = bo.getKeyword();
|
||||
lqw.and(w -> w.like(BmDeviceAlert::getDeviceNo, keyword)
|
||||
.or().like(BmDeviceAlert::getContent, keyword));
|
||||
}
|
||||
Map<String, Object> params = bo.getParams() == null ? new HashMap<>() : bo.getParams();
|
||||
LocalDateTime begin = parseDateTime(params.get("beginTime"), true);
|
||||
LocalDateTime end = parseDateTime(params.get("endTime"), false);
|
||||
lqw.ge(begin != null, BmDeviceAlert::getAlertTime, begin);
|
||||
lqw.le(end != null, BmDeviceAlert::getAlertTime, end);
|
||||
// alert_id 作为稳定的次级排序,避免同一秒内多条告警跨页时重复或遗漏。
|
||||
lqw.orderByAsc(BmDeviceAlert::getStatus)
|
||||
.orderByDesc(BmDeviceAlert::getAlertTime)
|
||||
.orderByDesc(BmDeviceAlert::getAlertId);
|
||||
return lqw;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析前端日期区间入参({@code params.beginTime} / {@code params.endTime})。
|
||||
* <p>
|
||||
* 前端 {@code addDateRange} 可能传「纯日期」({@code yyyy-MM-dd})或「日期时间」两种粒度。
|
||||
* 纯日期时按区间端点补齐:起始取 {@code 00:00:00}、结束取 {@code 23:59:59},
|
||||
* 否则「结束日 = 今天」会把当天的告警整体漏掉——这是日期区间筛选最常见的边界 bug。
|
||||
* <p>
|
||||
* 解析失败一律返回 {@code null}(该侧区间不生效),不抛异常:筛选条件不合法不应让整个列表打不开。
|
||||
*
|
||||
* @param value 原始入参
|
||||
* @param beginDay 是否为区间起点(决定纯日期时的补齐方向)
|
||||
* @return 解析后的时间;无法解析时返回 {@code null}
|
||||
*/
|
||||
private LocalDateTime parseDateTime(Object value, boolean beginDay) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
if (text.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
LocalDateTime parsed = LocalDateTimeUtil.of(DateUtil.parse(text));
|
||||
if (text.length() <= 10) {
|
||||
return beginDay ? parsed.toLocalDate().atStartOfDay()
|
||||
: parsed.toLocalDate().atTime(23, 59, 59);
|
||||
}
|
||||
return parsed;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量补齐设备名称。
|
||||
* <p>
|
||||
* 告警表只存 {@code device_no} 快照,不含名称;这里按 {@code deviceNo} 一次性查出名称再回填,
|
||||
* 避免逐行查询(N+1)。设备已被逻辑删除时查不到,{@code deviceName} 保持 {@code null},
|
||||
* 由前端降级展示设备编号。
|
||||
*
|
||||
* @param records 当前页告警记录
|
||||
*/
|
||||
private void fillDeviceName(List<AlertVo> records) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<String> deviceNos = records.stream()
|
||||
.map(AlertVo::getDeviceNo)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(Collectors.toSet());
|
||||
if (deviceNos.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<BmDevice> devices = deviceMapper.selectList(Wrappers.<BmDevice>lambdaQuery()
|
||||
.select(BmDevice::getDeviceNo, BmDevice::getName)
|
||||
.in(BmDevice::getDeviceNo, deviceNos));
|
||||
Map<String, String> nameMap = toDeviceNameMap(devices);
|
||||
records.forEach(record -> record.setDeviceName(nameMap.get(record.getDeviceNo())));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把设备列表转为「设备编号 → 设备名称」映射。
|
||||
*
|
||||
* @param devices 设备列表
|
||||
* @return 名称映射(名称为空时保留空串,交由前端降级展示编号)
|
||||
*/
|
||||
private Map<String, String> toDeviceNameMap(Collection<BmDevice> devices) {
|
||||
Map<String, String> nameMap = new HashMap<>();
|
||||
if (devices == null) {
|
||||
return nameMap;
|
||||
}
|
||||
for (BmDevice device : devices) {
|
||||
if (StringUtils.isBlank(device.getDeviceNo())) {
|
||||
continue;
|
||||
}
|
||||
nameMap.put(device.getDeviceNo(), Objects.requireNonNullElse(device.getName(), ""));
|
||||
}
|
||||
return nameMap;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import org.dromara.device.domain.bo.AppSmsCodeBo;
|
||||
import org.dromara.device.domain.bo.AppSmsLoginBo;
|
||||
import org.dromara.device.email.AppEmailService;
|
||||
import org.dromara.device.service.IAppAuthService;
|
||||
import org.dromara.device.sms.AppPhoneNumbers;
|
||||
import org.dromara.device.sms.AppSmsService;
|
||||
import org.dromara.system.api.AppUserApiService;
|
||||
import org.dromara.system.api.domain.AppUserDTO;
|
||||
@@ -66,7 +67,7 @@ public class AppAuthServiceImpl implements IAppAuthService {
|
||||
*/
|
||||
@Override
|
||||
public String sendSmsCode(AppSmsCodeBo bo) {
|
||||
return appSmsService.sendCode(bo.getPhoneNumber(), bo.getScene());
|
||||
return appSmsService.sendCode(AppPhoneNumbers.canonical(bo.getPhoneNumber()), bo.getScene());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,15 +88,16 @@ public class AppAuthServiceImpl implements IAppAuthService {
|
||||
*/
|
||||
@Override
|
||||
public void register(AppRegisterBo bo) {
|
||||
RLock lock = RedisUtils.getClient().getLock(AppConstants.REGISTER_LOCK_KEY_PREFIX + bo.getPhoneNumber());
|
||||
String phone = AppPhoneNumbers.canonical(bo.getPhoneNumber());
|
||||
RLock lock = RedisUtils.getClient().getLock(AppConstants.REGISTER_LOCK_KEY_PREFIX + phone);
|
||||
lock.lock();
|
||||
try {
|
||||
if (appUserApiService.existsByPhone(bo.getPhoneNumber())) {
|
||||
if (appUserApiService.existsByPhone(phone)) {
|
||||
throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED);
|
||||
}
|
||||
appSmsService.verifyCode(bo.getPhoneNumber(), AppConstants.SCENE_REGISTER, bo.getCode());
|
||||
appSmsService.verifyCode(phone, AppConstants.SCENE_REGISTER, bo.getCode());
|
||||
try {
|
||||
appUserApiService.registerByPhone(bo.getPhoneNumber(), bo.getPassword(), bo.getNickName());
|
||||
appUserApiService.registerByPhone(phone, bo.getPassword(), bo.getNickName());
|
||||
} catch (DuplicateKeyException e) {
|
||||
// DB 唯一键 uk_sys_user_phone_number 冲突:并发同号注册的最终兜底
|
||||
throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED);
|
||||
@@ -115,7 +117,7 @@ public class AppAuthServiceImpl implements IAppAuthService {
|
||||
*/
|
||||
@Override
|
||||
public String login(AppLoginBo bo) {
|
||||
AppUserDTO user = appUserApiService.selectByPhone(bo.getPhoneNumber());
|
||||
AppUserDTO user = appUserApiService.selectByPhone(AppPhoneNumbers.canonical(bo.getPhoneNumber()));
|
||||
if (user == null) {
|
||||
throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
|
||||
}
|
||||
@@ -134,12 +136,13 @@ public class AppAuthServiceImpl implements IAppAuthService {
|
||||
*/
|
||||
@Override
|
||||
public String smsLogin(AppSmsLoginBo bo) {
|
||||
AppUserDTO user = appUserApiService.selectByPhone(bo.getPhoneNumber());
|
||||
String phone = AppPhoneNumbers.canonical(bo.getPhoneNumber());
|
||||
AppUserDTO user = appUserApiService.selectByPhone(phone);
|
||||
if (user == null) {
|
||||
throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
|
||||
}
|
||||
checkAccountEnabled(user);
|
||||
appSmsService.verifyCode(bo.getPhoneNumber(), AppConstants.SCENE_LOGIN, bo.getCode());
|
||||
appSmsService.verifyCode(phone, AppConstants.SCENE_LOGIN, bo.getCode());
|
||||
return doLogin(user);
|
||||
}
|
||||
|
||||
@@ -150,11 +153,12 @@ public class AppAuthServiceImpl implements IAppAuthService {
|
||||
*/
|
||||
@Override
|
||||
public void resetPwd(AppResetPwdBo bo) {
|
||||
AppUserDTO user = appUserApiService.selectByPhone(bo.getPhoneNumber());
|
||||
String phone = AppPhoneNumbers.canonical(bo.getPhoneNumber());
|
||||
AppUserDTO user = appUserApiService.selectByPhone(phone);
|
||||
if (user == null) {
|
||||
throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
|
||||
}
|
||||
appSmsService.verifyCode(bo.getPhoneNumber(), AppConstants.SCENE_RESET_PWD, bo.getCode());
|
||||
appSmsService.verifyCode(phone, AppConstants.SCENE_RESET_PWD, bo.getCode());
|
||||
appUserApiService.resetPassword(user.getUserId(), bo.getNewPassword());
|
||||
}
|
||||
|
||||
@@ -307,6 +311,7 @@ public class AppAuthServiceImpl implements IAppAuthService {
|
||||
|
||||
SaLoginParameter model = new SaLoginParameter();
|
||||
model.setDeviceType(client.getDeviceType());
|
||||
// 固定有效期与活跃超时默认均为 30 天,避免无操作 30 分钟导致 APP 提前失效。
|
||||
model.setTimeout(client.getTimeout());
|
||||
model.setActiveTimeout(client.getActiveTimeout());
|
||||
// 写入 token 扩展:clientid 供框架校验「请求头 clientid == 令牌扩展 clientid」(SecurityConfig:98-106);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package org.dromara.device.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.domain.PageResult;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.dromara.device.constant.DeviceMqttConstants;
|
||||
import org.dromara.device.domain.BmAppMessage;
|
||||
import org.dromara.device.domain.bo.AppMessageQueryBo;
|
||||
import org.dromara.device.domain.vo.AppMessageVo;
|
||||
import org.dromara.device.mapper.BmAppMessageMapper;
|
||||
import org.dromara.device.service.IAppMessageService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* APP 消息服务实现。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class AppMessageServiceImpl implements IAppMessageService {
|
||||
|
||||
/**
|
||||
* APP 端未传 pageSize 时的兜底页大小。
|
||||
* <p>
|
||||
* 框架 {@code PageQuery#DEFAULT_PAGE_SIZE} 是 {@code Integer.MAX_VALUE}(语义为「查全部」,
|
||||
* 实际被 {@code MybatisPlusConfig} 的 {@code maxLimit=1000} 截断)。这对后台表格没问题,
|
||||
* 但消息是**持续累积**的数据,APP 首屏一次拉 1000 条既浪费流量也拖慢渲染,
|
||||
* 因此这里显式兜底为 20 条,由 APP 下拉加载更多。
|
||||
*/
|
||||
private static final long DEFAULT_PAGE_SIZE = 20L;
|
||||
|
||||
/**
|
||||
* APP 端单页最大条数,防止客户端传超大 pageSize 拖垮服务端。
|
||||
*/
|
||||
private static final long MAX_PAGE_SIZE = 100L;
|
||||
|
||||
private final BmAppMessageMapper messageMapper;
|
||||
|
||||
@Override
|
||||
public PageResult<AppMessageVo> queryPageList(AppMessageQueryBo bo) {
|
||||
AppMessageQueryBo condition = bo == null ? new AppMessageQueryBo() : bo;
|
||||
Page<AppMessageVo> result = messageMapper.selectVoPage(buildPage(condition), buildQueryWrapper(condition));
|
||||
return PageResult.build(result.getRecords(), result.getTotal());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long unreadCount() {
|
||||
return messageMapper.selectCount(Wrappers.<BmAppMessage>lambdaQuery()
|
||||
.eq(BmAppMessage::getUserId, LoginHelper.getUserId())
|
||||
.eq(BmAppMessage::getReadFlag, DeviceMqttConstants.MESSAGE_READ_FLAG_UNREAD));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markRead(Long messageId) {
|
||||
if (messageId == null) {
|
||||
return;
|
||||
}
|
||||
// user_id 是必带条件:既做越权防护,又天然实现幂等(已是已读时更新 0 行,
|
||||
// 不会覆盖首次的 read_time)。
|
||||
LambdaUpdateWrapper<BmAppMessage> luw = Wrappers.lambdaUpdate();
|
||||
luw.eq(BmAppMessage::getMessageId, messageId)
|
||||
.eq(BmAppMessage::getUserId, LoginHelper.getUserId())
|
||||
.eq(BmAppMessage::getReadFlag, DeviceMqttConstants.MESSAGE_READ_FLAG_UNREAD)
|
||||
.set(BmAppMessage::getReadFlag, DeviceMqttConstants.MESSAGE_READ_FLAG_READ)
|
||||
.set(BmAppMessage::getReadTime, LocalDateTime.now());
|
||||
messageMapper.update(null, luw);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markAllRead() {
|
||||
LambdaUpdateWrapper<BmAppMessage> luw = Wrappers.lambdaUpdate();
|
||||
luw.eq(BmAppMessage::getUserId, LoginHelper.getUserId())
|
||||
.eq(BmAppMessage::getReadFlag, DeviceMqttConstants.MESSAGE_READ_FLAG_UNREAD)
|
||||
.set(BmAppMessage::getReadFlag, DeviceMqttConstants.MESSAGE_READ_FLAG_READ)
|
||||
.set(BmAppMessage::getReadTime, LocalDateTime.now());
|
||||
messageMapper.update(null, luw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建分页对象(APP 端兜底页大小与上限)。
|
||||
* <p>
|
||||
* 泛型必须是<b>实体</b> {@link BmAppMessage} 而非 VO:{@code BaseMapperPlus#selectVoPage}
|
||||
* 的第一个参数类型为 {@code IPage<实体>},VO 由内部转换器产出。
|
||||
*
|
||||
* @param query 前端分页入参
|
||||
* @return 分页对象
|
||||
*/
|
||||
private Page<BmAppMessage> buildPage(AppMessageQueryBo query) {
|
||||
long current = query.getPageNum() == null || query.getPageNum() < 1 ? 1L : query.getPageNum();
|
||||
long size = query.getPageSize() == null || query.getPageSize() < 1 ? DEFAULT_PAGE_SIZE : query.getPageSize();
|
||||
return new Page<>(current, Math.min(size, MAX_PAGE_SIZE));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建消息动态查询条件。
|
||||
* <p>
|
||||
* {@code user_id} 条件由服务端强制附加,前端无法覆盖;排序用
|
||||
* {@code create_time + message_id} 双列倒序,避免同一时刻(同秒批量落库)产生跨页乱序。
|
||||
*
|
||||
* @param bo 查询条件
|
||||
* @return 查询条件包装器
|
||||
*/
|
||||
private LambdaQueryWrapper<BmAppMessage> buildQueryWrapper(AppMessageQueryBo bo) {
|
||||
LambdaQueryWrapper<BmAppMessage> lqw = Wrappers.lambdaQuery();
|
||||
lqw.eq(BmAppMessage::getUserId, LoginHelper.getUserId());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getReadFlag()), BmAppMessage::getReadFlag, bo.getReadFlag());
|
||||
lqw.orderByDesc(BmAppMessage::getCreateTime).orderByDesc(BmAppMessage::getMessageId);
|
||||
return lqw;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,46 +1,42 @@
|
||||
package org.dromara.device.service.impl;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.device.config.MjpegProperties;
|
||||
import org.dromara.device.config.ZlmProperties;
|
||||
import org.dromara.device.constant.AppMsgConstants;
|
||||
import org.dromara.device.constant.DeviceMsgConstants;
|
||||
import org.dromara.device.domain.BmDevice;
|
||||
import org.dromara.device.domain.vo.AppStreamPlayUrlsVo;
|
||||
import org.dromara.device.domain.vo.AppStreamPlayVo;
|
||||
import org.dromara.device.domain.vo.AppStreamStatusVo;
|
||||
import org.dromara.device.enums.DeviceStatusEnum;
|
||||
import org.dromara.device.mapper.BmDeviceMapper;
|
||||
import org.dromara.device.mqtt.DeviceCommandPublisher;
|
||||
import org.dromara.device.mqtt.payload.StreamCommandPayload;
|
||||
import org.dromara.device.service.IAppStreamService;
|
||||
import org.dromara.device.zlm.stream.ZlmClient;
|
||||
import org.dromara.device.stream.MjpegStreamBackend;
|
||||
import org.dromara.device.stream.StreamBackend;
|
||||
import org.dromara.device.stream.ZlmStreamBackend;
|
||||
import org.dromara.device.zlm.stream.ZlmPlayAuthService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* APP 直播服务实现。
|
||||
* <p>
|
||||
* 流程对齐 dm:MQTT 通知设备推流到 ZLM → 查询 getMediaInfo → 签发带 token 的播放地址。
|
||||
* 根据配置选择 MJPEG 或 ZLM 后端,统一完成设备推流指令、状态查询与播放地址签发。
|
||||
* 越权隔离对齐 I3:SQL {@code device_no + bind_user_id},命中 0 行统一「设备不存在」。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-17
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AppStreamServiceImpl implements IAppStreamService {
|
||||
|
||||
private final ZlmClient zlmClient;
|
||||
private final ZlmProperties config;
|
||||
private final MjpegProperties mjpegProperties;
|
||||
private final MjpegStreamBackend mjpegStreamBackend;
|
||||
private final ZlmStreamBackend zlmStreamBackend;
|
||||
private final ZlmPlayAuthService playAuthService;
|
||||
private final DeviceCommandPublisher commandPublisher;
|
||||
private final BmDeviceMapper deviceMapper;
|
||||
|
||||
@Override
|
||||
@@ -50,18 +46,7 @@ public class AppStreamServiceImpl implements IAppStreamService {
|
||||
throw new ServiceException(DeviceMsgConstants.DEVICE_OFFLINE);
|
||||
}
|
||||
String streamId = normalizeStreamId(device.getDeviceNo());
|
||||
if (!zlmClient.isStreamOnline(streamId)) {
|
||||
StreamCommandPayload payload = StreamCommandPayload.start(
|
||||
DeviceCommandPublisher.newMsgId(),
|
||||
streamId,
|
||||
config.getApp(),
|
||||
config.getPushProtocol(),
|
||||
buildPushUrl(streamId));
|
||||
if (!commandPublisher.publishStream(device.getDeviceNo(), payload)) {
|
||||
throw new ServiceException(DeviceMsgConstants.COMMAND_SEND_FAILED);
|
||||
}
|
||||
log.info("[ZLM] 已通知设备开始推流 deviceNo={} streamId={}", device.getDeviceNo(), streamId);
|
||||
}
|
||||
backend().start(device, streamId);
|
||||
return buildPlayInfo(device.getDeviceNo(), userId);
|
||||
}
|
||||
|
||||
@@ -69,10 +54,7 @@ public class AppStreamServiceImpl implements IAppStreamService {
|
||||
public AppStreamStatusVo stop(String deviceNo, Long userId) {
|
||||
BmDevice device = requireOwnedDevice(deviceNo, userId);
|
||||
String streamId = normalizeStreamId(device.getDeviceNo());
|
||||
StreamCommandPayload payload = StreamCommandPayload.stop(
|
||||
DeviceCommandPublisher.newMsgId(), streamId, config.getApp());
|
||||
commandPublisher.publishStream(device.getDeviceNo(), payload);
|
||||
zlmClient.closeStreams(streamId);
|
||||
backend().stop(device, streamId);
|
||||
return statusOf(device.getDeviceNo(), streamId, false);
|
||||
}
|
||||
|
||||
@@ -84,105 +66,50 @@ public class AppStreamServiceImpl implements IAppStreamService {
|
||||
@Override
|
||||
public void close(String deviceNo, Long userId) {
|
||||
BmDevice device = requireOwnedDevice(deviceNo, userId);
|
||||
closeZlmStream(device.getDeviceNo());
|
||||
String streamId = normalizeStreamId(device.getDeviceNo());
|
||||
backend().close(streamId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AppStreamStatusVo status(String deviceNo, Long userId) {
|
||||
BmDevice device = requireOwnedDevice(deviceNo, userId);
|
||||
String streamId = normalizeStreamId(device.getDeviceNo());
|
||||
return statusOf(device.getDeviceNo(), streamId, zlmClient.isStreamOnline(streamId));
|
||||
return statusOf(device.getDeviceNo(), streamId, backend().online(streamId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void closeZlmStream(String streamId) {
|
||||
streamId = normalizeStreamId(streamId);
|
||||
zlmClient.delStreamProxy(streamId);
|
||||
zlmClient.closeStreams(streamId);
|
||||
// 该入口只处理 ZLM webhook,不能受当前 APP 是否启用 MJPEG 的配置影响。
|
||||
zlmStreamBackend.close(streamId);
|
||||
}
|
||||
|
||||
private AppStreamPlayVo buildPlayInfo(String deviceNo, Long userId) {
|
||||
BmDevice device = requireOwnedDevice(deviceNo, userId);
|
||||
String streamId = normalizeStreamId(device.getDeviceNo());
|
||||
ZlmPlayAuthService.PlayToken playToken = playAuthService.createToken(streamId);
|
||||
// 同一次请求固定后端,避免配置热切换时地址与在线状态来自不同协议。
|
||||
StreamBackend selectedBackend = backend();
|
||||
StreamBackend.Endpoint endpoint = selectedBackend.endpoint(streamId, playToken.token());
|
||||
|
||||
AppStreamPlayVo vo = new AppStreamPlayVo();
|
||||
vo.setDeviceNo(device.getDeviceNo());
|
||||
vo.setStreamId(streamId);
|
||||
vo.setApp(config.getApp());
|
||||
vo.setOnline(zlmClient.isStreamOnline(streamId));
|
||||
vo.setPushUrl(buildPushUrl(streamId));
|
||||
vo.setOnline(selectedBackend.online(streamId));
|
||||
vo.setPushUrl(endpoint.pushUrl());
|
||||
vo.setPlayToken(playToken.token());
|
||||
vo.setExpireAt(playToken.expireAt());
|
||||
vo.setExpireSeconds(config.getPlayExpireSeconds());
|
||||
vo.setPlayUrls(buildPlayUrls(streamId, playToken.token()));
|
||||
vo.setPlayUrls(endpoint.playUrls());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private AppStreamPlayUrlsVo buildPlayUrls(String streamId, String token) {
|
||||
String base = trimTrailingSlash(config.getHost()) + "/" + config.getApp() + "/" + streamId;
|
||||
AppStreamPlayUrlsVo urls = new AppStreamPlayUrlsVo();
|
||||
urls.setFlv(appendToken(base + ".live.flv", token));
|
||||
urls.setHls(appendToken(base + "/hls.m3u8", token));
|
||||
urls.setRtmp(appendToken(buildRtmpUrl(streamId), token));
|
||||
urls.setRtsp(appendToken(buildRtspUrl(streamId), token));
|
||||
urls.setWebrtc(appendToken(buildWebrtcUrl(streamId), token));
|
||||
return urls;
|
||||
}
|
||||
|
||||
private String buildPushUrl(String streamId) {
|
||||
if ("rtsp".equalsIgnoreCase(config.getPushProtocol())) {
|
||||
return buildRtspUrl(streamId);
|
||||
}
|
||||
return buildRtmpUrl(streamId);
|
||||
}
|
||||
|
||||
private String buildRtmpUrl(String streamId) {
|
||||
return toProtocol(resolveProtocolHost(config.getRtmpHost(), config.getHost(), "1935"), "rtmp")
|
||||
+ "/" + config.getApp() + "/" + streamId;
|
||||
}
|
||||
|
||||
private String buildRtspUrl(String streamId) {
|
||||
return toProtocol(resolveProtocolHost(config.getRtspHost(), config.getHost(), "554"), "rtsp")
|
||||
+ "/" + config.getApp() + "/" + streamId;
|
||||
}
|
||||
|
||||
private String buildWebrtcUrl(String streamId) {
|
||||
return toProtocol(resolveProtocolHost(config.getWebrtcHost(), config.getHost(), null), "webrtc")
|
||||
+ "/" + config.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;
|
||||
/**
|
||||
* 根据当前配置选择唯一的直播后端,调用方不再散落协议分支。
|
||||
*/
|
||||
private StreamBackend backend() {
|
||||
return mjpegProperties.isEnabled() ? mjpegStreamBackend : zlmStreamBackend;
|
||||
}
|
||||
|
||||
private String normalizeStreamId(String deviceNo) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.dromara.device.domain.vo.AppAgreementVo;
|
||||
import org.dromara.device.domain.vo.AppUserProfileVo;
|
||||
import org.dromara.device.service.IAppUserService;
|
||||
import org.dromara.device.service.IDeviceBindService;
|
||||
import org.dromara.device.sms.AppPhoneNumbers;
|
||||
import org.dromara.device.sms.AppSmsService;
|
||||
import org.dromara.system.api.AppUserApiService;
|
||||
import org.dromara.system.api.UserService;
|
||||
@@ -116,17 +117,18 @@ public class AppUserServiceImpl implements IAppUserService {
|
||||
throw new ServiceException(AppMsgConstants.NOT_LOGIN);
|
||||
}
|
||||
boolean firstBind = StringUtils.isBlank(current.getPhoneNumber());
|
||||
String newPhone = AppPhoneNumbers.canonical(bo.getNewPhoneNumber());
|
||||
// P3-6:新号与当前号相同时给出精确文案(原先 existsByPhone 命中自身 → 误报「手机号已注册」)
|
||||
// 首次绑定时当前号为空,不存在「与当前号相同」的情形,故仅在换绑分支判断。
|
||||
if (!firstBind && StringUtils.equals(current.getPhoneNumber(), bo.getNewPhoneNumber())) {
|
||||
if (!firstBind && StringUtils.equals(current.getPhoneNumber(), newPhone)) {
|
||||
throw new ServiceException(AppMsgConstants.PHONE_SAME_AS_CURRENT);
|
||||
}
|
||||
if (appUserApiService.existsByPhone(bo.getNewPhoneNumber())) {
|
||||
if (appUserApiService.existsByPhone(newPhone)) {
|
||||
throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED);
|
||||
}
|
||||
appSmsService.verifyCode(bo.getNewPhoneNumber(),
|
||||
appSmsService.verifyCode(newPhone,
|
||||
firstBind ? AppConstants.SCENE_BIND : AppConstants.SCENE_REBIND, bo.getCode());
|
||||
appUserApiService.updatePhone(userId, bo.getNewPhoneNumber());
|
||||
appUserApiService.updatePhone(userId, newPhone);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,7 +144,7 @@ public class AppUserServiceImpl implements IAppUserService {
|
||||
// 3. 清除会话 —— 必须「按 loginId 全端下线」,而非「按当前 token 清」。
|
||||
// loginId = userType + ":" + userId(见 LoginUser#getLoginId),APP 登录标识恒为 app_user:{userId};
|
||||
// StpUtil.logout(loginId) 一次调用即注销该账号名下「所有设备/所有会话」的 token。
|
||||
// 反例:若用 StpUtil.logout()(仅清当前 token),其他设备上仍在有效期(默认 7 天)内的 token 会继续可用,
|
||||
// 反例:若用 StpUtil.logout()(仅清当前 token),其他设备上仍在有效期(默认 30 天)内的 token 会继续可用,
|
||||
// 与该账号「已注销/已停用」语义冲突,故采用全端下线。停用(status=停用)另在登录入口兜底拦截再登录。
|
||||
String loginId = UserType.APP_USER.getUserType() + ":" + userId;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
package org.dromara.device.service.impl;
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.core.domain.PageResult;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.utils.MapstructUtils;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.device.constant.AppVersionMsgConstants;
|
||||
import org.dromara.device.domain.BmAppVersion;
|
||||
import org.dromara.device.domain.bo.AppVersionBo;
|
||||
import org.dromara.device.domain.bo.AppVersionCheckBo;
|
||||
import org.dromara.device.domain.vo.AppVersionLatestVo;
|
||||
import org.dromara.device.domain.vo.AppVersionVo;
|
||||
import org.dromara.device.enums.AppPlatformEnum;
|
||||
import org.dromara.device.enums.AppVersionStatusEnum;
|
||||
import org.dromara.device.mapper.BmAppVersionMapper;
|
||||
import org.dromara.device.service.IAppVersionService;
|
||||
import org.dromara.system.api.OssService;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* APP 客户端版本服务实现(见 {@link IAppVersionService} 的契约说明)。
|
||||
* <p>
|
||||
* 写侧核心是「唯一性 + 递增 + 必填校验」三点;读侧(APP 检查更新)核心是「整数构建号比较」。
|
||||
* 唯一性采用「预检给出友好文案 + DB 唯一键兜底」双层(对齐 {@code OtaPackageServiceImpl} 的既有模式):
|
||||
* 预检拦下常规场景,唯一键兜住预检与写入之间的并发窗口以及已被逻辑删除的历史行。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AppVersionServiceImpl implements IAppVersionService {
|
||||
|
||||
/** 私有 OSS 安装包地址有效期,覆盖用户停留在升级弹窗后的下载场景。 */
|
||||
private static final Duration APP_PACKAGE_URL_VALIDITY = Duration.ofHours(24);
|
||||
|
||||
private final BmAppVersionMapper appVersionMapper;
|
||||
private final OssService ossService;
|
||||
|
||||
/**
|
||||
* 查询版本详情。
|
||||
*
|
||||
* @param versionId 版本主键
|
||||
* @return 版本视图对象;不存在返回 {@code null}
|
||||
*/
|
||||
@Override
|
||||
public AppVersionVo queryById(Long versionId) {
|
||||
return appVersionMapper.selectVoById(versionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询版本列表。
|
||||
*
|
||||
* @param bo 查询条件
|
||||
* @param pageQuery 分页参数
|
||||
* @return 分页结果
|
||||
*/
|
||||
@Override
|
||||
public PageResult<AppVersionVo> queryPageList(AppVersionBo bo, PageQuery pageQuery) {
|
||||
AppVersionBo condition = bo == null ? new AppVersionBo() : bo;
|
||||
LambdaQueryWrapper<BmAppVersion> lqw = buildQueryWrapper(condition);
|
||||
Page<AppVersionVo> result = appVersionMapper.selectVoPage(pageQuery.build(), lqw);
|
||||
return PageResult.build(result.getRecords(), result.getTotal());
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记(新增)版本。
|
||||
*
|
||||
* @param bo 新增业务对象
|
||||
* @return 是否新增成功
|
||||
*/
|
||||
@Override
|
||||
public Boolean insertByBo(AppVersionBo bo) {
|
||||
BmAppVersion add = MapstructUtils.convert(bo, BmAppVersion.class);
|
||||
// status 缺省兜底为「停用」(与 DB DEFAULT '0' 同口径;页面表单默认勾选「启用」)
|
||||
if (StringUtils.isBlank(add.getStatus())) {
|
||||
add.setStatus(AppVersionStatusEnum.DISABLED.getCode());
|
||||
}
|
||||
// force_update 缺省兜底为「否」(列 NOT NULL DEFAULT '0')
|
||||
if (StringUtils.isBlank(add.getForceUpdate())) {
|
||||
add.setForceUpdate(AppVersionMsgConstants.FORCE_UPDATE_NO);
|
||||
}
|
||||
validateAndNormalize(add, true);
|
||||
// 首次置为启用时写入发布时间
|
||||
if (AppVersionStatusEnum.ENABLED.getCode().equals(add.getStatus())) {
|
||||
add.setPublishTime(LocalDateTime.now());
|
||||
}
|
||||
try {
|
||||
boolean flag = appVersionMapper.insert(add) > 0;
|
||||
if (flag) {
|
||||
bo.setVersionId(add.getVersionId());
|
||||
}
|
||||
return flag;
|
||||
} catch (DuplicateKeyException e) {
|
||||
throw duplicateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改版本(含启用 / 停用)。
|
||||
*
|
||||
* @param bo 编辑业务对象(含 versionId)
|
||||
* @return 是否修改成功
|
||||
*/
|
||||
@Override
|
||||
public Boolean updateByBo(AppVersionBo bo) {
|
||||
if (bo == null || bo.getVersionId() == null) {
|
||||
throw new ServiceException(AppVersionMsgConstants.RECORD_NOT_EXIST);
|
||||
}
|
||||
BmAppVersion existing = appVersionMapper.selectById(bo.getVersionId());
|
||||
if (existing == null) {
|
||||
throw new ServiceException(AppVersionMsgConstants.RECORD_NOT_EXIST);
|
||||
}
|
||||
// 平台不可改:请求带了 platform 且与库中不一致则拒绝
|
||||
if (StringUtils.isNotBlank(bo.getPlatform()) && !bo.getPlatform().equals(existing.getPlatform())) {
|
||||
throw new ServiceException(AppVersionMsgConstants.PLATFORM_IMMUTABLE);
|
||||
}
|
||||
BmAppVersion update = MapstructUtils.convert(bo, BmAppVersion.class);
|
||||
// 平台以库中为准(不可变);即便请求未带 platform 也能通过后续分支校验
|
||||
update.setPlatform(existing.getPlatform());
|
||||
if (StringUtils.isBlank(update.getForceUpdate())) {
|
||||
update.setForceUpdate(existing.getForceUpdate());
|
||||
}
|
||||
if (StringUtils.isBlank(update.getStatus())) {
|
||||
update.setStatus(existing.getStatus());
|
||||
}
|
||||
// 普通字段编辑和启停不应重新触发“构建号必须大于最大值”;只有实际改号时才校验历史高水位。
|
||||
boolean versionCodeChanged = !Objects.equals(existing.getVersionCode(), update.getVersionCode());
|
||||
validateAndNormalize(update, versionCodeChanged);
|
||||
// 首次置为启用(此前从未发布)时补写发布时间
|
||||
if (AppVersionStatusEnum.ENABLED.getCode().equals(update.getStatus()) && existing.getPublishTime() == null) {
|
||||
update.setPublishTime(LocalDateTime.now());
|
||||
}
|
||||
try {
|
||||
return appVersionMapper.updateById(update) > 0;
|
||||
} catch (DuplicateKeyException e) {
|
||||
throw duplicateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除版本(逻辑删除)。
|
||||
*
|
||||
* @param ids 版本主键集合
|
||||
* @return 是否删除成功
|
||||
*/
|
||||
@Override
|
||||
public Boolean deleteByIds(Collection<Long> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return Boolean.FALSE;
|
||||
}
|
||||
return appVersionMapper.deleteByIds(ids) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* APP 检查更新(免登录,不依赖登录态)。
|
||||
*
|
||||
* @param bo 检查更新入参
|
||||
* @return 检查更新响应
|
||||
*/
|
||||
@Override
|
||||
public AppVersionLatestVo checkLatest(AppVersionCheckBo bo) {
|
||||
String platform = bo == null ? null : bo.getPlatform();
|
||||
Integer currentCode = bo == null ? null : bo.getVersionCode();
|
||||
if (!AppPlatformEnum.isValid(platform)) {
|
||||
throw new ServiceException(AppVersionMsgConstants.PLATFORM_INVALID);
|
||||
}
|
||||
if (currentCode == null || currentCode <= 0) {
|
||||
throw new ServiceException(AppVersionMsgConstants.VERSION_CODE_INVALID);
|
||||
}
|
||||
|
||||
AppVersionLatestVo vo = new AppVersionLatestVo();
|
||||
vo.setPlatform(platform);
|
||||
vo.setCurrentVersionCode(currentCode);
|
||||
// 三态默认:无更新(forceUpdate 恒 false)
|
||||
vo.setHasUpdate(Boolean.FALSE);
|
||||
vo.setForceUpdate(Boolean.FALSE);
|
||||
|
||||
// 用 MyBatis-Plus 分页取首条,由方言插件生成分页 SQL,避免硬编码 MySQL LIMIT。
|
||||
Page<BmAppVersion> latestPage = appVersionMapper.selectPage(new Page<>(1, 1, false),
|
||||
Wrappers.<BmAppVersion>lambdaQuery()
|
||||
.eq(BmAppVersion::getPlatform, platform)
|
||||
.eq(BmAppVersion::getStatus, AppVersionStatusEnum.ENABLED.getCode())
|
||||
.orderByDesc(BmAppVersion::getVersionCode));
|
||||
BmAppVersion latest = latestPage.getRecords().isEmpty() ? null : latestPage.getRecords().getFirst();
|
||||
if (latest == null || latest.getVersionCode() == null) {
|
||||
// 无启用记录
|
||||
return vo;
|
||||
}
|
||||
if (currentCode >= latest.getVersionCode()) {
|
||||
// 已是最新 / 上报更高构建号:不提示降级
|
||||
return vo;
|
||||
}
|
||||
|
||||
String downloadUrl = resolveDownloadUrl(latest);
|
||||
if (StringUtils.isBlank(downloadUrl)) {
|
||||
// 包记录异常时宁可暂不提示更新,也不能把用户锁在一个无法下载的强更弹窗中。
|
||||
log.error("APP 最新版本安装包不可用 => versionId: {}, platform: {}, ossId: {}",
|
||||
latest.getVersionId(), latest.getPlatform(), latest.getOssId());
|
||||
return vo;
|
||||
}
|
||||
|
||||
vo.setHasUpdate(Boolean.TRUE);
|
||||
vo.setForceUpdate(AppVersionMsgConstants.FORCE_UPDATE_YES.equals(latest.getForceUpdate()));
|
||||
vo.setLatestVersionCode(latest.getVersionCode());
|
||||
vo.setLatestVersionName(latest.getVersionName());
|
||||
vo.setChangelog(latest.getChangelog());
|
||||
// 两平台口径一致:都下发安装包信息;storeUrl 有值则下发、无值给 null(不再按平台裁剪)
|
||||
vo.setFileSize(latest.getFileSize());
|
||||
vo.setMd5(latest.getMd5());
|
||||
vo.setDownloadUrl(downloadUrl);
|
||||
vo.setStoreUrl(latest.getStoreUrl());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建版本动态查询条件。
|
||||
* <p>
|
||||
* 默认按构建号倒序;日期区间经 {@code params.beginTime} / {@code params.endTime}
|
||||
* 按<b>发布时间</b>过滤(与页面「发布时间」筛选项口径一致)。
|
||||
*
|
||||
* @param bo 查询条件
|
||||
* @return 查询条件包装器
|
||||
*/
|
||||
private LambdaQueryWrapper<BmAppVersion> buildQueryWrapper(AppVersionBo bo) {
|
||||
LambdaQueryWrapper<BmAppVersion> lqw = Wrappers.lambdaQuery();
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getPlatform()), BmAppVersion::getPlatform, bo.getPlatform());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getStatus()), BmAppVersion::getStatus, bo.getStatus());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getForceUpdate()), BmAppVersion::getForceUpdate, bo.getForceUpdate());
|
||||
if (StringUtils.isNotBlank(bo.getKeyword())) {
|
||||
lqw.like(BmAppVersion::getVersionName, bo.getKeyword());
|
||||
}
|
||||
Map<String, Object> params = bo.getParams() == null ? new HashMap<>() : bo.getParams();
|
||||
LocalDateTime begin = parseDateTime(params.get("beginTime"), true);
|
||||
LocalDateTime end = parseDateTime(params.get("endTime"), false);
|
||||
lqw.ge(begin != null, BmAppVersion::getPublishTime, begin);
|
||||
lqw.le(end != null, BmAppVersion::getPublishTime, end);
|
||||
lqw.orderByDesc(BmAppVersion::getVersionCode);
|
||||
return lqw;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存前校验 + 归一化(新增与修改共用)。
|
||||
* <p>
|
||||
* 校验顺序:平台合法 → 必填(展示版本号 / 构建号 / 安装包)→ 唯一性预检
|
||||
* (构建号、展示版本号,均排除自身)→ 构建号必须大于同平台最大值(排除自身)。
|
||||
* 归一化:{@code md5} 本期恒置空(预留不填写);{@code storeUrl} 为选填,填了原样保留。
|
||||
*
|
||||
* @param entity 待保存实体(修改时 {@code versionId} 非空,用于排除自身)
|
||||
* @param validateVersionGreater 是否校验构建号历史高水位
|
||||
*/
|
||||
private void validateAndNormalize(BmAppVersion entity, boolean validateVersionGreater) {
|
||||
// 1. 平台合法
|
||||
if (!AppPlatformEnum.isValid(entity.getPlatform())) {
|
||||
throw new ServiceException(AppVersionMsgConstants.PLATFORM_INVALID);
|
||||
}
|
||||
// 2. 必填
|
||||
if (StringUtils.isBlank(entity.getVersionName())) {
|
||||
throw new ServiceException(AppVersionMsgConstants.VERSION_NAME_REQUIRED);
|
||||
}
|
||||
if (entity.getVersionCode() == null || entity.getVersionCode() <= 0) {
|
||||
throw new ServiceException(AppVersionMsgConstants.VERSION_CODE_INVALID);
|
||||
}
|
||||
// 3. 必填(两平台一致:必须上传安装包);storeUrl 为选填,填了就保留、不填保持 null
|
||||
if (entity.getOssId() == null || StringUtils.isBlank(entity.getFileUrl())) {
|
||||
throw new ServiceException(AppVersionMsgConstants.PACKAGE_REQUIRED);
|
||||
}
|
||||
// md5 本期不采集(框架上传响应无 md5),恒置空,不参与任何校验
|
||||
entity.setMd5(null);
|
||||
// 4. 唯一性预检(排除自身)
|
||||
validUnique(entity);
|
||||
// 5. 新增或实际修改构建号时,必须大于包含逻辑删除记录在内的历史高水位。
|
||||
if (validateVersionGreater) {
|
||||
validVersionCodeGreater(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 唯一性预检(平台 + 构建号、平台 + 展示版本号,均排除自身)。
|
||||
* <p>
|
||||
* 预检按 MyBatis-Plus 默认(仅 {@code del_flag='0'})查询;对已被逻辑删除的冲突行预检查不到,
|
||||
* 但 DB 唯一键仍会拒绝,此时由调用侧捕获 {@link DuplicateKeyException} 归一。
|
||||
*
|
||||
* @param entity 待保存实体
|
||||
*/
|
||||
private void validUnique(BmAppVersion entity) {
|
||||
Long selfId = entity.getVersionId();
|
||||
long codeConflict = appVersionMapper.selectCount(Wrappers.<BmAppVersion>lambdaQuery()
|
||||
.eq(BmAppVersion::getPlatform, entity.getPlatform())
|
||||
.eq(BmAppVersion::getVersionCode, entity.getVersionCode())
|
||||
.ne(selfId != null, BmAppVersion::getVersionId, selfId));
|
||||
if (codeConflict > 0) {
|
||||
throw new ServiceException(AppVersionMsgConstants.VERSION_CODE_DUPLICATE);
|
||||
}
|
||||
long nameConflict = appVersionMapper.selectCount(Wrappers.<BmAppVersion>lambdaQuery()
|
||||
.eq(BmAppVersion::getPlatform, entity.getPlatform())
|
||||
.eq(BmAppVersion::getVersionName, entity.getVersionName())
|
||||
.ne(selfId != null, BmAppVersion::getVersionId, selfId));
|
||||
if (nameConflict > 0) {
|
||||
throw new ServiceException(AppVersionMsgConstants.VERSION_NAME_DUPLICATE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验构建号必须严格大于该平台已有最大值(含停用记录;修改时排除自身)。
|
||||
* <p>
|
||||
* 为什么含停用记录:避免构建号被回收后历史包与将来新包的构建号冲突,导致部分用户永远收不到更新。
|
||||
*
|
||||
* @param entity 待保存实体
|
||||
*/
|
||||
private void validVersionCodeGreater(BmAppVersion entity) {
|
||||
Integer historicalMax = appVersionMapper.selectMaxVersionCodeIncludingDeleted(
|
||||
entity.getPlatform(), entity.getVersionId());
|
||||
int max = historicalMax == null ? 0 : historicalMax;
|
||||
if (entity.getVersionCode() <= max) {
|
||||
throw new ServiceException(String.format(AppVersionMsgConstants.VERSION_CODE_NOT_GREATER, max));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每次检查更新都按 {@code ossId} 重新解析下载地址,私有桶因此不会复用登记时的短期签名。
|
||||
*/
|
||||
private String resolveDownloadUrl(BmAppVersion latest) {
|
||||
if (latest.getOssId() == null) {
|
||||
return latest.getFileUrl();
|
||||
}
|
||||
try {
|
||||
return ossService.selectDownloadUrlById(latest.getOssId(), APP_PACKAGE_URL_VALIDITY);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("解析 APP 安装包下载地址失败 => versionId: {}, ossId: {}",
|
||||
latest.getVersionId(), latest.getOssId(), e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据唯一键名称把并发写入异常还原成稳定业务文案。
|
||||
*/
|
||||
private ServiceException duplicateException(DuplicateKeyException e) {
|
||||
String message = e.getMostSpecificCause() == null ? e.getMessage() : e.getMostSpecificCause().getMessage();
|
||||
if (StringUtils.contains(message, "uk_platform_version_name")) {
|
||||
return new ServiceException(AppVersionMsgConstants.VERSION_NAME_DUPLICATE);
|
||||
}
|
||||
return new ServiceException(AppVersionMsgConstants.VERSION_CODE_DUPLICATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析前端日期区间入参({@code params.beginTime} / {@code params.endTime})。
|
||||
* <p>
|
||||
* 纯日期({@code yyyy-MM-dd})时按区间端点补齐:起点取 {@code 00:00:00}、终点取 {@code 23:59:59},
|
||||
* 否则「结束日 = 今天」会把当天的记录整体漏掉。解析失败返回 {@code null}(该侧区间不生效),不抛异常。
|
||||
*
|
||||
* @param value 原始入参
|
||||
* @param beginDay 是否为区间起点(决定纯日期时的补齐方向)
|
||||
* @return 解析后的时间;无法解析时返回 {@code null}
|
||||
*/
|
||||
private LocalDateTime parseDateTime(Object value, boolean beginDay) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
if (text.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
LocalDateTime parsed = LocalDateTimeUtil.of(DateUtil.parse(text));
|
||||
if (text.length() <= 10) {
|
||||
return beginDay ? parsed.toLocalDate().atStartOfDay()
|
||||
: parsed.toLocalDate().atTime(23, 59, 59);
|
||||
}
|
||||
return parsed;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.dromara.device.sms;
|
||||
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.utils.MessageUtils;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
|
||||
/**
|
||||
* APP 手机号归一化:国内存 11 位,国际存带 {@code +} 的 E.164 号码。
|
||||
* <p>
|
||||
* 入参仍是 {@code phoneNumber}:国内可写 {@code 13800138000} 或 {@code +8613800138000};
|
||||
* 国际必须带 {@code +} 或 {@code 00} 国家码,例如 {@code +85212345678}。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
public final class AppPhoneNumbers {
|
||||
|
||||
private static final String CN_MOBILE = "1[3-9]\\d{9}";
|
||||
|
||||
private AppPhoneNumbers() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 转为账号/缓存用的规范号码。
|
||||
*
|
||||
* @param raw 用户输入
|
||||
* @return 国内 11 位,或带 {@code +} 的国际号码
|
||||
*/
|
||||
public static String canonical(String raw) {
|
||||
String compact = compact(raw);
|
||||
if (compact.matches(CN_MOBILE)) {
|
||||
return compact;
|
||||
}
|
||||
if (compact.startsWith("+")) {
|
||||
String digits = compact.substring(1);
|
||||
if (digits.matches("86" + CN_MOBILE)) {
|
||||
return digits.substring(2);
|
||||
}
|
||||
if (digits.matches("[1-9]\\d{6,14}")) {
|
||||
return "+" + digits;
|
||||
}
|
||||
}
|
||||
throw new ServiceException(MessageUtils.message("user.mobile.phone.number.not.valid"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否中国大陆手机号。
|
||||
*
|
||||
* @param canonical {@link #canonical(String)} 的结果
|
||||
* @return 国内为 true
|
||||
*/
|
||||
public static boolean chinaMainland(String canonical) {
|
||||
return canonical != null && canonical.matches(CN_MOBILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发给短信通道的号码:国内 11 位;国际去掉 {@code +},对齐阿里云国际短信。
|
||||
*
|
||||
* @param canonical {@link #canonical(String)} 的结果
|
||||
* @return 通道号码
|
||||
*/
|
||||
public static String forSms(String canonical) {
|
||||
if (canonical == null) {
|
||||
return null;
|
||||
}
|
||||
return canonical.startsWith("+") ? canonical.substring(1) : canonical;
|
||||
}
|
||||
|
||||
private static String compact(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
throw new ServiceException(MessageUtils.message("user.phonenumber.not.blank"));
|
||||
}
|
||||
String value = raw.strip().replaceAll("[\\s()-]", "");
|
||||
if (value.startsWith("00")) {
|
||||
value = "+" + value.substring(2);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import cn.hutool.core.util.RandomUtil;
|
||||
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.core.utils.MessageUtils;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.dromara.common.sms.core.AliyunInternationalSmsService;
|
||||
import org.dromara.device.config.AppProperties;
|
||||
import org.dromara.device.constant.AppConstants;
|
||||
import org.dromara.device.constant.AppMsgConstants;
|
||||
import org.dromara.device.constant.DeviceMsgConstants;
|
||||
import org.dromara.device.enums.SmsSceneEnum;
|
||||
import org.dromara.sms4j.api.SmsBlend;
|
||||
import org.dromara.sms4j.api.entity.SmsResponse;
|
||||
@@ -27,7 +28,7 @@ import java.util.LinkedHashMap;
|
||||
* <li><b>场景化隔离</b>:缓存 key 与限流 key 均带场景,登录/注册/忘记密码/换绑互不覆盖。</li>
|
||||
* <li><b>时长口径</b>:验证码有效期 5 分钟、重发间隔 60 秒、位数 6 位,均可由 {@code device.app.sms.*} 配置覆盖。</li>
|
||||
* <li><b>短信通道开关点</b>:{@code device.app.sms.mock}(默认 true)为 true 时仅打印日志,便于开发期(Q5 短信通道未定)联调;
|
||||
* 置为 false 时走 sms4j 真实通道。</li>
|
||||
* 置为 false 时国内走 sms4j,国际走 {@link AliyunInternationalSmsService}(与后台 {@code /sendInternational} 同一通道)。</li>
|
||||
* <li><b>不触碰 PC 通道</b>:本服务仅服务 {@code /app/**},与框架既有 {@code /resource/sms/code} 完全独立。</li>
|
||||
* </ul>
|
||||
*
|
||||
@@ -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<String, String> 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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<AppStreamPlayVo> 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<AppStreamStatusVo> status(@PathVariable String deviceNo) {
|
||||
return R.ok(appStreamService.status(deviceNo, currentUserId()));
|
||||
}
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> ok() {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("code", 0);
|
||||
|
||||
@@ -76,7 +76,8 @@ public class ZlmPlayAuthService {
|
||||
Map<String, Object> 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;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="org.dromara.device.mapper.BmAppMessageMapper">
|
||||
|
||||
<!-- 基础 CRUD 走 BaseMapperPlus,自定义 SQL 在后续迭代补充 -->
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="org.dromara.device.mapper.BmAppVersionMapper">
|
||||
|
||||
<!--
|
||||
自定义 SQL 不会被 MyBatis-Plus 追加 del_flag 条件。
|
||||
版本构建号是只能前进的历史高水位,因此删除记录也必须参与最大值计算。
|
||||
-->
|
||||
<select id="selectMaxVersionCodeIncludingDeleted" resultType="java.lang.Integer">
|
||||
SELECT MAX(version_code)
|
||||
FROM bm_app_version
|
||||
WHERE platform = #{platform}
|
||||
<if test="excludeVersionId != null">
|
||||
AND version_id != #{excludeVersionId}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -4,6 +4,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="org.dromara.device.mapper.BmDeviceAlertMapper">
|
||||
|
||||
<!-- 基础 CRUD 走 BaseMapperPlus,自定义 SQL 在后续迭代补充 -->
|
||||
<!--
|
||||
告警表本身作为消息 outbox:message_user_id 是发生时的收件人快照,alert_id 唯一键保证补偿幂等。
|
||||
这里有意不筛 del_flag;后台删除告警不等于撤回已经发生的 APP 通知。
|
||||
-->
|
||||
<select id="selectPendingMessageAlerts" resultType="org.dromara.device.domain.BmDeviceAlert">
|
||||
SELECT a.*
|
||||
FROM bm_device_alert a
|
||||
LEFT JOIN bm_app_message m ON m.alert_id = a.alert_id
|
||||
WHERE a.message_user_id IS NOT NULL
|
||||
AND m.message_id IS NULL
|
||||
ORDER BY a.alert_time ASC, a.alert_id ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -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 作为告警发生时的消息收件人快照,避免告警落库后再次查询导致换绑串号。
|
||||
-->
|
||||
<select id="selectStaleOnlineDevices" resultType="org.dromara.device.domain.BmDevice">
|
||||
SELECT device_id, device_no, last_online_time
|
||||
SELECT device_id, device_no, bind_user_id, last_online_time
|
||||
FROM bm_device
|
||||
WHERE del_flag = '0'
|
||||
AND status = '1'
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
package org.dromara.device;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.device.constant.AppVersionMsgConstants;
|
||||
import org.dromara.device.domain.BmAppVersion;
|
||||
import org.dromara.device.domain.bo.AppVersionCheckBo;
|
||||
import org.dromara.device.domain.vo.AppVersionLatestVo;
|
||||
import org.dromara.device.enums.AppPlatformEnum;
|
||||
import org.dromara.device.enums.AppVersionStatusEnum;
|
||||
import org.dromara.device.mapper.BmAppVersionMapper;
|
||||
import org.dromara.device.service.impl.AppVersionServiceImpl;
|
||||
import org.dromara.system.api.OssService;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertAll;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* APP 客户端版本「检查更新」核心判定逻辑回归测试。
|
||||
* <p>
|
||||
* 覆盖 {@link AppVersionServiceImpl#checkLatest(AppVersionCheckBo)}:
|
||||
* ① 版本比较(唯一依据是整数构建号);② 三态(无更新 / 可选更新 / 强制更新);
|
||||
* ③ 平台口径统一(两平台一致下发包字段);④ 非法入参兜底;⑤ 候选集查询意图。
|
||||
* <p>
|
||||
* ⚠️ 测试边界(如实说明):本套用例通过 mock {@link BmAppVersionMapper} 驱动,因此验证的是
|
||||
* <b>服务层的判定逻辑</b>。数据库层的真实语义({@code del_flag='0'} 逻辑删除过滤的真实 SQL、
|
||||
* 索引与排序在 MySQL 上的实际执行)不在单测范围内,由交付总监在补丁 7 落库后的端到端探针覆盖。
|
||||
* 第 ⑤ 组用例通过捕获查询条件做「结构化验证」,只能证明服务层向 DB 提出了正确的查询意图,
|
||||
* 不能替代真实 SQL 执行验证。
|
||||
*
|
||||
* @author Edward(QA)
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Tag("dev")
|
||||
@DisplayName("APP 版本检查更新 · AppVersionServiceImpl#checkLatest")
|
||||
class AppVersionServiceImplTest {
|
||||
|
||||
private static final String ANDROID = "android";
|
||||
private static final String IOS = "ios";
|
||||
private static final String STRONG = AppVersionMsgConstants.FORCE_UPDATE_YES;
|
||||
private static final String WEAK = AppVersionMsgConstants.FORCE_UPDATE_NO;
|
||||
|
||||
private BmAppVersionMapper appVersionMapper;
|
||||
private OssService ossService;
|
||||
private AppVersionServiceImpl service;
|
||||
|
||||
/**
|
||||
* 单测不启动 Spring / SqlSessionFactory,需手动初始化实体的 {@code TableInfo},
|
||||
* 否则 {@link LambdaQueryWrapper} 在解析列名(第 ⑤ 组用例渲染 SQL 片段)时会抛
|
||||
* {@code MybatisPlus can not find lambda cache for this entity}。
|
||||
*/
|
||||
@BeforeAll
|
||||
static void initMybatisPlusMetadata() {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
GlobalConfigUtils.setGlobalConfig(configuration, GlobalConfigUtils.defaults());
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
TableInfoHelper.initTableInfo(assistant, BmAppVersion.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
appVersionMapper = mock(BmAppVersionMapper.class);
|
||||
ossService = mock(OssService.class);
|
||||
service = new AppVersionServiceImpl(appVersionMapper, ossService);
|
||||
}
|
||||
|
||||
// ============================== 测试夹具 ==============================
|
||||
|
||||
/** 构造一条候选「线上最新版」记录(默认启用)。 */
|
||||
private static BmAppVersion row(String platform, int code, String name, String forceUpdate,
|
||||
String fileUrl, Long fileSize, String md5, String storeUrl) {
|
||||
BmAppVersion v = new BmAppVersion();
|
||||
v.setPlatform(platform);
|
||||
v.setVersionCode(code);
|
||||
v.setVersionName(name);
|
||||
v.setForceUpdate(forceUpdate);
|
||||
v.setOssId(1L);
|
||||
v.setFileUrl(fileUrl);
|
||||
v.setFileSize(fileSize);
|
||||
v.setMd5(md5);
|
||||
v.setStoreUrl(storeUrl);
|
||||
v.setStatus(AppVersionStatusEnum.ENABLED.getCode());
|
||||
return v;
|
||||
}
|
||||
|
||||
private static BmAppVersion androidRow(int code, String name, String forceUpdate) {
|
||||
return row(ANDROID, code, name, forceUpdate, "https://oss.example.com/app.apk", 20_480_000L, null, null);
|
||||
}
|
||||
|
||||
private static AppVersionCheckBo check(String platform, Integer versionCode) {
|
||||
AppVersionCheckBo bo = new AppVersionCheckBo();
|
||||
bo.setPlatform(platform);
|
||||
bo.setVersionCode(versionCode);
|
||||
return bo;
|
||||
}
|
||||
|
||||
/** 令分页查询返回给定记录,代表「从候选集中取到的最新版」(null 表示无启用记录)。 */
|
||||
private void latestWillBe(BmAppVersion row) {
|
||||
Page<BmAppVersion> page = new Page<>(1, 1, false);
|
||||
page.setRecords(row == null ? List.of() : List.of(row));
|
||||
when(appVersionMapper.selectPage(any(Page.class), any())).thenReturn(page);
|
||||
if (row != null) {
|
||||
when(ossService.selectDownloadUrlById(eq(row.getOssId()), any())).thenReturn(row.getFileUrl());
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== ① 版本比较 ==============================
|
||||
|
||||
@Nested
|
||||
@DisplayName("① 版本比较:唯一依据是整数构建号 versionCode")
|
||||
class Comparison {
|
||||
|
||||
@Test
|
||||
@DisplayName("核心用例:上报 1000009(1.0.9) 对线上 1000010(1.0.10) → hasUpdate=true")
|
||||
void detectsNewerBuildAcrossTens() {
|
||||
latestWillBe(androidRow(1_000_010, "1.0.10", WEAK));
|
||||
|
||||
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_009));
|
||||
|
||||
assertAll(
|
||||
() -> assertTrue(vo.getHasUpdate(), "较低的构建号必须被判定为有新版本"),
|
||||
() -> assertEquals(1_000_010, vo.getLatestVersionCode().intValue()),
|
||||
() -> assertEquals("1.0.10", vo.getLatestVersionName()),
|
||||
() -> assertEquals(1_000_009, vo.getCurrentVersionCode().intValue())
|
||||
);
|
||||
// 佐证本用例的必要性:按展示版本号做字符串比较会判反('1.0.9' > '1.0.10'),
|
||||
// 因此「跨十位版本」必须靠整数构建号比较,否则 1.0.9 的用户永远收不到 1.0.10。
|
||||
assertTrue("1.0.9".compareTo("1.0.10") > 0, "字符串比较会误判:1.0.9 字典序大于 1.0.10");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("上报构建号等于线上最新 → hasUpdate=false")
|
||||
void noUpdateWhenClientEqualsLatest() {
|
||||
latestWillBe(androidRow(1_000_010, "1.0.10", WEAK));
|
||||
|
||||
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_010));
|
||||
|
||||
assertAll(
|
||||
() -> assertFalse(vo.getHasUpdate()),
|
||||
() -> assertFalse(vo.getForceUpdate()),
|
||||
() -> assertNull(vo.getLatestVersionCode()),
|
||||
() -> assertNull(vo.getLatestVersionName())
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("上报构建号高于线上最新 → hasUpdate=false(不提示降级)")
|
||||
void noUpdateWhenClientIsNewer() {
|
||||
latestWillBe(androidRow(1_000_010, "1.0.10", WEAK));
|
||||
|
||||
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_011));
|
||||
|
||||
assertAll(
|
||||
() -> assertFalse(vo.getHasUpdate()),
|
||||
() -> assertFalse(vo.getForceUpdate()),
|
||||
() -> assertNull(vo.getLatestVersionCode())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== ② 三态 / 强更 ==============================
|
||||
|
||||
@Nested
|
||||
@DisplayName("② 三态:无更新 / 可选更新 / 强制更新")
|
||||
class TriState {
|
||||
|
||||
@Test
|
||||
@DisplayName("有新版本 + force_update='0' → 可选更新(hasUpdate=true, forceUpdate=false)")
|
||||
void optionalUpdateWhenForceOff() {
|
||||
latestWillBe(androidRow(1_000_010, "1.0.10", WEAK));
|
||||
|
||||
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_009));
|
||||
|
||||
assertAll(
|
||||
() -> assertTrue(vo.getHasUpdate()),
|
||||
() -> assertFalse(vo.getForceUpdate())
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有新版本 + force_update='1' → 强制更新(hasUpdate=true, forceUpdate=true)")
|
||||
void forcedUpdateWhenForceOn() {
|
||||
latestWillBe(androidRow(1_000_010, "1.0.10", STRONG));
|
||||
|
||||
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_009));
|
||||
|
||||
assertAll(
|
||||
() -> assertTrue(vo.getHasUpdate()),
|
||||
() -> assertTrue(vo.getForceUpdate())
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已是最新时即使 force_update='1',forceUpdate 也恒为 false(PRD C8)")
|
||||
void forceUpdateAlwaysFalseWhenNoUpdate() {
|
||||
latestWillBe(androidRow(1_000_010, "1.0.10", STRONG));
|
||||
|
||||
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_010));
|
||||
|
||||
assertAll(
|
||||
() -> assertFalse(vo.getHasUpdate()),
|
||||
() -> assertFalse(vo.getForceUpdate(), "无更新时 forceUpdate 必须恒为 false")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无可用版本记录 → 表达为「无更新」而非抛异常(PRD C3)")
|
||||
void noEnabledRecordYieldsNoUpdate() {
|
||||
latestWillBe(null);
|
||||
|
||||
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_009));
|
||||
|
||||
assertAll(
|
||||
() -> assertFalse(vo.getHasUpdate()),
|
||||
() -> assertFalse(vo.getForceUpdate()),
|
||||
() -> assertNull(vo.getLatestVersionCode()),
|
||||
() -> assertNull(vo.getLatestVersionName()),
|
||||
() -> assertNull(vo.getChangelog()),
|
||||
() -> assertNull(vo.getDownloadUrl()),
|
||||
() -> assertNull(vo.getStoreUrl()),
|
||||
() -> assertEquals(1_000_009, vo.getCurrentVersionCode().intValue(), "回显入参便于 APP 自校验")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== ③ 平台口径统一 ==============================
|
||||
|
||||
@Nested
|
||||
@DisplayName("③ 平台口径统一:两平台都下发安装包字段;storeUrl 有值才下发")
|
||||
class UnifiedPlatformFields {
|
||||
|
||||
@Test
|
||||
@DisplayName("android → downloadUrl/fileSize 有值;storeUrl 未填时下发 null")
|
||||
void androidCarriesPackageFieldsWithoutStoreUrl() {
|
||||
latestWillBe(androidRow(1_000_010, "1.0.10", WEAK));
|
||||
|
||||
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_009));
|
||||
|
||||
assertAll(
|
||||
() -> assertEquals(ANDROID, vo.getPlatform()),
|
||||
() -> assertTrue(vo.getHasUpdate()),
|
||||
() -> assertEquals("https://oss.example.com/app.apk", vo.getDownloadUrl()),
|
||||
() -> assertEquals(20_480_000L, vo.getFileSize().longValue()),
|
||||
() -> assertNull(vo.getStoreUrl(), "未填 storeUrl 时应下发 null")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ios → 与 android 口径一致:同样下发 downloadUrl/fileSize/md5;storeUrl 有值则下发")
|
||||
void iosAlsoCarriesPackageFields() {
|
||||
// iOS 也上传安装包:候选记录同时带包字段与 storeUrl,验证两平台口径一致、不再按平台裁剪
|
||||
BmAppVersion iosWithPackage = row(IOS, 1_000_010, "1.0.10", WEAK,
|
||||
"https://oss.example.com/app.ipa", 12_345_678L, "deadbeef", "https://apps.apple.com/app/id123");
|
||||
latestWillBe(iosWithPackage);
|
||||
|
||||
AppVersionLatestVo vo = service.checkLatest(check(IOS, 1_000_009));
|
||||
|
||||
assertAll(
|
||||
() -> assertEquals(IOS, vo.getPlatform()),
|
||||
() -> assertTrue(vo.getHasUpdate()),
|
||||
() -> assertEquals("https://oss.example.com/app.ipa", vo.getDownloadUrl(), "iOS 也应下发安装包地址"),
|
||||
() -> assertEquals(12_345_678L, vo.getFileSize().longValue(), "iOS 也应下发包体积"),
|
||||
() -> assertEquals("deadbeef", vo.getMd5(), "iOS 也应下发 md5"),
|
||||
() -> assertEquals("https://apps.apple.com/app/id123", vo.getStoreUrl(), "storeUrl 有值则下发")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== ④ 非法入参兜底 ==============================
|
||||
|
||||
@Nested
|
||||
@DisplayName("④ 非法入参兜底:平台非法 / 构建号非正整数")
|
||||
class InvalidInput {
|
||||
|
||||
@Test
|
||||
@DisplayName("platform 为 null → 平台参数不合法")
|
||||
void nullPlatformRejected() {
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.checkLatest(check(null, 1)));
|
||||
|
||||
assertEquals(AppVersionMsgConstants.PLATFORM_INVALID, ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("platform 取值非法(windows / 大写 / 空串)→ 平台参数不合法")
|
||||
void illegalPlatformRejected() {
|
||||
assertAll(
|
||||
() -> assertEquals(AppVersionMsgConstants.PLATFORM_INVALID,
|
||||
assertThrows(ServiceException.class, () -> service.checkLatest(check("windows", 1))).getMessage()),
|
||||
() -> assertEquals(AppVersionMsgConstants.PLATFORM_INVALID,
|
||||
assertThrows(ServiceException.class, () -> service.checkLatest(check("ANDROID", 1))).getMessage()),
|
||||
() -> assertEquals(AppVersionMsgConstants.PLATFORM_INVALID,
|
||||
assertThrows(ServiceException.class, () -> service.checkLatest(check("", 1))).getMessage())
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("入参对象为 null → 平台参数不合法(不应 NPE)")
|
||||
void nullBoRejected() {
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.checkLatest(null));
|
||||
|
||||
assertEquals(AppVersionMsgConstants.PLATFORM_INVALID, ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("versionCode 为 null / 0 / 负数 → 版本号不合法")
|
||||
void illegalVersionCodeRejected() {
|
||||
assertAll(
|
||||
() -> assertEquals(AppVersionMsgConstants.VERSION_CODE_INVALID,
|
||||
assertThrows(ServiceException.class, () -> service.checkLatest(check(ANDROID, null))).getMessage()),
|
||||
() -> assertEquals(AppVersionMsgConstants.VERSION_CODE_INVALID,
|
||||
assertThrows(ServiceException.class, () -> service.checkLatest(check(ANDROID, 0))).getMessage()),
|
||||
() -> assertEquals(AppVersionMsgConstants.VERSION_CODE_INVALID,
|
||||
assertThrows(ServiceException.class, () -> service.checkLatest(check(ANDROID, -5))).getMessage())
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("平台枚举严格匹配,不接受大小写或别名")
|
||||
void platformEnumIsStrict() {
|
||||
assertAll(
|
||||
() -> assertTrue(AppPlatformEnum.isValid(ANDROID)),
|
||||
() -> assertTrue(AppPlatformEnum.isValid(IOS)),
|
||||
() -> assertFalse(AppPlatformEnum.isValid("ANDROID")),
|
||||
() -> assertFalse(AppPlatformEnum.isValid("Ios")),
|
||||
() -> assertFalse(AppPlatformEnum.isValid("web"))
|
||||
);
|
||||
assertFalse(AppPlatformEnum.isValid(null), "null 必须判为非法");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== ⑤ 候选集查询意图(结构化验证) ==============================
|
||||
|
||||
@Nested
|
||||
@DisplayName("⑤ 候选集查询意图(结构化验证,真实 SQL 语义由端到端探针覆盖)")
|
||||
class QueryIntent {
|
||||
|
||||
@Test
|
||||
@DisplayName("候选集限定 platform + status='1',按构建号倒序取一条(对应 PRD C10/C11 查询口径)")
|
||||
void queryIsLimitedToEnabledRowsOrderedByMaxBuildNumber() {
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<LambdaQueryWrapper<BmAppVersion>> captor =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Page<BmAppVersion>> pageCaptor = ArgumentCaptor.forClass(Page.class);
|
||||
Page<BmAppVersion> empty = new Page<>(1, 1, false);
|
||||
empty.setRecords(List.of());
|
||||
when(appVersionMapper.selectPage(pageCaptor.capture(), captor.capture())).thenReturn(empty);
|
||||
|
||||
service.checkLatest(check(ANDROID, 1_000_009));
|
||||
|
||||
LambdaQueryWrapper<BmAppVersion> wrapper = captor.getValue();
|
||||
Map<String, Object> boundValues = wrapper.getParamNameValuePairs();
|
||||
String sqlSegment = wrapper.getSqlSegment().toLowerCase();
|
||||
|
||||
assertAll(
|
||||
() -> assertTrue(boundValues.containsValue(ANDROID), "查询应限定 platform=android"),
|
||||
() -> assertTrue(boundValues.containsValue(AppVersionStatusEnum.ENABLED.getCode()),
|
||||
"查询应限定 status='1':停用记录不得进入候选集(PRD C10/C11)"),
|
||||
() -> assertTrue(sqlSegment.contains("version_code"), "候选集应按 version_code 排序"),
|
||||
() -> assertTrue(sqlSegment.contains("desc"), "应取构建号最大的一条(倒序,而非创建时间最新)"),
|
||||
() -> assertEquals(1L, pageCaptor.getValue().getSize(), "候选集应通过方言分页只取一条"),
|
||||
() -> assertFalse(sqlSegment.contains("limit"), "业务 SQL 不应硬编码数据库方言")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package org.dromara.device;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils;
|
||||
import io.github.linpeilie.Converter;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.device.constant.AppVersionMsgConstants;
|
||||
import org.dromara.device.domain.BmAppVersion;
|
||||
import org.dromara.device.domain.bo.AppVersionBo;
|
||||
import org.dromara.device.mapper.BmAppVersionMapper;
|
||||
import org.dromara.device.service.impl.AppVersionServiceImpl;
|
||||
import org.dromara.system.api.OssService;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.support.StaticApplicationContext;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* APP 版本写侧回归测试,覆盖历史版本编辑与构建号高水位。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Tag("dev")
|
||||
@DisplayName("APP 版本写入规则")
|
||||
class AppVersionWriteServiceTest {
|
||||
|
||||
private BmAppVersionMapper appVersionMapper;
|
||||
private AppVersionServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initMybatisPlusMetadata() {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
GlobalConfigUtils.setGlobalConfig(configuration, GlobalConfigUtils.defaults());
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
TableInfoHelper.initTableInfo(assistant, BmAppVersion.class);
|
||||
|
||||
// 纯单测没有 Spring Boot 容器,为 MapstructUtils 注册最小转换器。
|
||||
Converter converter = mock(Converter.class, invocation -> {
|
||||
Object[] arguments = invocation.getArguments();
|
||||
if (arguments.length == 2 && arguments[0] instanceof AppVersionBo source
|
||||
&& arguments[1] == BmAppVersion.class) {
|
||||
BmAppVersion target = new BmAppVersion();
|
||||
BeanUtil.copyProperties(source, target);
|
||||
return target;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
StaticApplicationContext context = new StaticApplicationContext();
|
||||
context.getBeanFactory().registerSingleton("converter", converter);
|
||||
new SpringUtil().setApplicationContext(context);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
appVersionMapper = mock(BmAppVersionMapper.class);
|
||||
service = new AppVersionServiceImpl(appVersionMapper, mock(OssService.class));
|
||||
when(appVersionMapper.selectCount(any())).thenReturn(0L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("历史版本构建号未变化时允许编辑和启停")
|
||||
void unchangedHistoricalBuildCanBeUpdated() {
|
||||
BmAppVersion existing = version(10L, 100, "1.0.0", "1");
|
||||
when(appVersionMapper.selectById(10L)).thenReturn(existing);
|
||||
when(appVersionMapper.updateById(any(BmAppVersion.class))).thenReturn(1);
|
||||
|
||||
AppVersionBo bo = form(10L, 100, "1.0.0", "0");
|
||||
|
||||
assertTrue(service.updateByBo(bo));
|
||||
verify(appVersionMapper, never()).selectMaxVersionCodeIncludingDeleted(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("实际修改构建号时仍须大于包含删除记录的历史高水位")
|
||||
void changedBuildMustExceedHistoricalMaximum() {
|
||||
when(appVersionMapper.selectById(10L)).thenReturn(version(10L, 100, "1.0.0", "1"));
|
||||
when(appVersionMapper.selectMaxVersionCodeIncludingDeleted(eq("android"), eq(10L))).thenReturn(300);
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class,
|
||||
() -> service.updateByBo(form(10L, 200, "1.0.1", "1")));
|
||||
|
||||
assertEquals(String.format(AppVersionMsgConstants.VERSION_CODE_NOT_GREATER, 300), error.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("删除最高版本后也不能新增更低构建号")
|
||||
void deletedMaximumStillGuardsNewBuild() {
|
||||
when(appVersionMapper.selectMaxVersionCodeIncludingDeleted(eq("android"), isNull())).thenReturn(300);
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class,
|
||||
() -> service.insertByBo(form(null, 200, "2.0.0", "1")));
|
||||
|
||||
assertEquals(String.format(AppVersionMsgConstants.VERSION_CODE_NOT_GREATER, 300), error.getMessage());
|
||||
}
|
||||
|
||||
private static BmAppVersion version(Long id, int code, String name, String status) {
|
||||
BmAppVersion version = new BmAppVersion();
|
||||
version.setVersionId(id);
|
||||
version.setPlatform("android");
|
||||
version.setVersionCode(code);
|
||||
version.setVersionName(name);
|
||||
version.setForceUpdate("0");
|
||||
version.setOssId(1L);
|
||||
version.setFileUrl("https://oss.example.com/app.apk");
|
||||
version.setFileName("app.apk");
|
||||
version.setFileSize(1024L);
|
||||
version.setStatus(status);
|
||||
return version;
|
||||
}
|
||||
|
||||
private static AppVersionBo form(Long id, int code, String name, String status) {
|
||||
AppVersionBo bo = new AppVersionBo();
|
||||
bo.setVersionId(id);
|
||||
bo.setPlatform("android");
|
||||
bo.setVersionCode(code);
|
||||
bo.setVersionName(name);
|
||||
bo.setForceUpdate("0");
|
||||
bo.setOssId(1L);
|
||||
bo.setFileUrl("https://oss.example.com/app.apk");
|
||||
bo.setFileName("app.apk");
|
||||
bo.setFileSize(1024L);
|
||||
bo.setStatus(status);
|
||||
return bo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.dromara.device.config;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertAll;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* APP 配置默认值回归测试。
|
||||
*/
|
||||
@Tag("dev")
|
||||
@DisplayName("APP 配置默认值")
|
||||
class AppPropertiesTest {
|
||||
|
||||
private static final long THIRTY_DAYS_IN_SECONDS = 30L * 24 * 60 * 60;
|
||||
|
||||
@Test
|
||||
@DisplayName("登录令牌固定有效期和活跃超时均为 30 天")
|
||||
void tokenExpiresAfterThirtyDays() {
|
||||
AppProperties.Client client = new AppProperties().getClient();
|
||||
|
||||
assertAll(
|
||||
() -> assertEquals(THIRTY_DAYS_IN_SECONDS, client.getTimeout()),
|
||||
() -> assertEquals(THIRTY_DAYS_IN_SECONDS, client.getActiveTimeout())
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.dromara.device.mjpeg;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
/**
|
||||
* MJPEG 帧会话回归测试。
|
||||
*/
|
||||
@DisplayName("MJPEG 帧会话")
|
||||
class MjpegFrameHubTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("停播清理后在途帧不能重新创建会话")
|
||||
void inFlightFrameCannotReopenClearedSession() {
|
||||
MjpegFrameHub hub = new MjpegFrameHub();
|
||||
hub.open("BM001", "old-token");
|
||||
hub.clear("BM001");
|
||||
|
||||
hub.publish("BM001", "old-token", new byte[]{(byte) 0xFF, (byte) 0xD8});
|
||||
|
||||
assertFalse(hub.deviceNos().contains("BM001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("旧请求不能把帧写入重开的新会话")
|
||||
void staleTokenCannotPublishIntoReopenedSession() {
|
||||
MjpegFrameHub hub = new MjpegFrameHub();
|
||||
hub.open("BM001", "old-token");
|
||||
hub.clear("BM001");
|
||||
hub.open("BM001", "new-token");
|
||||
|
||||
assertFalse(hub.publish("BM001", "old-token", new byte[]{(byte) 0xFF, (byte) 0xD8}));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.dromara.device.mjpeg;
|
||||
|
||||
import org.dromara.device.config.MjpegProperties;
|
||||
import org.dromara.device.config.ZlmProperties;
|
||||
import org.dromara.device.mqtt.DeviceCommandPublisher;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* MJPEG 无人观看停推回归测试。
|
||||
*/
|
||||
@DisplayName("MJPEG 空闲停推")
|
||||
class MjpegIdleMonitorTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("最后一名观看者离开后重新计算空闲时间")
|
||||
void idleWindowStartsWhenLastViewerLeaves() throws Exception {
|
||||
MjpegProperties properties = new MjpegProperties();
|
||||
properties.setIdleStopSeconds(1);
|
||||
MjpegFrameHub hub = new MjpegFrameHub();
|
||||
MjpegIngestTokenService tokenService = mock(MjpegIngestTokenService.class);
|
||||
DeviceCommandPublisher publisher = mock(DeviceCommandPublisher.class);
|
||||
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
|
||||
try {
|
||||
MjpegIdleMonitor monitor = new MjpegIdleMonitor(
|
||||
properties, hub, tokenService, publisher, new ZlmProperties(),
|
||||
new MjpegLifecycleCoordinator(), executor);
|
||||
hub.open("BM001");
|
||||
hub.addViewer("BM001");
|
||||
Thread.sleep(1_100L);
|
||||
hub.removeViewer("BM001");
|
||||
|
||||
ReflectionTestUtils.invokeMethod(monitor, "scan");
|
||||
|
||||
verify(publisher, never()).publishStream(eq("BM001"), any());
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package org.dromara.device.mjpeg;
|
||||
|
||||
import jakarta.servlet.ServletOutputStream;
|
||||
import jakarta.servlet.WriteListener;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.dromara.device.config.MjpegProperties;
|
||||
import org.dromara.device.zlm.stream.ZlmPlayAuthService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* MJPEG 播放连接生命周期回归测试。
|
||||
*/
|
||||
@DisplayName("MJPEG 播放连接")
|
||||
class MjpegStreamControllerTest {
|
||||
|
||||
private static final String DEVICE_NO = "BM001";
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private MjpegFrameHub hub;
|
||||
private ZlmPlayAuthService playAuthService;
|
||||
private MjpegStreamController controller;
|
||||
private HttpServletRequest request;
|
||||
private HttpServletResponse response;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
hub = new MjpegFrameHub();
|
||||
MjpegProperties properties = new MjpegProperties();
|
||||
properties.setFps(20);
|
||||
playAuthService = mock(ZlmPlayAuthService.class);
|
||||
controller = new MjpegStreamController(
|
||||
hub, mock(MjpegIngestTokenService.class), properties, playAuthService);
|
||||
request = mock(HttpServletRequest.class);
|
||||
response = mock(HttpServletResponse.class);
|
||||
when(request.getRemoteAddr()).thenReturn("127.0.0.1");
|
||||
when(response.getOutputStream()).thenReturn(new MemoryServletOutputStream());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("平台清理会话后已有播放连接退出")
|
||||
void existingConnectionStopsWhenSessionIsCleared() throws Exception {
|
||||
when(playAuthService.validate(anyString(), anyString(), anyString())).thenReturn(true);
|
||||
hub.open(DEVICE_NO);
|
||||
Future<?> playback = startPlayback();
|
||||
awaitViewer();
|
||||
|
||||
hub.clear(DEVICE_NO);
|
||||
|
||||
assertDoesNotThrow(() -> playback.get(800, TimeUnit.MILLISECONDS));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("播放 token 失效后已有连接退出")
|
||||
void existingConnectionStopsWhenTokenExpires() throws Exception {
|
||||
when(playAuthService.validate(anyString(), anyString(), anyString()))
|
||||
.thenReturn(true, false);
|
||||
hub.open(DEVICE_NO);
|
||||
Future<?> playback = startPlayback();
|
||||
awaitViewer();
|
||||
|
||||
assertDoesNotThrow(() -> playback.get(1_800, TimeUnit.MILLISECONDS));
|
||||
verify(playAuthService, times(2)).validate("play-token", DEVICE_NO, "127.0.0.1");
|
||||
}
|
||||
|
||||
private Future<?> startPlayback() {
|
||||
return executor.submit(() -> {
|
||||
try {
|
||||
controller.play(DEVICE_NO, "play-token", request, response);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void awaitViewer() throws InterruptedException {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1);
|
||||
while (hub.viewerCount(DEVICE_NO) == 0 && System.nanoTime() < deadline) {
|
||||
Thread.sleep(5L);
|
||||
}
|
||||
assertTrue(hub.viewerCount(DEVICE_NO) > 0, "播放线程未进入观看状态");
|
||||
}
|
||||
|
||||
private static final class MemoryServletOutputStream extends ServletOutputStream {
|
||||
|
||||
private final ByteArrayOutputStream delegate = new ByteArrayOutputStream();
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setWriteListener(WriteListener writeListener) {
|
||||
// 同步测试输出流无需异步监听器。
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int value) throws IOException {
|
||||
delegate.write(value);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.dromara.device.mqtt.support;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.dromara.device.config.DeviceMqttProperties;
|
||||
import org.dromara.device.domain.BmAppMessage;
|
||||
import org.dromara.device.domain.BmDeviceAlert;
|
||||
import org.dromara.device.mapper.BmAppMessageMapper;
|
||||
import org.dromara.device.mapper.BmDeviceAlertMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 告警消息快照与 outbox 补偿回归测试。
|
||||
*
|
||||
* @author Kou
|
||||
* @date 2026-09-18
|
||||
*/
|
||||
@Tag("dev")
|
||||
@DisplayName("设备告警消息写入")
|
||||
class DeviceAlertWriterTest {
|
||||
|
||||
private BmDeviceAlertMapper alertMapper;
|
||||
private BmAppMessageMapper messageMapper;
|
||||
private DeviceAlertWriter writer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
alertMapper = mock(BmDeviceAlertMapper.class);
|
||||
messageMapper = mock(BmAppMessageMapper.class);
|
||||
writer = new DeviceAlertWriter(alertMapper, messageMapper, mock(DeviceMqttProperties.class));
|
||||
doAnswer(invocation -> {
|
||||
BmDeviceAlert alert = invocation.getArgument(0);
|
||||
alert.setAlertId(100L);
|
||||
return 1;
|
||||
}).when(alertMapper).insert(any(BmDeviceAlert.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("消息收件人使用告警发生时快照")
|
||||
void writesMessageForSnapshotUser() {
|
||||
when(messageMapper.insert(any(BmAppMessage.class))).thenReturn(1);
|
||||
|
||||
assertTrue(writer.writeOffline(1L, "BM001", 77L));
|
||||
|
||||
ArgumentCaptor<BmDeviceAlert> alertCaptor = ArgumentCaptor.forClass(BmDeviceAlert.class);
|
||||
ArgumentCaptor<BmAppMessage> messageCaptor = ArgumentCaptor.forClass(BmAppMessage.class);
|
||||
verify(alertMapper).insert(alertCaptor.capture());
|
||||
verify(messageMapper).insert(messageCaptor.capture());
|
||||
assertEquals(77L, alertCaptor.getValue().getMessageUserId());
|
||||
assertEquals(77L, messageCaptor.getValue().getUserId());
|
||||
assertEquals(100L, messageCaptor.getValue().getAlertId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同步消息写入失败后由 outbox 补偿成功")
|
||||
void retriesFailedMessageFromAlertOutbox() {
|
||||
doThrow(new RuntimeException("temporary database failure"))
|
||||
.doReturn(1)
|
||||
.when(messageMapper).insert(any(BmAppMessage.class));
|
||||
|
||||
assertTrue(writer.writeOffline(1L, "BM001", 77L));
|
||||
|
||||
ArgumentCaptor<BmDeviceAlert> alertCaptor = ArgumentCaptor.forClass(BmDeviceAlert.class);
|
||||
verify(alertMapper).insert(alertCaptor.capture());
|
||||
Page<BmDeviceAlert> pending = new Page<>(1, 100, false);
|
||||
pending.setRecords(List.of(alertCaptor.getValue()));
|
||||
when(alertMapper.selectPendingMessageAlerts(any(Page.class))).thenReturn(pending);
|
||||
|
||||
assertEquals(1, writer.retryPendingMessages(100));
|
||||
verify(messageMapper, times(2)).insert(any(BmAppMessage.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.dromara.device.sms;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.support.StaticApplicationContext;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* APP 手机号规范化回归测试。
|
||||
*/
|
||||
@DisplayName("APP 手机号规范化")
|
||||
class AppPhoneNumbersTest {
|
||||
|
||||
@BeforeAll
|
||||
static void setUpMessageSource() {
|
||||
// 非 Spring 单测为 MessageUtils 提供最小消息源,确保验证的是业务异常。
|
||||
StaticApplicationContext context = new StaticApplicationContext();
|
||||
new SpringUtil().setApplicationContext(context);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("北美 +1 号码保留国际身份并走国际通道")
|
||||
void preservesCountryIdentityForNanpNumber() {
|
||||
String canonical = AppPhoneNumbers.canonical("+14155552671");
|
||||
|
||||
assertEquals("+14155552671", canonical);
|
||||
assertFalse(AppPhoneNumbers.chinaMainland(canonical));
|
||||
assertEquals("14155552671", AppPhoneNumbers.forSms(canonical));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("+86 与 00 形式统一归一为大陆 11 位号码")
|
||||
void normalizesChinaCountryCode() {
|
||||
assertEquals("13800138000", AppPhoneNumbers.canonical("+8613800138000"));
|
||||
assertEquals("13800138000", AppPhoneNumbers.canonical("008613800138000"));
|
||||
assertTrue(AppPhoneNumbers.chinaMainland("13800138000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未带国际前缀的非大陆格式号码不能按国际号码接收")
|
||||
void rejectsUnprefixedInternationalNumber() {
|
||||
assertThrows(ServiceException.class, () -> AppPhoneNumbers.canonical("4155552671"));
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user