新视频推送接收(未测) app升级 告警 国际短信跟谷歌邮箱接入

This commit is contained in:
yuhaiming
2026-09-19 08:47:04 +08:00
parent e4ef35c795
commit bc1074d194
92 changed files with 5535 additions and 340 deletions

View File

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

View File

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

View File

@@ -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);
}
}
/**

View File

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

View File

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

View File

@@ -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 位,必须同时包含大写字母、小写字母和数字(不强制特殊字符,与原型一致)。

View File

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

View File

@@ -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";
/**
* 注册结果码:成功
*/

View File

@@ -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 直播地址未配置";
/**
* 客户端自定义拉流已停用
*/

View File

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

View File

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

View File

@@ -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);
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = "请输入正确的手机号")

View File

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

View File

@@ -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 = "请输入正确的手机号")

View File

@@ -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 = "请输入正确的手机号")

View File

@@ -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 = "请输入正确的手机号")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,4 +42,9 @@ public class AppStreamPlayUrlsVo implements Serializable {
*/
private String webrtc;
/**
* MJPEG(HTTP multipart,直传 JPEG)
*/
private String mjpeg;
}

View File

@@ -28,12 +28,12 @@ public class AppStreamPlayVo implements Serializable {
private String streamId;
/**
* ZLM app
* 流媒体应用名
*/
private String app;
/**
* 当前是否已在 ZLM 上在线
* 当前推流是否在线
*/
private Boolean online;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) + "?***";
}
}

View File

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

View File

@@ -62,7 +62,7 @@ public class StreamCommandPayload implements Serializable {
private String app;
/**
* 推流协议:rtmp / rtsp
* 推流协议:rtmp / rtsp / mjpeg
*/
private String protocol;

View File

@@ -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 "设备告警";
}
/**
* 判断是否为低电类告警(避免被误用到离线等类型上)。
*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'

View File

@@ -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 不应硬编码数据库方言")
);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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