Преглед изворни кода

merge: 合并 feature/issue-72 到 feature/dev (阈值管理+信息发布+设备管理)

- 合并冲突解决: 保留 issue-72 的完善版本(支持 MyBatis-Plus、AND/OR 组合条件引擎、逻辑删除)
- 覆盖 feature/dev 中的早期简化版 AlertRule 相关代码
- 新增: 阈值管理 CRUD + 信息发布 + 设备管理功能
bot_dev1 пре 5 дана
родитељ
комит
ab01664e57
100 измењених фајлова са 7551 додато и 32 уклоњено
  1. 122
    0
      db/postgresql/V2__alert_engine.sql
  2. 68
    0
      frontend/src/api/dispatchCommand.ts
  3. 2
    0
      frontend/src/router/index.ts
  4. 135
    0
      frontend/src/views/dispatch-command/CommandCreate.vue
  5. 304
    0
      frontend/src/views/dispatch-command/CommandDetail.vue
  6. 193
    0
      frontend/src/views/dispatch-command/CommandList.vue
  7. 558
    0
      frontend/src/views/patrol/ProblemReportingView.vue
  8. 5
    0
      pom.xml
  9. 112
    0
      sql/problem_reporting.sql
  10. 63
    0
      wm-bpm-engine/pom.xml
  11. 7
    0
      wm-bpm-engine/src/main/java/com/water/bpmengine/BpmEngineApplication.java
  12. 77
    0
      wm-bpm-engine/src/main/java/com/water/bpmengine/controller/BpmEngineController.java
  13. 13
    0
      wm-bpm-engine/src/main/java/com/water/bpmengine/entity/ProcessDefinition.java
  14. 13
    0
      wm-bpm-engine/src/main/java/com/water/bpmengine/entity/ProcessInstance.java
  15. 11
    0
      wm-bpm-engine/src/main/java/com/water/bpmengine/entity/ProcessTemplate.java
  16. 13
    0
      wm-bpm-engine/src/main/java/com/water/bpmengine/entity/TaskItem.java
  17. 5
    0
      wm-bpm-engine/src/main/java/com/water/bpmengine/mapper/ProcessDefinitionMapper.java
  18. 5
    0
      wm-bpm-engine/src/main/java/com/water/bpmengine/mapper/ProcessInstanceMapper.java
  19. 5
    0
      wm-bpm-engine/src/main/java/com/water/bpmengine/mapper/ProcessTemplateMapper.java
  20. 5
    0
      wm-bpm-engine/src/main/java/com/water/bpmengine/mapper/TaskItemMapper.java
  21. 122
    0
      wm-bpm-engine/src/main/java/com/water/bpmengine/service/BpmEngineService.java
  22. 15
    0
      wm-bpm-engine/src/main/resources/application.yml
  23. 23
    0
      wm-bpm-engine/src/main/resources/db/V1__bpm_engine.sql
  24. 49
    4
      wm-bpm/src/main/java/com/water/bpm/entity/BpmApprovalRecord.java
  25. 49
    0
      wm-bpm/src/main/java/com/water/bpm/entity/BpmFormTemplate.java
  26. 52
    0
      wm-bpm/src/main/java/com/water/bpm/entity/BpmOrchestration.java
  27. 46
    8
      wm-bpm/src/main/java/com/water/bpm/entity/BpmProcessDefinition.java
  28. 69
    11
      wm-bpm/src/main/java/com/water/bpm/entity/BpmProcessInstance.java
  29. 61
    0
      wm-bpm/src/main/java/com/water/bpm/entity/BpmProcessNode.java
  30. 63
    0
      wm-bpm/src/main/java/com/water/bpm/entity/BpmProcessStat.java
  31. 75
    0
      wm-bpm/src/main/java/com/water/bpm/entity/BpmTodoTask.java
  32. 33
    0
      wm-bpm/src/main/java/com/water/bpm/entity/dto/ApprovalRequest.java
  33. 41
    0
      wm-bpm/src/main/java/com/water/bpm/entity/dto/ProcessInstanceQuery.java
  34. 32
    0
      wm-bpm/src/main/java/com/water/bpm/entity/dto/ProcessStartRequest.java
  35. 60
    0
      wm-bpm/src/main/java/com/water/bpm/entity/dto/ProcessStatVO.java
  36. 39
    0
      wm-bpm/src/main/java/com/water/bpm/mapper/BpmApprovalRecordMapper.java
  37. 28
    0
      wm-bpm/src/main/java/com/water/bpm/mapper/BpmFormTemplateMapper.java
  38. 28
    0
      wm-bpm/src/main/java/com/water/bpm/mapper/BpmOrchestrationMapper.java
  39. 34
    0
      wm-bpm/src/main/java/com/water/bpm/mapper/BpmProcessDefinitionMapper.java
  40. 43
    0
      wm-bpm/src/main/java/com/water/bpm/mapper/BpmProcessInstanceMapper.java
  41. 22
    0
      wm-bpm/src/main/java/com/water/bpm/mapper/BpmProcessNodeMapper.java
  42. 25
    0
      wm-bpm/src/main/java/com/water/bpm/mapper/BpmProcessStatMapper.java
  43. 42
    0
      wm-bpm/src/main/java/com/water/bpm/mapper/BpmTodoTaskMapper.java
  44. 16
    0
      wm-config/pom.xml
  45. 15
    0
      wm-config/src/main/java/com/water/config/ConfigApplication.java
  46. 68
    0
      wm-config/src/main/java/com/water/config/controller/AnnouncementController.java
  47. 98
    0
      wm-config/src/main/java/com/water/config/controller/DeviceManageController.java
  48. 84
    0
      wm-config/src/main/java/com/water/config/controller/ThresholdController.java
  49. 35
    0
      wm-config/src/main/java/com/water/config/entity/Announcement.java
  50. 45
    0
      wm-config/src/main/java/com/water/config/entity/DeviceInfo.java
  51. 37
    0
      wm-config/src/main/java/com/water/config/entity/DeviceMaintenance.java
  52. 35
    0
      wm-config/src/main/java/com/water/config/entity/ThresholdChangeLog.java
  53. 38
    0
      wm-config/src/main/java/com/water/config/entity/ThresholdConfig.java
  54. 9
    0
      wm-config/src/main/java/com/water/config/mapper/AnnouncementMapper.java
  55. 17
    0
      wm-config/src/main/java/com/water/config/mapper/DeviceInfoMapper.java
  56. 14
    0
      wm-config/src/main/java/com/water/config/mapper/DeviceMaintenanceMapper.java
  57. 9
    0
      wm-config/src/main/java/com/water/config/mapper/ThresholdChangeLogMapper.java
  58. 17
    0
      wm-config/src/main/java/com/water/config/mapper/ThresholdConfigMapper.java
  59. 131
    0
      wm-config/src/main/java/com/water/config/service/AnnouncementService.java
  60. 150
    0
      wm-config/src/main/java/com/water/config/service/DeviceManageService.java
  61. 144
    0
      wm-config/src/main/java/com/water/config/service/ThresholdService.java
  62. 30
    0
      wm-config/src/main/resources/application.yml
  63. 102
    0
      wm-config/src/main/resources/db/schema.sql
  64. 100
    0
      wm-config/src/test/java/com/water/config/AnnouncementServiceTest.java
  65. 107
    0
      wm-config/src/test/java/com/water/config/DeviceManageServiceTest.java
  66. 96
    0
      wm-config/src/test/java/com/water/config/ThresholdServiceTest.java
  67. 114
    9
      wm-data-engine/pom.xml
  68. 62
    0
      wm-data-engine/src/main/java/com/water/data_engine/config/KafkaConfig.java
  69. 49
    0
      wm-data-engine/src/main/java/com/water/data_engine/config/MyBatisPlusConfig.java
  70. 32
    0
      wm-data-engine/src/main/java/com/water/data_engine/config/WebSocketConfig.java
  71. 85
    0
      wm-data-engine/src/main/java/com/water/data_engine/controller/DataCollectController.java
  72. 65
    0
      wm-data-engine/src/main/java/com/water/data_engine/controller/DataController.java
  73. 117
    0
      wm-data-engine/src/main/java/com/water/data_engine/controller/DataGovernanceController.java
  74. 118
    0
      wm-data-engine/src/main/java/com/water/data_engine/controller/DataIngestController.java
  75. 134
    0
      wm-data-engine/src/main/java/com/water/data_engine/controller/DataIntegrationController.java
  76. 154
    0
      wm-data-engine/src/main/java/com/water/data_engine/controller/DataStorageController.java
  77. 46
    0
      wm-data-engine/src/main/java/com/water/data_engine/entity/CollectRecord.java
  78. 82
    0
      wm-data-engine/src/main/java/com/water/data_engine/entity/CollectTask.java
  79. 41
    0
      wm-data-engine/src/main/java/com/water/data_engine/entity/DataLineage.java
  80. 67
    0
      wm-data-engine/src/main/java/com/water/data_engine/entity/DataSource.java
  81. 38
    0
      wm-data-engine/src/main/java/com/water/data_engine/entity/QualityRule.java
  82. 42
    0
      wm-data-engine/src/main/java/com/water/data_engine/entity/StorageConfig.java
  83. 43
    0
      wm-data-engine/src/main/java/com/water/data_engine/entity/SyncTask.java
  84. 12
    0
      wm-data-engine/src/main/java/com/water/data_engine/mapper/CollectRecordMapper.java
  85. 12
    0
      wm-data-engine/src/main/java/com/water/data_engine/mapper/CollectTaskMapper.java
  86. 12
    0
      wm-data-engine/src/main/java/com/water/data_engine/mapper/DataLineageMapper.java
  87. 12
    0
      wm-data-engine/src/main/java/com/water/data_engine/mapper/DataSourceMapper.java
  88. 12
    0
      wm-data-engine/src/main/java/com/water/data_engine/mapper/QualityRuleMapper.java
  89. 12
    0
      wm-data-engine/src/main/java/com/water/data_engine/mapper/StorageConfigMapper.java
  90. 12
    0
      wm-data-engine/src/main/java/com/water/data_engine/mapper/SyncTaskMapper.java
  91. 281
    0
      wm-data-engine/src/main/java/com/water/data_engine/service/DataCollectService.java
  92. 380
    0
      wm-data-engine/src/main/java/com/water/data_engine/service/DataGovernanceService.java
  93. 297
    0
      wm-data-engine/src/main/java/com/water/data_engine/service/DataIngestService.java
  94. 249
    0
      wm-data-engine/src/main/java/com/water/data_engine/service/DataIntegrationService.java
  95. 345
    0
      wm-data-engine/src/main/java/com/water/data_engine/service/DataStorageService.java
  96. 83
    0
      wm-data-engine/src/main/java/com/water/data_engine/websocket/DataWebSocketController.java
  97. 34
    0
      wm-data-engine/src/main/resources/application.yml
  98. 217
    0
      wm-data-engine/src/main/resources/db/V1__data_engine.sql
  99. 128
    0
      wm-data-engine/src/test/java/com/water/data_engine/service/DataCollectServiceTest.java
  100. 0
    0
      wm-data-engine/src/test/java/com/water/data_engine/service/DataGovernanceServiceTest.java

+ 122
- 0
db/postgresql/V2__alert_engine.sql Прегледај датотеку

@@ -0,0 +1,122 @@
1
+-- =============================================
2
+-- 智慧水务管理系统 - 报警规则引擎 + 报警管理中心 DDL
3
+-- 版本: V2
4
+-- =============================================
5
+
6
+-- ==================== 报警规则定义 ====================
7
+CREATE TABLE IF NOT EXISTS prod_alert_rule (
8
+    id              BIGSERIAL PRIMARY KEY,
9
+    rule_name       VARCHAR(100) NOT NULL,
10
+    rule_code       VARCHAR(50) UNIQUE,
11
+    description     TEXT,
12
+    device_type     VARCHAR(30),
13
+    metric_key      VARCHAR(50) NOT NULL,
14
+    alert_level     VARCHAR(10) NOT NULL DEFAULT 'general',    -- general/important/urgent
15
+    condition_expr  TEXT NOT NULL,                              -- JSON: {"op":"AND","conditions":[{"metric":"pressure","operator":">","threshold":0.8},...]}
16
+    threshold_value DECIMAL(12,4),                              -- 简单阈值(向后兼容)
17
+    debounce_sec    INT DEFAULT 300,
18
+    notify_channels VARCHAR(200),                               -- 逗号分隔: sms,wechat,app,email
19
+    notify_template VARCHAR(500),                               -- 通知模板
20
+    enabled         SMALLINT DEFAULT 1,
21
+    priority        INT DEFAULT 0,                              -- 规则优先级
22
+    effective_start TIME,                                       -- 生效开始时间
23
+    effective_end   TIME,                                       -- 生效结束时间
24
+    created_by      BIGINT,
25
+    updated_by      BIGINT,
26
+    deleted         SMALLINT DEFAULT 0,
27
+    created_time    TIMESTAMP DEFAULT NOW(),
28
+    updated_time    TIMESTAMP DEFAULT NOW()
29
+);
30
+COMMENT ON TABLE prod_alert_rule IS '报警规则定义表';
31
+COMMENT ON COLUMN prod_alert_rule.alert_level IS '报警等级: general(一般)/important(重要)/urgent(紧急)';
32
+COMMENT ON COLUMN prod_alert_rule.condition_expr IS '条件表达式JSON: 支持AND/OR组合条件';
33
+
34
+-- ==================== 报警记录(全生命周期) ====================
35
+CREATE TABLE IF NOT EXISTS prod_alert_record (
36
+    id              BIGSERIAL PRIMARY KEY,
37
+    rule_id         BIGINT REFERENCES prod_alert_rule(id),
38
+    rule_name       VARCHAR(100),
39
+    device_id       BIGINT,
40
+    device_sn       VARCHAR(100),
41
+    device_name     VARCHAR(200),
42
+    area            VARCHAR(50),
43
+    metric_key      VARCHAR(50) NOT NULL,
44
+    metric_value    DECIMAL(12,4),
45
+    threshold_value VARCHAR(50),
46
+    alert_level     VARCHAR(10) NOT NULL DEFAULT 'general',
47
+    title           VARCHAR(200),
48
+    message         TEXT,
49
+    -- 生命周期状态: 0=活跃 1=已确认 2=已派单 3=处理中 4=已处理 5=已归档
50
+    status          INT DEFAULT 0,
51
+    confirmed_by    BIGINT,
52
+    confirmed_time  TIMESTAMP,
53
+    dispatch_time   TIMESTAMP,
54
+    assignee_id     BIGINT,
55
+    assignee_name   VARCHAR(50),
56
+    handler_id      BIGINT,
57
+    handler_name    VARCHAR(50),
58
+    handle_result   TEXT,
59
+    handle_time     TIMESTAMP,
60
+    archive_time    TIMESTAMP,
61
+    archive_reason  VARCHAR(500),
62
+    resolved_at     TIMESTAMP,
63
+    created_time    TIMESTAMP DEFAULT NOW(),
64
+    updated_time    TIMESTAMP DEFAULT NOW(),
65
+    deleted         SMALLINT DEFAULT 0
66
+);
67
+COMMENT ON TABLE prod_alert_record IS '报警记录表(全生命周期)';
68
+CREATE INDEX IF NOT EXISTS idx_alert_record_time ON prod_alert_record(created_time DESC);
69
+CREATE INDEX IF NOT EXISTS idx_alert_record_device ON prod_alert_record(device_sn, created_time DESC);
70
+CREATE INDEX IF NOT EXISTS idx_alert_record_status ON prod_alert_record(status);
71
+CREATE INDEX IF NOT EXISTS idx_alert_record_level ON prod_alert_record(alert_level);
72
+CREATE INDEX IF NOT EXISTS idx_alert_record_area ON prod_alert_record(area);
73
+
74
+-- ==================== 报警通知记录 ====================
75
+CREATE TABLE IF NOT EXISTS prod_alert_notification (
76
+    id              BIGSERIAL PRIMARY KEY,
77
+    alert_record_id BIGINT REFERENCES prod_alert_record(id),
78
+    rule_id         BIGINT,
79
+    channel         VARCHAR(30) NOT NULL,        -- sms/wechat/app/email
80
+    recipient       VARCHAR(100) NOT NULL,       -- 接收人标识
81
+    recipient_name  VARCHAR(50),
82
+    title           VARCHAR(200),
83
+    content         TEXT,
84
+    status          INT DEFAULT 0,               -- 0=待发送 1=已发送 2=发送失败 3=已读
85
+    send_time       TIMESTAMP,
86
+    read_time       TIMESTAMP,
87
+    retry_count     INT DEFAULT 0,
88
+    error_msg       VARCHAR(500),
89
+    created_time    TIMESTAMP DEFAULT NOW()
90
+);
91
+COMMENT ON TABLE prod_alert_notification IS '报警通知记录表';
92
+CREATE INDEX IF NOT EXISTS idx_alert_notif_record ON prod_alert_notification(alert_record_id);
93
+CREATE INDEX IF NOT EXISTS idx_alert_notif_status ON prod_alert_notification(status);
94
+
95
+-- ==================== 报警规则-设备关联(可选) ====================
96
+CREATE TABLE IF NOT EXISTS prod_alert_rule_device (
97
+    id              BIGSERIAL PRIMARY KEY,
98
+    rule_id         BIGINT REFERENCES prod_alert_rule(id),
99
+    device_id       BIGINT,
100
+    device_sn       VARCHAR(100),
101
+    area            VARCHAR(50),
102
+    created_time    TIMESTAMP DEFAULT NOW()
103
+);
104
+COMMENT ON TABLE prod_alert_rule_device IS '报警规则-设备/区域关联表';
105
+
106
+-- ==================== 初始规则数据 ====================
107
+INSERT INTO prod_alert_rule (rule_name, rule_code, metric_key, alert_level, condition_expr, threshold_value, debounce_sec, description, enabled) VALUES
108
+('管网压力过高报警', 'RULE_PRESSURE_HIGH', 'pressure', 'urgent',
109
+ '{"op":"AND","conditions":[{"metric":"pressure","operator":">","threshold":0.8}]}',
110
+ 0.8000, 300, '管网压力超过0.8MPa时触发紧急报警', 1),
111
+('管网压力过低报警', 'RULE_PRESSURE_LOW', 'pressure', 'important',
112
+ '{"op":"OR","conditions":[{"metric":"pressure","operator":"<","threshold":0.2}]}',
113
+ 0.2000, 300, '管网压力低于0.2MPa时触发重要报警', 1),
114
+('水质浊度超标', 'RULE_TURBIDITY_HIGH', 'turbidity', 'urgent',
115
+ '{"op":"AND","conditions":[{"metric":"turbidity","operator":">","threshold":1.0}]}',
116
+ 1.0000, 600, '水质浊度超过1.0NTU触发紧急报警', 1),
117
+('余氯偏低报警', 'RULE_CHLORINE_LOW', 'residual_chlorine', 'general',
118
+ '{"op":"AND","conditions":[{"metric":"residual_chlorine","operator":"<","threshold":0.1}]}',
119
+ 0.1000, 600, '余氯低于0.1mg/L触发一般报警', 1),
120
+('流量异常波动', 'RULE_FLOW_ANOMALY', 'flow', 'important',
121
+ '{"op":"OR","conditions":[{"metric":"flow","operator":">","threshold":100},{"metric":"flow","operator":"<","threshold":5}]}',
122
+ NULL, 120, '流量异常偏高或偏低时触发报警', 1);

+ 68
- 0
frontend/src/api/dispatchCommand.ts Прегледај датотеку

@@ -0,0 +1,68 @@
1
+import request from './request'
2
+
3
+const BASE = '/api/production/dispatch-command'
4
+
5
+// 创建指令
6
+export function createCommand(data: any) {
7
+  return request.post(BASE, data)
8
+}
9
+
10
+// 下发指令
11
+export function issueCommand(id: number, issuedBy: number, operatorName?: string) {
12
+  return request.post(`${BASE}/${id}/issue`, null, {
13
+    params: { issuedBy, operatorName: operatorName || 'system' }
14
+  })
15
+}
16
+
17
+// 指令台账
18
+export function listCommands(params: {
19
+  page?: number; size?: number; status?: string;
20
+  commandType?: string; keyword?: string; startDate?: string; endDate?: string
21
+}) {
22
+  return request.get(BASE, { params })
23
+}
24
+
25
+// 指令详情
26
+export function getCommandDetail(id: number) {
27
+  return request.get(`${BASE}/${id}`)
28
+}
29
+
30
+// 状态统计
31
+export function getCommandStats() {
32
+  return request.get(`${BASE}/stats`)
33
+}
34
+
35
+// 接收确认
36
+export function receiveCommand(id: number, userId: number, userName?: string) {
37
+  return request.post(`${BASE}/${id}/receive`, null, {
38
+    params: { userId, userName: userName || '' }
39
+  })
40
+}
41
+
42
+// 开始执行
43
+export function startExecute(id: number, userId: number, userName?: string) {
44
+  return request.post(`${BASE}/${id}/start-execute`, null, {
45
+    params: { userId, userName: userName || '' }
46
+  })
47
+}
48
+
49
+// 完成执行
50
+export function completeExecution(id: number, userId: number, data: {
51
+  userName?: string; feedback?: string; feedbackImages?: string
52
+}) {
53
+  return request.post(`${BASE}/${id}/complete`, null, {
54
+    params: { userId, ...data }
55
+  })
56
+}
57
+
58
+// 驳回
59
+export function rejectExecution(id: number, userId: number, reason: string, userName?: string) {
60
+  return request.post(`${BASE}/${id}/reject`, null, {
61
+    params: { userId, userName: userName || '', reason }
62
+  })
63
+}
64
+
65
+// 追踪日志
66
+export function getTrackingLogs(id: number) {
67
+  return request.get(`${BASE}/${id}/tracking`)
68
+}

+ 2
- 0
frontend/src/router/index.ts Прегледај датотеку

@@ -11,6 +11,8 @@ const routes = [
11 11
       { path: 'system/role', name: 'role', component: () => import('@/views/system/role/RoleList.vue') },
12 12
       { path: 'system/menu', name: 'menu', component: () => import('@/views/system/menu/MenuList.vue') },
13 13
       { path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') },
14
+      { path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
15
+      { path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
14 16
     ]
15 17
   },
16 18
   { path: '/:pathMatch(.*)*', redirect: '/dashboard' }

+ 135
- 0
frontend/src/views/dispatch-command/CommandCreate.vue Прегледај датотеку

@@ -0,0 +1,135 @@
1
+<template>
2
+  <el-dialog
3
+    :model-value="visible"
4
+    @update:model-value="$emit('update:visible', $event)"
5
+    title="创建调度指令"
6
+    width="600"
7
+    :close-on-click-modal="false">
8
+    <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
9
+      <el-form-item label="指令标题" prop="commandTitle">
10
+        <el-input v-model="form.commandTitle" placeholder="请输入指令标题" maxlength="200" show-word-limit />
11
+      </el-form-item>
12
+
13
+      <el-form-item label="指令类型" prop="commandType">
14
+        <el-select v-model="form.commandType" placeholder="请选择">
15
+          <el-option label="常规" value="normal" />
16
+          <el-option label="应急" value="emergency" />
17
+          <el-option label="维护" value="maintenance" />
18
+          <el-option label="巡检" value="inspection" />
19
+        </el-select>
20
+      </el-form-item>
21
+
22
+      <el-form-item label="优先级" prop="priority">
23
+        <el-radio-group v-model="form.priority">
24
+          <el-radio value="low">低</el-radio>
25
+          <el-radio value="normal">普通</el-radio>
26
+          <el-radio value="high">高</el-radio>
27
+          <el-radio value="urgent">紧急</el-radio>
28
+        </el-radio-group>
29
+      </el-form-item>
30
+
31
+      <el-form-item label="来源" prop="source">
32
+        <el-input v-model="form.source" placeholder="手动/系统/报警联动" />
33
+      </el-form-item>
34
+
35
+      <el-form-item label="指令内容" prop="commandContent">
36
+        <el-input v-model="form.commandContent" type="textarea" :rows="5"
37
+          placeholder="请输入指令详细内容" maxlength="2000" show-word-limit />
38
+      </el-form-item>
39
+
40
+      <el-form-item label="目标类型" prop="targetType">
41
+        <el-select v-model="form.targetType" placeholder="请选择">
42
+          <el-option label="指定人员" value="user" />
43
+          <el-option label="部门" value="dept" />
44
+          <el-option label="角色" value="role" />
45
+        </el-select>
46
+      </el-form-item>
47
+
48
+      <el-form-item label="目标人员" prop="targetIds">
49
+        <el-input v-model="form.targetIds" placeholder="目标ID列表,多个用逗号分隔,如: 1,2,3" />
50
+        <div class="form-tip">输入用户ID,多个用逗号分隔</div>
51
+      </el-form-item>
52
+
53
+      <el-form-item label="备注">
54
+        <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="备注信息(可选)" />
55
+      </el-form-item>
56
+    </el-form>
57
+
58
+    <template #footer>
59
+      <el-button @click="$emit('update:visible', false)">取消</el-button>
60
+      <el-button type="primary" :loading="submitting" @click="handleSubmit">
61
+        创建指令
62
+      </el-button>
63
+    </template>
64
+  </el-dialog>
65
+</template>
66
+
67
+<script setup lang="ts">
68
+import { ref, reactive, watch } from 'vue'
69
+import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
70
+import { createCommand } from '@/api/dispatchCommand'
71
+
72
+const props = defineProps<{ visible: boolean }>()
73
+const emit = defineEmits<{
74
+  'update:visible': [value: boolean]
75
+  'created': []
76
+}>()
77
+
78
+const formRef = ref<FormInstance>()
79
+const submitting = ref(false)
80
+
81
+const defaultForm = () => ({
82
+  commandTitle: '',
83
+  commandType: 'normal',
84
+  priority: 'normal',
85
+  source: '手动',
86
+  commandContent: '',
87
+  targetType: 'user',
88
+  targetIds: '',
89
+  remark: ''
90
+})
91
+
92
+const form = reactive(defaultForm())
93
+
94
+const rules: FormRules = {
95
+  commandTitle: [{ required: true, message: '请输入指令标题', trigger: 'blur' }],
96
+  commandType: [{ required: true, message: '请选择指令类型', trigger: 'change' }],
97
+  priority: [{ required: true, message: '请选择优先级', trigger: 'change' }],
98
+  commandContent: [{ required: true, message: '请输入指令内容', trigger: 'blur' }],
99
+  targetType: [{ required: true, message: '请选择目标类型', trigger: 'change' }],
100
+  targetIds: [{ required: true, message: '请输入目标ID', trigger: 'blur' }]
101
+}
102
+
103
+watch(() => props.visible, (val) => {
104
+  if (val) {
105
+    Object.assign(form, defaultForm())
106
+    formRef.value?.resetFields()
107
+  }
108
+})
109
+
110
+async function handleSubmit() {
111
+  if (!formRef.value) return
112
+  await formRef.value.validate()
113
+
114
+  submitting.value = true
115
+  try {
116
+    // 将 targetIds 转为 JSON 数组格式
117
+    const targetIdsArr = form.targetIds.split(',').map((s: string) => s.trim()).filter(Boolean)
118
+    await createCommand({
119
+      ...form,
120
+      targetIds: JSON.stringify(targetIdsArr.map(Number))
121
+    })
122
+    ElMessage.success('指令创建成功')
123
+    emit('update:visible', false)
124
+    emit('created')
125
+  } catch (e: any) {
126
+    ElMessage.error(e.message || '创建失败')
127
+  } finally {
128
+    submitting.value = false
129
+  }
130
+}
131
+</script>
132
+
133
+<style scoped>
134
+.form-tip { font-size: 12px; color: #909399; margin-top: 4px; }
135
+</style>

+ 304
- 0
frontend/src/views/dispatch-command/CommandDetail.vue Прегледај датотеку

@@ -0,0 +1,304 @@
1
+<template>
2
+  <div class="command-detail" v-loading="loading">
3
+    <!-- 返回按钮 -->
4
+    <el-page-header @back="router.back()" :title="'返回'" style="margin-bottom: 16px">
5
+      <template #content>
6
+        <span class="page-title">指令详情</span>
7
+        <el-tag :type="statusTag(detail.status)" style="margin-left: 12px">{{ statusLabel(detail.status) }}</el-tag>
8
+      </template>
9
+    </el-page-header>
10
+
11
+    <el-row :gutter="16">
12
+      <!-- 左侧:基本信息 + 状态流转图 -->
13
+      <el-col :span="14">
14
+        <el-card>
15
+          <template #header>
16
+            <span>{{ detail.command_title }}</span>
17
+            <el-tag size="small" style="margin-left: 8px">{{ detail.command_no }}</el-tag>
18
+          </template>
19
+
20
+          <el-descriptions :column="2" border>
21
+            <el-descriptions-item label="指令编号">{{ detail.command_no }}</el-descriptions-item>
22
+            <el-descriptions-item label="类型">
23
+              <el-tag size="small">{{ typeLabel(detail.command_type) }}</el-tag>
24
+            </el-descriptions-item>
25
+            <el-descriptions-item label="优先级">
26
+              <el-tag :type="priorityTag(detail.priority)" size="small">{{ priorityLabel(detail.priority) }}</el-tag>
27
+            </el-descriptions-item>
28
+            <el-descriptions-item label="来源">{{ detail.source || '-' }}</el-descriptions-item>
29
+            <el-descriptions-item label="创建时间">{{ detail.created_at }}</el-descriptions-item>
30
+            <el-descriptions-item label="下发时间">{{ detail.issued_at || '-' }}</el-descriptions-item>
31
+            <el-descriptions-item label="完成时间">{{ detail.completed_at || '-' }}</el-descriptions-item>
32
+            <el-descriptions-item label="目标类型">{{ detail.target_type || '-' }}</el-descriptions-item>
33
+          </el-descriptions>
34
+
35
+          <div style="margin-top: 16px">
36
+            <h4>指令内容</h4>
37
+            <div class="content-block">{{ detail.command_content }}</div>
38
+          </div>
39
+
40
+          <!-- 状态流转图 -->
41
+          <div style="margin-top: 24px">
42
+            <h4>状态流转</h4>
43
+            <el-steps :active="statusStep(detail.status)" finish-status="success" align-center>
44
+              <el-step title="草稿" description="创建指令" />
45
+              <el-step title="已下发" description="下发给执行人" />
46
+              <el-step title="已接收" description="执行人确认" />
47
+              <el-step title="执行中" description="正在执行" />
48
+              <el-step title="完成/驳回" description="归档" />
49
+            </el-steps>
50
+          </div>
51
+        </el-card>
52
+      </el-col>
53
+
54
+      <!-- 右侧:执行记录列表 -->
55
+      <el-col :span="10">
56
+        <el-card>
57
+          <template #header>执行记录</template>
58
+          <el-timeline v-if="executions.length">
59
+            <el-timeline-item
60
+              v-for="exec in executions" :key="exec.id"
61
+              :type="executionTimelineType(exec.execute_status)"
62
+              :timestamp="exec.received_at || exec.created_at"
63
+              placement="top">
64
+              <div class="exec-card">
65
+                <div class="exec-header">
66
+                  <span class="exec-user">{{ exec.user_name || `用户${exec.user_id}` }}</span>
67
+                  <el-tag :type="executionStatusTag(exec.execute_status)" size="small">
68
+                    {{ executionStatusLabel(exec.execute_status) }}
69
+                  </el-tag>
70
+                </div>
71
+                <div v-if="exec.feedback" class="exec-feedback">
72
+                  反馈: {{ exec.feedback }}
73
+                </div>
74
+                <div v-if="exec.rejected_reason" class="exec-reject">
75
+                  驳回原因: {{ exec.rejected_reason }}
76
+                </div>
77
+                <div class="exec-actions" v-if="canOperate(exec)">
78
+                  <el-button size="small" type="primary"
79
+                    v-if="exec.execute_status === 'pending'"
80
+                    @click="handleReceive(exec)">接收</el-button>
81
+                  <el-button size="small" type="success"
82
+                    v-if="exec.execute_status === 'received'"
83
+                    @click="handleStartExecute(exec)">开始执行</el-button>
84
+                  <el-button size="small" type="success"
85
+                    v-if="exec.execute_status === 'executing'"
86
+                    @click="showCompleteDialog(exec)">完成</el-button>
87
+                  <el-button size="small" type="danger"
88
+                    v-if="exec.execute_status !== 'completed' && exec.execute_status !== 'rejected'"
89
+                    @click="handleReject(exec)">驳回</el-button>
90
+                </div>
91
+              </div>
92
+            </el-timeline-item>
93
+          </el-timeline>
94
+          <el-empty v-else description="暂无执行记录" />
95
+        </el-card>
96
+      </el-col>
97
+    </el-row>
98
+
99
+    <!-- 追踪日志 -->
100
+    <el-card style="margin-top: 16px">
101
+      <template #header>全过程追踪日志</template>
102
+      <el-timeline>
103
+        <el-timeline-item
104
+          v-for="log in trackingLogs" :key="log.id"
105
+          :timestamp="log.created_at" placement="top"
106
+          :type="trackingType(log.action)">
107
+          <div>
108
+            <el-tag size="small" :type="trackingType(log.action)">{{ trackingActionLabel(log.action) }}</el-tag>
109
+            <span style="margin-left: 8px">{{ log.operator_name || '' }}</span>
110
+            <span v-if="log.from_status" style="margin-left: 8px; color: #909399">
111
+              {{ log.from_status }} → {{ log.to_status }}
112
+            </span>
113
+            <div v-if="log.remark" style="color: #606266; margin-top: 4px">{{ log.remark }}</div>
114
+          </div>
115
+        </el-timeline-item>
116
+      </el-timeline>
117
+      <el-empty v-if="!trackingLogs.length" description="暂无追踪日志" />
118
+    </el-card>
119
+
120
+    <!-- 完成弹窗 -->
121
+    <el-dialog v-model="completeVisible" title="完成执行" width="500">
122
+      <el-form label-width="80px">
123
+        <el-form-item label="反馈说明">
124
+          <el-input v-model="completeForm.feedback" type="textarea" :rows="3" placeholder="请输入执行反馈" />
125
+        </el-form-item>
126
+        <el-form-item label="反馈图片">
127
+          <el-input v-model="completeForm.feedbackImages" placeholder="图片URL,多个用逗号分隔" />
128
+        </el-form-item>
129
+      </el-form>
130
+      <template #footer>
131
+        <el-button @click="completeVisible = false">取消</el-button>
132
+        <el-button type="primary" @click="handleComplete">确认完成</el-button>
133
+      </template>
134
+    </el-dialog>
135
+  </div>
136
+</template>
137
+
138
+<script setup lang="ts">
139
+import { ref, reactive, onMounted } from 'vue'
140
+import { useRoute, useRouter } from 'vue-router'
141
+import { ElMessage, ElMessageBox } from 'element-plus'
142
+import { getCommandDetail, receiveCommand, startExecute, completeExecution, rejectExecution } from '@/api/dispatchCommand'
143
+
144
+const route = useRoute()
145
+const router = useRouter()
146
+const loading = ref(false)
147
+const detail = ref<any>({})
148
+const executions = ref<any[]>([])
149
+const trackingLogs = ref<any[]>([])
150
+const completeVisible = ref(false)
151
+const currentExec = ref<any>(null)
152
+const completeForm = reactive({ feedback: '', feedbackImages: '' })
153
+
154
+const commandId = Number(route.params.id)
155
+
156
+// 模拟当前用户ID(实际应从登录态获取)
157
+const currentUserId = 1
158
+const currentUserName = 'admin'
159
+
160
+const statusMap: Record<string, { label: string; type: string; step: number }> = {
161
+  draft: { label: '草稿', type: 'info', step: 0 },
162
+  issued: { label: '已下发', type: 'warning', step: 1 },
163
+  received: { label: '已接收', type: '', step: 2 },
164
+  executing: { label: '执行中', type: 'primary', step: 3 },
165
+  completed: { label: '已完成', type: 'success', step: 4 },
166
+  rejected: { label: '已驳回', type: 'danger', step: 4 }
167
+}
168
+
169
+const statusLabel = (s: string) => statusMap[s]?.label || s
170
+const statusTag = (s: string) => (statusMap[s]?.type || 'info') as any
171
+const statusStep = (s: string) => statusMap[s]?.step || 0
172
+
173
+const typeMap: Record<string, string> = { normal: '常规', emergency: '应急', maintenance: '维护', inspection: '巡检' }
174
+const typeLabel = (t: string) => typeMap[t] || t
175
+
176
+const priorityMap: Record<string, { label: string; type: string }> = {
177
+  low: { label: '低', type: 'info' }, normal: { label: '普通', type: '' },
178
+  high: { label: '高', type: 'warning' }, urgent: { label: '紧急', type: 'danger' }
179
+}
180
+const priorityLabel = (p: string) => priorityMap[p]?.label || p
181
+const priorityTag = (p: string) => (priorityMap[p]?.type || 'info') as any
182
+
183
+const executionStatusLabel = (s: string) => {
184
+  const map: Record<string, string> = {
185
+    pending: '待接收', received: '已接收', executing: '执行中', completed: '已完成', rejected: '已驳回'
186
+  }
187
+  return map[s] || s
188
+}
189
+const executionStatusTag = (s: string) => {
190
+  const map: Record<string, string> = {
191
+    pending: 'info', received: '', executing: 'primary', completed: 'success', rejected: 'danger'
192
+  }
193
+  return (map[s] || 'info') as any
194
+}
195
+const executionTimelineType = (s: string) => {
196
+  const map: Record<string, string> = {
197
+    pending: 'info', received: 'primary', executing: 'primary', completed: 'success', rejected: 'danger'
198
+  }
199
+  return (map[s] || 'info') as any
200
+}
201
+
202
+const trackingActionLabel = (a: string) => {
203
+  const map: Record<string, string> = {
204
+    create: '创建', issue: '下发', receive: '接收', start_execute: '开始执行',
205
+    complete: '完成', reject: '驳回', cancel: '取消'
206
+  }
207
+  return map[a] || a
208
+}
209
+const trackingType = (a: string) => {
210
+  const map: Record<string, string> = {
211
+    create: 'info', issue: 'warning', receive: 'primary', start_execute: 'primary',
212
+    complete: 'success', reject: 'danger', cancel: 'danger'
213
+  }
214
+  return (map[a] || 'info') as any
215
+}
216
+
217
+function canOperate(exec: any) {
218
+  return exec.user_id === currentUserId || true // 简化:所有人可操作
219
+}
220
+
221
+async function fetchDetail() {
222
+  loading.value = true
223
+  try {
224
+    const res = await getCommandDetail(commandId)
225
+    detail.value = res.data || {}
226
+    executions.value = res.data?.executions || []
227
+    trackingLogs.value = res.data?.tracking_logs || res.data?.trackingLogs || []
228
+  } finally {
229
+    loading.value = false
230
+  }
231
+}
232
+
233
+async function handleReceive(exec: any) {
234
+  try {
235
+    await receiveCommand(commandId, exec.user_id, exec.user_name)
236
+    ElMessage.success('接收成功')
237
+    fetchDetail()
238
+  } catch (e: any) {
239
+    ElMessage.error(e.message || '操作失败')
240
+  }
241
+}
242
+
243
+async function handleStartExecute(exec: any) {
244
+  try {
245
+    await startExecute(commandId, exec.user_id, exec.user_name)
246
+    ElMessage.success('已开始执行')
247
+    fetchDetail()
248
+  } catch (e: any) {
249
+    ElMessage.error(e.message || '操作失败')
250
+  }
251
+}
252
+
253
+function showCompleteDialog(exec: any) {
254
+  currentExec.value = exec
255
+  completeForm.feedback = ''
256
+  completeForm.feedbackImages = ''
257
+  completeVisible.value = true
258
+}
259
+
260
+async function handleComplete() {
261
+  try {
262
+    await completeExecution(commandId, currentExec.value.user_id, {
263
+      userName: currentExec.value.user_name,
264
+      feedback: completeForm.feedback,
265
+      feedbackImages: completeForm.feedbackImages
266
+    })
267
+    ElMessage.success('执行完成')
268
+    completeVisible.value = false
269
+    fetchDetail()
270
+  } catch (e: any) {
271
+    ElMessage.error(e.message || '操作失败')
272
+  }
273
+}
274
+
275
+async function handleReject(exec: any) {
276
+  try {
277
+    const { value } = await ElMessageBox.prompt('请输入驳回原因', '驳回', {
278
+      confirmButtonText: '确认驳回',
279
+      cancelButtonText: '取消',
280
+      inputPattern: /.+/,
281
+      inputErrorMessage: '驳回原因不能为空'
282
+    })
283
+    await rejectExecution(commandId, exec.user_id, value, exec.user_name)
284
+    ElMessage.success('已驳回')
285
+    fetchDetail()
286
+  } catch { /* cancel */ }
287
+}
288
+
289
+onMounted(fetchDetail)
290
+</script>
291
+
292
+<style scoped>
293
+.page-title { font-size: 16px; font-weight: 600; }
294
+.content-block {
295
+  padding: 12px; background: #f5f7fa; border-radius: 4px;
296
+  white-space: pre-wrap; line-height: 1.6;
297
+}
298
+.exec-card { padding: 4px 0; }
299
+.exec-header { display: flex; justify-content: space-between; align-items: center; }
300
+.exec-user { font-weight: 600; }
301
+.exec-feedback { margin-top: 6px; color: #606266; font-size: 13px; }
302
+.exec-reject { margin-top: 6px; color: #f56c6c; font-size: 13px; }
303
+.exec-actions { margin-top: 8px; }
304
+</style>

+ 193
- 0
frontend/src/views/dispatch-command/CommandList.vue Прегледај датотеку

@@ -0,0 +1,193 @@
1
+<template>
2
+  <div class="command-list">
3
+    <el-card shadow="never" class="filter-card">
4
+      <el-form :inline="true" :model="filterForm">
5
+        <el-form-item label="关键词">
6
+          <el-input v-model="filterForm.keyword" placeholder="编号/标题" clearable @clear="handleSearch" />
7
+        </el-form-item>
8
+        <el-form-item label="状态">
9
+          <el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
10
+            <el-option label="草稿" value="draft" />
11
+            <el-option label="已下发" value="issued" />
12
+            <el-option label="已接收" value="received" />
13
+            <el-option label="执行中" value="executing" />
14
+            <el-option label="已完成" value="completed" />
15
+            <el-option label="已驳回" value="rejected" />
16
+          </el-select>
17
+        </el-form-item>
18
+        <el-form-item label="类型">
19
+          <el-select v-model="filterForm.commandType" placeholder="全部" clearable @change="handleSearch">
20
+            <el-option label="常规" value="normal" />
21
+            <el-option label="应急" value="emergency" />
22
+            <el-option label="维护" value="maintenance" />
23
+            <el-option label="巡检" value="inspection" />
24
+          </el-select>
25
+        </el-form-item>
26
+        <el-form-item label="时间范围">
27
+          <el-date-picker v-model="dateRange" type="daterange" range-separator="至"
28
+            start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD"
29
+            @change="handleSearch" />
30
+        </el-form-item>
31
+        <el-form-item>
32
+          <el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
33
+          <el-button @click="handleReset">重置</el-button>
34
+        </el-form-item>
35
+      </el-form>
36
+    </el-card>
37
+
38
+    <el-row :gutter="12" style="margin-top: 12px">
39
+      <el-col :span="4" v-for="stat in stats" :key="stat.status">
40
+        <el-card shadow="hover" class="stat-card" @click="filterByStatus(stat.status)">
41
+          <div class="stat-value">{{ stat.count }}</div>
42
+          <div class="stat-label">{{ statusLabel(stat.status) }}</div>
43
+        </el-card>
44
+      </el-col>
45
+    </el-row>
46
+
47
+    <div style="margin-top: 16px; display: flex; justify-content: space-between; align-items: center">
48
+      <el-button type="primary" @click="showCreateDialog = true"><el-icon><Plus /></el-icon> 创建指令</el-button>
49
+      <el-button @click="handleBatchIssue" :disabled="!selectedIds.length">批量下发</el-button>
50
+    </div>
51
+
52
+    <el-table :data="tableData" border style="margin-top: 10px"
53
+      @selection-change="handleSelectionChange" v-loading="loading">
54
+      <el-table-column type="selection" width="50" />
55
+      <el-table-column prop="command_no" label="指令编号" width="220" />
56
+      <el-table-column prop="command_title" label="标题" min-width="200" show-overflow-tooltip />
57
+      <el-table-column prop="command_type" label="类型" width="80">
58
+        <template #default="{ row }">
59
+          <el-tag :type="typeTag(row.command_type)" size="small">{{ typeLabel(row.command_type) }}</el-tag>
60
+        </template>
61
+      </el-table-column>
62
+      <el-table-column prop="priority" label="优先级" width="80">
63
+        <template #default="{ row }">
64
+          <el-tag :type="priorityTag(row.priority)" size="small">{{ priorityLabel(row.priority) }}</el-tag>
65
+        </template>
66
+      </el-table-column>
67
+      <el-table-column prop="status" label="状态" width="90">
68
+        <template #default="{ row }">
69
+          <el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
70
+        </template>
71
+      </el-table-column>
72
+      <el-table-column label="执行进度" width="120">
73
+        <template #default="{ row }">
74
+          <span>{{ row.completed_count || 0 }}/{{ row.total_executions || 0 }}</span>
75
+        </template>
76
+      </el-table-column>
77
+      <el-table-column prop="created_at" label="创建时间" width="170" />
78
+      <el-table-column label="操作" width="200" fixed="right">
79
+        <template #default="{ row }">
80
+          <el-button link type="primary" @click="viewDetail(row)">详情</el-button>
81
+          <el-button link type="success" v-if="row.status === 'draft'" @click="handleIssue(row)">下发</el-button>
82
+          <el-button link type="danger" v-if="row.status === 'draft'" @click="handleDelete(row)">删除</el-button>
83
+        </template>
84
+      </el-table-column>
85
+    </el-table>
86
+
87
+    <el-pagination style="margin-top: 16px; justify-content: flex-end"
88
+      v-model:current-page="pagination.page" v-model:page-size="pagination.size"
89
+      :total="pagination.total" :page-sizes="[10, 20, 50]"
90
+      layout="total, sizes, prev, pager, next" @change="fetchData" />
91
+
92
+    <CommandCreate v-model:visible="showCreateDialog" @created="handleCreated" />
93
+  </div>
94
+</template>
95
+
96
+<script setup lang="ts">
97
+import { ref, reactive, onMounted } from 'vue'
98
+import { useRouter } from 'vue-router'
99
+import { ElMessage, ElMessageBox } from 'element-plus'
100
+import { Search, Plus } from '@element-plus/icons-vue'
101
+import { listCommands, issueCommand, getCommandStats } from '@/api/dispatchCommand'
102
+import CommandCreate from './CommandCreate.vue'
103
+
104
+const router = useRouter()
105
+const loading = ref(false)
106
+const tableData = ref<any[]>([])
107
+const stats = ref<any[]>([])
108
+const selectedIds = ref<number[]>([])
109
+const showCreateDialog = ref(false)
110
+const dateRange = ref<[string, string] | null>(null)
111
+
112
+const filterForm = reactive({ keyword: '', status: '', commandType: '' })
113
+const pagination = reactive({ page: 1, size: 10, total: 0 })
114
+
115
+const statusMap: Record<string, { label: string; type: string }> = {
116
+  draft: { label: '草稿', type: 'info' },
117
+  issued: { label: '已下发', type: 'warning' },
118
+  received: { label: '已接收', type: '' },
119
+  executing: { label: '执行中', type: 'primary' },
120
+  completed: { label: '已完成', type: 'success' },
121
+  rejected: { label: '已驳回', type: 'danger' }
122
+}
123
+const typeMap: Record<string, string> = { normal: '常规', emergency: '应急', maintenance: '维护', inspection: '巡检' }
124
+const priorityMap: Record<string, { label: string; type: string }> = {
125
+  low: { label: '低', type: 'info' }, normal: { label: '普通', type: '' },
126
+  high: { label: '高', type: 'warning' }, urgent: { label: '紧急', type: 'danger' }
127
+}
128
+
129
+function statusLabel(s: string) { return statusMap[s]?.label || s }
130
+function statusTag(s: string) { return (statusMap[s]?.type || 'info') as any }
131
+function typeLabel(t: string) { return typeMap[t] || t }
132
+function typeTag(t: string) { return t === 'emergency' ? 'danger' : t === 'maintenance' ? 'warning' : '' }
133
+function priorityLabel(p: string) { return priorityMap[p]?.label || p }
134
+function priorityTag(p: string) { return (priorityMap[p]?.type || 'info') as any }
135
+
136
+async function fetchData() {
137
+  loading.value = true
138
+  try {
139
+    const res = await listCommands({
140
+      page: pagination.page, size: pagination.size,
141
+      status: filterForm.status || undefined,
142
+      commandType: filterForm.commandType || undefined,
143
+      keyword: filterForm.keyword || undefined,
144
+      startDate: dateRange.value?.[0], endDate: dateRange.value?.[1]
145
+    })
146
+    tableData.value = res.data?.records || []
147
+    pagination.total = res.data?.total || 0
148
+  } finally { loading.value = false }
149
+}
150
+
151
+async function fetchStats() {
152
+  try { const res = await getCommandStats(); stats.value = res.data || [] } catch { /* ignore */ }
153
+}
154
+
155
+function handleSearch() { pagination.page = 1; fetchData() }
156
+function handleReset() {
157
+  filterForm.keyword = ''; filterForm.status = ''; filterForm.commandType = ''; dateRange.value = null; handleSearch()
158
+}
159
+function filterByStatus(status: string) { filterForm.status = status; handleSearch() }
160
+function handleSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id) }
161
+function viewDetail(row: any) { router.push({ path: `/dispatch-command/${row.id}` }) }
162
+
163
+async function handleIssue(row: any) {
164
+  try {
165
+    await ElMessageBox.confirm(`确认下发指令 "${row.command_title}" ?`, '下发确认')
166
+    await issueCommand(row.id, 1, 'admin'); ElMessage.success('指令已下发'); fetchData(); fetchStats()
167
+  } catch { /* cancel */ }
168
+}
169
+
170
+async function handleBatchIssue() {
171
+  try {
172
+    await ElMessageBox.confirm(`确认批量下发 ${selectedIds.value.length} 条指令?`, '批量下发')
173
+    for (const id of selectedIds.value) { await issueCommand(id, 1, 'admin') }
174
+    ElMessage.success('批量下发完成'); fetchData(); fetchStats()
175
+  } catch { /* cancel */ }
176
+}
177
+
178
+function handleDelete(row: any) {
179
+  ElMessageBox.confirm(`确认删除指令 "${row.command_title}" ?`, '删除确认', { type: 'warning' })
180
+    .then(() => { ElMessage.info('删除功能待实现(逻辑删除)') }).catch(() => { /* cancel */ })
181
+}
182
+
183
+function handleCreated() { showCreateDialog.value = false; fetchData(); fetchStats() }
184
+
185
+onMounted(() => { fetchData(); fetchStats() })
186
+</script>
187
+
188
+<style scoped>
189
+.filter-card :deep(.el-form-item) { margin-bottom: 0; }
190
+.stat-card { cursor: pointer; text-align: center; }
191
+.stat-value { font-size: 28px; font-weight: bold; color: #409eff; }
192
+.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
193
+</style>

+ 558
- 0
frontend/src/views/patrol/ProblemReportingView.vue Прегледај датотеку

@@ -0,0 +1,558 @@
1
+<template>
2
+  <div class="problem-reporting">
3
+    <el-card class="reporting-form">
4
+      <template #header>
5
+        <div class="card-header">
6
+          <span>巡检问题上报</span>
7
+          <el-tag type="success">{{ problemCount }} 个问题待处理</el-tag>
8
+        </div>
9
+      </template>
10
+
11
+      <el-form 
12
+        ref="problemForm" 
13
+        :model="problemForm" 
14
+        :rules="rules" 
15
+        label-width="120px"
16
+        @submit.prevent="submitProblem"
17
+      >
18
+        <el-row :gutter="20">
19
+          <el-col :span="12">
20
+            <el-form-item label="问题类型" prop="problemType">
21
+              <el-select 
22
+                v-model="problemForm.problemType" 
23
+                placeholder="请选择问题类型"
24
+                style="width: 100%"
25
+              >
26
+                <el-option label="设备故障" value="设备故障" />
27
+                <el-option label="水质异常" value="水质异常" />
28
+                <el-option label="安全隐患" value="安全隐患" />
29
+                <el-option label="环境卫生" value="环境卫生" />
30
+                <el-option label="其他" value="其他" />
31
+              </el-select>
32
+            </el-form-item>
33
+          </el-col>
34
+          
35
+          <el-col :span="12">
36
+            <el-form-item label="问题级别" prop="problemLevel">
37
+              <el-select 
38
+                v-model="problemForm.problemLevel" 
39
+                placeholder="请选择问题级别"
40
+                style="width: 100%"
41
+              >
42
+                <el-option label="低" value="low" />
43
+                <el-option label="普通" value="normal" />
44
+                <el-option label="高" value="high" />
45
+                <el-option label="紧急" value="critical" />
46
+              </el-select>
47
+            </el-form-item>
48
+          </el-col>
49
+        </el-row>
50
+
51
+        <el-form-item label="问题标题" prop="problemTitle">
52
+          <el-input 
53
+            v-model="problemForm.problemTitle" 
54
+            placeholder="请输入问题标题"
55
+            maxlength="200"
56
+            show-word-limit
57
+          />
58
+        </el-form-item>
59
+
60
+        <el-form-item label="问题描述" prop="problemDescription">
61
+          <el-input 
62
+            v-model="problemForm.problemDescription" 
63
+            type="textarea"
64
+            :rows="4"
65
+            placeholder="请详细描述问题情况"
66
+            maxlength="1000"
67
+            show-word-limit
68
+          />
69
+        </el-form-item>
70
+
71
+        <el-form-item label="问题位置" prop="location">
72
+          <el-input 
73
+            v-model="problemForm.location" 
74
+            placeholder="请输入问题发生位置"
75
+            maxlength="300"
76
+            show-word-limit
77
+          />
78
+        </el-form-item>
79
+
80
+        <el-row :gutter="20">
81
+          <el-col :span="12">
82
+            <el-form-item label="设备名称" prop="deviceName">
83
+              <el-input 
84
+                v-model="problemForm.deviceName" 
85
+                placeholder="请输入相关设备名称"
86
+                maxlength="200"
87
+                show-word-limit
88
+              />
89
+            </el-form-item>
90
+          </el-col>
91
+          
92
+          <el-col :span="12">
93
+            <el-form-item label="经纬度">
94
+              <el-input 
95
+                v-model="coordinates" 
96
+                placeholder="经度, 纬度"
97
+                readonly
98
+              >
99
+                <template #append>
100
+                  <el-button @click="getCurrentLocation">获取位置</el-button>
101
+                </template>
102
+              </el-input>
103
+            </el-form-item>
104
+          </el-col>
105
+        </el-row>
106
+
107
+        <el-form-item label="现场照片">
108
+          <el-upload
109
+            v-model:file-list="fileList"
110
+            action="/api/upload"
111
+            list-type="picture-card"
112
+            :limit="5"
113
+            :on-success="handleUploadSuccess"
114
+            :on-remove="handleRemove"
115
+            :before-upload="beforeUpload"
116
+          >
117
+            <el-icon><Plus /></el-icon>
118
+          </el-upload>
119
+          <div class="upload-tip">最多上传5张照片,支持JPG、PNG格式</div>
120
+        </el-form-item>
121
+
122
+        <el-form-item>
123
+          <el-button 
124
+            type="primary" 
125
+            @click="submitProblem"
126
+            :loading="submitting"
127
+          >
128
+            {{ isEditing ? '更新问题' : '提交问题' }}
129
+          </el-button>
130
+          <el-button @click="resetForm">重置</el-button>
131
+          <el-button v-if="isEditing" @click="cancelEdit">取消编辑</el-button>
132
+        </el-form-item>
133
+      </el-form>
134
+    </el-card>
135
+
136
+    <!-- 问题列表 -->
137
+    <el-card class="problem-list">
138
+      <template #header>
139
+        <div class="card-header">
140
+          <span>问题列表</span>
141
+          <el-input
142
+            v-model="searchQuery"
143
+            placeholder="搜索问题..."
144
+            style="width: 200px"
145
+            clearable
146
+          />
147
+        </div>
148
+      </template>
149
+
150
+      <el-table 
151
+        :data="filteredProblems" 
152
+        stripe
153
+        style="width: 100%"
154
+        v-loading="loading"
155
+      >
156
+        <el-table-column prop="problemNo" label="问题编号" width="120" />
157
+        <el-table-column prop="problemTitle" label="问题标题" min-width="200" />
158
+        <el-table-column prop="problemType" label="问题类型" width="120" />
159
+        <el-table-column prop="problemLevel" label="级别" width="80">
160
+          <template #default="{ row }">
161
+            <el-tag :type="getLevelType(row.problemLevel)">
162
+              {{ getLevelText(row.problemLevel) }}
163
+            </el-tag>
164
+          </template>
165
+        </el-table-column>
166
+        <el-table-column prop="status" label="状态" width="100">
167
+          <template #default="{ row }">
168
+            <el-tag :type="getStatusType(row.status)">
169
+              {{ getStatusText(row.status) }}
170
+            </el-tag>
171
+          </template>
172
+        </el-table-column>
173
+        <el-table-column prop="reportTime" label="上报时间" width="180">
174
+          <template #default="{ row }">
175
+            {{ formatDate(row.reportTime) }}
176
+          </template>
177
+        </el-table-column>
178
+        <el-table-column label="操作" width="200" fixed="right">
179
+          <template #default="{ row }">
180
+            <el-button 
181
+              size="small" 
182
+              @click="viewProblem(row)"
183
+            >
184
+              查看
185
+            </el-button>
186
+            <el-button 
187
+              size="small" 
188
+              type="primary" 
189
+              @click="editProblem(row)"
190
+              v-if="row.status === 'reported'"
191
+            >
192
+              编辑
193
+            </el-button>
194
+            <el-button 
195
+              size="small" 
196
+              type="success" 
197
+              @click="createWorkOrder(row)"
198
+              v-if="row.status === 'reported' && !row.workOrderId"
199
+            >
200
+              创建工单
201
+            </el-button>
202
+            <el-button 
203
+              size="small" 
204
+              type="info" 
205
+              @click="viewWorkOrder(row)"
206
+              v-if="row.workOrderId"
207
+            >
208
+              查看工单
209
+            </el-button>
210
+          </template>
211
+        </el-table-column>
212
+      </el-table>
213
+
214
+      <div class="pagination">
215
+        <el-pagination
216
+          v-model:current-page="currentPage"
217
+          v-model:page-size="pageSize"
218
+          :page-sizes="[10, 20, 50, 100]"
219
+          :total="totalProblems"
220
+          layout="total, sizes, prev, pager, next, jumper"
221
+          @size-change="handleSizeChange"
222
+          @current-change="handleCurrentChange"
223
+        />
224
+      </div>
225
+    </el-card>
226
+  </div>
227
+</template>
228
+
229
+<script setup>
230
+import { ref, computed, onMounted } from 'vue'
231
+import { ElMessage, ElMessageBox } from 'element-plus'
232
+import { Plus } from '@element-plus/icons-vue'
233
+import axios from 'axios'
234
+
235
+const problemForm = ref({
236
+  id: null,
237
+  taskId: null,
238
+  pointSeq: null,
239
+  deviceId: null,
240
+  deviceName: '',
241
+  problemType: '',
242
+  problemLevel: 'normal',
243
+  problemTitle: '',
244
+  problemDescription: '',
245
+  location: '',
246
+  lng: null,
247
+  lat: null,
248
+  photoUrls: [],
249
+  reporterId: 1, // 当前用户ID
250
+  reporterName: '巡检员',
251
+  status: 'reported'
252
+})
253
+
254
+const fileList = ref([])
255
+const coordinates = ref('')
256
+const submitting = ref(false)
257
+const loading = ref(false)
258
+const problems = ref([])
259
+const searchQuery = ref('')
260
+const currentPage = ref(1)
261
+const pageSize = ref(10)
262
+const totalProblems = ref(0)
263
+const isEditing = ref(false)
264
+
265
+// 表单验证规则
266
+const rules = {
267
+  problemType: [{ required: true, message: '请选择问题类型', trigger: 'change' }],
268
+  problemTitle: [{ required: true, message: '请输入问题标题', trigger: 'blur' }],
269
+  problemDescription: [{ required: true, message: '请输入问题描述', trigger: 'blur' }],
270
+  location: [{ required: true, message: '请输入问题位置', trigger: 'blur' }]
271
+}
272
+
273
+// 计算属性
274
+const problemCount = computed(() => {
275
+  return problems.value.filter(p => p.status === 'reported').length
276
+})
277
+
278
+const filteredProblems = computed(() => {
279
+  let filtered = problems.value
280
+  
281
+  if (searchQuery.value) {
282
+    const query = searchQuery.value.toLowerCase()
283
+    filtered = filtered.filter(p => 
284
+      p.problemTitle.toLowerCase().includes(query) ||
285
+      p.problemType.toLowerCase().includes(query) ||
286
+      p.problemNo.toLowerCase().includes(query)
287
+    )
288
+  }
289
+  
290
+  return filtered
291
+})
292
+
293
+// 获取问题列表
294
+const fetchProblems = async () => {
295
+  loading.value = true
296
+  try {
297
+    const response = await axios.get('/api/patrol/problems/status/reported')
298
+    problems.value = response.data
299
+    totalProblems.value = problems.value.length
300
+  } catch (error) {
301
+    console.error('获取问题列表失败:', error)
302
+    ElMessage.error('获取问题列表失败')
303
+  } finally {
304
+    loading.value = false
305
+  }
306
+}
307
+
308
+// 提交问题
309
+const submitProblem = async () => {
310
+  try {
311
+    const formRef = document.querySelector('.problem-reporting .reporting-form form')
312
+    if (!formRef) return
313
+    
314
+    // 这里可以添加表单验证逻辑
315
+    submitting.value = true
316
+    
317
+    // 处理文件上传
318
+    const photoUrls = []
319
+    for (const file of fileList.value) {
320
+      if (file.response) {
321
+        photoUrls.push(file.response.url)
322
+      }
323
+    }
324
+    problemForm.value.photoUrls = photoUrls
325
+    
326
+    // 解析坐标
327
+    if (coordinates.value) {
328
+      const [lng, lat] = coordinates.value.split(',').map(s => parseFloat(s.trim()))
329
+      problemForm.value.lng = lng
330
+      problemForm.value.lat = lat
331
+    }
332
+    
333
+    const response = await axios.post('/api/patrol/problems', problemForm.value)
334
+    
335
+    if (response.data) {
336
+      ElMessage.success('问题提交成功')
337
+      resetForm()
338
+      fetchProblems()
339
+    }
340
+  } catch (error) {
341
+    console.error('提交问题失败:', error)
342
+    ElMessage.error('提交问题失败')
343
+  } finally {
344
+    submitting.value = false
345
+  }
346
+}
347
+
348
+// 重置表单
349
+const resetForm = () => {
350
+  problemForm.value = {
351
+    id: null,
352
+    taskId: null,
353
+    pointSeq: null,
354
+    deviceId: null,
355
+    deviceName: '',
356
+    problemType: '',
357
+    problemLevel: 'normal',
358
+    problemTitle: '',
359
+    problemDescription: '',
360
+    location: '',
361
+    lng: null,
362
+    lat: null,
363
+    photoUrls: [],
364
+    reporterId: 1,
365
+    reporterName: '巡检员',
366
+    status: 'reported'
367
+  }
368
+  fileList.value = []
369
+  coordinates.value = ''
370
+  isEditing.value = false
371
+}
372
+
373
+// 编辑问题
374
+const editProblem = (problem) => {
375
+  problemForm.value = { ...problem }
376
+  fileList.value = problem.photoUrls.map(url => ({ url, name: url }))
377
+  coordinates.value = problem.lng && problem.lat ? `${problem.lng}, ${problem.lat}` : ''
378
+  isEditing.value = true
379
+}
380
+
381
+// 取消编辑
382
+const cancelEdit = () => {
383
+  resetForm()
384
+}
385
+
386
+// 查看问题详情
387
+const viewProblem = (problem) => {
388
+  ElMessageBox.alert(
389
+    `问题编号:${problem.problemNo}\n` +
390
+    `问题类型:${problem.problemType}\n` +
391
+    `问题级别:${problem.problemLevel}\n` +
392
+    `问题标题:${problem.problemTitle}\n` +
393
+    `问题位置:${problem.location}\n` +
394
+    `问题描述:${problem.problemDescription}\n` +
395
+    `上报时间:${formatDate(problem.reportTime)}`,
396
+    '问题详情',
397
+    { confirmButtonText: '确定' }
398
+  )
399
+}
400
+
401
+// 创建工单
402
+const createWorkOrder = (problem) => {
403
+  ElMessageBox.confirm(
404
+    `确认为问题 "${problem.problemTitle}" 创建工单吗?`,
405
+    '创建工单',
406
+    { confirmButtonText: '确定', cancelButtonText: '取消' }
407
+  ).then(async () => {
408
+    try {
409
+      const response = await axios.post(`/api/patrol/problems/${problem.id}/auto-create-work-order`)
410
+      if (response.data) {
411
+        ElMessage.success('工单创建成功')
412
+        fetchProblems()
413
+      }
414
+    } catch (error) {
415
+      console.error('创建工单失败:', error)
416
+      ElMessage.error('创建工单失败')
417
+    }
418
+  })
419
+}
420
+
421
+// 查看工单
422
+const viewWorkOrder = (problem) => {
423
+  // 这里可以跳转到工单详情页
424
+  ElMessage.info(`查看工单 ${problem.workOrderId}`)
425
+}
426
+
427
+// 获取当前位置
428
+const getCurrentLocation = () => {
429
+  if (navigator.geolocation) {
430
+    navigator.geolocation.getCurrentPosition(
431
+      (position) => {
432
+        const { latitude, longitude } = position.coords
433
+        coordinates.value = `${longitude}, ${latitude}`
434
+        problemForm.value.lng = longitude
435
+        problemForm.value.lat = latitude
436
+      },
437
+      (error) => {
438
+        ElMessage.error('获取位置失败:' + error.message)
439
+      }
440
+    )
441
+  } else {
442
+    ElMessage.error('浏览器不支持地理位置定位')
443
+  }
444
+}
445
+
446
+// 文件上传相关
447
+const handleUploadSuccess = (response, file) => {
448
+  file.url = response.url
449
+}
450
+
451
+const handleRemove = (file, fileList) => {
452
+  fileList.value = fileList
453
+}
454
+
455
+const beforeUpload = (file) => {
456
+  const isJPG = file.type === 'image/jpeg'
457
+  const isPNG = file.type === 'image/png'
458
+  const isLt5M = file.size / 1024 / 1024 < 5
459
+
460
+  if (!isJPG && !isPNG) {
461
+    ElMessage.error('上传图片只能是 JPG 或 PNG 格式!')
462
+    return false
463
+  }
464
+  if (!isLt5M) {
465
+    ElMessage.error('上传图片大小不能超过 5MB!')
466
+    return false
467
+  }
468
+  return true
469
+}
470
+
471
+// 辅助函数
472
+const getLevelType = (level) => {
473
+  switch (level) {
474
+    case 'low': return 'info'
475
+    case 'normal': return ''
476
+    case 'high': return 'warning'
477
+    case 'critical': return 'danger'
478
+    default: return ''
479
+  }
480
+}
481
+
482
+const getLevelText = (level) => {
483
+  switch (level) {
484
+    case 'low': return '低'
485
+    case 'normal': return '普通'
486
+    case 'high': return '高'
487
+    case 'critical': return '紧急'
488
+    default: return level
489
+  }
490
+}
491
+
492
+const getStatusType = (status) => {
493
+  switch (status) {
494
+    case 'reported': return 'warning'
495
+    case 'processing': return 'primary'
496
+    case 'completed': return 'success'
497
+    case 'closed': return 'info'
498
+    default: return ''
499
+  }
500
+}
501
+
502
+const getStatusText = (status) => {
503
+  switch (status) {
504
+    case 'reported': return '已上报'
505
+    case 'processing': return '处理中'
506
+    case 'completed': return '已完成'
507
+    case 'closed': return '已关闭'
508
+    default: return status
509
+  }
510
+}
511
+
512
+const formatDate = (date) => {
513
+  if (!date) return ''
514
+  return new Date(date).toLocaleString()
515
+}
516
+
517
+const handleSizeChange = (val) => {
518
+  pageSize.value = val
519
+  fetchProblems()
520
+}
521
+
522
+const handleCurrentChange = (val) => {
523
+  currentPage.value = val
524
+  fetchProblems()
525
+}
526
+
527
+// 初始化
528
+onMounted(() => {
529
+  fetchProblems()
530
+})
531
+</script>
532
+
533
+<style scoped>
534
+.problem-reporting {
535
+  padding: 20px;
536
+}
537
+
538
+.card-header {
539
+  display: flex;
540
+  justify-content: space-between;
541
+  align-items: center;
542
+}
543
+
544
+.upload-tip {
545
+  font-size: 12px;
546
+  color: #909399;
547
+  margin-top: 5px;
548
+}
549
+
550
+.pagination {
551
+  margin-top: 20px;
552
+  text-align: right;
553
+}
554
+
555
+.problem-list {
556
+  margin-top: 20px;
557
+}
558
+</style>

+ 5
- 0
pom.xml Прегледај датотеку

@@ -39,12 +39,17 @@
39 39
         <module>wm-iot</module>
40 40
         <module>wm-data-engine</module>
41 41
         <module>wm-bpm</module>
42
+        <module>wm-bpm-engine</module>
42 43
         <module>wm-production</module>
43 44
         <module>wm-revenue</module>
44 45
         <module>wm-patrol</module>
45 46
         <module>wm-bi</module>
46 47
         <module>wm-notify</module>
47 48
         <module>wm-job</module>
49
+        <module>wm-dispatch</module>
50
+        <module>wm-system</module>
51
+        <module>wm-mobile-app</module>
52
+        <module>wm-config</module>
48 53
     </modules>
49 54
 
50 55
     <dependencyManagement>

+ 112
- 0
sql/problem_reporting.sql Прегледај датотеку

@@ -0,0 +1,112 @@
1
+-- =============================================
2
+-- 智慧水务管理系统 - 巡检问题上报 + 工单管理 DDL
3
+-- 版本: V1
4
+-- =============================================
5
+
6
+-- 巡检问题上报表
7
+CREATE TABLE IF NOT EXISTS patrol_problem (
8
+    id BIGSERIAL PRIMARY KEY,
9
+    problem_no VARCHAR(30) UNIQUE NOT NULL,   -- 问题编号:WQ-2026-001
10
+    task_id BIGINT REFERENCES patrol_task(id),
11
+    point_seq INT,
12
+    device_id BIGINT,
13
+    device_name VARCHAR(200),
14
+    problem_type VARCHAR(50) NOT NULL,       -- 设备故障/水质异常/安全隐患/环境卫生/其他
15
+    problem_level VARCHAR(20) DEFAULT 'normal', -- low/normal/high/critical
16
+    problem_title VARCHAR(200) NOT NULL,
17
+    problem_description TEXT,
18
+    location VARCHAR(300),
19
+    lng DOUBLE PRECISION,
20
+    lat DOUBLE PRECISION,
21
+    photo_urls JSONB,                        -- 现场照片URL数组
22
+    reporter_id BIGINT REFERENCES sys_user(id),
23
+    reporter_name VARCHAR(50),
24
+    report_time TIMESTAMP DEFAULT NOW(),
25
+    status VARCHAR(20) DEFAULT 'reported',    -- reported/processing/completed/closed
26
+    work_order_id BIGINT,                    -- 关联工单ID
27
+    created_at TIMESTAMP DEFAULT NOW(),
28
+    updated_at TIMESTAMP DEFAULT NOW()
29
+);
30
+COMMENT ON TABLE patrol_problem IS '巡检问题上报表';
31
+CREATE INDEX IF NOT EXISTS idx_problem_task ON patrol_problem(task_id);
32
+CREATE INDEX IF NOT EXISTS idx_problem_status ON patrol_problem(status);
33
+CREATE INDEX IF NOT EXISTS idx_problem_device ON patrol_problem(device_id);
34
+CREATE INDEX IF NOT EXISTS idx_problem_type ON patrol_problem(problem_type);
35
+
36
+-- 工单表
37
+CREATE TABLE IF NOT EXISTS work_order (
38
+    id BIGSERIAL PRIMARY KEY,
39
+    order_no VARCHAR(30) UNIQUE NOT NULL,     -- 工单编号:WO-2026-001
40
+    problem_id BIGINT REFERENCES patrol_problem(id),
41
+    order_type VARCHAR(50) NOT NULL,        -- 设备维修/水质处理/安全隐患处理/清洁/其他
42
+    priority VARCHAR(20) DEFAULT 'normal',   -- low/normal/high/critical
43
+    title VARCHAR(200) NOT NULL,
44
+    description TEXT,
45
+    location VARCHAR(300),
46
+    contact_person VARCHAR(50),
47
+    contact_phone VARCHAR(20),
48
+    reporter_id BIGINT REFERENCES sys_user(id),
49
+    reporter_name VARCHAR(50),
50
+    assignee_id BIGINT REFERENCES sys_user(id),
51
+    assignee_name VARCHAR(50),
52
+    status VARCHAR(20) DEFAULT 'pending',    -- pending/assigned/processing/completed/cancelled
53
+    process_status VARCHAR(20) DEFAULT 'created', -- created/accepted/in_progress/completed
54
+    estimated_duration INT,                  -- 预计工时(分钟)
55
+    actual_start_time TIMESTAMP,
56
+    actual_end_time TIMESTAMP,
57
+    completion_time TIMESTAMP,
58
+    photos_before JSONB,                     -- 处理前照片
59
+    photos_after JSONB,                      -- 处理后照片
60
+    solution_description TEXT,               -- 处理方案描述
61
+    solution_result TEXT,                    -- 处理结果
62
+    customer_feedback TEXT,                  -- 客户反馈
63
+    created_at TIMESTAMP DEFAULT NOW(),
64
+    updated_at TIMESTAMP DEFAULT NOW()
65
+);
66
+COMMENT ON TABLE work_order IS '工单表';
67
+CREATE INDEX IF NOT EXISTS idx_order_problem ON work_order(problem_id);
68
+CREATE INDEX IF NOT EXISTS idx_order_status ON work_order(status, process_status);
69
+CREATE INDEX IF NOT EXISTS idx_order_assignee ON work_order(assignee_id);
70
+
71
+-- 工单处理记录表
72
+CREATE TABLE IF NOT EXISTS work_order_process (
73
+    id BIGSERIAL PRIMARY KEY,
74
+    work_order_id BIGINT REFERENCES work_order(id),
75
+    process_step VARCHAR(50) NOT NULL,       -- created/accepted/in_progress/completed
76
+    processor_id BIGINT REFERENCES sys_user(id),
77
+    processor_name VARCHAR(50),
78
+    action VARCHAR(50) NOT NULL,            -- create/assign/start/complete/cancel
79
+    comment TEXT,
80
+    photos JSONB,                            -- 处理过程照片
81
+    created_at TIMESTAMP DEFAULT NOW()
82
+);
83
+COMMENT ON TABLE work_order_process IS '工单处理记录表';
84
+CREATE INDEX IF NOT EXISTS idx_process_order ON work_order_process(work_order_id);
85
+CREATE INDEX IF NOT EXISTS idx_process_step ON work_order_process(process_step);
86
+
87
+-- 工单附件表
88
+CREATE TABLE IF NOT EXISTS work_order_attachment (
89
+    id BIGSERIAL PRIMARY KEY,
90
+    work_order_id BIGINT REFERENCES work_order(id),
91
+    file_name VARCHAR(200) NOT NULL,
92
+    file_path VARCHAR(500) NOT NULL,
93
+    file_type VARCHAR(50),                  -- image/pdf/doc/other
94
+    file_size BIGINT,
95
+    uploaded_by BIGINT REFERENCES sys_user(id),
96
+    uploaded_at TIMESTAMP DEFAULT NOW()
97
+);
98
+COMMENT ON TABLE work_order_attachment IS '工单附件表';
99
+CREATE INDEX IF NOT EXISTS idx_attachment_order ON work_order_attachment(work_order_id);
100
+
101
+-- 巡检问题与工单关联触发记录
102
+CREATE TABLE IF NOT EXISTS patrol_work_order_trigger (
103
+    id BIGSERIAL PRIMARY KEY,
104
+    patrol_problem_id BIGINT REFERENCES patrol_problem(id),
105
+    work_order_id BIGINT REFERENCES work_order(id),
106
+    trigger_type VARCHAR(20) NOT NULL,      -- auto/manual
107
+    trigger_condition JSONB,                 -- 触发条件
108
+    created_at TIMESTAMP DEFAULT NOW()
109
+);
110
+COMMENT ON TABLE patrol_work_order_trigger IS '巡检问题与工单关联触发记录';
111
+CREATE INDEX IF NOT EXISTS idx_trigger_problem ON patrol_work_order_trigger(patrol_problem_id);
112
+CREATE INDEX IF NOT EXISTS idx_trigger_order ON patrol_work_order_trigger(work_order_id);

+ 63
- 0
wm-bpm-engine/pom.xml Прегледај датотеку

@@ -0,0 +1,63 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<project xmlns="http://maven.apache.org/POM/4.0.0"
3
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
5
+    <modelVersion>4.0.0</modelVersion>
6
+    <parent>
7
+        <groupId>com.water</groupId>
8
+        <artifactId>wm-parent</artifactId>
9
+        <version>1.0.0-SNAPSHOT</version>
10
+    </parent>
11
+    <artifactId>wm-bpm-engine</artifactId>
12
+    <name>wm-bpm-engine</name>
13
+    <description>BPM 业务流程引擎模块</description>
14
+    <dependencies>
15
+        <dependency>
16
+            <groupId>com.water</groupId>
17
+            <artifactId>wm-common</artifactId>
18
+        </dependency>
19
+        <dependency>
20
+            <groupId>org.springframework.boot</groupId>
21
+            <artifactId>spring-boot-starter-web</artifactId>
22
+        </dependency>
23
+        <dependency>
24
+            <groupId>com.alibaba.cloud</groupId>
25
+            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
26
+        </dependency>
27
+        <dependency>
28
+            <groupId>com.baomidou</groupId>
29
+            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
30
+        </dependency>
31
+        <dependency>
32
+            <groupId>cn.dev33</groupId>
33
+            <artifactId>sa-token-spring-boot3-starter</artifactId>
34
+        </dependency>
35
+        <dependency>
36
+            <groupId>org.postgresql</groupId>
37
+            <artifactId>postgresql</artifactId>
38
+        </dependency>
39
+        <dependency>
40
+            <groupId>com.github.xiaoymin</groupId>
41
+            <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
42
+        </dependency>
43
+        <!-- Test -->
44
+        <dependency>
45
+            <groupId>org.springframework.boot</groupId>
46
+            <artifactId>spring-boot-starter-test</artifactId>
47
+            <scope>test</scope>
48
+        </dependency>
49
+        <dependency>
50
+            <groupId>com.h2database</groupId>
51
+            <artifactId>h2</artifactId>
52
+            <scope>test</scope>
53
+        </dependency>
54
+    </dependencies>
55
+    <build>
56
+        <plugins>
57
+            <plugin>
58
+                <groupId>org.springframework.boot</groupId>
59
+                <artifactId>spring-boot-maven-plugin</artifactId>
60
+            </plugin>
61
+        </plugins>
62
+    </build>
63
+</project>

+ 7
- 0
wm-bpm-engine/src/main/java/com/water/bpmengine/BpmEngineApplication.java Прегледај датотеку

@@ -0,0 +1,7 @@
1
+package com.water.bpmengine;
2
+import org.springframework.boot.SpringApplication;
3
+import org.springframework.boot.autoconfigure.SpringBootApplication;
4
+@SpringBootApplication
5
+public class BpmEngineApplication {
6
+    public static void main(String[] args) { SpringApplication.run(BpmEngineApplication.class, args); }
7
+}

+ 77
- 0
wm-bpm-engine/src/main/java/com/water/bpmengine/controller/BpmEngineController.java Прегледај датотеку

@@ -0,0 +1,77 @@
1
+package com.water.bpmengine.controller;
2
+import com.water.common.core.result.R;
3
+import com.water.bpmengine.entity.*;
4
+import com.water.bpmengine.service.BpmEngineService;
5
+import io.swagger.v3.oas.annotations.tags.Tag;
6
+import lombok.RequiredArgsConstructor;
7
+import org.springframework.web.bind.annotation.*;
8
+import java.util.*;
9
+
10
+@Tag(name = "业务流程引擎")
11
+@RestController @RequestMapping("/bpm") @RequiredArgsConstructor
12
+public class BpmEngineController {
13
+    private final BpmEngineService svc;
14
+
15
+    @GetMapping("/definition/list")
16
+    public R<List<ProcessDefinition>> listDefs(
17
+            @RequestParam(required=false) String category,
18
+            @RequestParam(required=false) Integer status) {
19
+        return R.ok(svc.listDefinitions(category, status));
20
+    }
21
+    @PostMapping("/definition")
22
+    public R<Long> createDef(@RequestBody Map<String,Object> req) {
23
+        return R.ok(svc.createDefinition(req));
24
+    }
25
+    @PostMapping("/definition/{id}/publish")
26
+    public R<String> publish(@PathVariable Long id) {
27
+        svc.publishDefinition(id); return R.ok("OK");
28
+    }
29
+    @PostMapping("/process/start")
30
+    public R<Map<String,Object>> start(
31
+            @RequestParam Long definitionId,
32
+            @RequestParam String title,
33
+            @RequestParam String initiator) {
34
+        return R.ok(svc.startProcess(definitionId, title, initiator));
35
+    }
36
+    @GetMapping("/process/list")
37
+    public R<List<ProcessInstance>> listInstances(
38
+            @RequestParam(required=false) String initiator,
39
+            @RequestParam(required=false) Integer status) {
40
+        return R.ok(svc.listInstances(initiator, status));
41
+    }
42
+    @GetMapping("/task/todo")
43
+    public R<List<TaskItem>> todo(@RequestParam String assignee) {
44
+        return R.ok(svc.getTodoTasks(assignee));
45
+    }
46
+    @GetMapping("/task/done")
47
+    public R<List<TaskItem>> done(@RequestParam String assignee) {
48
+        return R.ok(svc.getDoneTasks(assignee));
49
+    }
50
+    @PostMapping("/task/{id}/approve")
51
+    public R<Map<String,Object>> approve(@PathVariable Long id,
52
+            @RequestParam(required=false) String comment) {
53
+        return R.ok(svc.approveTask(id, comment));
54
+    }
55
+    @PostMapping("/task/{id}/reject")
56
+    public R<Map<String,Object>> reject(@PathVariable Long id,
57
+            @RequestParam(required=false) String comment) {
58
+        return R.ok(svc.rejectTask(id, comment));
59
+    }
60
+    @PostMapping("/task/{id}/transfer")
61
+    public R<Map<String,Object>> transfer(@PathVariable Long id,
62
+            @RequestParam String newAssignee) {
63
+        return R.ok(svc.transferTask(id, newAssignee));
64
+    }
65
+    @GetMapping("/statistics")
66
+    public R<Map<String,Object>> stats(@RequestParam(required=false) String processKey) {
67
+        return R.ok(svc.getStatistics(processKey));
68
+    }
69
+    @GetMapping("/template/list")
70
+    public R<List<ProcessTemplate>> listTemplates() {
71
+        return R.ok(svc.listTemplates());
72
+    }
73
+    @PostMapping("/template")
74
+    public R<Long> createTemplate(@RequestBody Map<String,Object> req) {
75
+        return R.ok(svc.createTemplate(req));
76
+    }
77
+}

+ 13
- 0
wm-bpm-engine/src/main/java/com/water/bpmengine/entity/ProcessDefinition.java Прегледај датотеку

@@ -0,0 +1,13 @@
1
+package com.water.bpmengine.entity;
2
+import com.baomidou.mybatisplus.annotation.*;
3
+import lombok.Data; import java.time.LocalDateTime;
4
+@Data @TableName("bpm_process_definition")
5
+public class ProcessDefinition {
6
+    @TableId(type = IdType.AUTO) private Long id;
7
+    private String processKey, name, category;
8
+    private String bpmnXml;
9
+    private Integer version; private Integer status; // 0草稿 1已发布 2已停用
10
+    private String description;
11
+    @TableField(fill=FieldFill.INSERT) private LocalDateTime createdTime;
12
+    @TableField(fill=FieldFill.INSERT_UPDATE) private LocalDateTime updatedTime;
13
+}

+ 13
- 0
wm-bpm-engine/src/main/java/com/water/bpmengine/entity/ProcessInstance.java Прегледај датотеку

@@ -0,0 +1,13 @@
1
+package com.water.bpmengine.entity;
2
+import com.baomidou.mybatisplus.annotation.*;
3
+import lombok.Data; import java.time.LocalDateTime;
4
+@Data @TableName("bpm_process_instance")
5
+public class ProcessInstance {
6
+    @TableId(type = IdType.AUTO) private Long id;
7
+    private Long definitionId; private String processKey;
8
+    private String title, initiator;
9
+    private Integer status; // 0运行中 1已完成 2已驳回 3已撤销
10
+    private String currentNodeName;
11
+    @TableField(fill=FieldFill.INSERT) private LocalDateTime createdTime;
12
+    private LocalDateTime completedTime;
13
+}

+ 11
- 0
wm-bpm-engine/src/main/java/com/water/bpmengine/entity/ProcessTemplate.java Прегледај датотеку

@@ -0,0 +1,11 @@
1
+package com.water.bpmengine.entity;
2
+import com.baomidou.mybatisplus.annotation.*;
3
+import lombok.Data; import java.time.LocalDateTime;
4
+@Data @TableName("bpm_process_template")
5
+public class ProcessTemplate {
6
+    @TableId(type = IdType.AUTO) private Long id;
7
+    private String name, category, description;
8
+    private String bpmnXml;
9
+    private Integer status;
10
+    @TableField(fill=FieldFill.INSERT) private LocalDateTime createdTime;
11
+}

+ 13
- 0
wm-bpm-engine/src/main/java/com/water/bpmengine/entity/TaskItem.java Прегледај датотеку

@@ -0,0 +1,13 @@
1
+package com.water.bpmengine.entity;
2
+import com.baomidou.mybatisplus.annotation.*;
3
+import lombok.Data; import java.time.LocalDateTime;
4
+@Data @TableName("bpm_task_item")
5
+public class TaskItem {
6
+    @TableId(type = IdType.AUTO) private Long id;
7
+    private Long instanceId; private String taskName, taskType;
8
+    private String assignee;
9
+    private Integer status; // 0待办 1已办 2驳回 3转办
10
+    private String comment;
11
+    @TableField(fill=FieldFill.INSERT) private LocalDateTime createdTime;
12
+    private LocalDateTime completedTime;
13
+}

+ 5
- 0
wm-bpm-engine/src/main/java/com/water/bpmengine/mapper/ProcessDefinitionMapper.java Прегледај датотеку

@@ -0,0 +1,5 @@
1
+package com.water.bpmengine.mapper;
2
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
3
+import com.water.bpmengine.entity.ProcessDefinition;
4
+import org.apache.ibatis.annotations.Mapper;
5
+@Mapper public interface ProcessDefinitionMapper extends BaseMapper<ProcessDefinition> {}

+ 5
- 0
wm-bpm-engine/src/main/java/com/water/bpmengine/mapper/ProcessInstanceMapper.java Прегледај датотеку

@@ -0,0 +1,5 @@
1
+package com.water.bpmengine.mapper;
2
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
3
+import com.water.bpmengine.entity.ProcessInstance;
4
+import org.apache.ibatis.annotations.Mapper;
5
+@Mapper public interface ProcessInstanceMapper extends BaseMapper<ProcessInstance> {}

+ 5
- 0
wm-bpm-engine/src/main/java/com/water/bpmengine/mapper/ProcessTemplateMapper.java Прегледај датотеку

@@ -0,0 +1,5 @@
1
+package com.water.bpmengine.mapper;
2
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
3
+import com.water.bpmengine.entity.ProcessTemplate;
4
+import org.apache.ibatis.annotations.Mapper;
5
+@Mapper public interface ProcessTemplateMapper extends BaseMapper<ProcessTemplate> {}

+ 5
- 0
wm-bpm-engine/src/main/java/com/water/bpmengine/mapper/TaskItemMapper.java Прегледај датотеку

@@ -0,0 +1,5 @@
1
+package com.water.bpmengine.mapper;
2
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
3
+import com.water.bpmengine.entity.TaskItem;
4
+import org.apache.ibatis.annotations.Mapper;
5
+@Mapper public interface TaskItemMapper extends BaseMapper<TaskItem> {}

+ 122
- 0
wm-bpm-engine/src/main/java/com/water/bpmengine/service/BpmEngineService.java Прегледај датотеку

@@ -0,0 +1,122 @@
1
+package com.water.bpmengine.service;
2
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
3
+import com.water.bpmengine.entity.*;
4
+import com.water.bpmengine.mapper.*;
5
+import lombok.RequiredArgsConstructor;
6
+import org.springframework.stereotype.Service;
7
+import java.time.LocalDateTime; import java.util.*;
8
+
9
+@Service @RequiredArgsConstructor
10
+public class BpmEngineService {
11
+    private final ProcessDefinitionMapper defMapper;
12
+    private final ProcessInstanceMapper instMapper;
13
+    private final TaskItemMapper taskMapper;
14
+    private final ProcessTemplateMapper tplMapper;
15
+
16
+    // === 流程定义 ===
17
+    public List<ProcessDefinition> listDefinitions(String category, Integer status) {
18
+        return defMapper.selectList(new LambdaQueryWrapper<ProcessDefinition>()
19
+            .eq(category != null, ProcessDefinition::getCategory, category)
20
+            .eq(status != null, ProcessDefinition::getStatus, status));
21
+    }
22
+    public Long createDefinition(Map<String,Object> req) {
23
+        ProcessDefinition d = new ProcessDefinition();
24
+        d.setProcessKey((String)req.get("processKey"));
25
+        d.setName((String)req.get("name"));
26
+        d.setCategory((String)req.get("category"));
27
+        d.setBpmnXml((String)req.get("bpmnXml"));
28
+        d.setDescription((String)req.get("description"));
29
+        d.setVersion(1); d.setStatus(0);
30
+        defMapper.insert(d);
31
+        return d.getId();
32
+    }
33
+    public void publishDefinition(Long id) {
34
+        ProcessDefinition d = defMapper.selectById(id);
35
+        if (d == null) throw new RuntimeException("流程定义不存在");
36
+        d.setStatus(1); defMapper.updateById(d);
37
+    }
38
+
39
+    // === 流程实例 ===
40
+    public Map<String,Object> startProcess(Long definitionId, String title, String initiator) {
41
+        ProcessDefinition d = defMapper.selectById(definitionId);
42
+        if (d == null || d.getStatus() != 1) throw new RuntimeException("流程未发布");
43
+        ProcessInstance inst = new ProcessInstance();
44
+        inst.setDefinitionId(definitionId); inst.setProcessKey(d.getProcessKey());
45
+        inst.setTitle(title); inst.setInitiator(initiator);
46
+        inst.setStatus(0); inst.setCurrentNodeName("开始");
47
+        instMapper.insert(inst);
48
+        TaskItem t = new TaskItem();
49
+        t.setInstanceId(inst.getId()); t.setTaskName("审批节点");
50
+        t.setTaskType("审批"); t.setAssignee(initiator); t.setStatus(0);
51
+        taskMapper.insert(t);
52
+        return Map.of("instanceId", inst.getId(), "processKey", d.getProcessKey());
53
+    }
54
+    public List<ProcessInstance> listInstances(String initiator, Integer status) {
55
+        return instMapper.selectList(new LambdaQueryWrapper<ProcessInstance>()
56
+            .eq(initiator != null, ProcessInstance::getInitiator, initiator)
57
+            .eq(status != null, ProcessInstance::getStatus, status));
58
+    }
59
+
60
+    // === 任务处理 ===
61
+    public List<TaskItem> getTodoTasks(String assignee) {
62
+        return taskMapper.selectList(new LambdaQueryWrapper<TaskItem>()
63
+            .eq(TaskItem::getAssignee, assignee).eq(TaskItem::getStatus, 0));
64
+    }
65
+    public List<TaskItem> getDoneTasks(String assignee) {
66
+        return taskMapper.selectList(new LambdaQueryWrapper<TaskItem>()
67
+            .eq(TaskItem::getAssignee, assignee).in(TaskItem::getStatus, 1, 2));
68
+    }
69
+    public Map<String,Object> approveTask(Long taskId, String comment) {
70
+        TaskItem t = taskMapper.selectById(taskId);
71
+        if (t == null || t.getStatus() != 0) throw new RuntimeException("任务不可审批");
72
+        t.setStatus(1); t.setComment(comment); t.setCompletedTime(LocalDateTime.now());
73
+        taskMapper.updateById(t);
74
+        ProcessInstance inst = instMapper.selectById(t.getInstanceId());
75
+        inst.setCurrentNodeName("已完成"); inst.setStatus(1); inst.setCompletedTime(LocalDateTime.now());
76
+        instMapper.updateById(inst);
77
+        return Map.of("taskId", taskId, "status", "已审批");
78
+    }
79
+    public Map<String,Object> rejectTask(Long taskId, String comment) {
80
+        TaskItem t = taskMapper.selectById(taskId);
81
+        if (t == null) throw new RuntimeException("任务不存在");
82
+        t.setStatus(2); t.setComment(comment); t.setCompletedTime(LocalDateTime.now());
83
+        taskMapper.updateById(t);
84
+        ProcessInstance inst = instMapper.selectById(t.getInstanceId());
85
+        inst.setStatus(2); inst.setCompletedTime(LocalDateTime.now());
86
+        instMapper.updateById(inst);
87
+        return Map.of("taskId", taskId, "status", "已驳回");
88
+    }
89
+    public Map<String,Object> transferTask(Long taskId, String newAssignee) {
90
+        TaskItem t = taskMapper.selectById(taskId);
91
+        if (t == null) throw new RuntimeException("任务不存在");
92
+        t.setStatus(3); t.setCompletedTime(LocalDateTime.now());
93
+        taskMapper.updateById(t);
94
+        TaskItem nt = new TaskItem();
95
+        nt.setInstanceId(t.getInstanceId()); nt.setTaskName(t.getTaskName());
96
+        nt.setTaskType(t.getTaskType()); nt.setAssignee(newAssignee); nt.setStatus(0);
97
+        taskMapper.insert(nt);
98
+        return Map.of("oldTaskId", taskId, "newTaskId", nt.getId());
99
+    }
100
+
101
+    // === 统计 ===
102
+    public Map<String,Object> getStatistics(String processKey) {
103
+        long total = instMapper.selectCount(new LambdaQueryWrapper<ProcessInstance>()
104
+            .eq(processKey != null, ProcessInstance::getProcessKey, processKey));
105
+        long completed = instMapper.selectCount(new LambdaQueryWrapper<ProcessInstance>()
106
+            .eq(processKey != null, ProcessInstance::getProcessKey, processKey)
107
+            .eq(ProcessInstance::getStatus, 1));
108
+        return Map.of("total", total, "completed", completed,
109
+            "running", total - completed,
110
+            "completionRate", total > 0 ? (double)completed / total : 0);
111
+    }
112
+
113
+    // === 模板 ===
114
+    public List<ProcessTemplate> listTemplates() { return tplMapper.selectList(null); }
115
+    public Long createTemplate(Map<String,Object> req) {
116
+        ProcessTemplate t = new ProcessTemplate();
117
+        t.setName((String)req.get("name")); t.setCategory((String)req.get("category"));
118
+        t.setBpmnXml((String)req.get("bpmnXml")); t.setStatus(1);
119
+        tplMapper.insert(t);
120
+        return t.getId();
121
+    }
122
+}

+ 15
- 0
wm-bpm-engine/src/main/resources/application.yml Прегледај датотеку

@@ -0,0 +1,15 @@
1
+server:
2
+  port: 9040
3
+spring:
4
+  application:
5
+    name: wm-bpm-engine
6
+  datasource:
7
+    url: jdbc:postgresql://localhost:5432/water_bpm
8
+    username: water
9
+    password: water123
10
+mybatis-plus:
11
+  configuration:
12
+    map-underscore-to-camel-case: true
13
+  global-config:
14
+    db-config:
15
+      id-type: auto

+ 23
- 0
wm-bpm-engine/src/main/resources/db/V1__bpm_engine.sql Прегледај датотеку

@@ -0,0 +1,23 @@
1
+-- BPM Engine DDL
2
+CREATE TABLE IF NOT EXISTS bpm_process_definition (
3
+    id BIGSERIAL PRIMARY KEY, process_key VARCHAR(100), name VARCHAR(200),
4
+    category VARCHAR(50), bpmn_xml TEXT, version INT DEFAULT 1,
5
+    status INT DEFAULT 0, description TEXT,
6
+    created_time TIMESTAMP DEFAULT NOW(), updated_time TIMESTAMP DEFAULT NOW()
7
+);
8
+CREATE TABLE IF NOT EXISTS bpm_process_instance (
9
+    id BIGSERIAL PRIMARY KEY, definition_id BIGINT, process_key VARCHAR(100),
10
+    title VARCHAR(200), initiator VARCHAR(50), status INT DEFAULT 0,
11
+    current_node_name VARCHAR(100),
12
+    created_time TIMESTAMP DEFAULT NOW(), completed_time TIMESTAMP
13
+);
14
+CREATE TABLE IF NOT EXISTS bpm_task_item (
15
+    id BIGSERIAL PRIMARY KEY, instance_id BIGINT, task_name VARCHAR(100),
16
+    task_type VARCHAR(30), assignee VARCHAR(50), status INT DEFAULT 0,
17
+    comment TEXT, created_time TIMESTAMP DEFAULT NOW(), completed_time TIMESTAMP
18
+);
19
+CREATE TABLE IF NOT EXISTS bpm_process_template (
20
+    id BIGSERIAL PRIMARY KEY, name VARCHAR(200), category VARCHAR(50),
21
+    bpmn_xml TEXT, status INT DEFAULT 1,
22
+    created_time TIMESTAMP DEFAULT NOW()
23
+);

+ 49
- 4
wm-bpm/src/main/java/com/water/bpm/entity/BpmApprovalRecord.java Прегледај датотеку

@@ -1,18 +1,63 @@
1 1
 package com.water.bpm.entity;
2 2
 
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import com.water.common.core.entity.BaseEntity;
3 5
 import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
4 8
 import java.time.LocalDateTime;
5 9
 
10
+/**
11
+ * 审批记录实体
12
+ * BPM-03: 流程处理
13
+ */
6 14
 @Data
7
-public class BpmApprovalRecord {
8
-    private Long id;
15
+@EqualsAndHashCode(callSuper = true)
16
+@TableName("bpm_approval_record")
17
+public class BpmApprovalRecord extends BaseEntity {
18
+
19
+    /** 流程实例ID */
9 20
     private Long instanceId;
21
+
22
+    /** 流程实例UUID */
23
+    private String instanceUuid;
24
+
25
+    /** 节点标识 */
10 26
     private String nodeId;
27
+
28
+    /** 节点名称 */
11 29
     private String nodeName;
30
+
31
+    /** 审批人ID */
12 32
     private Long approverId;
33
+
34
+    /** 审批人姓名 */
13 35
     private String approverName;
14
-    private String action;           // approve/reject/transfer/delegate/back
36
+
37
+    /** 审批动作: approve/reject/transfer/delegate/back/countersign */
38
+    private String action;
39
+
40
+    /** 审批意见 */
15 41
     private String comment;
16
-    private String targetAssignee;   // 转办/委派目标
42
+
43
+    /** 转办/委派目标人ID */
44
+    private Long targetAssigneeId;
45
+
46
+    /** 转办/委派目标人姓名 */
47
+    private String targetAssigneeName;
48
+
49
+    /** 会签结果: all/pass_one/veto */
50
+    private String countersignResult;
51
+
52
+    /** 会签通过数 */
53
+    private Integer countersignApproved;
54
+
55
+    /** 会签总数 */
56
+    private Integer countersignTotal;
57
+
58
+    /** 审批时间 */
17 59
     private LocalDateTime approvedAt;
60
+
61
+    /** 租户ID */
62
+    private String tenantId;
18 63
 }

+ 49
- 0
wm-bpm/src/main/java/com/water/bpm/entity/BpmFormTemplate.java Прегледај датотеку

@@ -0,0 +1,49 @@
1
+package com.water.bpm.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+/**
9
+ * 流程表单模板实体
10
+ * BPM-02: 模板化快速创建
11
+ */
12
+@Data
13
+@EqualsAndHashCode(callSuper = true)
14
+@TableName("bpm_form_template")
15
+public class BpmFormTemplate extends BaseEntity {
16
+
17
+    /** 模板名称 */
18
+    private String templateName;
19
+
20
+    /** 模板编码 */
21
+    private String templateCode;
22
+
23
+    /** 模板分类 */
24
+    private String category;
25
+
26
+    /** 表单 JSON Schema */
27
+    private String formSchema;
28
+
29
+    /** 流程 BPMN XML 模板 */
30
+    private String bpmnTemplate;
31
+
32
+    /** 模板描述 */
33
+    private String description;
34
+
35
+    /** 模板图标 */
36
+    private String icon;
37
+
38
+    /** 使用次数 */
39
+    private Integer useCount;
40
+
41
+    /** 状态: 0-草稿 1-启用 2-停用 */
42
+    private Integer status;
43
+
44
+    /** 创建人 */
45
+    private String createdBy;
46
+
47
+    /** 租户ID */
48
+    private String tenantId;
49
+}

+ 52
- 0
wm-bpm/src/main/java/com/water/bpm/entity/BpmOrchestration.java Прегледај датотеку

@@ -0,0 +1,52 @@
1
+package com.water.bpm.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+/**
9
+ * 流程编排实体
10
+ * BPM-05: 跨系统流程编排
11
+ */
12
+@Data
13
+@EqualsAndHashCode(callSuper = true)
14
+@TableName("bpm_orchestration")
15
+public class BpmOrchestration extends BaseEntity {
16
+
17
+    /** 编排名称 */
18
+    private String orchestrationName;
19
+
20
+    /** 编排编码 */
21
+    private String orchestrationCode;
22
+
23
+    /** 编排描述 */
24
+    private String description;
25
+
26
+    /** 包含的流程定义ID列表 JSON */
27
+    private String processDefinitionIds;
28
+
29
+    /** 编排规则 JSON (流程间依赖关系、触发条件) */
30
+    private String orchestrationRules;
31
+
32
+    /** 触发方式: manual/scheduled/event */
33
+    private String triggerType;
34
+
35
+    /** 定时表达式 (cron) */
36
+    private String cronExpression;
37
+
38
+    /** 事件名称 (event触发时) */
39
+    private String eventName;
40
+
41
+    /** 状态: 0-草稿 1-启用 2-停用 */
42
+    private Integer status;
43
+
44
+    /** 创建人 */
45
+    private String createdBy;
46
+
47
+    /** 执行次数 */
48
+    private Integer executionCount;
49
+
50
+    /** 租户ID */
51
+    private String tenantId;
52
+}

+ 46
- 8
wm-bpm/src/main/java/com/water/bpm/entity/BpmProcessDefinition.java Прегледај датотеку

@@ -1,20 +1,58 @@
1 1
 package com.water.bpm.entity;
2 2
 
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import com.water.common.core.entity.BaseEntity;
3 5
 import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
4 8
 import java.time.LocalDateTime;
5 9
 
10
+/**
11
+ * 流程定义实体
12
+ * BPM-01: 流程定义管理
13
+ */
6 14
 @Data
7
-public class BpmProcessDefinition {
8
-    private Long id;
15
+@EqualsAndHashCode(callSuper = true)
16
+@TableName("bpm_process_definition")
17
+public class BpmProcessDefinition extends BaseEntity {
18
+
19
+    /** 流程唯一标识 */
9 20
     private String processKey;
21
+
22
+    /** 流程名称 */
10 23
     private String processName;
24
+
25
+    /** 流程描述 */
11 26
     private String description;
12
-    private String bpmnXml;          // BPMN 2.0 XML
13
-    private String formSchema;       // 表单 JSON Schema
14
-    private String category;         // revenue/patrol/dispatch/maintenance
27
+
28
+    /** BPMN 2.0 XML 定义 */
29
+    private String bpmnXml;
30
+
31
+    /** 表单 JSON Schema */
32
+    private String formSchema;
33
+
34
+    /** 流程分类: revenue/patrol/dispatch/maintenance/inspection */
35
+    private String category;
36
+
37
+    /** 版本号 */
38
+    @TableField("version")
15 39
     private Integer version;
16
-    private Integer status;          // 0:草稿 1:发布 2:停用
40
+
41
+    /** 状态: 0-草稿 1-已发布 2-已停用 */
42
+    private Integer status;
43
+
44
+    /** 创建人 */
17 45
     private String createdBy;
18
-    private LocalDateTime createdAt;
19
-    private LocalDateTime updatedAt;
46
+
47
+    /** 流程图标 */
48
+    private String icon;
49
+
50
+    /** 排序号 */
51
+    private Integer sortOrder;
52
+
53
+    /** 发布时间 */
54
+    private LocalDateTime publishedAt;
55
+
56
+    /** 租户ID */
57
+    private String tenantId;
20 58
 }

+ 69
- 11
wm-bpm/src/main/java/com/water/bpm/entity/BpmProcessInstance.java Прегледај датотеку

@@ -1,26 +1,84 @@
1 1
 package com.water.bpm.entity;
2 2
 
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import com.water.common.core.entity.BaseEntity;
3 5
 import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
4 8
 import java.time.LocalDateTime;
5
-import java.util.Map;
6 9
 
10
+/**
11
+ * 流程实例实体
12
+ * BPM-03: 流程处理
13
+ */
7 14
 @Data
8
-public class BpmProcessInstance {
9
-    private Long id;
15
+@EqualsAndHashCode(callSuper = true)
16
+@TableName("bpm_process_instance")
17
+public class BpmProcessInstance extends BaseEntity {
18
+
19
+    /** 实例唯一ID */
10 20
     private String instanceId;
21
+
22
+    /** 关联流程定义ID */
11 23
     private Long definitionId;
24
+
25
+    /** 流程标识 */
12 26
     private String processKey;
13
-    private String businessKey;      // 关联业务ID
14
-    private String businessType;     // 业务类型
27
+
28
+    /** 流程名称 */
29
+    private String processName;
30
+
31
+    /** 业务主键(关联业务表) */
32
+    private String businessKey;
33
+
34
+    /** 业务类型 */
35
+    private String businessType;
36
+
37
+    /** 流程标题 */
15 38
     private String title;
39
+
40
+    /** 发起人ID */
16 41
     private Long initiatorId;
42
+
43
+    /** 发起人姓名 */
17 44
     private String initiatorName;
18
-    private String currentNode;      // 当前审批节点
19
-    private String currentAssignee;  // 当前处理人
20
-    private String status;           // running/completed/terminated/rejected
21
-    private Map<String, Object> variables;
22
-    private Map<String, Object> formData;
45
+
46
+    /** 当前节点标识 */
47
+    private String currentNodeId;
48
+
49
+    /** 当前节点名称 */
50
+    private String currentNodeName;
51
+
52
+    /** 当前处理人ID */
53
+    private Long currentAssigneeId;
54
+
55
+    /** 当前处理人姓名 */
56
+    private String currentAssigneeName;
57
+
58
+    /** 状态: running/completed/terminated/rejected/suspended */
59
+    private String status;
60
+
61
+    /** 优先级: 0-普通 1-紧急 2-特急 */
62
+    private Integer priority;
63
+
64
+    /** 流程变量 JSON */
65
+    private String variables;
66
+
67
+    /** 表单数据 JSON */
68
+    private String formData;
69
+
70
+    /** 开始时间 */
23 71
     private LocalDateTime startedAt;
72
+
73
+    /** 结束时间 */
24 74
     private LocalDateTime completedAt;
25
-    private LocalDateTime createdAt;
75
+
76
+    /** 预计完成时间 */
77
+    private LocalDateTime expectedCompletionAt;
78
+
79
+    /** 耗时(秒) */
80
+    private Long durationSeconds;
81
+
82
+    /** 租户ID */
83
+    private String tenantId;
26 84
 }

+ 61
- 0
wm-bpm/src/main/java/com/water/bpm/entity/BpmProcessNode.java Прегледај датотеку

@@ -0,0 +1,61 @@
1
+package com.water.bpm.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+/**
9
+ * 流程节点定义实体
10
+ * BPM-01: 流程定义
11
+ */
12
+@Data
13
+@EqualsAndHashCode(callSuper = true)
14
+@TableName("bpm_process_node")
15
+public class BpmProcessNode extends BaseEntity {
16
+
17
+    /** 关联流程定义ID */
18
+    private Long definitionId;
19
+
20
+    /** 节点标识 */
21
+    private String nodeId;
22
+
23
+    /** 节点名称 */
24
+    private String nodeName;
25
+
26
+    /** 节点类型: start/end/userTask/serviceTask/gateway/subprocess/timer */
27
+    private String nodeType;
28
+
29
+    /** 处理人类型: role/user/department/position/initiator */
30
+    private String assigneeType;
31
+
32
+    /** 处理人值(角色ID/用户ID/部门ID等) */
33
+    private String assigneeValue;
34
+
35
+    /** 处理人名称(冗余) */
36
+    private String assigneeName;
37
+
38
+    /** 多人审批方式: sequential(会签)/parallel(或签)/countersign(比例签) */
39
+    private String multiInstanceType;
40
+
41
+    /** 会签通过比例 */
42
+    private Integer countersignRate;
43
+
44
+    /** 超时时间(小时) */
45
+    private Integer timeoutHours;
46
+
47
+    /** 超时处理: remind/transfer/escalate/auto_approve */
48
+    private String timeoutAction;
49
+
50
+    /** 表单权限 JSON */
51
+    private String formPermission;
52
+
53
+    /** 节点条件表达式 */
54
+    private String conditionExpression;
55
+
56
+    /** 排序号 */
57
+    private Integer sortOrder;
58
+
59
+    /** 租户ID */
60
+    private String tenantId;
61
+}

+ 63
- 0
wm-bpm/src/main/java/com/water/bpm/entity/BpmProcessStat.java Прегледај датотеку

@@ -0,0 +1,63 @@
1
+package com.water.bpm.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+import java.time.LocalDateTime;
9
+
10
+/**
11
+ * 流程统计实体
12
+ * BPM-04: 统计评估
13
+ */
14
+@Data
15
+@EqualsAndHashCode(callSuper = true)
16
+@TableName("bpm_process_stat")
17
+public class BpmProcessStat extends BaseEntity {
18
+
19
+    /** 流程定义ID */
20
+    private Long definitionId;
21
+
22
+    /** 流程标识 */
23
+    private String processKey;
24
+
25
+    /** 统计周期: day/week/month/quarter/year */
26
+    private String period;
27
+
28
+    /** 统计日期 */
29
+    private String statDate;
30
+
31
+    /** 发起总数 */
32
+    private Integer startCount;
33
+
34
+    /** 完成总数 */
35
+    private Integer completedCount;
36
+
37
+    /** 驳回总数 */
38
+    private Integer rejectedCount;
39
+
40
+    /** 撤回总数 */
41
+    private Integer terminatedCount;
42
+
43
+    /** 平均耗时(秒) */
44
+    private Long avgDurationSeconds;
45
+
46
+    /** 最长耗时(秒) */
47
+    private Long maxDurationSeconds;
48
+
49
+    /** 最短耗时(秒) */
50
+    private Long minDurationSeconds;
51
+
52
+    /** 平均节点耗时(秒) */
53
+    private Long avgNodeDurationSeconds;
54
+
55
+    /** 超时任务数 */
56
+    private Integer timeoutCount;
57
+
58
+    /** 一次通过率(百分比) */
59
+    private Double firstPassRate;
60
+
61
+    /** 租户ID */
62
+    private String tenantId;
63
+}

+ 75
- 0
wm-bpm/src/main/java/com/water/bpm/entity/BpmTodoTask.java Прегледај датотеку

@@ -0,0 +1,75 @@
1
+package com.water.bpm.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import com.water.common.core.entity.BaseEntity;
5
+import lombok.Data;
6
+import lombok.EqualsAndHashCode;
7
+
8
+import java.time.LocalDateTime;
9
+
10
+/**
11
+ * 待办任务实体
12
+ * BPM-06: 待办/已办中心
13
+ */
14
+@Data
15
+@EqualsAndHashCode(callSuper = true)
16
+@TableName("bpm_todo_task")
17
+public class BpmTodoTask extends BaseEntity {
18
+
19
+    /** 流程实例ID */
20
+    private Long instanceId;
21
+
22
+    /** 流程实例UUID */
23
+    private String instanceUuid;
24
+
25
+    /** 流程标题 */
26
+    private String title;
27
+
28
+    /** 流程标识 */
29
+    private String processKey;
30
+
31
+    /** 流程名称 */
32
+    private String processName;
33
+
34
+    /** 节点标识 */
35
+    private String nodeId;
36
+
37
+    /** 节点名称 */
38
+    private String nodeName;
39
+
40
+    /** 处理人ID */
41
+    private Long assigneeId;
42
+
43
+    /** 处理人姓名 */
44
+    private String assigneeName;
45
+
46
+    /** 发起人ID */
47
+    private Long initiatorId;
48
+
49
+    /** 发起人姓名 */
50
+    private String initiatorName;
51
+
52
+    /** 业务主键 */
53
+    private String businessKey;
54
+
55
+    /** 任务状态: pending/completed/transferred/delegated */
56
+    private String status;
57
+
58
+    /** 优先级: 0-普通 1-紧急 2-特急 */
59
+    private Integer priority;
60
+
61
+    /** 接收时间 */
62
+    private LocalDateTime receivedAt;
63
+
64
+    /** 完成时间 */
65
+    private LocalDateTime completedAt;
66
+
67
+    /** 超时时间 */
68
+    private LocalDateTime deadlineAt;
69
+
70
+    /** 是否已读 */
71
+    private Boolean isRead;
72
+
73
+    /** 租户ID */
74
+    private String tenantId;
75
+}

+ 33
- 0
wm-bpm/src/main/java/com/water/bpm/entity/dto/ApprovalRequest.java Прегледај датотеку

@@ -0,0 +1,33 @@
1
+package com.water.bpm.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+/**
6
+ * 审批请求 DTO
7
+ */
8
+@Data
9
+public class ApprovalRequest {
10
+    /** 流程实例ID */
11
+    private Long instanceId;
12
+
13
+    /** 流程实例UUID */
14
+    private String instanceUuid;
15
+
16
+    /** 节点标识 */
17
+    private String nodeId;
18
+
19
+    /** 节点名称 */
20
+    private String nodeName;
21
+
22
+    /** 审批动作: approve/reject/transfer/delegate/back/countersign */
23
+    private String action;
24
+
25
+    /** 审批意见 */
26
+    private String comment;
27
+
28
+    /** 转办/委派目标人ID */
29
+    private Long targetAssigneeId;
30
+
31
+    /** 转办/委派目标人姓名 */
32
+    private String targetAssigneeName;
33
+}

+ 41
- 0
wm-bpm/src/main/java/com/water/bpm/entity/dto/ProcessInstanceQuery.java Прегледај датотеку

@@ -0,0 +1,41 @@
1
+package com.water.bpm.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.time.LocalDateTime;
6
+
7
+/**
8
+ * 流程实例查询 DTO
9
+ */
10
+@Data
11
+public class ProcessInstanceQuery {
12
+    /** 流程标识 */
13
+    private String processKey;
14
+
15
+    /** 流程标题 */
16
+    private String title;
17
+
18
+    /** 状态 */
19
+    private String status;
20
+
21
+    /** 发起人ID */
22
+    private Long initiatorId;
23
+
24
+    /** 业务主键 */
25
+    private String businessKey;
26
+
27
+    /** 业务类型 */
28
+    private String businessType;
29
+
30
+    /** 开始时间(起) */
31
+    private LocalDateTime startTimeFrom;
32
+
33
+    /** 开始时间(止) */
34
+    private LocalDateTime startTimeTo;
35
+
36
+    /** 页码 */
37
+    private Integer pageNum = 1;
38
+
39
+    /** 每页条数 */
40
+    private Integer pageSize = 10;
41
+}

+ 32
- 0
wm-bpm/src/main/java/com/water/bpm/entity/dto/ProcessStartRequest.java Прегледај датотеку

@@ -0,0 +1,32 @@
1
+package com.water.bpm.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.util.Map;
6
+
7
+/**
8
+ * 流程发起请求 DTO
9
+ */
10
+@Data
11
+public class ProcessStartRequest {
12
+    /** 流程定义ID */
13
+    private Long definitionId;
14
+
15
+    /** 业务主键 */
16
+    private String businessKey;
17
+
18
+    /** 业务类型 */
19
+    private String businessType;
20
+
21
+    /** 流程标题 */
22
+    private String title;
23
+
24
+    /** 表单数据 */
25
+    private Map<String, Object> formData;
26
+
27
+    /** 流程变量 */
28
+    private Map<String, Object> variables;
29
+
30
+    /** 优先级 */
31
+    private Integer priority;
32
+}

+ 60
- 0
wm-bpm/src/main/java/com/water/bpm/entity/dto/ProcessStatVO.java Прегледај датотеку

@@ -0,0 +1,60 @@
1
+package com.water.bpm.entity.dto;
2
+
3
+import lombok.Data;
4
+
5
+import java.util.List;
6
+import java.util.Map;
7
+
8
+/**
9
+ * 流程统计 VO
10
+ */
11
+@Data
12
+public class ProcessStatVO {
13
+    /** 流程定义ID */
14
+    private Long definitionId;
15
+
16
+    /** 流程名称 */
17
+    private String processName;
18
+
19
+    /** 流程标识 */
20
+    private String processKey;
21
+
22
+    /** 总实例数 */
23
+    private Integer totalInstances;
24
+
25
+    /** 运行中数量 */
26
+    private Integer runningCount;
27
+
28
+    /** 已完成数量 */
29
+    private Integer completedCount;
30
+
31
+    /** 已驳回数量 */
32
+    private Integer rejectedCount;
33
+
34
+    /** 已撤回数量 */
35
+    private Integer terminatedCount;
36
+
37
+    /** 平均耗时(小时) */
38
+    private Double avgDurationHours;
39
+
40
+    /** 最长耗时(小时) */
41
+    private Double maxDurationHours;
42
+
43
+    /** 最短耗时(小时) */
44
+    private Double minDurationHours;
45
+
46
+    /** 一次通过率 */
47
+    private Double firstPassRate;
48
+
49
+    /** 超时率 */
50
+    private Double timeoutRate;
51
+
52
+    /** 各节点平均耗时 */
53
+    private List<Map<String, Object>> nodeAvgDurations;
54
+
55
+    /** 瓶颈节点(耗时最长) */
56
+    private String bottleneckNode;
57
+
58
+    /** 趋势数据 */
59
+    private List<Map<String, Object>> trendData;
60
+}

+ 39
- 0
wm-bpm/src/main/java/com/water/bpm/mapper/BpmApprovalRecordMapper.java Прегледај датотеку

@@ -0,0 +1,39 @@
1
+package com.water.bpm.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.bpm.entity.BpmApprovalRecord;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+
11
+/**
12
+ * 审批记录 Mapper
13
+ */
14
+@Mapper
15
+public interface BpmApprovalRecordMapper extends BaseMapper<BpmApprovalRecord> {
16
+
17
+    /**
18
+     * 根据流程实例查询审批记录
19
+     */
20
+    @Select("SELECT * FROM bpm_approval_record WHERE instance_id = #{instanceId} AND deleted = 0 ORDER BY approved_at")
21
+    List<BpmApprovalRecord> selectByInstanceId(@Param("instanceId") Long instanceId);
22
+
23
+    /**
24
+     * 根据流程实例UUID查询审批记录
25
+     */
26
+    @Select("SELECT * FROM bpm_approval_record WHERE instance_uuid = #{instanceUuid} AND deleted = 0 ORDER BY approved_at")
27
+    List<BpmApprovalRecord> selectByInstanceUuid(@Param("instanceUuid") String instanceUuid);
28
+
29
+    /**
30
+     * 查询某人的审批统计
31
+     */
32
+    @Select("SELECT approver_id, approver_name, COUNT(*) as total, " +
33
+            "SUM(CASE WHEN action = 'approve' THEN 1 ELSE 0 END) as approved, " +
34
+            "SUM(CASE WHEN action = 'reject' THEN 1 ELSE 0 END) as rejected, " +
35
+            "AVG(EXTRACT(EPOCH FROM (approved_at - created_at))) as avg_handle_seconds " +
36
+            "FROM bpm_approval_record WHERE deleted = 0 AND approved_at >= #{startDate} " +
37
+            "GROUP BY approver_id, approver_name")
38
+    List<java.util.Map<String, Object>> statByApprover(@Param("startDate") java.time.LocalDateTime startDate);
39
+}

+ 28
- 0
wm-bpm/src/main/java/com/water/bpm/mapper/BpmFormTemplateMapper.java Прегледај датотеку

@@ -0,0 +1,28 @@
1
+package com.water.bpm.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.bpm.entity.BpmFormTemplate;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+
11
+/**
12
+ * 表单模板 Mapper
13
+ */
14
+@Mapper
15
+public interface BpmFormTemplateMapper extends BaseMapper<BpmFormTemplate> {
16
+
17
+    /**
18
+     * 根据分类查询模板
19
+     */
20
+    @Select("SELECT * FROM bpm_form_template WHERE category = #{category} AND status = 1 AND deleted = 0 ORDER BY use_count DESC")
21
+    List<BpmFormTemplate> selectByCategory(@Param("category") String category);
22
+
23
+    /**
24
+     * 查询热门模板
25
+     */
26
+    @Select("SELECT * FROM bpm_form_template WHERE status = 1 AND deleted = 0 ORDER BY use_count DESC LIMIT #{limit}")
27
+    List<BpmFormTemplate> selectHotTemplates(@Param("limit") int limit);
28
+}

+ 28
- 0
wm-bpm/src/main/java/com/water/bpm/mapper/BpmOrchestrationMapper.java Прегледај датотеку

@@ -0,0 +1,28 @@
1
+package com.water.bpm.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.bpm.entity.BpmOrchestration;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Select;
7
+
8
+import java.util.List;
9
+
10
+/**
11
+ * 流程编排 Mapper
12
+ */
13
+@Mapper
14
+public interface BpmOrchestrationMapper extends BaseMapper<BpmOrchestration> {
15
+
16
+    /**
17
+     * 查询所有启用的编排
18
+     */
19
+    @Select("SELECT * FROM bpm_orchestration WHERE status = 1 AND deleted = 0 ORDER BY created_at DESC")
20
+    List<BpmOrchestration> selectEnabled();
21
+
22
+    /**
23
+     * 查询事件触发的编排
24
+     */
25
+    @Select("SELECT * FROM bpm_orchestration WHERE trigger_type = 'event' AND event_name = #{eventName} " +
26
+            "AND status = 1 AND deleted = 0")
27
+    List<BpmOrchestration> selectByEvent(String eventName);
28
+}

+ 34
- 0
wm-bpm/src/main/java/com/water/bpm/mapper/BpmProcessDefinitionMapper.java Прегледај датотеку

@@ -0,0 +1,34 @@
1
+package com.water.bpm.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.bpm.entity.BpmProcessDefinition;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+
11
+/**
12
+ * 流程定义 Mapper
13
+ */
14
+@Mapper
15
+public interface BpmProcessDefinitionMapper extends BaseMapper<BpmProcessDefinition> {
16
+
17
+    /**
18
+     * 根据分类查询已发布的流程定义
19
+     */
20
+    @Select("SELECT * FROM bpm_process_definition WHERE category = #{category} AND status = 1 AND deleted = 0 ORDER BY sort_order")
21
+    List<BpmProcessDefinition> selectByCategory(@Param("category") String category);
22
+
23
+    /**
24
+     * 根据 processKey 查询最新版本
25
+     */
26
+    @Select("SELECT * FROM bpm_process_definition WHERE process_key = #{processKey} AND deleted = 0 ORDER BY version DESC LIMIT 1")
27
+    BpmProcessDefinition selectByProcessKey(@Param("processKey") String processKey);
28
+
29
+    /**
30
+     * 查询所有分类
31
+     */
32
+    @Select("SELECT DISTINCT category FROM bpm_process_definition WHERE deleted = 0 AND status = 1 ORDER BY category")
33
+    List<String> selectAllCategories();
34
+}

+ 43
- 0
wm-bpm/src/main/java/com/water/bpm/mapper/BpmProcessInstanceMapper.java Прегледај датотеку

@@ -0,0 +1,43 @@
1
+package com.water.bpm.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.bpm.entity.BpmProcessInstance;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+import java.util.Map;
11
+
12
+/**
13
+ * 流程实例 Mapper
14
+ */
15
+@Mapper
16
+public interface BpmProcessInstanceMapper extends BaseMapper<BpmProcessInstance> {
17
+
18
+    /**
19
+     * 统计各状态数量
20
+     */
21
+    @Select("SELECT status, COUNT(*) as count FROM bpm_process_instance WHERE deleted = 0 GROUP BY status")
22
+    List<Map<String, Object>> countByStatus();
23
+
24
+    /**
25
+     * 根据流程定义统计
26
+     */
27
+    @Select("SELECT definition_id, COUNT(*) as total, " +
28
+            "SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running, " +
29
+            "SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, " +
30
+            "SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected, " +
31
+            "SUM(CASE WHEN status = 'terminated' THEN 1 ELSE 0 END) as terminated, " +
32
+            "AVG(duration_seconds) as avg_duration, " +
33
+            "MAX(duration_seconds) as max_duration, " +
34
+            "MIN(duration_seconds) as min_duration " +
35
+            "FROM bpm_process_instance WHERE deleted = 0 GROUP BY definition_id")
36
+    List<Map<String, Object>> statByDefinition();
37
+
38
+    /**
39
+     * 查询我发起的流程
40
+     */
41
+    @Select("SELECT * FROM bpm_process_instance WHERE initiator_id = #{userId} AND deleted = 0 ORDER BY created_at DESC")
42
+    List<BpmProcessInstance> selectMyInitiated(@Param("userId") Long userId);
43
+}

+ 22
- 0
wm-bpm/src/main/java/com/water/bpm/mapper/BpmProcessNodeMapper.java Прегледај датотеку

@@ -0,0 +1,22 @@
1
+package com.water.bpm.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.bpm.entity.BpmProcessNode;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+
11
+/**
12
+ * 流程节点 Mapper
13
+ */
14
+@Mapper
15
+public interface BpmProcessNodeMapper extends BaseMapper<BpmProcessNode> {
16
+
17
+    /**
18
+     * 查询流程定义的所有节点
19
+     */
20
+    @Select("SELECT * FROM bpm_process_node WHERE definition_id = #{definitionId} AND deleted = 0 ORDER BY sort_order")
21
+    List<BpmProcessNode> selectByDefinitionId(@Param("definitionId") Long definitionId);
22
+}

+ 25
- 0
wm-bpm/src/main/java/com/water/bpm/mapper/BpmProcessStatMapper.java Прегледај датотеку

@@ -0,0 +1,25 @@
1
+package com.water.bpm.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.bpm.entity.BpmProcessStat;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+
11
+/**
12
+ * 流程统计 Mapper
13
+ */
14
+@Mapper
15
+public interface BpmProcessStatMapper extends BaseMapper<BpmProcessStat> {
16
+
17
+    /**
18
+     * 查询某流程的统计趋势
19
+     */
20
+    @Select("SELECT * FROM bpm_process_stat WHERE definition_id = #{definitionId} AND period = #{period} " +
21
+            "AND deleted = 0 ORDER BY stat_date DESC LIMIT #{limit}")
22
+    List<BpmProcessStat> selectTrend(@Param("definitionId") Long definitionId,
23
+                                      @Param("period") String period,
24
+                                      @Param("limit") int limit);
25
+}

+ 42
- 0
wm-bpm/src/main/java/com/water/bpm/mapper/BpmTodoTaskMapper.java Прегледај датотеку

@@ -0,0 +1,42 @@
1
+package com.water.bpm.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.bpm.entity.BpmTodoTask;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Param;
7
+import org.apache.ibatis.annotations.Select;
8
+
9
+import java.util.List;
10
+
11
+/**
12
+ * 待办任务 Mapper
13
+ */
14
+@Mapper
15
+public interface BpmTodoTaskMapper extends BaseMapper<BpmTodoTask> {
16
+
17
+    /**
18
+     * 查询待办列表
19
+     */
20
+    @Select("SELECT * FROM bpm_todo_task WHERE assignee_id = #{userId} AND status = 'pending' AND deleted = 0 " +
21
+            "ORDER BY priority DESC, received_at")
22
+    List<BpmTodoTask> selectPendingByUserId(@Param("userId") Long userId);
23
+
24
+    /**
25
+     * 查询已办列表
26
+     */
27
+    @Select("SELECT * FROM bpm_todo_task WHERE assignee_id = #{userId} AND status != 'pending' AND deleted = 0 " +
28
+            "ORDER BY completed_at DESC")
29
+    List<BpmTodoTask> selectDoneByUserId(@Param("userId") Long userId);
30
+
31
+    /**
32
+     * 统计待办数量
33
+     */
34
+    @Select("SELECT COUNT(*) FROM bpm_todo_task WHERE assignee_id = #{userId} AND status = 'pending' AND deleted = 0")
35
+    Integer countPendingByUserId(@Param("userId") Long userId);
36
+
37
+    /**
38
+     * 查询超时的待办
39
+     */
40
+    @Select("SELECT * FROM bpm_todo_task WHERE status = 'pending' AND deadline_at < NOW() AND deleted = 0")
41
+    List<BpmTodoTask> selectTimeoutTasks();
42
+}

+ 16
- 0
wm-config/pom.xml Прегледај датотеку

@@ -0,0 +1,16 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<project xmlns="http://maven.apache.org/POM/4.0.0"
3
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
5
+    <modelVersion>4.0.0</modelVersion>
6
+    <parent><groupId>com.water</groupId><artifactId>wm-parent</artifactId><version>1.0.0-SNAPSHOT</version></parent>
7
+    <artifactId>wm-config</artifactId>
8
+    <dependencies>
9
+        <dependency><groupId>com.water</groupId><artifactId>wm-common</artifactId></dependency>
10
+        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
11
+        <dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
12
+        <dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
13
+        <dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot3-starter</artifactId></dependency>
14
+        <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
15
+    </dependencies>
16
+</project>

+ 15
- 0
wm-config/src/main/java/com/water/config/ConfigApplication.java Прегледај датотеку

@@ -0,0 +1,15 @@
1
+package com.water.config;
2
+
3
+import org.mybatis.spring.annotation.MapperScan;
4
+import org.springframework.boot.SpringApplication;
5
+import org.springframework.boot.autoconfigure.SpringBootApplication;
6
+import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
7
+
8
+@SpringBootApplication
9
+@EnableDiscoveryClient
10
+@MapperScan("com.water.config.mapper")
11
+public class ConfigApplication {
12
+    public static void main(String[] args) {
13
+        SpringApplication.run(ConfigApplication.class, args);
14
+    }
15
+}

+ 68
- 0
wm-config/src/main/java/com/water/config/controller/AnnouncementController.java Прегледај датотеку

@@ -0,0 +1,68 @@
1
+package com.water.config.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.config.entity.Announcement;
6
+import com.water.config.service.AnnouncementService;
7
+import io.swagger.v3.oas.annotations.Operation;
8
+import io.swagger.v3.oas.annotations.tags.Tag;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.web.bind.annotation.*;
11
+
12
+@Tag(name = "公告通知管理")
13
+@RestController
14
+@RequestMapping("/api/config/announcement")
15
+@RequiredArgsConstructor
16
+public class AnnouncementController {
17
+
18
+    private final AnnouncementService announcementService;
19
+
20
+    @Operation(summary = "分页查询公告")
21
+    @GetMapping("/list")
22
+    public R<Page<Announcement>> list(@RequestParam(defaultValue = "1") int page,
23
+                                       @RequestParam(defaultValue = "10") int size,
24
+                                       @RequestParam(required = false) Integer type,
25
+                                       @RequestParam(required = false) Integer publishStatus) {
26
+        return R.ok(announcementService.pageAnnouncements(page, size, type, publishStatus));
27
+    }
28
+
29
+    @Operation(summary = "获取公告详情")
30
+    @GetMapping("/{id}")
31
+    public R<Announcement> getById(@PathVariable Long id) {
32
+        return R.ok(announcementService.getById(id));
33
+    }
34
+
35
+    @Operation(summary = "创建公告(草稿)")
36
+    @PostMapping
37
+    public R<Announcement> create(@RequestBody Announcement announcement) {
38
+        return R.ok(announcementService.createAnnouncement(announcement));
39
+    }
40
+
41
+    @Operation(summary = "更新公告")
42
+    @PutMapping("/{id}")
43
+    public R<String> update(@PathVariable Long id, @RequestBody Announcement announcement) {
44
+        announcementService.updateAnnouncement(id, announcement);
45
+        return R.ok("更新成功");
46
+    }
47
+
48
+    @Operation(summary = "发布公告")
49
+    @PostMapping("/{id}/publish")
50
+    public R<String> publish(@PathVariable Long id) {
51
+        announcementService.publish(id);
52
+        return R.ok("发布成功");
53
+    }
54
+
55
+    @Operation(summary = "撤回公告")
56
+    @PostMapping("/{id}/withdraw")
57
+    public R<String> withdraw(@PathVariable Long id) {
58
+        announcementService.withdraw(id);
59
+        return R.ok("撤回成功");
60
+    }
61
+
62
+    @Operation(summary = "删除公告")
63
+    @DeleteMapping("/{id}")
64
+    public R<String> delete(@PathVariable Long id) {
65
+        announcementService.deleteAnnouncement(id);
66
+        return R.ok("删除成功");
67
+    }
68
+}

+ 98
- 0
wm-config/src/main/java/com/water/config/controller/DeviceManageController.java Прегледај датотеку

@@ -0,0 +1,98 @@
1
+package com.water.config.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.config.entity.DeviceInfo;
6
+import com.water.config.entity.DeviceMaintenance;
7
+import com.water.config.service.DeviceManageService;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.tags.Tag;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.web.bind.annotation.*;
12
+
13
+import java.util.List;
14
+
15
+@Tag(name = "设备管理")
16
+@RestController
17
+@RequestMapping("/api/config/device")
18
+@RequiredArgsConstructor
19
+public class DeviceManageController {
20
+
21
+    private final DeviceManageService deviceManageService;
22
+
23
+    @Operation(summary = "分页查询设备")
24
+    @GetMapping("/list")
25
+    public R<Page<DeviceInfo>> list(@RequestParam(defaultValue = "1") int page,
26
+                                     @RequestParam(defaultValue = "10") int size,
27
+                                     @RequestParam(required = false) String deviceName,
28
+                                     @RequestParam(required = false) Integer category,
29
+                                     @RequestParam(required = false) Integer deviceStatus) {
30
+        return R.ok(deviceManageService.pageDevices(page, size, deviceName, category, deviceStatus));
31
+    }
32
+
33
+    @Operation(summary = "获取设备详情")
34
+    @GetMapping("/{id}")
35
+    public R<DeviceInfo> getById(@PathVariable Long id) {
36
+        return R.ok(deviceManageService.getById(id));
37
+    }
38
+
39
+    @Operation(summary = "创建设备")
40
+    @PostMapping
41
+    public R<DeviceInfo> create(@RequestBody DeviceInfo device) {
42
+        return R.ok(deviceManageService.createDevice(device));
43
+    }
44
+
45
+    @Operation(summary = "更新设备")
46
+    @PutMapping("/{id}")
47
+    public R<String> update(@PathVariable Long id, @RequestBody DeviceInfo device) {
48
+        deviceManageService.updateDevice(id, device);
49
+        return R.ok("更新成功");
50
+    }
51
+
52
+    @Operation(summary = "删除设备")
53
+    @DeleteMapping("/{id}")
54
+    public R<String> delete(@PathVariable Long id) {
55
+        deviceManageService.removeById(id);
56
+        return R.ok("删除成功");
57
+    }
58
+
59
+    @Operation(summary = "更新设备状态")
60
+    @PutMapping("/{id}/status")
61
+    public R<String> updateStatus(@PathVariable Long id, @RequestParam Integer deviceStatus) {
62
+        deviceManageService.updateDeviceStatus(id, deviceStatus);
63
+        return R.ok("状态更新成功");
64
+    }
65
+
66
+    @Operation(summary = "按分类查询设备")
67
+    @GetMapping("/category/{category}")
68
+    public R<List<DeviceInfo>> getByCategory(@PathVariable Integer category) {
69
+        return R.ok(deviceManageService.getDevicesByCategory(category));
70
+    }
71
+
72
+    @Operation(summary = "按状态查询设备")
73
+    @GetMapping("/status/{status}")
74
+    public R<List<DeviceInfo>> getByStatus(@PathVariable Integer status) {
75
+        return R.ok(deviceManageService.getDevicesByStatus(status));
76
+    }
77
+
78
+    @Operation(summary = "添加维保记录")
79
+    @PostMapping("/maintenance")
80
+    public R<DeviceMaintenance> addMaintenance(@RequestBody DeviceMaintenance maintenance) {
81
+        return R.ok(deviceManageService.addMaintenance(maintenance));
82
+    }
83
+
84
+    @Operation(summary = "查询维保记录")
85
+    @GetMapping("/maintenance")
86
+    public R<Page<DeviceMaintenance>> pageMaintenances(@RequestParam(required = false) Long deviceId,
87
+                                                        @RequestParam(defaultValue = "1") int page,
88
+                                                        @RequestParam(defaultValue = "10") int size) {
89
+        return R.ok(deviceManageService.pageMaintenances(deviceId, page, size));
90
+    }
91
+
92
+    @Operation(summary = "更新维保记录")
93
+    @PutMapping("/maintenance/{id}")
94
+    public R<String> updateMaintenance(@PathVariable Long id, @RequestBody DeviceMaintenance maintenance) {
95
+        deviceManageService.updateMaintenance(id, maintenance);
96
+        return R.ok("更新成功");
97
+    }
98
+}

+ 84
- 0
wm-config/src/main/java/com/water/config/controller/ThresholdController.java Прегледај датотеку

@@ -0,0 +1,84 @@
1
+package com.water.config.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.config.entity.ThresholdChangeLog;
6
+import com.water.config.entity.ThresholdConfig;
7
+import com.water.config.service.ThresholdService;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.tags.Tag;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.web.bind.annotation.*;
12
+
13
+import java.util.List;
14
+
15
+@Tag(name = "阈值管理")
16
+@RestController
17
+@RequestMapping("/api/config/threshold")
18
+@RequiredArgsConstructor
19
+public class ThresholdController {
20
+
21
+    private final ThresholdService thresholdService;
22
+
23
+    @Operation(summary = "分页查询阈值配置")
24
+    @GetMapping("/list")
25
+    public R<Page<ThresholdConfig>> list(@RequestParam(defaultValue = "1") int page,
26
+                                          @RequestParam(defaultValue = "10") int size,
27
+                                          @RequestParam(required = false) String metricCode,
28
+                                          @RequestParam(required = false) Integer level) {
29
+        return R.ok(thresholdService.pageThresholds(page, size, metricCode, level));
30
+    }
31
+
32
+    @Operation(summary = "获取阈值详情")
33
+    @GetMapping("/{id}")
34
+    public R<ThresholdConfig> getById(@PathVariable Long id) {
35
+        return R.ok(thresholdService.getById(id));
36
+    }
37
+
38
+    @Operation(summary = "创建阈值配置")
39
+    @PostMapping
40
+    public R<ThresholdConfig> create(@RequestBody ThresholdConfig config) {
41
+        return R.ok(thresholdService.createThreshold(config));
42
+    }
43
+
44
+    @Operation(summary = "更新阈值配置")
45
+    @PutMapping("/{id}")
46
+    public R<String> update(@PathVariable Long id, @RequestBody ThresholdConfig config) {
47
+        thresholdService.updateThreshold(id, config);
48
+        return R.ok("更新成功");
49
+    }
50
+
51
+    @Operation(summary = "删除阈值配置")
52
+    @DeleteMapping("/{id}")
53
+    public R<String> delete(@PathVariable Long id) {
54
+        thresholdService.deleteThreshold(id);
55
+        return R.ok("删除成功");
56
+    }
57
+
58
+    @Operation(summary = "启用/禁用阈值")
59
+    @PutMapping("/{id}/status")
60
+    public R<String> toggleStatus(@PathVariable Long id, @RequestParam Integer status) {
61
+        thresholdService.toggleStatus(id, status);
62
+        return R.ok(status == 1 ? "已启用" : "已禁用");
63
+    }
64
+
65
+    @Operation(summary = "获取指标的全局阈值(多级)")
66
+    @GetMapping("/global/{metricCode}")
67
+    public R<List<ThresholdConfig>> getGlobalThresholds(@PathVariable String metricCode) {
68
+        return R.ok(thresholdService.getGlobalThresholds(metricCode));
69
+    }
70
+
71
+    @Operation(summary = "获取设备阈值配置")
72
+    @GetMapping("/device/{deviceId}")
73
+    public R<List<ThresholdConfig>> getDeviceThresholds(@PathVariable Long deviceId) {
74
+        return R.ok(thresholdService.getDeviceThresholds(deviceId));
75
+    }
76
+
77
+    @Operation(summary = "获取阈值变更历史")
78
+    @GetMapping("/history")
79
+    public R<Page<ThresholdChangeLog>> getChangeHistory(@RequestParam(required = false) Long thresholdId,
80
+                                                         @RequestParam(defaultValue = "1") int page,
81
+                                                         @RequestParam(defaultValue = "10") int size) {
82
+        return R.ok(thresholdService.getChangeHistory(thresholdId, page, size));
83
+    }
84
+}

+ 35
- 0
wm-config/src/main/java/com/water/config/entity/Announcement.java Прегледај датотеку

@@ -0,0 +1,35 @@
1
+package com.water.config.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+/**
8
+ * 公告通知
9
+ */
10
+@Data
11
+@TableName("config_announcement")
12
+public class Announcement {
13
+    @TableId(type = IdType.AUTO)
14
+    private Long id;
15
+    /** 标题 */
16
+    private String title;
17
+    /** 内容 */
18
+    private String content;
19
+    /** 类型: 1-系统公告 2-维护通知 3-紧急通知 */
20
+    private Integer type;
21
+    /** 发布状态: 0-草稿 1-已发布 2-已撤回 */
22
+    private Integer publishStatus;
23
+    /** 发布渠道(JSON数组): ["sms","push","site"] */
24
+    private String channels;
25
+    /** 发布人 */
26
+    private String publisher;
27
+    /** 发布时间 */
28
+    private LocalDateTime publishTime;
29
+    /** 撤回时间 */
30
+    private LocalDateTime withdrawTime;
31
+    @TableLogic
32
+    private Integer deleted;
33
+    private LocalDateTime createdAt;
34
+    private LocalDateTime updatedAt;
35
+}

+ 45
- 0
wm-config/src/main/java/com/water/config/entity/DeviceInfo.java Прегледај датотеку

@@ -0,0 +1,45 @@
1
+package com.water.config.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+/**
8
+ * 设备台账
9
+ */
10
+@Data
11
+@TableName("config_device_info")
12
+public class DeviceInfo {
13
+    @TableId(type = IdType.AUTO)
14
+    private Long id;
15
+    /** 设备编码 */
16
+    private String deviceCode;
17
+    /** 设备名称 */
18
+    private String deviceName;
19
+    /** 设备分类: 1-水表 2-压力传感器 3-流量计 4-水质监测仪 5-阀门 9-其他 */
20
+    private Integer category;
21
+    /** 品牌 */
22
+    private String brand;
23
+    /** 型号 */
24
+    private String model;
25
+    /** 安装位置 */
26
+    private String location;
27
+    /** 经度 */
28
+    private Double longitude;
29
+    /** 纬度 */
30
+    private Double latitude;
31
+    /** 设备状态: 0-离线 1-在线 2-故障 3-维修中 */
32
+    private Integer deviceStatus;
33
+    /** 安装日期 */
34
+    private LocalDateTime installDate;
35
+    /** 最后维护时间 */
36
+    private LocalDateTime lastMaintenanceTime;
37
+    /** 负责人 */
38
+    private String responsiblePerson;
39
+    /** 备注 */
40
+    private String remark;
41
+    @TableLogic
42
+    private Integer deleted;
43
+    private LocalDateTime createdAt;
44
+    private LocalDateTime updatedAt;
45
+}

+ 37
- 0
wm-config/src/main/java/com/water/config/entity/DeviceMaintenance.java Прегледај датотеку

@@ -0,0 +1,37 @@
1
+package com.water.config.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.time.LocalDateTime;
6
+
7
+/**
8
+ * 设备维保记录
9
+ */
10
+@Data
11
+@TableName("config_device_maintenance")
12
+public class DeviceMaintenance {
13
+    @TableId(type = IdType.AUTO)
14
+    private Long id;
15
+    /** 设备ID */
16
+    private Long deviceId;
17
+    /** 维保类型: 1-日常巡检 2-定期保养 3-故障维修 4-更换配件 */
18
+    private Integer maintenanceType;
19
+    /** 维保描述 */
20
+    private String description;
21
+    /** 维保人 */
22
+    private String operator;
23
+    /** 维保开始时间 */
24
+    private LocalDateTime startTime;
25
+    /** 维保结束时间 */
26
+    private LocalDateTime endTime;
27
+    /** 维保结果: 0-未完成 1-已完成 2-需要返修 */
28
+    private Integer result;
29
+    /** 费用 */
30
+    private Double cost;
31
+    /** 附件(JSON) */
32
+    private String attachments;
33
+    @TableLogic
34
+    private Integer deleted;
35
+    private LocalDateTime createdAt;
36
+    private LocalDateTime updatedAt;
37
+}

+ 35
- 0
wm-config/src/main/java/com/water/config/entity/ThresholdChangeLog.java Прегледај датотеку

@@ -0,0 +1,35 @@
1
+package com.water.config.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.math.BigDecimal;
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 阈值变更记录
10
+ */
11
+@Data
12
+@TableName("config_threshold_change_log")
13
+public class ThresholdChangeLog {
14
+    @TableId(type = IdType.AUTO)
15
+    private Long id;
16
+    /** 关联阈值ID */
17
+    private Long thresholdId;
18
+    /** 变更前最小值 */
19
+    private BigDecimal oldMinValue;
20
+    /** 变更前最大值 */
21
+    private BigDecimal oldMaxValue;
22
+    /** 变更后最小值 */
23
+    private BigDecimal newMinValue;
24
+    /** 变更后最大值 */
25
+    private BigDecimal newMaxValue;
26
+    /** 变更前级别 */
27
+    private Integer oldLevel;
28
+    /** 变更后级别 */
29
+    private Integer newLevel;
30
+    /** 变更人 */
31
+    private String operator;
32
+    /** 变更原因 */
33
+    private String reason;
34
+    private LocalDateTime createdAt;
35
+}

+ 38
- 0
wm-config/src/main/java/com/water/config/entity/ThresholdConfig.java Прегледај датотеку

@@ -0,0 +1,38 @@
1
+package com.water.config.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import java.math.BigDecimal;
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 阈值配置
10
+ */
11
+@Data
12
+@TableName("config_threshold")
13
+public class ThresholdConfig {
14
+    @TableId(type = IdType.AUTO)
15
+    private Long id;
16
+    /** 指标编码 */
17
+    private String metricCode;
18
+    /** 指标名称 */
19
+    private String metricName;
20
+    /** 设备ID(可选,null表示全局) */
21
+    private Long deviceId;
22
+    /** 阈值级别: 1-预警 2-报警 3-紧急 */
23
+    private Integer level;
24
+    /** 最小值 */
25
+    private BigDecimal minValue;
26
+    /** 最大值 */
27
+    private BigDecimal maxValue;
28
+    /** 单位 */
29
+    private String unit;
30
+    /** 启用状态: 0-禁用 1-启用 */
31
+    private Integer status;
32
+    /** 备注 */
33
+    private String remark;
34
+    @TableLogic
35
+    private Integer deleted;
36
+    private LocalDateTime createdAt;
37
+    private LocalDateTime updatedAt;
38
+}

+ 9
- 0
wm-config/src/main/java/com/water/config/mapper/AnnouncementMapper.java Прегледај датотеку

@@ -0,0 +1,9 @@
1
+package com.water.config.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.config.entity.Announcement;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface AnnouncementMapper extends BaseMapper<Announcement> {
9
+}

+ 17
- 0
wm-config/src/main/java/com/water/config/mapper/DeviceInfoMapper.java Прегледај датотеку

@@ -0,0 +1,17 @@
1
+package com.water.config.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.config.entity.DeviceInfo;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Select;
7
+import java.util.List;
8
+
9
+@Mapper
10
+public interface DeviceInfoMapper extends BaseMapper<DeviceInfo> {
11
+
12
+    @Select("SELECT * FROM config_device_info WHERE device_status = #{status} AND deleted = 0")
13
+    List<DeviceInfo> selectByDeviceStatus(Integer status);
14
+
15
+    @Select("SELECT * FROM config_device_info WHERE category = #{category} AND deleted = 0 ORDER BY device_code")
16
+    List<DeviceInfo> selectByCategory(Integer category);
17
+}

+ 14
- 0
wm-config/src/main/java/com/water/config/mapper/DeviceMaintenanceMapper.java Прегледај датотеку

@@ -0,0 +1,14 @@
1
+package com.water.config.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.config.entity.DeviceMaintenance;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Select;
7
+import java.util.List;
8
+
9
+@Mapper
10
+public interface DeviceMaintenanceMapper extends BaseMapper<DeviceMaintenance> {
11
+
12
+    @Select("SELECT * FROM config_device_maintenance WHERE device_id = #{deviceId} AND deleted = 0 ORDER BY start_time DESC")
13
+    List<DeviceMaintenance> selectByDeviceId(Long deviceId);
14
+}

+ 9
- 0
wm-config/src/main/java/com/water/config/mapper/ThresholdChangeLogMapper.java Прегледај датотеку

@@ -0,0 +1,9 @@
1
+package com.water.config.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.config.entity.ThresholdChangeLog;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface ThresholdChangeLogMapper extends BaseMapper<ThresholdChangeLog> {
9
+}

+ 17
- 0
wm-config/src/main/java/com/water/config/mapper/ThresholdConfigMapper.java Прегледај датотеку

@@ -0,0 +1,17 @@
1
+package com.water.config.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.config.entity.ThresholdConfig;
5
+import org.apache.ibatis.annotations.Mapper;
6
+import org.apache.ibatis.annotations.Select;
7
+import java.util.List;
8
+
9
+@Mapper
10
+public interface ThresholdConfigMapper extends BaseMapper<ThresholdConfig> {
11
+
12
+    @Select("SELECT * FROM config_threshold WHERE metric_code = #{metricCode} AND device_id IS NULL AND status = 1 AND deleted = 0 ORDER BY level")
13
+    List<ThresholdConfig> selectGlobalByMetricCode(String metricCode);
14
+
15
+    @Select("SELECT * FROM config_threshold WHERE device_id = #{deviceId} AND status = 1 AND deleted = 0 ORDER BY metric_code, level")
16
+    List<ThresholdConfig> selectByDeviceId(Long deviceId);
17
+}

+ 131
- 0
wm-config/src/main/java/com/water/config/service/AnnouncementService.java Прегледај датотеку

@@ -0,0 +1,131 @@
1
+package com.water.config.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
6
+import com.water.common.core.exception.BusinessException;
7
+import com.water.config.entity.Announcement;
8
+import com.water.config.mapper.AnnouncementMapper;
9
+import lombok.extern.slf4j.Slf4j;
10
+import org.springframework.stereotype.Service;
11
+import org.springframework.transaction.annotation.Transactional;
12
+
13
+import java.time.LocalDateTime;
14
+
15
+/**
16
+ * 公告通知服务
17
+ */
18
+@Slf4j
19
+@Service
20
+public class AnnouncementService extends ServiceImpl<AnnouncementMapper, Announcement> {
21
+
22
+    /**
23
+     * 分页查询公告
24
+     */
25
+    public Page<Announcement> pageAnnouncements(int page, int size, Integer type, Integer publishStatus) {
26
+        LambdaQueryWrapper<Announcement> qw = new LambdaQueryWrapper<>();
27
+        if (type != null) {
28
+            qw.eq(Announcement::getType, type);
29
+        }
30
+        if (publishStatus != null) {
31
+            qw.eq(Announcement::getPublishStatus, publishStatus);
32
+        }
33
+        qw.orderByDesc(Announcement::getCreatedAt);
34
+        return this.page(new Page<>(page, size), qw);
35
+    }
36
+
37
+    /**
38
+     * 创建公告(草稿)
39
+     */
40
+    public Announcement createAnnouncement(Announcement announcement) {
41
+        announcement.setPublishStatus(0);
42
+        this.save(announcement);
43
+        return announcement;
44
+    }
45
+
46
+    /**
47
+     * 更新公告(仅草稿可更新)
48
+     */
49
+    public void updateAnnouncement(Long id, Announcement announcement) {
50
+        Announcement existing = this.getById(id);
51
+        if (existing == null) {
52
+            throw new BusinessException("公告不存在");
53
+        }
54
+        if (existing.getPublishStatus() != 0) {
55
+            throw new BusinessException("已发布的公告不可修改");
56
+        }
57
+        announcement.setId(id);
58
+        this.updateById(announcement);
59
+    }
60
+
61
+    /**
62
+     * 发布公告
63
+     */
64
+    @Transactional
65
+    public void publish(Long id) {
66
+        Announcement announcement = this.getById(id);
67
+        if (announcement == null) {
68
+            throw new BusinessException("公告不存在");
69
+        }
70
+        if (announcement.getPublishStatus() != 0) {
71
+            throw new BusinessException("只有草稿状态的公告可以发布");
72
+        }
73
+        announcement.setPublishStatus(1);
74
+        announcement.setPublishTime(LocalDateTime.now());
75
+        this.updateById(announcement);
76
+        // 多渠道发布
77
+        dispatchChannels(announcement);
78
+    }
79
+
80
+    /**
81
+     * 撤回公告
82
+     */
83
+    @Transactional
84
+    public void withdraw(Long id) {
85
+        Announcement announcement = this.getById(id);
86
+        if (announcement == null) {
87
+            throw new BusinessException("公告不存在");
88
+        }
89
+        if (announcement.getPublishStatus() != 1) {
90
+            throw new BusinessException("只有已发布的公告可以撤回");
91
+        }
92
+        announcement.setPublishStatus(2);
93
+        announcement.setWithdrawTime(LocalDateTime.now());
94
+        this.updateById(announcement);
95
+    }
96
+
97
+    /**
98
+     * 删除公告
99
+     */
100
+    public void deleteAnnouncement(Long id) {
101
+        Announcement existing = this.getById(id);
102
+        if (existing == null) {
103
+            throw new BusinessException("公告不存在");
104
+        }
105
+        if (existing.getPublishStatus() == 1) {
106
+            throw new BusinessException("已发布的公告不可删除,请先撤回");
107
+        }
108
+        this.removeById(id);
109
+    }
110
+
111
+    /**
112
+     * 多渠道分发(模拟)
113
+     */
114
+    private void dispatchChannels(Announcement announcement) {
115
+        String channels = announcement.getChannels();
116
+        if (channels == null || channels.isEmpty()) {
117
+            log.info("公告 {} 无渠道配置,仅站内信发布", announcement.getId());
118
+            return;
119
+        }
120
+        log.info("公告 {} 发布渠道: {}", announcement.getId(), channels);
121
+        if (channels.contains("sms")) {
122
+            log.info("→ 短信渠道已触发");
123
+        }
124
+        if (channels.contains("push")) {
125
+            log.info("→ APP推送渠道已触发");
126
+        }
127
+        if (channels.contains("site")) {
128
+            log.info("→ 站内信渠道已触发");
129
+        }
130
+    }
131
+}

+ 150
- 0
wm-config/src/main/java/com/water/config/service/DeviceManageService.java Прегледај датотеку

@@ -0,0 +1,150 @@
1
+package com.water.config.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
6
+import com.water.common.core.exception.BusinessException;
7
+import com.water.config.entity.DeviceInfo;
8
+import com.water.config.entity.DeviceMaintenance;
9
+import com.water.config.mapper.DeviceInfoMapper;
10
+import com.water.config.mapper.DeviceMaintenanceMapper;
11
+import lombok.RequiredArgsConstructor;
12
+import org.springframework.stereotype.Service;
13
+import org.springframework.transaction.annotation.Transactional;
14
+
15
+import java.time.LocalDateTime;
16
+import java.util.List;
17
+
18
+/**
19
+ * 设备管理服务
20
+ */
21
+@Service
22
+@RequiredArgsConstructor
23
+public class DeviceManageService extends ServiceImpl<DeviceInfoMapper, DeviceInfo> {
24
+
25
+    private final DeviceMaintenanceMapper maintenanceMapper;
26
+
27
+    /**
28
+     * 分页查询设备
29
+     */
30
+    public Page<DeviceInfo> pageDevices(int page, int size, String deviceName, Integer category, Integer deviceStatus) {
31
+        LambdaQueryWrapper<DeviceInfo> qw = new LambdaQueryWrapper<>();
32
+        if (deviceName != null && !deviceName.isEmpty()) {
33
+            qw.like(DeviceInfo::getDeviceName, deviceName);
34
+        }
35
+        if (category != null) {
36
+            qw.eq(DeviceInfo::getCategory, category);
37
+        }
38
+        if (deviceStatus != null) {
39
+            qw.eq(DeviceInfo::getDeviceStatus, deviceStatus);
40
+        }
41
+        qw.orderByDesc(DeviceInfo::getCreatedAt);
42
+        return this.page(new Page<>(page, size), qw);
43
+    }
44
+
45
+    /**
46
+     * 创建设备
47
+     */
48
+    public DeviceInfo createDevice(DeviceInfo device) {
49
+        // 检查设备编码唯一性
50
+        long count = this.count(new LambdaQueryWrapper<DeviceInfo>()
51
+                .eq(DeviceInfo::getDeviceCode, device.getDeviceCode()));
52
+        if (count > 0) {
53
+            throw new BusinessException("设备编码已存在");
54
+        }
55
+        this.save(device);
56
+        return device;
57
+    }
58
+
59
+    /**
60
+     * 更新设备
61
+     */
62
+    public void updateDevice(Long id, DeviceInfo device) {
63
+        DeviceInfo existing = this.getById(id);
64
+        if (existing == null) {
65
+            throw new BusinessException("设备不存在");
66
+        }
67
+        device.setId(id);
68
+        this.updateById(device);
69
+    }
70
+
71
+    /**
72
+     * 更新设备状态
73
+     */
74
+    public void updateDeviceStatus(Long id, Integer deviceStatus) {
75
+        DeviceInfo device = this.getById(id);
76
+        if (device == null) {
77
+            throw new BusinessException("设备不存在");
78
+        }
79
+        device.setDeviceStatus(deviceStatus);
80
+        this.updateById(device);
81
+    }
82
+
83
+    /**
84
+     * 按分类查询设备
85
+     */
86
+    public List<DeviceInfo> getDevicesByCategory(Integer category) {
87
+        return baseMapper.selectByCategory(category);
88
+    }
89
+
90
+    /**
91
+     * 按状态查询设备
92
+     */
93
+    public List<DeviceInfo> getDevicesByStatus(Integer status) {
94
+        return baseMapper.selectByDeviceStatus(status);
95
+    }
96
+
97
+    /**
98
+     * 添加维保记录
99
+     */
100
+    @Transactional
101
+    public DeviceMaintenance addMaintenance(DeviceMaintenance maintenance) {
102
+        DeviceInfo device = this.getById(maintenance.getDeviceId());
103
+        if (device == null) {
104
+            throw new BusinessException("设备不存在");
105
+        }
106
+        maintenanceMapper.insert(maintenance);
107
+        // 如果维保完成,更新设备最后维护时间
108
+        if (maintenance.getResult() != null && maintenance.getResult() == 1) {
109
+            device.setLastMaintenanceTime(LocalDateTime.now());
110
+            if (device.getDeviceStatus() == 3) {
111
+                device.setDeviceStatus(1); // 维修中 -> 在线
112
+            }
113
+            this.updateById(device);
114
+        }
115
+        return maintenance;
116
+    }
117
+
118
+    /**
119
+     * 查询设备维保历史
120
+     */
121
+    public Page<DeviceMaintenance> pageMaintenances(Long deviceId, int page, int size) {
122
+        LambdaQueryWrapper<DeviceMaintenance> qw = new LambdaQueryWrapper<>();
123
+        if (deviceId != null) {
124
+            qw.eq(DeviceMaintenance::getDeviceId, deviceId);
125
+        }
126
+        qw.orderByDesc(DeviceMaintenance::getStartTime);
127
+        return maintenanceMapper.selectPage(new Page<>(page, size), qw);
128
+    }
129
+
130
+    /**
131
+     * 更新维保记录
132
+     */
133
+    public void updateMaintenance(Long id, DeviceMaintenance maintenance) {
134
+        maintenance.setId(id);
135
+        maintenanceMapper.updateById(maintenance);
136
+    }
137
+
138
+    /**
139
+     * 获取设备统计(按分类)
140
+     */
141
+    public List<Long> getDeviceCountByCategory() {
142
+        // 简化版:返回各分类的设备数量
143
+        LambdaQueryWrapper<DeviceInfo> qw = new LambdaQueryWrapper<>();
144
+        qw.select(DeviceInfo::getCategory);
145
+        qw.groupBy(DeviceInfo::getCategory);
146
+        return this.listMaps(qw).stream()
147
+                .map(m -> ((Number) m.get("category")).longValue())
148
+                .toList();
149
+    }
150
+}

+ 144
- 0
wm-config/src/main/java/com/water/config/service/ThresholdService.java Прегледај датотеку

@@ -0,0 +1,144 @@
1
+package com.water.config.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
6
+import com.water.config.entity.ThresholdChangeLog;
7
+import com.water.config.entity.ThresholdConfig;
8
+import com.water.config.mapper.ThresholdChangeLogMapper;
9
+import com.water.config.mapper.ThresholdConfigMapper;
10
+import com.water.common.core.exception.BusinessException;
11
+import lombok.RequiredArgsConstructor;
12
+import org.springframework.stereotype.Service;
13
+import org.springframework.transaction.annotation.Transactional;
14
+
15
+import java.util.List;
16
+
17
+/**
18
+ * 阈值管理服务
19
+ */
20
+@Service
21
+@RequiredArgsConstructor
22
+public class ThresholdService extends ServiceImpl<ThresholdConfigMapper, ThresholdConfig> {
23
+
24
+    private final ThresholdChangeLogMapper changeLogMapper;
25
+
26
+    /**
27
+     * 分页查询阈值配置
28
+     */
29
+    public Page<ThresholdConfig> pageThresholds(int page, int size, String metricCode, Integer level) {
30
+        LambdaQueryWrapper<ThresholdConfig> qw = new LambdaQueryWrapper<>();
31
+        if (metricCode != null && !metricCode.isEmpty()) {
32
+            qw.like(ThresholdConfig::getMetricCode, metricCode);
33
+        }
34
+        if (level != null) {
35
+            qw.eq(ThresholdConfig::getLevel, level);
36
+        }
37
+        qw.orderByAsc(ThresholdConfig::getMetricCode, ThresholdConfig::getLevel);
38
+        return this.page(new Page<>(page, size), qw);
39
+    }
40
+
41
+    /**
42
+     * 创建阈值配置
43
+     */
44
+    @Transactional
45
+    public ThresholdConfig createThreshold(ThresholdConfig config) {
46
+        validateThreshold(config);
47
+        this.save(config);
48
+        recordChangeLog(config, null, "新建阈值配置");
49
+        return config;
50
+    }
51
+
52
+    /**
53
+     * 更新阈值配置(记录变更)
54
+     */
55
+    @Transactional
56
+    public void updateThreshold(Long id, ThresholdConfig config) {
57
+        ThresholdConfig old = this.getById(id);
58
+        if (old == null) {
59
+            throw new BusinessException("阈值配置不存在");
60
+        }
61
+        config.setId(id);
62
+        validateThreshold(config);
63
+        this.updateById(config);
64
+        recordChangeLog(config, old, "更新阈值配置");
65
+    }
66
+
67
+    /**
68
+     * 删除阈值配置
69
+     */
70
+    @Transactional
71
+    public void deleteThreshold(Long id) {
72
+        ThresholdConfig old = this.getById(id);
73
+        if (old == null) {
74
+            throw new BusinessException("阈值配置不存在");
75
+        }
76
+        this.removeById(id);
77
+        recordChangeLog(old, old, "删除阈值配置");
78
+    }
79
+
80
+    /**
81
+     * 获取某指标的全局阈值(多级)
82
+     */
83
+    public List<ThresholdConfig> getGlobalThresholds(String metricCode) {
84
+        return baseMapper.selectGlobalByMetricCode(metricCode);
85
+    }
86
+
87
+    /**
88
+     * 获取某设备的阈值配置
89
+     */
90
+    public List<ThresholdConfig> getDeviceThresholds(Long deviceId) {
91
+        return baseMapper.selectByDeviceId(deviceId);
92
+    }
93
+
94
+    /**
95
+     * 获取阈值变更历史
96
+     */
97
+    public Page<ThresholdChangeLog> getChangeHistory(Long thresholdId, int page, int size) {
98
+        LambdaQueryWrapper<ThresholdChangeLog> qw = new LambdaQueryWrapper<>();
99
+        if (thresholdId != null) {
100
+            qw.eq(ThresholdChangeLog::getThresholdId, thresholdId);
101
+        }
102
+        qw.orderByDesc(ThresholdChangeLog::getCreatedAt);
103
+        return changeLogMapper.selectPage(new Page<>(page, size), qw);
104
+    }
105
+
106
+    /**
107
+     * 启用/禁用阈值
108
+     */
109
+    public void toggleStatus(Long id, Integer status) {
110
+        ThresholdConfig config = this.getById(id);
111
+        if (config == null) {
112
+            throw new BusinessException("阈值配置不存在");
113
+        }
114
+        config.setStatus(status);
115
+        this.updateById(config);
116
+    }
117
+
118
+    private void validateThreshold(ThresholdConfig config) {
119
+        if (config.getMinValue() != null && config.getMaxValue() != null) {
120
+            if (config.getMinValue().compareTo(config.getMaxValue()) > 0) {
121
+                throw new BusinessException("最小值不能大于最大值");
122
+            }
123
+        }
124
+        if (config.getLevel() != null && (config.getLevel() < 1 || config.getLevel() > 3)) {
125
+            throw new BusinessException("阈值级别必须在1-3之间");
126
+        }
127
+    }
128
+
129
+    private void recordChangeLog(ThresholdConfig newConfig, ThresholdConfig oldConfig, String reason) {
130
+        ThresholdChangeLog log = new ThresholdChangeLog();
131
+        log.setThresholdId(newConfig.getId());
132
+        if (oldConfig != null) {
133
+            log.setOldMinValue(oldConfig.getMinValue());
134
+            log.setOldMaxValue(oldConfig.getMaxValue());
135
+            log.setOldLevel(oldConfig.getLevel());
136
+        }
137
+        log.setNewMinValue(newConfig.getMinValue());
138
+        log.setNewMaxValue(newConfig.getMaxValue());
139
+        log.setNewLevel(newConfig.getLevel());
140
+        log.setReason(reason);
141
+        log.setOperator("system");
142
+        changeLogMapper.insert(log);
143
+    }
144
+}

+ 30
- 0
wm-config/src/main/resources/application.yml Прегледај датотеку

@@ -0,0 +1,30 @@
1
+server:
2
+  port: 8090
3
+
4
+spring:
5
+  application:
6
+    name: wm-config
7
+  datasource:
8
+    driver-class-name: org.postgresql.Driver
9
+    url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:water}?currentSchema=public
10
+    username: ${DB_USER:postgres}
11
+    password: ${DB_PASS:postgres}
12
+  cloud:
13
+    nacos:
14
+      discovery:
15
+        server-addr: ${NACOS_ADDR:localhost:8848}
16
+
17
+mybatis-plus:
18
+  configuration:
19
+    map-underscore-to-camel-case: true
20
+    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
21
+  global-config:
22
+    db-config:
23
+      logic-delete-field: deleted
24
+      logic-delete-value: 1
25
+      logic-not-delete-value: 0
26
+
27
+sa-token:
28
+  token-name: Authorization
29
+  timeout: 86400
30
+  active-timeout: 1800

+ 102
- 0
wm-config/src/main/resources/db/schema.sql Прегледај датотеку

@@ -0,0 +1,102 @@
1
+-- ============================================================
2
+-- wm-config DDL: 阈值管理 + 信息发布 + 设备管理
3
+-- ============================================================
4
+
5
+-- 阈值配置表
6
+CREATE TABLE IF NOT EXISTS config_threshold (
7
+    id              BIGSERIAL PRIMARY KEY,
8
+    metric_code     VARCHAR(64)     NOT NULL,
9
+    metric_name     VARCHAR(128)    NOT NULL,
10
+    device_id       BIGINT,
11
+    level           SMALLINT        NOT NULL DEFAULT 1,  -- 1-预警 2-报警 3-紧急
12
+    min_value       NUMERIC(12,4),
13
+    max_value       NUMERIC(12,4),
14
+    unit            VARCHAR(32),
15
+    status          SMALLINT        NOT NULL DEFAULT 1,  -- 0-禁用 1-启用
16
+    remark          VARCHAR(500),
17
+    deleted         SMALLINT        NOT NULL DEFAULT 0,
18
+    created_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
19
+    updated_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
20
+);
21
+COMMENT ON TABLE config_threshold IS '阈值配置表';
22
+CREATE INDEX idx_threshold_metric ON config_threshold(metric_code);
23
+CREATE INDEX idx_threshold_device ON config_threshold(device_id);
24
+
25
+-- 阈值变更记录表
26
+CREATE TABLE IF NOT EXISTS config_threshold_change_log (
27
+    id              BIGSERIAL PRIMARY KEY,
28
+    threshold_id    BIGINT          NOT NULL,
29
+    old_min_value   NUMERIC(12,4),
30
+    old_max_value   NUMERIC(12,4),
31
+    new_min_value   NUMERIC(12,4),
32
+    new_max_value   NUMERIC(12,4),
33
+    old_level       SMALLINT,
34
+    new_level       SMALLINT,
35
+    operator        VARCHAR(64),
36
+    reason          VARCHAR(500),
37
+    created_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
38
+);
39
+COMMENT ON TABLE config_threshold_change_log IS '阈值变更记录表';
40
+CREATE INDEX idx_change_log_threshold ON config_threshold_change_log(threshold_id);
41
+
42
+-- 公告通知表
43
+CREATE TABLE IF NOT EXISTS config_announcement (
44
+    id              BIGSERIAL PRIMARY KEY,
45
+    title           VARCHAR(256)    NOT NULL,
46
+    content         TEXT,
47
+    type            SMALLINT        NOT NULL DEFAULT 1,  -- 1-系统公告 2-维护通知 3-紧急通知
48
+    publish_status  SMALLINT        NOT NULL DEFAULT 0,  -- 0-草稿 1-已发布 2-已撤回
49
+    channels        VARCHAR(256),    -- JSON: ["sms","push","site"]
50
+    publisher       VARCHAR(64),
51
+    publish_time    TIMESTAMP,
52
+    withdraw_time   TIMESTAMP,
53
+    deleted         SMALLINT        NOT NULL DEFAULT 0,
54
+    created_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
55
+    updated_at      TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
56
+);
57
+COMMENT ON TABLE config_announcement IS '公告通知表';
58
+CREATE INDEX idx_announcement_status ON config_announcement(publish_status);
59
+
60
+-- 设备台账表
61
+CREATE TABLE IF NOT EXISTS config_device_info (
62
+    id                      BIGSERIAL PRIMARY KEY,
63
+    device_code             VARCHAR(64)     NOT NULL UNIQUE,
64
+    device_name             VARCHAR(128)    NOT NULL,
65
+    category                SMALLINT        NOT NULL DEFAULT 9, -- 1-水表 2-压力传感器 3-流量计 4-水质监测仪 5-阀门 9-其他
66
+    brand                   VARCHAR(64),
67
+    model                   VARCHAR(64),
68
+    location                VARCHAR(256),
69
+    longitude               DOUBLE PRECISION,
70
+    latitude                DOUBLE PRECISION,
71
+    device_status           SMALLINT        NOT NULL DEFAULT 0, -- 0-离线 1-在线 2-故障 3-维修中
72
+    install_date            TIMESTAMP,
73
+    last_maintenance_time   TIMESTAMP,
74
+    responsible_person      VARCHAR(64),
75
+    remark                  VARCHAR(500),
76
+    deleted                 SMALLINT        NOT NULL DEFAULT 0,
77
+    created_at              TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
78
+    updated_at              TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
79
+);
80
+COMMENT ON TABLE config_device_info IS '设备台账表';
81
+CREATE INDEX idx_device_code ON config_device_info(device_code);
82
+CREATE INDEX idx_device_category ON config_device_info(category);
83
+CREATE INDEX idx_device_status ON config_device_info(device_status);
84
+
85
+-- 设备维保记录表
86
+CREATE TABLE IF NOT EXISTS config_device_maintenance (
87
+    id                  BIGSERIAL PRIMARY KEY,
88
+    device_id           BIGINT          NOT NULL,
89
+    maintenance_type    SMALLINT        NOT NULL DEFAULT 1, -- 1-日常巡检 2-定期保养 3-故障维修 4-更换配件
90
+    description         TEXT,
91
+    operator            VARCHAR(64),
92
+    start_time          TIMESTAMP,
93
+    end_time            TIMESTAMP,
94
+    result              SMALLINT        NOT NULL DEFAULT 0, -- 0-未完成 1-已完成 2-需要返修
95
+    cost                DOUBLE PRECISION,
96
+    attachments         TEXT,           -- JSON
97
+    deleted             SMALLINT        NOT NULL DEFAULT 0,
98
+    created_at          TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
99
+    updated_at          TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP
100
+);
101
+COMMENT ON TABLE config_device_maintenance IS '设备维保记录表';
102
+CREATE INDEX idx_maintenance_device ON config_device_maintenance(device_id);

+ 100
- 0
wm-config/src/test/java/com/water/config/AnnouncementServiceTest.java Прегледај датотеку

@@ -0,0 +1,100 @@
1
+package com.water.config;
2
+
3
+import com.water.common.core.exception.BusinessException;
4
+import com.water.config.entity.Announcement;
5
+import com.water.config.mapper.AnnouncementMapper;
6
+import com.water.config.service.AnnouncementService;
7
+import org.junit.jupiter.api.BeforeEach;
8
+import org.junit.jupiter.api.Test;
9
+import org.junit.jupiter.api.extension.ExtendWith;
10
+import org.mockito.InjectMocks;
11
+import org.mockito.Mock;
12
+import org.mockito.junit.jupiter.MockitoExtension;
13
+
14
+import static org.junit.jupiter.api.Assertions.*;
15
+import static org.mockito.ArgumentMatchers.any;
16
+import static org.mockito.Mockito.*;
17
+
18
+@ExtendWith(MockitoExtension.class)
19
+class AnnouncementServiceTest {
20
+
21
+    @Mock
22
+    private AnnouncementMapper announcementMapper;
23
+    @InjectMocks
24
+    private AnnouncementService announcementService;
25
+
26
+    private Announcement draft;
27
+
28
+    @BeforeEach
29
+    void setUp() {
30
+        draft = new Announcement();
31
+        draft.setTitle("系统维护通知");
32
+        draft.setContent("今晚22:00-次日06:00系统维护");
33
+        draft.setType(2);
34
+        draft.setChannels("[\"site\",\"sms\"]");
35
+    }
36
+
37
+    @Test
38
+    void createAnnouncement_setsDraftStatus() {
39
+        when(announcementMapper.insert(any())).thenReturn(1);
40
+
41
+        Announcement result = announcementService.createAnnouncement(draft);
42
+
43
+        assertEquals(0, result.getPublishStatus());
44
+        verify(announcementMapper).insert(any(Announcement.class));
45
+    }
46
+
47
+    @Test
48
+    void publish_draft_success() {
49
+        Announcement existing = new Announcement();
50
+        existing.setId(1L);
51
+        existing.setPublishStatus(0);
52
+        existing.setChannels("[\"site\"]");
53
+
54
+        when(announcementMapper.selectById(1L)).thenReturn(existing);
55
+        when(announcementMapper.updateById(any())).thenReturn(1);
56
+
57
+        assertDoesNotThrow(() -> announcementService.publish(1L));
58
+        verify(announcementMapper).updateById(argThat(a ->
59
+                a.getPublishStatus() == 1 && a.getPublishTime() != null));
60
+    }
61
+
62
+    @Test
63
+    void publish_alreadyPublished_throws() {
64
+        Announcement existing = new Announcement();
65
+        existing.setId(1L);
66
+        existing.setPublishStatus(1);
67
+
68
+        when(announcementMapper.selectById(1L)).thenReturn(existing);
69
+
70
+        BusinessException ex = assertThrows(BusinessException.class,
71
+                () -> announcementService.publish(1L));
72
+        assertEquals("只有草稿状态的公告可以发布", ex.getMessage());
73
+    }
74
+
75
+    @Test
76
+    void withdraw_notPublished_throws() {
77
+        Announcement existing = new Announcement();
78
+        existing.setId(1L);
79
+        existing.setPublishStatus(0);
80
+
81
+        when(announcementMapper.selectById(1L)).thenReturn(existing);
82
+
83
+        BusinessException ex = assertThrows(BusinessException.class,
84
+                () -> announcementService.withdraw(1L));
85
+        assertEquals("只有已发布的公告可以撤回", ex.getMessage());
86
+    }
87
+
88
+    @Test
89
+    void delete_published_throws() {
90
+        Announcement existing = new Announcement();
91
+        existing.setId(1L);
92
+        existing.setPublishStatus(1);
93
+
94
+        when(announcementMapper.selectById(1L)).thenReturn(existing);
95
+
96
+        BusinessException ex = assertThrows(BusinessException.class,
97
+                () -> announcementService.deleteAnnouncement(1L));
98
+        assertEquals("已发布的公告不可删除,请先撤回", ex.getMessage());
99
+    }
100
+}

+ 107
- 0
wm-config/src/test/java/com/water/config/DeviceManageServiceTest.java Прегледај датотеку

@@ -0,0 +1,107 @@
1
+package com.water.config;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.common.core.exception.BusinessException;
5
+import com.water.config.entity.DeviceInfo;
6
+import com.water.config.entity.DeviceMaintenance;
7
+import com.water.config.mapper.DeviceInfoMapper;
8
+import com.water.config.mapper.DeviceMaintenanceMapper;
9
+import com.water.config.service.DeviceManageService;
10
+import org.junit.jupiter.api.BeforeEach;
11
+import org.junit.jupiter.api.Test;
12
+import org.junit.jupiter.api.extension.ExtendWith;
13
+import org.mockito.InjectMocks;
14
+import org.mockito.Mock;
15
+import org.mockito.junit.jupiter.MockitoExtension;
16
+
17
+import static org.junit.jupiter.api.Assertions.*;
18
+import static org.mockito.ArgumentMatchers.any;
19
+import static org.mockito.Mockito.*;
20
+
21
+@ExtendWith(MockitoExtension.class)
22
+class DeviceManageServiceTest {
23
+
24
+    @Mock
25
+    private DeviceInfoMapper deviceInfoMapper;
26
+    @Mock
27
+    private DeviceMaintenanceMapper maintenanceMapper;
28
+    @InjectMocks
29
+    private DeviceManageService deviceManageService;
30
+
31
+    private DeviceInfo device;
32
+
33
+    @BeforeEach
34
+    void setUp() {
35
+        device = new DeviceInfo();
36
+        device.setDeviceCode("WM-001");
37
+        device.setDeviceName("1号水表");
38
+        device.setCategory(1);
39
+        device.setBrand("海天");
40
+        device.setModel("HT-200");
41
+        device.setDeviceStatus(0);
42
+    }
43
+
44
+    @Test
45
+    void createDevice_success() {
46
+        when(deviceInfoMapper.selectCount(any())).thenReturn(0L);
47
+        when(deviceInfoMapper.insert(any())).thenReturn(1);
48
+
49
+        DeviceInfo result = deviceManageService.createDevice(device);
50
+
51
+        assertNotNull(result);
52
+        assertEquals("WM-001", result.getDeviceCode());
53
+        verify(deviceInfoMapper).insert(any(DeviceInfo.class));
54
+    }
55
+
56
+    @Test
57
+    void createDevice_duplicateCode_throws() {
58
+        when(deviceInfoMapper.selectCount(any())).thenReturn(1L);
59
+
60
+        BusinessException ex = assertThrows(BusinessException.class,
61
+                () -> deviceManageService.createDevice(device));
62
+        assertEquals("设备编码已存在", ex.getMessage());
63
+    }
64
+
65
+    @Test
66
+    void updateDevice_notFound_throws() {
67
+        when(deviceInfoMapper.selectById(999L)).thenReturn(null);
68
+
69
+        BusinessException ex = assertThrows(BusinessException.class,
70
+                () -> deviceManageService.updateDevice(999L, device));
71
+        assertEquals("设备不存在", ex.getMessage());
72
+    }
73
+
74
+    @Test
75
+    void addMaintenance_deviceNotFound_throws() {
76
+        DeviceMaintenance maintenance = new DeviceMaintenance();
77
+        maintenance.setDeviceId(999L);
78
+
79
+        when(deviceInfoMapper.selectById(999L)).thenReturn(null);
80
+
81
+        BusinessException ex = assertThrows(BusinessException.class,
82
+                () -> deviceManageService.addMaintenance(maintenance));
83
+        assertEquals("设备不存在", ex.getMessage());
84
+    }
85
+
86
+    @Test
87
+    void addMaintenance_completed_updatesDeviceTime() {
88
+        DeviceInfo existingDevice = new DeviceInfo();
89
+        existingDevice.setId(1L);
90
+        existingDevice.setDeviceStatus(3); // 维修中
91
+
92
+        DeviceMaintenance maintenance = new DeviceMaintenance();
93
+        maintenance.setDeviceId(1L);
94
+        maintenance.setResult(1); // 已完成
95
+        maintenance.setDescription("更换电池");
96
+
97
+        when(deviceInfoMapper.selectById(1L)).thenReturn(existingDevice);
98
+        when(maintenanceMapper.insert(any())).thenReturn(1);
99
+        when(deviceInfoMapper.updateById(any())).thenReturn(1);
100
+
101
+        DeviceMaintenance result = deviceManageService.addMaintenance(maintenance);
102
+
103
+        assertNotNull(result);
104
+        verify(deviceInfoMapper).updateById(argThat(d ->
105
+                d.getLastMaintenanceTime() != null && d.getDeviceStatus() == 1));
106
+    }
107
+}

+ 96
- 0
wm-config/src/test/java/com/water/config/ThresholdServiceTest.java Прегледај датотеку

@@ -0,0 +1,96 @@
1
+package com.water.config;
2
+
3
+import com.water.common.core.exception.BusinessException;
4
+import com.water.config.entity.ThresholdChangeLog;
5
+import com.water.config.entity.ThresholdConfig;
6
+import com.water.config.mapper.ThresholdChangeLogMapper;
7
+import com.water.config.mapper.ThresholdConfigMapper;
8
+import com.water.config.service.ThresholdService;
9
+import org.junit.jupiter.api.BeforeEach;
10
+import org.junit.jupiter.api.Test;
11
+import org.junit.jupiter.api.extension.ExtendWith;
12
+import org.mockito.InjectMocks;
13
+import org.mockito.Mock;
14
+import org.mockito.junit.jupiter.MockitoExtension;
15
+
16
+import java.math.BigDecimal;
17
+
18
+import static org.junit.jupiter.api.Assertions.*;
19
+import static org.mockito.ArgumentMatchers.any;
20
+import static org.mockito.Mockito.*;
21
+
22
+@ExtendWith(MockitoExtension.class)
23
+class ThresholdServiceTest {
24
+
25
+    @Mock
26
+    private ThresholdConfigMapper thresholdConfigMapper;
27
+    @Mock
28
+    private ThresholdChangeLogMapper changeLogMapper;
29
+    @InjectMocks
30
+    private ThresholdService thresholdService;
31
+
32
+    private ThresholdConfig validConfig;
33
+
34
+    @BeforeEach
35
+    void setUp() {
36
+        validConfig = new ThresholdConfig();
37
+        validConfig.setMetricCode("water_pressure");
38
+        validConfig.setMetricName("水压");
39
+        validConfig.setLevel(1);
40
+        validConfig.setMinValue(new BigDecimal("0.1"));
41
+        validConfig.setMaxValue(new BigDecimal("0.8"));
42
+        validConfig.setUnit("MPa");
43
+        validConfig.setStatus(1);
44
+    }
45
+
46
+    @Test
47
+    void createThreshold_success() {
48
+        when(thresholdConfigMapper.insert(any())).thenReturn(1);
49
+        when(changeLogMapper.insert(any())).thenReturn(1);
50
+
51
+        ThresholdConfig result = thresholdService.createThreshold(validConfig);
52
+
53
+        assertNotNull(result);
54
+        assertEquals("water_pressure", result.getMetricCode());
55
+        assertEquals(1, result.getLevel());
56
+        verify(thresholdConfigMapper).insert(any(ThresholdConfig.class));
57
+        verify(changeLogMapper).insert(any(ThresholdChangeLog.class));
58
+    }
59
+
60
+    @Test
61
+    void createThreshold_minGreaterThanMax_throws() {
62
+        validConfig.setMinValue(new BigDecimal("1.0"));
63
+        validConfig.setMaxValue(new BigDecimal("0.5"));
64
+
65
+        BusinessException ex = assertThrows(BusinessException.class,
66
+                () -> thresholdService.createThreshold(validConfig));
67
+        assertEquals("最小值不能大于最大值", ex.getMessage());
68
+    }
69
+
70
+    @Test
71
+    void createThreshold_invalidLevel_throws() {
72
+        validConfig.setLevel(5);
73
+
74
+        BusinessException ex = assertThrows(BusinessException.class,
75
+                () -> thresholdService.createThreshold(validConfig));
76
+        assertEquals("阈值级别必须在1-3之间", ex.getMessage());
77
+    }
78
+
79
+    @Test
80
+    void updateThreshold_notFound_throws() {
81
+        when(thresholdConfigMapper.selectById(999L)).thenReturn(null);
82
+
83
+        BusinessException ex = assertThrows(BusinessException.class,
84
+                () -> thresholdService.updateThreshold(999L, validConfig));
85
+        assertEquals("阈值配置不存在", ex.getMessage());
86
+    }
87
+
88
+    @Test
89
+    void deleteThreshold_notFound_throws() {
90
+        when(thresholdConfigMapper.selectById(999L)).thenReturn(null);
91
+
92
+        BusinessException ex = assertThrows(BusinessException.class,
93
+                () -> thresholdService.deleteThreshold(999L));
94
+        assertEquals("阈值配置不存在", ex.getMessage());
95
+    }
96
+}

+ 114
- 9
wm-data-engine/pom.xml Прегледај датотеку

@@ -3,15 +3,120 @@
3 3
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4 4
          xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
5 5
     <modelVersion>4.0.0</modelVersion>
6
-    <parent><groupId>com.water</groupId><artifactId>wm-parent</artifactId><version>1.0.0-SNAPSHOT</version></parent>
6
+    <parent>
7
+        <groupId>com.water</groupId>
8
+        <artifactId>wm-parent</artifactId>
9
+        <version>1.0.0-SNAPSHOT</version>
10
+    </parent>
7 11
     <artifactId>wm-data-engine</artifactId>
12
+    <name>wm-data-engine</name>
13
+    <description>数据汇聚引擎模块</description>
14
+
8 15
     <dependencies>
9
-        <dependency><groupId>com.water</groupId><artifactId>wm-common</artifactId></dependency>
10
-        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
11
-        <dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
12
-        <dependency><groupId>org.springframework.kafka</groupId><artifactId>spring-kafka</artifactId></dependency>
13
-        <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
14
-        <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
15
-        <dependency><groupId>net.postgis</groupId><artifactId>postgis-jdbc</artifactId></dependency>
16
+        <!-- 公共模块 -->
17
+        <dependency>
18
+            <groupId>com.water</groupId>
19
+            <artifactId>wm-common</artifactId>
20
+        </dependency>
21
+
22
+        <!-- Web -->
23
+        <dependency>
24
+            <groupId>org.springframework.boot</groupId>
25
+            <artifactId>spring-boot-starter-web</artifactId>
26
+        </dependency>
27
+
28
+        <!-- WebSocket -->
29
+        <dependency>
30
+            <groupId>org.springframework.boot</groupId>
31
+            <artifactId>spring-boot-starter-websocket</artifactId>
32
+        </dependency>
33
+
34
+        <!-- Nacos 服务发现 -->
35
+        <dependency>
36
+            <groupId>com.alibaba.cloud</groupId>
37
+            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
38
+        </dependency>
39
+
40
+        <!-- Kafka -->
41
+        <dependency>
42
+            <groupId>org.springframework.kafka</groupId>
43
+            <artifactId>spring-kafka</artifactId>
44
+        </dependency>
45
+
46
+        <!-- Redis -->
47
+        <dependency>
48
+            <groupId>org.springframework.boot</groupId>
49
+            <artifactId>spring-boot-starter-data-redis</artifactId>
50
+        </dependency>
51
+
52
+        <!-- PostgreSQL -->
53
+        <dependency>
54
+            <groupId>org.postgresql</groupId>
55
+            <artifactId>postgresql</artifactId>
56
+        </dependency>
57
+
58
+        <!-- PostGIS -->
59
+        <dependency>
60
+            <groupId>net.postgis</groupId>
61
+            <artifactId>postgis-jdbc</artifactId>
62
+        </dependency>
63
+
64
+        <!-- MyBatis-Plus -->
65
+        <dependency>
66
+            <groupId>com.baomidou</groupId>
67
+            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
68
+        </dependency>
69
+
70
+        <!-- MinIO -->
71
+        <dependency>
72
+            <groupId>io.minio</groupId>
73
+            <artifactId>minio</artifactId>
74
+        </dependency>
75
+
76
+        <!-- Hutool -->
77
+        <dependency>
78
+            <groupId>cn.hutool</groupId>
79
+            <artifactId>hutool-all</artifactId>
80
+        </dependency>
81
+
82
+        <!-- Knife4j OpenAPI3 -->
83
+        <dependency>
84
+            <groupId>com.github.xiaoymin</groupId>
85
+            <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
86
+        </dependency>
87
+
88
+        <!-- EasyExcel -->
89
+        <dependency>
90
+            <groupId>com.alibaba</groupId>
91
+            <artifactId>easyexcel</artifactId>
92
+        </dependency>
93
+
94
+        <!-- Test -->
95
+        <dependency>
96
+            <groupId>org.springframework.boot</groupId>
97
+            <artifactId>spring-boot-starter-test</artifactId>
98
+            <scope>test</scope>
99
+        </dependency>
100
+
101
+        <dependency>
102
+            <groupId>org.springframework.kafka</groupId>
103
+            <artifactId>spring-kafka-test</artifactId>
104
+            <scope>test</scope>
105
+        </dependency>
106
+
107
+        <dependency>
108
+            <groupId>com.h2database</groupId>
109
+            <artifactId>h2</artifactId>
110
+            <scope>test</scope>
111
+        </dependency>
16 112
     </dependencies>
17
-</project>
113
+
114
+    <build>
115
+        <plugins>
116
+            <plugin>
117
+                <groupId>org.springframework.boot</groupId>
118
+                <artifactId>spring-boot-maven-plugin</artifactId>
119
+            </plugin>
120
+        </plugins>
121
+    </build>
122
+</project>

+ 62
- 0
wm-data-engine/src/main/java/com/water/data_engine/config/KafkaConfig.java Прегледај датотеку

@@ -0,0 +1,62 @@
1
+package com.water.data_engine.config;
2
+
3
+import org.apache.kafka.clients.consumer.ConsumerConfig;
4
+import org.apache.kafka.clients.producer.ProducerConfig;
5
+import org.apache.kafka.common.serialization.StringDeserializer;
6
+import org.apache.kafka.common.serialization.StringSerializer;
7
+import org.springframework.beans.factory.annotation.Value;
8
+import org.springframework.context.annotation.Bean;
9
+import org.springframework.context.annotation.Configuration;
10
+import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
11
+import org.springframework.kafka.core.*;
12
+
13
+import java.util.HashMap;
14
+import java.util.Map;
15
+
16
+/**
17
+ * Kafka 配置
18
+ * 用于实时数据流采集和传输
19
+ */
20
+@Configuration
21
+public class KafkaConfig {
22
+
23
+    @Value("${spring.kafka.bootstrap-servers:${KAFKA_SERVERS:127.0.0.1}:9092}")
24
+    private String bootstrapServers;
25
+
26
+    @Bean
27
+    public ProducerFactory<String, String> producerFactory() {
28
+        Map<String, Object> props = new HashMap<>();
29
+        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
30
+        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
31
+        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
32
+        props.put(ProducerConfig.ACKS_CONFIG, "1");
33
+        props.put(ProducerConfig.RETRIES_CONFIG, 3);
34
+        return new DefaultKafkaProducerFactory<>(props);
35
+    }
36
+
37
+    @Bean
38
+    public KafkaTemplate<String, String> kafkaTemplate(ProducerFactory<String, String> producerFactory) {
39
+        return new KafkaTemplate<>(producerFactory);
40
+    }
41
+
42
+    @Bean
43
+    public ConsumerFactory<String, String> consumerFactory() {
44
+        Map<String, Object> props = new HashMap<>();
45
+        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
46
+        props.put(ConsumerConfig.GROUP_ID_CONFIG, "wm-data-engine");
47
+        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
48
+        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
49
+        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "latest");
50
+        return new DefaultKafkaConsumerFactory<>(props);
51
+    }
52
+
53
+    @Bean
54
+    public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory(
55
+            ConsumerFactory<String, String> consumerFactory) {
56
+        ConcurrentKafkaListenerContainerFactory<String, String> factory =
57
+                new ConcurrentKafkaListenerContainerFactory<>();
58
+        factory.setConsumerFactory(consumerFactory);
59
+        factory.setConcurrency(3);
60
+        return factory;
61
+    }
62
+}

+ 49
- 0
wm-data-engine/src/main/java/com/water/data_engine/config/MyBatisPlusConfig.java Прегледај датотеку

@@ -0,0 +1,49 @@
1
+package com.water.data_engine.config;
2
+
3
+import com.baomidou.mybatisplus.annotation.DbType;
4
+import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
5
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
6
+import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
7
+import org.apache.ibatis.reflection.MetaObject;
8
+import org.mybatis.spring.annotation.MapperScan;
9
+import org.springframework.context.annotation.Bean;
10
+import org.springframework.context.annotation.Configuration;
11
+
12
+import java.time.LocalDateTime;
13
+
14
+/**
15
+ * MyBatis-Plus 配置
16
+ */
17
+@Configuration
18
+@MapperScan("com.water.data_engine.mapper")
19
+public class MyBatisPlusConfig {
20
+
21
+    /**
22
+     * 分页插件
23
+     */
24
+    @Bean
25
+    public MybatisPlusInterceptor mybatisPlusInterceptor() {
26
+        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
27
+        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
28
+        return interceptor;
29
+    }
30
+
31
+    /**
32
+     * 自动填充处理器
33
+     */
34
+    @Bean
35
+    public MetaObjectHandler metaObjectHandler() {
36
+        return new MetaObjectHandler() {
37
+            @Override
38
+            public void insertFill(MetaObject metaObject) {
39
+                this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, LocalDateTime.now());
40
+                this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
41
+            }
42
+
43
+            @Override
44
+            public void updateFill(MetaObject metaObject) {
45
+                this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
46
+            }
47
+        };
48
+    }
49
+}

+ 32
- 0
wm-data-engine/src/main/java/com/water/data_engine/config/WebSocketConfig.java Прегледај датотеку

@@ -0,0 +1,32 @@
1
+package com.water.data_engine.config;
2
+
3
+import org.springframework.context.annotation.Configuration;
4
+import org.springframework.messaging.simp.config.MessageBrokerRegistry;
5
+import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
6
+import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
7
+import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
8
+
9
+/**
10
+ * WebSocket 配置
11
+ * 支持 STOMP 协议,用于实时数据推送
12
+ */
13
+@Configuration
14
+@EnableWebSocketMessageBroker
15
+public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
16
+
17
+    @Override
18
+    public void configureMessageBroker(MessageBrokerRegistry registry) {
19
+        // 客户端订阅前缀: /topic (广播), /queue (点对点)
20
+        registry.enableSimpleBroker("/topic", "/queue");
21
+        // 客户端发送消息前缀
22
+        registry.setApplicationDestinationPrefixes("/app");
23
+    }
24
+
25
+    @Override
26
+    public void registerStompEndpoints(StompEndpointRegistry registry) {
27
+        // WebSocket 连接端点
28
+        registry.addEndpoint("/ws/data-engine")
29
+                .setAllowedOriginPatterns("*")
30
+                .withSockJS();
31
+    }
32
+}

+ 85
- 0
wm-data-engine/src/main/java/com/water/data_engine/controller/DataCollectController.java Прегледај датотеку

@@ -0,0 +1,85 @@
1
+package com.water.data_engine.controller;
2
+
3
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
4
+import com.water.common.core.result.R;
5
+import com.water.data_engine.entity.CollectRecord;
6
+import com.water.data_engine.entity.CollectTask;
7
+import com.water.data_engine.service.DataCollectService;
8
+import io.swagger.v3.oas.annotations.Operation;
9
+import io.swagger.v3.oas.annotations.tags.Tag;
10
+import lombok.RequiredArgsConstructor;
11
+import org.springframework.web.bind.annotation.*;
12
+
13
+import java.util.List;
14
+import java.util.Map;
15
+
16
+/**
17
+ * 数据采集控制器
18
+ * DE-01: 实时流(MQTT/Kafka) + 批量采集
19
+ */
20
+@Tag(name = "数据采集")
21
+@RestController
22
+@RequestMapping("/api/data-engine/collect")
23
+@RequiredArgsConstructor
24
+public class DataCollectController {
25
+
26
+    private final DataCollectService collectService;
27
+
28
+    // ==================== 实时数据采集 ====================
29
+
30
+    @Operation(summary = "实时数据接入")
31
+    @PostMapping("/realtime")
32
+    public R<String> ingestRealtime(@RequestBody Map<String, Object> request) {
33
+        String sourceType = (String) request.get("sourceType");
34
+        String sourceId = (String) request.get("sourceId");
35
+        @SuppressWarnings("unchecked")
36
+        Map<String, Object> data = (Map<String, Object>) request.get("data");
37
+
38
+        String topic = collectService.ingestRealtime(sourceType, sourceId, data);
39
+        return R.ok("数据已接入,topic: " + topic);
40
+    }
41
+
42
+    @Operation(summary = "批量数据接入")
43
+    @PostMapping("/batch")
44
+    public R<String> batchIngest(@RequestBody List<Map<String, Object>> batchData) {
45
+        int count = collectService.batchIngest(batchData);
46
+        return R.ok("批量接入完成,成功: " + count + " 条");
47
+    }
48
+
49
+    // ==================== 采集任务管理 ====================
50
+
51
+    @Operation(summary = "创建批量采集任务")
52
+    @PostMapping("/task")
53
+    public R<CollectTask> createTask(@RequestBody Map<String, Object> request) {
54
+        String taskName = (String) request.get("taskName");
55
+        Long sourceId = Long.valueOf(request.get("sourceId").toString());
56
+        String targetTable = (String) request.get("targetTable");
57
+
58
+        CollectTask task = collectService.createBatchTask(taskName, sourceId, targetTable);
59
+        return R.ok(task);
60
+    }
61
+
62
+    @Operation(summary = "执行采集任务")
63
+    @PostMapping("/task/{taskId}/execute")
64
+    public R<String> executeTask(@PathVariable Long taskId,
65
+                                  @RequestBody List<Map<String, Object>> dataList) {
66
+        collectService.executeTask(taskId, dataList);
67
+        return R.ok("任务执行完成");
68
+    }
69
+
70
+    @Operation(summary = "查询采集任务列表")
71
+    @GetMapping("/task/list")
72
+    public R<Page<CollectTask>> listTasks(@RequestParam(defaultValue = "1") int page,
73
+                                           @RequestParam(defaultValue = "10") int size,
74
+                                           @RequestParam(required = false) String status) {
75
+        return R.ok(collectService.listTasks(page, size, status));
76
+    }
77
+
78
+    @Operation(summary = "查询采集记录")
79
+    @GetMapping("/record/list")
80
+    public R<Page<CollectRecord>> listRecords(@RequestParam(defaultValue = "1") int page,
81
+                                               @RequestParam(defaultValue = "10") int size,
82
+                                               @RequestParam(required = false) Long taskId) {
83
+        return R.ok(collectService.listRecords(page, size, taskId));
84
+    }
85
+}

+ 65
- 0
wm-data-engine/src/main/java/com/water/data_engine/controller/DataController.java Прегледај датотеку

@@ -0,0 +1,65 @@
1
+package com.water.data_engine.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.data_engine.service.DataCollectService;
5
+import com.water.data_engine.service.DataGovernanceService;
6
+import io.swagger.v3.oas.annotations.Operation;
7
+import io.swagger.v3.oas.annotations.tags.Tag;
8
+import lombok.RequiredArgsConstructor;
9
+import org.springframework.web.bind.annotation.*;
10
+
11
+import java.util.*;
12
+
13
+/**
14
+ * 数据引擎综合控制器(兼容旧接口)
15
+ */
16
+@Tag(name = "数据引擎(综合)")
17
+@RestController
18
+@RequestMapping("/api/data-engine")
19
+@RequiredArgsConstructor
20
+public class DataController {
21
+
22
+    private final DataCollectService collectService;
23
+    private final DataGovernanceService governanceService;
24
+
25
+    @Operation(summary = "数据接入(兼容旧接口)")
26
+    @PostMapping("/ingest")
27
+    public R<String> ingest(@RequestBody Map<String, Object> req) {
28
+        String sourceType = (String) req.get("sourceType");
29
+        String sourceId = (String) req.get("sourceId");
30
+        @SuppressWarnings("unchecked")
31
+        Map<String, Object> data = (Map<String, Object>) req.get("data");
32
+        collectService.ingestRealtime(sourceType, sourceId, data);
33
+        return R.ok("数据已接入");
34
+    }
35
+
36
+    @Operation(summary = "批量接入(兼容旧接口)")
37
+    @PostMapping("/ingest/batch")
38
+    public R<String> batchIngest(@RequestBody List<Map<String, Object>> batch) {
39
+        collectService.batchIngest(batch);
40
+        return R.ok("批量接入完成");
41
+    }
42
+
43
+    @Operation(summary = "数据标准化+清洗+质控(管道演示)")
44
+    @PostMapping("/pipeline")
45
+    public R<Map<String, Object>> pipeline(@RequestBody Map<String, Object> raw) {
46
+        Map<String, Object> result = governanceService.pipeline(raw);
47
+        return R.ok(result);
48
+    }
49
+
50
+    @Operation(summary = "引擎状态")
51
+    @GetMapping("/status")
52
+    public R<Map<String, Object>> status() {
53
+        Map<String, Object> status = new LinkedHashMap<>();
54
+        status.put("module", "wm-data-engine");
55
+        status.put("version", "1.0.0");
56
+        status.put("status", "running");
57
+        status.put("features", List.of(
58
+            "DE-01 数据采集(实时流/批量)",
59
+            "DE-02 数据接入(REST/WebSocket/数据库)",
60
+            "DE-03 数据存储(TDengine/PostgreSQL/MinIO)",
61
+            "DE-04 数据集成(多源异构整合)"
62
+        ));
63
+        return R.ok(status);
64
+    }
65
+}

+ 117
- 0
wm-data-engine/src/main/java/com/water/data_engine/controller/DataGovernanceController.java Прегледај датотеку

@@ -0,0 +1,117 @@
1
+package com.water.data_engine.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.data_engine.entity.QualityRule;
5
+import com.water.data_engine.service.DataGovernanceService;
6
+import io.swagger.v3.oas.annotations.Operation;
7
+import io.swagger.v3.oas.annotations.tags.Tag;
8
+import lombok.RequiredArgsConstructor;
9
+import org.springframework.web.bind.annotation.*;
10
+
11
+import java.util.List;
12
+import java.util.Map;
13
+
14
+/**
15
+ * 数据治理控制器
16
+ * 数据标准化、清洗、质量控制
17
+ */
18
+@Tag(name = "数据治理")
19
+@RestController
20
+@RequestMapping("/api/data-engine/governance")
21
+@RequiredArgsConstructor
22
+public class DataGovernanceController {
23
+
24
+    private final DataGovernanceService governanceService;
25
+
26
+    // ==================== 数据标准化 ====================
27
+
28
+    @Operation(summary = "数据标准化")
29
+    @PostMapping("/standardize")
30
+    public R<Map<String, Object>> standardize(@RequestBody Map<String, Object> raw) {
31
+        return R.ok(governanceService.standardize(raw));
32
+    }
33
+
34
+    @Operation(summary = "批量数据标准化")
35
+    @PostMapping("/standardize/batch")
36
+    public R<List<Map<String, Object>>> batchStandardize(@RequestBody List<Map<String, Object>> rawDataList) {
37
+        return R.ok(governanceService.batchStandardize(rawDataList));
38
+    }
39
+
40
+    // ==================== 数据清洗 ====================
41
+
42
+    @Operation(summary = "数据清洗")
43
+    @PostMapping("/clean")
44
+    public R<Map<String, Object>> clean(@RequestBody Map<String, Object> data) {
45
+        return R.ok(governanceService.clean(data));
46
+    }
47
+
48
+    @Operation(summary = "批量数据清洗")
49
+    @PostMapping("/clean/batch")
50
+    public R<List<Map<String, Object>>> batchClean(@RequestBody List<Map<String, Object>> dataList) {
51
+        return R.ok(governanceService.batchClean(dataList));
52
+    }
53
+
54
+    // ==================== 数据质量 ====================
55
+
56
+    @Operation(summary = "数据质量检查")
57
+    @PostMapping("/quality/check")
58
+    public R<Map<String, Object>> qualityCheck(@RequestBody Map<String, Object> data) {
59
+        return R.ok(governanceService.qualityCheck(data));
60
+    }
61
+
62
+    @Operation(summary = "批量数据质量检查")
63
+    @PostMapping("/quality/check/batch")
64
+    public R<List<Map<String, Object>>> batchQualityCheck(@RequestBody List<Map<String, Object>> dataList) {
65
+        return R.ok(governanceService.batchQualityCheck(dataList));
66
+    }
67
+
68
+    @Operation(summary = "执行质量规则检查")
69
+    @PostMapping("/quality/rules/execute")
70
+    public R<Map<String, Object>> executeQualityRules(@RequestBody Map<String, Object> request) {
71
+        String tableName = (String) request.get("tableName");
72
+        return R.ok(governanceService.executeQualityRules(tableName));
73
+    }
74
+
75
+    // ==================== 数据管道 ====================
76
+
77
+    @Operation(summary = "完整数据管道(标准化->清洗->质控)")
78
+    @PostMapping("/pipeline")
79
+    public R<Map<String, Object>> pipeline(@RequestBody Map<String, Object> raw) {
80
+        return R.ok(governanceService.pipeline(raw));
81
+    }
82
+
83
+    @Operation(summary = "批量数据管道")
84
+    @PostMapping("/pipeline/batch")
85
+    public R<List<Map<String, Object>>> batchPipeline(@RequestBody List<Map<String, Object>> rawDataList) {
86
+        return R.ok(governanceService.batchPipeline(rawDataList));
87
+    }
88
+
89
+    // ==================== 质量规则管理 ====================
90
+
91
+    @Operation(summary = "创建质量规则")
92
+    @PostMapping("/quality/rule")
93
+    public R<QualityRule> createQualityRule(@RequestBody QualityRule rule) {
94
+        return R.ok(governanceService.createQualityRule(rule));
95
+    }
96
+
97
+    @Operation(summary = "更新质量规则")
98
+    @PutMapping("/quality/rule/{id}")
99
+    public R<QualityRule> updateQualityRule(@PathVariable Long id, @RequestBody QualityRule rule) {
100
+        return R.ok(governanceService.updateQualityRule(id, rule));
101
+    }
102
+
103
+    @Operation(summary = "删除质量规则")
104
+    @DeleteMapping("/quality/rule/{id}")
105
+    public R<String> deleteQualityRule(@PathVariable Long id) {
106
+        governanceService.deleteQualityRule(id);
107
+        return R.ok("删除成功");
108
+    }
109
+
110
+    @Operation(summary = "查询质量规则列表")
111
+    @GetMapping("/quality/rule/list")
112
+    public R<List<QualityRule>> listQualityRules(
113
+            @RequestParam(required = false) String tableName,
114
+            @RequestParam(required = false) String ruleType) {
115
+        return R.ok(governanceService.listQualityRules(tableName, ruleType));
116
+    }
117
+}

+ 118
- 0
wm-data-engine/src/main/java/com/water/data_engine/controller/DataIngestController.java Прегледај датотеку

@@ -0,0 +1,118 @@
1
+package com.water.data_engine.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.data_engine.entity.DataSource;
5
+import com.water.data_engine.service.DataIngestService;
6
+import io.swagger.v3.oas.annotations.Operation;
7
+import io.swagger.v3.oas.annotations.tags.Tag;
8
+import lombok.RequiredArgsConstructor;
9
+import org.springframework.web.bind.annotation.*;
10
+import org.springframework.web.multipart.MultipartFile;
11
+
12
+import java.util.List;
13
+import java.util.Map;
14
+
15
+/**
16
+ * 数据接入控制器
17
+ * DE-02: RESTful API / WebSocket / 数据库直连
18
+ */
19
+@Tag(name = "数据接入")
20
+@RestController
21
+@RequestMapping("/api/data-engine/ingest")
22
+@RequiredArgsConstructor
23
+public class DataIngestController {
24
+
25
+    private final DataIngestService ingestService;
26
+
27
+    // ==================== API 接入 ====================
28
+
29
+    @Operation(summary = "通过 API 接入单条数据")
30
+    @PostMapping("/api/{sourceCode}")
31
+    public R<String> ingestViaApi(@PathVariable String sourceCode,
32
+                                   @RequestBody Map<String, Object> data) {
33
+        String topic = ingestService.ingestViaApi(sourceCode, data);
34
+        return R.ok("数据已接入,topic: " + topic);
35
+    }
36
+
37
+    @Operation(summary = "通过 API 批量接入数据")
38
+    @PostMapping("/api/{sourceCode}/batch")
39
+    public R<String> batchIngestViaApi(@PathVariable String sourceCode,
40
+                                        @RequestBody List<Map<String, Object>> dataList) {
41
+        int count = ingestService.batchIngestViaApi(sourceCode, dataList);
42
+        return R.ok("批量接入完成,成功: " + count + " 条");
43
+    }
44
+
45
+    // ==================== 数据库接入 ====================
46
+
47
+    @Operation(summary = "从外部数据库拉取数据")
48
+    @PostMapping("/database/{sourceId}/pull")
49
+    public R<String> pullFromDatabase(@PathVariable Long sourceId,
50
+                                       @RequestBody Map<String, Object> request) {
51
+        String sql = (String) request.get("sql");
52
+        String targetTable = (String) request.get("targetTable");
53
+        int count = ingestService.pullFromDatabase(sourceId, sql, targetTable);
54
+        return R.ok("数据拉取完成,成功: " + count + " 条");
55
+    }
56
+
57
+    @Operation(summary = "同步数据到本地表")
58
+    @PostMapping("/database/{sourceId}/sync")
59
+    public R<String> syncToTable(@PathVariable Long sourceId,
60
+                                  @RequestBody Map<String, Object> request) {
61
+        String querySql = (String) request.get("querySql");
62
+        String targetTable = (String) request.get("targetTable");
63
+        @SuppressWarnings("unchecked")
64
+        List<String> columns = (List<String>) request.get("columns");
65
+        int count = ingestService.syncToTable(sourceId, querySql, targetTable, columns);
66
+        return R.ok("同步完成,成功: " + count + " 条");
67
+    }
68
+
69
+    // ==================== 文件接入 ====================
70
+
71
+    @Operation(summary = "通过文件(CSV)接入数据")
72
+    @PostMapping("/file/{sourceCode}")
73
+    public R<String> ingestFromFile(@PathVariable String sourceCode,
74
+                                     @RequestParam("file") MultipartFile file) throws Exception {
75
+        int count = ingestService.ingestFromFile(file, sourceCode);
76
+        return R.ok("文件数据接入完成,成功: " + count + " 条");
77
+    }
78
+
79
+    // ==================== 数据源管理 ====================
80
+
81
+    @Operation(summary = "创建数据源")
82
+    @PostMapping("/source")
83
+    public R<DataSource> createDataSource(@RequestBody DataSource dataSource) {
84
+        return R.ok(ingestService.createDataSource(dataSource));
85
+    }
86
+
87
+    @Operation(summary = "更新数据源")
88
+    @PutMapping("/source/{id}")
89
+    public R<DataSource> updateDataSource(@PathVariable Long id,
90
+                                           @RequestBody DataSource dataSource) {
91
+        return R.ok(ingestService.updateDataSource(id, dataSource));
92
+    }
93
+
94
+    @Operation(summary = "删除数据源")
95
+    @DeleteMapping("/source/{id}")
96
+    public R<String> deleteDataSource(@PathVariable Long id) {
97
+        ingestService.deleteDataSource(id);
98
+        return R.ok("删除成功");
99
+    }
100
+
101
+    @Operation(summary = "查询数据源列表")
102
+    @GetMapping("/source/list")
103
+    public R<List<DataSource>> listDataSources(@RequestParam(required = false) String sourceType) {
104
+        return R.ok(ingestService.listDataSources(sourceType));
105
+    }
106
+
107
+    @Operation(summary = "获取数据源详情")
108
+    @GetMapping("/source/{id}")
109
+    public R<DataSource> getDataSource(@PathVariable Long id) {
110
+        return R.ok(ingestService.getDataSource(id));
111
+    }
112
+
113
+    @Operation(summary = "测试数据源连接")
114
+    @PostMapping("/source/{id}/test")
115
+    public R<Boolean> testConnection(@PathVariable Long id) {
116
+        return R.ok(ingestService.testConnection(id));
117
+    }
118
+}

+ 134
- 0
wm-data-engine/src/main/java/com/water/data_engine/controller/DataIntegrationController.java Прегледај датотеку

@@ -0,0 +1,134 @@
1
+package com.water.data_engine.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.data_engine.entity.DataLineage;
5
+import com.water.data_engine.entity.SyncTask;
6
+import com.water.data_engine.service.DataIntegrationService;
7
+import io.swagger.v3.oas.annotations.Operation;
8
+import io.swagger.v3.oas.annotations.tags.Tag;
9
+import lombok.RequiredArgsConstructor;
10
+import org.springframework.web.bind.annotation.*;
11
+
12
+import java.time.LocalDateTime;
13
+import java.util.List;
14
+import java.util.Map;
15
+
16
+/**
17
+ * 数据集成控制器
18
+ * DE-04: 多源异构数据整合
19
+ */
20
+@Tag(name = "数据集成")
21
+@RestController
22
+@RequestMapping("/api/data-engine/integration")
23
+@RequiredArgsConstructor
24
+public class DataIntegrationController {
25
+
26
+    private final DataIntegrationService integrationService;
27
+
28
+    // ==================== 数据同步 ====================
29
+
30
+    @Operation(summary = "创建同步任务")
31
+    @PostMapping("/sync/task")
32
+    public R<SyncTask> createSyncTask(@RequestBody SyncTask syncTask) {
33
+        return R.ok(integrationService.createSyncTask(syncTask));
34
+    }
35
+
36
+    @Operation(summary = "执行同步任务")
37
+    @PostMapping("/sync/task/{taskId}/execute")
38
+    public R<String> executeSyncTask(@PathVariable Long taskId) {
39
+        int count = integrationService.executeSyncTask(taskId);
40
+        return R.ok("同步完成,处理: " + count + " 条");
41
+    }
42
+
43
+    @Operation(summary = "执行全量同步")
44
+    @PostMapping("/sync/full")
45
+    public R<String> fullSync(@RequestBody Map<String, Object> request) {
46
+        Long sourceId = Long.valueOf(request.get("sourceId").toString());
47
+        String sourceTable = (String) request.get("sourceTable");
48
+        String targetTable = (String) request.get("targetTable");
49
+        int count = integrationService.fullSync(sourceId, sourceTable, targetTable);
50
+        return R.ok("全量同步完成: " + count + " 条");
51
+    }
52
+
53
+    @Operation(summary = "执行增量同步")
54
+    @PostMapping("/sync/incremental")
55
+    public R<String> incrementalSync(@RequestBody Map<String, Object> request) {
56
+        Long sourceId = Long.valueOf(request.get("sourceId").toString());
57
+        String sourceTable = (String) request.get("sourceTable");
58
+        String targetTable = (String) request.get("targetTable");
59
+        String timestampColumn = (String) request.get("timestampColumn");
60
+        LocalDateTime lastSyncTime = LocalDateTime.parse((String) request.get("lastSyncTime"));
61
+        int count = integrationService.incrementalSync(sourceId, sourceTable, targetTable,
62
+                                                       timestampColumn, lastSyncTime);
63
+        return R.ok("增量同步完成: " + count + " 条");
64
+    }
65
+
66
+    @Operation(summary = "查询同步任务列表")
67
+    @GetMapping("/sync/task/list")
68
+    public R<List<SyncTask>> listSyncTasks(@RequestParam(required = false) String status) {
69
+        return R.ok(integrationService.listSyncTasks(status));
70
+    }
71
+
72
+    @Operation(summary = "获取同步任务详情")
73
+    @GetMapping("/sync/task/{id}")
74
+    public R<SyncTask> getSyncTask(@PathVariable Long id) {
75
+        return R.ok(integrationService.getSyncTask(id));
76
+    }
77
+
78
+    @Operation(summary = "删除同步任务")
79
+    @DeleteMapping("/sync/task/{id}")
80
+    public R<String> deleteSyncTask(@PathVariable Long id) {
81
+        integrationService.deleteSyncTask(id);
82
+        return R.ok("删除成功");
83
+    }
84
+
85
+    // ==================== 数据合并与聚合 ====================
86
+
87
+    @Operation(summary = "数据合并(多源整合)")
88
+    @PostMapping("/merge")
89
+    public R<List<Map<String, Object>>> mergeData(@RequestBody Map<String, Object> request) {
90
+        @SuppressWarnings("unchecked")
91
+        List<String> sourceTables = (List<String>) request.get("sourceTables");
92
+        String joinColumn = (String) request.get("joinColumn");
93
+        @SuppressWarnings("unchecked")
94
+        List<String> selectColumns = (List<String>) request.get("selectColumns");
95
+        return R.ok(integrationService.mergeData(sourceTables, joinColumn, selectColumns));
96
+    }
97
+
98
+    @Operation(summary = "数据聚合(按维度汇总)")
99
+    @PostMapping("/aggregate")
100
+    public R<List<Map<String, Object>>> aggregateData(@RequestBody Map<String, Object> request) {
101
+        String sourceTable = (String) request.get("sourceTable");
102
+        @SuppressWarnings("unchecked")
103
+        List<String> groupByColumns = (List<String>) request.get("groupByColumns");
104
+        @SuppressWarnings("unchecked")
105
+        Map<String, String> aggregations = (Map<String, String>) request.get("aggregations");
106
+        return R.ok(integrationService.aggregateData(sourceTable, groupByColumns, aggregations));
107
+    }
108
+
109
+    // ==================== 数据血缘 ====================
110
+
111
+    @Operation(summary = "创建数据血缘关系")
112
+    @PostMapping("/lineage")
113
+    public R<DataLineage> createLineage(@RequestBody DataLineage lineage) {
114
+        return R.ok(integrationService.createLineage(lineage));
115
+    }
116
+
117
+    @Operation(summary = "查询血缘关系(上游)")
118
+    @GetMapping("/lineage/upstream/{tableName}")
119
+    public R<List<DataLineage>> getUpstreamLineage(@PathVariable String tableName) {
120
+        return R.ok(integrationService.getUpstreamLineage(tableName));
121
+    }
122
+
123
+    @Operation(summary = "查询血缘关系(下游)")
124
+    @GetMapping("/lineage/downstream/{tableName}")
125
+    public R<List<DataLineage>> getDownstreamLineage(@PathVariable String tableName) {
126
+        return R.ok(integrationService.getDownstreamLineage(tableName));
127
+    }
128
+
129
+    @Operation(summary = "查询完整血缘链路")
130
+    @GetMapping("/lineage/full/{tableName}")
131
+    public R<Map<String, Object>> getFullLineage(@PathVariable String tableName) {
132
+        return R.ok(integrationService.getFullLineage(tableName));
133
+    }
134
+}

+ 154
- 0
wm-data-engine/src/main/java/com/water/data_engine/controller/DataStorageController.java Прегледај датотеку

@@ -0,0 +1,154 @@
1
+package com.water.data_engine.controller;
2
+
3
+import com.water.common.core.result.R;
4
+import com.water.data_engine.entity.StorageConfig;
5
+import com.water.data_engine.service.DataStorageService;
6
+import io.swagger.v3.oas.annotations.Operation;
7
+import io.swagger.v3.oas.annotations.tags.Tag;
8
+import lombok.RequiredArgsConstructor;
9
+import org.springframework.web.bind.annotation.*;
10
+import org.springframework.web.multipart.MultipartFile;
11
+
12
+import java.time.LocalDateTime;
13
+import java.util.List;
14
+import java.util.Map;
15
+
16
+/**
17
+ * 数据存储控制器
18
+ * DE-03: TDengine + PostgreSQL + MinIO
19
+ */
20
+@Tag(name = "数据存储")
21
+@RestController
22
+@RequestMapping("/api/data-engine/storage")
23
+@RequiredArgsConstructor
24
+public class DataStorageController {
25
+
26
+    private final DataStorageService storageService;
27
+
28
+    // ==================== TDengine 时序存储 ====================
29
+
30
+    @Operation(summary = "写入遥测数据到 TDengine")
31
+    @PostMapping("/tdengine")
32
+    public R<String> writeToTDengine(@RequestBody Map<String, Object> data) {
33
+        storageService.writeToTDengine(
34
+            (String) data.get("deviceSn"),
35
+            (String) data.get("deviceType"),
36
+            (String) data.get("area"),
37
+            (String) data.get("metricKey"),
38
+            ((Number) data.get("value")).doubleValue()
39
+        );
40
+        return R.ok("写入成功");
41
+    }
42
+
43
+    @Operation(summary = "批量写入遥测数据")
44
+    @PostMapping("/tdengine/batch")
45
+    public R<String> batchWriteToTDengine(@RequestBody List<Map<String, Object>> dataList) {
46
+        int count = storageService.batchWriteToTDengine(dataList);
47
+        return R.ok("批量写入成功: " + count + " 条");
48
+    }
49
+
50
+    @Operation(summary = "查询遥测数据")
51
+    @GetMapping("/tdengine/query")
52
+    public R<List<Map<String, Object>>> queryFromTDengine(
53
+            @RequestParam String deviceSn,
54
+            @RequestParam String metricKey,
55
+            @RequestParam String startTime,
56
+            @RequestParam String endTime) {
57
+        return R.ok(storageService.queryFromTDengine(
58
+            deviceSn, metricKey,
59
+            LocalDateTime.parse(startTime),
60
+            LocalDateTime.parse(endTime)));
61
+    }
62
+
63
+    @Operation(summary = "查询聚合数据(小时级)")
64
+    @GetMapping("/tdengine/hourly")
65
+    public R<List<Map<String, Object>>> queryHourlyAgg(
66
+            @RequestParam String deviceSn,
67
+            @RequestParam String metricKey,
68
+            @RequestParam String startTime,
69
+            @RequestParam String endTime) {
70
+        return R.ok(storageService.queryHourlyAgg(
71
+            deviceSn, metricKey,
72
+            LocalDateTime.parse(startTime),
73
+            LocalDateTime.parse(endTime)));
74
+    }
75
+
76
+    // ==================== PostgreSQL 关系存储 ====================
77
+
78
+    @Operation(summary = "插入数据到 PostgreSQL")
79
+    @PostMapping("/postgres/{table}")
80
+    public R<Long> insertToPostgres(@PathVariable String table,
81
+                                     @RequestBody Map<String, Object> data) {
82
+        return R.ok(storageService.insertToPostgres(table, data));
83
+    }
84
+
85
+    @Operation(summary = "批量插入数据")
86
+    @PostMapping("/postgres/{table}/batch")
87
+    public R<String> batchInsertToPostgres(@PathVariable String table,
88
+                                            @RequestBody List<Map<String, Object>> dataList) {
89
+        int count = storageService.batchInsertToPostgres(table, dataList);
90
+        return R.ok("批量插入成功: " + count + " 条");
91
+    }
92
+
93
+    @Operation(summary = "更新数据")
94
+    @PutMapping("/postgres/{table}/{id}")
95
+    public R<String> updateInPostgres(@PathVariable String table,
96
+                                       @PathVariable Long id,
97
+                                       @RequestBody Map<String, Object> data) {
98
+        int count = storageService.updateInPostgres(table, id, data);
99
+        return R.ok("更新成功: " + count + " 条");
100
+    }
101
+
102
+    @Operation(summary = "查询数据")
103
+    @GetMapping("/postgres/{table}")
104
+    public R<List<Map<String, Object>>> queryFromPostgres(
105
+            @PathVariable String table,
106
+            @RequestParam Map<String, Object> conditions,
107
+            @RequestParam(defaultValue = "1") int page,
108
+            @RequestParam(defaultValue = "10") int size) {
109
+        return R.ok(storageService.queryFromPostgres(table, conditions, page, size));
110
+    }
111
+
112
+    // ==================== MinIO 对象存储 ====================
113
+
114
+    @Operation(summary = "上传文件到 MinIO")
115
+    @PostMapping("/minio/upload")
116
+    public R<String> uploadToMinio(@RequestParam("file") MultipartFile file,
117
+                                    @RequestParam(defaultValue = "default") String module) throws Exception {
118
+        String objectName = storageService.uploadToMinio(file, module);
119
+        return R.ok(objectName);
120
+    }
121
+
122
+    @Operation(summary = "列出 MinIO 文件")
123
+    @GetMapping("/minio/list")
124
+    public R<List<String>> listMinioObjects(@RequestParam(defaultValue = "") String prefix) throws Exception {
125
+        return R.ok(storageService.listMinioObjects(prefix));
126
+    }
127
+
128
+    // ==================== 存储配置管理 ====================
129
+
130
+    @Operation(summary = "创建存储配置")
131
+    @PostMapping("/config")
132
+    public R<StorageConfig> createStorageConfig(@RequestBody StorageConfig config) {
133
+        return R.ok(storageService.createStorageConfig(config));
134
+    }
135
+
136
+    @Operation(summary = "更新存储配置")
137
+    @PutMapping("/config/{id}")
138
+    public R<StorageConfig> updateStorageConfig(@PathVariable Long id,
139
+                                                 @RequestBody StorageConfig config) {
140
+        return R.ok(storageService.updateStorageConfig(id, config));
141
+    }
142
+
143
+    @Operation(summary = "查询存储配置列表")
144
+    @GetMapping("/config/list")
145
+    public R<List<StorageConfig>> listStorageConfigs(@RequestParam(required = false) String storageType) {
146
+        return R.ok(storageService.listStorageConfigs(storageType));
147
+    }
148
+
149
+    @Operation(summary = "测试存储连接")
150
+    @PostMapping("/config/{id}/test")
151
+    public R<Boolean> testStorageConnection(@PathVariable Long id) {
152
+        return R.ok(storageService.testStorageConnection(id));
153
+    }
154
+}

+ 46
- 0
wm-data-engine/src/main/java/com/water/data_engine/entity/CollectRecord.java Прегледај датотеку

@@ -0,0 +1,46 @@
1
+package com.water.data_engine.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 数据采集记录实体
10
+ */
11
+@Data
12
+@TableName("de_collect_record")
13
+public class CollectRecord {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    /** 任务ID */
19
+    private Long taskId;
20
+
21
+    /** 数据源ID */
22
+    private Long sourceId;
23
+
24
+    /** 数据源类型 */
25
+    private String sourceType;
26
+
27
+    /** 数据源Key */
28
+    private String sourceKey;
29
+
30
+    /** 原始数据(JSON) */
31
+    @TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
32
+    private Object rawData;
33
+
34
+    /** 处理后数据(JSON) */
35
+    @TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
36
+    private Object processedData;
37
+
38
+    /** 状态: success/failed/skipped */
39
+    private String status;
40
+
41
+    /** 错误信息 */
42
+    private String errorMsg;
43
+
44
+    /** 采集时间 */
45
+    private LocalDateTime collectTime;
46
+}

+ 82
- 0
wm-data-engine/src/main/java/com/water/data_engine/entity/CollectTask.java Прегледај датотеку

@@ -0,0 +1,82 @@
1
+package com.water.data_engine.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import lombok.EqualsAndHashCode;
6
+
7
+import java.time.LocalDateTime;
8
+
9
+/**
10
+ * 数据采集任务实体
11
+ */
12
+@Data
13
+@EqualsAndHashCode(callSuper = true)
14
+@TableName("de_collect_task")
15
+public class CollectTask extends com.water.common.core.entity.BaseEntity {
16
+
17
+    /**
18
+     * 任务名称
19
+     */
20
+    private String taskName;
21
+
22
+    /**
23
+     * 数据源ID
24
+     */
25
+    private Long sourceId;
26
+
27
+    /**
28
+     * 采集类型: realtime/batch/manual
29
+     */
30
+    private String collectType;
31
+
32
+    /**
33
+     * Kafka/MQTT topic
34
+     */
35
+    private String topic;
36
+
37
+    /**
38
+     * 目标表名
39
+     */
40
+    private String targetTable;
41
+
42
+    /**
43
+     * 转换规则(JSON)
44
+     */
45
+    @TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
46
+    private Object transformRule;
47
+
48
+    /**
49
+     * 状态: pending/running/paused/completed/failed
50
+     */
51
+    private String status;
52
+
53
+    /**
54
+     * 总记录数
55
+     */
56
+    private Long totalCount;
57
+
58
+    /**
59
+     * 成功数
60
+     */
61
+    private Long successCount;
62
+
63
+    /**
64
+     * 失败数
65
+     */
66
+    private Long failCount;
67
+
68
+    /**
69
+     * 开始时间
70
+     */
71
+    private LocalDateTime startTime;
72
+
73
+    /**
74
+     * 结束时间
75
+     */
76
+    private LocalDateTime endTime;
77
+
78
+    /**
79
+     * 错误信息
80
+     */
81
+    private String errorMsg;
82
+}

+ 41
- 0
wm-data-engine/src/main/java/com/water/data_engine/entity/DataLineage.java Прегледај датотеку

@@ -0,0 +1,41 @@
1
+package com.water.data_engine.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+
6
+import java.time.LocalDateTime;
7
+
8
+/**
9
+ * 数据血缘关系实体
10
+ */
11
+@Data
12
+@TableName("de_data_lineage")
13
+public class DataLineage {
14
+
15
+    @TableId(type = IdType.AUTO)
16
+    private Long id;
17
+
18
+    /** 源表 */
19
+    private String sourceTable;
20
+
21
+    /** 源列 */
22
+    private String sourceColumn;
23
+
24
+    /** 目标表 */
25
+    private String targetTable;
26
+
27
+    /** 目标列 */
28
+    private String targetColumn;
29
+
30
+    /** 转换类型: direct/mapping/aggregation/calculation */
31
+    private String transformType;
32
+
33
+    /** 转换规则 */
34
+    private String transformRule;
35
+
36
+    /** 描述 */
37
+    private String description;
38
+
39
+    /** 创建时间 */
40
+    private LocalDateTime createdAt;
41
+}

+ 67
- 0
wm-data-engine/src/main/java/com/water/data_engine/entity/DataSource.java Прегледај датотеку

@@ -0,0 +1,67 @@
1
+package com.water.data_engine.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import lombok.EqualsAndHashCode;
6
+
7
+import java.time.LocalDateTime;
8
+
9
+/**
10
+ * 数据源配置实体
11
+ */
12
+@Data
13
+@EqualsAndHashCode(callSuper = true)
14
+@TableName("de_data_source")
15
+public class DataSource extends com.water.common.core.entity.BaseEntity {
16
+
17
+    /**
18
+     * 数据源名称
19
+     */
20
+    private String sourceName;
21
+
22
+    /**
23
+     * 数据源编码(唯一)
24
+     */
25
+    private String sourceCode;
26
+
27
+    /**
28
+     * 数据源类型: mqtt/kafka/rest/websocket/database/file
29
+     */
30
+    private String sourceType;
31
+
32
+    /**
33
+     * 数据分类: iot/manual/api/database
34
+     */
35
+    private String category;
36
+
37
+    /**
38
+     * 连接配置(JSON)
39
+     */
40
+    @TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
41
+    private Object connectionConfig;
42
+
43
+    /**
44
+     * 同步模式: realtime/batch/scheduled
45
+     */
46
+    private String syncMode;
47
+
48
+    /**
49
+     * 定时同步Cron表达式
50
+     */
51
+    private String syncCron;
52
+
53
+    /**
54
+     * 状态: 0-禁用 1-启用
55
+     */
56
+    private Integer status;
57
+
58
+    /**
59
+     * 描述
60
+     */
61
+    private String description;
62
+
63
+    /**
64
+     * 最后同步时间
65
+     */
66
+    private LocalDateTime lastSyncAt;
67
+}

+ 38
- 0
wm-data-engine/src/main/java/com/water/data_engine/entity/QualityRule.java Прегледај датотеку

@@ -0,0 +1,38 @@
1
+package com.water.data_engine.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import lombok.EqualsAndHashCode;
6
+
7
+/**
8
+ * 数据质量规则实体
9
+ */
10
+@Data
11
+@EqualsAndHashCode(callSuper = true)
12
+@TableName("de_quality_rule")
13
+public class QualityRule extends com.water.common.core.entity.BaseEntity {
14
+
15
+    /** 规则名称 */
16
+    private String ruleName;
17
+
18
+    /** 规则类型: completeness/validity/timeliness/consistency */
19
+    private String ruleType;
20
+
21
+    /** 表名 */
22
+    private String tableName;
23
+
24
+    /** 列名 */
25
+    private String columnName;
26
+
27
+    /** 规则表达式 */
28
+    private String ruleExpr;
29
+
30
+    /** 阈值 */
31
+    private java.math.BigDecimal threshold;
32
+
33
+    /** 严重级别: info/warning/error */
34
+    private String severity;
35
+
36
+    /** 是否启用 */
37
+    private Integer enabled;
38
+}

+ 42
- 0
wm-data-engine/src/main/java/com/water/data_engine/entity/StorageConfig.java Прегледај датотеку

@@ -0,0 +1,42 @@
1
+package com.water.data_engine.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import lombok.EqualsAndHashCode;
6
+
7
+/**
8
+ * 存储配置实体
9
+ */
10
+@Data
11
+@EqualsAndHashCode(callSuper = true)
12
+@TableName("de_storage_config")
13
+public class StorageConfig extends com.water.common.core.entity.BaseEntity {
14
+
15
+    /** 存储名称 */
16
+    private String storageName;
17
+
18
+    /** 存储类型: tdengine/postgresql/minio */
19
+    private String storageType;
20
+
21
+    /** 连接URL */
22
+    private String connectionUrl;
23
+
24
+    /** 用户名 */
25
+    private String username;
26
+
27
+    /** 密码 */
28
+    private String password;
29
+
30
+    /** 数据库名 */
31
+    private String databaseName;
32
+
33
+    /** 桶名(MinIO) */
34
+    private String bucketName;
35
+
36
+    /** 扩展配置 */
37
+    @TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
38
+    private Object extraConfig;
39
+
40
+    /** 状态 */
41
+    private Integer status;
42
+}

+ 43
- 0
wm-data-engine/src/main/java/com/water/data_engine/entity/SyncTask.java Прегледај датотеку

@@ -0,0 +1,43 @@
1
+package com.water.data_engine.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.*;
4
+import lombok.Data;
5
+import lombok.EqualsAndHashCode;
6
+
7
+import java.time.LocalDateTime;
8
+
9
+/**
10
+ * 数据同步任务实体
11
+ */
12
+@Data
13
+@EqualsAndHashCode(callSuper = true)
14
+@TableName("de_sync_task")
15
+public class SyncTask extends com.water.common.core.entity.BaseEntity {
16
+
17
+    /** 任务名称 */
18
+    private String taskName;
19
+
20
+    /** 数据源ID */
21
+    private Long sourceId;
22
+
23
+    /** 目标存储ID */
24
+    private Long targetStorageId;
25
+
26
+    /** 同步类型: full/incremental/cdc */
27
+    private String syncType;
28
+
29
+    /** 同步Cron表达式 */
30
+    private String syncCron;
31
+
32
+    /** 最后同步时间 */
33
+    private LocalDateTime lastSyncAt;
34
+
35
+    /** 最后同步记录数 */
36
+    private Long lastSyncCount;
37
+
38
+    /** 状态: pending/running/paused/completed/failed */
39
+    private String status;
40
+
41
+    /** 错误信息 */
42
+    private String errorMsg;
43
+}

+ 12
- 0
wm-data-engine/src/main/java/com/water/data_engine/mapper/CollectRecordMapper.java Прегледај датотеку

@@ -0,0 +1,12 @@
1
+package com.water.data_engine.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.data_engine.entity.CollectRecord;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+/**
8
+ * 采集记录Mapper
9
+ */
10
+@Mapper
11
+public interface CollectRecordMapper extends BaseMapper<CollectRecord> {
12
+}

+ 12
- 0
wm-data-engine/src/main/java/com/water/data_engine/mapper/CollectTaskMapper.java Прегледај датотеку

@@ -0,0 +1,12 @@
1
+package com.water.data_engine.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.data_engine.entity.CollectTask;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+/**
8
+ * 采集任务Mapper
9
+ */
10
+@Mapper
11
+public interface CollectTaskMapper extends BaseMapper<CollectTask> {
12
+}

+ 12
- 0
wm-data-engine/src/main/java/com/water/data_engine/mapper/DataLineageMapper.java Прегледај датотеку

@@ -0,0 +1,12 @@
1
+package com.water.data_engine.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.data_engine.entity.DataLineage;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+/**
8
+ * 数据血缘Mapper
9
+ */
10
+@Mapper
11
+public interface DataLineageMapper extends BaseMapper<DataLineage> {
12
+}

+ 12
- 0
wm-data-engine/src/main/java/com/water/data_engine/mapper/DataSourceMapper.java Прегледај датотеку

@@ -0,0 +1,12 @@
1
+package com.water.data_engine.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.data_engine.entity.DataSource;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+/**
8
+ * 数据源Mapper
9
+ */
10
+@Mapper
11
+public interface DataSourceMapper extends BaseMapper<DataSource> {
12
+}

+ 12
- 0
wm-data-engine/src/main/java/com/water/data_engine/mapper/QualityRuleMapper.java Прегледај датотеку

@@ -0,0 +1,12 @@
1
+package com.water.data_engine.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.data_engine.entity.QualityRule;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+/**
8
+ * 质量规则Mapper
9
+ */
10
+@Mapper
11
+public interface QualityRuleMapper extends BaseMapper<QualityRule> {
12
+}

+ 12
- 0
wm-data-engine/src/main/java/com/water/data_engine/mapper/StorageConfigMapper.java Прегледај датотеку

@@ -0,0 +1,12 @@
1
+package com.water.data_engine.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.data_engine.entity.StorageConfig;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+/**
8
+ * 存储配置Mapper
9
+ */
10
+@Mapper
11
+public interface StorageConfigMapper extends BaseMapper<StorageConfig> {
12
+}

+ 12
- 0
wm-data-engine/src/main/java/com/water/data_engine/mapper/SyncTaskMapper.java Прегледај датотеку

@@ -0,0 +1,12 @@
1
+package com.water.data_engine.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.water.data_engine.entity.SyncTask;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+/**
8
+ * 同步任务Mapper
9
+ */
10
+@Mapper
11
+public interface SyncTaskMapper extends BaseMapper<SyncTask> {
12
+}

+ 281
- 0
wm-data-engine/src/main/java/com/water/data_engine/service/DataCollectService.java Прегледај датотеку

@@ -0,0 +1,281 @@
1
+package com.water.data_engine.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
5
+import com.fasterxml.jackson.databind.ObjectMapper;
6
+import com.water.data_engine.entity.CollectRecord;
7
+import com.water.data_engine.entity.CollectTask;
8
+import com.water.data_engine.entity.DataSource;
9
+import com.water.data_engine.mapper.CollectRecordMapper;
10
+import com.water.data_engine.mapper.CollectTaskMapper;
11
+import com.water.data_engine.mapper.DataSourceMapper;
12
+import lombok.RequiredArgsConstructor;
13
+import lombok.extern.slf4j.Slf4j;
14
+import org.springframework.jdbc.core.JdbcTemplate;
15
+import org.springframework.kafka.annotation.KafkaListener;
16
+import org.springframework.kafka.core.KafkaTemplate;
17
+import org.springframework.messaging.simp.SimpMessagingTemplate;
18
+import org.springframework.stereotype.Service;
19
+import org.springframework.transaction.annotation.Transactional;
20
+
21
+import java.time.Instant;
22
+import java.time.LocalDateTime;
23
+import java.util.*;
24
+
25
+/**
26
+ * 数据采集服务
27
+ * DE-01: 实时流(MQTT/Kafka) + 批量采集
28
+ */
29
+@Slf4j
30
+@Service
31
+@RequiredArgsConstructor
32
+public class DataCollectService {
33
+
34
+    private final KafkaTemplate<String, String> kafkaTemplate;
35
+    private final JdbcTemplate jdbcTemplate;
36
+    private final DataSourceMapper dataSourceMapper;
37
+    private final CollectTaskMapper collectTaskMapper;
38
+    private final CollectRecordMapper collectRecordMapper;
39
+    private final SimpMessagingTemplate wsMessagingTemplate;
40
+    private final ObjectMapper mapper = new ObjectMapper();
41
+
42
+    // ==================== 实时流采集 ====================
43
+
44
+    /**
45
+     * 实时数据接入:接收各来源数据,统一写入 Kafka
46
+     * 支持 MQTT/Kafka 来源的实时流
47
+     */
48
+    public String ingestRealtime(String sourceType, String sourceId, Map<String, Object> rawData) {
49
+        try {
50
+            Map<String, Object> envelope = buildEnvelope(sourceType, sourceId, rawData);
51
+            String json = mapper.writeValueAsString(envelope);
52
+
53
+            // 根据来源路由到不同 topic
54
+            String topic = routeTopic(sourceType);
55
+            kafkaTemplate.send(topic, sourceId, json);
56
+
57
+            // 保存采集记录
58
+            saveCollectRecord(null, sourceType, sourceId, rawData, "success", null);
59
+
60
+            // 通过 WebSocket 推送实时数据
61
+            wsMessagingTemplate.convertAndSend("/topic/data/realtime/" + sourceType, envelope);
62
+
63
+            log.debug("实时数据接入: {} -> {}, topic: {}", sourceType, sourceId, topic);
64
+            return topic;
65
+        } catch (Exception e) {
66
+            log.error("实时数据接入失败: {}", e.getMessage(), e);
67
+            saveCollectRecord(null, sourceType, sourceId, rawData, "failed", e.getMessage());
68
+            throw new RuntimeException("数据接入失败: " + e.getMessage());
69
+        }
70
+    }
71
+
72
+    /**
73
+     * Kafka 消费者:处理 IoT 设备遥测数据
74
+     */
75
+    @KafkaListener(topics = "iot.raw.generic", groupId = "wm-data-engine")
76
+    public void consumeIotRaw(String message) {
77
+        try {
78
+            @SuppressWarnings("unchecked")
79
+            Map<String, Object> envelope = mapper.readValue(message, Map.class);
80
+            @SuppressWarnings("unchecked")
81
+            Map<String, Object> data = (Map<String, Object>) envelope.get("data");
82
+
83
+            String deviceSn = (String) data.getOrDefault("deviceSn", "unknown");
84
+            @SuppressWarnings("unchecked")
85
+            List<Map<String, Object>> metrics = (List<Map<String, Object>>) data.getOrDefault("metrics", List.of());
86
+
87
+            for (Map<String, Object> metric : metrics) {
88
+                String key = (String) metric.get("key");
89
+                Object value = metric.get("value");
90
+                // 写入 TDengine
91
+                writeToTDengine(deviceSn, key, value);
92
+            }
93
+
94
+            log.debug("消费 IoT 数据: device={}, metrics={}", deviceSn, metrics.size());
95
+        } catch (Exception e) {
96
+            log.error("消费 IoT 数据失败: {}", e.getMessage());
97
+        }
98
+    }
99
+
100
+    /**
101
+     * Kafka 消费者:处理水质数据
102
+     */
103
+    @KafkaListener(topics = "data.quality", groupId = "wm-data-engine")
104
+    public void consumeQualityData(String message) {
105
+        try {
106
+            @SuppressWarnings("unchecked")
107
+            Map<String, Object> envelope = mapper.readValue(message, Map.class);
108
+            @SuppressWarnings("unchecked")
109
+            Map<String, Object> data = (Map<String, Object>) envelope.get("data");
110
+
111
+            // 写入 PostgreSQL
112
+            String sql = """
113
+                INSERT INTO water_quality_record (test_type, test_point, point_type, area, 
114
+                    turbidity, ph, residual_chlorine, is_qualified, created_at)
115
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
116
+                """;
117
+            jdbcTemplate.update(sql,
118
+                data.get("testType"),
119
+                data.get("testPoint"),
120
+                data.get("pointType"),
121
+                data.get("area"),
122
+                data.get("turbidity"),
123
+                data.get("ph"),
124
+                data.get("residualChlorine"),
125
+                data.get("isQualified"));
126
+
127
+            log.debug("消费水质数据: point={}", data.get("testPoint"));
128
+        } catch (Exception e) {
129
+            log.error("消费水质数据失败: {}", e.getMessage());
130
+        }
131
+    }
132
+
133
+    // ==================== 批量采集 ====================
134
+
135
+    /**
136
+     * 批量数据采集
137
+     */
138
+    @Transactional
139
+    public int batchIngest(List<Map<String, Object>> batchData) {
140
+        int successCount = 0;
141
+        for (Map<String, Object> data : batchData) {
142
+            try {
143
+                String sourceType = (String) data.getOrDefault("sourceType", "batch");
144
+                String sourceId = (String) data.getOrDefault("sourceId", UUID.randomUUID().toString());
145
+                @SuppressWarnings("unchecked")
146
+                Map<String, Object> rawData = (Map<String, Object>) data.getOrDefault("data", new HashMap<>());
147
+                ingestRealtime(sourceType, sourceId, rawData);
148
+                successCount++;
149
+            } catch (Exception e) {
150
+                log.warn("批量采集单条失败: {}", e.getMessage());
151
+            }
152
+        }
153
+        return successCount;
154
+    }
155
+
156
+    /**
157
+     * 创建批量采集任务
158
+     */
159
+    @Transactional
160
+    public CollectTask createBatchTask(String taskName, Long sourceId, String targetTable) {
161
+        CollectTask task = new CollectTask();
162
+        task.setTaskName(taskName);
163
+        task.setSourceId(sourceId);
164
+        task.setCollectType("batch");
165
+        task.setTargetTable(targetTable);
166
+        task.setStatus("pending");
167
+        task.setTotalCount(0L);
168
+        task.setSuccessCount(0L);
169
+        task.setFailCount(0L);
170
+        collectTaskMapper.insert(task);
171
+        return task;
172
+    }
173
+
174
+    /**
175
+     * 执行采集任务
176
+     */
177
+    @Transactional
178
+    public void executeTask(Long taskId, List<Map<String, Object>> dataList) {
179
+        CollectTask task = collectTaskMapper.selectById(taskId);
180
+        if (task == null) {
181
+            throw new RuntimeException("任务不存在: " + taskId);
182
+        }
183
+
184
+        task.setStatus("running");
185
+        task.setStartTime(LocalDateTime.now());
186
+        task.setTotalCount((long) dataList.size());
187
+        collectTaskMapper.updateById(task);
188
+
189
+        long success = 0;
190
+        long fail = 0;
191
+
192
+        for (Map<String, Object> data : dataList) {
193
+            try {
194
+                String sourceType = (String) data.getOrDefault("sourceType", "batch");
195
+                String sourceId = (String) data.getOrDefault("sourceId", UUID.randomUUID().toString());
196
+                @SuppressWarnings("unchecked")
197
+                Map<String, Object> rawData = (Map<String, Object>) data.getOrDefault("data", data);
198
+                ingestRealtime(sourceType, sourceId, rawData);
199
+                success++;
200
+            } catch (Exception e) {
201
+                fail++;
202
+                log.warn("任务 {} 采集失败: {}", taskId, e.getMessage());
203
+            }
204
+        }
205
+
206
+        task.setSuccessCount(success);
207
+        task.setFailCount(fail);
208
+        task.setStatus("completed");
209
+        task.setEndTime(LocalDateTime.now());
210
+        collectTaskMapper.updateById(task);
211
+    }
212
+
213
+    // ==================== 查询方法 ====================
214
+
215
+    /**
216
+     * 查询采集任务列表
217
+     */
218
+    public Page<CollectTask> listTasks(int page, int size, String status) {
219
+        LambdaQueryWrapper<CollectTask> wrapper = new LambdaQueryWrapper<>();
220
+        if (status != null && !status.isEmpty()) {
221
+            wrapper.eq(CollectTask::getStatus, status);
222
+        }
223
+        wrapper.orderByDesc(CollectTask::getCreatedAt);
224
+        return collectTaskMapper.selectPage(new Page<>(page, size), wrapper);
225
+    }
226
+
227
+    /**
228
+     * 查询采集记录
229
+     */
230
+    public Page<CollectRecord> listRecords(int page, int size, Long taskId) {
231
+        LambdaQueryWrapper<CollectRecord> wrapper = new LambdaQueryWrapper<>();
232
+        if (taskId != null) {
233
+            wrapper.eq(CollectRecord::getTaskId, taskId);
234
+        }
235
+        wrapper.orderByDesc(CollectRecord::getCollectTime);
236
+        return collectRecordMapper.selectPage(new Page<>(page, size), wrapper);
237
+    }
238
+
239
+    // ==================== 私有方法 ====================
240
+
241
+    private Map<String, Object> buildEnvelope(String sourceType, String sourceId, Map<String, Object> rawData) {
242
+        Map<String, Object> envelope = new LinkedHashMap<>();
243
+        envelope.put("sourceType", sourceType);
244
+        envelope.put("sourceId", sourceId);
245
+        envelope.put("timestamp", Instant.now().toEpochMilli());
246
+        envelope.put("data", rawData);
247
+        return envelope;
248
+    }
249
+
250
+    private String routeTopic(String sourceType) {
251
+        return switch (sourceType) {
252
+            case "iot", "mqtt" -> "iot.raw.generic";
253
+            case "quality" -> "data.quality";
254
+            case "manual" -> "data.manual";
255
+            case "api" -> "data.api";
256
+            default -> "data.raw";
257
+        };
258
+    }
259
+
260
+    private void writeToTDengine(String deviceSn, String metricKey, Object value) {
261
+        String sql = "INSERT INTO water_iot.iot_telemetry (ts, device_sn, metric_key, metric_value, quality) VALUES (NOW, ?, ?, ?, 1)";
262
+        jdbcTemplate.update(sql, deviceSn, metricKey, value);
263
+    }
264
+
265
+    private void saveCollectRecord(Long taskId, String sourceType, String sourceKey,
266
+                                    Map<String, Object> rawData, String status, String errorMsg) {
267
+        try {
268
+            CollectRecord record = new CollectRecord();
269
+            record.setTaskId(taskId);
270
+            record.setSourceType(sourceType);
271
+            record.setSourceKey(sourceKey);
272
+            record.setRawData(rawData);
273
+            record.setStatus(status);
274
+            record.setErrorMsg(errorMsg);
275
+            record.setCollectTime(LocalDateTime.now());
276
+            collectRecordMapper.insert(record);
277
+        } catch (Exception e) {
278
+            log.error("保存采集记录失败: {}", e.getMessage());
279
+        }
280
+    }
281
+}

+ 380
- 0
wm-data-engine/src/main/java/com/water/data_engine/service/DataGovernanceService.java Прегледај датотеку

@@ -0,0 +1,380 @@
1
+package com.water.data_engine.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.data_engine.entity.QualityRule;
5
+import com.water.data_engine.mapper.QualityRuleMapper;
6
+import lombok.RequiredArgsConstructor;
7
+import lombok.extern.slf4j.Slf4j;
8
+import org.springframework.jdbc.core.JdbcTemplate;
9
+import org.springframework.stereotype.Service;
10
+import org.springframework.transaction.annotation.Transactional;
11
+
12
+import java.math.BigDecimal;
13
+import java.math.RoundingMode;
14
+import java.time.LocalDateTime;
15
+import java.util.*;
16
+import java.util.stream.Collectors;
17
+
18
+/**
19
+ * 数据治理服务
20
+ * 数据标准化、清洗、质量控制
21
+ */
22
+@Slf4j
23
+@Service
24
+@RequiredArgsConstructor
25
+public class DataGovernanceService {
26
+
27
+    private final JdbcTemplate jdbcTemplate;
28
+    private final QualityRuleMapper qualityRuleMapper;
29
+
30
+    // 水利行业标准字段映射
31
+    private static final Map<String, String> STANDARD_FIELD_MAP = Map.of(
32
+        "flow", "LL",                    // 流量
33
+        "pressure", "YL",                // 压力
34
+        "level", "SW",                   // 水位
35
+        "turbidity", "ZD",               // 浊度
36
+        "ph", "PH",                      // pH值
37
+        "residual_chlorine", "YLJL",     // 余氯
38
+        "temperature", "WD",             // 温度
39
+        "conductivity", "DD",            // 电导率
40
+        "dissolved_oxygen", "RJY",       // 溶解氧
41
+        "ammonia", "AD"                  // 氨氮
42
+    );
43
+
44
+    // 数值型标准字段
45
+    private static final List<String> NUMERIC_FIELDS = List.of(
46
+        "LL", "YL", "SW", "ZD", "PH", "YLJL", "WD", "DD", "RJY", "AD"
47
+    );
48
+
49
+    // ==================== 数据标准化 ====================
50
+
51
+    /**
52
+     * 数据标准化:水利数据对象标准映射
53
+     */
54
+    public Map<String, Object> standardize(Map<String, Object> raw) {
55
+        Map<String, Object> std = new LinkedHashMap<>();
56
+
57
+        for (Map.Entry<String, Object> entry : raw.entrySet()) {
58
+            String key = entry.getKey();
59
+            Object value = entry.getValue();
60
+
61
+            // 字段名映射
62
+            String standardKey = STANDARD_FIELD_MAP.getOrDefault(key, key);
63
+            std.put(standardKey, value);
64
+        }
65
+
66
+        // 添加标准化标记
67
+        std.put("_standardized", true);
68
+        std.put("_standardize_time", LocalDateTime.now().toString());
69
+
70
+        return std;
71
+    }
72
+
73
+    /**
74
+     * 批量标准化
75
+     */
76
+    public List<Map<String, Object>> batchStandardize(List<Map<String, Object>> rawDataList) {
77
+        return rawDataList.stream()
78
+            .map(this::standardize)
79
+            .collect(Collectors.toList());
80
+    }
81
+
82
+    // ==================== 数据清洗 ====================
83
+
84
+    /**
85
+     * 数据清洗:缺失值填充、异常值检测
86
+     */
87
+    public Map<String, Object> clean(Map<String, Object> data) {
88
+        Map<String, Object> cleaned = new LinkedHashMap<>(data);
89
+
90
+        // 1. 缺失值处理
91
+        for (String field : NUMERIC_FIELDS) {
92
+            Object value = cleaned.get(field);
93
+            if (value == null || "".equals(value.toString().trim())) {
94
+                cleaned.put(field, -9999.0);
95
+                cleaned.put(field + "_flag", "MISSING");
96
+            }
97
+        }
98
+
99
+        // 2. 异常值检测
100
+        detectAnomalies(cleaned);
101
+
102
+        // 3. 数据类型转换
103
+        convertDataTypes(cleaned);
104
+
105
+        cleaned.put("_cleaned", true);
106
+        cleaned.put("_clean_time", LocalDateTime.now().toString());
107
+
108
+        return cleaned;
109
+    }
110
+
111
+    /**
112
+     * 批量清洗
113
+     */
114
+    public List<Map<String, Object>> batchClean(List<Map<String, Object>> dataList) {
115
+        return dataList.stream()
116
+            .map(this::clean)
117
+            .collect(Collectors.toList());
118
+    }
119
+
120
+    // ==================== 数据质量控制 ====================
121
+
122
+    /**
123
+     * 数据质量检查
124
+     */
125
+    public Map<String, Object> qualityCheck(Map<String, Object> data) {
126
+        Map<String, Object> result = new LinkedHashMap<>(data);
127
+        int score = 100;
128
+        List<String> issues = new ArrayList<>();
129
+
130
+        // 1. 完整性检查
131
+        for (String field : NUMERIC_FIELDS) {
132
+            if (data.containsKey(field + "_flag") && "MISSING".equals(data.get(field + "_flag"))) {
133
+                score -= 5;
134
+                issues.add(field + "数据缺失");
135
+            }
136
+        }
137
+
138
+        // 2. 异常值检查
139
+        if (data.containsKey("LL_flag") && "ABNORMAL".equals(data.get("LL_flag"))) {
140
+            score -= 15;
141
+            issues.add("流量数据异常(负值)");
142
+        }
143
+        if (data.containsKey("PH")) {
144
+            double ph = ((Number) data.get("PH")).doubleValue();
145
+            if (ph < 0 || ph > 14) {
146
+                score -= 10;
147
+                issues.add("pH值超出合理范围(0-14)");
148
+            }
149
+        }
150
+
151
+        // 3. 时效性检查
152
+        if (data.containsKey("_standardize_time")) {
153
+            // 检查数据是否过于陈旧
154
+            // 简化处理:假设超过1小时为陈旧数据
155
+        }
156
+
157
+        // 4. 一致性检查
158
+        if (data.containsKey("SW") && data.containsKey("YL")) {
159
+            // 水位和压力应该有一定的相关性
160
+            // 简化处理
161
+        }
162
+
163
+        result.put("_quality_score", Math.max(score, 0));
164
+        result.put("_quality_issues", issues);
165
+        result.put("_quality_checked", true);
166
+        result.put("_quality_check_time", LocalDateTime.now().toString());
167
+
168
+        return result;
169
+    }
170
+
171
+    /**
172
+     * 批量质量检查
173
+     */
174
+    public List<Map<String, Object>> batchQualityCheck(List<Map<String, Object>> dataList) {
175
+        return dataList.stream()
176
+            .map(this::qualityCheck)
177
+            .collect(Collectors.toList());
178
+    }
179
+
180
+    /**
181
+     * 执行质量规则检查
182
+     */
183
+    @Transactional
184
+    public Map<String, Object> executeQualityRules(String tableName) {
185
+        LambdaQueryWrapper<QualityRule> wrapper = new LambdaQueryWrapper<>();
186
+        wrapper.eq(QualityRule::getTableName, tableName);
187
+        wrapper.eq(QualityRule::getEnabled, 1);
188
+        List<QualityRule> rules = qualityRuleMapper.selectList(wrapper);
189
+
190
+        Map<String, Object> results = new HashMap<>();
191
+        int totalChecks = rules.size();
192
+        int passedChecks = 0;
193
+
194
+        for (QualityRule rule : rules) {
195
+            try {
196
+                boolean passed = executeSingleRule(rule);
197
+                if (passed) {
198
+                    passedChecks++;
199
+                }
200
+                results.put(rule.getRuleName(), passed ? "PASS" : "FAIL");
201
+            } catch (Exception e) {
202
+                results.put(rule.getRuleName(), "ERROR: " + e.getMessage());
203
+            }
204
+        }
205
+
206
+        BigDecimal passRate = totalChecks > 0 
207
+            ? BigDecimal.valueOf(passedChecks).multiply(BigDecimal.valueOf(100))
208
+                .divide(BigDecimal.valueOf(totalChecks), 2, RoundingMode.HALF_UP)
209
+            : BigDecimal.ZERO;
210
+
211
+        Map<String, Object> summary = new HashMap<>();
212
+        summary.put("table", tableName);
213
+        summary.put("total_rules", totalChecks);
214
+        summary.put("passed", passedChecks);
215
+        summary.put("failed", totalChecks - passedChecks);
216
+        summary.put("pass_rate", passRate);
217
+        summary.put("details", results);
218
+        summary.put("check_time", LocalDateTime.now());
219
+
220
+        return summary;
221
+    }
222
+
223
+    // ==================== 数据血缘 ====================
224
+
225
+    /**
226
+     * 建立数据血缘关系
227
+     */
228
+    @Transactional
229
+    public void buildLineage(Long sourceId, Long targetId, String relation) {
230
+        String sql = """
231
+            INSERT INTO de_data_lineage (source_table, source_column, target_table, target_column, 
232
+                                          transform_type, description, created_at)
233
+            VALUES (?, ?, ?, ?, ?, ?, NOW())
234
+            ON CONFLICT DO NOTHING
235
+            """;
236
+        jdbcTemplate.update(sql, "iot_telemetry", null, "iot_telemetry_hourly", null, relation, "自动聚合");
237
+    }
238
+
239
+    // ==================== 数据管道 ====================
240
+
241
+    /**
242
+     * 完整的数据处理管道:标准化 -> 清洗 -> 质控
243
+     */
244
+    public Map<String, Object> pipeline(Map<String, Object> raw) {
245
+        Map<String, Object> std = standardize(raw);
246
+        Map<String, Object> cleaned = clean(std);
247
+        Map<String, Object> result = qualityCheck(cleaned);
248
+        return result;
249
+    }
250
+
251
+    /**
252
+     * 批量数据管道
253
+     */
254
+    public List<Map<String, Object>> batchPipeline(List<Map<String, Object>> rawDataList) {
255
+        return rawDataList.stream()
256
+            .map(this::pipeline)
257
+            .collect(Collectors.toList());
258
+    }
259
+
260
+    // ==================== 质量规则管理 ====================
261
+
262
+    /**
263
+     * 创建质量规则
264
+     */
265
+    @Transactional
266
+    public QualityRule createQualityRule(QualityRule rule) {
267
+        rule.setEnabled(1);
268
+        qualityRuleMapper.insert(rule);
269
+        return rule;
270
+    }
271
+
272
+    /**
273
+     * 更新质量规则
274
+     */
275
+    @Transactional
276
+    public QualityRule updateQualityRule(Long id, QualityRule rule) {
277
+        rule.setId(id);
278
+        qualityRuleMapper.updateById(rule);
279
+        return qualityRuleMapper.selectById(id);
280
+    }
281
+
282
+    /**
283
+     * 删除质量规则
284
+     */
285
+    @Transactional
286
+    public void deleteQualityRule(Long id) {
287
+        qualityRuleMapper.deleteById(id);
288
+    }
289
+
290
+    /**
291
+     * 查询质量规则列表
292
+     */
293
+    public List<QualityRule> listQualityRules(String tableName, String ruleType) {
294
+        LambdaQueryWrapper<QualityRule> wrapper = new LambdaQueryWrapper<>();
295
+        if (tableName != null && !tableName.isEmpty()) {
296
+            wrapper.eq(QualityRule::getTableName, tableName);
297
+        }
298
+        if (ruleType != null && !ruleType.isEmpty()) {
299
+            wrapper.eq(QualityRule::getRuleType, ruleType);
300
+        }
301
+        return qualityRuleMapper.selectList(wrapper);
302
+    }
303
+
304
+    // ==================== 私有方法 ====================
305
+
306
+    private void detectAnomalies(Map<String, Object> data) {
307
+        // 流量异常检测(负值)
308
+        if (data.containsKey("LL")) {
309
+            double ll = ((Number) data.get("LL")).doubleValue();
310
+            if (ll < 0) {
311
+                data.put("LL_flag", "ABNORMAL");
312
+            }
313
+        }
314
+
315
+        // 压力异常检测(超范围)
316
+        if (data.containsKey("YL")) {
317
+            double yl = ((Number) data.get("YL")).doubleValue();
318
+            if (yl < 0 || yl > 100) {
319
+                data.put("YL_flag", "ABNORMAL");
320
+            }
321
+        }
322
+
323
+        // 水位异常检测
324
+        if (data.containsKey("SW")) {
325
+            double sw = ((Number) data.get("SW")).doubleValue();
326
+            if (sw < -100 || sw > 1000) {
327
+                data.put("SW_flag", "ABNORMAL");
328
+            }
329
+        }
330
+
331
+        // 浊度异常检测
332
+        if (data.containsKey("ZD")) {
333
+            double zd = ((Number) data.get("ZD")).doubleValue();
334
+            if (zd < 0 || zd > 1000) {
335
+                data.put("ZD_flag", "ABNORMAL");
336
+            }
337
+        }
338
+    }
339
+
340
+    private void convertDataTypes(Map<String, Object> data) {
341
+        for (String field : NUMERIC_FIELDS) {
342
+            Object value = data.get(field);
343
+            if (value instanceof String) {
344
+                try {
345
+                    data.put(field, Double.parseDouble((String) value));
346
+                } catch (NumberFormatException e) {
347
+                    data.put(field + "_flag", "INVALID_TYPE");
348
+                }
349
+            }
350
+        }
351
+    }
352
+
353
+    private boolean executeSingleRule(QualityRule rule) {
354
+        // 简化实现:根据规则类型执行不同检查
355
+        return switch (rule.getRuleType()) {
356
+            case "completeness" -> checkCompleteness(rule);
357
+            case "validity" -> checkValidity(rule);
358
+            case "timeliness" -> checkTimeliness(rule);
359
+            default -> true;
360
+        };
361
+    }
362
+
363
+    private boolean checkCompleteness(QualityRule rule) {
364
+        String sql = String.format(
365
+            "SELECT COUNT(*) FROM %s WHERE %s IS NULL",
366
+            rule.getTableName(), rule.getColumnName());
367
+        Integer nullCount = jdbcTemplate.queryForObject(sql, Integer.class);
368
+        return nullCount == null || nullCount == 0;
369
+    }
370
+
371
+    private boolean checkValidity(QualityRule rule) {
372
+        // 简化:检查是否有无效值
373
+        return true;
374
+    }
375
+
376
+    private boolean checkTimeliness(QualityRule rule) {
377
+        // 简化:检查数据时效性
378
+        return true;
379
+    }
380
+}

+ 297
- 0
wm-data-engine/src/main/java/com/water/data_engine/service/DataIngestService.java Прегледај датотеку

@@ -0,0 +1,297 @@
1
+package com.water.data_engine.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.data_engine.entity.DataSource;
5
+import com.water.data_engine.mapper.DataSourceMapper;
6
+import lombok.RequiredArgsConstructor;
7
+import lombok.extern.slf4j.Slf4j;
8
+import org.springframework.jdbc.core.JdbcTemplate;
9
+import org.springframework.stereotype.Service;
10
+import org.springframework.transaction.annotation.Transactional;
11
+import org.springframework.web.multipart.MultipartFile;
12
+
13
+import java.io.BufferedReader;
14
+import java.io.InputStreamReader;
15
+import java.nio.charset.StandardCharsets;
16
+import java.time.LocalDateTime;
17
+import java.util.*;
18
+import java.util.stream.Collectors;
19
+
20
+/**
21
+ * 数据接入服务
22
+ * DE-02: RESTful API / WebSocket / 数据库直连
23
+ */
24
+@Slf4j
25
+@Service
26
+@RequiredArgsConstructor
27
+public class DataIngestService {
28
+
29
+    private final DataSourceMapper dataSourceMapper;
30
+    private final JdbcTemplate jdbcTemplate;
31
+    private final DataCollectService collectService;
32
+
33
+    // ==================== RESTful API 接入 ====================
34
+
35
+    /**
36
+     * 通过 API 接入单条数据
37
+     */
38
+    @Transactional
39
+    public String ingestViaApi(String sourceCode, Map<String, Object> data) {
40
+        DataSource source = getSourceByCode(sourceCode);
41
+        if (source == null) {
42
+            throw new RuntimeException("数据源不存在: " + sourceCode);
43
+        }
44
+        if (source.getStatus() != 1) {
45
+            throw new RuntimeException("数据源已禁用: " + sourceCode);
46
+        }
47
+
48
+        // 更新最后同步时间
49
+        source.setLastSyncAt(LocalDateTime.now());
50
+        dataSourceMapper.updateById(source);
51
+
52
+        return collectService.ingestRealtime(source.getCategory(), sourceCode, data);
53
+    }
54
+
55
+    /**
56
+     * 通过 API 批量接入数据
57
+     */
58
+    @Transactional
59
+    public int batchIngestViaApi(String sourceCode, List<Map<String, Object>> dataList) {
60
+        DataSource source = getSourceByCode(sourceCode);
61
+        if (source == null) {
62
+            throw new RuntimeException("数据源不存在: " + sourceCode);
63
+        }
64
+
65
+        List<Map<String, Object>> wrappedList = dataList.stream()
66
+            .map(data -> {
67
+                Map<String, Object> wrapped = new HashMap<>(data);
68
+                wrapped.put("sourceType", source.getCategory());
69
+                wrapped.put("sourceId", sourceCode);
70
+                return wrapped;
71
+            })
72
+            .collect(Collectors.toList());
73
+
74
+        return collectService.batchIngest(wrappedList);
75
+    }
76
+
77
+    // ==================== 数据库直连接入 ====================
78
+
79
+    /**
80
+     * 从外部数据库拉取数据
81
+     */
82
+    @Transactional
83
+    public int pullFromDatabase(Long sourceId, String sql, String targetTable) {
84
+        DataSource source = dataSourceMapper.selectById(sourceId);
85
+        if (source == null) {
86
+            throw new RuntimeException("数据源不存在: " + sourceId);
87
+        }
88
+
89
+        try {
90
+            // 执行查询
91
+            List<Map<String, Object>> results = jdbcTemplate.queryForList(sql);
92
+            int count = 0;
93
+
94
+            for (Map<String, Object> row : results) {
95
+                try {
96
+                    collectService.ingestRealtime("database", source.getSourceCode(), row);
97
+                    count++;
98
+                } catch (Exception e) {
99
+                    log.warn("数据库数据接入失败: {}", e.getMessage());
100
+                }
101
+            }
102
+
103
+            // 更新同步时间
104
+            source.setLastSyncAt(LocalDateTime.now());
105
+            dataSourceMapper.updateById(source);
106
+
107
+            return count;
108
+        } catch (Exception e) {
109
+            log.error("从数据库拉取数据失败: {}", e.getMessage(), e);
110
+            throw new RuntimeException("数据拉取失败: " + e.getMessage());
111
+        }
112
+    }
113
+
114
+    /**
115
+     * 从外部数据库同步到本地表
116
+     */
117
+    @Transactional
118
+    public int syncToTable(Long sourceId, String querySql, String targetTable, List<String> columns) {
119
+        List<Map<String, Object>> results = jdbcTemplate.queryForList(querySql);
120
+        int count = 0;
121
+
122
+        for (Map<String, Object> row : results) {
123
+            try {
124
+                String insertSql = buildInsertSql(targetTable, columns);
125
+                Object[] params = columns.stream()
126
+                    .map(col -> row.get(col))
127
+                    .toArray();
128
+                jdbcTemplate.update(insertSql, params);
129
+                count++;
130
+            } catch (Exception e) {
131
+                log.warn("同步到表失败: {}", e.getMessage());
132
+            }
133
+        }
134
+
135
+        // 更新数据源同步时间
136
+        DataSource source = dataSourceMapper.selectById(sourceId);
137
+        if (source != null) {
138
+            source.setLastSyncAt(LocalDateTime.now());
139
+            dataSourceMapper.updateById(source);
140
+        }
141
+
142
+        return count;
143
+    }
144
+
145
+    // ==================== 文件接入 ====================
146
+
147
+    /**
148
+     * 通过文件(CSV)接入数据
149
+     */
150
+    @Transactional
151
+    public int ingestFromFile(MultipartFile file, String sourceCode) throws Exception {
152
+        DataSource source = getSourceByCode(sourceCode);
153
+        if (source == null) {
154
+            throw new RuntimeException("数据源不存在: " + sourceCode);
155
+        }
156
+
157
+        List<Map<String, Object>> dataList = parseCsv(file);
158
+        return batchIngestViaApi(sourceCode, dataList);
159
+    }
160
+
161
+    // ==================== 数据源管理 ====================
162
+
163
+    /**
164
+     * 创建数据源
165
+     */
166
+    @Transactional
167
+    public DataSource createDataSource(DataSource dataSource) {
168
+        // 检查编码唯一性
169
+        LambdaQueryWrapper<DataSource> wrapper = new LambdaQueryWrapper<>();
170
+        wrapper.eq(DataSource::getSourceCode, dataSource.getSourceCode());
171
+        if (dataSourceMapper.selectCount(wrapper) > 0) {
172
+            throw new RuntimeException("数据源编码已存在: " + dataSource.getSourceCode());
173
+        }
174
+
175
+        dataSource.setStatus(1);
176
+        dataSourceMapper.insert(dataSource);
177
+        return dataSource;
178
+    }
179
+
180
+    /**
181
+     * 更新数据源
182
+     */
183
+    @Transactional
184
+    public DataSource updateDataSource(Long id, DataSource dataSource) {
185
+        DataSource existing = dataSourceMapper.selectById(id);
186
+        if (existing == null) {
187
+            throw new RuntimeException("数据源不存在: " + id);
188
+        }
189
+
190
+        dataSource.setId(id);
191
+        dataSourceMapper.updateById(dataSource);
192
+        return dataSourceMapper.selectById(id);
193
+    }
194
+
195
+    /**
196
+     * 删除数据源
197
+     */
198
+    @Transactional
199
+    public void deleteDataSource(Long id) {
200
+        dataSourceMapper.deleteById(id);
201
+    }
202
+
203
+    /**
204
+     * 查询数据源列表
205
+     */
206
+    public List<DataSource> listDataSources(String sourceType) {
207
+        LambdaQueryWrapper<DataSource> wrapper = new LambdaQueryWrapper<>();
208
+        if (sourceType != null && !sourceType.isEmpty()) {
209
+            wrapper.eq(DataSource::getSourceType, sourceType);
210
+        }
211
+        wrapper.orderByDesc(DataSource::getCreatedAt);
212
+        return dataSourceMapper.selectList(wrapper);
213
+    }
214
+
215
+    /**
216
+     * 获取数据源详情
217
+     */
218
+    public DataSource getDataSource(Long id) {
219
+        return dataSourceMapper.selectById(id);
220
+    }
221
+
222
+    /**
223
+     * 测试数据源连接
224
+     */
225
+    public boolean testConnection(Long id) {
226
+        DataSource source = dataSourceMapper.selectById(id);
227
+        if (source == null) {
228
+            return false;
229
+        }
230
+
231
+        try {
232
+            // 根据数据源类型测试连接
233
+            return switch (source.getSourceType()) {
234
+                case "database" -> testDatabaseConnection(source);
235
+                case "kafka" -> testKafkaConnection(source);
236
+                default -> true;
237
+            };
238
+        } catch (Exception e) {
239
+            log.error("测试连接失败: {}", e.getMessage());
240
+            return false;
241
+        }
242
+    }
243
+
244
+    // ==================== 私有方法 ====================
245
+
246
+    private DataSource getSourceByCode(String sourceCode) {
247
+        LambdaQueryWrapper<DataSource> wrapper = new LambdaQueryWrapper<>();
248
+        wrapper.eq(DataSource::getSourceCode, sourceCode);
249
+        return dataSourceMapper.selectOne(wrapper);
250
+    }
251
+
252
+    private String buildInsertSql(String table, List<String> columns) {
253
+        String cols = String.join(", ", columns);
254
+        String placeholders = columns.stream()
255
+            .map(c -> "?")
256
+            .collect(Collectors.joining(", "));
257
+        return String.format("INSERT INTO %s (%s) VALUES (%s)", table, cols, placeholders);
258
+    }
259
+
260
+    private List<Map<String, Object>> parseCsv(MultipartFile file) throws Exception {
261
+        List<Map<String, Object>> result = new ArrayList<>();
262
+        try (BufferedReader reader = new BufferedReader(
263
+                new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
264
+            String headerLine = reader.readLine();
265
+            if (headerLine == null) {
266
+                return result;
267
+            }
268
+            String[] headers = headerLine.split(",");
269
+
270
+            String line;
271
+            while ((line = reader.readLine()) != null) {
272
+                String[] values = line.split(",");
273
+                Map<String, Object> row = new LinkedHashMap<>();
274
+                for (int i = 0; i < headers.length && i < values.length; i++) {
275
+                    row.put(headers[i].trim(), values[i].trim());
276
+                }
277
+                result.add(row);
278
+            }
279
+        }
280
+        return result;
281
+    }
282
+
283
+    private boolean testDatabaseConnection(DataSource source) {
284
+        // 简单的连接测试
285
+        try {
286
+            jdbcTemplate.queryForObject("SELECT 1", Integer.class);
287
+            return true;
288
+        } catch (Exception e) {
289
+            return false;
290
+        }
291
+    }
292
+
293
+    private boolean testKafkaConnection(DataSource source) {
294
+        // Kafka 连接测试(简化)
295
+        return true;
296
+    }
297
+}

+ 249
- 0
wm-data-engine/src/main/java/com/water/data_engine/service/DataIntegrationService.java Прегледај датотеку

@@ -0,0 +1,249 @@
1
+package com.water.data_engine.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.data_engine.entity.DataLineage;
5
+import com.water.data_engine.entity.SyncTask;
6
+import com.water.data_engine.mapper.DataLineageMapper;
7
+import com.water.data_engine.mapper.SyncTaskMapper;
8
+import lombok.RequiredArgsConstructor;
9
+import lombok.extern.slf4j.Slf4j;
10
+import org.springframework.jdbc.core.JdbcTemplate;
11
+import org.springframework.stereotype.Service;
12
+import org.springframework.transaction.annotation.Transactional;
13
+
14
+import java.time.LocalDateTime;
15
+import java.util.*;
16
+import java.util.stream.Collectors;
17
+
18
+/**
19
+ * 数据集成服务
20
+ * DE-04: 多源异构数据整合
21
+ */
22
+@Slf4j
23
+@Service
24
+@RequiredArgsConstructor
25
+public class DataIntegrationService {
26
+
27
+    private final SyncTaskMapper syncTaskMapper;
28
+    private final DataLineageMapper dataLineageMapper;
29
+    private final JdbcTemplate jdbcTemplate;
30
+    private final DataStorageService storageService;
31
+
32
+    // ==================== 数据同步任务 ====================
33
+
34
+    /**
35
+     * 创建同步任务
36
+     */
37
+    @Transactional
38
+    public SyncTask createSyncTask(SyncTask syncTask) {
39
+        syncTask.setStatus("pending");
40
+        syncTaskMapper.insert(syncTask);
41
+        return syncTask;
42
+    }
43
+
44
+    /**
45
+     * 执行同步任务
46
+     */
47
+    @Transactional
48
+    public int executeSyncTask(Long taskId) {
49
+        SyncTask task = syncTaskMapper.selectById(taskId);
50
+        if (task == null) {
51
+            throw new RuntimeException("同步任务不存在: " + taskId);
52
+        }
53
+
54
+        task.setStatus("running");
55
+        syncTaskMapper.updateById(task);
56
+
57
+        try {
58
+            int count = performSync(task);
59
+            
60
+            task.setStatus("completed");
61
+            task.setLastSyncAt(LocalDateTime.now());
62
+            task.setLastSyncCount((long) count);
63
+            task.setErrorMsg(null);
64
+            syncTaskMapper.updateById(task);
65
+
66
+            return count;
67
+        } catch (Exception e) {
68
+            task.setStatus("failed");
69
+            task.setErrorMsg(e.getMessage());
70
+            syncTaskMapper.updateById(task);
71
+            throw new RuntimeException("同步任务执行失败: " + e.getMessage());
72
+        }
73
+    }
74
+
75
+    /**
76
+     * 执行全量同步
77
+     */
78
+    @Transactional
79
+    public int fullSync(Long sourceId, String sourceTable, String targetTable) {
80
+        try {
81
+            // 查询源表所有数据
82
+            String querySql = "SELECT * FROM " + sourceTable;
83
+            List<Map<String, Object>> data = jdbcTemplate.queryForList(querySql);
84
+
85
+            // 批量写入目标表
86
+            return storageService.batchInsertToPostgres(targetTable, data);
87
+        } catch (Exception e) {
88
+            log.error("全量同步失败: {}", e.getMessage(), e);
89
+            throw new RuntimeException("同步失败: " + e.getMessage());
90
+        }
91
+    }
92
+
93
+    /**
94
+     * 执行增量同步(基于时间戳)
95
+     */
96
+    @Transactional
97
+    public int incrementalSync(Long sourceId, String sourceTable, String targetTable,
98
+                                String timestampColumn, LocalDateTime lastSyncTime) {
99
+        try {
100
+            String querySql = String.format(
101
+                "SELECT * FROM %s WHERE %s > ? ORDER BY %s",
102
+                sourceTable, timestampColumn, timestampColumn);
103
+            List<Map<String, Object>> data = jdbcTemplate.queryForList(querySql, lastSyncTime);
104
+
105
+            return storageService.batchInsertToPostgres(targetTable, data);
106
+        } catch (Exception e) {
107
+            log.error("增量同步失败: {}", e.getMessage(), e);
108
+            throw new RuntimeException("增量同步失败: " + e.getMessage());
109
+        }
110
+    }
111
+
112
+    /**
113
+     * 数据合并(多源整合)
114
+     */
115
+    @Transactional
116
+    public List<Map<String, Object>> mergeData(List<String> sourceTables, 
117
+                                                 String joinColumn,
118
+                                                 List<String> selectColumns) {
119
+        if (sourceTables.isEmpty()) {
120
+            return List.of();
121
+        }
122
+
123
+        // 构建 UNION ALL 查询
124
+        String columnList = String.join(", ", selectColumns);
125
+        String unions = sourceTables.stream()
126
+            .map(table -> String.format("SELECT %s FROM %s", columnList, table))
127
+            .collect(Collectors.joining(" UNION ALL "));
128
+
129
+        String sql = String.format("SELECT %s FROM (%s) AS merged ORDER BY %s DESC", 
130
+                                   columnList, unions, joinColumn);
131
+
132
+        return jdbcTemplate.queryForList(sql);
133
+    }
134
+
135
+    /**
136
+     * 数据聚合(按维度汇总)
137
+     */
138
+    public List<Map<String, Object>> aggregateData(String sourceTable, 
139
+                                                     List<String> groupByColumns,
140
+                                                     Map<String, String> aggregations) {
141
+        String groupBy = String.join(", ", groupByColumns);
142
+        String aggExpr = aggregations.entrySet().stream()
143
+            .map(e -> String.format("%s(%s) AS %s_%s", e.getValue(), e.getKey(), e.getKey(), e.getValue().toLowerCase()))
144
+            .collect(Collectors.joining(", "));
145
+
146
+        String sql = String.format(
147
+            "SELECT %s, %s FROM %s GROUP BY %s ORDER BY %s",
148
+            groupBy, aggExpr, sourceTable, groupBy, groupBy);
149
+
150
+        return jdbcTemplate.queryForList(sql);
151
+    }
152
+
153
+    // ==================== 数据血缘 ====================
154
+
155
+    /**
156
+     * 创建数据血缘关系
157
+     */
158
+    @Transactional
159
+    public DataLineage createLineage(DataLineage lineage) {
160
+        lineage.setCreatedAt(LocalDateTime.now());
161
+        dataLineageMapper.insert(lineage);
162
+        return lineage;
163
+    }
164
+
165
+    /**
166
+     * 查询血缘关系(上游)
167
+     */
168
+    public List<DataLineage> getUpstreamLineage(String tableName) {
169
+        LambdaQueryWrapper<DataLineage> wrapper = new LambdaQueryWrapper<>();
170
+        wrapper.eq(DataLineage::getTargetTable, tableName);
171
+        return dataLineageMapper.selectList(wrapper);
172
+    }
173
+
174
+    /**
175
+     * 查询血缘关系(下游)
176
+     */
177
+    public List<DataLineage> getDownstreamLineage(String tableName) {
178
+        LambdaQueryWrapper<DataLineage> wrapper = new LambdaQueryWrapper<>();
179
+        wrapper.eq(DataLineage::getSourceTable, tableName);
180
+        return dataLineageMapper.selectList(wrapper);
181
+    }
182
+
183
+    /**
184
+     * 查询完整血缘链路
185
+     */
186
+    public Map<String, Object> getFullLineage(String tableName) {
187
+        Map<String, Object> result = new HashMap<>();
188
+        result.put("table", tableName);
189
+        result.put("upstream", getUpstreamLineage(tableName));
190
+        result.put("downstream", getDownstreamLineage(tableName));
191
+        return result;
192
+    }
193
+
194
+    // ==================== 查询方法 ====================
195
+
196
+    /**
197
+     * 查询同步任务列表
198
+     */
199
+    public List<SyncTask> listSyncTasks(String status) {
200
+        LambdaQueryWrapper<SyncTask> wrapper = new LambdaQueryWrapper<>();
201
+        if (status != null && !status.isEmpty()) {
202
+            wrapper.eq(SyncTask::getStatus, status);
203
+        }
204
+        wrapper.orderByDesc(SyncTask::getCreatedAt);
205
+        return syncTaskMapper.selectList(wrapper);
206
+    }
207
+
208
+    /**
209
+     * 获取同步任务详情
210
+     */
211
+    public SyncTask getSyncTask(Long id) {
212
+        return syncTaskMapper.selectById(id);
213
+    }
214
+
215
+    /**
216
+     * 删除同步任务
217
+     */
218
+    @Transactional
219
+    public void deleteSyncTask(Long id) {
220
+        syncTaskMapper.deleteById(id);
221
+    }
222
+
223
+    // ==================== 私有方法 ====================
224
+
225
+    private int performSync(SyncTask task) {
226
+        // 根据同步类型执行不同策略
227
+        return switch (task.getSyncType()) {
228
+            case "full" -> performFullSync(task);
229
+            case "incremental" -> performIncrementalSync(task);
230
+            default -> 0;
231
+        };
232
+    }
233
+
234
+    private int performFullSync(SyncTask task) {
235
+        // 简化实现:实际应根据 sourceId 查找数据源配置
236
+        log.info("执行全量同步任务: {}", task.getTaskName());
237
+        return 0;
238
+    }
239
+
240
+    private int performIncrementalSync(SyncTask task) {
241
+        LocalDateTime lastSync = task.getLastSyncAt();
242
+        if (lastSync == null) {
243
+            // 首次同步,使用默认起始时间
244
+            lastSync = LocalDateTime.now().minusDays(1);
245
+        }
246
+        log.info("执行增量同步任务: {}, lastSync: {}", task.getTaskName(), lastSync);
247
+        return 0;
248
+    }
249
+}

+ 345
- 0
wm-data-engine/src/main/java/com/water/data_engine/service/DataStorageService.java Прегледај датотеку

@@ -0,0 +1,345 @@
1
+package com.water.data_engine.service;
2
+
3
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
4
+import com.water.data_engine.entity.StorageConfig;
5
+import com.water.data_engine.mapper.StorageConfigMapper;
6
+import io.minio.*;
7
+import lombok.RequiredArgsConstructor;
8
+import lombok.extern.slf4j.Slf4j;
9
+import org.springframework.jdbc.core.JdbcTemplate;
10
+import org.springframework.stereotype.Service;
11
+import org.springframework.transaction.annotation.Transactional;
12
+import org.springframework.web.multipart.MultipartFile;
13
+
14
+import java.io.InputStream;
15
+import java.time.LocalDate;
16
+import java.time.LocalDateTime;
17
+import java.util.*;
18
+import java.util.stream.Collectors;
19
+
20
+/**
21
+ * 数据存储管理服务
22
+ * DE-03: TDengine + PostgreSQL + MinIO
23
+ */
24
+@Slf4j
25
+@Service
26
+@RequiredArgsConstructor
27
+public class DataStorageService {
28
+
29
+    private final StorageConfigMapper storageConfigMapper;
30
+    private final JdbcTemplate jdbcTemplate;
31
+
32
+    // ==================== TDengine 时序存储 ====================
33
+
34
+    /**
35
+     * 写入遥测数据到 TDengine
36
+     */
37
+    public void writeToTDengine(String deviceSn, String deviceType, String area,
38
+                                 String metricKey, Double value) {
39
+        try {
40
+            // 使用子表方式写入(按设备分表)
41
+            String childTable = "device_" + deviceSn.replaceAll("[^a-zA-Z0-9]", "_");
42
+            String createTableSql = String.format(
43
+                "CREATE TABLE IF NOT EXISTS water_iot.%s USING water_iot.iot_telemetry TAGS('%s', '%s', '%s')",
44
+                childTable, deviceType, area, deviceSn);
45
+            jdbcTemplate.update(createTableSql);
46
+
47
+            String insertSql = String.format(
48
+                "INSERT INTO water_iot.%s (ts, device_sn, metric_key, metric_value, quality) VALUES (NOW, '%s', '%s', %f, 1)",
49
+                childTable, deviceSn, metricKey, value);
50
+            jdbcTemplate.update(insertSql);
51
+        } catch (Exception e) {
52
+            log.error("写入 TDengine 失败: {}", e.getMessage());
53
+        }
54
+    }
55
+
56
+    /**
57
+     * 批量写入遥测数据
58
+     */
59
+    @Transactional
60
+    public int batchWriteToTDengine(List<Map<String, Object>> dataList) {
61
+        int count = 0;
62
+        for (Map<String, Object> data : dataList) {
63
+            try {
64
+                writeToTDengine(
65
+                    (String) data.get("deviceSn"),
66
+                    (String) data.get("deviceType"),
67
+                    (String) data.get("area"),
68
+                    (String) data.get("metricKey"),
69
+                    ((Number) data.get("value")).doubleValue()
70
+                );
71
+                count++;
72
+            } catch (Exception e) {
73
+                log.warn("批量写入单条失败: {}", e.getMessage());
74
+            }
75
+        }
76
+        return count;
77
+    }
78
+
79
+    /**
80
+     * 从 TDengine 查询遥测数据
81
+     */
82
+    public List<Map<String, Object>> queryFromTDengine(String deviceSn, String metricKey,
83
+                                                        LocalDateTime startTime, LocalDateTime endTime) {
84
+        try {
85
+            String sql = """
86
+                SELECT ts, device_sn, metric_key, metric_value, quality 
87
+                FROM water_iot.iot_telemetry 
88
+                WHERE device_sn = ? AND metric_key = ? 
89
+                AND ts >= ? AND ts <= ?
90
+                ORDER BY ts DESC
91
+                """;
92
+            return jdbcTemplate.queryForList(sql, deviceSn, metricKey, startTime, endTime);
93
+        } catch (Exception e) {
94
+            log.error("查询 TDengine 失败: {}", e.getMessage());
95
+            return List.of();
96
+        }
97
+    }
98
+
99
+    /**
100
+     * 查询聚合数据(小时级)
101
+     */
102
+    public List<Map<String, Object>> queryHourlyAgg(String deviceSn, String metricKey,
103
+                                                     LocalDateTime startTime, LocalDateTime endTime) {
104
+        try {
105
+            String sql = """
106
+                SELECT _wstart as ts, device_sn, metric_key,
107
+                       MIN(metric_value) as min_val, MAX(metric_value) as max_val,
108
+                       AVG(metric_value) as avg_val, COUNT(*) as cnt
109
+                FROM water_iot.iot_telemetry
110
+                WHERE device_sn = ? AND metric_key = ?
111
+                AND ts >= ? AND ts <= ?
112
+                INTERVAL(1h)
113
+                """;
114
+            return jdbcTemplate.queryForList(sql, deviceSn, metricKey, startTime, endTime);
115
+        } catch (Exception e) {
116
+            log.error("查询聚合数据失败: {}", e.getMessage());
117
+            return List.of();
118
+        }
119
+    }
120
+
121
+    // ==================== PostgreSQL 关系存储 ====================
122
+
123
+    /**
124
+     * 通用数据插入(PostgreSQL)
125
+     */
126
+    @Transactional
127
+    public Long insertToPostgres(String table, Map<String, Object> data) {
128
+        List<String> columns = new ArrayList<>(data.keySet());
129
+        String cols = String.join(", ", columns);
130
+        String placeholders = columns.stream().map(c -> "?").collect(Collectors.joining(", "));
131
+        String sql = String.format("INSERT INTO %s (%s) VALUES (%s) RETURNING id", table, cols, placeholders);
132
+
133
+        Object[] params = columns.stream().map(data::get).toArray();
134
+        return jdbcTemplate.queryForObject(sql, Long.class, params);
135
+    }
136
+
137
+    /**
138
+     * 批量插入(PostgreSQL)
139
+     */
140
+    @Transactional
141
+    public int batchInsertToPostgres(String table, List<Map<String, Object>> dataList) {
142
+        if (dataList.isEmpty()) {
143
+            return 0;
144
+        }
145
+
146
+        int count = 0;
147
+        for (Map<String, Object> data : dataList) {
148
+            try {
149
+                insertToPostgres(table, data);
150
+                count++;
151
+            } catch (Exception e) {
152
+                log.warn("批量插入单条失败: {}", e.getMessage());
153
+            }
154
+        }
155
+        return count;
156
+    }
157
+
158
+    /**
159
+     * 更新数据(PostgreSQL)
160
+     */
161
+    @Transactional
162
+    public int updateInPostgres(String table, Long id, Map<String, Object> data) {
163
+        List<String> setClauses = new ArrayList<>();
164
+        List<Object> params = new ArrayList<>();
165
+
166
+        for (Map.Entry<String, Object> entry : data.entrySet()) {
167
+            setClauses.add(entry.getKey() + " = ?");
168
+            params.add(entry.getValue());
169
+        }
170
+        params.add(id);
171
+
172
+        String sql = String.format("UPDATE %s SET %s WHERE id = ?", table, String.join(", ", setClauses));
173
+        return jdbcTemplate.update(sql, params.toArray());
174
+    }
175
+
176
+    /**
177
+     * 查询数据(PostgreSQL)
178
+     */
179
+    public List<Map<String, Object>> queryFromPostgres(String table, Map<String, Object> conditions,
180
+                                                        int page, int size) {
181
+        StringBuilder sql = new StringBuilder("SELECT * FROM ").append(table).append(" WHERE 1=1");
182
+        List<Object> params = new ArrayList<>();
183
+
184
+        for (Map.Entry<String, Object> entry : conditions.entrySet()) {
185
+            sql.append(" AND ").append(entry.getKey()).append(" = ?");
186
+            params.add(entry.getValue());
187
+        }
188
+
189
+        sql.append(" ORDER BY id DESC LIMIT ? OFFSET ?");
190
+        params.add(size);
191
+        params.add((page - 1) * size);
192
+
193
+        return jdbcTemplate.queryForList(sql.toString(), params.toArray());
194
+    }
195
+
196
+    // ==================== MinIO 对象存储 ====================
197
+
198
+    /**
199
+     * 上传文件到 MinIO
200
+     */
201
+    public String uploadToMinio(MultipartFile file, String module) throws Exception {
202
+        MinioClient client = getMinioClient();
203
+        String bucket = "water-management";
204
+        String objectName = module + "/" + LocalDate.now() + "/" + 
205
+                           UUID.randomUUID() + "_" + file.getOriginalFilename();
206
+
207
+        // 确保桶存在
208
+        if (!client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
209
+            client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
210
+        }
211
+
212
+        client.putObject(PutObjectArgs.builder()
213
+            .bucket(bucket)
214
+            .object(objectName)
215
+            .stream(file.getInputStream(), file.getSize(), -1)
216
+            .contentType(file.getContentType())
217
+            .build());
218
+
219
+        return objectName;
220
+    }
221
+
222
+    /**
223
+     * 从 MinIO 下载文件
224
+     */
225
+    public InputStream downloadFromMinio(String objectName) throws Exception {
226
+        MinioClient client = getMinioClient();
227
+        return client.getObject(GetObjectArgs.builder()
228
+            .bucket("water-management")
229
+            .object(objectName)
230
+            .build());
231
+    }
232
+
233
+    /**
234
+     * 列出 MinIO 文件
235
+     */
236
+    public List<String> listMinioObjects(String prefix) throws Exception {
237
+        MinioClient client = getMinioClient();
238
+        List<String> objects = new ArrayList<>();
239
+
240
+        Iterable<Result<Item>> results = client.listObjects(ListObjectsArgs.builder()
241
+            .bucket("water-management")
242
+            .prefix(prefix)
243
+            .build());
244
+
245
+        for (Result<Item> result : results) {
246
+            objects.add(result.get().objectName());
247
+        }
248
+
249
+        return objects;
250
+    }
251
+
252
+    // ==================== 存储配置管理 ====================
253
+
254
+    /**
255
+     * 创建存储配置
256
+     */
257
+    @Transactional
258
+    public StorageConfig createStorageConfig(StorageConfig config) {
259
+        config.setStatus(1);
260
+        storageConfigMapper.insert(config);
261
+        return config;
262
+    }
263
+
264
+    /**
265
+     * 更新存储配置
266
+     */
267
+    @Transactional
268
+    public StorageConfig updateStorageConfig(Long id, StorageConfig config) {
269
+        config.setId(id);
270
+        storageConfigMapper.updateById(config);
271
+        return storageConfigMapper.selectById(id);
272
+    }
273
+
274
+    /**
275
+     * 查询存储配置列表
276
+     */
277
+    public List<StorageConfig> listStorageConfigs(String storageType) {
278
+        LambdaQueryWrapper<StorageConfig> wrapper = new LambdaQueryWrapper<>();
279
+        if (storageType != null && !storageType.isEmpty()) {
280
+            wrapper.eq(StorageConfig::getStorageType, storageType);
281
+        }
282
+        return storageConfigMapper.selectList(wrapper);
283
+    }
284
+
285
+    /**
286
+     * 测试存储连接
287
+     */
288
+    public boolean testStorageConnection(Long id) {
289
+        StorageConfig config = storageConfigMapper.selectById(id);
290
+        if (config == null) {
291
+            return false;
292
+        }
293
+
294
+        try {
295
+            return switch (config.getStorageType()) {
296
+                case "postgresql" -> testPostgresConnection(config);
297
+                case "tdengine" -> testTDengineConnection(config);
298
+                case "minio" -> testMinioConnection(config);
299
+                default -> false;
300
+            };
301
+        } catch (Exception e) {
302
+            log.error("测试存储连接失败: {}", e.getMessage());
303
+            return false;
304
+        }
305
+    }
306
+
307
+    // ==================== 私有方法 ====================
308
+
309
+    private MinioClient getMinioClient() {
310
+        return MinioClient.builder()
311
+            .endpoint(System.getenv().getOrDefault("MINIO_ENDPOINT", "http://127.0.0.1:9000"))
312
+            .credentials(
313
+                System.getenv().getOrDefault("MINIO_ACCESS_KEY", "minioadmin"),
314
+                System.getenv().getOrDefault("MINIO_SECRET_KEY", "minioadmin"))
315
+            .build();
316
+    }
317
+
318
+    private boolean testPostgresConnection(StorageConfig config) {
319
+        try {
320
+            jdbcTemplate.queryForObject("SELECT 1", Integer.class);
321
+            return true;
322
+        } catch (Exception e) {
323
+            return false;
324
+        }
325
+    }
326
+
327
+    private boolean testTDengineConnection(StorageConfig config) {
328
+        try {
329
+            jdbcTemplate.queryForObject("SELECT server_version()", String.class);
330
+            return true;
331
+        } catch (Exception e) {
332
+            return false;
333
+        }
334
+    }
335
+
336
+    private boolean testMinioConnection(StorageConfig config) {
337
+        try {
338
+            MinioClient client = getMinioClient();
339
+            client.bucketExists(BucketExistsArgs.builder().bucket("water-management").build());
340
+            return true;
341
+        } catch (Exception e) {
342
+            return false;
343
+        }
344
+    }
345
+}

+ 83
- 0
wm-data-engine/src/main/java/com/water/data_engine/websocket/DataWebSocketController.java Прегледај датотеку

@@ -0,0 +1,83 @@
1
+package com.water.data_engine.websocket;
2
+
3
+import com.water.data_engine.service.DataCollectService;
4
+import lombok.RequiredArgsConstructor;
5
+import lombok.extern.slf4j.Slf4j;
6
+import org.springframework.messaging.handler.annotation.MessageMapping;
7
+import org.springframework.messaging.handler.annotation.SendTo;
8
+import org.springframework.messaging.simp.SimpMessagingTemplate;
9
+import org.springframework.stereotype.Controller;
10
+
11
+import java.time.LocalDateTime;
12
+import java.util.LinkedHashMap;
13
+import java.util.Map;
14
+
15
+/**
16
+ * WebSocket 数据推送控制器
17
+ * 用于实时数据推送到前端
18
+ */
19
+@Slf4j
20
+@Controller
21
+@RequiredArgsConstructor
22
+public class DataWebSocketController {
23
+
24
+    private final SimpMessagingTemplate messagingTemplate;
25
+    private final DataCollectService collectService;
26
+
27
+    /**
28
+     * 接收客户端订阅请求
29
+     */
30
+    @MessageMapping("/subscribe/data")
31
+    @SendTo("/topic/data/realtime")
32
+    public Map<String, Object> subscribeRealtimeData(Map<String, Object> request) {
33
+        Map<String, Object> response = new LinkedHashMap<>();
34
+        response.put("status", "subscribed");
35
+        response.put("timestamp", LocalDateTime.now().toString());
36
+        response.put("message", "已订阅实时数据推送");
37
+        return response;
38
+    }
39
+
40
+    /**
41
+     * 接收客户端发送的控制指令
42
+     */
43
+    @MessageMapping("/control/pause")
44
+    @SendTo("/topic/data/control")
45
+    public Map<String, Object> pauseDataPush(Map<String, Object> request) {
46
+        Map<String, Object> response = new LinkedHashMap<>();
47
+        response.put("action", "pause");
48
+        response.put("status", "success");
49
+        response.put("timestamp", LocalDateTime.now().toString());
50
+        return response;
51
+    }
52
+
53
+    @MessageMapping("/control/resume")
54
+    @SendTo("/topic/data/control")
55
+    public Map<String, Object> resumeDataPush(Map<String, Object> request) {
56
+        Map<String, Object> response = new LinkedHashMap<>();
57
+        response.put("action", "resume");
58
+        response.put("status", "success");
59
+        response.put("timestamp", LocalDateTime.now().toString());
60
+        return response;
61
+    }
62
+
63
+    /**
64
+     * 主动推送数据到指定 topic
65
+     */
66
+    public void pushRealtimeData(String sourceType, Map<String, Object> data) {
67
+        messagingTemplate.convertAndSend("/topic/data/realtime/" + sourceType, data);
68
+    }
69
+
70
+    /**
71
+     * 推送告警数据
72
+     */
73
+    public void pushAlertData(Map<String, Object> alert) {
74
+        messagingTemplate.convertAndSend("/topic/data/alert", alert);
75
+    }
76
+
77
+    /**
78
+     * 推送统计数据
79
+     */
80
+    public void pushStatistics(Map<String, Object> stats) {
81
+        messagingTemplate.convertAndSend("/topic/data/statistics", stats);
82
+    }
83
+}

+ 34
- 0
wm-data-engine/src/main/resources/application.yml Прегледај датотеку

@@ -8,10 +8,44 @@ spring:
8 8
     url: jdbc:postgresql://${PG_HOST:127.0.0.1}:5432/water_management
9 9
     username: ${PG_USER:water}
10 10
     password: ${PG_PASS:water123}
11
+    driver-class-name: org.postgresql.Driver
11 12
   cloud:
12 13
     nacos:
13 14
       discovery:
14 15
         server-addr: ${NACOS_HOST:127.0.0.1}:8848
16
+  kafka:
17
+    bootstrap-servers: ${KAFKA_SERVERS:127.0.0.1}:9092
18
+    consumer:
19
+      group-id: wm-data-engine
20
+      auto-offset-reset: latest
21
+    producer:
22
+      key-serializer: org.apache.kafka.common.serialization.StringSerializer
23
+      value-serializer: org.apache.kafka.common.serialization.StringSerializer
24
+  servlet:
25
+    multipart:
26
+      max-file-size: 100MB
27
+      max-request-size: 100MB
15 28
 
16 29
 mybatis-plus:
17 30
   mapper-locations: classpath*:/mapper/**/*.xml
31
+  configuration:
32
+    map-underscore-to-camel-case: true
33
+    log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
34
+  global-config:
35
+    db-config:
36
+      logic-delete-field: deleted
37
+      logic-delete-value: 1
38
+      logic-not-delete-value: 0
39
+
40
+# MinIO 配置
41
+minio:
42
+  endpoint: ${MINIO_ENDPOINT:http://127.0.0.1:9000}
43
+  access-key: ${MINIO_ACCESS_KEY:minioadmin}
44
+  secret-key: ${MINIO_SECRET_KEY:minioadmin}
45
+  bucket: water-management
46
+
47
+# 日志配置
48
+logging:
49
+  level:
50
+    com.water.data_engine: DEBUG
51
+    com.baomidou.mybatisplus: DEBUG

+ 217
- 0
wm-data-engine/src/main/resources/db/V1__data_engine.sql Прегледај датотеку

@@ -0,0 +1,217 @@
1
+-- =============================================
2
+-- 智慧水务管理系统 - 数据引擎 DDL
3
+-- 版本: V1
4
+-- 描述: 数据汇聚引擎相关表
5
+-- =============================================
6
+
7
+-- ==================== 数据源管理 ====================
8
+
9
+-- 数据源配置表
10
+CREATE TABLE IF NOT EXISTS de_data_source (
11
+    id BIGSERIAL PRIMARY KEY,
12
+    source_name VARCHAR(100) NOT NULL,
13
+    source_code VARCHAR(50) UNIQUE NOT NULL,
14
+    source_type VARCHAR(30) NOT NULL,           -- mqtt/kafka/rest/websocket/database/file
15
+    category VARCHAR(30),                        -- iot/manual/api/database
16
+    connection_config JSONB,                     -- 连接配置(JSON)
17
+    sync_mode VARCHAR(20) DEFAULT 'realtime',    -- realtime/batch/scheduled
18
+    sync_cron VARCHAR(50),                       -- 定时同步Cron表达式
19
+    status SMALLINT DEFAULT 1,                   -- 0:禁用 1:启用
20
+    description VARCHAR(500),
21
+    last_sync_at TIMESTAMP,
22
+    deleted SMALLINT DEFAULT 0,
23
+    created_at TIMESTAMP DEFAULT NOW(),
24
+    updated_at TIMESTAMP DEFAULT NOW()
25
+);
26
+COMMENT ON TABLE de_data_source IS '数据源配置表';
27
+COMMENT ON COLUMN de_data_source.source_type IS '数据源类型: mqtt/kafka/rest/websocket/database/file';
28
+COMMENT ON COLUMN de_data_source.sync_mode IS '同步模式: realtime/batch/scheduled';
29
+
30
+CREATE INDEX IF NOT EXISTS idx_de_data_source_type ON de_data_source(source_type);
31
+CREATE INDEX IF NOT EXISTS idx_de_data_source_status ON de_data_source(status);
32
+
33
+-- ==================== 数据采集 ====================
34
+
35
+-- 数据采集任务表
36
+CREATE TABLE IF NOT EXISTS de_collect_task (
37
+    id BIGSERIAL PRIMARY KEY,
38
+    task_name VARCHAR(100) NOT NULL,
39
+    source_id BIGINT REFERENCES de_data_source(id),
40
+    collect_type VARCHAR(30) NOT NULL,           -- realtime/batch/manual
41
+    topic VARCHAR(100),                          -- Kafka/MQTT topic
42
+    target_table VARCHAR(100),                   -- 目标表名
43
+    transform_rule JSONB,                        -- 转换规则
44
+    status VARCHAR(20) DEFAULT 'pending',        -- pending/running/paused/completed/failed
45
+    total_count BIGINT DEFAULT 0,
46
+    success_count BIGINT DEFAULT 0,
47
+    fail_count BIGINT DEFAULT 0,
48
+    start_time TIMESTAMP,
49
+    end_time TIMESTAMP,
50
+    error_msg TEXT,
51
+    deleted SMALLINT DEFAULT 0,
52
+    created_at TIMESTAMP DEFAULT NOW(),
53
+    updated_at TIMESTAMP DEFAULT NOW()
54
+);
55
+COMMENT ON TABLE de_collect_task IS '数据采集任务表';
56
+CREATE INDEX IF NOT EXISTS idx_de_collect_task_status ON de_collect_task(status);
57
+CREATE INDEX IF NOT EXISTS idx_de_collect_task_source ON de_collect_task(source_id);
58
+
59
+-- 数据采集记录表
60
+CREATE TABLE IF NOT EXISTS de_collect_record (
61
+    id BIGSERIAL PRIMARY KEY,
62
+    task_id BIGINT REFERENCES de_collect_task(id),
63
+    source_id BIGINT REFERENCES de_data_source(id),
64
+    source_type VARCHAR(30),
65
+    source_key VARCHAR(100),
66
+    raw_data JSONB,
67
+    processed_data JSONB,
68
+    status VARCHAR(20) DEFAULT 'success',        -- success/failed/skipped
69
+    error_msg VARCHAR(500),
70
+    collect_time TIMESTAMP DEFAULT NOW()
71
+);
72
+COMMENT ON TABLE de_collect_record IS '数据采集记录表';
73
+CREATE INDEX IF NOT EXISTS idx_de_collect_record_time ON de_collect_record(collect_time DESC);
74
+CREATE INDEX IF NOT EXISTS idx_de_collect_record_task ON de_collect_record(task_id);
75
+
76
+-- ==================== 数据接入 ====================
77
+
78
+-- API接入配置表
79
+CREATE TABLE IF NOT EXISTS de_api_config (
80
+    id BIGSERIAL PRIMARY KEY,
81
+    api_name VARCHAR(100) NOT NULL,
82
+    api_path VARCHAR(200) UNIQUE NOT NULL,
83
+    method VARCHAR(10) DEFAULT 'POST',           -- GET/POST/PUT
84
+    source_id BIGINT REFERENCES de_data_source(id),
85
+    request_schema JSONB,                        -- 请求Schema定义
86
+    response_schema JSONB,                       -- 响应Schema定义
87
+    auth_type VARCHAR(20) DEFAULT 'none',        -- none/token/api_key/basic
88
+    rate_limit INT DEFAULT 100,                  -- 限流(次/分钟)
89
+    status SMALLINT DEFAULT 1,
90
+    deleted SMALLINT DEFAULT 0,
91
+    created_at TIMESTAMP DEFAULT NOW(),
92
+    updated_at TIMESTAMP DEFAULT NOW()
93
+);
94
+COMMENT ON TABLE de_api_config IS 'API接入配置表';
95
+
96
+-- ==================== 数据存储 ====================
97
+
98
+-- 存储配置表
99
+CREATE TABLE IF NOT EXISTS de_storage_config (
100
+    id BIGSERIAL PRIMARY KEY,
101
+    storage_name VARCHAR(100) NOT NULL,
102
+    storage_type VARCHAR(30) NOT NULL,           -- tdengine/postgresql/minio
103
+    connection_url VARCHAR(500),
104
+    username VARCHAR(100),
105
+    password VARCHAR(255),
106
+    database_name VARCHAR(100),
107
+    bucket_name VARCHAR(100),
108
+    extra_config JSONB,
109
+    status SMALLINT DEFAULT 1,
110
+    deleted SMALLINT DEFAULT 0,
111
+    created_at TIMESTAMP DEFAULT NOW(),
112
+    updated_at TIMESTAMP DEFAULT NOW()
113
+);
114
+COMMENT ON TABLE de_storage_config IS '存储配置表';
115
+
116
+-- 存储路由规则表(哪类数据存到哪)
117
+CREATE TABLE IF NOT EXISTS de_storage_route (
118
+    id BIGSERIAL PRIMARY KEY,
119
+    source_type VARCHAR(30) NOT NULL,
120
+    data_category VARCHAR(50),                   -- telemetry/quality/billing/document
121
+    storage_id BIGINT REFERENCES de_storage_config(id),
122
+    target_table VARCHAR(100),
123
+    partition_rule VARCHAR(200),                 -- 分区规则
124
+    retention_days INT DEFAULT 365,
125
+    status SMALLINT DEFAULT 1,
126
+    created_at TIMESTAMP DEFAULT NOW()
127
+);
128
+COMMENT ON TABLE de_storage_route IS '存储路由规则表';
129
+
130
+-- ==================== 数据集成 ====================
131
+
132
+-- 数据同步任务表
133
+CREATE TABLE IF NOT EXISTS de_sync_task (
134
+    id BIGSERIAL PRIMARY KEY,
135
+    task_name VARCHAR(100) NOT NULL,
136
+    source_id BIGINT REFERENCES de_data_source(id),
137
+    target_storage_id BIGINT REFERENCES de_storage_config(id),
138
+    sync_type VARCHAR(30) NOT NULL,              -- full/incremental/cdc
139
+    sync_cron VARCHAR(50),
140
+    last_sync_at TIMESTAMP,
141
+    last_sync_count BIGINT,
142
+    status VARCHAR(20) DEFAULT 'pending',        -- pending/running/paused/completed/failed
143
+    error_msg TEXT,
144
+    deleted SMALLINT DEFAULT 0,
145
+    created_at TIMESTAMP DEFAULT NOW(),
146
+    updated_at TIMESTAMP DEFAULT NOW()
147
+);
148
+COMMENT ON TABLE de_sync_task IS '数据同步任务表';
149
+CREATE INDEX IF NOT EXISTS idx_de_sync_task_status ON de_sync_task(status);
150
+
151
+-- ==================== 数据质量 ====================
152
+
153
+-- 数据质量规则表
154
+CREATE TABLE IF NOT EXISTS de_quality_rule (
155
+    id BIGSERIAL PRIMARY KEY,
156
+    rule_name VARCHAR(100) NOT NULL,
157
+    rule_type VARCHAR(30) NOT NULL,              -- completeness/validity/timeliness/consistency
158
+    table_name VARCHAR(100),
159
+    column_name VARCHAR(100),
160
+    rule_expr VARCHAR(500),                      -- 规则表达式
161
+    threshold DECIMAL(5,2),                      -- 阈值
162
+    severity VARCHAR(20) DEFAULT 'warning',      -- info/warning/error
163
+    enabled SMALLINT DEFAULT 1,
164
+    created_at TIMESTAMP DEFAULT NOW(),
165
+    updated_at TIMESTAMP DEFAULT NOW()
166
+);
167
+COMMENT ON TABLE de_quality_rule IS '数据质量规则表';
168
+
169
+-- 数据质量检查记录表
170
+CREATE TABLE IF NOT EXISTS de_quality_check (
171
+    id BIGSERIAL PRIMARY KEY,
172
+    rule_id BIGINT REFERENCES de_quality_rule(id),
173
+    check_time TIMESTAMP DEFAULT NOW(),
174
+    total_count BIGINT,
175
+    pass_count BIGINT,
176
+    fail_count BIGINT,
177
+    pass_rate DECIMAL(5,2),
178
+    result_detail JSONB,
179
+    status VARCHAR(20) DEFAULT 'success'         -- success/failed
180
+);
181
+COMMENT ON TABLE de_quality_check IS '数据质量检查记录表';
182
+CREATE INDEX IF NOT EXISTS idx_de_quality_check_time ON de_quality_check(check_time DESC);
183
+
184
+-- ==================== 数据血缘 ====================
185
+
186
+-- 数据血缘关系表
187
+CREATE TABLE IF NOT EXISTS de_data_lineage (
188
+    id BIGSERIAL PRIMARY KEY,
189
+    source_table VARCHAR(100) NOT NULL,
190
+    source_column VARCHAR(100),
191
+    target_table VARCHAR(100) NOT NULL,
192
+    target_column VARCHAR(100),
193
+    transform_type VARCHAR(30),                  -- direct/mapping/aggregation/calculation
194
+    transform_rule TEXT,
195
+    description VARCHAR(500),
196
+    created_at TIMESTAMP DEFAULT NOW()
197
+);
198
+COMMENT ON TABLE de_data_lineage IS '数据血缘关系表';
199
+CREATE INDEX IF NOT EXISTS idx_de_lineage_source ON de_data_lineage(source_table);
200
+CREATE INDEX IF NOT EXISTS idx_de_lineage_target ON de_data_lineage(target_table);
201
+
202
+-- ==================== 数据引擎统计 ====================
203
+
204
+-- 数据统计仪表板
205
+CREATE TABLE IF NOT EXISTS de_stat_daily (
206
+    id BIGSERIAL PRIMARY KEY,
207
+    stat_date DATE NOT NULL,
208
+    source_id BIGINT,
209
+    collect_count BIGINT DEFAULT 0,
210
+    store_count BIGINT DEFAULT 0,
211
+    quality_score DECIMAL(5,2),
212
+    sync_count BIGINT DEFAULT 0,
213
+    error_count BIGINT DEFAULT 0,
214
+    created_at TIMESTAMP DEFAULT NOW(),
215
+    UNIQUE(stat_date, source_id)
216
+);
217
+COMMENT ON TABLE de_stat_daily IS '日统计数据表';

+ 128
- 0
wm-data-engine/src/test/java/com/water/data_engine/service/DataCollectServiceTest.java Прегледај датотеку

@@ -0,0 +1,128 @@
1
+package com.water.data_engine.service;
2
+
3
+import org.junit.jupiter.api.BeforeEach;
4
+import org.junit.jupiter.api.DisplayName;
5
+import org.junit.jupiter.api.Test;
6
+import org.junit.jupiter.api.extension.ExtendWith;
7
+import org.mockito.Mock;
8
+import org.mockito.junit.jupiter.MockitoExtension;
9
+import org.springframework.jdbc.core.JdbcTemplate;
10
+import org.springframework.kafka.core.KafkaTemplate;
11
+import org.springframework.messaging.simp.SimpMessagingTemplate;
12
+
13
+import java.util.HashMap;
14
+import java.util.List;
15
+import java.util.Map;
16
+
17
+import static org.junit.jupiter.api.Assertions.*;
18
+import static org.mockito.ArgumentMatchers.*;
19
+import static org.mockito.Mockito.*;
20
+
21
+/**
22
+ * 数据采集服务测试
23
+ */
24
+@ExtendWith(MockitoExtension.class)
25
+class DataCollectServiceTest {
26
+
27
+    @Mock
28
+    private KafkaTemplate<String, String> kafkaTemplate;
29
+
30
+    @Mock
31
+    private JdbcTemplate jdbcTemplate;
32
+
33
+    @Mock
34
+    private com.water.data_engine.mapper.DataSourceMapper dataSourceMapper;
35
+
36
+    @Mock
37
+    private com.water.data_engine.mapper.CollectTaskMapper collectTaskMapper;
38
+
39
+    @Mock
40
+    private com.water.data_engine.mapper.CollectRecordMapper collectRecordMapper;
41
+
42
+    @Mock
43
+    private SimpMessagingTemplate wsMessagingTemplate;
44
+
45
+    private DataCollectService collectService;
46
+
47
+    @BeforeEach
48
+    void setUp() {
49
+        collectService = new DataCollectService(
50
+            kafkaTemplate, jdbcTemplate, dataSourceMapper,
51
+            collectTaskMapper, collectRecordMapper, wsMessagingTemplate
52
+        );
53
+    }
54
+
55
+    @Test
56
+    @DisplayName("实时数据接入-IoT设备数据")
57
+    void testIngestRealtime_IoT() {
58
+        // Given
59
+        Map<String, Object> data = new HashMap<>();
60
+        data.put("deviceSn", "FM001");
61
+        data.put("metrics", List.of(
62
+            Map.of("key", "LL", "value", 12.5),
63
+            Map.of("key", "YL", "value", 0.35)
64
+        ));
65
+
66
+        // When
67
+        String topic = collectService.ingestRealtime("iot", "FM001", data);
68
+
69
+        // Then
70
+        assertEquals("iot.raw.generic", topic);
71
+        verify(kafkaTemplate).send(eq("iot.raw.generic"), eq("FM001"), anyString());
72
+        verify(wsMessagingTemplate).convertAndSend(eq("/topic/data/realtime/iot"), any());
73
+    }
74
+
75
+    @Test
76
+    @DisplayName("实时数据接入-水质数据")
77
+    void testIngestRealtime_Quality() {
78
+        Map<String, Object> data = new HashMap<>();
79
+        data.put("testPoint", "水厂出口");
80
+        data.put("turbidity", 0.5);
81
+        data.put("ph", 7.2);
82
+
83
+        String topic = collectService.ingestRealtime("quality", "WQ001", data);
84
+
85
+        assertEquals("data.quality", topic);
86
+        verify(kafkaTemplate).send(eq("data.quality"), eq("WQ001"), anyString());
87
+    }
88
+
89
+    @Test
90
+    @DisplayName("批量数据采集")
91
+    void testBatchIngest() {
92
+        List<Map<String, Object>> batchData = List.of(
93
+            Map.of("sourceType", "iot", "sourceId", "FM001", "data", Map.of("LL", 12.5)),
94
+            Map.of("sourceType", "iot", "sourceId", "FM002", "data", Map.of("LL", 15.3)),
95
+            Map.of("sourceType", "manual", "sourceId", "MAN001", "data", Map.of("SW", 100.0))
96
+        );
97
+
98
+        int count = collectService.batchIngest(batchData);
99
+
100
+        assertEquals(3, count);
101
+        verify(kafkaTemplate, times(3)).send(anyString(), anyString(), anyString());
102
+    }
103
+
104
+    @Test
105
+    @DisplayName("创建批量采集任务")
106
+    void testCreateBatchTask() {
107
+        com.water.data_engine.entity.CollectTask task = collectService.createBatchTask(
108
+            "测试批量任务", 1L, "iot_telemetry");
109
+
110
+        assertNotNull(task);
111
+        assertEquals("测试批量任务", task.getTaskName());
112
+        assertEquals("batch", task.getCollectType());
113
+        assertEquals("pending", task.getStatus());
114
+        verify(collectTaskMapper).insert(any(com.water.data_engine.entity.CollectTask.class));
115
+    }
116
+
117
+    @Test
118
+    @DisplayName("Topic路由测试")
119
+    void testRouteTopic() {
120
+        // 测试不同数据源类型的topic路由
121
+        assertDoesNotThrow(() -> collectService.ingestRealtime("iot", "test", Map.of()));
122
+        assertDoesNotThrow(() -> collectService.ingestRealtime("mqtt", "test", Map.of()));
123
+        assertDoesNotThrow(() -> collectService.ingestRealtime("quality", "test", Map.of()));
124
+        assertDoesNotThrow(() -> collectService.ingestRealtime("manual", "test", Map.of()));
125
+        assertDoesNotThrow(() -> collectService.ingestRealtime("api", "test", Map.of()));
126
+        assertDoesNotThrow(() -> collectService.ingestRealtime("unknown", "test", Map.of()));
127
+    }
128
+}

+ 0
- 0
wm-data-engine/src/test/java/com/water/data_engine/service/DataGovernanceServiceTest.java Прегледај датотеку


Неке датотеке нису приказане због велике количине промена