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

- 合并冲突解决: 保留 issue-72 的完善版本(支持 MyBatis-Plus、AND/OR 组合条件引擎、逻辑删除)
- 覆盖 feature/dev 中的早期简化版 AlertRule 相关代码
- 新增: 阈值管理 CRUD + 信息发布 + 设备管理功能
This commit is contained in:
2026-06-14 16:02:50 +08:00
274 changed files with 18370 additions and 117 deletions
+122
View File
@@ -0,0 +1,122 @@
-- =============================================
-- 智慧水务管理系统 - 报警规则引擎 + 报警管理中心 DDL
-- 版本: V2
-- =============================================
-- ==================== 报警规则定义 ====================
CREATE TABLE IF NOT EXISTS prod_alert_rule (
id BIGSERIAL PRIMARY KEY,
rule_name VARCHAR(100) NOT NULL,
rule_code VARCHAR(50) UNIQUE,
description TEXT,
device_type VARCHAR(30),
metric_key VARCHAR(50) NOT NULL,
alert_level VARCHAR(10) NOT NULL DEFAULT 'general', -- general/important/urgent
condition_expr TEXT NOT NULL, -- JSON: {"op":"AND","conditions":[{"metric":"pressure","operator":">","threshold":0.8},...]}
threshold_value DECIMAL(12,4), -- 简单阈值(向后兼容)
debounce_sec INT DEFAULT 300,
notify_channels VARCHAR(200), -- 逗号分隔: sms,wechat,app,email
notify_template VARCHAR(500), -- 通知模板
enabled SMALLINT DEFAULT 1,
priority INT DEFAULT 0, -- 规则优先级
effective_start TIME, -- 生效开始时间
effective_end TIME, -- 生效结束时间
created_by BIGINT,
updated_by BIGINT,
deleted SMALLINT DEFAULT 0,
created_time TIMESTAMP DEFAULT NOW(),
updated_time TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE prod_alert_rule IS '报警规则定义表';
COMMENT ON COLUMN prod_alert_rule.alert_level IS '报警等级: general(一般)/important(重要)/urgent(紧急)';
COMMENT ON COLUMN prod_alert_rule.condition_expr IS '条件表达式JSON: 支持AND/OR组合条件';
-- ==================== 报警记录(全生命周期) ====================
CREATE TABLE IF NOT EXISTS prod_alert_record (
id BIGSERIAL PRIMARY KEY,
rule_id BIGINT REFERENCES prod_alert_rule(id),
rule_name VARCHAR(100),
device_id BIGINT,
device_sn VARCHAR(100),
device_name VARCHAR(200),
area VARCHAR(50),
metric_key VARCHAR(50) NOT NULL,
metric_value DECIMAL(12,4),
threshold_value VARCHAR(50),
alert_level VARCHAR(10) NOT NULL DEFAULT 'general',
title VARCHAR(200),
message TEXT,
-- 生命周期状态: 0=活跃 1=已确认 2=已派单 3=处理中 4=已处理 5=已归档
status INT DEFAULT 0,
confirmed_by BIGINT,
confirmed_time TIMESTAMP,
dispatch_time TIMESTAMP,
assignee_id BIGINT,
assignee_name VARCHAR(50),
handler_id BIGINT,
handler_name VARCHAR(50),
handle_result TEXT,
handle_time TIMESTAMP,
archive_time TIMESTAMP,
archive_reason VARCHAR(500),
resolved_at TIMESTAMP,
created_time TIMESTAMP DEFAULT NOW(),
updated_time TIMESTAMP DEFAULT NOW(),
deleted SMALLINT DEFAULT 0
);
COMMENT ON TABLE prod_alert_record IS '报警记录表(全生命周期)';
CREATE INDEX IF NOT EXISTS idx_alert_record_time ON prod_alert_record(created_time DESC);
CREATE INDEX IF NOT EXISTS idx_alert_record_device ON prod_alert_record(device_sn, created_time DESC);
CREATE INDEX IF NOT EXISTS idx_alert_record_status ON prod_alert_record(status);
CREATE INDEX IF NOT EXISTS idx_alert_record_level ON prod_alert_record(alert_level);
CREATE INDEX IF NOT EXISTS idx_alert_record_area ON prod_alert_record(area);
-- ==================== 报警通知记录 ====================
CREATE TABLE IF NOT EXISTS prod_alert_notification (
id BIGSERIAL PRIMARY KEY,
alert_record_id BIGINT REFERENCES prod_alert_record(id),
rule_id BIGINT,
channel VARCHAR(30) NOT NULL, -- sms/wechat/app/email
recipient VARCHAR(100) NOT NULL, -- 接收人标识
recipient_name VARCHAR(50),
title VARCHAR(200),
content TEXT,
status INT DEFAULT 0, -- 0=待发送 1=已发送 2=发送失败 3=已读
send_time TIMESTAMP,
read_time TIMESTAMP,
retry_count INT DEFAULT 0,
error_msg VARCHAR(500),
created_time TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE prod_alert_notification IS '报警通知记录表';
CREATE INDEX IF NOT EXISTS idx_alert_notif_record ON prod_alert_notification(alert_record_id);
CREATE INDEX IF NOT EXISTS idx_alert_notif_status ON prod_alert_notification(status);
-- ==================== 报警规则-设备关联(可选) ====================
CREATE TABLE IF NOT EXISTS prod_alert_rule_device (
id BIGSERIAL PRIMARY KEY,
rule_id BIGINT REFERENCES prod_alert_rule(id),
device_id BIGINT,
device_sn VARCHAR(100),
area VARCHAR(50),
created_time TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE prod_alert_rule_device IS '报警规则-设备/区域关联表';
-- ==================== 初始规则数据 ====================
INSERT INTO prod_alert_rule (rule_name, rule_code, metric_key, alert_level, condition_expr, threshold_value, debounce_sec, description, enabled) VALUES
('管网压力过高报警', 'RULE_PRESSURE_HIGH', 'pressure', 'urgent',
'{"op":"AND","conditions":[{"metric":"pressure","operator":">","threshold":0.8}]}',
0.8000, 300, '管网压力超过0.8MPa时触发紧急报警', 1),
('管网压力过低报警', 'RULE_PRESSURE_LOW', 'pressure', 'important',
'{"op":"OR","conditions":[{"metric":"pressure","operator":"<","threshold":0.2}]}',
0.2000, 300, '管网压力低于0.2MPa时触发重要报警', 1),
('水质浊度超标', 'RULE_TURBIDITY_HIGH', 'turbidity', 'urgent',
'{"op":"AND","conditions":[{"metric":"turbidity","operator":">","threshold":1.0}]}',
1.0000, 600, '水质浊度超过1.0NTU触发紧急报警', 1),
('余氯偏低报警', 'RULE_CHLORINE_LOW', 'residual_chlorine', 'general',
'{"op":"AND","conditions":[{"metric":"residual_chlorine","operator":"<","threshold":0.1}]}',
0.1000, 600, '余氯低于0.1mg/L触发一般报警', 1),
('流量异常波动', 'RULE_FLOW_ANOMALY', 'flow', 'important',
'{"op":"OR","conditions":[{"metric":"flow","operator":">","threshold":100},{"metric":"flow","operator":"<","threshold":5}]}',
NULL, 120, '流量异常偏高或偏低时触发报警', 1);
+68
View File
@@ -0,0 +1,68 @@
import request from './request'
const BASE = '/api/production/dispatch-command'
// 创建指令
export function createCommand(data: any) {
return request.post(BASE, data)
}
// 下发指令
export function issueCommand(id: number, issuedBy: number, operatorName?: string) {
return request.post(`${BASE}/${id}/issue`, null, {
params: { issuedBy, operatorName: operatorName || 'system' }
})
}
// 指令台账
export function listCommands(params: {
page?: number; size?: number; status?: string;
commandType?: string; keyword?: string; startDate?: string; endDate?: string
}) {
return request.get(BASE, { params })
}
// 指令详情
export function getCommandDetail(id: number) {
return request.get(`${BASE}/${id}`)
}
// 状态统计
export function getCommandStats() {
return request.get(`${BASE}/stats`)
}
// 接收确认
export function receiveCommand(id: number, userId: number, userName?: string) {
return request.post(`${BASE}/${id}/receive`, null, {
params: { userId, userName: userName || '' }
})
}
// 开始执行
export function startExecute(id: number, userId: number, userName?: string) {
return request.post(`${BASE}/${id}/start-execute`, null, {
params: { userId, userName: userName || '' }
})
}
// 完成执行
export function completeExecution(id: number, userId: number, data: {
userName?: string; feedback?: string; feedbackImages?: string
}) {
return request.post(`${BASE}/${id}/complete`, null, {
params: { userId, ...data }
})
}
// 驳回
export function rejectExecution(id: number, userId: number, reason: string, userName?: string) {
return request.post(`${BASE}/${id}/reject`, null, {
params: { userId, userName: userName || '', reason }
})
}
// 追踪日志
export function getTrackingLogs(id: number) {
return request.get(`${BASE}/${id}/tracking`)
}
+2
View File
@@ -11,6 +11,8 @@ const routes = [
{ path: 'system/role', name: 'role', component: () => import('@/views/system/role/RoleList.vue') },
{ path: 'system/menu', name: 'menu', component: () => import('@/views/system/menu/MenuList.vue') },
{ path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') },
{ path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
{ path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
]
},
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }
@@ -0,0 +1,135 @@
<template>
<el-dialog
:model-value="visible"
@update:model-value="$emit('update:visible', $event)"
title="创建调度指令"
width="600"
:close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="指令标题" prop="commandTitle">
<el-input v-model="form.commandTitle" placeholder="请输入指令标题" maxlength="200" show-word-limit />
</el-form-item>
<el-form-item label="指令类型" prop="commandType">
<el-select v-model="form.commandType" placeholder="请选择">
<el-option label="常规" value="normal" />
<el-option label="应急" value="emergency" />
<el-option label="维护" value="maintenance" />
<el-option label="巡检" value="inspection" />
</el-select>
</el-form-item>
<el-form-item label="优先级" prop="priority">
<el-radio-group v-model="form.priority">
<el-radio value="low">低</el-radio>
<el-radio value="normal">普通</el-radio>
<el-radio value="high">高</el-radio>
<el-radio value="urgent">紧急</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="来源" prop="source">
<el-input v-model="form.source" placeholder="手动/系统/报警联动" />
</el-form-item>
<el-form-item label="指令内容" prop="commandContent">
<el-input v-model="form.commandContent" type="textarea" :rows="5"
placeholder="请输入指令详细内容" maxlength="2000" show-word-limit />
</el-form-item>
<el-form-item label="目标类型" prop="targetType">
<el-select v-model="form.targetType" placeholder="请选择">
<el-option label="指定人员" value="user" />
<el-option label="部门" value="dept" />
<el-option label="角色" value="role" />
</el-select>
</el-form-item>
<el-form-item label="目标人员" prop="targetIds">
<el-input v-model="form.targetIds" placeholder="目标ID列表,多个用逗号分隔,如: 1,2,3" />
<div class="form-tip">输入用户ID,多个用逗号分隔</div>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="备注信息(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="$emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
创建指令
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { createCommand } from '@/api/dispatchCommand'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'created': []
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const defaultForm = () => ({
commandTitle: '',
commandType: 'normal',
priority: 'normal',
source: '手动',
commandContent: '',
targetType: 'user',
targetIds: '',
remark: ''
})
const form = reactive(defaultForm())
const rules: FormRules = {
commandTitle: [{ required: true, message: '请输入指令标题', trigger: 'blur' }],
commandType: [{ required: true, message: '请选择指令类型', trigger: 'change' }],
priority: [{ required: true, message: '请选择优先级', trigger: 'change' }],
commandContent: [{ required: true, message: '请输入指令内容', trigger: 'blur' }],
targetType: [{ required: true, message: '请选择目标类型', trigger: 'change' }],
targetIds: [{ required: true, message: '请输入目标ID', trigger: 'blur' }]
}
watch(() => props.visible, (val) => {
if (val) {
Object.assign(form, defaultForm())
formRef.value?.resetFields()
}
})
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate()
submitting.value = true
try {
// 将 targetIds 转为 JSON 数组格式
const targetIdsArr = form.targetIds.split(',').map((s: string) => s.trim()).filter(Boolean)
await createCommand({
...form,
targetIds: JSON.stringify(targetIdsArr.map(Number))
})
ElMessage.success('指令创建成功')
emit('update:visible', false)
emit('created')
} catch (e: any) {
ElMessage.error(e.message || '创建失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.form-tip { font-size: 12px; color: #909399; margin-top: 4px; }
</style>
@@ -0,0 +1,304 @@
<template>
<div class="command-detail" v-loading="loading">
<!-- 返回按钮 -->
<el-page-header @back="router.back()" :title="'返回'" style="margin-bottom: 16px">
<template #content>
<span class="page-title">指令详情</span>
<el-tag :type="statusTag(detail.status)" style="margin-left: 12px">{{ statusLabel(detail.status) }}</el-tag>
</template>
</el-page-header>
<el-row :gutter="16">
<!-- 左侧:基本信息 + 状态流转图 -->
<el-col :span="14">
<el-card>
<template #header>
<span>{{ detail.command_title }}</span>
<el-tag size="small" style="margin-left: 8px">{{ detail.command_no }}</el-tag>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="指令编号">{{ detail.command_no }}</el-descriptions-item>
<el-descriptions-item label="类型">
<el-tag size="small">{{ typeLabel(detail.command_type) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="优先级">
<el-tag :type="priorityTag(detail.priority)" size="small">{{ priorityLabel(detail.priority) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="来源">{{ detail.source || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detail.created_at }}</el-descriptions-item>
<el-descriptions-item label="下发时间">{{ detail.issued_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="完成时间">{{ detail.completed_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="目标类型">{{ detail.target_type || '-' }}</el-descriptions-item>
</el-descriptions>
<div style="margin-top: 16px">
<h4>指令内容</h4>
<div class="content-block">{{ detail.command_content }}</div>
</div>
<!-- 状态流转图 -->
<div style="margin-top: 24px">
<h4>状态流转</h4>
<el-steps :active="statusStep(detail.status)" finish-status="success" align-center>
<el-step title="草稿" description="创建指令" />
<el-step title="已下发" description="下发给执行人" />
<el-step title="已接收" description="执行人确认" />
<el-step title="执行中" description="正在执行" />
<el-step title="完成/驳回" description="归档" />
</el-steps>
</div>
</el-card>
</el-col>
<!-- 右侧:执行记录列表 -->
<el-col :span="10">
<el-card>
<template #header>执行记录</template>
<el-timeline v-if="executions.length">
<el-timeline-item
v-for="exec in executions" :key="exec.id"
:type="executionTimelineType(exec.execute_status)"
:timestamp="exec.received_at || exec.created_at"
placement="top">
<div class="exec-card">
<div class="exec-header">
<span class="exec-user">{{ exec.user_name || `用户${exec.user_id}` }}</span>
<el-tag :type="executionStatusTag(exec.execute_status)" size="small">
{{ executionStatusLabel(exec.execute_status) }}
</el-tag>
</div>
<div v-if="exec.feedback" class="exec-feedback">
反馈: {{ exec.feedback }}
</div>
<div v-if="exec.rejected_reason" class="exec-reject">
驳回原因: {{ exec.rejected_reason }}
</div>
<div class="exec-actions" v-if="canOperate(exec)">
<el-button size="small" type="primary"
v-if="exec.execute_status === 'pending'"
@click="handleReceive(exec)">接收</el-button>
<el-button size="small" type="success"
v-if="exec.execute_status === 'received'"
@click="handleStartExecute(exec)">开始执行</el-button>
<el-button size="small" type="success"
v-if="exec.execute_status === 'executing'"
@click="showCompleteDialog(exec)">完成</el-button>
<el-button size="small" type="danger"
v-if="exec.execute_status !== 'completed' && exec.execute_status !== 'rejected'"
@click="handleReject(exec)">驳回</el-button>
</div>
</div>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无执行记录" />
</el-card>
</el-col>
</el-row>
<!-- 追踪日志 -->
<el-card style="margin-top: 16px">
<template #header>全过程追踪日志</template>
<el-timeline>
<el-timeline-item
v-for="log in trackingLogs" :key="log.id"
:timestamp="log.created_at" placement="top"
:type="trackingType(log.action)">
<div>
<el-tag size="small" :type="trackingType(log.action)">{{ trackingActionLabel(log.action) }}</el-tag>
<span style="margin-left: 8px">{{ log.operator_name || '' }}</span>
<span v-if="log.from_status" style="margin-left: 8px; color: #909399">
{{ log.from_status }} → {{ log.to_status }}
</span>
<div v-if="log.remark" style="color: #606266; margin-top: 4px">{{ log.remark }}</div>
</div>
</el-timeline-item>
</el-timeline>
<el-empty v-if="!trackingLogs.length" description="暂无追踪日志" />
</el-card>
<!-- 完成弹窗 -->
<el-dialog v-model="completeVisible" title="完成执行" width="500">
<el-form label-width="80px">
<el-form-item label="反馈说明">
<el-input v-model="completeForm.feedback" type="textarea" :rows="3" placeholder="请输入执行反馈" />
</el-form-item>
<el-form-item label="反馈图片">
<el-input v-model="completeForm.feedbackImages" placeholder="图片URL,多个用逗号分隔" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="completeVisible = false">取消</el-button>
<el-button type="primary" @click="handleComplete">确认完成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCommandDetail, receiveCommand, startExecute, completeExecution, rejectExecution } from '@/api/dispatchCommand'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detail = ref<any>({})
const executions = ref<any[]>([])
const trackingLogs = ref<any[]>([])
const completeVisible = ref(false)
const currentExec = ref<any>(null)
const completeForm = reactive({ feedback: '', feedbackImages: '' })
const commandId = Number(route.params.id)
// 模拟当前用户ID(实际应从登录态获取)
const currentUserId = 1
const currentUserName = 'admin'
const statusMap: Record<string, { label: string; type: string; step: number }> = {
draft: { label: '草稿', type: 'info', step: 0 },
issued: { label: '已下发', type: 'warning', step: 1 },
received: { label: '已接收', type: '', step: 2 },
executing: { label: '执行中', type: 'primary', step: 3 },
completed: { label: '已完成', type: 'success', step: 4 },
rejected: { label: '已驳回', type: 'danger', step: 4 }
}
const statusLabel = (s: string) => statusMap[s]?.label || s
const statusTag = (s: string) => (statusMap[s]?.type || 'info') as any
const statusStep = (s: string) => statusMap[s]?.step || 0
const typeMap: Record<string, string> = { normal: '常规', emergency: '应急', maintenance: '维护', inspection: '巡检' }
const typeLabel = (t: string) => typeMap[t] || t
const priorityMap: Record<string, { label: string; type: string }> = {
low: { label: '低', type: 'info' }, normal: { label: '普通', type: '' },
high: { label: '高', type: 'warning' }, urgent: { label: '紧急', type: 'danger' }
}
const priorityLabel = (p: string) => priorityMap[p]?.label || p
const priorityTag = (p: string) => (priorityMap[p]?.type || 'info') as any
const executionStatusLabel = (s: string) => {
const map: Record<string, string> = {
pending: '待接收', received: '已接收', executing: '执行中', completed: '已完成', rejected: '已驳回'
}
return map[s] || s
}
const executionStatusTag = (s: string) => {
const map: Record<string, string> = {
pending: 'info', received: '', executing: 'primary', completed: 'success', rejected: 'danger'
}
return (map[s] || 'info') as any
}
const executionTimelineType = (s: string) => {
const map: Record<string, string> = {
pending: 'info', received: 'primary', executing: 'primary', completed: 'success', rejected: 'danger'
}
return (map[s] || 'info') as any
}
const trackingActionLabel = (a: string) => {
const map: Record<string, string> = {
create: '创建', issue: '下发', receive: '接收', start_execute: '开始执行',
complete: '完成', reject: '驳回', cancel: '取消'
}
return map[a] || a
}
const trackingType = (a: string) => {
const map: Record<string, string> = {
create: 'info', issue: 'warning', receive: 'primary', start_execute: 'primary',
complete: 'success', reject: 'danger', cancel: 'danger'
}
return (map[a] || 'info') as any
}
function canOperate(exec: any) {
return exec.user_id === currentUserId || true // 简化:所有人可操作
}
async function fetchDetail() {
loading.value = true
try {
const res = await getCommandDetail(commandId)
detail.value = res.data || {}
executions.value = res.data?.executions || []
trackingLogs.value = res.data?.tracking_logs || res.data?.trackingLogs || []
} finally {
loading.value = false
}
}
async function handleReceive(exec: any) {
try {
await receiveCommand(commandId, exec.user_id, exec.user_name)
ElMessage.success('接收成功')
fetchDetail()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
}
}
async function handleStartExecute(exec: any) {
try {
await startExecute(commandId, exec.user_id, exec.user_name)
ElMessage.success('已开始执行')
fetchDetail()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
}
}
function showCompleteDialog(exec: any) {
currentExec.value = exec
completeForm.feedback = ''
completeForm.feedbackImages = ''
completeVisible.value = true
}
async function handleComplete() {
try {
await completeExecution(commandId, currentExec.value.user_id, {
userName: currentExec.value.user_name,
feedback: completeForm.feedback,
feedbackImages: completeForm.feedbackImages
})
ElMessage.success('执行完成')
completeVisible.value = false
fetchDetail()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
}
}
async function handleReject(exec: any) {
try {
const { value } = await ElMessageBox.prompt('请输入驳回原因', '驳回', {
confirmButtonText: '确认驳回',
cancelButtonText: '取消',
inputPattern: /.+/,
inputErrorMessage: '驳回原因不能为空'
})
await rejectExecution(commandId, exec.user_id, value, exec.user_name)
ElMessage.success('已驳回')
fetchDetail()
} catch { /* cancel */ }
}
onMounted(fetchDetail)
</script>
<style scoped>
.page-title { font-size: 16px; font-weight: 600; }
.content-block {
padding: 12px; background: #f5f7fa; border-radius: 4px;
white-space: pre-wrap; line-height: 1.6;
}
.exec-card { padding: 4px 0; }
.exec-header { display: flex; justify-content: space-between; align-items: center; }
.exec-user { font-weight: 600; }
.exec-feedback { margin-top: 6px; color: #606266; font-size: 13px; }
.exec-reject { margin-top: 6px; color: #f56c6c; font-size: 13px; }
.exec-actions { margin-top: 8px; }
</style>
@@ -0,0 +1,193 @@
<template>
<div class="command-list">
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filterForm">
<el-form-item label="关键词">
<el-input v-model="filterForm.keyword" placeholder="编号/标题" clearable @clear="handleSearch" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
<el-option label="草稿" value="draft" />
<el-option label="已下发" value="issued" />
<el-option label="已接收" value="received" />
<el-option label="执行中" value="executing" />
<el-option label="已完成" value="completed" />
<el-option label="已驳回" value="rejected" />
</el-select>
</el-form-item>
<el-form-item label="类型">
<el-select v-model="filterForm.commandType" placeholder="全部" clearable @change="handleSearch">
<el-option label="常规" value="normal" />
<el-option label="应急" value="emergency" />
<el-option label="维护" value="maintenance" />
<el-option label="巡检" value="inspection" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker v-model="dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD"
@change="handleSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-row :gutter="12" style="margin-top: 12px">
<el-col :span="4" v-for="stat in stats" :key="stat.status">
<el-card shadow="hover" class="stat-card" @click="filterByStatus(stat.status)">
<div class="stat-value">{{ stat.count }}</div>
<div class="stat-label">{{ statusLabel(stat.status) }}</div>
</el-card>
</el-col>
</el-row>
<div style="margin-top: 16px; display: flex; justify-content: space-between; align-items: center">
<el-button type="primary" @click="showCreateDialog = true"><el-icon><Plus /></el-icon> 创建指令</el-button>
<el-button @click="handleBatchIssue" :disabled="!selectedIds.length">批量下发</el-button>
</div>
<el-table :data="tableData" border style="margin-top: 10px"
@selection-change="handleSelectionChange" v-loading="loading">
<el-table-column type="selection" width="50" />
<el-table-column prop="command_no" label="指令编号" width="220" />
<el-table-column prop="command_title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="command_type" label="类型" width="80">
<template #default="{ row }">
<el-tag :type="typeTag(row.command_type)" size="small">{{ typeLabel(row.command_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="80">
<template #default="{ row }">
<el-tag :type="priorityTag(row.priority)" size="small">{{ priorityLabel(row.priority) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="执行进度" width="120">
<template #default="{ row }">
<span>{{ row.completed_count || 0 }}/{{ row.total_executions || 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewDetail(row)">详情</el-button>
<el-button link type="success" v-if="row.status === 'draft'" @click="handleIssue(row)">下发</el-button>
<el-button link type="danger" v-if="row.status === 'draft'" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top: 16px; justify-content: flex-end"
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
:total="pagination.total" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" @change="fetchData" />
<CommandCreate v-model:visible="showCreateDialog" @created="handleCreated" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import { listCommands, issueCommand, getCommandStats } from '@/api/dispatchCommand'
import CommandCreate from './CommandCreate.vue'
const router = useRouter()
const loading = ref(false)
const tableData = ref<any[]>([])
const stats = ref<any[]>([])
const selectedIds = ref<number[]>([])
const showCreateDialog = ref(false)
const dateRange = ref<[string, string] | null>(null)
const filterForm = reactive({ keyword: '', status: '', commandType: '' })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const statusMap: Record<string, { label: string; type: string }> = {
draft: { label: '草稿', type: 'info' },
issued: { label: '已下发', type: 'warning' },
received: { label: '已接收', type: '' },
executing: { label: '执行中', type: 'primary' },
completed: { label: '已完成', type: 'success' },
rejected: { label: '已驳回', type: 'danger' }
}
const typeMap: Record<string, string> = { normal: '常规', emergency: '应急', maintenance: '维护', inspection: '巡检' }
const priorityMap: Record<string, { label: string; type: string }> = {
low: { label: '低', type: 'info' }, normal: { label: '普通', type: '' },
high: { label: '高', type: 'warning' }, urgent: { label: '紧急', type: 'danger' }
}
function statusLabel(s: string) { return statusMap[s]?.label || s }
function statusTag(s: string) { return (statusMap[s]?.type || 'info') as any }
function typeLabel(t: string) { return typeMap[t] || t }
function typeTag(t: string) { return t === 'emergency' ? 'danger' : t === 'maintenance' ? 'warning' : '' }
function priorityLabel(p: string) { return priorityMap[p]?.label || p }
function priorityTag(p: string) { return (priorityMap[p]?.type || 'info') as any }
async function fetchData() {
loading.value = true
try {
const res = await listCommands({
page: pagination.page, size: pagination.size,
status: filterForm.status || undefined,
commandType: filterForm.commandType || undefined,
keyword: filterForm.keyword || undefined,
startDate: dateRange.value?.[0], endDate: dateRange.value?.[1]
})
tableData.value = res.data?.records || []
pagination.total = res.data?.total || 0
} finally { loading.value = false }
}
async function fetchStats() {
try { const res = await getCommandStats(); stats.value = res.data || [] } catch { /* ignore */ }
}
function handleSearch() { pagination.page = 1; fetchData() }
function handleReset() {
filterForm.keyword = ''; filterForm.status = ''; filterForm.commandType = ''; dateRange.value = null; handleSearch()
}
function filterByStatus(status: string) { filterForm.status = status; handleSearch() }
function handleSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id) }
function viewDetail(row: any) { router.push({ path: `/dispatch-command/${row.id}` }) }
async function handleIssue(row: any) {
try {
await ElMessageBox.confirm(`确认下发指令 "${row.command_title}" ?`, '下发确认')
await issueCommand(row.id, 1, 'admin'); ElMessage.success('指令已下发'); fetchData(); fetchStats()
} catch { /* cancel */ }
}
async function handleBatchIssue() {
try {
await ElMessageBox.confirm(`确认批量下发 ${selectedIds.value.length} 条指令?`, '批量下发')
for (const id of selectedIds.value) { await issueCommand(id, 1, 'admin') }
ElMessage.success('批量下发完成'); fetchData(); fetchStats()
} catch { /* cancel */ }
}
function handleDelete(row: any) {
ElMessageBox.confirm(`确认删除指令 "${row.command_title}" ?`, '删除确认', { type: 'warning' })
.then(() => { ElMessage.info('删除功能待实现(逻辑删除)') }).catch(() => { /* cancel */ })
}
function handleCreated() { showCreateDialog.value = false; fetchData(); fetchStats() }
onMounted(() => { fetchData(); fetchStats() })
</script>
<style scoped>
.filter-card :deep(.el-form-item) { margin-bottom: 0; }
.stat-card { cursor: pointer; text-align: center; }
.stat-value { font-size: 28px; font-weight: bold; color: #409eff; }
.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
</style>
@@ -0,0 +1,558 @@
<template>
<div class="problem-reporting">
<el-card class="reporting-form">
<template #header>
<div class="card-header">
<span>巡检问题上报</span>
<el-tag type="success">{{ problemCount }} 个问题待处理</el-tag>
</div>
</template>
<el-form
ref="problemForm"
:model="problemForm"
:rules="rules"
label-width="120px"
@submit.prevent="submitProblem"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="问题类型" prop="problemType">
<el-select
v-model="problemForm.problemType"
placeholder="请选择问题类型"
style="width: 100%"
>
<el-option label="设备故障" value="设备故障" />
<el-option label="水质异常" value="水质异常" />
<el-option label="安全隐患" value="安全隐患" />
<el-option label="环境卫生" value="环境卫生" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="问题级别" prop="problemLevel">
<el-select
v-model="problemForm.problemLevel"
placeholder="请选择问题级别"
style="width: 100%"
>
<el-option label="低" value="low" />
<el-option label="普通" value="normal" />
<el-option label="高" value="high" />
<el-option label="紧急" value="critical" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="问题标题" prop="problemTitle">
<el-input
v-model="problemForm.problemTitle"
placeholder="请输入问题标题"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="问题描述" prop="problemDescription">
<el-input
v-model="problemForm.problemDescription"
type="textarea"
:rows="4"
placeholder="请详细描述问题情况"
maxlength="1000"
show-word-limit
/>
</el-form-item>
<el-form-item label="问题位置" prop="location">
<el-input
v-model="problemForm.location"
placeholder="请输入问题发生位置"
maxlength="300"
show-word-limit
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="设备名称" prop="deviceName">
<el-input
v-model="problemForm.deviceName"
placeholder="请输入相关设备名称"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="经纬度">
<el-input
v-model="coordinates"
placeholder="经度, 纬度"
readonly
>
<template #append>
<el-button @click="getCurrentLocation">获取位置</el-button>
</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="现场照片">
<el-upload
v-model:file-list="fileList"
action="/api/upload"
list-type="picture-card"
:limit="5"
:on-success="handleUploadSuccess"
:on-remove="handleRemove"
:before-upload="beforeUpload"
>
<el-icon><Plus /></el-icon>
</el-upload>
<div class="upload-tip">最多上传5张照片,支持JPG、PNG格式</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="submitProblem"
:loading="submitting"
>
{{ isEditing ? '更新问题' : '提交问题' }}
</el-button>
<el-button @click="resetForm">重置</el-button>
<el-button v-if="isEditing" @click="cancelEdit">取消编辑</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 问题列表 -->
<el-card class="problem-list">
<template #header>
<div class="card-header">
<span>问题列表</span>
<el-input
v-model="searchQuery"
placeholder="搜索问题..."
style="width: 200px"
clearable
/>
</div>
</template>
<el-table
:data="filteredProblems"
stripe
style="width: 100%"
v-loading="loading"
>
<el-table-column prop="problemNo" label="问题编号" width="120" />
<el-table-column prop="problemTitle" label="问题标题" min-width="200" />
<el-table-column prop="problemType" label="问题类型" width="120" />
<el-table-column prop="problemLevel" label="级别" width="80">
<template #default="{ row }">
<el-tag :type="getLevelType(row.problemLevel)">
{{ getLevelText(row.problemLevel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="reportTime" label="上报时间" width="180">
<template #default="{ row }">
{{ formatDate(row.reportTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button
size="small"
@click="viewProblem(row)"
>
查看
</el-button>
<el-button
size="small"
type="primary"
@click="editProblem(row)"
v-if="row.status === 'reported'"
>
编辑
</el-button>
<el-button
size="small"
type="success"
@click="createWorkOrder(row)"
v-if="row.status === 'reported' && !row.workOrderId"
>
创建工单
</el-button>
<el-button
size="small"
type="info"
@click="viewWorkOrder(row)"
v-if="row.workOrderId"
>
查看工单
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="totalProblems"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import axios from 'axios'
const problemForm = ref({
id: null,
taskId: null,
pointSeq: null,
deviceId: null,
deviceName: '',
problemType: '',
problemLevel: 'normal',
problemTitle: '',
problemDescription: '',
location: '',
lng: null,
lat: null,
photoUrls: [],
reporterId: 1, // 当前用户ID
reporterName: '巡检员',
status: 'reported'
})
const fileList = ref([])
const coordinates = ref('')
const submitting = ref(false)
const loading = ref(false)
const problems = ref([])
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const totalProblems = ref(0)
const isEditing = ref(false)
// 表单验证规则
const rules = {
problemType: [{ required: true, message: '请选择问题类型', trigger: 'change' }],
problemTitle: [{ required: true, message: '请输入问题标题', trigger: 'blur' }],
problemDescription: [{ required: true, message: '请输入问题描述', trigger: 'blur' }],
location: [{ required: true, message: '请输入问题位置', trigger: 'blur' }]
}
// 计算属性
const problemCount = computed(() => {
return problems.value.filter(p => p.status === 'reported').length
})
const filteredProblems = computed(() => {
let filtered = problems.value
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(p =>
p.problemTitle.toLowerCase().includes(query) ||
p.problemType.toLowerCase().includes(query) ||
p.problemNo.toLowerCase().includes(query)
)
}
return filtered
})
// 获取问题列表
const fetchProblems = async () => {
loading.value = true
try {
const response = await axios.get('/api/patrol/problems/status/reported')
problems.value = response.data
totalProblems.value = problems.value.length
} catch (error) {
console.error('获取问题列表失败:', error)
ElMessage.error('获取问题列表失败')
} finally {
loading.value = false
}
}
// 提交问题
const submitProblem = async () => {
try {
const formRef = document.querySelector('.problem-reporting .reporting-form form')
if (!formRef) return
// 这里可以添加表单验证逻辑
submitting.value = true
// 处理文件上传
const photoUrls = []
for (const file of fileList.value) {
if (file.response) {
photoUrls.push(file.response.url)
}
}
problemForm.value.photoUrls = photoUrls
// 解析坐标
if (coordinates.value) {
const [lng, lat] = coordinates.value.split(',').map(s => parseFloat(s.trim()))
problemForm.value.lng = lng
problemForm.value.lat = lat
}
const response = await axios.post('/api/patrol/problems', problemForm.value)
if (response.data) {
ElMessage.success('问题提交成功')
resetForm()
fetchProblems()
}
} catch (error) {
console.error('提交问题失败:', error)
ElMessage.error('提交问题失败')
} finally {
submitting.value = false
}
}
// 重置表单
const resetForm = () => {
problemForm.value = {
id: null,
taskId: null,
pointSeq: null,
deviceId: null,
deviceName: '',
problemType: '',
problemLevel: 'normal',
problemTitle: '',
problemDescription: '',
location: '',
lng: null,
lat: null,
photoUrls: [],
reporterId: 1,
reporterName: '巡检员',
status: 'reported'
}
fileList.value = []
coordinates.value = ''
isEditing.value = false
}
// 编辑问题
const editProblem = (problem) => {
problemForm.value = { ...problem }
fileList.value = problem.photoUrls.map(url => ({ url, name: url }))
coordinates.value = problem.lng && problem.lat ? `${problem.lng}, ${problem.lat}` : ''
isEditing.value = true
}
// 取消编辑
const cancelEdit = () => {
resetForm()
}
// 查看问题详情
const viewProblem = (problem) => {
ElMessageBox.alert(
`问题编号:${problem.problemNo}\n` +
`问题类型:${problem.problemType}\n` +
`问题级别:${problem.problemLevel}\n` +
`问题标题:${problem.problemTitle}\n` +
`问题位置:${problem.location}\n` +
`问题描述:${problem.problemDescription}\n` +
`上报时间:${formatDate(problem.reportTime)}`,
'问题详情',
{ confirmButtonText: '确定' }
)
}
// 创建工单
const createWorkOrder = (problem) => {
ElMessageBox.confirm(
`确认为问题 "${problem.problemTitle}" 创建工单吗?`,
'创建工单',
{ confirmButtonText: '确定', cancelButtonText: '取消' }
).then(async () => {
try {
const response = await axios.post(`/api/patrol/problems/${problem.id}/auto-create-work-order`)
if (response.data) {
ElMessage.success('工单创建成功')
fetchProblems()
}
} catch (error) {
console.error('创建工单失败:', error)
ElMessage.error('创建工单失败')
}
})
}
// 查看工单
const viewWorkOrder = (problem) => {
// 这里可以跳转到工单详情页
ElMessage.info(`查看工单 ${problem.workOrderId}`)
}
// 获取当前位置
const getCurrentLocation = () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords
coordinates.value = `${longitude}, ${latitude}`
problemForm.value.lng = longitude
problemForm.value.lat = latitude
},
(error) => {
ElMessage.error('获取位置失败:' + error.message)
}
)
} else {
ElMessage.error('浏览器不支持地理位置定位')
}
}
// 文件上传相关
const handleUploadSuccess = (response, file) => {
file.url = response.url
}
const handleRemove = (file, fileList) => {
fileList.value = fileList
}
const beforeUpload = (file) => {
const isJPG = file.type === 'image/jpeg'
const isPNG = file.type === 'image/png'
const isLt5M = file.size / 1024 / 1024 < 5
if (!isJPG && !isPNG) {
ElMessage.error('上传图片只能是 JPG 或 PNG 格式!')
return false
}
if (!isLt5M) {
ElMessage.error('上传图片大小不能超过 5MB!')
return false
}
return true
}
// 辅助函数
const getLevelType = (level) => {
switch (level) {
case 'low': return 'info'
case 'normal': return ''
case 'high': return 'warning'
case 'critical': return 'danger'
default: return ''
}
}
const getLevelText = (level) => {
switch (level) {
case 'low': return '低'
case 'normal': return '普通'
case 'high': return '高'
case 'critical': return '紧急'
default: return level
}
}
const getStatusType = (status) => {
switch (status) {
case 'reported': return 'warning'
case 'processing': return 'primary'
case 'completed': return 'success'
case 'closed': return 'info'
default: return ''
}
}
const getStatusText = (status) => {
switch (status) {
case 'reported': return '已上报'
case 'processing': return '处理中'
case 'completed': return '已完成'
case 'closed': return '已关闭'
default: return status
}
}
const formatDate = (date) => {
if (!date) return ''
return new Date(date).toLocaleString()
}
const handleSizeChange = (val) => {
pageSize.value = val
fetchProblems()
}
const handleCurrentChange = (val) => {
currentPage.value = val
fetchProblems()
}
// 初始化
onMounted(() => {
fetchProblems()
})
</script>
<style scoped>
.problem-reporting {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.upload-tip {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
.pagination {
margin-top: 20px;
text-align: right;
}
.problem-list {
margin-top: 20px;
}
</style>
+5
View File
@@ -39,12 +39,17 @@
<module>wm-iot</module>
<module>wm-data-engine</module>
<module>wm-bpm</module>
<module>wm-bpm-engine</module>
<module>wm-production</module>
<module>wm-revenue</module>
<module>wm-patrol</module>
<module>wm-bi</module>
<module>wm-notify</module>
<module>wm-job</module>
<module>wm-dispatch</module>
<module>wm-system</module>
<module>wm-mobile-app</module>
<module>wm-config</module>
</modules>
<dependencyManagement>
+112
View File
@@ -0,0 +1,112 @@
-- =============================================
-- 智慧水务管理系统 - 巡检问题上报 + 工单管理 DDL
-- 版本: V1
-- =============================================
-- 巡检问题上报表
CREATE TABLE IF NOT EXISTS patrol_problem (
id BIGSERIAL PRIMARY KEY,
problem_no VARCHAR(30) UNIQUE NOT NULL, -- 问题编号:WQ-2026-001
task_id BIGINT REFERENCES patrol_task(id),
point_seq INT,
device_id BIGINT,
device_name VARCHAR(200),
problem_type VARCHAR(50) NOT NULL, -- 设备故障/水质异常/安全隐患/环境卫生/其他
problem_level VARCHAR(20) DEFAULT 'normal', -- low/normal/high/critical
problem_title VARCHAR(200) NOT NULL,
problem_description TEXT,
location VARCHAR(300),
lng DOUBLE PRECISION,
lat DOUBLE PRECISION,
photo_urls JSONB, -- 现场照片URL数组
reporter_id BIGINT REFERENCES sys_user(id),
reporter_name VARCHAR(50),
report_time TIMESTAMP DEFAULT NOW(),
status VARCHAR(20) DEFAULT 'reported', -- reported/processing/completed/closed
work_order_id BIGINT, -- 关联工单ID
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE patrol_problem IS '巡检问题上报表';
CREATE INDEX IF NOT EXISTS idx_problem_task ON patrol_problem(task_id);
CREATE INDEX IF NOT EXISTS idx_problem_status ON patrol_problem(status);
CREATE INDEX IF NOT EXISTS idx_problem_device ON patrol_problem(device_id);
CREATE INDEX IF NOT EXISTS idx_problem_type ON patrol_problem(problem_type);
-- 工单表
CREATE TABLE IF NOT EXISTS work_order (
id BIGSERIAL PRIMARY KEY,
order_no VARCHAR(30) UNIQUE NOT NULL, -- 工单编号:WO-2026-001
problem_id BIGINT REFERENCES patrol_problem(id),
order_type VARCHAR(50) NOT NULL, -- 设备维修/水质处理/安全隐患处理/清洁/其他
priority VARCHAR(20) DEFAULT 'normal', -- low/normal/high/critical
title VARCHAR(200) NOT NULL,
description TEXT,
location VARCHAR(300),
contact_person VARCHAR(50),
contact_phone VARCHAR(20),
reporter_id BIGINT REFERENCES sys_user(id),
reporter_name VARCHAR(50),
assignee_id BIGINT REFERENCES sys_user(id),
assignee_name VARCHAR(50),
status VARCHAR(20) DEFAULT 'pending', -- pending/assigned/processing/completed/cancelled
process_status VARCHAR(20) DEFAULT 'created', -- created/accepted/in_progress/completed
estimated_duration INT, -- 预计工时(分钟)
actual_start_time TIMESTAMP,
actual_end_time TIMESTAMP,
completion_time TIMESTAMP,
photos_before JSONB, -- 处理前照片
photos_after JSONB, -- 处理后照片
solution_description TEXT, -- 处理方案描述
solution_result TEXT, -- 处理结果
customer_feedback TEXT, -- 客户反馈
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE work_order IS '工单表';
CREATE INDEX IF NOT EXISTS idx_order_problem ON work_order(problem_id);
CREATE INDEX IF NOT EXISTS idx_order_status ON work_order(status, process_status);
CREATE INDEX IF NOT EXISTS idx_order_assignee ON work_order(assignee_id);
-- 工单处理记录表
CREATE TABLE IF NOT EXISTS work_order_process (
id BIGSERIAL PRIMARY KEY,
work_order_id BIGINT REFERENCES work_order(id),
process_step VARCHAR(50) NOT NULL, -- created/accepted/in_progress/completed
processor_id BIGINT REFERENCES sys_user(id),
processor_name VARCHAR(50),
action VARCHAR(50) NOT NULL, -- create/assign/start/complete/cancel
comment TEXT,
photos JSONB, -- 处理过程照片
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE work_order_process IS '工单处理记录表';
CREATE INDEX IF NOT EXISTS idx_process_order ON work_order_process(work_order_id);
CREATE INDEX IF NOT EXISTS idx_process_step ON work_order_process(process_step);
-- 工单附件表
CREATE TABLE IF NOT EXISTS work_order_attachment (
id BIGSERIAL PRIMARY KEY,
work_order_id BIGINT REFERENCES work_order(id),
file_name VARCHAR(200) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_type VARCHAR(50), -- image/pdf/doc/other
file_size BIGINT,
uploaded_by BIGINT REFERENCES sys_user(id),
uploaded_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE work_order_attachment IS '工单附件表';
CREATE INDEX IF NOT EXISTS idx_attachment_order ON work_order_attachment(work_order_id);
-- 巡检问题与工单关联触发记录
CREATE TABLE IF NOT EXISTS patrol_work_order_trigger (
id BIGSERIAL PRIMARY KEY,
patrol_problem_id BIGINT REFERENCES patrol_problem(id),
work_order_id BIGINT REFERENCES work_order(id),
trigger_type VARCHAR(20) NOT NULL, -- auto/manual
trigger_condition JSONB, -- 触发条件
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE patrol_work_order_trigger IS '巡检问题与工单关联触发记录';
CREATE INDEX IF NOT EXISTS idx_trigger_problem ON patrol_work_order_trigger(patrol_problem_id);
CREATE INDEX IF NOT EXISTS idx_trigger_order ON patrol_work_order_trigger(work_order_id);
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.water</groupId>
<artifactId>wm-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>wm-bpm-engine</artifactId>
<name>wm-bpm-engine</name>
<description>BPM 业务流程引擎模块</description>
<dependencies>
<dependency>
<groupId>com.water</groupId>
<artifactId>wm-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,7 @@
package com.water.bpmengine;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BpmEngineApplication {
public static void main(String[] args) { SpringApplication.run(BpmEngineApplication.class, args); }
}
@@ -0,0 +1,77 @@
package com.water.bpmengine.controller;
import com.water.common.core.result.R;
import com.water.bpmengine.entity.*;
import com.water.bpmengine.service.BpmEngineService;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@Tag(name = "业务流程引擎")
@RestController @RequestMapping("/bpm") @RequiredArgsConstructor
public class BpmEngineController {
private final BpmEngineService svc;
@GetMapping("/definition/list")
public R<List<ProcessDefinition>> listDefs(
@RequestParam(required=false) String category,
@RequestParam(required=false) Integer status) {
return R.ok(svc.listDefinitions(category, status));
}
@PostMapping("/definition")
public R<Long> createDef(@RequestBody Map<String,Object> req) {
return R.ok(svc.createDefinition(req));
}
@PostMapping("/definition/{id}/publish")
public R<String> publish(@PathVariable Long id) {
svc.publishDefinition(id); return R.ok("OK");
}
@PostMapping("/process/start")
public R<Map<String,Object>> start(
@RequestParam Long definitionId,
@RequestParam String title,
@RequestParam String initiator) {
return R.ok(svc.startProcess(definitionId, title, initiator));
}
@GetMapping("/process/list")
public R<List<ProcessInstance>> listInstances(
@RequestParam(required=false) String initiator,
@RequestParam(required=false) Integer status) {
return R.ok(svc.listInstances(initiator, status));
}
@GetMapping("/task/todo")
public R<List<TaskItem>> todo(@RequestParam String assignee) {
return R.ok(svc.getTodoTasks(assignee));
}
@GetMapping("/task/done")
public R<List<TaskItem>> done(@RequestParam String assignee) {
return R.ok(svc.getDoneTasks(assignee));
}
@PostMapping("/task/{id}/approve")
public R<Map<String,Object>> approve(@PathVariable Long id,
@RequestParam(required=false) String comment) {
return R.ok(svc.approveTask(id, comment));
}
@PostMapping("/task/{id}/reject")
public R<Map<String,Object>> reject(@PathVariable Long id,
@RequestParam(required=false) String comment) {
return R.ok(svc.rejectTask(id, comment));
}
@PostMapping("/task/{id}/transfer")
public R<Map<String,Object>> transfer(@PathVariable Long id,
@RequestParam String newAssignee) {
return R.ok(svc.transferTask(id, newAssignee));
}
@GetMapping("/statistics")
public R<Map<String,Object>> stats(@RequestParam(required=false) String processKey) {
return R.ok(svc.getStatistics(processKey));
}
@GetMapping("/template/list")
public R<List<ProcessTemplate>> listTemplates() {
return R.ok(svc.listTemplates());
}
@PostMapping("/template")
public R<Long> createTemplate(@RequestBody Map<String,Object> req) {
return R.ok(svc.createTemplate(req));
}
}
@@ -0,0 +1,13 @@
package com.water.bpmengine.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data; import java.time.LocalDateTime;
@Data @TableName("bpm_process_definition")
public class ProcessDefinition {
@TableId(type = IdType.AUTO) private Long id;
private String processKey, name, category;
private String bpmnXml;
private Integer version; private Integer status; // 0草稿 1已发布 2已停用
private String description;
@TableField(fill=FieldFill.INSERT) private LocalDateTime createdTime;
@TableField(fill=FieldFill.INSERT_UPDATE) private LocalDateTime updatedTime;
}
@@ -0,0 +1,13 @@
package com.water.bpmengine.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data; import java.time.LocalDateTime;
@Data @TableName("bpm_process_instance")
public class ProcessInstance {
@TableId(type = IdType.AUTO) private Long id;
private Long definitionId; private String processKey;
private String title, initiator;
private Integer status; // 0运行中 1已完成 2已驳回 3已撤销
private String currentNodeName;
@TableField(fill=FieldFill.INSERT) private LocalDateTime createdTime;
private LocalDateTime completedTime;
}
@@ -0,0 +1,11 @@
package com.water.bpmengine.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data; import java.time.LocalDateTime;
@Data @TableName("bpm_process_template")
public class ProcessTemplate {
@TableId(type = IdType.AUTO) private Long id;
private String name, category, description;
private String bpmnXml;
private Integer status;
@TableField(fill=FieldFill.INSERT) private LocalDateTime createdTime;
}
@@ -0,0 +1,13 @@
package com.water.bpmengine.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data; import java.time.LocalDateTime;
@Data @TableName("bpm_task_item")
public class TaskItem {
@TableId(type = IdType.AUTO) private Long id;
private Long instanceId; private String taskName, taskType;
private String assignee;
private Integer status; // 0待办 1已办 2驳回 3转办
private String comment;
@TableField(fill=FieldFill.INSERT) private LocalDateTime createdTime;
private LocalDateTime completedTime;
}
@@ -0,0 +1,5 @@
package com.water.bpmengine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.bpmengine.entity.ProcessDefinition;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface ProcessDefinitionMapper extends BaseMapper<ProcessDefinition> {}
@@ -0,0 +1,5 @@
package com.water.bpmengine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.bpmengine.entity.ProcessInstance;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface ProcessInstanceMapper extends BaseMapper<ProcessInstance> {}
@@ -0,0 +1,5 @@
package com.water.bpmengine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.bpmengine.entity.ProcessTemplate;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface ProcessTemplateMapper extends BaseMapper<ProcessTemplate> {}
@@ -0,0 +1,5 @@
package com.water.bpmengine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.bpmengine.entity.TaskItem;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface TaskItemMapper extends BaseMapper<TaskItem> {}
@@ -0,0 +1,122 @@
package com.water.bpmengine.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.bpmengine.entity.*;
import com.water.bpmengine.mapper.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.util.*;
@Service @RequiredArgsConstructor
public class BpmEngineService {
private final ProcessDefinitionMapper defMapper;
private final ProcessInstanceMapper instMapper;
private final TaskItemMapper taskMapper;
private final ProcessTemplateMapper tplMapper;
// === 流程定义 ===
public List<ProcessDefinition> listDefinitions(String category, Integer status) {
return defMapper.selectList(new LambdaQueryWrapper<ProcessDefinition>()
.eq(category != null, ProcessDefinition::getCategory, category)
.eq(status != null, ProcessDefinition::getStatus, status));
}
public Long createDefinition(Map<String,Object> req) {
ProcessDefinition d = new ProcessDefinition();
d.setProcessKey((String)req.get("processKey"));
d.setName((String)req.get("name"));
d.setCategory((String)req.get("category"));
d.setBpmnXml((String)req.get("bpmnXml"));
d.setDescription((String)req.get("description"));
d.setVersion(1); d.setStatus(0);
defMapper.insert(d);
return d.getId();
}
public void publishDefinition(Long id) {
ProcessDefinition d = defMapper.selectById(id);
if (d == null) throw new RuntimeException("流程定义不存在");
d.setStatus(1); defMapper.updateById(d);
}
// === 流程实例 ===
public Map<String,Object> startProcess(Long definitionId, String title, String initiator) {
ProcessDefinition d = defMapper.selectById(definitionId);
if (d == null || d.getStatus() != 1) throw new RuntimeException("流程未发布");
ProcessInstance inst = new ProcessInstance();
inst.setDefinitionId(definitionId); inst.setProcessKey(d.getProcessKey());
inst.setTitle(title); inst.setInitiator(initiator);
inst.setStatus(0); inst.setCurrentNodeName("开始");
instMapper.insert(inst);
TaskItem t = new TaskItem();
t.setInstanceId(inst.getId()); t.setTaskName("审批节点");
t.setTaskType("审批"); t.setAssignee(initiator); t.setStatus(0);
taskMapper.insert(t);
return Map.of("instanceId", inst.getId(), "processKey", d.getProcessKey());
}
public List<ProcessInstance> listInstances(String initiator, Integer status) {
return instMapper.selectList(new LambdaQueryWrapper<ProcessInstance>()
.eq(initiator != null, ProcessInstance::getInitiator, initiator)
.eq(status != null, ProcessInstance::getStatus, status));
}
// === 任务处理 ===
public List<TaskItem> getTodoTasks(String assignee) {
return taskMapper.selectList(new LambdaQueryWrapper<TaskItem>()
.eq(TaskItem::getAssignee, assignee).eq(TaskItem::getStatus, 0));
}
public List<TaskItem> getDoneTasks(String assignee) {
return taskMapper.selectList(new LambdaQueryWrapper<TaskItem>()
.eq(TaskItem::getAssignee, assignee).in(TaskItem::getStatus, 1, 2));
}
public Map<String,Object> approveTask(Long taskId, String comment) {
TaskItem t = taskMapper.selectById(taskId);
if (t == null || t.getStatus() != 0) throw new RuntimeException("任务不可审批");
t.setStatus(1); t.setComment(comment); t.setCompletedTime(LocalDateTime.now());
taskMapper.updateById(t);
ProcessInstance inst = instMapper.selectById(t.getInstanceId());
inst.setCurrentNodeName("已完成"); inst.setStatus(1); inst.setCompletedTime(LocalDateTime.now());
instMapper.updateById(inst);
return Map.of("taskId", taskId, "status", "已审批");
}
public Map<String,Object> rejectTask(Long taskId, String comment) {
TaskItem t = taskMapper.selectById(taskId);
if (t == null) throw new RuntimeException("任务不存在");
t.setStatus(2); t.setComment(comment); t.setCompletedTime(LocalDateTime.now());
taskMapper.updateById(t);
ProcessInstance inst = instMapper.selectById(t.getInstanceId());
inst.setStatus(2); inst.setCompletedTime(LocalDateTime.now());
instMapper.updateById(inst);
return Map.of("taskId", taskId, "status", "已驳回");
}
public Map<String,Object> transferTask(Long taskId, String newAssignee) {
TaskItem t = taskMapper.selectById(taskId);
if (t == null) throw new RuntimeException("任务不存在");
t.setStatus(3); t.setCompletedTime(LocalDateTime.now());
taskMapper.updateById(t);
TaskItem nt = new TaskItem();
nt.setInstanceId(t.getInstanceId()); nt.setTaskName(t.getTaskName());
nt.setTaskType(t.getTaskType()); nt.setAssignee(newAssignee); nt.setStatus(0);
taskMapper.insert(nt);
return Map.of("oldTaskId", taskId, "newTaskId", nt.getId());
}
// === 统计 ===
public Map<String,Object> getStatistics(String processKey) {
long total = instMapper.selectCount(new LambdaQueryWrapper<ProcessInstance>()
.eq(processKey != null, ProcessInstance::getProcessKey, processKey));
long completed = instMapper.selectCount(new LambdaQueryWrapper<ProcessInstance>()
.eq(processKey != null, ProcessInstance::getProcessKey, processKey)
.eq(ProcessInstance::getStatus, 1));
return Map.of("total", total, "completed", completed,
"running", total - completed,
"completionRate", total > 0 ? (double)completed / total : 0);
}
// === 模板 ===
public List<ProcessTemplate> listTemplates() { return tplMapper.selectList(null); }
public Long createTemplate(Map<String,Object> req) {
ProcessTemplate t = new ProcessTemplate();
t.setName((String)req.get("name")); t.setCategory((String)req.get("category"));
t.setBpmnXml((String)req.get("bpmnXml")); t.setStatus(1);
tplMapper.insert(t);
return t.getId();
}
}
@@ -0,0 +1,15 @@
server:
port: 9040
spring:
application:
name: wm-bpm-engine
datasource:
url: jdbc:postgresql://localhost:5432/water_bpm
username: water
password: water123
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
global-config:
db-config:
id-type: auto
@@ -0,0 +1,23 @@
-- BPM Engine DDL
CREATE TABLE IF NOT EXISTS bpm_process_definition (
id BIGSERIAL PRIMARY KEY, process_key VARCHAR(100), name VARCHAR(200),
category VARCHAR(50), bpmn_xml TEXT, version INT DEFAULT 1,
status INT DEFAULT 0, description TEXT,
created_time TIMESTAMP DEFAULT NOW(), updated_time TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS bpm_process_instance (
id BIGSERIAL PRIMARY KEY, definition_id BIGINT, process_key VARCHAR(100),
title VARCHAR(200), initiator VARCHAR(50), status INT DEFAULT 0,
current_node_name VARCHAR(100),
created_time TIMESTAMP DEFAULT NOW(), completed_time TIMESTAMP
);
CREATE TABLE IF NOT EXISTS bpm_task_item (
id BIGSERIAL PRIMARY KEY, instance_id BIGINT, task_name VARCHAR(100),
task_type VARCHAR(30), assignee VARCHAR(50), status INT DEFAULT 0,
comment TEXT, created_time TIMESTAMP DEFAULT NOW(), completed_time TIMESTAMP
);
CREATE TABLE IF NOT EXISTS bpm_process_template (
id BIGSERIAL PRIMARY KEY, name VARCHAR(200), category VARCHAR(50),
bpmn_xml TEXT, status INT DEFAULT 1,
created_time TIMESTAMP DEFAULT NOW()
);
@@ -1,18 +1,63 @@
package com.water.bpm.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 审批记录实体
* BPM-03: 流程处理
*/
@Data
public class BpmApprovalRecord {
private Long id;
@EqualsAndHashCode(callSuper = true)
@TableName("bpm_approval_record")
public class BpmApprovalRecord extends BaseEntity {
/** 流程实例ID */
private Long instanceId;
/** 流程实例UUID */
private String instanceUuid;
/** 节点标识 */
private String nodeId;
/** 节点名称 */
private String nodeName;
/** 审批人ID */
private Long approverId;
/** 审批人姓名 */
private String approverName;
private String action; // approve/reject/transfer/delegate/back
/** 审批动作: approve/reject/transfer/delegate/back/countersign */
private String action;
/** 审批意见 */
private String comment;
private String targetAssignee; // 转办/委派目标
/** 转办/委派目标人ID */
private Long targetAssigneeId;
/** 转办/委派目标人姓名 */
private String targetAssigneeName;
/** 会签结果: all/pass_one/veto */
private String countersignResult;
/** 会签通过数 */
private Integer countersignApproved;
/** 会签总数 */
private Integer countersignTotal;
/** 审批时间 */
private LocalDateTime approvedAt;
/** 租户ID */
private String tenantId;
}
@@ -0,0 +1,49 @@
package com.water.bpm.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 流程表单模板实体
* BPM-02: 模板化快速创建
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("bpm_form_template")
public class BpmFormTemplate extends BaseEntity {
/** 模板名称 */
private String templateName;
/** 模板编码 */
private String templateCode;
/** 模板分类 */
private String category;
/** 表单 JSON Schema */
private String formSchema;
/** 流程 BPMN XML 模板 */
private String bpmnTemplate;
/** 模板描述 */
private String description;
/** 模板图标 */
private String icon;
/** 使用次数 */
private Integer useCount;
/** 状态: 0-草稿 1-启用 2-停用 */
private Integer status;
/** 创建人 */
private String createdBy;
/** 租户ID */
private String tenantId;
}
@@ -0,0 +1,52 @@
package com.water.bpm.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 流程编排实体
* BPM-05: 跨系统流程编排
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("bpm_orchestration")
public class BpmOrchestration extends BaseEntity {
/** 编排名称 */
private String orchestrationName;
/** 编排编码 */
private String orchestrationCode;
/** 编排描述 */
private String description;
/** 包含的流程定义ID列表 JSON */
private String processDefinitionIds;
/** 编排规则 JSON (流程间依赖关系、触发条件) */
private String orchestrationRules;
/** 触发方式: manual/scheduled/event */
private String triggerType;
/** 定时表达式 (cron) */
private String cronExpression;
/** 事件名称 (event触发时) */
private String eventName;
/** 状态: 0-草稿 1-启用 2-停用 */
private Integer status;
/** 创建人 */
private String createdBy;
/** 执行次数 */
private Integer executionCount;
/** 租户ID */
private String tenantId;
}
@@ -1,20 +1,58 @@
package com.water.bpm.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 流程定义实体
* BPM-01: 流程定义管理
*/
@Data
public class BpmProcessDefinition {
private Long id;
@EqualsAndHashCode(callSuper = true)
@TableName("bpm_process_definition")
public class BpmProcessDefinition extends BaseEntity {
/** 流程唯一标识 */
private String processKey;
/** 流程名称 */
private String processName;
/** 流程描述 */
private String description;
private String bpmnXml; // BPMN 2.0 XML
private String formSchema; // 表单 JSON Schema
private String category; // revenue/patrol/dispatch/maintenance
/** BPMN 2.0 XML 定义 */
private String bpmnXml;
/** 表单 JSON Schema */
private String formSchema;
/** 流程分类: revenue/patrol/dispatch/maintenance/inspection */
private String category;
/** 版本号 */
@TableField("version")
private Integer version;
private Integer status; // 0:草稿 1:发布 2:停用
/** 状态: 0-草稿 1-已发布 2-已停用 */
private Integer status;
/** 创建人 */
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** 流程图标 */
private String icon;
/** 排序号 */
private Integer sortOrder;
/** 发布时间 */
private LocalDateTime publishedAt;
/** 租户ID */
private String tenantId;
}
@@ -1,26 +1,84 @@
package com.water.bpm.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 流程实例实体
* BPM-03: 流程处理
*/
@Data
public class BpmProcessInstance {
private Long id;
@EqualsAndHashCode(callSuper = true)
@TableName("bpm_process_instance")
public class BpmProcessInstance extends BaseEntity {
/** 实例唯一ID */
private String instanceId;
/** 关联流程定义ID */
private Long definitionId;
/** 流程标识 */
private String processKey;
private String businessKey; // 关联业务ID
private String businessType; // 业务类型
/** 流程名称 */
private String processName;
/** 业务主键(关联业务表) */
private String businessKey;
/** 业务类型 */
private String businessType;
/** 流程标题 */
private String title;
/** 发起人ID */
private Long initiatorId;
/** 发起人姓名 */
private String initiatorName;
private String currentNode; // 当前审批节点
private String currentAssignee; // 当前处理人
private String status; // running/completed/terminated/rejected
private Map<String, Object> variables;
private Map<String, Object> formData;
/** 当前节点标识 */
private String currentNodeId;
/** 当前节点名称 */
private String currentNodeName;
/** 当前处理人ID */
private Long currentAssigneeId;
/** 当前处理人姓名 */
private String currentAssigneeName;
/** 状态: running/completed/terminated/rejected/suspended */
private String status;
/** 优先级: 0-普通 1-紧急 2-特急 */
private Integer priority;
/** 流程变量 JSON */
private String variables;
/** 表单数据 JSON */
private String formData;
/** 开始时间 */
private LocalDateTime startedAt;
/** 结束时间 */
private LocalDateTime completedAt;
private LocalDateTime createdAt;
/** 预计完成时间 */
private LocalDateTime expectedCompletionAt;
/** 耗时(秒) */
private Long durationSeconds;
/** 租户ID */
private String tenantId;
}
@@ -0,0 +1,61 @@
package com.water.bpm.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 流程节点定义实体
* BPM-01: 流程定义
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("bpm_process_node")
public class BpmProcessNode extends BaseEntity {
/** 关联流程定义ID */
private Long definitionId;
/** 节点标识 */
private String nodeId;
/** 节点名称 */
private String nodeName;
/** 节点类型: start/end/userTask/serviceTask/gateway/subprocess/timer */
private String nodeType;
/** 处理人类型: role/user/department/position/initiator */
private String assigneeType;
/** 处理人值(角色ID/用户ID/部门ID等) */
private String assigneeValue;
/** 处理人名称(冗余) */
private String assigneeName;
/** 多人审批方式: sequential(会签)/parallel(或签)/countersign(比例签) */
private String multiInstanceType;
/** 会签通过比例 */
private Integer countersignRate;
/** 超时时间(小时) */
private Integer timeoutHours;
/** 超时处理: remind/transfer/escalate/auto_approve */
private String timeoutAction;
/** 表单权限 JSON */
private String formPermission;
/** 节点条件表达式 */
private String conditionExpression;
/** 排序号 */
private Integer sortOrder;
/** 租户ID */
private String tenantId;
}
@@ -0,0 +1,63 @@
package com.water.bpm.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 流程统计实体
* BPM-04: 统计评估
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("bpm_process_stat")
public class BpmProcessStat extends BaseEntity {
/** 流程定义ID */
private Long definitionId;
/** 流程标识 */
private String processKey;
/** 统计周期: day/week/month/quarter/year */
private String period;
/** 统计日期 */
private String statDate;
/** 发起总数 */
private Integer startCount;
/** 完成总数 */
private Integer completedCount;
/** 驳回总数 */
private Integer rejectedCount;
/** 撤回总数 */
private Integer terminatedCount;
/** 平均耗时(秒) */
private Long avgDurationSeconds;
/** 最长耗时(秒) */
private Long maxDurationSeconds;
/** 最短耗时(秒) */
private Long minDurationSeconds;
/** 平均节点耗时(秒) */
private Long avgNodeDurationSeconds;
/** 超时任务数 */
private Integer timeoutCount;
/** 一次通过率(百分比) */
private Double firstPassRate;
/** 租户ID */
private String tenantId;
}
@@ -0,0 +1,75 @@
package com.water.bpm.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.water.common.core.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 待办任务实体
* BPM-06: 待办/已办中心
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("bpm_todo_task")
public class BpmTodoTask extends BaseEntity {
/** 流程实例ID */
private Long instanceId;
/** 流程实例UUID */
private String instanceUuid;
/** 流程标题 */
private String title;
/** 流程标识 */
private String processKey;
/** 流程名称 */
private String processName;
/** 节点标识 */
private String nodeId;
/** 节点名称 */
private String nodeName;
/** 处理人ID */
private Long assigneeId;
/** 处理人姓名 */
private String assigneeName;
/** 发起人ID */
private Long initiatorId;
/** 发起人姓名 */
private String initiatorName;
/** 业务主键 */
private String businessKey;
/** 任务状态: pending/completed/transferred/delegated */
private String status;
/** 优先级: 0-普通 1-紧急 2-特急 */
private Integer priority;
/** 接收时间 */
private LocalDateTime receivedAt;
/** 完成时间 */
private LocalDateTime completedAt;
/** 超时时间 */
private LocalDateTime deadlineAt;
/** 是否已读 */
private Boolean isRead;
/** 租户ID */
private String tenantId;
}
@@ -0,0 +1,33 @@
package com.water.bpm.entity.dto;
import lombok.Data;
/**
* 审批请求 DTO
*/
@Data
public class ApprovalRequest {
/** 流程实例ID */
private Long instanceId;
/** 流程实例UUID */
private String instanceUuid;
/** 节点标识 */
private String nodeId;
/** 节点名称 */
private String nodeName;
/** 审批动作: approve/reject/transfer/delegate/back/countersign */
private String action;
/** 审批意见 */
private String comment;
/** 转办/委派目标人ID */
private Long targetAssigneeId;
/** 转办/委派目标人姓名 */
private String targetAssigneeName;
}
@@ -0,0 +1,41 @@
package com.water.bpm.entity.dto;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 流程实例查询 DTO
*/
@Data
public class ProcessInstanceQuery {
/** 流程标识 */
private String processKey;
/** 流程标题 */
private String title;
/** 状态 */
private String status;
/** 发起人ID */
private Long initiatorId;
/** 业务主键 */
private String businessKey;
/** 业务类型 */
private String businessType;
/** 开始时间(起) */
private LocalDateTime startTimeFrom;
/** 开始时间(止) */
private LocalDateTime startTimeTo;
/** 页码 */
private Integer pageNum = 1;
/** 每页条数 */
private Integer pageSize = 10;
}
@@ -0,0 +1,32 @@
package com.water.bpm.entity.dto;
import lombok.Data;
import java.util.Map;
/**
* 流程发起请求 DTO
*/
@Data
public class ProcessStartRequest {
/** 流程定义ID */
private Long definitionId;
/** 业务主键 */
private String businessKey;
/** 业务类型 */
private String businessType;
/** 流程标题 */
private String title;
/** 表单数据 */
private Map<String, Object> formData;
/** 流程变量 */
private Map<String, Object> variables;
/** 优先级 */
private Integer priority;
}
@@ -0,0 +1,60 @@
package com.water.bpm.entity.dto;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 流程统计 VO
*/
@Data
public class ProcessStatVO {
/** 流程定义ID */
private Long definitionId;
/** 流程名称 */
private String processName;
/** 流程标识 */
private String processKey;
/** 总实例数 */
private Integer totalInstances;
/** 运行中数量 */
private Integer runningCount;
/** 已完成数量 */
private Integer completedCount;
/** 已驳回数量 */
private Integer rejectedCount;
/** 已撤回数量 */
private Integer terminatedCount;
/** 平均耗时(小时) */
private Double avgDurationHours;
/** 最长耗时(小时) */
private Double maxDurationHours;
/** 最短耗时(小时) */
private Double minDurationHours;
/** 一次通过率 */
private Double firstPassRate;
/** 超时率 */
private Double timeoutRate;
/** 各节点平均耗时 */
private List<Map<String, Object>> nodeAvgDurations;
/** 瓶颈节点(耗时最长) */
private String bottleneckNode;
/** 趋势数据 */
private List<Map<String, Object>> trendData;
}
@@ -0,0 +1,39 @@
package com.water.bpm.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.bpm.entity.BpmApprovalRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 审批记录 Mapper
*/
@Mapper
public interface BpmApprovalRecordMapper extends BaseMapper<BpmApprovalRecord> {
/**
* 根据流程实例查询审批记录
*/
@Select("SELECT * FROM bpm_approval_record WHERE instance_id = #{instanceId} AND deleted = 0 ORDER BY approved_at")
List<BpmApprovalRecord> selectByInstanceId(@Param("instanceId") Long instanceId);
/**
* 根据流程实例UUID查询审批记录
*/
@Select("SELECT * FROM bpm_approval_record WHERE instance_uuid = #{instanceUuid} AND deleted = 0 ORDER BY approved_at")
List<BpmApprovalRecord> selectByInstanceUuid(@Param("instanceUuid") String instanceUuid);
/**
* 查询某人的审批统计
*/
@Select("SELECT approver_id, approver_name, COUNT(*) as total, " +
"SUM(CASE WHEN action = 'approve' THEN 1 ELSE 0 END) as approved, " +
"SUM(CASE WHEN action = 'reject' THEN 1 ELSE 0 END) as rejected, " +
"AVG(EXTRACT(EPOCH FROM (approved_at - created_at))) as avg_handle_seconds " +
"FROM bpm_approval_record WHERE deleted = 0 AND approved_at >= #{startDate} " +
"GROUP BY approver_id, approver_name")
List<java.util.Map<String, Object>> statByApprover(@Param("startDate") java.time.LocalDateTime startDate);
}
@@ -0,0 +1,28 @@
package com.water.bpm.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.bpm.entity.BpmFormTemplate;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 表单模板 Mapper
*/
@Mapper
public interface BpmFormTemplateMapper extends BaseMapper<BpmFormTemplate> {
/**
* 根据分类查询模板
*/
@Select("SELECT * FROM bpm_form_template WHERE category = #{category} AND status = 1 AND deleted = 0 ORDER BY use_count DESC")
List<BpmFormTemplate> selectByCategory(@Param("category") String category);
/**
* 查询热门模板
*/
@Select("SELECT * FROM bpm_form_template WHERE status = 1 AND deleted = 0 ORDER BY use_count DESC LIMIT #{limit}")
List<BpmFormTemplate> selectHotTemplates(@Param("limit") int limit);
}
@@ -0,0 +1,28 @@
package com.water.bpm.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.bpm.entity.BpmOrchestration;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 流程编排 Mapper
*/
@Mapper
public interface BpmOrchestrationMapper extends BaseMapper<BpmOrchestration> {
/**
* 查询所有启用的编排
*/
@Select("SELECT * FROM bpm_orchestration WHERE status = 1 AND deleted = 0 ORDER BY created_at DESC")
List<BpmOrchestration> selectEnabled();
/**
* 查询事件触发的编排
*/
@Select("SELECT * FROM bpm_orchestration WHERE trigger_type = 'event' AND event_name = #{eventName} " +
"AND status = 1 AND deleted = 0")
List<BpmOrchestration> selectByEvent(String eventName);
}
@@ -0,0 +1,34 @@
package com.water.bpm.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.bpm.entity.BpmProcessDefinition;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 流程定义 Mapper
*/
@Mapper
public interface BpmProcessDefinitionMapper extends BaseMapper<BpmProcessDefinition> {
/**
* 根据分类查询已发布的流程定义
*/
@Select("SELECT * FROM bpm_process_definition WHERE category = #{category} AND status = 1 AND deleted = 0 ORDER BY sort_order")
List<BpmProcessDefinition> selectByCategory(@Param("category") String category);
/**
* 根据 processKey 查询最新版本
*/
@Select("SELECT * FROM bpm_process_definition WHERE process_key = #{processKey} AND deleted = 0 ORDER BY version DESC LIMIT 1")
BpmProcessDefinition selectByProcessKey(@Param("processKey") String processKey);
/**
* 查询所有分类
*/
@Select("SELECT DISTINCT category FROM bpm_process_definition WHERE deleted = 0 AND status = 1 ORDER BY category")
List<String> selectAllCategories();
}
@@ -0,0 +1,43 @@
package com.water.bpm.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.bpm.entity.BpmProcessInstance;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;
/**
* 流程实例 Mapper
*/
@Mapper
public interface BpmProcessInstanceMapper extends BaseMapper<BpmProcessInstance> {
/**
* 统计各状态数量
*/
@Select("SELECT status, COUNT(*) as count FROM bpm_process_instance WHERE deleted = 0 GROUP BY status")
List<Map<String, Object>> countByStatus();
/**
* 根据流程定义统计
*/
@Select("SELECT definition_id, COUNT(*) as total, " +
"SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running, " +
"SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, " +
"SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected, " +
"SUM(CASE WHEN status = 'terminated' THEN 1 ELSE 0 END) as terminated, " +
"AVG(duration_seconds) as avg_duration, " +
"MAX(duration_seconds) as max_duration, " +
"MIN(duration_seconds) as min_duration " +
"FROM bpm_process_instance WHERE deleted = 0 GROUP BY definition_id")
List<Map<String, Object>> statByDefinition();
/**
* 查询我发起的流程
*/
@Select("SELECT * FROM bpm_process_instance WHERE initiator_id = #{userId} AND deleted = 0 ORDER BY created_at DESC")
List<BpmProcessInstance> selectMyInitiated(@Param("userId") Long userId);
}
@@ -0,0 +1,22 @@
package com.water.bpm.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.bpm.entity.BpmProcessNode;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 流程节点 Mapper
*/
@Mapper
public interface BpmProcessNodeMapper extends BaseMapper<BpmProcessNode> {
/**
* 查询流程定义的所有节点
*/
@Select("SELECT * FROM bpm_process_node WHERE definition_id = #{definitionId} AND deleted = 0 ORDER BY sort_order")
List<BpmProcessNode> selectByDefinitionId(@Param("definitionId") Long definitionId);
}
@@ -0,0 +1,25 @@
package com.water.bpm.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.bpm.entity.BpmProcessStat;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 流程统计 Mapper
*/
@Mapper
public interface BpmProcessStatMapper extends BaseMapper<BpmProcessStat> {
/**
* 查询某流程的统计趋势
*/
@Select("SELECT * FROM bpm_process_stat WHERE definition_id = #{definitionId} AND period = #{period} " +
"AND deleted = 0 ORDER BY stat_date DESC LIMIT #{limit}")
List<BpmProcessStat> selectTrend(@Param("definitionId") Long definitionId,
@Param("period") String period,
@Param("limit") int limit);
}
@@ -0,0 +1,42 @@
package com.water.bpm.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.bpm.entity.BpmTodoTask;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 待办任务 Mapper
*/
@Mapper
public interface BpmTodoTaskMapper extends BaseMapper<BpmTodoTask> {
/**
* 查询待办列表
*/
@Select("SELECT * FROM bpm_todo_task WHERE assignee_id = #{userId} AND status = 'pending' AND deleted = 0 " +
"ORDER BY priority DESC, received_at")
List<BpmTodoTask> selectPendingByUserId(@Param("userId") Long userId);
/**
* 查询已办列表
*/
@Select("SELECT * FROM bpm_todo_task WHERE assignee_id = #{userId} AND status != 'pending' AND deleted = 0 " +
"ORDER BY completed_at DESC")
List<BpmTodoTask> selectDoneByUserId(@Param("userId") Long userId);
/**
* 统计待办数量
*/
@Select("SELECT COUNT(*) FROM bpm_todo_task WHERE assignee_id = #{userId} AND status = 'pending' AND deleted = 0")
Integer countPendingByUserId(@Param("userId") Long userId);
/**
* 查询超时的待办
*/
@Select("SELECT * FROM bpm_todo_task WHERE status = 'pending' AND deadline_at < NOW() AND deleted = 0")
List<BpmTodoTask> selectTimeoutTasks();
}
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>com.water</groupId><artifactId>wm-parent</artifactId><version>1.0.0-SNAPSHOT</version></parent>
<artifactId>wm-config</artifactId>
<dependencies>
<dependency><groupId>com.water</groupId><artifactId>wm-common</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
</dependencies>
</project>
@@ -0,0 +1,15 @@
package com.water.config;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication
@EnableDiscoveryClient
@MapperScan("com.water.config.mapper")
public class ConfigApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigApplication.class, args);
}
}
@@ -0,0 +1,68 @@
package com.water.config.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.result.R;
import com.water.config.entity.Announcement;
import com.water.config.service.AnnouncementService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "公告通知管理")
@RestController
@RequestMapping("/api/config/announcement")
@RequiredArgsConstructor
public class AnnouncementController {
private final AnnouncementService announcementService;
@Operation(summary = "分页查询公告")
@GetMapping("/list")
public R<Page<Announcement>> list(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) Integer type,
@RequestParam(required = false) Integer publishStatus) {
return R.ok(announcementService.pageAnnouncements(page, size, type, publishStatus));
}
@Operation(summary = "获取公告详情")
@GetMapping("/{id}")
public R<Announcement> getById(@PathVariable Long id) {
return R.ok(announcementService.getById(id));
}
@Operation(summary = "创建公告(草稿)")
@PostMapping
public R<Announcement> create(@RequestBody Announcement announcement) {
return R.ok(announcementService.createAnnouncement(announcement));
}
@Operation(summary = "更新公告")
@PutMapping("/{id}")
public R<String> update(@PathVariable Long id, @RequestBody Announcement announcement) {
announcementService.updateAnnouncement(id, announcement);
return R.ok("更新成功");
}
@Operation(summary = "发布公告")
@PostMapping("/{id}/publish")
public R<String> publish(@PathVariable Long id) {
announcementService.publish(id);
return R.ok("发布成功");
}
@Operation(summary = "撤回公告")
@PostMapping("/{id}/withdraw")
public R<String> withdraw(@PathVariable Long id) {
announcementService.withdraw(id);
return R.ok("撤回成功");
}
@Operation(summary = "删除公告")
@DeleteMapping("/{id}")
public R<String> delete(@PathVariable Long id) {
announcementService.deleteAnnouncement(id);
return R.ok("删除成功");
}
}
@@ -0,0 +1,98 @@
package com.water.config.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.result.R;
import com.water.config.entity.DeviceInfo;
import com.water.config.entity.DeviceMaintenance;
import com.water.config.service.DeviceManageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "设备管理")
@RestController
@RequestMapping("/api/config/device")
@RequiredArgsConstructor
public class DeviceManageController {
private final DeviceManageService deviceManageService;
@Operation(summary = "分页查询设备")
@GetMapping("/list")
public R<Page<DeviceInfo>> list(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String deviceName,
@RequestParam(required = false) Integer category,
@RequestParam(required = false) Integer deviceStatus) {
return R.ok(deviceManageService.pageDevices(page, size, deviceName, category, deviceStatus));
}
@Operation(summary = "获取设备详情")
@GetMapping("/{id}")
public R<DeviceInfo> getById(@PathVariable Long id) {
return R.ok(deviceManageService.getById(id));
}
@Operation(summary = "创建设备")
@PostMapping
public R<DeviceInfo> create(@RequestBody DeviceInfo device) {
return R.ok(deviceManageService.createDevice(device));
}
@Operation(summary = "更新设备")
@PutMapping("/{id}")
public R<String> update(@PathVariable Long id, @RequestBody DeviceInfo device) {
deviceManageService.updateDevice(id, device);
return R.ok("更新成功");
}
@Operation(summary = "删除设备")
@DeleteMapping("/{id}")
public R<String> delete(@PathVariable Long id) {
deviceManageService.removeById(id);
return R.ok("删除成功");
}
@Operation(summary = "更新设备状态")
@PutMapping("/{id}/status")
public R<String> updateStatus(@PathVariable Long id, @RequestParam Integer deviceStatus) {
deviceManageService.updateDeviceStatus(id, deviceStatus);
return R.ok("状态更新成功");
}
@Operation(summary = "按分类查询设备")
@GetMapping("/category/{category}")
public R<List<DeviceInfo>> getByCategory(@PathVariable Integer category) {
return R.ok(deviceManageService.getDevicesByCategory(category));
}
@Operation(summary = "按状态查询设备")
@GetMapping("/status/{status}")
public R<List<DeviceInfo>> getByStatus(@PathVariable Integer status) {
return R.ok(deviceManageService.getDevicesByStatus(status));
}
@Operation(summary = "添加维保记录")
@PostMapping("/maintenance")
public R<DeviceMaintenance> addMaintenance(@RequestBody DeviceMaintenance maintenance) {
return R.ok(deviceManageService.addMaintenance(maintenance));
}
@Operation(summary = "查询维保记录")
@GetMapping("/maintenance")
public R<Page<DeviceMaintenance>> pageMaintenances(@RequestParam(required = false) Long deviceId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return R.ok(deviceManageService.pageMaintenances(deviceId, page, size));
}
@Operation(summary = "更新维保记录")
@PutMapping("/maintenance/{id}")
public R<String> updateMaintenance(@PathVariable Long id, @RequestBody DeviceMaintenance maintenance) {
deviceManageService.updateMaintenance(id, maintenance);
return R.ok("更新成功");
}
}
@@ -0,0 +1,84 @@
package com.water.config.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.result.R;
import com.water.config.entity.ThresholdChangeLog;
import com.water.config.entity.ThresholdConfig;
import com.water.config.service.ThresholdService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "阈值管理")
@RestController
@RequestMapping("/api/config/threshold")
@RequiredArgsConstructor
public class ThresholdController {
private final ThresholdService thresholdService;
@Operation(summary = "分页查询阈值配置")
@GetMapping("/list")
public R<Page<ThresholdConfig>> list(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String metricCode,
@RequestParam(required = false) Integer level) {
return R.ok(thresholdService.pageThresholds(page, size, metricCode, level));
}
@Operation(summary = "获取阈值详情")
@GetMapping("/{id}")
public R<ThresholdConfig> getById(@PathVariable Long id) {
return R.ok(thresholdService.getById(id));
}
@Operation(summary = "创建阈值配置")
@PostMapping
public R<ThresholdConfig> create(@RequestBody ThresholdConfig config) {
return R.ok(thresholdService.createThreshold(config));
}
@Operation(summary = "更新阈值配置")
@PutMapping("/{id}")
public R<String> update(@PathVariable Long id, @RequestBody ThresholdConfig config) {
thresholdService.updateThreshold(id, config);
return R.ok("更新成功");
}
@Operation(summary = "删除阈值配置")
@DeleteMapping("/{id}")
public R<String> delete(@PathVariable Long id) {
thresholdService.deleteThreshold(id);
return R.ok("删除成功");
}
@Operation(summary = "启用/禁用阈值")
@PutMapping("/{id}/status")
public R<String> toggleStatus(@PathVariable Long id, @RequestParam Integer status) {
thresholdService.toggleStatus(id, status);
return R.ok(status == 1 ? "已启用" : "已禁用");
}
@Operation(summary = "获取指标的全局阈值(多级)")
@GetMapping("/global/{metricCode}")
public R<List<ThresholdConfig>> getGlobalThresholds(@PathVariable String metricCode) {
return R.ok(thresholdService.getGlobalThresholds(metricCode));
}
@Operation(summary = "获取设备阈值配置")
@GetMapping("/device/{deviceId}")
public R<List<ThresholdConfig>> getDeviceThresholds(@PathVariable Long deviceId) {
return R.ok(thresholdService.getDeviceThresholds(deviceId));
}
@Operation(summary = "获取阈值变更历史")
@GetMapping("/history")
public R<Page<ThresholdChangeLog>> getChangeHistory(@RequestParam(required = false) Long thresholdId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return R.ok(thresholdService.getChangeHistory(thresholdId, page, size));
}
}
@@ -0,0 +1,35 @@
package com.water.config.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 公告通知
*/
@Data
@TableName("config_announcement")
public class Announcement {
@TableId(type = IdType.AUTO)
private Long id;
/** 标题 */
private String title;
/** 内容 */
private String content;
/** 类型: 1-系统公告 2-维护通知 3-紧急通知 */
private Integer type;
/** 发布状态: 0-草稿 1-已发布 2-已撤回 */
private Integer publishStatus;
/** 发布渠道(JSON数组): ["sms","push","site"] */
private String channels;
/** 发布人 */
private String publisher;
/** 发布时间 */
private LocalDateTime publishTime;
/** 撤回时间 */
private LocalDateTime withdrawTime;
@TableLogic
private Integer deleted;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,45 @@
package com.water.config.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 设备台账
*/
@Data
@TableName("config_device_info")
public class DeviceInfo {
@TableId(type = IdType.AUTO)
private Long id;
/** 设备编码 */
private String deviceCode;
/** 设备名称 */
private String deviceName;
/** 设备分类: 1-水表 2-压力传感器 3-流量计 4-水质监测仪 5-阀门 9-其他 */
private Integer category;
/** 品牌 */
private String brand;
/** 型号 */
private String model;
/** 安装位置 */
private String location;
/** 经度 */
private Double longitude;
/** 纬度 */
private Double latitude;
/** 设备状态: 0-离线 1-在线 2-故障 3-维修中 */
private Integer deviceStatus;
/** 安装日期 */
private LocalDateTime installDate;
/** 最后维护时间 */
private LocalDateTime lastMaintenanceTime;
/** 负责人 */
private String responsiblePerson;
/** 备注 */
private String remark;
@TableLogic
private Integer deleted;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,37 @@
package com.water.config.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 设备维保记录
*/
@Data
@TableName("config_device_maintenance")
public class DeviceMaintenance {
@TableId(type = IdType.AUTO)
private Long id;
/** 设备ID */
private Long deviceId;
/** 维保类型: 1-日常巡检 2-定期保养 3-故障维修 4-更换配件 */
private Integer maintenanceType;
/** 维保描述 */
private String description;
/** 维保人 */
private String operator;
/** 维保开始时间 */
private LocalDateTime startTime;
/** 维保结束时间 */
private LocalDateTime endTime;
/** 维保结果: 0-未完成 1-已完成 2-需要返修 */
private Integer result;
/** 费用 */
private Double cost;
/** 附件(JSON) */
private String attachments;
@TableLogic
private Integer deleted;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,35 @@
package com.water.config.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 阈值变更记录
*/
@Data
@TableName("config_threshold_change_log")
public class ThresholdChangeLog {
@TableId(type = IdType.AUTO)
private Long id;
/** 关联阈值ID */
private Long thresholdId;
/** 变更前最小值 */
private BigDecimal oldMinValue;
/** 变更前最大值 */
private BigDecimal oldMaxValue;
/** 变更后最小值 */
private BigDecimal newMinValue;
/** 变更后最大值 */
private BigDecimal newMaxValue;
/** 变更前级别 */
private Integer oldLevel;
/** 变更后级别 */
private Integer newLevel;
/** 变更人 */
private String operator;
/** 变更原因 */
private String reason;
private LocalDateTime createdAt;
}
@@ -0,0 +1,38 @@
package com.water.config.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 阈值配置
*/
@Data
@TableName("config_threshold")
public class ThresholdConfig {
@TableId(type = IdType.AUTO)
private Long id;
/** 指标编码 */
private String metricCode;
/** 指标名称 */
private String metricName;
/** 设备ID(可选,null表示全局) */
private Long deviceId;
/** 阈值级别: 1-预警 2-报警 3-紧急 */
private Integer level;
/** 最小值 */
private BigDecimal minValue;
/** 最大值 */
private BigDecimal maxValue;
/** 单位 */
private String unit;
/** 启用状态: 0-禁用 1-启用 */
private Integer status;
/** 备注 */
private String remark;
@TableLogic
private Integer deleted;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,9 @@
package com.water.config.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.config.entity.Announcement;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AnnouncementMapper extends BaseMapper<Announcement> {
}
@@ -0,0 +1,17 @@
package com.water.config.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.config.entity.DeviceInfo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface DeviceInfoMapper extends BaseMapper<DeviceInfo> {
@Select("SELECT * FROM config_device_info WHERE device_status = #{status} AND deleted = 0")
List<DeviceInfo> selectByDeviceStatus(Integer status);
@Select("SELECT * FROM config_device_info WHERE category = #{category} AND deleted = 0 ORDER BY device_code")
List<DeviceInfo> selectByCategory(Integer category);
}
@@ -0,0 +1,14 @@
package com.water.config.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.config.entity.DeviceMaintenance;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface DeviceMaintenanceMapper extends BaseMapper<DeviceMaintenance> {
@Select("SELECT * FROM config_device_maintenance WHERE device_id = #{deviceId} AND deleted = 0 ORDER BY start_time DESC")
List<DeviceMaintenance> selectByDeviceId(Long deviceId);
}
@@ -0,0 +1,9 @@
package com.water.config.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.config.entity.ThresholdChangeLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ThresholdChangeLogMapper extends BaseMapper<ThresholdChangeLog> {
}
@@ -0,0 +1,17 @@
package com.water.config.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.config.entity.ThresholdConfig;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ThresholdConfigMapper extends BaseMapper<ThresholdConfig> {
@Select("SELECT * FROM config_threshold WHERE metric_code = #{metricCode} AND device_id IS NULL AND status = 1 AND deleted = 0 ORDER BY level")
List<ThresholdConfig> selectGlobalByMetricCode(String metricCode);
@Select("SELECT * FROM config_threshold WHERE device_id = #{deviceId} AND status = 1 AND deleted = 0 ORDER BY metric_code, level")
List<ThresholdConfig> selectByDeviceId(Long deviceId);
}
@@ -0,0 +1,131 @@
package com.water.config.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.water.common.core.exception.BusinessException;
import com.water.config.entity.Announcement;
import com.water.config.mapper.AnnouncementMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 公告通知服务
*/
@Slf4j
@Service
public class AnnouncementService extends ServiceImpl<AnnouncementMapper, Announcement> {
/**
* 分页查询公告
*/
public Page<Announcement> pageAnnouncements(int page, int size, Integer type, Integer publishStatus) {
LambdaQueryWrapper<Announcement> qw = new LambdaQueryWrapper<>();
if (type != null) {
qw.eq(Announcement::getType, type);
}
if (publishStatus != null) {
qw.eq(Announcement::getPublishStatus, publishStatus);
}
qw.orderByDesc(Announcement::getCreatedAt);
return this.page(new Page<>(page, size), qw);
}
/**
* 创建公告(草稿)
*/
public Announcement createAnnouncement(Announcement announcement) {
announcement.setPublishStatus(0);
this.save(announcement);
return announcement;
}
/**
* 更新公告(仅草稿可更新)
*/
public void updateAnnouncement(Long id, Announcement announcement) {
Announcement existing = this.getById(id);
if (existing == null) {
throw new BusinessException("公告不存在");
}
if (existing.getPublishStatus() != 0) {
throw new BusinessException("已发布的公告不可修改");
}
announcement.setId(id);
this.updateById(announcement);
}
/**
* 发布公告
*/
@Transactional
public void publish(Long id) {
Announcement announcement = this.getById(id);
if (announcement == null) {
throw new BusinessException("公告不存在");
}
if (announcement.getPublishStatus() != 0) {
throw new BusinessException("只有草稿状态的公告可以发布");
}
announcement.setPublishStatus(1);
announcement.setPublishTime(LocalDateTime.now());
this.updateById(announcement);
// 多渠道发布
dispatchChannels(announcement);
}
/**
* 撤回公告
*/
@Transactional
public void withdraw(Long id) {
Announcement announcement = this.getById(id);
if (announcement == null) {
throw new BusinessException("公告不存在");
}
if (announcement.getPublishStatus() != 1) {
throw new BusinessException("只有已发布的公告可以撤回");
}
announcement.setPublishStatus(2);
announcement.setWithdrawTime(LocalDateTime.now());
this.updateById(announcement);
}
/**
* 删除公告
*/
public void deleteAnnouncement(Long id) {
Announcement existing = this.getById(id);
if (existing == null) {
throw new BusinessException("公告不存在");
}
if (existing.getPublishStatus() == 1) {
throw new BusinessException("已发布的公告不可删除,请先撤回");
}
this.removeById(id);
}
/**
* 多渠道分发(模拟)
*/
private void dispatchChannels(Announcement announcement) {
String channels = announcement.getChannels();
if (channels == null || channels.isEmpty()) {
log.info("公告 {} 无渠道配置,仅站内信发布", announcement.getId());
return;
}
log.info("公告 {} 发布渠道: {}", announcement.getId(), channels);
if (channels.contains("sms")) {
log.info("→ 短信渠道已触发");
}
if (channels.contains("push")) {
log.info("→ APP推送渠道已触发");
}
if (channels.contains("site")) {
log.info("→ 站内信渠道已触发");
}
}
}
@@ -0,0 +1,150 @@
package com.water.config.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.water.common.core.exception.BusinessException;
import com.water.config.entity.DeviceInfo;
import com.water.config.entity.DeviceMaintenance;
import com.water.config.mapper.DeviceInfoMapper;
import com.water.config.mapper.DeviceMaintenanceMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 设备管理服务
*/
@Service
@RequiredArgsConstructor
public class DeviceManageService extends ServiceImpl<DeviceInfoMapper, DeviceInfo> {
private final DeviceMaintenanceMapper maintenanceMapper;
/**
* 分页查询设备
*/
public Page<DeviceInfo> pageDevices(int page, int size, String deviceName, Integer category, Integer deviceStatus) {
LambdaQueryWrapper<DeviceInfo> qw = new LambdaQueryWrapper<>();
if (deviceName != null && !deviceName.isEmpty()) {
qw.like(DeviceInfo::getDeviceName, deviceName);
}
if (category != null) {
qw.eq(DeviceInfo::getCategory, category);
}
if (deviceStatus != null) {
qw.eq(DeviceInfo::getDeviceStatus, deviceStatus);
}
qw.orderByDesc(DeviceInfo::getCreatedAt);
return this.page(new Page<>(page, size), qw);
}
/**
* 创建设备
*/
public DeviceInfo createDevice(DeviceInfo device) {
// 检查设备编码唯一性
long count = this.count(new LambdaQueryWrapper<DeviceInfo>()
.eq(DeviceInfo::getDeviceCode, device.getDeviceCode()));
if (count > 0) {
throw new BusinessException("设备编码已存在");
}
this.save(device);
return device;
}
/**
* 更新设备
*/
public void updateDevice(Long id, DeviceInfo device) {
DeviceInfo existing = this.getById(id);
if (existing == null) {
throw new BusinessException("设备不存在");
}
device.setId(id);
this.updateById(device);
}
/**
* 更新设备状态
*/
public void updateDeviceStatus(Long id, Integer deviceStatus) {
DeviceInfo device = this.getById(id);
if (device == null) {
throw new BusinessException("设备不存在");
}
device.setDeviceStatus(deviceStatus);
this.updateById(device);
}
/**
* 按分类查询设备
*/
public List<DeviceInfo> getDevicesByCategory(Integer category) {
return baseMapper.selectByCategory(category);
}
/**
* 按状态查询设备
*/
public List<DeviceInfo> getDevicesByStatus(Integer status) {
return baseMapper.selectByDeviceStatus(status);
}
/**
* 添加维保记录
*/
@Transactional
public DeviceMaintenance addMaintenance(DeviceMaintenance maintenance) {
DeviceInfo device = this.getById(maintenance.getDeviceId());
if (device == null) {
throw new BusinessException("设备不存在");
}
maintenanceMapper.insert(maintenance);
// 如果维保完成,更新设备最后维护时间
if (maintenance.getResult() != null && maintenance.getResult() == 1) {
device.setLastMaintenanceTime(LocalDateTime.now());
if (device.getDeviceStatus() == 3) {
device.setDeviceStatus(1); // 维修中 -> 在线
}
this.updateById(device);
}
return maintenance;
}
/**
* 查询设备维保历史
*/
public Page<DeviceMaintenance> pageMaintenances(Long deviceId, int page, int size) {
LambdaQueryWrapper<DeviceMaintenance> qw = new LambdaQueryWrapper<>();
if (deviceId != null) {
qw.eq(DeviceMaintenance::getDeviceId, deviceId);
}
qw.orderByDesc(DeviceMaintenance::getStartTime);
return maintenanceMapper.selectPage(new Page<>(page, size), qw);
}
/**
* 更新维保记录
*/
public void updateMaintenance(Long id, DeviceMaintenance maintenance) {
maintenance.setId(id);
maintenanceMapper.updateById(maintenance);
}
/**
* 获取设备统计(按分类)
*/
public List<Long> getDeviceCountByCategory() {
// 简化版:返回各分类的设备数量
LambdaQueryWrapper<DeviceInfo> qw = new LambdaQueryWrapper<>();
qw.select(DeviceInfo::getCategory);
qw.groupBy(DeviceInfo::getCategory);
return this.listMaps(qw).stream()
.map(m -> ((Number) m.get("category")).longValue())
.toList();
}
}
@@ -0,0 +1,144 @@
package com.water.config.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.water.config.entity.ThresholdChangeLog;
import com.water.config.entity.ThresholdConfig;
import com.water.config.mapper.ThresholdChangeLogMapper;
import com.water.config.mapper.ThresholdConfigMapper;
import com.water.common.core.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 阈值管理服务
*/
@Service
@RequiredArgsConstructor
public class ThresholdService extends ServiceImpl<ThresholdConfigMapper, ThresholdConfig> {
private final ThresholdChangeLogMapper changeLogMapper;
/**
* 分页查询阈值配置
*/
public Page<ThresholdConfig> pageThresholds(int page, int size, String metricCode, Integer level) {
LambdaQueryWrapper<ThresholdConfig> qw = new LambdaQueryWrapper<>();
if (metricCode != null && !metricCode.isEmpty()) {
qw.like(ThresholdConfig::getMetricCode, metricCode);
}
if (level != null) {
qw.eq(ThresholdConfig::getLevel, level);
}
qw.orderByAsc(ThresholdConfig::getMetricCode, ThresholdConfig::getLevel);
return this.page(new Page<>(page, size), qw);
}
/**
* 创建阈值配置
*/
@Transactional
public ThresholdConfig createThreshold(ThresholdConfig config) {
validateThreshold(config);
this.save(config);
recordChangeLog(config, null, "新建阈值配置");
return config;
}
/**
* 更新阈值配置(记录变更)
*/
@Transactional
public void updateThreshold(Long id, ThresholdConfig config) {
ThresholdConfig old = this.getById(id);
if (old == null) {
throw new BusinessException("阈值配置不存在");
}
config.setId(id);
validateThreshold(config);
this.updateById(config);
recordChangeLog(config, old, "更新阈值配置");
}
/**
* 删除阈值配置
*/
@Transactional
public void deleteThreshold(Long id) {
ThresholdConfig old = this.getById(id);
if (old == null) {
throw new BusinessException("阈值配置不存在");
}
this.removeById(id);
recordChangeLog(old, old, "删除阈值配置");
}
/**
* 获取某指标的全局阈值(多级)
*/
public List<ThresholdConfig> getGlobalThresholds(String metricCode) {
return baseMapper.selectGlobalByMetricCode(metricCode);
}
/**
* 获取某设备的阈值配置
*/
public List<ThresholdConfig> getDeviceThresholds(Long deviceId) {
return baseMapper.selectByDeviceId(deviceId);
}
/**
* 获取阈值变更历史
*/
public Page<ThresholdChangeLog> getChangeHistory(Long thresholdId, int page, int size) {
LambdaQueryWrapper<ThresholdChangeLog> qw = new LambdaQueryWrapper<>();
if (thresholdId != null) {
qw.eq(ThresholdChangeLog::getThresholdId, thresholdId);
}
qw.orderByDesc(ThresholdChangeLog::getCreatedAt);
return changeLogMapper.selectPage(new Page<>(page, size), qw);
}
/**
* 启用/禁用阈值
*/
public void toggleStatus(Long id, Integer status) {
ThresholdConfig config = this.getById(id);
if (config == null) {
throw new BusinessException("阈值配置不存在");
}
config.setStatus(status);
this.updateById(config);
}
private void validateThreshold(ThresholdConfig config) {
if (config.getMinValue() != null && config.getMaxValue() != null) {
if (config.getMinValue().compareTo(config.getMaxValue()) > 0) {
throw new BusinessException("最小值不能大于最大值");
}
}
if (config.getLevel() != null && (config.getLevel() < 1 || config.getLevel() > 3)) {
throw new BusinessException("阈值级别必须在1-3之间");
}
}
private void recordChangeLog(ThresholdConfig newConfig, ThresholdConfig oldConfig, String reason) {
ThresholdChangeLog log = new ThresholdChangeLog();
log.setThresholdId(newConfig.getId());
if (oldConfig != null) {
log.setOldMinValue(oldConfig.getMinValue());
log.setOldMaxValue(oldConfig.getMaxValue());
log.setOldLevel(oldConfig.getLevel());
}
log.setNewMinValue(newConfig.getMinValue());
log.setNewMaxValue(newConfig.getMaxValue());
log.setNewLevel(newConfig.getLevel());
log.setReason(reason);
log.setOperator("system");
changeLogMapper.insert(log);
}
}
@@ -0,0 +1,30 @@
server:
port: 8090
spring:
application:
name: wm-config
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:water}?currentSchema=public
username: ${DB_USER:postgres}
password: ${DB_PASS:postgres}
cloud:
nacos:
discovery:
server-addr: ${NACOS_ADDR:localhost:8848}
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
sa-token:
token-name: Authorization
timeout: 86400
active-timeout: 1800
+102
View File
@@ -0,0 +1,102 @@
-- ============================================================
-- wm-config DDL: 阈值管理 + 信息发布 + 设备管理
-- ============================================================
-- 阈值配置表
CREATE TABLE IF NOT EXISTS config_threshold (
id BIGSERIAL PRIMARY KEY,
metric_code VARCHAR(64) NOT NULL,
metric_name VARCHAR(128) NOT NULL,
device_id BIGINT,
level SMALLINT NOT NULL DEFAULT 1, -- 1-预警 2-报警 3-紧急
min_value NUMERIC(12,4),
max_value NUMERIC(12,4),
unit VARCHAR(32),
status SMALLINT NOT NULL DEFAULT 1, -- 0-禁用 1-启用
remark VARCHAR(500),
deleted SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE config_threshold IS '阈值配置表';
CREATE INDEX idx_threshold_metric ON config_threshold(metric_code);
CREATE INDEX idx_threshold_device ON config_threshold(device_id);
-- 阈值变更记录表
CREATE TABLE IF NOT EXISTS config_threshold_change_log (
id BIGSERIAL PRIMARY KEY,
threshold_id BIGINT NOT NULL,
old_min_value NUMERIC(12,4),
old_max_value NUMERIC(12,4),
new_min_value NUMERIC(12,4),
new_max_value NUMERIC(12,4),
old_level SMALLINT,
new_level SMALLINT,
operator VARCHAR(64),
reason VARCHAR(500),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE config_threshold_change_log IS '阈值变更记录表';
CREATE INDEX idx_change_log_threshold ON config_threshold_change_log(threshold_id);
-- 公告通知表
CREATE TABLE IF NOT EXISTS config_announcement (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(256) NOT NULL,
content TEXT,
type SMALLINT NOT NULL DEFAULT 1, -- 1-系统公告 2-维护通知 3-紧急通知
publish_status SMALLINT NOT NULL DEFAULT 0, -- 0-草稿 1-已发布 2-已撤回
channels VARCHAR(256), -- JSON: ["sms","push","site"]
publisher VARCHAR(64),
publish_time TIMESTAMP,
withdraw_time TIMESTAMP,
deleted SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE config_announcement IS '公告通知表';
CREATE INDEX idx_announcement_status ON config_announcement(publish_status);
-- 设备台账表
CREATE TABLE IF NOT EXISTS config_device_info (
id BIGSERIAL PRIMARY KEY,
device_code VARCHAR(64) NOT NULL UNIQUE,
device_name VARCHAR(128) NOT NULL,
category SMALLINT NOT NULL DEFAULT 9, -- 1-水表 2-压力传感器 3-流量计 4-水质监测仪 5-阀门 9-其他
brand VARCHAR(64),
model VARCHAR(64),
location VARCHAR(256),
longitude DOUBLE PRECISION,
latitude DOUBLE PRECISION,
device_status SMALLINT NOT NULL DEFAULT 0, -- 0-离线 1-在线 2-故障 3-维修中
install_date TIMESTAMP,
last_maintenance_time TIMESTAMP,
responsible_person VARCHAR(64),
remark VARCHAR(500),
deleted SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE config_device_info IS '设备台账表';
CREATE INDEX idx_device_code ON config_device_info(device_code);
CREATE INDEX idx_device_category ON config_device_info(category);
CREATE INDEX idx_device_status ON config_device_info(device_status);
-- 设备维保记录表
CREATE TABLE IF NOT EXISTS config_device_maintenance (
id BIGSERIAL PRIMARY KEY,
device_id BIGINT NOT NULL,
maintenance_type SMALLINT NOT NULL DEFAULT 1, -- 1-日常巡检 2-定期保养 3-故障维修 4-更换配件
description TEXT,
operator VARCHAR(64),
start_time TIMESTAMP,
end_time TIMESTAMP,
result SMALLINT NOT NULL DEFAULT 0, -- 0-未完成 1-已完成 2-需要返修
cost DOUBLE PRECISION,
attachments TEXT, -- JSON
deleted SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE config_device_maintenance IS '设备维保记录表';
CREATE INDEX idx_maintenance_device ON config_device_maintenance(device_id);
@@ -0,0 +1,100 @@
package com.water.config;
import com.water.common.core.exception.BusinessException;
import com.water.config.entity.Announcement;
import com.water.config.mapper.AnnouncementMapper;
import com.water.config.service.AnnouncementService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AnnouncementServiceTest {
@Mock
private AnnouncementMapper announcementMapper;
@InjectMocks
private AnnouncementService announcementService;
private Announcement draft;
@BeforeEach
void setUp() {
draft = new Announcement();
draft.setTitle("系统维护通知");
draft.setContent("今晚22:00-次日06:00系统维护");
draft.setType(2);
draft.setChannels("[\"site\",\"sms\"]");
}
@Test
void createAnnouncement_setsDraftStatus() {
when(announcementMapper.insert(any())).thenReturn(1);
Announcement result = announcementService.createAnnouncement(draft);
assertEquals(0, result.getPublishStatus());
verify(announcementMapper).insert(any(Announcement.class));
}
@Test
void publish_draft_success() {
Announcement existing = new Announcement();
existing.setId(1L);
existing.setPublishStatus(0);
existing.setChannels("[\"site\"]");
when(announcementMapper.selectById(1L)).thenReturn(existing);
when(announcementMapper.updateById(any())).thenReturn(1);
assertDoesNotThrow(() -> announcementService.publish(1L));
verify(announcementMapper).updateById(argThat(a ->
a.getPublishStatus() == 1 && a.getPublishTime() != null));
}
@Test
void publish_alreadyPublished_throws() {
Announcement existing = new Announcement();
existing.setId(1L);
existing.setPublishStatus(1);
when(announcementMapper.selectById(1L)).thenReturn(existing);
BusinessException ex = assertThrows(BusinessException.class,
() -> announcementService.publish(1L));
assertEquals("只有草稿状态的公告可以发布", ex.getMessage());
}
@Test
void withdraw_notPublished_throws() {
Announcement existing = new Announcement();
existing.setId(1L);
existing.setPublishStatus(0);
when(announcementMapper.selectById(1L)).thenReturn(existing);
BusinessException ex = assertThrows(BusinessException.class,
() -> announcementService.withdraw(1L));
assertEquals("只有已发布的公告可以撤回", ex.getMessage());
}
@Test
void delete_published_throws() {
Announcement existing = new Announcement();
existing.setId(1L);
existing.setPublishStatus(1);
when(announcementMapper.selectById(1L)).thenReturn(existing);
BusinessException ex = assertThrows(BusinessException.class,
() -> announcementService.deleteAnnouncement(1L));
assertEquals("已发布的公告不可删除,请先撤回", ex.getMessage());
}
}
@@ -0,0 +1,107 @@
package com.water.config;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.common.core.exception.BusinessException;
import com.water.config.entity.DeviceInfo;
import com.water.config.entity.DeviceMaintenance;
import com.water.config.mapper.DeviceInfoMapper;
import com.water.config.mapper.DeviceMaintenanceMapper;
import com.water.config.service.DeviceManageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class DeviceManageServiceTest {
@Mock
private DeviceInfoMapper deviceInfoMapper;
@Mock
private DeviceMaintenanceMapper maintenanceMapper;
@InjectMocks
private DeviceManageService deviceManageService;
private DeviceInfo device;
@BeforeEach
void setUp() {
device = new DeviceInfo();
device.setDeviceCode("WM-001");
device.setDeviceName("1号水表");
device.setCategory(1);
device.setBrand("海天");
device.setModel("HT-200");
device.setDeviceStatus(0);
}
@Test
void createDevice_success() {
when(deviceInfoMapper.selectCount(any())).thenReturn(0L);
when(deviceInfoMapper.insert(any())).thenReturn(1);
DeviceInfo result = deviceManageService.createDevice(device);
assertNotNull(result);
assertEquals("WM-001", result.getDeviceCode());
verify(deviceInfoMapper).insert(any(DeviceInfo.class));
}
@Test
void createDevice_duplicateCode_throws() {
when(deviceInfoMapper.selectCount(any())).thenReturn(1L);
BusinessException ex = assertThrows(BusinessException.class,
() -> deviceManageService.createDevice(device));
assertEquals("设备编码已存在", ex.getMessage());
}
@Test
void updateDevice_notFound_throws() {
when(deviceInfoMapper.selectById(999L)).thenReturn(null);
BusinessException ex = assertThrows(BusinessException.class,
() -> deviceManageService.updateDevice(999L, device));
assertEquals("设备不存在", ex.getMessage());
}
@Test
void addMaintenance_deviceNotFound_throws() {
DeviceMaintenance maintenance = new DeviceMaintenance();
maintenance.setDeviceId(999L);
when(deviceInfoMapper.selectById(999L)).thenReturn(null);
BusinessException ex = assertThrows(BusinessException.class,
() -> deviceManageService.addMaintenance(maintenance));
assertEquals("设备不存在", ex.getMessage());
}
@Test
void addMaintenance_completed_updatesDeviceTime() {
DeviceInfo existingDevice = new DeviceInfo();
existingDevice.setId(1L);
existingDevice.setDeviceStatus(3); // 维修中
DeviceMaintenance maintenance = new DeviceMaintenance();
maintenance.setDeviceId(1L);
maintenance.setResult(1); // 已完成
maintenance.setDescription("更换电池");
when(deviceInfoMapper.selectById(1L)).thenReturn(existingDevice);
when(maintenanceMapper.insert(any())).thenReturn(1);
when(deviceInfoMapper.updateById(any())).thenReturn(1);
DeviceMaintenance result = deviceManageService.addMaintenance(maintenance);
assertNotNull(result);
verify(deviceInfoMapper).updateById(argThat(d ->
d.getLastMaintenanceTime() != null && d.getDeviceStatus() == 1));
}
}
@@ -0,0 +1,96 @@
package com.water.config;
import com.water.common.core.exception.BusinessException;
import com.water.config.entity.ThresholdChangeLog;
import com.water.config.entity.ThresholdConfig;
import com.water.config.mapper.ThresholdChangeLogMapper;
import com.water.config.mapper.ThresholdConfigMapper;
import com.water.config.service.ThresholdService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class ThresholdServiceTest {
@Mock
private ThresholdConfigMapper thresholdConfigMapper;
@Mock
private ThresholdChangeLogMapper changeLogMapper;
@InjectMocks
private ThresholdService thresholdService;
private ThresholdConfig validConfig;
@BeforeEach
void setUp() {
validConfig = new ThresholdConfig();
validConfig.setMetricCode("water_pressure");
validConfig.setMetricName("水压");
validConfig.setLevel(1);
validConfig.setMinValue(new BigDecimal("0.1"));
validConfig.setMaxValue(new BigDecimal("0.8"));
validConfig.setUnit("MPa");
validConfig.setStatus(1);
}
@Test
void createThreshold_success() {
when(thresholdConfigMapper.insert(any())).thenReturn(1);
when(changeLogMapper.insert(any())).thenReturn(1);
ThresholdConfig result = thresholdService.createThreshold(validConfig);
assertNotNull(result);
assertEquals("water_pressure", result.getMetricCode());
assertEquals(1, result.getLevel());
verify(thresholdConfigMapper).insert(any(ThresholdConfig.class));
verify(changeLogMapper).insert(any(ThresholdChangeLog.class));
}
@Test
void createThreshold_minGreaterThanMax_throws() {
validConfig.setMinValue(new BigDecimal("1.0"));
validConfig.setMaxValue(new BigDecimal("0.5"));
BusinessException ex = assertThrows(BusinessException.class,
() -> thresholdService.createThreshold(validConfig));
assertEquals("最小值不能大于最大值", ex.getMessage());
}
@Test
void createThreshold_invalidLevel_throws() {
validConfig.setLevel(5);
BusinessException ex = assertThrows(BusinessException.class,
() -> thresholdService.createThreshold(validConfig));
assertEquals("阈值级别必须在1-3之间", ex.getMessage());
}
@Test
void updateThreshold_notFound_throws() {
when(thresholdConfigMapper.selectById(999L)).thenReturn(null);
BusinessException ex = assertThrows(BusinessException.class,
() -> thresholdService.updateThreshold(999L, validConfig));
assertEquals("阈值配置不存在", ex.getMessage());
}
@Test
void deleteThreshold_notFound_throws() {
when(thresholdConfigMapper.selectById(999L)).thenReturn(null);
BusinessException ex = assertThrows(BusinessException.class,
() -> thresholdService.deleteThreshold(999L));
assertEquals("阈值配置不存在", ex.getMessage());
}
}
+114 -9
View File
@@ -3,15 +3,120 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>com.water</groupId><artifactId>wm-parent</artifactId><version>1.0.0-SNAPSHOT</version></parent>
<parent>
<groupId>com.water</groupId>
<artifactId>wm-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>wm-data-engine</artifactId>
<name>wm-data-engine</name>
<description>数据汇聚引擎模块</description>
<dependencies>
<dependency><groupId>com.water</groupId><artifactId>wm-common</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
<dependency><groupId>org.springframework.kafka</groupId><artifactId>spring-kafka</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
<dependency><groupId>net.postgis</groupId><artifactId>postgis-jdbc</artifactId></dependency>
<!-- 公共模块 -->
<dependency>
<groupId>com.water</groupId>
<artifactId>wm-common</artifactId>
</dependency>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Nacos 服务发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- Kafka -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<!-- PostGIS -->
<dependency>
<groupId>net.postgis</groupId>
<artifactId>postgis-jdbc</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<!-- MinIO -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
</dependency>
<!-- Hutool -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
<!-- Knife4j OpenAPI3 -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
</dependency>
<!-- EasyExcel -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,62 @@
package com.water.data_engine.config;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.*;
import java.util.HashMap;
import java.util.Map;
/**
* Kafka 配置
* 用于实时数据流采集和传输
*/
@Configuration
public class KafkaConfig {
@Value("${spring.kafka.bootstrap-servers:${KAFKA_SERVERS:127.0.0.1}:9092}")
private String bootstrapServers;
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.ACKS_CONFIG, "1");
props.put(ProducerConfig.RETRIES_CONFIG, 3);
return new DefaultKafkaProducerFactory<>(props);
}
@Bean
public KafkaTemplate<String, String> kafkaTemplate(ProducerFactory<String, String> producerFactory) {
return new KafkaTemplate<>(producerFactory);
}
@Bean
public ConsumerFactory<String, String> consumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ConsumerConfig.GROUP_ID_CONFIG, "wm-data-engine");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "latest");
return new DefaultKafkaConsumerFactory<>(props);
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory(
ConsumerFactory<String, String> consumerFactory) {
ConcurrentKafkaListenerContainerFactory<String, String> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory);
factory.setConcurrency(3);
return factory;
}
}
@@ -0,0 +1,49 @@
package com.water.data_engine.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.apache.ibatis.reflection.MetaObject;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDateTime;
/**
* MyBatis-Plus 配置
*/
@Configuration
@MapperScan("com.water.data_engine.mapper")
public class MyBatisPlusConfig {
/**
* 分页插件
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
return interceptor;
}
/**
* 自动填充处理器
*/
@Bean
public MetaObjectHandler metaObjectHandler() {
return new MetaObjectHandler() {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
}
};
}
}
@@ -0,0 +1,32 @@
package com.water.data_engine.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
/**
* WebSocket 配置
* 支持 STOMP 协议,用于实时数据推送
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 客户端订阅前缀: /topic (广播), /queue (点对点)
registry.enableSimpleBroker("/topic", "/queue");
// 客户端发送消息前缀
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// WebSocket 连接端点
registry.addEndpoint("/ws/data-engine")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}
@@ -0,0 +1,85 @@
package com.water.data_engine.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.water.common.core.result.R;
import com.water.data_engine.entity.CollectRecord;
import com.water.data_engine.entity.CollectTask;
import com.water.data_engine.service.DataCollectService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 数据采集控制器
* DE-01: 实时流(MQTT/Kafka) + 批量采集
*/
@Tag(name = "数据采集")
@RestController
@RequestMapping("/api/data-engine/collect")
@RequiredArgsConstructor
public class DataCollectController {
private final DataCollectService collectService;
// ==================== 实时数据采集 ====================
@Operation(summary = "实时数据接入")
@PostMapping("/realtime")
public R<String> ingestRealtime(@RequestBody Map<String, Object> request) {
String sourceType = (String) request.get("sourceType");
String sourceId = (String) request.get("sourceId");
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) request.get("data");
String topic = collectService.ingestRealtime(sourceType, sourceId, data);
return R.ok("数据已接入,topic: " + topic);
}
@Operation(summary = "批量数据接入")
@PostMapping("/batch")
public R<String> batchIngest(@RequestBody List<Map<String, Object>> batchData) {
int count = collectService.batchIngest(batchData);
return R.ok("批量接入完成,成功: " + count + " 条");
}
// ==================== 采集任务管理 ====================
@Operation(summary = "创建批量采集任务")
@PostMapping("/task")
public R<CollectTask> createTask(@RequestBody Map<String, Object> request) {
String taskName = (String) request.get("taskName");
Long sourceId = Long.valueOf(request.get("sourceId").toString());
String targetTable = (String) request.get("targetTable");
CollectTask task = collectService.createBatchTask(taskName, sourceId, targetTable);
return R.ok(task);
}
@Operation(summary = "执行采集任务")
@PostMapping("/task/{taskId}/execute")
public R<String> executeTask(@PathVariable Long taskId,
@RequestBody List<Map<String, Object>> dataList) {
collectService.executeTask(taskId, dataList);
return R.ok("任务执行完成");
}
@Operation(summary = "查询采集任务列表")
@GetMapping("/task/list")
public R<Page<CollectTask>> listTasks(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String status) {
return R.ok(collectService.listTasks(page, size, status));
}
@Operation(summary = "查询采集记录")
@GetMapping("/record/list")
public R<Page<CollectRecord>> listRecords(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) Long taskId) {
return R.ok(collectService.listRecords(page, size, taskId));
}
}
@@ -0,0 +1,65 @@
package com.water.data_engine.controller;
import com.water.common.core.result.R;
import com.water.data_engine.service.DataCollectService;
import com.water.data_engine.service.DataGovernanceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 数据引擎综合控制器(兼容旧接口)
*/
@Tag(name = "数据引擎(综合)")
@RestController
@RequestMapping("/api/data-engine")
@RequiredArgsConstructor
public class DataController {
private final DataCollectService collectService;
private final DataGovernanceService governanceService;
@Operation(summary = "数据接入(兼容旧接口)")
@PostMapping("/ingest")
public R<String> ingest(@RequestBody Map<String, Object> req) {
String sourceType = (String) req.get("sourceType");
String sourceId = (String) req.get("sourceId");
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) req.get("data");
collectService.ingestRealtime(sourceType, sourceId, data);
return R.ok("数据已接入");
}
@Operation(summary = "批量接入(兼容旧接口)")
@PostMapping("/ingest/batch")
public R<String> batchIngest(@RequestBody List<Map<String, Object>> batch) {
collectService.batchIngest(batch);
return R.ok("批量接入完成");
}
@Operation(summary = "数据标准化+清洗+质控(管道演示)")
@PostMapping("/pipeline")
public R<Map<String, Object>> pipeline(@RequestBody Map<String, Object> raw) {
Map<String, Object> result = governanceService.pipeline(raw);
return R.ok(result);
}
@Operation(summary = "引擎状态")
@GetMapping("/status")
public R<Map<String, Object>> status() {
Map<String, Object> status = new LinkedHashMap<>();
status.put("module", "wm-data-engine");
status.put("version", "1.0.0");
status.put("status", "running");
status.put("features", List.of(
"DE-01 数据采集(实时流/批量)",
"DE-02 数据接入(REST/WebSocket/数据库)",
"DE-03 数据存储(TDengine/PostgreSQL/MinIO)",
"DE-04 数据集成(多源异构整合)"
));
return R.ok(status);
}
}
@@ -0,0 +1,117 @@
package com.water.data_engine.controller;
import com.water.common.core.result.R;
import com.water.data_engine.entity.QualityRule;
import com.water.data_engine.service.DataGovernanceService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 数据治理控制器
* 数据标准化、清洗、质量控制
*/
@Tag(name = "数据治理")
@RestController
@RequestMapping("/api/data-engine/governance")
@RequiredArgsConstructor
public class DataGovernanceController {
private final DataGovernanceService governanceService;
// ==================== 数据标准化 ====================
@Operation(summary = "数据标准化")
@PostMapping("/standardize")
public R<Map<String, Object>> standardize(@RequestBody Map<String, Object> raw) {
return R.ok(governanceService.standardize(raw));
}
@Operation(summary = "批量数据标准化")
@PostMapping("/standardize/batch")
public R<List<Map<String, Object>>> batchStandardize(@RequestBody List<Map<String, Object>> rawDataList) {
return R.ok(governanceService.batchStandardize(rawDataList));
}
// ==================== 数据清洗 ====================
@Operation(summary = "数据清洗")
@PostMapping("/clean")
public R<Map<String, Object>> clean(@RequestBody Map<String, Object> data) {
return R.ok(governanceService.clean(data));
}
@Operation(summary = "批量数据清洗")
@PostMapping("/clean/batch")
public R<List<Map<String, Object>>> batchClean(@RequestBody List<Map<String, Object>> dataList) {
return R.ok(governanceService.batchClean(dataList));
}
// ==================== 数据质量 ====================
@Operation(summary = "数据质量检查")
@PostMapping("/quality/check")
public R<Map<String, Object>> qualityCheck(@RequestBody Map<String, Object> data) {
return R.ok(governanceService.qualityCheck(data));
}
@Operation(summary = "批量数据质量检查")
@PostMapping("/quality/check/batch")
public R<List<Map<String, Object>>> batchQualityCheck(@RequestBody List<Map<String, Object>> dataList) {
return R.ok(governanceService.batchQualityCheck(dataList));
}
@Operation(summary = "执行质量规则检查")
@PostMapping("/quality/rules/execute")
public R<Map<String, Object>> executeQualityRules(@RequestBody Map<String, Object> request) {
String tableName = (String) request.get("tableName");
return R.ok(governanceService.executeQualityRules(tableName));
}
// ==================== 数据管道 ====================
@Operation(summary = "完整数据管道(标准化->清洗->质控)")
@PostMapping("/pipeline")
public R<Map<String, Object>> pipeline(@RequestBody Map<String, Object> raw) {
return R.ok(governanceService.pipeline(raw));
}
@Operation(summary = "批量数据管道")
@PostMapping("/pipeline/batch")
public R<List<Map<String, Object>>> batchPipeline(@RequestBody List<Map<String, Object>> rawDataList) {
return R.ok(governanceService.batchPipeline(rawDataList));
}
// ==================== 质量规则管理 ====================
@Operation(summary = "创建质量规则")
@PostMapping("/quality/rule")
public R<QualityRule> createQualityRule(@RequestBody QualityRule rule) {
return R.ok(governanceService.createQualityRule(rule));
}
@Operation(summary = "更新质量规则")
@PutMapping("/quality/rule/{id}")
public R<QualityRule> updateQualityRule(@PathVariable Long id, @RequestBody QualityRule rule) {
return R.ok(governanceService.updateQualityRule(id, rule));
}
@Operation(summary = "删除质量规则")
@DeleteMapping("/quality/rule/{id}")
public R<String> deleteQualityRule(@PathVariable Long id) {
governanceService.deleteQualityRule(id);
return R.ok("删除成功");
}
@Operation(summary = "查询质量规则列表")
@GetMapping("/quality/rule/list")
public R<List<QualityRule>> listQualityRules(
@RequestParam(required = false) String tableName,
@RequestParam(required = false) String ruleType) {
return R.ok(governanceService.listQualityRules(tableName, ruleType));
}
}
@@ -0,0 +1,118 @@
package com.water.data_engine.controller;
import com.water.common.core.result.R;
import com.water.data_engine.entity.DataSource;
import com.water.data_engine.service.DataIngestService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
/**
* 数据接入控制器
* DE-02: RESTful API / WebSocket / 数据库直连
*/
@Tag(name = "数据接入")
@RestController
@RequestMapping("/api/data-engine/ingest")
@RequiredArgsConstructor
public class DataIngestController {
private final DataIngestService ingestService;
// ==================== API 接入 ====================
@Operation(summary = "通过 API 接入单条数据")
@PostMapping("/api/{sourceCode}")
public R<String> ingestViaApi(@PathVariable String sourceCode,
@RequestBody Map<String, Object> data) {
String topic = ingestService.ingestViaApi(sourceCode, data);
return R.ok("数据已接入,topic: " + topic);
}
@Operation(summary = "通过 API 批量接入数据")
@PostMapping("/api/{sourceCode}/batch")
public R<String> batchIngestViaApi(@PathVariable String sourceCode,
@RequestBody List<Map<String, Object>> dataList) {
int count = ingestService.batchIngestViaApi(sourceCode, dataList);
return R.ok("批量接入完成,成功: " + count + " 条");
}
// ==================== 数据库接入 ====================
@Operation(summary = "从外部数据库拉取数据")
@PostMapping("/database/{sourceId}/pull")
public R<String> pullFromDatabase(@PathVariable Long sourceId,
@RequestBody Map<String, Object> request) {
String sql = (String) request.get("sql");
String targetTable = (String) request.get("targetTable");
int count = ingestService.pullFromDatabase(sourceId, sql, targetTable);
return R.ok("数据拉取完成,成功: " + count + " 条");
}
@Operation(summary = "同步数据到本地表")
@PostMapping("/database/{sourceId}/sync")
public R<String> syncToTable(@PathVariable Long sourceId,
@RequestBody Map<String, Object> request) {
String querySql = (String) request.get("querySql");
String targetTable = (String) request.get("targetTable");
@SuppressWarnings("unchecked")
List<String> columns = (List<String>) request.get("columns");
int count = ingestService.syncToTable(sourceId, querySql, targetTable, columns);
return R.ok("同步完成,成功: " + count + " 条");
}
// ==================== 文件接入 ====================
@Operation(summary = "通过文件(CSV)接入数据")
@PostMapping("/file/{sourceCode}")
public R<String> ingestFromFile(@PathVariable String sourceCode,
@RequestParam("file") MultipartFile file) throws Exception {
int count = ingestService.ingestFromFile(file, sourceCode);
return R.ok("文件数据接入完成,成功: " + count + " 条");
}
// ==================== 数据源管理 ====================
@Operation(summary = "创建数据源")
@PostMapping("/source")
public R<DataSource> createDataSource(@RequestBody DataSource dataSource) {
return R.ok(ingestService.createDataSource(dataSource));
}
@Operation(summary = "更新数据源")
@PutMapping("/source/{id}")
public R<DataSource> updateDataSource(@PathVariable Long id,
@RequestBody DataSource dataSource) {
return R.ok(ingestService.updateDataSource(id, dataSource));
}
@Operation(summary = "删除数据源")
@DeleteMapping("/source/{id}")
public R<String> deleteDataSource(@PathVariable Long id) {
ingestService.deleteDataSource(id);
return R.ok("删除成功");
}
@Operation(summary = "查询数据源列表")
@GetMapping("/source/list")
public R<List<DataSource>> listDataSources(@RequestParam(required = false) String sourceType) {
return R.ok(ingestService.listDataSources(sourceType));
}
@Operation(summary = "获取数据源详情")
@GetMapping("/source/{id}")
public R<DataSource> getDataSource(@PathVariable Long id) {
return R.ok(ingestService.getDataSource(id));
}
@Operation(summary = "测试数据源连接")
@PostMapping("/source/{id}/test")
public R<Boolean> testConnection(@PathVariable Long id) {
return R.ok(ingestService.testConnection(id));
}
}
@@ -0,0 +1,134 @@
package com.water.data_engine.controller;
import com.water.common.core.result.R;
import com.water.data_engine.entity.DataLineage;
import com.water.data_engine.entity.SyncTask;
import com.water.data_engine.service.DataIntegrationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 数据集成控制器
* DE-04: 多源异构数据整合
*/
@Tag(name = "数据集成")
@RestController
@RequestMapping("/api/data-engine/integration")
@RequiredArgsConstructor
public class DataIntegrationController {
private final DataIntegrationService integrationService;
// ==================== 数据同步 ====================
@Operation(summary = "创建同步任务")
@PostMapping("/sync/task")
public R<SyncTask> createSyncTask(@RequestBody SyncTask syncTask) {
return R.ok(integrationService.createSyncTask(syncTask));
}
@Operation(summary = "执行同步任务")
@PostMapping("/sync/task/{taskId}/execute")
public R<String> executeSyncTask(@PathVariable Long taskId) {
int count = integrationService.executeSyncTask(taskId);
return R.ok("同步完成,处理: " + count + " 条");
}
@Operation(summary = "执行全量同步")
@PostMapping("/sync/full")
public R<String> fullSync(@RequestBody Map<String, Object> request) {
Long sourceId = Long.valueOf(request.get("sourceId").toString());
String sourceTable = (String) request.get("sourceTable");
String targetTable = (String) request.get("targetTable");
int count = integrationService.fullSync(sourceId, sourceTable, targetTable);
return R.ok("全量同步完成: " + count + " 条");
}
@Operation(summary = "执行增量同步")
@PostMapping("/sync/incremental")
public R<String> incrementalSync(@RequestBody Map<String, Object> request) {
Long sourceId = Long.valueOf(request.get("sourceId").toString());
String sourceTable = (String) request.get("sourceTable");
String targetTable = (String) request.get("targetTable");
String timestampColumn = (String) request.get("timestampColumn");
LocalDateTime lastSyncTime = LocalDateTime.parse((String) request.get("lastSyncTime"));
int count = integrationService.incrementalSync(sourceId, sourceTable, targetTable,
timestampColumn, lastSyncTime);
return R.ok("增量同步完成: " + count + " 条");
}
@Operation(summary = "查询同步任务列表")
@GetMapping("/sync/task/list")
public R<List<SyncTask>> listSyncTasks(@RequestParam(required = false) String status) {
return R.ok(integrationService.listSyncTasks(status));
}
@Operation(summary = "获取同步任务详情")
@GetMapping("/sync/task/{id}")
public R<SyncTask> getSyncTask(@PathVariable Long id) {
return R.ok(integrationService.getSyncTask(id));
}
@Operation(summary = "删除同步任务")
@DeleteMapping("/sync/task/{id}")
public R<String> deleteSyncTask(@PathVariable Long id) {
integrationService.deleteSyncTask(id);
return R.ok("删除成功");
}
// ==================== 数据合并与聚合 ====================
@Operation(summary = "数据合并(多源整合)")
@PostMapping("/merge")
public R<List<Map<String, Object>>> mergeData(@RequestBody Map<String, Object> request) {
@SuppressWarnings("unchecked")
List<String> sourceTables = (List<String>) request.get("sourceTables");
String joinColumn = (String) request.get("joinColumn");
@SuppressWarnings("unchecked")
List<String> selectColumns = (List<String>) request.get("selectColumns");
return R.ok(integrationService.mergeData(sourceTables, joinColumn, selectColumns));
}
@Operation(summary = "数据聚合(按维度汇总)")
@PostMapping("/aggregate")
public R<List<Map<String, Object>>> aggregateData(@RequestBody Map<String, Object> request) {
String sourceTable = (String) request.get("sourceTable");
@SuppressWarnings("unchecked")
List<String> groupByColumns = (List<String>) request.get("groupByColumns");
@SuppressWarnings("unchecked")
Map<String, String> aggregations = (Map<String, String>) request.get("aggregations");
return R.ok(integrationService.aggregateData(sourceTable, groupByColumns, aggregations));
}
// ==================== 数据血缘 ====================
@Operation(summary = "创建数据血缘关系")
@PostMapping("/lineage")
public R<DataLineage> createLineage(@RequestBody DataLineage lineage) {
return R.ok(integrationService.createLineage(lineage));
}
@Operation(summary = "查询血缘关系(上游)")
@GetMapping("/lineage/upstream/{tableName}")
public R<List<DataLineage>> getUpstreamLineage(@PathVariable String tableName) {
return R.ok(integrationService.getUpstreamLineage(tableName));
}
@Operation(summary = "查询血缘关系(下游)")
@GetMapping("/lineage/downstream/{tableName}")
public R<List<DataLineage>> getDownstreamLineage(@PathVariable String tableName) {
return R.ok(integrationService.getDownstreamLineage(tableName));
}
@Operation(summary = "查询完整血缘链路")
@GetMapping("/lineage/full/{tableName}")
public R<Map<String, Object>> getFullLineage(@PathVariable String tableName) {
return R.ok(integrationService.getFullLineage(tableName));
}
}
@@ -0,0 +1,154 @@
package com.water.data_engine.controller;
import com.water.common.core.result.R;
import com.water.data_engine.entity.StorageConfig;
import com.water.data_engine.service.DataStorageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 数据存储控制器
* DE-03: TDengine + PostgreSQL + MinIO
*/
@Tag(name = "数据存储")
@RestController
@RequestMapping("/api/data-engine/storage")
@RequiredArgsConstructor
public class DataStorageController {
private final DataStorageService storageService;
// ==================== TDengine 时序存储 ====================
@Operation(summary = "写入遥测数据到 TDengine")
@PostMapping("/tdengine")
public R<String> writeToTDengine(@RequestBody Map<String, Object> data) {
storageService.writeToTDengine(
(String) data.get("deviceSn"),
(String) data.get("deviceType"),
(String) data.get("area"),
(String) data.get("metricKey"),
((Number) data.get("value")).doubleValue()
);
return R.ok("写入成功");
}
@Operation(summary = "批量写入遥测数据")
@PostMapping("/tdengine/batch")
public R<String> batchWriteToTDengine(@RequestBody List<Map<String, Object>> dataList) {
int count = storageService.batchWriteToTDengine(dataList);
return R.ok("批量写入成功: " + count + " 条");
}
@Operation(summary = "查询遥测数据")
@GetMapping("/tdengine/query")
public R<List<Map<String, Object>>> queryFromTDengine(
@RequestParam String deviceSn,
@RequestParam String metricKey,
@RequestParam String startTime,
@RequestParam String endTime) {
return R.ok(storageService.queryFromTDengine(
deviceSn, metricKey,
LocalDateTime.parse(startTime),
LocalDateTime.parse(endTime)));
}
@Operation(summary = "查询聚合数据(小时级)")
@GetMapping("/tdengine/hourly")
public R<List<Map<String, Object>>> queryHourlyAgg(
@RequestParam String deviceSn,
@RequestParam String metricKey,
@RequestParam String startTime,
@RequestParam String endTime) {
return R.ok(storageService.queryHourlyAgg(
deviceSn, metricKey,
LocalDateTime.parse(startTime),
LocalDateTime.parse(endTime)));
}
// ==================== PostgreSQL 关系存储 ====================
@Operation(summary = "插入数据到 PostgreSQL")
@PostMapping("/postgres/{table}")
public R<Long> insertToPostgres(@PathVariable String table,
@RequestBody Map<String, Object> data) {
return R.ok(storageService.insertToPostgres(table, data));
}
@Operation(summary = "批量插入数据")
@PostMapping("/postgres/{table}/batch")
public R<String> batchInsertToPostgres(@PathVariable String table,
@RequestBody List<Map<String, Object>> dataList) {
int count = storageService.batchInsertToPostgres(table, dataList);
return R.ok("批量插入成功: " + count + " 条");
}
@Operation(summary = "更新数据")
@PutMapping("/postgres/{table}/{id}")
public R<String> updateInPostgres(@PathVariable String table,
@PathVariable Long id,
@RequestBody Map<String, Object> data) {
int count = storageService.updateInPostgres(table, id, data);
return R.ok("更新成功: " + count + " 条");
}
@Operation(summary = "查询数据")
@GetMapping("/postgres/{table}")
public R<List<Map<String, Object>>> queryFromPostgres(
@PathVariable String table,
@RequestParam Map<String, Object> conditions,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return R.ok(storageService.queryFromPostgres(table, conditions, page, size));
}
// ==================== MinIO 对象存储 ====================
@Operation(summary = "上传文件到 MinIO")
@PostMapping("/minio/upload")
public R<String> uploadToMinio(@RequestParam("file") MultipartFile file,
@RequestParam(defaultValue = "default") String module) throws Exception {
String objectName = storageService.uploadToMinio(file, module);
return R.ok(objectName);
}
@Operation(summary = "列出 MinIO 文件")
@GetMapping("/minio/list")
public R<List<String>> listMinioObjects(@RequestParam(defaultValue = "") String prefix) throws Exception {
return R.ok(storageService.listMinioObjects(prefix));
}
// ==================== 存储配置管理 ====================
@Operation(summary = "创建存储配置")
@PostMapping("/config")
public R<StorageConfig> createStorageConfig(@RequestBody StorageConfig config) {
return R.ok(storageService.createStorageConfig(config));
}
@Operation(summary = "更新存储配置")
@PutMapping("/config/{id}")
public R<StorageConfig> updateStorageConfig(@PathVariable Long id,
@RequestBody StorageConfig config) {
return R.ok(storageService.updateStorageConfig(id, config));
}
@Operation(summary = "查询存储配置列表")
@GetMapping("/config/list")
public R<List<StorageConfig>> listStorageConfigs(@RequestParam(required = false) String storageType) {
return R.ok(storageService.listStorageConfigs(storageType));
}
@Operation(summary = "测试存储连接")
@PostMapping("/config/{id}/test")
public R<Boolean> testStorageConnection(@PathVariable Long id) {
return R.ok(storageService.testStorageConnection(id));
}
}
@@ -0,0 +1,46 @@
package com.water.data_engine.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 数据采集记录实体
*/
@Data
@TableName("de_collect_record")
public class CollectRecord {
@TableId(type = IdType.AUTO)
private Long id;
/** 任务ID */
private Long taskId;
/** 数据源ID */
private Long sourceId;
/** 数据源类型 */
private String sourceType;
/** 数据源Key */
private String sourceKey;
/** 原始数据(JSON) */
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private Object rawData;
/** 处理后数据(JSON) */
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private Object processedData;
/** 状态: success/failed/skipped */
private String status;
/** 错误信息 */
private String errorMsg;
/** 采集时间 */
private LocalDateTime collectTime;
}
@@ -0,0 +1,82 @@
package com.water.data_engine.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 数据采集任务实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("de_collect_task")
public class CollectTask extends com.water.common.core.entity.BaseEntity {
/**
* 任务名称
*/
private String taskName;
/**
* 数据源ID
*/
private Long sourceId;
/**
* 采集类型: realtime/batch/manual
*/
private String collectType;
/**
* Kafka/MQTT topic
*/
private String topic;
/**
* 目标表名
*/
private String targetTable;
/**
* 转换规则(JSON)
*/
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private Object transformRule;
/**
* 状态: pending/running/paused/completed/failed
*/
private String status;
/**
* 总记录数
*/
private Long totalCount;
/**
* 成功数
*/
private Long successCount;
/**
* 失败数
*/
private Long failCount;
/**
* 开始时间
*/
private LocalDateTime startTime;
/**
* 结束时间
*/
private LocalDateTime endTime;
/**
* 错误信息
*/
private String errorMsg;
}
@@ -0,0 +1,41 @@
package com.water.data_engine.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 数据血缘关系实体
*/
@Data
@TableName("de_data_lineage")
public class DataLineage {
@TableId(type = IdType.AUTO)
private Long id;
/** 源表 */
private String sourceTable;
/** 源列 */
private String sourceColumn;
/** 目标表 */
private String targetTable;
/** 目标列 */
private String targetColumn;
/** 转换类型: direct/mapping/aggregation/calculation */
private String transformType;
/** 转换规则 */
private String transformRule;
/** 描述 */
private String description;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -0,0 +1,67 @@
package com.water.data_engine.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 数据源配置实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("de_data_source")
public class DataSource extends com.water.common.core.entity.BaseEntity {
/**
* 数据源名称
*/
private String sourceName;
/**
* 数据源编码(唯一)
*/
private String sourceCode;
/**
* 数据源类型: mqtt/kafka/rest/websocket/database/file
*/
private String sourceType;
/**
* 数据分类: iot/manual/api/database
*/
private String category;
/**
* 连接配置(JSON)
*/
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private Object connectionConfig;
/**
* 同步模式: realtime/batch/scheduled
*/
private String syncMode;
/**
* 定时同步Cron表达式
*/
private String syncCron;
/**
* 状态: 0-禁用 1-启用
*/
private Integer status;
/**
* 描述
*/
private String description;
/**
* 最后同步时间
*/
private LocalDateTime lastSyncAt;
}
@@ -0,0 +1,38 @@
package com.water.data_engine.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 数据质量规则实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("de_quality_rule")
public class QualityRule extends com.water.common.core.entity.BaseEntity {
/** 规则名称 */
private String ruleName;
/** 规则类型: completeness/validity/timeliness/consistency */
private String ruleType;
/** 表名 */
private String tableName;
/** 列名 */
private String columnName;
/** 规则表达式 */
private String ruleExpr;
/** 阈值 */
private java.math.BigDecimal threshold;
/** 严重级别: info/warning/error */
private String severity;
/** 是否启用 */
private Integer enabled;
}
@@ -0,0 +1,42 @@
package com.water.data_engine.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 存储配置实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("de_storage_config")
public class StorageConfig extends com.water.common.core.entity.BaseEntity {
/** 存储名称 */
private String storageName;
/** 存储类型: tdengine/postgresql/minio */
private String storageType;
/** 连接URL */
private String connectionUrl;
/** 用户名 */
private String username;
/** 密码 */
private String password;
/** 数据库名 */
private String databaseName;
/** 桶名(MinIO) */
private String bucketName;
/** 扩展配置 */
@TableField(typeHandler = com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler.class)
private Object extraConfig;
/** 状态 */
private Integer status;
}
@@ -0,0 +1,43 @@
package com.water.data_engine.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 数据同步任务实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("de_sync_task")
public class SyncTask extends com.water.common.core.entity.BaseEntity {
/** 任务名称 */
private String taskName;
/** 数据源ID */
private Long sourceId;
/** 目标存储ID */
private Long targetStorageId;
/** 同步类型: full/incremental/cdc */
private String syncType;
/** 同步Cron表达式 */
private String syncCron;
/** 最后同步时间 */
private LocalDateTime lastSyncAt;
/** 最后同步记录数 */
private Long lastSyncCount;
/** 状态: pending/running/paused/completed/failed */
private String status;
/** 错误信息 */
private String errorMsg;
}
@@ -0,0 +1,12 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.CollectRecord;
import org.apache.ibatis.annotations.Mapper;
/**
* 采集记录Mapper
*/
@Mapper
public interface CollectRecordMapper extends BaseMapper<CollectRecord> {
}
@@ -0,0 +1,12 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.CollectTask;
import org.apache.ibatis.annotations.Mapper;
/**
* 采集任务Mapper
*/
@Mapper
public interface CollectTaskMapper extends BaseMapper<CollectTask> {
}
@@ -0,0 +1,12 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.DataLineage;
import org.apache.ibatis.annotations.Mapper;
/**
* 数据血缘Mapper
*/
@Mapper
public interface DataLineageMapper extends BaseMapper<DataLineage> {
}
@@ -0,0 +1,12 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.DataSource;
import org.apache.ibatis.annotations.Mapper;
/**
* 数据源Mapper
*/
@Mapper
public interface DataSourceMapper extends BaseMapper<DataSource> {
}
@@ -0,0 +1,12 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.QualityRule;
import org.apache.ibatis.annotations.Mapper;
/**
* 质量规则Mapper
*/
@Mapper
public interface QualityRuleMapper extends BaseMapper<QualityRule> {
}
@@ -0,0 +1,12 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.StorageConfig;
import org.apache.ibatis.annotations.Mapper;
/**
* 存储配置Mapper
*/
@Mapper
public interface StorageConfigMapper extends BaseMapper<StorageConfig> {
}
@@ -0,0 +1,12 @@
package com.water.data_engine.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.water.data_engine.entity.SyncTask;
import org.apache.ibatis.annotations.Mapper;
/**
* 同步任务Mapper
*/
@Mapper
public interface SyncTaskMapper extends BaseMapper<SyncTask> {
}
@@ -0,0 +1,281 @@
package com.water.data_engine.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.water.data_engine.entity.CollectRecord;
import com.water.data_engine.entity.CollectTask;
import com.water.data_engine.entity.DataSource;
import com.water.data_engine.mapper.CollectRecordMapper;
import com.water.data_engine.mapper.CollectTaskMapper;
import com.water.data_engine.mapper.DataSourceMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.*;
/**
* 数据采集服务
* DE-01: 实时流(MQTT/Kafka) + 批量采集
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DataCollectService {
private final KafkaTemplate<String, String> kafkaTemplate;
private final JdbcTemplate jdbcTemplate;
private final DataSourceMapper dataSourceMapper;
private final CollectTaskMapper collectTaskMapper;
private final CollectRecordMapper collectRecordMapper;
private final SimpMessagingTemplate wsMessagingTemplate;
private final ObjectMapper mapper = new ObjectMapper();
// ==================== 实时流采集 ====================
/**
* 实时数据接入:接收各来源数据,统一写入 Kafka
* 支持 MQTT/Kafka 来源的实时流
*/
public String ingestRealtime(String sourceType, String sourceId, Map<String, Object> rawData) {
try {
Map<String, Object> envelope = buildEnvelope(sourceType, sourceId, rawData);
String json = mapper.writeValueAsString(envelope);
// 根据来源路由到不同 topic
String topic = routeTopic(sourceType);
kafkaTemplate.send(topic, sourceId, json);
// 保存采集记录
saveCollectRecord(null, sourceType, sourceId, rawData, "success", null);
// 通过 WebSocket 推送实时数据
wsMessagingTemplate.convertAndSend("/topic/data/realtime/" + sourceType, envelope);
log.debug("实时数据接入: {} -> {}, topic: {}", sourceType, sourceId, topic);
return topic;
} catch (Exception e) {
log.error("实时数据接入失败: {}", e.getMessage(), e);
saveCollectRecord(null, sourceType, sourceId, rawData, "failed", e.getMessage());
throw new RuntimeException("数据接入失败: " + e.getMessage());
}
}
/**
* Kafka 消费者:处理 IoT 设备遥测数据
*/
@KafkaListener(topics = "iot.raw.generic", groupId = "wm-data-engine")
public void consumeIotRaw(String message) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> envelope = mapper.readValue(message, Map.class);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) envelope.get("data");
String deviceSn = (String) data.getOrDefault("deviceSn", "unknown");
@SuppressWarnings("unchecked")
List<Map<String, Object>> metrics = (List<Map<String, Object>>) data.getOrDefault("metrics", List.of());
for (Map<String, Object> metric : metrics) {
String key = (String) metric.get("key");
Object value = metric.get("value");
// 写入 TDengine
writeToTDengine(deviceSn, key, value);
}
log.debug("消费 IoT 数据: device={}, metrics={}", deviceSn, metrics.size());
} catch (Exception e) {
log.error("消费 IoT 数据失败: {}", e.getMessage());
}
}
/**
* Kafka 消费者:处理水质数据
*/
@KafkaListener(topics = "data.quality", groupId = "wm-data-engine")
public void consumeQualityData(String message) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> envelope = mapper.readValue(message, Map.class);
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) envelope.get("data");
// 写入 PostgreSQL
String sql = """
INSERT INTO water_quality_record (test_type, test_point, point_type, area,
turbidity, ph, residual_chlorine, is_qualified, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
""";
jdbcTemplate.update(sql,
data.get("testType"),
data.get("testPoint"),
data.get("pointType"),
data.get("area"),
data.get("turbidity"),
data.get("ph"),
data.get("residualChlorine"),
data.get("isQualified"));
log.debug("消费水质数据: point={}", data.get("testPoint"));
} catch (Exception e) {
log.error("消费水质数据失败: {}", e.getMessage());
}
}
// ==================== 批量采集 ====================
/**
* 批量数据采集
*/
@Transactional
public int batchIngest(List<Map<String, Object>> batchData) {
int successCount = 0;
for (Map<String, Object> data : batchData) {
try {
String sourceType = (String) data.getOrDefault("sourceType", "batch");
String sourceId = (String) data.getOrDefault("sourceId", UUID.randomUUID().toString());
@SuppressWarnings("unchecked")
Map<String, Object> rawData = (Map<String, Object>) data.getOrDefault("data", new HashMap<>());
ingestRealtime(sourceType, sourceId, rawData);
successCount++;
} catch (Exception e) {
log.warn("批量采集单条失败: {}", e.getMessage());
}
}
return successCount;
}
/**
* 创建批量采集任务
*/
@Transactional
public CollectTask createBatchTask(String taskName, Long sourceId, String targetTable) {
CollectTask task = new CollectTask();
task.setTaskName(taskName);
task.setSourceId(sourceId);
task.setCollectType("batch");
task.setTargetTable(targetTable);
task.setStatus("pending");
task.setTotalCount(0L);
task.setSuccessCount(0L);
task.setFailCount(0L);
collectTaskMapper.insert(task);
return task;
}
/**
* 执行采集任务
*/
@Transactional
public void executeTask(Long taskId, List<Map<String, Object>> dataList) {
CollectTask task = collectTaskMapper.selectById(taskId);
if (task == null) {
throw new RuntimeException("任务不存在: " + taskId);
}
task.setStatus("running");
task.setStartTime(LocalDateTime.now());
task.setTotalCount((long) dataList.size());
collectTaskMapper.updateById(task);
long success = 0;
long fail = 0;
for (Map<String, Object> data : dataList) {
try {
String sourceType = (String) data.getOrDefault("sourceType", "batch");
String sourceId = (String) data.getOrDefault("sourceId", UUID.randomUUID().toString());
@SuppressWarnings("unchecked")
Map<String, Object> rawData = (Map<String, Object>) data.getOrDefault("data", data);
ingestRealtime(sourceType, sourceId, rawData);
success++;
} catch (Exception e) {
fail++;
log.warn("任务 {} 采集失败: {}", taskId, e.getMessage());
}
}
task.setSuccessCount(success);
task.setFailCount(fail);
task.setStatus("completed");
task.setEndTime(LocalDateTime.now());
collectTaskMapper.updateById(task);
}
// ==================== 查询方法 ====================
/**
* 查询采集任务列表
*/
public Page<CollectTask> listTasks(int page, int size, String status) {
LambdaQueryWrapper<CollectTask> wrapper = new LambdaQueryWrapper<>();
if (status != null && !status.isEmpty()) {
wrapper.eq(CollectTask::getStatus, status);
}
wrapper.orderByDesc(CollectTask::getCreatedAt);
return collectTaskMapper.selectPage(new Page<>(page, size), wrapper);
}
/**
* 查询采集记录
*/
public Page<CollectRecord> listRecords(int page, int size, Long taskId) {
LambdaQueryWrapper<CollectRecord> wrapper = new LambdaQueryWrapper<>();
if (taskId != null) {
wrapper.eq(CollectRecord::getTaskId, taskId);
}
wrapper.orderByDesc(CollectRecord::getCollectTime);
return collectRecordMapper.selectPage(new Page<>(page, size), wrapper);
}
// ==================== 私有方法 ====================
private Map<String, Object> buildEnvelope(String sourceType, String sourceId, Map<String, Object> rawData) {
Map<String, Object> envelope = new LinkedHashMap<>();
envelope.put("sourceType", sourceType);
envelope.put("sourceId", sourceId);
envelope.put("timestamp", Instant.now().toEpochMilli());
envelope.put("data", rawData);
return envelope;
}
private String routeTopic(String sourceType) {
return switch (sourceType) {
case "iot", "mqtt" -> "iot.raw.generic";
case "quality" -> "data.quality";
case "manual" -> "data.manual";
case "api" -> "data.api";
default -> "data.raw";
};
}
private void writeToTDengine(String deviceSn, String metricKey, Object value) {
String sql = "INSERT INTO water_iot.iot_telemetry (ts, device_sn, metric_key, metric_value, quality) VALUES (NOW, ?, ?, ?, 1)";
jdbcTemplate.update(sql, deviceSn, metricKey, value);
}
private void saveCollectRecord(Long taskId, String sourceType, String sourceKey,
Map<String, Object> rawData, String status, String errorMsg) {
try {
CollectRecord record = new CollectRecord();
record.setTaskId(taskId);
record.setSourceType(sourceType);
record.setSourceKey(sourceKey);
record.setRawData(rawData);
record.setStatus(status);
record.setErrorMsg(errorMsg);
record.setCollectTime(LocalDateTime.now());
collectRecordMapper.insert(record);
} catch (Exception e) {
log.error("保存采集记录失败: {}", e.getMessage());
}
}
}
@@ -0,0 +1,380 @@
package com.water.data_engine.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.data_engine.entity.QualityRule;
import com.water.data_engine.mapper.QualityRuleMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 数据治理服务
* 数据标准化、清洗、质量控制
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DataGovernanceService {
private final JdbcTemplate jdbcTemplate;
private final QualityRuleMapper qualityRuleMapper;
// 水利行业标准字段映射
private static final Map<String, String> STANDARD_FIELD_MAP = Map.of(
"flow", "LL", // 流量
"pressure", "YL", // 压力
"level", "SW", // 水位
"turbidity", "ZD", // 浊度
"ph", "PH", // pH值
"residual_chlorine", "YLJL", // 余氯
"temperature", "WD", // 温度
"conductivity", "DD", // 电导率
"dissolved_oxygen", "RJY", // 溶解氧
"ammonia", "AD" // 氨氮
);
// 数值型标准字段
private static final List<String> NUMERIC_FIELDS = List.of(
"LL", "YL", "SW", "ZD", "PH", "YLJL", "WD", "DD", "RJY", "AD"
);
// ==================== 数据标准化 ====================
/**
* 数据标准化:水利数据对象标准映射
*/
public Map<String, Object> standardize(Map<String, Object> raw) {
Map<String, Object> std = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : raw.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
// 字段名映射
String standardKey = STANDARD_FIELD_MAP.getOrDefault(key, key);
std.put(standardKey, value);
}
// 添加标准化标记
std.put("_standardized", true);
std.put("_standardize_time", LocalDateTime.now().toString());
return std;
}
/**
* 批量标准化
*/
public List<Map<String, Object>> batchStandardize(List<Map<String, Object>> rawDataList) {
return rawDataList.stream()
.map(this::standardize)
.collect(Collectors.toList());
}
// ==================== 数据清洗 ====================
/**
* 数据清洗:缺失值填充、异常值检测
*/
public Map<String, Object> clean(Map<String, Object> data) {
Map<String, Object> cleaned = new LinkedHashMap<>(data);
// 1. 缺失值处理
for (String field : NUMERIC_FIELDS) {
Object value = cleaned.get(field);
if (value == null || "".equals(value.toString().trim())) {
cleaned.put(field, -9999.0);
cleaned.put(field + "_flag", "MISSING");
}
}
// 2. 异常值检测
detectAnomalies(cleaned);
// 3. 数据类型转换
convertDataTypes(cleaned);
cleaned.put("_cleaned", true);
cleaned.put("_clean_time", LocalDateTime.now().toString());
return cleaned;
}
/**
* 批量清洗
*/
public List<Map<String, Object>> batchClean(List<Map<String, Object>> dataList) {
return dataList.stream()
.map(this::clean)
.collect(Collectors.toList());
}
// ==================== 数据质量控制 ====================
/**
* 数据质量检查
*/
public Map<String, Object> qualityCheck(Map<String, Object> data) {
Map<String, Object> result = new LinkedHashMap<>(data);
int score = 100;
List<String> issues = new ArrayList<>();
// 1. 完整性检查
for (String field : NUMERIC_FIELDS) {
if (data.containsKey(field + "_flag") && "MISSING".equals(data.get(field + "_flag"))) {
score -= 5;
issues.add(field + "数据缺失");
}
}
// 2. 异常值检查
if (data.containsKey("LL_flag") && "ABNORMAL".equals(data.get("LL_flag"))) {
score -= 15;
issues.add("流量数据异常(负值)");
}
if (data.containsKey("PH")) {
double ph = ((Number) data.get("PH")).doubleValue();
if (ph < 0 || ph > 14) {
score -= 10;
issues.add("pH值超出合理范围(0-14)");
}
}
// 3. 时效性检查
if (data.containsKey("_standardize_time")) {
// 检查数据是否过于陈旧
// 简化处理:假设超过1小时为陈旧数据
}
// 4. 一致性检查
if (data.containsKey("SW") && data.containsKey("YL")) {
// 水位和压力应该有一定的相关性
// 简化处理
}
result.put("_quality_score", Math.max(score, 0));
result.put("_quality_issues", issues);
result.put("_quality_checked", true);
result.put("_quality_check_time", LocalDateTime.now().toString());
return result;
}
/**
* 批量质量检查
*/
public List<Map<String, Object>> batchQualityCheck(List<Map<String, Object>> dataList) {
return dataList.stream()
.map(this::qualityCheck)
.collect(Collectors.toList());
}
/**
* 执行质量规则检查
*/
@Transactional
public Map<String, Object> executeQualityRules(String tableName) {
LambdaQueryWrapper<QualityRule> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(QualityRule::getTableName, tableName);
wrapper.eq(QualityRule::getEnabled, 1);
List<QualityRule> rules = qualityRuleMapper.selectList(wrapper);
Map<String, Object> results = new HashMap<>();
int totalChecks = rules.size();
int passedChecks = 0;
for (QualityRule rule : rules) {
try {
boolean passed = executeSingleRule(rule);
if (passed) {
passedChecks++;
}
results.put(rule.getRuleName(), passed ? "PASS" : "FAIL");
} catch (Exception e) {
results.put(rule.getRuleName(), "ERROR: " + e.getMessage());
}
}
BigDecimal passRate = totalChecks > 0
? BigDecimal.valueOf(passedChecks).multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(totalChecks), 2, RoundingMode.HALF_UP)
: BigDecimal.ZERO;
Map<String, Object> summary = new HashMap<>();
summary.put("table", tableName);
summary.put("total_rules", totalChecks);
summary.put("passed", passedChecks);
summary.put("failed", totalChecks - passedChecks);
summary.put("pass_rate", passRate);
summary.put("details", results);
summary.put("check_time", LocalDateTime.now());
return summary;
}
// ==================== 数据血缘 ====================
/**
* 建立数据血缘关系
*/
@Transactional
public void buildLineage(Long sourceId, Long targetId, String relation) {
String sql = """
INSERT INTO de_data_lineage (source_table, source_column, target_table, target_column,
transform_type, description, created_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())
ON CONFLICT DO NOTHING
""";
jdbcTemplate.update(sql, "iot_telemetry", null, "iot_telemetry_hourly", null, relation, "自动聚合");
}
// ==================== 数据管道 ====================
/**
* 完整的数据处理管道:标准化 -> 清洗 -> 质控
*/
public Map<String, Object> pipeline(Map<String, Object> raw) {
Map<String, Object> std = standardize(raw);
Map<String, Object> cleaned = clean(std);
Map<String, Object> result = qualityCheck(cleaned);
return result;
}
/**
* 批量数据管道
*/
public List<Map<String, Object>> batchPipeline(List<Map<String, Object>> rawDataList) {
return rawDataList.stream()
.map(this::pipeline)
.collect(Collectors.toList());
}
// ==================== 质量规则管理 ====================
/**
* 创建质量规则
*/
@Transactional
public QualityRule createQualityRule(QualityRule rule) {
rule.setEnabled(1);
qualityRuleMapper.insert(rule);
return rule;
}
/**
* 更新质量规则
*/
@Transactional
public QualityRule updateQualityRule(Long id, QualityRule rule) {
rule.setId(id);
qualityRuleMapper.updateById(rule);
return qualityRuleMapper.selectById(id);
}
/**
* 删除质量规则
*/
@Transactional
public void deleteQualityRule(Long id) {
qualityRuleMapper.deleteById(id);
}
/**
* 查询质量规则列表
*/
public List<QualityRule> listQualityRules(String tableName, String ruleType) {
LambdaQueryWrapper<QualityRule> wrapper = new LambdaQueryWrapper<>();
if (tableName != null && !tableName.isEmpty()) {
wrapper.eq(QualityRule::getTableName, tableName);
}
if (ruleType != null && !ruleType.isEmpty()) {
wrapper.eq(QualityRule::getRuleType, ruleType);
}
return qualityRuleMapper.selectList(wrapper);
}
// ==================== 私有方法 ====================
private void detectAnomalies(Map<String, Object> data) {
// 流量异常检测(负值)
if (data.containsKey("LL")) {
double ll = ((Number) data.get("LL")).doubleValue();
if (ll < 0) {
data.put("LL_flag", "ABNORMAL");
}
}
// 压力异常检测(超范围)
if (data.containsKey("YL")) {
double yl = ((Number) data.get("YL")).doubleValue();
if (yl < 0 || yl > 100) {
data.put("YL_flag", "ABNORMAL");
}
}
// 水位异常检测
if (data.containsKey("SW")) {
double sw = ((Number) data.get("SW")).doubleValue();
if (sw < -100 || sw > 1000) {
data.put("SW_flag", "ABNORMAL");
}
}
// 浊度异常检测
if (data.containsKey("ZD")) {
double zd = ((Number) data.get("ZD")).doubleValue();
if (zd < 0 || zd > 1000) {
data.put("ZD_flag", "ABNORMAL");
}
}
}
private void convertDataTypes(Map<String, Object> data) {
for (String field : NUMERIC_FIELDS) {
Object value = data.get(field);
if (value instanceof String) {
try {
data.put(field, Double.parseDouble((String) value));
} catch (NumberFormatException e) {
data.put(field + "_flag", "INVALID_TYPE");
}
}
}
}
private boolean executeSingleRule(QualityRule rule) {
// 简化实现:根据规则类型执行不同检查
return switch (rule.getRuleType()) {
case "completeness" -> checkCompleteness(rule);
case "validity" -> checkValidity(rule);
case "timeliness" -> checkTimeliness(rule);
default -> true;
};
}
private boolean checkCompleteness(QualityRule rule) {
String sql = String.format(
"SELECT COUNT(*) FROM %s WHERE %s IS NULL",
rule.getTableName(), rule.getColumnName());
Integer nullCount = jdbcTemplate.queryForObject(sql, Integer.class);
return nullCount == null || nullCount == 0;
}
private boolean checkValidity(QualityRule rule) {
// 简化:检查是否有无效值
return true;
}
private boolean checkTimeliness(QualityRule rule) {
// 简化:检查数据时效性
return true;
}
}
@@ -0,0 +1,297 @@
package com.water.data_engine.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.data_engine.entity.DataSource;
import com.water.data_engine.mapper.DataSourceMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 数据接入服务
* DE-02: RESTful API / WebSocket / 数据库直连
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DataIngestService {
private final DataSourceMapper dataSourceMapper;
private final JdbcTemplate jdbcTemplate;
private final DataCollectService collectService;
// ==================== RESTful API 接入 ====================
/**
* 通过 API 接入单条数据
*/
@Transactional
public String ingestViaApi(String sourceCode, Map<String, Object> data) {
DataSource source = getSourceByCode(sourceCode);
if (source == null) {
throw new RuntimeException("数据源不存在: " + sourceCode);
}
if (source.getStatus() != 1) {
throw new RuntimeException("数据源已禁用: " + sourceCode);
}
// 更新最后同步时间
source.setLastSyncAt(LocalDateTime.now());
dataSourceMapper.updateById(source);
return collectService.ingestRealtime(source.getCategory(), sourceCode, data);
}
/**
* 通过 API 批量接入数据
*/
@Transactional
public int batchIngestViaApi(String sourceCode, List<Map<String, Object>> dataList) {
DataSource source = getSourceByCode(sourceCode);
if (source == null) {
throw new RuntimeException("数据源不存在: " + sourceCode);
}
List<Map<String, Object>> wrappedList = dataList.stream()
.map(data -> {
Map<String, Object> wrapped = new HashMap<>(data);
wrapped.put("sourceType", source.getCategory());
wrapped.put("sourceId", sourceCode);
return wrapped;
})
.collect(Collectors.toList());
return collectService.batchIngest(wrappedList);
}
// ==================== 数据库直连接入 ====================
/**
* 从外部数据库拉取数据
*/
@Transactional
public int pullFromDatabase(Long sourceId, String sql, String targetTable) {
DataSource source = dataSourceMapper.selectById(sourceId);
if (source == null) {
throw new RuntimeException("数据源不存在: " + sourceId);
}
try {
// 执行查询
List<Map<String, Object>> results = jdbcTemplate.queryForList(sql);
int count = 0;
for (Map<String, Object> row : results) {
try {
collectService.ingestRealtime("database", source.getSourceCode(), row);
count++;
} catch (Exception e) {
log.warn("数据库数据接入失败: {}", e.getMessage());
}
}
// 更新同步时间
source.setLastSyncAt(LocalDateTime.now());
dataSourceMapper.updateById(source);
return count;
} catch (Exception e) {
log.error("从数据库拉取数据失败: {}", e.getMessage(), e);
throw new RuntimeException("数据拉取失败: " + e.getMessage());
}
}
/**
* 从外部数据库同步到本地表
*/
@Transactional
public int syncToTable(Long sourceId, String querySql, String targetTable, List<String> columns) {
List<Map<String, Object>> results = jdbcTemplate.queryForList(querySql);
int count = 0;
for (Map<String, Object> row : results) {
try {
String insertSql = buildInsertSql(targetTable, columns);
Object[] params = columns.stream()
.map(col -> row.get(col))
.toArray();
jdbcTemplate.update(insertSql, params);
count++;
} catch (Exception e) {
log.warn("同步到表失败: {}", e.getMessage());
}
}
// 更新数据源同步时间
DataSource source = dataSourceMapper.selectById(sourceId);
if (source != null) {
source.setLastSyncAt(LocalDateTime.now());
dataSourceMapper.updateById(source);
}
return count;
}
// ==================== 文件接入 ====================
/**
* 通过文件(CSV)接入数据
*/
@Transactional
public int ingestFromFile(MultipartFile file, String sourceCode) throws Exception {
DataSource source = getSourceByCode(sourceCode);
if (source == null) {
throw new RuntimeException("数据源不存在: " + sourceCode);
}
List<Map<String, Object>> dataList = parseCsv(file);
return batchIngestViaApi(sourceCode, dataList);
}
// ==================== 数据源管理 ====================
/**
* 创建数据源
*/
@Transactional
public DataSource createDataSource(DataSource dataSource) {
// 检查编码唯一性
LambdaQueryWrapper<DataSource> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DataSource::getSourceCode, dataSource.getSourceCode());
if (dataSourceMapper.selectCount(wrapper) > 0) {
throw new RuntimeException("数据源编码已存在: " + dataSource.getSourceCode());
}
dataSource.setStatus(1);
dataSourceMapper.insert(dataSource);
return dataSource;
}
/**
* 更新数据源
*/
@Transactional
public DataSource updateDataSource(Long id, DataSource dataSource) {
DataSource existing = dataSourceMapper.selectById(id);
if (existing == null) {
throw new RuntimeException("数据源不存在: " + id);
}
dataSource.setId(id);
dataSourceMapper.updateById(dataSource);
return dataSourceMapper.selectById(id);
}
/**
* 删除数据源
*/
@Transactional
public void deleteDataSource(Long id) {
dataSourceMapper.deleteById(id);
}
/**
* 查询数据源列表
*/
public List<DataSource> listDataSources(String sourceType) {
LambdaQueryWrapper<DataSource> wrapper = new LambdaQueryWrapper<>();
if (sourceType != null && !sourceType.isEmpty()) {
wrapper.eq(DataSource::getSourceType, sourceType);
}
wrapper.orderByDesc(DataSource::getCreatedAt);
return dataSourceMapper.selectList(wrapper);
}
/**
* 获取数据源详情
*/
public DataSource getDataSource(Long id) {
return dataSourceMapper.selectById(id);
}
/**
* 测试数据源连接
*/
public boolean testConnection(Long id) {
DataSource source = dataSourceMapper.selectById(id);
if (source == null) {
return false;
}
try {
// 根据数据源类型测试连接
return switch (source.getSourceType()) {
case "database" -> testDatabaseConnection(source);
case "kafka" -> testKafkaConnection(source);
default -> true;
};
} catch (Exception e) {
log.error("测试连接失败: {}", e.getMessage());
return false;
}
}
// ==================== 私有方法 ====================
private DataSource getSourceByCode(String sourceCode) {
LambdaQueryWrapper<DataSource> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DataSource::getSourceCode, sourceCode);
return dataSourceMapper.selectOne(wrapper);
}
private String buildInsertSql(String table, List<String> columns) {
String cols = String.join(", ", columns);
String placeholders = columns.stream()
.map(c -> "?")
.collect(Collectors.joining(", "));
return String.format("INSERT INTO %s (%s) VALUES (%s)", table, cols, placeholders);
}
private List<Map<String, Object>> parseCsv(MultipartFile file) throws Exception {
List<Map<String, Object>> result = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) {
String headerLine = reader.readLine();
if (headerLine == null) {
return result;
}
String[] headers = headerLine.split(",");
String line;
while ((line = reader.readLine()) != null) {
String[] values = line.split(",");
Map<String, Object> row = new LinkedHashMap<>();
for (int i = 0; i < headers.length && i < values.length; i++) {
row.put(headers[i].trim(), values[i].trim());
}
result.add(row);
}
}
return result;
}
private boolean testDatabaseConnection(DataSource source) {
// 简单的连接测试
try {
jdbcTemplate.queryForObject("SELECT 1", Integer.class);
return true;
} catch (Exception e) {
return false;
}
}
private boolean testKafkaConnection(DataSource source) {
// Kafka 连接测试(简化)
return true;
}
}
@@ -0,0 +1,249 @@
package com.water.data_engine.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.data_engine.entity.DataLineage;
import com.water.data_engine.entity.SyncTask;
import com.water.data_engine.mapper.DataLineageMapper;
import com.water.data_engine.mapper.SyncTaskMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 数据集成服务
* DE-04: 多源异构数据整合
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DataIntegrationService {
private final SyncTaskMapper syncTaskMapper;
private final DataLineageMapper dataLineageMapper;
private final JdbcTemplate jdbcTemplate;
private final DataStorageService storageService;
// ==================== 数据同步任务 ====================
/**
* 创建同步任务
*/
@Transactional
public SyncTask createSyncTask(SyncTask syncTask) {
syncTask.setStatus("pending");
syncTaskMapper.insert(syncTask);
return syncTask;
}
/**
* 执行同步任务
*/
@Transactional
public int executeSyncTask(Long taskId) {
SyncTask task = syncTaskMapper.selectById(taskId);
if (task == null) {
throw new RuntimeException("同步任务不存在: " + taskId);
}
task.setStatus("running");
syncTaskMapper.updateById(task);
try {
int count = performSync(task);
task.setStatus("completed");
task.setLastSyncAt(LocalDateTime.now());
task.setLastSyncCount((long) count);
task.setErrorMsg(null);
syncTaskMapper.updateById(task);
return count;
} catch (Exception e) {
task.setStatus("failed");
task.setErrorMsg(e.getMessage());
syncTaskMapper.updateById(task);
throw new RuntimeException("同步任务执行失败: " + e.getMessage());
}
}
/**
* 执行全量同步
*/
@Transactional
public int fullSync(Long sourceId, String sourceTable, String targetTable) {
try {
// 查询源表所有数据
String querySql = "SELECT * FROM " + sourceTable;
List<Map<String, Object>> data = jdbcTemplate.queryForList(querySql);
// 批量写入目标表
return storageService.batchInsertToPostgres(targetTable, data);
} catch (Exception e) {
log.error("全量同步失败: {}", e.getMessage(), e);
throw new RuntimeException("同步失败: " + e.getMessage());
}
}
/**
* 执行增量同步(基于时间戳)
*/
@Transactional
public int incrementalSync(Long sourceId, String sourceTable, String targetTable,
String timestampColumn, LocalDateTime lastSyncTime) {
try {
String querySql = String.format(
"SELECT * FROM %s WHERE %s > ? ORDER BY %s",
sourceTable, timestampColumn, timestampColumn);
List<Map<String, Object>> data = jdbcTemplate.queryForList(querySql, lastSyncTime);
return storageService.batchInsertToPostgres(targetTable, data);
} catch (Exception e) {
log.error("增量同步失败: {}", e.getMessage(), e);
throw new RuntimeException("增量同步失败: " + e.getMessage());
}
}
/**
* 数据合并(多源整合)
*/
@Transactional
public List<Map<String, Object>> mergeData(List<String> sourceTables,
String joinColumn,
List<String> selectColumns) {
if (sourceTables.isEmpty()) {
return List.of();
}
// 构建 UNION ALL 查询
String columnList = String.join(", ", selectColumns);
String unions = sourceTables.stream()
.map(table -> String.format("SELECT %s FROM %s", columnList, table))
.collect(Collectors.joining(" UNION ALL "));
String sql = String.format("SELECT %s FROM (%s) AS merged ORDER BY %s DESC",
columnList, unions, joinColumn);
return jdbcTemplate.queryForList(sql);
}
/**
* 数据聚合(按维度汇总)
*/
public List<Map<String, Object>> aggregateData(String sourceTable,
List<String> groupByColumns,
Map<String, String> aggregations) {
String groupBy = String.join(", ", groupByColumns);
String aggExpr = aggregations.entrySet().stream()
.map(e -> String.format("%s(%s) AS %s_%s", e.getValue(), e.getKey(), e.getKey(), e.getValue().toLowerCase()))
.collect(Collectors.joining(", "));
String sql = String.format(
"SELECT %s, %s FROM %s GROUP BY %s ORDER BY %s",
groupBy, aggExpr, sourceTable, groupBy, groupBy);
return jdbcTemplate.queryForList(sql);
}
// ==================== 数据血缘 ====================
/**
* 创建数据血缘关系
*/
@Transactional
public DataLineage createLineage(DataLineage lineage) {
lineage.setCreatedAt(LocalDateTime.now());
dataLineageMapper.insert(lineage);
return lineage;
}
/**
* 查询血缘关系(上游)
*/
public List<DataLineage> getUpstreamLineage(String tableName) {
LambdaQueryWrapper<DataLineage> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DataLineage::getTargetTable, tableName);
return dataLineageMapper.selectList(wrapper);
}
/**
* 查询血缘关系(下游)
*/
public List<DataLineage> getDownstreamLineage(String tableName) {
LambdaQueryWrapper<DataLineage> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(DataLineage::getSourceTable, tableName);
return dataLineageMapper.selectList(wrapper);
}
/**
* 查询完整血缘链路
*/
public Map<String, Object> getFullLineage(String tableName) {
Map<String, Object> result = new HashMap<>();
result.put("table", tableName);
result.put("upstream", getUpstreamLineage(tableName));
result.put("downstream", getDownstreamLineage(tableName));
return result;
}
// ==================== 查询方法 ====================
/**
* 查询同步任务列表
*/
public List<SyncTask> listSyncTasks(String status) {
LambdaQueryWrapper<SyncTask> wrapper = new LambdaQueryWrapper<>();
if (status != null && !status.isEmpty()) {
wrapper.eq(SyncTask::getStatus, status);
}
wrapper.orderByDesc(SyncTask::getCreatedAt);
return syncTaskMapper.selectList(wrapper);
}
/**
* 获取同步任务详情
*/
public SyncTask getSyncTask(Long id) {
return syncTaskMapper.selectById(id);
}
/**
* 删除同步任务
*/
@Transactional
public void deleteSyncTask(Long id) {
syncTaskMapper.deleteById(id);
}
// ==================== 私有方法 ====================
private int performSync(SyncTask task) {
// 根据同步类型执行不同策略
return switch (task.getSyncType()) {
case "full" -> performFullSync(task);
case "incremental" -> performIncrementalSync(task);
default -> 0;
};
}
private int performFullSync(SyncTask task) {
// 简化实现:实际应根据 sourceId 查找数据源配置
log.info("执行全量同步任务: {}", task.getTaskName());
return 0;
}
private int performIncrementalSync(SyncTask task) {
LocalDateTime lastSync = task.getLastSyncAt();
if (lastSync == null) {
// 首次同步,使用默认起始时间
lastSync = LocalDateTime.now().minusDays(1);
}
log.info("执行增量同步任务: {}, lastSync: {}", task.getTaskName(), lastSync);
return 0;
}
}
@@ -0,0 +1,345 @@
package com.water.data_engine.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.water.data_engine.entity.StorageConfig;
import com.water.data_engine.mapper.StorageConfigMapper;
import io.minio.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 数据存储管理服务
* DE-03: TDengine + PostgreSQL + MinIO
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DataStorageService {
private final StorageConfigMapper storageConfigMapper;
private final JdbcTemplate jdbcTemplate;
// ==================== TDengine 时序存储 ====================
/**
* 写入遥测数据到 TDengine
*/
public void writeToTDengine(String deviceSn, String deviceType, String area,
String metricKey, Double value) {
try {
// 使用子表方式写入(按设备分表)
String childTable = "device_" + deviceSn.replaceAll("[^a-zA-Z0-9]", "_");
String createTableSql = String.format(
"CREATE TABLE IF NOT EXISTS water_iot.%s USING water_iot.iot_telemetry TAGS('%s', '%s', '%s')",
childTable, deviceType, area, deviceSn);
jdbcTemplate.update(createTableSql);
String insertSql = String.format(
"INSERT INTO water_iot.%s (ts, device_sn, metric_key, metric_value, quality) VALUES (NOW, '%s', '%s', %f, 1)",
childTable, deviceSn, metricKey, value);
jdbcTemplate.update(insertSql);
} catch (Exception e) {
log.error("写入 TDengine 失败: {}", e.getMessage());
}
}
/**
* 批量写入遥测数据
*/
@Transactional
public int batchWriteToTDengine(List<Map<String, Object>> dataList) {
int count = 0;
for (Map<String, Object> data : dataList) {
try {
writeToTDengine(
(String) data.get("deviceSn"),
(String) data.get("deviceType"),
(String) data.get("area"),
(String) data.get("metricKey"),
((Number) data.get("value")).doubleValue()
);
count++;
} catch (Exception e) {
log.warn("批量写入单条失败: {}", e.getMessage());
}
}
return count;
}
/**
* 从 TDengine 查询遥测数据
*/
public List<Map<String, Object>> queryFromTDengine(String deviceSn, String metricKey,
LocalDateTime startTime, LocalDateTime endTime) {
try {
String sql = """
SELECT ts, device_sn, metric_key, metric_value, quality
FROM water_iot.iot_telemetry
WHERE device_sn = ? AND metric_key = ?
AND ts >= ? AND ts <= ?
ORDER BY ts DESC
""";
return jdbcTemplate.queryForList(sql, deviceSn, metricKey, startTime, endTime);
} catch (Exception e) {
log.error("查询 TDengine 失败: {}", e.getMessage());
return List.of();
}
}
/**
* 查询聚合数据(小时级)
*/
public List<Map<String, Object>> queryHourlyAgg(String deviceSn, String metricKey,
LocalDateTime startTime, LocalDateTime endTime) {
try {
String sql = """
SELECT _wstart as ts, device_sn, metric_key,
MIN(metric_value) as min_val, MAX(metric_value) as max_val,
AVG(metric_value) as avg_val, COUNT(*) as cnt
FROM water_iot.iot_telemetry
WHERE device_sn = ? AND metric_key = ?
AND ts >= ? AND ts <= ?
INTERVAL(1h)
""";
return jdbcTemplate.queryForList(sql, deviceSn, metricKey, startTime, endTime);
} catch (Exception e) {
log.error("查询聚合数据失败: {}", e.getMessage());
return List.of();
}
}
// ==================== PostgreSQL 关系存储 ====================
/**
* 通用数据插入(PostgreSQL)
*/
@Transactional
public Long insertToPostgres(String table, Map<String, Object> data) {
List<String> columns = new ArrayList<>(data.keySet());
String cols = String.join(", ", columns);
String placeholders = columns.stream().map(c -> "?").collect(Collectors.joining(", "));
String sql = String.format("INSERT INTO %s (%s) VALUES (%s) RETURNING id", table, cols, placeholders);
Object[] params = columns.stream().map(data::get).toArray();
return jdbcTemplate.queryForObject(sql, Long.class, params);
}
/**
* 批量插入(PostgreSQL)
*/
@Transactional
public int batchInsertToPostgres(String table, List<Map<String, Object>> dataList) {
if (dataList.isEmpty()) {
return 0;
}
int count = 0;
for (Map<String, Object> data : dataList) {
try {
insertToPostgres(table, data);
count++;
} catch (Exception e) {
log.warn("批量插入单条失败: {}", e.getMessage());
}
}
return count;
}
/**
* 更新数据(PostgreSQL)
*/
@Transactional
public int updateInPostgres(String table, Long id, Map<String, Object> data) {
List<String> setClauses = new ArrayList<>();
List<Object> params = new ArrayList<>();
for (Map.Entry<String, Object> entry : data.entrySet()) {
setClauses.add(entry.getKey() + " = ?");
params.add(entry.getValue());
}
params.add(id);
String sql = String.format("UPDATE %s SET %s WHERE id = ?", table, String.join(", ", setClauses));
return jdbcTemplate.update(sql, params.toArray());
}
/**
* 查询数据(PostgreSQL)
*/
public List<Map<String, Object>> queryFromPostgres(String table, Map<String, Object> conditions,
int page, int size) {
StringBuilder sql = new StringBuilder("SELECT * FROM ").append(table).append(" WHERE 1=1");
List<Object> params = new ArrayList<>();
for (Map.Entry<String, Object> entry : conditions.entrySet()) {
sql.append(" AND ").append(entry.getKey()).append(" = ?");
params.add(entry.getValue());
}
sql.append(" ORDER BY id DESC LIMIT ? OFFSET ?");
params.add(size);
params.add((page - 1) * size);
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
}
// ==================== MinIO 对象存储 ====================
/**
* 上传文件到 MinIO
*/
public String uploadToMinio(MultipartFile file, String module) throws Exception {
MinioClient client = getMinioClient();
String bucket = "water-management";
String objectName = module + "/" + LocalDate.now() + "/" +
UUID.randomUUID() + "_" + file.getOriginalFilename();
// 确保桶存在
if (!client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
}
client.putObject(PutObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build());
return objectName;
}
/**
* 从 MinIO 下载文件
*/
public InputStream downloadFromMinio(String objectName) throws Exception {
MinioClient client = getMinioClient();
return client.getObject(GetObjectArgs.builder()
.bucket("water-management")
.object(objectName)
.build());
}
/**
* 列出 MinIO 文件
*/
public List<String> listMinioObjects(String prefix) throws Exception {
MinioClient client = getMinioClient();
List<String> objects = new ArrayList<>();
Iterable<Result<Item>> results = client.listObjects(ListObjectsArgs.builder()
.bucket("water-management")
.prefix(prefix)
.build());
for (Result<Item> result : results) {
objects.add(result.get().objectName());
}
return objects;
}
// ==================== 存储配置管理 ====================
/**
* 创建存储配置
*/
@Transactional
public StorageConfig createStorageConfig(StorageConfig config) {
config.setStatus(1);
storageConfigMapper.insert(config);
return config;
}
/**
* 更新存储配置
*/
@Transactional
public StorageConfig updateStorageConfig(Long id, StorageConfig config) {
config.setId(id);
storageConfigMapper.updateById(config);
return storageConfigMapper.selectById(id);
}
/**
* 查询存储配置列表
*/
public List<StorageConfig> listStorageConfigs(String storageType) {
LambdaQueryWrapper<StorageConfig> wrapper = new LambdaQueryWrapper<>();
if (storageType != null && !storageType.isEmpty()) {
wrapper.eq(StorageConfig::getStorageType, storageType);
}
return storageConfigMapper.selectList(wrapper);
}
/**
* 测试存储连接
*/
public boolean testStorageConnection(Long id) {
StorageConfig config = storageConfigMapper.selectById(id);
if (config == null) {
return false;
}
try {
return switch (config.getStorageType()) {
case "postgresql" -> testPostgresConnection(config);
case "tdengine" -> testTDengineConnection(config);
case "minio" -> testMinioConnection(config);
default -> false;
};
} catch (Exception e) {
log.error("测试存储连接失败: {}", e.getMessage());
return false;
}
}
// ==================== 私有方法 ====================
private MinioClient getMinioClient() {
return MinioClient.builder()
.endpoint(System.getenv().getOrDefault("MINIO_ENDPOINT", "http://127.0.0.1:9000"))
.credentials(
System.getenv().getOrDefault("MINIO_ACCESS_KEY", "minioadmin"),
System.getenv().getOrDefault("MINIO_SECRET_KEY", "minioadmin"))
.build();
}
private boolean testPostgresConnection(StorageConfig config) {
try {
jdbcTemplate.queryForObject("SELECT 1", Integer.class);
return true;
} catch (Exception e) {
return false;
}
}
private boolean testTDengineConnection(StorageConfig config) {
try {
jdbcTemplate.queryForObject("SELECT server_version()", String.class);
return true;
} catch (Exception e) {
return false;
}
}
private boolean testMinioConnection(StorageConfig config) {
try {
MinioClient client = getMinioClient();
client.bucketExists(BucketExistsArgs.builder().bucket("water-management").build());
return true;
} catch (Exception e) {
return false;
}
}
}
@@ -0,0 +1,83 @@
package com.water.data_engine.websocket;
import com.water.data_engine.service.DataCollectService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* WebSocket 数据推送控制器
* 用于实时数据推送到前端
*/
@Slf4j
@Controller
@RequiredArgsConstructor
public class DataWebSocketController {
private final SimpMessagingTemplate messagingTemplate;
private final DataCollectService collectService;
/**
* 接收客户端订阅请求
*/
@MessageMapping("/subscribe/data")
@SendTo("/topic/data/realtime")
public Map<String, Object> subscribeRealtimeData(Map<String, Object> request) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("status", "subscribed");
response.put("timestamp", LocalDateTime.now().toString());
response.put("message", "已订阅实时数据推送");
return response;
}
/**
* 接收客户端发送的控制指令
*/
@MessageMapping("/control/pause")
@SendTo("/topic/data/control")
public Map<String, Object> pauseDataPush(Map<String, Object> request) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("action", "pause");
response.put("status", "success");
response.put("timestamp", LocalDateTime.now().toString());
return response;
}
@MessageMapping("/control/resume")
@SendTo("/topic/data/control")
public Map<String, Object> resumeDataPush(Map<String, Object> request) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("action", "resume");
response.put("status", "success");
response.put("timestamp", LocalDateTime.now().toString());
return response;
}
/**
* 主动推送数据到指定 topic
*/
public void pushRealtimeData(String sourceType, Map<String, Object> data) {
messagingTemplate.convertAndSend("/topic/data/realtime/" + sourceType, data);
}
/**
* 推送告警数据
*/
public void pushAlertData(Map<String, Object> alert) {
messagingTemplate.convertAndSend("/topic/data/alert", alert);
}
/**
* 推送统计数据
*/
public void pushStatistics(Map<String, Object> stats) {
messagingTemplate.convertAndSend("/topic/data/statistics", stats);
}
}
@@ -8,10 +8,44 @@ spring:
url: jdbc:postgresql://${PG_HOST:127.0.0.1}:5432/water_management
username: ${PG_USER:water}
password: ${PG_PASS:water123}
driver-class-name: org.postgresql.Driver
cloud:
nacos:
discovery:
server-addr: ${NACOS_HOST:127.0.0.1}:8848
kafka:
bootstrap-servers: ${KAFKA_SERVERS:127.0.0.1}:9092
consumer:
group-id: wm-data-engine
auto-offset-reset: latest
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
# MinIO 配置
minio:
endpoint: ${MINIO_ENDPOINT:http://127.0.0.1:9000}
access-key: ${MINIO_ACCESS_KEY:minioadmin}
secret-key: ${MINIO_SECRET_KEY:minioadmin}
bucket: water-management
# 日志配置
logging:
level:
com.water.data_engine: DEBUG
com.baomidou.mybatisplus: DEBUG
@@ -0,0 +1,217 @@
-- =============================================
-- 智慧水务管理系统 - 数据引擎 DDL
-- 版本: V1
-- 描述: 数据汇聚引擎相关表
-- =============================================
-- ==================== 数据源管理 ====================
-- 数据源配置表
CREATE TABLE IF NOT EXISTS de_data_source (
id BIGSERIAL PRIMARY KEY,
source_name VARCHAR(100) NOT NULL,
source_code VARCHAR(50) UNIQUE NOT NULL,
source_type VARCHAR(30) NOT NULL, -- mqtt/kafka/rest/websocket/database/file
category VARCHAR(30), -- iot/manual/api/database
connection_config JSONB, -- 连接配置(JSON)
sync_mode VARCHAR(20) DEFAULT 'realtime', -- realtime/batch/scheduled
sync_cron VARCHAR(50), -- 定时同步Cron表达式
status SMALLINT DEFAULT 1, -- 0:禁用 1:启用
description VARCHAR(500),
last_sync_at TIMESTAMP,
deleted SMALLINT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE de_data_source IS '数据源配置表';
COMMENT ON COLUMN de_data_source.source_type IS '数据源类型: mqtt/kafka/rest/websocket/database/file';
COMMENT ON COLUMN de_data_source.sync_mode IS '同步模式: realtime/batch/scheduled';
CREATE INDEX IF NOT EXISTS idx_de_data_source_type ON de_data_source(source_type);
CREATE INDEX IF NOT EXISTS idx_de_data_source_status ON de_data_source(status);
-- ==================== 数据采集 ====================
-- 数据采集任务表
CREATE TABLE IF NOT EXISTS de_collect_task (
id BIGSERIAL PRIMARY KEY,
task_name VARCHAR(100) NOT NULL,
source_id BIGINT REFERENCES de_data_source(id),
collect_type VARCHAR(30) NOT NULL, -- realtime/batch/manual
topic VARCHAR(100), -- Kafka/MQTT topic
target_table VARCHAR(100), -- 目标表名
transform_rule JSONB, -- 转换规则
status VARCHAR(20) DEFAULT 'pending', -- pending/running/paused/completed/failed
total_count BIGINT DEFAULT 0,
success_count BIGINT DEFAULT 0,
fail_count BIGINT DEFAULT 0,
start_time TIMESTAMP,
end_time TIMESTAMP,
error_msg TEXT,
deleted SMALLINT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE de_collect_task IS '数据采集任务表';
CREATE INDEX IF NOT EXISTS idx_de_collect_task_status ON de_collect_task(status);
CREATE INDEX IF NOT EXISTS idx_de_collect_task_source ON de_collect_task(source_id);
-- 数据采集记录表
CREATE TABLE IF NOT EXISTS de_collect_record (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT REFERENCES de_collect_task(id),
source_id BIGINT REFERENCES de_data_source(id),
source_type VARCHAR(30),
source_key VARCHAR(100),
raw_data JSONB,
processed_data JSONB,
status VARCHAR(20) DEFAULT 'success', -- success/failed/skipped
error_msg VARCHAR(500),
collect_time TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE de_collect_record IS '数据采集记录表';
CREATE INDEX IF NOT EXISTS idx_de_collect_record_time ON de_collect_record(collect_time DESC);
CREATE INDEX IF NOT EXISTS idx_de_collect_record_task ON de_collect_record(task_id);
-- ==================== 数据接入 ====================
-- API接入配置表
CREATE TABLE IF NOT EXISTS de_api_config (
id BIGSERIAL PRIMARY KEY,
api_name VARCHAR(100) NOT NULL,
api_path VARCHAR(200) UNIQUE NOT NULL,
method VARCHAR(10) DEFAULT 'POST', -- GET/POST/PUT
source_id BIGINT REFERENCES de_data_source(id),
request_schema JSONB, -- 请求Schema定义
response_schema JSONB, -- 响应Schema定义
auth_type VARCHAR(20) DEFAULT 'none', -- none/token/api_key/basic
rate_limit INT DEFAULT 100, -- 限流(次/分钟)
status SMALLINT DEFAULT 1,
deleted SMALLINT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE de_api_config IS 'API接入配置表';
-- ==================== 数据存储 ====================
-- 存储配置表
CREATE TABLE IF NOT EXISTS de_storage_config (
id BIGSERIAL PRIMARY KEY,
storage_name VARCHAR(100) NOT NULL,
storage_type VARCHAR(30) NOT NULL, -- tdengine/postgresql/minio
connection_url VARCHAR(500),
username VARCHAR(100),
password VARCHAR(255),
database_name VARCHAR(100),
bucket_name VARCHAR(100),
extra_config JSONB,
status SMALLINT DEFAULT 1,
deleted SMALLINT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE de_storage_config IS '存储配置表';
-- 存储路由规则表(哪类数据存到哪)
CREATE TABLE IF NOT EXISTS de_storage_route (
id BIGSERIAL PRIMARY KEY,
source_type VARCHAR(30) NOT NULL,
data_category VARCHAR(50), -- telemetry/quality/billing/document
storage_id BIGINT REFERENCES de_storage_config(id),
target_table VARCHAR(100),
partition_rule VARCHAR(200), -- 分区规则
retention_days INT DEFAULT 365,
status SMALLINT DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE de_storage_route IS '存储路由规则表';
-- ==================== 数据集成 ====================
-- 数据同步任务表
CREATE TABLE IF NOT EXISTS de_sync_task (
id BIGSERIAL PRIMARY KEY,
task_name VARCHAR(100) NOT NULL,
source_id BIGINT REFERENCES de_data_source(id),
target_storage_id BIGINT REFERENCES de_storage_config(id),
sync_type VARCHAR(30) NOT NULL, -- full/incremental/cdc
sync_cron VARCHAR(50),
last_sync_at TIMESTAMP,
last_sync_count BIGINT,
status VARCHAR(20) DEFAULT 'pending', -- pending/running/paused/completed/failed
error_msg TEXT,
deleted SMALLINT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE de_sync_task IS '数据同步任务表';
CREATE INDEX IF NOT EXISTS idx_de_sync_task_status ON de_sync_task(status);
-- ==================== 数据质量 ====================
-- 数据质量规则表
CREATE TABLE IF NOT EXISTS de_quality_rule (
id BIGSERIAL PRIMARY KEY,
rule_name VARCHAR(100) NOT NULL,
rule_type VARCHAR(30) NOT NULL, -- completeness/validity/timeliness/consistency
table_name VARCHAR(100),
column_name VARCHAR(100),
rule_expr VARCHAR(500), -- 规则表达式
threshold DECIMAL(5,2), -- 阈值
severity VARCHAR(20) DEFAULT 'warning', -- info/warning/error
enabled SMALLINT DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE de_quality_rule IS '数据质量规则表';
-- 数据质量检查记录表
CREATE TABLE IF NOT EXISTS de_quality_check (
id BIGSERIAL PRIMARY KEY,
rule_id BIGINT REFERENCES de_quality_rule(id),
check_time TIMESTAMP DEFAULT NOW(),
total_count BIGINT,
pass_count BIGINT,
fail_count BIGINT,
pass_rate DECIMAL(5,2),
result_detail JSONB,
status VARCHAR(20) DEFAULT 'success' -- success/failed
);
COMMENT ON TABLE de_quality_check IS '数据质量检查记录表';
CREATE INDEX IF NOT EXISTS idx_de_quality_check_time ON de_quality_check(check_time DESC);
-- ==================== 数据血缘 ====================
-- 数据血缘关系表
CREATE TABLE IF NOT EXISTS de_data_lineage (
id BIGSERIAL PRIMARY KEY,
source_table VARCHAR(100) NOT NULL,
source_column VARCHAR(100),
target_table VARCHAR(100) NOT NULL,
target_column VARCHAR(100),
transform_type VARCHAR(30), -- direct/mapping/aggregation/calculation
transform_rule TEXT,
description VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE de_data_lineage IS '数据血缘关系表';
CREATE INDEX IF NOT EXISTS idx_de_lineage_source ON de_data_lineage(source_table);
CREATE INDEX IF NOT EXISTS idx_de_lineage_target ON de_data_lineage(target_table);
-- ==================== 数据引擎统计 ====================
-- 数据统计仪表板
CREATE TABLE IF NOT EXISTS de_stat_daily (
id BIGSERIAL PRIMARY KEY,
stat_date DATE NOT NULL,
source_id BIGINT,
collect_count BIGINT DEFAULT 0,
store_count BIGINT DEFAULT 0,
quality_score DECIMAL(5,2),
sync_count BIGINT DEFAULT 0,
error_count BIGINT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(stat_date, source_id)
);
COMMENT ON TABLE de_stat_daily IS '日统计数据表';
@@ -0,0 +1,128 @@
package com.water.data_engine.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 数据采集服务测试
*/
@ExtendWith(MockitoExtension.class)
class DataCollectServiceTest {
@Mock
private KafkaTemplate<String, String> kafkaTemplate;
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private com.water.data_engine.mapper.DataSourceMapper dataSourceMapper;
@Mock
private com.water.data_engine.mapper.CollectTaskMapper collectTaskMapper;
@Mock
private com.water.data_engine.mapper.CollectRecordMapper collectRecordMapper;
@Mock
private SimpMessagingTemplate wsMessagingTemplate;
private DataCollectService collectService;
@BeforeEach
void setUp() {
collectService = new DataCollectService(
kafkaTemplate, jdbcTemplate, dataSourceMapper,
collectTaskMapper, collectRecordMapper, wsMessagingTemplate
);
}
@Test
@DisplayName("实时数据接入-IoT设备数据")
void testIngestRealtime_IoT() {
// Given
Map<String, Object> data = new HashMap<>();
data.put("deviceSn", "FM001");
data.put("metrics", List.of(
Map.of("key", "LL", "value", 12.5),
Map.of("key", "YL", "value", 0.35)
));
// When
String topic = collectService.ingestRealtime("iot", "FM001", data);
// Then
assertEquals("iot.raw.generic", topic);
verify(kafkaTemplate).send(eq("iot.raw.generic"), eq("FM001"), anyString());
verify(wsMessagingTemplate).convertAndSend(eq("/topic/data/realtime/iot"), any());
}
@Test
@DisplayName("实时数据接入-水质数据")
void testIngestRealtime_Quality() {
Map<String, Object> data = new HashMap<>();
data.put("testPoint", "水厂出口");
data.put("turbidity", 0.5);
data.put("ph", 7.2);
String topic = collectService.ingestRealtime("quality", "WQ001", data);
assertEquals("data.quality", topic);
verify(kafkaTemplate).send(eq("data.quality"), eq("WQ001"), anyString());
}
@Test
@DisplayName("批量数据采集")
void testBatchIngest() {
List<Map<String, Object>> batchData = List.of(
Map.of("sourceType", "iot", "sourceId", "FM001", "data", Map.of("LL", 12.5)),
Map.of("sourceType", "iot", "sourceId", "FM002", "data", Map.of("LL", 15.3)),
Map.of("sourceType", "manual", "sourceId", "MAN001", "data", Map.of("SW", 100.0))
);
int count = collectService.batchIngest(batchData);
assertEquals(3, count);
verify(kafkaTemplate, times(3)).send(anyString(), anyString(), anyString());
}
@Test
@DisplayName("创建批量采集任务")
void testCreateBatchTask() {
com.water.data_engine.entity.CollectTask task = collectService.createBatchTask(
"测试批量任务", 1L, "iot_telemetry");
assertNotNull(task);
assertEquals("测试批量任务", task.getTaskName());
assertEquals("batch", task.getCollectType());
assertEquals("pending", task.getStatus());
verify(collectTaskMapper).insert(any(com.water.data_engine.entity.CollectTask.class));
}
@Test
@DisplayName("Topic路由测试")
void testRouteTopic() {
// 测试不同数据源类型的topic路由
assertDoesNotThrow(() -> collectService.ingestRealtime("iot", "test", Map.of()));
assertDoesNotThrow(() -> collectService.ingestRealtime("mqtt", "test", Map.of()));
assertDoesNotThrow(() -> collectService.ingestRealtime("quality", "test", Map.of()));
assertDoesNotThrow(() -> collectService.ingestRealtime("manual", "test", Map.of()));
assertDoesNotThrow(() -> collectService.ingestRealtime("api", "test", Map.of()));
assertDoesNotThrow(() -> collectService.ingestRealtime("unknown", "test", Map.of()));
}
}
@@ -0,0 +1,183 @@
package com.water.data_engine.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* 数据治理服务测试
*/
@ExtendWith(MockitoExtension.class)
class DataGovernanceServiceTest {
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private com.water.data_engine.mapper.QualityRuleMapper qualityRuleMapper;
private DataGovernanceService governanceService;
@BeforeEach
void setUp() {
governanceService = new DataGovernanceService(jdbcTemplate, qualityRuleMapper);
}
@Test
@DisplayName("数据标准化-字段映射")
void testStandardize() {
Map<String, Object> raw = new HashMap<>();
raw.put("flow", 12.5);
raw.put("pressure", 0.35);
raw.put("level", 100.0);
raw.put("turbidity", 0.5);
raw.put("ph", 7.2);
raw.put("custom_field", "test");
Map<String, Object> result = governanceService.standardize(raw);
// 验证字段映射
assertEquals(12.5, result.get("LL"));
assertEquals(0.35, result.get("YL"));
assertEquals(100.0, result.get("SW"));
assertEquals(0.5, result.get("ZD"));
assertEquals(7.2, result.get("PH"));
assertEquals("test", result.get("custom_field"));
assertEquals(true, result.get("_standardized"));
}
@Test
@DisplayName("数据标准化-批量处理")
void testBatchStandardize() {
List<Map<String, Object>> rawDataList = List.of(
Map.of("flow", 12.5, "pressure", 0.35),
Map.of("level", 100.0, "turbidity", 0.5)
);
List<Map<String, Object>> result = governanceService.batchStandardize(rawDataList);
assertEquals(2, result.size());
assertEquals(12.5, result.get(0).get("LL"));
assertEquals(100.0, result.get(1).get("SW"));
}
@Test
@DisplayName("数据清洗-缺失值处理")
void testClean_MissingValues() {
Map<String, Object> data = new HashMap<>();
data.put("LL", null);
data.put("YL", "");
data.put("SW", 100.0);
Map<String, Object> result = governanceService.clean(data);
assertEquals(-9999.0, result.get("LL"));
assertEquals("MISSING", result.get("LL_flag"));
assertEquals(-9999.0, result.get("YL"));
assertEquals("MISSING", result.get("YL_flag"));
assertEquals(100.0, result.get("SW"));
assertEquals(true, result.get("_cleaned"));
}
@Test
@DisplayName("数据清洗-异常值检测")
void testClean_AnomalyDetection() {
Map<String, Object> data = new HashMap<>();
data.put("LL", -5.0); // 流量为负值
data.put("YL", 150.0); // 压力超范围
data.put("ZD", -10.0); // 浊度为负值
Map<String, Object> result = governanceService.clean(data);
assertEquals("ABNORMAL", result.get("LL_flag"));
assertEquals("ABNORMAL", result.get("YL_flag"));
assertEquals("ABNORMAL", result.get("ZD_flag"));
}
@Test
@DisplayName("数据质量检查-完整性")
void testQualityCheck_Completeness() {
Map<String, Object> data = new HashMap<>();
data.put("LL", 12.5);
data.put("LL_flag", "MISSING");
Map<String, Object> result = governanceService.qualityCheck(data);
assertTrue((int) result.get("_quality_score") < 100);
@SuppressWarnings("unchecked")
List<String> issues = (List<String>) result.get("_quality_issues");
assertTrue(issues.stream().anyMatch(i -> i.contains("流量数据缺失")));
}
@Test
@DisplayName("数据质量检查-异常值")
void testQualityCheck_Anomaly() {
Map<String, Object> data = new HashMap<>();
data.put("LL", -5.0);
data.put("LL_flag", "ABNORMAL");
Map<String, Object> result = governanceService.qualityCheck(data);
assertTrue((int) result.get("_quality_score") < 100);
@SuppressWarnings("unchecked")
List<String> issues = (List<String>) result.get("_quality_issues");
assertTrue(issues.stream().anyMatch(i -> i.contains("流量数据异常")));
}
@Test
@DisplayName("完整数据管道")
void testPipeline() {
Map<String, Object> raw = new HashMap<>();
raw.put("flow", 12.5);
raw.put("pressure", 0.35);
Map<String, Object> result = governanceService.pipeline(raw);
// 验证经过标准化
assertEquals(12.5, result.get("LL"));
assertEquals(0.35, result.get("YL"));
// 验证经过清洗
assertEquals(true, result.get("_cleaned"));
// 验证经过质控
assertEquals(true, result.get("_quality_checked"));
assertNotNull(result.get("_quality_score"));
}
@Test
@DisplayName("批量数据管道")
void testBatchPipeline() {
List<Map<String, Object>> rawDataList = List.of(
Map.of("flow", 12.5, "pressure", 0.35),
Map.of("level", 100.0, "turbidity", 0.5)
);
List<Map<String, Object>> result = governanceService.batchPipeline(rawDataList);
assertEquals(2, result.size());
assertTrue(result.stream().allMatch(r -> Boolean.TRUE.equals(r.get("_quality_checked"))));
}
@Test
@DisplayName("pH值范围检查")
void testQualityCheck_PHRange() {
Map<String, Object> data = new HashMap<>();
data.put("PH", 15.0); // 超出 0-14 范围
Map<String, Object> result = governanceService.qualityCheck(data);
@SuppressWarnings("unchecked")
List<String> issues = (List<String>) result.get("_quality_issues");
assertTrue(issues.stream().anyMatch(i -> i.contains("pH值")));
}
}

Some files were not shown because too many files have changed in this diff Show More