新视频推送接收(未测) 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

@@ -155,7 +155,7 @@ spring.data:
# 数据库索引 # 数据库索引
database: 0 database: 0
# redis 密码必须配置 # redis 密码必须配置
password: 123456 #password: 123456
# 连接超时时间 # 连接超时时间
timeout: 10s timeout: 10s
# 是否开启ssl # 是否开启ssl
@@ -215,15 +215,11 @@ gmail:
port: 465 port: 465
auth: true auth: true
# 发信账号(Gmail 地址),由环境变量注入 # 发信账号(Gmail 地址),由环境变量注入
user: ${GMAIL_USER:huzuming2@gmail.com} user: ${GMAIL_USER:}
# user: ${GMAIL_USER:}
# user: "chinarzcui@gmail.com"
# 发信凭据:Gmail 16 位「应用专用密码」(非登录密码),由环境变量注入 # 发信凭据:Gmail 16 位「应用专用密码」(非登录密码),由环境变量注入
pass: ${GMAIL_PASS:lajl mccm ycqi uekj} pass: ${GMAIL_PASS:}
# pass: ${GMAIL_PASS:}
# 发件人,留空回落到 user;Gmail 要求发件人与认证账号一致 # 发件人,留空回落到 user;Gmail 要求发件人与认证账号一致
from: ${GMAIL_FROM:huzuming2@gmail.com} from: ${GMAIL_FROM:}
# from: ${GMAIL_FROM:}
sslEnable: true sslEnable: true
starttlsEnable: false starttlsEnable: false
# smtp.gmail.com 在国内机房通常不可达,快速失败避免挂死请求线程 # smtp.gmail.com 在国内机房通常不可达,快速失败避免挂死请求线程
@@ -239,21 +235,6 @@ device:
# 是否仅打印日志而不真实发信:开发环境默认 true,便于在无 SMTP 条件下联调。 # 是否仅打印日志而不真实发信:开发环境默认 true,便于在无 SMTP 条件下联调。
# 【生产环境(prod)必须为 false】——否则应用拒绝启动,防止验证码明文写入日志。 # 【生产环境(prod)必须为 false】——否则应用拒绝启动,防止验证码明文写入日志。
mock: true mock: true
# 谷歌邮箱 Gmail SMTP
host: ${DEVICE_APP_EMAIL_HOST:smtp.gmail.com}
# 465 走 SSL;若改用 587 端口,请同时设 ssl-enable=false 且 starttls-enable=true
port: ${DEVICE_APP_EMAIL_PORT:465}
# 发信账号(Gmail 地址)。凭据一律由环境变量注入,禁止写入本文件
user: ${DEVICE_APP_EMAIL_USER:}
# 发信凭据:Gmail 的 16 位「应用专用密码」(非账号登录密码),由环境变量注入
pass: ${DEVICE_APP_EMAIL_PASS:}
# 发件人,留空则回落到 user。Gmail 要求发件人与认证账号一致,否则拒收
from: ${DEVICE_APP_EMAIL_FROM:}
ssl-enable: true
starttls-enable: false
# smtp.gmail.com 在国内机房通常不可达,必须快速失败,避免挂死 HTTP 请求线程
connection-timeout: 5000
timeout: 10000
# 验证码位数 / 有效期(分钟)/ 重发间隔(秒) # 验证码位数 / 有效期(分钟)/ 重发间隔(秒)
code-length: 6 code-length: 6
expiry-minutes: 5 expiry-minutes: 5
@@ -261,6 +242,15 @@ device:
# 允许注册的邮箱域名白名单,留空(或不配)表示不限制域名。 # 允许注册的邮箱域名白名单,留空(或不配)表示不限制域名。
# 若产品要求「只允许谷歌邮箱注册」,取消下行注释即可,无需改代码: # 若产品要求「只允许谷歌邮箱注册」,取消下行注释即可,无需改代码:
# allowed-domains: gmail.com,googlemail.com # allowed-domains: gmail.com,googlemail.com
sms:
# 开发默认 mock;真发国际/国内短信时置 false,并配好 sms.blends 与模板 id
mock: true
blend: config1
template-id: ${DEVICE_APP_SMS_TEMPLATE_ID:}
mjpeg:
# 开发环境默认使用局域网地址;生产环境由 MJPEG_PUBLIC_HOST 显式注入。
public-host: ${MJPEG_PUBLIC_HOST:http://192.168.1.217:8081}
--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商 --- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商
# https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用 # https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用

View File

@@ -218,12 +218,11 @@ gmail:
port: 465 port: 465
auth: true auth: true
# 发信账号(Gmail 地址),由环境变量注入 # 发信账号(Gmail 地址),由环境变量注入
user: ${GMAIL_USER:huzuming2@gmail.com} user: ${GMAIL_USER:}
# user: "chinarzcui@gmail.com"
# 发信凭据:Gmail 16 位「应用专用密码」(非登录密码),由环境变量注入 # 发信凭据:Gmail 16 位「应用专用密码」(非登录密码),由环境变量注入
pass: ${GMAIL_PASS:qbjo lajl mccm ycqi uekj} pass: ${GMAIL_PASS:}
# 发件人,留空回落到 user;Gmail 要求发件人与认证账号一致 # 发件人,留空回落到 user;Gmail 要求发件人与认证账号一致
from: ${GMAIL_FROM:huzuming2@gmail.com} from: ${GMAIL_FROM:}
sslEnable: true sslEnable: true
starttlsEnable: false starttlsEnable: false
# smtp.gmail.com 在国内机房通常不可达,快速失败避免挂死请求线程 # smtp.gmail.com 在国内机房通常不可达,快速失败避免挂死请求线程
@@ -239,22 +238,6 @@ device:
# 【必须为 false】生产环境开启 mock 会把邮箱验证码明文写入日志, # 【必须为 false】生产环境开启 mock 会把邮箱验证码明文写入日志,
# 此时 org.dromara.device.config.AppEmailProdGuard 将直接拒绝启动(属预期保护)。 # 此时 org.dromara.device.config.AppEmailProdGuard 将直接拒绝启动(属预期保护)。
mock: false mock: false
# 谷歌邮箱 Gmail SMTP
host: ${DEVICE_APP_EMAIL_HOST:smtp.gmail.com}
# 465 走 SSL;若改用 587 端口,请同时设 ssl-enable=false 且 starttls-enable=true
port: ${DEVICE_APP_EMAIL_PORT:465}
# 发信账号(Gmail 地址)。凭据一律由环境变量注入,禁止写入本文件
user: ${DEVICE_APP_EMAIL_USER:}
# 发信凭据:Gmail 的 16 位「应用专用密码」(非账号登录密码),由环境变量注入
pass: ${DEVICE_APP_EMAIL_PASS:}
# 发件人,留空则回落到 user。Gmail 要求发件人与认证账号一致,否则拒收
from: ${DEVICE_APP_EMAIL_FROM:}
ssl-enable: true
starttls-enable: false
# smtp.gmail.com 在国内机房通常不可达:若部署在国内,请评估改用国内 SMTP,
# 或确认服务器具备出海能力后再上线邮箱注册功能。
connection-timeout: 5000
timeout: 10000
# 验证码位数 / 有效期(分钟)/ 重发间隔(秒) # 验证码位数 / 有效期(分钟)/ 重发间隔(秒)
code-length: 6 code-length: 6
expiry-minutes: 5 expiry-minutes: 5
@@ -262,6 +245,23 @@ device:
# 允许注册的邮箱域名白名单,留空(或不配)表示不限制域名。 # 允许注册的邮箱域名白名单,留空(或不配)表示不限制域名。
# 若产品要求「只允许谷歌邮箱注册」,取消下行注释即可,无需改代码: # 若产品要求「只允许谷歌邮箱注册」,取消下行注释即可,无需改代码:
# allowed-domains: gmail.com,googlemail.com # allowed-domains: gmail.com,googlemail.com
sms:
mock: false
blend: config1
template-id: ${DEVICE_APP_SMS_TEMPLATE_ID:}
mjpeg:
# 生产环境不提供私网默认值,必须注入可达地址。
public-host: ${MJPEG_PUBLIC_HOST:}
--- # 阿里云国际短信(与 application-dev.yml 同通道)
aliyun:
international-sms:
enabled: true
access-key-id: ${ALIYUN_INTL_SMS_AK:}
access-key-secret: ${ALIYUN_INTL_SMS_SK:}
endpoint: dysmsapi.ap-southeast-1.aliyuncs.com
sender-id: ''
--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商 --- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商
# https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用 # https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用

View File

@@ -25,6 +25,14 @@ zlm:
push-protocol: rtmp push-protocol: rtmp
play-expire-seconds: 120 play-expire-seconds: 120
stream-timeout: 30 # 流超时秒数 stream-timeout: 30 # 流超时秒数
mjpeg:
enabled: true
# 生产环境必须显式注入可被设备与客户端访问的地址,未配置时接口会拒绝生成播放地址。
public-host: ${MJPEG_PUBLIC_HOST:}
fps: 5
frame-ttl-seconds: 8
idle-stop-seconds: 30
max-frame-bytes: 1048576
captcha: captcha:
# 是否启用验证码校验 # 是否启用验证码校验
enable: true enable: true
@@ -77,9 +85,12 @@ spring:
servlet: servlet:
multipart: multipart:
# 单个文件大小 # 单个文件大小
max-file-size: 10MB # APP 客户端安装包(apk)体积通常数十 MB,10MB 会被 Spring 直接拒绝,故放大到 200MB。
# 设置总上传的文件大小 # 注意:若 application-dev.yml / -prod.yml / -local.yml 另配了同名项会**就近覆盖**本值,
max-request-size: 20MB # 修改本项后须同步确认这三份 profile 配置无覆盖(当前均无覆盖,仅 prod 配了 multipart.location)。
max-file-size: 200MB
# 设置总上传的文件大小(= 单文件上限 + form 其它字段冗余)
max-request-size: 210MB
mvc: mvc:
# 设置静态资源路径 防止所有请求都去查静态资源 # 设置静态资源路径 防止所有请求都去查静态资源
static-path-pattern: /static/** static-path-pattern: /static/**
@@ -122,6 +133,7 @@ security:
- /snail-chat/** - /snail-chat/**
- /api/snail/chat/** - /api/snail/chat/**
- /zlm/hook/** - /zlm/hook/**
- /mjpeg/**
# MyBatisPlus配置 # MyBatisPlus配置
# https://baomidou.com/config/ # https://baomidou.com/config/
@@ -328,6 +340,9 @@ device.mqtt:
low-battery-latch-ttl: 30d low-battery-latch-ttl: 30d
# 告警去重窗口:同一 设备+告警类型 在窗口内只落一条 bm_device_alert # 告警去重窗口:同一 设备+告警类型 在窗口内只落一条 bm_device_alert
alert-dedup-window: 30m alert-dedup-window: 30m
# APP 告警消息写入失败后的 outbox 补偿周期与单轮上限
app-message-retry-interval: 1m
app-message-retry-batch-size: 100
# 眼罩指令回执等待超时(协议 Q3 待硬件确认动作到位时长) # 眼罩指令回执等待超时(协议 Q3 待硬件确认动作到位时长)
hood-timeout: 5s hood-timeout: 5s
# 待回执指令标记的 Redis TTL(仅用于事后定位「回执晚到」) # 待回执指令标记的 Redis TTL(仅用于事后定位「回执晚到」)

View File

@@ -48,6 +48,15 @@ rate.limiter.message=访问过于频繁,请稍候再试
sms.code.not.blank=短信验证码不能为空 sms.code.not.blank=短信验证码不能为空
sms.code.retry.limit.count=短信验证码输入错误{0}次 sms.code.retry.limit.count=短信验证码输入错误{0}次
sms.code.retry.limit.exceed=短信验证码输入错误{0}次,账户锁定{1}分钟 sms.code.retry.limit.exceed=短信验证码输入错误{0}次,账户锁定{1}分钟
sms.code.format.invalid=验证码格式不正确
app.sms.scene.not.blank=验证码场景不能为空
app.sms.scene.invalid=验证码场景不合法
app.sms.send.too.frequent=验证码发送过于频繁,请稍后再试
app.sms.send.failed=验证码短信发送失败
app.sms.code.sent=验证码已发送
app.sms.verify.too.many=验证码错误次数过多,请稍后再试
app.sms.code.error=验证码错误
app.sms.code.expired=验证码已过期
email.code.not.blank=邮箱验证码不能为空 email.code.not.blank=邮箱验证码不能为空
email.code.retry.limit.count=邮箱验证码输入错误{0}次 email.code.retry.limit.count=邮箱验证码输入错误{0}次
email.code.retry.limit.exceed=邮箱验证码输入错误{0}次,账户锁定{1}分钟 email.code.retry.limit.exceed=邮箱验证码输入错误{0}次,账户锁定{1}分钟

View File

@@ -48,6 +48,15 @@ rate.limiter.message=Visit too frequently, please try again later
sms.code.not.blank=Sms code cannot be blank sms.code.not.blank=Sms code cannot be blank
sms.code.retry.limit.count=Sms code input error {0} times sms.code.retry.limit.count=Sms code input error {0} times
sms.code.retry.limit.exceed=Sms code input error {0} times, account locked for {1} minutes sms.code.retry.limit.exceed=Sms code input error {0} times, account locked for {1} minutes
sms.code.format.invalid=Invalid sms code format
app.sms.scene.not.blank=Sms scene cannot be blank
app.sms.scene.invalid=Invalid sms scene
app.sms.send.too.frequent=Verification code sent too frequently, please try again later
app.sms.send.failed=Failed to send sms verification code
app.sms.code.sent=Verification code sent
app.sms.verify.too.many=Too many incorrect attempts, please try again later
app.sms.code.error=Incorrect verification code
app.sms.code.expired=Verification code expired
email.code.not.blank=Email code cannot be blank email.code.not.blank=Email code cannot be blank
email.code.retry.limit.count=Email code input error {0} times email.code.retry.limit.count=Email code input error {0} times
email.code.retry.limit.exceed=Email code input error {0} times, account locked for {1} minutes email.code.retry.limit.exceed=Email code input error {0} times, account locked for {1} minutes

View File

@@ -48,6 +48,15 @@ rate.limiter.message=访问过于频繁,请稍候再试
sms.code.not.blank=短信验证码不能为空 sms.code.not.blank=短信验证码不能为空
sms.code.retry.limit.count=短信验证码输入错误{0}次 sms.code.retry.limit.count=短信验证码输入错误{0}次
sms.code.retry.limit.exceed=短信验证码输入错误{0}次,账户锁定{1}分钟 sms.code.retry.limit.exceed=短信验证码输入错误{0}次,账户锁定{1}分钟
sms.code.format.invalid=验证码格式不正确
app.sms.scene.not.blank=验证码场景不能为空
app.sms.scene.invalid=验证码场景不合法
app.sms.send.too.frequent=验证码发送过于频繁,请稍后再试
app.sms.send.failed=验证码短信发送失败
app.sms.code.sent=验证码已发送
app.sms.verify.too.many=验证码错误次数过多,请稍后再试
app.sms.code.error=验证码错误
app.sms.code.expired=验证码已过期
email.code.not.blank=邮箱验证码不能为空 email.code.not.blank=邮箱验证码不能为空
email.code.retry.limit.count=邮箱验证码输入错误{0}次 email.code.retry.limit.count=邮箱验证码输入错误{0}次
email.code.retry.limit.exceed=邮箱验证码输入错误{0}次,账户锁定{1}分钟 email.code.retry.limit.exceed=邮箱验证码输入错误{0}次,账户锁定{1}分钟

View File

@@ -2,6 +2,7 @@ package org.dromara.system.api;
import org.dromara.system.api.domain.OssDTO; import org.dromara.system.api.domain.OssDTO;
import java.time.Duration;
import java.util.List; import java.util.List;
/** /**
@@ -26,4 +27,16 @@ public interface OssService {
* @return 列表 * @return 列表
*/ */
List<OssDTO> selectByIds(String ossIds); List<OssDTO> selectByIds(String ossIds);
/**
* 根据 OSS 主键生成可直接下载的地址。
* <p>
* 公有桶返回稳定地址;私有桶按调用方给定的有效期重新签名,避免把上传接口返回的
* 短期签名地址持久化后继续下发。
*
* @param ossId OSS 主键
* @param validTime 私有桶签名有效期
* @return 下载地址;记录不存在时返回 {@code null}
*/
String selectDownloadUrlById(Long ossId, Duration validTime);
} }

View File

@@ -8,6 +8,7 @@ import com.aliyun.teautil.models.RuntimeOptions;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.DesensitizedUtils;
import org.dromara.common.sms.config.AliyunInternationalSmsProperties; import org.dromara.common.sms.config.AliyunInternationalSmsProperties;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -33,7 +34,9 @@ public class AliyunInternationalSmsService {
* @return 发送结果 * @return 发送结果
*/ */
public SendMessageToGlobeResponseBody sendMessageToGlobe(String to, String message) { public SendMessageToGlobeResponseBody sendMessageToGlobe(String to, String message) {
log.info("国际短信发送开始, 手机号={}, message={}", to, message); String maskedTo = DesensitizedUtils.maskHighSecurity(to, 3, 2);
// 国际短信正文包含验证码,日志只保留脱敏后的目标号码,不记录验证码。
log.info("国际短信发送开始, 手机号={}", maskedTo);
SendMessageToGlobeRequest request = new SendMessageToGlobeRequest() SendMessageToGlobeRequest request = new SendMessageToGlobeRequest()
.setTo(to) .setTo(to)
.setMessage("您的验证码为:" + message + ",请勿泄露于他人!"); .setMessage("您的验证码为:" + message + ",请勿泄露于他人!");
@@ -45,15 +48,19 @@ public class AliyunInternationalSmsService {
.sendMessageToGlobeWithOptions(request, new RuntimeOptions()); .sendMessageToGlobeWithOptions(request, new RuntimeOptions());
SendMessageToGlobeResponseBody body = response.getBody(); SendMessageToGlobeResponseBody body = response.getBody();
if (body == null || !"OK".equalsIgnoreCase(body.getResponseCode())) { if (body == null || !"OK".equalsIgnoreCase(body.getResponseCode())) {
log.error("国际短信发送失败, to={}, body={}", to, body); log.error("国际短信发送失败, to={}, responseCode={}", maskedTo,
body == null ? null : body.getResponseCode());
throw new ServiceException("国际短信发送失败:" + (body == null ? "无响应" : body.getResponseCode())); throw new ServiceException("国际短信发送失败:" + (body == null ? "无响应" : body.getResponseCode()));
} }
return body; return body;
} catch (ServiceException e) { } catch (ServiceException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
log.error("国际短信发送异常, to={}", to, e); // 供应商异常可能回显请求参数,日志不输出异常原文或堆栈。
throw new ServiceException("国际短信发送异常:" + e.getMessage()); log.error("国际短信发送异常, to={}, error={}",
maskedTo, e.getClass().getSimpleName());
// 底层异常可能回显请求参数,不向客户端透传供应商原文。
throw new ServiceException("国际短信发送异常");
} }
} }

View File

@@ -113,6 +113,13 @@
<artifactId>ruoyi-common-mail</artifactId> <artifactId>ruoyi-common-mail</artifactId>
</dependency> </dependency>
<!-- 设备域回归测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -2,6 +2,7 @@ package org.dromara.device.config;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.common.mail.config.properties.GmailProperties;
import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.InitializingBean;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -10,7 +11,7 @@ import org.springframework.stereotype.Component;
* APP 邮箱验证码生产环境守卫(口径镜像 {@link AppSmsProdGuard})。 * APP 邮箱验证码生产环境守卫(口径镜像 {@link AppSmsProdGuard})。
* <p> * <p>
* <b>背景</b>:{@code device.app.email.mock} 默认 {@code true},会把邮箱验证码<b>明文写入应用日志</b>。 * <b>背景</b>:{@code device.app.email.mock} 默认 {@code true},会把邮箱验证码<b>明文写入应用日志</b>。
* 开发/联调期这是便利(SMTP 凭据与出海网络尚未就绪),但生产环境误开 mock 等价于把验证码落盘。 * 开发/联调期这是便利(Gmail 凭据与出海网络尚未就绪),但生产环境误开 mock 等价于把验证码落盘。
* <p> * <p>
* <b>策略</b>: * <b>策略</b>:
* <ul> * <ul>
@@ -34,10 +35,11 @@ public class AppEmailProdGuard implements InitializingBean {
private static final String PROD_PROFILE = "prod"; private static final String PROD_PROFILE = "prod";
private final AppProperties appProperties; private final AppProperties appProperties;
private final GmailProperties gmailProperties;
private final Environment environment; private final Environment environment;
/** /**
* 校验「生产环境不得开启邮箱 mock」且「真实发信凭据已配置」,否则拒绝启动。 * 校验「生产环境不得开启邮箱 mock」并检查 Gmail 真实发信配置。
*/ */
@Override @Override
public void afterPropertiesSet() { public void afterPropertiesSet() {
@@ -53,20 +55,20 @@ public class AppEmailProdGuard implements InitializingBean {
+ "生产环境(prod)将因此拒绝启动。"); + "生产环境(prod)将因此拒绝启动。");
return; return;
} }
// 已关闭 mock(走真实 SMTP):此处**只告警不阻断启动**。 // 已关闭 mock(走真实 Gmail):此处**只告警不阻断启动**。
// 取舍说明:mock 开关误开会导致验证码明文落盘(安全风险)→ 必须拒绝启动; // 取舍说明:mock 开关误开会导致验证码明文落盘(安全风险)→ 必须拒绝启动;
// 而凭据缺失只影响「邮件发不出去」这一个功能(可用性风险),若也拒绝启动, // 而凭据缺失只影响「邮件发不出去」这一个功能(可用性风险),若也拒绝启动,
// 会让「邮箱功能未配置」升级为「整个服务起不来」,把一个局部问题放大成全站故障。 // 会让「邮箱功能未配置」升级为「整个服务起不来」,把一个局部问题放大成全站故障。
// 故降级为醒目告警,把失败推迟到发信那一刻,并由 AppEmailService 返回受控文案。 // 故降级为醒目告警,把失败推迟到发信那一刻,并由 AppEmailService 返回受控文案。
if (!email.isConfigured()) { if (!Boolean.TRUE.equals(gmailProperties.getEnabled()) || !gmailProperties.isConfigured()) {
log.warn("[APP邮箱] 已关闭 mock(要求真实发信)但发信账号或凭据未配置——" log.warn("[APP邮箱] 已关闭 mock(要求真实发信)但 Gmail 功能未启用或账号/凭据未配置——"
+ "邮箱验证码将无法送达!请通过环境变量 DEVICE_APP_EMAIL_USER / DEVICE_APP_EMAIL_PASS " + "邮箱验证码将无法送达!请通过环境变量 GMAIL_USER / GMAIL_PASS 注入 Gmail 地址与 16 位应用专用密码。");
+ "注入 Gmail 地址与 16 位应用专用密码。");
return; return;
} }
log.info("[APP邮箱] 真实发信已启用:host={}:{},from={},ssl={},starttls={},连接超时={}ms。", log.info("[APP邮箱] Gmail 真实发信已启用:host={}:{},from={},ssl={},starttls={},连接超时={}ms。",
email.getHost(), email.getPort(), email.getFrom(), email.isSslEnable(), gmailProperties.getHost(), gmailProperties.getPort(), gmailProperties.getFrom(),
email.isStarttlsEnable(), email.getConnectionTimeout()); gmailProperties.getSslEnable(), gmailProperties.getStarttlsEnable(),
gmailProperties.getConnectionTimeout());
} }
} }

View File

@@ -3,7 +3,6 @@ package org.dromara.device.config;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.dromara.common.core.utils.StringUtils;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -15,10 +14,10 @@ import java.util.List;
* <p> * <p>
* 全部字段均带默认值,未在 {@code application.yml} 显式配置时按默认值生效,因此本迭代无需改动任何 yml。 * 全部字段均带默认值,未在 {@code application.yml} 显式配置时按默认值生效,因此本迭代无需改动任何 yml。
* <p> * <p>
* <b>客户端身份默认值口径</b>:与框架 {@code sys_client} 中 {@code client_key = 'app'} 的记录保持镜像 * <b>客户端身份默认值口径</b>:身份与访问范围参考框架 {@code sys_client} 中
* ({@code device_type = 'android'}、{@code access_path = '/app/**'}、{@code timeout = 604800}、 * {@code client_key = 'app'} 的记录({@code device_type = 'android'}、{@code access_path = '/app/**'})。
* {@code activeTimeout = 1800})。原因:设备域({@code ruoyi-device})不依赖 {@code ruoyi-system}, * 设备域({@code ruoyi-device})不依赖 {@code ruoyi-system},无法读取 {@code sys_client},因此令牌固定有效期和
* 无法读取 {@code sys_client};若运维调整了 {@code sys_client} 的 app 记录,请同步此处配置。 * 活跃超时由本配置独立控制,默认均为 2592000 秒(30 天)。
* <p> * <p>
* <b>已知偏差</b>:{@link Client#clientId} 的取值口径与 PC 端 {@code sys_client.client_id} 不同, * <b>已知偏差</b>:{@link Client#clientId} 的取值口径与 PC 端 {@code sys_client.client_id} 不同,
* 详见 {@link Client#clientId} 字段注释与《05-交付说明-迭代I2》§七。 * 详见 {@link Client#clientId} 字段注释与《05-交付说明-迭代I2》§七。
@@ -42,7 +41,7 @@ public class AppProperties {
private Sms sms = new Sms(); private Sms sms = new Sms();
/** /**
* APP 邮箱验证码策略(SMTP 发信)。 * APP 邮箱验证码策略;真实发信统一由 common-mail 的 {@code gmail.*} 配置负责。
*/ */
private Email email = new Email(); private Email email = new Email();
@@ -87,14 +86,14 @@ public class AppProperties {
private String deviceType = "android"; 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"; private String blend = "config1";
/** /**
* sms4j 模板 id(mock=false 时使用)。 * sms4j 模板 id(mock=false 时使用,国内短信)。
*/ */
private String templateId = ""; private String templateId = "";
@@ -141,11 +140,10 @@ public class AppProperties {
} }
/** /**
* 邮箱验证码策略配置(SMTP 发信)。 * 邮箱验证码策略配置。
* <p> * <p>
* <b>凭据口径</b>:{@link #user} 与 {@link #pass} 在代码与 {@code application.yml} 中<b>均不预置真实值</b>, * <b>发信口径</b>:真实邮件统一调用 {@code GmailCodeService},账号、凭据、SMTP 主机和超时从
* 一律通过环境变量注入({@code DEVICE_APP_EMAIL_USER} / {@code DEVICE_APP_EMAIL_PASS}), * {@code gmail.*} 读取;本类中的旧 SMTP 字段仅为兼容历史配置保留,APP 业务不再读取。
* 避免重蹈「明文 broker 凭据硬编码」的覆辙。
* <p> * <p>
* <b>谷歌邮箱(Gmail)口径</b>:主机固定 {@code smtp.gmail.com}、端口 465(SSL)。 * <b>谷歌邮箱(Gmail)口径</b>:主机固定 {@code smtp.gmail.com}、端口 465(SSL)。
* Gmail 已停用「低安全性应用访问」,{@link #pass} 必须填 Google 账号生成的 * Gmail 已停用「低安全性应用访问」,{@link #pass} 必须填 Google 账号生成的
@@ -168,54 +166,34 @@ public class AppProperties {
*/ */
private boolean mock = true; private boolean mock = true;
/** /** 旧 SMTP 主机配置,仅为兼容历史配置保留,业务不再读取。 */
* Gmail SMTP 主机。
*/
private String host = "smtp.gmail.com"; private String host = "smtp.gmail.com";
/** /** 旧 SMTP 端口配置,仅为兼容历史配置保留,业务不再读取。 */
* Gmail SMTP 端口:465(SSL)或 587(STARTTLS)。
*/
private int port = 465; private int port = 465;
/** /** 旧发信账号配置,仅为兼容历史配置保留,业务不再读取。 */
* 发信账号(Gmail 地址),由环境变量注入。
*/
private String user = ""; private String user = "";
/** /** 旧发信凭据配置,仅为兼容历史配置保留,业务不再读取。 */
* 发信凭据:Gmail 的 16 位<b>应用专用密码</b>,由环境变量注入。
*/
private String pass = ""; private String pass = "";
/** /** 旧发件人配置,仅为兼容历史配置保留,业务不再读取。 */
* 发件人(须与 {@link #user} 一致,可写为 {@code 名称 <user@gmail.com>})。
*/
private String from = ""; private String from = "";
/** /** 旧 SSL 配置,仅为兼容历史配置保留,业务不再读取。 */
* 使用 SSL 安全连接(465 端口用 true)。
*/
private boolean sslEnable = true; private boolean sslEnable = true;
/** /** 旧 STARTTLS 配置,仅为兼容历史配置保留,业务不再读取。 */
* 使用 STARTTLS 升级连接(587 端口用 true,与 {@link #sslEnable} 互斥使用)。
*/
private boolean starttlsEnable = false; private boolean starttlsEnable = false;
/** /** 旧连接超时配置,仅为兼容历史配置保留,业务不再读取。 */
* SMTP 连接超时(毫秒):避免 Gmail 不可达时长时间占用请求线程。
*/
private int connectionTimeout = 5000; private int connectionTimeout = 5000;
/** /** 旧读写超时配置,仅为兼容历史配置保留,业务不再读取。 */
* SMTP 读写超时(毫秒)。
*/
private int timeout = 10000; private int timeout = 10000;
/** /** 旧邮件主题配置,仅为兼容历史配置保留,业务不再读取。 */
* 邮件主题。
*/
private String subject = "智能飞禽物联平台 验证码"; private String subject = "智能飞禽物联平台 验证码";
/** /**
@@ -240,15 +218,6 @@ public class AppProperties {
*/ */
private List<String> allowedDomains = new ArrayList<>(); 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); private Duration alertDedupWindow = Duration.ofMinutes(30);
/**
* APP 告警消息 outbox 补偿间隔。
* <p>
* 消息同步写入失败后,告警事实仍会保留;补偿任务按该间隔扫描并重试。
*/
private Duration appMessageRetryInterval = Duration.ofMinutes(1);
/**
* APP 告警消息单轮补偿上限,避免数据库恢复后瞬间形成写入洪峰。
*/
private int appMessageRetryBatchSize = 100;
/** /**
* 眼罩指令回执等待超时。 * 眼罩指令回执等待超时。
* <p> * <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 位,必须同时包含大写字母、小写字母和数字(不强制特殊字符,与原型一致)。 * 密码: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"; 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}}。 * 告警去重窗口占位,完整 Key:{@code bm:mqtt:alert:dedup:{deviceNo}:{alertType}}。
* <p> * <p>
@@ -48,6 +53,11 @@ public final class DeviceMqttConstants {
*/ */
public static final String ZLM_PLAY_TOKEN_PREFIX = "bm:zlm:play:token:"; 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}。 * 待回执指令标记,完整 Key:{@code bm:mqtt:ack:pending:{msgId}},值为目标 {@code deviceNo}。
* <p> * <p>
@@ -60,10 +70,27 @@ public final class DeviceMqttConstants {
// ---------------- 协议固定码 ---------------- // ---------------- 协议固定码 ----------------
/** /**
* 告警处理状态:未处理(一期只读展示,不做处理流程) * 告警处理状态:未处理
*/ */
public static final String ALERT_STATUS_UNHANDLED = "0"; 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 地址未配置"; 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 cn.dev33.satoken.annotation.SaIgnore;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.web.core.BaseController; import org.dromara.common.web.core.BaseController;
import org.dromara.device.constant.AppMsgConstants; import org.dromara.device.constant.AppMsgConstants;
import org.dromara.device.domain.bo.AppEmailCodeBo; import org.dromara.device.domain.bo.AppEmailCodeBo;
@@ -48,7 +49,7 @@ public class AppAuthController extends BaseController {
public R<Void> sendSmsCode(@Validated @RequestBody AppSmsCodeBo bo) { public R<Void> sendSmsCode(@Validated @RequestBody AppSmsCodeBo bo) {
String code = appAuthService.sendSmsCode(bo); String code = appAuthService.sendSmsCode(bo);
// 开发环境 mock 模式暂将验证码放入 msg,接入真实短信后仍返回标准成功文案 // 开发环境 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; package org.dromara.device.domain;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
@@ -60,13 +61,39 @@ public class BmDeviceAlert implements Serializable {
private String content; private String content;
/** /**
* 处理状态(0 未处理 1 已处理,一期只读展示) * 处理状态(0 未处理 1 已处理)
*/ */
private String status; 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; 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; private static final long serialVersionUID = 1L;
/** /**
* 手机号(大陆 11 位)。 * 手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
*/ */
@NotBlank(message = "手机号不能为空") @NotBlank(message = "手机号不能为空")
@Pattern(regexp = AppConstants.PHONE_REGEX, 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; private static final long serialVersionUID = 1L;
/** /**
* 新手机号(大陆 11 位)。 * 新手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
*/ */
@NotBlank(message = "新手机号不能为空") @NotBlank(message = "新手机号不能为空")
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号") @Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")

View File

@@ -26,7 +26,7 @@ public class AppRegisterBo implements Serializable {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** /**
* 手机号(大陆 11 位)。 * 手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
*/ */
@NotBlank(message = "手机号不能为空") @NotBlank(message = "手机号不能为空")
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号") @Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")

View File

@@ -22,7 +22,7 @@ public class AppResetPwdBo implements Serializable {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** /**
* 手机号(大陆 11 位)。 * 手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
*/ */
@NotBlank(message = "手机号不能为空") @NotBlank(message = "手机号不能为空")
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号") @Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")

View File

@@ -21,16 +21,16 @@ public class AppSmsCodeBo implements Serializable {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** /**
* 手机号(大陆 11 位)。 * 手机号:大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码。
*/ */
@NotBlank(message = "手机号不能为空") @NotBlank(message = "{user.phonenumber.not.blank}")
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号") @Pattern(regexp = AppConstants.PHONE_REGEX, message = "{user.mobile.phone.number.not.valid}")
private String phoneNumber; private String phoneNumber;
/** /**
* 场景:login / register / resetPwd / rebind。 * 场景:login / register / resetPwd / rebind。
*/ */
@NotBlank(message = "验证码场景不能为空") @NotBlank(message = "{app.sms.scene.not.blank}")
private String scene; private String scene;
} }

View File

@@ -21,17 +21,17 @@ public class AppSmsLoginBo implements Serializable {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** /**
* 手机号(大陆 11 位)。 * 手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
*/ */
@NotBlank(message = "手机号不能为空") @NotBlank(message = "{user.phonenumber.not.blank}")
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号") @Pattern(regexp = AppConstants.PHONE_REGEX, message = "{user.mobile.phone.number.not.valid}")
private String phoneNumber; private String phoneNumber;
/** /**
* 短信验证码(6 位数字)。 * 短信验证码(6 位数字)。
*/ */
@NotBlank(message = "验证码不能为空") @NotBlank(message = "{sms.code.not.blank}")
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确") @Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "{sms.code.format.invalid}")
private String code; 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; private String webrtc;
/**
* MJPEG(HTTP multipart,直传 JPEG)
*/
private String mjpeg;
} }

View File

@@ -28,12 +28,12 @@ public class AppStreamPlayVo implements Serializable {
private String streamId; private String streamId;
/** /**
* ZLM app * 流媒体应用名
*/ */
private String app; private String app;
/** /**
* 当前是否已在 ZLM 上在线 * 当前推流是否在线
*/ */
private Boolean online; 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; package org.dromara.device.email;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
import cn.hutool.extra.mail.MailAccount;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils; 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.common.redis.utils.RedisUtils;
import org.dromara.device.config.AppProperties; import org.dromara.device.config.AppProperties;
import org.dromara.device.constant.AppConstants; import org.dromara.device.constant.AppConstants;
@@ -41,6 +40,7 @@ import java.time.Duration;
public class AppEmailService { public class AppEmailService {
private final AppProperties appProperties; private final AppProperties appProperties;
private final GmailCodeService gmailCodeService;
/** /**
* 生成并按场景发送邮箱验证码(含 60 秒重发限流与域名白名单校验)。 * 生成并按场景发送邮箱验证码(含 60 秒重发限流与域名白名单校验)。
@@ -164,50 +164,16 @@ public class AppEmailService {
email, scene, code, cfg.getExpiryMinutes()); email, scene, code, cfg.getExpiryMinutes());
return; return;
} }
String from = StringUtils.isBlank(cfg.getFrom()) ? cfg.getUser() : cfg.getFrom();
try { try {
MailAccount account = new MailAccount(); // 真实发信统一复用刚接入的 Gmail 服务,确保 APP 与后台使用同一套账号、超时和发信配置。
account.setHost(cfg.getHost()); gmailCodeService.sendVerifyCode(email, code, cfg.getExpiryMinutes());
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();
log.info("[APP邮箱] 验证码已发送:收件邮箱={},场景={}", email, scene); log.info("[APP邮箱] 验证码已发送:收件邮箱={},场景={}", email, scene);
} catch (Exception e) { } catch (Exception e) {
// 不记录验证码本身,避免真实发信失败时把验证码写进日志 // 不记录验证码本身,避免真实发信失败时把验证码写进日志
log.error("APP 邮箱验证码发送异常 email={} host={}:{}(若为连接超时,请确认服务器可访问 smtp.gmail.com)", log.error("APP 邮箱验证码发送异常 email={}(若为连接超时,请确认服务器可访问 smtp.gmail.com)",
email, cfg.getHost(), cfg.getPort(), e); email, e);
throw new ServiceException(AppMsgConstants.EMAIL_SEND_FAILED); 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; package org.dromara.device.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus; import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
import org.dromara.device.domain.BmDeviceAlert; import org.dromara.device.domain.BmDeviceAlert;
import org.dromara.device.domain.vo.AlertVo;
/** /**
* 设备告警 Mapper 接口 * 设备告警 Mapper 接口
@@ -9,6 +11,14 @@ import org.dromara.device.domain.BmDeviceAlert;
* @author Kou * @author Kou
* @date 2026-09-16 * @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); String json = JsonUtils.toJsonString(payload);
boolean ok = client.publish(topic, json.getBytes(StandardCharsets.UTF_8), MqttQoS.QOS1); boolean ok = client.publish(topic, json.getBytes(StandardCharsets.UTF_8), MqttQoS.QOS1);
String safePayload = payloadForLog(payload, json);
if (ok) { if (ok) {
log.info("MQTT 下行 => identity: {}, topic: {}, msgId: {}, payload: {}", log.info("MQTT 下行 => identity: {}, topic: {}, msgId: {}, payload: {}",
identity, topic, msgId, json); identity, topic, msgId, safePayload);
} else { } else {
log.error("MQTT 下行失败 => identity: {}, topic: {}, msgId: {}, payload: {}", log.error("MQTT 下行失败 => identity: {}, topic: {}, msgId: {}, payload: {}",
identity, topic, msgId, json); identity, topic, msgId, safePayload);
} }
return ok; 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); telemetryArchiver.archive(device, payload, receivedAt);
// 低电判定用「该设备自己配的阈值」,头套与脚环各自独立 // 低电判定用「该设备自己配的阈值」,头套与脚环各自独立
alertWriter.applyLowBattery(device.getDeviceId(), deviceNo, 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, 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) { } catch (Exception e) {
log.error("遥测处理异常 => deviceNo: {}", deviceNo, e); log.error("遥测处理异常 => deviceNo: {}", deviceNo, e);
} }
@@ -142,9 +144,9 @@ public class DefaultDeviceUplinkHandler implements DeviceUplinkHandler {
case OFFLINE -> presenceWriter.markOffline(device); case OFFLINE -> presenceWriter.markOffline(device);
case OTA_PROGRESS -> updateOtaProgress(device, payload); case OTA_PROGRESS -> updateOtaProgress(device, payload);
case LOW_BATTERY_HOOD -> alertWriter.writeLowBatteryHint(device.getDeviceId(), deviceNo, 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, 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) { } catch (Exception e) {
log.error("事件处理异常 => deviceNo: {}, eventType: {}", deviceNo, payload.getEventType(), e); log.error("事件处理异常 => deviceNo: {}, eventType: {}", deviceNo, payload.getEventType(), e);

View File

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

View File

@@ -1,15 +1,19 @@
package org.dromara.device.mqtt.support; package org.dromara.device.mqtt.support;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.common.redis.utils.RedisUtils; import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.device.config.DeviceMqttProperties; import org.dromara.device.config.DeviceMqttProperties;
import org.dromara.device.constant.DeviceMqttConstants; import org.dromara.device.constant.DeviceMqttConstants;
import org.dromara.device.constant.DeviceMsgConstants; import org.dromara.device.constant.DeviceMsgConstants;
import org.dromara.device.domain.BmAppMessage;
import org.dromara.device.domain.BmDeviceAlert; import org.dromara.device.domain.BmDeviceAlert;
import org.dromara.device.enums.AlertLevelEnum; import org.dromara.device.enums.AlertLevelEnum;
import org.dromara.device.enums.AlertTypeEnum; import org.dromara.device.enums.AlertTypeEnum;
import org.dromara.device.mapper.BmAppMessageMapper;
import org.dromara.device.mapper.BmDeviceAlertMapper; import org.dromara.device.mapper.BmDeviceAlertMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.Duration; import java.time.Duration;
@@ -34,6 +38,12 @@ import java.time.LocalDateTime;
* <p> * <p>
* 与离线告警的区别:离线是「状态迁移」,由 {@code DevicePresenceWriter#markOffline} 的条件更新 * 与离线告警的区别:离线是「状态迁移」,由 {@code DevicePresenceWriter#markOffline} 的条件更新
* 保证一次迁移只触发一次,因此<b>不走去重窗口</b>——否则设备 30 分钟内掉线两次,第二次会被误吞。 * 保证一次迁移只触发一次,因此<b>不走去重窗口</b>——否则设备 30 分钟内掉线两次,第二次会被误吞。
* <p>
* <b>本类同时是 APP 消息的唯一写入入口</b>:告警落库时一并保存设备绑定用户快照,
* 若快照非空,则额外写一条 {@link BmAppMessage} 通知副本给该用户
* (开发计划 D8 修订:一期 APP 提供消息接口,仅供设备告警使用)。
* 告警行同时承担 outbox:消息写入失败不回滚告警,由后台补偿任务按收件人快照重试,
* {@code bm_app_message.alert_id} 唯一键保证多实例与重复重试幂等。
* *
* @author Kou * @author Kou
* @date 2026-09-16 * @date 2026-09-16
@@ -44,6 +54,7 @@ import java.time.LocalDateTime;
public class DeviceAlertWriter { public class DeviceAlertWriter {
private final BmDeviceAlertMapper alertMapper; private final BmDeviceAlertMapper alertMapper;
private final BmAppMessageMapper messageMapper;
private final DeviceMqttProperties properties; private final DeviceMqttProperties properties;
/** /**
@@ -56,9 +67,9 @@ public class DeviceAlertWriter {
* @param deviceNo 设备编号 * @param deviceNo 设备编号
* @return 是否成功落库 * @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, 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} 表示未配置,走工程默认) * @param deviceThreshold 设备上配置的触发阈值({@code null} 表示未配置,走工程默认)
*/ */
public void applyLowBattery(Long deviceId, String deviceNo, AlertTypeEnum type, public void applyLowBattery(Long deviceId, String deviceNo, AlertTypeEnum type,
Integer battery, Integer deviceThreshold) { Integer battery, Integer deviceThreshold, Long messageUserId) {
if (battery == null || !isLowBatteryType(type)) { if (battery == null || !isLowBatteryType(type)) {
return; return;
} }
@@ -102,7 +113,7 @@ public class DeviceAlertWriter {
// 先置闩锁再落库:即使落库被去重窗口挡住,也不会造成「每条遥测都重试落库」 // 先置闩锁再落库:即使落库被去重窗口挡住,也不会造成「每条遥测都重试落库」
RedisUtils.setCacheObject(latchKey, Boolean.TRUE, properties.getLowBatteryLatchTtl()); RedisUtils.setCacheObject(latchKey, Boolean.TRUE, properties.getLowBatteryLatchTtl());
writeWithDedup(deviceId, deviceNo, type, AlertLevelEnum.WARN, writeWithDedup(deviceId, deviceNo, type, AlertLevelEnum.WARN,
String.valueOf(battery), lowBatteryContent(type, battery)); String.valueOf(battery), lowBatteryContent(type, battery), messageUserId);
return; return;
} }
if (battery >= recover && latched) { if (battery >= recover && latched) {
@@ -125,13 +136,14 @@ public class DeviceAlertWriter {
* @param battery 设备上报的触发值 * @param battery 设备上报的触发值
* @return 是否成功落库 * @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)) { if (!isLowBatteryType(type)) {
return false; return false;
} }
return writeWithDedup(deviceId, deviceNo, type, AlertLevelEnum.WARN, return writeWithDedup(deviceId, deviceNo, type, AlertLevelEnum.WARN,
battery == null ? null : String.valueOf(battery), 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}) * @return 是否成功落库(窗口内被去重或落库失败均返回 {@code false})
*/ */
private boolean writeWithDedup(Long deviceId, String deviceNo, AlertTypeEnum type, 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(); String dedupKey = DeviceMqttConstants.ALERT_DEDUP_PREFIX + deviceNo + ":" + type.getType();
Duration window = properties.getAlertDedupWindow(); Duration window = properties.getAlertDedupWindow();
if (!RedisUtils.setObjectIfAbsent(dedupKey, LocalDateTime.now().toString(), window)) { if (!RedisUtils.setObjectIfAbsent(dedupKey, LocalDateTime.now().toString(), window)) {
log.debug("告警命中去重窗口,已跳过 => deviceNo: {}, type: {}", deviceNo, type.getType()); log.debug("告警命中去重窗口,已跳过 => deviceNo: {}, type: {}", deviceNo, type.getType());
return false; return false;
} }
if (insert(deviceId, deviceNo, type, level, value, content)) { if (insert(deviceId, deviceNo, type, level, value, content, messageUserId)) {
return true; return true;
} }
// 落库失败必须释放占位,否则整个窗口内这条告警再也补不上 // 落库失败必须释放占位,否则整个窗口内这条告警再也补不上
@@ -173,7 +185,7 @@ public class DeviceAlertWriter {
* @return 是否成功落库 * @return 是否成功落库
*/ */
private boolean insert(Long deviceId, String deviceNo, AlertTypeEnum type, private boolean insert(Long deviceId, String deviceNo, AlertTypeEnum type,
AlertLevelEnum level, String value, String content) { AlertLevelEnum level, String value, String content, Long messageUserId) {
try { try {
BmDeviceAlert alert = new BmDeviceAlert(); BmDeviceAlert alert = new BmDeviceAlert();
alert.setDeviceId(deviceId); alert.setDeviceId(deviceId);
@@ -183,10 +195,13 @@ public class DeviceAlertWriter {
alert.setAlertValue(value); alert.setAlertValue(value);
alert.setContent(content); alert.setContent(content);
alert.setStatus(DeviceMqttConstants.ALERT_STATUS_UNHANDLED); alert.setStatus(DeviceMqttConstants.ALERT_STATUS_UNHANDLED);
alert.setMessageUserId(messageUserId);
alert.setAlertTime(LocalDateTime.now()); alert.setAlertTime(LocalDateTime.now());
alertMapper.insert(alert); alertMapper.insert(alert);
log.info("设备告警 => deviceNo: {}, type: {}, level: {}, value: {}", log.info("设备告警 => deviceNo: {}, type: {}, level: {}, value: {}",
deviceNo, type.getType(), level.getCode(), value); deviceNo, type.getType(), level.getCode(), value);
// 告警已落库,再补一条面向绑定用户的通知副本(失败不影响上面的结果)
pushAppMessage(alert);
return true; return true;
} catch (RuntimeException e) { } 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; return false;
} }
log.info("设备离线 => deviceNo: {}, lastOnlineTime: {}", device.getDeviceNo(), device.getLastOnlineTime()); log.info("设备离线 => deviceNo: {}, lastOnlineTime: {}", device.getDeviceNo(), device.getLastOnlineTime());
alertWriter.writeOffline(device.getDeviceId(), device.getDeviceNo()); // 收件人取状态迁移时已读取的设备快照,避免告警落库后换绑导致消息串给新用户。
alertWriter.writeOffline(device.getDeviceId(), device.getDeviceNo(), device.getBindUserId());
return true; 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.domain.bo.AppSmsLoginBo;
import org.dromara.device.email.AppEmailService; import org.dromara.device.email.AppEmailService;
import org.dromara.device.service.IAppAuthService; import org.dromara.device.service.IAppAuthService;
import org.dromara.device.sms.AppPhoneNumbers;
import org.dromara.device.sms.AppSmsService; import org.dromara.device.sms.AppSmsService;
import org.dromara.system.api.AppUserApiService; import org.dromara.system.api.AppUserApiService;
import org.dromara.system.api.domain.AppUserDTO; import org.dromara.system.api.domain.AppUserDTO;
@@ -66,7 +67,7 @@ public class AppAuthServiceImpl implements IAppAuthService {
*/ */
@Override @Override
public String sendSmsCode(AppSmsCodeBo bo) { 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 @Override
public void register(AppRegisterBo bo) { 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(); lock.lock();
try { try {
if (appUserApiService.existsByPhone(bo.getPhoneNumber())) { if (appUserApiService.existsByPhone(phone)) {
throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED); throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED);
} }
appSmsService.verifyCode(bo.getPhoneNumber(), AppConstants.SCENE_REGISTER, bo.getCode()); appSmsService.verifyCode(phone, AppConstants.SCENE_REGISTER, bo.getCode());
try { try {
appUserApiService.registerByPhone(bo.getPhoneNumber(), bo.getPassword(), bo.getNickName()); appUserApiService.registerByPhone(phone, bo.getPassword(), bo.getNickName());
} catch (DuplicateKeyException e) { } catch (DuplicateKeyException e) {
// DB 唯一键 uk_sys_user_phone_number 冲突:并发同号注册的最终兜底 // DB 唯一键 uk_sys_user_phone_number 冲突:并发同号注册的最终兜底
throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED); throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED);
@@ -115,7 +117,7 @@ public class AppAuthServiceImpl implements IAppAuthService {
*/ */
@Override @Override
public String login(AppLoginBo bo) { public String login(AppLoginBo bo) {
AppUserDTO user = appUserApiService.selectByPhone(bo.getPhoneNumber()); AppUserDTO user = appUserApiService.selectByPhone(AppPhoneNumbers.canonical(bo.getPhoneNumber()));
if (user == null) { if (user == null) {
throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST); throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
} }
@@ -134,12 +136,13 @@ public class AppAuthServiceImpl implements IAppAuthService {
*/ */
@Override @Override
public String smsLogin(AppSmsLoginBo bo) { 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) { if (user == null) {
throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST); throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
} }
checkAccountEnabled(user); checkAccountEnabled(user);
appSmsService.verifyCode(bo.getPhoneNumber(), AppConstants.SCENE_LOGIN, bo.getCode()); appSmsService.verifyCode(phone, AppConstants.SCENE_LOGIN, bo.getCode());
return doLogin(user); return doLogin(user);
} }
@@ -150,11 +153,12 @@ public class AppAuthServiceImpl implements IAppAuthService {
*/ */
@Override @Override
public void resetPwd(AppResetPwdBo bo) { 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) { if (user == null) {
throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST); 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()); appUserApiService.resetPassword(user.getUserId(), bo.getNewPassword());
} }
@@ -307,6 +311,7 @@ public class AppAuthServiceImpl implements IAppAuthService {
SaLoginParameter model = new SaLoginParameter(); SaLoginParameter model = new SaLoginParameter();
model.setDeviceType(client.getDeviceType()); model.setDeviceType(client.getDeviceType());
// 固定有效期与活跃超时默认均为 30 天,避免无操作 30 分钟导致 APP 提前失效。
model.setTimeout(client.getTimeout()); model.setTimeout(client.getTimeout());
model.setActiveTimeout(client.getActiveTimeout()); model.setActiveTimeout(client.getActiveTimeout());
// 写入 token 扩展:clientid 供框架校验「请求头 clientid == 令牌扩展 clientid」(SecurityConfig:98-106); // 写入 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; package org.dromara.device.service.impl;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils; import org.dromara.common.core.utils.StringUtils;
import org.dromara.device.config.MjpegProperties;
import org.dromara.device.config.ZlmProperties; import org.dromara.device.config.ZlmProperties;
import org.dromara.device.constant.AppMsgConstants; import org.dromara.device.constant.AppMsgConstants;
import org.dromara.device.constant.DeviceMsgConstants; import org.dromara.device.constant.DeviceMsgConstants;
import org.dromara.device.domain.BmDevice; 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.AppStreamPlayVo;
import org.dromara.device.domain.vo.AppStreamStatusVo; import org.dromara.device.domain.vo.AppStreamStatusVo;
import org.dromara.device.enums.DeviceStatusEnum; import org.dromara.device.enums.DeviceStatusEnum;
import org.dromara.device.mapper.BmDeviceMapper; 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.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.dromara.device.zlm.stream.ZlmPlayAuthService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/** /**
* APP 直播服务实现。 * APP 直播服务实现。
* <p> * <p>
* 流程对齐 dm:MQTT 通知设备推流到 ZLM → 查询 getMediaInfo → 签发带 token 的播放地址。 * 根据配置选择 MJPEG 或 ZLM 后端,统一完成设备推流指令、状态查询与播放地址签发。
* 越权隔离对齐 I3:SQL {@code device_no + bind_user_id},命中 0 行统一「设备不存在」。 * 越权隔离对齐 I3:SQL {@code device_no + bind_user_id},命中 0 行统一「设备不存在」。
* *
* @author Kou * @author Kou
* @date 2026-09-17 * @date 2026-09-17
*/ */
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AppStreamServiceImpl implements IAppStreamService { public class AppStreamServiceImpl implements IAppStreamService {
private final ZlmClient zlmClient;
private final ZlmProperties config; private final ZlmProperties config;
private final MjpegProperties mjpegProperties;
private final MjpegStreamBackend mjpegStreamBackend;
private final ZlmStreamBackend zlmStreamBackend;
private final ZlmPlayAuthService playAuthService; private final ZlmPlayAuthService playAuthService;
private final DeviceCommandPublisher commandPublisher;
private final BmDeviceMapper deviceMapper; private final BmDeviceMapper deviceMapper;
@Override @Override
@@ -50,18 +46,7 @@ public class AppStreamServiceImpl implements IAppStreamService {
throw new ServiceException(DeviceMsgConstants.DEVICE_OFFLINE); throw new ServiceException(DeviceMsgConstants.DEVICE_OFFLINE);
} }
String streamId = normalizeStreamId(device.getDeviceNo()); String streamId = normalizeStreamId(device.getDeviceNo());
if (!zlmClient.isStreamOnline(streamId)) { backend().start(device, 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);
}
return buildPlayInfo(device.getDeviceNo(), userId); return buildPlayInfo(device.getDeviceNo(), userId);
} }
@@ -69,10 +54,7 @@ public class AppStreamServiceImpl implements IAppStreamService {
public AppStreamStatusVo stop(String deviceNo, Long userId) { public AppStreamStatusVo stop(String deviceNo, Long userId) {
BmDevice device = requireOwnedDevice(deviceNo, userId); BmDevice device = requireOwnedDevice(deviceNo, userId);
String streamId = normalizeStreamId(device.getDeviceNo()); String streamId = normalizeStreamId(device.getDeviceNo());
StreamCommandPayload payload = StreamCommandPayload.stop( backend().stop(device, streamId);
DeviceCommandPublisher.newMsgId(), streamId, config.getApp());
commandPublisher.publishStream(device.getDeviceNo(), payload);
zlmClient.closeStreams(streamId);
return statusOf(device.getDeviceNo(), streamId, false); return statusOf(device.getDeviceNo(), streamId, false);
} }
@@ -84,105 +66,50 @@ public class AppStreamServiceImpl implements IAppStreamService {
@Override @Override
public void close(String deviceNo, Long userId) { public void close(String deviceNo, Long userId) {
BmDevice device = requireOwnedDevice(deviceNo, userId); BmDevice device = requireOwnedDevice(deviceNo, userId);
closeZlmStream(device.getDeviceNo()); String streamId = normalizeStreamId(device.getDeviceNo());
backend().close(streamId);
} }
@Override @Override
public AppStreamStatusVo status(String deviceNo, Long userId) { public AppStreamStatusVo status(String deviceNo, Long userId) {
BmDevice device = requireOwnedDevice(deviceNo, userId); BmDevice device = requireOwnedDevice(deviceNo, userId);
String streamId = normalizeStreamId(device.getDeviceNo()); String streamId = normalizeStreamId(device.getDeviceNo());
return statusOf(device.getDeviceNo(), streamId, zlmClient.isStreamOnline(streamId)); return statusOf(device.getDeviceNo(), streamId, backend().online(streamId));
} }
@Override @Override
public void closeZlmStream(String streamId) { public void closeZlmStream(String streamId) {
streamId = normalizeStreamId(streamId); streamId = normalizeStreamId(streamId);
zlmClient.delStreamProxy(streamId); // 该入口只处理 ZLM webhook,不能受当前 APP 是否启用 MJPEG 的配置影响。
zlmClient.closeStreams(streamId); zlmStreamBackend.close(streamId);
} }
private AppStreamPlayVo buildPlayInfo(String deviceNo, Long userId) { private AppStreamPlayVo buildPlayInfo(String deviceNo, Long userId) {
BmDevice device = requireOwnedDevice(deviceNo, userId); BmDevice device = requireOwnedDevice(deviceNo, userId);
String streamId = normalizeStreamId(device.getDeviceNo()); String streamId = normalizeStreamId(device.getDeviceNo());
ZlmPlayAuthService.PlayToken playToken = playAuthService.createToken(streamId); ZlmPlayAuthService.PlayToken playToken = playAuthService.createToken(streamId);
// 同一次请求固定后端,避免配置热切换时地址与在线状态来自不同协议。
StreamBackend selectedBackend = backend();
StreamBackend.Endpoint endpoint = selectedBackend.endpoint(streamId, playToken.token());
AppStreamPlayVo vo = new AppStreamPlayVo(); AppStreamPlayVo vo = new AppStreamPlayVo();
vo.setDeviceNo(device.getDeviceNo()); vo.setDeviceNo(device.getDeviceNo());
vo.setStreamId(streamId); vo.setStreamId(streamId);
vo.setApp(config.getApp()); vo.setApp(config.getApp());
vo.setOnline(zlmClient.isStreamOnline(streamId)); vo.setOnline(selectedBackend.online(streamId));
vo.setPushUrl(buildPushUrl(streamId)); vo.setPushUrl(endpoint.pushUrl());
vo.setPlayToken(playToken.token()); vo.setPlayToken(playToken.token());
vo.setExpireAt(playToken.expireAt()); vo.setExpireAt(playToken.expireAt());
vo.setExpireSeconds(config.getPlayExpireSeconds()); vo.setExpireSeconds(config.getPlayExpireSeconds());
vo.setPlayUrls(buildPlayUrls(streamId, playToken.token())); vo.setPlayUrls(endpoint.playUrls());
return vo; 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)); private StreamBackend backend() {
urls.setHls(appendToken(base + "/hls.m3u8", token)); return mjpegProperties.isEnabled() ? mjpegStreamBackend : zlmStreamBackend;
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 String normalizeStreamId(String deviceNo) { 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.domain.vo.AppUserProfileVo;
import org.dromara.device.service.IAppUserService; import org.dromara.device.service.IAppUserService;
import org.dromara.device.service.IDeviceBindService; import org.dromara.device.service.IDeviceBindService;
import org.dromara.device.sms.AppPhoneNumbers;
import org.dromara.device.sms.AppSmsService; import org.dromara.device.sms.AppSmsService;
import org.dromara.system.api.AppUserApiService; import org.dromara.system.api.AppUserApiService;
import org.dromara.system.api.UserService; import org.dromara.system.api.UserService;
@@ -116,17 +117,18 @@ public class AppUserServiceImpl implements IAppUserService {
throw new ServiceException(AppMsgConstants.NOT_LOGIN); throw new ServiceException(AppMsgConstants.NOT_LOGIN);
} }
boolean firstBind = StringUtils.isBlank(current.getPhoneNumber()); boolean firstBind = StringUtils.isBlank(current.getPhoneNumber());
String newPhone = AppPhoneNumbers.canonical(bo.getNewPhoneNumber());
// P3-6:新号与当前号相同时给出精确文案(原先 existsByPhone 命中自身 → 误报「手机号已注册」) // 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); throw new ServiceException(AppMsgConstants.PHONE_SAME_AS_CURRENT);
} }
if (appUserApiService.existsByPhone(bo.getNewPhoneNumber())) { if (appUserApiService.existsByPhone(newPhone)) {
throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED); throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED);
} }
appSmsService.verifyCode(bo.getNewPhoneNumber(), appSmsService.verifyCode(newPhone,
firstBind ? AppConstants.SCENE_BIND : AppConstants.SCENE_REBIND, bo.getCode()); 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 清」。 // 3. 清除会话 —— 必须「按 loginId 全端下线」,而非「按当前 token 清」。
// loginId = userType + ":" + userId(见 LoginUser#getLoginId),APP 登录标识恒为 app_user:{userId}; // loginId = userType + ":" + userId(见 LoginUser#getLoginId),APP 登录标识恒为 app_user:{userId};
// StpUtil.logout(loginId) 一次调用即注销该账号名下「所有设备/所有会话」的 token。 // StpUtil.logout(loginId) 一次调用即注销该账号名下「所有设备/所有会话」的 token。
// 反例:若用 StpUtil.logout()(仅清当前 token),其他设备上仍在有效期(默认 7 天)内的 token 会继续可用, // 反例:若用 StpUtil.logout()(仅清当前 token),其他设备上仍在有效期(默认 30 天)内的 token 会继续可用,
// 与该账号「已注销/已停用」语义冲突,故采用全端下线。停用(status=停用)另在登录入口兜底拦截再登录。 // 与该账号「已注销/已停用」语义冲突,故采用全端下线。停用(status=停用)另在登录入口兜底拦截再登录。
String loginId = UserType.APP_USER.getUserType() + ":" + userId; String loginId = UserType.APP_USER.getUserType() + ":" + userId;
try { 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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException; 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.core.utils.StringUtils;
import org.dromara.common.redis.utils.RedisUtils; import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.sms.core.AliyunInternationalSmsService;
import org.dromara.device.config.AppProperties; import org.dromara.device.config.AppProperties;
import org.dromara.device.constant.AppConstants; 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.device.enums.SmsSceneEnum;
import org.dromara.sms4j.api.SmsBlend; import org.dromara.sms4j.api.SmsBlend;
import org.dromara.sms4j.api.entity.SmsResponse; import org.dromara.sms4j.api.entity.SmsResponse;
@@ -27,7 +28,7 @@ import java.util.LinkedHashMap;
* <li><b>场景化隔离</b>:缓存 key 与限流 key 均带场景,登录/注册/忘记密码/换绑互不覆盖。</li> * <li><b>场景化隔离</b>:缓存 key 与限流 key 均带场景,登录/注册/忘记密码/换绑互不覆盖。</li>
* <li><b>时长口径</b>:验证码有效期 5 分钟、重发间隔 60 秒、位数 6 位,均可由 {@code device.app.sms.*} 配置覆盖。</li> * <li><b>时长口径</b>:验证码有效期 5 分钟、重发间隔 60 秒、位数 6 位,均可由 {@code device.app.sms.*} 配置覆盖。</li>
* <li><b>短信通道开关点</b>:{@code device.app.sms.mock}(默认 true)为 true 时仅打印日志,便于开发期(Q5 短信通道未定)联调; * <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> * <li><b>不触碰 PC 通道</b>:本服务仅服务 {@code /app/**},与框架既有 {@code /resource/sms/code} 完全独立。</li>
* </ul> * </ul>
* *
@@ -40,6 +41,7 @@ import java.util.LinkedHashMap;
public class AppSmsService { public class AppSmsService {
private final AppProperties appProperties; private final AppProperties appProperties;
private final AliyunInternationalSmsService aliyunInternationalSmsService;
/** /**
* 生成并按场景发送短信验证码(含 60 秒重发限流)。 * 生成并按场景发送短信验证码(含 60 秒重发限流)。
@@ -50,15 +52,16 @@ public class AppSmsService {
*/ */
public String sendCode(String phoneNumber, String scene) { public String sendCode(String phoneNumber, String scene) {
if (!SmsSceneEnum.isValid(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(); AppProperties.Sms sms = appProperties.getSms();
// 60 秒重发限流:利用「不存在才写入」的原子性,写入成功即取得发送资格 // 60 秒重发限流:利用「不存在才写入」的原子性,写入成功即取得发送资格
String limitKey = AppConstants.SMS_LIMIT_KEY_PREFIX + scene + ":" + phoneNumber; String limitKey = AppConstants.SMS_LIMIT_KEY_PREFIX + scene + ":" + phoneNumber;
boolean acquired = RedisUtils.setObjectIfAbsent(limitKey, System.currentTimeMillis(), boolean acquired = RedisUtils.setObjectIfAbsent(limitKey, System.currentTimeMillis(),
Duration.ofSeconds(sms.getResendSeconds())); Duration.ofSeconds(sms.getResendSeconds()));
if (!acquired) { 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()); String code = RandomUtil.randomNumbers(sms.getCodeLength());
// 先落库缓存,再发送:即使发送异常,限流 key 已存在,避免短信通道故障时的刷量 // 先落库缓存,再发送:即使发送异常,限流 key 已存在,避免短信通道故障时的刷量
@@ -88,10 +91,11 @@ public class AppSmsService {
* @param code 用户输入的验证码 * @param code 用户输入的验证码
*/ */
public void verifyCode(String phoneNumber, String scene, String code) { public void verifyCode(String phoneNumber, String scene, String code) {
phoneNumber = AppPhoneNumbers.canonical(phoneNumber);
String codeKey = AppConstants.SMS_CODE_KEY_PREFIX + scene + ":" + phoneNumber; String codeKey = AppConstants.SMS_CODE_KEY_PREFIX + scene + ":" + phoneNumber;
String cached = RedisUtils.getCacheObject(codeKey); String cached = RedisUtils.getCacheObject(codeKey);
if (StringUtils.isBlank(cached)) { 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)); RedisUtils.expire(failKey, Duration.ofSeconds(AppConstants.SMS_VERIFY_FAIL_WINDOW_SECONDS));
} }
if (attempts > AppConstants.SMS_MAX_VERIFY_FAIL) { 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 phoneNumber 手机号
* @param code 验证码 * @param code 验证码
*/ */
private void send(String phoneNumber, String code) { private void send(String phoneNumber, String code) {
AppProperties.Sms sms = appProperties.getSms(); AppProperties.Sms sms = appProperties.getSms();
boolean china = AppPhoneNumbers.chinaMainland(phoneNumber);
String to = AppPhoneNumbers.forSms(phoneNumber);
if (sms.isMock()) { if (sms.isMock()) {
log.info("[APP短信-模拟发送] 手机号={},验证码={},有效期={}分钟(开发环境未接入短信通道,Q5)。", log.info("[APP短信-模拟发送] 手机号={},通道号码={},{},验证码={},有效期={}分钟。",
phoneNumber, code, sms.getExpiryMinutes()); DesensitizedUtils.maskHighSecurity(phoneNumber, 3, 2),
DesensitizedUtils.maskHighSecurity(to, 3, 2),
china ? "国内" : "国际", code, sms.getExpiryMinutes());
return;
}
if (!china) {
aliyunInternationalSmsService.sendMessageToGlobe(to, code);
return; return;
} }
try { try {
SmsBlend smsBlend = SmsFactory.getSmsBlend(sms.getBlend()); SmsBlend smsBlend = SmsFactory.getSmsBlend(sms.getBlend());
LinkedHashMap<String, String> params = new LinkedHashMap<>(1); LinkedHashMap<String, String> params = new LinkedHashMap<>(1);
params.put("code", code); params.put("code", code);
SmsResponse response = smsBlend.sendMessage(phoneNumber, sms.getTemplateId(), params); SmsResponse response = smsBlend.sendMessage(to, sms.getTemplateId(), params);
if (!response.isSuccess()) { 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) { } catch (ServiceException e) {
throw e; throw e;
} catch (Exception 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 播放信息 * @return 播放信息
*/ */
@GetMapping("/play-url/{deviceNo}") @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) { public R<AppStreamPlayVo> playUrl(@PathVariable String deviceNo) {
return R.ok(appStreamService.playUrl(deviceNo, currentUserId())); return R.ok(appStreamService.playUrl(deviceNo, currentUserId()));
} }
@@ -111,6 +113,8 @@ public class AppStreamController extends BaseController {
* @return 状态 * @return 状态
*/ */
@GetMapping("/status/{deviceNo}") @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) { public R<AppStreamStatusVo> status(@PathVariable String deviceNo) {
return R.ok(appStreamService.status(deviceNo, currentUserId())); 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()); log.warn("[ZLM-HOOK] on_publish 未通过 secret 校验, IP={}", request.getRemoteAddr());
return reject("unauthorized"); return reject("unauthorized");
} }
log.info("[ZLM-HOOK] on_publish {}", body); logHook("on_publish", body);
return ok(); return ok();
} }
@@ -69,7 +69,7 @@ public class ZlmHookController {
log.warn("[ZLM-HOOK] on_play 未通过 secret 校验, IP={}", request.getRemoteAddr()); log.warn("[ZLM-HOOK] on_play 未通过 secret 校验, IP={}", request.getRemoteAddr());
return reject("unauthorized"); return reject("unauthorized");
} }
log.info("[ZLM-HOOK] on_play {}", body); logHook("on_play", body);
String streamId = asString(body.get("stream")); String streamId = asString(body.get("stream"));
String token = extractToken(body); String token = extractToken(body);
String clientIp = asString(body.get("ip")); String clientIp = asString(body.get("ip"));
@@ -92,7 +92,7 @@ public class ZlmHookController {
log.warn("[ZLM-HOOK] on_stream_changed 未通过 secret 校验, IP={}", request.getRemoteAddr()); log.warn("[ZLM-HOOK] on_stream_changed 未通过 secret 校验, IP={}", request.getRemoteAddr());
return reject("unauthorized"); return reject("unauthorized");
} }
log.info("[ZLM-HOOK] on_stream_changed {}", body); logHook("on_stream_changed", body);
return ok(); return ok();
} }
@@ -109,7 +109,7 @@ public class ZlmHookController {
log.warn("[ZLM-HOOK] on_stream_none_reader 未通过 secret 校验, IP={}", request.getRemoteAddr()); log.warn("[ZLM-HOOK] on_stream_none_reader 未通过 secret 校验, IP={}", request.getRemoteAddr());
return reject("unauthorized"); return reject("unauthorized");
} }
log.info("[ZLM-HOOK] on_stream_none_reader {}", body); logHook("on_stream_none_reader", body);
Object stream = body.get("stream"); Object stream = body.get("stream");
if (stream instanceof String streamId && !streamId.isBlank()) { if (stream instanceof String streamId && !streamId.isBlank()) {
appStreamService.closeZlmStream(streamId); appStreamService.closeZlmStream(streamId);
@@ -149,6 +149,18 @@ public class ZlmHookController {
return false; 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() { private Map<String, Object> ok() {
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("code", 0); result.put("code", 0);

View File

@@ -76,7 +76,8 @@ public class ZlmPlayAuthService {
Map<String, Object> parsed = JsonUtils.parseObject(json, Map.class); Map<String, Object> parsed = JsonUtils.parseObject(json, Map.class);
payload = parsed; payload = parsed;
} catch (Exception e) { } 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; return false;
} }
if (payload == null) { if (payload == null) {
@@ -92,8 +93,7 @@ public class ZlmPlayAuthService {
String issuedIp = Objects.toString(payload.get("clientIp"), ""); String issuedIp = Objects.toString(payload.get("clientIp"), "");
if (StringUtils.isNotBlank(issuedIp) && StringUtils.isNotBlank(clientIp) if (StringUtils.isNotBlank(issuedIp) && StringUtils.isNotBlank(clientIp)
&& !Objects.equals(issuedIp, clientIp)) { && !Objects.equals(issuedIp, clientIp)) {
log.warn("[ZLM-AUTH] ip 不匹配 tokenIp={} requestIp={} streamId={}", log.warn("[ZLM-AUTH] ip 不匹配 streamId={}", streamId);
issuedIp, clientIp, streamId);
return false; return false;
} }
return true; 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"> "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.dromara.device.mapper.BmDeviceAlertMapper"> <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> </mapper>

View File

@@ -36,10 +36,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
离线扫描候选:del_flag='0' + status='1' + 已有心跳且已超时。 离线扫描候选:del_flag='0' + status='1' + 已有心跳且已超时。
刻意排除 last_online_time IS NULL:绑定成功即置「在线」但尚无心跳的设备不算超时, 刻意排除 last_online_time IS NULL:绑定成功即置「在线」但尚无心跳的设备不算超时,
否则用户刚添加设备就会被误判离线(详见 BmDeviceMapper#selectStaleOnlineDevices 注释)。 否则用户刚添加设备就会被误判离线(详见 BmDeviceMapper#selectStaleOnlineDevices 注释)。
只取三列足够裁决,避免整行回传。 同时取 bind_user_id 作为告警发生时的消息收件人快照,避免告警落库后再次查询导致换绑串号。
--> -->
<select id="selectStaleOnlineDevices" resultType="org.dromara.device.domain.BmDevice"> <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 FROM bm_device
WHERE del_flag = '0' WHERE del_flag = '0'
AND status = '1' 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"));
}
}

View File

@@ -156,6 +156,30 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
return list; return list;
} }
/**
* 根据 OSS 主键生成下载地址。
* <p>
* 私有桶必须在读取时重新签名;数据库中的原始 URL 没有访问权限,上传响应里的签名 URL
* 又只有 120 秒有效期,都不能直接用于 APP 长期升级。
*/
@Override
public String selectDownloadUrlById(Long ossId, Duration validTime) {
if (ossId == null) {
return null;
}
SysOssVo oss = SpringUtils.getAopProxy(this).getById(ossId);
if (oss == null) {
return null;
}
OssClient client = OssFactory.instance(oss.getService());
if (client.verifyConfig(config -> AccessPolicy.PRIVATE.equals(config.accessControlPolicyConfig().accessPolicy()))) {
Duration effective = validTime == null || validTime.isZero() || validTime.isNegative()
? Duration.ofSeconds(120) : validTime;
return client.presignGetUrl(oss.getFileName(), effective);
}
return oss.getUrl();
}
/** /**
* 构造 OSS 文件列表查询条件。 * 构造 OSS 文件列表查询条件。
* *

View File

@@ -199,3 +199,237 @@ ALTER TABLE bm_device
ALTER TABLE bm_device ALTER TABLE bm_device
ADD COLUMN ring_battery_threshold tinyint NOT NULL DEFAULT 20 ADD COLUMN ring_battery_threshold tinyint NOT NULL DEFAULT 20
COMMENT '脚环低电告警阈值0-100(≤该值触发,0=不触发)'; COMMENT '脚环低电告警阈值0-100(≤该值触发,0=不触发)';
-- ----------------------------------------------------------------------------
-- 补丁 5:sys_user.phone_number 加长以支持国际号码
-- ----------------------------------------------------------------------------
-- 背景:APP 国际短信按 E.164 落库为「+国家码+号码」(最长 16 位),原 varchar(11) 装不下。
-- 国内存量 11 位号码不受影响。
--
-- ★ 回滚
-- ALTER TABLE sys_user MODIFY COLUMN phone_number varchar(11) DEFAULT NULL COMMENT '手机号码';
-- ----------------------------------------------------------------------------
ALTER TABLE sys_user MODIFY COLUMN phone_number varchar(20) DEFAULT NULL COMMENT '手机号码';
-- ----------------------------------------------------------------------------
-- 补丁 6:告警记录「处理流转」+ APP 消息表(告警消息推送)
-- ----------------------------------------------------------------------------
-- 背景:
-- 原设计(一期初版)把告警定为「只读展示」:bm_device_alert 只有 status 一个状态列,
-- 没有处理人/处理时间,也没有逻辑删除;APP 侧不做消息中心(开发计划 D8)。
-- 本次范围变更为:
-- 1) 后台「告警记录」页支持「标记处理 / 批量处理 / 删除」——需要处理痕迹与可追溯删除;
-- 2) 告警发生时向**设备绑定的 APP 用户**推送一条消息(全部类型:离线 + 头套低电 + 脚环低电),
-- APP 需要未读角标 ⇒ 消息必须有「已读」状态。
--
-- ★ 为什么新建 bm_app_message 而不是复用框架的 sys_message
-- 1) sys_message **没有「已读」字段**(只有 sendUserIds 接收人串),无法支撑 APP 未读角标;
-- 2) 给 sys_message 加列 = 改框架表,违反项目铁律「ruoyi-* 框架不改,业务只在 ruoyi-device」;
-- 3) 告警消息需要携带 device_no / alert_id 以便 APP 点击跳设备详情,属业务字段。
-- 职责划分:bm_device_alert = 设备事实(含处理流程);bm_app_message = 推送副本(含收件人与已读)。
--
-- ★ del_flag 的语义与既有约束
-- 与 bm_device 一致:0 存在 / 1 删除(common-mybatis.yml: logicDeleteValue=1)。
-- 后台「删除」走 MyBatis-Plus 逻辑删除,**不物理抹除**告警审计痕迹。
--
-- ★ 执行前必读 · 前置检查(确认列尚不存在,避免重复执行报 1060)
-- SELECT column_name FROM information_schema.columns
-- WHERE table_schema = 'bird_monitoring' AND table_name = 'bm_device_alert'
-- AND column_name IN ('handle_by', 'handle_time', 'message_user_id', 'del_flag');
-- → 期望返回 0 行。
--
-- ★ 重复执行本文件时的预期报错(可忽略)
-- ERROR 1060 (42S21): Duplicate column name 'handle_by'
-- ERROR 1050 (42S01): Table 'bm_app_message' already exists
-- ERROR 1062 (23000): Duplicate entry '1771400000000000005' for key 'PRIMARY'(菜单重复插入)
--
-- ★ 回滚
-- ALTER TABLE bm_device_alert
-- DROP COLUMN handle_by, DROP COLUMN handle_time, DROP COLUMN message_user_id,
-- DROP COLUMN del_flag,
-- DROP INDEX idx_status_time;
-- DROP TABLE bm_app_message;
-- DELETE FROM sys_role_menu WHERE menu_id IN (1771400000000000005,1771400000000000019,
-- 1771400000000000020,1771400000000000021);
-- DELETE FROM sys_menu WHERE menu_id IN (1771400000000000005,1771400000000000019,
-- 1771400000000000020,1771400000000000021);
-- ----------------------------------------------------------------------------
-- 步骤 1:告警表补「处理人 / 处理时间 / 逻辑删除」
-- 注:status 列原注释写着「一期只读展示」,本次变更后已失效,一并纠正注释。
ALTER TABLE bm_device_alert
MODIFY COLUMN status char(1) NOT NULL COMMENT '处理状态(0未处理 1已处理)';
ALTER TABLE bm_device_alert
ADD COLUMN handle_by bigint(20) DEFAULT NULL COMMENT '处理人(sys_user.user_id,后台账号)';
ALTER TABLE bm_device_alert
ADD COLUMN handle_time datetime DEFAULT NULL COMMENT '处理时间';
-- 告警行同时承担消息 outbox:保存告警发生时的收件人快照,消息写入失败后可据此安全补偿。
ALTER TABLE bm_device_alert
ADD COLUMN message_user_id bigint(20) DEFAULT NULL COMMENT '告警发生时的APP收件人快照(用于消息补偿)';
-- del_flag 用 NOT NULL DEFAULT '0':与 bm_device / bm_ota_package / bm_ota_task 保持一致。
-- 必须 NOT NULL:MyBatis-Plus 逻辑删除生成 WHERE del_flag = '0',
-- 若某行是 NULL 会被静默排除在所有查询之外(数据「消失」而非报错)。
-- 存量行由 ADD COLUMN 的 DEFAULT 一次性回填 '0'(全部视为存在),升级零行为变化。
ALTER TABLE bm_device_alert
ADD COLUMN del_flag char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除(0存在 1删除)';
-- 「未处理优先 + 时间倒序」是告警列表的默认排序,建复合索引支撑。
ALTER TABLE bm_device_alert ADD INDEX idx_status_time (status, alert_time);
-- 步骤 2:APP 消息表
CREATE TABLE IF NOT EXISTS bm_app_message (
message_id bigint(20) NOT NULL COMMENT '主键(雪花)',
user_id bigint(20) NOT NULL COMMENT '接收用户(sys_user.user_id,APP 用户)',
device_id bigint(20) DEFAULT NULL COMMENT '关联设备主键',
device_no varchar(32) DEFAULT NULL COMMENT '关联设备编号(冗余,供展示与跳转)',
alert_id bigint(20) DEFAULT NULL COMMENT '关联告警主键(bm_device_alert)',
alert_type varchar(32) DEFAULT NULL COMMENT '告警类型(offline/low_battery_hood/low_battery_ring)',
alert_level char(1) DEFAULT NULL COMMENT '告警级别(0提示 1警告 2严重)',
title varchar(128) NOT NULL COMMENT '消息标题',
content varchar(255) DEFAULT NULL COMMENT '消息内容',
read_flag char(1) NOT NULL DEFAULT '0' COMMENT '已读标记(0未读 1已读)',
read_time datetime DEFAULT NULL COMMENT '已读时间',
create_time datetime NOT NULL COMMENT '产生时间',
PRIMARY KEY (message_id),
UNIQUE KEY uk_alert_id (alert_id),
KEY idx_user_read (user_id, read_flag),
KEY idx_user_time (user_id, create_time, message_id)
) ENGINE=InnoDB COMMENT = 'APP 消息(一期仅承载设备告警通知)';
-- 步骤 3:菜单 + 权限字(与 ry_device.sql「十、菜单 + 权限字」保持一致)
INSERT INTO sys_menu VALUES(1771400000000000005, '告警记录', 1771400000000000001, 4, 'alert', 'device/alert/index', '', 'N', 'Y', 'C', '0', '0', 'device:alert:list', 'message', '', '', 1761000000000000103, 1761100000000000001, sysdate(), NULL, NULL, '告警记录菜单');
INSERT INTO sys_menu VALUES(1771400000000000019, '告警查询', 1771400000000000005, 1, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:alert:query', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), NULL, NULL, '');
INSERT INTO sys_menu VALUES(1771400000000000020, '告警处理', 1771400000000000005, 2, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:alert:edit', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), NULL, NULL, '');
INSERT INTO sys_menu VALUES(1771400000000000021, '告警删除', 1771400000000000005, 3, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:alert:remove', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), NULL, NULL, '');
-- 步骤 4:角色-菜单关联
-- 设备管理员:全部告警权限
INSERT INTO sys_role_menu VALUES (1771300000000000001, 1771400000000000005);
INSERT INTO sys_role_menu VALUES (1771300000000000001, 1771400000000000019);
INSERT INTO sys_role_menu VALUES (1771300000000000001, 1771400000000000020);
INSERT INTO sys_role_menu VALUES (1771300000000000001, 1771400000000000021);
-- 审计员:只读(菜单 + 查询,不含处理与删除)
INSERT INTO sys_role_menu VALUES (1771300000000000002, 1771400000000000005);
INSERT INTO sys_role_menu VALUES (1771300000000000002, 1771400000000000019);
-- ----------------------------------------------------------------------------
-- 补丁 7:APP 客户端版本升级(bm_app_version 表 + 2 字典 + 菜单/权限字 + 角色授权
-- + 设备管理员补授框架上传权限 system:oss:upload)
-- ----------------------------------------------------------------------------
-- 背景:
-- 平台此前无「APP 客户端版本」概念,APP 发新版后老用户无感知,且缺少「不升级不可用」手段。
-- 本补丁落地「APP 客户端版本升级」功能的数据基线:后台登记 APP 安装包(Android / iOS 口径一致)
-- 与可选的 App Store 引导地址,APP 冷启动调用 GET /app/version/latest 判定是否有新版。
-- 业务上**与设备 OTA 固件升级完全独立**
-- (不共表、不共权限字、不共页面),仅共用「上传包到 OSS」这一技术手段。
--
-- ★ 关键口径(务必遵守)
-- 1) 版本比较**只用整数构建号 version_code**(int,32 位正整数,人工递增),version_name 仅展示。
-- ⚠️ 禁止用时间戳式构建号:int 上界 2147483647,时间戳(如 202609181200)会直接溢出。
-- 建议公式:主版本*1000000 + 次版本*1000 + 修订号(1.0.10 → 1000010)。
-- 2) 唯一键 uk_platform_version_code (platform, version_code) **不区分逻辑删除**(沿用项目既有约定,
-- 与 bm_device.device_no「逻辑删除后编号不可复用」同口径):记录被逻辑删除后,同平台同构建号
-- **不可再新增**。构建号填错的正确处理是**编辑(PUT)修正**,不是删除重建。
-- 3) file_size 用 bigint(对齐 OTA 先例);框架把 Long 序列化为**字符串**,APP 端需 Number() 后转换展示。
-- 数值由前端从本地文件对象 file.raw.size 取值一并提交(框架上传响应体不含 fileSize)。
-- 4) md5 本期**不采集**(框架上传响应只有 url/fileName/ossId):列保留但允许为空,不参与任何校验。
-- 5) 两平台(Android / iOS)均上传安装包、字段与校验口径一致;App Store 引导地址 store_url 为**选填**,
-- 填了就落库、就在检查更新接口下发,不填存 null。强更对 iOS 仍只能引导、无法真正拦截(系统限制)。
--
-- ★ 执行前必读 · 前置检查(确认表/菜单尚不存在,避免重复执行报错)
-- SELECT COUNT(*) FROM information_schema.tables
-- WHERE table_schema='bird_monitoring' AND table_name='bm_app_version'; -- 期望 0
-- SELECT COUNT(*) FROM sys_menu WHERE menu_id=1771400000000000006; -- 期望 0
--
-- ★ 重复执行本文件时的预期报错(可忽略)
-- ERROR 1050 (42S01): Table 'bm_app_version' already exists
-- ERROR 1062 (23000): Duplicate entry '1771500000000000009' for key 'PRIMARY'(字典类型重复)
-- ERROR 1062 (23000): Duplicate entry '1771600000000000024' for key 'PRIMARY'(字典数据重复)
-- ERROR 1062 (23000): Duplicate entry '1771400000000000006' for key 'PRIMARY'(菜单重复)
-- ERROR 1062 (23000): Duplicate entry '1771300000000000001-1761400000000001601' for key 'PRIMARY'(角色授权重复)
--
-- ★ 影响面提示
-- 仅新增表 / 字典 / 菜单 / 授权,不改动任何既有表结构与既有业务数据。
-- 设备管理员补授 system:oss:upload 后,该角色即可调用框架 POST /resource/oss/upload
-- (此前 403 的根因即缺此权限字,本修复同时惠及既有 OTA 上传页);**只补上传这一条**,
-- 读/删/改配置(system:oss:list/query/remove、system:ossConfig:*)仍属超管职责,不授。
--
-- ★ 回滚
-- DELETE FROM sys_role_menu WHERE menu_id IN (1771400000000000006,1771400000000000022,
-- 1771400000000000023,1771400000000000024,1771400000000000025)
-- OR (role_id=1771300000000000001 AND menu_id=1761400000000001601);
-- DELETE FROM sys_menu WHERE menu_id IN (1771400000000000006,1771400000000000022,
-- 1771400000000000023,1771400000000000024,1771400000000000025);
-- DELETE FROM sys_dict_data WHERE dict_type IN ('bm_app_platform','bm_app_version_status');
-- DELETE FROM sys_dict_type WHERE dict_type IN ('bm_app_platform','bm_app_version_status');
-- DROP TABLE bm_app_version;
-- ----------------------------------------------------------------------------
-- 步骤 1:APP 客户端版本表(第 8 张业务表)
CREATE TABLE IF NOT EXISTS bm_app_version (
version_id bigint(20) NOT NULL COMMENT '主键(雪花)',
platform varchar(16) NOT NULL COMMENT '平台(android/ios,字典 bm_app_platform)',
version_name varchar(32) NOT NULL COMMENT '展示版本号,如 1.0.10(仅展示,不参与比较)',
version_code int(11) NOT NULL COMMENT '构建号,32位正整数、单调递增、平台内唯一(唯一比较依据;禁止时间戳式构建号)',
force_update char(1) NOT NULL DEFAULT '0' COMMENT '强制更新(0否 1是;无字典,页面本地映射)',
oss_id bigint(20) DEFAULT NULL COMMENT '关联 sys_oss.oss_id(安装包,两平台均上传)',
file_url varchar(500) DEFAULT NULL COMMENT '安装包 URL(两平台均上传;冗余自 sys_oss,供 APP 直接下载)',
file_name varchar(255) DEFAULT NULL COMMENT '安装包文件名(两平台均上传)',
file_size bigint(20) DEFAULT NULL COMMENT '安装包字节数(两平台均上传;框架序列化为字符串,前端取 file.raw.size 提交)',
md5 varchar(64) DEFAULT NULL COMMENT '安装包校验值 MD5(预留,本期不填,不参与校验)',
store_url varchar(500) DEFAULT NULL COMMENT 'App Store 引导地址(选填)',
changelog varchar(500) DEFAULT NULL COMMENT '更新说明(纯文本)',
status char(1) NOT NULL DEFAULT '0' COMMENT '状态(0停用 1启用,字典 bm_app_version_status)',
publish_time datetime DEFAULT NULL COMMENT '发布时间(首次置为启用时写入)',
remark varchar(500) DEFAULT NULL COMMENT '备注',
create_dept bigint(20) DEFAULT NULL COMMENT '创建部门',
create_by bigint(20) DEFAULT NULL COMMENT '创建者',
create_time datetime COMMENT '创建时间',
update_by bigint(20) DEFAULT NULL COMMENT '更新者',
update_time datetime COMMENT '更新时间',
del_flag char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除(0存在 1删除)',
PRIMARY KEY (version_id),
UNIQUE KEY uk_platform_version_code (platform, version_code),
UNIQUE KEY uk_platform_version_name (platform, version_name),
KEY idx_platform_status (platform, status),
KEY idx_create_time (create_time)
) ENGINE=InnoDB COMMENT = 'APP客户端版本(后台维护,APP冷启动检查更新)';
-- 步骤 2:字典类型(2 个)
INSERT INTO sys_dict_type VALUES(1771500000000000009, 'APP平台', 'bm_app_platform', 1761000000000000103, 1761100000000000001, sysdate(), NULL, NULL, 'APP平台列表');
INSERT INTO sys_dict_type VALUES(1771500000000000010, 'APP版本状态', 'bm_app_version_status', 1761000000000000103, 1761100000000000001, sysdate(), NULL, NULL, 'APP版本状态列表');
-- 步骤 3:字典数据(4 条)
INSERT INTO sys_dict_data VALUES(1771600000000000024, 1, '安卓', 'android', 'bm_app_platform', '', 'success', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), NULL, NULL, '安卓');
INSERT INTO sys_dict_data VALUES(1771600000000000025, 2, 'iOS', 'ios', 'bm_app_platform', '', 'primary', 'N', 1761000000000000103, 1761100000000000001, sysdate(), NULL, NULL, 'iOS');
INSERT INTO sys_dict_data VALUES(1771600000000000026, 1, '停用', '0', 'bm_app_version_status', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), NULL, NULL, '停用');
INSERT INTO sys_dict_data VALUES(1771600000000000027, 2, '启用', '1', 'bm_app_version_status', '', 'success', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), NULL, NULL, '启用');
-- 步骤 4:菜单 + 权限字(挂「设备管理」目录 1771400000000000001;component 填 src/views/ 之后路径)
INSERT INTO sys_menu VALUES(1771400000000000006, 'APP版本管理', 1771400000000000001, 5, 'app-version', 'device/appVersion/index', '', 'N', 'Y', 'C', '0', '0', 'device:appversion:list', 'list', '', '', 1761000000000000103, 1761100000000000001, sysdate(), NULL, NULL, 'APP版本管理菜单');
INSERT INTO sys_menu VALUES(1771400000000000022, '版本查询', 1771400000000000006, 1, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appversion:query', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), NULL, NULL, '');
INSERT INTO sys_menu VALUES(1771400000000000023, '版本新增', 1771400000000000006, 2, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appversion:add', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), NULL, NULL, '');
INSERT INTO sys_menu VALUES(1771400000000000024, '版本修改', 1771400000000000006, 3, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appversion:edit', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), NULL, NULL, '');
INSERT INTO sys_menu VALUES(1771400000000000025, '版本删除', 1771400000000000006, 4, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appversion:remove', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), NULL, NULL, '');
-- 步骤 5:角色授权
-- 设备管理员:菜单 + 全部 4 个按钮
INSERT INTO sys_role_menu VALUES (1771300000000000001, 1771400000000000006);
INSERT INTO sys_role_menu VALUES (1771300000000000001, 1771400000000000022);
INSERT INTO sys_role_menu VALUES (1771300000000000001, 1771400000000000023);
INSERT INTO sys_role_menu VALUES (1771300000000000001, 1771400000000000024);
INSERT INTO sys_role_menu VALUES (1771300000000000001, 1771400000000000025);
-- 设备管理员:补授框架上传权限字 system:oss:upload
-- 该权限字挂在框架基线菜单 sys_menu.menu_id = 1761400000000001601('文件上传',见 ry_vue.sql:318)。
-- RuoYi 权限 = 角色所映射菜单的 perms 并集,故只需插这一行即可获得上传权,无需挂父菜单。
INSERT INTO sys_role_menu VALUES (1771300000000000001, 1761400000000001601);
-- 审计员:菜单 + 仅 query(不含新增/编辑/删除,符合只读审计定位)
INSERT INTO sys_role_menu VALUES (1771300000000000002, 1771400000000000006);
INSERT INTO sys_role_menu VALUES (1771300000000000002, 1771400000000000022);

View File

@@ -1,7 +1,7 @@
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
-- 智能飞禽物联平台 · 设备域建表脚本(阶段1 / 迭代 I1) -- 智能飞禽物联平台 · 设备域建表脚本(阶段1 / 迭代 I1)
-- 说明: -- 说明:
-- 1. 6 张设备表 + 8 个字典 + 设备菜单 + 8 个权限字 + 2 个角色及其菜单关联。 -- 1. 7 张设备表 + 8 个字典 + 设备菜单 + 12 个权限字 + 2 个角色及其菜单关联。
-- 2. 严禁出现任何直播字段(stream_url / play_url / live_*)与医疗模板字段。 -- 2. 严禁出现任何直播字段(stream_url / play_url / live_*)与医疗模板字段。
-- 3. 逻辑删除取值与框架配置一致:0 存在 / 1 删除(common-mybatis.yml: logicDeleteValue=1)。 -- 3. 逻辑删除取值与框架配置一致:0 存在 / 1 删除(common-mybatis.yml: logicDeleteValue=1)。
-- 4. 唯一索引(device_no / imei)叠加逻辑删除,存在「逻辑删除后编号不可复用」的既有约束, -- 4. 唯一索引(device_no / imei)叠加逻辑删除,存在「逻辑删除后编号不可复用」的既有约束,
@@ -101,12 +101,17 @@ create table bm_device_alert (
alert_level char(1) not null comment '告警级别(0提示 1警告 2严重)', alert_level char(1) not null comment '告警级别(0提示 1警告 2严重)',
alert_value varchar(64) default null comment '触发值(如电量)', alert_value varchar(64) default null comment '触发值(如电量)',
content varchar(255) default null comment '告警文案', content varchar(255) default null comment '告警文案',
status char(1) not null comment '状态(0未处理 1已处理,一期只读展示)', status char(1) not null comment '处理状态(0未处理 1已处理)',
handle_by bigint(20) default null comment '处理人(sys_user.user_id,后台账号)',
handle_time datetime default null comment '处理时间',
message_user_id bigint(20) default null comment '告警发生时的APP收件人快照(用于消息补偿)',
alert_time datetime not null comment '告警时间', alert_time datetime not null comment '告警时间',
del_flag char(1) not null default '0' comment '逻辑删除(0存在 1删除)',
primary key (alert_id), primary key (alert_id),
key idx_device_id (device_id), key idx_device_id (device_id),
key idx_alert_type (alert_type), key idx_alert_type (alert_type),
key idx_alert_time (alert_time) key idx_alert_time (alert_time),
key idx_status_time (status, alert_time)
) engine=innodb comment = '设备告警'; ) engine=innodb comment = '设备告警';
-- ============================ 五、OTA 升级包 ============================ -- ============================ 五、OTA 升级包 ============================
@@ -161,7 +166,36 @@ create table bm_ota_task (
key idx_package_id (package_id) key idx_package_id (package_id)
) engine=innodb comment = 'OTA升级任务'; ) engine=innodb comment = 'OTA升级任务';
-- ============================ 七、字典类型(8 个) ============================ -- ============================ 七、APP 消息表 ============================
-- 用途:仅承载「设备告警」向 APP 用户的推送副本(一期不做独立消息中心,见开发计划 D8)。
-- 设计取舍:
-- 1. 不复用框架 sys_message —— 该表无「已读」字段,无法支撑 APP 未读角标;
-- 且改框架表违反「ruoyi-* 框架不动、业务只在 ruoyi-device」的项目边界。
-- 2. 与 bm_device_alert 职责分离:alert 是「设备事实」(含处理流程),
-- message 是「推送副本」(含收件人与已读状态),二者不互相替代。
-- 3. 收件人在告警落库那一刻固化(取 bm_device.bind_user_id 快照),
-- 因此设备解绑/换绑后,历史消息仍归属当时的收件人,不会被后来者看到。
drop table if exists bm_app_message;
create table bm_app_message (
message_id bigint(20) not null comment '主键(雪花)',
user_id bigint(20) not null comment '接收用户(sys_user.user_id,APP 用户)',
device_id bigint(20) default null comment '关联设备主键',
device_no varchar(32) default null comment '关联设备编号(冗余,供展示与跳转)',
alert_id bigint(20) default null comment '关联告警主键(bm_device_alert)',
alert_type varchar(32) default null comment '告警类型(offline/low_battery_hood/low_battery_ring)',
alert_level char(1) default null comment '告警级别(0提示 1警告 2严重)',
title varchar(128) not null comment '消息标题',
content varchar(255) default null comment '消息内容',
read_flag char(1) not null default '0' comment '已读标记(0未读 1已读)',
read_time datetime default null comment '已读时间',
create_time datetime not null comment '产生时间',
primary key (message_id),
unique key uk_alert_id (alert_id),
key idx_user_read (user_id, read_flag),
key idx_user_time (user_id, create_time, message_id)
) engine=innodb comment = 'APP 消息(一期仅承载设备告警通知)';
-- ============================ 八、字典类型(8 个) ============================
insert into sys_dict_type values(1771500000000000001, '设备状态', 'bm_device_status', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备状态列表'); insert into sys_dict_type values(1771500000000000001, '设备状态', 'bm_device_status', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备状态列表');
insert into sys_dict_type values(1771500000000000002, '信号等级', 'bm_signal_level', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '信号等级列表'); insert into sys_dict_type values(1771500000000000002, '信号等级', 'bm_signal_level', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '信号等级列表');
insert into sys_dict_type values(1771500000000000003, '头套连接', 'bm_hood_connected', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '头套连接列表'); insert into sys_dict_type values(1771500000000000003, '头套连接', 'bm_hood_connected', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '头套连接列表');
@@ -171,7 +205,7 @@ insert into sys_dict_type values(1771500000000000006, '升级包状态', 'bm_o
insert into sys_dict_type values(1771500000000000007, '告警类型', 'bm_alert_type', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '告警类型列表'); insert into sys_dict_type values(1771500000000000007, '告警类型', 'bm_alert_type', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '告警类型列表');
insert into sys_dict_type values(1771500000000000008, '绑定动作', 'bm_bind_action', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '绑定动作列表'); insert into sys_dict_type values(1771500000000000008, '绑定动作', 'bm_bind_action', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '绑定动作列表');
-- ============================ 八、字典数据 ============================ -- ============================ 九、字典数据 ============================
-- 设备状态 -- 设备状态
insert into sys_dict_data values(1771600000000000001, 1, '未激活', '0', 'bm_device_status', '', 'info', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '未激活'); insert into sys_dict_data values(1771600000000000001, 1, '未激活', '0', 'bm_device_status', '', 'info', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '未激活');
insert into sys_dict_data values(1771600000000000002, 2, '在线', '1', 'bm_device_status', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '在线'); insert into sys_dict_data values(1771600000000000002, 2, '在线', '1', 'bm_device_status', '', 'success', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '在线');
@@ -204,7 +238,7 @@ insert into sys_dict_data values(1771600000000000021, 1, '绑定', 'bind', 'bm
insert into sys_dict_data values(1771600000000000022, 2, '解绑', 'unbind', 'bm_bind_action', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '解绑'); insert into sys_dict_data values(1771600000000000022, 2, '解绑', 'unbind', 'bm_bind_action', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '解绑');
insert into sys_dict_data values(1771600000000000023, 3, '换绑', 'rebind', 'bm_bind_action', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '换绑'); insert into sys_dict_data values(1771600000000000023, 3, '换绑', 'rebind', 'bm_bind_action', '', 'warning', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '换绑');
-- ============================ 九、菜单 + 权限字 ============================ -- ============================ 十、菜单 + 权限字 ============================
-- 一级目录 -- 一级目录
insert into sys_menu values(1771400000000000001, '设备管理', 0, 6, 'device', null, '', 'N', 'Y', 'M', '0', '0', '', 'dashboard', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备管理目录'); insert into sys_menu values(1771400000000000001, '设备管理', 0, 6, 'device', null, '', 'N', 'Y', 'M', '0', '0', '', 'dashboard', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备管理目录');
-- 二级菜单 -- 二级菜单
@@ -224,12 +258,18 @@ insert into sys_menu values(1771400000000000004, 'APP用户', 177140000000000000
-- APP 用户按钮(编辑权限字复用:状态变更 / 重置密码 / 解绑设备,口径同 device:ota:add) -- APP 用户按钮(编辑权限字复用:状态变更 / 重置密码 / 解绑设备,口径同 device:ota:add)
insert into sys_menu values(1771400000000000017, '用户查询', 1771400000000000004, 1, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appuser:query', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''); insert into sys_menu values(1771400000000000017, '用户查询', 1771400000000000004, 1, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appuser:query', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
insert into sys_menu values(1771400000000000018, '用户编辑', 1771400000000000004, 2, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appuser:edit', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, ''); insert into sys_menu values(1771400000000000018, '用户编辑', 1771400000000000004, 2, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appuser:edit', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
-- 告警记录(后台只读 + 标记处理;告警的「消息推送」面向 APP 用户,见 bm_app_message)
insert into sys_menu values(1771400000000000005, '告警记录', 1771400000000000001, 4, 'alert', 'device/alert/index', '', 'N', 'Y', 'C', '0', '0', 'device:alert:list', 'message', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '告警记录菜单');
-- 告警记录按钮
insert into sys_menu values(1771400000000000019, '告警查询', 1771400000000000005, 1, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:alert:query', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
insert into sys_menu values(1771400000000000020, '告警处理', 1771400000000000005, 2, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:alert:edit', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
insert into sys_menu values(1771400000000000021, '告警删除', 1771400000000000005, 3, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:alert:remove', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
-- ============================ 十、角色 ============================ -- ============================ 十一、角色 ============================
insert into sys_role values(1771300000000000001, '设备管理员', 'device_admin', 5, 1, 1, 1, '0', '0', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备域管理角色'); insert into sys_role values(1771300000000000001, '设备管理员', 'device_admin', 5, 1, 1, 1, '0', '0', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备域管理角色');
insert into sys_role values(1771300000000000002, '审计员', 'device_auditor', 6, 1, 1, 1, '0', '0', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备域只读审计角色'); insert into sys_role values(1771300000000000002, '审计员', 'device_auditor', 6, 1, 1, 1, '0', '0', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '设备域只读审计角色');
-- ============================ 十一、角色-菜单关联 ============================ -- ============================ 十二、角色-菜单关联 ============================
-- 设备管理员:全部设备菜单 -- 设备管理员:全部设备菜单
insert into sys_role_menu values (1771300000000000001, 1771400000000000001); insert into sys_role_menu values (1771300000000000001, 1771400000000000001);
insert into sys_role_menu values (1771300000000000001, 1771400000000000002); insert into sys_role_menu values (1771300000000000001, 1771400000000000002);
@@ -244,6 +284,11 @@ insert into sys_role_menu values (1771300000000000001, 1771400000000000016);
insert into sys_role_menu values (1771300000000000001, 1771400000000000004); insert into sys_role_menu values (1771300000000000001, 1771400000000000004);
insert into sys_role_menu values (1771300000000000001, 1771400000000000017); insert into sys_role_menu values (1771300000000000001, 1771400000000000017);
insert into sys_role_menu values (1771300000000000001, 1771400000000000018); insert into sys_role_menu values (1771300000000000001, 1771400000000000018);
-- 设备管理员:告警记录(菜单 + 查询 + 处理 + 删除)
insert into sys_role_menu values (1771300000000000001, 1771400000000000005);
insert into sys_role_menu values (1771300000000000001, 1771400000000000019);
insert into sys_role_menu values (1771300000000000001, 1771400000000000020);
insert into sys_role_menu values (1771300000000000001, 1771400000000000021);
-- 审计员:目录 + 列表 + 详情 + 导出 + OTA 列表 + APP 用户(只读) -- 审计员:目录 + 列表 + 详情 + 导出 + OTA 列表 + APP 用户(只读)
insert into sys_role_menu values (1771300000000000002, 1771400000000000001); insert into sys_role_menu values (1771300000000000002, 1771400000000000001);
insert into sys_role_menu values (1771300000000000002, 1771400000000000002); insert into sys_role_menu values (1771300000000000002, 1771400000000000002);
@@ -253,3 +298,80 @@ insert into sys_role_menu values (1771300000000000002, 1771400000000000015);
-- 审计员:APP 用户菜单 + 查询(不含编辑——审计只读) -- 审计员:APP 用户菜单 + 查询(不含编辑——审计只读)
insert into sys_role_menu values (1771300000000000002, 1771400000000000004); insert into sys_role_menu values (1771300000000000002, 1771400000000000004);
insert into sys_role_menu values (1771300000000000002, 1771400000000000017); insert into sys_role_menu values (1771300000000000002, 1771400000000000017);
-- 审计员:告警记录菜单 + 查询(不含处理与删除——审计只读)
insert into sys_role_menu values (1771300000000000002, 1771400000000000005);
insert into sys_role_menu values (1771300000000000002, 1771400000000000019);
-- ============================ 十三、APP 客户端版本(第 8 张业务表) ============================
-- 用途:后台登记 APP(Android/iOS)版本记录,APP 冷启动经 GET /app/version/latest 检查更新。
-- 口径(与 bm_patch.sql 补丁 7 一致):
-- 1. 版本比较**只用整数构建号 version_code**(int,32 位正整数,人工递增),version_name 仅展示;
-- ⚠️ 禁止时间戳式构建号(int 上界 2147483647,时间戳会溢出)。
-- 2. 唯一键 uk_platform_version_code 不区分逻辑删除(沿用 bm_device.device_no 同口径):
-- 逻辑删除后同平台同构建号不可复用,构建号填错走编辑修正。
-- 3. file_size 用 bigint(对齐 OTA);框架把 Long 序列化为字符串,前端按字符串接收再转 MB。
-- 4. md5 列保留但允许为空(本期不采集,不参与校验)。
drop table if exists bm_app_version;
create table bm_app_version (
version_id bigint(20) not null comment '主键(雪花)',
platform varchar(16) not null comment '平台(android/ios,字典 bm_app_platform)',
version_name varchar(32) not null comment '展示版本号,如 1.0.10(仅展示,不参与比较)',
version_code int(11) not null comment '构建号,32位正整数、单调递增、平台内唯一(唯一比较依据;禁止时间戳式构建号)',
force_update char(1) not null default '0' comment '强制更新(0否 1是;无字典,页面本地映射)',
oss_id bigint(20) default null comment '关联 sys_oss.oss_id(安装包,两平台均上传)',
file_url varchar(500) default null comment '安装包 URL(两平台均上传;冗余自 sys_oss,供 APP 直接下载)',
file_name varchar(255) default null comment '安装包文件名(两平台均上传)',
file_size bigint(20) default null comment '安装包字节数(两平台均上传;框架序列化为字符串,前端取 file.raw.size 提交)',
md5 varchar(64) default null comment '安装包校验值 MD5(预留,本期不填,不参与校验)',
store_url varchar(500) default null comment 'App Store 引导地址(选填)',
changelog varchar(500) default null comment '更新说明(纯文本)',
status char(1) not null default '0' comment '状态(0停用 1启用,字典 bm_app_version_status)',
publish_time datetime default null comment '发布时间(首次置为启用时写入)',
remark varchar(500) default null comment '备注',
create_dept bigint(20) default null comment '创建部门',
create_by bigint(20) default null comment '创建者',
create_time datetime comment '创建时间',
update_by bigint(20) default null comment '更新者',
update_time datetime comment '更新时间',
del_flag char(1) not null default '0' comment '逻辑删除(0存在 1删除)',
primary key (version_id),
unique key uk_platform_version_code (platform, version_code),
unique key uk_platform_version_name (platform, version_name),
key idx_platform_status (platform, status),
key idx_create_time (create_time)
) engine=innodb comment = 'APP客户端版本(后台维护,APP冷启动检查更新)';
-- ============================ 十四、字典类型(增量 2 个) ============================
insert into sys_dict_type values(1771500000000000009, 'APP平台', 'bm_app_platform', 1761000000000000103, 1761100000000000001, sysdate(), null, null, 'APP平台列表');
insert into sys_dict_type values(1771500000000000010, 'APP版本状态', 'bm_app_version_status', 1761000000000000103, 1761100000000000001, sysdate(), null, null, 'APP版本状态列表');
-- ============================ 十五、字典数据(增量 4 条) ============================
-- APP 平台(强制更新无字典,页面本地映射)
insert into sys_dict_data values(1771600000000000024, 1, '安卓', 'android', 'bm_app_platform', '', 'success', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '安卓');
insert into sys_dict_data values(1771600000000000025, 2, 'iOS', 'ios', 'bm_app_platform', '', 'primary', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, 'iOS');
-- APP 版本状态
insert into sys_dict_data values(1771600000000000026, 1, '停用', '0', 'bm_app_version_status', '', 'danger', 'N', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '停用');
insert into sys_dict_data values(1771600000000000027, 2, '启用', '1', 'bm_app_version_status', '', 'success', 'Y', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '启用');
-- ============================ 十六、菜单 + 权限字(APP版本管理) ============================
-- 二级菜单(挂「设备管理」目录 1771400000000000001,order_num=5)
insert into sys_menu values(1771400000000000006, 'APP版本管理', 1771400000000000001, 5, 'app-version', 'device/appVersion/index', '', 'N', 'Y', 'C', '0', '0', 'device:appversion:list', 'list', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, 'APP版本管理菜单');
-- 按钮
insert into sys_menu values(1771400000000000022, '版本查询', 1771400000000000006, 1, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appversion:query', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
insert into sys_menu values(1771400000000000023, '版本新增', 1771400000000000006, 2, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appversion:add', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
insert into sys_menu values(1771400000000000024, '版本修改', 1771400000000000006, 3, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appversion:edit', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
insert into sys_menu values(1771400000000000025, '版本删除', 1771400000000000006, 4, '', '', '', 'N', 'Y', 'F', '0', '0', 'device:appversion:remove', '#', '', '', 1761000000000000103, 1761100000000000001, sysdate(), null, null, '');
-- ============================ 十七、角色-菜单关联(APP 版本) ============================
-- 设备管理员:菜单 + 全部按钮
insert into sys_role_menu values (1771300000000000001, 1771400000000000006);
insert into sys_role_menu values (1771300000000000001, 1771400000000000022);
insert into sys_role_menu values (1771300000000000001, 1771400000000000023);
insert into sys_role_menu values (1771300000000000001, 1771400000000000024);
insert into sys_role_menu values (1771300000000000001, 1771400000000000025);
-- 设备管理员:补授框架上传权限字 system:oss:upload(menu_id=1761400000000001601 '文件上传',见 ry_vue.sql:318)
-- 只补上传这一条,读/删/改配置属超管职责,不授。
insert into sys_role_menu values (1771300000000000001, 1761400000000001601);
-- 审计员:菜单 + 仅 query(只读)
insert into sys_role_menu values (1771300000000000002, 1771400000000000006);
insert into sys_role_menu values (1771300000000000002, 1771400000000000022);