新视频推送接收(未测) app升级 告警 国际短信跟谷歌邮箱接入
This commit is contained in:
@@ -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/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用
|
||||||
|
|||||||
@@ -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/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用
|
||||||
|
|||||||
@@ -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(仅用于事后定位「回执晚到」)
|
||||||
|
|||||||
@@ -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}分钟
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}分钟
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("国际短信发送异常");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package org.dromara.device.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MJPEG 直传直播配置(前缀 {@code mjpeg})。
|
||||||
|
* <p>
|
||||||
|
* 开启后设备按 HTTP POST JPEG 推流,APP 按 multipart MJPEG 拉流,不再经 ZLM。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "mjpeg")
|
||||||
|
public class MjpegProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否启用 MJPEG 直传。关闭时直播仍走 ZLM RTMP。
|
||||||
|
*/
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备与播放端可访问的公网基址,例如 {@code http://47.104.136.221:8081}
|
||||||
|
*/
|
||||||
|
private String publicHost;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 播放端推帧频率
|
||||||
|
*/
|
||||||
|
private int fps = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 超过该秒数未收到新帧则视为不在线
|
||||||
|
*/
|
||||||
|
private int frameTtlSeconds = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开播后持续无人观看超过该秒数,则 MQTT 通知设备停推
|
||||||
|
*/
|
||||||
|
private int idleStopSeconds = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单帧 JPEG 上限(字节)
|
||||||
|
*/
|
||||||
|
private int maxFrameBytes = 1_048_576;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -14,9 +14,9 @@ public final class AppConstants {
|
|||||||
// ---------------- 校验正则 ----------------
|
// ---------------- 校验正则 ----------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手机号(中国大陆 11 位)。
|
* 手机号:大陆 11 位,或 {@code +}/{@code 00} + 国家码 + 号码(国际短信)。
|
||||||
*/
|
*/
|
||||||
public static final String PHONE_REGEX = "^1[3-9]\\d{9}$";
|
public static final String PHONE_REGEX = "^(?:(?:\\+|00)86)?1[3-9]\\d{9}$|^(?:\\+|00)[1-9]\\d{6,14}$";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 密码:8-20 位,必须同时包含大写字母、小写字母和数字(不强制特殊字符,与原型一致)。
|
* 密码:8-20 位,必须同时包含大写字母、小写字母和数字(不强制特殊字符,与原型一致)。
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package org.dromara.device.constant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 客户端版本模块常量的集中管理。
|
||||||
|
* <p>
|
||||||
|
* 命名与文案对齐架构设计 §9.4。前 8 个为「错误 / 提示文案」,后两个为「强制更新取值常量」
|
||||||
|
* (无字典、页面本地映射,见架构 §9.1)。新增 {@code VERSION_NAME_REQUIRED} /
|
||||||
|
* {@code RECORD_NOT_EXIST} 两条为服务端侧的必要校验文案(设计文档未显式列出,已在此统一维护)。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
public final class AppVersionMsgConstants {
|
||||||
|
|
||||||
|
private AppVersionMsgConstants() {
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 校验 / 提示文案 ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台参数不合法(APP 检查更新 / 后台登记 均适用)。
|
||||||
|
*/
|
||||||
|
public static final String PLATFORM_INVALID = "平台参数不合法";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 版本号(构建号)不合法(非正整数)。
|
||||||
|
*/
|
||||||
|
public static final String VERSION_CODE_INVALID = "版本号不合法";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展示版本号不能为空(后台登记 / 修改必填)。
|
||||||
|
*/
|
||||||
|
public static final String VERSION_NAME_REQUIRED = "版本号不能为空";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同平台构建号已存在。
|
||||||
|
* <p>对齐 DB 唯一键 {@code uk_platform_version_code (platform, version_code)}(<b>不区分逻辑删除行</b>)。
|
||||||
|
*/
|
||||||
|
public static final String VERSION_CODE_DUPLICATE = "该平台下构建号已存在";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同平台展示版本号已存在(应用层预检,避免两个构建号挂同一展示版本号)。
|
||||||
|
*/
|
||||||
|
public static final String VERSION_NAME_DUPLICATE = "该平台下版本号已存在";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建号必须大于该平台已有最大值,{@code %d} 为当前最大值。
|
||||||
|
*/
|
||||||
|
public static final String VERSION_CODE_NOT_GREATER = "构建号必须大于该平台已有最大值(当前 %d)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 版本必须上传安装包(Android / iOS 两平台一致)。
|
||||||
|
*/
|
||||||
|
public static final String PACKAGE_REQUIRED = "版本必须上传安装包";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台不可修改(修改时请求平台与库中不一致)。
|
||||||
|
*/
|
||||||
|
public static final String PLATFORM_IMMUTABLE = "平台不可修改";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 版本记录不存在(修改前置校验)。
|
||||||
|
*/
|
||||||
|
public static final String RECORD_NOT_EXIST = "版本记录不存在";
|
||||||
|
|
||||||
|
// ---------------- 取值常量(非文案) ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制更新:是({@code force_update='1'},无字典,页面本地映射)。
|
||||||
|
*/
|
||||||
|
public static final String FORCE_UPDATE_YES = "1";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制更新:否({@code force_update='0'},无字典,页面本地映射)。
|
||||||
|
*/
|
||||||
|
public static final String FORCE_UPDATE_NO = "0";
|
||||||
|
|
||||||
|
}
|
||||||
@@ -27,6 +27,11 @@ public final class DeviceMqttConstants {
|
|||||||
*/
|
*/
|
||||||
public static final String ONLINE_SCAN_LOCK = "bm:mqtt:online-scan:lock";
|
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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册结果码:成功
|
* 注册结果码:成功
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 直播地址未配置";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户端自定义拉流已停用
|
* 客户端自定义拉流已停用
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package org.dromara.device.controller;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.dromara.common.core.domain.PageResult;
|
||||||
|
import org.dromara.common.core.domain.R;
|
||||||
|
import org.dromara.common.log.annotation.Log;
|
||||||
|
import org.dromara.common.log.enums.BusinessType;
|
||||||
|
import org.dromara.common.web.core.BaseController;
|
||||||
|
import org.dromara.device.domain.bo.AlertBo;
|
||||||
|
import org.dromara.device.domain.bo.AlertHandleBo;
|
||||||
|
import org.dromara.device.domain.vo.AlertStatsVo;
|
||||||
|
import org.dromara.device.domain.vo.AlertVo;
|
||||||
|
import org.dromara.device.service.IAlertService;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台告警记录 Controller(前缀 {@code /device/alert})。
|
||||||
|
* <p>
|
||||||
|
* 接口清单:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code GET /device/alert/list} —— 分页列表(含设备名称回填)</li>
|
||||||
|
* <li>{@code GET /device/alert/stats} —— 顶部统计(总数 / 未处理 / 已处理)</li>
|
||||||
|
* <li>{@code PUT /device/alert/handle} —— 标记已处理(单条与批量共用,幂等)</li>
|
||||||
|
* <li>{@code DELETE /device/alert/{ids}} —— 批量删除(逻辑删除,保留审计痕迹)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* <b>本页只管理「设备事实」,不管理「消息推送」</b>:告警的 APP 消息推送副本记录在
|
||||||
|
* {@code bm_app_message},由 APP 侧 {@code /app/message/**} 读取,
|
||||||
|
* 后台<b>不提供</b>消息的重发/撤回能力。二者是「事实」与「通知」的关系,不互相替代。
|
||||||
|
* <p>
|
||||||
|
* ⚠️ <b>与设备模块一致:本组接口没有部门数据权限隔离</b>——
|
||||||
|
* {@code BmDeviceAlertMapper} 未标注 {@code @DataPermission},
|
||||||
|
* 因此任何持有 {@code device:alert:list} 的账号看到的都是<b>全量告警</b>。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Validated
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/device/alert")
|
||||||
|
public class AlertController extends BaseController {
|
||||||
|
|
||||||
|
private final IAlertService alertService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询告警记录。
|
||||||
|
* <p>
|
||||||
|
* 默认排序为「未处理优先 + 告警时间倒序」。
|
||||||
|
*/
|
||||||
|
@SaCheckPermission("device:alert:list")
|
||||||
|
@GetMapping("/list")
|
||||||
|
public R<PageResult<AlertVo>> list(@Validated AlertBo bo) {
|
||||||
|
return R.ok(alertService.queryPageList(bo));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计告警总数 / 未处理 / 已处理(顶部统计条)。
|
||||||
|
*/
|
||||||
|
@SaCheckPermission("device:alert:list")
|
||||||
|
@GetMapping("/stats")
|
||||||
|
public R<AlertStatsVo> stats() {
|
||||||
|
return R.ok(alertService.stats());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记告警为已处理。
|
||||||
|
* <p>
|
||||||
|
* 单条与批量共用本接口:标记单条时 {@code alertIds} 长度为 1。
|
||||||
|
* 幂等——重复点击不报错,也不会覆盖首次的处理人与处理时间。
|
||||||
|
*/
|
||||||
|
@SaCheckPermission("device:alert:edit")
|
||||||
|
@Log(title = "告警记录", businessType = BusinessType.UPDATE)
|
||||||
|
@PutMapping("/handle")
|
||||||
|
public R<Void> handle(@Validated @RequestBody AlertHandleBo bo) {
|
||||||
|
alertService.handle(bo);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除告警记录(逻辑删除)。
|
||||||
|
*/
|
||||||
|
@SaCheckPermission("device:alert:remove")
|
||||||
|
@Log(title = "告警记录", businessType = BusinessType.DELETE)
|
||||||
|
@DeleteMapping("/{ids}")
|
||||||
|
public R<Void> remove(@PathVariable("ids") Long[] ids) {
|
||||||
|
alertService.remove(Arrays.asList(ids));
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package org.dromara.device.controller;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.dromara.common.core.domain.PageResult;
|
||||||
|
import org.dromara.common.core.domain.R;
|
||||||
|
import org.dromara.common.log.annotation.Log;
|
||||||
|
import org.dromara.common.log.enums.BusinessType;
|
||||||
|
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||||
|
import org.dromara.common.web.core.BaseController;
|
||||||
|
import org.dromara.device.domain.bo.AppVersionBo;
|
||||||
|
import org.dromara.device.domain.vo.AppVersionVo;
|
||||||
|
import org.dromara.device.service.IAppVersionService;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 客户端版本 Controller(后台,前缀 {@code /device/app-version})。
|
||||||
|
* <p>
|
||||||
|
* 接口清单(架构 §6.1):
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code GET /device/app-version/list} —— 版本列表(默认按构建号倒序)</li>
|
||||||
|
* <li>{@code GET /device/app-version/{versionId}} —— 版本详情</li>
|
||||||
|
* <li>{@code POST /device/app-version} —— 登记版本</li>
|
||||||
|
* <li>{@code PUT /device/app-version} —— 修改版本(含启用 / 停用;平台不可改)</li>
|
||||||
|
* <li>{@code DELETE /device/app-version/{ids}} —— 删除版本(逻辑删除)</li>
|
||||||
|
* </ul>
|
||||||
|
* 后台接口一律 {@code @SaCheckPermission},变更类加 {@code @Log} 进操作日志。
|
||||||
|
* 上传安装包走框架 {@code POST /resource/oss/upload}(权限字 {@code system:oss:upload}),本控制器不承担上传。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Validated
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/device/app-version")
|
||||||
|
public class AppVersionController extends BaseController {
|
||||||
|
|
||||||
|
private final IAppVersionService appVersionService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询版本列表。
|
||||||
|
*
|
||||||
|
* @param bo 查询条件(keyword / platform / status / forceUpdate / 发布时间区间)
|
||||||
|
* @param pageQuery 分页参数
|
||||||
|
* @return 版本分页结果
|
||||||
|
*/
|
||||||
|
@SaCheckPermission("device:appversion:list")
|
||||||
|
@GetMapping("/list")
|
||||||
|
public R<PageResult<AppVersionVo>> list(AppVersionBo bo, PageQuery pageQuery) {
|
||||||
|
return R.ok(appVersionService.queryPageList(bo, pageQuery));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询版本详情。
|
||||||
|
*
|
||||||
|
* @param versionId 版本主键
|
||||||
|
* @return 版本视图对象(不存在返回 {@code null})
|
||||||
|
*/
|
||||||
|
@SaCheckPermission("device:appversion:query")
|
||||||
|
@GetMapping("/{versionId}")
|
||||||
|
public R<AppVersionVo> getInfo(@PathVariable("versionId") Long versionId) {
|
||||||
|
AppVersionVo vo = appVersionService.queryById(versionId);
|
||||||
|
return R.ok(vo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登记版本。
|
||||||
|
*
|
||||||
|
* @param bo 版本业务对象
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@SaCheckPermission("device:appversion:add")
|
||||||
|
@Log(title = "APP版本", businessType = BusinessType.INSERT)
|
||||||
|
@PostMapping
|
||||||
|
public R<Void> add(@RequestBody AppVersionBo bo) {
|
||||||
|
return toAjax(appVersionService.insertByBo(bo));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改版本(含启用 / 停用;平台不可改)。
|
||||||
|
*
|
||||||
|
* @param bo 版本业务对象(含 versionId)
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@SaCheckPermission("device:appversion:edit")
|
||||||
|
@Log(title = "APP版本", businessType = BusinessType.UPDATE)
|
||||||
|
@PutMapping
|
||||||
|
public R<Void> edit(@RequestBody AppVersionBo bo) {
|
||||||
|
return toAjax(appVersionService.updateByBo(bo));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除版本(逻辑删除)。
|
||||||
|
* <p>
|
||||||
|
* 唯一键不区分逻辑删除,删除后同平台同构建号<b>不可复用</b>(构建号填错走编辑修正)。
|
||||||
|
*
|
||||||
|
* @param ids 版本主键数组(路径中以逗号分隔,如 {@code /device/app-version/1,2,3})
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@SaCheckPermission("device:appversion:remove")
|
||||||
|
@Log(title = "APP版本", businessType = BusinessType.DELETE)
|
||||||
|
@DeleteMapping("/{ids}")
|
||||||
|
public R<Void> remove(@PathVariable("ids") Long[] ids) {
|
||||||
|
return toAjax(appVersionService.deleteByIds(Arrays.asList(ids)));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package org.dromara.device.controller.app;
|
|||||||
import cn.dev33.satoken.annotation.SaIgnore;
|
import 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package org.dromara.device.controller.app;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.dromara.common.core.domain.PageResult;
|
||||||
|
import org.dromara.common.core.domain.R;
|
||||||
|
import org.dromara.common.web.core.BaseController;
|
||||||
|
import org.dromara.device.domain.bo.AppMessageQueryBo;
|
||||||
|
import org.dromara.device.domain.vo.AppMessageVo;
|
||||||
|
import org.dromara.device.service.IAppMessageService;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 消息 Controller(前缀 {@code /app/message})。
|
||||||
|
* <p>
|
||||||
|
* 接口清单:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code GET /app/message/list} —— 消息分页列表(按产生时间倒序)</li>
|
||||||
|
* <li>{@code GET /app/message/unread-count} —— 未读数(供消息角标)</li>
|
||||||
|
* <li>{@code PUT /app/message/{messageId}/read} —— 标记单条已读</li>
|
||||||
|
* <li>{@code PUT /app/message/read-all} —— 全部标记已读</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 本组接口需登录态,当前用户取自 {@code LoginHelper.getUserId()},
|
||||||
|
* <b>仅能读写本人消息</b>,不做后台数据权限、不加 {@code @SaCheckPermission}
|
||||||
|
* (口径与 {@code /app/device/**}、{@code /app/user/**} 一致)。
|
||||||
|
* <p>
|
||||||
|
* 一期消息内容<b>只有设备告警</b>(开发计划 D8 修订):离线、头套低电、脚环低电。
|
||||||
|
* 不具备「已送达/已撤回」等推送状态,也不支持后台重发。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Validated
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/app/message")
|
||||||
|
public class AppMessageController extends BaseController {
|
||||||
|
|
||||||
|
private final IAppMessageService appMessageService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询本人消息(按产生时间倒序)。
|
||||||
|
* <p>
|
||||||
|
* 未传 {@code pageNum} / {@code pageSize} 时服务端兜底为每页 20 条,
|
||||||
|
* 上限 100 条(详见 {@code AppMessageServiceImpl})。
|
||||||
|
*
|
||||||
|
* @param bo 查询条件({@code readFlag} 可筛选未读 / 已读,留空返回全部)
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
public R<PageResult<AppMessageVo>> list(@Validated AppMessageQueryBo bo) {
|
||||||
|
return R.ok(appMessageService.queryPageList(bo));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询本人未读消息数(供消息角标)。
|
||||||
|
*/
|
||||||
|
@GetMapping("/unread-count")
|
||||||
|
public R<Long> unreadCount() {
|
||||||
|
return R.ok(appMessageService.unreadCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记单条消息为已读。
|
||||||
|
* <p>
|
||||||
|
* 幂等:重复调用不报错。消息不属于本人时同样返回成功(不做存在性暴露)。
|
||||||
|
*/
|
||||||
|
@PutMapping("/{messageId}/read")
|
||||||
|
public R<Void> markRead(@PathVariable("messageId") Long messageId) {
|
||||||
|
appMessageService.markRead(messageId);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把本人所有未读消息标记为已读。
|
||||||
|
*/
|
||||||
|
@PutMapping("/read-all")
|
||||||
|
public R<Void> markAllRead() {
|
||||||
|
appMessageService.markAllRead();
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package org.dromara.device.controller.app;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaIgnore;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.dromara.common.core.domain.R;
|
||||||
|
import org.dromara.common.web.core.BaseController;
|
||||||
|
import org.dromara.device.domain.bo.AppVersionCheckBo;
|
||||||
|
import org.dromara.device.domain.vo.AppVersionLatestVo;
|
||||||
|
import org.dromara.device.service.IAppVersionService;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 客户端版本 Controller(APP 端,前缀 {@code /app/version})。
|
||||||
|
* <p>
|
||||||
|
* 接口清单(架构 §6.2):
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code GET /app/version/latest} —— 冷启动检查更新(<b>免登录</b>)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* {@link SaIgnore} 放行登录校验:版本检查发生在 APP 冷启动最早阶段,此时用户可能尚未登录,
|
||||||
|
* 且强制更新必须在登录前就能拦住,否则未登录用户可绕过强更。实现侧({@code checkLatest})
|
||||||
|
* <b>不读取登录态</b>(不得使用 {@code LoginHelper}),否则免登录会 NPE。
|
||||||
|
* <p>
|
||||||
|
* APP 请求头 {@code clientid} 必须为 {@code app}(框架与 token 内扩展做等值比对)。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@SaIgnore
|
||||||
|
@Validated
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/app/version")
|
||||||
|
public class AppVersionAppController extends BaseController {
|
||||||
|
|
||||||
|
private final IAppVersionService appVersionService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查更新。
|
||||||
|
*
|
||||||
|
* @param bo 入参(platform / versionCode 必填,其余回显)
|
||||||
|
* @return 检查更新结果(三态:无更新 / 可选更新 / 强制更新)
|
||||||
|
*/
|
||||||
|
@GetMapping("/latest")
|
||||||
|
public R<AppVersionLatestVo> latest(@Validated AppVersionCheckBo bo) {
|
||||||
|
return R.ok(appVersionService.checkLatest(bo));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package org.dromara.device.domain;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 消息对象 bm_app_message
|
||||||
|
* <p>
|
||||||
|
* 一期仅承载「设备告警」向 APP 用户的推送副本(开发计划 D8 修订)。
|
||||||
|
* <p>
|
||||||
|
* 与 {@link BmDeviceAlert} 的分工:<b>告警是设备事实</b>(含后台处理流程),
|
||||||
|
* <b>消息是推送副本</b>(含收件人与已读状态)。同一设备解绑/换绑后,
|
||||||
|
* 历史消息仍归属告警发生时的收件人({@code user_id} 为快照),不会被后来者看到。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = false)
|
||||||
|
@TableName("bm_app_message")
|
||||||
|
public class BmAppMessage implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键(雪花)
|
||||||
|
*/
|
||||||
|
@TableId(value = "message_id")
|
||||||
|
private Long messageId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接收用户(sys_user.user_id,APP 用户)
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联设备主键
|
||||||
|
*/
|
||||||
|
private Long deviceId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联设备编号(冗余,供展示与跳转)
|
||||||
|
*/
|
||||||
|
private String deviceNo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联告警主键(bm_device_alert)
|
||||||
|
*/
|
||||||
|
private Long alertId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警类型(offline / low_battery_hood / low_battery_ring)
|
||||||
|
*/
|
||||||
|
private String alertType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警级别(0 提示 1 警告 2 严重)
|
||||||
|
*/
|
||||||
|
private String alertLevel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息标题
|
||||||
|
*/
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息内容
|
||||||
|
*/
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已读标记(0 未读 1 已读)
|
||||||
|
*/
|
||||||
|
private String readFlag;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已读时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime readTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 产生时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package org.dromara.device.domain;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 客户端版本对象 bm_app_version
|
||||||
|
* <p>
|
||||||
|
* 后台维护、APP 冷启动检查更新({@code GET /app/version/latest})的数据源。
|
||||||
|
* 业务上与设备 OTA 固件升级<b>完全独立</b>(不共表、不共权限字、不共页面),二者仅共用「上传包到 OSS」这一技术手段。
|
||||||
|
* <p>
|
||||||
|
* 关键类型口径:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code versionCode} 为 {@link Integer}(DB {@code int},32 位正整数)——唯一比较依据。
|
||||||
|
* 用 {@code Integer} 而非 {@code Long}:框架把 {@code Long} 序列化为<b>字符串</b>、把
|
||||||
|
* {@code Integer} 序列化为<b>数字</b>,而 APP 契约要求 {@code latestVersionCode} /
|
||||||
|
* {@code currentVersionCode} 为 JSON 数字。⚠️ 构建号人工递增,<b>禁止时间戳式构建号</b>(会溢出)。</li>
|
||||||
|
* <li>{@code ossId} / {@code fileSize} 为 {@link Long}(雪花主键 / 字节数),JSON 中表现为字符串。</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
@TableName("bm_app_version")
|
||||||
|
public class BmAppVersion extends BaseEntity {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键(雪花)
|
||||||
|
*/
|
||||||
|
@TableId(value = "version_id")
|
||||||
|
private Long versionId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台(android / ios,字典 bm_app_platform)
|
||||||
|
*/
|
||||||
|
private String platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展示版本号,如 1.0.10(仅展示,不参与比较)
|
||||||
|
*/
|
||||||
|
private String versionName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建号,32 位正整数、单调递增、平台内唯一(唯一比较依据)
|
||||||
|
*/
|
||||||
|
private Integer versionCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制更新(0 否 1 是;无字典,页面本地映射)
|
||||||
|
*/
|
||||||
|
private String forceUpdate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联 sys_oss.oss_id(安装包,两平台均上传)
|
||||||
|
*/
|
||||||
|
private Long ossId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包 URL(两平台均上传;冗余自 sys_oss,供 APP 直接下载)
|
||||||
|
*/
|
||||||
|
private String fileUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包文件名(两平台均上传)
|
||||||
|
*/
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包字节数(两平台均上传)
|
||||||
|
*/
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包校验值 MD5(预留,本期不填,不参与校验)
|
||||||
|
*/
|
||||||
|
private String md5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App Store 引导地址(选填)
|
||||||
|
*/
|
||||||
|
private String storeUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新说明(纯文本)
|
||||||
|
*/
|
||||||
|
private String changelog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态(0 停用 1 启用,字典 bm_app_version_status)
|
||||||
|
*/
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布时间(首次置为启用时写入)
|
||||||
|
*/
|
||||||
|
private LocalDateTime publishTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备注
|
||||||
|
*/
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逻辑删除(0 存在 1 删除)
|
||||||
|
*/
|
||||||
|
@TableLogic
|
||||||
|
private String delFlag;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.dromara.device.domain;
|
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;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package org.dromara.device.domain.bo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备告警查询入参({@code GET /device/alert/list})。
|
||||||
|
* <p>
|
||||||
|
* 分页字段({@code pageNum} / {@code pageSize} / {@code orderByColumn} / {@code isAsc})
|
||||||
|
* 继承自 {@link PageQuery},与框架其余列表页口径一致。
|
||||||
|
* <p>
|
||||||
|
* <b>日期区间经 {@link #params} 传递</b>:前端用 {@code useDateRangeQuery} hook
|
||||||
|
* (内部 {@code utils/ruoyi.ts#addDateRange})把区间写为 {@code params[beginTime]} /
|
||||||
|
* {@code params[endTime]},与框架既有约定一致。区间按 {@code alert_time} 过滤。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class AlertBo extends PageQuery {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备编号(精确匹配;告警表内为冗余快照,不随设备改名变化)
|
||||||
|
*/
|
||||||
|
private String deviceNo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警类型(offline / low_battery_hood / low_battery_ring,精确匹配)
|
||||||
|
*/
|
||||||
|
private String alertType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警级别(0 提示 1 警告 2 严重,精确匹配)
|
||||||
|
*/
|
||||||
|
private String alertLevel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理状态(0 未处理 1 已处理,精确匹配)
|
||||||
|
*/
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关键词:模糊匹配设备编号 / 告警内容(两字段 OR)
|
||||||
|
*/
|
||||||
|
private String keyword;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扩展查询参数(约定键 {@code beginTime} / {@code endTime}:告警时间区间)
|
||||||
|
*/
|
||||||
|
private Map<String, Object> params = new HashMap<>();
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package org.dromara.device.domain.bo;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警标记处理入参({@code PUT /device/alert/handle})。
|
||||||
|
* <p>
|
||||||
|
* 单条与批量共用同一入参:标记单条时列表长度即为 1。
|
||||||
|
* 之所以不拆两个接口,是因为二者语义完全一致(把 {@code status} 由 0 置 1),
|
||||||
|
* 拆开只会产生两份重复的校验与落库逻辑。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class AlertHandleBo implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待处理的告警主键集合
|
||||||
|
*/
|
||||||
|
@NotEmpty(message = "请至少选择一条告警")
|
||||||
|
private List<Long> alertIds;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ public class AppLoginBo implements Serializable {
|
|||||||
private static final long serialVersionUID = 1L;
|
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 = "请输入正确的手机号")
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package org.dromara.device.domain.bo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 消息查询入参({@code GET /app/message/list})。
|
||||||
|
* <p>
|
||||||
|
* 分页字段继承自 {@link PageQuery}。与后台列表不同,<b>APP 端不传分页参数时由服务端兜底为
|
||||||
|
* 每页 20 条</b>(而非框架默认的「查全部」),理由见 {@code AppMessageServiceImpl#buildPage}:
|
||||||
|
* 消息是持续累积的数据,APP 首屏一次拉 1000 条既浪费流量也拖慢渲染。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class AppMessageQueryBo extends PageQuery {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已读标记筛选(0 未读 1 已读);留空返回全部
|
||||||
|
*/
|
||||||
|
private String readFlag;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ public class AppPhoneBo implements Serializable {
|
|||||||
private static final long serialVersionUID = 1L;
|
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 = "请输入正确的手机号")
|
||||||
|
|||||||
@@ -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 = "请输入正确的手机号")
|
||||||
|
|||||||
@@ -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 = "请输入正确的手机号")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package org.dromara.device.domain.bo;
|
||||||
|
|
||||||
|
import io.github.linpeilie.annotations.AutoMapper;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.dromara.device.domain.BmAppVersion;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 客户端版本业务对象 bm_app_version
|
||||||
|
* <p>
|
||||||
|
* 后台「APP版本管理」的增 / 改 / 查询入参。与实体 {@link BmAppVersion} 自动映射
|
||||||
|
* ({@code @AutoMapper}),字段名与前端 {@code types.ts} 一一对齐。
|
||||||
|
* <p>
|
||||||
|
* ⚠️ <b>写模型铁律</b>:本 BO 仅承载「运营可提交」的字段,<b>绝不夹带运行态 / 只读字段</b>
|
||||||
|
* (如 {@code publishTime} / {@code delFlag} / 审计字段 / 详情回显字段)。
|
||||||
|
* 原因:{@code updateByBo} 走 MyBatis-Plus 默认 {@code NOT_NULL} 策略,空串会被当作有效值写库,
|
||||||
|
* 历史上曾因此把在线设备静默改成未激活。只读字段一律由服务端自行维护。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@AutoMapper(target = BmAppVersion.class, reverseConvertGenerate = false)
|
||||||
|
public class AppVersionBo implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键(新增留空;修改必填)
|
||||||
|
*/
|
||||||
|
private Long versionId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台(android / ios);新增必填,修改时<b>不可变更</b>
|
||||||
|
*/
|
||||||
|
private String platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展示版本号,如 1.0.10(必填,≤32 字符)
|
||||||
|
*/
|
||||||
|
private String versionName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建号,32 位正整数;必须大于同平台已有最大值(含停用记录)
|
||||||
|
*/
|
||||||
|
private Integer versionCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制更新(0 否 1 是)
|
||||||
|
*/
|
||||||
|
private String forceUpdate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联 sys_oss.oss_id(安装包,两平台均必传)
|
||||||
|
*/
|
||||||
|
private Long ossId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包 URL(两平台均必传)
|
||||||
|
*/
|
||||||
|
private String fileUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包文件名(两平台均上传)
|
||||||
|
*/
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包字节数(两平台均上传;由前端从本地文件对象 file.raw.size 提交)
|
||||||
|
*/
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包校验值 MD5(预留,本期不填)
|
||||||
|
*/
|
||||||
|
private String md5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App Store 引导地址(选填)
|
||||||
|
*/
|
||||||
|
private String storeUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新说明(纯文本)
|
||||||
|
*/
|
||||||
|
private String changelog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态(0 停用 1 启用)
|
||||||
|
*/
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备注
|
||||||
|
*/
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关键字(模糊匹配 versionName,查询用)
|
||||||
|
*/
|
||||||
|
private String keyword;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扩展查询参数(约定键 {@code beginTime} / {@code endTime}:发布时间区间)
|
||||||
|
*/
|
||||||
|
private Map<String, Object> params = new HashMap<>();
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package org.dromara.device.domain.bo;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 检查更新入参({@code GET /app/version/latest})。
|
||||||
|
* <p>
|
||||||
|
* 仅 {@code platform} 与 {@code versionCode} 为判定核心输入,其余为回显型(本期<b>不落库、不埋点</b>)。
|
||||||
|
* 校验注解的 {@code message} 刻意与 {@code AppVersionMsgConstants} 的文案逐字一致,
|
||||||
|
* 使「Controller 层 {@code @Validated} 拦下」与「Service 层再判」两种路径返回同一提示。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class AppVersionCheckBo implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台(android / ios,必填)
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "平台参数不合法")
|
||||||
|
private String platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 当前构建号(必填,正整数)
|
||||||
|
*/
|
||||||
|
@NotNull(message = "版本号不合法")
|
||||||
|
@Positive(message = "版本号不合法")
|
||||||
|
private Integer versionCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 当前展示版本号(可选,本期仅回显)
|
||||||
|
*/
|
||||||
|
private String versionName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机型(可选,本期仅回显)
|
||||||
|
*/
|
||||||
|
private String deviceModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统版本(可选,本期仅回显)
|
||||||
|
*/
|
||||||
|
private String osVersion;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package org.dromara.device.domain.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警统计视图对象({@code GET /device/alert/stats})
|
||||||
|
* <p>
|
||||||
|
* 供后台列表页顶部统计条使用。三个计数口径互相独立:
|
||||||
|
* {@code total} 为该筛选条件下的全量,{@code unhandled + handled} 应恒等于 {@code total}。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class AlertStatsVo implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警总数
|
||||||
|
*/
|
||||||
|
private Long total;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未处理数(status = 0)
|
||||||
|
*/
|
||||||
|
private Long unhandled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已处理数(status = 1)
|
||||||
|
*/
|
||||||
|
private Long handled;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package org.dromara.device.domain.vo;
|
||||||
|
|
||||||
|
import io.github.linpeilie.annotations.AutoMapper;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated;
|
||||||
|
import org.apache.fesod.sheet.annotation.ExcelProperty;
|
||||||
|
import org.apache.fesod.sheet.annotation.format.DateTimeFormat;
|
||||||
|
import org.dromara.common.translation.annotation.Translation;
|
||||||
|
import org.dromara.common.translation.constant.TransConstant;
|
||||||
|
import org.dromara.device.domain.BmDeviceAlert;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备告警视图对象 bm_device_alert
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ExcelIgnoreUnannotated
|
||||||
|
@AutoMapper(target = BmDeviceAlert.class)
|
||||||
|
public class AlertVo implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "告警主键")
|
||||||
|
private Long alertId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备主键
|
||||||
|
*/
|
||||||
|
private Long deviceId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备编号
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "设备编号")
|
||||||
|
private String deviceNo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备名称
|
||||||
|
* <p>
|
||||||
|
* 冗余自 {@code bm_device.name}(告警表本身不存名称),由服务层按 {@code deviceNo} 批量回填。
|
||||||
|
* 设备已被物理删除或名称缺失时为 {@code null},前端降级展示设备编号。
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "设备名称")
|
||||||
|
private String deviceName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警类型(offline / low_battery_hood / low_battery_ring)
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "告警类型")
|
||||||
|
private String alertType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警级别(0 提示 1 警告 2 严重)
|
||||||
|
* <p>
|
||||||
|
* 与 {@code alertType} 不同,本字段**无字典**(DB char(1) 直存),前端本地映射文案。
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "告警级别")
|
||||||
|
private String alertLevel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发值(如电量)
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "触发值")
|
||||||
|
private String alertValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警文案
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "告警内容")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理状态(0 未处理 1 已处理)
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "处理状态")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理人(后台账号主键)
|
||||||
|
*/
|
||||||
|
private Long handleBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理人账号名
|
||||||
|
*/
|
||||||
|
@Translation(type = TransConstant.USER_ID_TO_NAME, mapper = "handleBy")
|
||||||
|
@ExcelProperty(value = "处理人")
|
||||||
|
private String handleUserName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理时间
|
||||||
|
*/
|
||||||
|
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
|
||||||
|
@ExcelProperty(value = "处理时间")
|
||||||
|
private LocalDateTime handleTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警时间
|
||||||
|
*/
|
||||||
|
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
|
||||||
|
@ExcelProperty(value = "告警时间")
|
||||||
|
private LocalDateTime alertTime;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package org.dromara.device.domain.vo;
|
||||||
|
|
||||||
|
import io.github.linpeilie.annotations.AutoMapper;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.dromara.device.domain.BmAppMessage;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 消息视图对象({@code GET /app/message/list})。
|
||||||
|
* <p>
|
||||||
|
* 供 APP 消息列表与未读角标使用。{@code deviceNo} 一并返回,使 APP 可在用户点击消息时
|
||||||
|
* 直接跳转设备详情({@code /app/device/{deviceNo}}),无需再查一次告警。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@AutoMapper(target = BmAppMessage.class)
|
||||||
|
public class AppMessageVo implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息主键
|
||||||
|
*/
|
||||||
|
private Long messageId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联设备编号(供 APP 跳转设备详情)
|
||||||
|
*/
|
||||||
|
private String deviceNo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联告警主键
|
||||||
|
*/
|
||||||
|
private Long alertId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警类型(offline / low_battery_hood / low_battery_ring)
|
||||||
|
*/
|
||||||
|
private String alertType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警级别(0 提示 1 警告 2 严重)
|
||||||
|
*/
|
||||||
|
private String alertLevel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息标题
|
||||||
|
*/
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息内容
|
||||||
|
*/
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已读标记(0 未读 1 已读)
|
||||||
|
*/
|
||||||
|
private String readFlag;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已读时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime readTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 产生时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -42,4 +42,9 @@ public class AppStreamPlayUrlsVo implements Serializable {
|
|||||||
*/
|
*/
|
||||||
private String webrtc;
|
private String webrtc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MJPEG(HTTP multipart,直传 JPEG)
|
||||||
|
*/
|
||||||
|
private String mjpeg;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package org.dromara.device.domain.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 检查更新响应视图对象({@code GET /app/version/latest} 的响应体)。
|
||||||
|
* <p>
|
||||||
|
* 由 {@code AppVersionServiceImpl#checkLatest} <b>手工组装</b>(不与实体自动映射):
|
||||||
|
* 字段是「三态表达 + 安装包信息」的组合语义;Android 与 iOS 下发口径一致(storeUrl 选填)。
|
||||||
|
* <p>
|
||||||
|
* <b>三态表达(APP 侧判定口径)</b>
|
||||||
|
* <table border="1">
|
||||||
|
* <tr><th>判定</th><th>条件</th><th>APP 行为</th></tr>
|
||||||
|
* <tr><td>无更新</td><td>{@code hasUpdate=false}</td><td>不弹窗({@code forceUpdate} 恒 false)</td></tr>
|
||||||
|
* <tr><td>可选更新</td><td>{@code hasUpdate=true 且 forceUpdate=false}</td><td>可关闭弹窗:立即更新 / 稍后再说</td></tr>
|
||||||
|
* <tr><td>强制更新</td><td>{@code hasUpdate=true 且 forceUpdate=true}</td><td>不可关闭弹窗(iOS 仅能引导 App Store,无法真正拦截)</td></tr>
|
||||||
|
* </table>
|
||||||
|
* <p>
|
||||||
|
* ⚠️ 类型序列化:{@code latestVersionCode} / {@code currentVersionCode} 为 {@code Integer} → JSON <b>数字</b>;
|
||||||
|
* {@code fileSize} 为 {@code Long} → JSON <b>字符串</b>(APP 需 {@code Number()} 处理后展示)。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class AppVersionLatestVo implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台(回显入参)
|
||||||
|
*/
|
||||||
|
private String platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否有新版本(三态判定主开关)
|
||||||
|
*/
|
||||||
|
private Boolean hasUpdate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否强制更新;{@code hasUpdate=false} 时恒为 false
|
||||||
|
*/
|
||||||
|
private Boolean forceUpdate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最新构建号(Integer → JSON 数字;无启用记录或已是最新时为 null)
|
||||||
|
*/
|
||||||
|
private Integer latestVersionCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最新展示版本号(弹窗标题用)
|
||||||
|
*/
|
||||||
|
private String latestVersionName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新说明
|
||||||
|
*/
|
||||||
|
private String changelog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包字节数(Long → JSON 字符串;两平台均下发)
|
||||||
|
*/
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包校验值 MD5(本期多为 null;两平台均下发)
|
||||||
|
*/
|
||||||
|
private String md5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包 OSS 地址(两平台均下发;有值即下发)
|
||||||
|
*/
|
||||||
|
private String downloadUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App Store 引导地址(选填;有值即下发)
|
||||||
|
*/
|
||||||
|
private String storeUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回显入参,便于 APP 自校验
|
||||||
|
*/
|
||||||
|
private Integer currentVersionCode;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package org.dromara.device.domain.vo;
|
||||||
|
|
||||||
|
import io.github.linpeilie.annotations.AutoMapper;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated;
|
||||||
|
import org.apache.fesod.sheet.annotation.ExcelProperty;
|
||||||
|
import org.dromara.device.domain.BmAppVersion;
|
||||||
|
|
||||||
|
import java.io.Serial;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 客户端版本视图对象 bm_app_version
|
||||||
|
* <p>
|
||||||
|
* 后台列表 / 详情回显。与实体 {@link BmAppVersion} 自动映射({@code @AutoMapper})。
|
||||||
|
* <p>
|
||||||
|
* ⚠️ 前端字段类型约定(框架序列化):{@code versionId} / {@code ossId} / {@code fileSize}({@code Long})
|
||||||
|
* 在 JSON 中为<b>字符串</b>;{@code versionCode}({@code Integer})为<b>数字</b>。前端据此兼容处理。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ExcelIgnoreUnannotated
|
||||||
|
@AutoMapper(target = BmAppVersion.class)
|
||||||
|
public class AppVersionVo implements Serializable {
|
||||||
|
|
||||||
|
@Serial
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "版本主键")
|
||||||
|
private Long versionId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台(android / ios)
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "平台")
|
||||||
|
private String platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展示版本号,如 1.0.10
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "展示版本号")
|
||||||
|
private String versionName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建号(唯一比较依据)
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "构建号")
|
||||||
|
private Integer versionCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制更新(0 否 1 是)
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "强制更新")
|
||||||
|
private String forceUpdate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联 sys_oss.oss_id
|
||||||
|
*/
|
||||||
|
private Long ossId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包 URL(两平台均上传)
|
||||||
|
*/
|
||||||
|
private String fileUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包文件名(两平台均上传)
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "安装包文件名")
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包字节数(两平台均上传)
|
||||||
|
*/
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装包校验值 MD5(预留,本期多为空)
|
||||||
|
*/
|
||||||
|
private String md5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App Store 引导地址(选填)
|
||||||
|
*/
|
||||||
|
private String storeUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新说明
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "更新说明")
|
||||||
|
private String changelog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态(0 停用 1 启用)
|
||||||
|
*/
|
||||||
|
@ExcelProperty(value = "状态")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime publishTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备注
|
||||||
|
*/
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
package org.dromara.device.email;
|
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>";
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package org.dromara.device.enums;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 平台枚举
|
||||||
|
* <p>
|
||||||
|
* 对应字典 {@code bm_app_platform}:{@code android} 安卓 / {@code ios} iOS。
|
||||||
|
* 取值与表 {@code bm_app_version.platform}、APP 检查更新入参 {@code platform} 完全一致。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum AppPlatformEnum {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安卓
|
||||||
|
*/
|
||||||
|
ANDROID("android", "安卓"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS
|
||||||
|
*/
|
||||||
|
IOS("ios", "iOS");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台编码
|
||||||
|
*/
|
||||||
|
private final String code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台文案
|
||||||
|
*/
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判定平台编码是否合法(取值严格等于枚举定义,不接受大小写或别名变体)。
|
||||||
|
*
|
||||||
|
* @param code 待校验的平台编码
|
||||||
|
* @return {@code true} 合法;{@code false} 非法(含 {@code null})
|
||||||
|
*/
|
||||||
|
public static boolean isValid(String code) {
|
||||||
|
if (code == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (AppPlatformEnum item : values()) {
|
||||||
|
if (item.code.equals(code)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package org.dromara.device.enums;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 版本状态枚举
|
||||||
|
* <p>
|
||||||
|
* 对应字典 {@code bm_app_version_status}:{@code 0} 停用 / {@code 1} 启用。
|
||||||
|
* 逻辑删除取值口径另见 {@code common-mybatis.yml}({@code logicDeleteValue=1})。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum AppVersionStatusEnum {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停用
|
||||||
|
*/
|
||||||
|
DISABLED("0", "停用"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用
|
||||||
|
*/
|
||||||
|
ENABLED("1", "启用");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态编码
|
||||||
|
*/
|
||||||
|
private final String code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态文案
|
||||||
|
*/
|
||||||
|
private final String label;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.dromara.device.mapper;
|
||||||
|
|
||||||
|
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||||
|
import org.dromara.device.domain.BmAppMessage;
|
||||||
|
import org.dromara.device.domain.vo.AppMessageVo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 消息 Mapper 接口
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
public interface BmAppMessageMapper extends BaseMapperPlus<BmAppMessage, AppMessageVo> {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package org.dromara.device.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||||
|
import org.dromara.device.domain.BmAppVersion;
|
||||||
|
import org.dromara.device.domain.vo.AppVersionVo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 客户端版本 Mapper 接口
|
||||||
|
* <p>
|
||||||
|
* 基础 CRUD 走 {@link BaseMapperPlus},自定义 SQL 预留(见 {@code BmAppVersionMapper.xml})。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
public interface BmAppVersionMapper extends BaseMapperPlus<BmAppVersion, AppVersionVo> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询平台构建号历史高水位,包含已逻辑删除记录。
|
||||||
|
*
|
||||||
|
* @param platform 平台
|
||||||
|
* @param excludeVersionId 修改时排除自身;新增时为 {@code null}
|
||||||
|
* @return 历史最大构建号;没有记录时返回 {@code null}
|
||||||
|
*/
|
||||||
|
Integer selectMaxVersionCodeIncludingDeleted(@Param("platform") String platform,
|
||||||
|
@Param("excludeVersionId") Long excludeVersionId);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package org.dromara.device.mapper;
|
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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package org.dromara.device.mjpeg;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内存中的 MJPEG 帧中转:每台设备保留最新一帧和观看人数。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class MjpegFrameHub {
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<String, Session> sessions = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开播时建立会话,供空闲停推计时。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
*/
|
||||||
|
public void open(String deviceNo) {
|
||||||
|
open(deviceNo, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立或刷新推流会话,并绑定本次签发的推流凭证。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
* @param ingestToken 本次推流凭证;仅播放测试场景可为空
|
||||||
|
*/
|
||||||
|
public void open(String deviceNo, String ingestToken) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
sessions.compute(deviceNo, (key, session) -> {
|
||||||
|
if (session == null || session.closed || ingestToken != null) {
|
||||||
|
// 新 token 代表新一轮推流;替换会话可立即切断旧播放连接。
|
||||||
|
return new Session(now, ingestToken);
|
||||||
|
}
|
||||||
|
synchronized (session) {
|
||||||
|
session.ingestToken = ingestToken;
|
||||||
|
session.frame = null;
|
||||||
|
session.lastPushAt = 0L;
|
||||||
|
// 重复开播且当前无人观看时,从本次开播重新计算空闲窗口。
|
||||||
|
if (session.viewers.get() == 0) {
|
||||||
|
session.idleSince.set(now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入最新一帧。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
* @param jpeg JPEG 字节
|
||||||
|
*/
|
||||||
|
public boolean publish(String deviceNo, String ingestToken, byte[] jpeg) {
|
||||||
|
AtomicBoolean published = new AtomicBoolean();
|
||||||
|
// 只允许写入已由 open 建立的会话,避免停播与在途推帧竞争时复活会话。
|
||||||
|
sessions.computeIfPresent(deviceNo, (key, session) -> {
|
||||||
|
if (!session.closed && Objects.equals(session.ingestToken, ingestToken)) {
|
||||||
|
session.frame = jpeg;
|
||||||
|
session.lastPushAt = System.currentTimeMillis();
|
||||||
|
published.set(true);
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
});
|
||||||
|
return published.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容仅用于无 token 测试会话的旧调用形式。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
* @param jpeg JPEG 字节
|
||||||
|
* @return 是否写入
|
||||||
|
*/
|
||||||
|
public boolean publish(String deviceNo, byte[] jpeg) {
|
||||||
|
return publish(deviceNo, null, jpeg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最新一帧,没有则 {@code null}。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
* @return JPEG 或 {@code null}
|
||||||
|
*/
|
||||||
|
public byte[] latest(String deviceNo) {
|
||||||
|
Session session = sessions.get(deviceNo);
|
||||||
|
return session == null || session.closed ? null : session.frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取观看连接所属会话的最新帧,避免停播重开后旧连接串到新会话。
|
||||||
|
*
|
||||||
|
* @param lease 观看租约
|
||||||
|
* @return JPEG 或 {@code null}
|
||||||
|
*/
|
||||||
|
public byte[] latest(ViewerLease lease) {
|
||||||
|
Session session = activeSession(lease);
|
||||||
|
return session == null ? null : session.frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近 {@code ttlSeconds} 秒内是否收到过帧。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
* @param ttlSeconds 帧过期秒数
|
||||||
|
* @return 是否在线
|
||||||
|
*/
|
||||||
|
public boolean online(String deviceNo, int ttlSeconds) {
|
||||||
|
Session session = sessions.get(deviceNo);
|
||||||
|
if (session == null || session.closed || session.lastPushAt == 0L) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return System.currentTimeMillis() - session.lastPushAt <= ttlSeconds * 1000L;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加一名观看者。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
*/
|
||||||
|
public ViewerLease addViewer(String deviceNo) {
|
||||||
|
Session session = sessions.get(deviceNo);
|
||||||
|
if (session == null) {
|
||||||
|
return ViewerLease.invalid(deviceNo);
|
||||||
|
}
|
||||||
|
synchronized (session) {
|
||||||
|
// 开播替换或停播清理可能已把该对象移出当前会话,不能把观看者挂到旧对象上。
|
||||||
|
if (session.closed || sessions.get(deviceNo) != session) {
|
||||||
|
return ViewerLease.invalid(deviceNo);
|
||||||
|
}
|
||||||
|
session.viewers.incrementAndGet();
|
||||||
|
session.idleSince.set(0L);
|
||||||
|
}
|
||||||
|
return new ViewerLease(deviceNo, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 减少一名观看者。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
*/
|
||||||
|
public void removeViewer(String deviceNo) {
|
||||||
|
Session session = sessions.get(deviceNo);
|
||||||
|
if (session == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
decrementViewer(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放指定观看连接的租约,操作具备幂等性。
|
||||||
|
*
|
||||||
|
* @param lease 观看租约
|
||||||
|
*/
|
||||||
|
public void removeViewer(ViewerLease lease) {
|
||||||
|
if (lease == null || !lease.released.compareAndSet(false, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Session current = sessions.get(lease.deviceNo);
|
||||||
|
if (current == lease.session && !current.closed) {
|
||||||
|
decrementViewer(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断观看连接是否仍属于当前活动会话。
|
||||||
|
*
|
||||||
|
* @param lease 观看租约
|
||||||
|
* @return 是否有效
|
||||||
|
*/
|
||||||
|
public boolean active(ViewerLease lease) {
|
||||||
|
return activeSession(lease) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前观看人数。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
* @return 人数
|
||||||
|
*/
|
||||||
|
public int viewerCount(String deviceNo) {
|
||||||
|
Session session = sessions.get(deviceNo);
|
||||||
|
return session == null || session.closed ? 0 : session.viewers.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空闲计时起点;无会话返回 0。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
* @return epoch 毫秒
|
||||||
|
*/
|
||||||
|
public long idleSince(String deviceNo) {
|
||||||
|
Session session = sessions.get(deviceNo);
|
||||||
|
return session == null || session.closed ? 0L : session.idleSince.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子地清理达到空闲阈值的会话,避免扫描后新观看者加入仍被误停推。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
* @param expectedIdleAt 扫描时读取的空闲起点
|
||||||
|
* @param now 当前时间
|
||||||
|
* @param idleMs 空闲阈值
|
||||||
|
* @return 是否成功清理
|
||||||
|
*/
|
||||||
|
public boolean clearIfIdle(String deviceNo, long expectedIdleAt, long now, long idleMs) {
|
||||||
|
final boolean[] cleared = {false};
|
||||||
|
sessions.computeIfPresent(deviceNo, (key, session) -> {
|
||||||
|
synchronized (session) {
|
||||||
|
if (!session.closed && session.viewers.get() == 0
|
||||||
|
&& session.idleSince.get() == expectedIdleAt
|
||||||
|
&& expectedIdleAt > 0L && now - expectedIdleAt >= idleMs) {
|
||||||
|
session.closed = true;
|
||||||
|
session.frame = null;
|
||||||
|
cleared[0] = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
});
|
||||||
|
return cleared[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前仍在中转的设备编号。
|
||||||
|
*
|
||||||
|
* @return 设备编号集合
|
||||||
|
*/
|
||||||
|
public Set<String> deviceNos() {
|
||||||
|
return Set.copyOf(sessions.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清掉该设备的帧与观看计数。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
*/
|
||||||
|
public void clear(String deviceNo) {
|
||||||
|
Session session = sessions.remove(deviceNo);
|
||||||
|
if (session != null) {
|
||||||
|
synchronized (session) {
|
||||||
|
session.closed = true;
|
||||||
|
session.frame = null;
|
||||||
|
session.viewers.set(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class Session {
|
||||||
|
private final AtomicLong idleSince;
|
||||||
|
private volatile byte[] frame;
|
||||||
|
private volatile long lastPushAt;
|
||||||
|
private volatile String ingestToken;
|
||||||
|
private final AtomicInteger viewers = new AtomicInteger();
|
||||||
|
private volatile boolean closed;
|
||||||
|
|
||||||
|
private Session(long createdAt, String ingestToken) {
|
||||||
|
this.idleSince = new AtomicLong(createdAt);
|
||||||
|
this.ingestToken = ingestToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将观看人数减一,并在最后一名观看者离开时记录空闲起点。
|
||||||
|
*/
|
||||||
|
private void decrementViewer(Session session) {
|
||||||
|
synchronized (session) {
|
||||||
|
session.viewers.updateAndGet(count -> Math.max(0, count - 1));
|
||||||
|
if (session.viewers.get() == 0) {
|
||||||
|
session.idleSince.compareAndSet(0L, System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Session activeSession(ViewerLease lease) {
|
||||||
|
if (lease == null || lease.released.get()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Session session = sessions.get(lease.deviceNo);
|
||||||
|
return session == lease.session && !session.closed ? session : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个 HTTP 播放连接持有的会话租约。
|
||||||
|
*/
|
||||||
|
public static final class ViewerLease {
|
||||||
|
private final String deviceNo;
|
||||||
|
private final Session session;
|
||||||
|
private final AtomicBoolean released;
|
||||||
|
|
||||||
|
private ViewerLease(String deviceNo, Session session) {
|
||||||
|
this.deviceNo = deviceNo;
|
||||||
|
this.session = session;
|
||||||
|
this.released = new AtomicBoolean();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ViewerLease invalid(String deviceNo) {
|
||||||
|
ViewerLease lease = new ViewerLease(deviceNo, null);
|
||||||
|
lease.released.set(true);
|
||||||
|
return lease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package org.dromara.device.mjpeg;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.dromara.device.config.MjpegProperties;
|
||||||
|
import org.dromara.device.config.ZlmProperties;
|
||||||
|
import org.dromara.device.mqtt.DeviceCommandPublisher;
|
||||||
|
import org.dromara.device.mqtt.payload.StreamCommandPayload;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开播后持续无人观看则通知设备停推,避免摄像头空转耗电。
|
||||||
|
* <p>
|
||||||
|
* 不用 {@code @Scheduled}:与 {@code DeviceOnlineMonitor} 相同,SnailJob 关闭时注解任务不会跑。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MjpegIdleMonitor {
|
||||||
|
|
||||||
|
private final MjpegProperties properties;
|
||||||
|
private final MjpegFrameHub hub;
|
||||||
|
private final MjpegIngestTokenService ingestTokenService;
|
||||||
|
private final DeviceCommandPublisher commandPublisher;
|
||||||
|
private final ZlmProperties zlmProperties;
|
||||||
|
private final MjpegLifecycleCoordinator lifecycleCoordinator;
|
||||||
|
private final ScheduledExecutorService scheduledExecutorService;
|
||||||
|
|
||||||
|
private volatile ScheduledFuture<?> scanFuture;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动空闲扫描。
|
||||||
|
*/
|
||||||
|
@PostConstruct
|
||||||
|
public void start() {
|
||||||
|
if (!properties.isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scanFuture = scheduledExecutorService.scheduleWithFixedDelay(
|
||||||
|
this::scanSafely, 5, 2, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止扫描。
|
||||||
|
*/
|
||||||
|
@PreDestroy
|
||||||
|
public void stop() {
|
||||||
|
ScheduledFuture<?> future = scanFuture;
|
||||||
|
if (future != null) {
|
||||||
|
future.cancel(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描入口隔离异常,避免单台设备状态异常导致定时任务永久停止。
|
||||||
|
*/
|
||||||
|
private void scanSafely() {
|
||||||
|
try {
|
||||||
|
scan();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[MJPEG] 空闲扫描失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 遍历活动会话并对达到空闲阈值的设备发送停推指令。
|
||||||
|
*/
|
||||||
|
private void scan() {
|
||||||
|
// 空闲时间从最后一名观看者离开时开始,而不是从开播时刻开始。
|
||||||
|
long idleMs = properties.getIdleStopSeconds() * 1000L;
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
for (String deviceNo : hub.deviceNos()) {
|
||||||
|
lifecycleCoordinator.execute(deviceNo, () -> reclaimIfIdle(deviceNo, now, idleMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在设备生命周期锁内复核并回收空闲会话,避免与重新开播交错。
|
||||||
|
*/
|
||||||
|
private void reclaimIfIdle(String deviceNo, long now, long idleMs) {
|
||||||
|
long idleSince = hub.idleSince(deviceNo);
|
||||||
|
if (idleSince == 0L || !hub.clearIfIdle(deviceNo, idleSince, now, idleMs)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.info("[MJPEG] 无人观看,通知设备停推 deviceNo={}", deviceNo);
|
||||||
|
commandPublisher.publishStream(deviceNo, StreamCommandPayload.stop(
|
||||||
|
DeviceCommandPublisher.newMsgId(), deviceNo, zlmProperties.getApp()));
|
||||||
|
ingestTokenService.revoke(deviceNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package org.dromara.device.mjpeg;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.IdUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.dromara.common.core.utils.StringUtils;
|
||||||
|
import org.dromara.common.redis.utils.RedisUtils;
|
||||||
|
import org.dromara.device.constant.DeviceMqttConstants;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备 MJPEG 推流 token(按 deviceNo 存放,开播签发、停播删除)。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MjpegIngestTokenService {
|
||||||
|
|
||||||
|
private static final Duration TTL = Duration.ofHours(2);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为设备签发推流 token,覆盖旧值。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
* @return token
|
||||||
|
*/
|
||||||
|
public String issue(String deviceNo) {
|
||||||
|
String token = IdUtil.fastSimpleUUID();
|
||||||
|
RedisUtils.setCacheObject(key(deviceNo), token, TTL);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验推流 token。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
* @param token 请求携带的 token
|
||||||
|
* @return 是否通过
|
||||||
|
*/
|
||||||
|
public boolean validate(String deviceNo, String token) {
|
||||||
|
if (StringUtils.isBlank(deviceNo) || StringUtils.isBlank(token)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String cached = RedisUtils.getCacheObject(key(deviceNo));
|
||||||
|
return Objects.equals(cached, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收到合法帧后延长 token。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
*/
|
||||||
|
public void touch(String deviceNo) {
|
||||||
|
// 只延长现有 key 的 TTL,不重新写入值,避免停播撤销与在途帧竞争时复活 token。
|
||||||
|
RedisUtils.expire(key(deviceNo), TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停播时删除 token。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
*/
|
||||||
|
public void revoke(String deviceNo) {
|
||||||
|
RedisUtils.deleteObject(key(deviceNo));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String key(String deviceNo) {
|
||||||
|
return DeviceMqttConstants.MJPEG_INGEST_TOKEN_PREFIX + deviceNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package org.dromara.device.mjpeg;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MJPEG 会话生命周期协调器。
|
||||||
|
* <p>
|
||||||
|
* 同一设备的开播、停播与空闲回收必须串行,避免旧会话的延迟清理误删新签发的 token。
|
||||||
|
* 使用固定数量的分段锁,既限制内存占用,也允许不同设备并行处理。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class MjpegLifecycleCoordinator {
|
||||||
|
|
||||||
|
private static final int STRIPE_COUNT = 64;
|
||||||
|
|
||||||
|
private final ReentrantLock[] locks = new ReentrantLock[STRIPE_COUNT];
|
||||||
|
|
||||||
|
public MjpegLifecycleCoordinator() {
|
||||||
|
for (int index = 0; index < locks.length; index++) {
|
||||||
|
locks[index] = new ReentrantLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在设备对应的生命周期锁内执行操作。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
* @param action 生命周期操作
|
||||||
|
*/
|
||||||
|
public void execute(String deviceNo, Runnable action) {
|
||||||
|
ReentrantLock lock = locks[Math.floorMod(deviceNo.hashCode(), locks.length)];
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
action.run();
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package org.dromara.device.mjpeg;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaIgnore;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.dromara.common.core.domain.R;
|
||||||
|
import org.dromara.common.core.utils.ServletUtils;
|
||||||
|
import org.dromara.common.core.utils.StringUtils;
|
||||||
|
import org.dromara.device.config.MjpegProperties;
|
||||||
|
import org.dromara.device.zlm.stream.ZlmPlayAuthService;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MJPEG 推流入口与播放入口。
|
||||||
|
* <p>
|
||||||
|
* 免登录,靠 query {@code token} 鉴权:推流用开播签发的 ingest token,播放用 APP play token。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@SaIgnore
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/mjpeg")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MjpegStreamController {
|
||||||
|
|
||||||
|
private static final byte[] JPEG_SOI = {(byte) 0xFF, (byte) 0xD8};
|
||||||
|
private static final String BOUNDARY = "frame";
|
||||||
|
private static final long TOKEN_RECHECK_INTERVAL_MS = 1_000L;
|
||||||
|
|
||||||
|
private final MjpegFrameHub hub;
|
||||||
|
private final MjpegIngestTokenService ingestTokenService;
|
||||||
|
private final MjpegProperties properties;
|
||||||
|
private final ZlmPlayAuthService playAuthService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备推一帧 JPEG。固件按约 5fps 循环 POST。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
* @param token 开播指令 pushUrl 上的 token
|
||||||
|
* @param body JPEG 字节
|
||||||
|
* @return 成功或失败
|
||||||
|
*/
|
||||||
|
@PostMapping(value = "/ingest/{deviceNo}",
|
||||||
|
consumes = {MediaType.IMAGE_JPEG_VALUE, MediaType.APPLICATION_OCTET_STREAM_VALUE, MediaType.ALL_VALUE})
|
||||||
|
public R<Void> ingest(@PathVariable String deviceNo,
|
||||||
|
@RequestParam String token,
|
||||||
|
@RequestBody byte[] body) {
|
||||||
|
if (!properties.isEnabled()) {
|
||||||
|
return R.fail("MJPEG 未启用");
|
||||||
|
}
|
||||||
|
if (!ingestTokenService.validate(deviceNo, token)) {
|
||||||
|
return R.fail("推流凭证无效");
|
||||||
|
}
|
||||||
|
if (body == null || body.length < 2) {
|
||||||
|
return R.fail("空帧");
|
||||||
|
}
|
||||||
|
if (body.length > properties.getMaxFrameBytes()) {
|
||||||
|
return R.fail("帧过大");
|
||||||
|
}
|
||||||
|
if (body[0] != JPEG_SOI[0] || body[1] != JPEG_SOI[1]) {
|
||||||
|
return R.fail("不是 JPEG");
|
||||||
|
}
|
||||||
|
if (!hub.publish(deviceNo, token, body)) {
|
||||||
|
return R.fail("推流会话已结束");
|
||||||
|
}
|
||||||
|
ingestTokenService.touch(deviceNo);
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最新一帧,便于浏览器直接打开验证。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
* @param token 播放 token
|
||||||
|
* @param response HTTP 响应
|
||||||
|
*/
|
||||||
|
@GetMapping(value = "/play/{deviceNo}/snapshot", produces = MediaType.IMAGE_JPEG_VALUE)
|
||||||
|
public void snapshot(@PathVariable String deviceNo,
|
||||||
|
@RequestParam String token,
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response) throws Exception {
|
||||||
|
String clientIp = ServletUtils.getClientIP(request);
|
||||||
|
if (!allowPlay(deviceNo, token, clientIp)) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
byte[] frame = hub.latest(deviceNo);
|
||||||
|
if (frame == null) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.setContentType(MediaType.IMAGE_JPEG_VALUE);
|
||||||
|
response.setContentLength(frame.length);
|
||||||
|
response.getOutputStream().write(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* multipart MJPEG 播放流,APP / {@code <img>} 可持续拉。
|
||||||
|
*
|
||||||
|
* @param deviceNo 设备编号
|
||||||
|
* @param token 播放 token
|
||||||
|
* @param response HTTP 响应
|
||||||
|
*/
|
||||||
|
@GetMapping("/play/{deviceNo}")
|
||||||
|
public void play(@PathVariable String deviceNo,
|
||||||
|
@RequestParam String token,
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response) throws Exception {
|
||||||
|
String clientIp = ServletUtils.getClientIP(request);
|
||||||
|
if (!allowPlay(deviceNo, token, clientIp)) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long intervalMs = Math.max(40L, 1000L / Math.max(1, properties.getFps()));
|
||||||
|
MjpegFrameHub.ViewerLease lease = hub.addViewer(deviceNo);
|
||||||
|
if (!hub.active(lease)) {
|
||||||
|
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.setStatus(HttpServletResponse.SC_OK);
|
||||||
|
response.setContentType("multipart/x-mixed-replace;boundary=" + BOUNDARY);
|
||||||
|
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||||
|
response.setHeader("Pragma", "no-cache");
|
||||||
|
response.setHeader("Connection", "close");
|
||||||
|
try (OutputStream out = response.getOutputStream()) {
|
||||||
|
long nextTokenCheckAt = System.currentTimeMillis() + TOKEN_RECHECK_INTERVAL_MS;
|
||||||
|
while (!Thread.currentThread().isInterrupted() && hub.active(lease)) {
|
||||||
|
// 长连接定期复核 token,确保撤销或过期后及时释放 Jetty 工作线程。
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now >= nextTokenCheckAt) {
|
||||||
|
if (!allowPlay(deviceNo, token, clientIp)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
nextTokenCheckAt = now + TOKEN_RECHECK_INTERVAL_MS;
|
||||||
|
}
|
||||||
|
byte[] frame = hub.latest(lease);
|
||||||
|
if (frame != null) {
|
||||||
|
String header = "\r\n--" + BOUNDARY + "\r\n"
|
||||||
|
+ "Content-Type: image/jpeg\r\n"
|
||||||
|
+ "Content-Length: " + frame.length + "\r\n\r\n";
|
||||||
|
out.write(header.getBytes(StandardCharsets.US_ASCII));
|
||||||
|
out.write(frame);
|
||||||
|
out.flush();
|
||||||
|
}
|
||||||
|
Thread.sleep(intervalMs);
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
// 保留中断标记,便于容器在线程回收时正确识别连接已结束。
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[MJPEG] 播放连接结束 deviceNo={}", deviceNo);
|
||||||
|
} finally {
|
||||||
|
hub.removeViewer(lease);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean allowPlay(String deviceNo, String token, String clientIp) {
|
||||||
|
if (!properties.isEnabled() || StringUtils.isBlank(token)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return playAuthService.validate(token, deviceNo, clientIp);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package org.dromara.device.mqtt;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.dromara.common.redis.utils.RedisUtils;
|
||||||
|
import org.dromara.device.config.DeviceMqttProperties;
|
||||||
|
import org.dromara.device.constant.DeviceMqttConstants;
|
||||||
|
import org.dromara.device.mqtt.support.DeviceAlertWriter;
|
||||||
|
import org.redisson.api.RLock;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 告警消息 outbox 补偿任务。
|
||||||
|
* <p>
|
||||||
|
* 项目的 Spring Scheduling 受 SnailJob 开关控制,因此沿用设备离线扫描的显式调度方式;
|
||||||
|
* 多实例通过 Redis 锁互斥,消息表的 {@code alert_id} 唯一键再做最终幂等兜底。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AppMessageRetryMonitor {
|
||||||
|
|
||||||
|
private final DeviceMqttProperties properties;
|
||||||
|
private final DeviceAlertWriter alertWriter;
|
||||||
|
private final ScheduledExecutorService scheduledExecutorService;
|
||||||
|
|
||||||
|
private volatile ScheduledFuture<?> retryFuture;
|
||||||
|
|
||||||
|
/** 启动补偿调度,首轮延迟一个周期,避免与应用启动抢占数据库连接。 */
|
||||||
|
@PostConstruct
|
||||||
|
public void start() {
|
||||||
|
long intervalSeconds = Math.max(1L, properties.getAppMessageRetryInterval().toSeconds());
|
||||||
|
retryFuture = scheduledExecutorService.scheduleWithFixedDelay(
|
||||||
|
this::retrySafely, intervalSeconds, intervalSeconds, TimeUnit.SECONDS);
|
||||||
|
log.info("APP 告警消息补偿已启动 => 间隔: {}s, 单轮上限: {}",
|
||||||
|
intervalSeconds, properties.getAppMessageRetryBatchSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停止补偿调度。 */
|
||||||
|
@PreDestroy
|
||||||
|
public void stop() {
|
||||||
|
ScheduledFuture<?> future = retryFuture;
|
||||||
|
if (future != null) {
|
||||||
|
future.cancel(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 捕获单轮所有异常,避免 ScheduledExecutorService 因一次失败永久取消后续任务。
|
||||||
|
*/
|
||||||
|
private void retrySafely() {
|
||||||
|
RLock lock = null;
|
||||||
|
boolean locked = false;
|
||||||
|
try {
|
||||||
|
// 取得客户端和锁对象也可能因 Redis 短暂不可用而失败,必须一并纳入本轮异常兜底。
|
||||||
|
lock = RedisUtils.getClient().getLock(DeviceMqttConstants.APP_MESSAGE_RETRY_LOCK);
|
||||||
|
locked = lock.tryLock(0, TimeUnit.SECONDS);
|
||||||
|
if (!locked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alertWriter.retryPendingMessages(properties.getAppMessageRetryBatchSize());
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("APP 告警消息补偿异常,本轮跳过", e);
|
||||||
|
} finally {
|
||||||
|
if (locked && lock != null) {
|
||||||
|
try {
|
||||||
|
lock.unlock();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Redis 短暂不可用时不能让异常逃出,否则定时线程会永久停止后续补偿。
|
||||||
|
log.warn("APP 告警消息补偿锁释放失败,将依赖锁租期自动释放", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,14 +125,43 @@ public class DeviceCommandPublisher {
|
|||||||
}
|
}
|
||||||
String json = JsonUtils.toJsonString(payload);
|
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) + "?***";
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 "设备告警";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否为低电类告警(避免被误用到离线等类型上)。
|
* 判断是否为低电类告警(避免被误用到离线等类型上)。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package org.dromara.device.service;
|
||||||
|
|
||||||
|
import org.dromara.common.core.domain.PageResult;
|
||||||
|
import org.dromara.device.domain.bo.AlertBo;
|
||||||
|
import org.dromara.device.domain.bo.AlertHandleBo;
|
||||||
|
import org.dromara.device.domain.vo.AlertStatsVo;
|
||||||
|
import org.dromara.device.domain.vo.AlertVo;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备告警服务接口(后台管理端)。
|
||||||
|
* <p>
|
||||||
|
* 告警的<b>写入</b>不在本接口内——唯一写入入口是
|
||||||
|
* {@code org.dromara.device.mqtt.support.DeviceAlertWriter}(含去重窗口与低电滞回闩锁)。
|
||||||
|
* 本接口只负责后台侧的<b>查询与处置</b>,避免出现第二条写入路径。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
public interface IAlertService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询告警记录。
|
||||||
|
*
|
||||||
|
* @param bo 查询条件(分页参数继承自 {@code PageQuery},随本对象一并传入)
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
PageResult<AlertVo> queryPageList(AlertBo bo);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计告警总数 / 未处理 / 已处理。
|
||||||
|
*
|
||||||
|
* @return 统计结果
|
||||||
|
*/
|
||||||
|
AlertStatsVo stats();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记告警为已处理(单条与批量共用)。
|
||||||
|
* <p>
|
||||||
|
* <b>幂等</b>:只更新当前仍为「未处理」的记录,已是「已处理」的直接跳过,
|
||||||
|
* 因此重复点击不会报错,也不会覆盖首次处理人与处理时间。
|
||||||
|
*
|
||||||
|
* @param bo 待处理的告警主键集合
|
||||||
|
*/
|
||||||
|
void handle(AlertHandleBo bo);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除告警记录(逻辑删除,保留审计痕迹)。
|
||||||
|
*
|
||||||
|
* @param alertIds 告警主键集合
|
||||||
|
*/
|
||||||
|
void remove(List<Long> alertIds);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package org.dromara.device.service;
|
||||||
|
|
||||||
|
import org.dromara.common.core.domain.PageResult;
|
||||||
|
import org.dromara.device.domain.bo.AppMessageQueryBo;
|
||||||
|
import org.dromara.device.domain.vo.AppMessageVo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 消息服务接口。
|
||||||
|
* <p>
|
||||||
|
* 全部方法<b>只作用于当前登录 APP 用户本人</b>的消息({@code user_id = LoginHelper.getUserId()}),
|
||||||
|
* 不接受任何「指定用户」入参——从接口形状上杜绝越权读取他人消息的可能。
|
||||||
|
* <p>
|
||||||
|
* 消息的<b>写入</b>不在本接口内,唯一来源是告警落库后的推送副本
|
||||||
|
* ({@code DeviceAlertWriter} → {@code BmAppMessage})。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
public interface IAppMessageService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询当前用户的消息(按产生时间倒序)。
|
||||||
|
*
|
||||||
|
* @param bo 查询条件(分页参数继承自 {@code PageQuery})
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
PageResult<AppMessageVo> queryPageList(AppMessageQueryBo bo);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计当前用户的未读消息数(供 APP 消息角标)。
|
||||||
|
*
|
||||||
|
* @return 未读数
|
||||||
|
*/
|
||||||
|
Long unreadCount();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记单条消息为已读。
|
||||||
|
* <p>
|
||||||
|
* 消息不属于当前用户时静默成功(更新 0 行),不抛出「消息不存在」——
|
||||||
|
* 否则接口会变成一个探测他人消息 ID 是否存在的旁路。
|
||||||
|
*
|
||||||
|
* @param messageId 消息主键
|
||||||
|
*/
|
||||||
|
void markRead(Long messageId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把当前用户所有未读消息标记为已读。
|
||||||
|
*/
|
||||||
|
void markAllRead();
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package org.dromara.device.service;
|
||||||
|
|
||||||
|
import org.dromara.common.core.domain.PageResult;
|
||||||
|
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||||
|
import org.dromara.device.domain.bo.AppVersionBo;
|
||||||
|
import org.dromara.device.domain.bo.AppVersionCheckBo;
|
||||||
|
import org.dromara.device.domain.vo.AppVersionLatestVo;
|
||||||
|
import org.dromara.device.domain.vo.AppVersionVo;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 客户端版本服务接口。
|
||||||
|
* <p>
|
||||||
|
* 后台版本管理(前缀 {@code /device/app-version})5 法 + APP 检查更新 1 法。
|
||||||
|
* <p>
|
||||||
|
* 唯一性口径对齐 DB 唯一键 {@code uk_platform_version_code (platform, version_code)}——该索引
|
||||||
|
* <b>不区分逻辑删除行</b>,因此同平台同构建号即使被逻辑删除也仍占用取值;实现侧对「已存在」与
|
||||||
|
* 「唯一键冲突」两种情况都归一为 {@code AppVersionMsgConstants.VERSION_CODE_DUPLICATE}。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
public interface IAppVersionService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询版本详情。
|
||||||
|
*
|
||||||
|
* @param versionId 版本主键
|
||||||
|
* @return 版本视图对象;不存在返回 {@code null}(对齐项目口径)
|
||||||
|
*/
|
||||||
|
AppVersionVo queryById(Long versionId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询版本列表。
|
||||||
|
*
|
||||||
|
* @param bo 查询条件(keyword / platform / status / forceUpdate / params 时间区间)
|
||||||
|
* @param pageQuery 分页参数
|
||||||
|
* @return 分页结果(默认按构建号倒序)
|
||||||
|
*/
|
||||||
|
PageResult<AppVersionVo> queryPageList(AppVersionBo bo, PageQuery pageQuery);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登记(新增)版本。
|
||||||
|
* <p>
|
||||||
|
* {@code status} 留空时默认「停用」({@code 0},与 DB 默认值同口径;页面表单则默认勾选「启用」);
|
||||||
|
* 首次置为启用时写入 {@code publish_time}。
|
||||||
|
*
|
||||||
|
* @param bo 新增业务对象
|
||||||
|
* @return 是否新增成功
|
||||||
|
*/
|
||||||
|
Boolean insertByBo(AppVersionBo bo);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改版本(含启用 / 停用)。
|
||||||
|
* <p>
|
||||||
|
* {@code platform} 不可变更;校验同新增(唯一性与构建号递增均排除自身)。
|
||||||
|
*
|
||||||
|
* @param bo 编辑业务对象(含 versionId)
|
||||||
|
* @return 是否修改成功
|
||||||
|
*/
|
||||||
|
Boolean updateByBo(AppVersionBo bo);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除版本(逻辑删除)。
|
||||||
|
* <p>
|
||||||
|
* 逻辑删除后同平台同构建号<b>不可复用</b>(构建号填错走编辑修正)。
|
||||||
|
*
|
||||||
|
* @param ids 版本主键集合
|
||||||
|
* @return 是否删除成功
|
||||||
|
*/
|
||||||
|
Boolean deleteByIds(Collection<Long> ids);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 检查更新(免登录)。
|
||||||
|
* <p>
|
||||||
|
* 判定按「构建号」:候选集 = {@code platform + status='1' + del_flag='0'},取 {@code version_code} 最大;
|
||||||
|
* 上报 {@code >=} 最新 → {@code hasUpdate=false}(<b>不提示降级</b>);上报 {@code <} 最新 → 按该记录
|
||||||
|
* {@code force_update} 决定是否强制更新。本方法不依赖登录态({@code @SaIgnore} 放行)。
|
||||||
|
*
|
||||||
|
* @param bo 检查更新入参(platform / versionCode 必填)
|
||||||
|
* @return 检查更新响应(三态表达 + 安装包信息;两平台口径一致,storeUrl 有值才下发)
|
||||||
|
*/
|
||||||
|
AppVersionLatestVo checkLatest(AppVersionCheckBo bo);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package org.dromara.device.service.impl;
|
||||||
|
|
||||||
|
import cn.hutool.core.date.DateUtil;
|
||||||
|
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.dromara.common.core.domain.PageResult;
|
||||||
|
import org.dromara.common.core.utils.StringUtils;
|
||||||
|
import org.dromara.common.satoken.utils.LoginHelper;
|
||||||
|
import org.dromara.device.constant.DeviceMqttConstants;
|
||||||
|
import org.dromara.device.domain.BmDevice;
|
||||||
|
import org.dromara.device.domain.BmDeviceAlert;
|
||||||
|
import org.dromara.device.domain.bo.AlertBo;
|
||||||
|
import org.dromara.device.domain.bo.AlertHandleBo;
|
||||||
|
import org.dromara.device.domain.vo.AlertStatsVo;
|
||||||
|
import org.dromara.device.domain.vo.AlertVo;
|
||||||
|
import org.dromara.device.mapper.BmDeviceAlertMapper;
|
||||||
|
import org.dromara.device.mapper.BmDeviceMapper;
|
||||||
|
import org.dromara.device.service.IAlertService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备告警服务实现(后台管理端)。
|
||||||
|
* <p>
|
||||||
|
* 只读 + 处置,不含写入:告警的唯一写入入口是
|
||||||
|
* {@code org.dromara.device.mqtt.support.DeviceAlertWriter}。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Service
|
||||||
|
public class AlertServiceImpl implements IAlertService {
|
||||||
|
|
||||||
|
private final BmDeviceAlertMapper alertMapper;
|
||||||
|
private final BmDeviceMapper deviceMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PageResult<AlertVo> queryPageList(AlertBo bo) {
|
||||||
|
AlertBo condition = bo == null ? new AlertBo() : bo;
|
||||||
|
Page<AlertVo> result = alertMapper.selectVoPage(condition.build(), buildQueryWrapper(condition));
|
||||||
|
fillDeviceName(result.getRecords());
|
||||||
|
return PageResult.build(result.getRecords(), result.getTotal());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AlertStatsVo stats() {
|
||||||
|
AlertStatsVo vo = new AlertStatsVo();
|
||||||
|
// 必须显式给出实体泛型:Wrappers.lambdaQuery() 在链式调用下无法推断出 BmDeviceAlert,
|
||||||
|
// 会导致 BmDeviceAlert::getStatus 这类方法引用编译失败。
|
||||||
|
Long total = alertMapper.selectCount(Wrappers.<BmDeviceAlert>lambdaQuery());
|
||||||
|
Long unhandled = alertMapper.selectCount(Wrappers.<BmDeviceAlert>lambdaQuery()
|
||||||
|
.eq(BmDeviceAlert::getStatus, DeviceMqttConstants.ALERT_STATUS_UNHANDLED));
|
||||||
|
vo.setTotal(total);
|
||||||
|
vo.setUnhandled(unhandled);
|
||||||
|
vo.setHandled(total - unhandled);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(AlertHandleBo bo) {
|
||||||
|
// 条件里带上 status = 未处理,靠「影响行数」天然实现幂等:
|
||||||
|
// 重复点击时第二次匹配不到任何行,既不会报错,也不会把首次的处理人与处理时间覆盖掉。
|
||||||
|
LambdaUpdateWrapper<BmDeviceAlert> luw = Wrappers.lambdaUpdate();
|
||||||
|
luw.in(BmDeviceAlert::getAlertId, bo.getAlertIds())
|
||||||
|
.eq(BmDeviceAlert::getStatus, DeviceMqttConstants.ALERT_STATUS_UNHANDLED)
|
||||||
|
.set(BmDeviceAlert::getStatus, DeviceMqttConstants.ALERT_STATUS_HANDLED)
|
||||||
|
.set(BmDeviceAlert::getHandleBy, LoginHelper.getUserId())
|
||||||
|
.set(BmDeviceAlert::getHandleTime, LocalDateTime.now());
|
||||||
|
alertMapper.update(null, luw);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void remove(List<Long> alertIds) {
|
||||||
|
if (alertIds == null || alertIds.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 走 MP 逻辑删除(@TableLogic),保留审计痕迹:后台可查操作日志定位「谁删了什么」。
|
||||||
|
alertMapper.delete(Wrappers.<BmDeviceAlert>lambdaQuery()
|
||||||
|
.in(BmDeviceAlert::getAlertId, alertIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建告警动态查询条件。
|
||||||
|
* <p>
|
||||||
|
* 默认排序为「未处理优先 + 告警时间倒序」:现场处置时最关心的是新产生的未处理告警,
|
||||||
|
* 而不是历史已处理记录。
|
||||||
|
*
|
||||||
|
* @param bo 查询条件
|
||||||
|
* @return 查询条件包装器
|
||||||
|
*/
|
||||||
|
private LambdaQueryWrapper<BmDeviceAlert> buildQueryWrapper(AlertBo bo) {
|
||||||
|
LambdaQueryWrapper<BmDeviceAlert> lqw = Wrappers.lambdaQuery();
|
||||||
|
lqw.eq(StringUtils.isNotBlank(bo.getDeviceNo()), BmDeviceAlert::getDeviceNo, bo.getDeviceNo());
|
||||||
|
lqw.eq(StringUtils.isNotBlank(bo.getAlertType()), BmDeviceAlert::getAlertType, bo.getAlertType());
|
||||||
|
lqw.eq(StringUtils.isNotBlank(bo.getAlertLevel()), BmDeviceAlert::getAlertLevel, bo.getAlertLevel());
|
||||||
|
lqw.eq(StringUtils.isNotBlank(bo.getStatus()), BmDeviceAlert::getStatus, bo.getStatus());
|
||||||
|
if (StringUtils.isNotBlank(bo.getKeyword())) {
|
||||||
|
String keyword = bo.getKeyword();
|
||||||
|
lqw.and(w -> w.like(BmDeviceAlert::getDeviceNo, keyword)
|
||||||
|
.or().like(BmDeviceAlert::getContent, keyword));
|
||||||
|
}
|
||||||
|
Map<String, Object> params = bo.getParams() == null ? new HashMap<>() : bo.getParams();
|
||||||
|
LocalDateTime begin = parseDateTime(params.get("beginTime"), true);
|
||||||
|
LocalDateTime end = parseDateTime(params.get("endTime"), false);
|
||||||
|
lqw.ge(begin != null, BmDeviceAlert::getAlertTime, begin);
|
||||||
|
lqw.le(end != null, BmDeviceAlert::getAlertTime, end);
|
||||||
|
// alert_id 作为稳定的次级排序,避免同一秒内多条告警跨页时重复或遗漏。
|
||||||
|
lqw.orderByAsc(BmDeviceAlert::getStatus)
|
||||||
|
.orderByDesc(BmDeviceAlert::getAlertTime)
|
||||||
|
.orderByDesc(BmDeviceAlert::getAlertId);
|
||||||
|
return lqw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析前端日期区间入参({@code params.beginTime} / {@code params.endTime})。
|
||||||
|
* <p>
|
||||||
|
* 前端 {@code addDateRange} 可能传「纯日期」({@code yyyy-MM-dd})或「日期时间」两种粒度。
|
||||||
|
* 纯日期时按区间端点补齐:起始取 {@code 00:00:00}、结束取 {@code 23:59:59},
|
||||||
|
* 否则「结束日 = 今天」会把当天的告警整体漏掉——这是日期区间筛选最常见的边界 bug。
|
||||||
|
* <p>
|
||||||
|
* 解析失败一律返回 {@code null}(该侧区间不生效),不抛异常:筛选条件不合法不应让整个列表打不开。
|
||||||
|
*
|
||||||
|
* @param value 原始入参
|
||||||
|
* @param beginDay 是否为区间起点(决定纯日期时的补齐方向)
|
||||||
|
* @return 解析后的时间;无法解析时返回 {@code null}
|
||||||
|
*/
|
||||||
|
private LocalDateTime parseDateTime(Object value, boolean beginDay) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String text = String.valueOf(value).trim();
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
LocalDateTime parsed = LocalDateTimeUtil.of(DateUtil.parse(text));
|
||||||
|
if (text.length() <= 10) {
|
||||||
|
return beginDay ? parsed.toLocalDate().atStartOfDay()
|
||||||
|
: parsed.toLocalDate().atTime(23, 59, 59);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量补齐设备名称。
|
||||||
|
* <p>
|
||||||
|
* 告警表只存 {@code device_no} 快照,不含名称;这里按 {@code deviceNo} 一次性查出名称再回填,
|
||||||
|
* 避免逐行查询(N+1)。设备已被逻辑删除时查不到,{@code deviceName} 保持 {@code null},
|
||||||
|
* 由前端降级展示设备编号。
|
||||||
|
*
|
||||||
|
* @param records 当前页告警记录
|
||||||
|
*/
|
||||||
|
private void fillDeviceName(List<AlertVo> records) {
|
||||||
|
if (records == null || records.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Set<String> deviceNos = records.stream()
|
||||||
|
.map(AlertVo::getDeviceNo)
|
||||||
|
.filter(StringUtils::isNotBlank)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
if (deviceNos.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<BmDevice> devices = deviceMapper.selectList(Wrappers.<BmDevice>lambdaQuery()
|
||||||
|
.select(BmDevice::getDeviceNo, BmDevice::getName)
|
||||||
|
.in(BmDevice::getDeviceNo, deviceNos));
|
||||||
|
Map<String, String> nameMap = toDeviceNameMap(devices);
|
||||||
|
records.forEach(record -> record.setDeviceName(nameMap.get(record.getDeviceNo())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把设备列表转为「设备编号 → 设备名称」映射。
|
||||||
|
*
|
||||||
|
* @param devices 设备列表
|
||||||
|
* @return 名称映射(名称为空时保留空串,交由前端降级展示编号)
|
||||||
|
*/
|
||||||
|
private Map<String, String> toDeviceNameMap(Collection<BmDevice> devices) {
|
||||||
|
Map<String, String> nameMap = new HashMap<>();
|
||||||
|
if (devices == null) {
|
||||||
|
return nameMap;
|
||||||
|
}
|
||||||
|
for (BmDevice device : devices) {
|
||||||
|
if (StringUtils.isBlank(device.getDeviceNo())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nameMap.put(device.getDeviceNo(), Objects.requireNonNullElse(device.getName(), ""));
|
||||||
|
}
|
||||||
|
return nameMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import org.dromara.device.domain.bo.AppSmsCodeBo;
|
|||||||
import org.dromara.device.domain.bo.AppSmsLoginBo;
|
import org.dromara.device.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);
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package org.dromara.device.service.impl;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.dromara.common.core.domain.PageResult;
|
||||||
|
import org.dromara.common.core.utils.StringUtils;
|
||||||
|
import org.dromara.common.satoken.utils.LoginHelper;
|
||||||
|
import org.dromara.device.constant.DeviceMqttConstants;
|
||||||
|
import org.dromara.device.domain.BmAppMessage;
|
||||||
|
import org.dromara.device.domain.bo.AppMessageQueryBo;
|
||||||
|
import org.dromara.device.domain.vo.AppMessageVo;
|
||||||
|
import org.dromara.device.mapper.BmAppMessageMapper;
|
||||||
|
import org.dromara.device.service.IAppMessageService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 消息服务实现。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Service
|
||||||
|
public class AppMessageServiceImpl implements IAppMessageService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 端未传 pageSize 时的兜底页大小。
|
||||||
|
* <p>
|
||||||
|
* 框架 {@code PageQuery#DEFAULT_PAGE_SIZE} 是 {@code Integer.MAX_VALUE}(语义为「查全部」,
|
||||||
|
* 实际被 {@code MybatisPlusConfig} 的 {@code maxLimit=1000} 截断)。这对后台表格没问题,
|
||||||
|
* 但消息是**持续累积**的数据,APP 首屏一次拉 1000 条既浪费流量也拖慢渲染,
|
||||||
|
* 因此这里显式兜底为 20 条,由 APP 下拉加载更多。
|
||||||
|
*/
|
||||||
|
private static final long DEFAULT_PAGE_SIZE = 20L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 端单页最大条数,防止客户端传超大 pageSize 拖垮服务端。
|
||||||
|
*/
|
||||||
|
private static final long MAX_PAGE_SIZE = 100L;
|
||||||
|
|
||||||
|
private final BmAppMessageMapper messageMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PageResult<AppMessageVo> queryPageList(AppMessageQueryBo bo) {
|
||||||
|
AppMessageQueryBo condition = bo == null ? new AppMessageQueryBo() : bo;
|
||||||
|
Page<AppMessageVo> result = messageMapper.selectVoPage(buildPage(condition), buildQueryWrapper(condition));
|
||||||
|
return PageResult.build(result.getRecords(), result.getTotal());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Long unreadCount() {
|
||||||
|
return messageMapper.selectCount(Wrappers.<BmAppMessage>lambdaQuery()
|
||||||
|
.eq(BmAppMessage::getUserId, LoginHelper.getUserId())
|
||||||
|
.eq(BmAppMessage::getReadFlag, DeviceMqttConstants.MESSAGE_READ_FLAG_UNREAD));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void markRead(Long messageId) {
|
||||||
|
if (messageId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// user_id 是必带条件:既做越权防护,又天然实现幂等(已是已读时更新 0 行,
|
||||||
|
// 不会覆盖首次的 read_time)。
|
||||||
|
LambdaUpdateWrapper<BmAppMessage> luw = Wrappers.lambdaUpdate();
|
||||||
|
luw.eq(BmAppMessage::getMessageId, messageId)
|
||||||
|
.eq(BmAppMessage::getUserId, LoginHelper.getUserId())
|
||||||
|
.eq(BmAppMessage::getReadFlag, DeviceMqttConstants.MESSAGE_READ_FLAG_UNREAD)
|
||||||
|
.set(BmAppMessage::getReadFlag, DeviceMqttConstants.MESSAGE_READ_FLAG_READ)
|
||||||
|
.set(BmAppMessage::getReadTime, LocalDateTime.now());
|
||||||
|
messageMapper.update(null, luw);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void markAllRead() {
|
||||||
|
LambdaUpdateWrapper<BmAppMessage> luw = Wrappers.lambdaUpdate();
|
||||||
|
luw.eq(BmAppMessage::getUserId, LoginHelper.getUserId())
|
||||||
|
.eq(BmAppMessage::getReadFlag, DeviceMqttConstants.MESSAGE_READ_FLAG_UNREAD)
|
||||||
|
.set(BmAppMessage::getReadFlag, DeviceMqttConstants.MESSAGE_READ_FLAG_READ)
|
||||||
|
.set(BmAppMessage::getReadTime, LocalDateTime.now());
|
||||||
|
messageMapper.update(null, luw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建分页对象(APP 端兜底页大小与上限)。
|
||||||
|
* <p>
|
||||||
|
* 泛型必须是<b>实体</b> {@link BmAppMessage} 而非 VO:{@code BaseMapperPlus#selectVoPage}
|
||||||
|
* 的第一个参数类型为 {@code IPage<实体>},VO 由内部转换器产出。
|
||||||
|
*
|
||||||
|
* @param query 前端分页入参
|
||||||
|
* @return 分页对象
|
||||||
|
*/
|
||||||
|
private Page<BmAppMessage> buildPage(AppMessageQueryBo query) {
|
||||||
|
long current = query.getPageNum() == null || query.getPageNum() < 1 ? 1L : query.getPageNum();
|
||||||
|
long size = query.getPageSize() == null || query.getPageSize() < 1 ? DEFAULT_PAGE_SIZE : query.getPageSize();
|
||||||
|
return new Page<>(current, Math.min(size, MAX_PAGE_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建消息动态查询条件。
|
||||||
|
* <p>
|
||||||
|
* {@code user_id} 条件由服务端强制附加,前端无法覆盖;排序用
|
||||||
|
* {@code create_time + message_id} 双列倒序,避免同一时刻(同秒批量落库)产生跨页乱序。
|
||||||
|
*
|
||||||
|
* @param bo 查询条件
|
||||||
|
* @return 查询条件包装器
|
||||||
|
*/
|
||||||
|
private LambdaQueryWrapper<BmAppMessage> buildQueryWrapper(AppMessageQueryBo bo) {
|
||||||
|
LambdaQueryWrapper<BmAppMessage> lqw = Wrappers.lambdaQuery();
|
||||||
|
lqw.eq(BmAppMessage::getUserId, LoginHelper.getUserId());
|
||||||
|
lqw.eq(StringUtils.isNotBlank(bo.getReadFlag()), BmAppMessage::getReadFlag, bo.getReadFlag());
|
||||||
|
lqw.orderByDesc(BmAppMessage::getCreateTime).orderByDesc(BmAppMessage::getMessageId);
|
||||||
|
return lqw;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,46 +1,42 @@
|
|||||||
package org.dromara.device.service.impl;
|
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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
package org.dromara.device.service.impl;
|
||||||
|
|
||||||
|
import cn.hutool.core.date.DateUtil;
|
||||||
|
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.dromara.common.core.domain.PageResult;
|
||||||
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
|
import org.dromara.common.core.utils.MapstructUtils;
|
||||||
|
import org.dromara.common.core.utils.StringUtils;
|
||||||
|
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||||
|
import org.dromara.device.constant.AppVersionMsgConstants;
|
||||||
|
import org.dromara.device.domain.BmAppVersion;
|
||||||
|
import org.dromara.device.domain.bo.AppVersionBo;
|
||||||
|
import org.dromara.device.domain.bo.AppVersionCheckBo;
|
||||||
|
import org.dromara.device.domain.vo.AppVersionLatestVo;
|
||||||
|
import org.dromara.device.domain.vo.AppVersionVo;
|
||||||
|
import org.dromara.device.enums.AppPlatformEnum;
|
||||||
|
import org.dromara.device.enums.AppVersionStatusEnum;
|
||||||
|
import org.dromara.device.mapper.BmAppVersionMapper;
|
||||||
|
import org.dromara.device.service.IAppVersionService;
|
||||||
|
import org.dromara.system.api.OssService;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 客户端版本服务实现(见 {@link IAppVersionService} 的契约说明)。
|
||||||
|
* <p>
|
||||||
|
* 写侧核心是「唯一性 + 递增 + 必填校验」三点;读侧(APP 检查更新)核心是「整数构建号比较」。
|
||||||
|
* 唯一性采用「预检给出友好文案 + DB 唯一键兜底」双层(对齐 {@code OtaPackageServiceImpl} 的既有模式):
|
||||||
|
* 预检拦下常规场景,唯一键兜住预检与写入之间的并发窗口以及已被逻辑删除的历史行。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class AppVersionServiceImpl implements IAppVersionService {
|
||||||
|
|
||||||
|
/** 私有 OSS 安装包地址有效期,覆盖用户停留在升级弹窗后的下载场景。 */
|
||||||
|
private static final Duration APP_PACKAGE_URL_VALIDITY = Duration.ofHours(24);
|
||||||
|
|
||||||
|
private final BmAppVersionMapper appVersionMapper;
|
||||||
|
private final OssService ossService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询版本详情。
|
||||||
|
*
|
||||||
|
* @param versionId 版本主键
|
||||||
|
* @return 版本视图对象;不存在返回 {@code null}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public AppVersionVo queryById(Long versionId) {
|
||||||
|
return appVersionMapper.selectVoById(versionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询版本列表。
|
||||||
|
*
|
||||||
|
* @param bo 查询条件
|
||||||
|
* @param pageQuery 分页参数
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public PageResult<AppVersionVo> queryPageList(AppVersionBo bo, PageQuery pageQuery) {
|
||||||
|
AppVersionBo condition = bo == null ? new AppVersionBo() : bo;
|
||||||
|
LambdaQueryWrapper<BmAppVersion> lqw = buildQueryWrapper(condition);
|
||||||
|
Page<AppVersionVo> result = appVersionMapper.selectVoPage(pageQuery.build(), lqw);
|
||||||
|
return PageResult.build(result.getRecords(), result.getTotal());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登记(新增)版本。
|
||||||
|
*
|
||||||
|
* @param bo 新增业务对象
|
||||||
|
* @return 是否新增成功
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Boolean insertByBo(AppVersionBo bo) {
|
||||||
|
BmAppVersion add = MapstructUtils.convert(bo, BmAppVersion.class);
|
||||||
|
// status 缺省兜底为「停用」(与 DB DEFAULT '0' 同口径;页面表单默认勾选「启用」)
|
||||||
|
if (StringUtils.isBlank(add.getStatus())) {
|
||||||
|
add.setStatus(AppVersionStatusEnum.DISABLED.getCode());
|
||||||
|
}
|
||||||
|
// force_update 缺省兜底为「否」(列 NOT NULL DEFAULT '0')
|
||||||
|
if (StringUtils.isBlank(add.getForceUpdate())) {
|
||||||
|
add.setForceUpdate(AppVersionMsgConstants.FORCE_UPDATE_NO);
|
||||||
|
}
|
||||||
|
validateAndNormalize(add, true);
|
||||||
|
// 首次置为启用时写入发布时间
|
||||||
|
if (AppVersionStatusEnum.ENABLED.getCode().equals(add.getStatus())) {
|
||||||
|
add.setPublishTime(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
boolean flag = appVersionMapper.insert(add) > 0;
|
||||||
|
if (flag) {
|
||||||
|
bo.setVersionId(add.getVersionId());
|
||||||
|
}
|
||||||
|
return flag;
|
||||||
|
} catch (DuplicateKeyException e) {
|
||||||
|
throw duplicateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改版本(含启用 / 停用)。
|
||||||
|
*
|
||||||
|
* @param bo 编辑业务对象(含 versionId)
|
||||||
|
* @return 是否修改成功
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Boolean updateByBo(AppVersionBo bo) {
|
||||||
|
if (bo == null || bo.getVersionId() == null) {
|
||||||
|
throw new ServiceException(AppVersionMsgConstants.RECORD_NOT_EXIST);
|
||||||
|
}
|
||||||
|
BmAppVersion existing = appVersionMapper.selectById(bo.getVersionId());
|
||||||
|
if (existing == null) {
|
||||||
|
throw new ServiceException(AppVersionMsgConstants.RECORD_NOT_EXIST);
|
||||||
|
}
|
||||||
|
// 平台不可改:请求带了 platform 且与库中不一致则拒绝
|
||||||
|
if (StringUtils.isNotBlank(bo.getPlatform()) && !bo.getPlatform().equals(existing.getPlatform())) {
|
||||||
|
throw new ServiceException(AppVersionMsgConstants.PLATFORM_IMMUTABLE);
|
||||||
|
}
|
||||||
|
BmAppVersion update = MapstructUtils.convert(bo, BmAppVersion.class);
|
||||||
|
// 平台以库中为准(不可变);即便请求未带 platform 也能通过后续分支校验
|
||||||
|
update.setPlatform(existing.getPlatform());
|
||||||
|
if (StringUtils.isBlank(update.getForceUpdate())) {
|
||||||
|
update.setForceUpdate(existing.getForceUpdate());
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(update.getStatus())) {
|
||||||
|
update.setStatus(existing.getStatus());
|
||||||
|
}
|
||||||
|
// 普通字段编辑和启停不应重新触发“构建号必须大于最大值”;只有实际改号时才校验历史高水位。
|
||||||
|
boolean versionCodeChanged = !Objects.equals(existing.getVersionCode(), update.getVersionCode());
|
||||||
|
validateAndNormalize(update, versionCodeChanged);
|
||||||
|
// 首次置为启用(此前从未发布)时补写发布时间
|
||||||
|
if (AppVersionStatusEnum.ENABLED.getCode().equals(update.getStatus()) && existing.getPublishTime() == null) {
|
||||||
|
update.setPublishTime(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return appVersionMapper.updateById(update) > 0;
|
||||||
|
} catch (DuplicateKeyException e) {
|
||||||
|
throw duplicateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除版本(逻辑删除)。
|
||||||
|
*
|
||||||
|
* @param ids 版本主键集合
|
||||||
|
* @return 是否删除成功
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Boolean deleteByIds(Collection<Long> ids) {
|
||||||
|
if (ids == null || ids.isEmpty()) {
|
||||||
|
return Boolean.FALSE;
|
||||||
|
}
|
||||||
|
return appVersionMapper.deleteByIds(ids) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 检查更新(免登录,不依赖登录态)。
|
||||||
|
*
|
||||||
|
* @param bo 检查更新入参
|
||||||
|
* @return 检查更新响应
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public AppVersionLatestVo checkLatest(AppVersionCheckBo bo) {
|
||||||
|
String platform = bo == null ? null : bo.getPlatform();
|
||||||
|
Integer currentCode = bo == null ? null : bo.getVersionCode();
|
||||||
|
if (!AppPlatformEnum.isValid(platform)) {
|
||||||
|
throw new ServiceException(AppVersionMsgConstants.PLATFORM_INVALID);
|
||||||
|
}
|
||||||
|
if (currentCode == null || currentCode <= 0) {
|
||||||
|
throw new ServiceException(AppVersionMsgConstants.VERSION_CODE_INVALID);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppVersionLatestVo vo = new AppVersionLatestVo();
|
||||||
|
vo.setPlatform(platform);
|
||||||
|
vo.setCurrentVersionCode(currentCode);
|
||||||
|
// 三态默认:无更新(forceUpdate 恒 false)
|
||||||
|
vo.setHasUpdate(Boolean.FALSE);
|
||||||
|
vo.setForceUpdate(Boolean.FALSE);
|
||||||
|
|
||||||
|
// 用 MyBatis-Plus 分页取首条,由方言插件生成分页 SQL,避免硬编码 MySQL LIMIT。
|
||||||
|
Page<BmAppVersion> latestPage = appVersionMapper.selectPage(new Page<>(1, 1, false),
|
||||||
|
Wrappers.<BmAppVersion>lambdaQuery()
|
||||||
|
.eq(BmAppVersion::getPlatform, platform)
|
||||||
|
.eq(BmAppVersion::getStatus, AppVersionStatusEnum.ENABLED.getCode())
|
||||||
|
.orderByDesc(BmAppVersion::getVersionCode));
|
||||||
|
BmAppVersion latest = latestPage.getRecords().isEmpty() ? null : latestPage.getRecords().getFirst();
|
||||||
|
if (latest == null || latest.getVersionCode() == null) {
|
||||||
|
// 无启用记录
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
if (currentCode >= latest.getVersionCode()) {
|
||||||
|
// 已是最新 / 上报更高构建号:不提示降级
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
String downloadUrl = resolveDownloadUrl(latest);
|
||||||
|
if (StringUtils.isBlank(downloadUrl)) {
|
||||||
|
// 包记录异常时宁可暂不提示更新,也不能把用户锁在一个无法下载的强更弹窗中。
|
||||||
|
log.error("APP 最新版本安装包不可用 => versionId: {}, platform: {}, ossId: {}",
|
||||||
|
latest.getVersionId(), latest.getPlatform(), latest.getOssId());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
vo.setHasUpdate(Boolean.TRUE);
|
||||||
|
vo.setForceUpdate(AppVersionMsgConstants.FORCE_UPDATE_YES.equals(latest.getForceUpdate()));
|
||||||
|
vo.setLatestVersionCode(latest.getVersionCode());
|
||||||
|
vo.setLatestVersionName(latest.getVersionName());
|
||||||
|
vo.setChangelog(latest.getChangelog());
|
||||||
|
// 两平台口径一致:都下发安装包信息;storeUrl 有值则下发、无值给 null(不再按平台裁剪)
|
||||||
|
vo.setFileSize(latest.getFileSize());
|
||||||
|
vo.setMd5(latest.getMd5());
|
||||||
|
vo.setDownloadUrl(downloadUrl);
|
||||||
|
vo.setStoreUrl(latest.getStoreUrl());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建版本动态查询条件。
|
||||||
|
* <p>
|
||||||
|
* 默认按构建号倒序;日期区间经 {@code params.beginTime} / {@code params.endTime}
|
||||||
|
* 按<b>发布时间</b>过滤(与页面「发布时间」筛选项口径一致)。
|
||||||
|
*
|
||||||
|
* @param bo 查询条件
|
||||||
|
* @return 查询条件包装器
|
||||||
|
*/
|
||||||
|
private LambdaQueryWrapper<BmAppVersion> buildQueryWrapper(AppVersionBo bo) {
|
||||||
|
LambdaQueryWrapper<BmAppVersion> lqw = Wrappers.lambdaQuery();
|
||||||
|
lqw.eq(StringUtils.isNotBlank(bo.getPlatform()), BmAppVersion::getPlatform, bo.getPlatform());
|
||||||
|
lqw.eq(StringUtils.isNotBlank(bo.getStatus()), BmAppVersion::getStatus, bo.getStatus());
|
||||||
|
lqw.eq(StringUtils.isNotBlank(bo.getForceUpdate()), BmAppVersion::getForceUpdate, bo.getForceUpdate());
|
||||||
|
if (StringUtils.isNotBlank(bo.getKeyword())) {
|
||||||
|
lqw.like(BmAppVersion::getVersionName, bo.getKeyword());
|
||||||
|
}
|
||||||
|
Map<String, Object> params = bo.getParams() == null ? new HashMap<>() : bo.getParams();
|
||||||
|
LocalDateTime begin = parseDateTime(params.get("beginTime"), true);
|
||||||
|
LocalDateTime end = parseDateTime(params.get("endTime"), false);
|
||||||
|
lqw.ge(begin != null, BmAppVersion::getPublishTime, begin);
|
||||||
|
lqw.le(end != null, BmAppVersion::getPublishTime, end);
|
||||||
|
lqw.orderByDesc(BmAppVersion::getVersionCode);
|
||||||
|
return lqw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存前校验 + 归一化(新增与修改共用)。
|
||||||
|
* <p>
|
||||||
|
* 校验顺序:平台合法 → 必填(展示版本号 / 构建号 / 安装包)→ 唯一性预检
|
||||||
|
* (构建号、展示版本号,均排除自身)→ 构建号必须大于同平台最大值(排除自身)。
|
||||||
|
* 归一化:{@code md5} 本期恒置空(预留不填写);{@code storeUrl} 为选填,填了原样保留。
|
||||||
|
*
|
||||||
|
* @param entity 待保存实体(修改时 {@code versionId} 非空,用于排除自身)
|
||||||
|
* @param validateVersionGreater 是否校验构建号历史高水位
|
||||||
|
*/
|
||||||
|
private void validateAndNormalize(BmAppVersion entity, boolean validateVersionGreater) {
|
||||||
|
// 1. 平台合法
|
||||||
|
if (!AppPlatformEnum.isValid(entity.getPlatform())) {
|
||||||
|
throw new ServiceException(AppVersionMsgConstants.PLATFORM_INVALID);
|
||||||
|
}
|
||||||
|
// 2. 必填
|
||||||
|
if (StringUtils.isBlank(entity.getVersionName())) {
|
||||||
|
throw new ServiceException(AppVersionMsgConstants.VERSION_NAME_REQUIRED);
|
||||||
|
}
|
||||||
|
if (entity.getVersionCode() == null || entity.getVersionCode() <= 0) {
|
||||||
|
throw new ServiceException(AppVersionMsgConstants.VERSION_CODE_INVALID);
|
||||||
|
}
|
||||||
|
// 3. 必填(两平台一致:必须上传安装包);storeUrl 为选填,填了就保留、不填保持 null
|
||||||
|
if (entity.getOssId() == null || StringUtils.isBlank(entity.getFileUrl())) {
|
||||||
|
throw new ServiceException(AppVersionMsgConstants.PACKAGE_REQUIRED);
|
||||||
|
}
|
||||||
|
// md5 本期不采集(框架上传响应无 md5),恒置空,不参与任何校验
|
||||||
|
entity.setMd5(null);
|
||||||
|
// 4. 唯一性预检(排除自身)
|
||||||
|
validUnique(entity);
|
||||||
|
// 5. 新增或实际修改构建号时,必须大于包含逻辑删除记录在内的历史高水位。
|
||||||
|
if (validateVersionGreater) {
|
||||||
|
validVersionCodeGreater(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 唯一性预检(平台 + 构建号、平台 + 展示版本号,均排除自身)。
|
||||||
|
* <p>
|
||||||
|
* 预检按 MyBatis-Plus 默认(仅 {@code del_flag='0'})查询;对已被逻辑删除的冲突行预检查不到,
|
||||||
|
* 但 DB 唯一键仍会拒绝,此时由调用侧捕获 {@link DuplicateKeyException} 归一。
|
||||||
|
*
|
||||||
|
* @param entity 待保存实体
|
||||||
|
*/
|
||||||
|
private void validUnique(BmAppVersion entity) {
|
||||||
|
Long selfId = entity.getVersionId();
|
||||||
|
long codeConflict = appVersionMapper.selectCount(Wrappers.<BmAppVersion>lambdaQuery()
|
||||||
|
.eq(BmAppVersion::getPlatform, entity.getPlatform())
|
||||||
|
.eq(BmAppVersion::getVersionCode, entity.getVersionCode())
|
||||||
|
.ne(selfId != null, BmAppVersion::getVersionId, selfId));
|
||||||
|
if (codeConflict > 0) {
|
||||||
|
throw new ServiceException(AppVersionMsgConstants.VERSION_CODE_DUPLICATE);
|
||||||
|
}
|
||||||
|
long nameConflict = appVersionMapper.selectCount(Wrappers.<BmAppVersion>lambdaQuery()
|
||||||
|
.eq(BmAppVersion::getPlatform, entity.getPlatform())
|
||||||
|
.eq(BmAppVersion::getVersionName, entity.getVersionName())
|
||||||
|
.ne(selfId != null, BmAppVersion::getVersionId, selfId));
|
||||||
|
if (nameConflict > 0) {
|
||||||
|
throw new ServiceException(AppVersionMsgConstants.VERSION_NAME_DUPLICATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验构建号必须严格大于该平台已有最大值(含停用记录;修改时排除自身)。
|
||||||
|
* <p>
|
||||||
|
* 为什么含停用记录:避免构建号被回收后历史包与将来新包的构建号冲突,导致部分用户永远收不到更新。
|
||||||
|
*
|
||||||
|
* @param entity 待保存实体
|
||||||
|
*/
|
||||||
|
private void validVersionCodeGreater(BmAppVersion entity) {
|
||||||
|
Integer historicalMax = appVersionMapper.selectMaxVersionCodeIncludingDeleted(
|
||||||
|
entity.getPlatform(), entity.getVersionId());
|
||||||
|
int max = historicalMax == null ? 0 : historicalMax;
|
||||||
|
if (entity.getVersionCode() <= max) {
|
||||||
|
throw new ServiceException(String.format(AppVersionMsgConstants.VERSION_CODE_NOT_GREATER, max));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每次检查更新都按 {@code ossId} 重新解析下载地址,私有桶因此不会复用登记时的短期签名。
|
||||||
|
*/
|
||||||
|
private String resolveDownloadUrl(BmAppVersion latest) {
|
||||||
|
if (latest.getOssId() == null) {
|
||||||
|
return latest.getFileUrl();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return ossService.selectDownloadUrlById(latest.getOssId(), APP_PACKAGE_URL_VALIDITY);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.error("解析 APP 安装包下载地址失败 => versionId: {}, ossId: {}",
|
||||||
|
latest.getVersionId(), latest.getOssId(), e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据唯一键名称把并发写入异常还原成稳定业务文案。
|
||||||
|
*/
|
||||||
|
private ServiceException duplicateException(DuplicateKeyException e) {
|
||||||
|
String message = e.getMostSpecificCause() == null ? e.getMessage() : e.getMostSpecificCause().getMessage();
|
||||||
|
if (StringUtils.contains(message, "uk_platform_version_name")) {
|
||||||
|
return new ServiceException(AppVersionMsgConstants.VERSION_NAME_DUPLICATE);
|
||||||
|
}
|
||||||
|
return new ServiceException(AppVersionMsgConstants.VERSION_CODE_DUPLICATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析前端日期区间入参({@code params.beginTime} / {@code params.endTime})。
|
||||||
|
* <p>
|
||||||
|
* 纯日期({@code yyyy-MM-dd})时按区间端点补齐:起点取 {@code 00:00:00}、终点取 {@code 23:59:59},
|
||||||
|
* 否则「结束日 = 今天」会把当天的记录整体漏掉。解析失败返回 {@code null}(该侧区间不生效),不抛异常。
|
||||||
|
*
|
||||||
|
* @param value 原始入参
|
||||||
|
* @param beginDay 是否为区间起点(决定纯日期时的补齐方向)
|
||||||
|
* @return 解析后的时间;无法解析时返回 {@code null}
|
||||||
|
*/
|
||||||
|
private LocalDateTime parseDateTime(Object value, boolean beginDay) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String text = String.valueOf(value).trim();
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
LocalDateTime parsed = LocalDateTimeUtil.of(DateUtil.parse(text));
|
||||||
|
if (text.length() <= 10) {
|
||||||
|
return beginDay ? parsed.toLocalDate().atStartOfDay()
|
||||||
|
: parsed.toLocalDate().atTime(23, 59, 59);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package org.dromara.device.sms;
|
||||||
|
|
||||||
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
|
import org.dromara.common.core.utils.MessageUtils;
|
||||||
|
import org.dromara.common.core.utils.StringUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 手机号归一化:国内存 11 位,国际存带 {@code +} 的 E.164 号码。
|
||||||
|
* <p>
|
||||||
|
* 入参仍是 {@code phoneNumber}:国内可写 {@code 13800138000} 或 {@code +8613800138000};
|
||||||
|
* 国际必须带 {@code +} 或 {@code 00} 国家码,例如 {@code +85212345678}。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
public final class AppPhoneNumbers {
|
||||||
|
|
||||||
|
private static final String CN_MOBILE = "1[3-9]\\d{9}";
|
||||||
|
|
||||||
|
private AppPhoneNumbers() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转为账号/缓存用的规范号码。
|
||||||
|
*
|
||||||
|
* @param raw 用户输入
|
||||||
|
* @return 国内 11 位,或带 {@code +} 的国际号码
|
||||||
|
*/
|
||||||
|
public static String canonical(String raw) {
|
||||||
|
String compact = compact(raw);
|
||||||
|
if (compact.matches(CN_MOBILE)) {
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
if (compact.startsWith("+")) {
|
||||||
|
String digits = compact.substring(1);
|
||||||
|
if (digits.matches("86" + CN_MOBILE)) {
|
||||||
|
return digits.substring(2);
|
||||||
|
}
|
||||||
|
if (digits.matches("[1-9]\\d{6,14}")) {
|
||||||
|
return "+" + digits;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ServiceException(MessageUtils.message("user.mobile.phone.number.not.valid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否中国大陆手机号。
|
||||||
|
*
|
||||||
|
* @param canonical {@link #canonical(String)} 的结果
|
||||||
|
* @return 国内为 true
|
||||||
|
*/
|
||||||
|
public static boolean chinaMainland(String canonical) {
|
||||||
|
return canonical != null && canonical.matches(CN_MOBILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发给短信通道的号码:国内 11 位;国际去掉 {@code +},对齐阿里云国际短信。
|
||||||
|
*
|
||||||
|
* @param canonical {@link #canonical(String)} 的结果
|
||||||
|
* @return 通道号码
|
||||||
|
*/
|
||||||
|
public static String forSms(String canonical) {
|
||||||
|
if (canonical == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return canonical.startsWith("+") ? canonical.substring(1) : canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String compact(String raw) {
|
||||||
|
if (StringUtils.isBlank(raw)) {
|
||||||
|
throw new ServiceException(MessageUtils.message("user.phonenumber.not.blank"));
|
||||||
|
}
|
||||||
|
String value = raw.strip().replaceAll("[\\s()-]", "");
|
||||||
|
if (value.startsWith("00")) {
|
||||||
|
value = "+" + value.substring(2);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -4,12 +4,13 @@ import cn.hutool.core.util.RandomUtil;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package org.dromara.device.stream;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
|
import org.dromara.device.config.MjpegProperties;
|
||||||
|
import org.dromara.device.config.ZlmProperties;
|
||||||
|
import org.dromara.device.constant.DeviceMsgConstants;
|
||||||
|
import org.dromara.device.domain.BmDevice;
|
||||||
|
import org.dromara.device.domain.vo.AppStreamPlayUrlsVo;
|
||||||
|
import org.dromara.device.mjpeg.MjpegFrameHub;
|
||||||
|
import org.dromara.device.mjpeg.MjpegIngestTokenService;
|
||||||
|
import org.dromara.device.mjpeg.MjpegLifecycleCoordinator;
|
||||||
|
import org.dromara.device.mqtt.DeviceCommandPublisher;
|
||||||
|
import org.dromara.device.mqtt.payload.StreamCommandPayload;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP MJPEG 直播 adapter。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MjpegStreamBackend implements StreamBackend {
|
||||||
|
|
||||||
|
private final MjpegProperties properties;
|
||||||
|
private final ZlmProperties zlmProperties;
|
||||||
|
private final MjpegFrameHub frameHub;
|
||||||
|
private final MjpegIngestTokenService ingestTokenService;
|
||||||
|
private final MjpegLifecycleCoordinator lifecycleCoordinator;
|
||||||
|
private final DeviceCommandPublisher commandPublisher;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(BmDevice device, String streamId) {
|
||||||
|
lifecycleCoordinator.execute(streamId, () -> startLocked(device, streamId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在设备生命周期锁内执行开播,防止旧会话回收误伤本次新会话。
|
||||||
|
*/
|
||||||
|
private void startLocked(BmDevice device, String streamId) {
|
||||||
|
if (online(streamId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 先校验公网地址,避免配置缺失时留下无效 token 或空会话。
|
||||||
|
String ingestUrl = buildIngestUrl(streamId, null);
|
||||||
|
String ingestToken = ingestTokenService.issue(streamId);
|
||||||
|
frameHub.open(streamId, ingestToken);
|
||||||
|
StreamCommandPayload payload = StreamCommandPayload.start(
|
||||||
|
DeviceCommandPublisher.newMsgId(), streamId, zlmProperties.getApp(), "mjpeg",
|
||||||
|
appendToken(ingestUrl, ingestToken));
|
||||||
|
if (!commandPublisher.publishStream(device.getDeviceNo(), payload)) {
|
||||||
|
// 指令发送失败时回滚本地状态,避免空会话触发延迟停推。
|
||||||
|
ingestTokenService.revoke(streamId);
|
||||||
|
frameHub.clear(streamId);
|
||||||
|
throw new ServiceException(DeviceMsgConstants.COMMAND_SEND_FAILED);
|
||||||
|
}
|
||||||
|
log.info("[MJPEG] 已通知设备开始推流 deviceNo={}", device.getDeviceNo());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stop(BmDevice device, String streamId) {
|
||||||
|
lifecycleCoordinator.execute(streamId, () -> {
|
||||||
|
StreamCommandPayload payload = StreamCommandPayload.stop(
|
||||||
|
DeviceCommandPublisher.newMsgId(), streamId, zlmProperties.getApp());
|
||||||
|
commandPublisher.publishStream(device.getDeviceNo(), payload);
|
||||||
|
closeLocked(streamId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close(String streamId) {
|
||||||
|
lifecycleCoordinator.execute(streamId, () -> closeLocked(streamId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理当前设备的推流凭证和内存会话,调用方必须持有生命周期锁。
|
||||||
|
*/
|
||||||
|
private void closeLocked(String streamId) {
|
||||||
|
ingestTokenService.revoke(streamId);
|
||||||
|
frameHub.clear(streamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean online(String streamId) {
|
||||||
|
return frameHub.online(streamId, properties.getFrameTtlSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Endpoint endpoint(String streamId, String playToken) {
|
||||||
|
AppStreamPlayUrlsVo urls = new AppStreamPlayUrlsVo();
|
||||||
|
urls.setMjpeg(appendToken(publicBase() + "/mjpeg/play/" + streamId, playToken));
|
||||||
|
return new Endpoint(buildIngestUrl(streamId, null), urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildIngestUrl(String streamId, String ingestToken) {
|
||||||
|
String url = publicBase() + "/mjpeg/ingest/" + streamId;
|
||||||
|
return ingestToken == null ? url : appendToken(url, ingestToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String publicBase() {
|
||||||
|
String host = properties.getPublicHost();
|
||||||
|
if (host == null || host.isBlank()) {
|
||||||
|
throw new ServiceException(DeviceMsgConstants.MJPEG_NOT_CONFIGURED);
|
||||||
|
}
|
||||||
|
return host.endsWith("/") ? host.substring(0, host.length() - 1) : host;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String appendToken(String url, String token) {
|
||||||
|
String separator = url.contains("?") ? "&" : "?";
|
||||||
|
return url + separator + "token=" + URLEncoder.encode(token, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package org.dromara.device.stream;
|
||||||
|
|
||||||
|
import org.dromara.device.domain.BmDevice;
|
||||||
|
import org.dromara.device.domain.vo.AppStreamPlayUrlsVo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直播传输后端接口,统一封装协议差异、设备指令与资源清理。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
public interface StreamBackend {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流不在线时通知设备开始推流。
|
||||||
|
*
|
||||||
|
* @param device 设备
|
||||||
|
* @param streamId 流标识
|
||||||
|
*/
|
||||||
|
void start(BmDevice device, String streamId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知设备停止推流并清理服务端资源。
|
||||||
|
*
|
||||||
|
* @param device 设备
|
||||||
|
* @param streamId 流标识
|
||||||
|
*/
|
||||||
|
void stop(BmDevice device, String streamId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅清理服务端资源,不通知设备。
|
||||||
|
*
|
||||||
|
* @param streamId 流标识
|
||||||
|
*/
|
||||||
|
void close(String streamId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询流是否在线。
|
||||||
|
*
|
||||||
|
* @param streamId 流标识
|
||||||
|
* @return 是否在线
|
||||||
|
*/
|
||||||
|
boolean online(String streamId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成推流入口和带播放 token 的播放地址。
|
||||||
|
*
|
||||||
|
* @param streamId 流标识
|
||||||
|
* @param playToken 播放 token
|
||||||
|
* @return 地址信息
|
||||||
|
*/
|
||||||
|
Endpoint endpoint(String streamId, String playToken);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直播地址集合。
|
||||||
|
*
|
||||||
|
* @param pushUrl 设备推流入口(不含内部 ingest token)
|
||||||
|
* @param playUrls 客户端播放地址
|
||||||
|
*/
|
||||||
|
record Endpoint(String pushUrl, AppStreamPlayUrlsVo playUrls) {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package org.dromara.device.stream;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
|
import org.dromara.device.config.ZlmProperties;
|
||||||
|
import org.dromara.device.constant.DeviceMsgConstants;
|
||||||
|
import org.dromara.device.domain.BmDevice;
|
||||||
|
import org.dromara.device.domain.vo.AppStreamPlayUrlsVo;
|
||||||
|
import org.dromara.device.mqtt.DeviceCommandPublisher;
|
||||||
|
import org.dromara.device.mqtt.payload.StreamCommandPayload;
|
||||||
|
import org.dromara.device.zlm.stream.ZlmClient;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZLMediaKit 直播 adapter。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ZlmStreamBackend implements StreamBackend {
|
||||||
|
|
||||||
|
private final ZlmClient zlmClient;
|
||||||
|
private final ZlmProperties properties;
|
||||||
|
private final DeviceCommandPublisher commandPublisher;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(BmDevice device, String streamId) {
|
||||||
|
if (online(streamId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StreamCommandPayload payload = StreamCommandPayload.start(
|
||||||
|
DeviceCommandPublisher.newMsgId(), streamId, properties.getApp(),
|
||||||
|
properties.getPushProtocol(), buildPushUrl(streamId));
|
||||||
|
if (!commandPublisher.publishStream(device.getDeviceNo(), payload)) {
|
||||||
|
throw new ServiceException(DeviceMsgConstants.COMMAND_SEND_FAILED);
|
||||||
|
}
|
||||||
|
log.info("[ZLM] 已通知设备开始推流 deviceNo={} streamId={}", device.getDeviceNo(), streamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stop(BmDevice device, String streamId) {
|
||||||
|
StreamCommandPayload payload = StreamCommandPayload.stop(
|
||||||
|
DeviceCommandPublisher.newMsgId(), streamId, properties.getApp());
|
||||||
|
commandPublisher.publishStream(device.getDeviceNo(), payload);
|
||||||
|
zlmClient.closeStreams(streamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close(String streamId) {
|
||||||
|
zlmClient.delStreamProxy(streamId);
|
||||||
|
zlmClient.closeStreams(streamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean online(String streamId) {
|
||||||
|
return zlmClient.isStreamOnline(streamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Endpoint endpoint(String streamId, String playToken) {
|
||||||
|
String base = trimTrailingSlash(properties.getHost()) + "/" + properties.getApp() + "/" + streamId;
|
||||||
|
AppStreamPlayUrlsVo urls = new AppStreamPlayUrlsVo();
|
||||||
|
urls.setFlv(appendToken(base + ".live.flv", playToken));
|
||||||
|
urls.setHls(appendToken(base + "/hls.m3u8", playToken));
|
||||||
|
urls.setRtmp(appendToken(buildRtmpUrl(streamId), playToken));
|
||||||
|
urls.setRtsp(appendToken(buildRtspUrl(streamId), playToken));
|
||||||
|
urls.setWebrtc(appendToken(buildWebrtcUrl(streamId), playToken));
|
||||||
|
return new Endpoint(buildPushUrl(streamId), urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildPushUrl(String streamId) {
|
||||||
|
return "rtsp".equalsIgnoreCase(properties.getPushProtocol())
|
||||||
|
? buildRtspUrl(streamId) : buildRtmpUrl(streamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildRtmpUrl(String streamId) {
|
||||||
|
return toProtocol(resolveProtocolHost(properties.getRtmpHost(), properties.getHost(), "1935"), "rtmp")
|
||||||
|
+ "/" + properties.getApp() + "/" + streamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildRtspUrl(String streamId) {
|
||||||
|
return toProtocol(resolveProtocolHost(properties.getRtspHost(), properties.getHost(), "554"), "rtsp")
|
||||||
|
+ "/" + properties.getApp() + "/" + streamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildWebrtcUrl(String streamId) {
|
||||||
|
return toProtocol(resolveProtocolHost(properties.getWebrtcHost(), properties.getHost(), null), "webrtc")
|
||||||
|
+ "/" + properties.getApp() + "/" + streamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toProtocol(String host, String protocol) {
|
||||||
|
String url = trimTrailingSlash(host);
|
||||||
|
int index = url.indexOf("://");
|
||||||
|
return index > -1 ? protocol + url.substring(index) : protocol + "://" + url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String appendToken(String url, String token) {
|
||||||
|
String separator = url.contains("?") ? "&" : "?";
|
||||||
|
return url + separator + "token=" + URLEncoder.encode(token, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimTrailingSlash(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new ServiceException(DeviceMsgConstants.ZLM_NOT_CONFIGURED);
|
||||||
|
}
|
||||||
|
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveProtocolHost(String configuredHost, String fallbackHost, String defaultPort) {
|
||||||
|
if (configuredHost != null && !configuredHost.isBlank()) {
|
||||||
|
return configuredHost;
|
||||||
|
}
|
||||||
|
String host = trimTrailingSlash(fallbackHost);
|
||||||
|
int protocolIndex = host.indexOf("://");
|
||||||
|
String protocol = protocolIndex > -1 ? host.substring(0, protocolIndex + 3) : "";
|
||||||
|
String rest = protocolIndex > -1 ? host.substring(protocolIndex + 3) : host;
|
||||||
|
int slashIndex = rest.indexOf('/');
|
||||||
|
String authority = slashIndex > -1 ? rest.substring(0, slashIndex) : rest;
|
||||||
|
int colonIndex = authority.lastIndexOf(':');
|
||||||
|
String hostname = colonIndex > -1 ? authority.substring(0, colonIndex) : authority;
|
||||||
|
return defaultPort == null ? protocol + authority : protocol + hostname + ":" + defaultPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -73,6 +73,8 @@ public class AppStreamController extends BaseController {
|
|||||||
* @return 播放信息
|
* @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()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper
|
||||||
|
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="org.dromara.device.mapper.BmAppMessageMapper">
|
||||||
|
|
||||||
|
<!-- 基础 CRUD 走 BaseMapperPlus,自定义 SQL 在后续迭代补充 -->
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<!DOCTYPE mapper
|
||||||
|
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="org.dromara.device.mapper.BmAppVersionMapper">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
自定义 SQL 不会被 MyBatis-Plus 追加 del_flag 条件。
|
||||||
|
版本构建号是只能前进的历史高水位,因此删除记录也必须参与最大值计算。
|
||||||
|
-->
|
||||||
|
<select id="selectMaxVersionCodeIncludingDeleted" resultType="java.lang.Integer">
|
||||||
|
SELECT MAX(version_code)
|
||||||
|
FROM bm_app_version
|
||||||
|
WHERE platform = #{platform}
|
||||||
|
<if test="excludeVersionId != null">
|
||||||
|
AND version_id != #{excludeVersionId}
|
||||||
|
</if>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -4,6 +4,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
|||||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
"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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
package org.dromara.device;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
|
import org.dromara.device.constant.AppVersionMsgConstants;
|
||||||
|
import org.dromara.device.domain.BmAppVersion;
|
||||||
|
import org.dromara.device.domain.bo.AppVersionCheckBo;
|
||||||
|
import org.dromara.device.domain.vo.AppVersionLatestVo;
|
||||||
|
import org.dromara.device.enums.AppPlatformEnum;
|
||||||
|
import org.dromara.device.enums.AppVersionStatusEnum;
|
||||||
|
import org.dromara.device.mapper.BmAppVersionMapper;
|
||||||
|
import org.dromara.device.service.impl.AppVersionServiceImpl;
|
||||||
|
import org.dromara.system.api.OssService;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertAll;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 客户端版本「检查更新」核心判定逻辑回归测试。
|
||||||
|
* <p>
|
||||||
|
* 覆盖 {@link AppVersionServiceImpl#checkLatest(AppVersionCheckBo)}:
|
||||||
|
* ① 版本比较(唯一依据是整数构建号);② 三态(无更新 / 可选更新 / 强制更新);
|
||||||
|
* ③ 平台口径统一(两平台一致下发包字段);④ 非法入参兜底;⑤ 候选集查询意图。
|
||||||
|
* <p>
|
||||||
|
* ⚠️ 测试边界(如实说明):本套用例通过 mock {@link BmAppVersionMapper} 驱动,因此验证的是
|
||||||
|
* <b>服务层的判定逻辑</b>。数据库层的真实语义({@code del_flag='0'} 逻辑删除过滤的真实 SQL、
|
||||||
|
* 索引与排序在 MySQL 上的实际执行)不在单测范围内,由交付总监在补丁 7 落库后的端到端探针覆盖。
|
||||||
|
* 第 ⑤ 组用例通过捕获查询条件做「结构化验证」,只能证明服务层向 DB 提出了正确的查询意图,
|
||||||
|
* 不能替代真实 SQL 执行验证。
|
||||||
|
*
|
||||||
|
* @author Edward(QA)
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Tag("dev")
|
||||||
|
@DisplayName("APP 版本检查更新 · AppVersionServiceImpl#checkLatest")
|
||||||
|
class AppVersionServiceImplTest {
|
||||||
|
|
||||||
|
private static final String ANDROID = "android";
|
||||||
|
private static final String IOS = "ios";
|
||||||
|
private static final String STRONG = AppVersionMsgConstants.FORCE_UPDATE_YES;
|
||||||
|
private static final String WEAK = AppVersionMsgConstants.FORCE_UPDATE_NO;
|
||||||
|
|
||||||
|
private BmAppVersionMapper appVersionMapper;
|
||||||
|
private OssService ossService;
|
||||||
|
private AppVersionServiceImpl service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单测不启动 Spring / SqlSessionFactory,需手动初始化实体的 {@code TableInfo},
|
||||||
|
* 否则 {@link LambdaQueryWrapper} 在解析列名(第 ⑤ 组用例渲染 SQL 片段)时会抛
|
||||||
|
* {@code MybatisPlus can not find lambda cache for this entity}。
|
||||||
|
*/
|
||||||
|
@BeforeAll
|
||||||
|
static void initMybatisPlusMetadata() {
|
||||||
|
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||||
|
GlobalConfigUtils.setGlobalConfig(configuration, GlobalConfigUtils.defaults());
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, BmAppVersion.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
appVersionMapper = mock(BmAppVersionMapper.class);
|
||||||
|
ossService = mock(OssService.class);
|
||||||
|
service = new AppVersionServiceImpl(appVersionMapper, ossService);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== 测试夹具 ==============================
|
||||||
|
|
||||||
|
/** 构造一条候选「线上最新版」记录(默认启用)。 */
|
||||||
|
private static BmAppVersion row(String platform, int code, String name, String forceUpdate,
|
||||||
|
String fileUrl, Long fileSize, String md5, String storeUrl) {
|
||||||
|
BmAppVersion v = new BmAppVersion();
|
||||||
|
v.setPlatform(platform);
|
||||||
|
v.setVersionCode(code);
|
||||||
|
v.setVersionName(name);
|
||||||
|
v.setForceUpdate(forceUpdate);
|
||||||
|
v.setOssId(1L);
|
||||||
|
v.setFileUrl(fileUrl);
|
||||||
|
v.setFileSize(fileSize);
|
||||||
|
v.setMd5(md5);
|
||||||
|
v.setStoreUrl(storeUrl);
|
||||||
|
v.setStatus(AppVersionStatusEnum.ENABLED.getCode());
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BmAppVersion androidRow(int code, String name, String forceUpdate) {
|
||||||
|
return row(ANDROID, code, name, forceUpdate, "https://oss.example.com/app.apk", 20_480_000L, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppVersionCheckBo check(String platform, Integer versionCode) {
|
||||||
|
AppVersionCheckBo bo = new AppVersionCheckBo();
|
||||||
|
bo.setPlatform(platform);
|
||||||
|
bo.setVersionCode(versionCode);
|
||||||
|
return bo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 令分页查询返回给定记录,代表「从候选集中取到的最新版」(null 表示无启用记录)。 */
|
||||||
|
private void latestWillBe(BmAppVersion row) {
|
||||||
|
Page<BmAppVersion> page = new Page<>(1, 1, false);
|
||||||
|
page.setRecords(row == null ? List.of() : List.of(row));
|
||||||
|
when(appVersionMapper.selectPage(any(Page.class), any())).thenReturn(page);
|
||||||
|
if (row != null) {
|
||||||
|
when(ossService.selectDownloadUrlById(eq(row.getOssId()), any())).thenReturn(row.getFileUrl());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== ① 版本比较 ==============================
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("① 版本比较:唯一依据是整数构建号 versionCode")
|
||||||
|
class Comparison {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("核心用例:上报 1000009(1.0.9) 对线上 1000010(1.0.10) → hasUpdate=true")
|
||||||
|
void detectsNewerBuildAcrossTens() {
|
||||||
|
latestWillBe(androidRow(1_000_010, "1.0.10", WEAK));
|
||||||
|
|
||||||
|
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_009));
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
() -> assertTrue(vo.getHasUpdate(), "较低的构建号必须被判定为有新版本"),
|
||||||
|
() -> assertEquals(1_000_010, vo.getLatestVersionCode().intValue()),
|
||||||
|
() -> assertEquals("1.0.10", vo.getLatestVersionName()),
|
||||||
|
() -> assertEquals(1_000_009, vo.getCurrentVersionCode().intValue())
|
||||||
|
);
|
||||||
|
// 佐证本用例的必要性:按展示版本号做字符串比较会判反('1.0.9' > '1.0.10'),
|
||||||
|
// 因此「跨十位版本」必须靠整数构建号比较,否则 1.0.9 的用户永远收不到 1.0.10。
|
||||||
|
assertTrue("1.0.9".compareTo("1.0.10") > 0, "字符串比较会误判:1.0.9 字典序大于 1.0.10");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("上报构建号等于线上最新 → hasUpdate=false")
|
||||||
|
void noUpdateWhenClientEqualsLatest() {
|
||||||
|
latestWillBe(androidRow(1_000_010, "1.0.10", WEAK));
|
||||||
|
|
||||||
|
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_010));
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
() -> assertFalse(vo.getHasUpdate()),
|
||||||
|
() -> assertFalse(vo.getForceUpdate()),
|
||||||
|
() -> assertNull(vo.getLatestVersionCode()),
|
||||||
|
() -> assertNull(vo.getLatestVersionName())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("上报构建号高于线上最新 → hasUpdate=false(不提示降级)")
|
||||||
|
void noUpdateWhenClientIsNewer() {
|
||||||
|
latestWillBe(androidRow(1_000_010, "1.0.10", WEAK));
|
||||||
|
|
||||||
|
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_011));
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
() -> assertFalse(vo.getHasUpdate()),
|
||||||
|
() -> assertFalse(vo.getForceUpdate()),
|
||||||
|
() -> assertNull(vo.getLatestVersionCode())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== ② 三态 / 强更 ==============================
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("② 三态:无更新 / 可选更新 / 强制更新")
|
||||||
|
class TriState {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("有新版本 + force_update='0' → 可选更新(hasUpdate=true, forceUpdate=false)")
|
||||||
|
void optionalUpdateWhenForceOff() {
|
||||||
|
latestWillBe(androidRow(1_000_010, "1.0.10", WEAK));
|
||||||
|
|
||||||
|
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_009));
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
() -> assertTrue(vo.getHasUpdate()),
|
||||||
|
() -> assertFalse(vo.getForceUpdate())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("有新版本 + force_update='1' → 强制更新(hasUpdate=true, forceUpdate=true)")
|
||||||
|
void forcedUpdateWhenForceOn() {
|
||||||
|
latestWillBe(androidRow(1_000_010, "1.0.10", STRONG));
|
||||||
|
|
||||||
|
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_009));
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
() -> assertTrue(vo.getHasUpdate()),
|
||||||
|
() -> assertTrue(vo.getForceUpdate())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("已是最新时即使 force_update='1',forceUpdate 也恒为 false(PRD C8)")
|
||||||
|
void forceUpdateAlwaysFalseWhenNoUpdate() {
|
||||||
|
latestWillBe(androidRow(1_000_010, "1.0.10", STRONG));
|
||||||
|
|
||||||
|
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_010));
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
() -> assertFalse(vo.getHasUpdate()),
|
||||||
|
() -> assertFalse(vo.getForceUpdate(), "无更新时 forceUpdate 必须恒为 false")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("无可用版本记录 → 表达为「无更新」而非抛异常(PRD C3)")
|
||||||
|
void noEnabledRecordYieldsNoUpdate() {
|
||||||
|
latestWillBe(null);
|
||||||
|
|
||||||
|
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_009));
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
() -> assertFalse(vo.getHasUpdate()),
|
||||||
|
() -> assertFalse(vo.getForceUpdate()),
|
||||||
|
() -> assertNull(vo.getLatestVersionCode()),
|
||||||
|
() -> assertNull(vo.getLatestVersionName()),
|
||||||
|
() -> assertNull(vo.getChangelog()),
|
||||||
|
() -> assertNull(vo.getDownloadUrl()),
|
||||||
|
() -> assertNull(vo.getStoreUrl()),
|
||||||
|
() -> assertEquals(1_000_009, vo.getCurrentVersionCode().intValue(), "回显入参便于 APP 自校验")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== ③ 平台口径统一 ==============================
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("③ 平台口径统一:两平台都下发安装包字段;storeUrl 有值才下发")
|
||||||
|
class UnifiedPlatformFields {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("android → downloadUrl/fileSize 有值;storeUrl 未填时下发 null")
|
||||||
|
void androidCarriesPackageFieldsWithoutStoreUrl() {
|
||||||
|
latestWillBe(androidRow(1_000_010, "1.0.10", WEAK));
|
||||||
|
|
||||||
|
AppVersionLatestVo vo = service.checkLatest(check(ANDROID, 1_000_009));
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
() -> assertEquals(ANDROID, vo.getPlatform()),
|
||||||
|
() -> assertTrue(vo.getHasUpdate()),
|
||||||
|
() -> assertEquals("https://oss.example.com/app.apk", vo.getDownloadUrl()),
|
||||||
|
() -> assertEquals(20_480_000L, vo.getFileSize().longValue()),
|
||||||
|
() -> assertNull(vo.getStoreUrl(), "未填 storeUrl 时应下发 null")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ios → 与 android 口径一致:同样下发 downloadUrl/fileSize/md5;storeUrl 有值则下发")
|
||||||
|
void iosAlsoCarriesPackageFields() {
|
||||||
|
// iOS 也上传安装包:候选记录同时带包字段与 storeUrl,验证两平台口径一致、不再按平台裁剪
|
||||||
|
BmAppVersion iosWithPackage = row(IOS, 1_000_010, "1.0.10", WEAK,
|
||||||
|
"https://oss.example.com/app.ipa", 12_345_678L, "deadbeef", "https://apps.apple.com/app/id123");
|
||||||
|
latestWillBe(iosWithPackage);
|
||||||
|
|
||||||
|
AppVersionLatestVo vo = service.checkLatest(check(IOS, 1_000_009));
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
() -> assertEquals(IOS, vo.getPlatform()),
|
||||||
|
() -> assertTrue(vo.getHasUpdate()),
|
||||||
|
() -> assertEquals("https://oss.example.com/app.ipa", vo.getDownloadUrl(), "iOS 也应下发安装包地址"),
|
||||||
|
() -> assertEquals(12_345_678L, vo.getFileSize().longValue(), "iOS 也应下发包体积"),
|
||||||
|
() -> assertEquals("deadbeef", vo.getMd5(), "iOS 也应下发 md5"),
|
||||||
|
() -> assertEquals("https://apps.apple.com/app/id123", vo.getStoreUrl(), "storeUrl 有值则下发")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== ④ 非法入参兜底 ==============================
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("④ 非法入参兜底:平台非法 / 构建号非正整数")
|
||||||
|
class InvalidInput {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("platform 为 null → 平台参数不合法")
|
||||||
|
void nullPlatformRejected() {
|
||||||
|
ServiceException ex = assertThrows(ServiceException.class, () -> service.checkLatest(check(null, 1)));
|
||||||
|
|
||||||
|
assertEquals(AppVersionMsgConstants.PLATFORM_INVALID, ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("platform 取值非法(windows / 大写 / 空串)→ 平台参数不合法")
|
||||||
|
void illegalPlatformRejected() {
|
||||||
|
assertAll(
|
||||||
|
() -> assertEquals(AppVersionMsgConstants.PLATFORM_INVALID,
|
||||||
|
assertThrows(ServiceException.class, () -> service.checkLatest(check("windows", 1))).getMessage()),
|
||||||
|
() -> assertEquals(AppVersionMsgConstants.PLATFORM_INVALID,
|
||||||
|
assertThrows(ServiceException.class, () -> service.checkLatest(check("ANDROID", 1))).getMessage()),
|
||||||
|
() -> assertEquals(AppVersionMsgConstants.PLATFORM_INVALID,
|
||||||
|
assertThrows(ServiceException.class, () -> service.checkLatest(check("", 1))).getMessage())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("入参对象为 null → 平台参数不合法(不应 NPE)")
|
||||||
|
void nullBoRejected() {
|
||||||
|
ServiceException ex = assertThrows(ServiceException.class, () -> service.checkLatest(null));
|
||||||
|
|
||||||
|
assertEquals(AppVersionMsgConstants.PLATFORM_INVALID, ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("versionCode 为 null / 0 / 负数 → 版本号不合法")
|
||||||
|
void illegalVersionCodeRejected() {
|
||||||
|
assertAll(
|
||||||
|
() -> assertEquals(AppVersionMsgConstants.VERSION_CODE_INVALID,
|
||||||
|
assertThrows(ServiceException.class, () -> service.checkLatest(check(ANDROID, null))).getMessage()),
|
||||||
|
() -> assertEquals(AppVersionMsgConstants.VERSION_CODE_INVALID,
|
||||||
|
assertThrows(ServiceException.class, () -> service.checkLatest(check(ANDROID, 0))).getMessage()),
|
||||||
|
() -> assertEquals(AppVersionMsgConstants.VERSION_CODE_INVALID,
|
||||||
|
assertThrows(ServiceException.class, () -> service.checkLatest(check(ANDROID, -5))).getMessage())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("平台枚举严格匹配,不接受大小写或别名")
|
||||||
|
void platformEnumIsStrict() {
|
||||||
|
assertAll(
|
||||||
|
() -> assertTrue(AppPlatformEnum.isValid(ANDROID)),
|
||||||
|
() -> assertTrue(AppPlatformEnum.isValid(IOS)),
|
||||||
|
() -> assertFalse(AppPlatformEnum.isValid("ANDROID")),
|
||||||
|
() -> assertFalse(AppPlatformEnum.isValid("Ios")),
|
||||||
|
() -> assertFalse(AppPlatformEnum.isValid("web"))
|
||||||
|
);
|
||||||
|
assertFalse(AppPlatformEnum.isValid(null), "null 必须判为非法");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================== ⑤ 候选集查询意图(结构化验证) ==============================
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("⑤ 候选集查询意图(结构化验证,真实 SQL 语义由端到端探针覆盖)")
|
||||||
|
class QueryIntent {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("候选集限定 platform + status='1',按构建号倒序取一条(对应 PRD C10/C11 查询口径)")
|
||||||
|
void queryIsLimitedToEnabledRowsOrderedByMaxBuildNumber() {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<LambdaQueryWrapper<BmAppVersion>> captor =
|
||||||
|
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<Page<BmAppVersion>> pageCaptor = ArgumentCaptor.forClass(Page.class);
|
||||||
|
Page<BmAppVersion> empty = new Page<>(1, 1, false);
|
||||||
|
empty.setRecords(List.of());
|
||||||
|
when(appVersionMapper.selectPage(pageCaptor.capture(), captor.capture())).thenReturn(empty);
|
||||||
|
|
||||||
|
service.checkLatest(check(ANDROID, 1_000_009));
|
||||||
|
|
||||||
|
LambdaQueryWrapper<BmAppVersion> wrapper = captor.getValue();
|
||||||
|
Map<String, Object> boundValues = wrapper.getParamNameValuePairs();
|
||||||
|
String sqlSegment = wrapper.getSqlSegment().toLowerCase();
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
() -> assertTrue(boundValues.containsValue(ANDROID), "查询应限定 platform=android"),
|
||||||
|
() -> assertTrue(boundValues.containsValue(AppVersionStatusEnum.ENABLED.getCode()),
|
||||||
|
"查询应限定 status='1':停用记录不得进入候选集(PRD C10/C11)"),
|
||||||
|
() -> assertTrue(sqlSegment.contains("version_code"), "候选集应按 version_code 排序"),
|
||||||
|
() -> assertTrue(sqlSegment.contains("desc"), "应取构建号最大的一条(倒序,而非创建时间最新)"),
|
||||||
|
() -> assertEquals(1L, pageCaptor.getValue().getSize(), "候选集应通过方言分页只取一条"),
|
||||||
|
() -> assertFalse(sqlSegment.contains("limit"), "业务 SQL 不应硬编码数据库方言")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package org.dromara.device;
|
||||||
|
|
||||||
|
import cn.hutool.core.bean.BeanUtil;
|
||||||
|
import cn.hutool.extra.spring.SpringUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils;
|
||||||
|
import io.github.linpeilie.Converter;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
|
import org.dromara.device.constant.AppVersionMsgConstants;
|
||||||
|
import org.dromara.device.domain.BmAppVersion;
|
||||||
|
import org.dromara.device.domain.bo.AppVersionBo;
|
||||||
|
import org.dromara.device.mapper.BmAppVersionMapper;
|
||||||
|
import org.dromara.device.service.impl.AppVersionServiceImpl;
|
||||||
|
import org.dromara.system.api.OssService;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.context.support.StaticApplicationContext;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 版本写侧回归测试,覆盖历史版本编辑与构建号高水位。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Tag("dev")
|
||||||
|
@DisplayName("APP 版本写入规则")
|
||||||
|
class AppVersionWriteServiceTest {
|
||||||
|
|
||||||
|
private BmAppVersionMapper appVersionMapper;
|
||||||
|
private AppVersionServiceImpl service;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initMybatisPlusMetadata() {
|
||||||
|
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||||
|
GlobalConfigUtils.setGlobalConfig(configuration, GlobalConfigUtils.defaults());
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, BmAppVersion.class);
|
||||||
|
|
||||||
|
// 纯单测没有 Spring Boot 容器,为 MapstructUtils 注册最小转换器。
|
||||||
|
Converter converter = mock(Converter.class, invocation -> {
|
||||||
|
Object[] arguments = invocation.getArguments();
|
||||||
|
if (arguments.length == 2 && arguments[0] instanceof AppVersionBo source
|
||||||
|
&& arguments[1] == BmAppVersion.class) {
|
||||||
|
BmAppVersion target = new BmAppVersion();
|
||||||
|
BeanUtil.copyProperties(source, target);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
StaticApplicationContext context = new StaticApplicationContext();
|
||||||
|
context.getBeanFactory().registerSingleton("converter", converter);
|
||||||
|
new SpringUtil().setApplicationContext(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
appVersionMapper = mock(BmAppVersionMapper.class);
|
||||||
|
service = new AppVersionServiceImpl(appVersionMapper, mock(OssService.class));
|
||||||
|
when(appVersionMapper.selectCount(any())).thenReturn(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("历史版本构建号未变化时允许编辑和启停")
|
||||||
|
void unchangedHistoricalBuildCanBeUpdated() {
|
||||||
|
BmAppVersion existing = version(10L, 100, "1.0.0", "1");
|
||||||
|
when(appVersionMapper.selectById(10L)).thenReturn(existing);
|
||||||
|
when(appVersionMapper.updateById(any(BmAppVersion.class))).thenReturn(1);
|
||||||
|
|
||||||
|
AppVersionBo bo = form(10L, 100, "1.0.0", "0");
|
||||||
|
|
||||||
|
assertTrue(service.updateByBo(bo));
|
||||||
|
verify(appVersionMapper, never()).selectMaxVersionCodeIncludingDeleted(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("实际修改构建号时仍须大于包含删除记录的历史高水位")
|
||||||
|
void changedBuildMustExceedHistoricalMaximum() {
|
||||||
|
when(appVersionMapper.selectById(10L)).thenReturn(version(10L, 100, "1.0.0", "1"));
|
||||||
|
when(appVersionMapper.selectMaxVersionCodeIncludingDeleted(eq("android"), eq(10L))).thenReturn(300);
|
||||||
|
|
||||||
|
ServiceException error = assertThrows(ServiceException.class,
|
||||||
|
() -> service.updateByBo(form(10L, 200, "1.0.1", "1")));
|
||||||
|
|
||||||
|
assertEquals(String.format(AppVersionMsgConstants.VERSION_CODE_NOT_GREATER, 300), error.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("删除最高版本后也不能新增更低构建号")
|
||||||
|
void deletedMaximumStillGuardsNewBuild() {
|
||||||
|
when(appVersionMapper.selectMaxVersionCodeIncludingDeleted(eq("android"), isNull())).thenReturn(300);
|
||||||
|
|
||||||
|
ServiceException error = assertThrows(ServiceException.class,
|
||||||
|
() -> service.insertByBo(form(null, 200, "2.0.0", "1")));
|
||||||
|
|
||||||
|
assertEquals(String.format(AppVersionMsgConstants.VERSION_CODE_NOT_GREATER, 300), error.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BmAppVersion version(Long id, int code, String name, String status) {
|
||||||
|
BmAppVersion version = new BmAppVersion();
|
||||||
|
version.setVersionId(id);
|
||||||
|
version.setPlatform("android");
|
||||||
|
version.setVersionCode(code);
|
||||||
|
version.setVersionName(name);
|
||||||
|
version.setForceUpdate("0");
|
||||||
|
version.setOssId(1L);
|
||||||
|
version.setFileUrl("https://oss.example.com/app.apk");
|
||||||
|
version.setFileName("app.apk");
|
||||||
|
version.setFileSize(1024L);
|
||||||
|
version.setStatus(status);
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppVersionBo form(Long id, int code, String name, String status) {
|
||||||
|
AppVersionBo bo = new AppVersionBo();
|
||||||
|
bo.setVersionId(id);
|
||||||
|
bo.setPlatform("android");
|
||||||
|
bo.setVersionCode(code);
|
||||||
|
bo.setVersionName(name);
|
||||||
|
bo.setForceUpdate("0");
|
||||||
|
bo.setOssId(1L);
|
||||||
|
bo.setFileUrl("https://oss.example.com/app.apk");
|
||||||
|
bo.setFileName("app.apk");
|
||||||
|
bo.setFileSize(1024L);
|
||||||
|
bo.setStatus(status);
|
||||||
|
return bo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.dromara.device.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertAll;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 配置默认值回归测试。
|
||||||
|
*/
|
||||||
|
@Tag("dev")
|
||||||
|
@DisplayName("APP 配置默认值")
|
||||||
|
class AppPropertiesTest {
|
||||||
|
|
||||||
|
private static final long THIRTY_DAYS_IN_SECONDS = 30L * 24 * 60 * 60;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("登录令牌固定有效期和活跃超时均为 30 天")
|
||||||
|
void tokenExpiresAfterThirtyDays() {
|
||||||
|
AppProperties.Client client = new AppProperties().getClient();
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
() -> assertEquals(THIRTY_DAYS_IN_SECONDS, client.getTimeout()),
|
||||||
|
() -> assertEquals(THIRTY_DAYS_IN_SECONDS, client.getActiveTimeout())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package org.dromara.device.mjpeg;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MJPEG 帧会话回归测试。
|
||||||
|
*/
|
||||||
|
@DisplayName("MJPEG 帧会话")
|
||||||
|
class MjpegFrameHubTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("停播清理后在途帧不能重新创建会话")
|
||||||
|
void inFlightFrameCannotReopenClearedSession() {
|
||||||
|
MjpegFrameHub hub = new MjpegFrameHub();
|
||||||
|
hub.open("BM001", "old-token");
|
||||||
|
hub.clear("BM001");
|
||||||
|
|
||||||
|
hub.publish("BM001", "old-token", new byte[]{(byte) 0xFF, (byte) 0xD8});
|
||||||
|
|
||||||
|
assertFalse(hub.deviceNos().contains("BM001"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("旧请求不能把帧写入重开的新会话")
|
||||||
|
void staleTokenCannotPublishIntoReopenedSession() {
|
||||||
|
MjpegFrameHub hub = new MjpegFrameHub();
|
||||||
|
hub.open("BM001", "old-token");
|
||||||
|
hub.clear("BM001");
|
||||||
|
hub.open("BM001", "new-token");
|
||||||
|
|
||||||
|
assertFalse(hub.publish("BM001", "old-token", new byte[]{(byte) 0xFF, (byte) 0xD8}));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package org.dromara.device.mjpeg;
|
||||||
|
|
||||||
|
import org.dromara.device.config.MjpegProperties;
|
||||||
|
import org.dromara.device.config.ZlmProperties;
|
||||||
|
import org.dromara.device.mqtt.DeviceCommandPublisher;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MJPEG 无人观看停推回归测试。
|
||||||
|
*/
|
||||||
|
@DisplayName("MJPEG 空闲停推")
|
||||||
|
class MjpegIdleMonitorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("最后一名观看者离开后重新计算空闲时间")
|
||||||
|
void idleWindowStartsWhenLastViewerLeaves() throws Exception {
|
||||||
|
MjpegProperties properties = new MjpegProperties();
|
||||||
|
properties.setIdleStopSeconds(1);
|
||||||
|
MjpegFrameHub hub = new MjpegFrameHub();
|
||||||
|
MjpegIngestTokenService tokenService = mock(MjpegIngestTokenService.class);
|
||||||
|
DeviceCommandPublisher publisher = mock(DeviceCommandPublisher.class);
|
||||||
|
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
|
||||||
|
try {
|
||||||
|
MjpegIdleMonitor monitor = new MjpegIdleMonitor(
|
||||||
|
properties, hub, tokenService, publisher, new ZlmProperties(),
|
||||||
|
new MjpegLifecycleCoordinator(), executor);
|
||||||
|
hub.open("BM001");
|
||||||
|
hub.addViewer("BM001");
|
||||||
|
Thread.sleep(1_100L);
|
||||||
|
hub.removeViewer("BM001");
|
||||||
|
|
||||||
|
ReflectionTestUtils.invokeMethod(monitor, "scan");
|
||||||
|
|
||||||
|
verify(publisher, never()).publishStream(eq("BM001"), any());
|
||||||
|
} finally {
|
||||||
|
executor.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package org.dromara.device.mjpeg;
|
||||||
|
|
||||||
|
import jakarta.servlet.ServletOutputStream;
|
||||||
|
import jakarta.servlet.WriteListener;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.dromara.device.config.MjpegProperties;
|
||||||
|
import org.dromara.device.zlm.stream.ZlmPlayAuthService;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MJPEG 播放连接生命周期回归测试。
|
||||||
|
*/
|
||||||
|
@DisplayName("MJPEG 播放连接")
|
||||||
|
class MjpegStreamControllerTest {
|
||||||
|
|
||||||
|
private static final String DEVICE_NO = "BM001";
|
||||||
|
|
||||||
|
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||||
|
private MjpegFrameHub hub;
|
||||||
|
private ZlmPlayAuthService playAuthService;
|
||||||
|
private MjpegStreamController controller;
|
||||||
|
private HttpServletRequest request;
|
||||||
|
private HttpServletResponse response;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
hub = new MjpegFrameHub();
|
||||||
|
MjpegProperties properties = new MjpegProperties();
|
||||||
|
properties.setFps(20);
|
||||||
|
playAuthService = mock(ZlmPlayAuthService.class);
|
||||||
|
controller = new MjpegStreamController(
|
||||||
|
hub, mock(MjpegIngestTokenService.class), properties, playAuthService);
|
||||||
|
request = mock(HttpServletRequest.class);
|
||||||
|
response = mock(HttpServletResponse.class);
|
||||||
|
when(request.getRemoteAddr()).thenReturn("127.0.0.1");
|
||||||
|
when(response.getOutputStream()).thenReturn(new MemoryServletOutputStream());
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
executor.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("平台清理会话后已有播放连接退出")
|
||||||
|
void existingConnectionStopsWhenSessionIsCleared() throws Exception {
|
||||||
|
when(playAuthService.validate(anyString(), anyString(), anyString())).thenReturn(true);
|
||||||
|
hub.open(DEVICE_NO);
|
||||||
|
Future<?> playback = startPlayback();
|
||||||
|
awaitViewer();
|
||||||
|
|
||||||
|
hub.clear(DEVICE_NO);
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> playback.get(800, TimeUnit.MILLISECONDS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("播放 token 失效后已有连接退出")
|
||||||
|
void existingConnectionStopsWhenTokenExpires() throws Exception {
|
||||||
|
when(playAuthService.validate(anyString(), anyString(), anyString()))
|
||||||
|
.thenReturn(true, false);
|
||||||
|
hub.open(DEVICE_NO);
|
||||||
|
Future<?> playback = startPlayback();
|
||||||
|
awaitViewer();
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> playback.get(1_800, TimeUnit.MILLISECONDS));
|
||||||
|
verify(playAuthService, times(2)).validate("play-token", DEVICE_NO, "127.0.0.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Future<?> startPlayback() {
|
||||||
|
return executor.submit(() -> {
|
||||||
|
try {
|
||||||
|
controller.play(DEVICE_NO, "play-token", request, response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void awaitViewer() throws InterruptedException {
|
||||||
|
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1);
|
||||||
|
while (hub.viewerCount(DEVICE_NO) == 0 && System.nanoTime() < deadline) {
|
||||||
|
Thread.sleep(5L);
|
||||||
|
}
|
||||||
|
assertTrue(hub.viewerCount(DEVICE_NO) > 0, "播放线程未进入观看状态");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class MemoryServletOutputStream extends ServletOutputStream {
|
||||||
|
|
||||||
|
private final ByteArrayOutputStream delegate = new ByteArrayOutputStream();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isReady() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setWriteListener(WriteListener writeListener) {
|
||||||
|
// 同步测试输出流无需异步监听器。
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(int value) throws IOException {
|
||||||
|
delegate.write(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package org.dromara.device.mqtt.support;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import org.dromara.device.config.DeviceMqttProperties;
|
||||||
|
import org.dromara.device.domain.BmAppMessage;
|
||||||
|
import org.dromara.device.domain.BmDeviceAlert;
|
||||||
|
import org.dromara.device.mapper.BmAppMessageMapper;
|
||||||
|
import org.dromara.device.mapper.BmDeviceAlertMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 告警消息快照与 outbox 补偿回归测试。
|
||||||
|
*
|
||||||
|
* @author Kou
|
||||||
|
* @date 2026-09-18
|
||||||
|
*/
|
||||||
|
@Tag("dev")
|
||||||
|
@DisplayName("设备告警消息写入")
|
||||||
|
class DeviceAlertWriterTest {
|
||||||
|
|
||||||
|
private BmDeviceAlertMapper alertMapper;
|
||||||
|
private BmAppMessageMapper messageMapper;
|
||||||
|
private DeviceAlertWriter writer;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
alertMapper = mock(BmDeviceAlertMapper.class);
|
||||||
|
messageMapper = mock(BmAppMessageMapper.class);
|
||||||
|
writer = new DeviceAlertWriter(alertMapper, messageMapper, mock(DeviceMqttProperties.class));
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
BmDeviceAlert alert = invocation.getArgument(0);
|
||||||
|
alert.setAlertId(100L);
|
||||||
|
return 1;
|
||||||
|
}).when(alertMapper).insert(any(BmDeviceAlert.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("消息收件人使用告警发生时快照")
|
||||||
|
void writesMessageForSnapshotUser() {
|
||||||
|
when(messageMapper.insert(any(BmAppMessage.class))).thenReturn(1);
|
||||||
|
|
||||||
|
assertTrue(writer.writeOffline(1L, "BM001", 77L));
|
||||||
|
|
||||||
|
ArgumentCaptor<BmDeviceAlert> alertCaptor = ArgumentCaptor.forClass(BmDeviceAlert.class);
|
||||||
|
ArgumentCaptor<BmAppMessage> messageCaptor = ArgumentCaptor.forClass(BmAppMessage.class);
|
||||||
|
verify(alertMapper).insert(alertCaptor.capture());
|
||||||
|
verify(messageMapper).insert(messageCaptor.capture());
|
||||||
|
assertEquals(77L, alertCaptor.getValue().getMessageUserId());
|
||||||
|
assertEquals(77L, messageCaptor.getValue().getUserId());
|
||||||
|
assertEquals(100L, messageCaptor.getValue().getAlertId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("同步消息写入失败后由 outbox 补偿成功")
|
||||||
|
void retriesFailedMessageFromAlertOutbox() {
|
||||||
|
doThrow(new RuntimeException("temporary database failure"))
|
||||||
|
.doReturn(1)
|
||||||
|
.when(messageMapper).insert(any(BmAppMessage.class));
|
||||||
|
|
||||||
|
assertTrue(writer.writeOffline(1L, "BM001", 77L));
|
||||||
|
|
||||||
|
ArgumentCaptor<BmDeviceAlert> alertCaptor = ArgumentCaptor.forClass(BmDeviceAlert.class);
|
||||||
|
verify(alertMapper).insert(alertCaptor.capture());
|
||||||
|
Page<BmDeviceAlert> pending = new Page<>(1, 100, false);
|
||||||
|
pending.setRecords(List.of(alertCaptor.getValue()));
|
||||||
|
when(alertMapper.selectPendingMessageAlerts(any(Page.class))).thenReturn(pending);
|
||||||
|
|
||||||
|
assertEquals(1, writer.retryPendingMessages(100));
|
||||||
|
verify(messageMapper, times(2)).insert(any(BmAppMessage.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package org.dromara.device.sms;
|
||||||
|
|
||||||
|
import cn.hutool.extra.spring.SpringUtil;
|
||||||
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.context.support.StaticApplicationContext;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 手机号规范化回归测试。
|
||||||
|
*/
|
||||||
|
@DisplayName("APP 手机号规范化")
|
||||||
|
class AppPhoneNumbersTest {
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setUpMessageSource() {
|
||||||
|
// 非 Spring 单测为 MessageUtils 提供最小消息源,确保验证的是业务异常。
|
||||||
|
StaticApplicationContext context = new StaticApplicationContext();
|
||||||
|
new SpringUtil().setApplicationContext(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("北美 +1 号码保留国际身份并走国际通道")
|
||||||
|
void preservesCountryIdentityForNanpNumber() {
|
||||||
|
String canonical = AppPhoneNumbers.canonical("+14155552671");
|
||||||
|
|
||||||
|
assertEquals("+14155552671", canonical);
|
||||||
|
assertFalse(AppPhoneNumbers.chinaMainland(canonical));
|
||||||
|
assertEquals("14155552671", AppPhoneNumbers.forSms(canonical));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("+86 与 00 形式统一归一为大陆 11 位号码")
|
||||||
|
void normalizesChinaCountryCode() {
|
||||||
|
assertEquals("13800138000", AppPhoneNumbers.canonical("+8613800138000"));
|
||||||
|
assertEquals("13800138000", AppPhoneNumbers.canonical("008613800138000"));
|
||||||
|
assertTrue(AppPhoneNumbers.chinaMainland("13800138000"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("未带国际前缀的非大陆格式号码不能按国际号码接收")
|
||||||
|
void rejectsUnprefixedInternationalNumber() {
|
||||||
|
assertThrows(ServiceException.class, () -> AppPhoneNumbers.canonical("4155552671"));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 文件列表查询条件。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user