feat: CW-4 线索转办模块完善 — 新增6个接口覆盖完整业务闭环

- 待转办线索列表查询(pending-clues/query)
- 待转办线索详情(pending-clues/detail)
- 转办操作日志查询(operation-logs)
- 转办状态流转:transferred→processing→completed/failed,双表同步线索状态
- 转办催办(urge)
- 转办撤回(withdraw),线索状态回退允许重新转办
- 非法状态流转校验拦截

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chenjy 2026-05-18 16:05:40 +08:00
parent 1292d8769e
commit 687eb9a916
10 changed files with 435 additions and 0 deletions

View File

@ -3,8 +3,15 @@ package com.chinaweal.youfool.prj.modules.evidence.transfer.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.chinaweal.youfool.framework.springboot.rest.RestResult;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.query.ClueTransferQuery;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.query.PendingClueQuery;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.req.ClueTransferReq;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.req.TransferStatusUpdateReq;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.req.TransferUrgeReq;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.req.TransferWithdrawReq;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.vo.ClueTransferDetailVO;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.vo.OperationLogVO;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.vo.PendingClueDetailVO;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.vo.PendingClueVO;
import com.chinaweal.youfool.prj.modules.evidence.transfer.service.IClueTransferService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -92,4 +99,40 @@ public class ClueTransferController {
public RestResult<Map<String, Object>> disposalFeedback(String clueId) {
return clueTransferService.disposalFeedback(clueId);
}
@PostMapping("pending-clues/query")
@Operation(summary = "分页查询待转办线索列表")
public RestResult<Page<PendingClueVO>> queryPendingClues(PendingClueQuery query) {
return clueTransferService.queryPendingClues(query);
}
@GetMapping("pending-clues/detail")
@Operation(summary = "获取待转办线索详情")
public RestResult<PendingClueDetailVO> getPendingClueDetail(String clueId) {
return clueTransferService.getPendingClueDetail(clueId);
}
@GetMapping("operation-logs")
@Operation(summary = "获取转办操作日志")
public RestResult<List<OperationLogVO>> getOperationLogs(String transferRecordId) {
return clueTransferService.getOperationLogs(transferRecordId);
}
@PostMapping("status-update")
@Operation(summary = "更新转办状态(外部系统回调)")
public RestResult<?> updateTransferStatus(TransferStatusUpdateReq req) {
return clueTransferService.updateTransferStatus(req);
}
@PostMapping("urge")
@Operation(summary = "转办催办")
public RestResult<?> urgeTransfer(TransferUrgeReq req) {
return clueTransferService.urgeTransfer(req);
}
@PostMapping("withdraw")
@Operation(summary = "转办撤回")
public RestResult<?> withdrawTransfer(TransferWithdrawReq req) {
return clueTransferService.withdrawTransfer(req);
}
}

View File

@ -0,0 +1,25 @@
package com.chinaweal.youfool.prj.modules.evidence.transfer.entity.query;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 待转办线索查询条件
*
* @author chenjy
* @since 2026/05/18
*/
@Data
@Accessors(chain = true)
public class PendingClueQuery {
private Integer pageNum = 1;
private Integer pageSize = 10;
private String clueCode;
private String district;
private String keyword;
}

View File

@ -0,0 +1,25 @@
package com.chinaweal.youfool.prj.modules.evidence.transfer.entity.req;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 转办状态更新请求
*
* @author chenjy
* @since 2026/05/18
*/
@Data
@Accessors(chain = true)
public class TransferStatusUpdateReq {
private String transferRecordId;
private String newStatus;
private String externalClueId;
private String disposalResult;
private String remark;
}

View File

@ -0,0 +1,19 @@
package com.chinaweal.youfool.prj.modules.evidence.transfer.entity.req;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 转办催办请求
*
* @author chenjy
* @since 2026/05/18
*/
@Data
@Accessors(chain = true)
public class TransferUrgeReq {
private String transferRecordId;
private String urgeMessage;
}

View File

@ -0,0 +1,19 @@
package com.chinaweal.youfool.prj.modules.evidence.transfer.entity.req;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 转办撤回请求
*
* @author chenjy
* @since 2026/05/18
*/
@Data
@Accessors(chain = true)
public class TransferWithdrawReq {
private String transferRecordId;
private String withdrawReason;
}

View File

@ -0,0 +1,32 @@
package com.chinaweal.youfool.prj.modules.evidence.transfer.entity.vo;
import com.chinaweal.youfool.framework.springboot.common.util.DateUtil;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 转办操作日志 VO
*
* @author chenjy
* @since 2026/05/18
*/
@Data
public class OperationLogVO {
private String id;
private String operationType;
private String operationDetail;
private String fromStatus;
private String toStatus;
private String operator;
@JsonFormat(pattern = DateUtil.DATETIME_DEFAULT_FORMAT, timezone = "GMT+8")
private LocalDateTime operatedAt;
}

View File

@ -0,0 +1,37 @@
package com.chinaweal.youfool.prj.modules.evidence.transfer.entity.vo;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 待转办线索详情 VO
*
* @author chenjy
* @since 2026/05/18
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class PendingClueDetailVO extends PendingClueVO {
private String evidenceId;
private String videoEvidencePath;
private String clipStartTime;
private String clipEndTime;
private Integer clipDuration;
private String operatorUnit;
private String operatorContact;
private String ownerContact;
private String generatedBy;
private Boolean hasTransferRecord;
private String existingTransferId;
}

View File

@ -0,0 +1,42 @@
package com.chinaweal.youfool.prj.modules.evidence.transfer.entity.vo;
import com.chinaweal.youfool.framework.springboot.common.util.DateUtil;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 待转办线索列表项 VO
*
* @author chenjy
* @since 2026/05/18
*/
@Data
public class PendingClueVO {
private String id;
private String clueCode;
private String screenId;
private String screenName;
private String screenAddress;
private String district;
private String ownerUnit;
private String advertiser;
private String relatedRules;
private String relatedLawClauses;
private Integer clueStatus;
@JsonFormat(pattern = DateUtil.DATETIME_DEFAULT_FORMAT, timezone = "GMT+8")
private LocalDateTime generatedAt;
}

View File

@ -3,8 +3,15 @@ package com.chinaweal.youfool.prj.modules.evidence.transfer.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.chinaweal.youfool.framework.springboot.rest.RestResult;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.query.ClueTransferQuery;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.query.PendingClueQuery;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.req.ClueTransferReq;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.req.TransferStatusUpdateReq;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.req.TransferUrgeReq;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.req.TransferWithdrawReq;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.vo.ClueTransferDetailVO;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.vo.OperationLogVO;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.vo.PendingClueDetailVO;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.vo.PendingClueVO;
import java.util.List;
import java.util.Map;
@ -72,4 +79,16 @@ public interface IClueTransferService {
* @return 处置反馈
*/
RestResult<Map<String, Object>> disposalFeedback(String clueId);
RestResult<Page<PendingClueVO>> queryPendingClues(PendingClueQuery query);
RestResult<PendingClueDetailVO> getPendingClueDetail(String clueId);
RestResult<List<OperationLogVO>> getOperationLogs(String transferRecordId);
RestResult<?> updateTransferStatus(TransferStatusUpdateReq req);
RestResult<?> urgeTransfer(TransferUrgeReq req);
RestResult<?> withdrawTransfer(TransferWithdrawReq req);
}

View File

@ -13,8 +13,15 @@ import com.chinaweal.youfool.prj.modules.evidence.clue.mapper.MonitoringClueMapp
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.ClueTransferRecordEntity;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.TransferOperationLogEntity;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.query.ClueTransferQuery;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.query.PendingClueQuery;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.req.ClueTransferReq;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.req.TransferStatusUpdateReq;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.req.TransferUrgeReq;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.req.TransferWithdrawReq;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.vo.ClueTransferDetailVO;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.vo.OperationLogVO;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.vo.PendingClueDetailVO;
import com.chinaweal.youfool.prj.modules.evidence.transfer.entity.vo.PendingClueVO;
import com.chinaweal.youfool.prj.modules.evidence.transfer.mapper.ClueTransferRecordMapper;
import com.chinaweal.youfool.prj.modules.evidence.transfer.mapper.TransferOperationLogMapper;
import com.chinaweal.youfool.prj.modules.evidence.transfer.service.IClueTransferService;
@ -247,6 +254,166 @@ public class ClueTransferServiceImpl extends ServiceImpl<ClueTransferRecordMappe
return RestResult.ok(result);
}
@Override
public RestResult<Page<PendingClueVO>> queryPendingClues(PendingClueQuery query) {
Page<MonitoringClueEntity> page = new Page<>(query.getPageNum(), query.getPageSize());
LambdaQueryWrapper<MonitoringClueEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(MonitoringClueEntity::getClueStatus, 1);
if (StringUtils.isNotBlank(query.getClueCode())) {
wrapper.like(MonitoringClueEntity::getClueCode, query.getClueCode());
}
if (StringUtils.isNotBlank(query.getDistrict())) {
wrapper.eq(MonitoringClueEntity::getDistrict, query.getDistrict());
}
if (StringUtils.isNotBlank(query.getKeyword())) {
wrapper.and(w -> w
.like(MonitoringClueEntity::getScreenName, query.getKeyword())
.or()
.like(MonitoringClueEntity::getScreenAddress, query.getKeyword())
);
}
wrapper.orderByDesc(MonitoringClueEntity::getGeneratedAt);
Page<MonitoringClueEntity> entityPage = monitoringClueMapper.selectPage(page, wrapper);
Page<PendingClueVO> voPage = new Page<>(entityPage.getCurrent(),
entityPage.getSize(), entityPage.getTotal());
List<PendingClueVO> voList = entityPage.getRecords().stream().map(entity -> {
PendingClueVO vo = new PendingClueVO();
BeanUtils.copyProperties(entity, vo);
return vo;
}).collect(Collectors.toList());
voPage.setRecords(voList);
return RestResult.ok(voPage);
}
@Override
public RestResult<PendingClueDetailVO> getPendingClueDetail(String clueId) {
AssertUtils.isNotBlank(clueId);
MonitoringClueEntity entity = monitoringClueMapper.selectById(clueId);
AssertUtils.isNotNull(entity, "线索不存在");
AssertUtils.isTrue(entity.getClueStatus() == 1,
BaseResultCode.PARAM_IS_INVALID, "当前线索状态非待转办");
PendingClueDetailVO vo = new PendingClueDetailVO();
BeanUtils.copyProperties(entity, vo);
// 检查是否已有转办记录
LambdaQueryWrapper<ClueTransferRecordEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ClueTransferRecordEntity::getClueId, clueId);
wrapper.orderByDesc(ClueTransferRecordEntity::getTransferredAt);
wrapper.last("LIMIT 1");
List<ClueTransferRecordEntity> existing = this.list(wrapper);
vo.setHasTransferRecord(!existing.isEmpty());
if (!existing.isEmpty()) {
vo.setExistingTransferId(existing.get(0).getId());
}
return RestResult.ok(vo);
}
@Override
public RestResult<List<OperationLogVO>> getOperationLogs(String transferRecordId) {
AssertUtils.isNotBlank(transferRecordId);
LambdaQueryWrapper<TransferOperationLogEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(TransferOperationLogEntity::getClueTransferRecordId, transferRecordId);
wrapper.orderByAsc(TransferOperationLogEntity::getOperatedAt);
List<TransferOperationLogEntity> logs = transferOperationLogMapper.selectList(wrapper);
List<OperationLogVO> voList = logs.stream().map(logEntity -> {
OperationLogVO vo = new OperationLogVO();
BeanUtils.copyProperties(logEntity, vo);
return vo;
}).collect(Collectors.toList());
return RestResult.ok(voList);
}
@Override
@DSTransactional
public RestResult<?> updateTransferStatus(TransferStatusUpdateReq req) {
AssertUtils.isNotBlank(req.getTransferRecordId());
AssertUtils.isNotBlank(req.getNewStatus());
ClueTransferRecordEntity record = this.getById(req.getTransferRecordId());
AssertUtils.isNotNull(record, "转办记录不存在");
String oldStatus = record.getTransferStatus();
validateStatusTransition(oldStatus, req.getNewStatus());
// 更新转办记录
record.setTransferStatus(req.getNewStatus());
if (StringUtils.isNotBlank(req.getExternalClueId())) {
record.setExternalClueId(req.getExternalClueId());
}
if ("completed".equals(req.getNewStatus())) {
AssertUtils.isTrue(StringUtils.isNotBlank(req.getDisposalResult()),
BaseResultCode.PARAM_IS_INVALID, "处置结果不能为空");
record.setDisposalResult(req.getDisposalResult());
record.setDisposalCompletedAt(LocalDateTime.now());
}
this.updateById(record);
// 同步线索状态
MonitoringClueEntity clue = monitoringClueMapper.selectById(record.getClueId());
if (clue != null) {
if ("processing".equals(req.getNewStatus())) {
clue.setClueStatus(3);
} else if ("completed".equals(req.getNewStatus())) {
clue.setClueStatus(4);
} else if ("failed".equals(req.getNewStatus())) {
clue.setClueStatus(1);
}
monitoringClueMapper.updateById(clue);
}
// 记录操作日志
saveOperationLog(record.getId(), "status_update",
"状态从 " + oldStatus + " 变更为 " + req.getNewStatus(),
oldStatus, req.getNewStatus(), "外部系统回调");
return RestResult.ok();
}
@Override
@DSTransactional
public RestResult<?> urgeTransfer(TransferUrgeReq req) {
AssertUtils.isNotBlank(req.getTransferRecordId());
ClueTransferRecordEntity record = this.getById(req.getTransferRecordId());
AssertUtils.isNotNull(record, "转办记录不存在");
AssertUtils.isTrue("transferred".equals(record.getTransferStatus())
|| "processing".equals(record.getTransferStatus()),
BaseResultCode.PARAM_IS_INVALID, "当前状态不允许催办");
// TODO: 对接消息系统发送催办通知
saveOperationLog(record.getId(), "urge",
"催办:" + (req.getUrgeMessage() != null ? req.getUrgeMessage() : "请尽快处理"),
record.getTransferStatus(), record.getTransferStatus(), "系统管理员");
return RestResult.ok();
}
@Override
@DSTransactional
public RestResult<?> withdrawTransfer(TransferWithdrawReq req) {
AssertUtils.isNotBlank(req.getTransferRecordId());
ClueTransferRecordEntity record = this.getById(req.getTransferRecordId());
AssertUtils.isNotNull(record, "转办记录不存在");
AssertUtils.isTrue("transferred".equals(record.getTransferStatus()),
BaseResultCode.PARAM_IS_INVALID, "只有已转办状态才能撤回");
String oldStatus = record.getTransferStatus();
record.setTransferStatus("failed");
this.updateById(record);
// 回退线索状态到待转办
MonitoringClueEntity clue = monitoringClueMapper.selectById(record.getClueId());
if (clue != null) {
clue.setClueStatus(1);
clue.setTransferredAt(null);
monitoringClueMapper.updateById(clue);
}
saveOperationLog(record.getId(), "withdraw",
"撤回转办:" + (req.getWithdrawReason() != null ? req.getWithdrawReason() : ""),
oldStatus, "failed", "系统管理员");
return RestResult.ok();
}
// =========================================================================
// 私有方法
// =========================================================================
@ -267,4 +434,11 @@ public class ClueTransferServiceImpl extends ServiceImpl<ClueTransferRecordMappe
logEntity.setOperatedAt(LocalDateTime.now());
transferOperationLogMapper.insert(logEntity);
}
private void validateStatusTransition(String from, String to) {
boolean valid = ("transferred".equals(from) && ("processing".equals(to) || "failed".equals(to)))
|| ("processing".equals(from) && "completed".equals(to));
AssertUtils.isTrue(valid, BaseResultCode.PARAM_IS_INVALID,
"不允许的状态流转:" + from + " -> " + to);
}
}