From 69e13f504ff8119640d0309d60cb5bf71d9698e5 Mon Sep 17 00:00:00 2001 From: lianlonggang Date: Wed, 3 Jun 2026 11:09:41 +0800 Subject: [PATCH] feat: init iot-util project with OneNET API integration Co-Authored-By: Claude Opus 4.7 --- .gitignore | 29 + CLAUDE.md | 134 +++++ pom.xml | 106 ++++ src/main/java/com/yixiong/iot/App.java | 11 + .../java/com/yixiong/iot/DemoController.java | 19 + .../com/yixiong/iot/config/IotProperties.java | 46 ++ .../iot/controller/AuthController.java | 63 ++ .../iot/controller/DeviceController.java | 112 ++++ .../iot/controller/GlobalExceptionFilter.java | 29 + .../iot/controller/PageController.java | 34 ++ .../iot/controller/ProductController.java | 33 ++ .../iot/controller/ThingModelController.java | 87 +++ .../com/yixiong/iot/model/DatapointData.java | 43 ++ .../yixiong/iot/model/DeviceDetailData.java | 90 +++ .../yixiong/iot/model/DeviceEventItem.java | 30 + .../iot/model/DeviceEventListData.java | 21 + .../com/yixiong/iot/model/DeviceItem.java | 116 ++++ .../com/yixiong/iot/model/DeviceListData.java | 21 + .../yixiong/iot/model/DevicePropertyItem.java | 38 ++ .../com/yixiong/iot/model/IotResponse.java | 24 + .../com/yixiong/iot/model/OldIotResponse.java | 22 + .../yixiong/iot/model/ProductDetailData.java | 92 +++ .../iot/model/PropertyHistoryData.java | 16 + .../iot/model/PropertyHistoryItem.java | 16 + .../com/yixiong/iot/model/ThingModelData.java | 61 ++ .../yixiong/iot/service/DeviceService.java | 186 ++++++ .../yixiong/iot/service/ProductService.java | 46 ++ .../iot/service/ThingModelService.java | 134 +++++ .../com/yixiong/iot/util/IotHttpClient.java | 173 ++++++ .../com/yixiong/iot/util/IotTokenUtil.java | 70 +++ src/main/resources/app.yml | 53 ++ src/main/resources/static/base.css | 1 + src/main/resources/templates/hello2.ftl | 9 + src/main/resources/templates/index.ftl | 537 ++++++++++++++++++ src/main/resources/templates/login.ftl | 89 +++ src/test/java/features/HelloTest.java | 19 + 36 files changed, 2610 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 pom.xml create mode 100644 src/main/java/com/yixiong/iot/App.java create mode 100644 src/main/java/com/yixiong/iot/DemoController.java create mode 100644 src/main/java/com/yixiong/iot/config/IotProperties.java create mode 100644 src/main/java/com/yixiong/iot/controller/AuthController.java create mode 100644 src/main/java/com/yixiong/iot/controller/DeviceController.java create mode 100644 src/main/java/com/yixiong/iot/controller/GlobalExceptionFilter.java create mode 100644 src/main/java/com/yixiong/iot/controller/PageController.java create mode 100644 src/main/java/com/yixiong/iot/controller/ProductController.java create mode 100644 src/main/java/com/yixiong/iot/controller/ThingModelController.java create mode 100644 src/main/java/com/yixiong/iot/model/DatapointData.java create mode 100644 src/main/java/com/yixiong/iot/model/DeviceDetailData.java create mode 100644 src/main/java/com/yixiong/iot/model/DeviceEventItem.java create mode 100644 src/main/java/com/yixiong/iot/model/DeviceEventListData.java create mode 100644 src/main/java/com/yixiong/iot/model/DeviceItem.java create mode 100644 src/main/java/com/yixiong/iot/model/DeviceListData.java create mode 100644 src/main/java/com/yixiong/iot/model/DevicePropertyItem.java create mode 100644 src/main/java/com/yixiong/iot/model/IotResponse.java create mode 100644 src/main/java/com/yixiong/iot/model/OldIotResponse.java create mode 100644 src/main/java/com/yixiong/iot/model/ProductDetailData.java create mode 100644 src/main/java/com/yixiong/iot/model/PropertyHistoryData.java create mode 100644 src/main/java/com/yixiong/iot/model/PropertyHistoryItem.java create mode 100644 src/main/java/com/yixiong/iot/model/ThingModelData.java create mode 100644 src/main/java/com/yixiong/iot/service/DeviceService.java create mode 100644 src/main/java/com/yixiong/iot/service/ProductService.java create mode 100644 src/main/java/com/yixiong/iot/service/ThingModelService.java create mode 100644 src/main/java/com/yixiong/iot/util/IotHttpClient.java create mode 100644 src/main/java/com/yixiong/iot/util/IotTokenUtil.java create mode 100644 src/main/resources/app.yml create mode 100644 src/main/resources/static/base.css create mode 100644 src/main/resources/templates/hello2.ftl create mode 100644 src/main/resources/templates/index.ftl create mode 100644 src/main/resources/templates/login.ftl create mode 100644 src/test/java/features/HelloTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf2a19c --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a58e043 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..1ca897b --- /dev/null +++ b/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + + org.noear + solon-parent + 3.10.5 + + + + com.yixiong + iot-util + 1.0 + + jar + + iot-util for yixiong + + + 21 + + + + + org.noear + solon-web + + + + org.noear + solon-view-freemarker + + + + org.noear + solon-logging-logback-jakarta + + + + org.projectlombok + lombok + provided + + + + org.noear + solon-test + test + + + + com.squareup.okhttp3 + okhttp + 4.12.0 + + + + org.noear + solon-serialization-jackson + 3.10.5 + + + + cn.dev33 + sa-token-solon-plugin + 1.44.0 + + + + + ${project.artifactId} + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.noear + solon-maven-plugin + + + + + + + tencent + https://mirrors.cloud.tencent.com/nexus/repository/maven-public/ + + false + + + + + \ No newline at end of file diff --git a/src/main/java/com/yixiong/iot/App.java b/src/main/java/com/yixiong/iot/App.java new file mode 100644 index 0000000..cd62a9f --- /dev/null +++ b/src/main/java/com/yixiong/iot/App.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/com/yixiong/iot/DemoController.java b/src/main/java/com/yixiong/iot/DemoController.java new file mode 100644 index 0000000..0932d25 --- /dev/null +++ b/src/main/java/com/yixiong/iot/DemoController.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/com/yixiong/iot/config/IotProperties.java b/src/main/java/com/yixiong/iot/config/IotProperties.java new file mode 100644 index 0000000..49766cc --- /dev/null +++ b/src/main/java/com/yixiong/iot/config/IotProperties.java @@ -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; + } +} diff --git a/src/main/java/com/yixiong/iot/controller/AuthController.java b/src/main/java/com/yixiong/iot/controller/AuthController.java new file mode 100644 index 0000000..f56049a --- /dev/null +++ b/src/main/java/com/yixiong/iot/controller/AuthController.java @@ -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"); + } +} diff --git a/src/main/java/com/yixiong/iot/controller/DeviceController.java b/src/main/java/com/yixiong/iot/controller/DeviceController.java new file mode 100644 index 0000000..3e6877e --- /dev/null +++ b/src/main/java/com/yixiong/iot/controller/DeviceController.java @@ -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 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 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 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 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); + } +} diff --git a/src/main/java/com/yixiong/iot/controller/GlobalExceptionFilter.java b/src/main/java/com/yixiong/iot/controller/GlobalExceptionFilter.java new file mode 100644 index 0000000..f9dc841 --- /dev/null +++ b/src/main/java/com/yixiong/iot/controller/GlobalExceptionFilter.java @@ -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 err = new IotResponse<>(); + err.setCode(-1); + err.setMsg(e.getMessage() != null ? e.getMessage() : "服务器内部错误"); + ctx.render(err); + } + } + } +} diff --git a/src/main/java/com/yixiong/iot/controller/PageController.java b/src/main/java/com/yixiong/iot/controller/PageController.java new file mode 100644 index 0000000..f73d03b --- /dev/null +++ b/src/main/java/com/yixiong/iot/controller/PageController.java @@ -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()); + } +} diff --git a/src/main/java/com/yixiong/iot/controller/ProductController.java b/src/main/java/com/yixiong/iot/controller/ProductController.java new file mode 100644 index 0000000..fab3dc5 --- /dev/null +++ b/src/main/java/com/yixiong/iot/controller/ProductController.java @@ -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 detail(@Param("product_id") String productId) { + return productService.getProductDetail(productId); + } +} diff --git a/src/main/java/com/yixiong/iot/controller/ThingModelController.java b/src/main/java/com/yixiong/iot/controller/ThingModelController.java new file mode 100644 index 0000000..9bf2eec --- /dev/null +++ b/src/main/java/com/yixiong/iot/controller/ThingModelController.java @@ -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 queryThingModel() { + return thingModelService.queryThingModel(); + } + + /** + * 查询设备属性最新数据 + * + * @param deviceName 设备名称(必填) + */ + @Get + @Mapping("/query-device-property") + public IotResponse> queryDeviceProperty(@Param("device_name") String deviceName) { + return thingModelService.queryDeviceProperty(deviceName); + } + + /** + * 获取设备属性详情(下发命令到设备,需设备在线) + * + * @param body 请求体,含 device_name 和 params 字段 + */ + @Post + @Mapping("/query-device-property-detail") + public IotResponse> queryDevicePropertyDetail(@Body Map body) { + String deviceName = (String) body.get("device_name"); + @SuppressWarnings("unchecked") + List params = (List) 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 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); + } +} diff --git a/src/main/java/com/yixiong/iot/model/DatapointData.java b/src/main/java/com/yixiong/iot/model/DatapointData.java new file mode 100644 index 0000000..44dc232 --- /dev/null +++ b/src/main/java/com/yixiong/iot/model/DatapointData.java @@ -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 datastreams; + + /** + * 单个数据流及其数据点 + */ + @Data + public static class DatapointStream { + /** 数据流名称 */ + private String id; + /** 数据点列表 */ + private List datapoints; + } + + /** + * 单个数据点 + */ + @Data + public static class DatapointItem { + /** 数据记录时间 */ + private String at; + /** 数据点的值 */ + private Object value; + } +} diff --git a/src/main/java/com/yixiong/iot/model/DeviceDetailData.java b/src/main/java/com/yixiong/iot/model/DeviceDetailData.java new file mode 100644 index 0000000..25dafc0 --- /dev/null +++ b/src/main/java/com/yixiong/iot/model/DeviceDetailData.java @@ -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 imsiOld; + + /** 设备鉴权信息 */ + @JsonProperty("auth_info") + private Map authInfo; + + /** 设备位置坐标 */ + private Map location; + + /** 设备标签 */ + private List tags; + + /** 生产厂商名称 */ + @JsonProperty("manu_id") + private String manuId; + + /** 型号名称 */ + private String model; + + /** 数据流信息列表 */ + private List> datastreams; +} diff --git a/src/main/java/com/yixiong/iot/model/DeviceEventItem.java b/src/main/java/com/yixiong/iot/model/DeviceEventItem.java new file mode 100644 index 0000000..91e2f52 --- /dev/null +++ b/src/main/java/com/yixiong/iot/model/DeviceEventItem.java @@ -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; +} diff --git a/src/main/java/com/yixiong/iot/model/DeviceEventListData.java b/src/main/java/com/yixiong/iot/model/DeviceEventListData.java new file mode 100644 index 0000000..21c5efe --- /dev/null +++ b/src/main/java/com/yixiong/iot/model/DeviceEventListData.java @@ -0,0 +1,21 @@ +package com.yixiong.iot.model; + +import lombok.Data; + +import java.util.List; + +/** + * 设备事件记录列表响应的 data 字段 + */ +@Data +public class DeviceEventListData { + + /** 事件记录列表 */ + private List list; + + /** 本次请求起始位置 */ + private Integer offset; + + /** 本次请求记录数上限 */ + private Integer limit; +} diff --git a/src/main/java/com/yixiong/iot/model/DeviceItem.java b/src/main/java/com/yixiong/iot/model/DeviceItem.java new file mode 100644 index 0000000..d2111ea --- /dev/null +++ b/src/main/java/com/yixiong/iot/model/DeviceItem.java @@ -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 imsiOld; + + /** 设备 IMSI 最近一次修改时间 */ + @JsonProperty("imsi_mt") + private String imsiMt; + + /** 移动视联网设备唯一标识 */ + @JsonProperty("viot_device_sn") + private String viotDeviceSn; + + /** 移动视联网设备接入协议 */ + @JsonProperty("viot_protocol") + private Integer viotProtocol; +} diff --git a/src/main/java/com/yixiong/iot/model/DeviceListData.java b/src/main/java/com/yixiong/iot/model/DeviceListData.java new file mode 100644 index 0000000..9daf997 --- /dev/null +++ b/src/main/java/com/yixiong/iot/model/DeviceListData.java @@ -0,0 +1,21 @@ +package com.yixiong.iot.model; + +import lombok.Data; + +import java.util.List; + +/** + * 设备列表响应的 data 字段 + */ +@Data +public class DeviceListData { + + /** 设备列表 */ + private List list; + + /** 查询起始下标 */ + private Integer offset; + + /** 单次查询数量限额 */ + private Integer limit; +} diff --git a/src/main/java/com/yixiong/iot/model/DevicePropertyItem.java b/src/main/java/com/yixiong/iot/model/DevicePropertyItem.java new file mode 100644 index 0000000..0371e24 --- /dev/null +++ b/src/main/java/com/yixiong/iot/model/DevicePropertyItem.java @@ -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; +} diff --git a/src/main/java/com/yixiong/iot/model/IotResponse.java b/src/main/java/com/yixiong/iot/model/IotResponse.java new file mode 100644 index 0000000..251671b --- /dev/null +++ b/src/main/java/com/yixiong/iot/model/IotResponse.java @@ -0,0 +1,24 @@ +package com.yixiong.iot.model; + +import lombok.Data; + +/** + * IoT 平台接口通用响应包装类 + * + * @param 具体业务数据类型 + */ +@Data +public class IotResponse { + + /** 错误码,0 表示成功 */ + private int code; + + /** 错误描述信息 */ + private String msg; + + /** API 请求链路 ID */ + private String requestId; + + /** 具体响应内容 */ + private T data; +} diff --git a/src/main/java/com/yixiong/iot/model/OldIotResponse.java b/src/main/java/com/yixiong/iot/model/OldIotResponse.java new file mode 100644 index 0000000..7686d27 --- /dev/null +++ b/src/main/java/com/yixiong/iot/model/OldIotResponse.java @@ -0,0 +1,22 @@ +package com.yixiong.iot.model; + +import lombok.Data; + +/** + * 旧版 IoT API 通用响应包装类(api.heclouds.com) + * 使用 errno/error 而非 code/msg + * + * @param 具体业务数据类型 + */ +@Data +public class OldIotResponse { + + /** 错误码,0 表示成功 */ + private int errno; + + /** 错误描述,"succ" 表示成功 */ + private String error; + + /** 具体响应内容 */ + private T data; +} diff --git a/src/main/java/com/yixiong/iot/model/ProductDetailData.java b/src/main/java/com/yixiong/iot/model/ProductDetailData.java new file mode 100644 index 0000000..235c30c --- /dev/null +++ b/src/main/java/com/yixiong/iot/model/ProductDetailData.java @@ -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; +} diff --git a/src/main/java/com/yixiong/iot/model/PropertyHistoryData.java b/src/main/java/com/yixiong/iot/model/PropertyHistoryData.java new file mode 100644 index 0000000..e509d65 --- /dev/null +++ b/src/main/java/com/yixiong/iot/model/PropertyHistoryData.java @@ -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 list; +} diff --git a/src/main/java/com/yixiong/iot/model/PropertyHistoryItem.java b/src/main/java/com/yixiong/iot/model/PropertyHistoryItem.java new file mode 100644 index 0000000..3781121 --- /dev/null +++ b/src/main/java/com/yixiong/iot/model/PropertyHistoryItem.java @@ -0,0 +1,16 @@ +package com.yixiong.iot.model; + +import lombok.Data; + +/** + * 设备属性历史记录单条数据 + */ +@Data +public class PropertyHistoryItem { + + /** 属性功能点上报时间(毫秒时间戳字符串) */ + private String time; + + /** 属性功能点上报值 */ + private String value; +} diff --git a/src/main/java/com/yixiong/iot/model/ThingModelData.java b/src/main/java/com/yixiong/iot/model/ThingModelData.java new file mode 100644 index 0000000..2b5f47e --- /dev/null +++ b/src/main/java/com/yixiong/iot/model/ThingModelData.java @@ -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 properties; + + /** 事件功能点列表 */ + private List events; + + /** 服务功能点列表 */ + private List 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 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> 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> input; + private List> output; + } +} diff --git a/src/main/java/com/yixiong/iot/service/DeviceService.java b/src/main/java/com/yixiong/iot/service/DeviceService.java new file mode 100644 index 0000000..b8f4e9c --- /dev/null +++ b/src/main/java/com/yixiong/iot/service/DeviceService.java @@ -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 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>() {}); + } 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 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>() {}); + } 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 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>() {}); + } 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 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>() {}); + } catch (Exception e) { + log.error("[数据点] 查询失败,productId={}, deviceName={}", productId, deviceName, e); + throw new RuntimeException("查询设备数据点失败,deviceName=" + deviceName, e); + } + } +} diff --git a/src/main/java/com/yixiong/iot/service/ProductService.java b/src/main/java/com/yixiong/iot/service/ProductService.java new file mode 100644 index 0000000..698f1a5 --- /dev/null +++ b/src/main/java/com/yixiong/iot/service/ProductService.java @@ -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 getProductDetail(String productId) { + try { + String responseBody = iotHttpClient.get("/product/detail?product_id=" + productId); + return MAPPER.readValue(responseBody, new TypeReference>() {}); + } catch (Exception e) { + log.error("[产品详情] 查询失败,productId={}", productId, e); + throw new RuntimeException("查询产品详情失败,productId=" + productId, e); + } + } +} diff --git a/src/main/java/com/yixiong/iot/service/ThingModelService.java b/src/main/java/com/yixiong/iot/service/ThingModelService.java new file mode 100644 index 0000000..fe5e7c9 --- /dev/null +++ b/src/main/java/com/yixiong/iot/service/ThingModelService.java @@ -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 queryThingModel() { + String productId = props.getHttp().getDefaultProductId(); + try { + String responseBody = iotHttpClient.get("/thingmodel/query-thing-model?product_id=" + productId); + return MAPPER.readValue(responseBody, new TypeReference>() {}); + } catch (Exception e) { + log.error("[物模型] 查询失败,productId={}", productId, e); + throw new RuntimeException("查询物模型失败,productId=" + productId, e); + } + } + + /** + * 查询设备属性最新数据 + * product_id 使用配置中的默认值 + * + * @param deviceName 设备名称(必填) + * @return IoT 平台响应,data 字段为属性列表 + */ + public IotResponse> 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>>() {}); + } 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> queryDevicePropertyDetail(String deviceName, List 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>>() {}); + } 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 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>() {}); + } catch (Exception e) { + log.error("[属性历史] 查询失败,productId={}, deviceName={}, identifier={}", productId, deviceName, identifier, e); + throw new RuntimeException("查询属性历史记录失败,deviceName=" + deviceName + ", identifier=" + identifier, e); + } + } +} diff --git a/src/main/java/com/yixiong/iot/util/IotHttpClient.java b/src/main/java/com/yixiong/iot/util/IotHttpClient.java new file mode 100644 index 0000000..d40d849 --- /dev/null +++ b/src/main/java/com/yixiong/iot/util/IotHttpClient.java @@ -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; + } + } +} diff --git a/src/main/java/com/yixiong/iot/util/IotTokenUtil.java b/src/main/java/com/yixiong/iot/util/IotTokenUtil.java new file mode 100644 index 0000000..298fe41 --- /dev/null +++ b/src/main/java/com/yixiong/iot/util/IotTokenUtil.java @@ -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); + } +} diff --git a/src/main/resources/app.yml b/src/main/resources/app.yml new file mode 100644 index 0000000..9dcefe1 --- /dev/null +++ b/src/main/resources/app.yml @@ -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 \ No newline at end of file diff --git a/src/main/resources/static/base.css b/src/main/resources/static/base.css new file mode 100644 index 0000000..7af79a4 --- /dev/null +++ b/src/main/resources/static/base.css @@ -0,0 +1 @@ +div{font-size: 1.5em;} \ No newline at end of file diff --git a/src/main/resources/templates/hello2.ftl b/src/main/resources/templates/hello2.ftl new file mode 100644 index 0000000..b7381ee --- /dev/null +++ b/src/main/resources/templates/hello2.ftl @@ -0,0 +1,9 @@ + + + + + + +
Hello ${name!}!
+ + \ No newline at end of file diff --git a/src/main/resources/templates/index.ftl b/src/main/resources/templates/index.ftl new file mode 100644 index 0000000..db9c8c5 --- /dev/null +++ b/src/main/resources/templates/index.ftl @@ -0,0 +1,537 @@ + + + + + + IoT 设备管理 + + + +
+
IoT 设备管理
+ 退出登录 +
+
+
+ + +
+ + +
+
+
产品详情
+
+
+ + +
+ + +
+
+
+
+ + +
+
+
设备列表
+
+
+ + +
+
+ + +
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/templates/login.ftl b/src/main/resources/templates/login.ftl new file mode 100644 index 0000000..5e896d7 --- /dev/null +++ b/src/main/resources/templates/login.ftl @@ -0,0 +1,89 @@ + + + + + + 登录 - IoT 设备管理 + + + + + + diff --git a/src/test/java/features/HelloTest.java b/src/test/java/features/HelloTest.java new file mode 100644 index 0000000..23c1b3d --- /dev/null +++ b/src/test/java/features/HelloTest.java @@ -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"); + } +} \ No newline at end of file