第六阶段开发。

This commit is contained in:
zfjia committed 2026-11-12 13:36:24 +08:00
1 parent 35751b4f00
commit bacdd15f3a
93 files changed
+10190 -22

No files matched your search

+42
View File
@@ -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
@@ -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));
}
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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();
}
}
@@ -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);
}
@@ -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));
}
}
@@ -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);
@@ -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);
@@ -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)
@@ -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;
}
@@ -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())
@@ -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);
}
@@ -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
}
}
@@ -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();
}
}
@@ -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
*/
@@ -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();
}
@@ -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);
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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:&lt;orderId&gt;)
* 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();
}
}
@@ -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();
}
}
@@ -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:&lt;jcid&gt;),写入备注与流水
* @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);
}
@@ -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);
}
@@ -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();
}
}
@@ -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);
}
@@ -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();
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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);
}
}
}
@@ -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);
}
@@ -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「恢复触发重算」取证) */
@@ -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;
}
}
@@ -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);
@@ -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
@@ -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);
}
@@ -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;
}
}
@@ -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);
}
@@ -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);
@@ -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
@@ -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();
}
}
@@ -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);
}
@@ -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 &gt;= #{q.from}
</if>
<if test="q.to != null">
AND t.biz_time &lt;= #{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 &gt;= #{from})
AND (#{to} IS NULL OR c.scheduled_at &lt;= #{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 &gt;= #{from})
AND (#{to} IS NULL OR o.reported_at &lt;= #{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 &gt;= #{from})
AND (#{to} IS NULL OR c.scheduled_at &lt;= #{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 &gt;= #{from})
AND (#{to} IS NULL OR o.reported_at &lt;= #{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 &gt;= #{from})
AND (#{to} IS NULL OR c.scheduled_at &lt;= #{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 &gt;= #{from})
AND (#{to} IS NULL OR o.reported_at &lt;= #{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 &lt;&gt; #{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 &gt;= #{q.from}
</if>
<if test="q.to != null">
AND occurred_at &lt;= #{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 &lt; #{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
@@ -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 &lt;&gt; '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) &lt; #{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) &lt; #{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 &gt; NOW(3))
AND e.enabled = 1
ORDER BY b.effective_from DESC
@@ -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;
}
}
@@ -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");
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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);
}
}
@@ -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
})
}
+57
View File
@@ -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>
+132
View File
@@ -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;
+129
View File
@@ -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;
+41
View File
@@ -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;
+183
View File
@@ -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');
+60
View File
@@ -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;
+87
View File
@@ -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;
+91
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
'$mysql' 不是内部或外部命令,也不是可运行的程序
或批处理文件。
@@ -0,0 +1,204 @@
# 19 保洁与报修自动化(离寓触发 / 平板一键报修)
> 适用版本:apt-rev01(IT-5 之后增量)。
> 代码落点:`ruoyi-apt/.../job/**`、`dispatch/service/AptDutyStateServiceImpl`;库变更 `dev/sql/apt_rev01/28_job_automation.sql`。
## 0. 范围与结论
| 能力 | 触发方 | 自动化程度 | 人工介入 |
| --- | --- | --- | --- |
| 保洁工单 | 房间最后一名在寓人员登记离寓 | 自动建单(`source=AUTO`),无需管理员创建 | 保洁员在平板领取并执行 |
| 报修工单 | 房间平板「维修」按钮 | 一键建单,类别/优先级固定普通 | 维修工在平板领取并完成;后台可派单/取消 |
两条链路共用同一套状态流水 `apt_job_event` 与审计 `duty_operation_log`,做到「谁触发、谁执行、何时完成」全程可追溯。
## 1. 离寓判定逻辑
### 1.1 唯一口径(人员级)
以 `gy_jcxx` 业务事实判定,不使用任何缓存计数:
```
在寓人员 := gy_jcxx 中 deleted=0 AND finish_ly='0' AND sjlysj IS NULL 的记录
已离寓 := finish_ly='1'(同时 sjlysj 非空,由离寓命令写入)
```
- `finish_ly='1'` 仅由 `apt.duty.state` 唯一写入口(`MARK_LEAVE` 命令)产生,禁止其他模块直写。
- `FORCE_FINISH` 只置 `finish_jc=1`,**不**代表离寓,不触发保洁。
### 1.2 房间级判定
```
房间已全员离寓 := COUNT(在寓人员 WHERE fjh = 房间号) = 0
```
- 房号关联用 `gy_jcxx.fjh`(不是 `room_id`,历史数据只有房号)。
- 触发点每次只针对「被登记离寓人员所在房间」做一次判定,不做全库扫描。
- 计数查询走新增索引 `idx_jcxx_gyid_room_leave (gyid, fjh, finish_ly)`。
### 1.3 房间人数与房态同步
`gy_rooms.in_person_count` 不再靠 +1/-1 维护,离寓同事务内按上式**重算**:
```sql
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=? AND r.room_no=? AND r.deleted=0
```
房态按优先级重算:`维修中(4) > 保洁中(6) > 待清扫(3) > 住满(2)/未满(5)/空闲(1)`。
- 最后一人离寓:房态直接置 **3 待清扫**(不落回 1 空闲),避免「已离寓但仍显示可入住」的窗口;
- 仍有在寓人员:按人数重算为 2/5;
- 存在未完成维修单:房态保持 4,保洁任务仍可生成但房态不被覆盖。
### 1.4 边界
| 场景 | 处理 |
| --- | --- |
| 房号为空/房间不存在 | 跳过联动并记 WARN,不影响离寓提交 |
| 平台同步的叫乘记录(未本地入住占床) | 重算以 `gy_jcxx` 为准,天然自愈 |
| 离寓后又有人入住 | 自动建单提交后复查人数,>0 则不建单(`OCCUPIED`) |
| 同一人重复登记离寓 | 条件更新未命中 → 40902,不重复触发 |
## 2. 工单自动触发机制
### 2.1 时序
```
值班台提交 MARK_LEAVE
└─ 事务内:updateMarkLeave(finish_ly=1, sjlysj) → insert gygl_ly_event
→ 取 fjh → syncRoomOccupancy(重算人数 + 房态置 3)
└─ 事务提交后(afterCommit):autoCreateCleanOnVacated(REQUIRES_NEW)
→ 复查在寓人数 >0 ? 放弃
→ 已有 PENDING/PROCESSING 任务 ? 返回该任务(duplicated=true)
→ 建单:source=AUTO、state=PENDING、assignee 为空
→ 写流水(operator=SYSTEM)+ 审计 CLEAN_AUTO_CREATE
```
- **为什么在提交后触发**:新事务读不到未提交的离寓结果,若同事务调用会误判「仍有人在寓」;
同时保证建单失败不回滚离寓这一核心事实(失败仅 WARN)。
- **为什么房态要在事务内先置 3**:提交到建单之间存在毫秒级窗口,先置 3 可避免房间被当成空闲。
### 2.2 幂等与并发
| 手段 | 说明 |
| --- | --- |
| 服务层先判 | 按 `selectActiveCleanTaskByRoom`(state in PENDING/PROCESSING)复用已有任务 |
| 库层兜底 | `gy_clean_task.active_room` 生成列 + `uk_cleantask_gyid_active_room` 唯一索引:同房同时只允许一条活跃任务 |
| 冲突返回 | 命中唯一键 → 捕获 `DuplicateKeyException`,重读活跃任务并返回 `duplicated=true` |
| 平板并发上报 | `reportClean` 同样走唯一键兜底,不会产出两条待清扫 |
### 2.3 触发失败降级
自动建单异常只记 WARN(不重试、不回滚离寓);房态已为 3,管理员可在「作业工单」页或由保洁平板手动上报补单。
## 3. 平板端操作流程
### 3.1 保洁
```
[系统] 最后一人离寓 → 自动建单 PENDING(未分配)
↓
[平板] GET /open/v1/tablet/jobs?type=CLEAN
返回:本人任务 + 未分配公共池(行内 pool=true)
↓
[平板] POST /clean/{taskId}/start → PROCESSING,assignee=当前设备绑定人员(领取即认领)
↓
[平板] POST /clean/{taskId}/finish → DONE,房态重算 1/2/5
```
- 人员类型为 `CLEANER` 时接口只返回/只允许保洁类任务;`REPAIRMAN` 反之;`BOTH` 两类均可。
- 手动 `POST /clean`(上报待清扫)保留为兜底,`source=TABLET`,同样受唯一键约束。
### 3.2 报修
```
[平板] 点击「维修」→ POST /open/v1/tablet/jobs/repair/quick { "roomNo": "101" }
后端固定 category=OTHER、priority=NORMAL,描述缺省「普通维修(平板一键报修)」
↓ REPORTED(房态 → 4 不能入住,disabled_reason=REPAIR:<orderId>)
[平板] GET /open/v1/tablet/jobs?type=REPAIR → 本人任务 + 未分配公共池
↓
[平板] POST /repair/{orderId}/start → IN_PROGRESS(未派单时自动认领)
↓
[平板] POST /repair/{orderId}/complete → DONE(result 缺省「已修复(平板提交)」)
房态重算;已完成的工单不允许重复回写
```
后台仍可 `POST /apt/workorder/repair/{orderId}/assign` 派单/改派、`/cancel` 取消;平板侧不提供改状态为「已完成」以外的越权入口。
## 4. 状态流转规则
### 4.1 保洁(`gy_clean_task.state`)
| 当前态 | 事件 | 目标态 | 条件 / 副作用 |
| --- | --- | --- | --- |
| — | 全员离寓自动建单 | PENDING | 同房无活跃任务;`source=AUTO`;房态 3 |
| — | 平板上报 | PENDING | 同房无活跃任务;`source=TABLET`;房态 3 |
| PENDING | 开始(平板) | PROCESSING | 条件更新 `state='PENDING'`;写 `assignee_staff_id`、`started_at`;房态 6 |
| PROCESSING | 结束(平板) | DONE | 条件更新 `state='PROCESSING'`;写 `finished_at`;房态重算 |
| PENDING/PROCESSING | 取消 | CANCELLED | 表结构允许,本期未开放接口(保留) |
### 4.2 维修(`apt_repair_order.state`)
| 当前态 | 事件 | 目标态 | 条件 / 副作用 |
| --- | --- | --- | --- |
| — | 一键报修 / 平板报修 | REPORTED | 同房无未完成工单;房态 4 + `REPAIR:<orderId>` |
| REPORTED | 后台派单 | ASSIGNED | 人员须 ACTIVE 且非 CLEANER |
| ASSIGNED | 后台改派 | ASSIGNED | 同上,刷新 `assigned_at` |
| REPORTED / ASSIGNED | 平板开始 | IN_PROGRESS | 条件更新;未派单自动认领;写 `started_at` |
| IN_PROGRESS | 平板完成 | DONE | 必填/缺省 `result`;写 `finished_at`、`duration_sec`;房态重算 |
| REPORTED / ASSIGNED / IN_PROGRESS | 后台取消 | CANCELLED | 写 `remark`;房态重算 |
### 4.3 通用约束
1. 所有迁移均为「条件更新 + 影响行数」,0 行即冲突:重复提交按幂等返回 `duplicated=true`,非法迁移返回 40902。
2. 平板执行人由 `X-Device-Code` 反查 `apt_staff`(仅 `state=ACTIVE`),请求体不接受 `staffId`/`gyid`。
3. 后台不提供「开始/完成」入口,状态推进只能由作业人员在平板完成。
4. 房态由服务层统一条件更新并递增 `state_version`,冲突重读一次后仍失败返回 40903。
## 5. 接口清单
### 5.1 平板(`/open/v1/tablet/jobs`,设备四要素鉴权)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/` | 我的作业 + 公共池(`type`/`state`/`limit`,行内 `pool` 标识未分配) |
| POST | `/clean` | 手动上报待清扫(兜底,`source=TABLET`) |
| POST | `/clean/{taskId}/start` | 开始/领取保洁 |
| POST | `/clean/{taskId}/finish` | 结束保洁 |
| POST | `/repair` | 标准报修(可带类别/优先级/描述) |
| POST | `/repair/quick` | **一键报修**(仅房号,普通维修) |
| POST | `/repair/{orderId}/start` | 开始维修(未派单自动认领) |
| POST | `/repair/{orderId}/finish` | 完成维修(`result` 必填) |
| POST | `/repair/{orderId}/complete` | **一键完成**(`result` 可省略) |
### 5.2 后台(`/apt/workorder`、`/apt/staff`)
| 方法 | 路径 | 权限点 |
| --- | --- | --- |
| GET | `/apt/workorder/list` `/stats` `/{type}/{jobId}` | `apt:workorder:list` / `stats` / `query` |
| POST | `/apt/workorder/repair/{orderId}/assign` | `apt:workorder:assign` |
| POST | `/apt/workorder/repair/{orderId}/cancel` | `apt:workorder:handle` |
| CRUD | `/apt/staff/**` | `apt:staff:*` |
## 6. 可追溯性
| 载体 | 记录内容 |
| --- | --- |
| `apt_job_event` | 每次状态迁移:`from_state → to_state`、操作人(自动建单为 `SYSTEM`)、设备编码、备注(含 `LEAVE:<jcid>`) |
| `gy_clean_task.source` | `AUTO` / `TABLET` / `MANUAL`,可直接区分自动生成与人工上报 |
| `duty_operation_log` | 自动建单审计 `module=JOB, action=CLEAN_AUTO_CREATE`,detail 含房号、任务号、触发来源、在寓人数、traceId |
| `duty_manual_action` | 离寓命令的幂等键与结果 |
| `gygl_ly_event` | 离寓事实(`leave_type`) |
## 7. 已知边界与后续项
1. **退宿/换房链路尚未落地**:当前离寓入口只有值班台 `MARK_LEAVE`;后续退宿接口必须复用 `apt.duty.state` 与本文 §1/§2 的联动,禁止另写一套房态/建单逻辑。
2. **保洁取消无接口**:表结构支持 `CANCELLED`,如需「入住前取消待清扫」,应新增后台接口并写流水。
3. **待清扫房间仍可办理入住**:沿用既有房态规则(仅 4/6 阻断入住),因此「离寓→入住」窗口内可能不产生实际保洁;如需强制,应将 3 纳入入住阻断条件(需业务确认)。
4. **并发建单**:唯一索引已兜底,极端情况下可能出现「任务已建、人又入住」的短暂脏数据,由保洁结束后房态重算自愈。
5. **叫乘修正改房号不触发联动**:`correct` 允许未离寓记录修改 `fjh`,会改变房间归属但不会重算两端房态/保洁单;如需严格一致,应在修正后调用同一 `syncRoomOccupancy`,列入后续迭代。
@@ -0,0 +1,190 @@
# 20 第六轮开发(IT-6)交付说明:上报续传 + 人员 / 设备
> 迭代来源:`13-公寓端小步迭代开发计划.md` 表 13-16 `IT-6`、表 13-6 验收映射
> 里程碑:`M4`(叫乘人员管理)/ `M5`(设备管理)/ `D-1`~`D-3`(应用端最小集)
> 交付日期:2026-11-12
> 交付状态:**部分交付(后端链路已通,值班台入口与演练未完成)**,未就绪项见 §6
## 1. 范围与边界
### 1.1 本期已交付
| 项 | 内容 |
| --- | --- |
| 上报 outbox | 查询、状态汇总、失败重试(FAILED/DEAD → PENDING,沿用原 `idempotency_key`) |
| 水位 | `apt_report_watermark` 读写、`acked/current/lag` 对账视图、平台回执推进 |
| #14 心跳 | 设备心跳更新在线态、`last_heartbeat_at`、`clock_skew_ms`,>5s 标记超限 |
| 断网续报(#9/#8/#13) | `POST /open/v1/report/resume`:按原幂等键入队,`DUPLICATE` 视为成功 |
| M4 人员 | 列表 / 详情 / 关联 Tab(当前在住 + 最近 5 条叫乘 + 最近交路) |
| M4 只读 | `PUT /apt/person/{ryid}` 无条件 `40904 SNAPSHOT_READONLY` |
| M5 设备 | 档案 / 在线态 / 指令记录查询;指令只入队 `PENDING`、`protocol_confirmed=0` |
| M5 心跳 | `POST /open/v1/device/heartbeat`(设备四要素鉴权) |
| 配置 | `apt.report.*` 默认值与环境变量映射;`dispatch-enabled` 默认 `false` |
### 1.2 明确不做(沿用计划口径)
1. **#10 `LEDGER_OFFLINE`**:P2,本期不建账务接口 / 表 / 权限点。
2. **不新增业务表**:outbox / watermark / device_command 三表沿用 `IT-0` 基线 DDL,只加索引。
3. **不伪造设备 ACK**:现场协议未确认(Q-12),指令不置 `SENT/ACKED`。
4. **管理端不改队列态**:outbox 的 `SENT/DEAD` 只能由派发器与重试链路推进(CR-C10)。
## 2. 交付清单
### 2.1 后端(`dev/RuoYi-Vue-master/ruoyi-apt`)
| 文件 | 说明 |
| --- | --- |
| `report/domain/AptOutboxQuery.java` | outbox 过滤条件(bizType/bizId/state/时间/limit) |
| `report/domain/AptResumeEnvelope.java` | 续报信封(复用原 idempotencyKey) |
| `report/mapper/AptReportMapper.java` + `mapper/apt/AptReportMapper.xml` | outbox 列表/汇总/重试、水位 upsert/ack |
| `report/service/IAptReportService.java`、`AptReportServiceImpl.java` | 续报、重试白名单、水位视图、心跳 |
| `report/controller/AptReportController.java` | `/apt/report/**`(列表/统计/水位/重试/回执) |
| `report/controller/AptReportOpenController.java` | `/open/v1/report/resume` |
| `master/mapper/AptPersonMapper.java` + `mapper/apt/AptPersonMapper.xml` | 人员列表/详情/在住/最近叫乘/最近交路 |
| `master/service/IAptPersonService.java`、`AptPersonServiceImpl.java` | M4 读侧 + 快照只读拒绝 |
| `master/controller/AptPersonController.java` | `/apt/person/**`(含 `PUT` → 40904) |
| `device/domain/AptDeviceCommandRequest.java`、`AptDeviceHeartbeat.java` | 入队请求 / #14 心跳体 |
| `device/mapper/AptDeviceMapper.java` + `mapper/apt/AptDeviceMapper.xml` | `gy_sb` / `gy_sb_xt` / `apt_device_command` |
| `device/service/IAptDeviceService.java`、`AptDeviceServiceImpl.java` | 心跳、指令入队、重复命令幂等 |
| `device/controller/AptDeviceController.java` | `/apt/device/**` |
| `device/controller/AptDeviceOpenController.java` | `/open/v1/device/heartbeat` |
| `framework/config/AptProperties.java` | 新增 `report` 配置段 |
| `resources/apt-defaults.yml` | `APT_REPORT_*` 环境变量映射(`dispatch-enabled=false`) |
### 2.2 SQL(`dev/sql/apt_rev01`,已执行)
| 文件 | 说明 |
| --- | --- |
| `29_it6_permissions.sql` | 9 个 F 权限点(3250~3258)、超管绑定、2 个查询索引 |
执行校验(`29` 第 57~60 段):
| 段 | 期望 | 实测 |
| --- | --- | --- |
| 57 | 9 个权限点 | 9 |
| 58 | 超管绑定 9 | 9 |
| 59 | 2 个索引 | `idx_outbox_gyid_occurred`、`idx_devcmd_gyid_device_state` |
| 60 | 三表可读 | outbox 0 / watermark 1 / device_command 0 |
### 2.3 测试
| 文件 | 用例 |
| --- | --- |
| `test/.../report/AptReportServiceTest.java` | 6 例:重试白名单、水位差、心跳偏差、续报幂等 |
| `test/.../device/AptDeviceServiceTest.java` | 3 例:只入队 PENDING、停用拒绝、重复命令不重插 |
| `test/.../master/AptPersonServiceTest.java` | 3 例:关联组合、40904、404 |
全量回归:`mvn -o -pl ruoyi-apt -am test` → **246 passed / 0 failed**(本轮新增 12 例)。
## 3. 接口说明
### 3.1 管理端
| 接口 | 权限点 | 行为 |
| --- | --- | --- |
| `GET /apt/report/outbox` | `apt:report:list` | 队列列表,默认 100 / 上限 500,按 id 倒序 |
| `GET /apt/report/stats` | `apt:report:query` | `outbox{total,pending,sending,sent,failed,dead,maxWatermark}` + `watermark{}` |
| `GET /apt/report/watermark` | `apt:report:query` | `current/acked/lag/lastHeartbeatAt/clockSkewMs/阈值` |
| `POST /apt/report/outbox/{id}/requeue` | `apt:report:retry` | 仅 `FAILED/DEAD`;生成 `retryBatchId`,重试次数清零 |
| `POST /apt/report/watermark/ack/{acked}` | `apt:report:retry` | 只增不减,联调/派发器回执 |
| `GET /apt/person/list` | `apt:person:list` | keyword / roomNo / state,带当前在住房间 |
| `GET /apt/person/{ryid}` | `apt:person:query` | 人员详情 |
| `GET /apt/person/{ryid}/associations` | `apt:person:query` | 关联 Tab 数据 |
| `PUT /apt/person/{ryid}` | `apt:person:edit` | 固定 `40904`,审计 `PERSON_UPDATE_REJECTED` |
| `GET /apt/device/list` | `apt:device:list` | `gy_sb` + `gy_sb_xt` 在线态 |
| `GET /apt/device/{deviceCode}` | `apt:device:query` | 设备详情 |
| `GET /apt/device/{deviceCode}/commands` | `apt:device:query` | 指令记录 |
| `POST /apt/device/{deviceCode}/commands` | `apt:device:enqueue` | 入队 `PENDING`,停用设备 `40940` |
### 3.2 设备侧(`/open/v1/**`,设备四要素鉴权)
| 接口 | 行为 |
| --- | --- |
| `POST /open/v1/report/resume` | 批量续报,逐条 `ACCEPTED/DUPLICATE/REJECTED`;`DUPLICATE` 视为成功 |
| `POST /open/v1/device/heartbeat` | 刷新 `gy_sb_xt`、`last_seen_at`、水位与时钟偏差 |
续报响应示例:
```json
{
"code": 200,
"data": {
"accepted": 1,
"duplicated": 1,
"rejected": 0,
"items": [
{ "idempotencyKey": "KEY-1", "bizType": "CHECKIN", "bizId": "BIZ-1", "status": "DUPLICATE" }
]
}
}
```
## 4. 关键实现要点
### 4.1 队列与终态流水不互换
- `apt_report_outbox.state` 只表达本地队列态(`PENDING/SENDING/SENT/FAILED/DEAD`),
业务终态在 `apt_report_history`;本模块**不写 history**,也不按队列态推断业务事实。
- 重试复用原 `idempotency_key`,平台侧按 `PL-IDEMPOTENT-REPLAY` 视为成功,不产生新事实。
### 4.2 对账三维
| 维度 | 来源 |
| --- | --- |
| `count` 差异 | `stats.outbox` 各态计数 |
| `acked_watermark = current_watermark` | `watermark.lag = max(0, current-acked)` |
| `max(watermark)` 追平 | `stats.outbox.maxWatermark` |
### 4.3 心跳与偏差
- 心跳只写 `gy_sb_xt.online_state/heartbeat_at/app_version` 与 `gy_sb.last_seen_at`,
**不触碰指令态**;
- 水位取 `GREATEST(已有, 上报值)`,避免设备重启回退;
- 偏差阈值取 `apt.report.clock-skew-threshold-ms`(默认 5000ms),超限仅在响应中标记,
不自动改业务数据。
### 4.4 M4 快照只读
- 人员字段来自平台快照(`source_version`),本地无写入口;
- `PUT` 不接受任何可改字段,直接 `40904`,避免"部分可改"造成双写;
- 关联 Tab 数据实时取自 `gy_jcxx`(在住 = `finish_ly='0' AND sjlysj IS NULL`)。
### 4.5 M5 只入队
- 入队固定 `state='PENDING'`、`protocol_confirmed=0`;
- 重复 `commandId` 返回既有指令,`duplicated=true` 且状态如实返回(不强制改写);
- 停用设备拒绝入队(`40940 DEVICE_DISABLED`)。
## 5. 验证记录
| 项 | 方式 | 结果 |
| --- | --- | --- |
| 编译 | `mvn -o -pl ruoyi-apt -am -DskipTests compile` | BUILD SUCCESS |
| 单测 | `mvn -o -pl ruoyi-apt -am test` | 246 passed |
| SQL | `29_it6_permissions.sql` 第 57~60 段 | 全部符合期望 |
| 接口契约 | Controller 路径 / 权限点 / 错误码静态核对 | 与 §3 一致 |
尚未执行:断网 2h 演练、恢复 10min 追平、20 接口请求样例 → 落库断言基线(见 §6)。
## 6. 未就绪内容(必须记录的缺口)
| # | 未就绪项 | 现状 | 阻塞 / 依赖 | 建议排期 |
| --- | --- | --- | --- | --- |
| 1 | **`duty` 前端入口(D-1~D-3)** | 未创建 `src/entries/duty/**`,值班台仍用旧页 `JcXx.vue` | 前端排期;`IR-03` 要求不魔改全局动态路由 | IT-6 后半段 |
| 2 | **M4/M5 前端页面** | 仅后端接口,无人员关联 Tab / 设备指令页 | 同上 | IT-6 后半段 |
| 3 | **平台派发客户端 + 自动重试调度** | `dispatch-enabled=false`,outbox 只累积;无 Quartz 派发任务、无退避与 `DEAD` 判定 | Q-13 联调环境与凭据 | IT-6 后半段 / IT-8 |
| 4 | **#9 影子对账** | 未开启"只写不生效",未做连续 3 天零差异比对 | 依赖 #3 派发链路 | IT-8 收口 |
| 5 | **断网演练取证** | 未执行 2h 断网 / 恢复追平实测 | 需要完整环境与前端入口 | IT-8 |
| 6 | **20 接口回归基线** | 仅新增模块 12 例单测,未形成"请求样例 → 落库断言"全量基线 | 依赖联调环境 | IT-6 后半段 |
| 7 | **M5 指令下发 / ACK** | 协议未确认(Q-12),指令停留 `PENDING`,无 `SENT/ACKED` 通道 | 现场厂家协议 | IT-7 |
| 8 | **设备离线判定任务** | 心跳只写在线态,未实现"超时置 OFFLINE"的定时扫描 | 无 | IT-6 后半段 |
| 9 | **值班台看板(outbox/水位)** | 无界面,仅接口 | 依赖 #1 入口 | IT-6 后半段 |
| 10 | **Q-21/Q-22/Q-23/Q-25 签收** | 计划标注为 IT-6 依赖,交付时未见签收记录 | 业务/架构 | 试点前 |
## 7. 风险与后续
1. **Mock 口径**:`APT_REPORT_DISPATCH_ENABLED=false` 时 outbox 只累积,验收"恢复 ≤10min 清空"必须在开启派发的联调环境执行,不能用 Mock 数据代替。
2. **续报批量上限**:单次 200 条,超限直接 `40001`;平板侧需分批推送,避免一次提交被拒。
3. **人员关联性能**:列表用 3 个相关子查询取当前在住/交路/车号,人员量 >5k 时需改为窗口函数或冗余列(已建 `idx_jcxx_gyid_room_leave`,后续按实际数据量评估)。
4. **设备编码大小写**:统一按大写存储与匹配;设备端必须以大写 `X-Device-Code` 上报,否则 `40401`。
5. **与既有作业模块的边界**:`/apt/workorder`、`/apt/staff`(保洁/维修)不在 IT-6 范围内,其自动建单与平板链路不受本次改动影响。