From 116f6bb0f05f4af6dc01650663798aeb2dfadc97 Mon Sep 17 00:00:00 2001 From: 王彪总 Date: Fri, 12 Jun 2026 15:19:36 +0800 Subject: [PATCH] feat(garden): 添加开放API考勤和工单统计功能 --- docs/APP-园林工单创建接口文档.md | 350 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/全量工单统计-建表SQL.sql | 28 ++++++++++++++++++++++++++++ docs/全量工单统计-菜单SQL.sql | 27 +++++++++++++++++++++++++++ docs/开放API对接接口文档.md | 780 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/开放API对接接口文档.pdf | Bin 0 -> 1491868 bytes docs/系统鉴权方式说明文档.md | 2001 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/pom.xml | 14 ++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/AttendanceOpenController.java | 39 +++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/vo/AttendanceQueryReqVO.java | 36 ++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/vo/AttendanceQueryRespVO.java | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/FullOrderStatisticsOpenController.java | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/vo/FullOrderStatisticsOpenReqVO.java | 32 ++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/vo/FullOrderStatisticsOpenRespVO.java | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/MaterialOpenController.java | 39 +++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/vo/MaterialQueryReqVO.java | 36 ++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/vo/MaterialQueryRespVO.java | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiService.java | 27 +++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiServiceImpl.java | 382 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/StaffOpenController.java | 38 ++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/vo/StaffQueryReqVO.java | 26 ++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/vo/StaffQueryRespVO.java | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/TaskOpenController.java | 38 ++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/vo/TaskQueryReqVO.java | 42 ++++++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/vo/TaskQueryRespVO.java | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/TeamOpenController.java | 38 ++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/vo/TeamQueryReqVO.java | 20 ++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/vo/TeamQueryRespVO.java | 36 ++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/enums/ErrorCodeConstants.java | 7 +++++++ urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/FullOrderStatisticsController.java | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsPageReqVO.java | 38 ++++++++++++++++++++++++++++++++++++++ urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsRespVO.java | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsSaveReqVO.java | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/dal/dataobject/fullorderstatistics/FullOrderStatisticsDO.java | 33 +++++++++++++++++++++++++++++++++ urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/dal/mysql/fullorderstatistics/FullOrderStatisticsMapper.java | 23 +++++++++++++++++++++++ urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/enums/ErrorCodeConstants.java | 4 ++++ urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/service/fullorderstatistics/FullOrderStatisticsService.java | 24 ++++++++++++++++++++++++ urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/service/fullorderstatistics/FullOrderStatisticsServiceImpl.java | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ urbanops-server/src/main/resources/application.yaml | 2 +- 38 files changed, 4857 insertions(+), 1 deletion(-) create mode 100644 docs/APP-园林工单创建接口文档.md create mode 100644 docs/全量工单统计-建表SQL.sql create mode 100644 docs/全量工单统计-菜单SQL.sql create mode 100644 docs/开放API对接接口文档.md create mode 100644 docs/开放API对接接口文档.pdf create mode 100644 docs/系统鉴权方式说明文档.md create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/AttendanceOpenController.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/vo/AttendanceQueryReqVO.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/vo/AttendanceQueryRespVO.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/FullOrderStatisticsOpenController.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/vo/FullOrderStatisticsOpenReqVO.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/vo/FullOrderStatisticsOpenRespVO.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/MaterialOpenController.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/vo/MaterialQueryReqVO.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/vo/MaterialQueryRespVO.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiService.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiServiceImpl.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/StaffOpenController.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/vo/StaffQueryReqVO.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/vo/StaffQueryRespVO.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/TaskOpenController.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/vo/TaskQueryReqVO.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/vo/TaskQueryRespVO.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/TeamOpenController.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/vo/TeamQueryReqVO.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/vo/TeamQueryRespVO.java create mode 100644 urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/FullOrderStatisticsController.java create mode 100644 urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsPageReqVO.java create mode 100644 urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsRespVO.java create mode 100644 urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsSaveReqVO.java create mode 100644 urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/dal/dataobject/fullorderstatistics/FullOrderStatisticsDO.java create mode 100644 urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/dal/mysql/fullorderstatistics/FullOrderStatisticsMapper.java create mode 100644 urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/service/fullorderstatistics/FullOrderStatisticsService.java create mode 100644 urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/service/fullorderstatistics/FullOrderStatisticsServiceImpl.java diff --git a/docs/APP-园林工单创建接口文档.md b/docs/APP-园林工单创建接口文档.md new file mode 100644 index 0000000..ab7dc04 --- /dev/null +++ b/docs/APP-园林工单创建接口文档.md @@ -0,0 +1,350 @@ +# APP 端 — 园林工单创建接口 + +> **接口路径**:`POST /app-api/bpm/garden/workorder/create` +> +> **版本**:v1.0 +> +> **最后更新**:2026-06-07 + +--- + +## 1. 接口概述 + +| 属性 | 说明 | +|------|------| +| **接口名称** | APP 端创建园林工单 | +| **请求方式** | `POST` | +| **完整路径** | `/app-api/bpm/garden/workorder/create` | +| **认证方式** | Token 认证(`Authorization: Bearer {token}`) | +| **权限控制** | 无独立权限注解(由登录用户角色决定) | +| **请求格式** | `application/json` | +| **响应格式** | `application/json` | +| **幂等性** | 无(同一请求多次调用会创建多个工单) | + +### 业务说明 + +该接口用于 APP 移动端创建智慧园林养护工单。提交后系统自动完成: + +1. 校验道路信息存在性 +2. 根据道路养护级别自动赋值养护等级 +3. 自动生成工单编号(前缀 `GWO`) +4. 保存问题附件图片(如有) +5. 自动发起 Flowable BPM 工作流(流程定义 Key:`workorder_common_prod`) +6. 根据业务线自动匹配对应班组长角色作为流程审批人 + +--- + +## 2. 请求参数 + +### 2.1 Header 参数 + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| `Authorization` | String | **是** | Bearer Token,格式:`Bearer {token}` | +| `Content-Type` | String | **是** | `application/json` | +| `tenant-id` | Long | 否 | 租户 ID(多租户场景) | + +### 2.2 Body 参数(JSON) + +| 参数名 | 类型 | 必填 | 校验规则 | 说明 | 示例 | +|--------|------|------|----------|------|------| +| `orderName` | String | 否 | max 100 | 工单名称 | `"树木倒伏紧急处理"` | +| `orderCode` | String | 否 | max 50 | 三级编码 | `"YL001"` | +| `busiType` | String | 否 | max 50 | 业务类型 | `"TREE_MAINTENANCE"` | +| `orderType` | String | 否 | max 1 | 工单类型:`C`=普通工单,`O`=其他工单。**不能传 `Q`**(快速工单有独立接口 `/createQuick`) | `"C"` | +| `rootCauseBy` | String | 否 | max 50 | 工单溯源号,`"0"` 表示无溯源 | `"0"` | +| `busiLine` | String | 否 | max 10 | 业务线:`yl`=园林,`wy`=物业,`sz`=市政。不传则取登录用户所属业务线 | `"yl"` | +| `roadId` | Long | **是** | `@NotNull` | 道路 ID(系统中已存在的道路) | `21128` | +| `roadName` | String | 否 | max 100 | 道路名称(辅助字段,实际以 roadId 查到的为准) | `"西长安街"` | +| `sourceId` | Integer | 否 | — | 来源 ID,不传默认 `1`(巡查)。见 [工单来源枚举](#51-工单来源枚举) | `1` | +| `sourceName` | String | 否 | max 50 | 来源名称,不传默认为"巡查" | `"巡查"` | +| `pressingType` | Integer | **是** | `@NotNull` | 紧急程度:`1`=特急,`2`=紧急,`3`=一般 | `2` | +| `latLonType` | Integer | 否 | — | 坐标系类型:`1`=WGS-84,`2`=BD-09,`3`=GCJ-02,`4`=腾讯 | `3` | +| `lat` | BigDecimal | **是** | `@NotNull` | 经度 | `116.397428` | +| `lon` | BigDecimal | **是** | `@NotNull` | 纬度 | `39.909204` | +| `lonLatAddress` | String | **是** | `@NotEmpty`,max 200 | 经纬度对应的地址描述 | `"北京市西城区西长安街5号"` | +| `thirdWorkNo` | String | 否 | max 50 | 第三方系统工单编号(对接外部系统时使用) | `"EXT20250607001"` | +| `remark` | String | **是** | `@NotEmpty`,max 500 | 工单描述 / 现场情况说明 | `"巡查发现行道树倾斜,存在倒伏风险"` | +| `handleResult` | String | 否 | max 500 | 工单完成结果描述(创建时可留空) | `""` | +| `expectedFinishDate` | String | 否 | ISO 8601 | 期望完成时间 | `"2025-12-07T18:00:00"` | +| `problemsImgs` | String[] | 否 | — | 问题现场拍照图片 URL 列表 | `["https://img.example.com/p1.jpg"]` | +| `endImgs` | String[] | 否 | — | 完成拍照图片 URL 列表(普通工单创建时一般不需要) | `[]` | + +### 2.3 请求示例(完整) + +```json +{ + "orderName": "树木倒伏紧急处理", + "orderCode": "YL001", + "busiType": "TREE_MAINTENANCE", + "orderType": "C", + "rootCauseBy": "0", + "busiLine": "yl", + "roadId": 21128, + "roadName": "西长安街", + "sourceId": 1, + "sourceName": "巡查", + "pressingType": 2, + "latLonType": 3, + "lat": 116.397428, + "lon": 39.909204, + "lonLatAddress": "北京市西城区西长安街5号", + "thirdWorkNo": "", + "remark": "巡查发现行道树倾斜约30度,存在倒伏风险,需要紧急处理", + "handleResult": "", + "expectedFinishDate": "2025-12-07T18:00:00", + "problemsImgs": [ + "https://img.example.com/problem1.jpg", + "https://img.example.com/problem2.jpg" + ], + "endImgs": [] +} +``` + +### 2.4 请求示例(最小) + +```json +{ + "roadId": 21128, + "pressingType": 2, + "lat": 116.397428, + "lon": 39.909204, + "lonLatAddress": "北京市西城区西长安街5号", + "remark": "巡查发现树木倾斜,需要处理" +} +``` + +--- + +## 3. 响应参数 + +### 3.1 成功响应 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `code` | Integer | 状态码,成功为 `0` | +| `msg` | String | 提示消息 | +| `data` | Long | 创建成功的工单 ID | + +```json +{ + "code": 0, + "msg": "成功", + "data": 1024 +} +``` + +> `data` 返回工单主键 ID(`workorder_main_info.id`),可用于后续查询工单详情。 + +### 3.2 失败响应 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `code` | Integer | 错误码(非 0) | +| `msg` | String | 错误描述信息 | +| `data` | null | — | + +```json +{ + "code": 1900001003, + "msg": "养护班组长人员不存在", + "data": null +} +``` + +--- + +## 4. 错误码参考 + +| 错误码 | 错误消息 | 触发场景 | +|--------|----------|----------| +| `1900001003` | 养护班组长人员不存在 | 根据道路 ID 和业务线找不到对应班组长角色的人员 | +| `1900002000` | 养护级别编码错误,不存在 | 道路关联的养护级别在字典中不存在 | +| `1900002001` | 该人员未分配角色 | 登录用户未分配任何角色 | +| `1900002002` | 工单类型错误,快速工单无法走此流程 | 传入了 `orderType="Q"` | +| `1900004002` | 道路信息不存在 | 传入的 `roadId` 在系统中查询不到 | +| `系统级` | 未登录 / Token 已过期 | 未传 Authorization 或 Token 无效 | +| `系统级` | 参数校验失败 | 必填字段缺失或格式不合法 | + +--- + +## 5. 数据字典 + +### 5.1 工单来源枚举 + +| code | 描述 | +|------|------| +| `1` | 巡查 | +| `2` | 游客居民 | +| `3` | 12345 | +| `4` | 网格 | +| `5` | 大区经理 | +| `6` | AI | + +### 5.2 业务线 + +| 值 | 描述 | 对应班组长角色 | +|----|------|----------------| +| `yl` | 园林 | `team_leader_yl` | +| `wy` | 物业 | `team_leader_wy` | +| `sz` | 市政 | `team_leader_sz` | +| `zx` | 秩序管理 | `team_leader_zxgl` | +| `hj` | 环境卫生 | `team_leader_hjws` | + +### 5.3 工单类型 + +| 值 | 描述 | 说明 | +|----|------|------| +| `C` | 普通工单 | 走标准 BPM 审批流程 | +| `Q` | 快速工单 | **本接口不支持**,请使用 `/createQuick` 接口 | +| `O` | 其他工单 | 特殊类型 | + +### 5.4 紧急程度 + +| 值 | 描述 | +|----|------| +| `1` | 特急 | +| `2` | 紧急 | +| `3` | 一般 | + +### 5.5 坐标系类型 + +| 值 | 描述 | +|----|------| +| `1` | 国标(WGS-84) | +| `2` | 百度坐标系(BD-09) | +| `3` | 高德坐标系(GCJ-02) | +| `4` | 腾讯坐标系 | + +--- + +## 6. 业务处理流程 + +``` + 1. 参数校验(@Valid) + ├── roadId、pressingType、lat、lon、lonLatAddress、remark 必填校验 + └── 长度/格式校验 + + 2. 道路信息校验 + ├── 根据 roadId 查询道路信息(roadApi.getRoadInfoById) + ├── 赋值 workerCompanyId、streetId、streetName + └── 道路不存在 → 抛出 APP_WORK_ROAD_NOT_EXISTS + + 3. 工单类型处理 + ├── orderType 为空 → 默认 "C" + ├── orderType = "Q" → 抛出 APP_WORK_ORADER_ERROR + └── orderType = "C"/"O" → 正常 + + 4. 养护级别赋值 + ├── 根据道路 levelId 查询字典 "conserve_level" + └── 字典值不存在 → 抛出 APP_WORKER_LEVEL_ID_NOT_EXISTS + + 5. 登录用户信息赋值 + ├── companyId、deptId ← SecurityFrameworkUtils + ├── userId、userName ← 当前登录用户 + ├── 角色列表 ← roleApi(不能为空) + └── 角色为空 → 抛出 APP_USER_ROLE_NOT_EXISTS + + 6. 来源处理(默认值) + ├── sourceId 为空 → EventSourceEnum.PATROL(1) + └── sourceName 为空 → "巡查" + + 7. 业务线处理 + └── busiLine 为空 → 取登录用户所属业务线 + + 8. 工单编号生成 + └── workOrderApi.generateWONum("GWO") + + 9. 工单保存 + └── workOrderMapper.insert → workorder_main_info 表 + +10. 附件保存(条件) + └── problemsImgs 非空 → 写入 attachment 表 + +11. 发起 BPM 流程 + ├── 流程 Key: workorder_common_prod + ├── 变量: roadId、applyUserId、isNeed + ├── 审批人: 按 busiLine -> roleCode -> roadId 查找班组长 + └── 人员为空 → 抛出 TEAM_LEADER_YL_NOT_EXISTS + +12. 回写流程实例 ID + └── processInstanceId 更新到工单记录 + +13. 返回工单 ID +``` + +--- + +## 7. 写入数据表 + +### 7.1 `workorder_main_info`(工单主表) + +| 字段 | 来源 | 说明 | +|------|------|------| +| `order_no` | 自动生成(GWO 前缀) | 工单编号 | +| `status` | `1`(RUNNING) | 审批中 | +| `buz_status` | `"000"` | 业务初始化状态 | +| `order_type` | 请求参数/默认 `"C"` | 工单类型 | +| `busi_line` | 请求参数/登录用户 | 业务线 | +| `road_id` | 请求参数 | 道路 ID | +| `worker_company_id` | 道路查询结果 | 道路负责部门 | +| `street_id` / `street_name` | 道路查询结果 | 街道信息 | +| `curing_level_id` / `curing_level_name` | 字典查询 | 养护级别 | +| `company_id` | 登录用户 | 提交人部门 | +| `dept_id` | 登录用户 | 部门 ID | +| `user_id` / `user_name` | 登录用户 | 提交人 | +| `source_id` / `source_name` | 请求/默认值 | 工单来源 | +| `wo_source_id` / `wo_source_name` | 角色信息 | 来源编码 | +| `commit_date` | `LocalDateTime.now()` | 提交时间 | +| `process_instance_id` | BPM 流程实例 ID | 工作流编号 | + +### 7.2 `attachment`(附件表,条件写入) + +仅当 `problemsImgs` 不为空时写入,`busiType = "01"`。 + +--- + +## 8. 对接注意事项 + +### 8.1 调用前准备 + +1. **获取 Token**:先调用登录接口获取有效的 Bearer Token +2. **确认道路 ID**:`roadId` 必须是系统中已配置的道路 +3. **确认人员配置**:对应道路 + 业务线必须有班组长角色人员 + +### 8.2 限制与约束 + +| 约束 | 说明 | +|------|------| +| 不支持快速工单 | `orderType="Q"` 会报错,请用 `/createQuick` | +| 无幂等保证 | 同一请求重复调用会创建多个工单 | +| 默认工单类型 | 不传 `orderType` 默认为 `"C"`(普通工单) | +| 默认来源 | 不传 `sourceId` 默认为"巡查" | +| 事务保证 | 工单保存与流程发起在同一事务中,失败整体回滚 | +| 图片不校验 | `problemsImgs` 仅保存 URL,不校验有效性 | + +### 8.3 创建成功后可用接口 + +| 操作 | 接口 | +|------|------| +| 查询工单详情 | `GET /app-api/bpm/garden/workorder/get?id={id}` | +| 工单分页查询 | `GET /app-api/bpm/garden/workorder/page` | +| 查询待办任务 | `GET /app-api/bpm/garden/workorder/todoPage` | +| 审批通过 | `PUT /app-api/bpm/garden/workorder/approve` | +| 退回任务 | `PUT /app-api/bpm/garden/workorder/return` | +| 撤回任务 | `PUT /app-api/bpm/garden/workorder/withdraw?taskId={taskId}` | + +--- + +## 9. 变更记录 + +| 版本 | 日期 | 变更内容 | 作者 | +|------|------|----------|------| +| v1.0 | 2025-12-06 | 初始版本 | yanhuiqing | +| v1.1 | 2026-06-07 | 完善对外接口文档 | — | + +--- + +> **测试环境地址**:`https://test.jichengshanshui.com.cn:28302/app-api/bpm/garden/workorder/create` +> +> **相关文档**:CLAUDE.md(项目全局约束)、BPM 流程定义文档 diff --git a/docs/全量工单统计-建表SQL.sql b/docs/全量工单统计-建表SQL.sql new file mode 100644 index 0000000..64b2346 --- /dev/null +++ b/docs/全量工单统计-建表SQL.sql @@ -0,0 +1,28 @@ +-- ============================================= +-- 全量工单统计 — 建表 SQL +-- 模块: urbanops-module-workorder +-- 日期: 2026-06-11 +-- ============================================= + +CREATE TABLE IF NOT EXISTS `workorder_full_order_statistics` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `company_id` BIGINT NOT NULL COMMENT '公司/班组ID(关联 system_dept.id)', + `company_name` VARCHAR(100) DEFAULT NULL COMMENT '公司/班组名称(冗余)', + `type` TINYINT NOT NULL DEFAULT 1 COMMENT '类型: 1=按公司, 2=按班组', + `start_date` DATE NOT NULL COMMENT '统计开始日期', + `end_date` DATE NOT NULL COMMENT '统计结束日期', + `work_order_count` INT NOT NULL DEFAULT 0 COMMENT '工单完成数量', + `inspection_count` INT NOT NULL DEFAULT 0 COMMENT '巡查记录数量', + `maintenance_count` INT NOT NULL DEFAULT 0 COMMENT '养护记录数量', + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', + `creator` VARCHAR(64) DEFAULT '' COMMENT '创建者', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间', + `updater` VARCHAR(64) DEFAULT '' COMMENT '更新者', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT DEFAULT 0 NOT NULL COMMENT '逻辑删除: 0=正常, 1=已删除', + `tenant_id` BIGINT DEFAULT 0 NOT NULL COMMENT '租户编号', + PRIMARY KEY (`id`), + INDEX `idx_company_id` (`company_id`), + INDEX `idx_type` (`type`), + INDEX `idx_date_range` (`start_date`, `end_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全量工单统计表'; diff --git a/docs/全量工单统计-菜单SQL.sql b/docs/全量工单统计-菜单SQL.sql new file mode 100644 index 0000000..1930ce5 --- /dev/null +++ b/docs/全量工单统计-菜单SQL.sql @@ -0,0 +1,27 @@ +-- ============================================= +-- 全量工单统计 — 菜单 + 按钮权限 SQL +-- 说明: 替换占位符为实际不冲突的菜单ID +-- DIR_ID = 工单管理 目录菜单ID(如不存在则先创建) +-- PAGE_ID = 全量工单 页面菜单ID +-- BTN_ID = 按钮起始ID(5个按钮连续分配) +-- ============================================= + +-- 1. 全量工单 菜单页面(type=2, parent_id=工单管理目录ID) +INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) +VALUES (PAGE_ID, '全量工单', 'workorder:full-order-statistics:query', 2, 10, DIR_ID, 'full-order-statistics', 'ep:data-analysis', 'workorder/fullorderstatistics/index', 'WorkorderFullOrderStatistics', 0, b'1', b'1', b'1', 'admin', now(), 'admin', now(), b'0'); + +-- 2. 按钮权限(type=3, parent_id=全量工单页面ID) +INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) +VALUES (BTN_ID, '全量工单查询', 'workorder:full-order-statistics:query', 3, 1, PAGE_ID, '', '', '', NULL, 0, b'1', b'1', b'1', 'admin', now(), 'admin', now(), b'0'); + +INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) +VALUES (BTN_ID+1, '全量工单新增', 'workorder:full-order-statistics:create', 3, 2, PAGE_ID, '', '', '', NULL, 0, b'1', b'1', b'1', 'admin', now(), 'admin', now(), b'0'); + +INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) +VALUES (BTN_ID+2, '全量工单修改', 'workorder:full-order-statistics:update', 3, 3, PAGE_ID, '', '', '', NULL, 0, b'1', b'1', b'1', 'admin', now(), 'admin', now(), b'0'); + +INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) +VALUES (BTN_ID+3, '全量工单删除', 'workorder:full-order-statistics:delete', 3, 4, PAGE_ID, '', '', '', NULL, 0, b'1', b'1', b'1', 'admin', now(), 'admin', now(), b'0'); + +INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, create_time, updater, update_time, deleted) +VALUES (BTN_ID+4, '全量工单导出', 'workorder:full-order-statistics:export', 3, 5, PAGE_ID, '', '', '', NULL, 0, b'1', b'1', b'1', 'admin', now(), 'admin', now(), b'0'); diff --git a/docs/开放API对接接口文档.md b/docs/开放API对接接口文档.md new file mode 100644 index 0000000..6216c18 --- /dev/null +++ b/docs/开放API对接接口文档.md @@ -0,0 +1,780 @@ +# UrbanOps 开放 API 对接接口文档 + +> **版本**: v1.0 +> **更新日期**: 2026-06-11 +> **适用对象**: 第三方系统对接开发人员 +> **鉴权方式**: HMAC-SHA256 API 签名 + +--- + +## 目录 + +1. [概述](#1-概述) +2. [签名鉴权机制](#2-签名鉴权机制) +3. [通用规范](#3-通用规范) +4. [接口 1:人员查询](#4-接口-1人员查询) +5. [接口 2:班组查询](#5-接口-2班组查询) +6. [接口 3:考勤查询](#6-接口-3考勤查询) +7. [接口 4:工单查询](#7-接口-4工单查询) +8. [接口 5:物料查询](#8-接口-5物料查询) +9. [接口 6:全量工单统计](#9-接口-6全量工单统计) +10. [错误码说明](#10-错误码说明) +11. [附录:客户端签名代码示例](#11-附录客户端签名代码示例) + +--- + +## 1. 概述 + +### 1.1 基本信息 + +| 项目 | 说明 | +|------|------| +| 接口协议 | HTTPS | +| 接口地址 | `https://{host}/open-api/` | +| 鉴权方式 | HMAC-SHA256 API 签名(详见第2节) | +| 响应格式 | JSON | +| 字符编码 | UTF-8 | + +### 1.2 接口列表 + +| # | 接口名称 | 路径 | 方法 | 说明 | +|---|---------|------|------|------| +| 1 | 人员查询 | `/open-api/staff/query` | GET | 获取人员档案,支持按部门筛选 | +| 2 | 班组查询 | `/open-api/teams/query` | GET | 获取班组信息,含班组长和人员数量 | +| 3 | 考勤查询 | `/open-api/attendance/query` | GET | 获取考勤打卡数据 | +| 4 | 工单查询 | `/open-api/tasks/query` | GET | 获取工单全生命周期数据 | +| 5 | 物料查询 | `/open-api/materials/query` | GET | 获取物料消耗与照片数据 | +| 6 | 全量工单统计 | `/open-api/full-order-statistics/query` | GET | 获取公司/班组工单/巡查/养护完成数量 | + +### 1.3 接入前提 + +接入前需向 UrbanOps 平台申请以下凭证(由运维人员配置到 Redis): + +| 参数 | 说明 | +|------|------| +| `appId` | 应用唯一标识(由平台分配) | +| `appSecret` | 应用密钥(妥善保管,不可泄露) | + +--- + +## 2. 签名鉴权机制 + +### 2.1 签名参数 + +每个请求必须携带以下 **4 个请求头**: + +| 请求头 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| `appId` | String | ✅ | 平台分配的应用 ID | +| `timestamp` | String | ✅ | 当前 Unix 时间戳(**毫秒**),与服务器时间误差不能超过 60 秒 | +| `nonce` | String | ✅ | 随机字符串,**长度 ≥ 10**,每次请求必须不同(防重放) | +| `sign` | String | ✅ | 签名字符串(SHA256 计算结果,**小写十六进制**) | + +### 2.2 签名算法 + +#### 签名字符串构造规则 + +``` +签名字符串 = queryParams(按key字母序排列的value拼接) + + headerParams(按key字母序排列的value拼接) + + appSecret +``` + +**具体步骤:** + +1. **排序 Query 参数值**:将 URL 中所有 query 参数按 key 字母升序排列,拼接所有 value +2. **拼接 Header 参数值**:按 key 字母升序(即 `appId` → `nonce` → `timestamp`),依次拼接它们的值 +3. **追加 appSecret**:在最后拼接 `appSecret` +4. **计算 SHA256**:对上述完整字符串计算 SHA256 哈希,转为**小写十六进制**字符串 + +#### 签名公式 + +``` +sign = SHA256( + sortedQueryParamValues + + appId值 + nonce值 + timestamp值 + + appSecret +) +``` + +> **注意**:GET 请求无 body,body 部分为空字符串(不参与签名)。 + +### 2.3 签名示例 + +假设: +- `appId` = `my_app_001` +- `appSecret` = `abc123xyz` +- 请求 URL:`/open-api/staff/query?deptId=100` + +**步骤拆解:** + +``` +queryParams(sorted by key): + deptId=100 + → values = "100" + +headerParams(sorted by key): + appId=my_app_001, nonce=a1b2c3d4e5f6, timestamp=1749696000000 + → values = "my_app_001" + "a1b2c3d4e5f6" + "1749696000000" + +signStr = "100" + "my_app_001" + "a1b2c3d4e5f6" + "1749696000000" + "abc123xyz" + +sign = SHA256(signStr) +``` + +### 2.4 签名校验规则 + +| 校验项 | 规则 | 失败返回 | +|--------|------|----------| +| appId 非空 | 必须传递 | `400 签名不正确` | +| timestamp 时效 | `|serverTime - timestamp| ≤ 60s` | `400 签名不正确` | +| nonce 长度 | `len(nonce) ≥ 10` | `400 签名不正确` | +| sign 非空 | 必须传递 | `400 签名不正确` | +| 签名比对 | `serverSign == clientSign` | `400 签名不正确` | +| nonce 防重放 | 同一 appId + nonce 组合只能使用一次 | `900 存在重复请求` | + +--- + +## 3. 通用规范 + +### 3.1 通用响应格式 + +```json +{ + "code": 0, + "msg": "成功", + "data": [ ... ] +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `code` | Integer | `0` 表示成功,非 `0` 表示失败 | +| `msg` | String | 提示信息 | +| `data` | Array | 业务数据列表(根据查询条件返回所有匹配记录) | + +### 3.2 日期时间格式 + +所有日期时间字段统一使用格式:`yyyy-MM-dd HH:mm:ss` + +示例:`2026-06-11 09:00:00` + +--- + +## 4. 接口 1:人员查询 + +### 4.1 基本信息 + +| 项目 | 说明 | +|------|------| +| 路径 | `/open-api/staff/query` | +| 方法 | `GET` | +| 说明 | 获取人员基础档案信息,支持按部门、姓名、手机号等条件筛选 | + +### 4.2 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | String | 否 | 姓名,模糊匹配 | +| `mobile` | String | 否 | 手机号,模糊匹配 | +| `deptId` | Long | 否 | 部门ID,精确匹配 | +| `status` | Integer | 否 | 状态:0=启用,1=禁用 | +| `busiLine` | String | 否 | 业务线:yl=园林,wy=物业,sz=市政 | + +### 4.3 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | Long | 人员ID | +| `username` | String | 用户名 | +| `nickname` | String | 姓名 | +| `mobile` | String | 手机号 | +| `deptId` | Long | 部门ID | +| `deptName` | String | 部门名称(如"养护一班") | +| `postIds` | Array\ | 岗位ID列表 | +| `postNames` | String | 岗位名称(逗号分隔,如"班组长,技术员") | +| `busiLine` | String | 业务线 | +| `status` | Integer | 状态(0=启用,1=禁用) | +| `cardNumber` | String | 工卡号(设备编号,用于考勤打卡) | +| `isInner` | Integer | 是否内部人员(1=内部,2=外部) | +| `createTime` | String | 入职日期(格式:yyyy-MM-dd HH:mm:ss) | + +### 4.4 请求示例 + +```bash +curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/staff/query?deptId=100" \ + -H "appId: my_app_001" \ + -H "timestamp: 1749696000000" \ + -H "nonce: a1b2c3d4e5f6g7h8" \ + -H "sign: 3a5f8c9e2b1d4f6a8c0e2b4d6f8a0c2e4b6d8f0a" +``` + +### 4.5 响应示例 + +```json +{ + "code": 0, + "msg": "成功", + "data": [ + { + "id": 1001, + "username": "zhangsan", + "nickname": "张三", + "mobile": "13800138001", + "deptId": 100, + "deptName": "养护一班", + "postIds": [10, 20], + "postNames": "班组长,技术员", + "busiLine": "yl", + "status": 0, + "cardNumber": "DEV001", + "isInner": 1, + "createTime": "2024-01-15 00:00:00" + } + ], + "total": 150 + } +``` + +--- + +## 5. 接口 2:班组查询 + +### 5.1 基本信息 + +| 项目 | 说明 | +|------|------| +| 路径 | `/open-api/teams/query` | +| 方法 | `GET` | +| 说明 | 获取组织架构班组信息,含班组长、所属养护所、人员数量 | + +### 5.2 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | String | 否 | 班组名称,模糊匹配 | +| `parentId` | Long | 否 | 父部门ID(上级组织),精确匹配 | +| `status` | Integer | 否 | 状态:0=启用,1=禁用 | + +### 5.3 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | Long | 班组ID | +| `name` | String | 班组名称 | +| `parentId` | Long | 父部门ID | +| `parentName` | String | 父部门名称(所属养护所) | +| `leaderUserId` | Long | 班组长人员ID(Staff.id) | +| `leaderUserName` | String | 班组长姓名 | +| `memberCount` | Long | 人员数量 | +| `sort` | Integer | 排序 | +| `status` | Integer | 状态(0=启用,1=禁用) | + +### 5.4 请求示例 + +```bash +curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/teams/query" \ + -H "appId: my_app_001" \ + -H "timestamp: 1749696000000" \ + -H "nonce: b2c3d4e5f6g7h8i9" \ + -H "sign: ..." +``` + +### 5.5 响应示例 + +```json +{ + "code": 0, + "msg": "成功", + "data": [ + { + "id": 100, + "name": "养护一班", + "parentId": 1, + "parentName": "蓟城山水集团", + "leaderUserId": 1001, + "leaderUserName": "张三", + "memberCount": 15, + "sort": 1, + "status": 0 + } + ], + "total": 12 + } +} +``` + +--- + +## 6. 接口 3:考勤查询 + +### 6.1 基本信息 + +| 项目 | 说明 | +|------|------| +| 路径 | `/open-api/attendance/query` | +| 方法 | `GET` | +| 说明 | 获取电子围栏打卡与位置数据,支持按班组、人员、时间范围筛选 | + +### 6.2 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `userId` | Long | 否 | 人员ID,精确匹配 | +| `deptId` | Long | 否 | 部门ID(班组),精确匹配 | +| `busiLine` | String | 否 | 业务线 | +| `punchType` | String | 否 | 打卡类型(上班/下班) | +| `startTime` | String | 否 | 开始时间(格式:yyyy-MM-dd HH:mm:ss) | +| `endTime` | String | 否 | 结束时间(格式:yyyy-MM-dd HH:mm:ss) | + +### 6.3 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | Integer | 记录ID | +| `deviceCode` | String | 设备编码 | +| `deviceName` | String | 设备名称 | +| `punchType` | String | 打卡类型 | +| `userId` | Long | 人员ID | +| `nickname` | String | 人员姓名 | +| `deptId` | Long | 部门ID | +| `deptName` | String | 部门名称 | +| `address` | String | 打卡地址 | +| `latGcj02` | BigDecimal | 高德纬度(GCJ-02坐标系) | +| `lngGcj02` | BigDecimal | 高德经度(GCJ-02坐标系) | +| `lat` | BigDecimal | WGS84纬度 | +| `lng` | BigDecimal | WGS84经度 | +| `createTime` | String | 打卡时间 | + +> **注意**:当前版本暂不支持"轨迹点集""是否佩戴工卡""记录仪状态"字段(数据库无对应存储)。待业务系统确认后将补充。 + +### 6.4 响应示例 + +```json +{ + "code": 0, + "msg": "成功", + "data": [ + { + "id": 1, + "deviceCode": "DEV001", + "deviceName": "打卡机01", + "punchType": "上班", + "userId": 1001, + "nickname": "张三", + "deptId": 100, + "deptName": "养护一班", + "address": "北京市西城区xx路xx号", + "latGcj02": 39.9042, + "lngGcj02": 116.4074, + "lat": 39.9010, + "lng": 116.4030, + "createTime": "2026-06-11 08:30:00" + } + ], + "total": 320 + } +} +``` + +--- + +## 7. 接口 4:工单查询 + +### 7.1 基本信息 + +| 项目 | 说明 | +|------|------| +| 路径 | `/open-api/tasks/query` | +| 方法 | `GET` | +| 说明 | 获取工单全生命周期数据,含发布时间、状态、接单渠道、共同处理人 | + +### 7.2 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `orderNo` | String | 否 | 工单号,模糊匹配 | +| `orderName` | String | 否 | 工单名称,模糊匹配 | +| `busiLine` | String | 否 | 业务线 | +| `status` | Integer | 否 | 审批状态 | +| `buzStatus` | String | 否 | 业务状态 | +| `workerId` | Long | 否 | 接单人ID | +| `startDate` | String | 否 | 发布时间起始(yyyy-MM-dd HH:mm:ss) | +| `endDate` | String | 否 | 发布时间截止(yyyy-MM-dd HH:mm:ss) | + +### 7.3 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | Long | 工单ID | +| `orderNo` | String | 工单号 | +| `orderName` | String | 工单名称 | +| `orderType` | String | 工单类型:Q=快速,C=普通,O=其他 | +| `busiLine` | String | 业务线 | +| `commitDate` | String | 发布时间(工单派发时间) | +| `finishDate` | String | 完成时间 | +| `userId` | Long | 发布人ID | +| `userName` | String | 发布人姓名 | +| `workerId` | Long | 接单人ID | +| `workerName` | String | 接单人姓名 | +| `coHandlers` | Array\ | 共同处理人姓名列表 | +| `woSourceName` | String | 接单渠道(小程序/人工派单等) | +| `status` | Integer | 审批状态 | +| `buzStatus` | String | 业务状态 | +| `remark` | String | 备注 | +| `photos` | Array\ | 工单附件照片 | + +**PhotoVO 结构:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `busiType` | String | 业务类型:`01`=问题照片,`02`=处理中,`03`=完成 | +| `fileName` | String | 文件名 | +| `url` | String | 文件URL | + +### 7.4 响应示例 + +```json +{ + "code": 0, + "msg": "成功", + "data": [ + { + "id": 5001, + "orderNo": "WO202606110001", + "orderName": "树木修剪", + "orderType": "C", + "busiLine": "yl", + "commitDate": "2026-06-11 09:00:00", + "finishDate": null, + "userId": 1, + "userName": "管理员", + "workerId": 1001, + "workerName": "张三", + "coHandlers": ["李四", "王五"], + "woSourceName": "小程序", + "status": 1, + "buzStatus": "processing", + "remark": "紧急处理", + "photos": [ + { + "busiType": "01", + "fileName": "problem.jpg", + "url": "https://xxx.com/file/abc.jpg" + } + ] + } + ], + "total": 85 + } +} +``` + +--- + +## 8. 接口 5:物料查询 + +### 8.1 基本信息 + +| 项目 | 说明 | +|------|------| +| 路径 | `/open-api/materials/query` | +| 方法 | `GET` | +| 说明 | 获取物料消耗记录与人机材照片数据,支持按工单筛选 | + +### 8.2 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `orderNo` | String | 否 | 工单号,精确匹配 | +| `classifyName` | String | 否 | 物料分类名,模糊匹配 | +| `typeName` | String | 否 | 物料类型名,模糊匹配 | +| `userId` | Long | 否 | 使用人ID | +| `startDate` | String | 否 | 开始时间(yyyy-MM-dd HH:mm:ss) | +| `endDate` | String | 否 | 结束时间(yyyy-MM-dd HH:mm:ss) | + +### 8.3 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | Long | 记录ID | +| `orderNo` | String | 关联工单号 | +| `materialName` | String | 物料名称 | +| `specifications` | String | 规格 | +| `userCount` | Long | 使用数量 | +| `unitName` | String | 单位 | +| `classifyName` | String | 物料分类 | +| `typeName` | String | 物料类型 | +| `typeDetailName` | String | 物料明细类型 | +| `userId` | Long | 使用人ID | +| `userName` | String | 使用人姓名 | +| `source` | Integer | 来源:1=工单,2=巡检,3=养护 | +| `createTime` | String | 上传时间 | +| `photos` | Array\ | 人机材照片(结构同工单接口) | + +### 8.4 响应示例 + +```json +{ + "code": 0, + "msg": "成功", + "data": [ + { + "id": 3001, + "orderNo": "WO202606110001", + "materialName": "高效氯氰菊酯", + "specifications": "500ml/瓶", + "userCount": 5, + "unitName": "瓶", + "classifyName": "药品", + "typeName": "杀虫剂", + "typeDetailName": "有机磷", + "userId": 1001, + "userName": "张三", + "source": 1, + "createTime": "2026-06-11 14:30:00", + "photos": [ + { + "busiType": "02", + "fileName": "material.jpg", + "url": "https://xxx.com/file/def.jpg" + } + ] + } + ], + "total": 45 + } +} +``` + +--- + +## 9. 接口 6:全量工单统计 + +### 9.1 基本信息 + +| 项目 | 说明 | +|------|------| +| 路径 | `/open-api/full-order-statistics/query` | +| 方法 | `GET` | +| 说明 | 获取各公司/班组在一段时间内实际完成的工单数量、巡查记录数量、养护记录数量 | + +### 9.2 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `companyId` | Long | 否 | 公司/班组ID,精确匹配 | +| `companyName` | String | 否 | 公司/班组名称,模糊匹配 | +| `type` | Integer | 否 | 类型:1=按公司,2=按班组 | +| `startDate` | String | 否 | 统计开始日期(格式:yyyy-MM-dd,返回开始日期>=该值的记录) | +| `endDate` | String | 否 | 统计结束日期(格式:yyyy-MM-dd,返回结束日期<=该值的记录) | + +### 9.3 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | Long | 记录ID | +| `companyId` | Long | 公司/班组ID | +| `companyName` | String | 公司/班组名称 | +| `type` | Integer | 类型:1=按公司,2=按班组 | +| `startDate` | String | 统计开始日期 | +| `endDate` | String | 统计结束日期 | +| `workOrderCount` | Integer | 工单完成数量 | +| `inspectionCount` | Integer | 巡查记录数量 | +| `maintenanceCount` | Integer | 养护记录数量 | +| `remark` | String | 备注 | +| `createTime` | String | 创建时间 | + +### 9.4 请求示例 + +```bash +curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/full-order-statistics/query?type=1&startDate=2026-06-01&endDate=2026-06-30" \ + -H "appId: my_app_001" \ + -H "timestamp: 1749696000000" \ + -H "nonce: f6g7h8i9j0k1l2m3" \ + -H "sign: ..." +``` + +### 9.5 响应示例 + +```json +{ + "code": 0, + "msg": "成功", + "data": [ + { + "id": 1, + "companyId": 100, + "companyName": "养护一班", + "type": 1, + "startDate": "2026-06-01", + "endDate": "2026-06-30", + "workOrderCount": 85, + "inspectionCount": 120, + "maintenanceCount": 60, + "remark": "二季度统计", + "createTime": "2026-06-11 10:00:00" + } + ] +} +``` + +--- + +## 10. 错误码说明 + +### 10.1 签名相关错误 + +### 10.2 业务相关错误 + +| code | msg | 说明 | +|------|-----|------| +| 400 | 签名不正确 | 签名验证失败(appId不存在/timestamp超时/nonce长度不足/签名不匹配) | +| 900 | 存在重复请求 | nonce 已被使用(重放攻击检测) | + +### 10.3 通用错误 + +| code | msg | 说明 | +|------|-----|------| +| 1-100-020-001 | 人员查询失败 | 人员查询内部错误 | +| 1-100-020-002 | 班组查询失败 | 班组查询内部错误 | +| 1-100-020-003 | 考勤查询失败 | 考勤查询内部错误 | +| 1-100-020-004 | 工单查询失败 | 工单查询内部错误 | +| 1-100-020-005 | 物料查询失败 | 物料查询内部错误 | + +| 1-900-005-001 | 全量工单统计不存在 | 记录不存在 | +| 1-900-005-002 | 相同公司/班组同时间段统计已存在 | 重复数据校验 | + +### 10.3 通用错误 + +| code | msg | 说明 | +|------|-----|------| +| 400 | 参数校验失败 | 请求参数格式或值不正确 | +| 500 | 系统内部错误 | 服务器内部异常 | + +--- + +## 11. 附录:客户端签名代码示例 + +### 10.1 Java 示例 + +```java +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.core.util.RandomUtil; + +import java.util.Map; +import java.util.TreeMap; + +public class OpenApiClient { + + private static final String BASE_URL = "https://test.jichengshanshui.com.cn:28302"; + private static final String APP_ID = "your_app_id"; + private static final String APP_SECRET = "your_app_secret"; + + public static String call(String path, Map params) { + String timestamp = String.valueOf(System.currentTimeMillis()); + String nonce = RandomUtil.randomString(16); + String sign = buildSign(params, timestamp, nonce); + + String url = BASE_URL + path; + HttpResponse response = HttpRequest.get(url) + .form(params) + .header("appId", APP_ID) + .header("timestamp", timestamp) + .header("nonce", nonce) + .header("sign", sign) + .execute(); + return response.body(); + } + + private static String buildSign(Map params, + String timestamp, String nonce) { + StringBuilder sb = new StringBuilder(); + + // 1. 排序 query 参数值 + if (params != null && !params.isEmpty()) { + new TreeMap<>(params).values().stream() + .map(String::valueOf).forEach(sb::append); + } + + // 2. header 参数值(appId, nonce, timestamp 字母序) + sb.append(APP_ID); + sb.append(nonce); + sb.append(timestamp); + + // 3. 追加 appSecret + SHA256 + sb.append(APP_SECRET); + return DigestUtil.sha256Hex(sb.toString()); + } +} +``` + +### 10.2 Python 示例 + +```python +import hashlib +import time +import random +import string +import requests + +BASE_URL = "https://test.jichengshanshui.com.cn:28302" +APP_ID = "your_app_id" +APP_SECRET = "your_app_secret" + +def build_sign(params, timestamp, nonce): + parts = [] + if params: + for key in sorted(params.keys()): + parts.append(str(params[key])) + parts.append(APP_ID) + parts.append(nonce) + parts.append(timestamp) + parts.append(APP_SECRET) + sign_str = "".join(parts) + return hashlib.sha256(sign_str.encode()).hexdigest() + +def call(path, params=None): + if params is None: + params = {} + timestamp = str(int(time.time() * 1000)) + nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=16)) + sign = build_sign(params, timestamp, nonce) + headers = { + "appId": APP_ID, + "timestamp": timestamp, + "nonce": nonce, + "sign": sign + } + response = requests.get(BASE_URL + path, params=params, headers=headers) + return response.json() +``` + +### 10.3 Shell (cURL) 示例 + +```bash +#!/bin/bash +APP_ID="your_app_id" +APP_SECRET="your_app_secret" +TIMESTAMP=$(date +%s%3N) +NONCE=$(openssl rand -hex 16) + +# 示例:/open-api/staff/query?deptId=100 +# query params 排序后 values: deptId=100 → "100" +QUERY_VALUES="100" +HEADER_VALUES="${APP_ID}${NONCE}${TIMESTAMP}" +SIGN_STR="${QUERY_VALUES}${HEADER_VALUES}${APP_SECRET}" +SIGN=$(echo -n "$SIGN_STR" | openssl dgst -sha256 -hex | awk '{print $2}') + +curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/staff/query?deptId=100" \ + -H "appId: ${APP_ID}" \ + -H "timestamp: ${TIMESTAMP}" \ + -H "nonce: ${NONCE}" \ + -H "sign: ${SIGN}" +``` + +--- + +> **本文档基于 2026-06-11 代码生成,如有接口变更请同步更新。** diff --git a/docs/开放API对接接口文档.pdf b/docs/开放API对接接口文档.pdf new file mode 100644 index 0000000..58e900a Binary files /dev/null and b/docs/开放API对接接口文档.pdf differ diff --git a/docs/系统鉴权方式说明文档.md b/docs/系统鉴权方式说明文档.md new file mode 100644 index 0000000..8bd8ff8 --- /dev/null +++ b/docs/系统鉴权方式说明文档.md @@ -0,0 +1,2001 @@ +# UrbanOps 系统鉴权方式说明文档 + +> **项目**: 蓟城山水集团全域智能运营管理平台 (UrbanOps) +> **版本**: 基于 2026-06-11 代码分析 +> **适用对象**: 前端开发、第三方对接、运维部署 + +--- + +## 目录 + +- [1. 鉴权体系概览](#1-鉴权体系概览) +- [2. Token 令牌鉴权(核心机制)](#2-token-令牌鉴权核心机制) +- [3. 用户名密码登录鉴权](#3-用户名密码登录鉴权) +- [4. 短信验证码登录鉴权](#4-短信验证码登录鉴权) +- [5. OAuth2 授权服务器](#5-oauth2-授权服务器) +- [6. API 签名鉴权(第三方对接)](#6-api-签名鉴权第三方对接) +- [7. 社交登录鉴权(JustAuth)](#7-社交登录鉴权justauth) +- [8. UAA SSO 单点登录](#8-uaa-sso-单点登录) +- [9. RBAC 权限鉴权](#9-rbac-权限鉴权) +- [10. 租户隔离鉴权](#10-租户隔离鉴权) +- [11. 数据权限鉴权(行级安全)](#11-数据权限鉴权行级安全) +- [12. API 加密](#12-api-加密) +- [13. 接口前缀与鉴权方式对照表](#13-接口前缀与鉴权方式对照表) + +--- + +## 1. 鉴权体系概览 + +UrbanOps 采用 **多层鉴权架构**,从请求进入系统到数据返回,依次经过以下安全层级: + +``` +请求到达 + │ + ├── 第0层: API 加密过滤 (ApiEncryptFilter) ← 可选,按 @ApiEncrypt 注解启用 + │ + ├── 第1层: 租户隔离 (TenantSecurityWebFilter) ← /admin-api/, /app-api/ 强制 + │ + ├── 第2层: Token 令牌校验 (TokenAuthenticationFilter) ← /admin-api/, /app-api/ 强制 + │ + ├── 第3层: 方法级权限 (Spring Security @PreAuthorize) ← /admin-api/ 方法级 + │ + └── 第4层: 数据权限 (DeptDataPermissionRule) ← /admin-api/ MyBatis SQL 注入 +``` + +--- + +## 2. Token 令牌鉴权(核心机制) + +### 2.1 概述 + +Token 令牌鉴权是系统的核心认证方式,覆盖所有 `/admin-api/` 和 `/app-api/` 接口。系统通过自定义 `TokenAuthenticationFilter` 拦截每个请求,提取并验证 Token。 + +### 2.2 核心类 + +| 类名 | 路径 | +|------|------| +| `TokenAuthenticationFilter` | `urbanops-framework/urbanops-spring-boot-starter-security/src/main/java/com/zteits/urbanops/framework/security/core/filter/TokenAuthenticationFilter.java` | +| `SecurityProperties` | `urbanops-framework/urbanops-spring-boot-starter-security/src/main/java/com/zteits/urbanops/framework/security/config/SecurityProperties.java` | +| `OAuth2TokenServiceImpl` | `urbanops-module-system/src/main/java/com/zteits/urbanops/module/system/service/oauth2/OAuth2TokenServiceImpl.java` | +| `SecurityFrameworkUtils` | `urbanops-framework/urbanops-spring-boot-starter-security/src/main/java/com/zteits/urbanops/framework/security/core/util/SecurityFrameworkUtils.java` | + +### 2.3 配置项 + +```yaml +# application.yaml +urbanops: + security: + token-header: Authorization # 请求头名称 + token-parameter: token # Query参数名称(WebSocket场景用) + mock-enable: false # 开发环境Mock模式开关 + mock-secret: test # Mock模式密钥前缀 + password-encoder-length: 4 # BCrypt加密强度 +``` + +### 2.4 工作流程 + +``` +客户端请求(带 Authorization: Bearer {token}) + │ + ▼ +TokenAuthenticationFilter.doFilterInternal() + │ + ├── 1. 提取 Token + │ 方式A: 从请求头 Authorization 提取 + │ 方式B: 从查询参数 ?token=xxx 提取(WebSocket 场景) + │ + ├── 2. 推断 userType + │ /admin-api/ → ADMIN(2) + │ /app-api/ → MEMBER(1) + │ + ├── 3. 调用 oauth2TokenApi.checkAccessToken(token) 校验 + │ 先查 Redis(key: oauth2_access_token:{token}) + │ 缓存未命中则查 MySQL(system_oauth2_access_token 表) + │ + ├── 4. 校验 userType 匹配 + │ 防止 admin token 访问 app 接口(反之亦然) + │ + ├── 5. 构建 LoginUser 对象 + │ 包含 userId, userType, tenantId, scopes, userInfo + │ + └── 6. 设置到 SecurityContextHolder + 后续 Controller 可通过 SecurityFrameworkUtils.getLoginUser() 获取 +``` + +### 2.5 实现代码示例 + +**Token 提取与验证核心代码:** `TokenAuthenticationFilter.java` + +```java +@RequiredArgsConstructor +public class TokenAuthenticationFilter extends OncePerRequestFilter { + + private final SecurityProperties securityProperties; + private final GlobalExceptionHandler globalExceptionHandler; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + // 从请求头或查询参数中提取 Token + String token = SecurityFrameworkUtils.obtainAuthorization( + request, securityProperties.getTokenHeader(), + securityProperties.getTokenParameter()); + + if (StrUtil.isNotEmpty(token)) { + Integer userType = WebFrameworkUtils.getLoginUserType(request); + try { + // 1. 基于 token 构建登录用户 + LoginUser loginUser = buildLoginUserByToken(token, userType); + // 2. 模拟登录(开发环境 fallback) + if (loginUser == null) { + loginUser = mockLoginUser(request, token, userType); + } + // 3. 设置当前用户到 SecurityContext + if (loginUser != null) { + SecurityFrameworkUtils.setLoginUser(loginUser, request); + } + } catch (Throwable ex) { + // Token 非法或过期,直接返回错误 JSON,中断过滤器链 + CommonResult result = globalExceptionHandler.allExceptionHandler(request, ex); + ServletUtils.writeJSON(response, result); + return; + } + } + // 4. 继续执行后续过滤器 + chain.doFilter(request, response); + } + + private LoginUser buildLoginUserByToken(String token, Integer userType) { + // 调用内部 OAuth2 服务校验 token + OAuth2AccessTokenCheckRespDTO accessToken = oauth2TokenApi.checkAccessToken(token); + if (accessToken == null) { + return null; + } + // 校验用户类型匹配 + if (ObjectUtil.notEqual(accessToken.getUserType(), userType)) { + throw new AccessDeniedException("错误的用户类型"); + } + // 构建 LoginUser + LoginUser loginUser = new LoginUser(); + loginUser.setId(accessToken.getUserId()); + loginUser.setUserType(accessToken.getUserType()); + loginUser.setTenantId(accessToken.getTenantId()); + loginUser.setScopes(accessToken.getScopes()); + loginUser.setContext(accessToken.getUserInfo()); + return loginUser; + } +} +``` + +**Token 生成核心代码:** `OAuth2TokenServiceImpl.java` + +```java +@Service +public class OAuth2TokenServiceImpl implements OAuth2TokenService { + + @Resource + private OAuth2AccessTokenMapper oauth2AccessTokenMapper; + @Resource + private OAuth2AccessTokenRedisDAO oauth2AccessTokenRedisDAO; + @Resource + private OAuth2RefreshTokenMapper oauth2RefreshTokenMapper; + + @Override + public OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, + String clientId, List scopes) { + OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientId); + // 生成 Token(UUID 无连字符) + OAuth2AccessTokenDO accessToken = new OAuth2AccessTokenDO(); + accessToken.setAccessToken(IdUtil.fastSimpleUUID()); // 32位十六进制 + accessToken.setRefreshToken(IdUtil.fastSimpleUUID()); + accessToken.setUserId(userId); + accessToken.setUserType(userType); + accessToken.setClientId(clientId); + accessToken.setScopes(scopes); + accessToken.setExpiresTime(LocalDateTime.now() + .plusSeconds(client.getAccessTokenValiditySeconds())); + // 双重存储:MySQL + Redis + oauth2AccessTokenMapper.insert(accessToken); + oauth2AccessTokenRedisDAO.set(accessToken); + return accessToken; + } + + @Override + public OAuth2AccessTokenCheckRespDTO checkAccessToken(String accessToken) { + // 先从 Redis 缓存获取(高性能) + OAuth2AccessTokenDO accessTokenDO = oauth2AccessTokenRedisDAO.get(accessToken); + if (accessTokenDO == null) { + // 缓存未命中,查 MySQL 兜底 + accessTokenDO = oauth2AccessTokenMapper.selectByAccessToken(accessToken); + if (accessTokenDO != null) { + // 回写 Redis 缓存 + oauth2AccessTokenRedisDAO.set(accessTokenDO); + } + } + if (accessTokenDO == null || isExpired(accessTokenDO)) { + throw exception(OAUTH2_ACCESS_TOKEN_NOT_FOUND_OR_EXPIRED); + } + return convertToCheckDTO(accessTokenDO); + } +} +``` + +### 2.6 调用示例 + +```bash +# 请求管理后台接口(需要在请求头带上 Token) +curl -X GET "https://test.jichengshanshui.com.cn:28302/admin-api/system/dept/list" \ + -H "Authorization: Bearer a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" + +# 返回成功示例 +{ + "code": 0, + "msg": "成功", + "data": { ... } +} + +# Token 无效返回示例 +{ + "code": 401, + "msg": "访问令牌不存在或已过期" +} +``` + +--- + +## 3. 用户名密码登录鉴权 + +### 3.1 概述 + +系统提供标准的用户名+密码登录方式,通过 BCrypt 密码比对验证用户身份,登录成功后返回 accessToken 和 refreshToken。 + +### 3.2 核心类 + +| 类名 | 路径 | +|------|------| +| `AuthController` | `urbanops-module-system/src/main/java/com/zteits/urbanops/module/system/controller/admin/auth/AuthController.java` | +| `AdminAuthServiceImpl` | `urbanops-module-system/src/main/java/com/zteits/urbanops/module/system/service/auth/AdminAuthServiceImpl.java` | +| `BCryptPasswordEncoder` | `urbanops-framework/urbanops-spring-boot-starter-security/src/main/java/com/zteits/urbanops/framework/security/config/UrbanopsSecurityAutoConfiguration.java` | + +### 3.3 工作流程 + +``` +POST /system/auth/login { username, password } + │ + ▼ +AuthController.login() + │ + ▼ +AdminAuthService.login() + │ + ├── 1. authenticate(username, password) + │ ├── 根据 username 查询 AdminUserDO + │ ├── 如果未找到,尝试按手机号查询 + │ ├── BCrypt 比对密码(强度4) + │ ├── 检查用户状态(是否禁用) + │ └── 记录登录日志 + │ + ├── 2. 处理社交账号绑定(可选) + │ + └── 3. createTokenAfterLoginSuccess() + ├── 创建 OAuth2AccessTokenDO + ├── 存储到 MySQL + Redis + └── 返回 { accessToken, refreshToken, expiresTime } +``` + +### 3.4 实现代码示例 + +**Controller 层:** `AuthController.java` + +```java +@Tag(name = "管理后台 - 认证") +@RestController +@RequestMapping("/system/auth") +@Validated +public class AuthController { + + @Resource + private AdminAuthService authService; + + @PostMapping("/login") + @PermitAll + @Operation(summary = "使用账号密码登录") + public CommonResult login(@RequestBody @Valid AuthLoginReqVO reqVO) { + return success(authService.login(reqVO)); + } +} +``` + +**Service 实现层 — 身份验证:** `AdminAuthServiceImpl.java` + +```java +@Service +public class AdminAuthServiceImpl implements AdminAuthService { + + @Resource + private AdminUserService userService; + + /** + * 账号密码认证 + */ + public AdminUserDO authenticate(String username, String password) { + final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME; + + // 1. 校验账号是否存在(先按用户名,再按手机号) + AdminUserDO user = userService.getUserByUsername(username); + if (user == null) { + user = userService.getUserByMobile(username); + if (user == null) { + createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); + throw exception(AUTH_LOGIN_BAD_CREDENTIALS); + } + } + + // 2. BCrypt 密码比对 + if (!userService.isPasswordMatch(password, user.getPassword())) { + createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); + throw exception(AUTH_LOGIN_BAD_CREDENTIALS); + } + + // 3. 校验用户状态 + if (CommonStatusEnum.isDisable(user.getStatus())) { + createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED); + throw exception(AUTH_LOGIN_USER_DISABLED); + } + return user; + } + + /** + * 登录:认证 → 创建 Token → 返回 + */ + @Override + @DataPermission(enable = false) // 关闭数据权限(登录时无需数据过滤) + public AuthLoginRespVO login(AuthLoginReqVO reqVO) { + AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword()); + return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), + LoginLogTypeEnum.LOGIN_USERNAME); + } + + private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, + LoginLogTypeEnum logType) { + // 创建 Token + OAuth2AccessTokenDO accessToken = oauth2TokenService.createAccessToken( + userId, UserTypeEnum.ADMIN.getValue(), + OAuth2ClientConstants.CLIENT_ID_DEFAULT, null); + // 记录登录成功日志 + createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS); + // 返回结果 + return AuthConvert.INSTANCE.convert(accessToken); + } +} +``` + +### 3.5 调用示例 + +```bash +# 用户名密码登录 +curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/auth/login" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "admin", + "password": "admin123" + }' + +# 返回示例 +{ + "code": 0, + "msg": "成功", + "data": { + "accessToken": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", + "refreshToken": "p6o5n4m3l2k1j0i9h8g7f6e5d4c3b2a1", + "expiresTime": "2026-06-12T10:30:00", + "userId": 1, + "userType": 2 + } +} +``` + +--- + +## 4. 短信验证码登录鉴权 + +### 4.1 概述 + +系统支持通过短信验证码方式进行免密登录,先发送验证码到用户手机,再通过验证码完成身份认证。 + +### 4.2 核心类 + +| 类名 | 路径 | +|------|------| +| `AuthController` | `urbanops-module-system/.../controller/admin/auth/AuthController.java` | +| `AdminAuthServiceImpl` | `urbanops-module-system/.../service/auth/AdminAuthServiceImpl.java` | +| `SmsCodeApi` | `urbanops-framework/urbanops-common/.../biz/system/sms/SmsCodeApi.java` | + +### 4.3 工作流程 + +``` +① 发送验证码 +POST /system/auth/send-sms-code { mobile } + │ + ▼ +AuthController.sendSmsCode() + └── smsCodeApi.sendSmsCode(mobile, scene) + └── 发送短信 + 存储验证码到 Redis(带过期时间) + +② 验证码登录 +POST /system/auth/sms-login { mobile, code } + │ + ▼ +AuthController.smsLogin() + │ + ▼ +AdminAuthService.smsLogin() + ├── 1. smsCodeApi.useSmsCode(mobile, code) ← 校验验证码 + ├── 2. userService.getUserByMobile(mobile) ← 查找用户 + └── 3. createTokenAfterLoginSuccess() ← 创建 Token +``` + +### 4.4 实现代码示例 + +**Controller 层:** `AuthController.java` + +```java +@PostMapping("/send-sms-code") +@PermitAll +@Operation(summary = "发送手机验证码") +public CommonResult sendSmsCode(@RequestBody @Valid AuthSendSmsReqVO reqVO) { + smsCodeApi.sendSmsCode(reqVO.getMobile(), SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), + WebFrameworkUtils.getClientIP()); + return success(true); +} + +@PostMapping("/sms-login") +@PermitAll +@Operation(summary = "使用短信验证码登录") +public CommonResult smsLogin(@RequestBody @Valid AuthSmsLoginReqVO reqVO) { + return success(authService.smsLogin(reqVO)); +} +``` + +**Service 实现层:** `AdminAuthServiceImpl.java` + +```java +@Override +public AuthLoginRespVO smsLogin(AuthSmsLoginReqVO reqVO) { + // 1. 校验验证码(一次性消费,用过即删) + smsCodeApi.useSmsCode( + AuthConvert.INSTANCE.convert( + reqVO, + SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), + getClientIP() + ) + ); + + // 2. 根据手机号获取用户 + AdminUserDO user = userService.getUserByMobile(reqVO.getMobile()); + if (user == null) { + throw exception(USER_NOT_EXISTS); + } + + // 3. 创建 Token + return createTokenAfterLoginSuccess( + user.getId(), reqVO.getMobile(), LoginLogTypeEnum.LOGIN_MOBILE); +} +``` + +### 4.5 调用示例 + +```bash +# 步骤1: 发送短信验证码 +curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/auth/send-sms-code" \ + -H "Content-Type: application/json" \ + -d '{"mobile": "13800138000"}' + +# 返回 +{ "code": 0, "msg": "成功", "data": true } + +# 步骤2: 验证码登录 +curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/auth/sms-login" \ + -H "Content-Type: application/json" \ + -d '{ + "mobile": "13800138000", + "code": "123456" + }' + +# 返回(同密码登录) +{ + "code": 0, + "msg": "成功", + "data": { + "accessToken": "x1y2z3...", + "refreshToken": "z3y2x1...", + "expiresTime": "2026-06-12T10:30:00" + } +} +``` + +--- + +## 5. OAuth2 授权服务器 + +### 5.1 概述 + +系统内置完整的 OAuth2 授权服务器实现,支持 5 种标准授权模式,可同时作为授权服务和资源服务对外提供标准 OAuth2 接口。 + +### 5.2 核心类 + +| 类名 | 路径 | +|------|------| +| `OAuth2OpenController` | `urbanops-module-system/.../controller/admin/oauth2/OAuth2OpenController.java` | +| `OAuth2GrantServiceImpl` | `urbanops-module-system/.../service/oauth2/OAuth2GrantServiceImpl.java` | +| `OAuth2TokenServiceImpl` | `urbanops-module-system/.../service/oauth2/OAuth2TokenServiceImpl.java` | +| `OAuth2ClientServiceImpl` | `urbanops-module-system/.../service/oauth2/OAuth2ClientServiceImpl.java` | +| `OAuth2GrantTypeEnum` | `urbanops-module-system/.../enums/oauth2/OAuth2GrantTypeEnum.java` | + +### 5.3 支持的授权模式 + +| 授权模式 | grant_type 值 | 说明 | +|----------|--------------|------| +| 密码模式 | `password` | 直接使用用户名+密码换取 Token | +| 授权码模式 | `authorization_code` | 先获取授权码 code,再换取 Token | +| 客户端模式 | `client_credentials` | 客户端以自己的名义访问资源 | +| 刷新令牌 | `refresh_token` | 使用 refreshToken 刷新 accessToken | +| 隐式模式 | `implicit` | 通过 `/authorize` 端点直接返回 Token | + +### 5.4 OAuth2 端点 + +| 端点 | 方法 | 鉴权方式 | 说明 | +|------|------|----------|------| +| `/system/oauth2/token` | POST | `@PermitAll` (client_id + client_secret Basic Auth) | 签发 Token | +| `/system/oauth2/check-token` | POST | `@PermitAll` (client_id + client_secret Basic Auth) | 校验 Token | +| `/system/oauth2/revoke-token` | DELETE | `@PermitAll` (client_id + client_secret Basic Auth) | 撤销 Token | +| `/system/oauth2/authorize` | GET | `@PermitAll` (需已登录用户) | 授权页面(SSO 入口) | +| `/system/oauth2/authorize` | POST | `@PermitAll` (需已登录用户) | 用户确认授权 | + +### 5.5 实现代码示例 + +**`/token` 端点核心代码:** `OAuth2OpenController.java` + +```java +@Tag(name = "管理后台 - OAuth2.0") +@RestController +@RequestMapping("/system/oauth2") +@Validated +public class OAuth2OpenController { + + @Resource + private OAuth2GrantService oauth2GrantService; + @Resource + private OAuth2ClientService oauth2ClientService; + + @PostMapping("/token") + @PermitAll + @Operation(summary = "获得访问令牌", + description = "支持 authorization_code / password / client_credentials / refresh_token 四种模式") + public CommonResult postAccessToken( + HttpServletRequest request, + @RequestParam("grant_type") String grantType, + @RequestParam(value = "code", required = false) String code, + @RequestParam(value = "redirect_uri", required = false) String redirectUri, + @RequestParam(value = "state", required = false) String state, + @RequestParam(value = "username", required = false) String username, + @RequestParam(value = "password", required = false) String password, + @RequestParam(value = "scope", required = false) String scope, + @RequestParam(value = "refresh_token", required = false) String refreshToken) { + + List scopes = OAuth2Utils.buildScopes(scope); + OAuth2GrantTypeEnum grantTypeEnum = OAuth2GrantTypeEnum.getByGrantType(grantType); + + // 1. 解析 Basic Auth,获取 client_id 和 client_secret + String[] clientIdAndSecret = obtainBasicAuthorization(request); + // 2. 校验客户端合法性 + OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache( + clientIdAndSecret[0], clientIdAndSecret[1], + grantType, scopes, redirectUri); + + // 3. 根据授权模式分发处理 + OAuth2AccessTokenDO accessTokenDO; + switch (grantTypeEnum) { + case AUTHORIZATION_CODE: + accessTokenDO = oauth2GrantService.grantAuthorizationCodeForAccessToken( + client.getClientId(), code, redirectUri, state); + break; + case PASSWORD: + accessTokenDO = oauth2GrantService.grantPassword( + username, password, client.getClientId(), scopes); + break; + case CLIENT_CREDENTIALS: + accessTokenDO = oauth2GrantService.grantClientCredentials( + client.getClientId(), scopes); + break; + case REFRESH_TOKEN: + accessTokenDO = oauth2GrantService.grantRefreshToken( + refreshToken, client.getClientId()); + break; + default: + throw new IllegalArgumentException("未知授权类型:" + grantType); + } + + Assert.notNull(accessTokenDO, "访问令牌不能为空"); + return success(OAuth2OpenConvert.INSTANCE.convert(accessTokenDO)); + } + + /** + * 从请求头解析 Basic Auth 获取 client_id:client_secret + */ + private String[] obtainBasicAuthorization(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + if (StrUtil.isEmpty(header) || !header.startsWith("Basic ")) { + throw exception(ErrorCodeConstants.UNKNOWN); + } + String base64Credentials = header.substring(6); + String credentials = new String(Base64.getDecoder().decode(base64Credentials)); + return credentials.split(":", 2); + } +} +``` + +**各授权模式的实现:** `OAuth2GrantServiceImpl.java` + +```java +@Service +public class OAuth2GrantServiceImpl implements OAuth2GrantService { + + @Resource + private OAuth2TokenService oauth2TokenService; + @Resource + private AdminAuthService adminAuthService; + + /** + * 密码模式:直接用用户名密码换 Token + */ + @Override + public OAuth2AccessTokenDO grantPassword(String username, String password, + String clientId, List scopes) { + // BCrypt 验证用户名密码 + AdminUserDO user = adminAuthService.authenticate(username, password); + Assert.notNull(user, "用户不能为空!"); + return oauth2TokenService.createAccessToken( + user.getId(), UserTypeEnum.ADMIN.getValue(), clientId, scopes); + } + + /** + * 授权码模式:用 code 换取 Token + */ + @Override + public OAuth2AccessTokenDO grantAuthorizationCodeForAccessToken( + String clientId, String code, String redirectUri, String state) { + OAuth2CodeDO codeDO = oauth2CodeService.validCode(code); + Assert.notNull(codeDO, "授权码不存在"); + // 校验 clientId、redirectUri、state 是否匹配 + oauth2CodeService.validateCode(codeDO, clientId, redirectUri, state); + // 删除已使用的授权码 + oauth2CodeService.deleteCode(code); + return oauth2TokenService.createAccessToken( + codeDO.getUserId(), codeDO.getUserType(), + clientId, codeDO.getScopes()); + } + + /** + * 客户端模式:系统用户 Token(userId=0) + */ + @Override + public OAuth2AccessTokenDO grantClientCredentials(String clientId, List scopes) { + return oauth2TokenService.createAccessToken( + 0L, UserTypeEnum.ADMIN.getValue(), clientId, scopes); + } + + /** + * 刷新令牌:用 refreshToken 刷新 accessToken + */ + @Override + public OAuth2AccessTokenDO grantRefreshToken(String refreshToken, String clientId) { + return oauth2TokenService.refreshAccessToken(refreshToken, clientId); + } +} +``` + +### 5.6 调用示例 + +```bash +# 密码模式:客户端凭证 Basic Auth + 用户密码换取 Token +curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/oauth2/token" \ + -H "Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==" \ + -d "grant_type=password&username=admin&password=admin123" + +# 客户端模式:仅用客户端凭证换取 Token +curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/oauth2/token" \ + -H "Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==" \ + -d "grant_type=client_credentials" + +# 刷新令牌 +curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/oauth2/token" \ + -H "Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==" \ + -d "grant_type=refresh_token&refresh_token=abc123..." + +# 返回格式 +{ + "code": 0, + "msg": "成功", + "data": { + "access_token": "a1b2c3d4...", + "refresh_token": "p6o5n4m3...", + "token_type": "bearer", + "expires_in": 7200, + "scope": "read write" + } +} +``` + +--- + +## 6. API 签名鉴权(第三方对接) + +### 6.1 概述 + +面向 `/open-api/` 前缀的第三方系统对接接口,通过 HMAC-SHA256 请求签名机制实现无状态的接口鉴权,防止请求被篡改、重放。 + +### 6.2 核心类 + +| 类名 | 路径 | +|------|------| +| `@ApiSignature` 注解 | `urbanops-framework/urbanops-spring-boot-starter-protection/src/main/java/com/zteits/urbanops/framework/signature/core/annotation/ApiSignature.java` | +| `ApiSignatureAspect` 切面 | `urbanops-framework/urbanops-spring-boot-starter-protection/src/main/java/com/zteits/urbanops/framework/signature/core/aop/ApiSignatureAspect.java` | + +### 6.3 签名参数(请求头) + +| 请求头 | 类型 | 说明 | +|--------|------|------| +| `appId` | string | 应用唯一标识(由平台分配) | +| `timestamp` | long | 请求时间戳(毫秒) | +| `nonce` | string | 随机字符串(>= 10 位,防重放) | +| `sign` | string | 签名字符串(SHA256 结果) | + +### 6.4 签名规则 + +``` +签名字符串 = sorted(queryParams, by key) + + requestBody + + sorted({appId, timestamp, nonce} headers, by key) + + appSecret + +最终签名 = SHA256(签名字符串) +``` + +### 6.5 工作流程 + +``` +客户端请求(带 appId, timestamp, nonce, sign 请求头) + │ + ▼ +@ApiSignature 注解的方法 + │ + ▼ +ApiSignatureAspect.beforePointCut() ← @Before AOP 拦截 + │ + ├── 1. verifyHeaders() 校验请求头完整性 + │ ├── appId 非空 + │ ├── timestamp 在时间窗口内(默认 60s) + │ ├── nonce 长度 >= 10 + │ └── sign 非空 + │ + ├── 2. 从 Redis 根据 appId 获取 appSecret + │ + ├── 3. 服务端按相同规则计算签名字符串 + │ serverSignStr = sorted(queryParams) + body + sorted(headers) + appSecret + │ + ├── 4. 比对签名:SHA256(serverSignStr) == clientSign + │ + └── 5. nonce 防重放:存入 Redis(有效期 = timeout * 2) +``` + +### 6.6 实现代码示例 + +**@ApiSignature 注解定义:** + +```java +@Inherited +@Documented +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiSignature { + + /** 签名超时时间,默认 60 秒 */ + int timeout() default 60; + + /** 超时时间单位,默认秒 */ + TimeUnit timeUnit() default TimeUnit.SECONDS; + + /** 签名校验失败提示信息 */ + String message() default "签名不正确"; + + /** 应用ID 请求头字段名 */ + String appId() default "appId"; + + /** 时间戳 请求头字段名 */ + String timestamp() default "timestamp"; + + /** 随机数 请求头字段名(长度 >= 10) */ + String nonce() default "nonce"; + + /** 签名 请求头字段名 */ + String sign() default "sign"; +} +``` + +**签名校验切面实现:** `ApiSignatureAspect.java` + +```java +@Aspect +@RequiredArgsConstructor +public class ApiSignatureAspect { + + private final ApiSignatureRedisDAO signatureRedisDAO; + + /** + * @Before 拦截所有标注 @ApiSignature 的方法 + */ + @Before("@annotation(signature)") + public void beforePointCut(JoinPoint joinPoint, ApiSignature signature) { + HttpServletRequest request = ServletUtils.getRequest(); + if (!verifySignature(signature, request)) { + throw new ServiceException(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), + signature.message()); + } + } + + /** + * 完整的签名验证流程 + */ + public boolean verifySignature(ApiSignature signature, HttpServletRequest request) { + // 1. 校验请求头完整性 + if (!verifyHeaders(signature, request)) { + return false; + } + + // 2. 根据 appId 获取 appSecret + String appId = request.getHeader(signature.appId()); + String appSecret = signatureRedisDAO.getAppSecret(appId); + Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId); + + // 3. 服务端按相同规则构造签名字符串 + String serverSignatureString = buildSignatureString(signature, request, appSecret); + String serverSignature = DigestUtil.sha256Hex(serverSignatureString); + + // 4. 比对客户端签名 + String clientSignature = request.getHeader(signature.sign()); + if (ObjUtil.notEqual(clientSignature, serverSignature)) { + return false; + } + + // 5. nonce 防重放(存入 Redis,过期时间 = timeout * 2) + String nonce = request.getHeader(signature.nonce()); + if (BooleanUtil.isFalse( + signatureRedisDAO.setNonce(appId, nonce, signature.timeout() * 2, signature.timeUnit()))) { + throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), + "存在重复请求"); + } + return true; + } + + /** + * 校验请求头参数 + */ + private boolean verifyHeaders(ApiSignature signature, HttpServletRequest request) { + String appId = request.getHeader(signature.appId()); + String timestamp = request.getHeader(signature.timestamp()); + String nonce = request.getHeader(signature.nonce()); + String sign = request.getHeader(signature.sign()); + + // appId 不能为空 + if (StrUtil.isBlank(appId)) return false; + // timestamp 必须在有效时间窗口内 + if (StrUtil.isBlank(timestamp)) return false; + long ts = Long.parseLong(timestamp); + long now = System.currentTimeMillis(); + long timeoutMs = signature.timeUnit().toMillis(signature.timeout()); + if (Math.abs(now - ts) > timeoutMs) return false; + // nonce 长度必须 >= 10 + if (StrUtil.length(nonce) < 10) return false; + // sign 不能为空 + if (StrUtil.isBlank(sign)) return false; + + return true; + } + + /** + * 构造签名字符串: + * sorted(GET参数) + 请求体 + sorted(Header加签参数) + appSecret + */ + private String buildSignatureString(ApiSignature signature, HttpServletRequest request, + String appSecret) { + StringBuilder sb = new StringBuilder(); + + // 1. 排序后的查询参数 + Map paramMap = request.getParameterMap(); + if (CollUtil.isNotEmpty(paramMap)) { + TreeMap sorted = new TreeMap<>(); + paramMap.forEach((key, values) -> sorted.put(key, values[0])); + sorted.forEach((key, value) -> sb.append(value)); + } + + // 2. 请求体(POST/PUT 场景) + String body = ServletUtils.getBody(request); + if (StrUtil.isNotBlank(body)) { + sb.append(body); + } + + // 3. 排序后的加签头参数 + TreeMap headerMap = new TreeMap<>(); + headerMap.put(signature.appId(), request.getHeader(signature.appId())); + headerMap.put(signature.timestamp(), request.getHeader(signature.timestamp())); + headerMap.put(signature.nonce(), request.getHeader(signature.nonce())); + headerMap.values().forEach(sb::append); + + // 4. 追加 appSecret + sb.append(appSecret); + + return sb.toString(); + } +} +``` + +### 6.7 使用示例 + +**服务端 Controller:** + +```java +@RestController +@RequestMapping("/open-api/partner") +public class PartnerOpenController { + + @PostMapping("/data-sync") + @ApiSignature(timeout = 120, message = "签名验证失败,请检查签名参数") + public CommonResult syncData(@RequestBody PartnerDataDTO data) { + // 签名已在 AOP 层面自动验证,此处可以直接处理业务 + partnerService.syncData(data); + return success("同步成功"); + } +} +``` + +**客户端调用示例(Java):** + +```java +public class ApiSignatureClient { + + private static final String APP_ID = "your_app_id"; + private static final String APP_SECRET = "your_app_secret"; + + public static String callOpenApi(String url, String requestBody) { + // 1. 生成参数 + String timestamp = String.valueOf(System.currentTimeMillis()); + String nonce = RandomUtil.randomString(16); + + // 2. 构造签名字符串 + String signStr = requestBody + APP_ID + timestamp + nonce + APP_SECRET; + String sign = DigestUtil.sha256Hex(signStr); + + // 3. 发起请求 + HttpResponse response = HttpRequest.post(url) + .header("appId", APP_ID) + .header("timestamp", timestamp) + .header("nonce", nonce) + .header("sign", sign) + .header("Content-Type", "application/json") + .body(requestBody) + .execute(); + + return response.body(); + } +} +``` + +**cURL 调用示例:** + +```bash +# 计算签名(shell 示例) +APP_ID="my_app_001" +APP_SECRET="my_secret_key" +TIMESTAMP=$(date +%s%3N) +NONCE=$(openssl rand -hex 16) +BODY='{"name":"test","value":123}' +SIGN_STR="${BODY}${APP_ID}${TIMESTAMP}${NONCE}${APP_SECRET}" +SIGN=$(echo -n "$SIGN_STR" | openssl dgst -sha256 -hex | awk '{print $2}') + +# 发起请求 +curl -X POST "https://test.jichengshanshui.com.cn:28302/open-api/partner/data-sync" \ + -H "Content-Type: application/json" \ + -H "appId: ${APP_ID}" \ + -H "timestamp: ${TIMESTAMP}" \ + -H "nonce: ${NONCE}" \ + -H "sign: ${SIGN}" \ + -d "${BODY}" + +# 签名失败返回 +{ "code": 400, "msg": "签名验证失败,请检查签名参数" } +``` + +--- + +## 7. 社交登录鉴权(JustAuth) + +### 7.1 概述 + +系统集成 JustAuth 1.16.7,支持 30+ 第三方社交平台登录。用户可通过微信、QQ、钉钉、GitHub 等平台授权后登录系统。 + +### 7.2 核心类 + +| 类名 | 路径 | +|------|------| +| `AuthRequestFactory` | `urbanops-module-system/.../framework/justauth/core/AuthRequestFactory.java` | +| `UrbanopsJustAuthConfiguration` | `urbanops-module-system/.../framework/justauth/config/UrbanopsJustAuthConfiguration.java` | +| `RedisStateCache` | JustAuth 内置(State 存储到 Redis) | + +### 7.3 支持的平台 + +GitHub · 微信开放平台 · 微信公众号 · 微信小程序 · 企业微信 · 微信网站应用 · QQ · 微博 · 钉钉 · 支付宝 · Google · Facebook · Apple · 飞书 · 以及 30+ 其他平台 + +### 7.4 工作流程 + +``` +用户点击"社交登录" + │ + ▼ +GET /system/auth/social-auth-redirect?type={platform}&redirectUri={url} + │ + ├── AuthRequestFactory.get(type) 获取对应平台的 AuthRequest + ├── 生成授权 URL(含 state 参数,state 存入 Redis) + └── 前端 302 跳转到第三方授权页面 + │ + ▼ +用户授权后,第三方回调 redirectUri(带 code + state 参数) + │ + ▼ +前端提取 code + state,调用后端 + │ + ▼ +POST /system/auth/social-login { type, code, state } + │ + ├── 1. AuthRequestFactory.get(type).login(callback) + │ ├── getAccessToken(AuthCallback) → 用 code 换 accessToken + │ └── getUserInfo(AuthToken) → 获取用户信息 + │ + ├── 2. 根据 socialUserId 查找绑定关系 + ├── 3. 自动注册 / 绑定已有用户 + └── 4. createTokenAfterLoginSuccess() +``` + +### 7.5 实现代码示例 + +**JustAuth 配置类:** `UrbanopsJustAuthConfiguration.java` + +```java +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties({JustAuthProperties.class}) +public class UrbanopsJustAuthConfiguration { + + /** + * 创建 AuthRequest 工厂(条件 Bean:justauth.enabled=true 时激活) + */ + @Bean + @ConditionalOnProperty(prefix = "justauth", value = {"enabled"}, + havingValue = "true", matchIfMissing = true) + public AuthRequestFactory authRequestFactory(JustAuthProperties properties, + AuthStateCache authStateCache) { + return new AuthRequestFactory(properties, authStateCache); + } + + /** + * OAuth2 State 参数缓存(Redis 存储,防 CSRF) + */ + @Bean + public AuthStateCache authStateCache( + RedisTemplate justAuthRedisCacheTemplate, + JustAuthProperties justAuthProperties) { + return new RedisStateCache(justAuthRedisCacheTemplate, + justAuthProperties.getCache()); + } +} +``` + +**社交登录请求工厂:** `AuthRequestFactory.java` + +```java +public class AuthRequestFactory { + + private final JustAuthProperties properties; + private final AuthStateCache authStateCache; + private final ConcurrentHashMap requestCache = new ConcurrentHashMap<>(); + + /** + * 获取指定平台的授权请求对象 + */ + public AuthRequest get(String source) { + return requestCache.computeIfAbsent(source, this::getDefaultRequest); + } + + /** + * 根据平台名称创建对应的 JustAuth 请求类 + */ + private AuthRequest getDefaultRequest(String source) { + AuthConfig config = properties.getType().get(source); + Assert.notNull(config, "平台 [{}] 的配置不存在", source); + + // 注入自定义 state 缓存(Redis) + config.setAuthStateCache(authStateCache); + + switch (source.toUpperCase()) { + case "GITHUB": + return new AuthGithubRequest(config, authStateCache); + case "WECHAT_OPEN": + return new AuthWeChatOpenRequest(config, authStateCache); + case "WECHAT_MP": + return new AuthWeChatMpRequest(config, authStateCache); + case "QQ": + return new AuthQqRequest(config, authStateCache); + case "DINGTALK": + return new AuthDingTalkRequest(config, authStateCache); + case "FEISHU": + return new AuthFeishuRequest(config, authStateCache); + case "ALIPAY": + return new AuthAlipayRequest(config, authStateCache); + case "GOOGLE": + return new AuthGoogleRequest(config, authStateCache); + case "FACEBOOK": + return new AuthFacebookRequest(config, authStateCache); + case "APPLE": + return new AuthAppleRequest(config, authStateCache); + case "UAA": + return new AuthUaaRequest(config, authStateCache); // 自定义 UAA SSO + // ... 30+ 其他平台 ... + default: + return null; + } + } +} +``` + +### 7.6 调用示例 + +```bash +# 步骤1: 获取社交登录授权 URL +curl -X GET "https://test.jichengshanshui.com.cn:28302/admin-api/system/auth/social-auth-redirect?type=GITHUB&redirectUri=https://example.com/callback" + +# 返回 +{ + "code": 0, + "data": { + "url": "https://github.com/login/oauth/authorize?client_id=xxx&redirect_uri=xxx&state=xxx" + } +} + +# 步骤2: 用户跳转到 GitHub 授权,回调到 redirectUri +# 浏览器地址栏: https://example.com/callback?code=abc123&state=xxx + +# 步骤3: 前端提取 code + state,调用后端完成登录 +curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/auth/social-login" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "GITHUB", + "code": "abc123", + "state": "xxx" + }' + +# 返回(同密码登录) +{ + "code": 0, + "msg": "成功", + "data": { + "accessToken": "token...", + "refreshToken": "refresh..." + } +} +``` + +--- + +## 8. UAA SSO 单点登录 + +### 8.1 概述 + +系统对接集团统一认证平台 UAA(Unified Authentication Application),通过自定义 `AuthUaaRequest` 实现企业级单点登录。用户登录 UAA 后即可无缝访问 UrbanOps。 + +### 8.2 核心类 + +| 类名 | 路径 | +|------|------| +| `AuthUaaRequest` | `urbanops-module-system/.../framework/justauth/core/AuthUaaRequest.java` | +| `SsoService` | `urbanops-module-system/.../service/oauth2/SsoService.java` | +| `CustomerAuthSource.UAA` | 自定义 AuthSource 枚举值 | + +### 8.3 UAA 配置 + +```yaml +sso: + client: + switch-state: true + client-id: urbanops + client-secret: ${SSO_CLIENT_SECRET} + base-url: https://uaa.fangshanparking.com:28201 + redirect-uri: ${urbanops.base-url}/admin-api/system/auth/social-login + jwt-public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A... +``` + +### 8.4 工作流程 + +``` +用户访问 UrbanOps → 未登录 → 重定向到 UAA 登录页 + │ + ▼ +UAA 登录成功后回调 redirectUri(带 authorization_code) + │ + ▼ +POST /system/auth/social-login { type: "UAA", code, state } + │ + ▼ +AuthUaaRequest + ├── getAccessToken(callback) + │ └── POST https://uaa.fangshanparking.com:28201/oauth2/token + │ code=xxx&grant_type=authorization_code&redirect_uri=xxx + │ + └── getUserInfo(authToken) + └── 使用 pub.cer 公钥解密 JWT token(RSA256) + ├── 提取 account(工号) + ├── 提取公司 ID + ├── 提取业务线 + └── 构建 AuthUser 对象 +``` + +### 8.5 实现代码示例 + +**自定义 UAA JustAuth 请求:** `AuthUaaRequest.java` + +```java +public class AuthUaaRequest extends AuthDefaultRequest { + + public AuthUaaRequest(AuthConfig config, AuthStateCache authStateCache) { + super(config, CustomerAuthSource.UAA, authStateCache); + } + + /** + * 步骤1:用 authorization_code 向 UAA 服务器换取 access_token + */ + @Override + public AuthToken getAccessToken(AuthCallback authCallback) { + String tokenUrl = source.accessToken(); + + // 构造请求体 + Map params = new HashMap<>(); + params.put("code", authCallback.getCode()); + params.put("grant_type", "authorization_code"); + params.put("redirect_uri", authCallback.getRedirectUri()); + + // POST /oauth2/token + String response = new HttpUtils(config.getHttpConfig()) + .post(tokenUrl, params, this.config.isIgnoreRedirect()); + + JSONObject json = JSONUtil.parseObj(response); + + // 校验响应 + checkResponse(json); + + return AuthToken.builder() + .accessToken(json.getStr("access_token")) + // ...其他字段... + .build(); + } + + /** + * 步骤2:从 JWT access_token 中解析用户身份信息 + */ + @Override + public AuthUser getUserInfo(AuthToken authToken) { + try { + // 从 classpath 读取 UAA 公钥证书 + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + ClassPathResource resource = new ClassPathResource("pub.cer"); + Certificate certificate = certificateFactory.generateCertificate(resource.getInputStream()); + RSAPublicKey publicKey = (RSAPublicKey) certificate.getPublicKey(); + + // 创建 JWT 验证器(RSA256 算法) + Algorithm algorithm = Algorithm.RSA256(publicKey, null); + JWTVerifier verifier = JWT.require(algorithm) + .acceptLeeway(60) // 允许 60 秒时钟偏差 + .build(); + + // 验证并解析 JWT + DecodedJWT decodedJWT = verifier.verify(authToken.getAccessToken()); + DecodedJWT jwt = JWT.decode(authToken.getAccessToken()); + + // 提取 JWT payload 中的用户信息 + String staffNo = jwt.getClaim("account").asString(); // 工号 + String companyId = jwt.getClaim("company_id").asString(); // 公司ID + String busiLine = jwt.getClaim("busi_line").asString(); // 业务线 + + return AuthUser.builder() + .uuid(staffNo) + .username(staffNo) + .nickname(staffNo) + .gender(AuthUserGender.UNKNOWN) + .token(authToken) + .source(this.source.toString()) + .build(); + + } catch (Exception e) { + throw new BusinessException("UAA SSO 登录失败:" + e.getMessage(), e); + } + } + + /** + * 校验 UAA 返回的 Token 响应 + */ + private void checkResponse(JSONObject json) { + if (json.containsKey("error")) { + throw new AuthException(json.getStr("error_description")); + } + } +} +``` + +--- + +## 9. RBAC 权限鉴权 + +### 9.1 概述 + +系统采用基于角色的访问控制(RBAC)模型,通过 Spring Security 的 `@PreAuthorize` 注解在方法级别进行权限控制。权限表达式中的 `@ss` Bean 提供 `hasPermission`、`hasRole`、`hasScope` 三个维度的权限判断。 + +### 9.2 核心类 + +| 类名 | 路径 | +|------|------| +| `SecurityFrameworkServiceImpl` (Bean name: `ss`) | `urbanops-framework/urbanops-spring-boot-starter-security/src/main/java/com/zteits/urbanops/framework/security/core/service/SecurityFrameworkServiceImpl.java` | +| `PermissionCommonApi` | `urbanops-framework/urbanops-common/src/main/java/com/zteits/urbanops/framework/common/biz/system/permission/PermissionCommonApi.java` | + +### 9.3 权限格式规范 + +``` +{模块}:{实体}:{操作} + +示例: + system:dept:create — 系统管理 · 部门 · 创建 + system:dept:update — 系统管理 · 部门 · 更新 + system:user:query — 系统管理 · 用户 · 查询 + workorder:event-info:create — 工单调度 · 事件 · 创建 + workorder:event-info:end — 工单调度 · 事件 · 结单 + bpm:garden-inspection:create— 工作流 · 园林巡检 · 创建 +``` + +### 9.4 权限方法一览 + +| SpEL 表达式 | 说明 | +|-------------|------| +| `@ss.hasPermission('perm')` | 检查单一权限 | +| `@ss.hasAnyPermissions('a','b')` | 检查任一权限(满足一个即可) | +| `@ss.hasRole('admin')` | 检查单一角色 | +| `@ss.hasAnyRoles('admin','manager')` | 检查任一角色 | +| `@ss.hasScope('read')` | 检查 OAuth2 scope | + +### 9.5 实现代码示例 + +**SecurityFrameworkServiceImpl — `@ss` Bean 实现:** + +```java +/** + * 安全框架服务实现 + * Bean 名称:ss + * 用于 @PreAuthorize("@ss.hasPermission(...)") 等表达式 + */ +@Service("ss") +public class SecurityFrameworkServiceImpl implements SecurityFrameworkService { + + @Resource + private PermissionCommonApi permissionApi; + + @Override + public boolean hasPermission(String permission) { + return hasAnyPermissions(permission); + } + + @Override + public boolean hasAnyPermissions(String... permissions) { + // 特殊场景:跨租户访问时,跳过权限校验 + if (skipPermissionCheck()) { + return true; + } + // 标准 RBAC 权限校验 + Long userId = getLoginUserId(); + if (userId == null) { + return false; + } + // 调用权限公共 API(查询用户角色 → 角色菜单 → 菜单权限标识) + return permissionApi.hasAnyPermissions(userId, permissions); + } + + @Override + public boolean hasAnyRoles(String... roles) { + if (skipPermissionCheck()) { + return true; + } + Long userId = getLoginUserId(); + if (userId == null) { + return false; + } + return permissionApi.hasAnyRoles(userId, roles); + } + + @Override + public boolean hasAnyScopes(String... scope) { + if (skipPermissionCheck()) { + return true; + } + LoginUser user = SecurityFrameworkUtils.getLoginUser(); + if (user == null) { + return false; + } + // 检查 LoginUser 的 scopes 列表中是否包含目标 scope + return CollUtil.containsAny(user.getScopes(), Arrays.asList(scope)); + } + + /** + * 跨租户访问时自动跳过权限检查 + * 逻辑:如果当前请求租户ID != 用户所属租户ID,说明是跨租户访问,自动放行 + */ + private boolean skipPermissionCheck() { + LoginUser user = SecurityFrameworkUtils.getLoginUser(); + if (user == null) return false; + Long visitTenantId = TenantContextHolder.getTenantId(); + return visitTenantId != null + && !Objects.equals(user.getTenantId(), visitTenantId); + } +} +``` + +**Controller 中的 @PreAuthorize 使用示例:** `TenantController.java` + +```java +@Tag(name = "管理后台 - 租户") +@RestController +@RequestMapping("/system/tenant") +@Validated +public class TenantController { + + @Resource + private TenantService tenantService; + + @PostMapping("/create") + @Operation(summary = "创建租户") + @PreAuthorize("@ss.hasPermission('system:tenant:create')") + public CommonResult createTenant(@Valid @RequestBody TenantSaveReqVO createReqVO) { + return success(tenantService.createTenant(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新租户") + @PreAuthorize("@ss.hasPermission('system:tenant:update')") + public CommonResult updateTenant(@Valid @RequestBody TenantSaveReqVO updateReqVO) { + tenantService.updateTenant(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除租户") + @PreAuthorize("@ss.hasPermission('system:tenant:delete')") + public CommonResult deleteTenant(@RequestParam("id") Long id) { + tenantService.deleteTenant(id); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得租户分页") + @PreAuthorize("@ss.hasPermission('system:tenant:query')") + public CommonResult> getTenantPage(@Valid TenantPageReqVO pageReqVO) { + return success(tenantService.getTenantPage(pageReqVO)); + } +} +``` + +### 9.6 前端权限控制 + +```vue + +``` + +--- + +## 10. 租户隔离鉴权 + +### 10.1 概述 + +系统为多租户 SaaS 架构,通过 `TenantSecurityWebFilter` 在请求级别强制租户隔离,确保用户只能访问自己所属租户的数据,防止租户间数据越权。 + +### 10.2 核心类 + +| 类名 | 路径 | +|------|------| +| `TenantSecurityWebFilter` | `urbanops-framework/urbanops-spring-boot-starter-biz-tenant/src/main/java/com/zteits/urbanops/framework/tenant/core/security/TenantSecurityWebFilter.java` | +| `TenantContextHolder` | 租户上下文持有者(ThreadLocal) | + +### 10.3 工作流程 + +``` +请求到达 + │ + ▼ +TenantSecurityWebFilter.doFilterInternal() + │ + ├── 1. 如果请求头/参数中没有 tenantId + │ └── 自动从 LoginUser.tenantId 填充 + │ + ├── 2. 如果请求中的 tenantId != LoginUser.tenantId + │ └── 判定为跨租户越权访问 → 直接返回 403 + │ + ├── 3. 如果 tenantId 为空 且 不在白名单中 + │ └── 返回 400 "请求的租户标识未传递" + │ + ├── 4. 校验租户合法性 + │ └── tenantFrameworkService.validTenant(tenantId) + │ ├── 租户存在 + │ ├── 租户未被禁用 + │ └── 租户未过期 + │ + └── 5. 放行 +``` + +### 10.4 实现代码示例 + +**租户隔离过滤器:** `TenantSecurityWebFilter.java` + +```java +@RequiredArgsConstructor +public class TenantSecurityWebFilter extends ApiRequestFilter { + + private final TenantProperties tenantProperties; + private final TenantFrameworkService tenantFrameworkService; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + Long tenantId = TenantContextHolder.getTenantId(); + LoginUser user = SecurityFrameworkUtils.getLoginUser(); + + // ========== 1. 已登录用户的租户校验 ========== + if (user != null) { + if (tenantId == null) { + // 请求未带租户ID → 自动使用用户所属租户 + tenantId = user.getTenantId(); + TenantContextHolder.setTenantId(tenantId); + } else if (!Objects.equals(user.getTenantId(), tenantId)) { + // 跨租户访问 → 拒绝请求 + log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]", + user.getTenantId(), user.getId(), user.getUserType(), + tenantId, request.getRequestURI(), request.getMethod()); + ServletUtils.writeJSON(response, + CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(), + "您无权访问该租户的数据")); + return; // ← 直接中断请求 + } + } + + // ========== 2. 租户合法性校验 ========== + if (!isIgnoreUrl(request)) { + // 非白名单 URL:必须有租户ID + if (tenantId == null) { + ServletUtils.writeJSON(response, + CommonResult.error(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), + "请求的租户标识未传递,请进行排查")); + return; + } + // 校验租户是否有效(未被禁用、未过期等) + tenantFrameworkService.validTenant(tenantId); + } else { + // 白名单 URL(如登录接口):允许无租户ID + if (tenantId == null) { + TenantContextHolder.setIgnore(true); + } + } + + chain.doFilter(request, response); + } + + /** + * 判断当前 URL 是否在租户忽略白名单中 + */ + private boolean isIgnoreUrl(HttpServletRequest request) { + return tenantProperties.getIgnoreUrls().stream() + .anyMatch(url -> WebFrameworkUtils.match(url, request)); + } +} +``` + +### 10.5 配置示例 + +```yaml +urbanops: + tenant: + ignore-urls: + - /system/auth/login + - /system/auth/sms-login + - /system/oauth2/** + - /swagger-ui/** + - /v3/api-docs/** +``` + +--- + +## 11. 数据权限鉴权(行级安全) + +### 11.1 概述 + +数据权限是 RBAC 权限模型的补充,在 SQL 层面注入 `WHERE` 条件,实现行级数据过滤。用户根据其数据权限范围,只能看到: +- **全部数据**(ALL) +- **本部门及下级部门数据**(DEPT_SCOPE) +- **仅本人数据**(SELF) + +### 11.2 核心类 + +| 类名 | 路径 | +|------|------| +| `@DataPermission` 注解 | `urbanops-framework/urbanops-spring-boot-starter-biz-data-permission/src/main/java/com/zteits/urbanops/framework/datapermission/core/annotation/DataPermission.java` | +| `DeptDataPermissionRule` | `urbanops-framework/urbanops-spring-boot-starter-biz-data-permission/src/main/java/com/zteits/urbanops/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java` | + +### 11.3 @DataPermission 注解 + +```java +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface DataPermission { + + /** 是否启用数据权限,默认 true */ + boolean enable() default true; + + /** 指定启用的数据权限规则 */ + Class[] includeRules() default {}; + + /** 指定排除的数据权限规则 */ + Class[] excludeRules() default {}; +} +``` + +### 11.4 SQL 注入逻辑 + +| 数据权限范围 | 生成的 SQL WHERE 条件 | +|-------------|--------------------| +| ALL(全部)| 不注入条件(查全部)| +| DEPT(部门)| `WHERE dept_id IN (1, 2, 3)` | +| SELF(仅本人)| `WHERE creator = 5`(或 `user_id = 5`)| +| DEPT + SELF | `WHERE (dept_id IN (1, 2, 3) OR creator = 5)` | +| NONE(无权限)| `WHERE null = null` | + +### 11.5 实现代码示例 + +**部门数据权限规则:** `DeptDataPermissionRule.java` + +```java +@Component +public class DeptDataPermissionRule implements DataPermissionRule { + + private static final String CONTEXT_KEY = DeptDataPermissionRule.class.getSimpleName(); + + @Resource + private PermissionCommonApi permissionApi; + + /** + * 根据当前用户的数据权限范围,构造 SQL 过滤表达式 + */ + @Override + public Expression getExpression(String tableName, Alias tableAlias) { + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if (loginUser == null) { + return null; + } + // 仅对 ADMIN 类型用户生效 + if (ObjectUtil.notEqual(loginUser.getUserType(), UserTypeEnum.ADMIN.getValue())) { + return null; + } + + // 从缓存或远程 API 获取用户的数据权限配置 + DeptDataPermissionRespDTO deptDataPermission = loginUser.getContext( + CONTEXT_KEY, DeptDataPermissionRespDTO.class); + if (deptDataPermission == null) { + deptDataPermission = permissionApi.getDeptDataPermission(loginUser.getId()); + loginUser.setContext(CONTEXT_KEY, deptDataPermission); + } + + // 情况1: 全部数据权限 → 不注入任何条件 + if (deptDataPermission.getAll()) { + return null; + } + + // 情况2: 既无部门权限,也无本人权限 → 查不到任何数据 + if (CollUtil.isEmpty(deptDataPermission.getDeptIds()) + && Boolean.FALSE.equals(deptDataPermission.getSelf())) { + return new EqualsTo(null, null); // WHERE null = null + } + + // 情况3: 拼接部门和本人的 OR 条件 + Expression deptExpression = buildDeptExpression( + tableName, tableAlias, deptDataPermission.getDeptIds()); + Expression userExpression = buildUserExpression( + tableName, tableAlias, deptDataPermission.getSelf(), loginUser.getId()); + + if (deptExpression == null) return userExpression; + if (userExpression == null) return deptExpression; + + // 组合: (dept_id IN (1,2,3) OR creator = 5) + return new ParenthesizedExpressionList( + new OrExpression(deptExpression, userExpression)); + } + + /** + * 构造部门条件:dept_id IN (1, 2, 3) + */ + private Expression buildDeptExpression(String tableName, Alias tableAlias, + Set deptIds) { + if (CollUtil.isEmpty(deptIds)) { + return null; + } + return new InExpression(new Column(tableAlias, "dept_id"), + new ExpressionList(deptIds)); + } + + /** + * 构造本人条件:creator = 5(或 user_id = 5) + */ + private Expression buildUserExpression(String tableName, Alias tableAlias, + Boolean self, Long userId) { + if (BooleanUtil.isFalse(self)) { + return null; + } + return new EqualsTo(new Column(tableAlias, "creator"), + new LongValue(userId)); + } +} +``` + +**Service 层使用示例:** `AdminAuthServiceImpl.java` + +```java +@Service +public class AdminAuthServiceImpl implements AdminAuthService { + + /** + * 登录方法:不需要数据权限过滤 + * 通过 @DataPermission(enable = false) 禁用行级过滤 + */ + @Override + @DataPermission(enable = false) + public AuthLoginRespVO login(AuthLoginReqVO reqVO) { + AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword()); + return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), + LoginLogTypeEnum.LOGIN_USERNAME); + } + + /** + * 列表查询方法:默认启用数据权限 + * 用户只能看到自己部门或自己的数据 + */ + @Override + public PageResult getUserPage(AdminUserPageReqVO reqVO) { + // MyBatis 查询时会自动注入 dept_id IN (...) OR creator = ? 条件 + Page page = userMapper.selectPage(reqVO, + new LambdaQueryWrapperX() + .likeIfPresent(AdminUserDO::getUsername, reqVO.getUsername())); + return AdminUserConvert.INSTANCE.convertPage(page); + } +} +``` + +--- + +## 12. API 加密 + +### 12.1 概述 + +系统支持对请求体和响应体进行 AES 或 RSA 加解密,通过 `@ApiEncrypt` 注解在方法级别控制,防止敏感数据在传输过程中被窃取。 + +### 12.2 核心类 + +| 类名 | 路径 | +|------|------| +| `@ApiEncrypt` 注解 | `urbanops-framework/urbanops-spring-boot-starter-web/src/main/java/com/zteits/urbanops/framework/encrypt/core/annotation/ApiEncrypt.java` | +| `ApiEncryptFilter` | `urbanops-framework/urbanops-spring-boot-starter-web/src/main/java/com/zteits/urbanops/framework/encrypt/core/filter/ApiEncryptFilter.java` | + +### 12.3 @ApiEncrypt 注解 + +```java +@Documented +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiEncrypt { + + /** 是否对请求参数进行解密(默认 true) */ + boolean request() default true; + + /** 是否对响应结果进行加密(默认 true) */ + boolean response() default true; +} +``` + +### 12.4 实现代码示例 + +**API 加密过滤器:** `ApiEncryptFilter.java` + +```java +@RequiredArgsConstructor +public class ApiEncryptFilter extends ApiRequestFilter { + + private final ApiEncryptProperties apiEncryptProperties; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + // 1. 获取 Controller 方法上的 @ApiEncrypt 注解 + ApiEncrypt apiEncrypt = getApiEncrypt(request); + boolean requestEnable = apiEncrypt != null && apiEncrypt.request(); + boolean responseEnable = apiEncrypt != null && apiEncrypt.response(); + String encryptHeader = request.getHeader(apiEncryptProperties.getHeader()); + + // 不需要加解密 → 直接放行 + if (!requestEnable && !responseEnable && StrUtil.isBlank(encryptHeader)) { + chain.doFilter(request, response); + return; + } + + // 2. 解密请求体(POST/PUT/DELETE 请求) + if (ObjectUtils.equalsAny(HttpMethod.valueOf(request.getMethod()), + HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE)) { + if (StrUtil.isNotBlank(encryptHeader)) { + // 使用加密请求包装器(AES 或 RSA 解密) + request = new ApiDecryptRequestWrapper(request, + requestSymmetricDecryptor, // AES 解密器 + requestAsymmetricDecryptor); // RSA 解密器 + } else if (requestEnable) { + throw invalidParamException("请求未包含加密标头,请检查是否正确配置了加密标头"); + } + } + + // 3. 包装响应对象(用于后续加密输出) + if (responseEnable) { + response = new ApiEncryptResponseWrapper(response); + } + + // 4. 执行后续过滤器链 + chain.doFilter(request, response); + + // 5. 加密响应体 + if (responseEnable) { + ((ApiEncryptResponseWrapper) response).encrypt( + apiEncryptProperties, + responseSymmetricEncryptor, // AES 加密器 + responseAsymmetricEncryptor); // RSA 加密器 + } + } +} +``` + +### 12.5 使用示例 + +```java +@RestController +@RequestMapping("/admin-api/system/user") +public class UserController { + + /** + * 创建用户:请求体加密传输,响应结果也加密返回 + */ + @PostMapping("/create") + @ApiEncrypt(request = true, response = true) + @PreAuthorize("@ss.hasPermission('system:user:create')") + public CommonResult createUser(@RequestBody UserSaveReqVO reqVO) { + return success(userService.createUser(reqVO)); + } +} +``` + +```yaml +# application.yaml +urbanops: + api-encrypt: + enable: true + header: X-Encrypt # 加密请求头标识 + algorithm: AES # AES / RSA + request-aes-key: ${API_ENCRYPT_REQ_KEY} + response-aes-key: ${API_ENCRYPT_RESP_KEY} +``` + +--- + +## 13. 接口前缀与鉴权方式对照表 + +| 接口前缀 | 鉴权方式 | 认证类型 | 权限控制 | 租户隔离 | 适用场景 | +|----------|----------|----------|----------|----------|----------| +| `/admin-api/` | Bearer Token + RBAC | Token(登录后获取) | `@PreAuthorize("@ss.hasPermission(...)")` | ✅ 强制 | 管理后台 Web | +| `/app-api/` | Bearer Token | Token(移动端登录获取) | 宽松(大多 @PermitAll) | ✅ 强制 | 移动端 App / 小程序 | +| `/open-api/` | HMAC-SHA256 签名 | appId + appSecret 签名 | 无(由签名保证) | ❌ | 第三方系统对接 | +| `/pub-api/` | 无 | 无 | 无 | ❌ | 支付回调等公开回调 | +| `/system/auth/*` | 无(`@PermitAll`) | BCrypt 密码 / 短信验证码 / 社交登录 | 无 | ❌ | 登录入口 | +| `/system/oauth2/*` | client_id + client_secret (Basic Auth) | OAuth2 标准协议 | OAuth2 Scope | ❌ | 授权服务器端点 | +| `/swagger-ui/**` | 无 | 无 | 无 | ❌ | API 文档(开发环境) | +| `/actuator/**` | 无(可配置) | 无 | 无 | ❌ | 健康检查 / 监控 | +| WebSocket | `?token=xxx` 查询参数 | Token | 无 | ❌ | WebSocket 连接 | + +--- + +## 附录:完整鉴权流程图 + +``` + ┌─────────────────────────────┐ + │ 客户端请求到达 │ + └──────────┬──────────────────┘ + │ + ┌──────────▼──────────────────┐ + │ 1. ApiEncryptFilter │ + │ @ApiEncrypt 注解的方法 │ + │ 解密请求体(AES/RSA) │ + └──────────┬──────────────────┘ + │ + ┌──────────▼──────────────────┐ + │ 2. TenantSecurityWebFilter │ + │ Tenant 租户隔离校验 │ + │ 防止跨租户越权 │ + └──────────┬──────────────────┘ + │ + ┌──────────▼──────────────────┐ + │ 3. TokenAuthenticationFilter │ + │ Bearer Token 提取 & 校验 │ + │ userType 匹配检查 │ + └──────────┬──────────────────┘ + │ + ┌──────────▼──────────────────┐ + │ 4. Spring Security │ + │ @PreAuthorize │ + │ "@ss.hasPermission(...)" │ + │ "@ss.hasRole(...)" │ + │ "@ss.hasScope(...)" │ + └──────────┬──────────────────┘ + │ + ┌──────────▼──────────────────┐ + │ 5. @ApiSignature AOP │ + │ (仅 /open-api/ 接口) │ + │ HMAC-SHA256 签名验证 │ + └──────────┬──────────────────┘ + │ + ┌──────────▼──────────────────┐ + │ 6. Controller 方法执行 │ + └──────────┬──────────────────┘ + │ + ┌──────────▼──────────────────┐ + │ 7. DeptDataPermissionRule │ + │ MyBatis SQL 行级过滤 │ + │ dept_id IN (...) OR │ + │ creator = ? │ + └──────────┬──────────────────┘ + │ + ┌──────────▼──────────────────┐ + │ 8. ApiEncryptResponseWrapper │ + │ 加密响应体(AES/RSA) │ + └──────────┬──────────────────┘ + │ + ┌──────────▼──────────────────┐ + │ 返回客户端 │ + └─────────────────────────────┘ +``` + +--- + +> 📌 **本文档基于 2026-06-11 项目代码分析生成,如项目安全机制有变更,请同步更新本文档。** diff --git a/urbanops-module-garden/pom.xml b/urbanops-module-garden/pom.xml index c3eaf1b..0221bf6 100644 --- a/urbanops-module-garden/pom.xml +++ b/urbanops-module-garden/pom.xml @@ -144,6 +144,20 @@ ${revision} compile + + + + com.zteits.boot + urbanops-module-workorder + ${revision} + compile + + + + + com.zteits.boot + urbanops-spring-boot-starter-protection + diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/AttendanceOpenController.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/AttendanceOpenController.java new file mode 100644 index 0000000..0ce6950 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/AttendanceOpenController.java @@ -0,0 +1,39 @@ +package com.zteits.urbanops.module.garden.controller.open.attendance; + +import com.zteits.urbanops.framework.common.pojo.CommonResult; +import com.zteits.urbanops.framework.signature.core.annotation.ApiSignature; +import com.zteits.urbanops.module.garden.controller.open.service.OpenApiService; +import com.zteits.urbanops.module.garden.controller.open.attendance.vo.AttendanceQueryReqVO; +import com.zteits.urbanops.module.garden.controller.open.attendance.vo.AttendanceQueryRespVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static com.zteits.urbanops.framework.common.pojo.CommonResult.success; + +@Tag(name = "开放API - 考勤查询") +@RestController +@RequestMapping("/open-api/attendance") +@Slf4j +@jakarta.annotation.security.PermitAll +@ApiSignature(timeout = 60, timeUnit = TimeUnit.SECONDS) +public class AttendanceOpenController { + + @Resource + private OpenApiService openApiService; + + @GetMapping("/query") + @Operation(summary = "查询考勤记录列表") + public CommonResult> queryAttendanceList( + @Valid AttendanceQueryReqVO reqVO) { + return success(openApiService.queryAttendanceList(reqVO)); + } +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/vo/AttendanceQueryReqVO.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/vo/AttendanceQueryReqVO.java new file mode 100644 index 0000000..d54f38d --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/vo/AttendanceQueryReqVO.java @@ -0,0 +1,36 @@ +package com.zteits.urbanops.module.garden.controller.open.attendance.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serializable; + +import java.time.LocalDateTime; + +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "开放API - 考勤查询 Request VO") +@Data +public class AttendanceQueryReqVO implements Serializable { + + @Schema(description = "人员ID", example = "1001") + private Long userId; + + @Schema(description = "部门ID", example = "100") + private Long deptId; + + @Schema(description = "业务线", example = "yl") + private String busiLine; + + @Schema(description = "打卡类型", example = "上班") + private String punchType; + + @Schema(description = "开始时间", example = "2026-06-01 00:00:00") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime startTime; + + @Schema(description = "结束时间", example = "2026-06-30 23:59:59") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime endTime; +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/vo/AttendanceQueryRespVO.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/vo/AttendanceQueryRespVO.java new file mode 100644 index 0000000..d376942 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/vo/AttendanceQueryRespVO.java @@ -0,0 +1,59 @@ +package com.zteits.urbanops.module.garden.controller.open.attendance.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; +import static com.zteits.urbanops.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; + +@Schema(description = "开放API - 考勤记录 Response VO") +@Data +public class AttendanceQueryRespVO { + + @Schema(description = "记录ID", example = "1") + private Integer id; + + @Schema(description = "设备编码", example = "DEV001") + private String deviceCode; + + @Schema(description = "设备名称", example = "打卡机01") + private String deviceName; + + @Schema(description = "打卡类型", example = "上班") + private String punchType; + + @Schema(description = "人员ID", example = "1001") + private Long userId; + + @Schema(description = "人员姓名", example = "张三") + private String nickname; + + @Schema(description = "部门ID", example = "100") + private Long deptId; + + @Schema(description = "部门名称", example = "养护一班") + private String deptName; + + @Schema(description = "打卡地址", example = "北京市西城区xx路") + private String address; + + @Schema(description = "高德纬度(GCJ-02)", example = "39.9042") + private BigDecimal latGcj02; + + @Schema(description = "高德经度(GCJ-02)", example = "116.4074") + private BigDecimal lngGcj02; + + @Schema(description = "WGS84纬度", example = "39.9010") + private BigDecimal lat; + + @Schema(description = "WGS84经度", example = "116.4030") + private BigDecimal lng; + + @Schema(description = "打卡时间", example = "2026-06-11 08:30:00") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private LocalDateTime createTime; +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/FullOrderStatisticsOpenController.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/FullOrderStatisticsOpenController.java new file mode 100644 index 0000000..c4f8e7b --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/FullOrderStatisticsOpenController.java @@ -0,0 +1,58 @@ +package com.zteits.urbanops.module.garden.controller.open.fullorderstatistics; + +import cn.hutool.core.collection.CollUtil; +import com.zteits.urbanops.framework.common.pojo.CommonResult; +import com.zteits.urbanops.framework.common.util.object.BeanUtils; +import com.zteits.urbanops.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.zteits.urbanops.framework.signature.core.annotation.ApiSignature; +import com.zteits.urbanops.module.garden.controller.open.fullorderstatistics.vo.FullOrderStatisticsOpenReqVO; +import com.zteits.urbanops.module.garden.controller.open.fullorderstatistics.vo.FullOrderStatisticsOpenRespVO; +import com.zteits.urbanops.module.workorder.dal.dataobject.fullorderstatistics.FullOrderStatisticsDO; +import com.zteits.urbanops.module.workorder.dal.mysql.fullorderstatistics.FullOrderStatisticsMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static com.zteits.urbanops.framework.common.pojo.CommonResult.success; + +@Tag(name = "开放API - 全量工单统计") +@RestController +@RequestMapping("/open-api/full-order-statistics") +@Slf4j +@jakarta.annotation.security.PermitAll +@ApiSignature(timeout = 60, timeUnit = TimeUnit.SECONDS) +public class FullOrderStatisticsOpenController { + + @Resource + private FullOrderStatisticsMapper mapper; + + @GetMapping("/query") + @Operation(summary = "查询全量工单统计列表(根据条件返回所有匹配记录)") + public CommonResult> queryList( + @Valid FullOrderStatisticsOpenReqVO reqVO) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .eqIfPresent(FullOrderStatisticsDO::getCompanyId, reqVO.getCompanyId()) + .likeIfPresent(FullOrderStatisticsDO::getCompanyName, reqVO.getCompanyName()) + .eqIfPresent(FullOrderStatisticsDO::getType, reqVO.getType()); + if (reqVO.getStartDate() != null) { + wrapper.ge(FullOrderStatisticsDO::getStartDate, reqVO.getStartDate()); + } + if (reqVO.getEndDate() != null) { + wrapper.le(FullOrderStatisticsDO::getEndDate, reqVO.getEndDate()); + } + List list = mapper.selectList(wrapper); + if (CollUtil.isEmpty(list)) { + return success(Collections.emptyList()); + } + return success(BeanUtils.toBean(list, FullOrderStatisticsOpenRespVO.class)); + } +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/vo/FullOrderStatisticsOpenReqVO.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/vo/FullOrderStatisticsOpenReqVO.java new file mode 100644 index 0000000..d854580 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/vo/FullOrderStatisticsOpenReqVO.java @@ -0,0 +1,32 @@ +package com.zteits.urbanops.module.garden.controller.open.fullorderstatistics.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serializable; +import java.time.LocalDate; + +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY; + +@Schema(description = "开放API - 全量工单统计查询 Request VO") +@Data +public class FullOrderStatisticsOpenReqVO implements Serializable { + + @Schema(description = "公司/班组ID", example = "100") + private Long companyId; + + @Schema(description = "公司/班组名称(模糊匹配)", example = "养护一班") + private String companyName; + + @Schema(description = "类型: 1=按公司, 2=按班组", example = "1") + private Integer type; + + @Schema(description = "统计开始日期(只返回开始日期>=该值的记录)", example = "2026-06-01") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) + private LocalDate startDate; + + @Schema(description = "统计结束日期(只返回结束日期<=该值的记录)", example = "2026-06-30") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) + private LocalDate endDate; +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/vo/FullOrderStatisticsOpenRespVO.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/vo/FullOrderStatisticsOpenRespVO.java new file mode 100644 index 0000000..6f14482 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/vo/FullOrderStatisticsOpenRespVO.java @@ -0,0 +1,53 @@ +package com.zteits.urbanops.module.garden.controller.open.fullorderstatistics.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY; +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; +import static com.zteits.urbanops.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; + +@Schema(description = "开放API - 全量工单统计 Response VO") +@Data +public class FullOrderStatisticsOpenRespVO { + + @Schema(description = "记录ID", example = "1") + private Long id; + + @Schema(description = "公司/班组ID", example = "100") + private Long companyId; + + @Schema(description = "公司/班组名称", example = "养护一班") + private String companyName; + + @Schema(description = "类型: 1=按公司, 2=按班组", example = "1") + private Integer type; + + @Schema(description = "统计开始日期", example = "2026-06-01") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY, timezone = TIME_ZONE_DEFAULT) + private LocalDate startDate; + + @Schema(description = "统计结束日期", example = "2026-06-30") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY, timezone = TIME_ZONE_DEFAULT) + private LocalDate endDate; + + @Schema(description = "工单完成数量", example = "100") + private Integer workOrderCount; + + @Schema(description = "巡查记录数量", example = "50") + private Integer inspectionCount; + + @Schema(description = "养护记录数量", example = "30") + private Integer maintenanceCount; + + @Schema(description = "备注", example = "二季度统计") + private String remark; + + @Schema(description = "创建时间", example = "2026-06-11 10:00:00") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private LocalDateTime createTime; +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/MaterialOpenController.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/MaterialOpenController.java new file mode 100644 index 0000000..47f99f6 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/MaterialOpenController.java @@ -0,0 +1,39 @@ +package com.zteits.urbanops.module.garden.controller.open.material; + +import com.zteits.urbanops.framework.common.pojo.CommonResult; +import com.zteits.urbanops.framework.signature.core.annotation.ApiSignature; +import com.zteits.urbanops.module.garden.controller.open.service.OpenApiService; +import com.zteits.urbanops.module.garden.controller.open.material.vo.MaterialQueryReqVO; +import com.zteits.urbanops.module.garden.controller.open.material.vo.MaterialQueryRespVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static com.zteits.urbanops.framework.common.pojo.CommonResult.success; + +@Tag(name = "开放API - 物料查询") +@RestController +@RequestMapping("/open-api/materials") +@Slf4j +@jakarta.annotation.security.PermitAll +@ApiSignature(timeout = 60, timeUnit = TimeUnit.SECONDS) +public class MaterialOpenController { + + @Resource + private OpenApiService openApiService; + + @GetMapping("/query") + @Operation(summary = "查询物料记录列表") + public CommonResult> queryMaterialList( + @Valid MaterialQueryReqVO reqVO) { + return success(openApiService.queryMaterialList(reqVO)); + } +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/vo/MaterialQueryReqVO.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/vo/MaterialQueryReqVO.java new file mode 100644 index 0000000..2056e52 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/vo/MaterialQueryReqVO.java @@ -0,0 +1,36 @@ +package com.zteits.urbanops.module.garden.controller.open.material.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serializable; + +import java.time.LocalDateTime; + +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "开放API - 物料查询 Request VO") +@Data +public class MaterialQueryReqVO implements Serializable { + + @Schema(description = "工单号(精确匹配)", example = "WO202606110001") + private String orderNo; + + @Schema(description = "物料分类名(模糊匹配)", example = "药品") + private String classifyName; + + @Schema(description = "物料类型名(模糊匹配)", example = "杀虫剂") + private String typeName; + + @Schema(description = "使用人ID", example = "1001") + private Long userId; + + @Schema(description = "开始时间", example = "2026-06-01 00:00:00") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime startDate; + + @Schema(description = "结束时间", example = "2026-06-30 23:59:59") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime endDate; +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/vo/MaterialQueryRespVO.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/vo/MaterialQueryRespVO.java new file mode 100644 index 0000000..034ed00 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/vo/MaterialQueryRespVO.java @@ -0,0 +1,73 @@ +package com.zteits.urbanops.module.garden.controller.open.material.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; +import static com.zteits.urbanops.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; + +@Schema(description = "开放API - 物料记录 Response VO") +@Data +public class MaterialQueryRespVO { + + @Schema(description = "记录ID", example = "1") + private Long id; + + @Schema(description = "工单号", example = "WO202606110001") + private String orderNo; + + @Schema(description = "物料名称", example = "高效氯氰菊酯") + private String materialName; + + @Schema(description = "规格", example = "500ml/瓶") + private String specifications; + + @Schema(description = "使用数量", example = "5") + private Long userCount; + + @Schema(description = "单位", example = "瓶") + private String unitName; + + @Schema(description = "物料分类", example = "药品") + private String classifyName; + + @Schema(description = "物料类型", example = "杀虫剂") + private String typeName; + + @Schema(description = "物料明细类型", example = "有机磷") + private String typeDetailName; + + @Schema(description = "使用人ID", example = "1001") + private Long userId; + + @Schema(description = "使用人姓名", example = "张三") + private String userName; + + @Schema(description = "来源(1=工单/2=巡检/3=养护)", example = "1") + private Integer source; + + @Schema(description = "上传时间", example = "2026-06-11 14:30:00") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private LocalDateTime createTime; + + @Schema(description = "关联照片") + private List photos; + + @Schema(description = "照片信息") + @Data + public static class PhotoVO { + + @Schema(description = "业务类型(01=问题/02=处理中/03=完成)", example = "02") + private String busiType; + + @Schema(description = "文件名", example = "material.jpg") + private String fileName; + + @Schema(description = "文件URL", example = "https://xxx.com/file/abc.jpg") + private String url; + } +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiService.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiService.java new file mode 100644 index 0000000..af3bdb1 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiService.java @@ -0,0 +1,27 @@ +package com.zteits.urbanops.module.garden.controller.open.service; + +import com.zteits.urbanops.module.garden.controller.open.attendance.vo.AttendanceQueryReqVO; +import com.zteits.urbanops.module.garden.controller.open.attendance.vo.AttendanceQueryRespVO; +import com.zteits.urbanops.module.garden.controller.open.material.vo.MaterialQueryReqVO; +import com.zteits.urbanops.module.garden.controller.open.material.vo.MaterialQueryRespVO; +import com.zteits.urbanops.module.garden.controller.open.staff.vo.StaffQueryReqVO; +import com.zteits.urbanops.module.garden.controller.open.staff.vo.StaffQueryRespVO; +import com.zteits.urbanops.module.garden.controller.open.task.vo.TaskQueryReqVO; +import com.zteits.urbanops.module.garden.controller.open.task.vo.TaskQueryRespVO; +import com.zteits.urbanops.module.garden.controller.open.team.vo.TeamQueryReqVO; +import com.zteits.urbanops.module.garden.controller.open.team.vo.TeamQueryRespVO; + +import java.util.List; + +public interface OpenApiService { + + List queryStaffList(StaffQueryReqVO reqVO); + + List queryTeamList(TeamQueryReqVO reqVO); + + List queryAttendanceList(AttendanceQueryReqVO reqVO); + + List queryTaskList(TaskQueryReqVO reqVO); + + List queryMaterialList(MaterialQueryReqVO reqVO); +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiServiceImpl.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiServiceImpl.java new file mode 100644 index 0000000..5862a1c --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiServiceImpl.java @@ -0,0 +1,382 @@ +package com.zteits.urbanops.module.garden.controller.open.service; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.zteits.urbanops.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.zteits.urbanops.module.garden.controller.open.attendance.vo.AttendanceQueryReqVO; +import com.zteits.urbanops.module.garden.controller.open.attendance.vo.AttendanceQueryRespVO; +import com.zteits.urbanops.module.garden.controller.open.material.vo.MaterialQueryReqVO; +import com.zteits.urbanops.module.garden.controller.open.material.vo.MaterialQueryRespVO; +import com.zteits.urbanops.module.garden.controller.open.staff.vo.StaffQueryReqVO; +import com.zteits.urbanops.module.garden.controller.open.staff.vo.StaffQueryRespVO; +import com.zteits.urbanops.module.garden.controller.open.task.vo.TaskQueryReqVO; +import com.zteits.urbanops.module.garden.controller.open.task.vo.TaskQueryRespVO; +import com.zteits.urbanops.module.garden.controller.open.team.vo.TeamQueryReqVO; +import com.zteits.urbanops.module.garden.controller.open.team.vo.TeamQueryRespVO; +import com.zteits.urbanops.module.garden.dal.dataobject.device.DeviceClockRecordDO; +import com.zteits.urbanops.module.garden.dal.dataobject.deviceuser.DeviceUserDO; +import com.zteits.urbanops.module.garden.dal.mysql.device.DeviceClockRecordMapper; +import com.zteits.urbanops.module.garden.dal.mysql.deviceuser.DeviceUserMapper; +import com.zteits.urbanops.module.system.dal.dataobject.dept.DeptDO; +import com.zteits.urbanops.module.system.dal.dataobject.dept.PostDO; +import com.zteits.urbanops.module.system.dal.dataobject.user.AdminUserDO; +import com.zteits.urbanops.module.system.dal.mysql.dept.DeptMapper; +import com.zteits.urbanops.module.system.dal.mysql.dept.PostMapper; +import com.zteits.urbanops.module.system.dal.mysql.user.AdminUserMapper; +import com.zteits.urbanops.module.workorder.dal.dataobject.attachment.AttachmentDO; +import com.zteits.urbanops.module.workorder.dal.dataobject.maininfo.MainInfoDO; +import com.zteits.urbanops.module.workorder.dal.dataobject.materialdetail.MaterialDetailDO; +import com.zteits.urbanops.module.workorder.dal.mysql.attachment.AttachmentMapper; +import com.zteits.urbanops.module.workorder.dal.mysql.maininfo.MainInfoMapper; +import com.zteits.urbanops.module.workorder.dal.mysql.materialdetail.MaterialDetailMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class OpenApiServiceImpl implements OpenApiService { + + @Resource + private AdminUserMapper adminUserMapper; + @Resource + private DeptMapper deptMapper; + @Resource + private PostMapper postMapper; + @Resource + private DeviceUserMapper deviceUserMapper; + @Resource + private DeviceClockRecordMapper deviceClockRecordMapper; + @Resource + private MainInfoMapper mainInfoMapper; + @Resource + private MaterialDetailMapper materialDetailMapper; + @Resource + private AttachmentMapper attachmentMapper; + + // ==================== 人员查询 ==================== + + @Override + public List queryStaffList(StaffQueryReqVO reqVO) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .likeIfPresent(AdminUserDO::getNickname, reqVO.getName()) + .likeIfPresent(AdminUserDO::getMobile, reqVO.getMobile()) + .eqIfPresent(AdminUserDO::getDeptId, reqVO.getDeptId()) + .eqIfPresent(AdminUserDO::getStatus, reqVO.getStatus()); + if (StrUtil.isNotBlank(reqVO.getBusiLine())) { + wrapper.apply("FIND_IN_SET({0}, busi_line)", reqVO.getBusiLine()); + } + List userList = adminUserMapper.selectList(wrapper); + if (CollUtil.isEmpty(userList)) { + return Collections.emptyList(); + } + + // 批量查部门名 + Set deptIds = userList.stream().map(AdminUserDO::getDeptId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map deptNameMap = CollUtil.isNotEmpty(deptIds) + ? deptMapper.selectList(DeptDO::getId, deptIds).stream() + .collect(Collectors.toMap(DeptDO::getId, DeptDO::getName, (a, b) -> a)) + : Collections.emptyMap(); + + // 批量查岗位名 + Set allPostIds = userList.stream() + .filter(u -> CollUtil.isNotEmpty(u.getPostIds())) + .flatMap(u -> u.getPostIds().stream()) + .collect(Collectors.toSet()); + Map postNameMap = CollUtil.isNotEmpty(allPostIds) + ? postMapper.selectList(PostDO::getId, allPostIds).stream() + .collect(Collectors.toMap(PostDO::getId, PostDO::getName, (a, b) -> a)) + : Collections.emptyMap(); + + // 批量查工卡号 + Set userIds = userList.stream().map(AdminUserDO::getId).collect(Collectors.toSet()); + Map deviceNoMap = CollUtil.isNotEmpty(userIds) + ? deviceUserMapper.selectList( + new LambdaQueryWrapperX().in(DeviceUserDO::getUserId, userIds)) + .stream().collect(Collectors.toMap(DeviceUserDO::getUserId, + d -> StrUtil.blankToDefault(d.getDeviceNo(), ""), (a, b) -> a)) + : Collections.emptyMap(); + + // 构建 VO + List voList = new ArrayList<>(); + for (AdminUserDO user : userList) { + StaffQueryRespVO vo = new StaffQueryRespVO(); + vo.setId(user.getId()); + vo.setUsername(user.getUsername()); + vo.setNickname(user.getNickname()); + vo.setMobile(user.getMobile()); + vo.setDeptId(user.getDeptId()); + vo.setDeptName(deptNameMap.getOrDefault(user.getDeptId(), "")); + vo.setPostIds(user.getPostIds()); + if (CollUtil.isNotEmpty(user.getPostIds())) { + vo.setPostNames(user.getPostIds().stream() + .map(pid -> postNameMap.getOrDefault(pid, "")) + .filter(StrUtil::isNotBlank) + .collect(Collectors.joining(","))); + } + vo.setBusiLine(user.getBusiLine()); + vo.setStatus(user.getStatus()); + vo.setCardNumber(deviceNoMap.getOrDefault(user.getId(), "")); + vo.setIsInner(user.getIsInner()); + vo.setCreateTime(user.getCreateTime()); + voList.add(vo); + } + return voList; + } + + // ==================== 班组查询 ==================== + + @Override + public List queryTeamList(TeamQueryReqVO reqVO) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .likeIfPresent(DeptDO::getName, reqVO.getName()) + .eqIfPresent(DeptDO::getParentId, reqVO.getParentId()) + .eqIfPresent(DeptDO::getStatus, reqVO.getStatus()); + List deptList = deptMapper.selectList(wrapper); + if (CollUtil.isEmpty(deptList)) { + return Collections.emptyList(); + } + + // 父部门名 + Set parentIds = deptList.stream().map(DeptDO::getParentId) + .filter(Objects::nonNull).filter(id -> id > 0).collect(Collectors.toSet()); + Map parentNameMap = CollUtil.isNotEmpty(parentIds) + ? deptMapper.selectList(DeptDO::getId, parentIds).stream() + .collect(Collectors.toMap(DeptDO::getId, DeptDO::getName, (a, b) -> a)) + : Collections.emptyMap(); + + // 班组长名 + Set leaderIds = deptList.stream().map(DeptDO::getLeaderUserId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map leaderNameMap = CollUtil.isNotEmpty(leaderIds) + ? adminUserMapper.selectList(AdminUserDO::getId, leaderIds).stream() + .collect(Collectors.toMap(AdminUserDO::getId, + AdminUserDO::getNickname, (a, b) -> a)) + : Collections.emptyMap(); + + // 人员数量统计 + Set deptIds = deptList.stream().map(DeptDO::getId).collect(Collectors.toSet()); + Map memberCountMap = Collections.emptyMap(); + if (CollUtil.isNotEmpty(deptIds)) { + List allUsers = adminUserMapper.selectList( + new LambdaQueryWrapperX().in(AdminUserDO::getDeptId, deptIds)); + memberCountMap = allUsers.stream() + .collect(Collectors.groupingBy(AdminUserDO::getDeptId, Collectors.counting())); + } + + // 构建 VO + List voList = new ArrayList<>(); + for (DeptDO dept : deptList) { + TeamQueryRespVO vo = new TeamQueryRespVO(); + vo.setId(dept.getId()); + vo.setName(dept.getName()); + vo.setParentId(dept.getParentId()); + vo.setParentName(dept.getParentId() != null && dept.getParentId() > 0 + ? parentNameMap.getOrDefault(dept.getParentId(), "") : ""); + vo.setLeaderUserId(dept.getLeaderUserId()); + vo.setLeaderUserName(leaderNameMap.getOrDefault(dept.getLeaderUserId(), "")); + vo.setMemberCount(memberCountMap.getOrDefault(dept.getId(), 0L)); + vo.setSort(dept.getSort()); + vo.setStatus(dept.getStatus()); + voList.add(vo); + } + return voList; + } + + // ==================== 考勤查询 ==================== + + @Override + public List queryAttendanceList(AttendanceQueryReqVO reqVO) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .eqIfPresent(DeviceClockRecordDO::getUserId, reqVO.getUserId()) + .eqIfPresent(DeviceClockRecordDO::getDeptId, reqVO.getDeptId()) + .eqIfPresent(DeviceClockRecordDO::getPunchType, reqVO.getPunchType()) + .betweenIfPresent(DeviceClockRecordDO::getCreateTime, + reqVO.getStartTime(), reqVO.getEndTime()); + if (StrUtil.isNotBlank(reqVO.getBusiLine())) { + wrapper.apply("FIND_IN_SET({0}, busi_line)", reqVO.getBusiLine()); + } + List recordList = deviceClockRecordMapper.selectList(wrapper); + if (CollUtil.isEmpty(recordList)) { + return Collections.emptyList(); + } + + // 部门名 + Set deptIds = recordList.stream().map(DeviceClockRecordDO::getDeptId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map deptNameMap = CollUtil.isNotEmpty(deptIds) + ? deptMapper.selectList(DeptDO::getId, deptIds).stream() + .collect(Collectors.toMap(DeptDO::getId, DeptDO::getName, (a, b) -> a)) + : Collections.emptyMap(); + + List voList = new ArrayList<>(); + for (DeviceClockRecordDO rec : recordList) { + AttendanceQueryRespVO vo = new AttendanceQueryRespVO(); + vo.setId(rec.getId()); + vo.setDeviceCode(rec.getDeviceCode()); + vo.setDeviceName(rec.getDeviceName()); + vo.setPunchType(rec.getPunchType()); + vo.setUserId(rec.getUserId()); + vo.setNickname(rec.getNickname()); + vo.setDeptId(rec.getDeptId()); + vo.setDeptName(deptNameMap.getOrDefault(rec.getDeptId(), "")); + vo.setAddress(rec.getAddress()); + vo.setLatGcj02(rec.getLatGcj02()); + vo.setLngGcj02(rec.getLngGcj02()); + vo.setLat(rec.getLat()); + vo.setLng(rec.getLng()); + vo.setCreateTime(rec.getCreateTime()); + voList.add(vo); + } + return voList; + } + + // ==================== 工单查询 ==================== + + @Override + public List queryTaskList(TaskQueryReqVO reqVO) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .likeIfPresent(MainInfoDO::getOrderNo, reqVO.getOrderNo()) + .likeIfPresent(MainInfoDO::getOrderName, reqVO.getOrderName()) + .eqIfPresent(MainInfoDO::getStatus, reqVO.getStatus()) + .eqIfPresent(MainInfoDO::getBuzStatus, reqVO.getBuzStatus()) + .eqIfPresent(MainInfoDO::getWorkerId, reqVO.getWorkerId()) + .betweenIfPresent(MainInfoDO::getCommitDate, + reqVO.getStartDate(), reqVO.getEndDate()); + if (StrUtil.isNotBlank(reqVO.getBusiLine())) { + wrapper.apply("FIND_IN_SET({0}, busi_line)", reqVO.getBusiLine()); + } + List taskList = mainInfoMapper.selectList(wrapper); + if (CollUtil.isEmpty(taskList)) { + return Collections.emptyList(); + } + + // 附件照片 + Set orderNos = taskList.stream().map(MainInfoDO::getOrderNo) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map> attachmentMap = Collections.emptyMap(); + if (CollUtil.isNotEmpty(orderNos)) { + List attachments = attachmentMapper.selectList( + new LambdaQueryWrapperX().in(AttachmentDO::getOrderNo, orderNos)); + attachmentMap = attachments.stream() + .collect(Collectors.groupingBy(AttachmentDO::getOrderNo)); + } + + // 共同处理人姓名 + Set allCoHandlerIds = taskList.stream() + .filter(t -> CollUtil.isNotEmpty(t.getCoHandlers())) + .flatMap(t -> t.getCoHandlers().stream()) + .collect(Collectors.toSet()); + Map coHandlerNameMap = CollUtil.isNotEmpty(allCoHandlerIds) + ? adminUserMapper.selectList(AdminUserDO::getId, allCoHandlerIds).stream() + .collect(Collectors.toMap(AdminUserDO::getId, + AdminUserDO::getNickname, (a, b) -> a)) + : Collections.emptyMap(); + + // 构建 VO + List voList = new ArrayList<>(); + for (MainInfoDO task : taskList) { + TaskQueryRespVO vo = new TaskQueryRespVO(); + vo.setId(task.getId()); + vo.setOrderNo(task.getOrderNo()); + vo.setOrderName(task.getOrderName()); + vo.setOrderType(task.getOrderType()); + vo.setBusiLine(task.getBusiLine()); + vo.setCommitDate(task.getCommitDate()); + vo.setFinishDate(task.getFinishDate()); + vo.setUserId(task.getUserId()); + vo.setUserName(task.getUserName()); + vo.setWorkerId(task.getWorkerId()); + vo.setWorkerName(task.getWorkerName()); + if (CollUtil.isNotEmpty(task.getCoHandlers())) { + vo.setCoHandlers(task.getCoHandlers().stream() + .map(id -> coHandlerNameMap.getOrDefault(id, "")) + .filter(StrUtil::isNotBlank) + .collect(Collectors.toList())); + } + vo.setWoSourceName(task.getWoSourceName()); + vo.setStatus(task.getStatus()); + vo.setBuzStatus(task.getBuzStatus()); + vo.setRemark(task.getRemark()); + // 照片 + List attList = attachmentMap.getOrDefault( + task.getOrderNo(), Collections.emptyList()); + if (CollUtil.isNotEmpty(attList)) { + vo.setPhotos(attList.stream().map(att -> { + TaskQueryRespVO.PhotoVO p = new TaskQueryRespVO.PhotoVO(); + p.setBusiType(att.getBusiType()); + p.setFileName(att.getFileNames()); + p.setUrl(att.getHostUrl()); + return p; + }).collect(Collectors.toList())); + } + voList.add(vo); + } + return voList; + } + + // ==================== 物料查询 ==================== + + @Override + public List queryMaterialList(MaterialQueryReqVO reqVO) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .eqIfPresent(MaterialDetailDO::getOrderNo, reqVO.getOrderNo()) + .likeIfPresent(MaterialDetailDO::getClassifyName, reqVO.getClassifyName()) + .likeIfPresent(MaterialDetailDO::getTypeName, reqVO.getTypeName()) + .eqIfPresent(MaterialDetailDO::getUserId, reqVO.getUserId()) + .betweenIfPresent(MaterialDetailDO::getCreateTime, + reqVO.getStartDate(), reqVO.getEndDate()); + List matList = materialDetailMapper.selectList(wrapper); + if (CollUtil.isEmpty(matList)) { + return Collections.emptyList(); + } + + // 批量查附件 + Set orderNos = matList.stream().map(MaterialDetailDO::getOrderNo) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map> attachmentMap = Collections.emptyMap(); + if (CollUtil.isNotEmpty(orderNos)) { + List attachments = attachmentMapper.selectList( + new LambdaQueryWrapperX().in(AttachmentDO::getOrderNo, orderNos)); + attachmentMap = attachments.stream() + .collect(Collectors.groupingBy(AttachmentDO::getOrderNo)); + } + + // 构建 VO + List voList = new ArrayList<>(); + for (MaterialDetailDO mat : matList) { + MaterialQueryRespVO vo = new MaterialQueryRespVO(); + vo.setId(mat.getId()); + vo.setOrderNo(mat.getOrderNo()); + vo.setMaterialName(mat.getMaterialName()); + vo.setSpecifications(mat.getSpecifications()); + vo.setUserCount(mat.getUserCount()); + vo.setUnitName(mat.getUnitName()); + vo.setClassifyName(mat.getClassifyName()); + vo.setTypeName(mat.getTypeName()); + vo.setTypeDetailName(mat.getTypeDetailName()); + vo.setUserId(mat.getUserId()); + vo.setUserName(mat.getUserName()); + vo.setSource(mat.getSource()); + vo.setCreateTime(mat.getCreateTime()); + // 照片 + List attList = attachmentMap.getOrDefault( + mat.getOrderNo(), Collections.emptyList()); + if (CollUtil.isNotEmpty(attList)) { + vo.setPhotos(attList.stream().map(att -> { + MaterialQueryRespVO.PhotoVO p = new MaterialQueryRespVO.PhotoVO(); + p.setBusiType(att.getBusiType()); + p.setFileName(att.getFileNames()); + p.setUrl(att.getHostUrl()); + return p; + }).collect(Collectors.toList())); + } + voList.add(vo); + } + return voList; + } +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/StaffOpenController.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/StaffOpenController.java new file mode 100644 index 0000000..0a23596 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/StaffOpenController.java @@ -0,0 +1,38 @@ +package com.zteits.urbanops.module.garden.controller.open.staff; + +import com.zteits.urbanops.framework.common.pojo.CommonResult; +import com.zteits.urbanops.framework.signature.core.annotation.ApiSignature; +import com.zteits.urbanops.module.garden.controller.open.service.OpenApiService; +import com.zteits.urbanops.module.garden.controller.open.staff.vo.StaffQueryReqVO; +import com.zteits.urbanops.module.garden.controller.open.staff.vo.StaffQueryRespVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static com.zteits.urbanops.framework.common.pojo.CommonResult.success; + +@Tag(name = "开放API - 人员查询") +@RestController +@RequestMapping("/open-api/staff") +@Slf4j +@jakarta.annotation.security.PermitAll +@ApiSignature(timeout = 60, timeUnit = TimeUnit.SECONDS) +public class StaffOpenController { + + @Resource + private OpenApiService openApiService; + + @GetMapping("/query") + @Operation(summary = "查询人员档案列表") + public CommonResult> queryStaffList(@Valid StaffQueryReqVO reqVO) { + return success(openApiService.queryStaffList(reqVO)); + } +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/vo/StaffQueryReqVO.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/vo/StaffQueryReqVO.java new file mode 100644 index 0000000..a9acb7e --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/vo/StaffQueryReqVO.java @@ -0,0 +1,26 @@ +package com.zteits.urbanops.module.garden.controller.open.staff.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +@Schema(description = "开放API - 人员查询 Request VO") +@Data +public class StaffQueryReqVO implements Serializable { + + @Schema(description = "姓名(模糊匹配)", example = "张三") + private String name; + + @Schema(description = "手机号(模糊匹配)", example = "13800138000") + private String mobile; + + @Schema(description = "部门ID", example = "100") + private Long deptId; + + @Schema(description = "状态(0=启用,1=禁用)", example = "0") + private Integer status; + + @Schema(description = "业务线", example = "yl") + private String busiLine; +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/vo/StaffQueryRespVO.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/vo/StaffQueryRespVO.java new file mode 100644 index 0000000..c94fe26 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/vo/StaffQueryRespVO.java @@ -0,0 +1,56 @@ +package com.zteits.urbanops.module.garden.controller.open.staff.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.Set; + +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; +import static com.zteits.urbanops.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; + +@Schema(description = "开放API - 人员信息 Response VO") +@Data +public class StaffQueryRespVO { + + @Schema(description = "人员ID", example = "1") + private Long id; + + @Schema(description = "用户名", example = "zhangsan") + private String username; + + @Schema(description = "姓名", example = "张三") + private String nickname; + + @Schema(description = "手机号", example = "13800138000") + private String mobile; + + @Schema(description = "部门ID", example = "100") + private Long deptId; + + @Schema(description = "部门名称", example = "养护一班") + private String deptName; + + @Schema(description = "岗位ID列表") + private Set postIds; + + @Schema(description = "岗位名称(逗号分隔)", example = "班组长,技术员") + private String postNames; + + @Schema(description = "业务线", example = "yl") + private String busiLine; + + @Schema(description = "状态(0=启用,1=禁用)", example = "0") + private Integer status; + + @Schema(description = "工卡号(设备编号)", example = "DEV001") + private String cardNumber; + + @Schema(description = "是否内部人员(1=内部,2=外部)", example = "1") + private Integer isInner; + + @Schema(description = "入职日期", example = "2024-01-15 00:00:00") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private LocalDateTime createTime; +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/TaskOpenController.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/TaskOpenController.java new file mode 100644 index 0000000..d9bf654 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/TaskOpenController.java @@ -0,0 +1,38 @@ +package com.zteits.urbanops.module.garden.controller.open.task; + +import com.zteits.urbanops.framework.common.pojo.CommonResult; +import com.zteits.urbanops.framework.signature.core.annotation.ApiSignature; +import com.zteits.urbanops.module.garden.controller.open.service.OpenApiService; +import com.zteits.urbanops.module.garden.controller.open.task.vo.TaskQueryReqVO; +import com.zteits.urbanops.module.garden.controller.open.task.vo.TaskQueryRespVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static com.zteits.urbanops.framework.common.pojo.CommonResult.success; + +@Tag(name = "开放API - 工单查询") +@RestController +@RequestMapping("/open-api/tasks") +@Slf4j +@jakarta.annotation.security.PermitAll +@ApiSignature(timeout = 60, timeUnit = TimeUnit.SECONDS) +public class TaskOpenController { + + @Resource + private OpenApiService openApiService; + + @GetMapping("/query") + @Operation(summary = "查询工单列表") + public CommonResult> queryTaskList(@Valid TaskQueryReqVO reqVO) { + return success(openApiService.queryTaskList(reqVO)); + } +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/vo/TaskQueryReqVO.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/vo/TaskQueryReqVO.java new file mode 100644 index 0000000..a977d49 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/vo/TaskQueryReqVO.java @@ -0,0 +1,42 @@ +package com.zteits.urbanops.module.garden.controller.open.task.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serializable; + +import java.time.LocalDateTime; + +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "开放API - 工单查询 Request VO") +@Data +public class TaskQueryReqVO implements Serializable { + + @Schema(description = "工单号(模糊匹配)", example = "WO202606110001") + private String orderNo; + + @Schema(description = "工单名称(模糊匹配)", example = "树木修剪") + private String orderName; + + @Schema(description = "业务线", example = "yl") + private String busiLine; + + @Schema(description = "审批状态", example = "1") + private Integer status; + + @Schema(description = "业务状态", example = "processing") + private String buzStatus; + + @Schema(description = "接单人ID", example = "1001") + private Long workerId; + + @Schema(description = "发布时间起始", example = "2026-06-01 00:00:00") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime startDate; + + @Schema(description = "发布时间截止", example = "2026-06-30 23:59:59") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime endDate; +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/vo/TaskQueryRespVO.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/vo/TaskQueryRespVO.java new file mode 100644 index 0000000..e2fbce9 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/vo/TaskQueryRespVO.java @@ -0,0 +1,83 @@ +package com.zteits.urbanops.module.garden.controller.open.task.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; +import static com.zteits.urbanops.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; + +@Schema(description = "开放API - 工单信息 Response VO") +@Data +public class TaskQueryRespVO { + + @Schema(description = "工单ID", example = "1001") + private Long id; + + @Schema(description = "工单号", example = "WO202606110001") + private String orderNo; + + @Schema(description = "工单名称", example = "树木修剪") + private String orderName; + + @Schema(description = "工单类型(Q=快速/C=普通/O=其他)", example = "C") + private String orderType; + + @Schema(description = "业务线", example = "yl") + private String busiLine; + + @Schema(description = "发布时间", example = "2026-06-11 09:00:00") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private LocalDateTime commitDate; + + @Schema(description = "完成时间", example = "2026-06-11 17:00:00") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private LocalDateTime finishDate; + + @Schema(description = "发布人ID", example = "1") + private Long userId; + + @Schema(description = "发布人姓名", example = "管理员") + private String userName; + + @Schema(description = "接单人ID", example = "1001") + private Long workerId; + + @Schema(description = "接单人姓名", example = "张三") + private String workerName; + + @Schema(description = "共同处理人姓名列表", example = "[\"李四\", \"王五\"]") + private List coHandlers; + + @Schema(description = "接单渠道", example = "小程序") + private String woSourceName; + + @Schema(description = "审批状态", example = "1") + private Integer status; + + @Schema(description = "业务状态", example = "processing") + private String buzStatus; + + @Schema(description = "备注", example = "紧急处理") + private String remark; + + @Schema(description = "工单照片") + private List photos; + + @Schema(description = "照片信息") + @Data + public static class PhotoVO { + + @Schema(description = "业务类型(01=问题/02=处理中/03=完成)", example = "01") + private String busiType; + + @Schema(description = "文件名", example = "problem.jpg") + private String fileName; + + @Schema(description = "文件URL", example = "https://xxx.com/file/abc.jpg") + private String url; + } +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/TeamOpenController.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/TeamOpenController.java new file mode 100644 index 0000000..100b4d9 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/TeamOpenController.java @@ -0,0 +1,38 @@ +package com.zteits.urbanops.module.garden.controller.open.team; + +import com.zteits.urbanops.framework.common.pojo.CommonResult; +import com.zteits.urbanops.framework.signature.core.annotation.ApiSignature; +import com.zteits.urbanops.module.garden.controller.open.service.OpenApiService; +import com.zteits.urbanops.module.garden.controller.open.team.vo.TeamQueryReqVO; +import com.zteits.urbanops.module.garden.controller.open.team.vo.TeamQueryRespVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static com.zteits.urbanops.framework.common.pojo.CommonResult.success; + +@Tag(name = "开放API - 班组查询") +@RestController +@RequestMapping("/open-api/teams") +@Slf4j +@jakarta.annotation.security.PermitAll +@ApiSignature(timeout = 60, timeUnit = TimeUnit.SECONDS) +public class TeamOpenController { + + @Resource + private OpenApiService openApiService; + + @GetMapping("/query") + @Operation(summary = "查询班组信息列表") + public CommonResult> queryTeamList(@Valid TeamQueryReqVO reqVO) { + return success(openApiService.queryTeamList(reqVO)); + } +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/vo/TeamQueryReqVO.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/vo/TeamQueryReqVO.java new file mode 100644 index 0000000..3f53de5 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/vo/TeamQueryReqVO.java @@ -0,0 +1,20 @@ +package com.zteits.urbanops.module.garden.controller.open.team.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +@Schema(description = "开放API - 班组查询 Request VO") +@Data +public class TeamQueryReqVO implements Serializable { + + @Schema(description = "班组名称(模糊匹配)", example = "养护一班") + private String name; + + @Schema(description = "父部门ID", example = "1") + private Long parentId; + + @Schema(description = "状态(0=启用,1=禁用)", example = "0") + private Integer status; +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/vo/TeamQueryRespVO.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/vo/TeamQueryRespVO.java new file mode 100644 index 0000000..fa344dc --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/vo/TeamQueryRespVO.java @@ -0,0 +1,36 @@ +package com.zteits.urbanops.module.garden.controller.open.team.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "开放API - 班组信息 Response VO") +@Data +public class TeamQueryRespVO { + + @Schema(description = "班组ID", example = "100") + private Long id; + + @Schema(description = "班组名称", example = "养护一班") + private String name; + + @Schema(description = "父部门ID", example = "1") + private Long parentId; + + @Schema(description = "父部门名称", example = "蓟城山水集团") + private String parentName; + + @Schema(description = "班组长人员ID", example = "1001") + private Long leaderUserId; + + @Schema(description = "班组长姓名", example = "张三") + private String leaderUserName; + + @Schema(description = "人员数量", example = "15") + private Long memberCount; + + @Schema(description = "排序", example = "1") + private Integer sort; + + @Schema(description = "状态(0=启用,1=禁用)", example = "0") + private Integer status; +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/enums/ErrorCodeConstants.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/enums/ErrorCodeConstants.java index ffb070e..ec9abc8 100644 --- a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/enums/ErrorCodeConstants.java +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/enums/ErrorCodeConstants.java @@ -145,4 +145,11 @@ public interface ErrorCodeConstants { ErrorCode EMERGENCY_TASK_NOT_EXISTS = new ErrorCode(1-100-006-002, "抢险任务主不存在"); + // ========== 开放API 1-100-020-xxx ========== + ErrorCode OPEN_API_STAFF_QUERY_ERROR = new ErrorCode(1-100-020-001, "人员查询失败"); + ErrorCode OPEN_API_TEAM_QUERY_ERROR = new ErrorCode(1-100-020-002, "班组查询失败"); + ErrorCode OPEN_API_ATTENDANCE_QUERY_ERROR = new ErrorCode(1-100-020-003, "考勤查询失败"); + ErrorCode OPEN_API_TASK_QUERY_ERROR = new ErrorCode(1-100-020-004, "工单查询失败"); + ErrorCode OPEN_API_MATERIAL_QUERY_ERROR = new ErrorCode(1-100-020-005, "物料查询失败"); + } diff --git a/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/FullOrderStatisticsController.java b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/FullOrderStatisticsController.java new file mode 100644 index 0000000..ad8bd87 --- /dev/null +++ b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/FullOrderStatisticsController.java @@ -0,0 +1,109 @@ +package com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.bean.BeanUtil; +import com.zteits.urbanops.framework.apilog.core.annotation.ApiAccessLog; +import com.zteits.urbanops.framework.common.pojo.CommonResult; +import com.zteits.urbanops.framework.common.pojo.PageParam; +import com.zteits.urbanops.framework.common.pojo.PageResult; +import com.zteits.urbanops.framework.common.util.object.BeanUtils; +import com.zteits.urbanops.framework.excel.core.util.ExcelUtils; +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsPageReqVO; +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsRespVO; +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsSaveReqVO; +import com.zteits.urbanops.module.workorder.dal.dataobject.fullorderstatistics.FullOrderStatisticsDO; +import com.zteits.urbanops.module.workorder.service.fullorderstatistics.FullOrderStatisticsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.util.List; + +import static com.zteits.urbanops.framework.apilog.core.enums.OperateTypeEnum.EXPORT; +import static com.zteits.urbanops.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 全量工单统计") +@RestController +@RequestMapping("/workorder/full-order-statistics") +@Validated +public class FullOrderStatisticsController { + + @Resource + private FullOrderStatisticsService service; + + @PostMapping("/create") + @Operation(summary = "创建全量工单统计") + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:create')") + public CommonResult createFullOrderStatistics(@Valid @RequestBody FullOrderStatisticsSaveReqVO createReqVO) { + return success(service.createFullOrderStatistics(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新全量工单统计") + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:update')") + public CommonResult updateFullOrderStatistics(@Valid @RequestBody FullOrderStatisticsSaveReqVO updateReqVO) { + service.updateFullOrderStatistics(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除全量工单统计") + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:delete')") + public CommonResult deleteFullOrderStatistics(@RequestParam("id") Long id) { + service.deleteFullOrderStatistics(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除全量工单统计") + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:delete')") + public CommonResult deleteFullOrderStatisticsList(@RequestParam("ids") List ids) { + service.deleteFullOrderStatisticsListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得全量工单统计") + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:query')") + public CommonResult getFullOrderStatistics(@RequestParam("id") Long id) { + FullOrderStatisticsDO entity = service.getFullOrderStatistics(id); + return success(BeanUtils.toBean(entity, FullOrderStatisticsRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得全量工单统计分页") + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:query')") + public CommonResult> getFullOrderStatisticsPage( + @Valid FullOrderStatisticsPageReqVO pageReqVO) { + PageResult pageResult = service.getFullOrderStatisticsPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, FullOrderStatisticsRespVO.class)); + } + + @GetMapping("/list-all") + @Operation(summary = "获得全量工单统计列表(不分页)") + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:query')") + public CommonResult> getFullOrderStatisticsList( + @Valid FullOrderStatisticsPageReqVO pageReqVO) { + pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + List list = service.getFullOrderStatisticsPage(pageReqVO).getList(); + return success(BeanUtils.toBean(list, FullOrderStatisticsRespVO.class)); + } + + @GetMapping("/export-excel") + @Operation(summary = "导出全量工单统计 Excel") + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:export')") + @ApiAccessLog(operateType = EXPORT) + public void exportFullOrderStatistics(@Valid FullOrderStatisticsPageReqVO pageReqVO, + HttpServletResponse response) throws IOException { + pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); + List list = service.getFullOrderStatisticsPage(pageReqVO).getList(); + ExcelUtils.write(response, "全量工单统计.xls", "数据", FullOrderStatisticsRespVO.class, + BeanUtils.toBean(list, FullOrderStatisticsRespVO.class)); + } +} diff --git a/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsPageReqVO.java b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsPageReqVO.java new file mode 100644 index 0000000..10288e0 --- /dev/null +++ b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsPageReqVO.java @@ -0,0 +1,38 @@ +package com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo; + +import com.zteits.urbanops.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY; +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 全量工单统计分页 Request VO") +@Data +public class FullOrderStatisticsPageReqVO extends PageParam { + + @Schema(description = "公司/班组ID", example = "100") + private Long companyId; + + @Schema(description = "公司/班组名称(模糊匹配)", example = "养护一班") + private String companyName; + + @Schema(description = "类型: 1=按公司, 2=按班组", example = "1") + private Integer type; + + @Schema(description = "统计开始日期") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) + private LocalDate[] startDate; + + @Schema(description = "统计结束日期") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) + private LocalDate[] endDate; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; +} diff --git a/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsRespVO.java b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsRespVO.java new file mode 100644 index 0000000..424fa22 --- /dev/null +++ b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsRespVO.java @@ -0,0 +1,56 @@ +package com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo; + +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; +import cn.idev.excel.annotation.ExcelProperty; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY; +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; +import static com.zteits.urbanops.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; + +@Schema(description = "管理后台 - 全量工单统计 Response VO") +@Data +@ExcelIgnoreUnannotated +public class FullOrderStatisticsRespVO { + + @ExcelProperty("ID") + private Long id; + + @ExcelProperty("公司/班组ID") + private Long companyId; + + @ExcelProperty("公司/班组名称") + private String companyName; + + @ExcelProperty("类型") + private Integer type; + + @ExcelProperty("统计开始日期") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY, timezone = TIME_ZONE_DEFAULT) + private LocalDate startDate; + + @ExcelProperty("统计结束日期") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY, timezone = TIME_ZONE_DEFAULT) + private LocalDate endDate; + + @ExcelProperty("工单完成数量") + private Integer workOrderCount; + + @ExcelProperty("巡查记录数量") + private Integer inspectionCount; + + @ExcelProperty("养护记录数量") + private Integer maintenanceCount; + + @ExcelProperty("备注") + private String remark; + + @ExcelProperty("创建时间") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private LocalDateTime createTime; +} diff --git a/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsSaveReqVO.java b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsSaveReqVO.java new file mode 100644 index 0000000..0674f1e --- /dev/null +++ b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsSaveReqVO.java @@ -0,0 +1,56 @@ +package com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY; + +@Schema(description = "管理后台 - 全量工单统计新增/修改 Request VO") +@Data +public class FullOrderStatisticsSaveReqVO { + + @Schema(description = "主键ID(修改时必填)", example = "1") + private Long id; + + @Schema(description = "公司/班组ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") + @NotNull(message = "公司/班组ID不能为空") + private Long companyId; + + @Schema(description = "公司/班组名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "养护一班") + @NotEmpty(message = "公司/班组名称不能为空") + private String companyName; + + @Schema(description = "类型: 1=按公司, 2=按班组", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "类型不能为空") + private Integer type; + + @Schema(description = "统计开始日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-01") + @NotNull(message = "统计开始日期不能为空") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) + private LocalDate startDate; + + @Schema(description = "统计结束日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-30") + @NotNull(message = "统计结束日期不能为空") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) + private LocalDate endDate; + + @Schema(description = "工单完成数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") + @NotNull(message = "工单完成数量不能为空") + private Integer workOrderCount; + + @Schema(description = "巡查记录数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "50") + @NotNull(message = "巡查记录数量不能为空") + private Integer inspectionCount; + + @Schema(description = "养护记录数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "30") + @NotNull(message = "养护记录数量不能为空") + private Integer maintenanceCount; + + @Schema(description = "备注", example = "二季度统计") + private String remark; +} diff --git a/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/dal/dataobject/fullorderstatistics/FullOrderStatisticsDO.java b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/dal/dataobject/fullorderstatistics/FullOrderStatisticsDO.java new file mode 100644 index 0000000..c856310 --- /dev/null +++ b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/dal/dataobject/fullorderstatistics/FullOrderStatisticsDO.java @@ -0,0 +1,33 @@ +package com.zteits.urbanops.module.workorder.dal.dataobject.fullorderstatistics; + +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.zteits.urbanops.framework.mybatis.core.dataobject.BaseDO; +import lombok.*; + +import java.time.LocalDate; + +@TableName("workorder_full_order_statistics") +@KeySequence("workorder_full_order_statistics_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FullOrderStatisticsDO extends BaseDO { + + @TableId + private Long id; + + private Long companyId; + private String companyName; + private Integer type; + private LocalDate startDate; + private LocalDate endDate; + private Integer workOrderCount; + private Integer inspectionCount; + private Integer maintenanceCount; + private String remark; +} diff --git a/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/dal/mysql/fullorderstatistics/FullOrderStatisticsMapper.java b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/dal/mysql/fullorderstatistics/FullOrderStatisticsMapper.java new file mode 100644 index 0000000..bb8d25b --- /dev/null +++ b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/dal/mysql/fullorderstatistics/FullOrderStatisticsMapper.java @@ -0,0 +1,23 @@ +package com.zteits.urbanops.module.workorder.dal.mysql.fullorderstatistics; + +import com.zteits.urbanops.framework.common.pojo.PageResult; +import com.zteits.urbanops.framework.mybatis.core.mapper.BaseMapperX; +import com.zteits.urbanops.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsPageReqVO; +import com.zteits.urbanops.module.workorder.dal.dataobject.fullorderstatistics.FullOrderStatisticsDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface FullOrderStatisticsMapper extends BaseMapperX { + + default PageResult selectPage(FullOrderStatisticsPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(FullOrderStatisticsDO::getCompanyId, reqVO.getCompanyId()) + .likeIfPresent(FullOrderStatisticsDO::getCompanyName, reqVO.getCompanyName()) + .eqIfPresent(FullOrderStatisticsDO::getType, reqVO.getType()) + .betweenIfPresent(FullOrderStatisticsDO::getStartDate, reqVO.getStartDate()) + .betweenIfPresent(FullOrderStatisticsDO::getEndDate, reqVO.getEndDate()) + .betweenIfPresent(FullOrderStatisticsDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(FullOrderStatisticsDO::getId)); + } +} diff --git a/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/enums/ErrorCodeConstants.java b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/enums/ErrorCodeConstants.java index 86e3e6e..c638e9b 100644 --- a/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/enums/ErrorCodeConstants.java +++ b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/enums/ErrorCodeConstants.java @@ -59,5 +59,9 @@ public interface ErrorCodeConstants { ErrorCode APP_WORK_AI_ROLE_ILLEGLE = new ErrorCode(1-900-004-005, "该登录人即是 大区经理 角色,又是 AI工单派发人员,无法发起流程!"); ErrorCode SANHAI_COMPANY_NOT_EXISTS = new ErrorCode(1-900-004-006, "三海道路归属部门的id,字典项不存在!"); + + // ========== 全量工单统计 1-900-005-xxx ========== + ErrorCode FULL_ORDER_STATISTICS_NOT_EXISTS = new ErrorCode(1-900-005-001, "全量工单统计不存在"); + ErrorCode FULL_ORDER_STATISTICS_DUPLICATE = new ErrorCode(1-900-005-002, "相同公司/班组同时间段统计已存在"); } diff --git a/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/service/fullorderstatistics/FullOrderStatisticsService.java b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/service/fullorderstatistics/FullOrderStatisticsService.java new file mode 100644 index 0000000..9ff9c47 --- /dev/null +++ b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/service/fullorderstatistics/FullOrderStatisticsService.java @@ -0,0 +1,24 @@ +package com.zteits.urbanops.module.workorder.service.fullorderstatistics; + +import com.zteits.urbanops.framework.common.pojo.PageResult; +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsPageReqVO; +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsSaveReqVO; +import com.zteits.urbanops.module.workorder.dal.dataobject.fullorderstatistics.FullOrderStatisticsDO; +import jakarta.validation.Valid; + +import java.util.List; + +public interface FullOrderStatisticsService { + + Long createFullOrderStatistics(@Valid FullOrderStatisticsSaveReqVO createReqVO); + + void updateFullOrderStatistics(@Valid FullOrderStatisticsSaveReqVO updateReqVO); + + void deleteFullOrderStatistics(Long id); + + void deleteFullOrderStatisticsListByIds(List ids); + + FullOrderStatisticsDO getFullOrderStatistics(Long id); + + PageResult getFullOrderStatisticsPage(FullOrderStatisticsPageReqVO pageReqVO); +} diff --git a/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/service/fullorderstatistics/FullOrderStatisticsServiceImpl.java b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/service/fullorderstatistics/FullOrderStatisticsServiceImpl.java new file mode 100644 index 0000000..150e272 --- /dev/null +++ b/urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/service/fullorderstatistics/FullOrderStatisticsServiceImpl.java @@ -0,0 +1,95 @@ +package com.zteits.urbanops.module.workorder.service.fullorderstatistics; + +import cn.hutool.core.bean.BeanUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.zteits.urbanops.framework.common.pojo.PageResult; +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsPageReqVO; +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsSaveReqVO; +import com.zteits.urbanops.module.workorder.dal.dataobject.fullorderstatistics.FullOrderStatisticsDO; +import com.zteits.urbanops.module.workorder.dal.mysql.fullorderstatistics.FullOrderStatisticsMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +import static com.zteits.urbanops.framework.common.exception.util.ServiceExceptionUtil.exception; +import static com.zteits.urbanops.module.workorder.enums.ErrorCodeConstants.FULL_ORDER_STATISTICS_DUPLICATE; +import static com.zteits.urbanops.module.workorder.enums.ErrorCodeConstants.FULL_ORDER_STATISTICS_NOT_EXISTS; + +@Service +@Slf4j +public class FullOrderStatisticsServiceImpl implements FullOrderStatisticsService { + + @Resource + private FullOrderStatisticsMapper mapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createFullOrderStatistics(FullOrderStatisticsSaveReqVO createReqVO) { + validateDuplicate(createReqVO.getCompanyId(), createReqVO.getType(), + createReqVO.getStartDate(), createReqVO.getEndDate(), null); + FullOrderStatisticsDO entity = BeanUtil.toBean(createReqVO, FullOrderStatisticsDO.class); + mapper.insert(entity); + return entity.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateFullOrderStatistics(FullOrderStatisticsSaveReqVO updateReqVO) { + FullOrderStatisticsDO exist = mapper.selectById(updateReqVO.getId()); + if (exist == null) { + throw exception(FULL_ORDER_STATISTICS_NOT_EXISTS); + } + validateDuplicate(updateReqVO.getCompanyId(), updateReqVO.getType(), + updateReqVO.getStartDate(), updateReqVO.getEndDate(), updateReqVO.getId()); + FullOrderStatisticsDO entity = BeanUtil.toBean(updateReqVO, FullOrderStatisticsDO.class); + mapper.updateById(entity); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteFullOrderStatistics(Long id) { + if (mapper.selectById(id) == null) { + throw exception(FULL_ORDER_STATISTICS_NOT_EXISTS); + } + mapper.deleteById(id); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteFullOrderStatisticsListByIds(List ids) { + mapper.deleteByIds(ids); + } + + @Override + public FullOrderStatisticsDO getFullOrderStatistics(Long id) { + FullOrderStatisticsDO entity = mapper.selectById(id); + if (entity == null) { + throw exception(FULL_ORDER_STATISTICS_NOT_EXISTS); + } + return entity; + } + + @Override + public PageResult getFullOrderStatisticsPage(FullOrderStatisticsPageReqVO pageReqVO) { + return mapper.selectPage(pageReqVO); + } + + private void validateDuplicate(Long companyId, Integer type, LocalDate startDate, + LocalDate endDate, Long excludeId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(FullOrderStatisticsDO::getCompanyId, companyId) + .eq(FullOrderStatisticsDO::getType, type) + .le(FullOrderStatisticsDO::getStartDate, endDate) + .ge(FullOrderStatisticsDO::getEndDate, startDate); + if (excludeId != null) { + wrapper.ne(FullOrderStatisticsDO::getId, excludeId); + } + if (mapper.selectCount(wrapper) > 0) { + throw exception(FULL_ORDER_STATISTICS_DUPLICATE); + } + } +} diff --git a/urbanops-server/src/main/resources/application.yaml b/urbanops-server/src/main/resources/application.yaml index 9360ff9..bcba59e 100644 --- a/urbanops-server/src/main/resources/application.yaml +++ b/urbanops-server/src/main/resources/application.yaml @@ -3,7 +3,7 @@ spring: name: urbanops-server profiles: - active: prod + active: local main: -- libgit2 0.21.4