feat: AM-1/AM-2/AM-3 PRD业务逻辑差异补齐

- AM-1 录屏设置:save/update 添加录屏时间范围校验(必须在广告播放时间内)
- AM-2 随机录屏:新增 district/screenName 关联查询 + 查询时间范围不超过90天限制
- AM-3 广告监控:新增时间范围/监控人员/大屏名称筛选 + 默认过滤已归档记录
- AM-3 违规判定自动触发 CW-1 取证记录创建

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chenjy 2026-05-20 15:45:13 +08:00
parent 687eb9a916
commit 5dff84255c
7 changed files with 292 additions and 12 deletions

View File

@ -159,17 +159,84 @@
#### 缺失接口汇总
| 类别 | 缺失接口数 | 涉及模块 |
|------|-----------|---------|
| 导入/导出/模板下载 | 3 | BS-1, MR-1 |
| 唯一性校验 | 2 | BS-1, LB-1 |
| 状态变更/废止 | 2 | BS-1, AM-1, LB-1 |
| 历史版本 | 1 | BS-1 |
| 文件下载/播放 | 2 | CW-1 |
| 级联选择(区域/部门/人员) | 3 | CW-4 |
| 线索预览/统计 | 2 | CW-3 |
| 处置反馈 | 1 | CW-4 |
| **合计** | **~16** | |
> 注:下表中标记 ❌ 的接口已在 2026-05-18 的"缺失接口补齐"和"CW-4 完善"中全部实现。此表保留作为历史对照。
| 类别 | 原缺失数 | 当前状态 |
|------|---------|---------|
| 导入/导出/模板下载 | 3 | ✅ 已补齐 |
| 唯一性校验 | 2 | ✅ 已补齐 |
| 状态变更/废止 | 2 | ✅ 已补齐 |
| 历史版本 | 1 | ✅ 已补齐 |
| 文件下载/播放 | 2 | ✅ 已补齐 |
| 级联选择 | 3 | ✅ 已补齐 |
| 线索预览/统计 | 2 | ✅ 已补齐 |
| 处置反馈 | 1 | ✅ 已补齐 |
| CW-4 业务闭环 | 6 | ✅ 已补齐pending-clues/status-update/urge/withdraw/operation-logs |
---
### PRD vs 后端代码详细差异分析2026-05-18 更新)
> 基础 CRUD 和 P0-P3 补齐接口均已实现并通过端到端测试。以下仅列出**仍然存在的差异**。
#### 一、风格差异(不影响功能)
| 项目 | PRD 要求 | 后端实现 | 影响 |
|------|---------|---------|------|
| 路径命名 | 复数名词 `/api/screens` | 单数名词 `/api/screen` | 不影响 |
| HTTP 方法 | RESTfulPUT/DELETE | 统一 POST | 不影响 |
| 状态枚举值 | 字符串 "RECORDING"/"COMPLETED" | 数字 1/2/3 | 不影响 |
| 分页默认大小 | 20 条/页 | 10 条/页 | 不影响 |
| API 前缀 | 部分 PRD 要求 `/api/cw/` | 无 `/cw/` 前缀 | 不影响 |
#### 二、缺失的业务逻辑(需补充)
##### AM-1 录屏设置
- **唯一性校验**PRD 要求同一大屏只能有一条配置,后端未在 save/update 中校验
- **时间范围校验**PRD 要求录屏时间必须在广告播放时间段内,后端未实现
##### AM-2 随机录屏
- **查询条件缺失**:缺少 `district`(区域)和 `screenName`(大屏名称模糊匹配)筛选条件
- **时间范围限制**PRD 要求最大查询范围不超过 90 天,后端未限制
##### AM-3 广告画面监控
- **查询条件缺失**缺少时间范围start_time/end_time、监控人员monitor_person筛选
- **默认状态过滤**PRD 要求默认只查询未归档记录,后端未实现
- **并发控制**PRD 要求同一记录只能由一人监控,后端未实现
- **自动联动**PRD 要求判定为违法时自动触发 CW-1 取证流程,后端未实现
##### CW-1 固化取证
- **状态历史独立接口**PRD 要求 `GET /evidence-records/{id}/status-history`,当前详情接口已含但无独立入口
- **关联上下文接口**PRD 要求 `GET /monitor-records/{id}/evidence-context`(来源监控记录关联信息),后端未实现
##### CW-2 规则关联
- **搜索可关联规则**PRD 要求搜索可关联的监测规则列表,当前只能在 MR-1 的 enabled 接口获取
##### CW-3 线索生成
- **线索生成日志**PRD 要求 `GET /monitoring-clues/{id}/generation-logs`,后端未实现独立接口
- **广告主补充**PRD 要求线索确认时可补充广告主信息,后端 generate 接口未支持
#### 三、缺失的通用功能
| 功能 | 涉及模块 | 说明 |
|------|---------|------|
| 权限控制 | 全部 | PRD 定义了市局/区局角色数据范围过滤,后端未实现 |
| 关联对象返回 | AM-1/2/3, CW-1 | PRD 要求返回 screen_info/config_info/video_info 嵌套对象,后端返回平铺字段 |
| 状态中文名 | 全部 | PRD 要求返回 `xxx_text` 中文字段(如 task_status_text后端未实现 |
| 归档标志 | AM-3 | PRD 要求 archive_flag 字段区分已归档/未归档,后端无此字段 |
#### 四、CW-4 已超出 PRD 的实现
以下接口为后端主动扩展PRD 中未要求但已实现:
| 接口 | 说明 |
|------|------|
| POST /api/clue-transfer/status-update | 外部系统回调状态流转 |
| POST /api/clue-transfer/urge | 催办 |
| POST /api/clue-transfer/withdraw | 撤回 |
| GET /api/clue-transfer/pending-clues/query | 待转办线索列表 |
| GET /api/clue-transfer/pending-clues/detail | 待转办线索详情 |
| GET /api/clue-transfer/operation-logs | 操作日志独立查询 |
### 框架 API 发现2026-05-18 编译修复过程中)

View File

@ -188,6 +188,57 @@
**总通过率**: 40/40 = 100%(所有可用接口均正常响应)
### 2026-05-18 — CW-4 线索转办模块完善
**新增 6 个接口**7 个新文件 + 3 个修改文件,+435 行):
| # | 接口 | 说明 |
|---|------|------|
| 1 | POST /api/clue-transfer/pending-clues/query | 待转办线索列表clue_status=1 |
| 2 | GET /api/clue-transfer/pending-clues/detail | 待转办线索详情 |
| 3 | GET /api/clue-transfer/operation-logs | 转办操作日志 |
| 4 | POST /api/clue-transfer/status-update | 状态流转transferred→processing→completed/failed |
| 5 | POST /api/clue-transfer/urge | 催办 |
| 6 | POST /api/clue-transfer/withdraw | 撤回(线索回退到待转办) |
**新增文件**:
- PendingClueQuery.java, PendingClueVO.java, PendingClueDetailVO.java
- OperationLogVO.java
- TransferStatusUpdateReq.java, TransferUrgeReq.java, TransferWithdrawReq.java
**端到端测试结果**:
- 完整流转 transferred→processing→completed ✅
- 双表状态同步(线索 1→2→3→4
- 操作日志 4 条完整记录 ✅
- 催办记录日志 ✅
- 撤回后线索回退到 1 ✅
- 非法状态流转拦截 ✅
**提交**: `687eb9a` feat: CW-4 线索转办模块完善 — 新增6个接口覆盖完整业务闭环
### 2026-05-18 — PRD 业务逻辑差异补齐
**操作**: 按优先级分 4 批补齐 AM-1/AM-2/AM-3 的 PRD 业务逻辑差异
**批次 1: AM-3 广告画面监控** (修改 2 文件):
- MonitorRecordQuery 添加 startTime/endTime/monitorPerson/screenName 字段
- MonitorRecordServiceImpl.queryList 补充时间范围/监控人员/大屏名称筛选条件
- MonitorRecordServiceImpl.judgeMonitor 判定为违规(4)时自动创建取证记录
**批次 2: AM-2 随机录屏** (修改 2 文件):
- RecordingTaskQuery 添加 district/screenName 字段
- RecordingTaskServiceImpl 通过 ScreenMapper 两步关联查询
**批次 3: AM-1 录屏设置** (修改 1 文件):
- RecordingConfigServiceImpl save/update 添加录屏时间范围校验
- 校验 recordStartTime/recordEndTime 必须在大屏广告播放时间范围内
**批次 4: 低优先级通用改进** (修改 2 文件):
- AM-3 默认过滤已判定记录monitorStatus < 3
- AM-2 查询时间范围超过 90 天时拦截
**编译结果**: 全部通过 ✅
---
## 阶段执行状态

View File

@ -90,6 +90,10 @@ public class RecordingConfigServiceImpl extends ServiceImpl<RecordingConfigMappe
ScreenEntity screen = screenMapper.selectById(req.getScreenId());
AssertUtils.isNotNull(screen, "大屏信息不存在");
// 校验录屏时间在大屏广告播放时间范围内
validateRecordTimeInRange(req.getRecordStartTime(), req.getRecordEndTime(),
screen.getAdPlayTimeRange());
// 检查大屏是否已配置录屏
LambdaQueryWrapper<RecordingConfigEntity> checkWrapper = new LambdaQueryWrapper<>();
checkWrapper.eq(RecordingConfigEntity::getScreenId, req.getScreenId());
@ -137,6 +141,18 @@ public class RecordingConfigServiceImpl extends ServiceImpl<RecordingConfigMappe
entity.setDistrict(screen.getDistrict());
}
// 校验录屏时间在大屏广告播放时间范围内
String recordStart = StringUtils.isNotBlank(req.getRecordStartTime())
? req.getRecordStartTime() : entity.getRecordStartTime();
String recordEnd = StringUtils.isNotBlank(req.getRecordEndTime())
? req.getRecordEndTime() : entity.getRecordEndTime();
String effectiveScreenId = StringUtils.isNotBlank(req.getScreenId())
? req.getScreenId() : entity.getScreenId();
ScreenEntity screenForTimeCheck = screenMapper.selectById(effectiveScreenId);
if (screenForTimeCheck != null) {
validateRecordTimeInRange(recordStart, recordEnd, screenForTimeCheck.getAdPlayTimeRange());
}
BeanUtils.copyProperties(req, entity);
this.updateById(entity);
return RestResult.ok();
@ -193,4 +209,23 @@ public class RecordingConfigServiceImpl extends ServiceImpl<RecordingConfigMappe
this.updateById(entity);
return RestResult.ok();
}
private void validateRecordTimeInRange(String recordStart, String recordEnd, String adPlayTimeRange) {
if (StringUtils.isBlank(adPlayTimeRange)) {
return;
}
// adPlayTimeRange 格式: "07:00-22:00"
String[] parts = adPlayTimeRange.split("-");
if (parts.length != 2) {
return;
}
String adStart = parts[0].trim();
String adEnd = parts[1].trim();
if (recordStart != null && recordStart.compareTo(adStart) < 0) {
throw new IllegalArgumentException("录屏开始时间不能早于广告播放开始时间(" + adStart + ")");
}
if (recordEnd != null && recordEnd.compareTo(adEnd) > 0) {
throw new IllegalArgumentException("录屏结束时间不能晚于广告播放结束时间(" + adEnd + ")");
}
}
}

View File

@ -37,4 +37,24 @@ public class MonitorRecordQuery {
* 行政区划
*/
private String district;
/**
* 监控时间范围起始(yyyy-MM-dd HH:mm:ss)
*/
private String startTime;
/**
* 监控时间范围结束(yyyy-MM-dd HH:mm:ss)
*/
private String endTime;
/**
* 监控人员
*/
private String monitorPerson;
/**
* 大屏名称(模糊查询)
*/
private String screenName;
}

View File

@ -7,14 +7,20 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.chinaweal.youfool.framework.springboot.common.util.AssertUtils;
import com.chinaweal.youfool.framework.springboot.common.util.StringUtils;
import com.chinaweal.youfool.framework.springboot.rest.RestResult;
import com.chinaweal.youfool.prj.modules.evidence.record.entity.EvidenceRecordEntity;
import com.chinaweal.youfool.prj.modules.evidence.record.mapper.EvidenceRecordMapper;
import com.chinaweal.youfool.prj.modules.monitor.record.entity.MonitorRecordEntity;
import com.chinaweal.youfool.prj.modules.monitor.record.entity.query.MonitorRecordQuery;
import com.chinaweal.youfool.prj.modules.monitor.record.entity.req.MonitorJudgeReq;
import com.chinaweal.youfool.prj.modules.monitor.record.mapper.MonitorRecordMapper;
import com.chinaweal.youfool.prj.modules.monitor.record.service.IMonitorRecordService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
/**
@ -25,8 +31,11 @@ import java.util.Date;
*/
@Slf4j
@Service
@AllArgsConstructor
public class MonitorRecordServiceImpl extends ServiceImpl<MonitorRecordMapper, MonitorRecordEntity> implements IMonitorRecordService {
private final EvidenceRecordMapper evidenceRecordMapper;
@Override
public RestResult<Page<MonitorRecordEntity>> queryList(MonitorRecordQuery query) {
Page<MonitorRecordEntity> page = new Page<>(query.getPageNum(), query.getPageSize());
@ -35,14 +44,40 @@ public class MonitorRecordServiceImpl extends ServiceImpl<MonitorRecordMapper, M
if (StringUtils.isNotBlank(query.getScreenId())) {
wrapper.eq(MonitorRecordEntity::getScreenId, query.getScreenId());
}
// 监控状态筛选
// 监控状态筛选(未指定时默认过滤出未归档/待监控记录)
if (query.getMonitorStatus() != null) {
wrapper.eq(MonitorRecordEntity::getMonitorStatus, query.getMonitorStatus());
} else {
wrapper.lt(MonitorRecordEntity::getMonitorStatus, 3);
}
// 行政区划精确筛选
if (StringUtils.isNotBlank(query.getDistrict())) {
wrapper.eq(MonitorRecordEntity::getDistrict, query.getDistrict());
}
// 监控人员精确筛选
if (StringUtils.isNotBlank(query.getMonitorPerson())) {
wrapper.eq(MonitorRecordEntity::getMonitorPerson, query.getMonitorPerson());
}
// 大屏名称模糊筛选
if (StringUtils.isNotBlank(query.getScreenName())) {
wrapper.like(MonitorRecordEntity::getScreenName, query.getScreenName());
}
// 监控时间范围筛选
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
if (StringUtils.isNotBlank(query.getStartTime())) {
try {
Date startDate = sdf.parse(query.getStartTime());
wrapper.ge(MonitorRecordEntity::getMonitoredAt, startDate);
} catch (Exception ignored) {
}
}
if (StringUtils.isNotBlank(query.getEndTime())) {
try {
Date endDate = sdf.parse(query.getEndTime());
wrapper.le(MonitorRecordEntity::getMonitoredAt, endDate);
} catch (Exception ignored) {
}
}
wrapper.orderByDesc(MonitorRecordEntity::getCreateTime);
Page<MonitorRecordEntity> entityPage = this.page(page, wrapper);
return RestResult.ok(entityPage);
@ -101,6 +136,42 @@ public class MonitorRecordServiceImpl extends ServiceImpl<MonitorRecordMapper, M
entity.setMonitoredAt(new Date());
}
this.updateById(entity);
// 判定为违规时自动创建取证记录
if (req.getMonitorStatus() == 4) {
autoCreateEvidence(entity);
}
return RestResult.ok();
}
private void autoCreateEvidence(MonitorRecordEntity monitorRecord) {
try {
EvidenceRecordEntity evidence = new EvidenceRecordEntity();
evidence.setMonitorRecordId(monitorRecord.getId());
evidence.setScreenId(monitorRecord.getScreenId());
evidence.setScreenName(monitorRecord.getScreenName());
evidence.setScreenAddress(monitorRecord.getScreenAddress());
evidence.setDistrict(monitorRecord.getDistrict());
evidence.setEvidenceVideoFile("auto_" + monitorRecord.getId() + "_" + System.currentTimeMillis());
evidence.setClipStartTime("00:00:00");
evidence.setClipEndTime("00:00:00");
evidence.setClipDuration(0);
evidence.setEvidenceStatus(1);
evidence.setEvidencePerson("系统自动");
evidence.setEvidencedAt(LocalDateTime.now());
evidence.setSourceMonitorPerson(
monitorRecord.getMonitorPerson() != null ? monitorRecord.getMonitorPerson() : "系统自动");
if (monitorRecord.getMonitoredAt() != null) {
evidence.setSourceMonitorTime(
monitorRecord.getMonitoredAt().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime());
}
evidence.setSourceMonitorRemark("违规判定自动创建取证记录");
evidenceRecordMapper.insert(evidence);
log.info("违规判定自动创建取证记录monitorRecordId={}, evidenceId={}",
monitorRecord.getId(), evidence.getId());
} catch (Exception e) {
log.error("自动创建取证记录失败monitorRecordId={}", monitorRecord.getId(), e);
}
}
}

View File

@ -42,4 +42,14 @@ public class RecordingTaskQuery {
* 开始时间范围-结束(yyyy-MM-dd HH:mm:ss)
*/
private String startTimeEnd;
/**
* 行政区划(关联bs_screen查询)
*/
private String district;
/**
* 大屏名称模糊查询(关联bs_screen查询)
*/
private String screenName;
}

View File

@ -12,6 +12,8 @@ import com.chinaweal.youfool.prj.modules.monitor.task.entity.query.RecordingTask
import com.chinaweal.youfool.prj.modules.monitor.task.mapper.AlertNotificationMapper;
import com.chinaweal.youfool.prj.modules.monitor.task.mapper.RecordingTaskMapper;
import com.chinaweal.youfool.prj.modules.monitor.task.service.IRecordingTaskService;
import com.chinaweal.youfool.prj.modules.screen.entity.ScreenEntity;
import com.chinaweal.youfool.prj.modules.screen.mapper.ScreenMapper;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -37,6 +39,7 @@ import java.util.stream.Collectors;
public class RecordingTaskServiceImpl extends ServiceImpl<RecordingTaskMapper, RecordingTaskEntity> implements IRecordingTaskService {
private final AlertNotificationMapper alertNotificationMapper;
private final ScreenMapper screenMapper;
@Override
public RestResult<Page<RecordingTaskEntity>> queryList(RecordingTaskQuery query) {
@ -50,12 +53,35 @@ public class RecordingTaskServiceImpl extends ServiceImpl<RecordingTaskMapper, R
if (query.getTaskStatus() != null) {
wrapper.eq(RecordingTaskEntity::getTaskStatus, query.getTaskStatus());
}
// 行政区划/大屏名称 → 通过bs_screen两步查询
if (StringUtils.isNotBlank(query.getDistrict()) || StringUtils.isNotBlank(query.getScreenName())) {
LambdaQueryWrapper<ScreenEntity> screenWrapper = new LambdaQueryWrapper<>();
if (StringUtils.isNotBlank(query.getDistrict())) {
screenWrapper.eq(ScreenEntity::getDistrict, query.getDistrict());
}
if (StringUtils.isNotBlank(query.getScreenName())) {
screenWrapper.like(ScreenEntity::getScreenName, query.getScreenName());
}
List<ScreenEntity> screens = screenMapper.selectList(screenWrapper);
List<String> screenIds = screens.stream()
.map(ScreenEntity::getId).collect(Collectors.toList());
if (screenIds.isEmpty()) {
// 无匹配大屏,返回空结果
return RestResult.ok(new Page<>(query.getPageNum(), query.getPageSize(), 0));
}
wrapper.in(RecordingTaskEntity::getScreenId, screenIds);
}
// 开始时间范围筛选
if (StringUtils.isNotBlank(query.getStartTimeBegin()) && StringUtils.isNotBlank(query.getStartTimeEnd())) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
try {
Date beginDate = sdf.parse(query.getStartTimeBegin());
Date endDate = sdf.parse(query.getStartTimeEnd());
// 限制查询范围不超过90天
long diffDays = (endDate.getTime() - beginDate.getTime()) / (1000 * 60 * 60 * 24);
if (diffDays > 90) {
throw new IllegalArgumentException("查询时间范围不能超过90天");
}
wrapper.ge(RecordingTaskEntity::getActualStartTime, beginDate);
wrapper.le(RecordingTaskEntity::getActualStartTime, endDate);
} catch (ParseException e) {