feat: add store customer fact timeline

This commit is contained in:
malei 2026-08-02 01:22:09 +08:00
parent 207115e18f
commit 07bad405cd
14 changed files with 793 additions and 3 deletions

View File

@ -113,6 +113,12 @@ curl http://localhost:8080/api/store/list
```
脚本把历史门店开通状态标为不可推断的 `unknown`,建立只保存 token SHA-256 的一次性员工邀请和不可变低敏审计表。末尾六项状态/回执一致性验证必须全部为 0不得恢复 legacy 永久邀请码注册。
10. **StoreCustomer 合并别名与事实时间线连续性**:门店开通迁移验证完成后执行:
```bash
mysql -h <host> -u <user> -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 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。
## 测试

View File

@ -0,0 +1,26 @@
-- Petstore Phase 0StoreCustomer 合并别名与事实时间线连续性
-- 前置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;

View File

@ -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 后续任务;接入前不得把本目录误当成自动执行目录。

View File

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

View File

@ -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<String, Object> listServiceCustomers(
@ -52,4 +55,27 @@ public class AdminServiceCustomerController {
);
return Map.of("code", 200, "data", data);
}
/** 本店客户的低敏业务事实时间线storeId 始终从会话派生。 */
@GetMapping("/service-customers/timeline")
public Map<String, Object> 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());
}
}
}

View File

@ -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
*
* <p>BusinessEvent 保持不可变并继续引用旧投影读取客户时间线时通过此指针合并历史事实
*/
@Column(name = "merged_into_store_customer_id")
private Long mergedIntoStoreCustomerId;
@Column(name = "create_time", nullable = false)
private LocalDateTime createTime;

View File

@ -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<BusinessEvent, Long>
List<BusinessEvent> findByStoreCustomerIdOrderByOccurredAtDesc(Long storeCustomerId);
Page<BusinessEvent> findByStoreIdAndStoreCustomerIdIn(
Long storeId,
Collection<Long> storeCustomerIds,
Pageable pageable
);
@Query("""
select count(distinct e.aggregateId)
from BusinessEvent e

View File

@ -13,4 +13,10 @@ public interface StoreCustomerMapper extends JpaRepository<StoreCustomer, Long>
Optional<StoreCustomer> findFirstByStoreIdAndCustomerUserId(Long storeId, Long customerUserId);
Optional<StoreCustomer> findFirstByStoreIdAndPhone(Long storeId, String phone);
Optional<StoreCustomer> findFirstByIdAndStoreIdAndDeletedFalse(Long id, Long storeId);
Optional<StoreCustomer> findFirstByIdAndStoreId(Long id, Long storeId);
List<StoreCustomer> findByStoreIdAndMergedIntoStoreCustomerId(Long storeId, Long mergedIntoStoreCustomerId);
}

View File

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

View File

@ -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<Map<String, Object>> 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<String, Object> 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<Long> 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<BusinessEvent> eventPage = businessEventMapper
.findByStoreIdAndStoreCustomerIdIn(storeId, timelineCustomerIds, pageable);
List<BusinessEvent> events = eventPage.getContent();
Map<Long, Appointment> appointments = loadAppointments(storeId, events);
Map<Long, Report> reports = loadReports(storeId, events);
Map<Long, ReportLead> leads = loadLeads(storeId, events);
Map<Long, User> actors = loadActors(events);
User customerUser = customer.getCustomerUserId() == null ? null
: userMapper.findByIdAndDeletedFalse(customer.getCustomerUserId()).orElse(null);
List<Map<String, Object>> 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<String, Object> 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<Long, Appointment> loadAppointments(Long storeId, List<BusinessEvent> events) {
Set<Long> 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<Long, Report> loadReports(Long storeId, List<BusinessEvent> events) {
Set<Long> 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<Long, ReportLead> loadLeads(Long storeId, List<BusinessEvent> events) {
Set<Long> 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<Long, User> loadActors(List<BusinessEvent> events) {
Set<Long> ids = events.stream()
.map(BusinessEvent::getActorUserId)
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
if (ids.isEmpty()) return Map.of();
Map<Long, User> result = new HashMap<>();
for (User user : userMapper.findAllById(ids)) {
result.put(user.getId(), user);
}
return result;
}
private static Set<Long> aggregateIds(List<BusinessEvent> 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<String, Object> toTimelineItem(
BusinessEvent event,
Appointment appointment,
Report report,
ReportLead lead,
User actor
) {
Map<String, Object> metadata = safeMetadata(event.getMetadataJson());
Map<String, Object> 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<String, Object> customerView(StoreCustomer customer, User user) {
Map<String, Object> 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<String, Object> appointmentView(Appointment appointment) {
Map<String, Object> 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<String, Object> reportView(Report report) {
Map<String, Object> 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<String, Object> leadView(ReportLead lead) {
Map<String, Object> 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<String, Object> 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<String, Object> metadata
) {
List<String> 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<String, Object> safeMetadata(String json) {
if (json == null || json.isBlank()) return Map.of();
try {
Map<String, Object> parsed = objectMapper.readValue(json, METADATA_TYPE);
Map<String, Object> 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<String> 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;
};
}
}

View File

@ -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<String, Object> summary = (Map<String, Object>) data.get("summary");
assertFalse(summary.containsKey("totalBalance"));
}
@Test
void timelineCustomerForbidden() {
CurrentUserContext.set(new CurrentUser(99L, null, "customer"));
Map<String, Object> 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<String, Object> result = controller.timeline(20L, 1, 20);
assertEquals(200, result.get("code"));
verify(storeCustomerTimelineService).timeline(10L, 20L, 1, 20);
}
}

View File

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

View File

@ -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<StoreCustomer> captor = ArgumentCaptor.forClass(StoreCustomer.class);
verify(storeCustomerMapper).save(captor.capture());

View File

@ -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<String, Object> result = service.timeline(10L, 50L, 1, 20);
assertEquals(3L, result.get("total"));
assertEquals(false, result.get("hasMore"));
Map<String, Object> customerView = (Map<String, Object>) result.get("customer");
assertEquals("138****8001", customerView.get("phoneMasked"));
List<Map<String, Object>> items = (List<Map<String, Object>>) result.get("list");
assertEquals("预约已取消", items.get(0).get("title"));
assertEquals("报告已确认发送", items.get(1).get("title"));
assertEquals("宠主提交下次服务提醒", items.get(2).get("title"));
Map<String, Object> appointmentView = (Map<String, Object>) 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<String, Object> result = service.timeline(10L, 51L, 1, 20);
Map<String, Object> customerView = (Map<String, Object>) 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;
}
}