diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml
new file mode 100644
index 0000000..9298202
--- /dev/null
+++ b/.idea/studiobot.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/APP端API接口文档.md b/APP端API接口文档.md
new file mode 100644
index 0000000..635efe9
--- /dev/null
+++ b/APP端API接口文档.md
@@ -0,0 +1,1628 @@
+# 智能飞禽物联平台 APP 端 API 接口文档
+
+> 文档版本:v2.0
+> 生成日期:2026-09-20
+> 面向对象:Android、iOS、跨端 APP 前端开发人员
+> 代码基线:`bird_monitoring` 当前 `HEAD`(`bc1074d`)
+> 接口范围:APP 认证、个人中心、设备、直播、版本检查、消息中心
+> 接口数量:35 个 APP 业务接口,其中 34 个可用、1 个旧接口已停用;另说明动态返回的 MJPEG 播放地址
+
+本文档以当前后端 Controller、BO、VO、Service、统一异常处理和 Jackson 配置为事实来源。它不包含后台管理端、工作流、代码生成器、演示模块和设备 MQTT 上下行协议。
+
+---
+
+## 1. 接入约定
+
+### 1.1 Base URL
+
+本地后端默认地址:
+
+```text
+http://localhost:8081
+```
+
+当前 Servlet Context Path 为 `/`,因此完整示例为:
+
+```text
+POST http://localhost:8081/app/auth/login
+GET http://localhost:8081/app/device/list
+```
+
+生产环境只替换协议、域名和端口,本文中的接口路径不变。建议 APP 只在一个环境配置项中维护 Base URL,不要在页面中拼接域名。
+
+### 1.2 请求头与登录态
+
+建议所有 APP JSON 请求统一发送:
+
+```http
+Content-Type: application/json;charset=utf-8
+clientid: app
+```
+
+登录后的受保护接口还必须发送:
+
+```http
+Authorization: Bearer
+```
+
+| 请求头 | 是否必填 | 使用范围 | 说明 |
+|---|---:|---|---|
+| `Content-Type` | 有 JSON Body 时是 | `POST`、`PUT` | 固定为 `application/json;charset=utf-8` |
+| `Authorization` | 受保护接口是 | 登录后接口 | 必须是 `Bearer`、一个空格、再接登录返回的 token |
+| `clientid` | 受保护接口是 | 登录后接口 | 当前固定值 `app`,必须与 token 内的客户端标识一致 |
+| `Content-Language` | 否 | 全部 | 可传 `zh_CN`;部分校验文案支持国际化 |
+
+公共接口:
+
+- `/app/auth/**` 全部使用 `@SaIgnore`,不要求登录。
+- `GET /app/version/latest` 使用 `@SaIgnore`,不要求登录。
+- 公共接口当前不会校验 token 与 `clientid`,但建议请求封装仍统一携带 `clientid: app`。
+- `POST /app/auth/logout` 本身允许未登录调用;若要注销当前会话,必须携带当前 token。
+
+受保护接口:
+
+- `/app/user/**`
+- `/app/device/**`
+- `/app/stream/**`
+- `/app/message/**`
+
+APP token 只允许访问 `/app/**`。拿 APP token 调后台 `/system/**`、`/device/**` 等路径会被拒绝。
+
+当前默认 token 固定有效期和活跃超时均为 30 天;允许同一账号多端同时登录,每次登录生成独立 token。普通退出只注销当前 token,注销账号会清理该账号的全部会话。
+
+### 1.3 统一响应结构
+
+除 MJPEG 图片/视频流外,所有接口都返回:
+
+```ts
+export interface ApiResponse {
+ code: number;
+ msg: string;
+ data: T | null;
+}
+```
+
+带业务数据的成功响应:
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": {
+ "token": "eyJ..."
+ }
+}
+```
+
+只返回操作结果的成功响应:
+
+```json
+{
+ "code": 200,
+ "msg": "修改成功",
+ "data": null
+}
+```
+
+业务失败响应:
+
+```json
+{
+ "code": 500,
+ "msg": "验证码错误",
+ "data": null
+}
+```
+
+| `code` | 含义 | APP 处理建议 |
+|---:|---|---|
+| `200` | 成功 | 使用 `data`;操作类接口可展示 `msg` |
+| `400` | JSON 或参数格式错误 | 展示 `msg`,保留当前表单 |
+| `401` | 未登录、token 失效、`clientid` 不匹配 | 清 token,停止重试,跳转登录页 |
+| `403` | token 无权访问当前路径 | 展示 `msg`,不要循环重试 |
+| `405` | HTTP Method 错误 | 作为客户端版本缺陷上报 |
+| `500` | Bean Validation 或业务校验失败 | `msg` 通常可直接展示给用户 |
+| `601` | 警告 | 使用警告样式展示 `msg` |
+
+重要:业务异常和多数鉴权异常会包装在响应体中,HTTP 状态不一定同步为失败状态。APP 必须判断响应体 `code`,不能只依赖 HTTP 2xx。
+
+### 1.4 JSON 类型与空值
+
+当前全局 Jackson 配置对前端类型有以下影响:
+
+| Java 类型 | JSON 表现 | APP 建议类型 |
+|---|---|---|
+| `Long` | 安全整数范围内为 number,超出后为 string | `string \| number` |
+| 雪花 ID | 通常超出 JS 安全整数,实际通常为 string | `string \| number`,业务中按 string 使用 |
+| `BigDecimal` | 固定序列化为 string | `string \| null` |
+| `Integer` | number | `number` |
+| `Boolean` | boolean | `boolean` |
+| `LocalDateTime` | `yyyy-MM-dd HH:mm:ss` | `string` |
+
+因此:
+
+- `lng`、`lat` 是十进制字符串,例如 `"116.397128"`,不是 JSON number。
+- `messageId`、`alertId` 等雪花 ID 不得转成 JavaScript `number` 后再回传,否则可能丢精度。
+- `fileSize`、`expireAt`、未读数等较小 `Long` 当前通常是 number,但客户端仍建议兼容 `string | number`。
+- 可空字段会返回 `null`;不要把 `null`、空字符串和 `0` 混为一类。
+- 日期时间不含时区偏移,按服务端本地时区解释;若 APP 需要跨时区展示,应在客户端明确约定时区。
+
+### 1.5 分页结构
+
+消息列表使用分页响应:
+
+```ts
+export interface PageResult {
+ total: number;
+ rows: T[];
+}
+```
+
+示例:
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": {
+ "total": 26,
+ "rows": []
+ }
+}
+```
+
+设备列表不分页,直接返回数组。
+
+### 1.6 通用输入规则
+
+| 数据 | 当前规则 |
+|---|---|
+| 中国大陆手机号 | `13800138000`、`+8613800138000`、`008613800138000` 均可;服务端统一保存为 11 位号码 |
+| 国际手机号 | 必须带 `+` 或 `00` 国家码,总数字长度 7-15 位,如 `+14155552671` |
+| 手机号字符 | 请求 DTO 校验不接受空格、括号或连字符,提交前必须清理 |
+| 邮箱 | 最长 50 字符;服务端去首尾空白并转小写;可通过配置限制允许域名 |
+| 验证码 | 6 位数字、5 分钟有效、验证成功后立即失效 |
+| 密码 | 8-20 位,同时包含大写字母、小写字母和数字,不允许空白字符 |
+| IMEI | 15 位数字 |
+| 设备名称 | 1-20 个字符,中文按一个字符计算 |
+| `deviceNo` | 路径参数;使用时应做 URL 编码 |
+
+短信和邮箱验证码均按“通道 + 场景 + 账号”隔离:不同场景的验证码不能混用。同一目标、同一场景 60 秒内只能发送一次。错误验证码在 60 秒窗口内超过 5 次后会短暂限速;正确验证码仍可通过,不会被错误尝试主动销毁。
+
+### 1.7 前端错误处理原则
+
+1. `code === 200` 才进入成功分支。
+2. `code === 401` 时清理本地登录态,不自动无限重放请求。
+3. `code === 500` 时优先展示后端 `msg`,不要替换成笼统的“请求失败”。
+4. 网络超时、DNS、TLS、网关错误与业务错误分开处理。
+5. 对眼罩控制、直播控制等物理动作,不做成功态的乐观更新,以服务端确认结果为准。
+6. 所有 ID 在 URL 中按原始字符串传递,不执行 `Number(id)`。
+
+---
+
+## 2. 认证接口
+
+基础路径:`/app/auth`。本组接口免登录。
+
+> APP 只能使用本章的 `/app/auth/sms/code` 和 `/app/auth/email/code` 获取验证码。仓库中的
+> `/resource/sms/code`、`/resource/email/code`、`/resource/gmail/code`、`/sendInternational`
+> 属于框架或后台兼容入口,验证码位数、场景和 Redis 缓存键都与 APP 认证链路不同。
+> 混用后即使验证码发送成功,APP 注册、登录或找回密码仍会校验失败。
+
+### 2.1 发送短信验证码
+
+```http
+POST /app/auth/sms/code
+```
+
+请求体:
+
+```json
+{
+ "phoneNumber": "+8613800138000",
+ "scene": "login"
+}
+```
+
+| 字段 | 类型 | 必填 | 规则 |
+|---|---|---:|---|
+| `phoneNumber` | string | 是 | 见 1.6 手机号规则 |
+| `scene` | string | 是 | `register`、`login`、`resetPwd`、`bind`、`rebind` |
+
+| `scene` | 后续接口 | 使用场景 |
+|---|---|---|
+| `register` | `POST /app/auth/register` | 手机号注册 |
+| `login` | `POST /app/auth/sms-login` | 短信验证码登录 |
+| `resetPwd` | `POST /app/auth/reset-pwd` | 手机号找回密码 |
+| `bind` | `PUT /app/user/phone` | 邮箱注册账号首次绑定手机号 |
+| `rebind` | `PUT /app/user/phone` | 已有手机号的账号更换手机号 |
+
+真实短信模式成功:
+
+```json
+{
+ "code": 200,
+ "msg": "验证码已发送",
+ "data": null
+}
+```
+
+开发环境 `device.app.sms.mock=true` 时,验证码会直接放在 `msg` 中,例如 `"123456"`,仅用于联调。生产环境不会在响应中返回验证码。
+
+常见失败:`验证码场景不合法`、`验证码发送过于频繁,请稍后再试`、`验证码短信发送失败`。
+
+### 2.2 手机号注册
+
+```http
+POST /app/auth/register
+```
+
+请求体:
+
+```json
+{
+ "phoneNumber": "13800138000",
+ "code": "123456",
+ "password": "Password1",
+ "nickName": "小明"
+}
+```
+
+| 字段 | 类型 | 必填 | 规则 |
+|---|---|---:|---|
+| `phoneNumber` | string | 是 | 手机号 |
+| `code` | string | 是 | `scene=register` 获取的 6 位验证码 |
+| `password` | string | 是 | 8-20 位,含大小写字母和数字 |
+| `nickName` | string | 否 | 最长 30 字符,不允许脚本内容;空时默认使用手机号 |
+
+成功:`msg = "注册成功"`,`data = null`。注册接口不自动登录,前端注册成功后仍需调用登录接口。
+
+协议勾选状态由 APP 本地校验,当前服务端不接收也不保存协议勾选字段。
+
+常见失败:`手机号已注册`、`验证码错误`、`验证码已过期`、密码或昵称格式提示。
+
+### 2.3 手机号密码登录
+
+```http
+POST /app/auth/login
+```
+
+```json
+{
+ "phoneNumber": "13800138000",
+ "password": "Password1"
+}
+```
+
+登录成功:
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": {
+ "token": "eyJ..."
+ }
+}
+```
+
+密码登录只要求密码非空,不在登录阶段用“新密码规则”拦截历史密码。
+
+常见失败:`账号不存在`、`手机号或密码错误`、`账号已被停用`。
+
+### 2.4 短信验证码登录
+
+```http
+POST /app/auth/sms-login
+```
+
+```json
+{
+ "phoneNumber": "13800138000",
+ "code": "123456"
+}
+```
+
+验证码必须由 `scene=login` 获取。成功响应与 2.3 相同。
+
+服务端先判断账号存在且可用,再验证验证码。短信登录不会自动注册新账号。
+
+### 2.5 手机号找回密码
+
+```http
+POST /app/auth/reset-pwd
+```
+
+```json
+{
+ "phoneNumber": "13800138000",
+ "code": "123456",
+ "newPassword": "NewPassword1"
+}
+```
+
+验证码必须由 `scene=resetPwd` 获取。成功:`msg = "密码重置成功"`。
+
+### 2.6 退出登录
+
+```http
+POST /app/auth/logout
+Authorization: Bearer
+clientid: app
+```
+
+无请求体。成功:`msg = "退出成功"`。
+
+该接口幂等:未登录或 token 已失效时调用仍按成功处理。它只退出当前 token,不影响同一账号的其他设备会话。APP 收到成功后应立即删除本地 token。
+
+### 2.7 发送邮箱验证码
+
+```http
+POST /app/auth/email/code
+```
+
+```json
+{
+ "email": "User@gmail.com",
+ "scene": "register"
+}
+```
+
+| 字段 | 类型 | 必填 | 规则 |
+|---|---|---:|---|
+| `email` | string | 是 | 合法邮箱,最长 50 字符;服务端统一转为小写 |
+| `scene` | string | 是 | 仅支持 `register`、`login`、`resetPwd` |
+
+成功:`msg = "验证码已发送至邮箱"`。
+
+邮箱 mock 模式也不会把验证码返回给 APP,只会写开发日志。若配置了邮箱域名白名单,不在白名单内会返回 `该邮箱域名暂不支持注册`。
+
+### 2.8 邮箱注册
+
+```http
+POST /app/auth/email/register
+```
+
+```json
+{
+ "email": "user@gmail.com",
+ "code": "123456",
+ "password": "Password1",
+ "nickName": "小明"
+}
+```
+
+| 字段 | 类型 | 必填 | 规则 |
+|---|---|---:|---|
+| `email` | string | 是 | 合法邮箱,最长 50 字符 |
+| `code` | string | 是 | `scene=register` 获取的邮箱验证码 |
+| `password` | string | 是 | 8-20 位,含大小写字母和数字 |
+| `nickName` | string | 否 | 最长 30 字符;空时默认使用邮箱 |
+
+成功:`msg = "注册成功,请绑定手机号"`。
+
+邮箱注册不会自动登录,也不会同时创建手机号。登录后 `profile.phoneBound` 为 `false`,此时服务端会禁止绑定设备,APP 必须先走 3.3 首次绑定手机号。
+
+### 2.9 邮箱密码登录
+
+```http
+POST /app/auth/email/login
+```
+
+```json
+{
+ "email": "user@gmail.com",
+ "password": "Password1"
+}
+```
+
+成功响应与 2.3 相同。常见失败:`账号不存在`、`邮箱或密码错误`、`账号已被停用`。
+
+### 2.10 邮箱验证码登录
+
+```http
+POST /app/auth/email/code-login
+```
+
+```json
+{
+ "email": "user@gmail.com",
+ "code": "123456"
+}
+```
+
+验证码必须由 `scene=login` 获取。成功响应与 2.3 相同,不会自动注册账号。
+
+### 2.11 邮箱找回密码
+
+```http
+POST /app/auth/email/reset-pwd
+```
+
+```json
+{
+ "email": "user@gmail.com",
+ "code": "123456",
+ "newPassword": "NewPassword1"
+}
+```
+
+验证码必须由 `scene=resetPwd` 获取。成功:`msg = "密码重置成功"`。
+
+---
+
+## 3. 个人中心接口
+
+基础路径:`/app/user`。本组全部需要登录,只能操作当前账号。
+
+### 3.1 获取当前用户资料
+
+```http
+GET /app/user/profile
+```
+
+响应:`ApiResponse`。
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": {
+ "userName": "app_1900000000000000000",
+ "nickName": "小明",
+ "phoneNumber": "138****8000",
+ "email": "u***@gmail.com",
+ "phoneBound": true,
+ "userType": "app_user",
+ "createTime": "2026-09-20 10:30:00"
+ }
+}
+```
+
+| 字段 | 类型 | 可空 | 说明 |
+|---|---|---:|---|
+| `userName` | string | 否 | 服务端账号标识,不是手机号或邮箱 |
+| `nickName` | string | 否 | 用户昵称 |
+| `phoneNumber` | string | 是 | 脱敏手机号;尚未绑定时为 `null` |
+| `email` | string | 是 | 脱敏邮箱;手机号注册账号通常为 `null` |
+| `phoneBound` | boolean | 否 | 是否已绑定手机号 |
+| `userType` | string | 否 | 固定为 `app_user` |
+| `createTime` | string | 否 | 注册时间 |
+
+登录成功后建议立即拉取本接口。`phoneBound=false` 时应引导绑定手机号,并禁止进入设备绑定流程。
+
+### 3.2 修改密码
+
+```http
+PUT /app/user/password
+```
+
+```json
+{
+ "oldPassword": "Password1",
+ "newPassword": "NewPassword1"
+}
+```
+
+| 字段 | 类型 | 必填 | 规则 |
+|---|---|---:|---|
+| `oldPassword` | string | 是 | 当前密码 |
+| `newPassword` | string | 是 | 8-20 位,含大小写字母和数字;不能与旧密码相同 |
+
+成功:`msg = "密码修改成功"`。当前实现不会在改密成功后自动退出本设备,APP 可按产品要求主动重新登录。
+
+### 3.3 首次绑定或更换手机号
+
+```http
+PUT /app/user/phone
+```
+
+```json
+{
+ "newPhoneNumber": "13900139000",
+ "code": "123456"
+}
+```
+
+发送验证码前必须根据 `profile.phoneBound` 选择场景:
+
+| `phoneBound` | 短信 `scene` | 含义 |
+|---:|---|---|
+| `false` | `bind` | 邮箱账号首次绑定手机号 |
+| `true` | `rebind` | 更换已有手机号 |
+
+验证码发送到新手机号。成功:`msg = "手机号更换成功"`,首次绑定也使用该文案。
+
+常见失败:`新手机号与当前手机号相同`、`手机号已注册`、`验证码错误`、`验证码已过期`。
+
+### 3.4 注销账号
+
+```http
+DELETE /app/user/account
+```
+
+无请求体。服务端依次执行:解绑全部设备、停用账号、注销该账号全部会话。成功:`msg = "注销成功"`。
+
+这是不可在 APP 内直接撤销的账号操作。前端应二次确认;成功后清除 token、用户缓存、设备缓存并返回登录页。
+
+### 3.5 查询用户协议或隐私政策
+
+```http
+GET /app/user/agreements?type=user
+```
+
+| Query 参数 | 类型 | 必填 | 取值 |
+|---|---|---:|---|
+| `type` | string | 是 | `user` 或 `privacy`,区分大小写 |
+
+响应:
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": {
+ "type": "user",
+ "url": "https://example.com/agreement/user",
+ "version": "v1.0",
+ "updateTime": "2026-09-20"
+ }
+}
+```
+
+注意:当前接口需要登录。如果登录页也要展示协议,应由产品/后端另行提供公开入口,前端不要假定本接口可匿名访问。
+协议内容来自服务端配置,默认 `url` 和 `updateTime` 可以是空字符串;APP 应为未配置状态准备占位提示,不要直接打开空 URL。
+
+---
+
+## 4. 设备接口
+
+基础路径:`/app/device`。全部需要登录。按设备编号查询或操作时,服务端同时校验设备属于当前用户;设备不存在、未绑定或属于他人统一返回 `设备不存在`,防止枚举设备归属。
+
+### 4.1 我的设备列表
+
+```http
+GET /app/device/list
+```
+
+无 Query 参数,不分页,按绑定时间倒序。没有设备时 `data = []`。
+
+响应项字段:
+
+| 字段 | 类型 | 可空 | 说明 |
+|---|---|---:|---|
+| `deviceNo` | string | 否 | 设备业务编号 |
+| `name` | string | 否 | 用户设置的名称 |
+| `imei` | string | 否 | 脱敏值,如 `1234****5678` |
+| `model` | string | 是 | 设备型号 |
+| `status` | string | 否 | `0` 未激活、`1` 在线、`2` 离线 |
+| `signalLevel` | number | 是 | `0` 离线、`1` 弱、`2` 一般、`3` 强 |
+| `signalText` | string | 是 | 服务端派生的信号文案 |
+| `hoodConnected` | string | 是 | `0` 已断开、`1` 已连接 |
+| `hoodConnectedText` | string | 是 | 服务端派生的连接文案 |
+| `hoodBattery` | number | 是 | 头套电量,0-100 |
+| `ringBattery` | number | 是 | 脚环电量,0-100 |
+| `lng` | string | 是 | 最新经度;`BigDecimal` 以字符串返回 |
+| `lat` | string | 是 | 最新纬度;`BigDecimal` 以字符串返回 |
+| `address` | string | 是 | 最新地址 |
+
+示例:
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": [
+ {
+ "deviceNo": "BM202609200001",
+ "name": "客厅设备",
+ "imei": "1234****2345",
+ "model": "BM-A1",
+ "status": "1",
+ "signalLevel": 3,
+ "signalText": "强",
+ "hoodConnected": "1",
+ "hoodConnectedText": "已连接",
+ "hoodBattery": 82,
+ "ringBattery": 76,
+ "lng": "116.397128",
+ "lat": "39.916527",
+ "address": "北京市东城区"
+ }
+ ]
+}
+```
+
+### 4.2 设备详情
+
+```http
+GET /app/device/{deviceNo}
+```
+
+响应字段与列表项相同,额外返回:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `hoodCoverOn` | string | `0` 打开、`1` 遮盖;可能因设备尚未上报而为 `null` |
+
+### 4.3 绑定设备
+
+```http
+POST /app/device/bind
+```
+
+```json
+{
+ "imei": "123456789012345",
+ "name": "客厅设备"
+}
+```
+
+| 字段 | 类型 | 必填 | 规则 |
+|---|---|---:|---|
+| `imei` | string | 是 | 15 位数字,通常来自扫码 |
+| `name` | string | 是 | 1-20 个字符 |
+
+成功:`msg = "添加成功"`。
+
+前置条件:当前账号必须已绑定手机号。常见失败:`请先绑定手机号`、`设备不存在`、`重复绑定`、`设备已被绑定`。
+
+### 4.4 重命名设备
+
+```http
+PUT /app/device/{deviceNo}/name
+```
+
+```json
+{
+ "name": "新的设备名称"
+}
+```
+
+Body 只传 `name`,不要重复传 `deviceNo`。成功:`msg = "修改成功"`。
+
+### 4.5 解绑设备
+
+```http
+DELETE /app/device/{deviceNo}
+```
+
+无请求体。成功:`msg = "解绑成功"`。
+
+解绑后设备回到未激活状态,但设备建档与历史告警仍保留。历史消息归告警发生时的用户,不会转给下一位绑定用户。
+
+### 4.6 控制眼罩
+
+```http
+PUT /app/device/{deviceNo}/hood
+```
+
+```json
+{
+ "coverOn": true
+}
+```
+
+| `coverOn` | 物理动作 |
+|---:|---|
+| `true` | 遮盖眼罩 |
+| `false` | 打开眼罩 |
+
+`coverOn` 必须显式传入,不能省略。
+
+成功响应:
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": {
+ "deviceNo": "BM202609200001",
+ "hoodCoverOn": "1",
+ "hoodCoverOnText": "遮盖"
+ }
+}
+```
+
+接口会等待设备回执,默认最长 5 秒,另有很短的服务端宽限时间。前端建议:
+
+- 单次请求超时设置为至少 8-10 秒。
+- 请求期间展示 loading 并禁用重复点击。
+- 只有收到 `code=200` 后才用 `data.hoodCoverOn` 更新页面。
+- 设备离线时两个方向都拒绝;低电只拒绝“遮盖”,不阻止“打开”。
+
+常见失败:`设备不存在`、`设备已离线`、`电量过低`、`指令下发失败`。
+
+### 4.7 获取设备定位
+
+```http
+GET /app/device/{deviceNo}/location
+```
+
+响应:
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": {
+ "deviceNo": "BM202609200001",
+ "lng": "116.397128",
+ "lat": "39.916527",
+ "address": "北京市东城区",
+ "lastOnlineTime": "2026-09-20 11:30:00"
+ }
+}
+```
+
+| 字段 | 类型 | 可空 | 说明 |
+|---|---|---:|---|
+| `deviceNo` | string | 否 | 设备编号 |
+| `lng` | string | 是 | 最新经度 |
+| `lat` | string | 是 | 最新纬度 |
+| `address` | string | 是 | 设备上报地址 |
+| `lastOnlineTime` | string | 是 | 最后在线时间 |
+
+从未上报定位时接口仍成功,`lng`、`lat` 为 `null`。APP 应展示“暂无定位”空态,不要把空坐标传给地图 SDK。
+
+### 4.8 获取直播播放信息
+
+```http
+GET /app/device/{deviceNo}/live
+```
+
+该接口等价于获取播放地址,不会通知设备开始推流。响应结构与 5.1 的 `AppStreamPlay` 相同。正常开播流程应先调用 `POST /app/stream/start`。
+
+---
+
+## 5. 直播接口
+
+基础路径:`/app/stream`。全部需要登录,只能操作当前用户绑定的设备。
+
+当前默认 `mjpeg.enabled=true`,直播走 HTTP MJPEG;关闭该配置后切换为 ZLMediaKit。APP 不应硬编码播放协议,应从 `playUrls` 中选择非空地址。
+
+### 5.1 开始推流
+
+```http
+POST /app/stream/start
+```
+
+```json
+{
+ "deviceNo": "BM202609200001"
+}
+```
+
+前置条件:设备属于当前用户且 `status=1`。接口向设备发送开始推流指令,然后立即返回播放信息;它不等待第一帧,因此刚开播时 `online` 可能为 `false`。
+
+当前 MJPEG 模式响应示例:
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": {
+ "deviceNo": "BM202609200001",
+ "streamId": "BM202609200001",
+ "app": "live",
+ "online": false,
+ "pushUrl": "https://stream.example.com/mjpeg/ingest/BM202609200001",
+ "playToken": "7f2d...",
+ "expireAt": 1789875123456,
+ "expireSeconds": 120,
+ "playUrls": {
+ "flv": null,
+ "hls": null,
+ "rtmp": null,
+ "rtsp": null,
+ "webrtc": null,
+ "mjpeg": "https://stream.example.com/mjpeg/play/BM202609200001?token=..."
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `deviceNo` | string | 设备编号 |
+| `streamId` | string | 当前与 `deviceNo` 相同 |
+| `app` | string | 流媒体应用名,当前默认 `live` |
+| `online` | boolean | 当前是否已经存在有效流/最近帧 |
+| `pushUrl` | string | 设备侧推流地址,APP 播放器不要使用 |
+| `playToken` | string | 播放凭证;一般无需单独拼接 |
+| `expireAt` | number \| string | 播放凭证过期时间,epoch 毫秒 |
+| `expireSeconds` | number | 有效期秒数,当前默认 120 |
+| `playUrls` | object | 各协议播放地址,token 已附在 URL 中 |
+
+限流:同一设备控制维度、同一 IP 60 秒内最多 30 次。
+
+播放 token 同时绑定 `streamId` 和签发时客户端 IP,不能分享给其他设备或网络出口。网络切换、token 临近过期或收到播放端 `401` 时,应重新调用播放信息接口获取新 URL。
+
+### 5.2 停止推流
+
+```http
+POST /app/stream/stop/{deviceNo}
+```
+
+通知设备停止推流,并关闭服务端当前流。响应:
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": {
+ "deviceNo": "BM202609200001",
+ "streamId": "BM202609200001",
+ "online": false
+ }
+}
+```
+
+限流:同一设备控制维度、同一 IP 60 秒内最多 20 次。离开直播页时建议调用一次,但不要在组件反复渲染时重复调用。
+
+### 5.3 获取播放地址
+
+```http
+GET /app/stream/play-url/{deviceNo}
+```
+
+只签发新的播放 token 和地址,不通知设备开播。响应结构与 5.1 相同。限流:同一设备、同一 IP 60 秒内最多 60 次。
+
+适用场景:
+
+- 播放 URL 即将过期时刷新。
+- APP 从后台回到前台后重新获取凭证。
+- 已开播但播放器重连。
+
+### 5.4 仅关闭服务端流
+
+```http
+POST /app/stream/close/{deviceNo}
+```
+
+只关闭当前直播后端的流或 MJPEG 会话,不通知设备停止推流。正常用户流程优先使用 5.2 `stop`。
+
+成功:`data = null`。限流:同一设备、同一 IP 60 秒内最多 20 次。
+
+### 5.5 查询流状态
+
+```http
+GET /app/stream/status/{deviceNo}
+```
+
+响应:`ApiResponse`,结构见 5.2。限流:同一设备、同一 IP 60 秒内最多 60 次。
+
+若需要等待第一帧,可每 2-3 秒轮询一次,成功在线或达到客户端等待上限后停止轮询。
+
+### 5.6 已停用的旧接口
+
+```http
+POST /app/stream/open
+```
+
+此接口固定失败:`msg = "客户端自定义视频源已停用"`。新 APP 不得调用,也不要允许用户输入自定义拉流地址。
+
+### 5.7 MJPEG 播放地址说明
+
+`playUrls.mjpeg` 指向:
+
+```http
+GET /mjpeg/play/{deviceNo}?token=
+```
+
+它返回 `multipart/x-mixed-replace` 长连接,不返回统一 JSON。APP 可使用支持 MJPEG 的图片/流组件直接打开完整 URL。
+
+调试单帧地址:
+
+```http
+GET /mjpeg/play/{deviceNo}/snapshot?token=
+```
+
+| HTTP 状态 | 含义 |
+|---:|---|
+| `200` | 有流或有最新帧 |
+| `401` | token 无效、过期、IP 不匹配或 MJPEG 未启用 |
+| `404` | 当前没有活动流或尚无帧 |
+
+不要自行构造或长期缓存播放 URL,始终使用业务接口返回的完整地址。
+
+---
+
+## 6. APP 版本检查
+
+基础路径:`/app/version`。本组免登录,供冷启动最早阶段调用。
+
+### 6.1 检查最新版本
+
+```http
+GET /app/version/latest?platform=android&versionCode=100&versionName=1.0.0&deviceModel=Pixel%208&osVersion=15
+```
+
+| Query 参数 | 类型 | 必填 | 说明 |
+|---|---|---:|---|
+| `platform` | string | 是 | 严格取 `android` 或 `ios`,区分大小写 |
+| `versionCode` | number | 是 | 当前构建号,必须为正整数;唯一比较依据 |
+| `versionName` | string | 否 | 当前实现接收但不参与比较、不落库、也不在响应中回显 |
+| `deviceModel` | string | 否 | 当前实现接收但不参与判定、不落库 |
+| `osVersion` | string | 否 | 当前实现接收但不参与判定、不落库 |
+
+比较规则只使用整数 `versionCode`。不要使用字符串 `versionName` 比较大小。
+
+无更新响应:
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": {
+ "platform": "android",
+ "hasUpdate": false,
+ "forceUpdate": false,
+ "latestVersionCode": null,
+ "latestVersionName": null,
+ "changelog": null,
+ "fileSize": null,
+ "md5": null,
+ "downloadUrl": null,
+ "storeUrl": null,
+ "currentVersionCode": 100
+ }
+}
+```
+
+有更新响应:
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": {
+ "platform": "android",
+ "hasUpdate": true,
+ "forceUpdate": true,
+ "latestVersionCode": 101,
+ "latestVersionName": "1.0.1",
+ "changelog": "修复已知问题并提升直播稳定性",
+ "fileSize": 52428800,
+ "md5": null,
+ "downloadUrl": "https://oss.example.com/app.apk?...",
+ "storeUrl": null,
+ "currentVersionCode": 100
+ }
+}
+```
+
+| 字段 | 类型 | 可空 | 说明 |
+|---|---|---:|---|
+| `platform` | string | 否 | 回显平台 |
+| `hasUpdate` | boolean | 否 | 更新弹窗主开关 |
+| `forceUpdate` | boolean | 否 | 是否强制更新;无更新时恒为 `false` |
+| `latestVersionCode` | number | 是 | 最新构建号 |
+| `latestVersionName` | string | 是 | 最新展示版本号 |
+| `changelog` | string | 是 | 更新说明 |
+| `fileSize` | number \| string | 是 | 安装包字节数;使用前执行 `Number(fileSize)` |
+| `md5` | string | 是 | 预留字段,本期通常为 `null` |
+| `downloadUrl` | string | 是 | 动态解析出的安装包 URL |
+| `storeUrl` | string | 是 | App Store 等引导地址,后台未填时为 `null` |
+| `currentVersionCode` | number | 否 | 回显当前构建号 |
+
+三态处理:
+
+| 条件 | APP 行为 |
+|---|---|
+| `hasUpdate=false` | 不弹更新框 |
+| `hasUpdate=true && forceUpdate=false` | 展示可关闭的可选更新弹窗 |
+| `hasUpdate=true && forceUpdate=true` | 展示不可跳过的强更弹窗 |
+
+补充规则:
+
+- 没有启用版本、当前构建号大于等于最新构建号、或安装包 URL 无法解析时,都返回“无更新”。
+- 下载地址针对私有 OSS 每次检查时重新解析,当前有效期为 24 小时;失败或过期后重新检查,不要永久缓存。
+- Android 与 iOS 都可能返回 `downloadUrl`;iOS 是否可安装取决于 App Store、TestFlight、企业签等实际分发方式。
+- `storeUrl` 有值时优先用于系统商店跳转;无值时根据平台和实际分发方案处理。
+
+常见失败:`平台参数不合法`、`版本号不合法`。
+
+---
+
+## 7. 消息中心接口
+
+基础路径:`/app/message`。全部需要登录,只能读写当前用户消息。
+
+一期消息仅包含设备告警:设备离线、头套低电、脚环低电。消息由服务端生成,APP 不提供新增、修改内容或删除接口。
+
+### 7.1 消息分页列表
+
+```http
+GET /app/message/list?pageNum=1&pageSize=20&readFlag=0
+```
+
+| Query 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---:|---:|---|
+| `pageNum` | number | 否 | `1` | 小于 1 时回落为 1 |
+| `pageSize` | number | 否 | `20` | 小于 1 时回落为 20,大于 100 时截为 100 |
+| `readFlag` | string | 否 | 全部 | `0` 未读、`1` 已读;其他值通常得到空结果 |
+
+排序:`createTime` 倒序,再按 `messageId` 倒序。
+
+响应项:
+
+| 字段 | 类型 | 可空 | 说明 |
+|---|---|---:|---|
+| `messageId` | string \| number | 否 | 雪花 ID,按字符串保存和回传 |
+| `deviceNo` | string | 是 | 关联设备编号,可用于跳详情 |
+| `alertId` | string \| number | 是 | 关联告警 ID |
+| `alertType` | string | 是 | 告警类型 |
+| `alertLevel` | string | 是 | `0` 提示、`1` 警告、`2` 严重 |
+| `title` | string | 否 | 消息标题 |
+| `content` | string | 是 | 消息内容 |
+| `readFlag` | string | 否 | `0` 未读、`1` 已读 |
+| `readTime` | string | 是 | 首次标记已读时间 |
+| `createTime` | string | 否 | 消息产生时间 |
+
+示例:
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": {
+ "total": 1,
+ "rows": [
+ {
+ "messageId": "1970000000000000001",
+ "deviceNo": "BM202609200001",
+ "alertId": "1970000000000000000",
+ "alertType": "low_battery_hood",
+ "alertLevel": "1",
+ "title": "头套电量过低",
+ "content": "头套电量过低(当前 18%)",
+ "readFlag": "0",
+ "readTime": null,
+ "createTime": "2026-09-20 12:00:00"
+ }
+ ]
+ }
+}
+```
+
+### 7.2 查询未读数
+
+```http
+GET /app/message/unread-count
+```
+
+```json
+{
+ "code": 200,
+ "msg": "操作成功",
+ "data": 3
+}
+```
+
+返回当前用户未读消息总数。APP 可在启动、进入消息页、下拉刷新、标记已读后重新获取。当前 APP 接口没有实时推送连接,角标实时性取决于客户端刷新策略。
+
+### 7.3 标记单条已读
+
+```http
+PUT /app/message/{messageId}/read
+```
+
+无请求体。成功:`data = null`。
+
+该接口幂等:重复调用不修改首次 `readTime`。消息不存在或不属于当前用户时也返回成功,不暴露其他用户的消息 ID 是否存在。
+
+### 7.4 全部标记已读
+
+```http
+PUT /app/message/read-all
+```
+
+无请求体。仅更新当前用户的未读消息;没有未读消息时仍成功。
+
+### 7.5 告警枚举与点击行为
+
+| `alertType` | 标题/含义 | 默认 `alertLevel` |
+|---|---|---|
+| `offline` | 设备离线 | `2` 严重 |
+| `low_battery_hood` | 头套电量过低 | `1` 警告 |
+| `low_battery_ring` | 脚环电量过低 | `1` 警告 |
+
+点击消息建议流程:
+
+```text
+消息列表
+ -> 本地先标记已读
+ -> GET /app/device/{deviceNo}
+ -> 成功则进入设备详情
+ -> 返回“设备不存在”则提示设备已解绑或不可访问,留在消息页
+```
+
+历史消息归告警发生时的用户,即使设备后来解绑或换绑也不会迁移。
+
+---
+
+## 8. TypeScript 契约
+
+以下类型按当前真实序列化行为编写,可直接作为 APP API 层的基础定义。
+
+```ts
+/** Java Long 可能按 number 或 string 返回,雪花 ID 应始终按字符串语义使用。 */
+export type JsonLong = string | number;
+
+export interface ApiResponse {
+ code: number;
+ msg: string;
+ data: T | null;
+}
+
+export interface PageResult {
+ total: number;
+ rows: T[];
+}
+
+export type SmsScene = 'register' | 'login' | 'resetPwd' | 'bind' | 'rebind';
+export type EmailScene = 'register' | 'login' | 'resetPwd';
+export type DeviceStatus = '0' | '1' | '2';
+export type HoodState = '0' | '1';
+export type ReadFlag = '0' | '1';
+export type AlertLevel = '0' | '1' | '2';
+export type AppPlatform = 'android' | 'ios';
+
+export interface AppToken {
+ token: string;
+}
+
+export interface AppUserProfile {
+ userName: string;
+ nickName: string;
+ phoneNumber: string | null;
+ email: string | null;
+ phoneBound: boolean;
+ userType: 'app_user';
+ createTime: string;
+}
+
+export interface AppAgreement {
+ type: 'user' | 'privacy';
+ url: string;
+ version: string;
+ updateTime: string;
+}
+
+export interface AppDevice {
+ deviceNo: string;
+ name: string;
+ imei: string;
+ model: string | null;
+ status: DeviceStatus;
+ signalLevel: 0 | 1 | 2 | 3 | null;
+ signalText: string | null;
+ hoodConnected: HoodState | null;
+ hoodConnectedText: string | null;
+ hoodBattery: number | null;
+ ringBattery: number | null;
+ /** BigDecimal 经全局 Jackson 配置固定返回字符串。 */
+ lng: string | null;
+ /** BigDecimal 经全局 Jackson 配置固定返回字符串。 */
+ lat: string | null;
+ address: string | null;
+}
+
+export interface AppDeviceDetail extends AppDevice {
+ hoodCoverOn: HoodState | null;
+}
+
+export interface AppHoodResult {
+ deviceNo: string;
+ hoodCoverOn: HoodState;
+ hoodCoverOnText: '打开' | '遮盖';
+}
+
+export interface DeviceLocation {
+ deviceNo: string;
+ lng: string | null;
+ lat: string | null;
+ address: string | null;
+ lastOnlineTime: string | null;
+}
+
+export interface AppStreamPlayUrls {
+ flv: string | null;
+ hls: string | null;
+ rtmp: string | null;
+ rtsp: string | null;
+ webrtc: string | null;
+ mjpeg: string | null;
+}
+
+export interface AppStreamPlay {
+ deviceNo: string;
+ streamId: string;
+ app: string;
+ online: boolean;
+ /** 设备侧地址,仅用于诊断,APP 播放器不使用。 */
+ pushUrl: string;
+ playToken: string;
+ expireAt: JsonLong;
+ expireSeconds: number;
+ playUrls: AppStreamPlayUrls;
+}
+
+export interface AppStreamStatus {
+ deviceNo: string;
+ streamId: string;
+ online: boolean;
+}
+
+export interface AppVersionLatest {
+ platform: AppPlatform;
+ hasUpdate: boolean;
+ forceUpdate: boolean;
+ latestVersionCode: number | null;
+ latestVersionName: string | null;
+ changelog: string | null;
+ fileSize: JsonLong | null;
+ md5: string | null;
+ downloadUrl: string | null;
+ storeUrl: string | null;
+ currentVersionCode: number;
+}
+
+export interface AppMessage {
+ messageId: JsonLong;
+ deviceNo: string | null;
+ alertId: JsonLong | null;
+ alertType: 'offline' | 'low_battery_hood' | 'low_battery_ring' | string;
+ alertLevel: AlertLevel | null;
+ title: string;
+ content: string | null;
+ readFlag: ReadFlag;
+ readTime: string | null;
+ createTime: string;
+}
+```
+
+请求 DTO:
+
+```ts
+export interface SmsCodeRequest {
+ phoneNumber: string;
+ scene: SmsScene;
+}
+
+export interface PhoneRegisterRequest {
+ phoneNumber: string;
+ code: string;
+ password: string;
+ nickName?: string;
+}
+
+export interface PhoneLoginRequest {
+ phoneNumber: string;
+ password: string;
+}
+
+export interface PhoneCodeLoginRequest {
+ phoneNumber: string;
+ code: string;
+}
+
+export interface PhoneResetPasswordRequest {
+ phoneNumber: string;
+ code: string;
+ newPassword: string;
+}
+
+export interface EmailCodeRequest {
+ email: string;
+ scene: EmailScene;
+}
+
+export interface EmailRegisterRequest {
+ email: string;
+ code: string;
+ password: string;
+ nickName?: string;
+}
+
+export interface EmailLoginRequest {
+ email: string;
+ password: string;
+}
+
+export interface EmailCodeLoginRequest {
+ email: string;
+ code: string;
+}
+
+export interface EmailResetPasswordRequest {
+ email: string;
+ code: string;
+ newPassword: string;
+}
+
+export interface ChangePasswordRequest {
+ oldPassword: string;
+ newPassword: string;
+}
+
+export interface BindPhoneRequest {
+ newPhoneNumber: string;
+ code: string;
+}
+
+export interface BindDeviceRequest {
+ imei: string;
+ name: string;
+}
+
+export interface RenameDeviceRequest {
+ name: string;
+}
+
+export interface HoodControlRequest {
+ coverOn: boolean;
+}
+
+export interface StreamStartRequest {
+ deviceNo: string;
+}
+
+export interface VersionCheckQuery {
+ platform: AppPlatform;
+ versionCode: number;
+ versionName?: string;
+ deviceModel?: string;
+ osVersion?: string;
+}
+
+export interface MessageQuery {
+ pageNum?: number;
+ pageSize?: number;
+ readFlag?: ReadFlag;
+}
+```
+
+### 8.1 Axios 请求封装示例
+
+```ts
+import axios, { type AxiosRequestConfig } from 'axios';
+
+export class ApiError extends Error {
+ constructor(
+ public readonly code: number,
+ message: string
+ ) {
+ super(message);
+ }
+}
+
+const http = axios.create({
+ baseURL: import.meta.env.VITE_API_BASE_URL,
+ timeout: 15_000,
+ headers: {
+ 'Content-Type': 'application/json;charset=utf-8',
+ clientid: 'app'
+ }
+});
+
+http.interceptors.request.use(config => {
+ const token = localStorage.getItem('app_token');
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+});
+
+export async function request(config: AxiosRequestConfig): Promise {
+ const response = await http.request>(config);
+ const body = response.data;
+
+ // 业务异常大多仍是 HTTP 200,必须检查响应体 code。
+ if (body.code === 401) {
+ localStorage.removeItem('app_token');
+ // 这里通知路由层跳转登录页,避免 API 层直接依赖具体导航实现。
+ throw new ApiError(body.code, body.msg);
+ }
+ if (body.code !== 200) {
+ throw new ApiError(body.code, body.msg);
+ }
+
+ return body.data as T;
+}
+```
+
+眼罩接口可单独覆盖超时:
+
+```ts
+export function controlHood(deviceNo: string, coverOn: boolean) {
+ return request({
+ method: 'PUT',
+ url: `/app/device/${encodeURIComponent(deviceNo)}/hood`,
+ data: { coverOn },
+ // 服务端默认等待设备回执 5 秒,客户端应预留网络开销。
+ timeout: 10_000
+ });
+}
+```
+
+---
+
+## 9. 推荐业务流程
+
+### 9.1 手机号注册与登录
+
+```text
+POST /app/auth/sms/code (scene=register)
+ -> POST /app/auth/register
+ -> POST /app/auth/login 或 /app/auth/sms-login
+ -> 保存 data.token
+ -> GET /app/user/profile
+ -> GET /app/device/list
+```
+
+### 9.2 邮箱注册与首次绑定手机号
+
+```text
+POST /app/auth/email/code (scene=register)
+ -> POST /app/auth/email/register
+ -> POST /app/auth/email/login 或 /app/auth/email/code-login
+ -> 保存 data.token
+ -> GET /app/user/profile
+ -> phoneBound=false
+ -> POST /app/auth/sms/code (scene=bind,目标是新手机号)
+ -> PUT /app/user/phone
+ -> 重新 GET /app/user/profile
+ -> phoneBound=true 后允许绑定设备
+```
+
+### 9.3 绑定设备
+
+```text
+GET /app/user/profile
+ -> 确认 phoneBound=true
+ -> 扫码取得 15 位 IMEI
+ -> POST /app/device/bind
+ -> GET /app/device/list 刷新设备卡片
+```
+
+### 9.4 眼罩控制
+
+```text
+禁用按钮并展示 loading
+ -> PUT /app/device/{deviceNo}/hood
+ -> code=200:使用 data.hoodCoverOn 更新界面
+ -> 失败:保持原状态,直接展示 msg
+ -> 恢复按钮
+```
+
+### 9.5 直播
+
+```text
+POST /app/stream/start
+ -> 从 playUrls 选择非空且客户端支持的协议
+ -> online=false 时每 2-3 秒查询 status
+ -> 播放 token 即将过期或网络切换时调用 play-url
+ -> 离开直播页调用 stop
+```
+
+### 9.6 冷启动检查更新
+
+```text
+GET /app/version/latest(无需登录)
+ -> hasUpdate=false:继续启动
+ -> 可选更新:可关闭弹窗
+ -> 强制更新:不可跳过,使用 storeUrl 或 downloadUrl
+ -> 下载 URL 失效:重新调用 latest 获取新地址
+```
+
+### 9.7 消息角标
+
+```text
+APP 启动/回前台 -> GET /app/message/unread-count
+进入消息页 -> GET /app/message/list?pageNum=1&pageSize=20
+点击消息 -> PUT /app/message/{id}/read
+ -> 重新获取未读数
+ -> 尝试进入对应设备详情
+```
+
+---
+
+## 10. 常见业务提示
+
+| 模块 | 常见 `msg` |
+|---|---|
+| 验证码 | `验证码发送过于频繁,请稍后再试`、`验证码错误`、`验证码已过期`、`验证码错误次数过多,请稍后再试` |
+| 注册登录 | `手机号已注册`、`邮箱已注册`、`账号不存在`、`手机号或密码错误`、`邮箱或密码错误`、`账号已被停用` |
+| 密码 | `原密码错误`、`新密码不能与原密码相同`、`密码需为 8-20 位,且包含大小写字母和数字` |
+| 手机号绑定 | `请先绑定手机号`、`新手机号与当前手机号相同`、`手机号已注册` |
+| 设备 | `设备不存在`、`设备已被绑定`、`重复绑定`、`设备已离线`、`电量过低`、`指令下发失败` |
+| 直播 | `MJPEG 直播地址未配置`、`ZLM 地址未配置`、`操作过于频繁,请稍后再试`、`客户端自定义视频源已停用` |
+| 版本 | `平台参数不合法`、`版本号不合法` |
+
+前端不要根据 `msg` 反推业务状态或做程序分支;程序逻辑应优先根据 `code` 和响应字段处理,`msg` 只用于用户提示。当前后端尚未为业务异常提供更细的稳定错误码,这是现有契约的限制。
+
+---
+
+## 11. 完整接口索引
+
+| # | 方法 | 路径 | 登录 | 状态 |
+|---:|---|---|---:|---|
+| 1 | POST | `/app/auth/sms/code` | 否 | 可用 |
+| 2 | POST | `/app/auth/register` | 否 | 可用 |
+| 3 | POST | `/app/auth/login` | 否 | 可用 |
+| 4 | POST | `/app/auth/sms-login` | 否 | 可用 |
+| 5 | POST | `/app/auth/reset-pwd` | 否 | 可用 |
+| 6 | POST | `/app/auth/logout` | 否;退出当前会话时带 token | 可用 |
+| 7 | POST | `/app/auth/email/code` | 否 | 可用 |
+| 8 | POST | `/app/auth/email/register` | 否 | 可用 |
+| 9 | POST | `/app/auth/email/login` | 否 | 可用 |
+| 10 | POST | `/app/auth/email/code-login` | 否 | 可用 |
+| 11 | POST | `/app/auth/email/reset-pwd` | 否 | 可用 |
+| 12 | GET | `/app/user/profile` | 是 | 可用 |
+| 13 | PUT | `/app/user/password` | 是 | 可用 |
+| 14 | PUT | `/app/user/phone` | 是 | 可用 |
+| 15 | DELETE | `/app/user/account` | 是 | 可用 |
+| 16 | GET | `/app/user/agreements` | 是 | 可用 |
+| 17 | GET | `/app/device/list` | 是 | 可用 |
+| 18 | GET | `/app/device/{deviceNo}` | 是 | 可用 |
+| 19 | POST | `/app/device/bind` | 是 | 可用 |
+| 20 | PUT | `/app/device/{deviceNo}/name` | 是 | 可用 |
+| 21 | DELETE | `/app/device/{deviceNo}` | 是 | 可用 |
+| 22 | PUT | `/app/device/{deviceNo}/hood` | 是 | 可用 |
+| 23 | GET | `/app/device/{deviceNo}/location` | 是 | 可用 |
+| 24 | GET | `/app/device/{deviceNo}/live` | 是 | 可用 |
+| 25 | POST | `/app/stream/start` | 是 | 可用 |
+| 26 | POST | `/app/stream/stop/{deviceNo}` | 是 | 可用 |
+| 27 | GET | `/app/stream/play-url/{deviceNo}` | 是 | 可用 |
+| 28 | POST | `/app/stream/open` | 是 | 已停用,固定失败 |
+| 29 | POST | `/app/stream/close/{deviceNo}` | 是 | 可用 |
+| 30 | GET | `/app/stream/status/{deviceNo}` | 是 | 可用 |
+| 31 | GET | `/app/version/latest` | 否 | 可用 |
+| 32 | GET | `/app/message/list` | 是 | 可用 |
+| 33 | GET | `/app/message/unread-count` | 是 | 可用 |
+| 34 | PUT | `/app/message/{messageId}/read` | 是 | 可用 |
+| 35 | PUT | `/app/message/read-all` | 是 | 可用 |
+
+动态媒体端点不计入上述 35 个业务接口:
+
+| 方法 | 路径 | 鉴权 | 返回 |
+|---|---|---|---|
+| GET | `/mjpeg/play/{deviceNo}?token=...` | 播放 token | MJPEG multipart 流 |
+| GET | `/mjpeg/play/{deviceNo}/snapshot?token=...` | 播放 token | 单帧 JPEG |
+
+---
+
+## 12. 代码事实来源
+
+接口路径和鉴权:
+
+- `ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppAuthController.java`
+- `ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppUserController.java`
+- `ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppDeviceController.java`
+- `ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppVersionAppController.java`
+- `ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/controller/app/AppMessageController.java`
+- `ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/zlm/controller/AppStreamController.java`
+- `ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/mjpeg/MjpegStreamController.java`
+
+请求与响应字段:
+
+- `ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/bo/`
+- `ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/domain/vo/`
+
+业务规则:
+
+- `ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/service/impl/`
+- `ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/sms/AppSmsService.java`
+- `ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/email/AppEmailService.java`
+- `ruoyi-modules/ruoyi-device/src/main/java/org/dromara/device/stream/`
+
+公共契约:
+
+- `ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/R.java`
+- `ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/domain/PageResult.java`
+- `ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/config/JacksonConfig.java`
+- `ruoyi-common/ruoyi-common-json/src/main/java/org/dromara/common/json/handler/BigNumberSerializer.java`
+- `ruoyi-common/ruoyi-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java`
+- `ruoyi-common/ruoyi-common-satoken/src/main/resources/common-satoken.yml`
diff --git a/README.md b/README.md
index 33285d6..7f4db79 100644
--- a/README.md
+++ b/README.md
@@ -235,3 +235,25 @@ class ExampleActivity :
## License
当前仓库暂未声明开源许可证。如需公开复用或分发,建议根据实际用途补充 LICENSE 文件。
+
+
+
+别名: chengkong
+创建日期: 2026年5月6日
+条目类型: PrivateKeyEntry
+证书链长度: 1
+证书[1]:
+所有者: C=86, ST=ShanDong, L=RiZhao, O=ChengKong, OU=ChengKong, CN=ChengKong
+发布者: C=86, ST=ShanDong, L=RiZhao, O=ChengKong, OU=ChengKong, CN=ChengKong
+序列号: 1
+生效时间: Wed May 06 17:08:58 CST 2026, 失效时间: Sun Apr 30 17:08:58 CST 2051
+证书指纹:
+ SHA1: B1:3A:DB:EA:42:E7:02:F8:FA:0A:71:A4:33:CD:42:CB:8A:6A:59:8E
+ SHA256: FB:4F:3C:AA:03:C3:30:2A:14:21:C0:66:1F:78:72:70:35:2C:BB:3A:E9:81:68:44:5E:AA:3B:33:D1:B8:FE:99
+签名算法名称: SHA256withRSA
+主体公共密钥算法: 2048 位 RSA 密钥
+版本: 1
+
+
+
+Android_JUMHABI 32c97984f1089eb5e85721ee39261650
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index 7201666..06b9e88 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -12,6 +12,13 @@ android {
}
defaultConfig {
+
+ ndk {
+ //设置支持的SO库架构(开发者可以根据需要,选择一个或多个平台的so)
+ abiFilters "armeabi-v7a", "arm64-v8a"
+ }
+
+
applicationId "com.juhabi.birds"
minSdk 24
targetSdk 36
@@ -21,6 +28,27 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
+ signingConfigs {
+ release {
+ storeFile file('../chengkong.jks')
+ keyAlias 'chengkong'
+ keyPassword '111111'
+ storePassword '111111'
+ }
+ debug {
+ storeFile file('../chengkong.jks')
+ keyAlias 'chengkong'
+ keyPassword '111111'
+ storePassword '111111'
+ }
+ config {
+ storeFile file('../chengkong.jks')
+ keyAlias 'chengkong'
+ keyPassword '111111'
+ storePassword '111111'
+ }
+ }
+
buildTypes {
release {
minifyEnabled false
@@ -82,4 +110,10 @@ dependencies {
implementation 'com.makeramen:roundedimageview:2.3.0'
//完整版引入
implementation 'io.github.carguo:gsyvideoplayer:13.2.1'
+ //高德相关
+ //2D地图
+ implementation 'com.amap.api:map2d:latest.integration'
+ //定位
+ implementation 'com.amap.api:location:latest.integration'
+
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f9f3b2c..edd83c4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,15 @@
+
+
+
+
+
+
+
+
+
@@ -26,6 +35,11 @@
android:usesCleartextTraffic="true"
android:theme="@style/Theme.KotMvpProject">
+
+
+
+
+
+
diff --git a/app/src/main/java/com/juhabi/birds/device/DeviceFragment.kt b/app/src/main/java/com/juhabi/birds/device/DeviceFragment.kt
index df6b896..5f650a4 100644
--- a/app/src/main/java/com/juhabi/birds/device/DeviceFragment.kt
+++ b/app/src/main/java/com/juhabi/birds/device/DeviceFragment.kt
@@ -6,6 +6,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import com.amap.api.maps2d.MapView
import com.juhabi.birds.R
import com.juhabi.birds.base.BaseFragment
import com.juhabi.birds.custom.AddDevicePopupWindow
@@ -30,6 +31,8 @@ class DeviceFragment : BaseFragment(),
private var currentPage = FIRST_PAGE
private var pendingUnbindPosition: Int? = null
+
+
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeBinding {
return FragmentHomeBinding.inflate(inflater, container, false)
}
@@ -95,13 +98,17 @@ class DeviceFragment : BaseFragment(),
)
}
R.id.tv_show_map -> {
- val device = deviceAdapter.data[position]
- // 没有经纬度时只展示空态,不要把空坐标传给地图
- if (device.lat.isNullOrBlank() || device.lng.isNullOrBlank()) {
- showMessage(mContext.getString(R.string.i18n_080)) //暂无定位
- return@setOnItemChildClickListener
- }
- // 地图页后续接入,这里先预留点击入口
+ //Todo 1.先查询定位权限,参考之前动态权限获取方式。2.跳转到MapViewActivity中,中心点则随便展示就行,可以默认北京天安门
+
+
+ //代码不要删
+// val device = deviceAdapter.data[position]
+// // 没有经纬度时只展示空态,不要把空坐标传给地图
+// if (device.lat.isNullOrBlank() || device.lng.isNullOrBlank()) {
+// showMessage(mContext.getString(R.string.i18n_080)) //暂无定位
+// return@setOnItemChildClickListener
+// }
+// // 地图页后续接入,这里先预留点击入口
}
}
}
diff --git a/app/src/main/java/com/juhabi/birds/device/MapViewActivity.kt b/app/src/main/java/com/juhabi/birds/device/MapViewActivity.kt
new file mode 100644
index 0000000..a87d191
--- /dev/null
+++ b/app/src/main/java/com/juhabi/birds/device/MapViewActivity.kt
@@ -0,0 +1,41 @@
+package com.juhabi.birds.device
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import com.juhabi.birds.base.BaseActivity
+import com.juhabi.birds.databinding.DeviceDetailLayoutBinding
+import com.juhabi.birds.databinding.MapviewLayoutBinding
+import com.juhabi.birds.device.entity.DeviceEntity
+import com.juhabi.birds.device.mvp.DeviceContract
+import com.juhabi.birds.device.mvp.DeviceImpl
+import com.juhabi.birds.device.mvp.DevicePresenter
+
+/**
+ * 地图显示
+ */
+class MapViewActivity :BaseActivity(), DeviceContract.DeviceView,
+ View.OnClickListener {
+ override fun createBinding(inflater: LayoutInflater): MapviewLayoutBinding {
+ return MapviewLayoutBinding.inflate(inflater)
+ }
+
+ override fun createPresenter(): DevicePresenter? {
+ return DevicePresenter(DeviceImpl(mContext), mContext)
+ }
+
+ override fun initView(savedInstanceState: Bundle?) {
+ }
+
+ override fun showError(message: String) {
+ }
+
+ override fun getBindDeviceListSuccess(devices: List) {
+ }
+
+ override fun bindDeviceSuccess(message: String) {
+ }
+
+ override fun onClick(p0: View?) {
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/juhabi/birds/device/adapter/DeviceAdapter.kt b/app/src/main/java/com/juhabi/birds/device/adapter/DeviceAdapter.kt
index 1b5e98a..79b9743 100644
--- a/app/src/main/java/com/juhabi/birds/device/adapter/DeviceAdapter.kt
+++ b/app/src/main/java/com/juhabi/birds/device/adapter/DeviceAdapter.kt
@@ -15,6 +15,7 @@ class DeviceAdapter : BaseQuickAdapter(R.layout.de
init {
addChildClickViewIds(R.id.img_more)
+ addChildClickViewIds(R.id.tv_show_map)
}
override fun convert(holder: BaseViewHolder, item: DeviceEntity) {
diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml
index fcfc090..9865c09 100644
--- a/app/src/main/res/layout/activity_login.xml
+++ b/app/src/main/res/layout/activity_login.xml
@@ -107,6 +107,7 @@
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="10dp"
+ android:gravity="center_vertical"
android:layout_weight="1"
android:background="@null"
android:hint="@string/register_password_hint"
diff --git a/app/src/main/res/layout/mapview_layout.xml b/app/src/main/res/layout/mapview_layout.xml
new file mode 100644
index 0000000..5d2e023
--- /dev/null
+++ b/app/src/main/res/layout/mapview_layout.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
\ No newline at end of file