Merge remote-tracking branch 'origin/master' into feat/iot-data-storage

This commit is contained in:
lzh
2026-04-13 14:16:48 +08:00
353 changed files with 27263 additions and 6545 deletions

View File

@@ -2,9 +2,10 @@ package com.viewsh.module.iot.api.device;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.iot.api.device.dto.IotDeviceSimpleRespDTO;
import com.viewsh.module.iot.controller.admin.device.vo.device.IotDeviceRespVO;
import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO;
import com.viewsh.module.iot.dal.dataobject.product.IotProductDO;
import com.viewsh.module.iot.service.device.IotDeviceService;
import com.viewsh.module.iot.service.product.IotProductService;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@@ -14,8 +15,8 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
import static com.viewsh.framework.common.pojo.CommonResult.success;
import static com.viewsh.module.iot.api.device.IotDeviceQueryApi.PREFIX;
@@ -36,55 +37,74 @@ public class IotDeviceQueryApiImpl implements IotDeviceQueryApi {
@Resource
private IotDeviceService deviceService;
@Resource
private IotProductService productService;
@Override
@GetMapping(PREFIX + "/simple-list")
@Operation(summary = "获取设备精简列表(按类型/产品筛选)")
public CommonResult<List<IotDeviceSimpleRespDTO>> getDeviceSimpleList(
@RequestParam(value = "deviceType", required = false) Integer deviceType,
@RequestParam(value = "productId", required = false) Long productId) {
try {
List<IotDeviceDO> list = deviceService.getDeviceListByCondition(deviceType, productId);
List<IotDeviceSimpleRespDTO> result = list.stream()
.map(device -> {
IotDeviceSimpleRespDTO dto = new IotDeviceSimpleRespDTO();
dto.setId(device.getId());
dto.setDeviceName(device.getDeviceName());
dto.setProductId(device.getProductId());
dto.setProductKey(device.getProductKey());
// TODO: 从产品服务获取产品名称
dto.setProductName("产品_" + device.getProductKey());
return dto;
})
.collect(Collectors.toList());
return success(result);
} catch (Exception e) {
log.error("[getDeviceSimpleList] 查询设备列表失败: deviceType={}, productId={}", deviceType, productId, e);
return success(List.of());
}
List<IotDeviceDO> list = deviceService.getDeviceListByCondition(deviceType, productId);
List<IotDeviceSimpleRespDTO> result = list.stream()
.map(this::toSimpleDTO)
.toList();
return success(result);
}
@Override
@GetMapping(PREFIX + "/get")
@Operation(summary = "获取设备详情")
public CommonResult<IotDeviceSimpleRespDTO> getDevice(@RequestParam("id") Long id) {
try {
IotDeviceDO device = deviceService.getDevice(id);
if (device == null) {
return success(null);
}
IotDeviceSimpleRespDTO dto = new IotDeviceSimpleRespDTO();
dto.setId(device.getId());
dto.setDeviceName(device.getDeviceName());
dto.setProductId(device.getProductId());
dto.setProductKey(device.getProductKey());
dto.setProductName("产品_" + device.getProductKey());
return success(dto);
} catch (Exception e) {
log.error("[getDevice] 查询设备详情失败: id={}", id, e);
IotDeviceDO device = deviceService.getDevice(id);
if (device == null) {
return success(null);
}
return success(toSimpleDTO(device));
}
@Override
@GetMapping(PREFIX + "/batch-get")
@Operation(summary = "批量获取设备详情")
public CommonResult<List<IotDeviceSimpleRespDTO>> batchGetDevices(
@RequestParam("ids") Collection<Long> ids) {
if (ids == null || ids.isEmpty()) {
return success(List.of());
}
List<IotDeviceDO> devices = deviceService.getDeviceList(ids);
List<IotDeviceSimpleRespDTO> result = devices.stream()
.map(this::toSimpleDTO)
.toList();
return success(result);
}
private IotDeviceSimpleRespDTO toSimpleDTO(IotDeviceDO device) {
IotDeviceSimpleRespDTO dto = new IotDeviceSimpleRespDTO();
dto.setId(device.getId());
dto.setDeviceName(device.getDeviceName());
dto.setProductId(device.getProductId());
dto.setProductKey(device.getProductKey());
dto.setNickname(device.getNickname());
dto.setSerialNumber(device.getSerialNumber());
dto.setState(device.getState());
dto.setDeviceType(device.getDeviceType());
// 从产品缓存获取产品名称
dto.setProductName(resolveProductName(device.getProductId()));
return dto;
}
private String resolveProductName(Long productId) {
if (productId == null) {
return null;
}
try {
IotProductDO product = productService.getProductFromCache(productId);
return product != null ? product.getName() : null;
} catch (Exception e) {
log.warn("[resolveProductName] 获取产品名称失败: productId={}, error={}", productId, e.getMessage());
return null;
}
}
}

View File

@@ -0,0 +1,46 @@
package com.viewsh.module.iot.controller.rpc;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.module.iot.api.trajectory.DeviceLocationDTO;
import com.viewsh.module.iot.api.trajectory.TrajectoryStateApi;
import com.viewsh.module.iot.dal.redis.clean.TrajectoryStateRedisDAO;
import jakarta.annotation.Resource;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RestController;
import static com.viewsh.framework.common.pojo.CommonResult.success;
/**
* 轨迹实时状态 RPC Controller
* <p>
* 实现 {@link TrajectoryStateApi},从 Redis 读取设备当前位置
*
* @author lzh
*/
@RestController
@Validated
public class TrajectoryStateController implements TrajectoryStateApi {
@Resource
private TrajectoryStateRedisDAO trajectoryStateRedisDAO;
@Override
public CommonResult<DeviceLocationDTO> getCurrentLocation(Long deviceId) {
TrajectoryStateRedisDAO.CurrentAreaInfo currentArea = trajectoryStateRedisDAO.getCurrentArea(deviceId);
if (currentArea == null) {
return success(DeviceLocationDTO.builder()
.deviceId(deviceId)
.inArea(false)
.build());
}
return success(DeviceLocationDTO.builder()
.deviceId(deviceId)
.areaId(currentArea.getAreaId())
.enterTime(currentArea.getEnterTime())
.beaconMac(currentArea.getBeaconMac())
.inArea(true)
.build());
}
}

View File

@@ -26,7 +26,31 @@ public class BeaconPresenceConfig {
private String beaconMac;
/**
* <EFBFBD><EFBFBD>动窗口配置
* iBeacon UUID
* <p>
* iBeacon 协议的 UUID 标识,用于区分不同的信标组
* 例如FDA50693-A4E2-4FB1-AFCF-C6EB07647825
*/
private String beaconUuid;
/**
* iBeacon Major 值
* <p>
* 用于区分同一 UUID 下的不同区域或楼层
* 范围0-65535
*/
private Integer major;
/**
* iBeacon Minor 值
* <p>
* 用于区分同一 Major 下的不同信标
* 范围0-65535
*/
private Integer minor;
/**
* 滑动窗口配置
*/
private WindowConfig window;

View File

@@ -12,6 +12,27 @@ import lombok.Data;
@Data
public class TrafficThresholdConfig {
/**
* 上报模式INCREMENTAL / CUMULATIVE
*/
public static final String REPORT_MODE_INCREMENTAL = "INCREMENTAL";
public static final String REPORT_MODE_CUMULATIVE = "CUMULATIVE";
/**
* 设备上报模式
* <p>
* INCREMENTAL默认设备上报增量值直接使用
* CUMULATIVE设备上报累计值需计算差值得到增量
*/
private String reportMode;
/**
* 是否累计值模式
*/
public boolean isCumulative() {
return REPORT_MODE_CUMULATIVE.equalsIgnoreCase(reportMode);
}
/**
* 触发阈值
* <p>

View File

@@ -0,0 +1,26 @@
package com.viewsh.module.iot.dal.dataobject.integration.clean;
import lombok.Data;
/**
* 轨迹记录功能配置
* <p>
* 存储在 iot_device.config JSON 字段中,与 ButtonEventConfig 同级
* <pre>
* {
* "buttonEvent": { ... },
* "trajectoryTracking": { "enabled": true }
* }
* </pre>
*
* @author lzh
*/
@Data
public class TrajectoryTrackingConfig {
/**
* 是否启用轨迹记录
*/
private Boolean enabled;
}

View File

@@ -1,5 +1,6 @@
package com.viewsh.module.iot.dal.redis.clean;
import com.viewsh.framework.tenant.core.context.TenantContextHolder;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
@@ -54,6 +55,7 @@ public class TrafficCounterRedisDAO {
private static final String FIELD_TOTAL_OUT = "totalOut";
private static final String FIELD_LAST_PERSISTED_IN = "lastPersistedIn";
private static final String FIELD_LAST_PERSISTED_OUT = "lastPersistedOut";
private static final String FIELD_TENANT_ID = "tenantId";
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
@@ -124,6 +126,8 @@ public class TrafficCounterRedisDAO {
/**
* 原子递增当日累积统计
* <p>
* 同时记录当前租户ID供持久化任务使用
*
* @param deviceId 设备ID
* @param date 日期
@@ -138,6 +142,11 @@ public class TrafficCounterRedisDAO {
if (peopleOut > 0) {
stringRedisTemplate.opsForHash().increment(key, FIELD_TOTAL_OUT, peopleOut);
}
// 记录租户ID幂等写入同一设备始终属于同一租户
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId != null) {
stringRedisTemplate.opsForHash().putIfAbsent(key, FIELD_TENANT_ID, String.valueOf(tenantId));
}
// 设置 TTL幂等每次都设置保证不过期
stringRedisTemplate.expire(key, DAILY_TTL_SECONDS, TimeUnit.SECONDS);
}
@@ -235,6 +244,58 @@ public class TrafficCounterRedisDAO {
return null;
}
// ==================== 累计值设备上次值存取 ====================
/**
* 累计值设备上次上报值 Key 模式
* <p>
* 格式iot:clean:traffic:lastvalue:{deviceId}
* Hash Fieldpeople_in / people_out
*/
private static final String LAST_VALUE_KEY_PATTERN = "iot:clean:traffic:lastvalue:%s";
/**
* 累计值 TTL- 7 天,跨天不丢失,设备离线数天后仍能衔接
*/
private static final int LAST_VALUE_TTL_SECONDS = 604800;
/**
* 获取设备上次上报的累计值
*
* @param deviceId 设备ID
* @param identifier 属性标识符people_in / people_out
* @return 上次累计值,首次上报时返回 null
*/
public Long getLastCumulativeValue(Long deviceId, String identifier) {
String key = String.format(LAST_VALUE_KEY_PATTERN, deviceId);
Object value = stringRedisTemplate.opsForHash().get(key, identifier);
if (value == null) {
return null;
}
try {
return Long.parseLong(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
/**
* 更新设备上次上报的累计值
*
* @param deviceId 设备ID
* @param identifier 属性标识符people_in / people_out
* @param value 当前累计值
*/
public void setLastCumulativeValue(Long deviceId, String identifier, long value) {
String key = String.format(LAST_VALUE_KEY_PATTERN, deviceId);
stringRedisTemplate.opsForHash().put(key, identifier, String.valueOf(value));
// 仅在 key 无 TTL 时设置,避免高频上报场景下每次都执行 EXPIRE
Long ttl = stringRedisTemplate.getExpire(key, TimeUnit.SECONDS);
if (ttl == null || ttl == -1) {
stringRedisTemplate.expire(key, LAST_VALUE_TTL_SECONDS, TimeUnit.SECONDS);
}
}
// ==================== 私有方法 ====================
private static String formatThresholdKey(Long deviceId, Long areaId) {

View File

@@ -0,0 +1,71 @@
package com.viewsh.module.iot.dal.redis.clean;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 轨迹检测 RSSI 滑动窗口 Redis DAO
* <p>
* 与 BeaconRssiWindowRedisDAO 功能相同,使用独立的 Key 前缀,
* 避免与保洁到岗检测的窗口数据互相干扰
*
* @author lzh
*/
@Repository
public class TrajectoryRssiWindowRedisDAO {
private static final String WINDOW_KEY_PATTERN = "iot:trajectory:rssi:window:%s:%s";
private static final int WINDOW_TTL_SECONDS = 3600;
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 更新滑动窗口(保留最近 N 个样本)
*/
public void updateWindow(Long deviceId, Long areaId, Integer rssi, Integer maxSize) {
String key = formatKey(deviceId, areaId);
List<Integer> window = getWindow(deviceId, areaId);
window.add(rssi);
if (window.size() > maxSize) {
window = window.subList(window.size() - maxSize, window.size());
}
String windowStr = window.stream()
.map(String::valueOf)
.collect(Collectors.joining(","));
stringRedisTemplate.opsForValue().set(key, windowStr, WINDOW_TTL_SECONDS, TimeUnit.SECONDS);
}
/**
* 获取窗口样本
*/
public List<Integer> getWindow(Long deviceId, Long areaId) {
String key = formatKey(deviceId, areaId);
String windowStr = stringRedisTemplate.opsForValue().get(key);
if (windowStr == null || windowStr.isEmpty()) {
return new ArrayList<>();
}
return List.of(windowStr.split(","))
.stream()
.map(Integer::parseInt)
.collect(Collectors.toList());
}
/**
* 清除窗口
*/
public void clearWindow(Long deviceId, Long areaId) {
String key = formatKey(deviceId, areaId);
stringRedisTemplate.delete(key);
}
private static String formatKey(Long deviceId, Long areaId) {
return String.format(WINDOW_KEY_PATTERN, deviceId, areaId);
}
}

View File

@@ -0,0 +1,109 @@
package com.viewsh.module.iot.dal.redis.clean;
import jakarta.annotation.Resource;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 轨迹实时状态 Redis DAO
* <p>
* 维护每个设备当前所在区域的实时状态
* <p>
* Key: iot:trajectory:current:{deviceId}
* Type: Hash
* Fields: areaId, enterTime, beaconMac
* TTL: 24小时
*
* @author lzh
*/
@Slf4j
@Repository
public class TrajectoryStateRedisDAO {
private static final String CURRENT_KEY_PATTERN = "iot:trajectory:current:%s";
private static final int CURRENT_TTL_SECONDS = 86400; // 24小时
private static final String FIELD_AREA_ID = "areaId";
private static final String FIELD_ENTER_TIME = "enterTime";
private static final String FIELD_BEACON_MAC = "beaconMac";
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 设置当前所在区域
*/
public void setCurrentArea(Long deviceId, Long areaId, long enterTime, String beaconMac) {
String key = formatKey(deviceId);
// 先删后写,清除旧字段(如上次的 beaconMac保证一致性
stringRedisTemplate.delete(key);
Map<String, String> fields = new HashMap<>();
fields.put(FIELD_AREA_ID, String.valueOf(areaId));
fields.put(FIELD_ENTER_TIME, String.valueOf(enterTime));
if (beaconMac != null) {
fields.put(FIELD_BEACON_MAC, beaconMac);
}
stringRedisTemplate.opsForHash().putAll(key, fields);
stringRedisTemplate.expire(key, CURRENT_TTL_SECONDS, TimeUnit.SECONDS);
}
/**
* 获取当前所在区域信息
*
* @return 当前区域信息,如果不在任何区域则返回 null
*/
public CurrentAreaInfo getCurrentArea(Long deviceId) {
String key = formatKey(deviceId);
Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(key);
if (entries == null || entries.isEmpty()) {
return null;
}
Object areaIdObj = entries.get(FIELD_AREA_ID);
Object enterTimeObj = entries.get(FIELD_ENTER_TIME);
if (areaIdObj == null || enterTimeObj == null) {
return null;
}
CurrentAreaInfo info = new CurrentAreaInfo();
info.setAreaId(Long.parseLong(areaIdObj.toString()));
info.setEnterTime(Long.parseLong(enterTimeObj.toString()));
Object beaconMacObj = entries.get(FIELD_BEACON_MAC);
if (beaconMacObj != null) {
info.setBeaconMac(beaconMacObj.toString());
}
return info;
}
/**
* 清除当前区域状态
*/
public void clearCurrentArea(Long deviceId) {
String key = formatKey(deviceId);
stringRedisTemplate.delete(key);
}
private static String formatKey(Long deviceId) {
return String.format(CURRENT_KEY_PATTERN, deviceId);
}
/**
* 当前区域信息
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class CurrentAreaInfo {
private Long areaId;
private Long enterTime;
private String beaconMac;
}
}

View File

@@ -0,0 +1,51 @@
package com.viewsh.module.iot.service.integration.clean;
import com.viewsh.module.iot.dal.dataobject.integration.clean.BeaconPresenceConfig;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* Beacon 全量注册表服务
* <p>
* 为轨迹检测功能提供 beaconMac → {areaId, config} 的全量映射
* <p>
* 数据来源Ops 模块的 ops_area_device_relation 表relationType=BEACON
* 缓存Redis Hash1小时 TTL启动预热 + 定时刷新
*
* @author lzh
*/
public interface BeaconRegistryService {
/**
* 获取所有已注册的 Beacon 配置
*
* @return beaconMac(大写) → BeaconAreaInfo 的映射
*/
Map<String, BeaconAreaInfo> getAllBeaconConfigs();
/**
* 刷新 Beacon 注册表缓存
*/
void refreshRegistry();
/**
* Beacon 与区域的关联信息
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
class BeaconAreaInfo {
/**
* 区域ID
*/
private Long areaId;
/**
* Beacon 检测配置
*/
private BeaconPresenceConfig config;
}
}

View File

@@ -0,0 +1,165 @@
package com.viewsh.module.iot.service.integration.clean;
import com.viewsh.framework.common.pojo.CommonResult;
import com.viewsh.framework.common.util.json.JsonUtils;
import com.viewsh.module.iot.dal.dataobject.integration.clean.BeaconPresenceConfig;
import com.viewsh.module.iot.dal.dataobject.integration.clean.CleanOrderIntegrationConfig;
import com.viewsh.module.ops.api.area.AreaDeviceApi;
import com.viewsh.module.ops.api.area.AreaDeviceDTO;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* Beacon 全量注册表服务实现
* <p>
* 缓存策略:
* - Redis Hash: iot:trajectory:beacon:registryfield=beaconMacvalue=JSON{areaId,config}
* - TTL: 1小时
* - 启动时预热每30分钟定时刷新
* - 如果 Redis 缓存失效,降级为实时调用 Feign API
*
* @author lzh
*/
@Slf4j
@Service
public class BeaconRegistryServiceImpl implements BeaconRegistryService {
private static final String REGISTRY_KEY = "iot:trajectory:beacon:registry";
private static final int REGISTRY_TTL_SECONDS = 3600; // 1小时
@Resource
private AreaDeviceApi areaDeviceApi;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private CleanOrderIntegrationConfigService configService;
/**
* 启动时预热缓存
*/
@PostConstruct
public void init() {
try {
refreshRegistry();
} catch (Exception e) {
log.warn("[BeaconRegistry] 启动预热失败,将在下次查询时加载", e);
}
}
/**
* 每30分钟刷新一次注册表
*/
@Scheduled(fixedDelay = 30 * 60 * 1000, initialDelay = 30 * 60 * 1000)
public void scheduledRefresh() {
try {
refreshRegistry();
} catch (Exception e) {
log.error("[BeaconRegistry] 定时刷新失败", e);
}
}
@Override
public Map<String, BeaconAreaInfo> getAllBeaconConfigs() {
// 1. 尝试从 Redis 读取
try {
Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(REGISTRY_KEY);
if (entries != null && !entries.isEmpty()) {
Map<String, BeaconAreaInfo> result = new HashMap<>(entries.size());
for (Map.Entry<Object, Object> entry : entries.entrySet()) {
String mac = entry.getKey().toString();
BeaconAreaInfo info = JsonUtils.parseObject(entry.getValue().toString(), BeaconAreaInfo.class);
if (info != null) {
result.put(mac, info);
}
}
log.debug("[BeaconRegistry] 从缓存加载 {} 个 Beacon 配置", result.size());
return result;
}
} catch (Exception e) {
log.warn("[BeaconRegistry] 读取缓存失败,降级为实时查询", e);
}
// 2. 缓存未命中,实时查询并写入缓存
return loadAndCacheRegistry();
}
@Override
public void refreshRegistry() {
loadAndCacheRegistry();
}
/**
* 从 Ops 加载全量 Beacon 注册表并写入 Redis 缓存
*/
private Map<String, BeaconAreaInfo> loadAndCacheRegistry() {
Map<String, BeaconAreaInfo> registry = new HashMap<>();
try {
CommonResult<List<AreaDeviceDTO>> result = areaDeviceApi.getAllEnabledBeacons();
if (result == null || !result.isSuccess() || result.getData() == null) {
log.warn("[BeaconRegistry] 调用 Ops 获取 Beacon 列表失败");
return registry;
}
Map<String, String> redisEntries = new HashMap<>();
for (AreaDeviceDTO dto : result.getData()) {
if (dto.getConfigData() == null || dto.getAreaId() == null) {
continue;
}
// 反序列化配置
CleanOrderIntegrationConfig integrationConfig = configService.convertConfig(dto.getConfigData());
if (integrationConfig == null || integrationConfig.getBeaconPresence() == null) {
continue;
}
BeaconPresenceConfig beaconConfig = integrationConfig.getBeaconPresence();
if (beaconConfig.getEnabled() == null || !beaconConfig.getEnabled()) {
continue;
}
String mac = beaconConfig.getBeaconMac();
if (mac == null || mac.isEmpty()) {
continue;
}
// 统一转大写
mac = mac.toUpperCase();
BeaconAreaInfo info = new BeaconAreaInfo(dto.getAreaId(), beaconConfig);
registry.put(mac, info);
redisEntries.put(mac, JsonUtils.toJsonString(info));
}
// 写入 Redis使用临时 Key + rename 保证原子性,避免竞态空窗期)
if (!redisEntries.isEmpty()) {
String tempKey = REGISTRY_KEY + ":tmp:" + System.currentTimeMillis();
stringRedisTemplate.opsForHash().putAll(tempKey, redisEntries);
stringRedisTemplate.expire(tempKey, REGISTRY_TTL_SECONDS, TimeUnit.SECONDS);
stringRedisTemplate.rename(tempKey, REGISTRY_KEY);
} else {
// 所有 Beacon 都被禁用时,清除旧缓存
stringRedisTemplate.delete(REGISTRY_KEY);
}
log.info("[BeaconRegistry] 加载并缓存 {} 个 Beacon 配置", registry.size());
} catch (Exception e) {
log.error("[BeaconRegistry] 加载 Beacon 注册表失败", e);
}
return registry;
}
}

View File

@@ -112,17 +112,43 @@ public class CleanOrderIntegrationConfigServiceImpl implements CleanOrderIntegra
// 2. 缓存未命中,调用 Ops 模块
CommonResult<List<AreaDeviceDTO>> result = areaDeviceApi.getDevicesByAreaAndType(areaId, relationType);
if (result == null || !result.isSuccess() || result.getData() == null) {
log.warn("[CleanOrderConfig] 调用 Ops 模块获取区域配置失败areaId={}, relationType={}", areaId, relationType);
if (result == null || !result.isSuccess() || result.getData() == null || result.getData().isEmpty()) {
log.warn("[CleanOrderConfig] 调用 Ops 模块获取区域配置为空或失败areaId={}, relationType={}", areaId, relationType);
// 写入防缓存穿透标记 (1分钟)
try {
stringRedisTemplate.opsForValue().set(cacheKey, NULL_CACHE, 1, TimeUnit.MINUTES);
} catch (Exception e) {
log.warn("[CleanOrderConfig] 写入防击穿缓存失败areaId={}, relationType={}", areaId, relationType, e);
}
return null;
}
// 返回第一个启用的配置
return result.getData().stream()
// 3. 返回第一个启用的配置,并主动写入缓存 (模拟 Ops 端的缓存策略24小时)
AreaDeviceDTO validDto = result.getData().stream()
.filter(dto -> dto.getEnabled() != null && dto.getEnabled())
.findFirst()
.map(this::wrapDto)
.orElse(null);
if (validDto != null) {
try {
stringRedisTemplate.opsForValue().set(
cacheKey,
JsonUtils.toJsonString(validDto),
24,
TimeUnit.HOURS);
} catch (Exception e) {
log.warn("[CleanOrderConfig] 写入区域配置缓存失败areaId={}, relationType={}", areaId, relationType, e);
}
return wrapDto(validDto);
} else {
// 没有找到启用的配置,写入 NULL_CACHE
try {
stringRedisTemplate.opsForValue().set(cacheKey, NULL_CACHE, 1, TimeUnit.MINUTES);
} catch (Exception e) {
log.warn("[CleanOrderConfig] 写入防击穿缓存失败areaId={}, relationType={}", areaId, relationType, e);
}
return null;
}
}
@Override

View File

@@ -5,6 +5,7 @@ import com.viewsh.module.iot.core.mq.message.IotDeviceMessage;
import com.viewsh.module.iot.service.rule.clean.processor.BeaconDetectionRuleProcessor;
import com.viewsh.module.iot.service.rule.clean.processor.ButtonEventRuleProcessor;
import com.viewsh.module.iot.service.rule.clean.processor.TrafficThresholdRuleProcessor;
import com.viewsh.module.iot.service.rule.clean.processor.TrajectoryDetectionProcessor;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@@ -33,6 +34,9 @@ public class CleanRuleProcessorManager {
@Resource
private ButtonEventRuleProcessor buttonEventRuleProcessor;
@Resource
private TrajectoryDetectionProcessor trajectoryDetectionProcessor;
/**
* 处理设备消息
* <p>
@@ -70,6 +74,15 @@ public class CleanRuleProcessorManager {
// 属性上报:直接遍历 key-value
data.forEach((identifier, value) ->
processDataSafely(deviceId, identifier, value));
// 4. 蓝牙信号缺失补偿:当设备上报了属性但不含 bluetoothDevices 时,
// 主动注入一次 null 调用,使 BeaconDetectionRuleProcessor 能写入 -999信号缺失
// 避免退出检测窗口因无数据而停滞
if (!data.containsKey("bluetoothDevices")) {
beaconDetectionRuleProcessor.processPropertyChange(deviceId, "bluetoothDevices", null);
// 轨迹检测同样需要信号丢失补偿,注入 null 使窗口写入 -999
trajectoryDetectionProcessor.processPropertyChange(deviceId, "bluetoothDevices", null);
}
}
}
@@ -119,8 +132,11 @@ public class CleanRuleProcessorManager {
switch (identifier) {
case "people_in", "people_out" ->
trafficThresholdRuleProcessor.processPropertyChange(deviceId, identifier, value);
case "bluetoothDevices" ->
beaconDetectionRuleProcessor.processPropertyChange(deviceId, identifier, value);
case "bluetoothDevices" -> {
beaconDetectionRuleProcessor.processPropertyChange(deviceId, identifier, value);
// 轨迹检测:独立于保洁到岗检测,匹配所有已知 Beacon
trajectoryDetectionProcessor.processPropertyChange(deviceId, identifier, value);
}
default -> {
// 其他属性/事件忽略
}

View File

@@ -145,13 +145,16 @@ public class RssiSlidingWindowDetector {
/**
* 从蓝牙设备列表中提取目标信标的 RSSI
* <p>
* 匹配策略:以设备上报数据为准,优先使用 MAC 地址匹配;
* 若 MAC 未配置,降级为 iBeacon 三元组uuid+major+minor匹配
*
* @param bluetoothDevices 蓝牙设备列表
* 格式:[{"rssi":-52,"type":243,"mac":"F0:C8:60:1D:10:BB"},...]
* @param targetMac 目标信标 MAC 地址
* @param beaconConfig 信标配置
* @return RSSI 值,如果未找到返回 -999缺失值
*/
public Integer extractTargetRssi(Object bluetoothDevices, String targetMac) {
public Integer extractTargetRssi(Object bluetoothDevices, BeaconPresenceConfig beaconConfig) {
if (bluetoothDevices == null) {
return -999;
}
@@ -160,17 +163,59 @@ public class RssiSlidingWindowDetector {
@SuppressWarnings("unchecked")
List<java.util.Map<String, Object>> deviceList = (List<java.util.Map<String, Object>>) bluetoothDevices;
return deviceList.stream()
.filter(device -> targetMac.equals(device.get("mac")))
.map(device -> (Integer) device.get("rssi"))
.findFirst()
.orElse(-999);
// 优先使用 MAC 地址匹配(工牌上报的是 MAC
String targetMac = beaconConfig.getBeaconMac();
if (targetMac != null) {
return deviceList.stream()
.filter(device -> targetMac.equalsIgnoreCase((String) device.get("mac")))
.map(device -> toInt(device.get("rssi")))
.findFirst()
.orElse(-999);
}
// 降级为 iBeacon 三元组匹配(适配上报 uuid+major+minor 的设备)
if (beaconConfig.getBeaconUuid() != null && beaconConfig.getMajor() != null
&& beaconConfig.getMinor() != null) {
return deviceList.stream()
.filter(device -> matchIBeacon(device, beaconConfig))
.map(device -> toInt(device.get("rssi")))
.findFirst()
.orElse(-999);
}
log.warn("[RssiDetector] 信标配置缺少 beaconMac 和 iBeacon 三元组,无法匹配");
return -999;
} catch (Exception e) {
log.error("[RssiDetector] 解析蓝牙数据失败:targetMac={}", targetMac, e);
log.error("[RssiDetector] 解析蓝牙数据失败:beaconMac={}", beaconConfig.getBeaconMac(), e);
return -999;
}
}
/**
* 匹配 iBeacon 三元组uuid + major + minor
*/
private boolean matchIBeacon(java.util.Map<String, Object> device, BeaconPresenceConfig config) {
String uuid = (String) device.get("uuid");
Integer major = toInt(device.get("major"));
Integer minor = toInt(device.get("minor"));
return config.getBeaconUuid().equalsIgnoreCase(uuid)
&& config.getMajor().equals(major)
&& config.getMinor().equals(minor);
}
/**
* 安全转换为 Integer
*/
private Integer toInt(Object value) {
if (value instanceof Integer) {
return (Integer) value;
}
if (value instanceof Number) {
return ((Number) value).intValue();
}
return null;
}
/**
* 判断窗口是否有效
* <p>

View File

@@ -120,7 +120,7 @@ public class BeaconDetectionRuleProcessor {
}
// 4. 解析蓝牙数据,提取目标信标的 RSSI
Integer targetRssi = detector.extractTargetRssi(propertyValue, beaconConfig.getBeaconMac());
Integer targetRssi = detector.extractTargetRssi(propertyValue, beaconConfig);
log.debug("[BeaconDetection] 提取RSSIdeviceId={}, areaId={}, beaconMac={}, rssi={}",
deviceId, areaId, beaconConfig.getBeaconMac(), targetRssi);
@@ -226,12 +226,7 @@ public class BeaconDetectionRuleProcessor {
// 首次丢失
signalLossRedisDAO.recordFirstLoss(deviceId, areaId, System.currentTimeMillis());
// 2. 发送警告
publishTtsEvent(deviceId, "你已离开当前区域," +
(exitConfig.getLossTimeoutMinutes() > 0 ? exitConfig.getLossTimeoutMinutes() + "分钟内工单将自动结算"
: "工单将自动结算"));
// 3. 发布审<E5B883><E5AEA1><EFBFBD>日志
// 2. 发布审计日志
Map<String, Object> data = new HashMap<>();
data.put("firstLossTime", System.currentTimeMillis());
data.put("rssi", window.isEmpty() ? -999 : window.get(window.size() - 1));

View File

@@ -74,74 +74,62 @@ public class ButtonEventRuleProcessor {
log.debug("[ButtonEvent] 按键解析成功deviceId={}, buttonId={}", deviceId, buttonId);
// 4. 匹配按键类型并处理
if (buttonId.equals(buttonConfig.getConfirmKeyId())) {
// 确认键
handleConfirmButton(deviceId, buttonId);
} else if (buttonId.equals(buttonConfig.getQueryKeyId())) {
// 查询键
handleQueryButton(deviceId, buttonId);
// 4. 匹配按键类型并处理(确认键和查询键统一路由到同一逻辑)
if (buttonId.equals(buttonConfig.getConfirmKeyId())
|| buttonId.equals(buttonConfig.getQueryKeyId())) {
// 所有已知按键统一走绿色按键逻辑(根据工单状态智能判断行为)
handleGreenButton(deviceId, buttonId);
} else {
log.debug("[ButtonEvent] 未配置的按键deviceId={}, buttonId={}", deviceId, buttonId);
}
}
/**
* 处理确认按键
* 处理绿色按键(统一按键逻辑)
* <p>
* 保洁员按下确认键,确认接收工单
* 根据当前工单状态智能判断行为:
* - 无工单发布查询事件Ops 端播报"没有工单"
* - DISPATCHED发布确认事件触发确认状态转换 + 停止循环 + 播报地点)
* - CONFIRMED/ARRIVED发布查询事件播报地点
* - 其他状态:发布查询事件(兜底处理)
*/
private void handleConfirmButton(Long deviceId, Integer buttonId) {
log.info("[ButtonEvent] 确认键按下deviceId={}, buttonId={}", deviceId, buttonId);
// 1. 查询设备当前工单
BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
if (currentOrder == null) {
log.warn("[ButtonEvent] 设备无当前工单跳过确认deviceId={}", deviceId);
return;
}
Long orderId = currentOrder.getOrderId();
// 2. 防重复检查(短时间内同一工单的确认操作去重)
String dedupKey = String.format("iot:clean:button:dedup:confirm:%s:%s", deviceId, orderId);
Boolean firstTime = stringRedisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", 10, java.util.concurrent.TimeUnit.SECONDS);
if (!firstTime) {
log.info("[ButtonEvent] 确认操作重复跳过deviceId={}, orderId={}", deviceId, orderId);
return;
}
// 3. 发布工单确认事件
publishConfirmEvent(deviceId, orderId, buttonId);
log.info("[ButtonEvent] 发布工单确认事件deviceId={}, orderId={}", deviceId, orderId);
}
/**
* 处理查询按键
* <p>
* 保洁员按下查询键,查询当前工单信息
*/
private void handleQueryButton(Long deviceId, Integer buttonId) {
log.info("[ButtonEvent] 查询键按下deviceId={}, buttonId={}", deviceId, buttonId);
private void handleGreenButton(Long deviceId, Integer buttonId) {
log.info("[ButtonEvent] 绿色按键按下deviceId={}, buttonId={}", deviceId, buttonId);
// 1. 查询设备当前工单
BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
if (currentOrder == null) {
// 无工单 → 发布查询事件Ops 端播报"没有工单"
log.info("[ButtonEvent] 设备无当前工单deviceId={}", deviceId);
// 发布查询结果事件(无工单)
publishQueryEvent(deviceId, null, buttonId, "当前无工单");
return;
}
// 2. 发布查询事件
publishQueryEvent(deviceId, currentOrder.getOrderId(), buttonId, "查询当前工单");
Long orderId = currentOrder.getOrderId();
String orderStatus = currentOrder.getStatus();
log.info("[ButtonEvent] 发布工单查询事件deviceId={}, orderId={}", deviceId, currentOrder.getOrderId());
// 2. 根据工单状态智能分派
if ("DISPATCHED".equals(orderStatus)) {
// DISPATCHED → 发布确认事件(触发确认 + 停止循环 + 播报地点)
// 防重复检查
String dedupKey = String.format("iot:clean:button:dedup:confirm:%s:%s", deviceId, orderId);
Boolean firstTime = stringRedisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", 10, java.util.concurrent.TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(firstTime)) {
// 重复确认不再静默,改为发查询事件给保洁员反馈(播报地点)
log.info("[ButtonEvent] 确认操作重复转为查询deviceId={}, orderId={}", deviceId, orderId);
publishQueryEvent(deviceId, orderId, buttonId, "重复确认,查询当前工单");
return;
}
publishConfirmEvent(deviceId, orderId, buttonId);
log.info("[ButtonEvent] DISPATCHED状态发布确认事件deviceId={}, orderId={}", deviceId, orderId);
} else {
// CONFIRMED / ARRIVED / 其他状态 → 发布查询事件(播报地点)
publishQueryEvent(deviceId, orderId, buttonId, "查询当前工单");
log.info("[ButtonEvent] {}状态发布查询事件deviceId={}, orderId={}", orderStatus, deviceId, orderId);
}
}
/**

View File

@@ -191,15 +191,16 @@ public class SignalLossRuleProcessor {
long minValidWorkMillis = exitConfig.getMinValidWorkMinutes() * 60000L;
// 6. 分支处理:有效 vs 无效作业
if (durationMs < minValidWorkMillis) {
// 作业时长不足,抑制完成
handleInvalidWork(deviceId, badgeDeviceKey, areaId,
durationMs, minValidWorkMillis, exitConfig);
} else {
// 作业时长有效,触发完成
handleTimeoutComplete(deviceId, badgeDeviceKey, areaId,
durationMs, lastLossTime);
}
// TODO 暂时取消作业时长不足抑制自动完成的逻辑,所有情况均触发完成
// if (durationMs < minValidWorkMillis) {
// // 作业时长不足,抑制完成
// handleInvalidWork(deviceId, badgeDeviceKey, areaId,
// durationMs, minValidWorkMillis, exitConfig);
// } else {
// 作业时长有效,触发完成
handleTimeoutComplete(deviceId, badgeDeviceKey, areaId,
durationMs, lastLossTime);
// }
}
/**

View File

@@ -47,12 +47,16 @@ public class TrafficThresholdRuleProcessor {
* 处理客流属性上报
* <p>
* 支持 people_in 和 people_out 两个属性:
* - people_in累加到阈值计数器 + 当日统计
* - people_out累加到当日统计
* - people_in累加到当日统计 + 阈值计数器(需配置)
* - people_out累加到当日统计
* <p>
* 支持两种上报模式(通过 configData.trafficThreshold.reportMode 配置):
* - INCREMENTAL默认上报值直接作为增量
* - CUMULATIVE上报值为累计值自动计算差值得到增量
*
* @param deviceId 设备ID
* @param identifier 属性标识符people_in 或 people_out
* @param propertyValue 属性值(周期内增量)
* @param propertyValue 属性值(增量或累计值,取决于 reportMode
*/
public void processPropertyChange(Long deviceId, String identifier, Object propertyValue) {
// 1. 校验属性类型
@@ -63,56 +67,106 @@ public class TrafficThresholdRuleProcessor {
log.debug("[TrafficThreshold] 收到客流属性deviceId={}, identifier={}, value={}",
deviceId, identifier, propertyValue);
// 2. 获取设备关联信息(包含 areaId
CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper configWrapper =
getConfigWrapper(deviceId);
if (configWrapper == null || configWrapper.getConfig() == null) {
log.debug("[TrafficThreshold] 设备无配置deviceId={}", deviceId);
// 2. 解析原始值
Long rawValue = parseTrafficCount(propertyValue);
if (rawValue == null || rawValue <= 0) {
return;
}
TrafficThresholdConfig thresholdConfig = configWrapper.getConfig().getTrafficThreshold();
if (thresholdConfig == null || !thresholdConfig.getAutoCreateOrder()) {
log.debug("[TrafficThreshold] 未启用客流阈值触发deviceId={}", deviceId);
// 3. 获取配置,判断上报模式
CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper configWrapper = getConfigWrapper(deviceId);
TrafficThresholdConfig thresholdConfig = resolveThresholdConfig(configWrapper);
// 4. 根据上报模式计算增量
long increment;
if (thresholdConfig != null && thresholdConfig.isCumulative()) {
increment = resolveIncrement(deviceId, identifier, rawValue);
} else {
increment = rawValue;
}
if (increment <= 0) {
return;
}
// 3. 解析增量值
Long increment = parseTrafficCount(propertyValue);
if (increment == null || increment <= 0) {
log.debug("[TrafficThreshold] 增量值无效deviceId={}, identifier={}, value={}",
deviceId, identifier, propertyValue);
// 5. 累加到当日统计(统计与工单触发解耦)
LocalDate today = LocalDate.now();
if ("people_in".equals(identifier)) {
trafficCounterRedisDAO.incrementDaily(deviceId, today, increment, 0);
} else {
trafficCounterRedisDAO.incrementDaily(deviceId, today, 0, increment);
}
log.debug("[TrafficThreshold] 当日统计累加deviceId={}, identifier={}, increment={}",
deviceId, identifier, increment);
// 6. 以下为工单触发逻辑,仅 people_in 参与
if (!"people_in".equals(identifier)) {
return;
}
if (thresholdConfig == null || !Boolean.TRUE.equals(thresholdConfig.getAutoCreateOrder())) {
return;
}
Long areaId = configWrapper.getAreaId();
LocalDate today = LocalDate.now();
handlePeopleIn(deviceId, areaId, increment, today, thresholdConfig, configWrapper);
}
// 4. 根据属性类型分别处理
if ("people_in".equals(identifier)) {
handlePeopleIn(deviceId, areaId, increment, today, thresholdConfig, configWrapper);
} else {
// people_out仅累加到当日统计
trafficCounterRedisDAO.incrementDaily(deviceId, today, 0, increment);
log.debug("[TrafficThreshold] people_out 累加到当日统计deviceId={}, areaId={}, increment={}",
deviceId, areaId, increment);
/**
* 从配置包装器中提取客流阈值配置
*
* @return 阈值配置,无配置时返回 null
*/
private TrafficThresholdConfig resolveThresholdConfig(
CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper configWrapper) {
if (configWrapper == null || configWrapper.getConfig() == null) {
return null;
}
return configWrapper.getConfig().getTrafficThreshold();
}
/**
* 累计值转增量
* <p>
* 通过 Redis 存储上次上报的累计值,计算差值得到本次增量。
* 处理三种场景:首次上报、正常递增、设备重启归零。
*
* @param deviceId 设备ID
* @param identifier 属性标识符
* @param currentValue 本次上报的累计值
* @return 增量值;首次上报返回 0
*/
private long resolveIncrement(Long deviceId, String identifier, long currentValue) {
Long lastValue = trafficCounterRedisDAO.getLastCumulativeValue(deviceId, identifier);
// 无论是否能算出增量,都记录当前值
trafficCounterRedisDAO.setLastCumulativeValue(deviceId, identifier, currentValue);
if (lastValue == null) {
// 首次上报:无历史基准,不计入统计
log.info("[TrafficThreshold] 累计值设备首次上报建立基准deviceId={}, identifier={}, value={}",
deviceId, identifier, currentValue);
return 0;
}
if (currentValue >= lastValue) {
return currentValue - lastValue;
}
// currentValue < lastValue → 设备重启归零
log.info("[TrafficThreshold] 检测到设备重启deviceId={}, identifier={}, last={}, current={}",
deviceId, identifier, lastValue, currentValue);
return currentValue;
}
/**
* 处理 people_in 增量
*/
private void handlePeopleIn(Long deviceId, Long areaId, Long increment, LocalDate today,
private void handlePeopleIn(Long deviceId, Long areaId, long increment, LocalDate today,
TrafficThresholdConfig thresholdConfig,
CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper configWrapper) {
// 1. 原子累加到阈值计数器,返回累积值
// 1. 原子累加到阈值计数器,返回累积值(当日统计已在 processPropertyChange 中完成)
Long accumulated = trafficCounterRedisDAO.incrementThreshold(deviceId, areaId, increment);
// 2. 原子累加到当日统计
trafficCounterRedisDAO.incrementDaily(deviceId, today, increment, 0);
log.debug("[TrafficThreshold] people_in 累加deviceId={}, areaId={}, increment={}, accumulated={}, threshold={}",
log.debug("[TrafficThreshold] people_in 阈值累加deviceId={}, areaId={}, increment={}, accumulated={}, threshold={}",
deviceId, areaId, increment, accumulated, thresholdConfig.getThreshold());
// 3. 阈值判定

View File

@@ -0,0 +1,432 @@
package com.viewsh.module.iot.service.rule.clean.processor;
import com.viewsh.framework.common.util.json.JsonUtils;
import com.viewsh.module.iot.core.integration.constants.TrajectoryTopics;
import com.viewsh.module.iot.core.integration.event.trajectory.TrajectoryEnterEvent;
import com.viewsh.module.iot.core.integration.event.trajectory.TrajectoryLeaveEvent;
import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO;
import com.viewsh.module.iot.dal.dataobject.integration.clean.BeaconPresenceConfig;
import com.viewsh.module.iot.dal.dataobject.integration.clean.TrajectoryTrackingConfig;
import com.viewsh.module.iot.dal.redis.clean.TrajectoryRssiWindowRedisDAO;
import com.viewsh.module.iot.dal.redis.clean.TrajectoryStateRedisDAO;
import com.viewsh.module.iot.service.device.IotDeviceService;
import com.viewsh.module.iot.service.integration.clean.BeaconRegistryService;
import com.viewsh.module.iot.service.integration.clean.BeaconRegistryService.BeaconAreaInfo;
import com.viewsh.module.iot.service.rule.clean.detector.RssiSlidingWindowDetector;
import com.viewsh.module.iot.service.rule.clean.detector.RssiSlidingWindowDetector.AreaState;
import com.viewsh.module.iot.service.rule.clean.detector.RssiSlidingWindowDetector.DetectionResult;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
import com.viewsh.framework.tenant.core.context.TenantContextHolder;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* 轨迹检测处理器
* <p>
* 工牌每次上报蓝牙数据时,匹配系统中所有已知 Beacon
* 基于"强进弱出"双阈值算法判断进出区域,自动记录轨迹。
* <p>
* 同一时刻只认一个区域,复用现有 {@link RssiSlidingWindowDetector}。
* <p>
* 核心流程:
* <ol>
* <li>检查设备是否开启轨迹功能</li>
* <li>获取全量 Beacon 注册表</li>
* <li>提取蓝牙列表中所有已知 Beacon 的 RSSI</li>
* <li>更新各区域的 RSSI 滑动窗口</li>
* <li>检测当前区域退出 / 新区域进入</li>
* <li>发布进入/离开事件到 RocketMQ</li>
* </ol>
*
* @author lzh
*/
@Component
@Slf4j
public class TrajectoryDetectionProcessor {
/**
* 设备轨迹功能开关缓存 Key
*/
private static final String DEVICE_ENABLED_KEY_PATTERN = "iot:trajectory:device:enabled:%s";
private static final int DEVICE_ENABLED_TTL_SECONDS = 3600; // 1小时
@Resource
private BeaconRegistryService beaconRegistryService;
@Resource
private TrajectoryRssiWindowRedisDAO windowRedisDAO;
@Resource
private TrajectoryStateRedisDAO stateRedisDAO;
@Resource
private RssiSlidingWindowDetector detector;
@Resource
private IotDeviceService deviceService;
@Resource
private RocketMQTemplate rocketMQTemplate;
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 处理蓝牙属性上报
*
* @param deviceId 设备ID工牌
* @param identifier 属性标识符
* @param propertyValue 蓝牙设备列表
*/
public void processPropertyChange(Long deviceId, String identifier, Object propertyValue) {
if (!"bluetoothDevices".equals(identifier)) {
return;
}
// 1. 检查设备是否开启轨迹功能
if (!isTrajectoryEnabled(deviceId)) {
return;
}
// 2. 信号丢失补偿快速路径propertyValue 为 null 且设备不在任何区域时,直接跳过
// 避免每次无蓝牙数据的属性上报都查询 Beacon 注册表
if (propertyValue == null && stateRedisDAO.getCurrentArea(deviceId) == null) {
return;
}
// 3. 获取 Beacon 注册表
Map<String, BeaconAreaInfo> beaconRegistry = beaconRegistryService.getAllBeaconConfigs();
if (beaconRegistry == null || beaconRegistry.isEmpty()) {
log.debug("[Trajectory] Beacon 注册表为空跳过检测deviceId={}", deviceId);
return;
}
// 4. 构建 areaId → config 索引(避免后续 O(n) 扫描)
Map<Long, BeaconPresenceConfig> areaConfigIndex = new HashMap<>();
for (BeaconAreaInfo info : beaconRegistry.values()) {
areaConfigIndex.putIfAbsent(info.getAreaId(), info.getConfig());
}
// 5. 提取蓝牙列表中所有已知 Beacon 的 RSSI
Map<Long, MatchedBeacon> matchedBeacons = extractMatchedBeacons(propertyValue, beaconRegistry);
// 6. 获取当前所在区域
TrajectoryStateRedisDAO.CurrentAreaInfo currentArea = stateRedisDAO.getCurrentArea(deviceId);
// 7. 对所有匹配区域更新滑动窗口
updateAllWindows(deviceId, matchedBeacons, areaConfigIndex, currentArea);
// 8. 处理区域状态变化
if (currentArea != null) {
processWithCurrentArea(deviceId, currentArea, matchedBeacons, areaConfigIndex);
} else {
processWithoutCurrentArea(deviceId, matchedBeacons, areaConfigIndex);
}
}
// ==================== 核心检测逻辑 ====================
/**
* 当前在某区域时的处理逻辑
*/
private void processWithCurrentArea(Long deviceId,
TrajectoryStateRedisDAO.CurrentAreaInfo currentArea,
Map<Long, MatchedBeacon> matchedBeacons,
Map<Long, BeaconPresenceConfig> areaConfigIndex) {
Long currentAreaId = currentArea.getAreaId();
// 6a. 检查当前区域的退出条件
BeaconPresenceConfig currentConfig = areaConfigIndex.get(currentAreaId);
if (currentConfig != null) {
List<Integer> window = windowRedisDAO.getWindow(deviceId, currentAreaId);
DetectionResult exitResult = detector.detect(
window,
currentConfig.getEnter(),
currentConfig.getExit(),
AreaState.IN_AREA);
if (exitResult == DetectionResult.LEAVE_CONFIRMED) {
// 确认离开当前区域
publishLeaveEvent(deviceId, currentAreaId, currentArea.getBeaconMac(),
"SIGNAL_LOSS", currentArea.getEnterTime());
stateRedisDAO.clearCurrentArea(deviceId);
windowRedisDAO.clearWindow(deviceId, currentAreaId);
log.info("[Trajectory] 离开区域deviceId={}, areaId={}, reason=SIGNAL_LOSS", deviceId, currentAreaId);
// 离开后,尝试进入新区域
processWithoutCurrentArea(deviceId, matchedBeacons, areaConfigIndex);
return;
}
}
// 6b. 当前区域未退出,检查是否有更强区域触发切换
MatchedBeacon bestCandidate = findBestEnterCandidate(deviceId, matchedBeacons, currentAreaId);
if (bestCandidate != null && !bestCandidate.areaId.equals(currentAreaId)) {
// 区域切换:先离开当前区域,再进入新区域
publishLeaveEvent(deviceId, currentAreaId, currentArea.getBeaconMac(),
"AREA_SWITCH", currentArea.getEnterTime());
windowRedisDAO.clearWindow(deviceId, currentAreaId);
long now = System.currentTimeMillis();
stateRedisDAO.setCurrentArea(deviceId, bestCandidate.areaId, now, bestCandidate.beaconMac);
windowRedisDAO.clearWindow(deviceId, bestCandidate.areaId);
publishEnterEvent(deviceId, bestCandidate.areaId, bestCandidate.beaconMac, bestCandidate.rssi);
log.info("[Trajectory] 区域切换deviceId={}, from={}, to={}", deviceId, currentAreaId, bestCandidate.areaId);
}
}
/**
* 当前不在任何区域时的处理逻辑
*/
private void processWithoutCurrentArea(Long deviceId,
Map<Long, MatchedBeacon> matchedBeacons,
Map<Long, BeaconPresenceConfig> areaConfigIndex) {
MatchedBeacon bestCandidate = findBestEnterCandidate(deviceId, matchedBeacons, null);
if (bestCandidate != null) {
long now = System.currentTimeMillis();
stateRedisDAO.setCurrentArea(deviceId, bestCandidate.areaId, now, bestCandidate.beaconMac);
windowRedisDAO.clearWindow(deviceId, bestCandidate.areaId);
publishEnterEvent(deviceId, bestCandidate.areaId, bestCandidate.beaconMac, bestCandidate.rssi);
log.info("[Trajectory] 进入区域deviceId={}, areaId={}, rssi={}", deviceId, bestCandidate.areaId, bestCandidate.rssi);
}
}
// ==================== 辅助方法 ====================
/**
* 从蓝牙列表中提取所有已知 Beacon 的 RSSI
* 同一区域如果有多个 Beacon 匹配,取 RSSI 最强的
*/
private Map<Long, MatchedBeacon> extractMatchedBeacons(Object bluetoothDevices,
Map<String, BeaconAreaInfo> beaconRegistry) {
Map<Long, MatchedBeacon> result = new HashMap<>();
if (bluetoothDevices == null) {
return result;
}
try {
if (!(bluetoothDevices instanceof List)) {
log.warn("[Trajectory] bluetoothDevices 类型不正确: {}", bluetoothDevices.getClass().getName());
return result;
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> deviceList = (List<Map<String, Object>>) bluetoothDevices;
for (Map<String, Object> device : deviceList) {
String mac = (String) device.get("mac");
if (mac == null) {
continue;
}
mac = mac.toUpperCase();
BeaconAreaInfo info = beaconRegistry.get(mac);
if (info == null) {
continue;
}
Integer rssi = toInt(device.get("rssi"));
if (rssi == null) {
continue;
}
Long areaId = info.getAreaId();
MatchedBeacon existing = result.get(areaId);
if (existing == null || rssi > existing.rssi) {
result.put(areaId, new MatchedBeacon(areaId, mac, rssi, info.getConfig()));
}
}
} catch (Exception e) {
log.error("[Trajectory] 解析蓝牙数据失败", e);
}
return result;
}
/**
* 更新所有匹配区域的滑动窗口
* 对于当前所在但未匹配到的区域,注入 -999信号缺失
*/
private void updateAllWindows(Long deviceId,
Map<Long, MatchedBeacon> matchedBeacons,
Map<Long, BeaconPresenceConfig> areaConfigIndex,
TrajectoryStateRedisDAO.CurrentAreaInfo currentArea) {
// 更新匹配到的区域
for (Map.Entry<Long, MatchedBeacon> entry : matchedBeacons.entrySet()) {
MatchedBeacon mb = entry.getValue();
int maxWindowSize = Math.max(
mb.config.getEnter().getWindowSize(),
mb.config.getExit().getWindowSize());
windowRedisDAO.updateWindow(deviceId, mb.areaId, mb.rssi, maxWindowSize);
}
// 当前区域未匹配到 Beacon 时,注入 -999 补偿
if (currentArea != null && !matchedBeacons.containsKey(currentArea.getAreaId())) {
BeaconPresenceConfig currentConfig = areaConfigIndex.get(currentArea.getAreaId());
if (currentConfig != null) {
int maxWindowSize = Math.max(
currentConfig.getEnter().getWindowSize(),
currentConfig.getExit().getWindowSize());
windowRedisDAO.updateWindow(deviceId, currentArea.getAreaId(), -999, maxWindowSize);
}
}
}
/**
* 找到信号最强且满足进入条件的候选区域
*
* @param excludeAreaId 排除的区域ID当前所在区域null 表示不排除
*/
private MatchedBeacon findBestEnterCandidate(Long deviceId,
Map<Long, MatchedBeacon> matchedBeacons,
Long excludeAreaId) {
MatchedBeacon best = null;
for (Map.Entry<Long, MatchedBeacon> entry : matchedBeacons.entrySet()) {
Long areaId = entry.getKey();
if (areaId.equals(excludeAreaId)) {
continue;
}
MatchedBeacon mb = entry.getValue();
List<Integer> window = windowRedisDAO.getWindow(deviceId, areaId);
DetectionResult result = detector.detect(
window,
mb.config.getEnter(),
mb.config.getExit(),
AreaState.OUT_AREA);
if (result == DetectionResult.ARRIVE_CONFIRMED) {
if (best == null || mb.rssi > best.rssi) {
best = mb;
}
}
}
return best;
}
// ==================== 功能开关 ====================
/**
* 检查设备是否开启轨迹功能
* 结果缓存1小时
*/
private boolean isTrajectoryEnabled(Long deviceId) {
String cacheKey = String.format(DEVICE_ENABLED_KEY_PATTERN, deviceId);
// 先读缓存
try {
String cached = stringRedisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return "true".equals(cached);
}
} catch (Exception e) {
log.warn("[Trajectory] 读取功能开关缓存失败deviceId={}", deviceId, e);
}
// 缓存未命中,从设备配置读取
boolean enabled = false;
try {
IotDeviceDO device = deviceService.getDeviceFromCache(deviceId);
if (device != null && device.getConfig() != null) {
@SuppressWarnings("unchecked")
Map<String, Object> configMap = JsonUtils.parseObject(device.getConfig(), Map.class);
if (configMap != null && configMap.containsKey("trajectoryTracking")) {
Object ttObj = configMap.get("trajectoryTracking");
TrajectoryTrackingConfig ttConfig = JsonUtils.parseObject(
JsonUtils.toJsonString(ttObj), TrajectoryTrackingConfig.class);
enabled = ttConfig != null && Boolean.TRUE.equals(ttConfig.getEnabled());
}
}
} catch (Exception e) {
log.error("[Trajectory] 获取轨迹配置失败deviceId={}", deviceId, e);
}
// 写入缓存
try {
stringRedisTemplate.opsForValue().set(cacheKey, String.valueOf(enabled),
DEVICE_ENABLED_TTL_SECONDS, TimeUnit.SECONDS);
} catch (Exception e) {
log.warn("[Trajectory] 写入功能开关缓存失败deviceId={}", deviceId, e);
}
return enabled;
}
// ==================== 事件发布 ====================
private void publishEnterEvent(Long deviceId, Long areaId, String beaconMac, Integer enterRssi) {
try {
IotDeviceDO device = deviceService.getDeviceFromCache(deviceId);
TrajectoryEnterEvent event = TrajectoryEnterEvent.builder()
.deviceId(deviceId)
.deviceName(device != null ? device.getDeviceName() : null)
.nickname(device != null ? device.getNickname() : null)
.areaId(areaId)
.beaconMac(beaconMac)
.enterRssi(enterRssi)
.tenantId(TenantContextHolder.getTenantId())
.build();
rocketMQTemplate.syncSend(TrajectoryTopics.TRAJECTORY_ENTER,
MessageBuilder.withPayload(event).build());
log.info("[Trajectory] 发布进入事件eventId={}, deviceId={}, areaId={}",
event.getEventId(), deviceId, areaId);
} catch (Exception e) {
log.error("[Trajectory] 发布进入事件失败deviceId={}, areaId={}", deviceId, areaId, e);
}
}
private void publishLeaveEvent(Long deviceId, Long areaId, String beaconMac,
String leaveReason, Long enterTimestamp) {
try {
IotDeviceDO device = deviceService.getDeviceFromCache(deviceId);
TrajectoryLeaveEvent event = TrajectoryLeaveEvent.builder()
.deviceId(deviceId)
.deviceName(device != null ? device.getDeviceName() : null)
.nickname(device != null ? device.getNickname() : null)
.areaId(areaId)
.beaconMac(beaconMac)
.leaveReason(leaveReason)
.enterTimestamp(enterTimestamp)
.tenantId(TenantContextHolder.getTenantId())
.build();
rocketMQTemplate.syncSend(TrajectoryTopics.TRAJECTORY_LEAVE,
MessageBuilder.withPayload(event).build());
log.info("[Trajectory] 发布离开事件eventId={}, deviceId={}, areaId={}, reason={}",
event.getEventId(), deviceId, areaId, leaveReason);
} catch (Exception e) {
log.error("[Trajectory] 发布离开事件失败deviceId={}, areaId={}", deviceId, areaId, e);
}
}
private Integer toInt(Object value) {
if (value instanceof Integer) {
return (Integer) value;
}
if (value instanceof Number) {
return ((Number) value).intValue();
}
return null;
}
/**
* 匹配到的 Beacon 信息
*/
private record MatchedBeacon(Long areaId, String beaconMac, Integer rssi, BeaconPresenceConfig config) {
}
}

View File

@@ -3,7 +3,7 @@
spring:
cloud:
nacos:
server-addr: 124.221.55.225:8848 # Nacos 服务器地址
server-addr: 124.222.218.198:8848 # Nacos 服务器地址
username: nacos # Nacos 账号
password: 9oDxX~}e7DeP # Nacos 密码
discovery: # 【配置中心】配置项
@@ -62,17 +62,17 @@ spring:
master:
# 默认使用服务器公网 IP (方便本地开发直连)
# CI/CD 部署时会自动注入环境变量 MYSQL_HOST=aiot-mysql 切换为内网连接
url: jdbc:mysql://${MYSQL_HOST:124.221.55.225}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:aiot-platform}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true
url: jdbc:mysql://${MYSQL_HOST:124.222.218.198}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:aiot-platform}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:65p^VTPi9Qd+}
slave: # 模拟从库,可根据自己需要修改
lazy: true # 开启懒加载,保证启动速度
url: jdbc:mysql://${MYSQL_HOST:124.221.55.225}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:aiot-platform}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&nullCatalogMeansCurrent=true
url: jdbc:mysql://${MYSQL_HOST:124.222.218.198}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:aiot-platform}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&nullCatalogMeansCurrent=true
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:65p^VTPi9Qd+}
tdengine: # IoT 数据库(需要 IoT 物联网再开启噢!)
# 本地开发走公网 IP部署环境走内网容器名
url: jdbc:TAOS-WS://${TDENGINE_HOST:124.221.55.225}:${TDENGINE_PORT:6041}/${TDENGINE_DATABASE:aiot_platform}
url: jdbc:TAOS-WS://${TDENGINE_HOST:124.222.218.198}:${TDENGINE_PORT:6041}/${TDENGINE_DATABASE:aiot_platform}
driver-class-name: com.taosdata.jdbc.ws.WebSocketDriver
username: ${TDENGINE_USERNAME:root}
password: ${TDENGINE_PASSWORD:taosdata}

View File

@@ -3,7 +3,7 @@
spring:
cloud:
nacos:
server-addr: 124.221.55.225:8848 # Nacos 服务器地址
server-addr: 124.222.218.198:8848 # Nacos 服务器地址
username: nacos # Nacos 账号
password: 9oDxX~}e7DeP # Nacos 密码
discovery: # 【配置中心】配置项
@@ -62,17 +62,17 @@ spring:
master:
# 默认使用服务器公网 IP (方便本地开发直连)
# CI/CD 部署时会自动注入环境变量 MYSQL_HOST=aiot-mysql 切换为内网连接
url: jdbc:mysql://${MYSQL_HOST:124.221.55.225}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:aiot-platform}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true
url: jdbc:mysql://${MYSQL_HOST:124.222.218.198}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:aiot-platform}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:65p^VTPi9Qd+}
slave: # 模拟从库,可根据自己需要修改
lazy: true # 开启懒加载,保证启动速度
url: jdbc:mysql://${MYSQL_HOST:124.221.55.225}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:aiot-platform}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&nullCatalogMeansCurrent=true
url: jdbc:mysql://${MYSQL_HOST:124.222.218.198}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:aiot-platform}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&nullCatalogMeansCurrent=true
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:65p^VTPi9Qd+}
tdengine: # IoT 数据库(需要 IoT 物联网再开启噢!)
# 本地开发走公网 IP部署环境走内网容器名
url: jdbc:TAOS-WS://${TDENGINE_HOST:124.221.55.225}:${TDENGINE_PORT:6041}/${TDENGINE_DATABASE:aiot_platform}
url: jdbc:TAOS-WS://${TDENGINE_HOST:124.222.218.198}:${TDENGINE_PORT:6041}/${TDENGINE_DATABASE:aiot_platform}
driver-class-name: com.taosdata.jdbc.ws.WebSocketDriver
username: ${TDENGINE_USERNAME:root}
password: ${TDENGINE_PASSWORD:taosdata}

View File

@@ -1,156 +1,166 @@
--- #################### 注册中心 + 配置中心相关配置 ####################
spring:
cloud:
nacos:
server-addr: ${NACOS_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
namespace: ${NACOS_DISCOVERY_NAMESPACE:prod}
group: DEFAULT_GROUP
metadata:
version: 1.0.0
config:
namespace: ${NACOS_CONFIG_NAMESPACE:prod}
group: DEFAULT_GROUP
file-extension: yaml
refresh-enabled: true
--- #################### 数据库相关配置 ####################
spring:
datasource:
druid:
web-stat-filter:
enabled: true
stat-view-servlet:
enabled: true
url-pattern: /druid/*
login-username: ${DRUID_USERNAME:admin}
login-password: ${DRUID_PASSWORD:admin}
filter:
stat:
enabled: true
log-slow-sql: true
slow-sql-millis: 100
merge-sql: true
wall:
config:
multi-statement-allow: true
dynamic:
druid:
initial-size: 5
min-idle: 10
max-active: 20
max-wait: 60000
time-between-eviction-runs-millis: 60000
min-evictable-idle-time-millis: 600000
max-evictable-idle-time-millis: 1800000
validation-query: SELECT 1 FROM DUAL
test-while-idle: true
test-on-borrow: false
test-on-return: false
pool-prepared-statements: true
max-pool-prepared-statement-per-connection-size: 20
primary: master
datasource:
master:
url: jdbc:mysql://${MYSQL_HOST:127.0.0.1}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:aiot_platform}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true
username: ${MYSQL_USER:root}
password: ${MYSQL_PASSWORD:}
slave:
lazy: true
url: jdbc:mysql://${MYSQL_SLAVE_HOST:${MYSQL_HOST:127.0.0.1}}:${MYSQL_SLAVE_PORT:${MYSQL_PORT:3306}}/${MYSQL_DATABASE:aiot_platform}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true
username: ${MYSQL_SLAVE_USER:${MYSQL_USER:root}}
password: ${MYSQL_SLAVE_PASSWORD:${MYSQL_PASSWORD:}}
tdengine:
url: jdbc:TAOS-RS://${TDENGINE_HOST:172.17.16.14}:${TDENGINE_PORT:6041}/aiot_platform
driver-class-name: com.taosdata.jdbc.rs.RestfulDriver
username: ${TDENGINE_USERNAME:root}
password: ${TDENGINE_PASSWORD:taosdata}
druid:
validation-query: SELECT SERVER_STATUS()
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
database: ${REDIS_DATABASE:0}
password: ${REDIS_PASSWORD:}
timeout: 5000ms
lettuce:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0
--- #################### MQ 消息队列相关配置 ####################
rocketmq:
name-server: ${ROCKETMQ_NAMESRV_ADDR:127.0.0.1:9876}
spring:
# 禁用 RabbitMQ 自动配置(如果不需要 RabbitMQ避免启动时连接失败
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration
# RabbitMQ 配置(已禁用自动配置,仅保留配置项供业务代码使用)
# rabbitmq:
# host: ${RABBITMQ_HOST:127.0.0.1}
# port: ${RABBITMQ_PORT:5672}
# username: ${RABBITMQ_USERNAME:guest}
# password: ${RABBITMQ_PASSWORD:guest}
kafka:
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:127.0.0.1:9092}
--- #################### 定时任务相关配置 ####################
xxl:
job:
admin:
addresses: ${XXL_JOB_ADMIN_ADDRESSES:http://172.17.16.14:19090/xxl-job-admin}
--- #################### 服务保障相关配置 ####################
lock4j:
acquire-timeout: 3000
expire: 30000
--- #################### 监控相关配置 ####################
management:
endpoints:
web:
base-path: /actuator
exposure:
include: '*'
spring:
boot:
admin:
client:
instance:
service-host-type: IP
username: ${SPRING_BOOT_ADMIN_USERNAME:admin}
password: ${SPRING_BOOT_ADMIN_PASSWORD:admin}
logging:
level:
root: INFO
com.viewsh: ${LOG_LEVEL:INFO}
com.viewsh.module.iot.dal.mysql: debug
com.viewsh.module.iot.dal.mysql.sms.SmsChannelMapper: INFO
org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR
file:
name: ${LOG_FILE_PATH:/app/logs}/${spring.application.name}.log
--- #################### 芋道相关配置 ####################
viewsh:
demo: false
env:
tag: ${HOSTNAME:prod}
captcha:
enable: true
security:
mock-enable: false
--- #################### 注册中心 + 配置中心相关配置 ####################
spring:
cloud:
nacos:
server-addr: ${NACOS_ADDR:172.17.16.7:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
namespace: ${NACOS_DISCOVERY_NAMESPACE:prod}
group: DEFAULT_GROUP
metadata:
version: 1.0.0
config:
namespace: ${NACOS_CONFIG_NAMESPACE:prod}
group: DEFAULT_GROUP
file-extension: yaml
refresh-enabled: true
--- #################### 数据库相关配置 ####################
spring:
datasource:
druid:
web-stat-filter:
enabled: true
stat-view-servlet:
enabled: true
url-pattern: /druid/*
login-username: ${DRUID_USERNAME:admin}
login-password: ${DRUID_PASSWORD:admin}
filter:
stat:
enabled: true
log-slow-sql: true
slow-sql-millis: 100
merge-sql: true
wall:
config:
multi-statement-allow: true
dynamic:
druid:
initial-size: 5
min-idle: 10
max-active: 20
max-wait: 60000
time-between-eviction-runs-millis: 60000
min-evictable-idle-time-millis: 600000
max-evictable-idle-time-millis: 1800000
validation-query: SELECT 1 FROM DUAL
test-while-idle: true
test-on-borrow: false
test-on-return: false
pool-prepared-statements: true
max-pool-prepared-statement-per-connection-size: 20
primary: master
datasource:
master:
url: jdbc:mysql://${MYSQL_HOST:172.17.16.8}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:aiot-platform-test}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true
username: ${MYSQL_USER:root}
password: ${MYSQL_PASSWORD:}
slave:
lazy: true
url: jdbc:mysql://${MYSQL_SLAVE_HOST:${MYSQL_HOST:172.17.16.8}}:${MYSQL_SLAVE_PORT:${MYSQL_PORT:3306}}/${MYSQL_DATABASE:aiot-platform-test}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true
username: ${MYSQL_SLAVE_USER:${MYSQL_USER:root}}
password: ${MYSQL_SLAVE_PASSWORD:${MYSQL_PASSWORD:}}
tdengine:
url: jdbc:TAOS-RS://${TDENGINE_HOST:172.17.16.7}:${TDENGINE_PORT:6041}/aiot_platform
driver-class-name: com.taosdata.jdbc.rs.RestfulDriver
username: ${TDENGINE_USERNAME:root}
password: ${TDENGINE_PASSWORD:taosdata}
druid:
validation-query: SELECT SERVER_STATUS()
data:
redis:
host: ${REDIS_HOST:172.17.16.13}
port: ${REDIS_PORT:6379}
database: ${REDIS_DATABASE:0}
password: ${REDIS_PASSWORD:}
timeout: 5000ms
lettuce:
pool:
max-active: 8
max-wait: -1ms
max-idle: 8
min-idle: 0
--- #################### MQ 消息队列相关配置 ####################
rocketmq:
name-server: ${ROCKETMQ_NAMESRV_ADDR:rmq-4wd73bxpv.rocketmq.sh.qcloud.tencenttdmq.com:8080}
producer:
group: ${spring.application.name}_PRODUCER
access-key: ${ROCKETMQ_ACCESS_KEY:}
secret-key: ${ROCKETMQ_SECRET_KEY:}
consumer:
access-key: ${ROCKETMQ_ACCESS_KEY:}
secret-key: ${ROCKETMQ_SECRET_KEY:}
spring:
# 禁用 RabbitMQ 自动配置(如果不需要 RabbitMQ避免启动时连接失败
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration
# RabbitMQ 配置(已禁用自动配置,仅保留配置项供业务代码使用)
# rabbitmq:
# host: ${RABBITMQ_HOST:127.0.0.1}
# port: ${RABBITMQ_PORT:5672}
# username: ${RABBITMQ_USERNAME:guest}
# password: ${RABBITMQ_PASSWORD:guest}
kafka:
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:127.0.0.1:9092}
--- #################### 定时任务相关配置 ####################
xxl:
job:
admin:
addresses: ${XXL_JOB_ADMIN_ADDRESSES:http://172.17.16.7:19090/xxl-job-admin}
executor:
ip: ${XXL_JOB_EXECUTOR_IP:}
port: ${XXL_JOB_EXECUTOR_PORT:9999}
--- #################### 服务保障相关配置 ####################
lock4j:
acquire-timeout: 3000
expire: 30000
--- #################### 监控相关配置 ####################
management:
endpoints:
web:
base-path: /actuator
exposure:
include: '*'
spring:
boot:
admin:
client:
instance:
service-host-type: IP
username: ${SPRING_BOOT_ADMIN_USERNAME:admin}
password: ${SPRING_BOOT_ADMIN_PASSWORD:admin}
logging:
level:
root: INFO
com.viewsh: ${LOG_LEVEL:INFO}
com.viewsh.module.iot.dal.mysql: debug
com.viewsh.module.iot.dal.mysql.sms.SmsChannelMapper: INFO
org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR
file:
name: ${LOG_FILE_PATH:/app/logs}/${spring.application.name}.log
--- #################### 芋道相关配置 ####################
viewsh:
demo: false
env:
tag: ${HOSTNAME:prod}
captcha:
enable: true
security:
mock-enable: false