diff --git a/README.md b/README.md index 406583c..742f7d4 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,12 @@ curl http://localhost:8080/api/store/list ``` 脚本新增门店并发容量、服务时长、预约时长快照和连续排班占用。末尾容量、时长和快照验证计数必须全部为 0。 +8. **报告显式确认发送状态**:预约容量验证完成后执行: + ```bash + mysql -h -u -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:// 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 与上线后只读冒烟全部通过 diff --git a/db/migrations/20260801_create_report_send_status.sql b/db/migrations/20260801_create_report_send_status.sql new file mode 100644 index 0000000..9ffc108 --- /dev/null +++ b/db/migrations/20260801_create_report_send_status.sql @@ -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); diff --git a/db/migrations/README.md b/db/migrations/README.md index 9f9cb4f..0485ad8 100644 --- a/db/migrations/README.md +++ b/db/migrations/README.md @@ -16,4 +16,6 @@ `20260801_create_booking_capacity.sql` 必须在业务事件迁移之后执行:为服务项目、预约快照和手动占用补充时长,为门店补充并发接待数。历史预约按服务名称采用稳定默认值回填;脚本末尾四个验证计数必须为 0。 +`20260801_create_report_send_status.sql` 必须在预约容量迁移之后执行:新增报告显式确认发送状态与首次确认回执。历史报告统一标记为 `unknown`,不得推测为未发送或已发送;迁移后的新报告默认 `unsent`。脚本末尾三个验证计数必须为 0。 + 正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。 diff --git a/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java b/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java index 8ade182..ebbe2eb 100644 --- a/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java +++ b/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java @@ -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) """) ); diff --git a/src/main/java/com/petstore/controller/ReportController.java b/src/main/java/com/petstore/controller/ReportController.java index 085f90f..48485a5 100644 --- a/src/main/java/com/petstore/controller/ReportController.java +++ b/src/main/java/com/petstore/controller/ReportController.java @@ -207,6 +207,45 @@ public class ReportController { } } + /** + * 门店员工在真实完成发送后显式确认。复制链接、二维码和预览不会自动调用本接口。 + */ + @PostMapping("/confirm-sent") + public Map confirmSent(@RequestBody Map 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 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 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 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> 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 imgs = r.getImages(); List 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 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 delete(@RequestParam Long id) { CurrentUser u = CurrentUserContext.require(); diff --git a/src/main/java/com/petstore/entity/Report.java b/src/main/java/com/petstore/entity/Report.java index df34e06..8cdb420 100644 --- a/src/main/java/com/petstore/entity/Report.java +++ b/src/main/java/com/petstore/entity/Report.java @@ -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; diff --git a/src/main/java/com/petstore/mapper/ReportMapper.java b/src/main/java/com/petstore/mapper/ReportMapper.java index edec5c8..44d0982 100644 --- a/src/main/java/com/petstore/mapper/ReportMapper.java +++ b/src/main/java/com/petstore/mapper/ReportMapper.java @@ -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 { Optional findFirstByAppointmentIdAndDeletedFalseOrderByCreateTimeDesc(Long appointmentId); @@ -23,6 +25,12 @@ public interface ReportMapper extends JpaRepository { List findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc( Long storeId, List highlightVideoStatuses); + List findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeAsc( + Long storeId, String sendStatus); + + List findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeDesc( + Long storeId, String sendStatus); + /** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */ @Deprecated List findByUserIdAndDeletedFalseOrderByCreateTimeDesc(Long userId); @@ -34,6 +42,7 @@ public interface ReportMapper extends JpaRepository { List findAllByDeletedFalseOrderByCreateTimeDesc(); Page findByStoreIdAndDeletedFalse(Long storeId, Pageable pageable); + Page findByStoreIdAndSendStatusAndDeletedFalse(Long storeId, String sendStatus, Pageable pageable); /** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */ @Deprecated @@ -61,5 +70,9 @@ public interface ReportMapper extends JpaRepository { Page pageForCustomer(@Param("customerUserId") Long customerUserId, Pageable pageable); Page findByDeletedFalse(Pageable pageable); Optional findByIdAndDeletedFalse(Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM Report r WHERE r.id = :id AND r.deleted = false") + Optional findByIdAndDeletedFalseForUpdate(@Param("id") Long id); } diff --git a/src/main/java/com/petstore/service/AdminBusinessEventService.java b/src/main/java/com/petstore/service/AdminBusinessEventService.java index aff25fc..b0c8819 100644 --- a/src/main/java/com/petstore/service/AdminBusinessEventService.java +++ b/src/main/java/com/petstore/service/AdminBusinessEventService.java @@ -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 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; } diff --git a/src/main/java/com/petstore/service/AdminWorkbenchService.java b/src/main/java/com/petstore/service/AdminWorkbenchService.java index 8e957b2..98a71b6 100644 --- a/src/main/java/com/petstore/service/AdminWorkbenchService.java +++ b/src/main/java/com/petstore/service/AdminWorkbenchService.java @@ -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 unsentReports = safeReports( + reportMapper.findByStoreIdAndSendStatusAndDeletedFalseOrderByCreateTimeAsc( + storeId, Report.SEND_STATUS_UNSENT + ) + ); + List 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 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 groups, List reports) { + if (reports.isEmpty()) return; + List 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 groups, List leads) { if (leads.isEmpty()) return; List items = leads.stream() @@ -210,6 +236,7 @@ public class AdminWorkbenchService { long inServiceCount, long upcomingTodayCount, long highlightAnomalyCount, + long unsentReportCount, long dueFollowUpCount) { } diff --git a/src/main/java/com/petstore/service/BusinessEventService.java b/src/main/java/com/petstore/service/BusinessEventService.java index 372dcc2..119b133 100644 --- a/src/main/java/com/petstore/service/BusinessEventService.java +++ b/src/main/java/com/petstore/service/BusinessEventService.java @@ -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, diff --git a/src/main/java/com/petstore/service/ReportService.java b/src/main/java/com/petstore/service/ReportService.java index a1f5392..86b6362 100644 --- a/src/main/java/com/petstore/service/ReportService.java +++ b/src/main/java/com/petstore/service/ReportService.java @@ -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 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 list(Long storeId, Long customerUserId) { + return list(storeId, customerUserId, null); + } + + public List list(Long storeId, Long customerUserId, String sendStatus) { List 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 page(Long storeId, Long customerUserId, int pageNo, int pageSize) { + return page(storeId, customerUserId, null, pageNo, pageSize); + } + + public Page 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) { diff --git a/src/test/java/com/petstore/controller/AdminBusinessEventControllerTest.java b/src/test/java/com/petstore/controller/AdminBusinessEventControllerTest.java index 8042cee..41afd22 100644 --- a/src/test/java/com/petstore/controller/AdminBusinessEventControllerTest.java +++ b/src/test/java/com/petstore/controller/AdminBusinessEventControllerTest.java @@ -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() ); diff --git a/src/test/java/com/petstore/controller/ReportControllerTest.java b/src/test/java/com/petstore/controller/ReportControllerTest.java index 46c96ea..63562e8 100644 --- a/src/test/java/com/petstore/controller/ReportControllerTest.java +++ b/src/test/java/com/petstore/controller/ReportControllerTest.java @@ -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 storeInfo = (Map) 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 result = controller.confirmSent(Map.of("reportId", 9L, "channel", "wechat")); + + assertEquals(200, result.get("code")); + Map data = (Map) 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 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 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 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 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")); } diff --git a/src/test/java/com/petstore/mapper/ReportSendMigrationTest.java b/src/test/java/com/petstore/mapper/ReportSendMigrationTest.java new file mode 100644 index 0000000..63bdd7e --- /dev/null +++ b/src/test/java/com/petstore/mapper/ReportSendMigrationTest.java @@ -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)); + } + } +} diff --git a/src/test/java/com/petstore/service/AdminBusinessEventServiceTest.java b/src/test/java/com/petstore/service/AdminBusinessEventServiceTest.java index e20e786..38d52df 100644 --- a/src/test/java/com/petstore/service/AdminBusinessEventServiceTest.java +++ b/src/test/java/com/petstore/service/AdminBusinessEventServiceTest.java @@ -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")); } } diff --git a/src/test/java/com/petstore/service/AdminWorkbenchServiceTest.java b/src/test/java/com/petstore/service/AdminWorkbenchServiceTest.java index 497a6bd..ce50405 100644 --- a/src/test/java/com/petstore/service/AdminWorkbenchServiceTest.java +++ b/src/test/java/com/petstore/service/AdminWorkbenchServiceTest.java @@ -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 diff --git a/src/test/java/com/petstore/service/BusinessEventServiceTest.java b/src/test/java/com/petstore/service/BusinessEventServiceTest.java index ed491bd..59f422e 100644 --- a/src/test/java/com/petstore/service/BusinessEventServiceTest.java +++ b/src/test/java/com/petstore/service/BusinessEventServiceTest.java @@ -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()); + } } diff --git a/src/test/java/com/petstore/service/ReportServiceTest.java b/src/test/java/com/petstore/service/ReportServiceTest.java index f16db4f..665b80c 100644 --- a/src/test/java/com/petstore/service/ReportServiceTest.java +++ b/src/test/java/com/petstore/service/ReportServiceTest.java @@ -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()); + } }