feat: track explicit report delivery
This commit is contained in:
parent
0ad8a4d596
commit
1887864d38
11
README.md
11
README.md
@ -101,6 +101,12 @@ curl http://localhost:8080/api/store/list
|
||||
```
|
||||
脚本新增门店并发容量、服务时长、预约时长快照和连续排班占用。末尾容量、时长和快照验证计数必须全部为 0。
|
||||
|
||||
8. **报告显式确认发送状态**:预约容量验证完成后执行:
|
||||
```bash
|
||||
mysql -h <host> -u <user> -p petstore < db/migrations/20260801_create_report_send_status.sql
|
||||
```
|
||||
脚本把历史报告统一标记为 `unknown`,迁移后的新报告默认 `unsent`;不得推测历史发送事实。末尾三项状态与回执一致性验证必须全部为 0。
|
||||
|
||||
### production profile
|
||||
|
||||
```bash
|
||||
@ -116,14 +122,14 @@ production profile 下:
|
||||
|
||||
### 生产只读预检
|
||||
|
||||
完成备份和四个迁移后,以最终生产环境变量运行:
|
||||
完成备份和五个迁移后,以最终生产环境变量运行:
|
||||
|
||||
```bash
|
||||
chmod +x deploy/release-preflight.sh deploy/production-smoke.sh
|
||||
BUILD_FIRST=1 deploy/release-preflight.sh
|
||||
```
|
||||
|
||||
脚本先校验生产配置、上传目录、Java 与 FFmpeg,再启动无 Web 的 `production` profile,执行 JPA schema `validate` 和 15 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。
|
||||
脚本先校验生产配置、上传目录、Java 与 FFmpeg,再启动无 Web 的 `production` profile,执行 JPA schema `validate` 和 18 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。
|
||||
|
||||
## 测试
|
||||
|
||||
@ -179,6 +185,7 @@ API_ORIGIN=https://<api-domain> backend/deploy/production-smoke.sh
|
||||
- [ ] 已按顺序执行 `20260801_create_store_customer.sql`,三项主档验证计数均为 0
|
||||
- [ ] 已执行 `20260801_create_business_event.sql`,四项事件回填验证计数均为 0
|
||||
- [ ] 已执行 `20260801_create_booking_capacity.sql`,容量与时长验证计数均为 0
|
||||
- [ ] 已执行 `20260801_create_report_send_status.sql`,历史 `unknown` 与三项回执一致性验证均为 0
|
||||
- [ ] `CORS_ALLOWED_ORIGINS` 仅包含本次发布的显式 HTTPS Web 源
|
||||
- [ ] `deploy/release-preflight.sh` 已以最终环境变量通过并留存输出
|
||||
- [ ] readiness 与上线后只读冒烟全部通过
|
||||
|
||||
40
db/migrations/20260801_create_report_send_status.sql
Normal file
40
db/migrations/20260801_create_report_send_status.sql
Normal file
@ -0,0 +1,40 @@
|
||||
-- Petstore Phase 0:报告显式确认发送状态
|
||||
-- 前置:20260801_create_booking_capacity.sql 已执行。
|
||||
-- 语义:历史报告无法可靠证明是否发送,统一标记 unknown;迁移后新报告默认 unsent。
|
||||
|
||||
ALTER TABLE t_report
|
||||
ADD COLUMN send_status VARCHAR(16) NULL COMMENT 'unknown | unsent | sent';
|
||||
|
||||
ALTER TABLE t_report
|
||||
ADD COLUMN sent_at DATETIME NULL COMMENT '首次显式确认发送时间';
|
||||
|
||||
ALTER TABLE t_report
|
||||
ADD COLUMN sent_by_user_id BIGINT NULL COMMENT '首次确认发送的门店员工';
|
||||
|
||||
ALTER TABLE t_report
|
||||
ADD COLUMN send_channel VARCHAR(16) NULL COMMENT 'wechat | qr | other';
|
||||
|
||||
UPDATE t_report
|
||||
SET send_status = 'unknown';
|
||||
|
||||
ALTER TABLE t_report
|
||||
MODIFY COLUMN send_status VARCHAR(16) NOT NULL DEFAULT 'unsent' COMMENT 'unknown | unsent | sent';
|
||||
|
||||
CREATE INDEX idx_report_store_send_time
|
||||
ON t_report (store_id, send_status, create_time);
|
||||
|
||||
-- 验证:以下三项必须全部为 0。
|
||||
SELECT COUNT(*) AS invalid_report_send_status_count
|
||||
FROM t_report
|
||||
WHERE send_status IS NULL OR send_status NOT IN ('unknown', 'unsent', 'sent');
|
||||
|
||||
SELECT COUNT(*) AS sent_report_without_receipt_count
|
||||
FROM t_report
|
||||
WHERE send_status = 'sent'
|
||||
AND (sent_at IS NULL OR sent_by_user_id IS NULL
|
||||
OR send_channel IS NULL OR send_channel NOT IN ('wechat', 'qr', 'other'));
|
||||
|
||||
SELECT COUNT(*) AS unconfirmed_report_with_receipt_count
|
||||
FROM t_report
|
||||
WHERE send_status IN ('unknown', 'unsent')
|
||||
AND (sent_at IS NOT NULL OR sent_by_user_id IS NOT NULL OR send_channel IS NOT NULL);
|
||||
@ -16,4 +16,6 @@
|
||||
|
||||
`20260801_create_booking_capacity.sql` 必须在业务事件迁移之后执行:为服务项目、预约快照和手动占用补充时长,为门店补充并发接待数。历史预约按服务名称采用稳定默认值回填;脚本末尾四个验证计数必须为 0。
|
||||
|
||||
`20260801_create_report_send_status.sql` 必须在预约容量迁移之后执行:新增报告显式确认发送状态与首次确认回执。历史报告统一标记为 `unknown`,不得推测为未发送或已发送;迁移后的新报告默认 `unsent`。脚本末尾三个验证计数必须为 0。
|
||||
|
||||
正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。
|
||||
|
||||
@ -102,6 +102,21 @@ public class ProductionDatabasePreflightRunner implements ApplicationRunner {
|
||||
WHERE deleted = 0 AND appointment_id IS NOT NULL
|
||||
GROUP BY appointment_id HAVING COUNT(*) > 1
|
||||
) duplicated
|
||||
"""),
|
||||
new Invariant("invalid_report_send_status", """
|
||||
SELECT COUNT(*) FROM t_report
|
||||
WHERE send_status IS NULL OR send_status NOT IN ('unknown', 'unsent', 'sent')
|
||||
"""),
|
||||
new Invariant("sent_report_without_receipt", """
|
||||
SELECT COUNT(*) FROM t_report
|
||||
WHERE send_status = 'sent'
|
||||
AND (sent_at IS NULL OR sent_by_user_id IS NULL
|
||||
OR send_channel IS NULL OR send_channel NOT IN ('wechat', 'qr', 'other'))
|
||||
"""),
|
||||
new Invariant("unconfirmed_report_with_receipt", """
|
||||
SELECT COUNT(*) FROM t_report
|
||||
WHERE send_status IN ('unknown', 'unsent')
|
||||
AND (sent_at IS NOT NULL OR sent_by_user_id IS NOT NULL OR send_channel IS NOT NULL)
|
||||
""")
|
||||
);
|
||||
|
||||
|
||||
@ -207,6 +207,45 @@ public class ReportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店员工在真实完成发送后显式确认。复制链接、二维码和预览不会自动调用本接口。
|
||||
*/
|
||||
@PostMapping("/confirm-sent")
|
||||
public Map<String, Object> confirmSent(@RequestBody Map<String, Object> body) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
if (!u.isStoreUser()) {
|
||||
return Map.of("code", 403, "message", "仅门店员工可确认报告发送", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
Object rawReportId = body == null ? null : body.get("reportId");
|
||||
if (rawReportId == null) {
|
||||
return Map.of("code", 400, "message", "reportId 必填", "bizCode", "REPORT_ID_REQUIRED");
|
||||
}
|
||||
try {
|
||||
Long reportId = Long.valueOf(rawReportId.toString());
|
||||
String channel = body.get("channel") == null ? "" : body.get("channel").toString();
|
||||
ReportService.ConfirmSentOutcome outcome = reportService.confirmSent(
|
||||
reportId, u.storeId(), u.userId(), u.role(), channel
|
||||
);
|
||||
if (outcome == null) {
|
||||
return Map.of("code", 404, "message", "报告不存在", "bizCode", "NOT_FOUND");
|
||||
}
|
||||
Report report = outcome.report();
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("reportId", report.getId());
|
||||
data.put("sendStatus", report.getSendStatus());
|
||||
data.put("sentAt", report.getSentAt());
|
||||
data.put("sendChannel", report.getSendChannel());
|
||||
data.put("alreadyConfirmed", outcome.alreadyConfirmed());
|
||||
return Map.of("code", 200, "message", "已确认发送", "data", data);
|
||||
} catch (NumberFormatException e) {
|
||||
return Map.of("code", 400, "message", "reportId 格式错误", "bizCode", "INVALID_REPORT_ID");
|
||||
} catch (SecurityException e) {
|
||||
return Map.of("code", 403, "message", e.getMessage(), "bizCode", "FORBIDDEN");
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Map.of("code", 400, "message", e.getMessage(), "bizCode", "INVALID_SEND_CHANNEL");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告是否含有指定类型(before / after)的有效照片:url 非空且 mediaType 非 video。
|
||||
*/
|
||||
@ -224,11 +263,20 @@ public class ReportController {
|
||||
|
||||
@GetMapping("/list")
|
||||
public Map<String, Object> list(@RequestParam(required = false) Integer page,
|
||||
@RequestParam(required = false) Integer pageSize) {
|
||||
@RequestParam(required = false) Integer pageSize,
|
||||
@RequestParam(required = false) String sendStatus) {
|
||||
// 身份边界从上下文派生:boss/staff 查本店;customer 查自己相关报告
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
Long scopedStoreId = u.isStoreUser() ? u.storeId() : null;
|
||||
Long scopedUserId = u.isCustomer() ? u.userId() : null;
|
||||
String scopedSendStatus = null;
|
||||
if (u.isStoreUser() && sendStatus != null && !sendStatus.isBlank()) {
|
||||
scopedSendStatus = sendStatus.trim().toLowerCase(Locale.ROOT);
|
||||
if (!Set.of(Report.SEND_STATUS_UNKNOWN, Report.SEND_STATUS_UNSENT, Report.SEND_STATUS_SENT)
|
||||
.contains(scopedSendStatus)) {
|
||||
return Map.of("code", 400, "message", "发送状态无效", "bizCode", "INVALID_SEND_STATUS");
|
||||
}
|
||||
}
|
||||
|
||||
boolean usePaging = page != null || pageSize != null;
|
||||
List<Report> reports;
|
||||
@ -238,10 +286,10 @@ public class ReportController {
|
||||
if (usePaging) {
|
||||
pageNo = Math.max(page == null ? 1 : page, 1);
|
||||
size = Math.min(Math.max(pageSize == null ? 20 : pageSize, 1), 200);
|
||||
paged = reportService.page(scopedStoreId, scopedUserId, pageNo - 1, size);
|
||||
paged = reportService.page(scopedStoreId, scopedUserId, scopedSendStatus, pageNo - 1, size);
|
||||
reports = paged.getContent();
|
||||
} else {
|
||||
reports = reportService.list(scopedStoreId, scopedUserId);
|
||||
reports = reportService.list(scopedStoreId, scopedUserId, scopedSendStatus);
|
||||
}
|
||||
// 附加技师名称,并补全图片URL
|
||||
List<Map<String, Object>> data = reports.stream().map(r -> {
|
||||
@ -259,6 +307,9 @@ public class ReportController {
|
||||
item.put("userId", r.getUserId());
|
||||
item.put("customerUserId", r.getCustomerUserId());
|
||||
item.put("authorStaffId", r.resolvedAuthorStaffId());
|
||||
if (u.isStoreUser()) {
|
||||
putSendFields(item, r, true);
|
||||
}
|
||||
// 图片列表
|
||||
List<ReportImage> imgs = r.getImages();
|
||||
List<String> beforePhotos = new ArrayList<>();
|
||||
@ -386,6 +437,7 @@ public class ReportController {
|
||||
data.put("authorStaffId", report.resolvedAuthorStaffId());
|
||||
data.put("storeId", report.getStoreId());
|
||||
data.put("reportToken", report.getReportToken());
|
||||
putSendFields(data, report, true);
|
||||
}
|
||||
// 公开 token 请求:不返回 top-level userId/storeId/reportToken(已在上方跳过)
|
||||
result.put("code", 200);
|
||||
@ -397,6 +449,15 @@ public class ReportController {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void putSendFields(Map<String, Object> target, Report report, boolean includeActor) {
|
||||
target.put("sendStatus", report.getSendStatus());
|
||||
target.put("sentAt", report.getSentAt());
|
||||
target.put("sendChannel", report.getSendChannel());
|
||||
if (includeActor) {
|
||||
target.put("sentByUserId", report.getSentByUserId());
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/delete")
|
||||
public Map<String, Object> delete(@RequestParam Long id) {
|
||||
CurrentUser u = CurrentUserContext.require();
|
||||
|
||||
@ -17,13 +17,18 @@ import java.util.List;
|
||||
@Index(name = "idx_report_token", columnList = "report_token"),
|
||||
@Index(name = "idx_report_store_user_time", columnList = "store_id,user_id,create_time"),
|
||||
@Index(name = "idx_report_customer_time", columnList = "customer_user_id,create_time"),
|
||||
@Index(name = "idx_report_author_time", columnList = "author_staff_id,create_time")
|
||||
@Index(name = "idx_report_author_time", columnList = "author_staff_id,create_time"),
|
||||
@Index(name = "idx_report_store_send_time", columnList = "store_id,send_status,create_time")
|
||||
},
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_report_appointment", columnNames = "appointment_id")
|
||||
}
|
||||
)
|
||||
public class Report {
|
||||
public static final String SEND_STATUS_UNKNOWN = "unknown";
|
||||
public static final String SEND_STATUS_UNSENT = "unsent";
|
||||
public static final String SEND_STATUS_SENT = "sent";
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
@ -74,6 +79,22 @@ public class Report {
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
/** unknown=历史不可确认;unsent=新报告待发送;sent=员工已显式确认发送。 */
|
||||
@Column(name = "send_status", nullable = false, length = 16)
|
||||
private String sendStatus = SEND_STATUS_UNSENT;
|
||||
|
||||
/** 首次显式确认发送时间;复制链接、展示二维码或预览均不写入。 */
|
||||
@Column(name = "sent_at")
|
||||
private LocalDateTime sentAt;
|
||||
|
||||
/** 首次确认发送的门店员工。 */
|
||||
@Column(name = "sent_by_user_id")
|
||||
private Long sentByUserId;
|
||||
|
||||
/** 首次确认渠道:wechat | qr | other。 */
|
||||
@Column(name = "send_channel", length = 16)
|
||||
private String sendChannel;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private Boolean deleted = false;
|
||||
|
||||
|
||||
@ -3,12 +3,14 @@ package com.petstore.mapper;
|
||||
import com.petstore.entity.Report;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import jakarta.persistence.LockModeType;
|
||||
|
||||
public interface ReportMapper extends JpaRepository<Report, Long> {
|
||||
Optional<Report> findFirstByAppointmentIdAndDeletedFalseOrderByCreateTimeDesc(Long appointmentId);
|
||||
@ -23,6 +25,12 @@ public interface ReportMapper extends JpaRepository<Report, Long> {
|
||||
List<Report> findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc(
|
||||
Long storeId, List<String> highlightVideoStatuses);
|
||||
|
||||
List<Report> findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeAsc(
|
||||
Long storeId, String sendStatus);
|
||||
|
||||
List<Report> findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeDesc(
|
||||
Long storeId, String sendStatus);
|
||||
|
||||
/** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */
|
||||
@Deprecated
|
||||
List<Report> findByUserIdAndDeletedFalseOrderByCreateTimeDesc(Long userId);
|
||||
@ -34,6 +42,7 @@ public interface ReportMapper extends JpaRepository<Report, Long> {
|
||||
List<Report> findAllByDeletedFalseOrderByCreateTimeDesc();
|
||||
|
||||
Page<Report> findByStoreIdAndDeletedFalse(Long storeId, Pageable pageable);
|
||||
Page<Report> findByStoreIdAndSendStatusAndDeletedFalse(Long storeId, String sendStatus, Pageable pageable);
|
||||
|
||||
/** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */
|
||||
@Deprecated
|
||||
@ -62,4 +71,8 @@ public interface ReportMapper extends JpaRepository<Report, Long> {
|
||||
Page<Report> findByDeletedFalse(Pageable pageable);
|
||||
Optional<Report> findByIdAndDeletedFalse(Long id);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT r FROM Report r WHERE r.id = :id AND r.deleted = false")
|
||||
Optional<Report> findByIdAndDeletedFalseForUpdate(@Param("id") Long id);
|
||||
|
||||
}
|
||||
|
||||
@ -26,6 +26,7 @@ public class AdminBusinessEventService {
|
||||
LocalDateTime to = day.plusDays(1).atStartOfDay();
|
||||
|
||||
long submitted = count(storeId, BusinessEventService.REPORT_SUBMITTED, from, to);
|
||||
long sent = count(storeId, BusinessEventService.REPORT_SENT, from, to);
|
||||
long opened = businessEventMapper.countDistinctAggregatesByTypes(
|
||||
storeId,
|
||||
List.of(BusinessEventService.REPORT_OPENED, BusinessEventService.REPORT_REOPENED),
|
||||
@ -37,11 +38,11 @@ public class AdminBusinessEventService {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("date", day.toString());
|
||||
data.put("reportSubmittedCount", submitted);
|
||||
data.put("reportSentCount", null);
|
||||
data.put("reportSentCount", sent);
|
||||
data.put("reportOpenedCount", opened);
|
||||
data.put("leadSubmittedCount", leads);
|
||||
data.put("rebookCreatedCount", null);
|
||||
data.put("reportSentTrackingReady", false);
|
||||
data.put("reportSentTrackingReady", true);
|
||||
data.put("rebookTrackingReady", false);
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ public class AdminWorkbenchService {
|
||||
public static final String IN_SERVICE = "in_service";
|
||||
public static final String UPCOMING_TODAY = "upcoming_today";
|
||||
public static final String HIGHLIGHT_ANOMALY = "highlight_anomaly";
|
||||
public static final String UNSENT_REPORT = "unsent_report";
|
||||
public static final String FOLLOW_UP_DUE = "follow_up_due";
|
||||
|
||||
private final AppointmentMapper appointmentMapper;
|
||||
@ -74,6 +75,12 @@ public class AdminWorkbenchService {
|
||||
Comparator.nullsFirst(Comparator.naturalOrder())))
|
||||
.toList();
|
||||
|
||||
List<Report> unsentReports = safeReports(
|
||||
reportMapper.findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeAsc(
|
||||
storeId, Report.SEND_STATUS_UNSENT
|
||||
)
|
||||
);
|
||||
|
||||
List<ReportLead> dueFollowUps = safeLeads(
|
||||
reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(storeId, "pending")
|
||||
).stream()
|
||||
@ -94,6 +101,7 @@ public class AdminWorkbenchService {
|
||||
inService.size(),
|
||||
upcomingToday.size(),
|
||||
highlightAnomalies.size(),
|
||||
unsentReports.size(),
|
||||
dueFollowUps.size()
|
||||
);
|
||||
|
||||
@ -102,6 +110,7 @@ public class AdminWorkbenchService {
|
||||
addAppointmentGroup(actions, IN_SERVICE, "high", inService, now);
|
||||
addAppointmentGroup(actions, UPCOMING_TODAY, "normal", upcomingToday, now);
|
||||
addReportGroup(actions, highlightAnomalies);
|
||||
addUnsentReportGroup(actions, unsentReports);
|
||||
addLeadGroup(actions, dueFollowUps);
|
||||
|
||||
Map<String, Object> reportFunnel = adminBusinessEventService.reportFunnel(storeId, day);
|
||||
@ -178,6 +187,23 @@ public class AdminWorkbenchService {
|
||||
groups.add(new ActionGroup(HIGHLIGHT_ANOMALY, "high", reports.size(), items));
|
||||
}
|
||||
|
||||
private static void addUnsentReportGroup(List<ActionGroup> groups, List<Report> reports) {
|
||||
if (reports.isEmpty()) return;
|
||||
List<ActionItem> items = reports.stream()
|
||||
.limit(PREVIEW_LIMIT)
|
||||
.map(report -> new ActionItem(
|
||||
"report",
|
||||
report.getId(),
|
||||
report.getPetName(),
|
||||
report.getServiceType(),
|
||||
report.getCreateTime() == null ? null : report.getCreateTime().toString(),
|
||||
report.getSendStatus(),
|
||||
true
|
||||
))
|
||||
.toList();
|
||||
groups.add(new ActionGroup(UNSENT_REPORT, "high", reports.size(), items));
|
||||
}
|
||||
|
||||
private static void addLeadGroup(List<ActionGroup> groups, List<ReportLead> leads) {
|
||||
if (leads.isEmpty()) return;
|
||||
List<ActionItem> items = leads.stream()
|
||||
@ -210,6 +236,7 @@ public class AdminWorkbenchService {
|
||||
long inServiceCount,
|
||||
long upcomingTodayCount,
|
||||
long highlightAnomalyCount,
|
||||
long unsentReportCount,
|
||||
long dueFollowUpCount) {
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ public class BusinessEventService {
|
||||
public static final String SERVICE_STARTED = "service_started";
|
||||
public static final String SERVICE_COMPLETED = "service_completed";
|
||||
public static final String REPORT_SUBMITTED = "report_submitted";
|
||||
public static final String REPORT_SENT = "report_sent";
|
||||
public static final String REPORT_OPENED = "report_opened";
|
||||
public static final String REPORT_REOPENED = "report_reopened";
|
||||
public static final String LEAD_SUBMITTED = "lead_submitted";
|
||||
@ -152,6 +153,23 @@ public class BusinessEventService {
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordReportSent(Report report, String actorRole) {
|
||||
return record(new EventCommand(
|
||||
REPORT_SENT,
|
||||
report.getStoreId(),
|
||||
resolveStoreCustomerId(report.getStoreId(), report.getCustomerUserId()),
|
||||
"report",
|
||||
report.getId(),
|
||||
report.getSentByUserId(),
|
||||
actorRole,
|
||||
"admin",
|
||||
report.getSentAt(),
|
||||
"report_sent:" + report.getId(),
|
||||
Map.of("channel", report.getSendChannel())
|
||||
));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessEvent recordLeadSubmitted(
|
||||
ReportLead lead,
|
||||
|
||||
@ -12,11 +12,15 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ReportService {
|
||||
private static final Set<String> SEND_CHANNELS = Set.of("wechat", "qr", "other");
|
||||
|
||||
private final ReportMapper reportMapper;
|
||||
private final ReportImageMapper reportImageMapper;
|
||||
private final AppointmentMapper appointmentMapper;
|
||||
@ -94,6 +98,10 @@ public class ReportService {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
report.setCreateTime(now);
|
||||
report.setUpdateTime(now);
|
||||
report.setSendStatus(Report.SEND_STATUS_UNSENT);
|
||||
report.setSentAt(null);
|
||||
report.setSentByUserId(null);
|
||||
report.setSendChannel(null);
|
||||
report.setDeleted(false);
|
||||
|
||||
// 先保存 Report 以获取生成的 ID
|
||||
@ -123,6 +131,54 @@ public class ReportService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录门店员工已经完成的发送事实。首次确认不可覆盖;重复确认幂等成功。
|
||||
* 复制链接、展示二维码和预览报告都不得调用本方法。
|
||||
*/
|
||||
@Transactional
|
||||
public ConfirmSentOutcome confirmSent(
|
||||
Long reportId,
|
||||
Long storeId,
|
||||
Long actorUserId,
|
||||
String actorRole,
|
||||
String channel
|
||||
) {
|
||||
if (reportId == null) {
|
||||
throw new IllegalArgumentException("reportId 必填");
|
||||
}
|
||||
if (storeId == null || actorUserId == null
|
||||
|| !("boss".equalsIgnoreCase(actorRole) || "staff".equalsIgnoreCase(actorRole))) {
|
||||
throw new SecurityException("仅门店员工可确认报告发送");
|
||||
}
|
||||
String normalizedChannel = channel == null ? "" : channel.trim().toLowerCase(Locale.ROOT);
|
||||
if (!SEND_CHANNELS.contains(normalizedChannel)) {
|
||||
throw new IllegalArgumentException("发送渠道仅支持 wechat、qr 或 other");
|
||||
}
|
||||
|
||||
Report report = reportMapper.findByIdAndDeletedFalseForUpdate(reportId).orElse(null);
|
||||
if (report == null) {
|
||||
return null;
|
||||
}
|
||||
if (!storeId.equals(report.getStoreId())) {
|
||||
throw new SecurityException("无权确认他店报告发送");
|
||||
}
|
||||
if (Report.SEND_STATUS_SENT.equals(report.getSendStatus())) {
|
||||
return new ConfirmSentOutcome(report, true);
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
report.setSendStatus(Report.SEND_STATUS_SENT);
|
||||
report.setSentAt(now);
|
||||
report.setSentByUserId(actorUserId);
|
||||
report.setSendChannel(normalizedChannel);
|
||||
Report saved = reportMapper.save(report);
|
||||
businessEventService.recordReportSent(saved, actorRole);
|
||||
return new ConfirmSentOutcome(saved, false);
|
||||
}
|
||||
|
||||
public record ConfirmSentOutcome(Report report, boolean alreadyConfirmed) {
|
||||
}
|
||||
|
||||
private String resolveActorRole(Long userId) {
|
||||
if (userId == null) {
|
||||
return null;
|
||||
@ -168,11 +224,19 @@ public class ReportService {
|
||||
}
|
||||
|
||||
public List<Report> list(Long storeId, Long customerUserId) {
|
||||
return list(storeId, customerUserId, null);
|
||||
}
|
||||
|
||||
public List<Report> list(Long storeId, Long customerUserId, String sendStatus) {
|
||||
List<Report> reports;
|
||||
if (storeId != null && customerUserId != null) {
|
||||
reports = reportMapper.findForCustomer(customerUserId).stream()
|
||||
.filter(r -> storeId.equals(r.getStoreId()))
|
||||
.toList();
|
||||
} else if (storeId != null && sendStatus != null) {
|
||||
reports = reportMapper.findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeDesc(
|
||||
storeId, sendStatus
|
||||
);
|
||||
} else if (storeId != null) {
|
||||
reports = reportMapper.findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(storeId);
|
||||
} else if (customerUserId != null) {
|
||||
@ -184,6 +248,11 @@ public class ReportService {
|
||||
}
|
||||
|
||||
public Page<Report> page(Long storeId, Long customerUserId, int pageNo, int pageSize) {
|
||||
return page(storeId, customerUserId, null, pageNo, pageSize);
|
||||
}
|
||||
|
||||
public Page<Report> page(
|
||||
Long storeId, Long customerUserId, String sendStatus, int pageNo, int pageSize) {
|
||||
Pageable pageable = PageRequest.of(
|
||||
Math.max(pageNo, 0),
|
||||
Math.max(pageSize, 1),
|
||||
@ -193,6 +262,8 @@ public class ReportService {
|
||||
if (storeId != null && customerUserId != null) {
|
||||
// 当前角色模型不会同时出现 store/customer scope;保守按客户归属查询。
|
||||
paged = reportMapper.pageForCustomer(customerUserId, pageable);
|
||||
} else if (storeId != null && sendStatus != null) {
|
||||
paged = reportMapper.findByStoreIdAndSendStatusAndDeletedFalse(storeId, sendStatus, pageable);
|
||||
} else if (storeId != null) {
|
||||
paged = reportMapper.findByStoreIdAndDeletedFalse(storeId, pageable);
|
||||
} else if (customerUserId != null) {
|
||||
|
||||
@ -74,7 +74,7 @@ class AdminBusinessEventControllerTest {
|
||||
AdminWorkbenchService.WorkbenchData data = new AdminWorkbenchService.WorkbenchData(
|
||||
"2026-08-01",
|
||||
LocalDateTime.of(2026, 8, 1, 10, 0),
|
||||
new AdminWorkbenchService.WorkbenchMetrics(3, 1, 1, 1, 1, 0, 0),
|
||||
new AdminWorkbenchService.WorkbenchMetrics(3, 1, 1, 1, 1, 0, 0, 0),
|
||||
List.of(),
|
||||
Map.of()
|
||||
);
|
||||
|
||||
@ -101,6 +101,9 @@ class ReportControllerTest {
|
||||
assertFalse(data.containsKey("authorStaffId"), "公开请求不应返回报告作者 ID");
|
||||
assertFalse(data.containsKey("storeId"), "公开请求不应返回 top-level storeId");
|
||||
assertFalse(data.containsKey("reportToken"), "公开请求不应返回 reportToken");
|
||||
assertFalse(data.containsKey("sendStatus"), "公开请求不应返回门店发送状态");
|
||||
assertFalse(data.containsKey("sentAt"), "公开请求不应返回门店发送回执");
|
||||
assertFalse(data.containsKey("sentByUserId"), "公开请求不应返回确认员工 ID");
|
||||
// 但 store 对象内应包含 id(用于预约跳转深链)
|
||||
Map<String, Object> storeInfo = (Map<String, Object>) data.get("store");
|
||||
assertNotNull(storeInfo);
|
||||
@ -290,6 +293,73 @@ class ReportControllerTest {
|
||||
);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Test
|
||||
void confirmSentUsesSessionScopeAndReturnsIdempotencyFlag() {
|
||||
setCurrentUserBoss();
|
||||
Report report = new Report();
|
||||
report.setId(9L);
|
||||
report.setSendStatus(Report.SEND_STATUS_SENT);
|
||||
report.setSentAt(java.time.LocalDateTime.of(2026, 8, 1, 10, 0));
|
||||
report.setSentByUserId(1L);
|
||||
report.setSendChannel("wechat");
|
||||
when(reportService.confirmSent(9L, 10L, 1L, "boss", "wechat"))
|
||||
.thenReturn(new ReportService.ConfirmSentOutcome(report, false));
|
||||
|
||||
Map<String, Object> result = controller.confirmSent(Map.of("reportId", 9L, "channel", "wechat"));
|
||||
|
||||
assertEquals(200, result.get("code"));
|
||||
Map<String, Object> data = (Map<String, Object>) result.get("data");
|
||||
assertEquals("sent", data.get("sendStatus"));
|
||||
assertEquals(false, data.get("alreadyConfirmed"));
|
||||
assertFalse(data.containsKey("sentByUserId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void customerCannotConfirmSent() {
|
||||
CurrentUserContext.set(new CurrentUser(99L, null, "customer"));
|
||||
|
||||
Map<String, Object> result = controller.confirmSent(Map.of("reportId", 9L, "channel", "wechat"));
|
||||
|
||||
assertEquals(403, result.get("code"));
|
||||
assertEquals("FORBIDDEN", result.get("bizCode"));
|
||||
verify(reportService, never()).confirmSent(any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossStoreConfirmSentReturnsForbidden() {
|
||||
setCurrentUserBoss();
|
||||
when(reportService.confirmSent(9L, 10L, 1L, "boss", "qr"))
|
||||
.thenThrow(new SecurityException("无权确认他店报告发送"));
|
||||
|
||||
Map<String, Object> result = controller.confirmSent(Map.of("reportId", 9L, "channel", "qr"));
|
||||
|
||||
assertEquals(403, result.get("code"));
|
||||
assertEquals("FORBIDDEN", result.get("bizCode"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeReportListFiltersBySendStatusServerSide() {
|
||||
setCurrentUserBoss();
|
||||
when(reportService.list(10L, null, Report.SEND_STATUS_UNSENT)).thenReturn(List.of());
|
||||
|
||||
Map<String, Object> result = controller.list(null, null, "unsent");
|
||||
|
||||
assertEquals(200, result.get("code"));
|
||||
verify(reportService).list(10L, null, Report.SEND_STATUS_UNSENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportListRejectsInvalidSendStatus() {
|
||||
setCurrentUserBoss();
|
||||
|
||||
Map<String, Object> result = controller.list(null, null, "delivered");
|
||||
|
||||
assertEquals(400, result.get("code"));
|
||||
assertEquals("INVALID_SEND_STATUS", result.get("bizCode"));
|
||||
verify(reportService, never()).list(any(), any(), any());
|
||||
}
|
||||
|
||||
private void setCurrentUserBoss() {
|
||||
CurrentUserContext.set(new CurrentUser(1L, 10L, "boss"));
|
||||
}
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
import org.h2.tools.RunScript;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.Reader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class ReportSendMigrationTest {
|
||||
|
||||
@Test
|
||||
void migrationKeepsHistoryUnknownAndDefaultsNewReportsToUnsent() throws Exception {
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
"jdbc:h2:mem:report-send-migration;MODE=MySQL;DATABASE_TO_LOWER=TRUE",
|
||||
"sa",
|
||||
""
|
||||
)) {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("""
|
||||
CREATE TABLE t_report (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
store_id BIGINT,
|
||||
create_time DATETIME,
|
||||
deleted BOOLEAN DEFAULT FALSE
|
||||
)
|
||||
""");
|
||||
statement.execute("INSERT INTO t_report (store_id, create_time) VALUES (10, '2026-08-01 10:00:00')");
|
||||
}
|
||||
|
||||
try (Reader reader = Files.newBufferedReader(
|
||||
Path.of("db/migrations/20260801_create_report_send_status.sql")
|
||||
)) {
|
||||
RunScript.execute(connection, reader);
|
||||
}
|
||||
|
||||
assertText(connection, "SELECT send_status FROM t_report WHERE id = 1", "unknown");
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("INSERT INTO t_report (store_id, create_time) VALUES (10, '2026-08-01 11:00:00')");
|
||||
}
|
||||
assertText(connection, "SELECT send_status FROM t_report WHERE id = 2", "unsent");
|
||||
assertScalar(connection, """
|
||||
SELECT COUNT(*) FROM t_report
|
||||
WHERE send_status IN ('unknown', 'unsent')
|
||||
AND (sent_at IS NOT NULL OR sent_by_user_id IS NOT NULL OR send_channel IS NOT NULL)
|
||||
""", 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertText(Connection connection, String sql, String expected) throws Exception {
|
||||
try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) {
|
||||
result.next();
|
||||
assertEquals(expected, result.getString(1));
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertScalar(Connection connection, String sql, int expected) throws Exception {
|
||||
try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) {
|
||||
result.next();
|
||||
assertEquals(expected, result.getInt(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -26,11 +26,14 @@ class AdminBusinessEventServiceTest {
|
||||
@InjectMocks private AdminBusinessEventService service;
|
||||
|
||||
@Test
|
||||
void reportFunnelCountsDistinctFactsAndMarksUntrackedStages() {
|
||||
void reportFunnelCountsDistinctFactsAndKeepsOnlyRebookUntracked() {
|
||||
LocalDate date = LocalDate.of(2026, 8, 1);
|
||||
when(businessEventMapper.countDistinctAggregates(
|
||||
eq(10L), eq(BusinessEventService.REPORT_SUBMITTED), any(LocalDateTime.class), any(LocalDateTime.class)
|
||||
)).thenReturn(8L);
|
||||
when(businessEventMapper.countDistinctAggregates(
|
||||
eq(10L), eq(BusinessEventService.REPORT_SENT), any(LocalDateTime.class), any(LocalDateTime.class)
|
||||
)).thenReturn(7L);
|
||||
when(businessEventMapper.countDistinctAggregatesByTypes(
|
||||
eq(10L), any(Collection.class), any(LocalDateTime.class), any(LocalDateTime.class)
|
||||
)).thenReturn(6L);
|
||||
@ -44,9 +47,9 @@ class AdminBusinessEventServiceTest {
|
||||
assertEquals(8L, data.get("reportSubmittedCount"));
|
||||
assertEquals(6L, data.get("reportOpenedCount"));
|
||||
assertEquals(3L, data.get("leadSubmittedCount"));
|
||||
assertNull(data.get("reportSentCount"));
|
||||
assertEquals(7L, data.get("reportSentCount"));
|
||||
assertNull(data.get("rebookCreatedCount"));
|
||||
assertFalse((Boolean) data.get("reportSentTrackingReady"));
|
||||
assertEquals(true, data.get("reportSentTrackingReady"));
|
||||
assertFalse((Boolean) data.get("rebookTrackingReady"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AdminWorkbenchServiceTest {
|
||||
@ -65,6 +66,12 @@ class AdminWorkbenchServiceTest {
|
||||
when(reportMapper.findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc(
|
||||
10L, List.of("failed", "processing")))
|
||||
.thenReturn(List.of(stale, fresh, failed));
|
||||
Report unsent = report(23L, "报告待发送", "洗护", null, now.minusMinutes(2));
|
||||
unsent.setCreateTime(now.minusMinutes(2));
|
||||
unsent.setSendStatus(Report.SEND_STATUS_UNSENT);
|
||||
when(reportMapper.findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeAsc(
|
||||
10L, Report.SEND_STATUS_UNSENT))
|
||||
.thenReturn(List.of(unsent));
|
||||
|
||||
ReportLead noDate = lead(30L, "无日期线索", null, "pending");
|
||||
ReportLead due = lead(31L, "今日回访", LocalDate.of(2026, 8, 1), "pending");
|
||||
@ -82,23 +89,28 @@ class AdminWorkbenchServiceTest {
|
||||
assertEquals(1L, data.metrics().inServiceCount());
|
||||
assertEquals(1L, data.metrics().upcomingTodayCount());
|
||||
assertEquals(2L, data.metrics().highlightAnomalyCount());
|
||||
assertEquals(1L, data.metrics().unsentReportCount());
|
||||
assertEquals(2L, data.metrics().dueFollowUpCount());
|
||||
assertEquals(List.of(
|
||||
AdminWorkbenchService.OVERDUE_APPOINTMENT,
|
||||
AdminWorkbenchService.IN_SERVICE,
|
||||
AdminWorkbenchService.UPCOMING_TODAY,
|
||||
AdminWorkbenchService.HIGHLIGHT_ANOMALY,
|
||||
AdminWorkbenchService.UNSENT_REPORT,
|
||||
AdminWorkbenchService.FOLLOW_UP_DUE
|
||||
),
|
||||
data.actions().stream().map(AdminWorkbenchService.ActionGroup::kind).toList());
|
||||
assertEquals("迟到的豆豆", data.actions().get(0).items().get(0).petName());
|
||||
assertEquals("report", data.actions().get(3).items().get(0).resourceType());
|
||||
assertEquals("unsent", data.actions().get(4).items().get(0).status());
|
||||
assertEquals(2L, data.reportFunnel().get("reportSubmittedCount"));
|
||||
|
||||
String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(data);
|
||||
assertFalse(json.contains("13800000000"));
|
||||
assertFalse(json.contains("report-secret-token"));
|
||||
assertFalse(json.contains("/private/video.mp4"));
|
||||
verify(reportMapper).findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeAsc(
|
||||
10L, Report.SEND_STATUS_UNSENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -140,4 +140,28 @@ class BusinessEventServiceTest {
|
||||
assertEquals(50L, result.getId());
|
||||
verify(businessEventMapper, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportSentIsIdempotentAndStoresOnlyChannelMetadata() {
|
||||
Report report = new Report();
|
||||
report.setId(9L);
|
||||
report.setStoreId(10L);
|
||||
report.setCustomerUserId(88L);
|
||||
report.setSentByUserId(7L);
|
||||
report.setSentAt(LocalDateTime.of(2026, 8, 1, 10, 0));
|
||||
report.setSendChannel("wechat");
|
||||
when(storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(10L, 88L))
|
||||
.thenReturn(Optional.empty());
|
||||
when(businessEventMapper.findFirstByIdempotencyKey("report_sent:9"))
|
||||
.thenReturn(Optional.empty());
|
||||
when(businessEventMapper.save(any(BusinessEvent.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
BusinessEvent event = service.recordReportSent(report, "staff");
|
||||
|
||||
assertEquals(BusinessEventService.REPORT_SENT, event.getEventType());
|
||||
assertEquals("report_sent:9", event.getIdempotencyKey());
|
||||
assertEquals("{\"channel\":\"wechat\"}", event.getMetadataJson());
|
||||
assertFalse(event.getMetadataJson().contains("token"));
|
||||
assertEquals(7L, event.getActorUserId());
|
||||
}
|
||||
}
|
||||
|
||||
@ -100,6 +100,10 @@ class ReportServiceTest {
|
||||
assertEquals(5L, created.getUserId());
|
||||
assertEquals("技师甲", created.getStaffName());
|
||||
assertNotNull(created.getReportToken());
|
||||
assertEquals(Report.SEND_STATUS_UNSENT, created.getSendStatus());
|
||||
assertNull(created.getSentAt());
|
||||
assertNull(created.getSentByUserId());
|
||||
assertNull(created.getSendChannel());
|
||||
verify(businessEventService).recordAppointmentStatusChanged(
|
||||
eq(appt), eq("doing"), eq("done"), eq(5L), isNull(), any(LocalDateTime.class)
|
||||
);
|
||||
@ -166,4 +170,85 @@ class ReportServiceTest {
|
||||
verify(reportMapper).findForCustomer(88L);
|
||||
verify(reportMapper, never()).findByUserIdAndDeletedFalseOrderByCreateTimeDesc(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void storeListCanFilterByCanonicalSendStatus() {
|
||||
when(reportMapper.findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeDesc(
|
||||
10L, Report.SEND_STATUS_UNSENT)).thenReturn(List.of());
|
||||
|
||||
assertTrue(reportService.list(10L, null, Report.SEND_STATUS_UNSENT).isEmpty());
|
||||
|
||||
verify(reportMapper).findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeDesc(
|
||||
10L, Report.SEND_STATUS_UNSENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstConfirmSentPersistsImmutableReceiptAndEvent() {
|
||||
Report report = new Report();
|
||||
report.setId(9L);
|
||||
report.setStoreId(10L);
|
||||
report.setCustomerUserId(88L);
|
||||
report.setSendStatus(Report.SEND_STATUS_UNSENT);
|
||||
LocalDateTime priorUpdateTime = LocalDateTime.of(2026, 8, 1, 9, 55);
|
||||
report.setUpdateTime(priorUpdateTime);
|
||||
when(reportMapper.findByIdAndDeletedFalseForUpdate(9L)).thenReturn(Optional.of(report));
|
||||
when(reportMapper.save(report)).thenReturn(report);
|
||||
|
||||
ReportService.ConfirmSentOutcome outcome = reportService.confirmSent(
|
||||
9L, 10L, 7L, "staff", "wechat"
|
||||
);
|
||||
|
||||
assertNotNull(outcome);
|
||||
assertFalse(outcome.alreadyConfirmed());
|
||||
assertEquals(Report.SEND_STATUS_SENT, report.getSendStatus());
|
||||
assertEquals(7L, report.getSentByUserId());
|
||||
assertEquals("wechat", report.getSendChannel());
|
||||
assertNotNull(report.getSentAt());
|
||||
assertEquals(priorUpdateTime, report.getUpdateTime(), "发送回执不得重置成片处理超时计时");
|
||||
verify(reportMapper).save(report);
|
||||
verify(businessEventService).recordReportSent(report, "staff");
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedConfirmSentIsIdempotentAndPreservesFirstReceipt() {
|
||||
LocalDateTime firstSentAt = LocalDateTime.of(2026, 8, 1, 10, 0);
|
||||
Report report = new Report();
|
||||
report.setId(9L);
|
||||
report.setStoreId(10L);
|
||||
report.setSendStatus(Report.SEND_STATUS_SENT);
|
||||
report.setSentAt(firstSentAt);
|
||||
report.setSentByUserId(7L);
|
||||
report.setSendChannel("wechat");
|
||||
when(reportMapper.findByIdAndDeletedFalseForUpdate(9L)).thenReturn(Optional.of(report));
|
||||
|
||||
ReportService.ConfirmSentOutcome outcome = reportService.confirmSent(
|
||||
9L, 10L, 8L, "boss", "qr"
|
||||
);
|
||||
|
||||
assertTrue(outcome.alreadyConfirmed());
|
||||
assertEquals(firstSentAt, report.getSentAt());
|
||||
assertEquals(7L, report.getSentByUserId());
|
||||
assertEquals("wechat", report.getSendChannel());
|
||||
verify(reportMapper, never()).save(any(Report.class));
|
||||
verify(businessEventService, never()).recordReportSent(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmSentRejectsCrossStore() {
|
||||
Report report = new Report();
|
||||
report.setId(9L);
|
||||
report.setStoreId(20L);
|
||||
when(reportMapper.findByIdAndDeletedFalseForUpdate(9L)).thenReturn(Optional.of(report));
|
||||
|
||||
assertThrows(SecurityException.class,
|
||||
() -> reportService.confirmSent(9L, 10L, 7L, "staff", "wechat"));
|
||||
verify(reportMapper, never()).save(any(Report.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmSentRejectsUnknownChannel() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> reportService.confirmSent(9L, 10L, 7L, "staff", "clipboard"));
|
||||
verify(reportMapper, never()).findByIdAndDeletedFalseForUpdate(anyLong());
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user