From 70a63b781a80532fbe994946d6dc7b870d7e6144 Mon Sep 17 00:00:00 2001 From: 王彪总 Date: Mon, 29 Jun 2026 12:49:38 +0800 Subject: [PATCH] feat(garden): 添加团队考核查询功能 --- docs/all_test.html | 841 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/ppt-assets/animations.css | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/ppt-assets/base.css | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/ppt-assets/fonts.css | 9 +++++++++ docs/ppt-assets/runtime.js | 960 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/ppt-assets/style.css | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/urbanops-architecture-ppt.html | 858 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/urbanops-tech-sharing.html | 771 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/开放API对接接口文档.html | 1170 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/开放API对接接口文档.md | 458 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------- docs/开放API对接接口文档.pdf | Bin 1491868 -> 0 bytes urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/assessment/TeamAssessmentOpenController.java | 38 ++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/assessment/vo/TeamAssessmentReqVO.java | 32 ++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/assessment/vo/TeamAssessmentRespVO.java | 375 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiService.java | 4 ++++ urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiServiceImpl.java | 573 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 16 files changed, 6392 insertions(+), 34 deletions(-) create mode 100644 docs/all_test.html create mode 100644 docs/ppt-assets/animations.css create mode 100644 docs/ppt-assets/base.css create mode 100644 docs/ppt-assets/fonts.css create mode 100644 docs/ppt-assets/runtime.js create mode 100644 docs/ppt-assets/style.css create mode 100644 docs/urbanops-architecture-ppt.html create mode 100644 docs/urbanops-tech-sharing.html create mode 100644 docs/开放API对接接口文档.html create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/assessment/TeamAssessmentOpenController.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/assessment/vo/TeamAssessmentReqVO.java create mode 100644 urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/assessment/vo/TeamAssessmentRespVO.java diff --git a/docs/all_test.html b/docs/all_test.html new file mode 100644 index 0000000..a411df5 --- /dev/null +++ b/docs/all_test.html @@ -0,0 +1,841 @@ + + + + +UrbanOps 全域平台 · 技术架构全景分享 + + + + + + + +
+ + +
+

urbanops · tech-sharing

+

城市运营全域平台
技术架构全景解析

+

业务架构 × 技术架构 × 系统架构 × 部署架构 — 一次讲透全域平台。

+
架构组platform infra · 60 min + Q&A
+ +
+ + +
+

agenda.yaml

+

今日路线图

+
+
01项目概述与业务架构全景~6min
+
02业务布局:园林 · 工单 · BPM · 全局~10min
+
03系统架构总览 & 模块依赖关系~6min
+
04技术架构分层 & 基础设施层~6min
+
05核心技术栈 & 数据存储策略~6min
+
06数据流 & 模块调用逻辑~6min
+
07安全架构 & 可观测性~6min
+
08部署架构 & 后续演进规划~6min
+
09总结 & Q&A~5min
+
+
+ + +
+

// overview

+

UrbanOps 全域平台:
智慧城市运营管理底座

+

基于 RuoYi-Vue-Pro 演进而来,面向城市园林绿化、工单调度、流程协同的综合运营管理平台。模块化单体架构向微服务演进中。

+
+
17
Maven 模块
+
7
激活业务模块
+
15
框架 Starter
+
10+
数据库支持
+
3.5.5
Spring Boot
+
+
+
+

后端技术基石

+

Java 17 · Spring Boot 3.5.5 · MyBatis Plus 3.5.14 · Spring Security 6.x · Flowable 7.0 · Redisson 3.51 · SkyWalking 9.5 · RocketMQ · Vert.x + MQTT · PF4J 插件框架

+
+
+

前端生态 & 核心能力

+

Vue3 + Element Plus / Vben 双管理后台 · uni-app 移动端 · 大屏报表设计器 · 多租户 SaaS · IoT 设备接入 · 代码生成器 · Spring AI 集成

+
+
+
+ + +
+

business · architecture

+

业务架构全景:
四大核心域 + 两大支撑域

+
+
+
🌳

智慧园林管理

+

树木资产管理、巡检计划、养护计划、绩效评估、物料出入库、机械成本核算

+
Garden巡检养护绩效
+
+
+
📋

工单调度系统

+

事件上报、工单流转、物料明细、附件管理、地理位置编码、园林事件映射

+
Workorder事件物料
+
+
+
🔄

工作流引擎

+

Flowable 驱动,仿钉钉/飞书 + BPMN 双设计器,会签、或签、转办、委派、加签全覆盖

+
BPMFlowable审批
+
+
+
🌐

全局业务管理

+

问题上报、道路信息、工单提交详情、流程路径想法、跨模块数据聚合

+
Global道路问题
+
+
+
⚙️

系统管理

+

用户/角色/菜单/部门/岗位管理、租户与套餐、字典、通知公告、应用管理

+
SystemRBAC多租户
+
+
+
🏗️

基础设施

+

代码生成、文件存储、定时任务、配置管理、消息队列、API 日志、监控追踪

+
Infra代码生成监控
+
+
+
+ + +
+

garden · smart-greening

+

核心业务:智慧园林管理

+

覆盖园林绿化全生命周期——从资产登记到巡检养护,从绩效评估到成本核算。

+
+
管理端
+
+
🌲

树木资产管理

古树名木 / 变更日志

+
📅

巡检计划管理

计划制定 / 明细配置

+
🔧

养护计划管理

养护任务 / 执行跟踪

+
📊

绩效管理

指标定义 / 专家评定

+
📦

物料管理

物料类型 / 出入库

+
💰

机械成本

折旧 / 租赁核算

+
+
+
移动端
+
+
📱

首页概览

数据汇总

+
📍

GPS 定位

位置上报

+

巡检提交

现场打卡

+
🛠️

养护提交

任务执行

+
📝

变更记录

树木变更

+
+
+
+
+

定时任务

自动生成巡检/养护计划

+

数据权限

按区域隔离园林数据

+

时间区间

灵活配置任务执行周期

+

防重复提交

计划明细幂等性保证

+
+
+ + +
+

workorder · dispatch

+

工单调度:事件驱动 + 流程闭环

+
+
+
+

工单核心实体模型

+
+
📋 MainInfo — 工单主信息
+
📎 Attachment — 附件管理
+
📦 MaterialDetail — 物料明细
+
🎯 EventInfo — 事件信息
+
🔗 EventMappingInfo — 事件映射
+
🌳 GardenRef — 园林业务关联
+
+
+
+
+
+

工单处理流程

+
+
+
📝

事件上报

+ +
🔍

工单生成

+ +
👤

人员指派

+
+
+
🔧

现场处理

+ +
📋

结果反馈

+ +

审核结单

+
+
+
+
+
+
WorkorderModule → GardenModule (Feign API) · EventMapping 策略 · GeoCode 位置服务 · 数据权限隔离
+
+ + +
+

bpm · flowable-engine

+

工作流引擎:同时支持
「轻量配置」与「深度编排」

+
+
+
+

SIMPLE 设计器

+

仿钉钉/飞书体验,拖拽搭建表单流程,10 分钟完成配置

+
+
✦ 拖拽式表单设计
+
✦ 审批节点可视化配置
+
✦ 抄送人 / 条件分支
+
✦ 超时审批 / 自动提醒
+
+
+
+
+
+

BPMN 设计器

+

基于 BPMN 2.0 标准,适配复杂多层级审批及自动化场景

+
+
✦ 会签 / 或签 / 依次审批
+
✦ 转办 / 委派 / 加签 / 减签
+
✦ 并行 / 包容 / 路由分支
+
✦ 父子流程 / 触发节点
+
+
+
+
+
+

Flowable 7.0

核心流程引擎

+

信创数据库兼容

DM8 / KingBase / OpenGauss

+

动态表单

节点级只读/编辑/隐藏权限

+
+
+ + +
+

global · system · infra

+

业务支撑域:
公共服务与系统管理

+
+
+

🌐 Global 全局模块

+
+

问题上报管理

公共问题发现 → 流转到工单系统

+

道路信息管理

道路基础数据维护

+

工单提交详情

跨模块工单数据聚合视图

+

流程路径想法

流程优化建议收集

+
+
+
+

⚙️ System + Infra 模块

+
+

RBAC 权限体系

用户 → 角色 → 菜单 → 按钮权限 · 数据权限

+

SaaS 多租户

租户管理 · 套餐配置 · 数据隔离 · 透明化封装

+

代码生成器

一键生成 Java · Vue · SQL · 接口文档

+

消息通知中心

短信 · 邮件 · 站内信 · 多通道

+
+
+
+
+ + +
+

tech-stack · layers

+

技术栈七层全景

+
+
前端
Vue 3Element PlusVben Adminuni-appTypeScript
+
网关 & 接入
Vert.xMQTTHTTP/2TCPWebSocket
+
业务框架
Spring Boot 3.5Spring SecurityFlowable 7QuartzSpring AIPF4J
+
数据层
MyBatis PlusDruidMySQL 8Redis SentinelRedissonDynamic DS
+
消息 & 集成
RocketMQKafkaRabbitMQJustAuthUAA SSOS3
+
可观测性
SkyWalkingSBA AdminOpenTracingActuatorKnife4j
+
部署 & DevOps
Dockerdocker-composeEclipse Temurin 21MavenGit
+
+
+ + +
+

architecture · system-overview

+

系统架构总览 — 五层全景

+
+ + + + + + + + + 接入层 CLIENT LAYER + Vue 3 Web + uni-app 小程序 + IoT 设备 + 第三方系统 + + + + 网关与接入 GATEWAY LAYER + UrbanOps Server :48081 + IoT 网关 :8092 (独立部署) + UAA SSO :28201 + + + + 业务模块层 BUSINESS MODULES + system用户·权限·租户 + garden园林养护·巡检 + workorder工单·调度 + bpmFlowable 流程 + gloabl全域运营 + API 契约层: garden-api · workorder-api (接口定义 + Feign 风格调用) + + + + 基础设施层 INFRASTRUCTURE: 15个Spring Boot Starter (Web · Security · MyBatis · Redis · MQ · Job · Excel · WebSocket · Monitor · Protection · Tenant · DataPermission · IP · Test) + + + + 数据层: MySQL 8 · Redis Sentinel · RocketMQ · Kafka · RabbitMQ · S3 · Druid · Redisson + +
+
+ + +
+

modules · dependency-graph

+

模块依赖关系图

+
+ + + + + + urbanops-dependencies (BOM) + + + + urbanops-framework + common · web · security · mybatis · redis · mq · job · excel · websocket · monitor · protection · tenant · data-permission · ip · test + + + + + + module-infra + module-system + module-gloabl + + + + + + module-bpm + module-garden + module-workorder + module-api (契约层) + + + + + + + IoT 网关 (独立进程 :8092) + + + 核心业务域 + 基础服务 + 独立部署 + API 契约调用 + +
+
+ + +
+

architecture · layers

+

技术架构五层模型

+

从用户端到底层基础设施,清晰分层、逐级解耦。

+
+
接入层
+
+
🖥️

Web Admin

Vue3 + Element Plus

+
📱

Mobile App

uni-app 跨端

+
📊

大屏展示

GoView 可视化

+
🔗

开放 API

第三方对接

+
+
+
网关层
+
+
🔐

认证鉴权

Spring Security + Token

+
🚪

路由转发

Nginx / Gateway

+
⚖️

负载均衡

多实例分发

+
🛡️

安全防护

XSS / CSRF / SQL注入

+
+
+
服务层
+
+
🌳

Garden

园林管理

+
📋

Workorder

工单调度

+
🔄

BPM

工作流

+
🌐

Global

全局服务

+
⚙️

System

系统管理

+
🏗️

Infra

基础设施

+
+
+
中间件
+
+
🗄️

MySQL

OLTP

+

Redis

缓存/锁

+
📨

RocketMQ

消息队列

+
🔍

Elasticsearch

全文检索

+
📡

TDengine

时序数据

+
+
+
+
+ + +
+

backend · layered

+

后端分层架构:
Controller → Service → DAL 三层解耦

+
+
+

controller/

接收请求、参数校验、VO 转换

admin/app/VO 对象@Valid
+

service/

业务逻辑编排、事务管理、策略模式

接口+实现@Transactional策略Listener
+

dal/

数据访问层:MySQL Mapper + Redis Cache

mysql/redis/dataobject/
+
+
+

convert/

MapStruct 对象转换层,编译期生成高性能映射代码

+

api/

模块间 Feign 接口定义 + DTO,实现跨模块调用

+

mq/ + job/

异步消息(producer/consumer)+ 定时任务调度

+

framework/

模块内框架代码:数据权限配置、Web 配置、操作日志等

+
+
+
+ + +
+

framework · starters

+

基础设施层:
15 个 Spring Boot Starter

+

将通用能力抽象为可复用的 Starter 组件,所有业务模块按需引入。

+
+
🌐

web

全局异常处理 · 统一返回 · CORS · Swagger

+
🔐

security

Spring Security · Token 鉴权 · SSO

+
🗄️

mybatis

MyBatis Plus · 多数据源 · 联表查询

+

redis

Redisson · 分布式锁 · 缓存策略

+
📨

mq

RocketMQ 集成 · 消息幂等 · 重试

+
🛡️

protection

分布式锁 · 幂等 · 限流 · 熔断

+
📊

monitor

SkyWalking · Admin · 日志中心

+

job

Quartz 定时任务 · 执行日志

+
📝

excel

FastExcel 导入导出

+
🔌

websocket

实时通信 · Token 校验 · 集群

+
🏢

biz-tenant

多租户数据隔离 · 透明封装

+
🔒

biz-data-permission

数据权限 · 行级过滤

+
+
+ + +
+

tech-stack · ecosystem

+

核心技术栈全景

+
+

核心框架

Spring Boot 3.5.5Spring MVC 6.xSpring Security 6.x
+
🗄️

持久层

MyBatis Plus 3.5.14Druid 1.2.27Dynamic-DS 4.3.1
+

缓存 & 锁

Redis 7.xRedisson 3.51Lock4j 2.2.7
+
📨

消息队列

RocketMQSpring-RocketMQRedis Stream
+
🔄

工作流

Flowable 7.0.1BPMN 2.0SIMPLE 设计器
+
📡

IoT & 通信

Vert.x 4.5.13Netty 4.2.4MQTT 1.2.5
+
🔍

可观测性

SkyWalking 9.5Admin 3.5.2OpenTracing
+
📋

API 文档

Springdoc 2.8.11Knife4j 4.5.0Swagger 3
+
🔌

插件 & 工具

PF4J 0.9.0MapStruct 1.6.3Hutool 5.8+6.0
+
🧪

测试

JUnit 5Mockito 5.xJedis-Mock
+
🗄️

多数据库

MySQL 8.xDM8KingBaseOpenGauss
+
🐳

部署运维

DockerK8sJenkinsHarbor
+
+
+ + +
+

data-flow · communication

+

数据流与通信架构

+
+ + + + + + + + UrbanOps Server核心业务处理引擎 + + MySQL 8主从 + Druid 连接池 + + + Redis Sentinel缓存 · 分布式锁 · Token + + + MQ 消息队列RocketMQ · Kafka · RabbitMQ + + + IoT 网关 :8092Vert.x · MQTT · TCP + + + IoT 设备层传感器 · GPS · 控制器 + + + + 对象存储 S3文件 · 图片 · 附件 + + + Flowable 7工作流引擎 + + + Quartz 调度定时任务集群 + + + WebSocket实时消息推送 + + +
+
+ + +
+

database · strategy

+

数据存储策略:
多数据源 + 多数据库 + 读写分离

+
+
+

Dynamic-Datasource 多数据源

+
一主多从读写分离
@DS 注解切换数据源
Druid 连接池监控
+

Redis 缓存体系

+
○ Redisson 分布式锁 · 幂等键
○ Redis Stream 轻量 MQ
○ Token 存储 & 在线用户管理
+
+
+

多数据库兼容矩阵

+
MySQL 5.7/8.0+ — 主力库
达梦 DM8 — 国产信创
人大金仓 KingBase 8.6 — 信创
OpenGauss 5.1 — 华为开源
Oracle / PostgreSQL / SQL Server
TDengine 3.7 — IoT 时序数据
+

数据隔离策略

SaaS 多租户:共享数据表 + tenant_id 字段隔离,biz-tenant Starter 透明拦截 SQL

+
+
+
+ + +
+

invocation · communication

+

模块调用逻辑:三种通信通道

+
+
Feign API
System → Dept APISystem → User APIWorkorder → Garden APIGarden → System API
+
▼ 同步调用 · 模块间 RPC · 请求/响应模式 ▼
+
MQ 消息
SMS Producer → ConsumerMail Producer → ConsumerEvent → 多消费者RocketMQ TopicRedis Stream
+
▼ 异步解耦 · 事件驱动 · 最终一致性 ▼
+
直接依赖
Server → All ModulesWorkorder → Garden ServiceBPM → System UserGarden → Infra File
+
+
+

api/ 包设计

每个模块暴露 Feign 接口 + DTO,其他模块引入依赖即可调用,编译期类型安全。

+

mq/ 包设计

Producer 发送消息,Consumer 消费处理。SMS / Mail 异步发送,不解耦主流程。

+

service/ 直接注入

同模块内 Service 互相注入。跨模块通过 api 包或直接依赖。

+
+
+ + +
+

async · event-driven

+

异步消息与事件驱动

+
+

RocketMQ 消息架构

+
📤 Producer → Topic
📥 Consumer 拉取并处理
🔄 消费失败自动重试
⚡ 顺序消息: 全局/分区有序
🔒 事务消息: 分布式事务最终一致性
+

典型异步场景

+
📧 SMS → MQ → 消费者推送
📨 邮件 → MQ → 消费者投递
🔔 站内信 → Redis Stream 广播
📋 操作日志 → 异步记录
🔄 工单事件 → 通知园林模块
+
+
+

定时任务 (Quartz)

巡检计划自动生成 · 养护计划定时创建 · 数据统计报表 · 超时任务自动处理

+

服务保障 (Protection)

@Lock4j 分布式锁防并发 · @Idempotent 幂等性 · @RateLimiter 限流保护 · 熔断降级

+
+
+ + +
+

security · auth-flow

+

安全与认证架构

+
+ + 客户端Web / 小程序 / App + + Security Filter Chain + API 签名校验 + 限流 & 幂等 + Token 认证 + 权限校验 + 数据脱敏 + 操作日志 + + + 认证与授权中心 + JWT TokenRedis 存储 · 自动续期 + UAA SSOOAuth2 · 单点登录 + JustAuth 社交登录钉钉 · 企业微信 · 小程序 · 公众号 + 多租户数据隔离 (SaaS) + 部门/用户级数据权限 (RBAC + Data Scope) + 授权通过 + + 业务模块: garden · workorder · bpm · gloabl · infra · system + AJ Captcha滑块验证 + + API 加密AES 算法 + +
+
+ + +
+

security · auth · tenant

+

安全与权限体系详解

+
+

🔐 认证体系

  • Spring Security 6.x 核心
  • Token + Redis 无状态会话
  • OAuth2 授权码模式
  • 多终端(Web / App / 小程序)
  • SSO 单点登录
  • 社交登录(JustAuth)
+

🛡️ 授权体系

  • RBAC:用户 → 角色 → 菜单
  • 按钮级别权限控制
  • 动态菜单加载
  • Redis 缓存权限信息
  • 数据权限:行级过滤
  • 部门数据范围隔离
+

🏢 多租户

  • tenant_id 字段隔离
  • MyBatis 拦截器自动注入
  • 租户套餐权限定制
  • 每个租户独立菜单/角色
  • 透明化封装无侵入
+
+
SystemModule → Security Starter → Token + Redis → @PreAuthorize → DataPermission AOP → SQL Interceptor
+
+ + +
+

observability · monitoring

+

可观测性与监控体系

+
+

📝 日志体系

Logback + SkyWalking Logback Appender,TraceId 自动注入

操作日志登录日志API 访问日志错误日志
+

📊 指标监控

Spring Boot Admin + Druid SQL 监控 + Redis 监控面板

JVM 指标连接池QPS/RT慢 SQL
+

🔍 链路追踪

Apache SkyWalking 9.5 分布式追踪,服务拓扑自动发现

调用链拓扑图慢端点告警
+
+
+

Monitor Starter 集成

SkyWalking · Admin Client · OpenTracing · 日志中心 · 一键接入

+

保护机制

分布式锁 (Lock4j) · 幂等性保证 · 限流降级 · 异常全局处理 · 错误码统一管理

+
+
+ + +
+

deployment · docker

+

部署架构 — Docker 容器化

+
+ + Docker Host + + urbanops-serverEclipse Temurin 21 JRE-Xms512m -Xmx512m + Port 48081 + Actuator /actuator + 包含所有业务模块 (fat JAR) + SpringDoc /v3/api-docs · Knife4j + + iot-gateway-server独立部署 · Vert.x 引擎 + Port 8092 + 协议: MQTT · HTTP · TCP + 设备接入 · 数据采集 + HTTP RPC → Server :48080 + + MySQL 8:3306主从读写分离 + Redis Sentinel:26379高可用集群 + + RocketMQ:9876 + Kafka:9092 + + urbanops-admin (前端 Nginx) :8080 + 可观测性: SkyWalking Agent · Spring Boot Admin · OpenTracing · Actuator · 日志采集 + PF4J 插件目录: ../plugins (动态扩展) + + fat JAR + 独立进程 + 数据服务 + 消息中间件 + +
+
+ + +
+

deployment · ci-cd

+

CI/CD 流水线与运维监控

+
+
🚀

CI 持续集成

  • Jenkins Pipeline
  • Maven 多模块构建
  • 单元测试 + 代码扫描
  • Docker 镜像构建
  • 推送 Harbor 私有仓库
+
☸️

容器编排

  • Kubernetes 集群
  • Deployment 滚动更新
  • Service 服务发现
  • ConfigMap 配置管理
  • Ingress 流量入口
+
📊

运维监控

  • Prometheus + Grafana
  • SkyWalking 链路追踪
  • ELK 日志收集
  • AlertManager 告警
  • Admin 应用监控
+
+
+

Dockerfile — 多阶段构建

+
urbanops-server/Dockerfile
+
# Stage 1: 构建
+FROM maven:3.9-eclipse-temurin-17 AS builder
+COPY . /app && WORKDIR /app
+RUN mvn clean package -DskipTests
+
+# Stage 2: 运行
+FROM eclipse-temurin:17-jre
+COPY --from=builder /app/urbanops-server/target/*.jar app.jar
+ENTRYPOINT ["java","-jar","/app.jar"]
+
+
+
+ + +
+

roadmap · planning

+

后续扩展规划

+
+
Phase 1 · 近期

🏗️ 架构夯实

  • 完善 IoT 设备接入能力
  • 会员中心 (Member) 激活
  • 支付系统 (Pay) 集成
  • 微信公众号 (MP) 接入
  • 数据报表与大屏 (Report)
  • 提升单元测试覆盖率
+ +
Phase 3 · 远期

🌐 平台化 & 智能化

  • 多租户 SaaS 化运营
  • 开放平台 & API 市场
  • 低代码/无代码搭建
  • PF4J 插件生态
  • 边缘计算 + IoT 规模化
  • 数据中台 & 智能决策
+
+
+
Q2 2025

物联网接入打通

MQTT 协议适配 · 设备影子 · TDengine 时序存储

+
Q3 2025

业务模块激活

Member + Pay + MP + Report 模块上线,补齐业务闭环

+
Q4 2025

微服务演进

核心域拆分 · Spring Cloud 迁移 · 服务独立部署

+
+
+ + +
+

evolution · target-architecture

+

架构演进目标:微服务全景

+
+

目标微服务架构

+
+ Nginx + Gateway + Nacos +
+ garden-serviceworkorder-servicebpm-servicesystem-serviceiot-gateway (已有) +
+ +
+ SentinelSeataSkyWalking +
+
+
+
+

1 · 业务驱动架构

智慧园林 + 工单调度 + 流程协同,三大核心域支撑城市运营。

+

2 · 分层清晰可扩展

15 个 Starter 组件化,模块间 Feign API + MQ 双通道解耦。

+

3 · 信创全栈兼容

MySQL / DM8 / KingBase / OpenGauss 多数据库无缝切换。

+

4 · 可观测性内置

SkyWalking + Admin + 日志中心,三大支柱保障系统可靠。

+

5 · 渐进式演进

模块化先行 → API 契约驱动 → 渐进式拆分 → 独立部署验证。

+

6 · IoT 独立先行

IoT 网关已实现独立部署,为后续微服务化积累了宝贵经验。

+
+
+
?
+

Questions & Discussion

+

感谢聆听 · 欢迎交流

+
#urbanops#spring-boot#microservices#smart-garden#cloud-native
+
+
+ +
+ + diff --git a/docs/ppt-assets/animations.css b/docs/ppt-assets/animations.css new file mode 100644 index 0000000..1d00263 --- /dev/null +++ b/docs/ppt-assets/animations.css @@ -0,0 +1,138 @@ +/* html-ppt :: animations.css + * Apply by adding class="anim-" or data-anim="". + * Durations are deliberately snappy; tweak --anim-dur per element. + */ +:root{--anim-dur:.7s;--anim-ease:cubic-bezier(.4,0,.2,1)} + +/* ---------- FADE DIRECTIONALS ---------- */ +@keyframes kf-fade-up{from{opacity:0;transform:translateY(32px)}to{opacity:1;transform:none}} +@keyframes kf-fade-down{from{opacity:0;transform:translateY(-32px)}to{opacity:1;transform:none}} +@keyframes kf-fade-left{from{opacity:0;transform:translateX(-40px)}to{opacity:1;transform:none}} +@keyframes kf-fade-right{from{opacity:0;transform:translateX(40px)}to{opacity:1;transform:none}} +.anim-fade-up{animation:kf-fade-up var(--anim-dur) var(--anim-ease) both} +.anim-fade-down{animation:kf-fade-down var(--anim-dur) var(--anim-ease) both} +.anim-fade-left{animation:kf-fade-left var(--anim-dur) var(--anim-ease) both} +.anim-fade-right{animation:kf-fade-right var(--anim-dur) var(--anim-ease) both} + +/* ---------- RISE / DROP / ZOOM / BLUR / GLITCH ---------- */ +@keyframes kf-rise{from{opacity:0;transform:translateY(60px) scale(.97);filter:blur(6px)}to{opacity:1;transform:none;filter:none}} +@keyframes kf-drop{from{opacity:0;transform:translateY(-60px) scale(.97)}to{opacity:1;transform:none}} +@keyframes kf-zoom{0%{opacity:0;transform:scale(.6)}60%{transform:scale(1.04)}100%{opacity:1;transform:scale(1)}} +@keyframes kf-blur{from{opacity:0;filter:blur(18px)}to{opacity:1;filter:none}} +@keyframes kf-glitch{0%{opacity:0;transform:translateX(0);clip-path:inset(0 0 0 0)} + 20%{opacity:1;transform:translateX(-6px);clip-path:inset(20% 0 30% 0)} + 40%{transform:translateX(4px);clip-path:inset(50% 0 10% 0)} + 60%{transform:translateX(-3px);clip-path:inset(10% 0 60% 0)} + 80%{transform:translateX(2px);clip-path:inset(0 0 0 0)} + 100%{opacity:1;transform:none}} +.anim-rise-in{animation:kf-rise .9s var(--anim-ease) both} +.anim-drop-in{animation:kf-drop .8s var(--anim-ease) both} +.anim-zoom-pop{animation:kf-zoom .7s cubic-bezier(.22,1.3,.36,1) both} +.anim-blur-in{animation:kf-blur .8s var(--anim-ease) both} +.anim-glitch-in{animation:kf-glitch .8s steps(5,end) both} + +/* ---------- TYPEWRITER ---------- */ +.anim-typewriter{display:inline-block;overflow:hidden;white-space:nowrap;border-right:2px solid currentColor; + width:0;animation:kf-type 2.4s steps(40,end) forwards, kf-caret 1s step-end infinite} +@keyframes kf-type{to{width:100%}} +@keyframes kf-caret{50%{border-color:transparent}} + +/* ---------- GLOW / SHIMMER / GRADIENT-FLOW ---------- */ +@keyframes kf-neon{0%,100%{text-shadow:0 0 8px var(--accent),0 0 20px var(--accent)} + 50%{text-shadow:0 0 16px var(--accent),0 0 40px var(--accent),0 0 80px var(--accent)}} +.anim-neon-glow{animation:kf-neon 2s ease-in-out infinite} + +.anim-shimmer-sweep{position:relative;overflow:hidden} +.anim-shimmer-sweep::after{content:"";position:absolute;inset:0; + background:linear-gradient(110deg,transparent 40%,rgba(255,255,255,.55) 50%,transparent 60%); + transform:translateX(-100%);animation:kf-shimmer 2.4s var(--anim-ease) infinite} +@keyframes kf-shimmer{to{transform:translateX(100%)}} + +.anim-gradient-flow{background:linear-gradient(90deg,var(--accent),var(--accent-2,var(--accent)),var(--accent-3,var(--accent)),var(--accent)); + background-size:300% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent; + animation:kf-gradflow 4s linear infinite} +@keyframes kf-gradflow{to{background-position:300% 0}} + +/* ---------- STAGGER LIST ---------- */ +.anim-stagger-list > *{opacity:0;animation:kf-rise .65s var(--anim-ease) both} +.anim-stagger-list > *:nth-child(1){animation-delay:.05s} +.anim-stagger-list > *:nth-child(2){animation-delay:.15s} +.anim-stagger-list > *:nth-child(3){animation-delay:.25s} +.anim-stagger-list > *:nth-child(4){animation-delay:.35s} +.anim-stagger-list > *:nth-child(5){animation-delay:.45s} +.anim-stagger-list > *:nth-child(6){animation-delay:.55s} +.anim-stagger-list > *:nth-child(7){animation-delay:.65s} +.anim-stagger-list > *:nth-child(8){animation-delay:.75s} +.anim-stagger-list > *:nth-child(n+9){animation-delay:.85s} + +/* ---------- COUNTER-UP (JS-driven, marker class only) ---------- */ +.counter{font-variant-numeric:tabular-nums} + +/* ---------- SVG PATH DRAW ---------- */ +.anim-path-draw path,.anim-path-draw line,.anim-path-draw polyline,.anim-path-draw circle,.anim-path-draw rect{ + stroke-dasharray:1000;stroke-dashoffset:1000;animation:kf-draw 2s var(--anim-ease) forwards} +@keyframes kf-draw{to{stroke-dashoffset:0}} + +/* ---------- PARALLAX TILT (hover) ---------- */ +.anim-parallax-tilt{transform-style:preserve-3d;transition:transform .4s var(--anim-ease)} +.anim-parallax-tilt:hover{transform:perspective(900px) rotateX(6deg) rotateY(-8deg) translateZ(10px)} + +/* ---------- CARD FLIP 3D ---------- */ +@keyframes kf-flip{from{transform:perspective(1200px) rotateY(-90deg);opacity:0} + to{transform:perspective(1200px) rotateY(0);opacity:1}} +.anim-card-flip-3d{animation:kf-flip .9s var(--anim-ease) both;transform-style:preserve-3d;backface-visibility:hidden} + +/* ---------- CUBE ROTATE 3D ---------- */ +@keyframes kf-cube{from{transform:perspective(1200px) rotateX(20deg) rotateY(-90deg) translateZ(-200px);opacity:0} + to{transform:perspective(1200px) rotateX(0) rotateY(0) translateZ(0);opacity:1}} +.anim-cube-rotate-3d{animation:kf-cube 1s var(--anim-ease) both} + +/* ---------- PAGE TURN 3D ---------- */ +@keyframes kf-pageturn{from{transform:perspective(1600px) rotateY(-85deg);transform-origin:left center;opacity:0} + to{transform:perspective(1600px) rotateY(0);opacity:1}} +.anim-page-turn-3d{animation:kf-pageturn 1s var(--anim-ease) both;transform-origin:left center} + +/* ---------- PERSPECTIVE ZOOM ---------- */ +@keyframes kf-pzoom{from{opacity:0;transform:perspective(1400px) translateZ(-400px) rotateX(12deg)} + to{opacity:1;transform:none}} +.anim-perspective-zoom{animation:kf-pzoom 1s var(--anim-ease) both} + +/* ---------- MARQUEE SCROLL ---------- */ +.anim-marquee-scroll{display:flex;gap:48px;white-space:nowrap;animation:kf-marquee 20s linear infinite} +@keyframes kf-marquee{from{transform:translateX(0)}to{transform:translateX(-50%)}} + +/* ---------- KEN BURNS ---------- */ +@keyframes kf-kenburns{0%{transform:scale(1) translate(0,0)}100%{transform:scale(1.15) translate(-2%,-1%)}} +.anim-kenburns{animation:kf-kenburns 14s ease-in-out infinite alternate} + +/* ---------- CONFETTI BURST (pseudo — pure CSS sparkles) ---------- */ +.anim-confetti-burst{position:relative} +.anim-confetti-burst::before,.anim-confetti-burst::after{ + content:"";position:absolute;top:50%;left:50%;width:8px;height:8px;border-radius:50%; + background:var(--accent);box-shadow: + 20px -30px 0 var(--accent-2,var(--accent)),-25px -20px 0 var(--accent-3,var(--accent)), + 30px 20px 0 var(--good,#1aaf6c),-30px 25px 0 var(--warn,#f5a524), + 40px -10px 0 var(--bad,#e0445a),-45px 0 0 var(--accent), + 10px 40px 0 var(--accent-2,var(--accent)),-15px -40px 0 var(--accent-3,var(--accent)); + opacity:0;animation:kf-confetti 1.2s var(--anim-ease) forwards} +.anim-confetti-burst::after{animation-delay:.15s;transform:rotate(45deg)} +@keyframes kf-confetti{0%{opacity:0;transform:scale(.2)}30%{opacity:1}100%{opacity:0;transform:scale(2.2)}} + +/* ---------- SPOTLIGHT ---------- */ +@keyframes kf-spot{0%{clip-path:circle(0% at 50% 50%)}100%{clip-path:circle(140% at 50% 50%)}} +.anim-spotlight{animation:kf-spot 1.1s var(--anim-ease) both} + +/* ---------- MORPH SHAPE (SVG) ---------- */ +.anim-morph-shape path{animation:kf-morph 6s ease-in-out infinite alternate} +@keyframes kf-morph{0%{d:path("M60,120 Q120,20 180,120 T300,120")} + 100%{d:path("M60,120 Q120,220 180,120 T300,120")}} + +/* ---------- RIPPLE REVEAL ---------- */ +@keyframes kf-ripple{0%{clip-path:circle(0% at 20% 80%);opacity:.4} + 100%{clip-path:circle(160% at 20% 80%);opacity:1}} +.anim-ripple-reveal{animation:kf-ripple 1.2s var(--anim-ease) both} + +/* reduced motion */ +@media (prefers-reduced-motion: reduce){ + [class*="anim-"]{animation:none!important;transition:none!important} +} diff --git a/docs/ppt-assets/base.css b/docs/ppt-assets/base.css new file mode 100644 index 0000000..b8c1107 --- /dev/null +++ b/docs/ppt-assets/base.css @@ -0,0 +1,150 @@ +/* html-ppt :: base.css — reset + shared tokens + layout primitives */ +/* Default tokens. Themes in assets/themes/*.css override the :root block. */ +:root { + --bg: #ffffff; + --bg-soft: #f7f7f8; + --surface: #ffffff; + --surface-2: #f2f2f4; + --border: rgba(0,0,0,.08); + --border-strong: rgba(0,0,0,.16); + --text-1: #111216; + --text-2: #55596a; + --text-3: #8a8f9e; + --accent: #3b6cff; + --accent-2: #7a5cff; + --accent-3: #ff5c8a; + --good: #1aaf6c; + --warn: #f5a524; + --bad: #e0445a; + --grad: linear-gradient(135deg,#3b6cff,#7a5cff 55%,#ff5c8a); + --grad-soft: linear-gradient(135deg,#eef2ff,#f5ecff 55%,#ffeef5); + --radius: 18px; + --radius-sm: 12px; + --radius-lg: 26px; + --shadow: 0 10px 30px rgba(18,24,40,.08), 0 2px 6px rgba(18,24,40,.04); + --shadow-lg: 0 24px 60px rgba(18,24,40,.14), 0 6px 16px rgba(18,24,40,.06); + --font-sans: 'Inter','Noto Sans SC',-apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif; + --font-serif: 'Playfair Display','Noto Serif SC',Georgia,serif; + --font-mono: 'JetBrains Mono','IBM Plex Mono',SFMono-Regular,Menlo,monospace; + --font-display: var(--font-sans); + --letter-tight: -.03em; + --letter-normal: -.01em; + --ease: cubic-bezier(.4,0,.2,1); +} + +*,*::before,*::after{box-sizing:border-box} +html,body{margin:0;padding:0;background:var(--bg);color:var(--text-1); + font-family:var(--font-sans);font-weight:400;line-height:1.6; + -webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale; + letter-spacing:var(--letter-normal)} +img,svg,video{max-width:100%;display:block} +a{color:var(--accent);text-decoration:none} +a:hover{text-decoration:underline} +code,kbd,pre,samp{font-family:var(--font-mono)} + +/* ================= SLIDE SYSTEM ================= */ +.deck{position:relative;width:100vw;height:100vh;overflow:hidden;background:var(--bg)} +.slide{ + position:absolute;inset:0; + display:flex;flex-direction:column;justify-content:center; + padding:72px 96px; + box-sizing:border-box; + opacity:0;pointer-events:none; + transition:opacity .5s var(--ease), transform .5s var(--ease); + transform:translateX(30px); + overflow:hidden; +} +.slide.is-active{opacity:1;pointer-events:auto;transform:translateX(0);z-index:2} +.slide.is-prev{transform:translateX(-30px)} + +/* single-page standalone (used when a layout file is opened directly) */ +body.single .slide{position:relative;width:100vw;height:100vh;opacity:1;transform:none;pointer-events:auto} + +/* ================= TYPOGRAPHY ================= */ +.eyebrow{font-size:13px;font-weight:500;letter-spacing:.16em;text-transform:uppercase;color:var(--text-3)} +.kicker{font-size:14px;font-weight:600;color:var(--accent);letter-spacing:.08em;text-transform:uppercase} +h1.title,.h1{font-family:var(--font-display);font-size:72px;line-height:1.05;font-weight:800;letter-spacing:var(--letter-tight);margin:0 0 18px;color:var(--text-1)} +h2.title,.h2{font-family:var(--font-display);font-size:54px;line-height:1.1;font-weight:700;letter-spacing:var(--letter-tight);margin:0 0 14px} +h3,.h3{font-size:32px;line-height:1.2;font-weight:600;letter-spacing:var(--letter-normal);margin:0 0 10px} +h4,.h4{font-size:22px;line-height:1.3;font-weight:600;margin:0 0 8px} +.lede{font-size:22px;line-height:1.55;color:var(--text-2);font-weight:300;max-width:62ch} +.dim{color:var(--text-2)} +.dim2{color:var(--text-3)} +.mono{font-family:var(--font-mono)} +.serif{font-family:var(--font-serif)} +.gradient-text{background:var(--grad);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent} + +/* ================= LAYOUT PRIMITIVES ================= */ +.stack>*+*{margin-top:14px} +.row{display:flex;gap:24px;align-items:center} +.row.wrap{flex-wrap:wrap} +.grid{display:grid;gap:24px} +.g2{grid-template-columns:repeat(2,1fr)} +.g3{grid-template-columns:repeat(3,1fr)} +.g4{grid-template-columns:repeat(4,1fr)} +.center{display:flex;align-items:center;justify-content:center;text-align:center} +.fill{flex:1} +.sp-t{padding-top:24px}.sp-b{padding-bottom:24px} +.mt-s{margin-top:8px}.mt-m{margin-top:18px}.mt-l{margin-top:32px} +.mb-s{margin-bottom:8px}.mb-m{margin-bottom:18px}.mb-l{margin-bottom:32px} + +/* ================= CARDS ================= */ +.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius); + padding:26px 28px;box-shadow:var(--shadow);position:relative;overflow:hidden} +.card-soft{background:var(--surface-2);border:1px solid var(--border)} +.card-outline{background:transparent;border:1.5px solid var(--border-strong);box-shadow:none} +.card-accent{background:var(--surface);border-top:3px solid var(--accent)} +.card-hover{transition:transform .3s var(--ease),box-shadow .3s var(--ease)} +.card-hover:hover{transform:translateY(-4px);box-shadow:var(--shadow-lg)} + +.pill{display:inline-block;padding:4px 12px;border-radius:999px;font-size:12px;font-weight:500; + background:var(--surface-2);color:var(--text-2);border:1px solid var(--border)} +.pill-accent{background:color-mix(in srgb,var(--accent) 12%,transparent);color:var(--accent);border-color:color-mix(in srgb,var(--accent) 28%,transparent)} + +/* ================= BARS / DIVIDERS ================= */ +.divider{height:1px;background:var(--border);width:100%} +.divider-accent{height:3px;width:72px;background:var(--accent);border-radius:2px} + +/* ================= CHROME (header/footer/progress) ================= */ +.deck-header{position:absolute;top:24px;left:40px;right:40px;display:flex;align-items:center;justify-content:space-between; + font-size:12px;color:var(--text-3);letter-spacing:.12em;text-transform:uppercase;z-index:10;pointer-events:none} +.deck-footer{position:absolute;bottom:24px;left:40px;right:40px;display:flex;align-items:center;justify-content:space-between; + font-size:12px;color:var(--text-3);z-index:10;pointer-events:none} +.slide-number::before{content:attr(data-current)} +.slide-number::after{content:" / " attr(data-total)} +.progress-bar{position:fixed;left:0;right:0;bottom:0;height:3px;background:transparent;z-index:20} +.progress-bar > span{display:block;height:100%;width:0;background:var(--accent);transition:width .3s var(--ease)} + +/* ================= PRESENTER / OVERVIEW ================= */ +.notes{display:none!important} +.notes-overlay{position:fixed;inset:auto 0 0 0;max-height:42vh;background:rgba(20,22,30,.95);color:#e8ebf4; + padding:20px 32px;font-size:16px;line-height:1.6;border-top:1px solid rgba(255,255,255,.1);transform:translateY(100%); + transition:transform .3s var(--ease);z-index:40;overflow:auto;font-family:var(--font-sans)} +.notes-overlay.open{transform:translateY(0)} +.overview{position:fixed;inset:0;background:rgba(10,12,18,.92);backdrop-filter:blur(12px);z-index:50; + display:none;padding:40px;overflow:auto} +.overview.open{display:grid;grid-template-columns:repeat(4,1fr);gap:20px;align-content:start} +.overview .thumb{background:var(--surface);border:1px solid var(--border);border-radius:12px; + aspect-ratio:16/9;overflow:hidden;cursor:pointer;position:relative;color:var(--text-1);padding:16px; + font-size:11px;transition:transform .2s var(--ease)} +.overview .thumb:hover{transform:scale(1.04)} +.overview .thumb .n{position:absolute;top:8px;left:10px;font-weight:700;font-size:14px;color:var(--text-3)} +.overview .thumb .t{position:absolute;bottom:10px;left:14px;right:14px;font-weight:600;color:var(--text-1)} + +/* ================= PRESENTER VIEW ================= */ +/* Presenter view opens in a separate popup window (S key). + * All presenter styles are self-contained in the popup HTML generated by runtime.js. + * The audience window (this file) is NOT affected — it stays as normal deck view. + * Only the .notes class below is needed to hide speaker notes from audience. */ + +/* ================= UTILITY ================= */ +.hidden{display:none!important} +.nowrap{white-space:nowrap} +.tr{text-align:right}.tc{text-align:center}.tl{text-align:left} +.uppercase{text-transform:uppercase;letter-spacing:.12em} + +/* ================= PRINT ================= */ +@media print{ + .slide{position:relative;opacity:1!important;transform:none!important;page-break-after:always;height:100vh} + .deck-header,.deck-footer,.progress-bar,.notes-overlay,.overview{display:none!important} +} diff --git a/docs/ppt-assets/fonts.css b/docs/ppt-assets/fonts.css new file mode 100644 index 0000000..64feb72 --- /dev/null +++ b/docs/ppt-assets/fonts.css @@ -0,0 +1,9 @@ +/* html-ppt :: shared webfonts */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800;900&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@200;300;400;500;600;700;900&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;400;600;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,800;1,400&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&display=swap'); diff --git a/docs/ppt-assets/runtime.js b/docs/ppt-assets/runtime.js new file mode 100644 index 0000000..535907d --- /dev/null +++ b/docs/ppt-assets/runtime.js @@ -0,0 +1,960 @@ +/* html-ppt :: runtime.js + * Keyboard-driven deck runtime. Zero dependencies. + * + * Features: + * ← → / space / PgUp PgDn / Home End navigation + * F fullscreen + * S presenter mode (opens a NEW WINDOW with current/next slide preview + notes + timer) + * The original window stays as audience view, synced via BroadcastChannel. + * Slide previews use CSS transform:scale() at design resolution for pixel-perfect layout. + * N quick notes overlay (bottom drawer) + * O slide overview grid + * T cycle themes (reads data-themes on or ) + * A cycle demo animation on current slide + * URL hash #/N deep-link to slide N (1-based) + * Progress bar auto-managed + */ +(function () { + 'use strict'; + + const ANIMS = ['fade-up','fade-down','fade-left','fade-right','rise-in','drop-in', + 'zoom-pop','blur-in','glitch-in','typewriter','neon-glow','shimmer-sweep', + 'gradient-flow','stagger-list','counter-up','path-draw','parallax-tilt', + 'card-flip-3d','cube-rotate-3d','page-turn-3d','perspective-zoom', + 'marquee-scroll','kenburns','confetti-burst','spotlight','morph-shape','ripple-reveal']; + + function ready(fn){ if(document.readyState!='loading')fn(); else document.addEventListener('DOMContentLoaded',fn);} + + /* ========== Parse URL for preview-only mode ========== + * When loaded as iframe.src = "index.html?preview=3", runtime enters a + * locked single-slide mode: only slide N is visible, no chrome, no keys, + * no hash updates. This is how the presenter window shows pixel-perfect + * previews — by loading the actual deck file in an iframe and telling it + * to display only a specific slide. + */ + function getPreviewIdx() { + const m = /[?&]preview=(\d+)/.exec(location.search || ''); + return m ? parseInt(m[1], 10) - 1 : -1; + } + + ready(function () { + const deck = document.querySelector('.deck'); + if (!deck) return; + const slides = Array.from(deck.querySelectorAll('.slide')); + if (!slides.length) return; + + const previewOnlyIdx = getPreviewIdx(); + const isPreviewMode = previewOnlyIdx >= 0 && previewOnlyIdx < slides.length; + + /* ===== Preview-only mode: show one slide, hide everything else ===== */ + if (isPreviewMode) { + function showSlide(i) { + slides.forEach((s, j) => { + const active = (j === i); + s.classList.toggle('is-active', active); + s.style.display = active ? '' : 'none'; + if (active) { + s.style.opacity = '1'; + s.style.transform = 'none'; + s.style.pointerEvents = 'auto'; + } + }); + } + showSlide(previewOnlyIdx); + /* Hide chrome that the presenter shouldn't see in preview */ + const hideSel = '.progress-bar, .notes-overlay, .overview, .notes, aside.notes, .speaker-notes'; + document.querySelectorAll(hideSel).forEach(el => { el.style.display = 'none'; }); + document.documentElement.setAttribute('data-preview', '1'); + document.body.setAttribute('data-preview', '1'); + /* Auto-detect theme base path for theme switching in preview mode */ + function getPreviewThemeBase() { + const base = document.documentElement.getAttribute('data-theme-base'); + if (base) return base; + const tl = document.getElementById('theme-link'); + if (tl) { + const raw = tl.getAttribute('href') || ''; + const ls = raw.lastIndexOf('/'); + if (ls >= 0) return raw.substring(0, ls + 1); + } + return 'assets/themes/'; + } + const previewThemeBase = getPreviewThemeBase(); + + /* Listen for postMessage from parent presenter window: + * - preview-goto: switch visible slide WITHOUT reloading + * - preview-theme: switch theme CSS link to match audience window */ + window.addEventListener('message', function(e) { + if (!e.data) return; + if (e.data.type === 'preview-goto') { + const n = parseInt(e.data.idx, 10); + if (n >= 0 && n < slides.length) showSlide(n); + } else if (e.data.type === 'preview-theme' && e.data.name) { + let link = document.getElementById('theme-link'); + if (!link) { + link = document.createElement('link'); + link.rel = 'stylesheet'; + link.id = 'theme-link'; + document.head.appendChild(link); + } + link.href = previewThemeBase + e.data.name + '.css'; + document.documentElement.setAttribute('data-theme', e.data.name); + } + }); + /* Signal to parent that preview iframe is ready */ + try { window.parent && window.parent.postMessage({ type: 'preview-ready' }, '*'); } catch(e) {} + return; + } + + let idx = 0; + const total = slides.length; + + /* ===== BroadcastChannel for presenter sync ===== */ + const CHANNEL_NAME = 'html-ppt-presenter-' + location.pathname; + let bc; + try { bc = new BroadcastChannel(CHANNEL_NAME); } catch(e) { bc = null; } + + // Are we running inside the presenter popup? (legacy flag, now unused) + const isPresenterWindow = false; + + /* ===== progress bar ===== */ + let bar = document.querySelector('.progress-bar'); + if (!bar) { + bar = document.createElement('div'); + bar.className = 'progress-bar'; + bar.innerHTML = ''; + document.body.appendChild(bar); + } + const barFill = bar.querySelector('span'); + + /* ===== notes overlay (N key) ===== */ + let notes = document.querySelector('.notes-overlay'); + if (!notes) { + notes = document.createElement('div'); + notes.className = 'notes-overlay'; + document.body.appendChild(notes); + } + + /* ===== overview grid (O key) ===== */ + let overview = document.querySelector('.overview'); + if (!overview) { + overview = document.createElement('div'); + overview.className = 'overview'; + slides.forEach((s, i) => { + const t = document.createElement('div'); + t.className = 'thumb'; + // Force 16:9 aspect ratio robustly + t.style.padding = '0 0 56.25% 0'; + t.style.height = '0'; + t.style.position = 'relative'; + t.style.overflow = 'hidden'; + + const title = s.getAttribute('data-title') || + (s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1)); + + // Create a container for the mini-slide + const mini = document.createElement('div'); + mini.className = 'mini-slide'; + mini.style.position = 'absolute'; + mini.style.top = '0'; + mini.style.left = '0'; + mini.style.width = '1920px'; + mini.style.height = '1080px'; + mini.style.transformOrigin = 'top left'; + mini.style.pointerEvents = 'none'; + mini.style.background = 'var(--bg)'; + + // Clone the slide content + const clone = s.cloneNode(true); + clone.className = 'slide is-active'; // force active styles + clone.style.position = 'absolute'; + clone.style.inset = '0'; + clone.style.transform = 'none'; + clone.style.opacity = '1'; + clone.style.padding = '72px 96px'; // ensure padding is kept + + mini.appendChild(clone); + t.appendChild(mini); + + // Add the number and title overlay + const overlay = document.createElement('div'); + overlay.style.position = 'absolute'; + overlay.style.inset = '0'; + overlay.style.background = 'linear-gradient(to bottom, rgba(0,0,0,0.2) 0%, transparent 40%, transparent 60%, rgba(0,0,0,0.8) 100%)'; + overlay.style.color = '#fff'; + overlay.style.zIndex = '10'; + overlay.style.pointerEvents = 'none'; + + const n = document.createElement('div'); + n.className = 'n'; + n.textContent = i + 1; + n.style.position = 'absolute'; + n.style.top = '12px'; + n.style.left = '16px'; + n.style.fontWeight = '700'; + n.style.fontSize = '16px'; + n.style.color = '#fff'; + n.style.textShadow = '0 1px 4px rgba(0,0,0,0.8)'; + + const text = document.createElement('div'); + text.className = 't'; + text.textContent = title.trim().slice(0,80); + text.style.position = 'absolute'; + text.style.bottom = '12px'; + text.style.left = '16px'; + text.style.right = '16px'; + text.style.fontWeight = '600'; + text.style.fontSize = '14px'; + text.style.color = '#fff'; + text.style.textShadow = '0 1px 4px rgba(0,0,0,0.8)'; + + overlay.appendChild(n); + overlay.appendChild(text); + t.appendChild(overlay); + + t.addEventListener('click', () => { go(i); toggleOverview(false); }); + overview.appendChild(t); + }); + document.body.appendChild(overview); + } + + /* ===== navigation ===== */ + function go(n, fromRemote){ + n = Math.max(0, Math.min(total-1, n)); + slides.forEach((s,i) => { + s.classList.toggle('is-active', i===n); + s.classList.toggle('is-prev', i { + const a = el.getAttribute('data-anim'); + el.classList.remove('anim-'+a); + void el.offsetWidth; + el.classList.add('anim-'+a); + }); + + // counter-up + slides[n].querySelectorAll('.counter').forEach(el => { + const target = parseFloat(el.getAttribute('data-to')||el.textContent); + const dur = parseInt(el.getAttribute('data-dur')||'1200',10); + const start = performance.now(); + const from = 0; + function tick(now){ + const t = Math.min(1,(now-start)/dur); + const v = from + (target-from)*(1-Math.pow(1-t,3)); + el.textContent = (target % 1 === 0) ? Math.round(v) : v.toFixed(1); + if (t<1) requestAnimationFrame(tick); + } + requestAnimationFrame(tick); + }); + + // Broadcast to other window (audience ↔ presenter) + if (!fromRemote && bc) { + bc.postMessage({ type: 'go', idx: n }); + } + } + + /* ===== listen for remote navigation / theme changes ===== */ + if (bc) { + bc.onmessage = function(e) { + if (!e.data) return; + if (e.data.type === 'go' && typeof e.data.idx === 'number') { + go(e.data.idx, true); + } else if (e.data.type === 'theme' && e.data.name) { + /* Sync theme across windows */ + const i = themes.indexOf(e.data.name); + if (i >= 0) themeIdx = i; + applyTheme(e.data.name); + } + }; + } + + function toggleNotes(force){ notes.classList.toggle('open', force!==undefined?force:!notes.classList.contains('open')); } + function toggleOverview(force){ + const isOpen = force!==undefined ? force : !overview.classList.contains('open'); + overview.classList.toggle('open', isOpen); + if (isOpen) { + requestAnimationFrame(() => { + const thumbs = overview.querySelectorAll('.thumb'); + if (thumbs.length) { + const scale = thumbs[0].clientWidth / 1920; + overview.querySelectorAll('.mini-slide').forEach(m => { + m.style.transform = 'scale(' + scale + ')'; + }); + } + }); + } + } + + /* ========== PRESENTER MODE — Magnetic-card popup window ========== */ + /* Opens a new window with 4 draggable, resizable cards: + * CURRENT — iframe(?preview=N) pixel-perfect preview of current slide + * NEXT — iframe(?preview=N+1) pixel-perfect preview of next slide + * SCRIPT — large speaker notes (逐字稿) + * TIMER — elapsed timer + page counter + controls + * Cards remember position/size in localStorage. + * Two windows sync via BroadcastChannel. + */ + let presenterWin = null; + + function openPresenterWindow() { + if (presenterWin && !presenterWin.closed) { + presenterWin.focus(); + return; + } + + // Build absolute URL of THIS deck file (without hash/query) + const deckUrl = location.protocol + '//' + location.host + location.pathname; + + // Collect slide titles + notes (HTML strings) + const slideMeta = slides.map((s, i) => { + const note = s.querySelector('.notes, aside.notes, .speaker-notes'); + return { + title: s.getAttribute('data-title') || + (s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1)), + notes: note ? note.innerHTML : '' + }; + }); + + /* Capture current theme so presenter previews match the audience */ + const currentTheme = root.getAttribute('data-theme') || (themes[themeIdx] || ''); + const presenterHTML = buildPresenterHTML(deckUrl, slideMeta, total, idx, CHANNEL_NAME, currentTheme); + + presenterWin = window.open('', 'html-ppt-presenter', 'width=1280,height=820,menubar=no,toolbar=no'); + if (!presenterWin) { + alert('请允许弹出窗口以使用演讲者视图'); + return; + } + presenterWin.document.open(); + presenterWin.document.write(presenterHTML); + presenterWin.document.close(); + } + + function buildPresenterHTML(deckUrl, slideMeta, total, startIdx, channelName, currentTheme) { + const metaJSON = JSON.stringify(slideMeta); + const deckUrlJSON = JSON.stringify(deckUrl); + const channelJSON = JSON.stringify(channelName); + const themeJSON = JSON.stringify(currentTheme || ''); + const storageKey = 'html-ppt-presenter:' + location.pathname; + + // Build the document as a single template string for clarity + return ` + + + +Presenter View + + + + +
+
+
+ + CURRENT + +
+
+
+
+ +
+
+ + NEXT + +
+
+
+
+ +
+
+ + SPEAKER SCRIPT · 逐字稿 +
+
+
+
+ +
+
+ + TIMER +
+
+
00:00
+
+ Slide + 1 / ${total} +
+
+ + + +
+
+
+
+
+ +
+ ← → 翻页 + R 重置计时 + Esc 关闭 + 拖动卡片头部移动 · 拖动右下角调整大小 + +
+ + + + diff --git a/docs/urbanops-tech-sharing.html b/docs/urbanops-tech-sharing.html new file mode 100644 index 0000000..786898f --- /dev/null +++ b/docs/urbanops-tech-sharing.html @@ -0,0 +1,771 @@ + + + + +城市运营全域平台 · 技术架构分享 + + + + + + + +
+ + +
+

urbanops · tech-sharing

+

城市运营全域平台
技术架构深度解析

+

从业务架构到技术架构,从单体模块到微服务演进——一文讲透全域平台。

+
架构组platform infra · 50 min + Q&A
+ +
+ + +
+

agenda.yaml

+

今日路线图

+
+
01项目概述与业务背景~3min
+
02业务架构全景与业务布局~8min
+
03技术架构总览与分层设计~8min
+
04核心技术栈与基础设施层~6min
+
05数据存储与多数据库策略~5min
+
06模块调用逻辑与通信机制~6min
+
07安全权限与可观测性~5min
+
08部署架构与后续规划~5min
+
09总结 & Q&A~4min
+
+
+ + +
+

// overview

+

UrbanOps 全域平台:
智慧城市运营管理底座

+

基于 RuoYi-Vue-Pro 演进而来,面向城市园林绿化、工单调度、流程协同的综合运营管理平台。

+
+
17
Maven 模块
+
7
激活业务模块
+
10+
数据库支持
+
3.5.5
Spring Boot
+
MIT
开源协议
+
+
+
+

后端技术基石

+

Java 17 · Spring Boot 3.5.5 · MyBatis Plus 3.5.14 · Spring Security 6.x · Flowable 7.0 · Redisson 3.51 · SkyWalking 9.5 · RocketMQ · Vert.x + MQTT

+
+
+

前端生态

+

Vue3 + element-plus 管理后台 · Vue3 + vben(ant-design-vue) 高级后台 · uni-app 移动端 · 大屏报表设计器 · 代码生成器一键生成前后端

+
+
+
+ + +
+

business · architecture

+

业务架构全景:
四大核心域 + 三大支撑域

+
+
+
🌳

智慧园林管理

+

树木资产管理、巡检计划、养护计划、绩效评估、物料出入库、机械成本核算

+
Garden巡检养护绩效
+
+
+
📋

工单调度系统

+

事件上报、工单流转、物料明细、附件管理、地理位置编码、园林事件映射

+
Workorder事件物料
+
+
+
🔄

工作流引擎

+

Flowable 驱动,仿钉钉/飞书 + BPMN 双设计器,会签、或签、转办、委派、加签全覆盖

+
BPMFlowable审批
+
+
+
🌐

全局业务管理

+

问题上报、道路信息、工单提交详情、流程路径想法、跨模块数据聚合

+
Global道路问题
+
+
+
⚙️

系统管理

+

用户/角色/菜单/部门/岗位管理、租户与套餐、字典、通知公告、应用管理

+
SystemRBAC多租户
+
+
+
🏗️

基础设施

+

代码生成、文件存储、定时任务、配置管理、消息队列、API 日志、监控追踪

+
Infra代码生成监控
+
+
+
+ + +
+

garden · smart-greening

+

核心业务:智慧园林管理

+

覆盖园林绿化全生命周期——从资产登记到巡检养护,从绩效评估到成本核算。

+
+
管理端
+
+
🌲

树木资产管理

古树名木 / 变更日志

+
📅

巡检计划管理

计划制定 / 明细配置

+
🔧

养护计划管理

养护任务 / 执行跟踪

+
📊

绩效管理

指标定义 / 专家评定

+
📦

物料管理

物料类型 / 出入库

+
💰

机械成本

折旧 / 租赁核算

+
+
+
移动端
+
+
📱

首页概览

数据汇总

+
📍

GPS 定位

位置上报

+

巡检提交

现场打卡

+
🛠️

养护提交

任务执行

+
📝

变更记录

树木变更

+
+
+
+
+

定时任务

自动生成巡检/养护计划

+

数据权限

按区域隔离园林数据

+

时间区间

灵活配置任务执行周期

+

防重复提交

计划明细幂等性保证

+
+
+ + +
+

workorder · dispatch

+

工单调度:事件驱动 + 流程闭环

+
+
+
+

工单核心实体模型

+
+
📋 MainInfo — 工单主信息
+
📎 Attachment — 附件管理
+
📦 MaterialDetail — 物料明细
+
🎯 EventInfo — 事件信息
+
🔗 EventMappingInfo — 事件映射
+
🌳 GardenRef — 园林业务关联
+
+
+
+
+
+

工单处理流程

+
+
+
📝

事件上报

+ +
🔍

工单生成

+ +
👤

人员指派

+
+
+
🔧

现场处理

+ +
📋

结果反馈

+ +

审核结单

+
+
+
+
+
+
WorkorderModule → GardenModule (Feign API) · EventMapping 策略 · GeoCode 位置服务 · 数据权限隔离
+
+ + +
+

bpm · flowable-engine

+

工作流引擎:同时支持
「轻量配置」与「深度编排」

+
+
+
+

SIMPLE 设计器

+

仿钉钉/飞书体验,拖拽搭建表单流程,10 分钟完成配置

+
+
✦ 拖拽式表单设计
+
✦ 审批节点可视化配置
+
✦ 抄送人 / 条件分支
+
✦ 超时审批 / 自动提醒
+
+
+
+
+
+

BPMN 设计器

+

基于 BPMN 2.0 标准,适配复杂多层级审批及自动化场景

+
+
✦ 会签 / 或签 / 依次审批
+
✦ 转办 / 委派 / 加签 / 减签
+
✦ 并行 / 包容 / 路由分支
+
✦ 父子流程 / 触发节点
+
+
+
+
+
+

Flowable 7.0

核心流程引擎

+

信创数据库兼容

DM8 / KingBase / OpenGauss

+

动态表单

节点级只读/编辑/隐藏权限

+
+
+ + +
+

global · system · infra

+

业务支撑域:
公共服务与系统管理

+
+
+

🌐 Global 全局模块

+
+

问题上报管理

公共问题发现 → 流转到工单系统

+

道路信息管理

道路基础数据维护

+

工单提交详情

跨模块工单数据聚合视图

+

流程路径想法

流程优化建议收集

+
+
+
+

⚙️ System + Infra 模块

+
+

RBAC 权限体系

用户 → 角色 → 菜单 → 按钮权限 · 数据权限

+

SaaS 多租户

租户管理 · 套餐配置 · 数据隔离 · 透明化封装

+

代码生成器

一键生成 Java · Vue · SQL · 接口文档

+

消息通知中心

短信 · 邮件 · 站内信 · 多通道

+
+
+
+
+ + +
+

architecture · layers

+

技术架构五层模型

+

从用户端到底层基础设施,清晰分层、逐级解耦。

+
+
+
接入层
+
+
🖥️

Web Admin

Vue3 + Element Plus

+
📱

Mobile App

uni-app 跨端

+
📊

大屏展示

GoView 可视化

+
🔗

开放 API

第三方对接

+
+
+
+
网关层
+
+
🔐

认证鉴权

Spring Security + Token

+
🚪

路由转发

Nginx / Gateway

+
⚖️

负载均衡

多实例分发

+
🛡️

安全防护

XSS / CSRF / SQL注入

+
+
+
+
服务层
+
+
🌳

Garden

园林管理

+
📋

Workorder

工单调度

+
🔄

BPM

工作流

+
🌐

Global

全局服务

+
⚙️

System

系统管理

+
🏗️

Infra

基础设施

+
+
+
+
中间件
+
+
🗄️

MySQL

OLTP

+

Redis

缓存/锁

+
📨

RocketMQ

消息队列

+
🔍

Elasticsearch

全文检索

+
📡

TDengine

时序数据

+
+
+
+
+ + +
+

backend · layered

+

后端分层架构:
Controller → Service → DAL 三层解耦

+
+
+
+
+

controller/

+

接收请求、参数校验、VO 转换

+
admin/app/VO 对象@Valid
+
+
+

service/

+

业务逻辑编排、事务管理、策略模式

+
接口+实现@Transactional策略Listener
+
+
+

dal/

+

数据访问层:MySQL Mapper + Redis Cache

+
mysql/redis/dataobject/
+
+
+
+
+
+
+

convert/

+

MapStruct 对象转换层,编译期生成高性能映射代码

+
+
+

api/

+

模块间 Feign 接口定义 + DTO,实现跨模块调用

+
+
+

mq/ + job/

+

异步消息(producer/consumer)+ 定时任务调度

+
+
+

framework/

+

模块内框架代码:数据权限配置、Web 配置、操作日志等

+
+
+
+
+
+ + +
+

framework · starters

+

基础设施层:
15 个 Spring Boot Starter

+

将通用能力抽象为可复用的 Starter 组件,所有业务模块按需引入。

+
+
🌐

web

全局异常处理 · 统一返回 · CORS · Swagger

+
🔐

security

Spring Security · Token 鉴权 · SSO

+
🗄️

mybatis

MyBatis Plus · 多数据源 · 联表查询

+

redis

Redisson · 分布式锁 · 缓存策略

+
📨

mq

RocketMQ 集成 · 消息幂等 · 重试

+
🛡️

protection

分布式锁 · 幂等 · 限流 · 熔断

+
📊

monitor

SkyWalking · Admin · 日志中心

+

job

Quartz 定时任务 · 执行日志

+
📝

excel

FastExcel 导入导出

+
🔌

websocket

实时通信 · Token 校验 · 集群

+
🏢

biz-tenant

多租户数据隔离 · 透明封装

+
🔒

biz-data-permission

数据权限 · 行级过滤

+
+
+ + +
+

tech-stack · ecosystem

+

核心技术栈全景

+
+

核心框架

Spring Boot 3.5.5Spring MVC 6.xSpring Security 6.x
+
🗄️

持久层

MyBatis Plus 3.5.14Druid 1.2.27Dynamic-DS 4.3.1
+

缓存 & 锁

Redis 7.xRedisson 3.51Lock4j 2.2.7
+
📨

消息队列

RocketMQSpring-RocketMQRedis Stream
+
🔄

工作流

Flowable 7.0.1BPMN 2.0SIMPLE 设计器
+
📡

IoT & 通信

Vert.x 4.5.13Netty 4.2.4MQTT 1.2.5
+
🔍

可观测性

SkyWalking 9.5Admin 3.5.2OpenTracing
+
📋

API 文档

Springdoc 2.8.11Knife4j 4.5.0Swagger 3
+
🔌

插件 & 工具

PF4J 0.9.0MapStruct 1.6.3Hutool 5.8+6.0
+
🧪

测试

JUnit 5Mockito 5.xJedis-Mock
+
🗄️

多数据库

MySQL 8.xDM8KingBaseOpenGauss
+
🐳

部署运维

DockerK8sJenkinsHarbor
+
+
+ + +
+

database · strategy

+

数据存储策略:
多数据源 + 多数据库 + 读写分离

+
+
+
+

Dynamic-Datasource 多数据源

+
+
一主多从读写分离
+
@DS 注解切换数据源
+
多库事务(本地事务 + 补偿)
+
Druid 连接池监控
+
+
+
+

Redis 缓存体系

+
+
○ Redisson 分布式锁 · 幂等键
+
○ Redis Stream 轻量 MQ
+
○ 菜单/字典/租户权限缓存
+
○ Token 存储 & 在线用户管理
+
+
+
+
+
+

多数据库兼容矩阵

+
+
MySQL 5.7 / 8.0+ — 主力库
+
达梦 DM8 — 国产信创
+
人大金仓 KingBase 8.6 — 信创
+
OpenGauss 5.1 — 华为开源
+
Oracle / PostgreSQL / SQL Server
+
TDengine 3.7 — IoT 时序数据
+
+
+
+

数据隔离策略

+

SaaS 多租户:共享数据表 + tenant_id 字段隔离,biz-tenant Starter 透明拦截 SQL,自动添加租户条件

+
+
+
+
+ + +
+

invocation · communication

+

模块调用逻辑:
三种通信通道

+
+
+
Feign API
+
System → Dept APISystem → User APISystem → Dict APIWorkorder → Garden APIGarden → System API
+
+
▼ 同步调用 · 模块间 RPC · 请求/响应模式 ▼
+
+
MQ 消息
+
SMS Producer → ConsumerMail Producer → ConsumerEvent → 多消费者RocketMQ TopicRedis Stream
+
+
▼ 异步解耦 · 事件驱动 · 最终一致性 ▼
+
+
直接依赖
+
Server → All ModulesWorkorder → Garden ServiceBPM → System UserGarden → Infra File
+
+
+
+

api/ 包设计

每个模块暴露 Feign 接口 + DTO,其他模块引入依赖即可调用,编译期类型安全。

+

mq/ 包设计

Producer 发送消息,Consumer 消费处理。SMS / Mail 异步发送,不解耦主流程。

+

service/ 直接注入

同模块内 Service 互相注入。跨模块通过 api 包或直接依赖。

+
+
+ + +
+

async · event-driven

+

异步消息与事件驱动

+
+
+
+

RocketMQ 消息架构

+
+
📤 Producer: 业务服务发送消息到 Topic
+
📥 Consumer: 独立消费组拉取并处理
+
🔄 重试机制: 消费失败自动重试
+
⚡ 顺序消息: 全局/分区有序
+
🔒 事务消息: 分布式事务最终一致性
+
+
+
+
+
+

典型异步场景

+
+
📧 SMS 发送 → MQ 异步 → 消费者推送
+
📨 邮件发送 → MQ 异步 → 消费者投递
+
🔔 站内信通知 → Redis Stream 广播
+
📋 操作日志 → 异步记录不影响主流程
+
🔄 工单事件 → MQ 通知园林模块更新
+
+
+
+
+
+

定时任务 (Quartz)

巡检计划自动生成 · 养护计划定时创建 · 数据统计报表 · 超时任务自动处理

+

服务保障 (Protection)

@Lock4j 分布式锁防并发 · @Idempotent 幂等性 · @RateLimiter 限流保护 · 熔断降级

+
+
+ + +
+

security · auth · tenant

+

安全与权限体系

+
+
+

🔐 认证体系

+
    +
  • Spring Security 6.x 核心
  • +
  • Token + Redis 无状态会话
  • +
  • OAuth2 授权码模式
  • +
  • 多终端(Web / App / 小程序)
  • +
  • SSO 单点登录
  • +
  • 社交登录(JustAuth)
  • +
+
+
+

🛡️ 授权体系

+
    +
  • RBAC:用户 → 角色 → 菜单
  • +
  • 按钮级别权限控制
  • +
  • 动态菜单加载
  • +
  • Redis 缓存权限信息
  • +
  • 数据权限:行级过滤
  • +
  • 部门数据范围隔离
  • +
+
+
+

🏢 多租户

+
    +
  • tenant_id 字段隔离
  • +
  • MyBatis 拦截器自动注入
  • +
  • 租户套餐权限定制
  • +
  • 每个租户独立菜单/角色
  • +
  • 透明化封装无侵入
  • +
  • 支持租户禁用/过期
  • +
+
+
+
SystemModule → Security Starter → Token + Redis → @PreAuthorize → DataPermission AOP → SQL Interceptor
+
+ + +
+

observability · monitoring

+

可观测性与监控体系

+
+
+

📝 日志体系

+

Logback + SkyWalking Logback Appender,TraceId 自动注入,一键关联链路

+
操作日志登录日志API 访问日志错误日志
+
+
+

📊 指标监控

+

Spring Boot Admin 应用监控 + Druid SQL 监控 + Redis 监控面板

+
JVM 指标连接池QPS/RT慢 SQL
+
+
+

🔍 链路追踪

+

Apache SkyWalking 9.5 分布式追踪,服务拓扑自动发现

+
调用链拓扑图慢端点告警
+
+
+
+

Monitor Starter 集成

SkyWalking · Admin Client · OpenTracing · 日志中心 · 一键接入

+

保护机制

分布式锁 (Lock4j) · 幂等性保证 · 限流降级 · 异常全局处理 · 错误码统一管理

+
+
+ + +
+

deployment · ci-cd

+

部署架构与 CI/CD 流水线

+
+
+
🚀

CI 持续集成

+
  • Jenkins Pipeline
  • Maven 多模块构建
  • 单元测试 + 代码扫描
  • Docker 镜像构建
  • 推送 Harbor 私有仓库
+
+
+
☸️

容器编排

+
  • Kubernetes 集群
  • Deployment 滚动更新
  • Service 服务发现
  • ConfigMap 配置管理
  • Ingress 流量入口
+
+
+
📊

运维监控

+
  • Prometheus + Grafana
  • SkyWalking 链路追踪
  • ELK 日志收集
  • AlertManager 告警
  • Admin 应用监控
+
+
+
+

Dockerfile — 多阶段构建

+
+
urbanops-server/Dockerfile
+
# Stage 1: 构建
+FROM maven:3.9-eclipse-temurin-17 AS builder
+COPY . /app && WORKDIR /app
+RUN mvn clean package -DskipTests
+
+# Stage 2: 运行
+FROM eclipse-temurin:17-jre
+COPY --from=builder /app/urbanops-server/target/*.jar app.jar
+ENTRYPOINT ["java","-jar","/app.jar"]
+
+
+
+ + +
+

roadmap · planning

+

后续扩展规划

+
+
+
Phase 1 · 近期
+

🏗️ 架构夯实

+
    +
  • 完善 IoT 设备接入能力 (MQTT + Vert.x)
  • +
  • 会员中心模块激活 (Member)
  • +
  • 支付系统集成 (Pay)
  • +
  • 微信公众号接入 (MP)
  • +
  • 数据报表与大屏 (Report)
  • +
  • 完善单元测试覆盖率
  • +
+
+ +
+
Phase 3 · 远期
+

🌐 平台化

+
    +
  • 多租户 SaaS 化运营
  • +
  • 开放平台 & API 市场
  • +
  • 低代码/无代码搭建
  • +
  • PF4J 插件生态
  • +
  • 边缘计算 + IoT 规模化
  • +
  • 数据中台 & 智能决策
  • +
+
+
+
+
Q2 2025

物联网接入打通

MQTT 协议适配 · 设备影子 · 数据上行 · TDengine 时序存储

+
Q3 2025

业务模块激活

Member + Pay + MP + Report 模块上线,补齐业务闭环

+
Q4 2025

微服务演进

核心域拆分 · Spring Cloud 迁移 · 服务独立部署

+
+
+ + +
+
+

// key-takeaways

+

五个核心要点

+
+

1 · 业务驱动架构

智慧园林 + 工单调度 + 流程协同,三大核心域支撑城市运营。

+

2 · 分层清晰可扩展

15 个 Starter 组件化,模块间 Feign API + MQ 双通道解耦。

+

3 · 信创全栈兼容

MySQL / DM8 / KingBase / OpenGauss 多数据库无缝切换。

+

4 · 可观测性内置

SkyWalking + Admin + 日志中心,三大支柱保障系统可靠。

+

5 · 渐进式演进

从模块化到微服务化,从单应用到平台化,路径清晰可落地。

+
+
+
?
+

Questions & Discussion

+

感谢聆听 · 欢迎交流

+
+ #urbanops + #spring-boot + #microservices + #smart-garden + #cloud-native +
+
+
+
+ +
+ + diff --git a/docs/开放API对接接口文档.html b/docs/开放API对接接口文档.html new file mode 100644 index 0000000..5bc4de8 --- /dev/null +++ b/docs/开放API对接接口文档.html @@ -0,0 +1,1170 @@ +# UrbanOps 开放 API 对接接口文档 + +> **版本**: v1.1 +> **更新日期**: 2026-06-25 +> **适用对象**: 第三方系统对接开发人员 / Co-Claw AI 助手 +> **鉴权方式**: HMAC-SHA256 API 签名 + +--- + +## 目录 + +1. [概述](#1-概述) +2. [签名鉴权机制](#2-签名鉴权机制) +3. [通用规范](#3-通用规范) +4. [接口 1:团队考核查询(Co-Claw AI 专用)](#4-接口-1团队考核查询Co-Claw-ai-专用) +5. [接口 2:人员查询](#5-接口-2人员查询) +6. [接口 3:班组查询](#6-接口-3班组查询) +7. [接口 4:考勤查询](#7-接口-4考勤查询) +8. [接口 5:工单查询](#8-接口-5工单查询) +9. [接口 6:物料查询](#9-接口-6物料查询) +10. [接口 7:全量工单统计](#10-接口-7全量工单统计) +11. [错误码说明](#11-错误码说明) +12. [附录:客户端签名代码示例](#12-附录客户端签名代码示例) + +--- + +## 1. 概述 + +### 1.1 基本信息 + +| 项目 | 说明 | +|------|------| +| 接口协议 | HTTPS | +| 接口地址 | `https://{host}/open-api/` | +| 鉴权方式 | HMAC-SHA256 API 签名(详见第2节) | +| 响应格式 | JSON | +| 字符编码 | UTF-8 | + +### 1.2 接口列表 + +| # | 接口名称 | 路径 | 方法 | 说明 | +|---|---------|------|------|------| +| 1 | **团队考核查询** ⭐ | `/open-api/team-assessment/query` | GET | **Co-Claw AI 专用**:班组/人员考核聚合接口,含考勤/工单/耗材三维评分 | +| 2 | 人员查询 | `/open-api/staff/query` | GET | 获取人员档案,支持按部门筛选 | +| 3 | 班组查询 | `/open-api/teams/query` | GET | 获取班组信息,含班组长和人员数量 | +| 4 | 考勤查询 | `/open-api/attendance/query` | GET | 获取考勤打卡数据 | +| 5 | 工单查询 | `/open-api/tasks/query` | GET | 获取工单全生命周期数据 | +| 6 | 物料查询 | `/open-api/materials/query` | GET | 获取物料消耗与照片数据 | +| 7 | 全量工单统计 | `/open-api/full-order-statistics/query` | GET | 获取公司/班组工单/巡查/养护完成数量 | + +### 1.3 接入前提 + +接入前需向 UrbanOps 平台申请以下凭证(由运维人员配置到 Redis): + +| 参数 | 说明 | +|------|------| +| `appId` | 应用唯一标识(由平台分配) | +| `appSecret` | 应用密钥(妥善保管,不可泄露) | + +--- + +## 2. 签名鉴权机制 + +### 2.1 签名参数 + +每个请求必须携带以下 **4 个请求头**: + +| 请求头 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| `appId` | String | ✅ | 平台分配的应用 ID | +| `timestamp` | String | ✅ | 当前 Unix 时间戳(**毫秒**),与服务器时间误差不能超过 60 秒 | +| `nonce` | String | ✅ | 随机字符串,**长度 ≥ 10**,每次请求必须不同(防重放) | +| `sign` | String | ✅ | 签名字符串(SHA256 计算结果,**小写十六进制**) | + +### 2.2 签名算法 + +#### 签名字符串构造规则 + +``` +签名字符串 = queryParams(按key字母序排列的value拼接) + + headerParams(按key字母序排列的value拼接) + + appSecret +``` + +**具体步骤:** + +1. **排序 Query 参数值**:将 URL 中所有 query 参数按 key 字母升序排列,拼接所有 value +2. **拼接 Header 参数值**:按 key 字母升序(即 `appId` → `nonce` → `timestamp`),依次拼接它们的值 +3. **追加 appSecret**:在最后拼接 `appSecret` +4. **计算 SHA256**:对上述完整字符串计算 SHA256 哈希,转为**小写十六进制**字符串 + +#### 签名公式 + +``` +sign = SHA256( + sortedQueryParamValues + + appId值 + nonce值 + timestamp值 + + appSecret +) +``` + +> **注意**:GET 请求无 body,body 部分为空字符串(不参与签名)。 + +### 2.3 签名示例 + +假设: +- `appId` = `my_app_001` +- `appSecret` = `abc123xyz` +- 请求 URL:`/open-api/staff/query?deptId=100` + +**步骤拆解:** + +``` +queryParams(sorted by key): + deptId=100 + → values = "100" + +headerParams(sorted by key): + appId=my_app_001, nonce=a1b2c3d4e5f6, timestamp=1749696000000 + → values = "my_app_001" + "a1b2c3d4e5f6" + "1749696000000" + +signStr = "100" + "my_app_001" + "a1b2c3d4e5f6" + "1749696000000" + "abc123xyz" + +sign = SHA256(signStr) +``` + +### 2.4 签名校验规则 + +| 校验项 | 规则 | 失败返回 | +|--------|------|----------| +| appId 非空 | 必须传递 | `400 签名不正确` | +| timestamp 时效 | `|serverTime - timestamp| ≤ 60s` | `400 签名不正确` | +| nonce 长度 | `len(nonce) ≥ 10` | `400 签名不正确` | +| sign 非空 | 必须传递 | `400 签名不正确` | +| 签名比对 | `serverSign == clientSign` | `400 签名不正确` | +| nonce 防重放 | 同一 appId + nonce 组合只能使用一次 | `900 存在重复请求` | + +--- + +## 3. 通用规范 + +### 3.1 通用响应格式 + +```json +{ + "code": 0, + "msg": "成功", + "data": [ ... ] +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `code` | Integer | `0` 表示成功,非 `0` 表示失败 | +| `msg` | String | 提示信息 | +| `data` | Array | 业务数据列表(根据查询条件返回所有匹配记录) | + +### 3.2 日期时间格式 + +所有日期时间字段统一使用格式:`yyyy-MM-dd HH:mm:ss` + +示例:`2026-06-11 09:00:00` + +--- + +## 4. 接口 1:团队考核查询(Co-Claw AI 专用)⭐ + +> **设计定位**:本接口专为 Co-Claw AI 助手设计,提供班组/人员考核数据的**一站式聚合查询**。 +> 接口内部完成考勤、工单、耗材三维度评分计算,Co-Claw 可直接使用返回的得分和扣分明细进行考核分析, +> 无需自行计算。默认只返回汇总指标(`includeDetail=false`),Token 友好。 + +### 4.1 考核背景 + +本接口基于《房山区园林所养护队智慧化管理制度》设计,考核满分 100 分,三大维度: + +| 维度 | 满分 | 主要扣分规则 | +|------|------|-------------| +| 工单上线率与及时率 | **40分** | 超24h补录 -1分/次 · 超时>2h -1分/次 | +| 人员考勤管理 | **30分** | 缺勤/未打卡 -1分/次 · 缺共同处理人 -1分/次 · 未佩戴工卡 -1分/次 · 班组长记录仪未开 -1分/次 | +| 人机材录入 | **30分** | 工单完成24h内未上报耗材 -2分/次 | + +### 4.2 基本信息 + +| 项目 | 说明 | +|------|------| +| 路径 | `/open-api/team-assessment/query` | +| 方法 | `GET` | +| 说明 | 团队考核聚合查询。支持 **team 模式**(班组→人员+工单→考勤+耗材,三层结构)和 **staff 模式**(人员→考勤+工单→耗材) | + +### 4.3 请求参数 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `keyword` | String | **是** | — | 查询关键字:班组名称或人员姓名,**支持模糊匹配** | +| `queryMode` | String | 否 | `team` | 查询模式:`team` 按班组查询 / `staff` 按人员查询 | +| `startDate` | String | 否 | 当月1日 | 考核开始日期(格式:yyyy-MM-dd) | +| `endDate` | String | 否 | 今天 | 考核结束日期(格式:yyyy-MM-dd) | +| `includeDetail` | Boolean | 否 | `false` | 是否返回明细数据。`false` 仅返回汇总指标(推荐),`true` 同时返回考勤明细和工单耗材明细 | + +### 4.4 响应字段(queryMode=team) + +#### 顶层结构 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `queryMode` | String | 查询模式(`team` 或 `staff`) | +| `period` | PeriodVO | 考核周期 | +| `teams` | Array\ | 班组考核列表(team 模式) | +| `staffList` | Array\ | 人员考核列表(staff 模式,与 teams 互斥) | + +#### TeamVO — 班组考核(第一层) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `teamId` | Long | 班组ID | +| `teamName` | String | 班组名称 | +| `parentTeamName` | String | 上级部门名称(所属养护所) | +| `leaderName` | String | 班组长姓名 | +| `totalMembers` | Long | 班组人数 | +| `score` | ScoreVO | 考核得分(详见下方) | +| `orderStats` | OrderStatsVO | 工单统计 | +| `attendanceStats` | AttendanceStatsVO | 考勤统计 | +| `materialStats` | MaterialStatsVO | 耗材统计 | +| `members` | Array\ | 人员列表(**第二层**,含个人考核指标) | +| `orders` | Array\ | 工单列表(**第二层**,含耗材) | + +#### ScoreVO — 考核得分 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `total` | Integer | 总分(满分100) | +| `orderScore` | Integer | 工单得分(满分40) | +| `attendanceScore` | Integer | 考勤得分(满分30) | +| `materialScore` | Integer | 人机材得分(满分30) | +| `deductions` | Array\ | 扣分明细列表 | + +**DeductionVO:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `rule` | String | 扣分规则描述 | +| `count` | Integer | 触发次数 | +| `points` | Integer | 扣分(负数) | + +#### OrderStatsVO — 工单统计 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `totalOrders` | Long | 总工单数 | +| `completedOrders` | Long | 已完成工单数 | +| `completionRate` | BigDecimal | 完成率(%) | +| `onlineRate` | BigDecimal | 线上化率(%) | +| `responseWithin30Min` | Long | 30分钟内响应数 | +| `responseRate30Min` | BigDecimal | 30分钟响应率(%) | +| `overdueOrders` | Long | 超期工单数 | +| `lateSupplementOrders` | Long | 超24小时补录工单数 | + +#### AttendanceStatsVO — 考勤统计 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `totalCheckins` | Long | 总打卡次数 | +| `absentCount` | Long | 缺勤人次 | +| `missedCheckinCount` | Long | 未触发电子围栏打卡次数 | +| `trajectoryLowCount` | Long | 轨迹覆盖率不达标次数 | +| `missingCoHandlerCount` | Long | 缺共同处理人次数 | +| `missingCardCount` | Long | 未佩戴工卡次数 | +| `multiCardViolation` | Boolean | 是否存在一人多卡违规 | +| `leaderRecorderOffCount` | Long | 班组长记录仪未开机次数 | + +#### MaterialStatsVO — 耗材统计 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ordersWithMaterial` | Long | 已上报耗材的工单数 | +| `ordersMissingMaterial` | Long | 缺耗材上报的工单数 | +| `totalMaterialItems` | Long | 耗材总用量 | +| `materialTypes` | Long | 耗材种类数 | + +#### MemberVO — 人员信息(第二层) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `userId` | Long | 人员ID | +| `userName` | String | 姓名 | +| `mobile` | String | 手机号 | +| `postNames` | String | 岗位名称(逗号分隔) | +| `cardNumber` | String | 工卡号 | +| `isInner` | Integer | 是否内部人员(1=内部,2=外部) | +| `personalScore` | PersonalScoreVO | 个人考核指标 | +| `attendances` | Array\ | 考勤明细(**第三层**,`includeDetail=true` 时返回) | +| `orders` | Array\ | 个人工单(**第三层**,`includeDetail=true` 时返回) | + +#### PersonalScoreVO — 个人考核指标 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `attendanceDays` | Long | 出勤天数 | +| `lateCount` | Long | 迟到次数 | +| `absentCount` | Long | 缺勤次数 | +| `assignedOrders` | Long | 承担工单数 | +| `completedOrders` | Long | 完成工单数 | +| `completionRate` | BigDecimal | 完成率(%) | +| `overdueOrders` | Long | 超期工单数 | +| `materialReported` | Long | 已上报耗材工单数 | +| `materialMissing` | Long | 缺耗材上报工单数 | + +#### OrderVO — 工单信息(第二层/第三层) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `orderNo` | String | 工单号 | +| `orderName` | String | 工单名称 | +| `orderType` | String | 工单类型:Q=快速,C=普通,O=其他 | +| `workerName` | String | 接单人姓名 | +| `status` | Integer | 审批状态 | +| `buzStatus` | String | 业务状态(completed=已完成) | +| `commitDate` | String | 发布时间 | +| `finishDate` | String | 完成时间 | +| `expectedFinishDate` | String | 期望完成时间 | +| `responseMinutes` | Long | 响应时长(分钟) | +| `isOverdue` | Boolean | 是否超期(`finishDate > expectedFinishDate`) | +| `isLateSupplement` | Boolean | 是否超24小时补录 | +| `hasMaterialReported` | Boolean | 是否已上报耗材 | +| `coHandlers` | Array\ | 共同处理人姓名 | +| `materials` | Array\ | 耗材明细(**第三层**,`includeDetail=true` 时返回) | + +#### 其他嵌套 VO + +**AttendanceRecordVO:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `punchType` | String | 打卡类型(上班/下班) | +| `punchTime` | String | 打卡时间 | +| `address` | String | 打卡地址 | + +**MaterialVO:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `materialName` | String | 耗材名称 | +| `specifications` | String | 规格 | +| `userCount` | Long | 使用数量 | +| `unitName` | String | 单位 | +| `classifyName` | String | 分类 | + +**PeriodVO:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `start` | String | 考核开始日期(yyyy-MM-dd) | +| `end` | String | 考核结束日期(yyyy-MM-dd) | + +### 4.5 请求示例 + +```bash +# team 模式:按班组名称查询考核数据(仅汇总) +curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/team-assessment/query?keyword=养护一班&queryMode=team&startDate=2026-06-01&endDate=2026-06-30" \ + -H "appId: my_app_001" \ + -H "timestamp: 1749696000000" \ + -H "nonce: c3d4e5f6g7h8i9j0" \ + -H "sign: ..." + +# staff 模式:按人员姓名查询考核数据(含考勤明细和工单耗材) +curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/team-assessment/query?keyword=张三&queryMode=staff&includeDetail=true" \ + -H "appId: my_app_001" \ + -H "timestamp: 1749696000000" \ + -H "nonce: d4e5f6g7h8i9j0k1" \ + -H "sign: ..." +``` + +### 4.6 响应示例(team 模式,includeDetail=false) + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "queryMode": "team", + "period": { + "start": "2026-06-01", + "end": "2026-06-30" + }, + "teams": [ + { + "teamId": 100, + "teamName": "养护一班", + "parentTeamName": "房山区园林所", + "leaderName": "张三", + "totalMembers": 15, + "score": { + "total": 85, + "orderScore": 34, + "attendanceScore": 25, + "materialScore": 26, + "deductions": [ + { "rule": "工单超24小时补录", "count": 1, "points": -1 }, + { "rule": "工单超时>2小时", "count": 2, "points": -2 }, + { "rule": "缺勤/未打卡", "count": 5, "points": -5 }, + { "rule": "人机材24h内未上报", "count": 1, "points": -2 } + ] + }, + "orderStats": { + "totalOrders": 45, + "completedOrders": 42, + "completionRate": 93.3, + "onlineRate": 97.8, + "responseWithin30Min": 40, + "responseRate30Min": 88.9, + "overdueOrders": 2, + "lateSupplementOrders": 1 + }, + "attendanceStats": { + "totalCheckins": 570, + "absentCount": 5, + "missedCheckinCount": 5, + "trajectoryLowCount": 1, + "missingCoHandlerCount": 0, + "missingCardCount": 0, + "multiCardViolation": false, + "leaderRecorderOffCount": 0 + }, + "materialStats": { + "ordersWithMaterial": 42, + "ordersMissingMaterial": 1, + "totalMaterialItems": 230, + "materialTypes": 15 + }, + "members": [ + { + "userId": 1001, + "userName": "张三", + "mobile": "13800138001", + "postNames": "班组长,技术员", + "cardNumber": "DEV001", + "isInner": 1, + "personalScore": { + "attendanceDays": 28, + "lateCount": 0, + "absentCount": 0, + "assignedOrders": 8, + "completedOrders": 8, + "completionRate": 100.0, + "overdueOrders": 0, + "materialReported": 8, + "materialMissing": 0 + } + } + ], + "orders": [ + { + "orderNo": "WO202606250001", + "orderName": "树木修剪", + "orderType": "C", + "workerName": "张三", + "status": 1, + "buzStatus": "completed", + "commitDate": "2026-06-25 09:00:00", + "finishDate": "2026-06-25 16:30:00", + "expectedFinishDate": "2026-06-25 18:00:00", + "responseMinutes": 15, + "isOverdue": false, + "isLateSupplement": false, + "hasMaterialReported": true, + "coHandlers": ["李四"] + } + ] + } + ] + } +} +``` + +### 4.7 响应示例(staff 模式,includeDetail=true) + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "queryMode": "staff", + "period": { + "start": "2026-06-01", + "end": "2026-06-25" + }, + "staffList": [ + { + "userId": 1001, + "userName": "张三", + "mobile": "13800138001", + "deptId": 100, + "deptName": "养护一班", + "postNames": "班组长", + "cardNumber": "DEV001", + "isInner": 1, + "assessment": { + "attendanceDays": 28, + "lateCount": 0, + "absentCount": 0, + "assignedOrders": 8, + "completedOrders": 8, + "completionRate": 100.0, + "overdueOrders": 0, + "materialReported": 8, + "materialMissing": 0 + }, + "attendances": [ + { "punchType": "上班", "punchTime": "2026-06-25 08:00:00", "address": "北京市房山区xx路" }, + { "punchType": "下班", "punchTime": "2026-06-25 17:30:00", "address": "北京市房山区xx路" } + ], + "orders": [ + { + "orderNo": "WO202606250001", + "orderName": "树木修剪", + "orderType": "C", + "workerName": "张三", + "status": 1, + "buzStatus": "completed", + "commitDate": "2026-06-25 09:00:00", + "finishDate": "2026-06-25 16:30:00", + "expectedFinishDate": "2026-06-25 18:00:00", + "responseMinutes": 15, + "isOverdue": false, + "isLateSupplement": false, + "hasMaterialReported": true, + "coHandlers": ["李四"], + "materials": [ + { + "materialName": "高效氯氰菊酯", + "specifications": "500ml/瓶", + "userCount": 2, + "unitName": "瓶", + "classifyName": "药品" + } + ] + } + ] + } + ] + } +} +``` + +### 4.8 使用建议(Co-ClawCo-Claw) + +| 场景 | 推荐参数 | 说明 | +|------|----------|------| +| 月底考核排名 | `keyword=养护&queryMode=team` | 模糊查询所有养护班组,返回考核得分用于排名 | +| 单班组详情分析 | `keyword=养护一班&queryMode=team&includeDetail=true` | 查看特定班组全部明细,分析扣分原因 | +| 个人绩效查询 | `keyword=张三&queryMode=staff&includeDetail=true` | 查看某人出勤、工单、耗材完整记录 | +| 日常快速巡检 | `keyword=养护&includeDetail=false` | 只看汇总指标,Token 最少 | + +--- + +## 5. 接口 2:人员查询 + +### 4.1 基本信息 + +| 项目 | 说明 | +|------|------| +| 路径 | `/open-api/staff/query` | +| 方法 | `GET` | +| 说明 | 获取人员基础档案信息,支持按部门、姓名、手机号等条件筛选 | + +### 4.2 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | String | 否 | 姓名,模糊匹配 | +| `mobile` | String | 否 | 手机号,模糊匹配 | +| `deptId` | Long | 否 | 部门ID,精确匹配 | +| `status` | Integer | 否 | 状态:0=启用,1=禁用 | +| `busiLine` | String | 否 | 业务线:yl=园林,wy=物业,sz=市政 | + +### 4.3 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | Long | 人员ID | +| `username` | String | 用户名 | +| `nickname` | String | 姓名 | +| `mobile` | String | 手机号 | +| `deptId` | Long | 部门ID | +| `deptName` | String | 部门名称(如"养护一班") | +| `postIds` | Array\ | 岗位ID列表 | +| `postNames` | String | 岗位名称(逗号分隔,如"班组长,技术员") | +| `busiLine` | String | 业务线 | +| `status` | Integer | 状态(0=启用,1=禁用) | +| `cardNumber` | String | 工卡号(设备编号,用于考勤打卡) | +| `isInner` | Integer | 是否内部人员(1=内部,2=外部) | +| `createTime` | String | 入职日期(格式:yyyy-MM-dd HH:mm:ss) | + +### 4.4 请求示例 + +```bash +curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/staff/query?deptId=100" \ + -H "appId: my_app_001" \ + -H "timestamp: 1749696000000" \ + -H "nonce: a1b2c3d4e5f6g7h8" \ + -H "sign: 3a5f8c9e2b1d4f6a8c0e2b4d6f8a0c2e4b6d8f0a" +``` + +### 4.5 响应示例 + +```json +{ + "code": 0, + "msg": "成功", + "data": [ + { + "id": 1001, + "username": "zhangsan", + "nickname": "张三", + "mobile": "13800138001", + "deptId": 100, + "deptName": "养护一班", + "postIds": [10, 20], + "postNames": "班组长,技术员", + "busiLine": "yl", + "status": 0, + "cardNumber": "DEV001", + "isInner": 1, + "createTime": "2024-01-15 00:00:00" + } + ], + "total": 150 + } +``` + +--- + +## 6. 接口 3:班组查询 + +### 5.1 基本信息 + +| 项目 | 说明 | +|------|------| +| 路径 | `/open-api/teams/query` | +| 方法 | `GET` | +| 说明 | 获取组织架构班组信息,含班组长、所属养护所、人员数量 | + +### 5.2 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | String | 否 | 班组名称,模糊匹配 | +| `parentId` | Long | 否 | 父部门ID(上级组织),精确匹配 | +| `status` | Integer | 否 | 状态:0=启用,1=禁用 | + +### 5.3 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | Long | 班组ID | +| `name` | String | 班组名称 | +| `parentId` | Long | 父部门ID | +| `parentName` | String | 父部门名称(所属养护所) | +| `leaderUserId` | Long | 班组长人员ID(Staff.id) | +| `leaderUserName` | String | 班组长姓名 | +| `memberCount` | Long | 人员数量 | +| `sort` | Integer | 排序 | +| `status` | Integer | 状态(0=启用,1=禁用) | + +### 5.4 请求示例 + +```bash +curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/teams/query" \ + -H "appId: my_app_001" \ + -H "timestamp: 1749696000000" \ + -H "nonce: b2c3d4e5f6g7h8i9" \ + -H "sign: ..." +``` + +### 5.5 响应示例 + +```json +{ + "code": 0, + "msg": "成功", + "data": [ + { + "id": 100, + "name": "养护一班", + "parentId": 1, + "parentName": "蓟城山水集团", + "leaderUserId": 1001, + "leaderUserName": "张三", + "memberCount": 15, + "sort": 1, + "status": 0 + } + ], + "total": 12 + } +} +``` + +--- + +## 7. 接口 4:考勤查询 + +### 6.1 基本信息 + +| 项目 | 说明 | +|------|------| +| 路径 | `/open-api/attendance/query` | +| 方法 | `GET` | +| 说明 | 获取电子围栏打卡与位置数据,支持按班组、人员、时间范围筛选 | + +### 6.2 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `userId` | Long | 否 | 人员ID,精确匹配 | +| `deptId` | Long | 否 | 部门ID(班组),精确匹配 | +| `busiLine` | String | 否 | 业务线 | +| `punchType` | String | 否 | 打卡类型(上班/下班) | +| `startTime` | String | 否 | 开始时间(格式:yyyy-MM-dd HH:mm:ss) | +| `endTime` | String | 否 | 结束时间(格式:yyyy-MM-dd HH:mm:ss) | + +### 6.3 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | Integer | 记录ID | +| `deviceCode` | String | 设备编码 | +| `deviceName` | String | 设备名称 | +| `punchType` | String | 打卡类型 | +| `userId` | Long | 人员ID | +| `nickname` | String | 人员姓名 | +| `deptId` | Long | 部门ID | +| `deptName` | String | 部门名称 | +| `address` | String | 打卡地址 | +| `latGcj02` | BigDecimal | 高德纬度(GCJ-02坐标系) | +| `lngGcj02` | BigDecimal | 高德经度(GCJ-02坐标系) | +| `lat` | BigDecimal | WGS84纬度 | +| `lng` | BigDecimal | WGS84经度 | +| `createTime` | String | 打卡时间 | + +> **注意**:当前版本暂不支持"轨迹点集""是否佩戴工卡""记录仪状态"字段(数据库无对应存储)。待业务系统确认后将补充。 + +### 6.4 响应示例 + +```json +{ + "code": 0, + "msg": "成功", + "data": [ + { + "id": 1, + "deviceCode": "DEV001", + "deviceName": "打卡机01", + "punchType": "上班", + "userId": 1001, + "nickname": "张三", + "deptId": 100, + "deptName": "养护一班", + "address": "北京市西城区xx路xx号", + "latGcj02": 39.9042, + "lngGcj02": 116.4074, + "lat": 39.9010, + "lng": 116.4030, + "createTime": "2026-06-11 08:30:00" + } + ], + "total": 320 + } +} +``` + +--- + +## 8. 接口 5:工单查询 + +### 7.1 基本信息 + +| 项目 | 说明 | +|------|------| +| 路径 | `/open-api/tasks/query` | +| 方法 | `GET` | +| 说明 | 获取工单全生命周期数据,含发布时间、状态、接单渠道、共同处理人 | + +### 7.2 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `orderNo` | String | 否 | 工单号,模糊匹配 | +| `orderName` | String | 否 | 工单名称,模糊匹配 | +| `busiLine` | String | 否 | 业务线 | +| `status` | Integer | 否 | 审批状态 | +| `buzStatus` | String | 否 | 业务状态 | +| `workerId` | Long | 否 | 接单人ID | +| `startDate` | String | 否 | 发布时间起始(yyyy-MM-dd HH:mm:ss) | +| `endDate` | String | 否 | 发布时间截止(yyyy-MM-dd HH:mm:ss) | + +### 7.3 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | Long | 工单ID | +| `orderNo` | String | 工单号 | +| `orderName` | String | 工单名称 | +| `orderType` | String | 工单类型:Q=快速,C=普通,O=其他 | +| `busiLine` | String | 业务线 | +| `commitDate` | String | 发布时间(工单派发时间) | +| `finishDate` | String | 完成时间 | +| `userId` | Long | 发布人ID | +| `userName` | String | 发布人姓名 | +| `workerId` | Long | 接单人ID | +| `workerName` | String | 接单人姓名 | +| `coHandlers` | Array\ | 共同处理人姓名列表 | +| `woSourceName` | String | 接单渠道(小程序/人工派单等) | +| `status` | Integer | 审批状态 | +| `buzStatus` | String | 业务状态 | +| `remark` | String | 备注 | +| `photos` | Array\ | 工单附件照片 | + +**PhotoVO 结构:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `busiType` | String | 业务类型:`01`=问题照片,`02`=处理中,`03`=完成 | +| `fileName` | String | 文件名 | +| `url` | String | 文件URL | + +### 7.4 响应示例 + +```json +{ + "code": 0, + "msg": "成功", + "data": [ + { + "id": 5001, + "orderNo": "WO202606110001", + "orderName": "树木修剪", + "orderType": "C", + "busiLine": "yl", + "commitDate": "2026-06-11 09:00:00", + "finishDate": null, + "userId": 1, + "userName": "管理员", + "workerId": 1001, + "workerName": "张三", + "coHandlers": ["李四", "王五"], + "woSourceName": "小程序", + "status": 1, + "buzStatus": "processing", + "remark": "紧急处理", + "photos": [ + { + "busiType": "01", + "fileName": "problem.jpg", + "url": "https://xxx.com/file/abc.jpg" + } + ] + } + ], + "total": 85 + } +} +``` + +--- + +## 9. 接口 6:物料查询 + +### 8.1 基本信息 + +| 项目 | 说明 | +|------|------| +| 路径 | `/open-api/materials/query` | +| 方法 | `GET` | +| 说明 | 获取物料消耗记录与人机材照片数据,支持按工单筛选 | + +### 8.2 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `orderNo` | String | 否 | 工单号,精确匹配 | +| `classifyName` | String | 否 | 物料分类名,模糊匹配 | +| `typeName` | String | 否 | 物料类型名,模糊匹配 | +| `userId` | Long | 否 | 使用人ID | +| `startDate` | String | 否 | 开始时间(yyyy-MM-dd HH:mm:ss) | +| `endDate` | String | 否 | 结束时间(yyyy-MM-dd HH:mm:ss) | + +### 8.3 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | Long | 记录ID | +| `orderNo` | String | 关联工单号 | +| `materialName` | String | 物料名称 | +| `specifications` | String | 规格 | +| `userCount` | Long | 使用数量 | +| `unitName` | String | 单位 | +| `classifyName` | String | 物料分类 | +| `typeName` | String | 物料类型 | +| `typeDetailName` | String | 物料明细类型 | +| `userId` | Long | 使用人ID | +| `userName` | String | 使用人姓名 | +| `source` | Integer | 来源:1=工单,2=巡检,3=养护 | +| `createTime` | String | 上传时间 | +| `photos` | Array\ | 人机材照片(结构同工单接口) | + +### 8.4 响应示例 + +```json +{ + "code": 0, + "msg": "成功", + "data": [ + { + "id": 3001, + "orderNo": "WO202606110001", + "materialName": "高效氯氰菊酯", + "specifications": "500ml/瓶", + "userCount": 5, + "unitName": "瓶", + "classifyName": "药品", + "typeName": "杀虫剂", + "typeDetailName": "有机磷", + "userId": 1001, + "userName": "张三", + "source": 1, + "createTime": "2026-06-11 14:30:00", + "photos": [ + { + "busiType": "02", + "fileName": "material.jpg", + "url": "https://xxx.com/file/def.jpg" + } + ] + } + ], + "total": 45 + } +} +``` + +--- + +## 10. 接口 7:全量工单统计 + +### 9.1 基本信息 + +| 项目 | 说明 | +|------|------| +| 路径 | `/open-api/full-order-statistics/query` | +| 方法 | `GET` | +| 说明 | 获取各公司/班组在一段时间内实际完成的工单数量、巡查记录数量、养护记录数量 | + +### 9.2 请求参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `companyId` | Long | 否 | 公司/班组ID,精确匹配 | +| `companyName` | String | 否 | 公司/班组名称,模糊匹配 | +| `type` | Integer | 否 | 类型:1=按公司,2=按班组 | +| `startDate` | String | 否 | 统计开始日期(格式:yyyy-MM-dd,返回开始日期>=该值的记录) | +| `endDate` | String | 否 | 统计结束日期(格式:yyyy-MM-dd,返回结束日期<=该值的记录) | + +### 9.3 响应字段 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | Long | 记录ID | +| `companyId` | Long | 公司/班组ID | +| `companyName` | String | 公司/班组名称 | +| `type` | Integer | 类型:1=按公司,2=按班组 | +| `startDate` | String | 统计开始日期 | +| `endDate` | String | 统计结束日期 | +| `workOrderCount` | Integer | 工单完成数量 | +| `inspectionCount` | Integer | 巡查记录数量 | +| `maintenanceCount` | Integer | 养护记录数量 | +| `remark` | String | 备注 | +| `createTime` | String | 创建时间 | + +### 9.4 请求示例 + +```bash +curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/full-order-statistics/query?type=1&startDate=2026-06-01&endDate=2026-06-30" \ + -H "appId: my_app_001" \ + -H "timestamp: 1749696000000" \ + -H "nonce: f6g7h8i9j0k1l2m3" \ + -H "sign: ..." +``` + +### 9.5 响应示例 + +```json +{ + "code": 0, + "msg": "成功", + "data": [ + { + "id": 1, + "companyId": 100, + "companyName": "养护一班", + "type": 1, + "startDate": "2026-06-01", + "endDate": "2026-06-30", + "workOrderCount": 85, + "inspectionCount": 120, + "maintenanceCount": 60, + "remark": "二季度统计", + "createTime": "2026-06-11 10:00:00" + } + ] +} +``` + +--- + +## 11. 错误码说明 + +### 11.1 签名相关错误 + +| code | msg | 说明 | +|------|-----|------| +| 400 | 签名不正确 | 签名验证失败(appId不存在/timestamp超时/nonce长度不足/签名不匹配) | +| 900 | 存在重复请求 | nonce 已被使用(重放攻击检测) | + +### 11.2 业务接口错误 + +| code | msg | 说明 | +|------|-----|------| +| 1-100-020-001 | 人员查询失败 | 人员查询内部错误 | +| 1-100-020-002 | 班组查询失败 | 班组查询内部错误 | +| 1-100-020-003 | 考勤查询失败 | 考勤查询内部错误 | +| 1-100-020-004 | 工单查询失败 | 工单查询内部错误 | +| 1-100-020-005 | 物料查询失败 | 物料查询内部错误 | +| 1-100-020-006 | 团队考核查询失败 | 团队考核聚合查询内部错误 | +| 1-900-005-001 | 全量工单统计不存在 | 记录不存在 | +| 1-900-005-002 | 相同公司/班组同时间段统计已存在 | 重复数据校验 | + +### 11.3 通用错误 + +| code | msg | 说明 | +|------|-----|------| +| 400 | 参数校验失败 | 请求参数格式或值不正确 | +| 500 | 系统内部错误 | 服务器内部异常 | + +--- + +## 12. 附录:客户端签名代码示例 + +### 12.1 Java 示例 + +```java +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.core.util.RandomUtil; + +import java.util.Map; +import java.util.TreeMap; + +public class OpenApiClient { + + private static final String BASE_URL = "https://test.jichengshanshui.com.cn:28302"; + private static final String APP_ID = "your_app_id"; + private static final String APP_SECRET = "your_app_secret"; + + public static String call(String path, Map params) { + String timestamp = String.valueOf(System.currentTimeMillis()); + String nonce = RandomUtil.randomString(16); + String sign = buildSign(params, timestamp, nonce); + + String url = BASE_URL + path; + HttpResponse response = HttpRequest.get(url) + .form(params) + .header("appId", APP_ID) + .header("timestamp", timestamp) + .header("nonce", nonce) + .header("sign", sign) + .execute(); + return response.body(); + } + + private static String buildSign(Map params, + String timestamp, String nonce) { + StringBuilder sb = new StringBuilder(); + + // 1. 排序 query 参数值 + if (params != null && !params.isEmpty()) { + new TreeMap<>(params).values().stream() + .map(String::valueOf).forEach(sb::append); + } + + // 2. header 参数值(appId, nonce, timestamp 字母序) + sb.append(APP_ID); + sb.append(nonce); + sb.append(timestamp); + + // 3. 追加 appSecret + SHA256 + sb.append(APP_SECRET); + return DigestUtil.sha256Hex(sb.toString()); + } +} +``` + +### 12.2 Python 示例 + +```python +import hashlib +import time +import random +import string +import requests + +BASE_URL = "https://test.jichengshanshui.com.cn:28302" +APP_ID = "your_app_id" +APP_SECRET = "your_app_secret" + +def build_sign(params, timestamp, nonce): + parts = [] + if params: + for key in sorted(params.keys()): + parts.append(str(params[key])) + parts.append(APP_ID) + parts.append(nonce) + parts.append(timestamp) + parts.append(APP_SECRET) + sign_str = "".join(parts) + return hashlib.sha256(sign_str.encode()).hexdigest() + +def call(path, params=None): + if params is None: + params = {} + timestamp = str(int(time.time() * 1000)) + nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=16)) + sign = build_sign(params, timestamp, nonce) + headers = { + "appId": APP_ID, + "timestamp": timestamp, + "nonce": nonce, + "sign": sign + } + response = requests.get(BASE_URL + path, params=params, headers=headers) + return response.json() +``` + +### 12.3 Shell (cURL) 示例 + +```bash +#!/bin/bash +APP_ID="your_app_id" +APP_SECRET="your_app_secret" +TIMESTAMP=$(date +%s%3N) +NONCE=$(openssl rand -hex 16) + +# 示例:/open-api/staff/query?deptId=100 +# query params 排序后 values: deptId=100 → "100" +QUERY_VALUES="100" +HEADER_VALUES="${APP_ID}${NONCE}${TIMESTAMP}" +SIGN_STR="${QUERY_VALUES}${HEADER_VALUES}${APP_SECRET}" +SIGN=$(echo -n "$SIGN_STR" | openssl dgst -sha256 -hex | awk '{print $2}') + +curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/staff/query?deptId=100" \ + -H "appId: ${APP_ID}" \ + -H "timestamp: ${TIMESTAMP}" \ + -H "nonce: ${NONCE}" \ + -H "sign: ${SIGN}" +``` + +--- + +> **本文档基于 2026-06-11 代码生成,如有接口变更请同步更新。** diff --git a/docs/开放API对接接口文档.md b/docs/开放API对接接口文档.md index 6216c18..5bc4de8 100644 --- a/docs/开放API对接接口文档.md +++ b/docs/开放API对接接口文档.md @@ -1,8 +1,8 @@ # UrbanOps 开放 API 对接接口文档 -> **版本**: v1.0 -> **更新日期**: 2026-06-11 -> **适用对象**: 第三方系统对接开发人员 +> **版本**: v1.1 +> **更新日期**: 2026-06-25 +> **适用对象**: 第三方系统对接开发人员 / Co-Claw AI 助手 > **鉴权方式**: HMAC-SHA256 API 签名 --- @@ -12,14 +12,15 @@ 1. [概述](#1-概述) 2. [签名鉴权机制](#2-签名鉴权机制) 3. [通用规范](#3-通用规范) -4. [接口 1:人员查询](#4-接口-1人员查询) -5. [接口 2:班组查询](#5-接口-2班组查询) -6. [接口 3:考勤查询](#6-接口-3考勤查询) -7. [接口 4:工单查询](#7-接口-4工单查询) -8. [接口 5:物料查询](#8-接口-5物料查询) -9. [接口 6:全量工单统计](#9-接口-6全量工单统计) -10. [错误码说明](#10-错误码说明) -11. [附录:客户端签名代码示例](#11-附录客户端签名代码示例) +4. [接口 1:团队考核查询(Co-Claw AI 专用)](#4-接口-1团队考核查询Co-Claw-ai-专用) +5. [接口 2:人员查询](#5-接口-2人员查询) +6. [接口 3:班组查询](#6-接口-3班组查询) +7. [接口 4:考勤查询](#7-接口-4考勤查询) +8. [接口 5:工单查询](#8-接口-5工单查询) +9. [接口 6:物料查询](#9-接口-6物料查询) +10. [接口 7:全量工单统计](#10-接口-7全量工单统计) +11. [错误码说明](#11-错误码说明) +12. [附录:客户端签名代码示例](#12-附录客户端签名代码示例) --- @@ -39,12 +40,13 @@ | # | 接口名称 | 路径 | 方法 | 说明 | |---|---------|------|------|------| -| 1 | 人员查询 | `/open-api/staff/query` | GET | 获取人员档案,支持按部门筛选 | -| 2 | 班组查询 | `/open-api/teams/query` | GET | 获取班组信息,含班组长和人员数量 | -| 3 | 考勤查询 | `/open-api/attendance/query` | GET | 获取考勤打卡数据 | -| 4 | 工单查询 | `/open-api/tasks/query` | GET | 获取工单全生命周期数据 | -| 5 | 物料查询 | `/open-api/materials/query` | GET | 获取物料消耗与照片数据 | -| 6 | 全量工单统计 | `/open-api/full-order-statistics/query` | GET | 获取公司/班组工单/巡查/养护完成数量 | +| 1 | **团队考核查询** ⭐ | `/open-api/team-assessment/query` | GET | **Co-Claw AI 专用**:班组/人员考核聚合接口,含考勤/工单/耗材三维评分 | +| 2 | 人员查询 | `/open-api/staff/query` | GET | 获取人员档案,支持按部门筛选 | +| 3 | 班组查询 | `/open-api/teams/query` | GET | 获取班组信息,含班组长和人员数量 | +| 4 | 考勤查询 | `/open-api/attendance/query` | GET | 获取考勤打卡数据 | +| 5 | 工单查询 | `/open-api/tasks/query` | GET | 获取工单全生命周期数据 | +| 6 | 物料查询 | `/open-api/materials/query` | GET | 获取物料消耗与照片数据 | +| 7 | 全量工单统计 | `/open-api/full-order-statistics/query` | GET | 获取公司/班组工单/巡查/养护完成数量 | ### 1.3 接入前提 @@ -161,7 +163,397 @@ sign = SHA256(signStr) --- -## 4. 接口 1:人员查询 +## 4. 接口 1:团队考核查询(Co-Claw AI 专用)⭐ + +> **设计定位**:本接口专为 Co-Claw AI 助手设计,提供班组/人员考核数据的**一站式聚合查询**。 +> 接口内部完成考勤、工单、耗材三维度评分计算,Co-Claw 可直接使用返回的得分和扣分明细进行考核分析, +> 无需自行计算。默认只返回汇总指标(`includeDetail=false`),Token 友好。 + +### 4.1 考核背景 + +本接口基于《房山区园林所养护队智慧化管理制度》设计,考核满分 100 分,三大维度: + +| 维度 | 满分 | 主要扣分规则 | +|------|------|-------------| +| 工单上线率与及时率 | **40分** | 超24h补录 -1分/次 · 超时>2h -1分/次 | +| 人员考勤管理 | **30分** | 缺勤/未打卡 -1分/次 · 缺共同处理人 -1分/次 · 未佩戴工卡 -1分/次 · 班组长记录仪未开 -1分/次 | +| 人机材录入 | **30分** | 工单完成24h内未上报耗材 -2分/次 | + +### 4.2 基本信息 + +| 项目 | 说明 | +|------|------| +| 路径 | `/open-api/team-assessment/query` | +| 方法 | `GET` | +| 说明 | 团队考核聚合查询。支持 **team 模式**(班组→人员+工单→考勤+耗材,三层结构)和 **staff 模式**(人员→考勤+工单→耗材) | + +### 4.3 请求参数 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| `keyword` | String | **是** | — | 查询关键字:班组名称或人员姓名,**支持模糊匹配** | +| `queryMode` | String | 否 | `team` | 查询模式:`team` 按班组查询 / `staff` 按人员查询 | +| `startDate` | String | 否 | 当月1日 | 考核开始日期(格式:yyyy-MM-dd) | +| `endDate` | String | 否 | 今天 | 考核结束日期(格式:yyyy-MM-dd) | +| `includeDetail` | Boolean | 否 | `false` | 是否返回明细数据。`false` 仅返回汇总指标(推荐),`true` 同时返回考勤明细和工单耗材明细 | + +### 4.4 响应字段(queryMode=team) + +#### 顶层结构 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `queryMode` | String | 查询模式(`team` 或 `staff`) | +| `period` | PeriodVO | 考核周期 | +| `teams` | Array\ | 班组考核列表(team 模式) | +| `staffList` | Array\ | 人员考核列表(staff 模式,与 teams 互斥) | + +#### TeamVO — 班组考核(第一层) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `teamId` | Long | 班组ID | +| `teamName` | String | 班组名称 | +| `parentTeamName` | String | 上级部门名称(所属养护所) | +| `leaderName` | String | 班组长姓名 | +| `totalMembers` | Long | 班组人数 | +| `score` | ScoreVO | 考核得分(详见下方) | +| `orderStats` | OrderStatsVO | 工单统计 | +| `attendanceStats` | AttendanceStatsVO | 考勤统计 | +| `materialStats` | MaterialStatsVO | 耗材统计 | +| `members` | Array\ | 人员列表(**第二层**,含个人考核指标) | +| `orders` | Array\ | 工单列表(**第二层**,含耗材) | + +#### ScoreVO — 考核得分 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `total` | Integer | 总分(满分100) | +| `orderScore` | Integer | 工单得分(满分40) | +| `attendanceScore` | Integer | 考勤得分(满分30) | +| `materialScore` | Integer | 人机材得分(满分30) | +| `deductions` | Array\ | 扣分明细列表 | + +**DeductionVO:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `rule` | String | 扣分规则描述 | +| `count` | Integer | 触发次数 | +| `points` | Integer | 扣分(负数) | + +#### OrderStatsVO — 工单统计 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `totalOrders` | Long | 总工单数 | +| `completedOrders` | Long | 已完成工单数 | +| `completionRate` | BigDecimal | 完成率(%) | +| `onlineRate` | BigDecimal | 线上化率(%) | +| `responseWithin30Min` | Long | 30分钟内响应数 | +| `responseRate30Min` | BigDecimal | 30分钟响应率(%) | +| `overdueOrders` | Long | 超期工单数 | +| `lateSupplementOrders` | Long | 超24小时补录工单数 | + +#### AttendanceStatsVO — 考勤统计 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `totalCheckins` | Long | 总打卡次数 | +| `absentCount` | Long | 缺勤人次 | +| `missedCheckinCount` | Long | 未触发电子围栏打卡次数 | +| `trajectoryLowCount` | Long | 轨迹覆盖率不达标次数 | +| `missingCoHandlerCount` | Long | 缺共同处理人次数 | +| `missingCardCount` | Long | 未佩戴工卡次数 | +| `multiCardViolation` | Boolean | 是否存在一人多卡违规 | +| `leaderRecorderOffCount` | Long | 班组长记录仪未开机次数 | + +#### MaterialStatsVO — 耗材统计 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ordersWithMaterial` | Long | 已上报耗材的工单数 | +| `ordersMissingMaterial` | Long | 缺耗材上报的工单数 | +| `totalMaterialItems` | Long | 耗材总用量 | +| `materialTypes` | Long | 耗材种类数 | + +#### MemberVO — 人员信息(第二层) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `userId` | Long | 人员ID | +| `userName` | String | 姓名 | +| `mobile` | String | 手机号 | +| `postNames` | String | 岗位名称(逗号分隔) | +| `cardNumber` | String | 工卡号 | +| `isInner` | Integer | 是否内部人员(1=内部,2=外部) | +| `personalScore` | PersonalScoreVO | 个人考核指标 | +| `attendances` | Array\ | 考勤明细(**第三层**,`includeDetail=true` 时返回) | +| `orders` | Array\ | 个人工单(**第三层**,`includeDetail=true` 时返回) | + +#### PersonalScoreVO — 个人考核指标 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `attendanceDays` | Long | 出勤天数 | +| `lateCount` | Long | 迟到次数 | +| `absentCount` | Long | 缺勤次数 | +| `assignedOrders` | Long | 承担工单数 | +| `completedOrders` | Long | 完成工单数 | +| `completionRate` | BigDecimal | 完成率(%) | +| `overdueOrders` | Long | 超期工单数 | +| `materialReported` | Long | 已上报耗材工单数 | +| `materialMissing` | Long | 缺耗材上报工单数 | + +#### OrderVO — 工单信息(第二层/第三层) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `orderNo` | String | 工单号 | +| `orderName` | String | 工单名称 | +| `orderType` | String | 工单类型:Q=快速,C=普通,O=其他 | +| `workerName` | String | 接单人姓名 | +| `status` | Integer | 审批状态 | +| `buzStatus` | String | 业务状态(completed=已完成) | +| `commitDate` | String | 发布时间 | +| `finishDate` | String | 完成时间 | +| `expectedFinishDate` | String | 期望完成时间 | +| `responseMinutes` | Long | 响应时长(分钟) | +| `isOverdue` | Boolean | 是否超期(`finishDate > expectedFinishDate`) | +| `isLateSupplement` | Boolean | 是否超24小时补录 | +| `hasMaterialReported` | Boolean | 是否已上报耗材 | +| `coHandlers` | Array\ | 共同处理人姓名 | +| `materials` | Array\ | 耗材明细(**第三层**,`includeDetail=true` 时返回) | + +#### 其他嵌套 VO + +**AttendanceRecordVO:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `punchType` | String | 打卡类型(上班/下班) | +| `punchTime` | String | 打卡时间 | +| `address` | String | 打卡地址 | + +**MaterialVO:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `materialName` | String | 耗材名称 | +| `specifications` | String | 规格 | +| `userCount` | Long | 使用数量 | +| `unitName` | String | 单位 | +| `classifyName` | String | 分类 | + +**PeriodVO:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `start` | String | 考核开始日期(yyyy-MM-dd) | +| `end` | String | 考核结束日期(yyyy-MM-dd) | + +### 4.5 请求示例 + +```bash +# team 模式:按班组名称查询考核数据(仅汇总) +curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/team-assessment/query?keyword=养护一班&queryMode=team&startDate=2026-06-01&endDate=2026-06-30" \ + -H "appId: my_app_001" \ + -H "timestamp: 1749696000000" \ + -H "nonce: c3d4e5f6g7h8i9j0" \ + -H "sign: ..." + +# staff 模式:按人员姓名查询考核数据(含考勤明细和工单耗材) +curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/team-assessment/query?keyword=张三&queryMode=staff&includeDetail=true" \ + -H "appId: my_app_001" \ + -H "timestamp: 1749696000000" \ + -H "nonce: d4e5f6g7h8i9j0k1" \ + -H "sign: ..." +``` + +### 4.6 响应示例(team 模式,includeDetail=false) + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "queryMode": "team", + "period": { + "start": "2026-06-01", + "end": "2026-06-30" + }, + "teams": [ + { + "teamId": 100, + "teamName": "养护一班", + "parentTeamName": "房山区园林所", + "leaderName": "张三", + "totalMembers": 15, + "score": { + "total": 85, + "orderScore": 34, + "attendanceScore": 25, + "materialScore": 26, + "deductions": [ + { "rule": "工单超24小时补录", "count": 1, "points": -1 }, + { "rule": "工单超时>2小时", "count": 2, "points": -2 }, + { "rule": "缺勤/未打卡", "count": 5, "points": -5 }, + { "rule": "人机材24h内未上报", "count": 1, "points": -2 } + ] + }, + "orderStats": { + "totalOrders": 45, + "completedOrders": 42, + "completionRate": 93.3, + "onlineRate": 97.8, + "responseWithin30Min": 40, + "responseRate30Min": 88.9, + "overdueOrders": 2, + "lateSupplementOrders": 1 + }, + "attendanceStats": { + "totalCheckins": 570, + "absentCount": 5, + "missedCheckinCount": 5, + "trajectoryLowCount": 1, + "missingCoHandlerCount": 0, + "missingCardCount": 0, + "multiCardViolation": false, + "leaderRecorderOffCount": 0 + }, + "materialStats": { + "ordersWithMaterial": 42, + "ordersMissingMaterial": 1, + "totalMaterialItems": 230, + "materialTypes": 15 + }, + "members": [ + { + "userId": 1001, + "userName": "张三", + "mobile": "13800138001", + "postNames": "班组长,技术员", + "cardNumber": "DEV001", + "isInner": 1, + "personalScore": { + "attendanceDays": 28, + "lateCount": 0, + "absentCount": 0, + "assignedOrders": 8, + "completedOrders": 8, + "completionRate": 100.0, + "overdueOrders": 0, + "materialReported": 8, + "materialMissing": 0 + } + } + ], + "orders": [ + { + "orderNo": "WO202606250001", + "orderName": "树木修剪", + "orderType": "C", + "workerName": "张三", + "status": 1, + "buzStatus": "completed", + "commitDate": "2026-06-25 09:00:00", + "finishDate": "2026-06-25 16:30:00", + "expectedFinishDate": "2026-06-25 18:00:00", + "responseMinutes": 15, + "isOverdue": false, + "isLateSupplement": false, + "hasMaterialReported": true, + "coHandlers": ["李四"] + } + ] + } + ] + } +} +``` + +### 4.7 响应示例(staff 模式,includeDetail=true) + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "queryMode": "staff", + "period": { + "start": "2026-06-01", + "end": "2026-06-25" + }, + "staffList": [ + { + "userId": 1001, + "userName": "张三", + "mobile": "13800138001", + "deptId": 100, + "deptName": "养护一班", + "postNames": "班组长", + "cardNumber": "DEV001", + "isInner": 1, + "assessment": { + "attendanceDays": 28, + "lateCount": 0, + "absentCount": 0, + "assignedOrders": 8, + "completedOrders": 8, + "completionRate": 100.0, + "overdueOrders": 0, + "materialReported": 8, + "materialMissing": 0 + }, + "attendances": [ + { "punchType": "上班", "punchTime": "2026-06-25 08:00:00", "address": "北京市房山区xx路" }, + { "punchType": "下班", "punchTime": "2026-06-25 17:30:00", "address": "北京市房山区xx路" } + ], + "orders": [ + { + "orderNo": "WO202606250001", + "orderName": "树木修剪", + "orderType": "C", + "workerName": "张三", + "status": 1, + "buzStatus": "completed", + "commitDate": "2026-06-25 09:00:00", + "finishDate": "2026-06-25 16:30:00", + "expectedFinishDate": "2026-06-25 18:00:00", + "responseMinutes": 15, + "isOverdue": false, + "isLateSupplement": false, + "hasMaterialReported": true, + "coHandlers": ["李四"], + "materials": [ + { + "materialName": "高效氯氰菊酯", + "specifications": "500ml/瓶", + "userCount": 2, + "unitName": "瓶", + "classifyName": "药品" + } + ] + } + ] + } + ] + } +} +``` + +### 4.8 使用建议(Co-ClawCo-Claw) + +| 场景 | 推荐参数 | 说明 | +|------|----------|------| +| 月底考核排名 | `keyword=养护&queryMode=team` | 模糊查询所有养护班组,返回考核得分用于排名 | +| 单班组详情分析 | `keyword=养护一班&queryMode=team&includeDetail=true` | 查看特定班组全部明细,分析扣分原因 | +| 个人绩效查询 | `keyword=张三&queryMode=staff&includeDetail=true` | 查看某人出勤、工单、耗材完整记录 | +| 日常快速巡检 | `keyword=养护&includeDetail=false` | 只看汇总指标,Token 最少 | + +--- + +## 5. 接口 2:人员查询 ### 4.1 基本信息 @@ -238,7 +630,7 @@ curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/staff/query?dept --- -## 5. 接口 2:班组查询 +## 6. 接口 3:班组查询 ### 5.1 基本信息 @@ -306,7 +698,7 @@ curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/teams/query" \ --- -## 6. 接口 3:考勤查询 +## 7. 接口 4:考勤查询 ### 6.1 基本信息 @@ -379,7 +771,7 @@ curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/teams/query" \ --- -## 7. 接口 4:工单查询 +## 8. 接口 5:工单查询 ### 7.1 基本信息 @@ -472,7 +864,7 @@ curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/teams/query" \ --- -## 8. 接口 5:物料查询 +## 9. 接口 6:物料查询 ### 8.1 基本信息 @@ -549,7 +941,7 @@ curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/teams/query" \ --- -## 9. 接口 6:全量工单统计 +## 10. 接口 7:全量工单统计 ### 9.1 基本信息 @@ -621,18 +1013,16 @@ curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/full-order-stati --- -## 10. 错误码说明 +## 11. 错误码说明 -### 10.1 签名相关错误 - -### 10.2 业务相关错误 +### 11.1 签名相关错误 | code | msg | 说明 | |------|-----|------| | 400 | 签名不正确 | 签名验证失败(appId不存在/timestamp超时/nonce长度不足/签名不匹配) | | 900 | 存在重复请求 | nonce 已被使用(重放攻击检测) | -### 10.3 通用错误 +### 11.2 业务接口错误 | code | msg | 说明 | |------|-----|------| @@ -641,11 +1031,11 @@ curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/full-order-stati | 1-100-020-003 | 考勤查询失败 | 考勤查询内部错误 | | 1-100-020-004 | 工单查询失败 | 工单查询内部错误 | | 1-100-020-005 | 物料查询失败 | 物料查询内部错误 | - +| 1-100-020-006 | 团队考核查询失败 | 团队考核聚合查询内部错误 | | 1-900-005-001 | 全量工单统计不存在 | 记录不存在 | | 1-900-005-002 | 相同公司/班组同时间段统计已存在 | 重复数据校验 | -### 10.3 通用错误 +### 11.3 通用错误 | code | msg | 说明 | |------|-----|------| @@ -654,9 +1044,9 @@ curl -X GET "https://test.jichengshanshui.com.cn:28302/open-api/full-order-stati --- -## 11. 附录:客户端签名代码示例 +## 12. 附录:客户端签名代码示例 -### 10.1 Java 示例 +### 12.1 Java 示例 ```java import cn.hutool.crypto.digest.DigestUtil; @@ -711,7 +1101,7 @@ public class OpenApiClient { } ``` -### 10.2 Python 示例 +### 12.2 Python 示例 ```python import hashlib @@ -752,7 +1142,7 @@ def call(path, params=None): return response.json() ``` -### 10.3 Shell (cURL) 示例 +### 12.3 Shell (cURL) 示例 ```bash #!/bin/bash diff --git a/docs/开放API对接接口文档.pdf b/docs/开放API对接接口文档.pdf index 58e900a..f19e9ee 100644 Binary files a/docs/开放API对接接口文档.pdf and b/docs/开放API对接接口文档.pdf differ diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/assessment/TeamAssessmentOpenController.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/assessment/TeamAssessmentOpenController.java new file mode 100644 index 0000000..bb952df --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/assessment/TeamAssessmentOpenController.java @@ -0,0 +1,38 @@ +package com.zteits.urbanops.module.garden.controller.open.assessment; + +import com.zteits.urbanops.framework.common.pojo.CommonResult; +import com.zteits.urbanops.framework.signature.core.annotation.ApiSignature; +import com.zteits.urbanops.module.garden.controller.open.assessment.vo.TeamAssessmentReqVO; +import com.zteits.urbanops.module.garden.controller.open.assessment.vo.TeamAssessmentRespVO; +import com.zteits.urbanops.module.garden.controller.open.service.OpenApiService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.concurrent.TimeUnit; + +import static com.zteits.urbanops.framework.common.pojo.CommonResult.success; + +@Tag(name = "开放API - 团队考核查询") +@RestController +@RequestMapping("/open-api/team-assessment") +@Slf4j +@jakarta.annotation.security.PermitAll +@ApiSignature(timeout = 60, timeUnit = TimeUnit.SECONDS) +public class TeamAssessmentOpenController { + + @Resource + private OpenApiService openApiService; + + @GetMapping("/query") + @Operation(summary = "查询班组/人员考核数据(供 OpenClaw AI 助手使用)") + public CommonResult queryTeamAssessment( + @Valid TeamAssessmentReqVO reqVO) { + return success(openApiService.queryTeamAssessment(reqVO)); + } +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/assessment/vo/TeamAssessmentReqVO.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/assessment/vo/TeamAssessmentReqVO.java new file mode 100644 index 0000000..4e54f5a --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/assessment/vo/TeamAssessmentReqVO.java @@ -0,0 +1,32 @@ +package com.zteits.urbanops.module.garden.controller.open.assessment.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serializable; +import java.time.LocalDate; + +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY; + +@Schema(description = "开放API - 团队考核查询 Request VO") +@Data +public class TeamAssessmentReqVO implements Serializable { + + @Schema(description = "查询关键字:班组名称或人员姓名(支持模糊匹配)", example = "养护一班", requiredMode = Schema.RequiredMode.REQUIRED) + private String keyword; + + @Schema(description = "查询模式:team 按班组查询 / staff 按人员查询", example = "team", defaultValue = "team") + private String queryMode; + + @Schema(description = "考核开始日期(默认当月1日)", example = "2026-06-01") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) + private LocalDate startDate; + + @Schema(description = "考核结束日期(默认今天)", example = "2026-06-25") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY) + private LocalDate endDate; + + @Schema(description = "是否返回明细数据(考勤明细、工单耗材明细)", example = "false", defaultValue = "false") + private Boolean includeDetail; +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/assessment/vo/TeamAssessmentRespVO.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/assessment/vo/TeamAssessmentRespVO.java new file mode 100644 index 0000000..985b0a4 --- /dev/null +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/assessment/vo/TeamAssessmentRespVO.java @@ -0,0 +1,375 @@ +package com.zteits.urbanops.module.garden.controller.open.assessment.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY; +import static com.zteits.urbanops.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; +import static com.zteits.urbanops.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; + +@Schema(description = "开放API - 团队考核查询 Response VO") +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class TeamAssessmentRespVO { + + @Schema(description = "查询模式", example = "team") + private String queryMode; + + @Schema(description = "考核周期") + private PeriodVO period; + + @Schema(description = "班组考核列表(queryMode=team 时返回)") + private List teams; + + @Schema(description = "人员考核列表(queryMode=staff 时返回)") + private List staffList; + + // ==================== 考核周期 ==================== + + @Data + public static class PeriodVO { + @Schema(description = "开始日期", example = "2026-06-01") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY, timezone = TIME_ZONE_DEFAULT) + private LocalDate start; + + @Schema(description = "结束日期", example = "2026-06-25") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY, timezone = TIME_ZONE_DEFAULT) + private LocalDate end; + } + + // ==================== 班组(第一层) ==================== + + @Data + public static class TeamVO { + @Schema(description = "班组ID", example = "100") + private Long teamId; + + @Schema(description = "班组名称", example = "养护一班") + private String teamName; + + @Schema(description = "上级部门名称", example = "房山区园林所") + private String parentTeamName; + + @Schema(description = "班组长姓名", example = "张三") + private String leaderName; + + @Schema(description = "班组人数", example = "15") + private Long totalMembers; + + @Schema(description = "考核得分") + private ScoreVO score; + + @Schema(description = "工单统计") + private OrderStatsVO orderStats; + + @Schema(description = "考勤统计") + private AttendanceStatsVO attendanceStats; + + @Schema(description = "耗材统计") + private MaterialStatsVO materialStats; + + @Schema(description = "人员列表(第二层)") + private List members; + + @Schema(description = "工单列表(第二层)") + private List orders; + } + + // ==================== 考核得分 ==================== + + @Data + public static class ScoreVO { + @Schema(description = "总分", example = "85") + private Integer total; + + @Schema(description = "工单得分(满分40)", example = "34") + private Integer orderScore; + + @Schema(description = "考勤得分(满分30)", example = "25") + private Integer attendanceScore; + + @Schema(description = "人机材得分(满分30)", example = "26") + private Integer materialScore; + + @Schema(description = "扣分明细") + private List deductions; + } + + @Data + public static class DeductionVO { + @Schema(description = "扣分规则", example = "工单超时>2小时") + private String rule; + + @Schema(description = "触发次数", example = "2") + private Integer count; + + @Schema(description = "扣分", example = "-2") + private Integer points; + } + + // ==================== 工单统计 ==================== + + @Data + public static class OrderStatsVO { + @Schema(description = "总工单数", example = "45") + private Long totalOrders; + + @Schema(description = "已完成工单数", example = "42") + private Long completedOrders; + + @Schema(description = "完成率(%)", example = "93.3") + private BigDecimal completionRate; + + @Schema(description = "线上化率(%)", example = "97.8") + private BigDecimal onlineRate; + + @Schema(description = "30分钟内响应数", example = "40") + private Long responseWithin30Min; + + @Schema(description = "30分钟响应率(%)", example = "88.9") + private BigDecimal responseRate30Min; + + @Schema(description = "超期工单数", example = "2") + private Long overdueOrders; + + @Schema(description = "超24小时补录工单数", example = "1") + private Long lateSupplementOrders; + } + + // ==================== 考勤统计 ==================== + + @Data + public static class AttendanceStatsVO { + @Schema(description = "总打卡次数", example = "570") + private Long totalCheckins; + + @Schema(description = "缺勤人次", example = "5") + private Long absentCount; + + @Schema(description = "未触发电子围栏打卡次数", example = "3") + private Long missedCheckinCount; + + @Schema(description = "轨迹覆盖率不达标次数", example = "1") + private Long trajectoryLowCount; + + @Schema(description = "缺共同处理人次数", example = "0") + private Long missingCoHandlerCount; + + @Schema(description = "未佩戴工卡次数", example = "0") + private Long missingCardCount; + + @Schema(description = "是否存在一人多卡违规", example = "false") + private Boolean multiCardViolation; + + @Schema(description = "班组长记录仪未开机次数", example = "0") + private Long leaderRecorderOffCount; + } + + // ==================== 耗材统计 ==================== + + @Data + public static class MaterialStatsVO { + @Schema(description = "已上报耗材的工单数", example = "42") + private Long ordersWithMaterial; + + @Schema(description = "缺耗材上报的工单数", example = "1") + private Long ordersMissingMaterial; + + @Schema(description = "耗材总用量", example = "230") + private Long totalMaterialItems; + + @Schema(description = "耗材种类数", example = "15") + private Long materialTypes; + } + + // ==================== 人员(第二层) ==================== + + @Data + public static class MemberVO { + @Schema(description = "人员ID", example = "1001") + private Long userId; + + @Schema(description = "姓名", example = "张三") + private String userName; + + @Schema(description = "手机号", example = "13800138000") + private String mobile; + + @Schema(description = "岗位名称", example = "班组长,技术员") + private String postNames; + + @Schema(description = "工卡号", example = "DEV001") + private String cardNumber; + + @Schema(description = "是否内部人员", example = "1") + private Integer isInner; + + @Schema(description = "个人考核指标") + private PersonalScoreVO personalScore; + + @Schema(description = "考勤明细 / 第三层(includeDetail=true 时返回)") + private List attendances; + + @Schema(description = "个人工单 / 第三层(includeDetail=true 时返回)") + private List orders; + } + + @Data + public static class PersonalScoreVO { + @Schema(description = "出勤天数", example = "28") + private Long attendanceDays; + + @Schema(description = "迟到次数", example = "0") + private Long lateCount; + + @Schema(description = "缺勤次数", example = "0") + private Long absentCount; + + @Schema(description = "承担工单数", example = "8") + private Long assignedOrders; + + @Schema(description = "完成工单数", example = "8") + private Long completedOrders; + + @Schema(description = "完成率(%)", example = "100.0") + private BigDecimal completionRate; + + @Schema(description = "超期工单数", example = "0") + private Long overdueOrders; + + @Schema(description = "已上报耗材工单数", example = "8") + private Long materialReported; + + @Schema(description = "缺耗材上报工单数", example = "0") + private Long materialMissing; + } + + @Data + public static class AttendanceRecordVO { + @Schema(description = "打卡类型", example = "上班") + private String punchType; + + @Schema(description = "打卡时间", example = "2026-06-25 08:00:00") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private LocalDateTime punchTime; + + @Schema(description = "打卡地址", example = "北京市房山区xx路") + private String address; + } + + // ==================== 工单(第二层/第三层) ==================== + + @Data + public static class OrderVO { + @Schema(description = "工单号", example = "WO202606250001") + private String orderNo; + + @Schema(description = "工单名称", example = "树木修剪") + private String orderName; + + @Schema(description = "工单类型", example = "C") + private String orderType; + + @Schema(description = "接单人姓名", example = "张三") + private String workerName; + + @Schema(description = "审批状态", example = "1") + private Integer status; + + @Schema(description = "业务状态", example = "completed") + private String buzStatus; + + @Schema(description = "发布时间", example = "2026-06-25 09:00:00") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private LocalDateTime commitDate; + + @Schema(description = "完成时间", example = "2026-06-25 16:30:00") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private LocalDateTime finishDate; + + @Schema(description = "期望完成时间", example = "2026-06-25 18:00:00") + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private LocalDateTime expectedFinishDate; + + @Schema(description = "响应时长(分钟)", example = "15") + private Long responseMinutes; + + @Schema(description = "是否超期", example = "false") + private Boolean isOverdue; + + @Schema(description = "是否超24小时补录", example = "false") + private Boolean isLateSupplement; + + @Schema(description = "是否已上报耗材", example = "true") + private Boolean hasMaterialReported; + + @Schema(description = "共同处理人姓名") + private List coHandlers; + + @Schema(description = "耗材明细 / 第三层(includeDetail=true 时返回)") + private List materials; + } + + @Data + public static class MaterialVO { + @Schema(description = "耗材名称", example = "高效氯氰菊酯") + private String materialName; + + @Schema(description = "规格", example = "500ml/瓶") + private String specifications; + + @Schema(description = "使用数量", example = "2") + private Long userCount; + + @Schema(description = "单位", example = "瓶") + private String unitName; + + @Schema(description = "分类", example = "药品") + private String classifyName; + } + + // ==================== 人员模式 ==================== + + @Data + public static class StaffVO { + @Schema(description = "人员ID", example = "1001") + private Long userId; + + @Schema(description = "姓名", example = "张三") + private String userName; + + @Schema(description = "手机号", example = "13800138000") + private String mobile; + + @Schema(description = "部门ID", example = "100") + private Long deptId; + + @Schema(description = "部门名称", example = "养护一班") + private String deptName; + + @Schema(description = "岗位名称", example = "班组长") + private String postNames; + + @Schema(description = "工卡号", example = "DEV001") + private String cardNumber; + + @Schema(description = "是否内部人员", example = "1") + private Integer isInner; + + @Schema(description = "个人考核汇总") + private PersonalScoreVO assessment; + + @Schema(description = "考勤明细") + private List attendances; + + @Schema(description = "工单列表(含耗材)") + private List orders; + } +} diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiService.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiService.java index af3bdb1..3b172d6 100644 --- a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiService.java +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiService.java @@ -8,6 +8,8 @@ import com.zteits.urbanops.module.garden.controller.open.staff.vo.StaffQueryReqV import com.zteits.urbanops.module.garden.controller.open.staff.vo.StaffQueryRespVO; import com.zteits.urbanops.module.garden.controller.open.task.vo.TaskQueryReqVO; import com.zteits.urbanops.module.garden.controller.open.task.vo.TaskQueryRespVO; +import com.zteits.urbanops.module.garden.controller.open.assessment.vo.TeamAssessmentReqVO; +import com.zteits.urbanops.module.garden.controller.open.assessment.vo.TeamAssessmentRespVO; import com.zteits.urbanops.module.garden.controller.open.team.vo.TeamQueryReqVO; import com.zteits.urbanops.module.garden.controller.open.team.vo.TeamQueryRespVO; @@ -15,6 +17,8 @@ import java.util.List; public interface OpenApiService { + TeamAssessmentRespVO queryTeamAssessment(TeamAssessmentReqVO reqVO); + List queryStaffList(StaffQueryReqVO reqVO); List queryTeamList(TeamQueryReqVO reqVO); diff --git a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiServiceImpl.java b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiServiceImpl.java index 5862a1c..2a3d8ba 100644 --- a/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiServiceImpl.java +++ b/urbanops-module-garden/src/main/java/com/zteits/urbanops/module/garden/controller/open/service/OpenApiServiceImpl.java @@ -3,6 +3,8 @@ package com.zteits.urbanops.module.garden.controller.open.service; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import com.zteits.urbanops.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.zteits.urbanops.module.garden.controller.open.assessment.vo.TeamAssessmentReqVO; +import com.zteits.urbanops.module.garden.controller.open.assessment.vo.TeamAssessmentRespVO; import com.zteits.urbanops.module.garden.controller.open.attendance.vo.AttendanceQueryReqVO; import com.zteits.urbanops.module.garden.controller.open.attendance.vo.AttendanceQueryRespVO; import com.zteits.urbanops.module.garden.controller.open.material.vo.MaterialQueryReqVO; @@ -33,6 +35,12 @@ import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.*; import java.util.stream.Collectors; @@ -379,4 +387,569 @@ public class OpenApiServiceImpl implements OpenApiService { } return voList; } + + // ==================== 团队考核聚合查询 ==================== + + @Override + public TeamAssessmentRespVO queryTeamAssessment(TeamAssessmentReqVO reqVO) { + String queryMode = StrUtil.blankToDefault(reqVO.getQueryMode(), "team"); + LocalDate startDate = reqVO.getStartDate() != null ? reqVO.getStartDate() + : LocalDate.now().withDayOfMonth(1); + LocalDate endDate = reqVO.getEndDate() != null ? reqVO.getEndDate() : LocalDate.now(); + boolean includeDetail = Boolean.TRUE.equals(reqVO.getIncludeDetail()); + LocalDateTime startDateTime = startDate.atStartOfDay(); + LocalDateTime endDateTime = endDate.plusDays(1).atStartOfDay(); + + TeamAssessmentRespVO resp = new TeamAssessmentRespVO(); + resp.setQueryMode(queryMode); + TeamAssessmentRespVO.PeriodVO period = new TeamAssessmentRespVO.PeriodVO(); + period.setStart(startDate); + period.setEnd(endDate); + resp.setPeriod(period); + + String keyword = reqVO.getKeyword(); + if (StrUtil.isBlank(keyword)) { + return resp; + } + + if ("staff".equals(queryMode)) { + resp.setStaffList(buildStaffAssessment(keyword, startDateTime, endDateTime, includeDetail)); + } else { + resp.setTeams(buildTeamAssessment(keyword, startDateTime, endDateTime, includeDetail)); + } + return resp; + } + + // ==================== 班组考核构建 ==================== + + private List buildTeamAssessment( + String keyword, LocalDateTime startDateTime, LocalDateTime endDateTime, boolean includeDetail) { + + // 1. 按班组名称模糊查询 + List deptList = deptMapper.selectList( + new LambdaQueryWrapperX() + .like(DeptDO::getName, keyword) + .eq(DeptDO::getStatus, 0)); + if (CollUtil.isEmpty(deptList)) { + return Collections.emptyList(); + } + + Set deptIds = deptList.stream().map(DeptDO::getId).collect(Collectors.toSet()); + + // 2. 批量查父部门名、班组长名 + Set parentIds = deptList.stream().map(DeptDO::getParentId) + .filter(Objects::nonNull).filter(id -> id > 0).collect(Collectors.toSet()); + Map parentNameMap = CollUtil.isNotEmpty(parentIds) + ? deptMapper.selectList(DeptDO::getId, parentIds).stream() + .collect(Collectors.toMap(DeptDO::getId, DeptDO::getName, (a, b) -> a)) + : Collections.emptyMap(); + Set leaderIds = deptList.stream().map(DeptDO::getLeaderUserId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map leaderNameMap = CollUtil.isNotEmpty(leaderIds) + ? adminUserMapper.selectList(AdminUserDO::getId, leaderIds).stream() + .collect(Collectors.toMap(AdminUserDO::getId, AdminUserDO::getNickname, (a, b) -> a)) + : Collections.emptyMap(); + + // 3. 批量查所有成员 + List allMembers = adminUserMapper.selectList( + new LambdaQueryWrapperX().in(AdminUserDO::getDeptId, deptIds)); + Map> deptMemberMap = allMembers.stream() + .collect(Collectors.groupingBy(AdminUserDO::getDeptId)); + Set allUserIds = allMembers.stream().map(AdminUserDO::getId).collect(Collectors.toSet()); + + // 4. 批量查岗位名、工卡号 + Map postNameMap = buildPostNameMap(allMembers); + Map cardNumberMap = buildCardNumberMap(allUserIds); + + // 5. 批量查考勤记录 + List allClockRecords; + Map> userClockMap; + if (CollUtil.isNotEmpty(allUserIds)) { + allClockRecords = deviceClockRecordMapper.selectList( + new LambdaQueryWrapperX() + .in(DeviceClockRecordDO::getUserId, allUserIds) + .between(DeviceClockRecordDO::getCreateTime, startDateTime, endDateTime)); + userClockMap = allClockRecords.stream() + .collect(Collectors.groupingBy(DeviceClockRecordDO::getUserId)); + } else { + allClockRecords = Collections.emptyList(); + userClockMap = Collections.emptyMap(); + } + + // 6. 批量查工单(按实施人员ID或部门ID) + List allOrders = mainInfoMapper.selectList( + new LambdaQueryWrapperX() + .in(MainInfoDO::getDeptId, deptIds) + .between(MainInfoDO::getCommitDate, startDateTime, endDateTime)); + Map> deptOrderMap = allOrders.stream() + .collect(Collectors.groupingBy(MainInfoDO::getDeptId)); + + // 7. 批量查耗材 + Set allOrderNos = allOrders.stream().map(MainInfoDO::getOrderNo) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map> orderMaterialMap; + if (CollUtil.isNotEmpty(allOrderNos)) { + List allMaterials = materialDetailMapper.selectList( + new LambdaQueryWrapperX().in(MaterialDetailDO::getOrderNo, allOrderNos)); + orderMaterialMap = allMaterials.stream() + .collect(Collectors.groupingBy(MaterialDetailDO::getOrderNo)); + } else { + orderMaterialMap = Collections.emptyMap(); + } + + // 8. 构建每个班组的考核数据 + List teams = new ArrayList<>(); + for (DeptDO dept : deptList) { + TeamAssessmentRespVO.TeamVO team = new TeamAssessmentRespVO.TeamVO(); + team.setTeamId(dept.getId()); + team.setTeamName(dept.getName()); + team.setParentTeamName(dept.getParentId() != null && dept.getParentId() > 0 + ? parentNameMap.getOrDefault(dept.getParentId(), "") : ""); + team.setLeaderName(leaderNameMap.getOrDefault(dept.getLeaderUserId(), "")); + + List members = deptMemberMap.getOrDefault(dept.getId(), Collections.emptyList()); + team.setTotalMembers((long) members.size()); + + // 班组工单 + List deptOrders = deptOrderMap.getOrDefault(dept.getId(), Collections.emptyList()); + // 班组考勤(所有成员的打卡记录) + List deptClocks = members.stream() + .flatMap(m -> userClockMap.getOrDefault(m.getId(), Collections.emptyList()).stream()) + .collect(Collectors.toList()); + + // 计算考核得分和统计 + computeTeamScore(team, deptOrders, deptClocks, members, orderMaterialMap, + userClockMap, cardNumberMap, startDateTime, endDateTime); + + // 构建成员列表 + team.setMembers(buildMemberList(members, userClockMap, deptOrders, orderMaterialMap, + postNameMap, cardNumberMap, startDateTime, endDateTime, includeDetail)); + + // 构建工单列表 + team.setOrders(buildOrderList(deptOrders, orderMaterialMap, includeDetail)); + + teams.add(team); + } + return teams; + } + + // ==================== 人员考核构建 ==================== + + private List buildStaffAssessment( + String keyword, LocalDateTime startDateTime, LocalDateTime endDateTime, boolean includeDetail) { + + // 1. 按姓名或手机号模糊查询 + List userList = adminUserMapper.selectList( + new LambdaQueryWrapperX() + .like(AdminUserDO::getNickname, keyword) + .or(w -> w.like(AdminUserDO::getMobile, keyword))); + if (CollUtil.isEmpty(userList)) { + return Collections.emptyList(); + } + + Set userIds = userList.stream().map(AdminUserDO::getId).collect(Collectors.toSet()); + + // 2. 批量查部门名、岗位名、工卡号 + Set deptIds = userList.stream().map(AdminUserDO::getDeptId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map deptNameMap = CollUtil.isNotEmpty(deptIds) + ? deptMapper.selectList(DeptDO::getId, deptIds).stream() + .collect(Collectors.toMap(DeptDO::getId, DeptDO::getName, (a, b) -> a)) + : Collections.emptyMap(); + Map postNameMap = buildPostNameMap(userList); + Map cardNumberMap = buildCardNumberMap(userIds); + + // 3. 批量查考勤 + List allClocks = deviceClockRecordMapper.selectList( + new LambdaQueryWrapperX() + .in(DeviceClockRecordDO::getUserId, userIds) + .between(DeviceClockRecordDO::getCreateTime, startDateTime, endDateTime)); + Map> userClockMap = allClocks.stream() + .collect(Collectors.groupingBy(DeviceClockRecordDO::getUserId)); + + // 4. 批量查工单 + List allOrders = mainInfoMapper.selectList( + new LambdaQueryWrapperX() + .in(MainInfoDO::getWorkerId, userIds) + .between(MainInfoDO::getCommitDate, startDateTime, endDateTime)); + Map> userOrderMap = allOrders.stream() + .collect(Collectors.groupingBy(MainInfoDO::getWorkerId)); + + // 5. 批量查耗材 + Set allOrderNos2 = allOrders.stream().map(MainInfoDO::getOrderNo) + .filter(Objects::nonNull).collect(Collectors.toSet()); + Map> orderMaterialMap2; + if (CollUtil.isNotEmpty(allOrderNos2)) { + List allMaterials = materialDetailMapper.selectList( + new LambdaQueryWrapperX().in(MaterialDetailDO::getOrderNo, allOrderNos2)); + orderMaterialMap2 = allMaterials.stream() + .collect(Collectors.groupingBy(MaterialDetailDO::getOrderNo)); + } else { + orderMaterialMap2 = Collections.emptyMap(); + } + + // 6. 构建人员考核VO + List staffList = new ArrayList<>(); + for (AdminUserDO user : userList) { + TeamAssessmentRespVO.StaffVO staff = new TeamAssessmentRespVO.StaffVO(); + staff.setUserId(user.getId()); + staff.setUserName(user.getNickname()); + staff.setMobile(user.getMobile()); + staff.setDeptId(user.getDeptId()); + staff.setDeptName(deptNameMap.getOrDefault(user.getDeptId(), "")); + staff.setPostNames(postNameMap.getOrDefault(user.getId(), "")); + staff.setCardNumber(cardNumberMap.getOrDefault(user.getId(), "")); + staff.setIsInner(user.getIsInner()); + + List clocks = userClockMap.getOrDefault(user.getId(), Collections.emptyList()); + List orders = userOrderMap.getOrDefault(user.getId(), Collections.emptyList()); + + // 个人考核指标 + staff.setAssessment(buildPersonalScore(clocks, orders, orderMaterialMap2, + startDateTime, endDateTime)); + + // 考勤明细 + if (includeDetail) { + staff.setAttendances(buildAttendanceRecords(clocks)); + staff.setOrders(buildOrderList(orders, orderMaterialMap2, true)); + } + + staffList.add(staff); + } + return staffList; + } + + // ==================== 辅助方法 ==================== + + private void computeTeamScore(TeamAssessmentRespVO.TeamVO team, + List orders, List clocks, + List members, + Map> orderMaterialMap, + Map> userClockMap, + Map cardNumberMap, + LocalDateTime startDateTime, LocalDateTime endDateTime) { + + int totalDays = (int) Math.max(1, + java.time.Duration.between(startDateTime, endDateTime).toDays()); + + // --- 工单统计 --- + long totalOrders = orders.size(); + long completedOrders = orders.stream() + .filter(o -> "completed".equals(o.getBuzStatus())).count(); + long overdueOrders = orders.stream() + .filter(o -> o.getExpectedFinishDate() != null && o.getFinishDate() != null + && o.getFinishDate().isAfter(o.getExpectedFinishDate())) + .count(); + long lateSupplementOrders = orders.stream() + .filter(o -> o.getCommitDate() != null && o.getCreateTime() != null + && java.time.Duration.between(o.getCommitDate(), o.getCreateTime()).toHours() > 24) + .count(); + long responseWithin30Min = orders.stream() + .filter(o -> o.getCommitDate() != null && o.getCreateTime() != null + && java.time.Duration.between(o.getCreateTime(), o.getCommitDate()).toMinutes() <= 30) + .count(); + + TeamAssessmentRespVO.OrderStatsVO orderStats = new TeamAssessmentRespVO.OrderStatsVO(); + orderStats.setTotalOrders(totalOrders); + orderStats.setCompletedOrders(completedOrders); + orderStats.setCompletionRate(totalOrders > 0 + ? BigDecimal.valueOf(completedOrders * 100.0 / totalOrders).setScale(1, java.math.RoundingMode.HALF_UP) + : BigDecimal.ZERO); + orderStats.setOnlineRate(totalOrders > 0 + ? BigDecimal.valueOf((totalOrders - lateSupplementOrders) * 100.0 / totalOrders) + .setScale(1, java.math.RoundingMode.HALF_UP) + : BigDecimal.ZERO); + orderStats.setResponseWithin30Min(responseWithin30Min); + orderStats.setResponseRate30Min(totalOrders > 0 + ? BigDecimal.valueOf(responseWithin30Min * 100.0 / totalOrders) + .setScale(1, java.math.RoundingMode.HALF_UP) + : BigDecimal.ZERO); + orderStats.setOverdueOrders(overdueOrders); + orderStats.setLateSupplementOrders(lateSupplementOrders); + team.setOrderStats(orderStats); + + // --- 考勤统计 --- + int totalMembers = members.size(); + long totalCheckins = clocks.size(); + // 缺勤:每个成员应有2次/天,缺少的算缺勤 + int expectedCheckinsPerPerson = totalDays * 2; + long absentCount = Math.max(0, + (long) totalMembers * expectedCheckinsPerPerson - totalCheckins) / 2; + // 未打卡:无工卡绑定的成员 + long missingCardCount = members.stream() + .filter(m -> StrUtil.isBlank(cardNumberMap.getOrDefault(m.getId(), ""))) + .count(); + // 缺共同处理人:>2人任务但co_handlers为空 + long missingCoHandlerCount = orders.stream() + .filter(o -> CollUtil.isEmpty(o.getCoHandlers())) + .count(); + // 轨迹覆盖率不达标(暂用打卡次数<天数的用户数近似) + long trajectoryLowCount = members.stream() + .filter(m -> { + List uc = userClockMap.getOrDefault(m.getId(), Collections.emptyList()); + return uc.size() < totalDays; + }) + .count(); + // 一人多卡违规(同一天同一用户多张卡打卡) + boolean multiCardViolation = false; + for (List uc : userClockMap.values()) { + Map> byDay = uc.stream() + .collect(Collectors.groupingBy(c -> c.getCreateTime().toLocalDate())); + for (List dayRecords : byDay.values()) { + Set devices = dayRecords.stream() + .map(DeviceClockRecordDO::getDeviceCode) + .filter(Objects::nonNull).collect(Collectors.toSet()); + if (devices.size() > 2) { multiCardViolation = true; break; } + } + if (multiCardViolation) break; + } + + TeamAssessmentRespVO.AttendanceStatsVO attStats = new TeamAssessmentRespVO.AttendanceStatsVO(); + attStats.setTotalCheckins(totalCheckins); + attStats.setAbsentCount(absentCount); + attStats.setMissedCheckinCount(absentCount); // 缺勤=未打卡近似 + attStats.setTrajectoryLowCount(trajectoryLowCount); + attStats.setMissingCoHandlerCount(missingCoHandlerCount); + attStats.setMissingCardCount(missingCardCount); + attStats.setMultiCardViolation(multiCardViolation); + attStats.setLeaderRecorderOffCount(0L); // 记录仪数据暂不可用 + team.setAttendanceStats(attStats); + + // --- 耗材统计 --- + long ordersWithMaterial = orders.stream() + .filter(o -> orderMaterialMap.containsKey(o.getOrderNo())).count(); + long ordersMissingMaterial = totalOrders - ordersWithMaterial; + long totalMaterialItems = orderMaterialMap.values().stream() + .flatMap(List::stream).mapToLong(m -> m.getUserCount() != null ? m.getUserCount() : 0).sum(); + long materialTypes = orderMaterialMap.values().stream() + .flatMap(List::stream).map(MaterialDetailDO::getTypeName) + .filter(Objects::nonNull).distinct().count(); + + TeamAssessmentRespVO.MaterialStatsVO matStats = new TeamAssessmentRespVO.MaterialStatsVO(); + matStats.setOrdersWithMaterial(ordersWithMaterial); + matStats.setOrdersMissingMaterial(ordersMissingMaterial); + matStats.setTotalMaterialItems(totalMaterialItems); + matStats.setMaterialTypes(materialTypes); + team.setMaterialStats(matStats); + + // --- 考核得分计算 --- + // 工单得分(40分) + int orderDeduction = (int) (lateSupplementOrders * 1 + overdueOrders * 1); + int orderScore = Math.max(0, 40 - orderDeduction); + + // 考勤得分(30分) + int attDeduction = (int) (absentCount * 1 + missingCoHandlerCount * 1 + + missingCardCount * 1 + attStats.getLeaderRecorderOffCount() * 1); + int attScore = Math.max(0, 30 - attDeduction); + + // 人机材得分(30分) + int matDeduction = (int) (ordersMissingMaterial * 2); + int matScore = Math.max(0, 30 - matDeduction); + + int totalScore = orderScore + attScore + matScore; + + TeamAssessmentRespVO.ScoreVO score = new TeamAssessmentRespVO.ScoreVO(); + score.setTotal(totalScore); + score.setOrderScore(orderScore); + score.setAttendanceScore(attScore); + score.setMaterialScore(matScore); + + List deductions = new ArrayList<>(); + if (lateSupplementOrders > 0) { + TeamAssessmentRespVO.DeductionVO d = new TeamAssessmentRespVO.DeductionVO(); + d.setRule("工单超24小时补录"); d.setCount((int) lateSupplementOrders); + d.setPoints((int) -lateSupplementOrders); deductions.add(d); + } + if (overdueOrders > 0) { + TeamAssessmentRespVO.DeductionVO d = new TeamAssessmentRespVO.DeductionVO(); + d.setRule("工单超时>2小时"); d.setCount((int) overdueOrders); + d.setPoints((int) -overdueOrders); deductions.add(d); + } + if (absentCount > 0) { + TeamAssessmentRespVO.DeductionVO d = new TeamAssessmentRespVO.DeductionVO(); + d.setRule("缺勤/未打卡"); d.setCount((int) absentCount); + d.setPoints((int) -absentCount); deductions.add(d); + } + if (missingCoHandlerCount > 0) { + TeamAssessmentRespVO.DeductionVO d = new TeamAssessmentRespVO.DeductionVO(); + d.setRule(">2人任务缺共同处理人"); d.setCount((int) missingCoHandlerCount); + d.setPoints((int) -missingCoHandlerCount); deductions.add(d); + } + if (missingCardCount > 0) { + TeamAssessmentRespVO.DeductionVO d = new TeamAssessmentRespVO.DeductionVO(); + d.setRule("未佩戴工卡"); d.setCount((int) missingCardCount); + d.setPoints((int) -missingCardCount); deductions.add(d); + } + if (ordersMissingMaterial > 0) { + TeamAssessmentRespVO.DeductionVO d = new TeamAssessmentRespVO.DeductionVO(); + d.setRule("人机材24h内未上报"); d.setCount((int) ordersMissingMaterial); + d.setPoints((int) (-ordersMissingMaterial * 2)); deductions.add(d); + } + score.setDeductions(deductions); + team.setScore(score); + } + + private List buildMemberList( + List members, + Map> userClockMap, + List deptOrders, + Map> orderMaterialMap, + Map postNameMap, Map cardNumberMap, + LocalDateTime startDateTime, LocalDateTime endDateTime, + boolean includeDetail) { + + Map> userOrderMap = deptOrders.stream() + .collect(Collectors.groupingBy(MainInfoDO::getWorkerId)); + + List voList = new ArrayList<>(); + for (AdminUserDO user : members) { + TeamAssessmentRespVO.MemberVO vo = new TeamAssessmentRespVO.MemberVO(); + vo.setUserId(user.getId()); + vo.setUserName(user.getNickname()); + vo.setMobile(user.getMobile()); + vo.setPostNames(postNameMap.getOrDefault(user.getId(), "")); + vo.setCardNumber(cardNumberMap.getOrDefault(user.getId(), "")); + vo.setIsInner(user.getIsInner()); + + List clocks = userClockMap.getOrDefault(user.getId(), Collections.emptyList()); + List orders = userOrderMap.getOrDefault(user.getId(), Collections.emptyList()); + + // 个人考核指标 + vo.setPersonalScore(buildPersonalScore(clocks, orders, orderMaterialMap, + startDateTime, endDateTime)); + + // 明细 + if (includeDetail) { + vo.setAttendances(buildAttendanceRecords(clocks)); + vo.setOrders(buildOrderList(orders, orderMaterialMap, true)); + } + + voList.add(vo); + } + return voList; + } + + private TeamAssessmentRespVO.PersonalScoreVO buildPersonalScore( + List clocks, List orders, + Map> orderMaterialMap, + LocalDateTime startDateTime, LocalDateTime endDateTime) { + + int totalDays = (int) Math.max(1, + java.time.Duration.between(startDateTime, endDateTime).toDays()); + + // 出勤天数(有打卡记录的天数) + long attendanceDays = clocks.stream() + .map(c -> c.getCreateTime().toLocalDate()).distinct().count(); + // 迟到次数(上班打卡晚于9点的次数) + long lateCount = clocks.stream() + .filter(c -> "上班".equals(c.getPunchType())) + .filter(c -> c.getCreateTime().toLocalTime().isAfter(java.time.LocalTime.of(9, 0))) + .count(); + long absentCount = Math.max(0, totalDays - attendanceDays); + + long assignedOrders = orders.size(); + long completedOrders = orders.stream() + .filter(o -> "completed".equals(o.getBuzStatus())).count(); + long overdueOrders = orders.stream() + .filter(o -> o.getExpectedFinishDate() != null && o.getFinishDate() != null + && o.getFinishDate().isAfter(o.getExpectedFinishDate())) + .count(); + long materialReported = orders.stream() + .filter(o -> orderMaterialMap.containsKey(o.getOrderNo())).count(); + long materialMissing = assignedOrders - materialReported; + + TeamAssessmentRespVO.PersonalScoreVO score = new TeamAssessmentRespVO.PersonalScoreVO(); + score.setAttendanceDays(attendanceDays); + score.setLateCount(lateCount); + score.setAbsentCount(absentCount); + score.setAssignedOrders(assignedOrders); + score.setCompletedOrders(completedOrders); + score.setCompletionRate(assignedOrders > 0 + ? BigDecimal.valueOf(completedOrders * 100.0 / assignedOrders) + .setScale(1, java.math.RoundingMode.HALF_UP) + : BigDecimal.ZERO); + score.setOverdueOrders(overdueOrders); + score.setMaterialReported(materialReported); + score.setMaterialMissing(materialMissing); + return score; + } + + private List buildAttendanceRecords( + List clocks) { + return clocks.stream().map(c -> { + TeamAssessmentRespVO.AttendanceRecordVO vo = new TeamAssessmentRespVO.AttendanceRecordVO(); + vo.setPunchType(c.getPunchType()); + vo.setPunchTime(c.getCreateTime()); + vo.setAddress(c.getAddress()); + return vo; + }).collect(Collectors.toList()); + } + + private List buildOrderList( + List orders, + Map> orderMaterialMap, + boolean includeMaterial) { + + return orders.stream().map(o -> { + TeamAssessmentRespVO.OrderVO vo = new TeamAssessmentRespVO.OrderVO(); + vo.setOrderNo(o.getOrderNo()); + vo.setOrderName(o.getOrderName()); + vo.setOrderType(o.getOrderType()); + vo.setWorkerName(o.getWorkerName()); + vo.setStatus(o.getStatus()); + vo.setBuzStatus(o.getBuzStatus()); + vo.setCommitDate(o.getCommitDate()); + vo.setFinishDate(o.getFinishDate()); + vo.setExpectedFinishDate(o.getExpectedFinishDate()); + // 响应时长 + if (o.getCommitDate() != null && o.getCreateTime() != null) { + vo.setResponseMinutes(java.time.Duration.between( + o.getCreateTime(), o.getCommitDate()).toMinutes()); + } + vo.setIsOverdue(o.getExpectedFinishDate() != null && o.getFinishDate() != null + && o.getFinishDate().isAfter(o.getExpectedFinishDate())); + vo.setIsLateSupplement(o.getCommitDate() != null && o.getCreateTime() != null + && java.time.Duration.between(o.getCommitDate(), o.getCreateTime()).toHours() > 24); + vo.setHasMaterialReported(orderMaterialMap.containsKey(o.getOrderNo())); + if (CollUtil.isNotEmpty(o.getCoHandlers())) { + vo.setCoHandlers(new ArrayList<>(o.getCoHandlers().stream() + .map(String::valueOf).collect(Collectors.toList()))); + } + // 耗材 + if (includeMaterial) { + List mats = orderMaterialMap.getOrDefault( + o.getOrderNo(), Collections.emptyList()); + vo.setMaterials(mats.stream().map(m -> { + TeamAssessmentRespVO.MaterialVO mv = new TeamAssessmentRespVO.MaterialVO(); + mv.setMaterialName(m.getMaterialName()); + mv.setSpecifications(m.getSpecifications()); + mv.setUserCount(m.getUserCount()); + mv.setUnitName(m.getUnitName()); + mv.setClassifyName(m.getClassifyName()); + return mv; + }).collect(Collectors.toList())); + } + return vo; + }).collect(Collectors.toList()); + } + + private Map buildPostNameMap(List users) { + Set allPostIds = users.stream() + .filter(u -> CollUtil.isNotEmpty(u.getPostIds())) + .flatMap(u -> u.getPostIds().stream()) + .collect(Collectors.toSet()); + return CollUtil.isNotEmpty(allPostIds) + ? postMapper.selectList(PostDO::getId, allPostIds).stream() + .collect(Collectors.toMap(PostDO::getId, PostDO::getName, (a, b) -> a)) + : Collections.emptyMap(); + } + + private Map buildCardNumberMap(Set userIds) { + return CollUtil.isNotEmpty(userIds) + ? deviceUserMapper.selectList( + new LambdaQueryWrapperX().in(DeviceUserDO::getUserId, userIds)) + .stream().collect(Collectors.toMap(DeviceUserDO::getUserId, + d -> StrUtil.blankToDefault(d.getDeviceNo(), ""), (a, b) -> a)) + : Collections.emptyMap(); + } } -- libgit2 0.21.4