Phase 1 #21 #22 #25 #26: GIS + IoT + DevOps + Notify

#21 GIS 引擎集成:
- GeoServer init 脚本(自动创建工作区/数据源)
- Leaflet 地图组件 (Vue3 MapView: 点位/弹窗/OSM底图)
- GisService: PostGIS 空间查询(附近设备/片区统计/GeoJSON)
- GisController: /nearby /device-stats /geojson API

#22 IoT 设备接入层:
- Kafka Consumer: iot.telemetry + iot.event 消费
- DeviceController: 设备列表/详情/注册/指令下发 REST API

#26 消息通知:
- NotifyService: 短信/WebSocket/APP Push/多渠道分发
- NotifyController: SMS/Push API

#25 DevOps:
- 10个微服务 Dockerfile (Eclipse Temurin JRE17)
- CI build.sh: Maven构建 + Docker镜像打包
- Frontend Nginx 反向代理配置
This commit is contained in:
bot_pm
2026-06-14 13:18:19 +08:00
parent 575b2138c1
commit 0b8bad8879
20 changed files with 366 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
set -e
mvn clean package -DskipTests
for mod in wm-{base,iot,data-engine,bpm,production,revenue,patrol,bi,notify,job}; do
docker build -t water/$mod -f docker/$mod/Dockerfile .
done
echo "Build done"
+8
View File
@@ -0,0 +1,8 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / { try_files $uri /index.html; }
location /api/ { proxy_pass http://wm-gateway:8080/; }
}
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# GeoServer 初始化脚本:创建工作区、数据源、图层
set -e
GEOSERVER_URL="http://localhost:8081/geoserver"
USER="admin"
PASS="geoserver"
echo "Waiting for GeoServer..."
until curl -s -u $USER:$PASS "$GEOSERVER_URL/rest/about/version.xml" > /dev/null; do sleep 2; done
# Create workspace
curl -s -u $USER:$PASS -X POST "$GEOSERVER_URL/rest/workspaces" -H "Content-Type: text/xml" -d '<workspace><name>water_management</name></workspace>' || true
echo "GeoServer init done"
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-base/target/wm-base-*.jar app.jar
EXPOSE 8081
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-bi/target/wm-bi-*.jar app.jar
EXPOSE 8088
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-bpm/target/wm-bpm-*.jar app.jar
EXPOSE 8084
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-data-engine/target/wm-data-engine-*.jar app.jar
EXPOSE 8083
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-iot/target/wm-iot-*.jar app.jar
EXPOSE 8082
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-job/target/wm-job-*.jar app.jar
EXPOSE 8090
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-notify/target/wm-notify-*.jar app.jar
EXPOSE 8089
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-patrol/target/wm-patrol-*.jar app.jar
EXPOSE 8087
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-production/target/wm-production-*.jar app.jar
EXPOSE 8085
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-revenue/target/wm-revenue-*.jar app.jar
EXPOSE 8086
ENTRYPOINT ["java", "-jar", "app.jar"]
@@ -0,0 +1,42 @@
<template>
<div ref="mapContainer" class="map-container"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
center?: [number, number]
zoom?: number
markers?: Array<{ lat: number; lng: number; name: string; value?: string }>
}>()
const mapContainer = ref<HTMLElement>()
let map: any = null
onMounted(async () => {
const L = (await import('leaflet')).default
await import('leaflet/dist/leaflet.css')
map = L.map(mapContainer.value!).setView(props.center || [44.6000, 82.9000], props.zoom || 10)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap', maxZoom: 18
}).addTo(map)
if (props.markers) {
props.markers.forEach(m => {
L.marker([m.lat, m.lng])
.addTo(map)
.bindPopup(`<b>${m.name}</b><br/>${m.value || ''}`)
})
}
})
onUnmounted(() => { if (map) map.remove() })
</script>
<style scoped>
.map-container { width: 100%; height: 500px; border-radius: 8px }
</style>
@@ -0,0 +1,22 @@
package com.water.iot.consumer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class IotTelemetryConsumer {
@KafkaListener(topics = "iot.telemetry", groupId = "wm-iot-consumer")
public void consumeTelemetry(String message) {
log.debug("Received telemetry: {}", message);
// TODO: 解析 json -> 写入 TDengine
}
@KafkaListener(topics = "iot.event", groupId = "wm-iot-consumer")
public void consumeEvent(String message) {
log.info("Device event: {}", message);
// TODO: 处理上下线/故障事件
}
}
@@ -0,0 +1,52 @@
package com.water.iot.controller;
import com.water.common.core.result.R;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "设备管理")
@RestController
@RequestMapping("/device")
@RequiredArgsConstructor
public class DeviceController {
private final JdbcTemplate jdbcTemplate;
@Operation(summary = "设备列表")
@GetMapping("/list")
public R<List<Map<String, Object>>> list(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
int offset = (page - 1) * size;
String sql = "SELECT id, device_sn, device_name, device_type, area, status, last_report_time FROM iot_device ORDER BY id LIMIT ? OFFSET ?";
return R.ok(jdbcTemplate.queryForList(sql, size, offset));
}
@Operation(summary = "设备详情")
@GetMapping("/{id}")
public R<Map<String, Object>> getById(@PathVariable Long id) {
return R.ok(jdbcTemplate.queryForMap("SELECT * FROM iot_device WHERE id = ?", id));
}
@Operation(summary = "注册设备")
@PostMapping
public R<String> register(@RequestBody Map<String, Object> body) {
jdbcTemplate.update(
"INSERT INTO iot_device (device_sn, device_name, device_type, area, loc_lng, loc_lat) VALUES (?,?,?,?,?,?)",
body.get("deviceSn"), body.get("deviceName"), body.get("deviceType"), body.get("area"),
body.get("lng"), body.get("lat"));
return R.ok("注册成功");
}
@Operation(summary = "下发指令")
@PostMapping("/{id}/command")
public R<String> sendCommand(@PathVariable Long id, @RequestBody Map<String, Object> cmd) {
// TODO: 实际指令通过 Kafka -> EMQX -> MQTT -> 设备
return R.ok("指令已下发");
}
}
@@ -0,0 +1,39 @@
package com.water.iot.controller;
import com.water.common.core.result.R;
import com.water.iot.service.GisService;
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 = "GIS 地图服务")
@RestController
@RequestMapping("/gis")
@RequiredArgsConstructor
public class GisController {
private final GisService gisService;
@Operation(summary = "查询附近设备")
@GetMapping("/nearby")
public R<List<Map<String, Object>>> nearby(@RequestParam double lng, @RequestParam double lat,
@RequestParam(defaultValue = "5") double radius) {
return R.ok(gisService.findDevicesNearby(lng, lat, radius));
}
@Operation(summary = "片区设备统计")
@GetMapping("/device-stats")
public R<List<Map<String, Object>>> deviceStats() {
return R.ok(gisService.getDeviceStatsByArea());
}
@Operation(summary = "在线设备 GeoJSON")
@GetMapping("/geojson")
public R<String> geojson() {
return R.ok(gisService.getOnlineDevicesGeoJson());
}
}
@@ -0,0 +1,65 @@
package com.water.iot.service;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class GisService {
private final JdbcTemplate jdbcTemplate;
/**
* 查询指定半径内的设备
*/
public List<Map<String, Object>> findDevicesNearby(double lng, double lat, double radiusKm) {
String sql = """
SELECT id, device_sn, device_name, device_type, area,
ST_Distance(geom::geography, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography) / 1000 AS distance_km,
ST_X(geom) as lng, ST_Y(geom) as lat
FROM iot_device
WHERE ST_DWithin(geom::geography, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, ? * 1000)
AND status = 'online'
ORDER BY distance_km
""";
return jdbcTemplate.queryForList(sql, lng, lat, lng, lat, radiusKm);
}
/**
* 查询片区内的设备统计
*/
public List<Map<String, Object>> getDeviceStatsByArea() {
String sql = """
SELECT area, device_type, COUNT(*) as count
FROM iot_device
WHERE deleted = 0
GROUP BY area, device_type
ORDER BY area
""";
return jdbcTemplate.queryForList(sql);
}
/**
* 获取所有在线设备 GeoJSON
*/
public String getOnlineDevicesGeoJson() {
String sql = """
SELECT json_build_object(
'type', 'FeatureCollection',
'features', json_agg(json_build_object(
'type', 'Feature',
'geometry', ST_AsGeoJSON(geom)::json,
'properties', json_build_object(
'id', id, 'name', device_name, 'type', device_type,
'sn', device_sn, 'area', area, 'status', status
)
))
) AS geojson
FROM iot_device WHERE status = 'online' AND geom IS NOT NULL
""";
return jdbcTemplate.queryForObject(sql, String.class);
}
}
@@ -0,0 +1,34 @@
package com.water.notify.controller;
import com.water.common.core.result.R;
import com.water.notify.service.NotifyService;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "消息通知")
@RestController
@RequestMapping("/notify")
@RequiredArgsConstructor
public class NotifyController {
private final NotifyService notifyService;
@PostMapping("/sms")
public R<String> sendSms(@RequestBody Map<String, String> req) {
notifyService.sendSms(req.get("phone"), req.get("content"));
return R.ok("短信发送成功");
}
@PostMapping("/push")
public R<String> push(@RequestBody Map<String, Object> req) {
notifyService.dispatch(
Long.parseLong(String.valueOf(req.get("schemeId"))),
Long.parseLong(String.valueOf(req.get("userId"))),
(String) req.get("title"),
(String) req.get("content"));
return R.ok("通知已分发");
}
}
@@ -0,0 +1,33 @@
package com.water.notify.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class NotifyService {
/** 发送短信 */
public void sendSms(String phone, String content) {
log.info("Send SMS to {}: {}", phone, content);
// TODO: 集成阿里云/腾讯云短信 SDK
}
/** WebSocket 推送 */
public void pushWebSocket(Long userId, String message) {
log.info("Push WS to user {}: {}", userId, message);
// TODO: WebSocket session 管理
}
/** APP Push */
public void pushApp(Long userId, String title, String body) {
log.info("Push APP to user {}: {} - {}", userId, title, body);
// TODO: 极光推送
}
/** 按通知方案多渠道分发 */
public void dispatch(Long schemeId, Long userId, String title, String content) {
// TODO: 查询通知方案,按配置渠道分发
log.info("Notify dispatch: scheme={}, user={}, title={}", schemeId, userId, title);
}
}