feat: track explicit report delivery

This commit is contained in:
malei 2026-08-02 00:15:00 +08:00
parent 0ad8a4d596
commit 1887864d38
18 changed files with 551 additions and 12 deletions

View File

@ -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 与上线后只读冒烟全部通过

View 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);

View File

@ -16,4 +16,6 @@
`20260801_create_booking_capacity.sql` 必须在业务事件迁移之后执行:为服务项目、预约快照和手动占用补充时长,为门店补充并发接待数。历史预约按服务名称采用稳定默认值回填;脚本末尾四个验证计数必须为 0。
`20260801_create_report_send_status.sql` 必须在预约容量迁移之后执行:新增报告显式确认发送状态与首次确认回执。历史报告统一标记为 `unknown`,不得推测为未发送或已发送;迁移后的新报告默认 `unsent`。脚本末尾三个验证计数必须为 0。
正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。

View File

@ -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)
""")
);

View File

@ -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();

View File

@ -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;

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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) {
}

View File

@ -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,

View File

@ -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) {

View File

@ -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()
);

View File

@ -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"));
}

View File

@ -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));
}
}
}

View File

@ -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"));
}
}

View File

@ -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

View File

@ -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());
}
}

View File

@ -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());
}
}