Compare commits

..

8 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
cnc
e3b01e7b44 Merge branch 'main' of https://gitea.bugtc.com/yuhaiming/bird_monitoring
# Conflicts:
#	ruoyi-admin/src/main/resources/application.yml
2026-09-18 09:45:45 +08:00
cnc
e0524d17eb 国际验证码以及手机验证码登录 2026-09-18 09:44:26 +08:00
yuhaiming
7bdc5c846d 设备列表 视频流服务器 2026-09-17 17:09:49 +08:00
yuhaiming
1cfb071dfb chore(device): 补充 device.app.email 配置段(Gmail SMTP,凭据走环境变量)
- application-dev.yml / application-prod.yml 新增 device.app.email 段(各 +32/33 行)
- 与已提交的 AppEmailService/AppProperties.Email 对齐:字段名、默认值一一对应
- mock 语义:dev=true 仅打日志;prod=false,且 prod+mock=true 时 AppEmailProdGuard 拒绝启动
- host/port/user/pass/from 全部由 ${DEVICE_APP_EMAIL_*} 环境变量注入,无任何明文凭据
- allowed-domains 默认注释(留空=不限制域名),要"只允许谷歌邮箱"取消注释即可,无需改代码
- 整段删除不影响启动(AppProperties 内均有同值默认)
2026-09-17 10:29:31 +08:00
yuhaiming
79b739fe02 feat(device): 一期设备域 I1-I5 全量实现(APP 接口/后台管理/OTA/MQTT/直播)
模块接入:
- 新增 ruoyi-device(org.dromara.device)并注册到根/ruoyi-modules/ruoyi-admin pom

APP 侧(/app/**,需登录,仅本人绑定设备):
- 账号闭环:短信/邮箱验证码注册登录、密码重置(不跨界返回散列)
- 设备:绑定/列表/详情/重命名/解绑(越权统一"设备不存在",防 IDOR)
- 眼罩开关(/app/device/{deviceNo}/hood,低电不对称拦截)
- 定位(/app/device/{deviceNo}/location)
- 直播(/app/stream 6 端点 + /app/device/{deviceNo}/live,自建 ZLMediaKit,设备主动推流)

后台(/device/**):
- 设备 CRUD/统计/删除/导出(ExcelBuilder)
- OTA(6 端点:升级包管理 + 批量下发,total==created+skipped+failed)

MQTT(T05):
- 遥测/事件/ack 监听、指令下发+回执(防串设备)、离线扫描、低电告警
- 报文身份仅来自 Topic,反查 device_id 防跨设备伪造

数据与脚本:
- script/sql/ry_device.sql(6 表/字典/权限字/菜单)、bm_patch.sql
- script/mqtt 设备模拟器(mqtt-simulate.js)

安全:
- MQTT broker 凭据外置为环境变量 MQTT_USERNAME/MQTT_PASSWORD,不落明文
- ZLM webhook 密钥外置为环境变量 ZLM_SECRET(未配置时 /zlm/hook/** 失败关闭),不落明文
2026-09-17 10:21:27 +08:00
224 changed files with 22252 additions and 27 deletions

23
pom.xml
View File

@@ -61,6 +61,8 @@
<aws.sdk.version>2.48.1</aws.sdk.version> <aws.sdk.version>2.48.1</aws.sdk.version>
<!-- SMS 配置 --> <!-- SMS 配置 -->
<sms4j.version>3.3.5</sms4j.version> <sms4j.version>3.3.5</sms4j.version>
<!-- 阿里云国际短信 SDK 版本,请以 https://mvnrepository.com/artifact/com.aliyun/dysmsapi20180501 最新版为准 -->
<aliyun-dysmsapi.version>1.0.12</aliyun-dysmsapi.version>
<!-- 工作流配置 --> <!-- 工作流配置 -->
<warm-flow.version>1.8.9</warm-flow.version> <warm-flow.version>1.8.9</warm-flow.version>
<liteflow.version>2.16.1.2</liteflow.version> <liteflow.version>2.16.1.2</liteflow.version>
@@ -259,6 +261,20 @@
<version>${sms4j.version}</version> <version>${sms4j.version}</version>
</dependency> </dependency>
<!-- 阿里云国际短信 SDK(SendMessageToGlobe) -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20180501</artifactId>
<version>${aliyun-dysmsapi.version}</version>
</dependency>
<!-- 阿里云 OpenAPI 基础包 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>tea-openapi</artifactId>
<version>0.3.10</version>
</dependency>
<!-- Spring Boot Admin 服务端 --> <!-- Spring Boot Admin 服务端 -->
<dependency> <dependency>
<groupId>de.codecentric</groupId> <groupId>de.codecentric</groupId>
@@ -457,6 +473,13 @@
<version>${revision}</version> <version>${revision}</version>
</dependency> </dependency>
<!-- 设备模块 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-device</artifactId>
<version>${revision}</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>

View File

@@ -62,6 +62,18 @@
<artifactId>ruoyi-common-mail</artifactId> <artifactId>ruoyi-common-mail</artifactId>
</dependency> </dependency>
<!-- 短信模块(提供 AliyunInternationalSmsProperties / 自动配置) -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-sms</artifactId>
</dependency>
<!-- 阿里云国际短信 SDK(本模块直接 import 了 Client / models / RuntimeOptions) -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20180501</artifactId>
</dependency>
<!-- mcp模块 --> <!-- mcp模块 -->
<dependency> <dependency>
<groupId>org.dromara</groupId> <groupId>org.dromara</groupId>
@@ -98,6 +110,12 @@
<artifactId>ruoyi-demo</artifactId> <artifactId>ruoyi-demo</artifactId>
</dependency> </dependency>
<!-- 设备模块 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-device</artifactId>
</dependency>
<!-- 工作流模块 --> <!-- 工作流模块 -->
<dependency> <dependency>
<groupId>org.dromara</groupId> <groupId>org.dromara</groupId>

View File

@@ -6,6 +6,7 @@ import cn.hutool.captcha.generator.MathGenerator;
import cn.hutool.captcha.generator.RandomGenerator; import cn.hutool.captcha.generator.RandomGenerator;
import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.RandomUtil;
import com.aliyun.dysmsapi20180501.models.SendMessageToGlobeResponseBody;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -21,6 +22,7 @@ import org.dromara.common.mail.core.MailBuilder;
import org.dromara.common.redis.annotation.RateLimiter; import org.dromara.common.redis.annotation.RateLimiter;
import org.dromara.common.redis.enums.LimitType; import org.dromara.common.redis.enums.LimitType;
import org.dromara.common.redis.utils.RedisUtils; import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.sms.core.AliyunInternationalSmsService;
import org.dromara.common.web.config.properties.CaptchaProperties; import org.dromara.common.web.config.properties.CaptchaProperties;
import org.dromara.common.web.core.WaveAndCircleCaptcha; import org.dromara.common.web.core.WaveAndCircleCaptcha;
import org.dromara.sms4j.api.SmsBlend; import org.dromara.sms4j.api.SmsBlend;
@@ -51,6 +53,7 @@ public class CaptchaController {
private final CaptchaProperties captchaProperties; private final CaptchaProperties captchaProperties;
private final MailProperties mailProperties; private final MailProperties mailProperties;
private final AliyunInternationalSmsService aliyunInternationalSmsService;
/** /**
* 发送短信验证码。 * 发送短信验证码。
@@ -80,6 +83,33 @@ public class CaptchaController {
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION)); RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
return R.ok(); return R.ok();
} }
/**
* 发送国际短信(阿里云国际短信包,向境外号码发送)
*
* @param phoneNumber 国际号码,带国家码不含 +/00,例如 62895111666
*/
@GetMapping("/sendInternational")
public R<Object> sendInternational( String phoneNumber) {
// if (!RegexValidator.isMobile(phoneNumber)) {
// return R.fail("请输入正确的手机号!");
// }
String key = GlobalConstants.CAPTCHA_CODE_KEY + phoneNumber;
String code = RandomUtil.randomNumbers(4);
// 验证码模板id 自行处理 (查数据库或写死均可)
String templateId = "";
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
map.put("code", code);
SmsBlend smsBlend = SmsFactory.getSmsBlend("config1");
SendMessageToGlobeResponseBody body = aliyunInternationalSmsService.sendMessageToGlobe(phoneNumber, code);
if (!"OK".equals(body.getResponseCode())) {
log.error("验证码短信发送异常 => {}", body.getResponseDescription());
return R.warn("验证码短信发送失败,请联系管理员!" );
}
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
return R.ok();
}
/** /**
* 发送邮箱验证码 * 发送邮箱验证码

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

@@ -3,12 +3,15 @@ package org.dromara.web.service.impl;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.stp.parameter.SaLoginParameter; import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.crypto.digest.BCrypt;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants; import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants; import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.constant.SystemConstants; import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.enums.LoginType; import org.dromara.common.core.enums.LoginType;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.exception.user.CaptchaExpireException; import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException; import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.MessageUtils; import org.dromara.common.core.utils.MessageUtils;
@@ -20,9 +23,11 @@ import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.api.model.LoginUser; import org.dromara.system.api.model.LoginUser;
import org.dromara.system.api.model.SmsLoginBody; import org.dromara.system.api.model.SmsLoginBody;
import org.dromara.system.domain.SysUser; import org.dromara.system.domain.SysUser;
import org.dromara.system.domain.bo.SysUserBo;
import org.dromara.system.domain.vo.SysClientVo; import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.system.domain.vo.SysUserVo; import org.dromara.system.domain.vo.SysUserVo;
import org.dromara.system.mapper.SysUserMapper; import org.dromara.system.mapper.SysUserMapper;
import org.dromara.system.service.ISysUserService;
import org.dromara.web.domain.vo.LoginVo; import org.dromara.web.domain.vo.LoginVo;
import org.dromara.web.service.IAuthStrategy; import org.dromara.web.service.IAuthStrategy;
import org.dromara.web.service.SysLoginService; import org.dromara.web.service.SysLoginService;
@@ -38,11 +43,18 @@ import org.springframework.stereotype.Service;
@RequiredArgsConstructor @RequiredArgsConstructor
public class SmsAuthStrategy implements IAuthStrategy { public class SmsAuthStrategy implements IAuthStrategy {
/**
* 短信自动注册新用户的初始登录密码
*/
private static final String INIT_PASSWORD = "@feiqin123456$";
private final SysLoginService loginService; private final SysLoginService loginService;
private final SysUserMapper userMapper; private final SysUserMapper userMapper;
private final ISysUserService userService;
/** /**
* 执行短信验证码登录,并按客户端配置生成访问令牌。 * 执行短信验证码登录,并按客户端配置生成访问令牌。
* 若手机号不存在,则先校验短信验证码,再根据手机号自动注册新用户并完成登录。
* *
* @param body 登录请求体 * @param body 登录请求体
* @param client 当前客户端配置 * @param client 当前客户端配置
@@ -54,8 +66,16 @@ public class SmsAuthStrategy implements IAuthStrategy {
ValidatorUtils.validate(loginBody); ValidatorUtils.validate(loginBody);
String phoneNumber = loginBody.getPhoneNumber(); String phoneNumber = loginBody.getPhoneNumber();
String smsCode = loginBody.getSmsCode(); String smsCode = loginBody.getSmsCode();
// 先校验短信验证码, 避免验证码错误时误注册新用户
loginService.checkLogin(LoginType.SMS, phoneNumber, () -> !validateSmsCode(phoneNumber, smsCode));
// 手机号对应用户不存在时, 自动注册新用户
SysUserVo user = loadUserByPhoneNumber(phoneNumber); SysUserVo user = loadUserByPhoneNumber(phoneNumber);
loginService.checkLogin(LoginType.SMS, user.getUserName(), () -> !validateSmsCode(phoneNumber, smsCode)); if (ObjectUtil.isNull(user)) {
user = registerUserByPhoneNumber(phoneNumber);
} else if (SystemConstants.DISABLE.equals(user.getStatus())) {
log.info("登录用户:{} 已被停用.", phoneNumber);
throw new UserException("user.blocked", phoneNumber);
}
// 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了 // 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了
LoginUser loginUser = loginService.buildLoginUser(user); LoginUser loginUser = loginService.buildLoginUser(user);
loginUser.setClientKey(client.getClientKey()); loginUser.setClientKey(client.getClientKey());
@@ -88,23 +108,53 @@ public class SmsAuthStrategy implements IAuthStrategy {
} }
/** /**
* 按手机号加载可登录用户,并校验是否存在或被停用。 * 按手机号加载可登录用户。
* *
* @param phoneNumber 手机号 * @param phoneNumber 手机号
* @return 用户信息 * @return 用户信息, 不存在时返回 {@code null}
*/ */
private SysUserVo loadUserByPhoneNumber(String phoneNumber) { private SysUserVo loadUserByPhoneNumber(String phoneNumber) {
SysUserVo user = userMapper.lambda() return userMapper.lambda()
.eq(SysUser::getPhoneNumber, phoneNumber) .eq(SysUser::getPhoneNumber, phoneNumber)
.voOne(); .voOne();
if (ObjectUtil.isNull(user)) { }
log.info("登录用户:{} 不存在.", phoneNumber);
throw new UserException("user.not.exists", phoneNumber); /**
} else if (SystemConstants.DISABLE.equals(user.getStatus())) { * 根据手机号注册新用户。
log.info("登录用户:{} 已被停用.", phoneNumber); *
throw new UserException("user.blocked", phoneNumber); * @param phoneNumber 手机号
* @return 注册后的用户信息
*/
private SysUserVo registerUserByPhoneNumber(String phoneNumber) {
SysUserBo userBo = new SysUserBo();
userBo.setUserName(generateUserName(phoneNumber));
userBo.setNickName(phoneNumber);
userBo.setPhoneNumber(phoneNumber);
userBo.setUserType(UserType.SYS_USER.getUserType());
userBo.setStatus(SystemConstants.NORMAL);
// 短信注册使用统一的初始登录密码
userBo.setPassword(BCrypt.hashpw(INIT_PASSWORD));
boolean regFlag = userService.registerUser(userBo);
if (!regFlag) {
log.info("手机号:{} 自动注册失败.", phoneNumber);
throw new UserException("user.register.error");
} }
return user; log.info("手机号:{} 不存在, 已自动注册新用户.", phoneNumber);
return loadUserByPhoneNumber(phoneNumber);
}
/**
* 生成唯一的登录账号, 默认使用手机号, 若已存在则追加随机后缀。
*
* @param phoneNumber 手机号
* @return 唯一的登录账号
*/
private String generateUserName(String phoneNumber) {
String userName = phoneNumber;
while (userMapper.lambda().eq(SysUser::getUserName, userName).exists()) {
userName = phoneNumber + RandomUtil.randomNumbers(4);
}
return userName;
} }
} }

View File

@@ -155,7 +155,7 @@ spring.data:
# 数据库索引 # 数据库索引
database: 0 database: 0
# redis 密码必须配置 # redis 密码必须配置
# password: ruoyi123 #password: 123456
# 连接超时时间 # 连接超时时间
timeout: 10s timeout: 10s
# 是否开启ssl # 是否开启ssl
@@ -206,6 +206,52 @@ mail:
# Socket连接超时值,单位毫秒,缺省值不超时 # Socket连接超时值,单位毫秒,缺省值不超时
connectionTimeout: 0 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:
app:
# ---------------- APP 邮箱验证码(邮箱注册 / 邮箱登录 / 邮箱找回密码) ----------------
email:
# 是否仅打印日志而不真实发信:开发环境默认 true,便于在无 SMTP 条件下联调。
# 【生产环境(prod)必须为 false】——否则应用拒绝启动,防止验证码明文写入日志。
mock: true
# 验证码位数 / 有效期(分钟)/ 重发间隔(秒)
code-length: 6
expiry-minutes: 5
resend-seconds: 60
# 允许注册的邮箱域名白名单,留空(或不配)表示不限制域名。
# 若产品要求「只允许谷歌邮箱注册」,取消下行注释即可,无需改代码:
# allowed-domains: gmail.com,googlemail.com
sms:
# 开发默认 mock;真发国际/国内短信时置 false,并配好 sms.blends 与模板 id
mock: true
blend: config1
template-id: ${DEVICE_APP_SMS_TEMPLATE_ID:}
mjpeg:
# 开发环境默认使用局域网地址;生产环境由 MJPEG_PUBLIC_HOST 显式注入。
public-host: ${MJPEG_PUBLIC_HOST:http://192.168.1.217:8081}
--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商 --- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商
# https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用 # https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用
sms: sms:
@@ -227,9 +273,9 @@ sms:
# 有些称为accessKey有些称之为apiKey,也有称为sdkKey或者appId。 # 有些称为accessKey有些称之为apiKey,也有称为sdkKey或者appId。
access-key-id: 您的accessKey access-key-id: 您的accessKey
# 称为accessSecret有些称之为apiSecret # 称为accessSecret有些称之为apiSecret
access-key-secret: 您的accessKeySecret access-key-secret: 9jea9cuubXCEWlMXEd48ye2FCWQQPf
signature: 您的短信签名 signature: 深圳市聚心时代科技
sdk-app-id: 您的sdkAppId sdk-app-id: LTAI5t8c9LHzr2oUVoKw5yEX
config2: config2:
# 厂商标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分 # 厂商标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
supplier: tencent supplier: tencent
@@ -238,6 +284,18 @@ sms:
signature: 您的短信签名 signature: 您的短信签名
sdk-app-id: 您的sdkAppId sdk-app-id: 您的sdkAppId
--- # 阿里云国际短信(SendMessageToGlobe,向境外号码发送)
aliyun:
international-sms:
# 是否启用
enabled: true
# 阿里云国际站 AccessKey(建议用环境变量注入,勿硬编码)
access-key-id: LTAI5t8c9LHzr2oUVoKw5yEX
access-key-secret: 9jea9cuubXCEWlMXEd48ye2FCWQQPf
# 国际短信接入点,固定为新加坡节点
endpoint: dysmsapi.ap-southeast-1.aliyuncs.com
# 发件人标识 SenderID(部分国家/地区需报备,可留空)
sender-id: ''
--- # 三方授权 --- # 三方授权
justauth: justauth:

View File

@@ -209,6 +209,60 @@ mail:
# Socket连接超时值,单位毫秒,缺省值不超时 # Socket连接超时值,单位毫秒,缺省值不超时
connectionTimeout: 0 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:
app:
# ---------------- APP 邮箱验证码(邮箱注册 / 邮箱登录 / 邮箱找回密码) ----------------
email:
# 【必须为 false】生产环境开启 mock 会把邮箱验证码明文写入日志,
# 此时 org.dromara.device.config.AppEmailProdGuard 将直接拒绝启动(属预期保护)。
mock: false
# 验证码位数 / 有效期(分钟)/ 重发间隔(秒)
code-length: 6
expiry-minutes: 5
resend-seconds: 60
# 允许注册的邮箱域名白名单,留空(或不配)表示不限制域名。
# 若产品要求「只允许谷歌邮箱注册」,取消下行注释即可,无需改代码:
# allowed-domains: gmail.com,googlemail.com
sms:
mock: false
blend: config1
template-id: ${DEVICE_APP_SMS_TEMPLATE_ID:}
mjpeg:
# 生产环境不提供私网默认值,必须注入可达地址。
public-host: ${MJPEG_PUBLIC_HOST:}
--- # 阿里云国际短信(与 application-dev.yml 同通道)
aliyun:
international-sms:
enabled: true
access-key-id: ${ALIYUN_INTL_SMS_AK:}
access-key-secret: ${ALIYUN_INTL_SMS_SK:}
endpoint: dysmsapi.ap-southeast-1.aliyuncs.com
sender-id: ''
--- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商 --- # sms 短信 支持 阿里云 腾讯云 云片 等等各式各样的短信服务商
# https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用 # https://sms4j.com/doc3/ 差异配置文档地址 支持单厂商多配置,可以配置多个同时使用
sms: sms:

View File

@@ -14,7 +14,25 @@ server:
min: 8 min: 8
# 最大线程数 # 最大线程数
max: 256 max: 256
zlm:
host: ${ZLM_HOST:http://47.104.136.221:8090} # ZLMediaKit 地址
rtmp-host: ${ZLM_RTMP_HOST:rtmp://47.104.136.221:1935}
rtsp-host: ${ZLM_RTSP_HOST:rtsp://47.104.136.221:554}
webrtc-host: ${ZLM_WEBRTC_HOST:http://47.104.136.221:8090}
secret: ${ZLM_SECRET:D5QbzhEkO8X0cdxMnIwj5USsCIfSKcOq} # ZLM webhook 密钥:不落明文,必须通过环境变量 ZLM_SECRET 注入;未配置时 /zlm/hook/** 回调一律拒绝(失败关闭)
app: live
schema: rtmp
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: captcha:
# 是否启用验证码校验 # 是否启用验证码校验
enable: true enable: true
@@ -67,9 +85,12 @@ spring:
servlet: servlet:
multipart: multipart:
# 单个文件大小 # 单个文件大小
max-file-size: 10MB # APP 客户端安装包(apk)体积通常数十 MB,10MB 会被 Spring 直接拒绝,故放大到 200MB。
# 设置总上传的文件大小 # 注意:若 application-dev.yml / -prod.yml / -local.yml 另配了同名项会**就近覆盖**本值,
max-request-size: 20MB # 修改本项后须同步确认这三份 profile 配置无覆盖(当前均无覆盖,仅 prod 配了 multipart.location)。
max-file-size: 200MB
# 设置总上传的文件大小(= 单文件上限 + form 其它字段冗余)
max-request-size: 210MB
mvc: mvc:
# 设置静态资源路径 防止所有请求都去查静态资源 # 设置静态资源路径 防止所有请求都去查静态资源
static-path-pattern: /static/** static-path-pattern: /static/**
@@ -111,6 +132,8 @@ security:
- /warm-flow-ui/config - /warm-flow-ui/config
- /snail-chat/** - /snail-chat/**
- /api/snail/chat/** - /api/snail/chat/**
- /zlm/hook/**
- /mjpeg/**
# MyBatisPlus配置 # MyBatisPlus配置
# https://baomidou.com/config/ # https://baomidou.com/config/
@@ -264,15 +287,17 @@ mqtt.client:
# 是否开启客户端,默认:true # 是否开启客户端,默认:true
enabled: true enabled: true
# 连接的服务端 ip ,默认:127.0.0.1 # 连接的服务端 ip ,默认:127.0.0.1
ip: ssl://www.mmipco.cn ip: 47.97.217.123
# 端口:默认:1883 # 端口:默认:1883
port: 8883 port: 1883
# 客户端名称 # 客户端名称
name: Mqtt-Client name: Mqtt-Client
# 客户端Id(非常重要,一般为设备 sn,不可重复) # 客户端Id(非常重要,一般为设备 sn,不可重复)
client-id: 000001 client-id: bird_monitor
username: admin # MQTT broker 账号/密码:不落明文,通过环境变量注入(避免凭据进入 git 历史)。
password: 61e6062129e9 # 联调前 export MQTT_USERNAME / MQTT_PASSWORD;未配置时取空值,broker 会拒绝认证。
username: ${MQTT_USERNAME:admin}
password: ${MQTT_PASSWORD:61e6062129e9}
# 超时时间,单位:秒,默认:5秒 # 超时时间,单位:秒,默认:5秒
timeout: 5 timeout: 5
# 重连时间,默认 5000 毫秒 # 重连时间,默认 5000 毫秒
@@ -291,12 +316,38 @@ mqtt.client:
mqtt-executor-size: 2 mqtt-executor-size: 2
# 是否开启 ssl # 是否开启 ssl
ssl: ssl:
enabled: true enabled: false
keystore-path: keystore-path:
keystore-pass: keystore-pass:
truststore-path: truststore-path:
truststore-pass: truststore-pass:
--- # 设备域通信链路配置(《设备通信协议-一期.md》口径)
# 全部项均有代码默认值,此处显式列出仅为便于运维调参;不需要调时整段删除也不影响启动。
device.mqtt:
# 离线扫描总开关。关闭后设备不会再被自动置离线,仅用于联调排障,生产必须为 true
scan-enabled: true
# 离线扫描间隔(上一轮跑完再计时,不会因扫描耗时堆积)
offline-scan-interval: 30s
# 离线判定阈值:超过该时长无遥测即判离线(协议 Q2:上报周期 60s × 2 容错)
offline-threshold: 120s
# 单轮扫描的设备上限,防止异常时一次性拉爆内存
scan-batch-size: 500
# 低电告警触发阈值(≤ 该值触发)与恢复阈值(≥ 该值恢复),两者之间的区间为滞回防抖区
low-battery-threshold: 20
low-battery-recover-threshold: 25
# 低电滞回闩锁存活时长,仅作已注销设备的残留清理兜底
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(仅用于事后定位「回执晚到」)
pending-ack-ttl: 10m
--- # elasticsearch 功能配置 --- # elasticsearch 功能配置
# 文档地址: https://www.easy-es.cn/ # 文档地址: https://www.easy-es.cn/
# 更改包名需要去 EasyEsConfiguration 修改包扫描(后续版本支持配置文件读取) # 更改包名需要去 EasyEsConfiguration 修改包扫描(后续版本支持配置文件读取)

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,192 @@
package org.dromara.system.api;
import org.dromara.common.core.domain.PageResult;
import org.dromara.system.api.domain.AppUserDTO;
import org.dromara.system.api.domain.AppUserQueryDTO;
/**
* APP 用户(C 端)领域服务对外暴露的 API。
* <p>
* 设计口径(迭代 I2 边界裁定 · 方案 A):
* <ul>
* <li>本接口仅面向设备域({@code ruoyi-device})等业务模块,用于 APP 账号闭环(注册/登录/改密/换绑/注销)。</li>
* <li><b>密码绝不跨模块边界</b>:本接口不返回、不接收任何已加密的密码散列;
* 校验密码由 {@link #matchesPassword(Long, String)} 在 {@code ruoyi-system} 内部完成并只回传布尔值,
* 写入密码由 {@link #registerByPhone(String, String, String)} / {@link #resetPassword(Long, String)}
* 在 {@code ruoyi-system} 内部完成散列(BCrypt)后落库。</li>
* <li>本接口为<b>纯新增</b>,不改动既有 {@link UserService} 及 {@code ruoyi-system} 中任何既有方法/行为。</li>
* <li>复用 {@code sys_user} 表,C 端账号 {@code user_type = app_user};不新增表、不新增字段。</li>
* </ul>
*
* @author Kou
* @date 2026-09-16
*/
public interface AppUserApiService {
/**
* 按手机号查询 APP 用户(仅匹配 {@code user_type = app_user} 且未逻辑删除的账号)。
*
* @param phoneNumber 手机号
* @return APP 用户信息(不含密码);不存在时返回 {@code null}
*/
AppUserDTO selectByPhone(String phoneNumber);
/**
* 判断手机号是否已被注册为 APP 用户(仅匹配 {@code user_type = app_user})。
*
* @param phoneNumber 手机号
* @return true 已注册;false 未注册
*/
boolean existsByPhone(String phoneNumber);
/**
* 校验用户密码是否匹配。
* <p>
* 明文密码仅在本方法内部比对(BCrypt),比较结果以布尔值回传,
* 密码散列与明文均<b>不</b>跨越模块边界。
*
* @param userId 用户主键
* @param rawPassword 明文密码
* @return true 匹配;false 不匹配或用户不存在
*/
boolean matchesPassword(Long userId, String rawPassword);
/**
* 以手机号注册一个 APP 用户。
* <p>
* 账号用户名取手机号;密码在本方法内部散列后落库。
*
* @param phoneNumber 手机号
* @param rawPassword 明文密码
* @param nickName 昵称(为空时默认生成 {@code user_ + 当前时间戳})
* @return 新建用户主键
*/
Long registerByPhone(String phoneNumber, String rawPassword, String nickName);
/**
* 按邮箱查询 APP 用户(仅匹配 {@code user_type = app_user} 且未逻辑删除的账号)。
* <p>
* 邮箱比对口径:调用方须先做「去空白 + 转小写」归一化(见
* {@code org.dromara.device.email.AppEmailService#normalize}),本方法按传入值精确匹配。
*
* @param email 邮箱地址(已归一化)
* @return APP 用户信息(不含密码);不存在时返回 {@code null}
*/
AppUserDTO selectByEmail(String email);
/**
* 判断邮箱是否已被注册为 APP 用户(仅匹配 {@code user_type = app_user})。
*
* @param email 邮箱地址(已归一化)
* @return true 已注册;false 未注册
*/
boolean existsByEmail(String email);
/**
* 更新 APP 用户邮箱;仅更新邮箱字段,昵称保持不变。
*
* @param userId 用户主键
* @param newEmail 新邮箱地址(已归一化)
* @return true 修改成功
*/
boolean updateEmail(Long userId, String newEmail);
/**
* 以邮箱注册一个 APP 用户(邮箱注册入口)。
* <p>
* <b>不写手机号</b>:本入口创建的账号 {@code phone_number} 为 {@code NULL}
* (依赖 {@code script/sql/bm_patch.sql} 补丁 3 把该列默认值改为 NULL,
* 否则 MyBatis-Plus 的 {@code insertStrategy: NOT_NULL} 会省略该列、落库为 {@code ''},
* 导致第二个邮箱账号撞 {@code uk_sys_user_phone_number})。账号须在后续流程中绑定手机号。
* <p>
* 密码在本方法内部散列后落库。
*
* @param email 邮箱地址(已归一化)
* @param rawPassword 明文密码
* @param nickName 昵称(为空时默认生成 {@code user_ + 当前时间戳})
* @return 新建用户主键
*/
Long registerByEmail(String email, String rawPassword, String nickName);
/**
* 重置用户密码(忘记密码场景)。
*
* @param userId 用户主键
* @param rawPassword 新明文密码
* @return true 修改成功
*/
boolean resetPassword(Long userId, String rawPassword);
/**
* 换绑手机号;仅更新手机号字段,昵称保持不变。
*
* @param userId 用户主键
* @param newPhoneNumber 新手机号
* @return true 修改成功
*/
boolean updatePhone(Long userId, String newPhoneNumber);
/**
* 停用账号(注销前置:解绑设备后停用)。
*
* @param userId 用户主键
* @return true 停用成功
*/
boolean disableUser(Long userId);
/**
* 判断账号是否已绑定手机号。
* <p>
* 供设备域做「邮箱注册后仍须绑手机号」的服务端硬拦截使用
* (未绑手机号的账号不允许绑定设备)。
*
* @param userId 用户主键
* @return true 已绑定非空手机号;false 未绑定或用户不存在
*/
boolean isPhoneBound(Long userId);
// ---------------- 后台管理端(APP 用户管理) ----------------
/**
* 分页查询 APP 用户列表(后台「APP 用户管理」用)。
* <p>
* <b>强制口径</b>:实现侧固定附加 {@code user_type = app_user} 条件,
* 调用方<b>无法</b>通过 {@link AppUserQueryDTO} 覆盖该条件,
* 因此本方法<b>恒不返回</b>后台账号({@code sys_user})。
* <p>
* <b>数据权限</b>:APP 用户注册时不写 {@code dept_id}(该列恒为 {@code NULL}),
* 不参与后台部门树,故实现侧<b>刻意不加</b>框架 {@code @DataPermission} 过滤——
* 否则按部门维度过滤会把全部 APP 用户过滤为空。
*
* @param query 查询条件(关键词 / 状态 / 注册时间区间 / 分页)
* @return APP 用户分页结果(不含密码)
*/
PageResult<AppUserDTO> selectAppUserPage(AppUserQueryDTO query);
/**
* 按主键查询 APP 用户详情(后台「APP 用户管理」用)。
* <p>
* 同样强制 {@code user_type = app_user}:传入后台账号主键时返回 {@code null},
* 避免管理端越界读到系统账号信息。
*
* @param userId 用户主键
* @return APP 用户信息(不含密码);不存在或非 app_user 时返回 {@code null}
*/
AppUserDTO selectAppUserById(Long userId);
/**
* 修改 APP 用户账号状态(停用 / 启用)。
* <p>
* 与 {@link #disableUser(Long)} 的区别:本方法同时支持<b>恢复启用</b>,
* 供后台管理端做双向状态切换;{@code disableUser} 保持「注销前置」单向语义不变。
* <p>
* <b>安全约束</b>:实现侧限定 {@code user_type = app_user},
* 防止通过本方法改动后台账号状态。
*
* @param userId 用户主键
* @param status 目标状态({@code 0} 正常 / {@code 1} 停用)
* @return true 修改成功(含目标状态与当前一致的情形)
*/
boolean updateStatus(Long userId, String status);
}

View File

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

View File

@@ -0,0 +1,75 @@
package org.dromara.system.api.domain;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* APP 用户传输对象。
* <p>
* 面向 APP 账号闭环的对外 DTO,<b>刻意不包含密码字段</b>,
* 以确保密码散列不会跨越模块边界(见 {@link org.dromara.system.api.AppUserApiService})。
*
* @author Kou
* @date 2026-09-16
*/
@Data
@NoArgsConstructor
public class AppUserDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 用户ID
*/
private Long userId;
/**
* 用户账号(APP 账号取手机号)
*/
private String userName;
/**
* 用户昵称
*/
private String nickName;
/**
* 手机号码
*/
private String phoneNumber;
/**
* 用户邮箱
* <p>
* 仅邮箱注册的账号非空;手机号注册的账号为 {@code null}。
*/
private String email;
/**
* 用户类型(固定为 app_user)
*/
private String userType;
/**
* 账号状态(0 正常 1 停用)
*/
private String status;
/**
* 创建时间(注册时间)
*/
private LocalDateTime createTime;
/**
* 更新时间
* <p>
* 供后台「APP 用户管理」展示「最近变更时间」(改密 / 换绑 / 停用等均会刷新该值)。
*/
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,69 @@
package org.dromara.system.api.domain;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
/**
* APP 用户管理查询条件(后台管理端使用)。
* <p>
* <b>为什么单独建一个 DTO 而不是复用 {@code SysUserBo} + {@code PageQuery}</b>:
* {@code ruoyi-api} 模块仅依赖 {@code ruoyi-common-core}(见 {@code ruoyi-api/pom.xml}),
* 而 {@code PageQuery} 位于 {@code ruoyi-common-mybatis},<b>在本模块不可见</b>。
* 因此分页参数直接内联为 {@code pageNum} / {@code pageSize},由 {@code ruoyi-system} 侧
* 组装成框架 {@code Page},从而在不放松模块依赖边界的前提下完成跨模块分页契约。
* <p>
* <b>日期区间用 {@link #params} 而非平铺字段</b>:与框架既有约定保持一致——
* 前端 {@code utils/ruoyi.ts#addDateRange}(经 {@code useDateRangeQuery} hook 调用)
* 把区间写入 {@code params.beginTime} / {@code params.endTime},
* 后端 {@code betweenParams(column, params, "beginTime", "endTime")} 消费,
* 故此处必须保留同名的 {@code params} 载体,前端才能直接复用现成 hook。
* <p>
* <b>不含 {@code userType}</b>:本查询只为 APP 用户({@code user_type = app_user})服务,
* 该条件由实现侧强制附加、不可由调用方覆盖——避免调用方漏传而把后台账号一并查出。
*
* @author Kou
* @date 2026-09-17
*/
@Data
@NoArgsConstructor
public class AppUserQueryDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 关键词:模糊匹配 user_name / nick_name / phone_number / email(四字段 OR)。
*/
private String keyword;
/**
* 账号状态(0 正常 1 停用);留空不参与过滤。
*/
private String status;
/**
* 扩展查询参数。
* <p>
* 约定键:{@code beginTime} / {@code endTime}——注册时间区间(闭区间)。
* 两者<b>均非空</b>时才生效(与框架 {@code betweenParams} 口径一致)。
* 注意:框架<b>不做</b>「日期补 23:59:59」的隐式处理,前端选「日期」而非「日期时间」时
* 需自行补全时分秒,否则会漏掉结束日当天数据。
*/
private Map<String, Object> params = new HashMap<>();
/**
* 页码(从 1 开始);为空时按 1 处理。
*/
private Integer pageNum;
/**
* 每页条数;为空时按 10 处理。
*/
private Integer pageSize;
}

View File

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

@@ -77,6 +77,15 @@ public class MybatisPlusConfig {
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(); PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
// 分页合理化 // 分页合理化
paginationInnerInterceptor.setOverflow(true); paginationInnerInterceptor.setOverflow(true);
// 单页上限 —— 兜住两种「一个请求打爆数据库」的姿势:
// 1) 不传 pageSize:PageQuery#build 会回落成 DEFAULT_PAGE_SIZE = Integer.MAX_VALUE,
// 等于「不传就取全表」,且此前 maxLimit 未设,没有任何兜底;
// 2) 显式传超大 pageSize(如 ?pageSize=999999999)。
// 取值 1000 的依据:当前最大单页需求是 200(设备型号候选去重),
// 需要全量的场景(Excel 导出)走的是不分页查询,不受本上限约束。
// ⚠️ 副作用:超过 1000 的请求会被**静默改写**(MyBatis-Plus 不抛错)。
// 因此**需要全量数据时请用不分页查询,不要靠「不传 pageSize」**。
paginationInnerInterceptor.setMaxLimit(1000L);
return paginationInnerInterceptor; return paginationInnerInterceptor;
} }

View File

@@ -54,7 +54,14 @@ public class PageQuery implements Serializable {
public static final int DEFAULT_PAGE_NUM = 1; public static final int DEFAULT_PAGE_NUM = 1;
/** /**
* 每页显示记录数 默认值 默认查全部 * 每页显示记录数 默认值:**不传 pageSize 时回落值**。
* <p>
* ⚠️ 这里是 {@link Integer#MAX_VALUE},字面语义是「查全部」,但**实际不会真的全表返回** ——
* {@code MybatisPlusConfig#paginationInnerInterceptor} 设了 {@code maxLimit = 1000},
* MyBatis-Plus 会把超限的 size 静默改写掉。原注释「默认查全部」有误导性,已订正。
* <p>
* 另外 {@link #getFirstNum()} 用本值算偏移量,{@code (pageNum - 1) * MAX_VALUE}
* 在 {@code pageNum > 1} 时会**整型溢出** —— 该方法目前无调用方,但若要启用必须先收口。
*/ */
public static final int DEFAULT_PAGE_SIZE = Integer.MAX_VALUE; public static final int DEFAULT_PAGE_SIZE = Integer.MAX_VALUE;

View File

@@ -23,11 +23,27 @@
<artifactId>sms4j-spring-boot-starter</artifactId> <artifactId>sms4j-spring-boot-starter</artifactId>
</dependency> </dependency>
<!-- 阿里云国际短信 SDK -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20180501</artifactId>
</dependency>
<!-- 阿里云 OpenAPI 基础包(提供 com.aliyun.teaopenapi.models.Config,显式声明避免仅靠传递依赖) -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>tea-openapi</artifactId>
</dependency>
<!-- RuoYi Common Redis--> <!-- RuoYi Common Redis-->
<dependency> <dependency>
<groupId>org.dromara</groupId> <groupId>org.dromara</groupId>
<artifactId>ruoyi-common-redis</artifactId> <artifactId>ruoyi-common-redis</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
</dependencies> </dependencies>

View File

@@ -0,0 +1,37 @@
package org.dromara.common.sms.config;
import com.aliyun.dysmsapi20180501.Client;
import com.aliyun.teaopenapi.models.Config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
/**
* 阿里云国际短信自动配置
*
* @author ruoyi
*/
@Slf4j
@AutoConfiguration
@EnableConfigurationProperties(AliyunInternationalSmsProperties.class)
@ConditionalOnProperty(prefix = "aliyun.international-sms", name = "enabled", havingValue = "true")
public class AliyunInternationalSmsAutoConfiguration {
/**
* 构建阿里云国际短信客户端
*
* @param properties 国际短信配置
* @return 短信客户端
*/
@Bean
public Client dysmsapi20180501Client(AliyunInternationalSmsProperties properties) throws Exception {
Config config = new Config()
.setAccessKeyId(properties.getAccessKeyId())
.setAccessKeySecret(properties.getAccessKeySecret());
config.endpoint = properties.getEndpoint();
return new Client(config);
}
}

View File

@@ -0,0 +1,40 @@
package org.dromara.common.sms.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 阿里云国际短信配置属性
*
* @author ruoyi
*/
@Data
@ConfigurationProperties(prefix = "aliyun.international-sms")
public class AliyunInternationalSmsProperties {
/**
* 是否启用
*/
private Boolean enabled = false;
/**
* 阿里云国际站 AccessKeyId
*/
private String accessKeyId;
/**
* 阿里云国际站 AccessKeySecret
*/
private String accessKeySecret;
/**
* 接入点,国际短信固定为 dysmsapi.ap-southeast-1.aliyuncs.com
*/
private String endpoint = "dysmsapi.ap-southeast-1.aliyuncs.com";
/**
* 发件人标识 SenderId(部分国家/地区需报备)
*/
private String senderId;
}

View File

@@ -0,0 +1,67 @@
package org.dromara.common.sms.core;
import com.aliyun.dysmsapi20180501.Client;
import com.aliyun.dysmsapi20180501.models.SendMessageToGlobeRequest;
import com.aliyun.dysmsapi20180501.models.SendMessageToGlobeResponse;
import com.aliyun.dysmsapi20180501.models.SendMessageToGlobeResponseBody;
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;
/**
* 阿里云国际短信发送服务
*
* @author ruoyi
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AliyunInternationalSmsService {
private final Client dysmsapi20180501Client;
private final AliyunInternationalSmsProperties properties;
/**
* 向境外手机号发送短信
*
* @param to 国际号码,带国家码且不含 + / 00 前缀,例如 62895111666
* @param message 短信内容(国际短信可自由编辑)
* @return 发送结果
*/
public SendMessageToGlobeResponseBody sendMessageToGlobe(String to, String message) {
String maskedTo = DesensitizedUtils.maskHighSecurity(to, 3, 2);
// 国际短信正文包含验证码,日志只保留脱敏后的目标号码,不记录验证码。
log.info("国际短信发送开始, 手机号={}", maskedTo);
SendMessageToGlobeRequest request = new SendMessageToGlobeRequest()
.setTo(to)
.setMessage("您的验证码为:" + message + ",请勿泄露于他人!");
if (StringUtils.hasText(properties.getSenderId())) {
request.setChannelId(properties.getSenderId());
}
try {
SendMessageToGlobeResponse response = dysmsapi20180501Client
.sendMessageToGlobeWithOptions(request, new RuntimeOptions());
SendMessageToGlobeResponseBody body = response.getBody();
if (body == null || !"OK".equalsIgnoreCase(body.getResponseCode())) {
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={}, error={}",
maskedTo, e.getClass().getSimpleName());
// 底层异常可能回显请求参数,不向客户端透传供应商原文。
throw new ServiceException("国际短信发送异常");
}
}
}

View File

@@ -1 +1,2 @@
org.dromara.common.sms.config.SmsAutoConfiguration org.dromara.common.sms.config.SmsAutoConfiguration
org.dromara.common.sms.config.AliyunInternationalSmsAutoConfiguration

View File

@@ -22,6 +22,7 @@
<module>ruoyi-system</module> <module>ruoyi-system</module>
<module>ruoyi-workflow</module> <module>ruoyi-workflow</module>
<module>ruoyi-ai</module> <module>ruoyi-ai</module>
<module>ruoyi-device</module>
</modules> </modules>
</project> </project>

View File

@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-modules</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>ruoyi-device</artifactId>
<description>
设备模块(智能飞禽物联平台 · 设备域)
</description>
<dependencies>
<!-- 通用工具 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-core</artifactId>
</dependency>
<!-- api模块(复用 UserService 等) -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-api</artifactId>
</dependency>
<!-- web服务 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-web</artifactId>
</dependency>
<!-- 数据库服务 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-mybatis</artifactId>
</dependency>
<!-- 安全模块 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-security</artifactId>
</dependency>
<!-- 日志记录 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-log</artifactId>
</dependency>
<!-- 缓存服务 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-redis</artifactId>
</dependency>
<!-- excel -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-excel</artifactId>
</dependency>
<!-- OSS功能模块(OTA 升级包上传) -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-oss</artifactId>
</dependency>
<!-- 短信模块 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-sms</artifactId>
</dependency>
<!-- 翻译模块 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-translation</artifactId>
</dependency>
<!-- 脱敏模块 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-sensitive</artifactId>
</dependency>
<!-- 接口模块 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-doc</artifactId>
</dependency>
<!-- mqtt模块 -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-mqtt</artifactId>
</dependency>
<!-- JSON 序列化(MQTT payload) -->
<dependency>
<groupId>org.dromara</groupId>
<artifactId>ruoyi-common-json</artifactId>
</dependency>
<!-- 邮件模块(APP 邮箱验证码发信:MailBuilder + Gmail SMTP) -->
<dependency>
<groupId>org.dromara</groupId>
<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

@@ -0,0 +1,74 @@
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;
/**
* APP 邮箱验证码生产环境守卫(口径镜像 {@link AppSmsProdGuard})。
* <p>
* <b>背景</b>:{@code device.app.email.mock} 默认 {@code true},会把邮箱验证码<b>明文写入应用日志</b>。
* 开发/联调期这是便利(Gmail 凭据与出海网络尚未就绪),但生产环境误开 mock 等价于把验证码落盘。
* <p>
* <b>策略</b>:
* <ul>
* <li>激活 profile 含 {@code prod} 且 {@code mock=true} → 抛 {@link IllegalStateException},<b>拒绝启动</b>;</li>
* <li>非 prod 且 {@code mock=true} → 打印 WARN,提示验证码将入日志;</li>
* <li>prod 且 {@code mock=false} 但未配置发信账号/凭据 → 抛异常拒绝启动,
* 避免「上线后才发现邮件发不出去」这类运行时才暴露的问题。</li>
* </ul>
*
* @author Kou
* @date 2026-09-17
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AppEmailProdGuard implements InitializingBean {
/**
* 生产环境 profile 名(对应 {@code application-prod.yml})。
*/
private static final String PROD_PROFILE = "prod";
private final AppProperties appProperties;
private final GmailProperties gmailProperties;
private final Environment environment;
/**
* 校验「生产环境不得开启邮箱 mock」并检查 Gmail 真实发信配置。
*/
@Override
public void afterPropertiesSet() {
AppProperties.Email email = appProperties.getEmail();
boolean prod = environment.matchesProfiles(PROD_PROFILE);
if (email.isMock()) {
if (prod) {
throw new IllegalStateException(
"生产环境(prod)禁止开启 device.app.email.mock(当前为 true)——该模式会把邮箱验证码明文写入日志。"
+ "请在 application-prod.yml 中设置 device.app.email.mock=false 并配置 Gmail 发信账号与凭据后重启。");
}
log.warn("[APP邮箱] device.app.email.mock=true:验证码将写入应用日志,仅限开发/测试环境使用;"
+ "生产环境(prod)将因此拒绝启动。");
return;
}
// 已关闭 mock(走真实 Gmail):此处**只告警不阻断启动**。
// 取舍说明:mock 开关误开会导致验证码明文落盘(安全风险)→ 必须拒绝启动;
// 而凭据缺失只影响「邮件发不出去」这一个功能(可用性风险),若也拒绝启动,
// 会让「邮箱功能未配置」升级为「整个服务起不来」,把一个局部问题放大成全站故障。
// 故降级为醒目告警,把失败推迟到发信那一刻,并由 AppEmailService 返回受控文案。
if (!Boolean.TRUE.equals(gmailProperties.getEnabled()) || !gmailProperties.isConfigured()) {
log.warn("[APP邮箱] 已关闭 mock(要求真实发信)但 Gmail 功能未启用或账号/凭据未配置——"
+ "邮箱验证码将无法送达!请通过环境变量 GMAIL_USER / GMAIL_PASS 注入 Gmail 地址与 16 位应用专用密码。");
return;
}
log.info("[APP邮箱] Gmail 真实发信已启用:host={}:{},from={},ssl={},starttls={},连接超时={}ms。",
gmailProperties.getHost(), gmailProperties.getPort(), gmailProperties.getFrom(),
gmailProperties.getSslEnable(), gmailProperties.getStarttlsEnable(),
gmailProperties.getConnectionTimeout());
}
}

View File

@@ -0,0 +1,266 @@
package org.dromara.device.config;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* APP 端(C 端)配置项。
* <p>
* 全部字段均带默认值,未在 {@code application.yml} 显式配置时按默认值生效,因此本迭代无需改动任何 yml。
* <p>
* <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》§七。
*
* @author Kou
* @date 2026-09-16
*/
@Data
@Component
@ConfigurationProperties(prefix = "device.app")
public class AppProperties {
/**
* APP 登录客户端身份(镜像 sys_client 的 app 记录)。
*/
private Client client = new Client();
/**
* APP 短信验证码策略。
*/
private Sms sms = new Sms();
/**
* APP 邮箱验证码策略;真实发信统一由 common-mail 的 {@code gmail.*} 配置负责。
*/
private Email email = new Email();
/**
* APP 协议(用户协议 / 隐私政策)内容来源。
*/
private Agreement agreement = new Agreement();
/**
* 客户端身份配置。
*/
@Data
public static class Client {
/**
* APP 请求头 {@code clientid} 的取值(对应配置项 {@code device.app.client.client-id})。
* <p>
* 框架中该请求头名即 {@code LoginHelper.CLIENT_KEY = "clientid"};APP 登录时本值会写入 token 扩展,
* 框架据此校验「请求头 clientid == 令牌扩展 clientid」({@code SecurityConfig:98-106}),
* 因此 APP 端每次请求须携带请求头 {@code clientid: app}。
* <p>
* <b>注意(已知偏差,非笔误)</b>:本值 ≠ {@code sys_client.client_id}。
* 框架的 {@code sys_client.client_id = md5(client_key + client_secret)}(见 {@code SysClientServiceImpl}),
* 而本值当前为自建的 {@code "app"},即 {@code client_key},并非其 md5。
* 之所以自洽:本链路<b>不查 {@code sys_client}</b>,token 扩展中的 clientid 由本配置直接写入,框架只做
* 「请求头 vs 令牌扩展」的自比对,故两端一致即可通过。
* <p>
* <b>后果</b>:后台对 {@code sys_client} 中 app 记录的编辑/停用({@code status}/{@code grant_type}/
* {@code access_path})<b>不影响</b> APP 链路,因为 APP 链路不读取该记录;如后续需与 PC 完全对齐,
* 将本项配置为 app 记录的 {@code client_id}(md5)即可,代码无需改动。
*/
private String clientId = "app";
/**
* 允许访问路径(写入 token extra,由框架 SecurityConfig 校验)。
*/
private String accessPath = "/app/**";
/**
* 设备类型。
*/
private String deviceType = "android";
/**
* token 固定超时时间(秒),默认 30 天;活跃超时仍由 {@link #activeTimeout} 独立控制。
*/
private Long timeout = 2592000L;
/**
* token 活跃超时时间(秒),默认 30 天;启用动态活跃超时时会在请求后续期。
*/
private Long activeTimeout = 2592000L;
}
/**
* 短信验证码策略配置。
*/
@Data
public static class Sms {
/**
* 是否走模拟发送(true 时仅打印日志,便于开发环境无短信通道时联调,见 Q5)。
* <p>
* <b>生产安全(I2 遗留项收口)</b>:本项默认 true 仅服务开发/联调;<b>生产环境(prod)不得为 true</b>——
* {@link AppSmsProdGuard} 会在激活 profile 含 {@code prod} 且本值为 true 时<b>拒绝启动</b>,
* 以避免验证码明文落盘。生产部署请在 {@code application-prod.yml} 置为 false 并配置真实通道。
*/
private boolean mock = true;
/**
* sms4j 通道标识(mock=false 时使用)。
*/
private String blend = "config1";
/**
* sms4j 模板 id(mock=false 时使用,国内短信)。
*/
private String templateId = "";
/**
* 验证码位数(APP 固定 6 位)。
*/
private int codeLength = 6;
/**
* 验证码有效期(分钟)。
*/
private int expiryMinutes = 5;
/**
* 重发间隔(秒),即 60 秒防刷。
*/
private int resendSeconds = 60;
}
/**
* 邮箱验证码策略配置。
* <p>
* <b>发信口径</b>:真实邮件统一调用 {@code GmailCodeService},账号、凭据、SMTP 主机和超时从
* {@code gmail.*} 读取;本类中的旧 SMTP 字段仅为兼容历史配置保留,APP 业务不再读取。
* <p>
* <b>谷歌邮箱(Gmail)口径</b>:主机固定 {@code smtp.gmail.com}、端口 465(SSL)。
* Gmail 已停用「低安全性应用访问」,{@link #pass} 必须填 Google 账号生成的
* <b>16 位应用专用密码</b>(非账号登录密码),且 {@link #from} 必须等于 {@link #user},
* 否则 Gmail 会以「发件人与认证账号不一致」拒收。
* <p>
* <b>网络前提(部署必读)</b>:{@code smtp.gmail.com} 需服务器可直连 Google。
* 国内机房通常不可达,此时表现为连接超时——故 {@link #connectionTimeout} 与 {@link #timeout}
* 默认设为较小值,让发信快速失败而不是把 HTTP 请求线程挂死。
*/
@Data
public static class Email {
/**
* 是否走模拟发送(true 时<b>仅打印日志不真实发信</b>,便于无 SMTP 条件时联调)。
* <p>
* 与短信 {@link Sms#isMock()} 同口径:默认 true 仅服务开发/联调。
* <b>生产环境(prod)不得为 true</b>——{@link AppEmailProdGuard} 会在激活 profile 含 {@code prod}
* 且本值为 true 时<b>拒绝启动</b>,避免验证码明文落盘。
*/
private boolean mock = true;
/** 旧 SMTP 主机配置,仅为兼容历史配置保留,业务不再读取。 */
private String host = "smtp.gmail.com";
/** 旧 SMTP 端口配置,仅为兼容历史配置保留,业务不再读取。 */
private int port = 465;
/** 旧发信账号配置,仅为兼容历史配置保留,业务不再读取。 */
private String user = "";
/** 旧发信凭据配置,仅为兼容历史配置保留,业务不再读取。 */
private String pass = "";
/** 旧发件人配置,仅为兼容历史配置保留,业务不再读取。 */
private String from = "";
/** 旧 SSL 配置,仅为兼容历史配置保留,业务不再读取。 */
private boolean sslEnable = true;
/** 旧 STARTTLS 配置,仅为兼容历史配置保留,业务不再读取。 */
private boolean starttlsEnable = false;
/** 旧连接超时配置,仅为兼容历史配置保留,业务不再读取。 */
private int connectionTimeout = 5000;
/** 旧读写超时配置,仅为兼容历史配置保留,业务不再读取。 */
private int timeout = 10000;
/** 旧邮件主题配置,仅为兼容历史配置保留,业务不再读取。 */
private String subject = "智能飞禽物联平台 验证码";
/**
* 验证码位数(APP 固定 6 位)。
*/
private int codeLength = 6;
/**
* 验证码有效期(分钟)。
*/
private int expiryMinutes = 5;
/**
* 重发间隔(秒),即 60 秒防刷。
*/
private int resendSeconds = 60;
/**
* 允许注册的邮箱域名白名单,<b>留空 = 不限制域名</b>。
* <p>
* 若产品要求「只允许谷歌邮箱注册」,配置为 {@code gmail.com,googlemail.com} 即可,无需改代码。
*/
private List<String> allowedDomains = new ArrayList<>();
}
/**
* 协议配置。
*/
@Data
public static class Agreement {
/**
* 用户协议。
*/
private Item userAgreement = new Item();
/**
* 隐私政策。
*/
private Item privacyPolicy = new Item();
/**
* 单条协议内容(URL + 版本 + 更新时间)。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Item {
/**
* 协议 URL(或富文本地址)。
*/
private String url = "";
/**
* 协议版本。
*/
private String version = "v1.0";
/**
* 协议更新时间(如 2026-01-01)。
*/
private String updateTime = "";
}
}
}

View File

@@ -0,0 +1,57 @@
package org.dromara.device.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
/**
* APP 短信生产环境守卫(I2 遗留项收口)。
* <p>
* <b>背景</b>:{@code device.app.sms.mock} 默认 {@code true},会把短信验证码<b>明文写入应用日志</b>。
* 这在开发/联调期是便利(Q5 短信通道未定),但若生产环境误开 mock,等价于把验证码落盘,属安全风险。
* <p>
* <b>策略</b>:不修改默认值(开发仍需 mock),而是保证「<b>生产环境无法带 mock 启动</b>」——
* <ul>
* <li>当激活 profile 含 {@code prod} 且 {@code mock=true} → 抛 {@link IllegalStateException},<b>拒绝启动</b>,
* 迫使运维显式关闭 mock 并配置真实通道;</li>
* <li>非 prod 环境且 {@code mock=true} → 打印 WARN,提示验证码将入日志,仅限开发/测试。</li>
* </ul>
* 采用框架惯用的 {@link InitializingBean}(与本模块外 {@code AllUrlHandler} 等一致),在属性注入后、容器就绪前校验。
*
* @author Kou
* @date 2026-09-16
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AppSmsProdGuard implements InitializingBean {
/**
* 生产环境 profile 名(对应 {@code application-prod.yml})。
*/
private static final String PROD_PROFILE = "prod";
private final AppProperties appProperties;
private final Environment environment;
/**
* 校验「生产环境不得开启短信 mock」,否则拒绝启动。
*/
@Override
public void afterPropertiesSet() {
if (!appProperties.getSms().isMock()) {
// 已走真实通道,无需干预
return;
}
if (environment.matchesProfiles(PROD_PROFILE)) {
throw new IllegalStateException(
"生产环境(prod)禁止开启 device.app.sms.mock(当前为 true)——该模式会把短信验证码明文写入日志。"
+ "请在 application-prod.yml 中设置 device.app.sms.mock=false 并配置真实短信通道后重启。");
}
log.warn("[APP短信] device.app.sms.mock=true:验证码将写入应用日志,仅限开发/测试环境使用;"
+ "生产环境(prod)将因此拒绝启动。");
}
}

View File

@@ -0,0 +1,159 @@
package org.dromara.device.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* 设备 MQTT 运行时配置(前缀 {@code device.mqtt})。
* <p>
* 全部字段带默认值,默认值即《设备通信协议-一期.md》给出的工程默认口径,
* 因此<b>不配置也能跑</b>;需要调参时只在 {@code application-*.yml} 覆盖对应项即可,无需改代码。
* <p>
* 与 {@code AppProperties}(前缀 {@code device.app})的分工:本类只管「设备侧通信链路」的可调参数,
* APP 侧账号/短信/协议配置仍归 {@code AppProperties}。
*
* @author Kou
* @date 2026-09-16
*/
@Data
@Component
@ConfigurationProperties(prefix = "device.mqtt")
public class DeviceMqttProperties {
/**
* 是否启用离线扫描。
* <p>
* 关闭后不再自动置离线,设备将长期停留在「在线」——仅用于联调排障,生产必须为 {@code true}。
*/
private boolean scanEnabled = true;
/**
* 离线扫描间隔。
* <p>
* 采用 {@code fixedDelay} 语义(上一轮跑完再计时),因此扫描耗时不会造成任务堆积。
*/
private Duration offlineScanInterval = Duration.ofSeconds(30);
/**
* 离线判定阈值:超过该时长无遥测即判离线。
* <p>
* 协议默认 120s,对应「上报周期 60s × 2」的容错口径(见协议 §5.1、Q2)。
*/
private Duration offlineThreshold = Duration.ofSeconds(120);
/**
* 单轮扫描的设备上限,防止异常情况下一次性拉爆内存。
* <p>
* 命中数量达到该值时说明积压严重,日志会给出提示。
*/
private int scanBatchSize = 500;
/**
* 低电告警触发阈值(≤ 该值触发)。
*/
private int lowBatteryThreshold = 20;
/**
* 低电告警恢复阈值(≥ 该值恢复)。
* <p>
* 与 {@link #lowBatteryThreshold} 之间构成滞回区间,避免电量在阈值附近抖动时反复告警/恢复。
* <p>
* ⚠️ 引入<b>设备级阈值</b>后本字段的含义有所扩展:
* <ul>
* <li>设备未配置 / 历史行 → 直接作为恢复阈值使用(与原行为完全一致);</li>
* <li>设备已配置 → 仅取「本值 − {@link #lowBatteryThreshold}」的<b>差值</b>作为统一回差,
* 由 {@link #resolveRecoverThreshold(int)} 派生该设备的恢复阈值 ——
* 这样无需为设备级阈值新增配置项,滞回口径也不会各设备一套。</li>
* </ul>
*/
private int lowBatteryRecoverThreshold = 25;
/**
* 低电滞回闩锁的存活时长。
* <p>
* 闩锁本身无业务过期语义(低电态由恢复阈值解除),设置 TTL 只为兜底清理已注销设备的残留键;
* 若 TTL 到期时设备仍处于低电态,会补发一条告警——因此不宜设得太短。
*/
private Duration lowBatteryLatchTtl = Duration.ofDays(30);
/**
* 告警去重窗口。
* <p>
* 同一 {@code deviceNo + alertType} 在该窗口内只落一条告警。两个场景共用:
* 设备上报的低电事件(协议要求 30 分钟)与设备事件与服务端阈值判定的交叉去重。
*/
private Duration alertDedupWindow = Duration.ofMinutes(30);
/**
* APP 告警消息 outbox 补偿间隔。
* <p>
* 消息同步写入失败后,告警事实仍会保留;补偿任务按该间隔扫描并重试。
*/
private Duration appMessageRetryInterval = Duration.ofMinutes(1);
/**
* APP 告警消息单轮补偿上限,避免数据库恢复后瞬间形成写入洪峰。
*/
private int appMessageRetryBatchSize = 100;
/**
* 眼罩指令回执等待超时。
* <p>
* 超时即向 APP 返回「指令下发失败」({@code DeviceMsgConstants.COMMAND_SEND_FAILED})。
* 对应协议 §13 Q3:{@code coverOn} 下行后物理动作到位时长需硬件确认。
*/
private Duration hoodTimeout = Duration.ofSeconds(5);
/**
* OTA 指令回执等待超时。
* <p>
* 设备收到 {@code command/ota} 后应立即回 {@code ack} 表示<b>已受理</b>(协议 §7.2),
* 因此等待窗口短;超时即判该设备本次下发失败({@code DeviceMsgConstants.OTA_BATCH_FAIL_DISPATCH}),
* 任务落「失败」终态,不空悬。
*/
private Duration otaTimeout = Duration.ofSeconds(10);
/**
* 待回执指令标记在 Redis 中的存活时长(仅用于事后定位,不参与正确性判定)。
*/
private Duration pendingAckTtl = Duration.ofMinutes(10);
// ---------------- 低电阈值解析(设备级配置 → 生效值) ----------------
/**
* 解析生效的<b>触发阈值</b>:设备级配置优先,未配置则回落工程默认。
* <p>
* 设备级阈值由 DDL 保证 {@code NOT NULL DEFAULT 20},正常路径下 {@code deviceThreshold}
* 不会是 null;保留兜底是为兼容「补丁执行前写入的历史行」等边界情形。
* <p>
* <b>返回 0 表示对该设备关闭低电保护</b>,而非「阈值取 0」——调用方必须显式判 0,
* 不能直接写 {@code battery <= resolveTriggerThreshold(...)},否则会退化成
* 「电量恰好归零才告警」。目前两个消费点(低电告警判定、遮盖指令拦截)都已按此约定处理。
*
* @param deviceThreshold 设备上配置的阈值(可为 null)
* @return 生效的触发阈值(电量 ≤ 该值即触发低电)
*/
public int resolveTriggerThreshold(Integer deviceThreshold) {
return deviceThreshold == null ? lowBatteryThreshold : deviceThreshold;
}
/**
* 派生生效的<b>恢复阈值</b>:{@code 触发阈值 + 回差}。
* <p>
* 回差取自 {@link #lowBatteryThreshold} 与 {@link #lowBatteryRecoverThreshold} 之差
* (工程默认为 {@code 25 - 20 = 5}),使设备级阈值天然沿用同一滞回口径。
* <p>
* 回差下限保护为 1:若运维把两个默认值配成相等(回差为 0,等于没有滞回),
* 电量在阈值附近抖动时会反复告警/恢复,故强制至少留 1 个百分点。
*
* @param triggerThreshold 生效的触发阈值
* @return 生效的恢复阈值(严格大于 {@code triggerThreshold})
*/
public int resolveRecoverThreshold(int triggerThreshold) {
return triggerThreshold + Math.max(1, lowBatteryRecoverThreshold - lowBatteryThreshold);
}
}

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

@@ -0,0 +1,71 @@
package org.dromara.device.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* ZLMediaKit 接入配置(前缀 {@code zlm})。
* <p>
* 设备侧推流到 ZLM,APP 侧拉 HLS/FLV/WebRTC。地址与密钥一律走环境变量覆盖,
* 未配置时直播接口返回「ZLM 地址未配置」,不阻断应用启动。
*
* @author Kou
* @date 2026-09-17
*/
@Data
@Component
@ConfigurationProperties(prefix = "zlm")
public class ZlmProperties {
/**
* ZLM HTTP API / HTTP-FLV / HLS 基址,例如 {@code http://127.0.0.1:80}
*/
private String host;
/**
* RTMP 推拉流基址,未配则从 {@link #host} 推导 1935 端口
*/
private String rtmpHost;
/**
* RTSP 推拉流基址,未配则从 {@link #host} 推导 554 端口
*/
private String rtspHost;
/**
* WebRTC 播放基址,未配则回落 {@link #host}
*/
private String webrtcHost;
/**
* ZLM REST / WebHook 共享密钥。WebHook 必须携带该值,未配置时拒绝回调。
*/
private String secret;
/**
* 应用名(ZLM app),默认 {@code live}
*/
private String app = "live";
/**
* 查询在线流时使用的协议,默认 {@code rtmp}
*/
private String schema = "rtmp";
/**
* 设备推流协议:{@code rtmp} 或 {@code rtsp}
*/
private String pushProtocol = "rtmp";
/**
* 播放 token 有效期(秒)
*/
private int playExpireSeconds = 120;
/**
* 流超时秒数(配置占位,当前由 ZLM 自身 none_reader 回调关流)
*/
private int streamTimeout = 30;
}

View File

@@ -0,0 +1,196 @@
package org.dromara.device.constant;
/**
* APP 端(C 端)通用常量:校验正则、验证码场景、Redis Key 前缀、协议类型。
*
* @author Kou
* @date 2026-09-16
*/
public final class AppConstants {
private AppConstants() {
}
// ---------------- 校验正则 ----------------
/**
* 手机号:大陆 11 位,或 {@code +}/{@code 00} + 国家码 + 号码(国际短信)。
*/
public static final String PHONE_REGEX = "^(?:(?:\\+|00)86)?1[3-9]\\d{9}$|^(?:\\+|00)[1-9]\\d{6,14}$";
/**
* 密码:8-20 位,必须同时包含大写字母、小写字母和数字(不强制特殊字符,与原型一致)。
*/
public static final String PASSWORD_REGEX = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)\\S{8,20}$";
/**
* 短信验证码:6 位数字。
*/
public static final String SMS_CODE_REGEX = "^\\d{6}$";
/**
* 邮箱地址(长度上限对齐 {@code sys_user.email varchar(50)})。
* <p>
* 采用宽松的通用校验:本地部分允许字母数字与常见符号,域名部分至少一个点。
* <b>不做「仅允许 gmail.com」的硬限制</b>——域名白名单由
* {@code device.app.email.allowed-domains} 配置控制(留空即不限制),
* 以便在不改代码的前提下切换为「只允许谷歌邮箱注册」。
*/
public static final String EMAIL_REGEX = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$";
/**
* 设备 IMEI(绑定码):默认 15 位数字(Q1 口径,见《02-架构设计-一期》§6.1)。
*/
public static final String IMEI_REGEX = "^\\d{15}$";
/**
* 设备名称最大字符数(按字符计数,中文按 1 字计,与《开发计划》§8.2「name ≤20」一致;
* 注意不是字节数,故 {@code "测试设备一二三四五六七八九十"} 为 12 字而非 12 字节)。
*/
public static final int DEVICE_NAME_MAX_LENGTH = 20;
// ---------------- 验证码场景 ----------------
/**
* 场景:登录。
*/
public static final String SCENE_LOGIN = "login";
/**
* 场景:注册。
*/
public static final String SCENE_REGISTER = "register";
/**
* 场景:忘记密码。
*/
public static final String SCENE_RESET_PWD = "resetPwd";
/**
* 场景:换绑手机。
*/
public static final String SCENE_REBIND = "rebind";
/**
* 场景:绑定手机(首次绑定,用于邮箱注册后补绑手机号的账号)。
* <p>
* 与 {@link #SCENE_REBIND} 分开的原因:两者语义与前置条件不同——{@code bind} 用于账号
* 尚无手机号({@code phone_number IS NULL})的首次绑定,{@code rebind} 用于已有手机号的更换。
* 缓存 key 按场景隔离,故两个场景的验证码互不覆盖。
*/
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";
// ---------------- 协议类型 ----------------
/**
* 协议类型:用户协议。
*/
public static final String AGREEMENT_USER = "user";
/**
* 协议类型:隐私政策。
*/
public static final String AGREEMENT_PRIVACY = "privacy";
// ---------------- Redis Key 前缀 ----------------
/**
* 验证码缓存 key 前缀,完整 key = 前缀 + 场景 + ":" + 手机号。
*/
public static final String SMS_CODE_KEY_PREFIX = "app:sms:code:";
/**
* 验证码发送限流 key 前缀,完整 key = 前缀 + 场景 + ":" + 手机号。
*/
public static final String SMS_LIMIT_KEY_PREFIX = "app:sms:limit:";
/**
* 验证码校验失败次数计数器 key 前缀,完整 key = 前缀 + 场景 + ":" + 手机号(P3-3 加固)。
*/
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>
* 注意:该阈值<b>只限制「错误验证码」的提交速率</b>,<b>不使验证码失效</b>——
* 正确验证码永远可校验成功。达到阈值后仅短暂拒绝「错误」尝试(冷却窗口 {@link #SMS_VERIFY_FAIL_WINDOW_SECONDS} 秒),
* 从而杜绝「攻击者用失败请求让受害者验证码失效」的定向 DoS。
*/
public static final int SMS_MAX_VERIFY_FAIL = 5;
/**
* 「错误尝试」限速窗口(秒):窗口内失败次数超过 {@link #SMS_MAX_VERIFY_FAIL} 时短暂拒绝错误尝试,
* 窗口到期自动自愈(Redis key TTL)。
*/
public static final int SMS_VERIFY_FAIL_WINDOW_SECONDS = 60;
/**
* 注册并发保护锁 key 前缀,完整 key = 前缀 + 手机号(P2-2-2)。
* <p>
* 风格对齐 I1 取号锁 {@code bm:device:no:lock:*},同属「按业务维度串行化」的 Redisson 锁。
* 加此锁后,DB 唯一键 {@code uk_sys_user_phone_number} 才是最终防线,锁仅用于把并发冲突降到「几乎不触发」。
*/
public static final String REGISTER_LOCK_KEY_PREFIX = "app:user:register:lock:";
// ---------------- 邮箱验证码 Redis Key 前缀(与短信通道完全隔离) ----------------
/**
* 邮箱验证码缓存 key 前缀,完整 key = 前缀 + 场景 + ":" + 邮箱。
* <p>
* 与短信前缀 {@link #SMS_CODE_KEY_PREFIX} <b>刻意分家</b>:同一场景下手机号与邮箱可能重名取值,
* 且两通道的验证码不应互相覆盖或互相销毁。
*/
public static final String EMAIL_CODE_KEY_PREFIX = "app:email:code:";
/**
* 邮箱验证码发送限流 key 前缀,完整 key = 前缀 + 场景 + ":" + 邮箱。
*/
public static final String EMAIL_LIMIT_KEY_PREFIX = "app:email:limit:";
/**
* 邮箱验证码校验失败次数计数器 key 前缀,完整 key = 前缀 + 场景 + ":" + 邮箱。
*/
public static final String EMAIL_FAIL_KEY_PREFIX = "app:email:fail:";
/**
* 邮箱注册并发保护锁 key 前缀,完整 key = 前缀 + 邮箱。
* <p>
* 与手机号注册锁 {@link #REGISTER_LOCK_KEY_PREFIX} 分家;最终防线同样是 DB 唯一键
* {@code uk_sys_user_email}。
*/
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

@@ -0,0 +1,203 @@
package org.dromara.device.constant;
/**
* APP 端(C 端)提示文案常量。
* <p>
* 取值口径:优先对齐《01-PRD-一期》《开发计划》§8.3「全局说明」原句;
* 原型未明确给出的文案(如密码错误、验证码过于频繁)标注 {@code TODO},
* 待阶段 7(R-38)按原型逐条核对后统一修正。
*
* @author Kou
* @date 2026-09-16
*/
public final class AppMsgConstants {
private AppMsgConstants() {
}
// ---------------- 验证码 ----------------
/**
* 验证码已发送(发送成功提示)。
*/
public static final String SMS_CODE_SENT = "验证码已发送";
/**
* 验证码场景不合法。
* <p>TODO 阶段 7 按原型核对。
*/
public static final String SMS_SCENE_INVALID = "验证码场景不合法";
/**
* 验证码发送过于频繁(60 秒内重复发送)。
* <p>TODO 阶段 7 按原型核对。
*/
public static final String SMS_SEND_TOO_FREQUENT = "验证码发送过于频繁,请稍后再试";
/**
* 验证码短信发送失败。
* <p>TODO 阶段 7 按原型核对。
*/
public static final String SMS_SEND_FAILED = "验证码短信发送失败";
/**
* 验证码错误尝试过于频繁(短冷却窗口内错误次数过多,稍后自动恢复)。
* <p>注意:此提示仅表示「错误尝试」被短暂限速,<b>不表示验证码已失效</b>;正确验证码仍可校验成功。
* <p>TODO 阶段 7 按原型核对。
*/
public static final String SMS_VERIFY_TOO_MANY = "验证码错误次数过多,请稍后再试";
// ---------------- 邮箱验证码 ----------------
/**
* 邮箱验证码已发送(发送成功提示)。
*/
public static final String EMAIL_CODE_SENT = "验证码已发送至邮箱";
/**
* 邮箱验证码场景不合法。
* <p>TODO 阶段 7 按原型核对。
*/
public static final String EMAIL_SCENE_INVALID = "验证码场景不合法";
/**
* 邮箱验证码发送失败(SMTP 通道异常)。
* <p>TODO 阶段 7 按原型核对。
*/
public static final String EMAIL_SEND_FAILED = "验证码邮件发送失败";
/**
* 邮箱域名不在允许列表内({@code device.app.email.allowed-domains} 非空时生效)。
* <p>TODO 阶段 7 按原型核对。
*/
public static final String EMAIL_DOMAIN_NOT_ALLOWED = "该邮箱域名暂不支持注册";
/**
* 邮箱注册成功,注册后可以直接使用邮箱登录;绑定手机号仅在绑定设备前要求。
*/
public static final String EMAIL_REGISTER_SUCCESS = "注册成功,请登录";
/**
* 该账号尚未绑定手机号(绑定设备前的硬拦截文案)。
*/
public static final String PHONE_NOT_BOUND = "请先绑定手机号";
/**
* 当前账号尚未绑定邮箱,无法发送原邮箱核验码。
*/
public static final String EMAIL_NOT_BOUND = "请先绑定邮箱";
// ---------------- 账号 ----------------
/**
* 注册成功。
*/
public static final String REGISTER_SUCCESS = "注册成功";
/**
* 手机号或密码错误。
* <p>TODO 阶段 7 按原型核对(原型「全局说明」未直接给出该句)。
*/
public static final String PASSWORD_ERROR = "手机号或密码错误";
/**
* 邮箱或密码错误(邮箱登录通道专用)。
* <p>不复用 {@link #PASSWORD_ERROR} 的原因:该句含「手机号」字样,用于邮箱登录会误导用户。
*/
public static final String EMAIL_PASSWORD_ERROR = "邮箱或密码错误";
/**
* 账号已被停用。
* <p>TODO 阶段 7 按原型核对。
*/
public static final String ACCOUNT_DISABLED = "账号已被停用";
/**
* 登录已失效(会话不存在)。
* <p>TODO 阶段 7 按原型核对。
*/
public static final String NOT_LOGIN = "登录已失效,请重新登录";
/**
* 退出成功。
*/
public static final String LOGOUT_SUCCESS = "退出成功";
/**
* 注销成功。
* <p>TODO 阶段 7 按原型核对。
*/
public static final String CANCEL_SUCCESS = "注销成功";
// ---------------- 个人中心 ----------------
/**
* 密码重置成功。
*/
public static final String PASSWORD_RESET_SUCCESS = "密码重置成功";
/**
* 密码修改成功。
*/
public static final String PASSWORD_UPDATE_SUCCESS = "密码修改成功";
/**
* 原密码错误。
* <p>TODO 阶段 7 按原型核对。
*/
public static final String OLD_PASSWORD_ERROR = "原密码错误";
/**
* 新密码不能与原密码相同。
* <p>TODO 阶段 7 按原型核对。
*/
public static final String NEW_PASSWORD_SAME = "新密码不能与原密码相同";
/**
* 手机号更换成功。
*/
public static final String PHONE_UPDATE_SUCCESS = "手机号更换成功";
/**
* 新手机号与当前手机号相同(换绑时的精确提示,替代原先误报的「手机号已注册」)。
* <p>TODO 阶段 7 按原型核对。
*/
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 按原型核对。
*/
public static final String AGREEMENT_TYPE_INVALID = "协议类型不合法";
}

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

@@ -0,0 +1,104 @@
package org.dromara.device.constant;
/**
* 设备 MQTT 运行时常量。
* <p>
* 只放「不随环境变化的键名与协议固定码」,阈值类可调参数见
* {@code org.dromara.device.config.DeviceMqttProperties}(配置前缀 {@code device.mqtt})。
* <p>
* Redis Key 命名风格对齐 I1 取号锁 {@code bm:device:no:lock:*}:{@code bm:} 域名前缀 +
* 业务段 + 维度后缀,便于运维按前缀排查与清理。
*
* @author Kou
* @date 2026-09-16
*/
public final class DeviceMqttConstants {
private DeviceMqttConstants() {
}
// ---------------- Redis Key ----------------
/**
* 离线扫描互斥锁。
* <p>
* 每个实例都会起扫描任务,但同一时刻只允许一个实例真正扫描——否则多实例会
* 对同一批超时设备重复「置离线 + 写告警」。
*/
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>
* 用于「设备上报的低电事件」与「服务端阈值判定」两条来源共用的去重窗口,
* 保证窗口内同一 {@code deviceNo + alertType} 只落一条 {@code bm_device_alert}。
*/
public static final String ALERT_DEDUP_PREFIX = "bm:mqtt:alert:dedup:";
/**
* 低电滞回闩锁,完整 Key:{@code bm:mqtt:battery:low:{deviceNo}:{alertType}}。
* <p>
* 语义是「该设备该类电量当前处于低电态」:置位后不再重复告警,直到电量回升越过恢复阈值才清除。
* 与「去重窗口」的分工:窗口解决重复写库,闩锁解决「长时间低电被周期性重复告警」。
*/
public static final String BATTERY_LOW_PREFIX = "bm:mqtt:battery:low:";
/**
* ZLM 播放 token,完整 Key:{@code bm:zlm:play:token:{token}}。
*/
public static final String ZLM_PLAY_TOKEN_PREFIX = "bm:zlm:play:token:";
/**
* MJPEG 设备推流 token,完整 Key:{@code bm:mjpeg:ingest:{deviceNo}}。
*/
public static final String MJPEG_INGEST_TOKEN_PREFIX = "bm:mjpeg:ingest:";
/**
* 待回执指令标记,完整 Key:{@code bm:mqtt:ack:pending:{msgId}},值为目标 {@code deviceNo}。
* <p>
* 该键<b>不参与正确性判定</b>(Future 关联在本实例内存中完成),只作两件事:
* 一是让「未识别的 msgId」与「本平台发出但已超时的 msgId」在日志里可区分;
* 二是让运维能直接看到「当前还有哪些指令在等回执」。
*/
public static final String PENDING_ACK_PREFIX = "bm:mqtt:ack:pending:";
// ---------------- 协议固定码 ----------------
/**
* 告警处理状态:未处理
*/
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";
/**
* 注册结果码:成功
*/
public static final int PROVISION_CODE_OK = 0;
/**
* 注册结果码:IMEI 未建档(含私拆、串货线索)
*/
public static final int PROVISION_CODE_NOT_REGISTERED = 404;
}

View File

@@ -0,0 +1,240 @@
package org.dromara.device.constant;
/**
* 设备域提示文案常量
* <p>
* 集中管理面向用户的提示语,取值口径对齐产品原型「全局说明」原句,后续迭代统一复用。
* 标注 {@code TODO} 的条目需在阶段 7(R-38)按原型逐条核对。
*
* @author Kou
* @date 2026-09-16
*/
public final class DeviceMsgConstants {
private DeviceMsgConstants() {
}
// ---------------- 账号 / 验证码 ----------------
/**
* 验证码错误
*/
public static final String SMS_CODE_ERROR = "验证码错误";
/**
* 验证码已过期
*/
public static final String SMS_CODE_EXPIRED = "验证码已过期";
/**
* 密码格式(8-20 位,含大小写字母和数字)
*/
public static final String PASSWORD_FORMAT = "密码需为 8-20 位,且包含大小写字母和数字";
/**
* 账号不存在
*/
public static final String ACCOUNT_NOT_EXIST = "账号不存在";
/**
* 手机号已注册
*/
public static final String PHONE_REGISTERED = "手机号已注册";
/**
* 邮箱已注册
* <p>与 {@link #PHONE_REGISTERED} 同口径:注册前置判重失败时的受控文案。
*/
public static final String EMAIL_REGISTERED = "邮箱已注册";
// ---------------- 设备绑定 / 解绑 / 重命名 ----------------
/**
* 绑定成功
*/
public static final String BIND_SUCCESS = "添加成功";
/**
* 解绑成功
*/
public static final String UNBIND_SUCCESS = "解绑成功";
/**
* 重命名成功
*/
public static final String RENAME_SUCCESS = "修改成功";
/**
* 设备不存在
*/
public static final String DEVICE_NOT_EXIST = "设备不存在";
/**
* 设备编号已存在
*/
public static final String DEVICE_NO_EXIST = "设备编号已存在";
/**
* IMEI 已存在
*/
public static final String IMEI_EXIST = "IMEI 已存在";
/**
* 设备编号或 IMEI 已存在(唯一键冲突兜底,无法判定具体冲突列时使用)
*/
public static final String DEVICE_NO_OR_IMEI_EXIST = "设备编号或 IMEI 已存在";
/**
* 当日设备编号已达上限
*/
public static final String DEVICE_NO_LIMIT = "当日设备编号已达上限(9999)";
/**
* 设备编号建档后不可修改(后台编辑时校验)。
* <p>{@code device_no} 由服务端建档时自动生成,是被 APP 绑定关系与运维话术引用的业务主展示键,
* 允许改号会造成「同一台设备两个编号」的口径混乱,故编辑入口显式拒绝。
*/
public static final String DEVICE_NO_IMMUTABLE = "设备编号由系统自动生成,不支持修改";
/**
* 设备已被他人绑定
* <p>TODO 阶段 7 按原型核对原句。
*/
public static final String DEVICE_BOUND_BY_OTHERS = "设备已被绑定";
/**
* 重复绑定(已绑定给自己)
* <p>TODO 阶段 7 按原型核对原句。
*/
public static final String DEVICE_ALREADY_BOUND = "重复绑定";
/**
* 设备名称不合法(为空或超过 {@code AppConstants#DEVICE_NAME_MAX_LENGTH} 个字)。
* <p>TODO 阶段 7 按原型核对原句(计划 §8.3 未给出该句)。
*/
public static final String DEVICE_NAME_INVALID = "设备名称需为 1-20 个字";
/**
* 已绑定设备不可直接删除(后台批量删除时命中 {@code bind_user_id} 非空的设备)。
* <p>计划 §9.1:「已绑定需先解绑或二次确认」——本迭代按<b>拒绝</b>实现,文案即此句。
* <p>TODO 阶段 7 按原型核对原句。
*/
public static final String DEVICE_BOUND_CANNOT_DELETE = "设备已绑定,请先解绑后再删除";
// ---------------- 设备操作 ----------------
/**
* 设备离线操作眼罩
*/
public static final String DEVICE_OFFLINE = "设备已离线";
/**
* 低电
*/
public static final String LOW_BATTERY = "电量过低";
/**
* 指令下发失败
*/
public static final String COMMAND_SEND_FAILED = "指令下发失败";
/**
* ZLM 地址未配置
* <p>TODO 阶段 7 按原型核对原句。
*/
public static final String ZLM_NOT_CONFIGURED = "ZLM 地址未配置";
/**
* MJPEG 公网基址未配置
*/
public static final String MJPEG_NOT_CONFIGURED = "MJPEG 直播地址未配置";
/**
* 客户端自定义拉流已停用
*/
public static final String STREAM_CUSTOM_DISABLED = "客户端自定义视频源已停用";
/**
* 直播操作过于频繁
*/
public static final String STREAM_TOO_FREQUENT = "操作过于频繁,请稍后再试";
// ---------------- 告警文案(bm_device_alert.content) ----------------
/**
* 头套低电告警文案,{@code %d} 为触发时的电量。
*/
public static final String ALERT_LOW_BATTERY_HOOD = "头套电量过低(当前 %d%%)";
/**
* 脚环低电告警文案,{@code %d} 为触发时的电量。
*/
public static final String ALERT_LOW_BATTERY_RING = "脚环电量过低(当前 %d%%)";
// ---------------- OTA 升级 ----------------
/**
* OTA 升级包不存在或未启用(批量建任务时整体拒绝)。
* <p>TODO 阶段 7 按原型核对原句。
*/
public static final String OTA_PACKAGE_UNAVAILABLE = "升级包不存在或未启用";
/**
* OTA 升级包的唯一约束冲突:同型号 + 同版本已存在。
* <p>对齐 DB 唯一键 {@code uk_model_version (model, version)}(不区分逻辑删除行)。
* <p>TODO 阶段 7 按原型核对原句。
*/
public static final String OTA_PACKAGE_EXIST = "该型号的升级包版本已存在";
/**
* OTA 升级包必填项缺失(适用型号 / 版本号为空)。
* <p>TODO 阶段 7 按原型核对原句。
*/
public static final String OTA_PACKAGE_MODEL_VERSION_REQUIRED = "适用型号与版本号不能为空";
/**
* OTA 升级任务不存在(编辑前置校验)。
* <p>TODO 阶段 7 按原型核对原句。
*/
public static final String OTA_TASK_NOT_EXIST = "升级任务不存在";
/**
* OTA 批量建任务明细:设备型号与升级包不匹配,计入 {@code skipped}。
* <p>{@code %s} 为设备编号。TODO 阶段 7 按原型核对原句。
*/
public static final String OTA_BATCH_SKIP_MODEL_MISMATCH = "设备[%s]型号与升级包不匹配,已跳过";
/**
* OTA 批量建任务明细:设备已是最新版本,计入 {@code skipped}。{@code %s} 为设备编号。
*/
public static final String OTA_BATCH_SKIP_ALREADY_LATEST = "设备[%s]已是最新版本,已跳过";
/**
* OTA 批量建任务明细:设备已有进行中的同包升级任务,计入 {@code skipped}。{@code %s} 为设备编号。
*/
public static final String OTA_BATCH_SKIP_DUPLICATE = "设备[%s]已有进行中的升级任务,已跳过";
/**
* OTA 批量建任务明细:设备不存在,计入 {@code failed}。{@code %s} 为设备主键。
*/
public static final String OTA_BATCH_FAIL_DEVICE_NOT_EXIST = "设备[%s]不存在,已失败";
/**
* OTA 批量建任务明细:设备离线({@code status != 在线}),计入 {@code failed}。{@code %s} 为设备编号。
*/
public static final String OTA_BATCH_FAIL_DEVICE_OFFLINE = "设备[%s]离线,已失败";
/**
* OTA 批量建任务明细:指令下发未被设备确认(发布失败 / 回执超时 / 回执拒绝),
* 计入 {@code failed}。{@code %s} 为设备编号。
*/
public static final String OTA_BATCH_FAIL_DISPATCH = "设备[%s]指令下发失败,已失败";
// ---------------- APP 用户管理(后台) ----------------
/**
* 账号状态更新成功(停用 / 启用)。
*/
public static final String USER_STATUS_SUCCESS = "状态更新成功";
}

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,150 @@
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.constant.AppMsgConstants;
import org.dromara.device.constant.DeviceMsgConstants;
import org.dromara.device.domain.bo.AppUserAdminQueryBo;
import org.dromara.device.domain.bo.AppUserResetPwdBo;
import org.dromara.device.domain.bo.AppUserStatusBo;
import org.dromara.device.domain.vo.AppUserAdminVo;
import org.dromara.device.domain.vo.AppUserDeviceVo;
import org.dromara.device.service.IAppUserAdminService;
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.List;
/**
* 后台 APP 用户管理 Controller(前缀 {@code /device/app-user})。
* <p>
* 与 APP 端的 {@code AppUserController}({@code /app/user})<b>不是同一组接口</b>:
* <ul>
* <li>{@code /app/user/**}:C 端「本人操作本人」,令牌登录态,无权限字;</li>
* <li>本类:后台「管理员操作 APP 账号」,<b>全部需权限字</b>,与后台设备管理同口径。</li>
* </ul>
* <p>
* 接口清单:
* <ul>
* <li>{@code GET /device/app-user/list} —— 分页列表(恒不含后台账号)</li>
* <li>{@code GET /device/app-user/{userId}} —— 用户详情</li>
* <li>{@code GET /device/app-user/{userId}/devices} —— 该用户绑定设备</li>
* <li>{@code PUT /device/app-user/status} —— 停用 / 启用</li>
* <li>{@code PUT /device/app-user/reset-pwd} —— 重置密码</li>
* <li>{@code DELETE /device/app-user/{userId}/device/{deviceNo}} —— 解绑设备</li>
* </ul>
* <p>
* <b>权限字</b>:{@code device:appuser:list}(列表) / {@code device:appuser:query}(详情与绑定设备) /
* {@code device:appuser:edit}(状态变更、重置密码、解绑设备)。
* 与 OTA 的做法一致,变更类复用单一 {@code edit} 权限字,避免权限字数量膨胀。
* <p>
* <b>数据权限</b>:APP 账号不属于后台组织架构({@code dept_id} 恒为 {@code NULL}),
* 故本组接口不参与框架的数据权限过滤,由权限字做粗粒度把关。
*
* @author Kou
* @date 2026-09-17
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/device/app-user")
public class AppUserAdminController extends BaseController {
private final IAppUserAdminService appUserAdminService;
/**
* 分页查询 APP 用户列表。
* <p>
* 入参只用 {@link AppUserAdminQueryBo}(它已继承 {@code PageQuery}),
* 不再并列声明 {@code PageQuery}——否则两个分页参数会同时参与绑定、
* 且「谁生效」取决于绑定顺序,属易错设计。
*
* @param bo 查询条件与分页参数
* @return APP 用户分页结果(含绑定设备数)
*/
@SaCheckPermission("device:appuser:list")
@GetMapping("/list")
public R<PageResult<AppUserAdminVo>> list(@Validated AppUserAdminQueryBo bo) {
return R.ok(appUserAdminService.queryPageList(bo));
}
/**
* 查询 APP 用户详情。
*
* @param userId 用户主键
* @return 用户详情;不存在或非 APP 账号时 {@code data} 为 {@code null}(前端按空态处理)
*/
@SaCheckPermission("device:appuser:query")
@GetMapping("/{userId}")
public R<AppUserAdminVo> getInfo(@PathVariable("userId") Long userId) {
return R.ok(appUserAdminService.queryById(userId));
}
/**
* 查询指定 APP 用户当前绑定的设备列表。
*
* @param userId 用户主键
* @return 绑定设备列表(无绑定或用户无效时为空列表,不报错)
*/
@SaCheckPermission("device:appuser:query")
@GetMapping("/{userId}/devices")
public R<List<AppUserDeviceVo>> devices(@PathVariable("userId") Long userId) {
return R.ok(appUserAdminService.listDevices(userId));
}
/**
* 停用 / 启用账号。
*
* @param bo 状态变更入参(userId + status)
* @return 成功文案
*/
@SaCheckPermission("device:appuser:edit")
@Log(title = "APP用户管理", businessType = BusinessType.UPDATE)
@PutMapping("/status")
public R<Void> changeStatus(@Validated @RequestBody AppUserStatusBo bo) {
appUserAdminService.updateStatus(bo);
return R.ok(DeviceMsgConstants.USER_STATUS_SUCCESS);
}
/**
* 重置账号密码(管理员代为设置)。
*
* @param bo 重置密码入参(userId + newPassword)
* @return 成功文案
*/
@SaCheckPermission("device:appuser:edit")
@Log(title = "APP用户管理", businessType = BusinessType.UPDATE)
@PutMapping("/reset-pwd")
public R<Void> resetPassword(@Validated @RequestBody AppUserResetPwdBo bo) {
appUserAdminService.resetPassword(bo);
return R.ok(AppMsgConstants.PASSWORD_RESET_SUCCESS);
}
/**
* 解绑该用户名下的某台设备。
*
* @param userId 用户主键
* @param deviceNo 设备编号
* @return 成功文案
*/
@SaCheckPermission("device:appuser:edit")
@Log(title = "APP用户管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{userId}/device/{deviceNo}")
public R<Void> unbindDevice(@PathVariable("userId") Long userId,
@PathVariable("deviceNo") String deviceNo) {
appUserAdminService.unbindDevice(userId, deviceNo);
return R.ok(DeviceMsgConstants.UNBIND_SUCCESS);
}
}

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

@@ -0,0 +1,144 @@
package org.dromara.device.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.PageResult;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.validate.AddGroup;
import org.dromara.common.core.validate.EditGroup;
import org.dromara.common.core.validate.QueryGroup;
import org.dromara.common.excel.utils.ExcelBuilder;
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.DeviceBo;
import org.dromara.device.domain.vo.DeviceStatsVo;
import org.dromara.device.domain.vo.DeviceVo;
import org.dromara.device.service.IDeviceService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.List;
/**
* 后台设备 Controller(前缀 {@code /device})。
* <p>
* 接口清单(架构 §7.2):
* <ul>
* <li>{@code GET /device/list} —— 分页列表(I1)</li>
* <li>{@code GET /device/{deviceId}} —— 详情(I1)</li>
* <li>{@code POST /device} —— 新增建档(I1)</li>
* <li>{@code PUT /device} —— 修改(I1)</li>
* <li>{@code GET /device/stats} —— 顶部统计(I5)</li>
* <li>{@code DELETE /device/{ids}} —— 批量删除,已绑定拒绝(I5)</li>
* <li>{@code POST /device/export} —— Excel 导出(I5)</li>
* </ul>
* OTA 相关接口见 {@link org.dromara.device.controller.OtaController}({@code /device/ota/**},I5)。
* 后台接口一律 {@code @SaCheckPermission},变更类加 {@code @Log}。
* <p>
* ⚠️ <b>本模块没有数据权限(部门)隔离</b>:{@code BmDeviceMapper} 未标注
* {@link org.dromara.common.mybatis.annotation.DataPermission},而框架的
* {@code PlusDataPermissionInterceptor} <b>必须靠该注解才会生成部门过滤条件</b> ——
* 因此任何持有 {@code device:device:list} 的账号看到的都是<b>全量设备</b>。
* <p>
* 原注释写「数据权限由框架全局拦截器承担」与本模块实际情况不符(全模块 {@code grep DataPermission} 零命中),
* 会让人误以为已经隔离,故订正。若后续确需按部门隔离,必须显式给 Mapper 加 {@code @DataPermission},
* 不能指望「全局生效」。
*
* @author Kou
* @date 2026-09-16
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/device")
public class DeviceController extends BaseController {
private final IDeviceService deviceService;
/**
* 查询设备列表
*/
@SaCheckPermission("device:device:list")
@GetMapping("/list")
public R<PageResult<DeviceVo>> list(@Validated(QueryGroup.class) DeviceBo bo, PageQuery pageQuery) {
return R.ok(deviceService.queryPageList(bo, pageQuery));
}
/**
* 获取设备详细信息
*
* @param deviceId 设备主键
*/
@SaCheckPermission("device:device:query")
@GetMapping("/{deviceId}")
public R<DeviceVo> getInfo(@NotNull(message = "设备主键不能为空")
@PathVariable("deviceId") Long deviceId) {
return R.ok(deviceService.queryById(deviceId));
}
/**
* 新增设备
*/
@SaCheckPermission("device:device:add")
@Log(title = "设备管理", businessType = BusinessType.INSERT)
@PostMapping
public R<Void> add(@Validated(AddGroup.class) @RequestBody DeviceBo bo) {
return toAjax(deviceService.insertByBo(bo));
}
/**
* 修改设备
*/
@SaCheckPermission("device:device:edit")
@Log(title = "设备管理", businessType = BusinessType.UPDATE)
@PutMapping
public R<Void> edit(@Validated(EditGroup.class) @RequestBody DeviceBo bo) {
return toAjax(deviceService.updateByBo(bo));
}
/**
* 设备统计(顶部统计条)。
*
* @return 统计({@code total / online / offline / inactive})
*/
@SaCheckPermission("device:device:list")
@GetMapping("/stats")
public R<DeviceStatsVo> stats() {
return R.ok(deviceService.queryStats());
}
/**
* 批量删除设备(已绑定设备拒绝)。
*
* @param ids 设备主键数组(路径中以逗号分隔,如 {@code /device/1,2,3})
*/
@SaCheckPermission("device:device:remove")
@Log(title = "设备管理", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public R<Void> remove(@PathVariable("ids") Long[] ids) {
return toAjax(deviceService.deleteByIds(Arrays.asList(ids)));
}
/**
* 导出设备列表(与 {@link #list} 同筛选口径)。
* <p>
* 采用若依 6.X 的 {@link ExcelBuilder} 直接写出到响应流;入参为 {@link DeviceBo}
* (复用列表筛选条件),以保证「顶部统计与筛选一致」。
*
* @param bo 查询条件(与 list 同口径)
* @param response HTTP 响应(Excel 字节流)
*/
@SaCheckPermission("device:device:export")
@Log(title = "设备管理", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(DeviceBo bo, HttpServletResponse response) {
List<DeviceVo> list = deviceService.queryList(bo);
ExcelBuilder.of(list, DeviceVo.class).sheetName("设备数据").toResponse(response);
}
}

View File

@@ -0,0 +1,140 @@
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.OtaBatchBo;
import org.dromara.device.domain.bo.OtaPackageBo;
import org.dromara.device.domain.bo.OtaTaskBo;
import org.dromara.device.domain.vo.OtaBatchResultVo;
import org.dromara.device.domain.vo.OtaPackageVo;
import org.dromara.device.domain.vo.OtaTaskVo;
import org.dromara.device.service.IOtaPackageService;
import org.dromara.device.service.IOtaTaskService;
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;
/**
* OTA 升级 Controller(前缀 {@code /device/ota})。
* <p>
* 接口清单(架构 §7.2、PRD §5 R-29~R-32):
* <ul>
* <li>{@code GET /device/ota/task/list} —— 升级任务列表(升级记录)</li>
* <li>{@code GET /device/ota/package/list} —— 升级包列表</li>
* <li>{@code POST /device/ota/package} —— 登记升级包</li>
* <li>{@code PUT /device/ota/package} —— 修改升级包(启用 / 停用)</li>
* <li>{@code DELETE /device/ota/package/{ids}} —— 删除升级包(逻辑删除)</li>
* <li>{@code POST /device/ota/batch} —— 按勾选设备批量建任务并下发</li>
* </ul>
* 后台接口一律 {@code @SaCheckPermission}(数据权限由框架全局拦截器承担),变更类加 {@code @Log}。
* 权限字与前端 {@code bird_monitor-ui/src/api/device/ota} 对齐:查询用 {@code device:ota:list},
* 其余变更用 {@code device:ota:add}。
*
* @author Kou
* @date 2026-09-16
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/device/ota")
public class OtaController extends BaseController {
private final IOtaPackageService otaPackageService;
private final IOtaTaskService otaTaskService;
/**
* 查询升级任务列表(升级记录)。
*
* @param bo 查询条件(deviceNo / version / status / keyword)
* @param pageQuery 分页参数
* @return 任务分页结果
*/
@SaCheckPermission("device:ota:list")
@GetMapping("/task/list")
public R<PageResult<OtaTaskVo>> taskList(OtaTaskBo bo, PageQuery pageQuery) {
return R.ok(otaTaskService.queryPageList(bo, pageQuery));
}
/**
* 查询升级包列表。
*
* @param bo 查询条件(model / version / status / keyword)
* @param pageQuery 分页参数
* @return 升级包分页结果
*/
@SaCheckPermission("device:ota:list")
@GetMapping("/package/list")
public R<PageResult<OtaPackageVo>> packageList(OtaPackageBo bo, PageQuery pageQuery) {
return R.ok(otaPackageService.queryPageList(bo, pageQuery));
}
/**
* 登记 OTA 升级包。
*
* @param bo 升级包业务对象
* @return 操作结果
*/
@SaCheckPermission("device:ota:add")
@Log(title = "OTA升级包", businessType = BusinessType.INSERT)
@PostMapping("/package")
public R<Void> addPackage(@RequestBody OtaPackageBo bo) {
return toAjax(otaPackageService.insertByBo(bo));
}
/**
* 修改 OTA 升级包(含启用 / 停用)。
*
* @param bo 升级包业务对象(含 packageId)
* @return 操作结果
*/
@SaCheckPermission("device:ota:add")
@Log(title = "OTA升级包", businessType = BusinessType.UPDATE)
@PutMapping("/package")
public R<Void> updatePackage(@RequestBody OtaPackageBo bo) {
return toAjax(otaPackageService.updateByBo(bo));
}
/**
* 删除 OTA 升级包(逻辑删除)。
*
* @param ids 升级包主键数组(路径中以逗号分隔,如 {@code /device/ota/package/1,2,3})
* @return 操作结果
*/
@SaCheckPermission("device:ota:add")
@Log(title = "OTA升级包", businessType = BusinessType.DELETE)
@DeleteMapping("/package/{ids}")
public R<Void> removePackage(@PathVariable("ids") Long[] ids) {
return toAjax(otaPackageService.deleteByIds(Arrays.asList(ids)));
}
/**
* 按勾选设备批量创建升级任务并下发。
* <p>
* 返回 {total / created / skipped / failed / messages},其中 {@code total == created + skipped + failed}。
* 逐台处置详情见 {@link IOtaTaskService#batchOta(OtaBatchBo)}。
*
* @param bo 批量入参(packageId + deviceIds)
* @return 批量结果
*/
@SaCheckPermission("device:ota:add")
@Log(title = "OTA升级任务", businessType = BusinessType.INSERT)
@PostMapping("/batch")
public R<OtaBatchResultVo> batch(@Validated @RequestBody OtaBatchBo bo) {
return R.ok(otaTaskService.batchOta(bo));
}
}

View File

@@ -0,0 +1,154 @@
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;
import org.dromara.device.domain.bo.AppEmailCodeLoginBo;
import org.dromara.device.domain.bo.AppEmailLoginBo;
import org.dromara.device.domain.bo.AppEmailRegisterBo;
import org.dromara.device.domain.bo.AppEmailResetPwdBo;
import org.dromara.device.domain.bo.AppLoginBo;
import org.dromara.device.domain.bo.AppRegisterBo;
import org.dromara.device.domain.bo.AppResetPwdBo;
import org.dromara.device.domain.bo.AppSmsCodeBo;
import org.dromara.device.domain.bo.AppSmsLoginBo;
import org.dromara.device.domain.vo.AppTokenVo;
import org.dromara.device.service.IAppAuthService;
import org.springframework.validation.annotation.Validated;
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.RestController;
/**
* APP 账号 Controller(前缀 {@code /app/auth})。
* <p>
* 本组接口为登录类,统一 {@link SaIgnore} 放行(与框架 {@code AuthController} 同口径);
* 业务鉴权由后续 {@code /app/user/**}、{@code /app/device/**} 承担。
*
* @author Kou
* @date 2026-09-16
*/
@SaIgnore
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/app/auth")
public class AppAuthController extends BaseController {
private final IAppAuthService appAuthService;
/**
* 发送短信验证码(6 位、场景化 60 秒限流、5 分钟有效)。
*/
@PostMapping("/sms/code")
public R<Void> sendSmsCode(@Validated @RequestBody AppSmsCodeBo bo) {
String code = appAuthService.sendSmsCode(bo);
// 开发环境 mock 模式暂将验证码放入 msg,接入真实短信后仍返回标准成功文案
return R.ok(code == null ? MessageUtils.message("app.sms.code.sent") : code);
}
/**
* 手机号注册。
*/
@PostMapping("/register")
public R<Void> register(@Validated @RequestBody AppRegisterBo bo) {
appAuthService.register(bo);
return R.ok(AppMsgConstants.REGISTER_SUCCESS);
}
/**
* 密码登录。
* <p>
* 令牌封装为 {@link AppTokenVo} 并置于响应 {@code data}(P3-1 修复)——避免 {@code R.ok(String)}
* 被解析为「msg 重载」而把令牌塞进 {@code msg}。{@code IAppAuthService#login} 仍返回令牌字符串(架构 §5 不变)。
*/
@PostMapping("/login")
public R<AppTokenVo> login(@Validated @RequestBody AppLoginBo bo) {
return R.ok(new AppTokenVo(appAuthService.login(bo)));
}
/**
* 短信验证码登录。
* <p>
* 令牌封装口径同 {@link #login}(P3-1 修复)。
*/
@PostMapping("/sms-login")
public R<AppTokenVo> smsLogin(@Validated @RequestBody AppSmsLoginBo bo) {
return R.ok(new AppTokenVo(appAuthService.smsLogin(bo)));
}
/**
* 忘记密码(重置密码)。
*/
@PostMapping("/reset-pwd")
public R<Void> resetPwd(@Validated @RequestBody AppResetPwdBo bo) {
appAuthService.resetPwd(bo);
return R.ok(AppMsgConstants.PASSWORD_RESET_SUCCESS);
}
/**
* 退出登录(清除当前会话)。
*/
@PostMapping("/logout")
public R<Void> logout() {
appAuthService.logout();
return R.ok(AppMsgConstants.LOGOUT_SUCCESS);
}
// ---------------- 邮箱通道(迭代:邮箱注册) ----------------
/**
* 发送邮箱验证码(6 位、场景化 60 秒限流、5 分钟有效)。
*/
@PostMapping("/email/code")
public R<Void> sendEmailCode(@Validated @RequestBody AppEmailCodeBo bo) {
String code = appAuthService.sendEmailCode(bo);
// 开发环境 mock 模式与短信接口保持一致:把验证码放入 msg;真实邮件仍只返回标准成功文案。
return R.ok(code == null ? AppMsgConstants.EMAIL_CODE_SENT : code);
}
/**
* 邮箱注册。
* <p>
* 邮箱注册完成后可直接使用邮箱登录;未绑定手机号的账号仅在绑定设备时受限
* (服务端硬拦截,见 {@code IAppDeviceService#bind})。
*/
@PostMapping("/email/register")
public R<Void> emailRegister(@Validated @RequestBody AppEmailRegisterBo bo) {
appAuthService.registerByEmail(bo);
return R.ok(AppMsgConstants.EMAIL_REGISTER_SUCCESS);
}
/**
* 邮箱 + 密码登录。
* <p>
* 令牌封装口径同 {@link #login}:置于响应 {@code data},避免 {@code R.ok(String)} 被解析为 msg 重载。
*/
@PostMapping("/email/login")
public R<AppTokenVo> emailLogin(@Validated @RequestBody AppEmailLoginBo bo) {
return R.ok(new AppTokenVo(appAuthService.emailLogin(bo)));
}
/**
* 邮箱 + 验证码登录。
*/
@PostMapping("/email/code-login")
public R<AppTokenVo> emailCodeLogin(@Validated @RequestBody AppEmailCodeLoginBo bo) {
return R.ok(new AppTokenVo(appAuthService.emailCodeLogin(bo)));
}
/**
* 邮箱找回密码(重置密码)。
*/
@PostMapping("/email/reset-pwd")
public R<Void> emailResetPwd(@Validated @RequestBody AppEmailResetPwdBo bo) {
appAuthService.resetPwdByEmail(bo);
return R.ok(AppMsgConstants.PASSWORD_RESET_SUCCESS);
}
}

View File

@@ -0,0 +1,189 @@
package org.dromara.device.controller.app;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.common.web.core.BaseController;
import org.dromara.device.constant.AppMsgConstants;
import org.dromara.device.constant.DeviceMsgConstants;
import org.dromara.device.domain.bo.AppDeviceBindBo;
import org.dromara.device.domain.bo.AppDeviceHoodBo;
import org.dromara.device.domain.bo.AppDeviceNameBo;
import org.dromara.device.domain.vo.AppDeviceDetailVo;
import org.dromara.device.domain.vo.AppDeviceListVo;
import org.dromara.device.domain.vo.AppHoodResultVo;
import org.dromara.device.domain.vo.AppStreamPlayVo;
import org.dromara.device.domain.vo.LocationVo;
import org.dromara.device.service.IAppDeviceService;
import org.dromara.device.service.IAppStreamService;
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.List;
/**
* APP 设备 Controller(前缀 {@code /app/device})。
* <p>
* 本组接口需登录态;当前用户取自登录标识 {@code app_user:{userId}}({@code LoginHelper.getUserId()}),
* 仅能操作本人绑定设备,<b>不参与后台数据权限、不加 {@code @SaCheckPermission}</b>(《开发计划》§12,与 I2 口径一致)。
* <p>
* 接口清单(迭代 I3,架构 §7.1 #12–16):
* <ul>
* <li>{@code GET /app/device/list} —— 当前用户绑定列表</li>
* <li>{@code GET /app/device/{deviceNo}} —— 设备详情</li>
* <li>{@code POST /app/device/bind} —— 绑定(imei + name)</li>
* <li>{@code PUT /app/device/{deviceNo}/name} —— 重命名</li>
* <li>{@code DELETE /app/device/{deviceNo}} —— 解绑</li>
* </ul>
* 迭代 I4 增补:
* <ul>
* <li>{@code PUT /app/device/{deviceNo}/hood} —— 眼罩开关(阻塞等待设备回执,协议 §8.2)</li>
* <li>{@code GET /app/device/{deviceNo}/live} —— 直播播放地址(不主动通知设备推流)</li>
* </ul>
* 开始/停止推流见 {@code /app/stream/**}。
* 迭代 I5 增补:
* <ul>
* <li>{@code GET /app/device/{deviceNo}/location} —— 定位最新点(架构 §7.1 #18)</li>
* </ul>
*
* @author Kou
* @date 2026-09-16
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/app/device")
public class AppDeviceController extends BaseController {
private final IAppDeviceService appDeviceService;
private final IAppStreamService appStreamService;
/**
* 当前用户绑定设备列表。
*
* @return 设备卡片列表
*/
@GetMapping("/list")
public R<List<AppDeviceListVo>> list() {
return R.ok(appDeviceService.listByUser(currentUserId()));
}
/**
* 设备详情(坐标、眼罩、双电量、信号、头套连接、地址)。
*
* @param deviceNo 设备编号
* @return 设备详情
*/
@GetMapping("/{deviceNo}")
public R<AppDeviceDetailVo> detail(@PathVariable("deviceNo") String deviceNo) {
return R.ok(appDeviceService.detail(deviceNo, currentUserId()));
}
/**
* 绑定设备(扫码 / 手动添加)。
*
* @param bo 绑定入参(imei + name)
* @return 成功文案
*/
@PostMapping("/bind")
public R<Void> bind(@Validated @RequestBody AppDeviceBindBo bo) {
appDeviceService.bind(bo, currentUserId());
return R.ok(DeviceMsgConstants.BIND_SUCCESS);
}
/**
* 重命名设备(设备编号取自路径)。
*
* @param deviceNo 设备编号(路径变量,回填至 BO)
* @param bo 重命名入参(name)
* @return 成功文案
*/
@PutMapping("/{deviceNo}/name")
public R<Void> rename(@PathVariable("deviceNo") String deviceNo,
@Validated @RequestBody AppDeviceNameBo bo) {
bo.setDeviceNo(deviceNo);
appDeviceService.rename(bo, currentUserId());
return R.ok(DeviceMsgConstants.RENAME_SUCCESS);
}
/**
* 解绑设备(当前用户)。
*
* @param deviceNo 设备编号
* @return 成功文案
*/
@DeleteMapping("/{deviceNo}")
public R<Void> unbind(@PathVariable("deviceNo") String deviceNo) {
appDeviceService.unbind(deviceNo, currentUserId());
return R.ok(DeviceMsgConstants.UNBIND_SUCCESS);
}
/**
* 眼罩开关(遮盖 / 打开)。
* <p>
* <b>本接口会阻塞约一个 RTT + 设备动作时间</b>(最长 {@code device.mqtt.hood-timeout},默认 5s),
* 直到设备回执 {@code ack.ok=true} 才返回成功——协议 §8.2 的唯一成功凭据。
* 失败(离线 / 低电 / 超时 / 设备拒绝)统一以业务异常返回,前端按提示文案处理即可。
*
* @param deviceNo 设备编号(路径变量,回填至 BO)
* @param bo 眼罩入参(coverOn)
* @return 设备确认执行后的眼罩状态
*/
@PutMapping("/{deviceNo}/hood")
public R<AppHoodResultVo> hood(@PathVariable("deviceNo") String deviceNo,
@Validated @RequestBody AppDeviceHoodBo bo) {
bo.setDeviceNo(deviceNo);
return R.ok(appDeviceService.hood(bo, currentUserId()));
}
/**
* 定位最新点(给「查看地图」用,架构 §7.1 #18)。
* <p>
* 越权隔离与 {@link #detail} 完全同口径:非本人设备统一「设备不存在」。
* 设备从未上报定位时返回<b>空 VO</b>({@code lng}/{@code lat} 为 {@code null}),<b>不抛异常</b>——
* APP 需要一个可展示的空态(见 {@link LocationVo})。
*
* @param deviceNo 设备编号
* @return 定位视图对象(坐标可能为 {@code null})
*/
@GetMapping("/{deviceNo}/location")
public R<LocationVo> location(@PathVariable("deviceNo") String deviceNo) {
return R.ok(appDeviceService.location(deviceNo, currentUserId()));
}
/**
* 获取直播播放地址(不通知设备推流)。
* <p>
* 需要设备已经在推、或随后调用 {@code POST /app/stream/start}。
* 路径写成 {@code /{deviceNo}/live},避免被 {@code GET /{deviceNo}} 吞掉 {@code /app/device/live}。
*
* @param deviceNo 设备编号
* @return 带 token 的播放地址
*/
@GetMapping("/{deviceNo}/live")
public R<AppStreamPlayVo> live(@PathVariable("deviceNo") String deviceNo) {
return R.ok(appStreamService.playUrl(deviceNo, currentUserId()));
}
/**
* 获取当前登录用户主键,未登录时抛出受控异常。
*
* @return 当前用户主键
*/
private Long currentUserId() {
Long userId = LoginHelper.getUserId();
if (userId == null) {
throw new ServiceException(AppMsgConstants.NOT_LOGIN);
}
return userId;
}
}

View File

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

View File

@@ -0,0 +1,136 @@
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;
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;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* APP 个人中心 Controller(前缀 {@code /app/user})。
* <p>
* 除协议查询接口外,本组接口均需登录态;当前用户取自 {@code LoginHelper.getUserId()}
* (登录标识 {@code app_user:{userId}}),仅能操作本人数据,不做后台数据权限。
*
* @author Kou
* @date 2026-09-16
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/app/user")
public class AppUserController extends BaseController {
private final IAppUserService appUserService;
/**
* 获取当前用户资料(手机号脱敏)。
*/
@GetMapping("/profile")
public R<AppUserProfileVo> profile() {
return R.ok(appUserService.profile());
}
/**
* 修改密码(旧密码 + 新密码)。
*/
@PutMapping("/password")
public R<Void> updatePassword(@Validated @RequestBody AppPasswordBo bo) {
appUserService.updatePassword(bo);
return R.ok(AppMsgConstants.PASSWORD_UPDATE_SUCCESS);
}
/**
* 向当前账号原手机号发送换绑确认验证码。
*/
@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) {
appUserService.rebindPhone(bo);
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);
}
/**
* 注销账号(先解绑全部设备,再停用账号)。
*/
@DeleteMapping("/account")
public R<Void> cancelAccount() {
appUserService.cancelAccount();
return R.ok(AppMsgConstants.CANCEL_SUCCESS);
}
/**
* 查询协议内容(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

@@ -0,0 +1,177 @@
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.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 设备主表对象 bm_device
* <p>
* 说明:本表不含任何直播字段(无 stream_url / play_url / live_*)。
*
* @author Kou
* @date 2026-09-16
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("bm_device")
public class BmDevice extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键(雪花,仅内部关联,不对外暴露)
*/
@TableId(value = "device_id")
private Long deviceId;
/**
* 业务编号(唯一,对外主展示键)
*/
private String deviceNo;
/**
* 绑定码 IMEI(唯一,APP 绑定键)
*/
private String imei;
/**
* 用户侧名称
*/
private String name;
/**
* 型号
*/
private String model;
/**
* 出厂序列号
*/
private String sn;
/**
* 硬件版本
*/
private String hwVersion;
/**
* 软件版本
*/
private String swVersion;
/**
* 固件版本
*/
private String fwVersion;
/**
* 数据包版本
*/
private String pkgVersion;
/**
* 设备 IP
*/
private String ip;
/**
* MAC
*/
private String mac;
/**
* WiFi SSID
*/
private String wifiSsid;
/**
* 状态(0 未激活 1 在线 2 离线)
*/
private String status;
/**
* 当前绑定 APP 用户
*/
private Long bindUserId;
/**
* 最近一次绑定时间
*/
private LocalDateTime bindTime;
/**
* 头套连接(0 断开 1 已连接)
*/
private String hoodConnected;
/**
* 眼罩状态(0 打开 1 遮盖)
*/
private String hoodCoverOn;
/**
* 信号等级(0 离线 1 弱 2 一般 3 强)
*/
private Integer signalLevel;
/**
* 头套电量 0-100
*/
private Integer hoodBattery;
/**
* 脚环电量 0-100
*/
private Integer ringBattery;
/**
* 头套低电告警阈值 0-100(电量 ≤ 该值触发;0 表示不触发,即对该设备关闭低电保护)
*/
private Integer hoodBatteryThreshold;
/**
* 脚环低电告警阈值 0-100(电量 ≤ 该值触发;0 表示不触发)
*/
private Integer ringBatteryThreshold;
/**
* 最新经度
*/
private BigDecimal lng;
/**
* 最新纬度
*/
private BigDecimal lat;
/**
* 地址(设备上报)
*/
private String address;
/**
* 最后在线时间
*/
private LocalDateTime lastOnlineTime;
/**
* 备注
*/
private String remark;
/**
* 逻辑删除(0 存在 1 删除)
*/
@TableLogic
private String delFlag;
}

View File

@@ -0,0 +1,99 @@
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 java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 设备告警对象 bm_device_alert
*
* @author Kou
* @date 2026-09-16
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("bm_device_alert")
public class BmDeviceAlert implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键(雪花)
*/
@TableId(value = "alert_id")
private Long alertId;
/**
* 设备主键
*/
private Long deviceId;
/**
* 设备编号(冗余)
*/
private String deviceNo;
/**
* 告警类型(offline / low_battery_hood / low_battery_ring)
*/
private String alertType;
/**
* 告警级别(0 提示 1 警告 2 严重)
*/
private String alertLevel;
/**
* 触发值(如电量)
*/
private String alertValue;
/**
* 告警文案
*/
private String content;
/**
* 处理状态(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,69 @@
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;
/**
* 设备绑定流水对象 bm_device_bind
* <p>
* 说明:流水表不设逻辑删除(禁止抹除历史)。
*
* @author Kou
* @date 2026-09-16
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("bm_device_bind")
public class BmDeviceBind implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键(雪花)
*/
@TableId(value = "bind_id")
private Long bindId;
/**
* 设备物理主键
*/
private Long deviceId;
/**
* 设备编号(冗余,便于审计/后台展示)
*/
private String deviceNo;
/**
* 操作 APP 用户
*/
private Long userId;
/**
* IMEI(冗余快照)
*/
private String imei;
/**
* 动作(bind / unbind / rebind)
*/
private String action;
/**
* 操作时间
*/
private LocalDateTime bindTime;
/**
* 备注
*/
private String remark;
}

View File

@@ -0,0 +1,90 @@
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.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 设备遥测历史对象 bm_device_telemetry
* <p>
* 说明:一期仅落库(INSERT),不做轨迹回放;不含任何直播字段。
*
* @author Kou
* @date 2026-09-16
*/
@Data
@EqualsAndHashCode(callSuper = false)
@TableName("bm_device_telemetry")
public class BmDeviceTelemetry implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键(雪花)
*/
@TableId(value = "telemetry_id")
private Long telemetryId;
/**
* 设备主键
*/
private Long deviceId;
/**
* 设备编号(冗余)
*/
private String deviceNo;
/**
* 头套电量 0-100
*/
private Integer hoodBattery;
/**
* 脚环电量 0-100
*/
private Integer ringBattery;
/**
* 信号等级(0 离线 1 弱 2 一般 3 强)
*/
private Integer signalLevel;
/**
* 头套连接(0 断开 1 已连接)
*/
private String hoodConnected;
/**
* 眼罩状态(0 打开 1 遮盖)
*/
private String hoodCoverOn;
/**
* 经度
*/
private BigDecimal lng;
/**
* 纬度
*/
private BigDecimal lat;
/**
* 地址
*/
private String address;
/**
* 设备上报时间
*/
private LocalDateTime reportTime;
}

View File

@@ -0,0 +1,88 @@
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;
/**
* OTA 升级包对象 bm_ota_package
*
* @author Kou
* @date 2026-09-16
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("bm_ota_package")
public class BmOtaPackage extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键(雪花)
*/
@TableId(value = "package_id")
private Long packageId;
/**
* 适用型号
*/
private String model;
/**
* 版本号
*/
private String version;
/**
* 关联 sys_oss.oss_id
*/
private Long ossId;
/**
* 文件 URL(冗余)
*/
private String fileUrl;
/**
* 文件名
*/
private String fileName;
/**
* 文件字节数
*/
private Long fileSize;
/**
* 校验值 MD5
*/
private String md5;
/**
* 更新说明
*/
private String changelog;
/**
* 状态(0 停用 1 启用)
*/
private String status;
/**
* 备注
*/
private String remark;
/**
* 逻辑删除(0 存在 1 删除)
*/
@TableLogic
private String delFlag;
}

View File

@@ -0,0 +1,94 @@
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;
/**
* OTA 升级任务对象 bm_ota_task
*
* @author Kou
* @date 2026-09-16
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("bm_ota_task")
public class BmOtaTask extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键(雪花)
*/
@TableId(value = "task_id")
private Long taskId;
/**
* 升级包主键
*/
private Long packageId;
/**
* 版本号(冗余)
*/
private String version;
/**
* 设备主键
*/
private Long deviceId;
/**
* 设备编号(冗余)
*/
private String deviceNo;
/**
* 状态(0 待升级 1 升级中 2 成功 3 失败)
*/
private String status;
/**
* 结果信息
*/
private String resultMsg;
/**
* 进度 0-100
*/
private Integer progress;
/**
* 操作人(后台用户)
*/
private Long operatorId;
/**
* 下发次数
*/
private Integer times;
/**
* 开始时间
*/
private LocalDateTime startTime;
/**
* 完成时间
*/
private LocalDateTime finishTime;
/**
* 逻辑删除(0 存在 1 删除)
*/
@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,45 @@
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 设备绑定入参({@code POST /app/device/bind})。
* <p>
* 字段口径对齐《开发计划》§8.2:入参 {@code imei} + {@code name}(≤20)。
* <ul>
* <li>{@code imei}:设备绑定码,默认 15 位数字({@link AppConstants#IMEI_REGEX})。</li>
* <li>{@code name}:用户侧设备名称,按<b>字符数</b>限制 ≤ {@link AppConstants#DEVICE_NAME_MAX_LENGTH},
* 中文按 1 字计({@link Size} 以 {@link CharSequence#length()} 计,非 UTF-8 字节数)。</li>
* </ul>
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class AppDeviceBindBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 设备 IMEI(绑定码,默认 15 位数字)。
*/
@NotBlank(message = "IMEI 不能为空")
@Pattern(regexp = AppConstants.IMEI_REGEX, message = "IMEI 格式不正确")
private String imei;
/**
* 用户侧设备名称(≤20 个字符)。
*/
@NotBlank(message = "设备名称不能为空")
@Size(max = AppConstants.DEVICE_NAME_MAX_LENGTH, message = "设备名称不能超过 20 个字")
private String name;
}

View File

@@ -0,0 +1,40 @@
package org.dromara.device.domain.bo;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 眼罩开关入参({@code PUT /app/device/{deviceNo}/hood})。
* <p>
* {@code deviceNo} 由路径变量提供,Controller 回填到本 BO(与 {@code AppDeviceNameBo} 同一约定),
* <b>该字段不做 Bean Validation</b>:Spring 在参数解析阶段完成 {@code @RequestBody} 校验,
* 彼时路径回填尚未发生、字段恒为 null。空值由服务层兜底,统一按「设备不存在」处理。
* <p>
* {@code coverOn} 必须显式传值——不接受「缺省即打开」:眼罩是物理器械,
* 请求体漏字段时服务端必须报错而不是替用户猜一个动作。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class AppDeviceHoodBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 设备编号(由 Controller 从路径变量 {@code {deviceNo}} 回填)。
* <p>不做 Bean Validation(见类注释);空值由服务层兜底。
*/
private String deviceNo;
/**
* 是否遮盖:{@code true} 遮盖 / {@code false} 打开。
*/
@NotNull(message = "眼罩状态不能为空")
private Boolean coverOn;
}

View File

@@ -0,0 +1,43 @@
package org.dromara.device.domain.bo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.dromara.device.constant.AppConstants;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 设备重命名入参({@code PUT /app/device/{deviceNo}/name})。
* <p>
* {@code deviceNo} 由路径变量提供,Controller 会将其回填到本 BO 的 {@code deviceNo} 字段,
* 以保证服务层签名 {@code rename(AppDeviceNameBo bo, Long userId)} 自洽。
* <b>该字段不做 Bean Validation</b>:Spring 在参数解析阶段即完成 {@code @RequestBody} 校验,
* 彼时路径回填尚未发生、字段恒为 null,若加校验会导致「按契约(body 仅 name)调用」恒失败;
* 空值改由服务层兜底,统一按「设备不存在」处理(与越权隔离同一文案)。
* {@code name} 按<b>字符数</b>限制 ≤ {@link AppConstants#DEVICE_NAME_MAX_LENGTH}。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class AppDeviceNameBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 设备编号(由 Controller 从路径变量 {@code {deviceNo}} 回填)。
* <p>不做 Bean Validation(见类注释);空值由 {@code AppDeviceServiceImpl#rename} 兜底。
*/
private String deviceNo;
/**
* 新的用户侧设备名称(≤20 个字符)。
*/
@NotBlank(message = "设备名称不能为空")
@Size(max = AppConstants.DEVICE_NAME_MAX_LENGTH, message = "设备名称不能超过 20 个字")
private String name;
}

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

@@ -0,0 +1,38 @@
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-17
*/
@Data
public class AppEmailCodeBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 邮箱地址(长度上限对齐 {@code sys_user.email varchar(50)})。
*/
@NotBlank(message = "邮箱不能为空")
@Size(max = 50, message = "邮箱长度不能超过{max}个字符")
@Pattern(regexp = AppConstants.EMAIL_REGEX, message = "请输入正确的邮箱地址")
private String email;
/**
* 场景:register / login / resetPwd。
*/
@NotBlank(message = "验证码场景不能为空")
private String scene;
}

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-17
*/
@Data
public class AppEmailCodeLoginBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 邮箱地址。
*/
@NotBlank(message = "邮箱不能为空")
@Size(max = 50, message = "邮箱长度不能超过{max}个字符")
@Pattern(regexp = AppConstants.EMAIL_REGEX, message = "请输入正确的邮箱地址")
private String email;
/**
* 邮箱验证码(6 位数字)。
*/
@NotBlank(message = "验证码不能为空")
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
private String code;
}

View File

@@ -0,0 +1,38 @@
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-17
*/
@Data
public class AppEmailLoginBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 邮箱地址。
*/
@NotBlank(message = "邮箱不能为空")
@Size(max = 50, message = "邮箱长度不能超过{max}个字符")
@Pattern(regexp = AppConstants.EMAIL_REGEX, message = "请输入正确的邮箱地址")
private String email;
/**
* 密码。
*/
@NotBlank(message = "密码不能为空")
private String password;
}

View File

@@ -0,0 +1,61 @@
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.common.core.xss.Xss;
import org.dromara.device.constant.AppConstants;
import org.dromara.device.constant.DeviceMsgConstants;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 邮箱注册入参。
* <p>
* <b>不含手机号</b>:邮箱注册先建号({@code phone_number} 为 {@code NULL}),注册后可直接登录;
* 用户绑定设备前再按需调用 {@code PUT /app/user/phone} 完成手机号绑定,未绑手机号的账号
* <b>无法绑定设备</b>(服务端硬拦截)。
* <p>
* 协议勾选态由 APP 前端把关,服务端不落库(按《开发计划》§8.1)。
*
* @author Kou
* @date 2026-09-17
*/
@Data
public class AppEmailRegisterBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 邮箱地址。
*/
@NotBlank(message = "邮箱不能为空")
@Size(max = 50, message = "邮箱长度不能超过{max}个字符")
@Pattern(regexp = AppConstants.EMAIL_REGEX, message = "请输入正确的邮箱地址")
private String email;
/**
* 邮箱验证码(6 位数字)。
*/
@NotBlank(message = "验证码不能为空")
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
private String code;
/**
* 密码(8-20 位,含大小写字母和数字)。
*/
@NotBlank(message = "密码不能为空")
@Pattern(regexp = AppConstants.PASSWORD_REGEX, message = DeviceMsgConstants.PASSWORD_FORMAT)
private String password;
/**
* 昵称(选填,为空时默认生成 user_ 加当前时间戳)。
*/
@Xss(message = "昵称不能包含脚本字符")
@Size(max = 30, message = "昵称长度不能超过{max}个字符")
private String nickName;
}

View File

@@ -0,0 +1,47 @@
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 org.dromara.device.constant.DeviceMsgConstants;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 邮箱找回密码(重置密码)入参。
*
* @author Kou
* @date 2026-09-17
*/
@Data
public class AppEmailResetPwdBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 邮箱地址。
*/
@NotBlank(message = "邮箱不能为空")
@Size(max = 50, message = "邮箱长度不能超过{max}个字符")
@Pattern(regexp = AppConstants.EMAIL_REGEX, message = "请输入正确的邮箱地址")
private String email;
/**
* 邮箱验证码(6 位数字)。
*/
@NotBlank(message = "验证码不能为空")
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
private String code;
/**
* 新密码(8-20 位,含大小写字母和数字)。
*/
@NotBlank(message = "新密码不能为空")
@Pattern(regexp = AppConstants.PASSWORD_REGEX, message = DeviceMsgConstants.PASSWORD_FORMAT)
private String newPassword;
}

View File

@@ -0,0 +1,38 @@
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 密码登录入参。
* <p>
* 登录仅校验密码非空(历史密码可能不符合新密码规则,不在登录时做格式拦截)。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class AppLoginBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
*/
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
private String phoneNumber;
/**
* 密码。
*/
@NotBlank(message = "密码不能为空")
private String password;
}

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

@@ -0,0 +1,37 @@
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 org.dromara.device.constant.DeviceMsgConstants;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 修改密码入参。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class AppPasswordBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 原密码。
*/
@NotBlank(message = "原密码不能为空")
private String oldPassword;
/**
* 新密码(8-20 位,含大小写字母和数字)。
*/
@NotBlank(message = "新密码不能为空")
@Pattern(regexp = AppConstants.PASSWORD_REGEX, message = DeviceMsgConstants.PASSWORD_FORMAT)
private String newPassword;
}

View File

@@ -0,0 +1,39 @@
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 换绑手机入参。
* <p>
* 验证码发送到<b>新手机号</b>(scene = rebind)。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class AppPhoneBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 新手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
*/
@NotBlank(message = "新手机号不能为空")
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
private String newPhoneNumber;
/**
* 短信验证码(6 位数字,发送至新手机号)。
*/
@NotBlank(message = "验证码不能为空")
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
private String code;
}

View File

@@ -0,0 +1,56 @@
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.common.core.xss.Xss;
import org.dromara.device.constant.AppConstants;
import org.dromara.device.constant.DeviceMsgConstants;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 手机号注册入参。
* <p>
* 协议勾选态由 APP 前端把关,服务端不落库(按《开发计划》§8.1)。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class AppRegisterBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
*/
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
private String phoneNumber;
/**
* 短信验证码(6 位数字)。
*/
@NotBlank(message = "验证码不能为空")
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
private String code;
/**
* 密码(8-20 位,含大小写字母和数字)。
*/
@NotBlank(message = "密码不能为空")
@Pattern(regexp = AppConstants.PASSWORD_REGEX, message = DeviceMsgConstants.PASSWORD_FORMAT)
private String password;
/**
* 昵称(选填,为空时默认生成 user_ 加当前时间戳)。
*/
@Xss(message = "昵称不能包含脚本字符")
@Size(max = 30, message = "昵称长度不能超过{max}个字符")
private String nickName;
}

View File

@@ -0,0 +1,45 @@
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 org.dromara.device.constant.DeviceMsgConstants;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 忘记密码(重置密码)入参。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class AppResetPwdBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
*/
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "请输入正确的手机号")
private String phoneNumber;
/**
* 短信验证码(6 位数字)。
*/
@NotBlank(message = "验证码不能为空")
@Pattern(regexp = AppConstants.SMS_CODE_REGEX, message = "验证码格式不正确")
private String code;
/**
* 新密码(8-20 位,含大小写字母和数字)。
*/
@NotBlank(message = "新密码不能为空")
@Pattern(regexp = AppConstants.PASSWORD_REGEX, message = DeviceMsgConstants.PASSWORD_FORMAT)
private String newPassword;
}

View File

@@ -0,0 +1,36 @@
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 发送短信验证码入参。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class AppSmsCodeBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 手机号:大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码。
*/
@NotBlank(message = "{user.phonenumber.not.blank}")
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "{user.mobile.phone.number.not.valid}")
private String phoneNumber;
/**
* 场景:login / register / resetPwd / bind / rebind。{@code rebindOld} 仅登录后原号发码接口可用。
*/
@NotBlank(message = "{app.sms.scene.not.blank}")
private String scene;
}

View File

@@ -0,0 +1,37 @@
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 短信验证码登录入参。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class AppSmsLoginBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 手机号(大陆 11 位,或带 {@code +}/{@code 00} 国家码的国际号码)。
*/
@NotBlank(message = "{user.phonenumber.not.blank}")
@Pattern(regexp = AppConstants.PHONE_REGEX, message = "{user.mobile.phone.number.not.valid}")
private String phoneNumber;
/**
* 短信验证码(6 位数字)。
*/
@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,27 @@
package org.dromara.device.domain.bo;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 开始推流入参({@code POST /app/stream/start})。
*
* @author Kou
* @date 2026-09-17
*/
@Data
public class AppStreamStartBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 设备编号
*/
@NotBlank(message = "设备编号不能为空")
private String deviceNo;
}

View File

@@ -0,0 +1,49 @@
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;
/**
* APP 用户管理查询入参({@code GET /device/app-user/list})。
* <p>
* 仅面向 APP 用户({@code sys_user.user_type = app_user})——类型条件由服务端强制附加,
* 前端<b>不需要</b>也无法通过本入参覆盖。
* <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]},与框架既有约定一致。
*
* @author Kou
* @date 2026-09-17
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class AppUserAdminQueryBo extends PageQuery {
@Serial
private static final long serialVersionUID = 1L;
/**
* 关键词:模糊匹配账号 / 昵称 / 手机号 / 邮箱(四字段 OR)。
*/
private String keyword;
/**
* 账号状态(0 正常 1 停用);留空查询全部。
*/
private String status;
/**
* 扩展查询参数(约定键 {@code beginTime} / {@code endTime}:注册时间区间)。
*/
private Map<String, Object> params = new HashMap<>();
}

View File

@@ -0,0 +1,44 @@
package org.dromara.device.domain.bo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import org.dromara.device.constant.AppConstants;
import org.dromara.device.constant.DeviceMsgConstants;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 用户重置密码入参({@code PUT /device/app-user/reset-pwd})。
* <p>
* 密码规则与 APP 端注册 / 改密<b>完全同源</b>({@link AppConstants#PASSWORD_REGEX}):
* 管理员在后台设的密码,必须同样满足 APP 端的登录前置要求,
* 否则会出现「后台设置成功、用户却因密码不合规被 APP 拒」的割裂体验。
* <p>
* <b>不引入「确认密码」字段</b>:二次输入属前端交互,服务端只需最终值。
*
* @author Kou
* @date 2026-09-17
*/
@Data
public class AppUserResetPwdBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 用户主键
*/
@NotNull(message = "用户主键不能为空")
private Long userId;
/**
* 新密码(8-20 位,含大小写字母和数字)
*/
@NotBlank(message = "新密码不能为空")
@Pattern(regexp = AppConstants.PASSWORD_REGEX, message = DeviceMsgConstants.PASSWORD_FORMAT)
private String newPassword;
}

View File

@@ -0,0 +1,39 @@
package org.dromara.device.domain.bo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 用户状态变更入参({@code PUT /device/app-user/status})。
* <p>
* {@code status} 用正则而非枚举校验:字典 {@code sys_user.status} 为 {@code varchar(1)},
* 取值仅 {@code 0}(正常)/ {@code 1}(停用),正则能同时挡住空串与超长值。
*
* @author Kou
* @date 2026-09-17
*/
@Data
public class AppUserStatusBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 用户主键
*/
@NotNull(message = "用户主键不能为空")
private Long userId;
/**
* 目标状态(0 正常 1 停用)
*/
@NotBlank(message = "账号状态不能为空")
@Pattern(regexp = "^[01]$", message = "账号状态只能为 0(正常)或 1(停用)")
private String status;
}

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,69 @@
package org.dromara.device.domain.bo;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.dromara.device.domain.BmDeviceBind;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 设备绑定流水业务对象 bm_device_bind
*
* @author Kou
* @date 2026-09-16
*/
@Data
@AutoMapper(target = BmDeviceBind.class, reverseConvertGenerate = false)
public class DeviceBindBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键
*/
private Long bindId;
/**
* 设备物理主键
*/
private Long deviceId;
/**
* 设备编号(冗余)
*/
private String deviceNo;
/**
* 操作 APP 用户
*/
private Long userId;
/**
* IMEI(冗余快照)
*/
private String imei;
/**
* 动作(bind / unbind / rebind)
*/
private String action;
/**
* 操作时间
*/
private LocalDateTime bindTime;
/**
* 备注
*/
private String remark;
/**
* 关键字(模糊匹配 deviceNo / imei,查询用)
*/
private String keyword;
}

View File

@@ -0,0 +1,186 @@
package org.dromara.device.domain.bo;
import io.github.linpeilie.annotations.AutoMapper;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.dromara.common.core.validate.AddGroup;
import org.dromara.common.core.validate.EditGroup;
import org.dromara.device.domain.BmDevice;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 设备主表业务对象 bm_device
*
* @author Kou
* @date 2026-09-16
*/
@Data
@AutoMapper(target = BmDevice.class, reverseConvertGenerate = false)
public class DeviceBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@NotNull(message = "设备主键不能为空", groups = {EditGroup.class})
private Long deviceId;
/**
* 业务编号(留空时由服务端生成 BM+yyyyMMdd+4位序号)
*/
private String deviceNo;
/**
* 绑定码 IMEI
*/
@NotBlank(message = "IMEI 不能为空", groups = {AddGroup.class, EditGroup.class})
private String imei;
/**
* 用户侧名称
*/
private String name;
/**
* 型号
*/
private String model;
/**
* 出厂序列号
*/
private String sn;
/**
* 硬件版本
*/
private String hwVersion;
/**
* 软件版本
*/
private String swVersion;
/**
* 固件版本
*/
private String fwVersion;
/**
* 数据包版本
*/
private String pkgVersion;
/**
* 设备 IP
*/
private String ip;
/**
* MAC
*/
private String mac;
/**
* WiFi SSID
*/
private String wifiSsid;
/**
* 状态(0 未激活 1 在线 2 离线)
*/
private String status;
/**
* 当前绑定 APP 用户
*/
private Long bindUserId;
/**
* 最近一次绑定时间
*/
private LocalDateTime bindTime;
/**
* 头套连接(0 断开 1 已连接)
*/
private String hoodConnected;
/**
* 眼罩状态(0 打开 1 遮盖)
*/
private String hoodCoverOn;
/**
* 信号等级(0 离线 1 弱 2 一般 3 强)
*/
private Integer signalLevel;
/**
* 头套电量 0-100
*/
private Integer hoodBattery;
/**
* 脚环电量 0-100
*/
private Integer ringBattery;
/**
* 头套低电告警阈值 0-100(电量 ≤ 该值触发告警;0 表示不触发)
* <p>
* 恢复阈值不在此配置:由服务端按「触发阈值 + 回差」派生
* (见 {@code DeviceMqttProperties#resolveRecoverThreshold(int)}),
* 避免出现「恢复阈值 ≤ 触发阈值」把设备卡死在告警态的配置。
*/
@Min(value = 0, message = "头套低电阈值不能小于 0", groups = {AddGroup.class, EditGroup.class})
@Max(value = 100, message = "头套低电阈值不能大于 100", groups = {AddGroup.class, EditGroup.class})
private Integer hoodBatteryThreshold;
/**
* 脚环低电告警阈值 0-100(电量 ≤ 该值触发告警;0 表示不触发)
*/
@Min(value = 0, message = "脚环低电阈值不能小于 0", groups = {AddGroup.class, EditGroup.class})
@Max(value = 100, message = "脚环低电阈值不能大于 100", groups = {AddGroup.class, EditGroup.class})
private Integer ringBatteryThreshold;
/**
* 最新经度
*/
private BigDecimal lng;
/**
* 最新纬度
*/
private BigDecimal lat;
/**
* 地址
*/
private String address;
/**
* 最后在线时间
*/
private LocalDateTime lastOnlineTime;
/**
* 备注
*/
private String remark;
/**
* 关键字(模糊匹配 deviceNo / name / imei,查询用)
*/
private String keyword;
}

View File

@@ -0,0 +1,37 @@
package org.dromara.device.domain.bo;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
/**
* OTA 批量创建升级任务入参({@code POST /device/ota/batch},PRD §5 R-31)。
* <p>
* 字段名严格对齐前端 {@code types.ts} 的 {@code OtaBatchForm}({@code packageId} / {@code deviceIds})。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class OtaBatchBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 目标升级包主键(必填)
*/
@NotNull(message = "升级包不能为空")
private Long packageId;
/**
* 勾选的设备主键集合(必填非空)
*/
@NotEmpty(message = "请至少选择一台设备")
private List<Long> deviceIds;
}

View File

@@ -0,0 +1,83 @@
package org.dromara.device.domain.bo;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.dromara.device.domain.BmOtaPackage;
import java.io.Serial;
import java.io.Serializable;
/**
* OTA 升级包业务对象 bm_ota_package
*
* @author Kou
* @date 2026-09-16
*/
@Data
@AutoMapper(target = BmOtaPackage.class, reverseConvertGenerate = false)
public class OtaPackageBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键
*/
private Long packageId;
/**
* 适用型号
*/
private String model;
/**
* 版本号
*/
private String version;
/**
* 关联 sys_oss.oss_id
*/
private Long ossId;
/**
* 文件 URL(冗余)
*/
private String fileUrl;
/**
* 文件名
*/
private String fileName;
/**
* 文件字节数
*/
private Long fileSize;
/**
* 校验值 MD5
*/
private String md5;
/**
* 更新说明
*/
private String changelog;
/**
* 状态(0 停用 1 启用)
*/
private String status;
/**
* 备注
*/
private String remark;
/**
* 关键字(模糊匹配 model / version,查询用)
*/
private String keyword;
}

View File

@@ -0,0 +1,89 @@
package org.dromara.device.domain.bo;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.dromara.device.domain.BmOtaTask;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* OTA 升级任务业务对象 bm_ota_task
*
* @author Kou
* @date 2026-09-16
*/
@Data
@AutoMapper(target = BmOtaTask.class, reverseConvertGenerate = false)
public class OtaTaskBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键
*/
private Long taskId;
/**
* 升级包主键
*/
private Long packageId;
/**
* 版本号(冗余)
*/
private String version;
/**
* 设备主键
*/
private Long deviceId;
/**
* 设备编号(冗余)
*/
private String deviceNo;
/**
* 状态(0 待升级 1 升级中 2 成功 3 失败)
*/
private String status;
/**
* 结果信息
*/
private String resultMsg;
/**
* 进度 0-100
*/
private Integer progress;
/**
* 操作人(后台用户)
*/
private Long operatorId;
/**
* 下发次数
*/
private Integer times;
/**
* 开始时间
*/
private LocalDateTime startTime;
/**
* 完成时间
*/
private LocalDateTime finishTime;
/**
* 关键字(模糊匹配 deviceNo / version,查询用)
*/
private String keyword;
}

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,43 @@
package org.dromara.device.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 协议(用户协议 / 隐私政策)视图对象。
* <p>
* 内容来源为配置项 {@code device.app.agreement.*}(见 {@code AppProperties.Agreement}),
* 返回「URL + 版本 + 更新时间」,不落地协议勾选态。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class AppAgreementVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 协议类型:user(用户协议)/ privacy(隐私政策)。
*/
private String type;
/**
* 协议 URL(或富文本地址)。
*/
private String url;
/**
* 协议版本。
*/
private String version;
/**
* 协议更新时间。
*/
private String updateTime;
}

View File

@@ -0,0 +1,109 @@
package org.dromara.device.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* APP 设备详情视图对象({@code GET /app/device/{deviceNo}})。
* <p>
* 范围对齐《开发计划》§8.2「详情:坐标、眼罩、双电量、信号、头套连接、地址」:
* 在列表卡片字段基础上<b>增补眼罩状态</b>({@code hoodCoverOn}),用于详情页展示与后续眼罩开关联动。
* <pre>
* 坐标:lng, lat, address
* 眼罩:hoodCoverOn
* 双电量:hoodBattery, ringBattery
* 信号:signalLevel, signalText
* 头套连接:hoodConnected, hoodConnectedText
* 身份:deviceNo, name, imei(脱敏), model, status
* </pre>
* 说明:{@code signalText} / {@code hoodConnectedText} 为服务端派生的展示文案;
* {@code imei} 复用 {@code DesensitizedUtils} 脱敏后返回。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class AppDeviceDetailVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 设备编号(对外主展示键)。
*/
private String deviceNo;
/**
* 用户侧设备名称。
*/
private String name;
/**
* IMEI(脱敏,如 1234****5678)。
*/
private String imei;
/**
* 型号。
*/
private String model;
/**
* 设备状态(0 未激活 1 在线 2 离线)。
*/
private String status;
/**
* 眼罩状态(0 打开 1 遮盖)。
*/
private String hoodCoverOn;
/**
* 信号等级(0 离线 1 弱 2 一般 3 强)。
*/
private Integer signalLevel;
/**
* 信号文案(离线 / 弱 / 一般 / 强)。
*/
private String signalText;
/**
* 头套连接(0 已断开 1 已连接)。
*/
private String hoodConnected;
/**
* 头套连接文案(已断开 / 已连接)。
*/
private String hoodConnectedText;
/**
* 头套电量 0-100。
*/
private Integer hoodBattery;
/**
* 脚环电量 0-100。
*/
private Integer ringBattery;
/**
* 最新经度。
*/
private BigDecimal lng;
/**
* 最新纬度。
*/
private BigDecimal lat;
/**
* 地址。
*/
private String address;
}

View File

@@ -0,0 +1,102 @@
package org.dromara.device.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* APP 设备列表卡片视图对象({@code GET /app/device/list})。
* <p>
* 字段严格对齐《开发计划》§8.2「列表卡片字段」,字段名保持一致,不新增未约定字段:
* <pre>
* deviceNo, name, imei(脱敏), model,
* status, signalLevel, signalText, // 强 / 一般 / 弱 / 离线(字典 4 值)
* hoodConnected, hoodConnectedText, // 头套已连接 / 已断开
* hoodBattery, ringBattery,
* lng, lat, address
* </pre>
* 说明:{@code signalText} / {@code hoodConnectedText} 为服务端按枚举/字典派生的展示文案;
* {@code imei} 复用 {@code DesensitizedUtils} 脱敏后返回(不外泄完整绑定码)。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class AppDeviceListVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 设备编号(对外主展示键)。
*/
private String deviceNo;
/**
* 用户侧设备名称。
*/
private String name;
/**
* IMEI(脱敏,如 1234****5678)。
*/
private String imei;
/**
* 型号。
*/
private String model;
/**
* 设备状态(0 未激活 1 在线 2 离线)。
*/
private String status;
/**
* 信号等级(0 离线 1 弱 2 一般 3 强)。
*/
private Integer signalLevel;
/**
* 信号文案(离线 / 弱 / 一般 / 强)。
*/
private String signalText;
/**
* 头套连接(0 已断开 1 已连接)。
*/
private String hoodConnected;
/**
* 头套连接文案(已断开 / 已连接)。
*/
private String hoodConnectedText;
/**
* 头套电量 0-100。
*/
private Integer hoodBattery;
/**
* 脚环电量 0-100。
*/
private Integer ringBattery;
/**
* 最新经度。
*/
private BigDecimal lng;
/**
* 最新纬度。
*/
private BigDecimal lat;
/**
* 地址。
*/
private String address;
}

View File

@@ -0,0 +1,41 @@
package org.dromara.device.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 眼罩开关结果视图对象({@code PUT /app/device/{deviceNo}/hood} 的响应体)。
* <p>
* 口径见《设备通信协议-一期.md》§8.2:<b>只有收到 {@code ack.ok=true} 才返回成功</b>,
* 响应即「设备已确认执行」后的最新眼罩状态,APP 可直接据此刷新按钮,不必再拉一次详情。
* <p>
* 为什么返回状态而不是一句文案:眼罩是双向开关(打开 / 遮盖),
* 返回完整状态让 APP 无需依赖本地乐观更新,弱网下也不会出现「按钮与设备实际相反」。
*
* @author Kou
* @date 2026-09-16
*/
@Data
public class AppHoodResultVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 设备编号。
*/
private String deviceNo;
/**
* 眼罩状态(0 打开 1 遮盖)。
*/
private String hoodCoverOn;
/**
* 眼罩状态文案(打开 / 遮盖)。
*/
private String hoodCoverOnText;
}

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

@@ -0,0 +1,50 @@
package org.dromara.device.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 直播播放地址(带 token 查询参数)。
*
* @author Kou
* @date 2026-09-17
*/
@Data
public class AppStreamPlayUrlsVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* HTTP-FLV
*/
private String flv;
/**
* HLS
*/
private String hls;
/**
* RTMP
*/
private String rtmp;
/**
* RTSP
*/
private String rtsp;
/**
* WebRTC
*/
private String webrtc;
/**
* MJPEG(HTTP multipart,直传 JPEG)
*/
private String mjpeg;
}

View File

@@ -0,0 +1,65 @@
package org.dromara.device.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 直播播放信息(开始推流 / 取播放地址)。
*
* @author Kou
* @date 2026-09-17
*/
@Data
public class AppStreamPlayVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 设备编号
*/
private String deviceNo;
/**
* 流名(与 deviceNo 相同)
*/
private String streamId;
/**
* 流媒体应用名
*/
private String app;
/**
* 当前推流是否在线
*/
private Boolean online;
/**
* 设备推流地址
*/
private String pushUrl;
/**
* 播放鉴权 token
*/
private String playToken;
/**
* token 过期时间(epoch 毫秒)
*/
private Long expireAt;
/**
* token 有效期(秒)
*/
private Integer expireSeconds;
/**
* 各协议播放地址(已附带 token)
*/
private AppStreamPlayUrlsVo playUrls;
}

View File

@@ -0,0 +1,35 @@
package org.dromara.device.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* APP 直播流状态。
*
* @author Kou
* @date 2026-09-17
*/
@Data
public class AppStreamStatusVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 设备编号
*/
private String deviceNo;
/**
* 流名
*/
private String streamId;
/**
* 是否在线
*/
private Boolean online;
}

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