feat: add store customer fact timeline
This commit is contained in:
parent
207115e18f
commit
07bad405cd
10
README.md
10
README.md
@ -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 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。
|
||||
|
||||
## 测试
|
||||
|
||||
|
||||
26
db/migrations/20260802_create_store_customer_timeline.sql
Normal file
26
db/migrations/20260802_create_store_customer_timeline.sql
Normal file
@ -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;
|
||||
@ -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 后续任务;接入前不得把本目录误当成自动执行目录。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user