Commit 116f6bb0f05f4af6dc01650663798aeb2dfadc97

Authored by 王彪总
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 144 <version>${revision}</version>
145 145 <scope>compile</scope>
146 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 161 </dependencies>
148 162  
149 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 145  
146 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 59 ErrorCode APP_WORK_AI_ROLE_ILLEGLE = new ErrorCode(1-900-004-005, "该登录人即是 大区经理 角色,又是 AI工单派发人员,无法发起流程!");
60 60  
61 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 +}
... ...
urbanops-server/src/main/resources/application.yaml
... ... @@ -3,7 +3,7 @@ spring:
3 3 name: urbanops-server
4 4  
5 5 profiles:
6   - active: prod
  6 + active: local
7 7  
8 8  
9 9 main:
... ...