第六阶段开发。
This commit is contained in:
1 parent
35751b4f00
commit
bacdd15f3a
93 files changed
+10190
-22
No files matched your search
@@ -42,6 +42,48 @@ APT_PROBE_TIMEOUT_MS=2000
|
||||
# Keep false in production; a non-local profile with true fails startup.
|
||||
APT_MASTER_CLIENT_MOCK=false
|
||||
|
||||
# ---- SIP voice / AMI (disabled by default) ----
|
||||
# Master switch; AMI host/port/user/timeouts live in table apt_ast_instance.
|
||||
# Keep false until a reachable Asterisk exists — false means "no connection, no SIP call",
|
||||
# and the scan task only handles the tablet chain.
|
||||
APT_VOICE_ENABLED=false
|
||||
# Auto-dial when jcsj is due; false = only manual POST /apt/jcxx/{jcid}/dial
|
||||
APT_VOICE_SIP_CALL_ENABLED=true
|
||||
# Duty desk extensions (second leg), comma separated, e.g. 1002,1003; empty = room leg only
|
||||
APT_VOICE_DUTY_EXTENSIONS=
|
||||
APT_VOICE_TTS_CONTEXT=apt-tts
|
||||
APT_VOICE_TTS_EXTENSION=s
|
||||
APT_VOICE_TTS_MEDIA_PREFIX=apt/call-prompt
|
||||
APT_VOICE_DIAL_TIMEOUT_MS=30000
|
||||
|
||||
# ---- AMI secret (process env only) ----
|
||||
# apt_ast_instance.ami_secret_ref stores the VARIABLE NAME only (default APT_AMI_SECRET).
|
||||
# The value must match manager.conf accounts; it is never written to DB, logs or git.
|
||||
# Missing value -> AmiException(AMI_CONFIG_MISSING), instance stays DOWN, startup is unaffected.
|
||||
APT_AMI_SECRET=<required: AMI secret, injected by process env only>
|
||||
|
||||
# ---- Device auth for /open/v1/** ----
|
||||
# Production must keep true; local debugging may set false (only X-Device-Code is checked).
|
||||
APT_DEVICE_HMAC_ENABLED=true
|
||||
APT_DEVICE_HMAC_SECRET=<required: shared HMAC secret, injected by process env only>
|
||||
|
||||
# ---- Local debug endpoints (/apt/local/**, must stay false in production) ----
|
||||
# true exposes: inject #7 match verdict, inject AMI events (bypasses real entry points).
|
||||
APT_DEBUG_ENABLED=false
|
||||
|
||||
# ---- Tablet face photo storage (paths only in DB, image on disk/OSS) ----
|
||||
# Writable directory; DB stores relative path face/{gyid}/{yyyy}/{MM}/{dd}/{jcid}/{photoId}.jpg
|
||||
APT_PHOTO_ROOT=./data/apt-photos
|
||||
# Per-file limit in bytes (must be <= spring.servlet.multipart.max-file-size, currently 10MB)
|
||||
APT_PHOTO_MAX_BYTES=5242880
|
||||
# Prefix used only when echoing a URL back to the tablet
|
||||
APT_PHOTO_PUBLIC_PREFIX=/apt/files
|
||||
APT_PHOTO_STORAGE_TARGET=LOCAL
|
||||
# Retention: photo records and files older than N days are purged daily at 03:30
|
||||
# (business cap = 1 month; keep default 30 unless ops asks otherwise)
|
||||
APT_PHOTO_RETENTION_DAYS=30
|
||||
APT_PHOTO_PURGE_BATCH=200
|
||||
|
||||
# ---- Druid console (disabled by default) ----
|
||||
APT_DRUID_CONSOLE=false
|
||||
APT_DRUID_USER=aptadmin
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.ruoyi.apt.device.controller;
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.ruoyi.apt.common.audit.AptAudit;
|
||||
import com.ruoyi.apt.device.domain.AptDeviceCommandRequest;
|
||||
import com.ruoyi.apt.device.service.IAptDeviceService;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
|
||||
/**
|
||||
* M5 设备管理端:档案 / 心跳视图 / 指令队列。
|
||||
*
|
||||
* <p>管理端不能把指令改成 SENT/ACKED:状态只允许设备回执或派发器推进,
|
||||
* 本期协议未确认,因此列表里恒为「待协议确认」。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/apt/device", produces = "application/json;charset=UTF-8")
|
||||
public class AptDeviceController
|
||||
{
|
||||
private final IAptDeviceService deviceService;
|
||||
|
||||
public AptDeviceController(IAptDeviceService deviceService)
|
||||
{
|
||||
this.deviceService = deviceService;
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:device:list')")
|
||||
@GetMapping("/list")
|
||||
public AjaxResult list(String deviceType, String state, String keyword, Integer limit)
|
||||
{
|
||||
return AjaxResult.success(deviceService.list(deviceType, state, keyword, limit));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:device:query')")
|
||||
@GetMapping("/{deviceCode}")
|
||||
public AjaxResult detail(@PathVariable String deviceCode)
|
||||
{
|
||||
return AjaxResult.success(deviceService.detail(deviceCode));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:device:query')")
|
||||
@GetMapping("/{deviceCode}/commands")
|
||||
public AjaxResult commands(@PathVariable String deviceCode, String state, Integer limit)
|
||||
{
|
||||
return AjaxResult.success(deviceService.commands(deviceCode, state, limit));
|
||||
}
|
||||
|
||||
/** 指令入队:返回 PENDING + protocolConfirmed=false;停用设备拒绝 */
|
||||
@PreAuthorize("@ss.hasPermi('apt:device:enqueue')")
|
||||
@AptAudit(module = "DEVICE", action = "DEVICE_COMMAND_ENQUEUE")
|
||||
@PostMapping("/{deviceCode}/commands")
|
||||
public AjaxResult enqueue(@PathVariable String deviceCode,
|
||||
@RequestBody AptDeviceCommandRequest request)
|
||||
{
|
||||
return AjaxResult.success(deviceService.enqueue(deviceCode, request));
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.ruoyi.apt.device.controller;
|
||||
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.device.domain.AptDeviceHeartbeat;
|
||||
import com.ruoyi.apt.device.service.IAptDeviceService;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
|
||||
/**
|
||||
* #14 设备心跳(/open/v1/** 走设备四要素鉴权)。
|
||||
*
|
||||
* <p>心跳只刷新在线态与水位/时钟偏差,不触发任何指令状态变化;
|
||||
* 设备离线后未投递指令保持 PENDING,不伪造 SENT/ACKED。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/open/v1/device", produces = "application/json;charset=UTF-8")
|
||||
public class AptDeviceOpenController
|
||||
{
|
||||
private final IAptDeviceService deviceService;
|
||||
|
||||
public AptDeviceOpenController(IAptDeviceService deviceService)
|
||||
{
|
||||
this.deviceService = deviceService;
|
||||
}
|
||||
|
||||
@PostMapping("/heartbeat")
|
||||
public AjaxResult heartbeat(
|
||||
@RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
|
||||
@RequestBody(required = false) AptDeviceHeartbeat heartbeat)
|
||||
{
|
||||
if (deviceCode == null || deviceCode.isBlank())
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_DEVICE_CODE",
|
||||
"缺少 X-Device-Code");
|
||||
}
|
||||
return AjaxResult.success(deviceService.heartbeat(deviceCode, heartbeat));
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.ruoyi.apt.device.domain;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 设备指令入队请求(M5)。
|
||||
*
|
||||
* <p>本期只入队 {@code PENDING} + {@code protocolConfirmed=false};
|
||||
* 现场协议未确认(Q-12)前不派发、不伪造 ACKED。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptDeviceCommandRequest
|
||||
{
|
||||
/** 幂等键,缺省由服务端生成;重复提交返回既有指令 */
|
||||
private String commandId;
|
||||
|
||||
/** FACE_ISSUE / FACE_ERASE / FACE_FULL_SYNC / PLAY_TASK */
|
||||
private String commandType;
|
||||
|
||||
private Map<String, Object> payload;
|
||||
|
||||
private String faceScopeRef;
|
||||
|
||||
private Date expireAt;
|
||||
|
||||
public String getCommandId()
|
||||
{
|
||||
return commandId;
|
||||
}
|
||||
|
||||
public void setCommandId(String commandId)
|
||||
{
|
||||
this.commandId = commandId;
|
||||
}
|
||||
|
||||
public String getCommandType()
|
||||
{
|
||||
return commandType;
|
||||
}
|
||||
|
||||
public void setCommandType(String commandType)
|
||||
{
|
||||
this.commandType = commandType;
|
||||
}
|
||||
|
||||
public Map<String, Object> getPayload()
|
||||
{
|
||||
return payload;
|
||||
}
|
||||
|
||||
public void setPayload(Map<String, Object> payload)
|
||||
{
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
public String getFaceScopeRef()
|
||||
{
|
||||
return faceScopeRef;
|
||||
}
|
||||
|
||||
public void setFaceScopeRef(String faceScopeRef)
|
||||
{
|
||||
this.faceScopeRef = faceScopeRef;
|
||||
}
|
||||
|
||||
public Date getExpireAt()
|
||||
{
|
||||
return expireAt;
|
||||
}
|
||||
|
||||
public void setExpireAt(Date expireAt)
|
||||
{
|
||||
this.expireAt = expireAt;
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.ruoyi.apt.device.domain;
|
||||
|
||||
/**
|
||||
* #14 设备心跳请求体(设备编码取 X-Device-Code,不在体内重复)。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptDeviceHeartbeat
|
||||
{
|
||||
private String appVersion;
|
||||
|
||||
/** 设备时钟与服务器的偏差(毫秒,正值=设备快) */
|
||||
private Long clockSkewMs;
|
||||
|
||||
/** 设备本地已积压的上报水位;缺省 0,不回退已有水位 */
|
||||
private Long watermark;
|
||||
|
||||
public String getAppVersion()
|
||||
{
|
||||
return appVersion;
|
||||
}
|
||||
|
||||
public void setAppVersion(String appVersion)
|
||||
{
|
||||
this.appVersion = appVersion;
|
||||
}
|
||||
|
||||
public Long getClockSkewMs()
|
||||
{
|
||||
return clockSkewMs;
|
||||
}
|
||||
|
||||
public void setClockSkewMs(Long clockSkewMs)
|
||||
{
|
||||
this.clockSkewMs = clockSkewMs;
|
||||
}
|
||||
|
||||
public Long getWatermark()
|
||||
{
|
||||
return watermark;
|
||||
}
|
||||
|
||||
public void setWatermark(Long watermark)
|
||||
{
|
||||
this.watermark = watermark;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.ruoyi.apt.device.mapper;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* M5 设备档案 / 心跳 / 指令队列。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface AptDeviceMapper
|
||||
{
|
||||
List<Map<String, Object>> selectDeviceList(@Param("gyid") String gyid,
|
||||
@Param("deviceType") String deviceType,
|
||||
@Param("state") String state,
|
||||
@Param("keyword") String keyword,
|
||||
@Param("limit") int limit);
|
||||
|
||||
Map<String, Object> selectDevice(@Param("gyid") String gyid, @Param("deviceCode") String deviceCode);
|
||||
|
||||
/** 心跳只写在线态与版本,不触碰指令态 */
|
||||
int upsertHeartbeat(@Param("gyid") String gyid, @Param("deviceCode") String deviceCode,
|
||||
@Param("appVersion") String appVersion, @Param("at") Date at);
|
||||
|
||||
int updateDeviceSeen(@Param("gyid") String gyid, @Param("deviceCode") String deviceCode,
|
||||
@Param("at") Date at);
|
||||
|
||||
Map<String, Object> selectCommand(@Param("gyid") String gyid, @Param("commandId") String commandId);
|
||||
|
||||
int insertCommand(@Param("gyid") String gyid, @Param("commandId") String commandId,
|
||||
@Param("deviceCode") String deviceCode, @Param("commandType") String commandType,
|
||||
@Param("payload") String payload, @Param("faceScopeRef") String faceScopeRef,
|
||||
@Param("expireAt") Date expireAt);
|
||||
|
||||
List<Map<String, Object>> selectCommandList(@Param("gyid") String gyid,
|
||||
@Param("deviceCode") String deviceCode,
|
||||
@Param("state") String state,
|
||||
@Param("limit") int limit);
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
package com.ruoyi.apt.device.service;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.device.domain.AptDeviceCommandRequest;
|
||||
import com.ruoyi.apt.device.domain.AptDeviceHeartbeat;
|
||||
import com.ruoyi.apt.device.mapper.AptDeviceMapper;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.report.service.IAptReportService;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* M5 设备管理实现。
|
||||
*
|
||||
* <p>红线:指令只入队 {@code PENDING} + {@code protocolConfirmed=false};
|
||||
* 无设备心跳不自动改 {@code SENT}/{@code ACKED}(Q-12 现场协议未确认)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class AptDeviceServiceImpl implements IAptDeviceService
|
||||
{
|
||||
private static final int DEFAULT_LIMIT = 50;
|
||||
private static final int MAX_LIMIT = 200;
|
||||
|
||||
private final AptDeviceMapper deviceMapper;
|
||||
private final IAptReportService reportService;
|
||||
private final AptProperties properties;
|
||||
|
||||
public AptDeviceServiceImpl(AptDeviceMapper deviceMapper, IAptReportService reportService,
|
||||
AptProperties properties)
|
||||
{
|
||||
this.deviceMapper = deviceMapper;
|
||||
this.reportService = reportService;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> list(String deviceType, String state, String keyword, Integer limit)
|
||||
{
|
||||
int size = limit == null ? DEFAULT_LIMIT : limit;
|
||||
size = Math.max(1, Math.min(size, MAX_LIMIT));
|
||||
return deviceMapper.selectDeviceList(gyid(), trim(deviceType), trim(state), trim(keyword), size);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> detail(String deviceCode)
|
||||
{
|
||||
return requireDevice(deviceCode);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> heartbeat(String deviceCode, AptDeviceHeartbeat heartbeat)
|
||||
{
|
||||
String code = normalize(deviceCode);
|
||||
Map<String, Object> device = requireDevice(code);
|
||||
AptDeviceHeartbeat body = heartbeat == null ? new AptDeviceHeartbeat() : heartbeat;
|
||||
Date at = new Date();
|
||||
deviceMapper.upsertHeartbeat(gyid(), code, trim(body.getAppVersion()), at);
|
||||
deviceMapper.updateDeviceSeen(gyid(), code, at);
|
||||
|
||||
Map<String, Object> watermark = reportService.recordHeartbeat(body.getWatermark(), body.getClockSkewMs());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("deviceCode", code);
|
||||
result.put("deviceType", device.get("deviceType"));
|
||||
result.put("onlineState", "ONLINE");
|
||||
result.put("heartbeatAt", at);
|
||||
result.put("appVersion", trim(body.getAppVersion()));
|
||||
result.put("clockSkewMs", body.getClockSkewMs());
|
||||
result.put("clockSkewExceeded", watermark.get("clockSkewExceeded"));
|
||||
result.put("clockSkewThresholdMs", properties.getReport().getClockSkewThresholdMs());
|
||||
result.put("watermark", watermark.get("currentWatermark"));
|
||||
result.put("ackedWatermark", watermark.get("ackedWatermark"));
|
||||
result.put("lag", watermark.get("lag"));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> enqueue(String deviceCode, AptDeviceCommandRequest request)
|
||||
{
|
||||
String code = normalize(deviceCode);
|
||||
Map<String, Object> device = requireDevice(code);
|
||||
if (!"ACTIVE".equals(text(device.get("state"))))
|
||||
{
|
||||
throw new AptException(AptErrorCode.FEATURE_DISABLED, "DEVICE_DISABLED",
|
||||
"设备已停用,不参与指令投递:" + code);
|
||||
}
|
||||
if (request == null || trim(request.getCommandType()).isEmpty())
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_COMMAND_TYPE", "缺少 commandType");
|
||||
}
|
||||
String commandId = trim(request.getCommandId());
|
||||
if (commandId.isEmpty())
|
||||
{
|
||||
commandId = "DC-" + UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
Map<String, Object> existing = deviceMapper.selectCommand(gyid(), commandId);
|
||||
if (existing != null)
|
||||
{
|
||||
return view(existing, true);
|
||||
}
|
||||
String payload = request.getPayload() == null ? null : JSON.toJSONString(request.getPayload());
|
||||
try
|
||||
{
|
||||
deviceMapper.insertCommand(gyid(), commandId, code, request.getCommandType(), payload,
|
||||
trim(request.getFaceScopeRef()), request.getExpireAt());
|
||||
}
|
||||
catch (DuplicateKeyException e)
|
||||
{
|
||||
Map<String, Object> duplicated = deviceMapper.selectCommand(gyid(), commandId);
|
||||
if (duplicated == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.DATA_ANOMALY, "COMMAND_LOST", "指令幂等冲突但不可读");
|
||||
}
|
||||
return view(duplicated, true);
|
||||
}
|
||||
Map<String, Object> command = deviceMapper.selectCommand(gyid(), commandId);
|
||||
return view(command, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> commands(String deviceCode, String state, Integer limit)
|
||||
{
|
||||
String code = normalize(deviceCode);
|
||||
requireDevice(code);
|
||||
int size = limit == null ? DEFAULT_LIMIT : limit;
|
||||
size = Math.max(1, Math.min(size, MAX_LIMIT));
|
||||
return deviceMapper.selectCommandList(gyid(), code, trim(state), size);
|
||||
}
|
||||
|
||||
private Map<String, Object> view(Map<String, Object> command, boolean duplicated)
|
||||
{
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
if (command != null)
|
||||
{
|
||||
result.putAll(command);
|
||||
}
|
||||
Object state = command == null ? null : command.get("state");
|
||||
result.put("state", state == null ? "PENDING" : String.valueOf(state));
|
||||
Object confirmed = command == null ? null : command.get("protocolConfirmed");
|
||||
boolean protocolConfirmed = confirmed instanceof Number
|
||||
? ((Number) confirmed).intValue() != 0
|
||||
: "1".equals(String.valueOf(confirmed)) || "true".equalsIgnoreCase(String.valueOf(confirmed));
|
||||
result.put("protocolConfirmed", protocolConfirmed);
|
||||
result.put("duplicated", duplicated);
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<String, Object> requireDevice(String deviceCode)
|
||||
{
|
||||
String code = normalize(deviceCode);
|
||||
if (code.isEmpty())
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_DEVICE_CODE", "缺少设备编码");
|
||||
}
|
||||
Map<String, Object> device = deviceMapper.selectDevice(gyid(), code);
|
||||
if (device == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "DEVICE_NOT_FOUND", "设备不存在:" + code);
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
private String gyid()
|
||||
{
|
||||
String gyid = AptContext.getGyIdOrNull();
|
||||
if (gyid == null || gyid.isBlank())
|
||||
{
|
||||
throw new AptException(AptErrorCode.FORBIDDEN_GYID, "MISSING_GYID", "缺少公寓标识");
|
||||
}
|
||||
return gyid;
|
||||
}
|
||||
|
||||
private static String normalize(String deviceCode)
|
||||
{
|
||||
return trim(deviceCode).toUpperCase();
|
||||
}
|
||||
|
||||
private static String trim(String value)
|
||||
{
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String text(Object value)
|
||||
{
|
||||
return value == null ? "" : String.valueOf(value).trim();
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.ruoyi.apt.device.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.device.domain.AptDeviceCommandRequest;
|
||||
import com.ruoyi.apt.device.domain.AptDeviceHeartbeat;
|
||||
|
||||
/**
|
||||
* M5 设备管理:档案读取、#14 心跳、指令入队。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface IAptDeviceService
|
||||
{
|
||||
List<Map<String, Object>> list(String deviceType, String state, String keyword, Integer limit);
|
||||
|
||||
Map<String, Object> detail(String deviceCode);
|
||||
|
||||
/** 心跳:写 gy_sb_xt 在线态 + gy_sb.last_seen_at + 上报水位与时钟偏差 */
|
||||
Map<String, Object> heartbeat(String deviceCode, AptDeviceHeartbeat heartbeat);
|
||||
|
||||
/** 指令只入队 PENDING,protocolConfirmed 恒 false */
|
||||
Map<String, Object> enqueue(String deviceCode, AptDeviceCommandRequest request);
|
||||
|
||||
List<Map<String, Object>> commands(String deviceCode, String state, Integer limit);
|
||||
}
|
||||
+21
-1
@@ -11,6 +11,7 @@ import com.ruoyi.apt.dispatch.domain.AptJcxxQuery;
|
||||
import com.ruoyi.apt.dispatch.service.IAptDutyCommandService;
|
||||
import com.ruoyi.apt.dispatch.service.IAptJcxxService;
|
||||
import com.ruoyi.apt.framework.web.AptBaseController;
|
||||
import com.ruoyi.apt.voice.service.IAptCallFlowService;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@@ -37,11 +38,14 @@ public class AptJcxxController extends AptBaseController
|
||||
{
|
||||
private final IAptJcxxService jcxxService;
|
||||
private final IAptDutyCommandService commandService;
|
||||
private final IAptCallFlowService callFlowService;
|
||||
|
||||
public AptJcxxController(IAptJcxxService jcxxService, IAptDutyCommandService commandService)
|
||||
public AptJcxxController(IAptJcxxService jcxxService, IAptDutyCommandService commandService,
|
||||
IAptCallFlowService callFlowService)
|
||||
{
|
||||
this.jcxxService = jcxxService;
|
||||
this.commandService = commandService;
|
||||
this.callFlowService = callFlowService;
|
||||
}
|
||||
|
||||
/** 表 4-9 #1:在寓/叫乘记录列表 */
|
||||
@@ -123,4 +127,20 @@ public class AptJcxxController extends AptBaseController
|
||||
AptDutyCommandResult result = commandService.execute(id, command);
|
||||
return AjaxResult.success(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 值班台手动发起 SIP 叫乘(表 5-12 步骤 2~7)。
|
||||
*
|
||||
* <p>用于「到点自动发起」缺失/失败时的补发:要求 {@code ryzt=0}、未停止、
|
||||
* 房间已绑定分机且 AMI 已 UP,否则按原有错误码返回(40904/40404/40903)。
|
||||
* 与自动发起共用同一实现 {@link IAptCallFlowService#dial(String)},
|
||||
* 不额外写 {@code gy_jcxx},状态仍只由状态机迁移。</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('apt:jcxx:changeStatus')")
|
||||
@AptAudit(module = "DISPATCH", action = "JCXX_DIAL")
|
||||
@PostMapping("/{jcid}/dial")
|
||||
public AjaxResult dial(@PathVariable String jcid)
|
||||
{
|
||||
return AjaxResult.success(callFlowService.dial(jcid));
|
||||
}
|
||||
}
|
||||
+3
@@ -19,6 +19,9 @@ public interface AptDutyStateMapper
|
||||
/** 状态快照(命令前置校验 + 结果去重判定) */
|
||||
AptStateSnapshot selectSnapshot(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
/** 离寓联动:取该叫乘记录对应的房号(gy_jcxx.fjh),无记录返回 null */
|
||||
String selectRoomNoByJcid(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
/** 0 待叫乘 -> 1 叫乘中(SIP 发起呼叫 / 人工临时叫乘) */
|
||||
int updateCallStart(@Param("gyid") String gyid, @Param("jcid") String jcid,
|
||||
@Param("tempCall") boolean tempCall);
|
||||
|
||||
+3
@@ -28,6 +28,9 @@ public interface AptJcxxMapper
|
||||
|
||||
List<Map<String, Object>> selectManualActions(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
/** 叫乘作业的刷脸照片记录(含失败/重试,按上传时间倒序) */
|
||||
List<Map<String, Object>> selectFacePhotos(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
/** 时间线:状态时间戳(表 3-10 的「同表写入」列) */
|
||||
List<Map<String, Object>> selectTimelineStates(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
|
||||
+56
-1
@@ -7,8 +7,13 @@ import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.dispatch.domain.AptStateSnapshot;
|
||||
import com.ruoyi.apt.dispatch.mapper.AptDutyStateMapper;
|
||||
import com.ruoyi.apt.job.service.IAptJobService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/**
|
||||
* 叫乘状态机唯一写入口(IT-5 / M3,公寓端/03 表 3-10)。
|
||||
@@ -27,14 +32,18 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
@Service
|
||||
public class AptDutyStateServiceImpl implements IAptDutyStateService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AptDutyStateServiceImpl.class);
|
||||
|
||||
/** 应答允许的来源态:0 待叫乘 / 1 叫乘中 / 2 叫乘报警(含复叫入会) */
|
||||
private static final List<String> ANSWERABLE = List.of("0", "1", "2");
|
||||
|
||||
private final AptDutyStateMapper stateMapper;
|
||||
private final IAptJobService jobService;
|
||||
|
||||
public AptDutyStateServiceImpl(AptDutyStateMapper stateMapper)
|
||||
public AptDutyStateServiceImpl(AptDutyStateMapper stateMapper, IAptJobService jobService)
|
||||
{
|
||||
this.stateMapper = stateMapper;
|
||||
this.jobService = jobService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -91,6 +100,13 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService
|
||||
return require(stateMapper.updateForceFinish(gyid(), jcid), jcid, "FORCE_FINISH");
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记离寓 + 保洁联动。
|
||||
*
|
||||
* <p>同事务内按 gy_jcxx 在寓事实重算房间人数与房态(最后一人离寓直接置 3 待清扫);
|
||||
* 事务提交后再触发「全员离寓自动建单」,避免新事务读不到本次离寓结果,
|
||||
* 也保证建单失败不会回滚离寓这一核心业务事实。</p>
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public int markLeave(String jcid, String leaveType, String remark)
|
||||
@@ -99,9 +115,48 @@ public class AptDutyStateServiceImpl implements IAptDutyStateService
|
||||
int rows = require(stateMapper.updateMarkLeave(gyid(), jcid, leaveType, eventId, remark),
|
||||
jcid, "MARK_LEAVE");
|
||||
stateMapper.insertLeaveEvent(gyid(), eventId, jcid, leaveType);
|
||||
|
||||
String roomNo = stateMapper.selectRoomNoByJcid(gyid(), jcid);
|
||||
if (roomNo == null || roomNo.isBlank())
|
||||
{
|
||||
return rows;
|
||||
}
|
||||
// 占用与房态必须与离寓同事务,避免「已离寓但房态仍住满/空闲」
|
||||
jobService.syncRoomOccupancy(roomNo);
|
||||
triggerCleanOnVacated(roomNo, jcid);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 提交后触发自动建单;建单失败只告警,不回滚已提交的离寓 */
|
||||
private void triggerCleanOnVacated(String roomNo, String jcid)
|
||||
{
|
||||
Runnable task = () -> {
|
||||
try
|
||||
{
|
||||
jobService.autoCreateCleanOnVacated(roomNo, "LEAVE:" + jcid);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("全员离寓自动建单失败,roomNo={}, jcid={}, cause={}", roomNo, jcid, e.toString());
|
||||
}
|
||||
};
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive())
|
||||
{
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization()
|
||||
{
|
||||
@Override
|
||||
public void afterCommit()
|
||||
{
|
||||
task.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
task.run();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public int cancel(String jcid)
|
||||
|
||||
+2
@@ -63,6 +63,8 @@ public class AptJcxxServiceImpl implements IAptJcxxService
|
||||
detail.put("session", jcxxMapper.selectLatestSession(gyid(), jcid));
|
||||
detail.put("recordings", nonNull(jcxxMapper.selectRecordings(gyid(), jcid)));
|
||||
detail.put("manualActions", nonNull(jcxxMapper.selectManualActions(gyid(), jcid)));
|
||||
// 平板刷脸照片(只含路径与状态,失败记录一并返回,便于值班台追溯)
|
||||
detail.put("facePhotos", nonNull(jcxxMapper.selectFacePhotos(gyid(), jcid)));
|
||||
return detail;
|
||||
}
|
||||
|
||||
|
||||
+25
-2
@@ -5,8 +5,10 @@ import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.dispatch.tablet.domain.AptTabletResultCommand;
|
||||
import com.ruoyi.apt.dispatch.tablet.service.IAptTabletDispatchService;
|
||||
import com.ruoyi.apt.dispatch.tablet.service.IAptTabletPhotoService;
|
||||
import com.ruoyi.apt.dispatch.tablet.service.IAptTabletResultService;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -14,7 +16,9 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 设备侧平板接口(IT-5 / M3,公寓端/05 表 5-16 步骤 2~3、8)。
|
||||
@@ -34,12 +38,15 @@ public class AptTabletOpenController
|
||||
{
|
||||
private final IAptTabletDispatchService dispatchService;
|
||||
private final IAptTabletResultService resultService;
|
||||
private final IAptTabletPhotoService photoService;
|
||||
|
||||
public AptTabletOpenController(IAptTabletDispatchService dispatchService,
|
||||
IAptTabletResultService resultService)
|
||||
IAptTabletResultService resultService,
|
||||
IAptTabletPhotoService photoService)
|
||||
{
|
||||
this.dispatchService = dispatchService;
|
||||
this.resultService = resultService;
|
||||
this.photoService = photoService;
|
||||
}
|
||||
|
||||
/** N-03:按 dispatch_seq 游标顺序拉取任务(拉取 ≠ 执行) */
|
||||
@@ -61,7 +68,7 @@ public class AptTabletOpenController
|
||||
return AjaxResult.success(dispatchService.ack(requireDevice(deviceCode), taskId));
|
||||
}
|
||||
|
||||
/** N-04:结果上报(resultId 幂等;只传识别结论,不传图像) */
|
||||
/** N-04:结果上报(resultId 幂等;JSON 只传识别结论,图像走下方照片接口) */
|
||||
@PostMapping("/results")
|
||||
public AjaxResult results(
|
||||
@RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
|
||||
@@ -70,6 +77,22 @@ public class AptTabletOpenController
|
||||
return AjaxResult.success(resultService.accept(requireDevice(deviceCode), command));
|
||||
}
|
||||
|
||||
/**
|
||||
* N-04 附属:刷脸照片上传(触发时机 = 结果被受理且为 ANSWERED + VERIFIED 之后)。
|
||||
*
|
||||
* <p>multipart/form-data,字段名 {@code file};同一 resultId 重复上传幂等(返回 duplicated=true),
|
||||
* 失败可原样重试,不影响已完成的叫乘状态。</p>
|
||||
*/
|
||||
@PostMapping(value = "/results/{resultId}/face-photo",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public AjaxResult facePhoto(
|
||||
@RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
|
||||
@PathVariable String resultId,
|
||||
@RequestPart("file") MultipartFile file)
|
||||
{
|
||||
return AjaxResult.success(photoService.store(requireDevice(deviceCode), resultId, file));
|
||||
}
|
||||
|
||||
private String requireDevice(String deviceCode)
|
||||
{
|
||||
if (deviceCode == null || deviceCode.isBlank())
|
||||
|
||||
+45
@@ -71,4 +71,49 @@ public interface AptTabletMapper
|
||||
@Param("bizType") String bizType, @Param("bizId") String bizId,
|
||||
@Param("bizVersion") long bizVersion, @Param("payload") String payload,
|
||||
@Param("occurredAt") Date occurredAt);
|
||||
|
||||
// ------------------------------------------------------------ 刷脸照片
|
||||
|
||||
/** 结果摘要(含设备/任务/状态/照片指针),上传前做归属与适用性校验 */
|
||||
Map<String, Object> selectResultForPhoto(@Param("gyid") String gyid,
|
||||
@Param("resultId") String resultId);
|
||||
|
||||
Map<String, Object> selectPhotoByResult(@Param("gyid") String gyid, @Param("resultId") String resultId);
|
||||
|
||||
/** 首次上传插入 PENDING;重传按 (gyid, result_id) 覆盖并 retry_count+1 */
|
||||
int insertPhotoPending(@Param("gyid") String gyid, @Param("photoId") String photoId,
|
||||
@Param("jcid") String jcid, @Param("taskId") String taskId,
|
||||
@Param("resultId") String resultId, @Param("ryid") String ryid,
|
||||
@Param("deviceCode") String deviceCode,
|
||||
@Param("storageTarget") String storageTarget,
|
||||
@Param("storagePath") String storagePath,
|
||||
@Param("fileName") String fileName,
|
||||
@Param("contentType") String contentType,
|
||||
@Param("fileSize") long fileSize,
|
||||
@Param("capturedAt") Date capturedAt);
|
||||
|
||||
int updatePhotoStored(@Param("gyid") String gyid, @Param("resultId") String resultId,
|
||||
@Param("fileSize") long fileSize, @Param("sha256") String sha256);
|
||||
|
||||
int updatePhotoFailed(@Param("gyid") String gyid, @Param("resultId") String resultId,
|
||||
@Param("failReason") String failReason);
|
||||
|
||||
int updateResultPhotoRef(@Param("gyid") String gyid, @Param("resultId") String resultId,
|
||||
@Param("photoId") String photoId, @Param("photoState") String photoState);
|
||||
|
||||
// -------------------------------------------------------- 过期清理(保留期)
|
||||
|
||||
/** 存在过期照片的公寓列表(清理任务按公寓逐个边界清理,保持 gyid 隔离) */
|
||||
List<String> selectGyidsWithExpiredPhotos(@Param("cutoff") Date cutoff);
|
||||
|
||||
/**
|
||||
* 过期照片批次:以 uploaded_at 为准,失败/未完成记录退化为 captured_at / create_time。
|
||||
* 每次最多 limit 条,由调用方循环直到取空。
|
||||
*/
|
||||
List<Map<String, Object>> selectExpiredPhotos(@Param("gyid") String gyid,
|
||||
@Param("cutoff") Date cutoff,
|
||||
@Param("limit") int limit);
|
||||
|
||||
/** 物理删除记录(保留期到期后不再保留任何路径信息) */
|
||||
int deletePhoto(@Param("gyid") String gyid, @Param("id") long id);
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package com.ruoyi.apt.dispatch.tablet.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.dispatch.tablet.mapper.AptTabletMapper;
|
||||
import com.ruoyi.apt.framework.config.AptPhotoProperties;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 刷脸照片保留期清理实现。
|
||||
*
|
||||
* <p>顺序与幂等:</p>
|
||||
* <ol>
|
||||
* <li>按 {@code gyid} 取过期流水,逐批处理(默认 200/批),单批无进展即停,防止死循环;</li>
|
||||
* <li>先删文件:文件已不存在视为已清理;路径不在根目录内只告警、不删文件但删记录;</li>
|
||||
* <li>文件删除抛错则<b>保留记录</b>(下轮重试),并计入 filesFailed;</li>
|
||||
* <li>最后物理删除数据库记录(保留期内已无任何路径信息)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class AptPhotoPurgeServiceImpl implements IAptPhotoPurgeService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AptPhotoPurgeServiceImpl.class);
|
||||
|
||||
/** 单公寓单次运行最多批次数,避免异常数据把任务拖死 */
|
||||
private static final int MAX_BATCHES = 200;
|
||||
|
||||
private static final int DEFAULT_RETENTION_DAYS = 30;
|
||||
|
||||
private final AptTabletMapper tabletMapper;
|
||||
private final AptPhotoProperties properties;
|
||||
|
||||
public AptPhotoPurgeServiceImpl(AptTabletMapper tabletMapper, AptPhotoProperties properties)
|
||||
{
|
||||
this.tabletMapper = tabletMapper;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> purgeExpired()
|
||||
{
|
||||
int retentionDays = properties.getRetentionDays() > 0
|
||||
? properties.getRetentionDays() : DEFAULT_RETENTION_DAYS;
|
||||
int batchSize = properties.getPurgeBatchSize() > 0 ? properties.getPurgeBatchSize() : 200;
|
||||
Date cutoff = Date.from(Instant.now().minus(retentionDays, ChronoUnit.DAYS));
|
||||
|
||||
long scanned = 0;
|
||||
long deleted = 0;
|
||||
long filesDeleted = 0;
|
||||
long filesMissing = 0;
|
||||
long filesFailed = 0;
|
||||
long filesSkipped = 0;
|
||||
|
||||
List<String> gyids = tabletMapper.selectGyidsWithExpiredPhotos(cutoff);
|
||||
for (String gyid : gyids)
|
||||
{
|
||||
for (int round = 0; round < MAX_BATCHES; round++)
|
||||
{
|
||||
List<Map<String, Object>> rows = tabletMapper.selectExpiredPhotos(gyid, cutoff, batchSize);
|
||||
if (rows == null || rows.isEmpty())
|
||||
{
|
||||
break;
|
||||
}
|
||||
long deletedInBatch = 0;
|
||||
for (Map<String, Object> row : rows)
|
||||
{
|
||||
scanned++;
|
||||
String storagePath = str(row.get("storagePath"));
|
||||
try
|
||||
{
|
||||
FileResult result = deleteFile(storagePath);
|
||||
switch (result)
|
||||
{
|
||||
case DELETED:
|
||||
filesDeleted++;
|
||||
break;
|
||||
case MISSING:
|
||||
filesMissing++;
|
||||
break;
|
||||
default:
|
||||
filesSkipped++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
// 文件删不掉就先留记录,下轮再试,避免路径信息丢失后无法定位残留文件
|
||||
filesFailed++;
|
||||
log.warn("刷脸照片文件删除失败,保留记录待下轮重试:gyid={}, photoId={}, path={}, err={}",
|
||||
gyid, row.get("photoId"), storagePath, e.toString());
|
||||
continue;
|
||||
}
|
||||
int affected = tabletMapper.deletePhoto(gyid, asLong(row.get("id")));
|
||||
deleted += affected;
|
||||
deletedInBatch += affected;
|
||||
}
|
||||
if (deletedInBatch == 0 || rows.size() < batchSize)
|
||||
{
|
||||
// 本批没有进展(全部是删文件失败)或已取空,结束该公寓
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> stats = new LinkedHashMap<>();
|
||||
stats.put("retentionDays", retentionDays);
|
||||
stats.put("cutoff", cutoff);
|
||||
stats.put("gyids", gyids.size());
|
||||
stats.put("scanned", scanned);
|
||||
stats.put("deleted", deleted);
|
||||
stats.put("filesDeleted", filesDeleted);
|
||||
stats.put("filesMissing", filesMissing);
|
||||
stats.put("filesFailed", filesFailed);
|
||||
stats.put("filesSkipped", filesSkipped);
|
||||
if (scanned > 0 || filesFailed > 0)
|
||||
{
|
||||
log.info("刷脸照片清理完成:{}", stats);
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** 删除单个文件:路径必须落在配置根目录内 */
|
||||
private FileResult deleteFile(String storagePath) throws IOException
|
||||
{
|
||||
if (storagePath == null || storagePath.isBlank())
|
||||
{
|
||||
return FileResult.SKIPPED;
|
||||
}
|
||||
Path root = Paths.get(properties.getRoot()).toAbsolutePath().normalize();
|
||||
Path file = root.resolve(storagePath).normalize();
|
||||
if (!file.startsWith(root))
|
||||
{
|
||||
log.warn("刷脸照片路径越界,跳过文件删除(记录仍清理):{}", storagePath);
|
||||
return FileResult.SKIPPED;
|
||||
}
|
||||
return Files.deleteIfExists(file) ? FileResult.DELETED : FileResult.MISSING;
|
||||
}
|
||||
|
||||
private long asLong(Object value)
|
||||
{
|
||||
if (value instanceof Number number)
|
||||
{
|
||||
return number.longValue();
|
||||
}
|
||||
return value == null ? 0L : Long.parseLong(String.valueOf(value));
|
||||
}
|
||||
|
||||
private String str(Object value)
|
||||
{
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
|
||||
private enum FileResult
|
||||
{
|
||||
DELETED, MISSING, SKIPPED
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
package com.ruoyi.apt.dispatch.tablet.service;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.dispatch.tablet.mapper.AptTabletMapper;
|
||||
import com.ruoyi.apt.framework.config.AptPhotoProperties;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 平板刷脸照片受理实现。
|
||||
*
|
||||
* <p>落盘规范(相对路径入库,根目录取 {@code apt.photo.root}):</p>
|
||||
* <pre>
|
||||
* face/{gyid}/{yyyy}/{MM}/{dd}/{jcid}/{photoId}.{jpg|png}
|
||||
* </pre>
|
||||
*
|
||||
* <p>可靠性约定:</p>
|
||||
* <ol>
|
||||
* <li>先写 PENDING 行,再落盘,最后置 STORED —— 中途失败必有记录,不留“有文件无记录”;</li>
|
||||
* <li>写临时文件 + 原子改名,避免半张图被读到;</li>
|
||||
* <li>重传复用同一 photo_id/路径(retry_count+1),不产生多行;</li>
|
||||
* <li>失败只影响照片态(FAILED),<b>不</b>回滚已经完成的 ryzt=3。</li>
|
||||
* </ol>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class AptTabletPhotoServiceImpl implements IAptTabletPhotoService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AptTabletPhotoServiceImpl.class);
|
||||
|
||||
private static final String ANSWERED = "ANSWERED";
|
||||
private static final String VERIFIED = "VERIFIED";
|
||||
private static final String STORED = "STORED";
|
||||
private static final String FAILED = "FAILED";
|
||||
private static final String PENDING = "PENDING";
|
||||
|
||||
private static final Set<String> ALLOWED_TYPES = Set.of("image/jpeg", "image/png");
|
||||
|
||||
/** jcid/photoId 会拼进路径,禁止任何可能穿越目录的字符 */
|
||||
private static final Pattern SAFE_SEGMENT = Pattern.compile("^[A-Za-z0-9._-]{1,64}$");
|
||||
|
||||
private static final DateTimeFormatter YEAR = DateTimeFormatter.ofPattern("yyyy");
|
||||
private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("MM");
|
||||
private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("dd");
|
||||
|
||||
private final AptTabletMapper tabletMapper;
|
||||
private final AptPhotoProperties properties;
|
||||
|
||||
public AptTabletPhotoServiceImpl(AptTabletMapper tabletMapper, AptPhotoProperties properties)
|
||||
{
|
||||
this.tabletMapper = tabletMapper;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> store(String deviceCode, String resultId, MultipartFile file)
|
||||
{
|
||||
String gyid = gyid();
|
||||
if (file == null || file.isEmpty())
|
||||
{
|
||||
throw new AptException(AptErrorCode.FACE_MEDIA_MISSING, "PHOTO_EMPTY", "照片文件为空");
|
||||
}
|
||||
if (resultId == null || resultId.isBlank())
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_RESULTID", "resultId 不能为空");
|
||||
}
|
||||
|
||||
Map<String, Object> result = tabletMapper.selectResultForPhoto(gyid, resultId);
|
||||
if (absent(result))
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "TABLET_RESULT_NOT_FOUND",
|
||||
"平板结果不存在,请先上报结果:" + resultId);
|
||||
}
|
||||
String boundDevice = firstText(result.get("deviceCode"), result.get("taskDeviceCode"));
|
||||
if (!boundDevice.isEmpty() && !boundDevice.equalsIgnoreCase(deviceCode))
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "TABLET_RESULT_DEVICE_MISMATCH",
|
||||
"结果不属于该平板:" + resultId);
|
||||
}
|
||||
if (!ANSWERED.equalsIgnoreCase(str(result.get("resultType")))
|
||||
|| !VERIFIED.equalsIgnoreCase(str(result.get("faceVerifyState"))))
|
||||
{
|
||||
// 只有「刷脸确认成功」才留照片;未刷脸/未应答不接收,避免误存无关图像
|
||||
throw new AptException(AptErrorCode.STATE_ILLEGAL, "TABLET_PHOTO_NOT_APPLICABLE",
|
||||
"仅刷脸确认成功(ANSWERED + VERIFIED)的结果可上传照片");
|
||||
}
|
||||
if ("IGNORED".equalsIgnoreCase(str(result.get("appliedState"))))
|
||||
{
|
||||
throw new AptException(AptErrorCode.STATE_ILLEGAL, "TABLET_RESULT_IGNORED",
|
||||
"结果已按迟到/重复忽略,不再接收照片");
|
||||
}
|
||||
|
||||
Map<String, Object> existing = tabletMapper.selectPhotoByResult(gyid, resultId);
|
||||
if (!absent(existing) && STORED.equals(str(existing.get("uploadState"))))
|
||||
{
|
||||
// 幂等:已存成功,直接回显(平板重试安全)
|
||||
return meta(existing, true);
|
||||
}
|
||||
|
||||
String contentType = detectContentType(file);
|
||||
long size = file.getSize();
|
||||
if (size > properties.getMaxSizeBytes())
|
||||
{
|
||||
throw new AptException(AptErrorCode.FACE_MEDIA_TOO_LARGE, "PHOTO_TOO_LARGE",
|
||||
"照片超过上限 " + properties.getMaxSizeBytes() + " 字节");
|
||||
}
|
||||
|
||||
String jcid = str(result.get("jcid"));
|
||||
if (!SAFE_SEGMENT.matcher(jcid).matches())
|
||||
{
|
||||
throw new AptException(AptErrorCode.DATA_ANOMALY, "PHOTO_PATH_UNSAFE",
|
||||
"jcid 含非法字符,拒绝落盘:" + jcid);
|
||||
}
|
||||
String photoId = absent(existing) ? "PH-" + suffix() : str(existing.get("photoId"));
|
||||
String extension = "image/png".equals(contentType) ? "png" : "jpg";
|
||||
// UUID 生成后统一大写,保持库内 photoId 口径一致
|
||||
photoId = photoId.toUpperCase(Locale.ROOT);
|
||||
String relativePath = String.join("/", "face", gyid, YEAR.format(LocalDate.now()),
|
||||
MONTH.format(LocalDate.now()), DAY.format(LocalDate.now()), jcid,
|
||||
photoId + "." + extension);
|
||||
String fileName = photoId + "." + extension;
|
||||
String storageTarget = properties.getStorageTarget();
|
||||
|
||||
// 1) 先落 PENDING:失败也能在库里查到这次尝试
|
||||
tabletMapper.insertPhotoPending(gyid, photoId, jcid, str(result.get("taskId")), resultId,
|
||||
str(result.get("verifiedRyid")), deviceCode, storageTarget, relativePath,
|
||||
fileName, contentType, size, result.get("occurredAt") == null ? null : toDate(result));
|
||||
tabletMapper.updateResultPhotoRef(gyid, resultId, photoId, PENDING);
|
||||
|
||||
// 2) 再落盘(临时文件 + 原子改名)
|
||||
Path root = Paths.get(properties.getRoot()).toAbsolutePath().normalize();
|
||||
Path target = root.resolve(relativePath).normalize();
|
||||
if (!target.startsWith(root))
|
||||
{
|
||||
markFailed(gyid, resultId, photoId, "路径越界:" + relativePath);
|
||||
throw new AptException(AptErrorCode.DATA_ANOMALY, "PHOTO_PATH_UNSAFE", "照片路径越界");
|
||||
}
|
||||
String sha256;
|
||||
try
|
||||
{
|
||||
sha256 = writeAtomically(file, target);
|
||||
}
|
||||
catch (IOException | RuntimeException e)
|
||||
{
|
||||
log.error("刷脸照片落盘失败:gyid={}, resultId={}, path={}, err={}",
|
||||
gyid, resultId, relativePath, e.toString());
|
||||
markFailed(gyid, resultId, photoId, e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
throw new AptException(AptErrorCode.DEPENDENCY_UNAVAILABLE, "PHOTO_STORE_FAILED",
|
||||
"照片存储失败,请重试:" + e.getMessage());
|
||||
}
|
||||
|
||||
// 3) 置 STORED 并回写结果表指针
|
||||
tabletMapper.updatePhotoStored(gyid, resultId, size, sha256);
|
||||
tabletMapper.updateResultPhotoRef(gyid, resultId, photoId, STORED);
|
||||
log.info("刷脸照片已入库:jcid={}, photoId={}, path={}, size={}, sha256={}",
|
||||
jcid, photoId, relativePath, size, sha256);
|
||||
|
||||
Map<String, Object> meta = new LinkedHashMap<>();
|
||||
meta.put("photoId", photoId);
|
||||
meta.put("resultId", resultId);
|
||||
meta.put("jcid", jcid);
|
||||
meta.put("taskId", str(result.get("taskId")));
|
||||
meta.put("ryid", str(result.get("verifiedRyid")));
|
||||
meta.put("deviceCode", deviceCode);
|
||||
meta.put("storageTarget", storageTarget);
|
||||
meta.put("storagePath", relativePath);
|
||||
meta.put("fileName", fileName);
|
||||
meta.put("contentType", contentType);
|
||||
meta.put("fileSize", size);
|
||||
meta.put("sha256", sha256);
|
||||
meta.put("uploadState", STORED);
|
||||
meta.put("duplicated", false);
|
||||
meta.put("url", url(relativePath));
|
||||
meta.put("traceId", AptContext.getTraceId());
|
||||
return meta;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 内部
|
||||
|
||||
/** 临时文件 + 原子改名,边写边算 sha256;返回十六进制摘要 */
|
||||
private String writeAtomically(MultipartFile file, Path target) throws IOException
|
||||
{
|
||||
Files.createDirectories(target.getParent());
|
||||
Path temp = target.resolveSibling(target.getFileName() + ".tmp");
|
||||
MessageDigest digest = sha256Digest();
|
||||
try (InputStream in = new BufferedInputStream(file.getInputStream()))
|
||||
{
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
try (var out = Files.newOutputStream(temp))
|
||||
{
|
||||
while ((read = in.read(buffer)) != -1)
|
||||
{
|
||||
digest.update(buffer, 0, read);
|
||||
out.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
Files.deleteIfExists(temp);
|
||||
throw e;
|
||||
}
|
||||
try
|
||||
{
|
||||
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE);
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
// 某些文件系统(如跨分区/NFS)不支持 ATOMIC_MOVE,退化为普通替换
|
||||
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
return toHex(digest.digest());
|
||||
}
|
||||
|
||||
private void markFailed(String gyid, String resultId, String photoId, String reason)
|
||||
{
|
||||
String trimmed = reason == null ? "UNKNOWN" : reason;
|
||||
if (trimmed.length() > 250)
|
||||
{
|
||||
trimmed = trimmed.substring(0, 250);
|
||||
}
|
||||
tabletMapper.updatePhotoFailed(gyid, resultId, trimmed);
|
||||
tabletMapper.updateResultPhotoRef(gyid, resultId, photoId, FAILED);
|
||||
}
|
||||
|
||||
/** 先看声明类型,再看文件头;两者都不合法才拒绝(防止伪造 Content-Type) */
|
||||
private String detectContentType(MultipartFile file)
|
||||
{
|
||||
String declared = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
|
||||
if (ALLOWED_TYPES.contains(declared) && matchesMagic(file, declared))
|
||||
{
|
||||
return declared;
|
||||
}
|
||||
try (InputStream in = new BufferedInputStream(file.getInputStream()))
|
||||
{
|
||||
in.mark(8);
|
||||
int b1 = in.read();
|
||||
int b2 = in.read();
|
||||
int b3 = in.read();
|
||||
int b4 = in.read();
|
||||
in.reset();
|
||||
if (b1 == 0xFF && b2 == 0xD8 && b3 == 0xFF)
|
||||
{
|
||||
return "image/jpeg";
|
||||
}
|
||||
if (b1 == 0x89 && b2 == 0x50 && b3 == 0x4E && b4 == 0x47)
|
||||
{
|
||||
return "image/png";
|
||||
}
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
throw new AptException(AptErrorCode.FACE_MEDIA_MISSING, "PHOTO_UNREADABLE",
|
||||
"照片不可读:" + e.getMessage());
|
||||
}
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "PHOTO_TYPE_INVALID",
|
||||
"仅支持 image/jpeg、image/png");
|
||||
}
|
||||
|
||||
private boolean matchesMagic(MultipartFile file, String declared)
|
||||
{
|
||||
try (InputStream in = new BufferedInputStream(file.getInputStream()))
|
||||
{
|
||||
int b1 = in.read();
|
||||
int b2 = in.read();
|
||||
int b3 = in.read();
|
||||
int b4 = in.read();
|
||||
if ("image/jpeg".equals(declared))
|
||||
{
|
||||
return b1 == 0xFF && b2 == 0xD8 && b3 == 0xFF;
|
||||
}
|
||||
return b1 == 0x89 && b2 == 0x50 && b3 == 0x4E && b4 == 0x47;
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> meta(Map<String, Object> row, boolean duplicated)
|
||||
{
|
||||
Map<String, Object> meta = new LinkedHashMap<>(row);
|
||||
meta.put("url", url(str(row.get("storagePath"))));
|
||||
meta.put("duplicated", duplicated);
|
||||
meta.put("traceId", AptContext.getTraceId());
|
||||
return meta;
|
||||
}
|
||||
|
||||
private String url(String relativePath)
|
||||
{
|
||||
String prefix = properties.getPublicPrefix() == null ? "" : properties.getPublicPrefix();
|
||||
return prefix.endsWith("/") ? prefix + relativePath : prefix + "/" + relativePath;
|
||||
}
|
||||
|
||||
private MessageDigest sha256Digest()
|
||||
{
|
||||
try
|
||||
{
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
}
|
||||
catch (NoSuchAlgorithmException e)
|
||||
{
|
||||
throw new AptException(AptErrorCode.INTERNAL_ERROR, "SHA256_UNAVAILABLE", "缺少 SHA-256 实现");
|
||||
}
|
||||
}
|
||||
|
||||
private String toHex(byte[] bytes)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes)
|
||||
{
|
||||
sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String suffix()
|
||||
{
|
||||
return UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||
}
|
||||
|
||||
private boolean absent(Map<String, Object> row)
|
||||
{
|
||||
return row == null || row.isEmpty();
|
||||
}
|
||||
|
||||
private String firstText(Object... values)
|
||||
{
|
||||
for (Object value : values)
|
||||
{
|
||||
String text = str(value);
|
||||
if (!text.isEmpty())
|
||||
{
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private java.util.Date toDate(Map<String, Object> row)
|
||||
{
|
||||
Object value = row.get("occurredAt");
|
||||
return value instanceof java.util.Date date ? date : null;
|
||||
}
|
||||
|
||||
private String str(Object value)
|
||||
{
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
|
||||
private String gyid()
|
||||
{
|
||||
return AptContext.getGyId();
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -35,7 +35,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
* </pre>
|
||||
*
|
||||
* <p>硬约束:只提交状态机命令,不直写 gy_jcxx;平板链路<b>绝不</b>入 #24 语音摘要;
|
||||
* 只接收识别结论,抓拍帧 / 人脸原图 / 比对帧一律不入库。</p>
|
||||
* 本接口只接收识别结论(JSON,不传图像)。刷脸原图由 {@code POST /open/v1/tablet/results/{resultId}/face-photo}
|
||||
* 单独上传,库内只落「相对路径 + 校验和 + 状态」({@code apt_tablet_face_photo}),不存 BLOB。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.ruoyi.apt.dispatch.tablet.service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 刷脸照片保留期清理(最多保留 1 个月)。
|
||||
*
|
||||
* <p>清理口径:{@code COALESCE(uploaded_at, captured_at, create_time) < now - retentionDays}
|
||||
* 的照片,先删物理文件,再删数据库记录;文件删除失败时保留记录,下一轮重试,
|
||||
* 避免「记录没了、文件还在」。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface IAptPhotoPurgeService
|
||||
{
|
||||
/**
|
||||
* 执行一次清理,按公寓逐个边界清理(gyid 隔离)。
|
||||
*
|
||||
* @return 统计:retentionDays / cutoff / gyids / scanned / deleted / filesDeleted / filesMissing / filesFailed
|
||||
*/
|
||||
Map<String, Object> purgeExpired();
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.ruoyi.apt.dispatch.tablet.service;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 平板刷脸照片受理(叫乘作业留痕)。
|
||||
*
|
||||
* <p>触发时机:平板「刷脸确认成功」= resultType {@code ANSWERED} 且
|
||||
* faceVerifyState {@code VERIFIED} 的结果上报成功(appliedState=APPLIED)之后,
|
||||
* 由同一平板立即调用本服务上传抓拍原图。</p>
|
||||
*
|
||||
* <p>只落路径与校验信息,图像本体写文件/对象存储;上传失败不回滚
|
||||
* {@code ryzt=3}(状态机已在结果受理时完成),只记录 {@code FAILED} 供重试与查询。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface IAptTabletPhotoService
|
||||
{
|
||||
/**
|
||||
* 保存一张刷脸照片。
|
||||
*
|
||||
* @param deviceCode 平板编码(与结果上报同一凭据,校验归属)
|
||||
* @param resultId 平板结果号(幂等键,一个结果只保留一张)
|
||||
* @param file 抓拍原图(jpeg/png,默认上限 5MB)
|
||||
* @return photoId / storagePath / url / uploadState / duplicated 等
|
||||
*/
|
||||
Map<String, Object> store(String deviceCode, String resultId, MultipartFile file);
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.ruoyi.apt.framework.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 刷脸照片存储配置({@code apt.photo.*})。
|
||||
*
|
||||
* <p>只落「路径 + 校验信息」,图像本体在文件系统/对象存储,绝不进数据库 BLOB。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "apt.photo")
|
||||
public class AptPhotoProperties
|
||||
{
|
||||
/** 存储根目录(绝对路径优先;相对路径以进程工作目录为基准) */
|
||||
private String root = "./data/apt-photos";
|
||||
|
||||
/** 单张上限(字节),默认 5MB;超出返回 40922 */
|
||||
private long maxSizeBytes = 5L * 1024 * 1024;
|
||||
|
||||
/** 对外访问前缀,仅用于回显 URL,不做静态资源映射 */
|
||||
private String publicPrefix = "/apt/files";
|
||||
|
||||
/** 存储介质:LOCAL / OSS(本期仅实现 LOCAL,字段保留) */
|
||||
private String storageTarget = "LOCAL";
|
||||
|
||||
/** 保留天数:超过即由清理任务删除文件与记录(默认 30 天,业务上限 1 个月) */
|
||||
private int retentionDays = 30;
|
||||
|
||||
/** 单批清理条数,避免一次锁表/长时间占用连接 */
|
||||
private int purgeBatchSize = 200;
|
||||
|
||||
public String getRoot()
|
||||
{
|
||||
return root;
|
||||
}
|
||||
|
||||
public void setRoot(String root)
|
||||
{
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
public long getMaxSizeBytes()
|
||||
{
|
||||
return maxSizeBytes;
|
||||
}
|
||||
|
||||
public void setMaxSizeBytes(long maxSizeBytes)
|
||||
{
|
||||
this.maxSizeBytes = maxSizeBytes;
|
||||
}
|
||||
|
||||
public String getPublicPrefix()
|
||||
{
|
||||
return publicPrefix;
|
||||
}
|
||||
|
||||
public void setPublicPrefix(String publicPrefix)
|
||||
{
|
||||
this.publicPrefix = publicPrefix;
|
||||
}
|
||||
|
||||
public String getStorageTarget()
|
||||
{
|
||||
return storageTarget;
|
||||
}
|
||||
|
||||
public void setStorageTarget(String storageTarget)
|
||||
{
|
||||
this.storageTarget = storageTarget;
|
||||
}
|
||||
|
||||
public int getRetentionDays()
|
||||
{
|
||||
return retentionDays;
|
||||
}
|
||||
|
||||
public void setRetentionDays(int retentionDays)
|
||||
{
|
||||
this.retentionDays = retentionDays;
|
||||
}
|
||||
|
||||
public int getPurgeBatchSize()
|
||||
{
|
||||
return purgeBatchSize;
|
||||
}
|
||||
|
||||
public void setPurgeBatchSize(int purgeBatchSize)
|
||||
{
|
||||
this.purgeBatchSize = purgeBatchSize;
|
||||
}
|
||||
}
|
||||
+93
@@ -22,6 +22,7 @@ public class AptProperties
|
||||
private Features features = new Features();
|
||||
private Dispatch dispatch = new Dispatch();
|
||||
private Voice voice = new Voice();
|
||||
private Report report = new Report();
|
||||
|
||||
public String getGyId()
|
||||
{
|
||||
@@ -103,6 +104,16 @@ public class AptProperties
|
||||
this.voice = voice;
|
||||
}
|
||||
|
||||
public Report getReport()
|
||||
{
|
||||
return report;
|
||||
}
|
||||
|
||||
public void setReport(Report report)
|
||||
{
|
||||
this.report = report;
|
||||
}
|
||||
|
||||
public static class Probe
|
||||
{
|
||||
private boolean enabled = true;
|
||||
@@ -557,4 +568,86 @@ public class AptProperties
|
||||
this.maxCallsPerScan = maxCallsPerScan;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IT-6 上报续传(公寓端/09 表 9-16 Mock 策略)。
|
||||
*
|
||||
* <p>{@code dispatchEnabled=false} 时 outbox 只累积、不派发,也不允许把记录假置 SENT;
|
||||
* 生产默认 false,待平台联调凭据(Q-13)就绪后再开启。</p>
|
||||
*/
|
||||
public static class Report
|
||||
{
|
||||
/** 平台派发总开关;false=只入队(Mock/断网口径) */
|
||||
private boolean dispatchEnabled = false;
|
||||
/** 单批派发条数 */
|
||||
private int batchSize = 200;
|
||||
/** 自动重试上限,超过置 DEAD */
|
||||
private int maxRetry = 5;
|
||||
/** #14 心跳时钟偏差告警阈值(毫秒),验收口径 ≤5s */
|
||||
private long clockSkewThresholdMs = 5000;
|
||||
/** 设备无心跳超过该秒数视为离线(不据此伪造 ACKED) */
|
||||
private int heartbeatTimeoutSeconds = 90;
|
||||
/** 单次续报批次上限 */
|
||||
private int resumeBatchLimit = 200;
|
||||
|
||||
public boolean isDispatchEnabled()
|
||||
{
|
||||
return dispatchEnabled;
|
||||
}
|
||||
|
||||
public void setDispatchEnabled(boolean dispatchEnabled)
|
||||
{
|
||||
this.dispatchEnabled = dispatchEnabled;
|
||||
}
|
||||
|
||||
public int getBatchSize()
|
||||
{
|
||||
return batchSize;
|
||||
}
|
||||
|
||||
public void setBatchSize(int batchSize)
|
||||
{
|
||||
this.batchSize = batchSize;
|
||||
}
|
||||
|
||||
public int getMaxRetry()
|
||||
{
|
||||
return maxRetry;
|
||||
}
|
||||
|
||||
public void setMaxRetry(int maxRetry)
|
||||
{
|
||||
this.maxRetry = maxRetry;
|
||||
}
|
||||
|
||||
public long getClockSkewThresholdMs()
|
||||
{
|
||||
return clockSkewThresholdMs;
|
||||
}
|
||||
|
||||
public void setClockSkewThresholdMs(long clockSkewThresholdMs)
|
||||
{
|
||||
this.clockSkewThresholdMs = clockSkewThresholdMs;
|
||||
}
|
||||
|
||||
public int getHeartbeatTimeoutSeconds()
|
||||
{
|
||||
return heartbeatTimeoutSeconds;
|
||||
}
|
||||
|
||||
public void setHeartbeatTimeoutSeconds(int heartbeatTimeoutSeconds)
|
||||
{
|
||||
this.heartbeatTimeoutSeconds = heartbeatTimeoutSeconds;
|
||||
}
|
||||
|
||||
public int getResumeBatchLimit()
|
||||
{
|
||||
return resumeBatchLimit;
|
||||
}
|
||||
|
||||
public void setResumeBatchLimit(int resumeBatchLimit)
|
||||
{
|
||||
this.resumeBatchLimit = resumeBatchLimit;
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.ruoyi.apt.job.controller;
|
||||
|
||||
import com.ruoyi.apt.common.audit.AptAudit;
|
||||
import com.ruoyi.apt.job.domain.AptStaffCommand;
|
||||
import com.ruoyi.apt.job.service.IAptStaffService;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 作业人员管理(保洁 / 维修)。
|
||||
*
|
||||
* <p>人员与权限的关系:{@code sys_user_id} 关联后台账号(决定其可登录的菜单/权限点),
|
||||
* {@code device_code} 绑定平板(平板接口据此识别执行人);停用即下线,删除为软删并解绑。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/apt/staff", produces = "application/json;charset=UTF-8")
|
||||
public class AptStaffController
|
||||
{
|
||||
private final IAptStaffService staffService;
|
||||
|
||||
public AptStaffController(IAptStaffService staffService)
|
||||
{
|
||||
this.staffService = staffService;
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:staff:list')")
|
||||
@GetMapping("/list")
|
||||
public AjaxResult list(@RequestParam(required = false) String staffType,
|
||||
@RequestParam(required = false) String state,
|
||||
@RequestParam(required = false) String keyword)
|
||||
{
|
||||
return AjaxResult.success(staffService.list(staffType, state, keyword));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:staff:query')")
|
||||
@GetMapping("/{staffId}")
|
||||
public AjaxResult detail(@PathVariable String staffId)
|
||||
{
|
||||
return AjaxResult.success(staffService.detail(staffId));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:staff:add')")
|
||||
@AptAudit(module = "STAFF", action = "STAFF_ADD")
|
||||
@PostMapping
|
||||
public AjaxResult add(@Valid @RequestBody AptStaffCommand command)
|
||||
{
|
||||
return AjaxResult.success(staffService.create(command));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:staff:edit')")
|
||||
@AptAudit(module = "STAFF", action = "STAFF_EDIT")
|
||||
@PutMapping("/{staffId}")
|
||||
public AjaxResult edit(@PathVariable String staffId, @Valid @RequestBody AptStaffCommand command)
|
||||
{
|
||||
return AjaxResult.success(staffService.update(staffId, command));
|
||||
}
|
||||
|
||||
/** 启停用:DISABLED 同时把在岗态置 OFFLINE,平板端立即失效 */
|
||||
@PreAuthorize("@ss.hasPermi('apt:staff:changeStatus')")
|
||||
@AptAudit(module = "STAFF", action = "STAFF_STATE")
|
||||
@PostMapping("/{staffId}/state")
|
||||
public AjaxResult changeState(@PathVariable String staffId,
|
||||
@RequestBody AptStaffCommand command)
|
||||
{
|
||||
return AjaxResult.success(staffService.changeState(staffId, command.getState()));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:staff:remove')")
|
||||
@AptAudit(module = "STAFF", action = "STAFF_DELETE")
|
||||
@DeleteMapping("/{staffId}")
|
||||
public AjaxResult remove(@PathVariable String staffId)
|
||||
{
|
||||
staffService.delete(staffId);
|
||||
return AjaxResult.success();
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package com.ruoyi.apt.job.controller;
|
||||
|
||||
import java.util.Locale;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.job.domain.AptJobCommand;
|
||||
import com.ruoyi.apt.job.domain.AptJobQuery;
|
||||
import com.ruoyi.apt.job.service.IAptJobService;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 平板作业接口(保洁 / 维修),挂在 {@code /open/v1/tablet/jobs} 下。
|
||||
*
|
||||
* <p>鉴权:{@code /open/v1/**} 由设备四要素拦截器校验;执行人由设备编码反查
|
||||
* {@code apt_staff.device_code}(未绑定返回 40301 STAFF_NOT_BOUND),请求体不接受 gyid/staffId。</p>
|
||||
*
|
||||
* <p>保洁:上报 → 开始 → 结束;维修:上报 → (值班台派单)→ 开始 → 完成。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/open/v1/tablet/jobs", produces = "application/json;charset=UTF-8")
|
||||
public class AptTabletJobController
|
||||
{
|
||||
private final IAptJobService jobService;
|
||||
|
||||
public AptTabletJobController(IAptJobService jobService)
|
||||
{
|
||||
this.jobService = jobService;
|
||||
}
|
||||
|
||||
/** 我的作业列表:type=CLEAN/REPAIR(空=全部),state 可选 */
|
||||
@GetMapping
|
||||
public AjaxResult list(
|
||||
@RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(required = false) String state,
|
||||
@RequestParam(required = false, defaultValue = "50") Integer limit)
|
||||
{
|
||||
AptJobQuery query = new AptJobQuery();
|
||||
query.setState(state);
|
||||
query.setLimit(limit);
|
||||
return AjaxResult.success(jobService.listMine(requireDevice(deviceCode), type, query));
|
||||
}
|
||||
|
||||
/** 上报待清扫(房态 → 3 待清扫;重复上报返回已有任务) */
|
||||
@PostMapping("/clean")
|
||||
public AjaxResult reportClean(
|
||||
@RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
|
||||
@Valid @RequestBody AptJobCommand command)
|
||||
{
|
||||
return AjaxResult.success(jobService.reportClean(command, requireDevice(deviceCode)));
|
||||
}
|
||||
|
||||
/** 开始保洁(PENDING → PROCESSING,房态 → 6 保洁中) */
|
||||
@PostMapping("/clean/{taskId}/start")
|
||||
public AjaxResult startClean(
|
||||
@RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
|
||||
@PathVariable String taskId,
|
||||
@RequestBody(required = false) AptJobCommand command)
|
||||
{
|
||||
return AjaxResult.success(jobService.startClean(taskId, command, requireDevice(deviceCode)));
|
||||
}
|
||||
|
||||
/** 结束保洁(PROCESSING → DONE,房态重算 → 1/2/5) */
|
||||
@PostMapping("/clean/{taskId}/finish")
|
||||
public AjaxResult finishClean(
|
||||
@RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
|
||||
@PathVariable String taskId,
|
||||
@RequestBody(required = false) AptJobCommand command)
|
||||
{
|
||||
return AjaxResult.success(jobService.finishClean(taskId, command, requireDevice(deviceCode)));
|
||||
}
|
||||
|
||||
/** 上报维修(房态 → 4 不能入住;同房已有未完成工单则返回该单) */
|
||||
@PostMapping("/repair")
|
||||
public AjaxResult reportRepair(
|
||||
@RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
|
||||
@Valid @RequestBody AptJobCommand command)
|
||||
{
|
||||
return AjaxResult.success(jobService.reportRepair(command, requireDevice(deviceCode)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键报修(房间平板「维修」按钮)。
|
||||
*
|
||||
* <p>只需房号;类别固定 OTHER、优先级固定 NORMAL,描述缺省「普通维修(平板一键报修)」。
|
||||
* 同房已有未完成工单时返回该单并标记 duplicated=true,不会重复建单。</p>
|
||||
*/
|
||||
@PostMapping("/repair/quick")
|
||||
public AjaxResult quickRepair(
|
||||
@RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
|
||||
@RequestBody(required = false) AptJobCommand command)
|
||||
{
|
||||
AptJobCommand cmd = command == null ? new AptJobCommand() : command;
|
||||
cmd.setCategory("OTHER");
|
||||
cmd.setPriority("NORMAL");
|
||||
if (cmd.getDescription() == null || cmd.getDescription().isBlank())
|
||||
{
|
||||
cmd.setDescription("普通维修(平板一键报修)");
|
||||
}
|
||||
return AjaxResult.success(jobService.reportRepair(cmd, requireDevice(deviceCode)));
|
||||
}
|
||||
|
||||
/** 开始维修(REPORTED/ASSIGNED → IN_PROGRESS) */
|
||||
@PostMapping("/repair/{orderId}/start")
|
||||
public AjaxResult startRepair(
|
||||
@RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
|
||||
@PathVariable String orderId,
|
||||
@RequestBody(required = false) AptJobCommand command)
|
||||
{
|
||||
return AjaxResult.success(jobService.startRepair(orderId, command, requireDevice(deviceCode)));
|
||||
}
|
||||
|
||||
/** 完成维修(IN_PROGRESS → DONE,result 必填;房态重算) */
|
||||
@PostMapping("/repair/{orderId}/finish")
|
||||
public AjaxResult finishRepair(
|
||||
@RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
|
||||
@PathVariable String orderId,
|
||||
@RequestBody AptJobCommand command)
|
||||
{
|
||||
return AjaxResult.success(jobService.finishRepair(orderId, command, requireDevice(deviceCode)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键完成维修(房间平板提交结束状态)。
|
||||
*
|
||||
* <p>与 /finish 的区别:result 可省略,缺省「已修复(平板提交)」;
|
||||
* 仍走 IN_PROGRESS → DONE 条件更新,重复提交按幂等返回 duplicated=true。</p>
|
||||
*/
|
||||
@PostMapping("/repair/{orderId}/complete")
|
||||
public AjaxResult completeRepair(
|
||||
@RequestHeader(value = "X-Device-Code", required = false) String deviceCode,
|
||||
@PathVariable String orderId,
|
||||
@RequestBody(required = false) AptJobCommand command)
|
||||
{
|
||||
AptJobCommand cmd = command == null ? new AptJobCommand() : command;
|
||||
if (cmd.getResult() == null || cmd.getResult().isBlank())
|
||||
{
|
||||
cmd.setResult("已修复(平板提交)");
|
||||
}
|
||||
return AjaxResult.success(jobService.finishRepair(orderId, cmd, requireDevice(deviceCode)));
|
||||
}
|
||||
|
||||
private String requireDevice(String deviceCode)
|
||||
{
|
||||
if (deviceCode == null || deviceCode.isBlank())
|
||||
{
|
||||
throw new AptException(AptErrorCode.UNAUTHORIZED, "DEVICE_CREDENTIAL_MISSING", "缺少设备凭据 X-Device-Code");
|
||||
}
|
||||
return deviceCode.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.ruoyi.apt.job.controller;
|
||||
|
||||
import java.util.Date;
|
||||
import com.ruoyi.apt.common.audit.AptAudit;
|
||||
import com.ruoyi.apt.job.domain.AptJobCommand;
|
||||
import com.ruoyi.apt.job.domain.AptJobQuery;
|
||||
import com.ruoyi.apt.job.service.IAptJobService;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 作业工单(保洁 / 维修)管理与统计,值班台/后台使用。
|
||||
*
|
||||
* <p>平板侧的上报与执行在 {@code /open/v1/tablet/jobs};本控制器只做查询、派单、取消与统计,
|
||||
* 状态推进仍由作业人员在平板上完成,避免后台越权改状态。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/apt/workorder", produces = "application/json;charset=UTF-8")
|
||||
public class AptWorkOrderController
|
||||
{
|
||||
private final IAptJobService jobService;
|
||||
|
||||
public AptWorkOrderController(IAptJobService jobService)
|
||||
{
|
||||
this.jobService = jobService;
|
||||
}
|
||||
|
||||
/** 工单列表:type/state/roomNo/staffId/时间区间,按业务时间倒序 */
|
||||
@PreAuthorize("@ss.hasPermi('apt:workorder:list')")
|
||||
@GetMapping("/list")
|
||||
public AjaxResult list(
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(required = false) String state,
|
||||
@RequestParam(required = false) String roomNo,
|
||||
@RequestParam(required = false) String staffId,
|
||||
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date from,
|
||||
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date to,
|
||||
@RequestParam(required = false, defaultValue = "100") Integer limit)
|
||||
{
|
||||
return AjaxResult.success(jobService.list(type, query(state, roomNo, staffId, from, to, limit)));
|
||||
}
|
||||
|
||||
/** 统计:总量/完成/在办、按类型+状态分布、人员工作量(含平均时长) */
|
||||
@PreAuthorize("@ss.hasPermi('apt:workorder:stats')")
|
||||
@GetMapping("/stats")
|
||||
public AjaxResult stats(
|
||||
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date from,
|
||||
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date to)
|
||||
{
|
||||
return AjaxResult.success(jobService.stats(query(null, null, null, from, to, null)));
|
||||
}
|
||||
|
||||
/** 工单详情(含状态流水):type=CLEAN/REPAIR */
|
||||
@PreAuthorize("@ss.hasPermi('apt:workorder:query')")
|
||||
@GetMapping("/{type}/{jobId}")
|
||||
public AjaxResult detail(@PathVariable String type, @PathVariable String jobId)
|
||||
{
|
||||
return AjaxResult.success(jobService.detail(type, jobId));
|
||||
}
|
||||
|
||||
/** 维修派单/改派 */
|
||||
@PreAuthorize("@ss.hasPermi('apt:workorder:assign')")
|
||||
@AptAudit(module = "JOB", action = "REPAIR_ASSIGN")
|
||||
@PostMapping("/repair/{orderId}/assign")
|
||||
public AjaxResult assign(@PathVariable String orderId, @RequestBody AptJobCommand command)
|
||||
{
|
||||
return AjaxResult.success(jobService.assignRepair(orderId, command.getStaffId()));
|
||||
}
|
||||
|
||||
/** 取消工单(误报/重复报修),房态随之重算 */
|
||||
@PreAuthorize("@ss.hasPermi('apt:workorder:handle')")
|
||||
@AptAudit(module = "JOB", action = "REPAIR_CANCEL")
|
||||
@PostMapping("/repair/{orderId}/cancel")
|
||||
public AjaxResult cancel(@PathVariable String orderId, @RequestBody AptJobCommand command)
|
||||
{
|
||||
return AjaxResult.success(jobService.cancelRepair(orderId, command.getRemark()));
|
||||
}
|
||||
|
||||
private AptJobQuery query(String state, String roomNo, String staffId, Date from, Date to, Integer limit)
|
||||
{
|
||||
AptJobQuery query = new AptJobQuery();
|
||||
query.setState(state);
|
||||
query.setRoomNo(roomNo);
|
||||
query.setStaffId(staffId);
|
||||
query.setFrom(from);
|
||||
query.setTo(to);
|
||||
if (limit != null)
|
||||
{
|
||||
query.setLimit(limit);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package com.ruoyi.apt.job.domain;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 保洁 / 维修作业命令(平板侧与值班台侧共用,按接口取用字段)。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptJobCommand
|
||||
{
|
||||
/** 房号(上报时必填) */
|
||||
@Size(max = 32, message = "房号长度不能超过 32")
|
||||
private String roomNo;
|
||||
|
||||
/** 保洁任务号(开始/结束时使用) */
|
||||
@Size(max = 64)
|
||||
private String taskId;
|
||||
|
||||
/** 维修工单号(派单/开始/结束/取消时使用) */
|
||||
@Size(max = 64)
|
||||
private String orderId;
|
||||
|
||||
/** 维修类别:ELECTRIC / PLUMBING / DOOR_WINDOW / AIRCON / OTHER */
|
||||
@Size(max = 20)
|
||||
private String category;
|
||||
|
||||
/** 故障描述(维修上报必填) */
|
||||
@Size(max = 500)
|
||||
private String description;
|
||||
|
||||
/** 优先级:NORMAL / URGENT */
|
||||
@Size(max = 10)
|
||||
private String priority;
|
||||
|
||||
/** 维修结果(完成时必填) */
|
||||
@Size(max = 255)
|
||||
private String result;
|
||||
|
||||
/** 备注(结束保洁 / 取消工单等) */
|
||||
@Size(max = 500)
|
||||
private String remark;
|
||||
|
||||
/** 指派人员(值班台派单时使用) */
|
||||
@Size(max = 64)
|
||||
private String staffId;
|
||||
|
||||
public String getRoomNo()
|
||||
{
|
||||
return roomNo;
|
||||
}
|
||||
|
||||
public void setRoomNo(String roomNo)
|
||||
{
|
||||
this.roomNo = roomNo;
|
||||
}
|
||||
|
||||
public String getTaskId()
|
||||
{
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public void setTaskId(String taskId)
|
||||
{
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
public String getOrderId()
|
||||
{
|
||||
return orderId;
|
||||
}
|
||||
|
||||
public void setOrderId(String orderId)
|
||||
{
|
||||
this.orderId = orderId;
|
||||
}
|
||||
|
||||
public String getCategory()
|
||||
{
|
||||
return category;
|
||||
}
|
||||
|
||||
public void setCategory(String category)
|
||||
{
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
public String getDescription()
|
||||
{
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description)
|
||||
{
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getPriority()
|
||||
{
|
||||
return priority;
|
||||
}
|
||||
|
||||
public void setPriority(String priority)
|
||||
{
|
||||
this.priority = priority;
|
||||
}
|
||||
|
||||
public String getResult()
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
public void setResult(String result)
|
||||
{
|
||||
this.result = result;
|
||||
}
|
||||
|
||||
public String getRemark()
|
||||
{
|
||||
return remark;
|
||||
}
|
||||
|
||||
public void setRemark(String remark)
|
||||
{
|
||||
this.remark = remark;
|
||||
}
|
||||
|
||||
public String getStaffId()
|
||||
{
|
||||
return staffId;
|
||||
}
|
||||
|
||||
public void setStaffId(String staffId)
|
||||
{
|
||||
this.staffId = staffId;
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.ruoyi.apt.job.domain;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 作业工单查询条件(管理端列表 / 统计共用)。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptJobQuery
|
||||
{
|
||||
/** CLEAN / REPAIR,空=两类都查 */
|
||||
private String type;
|
||||
|
||||
/** 工单状态,空=全部 */
|
||||
private String state;
|
||||
|
||||
private String roomNo;
|
||||
|
||||
private String staffId;
|
||||
|
||||
/** 统计区间起(含) */
|
||||
private Date from;
|
||||
|
||||
/** 统计区间止(含) */
|
||||
private Date to;
|
||||
|
||||
private Integer limit = 100;
|
||||
|
||||
/**
|
||||
* 平板「我的作业」是否带出未分配公共池(保洁 PENDING / 维修 REPORTED)。
|
||||
* 管理端恒为 false,只看指定执行人。
|
||||
*/
|
||||
private Boolean includeUnassigned = Boolean.FALSE;
|
||||
|
||||
public String getType()
|
||||
{
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type)
|
||||
{
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getState()
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
public void setState(String state)
|
||||
{
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
public String getRoomNo()
|
||||
{
|
||||
return roomNo;
|
||||
}
|
||||
|
||||
public void setRoomNo(String roomNo)
|
||||
{
|
||||
this.roomNo = roomNo;
|
||||
}
|
||||
|
||||
public String getStaffId()
|
||||
{
|
||||
return staffId;
|
||||
}
|
||||
|
||||
public void setStaffId(String staffId)
|
||||
{
|
||||
this.staffId = staffId;
|
||||
}
|
||||
|
||||
public Date getFrom()
|
||||
{
|
||||
return from;
|
||||
}
|
||||
|
||||
public void setFrom(Date from)
|
||||
{
|
||||
this.from = from;
|
||||
}
|
||||
|
||||
public Date getTo()
|
||||
{
|
||||
return to;
|
||||
}
|
||||
|
||||
public void setTo(Date to)
|
||||
{
|
||||
this.to = to;
|
||||
}
|
||||
|
||||
public Integer getLimit()
|
||||
{
|
||||
return limit;
|
||||
}
|
||||
|
||||
public void setLimit(Integer limit)
|
||||
{
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
public Boolean getIncludeUnassigned()
|
||||
{
|
||||
return includeUnassigned;
|
||||
}
|
||||
|
||||
public void setIncludeUnassigned(Boolean includeUnassigned)
|
||||
{
|
||||
this.includeUnassigned = includeUnassigned;
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package com.ruoyi.apt.job.domain;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 作业人员维护命令(保洁 / 维修)。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptStaffCommand
|
||||
{
|
||||
/** 人员业务键,空则由后台生成(编辑时必填) */
|
||||
@Size(max = 64)
|
||||
private String staffId;
|
||||
|
||||
/** CLEANER / REPAIRMAN / BOTH */
|
||||
@NotBlank(message = "staffType 不能为空")
|
||||
@Size(max = 20)
|
||||
private String staffType;
|
||||
|
||||
@NotBlank(message = "姓名不能为空")
|
||||
@Size(max = 64)
|
||||
private String name;
|
||||
|
||||
@Size(max = 32)
|
||||
private String workNo;
|
||||
|
||||
@Size(max = 32)
|
||||
private String phone;
|
||||
|
||||
/** 绑定后台账号,用于权限归属(可空) */
|
||||
private Long sysUserId;
|
||||
|
||||
/** 绑定平板设备编码(可空,绑定后平板免登识别人员) */
|
||||
@Size(max = 64)
|
||||
private String deviceCode;
|
||||
|
||||
/** ACTIVE / DISABLED(启停用) */
|
||||
@Size(max = 20)
|
||||
private String state;
|
||||
|
||||
@Size(max = 255)
|
||||
private String remark;
|
||||
|
||||
public String getStaffId()
|
||||
{
|
||||
return staffId;
|
||||
}
|
||||
|
||||
public void setStaffId(String staffId)
|
||||
{
|
||||
this.staffId = staffId;
|
||||
}
|
||||
|
||||
public String getStaffType()
|
||||
{
|
||||
return staffType;
|
||||
}
|
||||
|
||||
public void setStaffType(String staffType)
|
||||
{
|
||||
this.staffType = staffType;
|
||||
}
|
||||
|
||||
public String getName()
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name)
|
||||
{
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getWorkNo()
|
||||
{
|
||||
return workNo;
|
||||
}
|
||||
|
||||
public void setWorkNo(String workNo)
|
||||
{
|
||||
this.workNo = workNo;
|
||||
}
|
||||
|
||||
public String getPhone()
|
||||
{
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone)
|
||||
{
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public Long getSysUserId()
|
||||
{
|
||||
return sysUserId;
|
||||
}
|
||||
|
||||
public void setSysUserId(Long sysUserId)
|
||||
{
|
||||
this.sysUserId = sysUserId;
|
||||
}
|
||||
|
||||
public String getDeviceCode()
|
||||
{
|
||||
return deviceCode;
|
||||
}
|
||||
|
||||
public void setDeviceCode(String deviceCode)
|
||||
{
|
||||
this.deviceCode = deviceCode;
|
||||
}
|
||||
|
||||
public String getState()
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
public void setState(String state)
|
||||
{
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
public String getRemark()
|
||||
{
|
||||
return remark;
|
||||
}
|
||||
|
||||
public void setRemark(String remark)
|
||||
{
|
||||
this.remark = remark;
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.ruoyi.apt.job.mapper;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.job.domain.AptJobQuery;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 平板作业(保洁 / 维修)与作业人员数据访问。
|
||||
*
|
||||
* <p>硬约束:每条 SQL 必带 {@code gyid};状态迁移全部用「条件更新 + 影响行数」表达,
|
||||
* 冲突由服务层翻译成 40902;本 Mapper 不写 {@code gy_jcxx}。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface AptJobMapper
|
||||
{
|
||||
/** 按房号定位房间运行时记录(房态联动用) */
|
||||
Map<String, Object> selectRoomByNo(@Param("gyid") String gyid, @Param("roomNo") String roomNo);
|
||||
|
||||
// ------------------------------------------------------------- 保洁任务
|
||||
Map<String, Object> selectCleanTask(@Param("gyid") String gyid, @Param("taskId") String taskId);
|
||||
|
||||
Map<String, Object> selectActiveCleanTaskByRoom(@Param("gyid") String gyid, @Param("roomId") String roomId);
|
||||
|
||||
int insertCleanTask(@Param("gyid") String gyid, @Param("taskId") String taskId,
|
||||
@Param("roomId") String roomId, @Param("roomNo") String roomNo,
|
||||
@Param("staffId") String staffId, @Param("remark") String remark,
|
||||
@Param("scheduledAt") Date scheduledAt,
|
||||
@Param("source") String source);
|
||||
|
||||
/**
|
||||
* 按 {@code gy_jcxx} 在寓事实重算房间在寓人数(finish_ly='0' AND sjlysj IS NULL)。
|
||||
* 离寓后调用,避免只靠 +1/-1 造成长期偏差。
|
||||
*/
|
||||
int syncRoomOccupancy(@Param("gyid") String gyid, @Param("roomNo") String roomNo);
|
||||
|
||||
/** 房间有效在寓人数(离寓判定的唯一口径) */
|
||||
int countLiveStayByRoom(@Param("gyid") String gyid, @Param("roomNo") String roomNo);
|
||||
|
||||
/** PENDING -> PROCESSING,影响行数 0 表示状态已变(幂等/冲突由服务层判断) */
|
||||
int startCleanTask(@Param("gyid") String gyid, @Param("taskId") String taskId,
|
||||
@Param("staffId") String staffId, @Param("remark") String remark);
|
||||
|
||||
/** PROCESSING -> DONE,记录 finished_at */
|
||||
int finishCleanTask(@Param("gyid") String gyid, @Param("taskId") String taskId,
|
||||
@Param("remark") String remark);
|
||||
|
||||
List<Map<String, Object>> selectCleanList(@Param("gyid") String gyid,
|
||||
@Param("q") AptJobQuery query);
|
||||
|
||||
// ------------------------------------------------------------- 维修工单
|
||||
Map<String, Object> selectRepairOrder(@Param("gyid") String gyid, @Param("orderId") String orderId);
|
||||
|
||||
Map<String, Object> selectActiveRepairByRoom(@Param("gyid") String gyid, @Param("roomId") String roomId);
|
||||
|
||||
int countActiveRepairByRoom(@Param("gyid") String gyid, @Param("roomId") String roomId);
|
||||
|
||||
int insertRepairOrder(@Param("gyid") String gyid, @Param("orderId") String orderId,
|
||||
@Param("roomId") String roomId, @Param("roomNo") String roomNo,
|
||||
@Param("category") String category, @Param("description") String description,
|
||||
@Param("priority") String priority, @Param("source") String source,
|
||||
@Param("reporterStaffId") String reporterStaffId,
|
||||
@Param("reporterName") String reporterName,
|
||||
@Param("roomStatusBefore") String roomStatusBefore);
|
||||
|
||||
/** REPORTED/ASSIGNED -> ASSIGNED(允许改派) */
|
||||
int assignRepair(@Param("gyid") String gyid, @Param("orderId") String orderId,
|
||||
@Param("staffId") String staffId, @Param("staffName") String staffName);
|
||||
|
||||
/** REPORTED/ASSIGNED -> IN_PROGRESS */
|
||||
int startRepair(@Param("gyid") String gyid, @Param("orderId") String orderId,
|
||||
@Param("staffId") String staffId, @Param("staffName") String staffName);
|
||||
|
||||
/** IN_PROGRESS -> DONE,记录结果、时长与完成时间 */
|
||||
int finishRepair(@Param("gyid") String gyid, @Param("orderId") String orderId,
|
||||
@Param("result") String result, @Param("remark") String remark);
|
||||
|
||||
/** 非终态 -> CANCELLED */
|
||||
int cancelRepair(@Param("gyid") String gyid, @Param("orderId") String orderId,
|
||||
@Param("remark") String remark);
|
||||
|
||||
/** 完成后回写房间最终房态,便于追溯(不参与状态判定) */
|
||||
int updateRepairRoomAfter(@Param("gyid") String gyid, @Param("orderId") String orderId,
|
||||
@Param("roomStatusAfter") String roomStatusAfter);
|
||||
|
||||
List<Map<String, Object>> selectRepairList(@Param("gyid") String gyid,
|
||||
@Param("q") AptJobQuery query);
|
||||
|
||||
// --------------------------------------------------------------- 统计
|
||||
Map<String, Object> selectJobStats(@Param("gyid") String gyid,
|
||||
@Param("from") Date from, @Param("to") Date to);
|
||||
|
||||
List<Map<String, Object>> selectJobStatsByType(@Param("gyid") String gyid,
|
||||
@Param("from") Date from, @Param("to") Date to);
|
||||
|
||||
List<Map<String, Object>> selectStaffWorkload(@Param("gyid") String gyid,
|
||||
@Param("from") Date from, @Param("to") Date to);
|
||||
|
||||
// ------------------------------------------------------------- 状态流水
|
||||
int insertJobEvent(@Param("gyid") String gyid, @Param("eventId") String eventId,
|
||||
@Param("bizType") String bizType, @Param("bizId") String bizId,
|
||||
@Param("roomId") String roomId, @Param("fromState") String fromState,
|
||||
@Param("toState") String toState, @Param("operatorStaff") String operatorStaff,
|
||||
@Param("operatorName") String operatorName,
|
||||
@Param("deviceCode") String deviceCode, @Param("remark") String remark);
|
||||
|
||||
List<Map<String, Object>> selectJobEvents(@Param("gyid") String gyid,
|
||||
@Param("bizType") String bizType,
|
||||
@Param("bizId") String bizId);
|
||||
|
||||
// --------------------------------------------------------------- 人员
|
||||
Map<String, Object> selectStaff(@Param("gyid") String gyid, @Param("staffId") String staffId);
|
||||
|
||||
Map<String, Object> selectStaffByDevice(@Param("gyid") String gyid,
|
||||
@Param("deviceCode") String deviceCode);
|
||||
|
||||
List<Map<String, Object>> selectStaffList(@Param("gyid") String gyid,
|
||||
@Param("staffType") String staffType,
|
||||
@Param("state") String state,
|
||||
@Param("keyword") String keyword);
|
||||
|
||||
int countStaffWorkNo(@Param("gyid") String gyid, @Param("workNo") String workNo,
|
||||
@Param("excludeStaffId") String excludeStaffId);
|
||||
|
||||
int insertStaff(@Param("gyid") String gyid, @Param("staffId") String staffId,
|
||||
@Param("staffType") String staffType, @Param("name") String name,
|
||||
@Param("workNo") String workNo, @Param("phone") String phone,
|
||||
@Param("sysUserId") Long sysUserId, @Param("deviceCode") String deviceCode,
|
||||
@Param("remark") String remark, @Param("createBy") String createBy);
|
||||
|
||||
int updateStaff(@Param("gyid") String gyid, @Param("staffId") String staffId,
|
||||
@Param("staffType") String staffType, @Param("name") String name,
|
||||
@Param("workNo") String workNo, @Param("phone") String phone,
|
||||
@Param("sysUserId") Long sysUserId, @Param("deviceCode") String deviceCode,
|
||||
@Param("remark") String remark);
|
||||
|
||||
int updateStaffState(@Param("gyid") String gyid, @Param("staffId") String staffId,
|
||||
@Param("state") String state);
|
||||
|
||||
int softDeleteStaff(@Param("gyid") String gyid, @Param("staffId") String staffId);
|
||||
}
|
||||
+756
@@ -0,0 +1,756 @@
|
||||
package com.ruoyi.apt.job.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.framework.audit.AptAuditWriter;
|
||||
import com.ruoyi.apt.job.domain.AptJobCommand;
|
||||
import com.ruoyi.apt.job.domain.AptJobQuery;
|
||||
import com.ruoyi.apt.job.mapper.AptJobMapper;
|
||||
import com.ruoyi.apt.ops.domain.AptOperationLog;
|
||||
import com.ruoyi.apt.stay.domain.AptRoom;
|
||||
import com.ruoyi.apt.stay.mapper.AptRoomMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 保洁 / 维修作业实现。
|
||||
*
|
||||
* <p>房态联动口径(公寓端/04 表 4-7):</p>
|
||||
* <pre>
|
||||
* 3 待清扫 ← 保洁任务 PENDING
|
||||
* 6 保洁中 ← 保洁任务 PROCESSING
|
||||
* 4 不能入住 ← 存在未完成维修单(disabled_reason=REPAIR:<orderId>)
|
||||
* 1/2/5 ← 无上述情况时按在寓人数与床位数重算
|
||||
* </pre>
|
||||
*
|
||||
* <p>所有状态迁移用条件更新,重复提交按幂等返回 duplicated=true,非法迁移返回 40902。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class AptJobServiceImpl implements IAptJobService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AptJobServiceImpl.class);
|
||||
|
||||
private static final String CLEAN = "CLEAN";
|
||||
private static final String REPAIR = "REPAIR";
|
||||
|
||||
private static final String PENDING = "PENDING";
|
||||
private static final String PROCESSING = "PROCESSING";
|
||||
private static final String DONE = "DONE";
|
||||
private static final String REPORTED = "REPORTED";
|
||||
private static final String ASSIGNED = "ASSIGNED";
|
||||
private static final String IN_PROGRESS = "IN_PROGRESS";
|
||||
private static final String CANCELLED = "CANCELLED";
|
||||
|
||||
private static final String STATUS_IDLE = "1";
|
||||
private static final String STATUS_FULL = "2";
|
||||
private static final String STATUS_TO_CLEAN = "3";
|
||||
private static final String STATUS_BLOCKED = "4";
|
||||
private static final String STATUS_PARTIAL = "5";
|
||||
private static final String STATUS_CLEANING = "6";
|
||||
|
||||
private final AptJobMapper jobMapper;
|
||||
private final AptRoomMapper roomMapper;
|
||||
private final AptAuditWriter auditWriter;
|
||||
|
||||
public AptJobServiceImpl(AptJobMapper jobMapper, AptRoomMapper roomMapper, AptAuditWriter auditWriter)
|
||||
{
|
||||
this.jobMapper = jobMapper;
|
||||
this.roomMapper = roomMapper;
|
||||
this.auditWriter = auditWriter;
|
||||
}
|
||||
|
||||
// ================================================================== 保洁
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> reportClean(AptJobCommand command, String deviceCode)
|
||||
{
|
||||
Map<String, Object> staff = requireStaff(deviceCode);
|
||||
AptRoom room = requireRoom(command == null ? null : command.getRoomNo());
|
||||
Map<String, Object> active = jobMapper.selectActiveCleanTaskByRoom(gyid(), room.getRoomId());
|
||||
if (active != null && !active.isEmpty())
|
||||
{
|
||||
// 平板重试/重复上报:返回已有任务,不再新建,避免同房多条待清扫
|
||||
Map<String, Object> result = new LinkedHashMap<>(active);
|
||||
result.put("duplicated", true);
|
||||
return result;
|
||||
}
|
||||
String taskId = "CT-" + suffix();
|
||||
try
|
||||
{
|
||||
jobMapper.insertCleanTask(gyid(), taskId, room.getRoomId(), room.getRoomNo(),
|
||||
text(staff.get("staffId")), command == null ? null : command.getRemark(),
|
||||
new Date(), "TABLET");
|
||||
}
|
||||
catch (DuplicateKeyException e)
|
||||
{
|
||||
// 并发上报 / 自动建单同时命中:唯一键保证同房只有一条活跃任务,返回已存在的那条
|
||||
Map<String, Object> created = jobMapper.selectActiveCleanTaskByRoom(gyid(), room.getRoomId());
|
||||
Map<String, Object> result = new LinkedHashMap<>(created == null ? Map.of() : created);
|
||||
result.put("duplicated", true);
|
||||
return result;
|
||||
}
|
||||
event(CLEAN, taskId, room.getRoomId(), null, PENDING, staff, deviceCode, command);
|
||||
String roomStatus = applyRoomStatus(room, STATUS_TO_CLEAN, null);
|
||||
log.info("保洁上报:taskId={}, room={}, staff={}", taskId, room.getRoomNo(), staff.get("staffId"));
|
||||
return result(taskId, room, roomStatus, PENDING, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> startClean(String taskId, AptJobCommand command, String deviceCode)
|
||||
{
|
||||
Map<String, Object> staff = requireStaff(deviceCode);
|
||||
Map<String, Object> task = requireCleanTask(taskId);
|
||||
int affected = jobMapper.startCleanTask(gyid(), taskId, text(staff.get("staffId")),
|
||||
command == null ? null : command.getRemark());
|
||||
if (affected == 0)
|
||||
{
|
||||
Map<String, Object> current = jobMapper.selectCleanTask(gyid(), taskId);
|
||||
if (current != null && PROCESSING.equals(text(current.get("state"))))
|
||||
{
|
||||
return result(taskId, roomOf(current), text(roomOf(current).getRoomStatus()), PROCESSING, true);
|
||||
}
|
||||
throw stateIllegal("仅待清扫任务可开始保洁(当前 " + text(current == null ? null : current.get("state")) + ")");
|
||||
}
|
||||
event(CLEAN, taskId, text(task.get("roomId")), PENDING, PROCESSING, staff, deviceCode, command);
|
||||
AptRoom room = roomById(text(task.get("roomId")));
|
||||
String roomStatus = applyRoomStatus(room, STATUS_CLEANING, null);
|
||||
log.info("保洁开始:taskId={}, room={}, staff={}", taskId, room.getRoomNo(), staff.get("staffId"));
|
||||
return result(taskId, room, roomStatus, PROCESSING, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> finishClean(String taskId, AptJobCommand command, String deviceCode)
|
||||
{
|
||||
Map<String, Object> staff = requireStaff(deviceCode);
|
||||
Map<String, Object> task = requireCleanTask(taskId);
|
||||
int affected = jobMapper.finishCleanTask(gyid(), taskId, command == null ? null : command.getRemark());
|
||||
if (affected == 0)
|
||||
{
|
||||
Map<String, Object> current = jobMapper.selectCleanTask(gyid(), taskId);
|
||||
if (current != null && DONE.equals(text(current.get("state"))))
|
||||
{
|
||||
AptRoom room = roomById(text(current.get("roomId")));
|
||||
return result(taskId, room, text(room.getRoomStatus()), DONE, true);
|
||||
}
|
||||
throw stateIllegal("仅保洁中的任务可结束(当前 " + text(current == null ? null : current.get("state")) + ")");
|
||||
}
|
||||
event(CLEAN, taskId, text(task.get("roomId")), PROCESSING, DONE, staff, deviceCode, command);
|
||||
AptRoom room = roomById(text(task.get("roomId")));
|
||||
String roomStatus = refreshRoomStatus(room);
|
||||
log.info("保洁结束:taskId={}, room={} -> 房态 {}", taskId, room.getRoomNo(), roomStatus);
|
||||
return result(taskId, room, roomStatus, DONE, false);
|
||||
}
|
||||
|
||||
// ================================================================== 维修
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> reportRepair(AptJobCommand command, String deviceCode)
|
||||
{
|
||||
Map<String, Object> staff = requireStaff(deviceCode);
|
||||
AptRoom room = requireRoom(command == null ? null : command.getRoomNo());
|
||||
if (command.getDescription() == null || command.getDescription().isBlank())
|
||||
{
|
||||
throw badRequest("MISSING_DESCRIPTION", "故障描述不能为空");
|
||||
}
|
||||
Map<String, Object> active = jobMapper.selectActiveRepairByRoom(gyid(), room.getRoomId());
|
||||
if (active != null && !active.isEmpty())
|
||||
{
|
||||
Map<String, Object> result = new LinkedHashMap<>(active);
|
||||
result.put("duplicated", true);
|
||||
return result;
|
||||
}
|
||||
String orderId = "RO-" + suffix();
|
||||
jobMapper.insertRepairOrder(gyid(), orderId, room.getRoomId(), room.getRoomNo(),
|
||||
normalizeCategory(command.getCategory()), command.getDescription().trim(),
|
||||
normalizePriority(command.getPriority()), "TABLET",
|
||||
text(staff.get("staffId")), text(staff.get("name")), room.getRoomStatus());
|
||||
event(REPAIR, orderId, room.getRoomId(), null, REPORTED, staff, deviceCode, command);
|
||||
// 维修期间不能入住:房态 4 + 原因标记,便于值班台区分停用与维修
|
||||
String roomStatus = applyRoomStatus(room, STATUS_BLOCKED, "REPAIR:" + orderId);
|
||||
log.info("维修上报:orderId={}, room={}, reporter={}", orderId, room.getRoomNo(), staff.get("staffId"));
|
||||
return result(orderId, room, roomStatus, REPORTED, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> assignRepair(String orderId, String staffId)
|
||||
{
|
||||
Map<String, Object> order = requireRepairOrder(orderId);
|
||||
Map<String, Object> staff = jobMapper.selectStaff(gyid(), staffId);
|
||||
if (staff == null || staff.isEmpty())
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "STAFF_NOT_FOUND", "作业人员不存在:" + staffId);
|
||||
}
|
||||
if (!"ACTIVE".equals(text(staff.get("state"))))
|
||||
{
|
||||
throw stateIllegal("该人员已停用,不能派单");
|
||||
}
|
||||
if ("CLEANER".equals(text(staff.get("staffType"))))
|
||||
{
|
||||
throw stateIllegal("保洁人员不能承接维修工单");
|
||||
}
|
||||
int affected = jobMapper.assignRepair(gyid(), orderId, staffId, text(staff.get("name")));
|
||||
if (affected == 0)
|
||||
{
|
||||
return duplicatedOrIllegal(orderId, ASSIGNED);
|
||||
}
|
||||
event(REPAIR, orderId, text(order.get("roomId")), text(order.get("state")), ASSIGNED, staff, null, null);
|
||||
AptRoom room = roomById(text(order.get("roomId")));
|
||||
return result(orderId, room, text(room.getRoomStatus()), ASSIGNED, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> startRepair(String orderId, AptJobCommand command, String deviceCode)
|
||||
{
|
||||
Map<String, Object> staff = requireStaff(deviceCode);
|
||||
Map<String, Object> order = requireRepairOrder(orderId);
|
||||
int affected = jobMapper.startRepair(gyid(), orderId, text(staff.get("staffId")), text(staff.get("name")));
|
||||
if (affected == 0)
|
||||
{
|
||||
return duplicatedOrIllegal(orderId, IN_PROGRESS);
|
||||
}
|
||||
event(REPAIR, orderId, text(order.get("roomId")), text(order.get("state")), IN_PROGRESS, staff, deviceCode, command);
|
||||
AptRoom room = roomById(text(order.get("roomId")));
|
||||
return result(orderId, room, text(room.getRoomStatus()), IN_PROGRESS, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> finishRepair(String orderId, AptJobCommand command, String deviceCode)
|
||||
{
|
||||
Map<String, Object> staff = requireStaff(deviceCode);
|
||||
Map<String, Object> order = requireRepairOrder(orderId);
|
||||
if (command == null || command.getResult() == null || command.getResult().isBlank())
|
||||
{
|
||||
throw badRequest("MISSING_RESULT", "完成维修必须填写维修结果");
|
||||
}
|
||||
int affected = jobMapper.finishRepair(gyid(), orderId, command.getResult().trim(), command.getRemark());
|
||||
if (affected == 0)
|
||||
{
|
||||
return duplicatedOrIllegal(orderId, DONE);
|
||||
}
|
||||
event(REPAIR, orderId, text(order.get("roomId")), IN_PROGRESS, DONE, staff, deviceCode, command);
|
||||
AptRoom room = roomById(text(order.get("roomId")));
|
||||
String roomStatus = refreshRoomStatus(room);
|
||||
jobMapper.updateRepairRoomAfter(gyid(), orderId, roomStatus);
|
||||
log.info("维修完成:orderId={}, room={} -> 房态 {}", orderId, room.getRoomNo(), roomStatus);
|
||||
return result(orderId, room, roomStatus, DONE, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> cancelRepair(String orderId, String remark)
|
||||
{
|
||||
Map<String, Object> order = requireRepairOrder(orderId);
|
||||
int affected = jobMapper.cancelRepair(gyid(), orderId, remark);
|
||||
if (affected == 0)
|
||||
{
|
||||
return duplicatedOrIllegal(orderId, CANCELLED);
|
||||
}
|
||||
event(REPAIR, orderId, text(order.get("roomId")), text(order.get("state")), CANCELLED, null, null, null);
|
||||
AptRoom room = roomById(text(order.get("roomId")));
|
||||
String roomStatus = refreshRoomStatus(room);
|
||||
jobMapper.updateRepairRoomAfter(gyid(), orderId, roomStatus);
|
||||
return result(orderId, room, roomStatus, CANCELLED, false);
|
||||
}
|
||||
|
||||
// ============================================================ 离寓联动(自动)
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int syncRoomOccupancy(String roomNo)
|
||||
{
|
||||
if (roomNo == null || roomNo.isBlank())
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
String normalized = roomNo.trim();
|
||||
Map<String, Object> row = jobMapper.selectRoomByNo(gyid(), normalized);
|
||||
if (row == null || row.isEmpty())
|
||||
{
|
||||
log.warn("离寓联动:房号不存在,跳过占用重算 roomNo={}", normalized);
|
||||
return 0;
|
||||
}
|
||||
String roomId = text(row.get("roomId"));
|
||||
// 以 gy_jcxx 未离寓记录数为唯一口径重算,避免 +1/-1 长期漂移
|
||||
jobMapper.syncRoomOccupancy(gyid(), normalized);
|
||||
int live = jobMapper.countLiveStayByRoom(gyid(), normalized);
|
||||
AptRoom room = roomById(roomId);
|
||||
if (live <= 0)
|
||||
{
|
||||
applyVacantRoomStatus(room);
|
||||
}
|
||||
else
|
||||
{
|
||||
refreshRoomStatus(room);
|
||||
}
|
||||
log.info("离寓联动:room={}, 剩余在寓={}, 房态={}", normalized, live, room.getRoomStatus());
|
||||
return live;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
|
||||
public Map<String, Object> autoCreateCleanOnVacated(String roomNo, String reason)
|
||||
{
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("created", false);
|
||||
result.put("roomNo", roomNo);
|
||||
if (roomNo == null || roomNo.isBlank())
|
||||
{
|
||||
result.put("reason", "MISSING_ROOMNO");
|
||||
return result;
|
||||
}
|
||||
String normalized = roomNo.trim();
|
||||
Map<String, Object> row = jobMapper.selectRoomByNo(gyid(), normalized);
|
||||
if (row == null || row.isEmpty())
|
||||
{
|
||||
result.put("reason", "ROOM_NOT_FOUND");
|
||||
return result;
|
||||
}
|
||||
String roomId = text(row.get("roomId"));
|
||||
int live = jobMapper.countLiveStayByRoom(gyid(), normalized);
|
||||
if (live > 0)
|
||||
{
|
||||
// 有人返回(离寓后又有人入住):不建单,避免给在住房间挂待清扫
|
||||
result.put("reason", "OCCUPIED");
|
||||
result.put("liveCount", live);
|
||||
return result;
|
||||
}
|
||||
Map<String, Object> active = jobMapper.selectActiveCleanTaskByRoom(gyid(), roomId);
|
||||
if (active != null && !active.isEmpty())
|
||||
{
|
||||
result.put("reason", "TASK_EXISTS");
|
||||
result.put("duplicated", true);
|
||||
result.put("taskId", text(active.get("taskId")));
|
||||
result.put("state", text(active.get("state")));
|
||||
return result;
|
||||
}
|
||||
String taskId = "CT-" + suffix();
|
||||
String trigger = reason == null || reason.isBlank() ? "LEAVE" : reason;
|
||||
String remark = "全员离寓自动建单:" + trigger;
|
||||
try
|
||||
{
|
||||
jobMapper.insertCleanTask(gyid(), taskId, roomId, normalized, null, remark, new Date(), "AUTO");
|
||||
}
|
||||
catch (DuplicateKeyException e)
|
||||
{
|
||||
// 唯一键兜底:并发(重复离寓 / 平板同时上报)只有一条活跃任务
|
||||
Map<String, Object> created = jobMapper.selectActiveCleanTaskByRoom(gyid(), roomId);
|
||||
result.put("reason", "TASK_EXISTS");
|
||||
result.put("duplicated", true);
|
||||
result.put("taskId", created == null ? null : text(created.get("taskId")));
|
||||
result.put("state", created == null ? null : text(created.get("state")));
|
||||
return result;
|
||||
}
|
||||
insertEvent(CLEAN, taskId, roomId, null, PENDING, null, "SYSTEM", null, remark);
|
||||
// 房态已在离寓事务内置为 3(维修中保持 4、保洁中保持 6);
|
||||
// 只有异常数据才在这里补置,避免建单事务二次覆盖并发写入的房态。
|
||||
AptRoom room = roomById(roomId);
|
||||
String roomStatus = room.getRoomStatus();
|
||||
if (!STATUS_TO_CLEAN.equals(roomStatus) && !STATUS_BLOCKED.equals(roomStatus)
|
||||
&& !STATUS_CLEANING.equals(roomStatus))
|
||||
{
|
||||
roomStatus = applyRoomStatus(room, STATUS_TO_CLEAN, null);
|
||||
}
|
||||
auditAutoClean(normalized, taskId, trigger, live);
|
||||
log.info("全员离寓自动建单:taskId={}, room={}, 房态={}", taskId, normalized, roomStatus);
|
||||
result.put("created", true);
|
||||
result.put("taskId", taskId);
|
||||
result.put("state", PENDING);
|
||||
result.put("roomStatus", roomStatus);
|
||||
result.put("duplicated", false);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 最后一人离寓:无维修时直接置 3 待清扫(保洁中保持 6),不落回 1 空闲 */
|
||||
private void applyVacantRoomStatus(AptRoom room)
|
||||
{
|
||||
Map<String, Object> activeRepair = jobMapper.selectActiveRepairByRoom(gyid(), room.getRoomId());
|
||||
if (activeRepair != null && !activeRepair.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
String cleanState = roomMapper.selectActiveCleanState(gyid(), room.getRoomId());
|
||||
applyRoomStatus(room, PROCESSING.equals(cleanState) ? STATUS_CLEANING : STATUS_TO_CLEAN, null);
|
||||
}
|
||||
|
||||
private void auditAutoClean(String roomNo, String taskId, String trigger, int liveCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
Map<String, Object> detail = new LinkedHashMap<>();
|
||||
detail.put("roomNo", roomNo);
|
||||
detail.put("taskId", taskId);
|
||||
detail.put("trigger", trigger);
|
||||
detail.put("liveCount", liveCount);
|
||||
detail.put("source", "AUTO");
|
||||
detail.put("traceId", AptContext.getTraceId());
|
||||
|
||||
AptOperationLog entry = new AptOperationLog();
|
||||
entry.setLogId(UUID.randomUUID().toString().replace("-", ""));
|
||||
String gyId = AptContext.getGyIdOrNull();
|
||||
entry.setGyId(gyId == null ? "UNKNOWN" : gyId);
|
||||
entry.setOperatorRef("SYSTEM");
|
||||
entry.setModule("JOB");
|
||||
entry.setAction("CLEAN_AUTO_CREATE");
|
||||
entry.setDetail(JSON.toJSONString(detail));
|
||||
entry.setTraceId(AptContext.getTraceId());
|
||||
entry.setOccurredAt(new Date());
|
||||
auditWriter.write(entry);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("自动建单审计写入失败,roomNo={}, taskId={}, cause={}", roomNo, taskId, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================ 查询 / 统计
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> list(String type, AptJobQuery query)
|
||||
{
|
||||
AptJobQuery q = query == null ? new AptJobQuery() : query;
|
||||
if (q.getLimit() == null || q.getLimit() <= 0 || q.getLimit() > 500)
|
||||
{
|
||||
q.setLimit(100);
|
||||
}
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
if (type == null || type.isBlank() || CLEAN.equalsIgnoreCase(type))
|
||||
{
|
||||
for (Map<String, Object> row : jobMapper.selectCleanList(gyid(), q))
|
||||
{
|
||||
row.put("type", CLEAN);
|
||||
result.add(row);
|
||||
}
|
||||
}
|
||||
if (type == null || type.isBlank() || REPAIR.equalsIgnoreCase(type))
|
||||
{
|
||||
for (Map<String, Object> row : jobMapper.selectRepairList(gyid(), q))
|
||||
{
|
||||
row.put("type", REPAIR);
|
||||
result.add(row);
|
||||
}
|
||||
}
|
||||
result.sort(Comparator.comparingLong(this::bizMillis).reversed());
|
||||
return result.size() > q.getLimit() ? result.subList(0, q.getLimit()) : result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> listMine(String deviceCode, String type, AptJobQuery query)
|
||||
{
|
||||
Map<String, Object> staff = requireStaff(deviceCode);
|
||||
String staffType = text(staff.get("staffType"));
|
||||
String effectiveType = type;
|
||||
if (type != null && !type.isBlank())
|
||||
{
|
||||
// 作业类型与人员类型必须匹配:保洁不接维修单,维修工不接保洁单
|
||||
if ("CLEANER".equals(staffType) && !CLEAN.equalsIgnoreCase(type))
|
||||
{
|
||||
return List.of();
|
||||
}
|
||||
if ("REPAIRMAN".equals(staffType) && !REPAIR.equalsIgnoreCase(type))
|
||||
{
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
else if ("CLEANER".equals(staffType))
|
||||
{
|
||||
effectiveType = CLEAN;
|
||||
}
|
||||
else if ("REPAIRMAN".equals(staffType))
|
||||
{
|
||||
effectiveType = REPAIR;
|
||||
}
|
||||
AptJobQuery merged = query == null ? new AptJobQuery() : query;
|
||||
merged.setStaffId(text(staff.get("staffId")));
|
||||
// 未分配的待办(自动建单的保洁 / 一键报修的维修)进入平板公共池
|
||||
merged.setIncludeUnassigned(Boolean.TRUE);
|
||||
List<Map<String, Object>> rows = list(effectiveType, merged);
|
||||
for (Map<String, Object> row : rows)
|
||||
{
|
||||
row.put("pool", text(row.get("assigneeStaffId")).isEmpty());
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> detail(String type, String bizId)
|
||||
{
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
if (CLEAN.equalsIgnoreCase(type))
|
||||
{
|
||||
Map<String, Object> task = requireCleanTask(bizId);
|
||||
result.put("type", CLEAN);
|
||||
result.put("job", task);
|
||||
result.put("events", jobMapper.selectJobEvents(gyid(), CLEAN, bizId));
|
||||
return result;
|
||||
}
|
||||
if (REPAIR.equalsIgnoreCase(type))
|
||||
{
|
||||
Map<String, Object> order = requireRepairOrder(bizId);
|
||||
result.put("type", REPAIR);
|
||||
result.put("job", order);
|
||||
result.put("events", jobMapper.selectJobEvents(gyid(), REPAIR, bizId));
|
||||
return result;
|
||||
}
|
||||
throw badRequest("JOB_TYPE_INVALID", "type 仅允许 CLEAN / REPAIR");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> stats(AptJobQuery query)
|
||||
{
|
||||
Date from = query == null ? null : query.getFrom();
|
||||
Date to = query == null ? null : query.getTo();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("summary", jobMapper.selectJobStats(gyid(), from, to));
|
||||
result.put("byType", jobMapper.selectJobStatsByType(gyid(), from, to));
|
||||
result.put("staffWorkload", jobMapper.selectStaffWorkload(gyid(), from, to));
|
||||
result.put("from", from);
|
||||
result.put("to", to);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ================================================================== 内部
|
||||
|
||||
private Map<String, Object> requireStaff(String deviceCode)
|
||||
{
|
||||
if (deviceCode == null || deviceCode.isBlank())
|
||||
{
|
||||
throw new AptException(AptErrorCode.UNAUTHORIZED, "DEVICE_CREDENTIAL_MISSING", "缺少设备凭据 X-Device-Code");
|
||||
}
|
||||
Map<String, Object> staff = jobMapper.selectStaffByDevice(gyid(), deviceCode.trim().toUpperCase(Locale.ROOT));
|
||||
if (staff == null || staff.isEmpty())
|
||||
{
|
||||
// 平板未绑定作业人员:不允许匿名上报,避免作业责任无法追溯
|
||||
throw new AptException(AptErrorCode.FORBIDDEN_PERM, "STAFF_NOT_BOUND",
|
||||
"该平板未绑定作业人员,请先在人员管理中绑定设备:" + deviceCode);
|
||||
}
|
||||
return staff;
|
||||
}
|
||||
|
||||
private AptRoom requireRoom(String roomNo)
|
||||
{
|
||||
if (roomNo == null || roomNo.isBlank())
|
||||
{
|
||||
throw badRequest("MISSING_ROOMNO", "房号不能为空");
|
||||
}
|
||||
Map<String, Object> row = jobMapper.selectRoomByNo(gyid(), roomNo.trim());
|
||||
if (row == null || row.isEmpty())
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "ROOM_NOT_FOUND", "房间不存在:" + roomNo);
|
||||
}
|
||||
return roomById(text(row.get("roomId")));
|
||||
}
|
||||
|
||||
private AptRoom roomById(String roomId)
|
||||
{
|
||||
AptRoom room = roomMapper.selectRoomByRoomId(gyid(), roomId);
|
||||
if (room == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "ROOM_NOT_FOUND", "房间不存在:" + roomId);
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
private Map<String, Object> requireCleanTask(String taskId)
|
||||
{
|
||||
if (taskId == null || taskId.isBlank())
|
||||
{
|
||||
throw badRequest("MISSING_TASKID", "taskId 不能为空");
|
||||
}
|
||||
Map<String, Object> task = jobMapper.selectCleanTask(gyid(), taskId);
|
||||
if (task == null || task.isEmpty())
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "CLEAN_TASK_NOT_FOUND", "保洁任务不存在:" + taskId);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private Map<String, Object> requireRepairOrder(String orderId)
|
||||
{
|
||||
if (orderId == null || orderId.isBlank())
|
||||
{
|
||||
throw badRequest("MISSING_ORDERID", "orderId 不能为空");
|
||||
}
|
||||
Map<String, Object> order = jobMapper.selectRepairOrder(gyid(), orderId);
|
||||
if (order == null || order.isEmpty())
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "REPAIR_ORDER_NOT_FOUND", "维修工单不存在:" + orderId);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
/** 房态重算:维修中(4) > 保洁中(6) > 待清扫(3) > 住满(2)/未满(5)/空闲(1) */
|
||||
private String refreshRoomStatus(AptRoom room)
|
||||
{
|
||||
String target;
|
||||
String reason = null;
|
||||
Map<String, Object> activeRepair = jobMapper.selectActiveRepairByRoom(gyid(), room.getRoomId());
|
||||
if (activeRepair != null && !activeRepair.isEmpty())
|
||||
{
|
||||
target = STATUS_BLOCKED;
|
||||
reason = "REPAIR:" + text(activeRepair.get("orderId"));
|
||||
}
|
||||
else
|
||||
{
|
||||
String cleanState = roomMapper.selectActiveCleanState(gyid(), room.getRoomId());
|
||||
if (PROCESSING.equals(cleanState))
|
||||
{
|
||||
target = STATUS_CLEANING;
|
||||
}
|
||||
else if (PENDING.equals(cleanState))
|
||||
{
|
||||
target = STATUS_TO_CLEAN;
|
||||
}
|
||||
else
|
||||
{
|
||||
int inPerson = room.getInPersonCount() == null ? 0 : room.getInPersonCount();
|
||||
int beds = room.getBedCount() == null ? 0 : room.getBedCount();
|
||||
target = inPerson <= 0 ? STATUS_IDLE
|
||||
: (beds > 0 && inPerson >= beds ? STATUS_FULL : STATUS_PARTIAL);
|
||||
}
|
||||
}
|
||||
return applyRoomStatus(room, target, reason);
|
||||
}
|
||||
|
||||
/** 房态条件更新;版本冲突时重读一次再试,仍失败按 40903 返回 */
|
||||
private String applyRoomStatus(AptRoom room, String target, String reason)
|
||||
{
|
||||
long version = room.getStateVersion() == null ? 0L : room.getStateVersion();
|
||||
if (roomMapper.updateRoomStatus(gyid(), room.getId(), version, target, reason) == 0)
|
||||
{
|
||||
AptRoom fresh = roomMapper.selectRoomByRoomId(gyid(), room.getRoomId());
|
||||
if (fresh == null
|
||||
|| roomMapper.updateRoomStatus(gyid(), fresh.getId(),
|
||||
fresh.getStateVersion() == null ? 0L : fresh.getStateVersion(), target, reason) == 0)
|
||||
{
|
||||
throw new AptException(AptErrorCode.SNAPSHOT_EXPIRED, "STATE_VERSION_CONFLICT",
|
||||
"房态版本已变化,请重读后重试");
|
||||
}
|
||||
log.debug("房态更新版本冲突后重试成功:room={}, target={}", room.getRoomNo(), target);
|
||||
}
|
||||
room.setRoomStatus(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
private void event(String bizType, String bizId, String roomId, String from, String to,
|
||||
Map<String, Object> staff, String deviceCode, AptJobCommand command)
|
||||
{
|
||||
insertEvent(bizType, bizId, roomId, from, to,
|
||||
staff == null ? null : text(staff.get("staffId")),
|
||||
staff == null ? null : text(staff.get("name")),
|
||||
deviceCode == null ? null : deviceCode.toUpperCase(Locale.ROOT),
|
||||
command == null ? null : command.getRemark());
|
||||
}
|
||||
|
||||
/** 状态流水统一入口:自动建单用 operatorName=SYSTEM、deviceCode 为空留痕 */
|
||||
private void insertEvent(String bizType, String bizId, String roomId, String from, String to,
|
||||
String operatorStaff, String operatorName, String deviceCode, String remark)
|
||||
{
|
||||
jobMapper.insertJobEvent(gyid(), "JE-" + suffix(), bizType, bizId, roomId, from, to,
|
||||
operatorStaff, operatorName, deviceCode, remark);
|
||||
}
|
||||
|
||||
private Map<String, Object> duplicatedOrIllegal(String bizId, String expected)
|
||||
{
|
||||
Map<String, Object> order = jobMapper.selectRepairOrder(gyid(), bizId);
|
||||
if (order != null && expected.equals(text(order.get("state"))))
|
||||
{
|
||||
AptRoom room = roomById(text(order.get("roomId")));
|
||||
return result(bizId, room, text(room.getRoomStatus()), expected, true);
|
||||
}
|
||||
throw stateIllegal("工单当前状态 " + text(order == null ? null : order.get("state"))
|
||||
+ " 不允许该操作(期望 " + expected + ")");
|
||||
}
|
||||
|
||||
private Map<String, Object> result(String bizId, AptRoom room, String roomStatus,
|
||||
String state, boolean duplicated)
|
||||
{
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("jobId", bizId);
|
||||
result.put("roomId", room.getRoomId());
|
||||
result.put("roomNo", room.getRoomNo());
|
||||
result.put("state", state);
|
||||
result.put("roomStatus", roomStatus);
|
||||
result.put("duplicated", duplicated);
|
||||
result.put("traceId", AptContext.getTraceId());
|
||||
return result;
|
||||
}
|
||||
|
||||
private AptRoom roomOf(Map<String, Object> task)
|
||||
{
|
||||
return roomById(text(task.get("roomId")));
|
||||
}
|
||||
|
||||
private long bizMillis(Map<String, Object> row)
|
||||
{
|
||||
for (String key : new String[]{"scheduledAt", "reportedAt", "startedAt", "finishedAt"})
|
||||
{
|
||||
Object value = row.get(key);
|
||||
if (value instanceof Date date)
|
||||
{
|
||||
return date.getTime();
|
||||
}
|
||||
}
|
||||
return 0L;
|
||||
}
|
||||
|
||||
private String normalizeCategory(String category)
|
||||
{
|
||||
String value = category == null ? "OTHER" : category.trim().toUpperCase(Locale.ROOT);
|
||||
return List.of("ELECTRIC", "PLUMBING", "DOOR_WINDOW", "AIRCON", "OTHER").contains(value)
|
||||
? value : "OTHER";
|
||||
}
|
||||
|
||||
private String normalizePriority(String priority)
|
||||
{
|
||||
String value = priority == null ? "NORMAL" : priority.trim().toUpperCase(Locale.ROOT);
|
||||
return "URGENT".equals(value) ? "URGENT" : "NORMAL";
|
||||
}
|
||||
|
||||
private AptException stateIllegal(String message)
|
||||
{
|
||||
return new AptException(AptErrorCode.STATE_ILLEGAL, "JOB_STATE_ILLEGAL", message);
|
||||
}
|
||||
|
||||
private AptException badRequest(String reason, String message)
|
||||
{
|
||||
return new AptException(AptErrorCode.BAD_REQUEST, reason, message);
|
||||
}
|
||||
|
||||
private String suffix()
|
||||
{
|
||||
return UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String text(Object value)
|
||||
{
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
|
||||
private String gyid()
|
||||
{
|
||||
return AptContext.getGyId();
|
||||
}
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
package com.ruoyi.apt.job.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.job.domain.AptStaffCommand;
|
||||
import com.ruoyi.apt.job.mapper.AptJobMapper;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 作业人员维护实现。
|
||||
*
|
||||
* <p>约束:人员类型白名单、工号同公寓唯一、停用即下线(duty_state=OFFLINE),
|
||||
* 删除为软删除并解绑设备,避免历史工单失去执行人引用。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class AptStaffServiceImpl implements IAptStaffService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AptStaffServiceImpl.class);
|
||||
|
||||
private static final Set<String> TYPES = Set.of("CLEANER", "REPAIRMAN", "BOTH");
|
||||
private static final Set<String> STATES = Set.of("ACTIVE", "DISABLED");
|
||||
|
||||
private final AptJobMapper jobMapper;
|
||||
|
||||
public AptStaffServiceImpl(AptJobMapper jobMapper)
|
||||
{
|
||||
this.jobMapper = jobMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> list(String staffType, String state, String keyword)
|
||||
{
|
||||
return jobMapper.selectStaffList(gyid(),
|
||||
staffType == null ? null : staffType.trim().toUpperCase(Locale.ROOT),
|
||||
state == null ? null : state.trim().toUpperCase(Locale.ROOT),
|
||||
keyword == null ? null : keyword.trim());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> detail(String staffId)
|
||||
{
|
||||
return require(staffId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> create(AptStaffCommand command)
|
||||
{
|
||||
if (command == null)
|
||||
{
|
||||
throw badRequest("MISSING_BODY", "请求体不能为空");
|
||||
}
|
||||
String staffType = normalizeType(command.getStaffType());
|
||||
String name = requireName(command.getName());
|
||||
String workNo = trimToNull(command.getWorkNo());
|
||||
assertWorkNoUnique(workNo, null);
|
||||
String staffId = trimToNull(command.getStaffId());
|
||||
if (staffId == null)
|
||||
{
|
||||
staffId = "ST-" + suffix();
|
||||
}
|
||||
else if (!jobMapper.selectStaff(gyid(), staffId).isEmpty())
|
||||
{
|
||||
throw new AptException(AptErrorCode.DUPLICATE_KEY, "STAFF_DUPLICATED", "人员已存在:" + staffId);
|
||||
}
|
||||
jobMapper.insertStaff(gyid(), staffId, staffType, name, workNo, trimToNull(command.getPhone()),
|
||||
command.getSysUserId(), trimToNull(command.getDeviceCode()), trimToNull(command.getRemark()),
|
||||
currentUser());
|
||||
log.info("新增作业人员:staffId={}, type={}, name={}", staffId, staffType, name);
|
||||
return require(staffId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> update(String staffId, AptStaffCommand command)
|
||||
{
|
||||
require(staffId);
|
||||
if (command == null)
|
||||
{
|
||||
throw badRequest("MISSING_BODY", "请求体不能为空");
|
||||
}
|
||||
String staffType = normalizeType(command.getStaffType());
|
||||
String name = requireName(command.getName());
|
||||
String workNo = trimToNull(command.getWorkNo());
|
||||
assertWorkNoUnique(workNo, staffId);
|
||||
jobMapper.updateStaff(gyid(), staffId, staffType, name, workNo, trimToNull(command.getPhone()),
|
||||
command.getSysUserId(), trimToNull(command.getDeviceCode()), trimToNull(command.getRemark()));
|
||||
return require(staffId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> changeState(String staffId, String state)
|
||||
{
|
||||
require(staffId);
|
||||
String target = state == null ? "" : state.trim().toUpperCase(Locale.ROOT);
|
||||
if (!STATES.contains(target))
|
||||
{
|
||||
throw badRequest("STAFF_STATE_INVALID", "state 仅允许 ACTIVE / DISABLED");
|
||||
}
|
||||
jobMapper.updateStaffState(gyid(), staffId, target);
|
||||
log.info("作业人员状态变更:staffId={}, state={}", staffId, target);
|
||||
return require(staffId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(String staffId)
|
||||
{
|
||||
require(staffId);
|
||||
jobMapper.softDeleteStaff(gyid(), staffId);
|
||||
log.info("作业人员已删除(软删并解绑设备):staffId={}", staffId);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 内部
|
||||
|
||||
private Map<String, Object> require(String staffId)
|
||||
{
|
||||
if (staffId == null || staffId.isBlank())
|
||||
{
|
||||
throw badRequest("MISSING_STAFFID", "staffId 不能为空");
|
||||
}
|
||||
Map<String, Object> staff = jobMapper.selectStaff(gyid(), staffId.trim());
|
||||
if (staff == null || staff.isEmpty())
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "STAFF_NOT_FOUND", "作业人员不存在:" + staffId);
|
||||
}
|
||||
return staff;
|
||||
}
|
||||
|
||||
private void assertWorkNoUnique(String workNo, String excludeStaffId)
|
||||
{
|
||||
if (workNo == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (jobMapper.countStaffWorkNo(gyid(), workNo, excludeStaffId) > 0)
|
||||
{
|
||||
throw new AptException(AptErrorCode.DUPLICATE_KEY, "STAFF_WORKNO_DUPLICATED",
|
||||
"工号已存在:" + workNo);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeType(String staffType)
|
||||
{
|
||||
String value = staffType == null ? "" : staffType.trim().toUpperCase(Locale.ROOT);
|
||||
if (!TYPES.contains(value))
|
||||
{
|
||||
throw badRequest("STAFF_TYPE_INVALID", "staffType 仅允许 CLEANER / REPAIRMAN / BOTH");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String requireName(String name)
|
||||
{
|
||||
if (name == null || name.isBlank())
|
||||
{
|
||||
throw badRequest("MISSING_NAME", "姓名不能为空");
|
||||
}
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
/** 操作人:无登录上下文(定时/测试)时记 system */
|
||||
private String currentUser()
|
||||
{
|
||||
try
|
||||
{
|
||||
String username = SecurityUtils.getUsername();
|
||||
return username == null || username.isBlank() ? "system" : username;
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
private String trimToNull(String value)
|
||||
{
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
private AptException badRequest(String reason, String message)
|
||||
{
|
||||
return new AptException(AptErrorCode.BAD_REQUEST, reason, message);
|
||||
}
|
||||
|
||||
private String suffix()
|
||||
{
|
||||
return UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String gyid()
|
||||
{
|
||||
return AptContext.getGyId();
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.ruoyi.apt.job.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.job.domain.AptJobCommand;
|
||||
import com.ruoyi.apt.job.domain.AptJobQuery;
|
||||
|
||||
/**
|
||||
* 平板作业(保洁 / 维修)受理与查询。
|
||||
*
|
||||
* <p>保洁:上报(PENDING, 房态 3) → 开始(PROCESSING, 房态 6) → 结束(DONE, 房态重算 1/2/5)。<br>
|
||||
* 维修:上报(REPORTED, 房态 4) → 派单(ASSIGNED) → 开始(IN_PROGRESS) → 完成(DONE, 房态重算)/取消。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface IAptJobService
|
||||
{
|
||||
// ------------------------------------------------------------------ 保洁
|
||||
Map<String, Object> reportClean(AptJobCommand command, String deviceCode);
|
||||
|
||||
Map<String, Object> startClean(String taskId, AptJobCommand command, String deviceCode);
|
||||
|
||||
Map<String, Object> finishClean(String taskId, AptJobCommand command, String deviceCode);
|
||||
|
||||
// ------------------------------------------------------------------ 维修
|
||||
Map<String, Object> reportRepair(AptJobCommand command, String deviceCode);
|
||||
|
||||
Map<String, Object> assignRepair(String orderId, String staffId);
|
||||
|
||||
Map<String, Object> startRepair(String orderId, AptJobCommand command, String deviceCode);
|
||||
|
||||
Map<String, Object> finishRepair(String orderId, AptJobCommand command, String deviceCode);
|
||||
|
||||
Map<String, Object> cancelRepair(String orderId, String remark);
|
||||
|
||||
// -------------------------------------------------------- 离寓联动(自动)
|
||||
/**
|
||||
* 离寓后按 gy_jcxx 在寓事实重算房间人数并刷新房态。
|
||||
*
|
||||
* <p>与「登记离寓」同事务调用;无人时房态直接置 3 待清扫,避免出现「已离寓但仍空闲可入住」的窗口。
|
||||
* 返回房间剩余有效在寓人数。</p>
|
||||
*/
|
||||
int syncRoomOccupancy(String roomNo);
|
||||
|
||||
/**
|
||||
* 全员离寓后自动生成保洁工单(幂等):
|
||||
* 同一房间已有 PENDING/PROCESSING 任务时返回该任务并标记 duplicated;
|
||||
* 新建时 source=AUTO、状态 PENDING、执行人为空(进入平板公共池)。
|
||||
*
|
||||
* @param roomNo 房号
|
||||
* @param reason 触发来源(如 LEAVE:<jcid>),写入备注与流水
|
||||
* @return taskId / state / created / duplicated
|
||||
*/
|
||||
Map<String, Object> autoCreateCleanOnVacated(String roomNo, String reason);
|
||||
|
||||
// ------------------------------------------------------------ 查询 / 统计
|
||||
/** @param type CLEAN / REPAIR / 空(两类合并按时间倒序) */
|
||||
List<Map<String, Object>> list(String type, AptJobQuery query);
|
||||
|
||||
/** 平板「我的作业」:按设备编码反查执行人后过滤 */
|
||||
List<Map<String, Object>> listMine(String deviceCode, String type, AptJobQuery query);
|
||||
|
||||
/** @param type CLEAN / REPAIR */
|
||||
Map<String, Object> detail(String type, String bizId);
|
||||
|
||||
Map<String, Object> stats(AptJobQuery query);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.ruoyi.apt.job.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.job.domain.AptStaffCommand;
|
||||
|
||||
/**
|
||||
* 作业人员(保洁 / 维修)信息维护。
|
||||
*
|
||||
* <p>与权限的关系:人员通过 {@code sys_user_id} 归属后台账号、通过 {@code device_code}
|
||||
* 绑定平板;平板侧接口凭设备编码识别执行人,后台操作走 {@code apt:staff:*} 权限点。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface IAptStaffService
|
||||
{
|
||||
List<Map<String, Object>> list(String staffType, String state, String keyword);
|
||||
|
||||
Map<String, Object> detail(String staffId);
|
||||
|
||||
Map<String, Object> create(AptStaffCommand command);
|
||||
|
||||
Map<String, Object> update(String staffId, AptStaffCommand command);
|
||||
|
||||
/** 启停用:DISABLED 时同时把 duty_state 置 OFFLINE */
|
||||
Map<String, Object> changeState(String staffId, String state);
|
||||
|
||||
void delete(String staffId);
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.ruoyi.apt.master.controller;
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.ruoyi.apt.common.audit.AptAudit;
|
||||
import com.ruoyi.apt.master.service.IAptPersonService;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
|
||||
/**
|
||||
* M4 人员管理(只读快照 + 关联 Tab)。
|
||||
*
|
||||
* <p>平台下发字段(姓名/工号/部门/人员类别等)本地不可改:{@code PUT} 显式返回 40904,
|
||||
* 不做「部分可改」的模糊实现,避免与主数据快照校验冲突。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/apt/person", produces = "application/json;charset=UTF-8")
|
||||
public class AptPersonController
|
||||
{
|
||||
private final IAptPersonService personService;
|
||||
|
||||
public AptPersonController(IAptPersonService personService)
|
||||
{
|
||||
this.personService = personService;
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:person:list')")
|
||||
@GetMapping("/list")
|
||||
public AjaxResult list(String keyword, String roomNo, String state, Integer limit)
|
||||
{
|
||||
return AjaxResult.success(personService.list(keyword, roomNo, state, limit));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('apt:person:query')")
|
||||
@GetMapping("/{ryid}")
|
||||
public AjaxResult detail(@PathVariable String ryid)
|
||||
{
|
||||
return AjaxResult.success(personService.detail(ryid));
|
||||
}
|
||||
|
||||
/** 关联 Tab:当前在住房间、最近交路、最近 5 条叫乘 */
|
||||
@PreAuthorize("@ss.hasPermi('apt:person:query')")
|
||||
@GetMapping("/{ryid}/associations")
|
||||
public AjaxResult associations(@PathVariable String ryid)
|
||||
{
|
||||
return AjaxResult.success(personService.associations(ryid));
|
||||
}
|
||||
|
||||
/** 快照只读:无论请求体内容,一律 40904 */
|
||||
@PreAuthorize("@ss.hasPermi('apt:person:edit')")
|
||||
@AptAudit(module = "PERSON", action = "PERSON_UPDATE_REJECTED")
|
||||
@PutMapping("/{ryid}")
|
||||
public AjaxResult update(@PathVariable String ryid)
|
||||
{
|
||||
personService.rejectSnapshotWrite(ryid);
|
||||
return AjaxResult.success();
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.ruoyi.apt.master.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* M4 人员主数据读取与关联查询(关联 Tab)。
|
||||
*
|
||||
* <p>人员来自平台下发快照,本地只读;所有写入口在 Controller 层直接返回 40904。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface AptPersonMapper
|
||||
{
|
||||
List<Map<String, Object>> selectPersonList(@Param("gyid") String gyid,
|
||||
@Param("keyword") String keyword,
|
||||
@Param("roomNo") String roomNo,
|
||||
@Param("state") String state,
|
||||
@Param("limit") int limit);
|
||||
|
||||
Map<String, Object> selectPerson(@Param("gyid") String gyid, @Param("ryid") String ryid);
|
||||
|
||||
/** 当前在住(finish_ly='0' AND sjlysj IS NULL),取最近一条 */
|
||||
Map<String, Object> selectActiveStay(@Param("gyid") String gyid, @Param("ryid") String ryid);
|
||||
|
||||
/** 最近 N 条叫乘(默认 5) */
|
||||
List<Map<String, Object>> selectRecentCalls(@Param("gyid") String gyid,
|
||||
@Param("ryid") String ryid,
|
||||
@Param("limit") int limit);
|
||||
|
||||
/** 最近一次交路(按叫乘时间倒序) */
|
||||
Map<String, Object> selectRecentRoute(@Param("gyid") String gyid, @Param("ryid") String ryid);
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.ruoyi.apt.master.service;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.master.mapper.AptPersonMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* M4 人员主数据实现。
|
||||
*
|
||||
* <p>人员由平台下发并落在 {@code apt_master_person}(source_version 标识快照版本),
|
||||
* 本地不改字段:{@code PUT} 一律 40904;关联数据实时来自 {@code gy_jcxx},不做冗余。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class AptPersonServiceImpl implements IAptPersonService
|
||||
{
|
||||
private static final int DEFAULT_LIMIT = 50;
|
||||
private static final int MAX_LIMIT = 200;
|
||||
private static final int RECENT_CALL_LIMIT = 5;
|
||||
|
||||
private final AptPersonMapper personMapper;
|
||||
|
||||
public AptPersonServiceImpl(AptPersonMapper personMapper)
|
||||
{
|
||||
this.personMapper = personMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> list(String keyword, String roomNo, String state, Integer limit)
|
||||
{
|
||||
int size = limit == null ? DEFAULT_LIMIT : limit;
|
||||
size = Math.max(1, Math.min(size, MAX_LIMIT));
|
||||
return personMapper.selectPersonList(gyid(), trim(keyword), trim(roomNo), trim(state), size);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> detail(String ryid)
|
||||
{
|
||||
Map<String, Object> person = personMapper.selectPerson(gyid(), requireRyid(ryid));
|
||||
if (person == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "PERSON_NOT_FOUND", "人员不存在:" + ryid);
|
||||
}
|
||||
return person;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> associations(String ryid)
|
||||
{
|
||||
String id = requireRyid(ryid);
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("person", detail(id));
|
||||
result.put("currentStay", personMapper.selectActiveStay(gyid(), id));
|
||||
result.put("recentCalls", personMapper.selectRecentCalls(gyid(), id, RECENT_CALL_LIMIT));
|
||||
result.put("recentRoute", personMapper.selectRecentRoute(gyid(), id));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rejectSnapshotWrite(String ryid)
|
||||
{
|
||||
throw new AptException(AptErrorCode.SNAPSHOT_READONLY, "PERSON_SNAPSHOT_READONLY",
|
||||
"人员主数据由平台下发,本地只读(ryid=" + trim(ryid) + ")");
|
||||
}
|
||||
|
||||
private String requireRyid(String ryid)
|
||||
{
|
||||
String id = trim(ryid);
|
||||
if (id.isEmpty())
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_RYID", "缺少人员 ID");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private String gyid()
|
||||
{
|
||||
String gyid = AptContext.getGyIdOrNull();
|
||||
if (gyid == null || gyid.isBlank())
|
||||
{
|
||||
throw new AptException(AptErrorCode.FORBIDDEN_GYID, "MISSING_GYID", "缺少公寓标识");
|
||||
}
|
||||
return gyid;
|
||||
}
|
||||
|
||||
private static String trim(String value)
|
||||
{
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.ruoyi.apt.master.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* M4 人员主数据:列表 / 详情 / 关联 Tab(只读快照)。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface IAptPersonService
|
||||
{
|
||||
List<Map<String, Object>> list(String keyword, String roomNo, String state, Integer limit);
|
||||
|
||||
Map<String, Object> detail(String ryid);
|
||||
|
||||
/** 关联 Tab:当前在住 + 最近 5 条叫乘 + 最近交路 */
|
||||
Map<String, Object> associations(String ryid);
|
||||
|
||||
/** 快照只读:任何写操作一律 40904 */
|
||||
void rejectSnapshotWrite(String ryid);
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package com.ruoyi.apt.ops.controller;
|
||||
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.audit.AptAudit;
|
||||
import com.ruoyi.apt.stay.checkin.domain.AptCheckinMatchVerdict;
|
||||
import com.ruoyi.apt.stay.checkin.service.IAptCheckinMatchService;
|
||||
import com.ruoyi.apt.voice.domain.AptSipEventCommand;
|
||||
import com.ruoyi.apt.voice.service.IAptVoiceEventService;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 本地联调端点(仅 {@code apt.debug.enabled=true} 时装配)。
|
||||
*
|
||||
* <p>存在的唯一理由:真正的入口尚未就绪时,仍能本地验证叫乘链路——</p>
|
||||
* <ul>
|
||||
* <li>{@code #7 匹配结论}:生产入口是平台拉取器(IT-6)调用
|
||||
* {@link IAptCheckinMatchService#applyVerdict},拉取器未实现前用它补
|
||||
* {@code jcsj},让「到点自动发起」可跑;</li>
|
||||
* <li>{@code AMI 事件}:生产入口是 Asterisk 的 AMI 推送,无 Asterisk 时用它注入
|
||||
* {@code ORIGINATE_FAILURE / CONFBRIDGEJOIN / HANGUP} 验证状态机。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>红线</b>:本类不做任何字段直写,只调用既有服务/状态机命令;
|
||||
* 生产环境必须保持 {@code apt.debug.enabled=false}(默认值),否则等同开放状态机旁路。
|
||||
* 鉴权沿用管理端权限点,不额外开匿名口子。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/apt/local", produces = "application/json;charset=UTF-8")
|
||||
@ConditionalOnProperty(prefix = "apt.debug", name = "enabled", havingValue = "true")
|
||||
public class AptLocalDebugController
|
||||
{
|
||||
private final IAptCheckinMatchService matchService;
|
||||
private final IAptVoiceEventService voiceEventService;
|
||||
|
||||
public AptLocalDebugController(IAptCheckinMatchService matchService,
|
||||
IAptVoiceEventService voiceEventService)
|
||||
{
|
||||
this.matchService = matchService;
|
||||
this.voiceEventService = voiceEventService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 联调 #7 匹配结论:落结论 → 复算 jcsj → 写 gy_jcxx → 置 TASK_READY。
|
||||
*
|
||||
* <p>{@code verdict=MATCHED} 且能取到 {@code kcsj} 才会产出 {@code jcsj};
|
||||
* 请求体可省略 {@code applyId}(以路径为准)。</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('duty:checkin:review')")
|
||||
@AptAudit(module = "OPS", action = "LOCAL_MATCH_VERDICT")
|
||||
@PostMapping("/checkin/{applyId}/match-verdict")
|
||||
public AjaxResult matchVerdict(@PathVariable String applyId,
|
||||
@RequestBody AptCheckinMatchVerdict verdict)
|
||||
{
|
||||
verdict.setApplyId(applyId);
|
||||
return AjaxResult.success(matchService.applyVerdict(verdict));
|
||||
}
|
||||
|
||||
/**
|
||||
* 联调 AMI 事件:事件先落 {@code apt_call_event},再按表 5-13 提交状态机命令。
|
||||
*
|
||||
* <p>示例:{@code {"jcid":"JC-...","eventType":"CONFBRIDGEJOIN","sessionId":"CS-..."}},
|
||||
* 其中 {@code ORIGINATE_FAILURE / NO_ANSWER} → ryzt=2,{@code CONFBRIDGEJOIN} → ryzt=3,
|
||||
* {@code HANGUP / AMI_DISCONNECT} 不回写终态。</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('apt:jcxx:changeStatus')")
|
||||
@AptAudit(module = "OPS", action = "LOCAL_SIP_EVENT")
|
||||
@PostMapping("/voice/events")
|
||||
public AjaxResult sipEvent(@RequestBody AptSipEventCommand command)
|
||||
{
|
||||
Map<String, Object> result = voiceEventService.handle(command);
|
||||
return AjaxResult.success(result);
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.ruoyi.apt.report.controller;
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.ruoyi.apt.common.audit.AptAudit;
|
||||
import com.ruoyi.apt.report.domain.AptOutboxQuery;
|
||||
import com.ruoyi.apt.report.service.IAptReportService;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
|
||||
/**
|
||||
* 上报续传管理端(outbox 只读 + 失败重试 + 水位)。
|
||||
*
|
||||
* <p>管理端不提供「改状态」入口:outbox 的 SENT/DEAD 只能由派发器与重试链路推进,
|
||||
* 手工改态会破坏对账口径(CR-C10)。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/apt/report", produces = "application/json;charset=UTF-8")
|
||||
public class AptReportController
|
||||
{
|
||||
private final IAptReportService reportService;
|
||||
|
||||
public AptReportController(IAptReportService reportService)
|
||||
{
|
||||
this.reportService = reportService;
|
||||
}
|
||||
|
||||
/** outbox 列表:bizType / bizId / state / from / to / limit */
|
||||
@PreAuthorize("@ss.hasPermi('apt:report:list')")
|
||||
@GetMapping("/outbox")
|
||||
public AjaxResult outbox(AptOutboxQuery query)
|
||||
{
|
||||
return AjaxResult.success(reportService.listOutbox(query));
|
||||
}
|
||||
|
||||
/** 队列态汇总 + 水位差(对账三维:count / acked / max(watermark)) */
|
||||
@PreAuthorize("@ss.hasPermi('apt:report:query')")
|
||||
@GetMapping("/stats")
|
||||
public AjaxResult stats()
|
||||
{
|
||||
return AjaxResult.success(reportService.stats());
|
||||
}
|
||||
|
||||
/** 水位视图(#14 心跳时间、时钟偏差) */
|
||||
@PreAuthorize("@ss.hasPermi('apt:report:query')")
|
||||
@GetMapping("/watermark")
|
||||
public AjaxResult watermark()
|
||||
{
|
||||
return AjaxResult.success(reportService.watermark());
|
||||
}
|
||||
|
||||
/** FAILED / DEAD → PENDING;复用原 idempotencyKey,不产生新业务事实 */
|
||||
@PreAuthorize("@ss.hasPermi('apt:report:retry')")
|
||||
@AptAudit(module = "REPORT", action = "REPORT_REQUEUE")
|
||||
@PostMapping("/outbox/{id}/requeue")
|
||||
public AjaxResult requeue(@PathVariable Long id)
|
||||
{
|
||||
return AjaxResult.success(reportService.requeue(id));
|
||||
}
|
||||
|
||||
/** 平台回执水位(联调用;生产由派发器调用) */
|
||||
@PreAuthorize("@ss.hasPermi('apt:report:retry')")
|
||||
@AptAudit(module = "REPORT", action = "REPORT_ACK")
|
||||
@PostMapping("/watermark/ack/{acked}")
|
||||
public AjaxResult ack(@PathVariable long acked)
|
||||
{
|
||||
return AjaxResult.success(reportService.ack(acked));
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.ruoyi.apt.report.controller;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.ruoyi.apt.report.domain.AptResumeEnvelope;
|
||||
import com.ruoyi.apt.report.service.IAptReportService;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
|
||||
/**
|
||||
* 断网续报(设备侧,/open/v1/** 走设备四要素鉴权)。
|
||||
*
|
||||
* <p>入参为平板/边缘网关恢复联网后积压的跨端信封;服务端按原 {@code idempotencyKey}
|
||||
* 幂等入队,{@code DUPLICATE} 视为成功(PL-IDEMPOTENT-REPLAY),不重复产生业务事实。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = "/open/v1/report", produces = "application/json;charset=UTF-8")
|
||||
public class AptReportOpenController
|
||||
{
|
||||
private final IAptReportService reportService;
|
||||
|
||||
public AptReportOpenController(IAptReportService reportService)
|
||||
{
|
||||
this.reportService = reportService;
|
||||
}
|
||||
|
||||
@PostMapping("/resume")
|
||||
public AjaxResult resume(@RequestBody List<AptResumeEnvelope> envelopes)
|
||||
{
|
||||
return AjaxResult.success(reportService.resume(envelopes));
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.ruoyi.apt.report.domain;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 上报 outbox 查询条件(管理端只读)。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptOutboxQuery
|
||||
{
|
||||
private String bizType;
|
||||
|
||||
private String bizId;
|
||||
|
||||
/** 队列态:PENDING/SENDING/SENT/FAILED/DEAD,空=全部 */
|
||||
private String state;
|
||||
|
||||
private Date from;
|
||||
|
||||
private Date to;
|
||||
|
||||
private Integer limit = 100;
|
||||
|
||||
public String getBizType()
|
||||
{
|
||||
return bizType;
|
||||
}
|
||||
|
||||
public void setBizType(String bizType)
|
||||
{
|
||||
this.bizType = bizType;
|
||||
}
|
||||
|
||||
public String getBizId()
|
||||
{
|
||||
return bizId;
|
||||
}
|
||||
|
||||
public void setBizId(String bizId)
|
||||
{
|
||||
this.bizId = bizId;
|
||||
}
|
||||
|
||||
public String getState()
|
||||
{
|
||||
return state;
|
||||
}
|
||||
|
||||
public void setState(String state)
|
||||
{
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
public Date getFrom()
|
||||
{
|
||||
return from;
|
||||
}
|
||||
|
||||
public void setFrom(Date from)
|
||||
{
|
||||
this.from = from;
|
||||
}
|
||||
|
||||
public Date getTo()
|
||||
{
|
||||
return to;
|
||||
}
|
||||
|
||||
public void setTo(Date to)
|
||||
{
|
||||
this.to = to;
|
||||
}
|
||||
|
||||
public Integer getLimit()
|
||||
{
|
||||
return limit;
|
||||
}
|
||||
|
||||
public void setLimit(Integer limit)
|
||||
{
|
||||
this.limit = limit;
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.ruoyi.apt.report.domain;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 断网续报信封(#9 / #8 / #13 共用)。
|
||||
*
|
||||
* <p>续报必须复用首次上报的 {@code idempotencyKey};缺省时按
|
||||
* {@code hash(gyid|bizType|bizId|bizVersion)} 补齐,保证同一业务事实不产生新记录。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptResumeEnvelope
|
||||
{
|
||||
private String idempotencyKey;
|
||||
|
||||
private String bizType;
|
||||
|
||||
private String bizId;
|
||||
|
||||
private Long bizVersion;
|
||||
|
||||
/** 跨端信封原文,原样落 payload,不做字段裁剪 */
|
||||
private Object payload;
|
||||
|
||||
private Date occurredAt;
|
||||
|
||||
public String getIdempotencyKey()
|
||||
{
|
||||
return idempotencyKey;
|
||||
}
|
||||
|
||||
public void setIdempotencyKey(String idempotencyKey)
|
||||
{
|
||||
this.idempotencyKey = idempotencyKey;
|
||||
}
|
||||
|
||||
public String getBizType()
|
||||
{
|
||||
return bizType;
|
||||
}
|
||||
|
||||
public void setBizType(String bizType)
|
||||
{
|
||||
this.bizType = bizType;
|
||||
}
|
||||
|
||||
public String getBizId()
|
||||
{
|
||||
return bizId;
|
||||
}
|
||||
|
||||
public void setBizId(String bizId)
|
||||
{
|
||||
this.bizId = bizId;
|
||||
}
|
||||
|
||||
public Long getBizVersion()
|
||||
{
|
||||
return bizVersion;
|
||||
}
|
||||
|
||||
public void setBizVersion(Long bizVersion)
|
||||
{
|
||||
this.bizVersion = bizVersion;
|
||||
}
|
||||
|
||||
public Object getPayload()
|
||||
{
|
||||
return payload;
|
||||
}
|
||||
|
||||
public void setPayload(Object payload)
|
||||
{
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
public Date getOccurredAt()
|
||||
{
|
||||
return occurredAt;
|
||||
}
|
||||
|
||||
public void setOccurredAt(Date occurredAt)
|
||||
{
|
||||
this.occurredAt = occurredAt;
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.ruoyi.apt.report.mapper;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.report.domain.AptOutboxQuery;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 上报 outbox / 水位 / 续报读写。
|
||||
*
|
||||
* <p>outbox 是队列(PENDING/SENDING/SENT/FAILED/DEAD),终态流水在 apt_report_history,
|
||||
* 两者不互换;续报只允许复用原 {@code idempotency_key} 重新入队/幂等命中,不改业务事实。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface AptReportMapper
|
||||
{
|
||||
List<Map<String, Object>> selectOutbox(@Param("gyid") String gyid, @Param("q") AptOutboxQuery query);
|
||||
|
||||
Map<String, Object> selectOutboxById(@Param("gyid") String gyid, @Param("id") Long id);
|
||||
|
||||
/** 按状态汇总 + 对账三维 max(watermark) */
|
||||
Map<String, Object> selectOutboxStats(@Param("gyid") String gyid);
|
||||
|
||||
/** 仅 FAILED / DEAD 可重试,成功返回 1(条件更新) */
|
||||
int requeueOutbox(@Param("gyid") String gyid, @Param("id") Long id, @Param("batchId") String batchId);
|
||||
|
||||
int insertOutbox(@Param("gyid") String gyid, @Param("key") String key,
|
||||
@Param("bizType") String bizType, @Param("bizId") String bizId,
|
||||
@Param("bizVersion") long bizVersion, @Param("payload") String payload,
|
||||
@Param("occurredAt") Date occurredAt);
|
||||
|
||||
Map<String, Object> selectWatermark(@Param("gyid") String gyid);
|
||||
|
||||
/** #14 心跳:水位取 GREATEST,偏差以最新一次为准 */
|
||||
int upsertWatermark(@Param("gyid") String gyid, @Param("watermark") long watermark,
|
||||
@Param("clockSkewMs") Long clockSkewMs, @Param("at") Date at);
|
||||
|
||||
int ackWatermark(@Param("gyid") String gyid, @Param("acked") long acked);
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
package com.ruoyi.apt.report.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.report.domain.AptOutboxQuery;
|
||||
import com.ruoyi.apt.report.domain.AptResumeEnvelope;
|
||||
import com.ruoyi.apt.report.mapper.AptReportMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 上报续传实现。
|
||||
*
|
||||
* <p>红线(CR-C10 / 表 3-11):outbox 是队列、history 是终态流水,两者不互换;
|
||||
* 本服务不写 history,也不把队列态直接当作业务事实。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class AptReportServiceImpl implements IAptReportService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AptReportServiceImpl.class);
|
||||
|
||||
private static final int DEFAULT_LIMIT = 100;
|
||||
private static final int MAX_LIMIT = 500;
|
||||
private static final int DEFAULT_RESUME_BATCH = 200;
|
||||
private static final List<String> RETRYABLE = List.of("FAILED", "DEAD");
|
||||
|
||||
private final AptReportMapper reportMapper;
|
||||
private final AptProperties properties;
|
||||
|
||||
public AptReportServiceImpl(AptReportMapper reportMapper, AptProperties properties)
|
||||
{
|
||||
this.reportMapper = reportMapper;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> listOutbox(AptOutboxQuery query)
|
||||
{
|
||||
AptOutboxQuery q = query == null ? new AptOutboxQuery() : query;
|
||||
int limit = q.getLimit() == null ? DEFAULT_LIMIT : q.getLimit();
|
||||
q.setLimit(Math.max(1, Math.min(limit, MAX_LIMIT)));
|
||||
return reportMapper.selectOutbox(gyid(), q);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> stats()
|
||||
{
|
||||
Map<String, Object> outbox = reportMapper.selectOutboxStats(gyid());
|
||||
if (outbox == null)
|
||||
{
|
||||
outbox = new LinkedHashMap<>();
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("outbox", outbox);
|
||||
result.put("watermark", watermark());
|
||||
result.put("pendingCount", longOf(outbox, "pending"));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> requeue(Long id)
|
||||
{
|
||||
if (id == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "MISSING_ID", "缺少 outbox 主键");
|
||||
}
|
||||
Map<String, Object> row = reportMapper.selectOutboxById(gyid(), id);
|
||||
if (row == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "OUTBOX_NOT_FOUND", "outbox 记录不存在");
|
||||
}
|
||||
String state = text(row.get("state"));
|
||||
if (!RETRYABLE.contains(state))
|
||||
{
|
||||
throw new AptException(AptErrorCode.STATE_ILLEGAL, "OUTBOX_NOT_RETRYABLE",
|
||||
"仅 FAILED / DEAD 可重试,当前态:" + state);
|
||||
}
|
||||
String batchId = "RB-" + UUID.randomUUID().toString().replace("-", "");
|
||||
int rows = reportMapper.requeueOutbox(gyid(), id, batchId);
|
||||
if (rows == 0)
|
||||
{
|
||||
// 并发下已被人重试:按状态冲突返回,避免重复批次
|
||||
throw new AptException(AptErrorCode.STATE_ILLEGAL, "OUTBOX_NOT_RETRYABLE",
|
||||
"该记录已被重试或状态已变更,请刷新");
|
||||
}
|
||||
Map<String, Object> updated = reportMapper.selectOutboxById(gyid(), id);
|
||||
if (updated == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.DATA_ANOMALY, "OUTBOX_LOST", "重试后记录不可读");
|
||||
}
|
||||
updated.put("retryBatchId", batchId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> watermark()
|
||||
{
|
||||
Map<String, Object> row = reportMapper.selectWatermark(gyid());
|
||||
long current = longOf(row, "currentWatermark");
|
||||
long acked = longOf(row, "ackedWatermark");
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("currentWatermark", current);
|
||||
view.put("ackedWatermark", acked);
|
||||
view.put("lag", Math.max(0, current - acked));
|
||||
view.put("lastHeartbeatAt", row == null ? null : row.get("lastHeartbeatAt"));
|
||||
view.put("clockSkewMs", row == null ? null : row.get("clockSkewMs"));
|
||||
view.put("clockSkewThresholdMs", properties.getReport().getClockSkewThresholdMs());
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> recordHeartbeat(Long currentWatermark, Long clockSkewMs)
|
||||
{
|
||||
long watermark = currentWatermark == null ? 0 : Math.max(0, currentWatermark);
|
||||
reportMapper.upsertWatermark(gyid(), watermark, clockSkewMs, new Date());
|
||||
Map<String, Object> view = watermark();
|
||||
long threshold = properties.getReport().getClockSkewThresholdMs();
|
||||
view.put("clockSkewExceeded",
|
||||
clockSkewMs != null && Math.abs(clockSkewMs) > threshold);
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int ack(long ackedWatermark)
|
||||
{
|
||||
return reportMapper.ackWatermark(gyid(), Math.max(0, ackedWatermark));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> resume(List<AptResumeEnvelope> envelopes)
|
||||
{
|
||||
if (envelopes == null || envelopes.isEmpty())
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "EMPTY_RESUME_BATCH", "续报批次为空");
|
||||
}
|
||||
int maxBatch = properties.getReport().getResumeBatchLimit() > 0
|
||||
? properties.getReport().getResumeBatchLimit() : DEFAULT_RESUME_BATCH;
|
||||
if (envelopes.size() > maxBatch)
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "RESUME_BATCH_TOO_LARGE",
|
||||
"单次续报不能超过 " + maxBatch + " 条");
|
||||
}
|
||||
List<Map<String, Object>> items = new ArrayList<>(envelopes.size());
|
||||
int accepted = 0;
|
||||
int duplicated = 0;
|
||||
int rejected = 0;
|
||||
for (AptResumeEnvelope envelope : envelopes)
|
||||
{
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
if (envelope == null || text(envelope.getBizType()).isEmpty()
|
||||
|| text(envelope.getBizId()).isEmpty())
|
||||
{
|
||||
item.put("status", "REJECTED");
|
||||
item.put("reason", "MISSING_BIZ");
|
||||
items.add(item);
|
||||
rejected++;
|
||||
continue;
|
||||
}
|
||||
long version = envelope.getBizVersion() == null ? 1L : envelope.getBizVersion();
|
||||
String key = text(envelope.getIdempotencyKey());
|
||||
if (key.isEmpty())
|
||||
{
|
||||
key = sha256(gyid() + "|" + envelope.getBizType() + "|"
|
||||
+ envelope.getBizId() + "|" + version);
|
||||
}
|
||||
item.put("idempotencyKey", key);
|
||||
item.put("bizType", envelope.getBizType());
|
||||
item.put("bizId", envelope.getBizId());
|
||||
try
|
||||
{
|
||||
String payload = envelope.getPayload() == null ? null
|
||||
: JSON.toJSONString(envelope.getPayload());
|
||||
reportMapper.insertOutbox(gyid(), key, envelope.getBizType(), envelope.getBizId(),
|
||||
version, payload, envelope.getOccurredAt() == null ? new Date() : envelope.getOccurredAt());
|
||||
item.put("status", "ACCEPTED");
|
||||
accepted++;
|
||||
}
|
||||
catch (DuplicateKeyException e)
|
||||
{
|
||||
// PL-IDEMPOTENT-REPLAY:视为成功,不产生新业务事实
|
||||
item.put("status", "DUPLICATE");
|
||||
duplicated++;
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
log.warn("续报入队失败,bizType={}, bizId={}, cause={}",
|
||||
envelope.getBizType(), envelope.getBizId(), e.toString());
|
||||
item.put("status", "REJECTED");
|
||||
item.put("reason", "INSERT_FAILED");
|
||||
rejected++;
|
||||
}
|
||||
items.add(item);
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("accepted", accepted);
|
||||
result.put("duplicated", duplicated);
|
||||
result.put("rejected", rejected);
|
||||
result.put("items", items);
|
||||
return result;
|
||||
}
|
||||
|
||||
private String gyid()
|
||||
{
|
||||
String gyid = AptContext.getGyIdOrNull();
|
||||
if (gyid == null || gyid.isBlank())
|
||||
{
|
||||
throw new AptException(AptErrorCode.FORBIDDEN_GYID, "MISSING_GYID", "缺少公寓标识");
|
||||
}
|
||||
return gyid;
|
||||
}
|
||||
|
||||
private static String text(Object value)
|
||||
{
|
||||
return value == null ? "" : String.valueOf(value).trim();
|
||||
}
|
||||
|
||||
private static long longOf(Map<String, Object> row, String key)
|
||||
{
|
||||
if (row == null)
|
||||
{
|
||||
return 0L;
|
||||
}
|
||||
Object value = row.get(key);
|
||||
if (value instanceof Number)
|
||||
{
|
||||
return ((Number) value).longValue();
|
||||
}
|
||||
try
|
||||
{
|
||||
return value == null ? 0L : Long.parseLong(String.valueOf(value));
|
||||
}
|
||||
catch (NumberFormatException e)
|
||||
{
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private static String sha256(String raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder builder = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes)
|
||||
{
|
||||
builder.append(String.format("%02x", b));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new AptException(AptErrorCode.INTERNAL_ERROR, "HASH_FAILED", "幂等键计算失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.ruoyi.apt.report.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.report.domain.AptOutboxQuery;
|
||||
import com.ruoyi.apt.report.domain.AptResumeEnvelope;
|
||||
|
||||
/**
|
||||
* 上报续传:outbox 只读视图、失败重试、水位与心跳、断网续报。
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface IAptReportService
|
||||
{
|
||||
/** 管理端 outbox 列表(默认 100,上限 500) */
|
||||
List<Map<String, Object>> listOutbox(AptOutboxQuery query);
|
||||
|
||||
/** 队列态汇总 + 水位差(acked/current/maxWatermark) */
|
||||
Map<String, Object> stats();
|
||||
|
||||
/** FAILED/DEAD → PENDING,重试次数清零;非可重试态返回 40902 */
|
||||
Map<String, Object> requeue(Long id);
|
||||
|
||||
/** 水位视图;无记录时返回 0 值,不返回 null */
|
||||
Map<String, Object> watermark();
|
||||
|
||||
/** #14 心跳上报:记录 lastHeartbeatAt / clockSkewMs 并推进 current_watermark */
|
||||
Map<String, Object> recordHeartbeat(Long currentWatermark, Long clockSkewMs);
|
||||
|
||||
/** 平台回执:推进 acked_watermark(只增不减) */
|
||||
int ack(long ackedWatermark);
|
||||
|
||||
/** 断网续报:逐条按原 idempotencyKey 入队,重复视为成功(不产生新事实) */
|
||||
Map<String, Object> resume(List<AptResumeEnvelope> envelopes);
|
||||
}
|
||||
+69
-6
@@ -15,13 +15,18 @@ import com.ruoyi.apt.framework.audit.AptAuditWriter;
|
||||
import com.ruoyi.apt.framework.web.AptBaseController;
|
||||
import com.ruoyi.apt.ops.domain.AptOperationLog;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomCreateCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomDetail;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusResult;
|
||||
import com.ruoyi.apt.stay.service.IAptRoomService;
|
||||
import com.ruoyi.apt.voice.domain.AptSipIpCommand;
|
||||
import com.ruoyi.apt.voice.service.IAptSipEndpointService;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -35,7 +40,8 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
* 房间管理(IT-2 / M2,公寓端/04 表 4-7 接口 1~6)。
|
||||
*
|
||||
* <p>楼栋字段未启用(G-01)时 building 降级为楼层前缀匹配并在 warnings[] 标注;
|
||||
* 停用/恢复原因显式写 duty_operation_log;新增房间在 G-01/G-04 签字前恒返回 40940。</p>
|
||||
* 停用/恢复原因显式写 duty_operation_log;房间登记(POST)只把平台基线落成运行时记录,
|
||||
* 并在同一事务创建 SIP endpoint/aor/绑定,删除时反向同步下线。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@@ -44,11 +50,14 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class AptRoomController extends AptBaseController
|
||||
{
|
||||
private final IAptRoomService roomService;
|
||||
private final IAptSipEndpointService sipEndpointService;
|
||||
private final AptAuditWriter auditWriter;
|
||||
|
||||
public AptRoomController(IAptRoomService roomService, AptAuditWriter auditWriter)
|
||||
public AptRoomController(IAptRoomService roomService, IAptSipEndpointService sipEndpointService,
|
||||
AptAuditWriter auditWriter)
|
||||
{
|
||||
this.roomService = roomService;
|
||||
this.sipEndpointService = sipEndpointService;
|
||||
this.auditWriter = auditWriter;
|
||||
}
|
||||
|
||||
@@ -106,13 +115,67 @@ public class AptRoomController extends AptBaseController
|
||||
return AjaxResult.success(result);
|
||||
}
|
||||
|
||||
/** 表 4-7 #6:G-01/G-04 未签字前不开放本地新增,权限点 3014 亦保持置灰 */
|
||||
/**
|
||||
* 表 4-7 #6:房间登记。
|
||||
*
|
||||
* <p>只允许把平台基线 {@code apt_master_room} 落成 {@code gy_rooms},
|
||||
* 同事务创建 SIP 三件套;楼栋/床位等快照字段仍以平台为准。</p>
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('apt:room:add')")
|
||||
@AptAudit(module = "STAY", action = "ROOM_ADD")
|
||||
@PostMapping
|
||||
public AjaxResult add()
|
||||
public AjaxResult add(@Valid @RequestBody AptRoomCreateCommand command)
|
||||
{
|
||||
throw new AptException(AptErrorCode.FEATURE_DISABLED, "ROOM_ADD_DISABLED",
|
||||
"楼栋与房间新增口径(G-01/G-04)未定稿,本期不开放新增房间");
|
||||
AptRoomDetail created = roomService.create(command);
|
||||
return AjaxResult.success(created);
|
||||
}
|
||||
|
||||
/** 房间删除:软删房间并同步下线分机(有在寓人员时 40902) */
|
||||
@PreAuthorize("@ss.hasPermi('apt:room:edit')")
|
||||
@AptAudit(module = "STAY", action = "ROOM_DELETE")
|
||||
@DeleteMapping("/{id}")
|
||||
public AjaxResult delete(@PathVariable Long id)
|
||||
{
|
||||
roomService.delete(id);
|
||||
return AjaxResult.success();
|
||||
}
|
||||
|
||||
/** 房间 SIP 档案:endpoint + aor + contact(含历史)+ ACTIVE 绑定 */
|
||||
@PreAuthorize("@ss.hasPermi('apt:room:query')")
|
||||
@GetMapping("/{id}/sip")
|
||||
public AjaxResult sip(@PathVariable Long id)
|
||||
{
|
||||
return AjaxResult.success(sipEndpointService.viewByRoom(roomService.roomIdOf(id)));
|
||||
}
|
||||
|
||||
/** 补建/对账:确保该房间的 endpoint/aor/绑定存在(幂等,可用于历史房间自愈) */
|
||||
@PreAuthorize("@ss.hasPermi('apt:room:edit')")
|
||||
@AptAudit(module = "STAY", action = "ROOM_SIP_RECONCILE")
|
||||
@PostMapping("/{id}/sip/reconcile")
|
||||
public AjaxResult reconcileSip(@PathVariable Long id)
|
||||
{
|
||||
AptRoomDetail room = roomService.detail(id);
|
||||
return AjaxResult.success(sipEndpointService.ensureForRoom(
|
||||
room.getRoomId(), room.getRoomNo(), null, null));
|
||||
}
|
||||
|
||||
/** 绑定话机 IP(同一 IP 已绑定其它分机 → 40901) */
|
||||
@PreAuthorize("@ss.hasPermi('apt:room:edit')")
|
||||
@AptAudit(module = "STAY", action = "ROOM_SIP_BIND")
|
||||
@PostMapping("/{id}/sip/ip")
|
||||
public AjaxResult bindSipIp(@PathVariable Long id, @Valid @RequestBody AptSipIpCommand command)
|
||||
{
|
||||
return AjaxResult.success(sipEndpointService.bindIpByRoom(
|
||||
roomService.roomIdOf(id), command.getIp(), command.getPort()));
|
||||
}
|
||||
|
||||
/** 解绑话机 IP(释放 ACTIVE contact,保留历史) */
|
||||
@PreAuthorize("@ss.hasPermi('apt:room:edit')")
|
||||
@AptAudit(module = "STAY", action = "ROOM_SIP_UNBIND")
|
||||
@DeleteMapping("/{id}/sip/ip")
|
||||
public AjaxResult unbindSipIp(@PathVariable Long id)
|
||||
{
|
||||
return AjaxResult.success(sipEndpointService.unbindIpByRoom(roomService.roomIdOf(id)));
|
||||
}
|
||||
|
||||
/** 停用/恢复原因必须进审计 detail(表 7-3 M2「恢复触发重算」取证) */
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.ruoyi.apt.stay.domain;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 房间本地登记命令(表 4-7 #6)。
|
||||
*
|
||||
* <p>房间主数据仍以 {@code apt_master_room} 为只读基线:本命令只允许「按房号把平台基线
|
||||
* 落成 {@code gy_rooms} 运行时记录」,不接受床位/楼层等快照字段的改写。
|
||||
* 落库同时创建 SIP 三件套(endpoint / aor / 绑定),保证房间与分机一致。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptRoomCreateCommand
|
||||
{
|
||||
/** 房号,必须已存在于 apt_master_room(ACTIVE、未删除) */
|
||||
@NotBlank(message = "房号不能为空")
|
||||
@Size(max = 32, message = "房号长度不能超过 32")
|
||||
private String roomNo;
|
||||
|
||||
/** 展示名,空则由「房间 + 房号」派生,同时作为 endpoint 的 display_name */
|
||||
@Size(max = 64, message = "展示名长度不能超过 64")
|
||||
private String displayName;
|
||||
|
||||
/** 分机号,空则由房号派生(分机号创建后不可改) */
|
||||
@Size(max = 8, message = "分机号长度不能超过 8")
|
||||
private String extension;
|
||||
|
||||
public String getRoomNo()
|
||||
{
|
||||
return roomNo;
|
||||
}
|
||||
|
||||
public void setRoomNo(String roomNo)
|
||||
{
|
||||
this.roomNo = roomNo;
|
||||
}
|
||||
|
||||
public String getDisplayName()
|
||||
{
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName)
|
||||
{
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getExtension()
|
||||
{
|
||||
return extension;
|
||||
}
|
||||
|
||||
public void setExtension(String extension)
|
||||
{
|
||||
this.extension = extension;
|
||||
}
|
||||
}
|
||||
+13
@@ -26,6 +26,19 @@ public interface AptRoomMapper
|
||||
|
||||
AptRoom selectRoomById(@Param("gyid") String gyid, @Param("id") Long id);
|
||||
|
||||
AptRoom selectRoomByRoomId(@Param("gyid") String gyid, @Param("roomId") String roomId);
|
||||
|
||||
/** 平台基线:登记房间时按房号取只读快照(床位/楼层/房型) */
|
||||
Map<String, Object> selectMasterRoomByNo(@Param("gyid") String gyid, @Param("roomNo") String roomNo);
|
||||
|
||||
int countRoomByNo(@Param("gyid") String gyid, @Param("roomNo") String roomNo);
|
||||
|
||||
int insertRoom(@Param("gyid") String gyid, @Param("roomId") String roomId,
|
||||
@Param("roomNo") String roomNo, @Param("roomFloor") String roomFloor,
|
||||
@Param("roomType") String roomType);
|
||||
|
||||
int softDeleteRoom(@Param("gyid") String gyid, @Param("id") Long id);
|
||||
|
||||
List<Map<String, Object>> selectRoomBindings(@Param("gyid") String gyid,
|
||||
@Param("roomId") String roomId);
|
||||
|
||||
|
||||
+70
-2
@@ -11,11 +11,13 @@ import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.stay.domain.AptRoom;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomCreateCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomDetail;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusResult;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusSummary;
|
||||
import com.ruoyi.apt.stay.mapper.AptRoomMapper;
|
||||
import com.ruoyi.apt.voice.service.IAptSipEndpointService;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
@@ -45,10 +47,12 @@ public class AptRoomServiceImpl implements IAptRoomService
|
||||
private static final String ACTION_CORRECT = "CORRECT";
|
||||
|
||||
private final AptRoomMapper roomMapper;
|
||||
private final IAptSipEndpointService sipEndpointService;
|
||||
|
||||
public AptRoomServiceImpl(AptRoomMapper roomMapper)
|
||||
public AptRoomServiceImpl(AptRoomMapper roomMapper, IAptSipEndpointService sipEndpointService)
|
||||
{
|
||||
this.roomMapper = roomMapper;
|
||||
this.sipEndpointService = sipEndpointService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -92,7 +96,7 @@ public class AptRoomServiceImpl implements IAptRoomService
|
||||
@Override
|
||||
public void edit(Long id, AptRoomCommand command)
|
||||
{
|
||||
mustExist(id);
|
||||
AptRoom room = mustExist(id);
|
||||
if (command == null)
|
||||
{
|
||||
throw missing("请求体");
|
||||
@@ -114,6 +118,65 @@ public class AptRoomServiceImpl implements IAptRoomService
|
||||
throw invalid("roomOrder 不能为负数");
|
||||
}
|
||||
roomMapper.updateRoomLocal(gyid(), id, command);
|
||||
// 房间档案变更 → 同步 SIP 分机(展示名不动,仅保证 endpoint/aor/绑定存在)
|
||||
sipEndpointService.ensureForRoom(room.getRoomId(), room.getRoomNo(), null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AptRoomDetail create(AptRoomCreateCommand command)
|
||||
{
|
||||
if (command == null || command.getRoomNo() == null || command.getRoomNo().isBlank())
|
||||
{
|
||||
throw missing("roomNo");
|
||||
}
|
||||
String gyid = gyid();
|
||||
String roomNo = command.getRoomNo().trim();
|
||||
Map<String, Object> master = roomMapper.selectMasterRoomByNo(gyid, roomNo);
|
||||
if (master == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "ROOM_MASTER_NOT_FOUND",
|
||||
"平台房间基线不存在或未启用:" + roomNo);
|
||||
}
|
||||
if (roomMapper.countRoomByNo(gyid, roomNo) > 0)
|
||||
{
|
||||
throw new AptException(AptErrorCode.DUPLICATE_KEY, "ROOM_DUPLICATED",
|
||||
"房间 " + roomNo + " 已登记,请勿重复创建");
|
||||
}
|
||||
String roomId = stringValue(master.get("roomId"));
|
||||
roomMapper.insertRoom(gyid, roomId, roomNo,
|
||||
stringValue(master.get("roomFloor")), stringValue(master.get("roomType")));
|
||||
// 同一事务:房间落库 + SIP 三件套(endpoint / aor / ACTIVE 绑定),失败整体回滚
|
||||
sipEndpointService.ensureForRoom(roomId, roomNo, command.getDisplayName(), command.getExtension());
|
||||
AptRoom created = roomMapper.selectRoomByRoomId(gyid, roomId);
|
||||
if (created == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.DATA_ANOMALY, "ROOM_CREATE_FAILED", "房间创建后回读失败:" + roomNo);
|
||||
}
|
||||
return detail(created.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long id)
|
||||
{
|
||||
AptRoom room = mustExist(id);
|
||||
if (room.getInPersonCount() != null && room.getInPersonCount() > 0)
|
||||
{
|
||||
throw stateIllegal("房间仍有在寓人员,不可删除;请先办理退宿或换房");
|
||||
}
|
||||
// 先关 SIP 绑定/释放 contact,再软删房间,避免留下指向已删房间的 ACTIVE 绑定
|
||||
sipEndpointService.removeForRoom(room.getRoomId());
|
||||
if (roomMapper.softDeleteRoom(gyid(), id) == 0)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "ROOM_NOT_FOUND", "房间不存在:" + id);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String roomIdOf(Long id)
|
||||
{
|
||||
return mustExist(id).getRoomId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -300,6 +363,11 @@ public class AptRoomServiceImpl implements IAptRoomService
|
||||
return value instanceof Number ? ((Number) value).longValue() : Long.parseLong(value.toString());
|
||||
}
|
||||
|
||||
private String stringValue(Object value)
|
||||
{
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
private String requestedBy()
|
||||
{
|
||||
try
|
||||
|
||||
+13
@@ -3,6 +3,7 @@ package com.ruoyi.apt.stay.service;
|
||||
import java.util.List;
|
||||
import com.ruoyi.apt.stay.domain.AptRoom;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomCreateCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomDetail;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusResult;
|
||||
@@ -27,4 +28,16 @@ public interface IAptRoomService
|
||||
void edit(Long id, AptRoomCommand command);
|
||||
|
||||
AptRoomStatusResult changeStatus(Long id, AptRoomStatusCommand command);
|
||||
|
||||
/**
|
||||
* 房间登记:把平台基线 {@code apt_master_room} 落成 {@code gy_rooms},
|
||||
* 并在同一事务内创建 SIP 三件套(endpoint / aor / ACTIVE 绑定)。
|
||||
*/
|
||||
AptRoomDetail create(AptRoomCreateCommand command);
|
||||
|
||||
/** 房间删除:软删 {@code gy_rooms},同步关闭绑定、释放 contact、下线 endpoint/aor */
|
||||
void delete(Long id);
|
||||
|
||||
/** 供 SIP 子资源定位:按主键取 room_id(不存在返回 40401) */
|
||||
String roomIdOf(Long id);
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.ruoyi.apt.voice.domain;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 话机 IP 绑定命令(房间 → endpoint → contact)。
|
||||
*
|
||||
* <p>只接受话机自身 IP 与 SIP 端口:不接受 sip 域名或端口段,
|
||||
* 因为 contact 的 uri 要能被 Asterisk 直接注册/探测,含歧义的地址无法定位冲突。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public class AptSipIpCommand
|
||||
{
|
||||
/** 话机 IP(点分十进制 IPv4),同一公寓内只能绑定到一个分机 */
|
||||
@NotBlank(message = "话机 IP 不能为空")
|
||||
@Size(max = 64, message = "IP 长度不能超过 64")
|
||||
private String ip;
|
||||
|
||||
/** SIP 端口,缺省 5060 */
|
||||
private Integer port;
|
||||
|
||||
public String getIp()
|
||||
{
|
||||
return ip;
|
||||
}
|
||||
|
||||
public void setIp(String ip)
|
||||
{
|
||||
this.ip = ip;
|
||||
}
|
||||
|
||||
public Integer getPort()
|
||||
{
|
||||
return port;
|
||||
}
|
||||
|
||||
public void setPort(Integer port)
|
||||
{
|
||||
this.port = port;
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.ruoyi.apt.voice.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* SIP 分机 / AOR / Contact / 房间绑定 的数据访问。
|
||||
*
|
||||
* <p>硬约束:</p>
|
||||
* <ul>
|
||||
* <li>每条 SQL 必带 {@code gyid};</li>
|
||||
* <li>只写 {@code apt_sip_*} 四张表,不碰 {@code gy_jcxx}(叫乘状态仍只由状态机写);</li>
|
||||
* <li>软删除统一 {@code deleted=1},contact 解绑只置 {@code state='RELEASED'} 保留历史。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface AptSipEndpointMapper
|
||||
{
|
||||
// ------------------------------------------------------------ endpoint
|
||||
Map<String, Object> selectEndpointByRoom(@Param("gyid") String gyid, @Param("roomId") String roomId);
|
||||
|
||||
Map<String, Object> selectEndpoint(@Param("gyid") String gyid, @Param("endpointId") String endpointId);
|
||||
|
||||
int countEndpointByExtension(@Param("gyid") String gyid, @Param("extension") String extension);
|
||||
|
||||
int insertEndpoint(@Param("gyid") String gyid, @Param("endpointId") String endpointId,
|
||||
@Param("extension") String extension, @Param("roomId") String roomId,
|
||||
@Param("displayName") String displayName);
|
||||
|
||||
int updateEndpointProfile(@Param("gyid") String gyid, @Param("endpointId") String endpointId,
|
||||
@Param("displayName") String displayName, @Param("enabled") boolean enabled);
|
||||
|
||||
int updateEndpointIp(@Param("gyid") String gyid, @Param("endpointId") String endpointId,
|
||||
@Param("ipAddress") String ipAddress);
|
||||
|
||||
int softDeleteEndpoint(@Param("gyid") String gyid, @Param("endpointId") String endpointId);
|
||||
|
||||
// ----------------------------------------------------------------- aor
|
||||
Map<String, Object> selectAorByEndpoint(@Param("gyid") String gyid, @Param("endpointId") String endpointId);
|
||||
|
||||
int insertAor(@Param("gyid") String gyid, @Param("aorId") String aorId,
|
||||
@Param("endpointId") String endpointId);
|
||||
|
||||
int softDeleteAor(@Param("gyid") String gyid, @Param("endpointId") String endpointId);
|
||||
|
||||
// ------------------------------------------------------------- contact
|
||||
List<Map<String, Object>> selectContacts(@Param("gyid") String gyid, @Param("endpointId") String endpointId);
|
||||
|
||||
List<Map<String, Object>> selectActiveContacts(@Param("gyid") String gyid,
|
||||
@Param("endpointId") String endpointId);
|
||||
|
||||
Map<String, Object> selectActiveContactByIp(@Param("gyid") String gyid, @Param("ipAddress") String ipAddress);
|
||||
|
||||
int releaseContacts(@Param("gyid") String gyid, @Param("endpointId") String endpointId,
|
||||
@Param("remark") String remark);
|
||||
|
||||
int insertContact(@Param("gyid") String gyid, @Param("contactId") String contactId,
|
||||
@Param("aorId") String aorId, @Param("endpointId") String endpointId,
|
||||
@Param("uri") String uri, @Param("ipAddress") String ipAddress,
|
||||
@Param("port") int port);
|
||||
|
||||
// ----------------------------------------------------------------- auth
|
||||
/** 公寓当前启用的 Asterisk 实例(多实例场景取第一个启用项,认证按实例下发) */
|
||||
String selectActiveInstanceCode(@Param("gyid") String gyid);
|
||||
|
||||
Map<String, Object> selectAuth(@Param("gyid") String gyid, @Param("instanceCode") String instanceCode,
|
||||
@Param("username") String username);
|
||||
|
||||
/** 建认证账号;口令走表默认值(psw.voip),secret_mode 默认 PLAIN */
|
||||
int insertAuth(@Param("gyid") String gyid, @Param("instanceCode") String instanceCode,
|
||||
@Param("username") String username, @Param("authId") String authId,
|
||||
@Param("endpointId") String endpointId);
|
||||
|
||||
/** 房间删除/分机下线:禁用认证并置 PENDING,等待 Asterisk 收敛 */
|
||||
int disableAuthByEndpoint(@Param("gyid") String gyid, @Param("endpointId") String endpointId);
|
||||
|
||||
// ------------------------------------------------------------- binding
|
||||
Map<String, Object> selectActiveBindingByRoom(@Param("gyid") String gyid, @Param("roomId") String roomId);
|
||||
|
||||
Map<String, Object> selectActiveBindingByEndpoint(@Param("gyid") String gyid,
|
||||
@Param("endpointId") String endpointId);
|
||||
|
||||
int insertBinding(@Param("gyid") String gyid, @Param("bindingId") String bindingId,
|
||||
@Param("endpointId") String endpointId, @Param("roomId") String roomId);
|
||||
|
||||
int closeBindingsByRoom(@Param("gyid") String gyid, @Param("roomId") String roomId);
|
||||
|
||||
int closeBindingsByEndpoint(@Param("gyid") String gyid, @Param("endpointId") String endpointId);
|
||||
}
|
||||
+7
-1
@@ -74,7 +74,13 @@ public interface AptVoiceMapper
|
||||
|
||||
Map<String, Object> selectJcxxForCall(@Param("gyid") String gyid, @Param("jcid") String jcid);
|
||||
|
||||
/** 房间主分机(分机台账 + 房间绑定,ACTIVE 且在生效期内) */
|
||||
/**
|
||||
* 房间主分机(分机台账 + 房间绑定,ACTIVE 且在生效期内)。
|
||||
*
|
||||
* <p>入参是<b>房号</b>({@code gy_jcxx.fjh} = {@code apt_master_room.room_no},如 {@code 201}),
|
||||
* 不是 {@code room_id};SQL 内部经 {@code apt_master_room} 换算成 {@code room_id} 后再关联
|
||||
* {@code apt_sip_binding},避免两种标识混用。</p>
|
||||
*/
|
||||
Map<String, Object> selectRoomExtension(@Param("gyid") String gyid,
|
||||
@Param("roomNo") String roomNo);
|
||||
|
||||
|
||||
+3
-1
@@ -104,12 +104,14 @@ public class AptCallFlowServiceImpl implements IAptCallFlowService
|
||||
throw new AptException(AptErrorCode.DEPENDENCY_UNAVAILABLE, "AMI_INSTANCE_MISSING",
|
||||
"公寓未配置 Asterisk 实例");
|
||||
}
|
||||
// gy_jcxx.fjh 是房号(room_no),不是 room_id;换算与关联统一在 Mapper 内完成
|
||||
String roomNo = str(jcxx.get("fjh"));
|
||||
Map<String, Object> roomEndpoint = voiceMapper.selectRoomExtension(gyid, roomNo);
|
||||
if (roomEndpoint == null)
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "ENDPOINT_NOT_FOUND",
|
||||
"房间 " + roomNo + " 未绑定可用分机(apt_sip_binding / apt_sip_endpoint)");
|
||||
"房间 " + roomNo + " 未绑定可用分机(apt_master_room.room_no → "
|
||||
+ "apt_sip_binding.room_id → apt_sip_endpoint)");
|
||||
}
|
||||
|
||||
// 先落状态机再发起呼叫:即使 Originate 立即失败,也会经 CALL_FAILED 收敛到 ryzt=2
|
||||
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
package com.ruoyi.apt.voice.service;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.voice.mapper.AptSipEndpointMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* SIP 分机与房间生命周期同步实现。
|
||||
*
|
||||
* <p>一致性策略:</p>
|
||||
* <ul>
|
||||
* <li>「先库后 Asterisk」:库里落 PENDING + config_version+1,由 Asterisk 侧同步任务收敛,
|
||||
* 本类不直接改 Asterisk 配置;</li>
|
||||
* <li>三件套(endpoint / aor / ACTIVE binding)在同一事务内建成,缺一即整体回滚;</li>
|
||||
* <li>分机号不可改号:房间改号/换房不影响 extension,重复占用返回 40901;</li>
|
||||
* <li>IP 冲突在服务层先判(可读原因),库层唯一索引兜底并发。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Service
|
||||
public class AptSipEndpointServiceImpl implements IAptSipEndpointService
|
||||
{
|
||||
private static final Logger log = LoggerFactory.getLogger(AptSipEndpointServiceImpl.class);
|
||||
|
||||
/** 分机号:1~8 位字母数字(与 apt_sip_endpoint.extension CHAR(8) 对齐) */
|
||||
private static final Pattern EXTENSION = Pattern.compile("^[0-9A-Za-z]{1,8}$");
|
||||
|
||||
private static final Pattern IPV4 = Pattern.compile(
|
||||
"^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$");
|
||||
|
||||
private static final int DEFAULT_SIP_PORT = 5060;
|
||||
|
||||
private final AptSipEndpointMapper mapper;
|
||||
|
||||
public AptSipEndpointServiceImpl(AptSipEndpointMapper mapper)
|
||||
{
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> ensureForRoom(String roomId, String roomNo, String displayName, String extension)
|
||||
{
|
||||
String gyid = gyid();
|
||||
requireText(roomId, "roomId");
|
||||
Map<String, Object> existing = mapper.selectEndpointByRoom(gyid, roomId);
|
||||
String name = notBlank(displayName) ? displayName.trim() : "房间 " + safe(roomNo);
|
||||
if (!absent(existing))
|
||||
{
|
||||
// 幂等:已存在则只补齐缺失的 AOR / ACTIVE 绑定;
|
||||
// 展示名仅在调用方显式传入时覆盖,避免房间编辑把人工命名重置成默认值
|
||||
String endpointId = text(existing.get("endpointId"));
|
||||
if (notBlank(displayName) && !displayName.trim().equals(text(existing.get("displayName"))))
|
||||
{
|
||||
mapper.updateEndpointProfile(gyid, endpointId, name, true);
|
||||
}
|
||||
ensureAor(gyid, endpointId);
|
||||
ensureBinding(gyid, endpointId, roomId);
|
||||
ensureAuth(gyid, endpointId, text(existing.get("extension")));
|
||||
return viewByRoom(roomId);
|
||||
}
|
||||
|
||||
String ext = resolveExtension(extension, roomNo);
|
||||
if (mapper.countEndpointByExtension(gyid, ext) > 0)
|
||||
{
|
||||
throw conflict("EXTENSION_CONFLICT", "分机号 " + ext + " 已被占用(分机号不可复用)");
|
||||
}
|
||||
|
||||
String endpointId = "EP-" + suffix();
|
||||
try
|
||||
{
|
||||
mapper.insertEndpoint(gyid, endpointId, ext, roomId, name);
|
||||
}
|
||||
catch (DuplicateKeyException e)
|
||||
{
|
||||
// 并发或历史软删除占用同一分机号(UK 含已删除行,号码不复用)
|
||||
throw conflict("EXTENSION_CONFLICT", "分机号 " + ext + " 已被占用(分机号不可复用)");
|
||||
}
|
||||
ensureAor(gyid, endpointId);
|
||||
ensureBinding(gyid, endpointId, roomId);
|
||||
ensureAuth(gyid, endpointId, ext);
|
||||
log.info("房间 {} 已创建分机 endpoint={} ext={}(sync_state=PENDING,待 Asterisk 收敛)",
|
||||
roomId, endpointId, ext);
|
||||
return viewByRoom(roomId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> updateForRoom(String roomId, String displayName, boolean enabled)
|
||||
{
|
||||
String gyid = gyid();
|
||||
requireText(roomId, "roomId");
|
||||
Map<String, Object> endpoint = mapper.selectEndpointByRoom(gyid, roomId);
|
||||
if (absent(endpoint))
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "SIP_ENDPOINT_NOT_FOUND",
|
||||
"房间 " + roomId + " 尚未创建分机,请先补建");
|
||||
}
|
||||
String endpointId = text(endpoint.get("endpointId"));
|
||||
String name = notBlank(displayName) ? displayName.trim() : text(endpoint.get("displayName"));
|
||||
mapper.updateEndpointProfile(gyid, endpointId, name, enabled);
|
||||
return viewByRoom(roomId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void removeForRoom(String roomId)
|
||||
{
|
||||
String gyid = gyid();
|
||||
requireText(roomId, "roomId");
|
||||
Map<String, Object> endpoint = mapper.selectEndpointByRoom(gyid, roomId);
|
||||
// 绑定按房间关,避免 endpoint 已软删后查不到 room_id
|
||||
mapper.closeBindingsByRoom(gyid, roomId);
|
||||
if (absent(endpoint))
|
||||
{
|
||||
return;
|
||||
}
|
||||
String endpointId = text(endpoint.get("endpointId"));
|
||||
mapper.releaseContacts(gyid, endpointId, "ROOM_DELETED");
|
||||
mapper.disableAuthByEndpoint(gyid, endpointId);
|
||||
mapper.softDeleteAor(gyid, endpointId);
|
||||
mapper.softDeleteEndpoint(gyid, endpointId);
|
||||
log.info("房间 {} 已删除,分机 endpoint={} 同步下线(aor/contact 一并释放)", roomId, endpointId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> viewByRoom(String roomId)
|
||||
{
|
||||
String gyid = gyid();
|
||||
requireText(roomId, "roomId");
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("roomId", roomId);
|
||||
Map<String, Object> endpoint = mapper.selectEndpointByRoom(gyid, roomId);
|
||||
if (absent(endpoint))
|
||||
{
|
||||
result.put("endpoint", null);
|
||||
result.put("binding", mapper.selectActiveBindingByRoom(gyid, roomId));
|
||||
result.put("warnings", List.of("该房间尚未创建 SIP 分机(可调用 reconcile 补建)"));
|
||||
return result;
|
||||
}
|
||||
String endpointId = text(endpoint.get("endpointId"));
|
||||
result.put("endpoint", endpoint);
|
||||
result.put("aor", mapper.selectAorByEndpoint(gyid, endpointId));
|
||||
result.put("activeContacts", mapper.selectActiveContacts(gyid, endpointId));
|
||||
result.put("contactHistory", mapper.selectContacts(gyid, endpointId));
|
||||
result.put("binding", mapper.selectActiveBindingByRoom(gyid, roomId));
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> bindIp(String endpointId, String ipAddress, Integer port)
|
||||
{
|
||||
String gyid = gyid();
|
||||
requireText(endpointId, "endpointId");
|
||||
String ip = normalizeIp(ipAddress);
|
||||
int sipPort = normalizePort(port);
|
||||
|
||||
Map<String, Object> endpoint = mustEndpoint(gyid, endpointId);
|
||||
Map<String, Object> aor = ensureAor(gyid, endpointId);
|
||||
|
||||
Map<String, Object> occupied = mapper.selectActiveContactByIp(gyid, ip);
|
||||
if (!absent(occupied) && !endpointId.equals(text(occupied.get("endpointId"))))
|
||||
{
|
||||
// 同一 IP 已绑定到别的分机:不静默顶掉,要求先解绑,避免两台话机抢同一地址
|
||||
throw conflict("SIP_IP_CONFLICT", "IP " + ip + " 已绑定分机 "
|
||||
+ text(occupied.get("endpointId")) + "(" + text(occupied.get("uri"))
|
||||
+ "),请先解绑后再绑定");
|
||||
}
|
||||
if (!absent(occupied) && endpointId.equals(text(occupied.get("endpointId")))
|
||||
&& sipPort == intValue(occupied.get("port"), DEFAULT_SIP_PORT))
|
||||
{
|
||||
// 幂等:同分机同 IP 同端口,重复绑定直接返回
|
||||
return viewAfterChange(endpoint, endpointId);
|
||||
}
|
||||
|
||||
mapper.releaseContacts(gyid, endpointId, "REBIND");
|
||||
String uri = "sip:" + text(endpoint.get("extension")) + "@" + ip + ":" + sipPort;
|
||||
try
|
||||
{
|
||||
mapper.insertContact(gyid, "CT-" + suffix(), text(aor.get("aorId")), endpointId,
|
||||
uri, ip, sipPort);
|
||||
}
|
||||
catch (DuplicateKeyException e)
|
||||
{
|
||||
// 并发下两个分机同时抢同一 IP,库层唯一索引兜底
|
||||
throw conflict("SIP_IP_CONFLICT", "IP " + ip + " 已被其它分机占用(并发冲突)");
|
||||
}
|
||||
mapper.updateEndpointIp(gyid, endpointId, ip);
|
||||
log.info("分机 {} 绑定话机 IP {}:{}(uri={},sync_state=PENDING)", endpointId, ip, sipPort, uri);
|
||||
return viewAfterChange(endpoint, endpointId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> unbindIp(String endpointId)
|
||||
{
|
||||
String gyid = gyid();
|
||||
requireText(endpointId, "endpointId");
|
||||
Map<String, Object> endpoint = mustEndpoint(gyid, endpointId);
|
||||
mapper.releaseContacts(gyid, endpointId, "UNBIND");
|
||||
mapper.updateEndpointIp(gyid, endpointId, null);
|
||||
log.info("分机 {} 已解绑话机 IP(sync_state=PENDING)", endpointId);
|
||||
return viewAfterChange(endpoint, endpointId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> bindIpByRoom(String roomId, String ipAddress, Integer port)
|
||||
{
|
||||
return bindIp(requireEndpointOfRoom(gyid(), roomId), ipAddress, port);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> unbindIpByRoom(String roomId)
|
||||
{
|
||||
return unbindIp(requireEndpointOfRoom(gyid(), roomId));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 内部
|
||||
|
||||
/** 房间视角找分机:未建分机直接 40401,避免「解绑成功但什么也没发生」的假象 */
|
||||
private String requireEndpointOfRoom(String gyid, String roomId)
|
||||
{
|
||||
requireText(roomId, "roomId");
|
||||
Map<String, Object> endpoint = mapper.selectEndpointByRoom(gyid, roomId);
|
||||
if (absent(endpoint))
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "SIP_ENDPOINT_NOT_FOUND",
|
||||
"房间 " + roomId + " 尚未创建分机,请先补建(reconcile)");
|
||||
}
|
||||
return text(endpoint.get("endpointId"));
|
||||
}
|
||||
|
||||
/** 变更后回显:正常按房间聚合;分机已脱离房间(room_id 为空)时退回分机视图 */
|
||||
private Map<String, Object> viewAfterChange(Map<String, Object> endpoint, String endpointId)
|
||||
{
|
||||
String roomId = text(endpoint.get("roomId"));
|
||||
if (roomId.isBlank())
|
||||
{
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("endpoint", mapper.selectEndpoint(gyid(), endpointId));
|
||||
return result;
|
||||
}
|
||||
return viewByRoom(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证账号(apt_sip_auth):与 endpoint 1:1,按「公寓 + Asterisk 实例」隔离。
|
||||
*
|
||||
* <p>无启用实例时跳过并告警——房间/分机可以先建,等实例就绪后 reconcile 补齐;
|
||||
* 口令不在此处传递,走表默认值(psw.voip / PLAIN),生产改 ENV 模式后由环境注入。</p>
|
||||
*/
|
||||
private void ensureAuth(String gyid, String endpointId, String username)
|
||||
{
|
||||
if (!notBlank(username))
|
||||
{
|
||||
return;
|
||||
}
|
||||
String instanceCode = mapper.selectActiveInstanceCode(gyid);
|
||||
if (!notBlank(instanceCode))
|
||||
{
|
||||
log.warn("公寓 {} 无启用 Asterisk 实例,分机 {} 暂不建 SIP 认证(实例就绪后 reconcile 补齐)",
|
||||
gyid, endpointId);
|
||||
return;
|
||||
}
|
||||
// 同实例同用户名已有账号即复用,不覆盖既有口令与轮换版本
|
||||
if (!absent(mapper.selectAuth(gyid, instanceCode, username)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
mapper.insertAuth(gyid, instanceCode, username,
|
||||
"AUTH-" + instanceCode + "-" + username, endpointId);
|
||||
}
|
||||
|
||||
private Map<String, Object> mustEndpoint(String gyid, String endpointId)
|
||||
{
|
||||
Map<String, Object> endpoint = mapper.selectEndpoint(gyid, endpointId);
|
||||
if (absent(endpoint))
|
||||
{
|
||||
throw new AptException(AptErrorCode.NOT_FOUND, "SIP_ENDPOINT_NOT_FOUND",
|
||||
"分机不存在或已删除:" + endpointId);
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
/** AOR 与 endpoint 1:1,缺失即补建(自愈,不覆盖已有 max_contacts 等参数) */
|
||||
private Map<String, Object> ensureAor(String gyid, String endpointId)
|
||||
{
|
||||
Map<String, Object> aor = mapper.selectAorByEndpoint(gyid, endpointId);
|
||||
if (!absent(aor))
|
||||
{
|
||||
return aor;
|
||||
}
|
||||
String aorId = "AOR-" + suffix();
|
||||
mapper.insertAor(gyid, aorId, endpointId);
|
||||
return mapper.selectAorByEndpoint(gyid, endpointId);
|
||||
}
|
||||
|
||||
/** 房间绑定缺失即补建;已有其它 ACTIVE 绑定时先关闭再建(一个 endpoint 仅一条 ACTIVE) */
|
||||
private void ensureBinding(String gyid, String endpointId, String roomId)
|
||||
{
|
||||
Map<String, Object> current = mapper.selectActiveBindingByRoom(gyid, roomId);
|
||||
if (!absent(current) && endpointId.equals(text(current.get("endpointId"))))
|
||||
{
|
||||
return;
|
||||
}
|
||||
mapper.closeBindingsByEndpoint(gyid, endpointId);
|
||||
mapper.closeBindingsByRoom(gyid, roomId);
|
||||
mapper.insertBinding(gyid, "BND-" + suffix(), endpointId, roomId);
|
||||
}
|
||||
|
||||
private String resolveExtension(String extension, String roomNo)
|
||||
{
|
||||
String value = notBlank(extension) ? extension.trim() : safe(roomNo).trim();
|
||||
if (!EXTENSION.matcher(value).matches())
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "EXTENSION_INVALID",
|
||||
"分机号必须是 1~8 位字母或数字(roomNo=" + roomNo + ",extension=" + value + ")");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String normalizeIp(String ipAddress)
|
||||
{
|
||||
String ip = safe(ipAddress).trim();
|
||||
if (!IPV4.matcher(ip).matches())
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "SIP_IP_INVALID",
|
||||
"话机 IP 必须是点分十进制 IPv4:" + ipAddress);
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
private int normalizePort(Integer port)
|
||||
{
|
||||
int value = port == null ? DEFAULT_SIP_PORT : port;
|
||||
if (value < 1 || value > 65535)
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "SIP_PORT_INVALID", "SIP 端口超出 1~65535:" + port);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private AptException conflict(String reason, String message)
|
||||
{
|
||||
return new AptException(AptErrorCode.DUPLICATE_KEY, reason, message);
|
||||
}
|
||||
|
||||
private void requireText(String value, String field)
|
||||
{
|
||||
if (!notBlank(value))
|
||||
{
|
||||
throw new AptException(AptErrorCode.BAD_REQUEST, "PARAM_INVALID", "缺少必填字段: " + field);
|
||||
}
|
||||
}
|
||||
|
||||
/** 12 位十六进制后缀,保证 endpoint/aor/contact/binding 标识可读且唯一 */
|
||||
private String suffix()
|
||||
{
|
||||
return UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase();
|
||||
}
|
||||
|
||||
private boolean notBlank(String value)
|
||||
{
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
/** 查询无记录:MyBatis 返回 null;mock/空结果集返回空 Map,两种都要当作「不存在」 */
|
||||
private boolean absent(Map<String, Object> row)
|
||||
{
|
||||
return row == null || row.isEmpty();
|
||||
}
|
||||
|
||||
private String text(Object value)
|
||||
{
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
|
||||
private String safe(String value)
|
||||
{
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private int intValue(Object value, int defaultValue)
|
||||
{
|
||||
return value instanceof Number number ? number.intValue() : defaultValue;
|
||||
}
|
||||
|
||||
private String gyid()
|
||||
{
|
||||
return AptContext.getGyId();
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.ruoyi.apt.voice.service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* SIP 分机(endpoint / aor / contact)与房间的生命周期同步。
|
||||
*
|
||||
* <p>调用方约定:</p>
|
||||
* <ul>
|
||||
* <li>房间新建 → {@link #ensureForRoom}(幂等,已存在则按需补齐 AOR/绑定);</li>
|
||||
* <li>房间修改 → {@link #updateForRoom}(只同步展示名/启用态,分机号不可改);</li>
|
||||
* <li>房间删除 → {@link #removeForRoom}(关绑定 + 释放 contact + 软删 endpoint/aor);</li>
|
||||
* <li>话机 IP → {@link #bindIp} / {@link #unbindIp},同 IP 冲突返回 40901。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>gyid 一律取自 {@code AptContext},不接受入参,避免跨公寓改配置。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
public interface IAptSipEndpointService
|
||||
{
|
||||
/**
|
||||
* 房间创建/补建:保证 endpoint + aor + ACTIVE 绑定三件套存在。
|
||||
*
|
||||
* @param roomId 房间主键(apt_master_room.room_id / gy_rooms.room_id)
|
||||
* @param roomNo 房号(仅用于派生分机号与展示名)
|
||||
* @param displayName 展示名,空则用「房间 {roomNo}」
|
||||
* @param extension 指定分机号,空则由房号派生
|
||||
*/
|
||||
Map<String, Object> ensureForRoom(String roomId, String roomNo, String displayName, String extension);
|
||||
|
||||
/** 房间修改:同步展示名与启用态(分机号不可改,违反返回 40906) */
|
||||
Map<String, Object> updateForRoom(String roomId, String displayName, boolean enabled);
|
||||
|
||||
/** 房间删除:关闭绑定、释放 contact、软删 aor/endpoint(幂等) */
|
||||
void removeForRoom(String roomId);
|
||||
|
||||
/** 房间视角聚合:endpoint + aor + contact 历史 + ACTIVE 绑定 */
|
||||
Map<String, Object> viewByRoom(String roomId);
|
||||
|
||||
/** 绑定话机 IP:先释放该分机旧 contact,再写新 contact(同 IP 冲突 → 40901) */
|
||||
Map<String, Object> bindIp(String endpointId, String ipAddress, Integer port);
|
||||
|
||||
/** 解绑话机 IP:释放该分机全部 ACTIVE contact 并清空 endpoint.ip_address */
|
||||
Map<String, Object> unbindIp(String endpointId);
|
||||
|
||||
/** 房间视角绑定:内部先按 roomId 找分机,未建分机返回 40401 */
|
||||
Map<String, Object> bindIpByRoom(String roomId, String ipAddress, Integer port);
|
||||
|
||||
/** 房间视角解绑:同上 */
|
||||
Map<String, Object> unbindIpByRoom(String roomId);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.ruoyi.quartz.task;
|
||||
|
||||
import com.ruoyi.apt.dispatch.tablet.service.IAptPhotoPurgeService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 刷脸照片保留期清理 Quartz 任务(最多保留 1 个月)。
|
||||
*
|
||||
* <p>放在 {@code com.ruoyi.quartz.task} 是因为若依
|
||||
* {@code ScheduleUtils.whiteList} 只允许该前缀的 {@code invokeTarget};
|
||||
* 业务逻辑仍在 {@code ruoyi-apt},本类只做入口。</p>
|
||||
*
|
||||
* <p>建议 cron:{@code 0 30 3 * * ?}(每天 03:30,避开叫乘高峰),
|
||||
* 由 {@code sys_job} 登记,{@code invoke_target = aptPhotoPurgeTask.purge()}。</p>
|
||||
*
|
||||
* @author apt-rev01
|
||||
*/
|
||||
@Component("aptPhotoPurgeTask")
|
||||
public class AptPhotoPurgeTask
|
||||
{
|
||||
private final IAptPhotoPurgeService purgeService;
|
||||
|
||||
public AptPhotoPurgeTask(IAptPhotoPurgeService purgeService)
|
||||
{
|
||||
this.purgeService = purgeService;
|
||||
}
|
||||
|
||||
/** 无参入口,返回统计便于 Quartz 日志留痕 */
|
||||
public void purge()
|
||||
{
|
||||
purgeService.purgeExpired();
|
||||
}
|
||||
}
|
||||
@@ -84,3 +84,69 @@ apt:
|
||||
scan-batch-size: ${APT_SCAN_BATCH:50}
|
||||
tts-template: ${APT_TTS_TEMPLATE:CALL_ROOM}
|
||||
tts-text: ${APT_TTS_TEXT:请到门口集合,准备乘车}
|
||||
# ---------------------------------------------------------------------------
|
||||
# 语音链路(AMI)。默认关闭:未接 Asterisk 的环境不得因缺依赖启动失败,
|
||||
# 也不得静默改状态(公寓端/06 §7「依赖缺失显式降级」)。
|
||||
#
|
||||
# 注意:AMI 地址/端口/账号/各项超时**不在本文件**,而在 apt_ast_instance 表
|
||||
# (一公寓一实例,随实例走);AMI 口令只存环境变量名 ami_secret_ref,
|
||||
# 运行期从进程环境读取(默认 APT_AMI_SECRET),不落库、不进仓库、不打日志。
|
||||
# ---------------------------------------------------------------------------
|
||||
voice:
|
||||
# 总开关:false 时不建 AMI 连接、不发起 SIP 叫乘
|
||||
enabled: ${APT_VOICE_ENABLED:false}
|
||||
# 到点自动发起 SIP 叫乘;false 时只能由值班台手动
|
||||
# POST /apt/jcxx/{jcid}/dial 触发
|
||||
sip-call-enabled: ${APT_VOICE_SIP_CALL_ENABLED:true}
|
||||
# 值班台分机(双腿呼叫的第二腿),逗号分隔,例:1002,1003;空=只呼房间腿
|
||||
duty-extensions: ${APT_VOICE_DUTY_EXTENSIONS:}
|
||||
# TTS 播报:Originate 到 Local/<tts-extension>@<tts-context>,
|
||||
# Application=Playback、Data=<tts-media-prefix>(音频由 Asterisk 侧预先合成)
|
||||
tts-context: ${APT_VOICE_TTS_CONTEXT:apt-tts}
|
||||
tts-extension: ${APT_VOICE_TTS_EXTENSION:s}
|
||||
tts-text: ${APT_VOICE_TTS_TEXT:请到门口集合,准备乘车}
|
||||
tts-media-prefix: ${APT_VOICE_TTS_MEDIA_PREFIX:apt/call-prompt}
|
||||
# 单次 Originate 超时(毫秒),同时作为 AMI Action 等待上限
|
||||
dial-timeout-ms: ${APT_VOICE_DIAL_TIMEOUT_MS:30000}
|
||||
# AMI 事件队列容量(单实例单线程保序;满了丢弃并告警,不阻塞读线程)
|
||||
event-queue-capacity: ${APT_VOICE_EVENT_QUEUE_CAPACITY:2000}
|
||||
# 单次扫描最多发起数,避免停机补跑时瞬间打爆 Asterisk
|
||||
max-calls-per-scan: ${APT_VOICE_MAX_CALLS_PER_SCAN:10}
|
||||
# ---------------------------------------------------------------------------
|
||||
# 本地联调端点(/apt/local/**,见 AptLocalDebugController)。
|
||||
# 生产必须保持 false:开启后可用 HTTP 注入 #7 匹配结论与 AMI 事件,
|
||||
# 等同绕过平台拉取器与真实 Asterisk,仅用于无外部依赖时验证状态机。
|
||||
# ---------------------------------------------------------------------------
|
||||
debug:
|
||||
enabled: ${APT_DEBUG_ENABLED:false}
|
||||
# ---------------------------------------------------------------------------
|
||||
# 平板刷脸照片(叫乘作业留痕)
|
||||
# 只存「相对路径 + 校验和 + 状态」,图像本体落文件/对象存储,绝不进 BLOB。
|
||||
# 路径规范:face/{gyid}/{yyyy}/{MM}/{dd}/{jcid}/{photoId}.{jpg|png}
|
||||
# 注意:spring.servlet.multipart.max-file-size 必须 >= 这里的 max-size-bytes
|
||||
# (当前 10MB / 5MB,超出上传会在 Spring 层先被拒绝)。
|
||||
# ---------------------------------------------------------------------------
|
||||
photo:
|
||||
root: ${APT_PHOTO_ROOT:./data/apt-photos}
|
||||
max-size-bytes: ${APT_PHOTO_MAX_BYTES:5242880}
|
||||
public-prefix: ${APT_PHOTO_PUBLIC_PREFIX:/apt/files}
|
||||
storage-target: ${APT_PHOTO_STORAGE_TARGET:LOCAL}
|
||||
# 保留期(天):业务要求「照片记录最多保存 1 个月」,默认 30;
|
||||
# 由 aptPhotoPurgeTask.purge() 每天 03:30 清理文件与记录
|
||||
retention-days: ${APT_PHOTO_RETENTION_DAYS:30}
|
||||
# 单批清理条数,避免一次锁表
|
||||
purge-batch-size: ${APT_PHOTO_PURGE_BATCH:200}
|
||||
# ---------------------------------------------------------------------------
|
||||
# IT-6 上报续传(outbox / 水位 / #14 心跳)
|
||||
# dispatch-enabled=false:outbox 只累积、不派发,也不允许假置 SENT;
|
||||
# 生产保持 false,待 Q-13 联调环境与凭据就绪后再开启。
|
||||
# ---------------------------------------------------------------------------
|
||||
report:
|
||||
dispatch-enabled: ${APT_REPORT_DISPATCH_ENABLED:false}
|
||||
batch-size: ${APT_REPORT_BATCH_SIZE:200}
|
||||
max-retry: ${APT_REPORT_MAX_RETRY:5}
|
||||
# 心跳时钟偏差告警阈值(毫秒),验收口径 ≤5s
|
||||
clock-skew-threshold-ms: ${APT_REPORT_CLOCK_SKEW_MS:5000}
|
||||
# 超过该秒数无心跳视为离线(不据此伪造 ACKED)
|
||||
heartbeat-timeout-seconds: ${APT_REPORT_HEARTBEAT_TIMEOUT_SEC:90}
|
||||
resume-batch-limit: ${APT_REPORT_RESUME_BATCH:200}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.apt.device.mapper.AptDeviceMapper">
|
||||
|
||||
<sql id="deviceColumns">
|
||||
d.device_code AS deviceCode,
|
||||
d.device_type AS deviceType,
|
||||
d.room_id AS roomId,
|
||||
d.state AS state,
|
||||
d.last_seen_at AS lastSeenAt,
|
||||
d.face_scope_type AS faceScopeType,
|
||||
d.face_scope_ref AS faceScopeRef,
|
||||
x.online_state AS onlineState,
|
||||
x.heartbeat_at AS heartbeatAt,
|
||||
x.app_version AS appVersion
|
||||
</sql>
|
||||
|
||||
<select id="selectDeviceList" resultType="java.util.LinkedHashMap">
|
||||
SELECT <include refid="deviceColumns"/>
|
||||
FROM gy_sb d
|
||||
LEFT JOIN gy_sb_xt x
|
||||
ON x.gyid = d.gyid AND x.device_code = d.device_code AND x.deleted = 0
|
||||
WHERE d.gyid = #{gyid} AND d.deleted = 0
|
||||
<if test="deviceType != null and deviceType != ''">
|
||||
AND d.device_type = #{deviceType}
|
||||
</if>
|
||||
<if test="state != null and state != ''">
|
||||
AND d.state = #{state}
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (d.device_code LIKE CONCAT('%', #{keyword}, '%')
|
||||
OR d.room_id LIKE CONCAT('%', #{keyword}, '%'))
|
||||
</if>
|
||||
ORDER BY d.device_code
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<select id="selectDevice" resultType="java.util.LinkedHashMap">
|
||||
SELECT <include refid="deviceColumns"/>
|
||||
FROM gy_sb d
|
||||
LEFT JOIN gy_sb_xt x
|
||||
ON x.gyid = d.gyid AND x.device_code = d.device_code AND x.deleted = 0
|
||||
WHERE d.gyid = #{gyid} AND d.device_code = #{deviceCode} AND d.deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="upsertHeartbeat">
|
||||
INSERT INTO gy_sb_xt
|
||||
(gyid, device_code, online_state, app_version, heartbeat_at, deleted)
|
||||
VALUES
|
||||
(#{gyid}, #{deviceCode}, 'ONLINE', #{appVersion}, #{at}, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
online_state = 'ONLINE',
|
||||
app_version = VALUES(app_version),
|
||||
heartbeat_at = VALUES(heartbeat_at),
|
||||
deleted = 0,
|
||||
update_time = NOW(3)
|
||||
</insert>
|
||||
|
||||
<update id="updateDeviceSeen">
|
||||
UPDATE gy_sb
|
||||
SET last_seen_at = #{at}
|
||||
WHERE gyid = #{gyid} AND device_code = #{deviceCode} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<select id="selectCommand" resultType="java.util.LinkedHashMap">
|
||||
SELECT command_id AS commandId,
|
||||
device_code AS deviceCode,
|
||||
command_type AS commandType,
|
||||
CAST(payload_json AS CHAR) AS payload,
|
||||
state,
|
||||
protocol_confirmed AS protocolConfirmed,
|
||||
face_scope_ref AS faceScopeRef,
|
||||
sent_at AS sentAt,
|
||||
acked_at AS ackedAt,
|
||||
expire_at AS expireAt,
|
||||
last_error AS lastError,
|
||||
create_time AS createTime
|
||||
FROM apt_device_command
|
||||
WHERE gyid = #{gyid} AND command_id = #{commandId} AND deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="insertCommand">
|
||||
INSERT INTO apt_device_command
|
||||
(gyid, command_id, device_code, command_type, payload_json, state,
|
||||
protocol_confirmed, face_scope_ref, expire_at, deleted)
|
||||
VALUES
|
||||
(#{gyid}, #{commandId}, #{deviceCode}, #{commandType}, #{payload}, 'PENDING',
|
||||
0, #{faceScopeRef}, #{expireAt}, 0)
|
||||
</insert>
|
||||
|
||||
<select id="selectCommandList" resultType="java.util.LinkedHashMap">
|
||||
SELECT command_id AS commandId,
|
||||
device_code AS deviceCode,
|
||||
command_type AS commandType,
|
||||
state,
|
||||
protocol_confirmed AS protocolConfirmed,
|
||||
face_scope_ref AS faceScopeRef,
|
||||
sent_at AS sentAt,
|
||||
acked_at AS ackedAt,
|
||||
expire_at AS expireAt,
|
||||
last_error AS lastError,
|
||||
create_time AS createTime
|
||||
FROM apt_device_command
|
||||
WHERE gyid = #{gyid} AND device_code = #{deviceCode} AND deleted = 0
|
||||
<if test="state != null and state != ''">
|
||||
AND state = #{state}
|
||||
</if>
|
||||
ORDER BY create_time DESC, id DESC
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -19,6 +19,12 @@
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectRoomNoByJcid" resultType="java.lang.String">
|
||||
SELECT fjh FROM gy_jcxx
|
||||
WHERE gyid = #{gyid} AND jcid = #{jcid} AND deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<update id="updateCallStart">
|
||||
UPDATE gy_jcxx
|
||||
SET ryzt = '1',
|
||||
|
||||
@@ -110,6 +110,21 @@
|
||||
ORDER BY r.create_time DESC
|
||||
</select>
|
||||
|
||||
<!-- 刷脸照片:按 jcid 关联,失败记录同样返回(uploadState=FAILED + failReason) -->
|
||||
<select id="selectFacePhotos" resultType="java.util.LinkedHashMap">
|
||||
SELECT p.photo_id AS photoId, p.result_id AS resultId, p.task_id AS taskId,
|
||||
p.ryid, p.device_code AS deviceCode,
|
||||
p.storage_target AS storageTarget, p.storage_path AS storagePath,
|
||||
p.file_name AS fileName, p.content_type AS contentType,
|
||||
p.file_size AS fileSize, p.sha256,
|
||||
p.upload_state AS uploadState, p.fail_reason AS failReason,
|
||||
p.retry_count AS retryCount,
|
||||
p.captured_at AS capturedAt, p.uploaded_at AS uploadedAt
|
||||
FROM apt_tablet_face_photo p
|
||||
WHERE p.gyid = #{gyid} AND p.jcid = #{jcid} AND p.deleted = 0
|
||||
ORDER BY p.uploaded_at DESC, p.id DESC
|
||||
</select>
|
||||
|
||||
<select id="selectManualActions" resultType="java.util.LinkedHashMap">
|
||||
SELECT a.action_id AS actionId, a.action_type AS actionType, a.state,
|
||||
a.duplicated, a.result_code AS resultCode, a.reason,
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.apt.job.mapper.AptJobMapper">
|
||||
|
||||
<!--
|
||||
保洁(gy_clean_task)/ 维修(apt_repair_order)/ 人员(apt_staff)/ 流水(apt_job_event)。
|
||||
硬约束:
|
||||
1) 每条 SQL 必带 gyid;
|
||||
2) 状态迁移一律「条件更新 + 影响行数」,不做先查后改的乐观锁猜测;
|
||||
3) 本文件不写 gy_jcxx;房态由服务层用 AptRoomMapper.updateRoomStatus 条件更新。
|
||||
-->
|
||||
|
||||
<sql id="cleanColumns">
|
||||
c.task_id AS taskId, c.room_id AS roomId,
|
||||
COALESCE(c.room_no, r.room_no) AS roomNo,
|
||||
c.state, c.assignee_staff_id AS assigneeStaffId, s.name AS assigneeName,
|
||||
c.scheduled_at AS scheduledAt, c.started_at AS startedAt, c.finished_at AS finishedAt,
|
||||
c.remark, c.source,
|
||||
TIMESTAMPDIFF(SECOND, c.started_at, COALESCE(c.finished_at, NOW(3))) AS durationSec
|
||||
</sql>
|
||||
|
||||
<sql id="repairColumns">
|
||||
o.order_id AS orderId, o.room_id AS roomId, o.room_no AS roomNo,
|
||||
o.category, o.description, o.priority, o.source, o.state,
|
||||
o.reporter_staff_id AS reporterStaffId, o.reporter_name AS reporterName,
|
||||
o.assignee_staff_id AS assigneeStaffId, o.assignee_name AS assigneeName,
|
||||
o.room_status_before AS roomStatusBefore, o.room_status_after AS roomStatusAfter,
|
||||
o.result, o.remark, o.reported_at AS reportedAt, o.assigned_at AS assignedAt,
|
||||
o.started_at AS startedAt, o.finished_at AS finishedAt, o.duration_sec AS durationSec
|
||||
</sql>
|
||||
|
||||
<sql id="jobFilter">
|
||||
<if test="q.state != null and q.state != ''">
|
||||
AND t.state = #{q.state}
|
||||
</if>
|
||||
<if test="q.staffId != null and q.staffId != ''">
|
||||
<choose>
|
||||
<when test="q.includeUnassigned != null and q.includeUnassigned">
|
||||
AND (t.assigneeStaffId = #{q.staffId} OR t.assigneeStaffId IS NULL)
|
||||
</when>
|
||||
<otherwise>
|
||||
AND t.assigneeStaffId = #{q.staffId}
|
||||
</otherwise>
|
||||
</choose>
|
||||
</if>
|
||||
<if test="q.roomNo != null and q.roomNo != ''">
|
||||
AND t.roomNo = #{q.roomNo}
|
||||
</if>
|
||||
<if test="q.from != null">
|
||||
AND t.biz_time >= #{q.from}
|
||||
</if>
|
||||
<if test="q.to != null">
|
||||
AND t.biz_time <= #{q.to}
|
||||
</if>
|
||||
</sql>
|
||||
|
||||
<!--- 公共 -->
|
||||
<select id="selectRoomByNo" resultType="java.util.LinkedHashMap">
|
||||
SELECT id, room_id AS roomId, room_no AS roomNo, room_status AS roomStatus,
|
||||
state_version AS stateVersion, in_person_count AS inPersonCount,
|
||||
bed_count AS bedCount
|
||||
FROM gy_rooms
|
||||
WHERE gyid = #{gyid} AND room_no = #{roomNo} AND deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectCleanTask" resultType="java.util.LinkedHashMap">
|
||||
SELECT <include refid="cleanColumns"/>
|
||||
FROM gy_clean_task c
|
||||
LEFT JOIN gy_rooms r ON r.gyid = c.gyid AND r.room_id = c.room_id
|
||||
LEFT JOIN apt_staff s ON s.gyid = c.gyid AND s.staff_id = c.assignee_staff_id
|
||||
WHERE c.gyid = #{gyid} AND c.task_id = #{taskId} AND c.deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectActiveCleanTaskByRoom" resultType="java.util.LinkedHashMap">
|
||||
SELECT <include refid="cleanColumns"/>
|
||||
FROM gy_clean_task c
|
||||
LEFT JOIN gy_rooms r ON r.gyid = c.gyid AND r.room_id = c.room_id
|
||||
LEFT JOIN apt_staff s ON s.gyid = c.gyid AND s.staff_id = c.assignee_staff_id
|
||||
WHERE c.gyid = #{gyid} AND c.room_id = #{roomId} AND c.deleted = 0
|
||||
AND c.state IN ('PENDING','PROCESSING')
|
||||
ORDER BY c.scheduled_at DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="insertCleanTask">
|
||||
INSERT INTO gy_clean_task
|
||||
(gyid, task_id, room_id, room_no, state, assignee_staff_id,
|
||||
scheduled_at, remark, source, deleted)
|
||||
VALUES
|
||||
(#{gyid}, #{taskId}, #{roomId}, #{roomNo}, 'PENDING', #{staffId},
|
||||
#{scheduledAt}, #{remark}, #{source}, 0)
|
||||
</insert>
|
||||
|
||||
<!-- 离寓判定:房间有效在寓人数 = gy_jcxx 未离寓记录数(唯一口径) -->
|
||||
<select id="countLiveStayByRoom" resultType="int">
|
||||
SELECT COUNT(*) FROM gy_jcxx
|
||||
WHERE gyid = #{gyid} AND fjh = #{roomNo} AND deleted = 0
|
||||
AND finish_ly = '0' AND sjlysj IS NULL
|
||||
</select>
|
||||
|
||||
<update id="syncRoomOccupancy">
|
||||
UPDATE gy_rooms r
|
||||
SET r.in_person_count = (
|
||||
SELECT COUNT(*) FROM gy_jcxx j
|
||||
WHERE j.gyid = r.gyid AND j.fjh = r.room_no AND j.deleted = 0
|
||||
AND j.finish_ly = '0' AND j.sjlysj IS NULL
|
||||
),
|
||||
r.state_version = r.state_version + 1
|
||||
WHERE r.gyid = #{gyid} AND r.room_no = #{roomNo} AND r.deleted = 0
|
||||
</update>
|
||||
|
||||
<update id="startCleanTask">
|
||||
UPDATE gy_clean_task
|
||||
SET state = 'PROCESSING',
|
||||
assignee_staff_id = COALESCE(#{staffId}, assignee_staff_id),
|
||||
started_at = NOW(3),
|
||||
remark = COALESCE(#{remark}, remark)
|
||||
WHERE gyid = #{gyid} AND task_id = #{taskId}
|
||||
AND state = 'PENDING' AND deleted = 0
|
||||
</update>
|
||||
|
||||
<update id="finishCleanTask">
|
||||
UPDATE gy_clean_task
|
||||
SET state = 'DONE',
|
||||
finished_at = NOW(3),
|
||||
remark = COALESCE(#{remark}, remark)
|
||||
WHERE gyid = #{gyid} AND task_id = #{taskId}
|
||||
AND state = 'PROCESSING' AND deleted = 0
|
||||
</update>
|
||||
|
||||
<select id="selectCleanList" resultType="java.util.LinkedHashMap">
|
||||
SELECT taskId, roomId, roomNo, state, assigneeStaffId, assigneeName,
|
||||
scheduledAt, startedAt, finishedAt, remark, source, durationSec
|
||||
FROM (
|
||||
SELECT <include refid="cleanColumns"/>, c.scheduled_at AS biz_time
|
||||
FROM gy_clean_task c
|
||||
LEFT JOIN gy_rooms r ON r.gyid = c.gyid AND r.room_id = c.room_id
|
||||
LEFT JOIN apt_staff s ON s.gyid = c.gyid AND s.staff_id = c.assignee_staff_id
|
||||
WHERE c.gyid = #{gyid} AND c.deleted = 0
|
||||
) t
|
||||
WHERE 1 = 1
|
||||
<include refid="jobFilter"/>
|
||||
ORDER BY t.scheduledAt DESC
|
||||
LIMIT #{q.limit}
|
||||
</select>
|
||||
|
||||
<!-- 维修 -->
|
||||
<select id="selectRepairOrder" resultType="java.util.LinkedHashMap">
|
||||
SELECT <include refid="repairColumns"/>
|
||||
FROM apt_repair_order o
|
||||
WHERE o.gyid = #{gyid} AND o.order_id = #{orderId} AND o.deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectActiveRepairByRoom" resultType="java.util.LinkedHashMap">
|
||||
SELECT <include refid="repairColumns"/>
|
||||
FROM apt_repair_order o
|
||||
WHERE o.gyid = #{gyid} AND o.room_id = #{roomId} AND o.deleted = 0
|
||||
AND o.state IN ('REPORTED','ASSIGNED','IN_PROGRESS')
|
||||
ORDER BY o.reported_at DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="countActiveRepairByRoom" resultType="int">
|
||||
SELECT COUNT(*) FROM apt_repair_order
|
||||
WHERE gyid = #{gyid} AND room_id = #{roomId} AND deleted = 0
|
||||
AND state IN ('REPORTED','ASSIGNED','IN_PROGRESS')
|
||||
</select>
|
||||
|
||||
<insert id="insertRepairOrder">
|
||||
INSERT INTO apt_repair_order
|
||||
(gyid, order_id, room_id, room_no, category, description, priority, source,
|
||||
reporter_staff_id, reporter_name, state, room_status_before, reported_at, deleted)
|
||||
VALUES
|
||||
(#{gyid}, #{orderId}, #{roomId}, #{roomNo}, #{category}, #{description}, #{priority}, #{source},
|
||||
#{reporterStaffId}, #{reporterName}, 'REPORTED', #{roomStatusBefore}, NOW(3), 0)
|
||||
</insert>
|
||||
|
||||
<update id="assignRepair">
|
||||
UPDATE apt_repair_order
|
||||
SET assignee_staff_id = #{staffId},
|
||||
assignee_name = #{staffName},
|
||||
state = 'ASSIGNED',
|
||||
assigned_at = NOW(3),
|
||||
version = version + 1
|
||||
WHERE gyid = #{gyid} AND order_id = #{orderId} AND deleted = 0
|
||||
AND state IN ('REPORTED','ASSIGNED')
|
||||
</update>
|
||||
|
||||
<update id="startRepair">
|
||||
UPDATE apt_repair_order
|
||||
SET state = 'IN_PROGRESS',
|
||||
assignee_staff_id = COALESCE(assignee_staff_id, #{staffId}),
|
||||
assignee_name = COALESCE(assignee_name, #{staffName}),
|
||||
started_at = NOW(3),
|
||||
version = version + 1
|
||||
WHERE gyid = #{gyid} AND order_id = #{orderId} AND deleted = 0
|
||||
AND state IN ('REPORTED','ASSIGNED')
|
||||
</update>
|
||||
|
||||
<update id="finishRepair">
|
||||
UPDATE apt_repair_order
|
||||
SET state = 'DONE',
|
||||
result = #{result},
|
||||
remark = COALESCE(#{remark}, remark),
|
||||
finished_at = NOW(3),
|
||||
duration_sec = TIMESTAMPDIFF(SECOND, started_at, NOW(3)),
|
||||
version = version + 1
|
||||
WHERE gyid = #{gyid} AND order_id = #{orderId} AND deleted = 0
|
||||
AND state = 'IN_PROGRESS'
|
||||
</update>
|
||||
|
||||
<update id="cancelRepair">
|
||||
UPDATE apt_repair_order
|
||||
SET state = 'CANCELLED',
|
||||
remark = COALESCE(#{remark}, remark),
|
||||
finished_at = NOW(3),
|
||||
version = version + 1
|
||||
WHERE gyid = #{gyid} AND order_id = #{orderId} AND deleted = 0
|
||||
AND state IN ('REPORTED','ASSIGNED','IN_PROGRESS')
|
||||
</update>
|
||||
|
||||
<update id="updateRepairRoomAfter">
|
||||
UPDATE apt_repair_order
|
||||
SET room_status_after = #{roomStatusAfter}
|
||||
WHERE gyid = #{gyid} AND order_id = #{orderId} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<select id="selectRepairList" resultType="java.util.LinkedHashMap">
|
||||
SELECT orderId, roomId, roomNo, category, description, priority, source, state,
|
||||
reporterStaffId, reporterName, assigneeStaffId, assigneeName,
|
||||
roomStatusBefore, roomStatusAfter, result, remark,
|
||||
reportedAt, assignedAt, startedAt, finishedAt, durationSec
|
||||
FROM (
|
||||
SELECT <include refid="repairColumns"/>, o.reported_at AS biz_time
|
||||
FROM apt_repair_order o
|
||||
WHERE o.gyid = #{gyid} AND o.deleted = 0
|
||||
) t
|
||||
WHERE 1 = 1
|
||||
<include refid="jobFilter"/>
|
||||
ORDER BY t.reportedAt DESC
|
||||
LIMIT #{q.limit}
|
||||
</select>
|
||||
|
||||
<!-- 统计 -->
|
||||
<select id="selectJobStats" resultType="java.util.LinkedHashMap">
|
||||
SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN bizType = 'CLEAN' THEN 1 ELSE 0 END) AS cleanTotal,
|
||||
SUM(CASE WHEN bizType = 'REPAIR' THEN 1 ELSE 0 END) AS repairTotal,
|
||||
SUM(CASE WHEN state = 'DONE' THEN 1 ELSE 0 END) AS doneTotal,
|
||||
SUM(CASE WHEN state IN ('PENDING','REPORTED','ASSIGNED','IN_PROGRESS') THEN 1 ELSE 0 END) AS activeTotal,
|
||||
ROUND(AVG(CASE WHEN state = 'DONE' THEN durationSec END), 1) AS avgDurationSec
|
||||
FROM (
|
||||
SELECT 'CLEAN' AS bizType, c.state,
|
||||
TIMESTAMPDIFF(SECOND, c.started_at, c.finished_at) AS durationSec
|
||||
FROM gy_clean_task c
|
||||
WHERE c.gyid = #{gyid} AND c.deleted = 0
|
||||
AND (#{from} IS NULL OR c.scheduled_at >= #{from})
|
||||
AND (#{to} IS NULL OR c.scheduled_at <= #{to})
|
||||
UNION ALL
|
||||
SELECT 'REPAIR' AS bizType, o.state, o.duration_sec AS durationSec
|
||||
FROM apt_repair_order o
|
||||
WHERE o.gyid = #{gyid} AND o.deleted = 0
|
||||
AND (#{from} IS NULL OR o.reported_at >= #{from})
|
||||
AND (#{to} IS NULL OR o.reported_at <= #{to})
|
||||
) t
|
||||
</select>
|
||||
|
||||
<select id="selectJobStatsByType" resultType="java.util.LinkedHashMap">
|
||||
SELECT bizType, state, COUNT(*) AS cnt,
|
||||
ROUND(AVG(durationSec), 1) AS avgDurationSec
|
||||
FROM (
|
||||
SELECT 'CLEAN' AS bizType, c.state,
|
||||
TIMESTAMPDIFF(SECOND, c.started_at, c.finished_at) AS durationSec
|
||||
FROM gy_clean_task c
|
||||
WHERE c.gyid = #{gyid} AND c.deleted = 0
|
||||
AND (#{from} IS NULL OR c.scheduled_at >= #{from})
|
||||
AND (#{to} IS NULL OR c.scheduled_at <= #{to})
|
||||
UNION ALL
|
||||
SELECT 'REPAIR' AS bizType, o.state, o.duration_sec AS durationSec
|
||||
FROM apt_repair_order o
|
||||
WHERE o.gyid = #{gyid} AND o.deleted = 0
|
||||
AND (#{from} IS NULL OR o.reported_at >= #{from})
|
||||
AND (#{to} IS NULL OR o.reported_at <= #{to})
|
||||
) t
|
||||
GROUP BY bizType, state
|
||||
</select>
|
||||
|
||||
<select id="selectStaffWorkload" resultType="java.util.LinkedHashMap">
|
||||
SELECT staffId, MAX(staffName) AS staffName, COUNT(*) AS doneCount,
|
||||
ROUND(AVG(durationSec), 1) AS avgDurationSec
|
||||
FROM (
|
||||
SELECT c.assignee_staff_id AS staffId, s.name AS staffName,
|
||||
TIMESTAMPDIFF(SECOND, c.started_at, c.finished_at) AS durationSec
|
||||
FROM gy_clean_task c
|
||||
LEFT JOIN apt_staff s ON s.gyid = c.gyid AND s.staff_id = c.assignee_staff_id
|
||||
WHERE c.gyid = #{gyid} AND c.deleted = 0 AND c.state = 'DONE'
|
||||
AND c.assignee_staff_id IS NOT NULL
|
||||
AND (#{from} IS NULL OR c.scheduled_at >= #{from})
|
||||
AND (#{to} IS NULL OR c.scheduled_at <= #{to})
|
||||
UNION ALL
|
||||
SELECT o.assignee_staff_id, o.assignee_name, o.duration_sec
|
||||
FROM apt_repair_order o
|
||||
WHERE o.gyid = #{gyid} AND o.deleted = 0 AND o.state = 'DONE'
|
||||
AND o.assignee_staff_id IS NOT NULL
|
||||
AND (#{from} IS NULL OR o.reported_at >= #{from})
|
||||
AND (#{to} IS NULL OR o.reported_at <= #{to})
|
||||
) t
|
||||
GROUP BY staffId
|
||||
ORDER BY doneCount DESC
|
||||
</select>
|
||||
|
||||
<!-- - 状态流水 -->
|
||||
<insert id="insertJobEvent">
|
||||
INSERT INTO apt_job_event
|
||||
(gyid, event_id, biz_type, biz_id, room_id, from_state, to_state,
|
||||
operator_staff, operator_name, device_code, remark, occurred_at)
|
||||
VALUES
|
||||
(#{gyid}, #{eventId}, #{bizType}, #{bizId}, #{roomId}, #{fromState}, #{toState},
|
||||
#{operatorStaff}, #{operatorName}, #{deviceCode}, #{remark}, NOW(3))
|
||||
</insert>
|
||||
|
||||
<select id="selectJobEvents" resultType="java.util.LinkedHashMap">
|
||||
SELECT event_id AS eventId, biz_type AS bizType, biz_id AS bizId, room_id AS roomId,
|
||||
from_state AS fromState, to_state AS toState,
|
||||
operator_staff AS operatorStaff, operator_name AS operatorName,
|
||||
device_code AS deviceCode, remark, occurred_at AS occurredAt
|
||||
FROM apt_job_event
|
||||
WHERE gyid = #{gyid} AND biz_type = #{bizType} AND biz_id = #{bizId}
|
||||
ORDER BY occurred_at ASC
|
||||
</select>
|
||||
|
||||
<!--- 人员 -->
|
||||
<select id="selectStaff" resultType="java.util.LinkedHashMap">
|
||||
SELECT staff_id AS staffId, staff_type AS staffType, name, work_no AS workNo,
|
||||
phone, sys_user_id AS sysUserId, device_code AS deviceCode,
|
||||
duty_state AS dutyState, state, remark, biz_version AS bizVersion,
|
||||
create_time AS createTime, update_time AS updateTime
|
||||
FROM apt_staff
|
||||
WHERE gyid = #{gyid} AND staff_id = #{staffId} AND deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectStaffByDevice" resultType="java.util.LinkedHashMap">
|
||||
SELECT staff_id AS staffId, staff_type AS staffType, name, work_no AS workNo,
|
||||
device_code AS deviceCode, duty_state AS dutyState, state
|
||||
FROM apt_staff
|
||||
WHERE gyid = #{gyid} AND device_code = UPPER(#{deviceCode})
|
||||
AND state = 'ACTIVE' AND deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectStaffList" resultType="java.util.LinkedHashMap">
|
||||
SELECT staff_id AS staffId, staff_type AS staffType, name, work_no AS workNo,
|
||||
phone, sys_user_id AS sysUserId, device_code AS deviceCode,
|
||||
duty_state AS dutyState, state, remark, create_time AS createTime
|
||||
FROM apt_staff
|
||||
WHERE gyid = #{gyid} AND deleted = 0
|
||||
<if test="staffType != null and staffType != ''">
|
||||
AND staff_type = #{staffType}
|
||||
</if>
|
||||
<if test="state != null and state != ''">
|
||||
AND state = #{state}
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (name LIKE CONCAT('%', #{keyword}, '%') OR work_no LIKE CONCAT('%', #{keyword}, '%'))
|
||||
</if>
|
||||
ORDER BY staff_type ASC, name ASC
|
||||
</select>
|
||||
|
||||
<select id="countStaffWorkNo" resultType="int">
|
||||
SELECT COUNT(*) FROM apt_staff
|
||||
WHERE gyid = #{gyid} AND work_no = #{workNo} AND deleted = 0
|
||||
AND (#{excludeStaffId} IS NULL OR staff_id <> #{excludeStaffId})
|
||||
</select>
|
||||
|
||||
<insert id="insertStaff">
|
||||
INSERT INTO apt_staff
|
||||
(gyid, staff_id, staff_type, name, work_no, phone, sys_user_id,
|
||||
device_code, duty_state, state, remark, create_by, deleted)
|
||||
VALUES
|
||||
(#{gyid}, #{staffId}, #{staffType}, #{name}, #{workNo}, #{phone}, #{sysUserId},
|
||||
UPPER(#{deviceCode}), 'IDLE', 'ACTIVE', #{remark}, #{createBy}, 0)
|
||||
</insert>
|
||||
|
||||
<update id="updateStaff">
|
||||
UPDATE apt_staff
|
||||
SET staff_type = #{staffType},
|
||||
name = #{name},
|
||||
work_no = #{workNo},
|
||||
phone = #{phone},
|
||||
sys_user_id = #{sysUserId},
|
||||
device_code = UPPER(#{deviceCode}),
|
||||
remark = #{remark},
|
||||
biz_version = biz_version + 1
|
||||
WHERE gyid = #{gyid} AND staff_id = #{staffId} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<update id="updateStaffState">
|
||||
UPDATE apt_staff
|
||||
SET state = #{state},
|
||||
duty_state = CASE WHEN #{state} = 'DISABLED' THEN 'OFFLINE' ELSE 'IDLE' END,
|
||||
biz_version = biz_version + 1
|
||||
WHERE gyid = #{gyid} AND staff_id = #{staffId} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<update id="softDeleteStaff">
|
||||
UPDATE apt_staff
|
||||
SET deleted = 1,
|
||||
state = 'DISABLED',
|
||||
duty_state = 'OFFLINE',
|
||||
device_code = NULL,
|
||||
biz_version = biz_version + 1
|
||||
WHERE gyid = #{gyid} AND staff_id = #{staffId} AND deleted = 0
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,102 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.apt.master.mapper.AptPersonMapper">
|
||||
|
||||
<sql id="personColumns">
|
||||
p.ryid AS ryid,
|
||||
p.xm AS name,
|
||||
p.work_no AS workNo,
|
||||
p.dept_code AS deptCode,
|
||||
p.person_category AS personCategory,
|
||||
p.state AS state,
|
||||
p.face_sync_state AS faceSyncState,
|
||||
p.source_version AS sourceVersion,
|
||||
p.update_time AS updateTime,
|
||||
(SELECT j.fjh FROM gy_jcxx j
|
||||
WHERE j.gyid = p.gyid AND j.ryid = p.ryid AND j.deleted = 0
|
||||
AND j.finish_ly = '0' AND j.sjlysj IS NULL
|
||||
ORDER BY j.jcsj DESC, j.id DESC LIMIT 1) AS roomNo,
|
||||
(SELECT j.jlid FROM gy_jcxx j
|
||||
WHERE j.gyid = p.gyid AND j.ryid = p.ryid AND j.deleted = 0
|
||||
AND j.finish_ly = '0' AND j.sjlysj IS NULL
|
||||
ORDER BY j.jcsj DESC, j.id DESC LIMIT 1) AS routeId,
|
||||
(SELECT j.cc FROM gy_jcxx j
|
||||
WHERE j.gyid = p.gyid AND j.ryid = p.ryid AND j.deleted = 0
|
||||
AND j.finish_ly = '0' AND j.sjlysj IS NULL
|
||||
ORDER BY j.jcsj DESC, j.id DESC LIMIT 1) AS carNo
|
||||
</sql>
|
||||
|
||||
<select id="selectPersonList" resultType="java.util.LinkedHashMap">
|
||||
SELECT <include refid="personColumns"/>
|
||||
FROM apt_master_person p
|
||||
WHERE p.gyid = #{gyid} AND p.deleted = 0
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (p.xm LIKE CONCAT('%', #{keyword}, '%')
|
||||
OR p.work_no LIKE CONCAT('%', #{keyword}, '%')
|
||||
OR p.ryid LIKE CONCAT('%', #{keyword}, '%'))
|
||||
</if>
|
||||
<if test="state != null and state != ''">
|
||||
AND p.state = #{state}
|
||||
</if>
|
||||
<if test="roomNo != null and roomNo != ''">
|
||||
AND EXISTS (SELECT 1 FROM gy_jcxx j2
|
||||
WHERE j2.gyid = p.gyid AND j2.ryid = p.ryid AND j2.deleted = 0
|
||||
AND j2.finish_ly = '0' AND j2.sjlysj IS NULL AND j2.fjh = #{roomNo})
|
||||
</if>
|
||||
ORDER BY p.update_time DESC, p.id DESC
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<select id="selectPerson" resultType="java.util.LinkedHashMap">
|
||||
SELECT <include refid="personColumns"/>
|
||||
FROM apt_master_person p
|
||||
WHERE p.gyid = #{gyid} AND p.ryid = #{ryid} AND p.deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectActiveStay" resultType="java.util.LinkedHashMap">
|
||||
SELECT jcid AS jcid,
|
||||
fjh AS roomNo,
|
||||
jlid AS routeId,
|
||||
cc AS carNo,
|
||||
jch AS carNumber,
|
||||
kcsj AS busTime,
|
||||
jcsj AS callTime,
|
||||
call_mode AS callMode,
|
||||
create_time AS staySince
|
||||
FROM gy_jcxx
|
||||
WHERE gyid = #{gyid} AND ryid = #{ryid} AND deleted = 0
|
||||
AND finish_ly = '0' AND sjlysj IS NULL
|
||||
ORDER BY jcsj DESC, id DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectRecentCalls" resultType="java.util.LinkedHashMap">
|
||||
SELECT jcid AS jcid,
|
||||
fjh AS roomNo,
|
||||
jlid AS routeId,
|
||||
cc AS carNo,
|
||||
jch AS carNumber,
|
||||
ryzt AS callState,
|
||||
finish_jc AS finishCall,
|
||||
finish_ly AS finishLeave,
|
||||
jcsj AS callTime,
|
||||
sjlysj AS leaveTime
|
||||
FROM gy_jcxx
|
||||
WHERE gyid = #{gyid} AND ryid = #{ryid} AND deleted = 0
|
||||
ORDER BY jcsj DESC, id DESC
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<select id="selectRecentRoute" resultType="java.util.LinkedHashMap">
|
||||
SELECT jlid AS routeId,
|
||||
cc AS carNo,
|
||||
jch AS carNumber,
|
||||
jcsj AS callTime
|
||||
FROM gy_jcxx
|
||||
WHERE gyid = #{gyid} AND ryid = #{ryid} AND deleted = 0
|
||||
AND jlid IS NOT NULL
|
||||
ORDER BY jcsj DESC, id DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.apt.report.mapper.AptReportMapper">
|
||||
|
||||
<select id="selectOutbox" resultType="java.util.LinkedHashMap">
|
||||
SELECT id,
|
||||
idempotency_key AS idempotencyKey,
|
||||
biz_type AS bizType,
|
||||
biz_id AS bizId,
|
||||
biz_version AS bizVersion,
|
||||
state,
|
||||
retry_batch_id AS retryBatchId,
|
||||
retry_count AS retryCount,
|
||||
watermark,
|
||||
last_error AS lastError,
|
||||
occurred_at AS occurredAt,
|
||||
received_at AS receivedAt,
|
||||
create_time AS createTime
|
||||
FROM apt_report_outbox
|
||||
WHERE gyid = #{gyid}
|
||||
<if test="q.bizType != null and q.bizType != ''">
|
||||
AND biz_type = #{q.bizType}
|
||||
</if>
|
||||
<if test="q.bizId != null and q.bizId != ''">
|
||||
AND biz_id = #{q.bizId}
|
||||
</if>
|
||||
<if test="q.state != null and q.state != ''">
|
||||
AND state = #{q.state}
|
||||
</if>
|
||||
<if test="q.from != null">
|
||||
AND occurred_at >= #{q.from}
|
||||
</if>
|
||||
<if test="q.to != null">
|
||||
AND occurred_at <= #{q.to}
|
||||
</if>
|
||||
ORDER BY id DESC
|
||||
LIMIT #{q.limit}
|
||||
</select>
|
||||
|
||||
<select id="selectOutboxById" resultType="java.util.LinkedHashMap">
|
||||
SELECT id,
|
||||
idempotency_key AS idempotencyKey,
|
||||
biz_type AS bizType,
|
||||
biz_id AS bizId,
|
||||
biz_version AS bizVersion,
|
||||
state,
|
||||
retry_batch_id AS retryBatchId,
|
||||
retry_count AS retryCount,
|
||||
watermark,
|
||||
last_error AS lastError,
|
||||
occurred_at AS occurredAt,
|
||||
received_at AS receivedAt,
|
||||
create_time AS createTime
|
||||
FROM apt_report_outbox
|
||||
WHERE gyid = #{gyid} AND id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="selectOutboxStats" resultType="java.util.LinkedHashMap">
|
||||
SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(state = 'PENDING'), 0) AS pending,
|
||||
COALESCE(SUM(state = 'SENDING'), 0) AS sending,
|
||||
COALESCE(SUM(state = 'SENT'), 0) AS sent,
|
||||
COALESCE(SUM(state = 'FAILED'), 0) AS failed,
|
||||
COALESCE(SUM(state = 'DEAD'), 0) AS dead,
|
||||
COALESCE(MAX(watermark), 0) AS maxWatermark
|
||||
FROM apt_report_outbox
|
||||
WHERE gyid = #{gyid}
|
||||
</select>
|
||||
|
||||
<update id="requeueOutbox">
|
||||
UPDATE apt_report_outbox
|
||||
SET state = 'PENDING',
|
||||
retry_count = 0,
|
||||
retry_batch_id = #{batchId},
|
||||
last_error = NULL,
|
||||
next_retry_at = NOW(3)
|
||||
WHERE gyid = #{gyid} AND id = #{id} AND state IN ('FAILED', 'DEAD')
|
||||
</update>
|
||||
|
||||
<insert id="insertOutbox">
|
||||
INSERT INTO apt_report_outbox
|
||||
(gyid, idempotency_key, biz_type, biz_id, biz_version, payload, state, occurred_at)
|
||||
VALUES
|
||||
(#{gyid}, #{key}, #{bizType}, #{bizId}, #{bizVersion}, #{payload}, 'PENDING', #{occurredAt})
|
||||
</insert>
|
||||
|
||||
<select id="selectWatermark" resultType="java.util.LinkedHashMap">
|
||||
SELECT current_watermark AS currentWatermark,
|
||||
acked_watermark AS ackedWatermark,
|
||||
last_heartbeat_at AS lastHeartbeatAt,
|
||||
clock_skew_ms AS clockSkewMs
|
||||
FROM apt_report_watermark
|
||||
WHERE gyid = #{gyid} AND deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="upsertWatermark">
|
||||
INSERT INTO apt_report_watermark
|
||||
(gyid, current_watermark, acked_watermark, last_heartbeat_at, clock_skew_ms, deleted)
|
||||
VALUES
|
||||
(#{gyid}, #{watermark}, 0, #{at}, #{clockSkewMs}, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
current_watermark = GREATEST(current_watermark, VALUES(current_watermark)),
|
||||
last_heartbeat_at = VALUES(last_heartbeat_at),
|
||||
clock_skew_ms = VALUES(clock_skew_ms),
|
||||
update_time = NOW(3)
|
||||
</insert>
|
||||
|
||||
<update id="ackWatermark">
|
||||
UPDATE apt_report_watermark
|
||||
SET acked_watermark = GREATEST(acked_watermark, #{acked}),
|
||||
update_time = NOW(3)
|
||||
WHERE gyid = #{gyid} AND deleted = 0 AND acked_watermark < #{acked}
|
||||
</update>
|
||||
</mapper>
|
||||
@@ -75,6 +75,44 @@
|
||||
WHERE r.gyid = #{gyid} AND r.id = #{id} AND r.deleted = 0
|
||||
</select>
|
||||
|
||||
<select id="selectRoomByRoomId" resultType="com.ruoyi.apt.stay.domain.AptRoom">
|
||||
SELECT <include refid="roomColumns"/>
|
||||
<include refid="roomFrom"/>
|
||||
WHERE r.gyid = #{gyid} AND r.room_id = #{roomId} AND r.deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 平台基线只读:登记房间时按房号取楼层/房型,不改写 apt_master_room -->
|
||||
<select id="selectMasterRoomByNo" resultType="java.util.LinkedHashMap">
|
||||
SELECT room_id AS roomId, room_no AS roomNo, room_floor AS roomFloor,
|
||||
room_type AS roomType, bed_count AS bedCount, state
|
||||
FROM apt_master_room
|
||||
WHERE gyid = #{gyid} AND room_no = #{roomNo} AND deleted = 0 AND state = 'ACTIVE'
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="countRoomByNo" resultType="int">
|
||||
SELECT COUNT(*) FROM gy_rooms
|
||||
WHERE gyid = #{gyid} AND room_no = #{roomNo} AND deleted = 0
|
||||
</select>
|
||||
|
||||
<insert id="insertRoom">
|
||||
INSERT INTO gy_rooms
|
||||
(gyid, room_id, room_no, room_floor, room_type,
|
||||
room_status, in_person_count, state_version, deleted, create_time, update_time)
|
||||
VALUES
|
||||
(#{gyid}, #{roomId}, #{roomNo}, #{roomFloor}, #{roomType},
|
||||
'1', 0, 1, 0, NOW(3), NOW(3))
|
||||
</insert>
|
||||
|
||||
<update id="softDeleteRoom">
|
||||
UPDATE gy_rooms
|
||||
SET deleted = 1,
|
||||
state_version = state_version + 1,
|
||||
update_time = NOW(3)
|
||||
WHERE gyid = #{gyid} AND id = #{id} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<select id="selectRoomBindings" resultType="java.util.LinkedHashMap">
|
||||
SELECT binding_id AS bindingId, endpoint_id AS endpointId,
|
||||
effective_from AS effectiveFrom, state
|
||||
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.apt.voice.mapper.AptSipEndpointMapper">
|
||||
|
||||
<!--
|
||||
SIP 分机 / AOR / Contact / 房间绑定(03_domain_03_voice.sql + 22_sip_endpoint_room.sql)。
|
||||
硬约束:
|
||||
1) 每条 SQL 必带 gyid;
|
||||
2) 只写 apt_sip_* 四表,不写 gy_jcxx;
|
||||
3) contact 解绑 = state='RELEASED'(保留历史),endpoint/aor 删除 = deleted=1;
|
||||
4) 同 IP 唯一 ACTIVE、同 endpoint 唯一 ACTIVE 绑定由库层唯一索引兜底,
|
||||
本文件只负责把重复键异常交给服务层翻译成可读错误码。
|
||||
-->
|
||||
|
||||
<sql id="endpointColumns">
|
||||
e.endpoint_id AS endpointId, e.extension, e.room_id AS roomId,
|
||||
e.display_name AS displayName, e.ip_address AS ipAddress,
|
||||
e.config_version AS configVersion, e.sync_state AS syncState,
|
||||
e.enabled, e.source, e.last_qualify_at AS lastQualifyAt
|
||||
</sql>
|
||||
|
||||
<!-- - endpoint -->
|
||||
<select id="selectEndpointByRoom" resultType="java.util.LinkedHashMap">
|
||||
SELECT <include refid="endpointColumns"/>
|
||||
FROM apt_sip_endpoint e
|
||||
WHERE e.gyid = #{gyid} AND e.room_id = #{roomId} AND e.deleted = 0
|
||||
ORDER BY e.id DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectEndpoint" resultType="java.util.LinkedHashMap">
|
||||
SELECT <include refid="endpointColumns"/>
|
||||
FROM apt_sip_endpoint e
|
||||
WHERE e.gyid = #{gyid} AND e.endpoint_id = #{endpointId} AND e.deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 分机号占用(含已软删除行:UK 不含 deleted,号码不复用) -->
|
||||
<select id="countEndpointByExtension" resultType="int">
|
||||
SELECT COUNT(*) FROM apt_sip_endpoint
|
||||
WHERE gyid = #{gyid} AND extension = #{extension}
|
||||
</select>
|
||||
|
||||
<insert id="insertEndpoint">
|
||||
INSERT INTO apt_sip_endpoint
|
||||
(gyid, endpoint_id, extension, room_id, display_name,
|
||||
config_version, sync_state, enabled, source, deleted)
|
||||
VALUES
|
||||
(#{gyid}, #{endpointId}, #{extension}, #{roomId}, #{displayName},
|
||||
1, 'PENDING', 1, 'ROOM', 0)
|
||||
</insert>
|
||||
|
||||
<update id="updateEndpointProfile">
|
||||
UPDATE apt_sip_endpoint
|
||||
SET display_name = #{displayName},
|
||||
enabled = #{enabled},
|
||||
sync_state = 'PENDING',
|
||||
config_version = config_version + 1
|
||||
WHERE gyid = #{gyid} AND endpoint_id = #{endpointId} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<update id="updateEndpointIp">
|
||||
UPDATE apt_sip_endpoint
|
||||
SET ip_address = #{ipAddress},
|
||||
sync_state = 'PENDING',
|
||||
config_version = config_version + 1
|
||||
WHERE gyid = #{gyid} AND endpoint_id = #{endpointId} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<update id="softDeleteEndpoint">
|
||||
UPDATE apt_sip_endpoint
|
||||
SET deleted = 1,
|
||||
enabled = 0,
|
||||
room_id = NULL,
|
||||
ip_address = NULL,
|
||||
sync_state = 'PENDING',
|
||||
config_version = config_version + 1
|
||||
WHERE gyid = #{gyid} AND endpoint_id = #{endpointId} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<!-- - aor -->
|
||||
<select id="selectAorByEndpoint" resultType="java.util.LinkedHashMap">
|
||||
SELECT aor_id AS aorId, endpoint_id AS endpointId, max_contacts AS maxContacts,
|
||||
remove_existing AS removeExisting, default_expiration AS defaultExpiration,
|
||||
qualify_frequency AS qualifyFrequency, sync_state AS syncState,
|
||||
config_version AS configVersion
|
||||
FROM apt_sip_aor
|
||||
WHERE gyid = #{gyid} AND endpoint_id = #{endpointId} AND deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="insertAor">
|
||||
INSERT INTO apt_sip_aor (gyid, aor_id, endpoint_id, deleted)
|
||||
VALUES (#{gyid}, #{aorId}, #{endpointId}, 0)
|
||||
</insert>
|
||||
|
||||
<update id="softDeleteAor">
|
||||
UPDATE apt_sip_aor
|
||||
SET deleted = 1,
|
||||
sync_state = 'PENDING',
|
||||
config_version = config_version + 1
|
||||
WHERE gyid = #{gyid} AND endpoint_id = #{endpointId} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<!-- - contact -->
|
||||
<select id="selectContacts" resultType="java.util.LinkedHashMap">
|
||||
SELECT contact_id AS contactId, uri, ip_address AS ipAddress, port, transport,
|
||||
user_agent AS userAgent, source, state,
|
||||
last_seen_at AS lastSeenAt, released_at AS releasedAt,
|
||||
release_remark AS releaseRemark
|
||||
FROM apt_sip_contact
|
||||
WHERE gyid = #{gyid} AND endpoint_id = #{endpointId} AND deleted = 0
|
||||
ORDER BY update_time DESC
|
||||
LIMIT 20
|
||||
</select>
|
||||
|
||||
<select id="selectActiveContacts" resultType="java.util.LinkedHashMap">
|
||||
SELECT contact_id AS contactId, uri, ip_address AS ipAddress, port, transport,
|
||||
source, last_seen_at AS lastSeenAt
|
||||
FROM apt_sip_contact
|
||||
WHERE gyid = #{gyid} AND endpoint_id = #{endpointId}
|
||||
AND state = 'ACTIVE' AND deleted = 0
|
||||
ORDER BY id DESC
|
||||
</select>
|
||||
|
||||
<select id="selectActiveContactByIp" resultType="java.util.LinkedHashMap">
|
||||
SELECT contact_id AS contactId, endpoint_id AS endpointId, aor_id AS aorId,
|
||||
uri, ip_address AS ipAddress, port, state
|
||||
FROM apt_sip_contact
|
||||
WHERE gyid = #{gyid} AND ip_address = #{ipAddress}
|
||||
AND state = 'ACTIVE' AND deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<update id="releaseContacts">
|
||||
UPDATE apt_sip_contact
|
||||
SET state = 'RELEASED',
|
||||
released_at = NOW(3),
|
||||
release_remark = #{remark}
|
||||
WHERE gyid = #{gyid} AND endpoint_id = #{endpointId}
|
||||
AND state = 'ACTIVE' AND deleted = 0
|
||||
</update>
|
||||
|
||||
<insert id="insertContact">
|
||||
INSERT INTO apt_sip_contact
|
||||
(gyid, contact_id, aor_id, endpoint_id, uri, ip_address, port,
|
||||
transport, source, state, last_seen_at, deleted)
|
||||
VALUES
|
||||
(#{gyid}, #{contactId}, #{aorId}, #{endpointId}, #{uri}, #{ipAddress}, #{port},
|
||||
'udp', 'STATIC', 'ACTIVE', NOW(3), 0)
|
||||
</insert>
|
||||
|
||||
<!--- auth -->
|
||||
<!-- 多实例:一个公寓可能有多个 Asterisk 实例,认证按实例隔离;此处取首个启用实例 -->
|
||||
<select id="selectActiveInstanceCode" resultType="java.lang.String">
|
||||
SELECT instance_code FROM apt_ast_instance
|
||||
WHERE gyid = #{gyid} AND deleted = 0 AND enabled = 1
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectAuth" resultType="java.util.LinkedHashMap">
|
||||
SELECT auth_id AS authId, username, instance_code AS instanceCode,
|
||||
secret_mode AS secretMode, secret_ref AS secretRef,
|
||||
secret_version AS secretVersion, state, enabled, sync_state AS syncState
|
||||
FROM apt_sip_auth
|
||||
WHERE gyid = #{gyid} AND instance_code = #{instanceCode}
|
||||
AND username = #{username} AND deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 口令不上送:建号时只写业务字段,secret_plain 走表默认 psw.voip,secret_mode 默认 PLAIN -->
|
||||
<insert id="insertAuth">
|
||||
INSERT INTO apt_sip_auth
|
||||
(gyid, instance_code, username, auth_id, endpoint_id, deleted)
|
||||
VALUES
|
||||
(#{gyid}, #{instanceCode}, #{username}, #{authId}, #{endpointId}, 0)
|
||||
</insert>
|
||||
|
||||
<update id="disableAuthByEndpoint">
|
||||
UPDATE apt_sip_auth
|
||||
SET state = 'DISABLED',
|
||||
enabled = 0,
|
||||
sync_state = 'PENDING',
|
||||
config_version = config_version + 1
|
||||
WHERE gyid = #{gyid} AND endpoint_id = #{endpointId}
|
||||
AND deleted = 0 AND state <> 'DISABLED'
|
||||
</update>
|
||||
|
||||
<!-- binding -->
|
||||
<select id="selectActiveBindingByRoom" resultType="java.util.LinkedHashMap">
|
||||
SELECT binding_id AS bindingId, endpoint_id AS endpointId, room_id AS roomId,
|
||||
effective_from AS effectiveFrom, state
|
||||
FROM apt_sip_binding
|
||||
WHERE gyid = #{gyid} AND room_id = #{roomId}
|
||||
AND state = 'ACTIVE' AND deleted = 0
|
||||
ORDER BY effective_from DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectActiveBindingByEndpoint" resultType="java.util.LinkedHashMap">
|
||||
SELECT binding_id AS bindingId, endpoint_id AS endpointId, room_id AS roomId,
|
||||
effective_from AS effectiveFrom, state
|
||||
FROM apt_sip_binding
|
||||
WHERE gyid = #{gyid} AND endpoint_id = #{endpointId}
|
||||
AND state = 'ACTIVE' AND deleted = 0
|
||||
ORDER BY effective_from DESC
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<insert id="insertBinding">
|
||||
INSERT INTO apt_sip_binding
|
||||
(gyid, binding_id, endpoint_id, room_id, effective_from, state, deleted)
|
||||
VALUES
|
||||
(#{gyid}, #{bindingId}, #{endpointId}, #{roomId}, NOW(3), 'ACTIVE', 0)
|
||||
</insert>
|
||||
|
||||
<update id="closeBindingsByRoom">
|
||||
UPDATE apt_sip_binding
|
||||
SET state = 'RELEASED',
|
||||
effective_to = NOW(3)
|
||||
WHERE gyid = #{gyid} AND room_id = #{roomId}
|
||||
AND state = 'ACTIVE' AND deleted = 0
|
||||
</update>
|
||||
|
||||
<update id="closeBindingsByEndpoint">
|
||||
UPDATE apt_sip_binding
|
||||
SET state = 'RELEASED',
|
||||
effective_to = NOW(3)
|
||||
WHERE gyid = #{gyid} AND endpoint_id = #{endpointId}
|
||||
AND state = 'ACTIVE' AND deleted = 0
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
@@ -147,4 +147,112 @@
|
||||
'PENDING', #{occurredAt})
|
||||
</insert>
|
||||
|
||||
<!-- ===================================================================
|
||||
刷脸照片(apt_tablet_face_photo)
|
||||
只存路径与校验信息;图像本体在文件/对象存储。
|
||||
=================================================================== -->
|
||||
|
||||
<select id="selectResultForPhoto" resultType="java.util.LinkedHashMap">
|
||||
SELECT r.result_id AS resultId, r.task_id AS taskId, r.jcid,
|
||||
r.verified_ryid AS verifiedRyid,
|
||||
r.face_verify_state AS faceVerifyState, r.result_type AS resultType,
|
||||
r.device_code AS deviceCode, r.applied_state AS appliedState,
|
||||
r.occurred_at AS occurredAt,
|
||||
r.face_photo_id AS facePhotoId, r.face_photo_state AS facePhotoState,
|
||||
t.device_code AS taskDeviceCode
|
||||
FROM apt_tablet_call_result r
|
||||
LEFT JOIN apt_tablet_task t
|
||||
ON t.gyid = r.gyid AND t.task_id = r.task_id
|
||||
WHERE r.gyid = #{gyid} AND r.result_id = #{resultId}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectPhotoByResult" resultType="java.util.LinkedHashMap">
|
||||
SELECT photo_id AS photoId, jcid, task_id AS taskId, result_id AS resultId,
|
||||
ryid, device_code AS deviceCode,
|
||||
storage_target AS storageTarget, storage_path AS storagePath,
|
||||
file_name AS fileName, content_type AS contentType,
|
||||
file_size AS fileSize, sha256,
|
||||
upload_state AS uploadState, fail_reason AS failReason,
|
||||
retry_count AS retryCount, captured_at AS capturedAt, uploaded_at AS uploadedAt
|
||||
FROM apt_tablet_face_photo
|
||||
WHERE gyid = #{gyid} AND result_id = #{resultId} AND deleted = 0
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!-- 幂等:同一结果只保留一张;重传复用 photo_id/路径,重置为 PENDING 并记重试次数 -->
|
||||
<insert id="insertPhotoPending">
|
||||
INSERT INTO apt_tablet_face_photo
|
||||
(gyid, photo_id, jcid, task_id, result_id, ryid, device_code,
|
||||
storage_target, storage_path, file_name, content_type, file_size,
|
||||
captured_at, upload_state, deleted)
|
||||
VALUES
|
||||
(#{gyid}, #{photoId}, #{jcid}, #{taskId}, #{resultId}, #{ryid}, #{deviceCode},
|
||||
#{storageTarget}, #{storagePath}, #{fileName}, #{contentType}, #{fileSize},
|
||||
#{capturedAt}, 'PENDING', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
storage_target = #{storageTarget},
|
||||
storage_path = #{storagePath},
|
||||
file_name = #{fileName},
|
||||
content_type = #{contentType},
|
||||
file_size = #{fileSize},
|
||||
upload_state = 'PENDING',
|
||||
fail_reason = NULL,
|
||||
sha256 = NULL,
|
||||
uploaded_at = NULL,
|
||||
retry_count = retry_count + 1,
|
||||
deleted = 0
|
||||
</insert>
|
||||
|
||||
<update id="updatePhotoStored">
|
||||
UPDATE apt_tablet_face_photo
|
||||
SET upload_state = 'STORED',
|
||||
file_size = #{fileSize},
|
||||
sha256 = #{sha256},
|
||||
uploaded_at = NOW(3),
|
||||
fail_reason = NULL
|
||||
WHERE gyid = #{gyid} AND result_id = #{resultId} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<update id="updatePhotoFailed">
|
||||
UPDATE apt_tablet_face_photo
|
||||
SET upload_state = 'FAILED',
|
||||
fail_reason = #{failReason}
|
||||
WHERE gyid = #{gyid} AND result_id = #{resultId} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<update id="updateResultPhotoRef">
|
||||
UPDATE apt_tablet_call_result
|
||||
SET face_photo_id = #{photoId},
|
||||
face_photo_state = #{photoState}
|
||||
WHERE gyid = #{gyid} AND result_id = #{resultId}
|
||||
</update>
|
||||
|
||||
<!-- ===================================================================
|
||||
保留期清理:按公寓逐个边界清理,保证 gyid 隔离(不跨租户批量删)
|
||||
=================================================================== -->
|
||||
|
||||
<select id="selectGyidsWithExpiredPhotos" resultType="java.lang.String">
|
||||
SELECT DISTINCT gyid
|
||||
FROM apt_tablet_face_photo
|
||||
WHERE COALESCE(uploaded_at, captured_at, create_time) < #{cutoff}
|
||||
ORDER BY gyid
|
||||
</select>
|
||||
|
||||
<select id="selectExpiredPhotos" resultType="java.util.LinkedHashMap">
|
||||
SELECT id, photo_id AS photoId, jcid, storage_path AS storagePath,
|
||||
upload_state AS uploadState,
|
||||
COALESCE(uploaded_at, captured_at, create_time) AS expireBase
|
||||
FROM apt_tablet_face_photo
|
||||
WHERE gyid = #{gyid}
|
||||
AND COALESCE(uploaded_at, captured_at, create_time) < #{cutoff}
|
||||
ORDER BY expireBase ASC
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<delete id="deletePhoto">
|
||||
DELETE FROM apt_tablet_face_photo
|
||||
WHERE gyid = #{gyid} AND id = #{id}
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@@ -168,15 +168,24 @@
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<!--
|
||||
房间主分机。
|
||||
口径(不得混用):
|
||||
- gy_jcxx.fjh / apt_master_room.room_no = 房号(如 201);
|
||||
- apt_sip_binding.room_id / apt_sip_endpoint.room_id = 房间主键(如 ROOM-201)。
|
||||
入参只接受房号,room_id 一律经 apt_master_room 换算,禁止跨表直接比较两种标识。
|
||||
-->
|
||||
<select id="selectRoomExtension" resultType="java.util.LinkedHashMap">
|
||||
SELECT e.endpoint_id AS endpointId, e.extension
|
||||
FROM apt_sip_binding b
|
||||
SELECT e.endpoint_id AS endpointId, e.extension, m.room_id AS roomId
|
||||
FROM apt_master_room m
|
||||
JOIN apt_sip_binding b
|
||||
ON b.gyid = m.gyid AND b.room_id = m.room_id AND b.deleted = 0
|
||||
JOIN apt_sip_endpoint e
|
||||
ON e.gyid = b.gyid AND e.endpoint_id = b.endpoint_id AND e.deleted = 0
|
||||
WHERE b.gyid = #{gyid}
|
||||
AND b.room_id = #{roomNo}
|
||||
WHERE m.gyid = #{gyid}
|
||||
AND m.room_no = #{roomNo}
|
||||
AND m.deleted = 0
|
||||
AND b.state = 'ACTIVE'
|
||||
AND b.deleted = 0
|
||||
AND (b.effective_to IS NULL OR b.effective_to > NOW(3))
|
||||
AND e.enabled = 1
|
||||
ORDER BY b.effective_from DESC
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.ruoyi.apt.device;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.device.domain.AptDeviceCommandRequest;
|
||||
import com.ruoyi.apt.device.mapper.AptDeviceMapper;
|
||||
import com.ruoyi.apt.device.service.AptDeviceServiceImpl;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.report.service.IAptReportService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* M5 回归:指令只入队 PENDING、停用设备拒绝、重复命令不重复插入。
|
||||
*/
|
||||
class AptDeviceServiceTest
|
||||
{
|
||||
private AptDeviceMapper mapper;
|
||||
private IAptReportService reportService;
|
||||
private AptDeviceServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
mapper = mock(AptDeviceMapper.class);
|
||||
reportService = mock(IAptReportService.class);
|
||||
service = new AptDeviceServiceImpl(mapper, reportService, new AptProperties());
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void enqueueCreatesPendingWithoutProtocolConfirmation()
|
||||
{
|
||||
when(mapper.selectDevice("APT-A", "DEV-1")).thenReturn(device("ACTIVE"));
|
||||
Map<String, Object> saved = new LinkedHashMap<>();
|
||||
saved.put("commandId", "DC-1");
|
||||
saved.put("state", "PENDING");
|
||||
saved.put("protocolConfirmed", 0);
|
||||
when(mapper.selectCommand("APT-A", "DC-1")).thenReturn(null, saved);
|
||||
when(mapper.insertCommand(eq("APT-A"), eq("DC-1"), eq("DEV-1"), eq("PLAY_TASK"),
|
||||
any(), any(), any())).thenReturn(1);
|
||||
|
||||
AptDeviceCommandRequest request = new AptDeviceCommandRequest();
|
||||
request.setCommandId("DC-1");
|
||||
request.setCommandType("PLAY_TASK");
|
||||
Map<String, Object> result = service.enqueue("dev-1", request);
|
||||
|
||||
assertEquals("PENDING", result.get("state"));
|
||||
assertEquals(false, result.get("protocolConfirmed"));
|
||||
assertEquals(false, result.get("duplicated"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledDeviceCannotEnqueue()
|
||||
{
|
||||
when(mapper.selectDevice("APT-A", "DEV-2")).thenReturn(device("DISABLED"));
|
||||
AptDeviceCommandRequest request = new AptDeviceCommandRequest();
|
||||
request.setCommandType("PLAY_TASK");
|
||||
|
||||
AptException ex = assertThrows(AptException.class, () -> service.enqueue("DEV-2", request));
|
||||
|
||||
assertEquals(AptErrorCode.FEATURE_DISABLED, ex.getErrorCode());
|
||||
verify(mapper, never()).insertCommand(anyString(), anyString(), anyString(), anyString(),
|
||||
any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateCommandReturnsExistingState()
|
||||
{
|
||||
when(mapper.selectDevice("APT-A", "DEV-3")).thenReturn(device("ACTIVE"));
|
||||
Map<String, Object> existing = new LinkedHashMap<>();
|
||||
existing.put("commandId", "DC-9");
|
||||
existing.put("state", "SENT");
|
||||
existing.put("protocolConfirmed", 1);
|
||||
when(mapper.selectCommand("APT-A", "DC-9")).thenReturn(existing);
|
||||
|
||||
AptDeviceCommandRequest request = new AptDeviceCommandRequest();
|
||||
request.setCommandId("DC-9");
|
||||
request.setCommandType("PLAY_TASK");
|
||||
Map<String, Object> result = service.enqueue("DEV-3", request);
|
||||
|
||||
assertTrue((Boolean) result.get("duplicated"));
|
||||
assertEquals("SENT", result.get("state"));
|
||||
assertTrue((Boolean) result.get("protocolConfirmed"));
|
||||
verify(mapper, never()).insertCommand(anyString(), anyString(), anyString(), anyString(),
|
||||
any(), any(), any());
|
||||
}
|
||||
|
||||
private Map<String, Object> device(String state)
|
||||
{
|
||||
Map<String, Object> device = new LinkedHashMap<>();
|
||||
device.put("deviceCode", "DEV-X");
|
||||
device.put("deviceType", "TABLET");
|
||||
device.put("state", state);
|
||||
return device;
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -16,6 +16,7 @@ import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.dispatch.domain.AptStateSnapshot;
|
||||
import com.ruoyi.apt.dispatch.mapper.AptDutyStateMapper;
|
||||
import com.ruoyi.apt.dispatch.service.AptDutyStateServiceImpl;
|
||||
import com.ruoyi.apt.job.service.IAptJobService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -26,13 +27,15 @@ import org.junit.jupiter.api.Test;
|
||||
class AptDutyStateServiceTest
|
||||
{
|
||||
private AptDutyStateMapper mapper;
|
||||
private IAptJobService jobService;
|
||||
private AptDutyStateServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
mapper = mock(AptDutyStateMapper.class);
|
||||
service = new AptDutyStateServiceImpl(mapper);
|
||||
jobService = mock(IAptJobService.class);
|
||||
service = new AptDutyStateServiceImpl(mapper, jobService);
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
}
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package com.ruoyi.apt.dispatch;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.dispatch.tablet.mapper.AptTabletMapper;
|
||||
import com.ruoyi.apt.dispatch.tablet.service.AptPhotoPurgeServiceImpl;
|
||||
import com.ruoyi.apt.framework.config.AptPhotoProperties;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/**
|
||||
* 刷脸照片保留期清理:文件 + 记录、缺失容错、删除失败保留记录、路径越界保护。
|
||||
*/
|
||||
class AptPhotoPurgeServiceTest
|
||||
{
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private AptTabletMapper mapper;
|
||||
private AptPhotoProperties properties;
|
||||
private AptPhotoPurgeServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
mapper = mock(AptTabletMapper.class);
|
||||
properties = new AptPhotoProperties();
|
||||
properties.setRoot(tempDir.toString());
|
||||
properties.setRetentionDays(30);
|
||||
properties.setPurgeBatchSize(200);
|
||||
service = new AptPhotoPurgeServiceImpl(mapper, properties);
|
||||
// 默认删除成功;断言删除条数依赖真实影响行数
|
||||
when(mapper.deletePhoto(anyString(), anyLong())).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void purgesExpiredFileAndRecord() throws Exception
|
||||
{
|
||||
Path photo = tempDir.resolve("face/APT-A/2026/10/01/JC-1/PH-1.jpg");
|
||||
Files.createDirectories(photo.getParent());
|
||||
Files.write(photo, new byte[]{1, 2, 3});
|
||||
when(mapper.selectGyidsWithExpiredPhotos(any())).thenReturn(List.of("APT-A"));
|
||||
when(mapper.selectExpiredPhotos(eq("APT-A"), any(), anyInt()))
|
||||
.thenReturn(List.of(row(7L, "face/APT-A/2026/10/01/JC-1/PH-1.jpg")));
|
||||
|
||||
Map<String, Object> stats = service.purgeExpired();
|
||||
|
||||
verify(mapper).deletePhoto("APT-A", 7L);
|
||||
assertFalse(Files.exists(photo), "过期照片文件应被删除");
|
||||
assertEquals(1L, stats.get("deleted"));
|
||||
assertEquals(1L, stats.get("filesDeleted"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFileStillDeletesRecord() throws Exception
|
||||
{
|
||||
when(mapper.selectGyidsWithExpiredPhotos(any())).thenReturn(List.of("APT-A"));
|
||||
when(mapper.selectExpiredPhotos(eq("APT-A"), any(), anyInt()))
|
||||
.thenReturn(List.of(row(8L, "face/APT-A/2026/10/01/JC-1/PH-MISSING.jpg")));
|
||||
|
||||
Map<String, Object> stats = service.purgeExpired();
|
||||
|
||||
verify(mapper).deletePhoto("APT-A", 8L);
|
||||
assertEquals(1L, stats.get("filesMissing"));
|
||||
assertEquals(1L, stats.get("deleted"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsRecordWhenFileDeleteFails() throws Exception
|
||||
{
|
||||
// 用非空目录冒充「文件」,delete 会抛 DirectoryNotEmptyException
|
||||
Path blocked = tempDir.resolve("face/APT-A/2026/10/01/JC-1/PH-DIR.jpg");
|
||||
Files.createDirectories(blocked.resolve("child"));
|
||||
when(mapper.selectGyidsWithExpiredPhotos(any())).thenReturn(List.of("APT-A"));
|
||||
when(mapper.selectExpiredPhotos(eq("APT-A"), any(), anyInt()))
|
||||
.thenReturn(List.of(row(9L, "face/APT-A/2026/10/01/JC-1/PH-DIR.jpg")));
|
||||
|
||||
Map<String, Object> stats = service.purgeExpired();
|
||||
|
||||
verify(mapper, never()).deletePhoto(anyString(), anyLong());
|
||||
assertTrue(Files.exists(blocked), "删不掉的文件必须保留,避免路径信息丢失");
|
||||
assertEquals(1L, stats.get("filesFailed"));
|
||||
assertEquals(0L, stats.get("deleted"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsFileOutsideRootButDeletesRecord()
|
||||
{
|
||||
when(mapper.selectGyidsWithExpiredPhotos(any())).thenReturn(List.of("APT-A"));
|
||||
when(mapper.selectExpiredPhotos(eq("APT-A"), any(), anyInt()))
|
||||
.thenReturn(List.of(row(10L, "../../windows/win.ini")));
|
||||
|
||||
Map<String, Object> stats = service.purgeExpired();
|
||||
|
||||
verify(mapper).deletePhoto("APT-A", 10L);
|
||||
assertEquals(1L, stats.get("filesSkipped"));
|
||||
assertEquals(1L, stats.get("deleted"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noExpiredPhotosIsNoop()
|
||||
{
|
||||
when(mapper.selectGyidsWithExpiredPhotos(any())).thenReturn(List.of());
|
||||
|
||||
Map<String, Object> stats = service.purgeExpired();
|
||||
|
||||
verify(mapper, never()).selectExpiredPhotos(anyString(), any(), anyInt());
|
||||
assertEquals(0, stats.get("gyids"));
|
||||
assertEquals(0L, stats.get("deleted"));
|
||||
}
|
||||
|
||||
private Map<String, Object> row(long id, String storagePath)
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", id);
|
||||
row.put("photoId", "PH-" + id);
|
||||
row.put("jcid", "JC-1");
|
||||
row.put("storagePath", storagePath);
|
||||
row.put("uploadState", "STORED");
|
||||
row.put("expireBase", new Date());
|
||||
return row;
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package com.ruoyi.apt.dispatch;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.startsWith;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.dispatch.tablet.mapper.AptTabletMapper;
|
||||
import com.ruoyi.apt.dispatch.tablet.service.AptTabletPhotoServiceImpl;
|
||||
import com.ruoyi.apt.framework.config.AptPhotoProperties;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
/**
|
||||
* 平板刷脸照片:落盘、入库、幂等、失败留痕。
|
||||
*/
|
||||
class AptTabletPhotoServiceTest
|
||||
{
|
||||
private static final byte[] JPEG = new byte[]{
|
||||
(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01};
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private AptTabletMapper mapper;
|
||||
private AptPhotoProperties properties;
|
||||
private AptTabletPhotoServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
mapper = mock(AptTabletMapper.class);
|
||||
properties = new AptPhotoProperties();
|
||||
properties.setRoot(tempDir.toString());
|
||||
properties.setMaxSizeBytes(5L * 1024 * 1024);
|
||||
service = new AptTabletPhotoServiceImpl(mapper, properties);
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeWritesFileAndPersistsRelativePath() throws Exception
|
||||
{
|
||||
when(mapper.selectResultForPhoto("APT-A", "R1")).thenReturn(result("ANSWERED", "VERIFIED", "APPLIED"));
|
||||
MockMultipartFile file = new MockMultipartFile("file", "face.jpg", "image/jpeg", JPEG);
|
||||
|
||||
Map<String, Object> meta = service.store("TAB-1", "R1", file);
|
||||
|
||||
verify(mapper).insertPhotoPending(eq("APT-A"), anyString(), eq("JC-1"), eq("T1"), eq("R1"),
|
||||
eq("RY-1"), eq("TAB-1"), eq("LOCAL"), startsWith("face/APT-A/"),
|
||||
anyString(), eq("image/jpeg"), eq((long) JPEG.length), any());
|
||||
verify(mapper).updatePhotoStored(eq("APT-A"), eq("R1"), eq((long) JPEG.length), anyString());
|
||||
verify(mapper).updateResultPhotoRef(eq("APT-A"), eq("R1"), anyString(), eq("STORED"));
|
||||
|
||||
String storagePath = String.valueOf(meta.get("storagePath"));
|
||||
assertTrue(storagePath.matches("face/APT-A/\\d{4}/\\d{2}/\\d{2}/JC-1/PH-[0-9A-F]{12}\\.jpg"),
|
||||
storagePath);
|
||||
assertTrue(Files.exists(tempDir.resolve(storagePath)), "照片应写入配置根目录");
|
||||
assertEquals("STORED", meta.get("uploadState"));
|
||||
assertEquals(Boolean.FALSE, meta.get("duplicated"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeRejectsResultWithoutVerifiedFace()
|
||||
{
|
||||
when(mapper.selectResultForPhoto("APT-A", "R1")).thenReturn(result("ANSWERED", "UNVERIFIED", "APPLIED"));
|
||||
|
||||
AptException error = assertThrows(AptException.class,
|
||||
() -> service.store("TAB-1", "R1", jpeg()));
|
||||
|
||||
assertEquals("TABLET_PHOTO_NOT_APPLICABLE", error.getReason());
|
||||
verify(mapper, never()).insertPhotoPending(anyString(), anyString(), anyString(), anyString(),
|
||||
anyString(), anyString(), anyString(), anyString(), anyString(), anyString(),
|
||||
anyString(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeRejectsPhotoFromAnotherDevice()
|
||||
{
|
||||
when(mapper.selectResultForPhoto("APT-A", "R1")).thenReturn(result("ANSWERED", "VERIFIED", "APPLIED"));
|
||||
|
||||
AptException error = assertThrows(AptException.class,
|
||||
() -> service.store("TAB-OTHER", "R1", jpeg()));
|
||||
|
||||
assertEquals("TABLET_RESULT_DEVICE_MISMATCH", error.getReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeRejectsOversizedPhoto()
|
||||
{
|
||||
properties.setMaxSizeBytes(4);
|
||||
when(mapper.selectResultForPhoto("APT-A", "R1")).thenReturn(result("ANSWERED", "VERIFIED", "APPLIED"));
|
||||
|
||||
AptException error = assertThrows(AptException.class,
|
||||
() -> service.store("TAB-1", "R1", jpeg()));
|
||||
|
||||
assertEquals(40922, error.getErrorCode().getCode());
|
||||
verify(mapper, never()).insertPhotoPending(anyString(), anyString(), anyString(), anyString(),
|
||||
anyString(), anyString(), anyString(), anyString(), anyString(), anyString(),
|
||||
anyString(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeIsIdempotentWhenAlreadyStored()
|
||||
{
|
||||
when(mapper.selectResultForPhoto("APT-A", "R1")).thenReturn(result("ANSWERED", "VERIFIED", "APPLIED"));
|
||||
Map<String, Object> stored = new LinkedHashMap<>();
|
||||
stored.put("photoId", "PH-AAAAAAAAAAAA");
|
||||
stored.put("uploadState", "STORED");
|
||||
stored.put("storagePath", "face/APT-A/2026/11/12/JC-1/PH-AAAAAAAAAAAA.jpg");
|
||||
when(mapper.selectPhotoByResult("APT-A", "R1")).thenReturn(stored);
|
||||
|
||||
Map<String, Object> meta = service.store("TAB-1", "R1", jpeg());
|
||||
|
||||
assertEquals(Boolean.TRUE, meta.get("duplicated"));
|
||||
verify(mapper, never()).insertPhotoPending(anyString(), anyString(), anyString(), anyString(),
|
||||
anyString(), anyString(), anyString(), anyString(), anyString(), anyString(),
|
||||
anyString(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeReusesPhotoIdWhenRetryingFailedUpload()
|
||||
{
|
||||
when(mapper.selectResultForPhoto("APT-A", "R1")).thenReturn(result("ANSWERED", "VERIFIED", "APPLIED"));
|
||||
Map<String, Object> failed = new LinkedHashMap<>();
|
||||
failed.put("photoId", "PH-BBBBBBBBBBBB");
|
||||
failed.put("uploadState", "FAILED");
|
||||
when(mapper.selectPhotoByResult("APT-A", "R1")).thenReturn(failed);
|
||||
|
||||
Map<String, Object> meta = service.store("TAB-1", "R1", jpeg());
|
||||
|
||||
assertEquals("PH-BBBBBBBBBBBB", meta.get("photoId"));
|
||||
assertTrue(String.valueOf(meta.get("storagePath")).contains("PH-BBBBBBBBBBBB.jpg"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeRecordsFailureWhenDiskNotWritable() throws Exception
|
||||
{
|
||||
Path blocker = Files.createFile(tempDir.resolve("blocker"));
|
||||
properties.setRoot(blocker.resolve("nested").toString());
|
||||
when(mapper.selectResultForPhoto("APT-A", "R1")).thenReturn(result("ANSWERED", "VERIFIED", "APPLIED"));
|
||||
|
||||
AptException error = assertThrows(AptException.class,
|
||||
() -> service.store("TAB-1", "R1", jpeg()));
|
||||
|
||||
assertEquals("PHOTO_STORE_FAILED", error.getReason());
|
||||
verify(mapper).updatePhotoFailed(eq("APT-A"), eq("R1"), anyString());
|
||||
verify(mapper).updateResultPhotoRef(eq("APT-A"), eq("R1"), anyString(), eq("FAILED"));
|
||||
verify(mapper, never()).updatePhotoStored(anyString(), anyString(), anyLong(), anyString());
|
||||
assertFalse(Files.exists(tempDir.resolve("blocker").resolve("nested")), "失败不应留下目录");
|
||||
}
|
||||
|
||||
private MockMultipartFile jpeg()
|
||||
{
|
||||
return new MockMultipartFile("file", "face.jpg", "image/jpeg", JPEG);
|
||||
}
|
||||
|
||||
private Map<String, Object> result(String resultType, String faceState, String appliedState)
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("resultId", "R1");
|
||||
row.put("taskId", "T1");
|
||||
row.put("jcid", "JC-1");
|
||||
row.put("verifiedRyid", "RY-1");
|
||||
row.put("faceVerifyState", faceState);
|
||||
row.put("resultType", resultType);
|
||||
row.put("deviceCode", "TAB-1");
|
||||
row.put("appliedState", appliedState);
|
||||
row.put("occurredAt", new Date());
|
||||
return row;
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
package com.ruoyi.apt.job;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.framework.audit.AptAuditWriter;
|
||||
import com.ruoyi.apt.job.domain.AptJobQuery;
|
||||
import com.ruoyi.apt.job.mapper.AptJobMapper;
|
||||
import com.ruoyi.apt.job.service.AptJobServiceImpl;
|
||||
import com.ruoyi.apt.stay.domain.AptRoom;
|
||||
import com.ruoyi.apt.stay.mapper.AptRoomMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 离寓联动与自动建单回归(保洁自动建单 / 平板公共池)。
|
||||
*
|
||||
* <p>口径:房间有效在寓人数 = gy_jcxx 中 finish_ly='0' AND sjlysj IS NULL 的记录数。</p>
|
||||
*/
|
||||
class AptJobAutomationTest
|
||||
{
|
||||
private AptJobMapper jobMapper;
|
||||
private AptRoomMapper roomMapper;
|
||||
private AptAuditWriter auditWriter;
|
||||
private AptJobServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
jobMapper = mock(AptJobMapper.class);
|
||||
roomMapper = mock(AptRoomMapper.class);
|
||||
auditWriter = mock(AptAuditWriter.class);
|
||||
service = new AptJobServiceImpl(jobMapper, roomMapper, auditWriter);
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoCreateCleanWhenLastPersonLeft()
|
||||
{
|
||||
stubRoom();
|
||||
when(jobMapper.countLiveStayByRoom("APT-A", "101")).thenReturn(0);
|
||||
when(jobMapper.selectActiveCleanTaskByRoom("APT-A", "RM1")).thenReturn(null);
|
||||
when(jobMapper.insertCleanTask(anyString(), anyString(), anyString(), anyString(),
|
||||
any(), anyString(), any(Date.class), anyString())).thenReturn(1);
|
||||
when(jobMapper.selectActiveRepairByRoom("APT-A", "RM1")).thenReturn(null);
|
||||
when(roomMapper.selectActiveCleanState("APT-A", "RM1")).thenReturn("PENDING");
|
||||
when(roomMapper.updateRoomStatus(eq("APT-A"), anyLong(), anyLong(), anyString(), any())).thenReturn(1);
|
||||
|
||||
Map<String, Object> result = service.autoCreateCleanOnVacated("101", "LEAVE:JC-1");
|
||||
|
||||
assertTrue(Boolean.TRUE.equals(result.get("created")));
|
||||
assertEquals("PENDING", result.get("state"));
|
||||
assertEquals("3", result.get("roomStatus"));
|
||||
verify(jobMapper).insertCleanTask(eq("APT-A"), anyString(), eq("RM1"), eq("101"),
|
||||
isNull(), contains("全员离寓自动建单"), any(Date.class), eq("AUTO"));
|
||||
verify(jobMapper).insertJobEvent(eq("APT-A"), anyString(), eq("CLEAN"), anyString(),
|
||||
eq("RM1"), isNull(), eq("PENDING"), isNull(), eq("SYSTEM"), isNull(),
|
||||
contains("全员离寓自动建单"));
|
||||
verify(auditWriter).write(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoCreateSkippedWhenRoomStillOccupied()
|
||||
{
|
||||
stubRoom();
|
||||
when(jobMapper.countLiveStayByRoom("APT-A", "101")).thenReturn(2);
|
||||
|
||||
Map<String, Object> result = service.autoCreateCleanOnVacated("101", "LEAVE:JC-1");
|
||||
|
||||
assertFalse(Boolean.TRUE.equals(result.get("created")));
|
||||
assertEquals("OCCUPIED", result.get("reason"));
|
||||
assertEquals(2, result.get("liveCount"));
|
||||
verify(jobMapper, never()).insertCleanTask(anyString(), anyString(), anyString(), anyString(),
|
||||
any(), any(), any(Date.class), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoCreateReturnsExistingActiveTask()
|
||||
{
|
||||
stubRoom();
|
||||
when(jobMapper.countLiveStayByRoom("APT-A", "101")).thenReturn(0);
|
||||
Map<String, Object> active = new LinkedHashMap<>();
|
||||
active.put("taskId", "CT-EXIST");
|
||||
active.put("state", "PENDING");
|
||||
when(jobMapper.selectActiveCleanTaskByRoom("APT-A", "RM1")).thenReturn(active);
|
||||
|
||||
Map<String, Object> result = service.autoCreateCleanOnVacated("101", "LEAVE:JC-2");
|
||||
|
||||
assertFalse(Boolean.TRUE.equals(result.get("created")));
|
||||
assertTrue(Boolean.TRUE.equals(result.get("duplicated")));
|
||||
assertEquals("CT-EXIST", result.get("taskId"));
|
||||
verify(jobMapper, never()).insertCleanTask(anyString(), anyString(), anyString(), anyString(),
|
||||
any(), any(), any(Date.class), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncOccupancyMarksRoomToCleanWhenVacant()
|
||||
{
|
||||
stubRoom();
|
||||
when(jobMapper.syncRoomOccupancy("APT-A", "101")).thenReturn(1);
|
||||
when(jobMapper.countLiveStayByRoom("APT-A", "101")).thenReturn(0);
|
||||
when(jobMapper.selectActiveRepairByRoom("APT-A", "RM1")).thenReturn(null);
|
||||
when(roomMapper.selectActiveCleanState("APT-A", "RM1")).thenReturn(null);
|
||||
when(roomMapper.updateRoomStatus(eq("APT-A"), anyLong(), anyLong(), anyString(), any())).thenReturn(1);
|
||||
|
||||
int live = service.syncRoomOccupancy("101");
|
||||
|
||||
assertEquals(0, live);
|
||||
verify(roomMapper).updateRoomStatus(eq("APT-A"), eq(10L), eq(1L), eq("3"), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void listMineIncludesUnassignedPoolForCleaner()
|
||||
{
|
||||
Map<String, Object> staff = new LinkedHashMap<>();
|
||||
staff.put("staffId", "ST-1");
|
||||
staff.put("staffType", "CLEANER");
|
||||
when(jobMapper.selectStaffByDevice("APT-A", "DEV-1")).thenReturn(staff);
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("taskId", "CT-AUTO");
|
||||
row.put("assigneeStaffId", null);
|
||||
when(jobMapper.selectCleanList(eq("APT-A"), any())).thenReturn(new ArrayList<>(List.of(row)));
|
||||
|
||||
List<Map<String, Object>> rows = service.listMine("DEV-1", "CLEAN", new AptJobQuery());
|
||||
|
||||
assertEquals(1, rows.size());
|
||||
assertEquals(Boolean.TRUE, rows.get(0).get("pool"));
|
||||
|
||||
// 保洁员请求维修任务:直接返回空,避免越权看到维修单
|
||||
assertTrue(service.listMine("DEV-1", "REPAIR", new AptJobQuery()).isEmpty());
|
||||
verify(jobMapper, never()).selectRepairList(anyString(), any());
|
||||
}
|
||||
|
||||
private void stubRoom()
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("roomId", "RM1");
|
||||
row.put("roomNo", "101");
|
||||
row.put("roomStatus", "1");
|
||||
row.put("stateVersion", 1L);
|
||||
row.put("inPersonCount", 1);
|
||||
row.put("bedCount", 2);
|
||||
when(jobMapper.selectRoomByNo("APT-A", "101")).thenReturn(row);
|
||||
|
||||
AptRoom room = new AptRoom();
|
||||
room.setId(10L);
|
||||
room.setRoomId("RM1");
|
||||
room.setRoomNo("101");
|
||||
room.setRoomStatus("1");
|
||||
room.setStateVersion(1L);
|
||||
room.setInPersonCount(0);
|
||||
room.setBedCount(2);
|
||||
when(roomMapper.selectRoomByRoomId("APT-A", "RM1")).thenReturn(room);
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.ruoyi.apt.master;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.master.mapper.AptPersonMapper;
|
||||
import com.ruoyi.apt.master.service.AptPersonServiceImpl;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* M4 回归:关联 Tab 组合正确、快照只读 40904。
|
||||
*/
|
||||
class AptPersonServiceTest
|
||||
{
|
||||
private AptPersonMapper mapper;
|
||||
private AptPersonServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
mapper = mock(AptPersonMapper.class);
|
||||
service = new AptPersonServiceImpl(mapper);
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void associationsComposeStayCallsAndRoute()
|
||||
{
|
||||
Map<String, Object> person = new LinkedHashMap<>();
|
||||
person.put("ryid", "RY-1");
|
||||
person.put("name", "张三");
|
||||
Map<String, Object> stay = new LinkedHashMap<>();
|
||||
stay.put("roomNo", "101");
|
||||
Map<String, Object> route = new LinkedHashMap<>();
|
||||
route.put("routeId", "RT-1");
|
||||
when(mapper.selectPerson("APT-A", "RY-1")).thenReturn(person);
|
||||
when(mapper.selectActiveStay("APT-A", "RY-1")).thenReturn(stay);
|
||||
when(mapper.selectRecentCalls("APT-A", "RY-1", 5)).thenReturn(List.of(stay));
|
||||
when(mapper.selectRecentRoute("APT-A", "RY-1")).thenReturn(route);
|
||||
|
||||
Map<String, Object> result = service.associations("RY-1");
|
||||
|
||||
assertEquals("101", ((Map<?, ?>) result.get("currentStay")).get("roomNo"));
|
||||
assertEquals(1, ((List<?>) result.get("recentCalls")).size());
|
||||
assertEquals("RT-1", ((Map<?, ?>) result.get("recentRoute")).get("routeId"));
|
||||
assertTrue(result.get("person") instanceof Map);
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotWriteIsAlwaysRejected()
|
||||
{
|
||||
AptException ex = assertThrows(AptException.class, () -> service.rejectSnapshotWrite("RY-1"));
|
||||
assertEquals(AptErrorCode.SNAPSHOT_READONLY, ex.getErrorCode());
|
||||
assertEquals("PERSON_SNAPSHOT_READONLY", ex.getReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailMissingPersonIs404()
|
||||
{
|
||||
when(mapper.selectPerson("APT-A", "RY-404")).thenReturn(null);
|
||||
AptException ex = assertThrows(AptException.class, () -> service.detail("RY-404"));
|
||||
assertEquals(AptErrorCode.NOT_FOUND, ex.getErrorCode());
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package com.ruoyi.apt.report;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptErrorCode;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.framework.config.AptProperties;
|
||||
import com.ruoyi.apt.report.domain.AptResumeEnvelope;
|
||||
import com.ruoyi.apt.report.mapper.AptReportMapper;
|
||||
import com.ruoyi.apt.report.service.AptReportServiceImpl;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
|
||||
/**
|
||||
* IT-6 上报续传回归:重试白名单、水位对账、心跳偏差、幂等续报。
|
||||
*/
|
||||
class AptReportServiceTest
|
||||
{
|
||||
private AptReportMapper mapper;
|
||||
private AptReportServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
mapper = mock(AptReportMapper.class);
|
||||
service = new AptReportServiceImpl(mapper, new AptProperties());
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void requeueFailedBecomesPending()
|
||||
{
|
||||
Map<String, Object> failed = new LinkedHashMap<>();
|
||||
failed.put("id", 1L);
|
||||
failed.put("state", "FAILED");
|
||||
Map<String, Object> pending = new LinkedHashMap<>();
|
||||
pending.put("id", 1L);
|
||||
pending.put("state", "PENDING");
|
||||
when(mapper.selectOutboxById("APT-A", 1L)).thenReturn(failed, pending);
|
||||
when(mapper.requeueOutbox(eq("APT-A"), eq(1L), anyString())).thenReturn(1);
|
||||
|
||||
Map<String, Object> result = service.requeue(1L);
|
||||
|
||||
assertEquals("PENDING", result.get("state"));
|
||||
assertTrue(result.get("retryBatchId").toString().startsWith("RB-"));
|
||||
verify(mapper).requeueOutbox(eq("APT-A"), eq(1L), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requeueSentIsRejected()
|
||||
{
|
||||
Map<String, Object> sent = new LinkedHashMap<>();
|
||||
sent.put("id", 2L);
|
||||
sent.put("state", "SENT");
|
||||
when(mapper.selectOutboxById("APT-A", 2L)).thenReturn(sent);
|
||||
|
||||
AptException ex = assertThrows(AptException.class, () -> service.requeue(2L));
|
||||
assertEquals(AptErrorCode.STATE_ILLEGAL, ex.getErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void watermarkComputesLag()
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("currentWatermark", 10L);
|
||||
row.put("ackedWatermark", 4L);
|
||||
when(mapper.selectWatermark("APT-A")).thenReturn(row);
|
||||
|
||||
Map<String, Object> view = service.watermark();
|
||||
|
||||
assertEquals(10L, view.get("currentWatermark"));
|
||||
assertEquals(4L, view.get("ackedWatermark"));
|
||||
assertEquals(6L, view.get("lag"));
|
||||
assertEquals(5000L, view.get("clockSkewThresholdMs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void heartbeatFlagsClockSkewBeyondFiveSeconds()
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("currentWatermark", 100L);
|
||||
row.put("ackedWatermark", 100L);
|
||||
when(mapper.selectWatermark("APT-A")).thenReturn(row);
|
||||
|
||||
Map<String, Object> view = service.recordHeartbeat(100L, 6000L);
|
||||
|
||||
assertEquals(true, view.get("clockSkewExceeded"));
|
||||
verify(mapper).upsertWatermark(eq("APT-A"), eq(100L), eq(6000L), any(Date.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resumeReplayCountsAsDuplicate()
|
||||
{
|
||||
when(mapper.insertOutbox(anyString(), anyString(), anyString(), anyString(),
|
||||
anyLong(), any(), any(Date.class))).thenThrow(new DuplicateKeyException("uk_outbox_gyid_idem"));
|
||||
AptResumeEnvelope envelope = new AptResumeEnvelope();
|
||||
envelope.setIdempotencyKey("KEY-1");
|
||||
envelope.setBizType("CHECKIN");
|
||||
envelope.setBizId("BIZ-1");
|
||||
envelope.setBizVersion(2L);
|
||||
|
||||
Map<String, Object> result = service.resume(List.of(envelope));
|
||||
|
||||
assertEquals(0, result.get("accepted"));
|
||||
assertEquals(1, result.get("duplicated"));
|
||||
assertEquals(0, result.get("rejected"));
|
||||
List<?> items = (List<?>) result.get("items");
|
||||
assertEquals("DUPLICATE", ((Map<?, ?>) items.get(0)).get("status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resumeWithoutKeyHashesBusinessTuple()
|
||||
{
|
||||
when(mapper.insertOutbox(eq("APT-A"), anyString(), eq("CHECKIN"), eq("BIZ-2"),
|
||||
eq(1L), any(), any(Date.class))).thenReturn(1);
|
||||
AptResumeEnvelope envelope = new AptResumeEnvelope();
|
||||
envelope.setBizType("CHECKIN");
|
||||
envelope.setBizId("BIZ-2");
|
||||
|
||||
Map<String, Object> result = service.resume(List.of(envelope));
|
||||
|
||||
assertEquals(1, result.get("accepted"));
|
||||
List<?> items = (List<?>) result.get("items");
|
||||
String key = ((Map<?, ?>) items.get(0)).get("idempotencyKey").toString();
|
||||
assertEquals(64, key.length());
|
||||
}
|
||||
}
|
||||
+70
-1
@@ -19,12 +19,14 @@ import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.stay.domain.AptRoom;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomCreateCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomDetail;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusCommand;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusResult;
|
||||
import com.ruoyi.apt.stay.domain.AptRoomStatusSummary;
|
||||
import com.ruoyi.apt.stay.mapper.AptRoomMapper;
|
||||
import com.ruoyi.apt.stay.service.AptRoomServiceImpl;
|
||||
import com.ruoyi.apt.voice.service.IAptSipEndpointService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -34,13 +36,15 @@ import org.mockito.ArgumentCaptor;
|
||||
class AptRoomServiceTest
|
||||
{
|
||||
private AptRoomMapper mapper;
|
||||
private IAptSipEndpointService sipEndpointService;
|
||||
private AptRoomServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
mapper = mock(AptRoomMapper.class);
|
||||
service = new AptRoomServiceImpl(mapper);
|
||||
sipEndpointService = mock(IAptSipEndpointService.class);
|
||||
service = new AptRoomServiceImpl(mapper, sipEndpointService);
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
}
|
||||
@@ -277,4 +281,69 @@ class AptRoomServiceTest
|
||||
row.put(key, value);
|
||||
return row;
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRegistersRoomAndCreatesSipEndpoint()
|
||||
{
|
||||
Map<String, Object> master = new LinkedHashMap<>();
|
||||
master.put("roomId", "ROOM-201");
|
||||
master.put("roomFloor", "2");
|
||||
master.put("roomType", "DOUBLE");
|
||||
when(mapper.selectMasterRoomByNo("APT-A", "201")).thenReturn(master);
|
||||
when(mapper.countRoomByNo("APT-A", "201")).thenReturn(0);
|
||||
AptRoom created = room("1", 0L);
|
||||
created.setId(9L);
|
||||
created.setRoomId("ROOM-201");
|
||||
when(mapper.selectRoomByRoomId("APT-A", "ROOM-201")).thenReturn(created);
|
||||
when(mapper.selectRoomById("APT-A", 9L)).thenReturn(created);
|
||||
|
||||
AptRoomCreateCommand command = new AptRoomCreateCommand();
|
||||
command.setRoomNo("201");
|
||||
service.create(command);
|
||||
|
||||
verify(mapper).insertRoom("APT-A", "ROOM-201", "201", "2", "DOUBLE");
|
||||
verify(sipEndpointService).ensureForRoom("ROOM-201", "201", null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRejectsRoomMissingInMasterBaseline()
|
||||
{
|
||||
when(mapper.selectMasterRoomByNo("APT-A", "999")).thenReturn(null);
|
||||
|
||||
AptRoomCreateCommand command = new AptRoomCreateCommand();
|
||||
command.setRoomNo("999");
|
||||
AptException error = assertThrows(AptException.class, () -> service.create(command));
|
||||
|
||||
assertEquals("ROOM_MASTER_NOT_FOUND", error.getReason());
|
||||
verify(sipEndpointService, never()).ensureForRoom(anyString(), anyString(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRefusesRoomWithLivePersons()
|
||||
{
|
||||
AptRoom room = room("1", 0L);
|
||||
room.setId(3L);
|
||||
room.setInPersonCount(2);
|
||||
when(mapper.selectRoomById("APT-A", 3L)).thenReturn(room);
|
||||
|
||||
AptException error = assertThrows(AptException.class, () -> service.delete(3L));
|
||||
|
||||
assertEquals(40902, error.getErrorCode().getCode());
|
||||
verify(sipEndpointService, never()).removeForRoom(anyString());
|
||||
verify(mapper, never()).softDeleteRoom(anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteSyncsSipRecordsBeforeSoftDeletingRoom()
|
||||
{
|
||||
AptRoom room = room("1", 0L);
|
||||
room.setId(4L);
|
||||
when(mapper.selectRoomById("APT-A", 4L)).thenReturn(room);
|
||||
when(mapper.softDeleteRoom("APT-A", 4L)).thenReturn(1);
|
||||
|
||||
service.delete(4L);
|
||||
|
||||
verify(sipEndpointService).removeForRoom("ROOM-201");
|
||||
verify(mapper).softDeleteRoom("APT-A", 4L);
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package com.ruoyi.apt.voice;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.ruoyi.apt.common.AptContext;
|
||||
import com.ruoyi.apt.common.AptException;
|
||||
import com.ruoyi.apt.voice.mapper.AptSipEndpointMapper;
|
||||
import com.ruoyi.apt.voice.service.AptSipEndpointServiceImpl;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* 房间 ↔ SIP endpoint/aor/contact 生命周期同步与 IP 冲突回归。
|
||||
*/
|
||||
class AptSipEndpointServiceTest
|
||||
{
|
||||
private AptSipEndpointMapper mapper;
|
||||
private AptSipEndpointServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
mapper = mock(AptSipEndpointMapper.class);
|
||||
service = new AptSipEndpointServiceImpl(mapper);
|
||||
AptContext.clear();
|
||||
AptContext.setGyId("APT-A");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
AptContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ensureForRoomCreatesEndpointAorAndBinding()
|
||||
{
|
||||
service.ensureForRoom("ROOM-201", "201", "房间 201", null);
|
||||
|
||||
verify(mapper).insertEndpoint(eq("APT-A"), anyString(), eq("201"), eq("ROOM-201"), eq("房间 201"));
|
||||
verify(mapper).insertAor(eq("APT-A"), anyString(), anyString());
|
||||
verify(mapper).insertBinding(eq("APT-A"), anyString(), anyString(), eq("ROOM-201"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ensureForRoomIsIdempotentWhenEndpointExists()
|
||||
{
|
||||
when(mapper.selectEndpointByRoom("APT-A", "ROOM-201")).thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
|
||||
when(mapper.selectAorByEndpoint("APT-A", "EP-1")).thenReturn(row("aorId", "AOR-1"));
|
||||
when(mapper.selectActiveBindingByRoom("APT-A", "ROOM-201"))
|
||||
.thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
|
||||
|
||||
service.ensureForRoom("ROOM-201", "201", null, null);
|
||||
|
||||
verify(mapper, never()).insertEndpoint(anyString(), anyString(), anyString(), anyString(), any());
|
||||
verify(mapper, never()).insertAor(anyString(), anyString(), anyString());
|
||||
verify(mapper, never()).insertBinding(anyString(), anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ensureForRoomRejectsOccupiedExtension()
|
||||
{
|
||||
when(mapper.countEndpointByExtension("APT-A", "201")).thenReturn(1);
|
||||
|
||||
AptException error = assertThrows(AptException.class,
|
||||
() -> service.ensureForRoom("ROOM-201", "201", null, null));
|
||||
|
||||
assertEquals("EXTENSION_CONFLICT", error.getReason());
|
||||
verify(mapper, never()).insertEndpoint(anyString(), anyString(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindIpRejectsIpAlreadyBoundToAnotherEndpoint()
|
||||
{
|
||||
when(mapper.selectEndpoint("APT-A", "EP-1")).thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
|
||||
when(mapper.selectAorByEndpoint("APT-A", "EP-1")).thenReturn(row("aorId", "AOR-1"));
|
||||
Map<String, Object> occupied = row("endpointId", "EP-2");
|
||||
occupied.put("uri", "sip:1002@192.168.1.10:5060");
|
||||
when(mapper.selectActiveContactByIp("APT-A", "192.168.1.10")).thenReturn(occupied);
|
||||
|
||||
AptException error = assertThrows(AptException.class,
|
||||
() -> service.bindIp("EP-1", "192.168.1.10", 5060));
|
||||
|
||||
assertEquals("SIP_IP_CONFLICT", error.getReason());
|
||||
assertEquals(40901, error.getErrorCode().getCode());
|
||||
verify(mapper, never()).insertContact(anyString(), anyString(), anyString(), anyString(),
|
||||
anyString(), anyString(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindIpReleasesOldContactAndWritesNewOne()
|
||||
{
|
||||
when(mapper.selectEndpoint("APT-A", "EP-1")).thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
|
||||
when(mapper.selectAorByEndpoint("APT-A", "EP-1")).thenReturn(row("aorId", "AOR-1"));
|
||||
Map<String, Object> occupied = row("endpointId", "EP-1");
|
||||
occupied.put("port", 5060);
|
||||
when(mapper.selectActiveContactByIp("APT-A", "192.168.1.11")).thenReturn(occupied);
|
||||
|
||||
service.bindIp("EP-1", "192.168.1.11", 5062);
|
||||
|
||||
verify(mapper).releaseContacts("APT-A", "EP-1", "REBIND");
|
||||
verify(mapper).insertContact(eq("APT-A"), anyString(), eq("AOR-1"), eq("EP-1"),
|
||||
eq("sip:1001@192.168.1.11:5062"), eq("192.168.1.11"), eq(5062));
|
||||
verify(mapper).updateEndpointIp("APT-A", "EP-1", "192.168.1.11");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindIpIsIdempotentForSameEndpointAndPort()
|
||||
{
|
||||
when(mapper.selectEndpoint("APT-A", "EP-1")).thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
|
||||
when(mapper.selectAorByEndpoint("APT-A", "EP-1")).thenReturn(row("aorId", "AOR-1"));
|
||||
Map<String, Object> occupied = row("endpointId", "EP-1");
|
||||
occupied.put("port", 5060);
|
||||
when(mapper.selectActiveContactByIp("APT-A", "192.168.1.11")).thenReturn(occupied);
|
||||
|
||||
service.bindIp("EP-1", "192.168.1.11", null);
|
||||
|
||||
verify(mapper, never()).insertContact(anyString(), anyString(), anyString(), anyString(),
|
||||
anyString(), anyString(), anyInt());
|
||||
verify(mapper, never()).releaseContacts(anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unbindIpReleasesContactsAndClearsEndpointIp()
|
||||
{
|
||||
when(mapper.selectEndpoint("APT-A", "EP-1")).thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
|
||||
|
||||
service.unbindIp("EP-1");
|
||||
|
||||
verify(mapper).releaseContacts("APT-A", "EP-1", "UNBIND");
|
||||
verify(mapper).updateEndpointIp("APT-A", "EP-1", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeForRoomClosesBindingAndSoftDeletesEndpoint()
|
||||
{
|
||||
when(mapper.selectEndpointByRoom("APT-A", "ROOM-201")).thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
|
||||
|
||||
service.removeForRoom("ROOM-201");
|
||||
|
||||
verify(mapper).closeBindingsByRoom("APT-A", "ROOM-201");
|
||||
verify(mapper).releaseContacts("APT-A", "EP-1", "ROOM_DELETED");
|
||||
verify(mapper).softDeleteAor("APT-A", "EP-1");
|
||||
verify(mapper).softDeleteEndpoint("APT-A", "EP-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindIpRejectsIllegalIp()
|
||||
{
|
||||
AptException error = assertThrows(AptException.class,
|
||||
() -> service.bindIp("EP-1", "192.168.1", 5060));
|
||||
|
||||
assertEquals("SIP_IP_INVALID", error.getReason());
|
||||
verify(mapper, never()).selectEndpoint(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ensureForRoomCreatesAuthForActiveInstance()
|
||||
{
|
||||
when(mapper.selectActiveInstanceCode("APT-A")).thenReturn("AST-1");
|
||||
|
||||
service.ensureForRoom("ROOM-201", "201", null, null);
|
||||
|
||||
verify(mapper).insertAuth(eq("APT-A"), eq("AST-1"), eq("201"), eq("AUTH-AST-1-201"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ensureForRoomSkipsAuthWhenNoActiveInstance()
|
||||
{
|
||||
service.ensureForRoom("ROOM-201", "201", null, null);
|
||||
|
||||
verify(mapper, never()).insertAuth(anyString(), anyString(), anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ensureForRoomReusesExistingAuth()
|
||||
{
|
||||
when(mapper.selectActiveInstanceCode("APT-A")).thenReturn("AST-1");
|
||||
when(mapper.selectAuth("APT-A", "AST-1", "201")).thenReturn(row("authId", "AUTH-AST-1-201"));
|
||||
|
||||
service.ensureForRoom("ROOM-201", "201", null, null);
|
||||
|
||||
verify(mapper, never()).insertAuth(anyString(), anyString(), anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeForRoomDisablesAuth()
|
||||
{
|
||||
when(mapper.selectEndpointByRoom("APT-A", "ROOM-201")).thenReturn(endpoint("EP-1", "1001", "ROOM-201"));
|
||||
|
||||
service.removeForRoom("ROOM-201");
|
||||
|
||||
verify(mapper).disableAuthByEndpoint("APT-A", "EP-1");
|
||||
}
|
||||
|
||||
private Map<String, Object> endpoint(String endpointId, String extension, String roomId)
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("endpointId", endpointId);
|
||||
row.put("extension", extension);
|
||||
row.put("roomId", roomId);
|
||||
return row;
|
||||
}
|
||||
|
||||
private Map<String, Object> row(String key, String value)
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put(key, value);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 作业人员(保洁 / 维修)管理 API。
|
||||
*
|
||||
* 人员与权限:
|
||||
* - sysUserId 关联后台账号,决定其可登录的菜单与权限点;
|
||||
* - deviceCode 绑定平板,平板接口凭 X-Device-Code 识别执行人(大写存储);
|
||||
* - 停用即下线(dutyState=OFFLINE),删除为软删并解绑设备,保留历史工单执行人引用。
|
||||
*/
|
||||
|
||||
export function listStaff(query) {
|
||||
return request({
|
||||
url: '/apt/staff/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
export function getStaff(staffId) {
|
||||
return request({
|
||||
url: '/apt/staff/' + staffId,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function addStaff(data) {
|
||||
return request({
|
||||
url: '/apt/staff',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateStaff(staffId, data) {
|
||||
return request({
|
||||
url: '/apt/staff/' + staffId,
|
||||
method: 'put',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 启停用:body.state = ACTIVE / DISABLED
|
||||
export function changeStaffState(staffId, data) {
|
||||
return request({
|
||||
url: '/apt/staff/' + staffId + '/state',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
export function delStaff(staffId) {
|
||||
return request({
|
||||
url: '/apt/staff/' + staffId,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 作业工单(保洁 / 维修)管理 API。
|
||||
*
|
||||
* 职责边界:
|
||||
* - 平板侧上报与状态推进在 /open/v1/tablet/jobs,后台不提供改状态入口;
|
||||
* - 本文件只做查询、统计、维修派单与取消工单。
|
||||
*
|
||||
* 返回口径:控制器统一 AjaxResult.success(data),列表在 response.data(不是分页 rows),
|
||||
* 后端 limit 默认 100(上限 500),前端按页切片展示。
|
||||
*/
|
||||
|
||||
// 工单列表:type=CLEAN/REPAIR(空=两类合并),state/roomNo/staffId/from/to 可选
|
||||
export function listWorkOrder(query) {
|
||||
return request({
|
||||
url: '/apt/workorder/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 统计:summary(总量/完成/在办/平均时长)、byType(类型+状态分布)、staffWorkload(人员工作量)
|
||||
export function getWorkOrderStats(query) {
|
||||
return request({
|
||||
url: '/apt/workorder/stats',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 详情(含状态流水):type=CLEAN/REPAIR
|
||||
export function getWorkOrder(type, jobId) {
|
||||
return request({
|
||||
url: '/apt/workorder/' + type + '/' + jobId,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 维修派单 / 改派(仅 REPORTED / ASSIGNED)
|
||||
export function assignRepair(orderId, data) {
|
||||
return request({
|
||||
url: '/apt/workorder/repair/' + orderId + '/assign',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 取消工单(误报 / 重复报修),房态随之重算
|
||||
export function cancelRepair(orderId, data) {
|
||||
return request({
|
||||
url: '/apt/workorder/repair/' + orderId + '/cancel',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
@@ -202,6 +202,63 @@ export const aptTimelineSourceOptions = dictOptions([
|
||||
['TABLET', '平板结果', 'success']
|
||||
])
|
||||
|
||||
/** 作业类型(保洁 / 维修) */
|
||||
export const aptJobTypeOptions = dictOptions([
|
||||
['CLEAN', '保洁', 'info'],
|
||||
['REPAIR', '维修', 'warning']
|
||||
])
|
||||
|
||||
/** 保洁任务态(gy_clean_task.state) */
|
||||
export const aptCleanStateOptions = dictOptions([
|
||||
['PENDING', '待清扫', 'warning'],
|
||||
['PROCESSING', '保洁中', 'primary'],
|
||||
['DONE', '已完成', 'success']
|
||||
])
|
||||
|
||||
/** 维修工单态(apt_repair_order.state) */
|
||||
export const aptRepairStateOptions = dictOptions([
|
||||
['REPORTED', '待派单', 'warning'],
|
||||
['ASSIGNED', '已派单', 'primary'],
|
||||
['IN_PROGRESS', '维修中', 'primary'],
|
||||
['DONE', '已完成', 'success'],
|
||||
['CANCELLED', '已取消', 'info']
|
||||
])
|
||||
|
||||
/** 维修类别(apt_repair_order.category) */
|
||||
export const aptRepairCategoryOptions = dictOptions([
|
||||
['ELECTRIC', '电气', 'warning'],
|
||||
['PLUMBING', '水暖', 'primary'],
|
||||
['DOOR_WINDOW', '门窗', 'info'],
|
||||
['AIRCON', '空调', 'primary'],
|
||||
['OTHER', '其他', 'info']
|
||||
])
|
||||
|
||||
/** 维修优先级(apt_repair_order.priority) */
|
||||
export const aptRepairPriorityOptions = dictOptions([
|
||||
['NORMAL', '普通', 'info'],
|
||||
['URGENT', '紧急', 'danger']
|
||||
])
|
||||
|
||||
/** 作业人员类型(apt_staff.staff_type) */
|
||||
export const aptStaffTypeOptions = dictOptions([
|
||||
['CLEANER', '保洁员', 'info'],
|
||||
['REPAIRMAN', '维修工', 'warning'],
|
||||
['BOTH', '保洁兼维修', 'primary']
|
||||
])
|
||||
|
||||
/** 作业人员启停态(apt_staff.state) */
|
||||
export const aptStaffStateOptions = dictOptions([
|
||||
['ACTIVE', '启用', 'success'],
|
||||
['DISABLED', '停用', 'danger']
|
||||
])
|
||||
|
||||
/** 作业人员在岗态(apt_staff.duty_state,停用即 OFFLINE) */
|
||||
export const aptStaffDutyStateOptions = dictOptions([
|
||||
['IDLE', '空闲', 'success'],
|
||||
['BUSY', '作业中', 'primary'],
|
||||
['OFFLINE', '离线', 'info']
|
||||
])
|
||||
|
||||
/** 错误码 → 面向值班员的中文提示 */
|
||||
export const aptErrorText = {
|
||||
40001: '请求参数不合法,请检查表单填写',
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-alert
|
||||
v-if="errorText"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb8"
|
||||
:title="errorText"
|
||||
/>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb8"
|
||||
title="设备编码绑定平板后,平板凭 X-Device-Code 识别执行人;停用即下线,删除为软删并解绑设备(历史工单仍保留执行人)。"
|
||||
/>
|
||||
|
||||
<el-form
|
||||
:model="queryParams"
|
||||
ref="queryForm"
|
||||
size="small"
|
||||
:inline="true"
|
||||
v-show="showSearch"
|
||||
label-width="72px"
|
||||
>
|
||||
<el-form-item label="人员类型" prop="staffType">
|
||||
<el-select v-model="queryParams.staffType" placeholder="全部" clearable style="width: 150px">
|
||||
<el-option
|
||||
v-for="item in staffTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="state">
|
||||
<el-select v-model="queryParams.state" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option
|
||||
v-for="item in staffStateOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键字" prop="keyword">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="姓名 / 工号"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
@keyup.enter.native="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
icon="el-icon-plus"
|
||||
size="mini"
|
||||
@click="handleAdd"
|
||||
v-hasPermi="['apt:staff:add']"
|
||||
>新增</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
icon="el-icon-refresh"
|
||||
size="mini"
|
||||
:loading="loading"
|
||||
@click="getList"
|
||||
v-hasPermi="['apt:staff:list']"
|
||||
>刷新</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-table v-loading="loading" :data="pageRows" border size="small">
|
||||
<el-table-column label="人员编号" prop="staffId" width="150" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="姓名" prop="name" width="100" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="类型" width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<dict-tag :options="staffTypeOptions" :value="scope.row.staffType" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="工号" prop="workNo" width="110" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="电话" prop="phone" width="130" />
|
||||
<el-table-column label="绑定设备" width="150" :show-overflow-tooltip="true">
|
||||
<template slot-scope="scope">
|
||||
<span v-if="scope.row.deviceCode">{{ scope.row.deviceCode }}</span>
|
||||
<el-tag v-else size="mini" type="info">未绑定</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="后台账号" width="100" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span v-if="scope.row.sysUserId">{{ scope.row.sysUserId }}</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启停态" width="90" align="center">
|
||||
<template slot-scope="scope">
|
||||
<dict-tag :options="staffStateOptions" :value="scope.row.state" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="在岗态" width="90" align="center">
|
||||
<template slot-scope="scope">
|
||||
<dict-tag :options="dutyStateOptions" :value="scope.row.dutyState" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="160">
|
||||
<template slot-scope="scope">{{ fmt(scope.row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230" fixed="right" class-name="small-padding fixed-width">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-view"
|
||||
@click="handleDetail(scope.row)"
|
||||
v-hasPermi="['apt:staff:query']"
|
||||
>详情</el-button>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-edit"
|
||||
@click="handleUpdate(scope.row)"
|
||||
v-hasPermi="['apt:staff:edit']"
|
||||
>修改</el-button>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="text"
|
||||
:icon="scope.row.state === 'ACTIVE' ? 'el-icon-turn-off' : 'el-icon-open'"
|
||||
@click="handleState(scope.row)"
|
||||
v-hasPermi="['apt:staff:changeStatus']"
|
||||
>{{ scope.row.state === 'ACTIVE' ? '停用' : '启用' }}</el-button>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-delete"
|
||||
@click="handleDelete(scope.row)"
|
||||
v-hasPermi="['apt:staff:remove']"
|
||||
>删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="staffList.length > pageSize"
|
||||
:total="staffList.length"
|
||||
:page.sync="pageNum"
|
||||
:limit.sync="pageSize"
|
||||
/>
|
||||
|
||||
<!-- 新增 / 修改 -->
|
||||
<el-dialog :title="title" :visible.sync="open" width="640px" append-to-body>
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="100px" size="small">
|
||||
<el-row :gutter="16">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="人员编号" prop="staffId">
|
||||
<el-input
|
||||
v-model="form.staffId"
|
||||
:disabled="!isAdd"
|
||||
placeholder="留空由后台生成 ST-xxxxxxxxxxxx"
|
||||
maxlength="64"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="人员类型" prop="staffType">
|
||||
<el-select v-model="form.staffType" placeholder="请选择" style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in staffTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="姓名" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入姓名" maxlength="64" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="工号" prop="workNo">
|
||||
<el-input v-model="form.workNo" placeholder="同公寓内唯一,可空" maxlength="32" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="电话" prop="phone">
|
||||
<el-input v-model="form.phone" placeholder="联系电话" maxlength="32" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="后台账号" prop="sysUserId">
|
||||
<el-input-number
|
||||
v-model="form.sysUserId"
|
||||
:min="1"
|
||||
:controls="false"
|
||||
placeholder="sys_user.user_id,可空"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="平板设备" prop="deviceCode">
|
||||
<el-input v-model="form.deviceCode" placeholder="X-Device-Code,可空" maxlength="64" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="form.remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="人员编号保存后不可修改;一个设备编码只能绑定一名在岗人员,换绑请先解绑或删除原人员。"
|
||||
/>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitForm">确 定</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 人员详情 -->
|
||||
<el-drawer title="作业人员详情" :visible.sync="detailOpen" size="600px" append-to-body>
|
||||
<div v-loading="detailLoading" class="padding-20">
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="人员编号">{{ detail.staffId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="姓名">{{ detail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">
|
||||
<dict-tag :options="staffTypeOptions" :value="detail.staffType" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="工号">{{ detail.workNo || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="电话">{{ detail.phone || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="后台账号">{{ detail.sysUserId || '未绑定' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="平板设备">{{ detail.deviceCode || '未绑定' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="启停态">
|
||||
<dict-tag :options="staffStateOptions" :value="detail.state" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="在岗态">
|
||||
<dict-tag :options="dutyStateOptions" :value="detail.dutyState" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="业务版本">{{ detail.bizVersion || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ fmt(detail.createTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ fmt(detail.updateTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ detail.remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listStaff, getStaff, addStaff, updateStaff, changeStaffState, delStaff } from '@/api/apt/staff'
|
||||
import {
|
||||
aptStaffTypeOptions,
|
||||
aptStaffStateOptions,
|
||||
aptStaffDutyStateOptions,
|
||||
formatAptError,
|
||||
pickAptError
|
||||
} from '@/utils/apt'
|
||||
|
||||
export default {
|
||||
name: 'AptStaff',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
detailLoading: false,
|
||||
submitting: false,
|
||||
showSearch: true,
|
||||
errorText: '',
|
||||
staffList: [],
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
queryParams: {
|
||||
staffType: undefined,
|
||||
state: undefined,
|
||||
keyword: undefined
|
||||
},
|
||||
open: false,
|
||||
isAdd: true,
|
||||
title: '新增作业人员',
|
||||
form: {},
|
||||
rules: {
|
||||
staffType: [{ required: true, message: '请选择人员类型', trigger: 'change' }],
|
||||
name: [
|
||||
{ required: true, message: '姓名不能为空', trigger: 'blur' },
|
||||
{ max: 64, message: '姓名长度不能超过 64', trigger: 'blur' }
|
||||
],
|
||||
workNo: [{ max: 32, message: '工号长度不能超过 32', trigger: 'blur' }],
|
||||
phone: [{ max: 32, message: '电话长度不能超过 32', trigger: 'blur' }],
|
||||
deviceCode: [{ max: 64, message: '设备编码长度不能超过 64', trigger: 'blur' }],
|
||||
remark: [{ max: 255, message: '备注长度不能超过 255', trigger: 'blur' }]
|
||||
},
|
||||
detailOpen: false,
|
||||
detail: {},
|
||||
staffTypeOptions: aptStaffTypeOptions,
|
||||
staffStateOptions: aptStaffStateOptions,
|
||||
dutyStateOptions: aptStaffDutyStateOptions
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
pageRows() {
|
||||
const start = (this.pageNum - 1) * this.pageSize
|
||||
return this.staffList.slice(start, start + this.pageSize)
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
},
|
||||
methods: {
|
||||
fmt(value) {
|
||||
if (!value) {
|
||||
return '—'
|
||||
}
|
||||
return this.parseTime(value, '{y}-{m}-{d} {h}:{i}:{s}')
|
||||
},
|
||||
getList() {
|
||||
this.loading = true
|
||||
this.errorText = ''
|
||||
listStaff({
|
||||
staffType: this.queryParams.staffType || undefined,
|
||||
state: this.queryParams.state || undefined,
|
||||
keyword: this.queryParams.keyword || undefined
|
||||
})
|
||||
.then(response => {
|
||||
this.staffList = response.data || []
|
||||
this.pageNum = 1
|
||||
})
|
||||
.catch(error => {
|
||||
const picked = pickAptError(error)
|
||||
this.errorText = formatAptError(picked.code, picked.reason)
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
handleQuery() {
|
||||
this.getList()
|
||||
},
|
||||
resetQuery() {
|
||||
this.queryParams = { staffType: undefined, state: undefined, keyword: undefined }
|
||||
this.getList()
|
||||
},
|
||||
reset() {
|
||||
this.form = {
|
||||
staffId: undefined,
|
||||
staffType: 'CLEANER',
|
||||
name: undefined,
|
||||
workNo: undefined,
|
||||
phone: undefined,
|
||||
sysUserId: undefined,
|
||||
deviceCode: undefined,
|
||||
remark: undefined
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.form) {
|
||||
this.$refs.form.clearValidate()
|
||||
}
|
||||
})
|
||||
},
|
||||
cancel() {
|
||||
this.open = false
|
||||
this.reset()
|
||||
},
|
||||
handleAdd() {
|
||||
this.isAdd = true
|
||||
this.title = '新增作业人员'
|
||||
this.reset()
|
||||
this.open = true
|
||||
},
|
||||
handleUpdate(row) {
|
||||
this.isAdd = false
|
||||
this.title = '修改作业人员'
|
||||
this.form = {
|
||||
staffId: row.staffId,
|
||||
staffType: row.staffType,
|
||||
name: row.name,
|
||||
workNo: row.workNo,
|
||||
phone: row.phone,
|
||||
sysUserId: row.sysUserId,
|
||||
deviceCode: row.deviceCode,
|
||||
remark: row.remark
|
||||
}
|
||||
this.open = true
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.form) {
|
||||
this.$refs.form.clearValidate()
|
||||
}
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
this.$refs.form.validate(valid => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
this.submitting = true
|
||||
const payload = {
|
||||
staffType: this.form.staffType,
|
||||
name: this.form.name,
|
||||
workNo: this.form.workNo || undefined,
|
||||
phone: this.form.phone || undefined,
|
||||
sysUserId: this.form.sysUserId || undefined,
|
||||
deviceCode: this.form.deviceCode || undefined,
|
||||
remark: this.form.remark || undefined
|
||||
}
|
||||
const request = this.isAdd
|
||||
? addStaff(Object.assign({ staffId: this.form.staffId || undefined }, payload))
|
||||
: updateStaff(this.form.staffId, payload)
|
||||
request
|
||||
.then(response => {
|
||||
const data = response.data || {}
|
||||
this.$modal.msgSuccess((this.isAdd ? '新增成功:' : '修改成功:') + (data.staffId || ''))
|
||||
this.open = false
|
||||
this.getList()
|
||||
})
|
||||
.catch(error => {
|
||||
const picked = pickAptError(error)
|
||||
this.$modal.msgError(formatAptError(picked.code, picked.reason))
|
||||
})
|
||||
.finally(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
})
|
||||
},
|
||||
handleState(row) {
|
||||
const target = row.state === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
|
||||
const tip = target === 'DISABLED'
|
||||
? '停用后该人员平板立即失效(在岗态置为离线),确认停用「' + row.name + '」?'
|
||||
: '确认启用「' + row.name + '」?启用后恢复空闲态。'
|
||||
this.$modal.confirm(tip).then(() => {
|
||||
return changeStaffState(row.staffId, { state: target })
|
||||
}).then(response => {
|
||||
const data = response.data || {}
|
||||
this.$modal.msgSuccess('已' + (target === 'DISABLED' ? '停用' : '启用') + ':' + (data.name || row.name))
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
},
|
||||
handleDelete(row) {
|
||||
this.$modal.confirm('确认删除人员「' + row.name + '」?删除为软删并解绑设备,历史工单仍保留执行人。').then(() => {
|
||||
return delStaff(row.staffId)
|
||||
}).then(() => {
|
||||
this.$modal.msgSuccess('删除成功')
|
||||
this.getList()
|
||||
}).catch(() => {})
|
||||
},
|
||||
handleDetail(row) {
|
||||
this.detailOpen = true
|
||||
this.detailLoading = true
|
||||
this.detail = {}
|
||||
getStaff(row.staffId)
|
||||
.then(response => {
|
||||
this.detail = response.data || {}
|
||||
})
|
||||
.catch(error => {
|
||||
const picked = pickAptError(error)
|
||||
this.errorText = formatAptError(picked.code, picked.reason)
|
||||
})
|
||||
.finally(() => {
|
||||
this.detailLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.padding-20 {
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,727 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-alert
|
||||
v-if="errorText"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb8"
|
||||
:title="errorText"
|
||||
/>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb8"
|
||||
title="后台只做查询、维修派单与取消;开始/完成等状态推进由作业人员在平板(/open/v1/tablet/jobs)执行,房态随之重算。"
|
||||
/>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="12" class="mb8" v-loading="statsLoading">
|
||||
<el-col v-for="card in summaryCards" :key="card.label" :xs="12" :sm="8" :md="4">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value" :class="card.className">{{ card.value }}</div>
|
||||
<div class="stat-label">{{ card.label }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form
|
||||
:model="queryParams"
|
||||
ref="queryForm"
|
||||
size="small"
|
||||
:inline="true"
|
||||
v-show="showSearch"
|
||||
label-width="72px"
|
||||
>
|
||||
<el-form-item label="类型" prop="type">
|
||||
<el-select
|
||||
v-model="queryParams.type"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
style="width: 130px"
|
||||
@change="handleTypeChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in jobTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="state">
|
||||
<el-select v-model="queryParams.state" placeholder="全部" clearable style="width: 140px">
|
||||
<el-option
|
||||
v-for="item in stateOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="房号" prop="roomNo">
|
||||
<el-input
|
||||
v-model="queryParams.roomNo"
|
||||
placeholder="房间号"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
@keyup.enter.native="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="执行人" prop="staffId">
|
||||
<el-select
|
||||
v-model="queryParams.staffId"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 160px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in staffOptions"
|
||||
:key="item.staffId"
|
||||
:label="item.name + (item.workNo ? '(' + item.workNo + ')' : '')"
|
||||
:value="item.staffId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="业务时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="yyyy-MM-dd HH:mm:ss"
|
||||
:default-time="['00:00:00', '23:59:59']"
|
||||
style="width: 360px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
icon="el-icon-refresh"
|
||||
size="mini"
|
||||
:loading="loading"
|
||||
@click="getList"
|
||||
v-hasPermi="['apt:workorder:list']"
|
||||
>刷新</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="info"
|
||||
plain
|
||||
icon="el-icon-s-data"
|
||||
size="mini"
|
||||
@click="statsOpen = !statsOpen"
|
||||
v-hasPermi="['apt:workorder:stats']"
|
||||
>{{ statsOpen ? '收起统计' : '展开统计' }}</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-collapse-transition>
|
||||
<div v-show="statsOpen" class="mb8" v-hasPermi="['apt:workorder:stats']">
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never" class="mb8">
|
||||
<div slot="header" class="card-header">按类型 / 状态分布</div>
|
||||
<el-table :data="byType" size="mini" border>
|
||||
<el-table-column label="类型" width="90">
|
||||
<template slot-scope="scope">
|
||||
<dict-tag :options="jobTypeOptions" :value="scope.row.bizType" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template slot-scope="scope">
|
||||
<dict-tag :options="stateOptionsOf(scope.row.bizType)" :value="scope.row.state" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" prop="cnt" width="80" align="center" />
|
||||
<el-table-column label="平均时长" align="center">
|
||||
<template slot-scope="scope">{{ formatDuration(scope.row.avgDurationSec) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card shadow="never" class="mb8">
|
||||
<div slot="header" class="card-header">人员工作量(已完成)</div>
|
||||
<el-table :data="staffWorkload" size="mini" border>
|
||||
<el-table-column label="人员" min-width="120">
|
||||
<template slot-scope="scope">
|
||||
{{ scope.row.staffName || scope.row.staffId || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="完成数" prop="doneCount" width="90" align="center" />
|
||||
<el-table-column label="平均时长" align="center">
|
||||
<template slot-scope="scope">{{ formatDuration(scope.row.avgDurationSec) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-collapse-transition>
|
||||
|
||||
<el-table v-loading="loading" :data="pageRows" border size="small">
|
||||
<el-table-column label="类型" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<dict-tag :options="jobTypeOptions" :value="scope.row.type" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单号" prop="bizId" width="170" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="房号" prop="roomNo" width="90" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template slot-scope="scope">
|
||||
<dict-tag :options="stateOptionsOf(scope.row.type)" :value="scope.row.state" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类别" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
{{ scope.row.type === 'REPAIR' ? aptLabel(categoryOptions, scope.row.category) : '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="优先级" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.priority === 'URGENT'" size="mini" type="danger">紧急</el-tag>
|
||||
<span v-else-if="scope.row.type === 'REPAIR'">普通</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="执行人" min-width="110" :show-overflow-tooltip="true">
|
||||
<template slot-scope="scope">
|
||||
{{ scope.row.assigneeName || scope.row.assigneeStaffId || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="业务时间" width="160">
|
||||
<template slot-scope="scope">{{ fmt(scope.row.scheduledAt || scope.row.reportedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="完成时间" width="160">
|
||||
<template slot-scope="scope">{{ fmt(scope.row.finishedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时长" width="100" align="center">
|
||||
<template slot-scope="scope">{{ formatDuration(scope.row.durationSec) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right" class-name="small-padding fixed-width">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-view"
|
||||
@click="handleDetail(scope.row)"
|
||||
v-hasPermi="['apt:workorder:query']"
|
||||
>详情</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.type === 'REPAIR' && canAssign(scope.row)"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-s-promotion"
|
||||
@click="openAssign(scope.row)"
|
||||
v-hasPermi="['apt:workorder:assign']"
|
||||
>派单</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.type === 'REPAIR' && canCancel(scope.row)"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-circle-close"
|
||||
@click="openCancel(scope.row)"
|
||||
v-hasPermi="['apt:workorder:handle']"
|
||||
>取消</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="rows.length > pageSize"
|
||||
:total="rows.length"
|
||||
:page.sync="pageNum"
|
||||
:limit.sync="pageSize"
|
||||
/>
|
||||
|
||||
<!-- 工单详情 -->
|
||||
<el-drawer title="工单详情" :visible.sync="detailOpen" size="680px" append-to-body>
|
||||
<div v-loading="detailLoading" class="padding-20">
|
||||
<el-descriptions v-if="detail.type === 'CLEAN'" :column="2" border size="small">
|
||||
<el-descriptions-item label="任务号">{{ job.taskId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="房号">{{ job.roomNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<dict-tag :options="cleanStateOptions" :value="job.state" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="执行人">{{ job.assigneeName || job.assigneeStaffId || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="计划时间">{{ fmt(job.scheduledAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间">{{ fmt(job.startedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结束时间">{{ fmt(job.finishedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="时长">{{ formatDuration(job.durationSec) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="来源">{{ job.source || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ job.remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-descriptions v-if="detail.type === 'REPAIR'" :column="2" border size="small">
|
||||
<el-descriptions-item label="工单号">{{ job.orderId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="房号">{{ job.roomNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<dict-tag :options="repairStateOptions" :value="job.state" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="类别">{{ aptLabel(categoryOptions, job.category) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="优先级">{{ aptLabel(priorityOptions, job.priority) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="上报人">{{ job.reporterName || job.reporterStaffId || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="执行人">{{ job.assigneeName || job.assigneeStaffId || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="上报时间">{{ fmt(job.reportedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="派单时间">{{ fmt(job.assignedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间">{{ fmt(job.startedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="完成时间">{{ fmt(job.finishedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="时长">{{ formatDuration(job.durationSec) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="故障描述" :span="2">{{ job.description || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="维修结果" :span="2">{{ job.result || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ job.remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider content-position="left">状态流水(按时间升序)</el-divider>
|
||||
<el-timeline v-if="detail.events && detail.events.length">
|
||||
<el-timeline-item
|
||||
v-for="event in detail.events"
|
||||
:key="event.eventId"
|
||||
:timestamp="event.occurredAt"
|
||||
placement="top"
|
||||
:type="event.toState === 'DONE' ? 'success' : (event.toState === 'CANCELLED' ? 'danger' : 'primary')"
|
||||
>
|
||||
<div class="timeline-summary">
|
||||
{{ event.fromState || '创建' }} → {{ event.toState }}
|
||||
</div>
|
||||
<div class="timeline-meta">
|
||||
操作人:{{ event.operatorName || event.operatorStaff || '—' }}
|
||||
<span v-if="event.deviceCode"> / 设备:{{ event.deviceCode }}</span>
|
||||
<span v-if="event.remark"> / 备注:{{ event.remark }}</span>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-else description="暂无状态流水" :image-size="60" />
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 维修派单 -->
|
||||
<el-dialog title="维修派单 / 改派" :visible.sync="assignOpen" width="520px" append-to-body>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb8"
|
||||
title="仅「待派单 / 已派单」可派单;只有维修工(REPAIRMAN / BOTH)且启用的人员可选。"
|
||||
/>
|
||||
<el-form ref="assignForm" :model="assignForm" :rules="assignRules" label-width="90px" size="small">
|
||||
<el-form-item label="工单号">
|
||||
<el-input :value="currentRow.bizId" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="房号">
|
||||
<el-input :value="currentRow.roomNo" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="当前执行人">
|
||||
<el-input :value="currentRow.assigneeName || currentRow.assigneeStaffId || '未派单'" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="指派给" prop="staffId">
|
||||
<el-select v-model="assignForm.staffId" placeholder="请选择维修工" filterable style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in repairStaffOptions"
|
||||
:key="item.staffId"
|
||||
:label="item.name + (item.workNo ? '(' + item.workNo + ')' : '') + (item.dutyState === 'BUSY' ? ' - 作业中' : '')"
|
||||
:value="item.staffId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="assignOpen = false">取 消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitAssign">确 定</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 取消工单 -->
|
||||
<el-dialog title="取消维修工单" :visible.sync="cancelOpen" width="520px" append-to-body>
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb8"
|
||||
title="取消后房态按「维修中 > 保洁中 > 待清扫 > 住满/未满/空闲」重新计算;原因写入状态流水与审计。"
|
||||
/>
|
||||
<el-form ref="cancelForm" :model="cancelForm" :rules="cancelRules" label-width="90px" size="small">
|
||||
<el-form-item label="工单号">
|
||||
<el-input :value="currentRow.bizId" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="当前状态">
|
||||
<dict-tag :options="repairStateOptions" :value="currentRow.state" />
|
||||
</el-form-item>
|
||||
<el-form-item label="取消原因" prop="remark">
|
||||
<el-input
|
||||
v-model="cancelForm.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="如:误报、重复报修、住户自行处理"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button @click="cancelOpen = false">取 消</el-button>
|
||||
<el-button type="danger" :loading="submitting" @click="submitCancel">确 定</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listWorkOrder, getWorkOrderStats, getWorkOrder, assignRepair, cancelRepair } from '@/api/apt/workorder'
|
||||
import { listStaff } from '@/api/apt/staff'
|
||||
import {
|
||||
aptJobTypeOptions,
|
||||
aptCleanStateOptions,
|
||||
aptRepairStateOptions,
|
||||
aptRepairCategoryOptions,
|
||||
aptRepairPriorityOptions,
|
||||
aptLabel,
|
||||
formatAptError,
|
||||
pickAptError
|
||||
} from '@/utils/apt'
|
||||
|
||||
const REPAIR_ASSIGNABLE = ['REPORTED', 'ASSIGNED']
|
||||
const REPAIR_CANCELLABLE = ['REPORTED', 'ASSIGNED', 'IN_PROGRESS']
|
||||
|
||||
export default {
|
||||
name: 'AptWorkOrder',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
statsLoading: false,
|
||||
submitting: false,
|
||||
detailLoading: false,
|
||||
showSearch: true,
|
||||
statsOpen: false,
|
||||
errorText: '',
|
||||
rows: [],
|
||||
summary: {},
|
||||
byType: [],
|
||||
staffWorkload: [],
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
queryParams: {
|
||||
type: undefined,
|
||||
state: undefined,
|
||||
roomNo: undefined,
|
||||
staffId: undefined
|
||||
},
|
||||
dateRange: [],
|
||||
staffOptions: [],
|
||||
detailOpen: false,
|
||||
detail: { type: undefined, job: {}, events: [] },
|
||||
currentRow: {},
|
||||
assignOpen: false,
|
||||
assignForm: { staffId: undefined },
|
||||
assignRules: {
|
||||
staffId: [{ required: true, message: '请选择维修工', trigger: 'change' }]
|
||||
},
|
||||
cancelOpen: false,
|
||||
cancelForm: { remark: '' },
|
||||
cancelRules: {
|
||||
remark: [
|
||||
{ required: true, message: '取消原因不能为空', trigger: 'blur' },
|
||||
{ max: 500, message: '取消原因不能超过 500 字', trigger: 'blur' }
|
||||
]
|
||||
},
|
||||
jobTypeOptions: aptJobTypeOptions,
|
||||
cleanStateOptions: aptCleanStateOptions,
|
||||
repairStateOptions: aptRepairStateOptions,
|
||||
categoryOptions: aptRepairCategoryOptions,
|
||||
priorityOptions: aptRepairPriorityOptions
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
pageRows() {
|
||||
const start = (this.pageNum - 1) * this.pageSize
|
||||
return this.rows.slice(start, start + this.pageSize)
|
||||
},
|
||||
stateOptions() {
|
||||
if (this.queryParams.type === 'CLEAN') {
|
||||
return aptCleanStateOptions
|
||||
}
|
||||
if (this.queryParams.type === 'REPAIR') {
|
||||
return aptRepairStateOptions
|
||||
}
|
||||
const merged = {}
|
||||
aptCleanStateOptions.concat(aptRepairStateOptions).forEach(item => {
|
||||
if (!merged[item.value]) {
|
||||
merged[item.value] = item
|
||||
}
|
||||
})
|
||||
return Object.keys(merged).map(key => merged[key])
|
||||
},
|
||||
repairStaffOptions() {
|
||||
return this.staffOptions.filter(item => item.staffType !== 'CLEANER' && item.state === 'ACTIVE')
|
||||
},
|
||||
summaryCards() {
|
||||
const s = this.summary || {}
|
||||
const num = value => (value === null || value === undefined ? 0 : value)
|
||||
return [
|
||||
{ label: '工单总数', value: num(s.total), className: '' },
|
||||
{ label: '保洁', value: num(s.cleanTotal), className: 'text-primary' },
|
||||
{ label: '维修', value: num(s.repairTotal), className: 'text-warning' },
|
||||
{ label: '已完成', value: num(s.doneTotal), className: 'text-success' },
|
||||
{ label: '在办', value: num(s.activeTotal), className: 'text-danger' },
|
||||
{ label: '平均时长', value: this.formatDuration(s.avgDurationSec), className: '' }
|
||||
]
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
this.loadStaffOptions()
|
||||
},
|
||||
methods: {
|
||||
aptLabel,
|
||||
canAssign(row) {
|
||||
return REPAIR_ASSIGNABLE.indexOf(row.state) >= 0
|
||||
},
|
||||
canCancel(row) {
|
||||
return REPAIR_CANCELLABLE.indexOf(row.state) >= 0
|
||||
},
|
||||
stateOptionsOf(type) {
|
||||
return type === 'CLEAN' ? aptCleanStateOptions : aptRepairStateOptions
|
||||
},
|
||||
fmt(value) {
|
||||
if (!value) {
|
||||
return '—'
|
||||
}
|
||||
return this.parseTime(value, '{y}-{m}-{d} {h}:{i}:{s}')
|
||||
},
|
||||
formatDuration(seconds) {
|
||||
if (seconds === null || seconds === undefined || seconds === '' || isNaN(seconds)) {
|
||||
return '—'
|
||||
}
|
||||
const total = Math.max(0, Math.round(Number(seconds)))
|
||||
if (total < 60) {
|
||||
return total + ' 秒'
|
||||
}
|
||||
const minutes = Math.floor(total / 60)
|
||||
if (minutes < 60) {
|
||||
return minutes + ' 分 ' + (total % 60) + ' 秒'
|
||||
}
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return hours + ' 小时 ' + (minutes % 60) + ' 分'
|
||||
},
|
||||
queryRange() {
|
||||
return this.dateRange && this.dateRange.length === 2 ? this.dateRange : []
|
||||
},
|
||||
getList() {
|
||||
this.loading = true
|
||||
this.errorText = ''
|
||||
const range = this.queryRange()
|
||||
const params = {
|
||||
type: this.queryParams.type || undefined,
|
||||
state: this.queryParams.state || undefined,
|
||||
roomNo: this.queryParams.roomNo || undefined,
|
||||
staffId: this.queryParams.staffId || undefined,
|
||||
from: range[0],
|
||||
to: range[1],
|
||||
limit: 100
|
||||
}
|
||||
listWorkOrder(params)
|
||||
.then(response => {
|
||||
const data = response.data || []
|
||||
this.rows = data.map(row => Object.assign({}, row, { bizId: row.taskId || row.orderId }))
|
||||
this.pageNum = 1
|
||||
})
|
||||
.catch(error => {
|
||||
const picked = pickAptError(error)
|
||||
this.errorText = formatAptError(picked.code, picked.reason)
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
this.getStats()
|
||||
})
|
||||
},
|
||||
getStats() {
|
||||
this.statsLoading = true
|
||||
const range = this.queryRange()
|
||||
getWorkOrderStats({ from: range[0], to: range[1] })
|
||||
.then(response => {
|
||||
const data = response.data || {}
|
||||
this.summary = data.summary || {}
|
||||
this.byType = data.byType || []
|
||||
this.staffWorkload = data.staffWorkload || []
|
||||
})
|
||||
.catch(() => {
|
||||
this.summary = {}
|
||||
this.byType = []
|
||||
this.staffWorkload = []
|
||||
})
|
||||
.finally(() => {
|
||||
this.statsLoading = false
|
||||
})
|
||||
},
|
||||
loadStaffOptions() {
|
||||
listStaff({})
|
||||
.then(response => {
|
||||
this.staffOptions = response.data || []
|
||||
})
|
||||
.catch(() => {
|
||||
this.staffOptions = []
|
||||
})
|
||||
},
|
||||
handleTypeChange() {
|
||||
const allowed = this.stateOptions.map(item => item.value)
|
||||
if (this.queryParams.state && allowed.indexOf(this.queryParams.state) < 0) {
|
||||
this.queryParams.state = undefined
|
||||
}
|
||||
this.getList()
|
||||
},
|
||||
handleQuery() {
|
||||
this.getList()
|
||||
},
|
||||
resetQuery() {
|
||||
this.queryParams = { type: undefined, state: undefined, roomNo: undefined, staffId: undefined }
|
||||
this.dateRange = []
|
||||
this.getList()
|
||||
},
|
||||
handleDetail(row) {
|
||||
this.detailOpen = true
|
||||
this.detailLoading = true
|
||||
this.detail = { type: row.type, job: {}, events: [] }
|
||||
getWorkOrder(row.type, row.bizId)
|
||||
.then(response => {
|
||||
const data = response.data || {}
|
||||
this.detail = { type: data.type, job: data.job || {}, events: data.events || [] }
|
||||
})
|
||||
.catch(error => {
|
||||
const picked = pickAptError(error)
|
||||
this.errorText = formatAptError(picked.code, picked.reason)
|
||||
})
|
||||
.finally(() => {
|
||||
this.detailLoading = false
|
||||
})
|
||||
},
|
||||
openAssign(row) {
|
||||
this.currentRow = row
|
||||
this.assignForm = { staffId: row.assigneeStaffId || undefined }
|
||||
this.assignOpen = true
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.assignForm) {
|
||||
this.$refs.assignForm.clearValidate()
|
||||
}
|
||||
})
|
||||
},
|
||||
submitAssign() {
|
||||
this.$refs.assignForm.validate(valid => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
this.submitting = true
|
||||
assignRepair(this.currentRow.bizId, { staffId: this.assignForm.staffId })
|
||||
.then(response => {
|
||||
const result = response.data || {}
|
||||
this.$modal.msgSuccess('派单成功,工单状态:' + (result.state || 'ASSIGNED'))
|
||||
this.assignOpen = false
|
||||
this.getList()
|
||||
})
|
||||
.catch(error => {
|
||||
const picked = pickAptError(error)
|
||||
this.$modal.msgError(formatAptError(picked.code, picked.reason))
|
||||
})
|
||||
.finally(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
})
|
||||
},
|
||||
openCancel(row) {
|
||||
this.currentRow = row
|
||||
this.cancelForm = { remark: '' }
|
||||
this.cancelOpen = true
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.cancelForm) {
|
||||
this.$refs.cancelForm.clearValidate()
|
||||
}
|
||||
})
|
||||
},
|
||||
submitCancel() {
|
||||
this.$refs.cancelForm.validate(valid => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
this.submitting = true
|
||||
cancelRepair(this.currentRow.bizId, { remark: this.cancelForm.remark })
|
||||
.then(response => {
|
||||
const result = response.data || {}
|
||||
this.$modal.msgSuccess(
|
||||
result.duplicated ? '工单已取消(重复提交已按幂等处理)' : '工单已取消'
|
||||
)
|
||||
this.cancelOpen = false
|
||||
this.getList()
|
||||
})
|
||||
.catch(error => {
|
||||
const picked = pickAptError(error)
|
||||
this.$modal.msgError(formatAptError(picked.code, picked.reason))
|
||||
})
|
||||
.finally(() => {
|
||||
this.submitting = false
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: #303133;
|
||||
}
|
||||
.stat-label {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.card-header {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.padding-20 {
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
.timeline-summary {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
}
|
||||
.timeline-meta {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.text-primary {
|
||||
color: #409eff;
|
||||
}
|
||||
.text-warning {
|
||||
color: #e6a23c;
|
||||
}
|
||||
.text-success {
|
||||
color: #67c23a;
|
||||
}
|
||||
.text-danger {
|
||||
color: #f56c6c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
-- =============================================================================
|
||||
-- SIP 分机 / AOR / Contact 与房间的统一管理(承接 03_domain_03_voice.sql)
|
||||
-- 口径:
|
||||
-- 1) apt_sip_endpoint = 分机(PJSIP endpoint),一房一分机,extension 不可改号;
|
||||
-- 2) apt_sip_aor = 分机对应的 AOR(1:1,max_contacts=1 与文件一致);
|
||||
-- 3) apt_sip_contact = AOR 下的 contact(话机 IP 绑定事实,含历史);
|
||||
-- 4) apt_sip_binding = 房间 ↔ 分机的生效区间(历史可追溯,ACTIVE 唯一);
|
||||
-- 5) 同一 gyid 下「一个 IP 只能有一个 ACTIVE contact」= 同一 IP 不得重复绑定;
|
||||
-- 用生成列 + 唯一索引在库层兜底,服务层再做一次可读的冲突前置校验。
|
||||
-- 幂等:建表 IF NOT EXISTS,补列/补索引走 information_schema 判断 + 动态 SQL。
|
||||
-- =============================================================================
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. endpoint 补列:话机 IP(与 contact 一致)与来源
|
||||
-- -----------------------------------------------------------------------------
|
||||
CALL apt_add_column_if_missing('apt_sip_endpoint', 'ip_address',
|
||||
"ip_address VARCHAR(64) NULL COMMENT '话机 IP(与 ACTIVE contact 的 ip_address 一致,解绑后清空)'");
|
||||
CALL apt_add_column_if_missing('apt_sip_endpoint', 'source',
|
||||
"source VARCHAR(20) NOT NULL DEFAULT 'ROOM' COMMENT 'ROOM=随房间自动创建 / MANUAL=手工登记'");
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. AOR 表(与 endpoint 1:1)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS apt_sip_aor (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
gyid VARCHAR(32) NOT NULL,
|
||||
aor_id VARCHAR(64) NOT NULL COMMENT 'AOR 标识,如 AOR-<endpointId 后缀>',
|
||||
endpoint_id VARCHAR(64) NOT NULL COMMENT '所属分机(1:1)',
|
||||
max_contacts INT NOT NULL DEFAULT 1 COMMENT 'Asterisk max_contacts',
|
||||
remove_existing TINYINT NOT NULL DEFAULT 1 COMMENT 'Asterisk remove_existing:新注册顶掉旧 contact',
|
||||
default_expiration INT NOT NULL DEFAULT 3600 COMMENT 'contact 默认过期秒数',
|
||||
qualify_frequency INT NOT NULL DEFAULT 30 COMMENT 'OPTIONS 探测间隔秒',
|
||||
sync_state VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '仅 PENDING/SYNCED/FAILED/DRIFT',
|
||||
config_version BIGINT NOT NULL DEFAULT 1,
|
||||
deleted TINYINT NOT NULL DEFAULT 0,
|
||||
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_aor_gyid_id (gyid, aor_id),
|
||||
UNIQUE KEY uk_aor_gyid_endpoint (gyid, endpoint_id),
|
||||
KEY idx_aor_gyid_sync (gyid, sync_state),
|
||||
CONSTRAINT ck_aor_sync CHECK (sync_state IN ('PENDING','SYNCED','FAILED','DRIFT'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
|
||||
COMMENT='SIP AOR(与 endpoint 1:1,先库后 Asterisk)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. Contact 表(话机 IP 绑定事实)
|
||||
-- active_ip 生成列:仅 ACTIVE 且未删除时取值,配合唯一索引实现
|
||||
-- 「同 gyid 下一个 IP 只能有一个 ACTIVE contact」。
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS apt_sip_contact (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
gyid VARCHAR(32) NOT NULL,
|
||||
contact_id VARCHAR(64) NOT NULL,
|
||||
aor_id VARCHAR(64) NOT NULL,
|
||||
endpoint_id VARCHAR(64) NOT NULL,
|
||||
uri VARCHAR(255) NOT NULL COMMENT 'sip:<extension>@<ip>:<port>',
|
||||
ip_address VARCHAR(64) NOT NULL,
|
||||
port INT NOT NULL DEFAULT 5060,
|
||||
transport VARCHAR(8) NOT NULL DEFAULT 'udp',
|
||||
user_agent VARCHAR(128) NULL,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'STATIC' COMMENT 'STATIC=手工绑定 / DYNAMIC=注册上报',
|
||||
state VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/RELEASED/EXPIRED',
|
||||
last_seen_at DATETIME(3) NULL,
|
||||
released_at DATETIME(3) NULL,
|
||||
release_remark VARCHAR(128) NULL,
|
||||
active_ip VARCHAR(64) GENERATED ALWAYS AS
|
||||
(CASE WHEN state = 'ACTIVE' AND deleted = 0 THEN ip_address END) STORED,
|
||||
deleted TINYINT NOT NULL DEFAULT 0,
|
||||
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_contact_gyid_id (gyid, contact_id),
|
||||
UNIQUE KEY uk_contact_gyid_active_ip (gyid, active_ip),
|
||||
KEY idx_contact_gyid_endpoint (gyid, endpoint_id, state),
|
||||
KEY idx_contact_gyid_aor (gyid, aor_id, state),
|
||||
CONSTRAINT ck_contact_state CHECK (state IN ('ACTIVE','RELEASED','EXPIRED'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
|
||||
COMMENT='SIP contact(话机 IP 绑定事实,保留历史;同 IP 唯一 ACTIVE)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. binding 兜底:一个 endpoint 同时只能有一条 ACTIVE 绑定
|
||||
-- -----------------------------------------------------------------------------
|
||||
SET @apt_binding_col = (SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_sip_binding'
|
||||
AND COLUMN_NAME = 'active_endpoint');
|
||||
SET @apt_sql_binding_col = IF(@apt_binding_col = 0,
|
||||
'ALTER TABLE apt_sip_binding ADD COLUMN active_endpoint VARCHAR(64) GENERATED ALWAYS AS (CASE WHEN state = ''ACTIVE'' AND deleted = 0 THEN endpoint_id END) STORED',
|
||||
'SELECT 1');
|
||||
PREPARE stmt_binding_col FROM @apt_sql_binding_col;
|
||||
EXECUTE stmt_binding_col;
|
||||
DEALLOCATE PREPARE stmt_binding_col;
|
||||
|
||||
SET @apt_binding_idx = (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_sip_binding'
|
||||
AND INDEX_NAME = 'uk_binding_gyid_active_ep');
|
||||
SET @apt_sql_binding_idx = IF(@apt_binding_idx = 0,
|
||||
'ALTER TABLE apt_sip_binding ADD UNIQUE KEY uk_binding_gyid_active_ep (gyid, active_endpoint)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt_binding_idx FROM @apt_sql_binding_idx;
|
||||
EXECUTE stmt_binding_idx;
|
||||
DEALLOCATE PREPARE stmt_binding_idx;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 5. 校验
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT '=== 32. SIP 表就绪(应 4:endpoint/binding/aor/contact)===' AS section;
|
||||
SELECT COUNT(*) AS sip_tables
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN ('apt_sip_endpoint','apt_sip_binding','apt_sip_aor','apt_sip_contact');
|
||||
|
||||
SELECT '=== 33. endpoint 补列(应 2)===' AS section;
|
||||
SELECT COUNT(*) AS endpoint_columns
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_sip_endpoint'
|
||||
AND COLUMN_NAME IN ('ip_address','source');
|
||||
|
||||
SELECT '=== 34. 同 IP 唯一 ACTIVE 的库层兜底(应 1)===' AS section;
|
||||
SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_sip_contact'
|
||||
AND INDEX_NAME = 'uk_contact_gyid_active_ip'
|
||||
GROUP BY INDEX_NAME;
|
||||
|
||||
SELECT '=== 35. 一 endpoint 仅一条 ACTIVE 绑定(应 1)===' AS section;
|
||||
SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_sip_binding'
|
||||
AND INDEX_NAME = 'uk_binding_gyid_active_ep'
|
||||
GROUP BY INDEX_NAME;
|
||||
@@ -0,0 +1,129 @@
|
||||
-- =============================================================================
|
||||
-- SIP 认证(apt_sip_auth)
|
||||
-- 定位:分机在 Asterisk 上的 auth 账号,与 endpoint 1:1(可先建认证后绑分机)。
|
||||
-- 多租户/多实例:
|
||||
-- * 唯一性 = (gyid, instance_code, username) —— 同一实例下用户名唯一;
|
||||
-- 不同公寓或不同 Asterisk 实例可出现同名用户名,但彼此不可见;
|
||||
-- * 所有读写必须同时带 gyid + instance_code,禁止只按 username 查(跨公寓串号);
|
||||
-- * auth_id 采用 AUTH-<instance_code>-<username>,在实例内全局唯一,供 ps_auths.id 使用。
|
||||
-- 口令策略:
|
||||
-- * secret_mode='PLAIN':库内口令,默认 psw.voip(内网/联调用);
|
||||
-- * secret_mode='ENV' :只存环境变量名 secret_ref,取值在渲染/同步时注入,
|
||||
-- 生产建议用这一档(与 APT_AMI_SECRET 同一原则,不把口令写进库与日志);
|
||||
-- * 两种模式都保留 secret_version / last_rotated_at,便于轮换与审计。
|
||||
-- 幂等:建表 IF NOT EXISTS;种子与视图可重复执行。
|
||||
-- =============================================================================
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS apt_sip_auth (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
gyid VARCHAR(32) NOT NULL COMMENT '公寓标识(租户隔离键)',
|
||||
instance_code VARCHAR(64) NOT NULL COMMENT 'Asterisk 实例(apt_ast_instance.instance_code)',
|
||||
username VARCHAR(64) NOT NULL COMMENT 'SIP 用户名/分机号',
|
||||
auth_id VARCHAR(128) NOT NULL COMMENT 'Asterisk ps_auths.id,约定 AUTH-<instance>-<username>',
|
||||
endpoint_id VARCHAR(64) NULL COMMENT '关联分机(1:1,可空以便先建认证后绑分机)',
|
||||
auth_type VARCHAR(20) NOT NULL DEFAULT 'userpass' COMMENT 'Asterisk auth_type',
|
||||
secret_mode VARCHAR(10) NOT NULL DEFAULT 'PLAIN' COMMENT 'PLAIN=库内口令 / ENV=环境变量',
|
||||
secret_plain VARCHAR(128) NOT NULL DEFAULT 'psw.voip' COMMENT 'secret_mode=PLAIN 时的口令;空串表示未设置',
|
||||
secret_ref VARCHAR(64) NULL COMMENT 'secret_mode=ENV 时的环境变量名',
|
||||
secret_version INT NOT NULL DEFAULT 1 COMMENT '口令版本,轮换时 +1',
|
||||
realm VARCHAR(64) NOT NULL DEFAULT 'asterisk' COMMENT 'Asterisk auth realm',
|
||||
state VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/DISABLED/ROTATING',
|
||||
enabled TINYINT NOT NULL DEFAULT 1,
|
||||
last_rotated_at DATETIME(3) NULL,
|
||||
sync_state VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/SYNCED/FAILED/DRIFT',
|
||||
config_version BIGINT NOT NULL DEFAULT 1,
|
||||
deleted TINYINT NOT NULL DEFAULT 0,
|
||||
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_sipauth_gyid_auth_id (gyid, auth_id),
|
||||
UNIQUE KEY uk_sipauth_instance_username (gyid, instance_code, username),
|
||||
UNIQUE KEY uk_sipauth_instance_endpoint (gyid, instance_code, endpoint_id),
|
||||
KEY idx_sipauth_gyid_state (gyid, instance_code, state, enabled),
|
||||
KEY idx_sipauth_gyid_endpoint (gyid, endpoint_id),
|
||||
CONSTRAINT ck_sipauth_secret_mode CHECK (secret_mode IN ('PLAIN','ENV')),
|
||||
CONSTRAINT ck_sipauth_state CHECK (state IN ('ACTIVE','DISABLED','ROTATING')),
|
||||
CONSTRAINT ck_sipauth_auth_type CHECK (auth_type IN ('userpass','md5','userpass,md5')),
|
||||
CONSTRAINT ck_sipauth_secret_ref CHECK (secret_mode = 'PLAIN' OR secret_ref IS NOT NULL)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
|
||||
COMMENT='SIP 认证账号(按公寓 + Asterisk 实例隔离;默认口令 psw.voip 仅限内网/联调)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 种子:为已登记分机补认证账号(每个公寓取第一个启用的实例;已存在则跳过)
|
||||
-- -----------------------------------------------------------------------------
|
||||
INSERT INTO apt_sip_auth
|
||||
(gyid, instance_code, username, auth_id, endpoint_id, secret_plain)
|
||||
SELECT e.gyid,
|
||||
i.instance_code,
|
||||
e.extension,
|
||||
CONCAT('AUTH-', i.instance_code, '-', e.extension),
|
||||
e.endpoint_id,
|
||||
'psw.voip'
|
||||
FROM apt_sip_endpoint e
|
||||
JOIN apt_ast_instance i
|
||||
ON i.id = (SELECT i2.id FROM apt_ast_instance i2
|
||||
WHERE i2.gyid = e.gyid AND i2.deleted = 0 AND i2.enabled = 1
|
||||
ORDER BY i2.id LIMIT 1)
|
||||
LEFT JOIN apt_sip_auth a
|
||||
ON a.gyid = e.gyid AND a.instance_code = i.instance_code AND a.username = e.extension
|
||||
WHERE e.deleted = 0
|
||||
AND a.id IS NULL;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Asterisk realtime 视图:ps_auths / ps_endpoints(多实例按 gyid + instance_code 过滤)
|
||||
-- ENV 模式在视图中不回显口令(password 为 NULL),由同步器渲染后再下发。
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE VIEW ps_auths AS
|
||||
SELECT auth_id AS id,
|
||||
auth_type,
|
||||
username,
|
||||
CASE WHEN secret_mode = 'PLAIN' THEN secret_plain END AS password,
|
||||
realm
|
||||
FROM apt_sip_auth
|
||||
WHERE deleted = 0 AND enabled = 1 AND state = 'ACTIVE';
|
||||
|
||||
CREATE OR REPLACE VIEW ps_endpoints AS
|
||||
SELECT e.endpoint_id AS id,
|
||||
'transport-udp' AS transport,
|
||||
a.aor_id AS aors,
|
||||
au.auth_id AS auth,
|
||||
'from-internal' AS context,
|
||||
'ulaw' AS allow,
|
||||
'all' AS disallow,
|
||||
'no' AS direct_media,
|
||||
e.extension AS callerid,
|
||||
'yes' AS rewrite_contact
|
||||
FROM apt_sip_endpoint e
|
||||
JOIN apt_sip_aor a
|
||||
ON a.gyid = e.gyid AND a.endpoint_id = e.endpoint_id AND a.deleted = 0
|
||||
LEFT JOIN apt_sip_auth au
|
||||
ON au.gyid = e.gyid AND au.endpoint_id = e.endpoint_id
|
||||
AND au.deleted = 0 AND au.enabled = 1 AND au.state = 'ACTIVE'
|
||||
WHERE e.deleted = 0 AND e.enabled = 1;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 校验
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT '=== 36. apt_sip_auth 列数(应 20)===' AS section;
|
||||
SELECT COUNT(*) AS auth_columns
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_sip_auth';
|
||||
|
||||
SELECT '=== 37. 唯一键(应 3:auth_id / instance+username / instance+endpoint)===' AS section;
|
||||
SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_sip_auth' AND NON_UNIQUE = 0
|
||||
GROUP BY INDEX_NAME;
|
||||
|
||||
SELECT '=== 38. 默认口令 ===' AS section;
|
||||
SELECT COLUMN_NAME, COLUMN_DEFAULT
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_sip_auth'
|
||||
AND COLUMN_NAME IN ('secret_plain','secret_mode');
|
||||
|
||||
SELECT '=== 39. 现有认证账号(按实例分布)===' AS section;
|
||||
SELECT gyid, instance_code, COUNT(*) AS accounts, SUM(secret_mode = 'PLAIN') AS plain_accounts
|
||||
FROM apt_sip_auth
|
||||
WHERE deleted = 0
|
||||
GROUP BY gyid, instance_code;
|
||||
@@ -0,0 +1,77 @@
|
||||
-- =============================================================================
|
||||
-- 平板刷脸照片(叫乘作业留痕)
|
||||
-- 定位:平板在「刷脸确认成功(ANSWERED + VERIFIED)」后上传的抓拍原图,
|
||||
-- 库里只存「存储路径 + 校验信息 + 状态」,图像本体落在文件/对象存储,不进 BLOB。
|
||||
-- 关联:apt_tablet_call_result (result_id) → gy_jcxx (jcid),一结果最多一张有效照片。
|
||||
-- 路径规范(相对路径入库,根目录随环境配置 apt.photo.root):
|
||||
-- face/{gyid}/{yyyy}/{MM}/{dd}/{jcid}/{photoId}.{jpg|png}
|
||||
-- 例:face/APT-REV01-001/2026/11/12/JC-20261112-093000/PH-3F9A1C2B7D01.jpg
|
||||
-- 幂等:UNIQUE (gyid, result_id) —— 同一结果重传覆盖同一行(retry_count+1),不产生多张。
|
||||
-- 失败留痕:upload_state=FAILED + fail_reason,结果表 face_photo_state=FAILED,
|
||||
-- 不影响 ryzt=3(状态机已在结果受理时完成),平板可原样重试。
|
||||
-- =============================================================================
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS apt_tablet_face_photo (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
gyid VARCHAR(32) NOT NULL,
|
||||
photo_id VARCHAR(64) NOT NULL COMMENT '照片业务键,PH-<12位十六进制>',
|
||||
jcid VARCHAR(64) NOT NULL COMMENT '叫乘作业号(关联 gy_jcxx.jcid)',
|
||||
task_id VARCHAR(64) NOT NULL COMMENT '平板任务号',
|
||||
result_id VARCHAR(64) NOT NULL COMMENT '平板结果号(apt_tablet_call_result.result_id)',
|
||||
ryid VARCHAR(64) NULL COMMENT '刷脸命中人员(verified_ryid)',
|
||||
device_code VARCHAR(64) NOT NULL,
|
||||
storage_target VARCHAR(20) NOT NULL DEFAULT 'LOCAL' COMMENT 'LOCAL / OSS',
|
||||
storage_path VARCHAR(512) NOT NULL COMMENT '相对路径,不含根目录与域名',
|
||||
file_name VARCHAR(128) NOT NULL,
|
||||
content_type VARCHAR(64) NOT NULL COMMENT 'image/jpeg / image/png',
|
||||
file_size BIGINT NOT NULL DEFAULT 0,
|
||||
sha256 CHAR(64) NULL COMMENT '落盘后校验和,用于完整性核验',
|
||||
upload_state VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/STORED/FAILED/PURGED',
|
||||
fail_reason VARCHAR(255) NULL,
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
captured_at DATETIME(3) NULL COMMENT '平板抓拍时间(occurred_at)',
|
||||
uploaded_at DATETIME(3) NULL,
|
||||
deleted TINYINT NOT NULL DEFAULT 0,
|
||||
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_facephoto_gyid_id (gyid, photo_id),
|
||||
UNIQUE KEY uk_facephoto_gyid_result (gyid, result_id),
|
||||
KEY idx_facephoto_gyid_jcid (gyid, jcid, upload_state),
|
||||
KEY idx_facephoto_gyid_state (gyid, upload_state),
|
||||
CONSTRAINT ck_facephoto_state CHECK (upload_state IN ('PENDING','STORED','FAILED','PURGED')),
|
||||
CONSTRAINT ck_facephoto_target CHECK (storage_target IN ('LOCAL','OSS'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
|
||||
COMMENT='平板刷脸照片(只存路径与校验信息,图像落文件/对象存储)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 结果表补指针列:便于按 result 直接回显,不必每次 join
|
||||
-- -----------------------------------------------------------------------------
|
||||
CALL apt_add_column_if_missing('apt_tablet_call_result', 'face_photo_id',
|
||||
"face_photo_id VARCHAR(64) NULL COMMENT '最近一次上传的刷脸照片(apt_tablet_face_photo.photo_id)'");
|
||||
CALL apt_add_column_if_missing('apt_tablet_call_result', 'face_photo_state',
|
||||
"face_photo_state VARCHAR(20) NULL COMMENT '照片态:PENDING/STORED/FAILED,未上传为 NULL'");
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 校验
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT '=== 40. 照片表列数(应 22)===' AS section;
|
||||
SELECT COUNT(*) AS photo_columns
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_tablet_face_photo';
|
||||
|
||||
SELECT '=== 41. 唯一键(应 3:主键 + photo_id + result_id)===' AS section;
|
||||
SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_tablet_face_photo' AND NON_UNIQUE = 0
|
||||
GROUP BY INDEX_NAME;
|
||||
|
||||
SELECT '=== 42. 结果表照片指针列(应 2)===' AS section;
|
||||
SELECT COLUMN_NAME, COLUMN_TYPE
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_tablet_call_result'
|
||||
AND COLUMN_NAME IN ('face_photo_id','face_photo_state');
|
||||
|
||||
SELECT '=== 43. 现有照片记录 ===' AS section;
|
||||
SELECT upload_state, COUNT(*) AS cnt FROM apt_tablet_face_photo WHERE deleted = 0 GROUP BY upload_state;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- =============================================================================
|
||||
-- 刷脸照片保留期(最多 1 个月)+ 定期清理任务登记
|
||||
-- 清理范围:apt_tablet_face_photo 中 COALESCE(uploaded_at, captured_at, create_time)
|
||||
-- 早于 now - retentionDays 的记录:先删文件,再物理删记录。
|
||||
-- 保留天数由 apt.photo.retention-days 控制(默认 30,上限按业务 1 个月)。
|
||||
-- 任务:aptPhotoPurgeTask.purge(),建议每天 03:30(避开叫乘高峰)。
|
||||
-- 幂等:sys_job 按 invoke_target 判重,可重复执行。
|
||||
-- =============================================================================
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
INSERT INTO sys_job
|
||||
(job_name, job_group, invoke_target, cron_expression,
|
||||
misfire_policy, concurrent, status, create_by, create_time, remark)
|
||||
SELECT '刷脸照片过期清理(保留 30 天)',
|
||||
'SYSTEM',
|
||||
'aptPhotoPurgeTask.purge()',
|
||||
'0 30 3 * * ?',
|
||||
'1',
|
||||
1,
|
||||
'1',
|
||||
'IT-5',
|
||||
NOW(3),
|
||||
'每天 03:30 清理超过 apt.photo.retention-days(默认 30 天)的照片文件与记录'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_job WHERE invoke_target = 'aptPhotoPurgeTask.purge()'
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 校验
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT '=== 44. 清理任务登记(应 1 行,status=1)===' AS section;
|
||||
SELECT job_id, job_name, invoke_target, cron_expression, status
|
||||
FROM sys_job
|
||||
WHERE invoke_target = 'aptPhotoPurgeTask.purge()';
|
||||
|
||||
SELECT '=== 45. 当前照片记录与最老时间 ===' AS section;
|
||||
SELECT COUNT(*) AS photos,
|
||||
MIN(COALESCE(uploaded_at, captured_at, create_time)) AS oldest_at,
|
||||
SUM(COALESCE(uploaded_at, captured_at, create_time) < DATE_SUB(NOW(3), INTERVAL 30 DAY)) AS expired
|
||||
FROM apt_tablet_face_photo;
|
||||
@@ -0,0 +1,183 @@
|
||||
-- =============================================================================
|
||||
-- 平板作业(保洁 / 维修)与作业人员
|
||||
-- 1) 保洁复用既有 gy_clean_task(房态重算已依赖它:PENDING→3 待清扫 / PROCESSING→6 保洁中),
|
||||
-- 这里补「执行人、开始时间、备注、来源、房号快照」;
|
||||
-- 2) 维修新建 apt_repair_order(REPORTED→ASSIGNED→IN_PROGRESS→DONE/CANCELLED),
|
||||
-- 维修期间房态置 4(不能入住)并在 disabled_reason 标记 REPAIR:<orderId>;
|
||||
-- 3) 作业人员 apt_staff(保洁/维修,可绑后台账号与平板设备);
|
||||
-- 4) 状态流水 apt_job_event(两类作业共用,便于追溯与统计)。
|
||||
-- 5) 权限点挂在既有父菜单 3000(公寓端)下,menu_type=F。
|
||||
-- 幂等:列/表/权限均做存在性判断,可重复执行。
|
||||
-- =============================================================================
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. 保洁任务补列(沿用 gy_clean_task)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CALL apt_add_column_if_missing('gy_clean_task', 'assignee_staff_id',
|
||||
"assignee_staff_id VARCHAR(64) NULL COMMENT '保洁员(apt_staff.staff_id)'");
|
||||
CALL apt_add_column_if_missing('gy_clean_task', 'started_at',
|
||||
"started_at DATETIME(3) NULL COMMENT '开始保洁时间'");
|
||||
CALL apt_add_column_if_missing('gy_clean_task', 'remark',
|
||||
"remark VARCHAR(255) NULL COMMENT '平板备注'");
|
||||
CALL apt_add_column_if_missing('gy_clean_task', 'source',
|
||||
"source VARCHAR(20) NOT NULL DEFAULT 'TABLET' COMMENT 'TABLET=平板上报 / MANUAL=后台补录'");
|
||||
CALL apt_add_column_if_missing('gy_clean_task', 'room_no',
|
||||
"room_no VARCHAR(32) NULL COMMENT '房号快照(便于按房号查询与展示)'");
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. 作业人员
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS apt_staff (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
gyid VARCHAR(32) NOT NULL,
|
||||
staff_id VARCHAR(64) NOT NULL COMMENT '人员业务键 ST-<12位>',
|
||||
staff_type VARCHAR(20) NOT NULL COMMENT 'CLEANER / REPAIRMAN / BOTH',
|
||||
name VARCHAR(64) NOT NULL,
|
||||
work_no VARCHAR(32) NULL COMMENT '工号(同公寓内唯一,可空)',
|
||||
phone VARCHAR(32) NULL,
|
||||
sys_user_id BIGINT NULL COMMENT '绑定的后台账号(sys_user.user_id),用于权限归属',
|
||||
device_code VARCHAR(64) NULL COMMENT '绑定的平板设备(X-Device-Code)',
|
||||
duty_state VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT 'IDLE / BUSY / OFFLINE',
|
||||
state VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE / DISABLED',
|
||||
remark VARCHAR(255) NULL,
|
||||
biz_version BIGINT NOT NULL DEFAULT 1,
|
||||
deleted TINYINT NOT NULL DEFAULT 0,
|
||||
create_by VARCHAR(64) NULL,
|
||||
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_staff_gyid_id (gyid, staff_id),
|
||||
UNIQUE KEY uk_staff_gyid_workno (gyid, work_no),
|
||||
KEY idx_staff_gyid_type (gyid, staff_type, state),
|
||||
KEY idx_staff_gyid_device (gyid, device_code),
|
||||
CONSTRAINT ck_staff_type CHECK (staff_type IN ('CLEANER','REPAIRMAN','BOTH')),
|
||||
CONSTRAINT ck_staff_state CHECK (state IN ('ACTIVE','DISABLED')),
|
||||
CONSTRAINT ck_staff_duty CHECK (duty_state IN ('IDLE','BUSY','OFFLINE'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
|
||||
COMMENT='公寓作业人员(保洁/维修)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. 维修工单
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS apt_repair_order (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
gyid VARCHAR(32) NOT NULL,
|
||||
order_id VARCHAR(64) NOT NULL COMMENT '工单号 RO-<12位>',
|
||||
room_id VARCHAR(64) NOT NULL,
|
||||
room_no VARCHAR(32) NOT NULL,
|
||||
category VARCHAR(20) NOT NULL DEFAULT 'OTHER' COMMENT 'ELECTRIC/PLUMBING/DOOR_WINDOW/AIRCON/OTHER',
|
||||
description VARCHAR(500) NOT NULL,
|
||||
priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL' COMMENT 'NORMAL / URGENT',
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'TABLET' COMMENT 'TABLET / MANUAL',
|
||||
reporter_staff_id VARCHAR(64) NULL,
|
||||
reporter_name VARCHAR(64) NULL,
|
||||
assignee_staff_id VARCHAR(64) NULL,
|
||||
assignee_name VARCHAR(64) NULL,
|
||||
state VARCHAR(20) NOT NULL DEFAULT 'REPORTED' COMMENT 'REPORTED/ASSIGNED/IN_PROGRESS/DONE/CANCELLED',
|
||||
room_status_before VARCHAR(2) NULL,
|
||||
room_status_after VARCHAR(2) NULL,
|
||||
result VARCHAR(255) NULL COMMENT '维修结果(DONE 必填)',
|
||||
remark VARCHAR(500) NULL,
|
||||
reported_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
assigned_at DATETIME(3) NULL,
|
||||
started_at DATETIME(3) NULL,
|
||||
finished_at DATETIME(3) NULL,
|
||||
duration_sec BIGINT NULL COMMENT '作业时长(开始→完成)',
|
||||
version INT NOT NULL DEFAULT 0,
|
||||
deleted TINYINT NOT NULL DEFAULT 0,
|
||||
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_repair_gyid_order (gyid, order_id),
|
||||
KEY idx_repair_gyid_state (gyid, state, reported_at),
|
||||
KEY idx_repair_gyid_room (gyid, room_id, state),
|
||||
KEY idx_repair_gyid_assignee (gyid, assignee_staff_id, state),
|
||||
CONSTRAINT ck_repair_state CHECK (state IN ('REPORTED','ASSIGNED','IN_PROGRESS','DONE','CANCELLED')),
|
||||
CONSTRAINT ck_repair_priority CHECK (priority IN ('NORMAL','URGENT')),
|
||||
CONSTRAINT ck_repair_category CHECK (category IN ('ELECTRIC','PLUMBING','DOOR_WINDOW','AIRCON','OTHER'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
|
||||
COMMENT='维修工单(维修期间房态 4=不能入住)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. 作业状态流水(保洁 + 维修共用)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS apt_job_event (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
gyid VARCHAR(32) NOT NULL,
|
||||
event_id VARCHAR(64) NOT NULL,
|
||||
biz_type VARCHAR(20) NOT NULL COMMENT 'CLEAN / REPAIR',
|
||||
biz_id VARCHAR(64) NOT NULL COMMENT 'task_id / order_id',
|
||||
room_id VARCHAR(64) NULL,
|
||||
from_state VARCHAR(20) NULL,
|
||||
to_state VARCHAR(20) NOT NULL,
|
||||
operator_staff VARCHAR(64) NULL,
|
||||
operator_name VARCHAR(64) NULL,
|
||||
device_code VARCHAR(64) NULL,
|
||||
remark VARCHAR(255) NULL,
|
||||
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_jobevent_gyid_id (gyid, event_id),
|
||||
KEY idx_jobevent_gyid_biz (gyid, biz_type, biz_id, occurred_at),
|
||||
CONSTRAINT ck_jobevent_type CHECK (biz_type IN ('CLEAN','REPAIR'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
|
||||
COMMENT='作业状态流水(保洁/维修)';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 5. 权限点(挂在 3000 公寓端下,menu_type=F)
|
||||
-- -----------------------------------------------------------------------------
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, menu_type, visible, status, perms, create_by, create_time, remark)
|
||||
SELECT 3230, '作业工单查看', 3000, 30, 'F', '0', '0', 'apt:workorder:list', 'IT-5', NOW(3), '保洁/维修工单列表'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'apt:workorder:list');
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, menu_type, visible, status, perms, create_by, create_time, remark)
|
||||
SELECT 3231, '作业工单详情', 3000, 31, 'F', '0', '0', 'apt:workorder:query', 'IT-5', NOW(3), NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'apt:workorder:query');
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, menu_type, visible, status, perms, create_by, create_time, remark)
|
||||
SELECT 3232, '作业派单', 3000, 32, 'F', '0', '0', 'apt:workorder:assign', 'IT-5', NOW(3), '维修派单/改派'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'apt:workorder:assign');
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, menu_type, visible, status, perms, create_by, create_time, remark)
|
||||
SELECT 3233, '作业处置', 3000, 33, 'F', '0', '0', 'apt:workorder:handle', 'IT-5', NOW(3), '开始/完成/取消'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'apt:workorder:handle');
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, menu_type, visible, status, perms, create_by, create_time, remark)
|
||||
SELECT 3234, '作业统计', 3000, 34, 'F', '0', '0', 'apt:workorder:stats', 'IT-5', NOW(3), '按类型/状态/人员统计'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'apt:workorder:stats');
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, menu_type, visible, status, perms, create_by, create_time, remark)
|
||||
SELECT 3235, '作业人员列表', 3000, 35, 'F', '0', '0', 'apt:staff:list', 'IT-5', NOW(3), '保洁/维修人员'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'apt:staff:list');
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, menu_type, visible, status, perms, create_by, create_time, remark)
|
||||
SELECT 3236, '作业人员详情', 3000, 36, 'F', '0', '0', 'apt:staff:query', 'IT-5', NOW(3), NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'apt:staff:query');
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, menu_type, visible, status, perms, create_by, create_time, remark)
|
||||
SELECT 3237, '作业人员新增', 3000, 37, 'F', '0', '0', 'apt:staff:add', 'IT-5', NOW(3), NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'apt:staff:add');
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, menu_type, visible, status, perms, create_by, create_time, remark)
|
||||
SELECT 3238, '作业人员编辑', 3000, 38, 'F', '0', '0', 'apt:staff:edit', 'IT-5', NOW(3), NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'apt:staff:edit');
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, menu_type, visible, status, perms, create_by, create_time, remark)
|
||||
SELECT 3239, '作业人员删除', 3000, 39, 'F', '0', '0', 'apt:staff:remove', 'IT-5', NOW(3), NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'apt:staff:remove');
|
||||
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, menu_type, visible, status, perms, create_by, create_time, remark)
|
||||
SELECT 3240, '作业人员启停', 3000, 40, 'F', '0', '0', 'apt:staff:changeStatus', 'IT-5', NOW(3), NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'apt:staff:changeStatus');
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 6. 校验
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT '=== 46. 作业相关表(应 3)===' AS section;
|
||||
SELECT COUNT(*) AS tables_cnt
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN ('apt_staff','apt_repair_order','apt_job_event');
|
||||
|
||||
SELECT '=== 47. 保洁任务补列(应 5)===' AS section;
|
||||
SELECT COUNT(*) AS clean_columns
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gy_clean_task'
|
||||
AND COLUMN_NAME IN ('assignee_staff_id','started_at','remark','source','room_no');
|
||||
|
||||
SELECT '=== 48. 新增权限点(应 11)===' AS section;
|
||||
SELECT COUNT(*) AS perms_cnt FROM sys_menu
|
||||
WHERE perms IN ('apt:workorder:list','apt:workorder:query','apt:workorder:assign',
|
||||
'apt:workorder:handle','apt:workorder:stats',
|
||||
'apt:staff:list','apt:staff:query','apt:staff:add','apt:staff:edit',
|
||||
'apt:staff:remove','apt:staff:changeStatus');
|
||||
@@ -0,0 +1,60 @@
|
||||
-- =============================================================================
|
||||
-- 作业工单 / 作业人员前端页面菜单(menu_type='C',可重放)
|
||||
-- 前提:26_jobs_staff.sql 已建 F 权限点 3230~3240(apt:workorder:* / apt:staff:*)。
|
||||
-- 口径(与 13_it2_frontend_menus.sql 一致):
|
||||
-- 1) 只新增 C 类型页面菜单,不改 3000 父目录、不改任何 F 权限点;
|
||||
-- 2) 路由由后端 getRouters 下发,component 走 loadView('@/views/' + component);
|
||||
-- 3) C 菜单 perms 留空:可见性由 sys_role_menu 控制,按钮级权限由 F 权限点控制。
|
||||
-- 组件路径(须与前端一致):
|
||||
-- src/views/apt/workorder/index.vue ← 3204 作业工单
|
||||
-- src/views/apt/staff/index.vue ← 3205 作业人员
|
||||
-- =============================================================================
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. 页面菜单(C):父目录 3000(path='apt'),完整路由 /apt/workorder、/apt/staff
|
||||
-- -----------------------------------------------------------------------------
|
||||
INSERT INTO sys_menu
|
||||
(menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
|
||||
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
VALUES
|
||||
(3204, '作业工单', 3000, 8, 'workorder', 'apt/workorder/index', '', '', 1, 0, 'C', '0', '0',
|
||||
'', 'build', 'IT-5', NOW(3),
|
||||
'保洁/维修工单:列表、详情、维修派单、取消、统计;状态推进由平板执行'),
|
||||
(3205, '作业人员', 3000, 9, 'staff', 'apt/staff/index', '', '', 1, 0, 'C', '0', '0',
|
||||
'', 'peoples', 'IT-5', NOW(3),
|
||||
'保洁/维修人员:新增、编辑、启停、软删;device_code 绑定平板')
|
||||
ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name), parent_id = VALUES(parent_id),
|
||||
order_num = VALUES(order_num), path = VALUES(path), component = VALUES(component),
|
||||
menu_type = VALUES(menu_type), visible = VALUES(visible), status = VALUES(status),
|
||||
icon = VALUES(icon), remark = VALUES(remark);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. 关联超管角色(超管本身已有 *:*:*,此处保证 getRouters 下发并留痕)
|
||||
-- -----------------------------------------------------------------------------
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT 1, m.menu_id FROM sys_menu m
|
||||
WHERE m.menu_id IN (3204, 3205)
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.menu_id);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. 校验
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT '=== 49. 作业页面菜单(应 2 条 C)===' AS section;
|
||||
SELECT menu_id, menu_name, parent_id, path, component, menu_type, visible, status, icon
|
||||
FROM sys_menu WHERE menu_id IN (3204, 3205) ORDER BY menu_id;
|
||||
|
||||
SELECT '=== 50. 组件路径(须与前端 src/views/apt/** 一致)===' AS section;
|
||||
SELECT menu_id, CONCAT('src/views/', component, '.vue') AS vue_file
|
||||
FROM sys_menu WHERE menu_id IN (3204, 3205) ORDER BY menu_id;
|
||||
|
||||
SELECT '=== 51. C 菜单已关联超管(应 2)===' AS section;
|
||||
SELECT COUNT(*) AS admin_menu_bound
|
||||
FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id
|
||||
WHERE rm.role_id = 1 AND m.menu_id IN (3204, 3205) AND m.menu_type = 'C';
|
||||
|
||||
SELECT '=== 52. 公寓端菜单树构成(应 M=1 / C=9 / F=38)===' AS section;
|
||||
SELECT menu_type, COUNT(*) AS cnt
|
||||
FROM sys_menu
|
||||
WHERE menu_id = 3000 OR parent_id = 3000
|
||||
GROUP BY menu_type ORDER BY menu_type;
|
||||
@@ -0,0 +1,87 @@
|
||||
-- =============================================================================
|
||||
-- 保洁自动建单 / 报修自动流转(增量,可重放)
|
||||
-- 口径:
|
||||
-- 1) 离寓判定以 gy_jcxx 在寓事实为准:finish_ly='0' AND sjlysj IS NULL;
|
||||
-- gy_rooms.in_person_count 由该口径重算,不再依赖 +1/-1。
|
||||
-- 2) 同房同一时刻只允许一条活跃保洁任务(PENDING/PROCESSING):
|
||||
-- active_room 生成列 + 唯一索引,服务层先判后插,库层兜底并发。
|
||||
-- 3) source 增加 AUTO(全员离寓自动建单);TABLET=平板上报 / MANUAL=后台补录。
|
||||
-- 幂等:列/索引均做 information_schema 判断,可重复执行。
|
||||
-- =============================================================================
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. 保洁任务:活跃占位列 + 唯一索引(同房仅一条待处理/保洁中)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CALL apt_add_column_if_missing('gy_clean_task', 'active_room',
|
||||
"active_room VARCHAR(64) GENERATED ALWAYS AS (CASE WHEN state IN ('PENDING','PROCESSING') AND deleted = 0 THEN room_id END) STORED COMMENT '活跃任务房号占位:同房同时只允许一条待处理/保洁中'");
|
||||
|
||||
SET @apt_idx_active = (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gy_clean_task'
|
||||
AND INDEX_NAME = 'uk_cleantask_gyid_active_room');
|
||||
SET @apt_sql_active = IF(@apt_idx_active = 0,
|
||||
'ALTER TABLE gy_clean_task ADD UNIQUE KEY uk_cleantask_gyid_active_room (gyid, active_room)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt_cleantask_active FROM @apt_sql_active;
|
||||
EXECUTE stmt_cleantask_active;
|
||||
DEALLOCATE PREPARE stmt_cleantask_active;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. 查询索引:按房号找活跃任务 / 按房号统计在寓人数
|
||||
-- -----------------------------------------------------------------------------
|
||||
SET @apt_idx_room = (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gy_clean_task'
|
||||
AND INDEX_NAME = 'idx_cleantask_gyid_room_state');
|
||||
SET @apt_sql_room = IF(@apt_idx_room = 0,
|
||||
'ALTER TABLE gy_clean_task ADD KEY idx_cleantask_gyid_room_state (gyid, room_id, state, deleted)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt_cleantask_room FROM @apt_sql_room;
|
||||
EXECUTE stmt_cleantask_room;
|
||||
DEALLOCATE PREPARE stmt_cleantask_room;
|
||||
|
||||
SET @apt_idx_stay = (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gy_jcxx'
|
||||
AND INDEX_NAME = 'idx_jcxx_gyid_room_leave');
|
||||
SET @apt_sql_stay = IF(@apt_idx_stay = 0,
|
||||
'ALTER TABLE gy_jcxx ADD KEY idx_jcxx_gyid_room_leave (gyid, fjh, finish_ly)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt_jcxx_room FROM @apt_sql_stay;
|
||||
EXECUTE stmt_jcxx_room;
|
||||
DEALLOCATE PREPARE stmt_jcxx_room;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. source 取值口径补充 AUTO
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE gy_clean_task
|
||||
MODIFY COLUMN source VARCHAR(20) NOT NULL DEFAULT 'TABLET'
|
||||
COMMENT 'TABLET=平板上报 / MANUAL=后台补录 / AUTO=全员离寓自动建单';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. 校验
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT '=== 53. active_room 生成列(应 1:STORED GENERATED)===' AS section;
|
||||
SELECT COLUMN_NAME, COLUMN_TYPE, EXTRA
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gy_clean_task'
|
||||
AND COLUMN_NAME = 'active_room';
|
||||
|
||||
SELECT '=== 54. 同房唯一活跃任务索引(应 1)===' AS section;
|
||||
SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gy_clean_task'
|
||||
AND INDEX_NAME IN ('uk_cleantask_gyid_active_room','idx_cleantask_gyid_room_state')
|
||||
GROUP BY INDEX_NAME;
|
||||
|
||||
SELECT '=== 55. 在寓判定索引(应 1)===' AS section;
|
||||
SELECT INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gy_jcxx'
|
||||
AND INDEX_NAME = 'idx_jcxx_gyid_room_leave'
|
||||
GROUP BY INDEX_NAME;
|
||||
|
||||
SELECT '=== 56. 当前活跃保洁任务(每个房间应 <= 1)===' AS section;
|
||||
SELECT gyid, room_id, COUNT(*) AS active_cnt
|
||||
FROM gy_clean_task
|
||||
WHERE state IN ('PENDING','PROCESSING') AND deleted = 0
|
||||
GROUP BY gyid, room_id
|
||||
HAVING active_cnt > 1;
|
||||
@@ -0,0 +1,91 @@
|
||||
-- =============================================================================
|
||||
-- IT-6 权限点与查询索引(增量,可重放)
|
||||
-- 范围:上报续传(outbox/水位)、M4 人员关联、M5 设备指令入队
|
||||
-- 口径:
|
||||
-- 1) 只新增 F 权限点,不新增页面菜单(duty 入口与 M4/M5 页面不在本轮交付);
|
||||
-- 2) 权限点挂在 3000 公寓端目录下,与既有 3230~3240 作业权限并列;
|
||||
-- 3) 索引只补查询路径,不改任何业务列。
|
||||
-- =============================================================================
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. 权限点(F)
|
||||
-- -----------------------------------------------------------------------------
|
||||
INSERT INTO sys_menu
|
||||
(menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
|
||||
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
VALUES
|
||||
(3250, '上报队列查询', 3000, 30, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'apt:report:list', '#', 'IT-6', NOW(3), 'outbox 列表:bizType/state/时间'),
|
||||
(3251, '上报统计查询', 3000, 31, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'apt:report:query', '#', 'IT-6', NOW(3), '队列汇总 + 水位 + 心跳偏差'),
|
||||
(3252, '上报重试', 3000, 32, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'apt:report:retry', '#', 'IT-6', NOW(3), 'FAILED/DEAD 重新入队,复用原幂等键'),
|
||||
(3253, '人员列表', 3000, 33, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'apt:person:list', '#', 'IT-6', NOW(3), 'M4 人员列表'),
|
||||
(3254, '人员详情', 3000, 34, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'apt:person:query', '#', 'IT-6', NOW(3), 'M4 人员详情与关联 Tab'),
|
||||
(3255, '人员修改(拒绝)', 3000, 35, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'apt:person:edit', '#', 'IT-6', NOW(3), '快照只读,写入口固定返回 40904'),
|
||||
(3256, '设备列表', 3000, 36, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'apt:device:list', '#', 'IT-6', NOW(3), 'M5 设备档案与在线态'),
|
||||
(3257, '设备详情', 3000, 37, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'apt:device:query', '#', 'IT-6', NOW(3), 'M5 设备详情与指令记录'),
|
||||
(3258, '设备指令入队', 3000, 38, '', '', '', '', 1, 0, 'F', '0', '0',
|
||||
'apt:device:enqueue', '#', 'IT-6', NOW(3), '只入队 PENDING,不伪造 ACKED')
|
||||
ON DUPLICATE KEY UPDATE menu_name = VALUES(menu_name), parent_id = VALUES(parent_id),
|
||||
order_num = VALUES(order_num), menu_type = VALUES(menu_type), status = VALUES(status),
|
||||
perms = VALUES(perms), remark = VALUES(remark);
|
||||
|
||||
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||
SELECT 1, m.menu_id FROM sys_menu m
|
||||
WHERE m.menu_id BETWEEN 3250 AND 3258
|
||||
AND m.menu_type = 'F'
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.menu_id);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. 查询索引
|
||||
-- -----------------------------------------------------------------------------
|
||||
SET @apt_idx_outbox_time = (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_report_outbox'
|
||||
AND INDEX_NAME = 'idx_outbox_gyid_occurred');
|
||||
SET @apt_sql_outbox_time = IF(@apt_idx_outbox_time = 0,
|
||||
'ALTER TABLE apt_report_outbox ADD KEY idx_outbox_gyid_occurred (gyid, occurred_at)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt_outbox_time FROM @apt_sql_outbox_time;
|
||||
EXECUTE stmt_outbox_time;
|
||||
DEALLOCATE PREPARE stmt_outbox_time;
|
||||
|
||||
SET @apt_idx_cmd_device = (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'apt_device_command'
|
||||
AND INDEX_NAME = 'idx_devcmd_gyid_device_state');
|
||||
SET @apt_sql_cmd_device = IF(@apt_idx_cmd_device = 0,
|
||||
'ALTER TABLE apt_device_command ADD KEY idx_devcmd_gyid_device_state (gyid, device_code, state, create_time)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt_cmd_device FROM @apt_sql_cmd_device;
|
||||
EXECUTE stmt_cmd_device;
|
||||
DEALLOCATE PREPARE stmt_cmd_device;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. 校验
|
||||
-- -----------------------------------------------------------------------------
|
||||
SELECT '=== 57. IT-6 权限点(应 9)===' AS section;
|
||||
SELECT menu_id, perms, menu_name FROM sys_menu
|
||||
WHERE menu_id BETWEEN 3250 AND 3258 ORDER BY menu_id;
|
||||
|
||||
SELECT '=== 58. 超管已绑定(应 9)===' AS section;
|
||||
SELECT COUNT(*) AS admin_bound
|
||||
FROM sys_role_menu rm JOIN sys_menu m ON m.menu_id = rm.menu_id
|
||||
WHERE rm.role_id = 1 AND m.menu_id BETWEEN 3250 AND 3258 AND m.menu_type = 'F';
|
||||
|
||||
SELECT '=== 59. IT-6 索引(应 2)===' AS section;
|
||||
SELECT TABLE_NAME, INDEX_NAME, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) AS cols
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND INDEX_NAME IN ('idx_outbox_gyid_occurred', 'idx_devcmd_gyid_device_state')
|
||||
GROUP BY TABLE_NAME, INDEX_NAME;
|
||||
|
||||
SELECT '=== 60. outbox / 水位 / 指令表可读(应各 1 行)===' AS section;
|
||||
SELECT 'outbox' AS t, COUNT(*) AS cnt FROM apt_report_outbox
|
||||
UNION ALL SELECT 'watermark', COUNT(*) FROM apt_report_watermark
|
||||
UNION ALL SELECT 'device_command', COUNT(*) FROM apt_device_command;
|
||||
Reference in new issue
Block a user