diff --git a/README.md b/README.md index d3ac1ff..ee48de3 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,12 @@ curl http://localhost:8080/api/store/list ``` 脚本把历史门店开通状态标为不可推断的 `unknown`,建立只保存 token SHA-256 的一次性员工邀请和不可变低敏审计表。末尾六项状态/回执一致性验证必须全部为 0;不得恢复 legacy 永久邀请码注册。 +10. **StoreCustomer 合并别名与事实时间线连续性**:门店开通迁移验证完成后执行: + ```bash + mysql -h -u -p petstore < db/migrations/20260802_create_store_customer_timeline.sql + ``` + 脚本为被合并的软删除客户投影增加同店 canonical 指针。既有 `BusinessEvent` 保持不可变,时间线通过主档与别名共同读取历史事实。末尾两项别名状态/目标验证必须全部为 0。 + ### production profile ```bash @@ -128,14 +134,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` 和 24 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。 +脚本先校验生产配置、上传目录、Java 与 FFmpeg,再启动无 Web 的 `production` profile,执行 JPA schema `validate` 和 26 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。 ## 测试 diff --git a/db/migrations/20260802_create_store_customer_timeline.sql b/db/migrations/20260802_create_store_customer_timeline.sql new file mode 100644 index 0000000..314382f --- /dev/null +++ b/db/migrations/20260802_create_store_customer_timeline.sql @@ -0,0 +1,26 @@ +-- Petstore Phase 0:StoreCustomer 合并别名与事实时间线连续性 +-- 前置:20260802_create_store_onboarding.sql 已执行。 +-- 语义:BusinessEvent 不改写;被合并的软删除主档通过同店 canonical 指针保留历史事实归属。 + +ALTER TABLE t_store_customer + ADD COLUMN merged_into_store_customer_id BIGINT NULL + COMMENT '软删除客户投影并入的同店 canonical StoreCustomer ID'; + +CREATE INDEX idx_store_customer_merge + ON t_store_customer (store_id, merged_into_store_customer_id); + +-- 验证:以下两项必须全部为 0。 +SELECT COUNT(*) AS invalid_store_customer_alias_state_count +FROM t_store_customer sc +WHERE (sc.deleted = 0 AND sc.merged_into_store_customer_id IS NOT NULL) + OR sc.id = sc.merged_into_store_customer_id; + +SELECT COUNT(*) AS invalid_store_customer_alias_target_count +FROM t_store_customer sc_alias +LEFT JOIN t_store_customer canonical + ON canonical.id = sc_alias.merged_into_store_customer_id + AND canonical.store_id = sc_alias.store_id + AND canonical.deleted = 0 + AND canonical.merged_into_store_customer_id IS NULL +WHERE sc_alias.merged_into_store_customer_id IS NOT NULL + AND canonical.id IS NULL; diff --git a/db/migrations/README.md b/db/migrations/README.md index 69b03a6..4c374d7 100644 --- a/db/migrations/README.md +++ b/db/migrations/README.md @@ -20,4 +20,6 @@ `20260802_create_store_onboarding.sql` 必须在报告发送状态迁移之后执行:为历史门店写入不可推断的 `unknown` 开通状态,建立只存 token 摘要的限时员工邀请与低敏不可变操作审计。脚本末尾六个验证计数必须为 0;不得用 legacy `invite_code` 恢复公开员工注册。 +`20260802_create_store_customer_timeline.sql` 必须在门店开通迁移之后执行:为被合并的软删除 StoreCustomer 投影增加同店 canonical 指针。既有 BusinessEvent 不改写,事实时间线同时读取 canonical 与别名的历史事件;脚本末尾两个验证计数必须为 0。 + 正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。 diff --git a/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java b/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java index fcaf3ee..b908385 100644 --- a/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java +++ b/src/main/java/com/petstore/config/ProductionDatabasePreflightRunner.java @@ -61,6 +61,21 @@ public class ProductionDatabasePreflightRunner implements ApplicationRunner { JOIN t_user u ON u.id = sc.customer_user_id WHERE sc.deleted = 0 AND (u.deleted <> 0 OR u.role <> 'customer') """), + new Invariant("invalid_store_customer_alias_state", """ + SELECT COUNT(*) FROM t_store_customer sc + WHERE (sc.deleted = 0 AND sc.merged_into_store_customer_id IS NOT NULL) + OR sc.id = sc.merged_into_store_customer_id + """), + new Invariant("invalid_store_customer_alias_target", """ + SELECT COUNT(*) FROM t_store_customer sc_alias + LEFT JOIN t_store_customer canonical + ON canonical.id = sc_alias.merged_into_store_customer_id + AND canonical.store_id = sc_alias.store_id + AND canonical.deleted = 0 + AND canonical.merged_into_store_customer_id IS NULL + WHERE sc_alias.merged_into_store_customer_id IS NOT NULL + AND canonical.id IS NULL + """), new Invariant("appointments_without_created_event", """ SELECT COUNT(*) FROM t_appointment a LEFT JOIN t_business_event e diff --git a/src/main/java/com/petstore/controller/AdminServiceCustomerController.java b/src/main/java/com/petstore/controller/AdminServiceCustomerController.java index 91e3cd3..9e8beb1 100644 --- a/src/main/java/com/petstore/controller/AdminServiceCustomerController.java +++ b/src/main/java/com/petstore/controller/AdminServiceCustomerController.java @@ -3,6 +3,8 @@ package com.petstore.controller; import com.petstore.auth.CurrentUser; import com.petstore.auth.CurrentUserContext; import com.petstore.service.AdminServiceCustomerService; +import com.petstore.service.FlowException; +import com.petstore.service.StoreCustomerTimelineService; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.CrossOrigin; @@ -25,6 +27,7 @@ import java.util.Map; public class AdminServiceCustomerController { private final AdminServiceCustomerService adminServiceCustomerService; + private final StoreCustomerTimelineService storeCustomerTimelineService; @GetMapping("/service-customers") public Map listServiceCustomers( @@ -52,4 +55,27 @@ public class AdminServiceCustomerController { ); return Map.of("code", 200, "data", data); } + + /** 本店客户的低敏业务事实时间线;storeId 始终从会话派生。 */ + @GetMapping("/service-customers/timeline") + public Map timeline( + @RequestParam Long storeCustomerId, + @RequestParam(required = false, defaultValue = "1") int page, + @RequestParam(required = false, defaultValue = "20") int pageSize + ) { + CurrentUser u = CurrentUserContext.require(); + if (!u.isStoreUser() || u.storeId() == null) { + return Map.of("code", 403, "message", "仅门店员工可查看客户时间线", "bizCode", "FORBIDDEN"); + } + try { + return Map.of( + "code", 200, + "data", storeCustomerTimelineService.timeline( + u.storeId(), storeCustomerId, page, pageSize + ) + ); + } catch (FlowException e) { + return Map.of("code", e.code(), "message", e.getMessage(), "bizCode", e.bizCode()); + } + } } diff --git a/src/main/java/com/petstore/entity/StoreCustomer.java b/src/main/java/com/petstore/entity/StoreCustomer.java index dfccc03..2aee8cb 100644 --- a/src/main/java/com/petstore/entity/StoreCustomer.java +++ b/src/main/java/com/petstore/entity/StoreCustomer.java @@ -34,7 +34,8 @@ import java.time.LocalDateTime; }, indexes = { @Index(name = "idx_store_customer_active_update", columnList = "store_id,deleted,update_time"), - @Index(name = "idx_store_customer_last_contact", columnList = "store_id,last_contact_at") + @Index(name = "idx_store_customer_last_contact", columnList = "store_id,last_contact_at"), + @Index(name = "idx_store_customer_merge", columnList = "store_id,merged_into_store_customer_id") } ) public class StoreCustomer { @@ -67,6 +68,14 @@ public class StoreCustomer { @Column(name = "last_contact_at") private LocalDateTime lastContactAt; + /** + * 软删除投影并入的同店 canonical StoreCustomer。 + * + *

BusinessEvent 保持不可变并继续引用旧投影;读取客户时间线时通过此指针合并历史事实。 + */ + @Column(name = "merged_into_store_customer_id") + private Long mergedIntoStoreCustomerId; + @Column(name = "create_time", nullable = false) private LocalDateTime createTime; diff --git a/src/main/java/com/petstore/mapper/BusinessEventMapper.java b/src/main/java/com/petstore/mapper/BusinessEventMapper.java index 8662b3b..030f993 100644 --- a/src/main/java/com/petstore/mapper/BusinessEventMapper.java +++ b/src/main/java/com/petstore/mapper/BusinessEventMapper.java @@ -2,6 +2,8 @@ package com.petstore.mapper; import com.petstore.entity.BusinessEvent; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,6 +18,12 @@ public interface BusinessEventMapper extends JpaRepository List findByStoreCustomerIdOrderByOccurredAtDesc(Long storeCustomerId); + Page findByStoreIdAndStoreCustomerIdIn( + Long storeId, + Collection storeCustomerIds, + Pageable pageable + ); + @Query(""" select count(distinct e.aggregateId) from BusinessEvent e diff --git a/src/main/java/com/petstore/mapper/StoreCustomerMapper.java b/src/main/java/com/petstore/mapper/StoreCustomerMapper.java index 79e3b1b..1811e48 100644 --- a/src/main/java/com/petstore/mapper/StoreCustomerMapper.java +++ b/src/main/java/com/petstore/mapper/StoreCustomerMapper.java @@ -13,4 +13,10 @@ public interface StoreCustomerMapper extends JpaRepository Optional findFirstByStoreIdAndCustomerUserId(Long storeId, Long customerUserId); Optional findFirstByStoreIdAndPhone(Long storeId, String phone); + + Optional findFirstByIdAndStoreIdAndDeletedFalse(Long id, Long storeId); + + Optional findFirstByIdAndStoreId(Long id, Long storeId); + + List findByStoreIdAndMergedIntoStoreCustomerId(Long storeId, Long mergedIntoStoreCustomerId); } diff --git a/src/main/java/com/petstore/service/StoreCustomerService.java b/src/main/java/com/petstore/service/StoreCustomerService.java index 3375622..19ecb52 100644 --- a/src/main/java/com/petstore/service/StoreCustomerService.java +++ b/src/main/java/com/petstore/service/StoreCustomerService.java @@ -109,6 +109,7 @@ public class StoreCustomerService { } mergeContactWindow(byUser, byPhone); byPhone.setPhone(null); + byPhone.setMergedIntoStoreCustomerId(byUser.getId()); byPhone.setDeleted(true); byPhone.setUpdateTime(LocalDateTime.now()); storeCustomerMapper.saveAndFlush(byPhone); @@ -126,6 +127,7 @@ public class StoreCustomerService { target.setCreateTime(LocalDateTime.now()); } target.setDeleted(false); + target.setMergedIntoStoreCustomerId(null); if (customerUserId != null) { target.setCustomerUserId(customerUserId); } diff --git a/src/main/java/com/petstore/service/StoreCustomerTimelineService.java b/src/main/java/com/petstore/service/StoreCustomerTimelineService.java new file mode 100644 index 0000000..5eba529 --- /dev/null +++ b/src/main/java/com/petstore/service/StoreCustomerTimelineService.java @@ -0,0 +1,357 @@ +package com.petstore.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.petstore.entity.Appointment; +import com.petstore.entity.BusinessEvent; +import com.petstore.entity.Report; +import com.petstore.entity.ReportLead; +import com.petstore.entity.StoreCustomer; +import com.petstore.entity.User; +import com.petstore.mapper.AppointmentMapper; +import com.petstore.mapper.BusinessEventMapper; +import com.petstore.mapper.ReportLeadMapper; +import com.petstore.mapper.ReportMapper; +import com.petstore.mapper.StoreCustomerMapper; +import com.petstore.mapper.UserMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** StoreCustomer 稳定主档下的低敏、只读业务事实时间线。 */ +@Service +@RequiredArgsConstructor +public class StoreCustomerTimelineService { + + private static final TypeReference> METADATA_TYPE = new TypeReference<>() {}; + + private final StoreCustomerMapper storeCustomerMapper; + private final BusinessEventMapper businessEventMapper; + private final AppointmentMapper appointmentMapper; + private final ReportMapper reportMapper; + private final ReportLeadMapper reportLeadMapper; + private final UserMapper userMapper; + private final ObjectMapper objectMapper; + + public Map timeline(Long storeId, Long storeCustomerId, int page, int pageSize) { + if (storeId == null || storeCustomerId == null) { + throw new FlowException(400, "INVALID_CUSTOMER_TIMELINE", "缺少门店客户参数"); + } + StoreCustomer requestedCustomer = storeCustomerMapper + .findFirstByIdAndStoreId(storeCustomerId, storeId) + .orElseThrow(() -> new FlowException(404, "STORE_CUSTOMER_NOT_FOUND", "客户不存在")); + StoreCustomer customer = resolveCanonicalCustomer(storeId, requestedCustomer); + Set timelineCustomerIds = new LinkedHashSet<>(); + timelineCustomerIds.add(customer.getId()); + storeCustomerMapper.findByStoreIdAndMergedIntoStoreCustomerId(storeId, customer.getId()).stream() + .map(StoreCustomer::getId) + .filter(Objects::nonNull) + .forEach(timelineCustomerIds::add); + + int safePage = Math.max(page, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + PageRequest pageable = PageRequest.of( + safePage - 1, + safeSize, + Sort.by(Sort.Order.desc("occurredAt"), Sort.Order.desc("id")) + ); + Page eventPage = businessEventMapper + .findByStoreIdAndStoreCustomerIdIn(storeId, timelineCustomerIds, pageable); + List events = eventPage.getContent(); + + Map appointments = loadAppointments(storeId, events); + Map reports = loadReports(storeId, events); + Map leads = loadLeads(storeId, events); + Map actors = loadActors(events); + User customerUser = customer.getCustomerUserId() == null ? null + : userMapper.findByIdAndDeletedFalse(customer.getCustomerUserId()).orElse(null); + + List> items = events.stream() + .map(event -> toTimelineItem( + event, + "appointment".equals(event.getAggregateType()) + ? appointments.get(event.getAggregateId()) : null, + "report".equals(event.getAggregateType()) + ? reports.get(event.getAggregateId()) : null, + "report_lead".equals(event.getAggregateType()) + ? leads.get(event.getAggregateId()) : null, + event.getActorUserId() == null ? null : actors.get(event.getActorUserId()) + )) + .toList(); + + Map result = new LinkedHashMap<>(); + result.put("customer", customerView(customer, customerUser)); + result.put("total", eventPage.getTotalElements()); + result.put("page", safePage); + result.put("pageSize", safeSize); + result.put("hasMore", safePage < eventPage.getTotalPages()); + result.put("list", items); + return result; + } + + private StoreCustomer resolveCanonicalCustomer(Long storeId, StoreCustomer requestedCustomer) { + if (!Boolean.TRUE.equals(requestedCustomer.getDeleted())) { + return requestedCustomer; + } + Long canonicalId = requestedCustomer.getMergedIntoStoreCustomerId(); + if (canonicalId == null) { + throw new FlowException(404, "STORE_CUSTOMER_NOT_FOUND", "客户不存在"); + } + return storeCustomerMapper + .findFirstByIdAndStoreIdAndDeletedFalse(canonicalId, storeId) + .orElseThrow(() -> new FlowException(404, "STORE_CUSTOMER_NOT_FOUND", "客户不存在")); + } + + private Map loadAppointments(Long storeId, List events) { + Set ids = aggregateIds(events, "appointment"); + if (ids.isEmpty()) return Map.of(); + return appointmentMapper.findAllById(ids).stream() + .filter(a -> Objects.equals(storeId, a.getStoreId()) && !Boolean.TRUE.equals(a.getDeleted())) + .collect(Collectors.toMap(Appointment::getId, Function.identity(), (a, b) -> a)); + } + + private Map loadReports(Long storeId, List events) { + Set ids = aggregateIds(events, "report"); + if (ids.isEmpty()) return Map.of(); + return reportMapper.findAllById(ids).stream() + .filter(r -> Objects.equals(storeId, r.getStoreId()) && !Boolean.TRUE.equals(r.getDeleted())) + .collect(Collectors.toMap(Report::getId, Function.identity(), (a, b) -> a)); + } + + private Map loadLeads(Long storeId, List events) { + Set ids = aggregateIds(events, "report_lead"); + if (ids.isEmpty()) return Map.of(); + return reportLeadMapper.findAllById(ids).stream() + .filter(l -> Objects.equals(storeId, l.getStoreId())) + .collect(Collectors.toMap(ReportLead::getId, Function.identity(), (a, b) -> a)); + } + + private Map loadActors(List events) { + Set ids = events.stream() + .map(BusinessEvent::getActorUserId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (ids.isEmpty()) return Map.of(); + Map result = new HashMap<>(); + for (User user : userMapper.findAllById(ids)) { + result.put(user.getId(), user); + } + return result; + } + + private static Set aggregateIds(List events, String aggregateType) { + return events.stream() + .filter(e -> aggregateType.equals(e.getAggregateType())) + .map(BusinessEvent::getAggregateId) + .filter(Objects::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private Map toTimelineItem( + BusinessEvent event, + Appointment appointment, + Report report, + ReportLead lead, + User actor + ) { + Map metadata = safeMetadata(event.getMetadataJson()); + Map item = new LinkedHashMap<>(); + item.put("eventId", event.getEventId()); + item.put("eventType", event.getEventType()); + item.put("occurredAt", iso(event.getOccurredAt())); + item.put("title", title(event.getEventType(), metadata)); + item.put("description", description(event, appointment, report, lead, metadata)); + item.put("source", event.getSource()); + item.put("actorRole", event.getActorRole()); + item.put("actorName", safeActorName(actor)); + if (appointment != null && "appointment".equals(event.getAggregateType())) { + item.put("appointment", appointmentView(appointment)); + } + if (report != null && "report".equals(event.getAggregateType())) { + item.put("report", reportView(report)); + } + if (lead != null && "report_lead".equals(event.getAggregateType())) { + item.put("lead", leadView(lead)); + } + return item; + } + + private static Map customerView(StoreCustomer customer, User user) { + Map view = new LinkedHashMap<>(); + view.put("storeCustomerId", customer.getId()); + view.put("displayName", firstNonBlank( + customer.getDisplayName(), user == null ? null : user.getName(), "客户" + )); + view.put("phoneMasked", AdminServiceCustomerService.maskPhone(firstNonBlank( + customer.getPhone(), user == null ? null : user.getPhone() + ))); + view.put("originSource", customer.getSource()); + view.put("firstContactAt", iso(customer.getFirstContactAt())); + view.put("lastContactAt", iso(customer.getLastContactAt())); + return view; + } + + private static Map appointmentView(Appointment appointment) { + Map view = new LinkedHashMap<>(); + view.put("id", appointment.getId()); + view.put("appointmentTime", iso(appointment.getAppointmentTime())); + view.put("endTime", iso(appointment.getEndTime())); + view.put("petName", appointment.getPetName()); + view.put("petType", appointment.getPetType()); + view.put("serviceType", appointment.getServiceType()); + view.put("durationMinutes", appointment.resolvedDurationMinutes()); + view.put("currentStatus", appointment.getStatus()); + return view; + } + + private static Map reportView(Report report) { + Map view = new LinkedHashMap<>(); + view.put("id", report.getId()); + view.put("appointmentId", report.getAppointmentId()); + view.put("petName", report.getPetName()); + view.put("serviceType", report.getServiceType()); + view.put("sendStatus", report.getSendStatus()); + view.put("sentAt", iso(report.getSentAt())); + view.put("sendChannel", report.getSendChannel()); + return view; + } + + private static Map leadView(ReportLead lead) { + Map view = new LinkedHashMap<>(); + view.put("id", lead.getId()); + view.put("reportId", lead.getReportId()); + view.put("petName", lead.getPetName()); + view.put("serviceType", lead.getServiceType()); + view.put("remindDate", lead.getRemindDate() == null ? null : lead.getRemindDate().toString()); + view.put("remindStatus", lead.getRemindStatus()); + return view; + } + + private static String title(String eventType, Map metadata) { + if (BusinessEventService.APPOINTMENT_CREATED.equals(eventType)) return "预约已创建"; + if (BusinessEventService.APPOINTMENT_STATUS_CHANGED.equals(eventType)) { + return switch (text(metadata.get("toStatus"))) { + case "cancel" -> "预约已取消"; + case "doing" -> "预约进入服务中"; + case "done" -> "预约已完成"; + default -> "预约状态已更新"; + }; + } + if (BusinessEventService.SERVICE_STARTED.equals(eventType)) return "开始服务"; + if (BusinessEventService.SERVICE_COMPLETED.equals(eventType)) return "服务完成"; + if (BusinessEventService.REPORT_SUBMITTED.equals(eventType)) return "服务报告已提交"; + if (BusinessEventService.REPORT_SENT.equals(eventType)) return "报告已确认发送"; + if (BusinessEventService.REPORT_OPENED.equals(eventType)) return "宠主首次打开报告"; + if (BusinessEventService.REPORT_REOPENED.equals(eventType)) return "宠主再次打开报告"; + if (BusinessEventService.LEAD_SUBMITTED.equals(eventType)) return "宠主提交下次服务提醒"; + return "客户业务动态"; + } + + private static String description( + BusinessEvent event, + Appointment appointment, + Report report, + ReportLead lead, + Map metadata + ) { + List parts = new ArrayList<>(); + if (appointment != null) { + add(parts, appointment.getPetName()); + add(parts, appointment.getServiceType()); + add(parts, iso(appointment.getAppointmentTime())); + } else if (report != null) { + add(parts, report.getPetName()); + add(parts, report.getServiceType()); + } else if (lead != null) { + add(parts, lead.getPetName()); + add(parts, lead.getServiceType()); + if (lead.getRemindDate() != null) add(parts, "建议 " + lead.getRemindDate()); + } + if (BusinessEventService.APPOINTMENT_STATUS_CHANGED.equals(event.getEventType())) { + String from = text(metadata.get("fromStatus")); + String to = text(metadata.get("toStatus")); + if (!from.isBlank() || !to.isBlank()) add(parts, statusLabel(from) + " → " + statusLabel(to)); + } else if (BusinessEventService.REPORT_SENT.equals(event.getEventType())) { + add(parts, channelLabel(text(metadata.get("channel")))); + } else if (BusinessEventService.LEAD_SUBMITTED.equals(event.getEventType()) + && Boolean.TRUE.equals(metadata.get("repeatSubmit"))) { + add(parts, "重复提交已合并"); + } + return parts.isEmpty() ? "已记录业务事实" : String.join(" · ", parts); + } + + private Map safeMetadata(String json) { + if (json == null || json.isBlank()) return Map.of(); + try { + Map parsed = objectMapper.readValue(json, METADATA_TYPE); + Map safe = new LinkedHashMap<>(); + for (String key : List.of("fromStatus", "toStatus", "channel", "repeatSubmit")) { + Object value = parsed.get(key); + if (value instanceof String || value instanceof Boolean || value instanceof Number) { + safe.put(key, value); + } + } + return safe; + } catch (Exception ignored) { + return Map.of(); + } + } + + private static String safeActorName(User actor) { + return actor == null ? null : firstNonBlank(actor.getName()); + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) return value.trim(); + } + return null; + } + + private static void add(List parts, String value) { + if (value != null && !value.isBlank()) parts.add(value); + } + + private static String iso(LocalDateTime value) { + return value == null ? null : value.toString(); + } + + private static String text(Object value) { + return value == null ? "" : value.toString().trim().toLowerCase(Locale.ROOT); + } + + private static String statusLabel(String status) { + return switch (status) { + case "new" -> "待开始"; + case "doing" -> "服务中"; + case "done" -> "已完成"; + case "cancel" -> "已取消"; + case "unknown", "" -> "未知"; + default -> status; + }; + } + + private static String channelLabel(String channel) { + return switch (channel) { + case "wechat" -> "微信消息"; + case "qr" -> "当面扫码"; + case "other" -> "其他方式"; + default -> channel.isBlank() ? null : channel; + }; + } +} diff --git a/src/test/java/com/petstore/controller/AdminServiceCustomerControllerTest.java b/src/test/java/com/petstore/controller/AdminServiceCustomerControllerTest.java index 6470432..aabf6f8 100644 --- a/src/test/java/com/petstore/controller/AdminServiceCustomerControllerTest.java +++ b/src/test/java/com/petstore/controller/AdminServiceCustomerControllerTest.java @@ -3,6 +3,7 @@ package com.petstore.controller; import com.petstore.auth.CurrentUser; import com.petstore.auth.CurrentUserContext; import com.petstore.service.AdminServiceCustomerService; +import com.petstore.service.StoreCustomerTimelineService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,6 +31,9 @@ class AdminServiceCustomerControllerTest { @Mock private AdminServiceCustomerService adminServiceCustomerService; + @Mock + private StoreCustomerTimelineService storeCustomerTimelineService; + @InjectMocks private AdminServiceCustomerController controller; @@ -100,4 +104,32 @@ class AdminServiceCustomerControllerTest { Map summary = (Map) data.get("summary"); assertFalse(summary.containsKey("totalBalance")); } + + @Test + void timelineCustomerForbidden() { + CurrentUserContext.set(new CurrentUser(99L, null, "customer")); + + Map result = controller.timeline(20L, 1, 20); + + assertEquals(403, result.get("code")); + verifyNoInteractions(storeCustomerTimelineService); + } + + @Test + void timelineUsesSessionStoreScope() { + CurrentUserContext.set(new CurrentUser(2L, 10L, "staff")); + when(storeCustomerTimelineService.timeline(10L, 20L, 1, 20)).thenReturn(Map.of( + "customer", Map.of("storeCustomerId", 20L), + "total", 0L, + "page", 1, + "pageSize", 20, + "hasMore", false, + "list", List.of() + )); + + Map result = controller.timeline(20L, 1, 20); + + assertEquals(200, result.get("code")); + verify(storeCustomerTimelineService).timeline(10L, 20L, 1, 20); + } } diff --git a/src/test/java/com/petstore/mapper/StoreCustomerTimelineMigrationTest.java b/src/test/java/com/petstore/mapper/StoreCustomerTimelineMigrationTest.java new file mode 100644 index 0000000..512be88 --- /dev/null +++ b/src/test/java/com/petstore/mapper/StoreCustomerTimelineMigrationTest.java @@ -0,0 +1,61 @@ +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 StoreCustomerTimelineMigrationTest { + + @Test + void migrationAddsCanonicalAliasWithoutRewritingCustomerHistory() throws Exception { + try (Connection connection = DriverManager.getConnection( + "jdbc:h2:mem:store-customer-timeline-migration;MODE=MySQL;DATABASE_TO_LOWER=TRUE", + "sa", + "" + )) { + try (Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE t_store_customer ( + id BIGINT PRIMARY KEY, + store_id BIGINT NOT NULL, + deleted TINYINT NOT NULL DEFAULT 0 + ) + """); + } + + try (Reader reader = Files.newBufferedReader( + Path.of("db/migrations/20260802_create_store_customer_timeline.sql") + )) { + RunScript.execute(connection, reader); + } + + try (Statement statement = connection.createStatement()) { + statement.execute("INSERT INTO t_store_customer VALUES (50, 10, 0, NULL)"); + statement.execute("INSERT INTO t_store_customer VALUES (51, 10, 1, 50)"); + try (ResultSet rows = statement.executeQuery(""" + SELECT COUNT(*) AS invalid_count + FROM t_store_customer sc_alias + LEFT JOIN t_store_customer canonical + ON canonical.id = sc_alias.merged_into_store_customer_id + AND canonical.store_id = sc_alias.store_id + AND canonical.deleted = 0 + AND canonical.merged_into_store_customer_id IS NULL + WHERE sc_alias.merged_into_store_customer_id IS NOT NULL + AND canonical.id IS NULL + """)) { + rows.next(); + assertEquals(0L, rows.getLong("invalid_count")); + } + } + } + } +} diff --git a/src/test/java/com/petstore/service/StoreCustomerServiceTest.java b/src/test/java/com/petstore/service/StoreCustomerServiceTest.java index 539eb0d..793c94f 100644 --- a/src/test/java/com/petstore/service/StoreCustomerServiceTest.java +++ b/src/test/java/com/petstore/service/StoreCustomerServiceTest.java @@ -125,6 +125,7 @@ class StoreCustomerServiceTest { assertEquals("13800138001", saved.getPhone()); assertTrue(phoneProjection.getDeleted()); assertNull(phoneProjection.getPhone()); + assertEquals(1L, phoneProjection.getMergedIntoStoreCustomerId()); verify(storeCustomerMapper).saveAndFlush(phoneProjection); ArgumentCaptor captor = ArgumentCaptor.forClass(StoreCustomer.class); verify(storeCustomerMapper).save(captor.capture()); diff --git a/src/test/java/com/petstore/service/StoreCustomerTimelineServiceTest.java b/src/test/java/com/petstore/service/StoreCustomerTimelineServiceTest.java new file mode 100644 index 0000000..482e8b1 --- /dev/null +++ b/src/test/java/com/petstore/service/StoreCustomerTimelineServiceTest.java @@ -0,0 +1,239 @@ +package com.petstore.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.petstore.entity.Appointment; +import com.petstore.entity.BusinessEvent; +import com.petstore.entity.Report; +import com.petstore.entity.ReportLead; +import com.petstore.entity.StoreCustomer; +import com.petstore.entity.User; +import com.petstore.mapper.AppointmentMapper; +import com.petstore.mapper.BusinessEventMapper; +import com.petstore.mapper.ReportLeadMapper; +import com.petstore.mapper.ReportMapper; +import com.petstore.mapper.StoreCustomerMapper; +import com.petstore.mapper.UserMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StoreCustomerTimelineServiceTest { + + @Mock private StoreCustomerMapper storeCustomerMapper; + @Mock private BusinessEventMapper businessEventMapper; + @Mock private AppointmentMapper appointmentMapper; + @Mock private ReportMapper reportMapper; + @Mock private ReportLeadMapper reportLeadMapper; + @Mock private UserMapper userMapper; + + private StoreCustomerTimelineService service; + + @BeforeEach + void setUp() { + service = new StoreCustomerTimelineService( + storeCustomerMapper, + businessEventMapper, + appointmentMapper, + reportMapper, + reportLeadMapper, + userMapper, + new ObjectMapper() + ); + } + + @SuppressWarnings("unchecked") + @Test + void timelineReturnsStableOrderedLowSensitivityFacts() { + StoreCustomer customer = customer(); + BusinessEvent canceled = event( + 3L, "event-cancel", BusinessEventService.APPOINTMENT_STATUS_CHANGED, + "appointment", 100L, "{\"fromStatus\":\"new\",\"toStatus\":\"cancel\",\"token\":\"sensitive-token\"}" + ); + BusinessEvent sent = event( + 2L, "event-sent", BusinessEventService.REPORT_SENT, + "report", 200L, "{\"channel\":\"wechat\",\"phone\":\"13800138001\"}" + ); + BusinessEvent leadSubmitted = event( + 1L, "event-lead", BusinessEventService.LEAD_SUBMITTED, + "report_lead", 300L, "{\"repeatSubmit\":true}" + ); + + Appointment appointment = new Appointment(); + appointment.setId(100L); + appointment.setStoreId(10L); + appointment.setPetName("豆豆"); + appointment.setServiceType("洗护"); + appointment.setAppointmentTime(LocalDateTime.of(2026, 8, 1, 10, 0)); + appointment.setDurationMinutes(60); + appointment.setStatus("cancel"); + appointment.setRemark("不得返回的私密备注"); + appointment.setDeleted(false); + + Report report = new Report(); + report.setId(200L); + report.setStoreId(10L); + report.setAppointmentId(100L); + report.setPetName("豆豆"); + report.setServiceType("洗护"); + report.setSendStatus("sent"); + report.setSendChannel("wechat"); + report.setReportToken("不得返回的报告口令"); + report.setDeleted(false); + + ReportLead lead = new ReportLead(); + lead.setId(300L); + lead.setStoreId(10L); + lead.setReportId(200L); + lead.setPetName("豆豆"); + lead.setServiceType("洗护"); + lead.setPhone("13800138001"); + lead.setRemindDate(LocalDate.of(2026, 9, 1)); + lead.setRemindStatus("pending"); + + User customerUser = new User(); + customerUser.setId(88L); + customerUser.setName("豆豆家长"); + customerUser.setPhone("13800138001"); + + when(storeCustomerMapper.findFirstByIdAndStoreId(50L, 10L)) + .thenReturn(Optional.of(customer)); + when(storeCustomerMapper.findByStoreIdAndMergedIntoStoreCustomerId(10L, 50L)) + .thenReturn(List.of()); + when(businessEventMapper.findByStoreIdAndStoreCustomerIdIn(any(), any(), any(Pageable.class))) + .thenAnswer(invocation -> new PageImpl<>( + List.of(canceled, sent, leadSubmitted), invocation.getArgument(2), 3 + )); + when(appointmentMapper.findAllById(any())).thenReturn(List.of(appointment)); + when(reportMapper.findAllById(any())).thenReturn(List.of(report)); + when(reportLeadMapper.findAllById(any())).thenReturn(List.of(lead)); + when(userMapper.findByIdAndDeletedFalse(88L)).thenReturn(Optional.of(customerUser)); + + Map result = service.timeline(10L, 50L, 1, 20); + + assertEquals(3L, result.get("total")); + assertEquals(false, result.get("hasMore")); + Map customerView = (Map) result.get("customer"); + assertEquals("138****8001", customerView.get("phoneMasked")); + List> items = (List>) result.get("list"); + assertEquals("预约已取消", items.get(0).get("title")); + assertEquals("报告已确认发送", items.get(1).get("title")); + assertEquals("宠主提交下次服务提醒", items.get(2).get("title")); + Map appointmentView = (Map) items.get(0).get("appointment"); + assertFalse(appointmentView.containsKey("remark")); + String responseText = result.toString(); + assertFalse(responseText.contains("13800138001")); + assertFalse(responseText.contains("sensitive-token")); + assertFalse(responseText.contains("不得返回")); + } + + @Test + void crossStoreCustomerIsNotDisclosed() { + when(storeCustomerMapper.findFirstByIdAndStoreId(50L, 10L)) + .thenReturn(Optional.empty()); + + FlowException error = assertThrows( + FlowException.class, + () -> service.timeline(10L, 50L, 1, 20) + ); + + assertEquals("STORE_CUSTOMER_NOT_FOUND", error.bizCode()); + verify(businessEventMapper, never()).findByStoreIdAndStoreCustomerIdIn(any(), any(), any()); + } + + @SuppressWarnings("unchecked") + @Test + void mergedProjectionResolvesToCanonicalAndKeepsItsImmutableEvents() { + StoreCustomer canonical = customer(); + StoreCustomer alias = new StoreCustomer(); + alias.setId(51L); + alias.setStoreId(10L); + alias.setMergedIntoStoreCustomerId(50L); + alias.setDeleted(true); + BusinessEvent historicalLead = event( + 4L, "event-historical-lead", BusinessEventService.LEAD_SUBMITTED, + "report_lead", 301L, "{}" + ); + historicalLead.setStoreCustomerId(51L); + + when(storeCustomerMapper.findFirstByIdAndStoreId(51L, 10L)).thenReturn(Optional.of(alias)); + when(storeCustomerMapper.findFirstByIdAndStoreIdAndDeletedFalse(50L, 10L)) + .thenReturn(Optional.of(canonical)); + when(storeCustomerMapper.findByStoreIdAndMergedIntoStoreCustomerId(10L, 50L)) + .thenReturn(List.of(alias)); + when(businessEventMapper.findByStoreIdAndStoreCustomerIdIn(any(), any(), any(Pageable.class))) + .thenAnswer(invocation -> new PageImpl<>( + List.of(historicalLead), invocation.getArgument(2), 1 + )); + when(reportLeadMapper.findAllById(any())).thenReturn(List.of()); + when(userMapper.findByIdAndDeletedFalse(88L)).thenReturn(Optional.empty()); + + Map result = service.timeline(10L, 51L, 1, 20); + + Map customerView = (Map) result.get("customer"); + assertEquals(50L, customerView.get("storeCustomerId")); + assertEquals(1L, result.get("total")); + verify(businessEventMapper).findByStoreIdAndStoreCustomerIdIn( + eq(10L), + argThat(ids -> Set.copyOf(ids).equals(Set.of(50L, 51L))), + any(Pageable.class) + ); + } + + private static StoreCustomer customer() { + StoreCustomer customer = new StoreCustomer(); + customer.setId(50L); + customer.setStoreId(10L); + customer.setCustomerUserId(88L); + customer.setPhone("13800138001"); + customer.setDisplayName("豆豆家长"); + customer.setSource(StoreCustomerService.SOURCE_CUSTOMER_BOOKING); + customer.setFirstContactAt(LocalDateTime.of(2026, 7, 1, 9, 0)); + customer.setLastContactAt(LocalDateTime.of(2026, 8, 1, 10, 0)); + customer.setDeleted(false); + return customer; + } + + private static BusinessEvent event( + Long id, + String eventId, + String eventType, + String aggregateType, + Long aggregateId, + String metadata + ) { + BusinessEvent event = new BusinessEvent(); + event.setId(id); + event.setEventId(eventId); + event.setEventType(eventType); + event.setStoreId(10L); + event.setStoreCustomerId(50L); + event.setAggregateType(aggregateType); + event.setAggregateId(aggregateId); + event.setSource("admin"); + event.setOccurredAt(LocalDateTime.of(2026, 8, 1, 12, 0).plusMinutes(id)); + event.setMetadataJson(metadata); + return event; + } +}