feat: init iot-util project with OneNET API integration

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 11:09:41 +08:00
co-authored by Claude Opus 4.7
commit 69e13f504f
36 changed files with 2610 additions and 0 deletions
+29
View File
@@ -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
+134
View File
@@ -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-SHA1IoT 平台鉴权)
- **HTTP 客户端**: OkHttp 4.12.0
- **模板引擎**: FreeMarker
- **JSON**: Jacksonsolon-serialization-jackson 3.10.5
- **端口**: 8081app.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" # 新版 APIHMAC 鉴权)
default-product-id: "VGi8wC99jO" # 默认产品 ID,多数接口使用此值
old-base-url: "https://api.heclouds.com" # 旧版 APIapi-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
+106
View File
@@ -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>
+11
View File
@@ -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);
}
/**
* 查询设备详情(新版 APIproduct_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 nameIMEI */
@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-MQTT4-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);
}
}
/**
* 查询设备详情(新版 APIproduct_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 生成工具
* 算法:HMACSHA1/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);
}
}
+53
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
div{font-size: 1.5em;}
+9
View File
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/base.css" />
</head>
<body>
<div>Hello ${name!}!</div>
</body>
</html>
+537
View File
@@ -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>
+89
View File
@@ -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>
+19
View File
@@ -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");
}
}