Compare commits

..

3 Commits

Author SHA1 Message Date
yuhaiming
827b48e954 设备注册 短信跟邮箱验证 2026-09-23 16:28:24 +08:00
yuhaiming
bc1074d194 新视频推送接收(未测) app升级 告警 国际短信跟谷歌邮箱接入 2026-09-19 08:47:04 +08:00
cnc
e4ef35c795 google邮箱发送验证码 2026-09-18 17:58:10 +08:00
113 changed files with 6453 additions and 392 deletions

View File

@@ -0,0 +1,64 @@
package org.dromara.web.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.hutool.core.util.RandomUtil;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.regex.RegexValidator;
import org.dromara.common.mail.core.GmailCodeService;
import org.dromara.common.redis.annotation.RateLimiter;
import org.dromara.common.redis.utils.RedisUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
/**
* Gmail 邮箱验证码接口。
*
* @author Qoder
*/
@SaIgnore
@Slf4j
@Validated
@RequiredArgsConstructor
@RestController
public class GmailCaptchaController {
private final GmailCodeService gmailCodeService;
/**
* 使用 Gmail 发送邮箱验证码。
*
* @param email 收件邮箱
* @return 操作结果
*/
@GetMapping("/resource/gmail/code")
public R<Void> gmailCode(@NotBlank(message = "{user.email.not.blank}") String email) {
if (!RegexValidator.isEmail(email)) {
return R.fail("请输入正确的邮箱地址!");
}
SpringUtils.getAopProxy(this).gmailCodeImpl(email);
return R.ok();
}
/**
* 发送邮箱验证码的实际执行方法,拆分出来以便限流注解生效。
*
* @param email 收件邮箱
*/
@RateLimiter(key = "#email", time = 60, count = 1)
public void gmailCodeImpl(String email) {
String key = GlobalConstants.CAPTCHA_CODE_KEY + email;
String code = RandomUtil.randomNumbers(4);
// 先落缓存再发信:即使发信异常,验证码也不会丢失;发信失败会抛 ServiceException 由全局异常处理
gmailCodeService.sendVerifyCode(email, code, Constants.CAPTCHA_EXPIRATION);
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<title>JUMHABI 隐私政策</title>
<style>
:root{--ink:#172033;--muted:#647089;--line:#e6eaf0;--brand:#2563eb;--soft:#eff6ff;--warn:#fff7ed;--danger:#fff1f2;--bg:#f6f8fb;--white:#fff;--anchor-gap:16px}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",Arial,sans-serif;line-height:1.8}
.hero{background:linear-gradient(135deg,#14213d,#2563eb);color:#fff;padding:56px 20px 48px}.hero-inner{max-width:1080px;margin:auto}.eyebrow{font-size:13px;letter-spacing:.12em;opacity:.8}.hero h1{font-size:clamp(30px,5vw,48px);margin:12px 0 8px;letter-spacing:.02em}.hero p{margin:0;max-width:720px;color:#dbeafe;font-size:16px}.meta{display:flex;gap:12px;flex-wrap:wrap;margin-top:24px}.pill{padding:5px 12px;border:1px solid rgba(255,255,255,.35);border-radius:999px;font-size:13px}
.layout{max-width:1080px;margin:28px auto 64px;padding:0 20px;display:grid;grid-template-columns:230px 1fr;gap:28px}.toc{position:sticky;top:18px;align-self:start;background:var(--white);border:1px solid var(--line);padding:18px;border-radius:10px;box-shadow:0 5px 18px rgba(24,39,75,.05)}.toc strong{font-size:14px}.toc a{display:block;color:var(--muted);text-decoration:none;font-size:13px;padding:5px 0}.toc a:hover,.toc a:focus-visible{color:var(--brand)}main{min-width:0}.notice{background:var(--warn);border:1px solid #fed7aa;border-left:4px solid #f97316;padding:16px 18px;border-radius:8px;margin-bottom:20px}.notice strong{color:#9a3412}.card{background:var(--white);border:1px solid var(--line);border-radius:10px;padding:26px 28px;margin:16px 0;box-shadow:0 5px 18px rgba(24,39,75,.04)}h2{font-size:23px;margin:0 0 14px;padding-top:4px}h3{font-size:17px;margin:22px 0 8px}p{margin:8px 0}ul,ol{padding-left:22px}li{margin:5px 0}.tag{display:inline-block;background:var(--soft);color:#1d4ed8;padding:1px 8px;border-radius:5px;font-size:12px}.unconfirmed{color:#9a3412;background:#fff7ed;border:1px dashed #fdba74;border-radius:4px;padding:1px 5px;white-space:normal;font-size:.92em}table{width:100%;border-collapse:collapse;margin:12px 0;font-size:13px;display:block;overflow-x:auto;overscroll-behavior-inline:contain;-webkit-overflow-scrolling:touch;white-space:normal}th,td{border:1px solid var(--line);padding:10px 11px;vertical-align:top;min-width:125px}th{background:#f8fafc;text-align:left;color:#334155}.risk-high{color:#b91c1c;font-weight:700}.risk-mid{color:#c2410c;font-weight:700}.risk-low{color:#166534;font-weight:700}.footer{text-align:center;color:var(--muted);font-size:13px;margin:35px 0}.back{color:var(--brand);text-decoration:none;font-size:13px}section[id]{scroll-margin-top:var(--anchor-gap)}@media(max-width:820px){.hero{padding:36px 18px 30px}.hero h1{font-size:32px}.hero p{font-size:15px}.meta{gap:8px;margin-top:18px}.pill{font-size:12px}.layout{display:block;margin:14px auto 40px;padding:0 12px}.toc{position:sticky;top:0;z-index:5;margin:0 -12px 14px;padding:8px 12px 9px;border-width:0 0 1px;border-radius:0;box-shadow:0 3px 12px rgba(24,39,75,.08);overflow:hidden}.toc strong{display:block;font-size:12px;margin:0 0 5px}.toc a{display:inline-flex;align-items:center;white-space:nowrap;min-height:34px;margin:0 6px 0 0;padding:5px 10px;border:1px solid var(--line);border-radius:6px;background:#fff;font-size:12px}.toc nav{display:flex;overflow-x:auto;overscroll-behavior-inline:contain;-webkit-overflow-scrolling:touch;scrollbar-width:thin;padding-bottom:2px}.toc a:focus-visible{outline:2px solid var(--brand);outline-offset:1px}.notice{padding:13px 14px;margin-bottom:12px}.card{padding:18px 15px;margin:12px 0;border-radius:8px;box-shadow:0 2px 10px rgba(24,39,75,.035)}h2{font-size:20px;margin-bottom:10px}h3{font-size:16px;margin:18px 0 6px}p,li{font-size:14px}ul,ol{padding-left:20px}table{font-size:12px}th,td{padding:8px 9px;min-width:116px}.footer{margin:25px 0;font-size:12px}section[id]{scroll-margin-top:76px}}
</style>
</head>
<body id="top">
<header class="hero"><div class="hero-inner"><div class="eyebrow">JUMHABI · PRIVACY CENTER</div><h1>JUMHABI 隐私政策</h1><p>我们重视您的个人信息和隐私。本政策以清晰、易懂的方式说明 JUMHABI 可能如何处理个人信息,以及您可以如何行使相关权利。</p><div class="meta"><span class="pill">版本:V1.0</span><span class="pill">生效日期:2026年10月1日</span><span class="pill">适用平台:Android</span></div></div></header>
<div class="layout">
<aside class="toc"><strong>目录</strong><nav><a href="#pending">待确认事项</a><a href="#scope">一、引言</a><a href="#collect">二、收集与使用</a><a href="#device">三、设备信息</a><a href="#video">四、实时视频</a><a href="#location">五、位置与地图</a><a href="#account">六、账号安全</a><a href="#storage">七、存储与安全</a><a href="#sharing">八、共享与 SDK</a><a href="#rights">九、您的权利</a><a href="#delete">十、账号注销</a><a href="#minor">十一、未成年人</a><a href="#update">十二、更新与联系</a><a href="#checklist">开发者清单</a><a href="#risks">上线风险</a></nav></aside>
<main>
<section id="pending" class="notice"><strong>《隐私政策说明》</strong><p>本政策已根据当前提供的问题解答、AndroidManifest.xml 和 Gradle 依赖进行修订。第三方服务的具体数据字段、服务协议及部分后端安全配置仍应在上线前由研发和服务商资料最终核验。</p><ol><li>核验地图、短信、视频及海外云服务的准确服务商名称、处理数据字段和跨境处理安排。</li><li>核验服务端数据永久保存的必要性,以及备份、删除和访问控制措施。</li><li>核验录音、安装包请求、通知、图片/存储权限是否由应用实际调用及其对应功能。</li></ol></section>
<section id="scope" class="card"><h2>一、引言</h2><p>本隐私政策适用于 JUMHABI Android 应用(以下简称“本 App”)。本政策中的“我们”指深圳市聚心时代科技有限公司(个人信息处理者);“您”指本 App 用户。</p><p>在使用具体功能前,我们会按照必要、透明、最小化原则告知处理目的、方式和范围,并在依法需要时取得您的同意。您可以选择不提供非必要信息,但可能无法使用相应功能。</p></section>
<section id="collect" class="card"><h2>二、我们如何收集和使用您的个人信息</h2><h3>1. 注册、登录与身份验证</h3><p>本 App 支持手机号和邮箱注册/登录。为创建和管理账号、验证登录及保障账号安全,我们处理您的手机号、邮箱、验证码、账号 ID 和登录 token。短信验证码由阿里云短信服务提供;邮箱验证码服务商以实际配置为准。登录设备信息、IP 地址、登录时间和操作日志是否由服务端记录及其保存期限,以服务端实际配置为准。</p><h3>2. 设备绑定与管理</h3><p>您可扫描设备二维码绑定设备。扫码内容主要包括设备编码、生产批次等设备信息,用于识别设备并建立账号与设备的绑定关系。扫码使用手机摄像头;您也可以通过相册选择二维码图片(如该入口可用)。</p><h3>3. 远程控制与设备数据展示</h3><p>您发起控制操作时,服务端会根据账号和设备绑定关系处理相应请求。设备产生的设备状态、运行数据及设备位置等信息由设备提供给服务端,本 App 从服务端获取并向您展示。我们不通过本 App 采集手机硬件信息并上传至服务器。</p><h3>4. 个人中心</h3><p>本 App 保存和展示的账号资料包括手机号、邮箱、昵称、头像、用户 ID 和注册时间。您可以在 App 内修改绑定手机号、邮箱及个人密码;按目前提供的信息,修改时不要求额外身份认证。</p></section>
<section id="device" class="card"><h2>三、我们如何处理设备信息</h2><p>为展示和管理您绑定的设备,本 App 从服务端读取由硬件设备提供的设备编码、生产批次、设备状态、运行数据及设备位置信息等(具体展示字段以设备和服务端实际提供为准)。这些信息可能与您的账号和绑定关系关联,用于设备展示、状态判断、远程控制和服务支持。本 App 不采集手机硬件信息上传至服务器。设备信息在服务端的保存期限见第七条。</p></section>
<section id="video" class="card"><h2>四、实时视频功能及相关信息处理</h2><p>本 App 可查看已绑定设备的实时预览画面,视频由设备端产生。本 App 仅从服务端拉取视频流进行播放,不通过手机推送视频流;按目前提供的信息,本 App 不在本地录制或保存视频。Android 工程引入了 GSYVideoPlayer 播放器依赖。设备视频流在设备、服务端和 App 之间的完整传输路径、服务端是否中转或留存及其供应商,应以实际部署为准。</p><p>Manifest 声明了手机摄像头和麦克风权限。摄像头用于扫描设备二维码;目前提供的信息未说明 App 使用手机麦克风采集音频。拒绝摄像头权限将影响扫码绑定。我们会在相关功能调用权限前说明用途。</p></section>
<section id="location" class="card"><h2>五、位置与地图服务</h2><p>当您打开地图查看设备所在地址时,本 App 会在地图上展示设备位置,并展示您手机当前的位置。为提供该功能,App 声明并可能使用 Android 精确定位权限(ACCESS_FINE_LOCATION);您可以通过系统权限设置拒绝或撤回定位授权,届时手机当前位置功能将不可用,但不影响无需定位的其他功能。设备位置由硬件设备提供给服务端,不等同于手机定位。</p><p>当前使用高德地图海外定位服务。使用地图及定位功能时,手机当前位置信息和/或用于地址展示的设备经纬度可能按功能需要提供给该服务进行地图展示或定位处理。具体传输字段、服务主体名称、存储期限和跨境处理安排应以实际接入配置及服务条款核验。</p></section>
<section id="account" class="card"><h2>六、账号注册、登录与安全</h2><p>账号密码用于账号安全。App 与服务端之间使用 HTTPS 传输。我们不会在本政策中披露密钥、服务器配置等敏感安全细节;服务端访问控制、密码保存方式及 token 的有效期和失效机制按实际安全配置执行。</p><p>您可以在 App 内修改个人密码、绑定手机号和邮箱,也可以退出登录。退出登录时,本地缓存会清理,登录 token 等本地账号信息会按应用实际退出流程清除。</p></section>
<section id="storage" class="card"><h2>七、个人信息的存储与保护</h2><p>本地保存必要的用户基础信息,包括昵称、登录账号、登录方式和 token 等;退出登录时清理缓存。按目前提供的信息,服务端数据存储在阿里云海外云服务器并永久保存,直至您注销账号或依法需要删除。永久保存安排及其必要性应持续评估,并落实可执行的数据删除机制。</p><p>App 与服务端之间使用 HTTPS 传输。我们将采取与风险相适应的访问控制、最小权限和安全保护措施。备份策略、服务端访问权限及具体加密措施以实际架构和运维配置为准。发现个人信息泄露等安全事件时,我们将依法采取补救并按规定告知。</p></section>
<section id="sharing" class="card"><h2>八、共享、委托处理与第三方服务</h2><p>我们不会在没有必要性或法律依据的情况下出售您的个人信息。为提供地图、短信验证、视频播放和云服务器等服务,相关信息可能由对应服务商根据服务目的处理;我们会依法通过协议等方式约束受托方的处理目的和安全义务。</p><h3>第三方 SDK/服务清单</h3><table><thead><tr><th>SDK/服务</th><th>服务商/组件</th><th>用途</th><th>可能涉及的数据类型</th><th>处理方式</th></tr></thead><tbody><tr><td>高德地图海外定位服务</td><td>高德地图服务(具体海外服务主体待接入资料核验)</td><td>展示地图、手机当前位置及设备地址</td><td>手机当前位置、设备经纬度(按具体功能需要)</td><td>由 App 按功能调用地图/定位服务;具体字段和跨境路径以接入配置为准</td></tr><tr><td>阿里云短信服务</td><td>阿里云</td><td>发送手机号验证码</td><td>手机号、验证码发送请求及必要服务日志</td><td>通过短信服务发送验证码</td></tr><tr><td>阿里云海外云服务器</td><td>阿里云(具体地域待配置核验)</td><td>提供服务端及存储</td><td>账号资料、设备绑定信息、设备数据及服务运行所需信息</td><td>服务端存储和处理</td></tr><tr><td>扫码组件</td><td>MNMLKitScanner、Google ML Kit Barcode Scanning、AndroidX CameraX</td><td>扫描设备二维码</td><td>摄像头实时画面;二维码中的设备编码、生产批次等信息</td><td>扫码识别;是否有组件向其供应商发送数据需以 SDK 实际行为和配置核验</td></tr><tr><td>视频播放器</td><td>GSYVideoPlayer</td><td>播放服务端下发的设备视频流</td><td>视频流地址、播放请求及必要网络信息</td><td>App 从服务端拉流播放;App 不推流、不在本地录制保存视频</td></tr></tbody></table><p>Gradle 文件还包含 Retrofit、OkHttp、Gson、Luban、AndroidX 和界面组件等通用依赖。仅凭依赖声明不能确认这些组件是否会收集或向第三方传输个人信息;我们会根据实际功能调用和 SDK 行为核验并在需要时补充披露。</p></section>
<section id="rights" class="card"><h2>九、您的个人信息权利</h2><p>在符合法律规定的前提下,您享有知情、决定、查阅、复制、更正、删除、撤回同意、注销账号及要求解释说明个人信息处理规则等权利。您可以在 App 内修改手机号、邮箱和个人密码;其他个人信息请求可通过 App 设置中的联系方式或本政策列明的客服邮箱提交。我们可能为核实请求而进行必要身份确认。对于无法满足的请求,我们会说明理由。</p></section>
<section id="delete" class="card"><h2>十、用户账号注销</h2><p>您可以在 App 个人中心发起账号注销。申请前需先取消所有已绑定设备;在您确认注销后,服务端将在数据库删除该用户信息。注销后账号及其关联服务将无法继续使用。备份数据的清除时间、依法必须保留或争议处理所需信息的范围和期限,以实际数据删除及备份机制为准;如需保留,我们会依法限制其用途并在保存期限届满后删除或匿名化。</p></section>
<section id="minor" class="card"><h2>十一、未成年人保护</h2><p>本 App 可以面向未成年人。未成年人使用本 App 或提供个人信息,应在监护人指导下阅读本政策,并在法律要求时取得监护人同意。监护人如对未成年人个人信息处理有疑问或希望行使相关权利,可通过下方联系方式联系我们,我们将依法处理。</p></section>
<section id="update" class="card"><h2>十二、隐私政策更新与联系我们</h2><p>我们可能根据业务、法律法规或功能变化更新本政策。重大变更将通过 App 弹窗、页面提示或其他适当方式通知,并在页面标注最新版本和生效日期。您可在 App 的“隐私政策”入口查看当前版本。</p><p><strong>个人信息处理者:</strong>深圳市聚心时代科技有限公司<br><strong>联系邮箱:</strong><a href="mailto:huzuming2@gmail.com">huzuming2@gmail.com</a><br><strong>公司地址:</strong>深圳市宝安区沙井街道马安山社区马安山鞍胜路2号C栋203<br><strong>隐私负责人:</strong>深圳市聚心时代科技有限公司(联系邮箱同上)</p><p><strong>生效日期:</strong>2026年10月1日</p></section>
<section id="checklist" class="card"><h2>上线前核验清单</h2><table><thead><tr><th>检查项目</th><th>当前已知情况</th><th>仍需核验</th><th>核验内容</th></tr></thead><tbody>
<tr><td>Android 权限</td><td>Manifest 声明网络、请求安装包、通知、摄像头、录音、精确定位、读取图片/旧版外部存储权限</td><td>是</td><td>确认运行时申请时机、拒绝影响,以及录音、安装包请求、通知和图片/存储权限的实际功能</td></tr><tr><td>第三方 SDK</td><td>Gradle 包含 MNMLKitScanner、Google ML Kit Barcode Scanning、CameraX、GSYVideoPlayer、Retrofit/OkHttp 等</td><td>是</td><td>核验 SDK 实际数据收集行为、供应商、隐私链接及是否发生对外传输</td></tr><tr><td>地图与定位</td><td>高德地图海外定位服务;地图展示设备地址及手机当前位置</td><td>是</td><td>补充准确服务主体、位置字段流向、海外处理/跨境安排及服务商保存期限</td></tr><tr><td>视频</td><td>从服务端拉流播放;不推流、不本地录制保存;依赖 GSYVideoPlayer</td><td>是</td><td>确认流媒体服务商、服务器是否中转/留存及视频流保存期限</td></tr><tr><td>服务器与删除</td><td>阿里云海外云服务器;目前说明为永久保存;注销后服务端数据库删除用户信息</td><td>是</td><td>确认云服务器具体地域、永久保存必要性、备份删除时限和法定保留范围</td></tr><tr><td>账号与本地数据</td><td>本地保存昵称、登录账号、登录方式、token;退出登录清缓存</td><td>是</td><td>核验 token 失效方式、缓存范围、日志/Crash 字段及保存期限</td></tr><tr><td>未成年人</td><td>App 可以面向未成年人</td><td>是</td><td>确认监护人同意及未成年人个人信息保护流程如何在产品中落实</td></tr><tr><td>主体与联系方式</td><td>深圳市聚心时代科技有限公司;邮箱和地址已提供,不留电话</td><td>否</td><td>确认邮箱可正常接收和处理权利请求</td></tr></tbody></table></section>
<section id="risks" class="card"><h2>上线前隐私合规风险清单</h2><table><thead><tr><th>级别</th><th>风险与原因</th><th>建议处理</th></tr></thead><tbody><tr><td class="risk-high">高风险</td><td>阿里云海外服务器永久保存数据,且地图使用海外定位服务,涉及境外存储/处理及可能的数据跨境合规要求。</td><td>核实具体国家或地区、数据类别和流向,评估并依法落实个人信息出境告知、单独同意及适用的出境合规机制。</td></tr><tr><td class="risk-high">高风险</td><td>Manifest 声明录音、请求安装包等权限,但现有说明未指出对应业务用途。</td><td>确认应用是否实际调用;不必要权限应从 Manifest 移除,必要权限应在申请前清楚说明目的。</td></tr><tr><td class="risk-mid">中风险</td><td>设备位置、手机当前位置、视频流及服务商的实际字段和流向尚未由代码/服务商资料验证。</td><td>核验 SDK 和网络数据流,补齐服务主体、处理字段、保存期限及必要的单独同意。</td></tr><tr><td class="risk-mid">中风险</td><td>服务端称永久保存,注销后数据库删除,但备份和法定保留规则尚未说明。</td><td>评估保存期限的必要性,明确注销和备份删除时限,落实删除/匿名化及受限保留机制。</td></tr><tr><td class="risk-mid">中风险</td><td>面向未成年人,但当前未提供年龄识别和监护人同意流程。</td><td>上线前落实未成年人个人信息保护及监护人同意机制。</td></tr></tbody></table></section>
<p><a class="back" href="#top">返回顶部 ↑</a></p>
</main>
</div><footer class="footer">JUMHABI 隐私政策 · 版本 V1.0</footer>
</body></html>

View File

@@ -0,0 +1,53 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<title>JUMHABI 用户协议</title>
<style>
:root{--ink:#172033;--muted:#647089;--line:#e6eaf0;--brand:#2563eb;--soft:#eff6ff;--warn:#fff7ed;--bg:#f6f8fb;--white:#fff;--anchor-gap:16px}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",Arial,sans-serif;line-height:1.8}
.hero{background:linear-gradient(135deg,#14213d,#2563eb);color:#fff;padding:56px 20px 48px}.hero-inner{max-width:1080px;margin:auto}.eyebrow{font-size:13px;letter-spacing:.12em;opacity:.8}.hero h1{font-size:clamp(30px,5vw,48px);margin:12px 0 8px;letter-spacing:.02em}.hero p{margin:0;max-width:720px;color:#dbeafe;font-size:16px}.meta{display:flex;gap:12px;flex-wrap:wrap;margin-top:24px}.pill{padding:5px 12px;border:1px solid rgba(255,255,255,.35);border-radius:999px;font-size:13px}
.layout{max-width:1080px;margin:28px auto 64px;padding:0 20px;display:grid;grid-template-columns:230px 1fr;gap:28px}.toc{position:sticky;top:18px;align-self:start;background:var(--white);border:1px solid var(--line);padding:18px;border-radius:10px;box-shadow:0 5px 18px rgba(24,39,75,.05)}.toc strong{font-size:14px}.toc a{display:block;color:var(--muted);text-decoration:none;font-size:13px;padding:5px 0}.toc a:hover,.toc a:focus-visible{color:var(--brand)}main{min-width:0}.notice{background:var(--warn);border:1px solid #fed7aa;border-left:4px solid #f97316;padding:16px 18px;border-radius:8px;margin-bottom:20px}.notice strong{color:#9a3412}.card{background:var(--white);border:1px solid var(--line);border-radius:10px;padding:26px 28px;margin:16px 0;box-shadow:0 5px 18px rgba(24,39,75,.04)}h2{font-size:23px;margin:0 0 14px;padding-top:4px}h3{font-size:17px;margin:22px 0 8px}p{margin:8px 0}ul,ol{padding-left:22px}li{margin:5px 0}.footer{text-align:center;color:var(--muted);font-size:13px;margin:35px 0}.back{color:var(--brand);text-decoration:none;font-size:13px}section[id]{scroll-margin-top:var(--anchor-gap)}@media(max-width:820px){.hero{padding:36px 18px 30px}.hero h1{font-size:32px}.hero p{font-size:15px}.meta{gap:8px;margin-top:18px}.pill{font-size:12px}.layout{display:block;margin:14px auto 40px;padding:0 12px}.toc{position:sticky;top:0;z-index:5;margin:0 -12px 14px;padding:8px 12px 9px;border-width:0 0 1px;border-radius:0;box-shadow:0 3px 12px rgba(24,39,75,.08);overflow:hidden}.toc strong{display:block;font-size:12px;margin:0 0 5px}.toc nav{display:flex;overflow-x:auto;overscroll-behavior-inline:contain;-webkit-overflow-scrolling:touch;scrollbar-width:thin;padding-bottom:2px}.toc a{display:inline-flex;align-items:center;white-space:nowrap;min-height:34px;margin:0 6px 0 0;padding:5px 10px;border:1px solid var(--line);border-radius:6px;background:#fff;font-size:12px}.toc a:focus-visible{outline:2px solid var(--brand);outline-offset:1px}.card{padding:18px 15px;margin:12px 0;border-radius:8px;box-shadow:0 2px 10px rgba(24,39,75,.035)}h2{font-size:20px;margin-bottom:10px}h3{font-size:16px;margin:18px 0 6px}p,li{font-size:14px}ul,ol{padding-left:20px}.notice{padding:13px 14px;margin-bottom:12px}.footer{margin:25px 0;font-size:12px}section[id]{scroll-margin-top:76px}}
</style>
</head>
<body id="top">
<header class="hero"><div class="hero-inner"><div class="eyebrow">JUMHABI · USER AGREEMENT</div><h1>JUMHABI 用户协议</h1><p>本协议说明您使用 JUMHABI Android 应用及相关设备服务时的权利、义务和责任。</p><div class="meta"><span class="pill">版本:V1.0</span><span class="pill">生效日期:2026年10月1日</span><span class="pill">适用平台:Android</span></div></div></header>
<div class="layout">
<aside class="toc"><strong>目录</strong><nav><a href="#notice">协议说明</a><a href="#terms">一、接受与适用</a><a href="#account">二、账号注册与使用</a><a href="#device">三、设备绑定与控制</a><a href="#content">四、内容与服务使用</a><a href="#permissions">五、权限与第三方服务</a><a href="#conduct">六、用户行为规范</a><a href="#security">七、服务安全与中断</a><a href="#ip">八、知识产权</a><a href="#liability">九、责任限制</a><a href="#minor">十、未成年人</a><a href="#termination">十一、协议终止</a><a href="#changes">十二、协议更新</a><a href="#contact">十三、联系我们</a></nav></aside>
<main>
<section id="notice" class="notice"><strong>请在使用前阅读</strong><p>请您在注册、登录或使用 JUMHABI 前认真阅读本协议及《JUMHABI 隐私政策》。当您点击同意、注册、登录、绑定设备或实际使用本 App 的服务,即表示您已阅读、理解并同意接受本协议。若您不同意本协议,请停止注册或使用相关服务。</p></section>
<section id="terms" class="card"><h2>一、协议接受与适用</h2><p>本协议由深圳市聚心时代科技有限公司(以下简称“我们”)与 JUMHABI App 用户(以下简称“您”)订立,适用于您通过 Android App 使用账号、设备管理、远程控制、实时视频、地图及其他相关功能。</p><p>本协议与《JUMHABI 隐私政策》共同构成您使用本服务的规则。隐私政策说明我们如何处理个人信息;如两者对个人信息处理事项有不同表述,以隐私政策为准。</p><p>您应当具备与使用本服务相适应的民事行为能力。若您未达到所在地法律规定的相应年龄,应在监护人指导和同意下使用本服务。</p></section>
<section id="account" class="card"><h2>二、账号注册与使用</h2><h3>1. 注册信息</h3><p>本 App 支持使用手机号或邮箱注册、登录。您应提供真实、准确、完整且有效的信息,并及时更新。账号资料包括手机号、邮箱、昵称、头像、用户 ID 和注册时间等,具体以 App 实际功能为准。</p><h3>2. 账号安全</h3><p>您应妥善保管账号、密码、验证码及登录 token,不得将账号出借、出租、出售或以其他方式交由他人使用。因您保管不善、主动披露或使用不当导致的损失,由您自行承担;发现异常登录或安全问题时,请立即通过 App 设置中的联系方式联系我们。</p><h3>3. 账号操作</h3><p>您可以在 App 内修改绑定手机号、邮箱和个人密码,也可以退出登录。您应对使用账号进行的操作承担相应责任。账号注销规则见本协议第十一条及隐私政策相关说明。</p></section>
<section id="device" class="card"><h2>三、设备绑定与控制</h2><h3>1. 设备绑定</h3><p>您可以通过扫描设备二维码绑定硬件设备。二维码主要包含设备编码、生产批次等信息。您只能绑定自己拥有、管理或已获得合法授权使用的设备,不得通过技术手段绕过权限、绑定他人设备或获取未经授权的设备数据。</p><h3>2. 设备数据</h3><p>设备状态、运行数据、设备位置和实时视频等内容由硬件设备或服务端提供。设备数据可能受到设备供电、网络连接、服务端状态、地理环境和其他不可控因素影响,我们不保证设备数据始终实时、完整或无误。</p><h3>3. 远程控制</h3><p>您发起远程控制前,应确认目标设备、控制内容及操作后果。对于可能影响人身、财产、动物、设施或公共安全的设备,请在现场采取必要的安全措施,不应仅依赖 App 的远程控制结果。因网络延迟、设备故障、错误操作或未经授权使用造成的损失,按法律规定及实际责任承担。</p></section>
<section id="content" class="card"><h2>四、内容与服务使用</h2><p>本 App 可能提供设备管理、实时视频、地图展示、账号管理和相关技术服务。我们会根据产品运营、维护和安全需要对功能进行调整、暂停或升级,并尽量通过 App 页面或其他适当方式通知。</p><p>实时视频由设备端产生,本 App 仅从服务端拉取并播放视频流,不提供手机端推流功能;按目前功能说明,本 App 不在本地录制或保存视频。您应确保观看和使用设备视频符合设备所在地法律及他人隐私、肖像和财产权利。</p><p>地图及定位功能用于展示设备地址和手机当前位置。地图、定位结果可能因服务商、网络或定位精度而存在误差,不应作为安全、导航、应急救援或专业决策的唯一依据。</p></section>
<section id="permissions" class="card"><h2>五、系统权限与第三方服务</h2><p>为实现扫码、地图定位、图片选择、通知或其他功能,App 可能申请 Android 摄像头、精确定位、麦克风、通知、图片/存储等系统权限。您可以在系统设置中管理权限;拒绝非必要权限通常只会影响对应功能,但拒绝必要权限可能导致相关功能无法使用。</p><p>本 App 可能使用地图、短信、云服务器、扫码识别、视频播放及网络通信等第三方服务。第三方服务按照其自身协议和隐私规则提供服务;相关处理方式和信息范围请参阅《JUMHABI 隐私政策》中的第三方服务清单。</p></section>
<section id="conduct" class="card"><h2>六、用户行为规范</h2><p>您不得利用本服务从事违反法律法规、侵害他人权益或破坏服务安全的行为,包括但不限于:</p><ul><li>未经授权访问、控制、绑定或监视他人设备、账号或视频内容;</li><li>上传、传播违法、有害、欺诈、侵权、骚扰或侵犯他人隐私的内容;</li><li>反向工程、反编译、破解、抓取、干扰或绕过 App、服务端或设备的安全机制;</li><li>利用本服务发送恶意程序、垃圾请求,或实施可能影响网络、设备和其他用户的行为;</li><li>将本服务用于法律禁止的用途或用于需要专业许可但未取得许可的活动。</li></ul><p>如您违反本条,我们可以根据风险采取提醒、限制功能、暂停服务、解绑设备、注销账号或依法向有关部门报告等措施。</p></section>
<section id="security" class="card"><h2>七、服务安全与中断</h2><p>我们会采取合理的技术和管理措施维护服务安全,并通过 HTTPS 等方式保护 App 与服务端之间的传输。但互联网、移动网络、设备系统和第三方服务均可能出现故障、攻击或不可预见的中断,我们不承诺服务始终连续、无延迟、无错误或完全安全。</p><p>因系统维护、升级、网络故障、设备故障、供应商服务变化、法律要求、不可抗力或其他合理原因导致服务暂时不可用的,我们会在可行范围内进行通知和恢复。</p></section>
<section id="ip" class="card"><h2>八、知识产权</h2><p>JUMHABI App 的软件、界面、文字、图形、标识、页面设计和相关技术成果的知识产权归我们或相关权利人所有。除法律允许或经权利人书面许可外,您不得复制、修改、传播、出租、出售、反向工程或用于本协议约定之外的用途。</p><p>您在使用本服务过程中产生或上传的内容,仍由您或相应权利人享有权利。您应确保自己拥有必要授权,并授予我们为提供、维护、改进和保障本服务所必需的使用权限;具体个人信息处理以隐私政策为准。</p></section>
<section id="liability" class="card"><h2>九、责任限制</h2><p>在法律允许的范围内,我们不对因以下原因造成的间接损失、预期利益损失或数据损失承担责任:设备自身缺陷或断电、网络和定位误差、第三方服务中断、用户错误操作、账号泄露、未经授权使用、不可抗力或其他非我们可合理控制的原因。</p><p>本条不排除或限制法律规定不得排除或限制的责任。若本 App 提供的具体服务另有单独规则,该规则与本协议不一致的,以单独规则为准。</p></section>
<section id="minor" class="card"><h2>十、未成年人保护</h2><p>本 App 可以面向未成年人。未成年人应在监护人指导下阅读并使用本服务,监护人应监督其账号、设备和个人信息使用情况。若监护人发现未成年人未经适当同意使用本服务或提交个人信息,请通过本协议列明的联系方式联系我们。</p></section>
<section id="termination" class="card"><h2>十一、协议终止与账号注销</h2><p>您可以停止使用本服务,并在 App 个人中心申请注销账号。申请注销前需取消所有已绑定设备;确认注销后,服务端将按实际流程删除用户信息。注销后账号及关联服务可能无法恢复,相关数据删除、备份清理和依法保留事项以隐私政策及实际数据处理规则为准。</p><p>如您严重违反本协议、法律法规或危害服务安全,我们可以暂停或终止向您提供全部或部分服务。协议终止后,依其性质应继续有效的条款仍然有效。</p></section>
<section id="changes" class="card"><h2>十二、协议更新</h2><p>我们可能因法律法规、业务变化、产品功能或安全需要更新本协议。重大变更将通过 App 弹窗、页面提示或其他适当方式通知,并在本页面标注最新版本和生效日期。更新后的协议生效后,您继续使用本服务即视为接受更新内容;如您不同意,应停止使用并注销账号。</p></section>
<section id="contact" class="card"><h2>十三、联系我们</h2><p><strong>协议主体:</strong>深圳市聚心时代科技有限公司<br><strong>联系邮箱:</strong><a href="mailto:huzuming2@gmail.com">huzuming2@gmail.com</a><br><strong>公司地址:</strong>深圳市宝安区沙井街道马安山社区马安山鞍胜路2号C栋203</p><p>如您对本协议、账号安全、设备服务或用户权利有疑问,可通过上述邮箱联系我们。我们将在合理期限内处理您的咨询或请求。</p><p><strong>生效日期:</strong>2026年10月1日</p></section>
<p><a class="back" href="#top">返回顶部 ↑</a></p>
</main>
</div>
<footer class="footer">JUMHABI 用户协议 · 版本 V1.0</footer>
</body>
</html>

View File

@@ -58,7 +58,7 @@ public interface AppUserApiService {
*
* @param phoneNumber 手机号
* @param rawPassword 明文密码
* @param nickName 昵称(为空时默认取手机号)
* @param nickName 昵称(为空时默认生成 {@code user_ + 当前时间戳})
* @return 新建用户主键
*/
Long registerByPhone(String phoneNumber, String rawPassword, String nickName);
@@ -82,6 +82,15 @@ public interface AppUserApiService {
*/
boolean existsByEmail(String email);
/**
* 更新 APP 用户邮箱;仅更新邮箱字段,昵称保持不变。
*
* @param userId 用户主键
* @param newEmail 新邮箱地址(已归一化)
* @return true 修改成功
*/
boolean updateEmail(Long userId, String newEmail);
/**
* 以邮箱注册一个 APP 用户(邮箱注册入口)。
* <p>
@@ -94,7 +103,7 @@ public interface AppUserApiService {
*
* @param email 邮箱地址(已归一化)
* @param rawPassword 明文密码
* @param nickName 昵称(为空时默认取邮箱)
* @param nickName 昵称(为空时默认生成 {@code user_ + 当前时间戳})
* @return 新建用户主键
*/
Long registerByEmail(String email, String rawPassword, String nickName);
@@ -109,7 +118,7 @@ public interface AppUserApiService {
boolean resetPassword(Long userId, String rawPassword);
/**
* 换绑手机号。
* 换绑手机号;仅更新手机号字段,昵称保持不变。
*
* @param userId 用户主键
* @param newPhoneNumber 新手机号

View File

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

View File

@@ -1,6 +1,7 @@
package org.dromara.common.mail.config;
import cn.hutool.extra.mail.MailAccount;
import org.dromara.common.mail.config.properties.GmailProperties;
import org.dromara.common.mail.config.properties.MailProperties;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -13,7 +14,7 @@ import org.springframework.context.annotation.Bean;
* @author Michelle.Chung
*/
@AutoConfiguration
@EnableConfigurationProperties(MailProperties.class)
@EnableConfigurationProperties({MailProperties.class, GmailProperties.class})
public class MailConfig {
/**

View File

@@ -0,0 +1,107 @@
package org.dromara.common.mail.config.properties;
import cn.hutool.extra.mail.MailAccount;
import lombok.Data;
import org.dromara.common.core.utils.StringUtils;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Gmail 邮件服务配置属性。
* <p>
* 独立于框架默认的 {@link MailProperties}(prefix=mail),专用于 Gmail SMTP 发信。
* <p>
* <b>Gmail 口径</b>:主机固定 {@code smtp.gmail.com},端口 465 走 SSL(或 587 走 STARTTLS)。
* Gmail 已停用「低安全性应用访问」,{@link #pass} 必须填 Google 账号生成的
* <b>16 位应用专用密码</b>(非账号登录密码),且 {@link #from} 必须等于 {@link #user},否则会被拒收。
*
* @author Qoder
*/
@Data
@ConfigurationProperties(prefix = "gmail")
public class GmailProperties {
/**
* 是否启用 Gmail 发信功能。
*/
private Boolean enabled = false;
/**
* SMTP 服务器域名,Gmail 固定为 smtp.gmail.com。
*/
private String host = "smtp.gmail.com";
/**
* SMTP 服务端口:465(SSL)或 587(STARTTLS)。
*/
private Integer port = 465;
/**
* 是否需要用户名密码验证。
*/
private Boolean auth = true;
/**
* 发信账号(Gmail 地址),建议由环境变量注入。
*/
private String user;
/**
* 发信凭据:Gmail 的 16 位应用专用密码,建议由环境变量注入。
*/
private String pass;
/**
* 发件人,留空则回落到 {@link #user}。
*/
private String from;
/**
* 使用 SSL 安全连接(465 端口用 true)。
*/
private Boolean sslEnable = true;
/**
* 使用 STARTTLS 升级连接(587 端口用 true,与 {@link #sslEnable} 互斥)。
*/
private Boolean starttlsEnable = false;
/**
* SMTP 读写超时时长,单位毫秒。
*/
private Long timeout = 10000L;
/**
* Socket 连接超时值,单位毫秒。Gmail 国内不可达时快速失败,避免挂死请求线程。
*/
private Long connectionTimeout = 5000L;
/**
* 转换为 Hutool 邮件账户配置。
*
* @return 邮件账户配置
*/
public MailAccount toMailAccount() {
MailAccount account = new MailAccount();
account.setHost(host);
account.setPort(port);
account.setAuth(auth);
account.setUser(user);
account.setPass(pass);
account.setFrom(StringUtils.isBlank(from) ? user : from);
account.setSocketFactoryPort(port);
account.setStarttlsEnable(starttlsEnable);
account.setSslEnable(sslEnable);
account.setTimeout(timeout);
account.setConnectionTimeout(connectionTimeout);
return account;
}
/**
* 判断是否已具备真实发信条件(账号与凭据均已配置)。
*
* @return true 已配置;false 未配置
*/
public boolean isConfigured() {
return StringUtils.isNotBlank(user) && StringUtils.isNotBlank(pass);
}
}

View File

@@ -0,0 +1,71 @@
package org.dromara.common.mail.core;
import cn.hutool.extra.mail.MailAccount;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.mail.config.properties.GmailProperties;
import org.springframework.stereotype.Service;
/**
* Gmail 验证码邮件发送服务。
* <p>
* 仅负责「用 Gmail 账户把验证码邮件发出去」,验证码的生成、限流、缓存由调用方(Controller)承担,
* 保持本服务的单一职责,便于在各业务模块复用。
*
* @author Qoder
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GmailCodeService {
private final GmailProperties gmailProperties;
/**
* 通过 Gmail 发送邮箱验证码。
*
* @param to 收件邮箱
* @param code 验证码
* @param expiryMinutes 有效期(分钟),仅用于文案展示
*/
public void sendVerifyCode(String to, String code, int expiryMinutes) {
if (!Boolean.TRUE.equals(gmailProperties.getEnabled())) {
throw new ServiceException("当前系统未开启 Gmail 邮件功能!");
}
if (!gmailProperties.isConfigured()) {
throw new ServiceException("Gmail 发信账号或应用专用密码未配置!");
}
MailAccount account = gmailProperties.toMailAccount();
try {
MailBuilder.of(account)
.to(to)
.subject("邮箱验证码")
.html(buildHtml(code, expiryMinutes))
.send();
log.info("[Gmail] 验证码邮件已发送:收件邮箱={}", to);
} catch (Exception e) {
// 不记录验证码本身,避免真实发信失败时把验证码写进日志
log.error("[Gmail] 验证码邮件发送异常 to={} host={}:{}(若为连接超时,请确认服务器可访问 smtp.gmail.com)",
to, gmailProperties.getHost(), gmailProperties.getPort(), e);
throw new ServiceException("验证码邮件发送失败,请稍后重试!");
}
}
/**
* 构造验证码邮件正文(纯 HTML,无外部资源,避免被邮件客户端拦截)。
*
* @param code 验证码
* @param expiryMinutes 有效期(分钟)
* @return HTML 正文
*/
private String buildHtml(String code, int expiryMinutes) {
return "<div style=\"font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;"
+ "font-size:14px;line-height:1.6;color:#2c2c2a\">"
+ "<p>您本次操作的邮箱验证码为:</p>"
+ "<p style=\"font-size:24px;font-weight:500;letter-spacing:4px;margin:16px 0\">" + code + "</p>"
+ "<p>验证码 " + expiryMinutes + " 分钟内有效,请勿泄露给他人。</p>"
+ "<p style=\"color:#5f5e5a\">若非本人操作,请忽略本邮件。</p>"
+ "</div>";
}
}

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ package org.dromara.device.config;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.dromara.common.core.utils.StringUtils;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@@ -15,10 +14,10 @@ import java.util.List;
* <p>
* 全部字段均带默认值,未在 {@code application.yml} 显式配置时按默认值生效,因此本迭代无需改动任何 yml。
* <p>
* <b>客户端身份默认值口径</b>:与框架 {@code sys_client} 中 {@code client_key = 'app'} 的记录保持镜像
* ({@code device_type = 'android'}、{@code access_path = '/app/**'}、{@code timeout = 604800}、
* {@code activeTimeout = 1800})。原因:设备域({@code ruoyi-device})不依赖 {@code ruoyi-system},
* 无法读取 {@code sys_client};若运维调整了 {@code sys_client} 的 app 记录,请同步此处配置。
* <b>客户端身份默认值口径</b>:身份与访问范围参考框架 {@code sys_client} 中
* {@code client_key = 'app'} 的记录({@code device_type = 'android'}、{@code access_path = '/app/**'})。
* 设备域({@code ruoyi-device})不依赖 {@code ruoyi-system},无法读取 {@code sys_client},因此令牌固定有效期和
* 活跃超时由本配置独立控制,默认均为 2592000 秒(30 天)。
* <p>
* <b>已知偏差</b>:{@link Client#clientId} 的取值口径与 PC 端 {@code sys_client.client_id} 不同,
* 详见 {@link Client#clientId} 字段注释与《05-交付说明-迭代I2》§七。
@@ -42,7 +41,7 @@ public class AppProperties {
private Sms sms = new Sms();
/**
* APP 邮箱验证码策略(SMTP 发信)。
* APP 邮箱验证码策略;真实发信统一由 common-mail 的 {@code gmail.*} 配置负责。
*/
private Email email = new Email();
@@ -87,14 +86,14 @@ public class AppProperties {
private String deviceType = "android";
/**
* token 固定超时时间(秒)。
* token 固定超时时间(秒),默认 30 天;活跃超时仍由 {@link #activeTimeout} 独立控制。
*/
private Long timeout = 604800L;
private Long timeout = 2592000L;
/**
* token 活跃超时时间(秒)。
* token 活跃超时时间(秒),默认 30 天;启用动态活跃超时时会在请求后续期。
*/
private Long activeTimeout = 1800L;
private Long activeTimeout = 2592000L;
}
@@ -119,7 +118,7 @@ public class AppProperties {
private String blend = "config1";
/**
* sms4j 模板 id(mock=false 时使用)。
* sms4j 模板 id(mock=false 时使用,国内短信)。
*/
private String templateId = "";
@@ -141,11 +140,10 @@ public class AppProperties {
}
/**
* 邮箱验证码策略配置(SMTP 发信)。
* 邮箱验证码策略配置。
* <p>
* <b>凭据口径</b>:{@link #user} 与 {@link #pass} 在代码与 {@code application.yml} 中<b>均不预置真实值</b>,
* 一律通过环境变量注入({@code DEVICE_APP_EMAIL_USER} / {@code DEVICE_APP_EMAIL_PASS}),
* 避免重蹈「明文 broker 凭据硬编码」的覆辙。
* <b>发信口径</b>:真实邮件统一调用 {@code GmailCodeService},账号、凭据、SMTP 主机和超时从
* {@code gmail.*} 读取;本类中的旧 SMTP 字段仅为兼容历史配置保留,APP 业务不再读取。
* <p>
* <b>谷歌邮箱(Gmail)口径</b>:主机固定 {@code smtp.gmail.com}、端口 465(SSL)。
* Gmail 已停用「低安全性应用访问」,{@link #pass} 必须填 Google 账号生成的
@@ -168,54 +166,34 @@ public class AppProperties {
*/
private boolean mock = true;
/**
* Gmail SMTP 主机。
*/
/** 旧 SMTP 主机配置,仅为兼容历史配置保留,业务不再读取。 */
private String host = "smtp.gmail.com";
/**
* Gmail SMTP 端口:465(SSL)或 587(STARTTLS)。
*/
/** 旧 SMTP 端口配置,仅为兼容历史配置保留,业务不再读取。 */
private int port = 465;
/**
* 发信账号(Gmail 地址),由环境变量注入。
*/
/** 旧发信账号配置,仅为兼容历史配置保留,业务不再读取。 */
private String user = "";
/**
* 发信凭据:Gmail 的 16 位<b>应用专用密码</b>,由环境变量注入。
*/
/** 旧发信凭据配置,仅为兼容历史配置保留,业务不再读取。 */
private String pass = "";
/**
* 发件人(须与 {@link #user} 一致,可写为 {@code 名称 <user@gmail.com>})。
*/
/** 旧发件人配置,仅为兼容历史配置保留,业务不再读取。 */
private String from = "";
/**
* 使用 SSL 安全连接(465 端口用 true)。
*/
/** 旧 SSL 配置,仅为兼容历史配置保留,业务不再读取。 */
private boolean sslEnable = true;
/**
* 使用 STARTTLS 升级连接(587 端口用 true,与 {@link #sslEnable} 互斥使用)。
*/
/** 旧 STARTTLS 配置,仅为兼容历史配置保留,业务不再读取。 */
private boolean starttlsEnable = false;
/**
* SMTP 连接超时(毫秒):避免 Gmail 不可达时长时间占用请求线程。
*/
/** 旧连接超时配置,仅为兼容历史配置保留,业务不再读取。 */
private int connectionTimeout = 5000;
/**
* SMTP 读写超时(毫秒)。
*/
/** 旧读写超时配置,仅为兼容历史配置保留,业务不再读取。 */
private int timeout = 10000;
/**
* 邮件主题。
*/
/** 旧邮件主题配置,仅为兼容历史配置保留,业务不再读取。 */
private String subject = "智能飞禽物联平台 验证码";
/**
@@ -240,15 +218,6 @@ public class AppProperties {
*/
private List<String> allowedDomains = new ArrayList<>();
/**
* 判断是否已具备真实发信条件(发信账号与凭据均已配置)。
*
* @return true 已配置;false 未配置
*/
public boolean isConfigured() {
return StringUtils.isNotBlank(user) && StringUtils.isNotBlank(pass);
}
}
/**

View File

@@ -87,6 +87,18 @@ public class DeviceMqttProperties {
*/
private Duration alertDedupWindow = Duration.ofMinutes(30);
/**
* APP 告警消息 outbox 补偿间隔。
* <p>
* 消息同步写入失败后,告警事实仍会保留;补偿任务按该间隔扫描并重试。
*/
private Duration appMessageRetryInterval = Duration.ofMinutes(1);
/**
* APP 告警消息单轮补偿上限,避免数据库恢复后瞬间形成写入洪峰。
*/
private int appMessageRetryBatchSize = 100;
/**
* 眼罩指令回执等待超时。
* <p>

View File

@@ -0,0 +1,50 @@
package org.dromara.device.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* MJPEG 直传直播配置(前缀 {@code mjpeg})。
* <p>
* 开启后设备按 HTTP POST JPEG 推流,APP 按 multipart MJPEG 拉流,不再经 ZLM。
*
* @author Kou
* @date 2026-09-18
*/
@Data
@Component
@ConfigurationProperties(prefix = "mjpeg")
public class MjpegProperties {
/**
* 是否启用 MJPEG 直传。关闭时直播仍走 ZLM RTMP。
*/
private boolean enabled = true;
/**
* 设备与播放端可访问的公网基址,例如 {@code http://47.104.136.221:8081}
*/
private String publicHost;
/**
* 播放端推帧频率
*/
private int fps = 5;
/**
* 超过该秒数未收到新帧则视为不在线
*/
private int frameTtlSeconds = 8;
/**
* 开播后持续无人观看超过该秒数,则 MQTT 通知设备停推
*/
private int idleStopSeconds = 30;
/**
* 单帧 JPEG 上限(字节)
*/
private int maxFrameBytes = 1_048_576;
}

View File

@@ -14,9 +14,9 @@ public final class AppConstants {
// ---------------- 校验正则 ----------------
/**
* 手机号(中国大陆 11 位)。
* 手机号:大陆 11 位,或 {@code +}/{@code 00} + 国家码 + 号码(国际短信)。
*/
public static final String PHONE_REGEX = "^1[3-9]\\d{9}$";
public static final String PHONE_REGEX = "^(?:(?:\\+|00)86)?1[3-9]\\d{9}$|^(?:\\+|00)[1-9]\\d{6,14}$";
/**
* 密码:8-20 位,必须同时包含大写字母、小写字母和数字(不强制特殊字符,与原型一致)。
@@ -80,6 +80,14 @@ public final class AppConstants {
*/
public static final String SCENE_BIND = "bind";
/**
* 场景:换绑前确认原手机号。
* <p>
* 仅登录后的 {@code POST /app/user/phone/old-code} 可发送;公共 {@code /app/auth/sms/code} 拒绝本场景,
* 避免客户端指定任意号码冒充原号通道。与 {@link #SCENE_REBIND}(新号)缓存 key 隔离。
*/
public static final String SCENE_REBIND_OLD = "rebindOld";
// ---------------- 协议类型 ----------------
/**
@@ -109,6 +117,21 @@ public final class AppConstants {
*/
public static final String SMS_FAIL_KEY_PREFIX = "app:sms:fail:";
/**
* 换绑原号核验状态 key 前缀,完整 key = 前缀 + userId。
* <p>
* {@code POST /app/user/phone/verify-old} 写入,{@code PUT /app/user/phone} 换绑成功后删除;
* TTL 与短信验证码有效期一致。
*/
public static final String REBIND_OLD_OK_KEY_PREFIX = "app:rebind:old-ok:";
/**
* 手机号绑定/换绑并发保护锁 key 前缀,完整 key = 前缀 + userId。
* <p>
* 将原号核验状态检查、手机号更新和状态失效串行化,避免同一账号并发换绑重复消费一次核验状态。
*/
public static final String PHONE_REBIND_LOCK_KEY_PREFIX = "app:user:phone-rebind:lock:";
/**
* 「错误尝试」限速窗口内允许的最大失败次数(P2 加固后语义)。
* <p>
@@ -160,4 +183,14 @@ public final class AppConstants {
*/
public static final String EMAIL_REGISTER_LOCK_KEY_PREFIX = "app:user:register:lock:email:";
/**
* 换绑原邮箱核验状态 key 前缀,完整 key = 前缀 + userId。
*/
public static final String EMAIL_REBIND_OLD_OK_KEY_PREFIX = "app:rebind:email:old-ok:";
/**
* 邮箱绑定/换绑并发保护锁 key 前缀,完整 key = 前缀 + userId。
*/
public static final String EMAIL_REBIND_LOCK_KEY_PREFIX = "app:user:email-rebind:lock:";
}

View File

@@ -73,16 +73,20 @@ public final class AppMsgConstants {
public static final String EMAIL_DOMAIN_NOT_ALLOWED = "该邮箱域名暂不支持注册";
/**
* 邮箱注册成功(提示继续绑定手机号)。
* <p>「注册后仍须绑手机号」为本迭代的账号规则,故注册成功文案直接给出下一步引导。
* 邮箱注册成功,注册后可以直接使用邮箱登录;绑定手机号仅在绑定设备前要求。
*/
public static final String EMAIL_REGISTER_SUCCESS = "注册成功,请绑定手机号";
public static final String EMAIL_REGISTER_SUCCESS = "注册成功,请登录";
/**
* 该账号尚未绑定手机号(绑定设备前的硬拦截文案)。
*/
public static final String PHONE_NOT_BOUND = "请先绑定手机号";
/**
* 当前账号尚未绑定邮箱,无法发送原邮箱核验码。
*/
public static final String EMAIL_NOT_BOUND = "请先绑定邮箱";
// ---------------- 账号 ----------------
/**
@@ -160,6 +164,36 @@ public final class AppMsgConstants {
*/
public static final String PHONE_SAME_AS_CURRENT = "新手机号与当前手机号相同";
/**
* 原手机号验证码校验成功(换绑第 1 步)。
*/
public static final String OLD_PHONE_VERIFY_SUCCESS = "验证成功";
/**
* 换绑第 2 步缺少有效的原号核验状态。
*/
public static final String OLD_PHONE_NOT_VERIFIED = "请先验证原手机号";
/**
* 邮箱更换成功。
*/
public static final String EMAIL_UPDATE_SUCCESS = "邮箱更换成功";
/**
* 新邮箱与当前邮箱相同。
*/
public static final String EMAIL_SAME_AS_CURRENT = "新邮箱与当前邮箱相同";
/**
* 原邮箱验证码校验成功。
*/
public static final String OLD_EMAIL_VERIFY_SUCCESS = "验证成功";
/**
* 换绑邮箱缺少有效的原邮箱核验状态。
*/
public static final String OLD_EMAIL_NOT_VERIFIED = "请先验证原邮箱";
/**
* 协议类型不合法。
* <p>TODO 阶段 7 按原型核对。

View File

@@ -0,0 +1,78 @@
package org.dromara.device.constant;
/**
* APP 客户端版本模块常量的集中管理。
* <p>
* 命名与文案对齐架构设计 §9.4。前 8 个为「错误 / 提示文案」,后两个为「强制更新取值常量」
* (无字典、页面本地映射,见架构 §9.1)。新增 {@code VERSION_NAME_REQUIRED} /
* {@code RECORD_NOT_EXIST} 两条为服务端侧的必要校验文案(设计文档未显式列出,已在此统一维护)。
*
* @author Kou
* @date 2026-09-18
*/
public final class AppVersionMsgConstants {
private AppVersionMsgConstants() {
}
// ---------------- 校验 / 提示文案 ----------------
/**
* 平台参数不合法(APP 检查更新 / 后台登记 均适用)。
*/
public static final String PLATFORM_INVALID = "平台参数不合法";
/**
* 版本号(构建号)不合法(非正整数)。
*/
public static final String VERSION_CODE_INVALID = "版本号不合法";
/**
* 展示版本号不能为空(后台登记 / 修改必填)。
*/
public static final String VERSION_NAME_REQUIRED = "版本号不能为空";
/**
* 同平台构建号已存在。
* <p>对齐 DB 唯一键 {@code uk_platform_version_code (platform, version_code)}(<b>不区分逻辑删除行</b>)。
*/
public static final String VERSION_CODE_DUPLICATE = "该平台下构建号已存在";
/**
* 同平台展示版本号已存在(应用层预检,避免两个构建号挂同一展示版本号)。
*/
public static final String VERSION_NAME_DUPLICATE = "该平台下版本号已存在";
/**
* 构建号必须大于该平台已有最大值,{@code %d} 为当前最大值。
*/
public static final String VERSION_CODE_NOT_GREATER = "构建号必须大于该平台已有最大值(当前 %d)";
/**
* 版本必须上传安装包(Android / iOS 两平台一致)。
*/
public static final String PACKAGE_REQUIRED = "版本必须上传安装包";
/**
* 平台不可修改(修改时请求平台与库中不一致)。
*/
public static final String PLATFORM_IMMUTABLE = "平台不可修改";
/**
* 版本记录不存在(修改前置校验)。
*/
public static final String RECORD_NOT_EXIST = "版本记录不存在";
// ---------------- 取值常量(非文案) ----------------
/**
* 强制更新:是({@code force_update='1'},无字典,页面本地映射)。
*/
public static final String FORCE_UPDATE_YES = "1";
/**
* 强制更新:否({@code force_update='0'},无字典,页面本地映射)。
*/
public static final String FORCE_UPDATE_NO = "0";
}

View File

@@ -27,6 +27,11 @@ public final class DeviceMqttConstants {
*/
public static final String ONLINE_SCAN_LOCK = "bm:mqtt:online-scan:lock";
/**
* APP 告警消息 outbox 补偿互斥锁,多实例同一时刻只执行一轮扫描。
*/
public static final String APP_MESSAGE_RETRY_LOCK = "bm:mqtt:app-message-retry:lock";
/**
* 告警去重窗口占位,完整 Key:{@code bm:mqtt:alert:dedup:{deviceNo}:{alertType}}。
* <p>
@@ -48,6 +53,11 @@ public final class DeviceMqttConstants {
*/
public static final String ZLM_PLAY_TOKEN_PREFIX = "bm:zlm:play:token:";
/**
* MJPEG 设备推流 token,完整 Key:{@code bm:mjpeg:ingest:{deviceNo}}。
*/
public static final String MJPEG_INGEST_TOKEN_PREFIX = "bm:mjpeg:ingest:";
/**
* 待回执指令标记,完整 Key:{@code bm:mqtt:ack:pending:{msgId}},值为目标 {@code deviceNo}。
* <p>
@@ -60,10 +70,27 @@ public final class DeviceMqttConstants {
// ---------------- 协议固定码 ----------------
/**
* 告警处理状态:未处理(一期只读展示,不做处理流程)
* 告警处理状态:未处理
*/
public static final String ALERT_STATUS_UNHANDLED = "0";
/**
* 告警处理状态:已处理
* <p>
* 由后台「告警记录」页标记(单条或批量),与 {@link #ALERT_STATUS_UNHANDLED} 成对使用。
*/
public static final String ALERT_STATUS_HANDLED = "1";
/**
* APP 消息已读标记:未读
*/
public static final String MESSAGE_READ_FLAG_UNREAD = "0";
/**
* APP 消息已读标记:已读
*/
public static final String MESSAGE_READ_FLAG_READ = "1";
/**
* 注册结果码:成功
*/

View File

@@ -144,6 +144,11 @@ public final class DeviceMsgConstants {
*/
public static final String ZLM_NOT_CONFIGURED = "ZLM 地址未配置";
/**
* MJPEG 公网基址未配置
*/
public static final String MJPEG_NOT_CONFIGURED = "MJPEG 直播地址未配置";
/**
* 客户端自定义拉流已停用
*/

View File

@@ -0,0 +1,101 @@
package org.dromara.device.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.PageResult;
import org.dromara.common.core.domain.R;
import org.dromara.common.log.annotation.Log;
import org.dromara.common.log.enums.BusinessType;
import org.dromara.common.web.core.BaseController;
import org.dromara.device.domain.bo.AlertBo;
import org.dromara.device.domain.bo.AlertHandleBo;
import org.dromara.device.domain.vo.AlertStatsVo;
import org.dromara.device.domain.vo.AlertVo;
import org.dromara.device.service.IAlertService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
/**
* 后台告警记录 Controller(前缀 {@code /device/alert})。
* <p>
* 接口清单:
* <ul>
* <li>{@code GET /device/alert/list} —— 分页列表(含设备名称回填)</li>
* <li>{@code GET /device/alert/stats} —— 顶部统计(总数 / 未处理 / 已处理)</li>
* <li>{@code PUT /device/alert/handle} —— 标记已处理(单条与批量共用,幂等)</li>
* <li>{@code DELETE /device/alert/{ids}} —— 批量删除(逻辑删除,保留审计痕迹)</li>
* </ul>
* <p>
* <b>本页只管理「设备事实」,不管理「消息推送」</b>:告警的 APP 消息推送副本记录在
* {@code bm_app_message},由 APP 侧 {@code /app/message/**} 读取,
* 后台<b>不提供</b>消息的重发/撤回能力。二者是「事实」与「通知」的关系,不互相替代。
* <p>
* ⚠️ <b>与设备模块一致:本组接口没有部门数据权限隔离</b>——
* {@code BmDeviceAlertMapper} 未标注 {@code @DataPermission},
* 因此任何持有 {@code device:alert:list} 的账号看到的都是<b>全量告警</b>。
*
* @author Kou
* @date 2026-09-18
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/device/alert")
public class AlertController extends BaseController {
private final IAlertService alertService;
/**
* 分页查询告警记录。
* <p>
* 默认排序为「未处理优先 + 告警时间倒序」。
*/
@SaCheckPermission("device:alert:list")
@GetMapping("/list")
public R<PageResult<AlertVo>> list(@Validated AlertBo bo) {
return R.ok(alertService.queryPageList(bo));
}
/**
* 统计告警总数 / 未处理 / 已处理(顶部统计条)。
*/
@SaCheckPermission("device:alert:list")
@GetMapping("/stats")
public R<AlertStatsVo> stats() {
return R.ok(alertService.stats());
}
/**
* 标记告警为已处理。
* <p>
* 单条与批量共用本接口:标记单条时 {@code alertIds} 长度为 1。
* 幂等——重复点击不报错,也不会覆盖首次的处理人与处理时间。
*/
@SaCheckPermission("device:alert:edit")
@Log(title = "告警记录", businessType = BusinessType.UPDATE)
@PutMapping("/handle")
public R<Void> handle(@Validated @RequestBody AlertHandleBo bo) {
alertService.handle(bo);
return R.ok();
}
/**
* 批量删除告警记录(逻辑删除)。
*/
@SaCheckPermission("device:alert:remove")
@Log(title = "告警记录", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public R<Void> remove(@PathVariable("ids") Long[] ids) {
alertService.remove(Arrays.asList(ids));
return R.ok();
}
}

View File

@@ -0,0 +1,118 @@
package org.dromara.device.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.PageResult;
import org.dromara.common.core.domain.R;
import org.dromara.common.log.annotation.Log;
import org.dromara.common.log.enums.BusinessType;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.web.core.BaseController;
import org.dromara.device.domain.bo.AppVersionBo;
import org.dromara.device.domain.vo.AppVersionVo;
import org.dromara.device.service.IAppVersionService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
/**
* APP 客户端版本 Controller(后台,前缀 {@code /device/app-version})。
* <p>
* 接口清单(架构 §6.1):
* <ul>
* <li>{@code GET /device/app-version/list} —— 版本列表(默认按构建号倒序)</li>
* <li>{@code GET /device/app-version/{versionId}} —— 版本详情</li>
* <li>{@code POST /device/app-version} —— 登记版本</li>
* <li>{@code PUT /device/app-version} —— 修改版本(含启用 / 停用;平台不可改)</li>
* <li>{@code DELETE /device/app-version/{ids}} —— 删除版本(逻辑删除)</li>
* </ul>
* 后台接口一律 {@code @SaCheckPermission},变更类加 {@code @Log} 进操作日志。
* 上传安装包走框架 {@code POST /resource/oss/upload}(权限字 {@code system:oss:upload}),本控制器不承担上传。
*
* @author Kou
* @date 2026-09-18
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/device/app-version")
public class AppVersionController extends BaseController {
private final IAppVersionService appVersionService;
/**
* 查询版本列表。
*
* @param bo 查询条件(keyword / platform / status / forceUpdate / 发布时间区间)
* @param pageQuery 分页参数
* @return 版本分页结果
*/
@SaCheckPermission("device:appversion:list")
@GetMapping("/list")
public R<PageResult<AppVersionVo>> list(AppVersionBo bo, PageQuery pageQuery) {
return R.ok(appVersionService.queryPageList(bo, pageQuery));
}
/**
* 查询版本详情。
*
* @param versionId 版本主键
* @return 版本视图对象(不存在返回 {@code null})
*/
@SaCheckPermission("device:appversion:query")
@GetMapping("/{versionId}")
public R<AppVersionVo> getInfo(@PathVariable("versionId") Long versionId) {
AppVersionVo vo = appVersionService.queryById(versionId);
return R.ok(vo);
}
/**
* 登记版本。
*
* @param bo 版本业务对象
* @return 操作结果
*/
@SaCheckPermission("device:appversion:add")
@Log(title = "APP版本", businessType = BusinessType.INSERT)
@PostMapping
public R<Void> add(@RequestBody AppVersionBo bo) {
return toAjax(appVersionService.insertByBo(bo));
}
/**
* 修改版本(含启用 / 停用;平台不可改)。
*
* @param bo 版本业务对象(含 versionId)
* @return 操作结果
*/
@SaCheckPermission("device:appversion:edit")
@Log(title = "APP版本", businessType = BusinessType.UPDATE)
@PutMapping
public R<Void> edit(@RequestBody AppVersionBo bo) {
return toAjax(appVersionService.updateByBo(bo));
}
/**
* 删除版本(逻辑删除)。
* <p>
* 唯一键不区分逻辑删除,删除后同平台同构建号<b>不可复用</b>(构建号填错走编辑修正)。
*
* @param ids 版本主键数组(路径中以逗号分隔,如 {@code /device/app-version/1,2,3})
* @return 操作结果
*/
@SaCheckPermission("device:appversion:remove")
@Log(title = "APP版本", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public R<Void> remove(@PathVariable("ids") Long[] ids) {
return toAjax(appVersionService.deleteByIds(Arrays.asList(ids)));
}
}

View File

@@ -3,6 +3,7 @@ package org.dromara.device.controller.app;
import cn.dev33.satoken.annotation.SaIgnore;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.web.core.BaseController;
import org.dromara.device.constant.AppMsgConstants;
import org.dromara.device.domain.bo.AppEmailCodeBo;
@@ -48,7 +49,7 @@ public class AppAuthController extends BaseController {
public R<Void> sendSmsCode(@Validated @RequestBody AppSmsCodeBo bo) {
String code = appAuthService.sendSmsCode(bo);
// 开发环境 mock 模式暂将验证码放入 msg,接入真实短信后仍返回标准成功文案
return R.ok(code == null ? AppMsgConstants.SMS_CODE_SENT : code);
return R.ok(code == null ? MessageUtils.message("app.sms.code.sent") : code);
}
/**
@@ -106,15 +107,16 @@ public class AppAuthController extends BaseController {
*/
@PostMapping("/email/code")
public R<Void> sendEmailCode(@Validated @RequestBody AppEmailCodeBo bo) {
appAuthService.sendEmailCode(bo);
return R.ok(AppMsgConstants.EMAIL_CODE_SENT);
String code = appAuthService.sendEmailCode(bo);
// 开发环境 mock 模式与短信接口保持一致:把验证码放入 msg;真实邮件仍只返回标准成功文案。
return R.ok(code == null ? AppMsgConstants.EMAIL_CODE_SENT : code);
}
/**
* 邮箱注册。
* <p>
* 成功文案给出「请绑定手机号」引导:按本迭代账号规则,邮箱注册后须继续绑定手机号,
* 未绑手机号的账号无法绑定设备(服务端硬拦截,见 {@code IAppDeviceService#bind})。
* 邮箱注册完成后可直接使用邮箱登录;未绑定手机号的账号仅在绑定设备时受限
* (服务端硬拦截,见 {@code IAppDeviceService#bind})。
*/
@PostMapping("/email/register")
public R<Void> emailRegister(@Validated @RequestBody AppEmailRegisterBo bo) {

View File

@@ -0,0 +1,87 @@
package org.dromara.device.controller.app;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.PageResult;
import org.dromara.common.core.domain.R;
import org.dromara.common.web.core.BaseController;
import org.dromara.device.domain.bo.AppMessageQueryBo;
import org.dromara.device.domain.vo.AppMessageVo;
import org.dromara.device.service.IAppMessageService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* APP 消息 Controller(前缀 {@code /app/message})。
* <p>
* 接口清单:
* <ul>
* <li>{@code GET /app/message/list} —— 消息分页列表(按产生时间倒序)</li>
* <li>{@code GET /app/message/unread-count} —— 未读数(供消息角标)</li>
* <li>{@code PUT /app/message/{messageId}/read} —— 标记单条已读</li>
* <li>{@code PUT /app/message/read-all} —— 全部标记已读</li>
* </ul>
* <p>
* 本组接口需登录态,当前用户取自 {@code LoginHelper.getUserId()},
* <b>仅能读写本人消息</b>,不做后台数据权限、不加 {@code @SaCheckPermission}
* (口径与 {@code /app/device/**}、{@code /app/user/**} 一致)。
* <p>
* 一期消息内容<b>只有设备告警</b>(开发计划 D8 修订):离线、头套低电、脚环低电。
* 不具备「已送达/已撤回」等推送状态,也不支持后台重发。
*
* @author Kou
* @date 2026-09-18
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/app/message")
public class AppMessageController extends BaseController {
private final IAppMessageService appMessageService;
/**
* 分页查询本人消息(按产生时间倒序)。
* <p>
* 未传 {@code pageNum} / {@code pageSize} 时服务端兜底为每页 20 条,
* 上限 100 条(详见 {@code AppMessageServiceImpl})。
*
* @param bo 查询条件({@code readFlag} 可筛选未读 / 已读,留空返回全部)
*/
@GetMapping("/list")
public R<PageResult<AppMessageVo>> list(@Validated AppMessageQueryBo bo) {
return R.ok(appMessageService.queryPageList(bo));
}
/**
* 查询本人未读消息数(供消息角标)。
*/
@GetMapping("/unread-count")
public R<Long> unreadCount() {
return R.ok(appMessageService.unreadCount());
}
/**
* 标记单条消息为已读。
* <p>
* 幂等:重复调用不报错。消息不属于本人时同样返回成功(不做存在性暴露)。
*/
@PutMapping("/{messageId}/read")
public R<Void> markRead(@PathVariable("messageId") Long messageId) {
appMessageService.markRead(messageId);
return R.ok();
}
/**
* 把本人所有未读消息标记为已读。
*/
@PutMapping("/read-all")
public R<Void> markAllRead() {
appMessageService.markAllRead();
return R.ok();
}
}

View File

@@ -1,10 +1,15 @@
package org.dromara.device.controller.app;
import cn.dev33.satoken.annotation.SaIgnore;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.web.core.BaseController;
import org.dromara.device.constant.AppMsgConstants;
import org.dromara.device.domain.bo.AppOldPhoneCodeBo;
import org.dromara.device.domain.bo.AppOldEmailCodeBo;
import org.dromara.device.domain.bo.AppPasswordBo;
import org.dromara.device.domain.bo.AppEmailBo;
import org.dromara.device.domain.bo.AppPhoneBo;
import org.dromara.device.domain.vo.AppAgreementVo;
import org.dromara.device.domain.vo.AppUserProfileVo;
@@ -12,6 +17,7 @@ import org.dromara.device.service.IAppUserService;
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.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -21,8 +27,8 @@ import org.springframework.web.bind.annotation.RestController;
/**
* APP 个人中心 Controller(前缀 {@code /app/user})。
* <p>
* 本组接口需登录态;当前用户取自 {@code LoginHelper.getUserId()}(登录标识 {@code app_user:{userId}}),
* 仅能操作本人数据,不做后台数据权限。
* 除协议查询接口外,本组接口均需登录态;当前用户取自 {@code LoginHelper.getUserId()}
* (登录标识 {@code app_user:{userId}}),仅能操作本人数据,不做后台数据权限。
*
* @author Kou
* @date 2026-09-16
@@ -53,7 +59,25 @@ public class AppUserController extends BaseController {
}
/**
* 换绑手机号(新手机号 + 验证码)。
* 向当前账号原手机号发送换绑确认验证码。
*/
@PostMapping("/phone/old-code")
public R<Void> sendOldPhoneCode() {
String code = appUserService.sendOldPhoneCode();
return R.ok(code == null ? MessageUtils.message("app.sms.code.sent") : code);
}
/**
* 校验原手机号验证码,通过后写入换绑核验状态,APP 再进入绑定新号页。
*/
@PostMapping("/phone/verify-old")
public R<Void> verifyOldPhone(@Validated @RequestBody AppOldPhoneCodeBo bo) {
appUserService.verifyOldPhone(bo);
return R.ok(AppMsgConstants.OLD_PHONE_VERIFY_SUCCESS);
}
/**
* 首次绑定或更换手机号(新手机号 + 验证码)。换绑须先完成原号核验。
*/
@PutMapping("/phone")
public R<Void> rebindPhone(@Validated @RequestBody AppPhoneBo bo) {
@@ -61,6 +85,34 @@ public class AppUserController extends BaseController {
return R.ok(AppMsgConstants.PHONE_UPDATE_SUCCESS);
}
/**
* 向当前账号原邮箱发送换绑确认验证码。
*/
@PostMapping("/email/old-code")
public R<Void> sendOldEmailCode() {
String code = appUserService.sendOldEmailCode();
// 模拟邮件模式把验证码放入 msg,真实邮件模式只返回通用成功文案。
return R.ok(code == null ? AppMsgConstants.EMAIL_CODE_SENT : code);
}
/**
* 校验原邮箱验证码,通过后才允许更换已有邮箱。
*/
@PostMapping("/email/verify-old")
public R<Void> verifyOldEmail(@Validated @RequestBody AppOldEmailCodeBo bo) {
appUserService.verifyOldEmail(bo);
return R.ok(AppMsgConstants.OLD_EMAIL_VERIFY_SUCCESS);
}
/**
* 首次绑定或更换邮箱(新邮箱 + 验证码)。
*/
@PutMapping("/email")
public R<Void> rebindEmail(@Validated @RequestBody AppEmailBo bo) {
appUserService.rebindEmail(bo);
return R.ok(AppMsgConstants.EMAIL_UPDATE_SUCCESS);
}
/**
* 注销账号(先解绑全部设备,再停用账号)。
*/
@@ -71,10 +123,11 @@ public class AppUserController extends BaseController {
}
/**
* 查询协议内容(URL + 版本 + 更新时间)。
* 查询协议内容(URL + 版本 + 更新时间),供登录、注册等未登录页面使用。
*
* @param type 协议类型:user / privacy
*/
@SaIgnore
@GetMapping("/agreements")
public R<AppAgreementVo> agreements(@RequestParam("type") String type) {
return R.ok(appUserService.agreements(type));

View File

@@ -0,0 +1,52 @@
package org.dromara.device.controller.app;
import cn.dev33.satoken.annotation.SaIgnore;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R;
import org.dromara.common.web.core.BaseController;
import org.dromara.device.domain.bo.AppVersionCheckBo;
import org.dromara.device.domain.vo.AppVersionLatestVo;
import org.dromara.device.service.IAppVersionService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* APP 客户端版本 Controller(APP 端,前缀 {@code /app/version})。
* <p>
* 接口清单(架构 §6.2):
* <ul>
* <li>{@code GET /app/version/latest} —— 冷启动检查更新(<b>免登录</b>)</li>
* </ul>
* <p>
* {@link SaIgnore} 放行登录校验:版本检查发生在 APP 冷启动最早阶段,此时用户可能尚未登录,
* 且强制更新必须在登录前就能拦住,否则未登录用户可绕过强更。实现侧({@code checkLatest})
* <b>不读取登录态</b>(不得使用 {@code LoginHelper}),否则免登录会 NPE。
* <p>
* APP 请求头 {@code clientid} 必须为 {@code app}(框架与 token 内扩展做等值比对)。
*
* @author Kou
* @date 2026-09-18
*/
@SaIgnore
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/app/version")
public class AppVersionAppController extends BaseController {
private final IAppVersionService appVersionService;
/**
* 检查更新。
*
* @param bo 入参(platform / versionCode 必填,其余回显)
* @return 检查更新结果(三态:无更新 / 可选更新 / 强制更新)
*/
@GetMapping("/latest")
public R<AppVersionLatestVo> latest(@Validated AppVersionCheckBo bo) {
return R.ok(appVersionService.checkLatest(bo));
}
}

View File

@@ -0,0 +1,93 @@
package org.dromara.device.domain;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* APP 消息对象 bm_app_message
* <p>
* 一期仅承载「设备告警」向 APP 用户的推送副本(开发计划 D8 修订)。
* <p>
* 与 {@link BmDeviceAlert} 的分工:<b>告警是设备事实</b>(含后台处理流程),
* <b>消息是推送副本</b>(含收件人与已读状态)。同一设备解绑/换绑后,
* 历史消息仍归属告警发生时的收件人({@code user_id} 为快照),不会被后来者看到。
*
* @author Kou
* @date 2026-09-18
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("bm_app_message")
public class BmAppMessage implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键(雪花)
*/
@TableId(value = "message_id")
private Long messageId;
/**
* 接收用户(sys_user.user_id,APP 用户)
*/
private Long userId;
/**
* 关联设备主键
*/
private Long deviceId;
/**
* 关联设备编号(冗余,供展示与跳转)
*/
private String deviceNo;
/**
* 关联告警主键(bm_device_alert)
*/
private Long alertId;
/**
* 告警类型(offline / low_battery_hood / low_battery_ring)
*/
private String alertType;
/**
* 告警级别(0 提示 1 警告 2 严重)
*/
private String alertLevel;
/**
* 消息标题
*/
private String title;
/**
* 消息内容
*/
private String content;
/**
* 已读标记(0 未读 1 已读)
*/
private String readFlag;
/**
* 已读时间
*/
private LocalDateTime readTime;
/**
* 产生时间
*/
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,121 @@
package org.dromara.device.domain;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import java.io.Serial;
import java.time.LocalDateTime;
/**
* APP 客户端版本对象 bm_app_version
* <p>
* 后台维护、APP 冷启动检查更新({@code GET /app/version/latest})的数据源。
* 业务上与设备 OTA 固件升级<b>完全独立</b>(不共表、不共权限字、不共页面),二者仅共用「上传包到 OSS」这一技术手段。
* <p>
* 关键类型口径:
* <ul>
* <li>{@code versionCode} 为 {@link Integer}(DB {@code int},32 位正整数)——唯一比较依据。
* 用 {@code Integer} 而非 {@code Long}:框架把 {@code Long} 序列化为<b>字符串</b>、把
* {@code Integer} 序列化为<b>数字</b>,而 APP 契约要求 {@code latestVersionCode} /
* {@code currentVersionCode} 为 JSON 数字。⚠️ 构建号人工递增,<b>禁止时间戳式构建号</b>(会溢出)。</li>
* <li>{@code ossId} / {@code fileSize} 为 {@link Long}(雪花主键 / 字节数),JSON 中表现为字符串。</li>
* </ul>
*
* @author Kou
* @date 2026-09-18
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("bm_app_version")
public class BmAppVersion extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键(雪花)
*/
@TableId(value = "version_id")
private Long versionId;
/**
* 平台(android / ios,字典 bm_app_platform)
*/
private String platform;
/**
* 展示版本号,如 1.0.10(仅展示,不参与比较)
*/
private String versionName;
/**
* 构建号,32 位正整数、单调递增、平台内唯一(唯一比较依据)
*/
private Integer versionCode;
/**
* 强制更新(0 否 1 是;无字典,页面本地映射)
*/
private String forceUpdate;
/**
* 关联 sys_oss.oss_id(安装包,两平台均上传)
*/
private Long ossId;
/**
* 安装包 URL(两平台均上传;冗余自 sys_oss,供 APP 直接下载)
*/
private String fileUrl;
/**
* 安装包文件名(两平台均上传)
*/
private String fileName;
/**
* 安装包字节数(两平台均上传)
*/
private Long fileSize;
/**
* 安装包校验值 MD5(预留,本期不填,不参与校验)
*/
private String md5;
/**
* App Store 引导地址(选填)
*/
private String storeUrl;
/**
* 更新说明(纯文本)
*/
private String changelog;
/**
* 状态(0 停用 1 启用,字典 bm_app_version_status)
*/
private String status;
/**
* 发布时间(首次置为启用时写入)
*/
private LocalDateTime publishTime;
/**
* 备注
*/
private String remark;
/**
* 逻辑删除(0 存在 1 删除)
*/
@TableLogic
private String delFlag;
}

View File

@@ -1,6 +1,7 @@
package org.dromara.device.domain;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -60,13 +61,39 @@ public class BmDeviceAlert implements Serializable {
private String content;
/**
* 处理状态(0 未处理 1 已处理,一期只读展示)
* 处理状态(0 未处理 1 已处理)
*/
private String status;
/**
* 处理人(sys_user.user_id,后台账号)
*/
private Long handleBy;
/**
* 处理时间
*/
private LocalDateTime handleTime;
/**
* 告警发生时的 APP 收件人快照。
* <p>
* 该字段也是消息补偿的 outbox 载荷:消息表中不存在对应 {@code alert_id} 时,后台任务按此用户重试,
* 不再查询可能已经发生解绑或换绑的设备当前归属。
*/
private Long messageUserId;
/**
* 告警时间
*/
private LocalDateTime alertTime;
/**
* 逻辑删除(0 存在 1 删除)
* <p>
* 后台「删除」走逻辑删除,保留告警审计痕迹(与 {@code bm_device} 同款口径)。
*/
@TableLogic
private String delFlag;
}

View File

@@ -0,0 +1,61 @@
package org.dromara.device.domain.bo;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.dromara.common.mybatis.core.page.PageQuery;
import java.io.Serial;
import java.util.HashMap;
import java.util.Map;
/**
* 设备告警查询入参({@code GET /device/alert/list})。
* <p>
* 分页字段({@code pageNum} / {@code pageSize} / {@code orderByColumn} / {@code isAsc})
* 继承自 {@link PageQuery},与框架其余列表页口径一致。
* <p>
* <b>日期区间经 {@link #params} 传递</b>:前端用 {@code useDateRangeQuery} hook
* (内部 {@code utils/ruoyi.ts#addDateRange})把区间写为 {@code params[beginTime]} /
* {@code params[endTime]},与框架既有约定一致。区间按 {@code alert_time} 过滤。
*
* @author Kou
* @date 2026-09-18
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class AlertBo extends PageQuery {
@Serial
private static final long serialVersionUID = 1L;
/**
* 设备编号(精确匹配;告警表内为冗余快照,不随设备改名变化)
*/
private String deviceNo;
/**
* 告警类型(offline / low_battery_hood / low_battery_ring,精确匹配)
*/
private String alertType;
/**
* 告警级别(0 提示 1 警告 2 严重,精确匹配)
*/
private String alertLevel;
/**
* 处理状态(0 未处理 1 已处理,精确匹配)
*/
private String status;
/**
* 关键词:模糊匹配设备编号 / 告警内容(两字段 OR)
*/
private String keyword;
/**
* 扩展查询参数(约定键 {@code beginTime} / {@code endTime}:告警时间区间)
*/
private Map<String, Object> params = new HashMap<>();
}

View File

@@ -0,0 +1,32 @@
package org.dromara.device.domain.bo;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
/**
* 告警标记处理入参({@code PUT /device/alert/handle})。
* <p>
* 单条与批量共用同一入参:标记单条时列表长度即为 1。
* 之所以不拆两个接口,是因为二者语义完全一致(把 {@code status} 由 0 置 1),
* 拆开只会产生两份重复的校验与落库逻辑。
*
* @author Kou
* @date 2026-09-18
*/
@Data
public class AlertHandleBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 待处理的告警主键集合
*/
@NotEmpty(message = "请至少选择一条告警")
private List<Long> alertIds;
}

View File

@@ -0,0 +1,39 @@
package org.dromara.device.domain.bo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.dromara.device.constant.AppConstants;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 首次绑定或换绑邮箱入参。
*
* @author Kou
* @date 2026-09-23
*/
@Data
public class AppEmailBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 新邮箱地址。
*/
@NotBlank(message = "新邮箱不能为空")
@Size(max = 50, message = "邮箱长度不能超过{max}个字符")
@Pattern(regexp = AppConstants.EMAIL_REGEX, message = "请输入正确的邮箱地址")
private String newEmail;
/**
* 邮箱验证码(发送至新邮箱)。
*/
@NotBlank(message = "验证码不能为空")
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
private String code;
}

View File

@@ -14,9 +14,9 @@ import java.io.Serializable;
/**
* APP 邮箱注册入参。
* <p>
* <b>不含手机号</b>:按本迭代账号规则「注册后仍须绑手机号」,邮箱注册先建号
* ({@code phone_number} 为 {@code NULL}),再由 APP 引导调用 {@code PUT /app/user/phone}
* 完成绑定;未绑手机号的账号<b>无法绑定设备</b>(服务端硬拦截)。
* <b>不含手机号</b>:邮箱注册先建号({@code phone_number} 为 {@code NULL}),注册后可直接登录;
* 用户绑定设备前再按需调用 {@code PUT /app/user/phone} 完成手机号绑定,未绑手机号的账号
* <b>无法绑定设备</b>(服务端硬拦截)。
* <p>
* 协议勾选态由 APP 前端把关,服务端不落库(按《开发计划》§8.1)。
*
@@ -52,7 +52,7 @@ public class AppEmailRegisterBo implements Serializable {
private String password;
/**
* 昵称(选填,为空时默认取邮箱)。
* 昵称(选填,为空时默认生成 user_ 加当前时间戳)。
*/
@Xss(message = "昵称不能包含脚本字符")
@Size(max = 30, message = "昵称长度不能超过{max}个字符")

View File

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

View File

@@ -0,0 +1,31 @@
package org.dromara.device.domain.bo;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.dromara.common.mybatis.core.page.PageQuery;
import java.io.Serial;
/**
* APP 消息查询入参({@code GET /app/message/list})。
* <p>
* 分页字段继承自 {@link PageQuery}。与后台列表不同,<b>APP 端不传分页参数时由服务端兜底为
* 每页 20 条</b>(而非框架默认的「查全部」),理由见 {@code AppMessageServiceImpl#buildPage}:
* 消息是持续累积的数据,APP 首屏一次拉 1000 条既浪费流量也拖慢渲染。
*
* @author Kou
* @date 2026-09-18
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class AppMessageQueryBo extends PageQuery {
@Serial
private static final long serialVersionUID = 1L;
/**
* 已读标记筛选(0 未读 1 已读);留空返回全部
*/
private String readFlag;
}

View File

@@ -0,0 +1,30 @@
package org.dromara.device.domain.bo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import org.dromara.device.constant.AppConstants;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 换绑第 1 步:校验原邮箱验证码入参。
*
* @author Kou
* @date 2026-09-23
*/
@Data
public class AppOldEmailCodeBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 邮箱验证码(6 位数字,发送至账号原邮箱)。
*/
@NotBlank(message = "验证码不能为空")
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
private String oldCode;
}

View File

@@ -0,0 +1,32 @@
package org.dromara.device.domain.bo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import org.dromara.device.constant.AppConstants;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 换绑第 1 步:校验原手机号验证码入参。
* <p>
* 验证码由 {@code POST /app/user/phone/old-code} 发到账号当前手机号(scene = rebindOld)。
*
* @author Kou
* @date 2026-09-22
*/
@Data
public class AppOldPhoneCodeBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 短信验证码(6 位数字,发送至账号原手机号)。
*/
@NotBlank(message = "验证码不能为空")
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
private String oldCode;
}

View File

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

View File

@@ -26,7 +26,7 @@ public class AppRegisterBo implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 手机号(大陆 11 位)。
* 手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
*/
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
@@ -47,7 +47,7 @@ public class AppRegisterBo implements Serializable {
private String password;
/**
* 昵称(选填,为空时默认取手机号)。
* 昵称(选填,为空时默认生成 user_ 加当前时间戳)。
*/
@Xss(message = "昵称不能包含脚本字符")
@Size(max = 30, message = "昵称长度不能超过{max}个字符")

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,113 @@
package org.dromara.device.domain.bo;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.dromara.device.domain.BmAppVersion;
import java.io.Serial;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
/**
* APP 客户端版本业务对象 bm_app_version
* <p>
* 后台「APP版本管理」的增 / 改 / 查询入参。与实体 {@link BmAppVersion} 自动映射
* ({@code @AutoMapper}),字段名与前端 {@code types.ts} 一一对齐。
* <p>
* ⚠️ <b>写模型铁律</b>:本 BO 仅承载「运营可提交」的字段,<b>绝不夹带运行态 / 只读字段</b>
* (如 {@code publishTime} / {@code delFlag} / 审计字段 / 详情回显字段)。
* 原因:{@code updateByBo} 走 MyBatis-Plus 默认 {@code NOT_NULL} 策略,空串会被当作有效值写库,
* 历史上曾因此把在线设备静默改成未激活。只读字段一律由服务端自行维护。
*
* @author Kou
* @date 2026-09-18
*/
@Data
@AutoMapper(target = BmAppVersion.class, reverseConvertGenerate = false)
public class AppVersionBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键(新增留空;修改必填)
*/
private Long versionId;
/**
* 平台(android / ios);新增必填,修改时<b>不可变更</b>
*/
private String platform;
/**
* 展示版本号,如 1.0.10(必填,≤32 字符)
*/
private String versionName;
/**
* 构建号,32 位正整数;必须大于同平台已有最大值(含停用记录)
*/
private Integer versionCode;
/**
* 强制更新(0 否 1 是)
*/
private String forceUpdate;
/**
* 关联 sys_oss.oss_id(安装包,两平台均必传)
*/
private Long ossId;
/**
* 安装包 URL(两平台均必传)
*/
private String fileUrl;
/**
* 安装包文件名(两平台均上传)
*/
private String fileName;
/**
* 安装包字节数(两平台均上传;由前端从本地文件对象 file.raw.size 提交)
*/
private Long fileSize;
/**
* 安装包校验值 MD5(预留,本期不填)
*/
private String md5;
/**
* App Store 引导地址(选填)
*/
private String storeUrl;
/**
* 更新说明(纯文本)
*/
private String changelog;
/**
* 状态(0 停用 1 启用)
*/
private String status;
/**
* 备注
*/
private String remark;
/**
* 关键字(模糊匹配 versionName,查询用)
*/
private String keyword;
/**
* 扩展查询参数(约定键 {@code beginTime} / {@code endTime}:发布时间区间)
*/
private Map<String, Object> params = new HashMap<>();
}

View File

@@ -0,0 +1,55 @@
package org.dromara.device.domain.bo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 检查更新入参({@code GET /app/version/latest})。
* <p>
* 仅 {@code platform} 与 {@code versionCode} 为判定核心输入,其余为回显型(本期<b>不落库、不埋点</b>)。
* 校验注解的 {@code message} 刻意与 {@code AppVersionMsgConstants} 的文案逐字一致,
* 使「Controller 层 {@code @Validated} 拦下」与「Service 层再判」两种路径返回同一提示。
*
* @author Kou
* @date 2026-09-18
*/
@Data
public class AppVersionCheckBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 平台(android / ios,必填)
*/
@NotBlank(message = "平台参数不合法")
private String platform;
/**
* APP 当前构建号(必填,正整数)
*/
@NotNull(message = "版本号不合法")
@Positive(message = "版本号不合法")
private Integer versionCode;
/**
* APP 当前展示版本号(可选,本期仅回显)
*/
private String versionName;
/**
* 机型(可选,本期仅回显)
*/
private String deviceModel;
/**
* 系统版本(可选,本期仅回显)
*/
private String osVersion;
}

View File

@@ -0,0 +1,38 @@
package org.dromara.device.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 告警统计视图对象({@code GET /device/alert/stats})
* <p>
* 供后台列表页顶部统计条使用。三个计数口径互相独立:
* {@code total} 为该筛选条件下的全量,{@code unhandled + handled} 应恒等于 {@code total}。
*
* @author Kou
* @date 2026-09-18
*/
@Data
public class AlertStatsVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 告警总数
*/
private Long total;
/**
* 未处理数(status = 0)
*/
private Long unhandled;
/**
* 已处理数(status = 1)
*/
private Long handled;
}

View File

@@ -0,0 +1,114 @@
package org.dromara.device.domain.vo;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated;
import org.apache.fesod.sheet.annotation.ExcelProperty;
import org.apache.fesod.sheet.annotation.format.DateTimeFormat;
import org.dromara.common.translation.annotation.Translation;
import org.dromara.common.translation.constant.TransConstant;
import org.dromara.device.domain.BmDeviceAlert;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 设备告警视图对象 bm_device_alert
*
* @author Kou
* @date 2026-09-18
*/
@Data
@ExcelIgnoreUnannotated
@AutoMapper(target = BmDeviceAlert.class)
public class AlertVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@ExcelProperty(value = "告警主键")
private Long alertId;
/**
* 设备主键
*/
private Long deviceId;
/**
* 设备编号
*/
@ExcelProperty(value = "设备编号")
private String deviceNo;
/**
* 设备名称
* <p>
* 冗余自 {@code bm_device.name}(告警表本身不存名称),由服务层按 {@code deviceNo} 批量回填。
* 设备已被物理删除或名称缺失时为 {@code null},前端降级展示设备编号。
*/
@ExcelProperty(value = "设备名称")
private String deviceName;
/**
* 告警类型(offline / low_battery_hood / low_battery_ring)
*/
@ExcelProperty(value = "告警类型")
private String alertType;
/**
* 告警级别(0 提示 1 警告 2 严重)
* <p>
* 与 {@code alertType} 不同,本字段**无字典**(DB char(1) 直存),前端本地映射文案。
*/
@ExcelProperty(value = "告警级别")
private String alertLevel;
/**
* 触发值(如电量)
*/
@ExcelProperty(value = "触发值")
private String alertValue;
/**
* 告警文案
*/
@ExcelProperty(value = "告警内容")
private String content;
/**
* 处理状态(0 未处理 1 已处理)
*/
@ExcelProperty(value = "处理状态")
private String status;
/**
* 处理人(后台账号主键)
*/
private Long handleBy;
/**
* 处理人账号名
*/
@Translation(type = TransConstant.USER_ID_TO_NAME, mapper = "handleBy")
@ExcelProperty(value = "处理人")
private String handleUserName;
/**
* 处理时间
*/
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
@ExcelProperty(value = "处理时间")
private LocalDateTime handleTime;
/**
* 告警时间
*/
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
@ExcelProperty(value = "告警时间")
private LocalDateTime alertTime;
}

View File

@@ -0,0 +1,77 @@
package org.dromara.device.domain.vo;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.dromara.device.domain.BmAppMessage;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* APP 消息视图对象({@code GET /app/message/list})。
* <p>
* 供 APP 消息列表与未读角标使用。{@code deviceNo} 一并返回,使 APP 可在用户点击消息时
* 直接跳转设备详情({@code /app/device/{deviceNo}}),无需再查一次告警。
*
* @author Kou
* @date 2026-09-18
*/
@Data
@AutoMapper(target = BmAppMessage.class)
public class AppMessageVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 消息主键
*/
private Long messageId;
/**
* 关联设备编号(供 APP 跳转设备详情)
*/
private String deviceNo;
/**
* 关联告警主键
*/
private Long alertId;
/**
* 告警类型(offline / low_battery_hood / low_battery_ring)
*/
private String alertType;
/**
* 告警级别(0 提示 1 警告 2 严重)
*/
private String alertLevel;
/**
* 消息标题
*/
private String title;
/**
* 消息内容
*/
private String content;
/**
* 已读标记(0 未读 1 已读)
*/
private String readFlag;
/**
* 已读时间
*/
private LocalDateTime readTime;
/**
* 产生时间
*/
private LocalDateTime createTime;
}

View File

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

View File

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

View File

@@ -36,7 +36,7 @@ public class AppUserAdminVo implements Serializable {
private String userName;
/**
* 昵称(手机号注册默认取手机号;邮箱注册默认取邮箱)
* 昵称(注册时未填写则默认生成 user_ 加当前时间戳)
*/
private String nickName;

View File

@@ -45,8 +45,7 @@ public class AppUserProfileVo implements Serializable {
/**
* 是否已绑定手机号。
* <p>
* 供 APP 判断是否需强制引导绑手机号(账号规则:邮箱注册后仍须绑手机号,
* 未绑手机号的账号无法绑定设备)。
* 供 APP 在绑定设备前判断是否需要引导绑手机号;未绑手机号不影响登录和其他个人中心操作。
*/
private Boolean phoneBound;

View File

@@ -0,0 +1,89 @@
package org.dromara.device.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 检查更新响应视图对象({@code GET /app/version/latest} 的响应体)。
* <p>
* 由 {@code AppVersionServiceImpl#checkLatest} <b>手工组装</b>(不与实体自动映射):
* 字段是「三态表达 + 安装包信息」的组合语义;Android 与 iOS 下发口径一致(storeUrl 选填)。
* <p>
* <b>三态表达(APP 侧判定口径)</b>
* <table border="1">
* <tr><th>判定</th><th>条件</th><th>APP 行为</th></tr>
* <tr><td>无更新</td><td>{@code hasUpdate=false}</td><td>不弹窗({@code forceUpdate} 恒 false)</td></tr>
* <tr><td>可选更新</td><td>{@code hasUpdate=true 且 forceUpdate=false}</td><td>可关闭弹窗:立即更新 / 稍后再说</td></tr>
* <tr><td>强制更新</td><td>{@code hasUpdate=true 且 forceUpdate=true}</td><td>不可关闭弹窗(iOS 仅能引导 App Store,无法真正拦截)</td></tr>
* </table>
* <p>
* ⚠️ 类型序列化:{@code latestVersionCode} / {@code currentVersionCode} 为 {@code Integer} → JSON <b>数字</b>;
* {@code fileSize} 为 {@code Long} → JSON <b>字符串</b>(APP 需 {@code Number()} 处理后展示)。
*
* @author Kou
* @date 2026-09-18
*/
@Data
public class AppVersionLatestVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 平台(回显入参)
*/
private String platform;
/**
* 是否有新版本(三态判定主开关)
*/
private Boolean hasUpdate;
/**
* 是否强制更新;{@code hasUpdate=false} 时恒为 false
*/
private Boolean forceUpdate;
/**
* 最新构建号(Integer → JSON 数字;无启用记录或已是最新时为 null)
*/
private Integer latestVersionCode;
/**
* 最新展示版本号(弹窗标题用)
*/
private String latestVersionName;
/**
* 更新说明
*/
private String changelog;
/**
* 安装包字节数(Long → JSON 字符串;两平台均下发)
*/
private Long fileSize;
/**
* 安装包校验值 MD5(本期多为 null;两平台均下发)
*/
private String md5;
/**
* 安装包 OSS 地址(两平台均下发;有值即下发)
*/
private String downloadUrl;
/**
* App Store 引导地址(选填;有值即下发)
*/
private String storeUrl;
/**
* 回显入参,便于 APP 自校验
*/
private Integer currentVersionCode;
}

View File

@@ -0,0 +1,125 @@
package org.dromara.device.domain.vo;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated;
import org.apache.fesod.sheet.annotation.ExcelProperty;
import org.dromara.device.domain.BmAppVersion;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* APP 客户端版本视图对象 bm_app_version
* <p>
* 后台列表 / 详情回显。与实体 {@link BmAppVersion} 自动映射({@code @AutoMapper})。
* <p>
* ⚠️ 前端字段类型约定(框架序列化):{@code versionId} / {@code ossId} / {@code fileSize}({@code Long})
* 在 JSON 中为<b>字符串</b>;{@code versionCode}({@code Integer})为<b>数字</b>。前端据此兼容处理。
*
* @author Kou
* @date 2026-09-18
*/
@Data
@ExcelIgnoreUnannotated
@AutoMapper(target = BmAppVersion.class)
public class AppVersionVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@ExcelProperty(value = "版本主键")
private Long versionId;
/**
* 平台(android / ios)
*/
@ExcelProperty(value = "平台")
private String platform;
/**
* 展示版本号,如 1.0.10
*/
@ExcelProperty(value = "展示版本号")
private String versionName;
/**
* 构建号(唯一比较依据)
*/
@ExcelProperty(value = "构建号")
private Integer versionCode;
/**
* 强制更新(0 否 1 是)
*/
@ExcelProperty(value = "强制更新")
private String forceUpdate;
/**
* 关联 sys_oss.oss_id
*/
private Long ossId;
/**
* 安装包 URL(两平台均上传)
*/
private String fileUrl;
/**
* 安装包文件名(两平台均上传)
*/
@ExcelProperty(value = "安装包文件名")
private String fileName;
/**
* 安装包字节数(两平台均上传)
*/
private Long fileSize;
/**
* 安装包校验值 MD5(预留,本期多为空)
*/
private String md5;
/**
* App Store 引导地址(选填)
*/
private String storeUrl;
/**
* 更新说明
*/
@ExcelProperty(value = "更新说明")
private String changelog;
/**
* 状态(0 停用 1 启用)
*/
@ExcelProperty(value = "状态")
private String status;
/**
* 发布时间
*/
private LocalDateTime publishTime;
/**
* 备注
*/
private String remark;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}

View File

@@ -1,12 +1,11 @@
package org.dromara.device.email;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.extra.mail.MailAccount;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mail.core.MailBuilder;
import org.dromara.common.mail.core.GmailCodeService;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.device.config.AppProperties;
import org.dromara.device.constant.AppConstants;
@@ -41,14 +40,16 @@ import java.time.Duration;
public class AppEmailService {
private final AppProperties appProperties;
private final GmailCodeService gmailCodeService;
/**
* 生成并按场景发送邮箱验证码(含 60 秒重发限流与域名白名单校验)。
*
* @param email 邮箱地址(大小写不敏感)
* @param scene 场景(register / login / resetPwd)
* @return mock 模式下返回验证码供接口联调,真实邮件模式返回 {@code null}
*/
public void sendCode(String email, String scene) {
public String sendCode(String email, String scene) {
if (!EmailSceneEnum.isValid(scene)) {
throw new ServiceException(AppMsgConstants.EMAIL_SCENE_INVALID);
}
@@ -69,6 +70,8 @@ public class AppEmailService {
// 新码对应新计数:清零历史失败计数(错误的限速窗口也随新码重新起算)
RedisUtils.deleteObject(AppConstants.EMAIL_FAIL_KEY_PREFIX + scene + ":" + to);
send(to, scene, code);
// 仅模拟发送时向上返回验证码,避免真实邮件模式通过接口泄露验证码。
return cfg.isMock() ? code : null;
}
/**
@@ -164,50 +167,16 @@ public class AppEmailService {
email, scene, code, cfg.getExpiryMinutes());
return;
}
String from = StringUtils.isBlank(cfg.getFrom()) ? cfg.getUser() : cfg.getFrom();
try {
MailAccount account = new MailAccount();
account.setHost(cfg.getHost());
account.setPort(cfg.getPort());
account.setAuth(true);
account.setUser(cfg.getUser());
account.setPass(cfg.getPass());
account.setFrom(from);
account.setSslEnable(cfg.isSslEnable());
account.setStarttlsEnable(cfg.isStarttlsEnable());
account.setSocketFactoryPort(cfg.getPort());
account.setConnectionTimeout(cfg.getConnectionTimeout());
account.setTimeout(cfg.getTimeout());
MailBuilder.of(account)
.to(email)
.subject(cfg.getSubject())
.html(buildHtml(code, cfg.getExpiryMinutes()))
.send();
// 真实发信统一复用刚接入的 Gmail 服务,确保 APP 与后台使用同一套账号、超时和发信配置。
gmailCodeService.sendVerifyCode(email, code, cfg.getExpiryMinutes());
log.info("[APP邮箱] 验证码已发送:收件邮箱={},场景={}", email, scene);
} catch (Exception e) {
// 不记录验证码本身,避免真实发信失败时把验证码写进日志
log.error("APP 邮箱验证码发送异常 email={} host={}:{}(若为连接超时,请确认服务器可访问 smtp.gmail.com)",
email, cfg.getHost(), cfg.getPort(), e);
log.error("APP 邮箱验证码发送异常 email={}(若为连接超时,请确认服务器可访问 smtp.gmail.com)",
email, e);
throw new ServiceException(AppMsgConstants.EMAIL_SEND_FAILED);
}
}
/**
* 构造验证码邮件正文(纯 HTML,无外部资源,避免被邮件客户端拦截)。
*
* @param code 验证码
* @param expiryMinutes 有效期(分钟)
* @return HTML 正文
*/
private String buildHtml(String code, int expiryMinutes) {
return "<div style=\"font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;"
+ "font-size:14px;line-height:1.6;color:#2c2c2a\">"
+ "<p>您正在使用智能飞禽物联平台,本次操作的验证码为:</p>"
+ "<p style=\"font-size:24px;font-weight:500;letter-spacing:4px;margin:16px 0\">" + code + "</p>"
+ "<p>验证码 " + expiryMinutes + " 分钟内有效,请勿泄露给他人。</p>"
+ "<p style=\"color:#5f5e5a\">若非本人操作,请忽略本邮件。</p>"
+ "</div>";
}
}

View File

@@ -0,0 +1,57 @@
package org.dromara.device.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* APP 平台枚举
* <p>
* 对应字典 {@code bm_app_platform}:{@code android} 安卓 / {@code ios} iOS。
* 取值与表 {@code bm_app_version.platform}、APP 检查更新入参 {@code platform} 完全一致。
*
* @author Kou
* @date 2026-09-18
*/
@Getter
@AllArgsConstructor
public enum AppPlatformEnum {
/**
* 安卓
*/
ANDROID("android", "安卓"),
/**
* iOS
*/
IOS("ios", "iOS");
/**
* 平台编码
*/
private final String code;
/**
* 平台文案
*/
private final String label;
/**
* 判定平台编码是否合法(取值严格等于枚举定义,不接受大小写或别名变体)。
*
* @param code 待校验的平台编码
* @return {@code true} 合法;{@code false} 非法(含 {@code null})
*/
public static boolean isValid(String code) {
if (code == null) {
return false;
}
for (AppPlatformEnum item : values()) {
if (item.code.equals(code)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,39 @@
package org.dromara.device.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* APP 版本状态枚举
* <p>
* 对应字典 {@code bm_app_version_status}:{@code 0} 停用 / {@code 1} 启用。
* 逻辑删除取值口径另见 {@code common-mybatis.yml}({@code logicDeleteValue=1})。
*
* @author Kou
* @date 2026-09-18
*/
@Getter
@AllArgsConstructor
public enum AppVersionStatusEnum {
/**
* 停用
*/
DISABLED("0", "停用"),
/**
* 启用
*/
ENABLED("1", "启用");
/**
* 状态编码
*/
private final String code;
/**
* 状态文案
*/
private final String label;
}

View File

@@ -7,12 +7,11 @@ import org.dromara.common.core.utils.StringUtils;
/**
* APP 邮箱验证码场景枚举。
* <p>
* 场景编码<b>复用</b> {@code AppConstants.SCENE_*} 的取值(register / login / resetPwd),
* 场景编码<b>复用</b> {@code AppConstants.SCENE_*} 的取值(register / login / resetPwd / bind / rebind / rebindOld),
* 与短信通道保持一致的语义;但由于 Redis key 前缀不同({@code app:email:*} 与 {@code app:sms:*}),
* 两个通道的验证码与限流互不影响。
* <p>
* <b>为什么没有 rebind / bind</b>:手机号的绑定与换绑必须由<b>新手机号收到的短信验证码</b>证明归属,
* 邮箱验证码无法证明手机号归属,故邮箱通道不提供这两个场景。
* 邮箱换绑同样采用「原邮箱核验 + 新邮箱验证码」两步流程;首次绑定邮箱使用 bind 场景。
*
* @author Kou
* @date 2026-09-17
@@ -34,7 +33,22 @@ public enum EmailSceneEnum {
/**
* 忘记密码。
*/
RESET_PWD("resetPwd", "忘记密码");
RESET_PWD("resetPwd", "忘记密码"),
/**
* 首次绑定邮箱。
*/
BIND("bind", "绑定邮箱"),
/**
* 更换已有邮箱。
*/
REBIND("rebind", "换绑邮箱"),
/**
* 换绑前确认原邮箱。
*/
REBIND_OLD("rebindOld", "验证原邮箱");
/**
* 场景编码。

View File

@@ -40,7 +40,12 @@ public enum SmsSceneEnum {
/**
* 绑定手机(首次绑定:邮箱注册的账号补绑手机号时使用)。
*/
BIND("bind", "绑定手机");
BIND("bind", "绑定手机"),
/**
* 换绑前确认原手机号(仅登录后的原号发码接口使用)。
*/
REBIND_OLD("rebindOld", "确认原手机号");
/**
* 场景编码。

View File

@@ -0,0 +1,15 @@
package org.dromara.device.mapper;
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
import org.dromara.device.domain.BmAppMessage;
import org.dromara.device.domain.vo.AppMessageVo;
/**
* APP 消息 Mapper 接口
*
* @author Kou
* @date 2026-09-18
*/
public interface BmAppMessageMapper extends BaseMapperPlus<BmAppMessage, AppMessageVo> {
}

View File

@@ -0,0 +1,28 @@
package org.dromara.device.mapper;
import org.apache.ibatis.annotations.Param;
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
import org.dromara.device.domain.BmAppVersion;
import org.dromara.device.domain.vo.AppVersionVo;
/**
* APP 客户端版本 Mapper 接口
* <p>
* 基础 CRUD 走 {@link BaseMapperPlus},自定义 SQL 预留(见 {@code BmAppVersionMapper.xml})。
*
* @author Kou
* @date 2026-09-18
*/
public interface BmAppVersionMapper extends BaseMapperPlus<BmAppVersion, AppVersionVo> {
/**
* 查询平台构建号历史高水位,包含已逻辑删除记录。
*
* @param platform 平台
* @param excludeVersionId 修改时排除自身;新增时为 {@code null}
* @return 历史最大构建号;没有记录时返回 {@code null}
*/
Integer selectMaxVersionCodeIncludingDeleted(@Param("platform") String platform,
@Param("excludeVersionId") Long excludeVersionId);
}

View File

@@ -1,7 +1,9 @@
package org.dromara.device.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
import org.dromara.device.domain.BmDeviceAlert;
import org.dromara.device.domain.vo.AlertVo;
/**
* 设备告警 Mapper 接口
@@ -9,6 +11,14 @@ import org.dromara.device.domain.BmDeviceAlert;
* @author Kou
* @date 2026-09-16
*/
public interface BmDeviceAlertMapper extends BaseMapperPlus<BmDeviceAlert, BmDeviceAlert> {
public interface BmDeviceAlertMapper extends BaseMapperPlus<BmDeviceAlert, AlertVo> {
/**
* 查询尚未生成 APP 消息的告警 outbox 记录。
*
* @param page 只用于数据库方言分页,调用方关闭总数查询
* @return 待补偿告警页
*/
Page<BmDeviceAlert> selectPendingMessageAlerts(Page<BmDeviceAlert> page);
}

View File

@@ -0,0 +1,314 @@
package org.dromara.device.mjpeg;
import org.springframework.stereotype.Component;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
/**
* 内存中的 MJPEG 帧中转:每台设备保留最新一帧和观看人数。
*
* @author Kou
* @date 2026-09-18
*/
@Component
public class MjpegFrameHub {
private final ConcurrentHashMap<String, Session> sessions = new ConcurrentHashMap<>();
/**
* 开播时建立会话,供空闲停推计时。
*
* @param deviceNo 设备编号
*/
public void open(String deviceNo) {
open(deviceNo, null);
}
/**
* 建立或刷新推流会话,并绑定本次签发的推流凭证。
*
* @param deviceNo 设备编号
* @param ingestToken 本次推流凭证;仅播放测试场景可为空
*/
public void open(String deviceNo, String ingestToken) {
long now = System.currentTimeMillis();
sessions.compute(deviceNo, (key, session) -> {
if (session == null || session.closed || ingestToken != null) {
// 新 token 代表新一轮推流;替换会话可立即切断旧播放连接。
return new Session(now, ingestToken);
}
synchronized (session) {
session.ingestToken = ingestToken;
session.frame = null;
session.lastPushAt = 0L;
// 重复开播且当前无人观看时,从本次开播重新计算空闲窗口。
if (session.viewers.get() == 0) {
session.idleSince.set(now);
}
}
return session;
});
}
/**
* 写入最新一帧。
*
* @param deviceNo 设备编号
* @param jpeg JPEG 字节
*/
public boolean publish(String deviceNo, String ingestToken, byte[] jpeg) {
AtomicBoolean published = new AtomicBoolean();
// 只允许写入已由 open 建立的会话,避免停播与在途推帧竞争时复活会话。
sessions.computeIfPresent(deviceNo, (key, session) -> {
if (!session.closed && Objects.equals(session.ingestToken, ingestToken)) {
session.frame = jpeg;
session.lastPushAt = System.currentTimeMillis();
published.set(true);
}
return session;
});
return published.get();
}
/**
* 兼容仅用于无 token 测试会话的旧调用形式。
*
* @param deviceNo 设备编号
* @param jpeg JPEG 字节
* @return 是否写入
*/
public boolean publish(String deviceNo, byte[] jpeg) {
return publish(deviceNo, null, jpeg);
}
/**
* 最新一帧,没有则 {@code null}。
*
* @param deviceNo 设备编号
* @return JPEG 或 {@code null}
*/
public byte[] latest(String deviceNo) {
Session session = sessions.get(deviceNo);
return session == null || session.closed ? null : session.frame;
}
/**
* 读取观看连接所属会话的最新帧,避免停播重开后旧连接串到新会话。
*
* @param lease 观看租约
* @return JPEG 或 {@code null}
*/
public byte[] latest(ViewerLease lease) {
Session session = activeSession(lease);
return session == null ? null : session.frame;
}
/**
* 最近 {@code ttlSeconds} 秒内是否收到过帧。
*
* @param deviceNo 设备编号
* @param ttlSeconds 帧过期秒数
* @return 是否在线
*/
public boolean online(String deviceNo, int ttlSeconds) {
Session session = sessions.get(deviceNo);
if (session == null || session.closed || session.lastPushAt == 0L) {
return false;
}
return System.currentTimeMillis() - session.lastPushAt <= ttlSeconds * 1000L;
}
/**
* 增加一名观看者。
*
* @param deviceNo 设备编号
*/
public ViewerLease addViewer(String deviceNo) {
Session session = sessions.get(deviceNo);
if (session == null) {
return ViewerLease.invalid(deviceNo);
}
synchronized (session) {
// 开播替换或停播清理可能已把该对象移出当前会话,不能把观看者挂到旧对象上。
if (session.closed || sessions.get(deviceNo) != session) {
return ViewerLease.invalid(deviceNo);
}
session.viewers.incrementAndGet();
session.idleSince.set(0L);
}
return new ViewerLease(deviceNo, session);
}
/**
* 减少一名观看者。
*
* @param deviceNo 设备编号
*/
public void removeViewer(String deviceNo) {
Session session = sessions.get(deviceNo);
if (session == null) {
return;
}
decrementViewer(session);
}
/**
* 释放指定观看连接的租约,操作具备幂等性。
*
* @param lease 观看租约
*/
public void removeViewer(ViewerLease lease) {
if (lease == null || !lease.released.compareAndSet(false, true)) {
return;
}
Session current = sessions.get(lease.deviceNo);
if (current == lease.session && !current.closed) {
decrementViewer(current);
}
}
/**
* 判断观看连接是否仍属于当前活动会话。
*
* @param lease 观看租约
* @return 是否有效
*/
public boolean active(ViewerLease lease) {
return activeSession(lease) != null;
}
/**
* 当前观看人数。
*
* @param deviceNo 设备编号
* @return 人数
*/
public int viewerCount(String deviceNo) {
Session session = sessions.get(deviceNo);
return session == null || session.closed ? 0 : session.viewers.get();
}
/**
* 空闲计时起点;无会话返回 0。
*
* @param deviceNo 设备编号
* @return epoch 毫秒
*/
public long idleSince(String deviceNo) {
Session session = sessions.get(deviceNo);
return session == null || session.closed ? 0L : session.idleSince.get();
}
/**
* 原子地清理达到空闲阈值的会话,避免扫描后新观看者加入仍被误停推。
*
* @param deviceNo 设备编号
* @param expectedIdleAt 扫描时读取的空闲起点
* @param now 当前时间
* @param idleMs 空闲阈值
* @return 是否成功清理
*/
public boolean clearIfIdle(String deviceNo, long expectedIdleAt, long now, long idleMs) {
final boolean[] cleared = {false};
sessions.computeIfPresent(deviceNo, (key, session) -> {
synchronized (session) {
if (!session.closed && session.viewers.get() == 0
&& session.idleSince.get() == expectedIdleAt
&& expectedIdleAt > 0L && now - expectedIdleAt >= idleMs) {
session.closed = true;
session.frame = null;
cleared[0] = true;
return null;
}
}
return session;
});
return cleared[0];
}
/**
* 当前仍在中转的设备编号。
*
* @return 设备编号集合
*/
public Set<String> deviceNos() {
return Set.copyOf(sessions.keySet());
}
/**
* 清掉该设备的帧与观看计数。
*
* @param deviceNo 设备编号
*/
public void clear(String deviceNo) {
Session session = sessions.remove(deviceNo);
if (session != null) {
synchronized (session) {
session.closed = true;
session.frame = null;
session.viewers.set(0);
}
}
}
private static final class Session {
private final AtomicLong idleSince;
private volatile byte[] frame;
private volatile long lastPushAt;
private volatile String ingestToken;
private final AtomicInteger viewers = new AtomicInteger();
private volatile boolean closed;
private Session(long createdAt, String ingestToken) {
this.idleSince = new AtomicLong(createdAt);
this.ingestToken = ingestToken;
}
}
/**
* 将观看人数减一,并在最后一名观看者离开时记录空闲起点。
*/
private void decrementViewer(Session session) {
synchronized (session) {
session.viewers.updateAndGet(count -> Math.max(0, count - 1));
if (session.viewers.get() == 0) {
session.idleSince.compareAndSet(0L, System.currentTimeMillis());
}
}
}
private Session activeSession(ViewerLease lease) {
if (lease == null || lease.released.get()) {
return null;
}
Session session = sessions.get(lease.deviceNo);
return session == lease.session && !session.closed ? session : null;
}
/**
* 单个 HTTP 播放连接持有的会话租约。
*/
public static final class ViewerLease {
private final String deviceNo;
private final Session session;
private final AtomicBoolean released;
private ViewerLease(String deviceNo, Session session) {
this.deviceNo = deviceNo;
this.session = session;
this.released = new AtomicBoolean();
}
private static ViewerLease invalid(String deviceNo) {
ViewerLease lease = new ViewerLease(deviceNo, null);
lease.released.set(true);
return lease;
}
}
}

View File

@@ -0,0 +1,100 @@
package org.dromara.device.mjpeg;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.device.config.MjpegProperties;
import org.dromara.device.config.ZlmProperties;
import org.dromara.device.mqtt.DeviceCommandPublisher;
import org.dromara.device.mqtt.payload.StreamCommandPayload;
import org.springframework.stereotype.Component;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
/**
* 开播后持续无人观看则通知设备停推,避免摄像头空转耗电。
* <p>
* 不用 {@code @Scheduled}:与 {@code DeviceOnlineMonitor} 相同,SnailJob 关闭时注解任务不会跑。
*
* @author Kou
* @date 2026-09-18
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MjpegIdleMonitor {
private final MjpegProperties properties;
private final MjpegFrameHub hub;
private final MjpegIngestTokenService ingestTokenService;
private final DeviceCommandPublisher commandPublisher;
private final ZlmProperties zlmProperties;
private final MjpegLifecycleCoordinator lifecycleCoordinator;
private final ScheduledExecutorService scheduledExecutorService;
private volatile ScheduledFuture<?> scanFuture;
/**
* 启动空闲扫描。
*/
@PostConstruct
public void start() {
if (!properties.isEnabled()) {
return;
}
scanFuture = scheduledExecutorService.scheduleWithFixedDelay(
this::scanSafely, 5, 2, TimeUnit.SECONDS);
}
/**
* 停止扫描。
*/
@PreDestroy
public void stop() {
ScheduledFuture<?> future = scanFuture;
if (future != null) {
future.cancel(false);
}
}
/**
* 扫描入口隔离异常,避免单台设备状态异常导致定时任务永久停止。
*/
private void scanSafely() {
try {
scan();
} catch (Exception e) {
log.warn("[MJPEG] 空闲扫描失败", e);
}
}
/**
* 遍历活动会话并对达到空闲阈值的设备发送停推指令。
*/
private void scan() {
// 空闲时间从最后一名观看者离开时开始,而不是从开播时刻开始。
long idleMs = properties.getIdleStopSeconds() * 1000L;
long now = System.currentTimeMillis();
for (String deviceNo : hub.deviceNos()) {
lifecycleCoordinator.execute(deviceNo, () -> reclaimIfIdle(deviceNo, now, idleMs));
}
}
/**
* 在设备生命周期锁内复核并回收空闲会话,避免与重新开播交错。
*/
private void reclaimIfIdle(String deviceNo, long now, long idleMs) {
long idleSince = hub.idleSince(deviceNo);
if (idleSince == 0L || !hub.clearIfIdle(deviceNo, idleSince, now, idleMs)) {
return;
}
log.info("[MJPEG] 无人观看,通知设备停推 deviceNo={}", deviceNo);
commandPublisher.publishStream(deviceNo, StreamCommandPayload.stop(
DeviceCommandPublisher.newMsgId(), deviceNo, zlmProperties.getApp()));
ingestTokenService.revoke(deviceNo);
}
}

View File

@@ -0,0 +1,75 @@
package org.dromara.device.mjpeg;
import cn.hutool.core.util.IdUtil;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.device.constant.DeviceMqttConstants;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Objects;
/**
* 设备 MJPEG 推流 token(按 deviceNo 存放,开播签发、停播删除)。
*
* @author Kou
* @date 2026-09-18
*/
@Service
@RequiredArgsConstructor
public class MjpegIngestTokenService {
private static final Duration TTL = Duration.ofHours(2);
/**
* 为设备签发推流 token,覆盖旧值。
*
* @param deviceNo 设备编号
* @return token
*/
public String issue(String deviceNo) {
String token = IdUtil.fastSimpleUUID();
RedisUtils.setCacheObject(key(deviceNo), token, TTL);
return token;
}
/**
* 校验推流 token。
*
* @param deviceNo 设备编号
* @param token 请求携带的 token
* @return 是否通过
*/
public boolean validate(String deviceNo, String token) {
if (StringUtils.isBlank(deviceNo) || StringUtils.isBlank(token)) {
return false;
}
String cached = RedisUtils.getCacheObject(key(deviceNo));
return Objects.equals(cached, token);
}
/**
* 收到合法帧后延长 token。
*
* @param deviceNo 设备编号
*/
public void touch(String deviceNo) {
// 只延长现有 key 的 TTL,不重新写入值,避免停播撤销与在途帧竞争时复活 token。
RedisUtils.expire(key(deviceNo), TTL);
}
/**
* 停播时删除 token。
*
* @param deviceNo 设备编号
*/
public void revoke(String deviceNo) {
RedisUtils.deleteObject(key(deviceNo));
}
private String key(String deviceNo) {
return DeviceMqttConstants.MJPEG_INGEST_TOKEN_PREFIX + deviceNo;
}
}

View File

@@ -0,0 +1,45 @@
package org.dromara.device.mjpeg;
import org.springframework.stereotype.Component;
import java.util.concurrent.locks.ReentrantLock;
/**
* MJPEG 会话生命周期协调器。
* <p>
* 同一设备的开播、停播与空闲回收必须串行,避免旧会话的延迟清理误删新签发的 token。
* 使用固定数量的分段锁,既限制内存占用,也允许不同设备并行处理。
*
* @author Kou
* @date 2026-09-18
*/
@Component
public class MjpegLifecycleCoordinator {
private static final int STRIPE_COUNT = 64;
private final ReentrantLock[] locks = new ReentrantLock[STRIPE_COUNT];
public MjpegLifecycleCoordinator() {
for (int index = 0; index < locks.length; index++) {
locks[index] = new ReentrantLock();
}
}
/**
* 在设备对应的生命周期锁内执行操作。
*
* @param deviceNo 设备编号
* @param action 生命周期操作
*/
public void execute(String deviceNo, Runnable action) {
ReentrantLock lock = locks[Math.floorMod(deviceNo.hashCode(), locks.length)];
lock.lock();
try {
action.run();
} finally {
lock.unlock();
}
}
}

View File

@@ -0,0 +1,178 @@
package org.dromara.device.mjpeg;
import cn.dev33.satoken.annotation.SaIgnore;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.utils.ServletUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.device.config.MjpegProperties;
import org.dromara.device.zlm.stream.ZlmPlayAuthService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
/**
* MJPEG 推流入口与播放入口。
* <p>
* 免登录,靠 query {@code token} 鉴权:推流用开播签发的 ingest token,播放用 APP play token。
*
* @author Kou
* @date 2026-09-18
*/
@Slf4j
@SaIgnore
@RestController
@RequestMapping("/mjpeg")
@RequiredArgsConstructor
public class MjpegStreamController {
private static final byte[] JPEG_SOI = {(byte) 0xFF, (byte) 0xD8};
private static final String BOUNDARY = "frame";
private static final long TOKEN_RECHECK_INTERVAL_MS = 1_000L;
private final MjpegFrameHub hub;
private final MjpegIngestTokenService ingestTokenService;
private final MjpegProperties properties;
private final ZlmPlayAuthService playAuthService;
/**
* 设备推一帧 JPEG。固件按约 5fps 循环 POST。
*
* @param deviceNo 设备编号
* @param token 开播指令 pushUrl 上的 token
* @param body JPEG 字节
* @return 成功或失败
*/
@PostMapping(value = "/ingest/{deviceNo}",
consumes = {MediaType.IMAGE_JPEG_VALUE, MediaType.APPLICATION_OCTET_STREAM_VALUE, MediaType.ALL_VALUE})
public R<Void> ingest(@PathVariable String deviceNo,
@RequestParam String token,
@RequestBody byte[] body) {
if (!properties.isEnabled()) {
return R.fail("MJPEG 未启用");
}
if (!ingestTokenService.validate(deviceNo, token)) {
return R.fail("推流凭证无效");
}
if (body == null || body.length < 2) {
return R.fail("空帧");
}
if (body.length > properties.getMaxFrameBytes()) {
return R.fail("帧过大");
}
if (body[0] != JPEG_SOI[0] || body[1] != JPEG_SOI[1]) {
return R.fail("不是 JPEG");
}
if (!hub.publish(deviceNo, token, body)) {
return R.fail("推流会话已结束");
}
ingestTokenService.touch(deviceNo);
return R.ok();
}
/**
* 最新一帧,便于浏览器直接打开验证。
*
* @param deviceNo 设备编号
* @param token 播放 token
* @param response HTTP 响应
*/
@GetMapping(value = "/play/{deviceNo}/snapshot", produces = MediaType.IMAGE_JPEG_VALUE)
public void snapshot(@PathVariable String deviceNo,
@RequestParam String token,
HttpServletRequest request,
HttpServletResponse response) throws Exception {
String clientIp = ServletUtils.getClientIP(request);
if (!allowPlay(deviceNo, token, clientIp)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
byte[] frame = hub.latest(deviceNo);
if (frame == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
response.setContentType(MediaType.IMAGE_JPEG_VALUE);
response.setContentLength(frame.length);
response.getOutputStream().write(frame);
}
/**
* multipart MJPEG 播放流,APP / {@code <img>} 可持续拉。
*
* @param deviceNo 设备编号
* @param token 播放 token
* @param response HTTP 响应
*/
@GetMapping("/play/{deviceNo}")
public void play(@PathVariable String deviceNo,
@RequestParam String token,
HttpServletRequest request,
HttpServletResponse response) throws Exception {
String clientIp = ServletUtils.getClientIP(request);
if (!allowPlay(deviceNo, token, clientIp)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
long intervalMs = Math.max(40L, 1000L / Math.max(1, properties.getFps()));
MjpegFrameHub.ViewerLease lease = hub.addViewer(deviceNo);
if (!hub.active(lease)) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("multipart/x-mixed-replace;boundary=" + BOUNDARY);
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response.setHeader("Pragma", "no-cache");
response.setHeader("Connection", "close");
try (OutputStream out = response.getOutputStream()) {
long nextTokenCheckAt = System.currentTimeMillis() + TOKEN_RECHECK_INTERVAL_MS;
while (!Thread.currentThread().isInterrupted() && hub.active(lease)) {
// 长连接定期复核 token,确保撤销或过期后及时释放 Jetty 工作线程。
long now = System.currentTimeMillis();
if (now >= nextTokenCheckAt) {
if (!allowPlay(deviceNo, token, clientIp)) {
break;
}
nextTokenCheckAt = now + TOKEN_RECHECK_INTERVAL_MS;
}
byte[] frame = hub.latest(lease);
if (frame != null) {
String header = "\r\n--" + BOUNDARY + "\r\n"
+ "Content-Type: image/jpeg\r\n"
+ "Content-Length: " + frame.length + "\r\n\r\n";
out.write(header.getBytes(StandardCharsets.US_ASCII));
out.write(frame);
out.flush();
}
Thread.sleep(intervalMs);
}
} catch (InterruptedException e) {
// 保留中断标记,便于容器在线程回收时正确识别连接已结束。
Thread.currentThread().interrupt();
} catch (Exception e) {
log.debug("[MJPEG] 播放连接结束 deviceNo={}", deviceNo);
} finally {
hub.removeViewer(lease);
}
}
private boolean allowPlay(String deviceNo, String token, String clientIp) {
if (!properties.isEnabled() || StringUtils.isBlank(token)) {
return false;
}
return playAuthService.validate(token, deviceNo, clientIp);
}
}

View File

@@ -0,0 +1,86 @@
package org.dromara.device.mqtt;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.device.config.DeviceMqttProperties;
import org.dromara.device.constant.DeviceMqttConstants;
import org.dromara.device.mqtt.support.DeviceAlertWriter;
import org.redisson.api.RLock;
import org.springframework.stereotype.Component;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
/**
* APP 告警消息 outbox 补偿任务。
* <p>
* 项目的 Spring Scheduling 受 SnailJob 开关控制,因此沿用设备离线扫描的显式调度方式;
* 多实例通过 Redis 锁互斥,消息表的 {@code alert_id} 唯一键再做最终幂等兜底。
*
* @author Kou
* @date 2026-09-18
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AppMessageRetryMonitor {
private final DeviceMqttProperties properties;
private final DeviceAlertWriter alertWriter;
private final ScheduledExecutorService scheduledExecutorService;
private volatile ScheduledFuture<?> retryFuture;
/** 启动补偿调度,首轮延迟一个周期,避免与应用启动抢占数据库连接。 */
@PostConstruct
public void start() {
long intervalSeconds = Math.max(1L, properties.getAppMessageRetryInterval().toSeconds());
retryFuture = scheduledExecutorService.scheduleWithFixedDelay(
this::retrySafely, intervalSeconds, intervalSeconds, TimeUnit.SECONDS);
log.info("APP 告警消息补偿已启动 => 间隔: {}s, 单轮上限: {}",
intervalSeconds, properties.getAppMessageRetryBatchSize());
}
/** 停止补偿调度。 */
@PreDestroy
public void stop() {
ScheduledFuture<?> future = retryFuture;
if (future != null) {
future.cancel(false);
}
}
/**
* 捕获单轮所有异常,避免 ScheduledExecutorService 因一次失败永久取消后续任务。
*/
private void retrySafely() {
RLock lock = null;
boolean locked = false;
try {
// 取得客户端和锁对象也可能因 Redis 短暂不可用而失败,必须一并纳入本轮异常兜底。
lock = RedisUtils.getClient().getLock(DeviceMqttConstants.APP_MESSAGE_RETRY_LOCK);
locked = lock.tryLock(0, TimeUnit.SECONDS);
if (!locked) {
return;
}
alertWriter.retryPendingMessages(properties.getAppMessageRetryBatchSize());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
log.error("APP 告警消息补偿异常,本轮跳过", e);
} finally {
if (locked && lock != null) {
try {
lock.unlock();
} catch (Exception e) {
// Redis 短暂不可用时不能让异常逃出,否则定时线程会永久停止后续补偿。
log.warn("APP 告警消息补偿锁释放失败,将依赖锁租期自动释放", e);
}
}
}
}
}

View File

@@ -125,14 +125,43 @@ public class DeviceCommandPublisher {
}
String json = JsonUtils.toJsonString(payload);
boolean ok = client.publish(topic, json.getBytes(StandardCharsets.UTF_8), MqttQoS.QOS1);
String safePayload = payloadForLog(payload, json);
if (ok) {
log.info("MQTT 下行 => identity: {}, topic: {}, msgId: {}, payload: {}",
identity, topic, msgId, json);
identity, topic, msgId, safePayload);
} else {
log.error("MQTT 下行失败 => identity: {}, topic: {}, msgId: {}, payload: {}",
identity, topic, msgId, json);
identity, topic, msgId, safePayload);
}
return ok;
}
/**
* 流指令的推流地址可能携带临时令牌,日志仅保留地址主体并隐藏查询参数。
*/
private String payloadForLog(Object payload, String json) {
if (!(payload instanceof StreamCommandPayload streamPayload)) {
return json;
}
StreamCommandPayload safePayload = new StreamCommandPayload();
safePayload.setMsgId(streamPayload.getMsgId());
safePayload.setCmd(streamPayload.getCmd());
safePayload.setAction(streamPayload.getAction());
safePayload.setStreamId(streamPayload.getStreamId());
safePayload.setApp(streamPayload.getApp());
safePayload.setProtocol(streamPayload.getProtocol());
safePayload.setPushUrl(redactQuery(streamPayload.getPushUrl()));
safePayload.setTs(streamPayload.getTs());
return JsonUtils.toJsonString(safePayload);
}
private String redactQuery(String url) {
if (url == null) {
return null;
}
int queryIndex = url.indexOf('?');
return queryIndex < 0 ? url : url.substring(0, queryIndex) + "?***";
}
}

View File

@@ -109,9 +109,11 @@ public class DefaultDeviceUplinkHandler implements DeviceUplinkHandler {
telemetryArchiver.archive(device, payload, receivedAt);
// 低电判定用「该设备自己配的阈值」,头套与脚环各自独立
alertWriter.applyLowBattery(device.getDeviceId(), deviceNo,
AlertTypeEnum.LOW_BATTERY_HOOD, payload.getHoodBattery(), device.getHoodBatteryThreshold());
AlertTypeEnum.LOW_BATTERY_HOOD, payload.getHoodBattery(), device.getHoodBatteryThreshold(),
device.getBindUserId());
alertWriter.applyLowBattery(device.getDeviceId(), deviceNo,
AlertTypeEnum.LOW_BATTERY_RING, payload.getRingBattery(), device.getRingBatteryThreshold());
AlertTypeEnum.LOW_BATTERY_RING, payload.getRingBattery(), device.getRingBatteryThreshold(),
device.getBindUserId());
} catch (Exception e) {
log.error("遥测处理异常 => deviceNo: {}", deviceNo, e);
}
@@ -142,9 +144,9 @@ public class DefaultDeviceUplinkHandler implements DeviceUplinkHandler {
case OFFLINE -> presenceWriter.markOffline(device);
case OTA_PROGRESS -> updateOtaProgress(device, payload);
case LOW_BATTERY_HOOD -> alertWriter.writeLowBatteryHint(device.getDeviceId(), deviceNo,
AlertTypeEnum.LOW_BATTERY_HOOD, payload.getValue());
AlertTypeEnum.LOW_BATTERY_HOOD, payload.getValue(), device.getBindUserId());
case LOW_BATTERY_RING -> alertWriter.writeLowBatteryHint(device.getDeviceId(), deviceNo,
AlertTypeEnum.LOW_BATTERY_RING, payload.getValue());
AlertTypeEnum.LOW_BATTERY_RING, payload.getValue(), device.getBindUserId());
}
} catch (Exception e) {
log.error("事件处理异常 => deviceNo: {}, eventType: {}", deviceNo, payload.getEventType(), e);

View File

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

View File

@@ -1,15 +1,19 @@
package org.dromara.device.mqtt.support;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.device.config.DeviceMqttProperties;
import org.dromara.device.constant.DeviceMqttConstants;
import org.dromara.device.constant.DeviceMsgConstants;
import org.dromara.device.domain.BmAppMessage;
import org.dromara.device.domain.BmDeviceAlert;
import org.dromara.device.enums.AlertLevelEnum;
import org.dromara.device.enums.AlertTypeEnum;
import org.dromara.device.mapper.BmAppMessageMapper;
import org.dromara.device.mapper.BmDeviceAlertMapper;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Component;
import java.time.Duration;
@@ -34,6 +38,12 @@ import java.time.LocalDateTime;
* <p>
* 与离线告警的区别:离线是「状态迁移」,由 {@code DevicePresenceWriter#markOffline} 的条件更新
* 保证一次迁移只触发一次,因此<b>不走去重窗口</b>——否则设备 30 分钟内掉线两次,第二次会被误吞。
* <p>
* <b>本类同时是 APP 消息的唯一写入入口</b>:告警落库时一并保存设备绑定用户快照,
* 若快照非空,则额外写一条 {@link BmAppMessage} 通知副本给该用户
* (开发计划 D8 修订:一期 APP 提供消息接口,仅供设备告警使用)。
* 告警行同时承担 outbox:消息写入失败不回滚告警,由后台补偿任务按收件人快照重试,
* {@code bm_app_message.alert_id} 唯一键保证多实例与重复重试幂等。
*
* @author Kou
* @date 2026-09-16
@@ -44,6 +54,7 @@ import java.time.LocalDateTime;
public class DeviceAlertWriter {
private final BmDeviceAlertMapper alertMapper;
private final BmAppMessageMapper messageMapper;
private final DeviceMqttProperties properties;
/**
@@ -56,9 +67,9 @@ public class DeviceAlertWriter {
* @param deviceNo 设备编号
* @return 是否成功落库
*/
public boolean writeOffline(Long deviceId, String deviceNo) {
public boolean writeOffline(Long deviceId, String deviceNo, Long messageUserId) {
return insert(deviceId, deviceNo, AlertTypeEnum.OFFLINE, AlertLevelEnum.CRITICAL,
null, DeviceMsgConstants.DEVICE_OFFLINE);
null, DeviceMsgConstants.DEVICE_OFFLINE, messageUserId);
}
/**
@@ -83,7 +94,7 @@ public class DeviceAlertWriter {
* @param deviceThreshold 设备上配置的触发阈值({@code null} 表示未配置,走工程默认)
*/
public void applyLowBattery(Long deviceId, String deviceNo, AlertTypeEnum type,
Integer battery, Integer deviceThreshold) {
Integer battery, Integer deviceThreshold, Long messageUserId) {
if (battery == null || !isLowBatteryType(type)) {
return;
}
@@ -102,7 +113,7 @@ public class DeviceAlertWriter {
// 先置闩锁再落库:即使落库被去重窗口挡住,也不会造成「每条遥测都重试落库」
RedisUtils.setCacheObject(latchKey, Boolean.TRUE, properties.getLowBatteryLatchTtl());
writeWithDedup(deviceId, deviceNo, type, AlertLevelEnum.WARN,
String.valueOf(battery), lowBatteryContent(type, battery));
String.valueOf(battery), lowBatteryContent(type, battery), messageUserId);
return;
}
if (battery >= recover && latched) {
@@ -125,13 +136,14 @@ public class DeviceAlertWriter {
* @param battery 设备上报的触发值
* @return 是否成功落库
*/
public boolean writeLowBatteryHint(Long deviceId, String deviceNo, AlertTypeEnum type, Integer battery) {
public boolean writeLowBatteryHint(Long deviceId, String deviceNo, AlertTypeEnum type,
Integer battery, Long messageUserId) {
if (!isLowBatteryType(type)) {
return false;
}
return writeWithDedup(deviceId, deviceNo, type, AlertLevelEnum.WARN,
battery == null ? null : String.valueOf(battery),
battery == null ? type.getLabel() : lowBatteryContent(type, battery));
battery == null ? type.getLabel() : lowBatteryContent(type, battery), messageUserId);
}
/**
@@ -146,14 +158,14 @@ public class DeviceAlertWriter {
* @return 是否成功落库(窗口内被去重或落库失败均返回 {@code false})
*/
private boolean writeWithDedup(Long deviceId, String deviceNo, AlertTypeEnum type,
AlertLevelEnum level, String value, String content) {
AlertLevelEnum level, String value, String content, Long messageUserId) {
String dedupKey = DeviceMqttConstants.ALERT_DEDUP_PREFIX + deviceNo + ":" + type.getType();
Duration window = properties.getAlertDedupWindow();
if (!RedisUtils.setObjectIfAbsent(dedupKey, LocalDateTime.now().toString(), window)) {
log.debug("告警命中去重窗口,已跳过 => deviceNo: {}, type: {}", deviceNo, type.getType());
return false;
}
if (insert(deviceId, deviceNo, type, level, value, content)) {
if (insert(deviceId, deviceNo, type, level, value, content, messageUserId)) {
return true;
}
// 落库失败必须释放占位,否则整个窗口内这条告警再也补不上
@@ -173,7 +185,7 @@ public class DeviceAlertWriter {
* @return 是否成功落库
*/
private boolean insert(Long deviceId, String deviceNo, AlertTypeEnum type,
AlertLevelEnum level, String value, String content) {
AlertLevelEnum level, String value, String content, Long messageUserId) {
try {
BmDeviceAlert alert = new BmDeviceAlert();
alert.setDeviceId(deviceId);
@@ -183,10 +195,13 @@ public class DeviceAlertWriter {
alert.setAlertValue(value);
alert.setContent(content);
alert.setStatus(DeviceMqttConstants.ALERT_STATUS_UNHANDLED);
alert.setMessageUserId(messageUserId);
alert.setAlertTime(LocalDateTime.now());
alertMapper.insert(alert);
log.info("设备告警 => deviceNo: {}, type: {}, level: {}, value: {}",
deviceNo, type.getType(), level.getCode(), value);
// 告警已落库,再补一条面向绑定用户的通知副本(失败不影响上面的结果)
pushAppMessage(alert);
return true;
} catch (RuntimeException e) {
// 告警落库失败不得中断遥测/事件处理链路,只记错误,由监控告警兜底
@@ -195,6 +210,88 @@ public class DeviceAlertWriter {
}
}
/**
* 写一条 APP 消息通知副本(告警 → 发生时的绑定用户快照)。
* <p>
* <b>本方法的普通失败一律吞掉</b>,理由有二:
* <ol>
* <li>告警此时<b>已经落库成功</b>,若让消息异常冒泡,调用方会把整条告警误判为失败并返回
* {@code false},进而触发「释放去重窗口占位」——结果是同一告警在下一轮被重复写入。</li>
* <li>消息是通知副本,失败后由 outbox 补偿任务重试,不影响告警事实本身。</li>
* </ol>
* <p>
* <b>设备未绑定(收件人快照为空)时静默跳过</b>:这是正常业务态
* (设备已建档但尚未被 APP 绑定,或刚被解绑),不是异常,不应产生告警日志噪音。
*
* @param alert 已落库的告警实体({@code alertId} 由 MP 回填)
* @return 是否已经存在或成功写入消息
*/
private boolean pushAppMessage(BmDeviceAlert alert) {
try {
Long userId = alert.getMessageUserId();
if (userId == null) {
return true;
}
BmAppMessage message = new BmAppMessage();
message.setUserId(userId);
message.setDeviceId(alert.getDeviceId());
message.setDeviceNo(alert.getDeviceNo());
message.setAlertId(alert.getAlertId());
message.setAlertType(alert.getAlertType());
message.setAlertLevel(alert.getAlertLevel());
message.setTitle(resolveAlertTitle(alert.getAlertType()));
message.setContent(alert.getContent());
message.setReadFlag(DeviceMqttConstants.MESSAGE_READ_FLAG_UNREAD);
message.setCreateTime(alert.getAlertTime() == null ? LocalDateTime.now() : alert.getAlertTime());
messageMapper.insert(message);
log.info("APP 告警消息已写入 => userId: {}, deviceNo: {}, type: {}",
userId, alert.getDeviceNo(), alert.getAlertType());
return true;
} catch (DuplicateKeyException e) {
// alert_id 唯一键命中代表其他实例或上一轮补偿已经完成,按幂等成功处理。
return true;
} catch (Exception e) {
log.error("APP 告警消息写入失败,等待后台补偿 => alertId: {}, deviceNo: {}, type: {}",
alert.getAlertId(),
alert.getDeviceNo(), alert.getAlertType(), e);
return false;
}
}
/**
* 补偿尚未写入 APP 消息的告警 outbox。
*
* @param batchSize 单轮上限
* @return 本轮成功补偿数
*/
public int retryPendingMessages(int batchSize) {
int safeBatchSize = Math.max(1, batchSize);
Page<BmDeviceAlert> page = alertMapper.selectPendingMessageAlerts(
new Page<>(1, safeBatchSize, false));
int success = 0;
for (BmDeviceAlert alert : page.getRecords()) {
if (pushAppMessage(alert)) {
success++;
}
}
if (!page.getRecords().isEmpty()) {
log.info("APP 告警消息补偿完成 => 待补偿: {}, 成功: {}", page.getRecords().size(), success);
}
return success;
}
/**
* 根据告警编码恢复消息标题;未知编码保留通用标题,避免补偿任务因脏数据中断。
*/
private String resolveAlertTitle(String alertType) {
for (AlertTypeEnum candidate : AlertTypeEnum.values()) {
if (candidate.getType().equals(alertType)) {
return candidate.getLabel();
}
}
return "设备告警";
}
/**
* 判断是否为低电类告警(避免被误用到离线等类型上)。
*

View File

@@ -86,7 +86,8 @@ public class DevicePresenceWriter {
return false;
}
log.info("设备离线 => deviceNo: {}, lastOnlineTime: {}", device.getDeviceNo(), device.getLastOnlineTime());
alertWriter.writeOffline(device.getDeviceId(), device.getDeviceNo());
// 收件人取状态迁移时已读取的设备快照,避免告警落库后换绑导致消息串给新用户。
alertWriter.writeOffline(device.getDeviceId(), device.getDeviceNo(), device.getBindUserId());
return true;
}

View File

@@ -0,0 +1,55 @@
package org.dromara.device.service;
import org.dromara.common.core.domain.PageResult;
import org.dromara.device.domain.bo.AlertBo;
import org.dromara.device.domain.bo.AlertHandleBo;
import org.dromara.device.domain.vo.AlertStatsVo;
import org.dromara.device.domain.vo.AlertVo;
import java.util.List;
/**
* 设备告警服务接口(后台管理端)。
* <p>
* 告警的<b>写入</b>不在本接口内——唯一写入入口是
* {@code org.dromara.device.mqtt.support.DeviceAlertWriter}(含去重窗口与低电滞回闩锁)。
* 本接口只负责后台侧的<b>查询与处置</b>,避免出现第二条写入路径。
*
* @author Kou
* @date 2026-09-18
*/
public interface IAlertService {
/**
* 分页查询告警记录。
*
* @param bo 查询条件(分页参数继承自 {@code PageQuery},随本对象一并传入)
* @return 分页结果
*/
PageResult<AlertVo> queryPageList(AlertBo bo);
/**
* 统计告警总数 / 未处理 / 已处理。
*
* @return 统计结果
*/
AlertStatsVo stats();
/**
* 标记告警为已处理(单条与批量共用)。
* <p>
* <b>幂等</b>:只更新当前仍为「未处理」的记录,已是「已处理」的直接跳过,
* 因此重复点击不会报错,也不会覆盖首次处理人与处理时间。
*
* @param bo 待处理的告警主键集合
*/
void handle(AlertHandleBo bo);
/**
* 删除告警记录(逻辑删除,保留审计痕迹)。
*
* @param alertIds 告警主键集合
*/
void remove(List<Long> alertIds);
}

View File

@@ -68,14 +68,15 @@ public interface IAppAuthService {
// ---------------- 邮箱通道(迭代:邮箱注册) ----------------
/**
* 发送邮箱验证码(6 位、场景化 60 秒限流、5 分钟有效)。
* 发送邮箱验证码(6 位、场景化 60 秒限流、5 分钟有效);模拟模式返回验证码,真实发送模式返回 {@code null}。
*
* @param bo 验证码入参(邮箱 + 场景)
* @param bo 邮箱验证码请求
* @return 模拟验证码或 {@code null}
*/
void sendEmailCode(AppEmailCodeBo bo);
String sendEmailCode(AppEmailCodeBo bo);
/**
* 邮箱注册(注册后账号尚无手机号,须按账号规则继续绑定手机号)。
* 邮箱注册(注册后可直接邮箱登录,绑定手机号仅在绑定设备前要求)。
*
* @param bo 注册入参
*/

View File

@@ -0,0 +1,51 @@
package org.dromara.device.service;
import org.dromara.common.core.domain.PageResult;
import org.dromara.device.domain.bo.AppMessageQueryBo;
import org.dromara.device.domain.vo.AppMessageVo;
/**
* APP 消息服务接口。
* <p>
* 全部方法<b>只作用于当前登录 APP 用户本人</b>的消息({@code user_id = LoginHelper.getUserId()}),
* 不接受任何「指定用户」入参——从接口形状上杜绝越权读取他人消息的可能。
* <p>
* 消息的<b>写入</b>不在本接口内,唯一来源是告警落库后的推送副本
* ({@code DeviceAlertWriter} → {@code BmAppMessage})。
*
* @author Kou
* @date 2026-09-18
*/
public interface IAppMessageService {
/**
* 分页查询当前用户的消息(按产生时间倒序)。
*
* @param bo 查询条件(分页参数继承自 {@code PageQuery})
* @return 分页结果
*/
PageResult<AppMessageVo> queryPageList(AppMessageQueryBo bo);
/**
* 统计当前用户的未读消息数(供 APP 消息角标)。
*
* @return 未读数
*/
Long unreadCount();
/**
* 标记单条消息为已读。
* <p>
* 消息不属于当前用户时静默成功(更新 0 行),不抛出「消息不存在」——
* 否则接口会变成一个探测他人消息 ID 是否存在的旁路。
*
* @param messageId 消息主键
*/
void markRead(Long messageId);
/**
* 把当前用户所有未读消息标记为已读。
*/
void markAllRead();
}

View File

@@ -1,6 +1,9 @@
package org.dromara.device.service;
import org.dromara.device.domain.bo.AppOldPhoneCodeBo;
import org.dromara.device.domain.bo.AppOldEmailCodeBo;
import org.dromara.device.domain.bo.AppPasswordBo;
import org.dromara.device.domain.bo.AppEmailBo;
import org.dromara.device.domain.bo.AppPhoneBo;
import org.dromara.device.domain.vo.AppAgreementVo;
import org.dromara.device.domain.vo.AppUserProfileVo;
@@ -28,12 +31,49 @@ public interface IAppUserService {
void updatePassword(AppPasswordBo bo);
/**
* 换绑手机号(新手机号 + 验证码)。
* 向当前账号原手机号发送换绑确认验证码(scene = rebindOld)。
*
* @return mock 模式下返回验证码,真实短信模式返回 {@code null}
*/
String sendOldPhoneCode();
/**
* 校验原手机号验证码,通过后写入换绑核验状态。
*
* @param bo 原号验证码
*/
void verifyOldPhone(AppOldPhoneCodeBo bo);
/**
* 绑定或换绑手机号(新手机号 + 验证码)。
* <p>
* 已有手机号的换绑必须先通过 {@link #verifyOldPhone} 取得核验状态。
*
* @param bo 换绑入参
*/
void rebindPhone(AppPhoneBo bo);
/**
* 向当前账号原邮箱发送换绑确认验证码。
*
* @return mock 模式下返回验证码,真实邮件模式返回 {@code null}
*/
String sendOldEmailCode();
/**
* 校验原邮箱验证码,通过后写入换绑核验状态。
*
* @param bo 原邮箱验证码
*/
void verifyOldEmail(AppOldEmailCodeBo bo);
/**
* 首次绑定或更换邮箱;已有邮箱的换绑必须先通过原邮箱核验。
*
* @param bo 邮箱换绑入参
*/
void rebindEmail(AppEmailBo bo);
/**
* 注销账号:先解绑其下全部设备,再停用账号,最后按 loginId 全端下线。
* <p>

View File

@@ -0,0 +1,86 @@
package org.dromara.device.service;
import org.dromara.common.core.domain.PageResult;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.device.domain.bo.AppVersionBo;
import org.dromara.device.domain.bo.AppVersionCheckBo;
import org.dromara.device.domain.vo.AppVersionLatestVo;
import org.dromara.device.domain.vo.AppVersionVo;
import java.util.Collection;
/**
* APP 客户端版本服务接口。
* <p>
* 后台版本管理(前缀 {@code /device/app-version})5 法 + APP 检查更新 1 法。
* <p>
* 唯一性口径对齐 DB 唯一键 {@code uk_platform_version_code (platform, version_code)}——该索引
* <b>不区分逻辑删除行</b>,因此同平台同构建号即使被逻辑删除也仍占用取值;实现侧对「已存在」与
* 「唯一键冲突」两种情况都归一为 {@code AppVersionMsgConstants.VERSION_CODE_DUPLICATE}。
*
* @author Kou
* @date 2026-09-18
*/
public interface IAppVersionService {
/**
* 查询版本详情。
*
* @param versionId 版本主键
* @return 版本视图对象;不存在返回 {@code null}(对齐项目口径)
*/
AppVersionVo queryById(Long versionId);
/**
* 分页查询版本列表。
*
* @param bo 查询条件(keyword / platform / status / forceUpdate / params 时间区间)
* @param pageQuery 分页参数
* @return 分页结果(默认按构建号倒序)
*/
PageResult<AppVersionVo> queryPageList(AppVersionBo bo, PageQuery pageQuery);
/**
* 登记(新增)版本。
* <p>
* {@code status} 留空时默认「停用」({@code 0},与 DB 默认值同口径;页面表单则默认勾选「启用」);
* 首次置为启用时写入 {@code publish_time}。
*
* @param bo 新增业务对象
* @return 是否新增成功
*/
Boolean insertByBo(AppVersionBo bo);
/**
* 修改版本(含启用 / 停用)。
* <p>
* {@code platform} 不可变更;校验同新增(唯一性与构建号递增均排除自身)。
*
* @param bo 编辑业务对象(含 versionId)
* @return 是否修改成功
*/
Boolean updateByBo(AppVersionBo bo);
/**
* 批量删除版本(逻辑删除)。
* <p>
* 逻辑删除后同平台同构建号<b>不可复用</b>(构建号填错走编辑修正)。
*
* @param ids 版本主键集合
* @return 是否删除成功
*/
Boolean deleteByIds(Collection<Long> ids);
/**
* APP 检查更新(免登录)。
* <p>
* 判定按「构建号」:候选集 = {@code platform + status='1' + del_flag='0'},取 {@code version_code} 最大;
* 上报 {@code >=} 最新 → {@code hasUpdate=false}(<b>不提示降级</b>);上报 {@code <} 最新 → 按该记录
* {@code force_update} 决定是否强制更新。本方法不依赖登录态({@code @SaIgnore} 放行)。
*
* @param bo 检查更新入参(platform / versionCode 必填)
* @return 检查更新响应(三态表达 + 安装包信息;两平台口径一致,storeUrl 有值才下发)
*/
AppVersionLatestVo checkLatest(AppVersionCheckBo bo);
}

View File

@@ -0,0 +1,207 @@
package org.dromara.device.service.impl;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.date.LocalDateTimeUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.PageResult;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.device.constant.DeviceMqttConstants;
import org.dromara.device.domain.BmDevice;
import org.dromara.device.domain.BmDeviceAlert;
import org.dromara.device.domain.bo.AlertBo;
import org.dromara.device.domain.bo.AlertHandleBo;
import org.dromara.device.domain.vo.AlertStatsVo;
import org.dromara.device.domain.vo.AlertVo;
import org.dromara.device.mapper.BmDeviceAlertMapper;
import org.dromara.device.mapper.BmDeviceMapper;
import org.dromara.device.service.IAlertService;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 设备告警服务实现(后台管理端)。
* <p>
* 只读 + 处置,不含写入:告警的唯一写入入口是
* {@code org.dromara.device.mqtt.support.DeviceAlertWriter}。
*
* @author Kou
* @date 2026-09-18
*/
@RequiredArgsConstructor
@Service
public class AlertServiceImpl implements IAlertService {
private final BmDeviceAlertMapper alertMapper;
private final BmDeviceMapper deviceMapper;
@Override
public PageResult<AlertVo> queryPageList(AlertBo bo) {
AlertBo condition = bo == null ? new AlertBo() : bo;
Page<AlertVo> result = alertMapper.selectVoPage(condition.build(), buildQueryWrapper(condition));
fillDeviceName(result.getRecords());
return PageResult.build(result.getRecords(), result.getTotal());
}
@Override
public AlertStatsVo stats() {
AlertStatsVo vo = new AlertStatsVo();
// 必须显式给出实体泛型:Wrappers.lambdaQuery() 在链式调用下无法推断出 BmDeviceAlert,
// 会导致 BmDeviceAlert::getStatus 这类方法引用编译失败。
Long total = alertMapper.selectCount(Wrappers.<BmDeviceAlert>lambdaQuery());
Long unhandled = alertMapper.selectCount(Wrappers.<BmDeviceAlert>lambdaQuery()
.eq(BmDeviceAlert::getStatus, DeviceMqttConstants.ALERT_STATUS_UNHANDLED));
vo.setTotal(total);
vo.setUnhandled(unhandled);
vo.setHandled(total - unhandled);
return vo;
}
@Override
public void handle(AlertHandleBo bo) {
// 条件里带上 status = 未处理,靠「影响行数」天然实现幂等:
// 重复点击时第二次匹配不到任何行,既不会报错,也不会把首次的处理人与处理时间覆盖掉。
LambdaUpdateWrapper<BmDeviceAlert> luw = Wrappers.lambdaUpdate();
luw.in(BmDeviceAlert::getAlertId, bo.getAlertIds())
.eq(BmDeviceAlert::getStatus, DeviceMqttConstants.ALERT_STATUS_UNHANDLED)
.set(BmDeviceAlert::getStatus, DeviceMqttConstants.ALERT_STATUS_HANDLED)
.set(BmDeviceAlert::getHandleBy, LoginHelper.getUserId())
.set(BmDeviceAlert::getHandleTime, LocalDateTime.now());
alertMapper.update(null, luw);
}
@Override
public void remove(List<Long> alertIds) {
if (alertIds == null || alertIds.isEmpty()) {
return;
}
// 走 MP 逻辑删除(@TableLogic),保留审计痕迹:后台可查操作日志定位「谁删了什么」。
alertMapper.delete(Wrappers.<BmDeviceAlert>lambdaQuery()
.in(BmDeviceAlert::getAlertId, alertIds));
}
/**
* 构建告警动态查询条件。
* <p>
* 默认排序为「未处理优先 + 告警时间倒序」:现场处置时最关心的是新产生的未处理告警,
* 而不是历史已处理记录。
*
* @param bo 查询条件
* @return 查询条件包装器
*/
private LambdaQueryWrapper<BmDeviceAlert> buildQueryWrapper(AlertBo bo) {
LambdaQueryWrapper<BmDeviceAlert> lqw = Wrappers.lambdaQuery();
lqw.eq(StringUtils.isNotBlank(bo.getDeviceNo()), BmDeviceAlert::getDeviceNo, bo.getDeviceNo());
lqw.eq(StringUtils.isNotBlank(bo.getAlertType()), BmDeviceAlert::getAlertType, bo.getAlertType());
lqw.eq(StringUtils.isNotBlank(bo.getAlertLevel()), BmDeviceAlert::getAlertLevel, bo.getAlertLevel());
lqw.eq(StringUtils.isNotBlank(bo.getStatus()), BmDeviceAlert::getStatus, bo.getStatus());
if (StringUtils.isNotBlank(bo.getKeyword())) {
String keyword = bo.getKeyword();
lqw.and(w -> w.like(BmDeviceAlert::getDeviceNo, keyword)
.or().like(BmDeviceAlert::getContent, keyword));
}
Map<String, Object> params = bo.getParams() == null ? new HashMap<>() : bo.getParams();
LocalDateTime begin = parseDateTime(params.get("beginTime"), true);
LocalDateTime end = parseDateTime(params.get("endTime"), false);
lqw.ge(begin != null, BmDeviceAlert::getAlertTime, begin);
lqw.le(end != null, BmDeviceAlert::getAlertTime, end);
// alert_id 作为稳定的次级排序,避免同一秒内多条告警跨页时重复或遗漏。
lqw.orderByAsc(BmDeviceAlert::getStatus)
.orderByDesc(BmDeviceAlert::getAlertTime)
.orderByDesc(BmDeviceAlert::getAlertId);
return lqw;
}
/**
* 解析前端日期区间入参({@code params.beginTime} / {@code params.endTime})。
* <p>
* 前端 {@code addDateRange} 可能传「纯日期」({@code yyyy-MM-dd})或「日期时间」两种粒度。
* 纯日期时按区间端点补齐:起始取 {@code 00:00:00}、结束取 {@code 23:59:59},
* 否则「结束日 = 今天」会把当天的告警整体漏掉——这是日期区间筛选最常见的边界 bug。
* <p>
* 解析失败一律返回 {@code null}(该侧区间不生效),不抛异常:筛选条件不合法不应让整个列表打不开。
*
* @param value 原始入参
* @param beginDay 是否为区间起点(决定纯日期时的补齐方向)
* @return 解析后的时间;无法解析时返回 {@code null}
*/
private LocalDateTime parseDateTime(Object value, boolean beginDay) {
if (value == null) {
return null;
}
String text = String.valueOf(value).trim();
if (text.isEmpty()) {
return null;
}
try {
LocalDateTime parsed = LocalDateTimeUtil.of(DateUtil.parse(text));
if (text.length() <= 10) {
return beginDay ? parsed.toLocalDate().atStartOfDay()
: parsed.toLocalDate().atTime(23, 59, 59);
}
return parsed;
} catch (Exception e) {
return null;
}
}
/**
* 批量补齐设备名称。
* <p>
* 告警表只存 {@code device_no} 快照,不含名称;这里按 {@code deviceNo} 一次性查出名称再回填,
* 避免逐行查询(N+1)。设备已被逻辑删除时查不到,{@code deviceName} 保持 {@code null},
* 由前端降级展示设备编号。
*
* @param records 当前页告警记录
*/
private void fillDeviceName(List<AlertVo> records) {
if (records == null || records.isEmpty()) {
return;
}
Set<String> deviceNos = records.stream()
.map(AlertVo::getDeviceNo)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toSet());
if (deviceNos.isEmpty()) {
return;
}
List<BmDevice> devices = deviceMapper.selectList(Wrappers.<BmDevice>lambdaQuery()
.select(BmDevice::getDeviceNo, BmDevice::getName)
.in(BmDevice::getDeviceNo, deviceNos));
Map<String, String> nameMap = toDeviceNameMap(devices);
records.forEach(record -> record.setDeviceName(nameMap.get(record.getDeviceNo())));
}
/**
* 把设备列表转为「设备编号 → 设备名称」映射。
*
* @param devices 设备列表
* @return 名称映射(名称为空时保留空串,交由前端降级展示编号)
*/
private Map<String, String> toDeviceNameMap(Collection<BmDevice> devices) {
Map<String, String> nameMap = new HashMap<>();
if (devices == null) {
return nameMap;
}
for (BmDevice device : devices) {
if (StringUtils.isBlank(device.getDeviceNo())) {
continue;
}
nameMap.put(device.getDeviceNo(), Objects.requireNonNullElse(device.getName(), ""));
}
return nameMap;
}
}

View File

@@ -25,6 +25,7 @@ import org.dromara.device.domain.bo.AppSmsCodeBo;
import org.dromara.device.domain.bo.AppSmsLoginBo;
import org.dromara.device.email.AppEmailService;
import org.dromara.device.service.IAppAuthService;
import org.dromara.device.sms.AppPhoneNumbers;
import org.dromara.device.sms.AppSmsService;
import org.dromara.system.api.AppUserApiService;
import org.dromara.system.api.domain.AppUserDTO;
@@ -66,7 +67,10 @@ public class AppAuthServiceImpl implements IAppAuthService {
*/
@Override
public String sendSmsCode(AppSmsCodeBo bo) {
return appSmsService.sendCode(bo.getPhoneNumber(), bo.getScene());
if (AppConstants.SCENE_REBIND_OLD.equals(bo.getScene())) {
throw new ServiceException(AppMsgConstants.SMS_SCENE_INVALID);
}
return appSmsService.sendCode(AppPhoneNumbers.canonical(bo.getPhoneNumber()), bo.getScene());
}
/**
@@ -87,15 +91,16 @@ public class AppAuthServiceImpl implements IAppAuthService {
*/
@Override
public void register(AppRegisterBo bo) {
RLock lock = RedisUtils.getClient().getLock(AppConstants.REGISTER_LOCK_KEY_PREFIX + bo.getPhoneNumber());
String phone = AppPhoneNumbers.canonical(bo.getPhoneNumber());
RLock lock = RedisUtils.getClient().getLock(AppConstants.REGISTER_LOCK_KEY_PREFIX + phone);
lock.lock();
try {
if (appUserApiService.existsByPhone(bo.getPhoneNumber())) {
if (appUserApiService.existsByPhone(phone)) {
throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED);
}
appSmsService.verifyCode(bo.getPhoneNumber(), AppConstants.SCENE_REGISTER, bo.getCode());
appSmsService.verifyCode(phone, AppConstants.SCENE_REGISTER, bo.getCode());
try {
appUserApiService.registerByPhone(bo.getPhoneNumber(), bo.getPassword(), bo.getNickName());
appUserApiService.registerByPhone(phone, bo.getPassword(), bo.getNickName());
} catch (DuplicateKeyException e) {
// DB 唯一键 uk_sys_user_phone_number 冲突:并发同号注册的最终兜底
throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED);
@@ -115,7 +120,7 @@ public class AppAuthServiceImpl implements IAppAuthService {
*/
@Override
public String login(AppLoginBo bo) {
AppUserDTO user = appUserApiService.selectByPhone(bo.getPhoneNumber());
AppUserDTO user = appUserApiService.selectByPhone(AppPhoneNumbers.canonical(bo.getPhoneNumber()));
if (user == null) {
throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
}
@@ -134,12 +139,13 @@ public class AppAuthServiceImpl implements IAppAuthService {
*/
@Override
public String smsLogin(AppSmsLoginBo bo) {
AppUserDTO user = appUserApiService.selectByPhone(bo.getPhoneNumber());
String phone = AppPhoneNumbers.canonical(bo.getPhoneNumber());
AppUserDTO user = appUserApiService.selectByPhone(phone);
if (user == null) {
throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
}
checkAccountEnabled(user);
appSmsService.verifyCode(bo.getPhoneNumber(), AppConstants.SCENE_LOGIN, bo.getCode());
appSmsService.verifyCode(phone, AppConstants.SCENE_LOGIN, bo.getCode());
return doLogin(user);
}
@@ -150,11 +156,12 @@ public class AppAuthServiceImpl implements IAppAuthService {
*/
@Override
public void resetPwd(AppResetPwdBo bo) {
AppUserDTO user = appUserApiService.selectByPhone(bo.getPhoneNumber());
String phone = AppPhoneNumbers.canonical(bo.getPhoneNumber());
AppUserDTO user = appUserApiService.selectByPhone(phone);
if (user == null) {
throw new ServiceException(DeviceMsgConstants.ACCOUNT_NOT_EXIST);
}
appSmsService.verifyCode(bo.getPhoneNumber(), AppConstants.SCENE_RESET_PWD, bo.getCode());
appSmsService.verifyCode(phone, AppConstants.SCENE_RESET_PWD, bo.getCode());
appUserApiService.resetPassword(user.getUserId(), bo.getNewPassword());
}
@@ -176,8 +183,8 @@ public class AppAuthServiceImpl implements IAppAuthService {
* @param bo 验证码入参(邮箱 + 场景)
*/
@Override
public void sendEmailCode(AppEmailCodeBo bo) {
appEmailService.sendCode(bo.getEmail(), bo.getScene());
public String sendEmailCode(AppEmailCodeBo bo) {
return appEmailService.sendCode(bo.getEmail(), bo.getScene());
}
/**
@@ -307,6 +314,7 @@ public class AppAuthServiceImpl implements IAppAuthService {
SaLoginParameter model = new SaLoginParameter();
model.setDeviceType(client.getDeviceType());
// 固定有效期与活跃超时默认均为 30 天,避免无操作 30 分钟导致 APP 提前失效。
model.setTimeout(client.getTimeout());
model.setActiveTimeout(client.getActiveTimeout());
// 写入 token 扩展:clientid 供框架校验「请求头 clientid == 令牌扩展 clientid」(SecurityConfig:98-106);

View File

@@ -117,10 +117,8 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
/**
* 绑定设备(条件更新防并发双绑;成功才写 bind 流水)。
* <p>
* <b>前置硬约束(本迭代新增)</b>:账号须已绑定手机号。邮箱注册的账号在建号时
* {@code phone_number} 为空,按账号规则「注册后仍须绑手机号」,
* 此处<b>服务端拦截</b>而非依赖前端引导——否则「必须绑手机号」只是产品约定,
* 绕过 APP 直接调用接口即可跳过。
* <b>前置硬约束</b>:账号须已绑定手机号。邮箱注册账号可以先登录和使用其他 APP 功能,
* 但在绑定设备时由服务端拦截未绑定手机号的请求,避免绕过前端直接调用接口。
*
* @param bo 绑定入参(imei + name)
* @param userId 当前用户主键

View File

@@ -0,0 +1,120 @@
package org.dromara.device.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.PageResult;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.device.constant.DeviceMqttConstants;
import org.dromara.device.domain.BmAppMessage;
import org.dromara.device.domain.bo.AppMessageQueryBo;
import org.dromara.device.domain.vo.AppMessageVo;
import org.dromara.device.mapper.BmAppMessageMapper;
import org.dromara.device.service.IAppMessageService;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
/**
* APP 消息服务实现。
*
* @author Kou
* @date 2026-09-18
*/
@RequiredArgsConstructor
@Service
public class AppMessageServiceImpl implements IAppMessageService {
/**
* APP 端未传 pageSize 时的兜底页大小。
* <p>
* 框架 {@code PageQuery#DEFAULT_PAGE_SIZE} 是 {@code Integer.MAX_VALUE}(语义为「查全部」,
* 实际被 {@code MybatisPlusConfig} 的 {@code maxLimit=1000} 截断)。这对后台表格没问题,
* 但消息是**持续累积**的数据,APP 首屏一次拉 1000 条既浪费流量也拖慢渲染,
* 因此这里显式兜底为 20 条,由 APP 下拉加载更多。
*/
private static final long DEFAULT_PAGE_SIZE = 20L;
/**
* APP 端单页最大条数,防止客户端传超大 pageSize 拖垮服务端。
*/
private static final long MAX_PAGE_SIZE = 100L;
private final BmAppMessageMapper messageMapper;
@Override
public PageResult<AppMessageVo> queryPageList(AppMessageQueryBo bo) {
AppMessageQueryBo condition = bo == null ? new AppMessageQueryBo() : bo;
Page<AppMessageVo> result = messageMapper.selectVoPage(buildPage(condition), buildQueryWrapper(condition));
return PageResult.build(result.getRecords(), result.getTotal());
}
@Override
public Long unreadCount() {
return messageMapper.selectCount(Wrappers.<BmAppMessage>lambdaQuery()
.eq(BmAppMessage::getUserId, LoginHelper.getUserId())
.eq(BmAppMessage::getReadFlag, DeviceMqttConstants.MESSAGE_READ_FLAG_UNREAD));
}
@Override
public void markRead(Long messageId) {
if (messageId == null) {
return;
}
// user_id 是必带条件:既做越权防护,又天然实现幂等(已是已读时更新 0 行,
// 不会覆盖首次的 read_time)。
LambdaUpdateWrapper<BmAppMessage> luw = Wrappers.lambdaUpdate();
luw.eq(BmAppMessage::getMessageId, messageId)
.eq(BmAppMessage::getUserId, LoginHelper.getUserId())
.eq(BmAppMessage::getReadFlag, DeviceMqttConstants.MESSAGE_READ_FLAG_UNREAD)
.set(BmAppMessage::getReadFlag, DeviceMqttConstants.MESSAGE_READ_FLAG_READ)
.set(BmAppMessage::getReadTime, LocalDateTime.now());
messageMapper.update(null, luw);
}
@Override
public void markAllRead() {
LambdaUpdateWrapper<BmAppMessage> luw = Wrappers.lambdaUpdate();
luw.eq(BmAppMessage::getUserId, LoginHelper.getUserId())
.eq(BmAppMessage::getReadFlag, DeviceMqttConstants.MESSAGE_READ_FLAG_UNREAD)
.set(BmAppMessage::getReadFlag, DeviceMqttConstants.MESSAGE_READ_FLAG_READ)
.set(BmAppMessage::getReadTime, LocalDateTime.now());
messageMapper.update(null, luw);
}
/**
* 构建分页对象(APP 端兜底页大小与上限)。
* <p>
* 泛型必须是<b>实体</b> {@link BmAppMessage} 而非 VO:{@code BaseMapperPlus#selectVoPage}
* 的第一个参数类型为 {@code IPage<实体>},VO 由内部转换器产出。
*
* @param query 前端分页入参
* @return 分页对象
*/
private Page<BmAppMessage> buildPage(AppMessageQueryBo query) {
long current = query.getPageNum() == null || query.getPageNum() < 1 ? 1L : query.getPageNum();
long size = query.getPageSize() == null || query.getPageSize() < 1 ? DEFAULT_PAGE_SIZE : query.getPageSize();
return new Page<>(current, Math.min(size, MAX_PAGE_SIZE));
}
/**
* 构建消息动态查询条件。
* <p>
* {@code user_id} 条件由服务端强制附加,前端无法覆盖;排序用
* {@code create_time + message_id} 双列倒序,避免同一时刻(同秒批量落库)产生跨页乱序。
*
* @param bo 查询条件
* @return 查询条件包装器
*/
private LambdaQueryWrapper<BmAppMessage> buildQueryWrapper(AppMessageQueryBo bo) {
LambdaQueryWrapper<BmAppMessage> lqw = Wrappers.lambdaQuery();
lqw.eq(BmAppMessage::getUserId, LoginHelper.getUserId());
lqw.eq(StringUtils.isNotBlank(bo.getReadFlag()), BmAppMessage::getReadFlag, bo.getReadFlag());
lqw.orderByDesc(BmAppMessage::getCreateTime).orderByDesc(BmAppMessage::getMessageId);
return lqw;
}
}

View File

@@ -1,46 +1,42 @@
package org.dromara.device.service.impl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.device.config.MjpegProperties;
import org.dromara.device.config.ZlmProperties;
import org.dromara.device.constant.AppMsgConstants;
import org.dromara.device.constant.DeviceMsgConstants;
import org.dromara.device.domain.BmDevice;
import org.dromara.device.domain.vo.AppStreamPlayUrlsVo;
import org.dromara.device.domain.vo.AppStreamPlayVo;
import org.dromara.device.domain.vo.AppStreamStatusVo;
import org.dromara.device.enums.DeviceStatusEnum;
import org.dromara.device.mapper.BmDeviceMapper;
import org.dromara.device.mqtt.DeviceCommandPublisher;
import org.dromara.device.mqtt.payload.StreamCommandPayload;
import org.dromara.device.service.IAppStreamService;
import org.dromara.device.zlm.stream.ZlmClient;
import org.dromara.device.stream.MjpegStreamBackend;
import org.dromara.device.stream.StreamBackend;
import org.dromara.device.stream.ZlmStreamBackend;
import org.dromara.device.zlm.stream.ZlmPlayAuthService;
import org.springframework.stereotype.Service;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* APP 直播服务实现。
* <p>
* 流程对齐 dm:MQTT 通知设备推流到 ZLM → 查询 getMediaInfo → 签发带 token 的播放地址。
* 根据配置选择 MJPEG 或 ZLM 后端,统一完成设备推流指令、状态查询与播放地址签发。
* 越权隔离对齐 I3:SQL {@code device_no + bind_user_id},命中 0 行统一「设备不存在」。
*
* @author Kou
* @date 2026-09-17
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AppStreamServiceImpl implements IAppStreamService {
private final ZlmClient zlmClient;
private final ZlmProperties config;
private final MjpegProperties mjpegProperties;
private final MjpegStreamBackend mjpegStreamBackend;
private final ZlmStreamBackend zlmStreamBackend;
private final ZlmPlayAuthService playAuthService;
private final DeviceCommandPublisher commandPublisher;
private final BmDeviceMapper deviceMapper;
@Override
@@ -50,18 +46,7 @@ public class AppStreamServiceImpl implements IAppStreamService {
throw new ServiceException(DeviceMsgConstants.DEVICE_OFFLINE);
}
String streamId = normalizeStreamId(device.getDeviceNo());
if (!zlmClient.isStreamOnline(streamId)) {
StreamCommandPayload payload = StreamCommandPayload.start(
DeviceCommandPublisher.newMsgId(),
streamId,
config.getApp(),
config.getPushProtocol(),
buildPushUrl(streamId));
if (!commandPublisher.publishStream(device.getDeviceNo(), payload)) {
throw new ServiceException(DeviceMsgConstants.COMMAND_SEND_FAILED);
}
log.info("[ZLM] 已通知设备开始推流 deviceNo={} streamId={}", device.getDeviceNo(), streamId);
}
backend().start(device, streamId);
return buildPlayInfo(device.getDeviceNo(), userId);
}
@@ -69,10 +54,7 @@ public class AppStreamServiceImpl implements IAppStreamService {
public AppStreamStatusVo stop(String deviceNo, Long userId) {
BmDevice device = requireOwnedDevice(deviceNo, userId);
String streamId = normalizeStreamId(device.getDeviceNo());
StreamCommandPayload payload = StreamCommandPayload.stop(
DeviceCommandPublisher.newMsgId(), streamId, config.getApp());
commandPublisher.publishStream(device.getDeviceNo(), payload);
zlmClient.closeStreams(streamId);
backend().stop(device, streamId);
return statusOf(device.getDeviceNo(), streamId, false);
}
@@ -84,105 +66,50 @@ public class AppStreamServiceImpl implements IAppStreamService {
@Override
public void close(String deviceNo, Long userId) {
BmDevice device = requireOwnedDevice(deviceNo, userId);
closeZlmStream(device.getDeviceNo());
String streamId = normalizeStreamId(device.getDeviceNo());
backend().close(streamId);
}
@Override
public AppStreamStatusVo status(String deviceNo, Long userId) {
BmDevice device = requireOwnedDevice(deviceNo, userId);
String streamId = normalizeStreamId(device.getDeviceNo());
return statusOf(device.getDeviceNo(), streamId, zlmClient.isStreamOnline(streamId));
return statusOf(device.getDeviceNo(), streamId, backend().online(streamId));
}
@Override
public void closeZlmStream(String streamId) {
streamId = normalizeStreamId(streamId);
zlmClient.delStreamProxy(streamId);
zlmClient.closeStreams(streamId);
// 该入口只处理 ZLM webhook,不能受当前 APP 是否启用 MJPEG 的配置影响。
zlmStreamBackend.close(streamId);
}
private AppStreamPlayVo buildPlayInfo(String deviceNo, Long userId) {
BmDevice device = requireOwnedDevice(deviceNo, userId);
String streamId = normalizeStreamId(device.getDeviceNo());
ZlmPlayAuthService.PlayToken playToken = playAuthService.createToken(streamId);
// 同一次请求固定后端,避免配置热切换时地址与在线状态来自不同协议。
StreamBackend selectedBackend = backend();
StreamBackend.Endpoint endpoint = selectedBackend.endpoint(streamId, playToken.token());
AppStreamPlayVo vo = new AppStreamPlayVo();
vo.setDeviceNo(device.getDeviceNo());
vo.setStreamId(streamId);
vo.setApp(config.getApp());
vo.setOnline(zlmClient.isStreamOnline(streamId));
vo.setPushUrl(buildPushUrl(streamId));
vo.setOnline(selectedBackend.online(streamId));
vo.setPushUrl(endpoint.pushUrl());
vo.setPlayToken(playToken.token());
vo.setExpireAt(playToken.expireAt());
vo.setExpireSeconds(config.getPlayExpireSeconds());
vo.setPlayUrls(buildPlayUrls(streamId, playToken.token()));
vo.setPlayUrls(endpoint.playUrls());
return vo;
}
private AppStreamPlayUrlsVo buildPlayUrls(String streamId, String token) {
String base = trimTrailingSlash(config.getHost()) + "/" + config.getApp() + "/" + streamId;
AppStreamPlayUrlsVo urls = new AppStreamPlayUrlsVo();
urls.setFlv(appendToken(base + ".live.flv", token));
urls.setHls(appendToken(base + "/hls.m3u8", token));
urls.setRtmp(appendToken(buildRtmpUrl(streamId), token));
urls.setRtsp(appendToken(buildRtspUrl(streamId), token));
urls.setWebrtc(appendToken(buildWebrtcUrl(streamId), token));
return urls;
}
private String buildPushUrl(String streamId) {
if ("rtsp".equalsIgnoreCase(config.getPushProtocol())) {
return buildRtspUrl(streamId);
}
return buildRtmpUrl(streamId);
}
private String buildRtmpUrl(String streamId) {
return toProtocol(resolveProtocolHost(config.getRtmpHost(), config.getHost(), "1935"), "rtmp")
+ "/" + config.getApp() + "/" + streamId;
}
private String buildRtspUrl(String streamId) {
return toProtocol(resolveProtocolHost(config.getRtspHost(), config.getHost(), "554"), "rtsp")
+ "/" + config.getApp() + "/" + streamId;
}
private String buildWebrtcUrl(String streamId) {
return toProtocol(resolveProtocolHost(config.getWebrtcHost(), config.getHost(), null), "webrtc")
+ "/" + config.getApp() + "/" + streamId;
}
private String toProtocol(String host, String protocol) {
String url = trimTrailingSlash(host);
int index = url.indexOf("://");
return index > -1 ? protocol + url.substring(index) : protocol + "://" + url;
}
private String appendToken(String url, String token) {
String separator = url.contains("?") ? "&" : "?";
return url + separator + "token=" + URLEncoder.encode(token, StandardCharsets.UTF_8);
}
private String trimTrailingSlash(String value) {
if (value == null || value.isBlank()) {
throw new ServiceException(DeviceMsgConstants.ZLM_NOT_CONFIGURED);
}
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
}
private String resolveProtocolHost(String configuredHost, String fallbackHost, String defaultPort) {
if (configuredHost != null && !configuredHost.isBlank()) {
return configuredHost;
}
String host = trimTrailingSlash(fallbackHost);
int protocolIndex = host.indexOf("://");
String protocol = protocolIndex > -1 ? host.substring(0, protocolIndex + 3) : "";
String rest = protocolIndex > -1 ? host.substring(protocolIndex + 3) : host;
int slashIndex = rest.indexOf('/');
String authority = slashIndex > -1 ? rest.substring(0, slashIndex) : rest;
int colonIndex = authority.lastIndexOf(':');
String hostname = colonIndex > -1 ? authority.substring(0, colonIndex) : authority;
return defaultPort == null ? protocol + authority : protocol + hostname + ":" + defaultPort;
/**
* 根据当前配置选择唯一的直播后端,调用方不再散落协议分支。
*/
private StreamBackend backend() {
return mjpegProperties.isEnabled() ? mjpegStreamBackend : zlmStreamBackend;
}
private String normalizeStreamId(String deviceNo) {

View File

@@ -7,23 +7,32 @@ import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.DesensitizedUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.device.config.AppProperties;
import org.dromara.device.constant.AppConstants;
import org.dromara.device.constant.AppMsgConstants;
import org.dromara.device.constant.DeviceMsgConstants;
import org.dromara.device.domain.bo.AppOldPhoneCodeBo;
import org.dromara.device.domain.bo.AppOldEmailCodeBo;
import org.dromara.device.domain.bo.AppEmailBo;
import org.dromara.device.domain.bo.AppPasswordBo;
import org.dromara.device.domain.bo.AppPhoneBo;
import org.dromara.device.domain.vo.AppAgreementVo;
import org.dromara.device.domain.vo.AppUserProfileVo;
import org.dromara.device.service.IAppUserService;
import org.dromara.device.service.IDeviceBindService;
import org.dromara.device.sms.AppPhoneNumbers;
import org.dromara.device.sms.AppSmsService;
import org.dromara.device.email.AppEmailService;
import org.dromara.system.api.AppUserApiService;
import org.dromara.system.api.UserService;
import org.dromara.system.api.domain.UserDTO;
import org.redisson.api.RLock;
import org.springframework.stereotype.Service;
import java.time.Duration;
/**
* APP 个人中心服务实现。
* <p>
@@ -51,6 +60,7 @@ public class AppUserServiceImpl implements IAppUserService {
private final UserService userService;
private final IDeviceBindService deviceBindService;
private final AppSmsService appSmsService;
private final AppEmailService appEmailService;
private final AppProperties appProperties;
/**
@@ -70,8 +80,7 @@ public class AppUserServiceImpl implements IAppUserService {
vo.setNickName(user.getNickName());
vo.setPhoneNumber(DesensitizedUtils.mobilePhone(user.getPhoneNumber()));
vo.setEmail(DesensitizedUtils.email(user.getEmail()));
// 账号规则:邮箱注册的账号注册后仍须绑定手机号;未绑定时 APP 需据此强制引导,
// 且服务端已禁止未绑手机号的账号绑定设备(见 IAppDeviceService#bind)。
// 邮箱注册账号可以先登录和使用个人中心;未绑定手机号时仅禁止绑定设备,绑定设备前再引导补绑。
vo.setPhoneBound(StringUtils.isNotBlank(user.getPhoneNumber()));
vo.setUserType(user.getUserType());
vo.setCreateTime(user.getCreateTime());
@@ -95,38 +104,163 @@ public class AppUserServiceImpl implements IAppUserService {
appUserApiService.resetPassword(userId, bo.getNewPassword());
}
/**
* 向当前账号原手机号发送换绑确认验证码。
*
* @return mock 模式下返回验证码,真实短信模式返回 {@code null}
*/
@Override
public String sendOldPhoneCode() {
UserDTO current = requireCurrentUser();
return appSmsService.sendCode(requireBoundPhone(current), AppConstants.SCENE_REBIND_OLD);
}
/**
* 校验原手机号验证码,通过后写入 Redis 核验状态(TTL 与短信有效期一致)。
*
* @param bo 原号验证码
*/
@Override
public void verifyOldPhone(AppOldPhoneCodeBo bo) {
Long userId = currentUserId();
RLock lock = RedisUtils.getClient().getLock(AppConstants.PHONE_REBIND_LOCK_KEY_PREFIX + userId);
lock.lock();
try {
// 与换绑共用用户锁,避免核验状态在换绑成功删除后又被并发写回。
UserDTO current = requireCurrentUser();
appSmsService.verifyCode(requireBoundPhone(current), AppConstants.SCENE_REBIND_OLD, bo.getOldCode());
RedisUtils.setCacheObject(AppConstants.REBIND_OLD_OK_KEY_PREFIX + userId, Boolean.TRUE,
Duration.ofMinutes(appProperties.getSms().getExpiryMinutes()));
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 绑定 / 换绑手机号:先拦截「新号==当前号」,再校验新号未被占用,再校验新号收到的验证码,最后落库。
* <p>
* <b>场景自适应(本迭代新增)</b>:按当前账号是否已有手机号选择验证码场景——
* 已有手机号的换绑必须先通过 {@link #verifyOldPhone} 取得核验状态;首次绑定不查该状态。
* <p>
* <b>场景自适应</b>:按当前账号是否已有手机号选择验证码场景——
* <ul>
* <li>当前无手机号(邮箱注册的账号)→ 场景 {@link AppConstants#SCENE_BIND}「绑定手机」;</li>
* <li>当前已有手机号 → 场景 {@link AppConstants#SCENE_REBIND}「换绑手机」(原有行为不变)。</li>
* <li>当前已有手机号 → 场景 {@link AppConstants#SCENE_REBIND}「换绑手机」;</li>
* </ul>
* 两个场景的验证码缓存 key 互相隔离,故 APP 必须按账号当前状态请求对应场景的验证码,
* 用错场景会得到「验证码已过期」。这样划分是为了让「首次绑定」与「更换手机号」在审计与文案上可区分。
*
* @param bo 换绑入参
*/
@Override
public void rebindPhone(AppPhoneBo bo) {
Long userId = currentUserId();
UserDTO current = userService.selectById(userId);
if (current == null) {
throw new ServiceException(AppMsgConstants.NOT_LOGIN);
RLock lock = RedisUtils.getClient().getLock(AppConstants.PHONE_REBIND_LOCK_KEY_PREFIX + userId);
lock.lock();
try {
// 锁内重新读取账号,保证核验状态检查与手机号更新针对同一份最新账号数据。
UserDTO current = requireCurrentUser();
boolean firstBind = StringUtils.isBlank(current.getPhoneNumber());
String rebindOldVerifiedKey = AppConstants.REBIND_OLD_OK_KEY_PREFIX + userId;
if (!firstBind && RedisUtils.getCacheObject(rebindOldVerifiedKey) == null) {
throw new ServiceException(AppMsgConstants.OLD_PHONE_NOT_VERIFIED);
}
String newPhone = AppPhoneNumbers.canonical(bo.getNewPhoneNumber());
// P3-6:新号与当前号相同时给出精确文案(原先 existsByPhone 命中自身 → 误报「手机号已注册」)
// 首次绑定时当前号为空,不存在「与当前号相同」的情形,故仅在换绑分支判断。
if (!firstBind && StringUtils.equals(current.getPhoneNumber(), newPhone)) {
throw new ServiceException(AppMsgConstants.PHONE_SAME_AS_CURRENT);
}
if (appUserApiService.existsByPhone(newPhone)) {
throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED);
}
appSmsService.verifyCode(newPhone,
firstBind ? AppConstants.SCENE_BIND : AppConstants.SCENE_REBIND, bo.getCode());
// 换绑只更新手机号,昵称保持用户原值,不重新生成默认昵称。
appUserApiService.updatePhone(userId, newPhone);
if (!firstBind) {
// 与上面的状态检查处于同一用户锁内,确保一次核验只允许一次换绑成功。
RedisUtils.deleteObject(rebindOldVerifiedKey);
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
boolean firstBind = StringUtils.isBlank(current.getPhoneNumber());
// P3-6:新号与当前号相同时给出精确文案(原先 existsByPhone 命中自身 → 误报「手机号已注册」)
// 首次绑定时当前号为空,不存在「与当前号相同」的情形,故仅在换绑分支判断。
if (!firstBind && StringUtils.equals(current.getPhoneNumber(), bo.getNewPhoneNumber())) {
throw new ServiceException(AppMsgConstants.PHONE_SAME_AS_CURRENT);
}
/**
* 向当前账号原邮箱发送换绑确认验证码。
*
* @return mock 模式下返回验证码,真实邮件模式返回 {@code null}
*/
@Override
public String sendOldEmailCode() {
UserDTO current = requireCurrentUser();
return appEmailService.sendCode(requireBoundEmail(current), AppConstants.SCENE_REBIND_OLD);
}
/**
* 校验原邮箱验证码,通过后写入 Redis 核验状态;状态与邮箱换绑共用同一把用户锁。
*
* @param bo 原邮箱验证码
*/
@Override
public void verifyOldEmail(AppOldEmailCodeBo bo) {
Long userId = currentUserId();
RLock lock = RedisUtils.getClient().getLock(AppConstants.EMAIL_REBIND_LOCK_KEY_PREFIX + userId);
lock.lock();
try {
UserDTO current = requireCurrentUser();
appEmailService.verifyCode(requireBoundEmail(current), AppConstants.SCENE_REBIND_OLD, bo.getOldCode());
RedisUtils.setCacheObject(AppConstants.EMAIL_REBIND_OLD_OK_KEY_PREFIX + userId, Boolean.TRUE,
Duration.ofMinutes(appProperties.getEmail().getExpiryMinutes()));
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
if (appUserApiService.existsByPhone(bo.getNewPhoneNumber())) {
throw new ServiceException(DeviceMsgConstants.PHONE_REGISTERED);
}
/**
* 绑定 / 换绑邮箱:先校验原邮箱核验状态,再校验新邮箱唯一性与验证码,最后更新账号。
* <p>
* 当前账号没有邮箱时视为首次绑定,不要求原邮箱核验,验证码场景使用 bind;已有邮箱时使用 rebind。
*
* @param bo 新邮箱与验证码
*/
@Override
public void rebindEmail(AppEmailBo bo) {
Long userId = currentUserId();
RLock lock = RedisUtils.getClient().getLock(AppConstants.EMAIL_REBIND_LOCK_KEY_PREFIX + userId);
lock.lock();
try {
UserDTO current = requireCurrentUser();
boolean firstBind = StringUtils.isBlank(current.getEmail());
String oldVerifiedKey = AppConstants.EMAIL_REBIND_OLD_OK_KEY_PREFIX + userId;
if (!firstBind && RedisUtils.getCacheObject(oldVerifiedKey) == null) {
throw new ServiceException(AppMsgConstants.OLD_EMAIL_NOT_VERIFIED);
}
String newEmail = appEmailService.normalize(bo.getNewEmail());
if (!firstBind && StringUtils.equals(current.getEmail(), newEmail)) {
throw new ServiceException(AppMsgConstants.EMAIL_SAME_AS_CURRENT);
}
if (appUserApiService.existsByEmail(newEmail)) {
throw new ServiceException(DeviceMsgConstants.EMAIL_REGISTERED);
}
appEmailService.verifyCode(newEmail,
firstBind ? AppConstants.SCENE_BIND : AppConstants.SCENE_REBIND, bo.getCode());
// 换绑只更新邮箱,昵称保持用户原值,不重新生成默认昵称。
if (!appUserApiService.updateEmail(userId, newEmail)) {
throw new ServiceException(AppMsgConstants.NOT_LOGIN);
}
if (!firstBind) {
RedisUtils.deleteObject(oldVerifiedKey);
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
appSmsService.verifyCode(bo.getNewPhoneNumber(),
firstBind ? AppConstants.SCENE_BIND : AppConstants.SCENE_REBIND, bo.getCode());
appUserApiService.updatePhone(userId, bo.getNewPhoneNumber());
}
/**
@@ -142,7 +276,7 @@ public class AppUserServiceImpl implements IAppUserService {
// 3. 清除会话 —— 必须「按 loginId 全端下线」,而非「按当前 token 清」。
// loginId = userType + ":" + userId(见 LoginUser#getLoginId),APP 登录标识恒为 app_user:{userId};
// StpUtil.logout(loginId) 一次调用即注销该账号名下「所有设备/所有会话」的 token。
// 反例:若用 StpUtil.logout()(仅清当前 token),其他设备上仍在有效期(默认 7 天)内的 token 会继续可用,
// 反例:若用 StpUtil.logout()(仅清当前 token),其他设备上仍在有效期(默认 30 天)内的 token 会继续可用,
// 与该账号「已注销/已停用」语义冲突,故采用全端下线。停用(status=停用)另在登录入口兜底拦截再登录。
String loginId = UserType.APP_USER.getUserType() + ":" + userId;
try {
@@ -196,4 +330,26 @@ public class AppUserServiceImpl implements IAppUserService {
return userId;
}
private UserDTO requireCurrentUser() {
UserDTO current = userService.selectById(currentUserId());
if (current == null) {
throw new ServiceException(AppMsgConstants.NOT_LOGIN);
}
return current;
}
private String requireBoundPhone(UserDTO current) {
if (StringUtils.isBlank(current.getPhoneNumber())) {
throw new ServiceException(AppMsgConstants.PHONE_NOT_BOUND);
}
return current.getPhoneNumber();
}
private String requireBoundEmail(UserDTO current) {
if (StringUtils.isBlank(current.getEmail())) {
throw new ServiceException(AppMsgConstants.EMAIL_NOT_BOUND);
}
return appEmailService.normalize(current.getEmail());
}
}

View File

@@ -0,0 +1,394 @@
package org.dromara.device.service.impl;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.date.LocalDateTimeUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.domain.PageResult;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.MapstructUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.device.constant.AppVersionMsgConstants;
import org.dromara.device.domain.BmAppVersion;
import org.dromara.device.domain.bo.AppVersionBo;
import org.dromara.device.domain.bo.AppVersionCheckBo;
import org.dromara.device.domain.vo.AppVersionLatestVo;
import org.dromara.device.domain.vo.AppVersionVo;
import org.dromara.device.enums.AppPlatformEnum;
import org.dromara.device.enums.AppVersionStatusEnum;
import org.dromara.device.mapper.BmAppVersionMapper;
import org.dromara.device.service.IAppVersionService;
import org.dromara.system.api.OssService;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* APP 客户端版本服务实现(见 {@link IAppVersionService} 的契约说明)。
* <p>
* 写侧核心是「唯一性 + 递增 + 必填校验」三点;读侧(APP 检查更新)核心是「整数构建号比较」。
* 唯一性采用「预检给出友好文案 + DB 唯一键兜底」双层(对齐 {@code OtaPackageServiceImpl} 的既有模式):
* 预检拦下常规场景,唯一键兜住预检与写入之间的并发窗口以及已被逻辑删除的历史行。
*
* @author Kou
* @date 2026-09-18
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AppVersionServiceImpl implements IAppVersionService {
/** 私有 OSS 安装包地址有效期,覆盖用户停留在升级弹窗后的下载场景。 */
private static final Duration APP_PACKAGE_URL_VALIDITY = Duration.ofHours(24);
private final BmAppVersionMapper appVersionMapper;
private final OssService ossService;
/**
* 查询版本详情。
*
* @param versionId 版本主键
* @return 版本视图对象;不存在返回 {@code null}
*/
@Override
public AppVersionVo queryById(Long versionId) {
return appVersionMapper.selectVoById(versionId);
}
/**
* 分页查询版本列表。
*
* @param bo 查询条件
* @param pageQuery 分页参数
* @return 分页结果
*/
@Override
public PageResult<AppVersionVo> queryPageList(AppVersionBo bo, PageQuery pageQuery) {
AppVersionBo condition = bo == null ? new AppVersionBo() : bo;
LambdaQueryWrapper<BmAppVersion> lqw = buildQueryWrapper(condition);
Page<AppVersionVo> result = appVersionMapper.selectVoPage(pageQuery.build(), lqw);
return PageResult.build(result.getRecords(), result.getTotal());
}
/**
* 登记(新增)版本。
*
* @param bo 新增业务对象
* @return 是否新增成功
*/
@Override
public Boolean insertByBo(AppVersionBo bo) {
BmAppVersion add = MapstructUtils.convert(bo, BmAppVersion.class);
// status 缺省兜底为「停用」(与 DB DEFAULT '0' 同口径;页面表单默认勾选「启用」)
if (StringUtils.isBlank(add.getStatus())) {
add.setStatus(AppVersionStatusEnum.DISABLED.getCode());
}
// force_update 缺省兜底为「否」(列 NOT NULL DEFAULT '0')
if (StringUtils.isBlank(add.getForceUpdate())) {
add.setForceUpdate(AppVersionMsgConstants.FORCE_UPDATE_NO);
}
validateAndNormalize(add, true);
// 首次置为启用时写入发布时间
if (AppVersionStatusEnum.ENABLED.getCode().equals(add.getStatus())) {
add.setPublishTime(LocalDateTime.now());
}
try {
boolean flag = appVersionMapper.insert(add) > 0;
if (flag) {
bo.setVersionId(add.getVersionId());
}
return flag;
} catch (DuplicateKeyException e) {
throw duplicateException(e);
}
}
/**
* 修改版本(含启用 / 停用)。
*
* @param bo 编辑业务对象(含 versionId)
* @return 是否修改成功
*/
@Override
public Boolean updateByBo(AppVersionBo bo) {
if (bo == null || bo.getVersionId() == null) {
throw new ServiceException(AppVersionMsgConstants.RECORD_NOT_EXIST);
}
BmAppVersion existing = appVersionMapper.selectById(bo.getVersionId());
if (existing == null) {
throw new ServiceException(AppVersionMsgConstants.RECORD_NOT_EXIST);
}
// 平台不可改:请求带了 platform 且与库中不一致则拒绝
if (StringUtils.isNotBlank(bo.getPlatform()) && !bo.getPlatform().equals(existing.getPlatform())) {
throw new ServiceException(AppVersionMsgConstants.PLATFORM_IMMUTABLE);
}
BmAppVersion update = MapstructUtils.convert(bo, BmAppVersion.class);
// 平台以库中为准(不可变);即便请求未带 platform 也能通过后续分支校验
update.setPlatform(existing.getPlatform());
if (StringUtils.isBlank(update.getForceUpdate())) {
update.setForceUpdate(existing.getForceUpdate());
}
if (StringUtils.isBlank(update.getStatus())) {
update.setStatus(existing.getStatus());
}
// 普通字段编辑和启停不应重新触发“构建号必须大于最大值”;只有实际改号时才校验历史高水位。
boolean versionCodeChanged = !Objects.equals(existing.getVersionCode(), update.getVersionCode());
validateAndNormalize(update, versionCodeChanged);
// 首次置为启用(此前从未发布)时补写发布时间
if (AppVersionStatusEnum.ENABLED.getCode().equals(update.getStatus()) && existing.getPublishTime() == null) {
update.setPublishTime(LocalDateTime.now());
}
try {
return appVersionMapper.updateById(update) > 0;
} catch (DuplicateKeyException e) {
throw duplicateException(e);
}
}
/**
* 批量删除版本(逻辑删除)。
*
* @param ids 版本主键集合
* @return 是否删除成功
*/
@Override
public Boolean deleteByIds(Collection<Long> ids) {
if (ids == null || ids.isEmpty()) {
return Boolean.FALSE;
}
return appVersionMapper.deleteByIds(ids) > 0;
}
/**
* APP 检查更新(免登录,不依赖登录态)。
*
* @param bo 检查更新入参
* @return 检查更新响应
*/
@Override
public AppVersionLatestVo checkLatest(AppVersionCheckBo bo) {
String platform = bo == null ? null : bo.getPlatform();
Integer currentCode = bo == null ? null : bo.getVersionCode();
if (!AppPlatformEnum.isValid(platform)) {
throw new ServiceException(AppVersionMsgConstants.PLATFORM_INVALID);
}
if (currentCode == null || currentCode <= 0) {
throw new ServiceException(AppVersionMsgConstants.VERSION_CODE_INVALID);
}
AppVersionLatestVo vo = new AppVersionLatestVo();
vo.setPlatform(platform);
vo.setCurrentVersionCode(currentCode);
// 三态默认:无更新(forceUpdate 恒 false)
vo.setHasUpdate(Boolean.FALSE);
vo.setForceUpdate(Boolean.FALSE);
// 用 MyBatis-Plus 分页取首条,由方言插件生成分页 SQL,避免硬编码 MySQL LIMIT。
Page<BmAppVersion> latestPage = appVersionMapper.selectPage(new Page<>(1, 1, false),
Wrappers.<BmAppVersion>lambdaQuery()
.eq(BmAppVersion::getPlatform, platform)
.eq(BmAppVersion::getStatus, AppVersionStatusEnum.ENABLED.getCode())
.orderByDesc(BmAppVersion::getVersionCode));
BmAppVersion latest = latestPage.getRecords().isEmpty() ? null : latestPage.getRecords().getFirst();
if (latest == null || latest.getVersionCode() == null) {
// 无启用记录
return vo;
}
if (currentCode >= latest.getVersionCode()) {
// 已是最新 / 上报更高构建号:不提示降级
return vo;
}
String downloadUrl = resolveDownloadUrl(latest);
if (StringUtils.isBlank(downloadUrl)) {
// 包记录异常时宁可暂不提示更新,也不能把用户锁在一个无法下载的强更弹窗中。
log.error("APP 最新版本安装包不可用 => versionId: {}, platform: {}, ossId: {}",
latest.getVersionId(), latest.getPlatform(), latest.getOssId());
return vo;
}
vo.setHasUpdate(Boolean.TRUE);
vo.setForceUpdate(AppVersionMsgConstants.FORCE_UPDATE_YES.equals(latest.getForceUpdate()));
vo.setLatestVersionCode(latest.getVersionCode());
vo.setLatestVersionName(latest.getVersionName());
vo.setChangelog(latest.getChangelog());
// 两平台口径一致:都下发安装包信息;storeUrl 有值则下发、无值给 null(不再按平台裁剪)
vo.setFileSize(latest.getFileSize());
vo.setMd5(latest.getMd5());
vo.setDownloadUrl(downloadUrl);
vo.setStoreUrl(latest.getStoreUrl());
return vo;
}
/**
* 构建版本动态查询条件。
* <p>
* 默认按构建号倒序;日期区间经 {@code params.beginTime} / {@code params.endTime}
* 按<b>发布时间</b>过滤(与页面「发布时间」筛选项口径一致)。
*
* @param bo 查询条件
* @return 查询条件包装器
*/
private LambdaQueryWrapper<BmAppVersion> buildQueryWrapper(AppVersionBo bo) {
LambdaQueryWrapper<BmAppVersion> lqw = Wrappers.lambdaQuery();
lqw.eq(StringUtils.isNotBlank(bo.getPlatform()), BmAppVersion::getPlatform, bo.getPlatform());
lqw.eq(StringUtils.isNotBlank(bo.getStatus()), BmAppVersion::getStatus, bo.getStatus());
lqw.eq(StringUtils.isNotBlank(bo.getForceUpdate()), BmAppVersion::getForceUpdate, bo.getForceUpdate());
if (StringUtils.isNotBlank(bo.getKeyword())) {
lqw.like(BmAppVersion::getVersionName, bo.getKeyword());
}
Map<String, Object> params = bo.getParams() == null ? new HashMap<>() : bo.getParams();
LocalDateTime begin = parseDateTime(params.get("beginTime"), true);
LocalDateTime end = parseDateTime(params.get("endTime"), false);
lqw.ge(begin != null, BmAppVersion::getPublishTime, begin);
lqw.le(end != null, BmAppVersion::getPublishTime, end);
lqw.orderByDesc(BmAppVersion::getVersionCode);
return lqw;
}
/**
* 保存前校验 + 归一化(新增与修改共用)。
* <p>
* 校验顺序:平台合法 → 必填(展示版本号 / 构建号 / 安装包)→ 唯一性预检
* (构建号、展示版本号,均排除自身)→ 构建号必须大于同平台最大值(排除自身)。
* 归一化:{@code md5} 本期恒置空(预留不填写);{@code storeUrl} 为选填,填了原样保留。
*
* @param entity 待保存实体(修改时 {@code versionId} 非空,用于排除自身)
* @param validateVersionGreater 是否校验构建号历史高水位
*/
private void validateAndNormalize(BmAppVersion entity, boolean validateVersionGreater) {
// 1. 平台合法
if (!AppPlatformEnum.isValid(entity.getPlatform())) {
throw new ServiceException(AppVersionMsgConstants.PLATFORM_INVALID);
}
// 2. 必填
if (StringUtils.isBlank(entity.getVersionName())) {
throw new ServiceException(AppVersionMsgConstants.VERSION_NAME_REQUIRED);
}
if (entity.getVersionCode() == null || entity.getVersionCode() <= 0) {
throw new ServiceException(AppVersionMsgConstants.VERSION_CODE_INVALID);
}
// 3. 必填(两平台一致:必须上传安装包);storeUrl 为选填,填了就保留、不填保持 null
if (entity.getOssId() == null || StringUtils.isBlank(entity.getFileUrl())) {
throw new ServiceException(AppVersionMsgConstants.PACKAGE_REQUIRED);
}
// md5 本期不采集(框架上传响应无 md5),恒置空,不参与任何校验
entity.setMd5(null);
// 4. 唯一性预检(排除自身)
validUnique(entity);
// 5. 新增或实际修改构建号时,必须大于包含逻辑删除记录在内的历史高水位。
if (validateVersionGreater) {
validVersionCodeGreater(entity);
}
}
/**
* 唯一性预检(平台 + 构建号、平台 + 展示版本号,均排除自身)。
* <p>
* 预检按 MyBatis-Plus 默认(仅 {@code del_flag='0'})查询;对已被逻辑删除的冲突行预检查不到,
* 但 DB 唯一键仍会拒绝,此时由调用侧捕获 {@link DuplicateKeyException} 归一。
*
* @param entity 待保存实体
*/
private void validUnique(BmAppVersion entity) {
Long selfId = entity.getVersionId();
long codeConflict = appVersionMapper.selectCount(Wrappers.<BmAppVersion>lambdaQuery()
.eq(BmAppVersion::getPlatform, entity.getPlatform())
.eq(BmAppVersion::getVersionCode, entity.getVersionCode())
.ne(selfId != null, BmAppVersion::getVersionId, selfId));
if (codeConflict > 0) {
throw new ServiceException(AppVersionMsgConstants.VERSION_CODE_DUPLICATE);
}
long nameConflict = appVersionMapper.selectCount(Wrappers.<BmAppVersion>lambdaQuery()
.eq(BmAppVersion::getPlatform, entity.getPlatform())
.eq(BmAppVersion::getVersionName, entity.getVersionName())
.ne(selfId != null, BmAppVersion::getVersionId, selfId));
if (nameConflict > 0) {
throw new ServiceException(AppVersionMsgConstants.VERSION_NAME_DUPLICATE);
}
}
/**
* 校验构建号必须严格大于该平台已有最大值(含停用记录;修改时排除自身)。
* <p>
* 为什么含停用记录:避免构建号被回收后历史包与将来新包的构建号冲突,导致部分用户永远收不到更新。
*
* @param entity 待保存实体
*/
private void validVersionCodeGreater(BmAppVersion entity) {
Integer historicalMax = appVersionMapper.selectMaxVersionCodeIncludingDeleted(
entity.getPlatform(), entity.getVersionId());
int max = historicalMax == null ? 0 : historicalMax;
if (entity.getVersionCode() <= max) {
throw new ServiceException(String.format(AppVersionMsgConstants.VERSION_CODE_NOT_GREATER, max));
}
}
/**
* 每次检查更新都按 {@code ossId} 重新解析下载地址,私有桶因此不会复用登记时的短期签名。
*/
private String resolveDownloadUrl(BmAppVersion latest) {
if (latest.getOssId() == null) {
return latest.getFileUrl();
}
try {
return ossService.selectDownloadUrlById(latest.getOssId(), APP_PACKAGE_URL_VALIDITY);
} catch (RuntimeException e) {
log.error("解析 APP 安装包下载地址失败 => versionId: {}, ossId: {}",
latest.getVersionId(), latest.getOssId(), e);
return null;
}
}
/**
* 根据唯一键名称把并发写入异常还原成稳定业务文案。
*/
private ServiceException duplicateException(DuplicateKeyException e) {
String message = e.getMostSpecificCause() == null ? e.getMessage() : e.getMostSpecificCause().getMessage();
if (StringUtils.contains(message, "uk_platform_version_name")) {
return new ServiceException(AppVersionMsgConstants.VERSION_NAME_DUPLICATE);
}
return new ServiceException(AppVersionMsgConstants.VERSION_CODE_DUPLICATE);
}
/**
* 解析前端日期区间入参({@code params.beginTime} / {@code params.endTime})。
* <p>
* 纯日期({@code yyyy-MM-dd})时按区间端点补齐:起点取 {@code 00:00:00}、终点取 {@code 23:59:59},
* 否则「结束日 = 今天」会把当天的记录整体漏掉。解析失败返回 {@code null}(该侧区间不生效),不抛异常。
*
* @param value 原始入参
* @param beginDay 是否为区间起点(决定纯日期时的补齐方向)
* @return 解析后的时间;无法解析时返回 {@code null}
*/
private LocalDateTime parseDateTime(Object value, boolean beginDay) {
if (value == null) {
return null;
}
String text = String.valueOf(value).trim();
if (text.isEmpty()) {
return null;
}
try {
LocalDateTime parsed = LocalDateTimeUtil.of(DateUtil.parse(text));
if (text.length() <= 10) {
return beginDay ? parsed.toLocalDate().atStartOfDay()
: parsed.toLocalDate().atTime(23, 59, 59);
}
return parsed;
} catch (Exception e) {
return null;
}
}
}

View File

@@ -0,0 +1,80 @@
package org.dromara.device.sms;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.StringUtils;
/**
* APP 手机号归一化:国内存 11 位,国际存带 {@code +} 的 E.164 号码。
* <p>
* 入参仍是 {@code phoneNumber}:国内可写 {@code 13800138000} 或 {@code +8613800138000};
* 国际必须带 {@code +} 或 {@code 00} 国家码,例如 {@code +85212345678}。
*
* @author Kou
* @date 2026-09-18
*/
public final class AppPhoneNumbers {
private static final String CN_MOBILE = "1[3-9]\\d{9}";
private AppPhoneNumbers() {
}
/**
* 转为账号/缓存用的规范号码。
*
* @param raw 用户输入
* @return 国内 11 位,或带 {@code +} 的国际号码
*/
public static String canonical(String raw) {
String compact = compact(raw);
if (compact.matches(CN_MOBILE)) {
return compact;
}
if (compact.startsWith("+")) {
String digits = compact.substring(1);
if (digits.matches("86" + CN_MOBILE)) {
return digits.substring(2);
}
if (digits.matches("[1-9]\\d{6,14}")) {
return "+" + digits;
}
}
throw new ServiceException(MessageUtils.message("user.mobile.phone.number.not.valid"));
}
/**
* 是否中国大陆手机号。
*
* @param canonical {@link #canonical(String)} 的结果
* @return 国内为 true
*/
public static boolean chinaMainland(String canonical) {
return canonical != null && canonical.matches(CN_MOBILE);
}
/**
* 发给短信通道的号码:国内 11 位;国际去掉 {@code +},对齐阿里云国际短信。
*
* @param canonical {@link #canonical(String)} 的结果
* @return 通道号码
*/
public static String forSms(String canonical) {
if (canonical == null) {
return null;
}
return canonical.startsWith("+") ? canonical.substring(1) : canonical;
}
private static String compact(String raw) {
if (StringUtils.isBlank(raw)) {
throw new ServiceException(MessageUtils.message("user.phonenumber.not.blank"));
}
String value = raw.strip().replaceAll("[\\s()-]", "");
if (value.startsWith("00")) {
value = "+" + value.substring(2);
}
return value;
}
}

View File

@@ -4,12 +4,13 @@ import cn.hutool.core.util.RandomUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.DesensitizedUtils;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.sms.core.AliyunInternationalSmsService;
import org.dromara.device.config.AppProperties;
import org.dromara.device.constant.AppConstants;
import org.dromara.device.constant.AppMsgConstants;
import org.dromara.device.constant.DeviceMsgConstants;
import org.dromara.device.enums.SmsSceneEnum;
import org.dromara.sms4j.api.SmsBlend;
import org.dromara.sms4j.api.entity.SmsResponse;
@@ -27,7 +28,7 @@ import java.util.LinkedHashMap;
* <li><b>场景化隔离</b>:缓存 key 与限流 key 均带场景,登录/注册/忘记密码/换绑互不覆盖。</li>
* <li><b>时长口径</b>:验证码有效期 5 分钟、重发间隔 60 秒、位数 6 位,均可由 {@code device.app.sms.*} 配置覆盖。</li>
* <li><b>短信通道开关点</b>:{@code device.app.sms.mock}(默认 true)为 true 时仅打印日志,便于开发期(Q5 短信通道未定)联调;
* 置为 false 时走 sms4j 真实通道。</li>
* 置为 false 时国内走 sms4j,国际走 {@link AliyunInternationalSmsService}(与后台 {@code /sendInternational} 同一通道)。</li>
* <li><b>不触碰 PC 通道</b>:本服务仅服务 {@code /app/**},与框架既有 {@code /resource/sms/code} 完全独立。</li>
* </ul>
*
@@ -40,25 +41,27 @@ import java.util.LinkedHashMap;
public class AppSmsService {
private final AppProperties appProperties;
private final AliyunInternationalSmsService aliyunInternationalSmsService;
/**
* 生成并按场景发送短信验证码(含 60 秒重发限流)。
*
* @param phoneNumber 手机号
* @param scene 场景(login / register / resetPwd / rebind)
* @param scene 场景(login / register / resetPwd / bind / rebind / rebindOld)
* @return mock 模式下返回验证码供接口联调,真实短信模式返回 {@code null}
*/
public String sendCode(String phoneNumber, String scene) {
if (!SmsSceneEnum.isValid(scene)) {
throw new ServiceException(AppMsgConstants.SMS_SCENE_INVALID);
throw new ServiceException(MessageUtils.message("app.sms.scene.invalid"));
}
phoneNumber = AppPhoneNumbers.canonical(phoneNumber);
AppProperties.Sms sms = appProperties.getSms();
// 60 秒重发限流:利用「不存在才写入」的原子性,写入成功即取得发送资格
String limitKey = AppConstants.SMS_LIMIT_KEY_PREFIX + scene + ":" + phoneNumber;
boolean acquired = RedisUtils.setObjectIfAbsent(limitKey, System.currentTimeMillis(),
Duration.ofSeconds(sms.getResendSeconds()));
if (!acquired) {
throw new ServiceException(AppMsgConstants.SMS_SEND_TOO_FREQUENT);
throw new ServiceException(MessageUtils.message("app.sms.send.too.frequent"));
}
String code = RandomUtil.randomNumbers(sms.getCodeLength());
// 先落库缓存,再发送:即使发送异常,限流 key 已存在,避免短信通道故障时的刷量
@@ -88,10 +91,11 @@ public class AppSmsService {
* @param code 用户输入的验证码
*/
public void verifyCode(String phoneNumber, String scene, String code) {
phoneNumber = AppPhoneNumbers.canonical(phoneNumber);
String codeKey = AppConstants.SMS_CODE_KEY_PREFIX + scene + ":" + phoneNumber;
String cached = RedisUtils.getCacheObject(codeKey);
if (StringUtils.isBlank(cached)) {
throw new ServiceException(DeviceMsgConstants.SMS_CODE_EXPIRED);
throw new ServiceException(MessageUtils.message("app.sms.code.expired"));
}
// ① 先判「正确」:正确码永远放行(在限速之前),故攻击者无法阻断受害者;
// 一次性使用,成功后清零失败计数。
@@ -108,38 +112,51 @@ public class AppSmsService {
RedisUtils.expire(failKey, Duration.ofSeconds(AppConstants.SMS_VERIFY_FAIL_WINDOW_SECONDS));
}
if (attempts > AppConstants.SMS_MAX_VERIFY_FAIL) {
throw new ServiceException(AppMsgConstants.SMS_VERIFY_TOO_MANY);
throw new ServiceException(MessageUtils.message("app.sms.verify.too.many"));
}
throw new ServiceException(DeviceMsgConstants.SMS_CODE_ERROR);
throw new ServiceException(MessageUtils.message("app.sms.code.error"));
}
/**
* 实际发送验证码:mock 模式打印日志,否则走 sms4j。
* 实际发送验证码:mock 打日志;国内 sms4j;国际走阿里云 {@code SendMessageToGlobe}。
*
* @param phoneNumber 手机号
* @param code 验证码
*/
private void send(String phoneNumber, String code) {
AppProperties.Sms sms = appProperties.getSms();
boolean china = AppPhoneNumbers.chinaMainland(phoneNumber);
String to = AppPhoneNumbers.forSms(phoneNumber);
if (sms.isMock()) {
log.info("[APP短信-模拟发送] 手机号={},验证码={},有效期={}分钟(开发环境未接入短信通道,Q5)。",
phoneNumber, code, sms.getExpiryMinutes());
log.info("[APP短信-模拟发送] 手机号={},通道号码={},{},验证码={},有效期={}分钟。",
DesensitizedUtils.maskHighSecurity(phoneNumber, 3, 2),
DesensitizedUtils.maskHighSecurity(to, 3, 2),
china ? "国内" : "国际", code, sms.getExpiryMinutes());
return;
}
if (!china) {
aliyunInternationalSmsService.sendMessageToGlobe(to, code);
return;
}
try {
SmsBlend smsBlend = SmsFactory.getSmsBlend(sms.getBlend());
LinkedHashMap<String, String> params = new LinkedHashMap<>(1);
params.put("code", code);
SmsResponse response = smsBlend.sendMessage(phoneNumber, sms.getTemplateId(), params);
SmsResponse response = smsBlend.sendMessage(to, sms.getTemplateId(), params);
if (!response.isSuccess()) {
log.error("APP 短信验证码发送失败 phone={} response={}", phoneNumber, response);
throw new ServiceException(AppMsgConstants.SMS_SEND_FAILED);
// 通道响应可能包含目标号码或请求参数,日志仅保留脱敏号码和配置标识。
log.error("验证码短信发送失败 phone={} configId={}",
DesensitizedUtils.maskHighSecurity(phoneNumber, 3, 2), response.getConfigId());
throw new ServiceException(MessageUtils.message("app.sms.send.failed"));
}
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.error("APP 短信验证码发送异常 phone={}", phoneNumber, e);
throw new ServiceException(AppMsgConstants.SMS_SEND_FAILED);
// 通道异常可能携带短信请求参数,日志仅保留异常类型。
log.error("APP 短信验证码发送异常 phone={}, error={}",
DesensitizedUtils.maskHighSecurity(phoneNumber, 3, 2),
e.getClass().getSimpleName());
throw new ServiceException(MessageUtils.message("app.sms.send.failed"));
}
}

View File

@@ -0,0 +1,120 @@
package org.dromara.device.stream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.device.config.MjpegProperties;
import org.dromara.device.config.ZlmProperties;
import org.dromara.device.constant.DeviceMsgConstants;
import org.dromara.device.domain.BmDevice;
import org.dromara.device.domain.vo.AppStreamPlayUrlsVo;
import org.dromara.device.mjpeg.MjpegFrameHub;
import org.dromara.device.mjpeg.MjpegIngestTokenService;
import org.dromara.device.mjpeg.MjpegLifecycleCoordinator;
import org.dromara.device.mqtt.DeviceCommandPublisher;
import org.dromara.device.mqtt.payload.StreamCommandPayload;
import org.springframework.stereotype.Component;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* HTTP MJPEG 直播 adapter。
*
* @author Kou
* @date 2026-09-18
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MjpegStreamBackend implements StreamBackend {
private final MjpegProperties properties;
private final ZlmProperties zlmProperties;
private final MjpegFrameHub frameHub;
private final MjpegIngestTokenService ingestTokenService;
private final MjpegLifecycleCoordinator lifecycleCoordinator;
private final DeviceCommandPublisher commandPublisher;
@Override
public void start(BmDevice device, String streamId) {
lifecycleCoordinator.execute(streamId, () -> startLocked(device, streamId));
}
/**
* 在设备生命周期锁内执行开播,防止旧会话回收误伤本次新会话。
*/
private void startLocked(BmDevice device, String streamId) {
if (online(streamId)) {
return;
}
// 先校验公网地址,避免配置缺失时留下无效 token 或空会话。
String ingestUrl = buildIngestUrl(streamId, null);
String ingestToken = ingestTokenService.issue(streamId);
frameHub.open(streamId, ingestToken);
StreamCommandPayload payload = StreamCommandPayload.start(
DeviceCommandPublisher.newMsgId(), streamId, zlmProperties.getApp(), "mjpeg",
appendToken(ingestUrl, ingestToken));
if (!commandPublisher.publishStream(device.getDeviceNo(), payload)) {
// 指令发送失败时回滚本地状态,避免空会话触发延迟停推。
ingestTokenService.revoke(streamId);
frameHub.clear(streamId);
throw new ServiceException(DeviceMsgConstants.COMMAND_SEND_FAILED);
}
log.info("[MJPEG] 已通知设备开始推流 deviceNo={}", device.getDeviceNo());
}
@Override
public void stop(BmDevice device, String streamId) {
lifecycleCoordinator.execute(streamId, () -> {
StreamCommandPayload payload = StreamCommandPayload.stop(
DeviceCommandPublisher.newMsgId(), streamId, zlmProperties.getApp());
commandPublisher.publishStream(device.getDeviceNo(), payload);
closeLocked(streamId);
});
}
@Override
public void close(String streamId) {
lifecycleCoordinator.execute(streamId, () -> closeLocked(streamId));
}
/**
* 清理当前设备的推流凭证和内存会话,调用方必须持有生命周期锁。
*/
private void closeLocked(String streamId) {
ingestTokenService.revoke(streamId);
frameHub.clear(streamId);
}
@Override
public boolean online(String streamId) {
return frameHub.online(streamId, properties.getFrameTtlSeconds());
}
@Override
public Endpoint endpoint(String streamId, String playToken) {
AppStreamPlayUrlsVo urls = new AppStreamPlayUrlsVo();
urls.setMjpeg(appendToken(publicBase() + "/mjpeg/play/" + streamId, playToken));
return new Endpoint(buildIngestUrl(streamId, null), urls);
}
private String buildIngestUrl(String streamId, String ingestToken) {
String url = publicBase() + "/mjpeg/ingest/" + streamId;
return ingestToken == null ? url : appendToken(url, ingestToken);
}
private String publicBase() {
String host = properties.getPublicHost();
if (host == null || host.isBlank()) {
throw new ServiceException(DeviceMsgConstants.MJPEG_NOT_CONFIGURED);
}
return host.endsWith("/") ? host.substring(0, host.length() - 1) : host;
}
private String appendToken(String url, String token) {
String separator = url.contains("?") ? "&" : "?";
return url + separator + "token=" + URLEncoder.encode(token, StandardCharsets.UTF_8);
}
}

View File

@@ -0,0 +1,63 @@
package org.dromara.device.stream;
import org.dromara.device.domain.BmDevice;
import org.dromara.device.domain.vo.AppStreamPlayUrlsVo;
/**
* 直播传输后端接口,统一封装协议差异、设备指令与资源清理。
*
* @author Kou
* @date 2026-09-18
*/
public interface StreamBackend {
/**
* 流不在线时通知设备开始推流。
*
* @param device 设备
* @param streamId 流标识
*/
void start(BmDevice device, String streamId);
/**
* 通知设备停止推流并清理服务端资源。
*
* @param device 设备
* @param streamId 流标识
*/
void stop(BmDevice device, String streamId);
/**
* 仅清理服务端资源,不通知设备。
*
* @param streamId 流标识
*/
void close(String streamId);
/**
* 查询流是否在线。
*
* @param streamId 流标识
* @return 是否在线
*/
boolean online(String streamId);
/**
* 生成推流入口和带播放 token 的播放地址。
*
* @param streamId 流标识
* @param playToken 播放 token
* @return 地址信息
*/
Endpoint endpoint(String streamId, String playToken);
/**
* 直播地址集合。
*
* @param pushUrl 设备推流入口(不含内部 ingest token)
* @param playUrls 客户端播放地址
*/
record Endpoint(String pushUrl, AppStreamPlayUrlsVo playUrls) {
}
}

View File

@@ -0,0 +1,131 @@
package org.dromara.device.stream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.device.config.ZlmProperties;
import org.dromara.device.constant.DeviceMsgConstants;
import org.dromara.device.domain.BmDevice;
import org.dromara.device.domain.vo.AppStreamPlayUrlsVo;
import org.dromara.device.mqtt.DeviceCommandPublisher;
import org.dromara.device.mqtt.payload.StreamCommandPayload;
import org.dromara.device.zlm.stream.ZlmClient;
import org.springframework.stereotype.Component;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* ZLMediaKit 直播 adapter。
*
* @author Kou
* @date 2026-09-18
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ZlmStreamBackend implements StreamBackend {
private final ZlmClient zlmClient;
private final ZlmProperties properties;
private final DeviceCommandPublisher commandPublisher;
@Override
public void start(BmDevice device, String streamId) {
if (online(streamId)) {
return;
}
StreamCommandPayload payload = StreamCommandPayload.start(
DeviceCommandPublisher.newMsgId(), streamId, properties.getApp(),
properties.getPushProtocol(), buildPushUrl(streamId));
if (!commandPublisher.publishStream(device.getDeviceNo(), payload)) {
throw new ServiceException(DeviceMsgConstants.COMMAND_SEND_FAILED);
}
log.info("[ZLM] 已通知设备开始推流 deviceNo={} streamId={}", device.getDeviceNo(), streamId);
}
@Override
public void stop(BmDevice device, String streamId) {
StreamCommandPayload payload = StreamCommandPayload.stop(
DeviceCommandPublisher.newMsgId(), streamId, properties.getApp());
commandPublisher.publishStream(device.getDeviceNo(), payload);
zlmClient.closeStreams(streamId);
}
@Override
public void close(String streamId) {
zlmClient.delStreamProxy(streamId);
zlmClient.closeStreams(streamId);
}
@Override
public boolean online(String streamId) {
return zlmClient.isStreamOnline(streamId);
}
@Override
public Endpoint endpoint(String streamId, String playToken) {
String base = trimTrailingSlash(properties.getHost()) + "/" + properties.getApp() + "/" + streamId;
AppStreamPlayUrlsVo urls = new AppStreamPlayUrlsVo();
urls.setFlv(appendToken(base + ".live.flv", playToken));
urls.setHls(appendToken(base + "/hls.m3u8", playToken));
urls.setRtmp(appendToken(buildRtmpUrl(streamId), playToken));
urls.setRtsp(appendToken(buildRtspUrl(streamId), playToken));
urls.setWebrtc(appendToken(buildWebrtcUrl(streamId), playToken));
return new Endpoint(buildPushUrl(streamId), urls);
}
private String buildPushUrl(String streamId) {
return "rtsp".equalsIgnoreCase(properties.getPushProtocol())
? buildRtspUrl(streamId) : buildRtmpUrl(streamId);
}
private String buildRtmpUrl(String streamId) {
return toProtocol(resolveProtocolHost(properties.getRtmpHost(), properties.getHost(), "1935"), "rtmp")
+ "/" + properties.getApp() + "/" + streamId;
}
private String buildRtspUrl(String streamId) {
return toProtocol(resolveProtocolHost(properties.getRtspHost(), properties.getHost(), "554"), "rtsp")
+ "/" + properties.getApp() + "/" + streamId;
}
private String buildWebrtcUrl(String streamId) {
return toProtocol(resolveProtocolHost(properties.getWebrtcHost(), properties.getHost(), null), "webrtc")
+ "/" + properties.getApp() + "/" + streamId;
}
private String toProtocol(String host, String protocol) {
String url = trimTrailingSlash(host);
int index = url.indexOf("://");
return index > -1 ? protocol + url.substring(index) : protocol + "://" + url;
}
private String appendToken(String url, String token) {
String separator = url.contains("?") ? "&" : "?";
return url + separator + "token=" + URLEncoder.encode(token, StandardCharsets.UTF_8);
}
private String trimTrailingSlash(String value) {
if (value == null || value.isBlank()) {
throw new ServiceException(DeviceMsgConstants.ZLM_NOT_CONFIGURED);
}
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
}
private String resolveProtocolHost(String configuredHost, String fallbackHost, String defaultPort) {
if (configuredHost != null && !configuredHost.isBlank()) {
return configuredHost;
}
String host = trimTrailingSlash(fallbackHost);
int protocolIndex = host.indexOf("://");
String protocol = protocolIndex > -1 ? host.substring(0, protocolIndex + 3) : "";
String rest = protocolIndex > -1 ? host.substring(protocolIndex + 3) : host;
int slashIndex = rest.indexOf('/');
String authority = slashIndex > -1 ? rest.substring(0, slashIndex) : rest;
int colonIndex = authority.lastIndexOf(':');
String hostname = colonIndex > -1 ? authority.substring(0, colonIndex) : authority;
return defaultPort == null ? protocol + authority : protocol + hostname + ":" + defaultPort;
}
}

View File

@@ -73,6 +73,8 @@ public class AppStreamController extends BaseController {
* @return 播放信息
*/
@GetMapping("/play-url/{deviceNo}")
@RateLimiter(key = "@streamRateLimitKey.control(#deviceNo)", time = 60, count = 60,
limitType = LimitType.IP, message = DeviceMsgConstants.STREAM_TOO_FREQUENT)
public R<AppStreamPlayVo> playUrl(@PathVariable String deviceNo) {
return R.ok(appStreamService.playUrl(deviceNo, currentUserId()));
}
@@ -111,6 +113,8 @@ public class AppStreamController extends BaseController {
* @return 状态
*/
@GetMapping("/status/{deviceNo}")
@RateLimiter(key = "@streamRateLimitKey.control(#deviceNo)", time = 60, count = 60,
limitType = LimitType.IP, message = DeviceMsgConstants.STREAM_TOO_FREQUENT)
public R<AppStreamStatusVo> status(@PathVariable String deviceNo) {
return R.ok(appStreamService.status(deviceNo, currentUserId()));
}

View File

@@ -52,7 +52,7 @@ public class ZlmHookController {
log.warn("[ZLM-HOOK] on_publish 未通过 secret 校验, IP={}", request.getRemoteAddr());
return reject("unauthorized");
}
log.info("[ZLM-HOOK] on_publish {}", body);
logHook("on_publish", body);
return ok();
}
@@ -69,7 +69,7 @@ public class ZlmHookController {
log.warn("[ZLM-HOOK] on_play 未通过 secret 校验, IP={}", request.getRemoteAddr());
return reject("unauthorized");
}
log.info("[ZLM-HOOK] on_play {}", body);
logHook("on_play", body);
String streamId = asString(body.get("stream"));
String token = extractToken(body);
String clientIp = asString(body.get("ip"));
@@ -92,7 +92,7 @@ public class ZlmHookController {
log.warn("[ZLM-HOOK] on_stream_changed 未通过 secret 校验, IP={}", request.getRemoteAddr());
return reject("unauthorized");
}
log.info("[ZLM-HOOK] on_stream_changed {}", body);
logHook("on_stream_changed", body);
return ok();
}
@@ -109,7 +109,7 @@ public class ZlmHookController {
log.warn("[ZLM-HOOK] on_stream_none_reader 未通过 secret 校验, IP={}", request.getRemoteAddr());
return reject("unauthorized");
}
log.info("[ZLM-HOOK] on_stream_none_reader {}", body);
logHook("on_stream_none_reader", body);
Object stream = body.get("stream");
if (stream instanceof String streamId && !streamId.isBlank()) {
appStreamService.closeZlmStream(streamId);
@@ -149,6 +149,18 @@ public class ZlmHookController {
return false;
}
/**
* WebHook 请求体含 secret、播放 token 等敏感字段,日志只记录排障所需的白名单字段。
*/
private void logHook(String hookName, Map<String, Object> body) {
log.info("[ZLM-HOOK] {} app={} stream={} schema={} ip={}",
hookName,
asString(body.get("app")),
asString(body.get("stream")),
asString(body.get("schema")),
asString(body.get("ip")));
}
private Map<String, Object> ok() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("code", 0);

View File

@@ -76,7 +76,8 @@ public class ZlmPlayAuthService {
Map<String, Object> parsed = JsonUtils.parseObject(json, Map.class);
payload = parsed;
} catch (Exception e) {
log.warn("[ZLM-AUTH] token payload 解析失败 token={}", token, e);
log.warn("[ZLM-AUTH] token payload 解析失败 streamId={}, error={}",
streamId, e.getClass().getSimpleName());
return false;
}
if (payload == null) {
@@ -92,8 +93,7 @@ public class ZlmPlayAuthService {
String issuedIp = Objects.toString(payload.get("clientIp"), "");
if (StringUtils.isNotBlank(issuedIp) && StringUtils.isNotBlank(clientIp)
&& !Objects.equals(issuedIp, clientIp)) {
log.warn("[ZLM-AUTH] ip 不匹配 tokenIp={} requestIp={} streamId={}",
issuedIp, clientIp, streamId);
log.warn("[ZLM-AUTH] ip 不匹配 streamId={}", streamId);
return false;
}
return true;

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.dromara.device.mapper.BmAppMessageMapper">
<!-- 基础 CRUD 走 BaseMapperPlus,自定义 SQL 在后续迭代补充 -->
</mapper>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.dromara.device.mapper.BmAppVersionMapper">
<!--
自定义 SQL 不会被 MyBatis-Plus 追加 del_flag 条件。
版本构建号是只能前进的历史高水位,因此删除记录也必须参与最大值计算。
-->
<select id="selectMaxVersionCodeIncludingDeleted" resultType="java.lang.Integer">
SELECT MAX(version_code)
FROM bm_app_version
WHERE platform = #{platform}
<if test="excludeVersionId != null">
AND version_id != #{excludeVersionId}
</if>
</select>
</mapper>

View File

@@ -4,6 +4,17 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.dromara.device.mapper.BmDeviceAlertMapper">
<!-- 基础 CRUD 走 BaseMapperPlus,自定义 SQL 在后续迭代补充 -->
<!--
告警表本身作为消息 outbox:message_user_id 是发生时的收件人快照,alert_id 唯一键保证补偿幂等。
这里有意不筛 del_flag;后台删除告警不等于撤回已经发生的 APP 通知。
-->
<select id="selectPendingMessageAlerts" resultType="org.dromara.device.domain.BmDeviceAlert">
SELECT a.*
FROM bm_device_alert a
LEFT JOIN bm_app_message m ON m.alert_id = a.alert_id
WHERE a.message_user_id IS NOT NULL
AND m.message_id IS NULL
ORDER BY a.alert_time ASC, a.alert_id ASC
</select>
</mapper>

Some files were not shown because too many files have changed in this diff Show More