Commit 116f6bb0f05f4af6dc01650663798aeb2dfadc97
1 parent
b23fe9d7
feat(garden): 添加开放API考勤和工单统计功能
- 修改应用配置文件将激活环境从prod改为local - 新增AttendanceOpenController提供考勤记录查询接口 - 创建AttendanceQueryReqVO和AttendanceQueryRespVO定义考勤查询请求响应对象 - 在ErrorCodeConstants中添加开放API相关错误码定义 - 新增FullOrderStatistics相关实体类、控制器、服务类及数据访问层实现 - 创建全量工单统计数据表映射和查询逻辑 - 实现工单统计的CRUD操作和分页查询功能 - 添加Excel导出功能支持工单统计数据导出 - 新增MaterialOpenController提供物料查询接口 - 完善开放API物料查询相关的请求响应对象定义
Showing
38 changed files
with
4857 additions
and
1 deletions
docs/APP-园林工单创建接口文档.md
0 → 100644
| 1 | +# APP 端 — 园林工单创建接口 | ||
| 2 | + | ||
| 3 | +> **接口路径**:`POST /app-api/bpm/garden/workorder/create` | ||
| 4 | +> | ||
| 5 | +> **版本**:v1.0 | ||
| 6 | +> | ||
| 7 | +> **最后更新**:2026-06-07 | ||
| 8 | + | ||
| 9 | +--- | ||
| 10 | + | ||
| 11 | +## 1. 接口概述 | ||
| 12 | + | ||
| 13 | +| 属性 | 说明 | | ||
| 14 | +|------|------| | ||
| 15 | +| **接口名称** | APP 端创建园林工单 | | ||
| 16 | +| **请求方式** | `POST` | | ||
| 17 | +| **完整路径** | `/app-api/bpm/garden/workorder/create` | | ||
| 18 | +| **认证方式** | Token 认证(`Authorization: Bearer {token}`) | | ||
| 19 | +| **权限控制** | 无独立权限注解(由登录用户角色决定) | | ||
| 20 | +| **请求格式** | `application/json` | | ||
| 21 | +| **响应格式** | `application/json` | | ||
| 22 | +| **幂等性** | 无(同一请求多次调用会创建多个工单) | | ||
| 23 | + | ||
| 24 | +### 业务说明 | ||
| 25 | + | ||
| 26 | +该接口用于 APP 移动端创建智慧园林养护工单。提交后系统自动完成: | ||
| 27 | + | ||
| 28 | +1. 校验道路信息存在性 | ||
| 29 | +2. 根据道路养护级别自动赋值养护等级 | ||
| 30 | +3. 自动生成工单编号(前缀 `GWO`) | ||
| 31 | +4. 保存问题附件图片(如有) | ||
| 32 | +5. 自动发起 Flowable BPM 工作流(流程定义 Key:`workorder_common_prod`) | ||
| 33 | +6. 根据业务线自动匹配对应班组长角色作为流程审批人 | ||
| 34 | + | ||
| 35 | +--- | ||
| 36 | + | ||
| 37 | +## 2. 请求参数 | ||
| 38 | + | ||
| 39 | +### 2.1 Header 参数 | ||
| 40 | + | ||
| 41 | +| 参数名 | 类型 | 必填 | 说明 | | ||
| 42 | +|--------|------|------|------| | ||
| 43 | +| `Authorization` | String | **是** | Bearer Token,格式:`Bearer {token}` | | ||
| 44 | +| `Content-Type` | String | **是** | `application/json` | | ||
| 45 | +| `tenant-id` | Long | 否 | 租户 ID(多租户场景) | | ||
| 46 | + | ||
| 47 | +### 2.2 Body 参数(JSON) | ||
| 48 | + | ||
| 49 | +| 参数名 | 类型 | 必填 | 校验规则 | 说明 | 示例 | | ||
| 50 | +|--------|------|------|----------|------|------| | ||
| 51 | +| `orderName` | String | 否 | max 100 | 工单名称 | `"树木倒伏紧急处理"` | | ||
| 52 | +| `orderCode` | String | 否 | max 50 | 三级编码 | `"YL001"` | | ||
| 53 | +| `busiType` | String | 否 | max 50 | 业务类型 | `"TREE_MAINTENANCE"` | | ||
| 54 | +| `orderType` | String | 否 | max 1 | 工单类型:`C`=普通工单,`O`=其他工单。**不能传 `Q`**(快速工单有独立接口 `/createQuick`) | `"C"` | | ||
| 55 | +| `rootCauseBy` | String | 否 | max 50 | 工单溯源号,`"0"` 表示无溯源 | `"0"` | | ||
| 56 | +| `busiLine` | String | 否 | max 10 | 业务线:`yl`=园林,`wy`=物业,`sz`=市政。不传则取登录用户所属业务线 | `"yl"` | | ||
| 57 | +| `roadId` | Long | **是** | `@NotNull` | 道路 ID(系统中已存在的道路) | `21128` | | ||
| 58 | +| `roadName` | String | 否 | max 100 | 道路名称(辅助字段,实际以 roadId 查到的为准) | `"西长安街"` | | ||
| 59 | +| `sourceId` | Integer | 否 | — | 来源 ID,不传默认 `1`(巡查)。见 [工单来源枚举](#51-工单来源枚举) | `1` | | ||
| 60 | +| `sourceName` | String | 否 | max 50 | 来源名称,不传默认为"巡查" | `"巡查"` | | ||
| 61 | +| `pressingType` | Integer | **是** | `@NotNull` | 紧急程度:`1`=特急,`2`=紧急,`3`=一般 | `2` | | ||
| 62 | +| `latLonType` | Integer | 否 | — | 坐标系类型:`1`=WGS-84,`2`=BD-09,`3`=GCJ-02,`4`=腾讯 | `3` | | ||
| 63 | +| `lat` | BigDecimal | **是** | `@NotNull` | 经度 | `116.397428` | | ||
| 64 | +| `lon` | BigDecimal | **是** | `@NotNull` | 纬度 | `39.909204` | | ||
| 65 | +| `lonLatAddress` | String | **是** | `@NotEmpty`,max 200 | 经纬度对应的地址描述 | `"北京市西城区西长安街5号"` | | ||
| 66 | +| `thirdWorkNo` | String | 否 | max 50 | 第三方系统工单编号(对接外部系统时使用) | `"EXT20250607001"` | | ||
| 67 | +| `remark` | String | **是** | `@NotEmpty`,max 500 | 工单描述 / 现场情况说明 | `"巡查发现行道树倾斜,存在倒伏风险"` | | ||
| 68 | +| `handleResult` | String | 否 | max 500 | 工单完成结果描述(创建时可留空) | `""` | | ||
| 69 | +| `expectedFinishDate` | String | 否 | ISO 8601 | 期望完成时间 | `"2025-12-07T18:00:00"` | | ||
| 70 | +| `problemsImgs` | String[] | 否 | — | 问题现场拍照图片 URL 列表 | `["https://img.example.com/p1.jpg"]` | | ||
| 71 | +| `endImgs` | String[] | 否 | — | 完成拍照图片 URL 列表(普通工单创建时一般不需要) | `[]` | | ||
| 72 | + | ||
| 73 | +### 2.3 请求示例(完整) | ||
| 74 | + | ||
| 75 | +```json | ||
| 76 | +{ | ||
| 77 | + "orderName": "树木倒伏紧急处理", | ||
| 78 | + "orderCode": "YL001", | ||
| 79 | + "busiType": "TREE_MAINTENANCE", | ||
| 80 | + "orderType": "C", | ||
| 81 | + "rootCauseBy": "0", | ||
| 82 | + "busiLine": "yl", | ||
| 83 | + "roadId": 21128, | ||
| 84 | + "roadName": "西长安街", | ||
| 85 | + "sourceId": 1, | ||
| 86 | + "sourceName": "巡查", | ||
| 87 | + "pressingType": 2, | ||
| 88 | + "latLonType": 3, | ||
| 89 | + "lat": 116.397428, | ||
| 90 | + "lon": 39.909204, | ||
| 91 | + "lonLatAddress": "北京市西城区西长安街5号", | ||
| 92 | + "thirdWorkNo": "", | ||
| 93 | + "remark": "巡查发现行道树倾斜约30度,存在倒伏风险,需要紧急处理", | ||
| 94 | + "handleResult": "", | ||
| 95 | + "expectedFinishDate": "2025-12-07T18:00:00", | ||
| 96 | + "problemsImgs": [ | ||
| 97 | + "https://img.example.com/problem1.jpg", | ||
| 98 | + "https://img.example.com/problem2.jpg" | ||
| 99 | + ], | ||
| 100 | + "endImgs": [] | ||
| 101 | +} | ||
| 102 | +``` | ||
| 103 | + | ||
| 104 | +### 2.4 请求示例(最小) | ||
| 105 | + | ||
| 106 | +```json | ||
| 107 | +{ | ||
| 108 | + "roadId": 21128, | ||
| 109 | + "pressingType": 2, | ||
| 110 | + "lat": 116.397428, | ||
| 111 | + "lon": 39.909204, | ||
| 112 | + "lonLatAddress": "北京市西城区西长安街5号", | ||
| 113 | + "remark": "巡查发现树木倾斜,需要处理" | ||
| 114 | +} | ||
| 115 | +``` | ||
| 116 | + | ||
| 117 | +--- | ||
| 118 | + | ||
| 119 | +## 3. 响应参数 | ||
| 120 | + | ||
| 121 | +### 3.1 成功响应 | ||
| 122 | + | ||
| 123 | +| 字段 | 类型 | 说明 | | ||
| 124 | +|------|------|------| | ||
| 125 | +| `code` | Integer | 状态码,成功为 `0` | | ||
| 126 | +| `msg` | String | 提示消息 | | ||
| 127 | +| `data` | Long | 创建成功的工单 ID | | ||
| 128 | + | ||
| 129 | +```json | ||
| 130 | +{ | ||
| 131 | + "code": 0, | ||
| 132 | + "msg": "成功", | ||
| 133 | + "data": 1024 | ||
| 134 | +} | ||
| 135 | +``` | ||
| 136 | + | ||
| 137 | +> `data` 返回工单主键 ID(`workorder_main_info.id`),可用于后续查询工单详情。 | ||
| 138 | + | ||
| 139 | +### 3.2 失败响应 | ||
| 140 | + | ||
| 141 | +| 字段 | 类型 | 说明 | | ||
| 142 | +|------|------|------| | ||
| 143 | +| `code` | Integer | 错误码(非 0) | | ||
| 144 | +| `msg` | String | 错误描述信息 | | ||
| 145 | +| `data` | null | — | | ||
| 146 | + | ||
| 147 | +```json | ||
| 148 | +{ | ||
| 149 | + "code": 1900001003, | ||
| 150 | + "msg": "养护班组长人员不存在", | ||
| 151 | + "data": null | ||
| 152 | +} | ||
| 153 | +``` | ||
| 154 | + | ||
| 155 | +--- | ||
| 156 | + | ||
| 157 | +## 4. 错误码参考 | ||
| 158 | + | ||
| 159 | +| 错误码 | 错误消息 | 触发场景 | | ||
| 160 | +|--------|----------|----------| | ||
| 161 | +| `1900001003` | 养护班组长人员不存在 | 根据道路 ID 和业务线找不到对应班组长角色的人员 | | ||
| 162 | +| `1900002000` | 养护级别编码错误,不存在 | 道路关联的养护级别在字典中不存在 | | ||
| 163 | +| `1900002001` | 该人员未分配角色 | 登录用户未分配任何角色 | | ||
| 164 | +| `1900002002` | 工单类型错误,快速工单无法走此流程 | 传入了 `orderType="Q"` | | ||
| 165 | +| `1900004002` | 道路信息不存在 | 传入的 `roadId` 在系统中查询不到 | | ||
| 166 | +| `系统级` | 未登录 / Token 已过期 | 未传 Authorization 或 Token 无效 | | ||
| 167 | +| `系统级` | 参数校验失败 | 必填字段缺失或格式不合法 | | ||
| 168 | + | ||
| 169 | +--- | ||
| 170 | + | ||
| 171 | +## 5. 数据字典 | ||
| 172 | + | ||
| 173 | +### 5.1 工单来源枚举 | ||
| 174 | + | ||
| 175 | +| code | 描述 | | ||
| 176 | +|------|------| | ||
| 177 | +| `1` | 巡查 | | ||
| 178 | +| `2` | 游客居民 | | ||
| 179 | +| `3` | 12345 | | ||
| 180 | +| `4` | 网格 | | ||
| 181 | +| `5` | 大区经理 | | ||
| 182 | +| `6` | AI | | ||
| 183 | + | ||
| 184 | +### 5.2 业务线 | ||
| 185 | + | ||
| 186 | +| 值 | 描述 | 对应班组长角色 | | ||
| 187 | +|----|------|----------------| | ||
| 188 | +| `yl` | 园林 | `team_leader_yl` | | ||
| 189 | +| `wy` | 物业 | `team_leader_wy` | | ||
| 190 | +| `sz` | 市政 | `team_leader_sz` | | ||
| 191 | +| `zx` | 秩序管理 | `team_leader_zxgl` | | ||
| 192 | +| `hj` | 环境卫生 | `team_leader_hjws` | | ||
| 193 | + | ||
| 194 | +### 5.3 工单类型 | ||
| 195 | + | ||
| 196 | +| 值 | 描述 | 说明 | | ||
| 197 | +|----|------|------| | ||
| 198 | +| `C` | 普通工单 | 走标准 BPM 审批流程 | | ||
| 199 | +| `Q` | 快速工单 | **本接口不支持**,请使用 `/createQuick` 接口 | | ||
| 200 | +| `O` | 其他工单 | 特殊类型 | | ||
| 201 | + | ||
| 202 | +### 5.4 紧急程度 | ||
| 203 | + | ||
| 204 | +| 值 | 描述 | | ||
| 205 | +|----|------| | ||
| 206 | +| `1` | 特急 | | ||
| 207 | +| `2` | 紧急 | | ||
| 208 | +| `3` | 一般 | | ||
| 209 | + | ||
| 210 | +### 5.5 坐标系类型 | ||
| 211 | + | ||
| 212 | +| 值 | 描述 | | ||
| 213 | +|----|------| | ||
| 214 | +| `1` | 国标(WGS-84) | | ||
| 215 | +| `2` | 百度坐标系(BD-09) | | ||
| 216 | +| `3` | 高德坐标系(GCJ-02) | | ||
| 217 | +| `4` | 腾讯坐标系 | | ||
| 218 | + | ||
| 219 | +--- | ||
| 220 | + | ||
| 221 | +## 6. 业务处理流程 | ||
| 222 | + | ||
| 223 | +``` | ||
| 224 | + 1. 参数校验(@Valid) | ||
| 225 | + ├── roadId、pressingType、lat、lon、lonLatAddress、remark 必填校验 | ||
| 226 | + └── 长度/格式校验 | ||
| 227 | + | ||
| 228 | + 2. 道路信息校验 | ||
| 229 | + ├── 根据 roadId 查询道路信息(roadApi.getRoadInfoById) | ||
| 230 | + ├── 赋值 workerCompanyId、streetId、streetName | ||
| 231 | + └── 道路不存在 → 抛出 APP_WORK_ROAD_NOT_EXISTS | ||
| 232 | + | ||
| 233 | + 3. 工单类型处理 | ||
| 234 | + ├── orderType 为空 → 默认 "C" | ||
| 235 | + ├── orderType = "Q" → 抛出 APP_WORK_ORADER_ERROR | ||
| 236 | + └── orderType = "C"/"O" → 正常 | ||
| 237 | + | ||
| 238 | + 4. 养护级别赋值 | ||
| 239 | + ├── 根据道路 levelId 查询字典 "conserve_level" | ||
| 240 | + └── 字典值不存在 → 抛出 APP_WORKER_LEVEL_ID_NOT_EXISTS | ||
| 241 | + | ||
| 242 | + 5. 登录用户信息赋值 | ||
| 243 | + ├── companyId、deptId ← SecurityFrameworkUtils | ||
| 244 | + ├── userId、userName ← 当前登录用户 | ||
| 245 | + ├── 角色列表 ← roleApi(不能为空) | ||
| 246 | + └── 角色为空 → 抛出 APP_USER_ROLE_NOT_EXISTS | ||
| 247 | + | ||
| 248 | + 6. 来源处理(默认值) | ||
| 249 | + ├── sourceId 为空 → EventSourceEnum.PATROL(1) | ||
| 250 | + └── sourceName 为空 → "巡查" | ||
| 251 | + | ||
| 252 | + 7. 业务线处理 | ||
| 253 | + └── busiLine 为空 → 取登录用户所属业务线 | ||
| 254 | + | ||
| 255 | + 8. 工单编号生成 | ||
| 256 | + └── workOrderApi.generateWONum("GWO") | ||
| 257 | + | ||
| 258 | + 9. 工单保存 | ||
| 259 | + └── workOrderMapper.insert → workorder_main_info 表 | ||
| 260 | + | ||
| 261 | +10. 附件保存(条件) | ||
| 262 | + └── problemsImgs 非空 → 写入 attachment 表 | ||
| 263 | + | ||
| 264 | +11. 发起 BPM 流程 | ||
| 265 | + ├── 流程 Key: workorder_common_prod | ||
| 266 | + ├── 变量: roadId、applyUserId、isNeed | ||
| 267 | + ├── 审批人: 按 busiLine -> roleCode -> roadId 查找班组长 | ||
| 268 | + └── 人员为空 → 抛出 TEAM_LEADER_YL_NOT_EXISTS | ||
| 269 | + | ||
| 270 | +12. 回写流程实例 ID | ||
| 271 | + └── processInstanceId 更新到工单记录 | ||
| 272 | + | ||
| 273 | +13. 返回工单 ID | ||
| 274 | +``` | ||
| 275 | + | ||
| 276 | +--- | ||
| 277 | + | ||
| 278 | +## 7. 写入数据表 | ||
| 279 | + | ||
| 280 | +### 7.1 `workorder_main_info`(工单主表) | ||
| 281 | + | ||
| 282 | +| 字段 | 来源 | 说明 | | ||
| 283 | +|------|------|------| | ||
| 284 | +| `order_no` | 自动生成(GWO 前缀) | 工单编号 | | ||
| 285 | +| `status` | `1`(RUNNING) | 审批中 | | ||
| 286 | +| `buz_status` | `"000"` | 业务初始化状态 | | ||
| 287 | +| `order_type` | 请求参数/默认 `"C"` | 工单类型 | | ||
| 288 | +| `busi_line` | 请求参数/登录用户 | 业务线 | | ||
| 289 | +| `road_id` | 请求参数 | 道路 ID | | ||
| 290 | +| `worker_company_id` | 道路查询结果 | 道路负责部门 | | ||
| 291 | +| `street_id` / `street_name` | 道路查询结果 | 街道信息 | | ||
| 292 | +| `curing_level_id` / `curing_level_name` | 字典查询 | 养护级别 | | ||
| 293 | +| `company_id` | 登录用户 | 提交人部门 | | ||
| 294 | +| `dept_id` | 登录用户 | 部门 ID | | ||
| 295 | +| `user_id` / `user_name` | 登录用户 | 提交人 | | ||
| 296 | +| `source_id` / `source_name` | 请求/默认值 | 工单来源 | | ||
| 297 | +| `wo_source_id` / `wo_source_name` | 角色信息 | 来源编码 | | ||
| 298 | +| `commit_date` | `LocalDateTime.now()` | 提交时间 | | ||
| 299 | +| `process_instance_id` | BPM 流程实例 ID | 工作流编号 | | ||
| 300 | + | ||
| 301 | +### 7.2 `attachment`(附件表,条件写入) | ||
| 302 | + | ||
| 303 | +仅当 `problemsImgs` 不为空时写入,`busiType = "01"`。 | ||
| 304 | + | ||
| 305 | +--- | ||
| 306 | + | ||
| 307 | +## 8. 对接注意事项 | ||
| 308 | + | ||
| 309 | +### 8.1 调用前准备 | ||
| 310 | + | ||
| 311 | +1. **获取 Token**:先调用登录接口获取有效的 Bearer Token | ||
| 312 | +2. **确认道路 ID**:`roadId` 必须是系统中已配置的道路 | ||
| 313 | +3. **确认人员配置**:对应道路 + 业务线必须有班组长角色人员 | ||
| 314 | + | ||
| 315 | +### 8.2 限制与约束 | ||
| 316 | + | ||
| 317 | +| 约束 | 说明 | | ||
| 318 | +|------|------| | ||
| 319 | +| 不支持快速工单 | `orderType="Q"` 会报错,请用 `/createQuick` | | ||
| 320 | +| 无幂等保证 | 同一请求重复调用会创建多个工单 | | ||
| 321 | +| 默认工单类型 | 不传 `orderType` 默认为 `"C"`(普通工单) | | ||
| 322 | +| 默认来源 | 不传 `sourceId` 默认为"巡查" | | ||
| 323 | +| 事务保证 | 工单保存与流程发起在同一事务中,失败整体回滚 | | ||
| 324 | +| 图片不校验 | `problemsImgs` 仅保存 URL,不校验有效性 | | ||
| 325 | + | ||
| 326 | +### 8.3 创建成功后可用接口 | ||
| 327 | + | ||
| 328 | +| 操作 | 接口 | | ||
| 329 | +|------|------| | ||
| 330 | +| 查询工单详情 | `GET /app-api/bpm/garden/workorder/get?id={id}` | | ||
| 331 | +| 工单分页查询 | `GET /app-api/bpm/garden/workorder/page` | | ||
| 332 | +| 查询待办任务 | `GET /app-api/bpm/garden/workorder/todoPage` | | ||
| 333 | +| 审批通过 | `PUT /app-api/bpm/garden/workorder/approve` | | ||
| 334 | +| 退回任务 | `PUT /app-api/bpm/garden/workorder/return` | | ||
| 335 | +| 撤回任务 | `PUT /app-api/bpm/garden/workorder/withdraw?taskId={taskId}` | | ||
| 336 | + | ||
| 337 | +--- | ||
| 338 | + | ||
| 339 | +## 9. 变更记录 | ||
| 340 | + | ||
| 341 | +| 版本 | 日期 | 变更内容 | 作者 | | ||
| 342 | +|------|------|----------|------| | ||
| 343 | +| v1.0 | 2025-12-06 | 初始版本 | yanhuiqing | | ||
| 344 | +| v1.1 | 2026-06-07 | 完善对外接口文档 | — | | ||
| 345 | + | ||
| 346 | +--- | ||
| 347 | + | ||
| 348 | +> **测试环境地址**:`https://test.jichengshanshui.com.cn:28302/app-api/bpm/garden/workorder/create` | ||
| 349 | +> | ||
| 350 | +> **相关文档**:CLAUDE.md(项目全局约束)、BPM 流程定义文档 |
docs/全量工单统计-建表SQL.sql
0 → 100644
| 1 | +-- ============================================= | ||
| 2 | +-- 全量工单统计 — 建表 SQL | ||
| 3 | +-- 模块: urbanops-module-workorder | ||
| 4 | +-- 日期: 2026-06-11 | ||
| 5 | +-- ============================================= | ||
| 6 | + | ||
| 7 | +CREATE TABLE IF NOT EXISTS `workorder_full_order_statistics` ( | ||
| 8 | + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', | ||
| 9 | + `company_id` BIGINT NOT NULL COMMENT '公司/班组ID(关联 system_dept.id)', | ||
| 10 | + `company_name` VARCHAR(100) DEFAULT NULL COMMENT '公司/班组名称(冗余)', | ||
| 11 | + `type` TINYINT NOT NULL DEFAULT 1 COMMENT '类型: 1=按公司, 2=按班组', | ||
| 12 | + `start_date` DATE NOT NULL COMMENT '统计开始日期', | ||
| 13 | + `end_date` DATE NOT NULL COMMENT '统计结束日期', | ||
| 14 | + `work_order_count` INT NOT NULL DEFAULT 0 COMMENT '工单完成数量', | ||
| 15 | + `inspection_count` INT NOT NULL DEFAULT 0 COMMENT '巡查记录数量', | ||
| 16 | + `maintenance_count` INT NOT NULL DEFAULT 0 COMMENT '养护记录数量', | ||
| 17 | + `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注', | ||
| 18 | + `creator` VARCHAR(64) DEFAULT '' COMMENT '创建者', | ||
| 19 | + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间', | ||
| 20 | + `updater` VARCHAR(64) DEFAULT '' COMMENT '更新者', | ||
| 21 | + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', | ||
| 22 | + `deleted` TINYINT DEFAULT 0 NOT NULL COMMENT '逻辑删除: 0=正常, 1=已删除', | ||
| 23 | + `tenant_id` BIGINT DEFAULT 0 NOT NULL COMMENT '租户编号', | ||
| 24 | + PRIMARY KEY (`id`), | ||
| 25 | + INDEX `idx_company_id` (`company_id`), | ||
| 26 | + INDEX `idx_type` (`type`), | ||
| 27 | + INDEX `idx_date_range` (`start_date`, `end_date`) | ||
| 28 | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全量工单统计表'; |
docs/全量工单统计-菜单SQL.sql
0 → 100644
| 1 | +-- ============================================= | ||
| 2 | +-- 全量工单统计 — 菜单 + 按钮权限 SQL | ||
| 3 | +-- 说明: 替换占位符为实际不冲突的菜单ID | ||
| 4 | +-- DIR_ID = 工单管理 目录菜单ID(如不存在则先创建) | ||
| 5 | +-- PAGE_ID = 全量工单 页面菜单ID | ||
| 6 | +-- BTN_ID = 按钮起始ID(5个按钮连续分配) | ||
| 7 | +-- ============================================= | ||
| 8 | + | ||
| 9 | +-- 1. 全量工单 菜单页面(type=2, parent_id=工单管理目录ID) | ||
| 10 | +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) | ||
| 11 | +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'); | ||
| 12 | + | ||
| 13 | +-- 2. 按钮权限(type=3, parent_id=全量工单页面ID) | ||
| 14 | +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) | ||
| 15 | +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'); | ||
| 16 | + | ||
| 17 | +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) | ||
| 18 | +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'); | ||
| 19 | + | ||
| 20 | +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) | ||
| 21 | +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'); | ||
| 22 | + | ||
| 23 | +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) | ||
| 24 | +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'); | ||
| 25 | + | ||
| 26 | +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) | ||
| 27 | +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'); |
docs/开放API对接接口文档.md
0 → 100644
| 1 | +# UrbanOps 开放 API 对接接口文档 | ||
| 2 | + | ||
| 3 | +> **版本**: v1.0 | ||
| 4 | +> **更新日期**: 2026-06-11 | ||
| 5 | +> **适用对象**: 第三方系统对接开发人员 | ||
| 6 | +> **鉴权方式**: HMAC-SHA256 API 签名 | ||
| 7 | + | ||
| 8 | +--- | ||
| 9 | + | ||
| 10 | +## 目录 | ||
| 11 | + | ||
| 12 | +1. [概述](#1-概述) | ||
| 13 | +2. [签名鉴权机制](#2-签名鉴权机制) | ||
| 14 | +3. [通用规范](#3-通用规范) | ||
| 15 | +4. [接口 1:人员查询](#4-接口-1人员查询) | ||
| 16 | +5. [接口 2:班组查询](#5-接口-2班组查询) | ||
| 17 | +6. [接口 3:考勤查询](#6-接口-3考勤查询) | ||
| 18 | +7. [接口 4:工单查询](#7-接口-4工单查询) | ||
| 19 | +8. [接口 5:物料查询](#8-接口-5物料查询) | ||
| 20 | +9. [接口 6:全量工单统计](#9-接口-6全量工单统计) | ||
| 21 | +10. [错误码说明](#10-错误码说明) | ||
| 22 | +11. [附录:客户端签名代码示例](#11-附录客户端签名代码示例) | ||
| 23 | + | ||
| 24 | +--- | ||
| 25 | + | ||
| 26 | +## 1. 概述 | ||
| 27 | + | ||
| 28 | +### 1.1 基本信息 | ||
| 29 | + | ||
| 30 | +| 项目 | 说明 | | ||
| 31 | +|------|------| | ||
| 32 | +| 接口协议 | HTTPS | | ||
| 33 | +| 接口地址 | `https://{host}/open-api/` | | ||
| 34 | +| 鉴权方式 | HMAC-SHA256 API 签名(详见第2节) | | ||
| 35 | +| 响应格式 | JSON | | ||
| 36 | +| 字符编码 | UTF-8 | | ||
| 37 | + | ||
| 38 | +### 1.2 接口列表 | ||
| 39 | + | ||
| 40 | +| # | 接口名称 | 路径 | 方法 | 说明 | | ||
| 41 | +|---|---------|------|------|------| | ||
| 42 | +| 1 | 人员查询 | `/open-api/staff/query` | GET | 获取人员档案,支持按部门筛选 | | ||
| 43 | +| 2 | 班组查询 | `/open-api/teams/query` | GET | 获取班组信息,含班组长和人员数量 | | ||
| 44 | +| 3 | 考勤查询 | `/open-api/attendance/query` | GET | 获取考勤打卡数据 | | ||
| 45 | +| 4 | 工单查询 | `/open-api/tasks/query` | GET | 获取工单全生命周期数据 | | ||
| 46 | +| 5 | 物料查询 | `/open-api/materials/query` | GET | 获取物料消耗与照片数据 | | ||
| 47 | +| 6 | 全量工单统计 | `/open-api/full-order-statistics/query` | GET | 获取公司/班组工单/巡查/养护完成数量 | | ||
| 48 | + | ||
| 49 | +### 1.3 接入前提 | ||
| 50 | + | ||
| 51 | +接入前需向 UrbanOps 平台申请以下凭证(由运维人员配置到 Redis): | ||
| 52 | + | ||
| 53 | +| 参数 | 说明 | | ||
| 54 | +|------|------| | ||
| 55 | +| `appId` | 应用唯一标识(由平台分配) | | ||
| 56 | +| `appSecret` | 应用密钥(妥善保管,不可泄露) | | ||
| 57 | + | ||
| 58 | +--- | ||
| 59 | + | ||
| 60 | +## 2. 签名鉴权机制 | ||
| 61 | + | ||
| 62 | +### 2.1 签名参数 | ||
| 63 | + | ||
| 64 | +每个请求必须携带以下 **4 个请求头**: | ||
| 65 | + | ||
| 66 | +| 请求头 | 类型 | 必填 | 说明 | | ||
| 67 | +|--------|------|------|------| | ||
| 68 | +| `appId` | String | ✅ | 平台分配的应用 ID | | ||
| 69 | +| `timestamp` | String | ✅ | 当前 Unix 时间戳(**毫秒**),与服务器时间误差不能超过 60 秒 | | ||
| 70 | +| `nonce` | String | ✅ | 随机字符串,**长度 ≥ 10**,每次请求必须不同(防重放) | | ||
| 71 | +| `sign` | String | ✅ | 签名字符串(SHA256 计算结果,**小写十六进制**) | | ||
| 72 | + | ||
| 73 | +### 2.2 签名算法 | ||
| 74 | + | ||
| 75 | +#### 签名字符串构造规则 | ||
| 76 | + | ||
| 77 | +``` | ||
| 78 | +签名字符串 = queryParams(按key字母序排列的value拼接) | ||
| 79 | + + headerParams(按key字母序排列的value拼接) | ||
| 80 | + + appSecret | ||
| 81 | +``` | ||
| 82 | + | ||
| 83 | +**具体步骤:** | ||
| 84 | + | ||
| 85 | +1. **排序 Query 参数值**:将 URL 中所有 query 参数按 key 字母升序排列,拼接所有 value | ||
| 86 | +2. **拼接 Header 参数值**:按 key 字母升序(即 `appId` → `nonce` → `timestamp`),依次拼接它们的值 | ||
| 87 | +3. **追加 appSecret**:在最后拼接 `appSecret` | ||
| 88 | +4. **计算 SHA256**:对上述完整字符串计算 SHA256 哈希,转为**小写十六进制**字符串 | ||
| 89 | + | ||
| 90 | +#### 签名公式 | ||
| 91 | + | ||
| 92 | +``` | ||
| 93 | +sign = SHA256( | ||
| 94 | + sortedQueryParamValues | ||
| 95 | + + appId值 + nonce值 + timestamp值 | ||
| 96 | + + appSecret | ||
| 97 | +) | ||
| 98 | +``` | ||
| 99 | + | ||
| 100 | +> **注意**:GET 请求无 body,body 部分为空字符串(不参与签名)。 | ||
| 101 | + | ||
| 102 | +### 2.3 签名示例 | ||
| 103 | + | ||
| 104 | +假设: | ||
| 105 | +- `appId` = `my_app_001` | ||
| 106 | +- `appSecret` = `abc123xyz` | ||
| 107 | +- 请求 URL:`/open-api/staff/query?deptId=100` | ||
| 108 | + | ||
| 109 | +**步骤拆解:** | ||
| 110 | + | ||
| 111 | +``` | ||
| 112 | +queryParams(sorted by key): | ||
| 113 | + deptId=100 | ||
| 114 | + → values = "100" | ||
| 115 | + | ||
| 116 | +headerParams(sorted by key): | ||
| 117 | + appId=my_app_001, nonce=a1b2c3d4e5f6, timestamp=1749696000000 | ||
| 118 | + → values = "my_app_001" + "a1b2c3d4e5f6" + "1749696000000" | ||
| 119 | + | ||
| 120 | +signStr = "100" + "my_app_001" + "a1b2c3d4e5f6" + "1749696000000" + "abc123xyz" | ||
| 121 | + | ||
| 122 | +sign = SHA256(signStr) | ||
| 123 | +``` | ||
| 124 | + | ||
| 125 | +### 2.4 签名校验规则 | ||
| 126 | + | ||
| 127 | +| 校验项 | 规则 | 失败返回 | | ||
| 128 | +|--------|------|----------| | ||
| 129 | +| appId 非空 | 必须传递 | `400 签名不正确` | | ||
| 130 | +| timestamp 时效 | `|serverTime - timestamp| ≤ 60s` | `400 签名不正确` | | ||
| 131 | +| nonce 长度 | `len(nonce) ≥ 10` | `400 签名不正确` | | ||
| 132 | +| sign 非空 | 必须传递 | `400 签名不正确` | | ||
| 133 | +| 签名比对 | `serverSign == clientSign` | `400 签名不正确` | | ||
| 134 | +| nonce 防重放 | 同一 appId + nonce 组合只能使用一次 | `900 存在重复请求` | | ||
| 135 | + | ||
| 136 | +--- | ||
| 137 | + | ||
| 138 | +## 3. 通用规范 | ||
| 139 | + | ||
| 140 | +### 3.1 通用响应格式 | ||
| 141 | + | ||
| 142 | +```json | ||
| 143 | +{ | ||
| 144 | + "code": 0, | ||
| 145 | + "msg": "成功", | ||
| 146 | + "data": [ ... ] | ||
| 147 | +} | ||
| 148 | +``` | ||
| 149 | + | ||
| 150 | +| 字段 | 类型 | 说明 | | ||
| 151 | +|------|------|------| | ||
| 152 | +| `code` | Integer | `0` 表示成功,非 `0` 表示失败 | | ||
| 153 | +| `msg` | String | 提示信息 | | ||
| 154 | +| `data` | Array | 业务数据列表(根据查询条件返回所有匹配记录) | | ||
| 155 | + | ||
| 156 | +### 3.2 日期时间格式 | ||
| 157 | + | ||
| 158 | +所有日期时间字段统一使用格式:`yyyy-MM-dd HH:mm:ss` | ||
| 159 | + | ||
| 160 | +示例:`2026-06-11 09:00:00` | ||
| 161 | + | ||
| 162 | +--- | ||
| 163 | + | ||
| 164 | +## 4. 接口 1:人员查询 | ||
| 165 | + | ||
| 166 | +### 4.1 基本信息 | ||
| 167 | + | ||
| 168 | +| 项目 | 说明 | | ||
| 169 | +|------|------| | ||
| 170 | +| 路径 | `/open-api/staff/query` | | ||
| 171 | +| 方法 | `GET` | | ||
| 172 | +| 说明 | 获取人员基础档案信息,支持按部门、姓名、手机号等条件筛选 | | ||
| 173 | + | ||
| 174 | +### 4.2 请求参数 | ||
| 175 | + | ||
| 176 | +| 参数 | 类型 | 必填 | 说明 | | ||
| 177 | +|------|------|------|------| | ||
| 178 | +| `name` | String | 否 | 姓名,模糊匹配 | | ||
| 179 | +| `mobile` | String | 否 | 手机号,模糊匹配 | | ||
| 180 | +| `deptId` | Long | 否 | 部门ID,精确匹配 | | ||
| 181 | +| `status` | Integer | 否 | 状态:0=启用,1=禁用 | | ||
| 182 | +| `busiLine` | String | 否 | 业务线:yl=园林,wy=物业,sz=市政 | | ||
| 183 | + | ||
| 184 | +### 4.3 响应字段 | ||
| 185 | + | ||
| 186 | +| 字段 | 类型 | 说明 | | ||
| 187 | +|------|------|------| | ||
| 188 | +| `id` | Long | 人员ID | | ||
| 189 | +| `username` | String | 用户名 | | ||
| 190 | +| `nickname` | String | 姓名 | | ||
| 191 | +| `mobile` | String | 手机号 | | ||
| 192 | +| `deptId` | Long | 部门ID | | ||
| 193 | +| `deptName` | String | 部门名称(如"养护一班") | | ||
| 194 | +| `postIds` | Array\<Long\> | 岗位ID列表 | | ||
| 195 | +| `postNames` | String | 岗位名称(逗号分隔,如"班组长,技术员") | | ||
| 196 | +| `busiLine` | String | 业务线 | | ||
| 197 | +| `status` | Integer | 状态(0=启用,1=禁用) | | ||
| 198 | +| `cardNumber` | String | 工卡号(设备编号,用于考勤打卡) | | ||
| 199 | +| `isInner` | Integer | 是否内部人员(1=内部,2=外部) | | ||
| 200 | +| `createTime` | String | 入职日期(格式:yyyy-MM-dd HH:mm:ss) | | ||
| 201 | + | ||
| 202 | +### 4.4 请求示例 | ||
| 203 | + | ||
| 204 | +```bash | ||
| 205 | +curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/staff/query?deptId=100" \ | ||
| 206 | + -H "appId: my_app_001" \ | ||
| 207 | + -H "timestamp: 1749696000000" \ | ||
| 208 | + -H "nonce: a1b2c3d4e5f6g7h8" \ | ||
| 209 | + -H "sign: 3a5f8c9e2b1d4f6a8c0e2b4d6f8a0c2e4b6d8f0a" | ||
| 210 | +``` | ||
| 211 | + | ||
| 212 | +### 4.5 响应示例 | ||
| 213 | + | ||
| 214 | +```json | ||
| 215 | +{ | ||
| 216 | + "code": 0, | ||
| 217 | + "msg": "成功", | ||
| 218 | + "data": [ | ||
| 219 | + { | ||
| 220 | + "id": 1001, | ||
| 221 | + "username": "zhangsan", | ||
| 222 | + "nickname": "张三", | ||
| 223 | + "mobile": "13800138001", | ||
| 224 | + "deptId": 100, | ||
| 225 | + "deptName": "养护一班", | ||
| 226 | + "postIds": [10, 20], | ||
| 227 | + "postNames": "班组长,技术员", | ||
| 228 | + "busiLine": "yl", | ||
| 229 | + "status": 0, | ||
| 230 | + "cardNumber": "DEV001", | ||
| 231 | + "isInner": 1, | ||
| 232 | + "createTime": "2024-01-15 00:00:00" | ||
| 233 | + } | ||
| 234 | + ], | ||
| 235 | + "total": 150 | ||
| 236 | + } | ||
| 237 | +``` | ||
| 238 | + | ||
| 239 | +--- | ||
| 240 | + | ||
| 241 | +## 5. 接口 2:班组查询 | ||
| 242 | + | ||
| 243 | +### 5.1 基本信息 | ||
| 244 | + | ||
| 245 | +| 项目 | 说明 | | ||
| 246 | +|------|------| | ||
| 247 | +| 路径 | `/open-api/teams/query` | | ||
| 248 | +| 方法 | `GET` | | ||
| 249 | +| 说明 | 获取组织架构班组信息,含班组长、所属养护所、人员数量 | | ||
| 250 | + | ||
| 251 | +### 5.2 请求参数 | ||
| 252 | + | ||
| 253 | +| 参数 | 类型 | 必填 | 说明 | | ||
| 254 | +|------|------|------|------| | ||
| 255 | +| `name` | String | 否 | 班组名称,模糊匹配 | | ||
| 256 | +| `parentId` | Long | 否 | 父部门ID(上级组织),精确匹配 | | ||
| 257 | +| `status` | Integer | 否 | 状态:0=启用,1=禁用 | | ||
| 258 | + | ||
| 259 | +### 5.3 响应字段 | ||
| 260 | + | ||
| 261 | +| 字段 | 类型 | 说明 | | ||
| 262 | +|------|------|------| | ||
| 263 | +| `id` | Long | 班组ID | | ||
| 264 | +| `name` | String | 班组名称 | | ||
| 265 | +| `parentId` | Long | 父部门ID | | ||
| 266 | +| `parentName` | String | 父部门名称(所属养护所) | | ||
| 267 | +| `leaderUserId` | Long | 班组长人员ID(Staff.id) | | ||
| 268 | +| `leaderUserName` | String | 班组长姓名 | | ||
| 269 | +| `memberCount` | Long | 人员数量 | | ||
| 270 | +| `sort` | Integer | 排序 | | ||
| 271 | +| `status` | Integer | 状态(0=启用,1=禁用) | | ||
| 272 | + | ||
| 273 | +### 5.4 请求示例 | ||
| 274 | + | ||
| 275 | +```bash | ||
| 276 | +curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/teams/query" \ | ||
| 277 | + -H "appId: my_app_001" \ | ||
| 278 | + -H "timestamp: 1749696000000" \ | ||
| 279 | + -H "nonce: b2c3d4e5f6g7h8i9" \ | ||
| 280 | + -H "sign: ..." | ||
| 281 | +``` | ||
| 282 | + | ||
| 283 | +### 5.5 响应示例 | ||
| 284 | + | ||
| 285 | +```json | ||
| 286 | +{ | ||
| 287 | + "code": 0, | ||
| 288 | + "msg": "成功", | ||
| 289 | + "data": [ | ||
| 290 | + { | ||
| 291 | + "id": 100, | ||
| 292 | + "name": "养护一班", | ||
| 293 | + "parentId": 1, | ||
| 294 | + "parentName": "蓟城山水集团", | ||
| 295 | + "leaderUserId": 1001, | ||
| 296 | + "leaderUserName": "张三", | ||
| 297 | + "memberCount": 15, | ||
| 298 | + "sort": 1, | ||
| 299 | + "status": 0 | ||
| 300 | + } | ||
| 301 | + ], | ||
| 302 | + "total": 12 | ||
| 303 | + } | ||
| 304 | +} | ||
| 305 | +``` | ||
| 306 | + | ||
| 307 | +--- | ||
| 308 | + | ||
| 309 | +## 6. 接口 3:考勤查询 | ||
| 310 | + | ||
| 311 | +### 6.1 基本信息 | ||
| 312 | + | ||
| 313 | +| 项目 | 说明 | | ||
| 314 | +|------|------| | ||
| 315 | +| 路径 | `/open-api/attendance/query` | | ||
| 316 | +| 方法 | `GET` | | ||
| 317 | +| 说明 | 获取电子围栏打卡与位置数据,支持按班组、人员、时间范围筛选 | | ||
| 318 | + | ||
| 319 | +### 6.2 请求参数 | ||
| 320 | + | ||
| 321 | +| 参数 | 类型 | 必填 | 说明 | | ||
| 322 | +|------|------|------|------| | ||
| 323 | +| `userId` | Long | 否 | 人员ID,精确匹配 | | ||
| 324 | +| `deptId` | Long | 否 | 部门ID(班组),精确匹配 | | ||
| 325 | +| `busiLine` | String | 否 | 业务线 | | ||
| 326 | +| `punchType` | String | 否 | 打卡类型(上班/下班) | | ||
| 327 | +| `startTime` | String | 否 | 开始时间(格式:yyyy-MM-dd HH:mm:ss) | | ||
| 328 | +| `endTime` | String | 否 | 结束时间(格式:yyyy-MM-dd HH:mm:ss) | | ||
| 329 | + | ||
| 330 | +### 6.3 响应字段 | ||
| 331 | + | ||
| 332 | +| 字段 | 类型 | 说明 | | ||
| 333 | +|------|------|------| | ||
| 334 | +| `id` | Integer | 记录ID | | ||
| 335 | +| `deviceCode` | String | 设备编码 | | ||
| 336 | +| `deviceName` | String | 设备名称 | | ||
| 337 | +| `punchType` | String | 打卡类型 | | ||
| 338 | +| `userId` | Long | 人员ID | | ||
| 339 | +| `nickname` | String | 人员姓名 | | ||
| 340 | +| `deptId` | Long | 部门ID | | ||
| 341 | +| `deptName` | String | 部门名称 | | ||
| 342 | +| `address` | String | 打卡地址 | | ||
| 343 | +| `latGcj02` | BigDecimal | 高德纬度(GCJ-02坐标系) | | ||
| 344 | +| `lngGcj02` | BigDecimal | 高德经度(GCJ-02坐标系) | | ||
| 345 | +| `lat` | BigDecimal | WGS84纬度 | | ||
| 346 | +| `lng` | BigDecimal | WGS84经度 | | ||
| 347 | +| `createTime` | String | 打卡时间 | | ||
| 348 | + | ||
| 349 | +> **注意**:当前版本暂不支持"轨迹点集""是否佩戴工卡""记录仪状态"字段(数据库无对应存储)。待业务系统确认后将补充。 | ||
| 350 | + | ||
| 351 | +### 6.4 响应示例 | ||
| 352 | + | ||
| 353 | +```json | ||
| 354 | +{ | ||
| 355 | + "code": 0, | ||
| 356 | + "msg": "成功", | ||
| 357 | + "data": [ | ||
| 358 | + { | ||
| 359 | + "id": 1, | ||
| 360 | + "deviceCode": "DEV001", | ||
| 361 | + "deviceName": "打卡机01", | ||
| 362 | + "punchType": "上班", | ||
| 363 | + "userId": 1001, | ||
| 364 | + "nickname": "张三", | ||
| 365 | + "deptId": 100, | ||
| 366 | + "deptName": "养护一班", | ||
| 367 | + "address": "北京市西城区xx路xx号", | ||
| 368 | + "latGcj02": 39.9042, | ||
| 369 | + "lngGcj02": 116.4074, | ||
| 370 | + "lat": 39.9010, | ||
| 371 | + "lng": 116.4030, | ||
| 372 | + "createTime": "2026-06-11 08:30:00" | ||
| 373 | + } | ||
| 374 | + ], | ||
| 375 | + "total": 320 | ||
| 376 | + } | ||
| 377 | +} | ||
| 378 | +``` | ||
| 379 | + | ||
| 380 | +--- | ||
| 381 | + | ||
| 382 | +## 7. 接口 4:工单查询 | ||
| 383 | + | ||
| 384 | +### 7.1 基本信息 | ||
| 385 | + | ||
| 386 | +| 项目 | 说明 | | ||
| 387 | +|------|------| | ||
| 388 | +| 路径 | `/open-api/tasks/query` | | ||
| 389 | +| 方法 | `GET` | | ||
| 390 | +| 说明 | 获取工单全生命周期数据,含发布时间、状态、接单渠道、共同处理人 | | ||
| 391 | + | ||
| 392 | +### 7.2 请求参数 | ||
| 393 | + | ||
| 394 | +| 参数 | 类型 | 必填 | 说明 | | ||
| 395 | +|------|------|------|------| | ||
| 396 | +| `orderNo` | String | 否 | 工单号,模糊匹配 | | ||
| 397 | +| `orderName` | String | 否 | 工单名称,模糊匹配 | | ||
| 398 | +| `busiLine` | String | 否 | 业务线 | | ||
| 399 | +| `status` | Integer | 否 | 审批状态 | | ||
| 400 | +| `buzStatus` | String | 否 | 业务状态 | | ||
| 401 | +| `workerId` | Long | 否 | 接单人ID | | ||
| 402 | +| `startDate` | String | 否 | 发布时间起始(yyyy-MM-dd HH:mm:ss) | | ||
| 403 | +| `endDate` | String | 否 | 发布时间截止(yyyy-MM-dd HH:mm:ss) | | ||
| 404 | + | ||
| 405 | +### 7.3 响应字段 | ||
| 406 | + | ||
| 407 | +| 字段 | 类型 | 说明 | | ||
| 408 | +|------|------|------| | ||
| 409 | +| `id` | Long | 工单ID | | ||
| 410 | +| `orderNo` | String | 工单号 | | ||
| 411 | +| `orderName` | String | 工单名称 | | ||
| 412 | +| `orderType` | String | 工单类型:Q=快速,C=普通,O=其他 | | ||
| 413 | +| `busiLine` | String | 业务线 | | ||
| 414 | +| `commitDate` | String | 发布时间(工单派发时间) | | ||
| 415 | +| `finishDate` | String | 完成时间 | | ||
| 416 | +| `userId` | Long | 发布人ID | | ||
| 417 | +| `userName` | String | 发布人姓名 | | ||
| 418 | +| `workerId` | Long | 接单人ID | | ||
| 419 | +| `workerName` | String | 接单人姓名 | | ||
| 420 | +| `coHandlers` | Array\<String\> | 共同处理人姓名列表 | | ||
| 421 | +| `woSourceName` | String | 接单渠道(小程序/人工派单等) | | ||
| 422 | +| `status` | Integer | 审批状态 | | ||
| 423 | +| `buzStatus` | String | 业务状态 | | ||
| 424 | +| `remark` | String | 备注 | | ||
| 425 | +| `photos` | Array\<PhotoVO\> | 工单附件照片 | | ||
| 426 | + | ||
| 427 | +**PhotoVO 结构:** | ||
| 428 | + | ||
| 429 | +| 字段 | 类型 | 说明 | | ||
| 430 | +|------|------|------| | ||
| 431 | +| `busiType` | String | 业务类型:`01`=问题照片,`02`=处理中,`03`=完成 | | ||
| 432 | +| `fileName` | String | 文件名 | | ||
| 433 | +| `url` | String | 文件URL | | ||
| 434 | + | ||
| 435 | +### 7.4 响应示例 | ||
| 436 | + | ||
| 437 | +```json | ||
| 438 | +{ | ||
| 439 | + "code": 0, | ||
| 440 | + "msg": "成功", | ||
| 441 | + "data": [ | ||
| 442 | + { | ||
| 443 | + "id": 5001, | ||
| 444 | + "orderNo": "WO202606110001", | ||
| 445 | + "orderName": "树木修剪", | ||
| 446 | + "orderType": "C", | ||
| 447 | + "busiLine": "yl", | ||
| 448 | + "commitDate": "2026-06-11 09:00:00", | ||
| 449 | + "finishDate": null, | ||
| 450 | + "userId": 1, | ||
| 451 | + "userName": "管理员", | ||
| 452 | + "workerId": 1001, | ||
| 453 | + "workerName": "张三", | ||
| 454 | + "coHandlers": ["李四", "王五"], | ||
| 455 | + "woSourceName": "小程序", | ||
| 456 | + "status": 1, | ||
| 457 | + "buzStatus": "processing", | ||
| 458 | + "remark": "紧急处理", | ||
| 459 | + "photos": [ | ||
| 460 | + { | ||
| 461 | + "busiType": "01", | ||
| 462 | + "fileName": "problem.jpg", | ||
| 463 | + "url": "https://xxx.com/file/abc.jpg" | ||
| 464 | + } | ||
| 465 | + ] | ||
| 466 | + } | ||
| 467 | + ], | ||
| 468 | + "total": 85 | ||
| 469 | + } | ||
| 470 | +} | ||
| 471 | +``` | ||
| 472 | + | ||
| 473 | +--- | ||
| 474 | + | ||
| 475 | +## 8. 接口 5:物料查询 | ||
| 476 | + | ||
| 477 | +### 8.1 基本信息 | ||
| 478 | + | ||
| 479 | +| 项目 | 说明 | | ||
| 480 | +|------|------| | ||
| 481 | +| 路径 | `/open-api/materials/query` | | ||
| 482 | +| 方法 | `GET` | | ||
| 483 | +| 说明 | 获取物料消耗记录与人机材照片数据,支持按工单筛选 | | ||
| 484 | + | ||
| 485 | +### 8.2 请求参数 | ||
| 486 | + | ||
| 487 | +| 参数 | 类型 | 必填 | 说明 | | ||
| 488 | +|------|------|------|------| | ||
| 489 | +| `orderNo` | String | 否 | 工单号,精确匹配 | | ||
| 490 | +| `classifyName` | String | 否 | 物料分类名,模糊匹配 | | ||
| 491 | +| `typeName` | String | 否 | 物料类型名,模糊匹配 | | ||
| 492 | +| `userId` | Long | 否 | 使用人ID | | ||
| 493 | +| `startDate` | String | 否 | 开始时间(yyyy-MM-dd HH:mm:ss) | | ||
| 494 | +| `endDate` | String | 否 | 结束时间(yyyy-MM-dd HH:mm:ss) | | ||
| 495 | + | ||
| 496 | +### 8.3 响应字段 | ||
| 497 | + | ||
| 498 | +| 字段 | 类型 | 说明 | | ||
| 499 | +|------|------|------| | ||
| 500 | +| `id` | Long | 记录ID | | ||
| 501 | +| `orderNo` | String | 关联工单号 | | ||
| 502 | +| `materialName` | String | 物料名称 | | ||
| 503 | +| `specifications` | String | 规格 | | ||
| 504 | +| `userCount` | Long | 使用数量 | | ||
| 505 | +| `unitName` | String | 单位 | | ||
| 506 | +| `classifyName` | String | 物料分类 | | ||
| 507 | +| `typeName` | String | 物料类型 | | ||
| 508 | +| `typeDetailName` | String | 物料明细类型 | | ||
| 509 | +| `userId` | Long | 使用人ID | | ||
| 510 | +| `userName` | String | 使用人姓名 | | ||
| 511 | +| `source` | Integer | 来源:1=工单,2=巡检,3=养护 | | ||
| 512 | +| `createTime` | String | 上传时间 | | ||
| 513 | +| `photos` | Array\<PhotoVO\> | 人机材照片(结构同工单接口) | | ||
| 514 | + | ||
| 515 | +### 8.4 响应示例 | ||
| 516 | + | ||
| 517 | +```json | ||
| 518 | +{ | ||
| 519 | + "code": 0, | ||
| 520 | + "msg": "成功", | ||
| 521 | + "data": [ | ||
| 522 | + { | ||
| 523 | + "id": 3001, | ||
| 524 | + "orderNo": "WO202606110001", | ||
| 525 | + "materialName": "高效氯氰菊酯", | ||
| 526 | + "specifications": "500ml/瓶", | ||
| 527 | + "userCount": 5, | ||
| 528 | + "unitName": "瓶", | ||
| 529 | + "classifyName": "药品", | ||
| 530 | + "typeName": "杀虫剂", | ||
| 531 | + "typeDetailName": "有机磷", | ||
| 532 | + "userId": 1001, | ||
| 533 | + "userName": "张三", | ||
| 534 | + "source": 1, | ||
| 535 | + "createTime": "2026-06-11 14:30:00", | ||
| 536 | + "photos": [ | ||
| 537 | + { | ||
| 538 | + "busiType": "02", | ||
| 539 | + "fileName": "material.jpg", | ||
| 540 | + "url": "https://xxx.com/file/def.jpg" | ||
| 541 | + } | ||
| 542 | + ] | ||
| 543 | + } | ||
| 544 | + ], | ||
| 545 | + "total": 45 | ||
| 546 | + } | ||
| 547 | +} | ||
| 548 | +``` | ||
| 549 | + | ||
| 550 | +--- | ||
| 551 | + | ||
| 552 | +## 9. 接口 6:全量工单统计 | ||
| 553 | + | ||
| 554 | +### 9.1 基本信息 | ||
| 555 | + | ||
| 556 | +| 项目 | 说明 | | ||
| 557 | +|------|------| | ||
| 558 | +| 路径 | `/open-api/full-order-statistics/query` | | ||
| 559 | +| 方法 | `GET` | | ||
| 560 | +| 说明 | 获取各公司/班组在一段时间内实际完成的工单数量、巡查记录数量、养护记录数量 | | ||
| 561 | + | ||
| 562 | +### 9.2 请求参数 | ||
| 563 | + | ||
| 564 | +| 参数 | 类型 | 必填 | 说明 | | ||
| 565 | +|------|------|------|------| | ||
| 566 | +| `companyId` | Long | 否 | 公司/班组ID,精确匹配 | | ||
| 567 | +| `companyName` | String | 否 | 公司/班组名称,模糊匹配 | | ||
| 568 | +| `type` | Integer | 否 | 类型:1=按公司,2=按班组 | | ||
| 569 | +| `startDate` | String | 否 | 统计开始日期(格式:yyyy-MM-dd,返回开始日期>=该值的记录) | | ||
| 570 | +| `endDate` | String | 否 | 统计结束日期(格式:yyyy-MM-dd,返回结束日期<=该值的记录) | | ||
| 571 | + | ||
| 572 | +### 9.3 响应字段 | ||
| 573 | + | ||
| 574 | +| 字段 | 类型 | 说明 | | ||
| 575 | +|------|------|------| | ||
| 576 | +| `id` | Long | 记录ID | | ||
| 577 | +| `companyId` | Long | 公司/班组ID | | ||
| 578 | +| `companyName` | String | 公司/班组名称 | | ||
| 579 | +| `type` | Integer | 类型:1=按公司,2=按班组 | | ||
| 580 | +| `startDate` | String | 统计开始日期 | | ||
| 581 | +| `endDate` | String | 统计结束日期 | | ||
| 582 | +| `workOrderCount` | Integer | 工单完成数量 | | ||
| 583 | +| `inspectionCount` | Integer | 巡查记录数量 | | ||
| 584 | +| `maintenanceCount` | Integer | 养护记录数量 | | ||
| 585 | +| `remark` | String | 备注 | | ||
| 586 | +| `createTime` | String | 创建时间 | | ||
| 587 | + | ||
| 588 | +### 9.4 请求示例 | ||
| 589 | + | ||
| 590 | +```bash | ||
| 591 | +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" \ | ||
| 592 | + -H "appId: my_app_001" \ | ||
| 593 | + -H "timestamp: 1749696000000" \ | ||
| 594 | + -H "nonce: f6g7h8i9j0k1l2m3" \ | ||
| 595 | + -H "sign: ..." | ||
| 596 | +``` | ||
| 597 | + | ||
| 598 | +### 9.5 响应示例 | ||
| 599 | + | ||
| 600 | +```json | ||
| 601 | +{ | ||
| 602 | + "code": 0, | ||
| 603 | + "msg": "成功", | ||
| 604 | + "data": [ | ||
| 605 | + { | ||
| 606 | + "id": 1, | ||
| 607 | + "companyId": 100, | ||
| 608 | + "companyName": "养护一班", | ||
| 609 | + "type": 1, | ||
| 610 | + "startDate": "2026-06-01", | ||
| 611 | + "endDate": "2026-06-30", | ||
| 612 | + "workOrderCount": 85, | ||
| 613 | + "inspectionCount": 120, | ||
| 614 | + "maintenanceCount": 60, | ||
| 615 | + "remark": "二季度统计", | ||
| 616 | + "createTime": "2026-06-11 10:00:00" | ||
| 617 | + } | ||
| 618 | + ] | ||
| 619 | +} | ||
| 620 | +``` | ||
| 621 | + | ||
| 622 | +--- | ||
| 623 | + | ||
| 624 | +## 10. 错误码说明 | ||
| 625 | + | ||
| 626 | +### 10.1 签名相关错误 | ||
| 627 | + | ||
| 628 | +### 10.2 业务相关错误 | ||
| 629 | + | ||
| 630 | +| code | msg | 说明 | | ||
| 631 | +|------|-----|------| | ||
| 632 | +| 400 | 签名不正确 | 签名验证失败(appId不存在/timestamp超时/nonce长度不足/签名不匹配) | | ||
| 633 | +| 900 | 存在重复请求 | nonce 已被使用(重放攻击检测) | | ||
| 634 | + | ||
| 635 | +### 10.3 通用错误 | ||
| 636 | + | ||
| 637 | +| code | msg | 说明 | | ||
| 638 | +|------|-----|------| | ||
| 639 | +| 1-100-020-001 | 人员查询失败 | 人员查询内部错误 | | ||
| 640 | +| 1-100-020-002 | 班组查询失败 | 班组查询内部错误 | | ||
| 641 | +| 1-100-020-003 | 考勤查询失败 | 考勤查询内部错误 | | ||
| 642 | +| 1-100-020-004 | 工单查询失败 | 工单查询内部错误 | | ||
| 643 | +| 1-100-020-005 | 物料查询失败 | 物料查询内部错误 | | ||
| 644 | + | ||
| 645 | +| 1-900-005-001 | 全量工单统计不存在 | 记录不存在 | | ||
| 646 | +| 1-900-005-002 | 相同公司/班组同时间段统计已存在 | 重复数据校验 | | ||
| 647 | + | ||
| 648 | +### 10.3 通用错误 | ||
| 649 | + | ||
| 650 | +| code | msg | 说明 | | ||
| 651 | +|------|-----|------| | ||
| 652 | +| 400 | 参数校验失败 | 请求参数格式或值不正确 | | ||
| 653 | +| 500 | 系统内部错误 | 服务器内部异常 | | ||
| 654 | + | ||
| 655 | +--- | ||
| 656 | + | ||
| 657 | +## 11. 附录:客户端签名代码示例 | ||
| 658 | + | ||
| 659 | +### 10.1 Java 示例 | ||
| 660 | + | ||
| 661 | +```java | ||
| 662 | +import cn.hutool.crypto.digest.DigestUtil; | ||
| 663 | +import cn.hutool.http.HttpRequest; | ||
| 664 | +import cn.hutool.http.HttpResponse; | ||
| 665 | +import cn.hutool.core.util.RandomUtil; | ||
| 666 | + | ||
| 667 | +import java.util.Map; | ||
| 668 | +import java.util.TreeMap; | ||
| 669 | + | ||
| 670 | +public class OpenApiClient { | ||
| 671 | + | ||
| 672 | + private static final String BASE_URL = "https://test.jichengshanshui.com.cn:28302"; | ||
| 673 | + private static final String APP_ID = "your_app_id"; | ||
| 674 | + private static final String APP_SECRET = "your_app_secret"; | ||
| 675 | + | ||
| 676 | + public static String call(String path, Map<String, Object> params) { | ||
| 677 | + String timestamp = String.valueOf(System.currentTimeMillis()); | ||
| 678 | + String nonce = RandomUtil.randomString(16); | ||
| 679 | + String sign = buildSign(params, timestamp, nonce); | ||
| 680 | + | ||
| 681 | + String url = BASE_URL + path; | ||
| 682 | + HttpResponse response = HttpRequest.get(url) | ||
| 683 | + .form(params) | ||
| 684 | + .header("appId", APP_ID) | ||
| 685 | + .header("timestamp", timestamp) | ||
| 686 | + .header("nonce", nonce) | ||
| 687 | + .header("sign", sign) | ||
| 688 | + .execute(); | ||
| 689 | + return response.body(); | ||
| 690 | + } | ||
| 691 | + | ||
| 692 | + private static String buildSign(Map<String, Object> params, | ||
| 693 | + String timestamp, String nonce) { | ||
| 694 | + StringBuilder sb = new StringBuilder(); | ||
| 695 | + | ||
| 696 | + // 1. 排序 query 参数值 | ||
| 697 | + if (params != null && !params.isEmpty()) { | ||
| 698 | + new TreeMap<>(params).values().stream() | ||
| 699 | + .map(String::valueOf).forEach(sb::append); | ||
| 700 | + } | ||
| 701 | + | ||
| 702 | + // 2. header 参数值(appId, nonce, timestamp 字母序) | ||
| 703 | + sb.append(APP_ID); | ||
| 704 | + sb.append(nonce); | ||
| 705 | + sb.append(timestamp); | ||
| 706 | + | ||
| 707 | + // 3. 追加 appSecret + SHA256 | ||
| 708 | + sb.append(APP_SECRET); | ||
| 709 | + return DigestUtil.sha256Hex(sb.toString()); | ||
| 710 | + } | ||
| 711 | +} | ||
| 712 | +``` | ||
| 713 | + | ||
| 714 | +### 10.2 Python 示例 | ||
| 715 | + | ||
| 716 | +```python | ||
| 717 | +import hashlib | ||
| 718 | +import time | ||
| 719 | +import random | ||
| 720 | +import string | ||
| 721 | +import requests | ||
| 722 | + | ||
| 723 | +BASE_URL = "https://test.jichengshanshui.com.cn:28302" | ||
| 724 | +APP_ID = "your_app_id" | ||
| 725 | +APP_SECRET = "your_app_secret" | ||
| 726 | + | ||
| 727 | +def build_sign(params, timestamp, nonce): | ||
| 728 | + parts = [] | ||
| 729 | + if params: | ||
| 730 | + for key in sorted(params.keys()): | ||
| 731 | + parts.append(str(params[key])) | ||
| 732 | + parts.append(APP_ID) | ||
| 733 | + parts.append(nonce) | ||
| 734 | + parts.append(timestamp) | ||
| 735 | + parts.append(APP_SECRET) | ||
| 736 | + sign_str = "".join(parts) | ||
| 737 | + return hashlib.sha256(sign_str.encode()).hexdigest() | ||
| 738 | + | ||
| 739 | +def call(path, params=None): | ||
| 740 | + if params is None: | ||
| 741 | + params = {} | ||
| 742 | + timestamp = str(int(time.time() * 1000)) | ||
| 743 | + nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=16)) | ||
| 744 | + sign = build_sign(params, timestamp, nonce) | ||
| 745 | + headers = { | ||
| 746 | + "appId": APP_ID, | ||
| 747 | + "timestamp": timestamp, | ||
| 748 | + "nonce": nonce, | ||
| 749 | + "sign": sign | ||
| 750 | + } | ||
| 751 | + response = requests.get(BASE_URL + path, params=params, headers=headers) | ||
| 752 | + return response.json() | ||
| 753 | +``` | ||
| 754 | + | ||
| 755 | +### 10.3 Shell (cURL) 示例 | ||
| 756 | + | ||
| 757 | +```bash | ||
| 758 | +#!/bin/bash | ||
| 759 | +APP_ID="your_app_id" | ||
| 760 | +APP_SECRET="your_app_secret" | ||
| 761 | +TIMESTAMP=$(date +%s%3N) | ||
| 762 | +NONCE=$(openssl rand -hex 16) | ||
| 763 | + | ||
| 764 | +# 示例:/open-api/staff/query?deptId=100 | ||
| 765 | +# query params 排序后 values: deptId=100 → "100" | ||
| 766 | +QUERY_VALUES="100" | ||
| 767 | +HEADER_VALUES="${APP_ID}${NONCE}${TIMESTAMP}" | ||
| 768 | +SIGN_STR="${QUERY_VALUES}${HEADER_VALUES}${APP_SECRET}" | ||
| 769 | +SIGN=$(echo -n "$SIGN_STR" | openssl dgst -sha256 -hex | awk '{print $2}') | ||
| 770 | + | ||
| 771 | +curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/staff/query?deptId=100" \ | ||
| 772 | + -H "appId: ${APP_ID}" \ | ||
| 773 | + -H "timestamp: ${TIMESTAMP}" \ | ||
| 774 | + -H "nonce: ${NONCE}" \ | ||
| 775 | + -H "sign: ${SIGN}" | ||
| 776 | +``` | ||
| 777 | + | ||
| 778 | +--- | ||
| 779 | + | ||
| 780 | +> **本文档基于 2026-06-11 代码生成,如有接口变更请同步更新。** |
docs/开放API对接接口文档.pdf
0 → 100644
No preview for this file type
docs/系统鉴权方式说明文档.md
0 → 100644
| 1 | +# UrbanOps 系统鉴权方式说明文档 | ||
| 2 | + | ||
| 3 | +> **项目**: 蓟城山水集团全域智能运营管理平台 (UrbanOps) | ||
| 4 | +> **版本**: 基于 2026-06-11 代码分析 | ||
| 5 | +> **适用对象**: 前端开发、第三方对接、运维部署 | ||
| 6 | + | ||
| 7 | +--- | ||
| 8 | + | ||
| 9 | +## 目录 | ||
| 10 | + | ||
| 11 | +- [1. 鉴权体系概览](#1-鉴权体系概览) | ||
| 12 | +- [2. Token 令牌鉴权(核心机制)](#2-token-令牌鉴权核心机制) | ||
| 13 | +- [3. 用户名密码登录鉴权](#3-用户名密码登录鉴权) | ||
| 14 | +- [4. 短信验证码登录鉴权](#4-短信验证码登录鉴权) | ||
| 15 | +- [5. OAuth2 授权服务器](#5-oauth2-授权服务器) | ||
| 16 | +- [6. API 签名鉴权(第三方对接)](#6-api-签名鉴权第三方对接) | ||
| 17 | +- [7. 社交登录鉴权(JustAuth)](#7-社交登录鉴权justauth) | ||
| 18 | +- [8. UAA SSO 单点登录](#8-uaa-sso-单点登录) | ||
| 19 | +- [9. RBAC 权限鉴权](#9-rbac-权限鉴权) | ||
| 20 | +- [10. 租户隔离鉴权](#10-租户隔离鉴权) | ||
| 21 | +- [11. 数据权限鉴权(行级安全)](#11-数据权限鉴权行级安全) | ||
| 22 | +- [12. API 加密](#12-api-加密) | ||
| 23 | +- [13. 接口前缀与鉴权方式对照表](#13-接口前缀与鉴权方式对照表) | ||
| 24 | + | ||
| 25 | +--- | ||
| 26 | + | ||
| 27 | +## 1. 鉴权体系概览 | ||
| 28 | + | ||
| 29 | +UrbanOps 采用 **多层鉴权架构**,从请求进入系统到数据返回,依次经过以下安全层级: | ||
| 30 | + | ||
| 31 | +``` | ||
| 32 | +请求到达 | ||
| 33 | + │ | ||
| 34 | + ├── 第0层: API 加密过滤 (ApiEncryptFilter) ← 可选,按 @ApiEncrypt 注解启用 | ||
| 35 | + │ | ||
| 36 | + ├── 第1层: 租户隔离 (TenantSecurityWebFilter) ← /admin-api/, /app-api/ 强制 | ||
| 37 | + │ | ||
| 38 | + ├── 第2层: Token 令牌校验 (TokenAuthenticationFilter) ← /admin-api/, /app-api/ 强制 | ||
| 39 | + │ | ||
| 40 | + ├── 第3层: 方法级权限 (Spring Security @PreAuthorize) ← /admin-api/ 方法级 | ||
| 41 | + │ | ||
| 42 | + └── 第4层: 数据权限 (DeptDataPermissionRule) ← /admin-api/ MyBatis SQL 注入 | ||
| 43 | +``` | ||
| 44 | + | ||
| 45 | +--- | ||
| 46 | + | ||
| 47 | +## 2. Token 令牌鉴权(核心机制) | ||
| 48 | + | ||
| 49 | +### 2.1 概述 | ||
| 50 | + | ||
| 51 | +Token 令牌鉴权是系统的核心认证方式,覆盖所有 `/admin-api/` 和 `/app-api/` 接口。系统通过自定义 `TokenAuthenticationFilter` 拦截每个请求,提取并验证 Token。 | ||
| 52 | + | ||
| 53 | +### 2.2 核心类 | ||
| 54 | + | ||
| 55 | +| 类名 | 路径 | | ||
| 56 | +|------|------| | ||
| 57 | +| `TokenAuthenticationFilter` | `urbanops-framework/urbanops-spring-boot-starter-security/src/main/java/com/zteits/urbanops/framework/security/core/filter/TokenAuthenticationFilter.java` | | ||
| 58 | +| `SecurityProperties` | `urbanops-framework/urbanops-spring-boot-starter-security/src/main/java/com/zteits/urbanops/framework/security/config/SecurityProperties.java` | | ||
| 59 | +| `OAuth2TokenServiceImpl` | `urbanops-module-system/src/main/java/com/zteits/urbanops/module/system/service/oauth2/OAuth2TokenServiceImpl.java` | | ||
| 60 | +| `SecurityFrameworkUtils` | `urbanops-framework/urbanops-spring-boot-starter-security/src/main/java/com/zteits/urbanops/framework/security/core/util/SecurityFrameworkUtils.java` | | ||
| 61 | + | ||
| 62 | +### 2.3 配置项 | ||
| 63 | + | ||
| 64 | +```yaml | ||
| 65 | +# application.yaml | ||
| 66 | +urbanops: | ||
| 67 | + security: | ||
| 68 | + token-header: Authorization # 请求头名称 | ||
| 69 | + token-parameter: token # Query参数名称(WebSocket场景用) | ||
| 70 | + mock-enable: false # 开发环境Mock模式开关 | ||
| 71 | + mock-secret: test # Mock模式密钥前缀 | ||
| 72 | + password-encoder-length: 4 # BCrypt加密强度 | ||
| 73 | +``` | ||
| 74 | + | ||
| 75 | +### 2.4 工作流程 | ||
| 76 | + | ||
| 77 | +``` | ||
| 78 | +客户端请求(带 Authorization: Bearer {token}) | ||
| 79 | + │ | ||
| 80 | + ▼ | ||
| 81 | +TokenAuthenticationFilter.doFilterInternal() | ||
| 82 | + │ | ||
| 83 | + ├── 1. 提取 Token | ||
| 84 | + │ 方式A: 从请求头 Authorization 提取 | ||
| 85 | + │ 方式B: 从查询参数 ?token=xxx 提取(WebSocket 场景) | ||
| 86 | + │ | ||
| 87 | + ├── 2. 推断 userType | ||
| 88 | + │ /admin-api/ → ADMIN(2) | ||
| 89 | + │ /app-api/ → MEMBER(1) | ||
| 90 | + │ | ||
| 91 | + ├── 3. 调用 oauth2TokenApi.checkAccessToken(token) 校验 | ||
| 92 | + │ 先查 Redis(key: oauth2_access_token:{token}) | ||
| 93 | + │ 缓存未命中则查 MySQL(system_oauth2_access_token 表) | ||
| 94 | + │ | ||
| 95 | + ├── 4. 校验 userType 匹配 | ||
| 96 | + │ 防止 admin token 访问 app 接口(反之亦然) | ||
| 97 | + │ | ||
| 98 | + ├── 5. 构建 LoginUser 对象 | ||
| 99 | + │ 包含 userId, userType, tenantId, scopes, userInfo | ||
| 100 | + │ | ||
| 101 | + └── 6. 设置到 SecurityContextHolder | ||
| 102 | + 后续 Controller 可通过 SecurityFrameworkUtils.getLoginUser() 获取 | ||
| 103 | +``` | ||
| 104 | + | ||
| 105 | +### 2.5 实现代码示例 | ||
| 106 | + | ||
| 107 | +**Token 提取与验证核心代码:** `TokenAuthenticationFilter.java` | ||
| 108 | + | ||
| 109 | +```java | ||
| 110 | +@RequiredArgsConstructor | ||
| 111 | +public class TokenAuthenticationFilter extends OncePerRequestFilter { | ||
| 112 | + | ||
| 113 | + private final SecurityProperties securityProperties; | ||
| 114 | + private final GlobalExceptionHandler globalExceptionHandler; | ||
| 115 | + | ||
| 116 | + @Override | ||
| 117 | + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, | ||
| 118 | + FilterChain chain) throws ServletException, IOException { | ||
| 119 | + // 从请求头或查询参数中提取 Token | ||
| 120 | + String token = SecurityFrameworkUtils.obtainAuthorization( | ||
| 121 | + request, securityProperties.getTokenHeader(), | ||
| 122 | + securityProperties.getTokenParameter()); | ||
| 123 | + | ||
| 124 | + if (StrUtil.isNotEmpty(token)) { | ||
| 125 | + Integer userType = WebFrameworkUtils.getLoginUserType(request); | ||
| 126 | + try { | ||
| 127 | + // 1. 基于 token 构建登录用户 | ||
| 128 | + LoginUser loginUser = buildLoginUserByToken(token, userType); | ||
| 129 | + // 2. 模拟登录(开发环境 fallback) | ||
| 130 | + if (loginUser == null) { | ||
| 131 | + loginUser = mockLoginUser(request, token, userType); | ||
| 132 | + } | ||
| 133 | + // 3. 设置当前用户到 SecurityContext | ||
| 134 | + if (loginUser != null) { | ||
| 135 | + SecurityFrameworkUtils.setLoginUser(loginUser, request); | ||
| 136 | + } | ||
| 137 | + } catch (Throwable ex) { | ||
| 138 | + // Token 非法或过期,直接返回错误 JSON,中断过滤器链 | ||
| 139 | + CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex); | ||
| 140 | + ServletUtils.writeJSON(response, result); | ||
| 141 | + return; | ||
| 142 | + } | ||
| 143 | + } | ||
| 144 | + // 4. 继续执行后续过滤器 | ||
| 145 | + chain.doFilter(request, response); | ||
| 146 | + } | ||
| 147 | + | ||
| 148 | + private LoginUser buildLoginUserByToken(String token, Integer userType) { | ||
| 149 | + // 调用内部 OAuth2 服务校验 token | ||
| 150 | + OAuth2AccessTokenCheckRespDTO accessToken = oauth2TokenApi.checkAccessToken(token); | ||
| 151 | + if (accessToken == null) { | ||
| 152 | + return null; | ||
| 153 | + } | ||
| 154 | + // 校验用户类型匹配 | ||
| 155 | + if (ObjectUtil.notEqual(accessToken.getUserType(), userType)) { | ||
| 156 | + throw new AccessDeniedException("错误的用户类型"); | ||
| 157 | + } | ||
| 158 | + // 构建 LoginUser | ||
| 159 | + LoginUser loginUser = new LoginUser(); | ||
| 160 | + loginUser.setId(accessToken.getUserId()); | ||
| 161 | + loginUser.setUserType(accessToken.getUserType()); | ||
| 162 | + loginUser.setTenantId(accessToken.getTenantId()); | ||
| 163 | + loginUser.setScopes(accessToken.getScopes()); | ||
| 164 | + loginUser.setContext(accessToken.getUserInfo()); | ||
| 165 | + return loginUser; | ||
| 166 | + } | ||
| 167 | +} | ||
| 168 | +``` | ||
| 169 | + | ||
| 170 | +**Token 生成核心代码:** `OAuth2TokenServiceImpl.java` | ||
| 171 | + | ||
| 172 | +```java | ||
| 173 | +@Service | ||
| 174 | +public class OAuth2TokenServiceImpl implements OAuth2TokenService { | ||
| 175 | + | ||
| 176 | + @Resource | ||
| 177 | + private OAuth2AccessTokenMapper oauth2AccessTokenMapper; | ||
| 178 | + @Resource | ||
| 179 | + private OAuth2AccessTokenRedisDAO oauth2AccessTokenRedisDAO; | ||
| 180 | + @Resource | ||
| 181 | + private OAuth2RefreshTokenMapper oauth2RefreshTokenMapper; | ||
| 182 | + | ||
| 183 | + @Override | ||
| 184 | + public OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, | ||
| 185 | + String clientId, List<String> scopes) { | ||
| 186 | + OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(clientId); | ||
| 187 | + // 生成 Token(UUID 无连字符) | ||
| 188 | + OAuth2AccessTokenDO accessToken = new OAuth2AccessTokenDO(); | ||
| 189 | + accessToken.setAccessToken(IdUtil.fastSimpleUUID()); // 32位十六进制 | ||
| 190 | + accessToken.setRefreshToken(IdUtil.fastSimpleUUID()); | ||
| 191 | + accessToken.setUserId(userId); | ||
| 192 | + accessToken.setUserType(userType); | ||
| 193 | + accessToken.setClientId(clientId); | ||
| 194 | + accessToken.setScopes(scopes); | ||
| 195 | + accessToken.setExpiresTime(LocalDateTime.now() | ||
| 196 | + .plusSeconds(client.getAccessTokenValiditySeconds())); | ||
| 197 | + // 双重存储:MySQL + Redis | ||
| 198 | + oauth2AccessTokenMapper.insert(accessToken); | ||
| 199 | + oauth2AccessTokenRedisDAO.set(accessToken); | ||
| 200 | + return accessToken; | ||
| 201 | + } | ||
| 202 | + | ||
| 203 | + @Override | ||
| 204 | + public OAuth2AccessTokenCheckRespDTO checkAccessToken(String accessToken) { | ||
| 205 | + // 先从 Redis 缓存获取(高性能) | ||
| 206 | + OAuth2AccessTokenDO accessTokenDO = oauth2AccessTokenRedisDAO.get(accessToken); | ||
| 207 | + if (accessTokenDO == null) { | ||
| 208 | + // 缓存未命中,查 MySQL 兜底 | ||
| 209 | + accessTokenDO = oauth2AccessTokenMapper.selectByAccessToken(accessToken); | ||
| 210 | + if (accessTokenDO != null) { | ||
| 211 | + // 回写 Redis 缓存 | ||
| 212 | + oauth2AccessTokenRedisDAO.set(accessTokenDO); | ||
| 213 | + } | ||
| 214 | + } | ||
| 215 | + if (accessTokenDO == null || isExpired(accessTokenDO)) { | ||
| 216 | + throw exception(OAUTH2_ACCESS_TOKEN_NOT_FOUND_OR_EXPIRED); | ||
| 217 | + } | ||
| 218 | + return convertToCheckDTO(accessTokenDO); | ||
| 219 | + } | ||
| 220 | +} | ||
| 221 | +``` | ||
| 222 | + | ||
| 223 | +### 2.6 调用示例 | ||
| 224 | + | ||
| 225 | +```bash | ||
| 226 | +# 请求管理后台接口(需要在请求头带上 Token) | ||
| 227 | +curl -X GET "https://test.jichengshanshui.com.cn:28302/admin-api/system/dept/list" \ | ||
| 228 | + -H "Authorization: Bearer a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" | ||
| 229 | + | ||
| 230 | +# 返回成功示例 | ||
| 231 | +{ | ||
| 232 | + "code": 0, | ||
| 233 | + "msg": "成功", | ||
| 234 | + "data": { ... } | ||
| 235 | +} | ||
| 236 | + | ||
| 237 | +# Token 无效返回示例 | ||
| 238 | +{ | ||
| 239 | + "code": 401, | ||
| 240 | + "msg": "访问令牌不存在或已过期" | ||
| 241 | +} | ||
| 242 | +``` | ||
| 243 | + | ||
| 244 | +--- | ||
| 245 | + | ||
| 246 | +## 3. 用户名密码登录鉴权 | ||
| 247 | + | ||
| 248 | +### 3.1 概述 | ||
| 249 | + | ||
| 250 | +系统提供标准的用户名+密码登录方式,通过 BCrypt 密码比对验证用户身份,登录成功后返回 accessToken 和 refreshToken。 | ||
| 251 | + | ||
| 252 | +### 3.2 核心类 | ||
| 253 | + | ||
| 254 | +| 类名 | 路径 | | ||
| 255 | +|------|------| | ||
| 256 | +| `AuthController` | `urbanops-module-system/src/main/java/com/zteits/urbanops/module/system/controller/admin/auth/AuthController.java` | | ||
| 257 | +| `AdminAuthServiceImpl` | `urbanops-module-system/src/main/java/com/zteits/urbanops/module/system/service/auth/AdminAuthServiceImpl.java` | | ||
| 258 | +| `BCryptPasswordEncoder` | `urbanops-framework/urbanops-spring-boot-starter-security/src/main/java/com/zteits/urbanops/framework/security/config/UrbanopsSecurityAutoConfiguration.java` | | ||
| 259 | + | ||
| 260 | +### 3.3 工作流程 | ||
| 261 | + | ||
| 262 | +``` | ||
| 263 | +POST /system/auth/login { username, password } | ||
| 264 | + │ | ||
| 265 | + ▼ | ||
| 266 | +AuthController.login() | ||
| 267 | + │ | ||
| 268 | + ▼ | ||
| 269 | +AdminAuthService.login() | ||
| 270 | + │ | ||
| 271 | + ├── 1. authenticate(username, password) | ||
| 272 | + │ ├── 根据 username 查询 AdminUserDO | ||
| 273 | + │ ├── 如果未找到,尝试按手机号查询 | ||
| 274 | + │ ├── BCrypt 比对密码(强度4) | ||
| 275 | + │ ├── 检查用户状态(是否禁用) | ||
| 276 | + │ └── 记录登录日志 | ||
| 277 | + │ | ||
| 278 | + ├── 2. 处理社交账号绑定(可选) | ||
| 279 | + │ | ||
| 280 | + └── 3. createTokenAfterLoginSuccess() | ||
| 281 | + ├── 创建 OAuth2AccessTokenDO | ||
| 282 | + ├── 存储到 MySQL + Redis | ||
| 283 | + └── 返回 { accessToken, refreshToken, expiresTime } | ||
| 284 | +``` | ||
| 285 | + | ||
| 286 | +### 3.4 实现代码示例 | ||
| 287 | + | ||
| 288 | +**Controller 层:** `AuthController.java` | ||
| 289 | + | ||
| 290 | +```java | ||
| 291 | +@Tag(name = "管理后台 - 认证") | ||
| 292 | +@RestController | ||
| 293 | +@RequestMapping("/system/auth") | ||
| 294 | +@Validated | ||
| 295 | +public class AuthController { | ||
| 296 | + | ||
| 297 | + @Resource | ||
| 298 | + private AdminAuthService authService; | ||
| 299 | + | ||
| 300 | + @PostMapping("/login") | ||
| 301 | + @PermitAll | ||
| 302 | + @Operation(summary = "使用账号密码登录") | ||
| 303 | + public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) { | ||
| 304 | + return success(authService.login(reqVO)); | ||
| 305 | + } | ||
| 306 | +} | ||
| 307 | +``` | ||
| 308 | + | ||
| 309 | +**Service 实现层 — 身份验证:** `AdminAuthServiceImpl.java` | ||
| 310 | + | ||
| 311 | +```java | ||
| 312 | +@Service | ||
| 313 | +public class AdminAuthServiceImpl implements AdminAuthService { | ||
| 314 | + | ||
| 315 | + @Resource | ||
| 316 | + private AdminUserService userService; | ||
| 317 | + | ||
| 318 | + /** | ||
| 319 | + * 账号密码认证 | ||
| 320 | + */ | ||
| 321 | + public AdminUserDO authenticate(String username, String password) { | ||
| 322 | + final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME; | ||
| 323 | + | ||
| 324 | + // 1. 校验账号是否存在(先按用户名,再按手机号) | ||
| 325 | + AdminUserDO user = userService.getUserByUsername(username); | ||
| 326 | + if (user == null) { | ||
| 327 | + user = userService.getUserByMobile(username); | ||
| 328 | + if (user == null) { | ||
| 329 | + createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); | ||
| 330 | + throw exception(AUTH_LOGIN_BAD_CREDENTIALS); | ||
| 331 | + } | ||
| 332 | + } | ||
| 333 | + | ||
| 334 | + // 2. BCrypt 密码比对 | ||
| 335 | + if (!userService.isPasswordMatch(password, user.getPassword())) { | ||
| 336 | + createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS); | ||
| 337 | + throw exception(AUTH_LOGIN_BAD_CREDENTIALS); | ||
| 338 | + } | ||
| 339 | + | ||
| 340 | + // 3. 校验用户状态 | ||
| 341 | + if (CommonStatusEnum.isDisable(user.getStatus())) { | ||
| 342 | + createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED); | ||
| 343 | + throw exception(AUTH_LOGIN_USER_DISABLED); | ||
| 344 | + } | ||
| 345 | + return user; | ||
| 346 | + } | ||
| 347 | + | ||
| 348 | + /** | ||
| 349 | + * 登录:认证 → 创建 Token → 返回 | ||
| 350 | + */ | ||
| 351 | + @Override | ||
| 352 | + @DataPermission(enable = false) // 关闭数据权限(登录时无需数据过滤) | ||
| 353 | + public AuthLoginRespVO login(AuthLoginReqVO reqVO) { | ||
| 354 | + AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword()); | ||
| 355 | + return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), | ||
| 356 | + LoginLogTypeEnum.LOGIN_USERNAME); | ||
| 357 | + } | ||
| 358 | + | ||
| 359 | + private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, | ||
| 360 | + LoginLogTypeEnum logType) { | ||
| 361 | + // 创建 Token | ||
| 362 | + OAuth2AccessTokenDO accessToken = oauth2TokenService.createAccessToken( | ||
| 363 | + userId, UserTypeEnum.ADMIN.getValue(), | ||
| 364 | + OAuth2ClientConstants.CLIENT_ID_DEFAULT, null); | ||
| 365 | + // 记录登录成功日志 | ||
| 366 | + createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS); | ||
| 367 | + // 返回结果 | ||
| 368 | + return AuthConvert.INSTANCE.convert(accessToken); | ||
| 369 | + } | ||
| 370 | +} | ||
| 371 | +``` | ||
| 372 | + | ||
| 373 | +### 3.5 调用示例 | ||
| 374 | + | ||
| 375 | +```bash | ||
| 376 | +# 用户名密码登录 | ||
| 377 | +curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/auth/login" \ | ||
| 378 | + -H "Content-Type: application/json" \ | ||
| 379 | + -d '{ | ||
| 380 | + "username": "admin", | ||
| 381 | + "password": "admin123" | ||
| 382 | + }' | ||
| 383 | + | ||
| 384 | +# 返回示例 | ||
| 385 | +{ | ||
| 386 | + "code": 0, | ||
| 387 | + "msg": "成功", | ||
| 388 | + "data": { | ||
| 389 | + "accessToken": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", | ||
| 390 | + "refreshToken": "p6o5n4m3l2k1j0i9h8g7f6e5d4c3b2a1", | ||
| 391 | + "expiresTime": "2026-06-12T10:30:00", | ||
| 392 | + "userId": 1, | ||
| 393 | + "userType": 2 | ||
| 394 | + } | ||
| 395 | +} | ||
| 396 | +``` | ||
| 397 | + | ||
| 398 | +--- | ||
| 399 | + | ||
| 400 | +## 4. 短信验证码登录鉴权 | ||
| 401 | + | ||
| 402 | +### 4.1 概述 | ||
| 403 | + | ||
| 404 | +系统支持通过短信验证码方式进行免密登录,先发送验证码到用户手机,再通过验证码完成身份认证。 | ||
| 405 | + | ||
| 406 | +### 4.2 核心类 | ||
| 407 | + | ||
| 408 | +| 类名 | 路径 | | ||
| 409 | +|------|------| | ||
| 410 | +| `AuthController` | `urbanops-module-system/.../controller/admin/auth/AuthController.java` | | ||
| 411 | +| `AdminAuthServiceImpl` | `urbanops-module-system/.../service/auth/AdminAuthServiceImpl.java` | | ||
| 412 | +| `SmsCodeApi` | `urbanops-framework/urbanops-common/.../biz/system/sms/SmsCodeApi.java` | | ||
| 413 | + | ||
| 414 | +### 4.3 工作流程 | ||
| 415 | + | ||
| 416 | +``` | ||
| 417 | +① 发送验证码 | ||
| 418 | +POST /system/auth/send-sms-code { mobile } | ||
| 419 | + │ | ||
| 420 | + ▼ | ||
| 421 | +AuthController.sendSmsCode() | ||
| 422 | + └── smsCodeApi.sendSmsCode(mobile, scene) | ||
| 423 | + └── 发送短信 + 存储验证码到 Redis(带过期时间) | ||
| 424 | + | ||
| 425 | +② 验证码登录 | ||
| 426 | +POST /system/auth/sms-login { mobile, code } | ||
| 427 | + │ | ||
| 428 | + ▼ | ||
| 429 | +AuthController.smsLogin() | ||
| 430 | + │ | ||
| 431 | + ▼ | ||
| 432 | +AdminAuthService.smsLogin() | ||
| 433 | + ├── 1. smsCodeApi.useSmsCode(mobile, code) ← 校验验证码 | ||
| 434 | + ├── 2. userService.getUserByMobile(mobile) ← 查找用户 | ||
| 435 | + └── 3. createTokenAfterLoginSuccess() ← 创建 Token | ||
| 436 | +``` | ||
| 437 | + | ||
| 438 | +### 4.4 实现代码示例 | ||
| 439 | + | ||
| 440 | +**Controller 层:** `AuthController.java` | ||
| 441 | + | ||
| 442 | +```java | ||
| 443 | +@PostMapping("/send-sms-code") | ||
| 444 | +@PermitAll | ||
| 445 | +@Operation(summary = "发送手机验证码") | ||
| 446 | +public CommonResult<Boolean> sendSmsCode(@RequestBody @Valid AuthSendSmsReqVO reqVO) { | ||
| 447 | + smsCodeApi.sendSmsCode(reqVO.getMobile(), SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), | ||
| 448 | + WebFrameworkUtils.getClientIP()); | ||
| 449 | + return success(true); | ||
| 450 | +} | ||
| 451 | + | ||
| 452 | +@PostMapping("/sms-login") | ||
| 453 | +@PermitAll | ||
| 454 | +@Operation(summary = "使用短信验证码登录") | ||
| 455 | +public CommonResult<AuthLoginRespVO> smsLogin(@RequestBody @Valid AuthSmsLoginReqVO reqVO) { | ||
| 456 | + return success(authService.smsLogin(reqVO)); | ||
| 457 | +} | ||
| 458 | +``` | ||
| 459 | + | ||
| 460 | +**Service 实现层:** `AdminAuthServiceImpl.java` | ||
| 461 | + | ||
| 462 | +```java | ||
| 463 | +@Override | ||
| 464 | +public AuthLoginRespVO smsLogin(AuthSmsLoginReqVO reqVO) { | ||
| 465 | + // 1. 校验验证码(一次性消费,用过即删) | ||
| 466 | + smsCodeApi.useSmsCode( | ||
| 467 | + AuthConvert.INSTANCE.convert( | ||
| 468 | + reqVO, | ||
| 469 | + SmsSceneEnum.ADMIN_MEMBER_LOGIN.getScene(), | ||
| 470 | + getClientIP() | ||
| 471 | + ) | ||
| 472 | + ); | ||
| 473 | + | ||
| 474 | + // 2. 根据手机号获取用户 | ||
| 475 | + AdminUserDO user = userService.getUserByMobile(reqVO.getMobile()); | ||
| 476 | + if (user == null) { | ||
| 477 | + throw exception(USER_NOT_EXISTS); | ||
| 478 | + } | ||
| 479 | + | ||
| 480 | + // 3. 创建 Token | ||
| 481 | + return createTokenAfterLoginSuccess( | ||
| 482 | + user.getId(), reqVO.getMobile(), LoginLogTypeEnum.LOGIN_MOBILE); | ||
| 483 | +} | ||
| 484 | +``` | ||
| 485 | + | ||
| 486 | +### 4.5 调用示例 | ||
| 487 | + | ||
| 488 | +```bash | ||
| 489 | +# 步骤1: 发送短信验证码 | ||
| 490 | +curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/auth/send-sms-code" \ | ||
| 491 | + -H "Content-Type: application/json" \ | ||
| 492 | + -d '{"mobile": "13800138000"}' | ||
| 493 | + | ||
| 494 | +# 返回 | ||
| 495 | +{ "code": 0, "msg": "成功", "data": true } | ||
| 496 | + | ||
| 497 | +# 步骤2: 验证码登录 | ||
| 498 | +curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/auth/sms-login" \ | ||
| 499 | + -H "Content-Type: application/json" \ | ||
| 500 | + -d '{ | ||
| 501 | + "mobile": "13800138000", | ||
| 502 | + "code": "123456" | ||
| 503 | + }' | ||
| 504 | + | ||
| 505 | +# 返回(同密码登录) | ||
| 506 | +{ | ||
| 507 | + "code": 0, | ||
| 508 | + "msg": "成功", | ||
| 509 | + "data": { | ||
| 510 | + "accessToken": "x1y2z3...", | ||
| 511 | + "refreshToken": "z3y2x1...", | ||
| 512 | + "expiresTime": "2026-06-12T10:30:00" | ||
| 513 | + } | ||
| 514 | +} | ||
| 515 | +``` | ||
| 516 | + | ||
| 517 | +--- | ||
| 518 | + | ||
| 519 | +## 5. OAuth2 授权服务器 | ||
| 520 | + | ||
| 521 | +### 5.1 概述 | ||
| 522 | + | ||
| 523 | +系统内置完整的 OAuth2 授权服务器实现,支持 5 种标准授权模式,可同时作为授权服务和资源服务对外提供标准 OAuth2 接口。 | ||
| 524 | + | ||
| 525 | +### 5.2 核心类 | ||
| 526 | + | ||
| 527 | +| 类名 | 路径 | | ||
| 528 | +|------|------| | ||
| 529 | +| `OAuth2OpenController` | `urbanops-module-system/.../controller/admin/oauth2/OAuth2OpenController.java` | | ||
| 530 | +| `OAuth2GrantServiceImpl` | `urbanops-module-system/.../service/oauth2/OAuth2GrantServiceImpl.java` | | ||
| 531 | +| `OAuth2TokenServiceImpl` | `urbanops-module-system/.../service/oauth2/OAuth2TokenServiceImpl.java` | | ||
| 532 | +| `OAuth2ClientServiceImpl` | `urbanops-module-system/.../service/oauth2/OAuth2ClientServiceImpl.java` | | ||
| 533 | +| `OAuth2GrantTypeEnum` | `urbanops-module-system/.../enums/oauth2/OAuth2GrantTypeEnum.java` | | ||
| 534 | + | ||
| 535 | +### 5.3 支持的授权模式 | ||
| 536 | + | ||
| 537 | +| 授权模式 | grant_type 值 | 说明 | | ||
| 538 | +|----------|--------------|------| | ||
| 539 | +| 密码模式 | `password` | 直接使用用户名+密码换取 Token | | ||
| 540 | +| 授权码模式 | `authorization_code` | 先获取授权码 code,再换取 Token | | ||
| 541 | +| 客户端模式 | `client_credentials` | 客户端以自己的名义访问资源 | | ||
| 542 | +| 刷新令牌 | `refresh_token` | 使用 refreshToken 刷新 accessToken | | ||
| 543 | +| 隐式模式 | `implicit` | 通过 `/authorize` 端点直接返回 Token | | ||
| 544 | + | ||
| 545 | +### 5.4 OAuth2 端点 | ||
| 546 | + | ||
| 547 | +| 端点 | 方法 | 鉴权方式 | 说明 | | ||
| 548 | +|------|------|----------|------| | ||
| 549 | +| `/system/oauth2/token` | POST | `@PermitAll` (client_id + client_secret Basic Auth) | 签发 Token | | ||
| 550 | +| `/system/oauth2/check-token` | POST | `@PermitAll` (client_id + client_secret Basic Auth) | 校验 Token | | ||
| 551 | +| `/system/oauth2/revoke-token` | DELETE | `@PermitAll` (client_id + client_secret Basic Auth) | 撤销 Token | | ||
| 552 | +| `/system/oauth2/authorize` | GET | `@PermitAll` (需已登录用户) | 授权页面(SSO 入口) | | ||
| 553 | +| `/system/oauth2/authorize` | POST | `@PermitAll` (需已登录用户) | 用户确认授权 | | ||
| 554 | + | ||
| 555 | +### 5.5 实现代码示例 | ||
| 556 | + | ||
| 557 | +**`/token` 端点核心代码:** `OAuth2OpenController.java` | ||
| 558 | + | ||
| 559 | +```java | ||
| 560 | +@Tag(name = "管理后台 - OAuth2.0") | ||
| 561 | +@RestController | ||
| 562 | +@RequestMapping("/system/oauth2") | ||
| 563 | +@Validated | ||
| 564 | +public class OAuth2OpenController { | ||
| 565 | + | ||
| 566 | + @Resource | ||
| 567 | + private OAuth2GrantService oauth2GrantService; | ||
| 568 | + @Resource | ||
| 569 | + private OAuth2ClientService oauth2ClientService; | ||
| 570 | + | ||
| 571 | + @PostMapping("/token") | ||
| 572 | + @PermitAll | ||
| 573 | + @Operation(summary = "获得访问令牌", | ||
| 574 | + description = "支持 authorization_code / password / client_credentials / refresh_token 四种模式") | ||
| 575 | + public CommonResult<OAuth2OpenAccessTokenRespVO> postAccessToken( | ||
| 576 | + HttpServletRequest request, | ||
| 577 | + @RequestParam("grant_type") String grantType, | ||
| 578 | + @RequestParam(value = "code", required = false) String code, | ||
| 579 | + @RequestParam(value = "redirect_uri", required = false) String redirectUri, | ||
| 580 | + @RequestParam(value = "state", required = false) String state, | ||
| 581 | + @RequestParam(value = "username", required = false) String username, | ||
| 582 | + @RequestParam(value = "password", required = false) String password, | ||
| 583 | + @RequestParam(value = "scope", required = false) String scope, | ||
| 584 | + @RequestParam(value = "refresh_token", required = false) String refreshToken) { | ||
| 585 | + | ||
| 586 | + List<String> scopes = OAuth2Utils.buildScopes(scope); | ||
| 587 | + OAuth2GrantTypeEnum grantTypeEnum = OAuth2GrantTypeEnum.getByGrantType(grantType); | ||
| 588 | + | ||
| 589 | + // 1. 解析 Basic Auth,获取 client_id 和 client_secret | ||
| 590 | + String[] clientIdAndSecret = obtainBasicAuthorization(request); | ||
| 591 | + // 2. 校验客户端合法性 | ||
| 592 | + OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache( | ||
| 593 | + clientIdAndSecret[0], clientIdAndSecret[1], | ||
| 594 | + grantType, scopes, redirectUri); | ||
| 595 | + | ||
| 596 | + // 3. 根据授权模式分发处理 | ||
| 597 | + OAuth2AccessTokenDO accessTokenDO; | ||
| 598 | + switch (grantTypeEnum) { | ||
| 599 | + case AUTHORIZATION_CODE: | ||
| 600 | + accessTokenDO = oauth2GrantService.grantAuthorizationCodeForAccessToken( | ||
| 601 | + client.getClientId(), code, redirectUri, state); | ||
| 602 | + break; | ||
| 603 | + case PASSWORD: | ||
| 604 | + accessTokenDO = oauth2GrantService.grantPassword( | ||
| 605 | + username, password, client.getClientId(), scopes); | ||
| 606 | + break; | ||
| 607 | + case CLIENT_CREDENTIALS: | ||
| 608 | + accessTokenDO = oauth2GrantService.grantClientCredentials( | ||
| 609 | + client.getClientId(), scopes); | ||
| 610 | + break; | ||
| 611 | + case REFRESH_TOKEN: | ||
| 612 | + accessTokenDO = oauth2GrantService.grantRefreshToken( | ||
| 613 | + refreshToken, client.getClientId()); | ||
| 614 | + break; | ||
| 615 | + default: | ||
| 616 | + throw new IllegalArgumentException("未知授权类型:" + grantType); | ||
| 617 | + } | ||
| 618 | + | ||
| 619 | + Assert.notNull(accessTokenDO, "访问令牌不能为空"); | ||
| 620 | + return success(OAuth2OpenConvert.INSTANCE.convert(accessTokenDO)); | ||
| 621 | + } | ||
| 622 | + | ||
| 623 | + /** | ||
| 624 | + * 从请求头解析 Basic Auth 获取 client_id:client_secret | ||
| 625 | + */ | ||
| 626 | + private String[] obtainBasicAuthorization(HttpServletRequest request) { | ||
| 627 | + String header = request.getHeader("Authorization"); | ||
| 628 | + if (StrUtil.isEmpty(header) || !header.startsWith("Basic ")) { | ||
| 629 | + throw exception(ErrorCodeConstants.UNKNOWN); | ||
| 630 | + } | ||
| 631 | + String base64Credentials = header.substring(6); | ||
| 632 | + String credentials = new String(Base64.getDecoder().decode(base64Credentials)); | ||
| 633 | + return credentials.split(":", 2); | ||
| 634 | + } | ||
| 635 | +} | ||
| 636 | +``` | ||
| 637 | + | ||
| 638 | +**各授权模式的实现:** `OAuth2GrantServiceImpl.java` | ||
| 639 | + | ||
| 640 | +```java | ||
| 641 | +@Service | ||
| 642 | +public class OAuth2GrantServiceImpl implements OAuth2GrantService { | ||
| 643 | + | ||
| 644 | + @Resource | ||
| 645 | + private OAuth2TokenService oauth2TokenService; | ||
| 646 | + @Resource | ||
| 647 | + private AdminAuthService adminAuthService; | ||
| 648 | + | ||
| 649 | + /** | ||
| 650 | + * 密码模式:直接用用户名密码换 Token | ||
| 651 | + */ | ||
| 652 | + @Override | ||
| 653 | + public OAuth2AccessTokenDO grantPassword(String username, String password, | ||
| 654 | + String clientId, List<String> scopes) { | ||
| 655 | + // BCrypt 验证用户名密码 | ||
| 656 | + AdminUserDO user = adminAuthService.authenticate(username, password); | ||
| 657 | + Assert.notNull(user, "用户不能为空!"); | ||
| 658 | + return oauth2TokenService.createAccessToken( | ||
| 659 | + user.getId(), UserTypeEnum.ADMIN.getValue(), clientId, scopes); | ||
| 660 | + } | ||
| 661 | + | ||
| 662 | + /** | ||
| 663 | + * 授权码模式:用 code 换取 Token | ||
| 664 | + */ | ||
| 665 | + @Override | ||
| 666 | + public OAuth2AccessTokenDO grantAuthorizationCodeForAccessToken( | ||
| 667 | + String clientId, String code, String redirectUri, String state) { | ||
| 668 | + OAuth2CodeDO codeDO = oauth2CodeService.validCode(code); | ||
| 669 | + Assert.notNull(codeDO, "授权码不存在"); | ||
| 670 | + // 校验 clientId、redirectUri、state 是否匹配 | ||
| 671 | + oauth2CodeService.validateCode(codeDO, clientId, redirectUri, state); | ||
| 672 | + // 删除已使用的授权码 | ||
| 673 | + oauth2CodeService.deleteCode(code); | ||
| 674 | + return oauth2TokenService.createAccessToken( | ||
| 675 | + codeDO.getUserId(), codeDO.getUserType(), | ||
| 676 | + clientId, codeDO.getScopes()); | ||
| 677 | + } | ||
| 678 | + | ||
| 679 | + /** | ||
| 680 | + * 客户端模式:系统用户 Token(userId=0) | ||
| 681 | + */ | ||
| 682 | + @Override | ||
| 683 | + public OAuth2AccessTokenDO grantClientCredentials(String clientId, List<String> scopes) { | ||
| 684 | + return oauth2TokenService.createAccessToken( | ||
| 685 | + 0L, UserTypeEnum.ADMIN.getValue(), clientId, scopes); | ||
| 686 | + } | ||
| 687 | + | ||
| 688 | + /** | ||
| 689 | + * 刷新令牌:用 refreshToken 刷新 accessToken | ||
| 690 | + */ | ||
| 691 | + @Override | ||
| 692 | + public OAuth2AccessTokenDO grantRefreshToken(String refreshToken, String clientId) { | ||
| 693 | + return oauth2TokenService.refreshAccessToken(refreshToken, clientId); | ||
| 694 | + } | ||
| 695 | +} | ||
| 696 | +``` | ||
| 697 | + | ||
| 698 | +### 5.6 调用示例 | ||
| 699 | + | ||
| 700 | +```bash | ||
| 701 | +# 密码模式:客户端凭证 Basic Auth + 用户密码换取 Token | ||
| 702 | +curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/oauth2/token" \ | ||
| 703 | + -H "Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==" \ | ||
| 704 | + -d "grant_type=password&username=admin&password=admin123" | ||
| 705 | + | ||
| 706 | +# 客户端模式:仅用客户端凭证换取 Token | ||
| 707 | +curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/oauth2/token" \ | ||
| 708 | + -H "Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==" \ | ||
| 709 | + -d "grant_type=client_credentials" | ||
| 710 | + | ||
| 711 | +# 刷新令牌 | ||
| 712 | +curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/oauth2/token" \ | ||
| 713 | + -H "Authorization: Basic ZGVmYXVsdDphZG1pbjEyMw==" \ | ||
| 714 | + -d "grant_type=refresh_token&refresh_token=abc123..." | ||
| 715 | + | ||
| 716 | +# 返回格式 | ||
| 717 | +{ | ||
| 718 | + "code": 0, | ||
| 719 | + "msg": "成功", | ||
| 720 | + "data": { | ||
| 721 | + "access_token": "a1b2c3d4...", | ||
| 722 | + "refresh_token": "p6o5n4m3...", | ||
| 723 | + "token_type": "bearer", | ||
| 724 | + "expires_in": 7200, | ||
| 725 | + "scope": "read write" | ||
| 726 | + } | ||
| 727 | +} | ||
| 728 | +``` | ||
| 729 | + | ||
| 730 | +--- | ||
| 731 | + | ||
| 732 | +## 6. API 签名鉴权(第三方对接) | ||
| 733 | + | ||
| 734 | +### 6.1 概述 | ||
| 735 | + | ||
| 736 | +面向 `/open-api/` 前缀的第三方系统对接接口,通过 HMAC-SHA256 请求签名机制实现无状态的接口鉴权,防止请求被篡改、重放。 | ||
| 737 | + | ||
| 738 | +### 6.2 核心类 | ||
| 739 | + | ||
| 740 | +| 类名 | 路径 | | ||
| 741 | +|------|------| | ||
| 742 | +| `@ApiSignature` 注解 | `urbanops-framework/urbanops-spring-boot-starter-protection/src/main/java/com/zteits/urbanops/framework/signature/core/annotation/ApiSignature.java` | | ||
| 743 | +| `ApiSignatureAspect` 切面 | `urbanops-framework/urbanops-spring-boot-starter-protection/src/main/java/com/zteits/urbanops/framework/signature/core/aop/ApiSignatureAspect.java` | | ||
| 744 | + | ||
| 745 | +### 6.3 签名参数(请求头) | ||
| 746 | + | ||
| 747 | +| 请求头 | 类型 | 说明 | | ||
| 748 | +|--------|------|------| | ||
| 749 | +| `appId` | string | 应用唯一标识(由平台分配) | | ||
| 750 | +| `timestamp` | long | 请求时间戳(毫秒) | | ||
| 751 | +| `nonce` | string | 随机字符串(>= 10 位,防重放) | | ||
| 752 | +| `sign` | string | 签名字符串(SHA256 结果) | | ||
| 753 | + | ||
| 754 | +### 6.4 签名规则 | ||
| 755 | + | ||
| 756 | +``` | ||
| 757 | +签名字符串 = sorted(queryParams, by key) | ||
| 758 | + + requestBody | ||
| 759 | + + sorted({appId, timestamp, nonce} headers, by key) | ||
| 760 | + + appSecret | ||
| 761 | + | ||
| 762 | +最终签名 = SHA256(签名字符串) | ||
| 763 | +``` | ||
| 764 | + | ||
| 765 | +### 6.5 工作流程 | ||
| 766 | + | ||
| 767 | +``` | ||
| 768 | +客户端请求(带 appId, timestamp, nonce, sign 请求头) | ||
| 769 | + │ | ||
| 770 | + ▼ | ||
| 771 | +@ApiSignature 注解的方法 | ||
| 772 | + │ | ||
| 773 | + ▼ | ||
| 774 | +ApiSignatureAspect.beforePointCut() ← @Before AOP 拦截 | ||
| 775 | + │ | ||
| 776 | + ├── 1. verifyHeaders() 校验请求头完整性 | ||
| 777 | + │ ├── appId 非空 | ||
| 778 | + │ ├── timestamp 在时间窗口内(默认 60s) | ||
| 779 | + │ ├── nonce 长度 >= 10 | ||
| 780 | + │ └── sign 非空 | ||
| 781 | + │ | ||
| 782 | + ├── 2. 从 Redis 根据 appId 获取 appSecret | ||
| 783 | + │ | ||
| 784 | + ├── 3. 服务端按相同规则计算签名字符串 | ||
| 785 | + │ serverSignStr = sorted(queryParams) + body + sorted(headers) + appSecret | ||
| 786 | + │ | ||
| 787 | + ├── 4. 比对签名:SHA256(serverSignStr) == clientSign | ||
| 788 | + │ | ||
| 789 | + └── 5. nonce 防重放:存入 Redis(有效期 = timeout * 2) | ||
| 790 | +``` | ||
| 791 | + | ||
| 792 | +### 6.6 实现代码示例 | ||
| 793 | + | ||
| 794 | +**@ApiSignature 注解定义:** | ||
| 795 | + | ||
| 796 | +```java | ||
| 797 | +@Inherited | ||
| 798 | +@Documented | ||
| 799 | +@Target({ElementType.METHOD, ElementType.TYPE}) | ||
| 800 | +@Retention(RetentionPolicy.RUNTIME) | ||
| 801 | +public @interface ApiSignature { | ||
| 802 | + | ||
| 803 | + /** 签名超时时间,默认 60 秒 */ | ||
| 804 | + int timeout() default 60; | ||
| 805 | + | ||
| 806 | + /** 超时时间单位,默认秒 */ | ||
| 807 | + TimeUnit timeUnit() default TimeUnit.SECONDS; | ||
| 808 | + | ||
| 809 | + /** 签名校验失败提示信息 */ | ||
| 810 | + String message() default "签名不正确"; | ||
| 811 | + | ||
| 812 | + /** 应用ID 请求头字段名 */ | ||
| 813 | + String appId() default "appId"; | ||
| 814 | + | ||
| 815 | + /** 时间戳 请求头字段名 */ | ||
| 816 | + String timestamp() default "timestamp"; | ||
| 817 | + | ||
| 818 | + /** 随机数 请求头字段名(长度 >= 10) */ | ||
| 819 | + String nonce() default "nonce"; | ||
| 820 | + | ||
| 821 | + /** 签名 请求头字段名 */ | ||
| 822 | + String sign() default "sign"; | ||
| 823 | +} | ||
| 824 | +``` | ||
| 825 | + | ||
| 826 | +**签名校验切面实现:** `ApiSignatureAspect.java` | ||
| 827 | + | ||
| 828 | +```java | ||
| 829 | +@Aspect | ||
| 830 | +@RequiredArgsConstructor | ||
| 831 | +public class ApiSignatureAspect { | ||
| 832 | + | ||
| 833 | + private final ApiSignatureRedisDAO signatureRedisDAO; | ||
| 834 | + | ||
| 835 | + /** | ||
| 836 | + * @Before 拦截所有标注 @ApiSignature 的方法 | ||
| 837 | + */ | ||
| 838 | + @Before("@annotation(signature)") | ||
| 839 | + public void beforePointCut(JoinPoint joinPoint, ApiSignature signature) { | ||
| 840 | + HttpServletRequest request = ServletUtils.getRequest(); | ||
| 841 | + if (!verifySignature(signature, request)) { | ||
| 842 | + throw new ServiceException(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), | ||
| 843 | + signature.message()); | ||
| 844 | + } | ||
| 845 | + } | ||
| 846 | + | ||
| 847 | + /** | ||
| 848 | + * 完整的签名验证流程 | ||
| 849 | + */ | ||
| 850 | + public boolean verifySignature(ApiSignature signature, HttpServletRequest request) { | ||
| 851 | + // 1. 校验请求头完整性 | ||
| 852 | + if (!verifyHeaders(signature, request)) { | ||
| 853 | + return false; | ||
| 854 | + } | ||
| 855 | + | ||
| 856 | + // 2. 根据 appId 获取 appSecret | ||
| 857 | + String appId = request.getHeader(signature.appId()); | ||
| 858 | + String appSecret = signatureRedisDAO.getAppSecret(appId); | ||
| 859 | + Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId); | ||
| 860 | + | ||
| 861 | + // 3. 服务端按相同规则构造签名字符串 | ||
| 862 | + String serverSignatureString = buildSignatureString(signature, request, appSecret); | ||
| 863 | + String serverSignature = DigestUtil.sha256Hex(serverSignatureString); | ||
| 864 | + | ||
| 865 | + // 4. 比对客户端签名 | ||
| 866 | + String clientSignature = request.getHeader(signature.sign()); | ||
| 867 | + if (ObjUtil.notEqual(clientSignature, serverSignature)) { | ||
| 868 | + return false; | ||
| 869 | + } | ||
| 870 | + | ||
| 871 | + // 5. nonce 防重放(存入 Redis,过期时间 = timeout * 2) | ||
| 872 | + String nonce = request.getHeader(signature.nonce()); | ||
| 873 | + if (BooleanUtil.isFalse( | ||
| 874 | + signatureRedisDAO.setNonce(appId, nonce, signature.timeout() * 2, signature.timeUnit()))) { | ||
| 875 | + throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), | ||
| 876 | + "存在重复请求"); | ||
| 877 | + } | ||
| 878 | + return true; | ||
| 879 | + } | ||
| 880 | + | ||
| 881 | + /** | ||
| 882 | + * 校验请求头参数 | ||
| 883 | + */ | ||
| 884 | + private boolean verifyHeaders(ApiSignature signature, HttpServletRequest request) { | ||
| 885 | + String appId = request.getHeader(signature.appId()); | ||
| 886 | + String timestamp = request.getHeader(signature.timestamp()); | ||
| 887 | + String nonce = request.getHeader(signature.nonce()); | ||
| 888 | + String sign = request.getHeader(signature.sign()); | ||
| 889 | + | ||
| 890 | + // appId 不能为空 | ||
| 891 | + if (StrUtil.isBlank(appId)) return false; | ||
| 892 | + // timestamp 必须在有效时间窗口内 | ||
| 893 | + if (StrUtil.isBlank(timestamp)) return false; | ||
| 894 | + long ts = Long.parseLong(timestamp); | ||
| 895 | + long now = System.currentTimeMillis(); | ||
| 896 | + long timeoutMs = signature.timeUnit().toMillis(signature.timeout()); | ||
| 897 | + if (Math.abs(now - ts) > timeoutMs) return false; | ||
| 898 | + // nonce 长度必须 >= 10 | ||
| 899 | + if (StrUtil.length(nonce) < 10) return false; | ||
| 900 | + // sign 不能为空 | ||
| 901 | + if (StrUtil.isBlank(sign)) return false; | ||
| 902 | + | ||
| 903 | + return true; | ||
| 904 | + } | ||
| 905 | + | ||
| 906 | + /** | ||
| 907 | + * 构造签名字符串: | ||
| 908 | + * sorted(GET参数) + 请求体 + sorted(Header加签参数) + appSecret | ||
| 909 | + */ | ||
| 910 | + private String buildSignatureString(ApiSignature signature, HttpServletRequest request, | ||
| 911 | + String appSecret) { | ||
| 912 | + StringBuilder sb = new StringBuilder(); | ||
| 913 | + | ||
| 914 | + // 1. 排序后的查询参数 | ||
| 915 | + Map<String, String[]> paramMap = request.getParameterMap(); | ||
| 916 | + if (CollUtil.isNotEmpty(paramMap)) { | ||
| 917 | + TreeMap<String, String> sorted = new TreeMap<>(); | ||
| 918 | + paramMap.forEach((key, values) -> sorted.put(key, values[0])); | ||
| 919 | + sorted.forEach((key, value) -> sb.append(value)); | ||
| 920 | + } | ||
| 921 | + | ||
| 922 | + // 2. 请求体(POST/PUT 场景) | ||
| 923 | + String body = ServletUtils.getBody(request); | ||
| 924 | + if (StrUtil.isNotBlank(body)) { | ||
| 925 | + sb.append(body); | ||
| 926 | + } | ||
| 927 | + | ||
| 928 | + // 3. 排序后的加签头参数 | ||
| 929 | + TreeMap<String, String> headerMap = new TreeMap<>(); | ||
| 930 | + headerMap.put(signature.appId(), request.getHeader(signature.appId())); | ||
| 931 | + headerMap.put(signature.timestamp(), request.getHeader(signature.timestamp())); | ||
| 932 | + headerMap.put(signature.nonce(), request.getHeader(signature.nonce())); | ||
| 933 | + headerMap.values().forEach(sb::append); | ||
| 934 | + | ||
| 935 | + // 4. 追加 appSecret | ||
| 936 | + sb.append(appSecret); | ||
| 937 | + | ||
| 938 | + return sb.toString(); | ||
| 939 | + } | ||
| 940 | +} | ||
| 941 | +``` | ||
| 942 | + | ||
| 943 | +### 6.7 使用示例 | ||
| 944 | + | ||
| 945 | +**服务端 Controller:** | ||
| 946 | + | ||
| 947 | +```java | ||
| 948 | +@RestController | ||
| 949 | +@RequestMapping("/open-api/partner") | ||
| 950 | +public class PartnerOpenController { | ||
| 951 | + | ||
| 952 | + @PostMapping("/data-sync") | ||
| 953 | + @ApiSignature(timeout = 120, message = "签名验证失败,请检查签名参数") | ||
| 954 | + public CommonResult<String> syncData(@RequestBody PartnerDataDTO data) { | ||
| 955 | + // 签名已在 AOP 层面自动验证,此处可以直接处理业务 | ||
| 956 | + partnerService.syncData(data); | ||
| 957 | + return success("同步成功"); | ||
| 958 | + } | ||
| 959 | +} | ||
| 960 | +``` | ||
| 961 | + | ||
| 962 | +**客户端调用示例(Java):** | ||
| 963 | + | ||
| 964 | +```java | ||
| 965 | +public class ApiSignatureClient { | ||
| 966 | + | ||
| 967 | + private static final String APP_ID = "your_app_id"; | ||
| 968 | + private static final String APP_SECRET = "your_app_secret"; | ||
| 969 | + | ||
| 970 | + public static String callOpenApi(String url, String requestBody) { | ||
| 971 | + // 1. 生成参数 | ||
| 972 | + String timestamp = String.valueOf(System.currentTimeMillis()); | ||
| 973 | + String nonce = RandomUtil.randomString(16); | ||
| 974 | + | ||
| 975 | + // 2. 构造签名字符串 | ||
| 976 | + String signStr = requestBody + APP_ID + timestamp + nonce + APP_SECRET; | ||
| 977 | + String sign = DigestUtil.sha256Hex(signStr); | ||
| 978 | + | ||
| 979 | + // 3. 发起请求 | ||
| 980 | + HttpResponse response = HttpRequest.post(url) | ||
| 981 | + .header("appId", APP_ID) | ||
| 982 | + .header("timestamp", timestamp) | ||
| 983 | + .header("nonce", nonce) | ||
| 984 | + .header("sign", sign) | ||
| 985 | + .header("Content-Type", "application/json") | ||
| 986 | + .body(requestBody) | ||
| 987 | + .execute(); | ||
| 988 | + | ||
| 989 | + return response.body(); | ||
| 990 | + } | ||
| 991 | +} | ||
| 992 | +``` | ||
| 993 | + | ||
| 994 | +**cURL 调用示例:** | ||
| 995 | + | ||
| 996 | +```bash | ||
| 997 | +# 计算签名(shell 示例) | ||
| 998 | +APP_ID="my_app_001" | ||
| 999 | +APP_SECRET="my_secret_key" | ||
| 1000 | +TIMESTAMP=$(date +%s%3N) | ||
| 1001 | +NONCE=$(openssl rand -hex 16) | ||
| 1002 | +BODY='{"name":"test","value":123}' | ||
| 1003 | +SIGN_STR="${BODY}${APP_ID}${TIMESTAMP}${NONCE}${APP_SECRET}" | ||
| 1004 | +SIGN=$(echo -n "$SIGN_STR" | openssl dgst -sha256 -hex | awk '{print $2}') | ||
| 1005 | + | ||
| 1006 | +# 发起请求 | ||
| 1007 | +curl -X POST "https://test.jichengshanshui.com.cn:28302/open-api/partner/data-sync" \ | ||
| 1008 | + -H "Content-Type: application/json" \ | ||
| 1009 | + -H "appId: ${APP_ID}" \ | ||
| 1010 | + -H "timestamp: ${TIMESTAMP}" \ | ||
| 1011 | + -H "nonce: ${NONCE}" \ | ||
| 1012 | + -H "sign: ${SIGN}" \ | ||
| 1013 | + -d "${BODY}" | ||
| 1014 | + | ||
| 1015 | +# 签名失败返回 | ||
| 1016 | +{ "code": 400, "msg": "签名验证失败,请检查签名参数" } | ||
| 1017 | +``` | ||
| 1018 | + | ||
| 1019 | +--- | ||
| 1020 | + | ||
| 1021 | +## 7. 社交登录鉴权(JustAuth) | ||
| 1022 | + | ||
| 1023 | +### 7.1 概述 | ||
| 1024 | + | ||
| 1025 | +系统集成 JustAuth 1.16.7,支持 30+ 第三方社交平台登录。用户可通过微信、QQ、钉钉、GitHub 等平台授权后登录系统。 | ||
| 1026 | + | ||
| 1027 | +### 7.2 核心类 | ||
| 1028 | + | ||
| 1029 | +| 类名 | 路径 | | ||
| 1030 | +|------|------| | ||
| 1031 | +| `AuthRequestFactory` | `urbanops-module-system/.../framework/justauth/core/AuthRequestFactory.java` | | ||
| 1032 | +| `UrbanopsJustAuthConfiguration` | `urbanops-module-system/.../framework/justauth/config/UrbanopsJustAuthConfiguration.java` | | ||
| 1033 | +| `RedisStateCache` | JustAuth 内置(State 存储到 Redis) | | ||
| 1034 | + | ||
| 1035 | +### 7.3 支持的平台 | ||
| 1036 | + | ||
| 1037 | +GitHub · 微信开放平台 · 微信公众号 · 微信小程序 · 企业微信 · 微信网站应用 · QQ · 微博 · 钉钉 · 支付宝 · Google · Facebook · Apple · 飞书 · 以及 30+ 其他平台 | ||
| 1038 | + | ||
| 1039 | +### 7.4 工作流程 | ||
| 1040 | + | ||
| 1041 | +``` | ||
| 1042 | +用户点击"社交登录" | ||
| 1043 | + │ | ||
| 1044 | + ▼ | ||
| 1045 | +GET /system/auth/social-auth-redirect?type={platform}&redirectUri={url} | ||
| 1046 | + │ | ||
| 1047 | + ├── AuthRequestFactory.get(type) 获取对应平台的 AuthRequest | ||
| 1048 | + ├── 生成授权 URL(含 state 参数,state 存入 Redis) | ||
| 1049 | + └── 前端 302 跳转到第三方授权页面 | ||
| 1050 | + │ | ||
| 1051 | + ▼ | ||
| 1052 | +用户授权后,第三方回调 redirectUri(带 code + state 参数) | ||
| 1053 | + │ | ||
| 1054 | + ▼ | ||
| 1055 | +前端提取 code + state,调用后端 | ||
| 1056 | + │ | ||
| 1057 | + ▼ | ||
| 1058 | +POST /system/auth/social-login { type, code, state } | ||
| 1059 | + │ | ||
| 1060 | + ├── 1. AuthRequestFactory.get(type).login(callback) | ||
| 1061 | + │ ├── getAccessToken(AuthCallback) → 用 code 换 accessToken | ||
| 1062 | + │ └── getUserInfo(AuthToken) → 获取用户信息 | ||
| 1063 | + │ | ||
| 1064 | + ├── 2. 根据 socialUserId 查找绑定关系 | ||
| 1065 | + ├── 3. 自动注册 / 绑定已有用户 | ||
| 1066 | + └── 4. createTokenAfterLoginSuccess() | ||
| 1067 | +``` | ||
| 1068 | + | ||
| 1069 | +### 7.5 实现代码示例 | ||
| 1070 | + | ||
| 1071 | +**JustAuth 配置类:** `UrbanopsJustAuthConfiguration.java` | ||
| 1072 | + | ||
| 1073 | +```java | ||
| 1074 | +@Configuration(proxyBeanMethods = false) | ||
| 1075 | +@EnableConfigurationProperties({JustAuthProperties.class}) | ||
| 1076 | +public class UrbanopsJustAuthConfiguration { | ||
| 1077 | + | ||
| 1078 | + /** | ||
| 1079 | + * 创建 AuthRequest 工厂(条件 Bean:justauth.enabled=true 时激活) | ||
| 1080 | + */ | ||
| 1081 | + @Bean | ||
| 1082 | + @ConditionalOnProperty(prefix = "justauth", value = {"enabled"}, | ||
| 1083 | + havingValue = "true", matchIfMissing = true) | ||
| 1084 | + public AuthRequestFactory authRequestFactory(JustAuthProperties properties, | ||
| 1085 | + AuthStateCache authStateCache) { | ||
| 1086 | + return new AuthRequestFactory(properties, authStateCache); | ||
| 1087 | + } | ||
| 1088 | + | ||
| 1089 | + /** | ||
| 1090 | + * OAuth2 State 参数缓存(Redis 存储,防 CSRF) | ||
| 1091 | + */ | ||
| 1092 | + @Bean | ||
| 1093 | + public AuthStateCache authStateCache( | ||
| 1094 | + RedisTemplate<String, String> justAuthRedisCacheTemplate, | ||
| 1095 | + JustAuthProperties justAuthProperties) { | ||
| 1096 | + return new RedisStateCache(justAuthRedisCacheTemplate, | ||
| 1097 | + justAuthProperties.getCache()); | ||
| 1098 | + } | ||
| 1099 | +} | ||
| 1100 | +``` | ||
| 1101 | + | ||
| 1102 | +**社交登录请求工厂:** `AuthRequestFactory.java` | ||
| 1103 | + | ||
| 1104 | +```java | ||
| 1105 | +public class AuthRequestFactory { | ||
| 1106 | + | ||
| 1107 | + private final JustAuthProperties properties; | ||
| 1108 | + private final AuthStateCache authStateCache; | ||
| 1109 | + private final ConcurrentHashMap<String, AuthRequest> requestCache = new ConcurrentHashMap<>(); | ||
| 1110 | + | ||
| 1111 | + /** | ||
| 1112 | + * 获取指定平台的授权请求对象 | ||
| 1113 | + */ | ||
| 1114 | + public AuthRequest get(String source) { | ||
| 1115 | + return requestCache.computeIfAbsent(source, this::getDefaultRequest); | ||
| 1116 | + } | ||
| 1117 | + | ||
| 1118 | + /** | ||
| 1119 | + * 根据平台名称创建对应的 JustAuth 请求类 | ||
| 1120 | + */ | ||
| 1121 | + private AuthRequest getDefaultRequest(String source) { | ||
| 1122 | + AuthConfig config = properties.getType().get(source); | ||
| 1123 | + Assert.notNull(config, "平台 [{}] 的配置不存在", source); | ||
| 1124 | + | ||
| 1125 | + // 注入自定义 state 缓存(Redis) | ||
| 1126 | + config.setAuthStateCache(authStateCache); | ||
| 1127 | + | ||
| 1128 | + switch (source.toUpperCase()) { | ||
| 1129 | + case "GITHUB": | ||
| 1130 | + return new AuthGithubRequest(config, authStateCache); | ||
| 1131 | + case "WECHAT_OPEN": | ||
| 1132 | + return new AuthWeChatOpenRequest(config, authStateCache); | ||
| 1133 | + case "WECHAT_MP": | ||
| 1134 | + return new AuthWeChatMpRequest(config, authStateCache); | ||
| 1135 | + case "QQ": | ||
| 1136 | + return new AuthQqRequest(config, authStateCache); | ||
| 1137 | + case "DINGTALK": | ||
| 1138 | + return new AuthDingTalkRequest(config, authStateCache); | ||
| 1139 | + case "FEISHU": | ||
| 1140 | + return new AuthFeishuRequest(config, authStateCache); | ||
| 1141 | + case "ALIPAY": | ||
| 1142 | + return new AuthAlipayRequest(config, authStateCache); | ||
| 1143 | + case "GOOGLE": | ||
| 1144 | + return new AuthGoogleRequest(config, authStateCache); | ||
| 1145 | + case "FACEBOOK": | ||
| 1146 | + return new AuthFacebookRequest(config, authStateCache); | ||
| 1147 | + case "APPLE": | ||
| 1148 | + return new AuthAppleRequest(config, authStateCache); | ||
| 1149 | + case "UAA": | ||
| 1150 | + return new AuthUaaRequest(config, authStateCache); // 自定义 UAA SSO | ||
| 1151 | + // ... 30+ 其他平台 ... | ||
| 1152 | + default: | ||
| 1153 | + return null; | ||
| 1154 | + } | ||
| 1155 | + } | ||
| 1156 | +} | ||
| 1157 | +``` | ||
| 1158 | + | ||
| 1159 | +### 7.6 调用示例 | ||
| 1160 | + | ||
| 1161 | +```bash | ||
| 1162 | +# 步骤1: 获取社交登录授权 URL | ||
| 1163 | +curl -X GET "https://test.jichengshanshui.com.cn:28302/admin-api/system/auth/social-auth-redirect?type=GITHUB&redirectUri=https://example.com/callback" | ||
| 1164 | + | ||
| 1165 | +# 返回 | ||
| 1166 | +{ | ||
| 1167 | + "code": 0, | ||
| 1168 | + "data": { | ||
| 1169 | + "url": "https://github.com/login/oauth/authorize?client_id=xxx&redirect_uri=xxx&state=xxx" | ||
| 1170 | + } | ||
| 1171 | +} | ||
| 1172 | + | ||
| 1173 | +# 步骤2: 用户跳转到 GitHub 授权,回调到 redirectUri | ||
| 1174 | +# 浏览器地址栏: https://example.com/callback?code=abc123&state=xxx | ||
| 1175 | + | ||
| 1176 | +# 步骤3: 前端提取 code + state,调用后端完成登录 | ||
| 1177 | +curl -X POST "https://test.jichengshanshui.com.cn:28302/admin-api/system/auth/social-login" \ | ||
| 1178 | + -H "Content-Type: application/json" \ | ||
| 1179 | + -d '{ | ||
| 1180 | + "type": "GITHUB", | ||
| 1181 | + "code": "abc123", | ||
| 1182 | + "state": "xxx" | ||
| 1183 | + }' | ||
| 1184 | + | ||
| 1185 | +# 返回(同密码登录) | ||
| 1186 | +{ | ||
| 1187 | + "code": 0, | ||
| 1188 | + "msg": "成功", | ||
| 1189 | + "data": { | ||
| 1190 | + "accessToken": "token...", | ||
| 1191 | + "refreshToken": "refresh..." | ||
| 1192 | + } | ||
| 1193 | +} | ||
| 1194 | +``` | ||
| 1195 | + | ||
| 1196 | +--- | ||
| 1197 | + | ||
| 1198 | +## 8. UAA SSO 单点登录 | ||
| 1199 | + | ||
| 1200 | +### 8.1 概述 | ||
| 1201 | + | ||
| 1202 | +系统对接集团统一认证平台 UAA(Unified Authentication Application),通过自定义 `AuthUaaRequest` 实现企业级单点登录。用户登录 UAA 后即可无缝访问 UrbanOps。 | ||
| 1203 | + | ||
| 1204 | +### 8.2 核心类 | ||
| 1205 | + | ||
| 1206 | +| 类名 | 路径 | | ||
| 1207 | +|------|------| | ||
| 1208 | +| `AuthUaaRequest` | `urbanops-module-system/.../framework/justauth/core/AuthUaaRequest.java` | | ||
| 1209 | +| `SsoService` | `urbanops-module-system/.../service/oauth2/SsoService.java` | | ||
| 1210 | +| `CustomerAuthSource.UAA` | 自定义 AuthSource 枚举值 | | ||
| 1211 | + | ||
| 1212 | +### 8.3 UAA 配置 | ||
| 1213 | + | ||
| 1214 | +```yaml | ||
| 1215 | +sso: | ||
| 1216 | + client: | ||
| 1217 | + switch-state: true | ||
| 1218 | + client-id: urbanops | ||
| 1219 | + client-secret: ${SSO_CLIENT_SECRET} | ||
| 1220 | + base-url: https://uaa.fangshanparking.com:28201 | ||
| 1221 | + redirect-uri: ${urbanops.base-url}/admin-api/system/auth/social-login | ||
| 1222 | + jwt-public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A... | ||
| 1223 | +``` | ||
| 1224 | + | ||
| 1225 | +### 8.4 工作流程 | ||
| 1226 | + | ||
| 1227 | +``` | ||
| 1228 | +用户访问 UrbanOps → 未登录 → 重定向到 UAA 登录页 | ||
| 1229 | + │ | ||
| 1230 | + ▼ | ||
| 1231 | +UAA 登录成功后回调 redirectUri(带 authorization_code) | ||
| 1232 | + │ | ||
| 1233 | + ▼ | ||
| 1234 | +POST /system/auth/social-login { type: "UAA", code, state } | ||
| 1235 | + │ | ||
| 1236 | + ▼ | ||
| 1237 | +AuthUaaRequest | ||
| 1238 | + ├── getAccessToken(callback) | ||
| 1239 | + │ └── POST https://uaa.fangshanparking.com:28201/oauth2/token | ||
| 1240 | + │ code=xxx&grant_type=authorization_code&redirect_uri=xxx | ||
| 1241 | + │ | ||
| 1242 | + └── getUserInfo(authToken) | ||
| 1243 | + └── 使用 pub.cer 公钥解密 JWT token(RSA256) | ||
| 1244 | + ├── 提取 account(工号) | ||
| 1245 | + ├── 提取公司 ID | ||
| 1246 | + ├── 提取业务线 | ||
| 1247 | + └── 构建 AuthUser 对象 | ||
| 1248 | +``` | ||
| 1249 | + | ||
| 1250 | +### 8.5 实现代码示例 | ||
| 1251 | + | ||
| 1252 | +**自定义 UAA JustAuth 请求:** `AuthUaaRequest.java` | ||
| 1253 | + | ||
| 1254 | +```java | ||
| 1255 | +public class AuthUaaRequest extends AuthDefaultRequest { | ||
| 1256 | + | ||
| 1257 | + public AuthUaaRequest(AuthConfig config, AuthStateCache authStateCache) { | ||
| 1258 | + super(config, CustomerAuthSource.UAA, authStateCache); | ||
| 1259 | + } | ||
| 1260 | + | ||
| 1261 | + /** | ||
| 1262 | + * 步骤1:用 authorization_code 向 UAA 服务器换取 access_token | ||
| 1263 | + */ | ||
| 1264 | + @Override | ||
| 1265 | + public AuthToken getAccessToken(AuthCallback authCallback) { | ||
| 1266 | + String tokenUrl = source.accessToken(); | ||
| 1267 | + | ||
| 1268 | + // 构造请求体 | ||
| 1269 | + Map<String, String> params = new HashMap<>(); | ||
| 1270 | + params.put("code", authCallback.getCode()); | ||
| 1271 | + params.put("grant_type", "authorization_code"); | ||
| 1272 | + params.put("redirect_uri", authCallback.getRedirectUri()); | ||
| 1273 | + | ||
| 1274 | + // POST /oauth2/token | ||
| 1275 | + String response = new HttpUtils(config.getHttpConfig()) | ||
| 1276 | + .post(tokenUrl, params, this.config.isIgnoreRedirect()); | ||
| 1277 | + | ||
| 1278 | + JSONObject json = JSONUtil.parseObj(response); | ||
| 1279 | + | ||
| 1280 | + // 校验响应 | ||
| 1281 | + checkResponse(json); | ||
| 1282 | + | ||
| 1283 | + return AuthToken.builder() | ||
| 1284 | + .accessToken(json.getStr("access_token")) | ||
| 1285 | + // ...其他字段... | ||
| 1286 | + .build(); | ||
| 1287 | + } | ||
| 1288 | + | ||
| 1289 | + /** | ||
| 1290 | + * 步骤2:从 JWT access_token 中解析用户身份信息 | ||
| 1291 | + */ | ||
| 1292 | + @Override | ||
| 1293 | + public AuthUser getUserInfo(AuthToken authToken) { | ||
| 1294 | + try { | ||
| 1295 | + // 从 classpath 读取 UAA 公钥证书 | ||
| 1296 | + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); | ||
| 1297 | + ClassPathResource resource = new ClassPathResource("pub.cer"); | ||
| 1298 | + Certificate certificate = certificateFactory.generateCertificate(resource.getInputStream()); | ||
| 1299 | + RSAPublicKey publicKey = (RSAPublicKey) certificate.getPublicKey(); | ||
| 1300 | + | ||
| 1301 | + // 创建 JWT 验证器(RSA256 算法) | ||
| 1302 | + Algorithm algorithm = Algorithm.RSA256(publicKey, null); | ||
| 1303 | + JWTVerifier verifier = JWT.require(algorithm) | ||
| 1304 | + .acceptLeeway(60) // 允许 60 秒时钟偏差 | ||
| 1305 | + .build(); | ||
| 1306 | + | ||
| 1307 | + // 验证并解析 JWT | ||
| 1308 | + DecodedJWT decodedJWT = verifier.verify(authToken.getAccessToken()); | ||
| 1309 | + DecodedJWT jwt = JWT.decode(authToken.getAccessToken()); | ||
| 1310 | + | ||
| 1311 | + // 提取 JWT payload 中的用户信息 | ||
| 1312 | + String staffNo = jwt.getClaim("account").asString(); // 工号 | ||
| 1313 | + String companyId = jwt.getClaim("company_id").asString(); // 公司ID | ||
| 1314 | + String busiLine = jwt.getClaim("busi_line").asString(); // 业务线 | ||
| 1315 | + | ||
| 1316 | + return AuthUser.builder() | ||
| 1317 | + .uuid(staffNo) | ||
| 1318 | + .username(staffNo) | ||
| 1319 | + .nickname(staffNo) | ||
| 1320 | + .gender(AuthUserGender.UNKNOWN) | ||
| 1321 | + .token(authToken) | ||
| 1322 | + .source(this.source.toString()) | ||
| 1323 | + .build(); | ||
| 1324 | + | ||
| 1325 | + } catch (Exception e) { | ||
| 1326 | + throw new BusinessException("UAA SSO 登录失败:" + e.getMessage(), e); | ||
| 1327 | + } | ||
| 1328 | + } | ||
| 1329 | + | ||
| 1330 | + /** | ||
| 1331 | + * 校验 UAA 返回的 Token 响应 | ||
| 1332 | + */ | ||
| 1333 | + private void checkResponse(JSONObject json) { | ||
| 1334 | + if (json.containsKey("error")) { | ||
| 1335 | + throw new AuthException(json.getStr("error_description")); | ||
| 1336 | + } | ||
| 1337 | + } | ||
| 1338 | +} | ||
| 1339 | +``` | ||
| 1340 | + | ||
| 1341 | +--- | ||
| 1342 | + | ||
| 1343 | +## 9. RBAC 权限鉴权 | ||
| 1344 | + | ||
| 1345 | +### 9.1 概述 | ||
| 1346 | + | ||
| 1347 | +系统采用基于角色的访问控制(RBAC)模型,通过 Spring Security 的 `@PreAuthorize` 注解在方法级别进行权限控制。权限表达式中的 `@ss` Bean 提供 `hasPermission`、`hasRole`、`hasScope` 三个维度的权限判断。 | ||
| 1348 | + | ||
| 1349 | +### 9.2 核心类 | ||
| 1350 | + | ||
| 1351 | +| 类名 | 路径 | | ||
| 1352 | +|------|------| | ||
| 1353 | +| `SecurityFrameworkServiceImpl` (Bean name: `ss`) | `urbanops-framework/urbanops-spring-boot-starter-security/src/main/java/com/zteits/urbanops/framework/security/core/service/SecurityFrameworkServiceImpl.java` | | ||
| 1354 | +| `PermissionCommonApi` | `urbanops-framework/urbanops-common/src/main/java/com/zteits/urbanops/framework/common/biz/system/permission/PermissionCommonApi.java` | | ||
| 1355 | + | ||
| 1356 | +### 9.3 权限格式规范 | ||
| 1357 | + | ||
| 1358 | +``` | ||
| 1359 | +{模块}:{实体}:{操作} | ||
| 1360 | + | ||
| 1361 | +示例: | ||
| 1362 | + system:dept:create — 系统管理 · 部门 · 创建 | ||
| 1363 | + system:dept:update — 系统管理 · 部门 · 更新 | ||
| 1364 | + system:user:query — 系统管理 · 用户 · 查询 | ||
| 1365 | + workorder:event-info:create — 工单调度 · 事件 · 创建 | ||
| 1366 | + workorder:event-info:end — 工单调度 · 事件 · 结单 | ||
| 1367 | + bpm:garden-inspection:create— 工作流 · 园林巡检 · 创建 | ||
| 1368 | +``` | ||
| 1369 | + | ||
| 1370 | +### 9.4 权限方法一览 | ||
| 1371 | + | ||
| 1372 | +| SpEL 表达式 | 说明 | | ||
| 1373 | +|-------------|------| | ||
| 1374 | +| `@ss.hasPermission('perm')` | 检查单一权限 | | ||
| 1375 | +| `@ss.hasAnyPermissions('a','b')` | 检查任一权限(满足一个即可) | | ||
| 1376 | +| `@ss.hasRole('admin')` | 检查单一角色 | | ||
| 1377 | +| `@ss.hasAnyRoles('admin','manager')` | 检查任一角色 | | ||
| 1378 | +| `@ss.hasScope('read')` | 检查 OAuth2 scope | | ||
| 1379 | + | ||
| 1380 | +### 9.5 实现代码示例 | ||
| 1381 | + | ||
| 1382 | +**SecurityFrameworkServiceImpl — `@ss` Bean 实现:** | ||
| 1383 | + | ||
| 1384 | +```java | ||
| 1385 | +/** | ||
| 1386 | + * 安全框架服务实现 | ||
| 1387 | + * Bean 名称:ss | ||
| 1388 | + * 用于 @PreAuthorize("@ss.hasPermission(...)") 等表达式 | ||
| 1389 | + */ | ||
| 1390 | +@Service("ss") | ||
| 1391 | +public class SecurityFrameworkServiceImpl implements SecurityFrameworkService { | ||
| 1392 | + | ||
| 1393 | + @Resource | ||
| 1394 | + private PermissionCommonApi permissionApi; | ||
| 1395 | + | ||
| 1396 | + @Override | ||
| 1397 | + public boolean hasPermission(String permission) { | ||
| 1398 | + return hasAnyPermissions(permission); | ||
| 1399 | + } | ||
| 1400 | + | ||
| 1401 | + @Override | ||
| 1402 | + public boolean hasAnyPermissions(String... permissions) { | ||
| 1403 | + // 特殊场景:跨租户访问时,跳过权限校验 | ||
| 1404 | + if (skipPermissionCheck()) { | ||
| 1405 | + return true; | ||
| 1406 | + } | ||
| 1407 | + // 标准 RBAC 权限校验 | ||
| 1408 | + Long userId = getLoginUserId(); | ||
| 1409 | + if (userId == null) { | ||
| 1410 | + return false; | ||
| 1411 | + } | ||
| 1412 | + // 调用权限公共 API(查询用户角色 → 角色菜单 → 菜单权限标识) | ||
| 1413 | + return permissionApi.hasAnyPermissions(userId, permissions); | ||
| 1414 | + } | ||
| 1415 | + | ||
| 1416 | + @Override | ||
| 1417 | + public boolean hasAnyRoles(String... roles) { | ||
| 1418 | + if (skipPermissionCheck()) { | ||
| 1419 | + return true; | ||
| 1420 | + } | ||
| 1421 | + Long userId = getLoginUserId(); | ||
| 1422 | + if (userId == null) { | ||
| 1423 | + return false; | ||
| 1424 | + } | ||
| 1425 | + return permissionApi.hasAnyRoles(userId, roles); | ||
| 1426 | + } | ||
| 1427 | + | ||
| 1428 | + @Override | ||
| 1429 | + public boolean hasAnyScopes(String... scope) { | ||
| 1430 | + if (skipPermissionCheck()) { | ||
| 1431 | + return true; | ||
| 1432 | + } | ||
| 1433 | + LoginUser user = SecurityFrameworkUtils.getLoginUser(); | ||
| 1434 | + if (user == null) { | ||
| 1435 | + return false; | ||
| 1436 | + } | ||
| 1437 | + // 检查 LoginUser 的 scopes 列表中是否包含目标 scope | ||
| 1438 | + return CollUtil.containsAny(user.getScopes(), Arrays.asList(scope)); | ||
| 1439 | + } | ||
| 1440 | + | ||
| 1441 | + /** | ||
| 1442 | + * 跨租户访问时自动跳过权限检查 | ||
| 1443 | + * 逻辑:如果当前请求租户ID != 用户所属租户ID,说明是跨租户访问,自动放行 | ||
| 1444 | + */ | ||
| 1445 | + private boolean skipPermissionCheck() { | ||
| 1446 | + LoginUser user = SecurityFrameworkUtils.getLoginUser(); | ||
| 1447 | + if (user == null) return false; | ||
| 1448 | + Long visitTenantId = TenantContextHolder.getTenantId(); | ||
| 1449 | + return visitTenantId != null | ||
| 1450 | + && !Objects.equals(user.getTenantId(), visitTenantId); | ||
| 1451 | + } | ||
| 1452 | +} | ||
| 1453 | +``` | ||
| 1454 | + | ||
| 1455 | +**Controller 中的 @PreAuthorize 使用示例:** `TenantController.java` | ||
| 1456 | + | ||
| 1457 | +```java | ||
| 1458 | +@Tag(name = "管理后台 - 租户") | ||
| 1459 | +@RestController | ||
| 1460 | +@RequestMapping("/system/tenant") | ||
| 1461 | +@Validated | ||
| 1462 | +public class TenantController { | ||
| 1463 | + | ||
| 1464 | + @Resource | ||
| 1465 | + private TenantService tenantService; | ||
| 1466 | + | ||
| 1467 | + @PostMapping("/create") | ||
| 1468 | + @Operation(summary = "创建租户") | ||
| 1469 | + @PreAuthorize("@ss.hasPermission('system:tenant:create')") | ||
| 1470 | + public CommonResult<Long> createTenant(@Valid @RequestBody TenantSaveReqVO createReqVO) { | ||
| 1471 | + return success(tenantService.createTenant(createReqVO)); | ||
| 1472 | + } | ||
| 1473 | + | ||
| 1474 | + @PutMapping("/update") | ||
| 1475 | + @Operation(summary = "更新租户") | ||
| 1476 | + @PreAuthorize("@ss.hasPermission('system:tenant:update')") | ||
| 1477 | + public CommonResult<Boolean> updateTenant(@Valid @RequestBody TenantSaveReqVO updateReqVO) { | ||
| 1478 | + tenantService.updateTenant(updateReqVO); | ||
| 1479 | + return success(true); | ||
| 1480 | + } | ||
| 1481 | + | ||
| 1482 | + @DeleteMapping("/delete") | ||
| 1483 | + @Operation(summary = "删除租户") | ||
| 1484 | + @PreAuthorize("@ss.hasPermission('system:tenant:delete')") | ||
| 1485 | + public CommonResult<Boolean> deleteTenant(@RequestParam("id") Long id) { | ||
| 1486 | + tenantService.deleteTenant(id); | ||
| 1487 | + return success(true); | ||
| 1488 | + } | ||
| 1489 | + | ||
| 1490 | + @GetMapping("/page") | ||
| 1491 | + @Operation(summary = "获得租户分页") | ||
| 1492 | + @PreAuthorize("@ss.hasPermission('system:tenant:query')") | ||
| 1493 | + public CommonResult<PageResult<TenantRespVO>> getTenantPage(@Valid TenantPageReqVO pageReqVO) { | ||
| 1494 | + return success(tenantService.getTenantPage(pageReqVO)); | ||
| 1495 | + } | ||
| 1496 | +} | ||
| 1497 | +``` | ||
| 1498 | + | ||
| 1499 | +### 9.6 前端权限控制 | ||
| 1500 | + | ||
| 1501 | +```vue | ||
| 1502 | +<template> | ||
| 1503 | + <!-- 按钮级别权限控制(v-hasPermi 指令) --> | ||
| 1504 | + <el-button v-hasPermi="['system:tenant:create']" type="primary"> | ||
| 1505 | + 新增租户 | ||
| 1506 | + </el-button> | ||
| 1507 | + | ||
| 1508 | + <!-- 角色级别权限控制(v-hasRole 指令) --> | ||
| 1509 | + <div v-hasRole="['admin']"> | ||
| 1510 | + 管理员专属内容 | ||
| 1511 | + </div> | ||
| 1512 | +</template> | ||
| 1513 | +``` | ||
| 1514 | + | ||
| 1515 | +--- | ||
| 1516 | + | ||
| 1517 | +## 10. 租户隔离鉴权 | ||
| 1518 | + | ||
| 1519 | +### 10.1 概述 | ||
| 1520 | + | ||
| 1521 | +系统为多租户 SaaS 架构,通过 `TenantSecurityWebFilter` 在请求级别强制租户隔离,确保用户只能访问自己所属租户的数据,防止租户间数据越权。 | ||
| 1522 | + | ||
| 1523 | +### 10.2 核心类 | ||
| 1524 | + | ||
| 1525 | +| 类名 | 路径 | | ||
| 1526 | +|------|------| | ||
| 1527 | +| `TenantSecurityWebFilter` | `urbanops-framework/urbanops-spring-boot-starter-biz-tenant/src/main/java/com/zteits/urbanops/framework/tenant/core/security/TenantSecurityWebFilter.java` | | ||
| 1528 | +| `TenantContextHolder` | 租户上下文持有者(ThreadLocal) | | ||
| 1529 | + | ||
| 1530 | +### 10.3 工作流程 | ||
| 1531 | + | ||
| 1532 | +``` | ||
| 1533 | +请求到达 | ||
| 1534 | + │ | ||
| 1535 | + ▼ | ||
| 1536 | +TenantSecurityWebFilter.doFilterInternal() | ||
| 1537 | + │ | ||
| 1538 | + ├── 1. 如果请求头/参数中没有 tenantId | ||
| 1539 | + │ └── 自动从 LoginUser.tenantId 填充 | ||
| 1540 | + │ | ||
| 1541 | + ├── 2. 如果请求中的 tenantId != LoginUser.tenantId | ||
| 1542 | + │ └── 判定为跨租户越权访问 → 直接返回 403 | ||
| 1543 | + │ | ||
| 1544 | + ├── 3. 如果 tenantId 为空 且 不在白名单中 | ||
| 1545 | + │ └── 返回 400 "请求的租户标识未传递" | ||
| 1546 | + │ | ||
| 1547 | + ├── 4. 校验租户合法性 | ||
| 1548 | + │ └── tenantFrameworkService.validTenant(tenantId) | ||
| 1549 | + │ ├── 租户存在 | ||
| 1550 | + │ ├── 租户未被禁用 | ||
| 1551 | + │ └── 租户未过期 | ||
| 1552 | + │ | ||
| 1553 | + └── 5. 放行 | ||
| 1554 | +``` | ||
| 1555 | + | ||
| 1556 | +### 10.4 实现代码示例 | ||
| 1557 | + | ||
| 1558 | +**租户隔离过滤器:** `TenantSecurityWebFilter.java` | ||
| 1559 | + | ||
| 1560 | +```java | ||
| 1561 | +@RequiredArgsConstructor | ||
| 1562 | +public class TenantSecurityWebFilter extends ApiRequestFilter { | ||
| 1563 | + | ||
| 1564 | + private final TenantProperties tenantProperties; | ||
| 1565 | + private final TenantFrameworkService tenantFrameworkService; | ||
| 1566 | + | ||
| 1567 | + @Override | ||
| 1568 | + protected void doFilterInternal(HttpServletRequest request, | ||
| 1569 | + HttpServletResponse response, | ||
| 1570 | + FilterChain chain) throws ServletException, IOException { | ||
| 1571 | + Long tenantId = TenantContextHolder.getTenantId(); | ||
| 1572 | + LoginUser user = SecurityFrameworkUtils.getLoginUser(); | ||
| 1573 | + | ||
| 1574 | + // ========== 1. 已登录用户的租户校验 ========== | ||
| 1575 | + if (user != null) { | ||
| 1576 | + if (tenantId == null) { | ||
| 1577 | + // 请求未带租户ID → 自动使用用户所属租户 | ||
| 1578 | + tenantId = user.getTenantId(); | ||
| 1579 | + TenantContextHolder.setTenantId(tenantId); | ||
| 1580 | + } else if (!Objects.equals(user.getTenantId(), tenantId)) { | ||
| 1581 | + // 跨租户访问 → 拒绝请求 | ||
| 1582 | + log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]", | ||
| 1583 | + user.getTenantId(), user.getId(), user.getUserType(), | ||
| 1584 | + tenantId, request.getRequestURI(), request.getMethod()); | ||
| 1585 | + ServletUtils.writeJSON(response, | ||
| 1586 | + CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(), | ||
| 1587 | + "您无权访问该租户的数据")); | ||
| 1588 | + return; // ← 直接中断请求 | ||
| 1589 | + } | ||
| 1590 | + } | ||
| 1591 | + | ||
| 1592 | + // ========== 2. 租户合法性校验 ========== | ||
| 1593 | + if (!isIgnoreUrl(request)) { | ||
| 1594 | + // 非白名单 URL:必须有租户ID | ||
| 1595 | + if (tenantId == null) { | ||
| 1596 | + ServletUtils.writeJSON(response, | ||
| 1597 | + CommonResult.error(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), | ||
| 1598 | + "请求的租户标识未传递,请进行排查")); | ||
| 1599 | + return; | ||
| 1600 | + } | ||
| 1601 | + // 校验租户是否有效(未被禁用、未过期等) | ||
| 1602 | + tenantFrameworkService.validTenant(tenantId); | ||
| 1603 | + } else { | ||
| 1604 | + // 白名单 URL(如登录接口):允许无租户ID | ||
| 1605 | + if (tenantId == null) { | ||
| 1606 | + TenantContextHolder.setIgnore(true); | ||
| 1607 | + } | ||
| 1608 | + } | ||
| 1609 | + | ||
| 1610 | + chain.doFilter(request, response); | ||
| 1611 | + } | ||
| 1612 | + | ||
| 1613 | + /** | ||
| 1614 | + * 判断当前 URL 是否在租户忽略白名单中 | ||
| 1615 | + */ | ||
| 1616 | + private boolean isIgnoreUrl(HttpServletRequest request) { | ||
| 1617 | + return tenantProperties.getIgnoreUrls().stream() | ||
| 1618 | + .anyMatch(url -> WebFrameworkUtils.match(url, request)); | ||
| 1619 | + } | ||
| 1620 | +} | ||
| 1621 | +``` | ||
| 1622 | + | ||
| 1623 | +### 10.5 配置示例 | ||
| 1624 | + | ||
| 1625 | +```yaml | ||
| 1626 | +urbanops: | ||
| 1627 | + tenant: | ||
| 1628 | + ignore-urls: | ||
| 1629 | + - /system/auth/login | ||
| 1630 | + - /system/auth/sms-login | ||
| 1631 | + - /system/oauth2/** | ||
| 1632 | + - /swagger-ui/** | ||
| 1633 | + - /v3/api-docs/** | ||
| 1634 | +``` | ||
| 1635 | + | ||
| 1636 | +--- | ||
| 1637 | + | ||
| 1638 | +## 11. 数据权限鉴权(行级安全) | ||
| 1639 | + | ||
| 1640 | +### 11.1 概述 | ||
| 1641 | + | ||
| 1642 | +数据权限是 RBAC 权限模型的补充,在 SQL 层面注入 `WHERE` 条件,实现行级数据过滤。用户根据其数据权限范围,只能看到: | ||
| 1643 | +- **全部数据**(ALL) | ||
| 1644 | +- **本部门及下级部门数据**(DEPT_SCOPE) | ||
| 1645 | +- **仅本人数据**(SELF) | ||
| 1646 | + | ||
| 1647 | +### 11.2 核心类 | ||
| 1648 | + | ||
| 1649 | +| 类名 | 路径 | | ||
| 1650 | +|------|------| | ||
| 1651 | +| `@DataPermission` 注解 | `urbanops-framework/urbanops-spring-boot-starter-biz-data-permission/src/main/java/com/zteits/urbanops/framework/datapermission/core/annotation/DataPermission.java` | | ||
| 1652 | +| `DeptDataPermissionRule` | `urbanops-framework/urbanops-spring-boot-starter-biz-data-permission/src/main/java/com/zteits/urbanops/framework/datapermission/core/rule/dept/DeptDataPermissionRule.java` | | ||
| 1653 | + | ||
| 1654 | +### 11.3 @DataPermission 注解 | ||
| 1655 | + | ||
| 1656 | +```java | ||
| 1657 | +@Target({ElementType.TYPE, ElementType.METHOD}) | ||
| 1658 | +@Retention(RetentionPolicy.RUNTIME) | ||
| 1659 | +@Documented | ||
| 1660 | +public @interface DataPermission { | ||
| 1661 | + | ||
| 1662 | + /** 是否启用数据权限,默认 true */ | ||
| 1663 | + boolean enable() default true; | ||
| 1664 | + | ||
| 1665 | + /** 指定启用的数据权限规则 */ | ||
| 1666 | + Class<? extends DataPermissionRule>[] includeRules() default {}; | ||
| 1667 | + | ||
| 1668 | + /** 指定排除的数据权限规则 */ | ||
| 1669 | + Class<? extends DataPermissionRule>[] excludeRules() default {}; | ||
| 1670 | +} | ||
| 1671 | +``` | ||
| 1672 | + | ||
| 1673 | +### 11.4 SQL 注入逻辑 | ||
| 1674 | + | ||
| 1675 | +| 数据权限范围 | 生成的 SQL WHERE 条件 | | ||
| 1676 | +|-------------|--------------------| | ||
| 1677 | +| ALL(全部)| 不注入条件(查全部)| | ||
| 1678 | +| DEPT(部门)| `WHERE dept_id IN (1, 2, 3)` | | ||
| 1679 | +| SELF(仅本人)| `WHERE creator = 5`(或 `user_id = 5`)| | ||
| 1680 | +| DEPT + SELF | `WHERE (dept_id IN (1, 2, 3) OR creator = 5)` | | ||
| 1681 | +| NONE(无权限)| `WHERE null = null` | | ||
| 1682 | + | ||
| 1683 | +### 11.5 实现代码示例 | ||
| 1684 | + | ||
| 1685 | +**部门数据权限规则:** `DeptDataPermissionRule.java` | ||
| 1686 | + | ||
| 1687 | +```java | ||
| 1688 | +@Component | ||
| 1689 | +public class DeptDataPermissionRule implements DataPermissionRule { | ||
| 1690 | + | ||
| 1691 | + private static final String CONTEXT_KEY = DeptDataPermissionRule.class.getSimpleName(); | ||
| 1692 | + | ||
| 1693 | + @Resource | ||
| 1694 | + private PermissionCommonApi permissionApi; | ||
| 1695 | + | ||
| 1696 | + /** | ||
| 1697 | + * 根据当前用户的数据权限范围,构造 SQL 过滤表达式 | ||
| 1698 | + */ | ||
| 1699 | + @Override | ||
| 1700 | + public Expression getExpression(String tableName, Alias tableAlias) { | ||
| 1701 | + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); | ||
| 1702 | + if (loginUser == null) { | ||
| 1703 | + return null; | ||
| 1704 | + } | ||
| 1705 | + // 仅对 ADMIN 类型用户生效 | ||
| 1706 | + if (ObjectUtil.notEqual(loginUser.getUserType(), UserTypeEnum.ADMIN.getValue())) { | ||
| 1707 | + return null; | ||
| 1708 | + } | ||
| 1709 | + | ||
| 1710 | + // 从缓存或远程 API 获取用户的数据权限配置 | ||
| 1711 | + DeptDataPermissionRespDTO deptDataPermission = loginUser.getContext( | ||
| 1712 | + CONTEXT_KEY, DeptDataPermissionRespDTO.class); | ||
| 1713 | + if (deptDataPermission == null) { | ||
| 1714 | + deptDataPermission = permissionApi.getDeptDataPermission(loginUser.getId()); | ||
| 1715 | + loginUser.setContext(CONTEXT_KEY, deptDataPermission); | ||
| 1716 | + } | ||
| 1717 | + | ||
| 1718 | + // 情况1: 全部数据权限 → 不注入任何条件 | ||
| 1719 | + if (deptDataPermission.getAll()) { | ||
| 1720 | + return null; | ||
| 1721 | + } | ||
| 1722 | + | ||
| 1723 | + // 情况2: 既无部门权限,也无本人权限 → 查不到任何数据 | ||
| 1724 | + if (CollUtil.isEmpty(deptDataPermission.getDeptIds()) | ||
| 1725 | + && Boolean.FALSE.equals(deptDataPermission.getSelf())) { | ||
| 1726 | + return new EqualsTo(null, null); // WHERE null = null | ||
| 1727 | + } | ||
| 1728 | + | ||
| 1729 | + // 情况3: 拼接部门和本人的 OR 条件 | ||
| 1730 | + Expression deptExpression = buildDeptExpression( | ||
| 1731 | + tableName, tableAlias, deptDataPermission.getDeptIds()); | ||
| 1732 | + Expression userExpression = buildUserExpression( | ||
| 1733 | + tableName, tableAlias, deptDataPermission.getSelf(), loginUser.getId()); | ||
| 1734 | + | ||
| 1735 | + if (deptExpression == null) return userExpression; | ||
| 1736 | + if (userExpression == null) return deptExpression; | ||
| 1737 | + | ||
| 1738 | + // 组合: (dept_id IN (1,2,3) OR creator = 5) | ||
| 1739 | + return new ParenthesizedExpressionList( | ||
| 1740 | + new OrExpression(deptExpression, userExpression)); | ||
| 1741 | + } | ||
| 1742 | + | ||
| 1743 | + /** | ||
| 1744 | + * 构造部门条件:dept_id IN (1, 2, 3) | ||
| 1745 | + */ | ||
| 1746 | + private Expression buildDeptExpression(String tableName, Alias tableAlias, | ||
| 1747 | + Set<Long> deptIds) { | ||
| 1748 | + if (CollUtil.isEmpty(deptIds)) { | ||
| 1749 | + return null; | ||
| 1750 | + } | ||
| 1751 | + return new InExpression(new Column(tableAlias, "dept_id"), | ||
| 1752 | + new ExpressionList(deptIds)); | ||
| 1753 | + } | ||
| 1754 | + | ||
| 1755 | + /** | ||
| 1756 | + * 构造本人条件:creator = 5(或 user_id = 5) | ||
| 1757 | + */ | ||
| 1758 | + private Expression buildUserExpression(String tableName, Alias tableAlias, | ||
| 1759 | + Boolean self, Long userId) { | ||
| 1760 | + if (BooleanUtil.isFalse(self)) { | ||
| 1761 | + return null; | ||
| 1762 | + } | ||
| 1763 | + return new EqualsTo(new Column(tableAlias, "creator"), | ||
| 1764 | + new LongValue(userId)); | ||
| 1765 | + } | ||
| 1766 | +} | ||
| 1767 | +``` | ||
| 1768 | + | ||
| 1769 | +**Service 层使用示例:** `AdminAuthServiceImpl.java` | ||
| 1770 | + | ||
| 1771 | +```java | ||
| 1772 | +@Service | ||
| 1773 | +public class AdminAuthServiceImpl implements AdminAuthService { | ||
| 1774 | + | ||
| 1775 | + /** | ||
| 1776 | + * 登录方法:不需要数据权限过滤 | ||
| 1777 | + * 通过 @DataPermission(enable = false) 禁用行级过滤 | ||
| 1778 | + */ | ||
| 1779 | + @Override | ||
| 1780 | + @DataPermission(enable = false) | ||
| 1781 | + public AuthLoginRespVO login(AuthLoginReqVO reqVO) { | ||
| 1782 | + AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword()); | ||
| 1783 | + return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), | ||
| 1784 | + LoginLogTypeEnum.LOGIN_USERNAME); | ||
| 1785 | + } | ||
| 1786 | + | ||
| 1787 | + /** | ||
| 1788 | + * 列表查询方法:默认启用数据权限 | ||
| 1789 | + * 用户只能看到自己部门或自己的数据 | ||
| 1790 | + */ | ||
| 1791 | + @Override | ||
| 1792 | + public PageResult<AdminUserRespVO> getUserPage(AdminUserPageReqVO reqVO) { | ||
| 1793 | + // MyBatis 查询时会自动注入 dept_id IN (...) OR creator = ? 条件 | ||
| 1794 | + Page<AdminUserDO> page = userMapper.selectPage(reqVO, | ||
| 1795 | + new LambdaQueryWrapperX<AdminUserDO>() | ||
| 1796 | + .likeIfPresent(AdminUserDO::getUsername, reqVO.getUsername())); | ||
| 1797 | + return AdminUserConvert.INSTANCE.convertPage(page); | ||
| 1798 | + } | ||
| 1799 | +} | ||
| 1800 | +``` | ||
| 1801 | + | ||
| 1802 | +--- | ||
| 1803 | + | ||
| 1804 | +## 12. API 加密 | ||
| 1805 | + | ||
| 1806 | +### 12.1 概述 | ||
| 1807 | + | ||
| 1808 | +系统支持对请求体和响应体进行 AES 或 RSA 加解密,通过 `@ApiEncrypt` 注解在方法级别控制,防止敏感数据在传输过程中被窃取。 | ||
| 1809 | + | ||
| 1810 | +### 12.2 核心类 | ||
| 1811 | + | ||
| 1812 | +| 类名 | 路径 | | ||
| 1813 | +|------|------| | ||
| 1814 | +| `@ApiEncrypt` 注解 | `urbanops-framework/urbanops-spring-boot-starter-web/src/main/java/com/zteits/urbanops/framework/encrypt/core/annotation/ApiEncrypt.java` | | ||
| 1815 | +| `ApiEncryptFilter` | `urbanops-framework/urbanops-spring-boot-starter-web/src/main/java/com/zteits/urbanops/framework/encrypt/core/filter/ApiEncryptFilter.java` | | ||
| 1816 | + | ||
| 1817 | +### 12.3 @ApiEncrypt 注解 | ||
| 1818 | + | ||
| 1819 | +```java | ||
| 1820 | +@Documented | ||
| 1821 | +@Target({ElementType.TYPE, ElementType.METHOD}) | ||
| 1822 | +@Retention(RetentionPolicy.RUNTIME) | ||
| 1823 | +public @interface ApiEncrypt { | ||
| 1824 | + | ||
| 1825 | + /** 是否对请求参数进行解密(默认 true) */ | ||
| 1826 | + boolean request() default true; | ||
| 1827 | + | ||
| 1828 | + /** 是否对响应结果进行加密(默认 true) */ | ||
| 1829 | + boolean response() default true; | ||
| 1830 | +} | ||
| 1831 | +``` | ||
| 1832 | + | ||
| 1833 | +### 12.4 实现代码示例 | ||
| 1834 | + | ||
| 1835 | +**API 加密过滤器:** `ApiEncryptFilter.java` | ||
| 1836 | + | ||
| 1837 | +```java | ||
| 1838 | +@RequiredArgsConstructor | ||
| 1839 | +public class ApiEncryptFilter extends ApiRequestFilter { | ||
| 1840 | + | ||
| 1841 | + private final ApiEncryptProperties apiEncryptProperties; | ||
| 1842 | + | ||
| 1843 | + @Override | ||
| 1844 | + protected void doFilterInternal(HttpServletRequest request, | ||
| 1845 | + HttpServletResponse response, | ||
| 1846 | + FilterChain chain) throws ServletException, IOException { | ||
| 1847 | + // 1. 获取 Controller 方法上的 @ApiEncrypt 注解 | ||
| 1848 | + ApiEncrypt apiEncrypt = getApiEncrypt(request); | ||
| 1849 | + boolean requestEnable = apiEncrypt != null && apiEncrypt.request(); | ||
| 1850 | + boolean responseEnable = apiEncrypt != null && apiEncrypt.response(); | ||
| 1851 | + String encryptHeader = request.getHeader(apiEncryptProperties.getHeader()); | ||
| 1852 | + | ||
| 1853 | + // 不需要加解密 → 直接放行 | ||
| 1854 | + if (!requestEnable && !responseEnable && StrUtil.isBlank(encryptHeader)) { | ||
| 1855 | + chain.doFilter(request, response); | ||
| 1856 | + return; | ||
| 1857 | + } | ||
| 1858 | + | ||
| 1859 | + // 2. 解密请求体(POST/PUT/DELETE 请求) | ||
| 1860 | + if (ObjectUtils.equalsAny(HttpMethod.valueOf(request.getMethod()), | ||
| 1861 | + HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE)) { | ||
| 1862 | + if (StrUtil.isNotBlank(encryptHeader)) { | ||
| 1863 | + // 使用加密请求包装器(AES 或 RSA 解密) | ||
| 1864 | + request = new ApiDecryptRequestWrapper(request, | ||
| 1865 | + requestSymmetricDecryptor, // AES 解密器 | ||
| 1866 | + requestAsymmetricDecryptor); // RSA 解密器 | ||
| 1867 | + } else if (requestEnable) { | ||
| 1868 | + throw invalidParamException("请求未包含加密标头,请检查是否正确配置了加密标头"); | ||
| 1869 | + } | ||
| 1870 | + } | ||
| 1871 | + | ||
| 1872 | + // 3. 包装响应对象(用于后续加密输出) | ||
| 1873 | + if (responseEnable) { | ||
| 1874 | + response = new ApiEncryptResponseWrapper(response); | ||
| 1875 | + } | ||
| 1876 | + | ||
| 1877 | + // 4. 执行后续过滤器链 | ||
| 1878 | + chain.doFilter(request, response); | ||
| 1879 | + | ||
| 1880 | + // 5. 加密响应体 | ||
| 1881 | + if (responseEnable) { | ||
| 1882 | + ((ApiEncryptResponseWrapper) response).encrypt( | ||
| 1883 | + apiEncryptProperties, | ||
| 1884 | + responseSymmetricEncryptor, // AES 加密器 | ||
| 1885 | + responseAsymmetricEncryptor); // RSA 加密器 | ||
| 1886 | + } | ||
| 1887 | + } | ||
| 1888 | +} | ||
| 1889 | +``` | ||
| 1890 | + | ||
| 1891 | +### 12.5 使用示例 | ||
| 1892 | + | ||
| 1893 | +```java | ||
| 1894 | +@RestController | ||
| 1895 | +@RequestMapping("/admin-api/system/user") | ||
| 1896 | +public class UserController { | ||
| 1897 | + | ||
| 1898 | + /** | ||
| 1899 | + * 创建用户:请求体加密传输,响应结果也加密返回 | ||
| 1900 | + */ | ||
| 1901 | + @PostMapping("/create") | ||
| 1902 | + @ApiEncrypt(request = true, response = true) | ||
| 1903 | + @PreAuthorize("@ss.hasPermission('system:user:create')") | ||
| 1904 | + public CommonResult<Long> createUser(@RequestBody UserSaveReqVO reqVO) { | ||
| 1905 | + return success(userService.createUser(reqVO)); | ||
| 1906 | + } | ||
| 1907 | +} | ||
| 1908 | +``` | ||
| 1909 | + | ||
| 1910 | +```yaml | ||
| 1911 | +# application.yaml | ||
| 1912 | +urbanops: | ||
| 1913 | + api-encrypt: | ||
| 1914 | + enable: true | ||
| 1915 | + header: X-Encrypt # 加密请求头标识 | ||
| 1916 | + algorithm: AES # AES / RSA | ||
| 1917 | + request-aes-key: ${API_ENCRYPT_REQ_KEY} | ||
| 1918 | + response-aes-key: ${API_ENCRYPT_RESP_KEY} | ||
| 1919 | +``` | ||
| 1920 | + | ||
| 1921 | +--- | ||
| 1922 | + | ||
| 1923 | +## 13. 接口前缀与鉴权方式对照表 | ||
| 1924 | + | ||
| 1925 | +| 接口前缀 | 鉴权方式 | 认证类型 | 权限控制 | 租户隔离 | 适用场景 | | ||
| 1926 | +|----------|----------|----------|----------|----------|----------| | ||
| 1927 | +| `/admin-api/` | Bearer Token + RBAC | Token(登录后获取) | `@PreAuthorize("@ss.hasPermission(...)")` | ✅ 强制 | 管理后台 Web | | ||
| 1928 | +| `/app-api/` | Bearer Token | Token(移动端登录获取) | 宽松(大多 @PermitAll) | ✅ 强制 | 移动端 App / 小程序 | | ||
| 1929 | +| `/open-api/` | HMAC-SHA256 签名 | appId + appSecret 签名 | 无(由签名保证) | ❌ | 第三方系统对接 | | ||
| 1930 | +| `/pub-api/` | 无 | 无 | 无 | ❌ | 支付回调等公开回调 | | ||
| 1931 | +| `/system/auth/*` | 无(`@PermitAll`) | BCrypt 密码 / 短信验证码 / 社交登录 | 无 | ❌ | 登录入口 | | ||
| 1932 | +| `/system/oauth2/*` | client_id + client_secret (Basic Auth) | OAuth2 标准协议 | OAuth2 Scope | ❌ | 授权服务器端点 | | ||
| 1933 | +| `/swagger-ui/**` | 无 | 无 | 无 | ❌ | API 文档(开发环境) | | ||
| 1934 | +| `/actuator/**` | 无(可配置) | 无 | 无 | ❌ | 健康检查 / 监控 | | ||
| 1935 | +| WebSocket | `?token=xxx` 查询参数 | Token | 无 | ❌ | WebSocket 连接 | | ||
| 1936 | + | ||
| 1937 | +--- | ||
| 1938 | + | ||
| 1939 | +## 附录:完整鉴权流程图 | ||
| 1940 | + | ||
| 1941 | +``` | ||
| 1942 | + ┌─────────────────────────────┐ | ||
| 1943 | + │ 客户端请求到达 │ | ||
| 1944 | + └──────────┬──────────────────┘ | ||
| 1945 | + │ | ||
| 1946 | + ┌──────────▼──────────────────┐ | ||
| 1947 | + │ 1. ApiEncryptFilter │ | ||
| 1948 | + │ @ApiEncrypt 注解的方法 │ | ||
| 1949 | + │ 解密请求体(AES/RSA) │ | ||
| 1950 | + └──────────┬──────────────────┘ | ||
| 1951 | + │ | ||
| 1952 | + ┌──────────▼──────────────────┐ | ||
| 1953 | + │ 2. TenantSecurityWebFilter │ | ||
| 1954 | + │ Tenant 租户隔离校验 │ | ||
| 1955 | + │ 防止跨租户越权 │ | ||
| 1956 | + └──────────┬──────────────────┘ | ||
| 1957 | + │ | ||
| 1958 | + ┌──────────▼──────────────────┐ | ||
| 1959 | + │ 3. TokenAuthenticationFilter │ | ||
| 1960 | + │ Bearer Token 提取 & 校验 │ | ||
| 1961 | + │ userType 匹配检查 │ | ||
| 1962 | + └──────────┬──────────────────┘ | ||
| 1963 | + │ | ||
| 1964 | + ┌──────────▼──────────────────┐ | ||
| 1965 | + │ 4. Spring Security │ | ||
| 1966 | + │ @PreAuthorize │ | ||
| 1967 | + │ "@ss.hasPermission(...)" │ | ||
| 1968 | + │ "@ss.hasRole(...)" │ | ||
| 1969 | + │ "@ss.hasScope(...)" │ | ||
| 1970 | + └──────────┬──────────────────┘ | ||
| 1971 | + │ | ||
| 1972 | + ┌──────────▼──────────────────┐ | ||
| 1973 | + │ 5. @ApiSignature AOP │ | ||
| 1974 | + │ (仅 /open-api/ 接口) │ | ||
| 1975 | + │ HMAC-SHA256 签名验证 │ | ||
| 1976 | + └──────────┬──────────────────┘ | ||
| 1977 | + │ | ||
| 1978 | + ┌──────────▼──────────────────┐ | ||
| 1979 | + │ 6. Controller 方法执行 │ | ||
| 1980 | + └──────────┬──────────────────┘ | ||
| 1981 | + │ | ||
| 1982 | + ┌──────────▼──────────────────┐ | ||
| 1983 | + │ 7. DeptDataPermissionRule │ | ||
| 1984 | + │ MyBatis SQL 行级过滤 │ | ||
| 1985 | + │ dept_id IN (...) OR │ | ||
| 1986 | + │ creator = ? │ | ||
| 1987 | + └──────────┬──────────────────┘ | ||
| 1988 | + │ | ||
| 1989 | + ┌──────────▼──────────────────┐ | ||
| 1990 | + │ 8. ApiEncryptResponseWrapper │ | ||
| 1991 | + │ 加密响应体(AES/RSA) │ | ||
| 1992 | + └──────────┬──────────────────┘ | ||
| 1993 | + │ | ||
| 1994 | + ┌──────────▼──────────────────┐ | ||
| 1995 | + │ 返回客户端 │ | ||
| 1996 | + └─────────────────────────────┘ | ||
| 1997 | +``` | ||
| 1998 | + | ||
| 1999 | +--- | ||
| 2000 | + | ||
| 2001 | +> 📌 **本文档基于 2026-06-11 项目代码分析生成,如项目安全机制有变更,请同步更新本文档。** |
urbanops-module-garden/pom.xml
| @@ -144,6 +144,20 @@ | @@ -144,6 +144,20 @@ | ||
| 144 | <version>${revision}</version> | 144 | <version>${revision}</version> |
| 145 | <scope>compile</scope> | 145 | <scope>compile</scope> |
| 146 | </dependency> | 146 | </dependency> |
| 147 | + | ||
| 148 | + <!-- 开放API:工单模块 Mapper --> | ||
| 149 | + <dependency> | ||
| 150 | + <groupId>com.zteits.boot</groupId> | ||
| 151 | + <artifactId>urbanops-module-workorder</artifactId> | ||
| 152 | + <version>${revision}</version> | ||
| 153 | + <scope>compile</scope> | ||
| 154 | + </dependency> | ||
| 155 | + | ||
| 156 | + <!-- 开放API:签名鉴权 --> | ||
| 157 | + <dependency> | ||
| 158 | + <groupId>com.zteits.boot</groupId> | ||
| 159 | + <artifactId>urbanops-spring-boot-starter-protection</artifactId> | ||
| 160 | + </dependency> | ||
| 147 | </dependencies> | 161 | </dependencies> |
| 148 | 162 | ||
| 149 | </project> | 163 | </project> |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/AttendanceOpenController.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.attendance; | ||
| 2 | + | ||
| 3 | +import com.zteits.urbanops.framework.common.pojo.CommonResult; | ||
| 4 | +import com.zteits.urbanops.framework.signature.core.annotation.ApiSignature; | ||
| 5 | +import com.zteits.urbanops.module.garden.controller.open.service.OpenApiService; | ||
| 6 | +import com.zteits.urbanops.module.garden.controller.open.attendance.vo.AttendanceQueryReqVO; | ||
| 7 | +import com.zteits.urbanops.module.garden.controller.open.attendance.vo.AttendanceQueryRespVO; | ||
| 8 | +import io.swagger.v3.oas.annotations.Operation; | ||
| 9 | +import io.swagger.v3.oas.annotations.tags.Tag; | ||
| 10 | +import jakarta.annotation.Resource; | ||
| 11 | +import jakarta.validation.Valid; | ||
| 12 | +import lombok.extern.slf4j.Slf4j; | ||
| 13 | +import org.springframework.web.bind.annotation.GetMapping; | ||
| 14 | +import org.springframework.web.bind.annotation.RequestMapping; | ||
| 15 | +import org.springframework.web.bind.annotation.RestController; | ||
| 16 | + | ||
| 17 | +import java.util.List; | ||
| 18 | +import java.util.concurrent.TimeUnit; | ||
| 19 | + | ||
| 20 | +import static com.zteits.urbanops.framework.common.pojo.CommonResult.success; | ||
| 21 | + | ||
| 22 | +@Tag(name = "开放API - 考勤查询") | ||
| 23 | +@RestController | ||
| 24 | +@RequestMapping("/open-api/attendance") | ||
| 25 | +@Slf4j | ||
| 26 | +@jakarta.annotation.security.PermitAll | ||
| 27 | +@ApiSignature(timeout = 60, timeUnit = TimeUnit.SECONDS) | ||
| 28 | +public class AttendanceOpenController { | ||
| 29 | + | ||
| 30 | + @Resource | ||
| 31 | + private OpenApiService openApiService; | ||
| 32 | + | ||
| 33 | + @GetMapping("/query") | ||
| 34 | + @Operation(summary = "查询考勤记录列表") | ||
| 35 | + public CommonResult<List<AttendanceQueryRespVO>> queryAttendanceList( | ||
| 36 | + @Valid AttendanceQueryReqVO reqVO) { | ||
| 37 | + return success(openApiService.queryAttendanceList(reqVO)); | ||
| 38 | + } | ||
| 39 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/vo/AttendanceQueryReqVO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.attendance.vo; | ||
| 2 | + | ||
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | ||
| 4 | +import lombok.Data; | ||
| 5 | +import org.springframework.format.annotation.DateTimeFormat; | ||
| 6 | + | ||
| 7 | +import java.io.Serializable; | ||
| 8 | + | ||
| 9 | +import java.time.LocalDateTime; | ||
| 10 | + | ||
| 11 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; | ||
| 12 | + | ||
| 13 | +@Schema(description = "开放API - 考勤查询 Request VO") | ||
| 14 | +@Data | ||
| 15 | +public class AttendanceQueryReqVO implements Serializable { | ||
| 16 | + | ||
| 17 | + @Schema(description = "人员ID", example = "1001") | ||
| 18 | + private Long userId; | ||
| 19 | + | ||
| 20 | + @Schema(description = "部门ID", example = "100") | ||
| 21 | + private Long deptId; | ||
| 22 | + | ||
| 23 | + @Schema(description = "业务线", example = "yl") | ||
| 24 | + private String busiLine; | ||
| 25 | + | ||
| 26 | + @Schema(description = "打卡类型", example = "上班") | ||
| 27 | + private String punchType; | ||
| 28 | + | ||
| 29 | + @Schema(description = "开始时间", example = "2026-06-01 00:00:00") | ||
| 30 | + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) | ||
| 31 | + private LocalDateTime startTime; | ||
| 32 | + | ||
| 33 | + @Schema(description = "结束时间", example = "2026-06-30 23:59:59") | ||
| 34 | + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) | ||
| 35 | + private LocalDateTime endTime; | ||
| 36 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/attendance/vo/AttendanceQueryRespVO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.attendance.vo; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.annotation.JsonFormat; | ||
| 4 | +import io.swagger.v3.oas.annotations.media.Schema; | ||
| 5 | +import lombok.Data; | ||
| 6 | + | ||
| 7 | +import java.math.BigDecimal; | ||
| 8 | +import java.time.LocalDateTime; | ||
| 9 | + | ||
| 10 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; | ||
| 11 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; | ||
| 12 | + | ||
| 13 | +@Schema(description = "开放API - 考勤记录 Response VO") | ||
| 14 | +@Data | ||
| 15 | +public class AttendanceQueryRespVO { | ||
| 16 | + | ||
| 17 | + @Schema(description = "记录ID", example = "1") | ||
| 18 | + private Integer id; | ||
| 19 | + | ||
| 20 | + @Schema(description = "设备编码", example = "DEV001") | ||
| 21 | + private String deviceCode; | ||
| 22 | + | ||
| 23 | + @Schema(description = "设备名称", example = "打卡机01") | ||
| 24 | + private String deviceName; | ||
| 25 | + | ||
| 26 | + @Schema(description = "打卡类型", example = "上班") | ||
| 27 | + private String punchType; | ||
| 28 | + | ||
| 29 | + @Schema(description = "人员ID", example = "1001") | ||
| 30 | + private Long userId; | ||
| 31 | + | ||
| 32 | + @Schema(description = "人员姓名", example = "张三") | ||
| 33 | + private String nickname; | ||
| 34 | + | ||
| 35 | + @Schema(description = "部门ID", example = "100") | ||
| 36 | + private Long deptId; | ||
| 37 | + | ||
| 38 | + @Schema(description = "部门名称", example = "养护一班") | ||
| 39 | + private String deptName; | ||
| 40 | + | ||
| 41 | + @Schema(description = "打卡地址", example = "北京市西城区xx路") | ||
| 42 | + private String address; | ||
| 43 | + | ||
| 44 | + @Schema(description = "高德纬度(GCJ-02)", example = "39.9042") | ||
| 45 | + private BigDecimal latGcj02; | ||
| 46 | + | ||
| 47 | + @Schema(description = "高德经度(GCJ-02)", example = "116.4074") | ||
| 48 | + private BigDecimal lngGcj02; | ||
| 49 | + | ||
| 50 | + @Schema(description = "WGS84纬度", example = "39.9010") | ||
| 51 | + private BigDecimal lat; | ||
| 52 | + | ||
| 53 | + @Schema(description = "WGS84经度", example = "116.4030") | ||
| 54 | + private BigDecimal lng; | ||
| 55 | + | ||
| 56 | + @Schema(description = "打卡时间", example = "2026-06-11 08:30:00") | ||
| 57 | + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) | ||
| 58 | + private LocalDateTime createTime; | ||
| 59 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/FullOrderStatisticsOpenController.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.fullorderstatistics; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.collection.CollUtil; | ||
| 4 | +import com.zteits.urbanops.framework.common.pojo.CommonResult; | ||
| 5 | +import com.zteits.urbanops.framework.common.util.object.BeanUtils; | ||
| 6 | +import com.zteits.urbanops.framework.mybatis.core.query.LambdaQueryWrapperX; | ||
| 7 | +import com.zteits.urbanops.framework.signature.core.annotation.ApiSignature; | ||
| 8 | +import com.zteits.urbanops.module.garden.controller.open.fullorderstatistics.vo.FullOrderStatisticsOpenReqVO; | ||
| 9 | +import com.zteits.urbanops.module.garden.controller.open.fullorderstatistics.vo.FullOrderStatisticsOpenRespVO; | ||
| 10 | +import com.zteits.urbanops.module.workorder.dal.dataobject.fullorderstatistics.FullOrderStatisticsDO; | ||
| 11 | +import com.zteits.urbanops.module.workorder.dal.mysql.fullorderstatistics.FullOrderStatisticsMapper; | ||
| 12 | +import io.swagger.v3.oas.annotations.Operation; | ||
| 13 | +import io.swagger.v3.oas.annotations.tags.Tag; | ||
| 14 | +import jakarta.annotation.Resource; | ||
| 15 | +import jakarta.validation.Valid; | ||
| 16 | +import lombok.extern.slf4j.Slf4j; | ||
| 17 | +import org.springframework.web.bind.annotation.GetMapping; | ||
| 18 | +import org.springframework.web.bind.annotation.RequestMapping; | ||
| 19 | +import org.springframework.web.bind.annotation.RestController; | ||
| 20 | + | ||
| 21 | +import java.util.Collections; | ||
| 22 | +import java.util.List; | ||
| 23 | +import java.util.concurrent.TimeUnit; | ||
| 24 | + | ||
| 25 | +import static com.zteits.urbanops.framework.common.pojo.CommonResult.success; | ||
| 26 | + | ||
| 27 | +@Tag(name = "开放API - 全量工单统计") | ||
| 28 | +@RestController | ||
| 29 | +@RequestMapping("/open-api/full-order-statistics") | ||
| 30 | +@Slf4j | ||
| 31 | +@jakarta.annotation.security.PermitAll | ||
| 32 | +@ApiSignature(timeout = 60, timeUnit = TimeUnit.SECONDS) | ||
| 33 | +public class FullOrderStatisticsOpenController { | ||
| 34 | + | ||
| 35 | + @Resource | ||
| 36 | + private FullOrderStatisticsMapper mapper; | ||
| 37 | + | ||
| 38 | + @GetMapping("/query") | ||
| 39 | + @Operation(summary = "查询全量工单统计列表(根据条件返回所有匹配记录)") | ||
| 40 | + public CommonResult<List<FullOrderStatisticsOpenRespVO>> queryList( | ||
| 41 | + @Valid FullOrderStatisticsOpenReqVO reqVO) { | ||
| 42 | + LambdaQueryWrapperX<FullOrderStatisticsDO> wrapper = new LambdaQueryWrapperX<FullOrderStatisticsDO>() | ||
| 43 | + .eqIfPresent(FullOrderStatisticsDO::getCompanyId, reqVO.getCompanyId()) | ||
| 44 | + .likeIfPresent(FullOrderStatisticsDO::getCompanyName, reqVO.getCompanyName()) | ||
| 45 | + .eqIfPresent(FullOrderStatisticsDO::getType, reqVO.getType()); | ||
| 46 | + if (reqVO.getStartDate() != null) { | ||
| 47 | + wrapper.ge(FullOrderStatisticsDO::getStartDate, reqVO.getStartDate()); | ||
| 48 | + } | ||
| 49 | + if (reqVO.getEndDate() != null) { | ||
| 50 | + wrapper.le(FullOrderStatisticsDO::getEndDate, reqVO.getEndDate()); | ||
| 51 | + } | ||
| 52 | + List<FullOrderStatisticsDO> list = mapper.selectList(wrapper); | ||
| 53 | + if (CollUtil.isEmpty(list)) { | ||
| 54 | + return success(Collections.emptyList()); | ||
| 55 | + } | ||
| 56 | + return success(BeanUtils.toBean(list, FullOrderStatisticsOpenRespVO.class)); | ||
| 57 | + } | ||
| 58 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/vo/FullOrderStatisticsOpenReqVO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.fullorderstatistics.vo; | ||
| 2 | + | ||
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | ||
| 4 | +import lombok.Data; | ||
| 5 | +import org.springframework.format.annotation.DateTimeFormat; | ||
| 6 | + | ||
| 7 | +import java.io.Serializable; | ||
| 8 | +import java.time.LocalDate; | ||
| 9 | + | ||
| 10 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY; | ||
| 11 | + | ||
| 12 | +@Schema(description = "开放API - 全量工单统计查询 Request VO") | ||
| 13 | +@Data | ||
| 14 | +public class FullOrderStatisticsOpenReqVO implements Serializable { | ||
| 15 | + | ||
| 16 | + @Schema(description = "公司/班组ID", example = "100") | ||
| 17 | + private Long companyId; | ||
| 18 | + | ||
| 19 | + @Schema(description = "公司/班组名称(模糊匹配)", example = "养护一班") | ||
| 20 | + private String companyName; | ||
| 21 | + | ||
| 22 | + @Schema(description = "类型: 1=按公司, 2=按班组", example = "1") | ||
| 23 | + private Integer type; | ||
| 24 | + | ||
| 25 | + @Schema(description = "统计开始日期(只返回开始日期>=该值的记录)", example = "2026-06-01") | ||
| 26 | + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) | ||
| 27 | + private LocalDate startDate; | ||
| 28 | + | ||
| 29 | + @Schema(description = "统计结束日期(只返回结束日期<=该值的记录)", example = "2026-06-30") | ||
| 30 | + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) | ||
| 31 | + private LocalDate endDate; | ||
| 32 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/fullorderstatistics/vo/FullOrderStatisticsOpenRespVO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.fullorderstatistics.vo; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.annotation.JsonFormat; | ||
| 4 | +import io.swagger.v3.oas.annotations.media.Schema; | ||
| 5 | +import lombok.Data; | ||
| 6 | + | ||
| 7 | +import java.time.LocalDate; | ||
| 8 | +import java.time.LocalDateTime; | ||
| 9 | + | ||
| 10 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY; | ||
| 11 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; | ||
| 12 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; | ||
| 13 | + | ||
| 14 | +@Schema(description = "开放API - 全量工单统计 Response VO") | ||
| 15 | +@Data | ||
| 16 | +public class FullOrderStatisticsOpenRespVO { | ||
| 17 | + | ||
| 18 | + @Schema(description = "记录ID", example = "1") | ||
| 19 | + private Long id; | ||
| 20 | + | ||
| 21 | + @Schema(description = "公司/班组ID", example = "100") | ||
| 22 | + private Long companyId; | ||
| 23 | + | ||
| 24 | + @Schema(description = "公司/班组名称", example = "养护一班") | ||
| 25 | + private String companyName; | ||
| 26 | + | ||
| 27 | + @Schema(description = "类型: 1=按公司, 2=按班组", example = "1") | ||
| 28 | + private Integer type; | ||
| 29 | + | ||
| 30 | + @Schema(description = "统计开始日期", example = "2026-06-01") | ||
| 31 | + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY, timezone = TIME_ZONE_DEFAULT) | ||
| 32 | + private LocalDate startDate; | ||
| 33 | + | ||
| 34 | + @Schema(description = "统计结束日期", example = "2026-06-30") | ||
| 35 | + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY, timezone = TIME_ZONE_DEFAULT) | ||
| 36 | + private LocalDate endDate; | ||
| 37 | + | ||
| 38 | + @Schema(description = "工单完成数量", example = "100") | ||
| 39 | + private Integer workOrderCount; | ||
| 40 | + | ||
| 41 | + @Schema(description = "巡查记录数量", example = "50") | ||
| 42 | + private Integer inspectionCount; | ||
| 43 | + | ||
| 44 | + @Schema(description = "养护记录数量", example = "30") | ||
| 45 | + private Integer maintenanceCount; | ||
| 46 | + | ||
| 47 | + @Schema(description = "备注", example = "二季度统计") | ||
| 48 | + private String remark; | ||
| 49 | + | ||
| 50 | + @Schema(description = "创建时间", example = "2026-06-11 10:00:00") | ||
| 51 | + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) | ||
| 52 | + private LocalDateTime createTime; | ||
| 53 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/MaterialOpenController.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.material; | ||
| 2 | + | ||
| 3 | +import com.zteits.urbanops.framework.common.pojo.CommonResult; | ||
| 4 | +import com.zteits.urbanops.framework.signature.core.annotation.ApiSignature; | ||
| 5 | +import com.zteits.urbanops.module.garden.controller.open.service.OpenApiService; | ||
| 6 | +import com.zteits.urbanops.module.garden.controller.open.material.vo.MaterialQueryReqVO; | ||
| 7 | +import com.zteits.urbanops.module.garden.controller.open.material.vo.MaterialQueryRespVO; | ||
| 8 | +import io.swagger.v3.oas.annotations.Operation; | ||
| 9 | +import io.swagger.v3.oas.annotations.tags.Tag; | ||
| 10 | +import jakarta.annotation.Resource; | ||
| 11 | +import jakarta.validation.Valid; | ||
| 12 | +import lombok.extern.slf4j.Slf4j; | ||
| 13 | +import org.springframework.web.bind.annotation.GetMapping; | ||
| 14 | +import org.springframework.web.bind.annotation.RequestMapping; | ||
| 15 | +import org.springframework.web.bind.annotation.RestController; | ||
| 16 | + | ||
| 17 | +import java.util.List; | ||
| 18 | +import java.util.concurrent.TimeUnit; | ||
| 19 | + | ||
| 20 | +import static com.zteits.urbanops.framework.common.pojo.CommonResult.success; | ||
| 21 | + | ||
| 22 | +@Tag(name = "开放API - 物料查询") | ||
| 23 | +@RestController | ||
| 24 | +@RequestMapping("/open-api/materials") | ||
| 25 | +@Slf4j | ||
| 26 | +@jakarta.annotation.security.PermitAll | ||
| 27 | +@ApiSignature(timeout = 60, timeUnit = TimeUnit.SECONDS) | ||
| 28 | +public class MaterialOpenController { | ||
| 29 | + | ||
| 30 | + @Resource | ||
| 31 | + private OpenApiService openApiService; | ||
| 32 | + | ||
| 33 | + @GetMapping("/query") | ||
| 34 | + @Operation(summary = "查询物料记录列表") | ||
| 35 | + public CommonResult<List<MaterialQueryRespVO>> queryMaterialList( | ||
| 36 | + @Valid MaterialQueryReqVO reqVO) { | ||
| 37 | + return success(openApiService.queryMaterialList(reqVO)); | ||
| 38 | + } | ||
| 39 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/vo/MaterialQueryReqVO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.material.vo; | ||
| 2 | + | ||
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | ||
| 4 | +import lombok.Data; | ||
| 5 | +import org.springframework.format.annotation.DateTimeFormat; | ||
| 6 | + | ||
| 7 | +import java.io.Serializable; | ||
| 8 | + | ||
| 9 | +import java.time.LocalDateTime; | ||
| 10 | + | ||
| 11 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; | ||
| 12 | + | ||
| 13 | +@Schema(description = "开放API - 物料查询 Request VO") | ||
| 14 | +@Data | ||
| 15 | +public class MaterialQueryReqVO implements Serializable { | ||
| 16 | + | ||
| 17 | + @Schema(description = "工单号(精确匹配)", example = "WO202606110001") | ||
| 18 | + private String orderNo; | ||
| 19 | + | ||
| 20 | + @Schema(description = "物料分类名(模糊匹配)", example = "药品") | ||
| 21 | + private String classifyName; | ||
| 22 | + | ||
| 23 | + @Schema(description = "物料类型名(模糊匹配)", example = "杀虫剂") | ||
| 24 | + private String typeName; | ||
| 25 | + | ||
| 26 | + @Schema(description = "使用人ID", example = "1001") | ||
| 27 | + private Long userId; | ||
| 28 | + | ||
| 29 | + @Schema(description = "开始时间", example = "2026-06-01 00:00:00") | ||
| 30 | + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) | ||
| 31 | + private LocalDateTime startDate; | ||
| 32 | + | ||
| 33 | + @Schema(description = "结束时间", example = "2026-06-30 23:59:59") | ||
| 34 | + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) | ||
| 35 | + private LocalDateTime endDate; | ||
| 36 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/material/vo/MaterialQueryRespVO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.material.vo; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.annotation.JsonFormat; | ||
| 4 | +import io.swagger.v3.oas.annotations.media.Schema; | ||
| 5 | +import lombok.Data; | ||
| 6 | + | ||
| 7 | +import java.time.LocalDateTime; | ||
| 8 | +import java.util.List; | ||
| 9 | + | ||
| 10 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; | ||
| 11 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; | ||
| 12 | + | ||
| 13 | +@Schema(description = "开放API - 物料记录 Response VO") | ||
| 14 | +@Data | ||
| 15 | +public class MaterialQueryRespVO { | ||
| 16 | + | ||
| 17 | + @Schema(description = "记录ID", example = "1") | ||
| 18 | + private Long id; | ||
| 19 | + | ||
| 20 | + @Schema(description = "工单号", example = "WO202606110001") | ||
| 21 | + private String orderNo; | ||
| 22 | + | ||
| 23 | + @Schema(description = "物料名称", example = "高效氯氰菊酯") | ||
| 24 | + private String materialName; | ||
| 25 | + | ||
| 26 | + @Schema(description = "规格", example = "500ml/瓶") | ||
| 27 | + private String specifications; | ||
| 28 | + | ||
| 29 | + @Schema(description = "使用数量", example = "5") | ||
| 30 | + private Long userCount; | ||
| 31 | + | ||
| 32 | + @Schema(description = "单位", example = "瓶") | ||
| 33 | + private String unitName; | ||
| 34 | + | ||
| 35 | + @Schema(description = "物料分类", example = "药品") | ||
| 36 | + private String classifyName; | ||
| 37 | + | ||
| 38 | + @Schema(description = "物料类型", example = "杀虫剂") | ||
| 39 | + private String typeName; | ||
| 40 | + | ||
| 41 | + @Schema(description = "物料明细类型", example = "有机磷") | ||
| 42 | + private String typeDetailName; | ||
| 43 | + | ||
| 44 | + @Schema(description = "使用人ID", example = "1001") | ||
| 45 | + private Long userId; | ||
| 46 | + | ||
| 47 | + @Schema(description = "使用人姓名", example = "张三") | ||
| 48 | + private String userName; | ||
| 49 | + | ||
| 50 | + @Schema(description = "来源(1=工单/2=巡检/3=养护)", example = "1") | ||
| 51 | + private Integer source; | ||
| 52 | + | ||
| 53 | + @Schema(description = "上传时间", example = "2026-06-11 14:30:00") | ||
| 54 | + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) | ||
| 55 | + private LocalDateTime createTime; | ||
| 56 | + | ||
| 57 | + @Schema(description = "关联照片") | ||
| 58 | + private List<PhotoVO> photos; | ||
| 59 | + | ||
| 60 | + @Schema(description = "照片信息") | ||
| 61 | + @Data | ||
| 62 | + public static class PhotoVO { | ||
| 63 | + | ||
| 64 | + @Schema(description = "业务类型(01=问题/02=处理中/03=完成)", example = "02") | ||
| 65 | + private String busiType; | ||
| 66 | + | ||
| 67 | + @Schema(description = "文件名", example = "material.jpg") | ||
| 68 | + private String fileName; | ||
| 69 | + | ||
| 70 | + @Schema(description = "文件URL", example = "https://xxx.com/file/abc.jpg") | ||
| 71 | + private String url; | ||
| 72 | + } | ||
| 73 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiService.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.service; | ||
| 2 | + | ||
| 3 | +import com.zteits.urbanops.module.garden.controller.open.attendance.vo.AttendanceQueryReqVO; | ||
| 4 | +import com.zteits.urbanops.module.garden.controller.open.attendance.vo.AttendanceQueryRespVO; | ||
| 5 | +import com.zteits.urbanops.module.garden.controller.open.material.vo.MaterialQueryReqVO; | ||
| 6 | +import com.zteits.urbanops.module.garden.controller.open.material.vo.MaterialQueryRespVO; | ||
| 7 | +import com.zteits.urbanops.module.garden.controller.open.staff.vo.StaffQueryReqVO; | ||
| 8 | +import com.zteits.urbanops.module.garden.controller.open.staff.vo.StaffQueryRespVO; | ||
| 9 | +import com.zteits.urbanops.module.garden.controller.open.task.vo.TaskQueryReqVO; | ||
| 10 | +import com.zteits.urbanops.module.garden.controller.open.task.vo.TaskQueryRespVO; | ||
| 11 | +import com.zteits.urbanops.module.garden.controller.open.team.vo.TeamQueryReqVO; | ||
| 12 | +import com.zteits.urbanops.module.garden.controller.open.team.vo.TeamQueryRespVO; | ||
| 13 | + | ||
| 14 | +import java.util.List; | ||
| 15 | + | ||
| 16 | +public interface OpenApiService { | ||
| 17 | + | ||
| 18 | + List<StaffQueryRespVO> queryStaffList(StaffQueryReqVO reqVO); | ||
| 19 | + | ||
| 20 | + List<TeamQueryRespVO> queryTeamList(TeamQueryReqVO reqVO); | ||
| 21 | + | ||
| 22 | + List<AttendanceQueryRespVO> queryAttendanceList(AttendanceQueryReqVO reqVO); | ||
| 23 | + | ||
| 24 | + List<TaskQueryRespVO> queryTaskList(TaskQueryReqVO reqVO); | ||
| 25 | + | ||
| 26 | + List<MaterialQueryRespVO> queryMaterialList(MaterialQueryReqVO reqVO); | ||
| 27 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiServiceImpl.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.service; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.collection.CollUtil; | ||
| 4 | +import cn.hutool.core.util.StrUtil; | ||
| 5 | +import com.zteits.urbanops.framework.mybatis.core.query.LambdaQueryWrapperX; | ||
| 6 | +import com.zteits.urbanops.module.garden.controller.open.attendance.vo.AttendanceQueryReqVO; | ||
| 7 | +import com.zteits.urbanops.module.garden.controller.open.attendance.vo.AttendanceQueryRespVO; | ||
| 8 | +import com.zteits.urbanops.module.garden.controller.open.material.vo.MaterialQueryReqVO; | ||
| 9 | +import com.zteits.urbanops.module.garden.controller.open.material.vo.MaterialQueryRespVO; | ||
| 10 | +import com.zteits.urbanops.module.garden.controller.open.staff.vo.StaffQueryReqVO; | ||
| 11 | +import com.zteits.urbanops.module.garden.controller.open.staff.vo.StaffQueryRespVO; | ||
| 12 | +import com.zteits.urbanops.module.garden.controller.open.task.vo.TaskQueryReqVO; | ||
| 13 | +import com.zteits.urbanops.module.garden.controller.open.task.vo.TaskQueryRespVO; | ||
| 14 | +import com.zteits.urbanops.module.garden.controller.open.team.vo.TeamQueryReqVO; | ||
| 15 | +import com.zteits.urbanops.module.garden.controller.open.team.vo.TeamQueryRespVO; | ||
| 16 | +import com.zteits.urbanops.module.garden.dal.dataobject.device.DeviceClockRecordDO; | ||
| 17 | +import com.zteits.urbanops.module.garden.dal.dataobject.deviceuser.DeviceUserDO; | ||
| 18 | +import com.zteits.urbanops.module.garden.dal.mysql.device.DeviceClockRecordMapper; | ||
| 19 | +import com.zteits.urbanops.module.garden.dal.mysql.deviceuser.DeviceUserMapper; | ||
| 20 | +import com.zteits.urbanops.module.system.dal.dataobject.dept.DeptDO; | ||
| 21 | +import com.zteits.urbanops.module.system.dal.dataobject.dept.PostDO; | ||
| 22 | +import com.zteits.urbanops.module.system.dal.dataobject.user.AdminUserDO; | ||
| 23 | +import com.zteits.urbanops.module.system.dal.mysql.dept.DeptMapper; | ||
| 24 | +import com.zteits.urbanops.module.system.dal.mysql.dept.PostMapper; | ||
| 25 | +import com.zteits.urbanops.module.system.dal.mysql.user.AdminUserMapper; | ||
| 26 | +import com.zteits.urbanops.module.workorder.dal.dataobject.attachment.AttachmentDO; | ||
| 27 | +import com.zteits.urbanops.module.workorder.dal.dataobject.maininfo.MainInfoDO; | ||
| 28 | +import com.zteits.urbanops.module.workorder.dal.dataobject.materialdetail.MaterialDetailDO; | ||
| 29 | +import com.zteits.urbanops.module.workorder.dal.mysql.attachment.AttachmentMapper; | ||
| 30 | +import com.zteits.urbanops.module.workorder.dal.mysql.maininfo.MainInfoMapper; | ||
| 31 | +import com.zteits.urbanops.module.workorder.dal.mysql.materialdetail.MaterialDetailMapper; | ||
| 32 | +import jakarta.annotation.Resource; | ||
| 33 | +import lombok.extern.slf4j.Slf4j; | ||
| 34 | +import org.springframework.stereotype.Service; | ||
| 35 | + | ||
| 36 | +import java.util.*; | ||
| 37 | +import java.util.stream.Collectors; | ||
| 38 | + | ||
| 39 | +@Service | ||
| 40 | +@Slf4j | ||
| 41 | +public class OpenApiServiceImpl implements OpenApiService { | ||
| 42 | + | ||
| 43 | + @Resource | ||
| 44 | + private AdminUserMapper adminUserMapper; | ||
| 45 | + @Resource | ||
| 46 | + private DeptMapper deptMapper; | ||
| 47 | + @Resource | ||
| 48 | + private PostMapper postMapper; | ||
| 49 | + @Resource | ||
| 50 | + private DeviceUserMapper deviceUserMapper; | ||
| 51 | + @Resource | ||
| 52 | + private DeviceClockRecordMapper deviceClockRecordMapper; | ||
| 53 | + @Resource | ||
| 54 | + private MainInfoMapper mainInfoMapper; | ||
| 55 | + @Resource | ||
| 56 | + private MaterialDetailMapper materialDetailMapper; | ||
| 57 | + @Resource | ||
| 58 | + private AttachmentMapper attachmentMapper; | ||
| 59 | + | ||
| 60 | + // ==================== 人员查询 ==================== | ||
| 61 | + | ||
| 62 | + @Override | ||
| 63 | + public List<StaffQueryRespVO> queryStaffList(StaffQueryReqVO reqVO) { | ||
| 64 | + LambdaQueryWrapperX<AdminUserDO> wrapper = new LambdaQueryWrapperX<AdminUserDO>() | ||
| 65 | + .likeIfPresent(AdminUserDO::getNickname, reqVO.getName()) | ||
| 66 | + .likeIfPresent(AdminUserDO::getMobile, reqVO.getMobile()) | ||
| 67 | + .eqIfPresent(AdminUserDO::getDeptId, reqVO.getDeptId()) | ||
| 68 | + .eqIfPresent(AdminUserDO::getStatus, reqVO.getStatus()); | ||
| 69 | + if (StrUtil.isNotBlank(reqVO.getBusiLine())) { | ||
| 70 | + wrapper.apply("FIND_IN_SET({0}, busi_line)", reqVO.getBusiLine()); | ||
| 71 | + } | ||
| 72 | + List<AdminUserDO> userList = adminUserMapper.selectList(wrapper); | ||
| 73 | + if (CollUtil.isEmpty(userList)) { | ||
| 74 | + return Collections.emptyList(); | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + // 批量查部门名 | ||
| 78 | + Set<Long> deptIds = userList.stream().map(AdminUserDO::getDeptId) | ||
| 79 | + .filter(Objects::nonNull).collect(Collectors.toSet()); | ||
| 80 | + Map<Long, String> deptNameMap = CollUtil.isNotEmpty(deptIds) | ||
| 81 | + ? deptMapper.selectList(DeptDO::getId, deptIds).stream() | ||
| 82 | + .collect(Collectors.toMap(DeptDO::getId, DeptDO::getName, (a, b) -> a)) | ||
| 83 | + : Collections.emptyMap(); | ||
| 84 | + | ||
| 85 | + // 批量查岗位名 | ||
| 86 | + Set<Long> allPostIds = userList.stream() | ||
| 87 | + .filter(u -> CollUtil.isNotEmpty(u.getPostIds())) | ||
| 88 | + .flatMap(u -> u.getPostIds().stream()) | ||
| 89 | + .collect(Collectors.toSet()); | ||
| 90 | + Map<Long, String> postNameMap = CollUtil.isNotEmpty(allPostIds) | ||
| 91 | + ? postMapper.selectList(PostDO::getId, allPostIds).stream() | ||
| 92 | + .collect(Collectors.toMap(PostDO::getId, PostDO::getName, (a, b) -> a)) | ||
| 93 | + : Collections.emptyMap(); | ||
| 94 | + | ||
| 95 | + // 批量查工卡号 | ||
| 96 | + Set<Long> userIds = userList.stream().map(AdminUserDO::getId).collect(Collectors.toSet()); | ||
| 97 | + Map<Long, String> deviceNoMap = CollUtil.isNotEmpty(userIds) | ||
| 98 | + ? deviceUserMapper.selectList( | ||
| 99 | + new LambdaQueryWrapperX<DeviceUserDO>().in(DeviceUserDO::getUserId, userIds)) | ||
| 100 | + .stream().collect(Collectors.toMap(DeviceUserDO::getUserId, | ||
| 101 | + d -> StrUtil.blankToDefault(d.getDeviceNo(), ""), (a, b) -> a)) | ||
| 102 | + : Collections.emptyMap(); | ||
| 103 | + | ||
| 104 | + // 构建 VO | ||
| 105 | + List<StaffQueryRespVO> voList = new ArrayList<>(); | ||
| 106 | + for (AdminUserDO user : userList) { | ||
| 107 | + StaffQueryRespVO vo = new StaffQueryRespVO(); | ||
| 108 | + vo.setId(user.getId()); | ||
| 109 | + vo.setUsername(user.getUsername()); | ||
| 110 | + vo.setNickname(user.getNickname()); | ||
| 111 | + vo.setMobile(user.getMobile()); | ||
| 112 | + vo.setDeptId(user.getDeptId()); | ||
| 113 | + vo.setDeptName(deptNameMap.getOrDefault(user.getDeptId(), "")); | ||
| 114 | + vo.setPostIds(user.getPostIds()); | ||
| 115 | + if (CollUtil.isNotEmpty(user.getPostIds())) { | ||
| 116 | + vo.setPostNames(user.getPostIds().stream() | ||
| 117 | + .map(pid -> postNameMap.getOrDefault(pid, "")) | ||
| 118 | + .filter(StrUtil::isNotBlank) | ||
| 119 | + .collect(Collectors.joining(","))); | ||
| 120 | + } | ||
| 121 | + vo.setBusiLine(user.getBusiLine()); | ||
| 122 | + vo.setStatus(user.getStatus()); | ||
| 123 | + vo.setCardNumber(deviceNoMap.getOrDefault(user.getId(), "")); | ||
| 124 | + vo.setIsInner(user.getIsInner()); | ||
| 125 | + vo.setCreateTime(user.getCreateTime()); | ||
| 126 | + voList.add(vo); | ||
| 127 | + } | ||
| 128 | + return voList; | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + // ==================== 班组查询 ==================== | ||
| 132 | + | ||
| 133 | + @Override | ||
| 134 | + public List<TeamQueryRespVO> queryTeamList(TeamQueryReqVO reqVO) { | ||
| 135 | + LambdaQueryWrapperX<DeptDO> wrapper = new LambdaQueryWrapperX<DeptDO>() | ||
| 136 | + .likeIfPresent(DeptDO::getName, reqVO.getName()) | ||
| 137 | + .eqIfPresent(DeptDO::getParentId, reqVO.getParentId()) | ||
| 138 | + .eqIfPresent(DeptDO::getStatus, reqVO.getStatus()); | ||
| 139 | + List<DeptDO> deptList = deptMapper.selectList(wrapper); | ||
| 140 | + if (CollUtil.isEmpty(deptList)) { | ||
| 141 | + return Collections.emptyList(); | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + // 父部门名 | ||
| 145 | + Set<Long> parentIds = deptList.stream().map(DeptDO::getParentId) | ||
| 146 | + .filter(Objects::nonNull).filter(id -> id > 0).collect(Collectors.toSet()); | ||
| 147 | + Map<Long, String> parentNameMap = CollUtil.isNotEmpty(parentIds) | ||
| 148 | + ? deptMapper.selectList(DeptDO::getId, parentIds).stream() | ||
| 149 | + .collect(Collectors.toMap(DeptDO::getId, DeptDO::getName, (a, b) -> a)) | ||
| 150 | + : Collections.emptyMap(); | ||
| 151 | + | ||
| 152 | + // 班组长名 | ||
| 153 | + Set<Long> leaderIds = deptList.stream().map(DeptDO::getLeaderUserId) | ||
| 154 | + .filter(Objects::nonNull).collect(Collectors.toSet()); | ||
| 155 | + Map<Long, String> leaderNameMap = CollUtil.isNotEmpty(leaderIds) | ||
| 156 | + ? adminUserMapper.selectList(AdminUserDO::getId, leaderIds).stream() | ||
| 157 | + .collect(Collectors.toMap(AdminUserDO::getId, | ||
| 158 | + AdminUserDO::getNickname, (a, b) -> a)) | ||
| 159 | + : Collections.emptyMap(); | ||
| 160 | + | ||
| 161 | + // 人员数量统计 | ||
| 162 | + Set<Long> deptIds = deptList.stream().map(DeptDO::getId).collect(Collectors.toSet()); | ||
| 163 | + Map<Long, Long> memberCountMap = Collections.emptyMap(); | ||
| 164 | + if (CollUtil.isNotEmpty(deptIds)) { | ||
| 165 | + List<AdminUserDO> allUsers = adminUserMapper.selectList( | ||
| 166 | + new LambdaQueryWrapperX<AdminUserDO>().in(AdminUserDO::getDeptId, deptIds)); | ||
| 167 | + memberCountMap = allUsers.stream() | ||
| 168 | + .collect(Collectors.groupingBy(AdminUserDO::getDeptId, Collectors.counting())); | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + // 构建 VO | ||
| 172 | + List<TeamQueryRespVO> voList = new ArrayList<>(); | ||
| 173 | + for (DeptDO dept : deptList) { | ||
| 174 | + TeamQueryRespVO vo = new TeamQueryRespVO(); | ||
| 175 | + vo.setId(dept.getId()); | ||
| 176 | + vo.setName(dept.getName()); | ||
| 177 | + vo.setParentId(dept.getParentId()); | ||
| 178 | + vo.setParentName(dept.getParentId() != null && dept.getParentId() > 0 | ||
| 179 | + ? parentNameMap.getOrDefault(dept.getParentId(), "") : ""); | ||
| 180 | + vo.setLeaderUserId(dept.getLeaderUserId()); | ||
| 181 | + vo.setLeaderUserName(leaderNameMap.getOrDefault(dept.getLeaderUserId(), "")); | ||
| 182 | + vo.setMemberCount(memberCountMap.getOrDefault(dept.getId(), 0L)); | ||
| 183 | + vo.setSort(dept.getSort()); | ||
| 184 | + vo.setStatus(dept.getStatus()); | ||
| 185 | + voList.add(vo); | ||
| 186 | + } | ||
| 187 | + return voList; | ||
| 188 | + } | ||
| 189 | + | ||
| 190 | + // ==================== 考勤查询 ==================== | ||
| 191 | + | ||
| 192 | + @Override | ||
| 193 | + public List<AttendanceQueryRespVO> queryAttendanceList(AttendanceQueryReqVO reqVO) { | ||
| 194 | + LambdaQueryWrapperX<DeviceClockRecordDO> wrapper = new LambdaQueryWrapperX<DeviceClockRecordDO>() | ||
| 195 | + .eqIfPresent(DeviceClockRecordDO::getUserId, reqVO.getUserId()) | ||
| 196 | + .eqIfPresent(DeviceClockRecordDO::getDeptId, reqVO.getDeptId()) | ||
| 197 | + .eqIfPresent(DeviceClockRecordDO::getPunchType, reqVO.getPunchType()) | ||
| 198 | + .betweenIfPresent(DeviceClockRecordDO::getCreateTime, | ||
| 199 | + reqVO.getStartTime(), reqVO.getEndTime()); | ||
| 200 | + if (StrUtil.isNotBlank(reqVO.getBusiLine())) { | ||
| 201 | + wrapper.apply("FIND_IN_SET({0}, busi_line)", reqVO.getBusiLine()); | ||
| 202 | + } | ||
| 203 | + List<DeviceClockRecordDO> recordList = deviceClockRecordMapper.selectList(wrapper); | ||
| 204 | + if (CollUtil.isEmpty(recordList)) { | ||
| 205 | + return Collections.emptyList(); | ||
| 206 | + } | ||
| 207 | + | ||
| 208 | + // 部门名 | ||
| 209 | + Set<Long> deptIds = recordList.stream().map(DeviceClockRecordDO::getDeptId) | ||
| 210 | + .filter(Objects::nonNull).collect(Collectors.toSet()); | ||
| 211 | + Map<Long, String> deptNameMap = CollUtil.isNotEmpty(deptIds) | ||
| 212 | + ? deptMapper.selectList(DeptDO::getId, deptIds).stream() | ||
| 213 | + .collect(Collectors.toMap(DeptDO::getId, DeptDO::getName, (a, b) -> a)) | ||
| 214 | + : Collections.emptyMap(); | ||
| 215 | + | ||
| 216 | + List<AttendanceQueryRespVO> voList = new ArrayList<>(); | ||
| 217 | + for (DeviceClockRecordDO rec : recordList) { | ||
| 218 | + AttendanceQueryRespVO vo = new AttendanceQueryRespVO(); | ||
| 219 | + vo.setId(rec.getId()); | ||
| 220 | + vo.setDeviceCode(rec.getDeviceCode()); | ||
| 221 | + vo.setDeviceName(rec.getDeviceName()); | ||
| 222 | + vo.setPunchType(rec.getPunchType()); | ||
| 223 | + vo.setUserId(rec.getUserId()); | ||
| 224 | + vo.setNickname(rec.getNickname()); | ||
| 225 | + vo.setDeptId(rec.getDeptId()); | ||
| 226 | + vo.setDeptName(deptNameMap.getOrDefault(rec.getDeptId(), "")); | ||
| 227 | + vo.setAddress(rec.getAddress()); | ||
| 228 | + vo.setLatGcj02(rec.getLatGcj02()); | ||
| 229 | + vo.setLngGcj02(rec.getLngGcj02()); | ||
| 230 | + vo.setLat(rec.getLat()); | ||
| 231 | + vo.setLng(rec.getLng()); | ||
| 232 | + vo.setCreateTime(rec.getCreateTime()); | ||
| 233 | + voList.add(vo); | ||
| 234 | + } | ||
| 235 | + return voList; | ||
| 236 | + } | ||
| 237 | + | ||
| 238 | + // ==================== 工单查询 ==================== | ||
| 239 | + | ||
| 240 | + @Override | ||
| 241 | + public List<TaskQueryRespVO> queryTaskList(TaskQueryReqVO reqVO) { | ||
| 242 | + LambdaQueryWrapperX<MainInfoDO> wrapper = new LambdaQueryWrapperX<MainInfoDO>() | ||
| 243 | + .likeIfPresent(MainInfoDO::getOrderNo, reqVO.getOrderNo()) | ||
| 244 | + .likeIfPresent(MainInfoDO::getOrderName, reqVO.getOrderName()) | ||
| 245 | + .eqIfPresent(MainInfoDO::getStatus, reqVO.getStatus()) | ||
| 246 | + .eqIfPresent(MainInfoDO::getBuzStatus, reqVO.getBuzStatus()) | ||
| 247 | + .eqIfPresent(MainInfoDO::getWorkerId, reqVO.getWorkerId()) | ||
| 248 | + .betweenIfPresent(MainInfoDO::getCommitDate, | ||
| 249 | + reqVO.getStartDate(), reqVO.getEndDate()); | ||
| 250 | + if (StrUtil.isNotBlank(reqVO.getBusiLine())) { | ||
| 251 | + wrapper.apply("FIND_IN_SET({0}, busi_line)", reqVO.getBusiLine()); | ||
| 252 | + } | ||
| 253 | + List<MainInfoDO> taskList = mainInfoMapper.selectList(wrapper); | ||
| 254 | + if (CollUtil.isEmpty(taskList)) { | ||
| 255 | + return Collections.emptyList(); | ||
| 256 | + } | ||
| 257 | + | ||
| 258 | + // 附件照片 | ||
| 259 | + Set<String> orderNos = taskList.stream().map(MainInfoDO::getOrderNo) | ||
| 260 | + .filter(Objects::nonNull).collect(Collectors.toSet()); | ||
| 261 | + Map<String, List<AttachmentDO>> attachmentMap = Collections.emptyMap(); | ||
| 262 | + if (CollUtil.isNotEmpty(orderNos)) { | ||
| 263 | + List<AttachmentDO> attachments = attachmentMapper.selectList( | ||
| 264 | + new LambdaQueryWrapperX<AttachmentDO>().in(AttachmentDO::getOrderNo, orderNos)); | ||
| 265 | + attachmentMap = attachments.stream() | ||
| 266 | + .collect(Collectors.groupingBy(AttachmentDO::getOrderNo)); | ||
| 267 | + } | ||
| 268 | + | ||
| 269 | + // 共同处理人姓名 | ||
| 270 | + Set<Long> allCoHandlerIds = taskList.stream() | ||
| 271 | + .filter(t -> CollUtil.isNotEmpty(t.getCoHandlers())) | ||
| 272 | + .flatMap(t -> t.getCoHandlers().stream()) | ||
| 273 | + .collect(Collectors.toSet()); | ||
| 274 | + Map<Long, String> coHandlerNameMap = CollUtil.isNotEmpty(allCoHandlerIds) | ||
| 275 | + ? adminUserMapper.selectList(AdminUserDO::getId, allCoHandlerIds).stream() | ||
| 276 | + .collect(Collectors.toMap(AdminUserDO::getId, | ||
| 277 | + AdminUserDO::getNickname, (a, b) -> a)) | ||
| 278 | + : Collections.emptyMap(); | ||
| 279 | + | ||
| 280 | + // 构建 VO | ||
| 281 | + List<TaskQueryRespVO> voList = new ArrayList<>(); | ||
| 282 | + for (MainInfoDO task : taskList) { | ||
| 283 | + TaskQueryRespVO vo = new TaskQueryRespVO(); | ||
| 284 | + vo.setId(task.getId()); | ||
| 285 | + vo.setOrderNo(task.getOrderNo()); | ||
| 286 | + vo.setOrderName(task.getOrderName()); | ||
| 287 | + vo.setOrderType(task.getOrderType()); | ||
| 288 | + vo.setBusiLine(task.getBusiLine()); | ||
| 289 | + vo.setCommitDate(task.getCommitDate()); | ||
| 290 | + vo.setFinishDate(task.getFinishDate()); | ||
| 291 | + vo.setUserId(task.getUserId()); | ||
| 292 | + vo.setUserName(task.getUserName()); | ||
| 293 | + vo.setWorkerId(task.getWorkerId()); | ||
| 294 | + vo.setWorkerName(task.getWorkerName()); | ||
| 295 | + if (CollUtil.isNotEmpty(task.getCoHandlers())) { | ||
| 296 | + vo.setCoHandlers(task.getCoHandlers().stream() | ||
| 297 | + .map(id -> coHandlerNameMap.getOrDefault(id, "")) | ||
| 298 | + .filter(StrUtil::isNotBlank) | ||
| 299 | + .collect(Collectors.toList())); | ||
| 300 | + } | ||
| 301 | + vo.setWoSourceName(task.getWoSourceName()); | ||
| 302 | + vo.setStatus(task.getStatus()); | ||
| 303 | + vo.setBuzStatus(task.getBuzStatus()); | ||
| 304 | + vo.setRemark(task.getRemark()); | ||
| 305 | + // 照片 | ||
| 306 | + List<AttachmentDO> attList = attachmentMap.getOrDefault( | ||
| 307 | + task.getOrderNo(), Collections.emptyList()); | ||
| 308 | + if (CollUtil.isNotEmpty(attList)) { | ||
| 309 | + vo.setPhotos(attList.stream().map(att -> { | ||
| 310 | + TaskQueryRespVO.PhotoVO p = new TaskQueryRespVO.PhotoVO(); | ||
| 311 | + p.setBusiType(att.getBusiType()); | ||
| 312 | + p.setFileName(att.getFileNames()); | ||
| 313 | + p.setUrl(att.getHostUrl()); | ||
| 314 | + return p; | ||
| 315 | + }).collect(Collectors.toList())); | ||
| 316 | + } | ||
| 317 | + voList.add(vo); | ||
| 318 | + } | ||
| 319 | + return voList; | ||
| 320 | + } | ||
| 321 | + | ||
| 322 | + // ==================== 物料查询 ==================== | ||
| 323 | + | ||
| 324 | + @Override | ||
| 325 | + public List<MaterialQueryRespVO> queryMaterialList(MaterialQueryReqVO reqVO) { | ||
| 326 | + LambdaQueryWrapperX<MaterialDetailDO> wrapper = new LambdaQueryWrapperX<MaterialDetailDO>() | ||
| 327 | + .eqIfPresent(MaterialDetailDO::getOrderNo, reqVO.getOrderNo()) | ||
| 328 | + .likeIfPresent(MaterialDetailDO::getClassifyName, reqVO.getClassifyName()) | ||
| 329 | + .likeIfPresent(MaterialDetailDO::getTypeName, reqVO.getTypeName()) | ||
| 330 | + .eqIfPresent(MaterialDetailDO::getUserId, reqVO.getUserId()) | ||
| 331 | + .betweenIfPresent(MaterialDetailDO::getCreateTime, | ||
| 332 | + reqVO.getStartDate(), reqVO.getEndDate()); | ||
| 333 | + List<MaterialDetailDO> matList = materialDetailMapper.selectList(wrapper); | ||
| 334 | + if (CollUtil.isEmpty(matList)) { | ||
| 335 | + return Collections.emptyList(); | ||
| 336 | + } | ||
| 337 | + | ||
| 338 | + // 批量查附件 | ||
| 339 | + Set<String> orderNos = matList.stream().map(MaterialDetailDO::getOrderNo) | ||
| 340 | + .filter(Objects::nonNull).collect(Collectors.toSet()); | ||
| 341 | + Map<String, List<AttachmentDO>> attachmentMap = Collections.emptyMap(); | ||
| 342 | + if (CollUtil.isNotEmpty(orderNos)) { | ||
| 343 | + List<AttachmentDO> attachments = attachmentMapper.selectList( | ||
| 344 | + new LambdaQueryWrapperX<AttachmentDO>().in(AttachmentDO::getOrderNo, orderNos)); | ||
| 345 | + attachmentMap = attachments.stream() | ||
| 346 | + .collect(Collectors.groupingBy(AttachmentDO::getOrderNo)); | ||
| 347 | + } | ||
| 348 | + | ||
| 349 | + // 构建 VO | ||
| 350 | + List<MaterialQueryRespVO> voList = new ArrayList<>(); | ||
| 351 | + for (MaterialDetailDO mat : matList) { | ||
| 352 | + MaterialQueryRespVO vo = new MaterialQueryRespVO(); | ||
| 353 | + vo.setId(mat.getId()); | ||
| 354 | + vo.setOrderNo(mat.getOrderNo()); | ||
| 355 | + vo.setMaterialName(mat.getMaterialName()); | ||
| 356 | + vo.setSpecifications(mat.getSpecifications()); | ||
| 357 | + vo.setUserCount(mat.getUserCount()); | ||
| 358 | + vo.setUnitName(mat.getUnitName()); | ||
| 359 | + vo.setClassifyName(mat.getClassifyName()); | ||
| 360 | + vo.setTypeName(mat.getTypeName()); | ||
| 361 | + vo.setTypeDetailName(mat.getTypeDetailName()); | ||
| 362 | + vo.setUserId(mat.getUserId()); | ||
| 363 | + vo.setUserName(mat.getUserName()); | ||
| 364 | + vo.setSource(mat.getSource()); | ||
| 365 | + vo.setCreateTime(mat.getCreateTime()); | ||
| 366 | + // 照片 | ||
| 367 | + List<AttachmentDO> attList = attachmentMap.getOrDefault( | ||
| 368 | + mat.getOrderNo(), Collections.emptyList()); | ||
| 369 | + if (CollUtil.isNotEmpty(attList)) { | ||
| 370 | + vo.setPhotos(attList.stream().map(att -> { | ||
| 371 | + MaterialQueryRespVO.PhotoVO p = new MaterialQueryRespVO.PhotoVO(); | ||
| 372 | + p.setBusiType(att.getBusiType()); | ||
| 373 | + p.setFileName(att.getFileNames()); | ||
| 374 | + p.setUrl(att.getHostUrl()); | ||
| 375 | + return p; | ||
| 376 | + }).collect(Collectors.toList())); | ||
| 377 | + } | ||
| 378 | + voList.add(vo); | ||
| 379 | + } | ||
| 380 | + return voList; | ||
| 381 | + } | ||
| 382 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/StaffOpenController.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.staff; | ||
| 2 | + | ||
| 3 | +import com.zteits.urbanops.framework.common.pojo.CommonResult; | ||
| 4 | +import com.zteits.urbanops.framework.signature.core.annotation.ApiSignature; | ||
| 5 | +import com.zteits.urbanops.module.garden.controller.open.service.OpenApiService; | ||
| 6 | +import com.zteits.urbanops.module.garden.controller.open.staff.vo.StaffQueryReqVO; | ||
| 7 | +import com.zteits.urbanops.module.garden.controller.open.staff.vo.StaffQueryRespVO; | ||
| 8 | +import io.swagger.v3.oas.annotations.Operation; | ||
| 9 | +import io.swagger.v3.oas.annotations.tags.Tag; | ||
| 10 | +import jakarta.annotation.Resource; | ||
| 11 | +import jakarta.validation.Valid; | ||
| 12 | +import lombok.extern.slf4j.Slf4j; | ||
| 13 | +import org.springframework.web.bind.annotation.GetMapping; | ||
| 14 | +import org.springframework.web.bind.annotation.RequestMapping; | ||
| 15 | +import org.springframework.web.bind.annotation.RestController; | ||
| 16 | + | ||
| 17 | +import java.util.List; | ||
| 18 | +import java.util.concurrent.TimeUnit; | ||
| 19 | + | ||
| 20 | +import static com.zteits.urbanops.framework.common.pojo.CommonResult.success; | ||
| 21 | + | ||
| 22 | +@Tag(name = "开放API - 人员查询") | ||
| 23 | +@RestController | ||
| 24 | +@RequestMapping("/open-api/staff") | ||
| 25 | +@Slf4j | ||
| 26 | +@jakarta.annotation.security.PermitAll | ||
| 27 | +@ApiSignature(timeout = 60, timeUnit = TimeUnit.SECONDS) | ||
| 28 | +public class StaffOpenController { | ||
| 29 | + | ||
| 30 | + @Resource | ||
| 31 | + private OpenApiService openApiService; | ||
| 32 | + | ||
| 33 | + @GetMapping("/query") | ||
| 34 | + @Operation(summary = "查询人员档案列表") | ||
| 35 | + public CommonResult<List<StaffQueryRespVO>> queryStaffList(@Valid StaffQueryReqVO reqVO) { | ||
| 36 | + return success(openApiService.queryStaffList(reqVO)); | ||
| 37 | + } | ||
| 38 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/vo/StaffQueryReqVO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.staff.vo; | ||
| 2 | + | ||
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | ||
| 4 | +import lombok.Data; | ||
| 5 | + | ||
| 6 | +import java.io.Serializable; | ||
| 7 | + | ||
| 8 | +@Schema(description = "开放API - 人员查询 Request VO") | ||
| 9 | +@Data | ||
| 10 | +public class StaffQueryReqVO implements Serializable { | ||
| 11 | + | ||
| 12 | + @Schema(description = "姓名(模糊匹配)", example = "张三") | ||
| 13 | + private String name; | ||
| 14 | + | ||
| 15 | + @Schema(description = "手机号(模糊匹配)", example = "13800138000") | ||
| 16 | + private String mobile; | ||
| 17 | + | ||
| 18 | + @Schema(description = "部门ID", example = "100") | ||
| 19 | + private Long deptId; | ||
| 20 | + | ||
| 21 | + @Schema(description = "状态(0=启用,1=禁用)", example = "0") | ||
| 22 | + private Integer status; | ||
| 23 | + | ||
| 24 | + @Schema(description = "业务线", example = "yl") | ||
| 25 | + private String busiLine; | ||
| 26 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/staff/vo/StaffQueryRespVO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.staff.vo; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.annotation.JsonFormat; | ||
| 4 | +import io.swagger.v3.oas.annotations.media.Schema; | ||
| 5 | +import lombok.Data; | ||
| 6 | + | ||
| 7 | +import java.time.LocalDateTime; | ||
| 8 | +import java.util.Set; | ||
| 9 | + | ||
| 10 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; | ||
| 11 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; | ||
| 12 | + | ||
| 13 | +@Schema(description = "开放API - 人员信息 Response VO") | ||
| 14 | +@Data | ||
| 15 | +public class StaffQueryRespVO { | ||
| 16 | + | ||
| 17 | + @Schema(description = "人员ID", example = "1") | ||
| 18 | + private Long id; | ||
| 19 | + | ||
| 20 | + @Schema(description = "用户名", example = "zhangsan") | ||
| 21 | + private String username; | ||
| 22 | + | ||
| 23 | + @Schema(description = "姓名", example = "张三") | ||
| 24 | + private String nickname; | ||
| 25 | + | ||
| 26 | + @Schema(description = "手机号", example = "13800138000") | ||
| 27 | + private String mobile; | ||
| 28 | + | ||
| 29 | + @Schema(description = "部门ID", example = "100") | ||
| 30 | + private Long deptId; | ||
| 31 | + | ||
| 32 | + @Schema(description = "部门名称", example = "养护一班") | ||
| 33 | + private String deptName; | ||
| 34 | + | ||
| 35 | + @Schema(description = "岗位ID列表") | ||
| 36 | + private Set<Long> postIds; | ||
| 37 | + | ||
| 38 | + @Schema(description = "岗位名称(逗号分隔)", example = "班组长,技术员") | ||
| 39 | + private String postNames; | ||
| 40 | + | ||
| 41 | + @Schema(description = "业务线", example = "yl") | ||
| 42 | + private String busiLine; | ||
| 43 | + | ||
| 44 | + @Schema(description = "状态(0=启用,1=禁用)", example = "0") | ||
| 45 | + private Integer status; | ||
| 46 | + | ||
| 47 | + @Schema(description = "工卡号(设备编号)", example = "DEV001") | ||
| 48 | + private String cardNumber; | ||
| 49 | + | ||
| 50 | + @Schema(description = "是否内部人员(1=内部,2=外部)", example = "1") | ||
| 51 | + private Integer isInner; | ||
| 52 | + | ||
| 53 | + @Schema(description = "入职日期", example = "2024-01-15 00:00:00") | ||
| 54 | + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) | ||
| 55 | + private LocalDateTime createTime; | ||
| 56 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/TaskOpenController.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.task; | ||
| 2 | + | ||
| 3 | +import com.zteits.urbanops.framework.common.pojo.CommonResult; | ||
| 4 | +import com.zteits.urbanops.framework.signature.core.annotation.ApiSignature; | ||
| 5 | +import com.zteits.urbanops.module.garden.controller.open.service.OpenApiService; | ||
| 6 | +import com.zteits.urbanops.module.garden.controller.open.task.vo.TaskQueryReqVO; | ||
| 7 | +import com.zteits.urbanops.module.garden.controller.open.task.vo.TaskQueryRespVO; | ||
| 8 | +import io.swagger.v3.oas.annotations.Operation; | ||
| 9 | +import io.swagger.v3.oas.annotations.tags.Tag; | ||
| 10 | +import jakarta.annotation.Resource; | ||
| 11 | +import jakarta.validation.Valid; | ||
| 12 | +import lombok.extern.slf4j.Slf4j; | ||
| 13 | +import org.springframework.web.bind.annotation.GetMapping; | ||
| 14 | +import org.springframework.web.bind.annotation.RequestMapping; | ||
| 15 | +import org.springframework.web.bind.annotation.RestController; | ||
| 16 | + | ||
| 17 | +import java.util.List; | ||
| 18 | +import java.util.concurrent.TimeUnit; | ||
| 19 | + | ||
| 20 | +import static com.zteits.urbanops.framework.common.pojo.CommonResult.success; | ||
| 21 | + | ||
| 22 | +@Tag(name = "开放API - 工单查询") | ||
| 23 | +@RestController | ||
| 24 | +@RequestMapping("/open-api/tasks") | ||
| 25 | +@Slf4j | ||
| 26 | +@jakarta.annotation.security.PermitAll | ||
| 27 | +@ApiSignature(timeout = 60, timeUnit = TimeUnit.SECONDS) | ||
| 28 | +public class TaskOpenController { | ||
| 29 | + | ||
| 30 | + @Resource | ||
| 31 | + private OpenApiService openApiService; | ||
| 32 | + | ||
| 33 | + @GetMapping("/query") | ||
| 34 | + @Operation(summary = "查询工单列表") | ||
| 35 | + public CommonResult<List<TaskQueryRespVO>> queryTaskList(@Valid TaskQueryReqVO reqVO) { | ||
| 36 | + return success(openApiService.queryTaskList(reqVO)); | ||
| 37 | + } | ||
| 38 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/vo/TaskQueryReqVO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.task.vo; | ||
| 2 | + | ||
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | ||
| 4 | +import lombok.Data; | ||
| 5 | +import org.springframework.format.annotation.DateTimeFormat; | ||
| 6 | + | ||
| 7 | +import java.io.Serializable; | ||
| 8 | + | ||
| 9 | +import java.time.LocalDateTime; | ||
| 10 | + | ||
| 11 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; | ||
| 12 | + | ||
| 13 | +@Schema(description = "开放API - 工单查询 Request VO") | ||
| 14 | +@Data | ||
| 15 | +public class TaskQueryReqVO implements Serializable { | ||
| 16 | + | ||
| 17 | + @Schema(description = "工单号(模糊匹配)", example = "WO202606110001") | ||
| 18 | + private String orderNo; | ||
| 19 | + | ||
| 20 | + @Schema(description = "工单名称(模糊匹配)", example = "树木修剪") | ||
| 21 | + private String orderName; | ||
| 22 | + | ||
| 23 | + @Schema(description = "业务线", example = "yl") | ||
| 24 | + private String busiLine; | ||
| 25 | + | ||
| 26 | + @Schema(description = "审批状态", example = "1") | ||
| 27 | + private Integer status; | ||
| 28 | + | ||
| 29 | + @Schema(description = "业务状态", example = "processing") | ||
| 30 | + private String buzStatus; | ||
| 31 | + | ||
| 32 | + @Schema(description = "接单人ID", example = "1001") | ||
| 33 | + private Long workerId; | ||
| 34 | + | ||
| 35 | + @Schema(description = "发布时间起始", example = "2026-06-01 00:00:00") | ||
| 36 | + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) | ||
| 37 | + private LocalDateTime startDate; | ||
| 38 | + | ||
| 39 | + @Schema(description = "发布时间截止", example = "2026-06-30 23:59:59") | ||
| 40 | + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) | ||
| 41 | + private LocalDateTime endDate; | ||
| 42 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/task/vo/TaskQueryRespVO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.task.vo; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.annotation.JsonFormat; | ||
| 4 | +import io.swagger.v3.oas.annotations.media.Schema; | ||
| 5 | +import lombok.Data; | ||
| 6 | + | ||
| 7 | +import java.time.LocalDateTime; | ||
| 8 | +import java.util.List; | ||
| 9 | + | ||
| 10 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; | ||
| 11 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; | ||
| 12 | + | ||
| 13 | +@Schema(description = "开放API - 工单信息 Response VO") | ||
| 14 | +@Data | ||
| 15 | +public class TaskQueryRespVO { | ||
| 16 | + | ||
| 17 | + @Schema(description = "工单ID", example = "1001") | ||
| 18 | + private Long id; | ||
| 19 | + | ||
| 20 | + @Schema(description = "工单号", example = "WO202606110001") | ||
| 21 | + private String orderNo; | ||
| 22 | + | ||
| 23 | + @Schema(description = "工单名称", example = "树木修剪") | ||
| 24 | + private String orderName; | ||
| 25 | + | ||
| 26 | + @Schema(description = "工单类型(Q=快速/C=普通/O=其他)", example = "C") | ||
| 27 | + private String orderType; | ||
| 28 | + | ||
| 29 | + @Schema(description = "业务线", example = "yl") | ||
| 30 | + private String busiLine; | ||
| 31 | + | ||
| 32 | + @Schema(description = "发布时间", example = "2026-06-11 09:00:00") | ||
| 33 | + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) | ||
| 34 | + private LocalDateTime commitDate; | ||
| 35 | + | ||
| 36 | + @Schema(description = "完成时间", example = "2026-06-11 17:00:00") | ||
| 37 | + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) | ||
| 38 | + private LocalDateTime finishDate; | ||
| 39 | + | ||
| 40 | + @Schema(description = "发布人ID", example = "1") | ||
| 41 | + private Long userId; | ||
| 42 | + | ||
| 43 | + @Schema(description = "发布人姓名", example = "管理员") | ||
| 44 | + private String userName; | ||
| 45 | + | ||
| 46 | + @Schema(description = "接单人ID", example = "1001") | ||
| 47 | + private Long workerId; | ||
| 48 | + | ||
| 49 | + @Schema(description = "接单人姓名", example = "张三") | ||
| 50 | + private String workerName; | ||
| 51 | + | ||
| 52 | + @Schema(description = "共同处理人姓名列表", example = "[\"李四\", \"王五\"]") | ||
| 53 | + private List<String> coHandlers; | ||
| 54 | + | ||
| 55 | + @Schema(description = "接单渠道", example = "小程序") | ||
| 56 | + private String woSourceName; | ||
| 57 | + | ||
| 58 | + @Schema(description = "审批状态", example = "1") | ||
| 59 | + private Integer status; | ||
| 60 | + | ||
| 61 | + @Schema(description = "业务状态", example = "processing") | ||
| 62 | + private String buzStatus; | ||
| 63 | + | ||
| 64 | + @Schema(description = "备注", example = "紧急处理") | ||
| 65 | + private String remark; | ||
| 66 | + | ||
| 67 | + @Schema(description = "工单照片") | ||
| 68 | + private List<PhotoVO> photos; | ||
| 69 | + | ||
| 70 | + @Schema(description = "照片信息") | ||
| 71 | + @Data | ||
| 72 | + public static class PhotoVO { | ||
| 73 | + | ||
| 74 | + @Schema(description = "业务类型(01=问题/02=处理中/03=完成)", example = "01") | ||
| 75 | + private String busiType; | ||
| 76 | + | ||
| 77 | + @Schema(description = "文件名", example = "problem.jpg") | ||
| 78 | + private String fileName; | ||
| 79 | + | ||
| 80 | + @Schema(description = "文件URL", example = "https://xxx.com/file/abc.jpg") | ||
| 81 | + private String url; | ||
| 82 | + } | ||
| 83 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/TeamOpenController.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.team; | ||
| 2 | + | ||
| 3 | +import com.zteits.urbanops.framework.common.pojo.CommonResult; | ||
| 4 | +import com.zteits.urbanops.framework.signature.core.annotation.ApiSignature; | ||
| 5 | +import com.zteits.urbanops.module.garden.controller.open.service.OpenApiService; | ||
| 6 | +import com.zteits.urbanops.module.garden.controller.open.team.vo.TeamQueryReqVO; | ||
| 7 | +import com.zteits.urbanops.module.garden.controller.open.team.vo.TeamQueryRespVO; | ||
| 8 | +import io.swagger.v3.oas.annotations.Operation; | ||
| 9 | +import io.swagger.v3.oas.annotations.tags.Tag; | ||
| 10 | +import jakarta.annotation.Resource; | ||
| 11 | +import jakarta.validation.Valid; | ||
| 12 | +import lombok.extern.slf4j.Slf4j; | ||
| 13 | +import org.springframework.web.bind.annotation.GetMapping; | ||
| 14 | +import org.springframework.web.bind.annotation.RequestMapping; | ||
| 15 | +import org.springframework.web.bind.annotation.RestController; | ||
| 16 | + | ||
| 17 | +import java.util.List; | ||
| 18 | +import java.util.concurrent.TimeUnit; | ||
| 19 | + | ||
| 20 | +import static com.zteits.urbanops.framework.common.pojo.CommonResult.success; | ||
| 21 | + | ||
| 22 | +@Tag(name = "开放API - 班组查询") | ||
| 23 | +@RestController | ||
| 24 | +@RequestMapping("/open-api/teams") | ||
| 25 | +@Slf4j | ||
| 26 | +@jakarta.annotation.security.PermitAll | ||
| 27 | +@ApiSignature(timeout = 60, timeUnit = TimeUnit.SECONDS) | ||
| 28 | +public class TeamOpenController { | ||
| 29 | + | ||
| 30 | + @Resource | ||
| 31 | + private OpenApiService openApiService; | ||
| 32 | + | ||
| 33 | + @GetMapping("/query") | ||
| 34 | + @Operation(summary = "查询班组信息列表") | ||
| 35 | + public CommonResult<List<TeamQueryRespVO>> queryTeamList(@Valid TeamQueryReqVO reqVO) { | ||
| 36 | + return success(openApiService.queryTeamList(reqVO)); | ||
| 37 | + } | ||
| 38 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/vo/TeamQueryReqVO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.team.vo; | ||
| 2 | + | ||
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | ||
| 4 | +import lombok.Data; | ||
| 5 | + | ||
| 6 | +import java.io.Serializable; | ||
| 7 | + | ||
| 8 | +@Schema(description = "开放API - 班组查询 Request VO") | ||
| 9 | +@Data | ||
| 10 | +public class TeamQueryReqVO implements Serializable { | ||
| 11 | + | ||
| 12 | + @Schema(description = "班组名称(模糊匹配)", example = "养护一班") | ||
| 13 | + private String name; | ||
| 14 | + | ||
| 15 | + @Schema(description = "父部门ID", example = "1") | ||
| 16 | + private Long parentId; | ||
| 17 | + | ||
| 18 | + @Schema(description = "状态(0=启用,1=禁用)", example = "0") | ||
| 19 | + private Integer status; | ||
| 20 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/team/vo/TeamQueryRespVO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.garden.controller.open.team.vo; | ||
| 2 | + | ||
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | ||
| 4 | +import lombok.Data; | ||
| 5 | + | ||
| 6 | +@Schema(description = "开放API - 班组信息 Response VO") | ||
| 7 | +@Data | ||
| 8 | +public class TeamQueryRespVO { | ||
| 9 | + | ||
| 10 | + @Schema(description = "班组ID", example = "100") | ||
| 11 | + private Long id; | ||
| 12 | + | ||
| 13 | + @Schema(description = "班组名称", example = "养护一班") | ||
| 14 | + private String name; | ||
| 15 | + | ||
| 16 | + @Schema(description = "父部门ID", example = "1") | ||
| 17 | + private Long parentId; | ||
| 18 | + | ||
| 19 | + @Schema(description = "父部门名称", example = "蓟城山水集团") | ||
| 20 | + private String parentName; | ||
| 21 | + | ||
| 22 | + @Schema(description = "班组长人员ID", example = "1001") | ||
| 23 | + private Long leaderUserId; | ||
| 24 | + | ||
| 25 | + @Schema(description = "班组长姓名", example = "张三") | ||
| 26 | + private String leaderUserName; | ||
| 27 | + | ||
| 28 | + @Schema(description = "人员数量", example = "15") | ||
| 29 | + private Long memberCount; | ||
| 30 | + | ||
| 31 | + @Schema(description = "排序", example = "1") | ||
| 32 | + private Integer sort; | ||
| 33 | + | ||
| 34 | + @Schema(description = "状态(0=启用,1=禁用)", example = "0") | ||
| 35 | + private Integer status; | ||
| 36 | +} |
urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/enums/ErrorCodeConstants.java
| @@ -145,4 +145,11 @@ public interface ErrorCodeConstants { | @@ -145,4 +145,11 @@ public interface ErrorCodeConstants { | ||
| 145 | 145 | ||
| 146 | ErrorCode EMERGENCY_TASK_NOT_EXISTS = new ErrorCode(1-100-006-002, "抢险任务主不存在"); | 146 | ErrorCode EMERGENCY_TASK_NOT_EXISTS = new ErrorCode(1-100-006-002, "抢险任务主不存在"); |
| 147 | 147 | ||
| 148 | + // ========== 开放API 1-100-020-xxx ========== | ||
| 149 | + ErrorCode OPEN_API_STAFF_QUERY_ERROR = new ErrorCode(1-100-020-001, "人员查询失败"); | ||
| 150 | + ErrorCode OPEN_API_TEAM_QUERY_ERROR = new ErrorCode(1-100-020-002, "班组查询失败"); | ||
| 151 | + ErrorCode OPEN_API_ATTENDANCE_QUERY_ERROR = new ErrorCode(1-100-020-003, "考勤查询失败"); | ||
| 152 | + ErrorCode OPEN_API_TASK_QUERY_ERROR = new ErrorCode(1-100-020-004, "工单查询失败"); | ||
| 153 | + ErrorCode OPEN_API_MATERIAL_QUERY_ERROR = new ErrorCode(1-100-020-005, "物料查询失败"); | ||
| 154 | + | ||
| 148 | } | 155 | } |
urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/FullOrderStatisticsController.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.bean.BeanUtil; | ||
| 4 | +import cn.hutool.core.bean.BeanUtil; | ||
| 5 | +import com.zteits.urbanops.framework.apilog.core.annotation.ApiAccessLog; | ||
| 6 | +import com.zteits.urbanops.framework.common.pojo.CommonResult; | ||
| 7 | +import com.zteits.urbanops.framework.common.pojo.PageParam; | ||
| 8 | +import com.zteits.urbanops.framework.common.pojo.PageResult; | ||
| 9 | +import com.zteits.urbanops.framework.common.util.object.BeanUtils; | ||
| 10 | +import com.zteits.urbanops.framework.excel.core.util.ExcelUtils; | ||
| 11 | +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsPageReqVO; | ||
| 12 | +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsRespVO; | ||
| 13 | +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsSaveReqVO; | ||
| 14 | +import com.zteits.urbanops.module.workorder.dal.dataobject.fullorderstatistics.FullOrderStatisticsDO; | ||
| 15 | +import com.zteits.urbanops.module.workorder.service.fullorderstatistics.FullOrderStatisticsService; | ||
| 16 | +import io.swagger.v3.oas.annotations.Operation; | ||
| 17 | +import io.swagger.v3.oas.annotations.tags.Tag; | ||
| 18 | +import jakarta.annotation.Resource; | ||
| 19 | +import jakarta.servlet.http.HttpServletResponse; | ||
| 20 | +import jakarta.validation.Valid; | ||
| 21 | +import org.springframework.security.access.prepost.PreAuthorize; | ||
| 22 | +import org.springframework.validation.annotation.Validated; | ||
| 23 | +import org.springframework.web.bind.annotation.*; | ||
| 24 | + | ||
| 25 | +import java.io.IOException; | ||
| 26 | +import java.util.List; | ||
| 27 | + | ||
| 28 | +import static com.zteits.urbanops.framework.apilog.core.enums.OperateTypeEnum.EXPORT; | ||
| 29 | +import static com.zteits.urbanops.framework.common.pojo.CommonResult.success; | ||
| 30 | + | ||
| 31 | +@Tag(name = "管理后台 - 全量工单统计") | ||
| 32 | +@RestController | ||
| 33 | +@RequestMapping("/workorder/full-order-statistics") | ||
| 34 | +@Validated | ||
| 35 | +public class FullOrderStatisticsController { | ||
| 36 | + | ||
| 37 | + @Resource | ||
| 38 | + private FullOrderStatisticsService service; | ||
| 39 | + | ||
| 40 | + @PostMapping("/create") | ||
| 41 | + @Operation(summary = "创建全量工单统计") | ||
| 42 | + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:create')") | ||
| 43 | + public CommonResult<Long> createFullOrderStatistics(@Valid @RequestBody FullOrderStatisticsSaveReqVO createReqVO) { | ||
| 44 | + return success(service.createFullOrderStatistics(createReqVO)); | ||
| 45 | + } | ||
| 46 | + | ||
| 47 | + @PutMapping("/update") | ||
| 48 | + @Operation(summary = "更新全量工单统计") | ||
| 49 | + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:update')") | ||
| 50 | + public CommonResult<Boolean> updateFullOrderStatistics(@Valid @RequestBody FullOrderStatisticsSaveReqVO updateReqVO) { | ||
| 51 | + service.updateFullOrderStatistics(updateReqVO); | ||
| 52 | + return success(true); | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + @DeleteMapping("/delete") | ||
| 56 | + @Operation(summary = "删除全量工单统计") | ||
| 57 | + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:delete')") | ||
| 58 | + public CommonResult<Boolean> deleteFullOrderStatistics(@RequestParam("id") Long id) { | ||
| 59 | + service.deleteFullOrderStatistics(id); | ||
| 60 | + return success(true); | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + @DeleteMapping("/delete-list") | ||
| 64 | + @Operation(summary = "批量删除全量工单统计") | ||
| 65 | + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:delete')") | ||
| 66 | + public CommonResult<Boolean> deleteFullOrderStatisticsList(@RequestParam("ids") List<Long> ids) { | ||
| 67 | + service.deleteFullOrderStatisticsListByIds(ids); | ||
| 68 | + return success(true); | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + @GetMapping("/get") | ||
| 72 | + @Operation(summary = "获得全量工单统计") | ||
| 73 | + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:query')") | ||
| 74 | + public CommonResult<FullOrderStatisticsRespVO> getFullOrderStatistics(@RequestParam("id") Long id) { | ||
| 75 | + FullOrderStatisticsDO entity = service.getFullOrderStatistics(id); | ||
| 76 | + return success(BeanUtils.toBean(entity, FullOrderStatisticsRespVO.class)); | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + @GetMapping("/page") | ||
| 80 | + @Operation(summary = "获得全量工单统计分页") | ||
| 81 | + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:query')") | ||
| 82 | + public CommonResult<PageResult<FullOrderStatisticsRespVO>> getFullOrderStatisticsPage( | ||
| 83 | + @Valid FullOrderStatisticsPageReqVO pageReqVO) { | ||
| 84 | + PageResult<FullOrderStatisticsDO> pageResult = service.getFullOrderStatisticsPage(pageReqVO); | ||
| 85 | + return success(BeanUtils.toBean(pageResult, FullOrderStatisticsRespVO.class)); | ||
| 86 | + } | ||
| 87 | + | ||
| 88 | + @GetMapping("/list-all") | ||
| 89 | + @Operation(summary = "获得全量工单统计列表(不分页)") | ||
| 90 | + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:query')") | ||
| 91 | + public CommonResult<List<FullOrderStatisticsRespVO>> getFullOrderStatisticsList( | ||
| 92 | + @Valid FullOrderStatisticsPageReqVO pageReqVO) { | ||
| 93 | + pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); | ||
| 94 | + List<FullOrderStatisticsDO> list = service.getFullOrderStatisticsPage(pageReqVO).getList(); | ||
| 95 | + return success(BeanUtils.toBean(list, FullOrderStatisticsRespVO.class)); | ||
| 96 | + } | ||
| 97 | + | ||
| 98 | + @GetMapping("/export-excel") | ||
| 99 | + @Operation(summary = "导出全量工单统计 Excel") | ||
| 100 | + @PreAuthorize("@ss.hasPermission('workorder:full-order-statistics:export')") | ||
| 101 | + @ApiAccessLog(operateType = EXPORT) | ||
| 102 | + public void exportFullOrderStatistics(@Valid FullOrderStatisticsPageReqVO pageReqVO, | ||
| 103 | + HttpServletResponse response) throws IOException { | ||
| 104 | + pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE); | ||
| 105 | + List<FullOrderStatisticsDO> list = service.getFullOrderStatisticsPage(pageReqVO).getList(); | ||
| 106 | + ExcelUtils.write(response, "全量工单统计.xls", "数据", FullOrderStatisticsRespVO.class, | ||
| 107 | + BeanUtils.toBean(list, FullOrderStatisticsRespVO.class)); | ||
| 108 | + } | ||
| 109 | +} |
urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsPageReqVO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo; | ||
| 2 | + | ||
| 3 | +import com.zteits.urbanops.framework.common.pojo.PageParam; | ||
| 4 | +import io.swagger.v3.oas.annotations.media.Schema; | ||
| 5 | +import lombok.Data; | ||
| 6 | +import org.springframework.format.annotation.DateTimeFormat; | ||
| 7 | + | ||
| 8 | +import java.time.LocalDate; | ||
| 9 | +import java.time.LocalDateTime; | ||
| 10 | + | ||
| 11 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY; | ||
| 12 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; | ||
| 13 | + | ||
| 14 | +@Schema(description = "管理后台 - 全量工单统计分页 Request VO") | ||
| 15 | +@Data | ||
| 16 | +public class FullOrderStatisticsPageReqVO extends PageParam { | ||
| 17 | + | ||
| 18 | + @Schema(description = "公司/班组ID", example = "100") | ||
| 19 | + private Long companyId; | ||
| 20 | + | ||
| 21 | + @Schema(description = "公司/班组名称(模糊匹配)", example = "养护一班") | ||
| 22 | + private String companyName; | ||
| 23 | + | ||
| 24 | + @Schema(description = "类型: 1=按公司, 2=按班组", example = "1") | ||
| 25 | + private Integer type; | ||
| 26 | + | ||
| 27 | + @Schema(description = "统计开始日期") | ||
| 28 | + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) | ||
| 29 | + private LocalDate[] startDate; | ||
| 30 | + | ||
| 31 | + @Schema(description = "统计结束日期") | ||
| 32 | + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) | ||
| 33 | + private LocalDate[] endDate; | ||
| 34 | + | ||
| 35 | + @Schema(description = "创建时间") | ||
| 36 | + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) | ||
| 37 | + private LocalDateTime[] createTime; | ||
| 38 | +} |
urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsRespVO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo; | ||
| 2 | + | ||
| 3 | +import cn.idev.excel.annotation.ExcelIgnoreUnannotated; | ||
| 4 | +import cn.idev.excel.annotation.ExcelProperty; | ||
| 5 | +import com.fasterxml.jackson.annotation.JsonFormat; | ||
| 6 | +import io.swagger.v3.oas.annotations.media.Schema; | ||
| 7 | +import lombok.Data; | ||
| 8 | + | ||
| 9 | +import java.time.LocalDate; | ||
| 10 | +import java.time.LocalDateTime; | ||
| 11 | + | ||
| 12 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY; | ||
| 13 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; | ||
| 14 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; | ||
| 15 | + | ||
| 16 | +@Schema(description = "管理后台 - 全量工单统计 Response VO") | ||
| 17 | +@Data | ||
| 18 | +@ExcelIgnoreUnannotated | ||
| 19 | +public class FullOrderStatisticsRespVO { | ||
| 20 | + | ||
| 21 | + @ExcelProperty("ID") | ||
| 22 | + private Long id; | ||
| 23 | + | ||
| 24 | + @ExcelProperty("公司/班组ID") | ||
| 25 | + private Long companyId; | ||
| 26 | + | ||
| 27 | + @ExcelProperty("公司/班组名称") | ||
| 28 | + private String companyName; | ||
| 29 | + | ||
| 30 | + @ExcelProperty("类型") | ||
| 31 | + private Integer type; | ||
| 32 | + | ||
| 33 | + @ExcelProperty("统计开始日期") | ||
| 34 | + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY, timezone = TIME_ZONE_DEFAULT) | ||
| 35 | + private LocalDate startDate; | ||
| 36 | + | ||
| 37 | + @ExcelProperty("统计结束日期") | ||
| 38 | + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY, timezone = TIME_ZONE_DEFAULT) | ||
| 39 | + private LocalDate endDate; | ||
| 40 | + | ||
| 41 | + @ExcelProperty("工单完成数量") | ||
| 42 | + private Integer workOrderCount; | ||
| 43 | + | ||
| 44 | + @ExcelProperty("巡查记录数量") | ||
| 45 | + private Integer inspectionCount; | ||
| 46 | + | ||
| 47 | + @ExcelProperty("养护记录数量") | ||
| 48 | + private Integer maintenanceCount; | ||
| 49 | + | ||
| 50 | + @ExcelProperty("备注") | ||
| 51 | + private String remark; | ||
| 52 | + | ||
| 53 | + @ExcelProperty("创建时间") | ||
| 54 | + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) | ||
| 55 | + private LocalDateTime createTime; | ||
| 56 | +} |
urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/controller/admin/fullorderstatistics/vo/FullOrderStatisticsSaveReqVO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo; | ||
| 2 | + | ||
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | ||
| 4 | +import jakarta.validation.constraints.NotEmpty; | ||
| 5 | +import jakarta.validation.constraints.NotNull; | ||
| 6 | +import lombok.Data; | ||
| 7 | +import org.springframework.format.annotation.DateTimeFormat; | ||
| 8 | + | ||
| 9 | +import java.time.LocalDate; | ||
| 10 | + | ||
| 11 | +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY; | ||
| 12 | + | ||
| 13 | +@Schema(description = "管理后台 - 全量工单统计新增/修改 Request VO") | ||
| 14 | +@Data | ||
| 15 | +public class FullOrderStatisticsSaveReqVO { | ||
| 16 | + | ||
| 17 | + @Schema(description = "主键ID(修改时必填)", example = "1") | ||
| 18 | + private Long id; | ||
| 19 | + | ||
| 20 | + @Schema(description = "公司/班组ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") | ||
| 21 | + @NotNull(message = "公司/班组ID不能为空") | ||
| 22 | + private Long companyId; | ||
| 23 | + | ||
| 24 | + @Schema(description = "公司/班组名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "养护一班") | ||
| 25 | + @NotEmpty(message = "公司/班组名称不能为空") | ||
| 26 | + private String companyName; | ||
| 27 | + | ||
| 28 | + @Schema(description = "类型: 1=按公司, 2=按班组", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") | ||
| 29 | + @NotNull(message = "类型不能为空") | ||
| 30 | + private Integer type; | ||
| 31 | + | ||
| 32 | + @Schema(description = "统计开始日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-01") | ||
| 33 | + @NotNull(message = "统计开始日期不能为空") | ||
| 34 | + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) | ||
| 35 | + private LocalDate startDate; | ||
| 36 | + | ||
| 37 | + @Schema(description = "统计结束日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-30") | ||
| 38 | + @NotNull(message = "统计结束日期不能为空") | ||
| 39 | + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) | ||
| 40 | + private LocalDate endDate; | ||
| 41 | + | ||
| 42 | + @Schema(description = "工单完成数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") | ||
| 43 | + @NotNull(message = "工单完成数量不能为空") | ||
| 44 | + private Integer workOrderCount; | ||
| 45 | + | ||
| 46 | + @Schema(description = "巡查记录数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "50") | ||
| 47 | + @NotNull(message = "巡查记录数量不能为空") | ||
| 48 | + private Integer inspectionCount; | ||
| 49 | + | ||
| 50 | + @Schema(description = "养护记录数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "30") | ||
| 51 | + @NotNull(message = "养护记录数量不能为空") | ||
| 52 | + private Integer maintenanceCount; | ||
| 53 | + | ||
| 54 | + @Schema(description = "备注", example = "二季度统计") | ||
| 55 | + private String remark; | ||
| 56 | +} |
urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/dal/dataobject/fullorderstatistics/FullOrderStatisticsDO.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.workorder.dal.dataobject.fullorderstatistics; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.annotation.KeySequence; | ||
| 4 | +import com.baomidou.mybatisplus.annotation.TableId; | ||
| 5 | +import com.baomidou.mybatisplus.annotation.TableName; | ||
| 6 | +import com.zteits.urbanops.framework.mybatis.core.dataobject.BaseDO; | ||
| 7 | +import lombok.*; | ||
| 8 | + | ||
| 9 | +import java.time.LocalDate; | ||
| 10 | + | ||
| 11 | +@TableName("workorder_full_order_statistics") | ||
| 12 | +@KeySequence("workorder_full_order_statistics_seq") | ||
| 13 | +@Data | ||
| 14 | +@EqualsAndHashCode(callSuper = true) | ||
| 15 | +@ToString(callSuper = true) | ||
| 16 | +@Builder | ||
| 17 | +@NoArgsConstructor | ||
| 18 | +@AllArgsConstructor | ||
| 19 | +public class FullOrderStatisticsDO extends BaseDO { | ||
| 20 | + | ||
| 21 | + @TableId | ||
| 22 | + private Long id; | ||
| 23 | + | ||
| 24 | + private Long companyId; | ||
| 25 | + private String companyName; | ||
| 26 | + private Integer type; | ||
| 27 | + private LocalDate startDate; | ||
| 28 | + private LocalDate endDate; | ||
| 29 | + private Integer workOrderCount; | ||
| 30 | + private Integer inspectionCount; | ||
| 31 | + private Integer maintenanceCount; | ||
| 32 | + private String remark; | ||
| 33 | +} |
urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/dal/mysql/fullorderstatistics/FullOrderStatisticsMapper.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.workorder.dal.mysql.fullorderstatistics; | ||
| 2 | + | ||
| 3 | +import com.zteits.urbanops.framework.common.pojo.PageResult; | ||
| 4 | +import com.zteits.urbanops.framework.mybatis.core.mapper.BaseMapperX; | ||
| 5 | +import com.zteits.urbanops.framework.mybatis.core.query.LambdaQueryWrapperX; | ||
| 6 | +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsPageReqVO; | ||
| 7 | +import com.zteits.urbanops.module.workorder.dal.dataobject.fullorderstatistics.FullOrderStatisticsDO; | ||
| 8 | +import org.apache.ibatis.annotations.Mapper; | ||
| 9 | + | ||
| 10 | +@Mapper | ||
| 11 | +public interface FullOrderStatisticsMapper extends BaseMapperX<FullOrderStatisticsDO> { | ||
| 12 | + | ||
| 13 | + default PageResult<FullOrderStatisticsDO> selectPage(FullOrderStatisticsPageReqVO reqVO) { | ||
| 14 | + return selectPage(reqVO, new LambdaQueryWrapperX<FullOrderStatisticsDO>() | ||
| 15 | + .eqIfPresent(FullOrderStatisticsDO::getCompanyId, reqVO.getCompanyId()) | ||
| 16 | + .likeIfPresent(FullOrderStatisticsDO::getCompanyName, reqVO.getCompanyName()) | ||
| 17 | + .eqIfPresent(FullOrderStatisticsDO::getType, reqVO.getType()) | ||
| 18 | + .betweenIfPresent(FullOrderStatisticsDO::getStartDate, reqVO.getStartDate()) | ||
| 19 | + .betweenIfPresent(FullOrderStatisticsDO::getEndDate, reqVO.getEndDate()) | ||
| 20 | + .betweenIfPresent(FullOrderStatisticsDO::getCreateTime, reqVO.getCreateTime()) | ||
| 21 | + .orderByDesc(FullOrderStatisticsDO::getId)); | ||
| 22 | + } | ||
| 23 | +} |
urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/enums/ErrorCodeConstants.java
| @@ -59,5 +59,9 @@ public interface ErrorCodeConstants { | @@ -59,5 +59,9 @@ public interface ErrorCodeConstants { | ||
| 59 | ErrorCode APP_WORK_AI_ROLE_ILLEGLE = new ErrorCode(1-900-004-005, "该登录人即是 大区经理 角色,又是 AI工单派发人员,无法发起流程!"); | 59 | ErrorCode APP_WORK_AI_ROLE_ILLEGLE = new ErrorCode(1-900-004-005, "该登录人即是 大区经理 角色,又是 AI工单派发人员,无法发起流程!"); |
| 60 | 60 | ||
| 61 | ErrorCode SANHAI_COMPANY_NOT_EXISTS = new ErrorCode(1-900-004-006, "三海道路归属部门的id,字典项不存在!"); | 61 | ErrorCode SANHAI_COMPANY_NOT_EXISTS = new ErrorCode(1-900-004-006, "三海道路归属部门的id,字典项不存在!"); |
| 62 | + | ||
| 63 | + // ========== 全量工单统计 1-900-005-xxx ========== | ||
| 64 | + ErrorCode FULL_ORDER_STATISTICS_NOT_EXISTS = new ErrorCode(1-900-005-001, "全量工单统计不存在"); | ||
| 65 | + ErrorCode FULL_ORDER_STATISTICS_DUPLICATE = new ErrorCode(1-900-005-002, "相同公司/班组同时间段统计已存在"); | ||
| 62 | } | 66 | } |
| 63 | 67 |
urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/service/fullorderstatistics/FullOrderStatisticsService.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.workorder.service.fullorderstatistics; | ||
| 2 | + | ||
| 3 | +import com.zteits.urbanops.framework.common.pojo.PageResult; | ||
| 4 | +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsPageReqVO; | ||
| 5 | +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsSaveReqVO; | ||
| 6 | +import com.zteits.urbanops.module.workorder.dal.dataobject.fullorderstatistics.FullOrderStatisticsDO; | ||
| 7 | +import jakarta.validation.Valid; | ||
| 8 | + | ||
| 9 | +import java.util.List; | ||
| 10 | + | ||
| 11 | +public interface FullOrderStatisticsService { | ||
| 12 | + | ||
| 13 | + Long createFullOrderStatistics(@Valid FullOrderStatisticsSaveReqVO createReqVO); | ||
| 14 | + | ||
| 15 | + void updateFullOrderStatistics(@Valid FullOrderStatisticsSaveReqVO updateReqVO); | ||
| 16 | + | ||
| 17 | + void deleteFullOrderStatistics(Long id); | ||
| 18 | + | ||
| 19 | + void deleteFullOrderStatisticsListByIds(List<Long> ids); | ||
| 20 | + | ||
| 21 | + FullOrderStatisticsDO getFullOrderStatistics(Long id); | ||
| 22 | + | ||
| 23 | + PageResult<FullOrderStatisticsDO> getFullOrderStatisticsPage(FullOrderStatisticsPageReqVO pageReqVO); | ||
| 24 | +} |
urbanops-module-workorder/src/main/java/com/zteits/urbanops/module/workorder/service/fullorderstatistics/FullOrderStatisticsServiceImpl.java
0 → 100644
| 1 | +package com.zteits.urbanops.module.workorder.service.fullorderstatistics; | ||
| 2 | + | ||
| 3 | +import cn.hutool.core.bean.BeanUtil; | ||
| 4 | +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; | ||
| 5 | +import com.zteits.urbanops.framework.common.pojo.PageResult; | ||
| 6 | +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsPageReqVO; | ||
| 7 | +import com.zteits.urbanops.module.workorder.controller.admin.fullorderstatistics.vo.FullOrderStatisticsSaveReqVO; | ||
| 8 | +import com.zteits.urbanops.module.workorder.dal.dataobject.fullorderstatistics.FullOrderStatisticsDO; | ||
| 9 | +import com.zteits.urbanops.module.workorder.dal.mysql.fullorderstatistics.FullOrderStatisticsMapper; | ||
| 10 | +import jakarta.annotation.Resource; | ||
| 11 | +import lombok.extern.slf4j.Slf4j; | ||
| 12 | +import org.springframework.stereotype.Service; | ||
| 13 | +import org.springframework.transaction.annotation.Transactional; | ||
| 14 | + | ||
| 15 | +import java.time.LocalDate; | ||
| 16 | +import java.util.List; | ||
| 17 | + | ||
| 18 | +import static com.zteits.urbanops.framework.common.exception.util.ServiceExceptionUtil.exception; | ||
| 19 | +import static com.zteits.urbanops.module.workorder.enums.ErrorCodeConstants.FULL_ORDER_STATISTICS_DUPLICATE; | ||
| 20 | +import static com.zteits.urbanops.module.workorder.enums.ErrorCodeConstants.FULL_ORDER_STATISTICS_NOT_EXISTS; | ||
| 21 | + | ||
| 22 | +@Service | ||
| 23 | +@Slf4j | ||
| 24 | +public class FullOrderStatisticsServiceImpl implements FullOrderStatisticsService { | ||
| 25 | + | ||
| 26 | + @Resource | ||
| 27 | + private FullOrderStatisticsMapper mapper; | ||
| 28 | + | ||
| 29 | + @Override | ||
| 30 | + @Transactional(rollbackFor = Exception.class) | ||
| 31 | + public Long createFullOrderStatistics(FullOrderStatisticsSaveReqVO createReqVO) { | ||
| 32 | + validateDuplicate(createReqVO.getCompanyId(), createReqVO.getType(), | ||
| 33 | + createReqVO.getStartDate(), createReqVO.getEndDate(), null); | ||
| 34 | + FullOrderStatisticsDO entity = BeanUtil.toBean(createReqVO, FullOrderStatisticsDO.class); | ||
| 35 | + mapper.insert(entity); | ||
| 36 | + return entity.getId(); | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + @Override | ||
| 40 | + @Transactional(rollbackFor = Exception.class) | ||
| 41 | + public void updateFullOrderStatistics(FullOrderStatisticsSaveReqVO updateReqVO) { | ||
| 42 | + FullOrderStatisticsDO exist = mapper.selectById(updateReqVO.getId()); | ||
| 43 | + if (exist == null) { | ||
| 44 | + throw exception(FULL_ORDER_STATISTICS_NOT_EXISTS); | ||
| 45 | + } | ||
| 46 | + validateDuplicate(updateReqVO.getCompanyId(), updateReqVO.getType(), | ||
| 47 | + updateReqVO.getStartDate(), updateReqVO.getEndDate(), updateReqVO.getId()); | ||
| 48 | + FullOrderStatisticsDO entity = BeanUtil.toBean(updateReqVO, FullOrderStatisticsDO.class); | ||
| 49 | + mapper.updateById(entity); | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + @Override | ||
| 53 | + @Transactional(rollbackFor = Exception.class) | ||
| 54 | + public void deleteFullOrderStatistics(Long id) { | ||
| 55 | + if (mapper.selectById(id) == null) { | ||
| 56 | + throw exception(FULL_ORDER_STATISTICS_NOT_EXISTS); | ||
| 57 | + } | ||
| 58 | + mapper.deleteById(id); | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + @Override | ||
| 62 | + @Transactional(rollbackFor = Exception.class) | ||
| 63 | + public void deleteFullOrderStatisticsListByIds(List<Long> ids) { | ||
| 64 | + mapper.deleteByIds(ids); | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + @Override | ||
| 68 | + public FullOrderStatisticsDO getFullOrderStatistics(Long id) { | ||
| 69 | + FullOrderStatisticsDO entity = mapper.selectById(id); | ||
| 70 | + if (entity == null) { | ||
| 71 | + throw exception(FULL_ORDER_STATISTICS_NOT_EXISTS); | ||
| 72 | + } | ||
| 73 | + return entity; | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + @Override | ||
| 77 | + public PageResult<FullOrderStatisticsDO> getFullOrderStatisticsPage(FullOrderStatisticsPageReqVO pageReqVO) { | ||
| 78 | + return mapper.selectPage(pageReqVO); | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + private void validateDuplicate(Long companyId, Integer type, LocalDate startDate, | ||
| 82 | + LocalDate endDate, Long excludeId) { | ||
| 83 | + LambdaQueryWrapper<FullOrderStatisticsDO> wrapper = new LambdaQueryWrapper<>(); | ||
| 84 | + wrapper.eq(FullOrderStatisticsDO::getCompanyId, companyId) | ||
| 85 | + .eq(FullOrderStatisticsDO::getType, type) | ||
| 86 | + .le(FullOrderStatisticsDO::getStartDate, endDate) | ||
| 87 | + .ge(FullOrderStatisticsDO::getEndDate, startDate); | ||
| 88 | + if (excludeId != null) { | ||
| 89 | + wrapper.ne(FullOrderStatisticsDO::getId, excludeId); | ||
| 90 | + } | ||
| 91 | + if (mapper.selectCount(wrapper) > 0) { | ||
| 92 | + throw exception(FULL_ORDER_STATISTICS_DUPLICATE); | ||
| 93 | + } | ||
| 94 | + } | ||
| 95 | +} |