feat: init iot-util project with OneNET API integration
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
*.log
|
||||
*.flattened-pom.xml
|
||||
|
||||
### NetBeans ###
|
||||
nbproject/private/
|
||||
build/
|
||||
nbbuild/
|
||||
dist/
|
||||
nbdist/
|
||||
.nb-gradle/
|
||||
|
||||
### Mac files ###
|
||||
*.DS_Store
|
||||
@@ -0,0 +1,134 @@
|
||||
# iot-util 项目开发规范
|
||||
|
||||
## 本地测试
|
||||
|
||||
- **规则**: 不进行本地启动测试,由用户自行在 IDEA 中运行验证。
|
||||
- **约束**: 严禁使用任何方式启动或停止本地 Java 进程。
|
||||
|
||||
---
|
||||
|
||||
## 项目概述
|
||||
|
||||
IoT 设备管理工具,对接中国移动 OneNET 平台(iot-api.heclouds.com),提供产品详情、设备列表、设备属性、物模型、数据点等查询功能,并附带 Web 管理界面。
|
||||
|
||||
- **框架**: Solon 3.10.5
|
||||
- **认证**: Sa-Token 1.44.0(登录鉴权)+ HMAC-SHA1(IoT 平台鉴权)
|
||||
- **HTTP 客户端**: OkHttp 4.12.0
|
||||
- **模板引擎**: FreeMarker
|
||||
- **JSON**: Jackson(solon-serialization-jackson 3.10.5)
|
||||
- **端口**: 8081(app.yml 配置)
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/main/java/com/yixiong/iot/
|
||||
├── App.java # 启动类
|
||||
├── config/
|
||||
│ └── IotProperties.java # IoT 配置属性(对应 app.yml iot.* 节点)
|
||||
├── controller/
|
||||
│ ├── AuthController.java # 登录/登出页面路由
|
||||
│ ├── PageController.java # 主页路由(注入 defaultProductId 到模板)
|
||||
│ ├── ProductController.java # GET /product/detail
|
||||
│ ├── DeviceController.java # GET /device/list|detail|event-log|datapoints
|
||||
│ ├── ThingModelController.java # GET|POST /thingmodel/*
|
||||
│ └── GlobalExceptionFilter.java # 全局异常过滤,统一返回 JSON
|
||||
├── service/
|
||||
│ ├── ProductService.java
|
||||
│ ├── DeviceService.java
|
||||
│ └── ThingModelService.java
|
||||
├── model/
|
||||
│ ├── IotResponse.java # 通用响应包装(code/msg/requestId/data)
|
||||
│ ├── OldIotResponse.java # 旧版响应(errno/error/data,已备用)
|
||||
│ ├── ProductDetailData.java
|
||||
│ ├── DeviceItem.java # 设备列表项 & 设备详情复用
|
||||
│ ├── DeviceListData.java
|
||||
│ ├── DeviceEventItem.java
|
||||
│ ├── DeviceEventListData.java
|
||||
│ ├── DevicePropertyItem.java # 设备属性最新数据
|
||||
│ ├── PropertyHistoryData.java # 属性历史包装(data.list)
|
||||
│ ├── PropertyHistoryItem.java
|
||||
│ ├── DatapointData.java # 数据点(含内部类 DatapointStream/DatapointItem)
|
||||
│ └── ThingModelData.java # 物模型(含内部类 Property/Event/Service)
|
||||
└── util/
|
||||
├── IotHttpClient.java # OkHttp 封装,HMAC 鉴权,正常响应 DEBUG 日志
|
||||
└── IotTokenUtil.java # HMAC-SHA1 Token 生成
|
||||
src/main/resources/
|
||||
├── app.yml
|
||||
└── templates/
|
||||
├── login.ftl # 登录页
|
||||
└── index.ftl # 主页(产品详情 + 设备列表 Tab,多个弹窗)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 路由一览
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/` | 主页(需登录) |
|
||||
| GET | `/auth/login` | 登录页 |
|
||||
| POST | `/auth/doLogin` | 登录处理 |
|
||||
| GET | `/auth/logout` | 登出 |
|
||||
| GET | `/product/detail?product_id=` | 产品详情 |
|
||||
| GET | `/device/list` | 设备列表(product_id 取配置默认值) |
|
||||
| GET | `/device/detail?device_name=` | 设备详情(product_id 取配置默认值) |
|
||||
| GET | `/device/event-log` | 设备事件记录 |
|
||||
| GET | `/device/datapoints` | 设备历史数据点 |
|
||||
| GET | `/thingmodel/query-thing-model` | 产品物模型(product_id 取配置默认值) |
|
||||
| GET | `/thingmodel/query-device-property?device_name=` | 设备属性最新数据 |
|
||||
| POST | `/thingmodel/query-device-property-detail` | 设备属性详情(下发命令) |
|
||||
| GET | `/thingmodel/query-property-history` | 设备属性历史记录(data 为 Object,结构不固定) |
|
||||
|
||||
---
|
||||
|
||||
## 配置说明(app.yml)
|
||||
|
||||
```yaml
|
||||
iot:
|
||||
auth:
|
||||
version: "2022-05-01"
|
||||
resource-name: "userid/459810" # IoT 平台用户 ID
|
||||
access-key: "..." # HMAC 密钥(Base64)
|
||||
signature-method: "sha1"
|
||||
expiration-seconds: 3600
|
||||
http:
|
||||
base-url: "https://iot-api.heclouds.com" # 新版 API(HMAC 鉴权)
|
||||
default-product-id: "VGi8wC99jO" # 默认产品 ID,多数接口使用此值
|
||||
old-base-url: "https://api.heclouds.com" # 旧版 API(api-key 鉴权,备用)
|
||||
api-key: "..." # 旧版 Master Key
|
||||
|
||||
auth:
|
||||
username: admin
|
||||
password: ... # 登录密码
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
### IoT 平台鉴权
|
||||
- 新版接口(`iot-api.heclouds.com`)使用 HMAC-SHA1 Token,放在 `Authorization` 请求头
|
||||
- Token 格式:`version=...&res=...&et=...&method=sha1&sign=...`(查询字符串格式)
|
||||
- 每次请求实时生成 Token,避免过期
|
||||
|
||||
### HTTP 客户端
|
||||
- `IotHttpClient.execute()` 不对非 2xx 状态码抛异常,始终返回响应体
|
||||
- 正常响应打 DEBUG 日志,非 2xx 打 WARN,网络异常打 ERROR
|
||||
- 全局日志级别 WARN,框架 INFO 日志屏蔽
|
||||
|
||||
### 响应反序列化
|
||||
- 各 Service 使用独立 `ObjectMapper`,配置 `SNAKE_CASE` 命名策略 + 忽略未知字段
|
||||
- 属性历史接口(`/thingmodel/query-property-history`)的 `data` 结构不固定,反序列化为 `Object` 透传
|
||||
|
||||
### 前端页面
|
||||
- 纯原生 HTML/CSS/JS + FreeMarker,无第三方 UI 框架
|
||||
- 主页两个 Tab:产品详情、设备列表;页面加载后自动触发两个查询
|
||||
- 设备列表每行操作按钮:详情、数据点、事件记录、属性(均以弹窗展示)
|
||||
- 属性弹窗支持二级跳转:属性列表 → 历史记录弹窗
|
||||
- 产品详情页有"物模型"按钮,展示属性/事件/服务功能点
|
||||
|
||||
### 已知注意事项
|
||||
- `DeviceDetailData.java` 和 `OldIotResponse.java` 为旧版 API 遗留,当前设备详情已改用新版接口(`/device/detail`),这两个类暂未删除
|
||||
- `ThingModelData.ThingModelService` 内部类与 Spring 的 `@Service` 无关,是物模型的服务功能点 DTO
|
||||
@@ -0,0 +1,106 @@
|
||||
<?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">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-parent</artifactId>
|
||||
<version>3.10.5</version>
|
||||
<relativePath />
|
||||
</parent>
|
||||
|
||||
<groupId>com.yixiong</groupId>
|
||||
<artifactId>iot-util</artifactId>
|
||||
<version>1.0</version>
|
||||
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<description>iot-util for yixiong</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-view-freemarker</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-logging-logback-jakarta</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp</artifactId>
|
||||
<version>4.12.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-serialization-jackson</artifactId>
|
||||
<version>3.10.5</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>cn.dev33</groupId>
|
||||
<artifactId>sa-token-solon-plugin</artifactId>
|
||||
<version>1.44.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>${project.artifactId}</finalName>
|
||||
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>tencent</id>
|
||||
<url>https://mirrors.cloud.tencent.com/nexus/repository/maven-public/</url>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yixiong.iot;
|
||||
|
||||
import org.noear.solon.Solon;
|
||||
import org.noear.solon.annotation.SolonMain;
|
||||
|
||||
@SolonMain
|
||||
public class App {
|
||||
public static void main(String[] args) {
|
||||
Solon.start(App.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yixiong.iot;
|
||||
|
||||
import org.noear.solon.annotation.Controller;
|
||||
import org.noear.solon.annotation.Mapping;
|
||||
import org.noear.solon.annotation.Param;
|
||||
import org.noear.solon.core.handle.ModelAndView;
|
||||
|
||||
@Controller
|
||||
public class DemoController {
|
||||
@Mapping("/hello")
|
||||
public String hello(@Param(defaultValue = "world") String name) {
|
||||
return String.format("Hello %s!", name);
|
||||
}
|
||||
|
||||
@Mapping("/hello2")
|
||||
public ModelAndView hello2(@Param(defaultValue = "world") String name) {
|
||||
return new ModelAndView("hello2.ftl").put("name", name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.yixiong.iot.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.noear.solon.annotation.Configuration;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
|
||||
/**
|
||||
* IoT 平台鉴权与 HTTP 配置属性,对应 app.yml 中的 iot.* 节点
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
@Inject("${iot}")
|
||||
public class IotProperties {
|
||||
|
||||
/** 鉴权配置 */
|
||||
private Auth auth = new Auth();
|
||||
|
||||
/** HTTP 客户端配置 */
|
||||
private Http http = new Http();
|
||||
|
||||
@Data
|
||||
public static class Auth {
|
||||
/** 鉴权版本号 */
|
||||
private String version;
|
||||
/** 资源名称,如 userid/459810 */
|
||||
private String resourceName;
|
||||
/** HMAC 签名密钥(Base64 编码) */
|
||||
private String accessKey;
|
||||
/** 签名算法:sha1 / md5 / sha256 */
|
||||
private String signatureMethod;
|
||||
/** token 有效期(秒) */
|
||||
private long expirationSeconds = 3600;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Http {
|
||||
/** IoT 平台接口基础地址(新版 HMAC 鉴权) */
|
||||
private String baseUrl;
|
||||
/** 默认产品 ID */
|
||||
private String defaultProductId;
|
||||
/** 旧版 API 基础地址(api-key 鉴权) */
|
||||
private String oldBaseUrl;
|
||||
/** 旧版 API Master Key */
|
||||
private String apiKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.yixiong.iot.controller;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import org.noear.solon.annotation.Controller;
|
||||
import org.noear.solon.annotation.Get;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.noear.solon.annotation.Mapping;
|
||||
import org.noear.solon.annotation.Post;
|
||||
import org.noear.solon.annotation.Param;
|
||||
import org.noear.solon.core.handle.Context;
|
||||
import org.noear.solon.core.handle.ModelAndView;
|
||||
|
||||
/**
|
||||
* 登录/登出控制器
|
||||
*/
|
||||
@Controller
|
||||
public class AuthController {
|
||||
|
||||
@Inject("${auth.username}")
|
||||
private String username;
|
||||
|
||||
@Inject("${auth.password}")
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* 显示登录页
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/auth/login")
|
||||
public ModelAndView loginPage(@Param(value = "error", required = false) String error) {
|
||||
ModelAndView mv = new ModelAndView("login.ftl");
|
||||
if ("1".equals(error)) {
|
||||
mv.put("errorMsg", "用户名或密码错误");
|
||||
}
|
||||
return mv;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理登录表单提交
|
||||
*/
|
||||
@Post
|
||||
@Mapping("/auth/doLogin")
|
||||
public void doLogin(@Param("username") String inputUser,
|
||||
@Param("password") String inputPass,
|
||||
Context ctx) throws Exception {
|
||||
if (username.equals(inputUser) && password.equals(inputPass)) {
|
||||
StpUtil.login(inputUser);
|
||||
ctx.redirect("/");
|
||||
} else {
|
||||
ctx.redirect("/auth/login?error=1");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/auth/logout")
|
||||
public void logout(Context ctx) throws Exception {
|
||||
StpUtil.logout();
|
||||
ctx.redirect("/auth/login");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.yixiong.iot.controller;
|
||||
|
||||
import com.yixiong.iot.model.DatapointData;
|
||||
import com.yixiong.iot.model.DeviceEventListData;
|
||||
import com.yixiong.iot.model.DeviceItem;
|
||||
import com.yixiong.iot.model.DeviceListData;
|
||||
import com.yixiong.iot.model.IotResponse;
|
||||
import com.yixiong.iot.service.DeviceService;
|
||||
import org.noear.solon.annotation.Controller;
|
||||
import org.noear.solon.annotation.Get;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.noear.solon.annotation.Mapping;
|
||||
import org.noear.solon.annotation.Param;
|
||||
|
||||
/**
|
||||
* 设备相关接口,透传 IoT 平台设备数据
|
||||
*/
|
||||
@Controller
|
||||
@Mapping("/device")
|
||||
public class DeviceController {
|
||||
|
||||
@Inject
|
||||
private DeviceService deviceService;
|
||||
|
||||
/**
|
||||
* 查询设备列表
|
||||
* product_id 使用 yml 中配置的默认值,无需传入
|
||||
*
|
||||
* @param deviceName 设备名称左前缀模糊匹配(可选)
|
||||
* @param offset 查询起始下标(可选,默认 0)
|
||||
* @param limit 单次查询数量(可选,默认 10,最大 100)
|
||||
* @return IoT 平台设备列表响应
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/list")
|
||||
public IotResponse<DeviceListData> list(
|
||||
@Param(value = "device_name", required = false) String deviceName,
|
||||
@Param(value = "offset", required = false) Integer offset,
|
||||
@Param(value = "limit", required = false) Integer limit) {
|
||||
return deviceService.listDevices(deviceName, offset, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询设备事件记录
|
||||
* product_id 使用 yml 中配置的默认值,device_name 与 imei 二选一必填
|
||||
*
|
||||
* @param deviceName 设备名称(与 imei 二选一)
|
||||
* @param imei 设备 IMEI(与 deviceName 二选一)
|
||||
* @param startTime 查询起始时间(毫秒时间戳,必填)
|
||||
* @param endTime 查询截止时间(毫秒时间戳,必填)
|
||||
* @param identifier 事件功能点标识(可选)
|
||||
* @param eventType 事件类型:1-信息,2-告警,3-故障(可选)
|
||||
* @param offset 查询起始位置(可选,默认 0)
|
||||
* @param limit 每次请求记录数(可选,默认 10,最大 100)
|
||||
* @return IoT 平台事件记录响应
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/event-log")
|
||||
public IotResponse<DeviceEventListData> eventLog(
|
||||
@Param(value = "device_name", required = false) String deviceName,
|
||||
@Param(value = "imei", required = false) String imei,
|
||||
@Param("start_time") Long startTime,
|
||||
@Param("end_time") Long endTime,
|
||||
@Param(value = "identifier", required = false) String identifier,
|
||||
@Param(value = "event_type", required = false) Integer eventType,
|
||||
@Param(value = "offset", required = false) Integer offset,
|
||||
@Param(value = "limit", required = false) Integer limit) {
|
||||
return deviceService.getEventLog(deviceName, imei, startTime, endTime,
|
||||
identifier, eventType, offset, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询设备详情(新版 API,product_id + device_name)
|
||||
*
|
||||
* @param deviceName 设备名称(与 imei 二选一)
|
||||
* @param imei 设备 IMEI(与 deviceName 二选一)
|
||||
* @return 设备详情响应
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/detail")
|
||||
public IotResponse<DeviceItem> detail(
|
||||
@Param(value = "device_name", required = false) String deviceName,
|
||||
@Param(value = "imei", required = false) String imei) {
|
||||
return deviceService.getDeviceDetail(deviceName, imei);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询设备历史数据点
|
||||
* product_id 使用 yml 中配置的默认值
|
||||
*
|
||||
* @param deviceName 设备名称(必填)
|
||||
* @param datastreamId 数据流 ID,多个用逗号分隔(可选)
|
||||
* @param start 开始时间,格式 2015-01-10T08:00:35(可选)
|
||||
* @param end 结束时间,格式 2015-01-10T08:00:35(可选)
|
||||
* @param limit 最多返回数据点数(可选,默认 100)
|
||||
* @param cursor 分页游标(可选)
|
||||
* @param sort 排序方式:DESC/ASC(可选)
|
||||
* @return 数据点响应
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/datapoints")
|
||||
public IotResponse<DatapointData> datapoints(
|
||||
@Param("device_name") String deviceName,
|
||||
@Param(value = "datastream_id", required = false) String datastreamId,
|
||||
@Param(value = "start", required = false) String start,
|
||||
@Param(value = "end", required = false) String end,
|
||||
@Param(value = "limit", required = false) Integer limit,
|
||||
@Param(value = "cursor", required = false) String cursor,
|
||||
@Param(value = "sort", required = false) String sort) {
|
||||
return deviceService.getHistoryDatapoints(deviceName, datastreamId, start, end, limit, cursor, sort);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yixiong.iot.controller;
|
||||
|
||||
import com.yixiong.iot.model.IotResponse;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.core.handle.Context;
|
||||
import org.noear.solon.core.handle.Filter;
|
||||
import org.noear.solon.core.handle.FilterChain;
|
||||
|
||||
/**
|
||||
* 全局异常过滤器,捕获接口层未处理的异常,统一返回 JSON 格式错误响应
|
||||
* 避免后端异常导致前端收到非 JSON 响应而解析失败
|
||||
*/
|
||||
@Component
|
||||
public class GlobalExceptionFilter implements Filter {
|
||||
|
||||
@Override
|
||||
public void doFilter(Context ctx, FilterChain chain) throws Throwable {
|
||||
try {
|
||||
chain.doFilter(ctx);
|
||||
} catch (Throwable e) {
|
||||
if (!ctx.getHandled()) {
|
||||
IotResponse<Void> err = new IotResponse<>();
|
||||
err.setCode(-1);
|
||||
err.setMsg(e.getMessage() != null ? e.getMessage() : "服务器内部错误");
|
||||
ctx.render(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.yixiong.iot.controller;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.yixiong.iot.config.IotProperties;
|
||||
import org.noear.solon.annotation.Controller;
|
||||
import org.noear.solon.annotation.Get;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.noear.solon.annotation.Mapping;
|
||||
import org.noear.solon.core.handle.Context;
|
||||
import org.noear.solon.core.handle.ModelAndView;
|
||||
|
||||
/**
|
||||
* 页面路由控制器,负责渲染前端页面,未登录时重定向到登录页
|
||||
*/
|
||||
@Controller
|
||||
public class PageController {
|
||||
|
||||
@Inject
|
||||
private IotProperties iotProperties;
|
||||
|
||||
/**
|
||||
* 主页,未登录则跳转登录页
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/")
|
||||
public Object index(Context ctx) throws Exception {
|
||||
if (!StpUtil.isLogin()) {
|
||||
ctx.redirect("/auth/login");
|
||||
return null;
|
||||
}
|
||||
return new ModelAndView("index.ftl")
|
||||
.put("defaultProductId", iotProperties.getHttp().getDefaultProductId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.yixiong.iot.controller;
|
||||
|
||||
import com.yixiong.iot.model.IotResponse;
|
||||
import com.yixiong.iot.model.ProductDetailData;
|
||||
import com.yixiong.iot.service.ProductService;
|
||||
import org.noear.solon.annotation.Controller;
|
||||
import org.noear.solon.annotation.Get;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.noear.solon.annotation.Mapping;
|
||||
import org.noear.solon.annotation.Param;
|
||||
|
||||
/**
|
||||
* 产品相关接口,透传 IoT 平台产品数据
|
||||
*/
|
||||
@Controller
|
||||
@Mapping("/product")
|
||||
public class ProductController {
|
||||
|
||||
@Inject
|
||||
private ProductService productService;
|
||||
|
||||
/**
|
||||
* 查询产品详情
|
||||
*
|
||||
* @param productId 产品 ID(必填)
|
||||
* @return IoT 平台产品详情响应
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/detail")
|
||||
public IotResponse<ProductDetailData> detail(@Param("product_id") String productId) {
|
||||
return productService.getProductDetail(productId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.yixiong.iot.controller;
|
||||
|
||||
import com.yixiong.iot.model.DevicePropertyItem;
|
||||
import com.yixiong.iot.model.IotResponse;
|
||||
import com.yixiong.iot.model.PropertyHistoryData;
|
||||
import com.yixiong.iot.model.ThingModelData;
|
||||
import com.yixiong.iot.service.ThingModelService;
|
||||
import org.noear.solon.annotation.Body;
|
||||
import org.noear.solon.annotation.Controller;
|
||||
import org.noear.solon.annotation.Get;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.noear.solon.annotation.Mapping;
|
||||
import org.noear.solon.annotation.Param;
|
||||
import org.noear.solon.annotation.Post;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 物模型相关接口,透传 IoT 平台物模型数据
|
||||
*/
|
||||
@Controller
|
||||
@Mapping("/thingmodel")
|
||||
public class ThingModelController {
|
||||
|
||||
@Inject
|
||||
private ThingModelService thingModelService;
|
||||
|
||||
/**
|
||||
* 查询产品物模型
|
||||
* product_id 使用 yml 中配置的默认值
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/query-thing-model")
|
||||
public IotResponse<ThingModelData> queryThingModel() {
|
||||
return thingModelService.queryThingModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询设备属性最新数据
|
||||
*
|
||||
* @param deviceName 设备名称(必填)
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/query-device-property")
|
||||
public IotResponse<List<DevicePropertyItem>> queryDeviceProperty(@Param("device_name") String deviceName) {
|
||||
return thingModelService.queryDeviceProperty(deviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备属性详情(下发命令到设备,需设备在线)
|
||||
*
|
||||
* @param body 请求体,含 device_name 和 params 字段
|
||||
*/
|
||||
@Post
|
||||
@Mapping("/query-device-property-detail")
|
||||
public IotResponse<Map<String, Object>> queryDevicePropertyDetail(@Body Map<String, Object> body) {
|
||||
String deviceName = (String) body.get("device_name");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> params = (List<String>) body.get("params");
|
||||
return thingModelService.queryDevicePropertyDetail(deviceName, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询设备属性历史记录
|
||||
*
|
||||
* @param deviceName 设备名称(必填)
|
||||
* @param identifier 属性功能点标识(必填)
|
||||
* @param startTime 查询起始时间(毫秒时间戳,必填)
|
||||
* @param endTime 查询结束时间(毫秒时间戳,必填)
|
||||
* @param sort 排序:1-正序,2-倒序(可选)
|
||||
* @param offset 起始位置(可选)
|
||||
* @param limit 每次记录数(可选)
|
||||
*/
|
||||
@Get
|
||||
@Mapping("/query-property-history")
|
||||
public IotResponse<Object> queryPropertyHistory(
|
||||
@Param("device_name") String deviceName,
|
||||
@Param("identifier") String identifier,
|
||||
@Param("start_time") Long startTime,
|
||||
@Param("end_time") Long endTime,
|
||||
@Param(value = "sort", required = false) Integer sort,
|
||||
@Param(value = "offset", required = false) Integer offset,
|
||||
@Param(value = "limit", required = false) Integer limit) {
|
||||
return thingModelService.queryPropertyHistory(deviceName, identifier, startTime, endTime, sort, offset, limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.yixiong.iot.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 设备历史数据点响应的 data 字段
|
||||
*/
|
||||
@Data
|
||||
public class DatapointData {
|
||||
|
||||
/** 本次返回的数据点数量 */
|
||||
private Integer count;
|
||||
|
||||
/** 游标,用于分页继续请求 */
|
||||
private String cursor;
|
||||
|
||||
/** 数据流列表 */
|
||||
private List<DatapointStream> datastreams;
|
||||
|
||||
/**
|
||||
* 单个数据流及其数据点
|
||||
*/
|
||||
@Data
|
||||
public static class DatapointStream {
|
||||
/** 数据流名称 */
|
||||
private String id;
|
||||
/** 数据点列表 */
|
||||
private List<DatapointItem> datapoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个数据点
|
||||
*/
|
||||
@Data
|
||||
public static class DatapointItem {
|
||||
/** 数据记录时间 */
|
||||
private String at;
|
||||
/** 数据点的值 */
|
||||
private Object value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.yixiong.iot.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 旧版 API 设备详情数据(对应 api.heclouds.com/devices/{device_id})
|
||||
*/
|
||||
@Data
|
||||
public class DeviceDetailData {
|
||||
|
||||
/** 平台分配的唯一 ID */
|
||||
private String id;
|
||||
|
||||
/** 设备名称 */
|
||||
private String title;
|
||||
|
||||
/** 设备描述 */
|
||||
private String desc;
|
||||
|
||||
/** 设备接入协议 */
|
||||
private String protocol;
|
||||
|
||||
/** 是否在线 */
|
||||
private Boolean online;
|
||||
|
||||
/** 是否自动订阅 */
|
||||
private Boolean obsv;
|
||||
|
||||
/** 订阅状态 */
|
||||
@JsonProperty("obsv_st")
|
||||
private Boolean obsvSt;
|
||||
|
||||
/** 设备私密性 */
|
||||
@JsonProperty("private")
|
||||
private Boolean privateFlag;
|
||||
|
||||
/** 设备创建时间 */
|
||||
@JsonProperty("create_time")
|
||||
private String createTime;
|
||||
|
||||
/** 激活时间(第一次上线时间) */
|
||||
@JsonProperty("act_time")
|
||||
private String actTime;
|
||||
|
||||
/** 最后连接服务器时间 */
|
||||
@JsonProperty("last_ct")
|
||||
private String lastCt;
|
||||
|
||||
/** NB-IoT 设备 endpoint name(IMEI) */
|
||||
@JsonProperty("rg_id")
|
||||
private String rgId;
|
||||
|
||||
/** NB-IoT 设备当前 IMSI */
|
||||
private String imsi;
|
||||
|
||||
/** IMSI 最新修改时间 */
|
||||
@JsonProperty("imsi_mt")
|
||||
private String imsiMt;
|
||||
|
||||
/** IMSI 更改通知状态 */
|
||||
private Boolean ack;
|
||||
|
||||
/** 历史 IMSI 列表 */
|
||||
@JsonProperty("imsi_old")
|
||||
private List<String> imsiOld;
|
||||
|
||||
/** 设备鉴权信息 */
|
||||
@JsonProperty("auth_info")
|
||||
private Map<String, Object> authInfo;
|
||||
|
||||
/** 设备位置坐标 */
|
||||
private Map<String, Object> location;
|
||||
|
||||
/** 设备标签 */
|
||||
private List<String> tags;
|
||||
|
||||
/** 生产厂商名称 */
|
||||
@JsonProperty("manu_id")
|
||||
private String manuId;
|
||||
|
||||
/** 型号名称 */
|
||||
private String model;
|
||||
|
||||
/** 数据流信息列表 */
|
||||
private List<Map<String, Object>> datastreams;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.yixiong.iot.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 设备事件记录单条数据
|
||||
*/
|
||||
@Data
|
||||
public class DeviceEventItem {
|
||||
|
||||
/**
|
||||
* 事件类型
|
||||
* 1-信息;2-告警;3-故障
|
||||
*/
|
||||
@JsonProperty("event_type")
|
||||
private Integer eventType;
|
||||
|
||||
/** 设备事件上报时间戳(毫秒) */
|
||||
private Long time;
|
||||
|
||||
/** 事件功能点标识 */
|
||||
private String identifier;
|
||||
|
||||
/** 事件名称 */
|
||||
private String name;
|
||||
|
||||
/** 事件值(JSON 字符串) */
|
||||
private String value;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.yixiong.iot.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 设备事件记录列表响应的 data 字段
|
||||
*/
|
||||
@Data
|
||||
public class DeviceEventListData {
|
||||
|
||||
/** 事件记录列表 */
|
||||
private List<DeviceEventItem> list;
|
||||
|
||||
/** 本次请求起始位置 */
|
||||
private Integer offset;
|
||||
|
||||
/** 本次请求记录数上限 */
|
||||
private Integer limit;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.yixiong.iot.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 设备列表中单个设备的数据结构
|
||||
*/
|
||||
@Data
|
||||
public class DeviceItem {
|
||||
|
||||
/** 设备 ID */
|
||||
private String did;
|
||||
|
||||
/** 产品 ID */
|
||||
private String pid;
|
||||
|
||||
/** 接入协议:2-MQTT,4-LwM2M */
|
||||
@JsonProperty("access_pt")
|
||||
private Integer accessPt;
|
||||
|
||||
/** 数据协议 */
|
||||
@JsonProperty("data_pt")
|
||||
private Integer dataPt;
|
||||
|
||||
/** 设备名称 */
|
||||
private String name;
|
||||
|
||||
/** 设备描述 */
|
||||
private String desc;
|
||||
|
||||
/**
|
||||
* 设备状态
|
||||
* 0-离线;1-在线;2-未激活
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/** 设备创建时间 */
|
||||
@JsonProperty("create_time")
|
||||
private String createTime;
|
||||
|
||||
/** 设备激活时间 */
|
||||
@JsonProperty("activate_time")
|
||||
private String activateTime;
|
||||
|
||||
/** 设备最后一次在线时间 */
|
||||
@JsonProperty("last_time")
|
||||
private String lastTime;
|
||||
|
||||
/** 设备接入鉴权 key */
|
||||
@JsonProperty("sec_key")
|
||||
private String secKey;
|
||||
|
||||
/** LwM2M 设备 IMEI */
|
||||
private String imei;
|
||||
|
||||
/** LwM2M 设备 IMSI */
|
||||
private String imsi;
|
||||
|
||||
/** LwM2M 设备 PSK */
|
||||
private String psk;
|
||||
|
||||
/** LwM2M 设备 auth_code */
|
||||
@JsonProperty("auth_code")
|
||||
private String authCode;
|
||||
|
||||
/**
|
||||
* 产品智能化方式
|
||||
* 1-设备接入;2-产品智能化
|
||||
*/
|
||||
@JsonProperty("intelligent_way")
|
||||
private Integer intelligentWay;
|
||||
|
||||
/** 设备分组 ID */
|
||||
@JsonProperty("group_id")
|
||||
private String groupId;
|
||||
|
||||
/** 设备启用状态 */
|
||||
@JsonProperty("enable_status")
|
||||
private Boolean enableStatus;
|
||||
|
||||
/** 经度 */
|
||||
@JsonProperty("Lon")
|
||||
private String lon;
|
||||
|
||||
/** 纬度 */
|
||||
@JsonProperty("Lat")
|
||||
private String lat;
|
||||
|
||||
/** 设备资源自动订阅是否启用 */
|
||||
private Boolean obsv;
|
||||
|
||||
/** 设备资源自动订阅状态 */
|
||||
@JsonProperty("obsv_st")
|
||||
private Boolean obsvSt;
|
||||
|
||||
/** 设备私密性 */
|
||||
@JsonProperty("private")
|
||||
private Boolean privateFlag;
|
||||
|
||||
/** 设备 IMSI 历史变更记录 */
|
||||
@JsonProperty("imsi_old")
|
||||
private java.util.List<String> imsiOld;
|
||||
|
||||
/** 设备 IMSI 最近一次修改时间 */
|
||||
@JsonProperty("imsi_mt")
|
||||
private String imsiMt;
|
||||
|
||||
/** 移动视联网设备唯一标识 */
|
||||
@JsonProperty("viot_device_sn")
|
||||
private String viotDeviceSn;
|
||||
|
||||
/** 移动视联网设备接入协议 */
|
||||
@JsonProperty("viot_protocol")
|
||||
private Integer viotProtocol;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.yixiong.iot.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 设备列表响应的 data 字段
|
||||
*/
|
||||
@Data
|
||||
public class DeviceListData {
|
||||
|
||||
/** 设备列表 */
|
||||
private List<DeviceItem> list;
|
||||
|
||||
/** 查询起始下标 */
|
||||
private Integer offset;
|
||||
|
||||
/** 单次查询数量限额 */
|
||||
private Integer limit;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yixiong.iot.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 设备属性最新数据单条记录
|
||||
*/
|
||||
@Data
|
||||
public class DevicePropertyItem {
|
||||
|
||||
/** 功能点标识 */
|
||||
private String identifier;
|
||||
|
||||
/** 功能点名称 */
|
||||
private String name;
|
||||
|
||||
/** 功能描述 */
|
||||
private String description;
|
||||
|
||||
/** 数据类型 */
|
||||
@JsonProperty("data_type")
|
||||
private String dataType;
|
||||
|
||||
/** 读写类型 */
|
||||
@JsonProperty("access_mode")
|
||||
private String accessMode;
|
||||
|
||||
/** 上报时间(毫秒时间戳字符串) */
|
||||
private String time;
|
||||
|
||||
/** 功能点上报值(JSON 字符串) */
|
||||
private String value;
|
||||
|
||||
/** 期望值(JSON 字符串) */
|
||||
@JsonProperty("expect_value")
|
||||
private String expectValue;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yixiong.iot.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* IoT 平台接口通用响应包装类
|
||||
*
|
||||
* @param <T> 具体业务数据类型
|
||||
*/
|
||||
@Data
|
||||
public class IotResponse<T> {
|
||||
|
||||
/** 错误码,0 表示成功 */
|
||||
private int code;
|
||||
|
||||
/** 错误描述信息 */
|
||||
private String msg;
|
||||
|
||||
/** API 请求链路 ID */
|
||||
private String requestId;
|
||||
|
||||
/** 具体响应内容 */
|
||||
private T data;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.yixiong.iot.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 旧版 IoT API 通用响应包装类(api.heclouds.com)
|
||||
* 使用 errno/error 而非 code/msg
|
||||
*
|
||||
* @param <T> 具体业务数据类型
|
||||
*/
|
||||
@Data
|
||||
public class OldIotResponse<T> {
|
||||
|
||||
/** 错误码,0 表示成功 */
|
||||
private int errno;
|
||||
|
||||
/** 错误描述,"succ" 表示成功 */
|
||||
private String error;
|
||||
|
||||
/** 具体响应内容 */
|
||||
private T data;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.yixiong.iot.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 产品详情数据,对应 IoT 平台 /product/detail 接口的 data 字段
|
||||
*/
|
||||
@Data
|
||||
public class ProductDetailData {
|
||||
|
||||
/** 产品 ID */
|
||||
@JsonProperty("product_id")
|
||||
private String productId;
|
||||
|
||||
/** 产品名称 */
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 节点类型
|
||||
* 1-直连设备;2-网关设备;3-子设备
|
||||
*/
|
||||
@JsonProperty("node_type")
|
||||
private Integer nodeType;
|
||||
|
||||
/**
|
||||
* 接入协议
|
||||
* 取值见接入协议取值说明表
|
||||
*/
|
||||
@JsonProperty("access_protocol")
|
||||
private Integer accessProtocol;
|
||||
|
||||
/**
|
||||
* 数据协议
|
||||
* 取值见数据协议取值说明表
|
||||
*/
|
||||
@JsonProperty("data_protocol")
|
||||
private Integer dataProtocol;
|
||||
|
||||
/** 联网方式 */
|
||||
private String network;
|
||||
|
||||
/** 产品厂商 */
|
||||
private String manufacturer;
|
||||
|
||||
/** 产品型号 */
|
||||
private String model;
|
||||
|
||||
/** 产品品牌 */
|
||||
private String brand;
|
||||
|
||||
/** 产品描述 */
|
||||
private String desc;
|
||||
|
||||
/**
|
||||
* 开发状态
|
||||
* 1-开发中;2-审核中;3-审核未通过;4-已发布;5-已下架
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/** 产品创建时间 */
|
||||
@JsonProperty("create_time")
|
||||
private String createTime;
|
||||
|
||||
/** 最近修改时间 */
|
||||
@JsonProperty("update_time")
|
||||
private String updateTime;
|
||||
|
||||
/** 自有设备总数(缓存时效 5 分钟) */
|
||||
@JsonProperty("own_device_count")
|
||||
private Integer ownDeviceCount;
|
||||
|
||||
/** 在线设备总数(缓存时效 5 分钟) */
|
||||
@JsonProperty("online_device_count")
|
||||
private Integer onlineDeviceCount;
|
||||
|
||||
/** 离线设备数(缓存时效 5 分钟) */
|
||||
@JsonProperty("offline_device_count")
|
||||
private Integer offlineDeviceCount;
|
||||
|
||||
/** 未激活设备数(缓存时效 5 分钟) */
|
||||
@JsonProperty("not_active_device_count")
|
||||
private Integer notActiveDeviceCount;
|
||||
|
||||
/** 查询时间(毫秒时间戳) */
|
||||
@JsonProperty("query_time")
|
||||
private Long queryTime;
|
||||
|
||||
/** 视频类产品返回,用于视频播放器 SDK 集成参数 */
|
||||
@JsonProperty("viot_app_id")
|
||||
private String viotAppId;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.yixiong.iot.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 设备属性历史记录响应的 data 字段
|
||||
* 实际 API 返回 data 为对象,内含 list 数组
|
||||
*/
|
||||
@Data
|
||||
public class PropertyHistoryData {
|
||||
|
||||
/** 属性历史记录列表 */
|
||||
private List<PropertyHistoryItem> list;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.yixiong.iot.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 设备属性历史记录单条数据
|
||||
*/
|
||||
@Data
|
||||
public class PropertyHistoryItem {
|
||||
|
||||
/** 属性功能点上报时间(毫秒时间戳字符串) */
|
||||
private String time;
|
||||
|
||||
/** 属性功能点上报值 */
|
||||
private String value;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.yixiong.iot.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 物模型查询响应的 data 字段
|
||||
*/
|
||||
@Data
|
||||
public class ThingModelData {
|
||||
|
||||
/** 属性功能点列表 */
|
||||
private List<ThingModelProperty> properties;
|
||||
|
||||
/** 事件功能点列表 */
|
||||
private List<ThingModelEvent> events;
|
||||
|
||||
/** 服务功能点列表 */
|
||||
private List<ThingModelService> services;
|
||||
|
||||
/** 属性功能点 */
|
||||
@Data
|
||||
public static class ThingModelProperty {
|
||||
private String identifier;
|
||||
private String name;
|
||||
private String desc;
|
||||
private String accessMode;
|
||||
private String functionMode;
|
||||
private String functionType;
|
||||
private Map<String, Object> dataType;
|
||||
}
|
||||
|
||||
/** 事件功能点 */
|
||||
@Data
|
||||
public static class ThingModelEvent {
|
||||
private String identifier;
|
||||
private String name;
|
||||
private String desc;
|
||||
private String functionMode;
|
||||
@JsonProperty("eventType")
|
||||
private String eventType;
|
||||
private String fuctionType;
|
||||
private List<Map<String, Object>> outputData;
|
||||
}
|
||||
|
||||
/** 服务功能点 */
|
||||
@Data
|
||||
public static class ThingModelService {
|
||||
private String identifier;
|
||||
private String name;
|
||||
private String desc;
|
||||
private String callType;
|
||||
private String functionMode;
|
||||
private String fuctionType;
|
||||
private List<Map<String, Object>> input;
|
||||
private List<Map<String, Object>> output;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.yixiong.iot.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||
import com.yixiong.iot.config.IotProperties;
|
||||
import com.yixiong.iot.model.DatapointData;
|
||||
import com.yixiong.iot.model.DeviceEventListData;
|
||||
import com.yixiong.iot.model.DeviceItem;
|
||||
import com.yixiong.iot.model.DeviceListData;
|
||||
import com.yixiong.iot.model.IotResponse;
|
||||
import com.yixiong.iot.util.IotHttpClient;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* 设备相关业务服务,封装对 IoT 平台设备接口的调用
|
||||
*/
|
||||
@Component
|
||||
public class DeviceService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DeviceService.class);
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
|
||||
|
||||
@Inject
|
||||
private IotHttpClient iotHttpClient;
|
||||
|
||||
@Inject
|
||||
private IotProperties props;
|
||||
|
||||
/**
|
||||
* 查询设备列表,product_id 使用配置中的默认值
|
||||
*
|
||||
* @param deviceName 设备名称左前缀模糊匹配,可为 null
|
||||
* @param offset 查询起始下标,可为 null(默认 0)
|
||||
* @param limit 单次查询数量,可为 null(默认 10,最大 100)
|
||||
* @return IoT 平台响应,data 字段为设备列表
|
||||
*/
|
||||
public IotResponse<DeviceListData> listDevices(String deviceName, Integer offset, Integer limit) {
|
||||
String productId = props.getHttp().getDefaultProductId();
|
||||
StringBuilder path = new StringBuilder("/device/list?product_id=").append(productId);
|
||||
if (deviceName != null && !deviceName.isEmpty()) {
|
||||
path.append("&device_name=").append(deviceName);
|
||||
}
|
||||
if (offset != null) {
|
||||
path.append("&offset=").append(offset);
|
||||
}
|
||||
if (limit != null) {
|
||||
path.append("&limit=").append(limit);
|
||||
}
|
||||
try {
|
||||
String responseBody = iotHttpClient.get(path.toString());
|
||||
return MAPPER.readValue(responseBody, new TypeReference<IotResponse<DeviceListData>>() {});
|
||||
} catch (Exception e) {
|
||||
log.error("[设备列表] 查询失败,productId={}", productId, e);
|
||||
throw new RuntimeException("查询设备列表失败,productId=" + productId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询设备事件记录
|
||||
* product_id 使用配置中的默认值,device_name 与 imei 二选一必填
|
||||
*
|
||||
* @param deviceName 设备名称(与 imei 二选一)
|
||||
* @param imei 设备 IMEI(与 deviceName 二选一)
|
||||
* @param startTime 查询起始时间(毫秒时间戳,必填)
|
||||
* @param endTime 查询截止时间(毫秒时间戳,必填)
|
||||
* @param identifier 事件功能点标识(可选)
|
||||
* @param eventType 事件类型:1-信息,2-告警,3-故障(可选)
|
||||
* @param offset 查询起始位置(可选,默认 0)
|
||||
* @param limit 每次请求记录数(可选,默认 10,最大 100)
|
||||
* @return IoT 平台响应,data 字段为事件记录列表
|
||||
*/
|
||||
public IotResponse<DeviceEventListData> getEventLog(String deviceName, String imei,
|
||||
Long startTime, Long endTime,
|
||||
String identifier, Integer eventType,
|
||||
Integer offset, Integer limit) {
|
||||
String productId = props.getHttp().getDefaultProductId();
|
||||
StringBuilder path = new StringBuilder("/device/event-log?product_id=").append(productId);
|
||||
if (deviceName != null && !deviceName.isEmpty()) {
|
||||
path.append("&device_name=").append(deviceName);
|
||||
}
|
||||
if (imei != null && !imei.isEmpty()) {
|
||||
path.append("&imei=").append(imei);
|
||||
}
|
||||
path.append("&start_time=").append(startTime);
|
||||
path.append("&end_time=").append(endTime);
|
||||
if (identifier != null && !identifier.isEmpty()) {
|
||||
path.append("&identifier=").append(identifier);
|
||||
}
|
||||
if (eventType != null) {
|
||||
path.append("&event_type=").append(eventType);
|
||||
}
|
||||
if (offset != null) {
|
||||
path.append("&offset=").append(offset);
|
||||
}
|
||||
if (limit != null) {
|
||||
path.append("&limit=").append(limit);
|
||||
}
|
||||
try {
|
||||
String responseBody = iotHttpClient.get(path.toString());
|
||||
return MAPPER.readValue(responseBody, new TypeReference<IotResponse<DeviceEventListData>>() {});
|
||||
} catch (Exception e) {
|
||||
log.error("[设备事件] 查询失败,productId={}, deviceName={}", productId, deviceName, e);
|
||||
throw new RuntimeException("查询设备事件记录失败,productId=" + productId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询设备详情(新版 API,product_id + device_name)
|
||||
*
|
||||
* @param deviceName 设备名称(与 imei 二选一)
|
||||
* @param imei 设备 IMEI(与 deviceName 二选一)
|
||||
* @return IoT 平台响应,data 字段为设备详情
|
||||
*/
|
||||
public IotResponse<DeviceItem> getDeviceDetail(String deviceName, String imei) {
|
||||
String productId = props.getHttp().getDefaultProductId();
|
||||
StringBuilder path = new StringBuilder("/device/detail?product_id=").append(productId);
|
||||
if (deviceName != null && !deviceName.isEmpty()) {
|
||||
path.append("&device_name=").append(deviceName);
|
||||
}
|
||||
if (imei != null && !imei.isEmpty()) {
|
||||
path.append("&imei=").append(imei);
|
||||
}
|
||||
try {
|
||||
String responseBody = iotHttpClient.get(path.toString());
|
||||
return MAPPER.readValue(responseBody, new TypeReference<IotResponse<DeviceItem>>() {});
|
||||
} catch (Exception e) {
|
||||
log.error("[设备详情] 查询失败,productId={}, deviceName={}", productId, deviceName, e);
|
||||
throw new RuntimeException("查询设备详情失败,deviceName=" + deviceName, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询设备历史数据点
|
||||
* product_id 使用配置中的默认值
|
||||
*
|
||||
* @param deviceName 设备名称(必填)
|
||||
* @param datastreamId 数据流 ID,多个用逗号分隔(可选)
|
||||
* @param start 开始时间,格式 2015-01-10T08:00:35(可选)
|
||||
* @param end 结束时间,格式 2015-01-10T08:00:35(可选)
|
||||
* @param limit 最多返回数据点数(可选,默认 100)
|
||||
* @param cursor 分页游标(可选)
|
||||
* @param sort 排序方式:DESC/ASC(可选)
|
||||
* @return IoT 平台响应,data 字段为数据点列表
|
||||
*/
|
||||
public IotResponse<DatapointData> getHistoryDatapoints(String deviceName, String datastreamId,
|
||||
String start, String end,
|
||||
Integer limit, String cursor, String sort) {
|
||||
String productId = props.getHttp().getDefaultProductId();
|
||||
StringBuilder path = new StringBuilder("/datapoint/history-datapoints")
|
||||
.append("?product_id=").append(productId)
|
||||
.append("&device_name=").append(deviceName);
|
||||
if (datastreamId != null && !datastreamId.isEmpty()) {
|
||||
path.append("&datastream_id=").append(datastreamId);
|
||||
}
|
||||
if (start != null && !start.isEmpty()) {
|
||||
path.append("&start=").append(start);
|
||||
}
|
||||
if (end != null && !end.isEmpty()) {
|
||||
path.append("&end=").append(end);
|
||||
}
|
||||
if (limit != null) {
|
||||
path.append("&limit=").append(limit);
|
||||
}
|
||||
if (cursor != null && !cursor.isEmpty()) {
|
||||
path.append("&cursor=").append(cursor);
|
||||
}
|
||||
if (sort != null && !sort.isEmpty()) {
|
||||
path.append("&sort=").append(sort);
|
||||
}
|
||||
try {
|
||||
String responseBody = iotHttpClient.get(path.toString());
|
||||
return MAPPER.readValue(responseBody, new TypeReference<IotResponse<DatapointData>>() {});
|
||||
} catch (Exception e) {
|
||||
log.error("[数据点] 查询失败,productId={}, deviceName={}", productId, deviceName, e);
|
||||
throw new RuntimeException("查询设备数据点失败,deviceName=" + deviceName, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.yixiong.iot.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||
import com.yixiong.iot.model.IotResponse;
|
||||
import com.yixiong.iot.model.ProductDetailData;
|
||||
import com.yixiong.iot.util.IotHttpClient;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* 产品相关业务服务,封装对 IoT 平台产品接口的调用
|
||||
*/
|
||||
@Component
|
||||
public class ProductService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ProductService.class);
|
||||
|
||||
/** Jackson 实例:忽略未知字段,属性名使用下划线映射 */
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
|
||||
|
||||
@Inject
|
||||
private IotHttpClient iotHttpClient;
|
||||
|
||||
/**
|
||||
* 查询产品详情
|
||||
*
|
||||
* @param productId 产品 ID
|
||||
* @return IoT 平台响应,data 字段为产品详情
|
||||
*/
|
||||
public IotResponse<ProductDetailData> getProductDetail(String productId) {
|
||||
try {
|
||||
String responseBody = iotHttpClient.get("/product/detail?product_id=" + productId);
|
||||
return MAPPER.readValue(responseBody, new TypeReference<IotResponse<ProductDetailData>>() {});
|
||||
} catch (Exception e) {
|
||||
log.error("[产品详情] 查询失败,productId={}", productId, e);
|
||||
throw new RuntimeException("查询产品详情失败,productId=" + productId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.yixiong.iot.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||
import com.yixiong.iot.config.IotProperties;
|
||||
import com.yixiong.iot.model.DevicePropertyItem;
|
||||
import com.yixiong.iot.model.IotResponse;
|
||||
import com.yixiong.iot.model.PropertyHistoryData;
|
||||
import com.yixiong.iot.model.PropertyHistoryItem;
|
||||
import com.yixiong.iot.model.ThingModelData;
|
||||
import com.yixiong.iot.util.IotHttpClient;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 物模型相关业务服务,封装对 IoT 平台物模型接口的调用
|
||||
*/
|
||||
@Component
|
||||
public class ThingModelService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ThingModelService.class);
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
|
||||
|
||||
@Inject
|
||||
private IotHttpClient iotHttpClient;
|
||||
|
||||
@Inject
|
||||
private IotProperties props;
|
||||
|
||||
/**
|
||||
* 查询产品物模型(属性/事件/服务功能点)
|
||||
* product_id 使用配置中的默认值
|
||||
*
|
||||
* @return IoT 平台响应,data 字段为物模型数据
|
||||
*/
|
||||
public IotResponse<ThingModelData> queryThingModel() {
|
||||
String productId = props.getHttp().getDefaultProductId();
|
||||
try {
|
||||
String responseBody = iotHttpClient.get("/thingmodel/query-thing-model?product_id=" + productId);
|
||||
return MAPPER.readValue(responseBody, new TypeReference<IotResponse<ThingModelData>>() {});
|
||||
} catch (Exception e) {
|
||||
log.error("[物模型] 查询失败,productId={}", productId, e);
|
||||
throw new RuntimeException("查询物模型失败,productId=" + productId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询设备属性最新数据
|
||||
* product_id 使用配置中的默认值
|
||||
*
|
||||
* @param deviceName 设备名称(必填)
|
||||
* @return IoT 平台响应,data 字段为属性列表
|
||||
*/
|
||||
public IotResponse<List<DevicePropertyItem>> queryDeviceProperty(String deviceName) {
|
||||
String productId = props.getHttp().getDefaultProductId();
|
||||
String path = "/thingmodel/query-device-property?product_id=" + productId + "&device_name=" + deviceName;
|
||||
try {
|
||||
String responseBody = iotHttpClient.get(path);
|
||||
return MAPPER.readValue(responseBody, new TypeReference<IotResponse<List<DevicePropertyItem>>>() {});
|
||||
} catch (Exception e) {
|
||||
log.error("[设备属性] 查询失败,productId={}, deviceName={}", productId, deviceName, e);
|
||||
throw new RuntimeException("查询设备属性失败,deviceName=" + deviceName, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备属性详情(POST,下发命令到设备)
|
||||
* product_id 使用配置中的默认值
|
||||
*
|
||||
* @param deviceName 设备名称(必填)
|
||||
* @param params 功能点标识数组(必填)
|
||||
* @return IoT 平台响应,data 字段为各属性当前值的 Map
|
||||
*/
|
||||
public IotResponse<Map<String, Object>> queryDevicePropertyDetail(String deviceName, List<String> params) {
|
||||
String productId = props.getHttp().getDefaultProductId();
|
||||
try {
|
||||
String body = MAPPER.writeValueAsString(Map.of(
|
||||
"product_id", productId,
|
||||
"device_name", deviceName,
|
||||
"params", params
|
||||
));
|
||||
String responseBody = iotHttpClient.post("/thingmodel/query-device-property-detail", body);
|
||||
return MAPPER.readValue(responseBody, new TypeReference<IotResponse<Map<String, Object>>>() {});
|
||||
} catch (Exception e) {
|
||||
log.error("[设备属性详情] 查询失败,productId={}, deviceName={}", productId, deviceName, e);
|
||||
throw new RuntimeException("查询设备属性详情失败,deviceName=" + deviceName, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询设备属性历史记录
|
||||
* product_id 使用配置中的默认值
|
||||
*
|
||||
* @param deviceName 设备名称(必填)
|
||||
* @param identifier 属性功能点标识(必填)
|
||||
* @param startTime 查询起始时间(毫秒时间戳,必填)
|
||||
* @param endTime 查询结束时间(毫秒时间戳,必填)
|
||||
* @param sort 排序:1-正序,2-倒序(可选)
|
||||
* @param offset 起始位置(可选)
|
||||
* @param limit 每次记录数(可选,默认 10,最大 100)
|
||||
* @return IoT 平台响应,data 字段为历史记录列表
|
||||
*/
|
||||
public IotResponse<Object> queryPropertyHistory(String deviceName, String identifier,
|
||||
Long startTime, Long endTime,
|
||||
Integer sort, Integer offset, Integer limit) {
|
||||
String productId = props.getHttp().getDefaultProductId();
|
||||
StringBuilder path = new StringBuilder("/thingmodel/query-device-property-history")
|
||||
.append("?product_id=").append(productId)
|
||||
.append("&device_name=").append(deviceName)
|
||||
.append("&identifier=").append(identifier)
|
||||
.append("&start_time=").append(startTime)
|
||||
.append("&end_time=").append(endTime);
|
||||
if (sort != null) path.append("&sort=").append(sort);
|
||||
if (offset != null) path.append("&offset=").append(offset);
|
||||
if (limit != null) path.append("&limit=").append(limit);
|
||||
try {
|
||||
String responseBody = iotHttpClient.get(path.toString());
|
||||
return MAPPER.readValue(responseBody, new TypeReference<IotResponse<Object>>() {});
|
||||
} catch (Exception e) {
|
||||
log.error("[属性历史] 查询失败,productId={}, deviceName={}, identifier={}", productId, deviceName, identifier, e);
|
||||
throw new RuntimeException("查询属性历史记录失败,deviceName=" + deviceName + ", identifier=" + identifier, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.yixiong.iot.util;
|
||||
|
||||
import com.yixiong.iot.config.IotProperties;
|
||||
import okhttp3.*;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.annotation.Init;
|
||||
import org.noear.solon.annotation.Inject;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* IoT 平台 HTTP 客户端
|
||||
* 封装 OkHttp,每次请求自动生成并注入鉴权 Authorization header
|
||||
*/
|
||||
@Component
|
||||
public class IotHttpClient {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(IotHttpClient.class);
|
||||
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
||||
|
||||
@Inject
|
||||
private IotProperties props;
|
||||
|
||||
private OkHttpClient client;
|
||||
|
||||
@Init
|
||||
public void init() {
|
||||
this.client = new OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起 GET 请求(HMAC 鉴权)
|
||||
*
|
||||
* @param path 相对路径,如 "/device/list"
|
||||
* @return 响应体字符串
|
||||
*/
|
||||
public String get(String path) throws IOException {
|
||||
Request request = new Request.Builder()
|
||||
.url(buildUrl(path))
|
||||
.header("Authorization", buildToken())
|
||||
.get()
|
||||
.build();
|
||||
return execute(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起 POST 请求(JSON 请求体,HMAC 鉴权)
|
||||
*
|
||||
* @param path 相对路径
|
||||
* @param jsonBody JSON 字符串
|
||||
* @return 响应体字符串
|
||||
*/
|
||||
public String post(String path, String jsonBody) throws IOException {
|
||||
RequestBody body = RequestBody.create(jsonBody, JSON);
|
||||
Request request = new Request.Builder()
|
||||
.url(buildUrl(path))
|
||||
.header("Authorization", buildToken())
|
||||
.post(body)
|
||||
.build();
|
||||
return execute(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起 PUT 请求(JSON 请求体,HMAC 鉴权)
|
||||
*
|
||||
* @param path 相对路径
|
||||
* @param jsonBody JSON 字符串
|
||||
* @return 响应体字符串
|
||||
*/
|
||||
public String put(String path, String jsonBody) throws IOException {
|
||||
RequestBody body = RequestBody.create(jsonBody, JSON);
|
||||
Request request = new Request.Builder()
|
||||
.url(buildUrl(path))
|
||||
.header("Authorization", buildToken())
|
||||
.put(body)
|
||||
.build();
|
||||
return execute(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起 DELETE 请求(HMAC 鉴权)
|
||||
*
|
||||
* @param path 相对路径
|
||||
* @return 响应体字符串
|
||||
*/
|
||||
public String delete(String path) throws IOException {
|
||||
Request request = new Request.Builder()
|
||||
.url(buildUrl(path))
|
||||
.header("Authorization", buildToken())
|
||||
.delete()
|
||||
.build();
|
||||
return execute(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用旧版 api-key 鉴权发起 GET 请求(对应 api.heclouds.com 接口)
|
||||
*
|
||||
* @param path 相对路径,如 "/devices/123456"
|
||||
* @return 响应体字符串
|
||||
*/
|
||||
public String getWithApiKey(String path) throws IOException {
|
||||
Request request = new Request.Builder()
|
||||
.url(buildOldUrl(path))
|
||||
.header("api-key", props.getHttp().getApiKey())
|
||||
.get()
|
||||
.build();
|
||||
return execute(request);
|
||||
}
|
||||
|
||||
/** 拼接新版 API 完整 URL */
|
||||
private String buildUrl(String path) {
|
||||
String base = props.getHttp().getBaseUrl();
|
||||
if (base.endsWith("/") && path.startsWith("/")) {
|
||||
return base + path.substring(1);
|
||||
}
|
||||
if (!base.endsWith("/") && !path.startsWith("/")) {
|
||||
return base + "/" + path;
|
||||
}
|
||||
return base + path;
|
||||
}
|
||||
|
||||
/** 拼接旧版 API 完整 URL */
|
||||
private String buildOldUrl(String path) {
|
||||
String base = props.getHttp().getOldBaseUrl();
|
||||
if (base.endsWith("/") && path.startsWith("/")) {
|
||||
return base + path.substring(1);
|
||||
}
|
||||
if (!base.endsWith("/") && !path.startsWith("/")) {
|
||||
return base + "/" + path;
|
||||
}
|
||||
return base + path;
|
||||
}
|
||||
|
||||
/** 每次调用实时生成 token,确保不过期 */
|
||||
private String buildToken() {
|
||||
IotProperties.Auth auth = props.getAuth();
|
||||
return IotTokenUtil.generateToken(
|
||||
auth.getVersion(),
|
||||
auth.getResourceName(),
|
||||
auth.getSignatureMethod(),
|
||||
auth.getAccessKey(),
|
||||
auth.getExpirationSeconds()
|
||||
);
|
||||
}
|
||||
|
||||
/** 执行请求,打印完整请求/响应日志,始终返回响应体由业务层处理错误码 */
|
||||
private String execute(Request request) throws IOException {
|
||||
String url = request.url().toString();
|
||||
String method = request.method();
|
||||
log.debug("[IoT请求] {} {}", method, url);
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
int code = response.code();
|
||||
ResponseBody responseBody = response.body();
|
||||
String body = responseBody != null ? responseBody.string() : "{}";
|
||||
if (response.isSuccessful()) {
|
||||
log.debug("[IoT响应] {} {} -> HTTP {} | body: {}", method, url, code, body);
|
||||
} else {
|
||||
log.warn("[IoT响应异常] {} {} -> HTTP {} | body: {}", method, url, code, body);
|
||||
}
|
||||
return body;
|
||||
} catch (IOException e) {
|
||||
log.error("[IoT请求失败] {} {} -> {}", method, url, e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.yixiong.iot.util;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* IoT 平台鉴权 Token 生成工具
|
||||
* 算法:HMAC(SHA1/MD5/SHA256)签名后 Base64 编码,再 URL 编码拼装为查询字符串
|
||||
*/
|
||||
public class IotTokenUtil {
|
||||
|
||||
private IotTokenUtil() {}
|
||||
|
||||
/**
|
||||
* 生成完整的鉴权 Token 字符串
|
||||
*
|
||||
* @param version 版本号,如 "2022-05-01"
|
||||
* @param resourceName 资源名称,如 "userid/459810"
|
||||
* @param signatureMethod 签名算法,如 "sha1"
|
||||
* @param accessKey Base64 编码的密钥
|
||||
* @param expirationSeconds token 有效期(秒),从当前时间起算
|
||||
* @return 拼装好的 token 查询字符串
|
||||
*/
|
||||
public static String generateToken(String version, String resourceName,
|
||||
String signatureMethod, String accessKey,
|
||||
long expirationSeconds) {
|
||||
try {
|
||||
String expirationTime = String.valueOf(System.currentTimeMillis() / 1000 + expirationSeconds);
|
||||
String signature = generateSignature(version, resourceName, expirationTime, accessKey, signatureMethod);
|
||||
String encodedRes = URLEncoder.encode(resourceName, StandardCharsets.UTF_8);
|
||||
String encodedSig = URLEncoder.encode(signature, StandardCharsets.UTF_8);
|
||||
|
||||
return "version=" + version
|
||||
+ "&res=" + encodedRes
|
||||
+ "&et=" + expirationTime
|
||||
+ "&method=" + signatureMethod
|
||||
+ "&sign=" + encodedSig;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("生成 IoT 鉴权 Token 失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 HMAC 签名(Base64 编码结果)
|
||||
*
|
||||
* @param version 版本号
|
||||
* @param resourceName 资源名称
|
||||
* @param expirationTime 过期时间戳(秒)
|
||||
* @param accessKey Base64 编码的密钥
|
||||
* @param signatureMethod 签名算法,如 "sha1"
|
||||
* @return Base64 编码的签名字符串
|
||||
*/
|
||||
private static String generateSignature(String version, String resourceName,
|
||||
String expirationTime, String accessKey,
|
||||
String signatureMethod) throws Exception {
|
||||
// 待签名文本:过期时间 + 算法 + 资源名 + 版本,换行分隔
|
||||
String plainText = expirationTime + "\n" + signatureMethod + "\n" + resourceName + "\n" + version;
|
||||
byte[] keyBytes = Base64.getDecoder().decode(accessKey);
|
||||
String algorithm = "Hmac" + signatureMethod.toUpperCase();
|
||||
|
||||
SecretKeySpec signingKey = new SecretKeySpec(keyBytes, algorithm);
|
||||
Mac mac = Mac.getInstance(algorithm);
|
||||
mac.init(signingKey);
|
||||
byte[] rawHmac = mac.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(rawHmac);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
server.port: 8081
|
||||
|
||||
solon.app:
|
||||
name: 'demo-app'
|
||||
group: 'demo'
|
||||
|
||||
solon.logging:
|
||||
appender:
|
||||
console:
|
||||
level: WARN
|
||||
file:
|
||||
level: WARN
|
||||
logger:
|
||||
# 只保留业务异常日志,屏蔽框架启动等 INFO 输出
|
||||
com.yixiong.iot:
|
||||
level: WARN
|
||||
|
||||
iot:
|
||||
auth:
|
||||
# 鉴权版本号
|
||||
version: "2022-05-01"
|
||||
# 资源名称,格式如 userid/{userId}
|
||||
resource-name: "userid/459810"
|
||||
# HMAC 签名密钥(Base64 编码)
|
||||
access-key: "3BeJti9supdvX4K+n/98Osx7Zmadd4ze9qR+EfXAfoQWiMXvqksrU0VDUZa7tDUQ"
|
||||
# 签名算法:sha1 / md5 / sha256
|
||||
signature-method: "sha1"
|
||||
# token 有效期(秒)
|
||||
expiration-seconds: 3600
|
||||
http:
|
||||
# IoT 平台接口基础地址(新版 HMAC 鉴权)
|
||||
base-url: "https://iot-api.heclouds.com"
|
||||
# 默认产品 ID
|
||||
default-product-id: "VGi8wC99jO"
|
||||
# 旧版 API 基础地址(设备详情等,api-key 鉴权)
|
||||
old-base-url: "https://api.heclouds.com"
|
||||
# 旧版 API Master Key
|
||||
api-key: "your-master-api-key-here"
|
||||
|
||||
# 登录账号配置
|
||||
auth:
|
||||
username: admin
|
||||
password: Aa135790!123
|
||||
|
||||
# Sa-Token 配置
|
||||
sa-token:
|
||||
token-name: iot-token
|
||||
timeout: 86400
|
||||
activity-timeout: -1
|
||||
allow-concurrent-login: true
|
||||
is-share: true
|
||||
token-style: uuid
|
||||
is-log: false
|
||||
@@ -0,0 +1 @@
|
||||
div{font-size: 1.5em;}
|
||||
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="/base.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div>Hello ${name!}!</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,537 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>IoT 设备管理</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Microsoft YaHei", sans-serif; background: #f0f2f5; min-height: 100vh; }
|
||||
.header { background: #1677ff; color: #fff; padding: 0 24px; height: 52px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||
.header-title { font-size: 18px; font-weight: 600; }
|
||||
.header-logout { color: rgba(255,255,255,0.85); font-size: 14px; text-decoration: none; }
|
||||
.header-logout:hover { color: #fff; }
|
||||
.main { padding: 24px; }
|
||||
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 2px solid #e8e8e8; }
|
||||
.tab-btn { padding: 10px 20px; border: none; background: none; font-size: 14px; color: #666; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s; }
|
||||
.tab-btn:hover { color: #1677ff; }
|
||||
.tab-btn.active { color: #1677ff; border-bottom-color: #1677ff; font-weight: 500; }
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
.card { background: #fff; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); padding: 20px 24px; margin-bottom: 16px; }
|
||||
.card-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 16px; }
|
||||
.form-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
|
||||
.form-item { display: flex; flex-direction: column; gap: 4px; }
|
||||
.form-item label { font-size: 12px; color: #888; }
|
||||
.form-item input, .form-item select { padding: 7px 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 13px; outline: none; min-width: 160px; }
|
||||
.form-item input:focus, .form-item select:focus { border-color: #1677ff; }
|
||||
.btn { padding: 8px 18px; border: none; border-radius: 5px; font-size: 13px; cursor: pointer; transition: background 0.2s; }
|
||||
.btn-primary { background: #1677ff; color: #fff; }
|
||||
.btn-primary:hover { background: #4096ff; }
|
||||
.btn-sm { padding: 3px 10px; font-size: 12px; border-radius: 4px; border: none; cursor: pointer; margin-right: 4px; }
|
||||
.btn-detail { background: #e6f4ff; color: #1677ff; }
|
||||
.btn-detail:hover { background: #bae0ff; }
|
||||
.btn-data { background: #f6ffed; color: #389e0d; }
|
||||
.btn-data:hover { background: #d9f7be; }
|
||||
.btn-event { background: #fff7e6; color: #d46b08; }
|
||||
.btn-event:hover { background: #ffe7ba; }
|
||||
.btn-prop { background: #f9f0ff; color: #722ed1; }
|
||||
.btn-prop:hover { background: #efdbff; }
|
||||
.table-wrap { overflow-x: auto; margin-top: 16px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { background: #fafafa; color: #555; font-weight: 500; padding: 10px 12px; text-align: left; border-bottom: 1px solid #e8e8e8; white-space: nowrap; }
|
||||
td { padding: 10px 12px; border-bottom: 1px solid #f0f0f0; color: #333; }
|
||||
tr:hover td { background: #f5f8ff; }
|
||||
.tag { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; }
|
||||
.tag-online { background: #e6f7e6; color: #389e0d; }
|
||||
.tag-offline { background: #f5f5f5; color: #999; }
|
||||
.tag-inactive { background: #fff7e6; color: #d46b08; }
|
||||
.tag-info { background: #e6f4ff; color: #1677ff; }
|
||||
.tag-warn { background: #fff7e6; color: #d46b08; }
|
||||
.tag-error { background: #fff0f0; color: #d32f2f; }
|
||||
.pagination { display: flex; align-items: center; gap: 8px; margin-top: 14px; font-size: 13px; color: #666; }
|
||||
.page-btn { padding: 5px 12px; border: 1px solid #ddd; border-radius: 4px; background: #fff; cursor: pointer; font-size: 13px; }
|
||||
.page-btn:hover:not(:disabled) { border-color: #1677ff; color: #1677ff; }
|
||||
.page-btn:disabled { color: #ccc; cursor: not-allowed; }
|
||||
.tip { color: #999; font-size: 13px; padding: 20px 0; text-align: center; }
|
||||
.err { color: #d32f2f; font-size: 13px; padding: 12px 0; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; margin-top: 8px; }
|
||||
.detail-item { display: flex; gap: 8px; font-size: 13px; }
|
||||
.detail-label { color: #888; white-space: nowrap; min-width: 110px; }
|
||||
.detail-value { color: #333; word-break: break-all; }
|
||||
/* 弹窗 */
|
||||
.modal-mask { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 1000; align-items: center; justify-content: center; }
|
||||
.modal-mask.show { display: flex; }
|
||||
.modal { background: #fff; border-radius: 10px; box-shadow: 0 8px 32px rgba(0,0,0,0.18); width: 860px; max-width: 96vw; max-height: 88vh; display: flex; flex-direction: column; }
|
||||
.modal-header { padding: 16px 24px; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; justify-content: space-between; }
|
||||
.modal-title { font-size: 15px; font-weight: 600; color: #333; }
|
||||
.modal-close { font-size: 20px; color: #999; cursor: pointer; line-height: 1; background: none; border: none; }
|
||||
.modal-close:hover { color: #333; }
|
||||
.modal-body { padding: 20px 24px; overflow-y: auto; flex: 1; }
|
||||
.modal-footer { padding: 12px 24px; border-top: 1px solid #f0f0f0; display: flex; justify-content: flex-end; gap: 8px; }
|
||||
/* 数据点弹窗内查询表单 */
|
||||
.modal-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; margin-bottom: 16px; }
|
||||
.modal-form .form-item input, .modal-form .form-item select { min-width: 130px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-title">IoT 设备管理</div>
|
||||
<a class="header-logout" href="/auth/logout">退出登录</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" onclick="switchTab('product')">产品详情</button>
|
||||
<button class="tab-btn" onclick="switchTab('device')">设备列表</button>
|
||||
</div>
|
||||
|
||||
<!-- 产品详情 -->
|
||||
<div id="panel-product" class="tab-panel active">
|
||||
<div class="card">
|
||||
<div class="card-title">产品详情</div>
|
||||
<div class="form-row">
|
||||
<div class="form-item">
|
||||
<label>产品 ID</label>
|
||||
<input type="text" id="product-id-input" placeholder="请输入产品 ID" value="${defaultProductId!}">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="queryProduct()">查 询</button>
|
||||
<button class="btn" style="background:#f9f0ff;color:#722ed1" onclick="showThingModel()">物模型</button>
|
||||
</div>
|
||||
<div id="product-result"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备列表 -->
|
||||
<div id="panel-device" class="tab-panel">
|
||||
<div class="card">
|
||||
<div class="card-title">设备列表</div>
|
||||
<div class="form-row">
|
||||
<div class="form-item">
|
||||
<label>设备名称(模糊匹配)</label>
|
||||
<input type="text" id="device-name-input" placeholder="可选">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>每页数量</label>
|
||||
<select id="device-limit-select">
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="queryDeviceList(0)">查 询</button>
|
||||
</div>
|
||||
<div id="device-result"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备详情弹窗 -->
|
||||
<div class="modal-mask" id="modal-detail">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">设备详情</span>
|
||||
<button class="modal-close" onclick="closeModal('modal-detail')">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-detail-body"><div class="tip">加载中...</div></div>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeModal('modal-detail')">关 闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据点弹窗 -->
|
||||
<div class="modal-mask" id="modal-data">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="modal-data-title">设备数据点</span>
|
||||
<button class="modal-close" onclick="closeModal('modal-data')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-form">
|
||||
<div class="form-item"><label>开始时间</label><input type="datetime-local" id="dp-start"></div>
|
||||
<div class="form-item"><label>结束时间</label><input type="datetime-local" id="dp-end"></div>
|
||||
<div class="form-item"><label>数据流 ID</label><input type="text" id="dp-stream" placeholder="多个用逗号分隔"></div>
|
||||
<div class="form-item"><label>排序</label>
|
||||
<select id="dp-sort"><option value="DESC">倒序</option><option value="ASC">升序</option></select>
|
||||
</div>
|
||||
<div class="form-item"><label>数量</label>
|
||||
<select id="dp-limit"><option value="20">20</option><option value="50">50</option><option value="100">100</option></select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="queryDatapoints()">查 询</button>
|
||||
</div>
|
||||
<div id="modal-data-body"><div class="tip">请设置查询条件后点击查询</div></div>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeModal('modal-data')">关 闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 事件记录弹窗 -->
|
||||
<div class="modal-mask" id="modal-event">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="modal-event-title">设备事件记录</span>
|
||||
<button class="modal-close" onclick="closeModal('modal-event')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-form">
|
||||
<div class="form-item"><label>开始时间</label><input type="datetime-local" id="me-start"></div>
|
||||
<div class="form-item"><label>结束时间</label><input type="datetime-local" id="me-end"></div>
|
||||
<div class="form-item"><label>事件类型</label>
|
||||
<select id="me-type"><option value="">全部</option><option value="1">信息</option><option value="2">告警</option><option value="3">故障</option></select>
|
||||
</div>
|
||||
<div class="form-item"><label>数量</label>
|
||||
<select id="me-limit"><option value="10">10</option><option value="20">20</option><option value="50">50</option></select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="queryModalEvent(0)">查 询</button>
|
||||
</div>
|
||||
<div id="modal-event-body"><div class="tip">请设置查询条件后点击查询</div></div>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeModal('modal-event')">关 闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备属性弹窗(一级) -->
|
||||
<div class="modal-mask" id="modal-prop">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="modal-prop-title">设备属性</span>
|
||||
<button class="modal-close" onclick="closeModal('modal-prop')">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-prop-body"><div class="tip">加载中...</div></div>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeModal('modal-prop')">关 闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 属性历史记录弹窗(二级) -->
|
||||
<div class="modal-mask" id="modal-prop-history" style="z-index:1100">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="modal-prop-history-title">属性历史记录</span>
|
||||
<button class="modal-close" onclick="closeModal('modal-prop-history')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-form">
|
||||
<div class="form-item"><label>开始时间</label><input type="datetime-local" id="ph-start"></div>
|
||||
<div class="form-item"><label>结束时间</label><input type="datetime-local" id="ph-end"></div>
|
||||
<div class="form-item"><label>排序</label>
|
||||
<select id="ph-sort"><option value="2">倒序</option><option value="1">正序</option></select>
|
||||
</div>
|
||||
<div class="form-item"><label>数量</label>
|
||||
<select id="ph-limit"><option value="10">10</option><option value="20">20</option><option value="50">50</option><option value="100">100</option></select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="queryPropHistory(0)">查 询</button>
|
||||
</div>
|
||||
<div id="modal-prop-history-body"><div class="tip">请设置查询条件后点击查询</div></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" style="background:#f5f5f5;color:#666" onclick="closeModal('modal-prop-history')">返 回</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 物模型弹窗 -->
|
||||
<div class="modal-mask" id="modal-thingmodel">
|
||||
<div class="modal" style="width:960px">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">物模型功能点</span>
|
||||
<button class="modal-close" onclick="closeModal('modal-thingmodel')">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-thingmodel-body"><div class="tip">加载中...</div></div>
|
||||
<div class="modal-footer"><button class="btn btn-primary" onclick="closeModal('modal-thingmodel')">关 闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===== 工具函数 =====
|
||||
function fmtDate(val) {
|
||||
if (!val) return '-';
|
||||
// 毫秒时间戳
|
||||
if (typeof val === 'number') return new Date(val).toLocaleString('zh-CN', {hour12: false});
|
||||
// 字符串时间
|
||||
const d = new Date(val);
|
||||
if (isNaN(d.getTime())) return val;
|
||||
return d.toLocaleString('zh-CN', {hour12: false});
|
||||
}
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab-btn').forEach((b, i) => b.classList.toggle('active', ['product','device'][i] === name));
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById('panel-' + name).classList.add('active');
|
||||
}
|
||||
function openModal(id) { document.getElementById(id).classList.add('show'); }
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
|
||||
// 点击遮罩关闭
|
||||
document.querySelectorAll('.modal-mask').forEach(m => m.addEventListener('click', e => { if (e.target === m) m.classList.remove('show'); }));
|
||||
|
||||
// ===== 产品详情 =====
|
||||
function queryProduct() {
|
||||
const pid = document.getElementById('product-id-input').value.trim();
|
||||
const el = document.getElementById('product-result');
|
||||
if (!pid) { el.innerHTML = '<div class="err">请输入产品 ID</div>'; return; }
|
||||
el.innerHTML = '<div class="tip">查询中...</div>';
|
||||
fetch('/product/detail?product_id=' + encodeURIComponent(pid))
|
||||
.then(r => r.json()).then(res => {
|
||||
if (res.code !== 0) { el.innerHTML = '<div class="err">错误:' + res.msg + '</div>'; return; }
|
||||
const d = res.data;
|
||||
const statusMap = {1:'开发中',2:'审核中',3:'审核未通过',4:'已发布',5:'已下架'};
|
||||
const nodeMap = {1:'直连设备',2:'网关设备',3:'子设备'};
|
||||
el.innerHTML = '<div class="detail-grid">' + [
|
||||
['产品 ID', d.productId], ['产品名称', d.name],
|
||||
['节点类型', nodeMap[d.nodeType]||d.nodeType], ['接入协议', d.accessProtocol],
|
||||
['数据协议', d.dataProtocol], ['联网方式', d.network],
|
||||
['开发状态', statusMap[d.status]||d.status], ['自有设备数', d.ownDeviceCount],
|
||||
['在线设备数', d.onlineDeviceCount], ['离线设备数', d.offlineDeviceCount],
|
||||
['未激活设备数', d.notActiveDeviceCount], ['厂商', d.manufacturer||'-'],
|
||||
['型号', d.model||'-'], ['品牌', d.brand||'-'], ['描述', d.desc||'-'],
|
||||
['创建时间', fmtDate(d.createTime)], ['更新时间', fmtDate(d.updateTime)],
|
||||
].map(([k,v]) => '<div class="detail-item"><span class="detail-label">'+k+'</span><span class="detail-value">'+(v??'-')+'</span></div>').join('') + '</div>';
|
||||
}).catch(e => { el.innerHTML = '<div class="err">请求失败:' + e.message + '</div>'; });
|
||||
}
|
||||
|
||||
// ===== 设备列表 =====
|
||||
let _curDeviceName = '', _curDeviceLimit = 10;
|
||||
function queryDeviceList(offset) {
|
||||
_curDeviceName = document.getElementById('device-name-input').value.trim();
|
||||
_curDeviceLimit = parseInt(document.getElementById('device-limit-select').value);
|
||||
const el = document.getElementById('device-result');
|
||||
el.innerHTML = '<div class="tip">查询中...</div>';
|
||||
let url = '/device/list?offset=' + offset + '&limit=' + _curDeviceLimit;
|
||||
if (_curDeviceName) url += '&device_name=' + encodeURIComponent(_curDeviceName);
|
||||
fetch(url).then(r => r.json()).then(res => {
|
||||
if (res.code !== 0) { el.innerHTML = '<div class="err">错误:' + res.msg + '</div>'; return; }
|
||||
const list = res.data.list || [];
|
||||
if (!list.length) { el.innerHTML = '<div class="tip">暂无数据</div>'; return; }
|
||||
const statusTag = s => s===1?'<span class="tag tag-online">在线</span>':s===0?'<span class="tag tag-offline">离线</span>':'<span class="tag tag-inactive">未激活</span>';
|
||||
el.innerHTML = '<div class="table-wrap"><table><thead><tr><th>设备 ID</th><th>设备名称</th><th>状态</th><th>创建时间</th><th>激活时间</th><th>最后在线</th><th>操作</th></tr></thead><tbody>'
|
||||
+ list.map(d => '<tr>'
|
||||
+ '<td>' + (d.did||'-') + '</td>'
|
||||
+ '<td>' + (d.name||'-') + '</td>'
|
||||
+ '<td>' + statusTag(d.status) + '</td>'
|
||||
+ '<td>' + fmtDate(d.create_time) + '</td>'
|
||||
+ '<td>' + fmtDate(d.activate_time) + '</td>'
|
||||
+ '<td>' + fmtDate(d.last_time) + '</td>'
|
||||
+ '<td><button class="btn-sm btn-detail" onclick="showDeviceDetail(\'' + d.name + '\')">详情</button>'
|
||||
+ '<button class="btn-sm btn-data" onclick="showDatapoints(\'' + (d.name||'') + '\',\'' + (d.did||'') + '\')">数据点</button>'
|
||||
+ '<button class="btn-sm btn-event" onclick="showEventFromList(\'' + (d.name||'') + '\',\'' + (d.did||'') + '\')">事件记录</button>'
|
||||
+ '<button class="btn-sm btn-prop" onclick="showDeviceProperty(\'' + (d.name||'') + '\')">属性</button></td>'
|
||||
+ '</tr>').join('')
|
||||
+ '</tbody></table></div>'
|
||||
+ '<div class="pagination">'
|
||||
+ '<button class="page-btn" onclick="queryDeviceList(' + Math.max(0, offset-_curDeviceLimit) + ')" ' + (offset===0?'disabled':'') + '>上一页</button>'
|
||||
+ '<span>第 ' + (Math.floor(offset/_curDeviceLimit)+1) + ' 页,本页 ' + list.length + ' 条</span>'
|
||||
+ '<button class="page-btn" onclick="queryDeviceList(' + (offset+_curDeviceLimit) + ')" ' + (list.length<_curDeviceLimit?'disabled':'') + '>下一页</button></div>';
|
||||
}).catch(e => { el.innerHTML = '<div class="err">请求失败:' + e.message + '</div>'; });
|
||||
}
|
||||
|
||||
// ===== 设备详情弹窗 =====
|
||||
function showDeviceDetail(deviceName) {
|
||||
document.getElementById('modal-detail-body').innerHTML = '<div class="tip">加载中...</div>';
|
||||
openModal('modal-detail');
|
||||
fetch('/device/detail?device_name=' + encodeURIComponent(deviceName))
|
||||
.then(r => r.json()).then(res => {
|
||||
if (res.code !== 0) { document.getElementById('modal-detail-body').innerHTML = '<div class="err">错误:' + res.msg + '</div>'; return; }
|
||||
const d = res.data || {};
|
||||
const statusTag = s => s===1?'<span class="tag tag-online">在线</span>':s===0?'<span class="tag tag-offline">离线</span>':'<span class="tag tag-inactive">未激活</span>';
|
||||
document.getElementById('modal-detail-body').innerHTML = '<div class="detail-grid">' + [
|
||||
['设备 ID', d.did], ['设备名称', d.name], ['描述', d.desc||'-'],
|
||||
['产品 ID', d.pid], ['状态', statusTag(d.status)],
|
||||
['接入协议', d.access_pt], ['数据协议', d.data_pt],
|
||||
['创建时间', fmtDate(d.create_time)], ['激活时间', fmtDate(d.activate_time)],
|
||||
['最后在线', fmtDate(d.last_time)], ['IMEI', d.imei||'-'],
|
||||
['IMSI', d.imsi||'-'], ['经度', d.lon||'-'], ['纬度', d.lat||'-'],
|
||||
['启用状态', d.enable_status?'启用':'禁用'], ['自动订阅', d.obsv?'是':'否'],
|
||||
].map(([k,v]) => '<div class="detail-item"><span class="detail-label">'+k+'</span><span class="detail-value">'+(v??'-')+'</span></div>').join('') + '</div>';
|
||||
}).catch(e => { document.getElementById('modal-detail-body').innerHTML = '<div class="err">请求失败:' + e.message + '</div>'; });
|
||||
}
|
||||
|
||||
// ===== 数据点弹窗 =====
|
||||
let _dpDeviceName = '';
|
||||
function showDatapoints(deviceName, deviceId) {
|
||||
_dpDeviceName = deviceName;
|
||||
document.getElementById('modal-data-title').textContent = '设备数据点 - ' + (deviceName||deviceId);
|
||||
document.getElementById('modal-data-body').innerHTML = '<div class="tip">请设置查询条件后点击查询</div>';
|
||||
openModal('modal-data');
|
||||
}
|
||||
function queryDatapoints(cursor) {
|
||||
const start = document.getElementById('dp-start').value;
|
||||
const end = document.getElementById('dp-end').value;
|
||||
const stream = document.getElementById('dp-stream').value.trim();
|
||||
const sort = document.getElementById('dp-sort').value;
|
||||
const limit = document.getElementById('dp-limit').value;
|
||||
const el = document.getElementById('modal-data-body');
|
||||
el.innerHTML = '<div class="tip">查询中...</div>';
|
||||
let url = '/device/datapoints?device_name=' + encodeURIComponent(_dpDeviceName) + '&sort=' + sort + '&limit=' + limit;
|
||||
if (start) url += '&start=' + encodeURIComponent(new Date(start).toISOString().slice(0,19));
|
||||
if (end) url += '&end=' + encodeURIComponent(new Date(end).toISOString().slice(0,19));
|
||||
if (stream) url += '&datastream_id=' + encodeURIComponent(stream);
|
||||
if (cursor) url += '&cursor=' + encodeURIComponent(cursor);
|
||||
fetch(url).then(r => r.json()).then(res => {
|
||||
if (res.code !== 0) { el.innerHTML = '<div class="err">错误:' + res.msg + '</div>'; return; }
|
||||
const streams = (res.data && res.data.datastreams) || [];
|
||||
if (!streams.length) { el.innerHTML = '<div class="tip">暂无数据</div>'; return; }
|
||||
let html = '';
|
||||
streams.forEach(s => {
|
||||
html += '<div style="margin-bottom:16px"><div style="font-size:13px;font-weight:600;color:#333;margin-bottom:8px">数据流:' + s.id + '</div>';
|
||||
html += '<div class="table-wrap"><table><thead><tr><th>时间</th><th>值</th></tr></thead><tbody>';
|
||||
(s.datapoints||[]).forEach(p => { html += '<tr><td>' + fmtDate(p.at) + '</td><td>' + (typeof p.value === 'object' ? JSON.stringify(p.value) : p.value) + '</td></tr>'; });
|
||||
html += '</tbody></table></div></div>';
|
||||
});
|
||||
if (res.data.cursor) html += '<div class="pagination"><button class="page-btn" onclick="queryDatapoints(\'' + res.data.cursor + '\')">加载更多</button><span>共 ' + res.data.count + ' 条</span></div>';
|
||||
el.innerHTML = html;
|
||||
}).catch(e => { el.innerHTML = '<div class="err">请求失败:' + e.message + '</div>'; });
|
||||
}
|
||||
|
||||
// ===== 事件记录弹窗(从设备列表触发) =====
|
||||
let _meDeviceName = '', _meImei = '';
|
||||
function showEventFromList(deviceName, deviceId) {
|
||||
_meDeviceName = deviceName;
|
||||
_meImei = '';
|
||||
document.getElementById('modal-event-title').textContent = '事件记录 - ' + (deviceName||deviceId);
|
||||
document.getElementById('modal-event-body').innerHTML = '<div class="tip">请设置查询条件后点击查询</div>';
|
||||
openModal('modal-event');
|
||||
}
|
||||
function queryModalEvent(offset) {
|
||||
const start = document.getElementById('me-start').value;
|
||||
const end = document.getElementById('me-end').value;
|
||||
const type = document.getElementById('me-type').value;
|
||||
const limit = document.getElementById('me-limit').value;
|
||||
const el = document.getElementById('modal-event-body');
|
||||
if (!start || !end) { el.innerHTML = '<div class="err">请选择开始和结束时间</div>'; return; }
|
||||
el.innerHTML = '<div class="tip">查询中...</div>';
|
||||
let url = '/device/event-log?start_time=' + new Date(start).getTime() + '&end_time=' + new Date(end).getTime() + '&offset=' + offset + '&limit=' + limit;
|
||||
if (_meDeviceName) url += '&device_name=' + encodeURIComponent(_meDeviceName);
|
||||
if (_meImei) url += '&imei=' + encodeURIComponent(_meImei);
|
||||
if (type) url += '&event_type=' + type;
|
||||
fetch(url).then(r => r.json()).then(res => {
|
||||
if (res.code !== 0) { el.innerHTML = '<div class="err">错误:' + res.msg + '</div>'; return; }
|
||||
const list = (res.data && res.data.list) || [];
|
||||
if (!list.length) { el.innerHTML = '<div class="tip">暂无数据</div>'; return; }
|
||||
const typeTag = t => t===1?'<span class="tag tag-info">信息</span>':t===2?'<span class="tag tag-warn">告警</span>':'<span class="tag tag-error">故障</span>';
|
||||
el.innerHTML = '<div class="table-wrap"><table><thead><tr><th>事件类型</th><th>标识</th><th>名称</th><th>上报时间</th><th>事件值</th></tr></thead><tbody>'
|
||||
+ list.map(d => '<tr><td>'+typeTag(d.event_type)+'</td><td>'+(d.identifier||'-')+'</td><td>'+(d.name||'-')+'</td><td>'+fmtDate(d.time)+'</td><td style="max-width:260px">'+(d.value||'-')+'</td></tr>').join('')
|
||||
+ '</tbody></table></div>'
|
||||
+ '<div class="pagination">'
|
||||
+ '<button class="page-btn" onclick="queryModalEvent('+Math.max(0,offset-parseInt(limit))+')" '+(offset===0?'disabled':'')+'>上一页</button>'
|
||||
+ '<span>第 '+(Math.floor(offset/parseInt(limit))+1)+' 页,本页 '+list.length+' 条</span>'
|
||||
+ '<button class="page-btn" onclick="queryModalEvent('+(offset+parseInt(limit))+')" '+(list.length<parseInt(limit)?'disabled':'')+'>下一页</button></div>';
|
||||
}).catch(e => { el.innerHTML = '<div class="err">请求失败:' + e.message + '</div>'; });
|
||||
}
|
||||
|
||||
// 初始化弹窗时间默认为最近 3 天
|
||||
(function() {
|
||||
const now = new Date(), pad = n => String(n).padStart(2,'0');
|
||||
const fmt = d => d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate())+'T'+pad(d.getHours())+':'+pad(d.getMinutes());
|
||||
const threeDaysAgo = new Date(now.getTime() - 3 * 86400000);
|
||||
// 事件记录弹窗
|
||||
document.getElementById('me-end').value = fmt(now);
|
||||
document.getElementById('me-start').value = fmt(threeDaysAgo);
|
||||
// 属性历史弹窗
|
||||
document.getElementById('ph-end').value = fmt(now);
|
||||
document.getElementById('ph-start').value = fmt(threeDaysAgo);
|
||||
// 数据点弹窗
|
||||
document.getElementById('dp-end').value = fmt(now);
|
||||
document.getElementById('dp-start').value = fmt(threeDaysAgo);
|
||||
// 页面加载后自动查询
|
||||
queryProduct();
|
||||
queryDeviceList(0);
|
||||
})();
|
||||
|
||||
// ===== 设备属性弹窗(一级) =====
|
||||
let _propDeviceName = '';
|
||||
let _propIdentifier = '';
|
||||
function showDeviceProperty(deviceName) {
|
||||
_propDeviceName = deviceName;
|
||||
document.getElementById('modal-prop-title').textContent = '设备属性 - ' + deviceName;
|
||||
document.getElementById('modal-prop-body').innerHTML = '<div class="tip">加载中...</div>';
|
||||
openModal('modal-prop');
|
||||
fetch('/thingmodel/query-device-property?device_name=' + encodeURIComponent(deviceName))
|
||||
.then(r => r.json()).then(res => {
|
||||
if (res.code !== 0) { document.getElementById('modal-prop-body').innerHTML = '<div class="err">错误:' + res.msg + '</div>'; return; }
|
||||
const list = res.data || [];
|
||||
if (!list.length) { document.getElementById('modal-prop-body').innerHTML = '<div class="tip">暂无属性数据</div>'; return; }
|
||||
document.getElementById('modal-prop-body').innerHTML =
|
||||
'<div class="table-wrap"><table style="table-layout:fixed;width:100%"><thead><tr>'
|
||||
+ '<th style="width:130px">标识</th><th style="width:90px">名称</th><th style="width:60px">类型</th>'
|
||||
+ '<th style="width:40px">读写</th><th style="width:140px">上报时间</th>'
|
||||
+ '<th style="width:80px">当前值</th><th style="width:80px">期望值</th><th style="width:70px">操作</th>'
|
||||
+ '</tr></thead><tbody>'
|
||||
+ list.map(p => '<tr>'
|
||||
+ '<td style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + (p.identifier||'') + '">' + (p.identifier||'-') + '</td>'
|
||||
+ '<td>' + (p.name||'-') + '</td>'
|
||||
+ '<td>' + (p.data_type||'-') + '</td>'
|
||||
+ '<td>' + (p.access_mode||'-') + '</td>'
|
||||
+ '<td>' + fmtDate(p.time ? parseInt(p.time) : null) + '</td>'
|
||||
+ '<td style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:80px" title="' + (p.value||'') + '">' + (p.value||'-') + '</td>'
|
||||
+ '<td style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:80px" title="' + (p.expect_value||'') + '">' + (p.expect_value||'-') + '</td>'
|
||||
+ '<td><button class="btn-sm btn-event" onclick="showPropHistory(\'' + (p.identifier||'') + '\',\'' + (p.name||'') + '\')">历史记录</button></td>'
|
||||
+ '</tr>').join('')
|
||||
+ '</tbody></table></div>';
|
||||
}).catch(e => { document.getElementById('modal-prop-body').innerHTML = '<div class="err">请求失败:' + e.message + '</div>'; });
|
||||
}
|
||||
|
||||
// ===== 属性历史记录弹窗(二级) =====
|
||||
function showPropHistory(identifier, propName) {
|
||||
_propIdentifier = identifier;
|
||||
document.getElementById('modal-prop-history-title').textContent = '属性历史 - ' + (propName||identifier);
|
||||
document.getElementById('modal-prop-history-body').innerHTML = '<div class="tip">请设置查询条件后点击查询</div>';
|
||||
openModal('modal-prop-history');
|
||||
}
|
||||
function queryPropHistory(offset) {
|
||||
const start = document.getElementById('ph-start').value;
|
||||
const end = document.getElementById('ph-end').value;
|
||||
const sort = document.getElementById('ph-sort').value;
|
||||
const limit = document.getElementById('ph-limit').value;
|
||||
const el = document.getElementById('modal-prop-history-body');
|
||||
if (!start || !end) { el.innerHTML = '<div class="err">请选择开始和结束时间</div>'; return; }
|
||||
el.innerHTML = '<div class="tip">查询中...</div>';
|
||||
const url = '/thingmodel/query-property-history?device_name=' + encodeURIComponent(_propDeviceName)
|
||||
+ '&identifier=' + encodeURIComponent(_propIdentifier)
|
||||
+ '&start_time=' + new Date(start).getTime()
|
||||
+ '&end_time=' + new Date(end).getTime()
|
||||
+ '&sort=' + sort + '&offset=' + offset + '&limit=' + limit;
|
||||
fetch(url).then(r => r.json()).then(res => {
|
||||
if (res.code !== 0) { el.innerHTML = '<div class="err">错误:' + res.msg + '</div>'; return; }
|
||||
if (!res.data) { el.innerHTML = '<div class="tip">暂无历史数据</div>'; return; }
|
||||
// 直接展示原始数据,兼容不同结构
|
||||
const raw = JSON.stringify(res.data, null, 2);
|
||||
el.innerHTML = '<pre style="font-size:12px;background:#fafafa;padding:12px;border-radius:6px;overflow:auto;max-height:400px">' + raw + '</pre>';
|
||||
}).catch(e => { el.innerHTML = '<div class="err">请求失败:' + e.message + '</div>'; });
|
||||
}
|
||||
|
||||
// ===== 物模型弹窗 =====
|
||||
function showThingModel() {
|
||||
document.getElementById('modal-thingmodel-body').innerHTML = '<div class="tip">加载中...</div>';
|
||||
openModal('modal-thingmodel');
|
||||
fetch('/thingmodel/query-thing-model')
|
||||
.then(r => r.json()).then(res => {
|
||||
if (res.code !== 0) { document.getElementById('modal-thingmodel-body').innerHTML = '<div class="err">错误:' + res.msg + '</div>'; return; }
|
||||
const d = res.data || {};
|
||||
let html = '';
|
||||
// 属性功能点
|
||||
const props = d.properties || [];
|
||||
html += '<div style="font-size:14px;font-weight:600;color:#333;margin-bottom:8px">属性功能点(' + props.length + ')</div>';
|
||||
if (props.length) {
|
||||
html += '<div class="table-wrap"><table><thead><tr><th>标识</th><th>名称</th><th>类型</th><th>读写</th><th>描述</th></tr></thead><tbody>'
|
||||
+ props.map(p => '<tr><td>'+p.identifier+'</td><td>'+p.name+'</td><td>'+(p.dataType&&p.dataType.type||'-')+'</td><td>'+(p.accessMode||'-')+'</td><td>'+(p.desc||'-')+'</td></tr>').join('')
|
||||
+ '</tbody></table></div>';
|
||||
} else { html += '<div class="tip" style="padding:8px 0">暂无属性功能点</div>'; }
|
||||
// 事件功能点
|
||||
const events = d.events || [];
|
||||
html += '<div style="font-size:14px;font-weight:600;color:#333;margin:16px 0 8px">事件功能点(' + events.length + ')</div>';
|
||||
if (events.length) {
|
||||
html += '<div class="table-wrap"><table><thead><tr><th>标识</th><th>名称</th><th>类型</th><th>描述</th></tr></thead><tbody>'
|
||||
+ events.map(e => '<tr><td>'+e.identifier+'</td><td>'+e.name+'</td><td>'+(e.eventType||'-')+'</td><td>'+(e.desc||'-')+'</td></tr>').join('')
|
||||
+ '</tbody></table></div>';
|
||||
} else { html += '<div class="tip" style="padding:8px 0">暂无事件功能点</div>'; }
|
||||
// 服务功能点
|
||||
const services = d.services || [];
|
||||
html += '<div style="font-size:14px;font-weight:600;color:#333;margin:16px 0 8px">服务功能点(' + services.length + ')</div>';
|
||||
if (services.length) {
|
||||
html += '<div class="table-wrap"><table><thead><tr><th>标识</th><th>名称</th><th>调用方式</th><th>描述</th></tr></thead><tbody>'
|
||||
+ services.map(s => '<tr><td>'+s.identifier+'</td><td>'+s.name+'</td><td>'+(s.callType==='s'?'同步':'异步')+'</td><td>'+(s.desc||'-')+'</td></tr>').join('')
|
||||
+ '</tbody></table></div>';
|
||||
} else { html += '<div class="tip" style="padding:8px 0">暂无服务功能点</div>'; }
|
||||
document.getElementById('modal-thingmodel-body').innerHTML = html;
|
||||
}).catch(e => { document.getElementById('modal-thingmodel-body').innerHTML = '<div class="err">请求失败:' + e.message + '</div>'; });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>登录 - IoT 设备管理</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Microsoft YaHei", sans-serif;
|
||||
background: #f0f2f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.login-wrapper { width: 380px; }
|
||||
.login-title {
|
||||
text-align: center;
|
||||
color: #1677ff;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.login-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
padding: 36px 40px;
|
||||
}
|
||||
.form-group { margin-bottom: 18px; }
|
||||
.form-group label { display: block; font-size: 13px; color: #666; margin-bottom: 6px; }
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px 13px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.form-group input:focus { border-color: #1677ff; }
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
margin-top: 6px;
|
||||
transition: background 0.2s;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.btn-login:hover { background: #4096ff; }
|
||||
.error-msg {
|
||||
background: #fff0f0;
|
||||
border: 1px solid #ffcccc;
|
||||
color: #d32f2f;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-wrapper">
|
||||
<div class="login-title">IoT 设备管理</div>
|
||||
<div class="login-card">
|
||||
<#if errorMsg?has_content>
|
||||
<div class="error-msg">${errorMsg}</div>
|
||||
</#if>
|
||||
<form method="post" action="/auth/doLogin">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" name="username" placeholder="请输入用户名" autofocus required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" placeholder="请输入密码" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-login">登 录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
package features;
|
||||
|
||||
import com.yixiong.iot.App;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.noear.solon.test.HttpTester;
|
||||
import org.noear.solon.test.SolonTest;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@SolonTest(App.class)
|
||||
public class HelloTest extends HttpTester {
|
||||
@Test
|
||||
public void hello() throws IOException {
|
||||
assert path("/hello?name=world").get().contains("world");
|
||||
assert path("/hello?name=solon").get().contains("solon");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user