diff --git a/README.md b/README.md index b691344..1defb9a 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,12 @@ curl http://localhost:8080/api/store/list ``` 脚本创建 `t_store_customer`,从预约和报告留资回填本店客户关系。末尾 `appointments_without_store_customer`、`leads_without_store_customer`、`store_customers_linked_to_non_customer` 必须全部为 0。 +6. **业务事件落库**:客户主档验证完成后执行: + ```bash + mysql -h -u -p petstore < db/migrations/20260801_create_business_event.sql + ``` + 脚本创建不可变 `t_business_event` 并回填可确认的历史事实。末尾四个验证计数必须全部为 0;报告打开和服务开始没有可靠历史数据,不做猜测性回填。 + ### production profile ```bash @@ -148,6 +154,7 @@ npm --prefix frontend run build:mp-weixin - [ ] 历史图片 URL 已执行一次性修复 SQL - [ ] 已执行 `20260801_split_service_identity.sql`,并留存未解析预约/报告验证结果 - [ ] 已按顺序执行 `20260801_create_store_customer.sql`,三项主档验证计数均为 0 +- [ ] 已执行 `20260801_create_business_event.sql`,四项事件回填验证计数均为 0 - [ ] 已暴露凭据(DB 密码、微信 AppSecret)已轮换 ## 安全说明 diff --git a/db/migrations/20260801_create_business_event.sql b/db/migrations/20260801_create_business_event.sql new file mode 100644 index 0000000..0693662 --- /dev/null +++ b/db/migrations/20260801_create_business_event.sql @@ -0,0 +1,190 @@ +-- Petstore Phase 0: persist immutable business events for funnels and timelines. +-- Target: MySQL. Run after 20260801_create_store_customer.sql. +-- One-time migration; back up the database before execution. + +CREATE TABLE t_business_event ( + id BIGINT NOT NULL AUTO_INCREMENT, + event_id VARCHAR(36) NOT NULL COMMENT '随机事件 ID', + event_type VARCHAR(64) NOT NULL COMMENT '过去时业务事件类型', + event_version INT NOT NULL DEFAULT 1, + store_id BIGINT NOT NULL, + store_customer_id BIGINT NULL COMMENT '门店客户稳定关系,可空', + aggregate_type VARCHAR(32) NOT NULL, + aggregate_id BIGINT NOT NULL, + actor_user_id BIGINT NULL, + actor_role VARCHAR(16) NULL, + source VARCHAR(32) NOT NULL, + occurred_at DATETIME(3) NOT NULL, + metadata_json TEXT NOT NULL COMMENT '仅低敏白名单维度 JSON', + idempotency_key VARCHAR(128) NULL, + create_time DATETIME(3) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_business_event_event_id (event_id), + UNIQUE KEY uk_business_event_idempotency (idempotency_key), + KEY idx_be_store_type_time (store_id, event_type, occurred_at), + KEY idx_be_aggregate_time (aggregate_type, aggregate_id, occurred_at), + KEY idx_be_store_customer_time (store_customer_id, occurred_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='不可变业务事件'; + +-- 历史预约只回填可确定的“预约已创建”,不猜测无法还原的开始服务时间。 +INSERT INTO t_business_event ( + event_id, event_type, event_version, store_id, store_customer_id, + aggregate_type, aggregate_id, actor_user_id, actor_role, source, + occurred_at, metadata_json, idempotency_key, create_time +) +SELECT + UUID(), + 'appointment_created', + 1, + a.store_id, + sc.id, + 'appointment', + a.id, + a.created_by_user_id, + actor.role, + CASE + WHEN a.created_by_user_id IS NULL THEN 'migration' + WHEN a.created_by_user_id = a.customer_user_id THEN 'customer' + ELSE 'admin' + END, + COALESCE(a.create_time, a.appointment_time, NOW()), + CASE + WHEN a.created_by_user_id IS NULL + THEN '{"bookingOrigin":"migration"}' + WHEN a.created_by_user_id = a.customer_user_id + THEN '{"bookingOrigin":"customer"}' + ELSE '{"bookingOrigin":"admin"}' + END, + CONCAT('appointment_created:', a.id), + NOW() +FROM t_appointment a +LEFT JOIN t_store_customer sc + ON sc.store_id = a.store_id + AND sc.customer_user_id = a.customer_user_id + AND sc.deleted = 0 +LEFT JOIN t_user actor + ON actor.id = a.created_by_user_id + AND actor.deleted = 0 +WHERE a.deleted = 0 + AND a.store_id IS NOT NULL; + +-- 历史报告提交是可靠事实;不保存 report_token 或其 hash。 +INSERT INTO t_business_event ( + event_id, event_type, event_version, store_id, store_customer_id, + aggregate_type, aggregate_id, actor_user_id, actor_role, source, + occurred_at, metadata_json, idempotency_key, create_time +) +SELECT + UUID(), + 'report_submitted', + 1, + r.store_id, + sc.id, + 'report', + r.id, + r.author_staff_id, + actor.role, + CASE WHEN r.author_staff_id IS NULL THEN 'migration' ELSE 'admin' END, + COALESCE(r.create_time, NOW()), + '{}', + CONCAT('report_submitted:', r.id), + NOW() +FROM t_report r +LEFT JOIN t_store_customer sc + ON sc.store_id = r.store_id + AND sc.customer_user_id = r.customer_user_id + AND sc.deleted = 0 +LEFT JOIN t_user actor + ON actor.id = r.author_staff_id + AND actor.deleted = 0 +WHERE r.deleted = 0 + AND r.store_id IS NOT NULL; + +-- 有报告的完成时间可由报告创建时间可靠近似;无报告的历史 done 不做猜测。 +INSERT INTO t_business_event ( + event_id, event_type, event_version, store_id, store_customer_id, + aggregate_type, aggregate_id, actor_user_id, actor_role, source, + occurred_at, metadata_json, idempotency_key, create_time +) +SELECT + UUID(), + 'service_completed', + 1, + r.store_id, + sc.id, + 'appointment', + r.appointment_id, + r.author_staff_id, + actor.role, + CASE WHEN r.author_staff_id IS NULL THEN 'migration' ELSE 'admin' END, + COALESCE(r.create_time, NOW()), + '{}', + CONCAT('service_completed:', r.appointment_id), + NOW() +FROM t_report r +LEFT JOIN t_store_customer sc + ON sc.store_id = r.store_id + AND sc.customer_user_id = r.customer_user_id + AND sc.deleted = 0 +LEFT JOIN t_user actor + ON actor.id = r.author_staff_id + AND actor.deleted = 0 +WHERE r.deleted = 0 + AND r.store_id IS NOT NULL + AND r.appointment_id IS NOT NULL; + +-- 历史留资每条只形成一个 lead_submitted 事实;不复制手机号、IP、openid 或退订 token。 +INSERT INTO t_business_event ( + event_id, event_type, event_version, store_id, store_customer_id, + aggregate_type, aggregate_id, actor_user_id, actor_role, source, + occurred_at, metadata_json, idempotency_key, create_time +) +SELECT + UUID(), + 'lead_submitted', + 1, + l.store_id, + sc.id, + 'report_lead', + l.id, + NULL, + NULL, + 'public_report', + COALESCE(l.consent_at, l.create_time, l.update_time, NOW()), + '{"repeatSubmit":false}', + CONCAT('lead_submitted:', l.id), + NOW() +FROM t_report_lead l +LEFT JOIN t_store_customer sc + ON sc.store_id = l.store_id + AND sc.phone = l.phone + AND sc.deleted = 0 +WHERE l.store_id IS NOT NULL; + +-- Verification queries. All four counts should be 0 before application release. +SELECT COUNT(*) AS appointments_without_created_event +FROM t_appointment a +LEFT JOIN t_business_event e + ON e.idempotency_key = CONCAT('appointment_created:', a.id) +WHERE a.deleted = 0 + AND a.store_id IS NOT NULL + AND e.id IS NULL; + +SELECT COUNT(*) AS reports_without_submitted_event +FROM t_report r +LEFT JOIN t_business_event e + ON e.idempotency_key = CONCAT('report_submitted:', r.id) +WHERE r.deleted = 0 + AND r.store_id IS NOT NULL + AND e.id IS NULL; + +SELECT COUNT(*) AS leads_without_submitted_event +FROM t_report_lead l +LEFT JOIN t_business_event e + ON e.idempotency_key = CONCAT('lead_submitted:', l.id) +WHERE l.store_id IS NOT NULL + AND e.id IS NULL; + +SELECT COUNT(*) AS events_without_store +FROM t_business_event +WHERE store_id IS NULL; diff --git a/db/migrations/README.md b/db/migrations/README.md index 5de6b8a..88ebd1a 100644 --- a/db/migrations/README.md +++ b/db/migrations/README.md @@ -12,4 +12,6 @@ `20260801_create_store_customer.sql` 必须在上述身份迁移之后执行:建立 `t_store_customer` 门店客户主档,并从预约与报告留资回填历史关系。脚本末尾三个验证计数必须为 0。 +`20260801_create_business_event.sql` 必须在客户主档迁移之后执行:建立不可变 `t_business_event`,回填预约创建、报告提交、可确认的服务完成和留资提交。脚本末尾四个验证计数必须为 0;脚本不回填无法从数据库可靠恢复的报告打开与服务开始事件。 + 正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。 diff --git a/src/main/java/com/petstore/controller/AdminBusinessEventController.java b/src/main/java/com/petstore/controller/AdminBusinessEventController.java new file mode 100644 index 0000000..b7135ee --- /dev/null +++ b/src/main/java/com/petstore/controller/AdminBusinessEventController.java @@ -0,0 +1,44 @@ +package com.petstore.controller; + +import com.petstore.auth.CurrentUser; +import com.petstore.auth.CurrentUserContext; +import com.petstore.service.AdminBusinessEventService; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.Map; + +/** 门店工作台的低敏事件指标。storeId 只从会话派生。 */ +@RestController +@RequestMapping("/api/admin/workbench") +@RequiredArgsConstructor +@CrossOrigin +public class AdminBusinessEventController { + + private final AdminBusinessEventService adminBusinessEventService; + + @GetMapping("/report-funnel") + public Map reportFunnel( + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date + ) { + CurrentUser user = CurrentUserContext.require(); + if (!user.isStoreUser() || user.storeId() == null) { + return Map.of( + "code", 403, + "message", "仅门店员工可查看经营指标", + "bizCode", "FORBIDDEN" + ); + } + return Map.of( + "code", 200, + "data", adminBusinessEventService.reportFunnel(user.storeId(), date) + ); + } +} diff --git a/src/main/java/com/petstore/controller/AppointmentController.java b/src/main/java/com/petstore/controller/AppointmentController.java index 4a339e2..f46ff91 100644 --- a/src/main/java/com/petstore/controller/AppointmentController.java +++ b/src/main/java/com/petstore/controller/AppointmentController.java @@ -209,7 +209,7 @@ public class AppointmentController { if (u.storeId() != null && !u.storeId().equals(appt.getStoreId())) { return Map.of("code", 403, "message", "无权操作他店预约", "bizCode", "FORBIDDEN"); } - return appointmentService.startService(appointmentId, u.userId()); + return appointmentService.startService(appointmentId, u.userId(), u.role()); } /** @@ -239,7 +239,7 @@ public class AppointmentController { } else { return Map.of("code", 403, "message", "无权操作", "bizCode", "FORBIDDEN"); } - return appointmentService.transitionStatus(id, status); + return appointmentService.transitionStatus(id, status, u.userId(), u.role()); } /** 删除预约:仅 boss/staff 且同店。 */ diff --git a/src/main/java/com/petstore/controller/ReportController.java b/src/main/java/com/petstore/controller/ReportController.java index 4c2340c..085f90f 100644 --- a/src/main/java/com/petstore/controller/ReportController.java +++ b/src/main/java/com/petstore/controller/ReportController.java @@ -9,6 +9,7 @@ import com.petstore.entity.Store; import com.petstore.entity.User; import com.petstore.entity.Appointment; import com.petstore.service.AppointmentService; +import com.petstore.service.BusinessEventService; import com.petstore.service.ReportHighlightVideoService; import com.petstore.service.ReportService; import com.petstore.service.ReportTestimonialService; @@ -23,6 +24,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import java.util.*; +import java.time.LocalDateTime; import java.util.stream.Collectors; @RestController @@ -37,6 +39,7 @@ public class ReportController { private final ReportHighlightVideoService reportHighlightVideoService; private final ReportTestimonialService reportTestimonialService; private final UserService userService; + private final BusinessEventService businessEventService; @Value("${app.base-url:http://localhost:8080}") private String baseUrl; @@ -52,7 +55,7 @@ public class ReportController { java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); byte[] digest = md.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); - for (int i = 0; i < Math.min(8, digest.length); i++) { + for (int i = 0; i < Math.min(4, digest.length); i++) { sb.append(String.format("%02x", digest[i])); } return sb.toString(); @@ -135,7 +138,7 @@ public class ReportController { } /** - * P0 埋点:报告打开(首访/再次)。当前写入应用日志,便于日志平台统计;不落库。 + * 报告打开(首访/再次):写入低敏 BusinessEvent;日志仅保留 token 短 hash。 */ @PostMapping("/open-track") public Map trackReportOpen(@RequestBody Map body) { @@ -145,7 +148,8 @@ public class ReportController { return Map.of("code", 400, "message", "token 必填"); } String visitType = body.get("visitType") == null ? "" : body.get("visitType").toString(); - log.info("report_open tokenHash={} visitType={}", shortTokenHash(token), visitType); + boolean recorded = businessEventService.recordReportOpenByToken(token, visitType, LocalDateTime.now()); + log.info("report_open tokenHash={} visitType={} recorded={}", shortTokenHash(token), visitType, recorded); return Map.of("code", 200); } diff --git a/src/main/java/com/petstore/entity/BusinessEvent.java b/src/main/java/com/petstore/entity/BusinessEvent.java new file mode 100644 index 0000000..2a029f5 --- /dev/null +++ b/src/main/java/com/petstore/entity/BusinessEvent.java @@ -0,0 +1,88 @@ +package com.petstore.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 不可变业务事实。用于经营漏斗、客户时间线与后续审计,不承载业务对象当前状态。 + * + *

metadataJson 只允许服务端构造的低敏枚举/布尔维度;不得写手机号、报告 token、 + * 媒体 URL、IP、openid/unionid、备注或任意请求体。 + */ +@Data +@Entity +@Table( + name = "t_business_event", + uniqueConstraints = { + @UniqueConstraint(name = "uk_business_event_event_id", columnNames = "event_id"), + @UniqueConstraint(name = "uk_business_event_idempotency", columnNames = "idempotency_key") + }, + indexes = { + @Index(name = "idx_be_store_type_time", columnList = "store_id,event_type,occurred_at"), + @Index(name = "idx_be_aggregate_time", columnList = "aggregate_type,aggregate_id,occurred_at"), + @Index(name = "idx_be_store_customer_time", columnList = "store_customer_id,occurred_at") + } +) +public class BusinessEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 对外可安全引用的随机事件 ID,不暴露数据库自增主键。 */ + @Column(name = "event_id", nullable = false, length = 36) + private String eventId; + + @Column(name = "event_type", nullable = false, length = 64) + private String eventType; + + @Column(name = "event_version", nullable = false) + private Integer eventVersion = 1; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + /** 门店客户稳定关系;没有客户语义的事件可空。 */ + @Column(name = "store_customer_id") + private Long storeCustomerId; + + @Column(name = "aggregate_type", nullable = false, length = 32) + private String aggregateType; + + @Column(name = "aggregate_id", nullable = false) + private Long aggregateId; + + /** 操作人快照;公开访问事件可空。 */ + @Column(name = "actor_user_id") + private Long actorUserId; + + @Column(name = "actor_role", length = 16) + private String actorRole; + + /** customer / admin / public_report / system / migration。 */ + @Column(length = 32, nullable = false) + private String source; + + @Column(name = "occurred_at", nullable = false) + private LocalDateTime occurredAt; + + /** 服务端白名单维度 JSON;不存原始请求。 */ + @Column(name = "metadata_json", nullable = false, columnDefinition = "TEXT") + private String metadataJson; + + /** 可空;用于一业务结果只落一条事件。MySQL 允许多个 NULL。 */ + @Column(name = "idempotency_key", length = 128) + private String idempotencyKey; + + @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 new file mode 100644 index 0000000..8662b3b --- /dev/null +++ b/src/main/java/com/petstore/mapper/BusinessEventMapper.java @@ -0,0 +1,48 @@ +package com.petstore.mapper; + +import com.petstore.entity.BusinessEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface BusinessEventMapper extends JpaRepository { + + Optional findFirstByIdempotencyKey(String idempotencyKey); + + List findByStoreCustomerIdOrderByOccurredAtDesc(Long storeCustomerId); + + @Query(""" + select count(distinct e.aggregateId) + from BusinessEvent e + where e.storeId = :storeId + and e.eventType = :eventType + and e.occurredAt >= :from + and e.occurredAt < :to + """) + long countDistinctAggregates( + @Param("storeId") Long storeId, + @Param("eventType") String eventType, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to + ); + + @Query(""" + select count(distinct e.aggregateId) + from BusinessEvent e + where e.storeId = :storeId + and e.eventType in :eventTypes + and e.occurredAt >= :from + and e.occurredAt < :to + """) + long countDistinctAggregatesByTypes( + @Param("storeId") Long storeId, + @Param("eventTypes") Collection eventTypes, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to + ); +} diff --git a/src/main/java/com/petstore/service/AdminBusinessEventService.java b/src/main/java/com/petstore/service/AdminBusinessEventService.java new file mode 100644 index 0000000..aff25fc --- /dev/null +++ b/src/main/java/com/petstore/service/AdminBusinessEventService.java @@ -0,0 +1,52 @@ +package com.petstore.service; + +import com.petstore.mapper.BusinessEventMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** 门店后台的业务事件读模型;只返回汇总,不暴露事件明细或内部身份字段。 */ +@Service +@RequiredArgsConstructor +public class AdminBusinessEventService { + + private final BusinessEventMapper businessEventMapper; + + public Map reportFunnel(Long storeId, LocalDate date) { + if (storeId == null) { + throw new IllegalArgumentException("storeId required"); + } + LocalDate day = date == null ? LocalDate.now() : date; + LocalDateTime from = day.atStartOfDay(); + LocalDateTime to = day.plusDays(1).atStartOfDay(); + + long submitted = count(storeId, BusinessEventService.REPORT_SUBMITTED, from, to); + long opened = businessEventMapper.countDistinctAggregatesByTypes( + storeId, + List.of(BusinessEventService.REPORT_OPENED, BusinessEventService.REPORT_REOPENED), + from, + to + ); + long leads = count(storeId, BusinessEventService.LEAD_SUBMITTED, from, to); + + Map data = new LinkedHashMap<>(); + data.put("date", day.toString()); + data.put("reportSubmittedCount", submitted); + data.put("reportSentCount", null); + data.put("reportOpenedCount", opened); + data.put("leadSubmittedCount", leads); + data.put("rebookCreatedCount", null); + data.put("reportSentTrackingReady", false); + data.put("rebookTrackingReady", false); + return data; + } + + private long count(Long storeId, String eventType, LocalDateTime from, LocalDateTime to) { + return businessEventMapper.countDistinctAggregates(storeId, eventType, from, to); + } +} diff --git a/src/main/java/com/petstore/service/AppointmentService.java b/src/main/java/com/petstore/service/AppointmentService.java index d13375e..4f53567 100644 --- a/src/main/java/com/petstore/service/AppointmentService.java +++ b/src/main/java/com/petstore/service/AppointmentService.java @@ -3,6 +3,7 @@ package com.petstore.service; import com.petstore.entity.Appointment; import com.petstore.entity.Pet; import com.petstore.entity.Report; +import com.petstore.entity.StoreCustomer; import com.petstore.entity.User; import com.petstore.mapper.AppointmentMapper; import com.petstore.mapper.PetMapper; @@ -39,6 +40,7 @@ public class AppointmentService { private final ReportMapper reportMapper; private final PetMapper petMapper; private final StoreCustomerService storeCustomerService; + private final BusinessEventService businessEventService; // 宠主查看归属于自己的预约;兼容 customer_user_id 尚未回填的旧数据。 public List getByUserId(Long customerUserId) { @@ -201,8 +203,9 @@ public class AppointmentService { appointment.setCreateTime(LocalDateTime.now()); appointment.setUpdateTime(LocalDateTime.now()); appointment.setDeleted(false); + StoreCustomer storeCustomer; try { - storeCustomerService.touchBooking( + storeCustomer = storeCustomerService.touchBooking( appointment.getStoreId(), appointment.getCustomerUserId(), appointment.getCreatedByUserId(), @@ -216,6 +219,11 @@ public class AppointmentService { ); } Appointment saved = appointmentMapper.save(appointment); + businessEventService.recordAppointmentCreated( + saved, + storeCustomer, + resolveActorRole(saved.getCreatedByUserId()) + ); Map ok = new HashMap<>(); ok.put("code", 200); ok.put("message", "创建成功"); @@ -227,7 +235,18 @@ public class AppointmentService { * 预约状态迁移(PUT /appointment/status 等)。 * 允许:new→cancel、doing→done;new→doing 请走 {@link #startService}。 */ + @Transactional public Map transitionStatus(Long id, String nextStatus) { + return transitionStatus(id, nextStatus, null, null); + } + + @Transactional + public Map transitionStatus( + Long id, + String nextStatus, + Long actorUserId, + String actorRole + ) { Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(id).orElse(null); if (appointment == null) { return Map.of("code", 404, "message", "预约不存在", "bizCode", "NOT_FOUND"); @@ -264,14 +283,29 @@ public class AppointmentService { return Map.of("code", 400, "message", "不支持的状态变更", "bizCode", "INVALID_STATUS"); } + LocalDateTime occurredAt = LocalDateTime.now(); appointment.setStatus(to); - appointment.setUpdateTime(LocalDateTime.now()); + appointment.setUpdateTime(occurredAt); Appointment saved = appointmentMapper.save(appointment); + businessEventService.recordAppointmentStatusChanged( + saved, + from, + to, + actorUserId, + actorRole, + occurredAt + ); return Map.of("code", 200, "message", "更新成功", "data", saved); } /** 开始服务:仅 new → doing,并指定技师 */ + @Transactional public Map startService(Long appointmentId, Long staffUserId) { + return startService(appointmentId, staffUserId, resolveActorRole(staffUserId)); + } + + @Transactional + public Map startService(Long appointmentId, Long staffUserId, String actorRole) { Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(appointmentId).orElse(null); if (appointment == null) { return Map.of("code", 404, "message", "预约不存在", "bizCode", "NOT_FOUND"); @@ -284,13 +318,31 @@ public class AppointmentService { "bizCode", "INVALID_STATUS" ); } + LocalDateTime occurredAt = LocalDateTime.now(); appointment.setStatus("doing"); appointment.setAssignedUserId(staffUserId); - appointment.setUpdateTime(LocalDateTime.now()); + appointment.setUpdateTime(occurredAt); Appointment saved = appointmentMapper.save(appointment); + businessEventService.recordAppointmentStatusChanged( + saved, + from, + "doing", + staffUserId, + actorRole, + occurredAt + ); return Map.of("code", 200, "message", "已开始服务", "data", saved); } + private String resolveActorRole(Long userId) { + if (userId == null) { + return null; + } + return userMapper.findByIdAndDeletedFalse(userId) + .map(User::getRole) + .orElse(null); + } + public boolean softDelete(Long id) { Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(id).orElse(null); if (appointment == null) { diff --git a/src/main/java/com/petstore/service/BusinessEventService.java b/src/main/java/com/petstore/service/BusinessEventService.java new file mode 100644 index 0000000..e52de53 --- /dev/null +++ b/src/main/java/com/petstore/service/BusinessEventService.java @@ -0,0 +1,316 @@ +package com.petstore.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +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.mapper.BusinessEventMapper; +import com.petstore.mapper.ReportMapper; +import com.petstore.mapper.StoreCustomerMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** 统一记录低敏、不可变、可统计的业务事实。 */ +@Service +@RequiredArgsConstructor +public class BusinessEventService { + + public static final String APPOINTMENT_CREATED = "appointment_created"; + public static final String APPOINTMENT_STATUS_CHANGED = "appointment_status_changed"; + public static final String SERVICE_STARTED = "service_started"; + public static final String SERVICE_COMPLETED = "service_completed"; + public static final String REPORT_SUBMITTED = "report_submitted"; + public static final String REPORT_OPENED = "report_opened"; + public static final String REPORT_REOPENED = "report_reopened"; + public static final String LEAD_SUBMITTED = "lead_submitted"; + + private static final Set FORBIDDEN_METADATA_FRAGMENTS = Set.of( + "phone", "token", "url", "openid", "unionid", + "password", "secret", "remark", "content", "request", "payload" + ); + + private final BusinessEventMapper businessEventMapper; + private final StoreCustomerMapper storeCustomerMapper; + private final ReportMapper reportMapper; + private final ObjectMapper objectMapper; + + @Transactional + public BusinessEvent recordAppointmentCreated( + Appointment appointment, + StoreCustomer storeCustomer, + String actorRole + ) { + String source = appointment.getCreatedByUserId() != null + && appointment.getCreatedByUserId().equals(appointment.resolvedCustomerUserId()) + ? "customer" + : "admin"; + return record(new EventCommand( + APPOINTMENT_CREATED, + appointment.getStoreId(), + storeCustomer == null ? null : storeCustomer.getId(), + "appointment", + appointment.getId(), + appointment.getCreatedByUserId(), + actorRole, + source, + appointment.getCreateTime(), + "appointment_created:" + appointment.getId(), + Map.of("bookingOrigin", source) + )); + } + + @Transactional + public void recordAppointmentStatusChanged( + Appointment appointment, + String fromStatus, + String toStatus, + Long actorUserId, + String actorRole, + LocalDateTime occurredAt + ) { + Long storeCustomerId = resolveStoreCustomerId( + appointment.getStoreId(), + appointment.resolvedCustomerUserId() + ); + String to = normalizeStatus(toStatus); + Map statusMetadata = new LinkedHashMap<>(); + statusMetadata.put("fromStatus", normalizeStatus(fromStatus)); + statusMetadata.put("toStatus", to); + record(new EventCommand( + APPOINTMENT_STATUS_CHANGED, + appointment.getStoreId(), + storeCustomerId, + "appointment", + appointment.getId(), + actorUserId, + actorRole, + actorRole == null ? "system" : ("customer".equals(actorRole) ? "customer" : "admin"), + occurredAt, + "appointment_status:" + appointment.getId() + ":" + to, + statusMetadata + )); + + if ("doing".equals(to)) { + record(new EventCommand( + SERVICE_STARTED, + appointment.getStoreId(), + storeCustomerId, + "appointment", + appointment.getId(), + actorUserId, + actorRole, + "admin", + occurredAt, + "service_started:" + appointment.getId(), + Map.of() + )); + } else if ("done".equals(to)) { + record(new EventCommand( + SERVICE_COMPLETED, + appointment.getStoreId(), + storeCustomerId, + "appointment", + appointment.getId(), + actorUserId, + actorRole, + actorRole == null ? "system" : "admin", + occurredAt, + "service_completed:" + appointment.getId(), + Map.of() + )); + } + } + + @Transactional + public BusinessEvent recordReportSubmitted(Report report, String actorRole) { + return record(new EventCommand( + REPORT_SUBMITTED, + report.getStoreId(), + resolveStoreCustomerId(report.getStoreId(), report.getCustomerUserId()), + "report", + report.getId(), + report.resolvedAuthorStaffId(), + actorRole, + "admin", + report.getCreateTime(), + "report_submitted:" + report.getId(), + Map.of() + )); + } + + @Transactional + public BusinessEvent recordLeadSubmitted( + ReportLead lead, + StoreCustomer storeCustomer, + boolean repeatSubmit + ) { + return record(new EventCommand( + LEAD_SUBMITTED, + lead.getStoreId(), + storeCustomer == null ? null : storeCustomer.getId(), + "report_lead", + lead.getId(), + null, + null, + "public_report", + lead.getConsentAt() == null ? lead.getUpdateTime() : lead.getConsentAt(), + null, + Map.of("repeatSubmit", repeatSubmit) + )); + } + + /** + * 公开报告打开事件。无效 token 静默忽略,避免借埋点接口枚举报告;绝不持久化 token/hash。 + */ + @Transactional + public boolean recordReportOpenByToken(String token, String visitType, LocalDateTime occurredAt) { + if (token == null || token.isBlank()) { + return false; + } + Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(token.trim()).orElse(null); + if (report == null || report.getId() == null || report.getStoreId() == null) { + return false; + } + String visit = "repeat".equalsIgnoreCase(visitType) ? "repeat" : "first"; + String eventType = "repeat".equals(visit) ? REPORT_REOPENED : REPORT_OPENED; + record(new EventCommand( + eventType, + report.getStoreId(), + resolveStoreCustomerId(report.getStoreId(), report.getCustomerUserId()), + "report", + report.getId(), + null, + null, + "public_report", + occurredAt, + null, + Map.of("visitType", visit) + )); + return true; + } + + private BusinessEvent record(EventCommand command) { + validate(command); + if (command.idempotencyKey() != null) { + BusinessEvent existing = businessEventMapper + .findFirstByIdempotencyKey(command.idempotencyKey()) + .orElse(null); + if (existing != null) { + return existing; + } + } + + BusinessEvent event = new BusinessEvent(); + event.setEventId(UUID.randomUUID().toString()); + event.setEventType(command.eventType()); + event.setEventVersion(1); + event.setStoreId(command.storeId()); + event.setStoreCustomerId(command.storeCustomerId()); + event.setAggregateType(command.aggregateType()); + event.setAggregateId(command.aggregateId()); + event.setActorUserId(command.actorUserId()); + event.setActorRole(normalizeOptional(command.actorRole(), 16)); + event.setSource(normalizeRequired(command.source(), 32, "system")); + event.setOccurredAt(command.occurredAt() == null ? LocalDateTime.now() : command.occurredAt()); + event.setMetadataJson(serializeMetadata(command.metadata())); + event.setIdempotencyKey(normalizeOptional(command.idempotencyKey(), 128)); + event.setCreateTime(LocalDateTime.now()); + return businessEventMapper.save(event); + } + + private Long resolveStoreCustomerId(Long storeId, Long customerUserId) { + if (storeId == null || customerUserId == null) { + return null; + } + return storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(storeId, customerUserId) + .map(StoreCustomer::getId) + .orElse(null); + } + + private String serializeMetadata(Map metadata) { + Map safe = new LinkedHashMap<>(); + if (metadata != null) { + for (Map.Entry entry : metadata.entrySet()) { + String key = entry.getKey() == null ? "" : entry.getKey().trim(); + String keyNorm = key.toLowerCase(Locale.ROOT).replace("_", ""); + boolean forbidden = FORBIDDEN_METADATA_FRAGMENTS.stream().anyMatch(keyNorm::contains) + || "ip".equals(keyNorm); + if (key.isBlank() || forbidden) { + throw new IllegalArgumentException("业务事件 metadata 包含禁止字段: " + key); + } + Object value = entry.getValue(); + if (value == null || value instanceof Boolean || value instanceof Number) { + safe.put(key, value); + } else if (value instanceof String text) { + safe.put(key, text.length() <= 100 ? text : text.substring(0, 100)); + } else { + throw new IllegalArgumentException("业务事件 metadata 仅允许标量值"); + } + } + } + try { + return objectMapper.writeValueAsString(safe); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("业务事件 metadata 无法序列化", e); + } + } + + private static void validate(EventCommand command) { + if (command == null || command.storeId() == null) { + throw new IllegalArgumentException("业务事件必须归属门店"); + } + if (command.aggregateId() == null) { + throw new IllegalArgumentException("业务事件必须关联业务对象"); + } + normalizeRequired(command.eventType(), 64, null); + normalizeRequired(command.aggregateType(), 32, null); + } + + private static String normalizeStatus(String status) { + return status == null ? "unknown" : status.trim().toLowerCase(Locale.ROOT); + } + + private static String normalizeRequired(String value, int maxLength, String fallback) { + String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + if (normalized.isBlank()) { + if (fallback == null) { + throw new IllegalArgumentException("业务事件必填字段为空"); + } + normalized = fallback; + } + return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength); + } + + private static String normalizeOptional(String value, int maxLength) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength); + } + + private record EventCommand( + String eventType, + Long storeId, + Long storeCustomerId, + String aggregateType, + Long aggregateId, + Long actorUserId, + String actorRole, + String source, + LocalDateTime occurredAt, + String idempotencyKey, + Map metadata + ) {} +} diff --git a/src/main/java/com/petstore/service/ReportLeadService.java b/src/main/java/com/petstore/service/ReportLeadService.java index 69f0f4d..a1f3355 100644 --- a/src/main/java/com/petstore/service/ReportLeadService.java +++ b/src/main/java/com/petstore/service/ReportLeadService.java @@ -3,6 +3,7 @@ package com.petstore.service; import com.petstore.entity.Report; import com.petstore.entity.ReportLead; import com.petstore.entity.ServiceInterval; +import com.petstore.entity.StoreCustomer; import com.petstore.entity.User; import com.petstore.mapper.ReportLeadMapper; import com.petstore.mapper.ReportMapper; @@ -32,6 +33,7 @@ public class ReportLeadService { private final UserService userService; private final WechatMiniProgramService wechatMiniProgramService; private final StoreCustomerService storeCustomerService; + private final BusinessEventService businessEventService; /** 留资提交结果:记录 + 是否已将微信 openid 绑定到宠主账号(S3→S4)+ 是否同报告同手机号再次提交(幂等更新) */ public record LeadSubmitOutcome(ReportLead lead, boolean wechatBound, boolean repeatSubmit) {} @@ -209,7 +211,8 @@ public class ReportLeadService { } } - storeCustomerService.touchLead(report.getStoreId(), phone, null, now); + StoreCustomer storeCustomer = storeCustomerService.touchLead(report.getStoreId(), phone, null, now); + businessEventService.recordLeadSubmitted(lead, storeCustomer, repeatSubmit); return new LeadSubmitOutcome(lead, wechatBound, repeatSubmit); } diff --git a/src/main/java/com/petstore/service/ReportService.java b/src/main/java/com/petstore/service/ReportService.java index 75b65ec..a1f5392 100644 --- a/src/main/java/com/petstore/service/ReportService.java +++ b/src/main/java/com/petstore/service/ReportService.java @@ -8,6 +8,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; @@ -20,7 +21,9 @@ public class ReportService { private final ReportImageMapper reportImageMapper; private final AppointmentMapper appointmentMapper; private final UserMapper userMapper; + private final BusinessEventService businessEventService; + @Transactional public Report create(Report report) { // 一约一份不变量:报告必须绑定预约;同一预约重复提交拒绝。 if (report.getAppointmentId() == null) { @@ -35,11 +38,15 @@ public class ReportService { Long requestedAuthorStaffId = report.getAuthorStaffId() != null ? report.getAuthorStaffId() : report.getUserId(); + Appointment boundAppointment = null; + boolean completedByReport = false; + LocalDateTime completedAt = null; // 填充客户/技师快照字段,并自动完成预约 if (report.getAppointmentId() != null) { Appointment appt = appointmentMapper.findByIdAndDeletedFalse(report.getAppointmentId()).orElse(null); if (appt != null) { + boundAppointment = appt; report.setPetName(appt.getPetName()); report.setServiceType(appt.getServiceType()); report.setAppointmentTime(appt.getAppointmentTime()); @@ -54,9 +61,11 @@ public class ReportService { } // 填写完报告:仅进行中 → 已完成(与预约状态机一致) if ("doing".equalsIgnoreCase(appt.getStatus())) { + completedAt = LocalDateTime.now(); appt.setStatus("done"); - appt.setUpdateTime(LocalDateTime.now()); + appt.setUpdateTime(completedAt); appointmentMapper.save(appt); + completedByReport = true; } } } @@ -82,8 +91,9 @@ public class ReportService { } } - report.setCreateTime(LocalDateTime.now()); - report.setUpdateTime(LocalDateTime.now()); + LocalDateTime now = LocalDateTime.now(); + report.setCreateTime(now); + report.setUpdateTime(now); report.setDeleted(false); // 先保存 Report 以获取生成的 ID @@ -96,10 +106,32 @@ public class ReportService { reportImageMapper.save(img); } } + + String actorRole = resolveActorRole(requestedAuthorStaffId); + if (completedByReport && boundAppointment != null) { + businessEventService.recordAppointmentStatusChanged( + boundAppointment, + "doing", + "done", + requestedAuthorStaffId, + actorRole, + completedAt + ); + } + businessEventService.recordReportSubmitted(saved, actorRole); return saved; } + private String resolveActorRole(Long userId) { + if (userId == null) { + return null; + } + return userMapper.findByIdAndDeletedFalse(userId) + .map(User::getRole) + .orElse(null); + } + /** 查询单条报告并填充图片 */ private Report enrichImages(Report r) { if (r == null) return null; diff --git a/src/test/java/com/petstore/controller/AdminBusinessEventControllerTest.java b/src/test/java/com/petstore/controller/AdminBusinessEventControllerTest.java new file mode 100644 index 0000000..a25ba6e --- /dev/null +++ b/src/test/java/com/petstore/controller/AdminBusinessEventControllerTest.java @@ -0,0 +1,56 @@ +package com.petstore.controller; + +import com.petstore.auth.CurrentUser; +import com.petstore.auth.CurrentUserContext; +import com.petstore.service.AdminBusinessEventService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminBusinessEventControllerTest { + + @Mock private AdminBusinessEventService adminBusinessEventService; + @InjectMocks private AdminBusinessEventController controller; + + @AfterEach + void clearContext() { + CurrentUserContext.clear(); + } + + @Test + void customerCannotReadStoreFunnel() { + CurrentUserContext.set(new CurrentUser(99L, null, "customer")); + + Map result = controller.reportFunnel(LocalDate.of(2026, 8, 1)); + + assertEquals(403, result.get("code")); + verifyNoInteractions(adminBusinessEventService); + } + + @Test + void staffCanOnlyReadDerivedStore() { + LocalDate date = LocalDate.of(2026, 8, 1); + CurrentUserContext.set(new CurrentUser(7L, 10L, "staff")); + when(adminBusinessEventService.reportFunnel(10L, date)).thenReturn(Map.of( + "date", "2026-08-01", + "reportSubmittedCount", 8L + )); + + Map result = controller.reportFunnel(date); + + assertEquals(200, result.get("code")); + verify(adminBusinessEventService).reportFunnel(10L, date); + } +} diff --git a/src/test/java/com/petstore/controller/ReportControllerTest.java b/src/test/java/com/petstore/controller/ReportControllerTest.java index 4dfcfaf..46c96ea 100644 --- a/src/test/java/com/petstore/controller/ReportControllerTest.java +++ b/src/test/java/com/petstore/controller/ReportControllerTest.java @@ -7,6 +7,7 @@ import com.petstore.entity.Report; import com.petstore.entity.ReportImage; import com.petstore.entity.Store; import com.petstore.service.AppointmentService; +import com.petstore.service.BusinessEventService; import com.petstore.service.ReportHighlightVideoService; import com.petstore.service.ReportService; import com.petstore.service.ReportTestimonialService; @@ -44,6 +45,7 @@ class ReportControllerTest { @Mock private ReportHighlightVideoService reportHighlightVideoService; @Mock private ReportTestimonialService reportTestimonialService; @Mock private UserService userService; + @Mock private BusinessEventService businessEventService; private ReportController controller; @@ -55,7 +57,8 @@ class ReportControllerTest { storeService, reportHighlightVideoService, reportTestimonialService, - userService + userService, + businessEventService ); ReflectionTestUtils.setField(controller, "baseUrl", "http://localhost:8080"); } @@ -271,6 +274,22 @@ class ReportControllerTest { assertEquals(1L, captor.getValue().getUserId()); } + @Test + void reportOpenPersistsLowSensitivityBusinessEvent() { + when(businessEventService.recordReportOpenByToken(eq("secret-token-abc"), eq("first"), any())) + .thenReturn(true); + + Map result = controller.trackReportOpen(Map.of( + "token", "secret-token-abc", + "visitType", "first" + )); + + assertEquals(200, result.get("code")); + verify(businessEventService).recordReportOpenByToken( + eq("secret-token-abc"), eq("first"), any() + ); + } + private void setCurrentUserBoss() { CurrentUserContext.set(new CurrentUser(1L, 10L, "boss")); } diff --git a/src/test/java/com/petstore/mapper/BusinessEventMigrationTest.java b/src/test/java/com/petstore/mapper/BusinessEventMigrationTest.java new file mode 100644 index 0000000..66af06b --- /dev/null +++ b/src/test/java/com/petstore/mapper/BusinessEventMigrationTest.java @@ -0,0 +1,134 @@ +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; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class BusinessEventMigrationTest { + + @Test + void migrationBackfillsOnlyReliableLowSensitivityFacts() throws Exception { + try (Connection connection = DriverManager.getConnection( + "jdbc:h2:mem:business-event-migration;MODE=MySQL;DATABASE_TO_LOWER=TRUE", + "sa", + "" + )) { + createSourceTables(connection); + insertSourceRows(connection); + + try (Reader reader = Files.newBufferedReader( + Path.of("db/migrations/20260801_create_business_event.sql") + )) { + RunScript.execute(connection, reader); + } + + try (Statement statement = connection.createStatement(); + ResultSet count = statement.executeQuery("SELECT COUNT(*) FROM t_business_event")) { + count.next(); + assertEquals(4L, count.getLong(1)); + } + try (Statement statement = connection.createStatement(); + ResultSet types = statement.executeQuery(""" + SELECT event_type, metadata_json + FROM t_business_event + ORDER BY event_type + """)) { + int rows = 0; + while (types.next()) { + rows++; + String metadata = types.getString("metadata_json"); + assertFalse(metadata.contains("13800138001")); + assertFalse(metadata.contains("secret-report-token")); + assertFalse(metadata.contains("127.0.0.1")); + } + assertEquals(4, rows); + } + } + } + + private static void createSourceTables(Connection connection) throws Exception { + try (Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE t_user ( + id BIGINT PRIMARY KEY, + role VARCHAR(16), + deleted TINYINT NOT NULL DEFAULT 0 + ) + """); + statement.execute(""" + CREATE TABLE t_store_customer ( + id BIGINT PRIMARY KEY, + store_id BIGINT NOT NULL, + customer_user_id BIGINT, + phone VARCHAR(20), + deleted TINYINT NOT NULL DEFAULT 0 + ) + """); + statement.execute(""" + CREATE TABLE t_appointment ( + id BIGINT PRIMARY KEY, + store_id BIGINT, + customer_user_id BIGINT, + created_by_user_id BIGINT, + appointment_time DATETIME, + create_time DATETIME, + deleted TINYINT NOT NULL DEFAULT 0 + ) + """); + statement.execute(""" + CREATE TABLE t_report ( + id BIGINT PRIMARY KEY, + appointment_id BIGINT, + store_id BIGINT, + customer_user_id BIGINT, + author_staff_id BIGINT, + report_token VARCHAR(64), + create_time DATETIME, + deleted TINYINT NOT NULL DEFAULT 0 + ) + """); + statement.execute(""" + CREATE TABLE t_report_lead ( + id BIGINT PRIMARY KEY, + report_id BIGINT, + store_id BIGINT, + phone VARCHAR(20), + consent_at DATETIME, + consent_ip VARCHAR(64), + create_time DATETIME, + update_time DATETIME + ) + """); + } + } + + private static void insertSourceRows(Connection connection) throws Exception { + try (Statement statement = connection.createStatement()) { + statement.execute("INSERT INTO t_user VALUES (7, 'staff', 0)"); + statement.execute("INSERT INTO t_store_customer VALUES (30, 10, 88, '13800138001', 0)"); + statement.execute(""" + INSERT INTO t_appointment VALUES + (100, 10, 88, 7, '2026-08-01 10:00:00', '2026-07-28 09:00:00', 0) + """); + statement.execute(""" + INSERT INTO t_report VALUES + (9, 100, 10, 88, 7, 'secret-report-token', '2026-08-01 11:00:00', 0) + """); + statement.execute(""" + INSERT INTO t_report_lead VALUES + (5, 9, 10, '13800138001', '2026-08-01 11:30:00', '127.0.0.1', + '2026-08-01 11:30:00', '2026-08-01 11:30:00') + """); + } + } +} diff --git a/src/test/java/com/petstore/mapper/IdentityOwnershipQueryTest.java b/src/test/java/com/petstore/mapper/IdentityOwnershipQueryTest.java index 03d0ccf..47234e9 100644 --- a/src/test/java/com/petstore/mapper/IdentityOwnershipQueryTest.java +++ b/src/test/java/com/petstore/mapper/IdentityOwnershipQueryTest.java @@ -1,6 +1,7 @@ package com.petstore.mapper; import com.petstore.entity.Appointment; +import com.petstore.entity.BusinessEvent; import com.petstore.entity.Report; import com.petstore.entity.StoreCustomer; import com.petstore.service.ServiceTypeService; @@ -12,6 +13,8 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.domain.PageRequest; import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -29,6 +32,7 @@ class IdentityOwnershipQueryTest { @Autowired private AppointmentMapper appointmentMapper; @Autowired private ReportMapper reportMapper; @Autowired private StoreCustomerMapper storeCustomerMapper; + @Autowired private BusinessEventMapper businessEventMapper; @MockBean private ServiceTypeService serviceTypeService; @Test @@ -78,6 +82,24 @@ class IdentityOwnershipQueryTest { .findFirstByStoreIdAndPhone(10L, "13800138001").orElseThrow().getId()); } + @Test + void businessEventFunnelCountsDistinctAggregatesInsideStoreAndDay() { + LocalDateTime from = LocalDateTime.of(2026, 8, 1, 0, 0); + businessEventMapper.saveAllAndFlush(List.of( + event(10L, "report_opened", 9L, from.plusHours(10)), + event(10L, "report_reopened", 9L, from.plusHours(11)), + event(10L, "report_opened", 10L, from.plusHours(12)), + event(20L, "report_opened", 11L, from.plusHours(12)) + )); + + assertEquals(2L, businessEventMapper.countDistinctAggregatesByTypes( + 10L, + List.of("report_opened", "report_reopened"), + from, + from.plusDays(1) + )); + } + private Appointment appointment(Long customerUserId, Long legacyUserId) { Appointment appointment = new Appointment(); appointment.setPetName("小白"); @@ -103,4 +125,19 @@ class IdentityOwnershipQueryTest { report.setCreateTime(LocalDateTime.of(2026, 8, 2, 11, 0)); return report; } + + private BusinessEvent event(Long storeId, String type, Long aggregateId, LocalDateTime occurredAt) { + BusinessEvent event = new BusinessEvent(); + event.setEventId(UUID.randomUUID().toString()); + event.setEventType(type); + event.setEventVersion(1); + event.setStoreId(storeId); + event.setAggregateType("report"); + event.setAggregateId(aggregateId); + event.setSource("public_report"); + event.setOccurredAt(occurredAt); + event.setMetadataJson("{}"); + event.setCreateTime(occurredAt); + return event; + } } diff --git a/src/test/java/com/petstore/service/AdminBusinessEventServiceTest.java b/src/test/java/com/petstore/service/AdminBusinessEventServiceTest.java new file mode 100644 index 0000000..e20e786 --- /dev/null +++ b/src/test/java/com/petstore/service/AdminBusinessEventServiceTest.java @@ -0,0 +1,52 @@ +package com.petstore.service; + +import com.petstore.mapper.BusinessEventMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminBusinessEventServiceTest { + + @Mock private BusinessEventMapper businessEventMapper; + @InjectMocks private AdminBusinessEventService service; + + @Test + void reportFunnelCountsDistinctFactsAndMarksUntrackedStages() { + LocalDate date = LocalDate.of(2026, 8, 1); + when(businessEventMapper.countDistinctAggregates( + eq(10L), eq(BusinessEventService.REPORT_SUBMITTED), any(LocalDateTime.class), any(LocalDateTime.class) + )).thenReturn(8L); + when(businessEventMapper.countDistinctAggregatesByTypes( + eq(10L), any(Collection.class), any(LocalDateTime.class), any(LocalDateTime.class) + )).thenReturn(6L); + when(businessEventMapper.countDistinctAggregates( + eq(10L), eq(BusinessEventService.LEAD_SUBMITTED), any(LocalDateTime.class), any(LocalDateTime.class) + )).thenReturn(3L); + + Map data = service.reportFunnel(10L, date); + + assertEquals("2026-08-01", data.get("date")); + assertEquals(8L, data.get("reportSubmittedCount")); + assertEquals(6L, data.get("reportOpenedCount")); + assertEquals(3L, data.get("leadSubmittedCount")); + assertNull(data.get("reportSentCount")); + assertNull(data.get("rebookCreatedCount")); + assertFalse((Boolean) data.get("reportSentTrackingReady")); + assertFalse((Boolean) data.get("rebookTrackingReady")); + } +} diff --git a/src/test/java/com/petstore/service/AppointmentServiceTest.java b/src/test/java/com/petstore/service/AppointmentServiceTest.java index 374d843..4400b57 100644 --- a/src/test/java/com/petstore/service/AppointmentServiceTest.java +++ b/src/test/java/com/petstore/service/AppointmentServiceTest.java @@ -35,6 +35,7 @@ class AppointmentServiceTest { @Mock private ReportMapper reportMapper; @Mock private PetMapper petMapper; @Mock private StoreCustomerService storeCustomerService; + @Mock private BusinessEventService businessEventService; @InjectMocks private AppointmentService appointmentService; @@ -73,6 +74,9 @@ class AppointmentServiceTest { var result = appointmentService.transitionStatus(1L, "done"); assertEquals(200, result.get("code")); assertEquals("done", appt.getStatus()); + verify(businessEventService).recordAppointmentStatusChanged( + eq(appt), eq("doing"), eq("done"), isNull(), isNull(), any(LocalDateTime.class) + ); } @Test @@ -103,6 +107,9 @@ class AppointmentServiceTest { assertEquals(200, result.get("code")); assertEquals("doing", appt.getStatus()); assertEquals(5L, appt.getAssignedUserId()); + verify(businessEventService).recordAppointmentStatusChanged( + eq(appt), eq("new"), eq("doing"), eq(5L), isNull(), any(LocalDateTime.class) + ); } @Test @@ -135,6 +142,7 @@ class AppointmentServiceTest { assertEquals(200, result.get("code")); verify(storeCustomerService).touchBooking(eq(10L), eq(88L), eq(7L), any(LocalDateTime.class)); verify(appointmentMapper).save(appointment); + verify(businessEventService).recordAppointmentCreated(eq(appointment), isNull(), isNull()); } private Appointment newAppointment(Long id, String status) { diff --git a/src/test/java/com/petstore/service/BusinessEventServiceTest.java b/src/test/java/com/petstore/service/BusinessEventServiceTest.java new file mode 100644 index 0000000..8f276e8 --- /dev/null +++ b/src/test/java/com/petstore/service/BusinessEventServiceTest.java @@ -0,0 +1,143 @@ +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.StoreCustomer; +import com.petstore.mapper.BusinessEventMapper; +import com.petstore.mapper.ReportMapper; +import com.petstore.mapper.StoreCustomerMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BusinessEventServiceTest { + + @Mock private BusinessEventMapper businessEventMapper; + @Mock private StoreCustomerMapper storeCustomerMapper; + @Mock private ReportMapper reportMapper; + + private BusinessEventService service; + + @BeforeEach + void setUp() { + service = new BusinessEventService( + businessEventMapper, + storeCustomerMapper, + reportMapper, + new ObjectMapper() + ); + } + + @Test + void appointmentCreatedUsesStableCustomerAndLowSensitivityMetadata() { + StoreCustomer storeCustomer = new StoreCustomer(); + storeCustomer.setId(30L); + Appointment appointment = new Appointment(); + appointment.setId(100L); + appointment.setStoreId(10L); + appointment.setCustomerUserId(88L); + appointment.setCreatedByUserId(7L); + appointment.setCreateTime(LocalDateTime.of(2026, 8, 1, 10, 0)); + when(businessEventMapper.save(any(BusinessEvent.class))).thenAnswer(inv -> inv.getArgument(0)); + when(businessEventMapper.findFirstByIdempotencyKey("appointment_created:100")) + .thenReturn(Optional.empty()); + + BusinessEvent event = service.recordAppointmentCreated(appointment, storeCustomer, "staff"); + + assertEquals(BusinessEventService.APPOINTMENT_CREATED, event.getEventType()); + assertEquals(30L, event.getStoreCustomerId()); + assertEquals("admin", event.getSource()); + assertEquals("{\"bookingOrigin\":\"admin\"}", event.getMetadataJson()); + assertFalse(event.getMetadataJson().contains("phone")); + assertFalse(event.getMetadataJson().contains("token")); + } + + @Test + void statusToDoingRecordsGenericAndServiceStartedFacts() { + Appointment appointment = new Appointment(); + appointment.setId(100L); + appointment.setStoreId(10L); + appointment.setCustomerUserId(88L); + when(storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(10L, 88L)) + .thenReturn(Optional.empty()); + when(businessEventMapper.findFirstByIdempotencyKey(any())).thenReturn(Optional.empty()); + when(businessEventMapper.save(any(BusinessEvent.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.recordAppointmentStatusChanged( + appointment, + "new", + "doing", + 7L, + "staff", + LocalDateTime.of(2026, 8, 1, 10, 0) + ); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BusinessEvent.class); + verify(businessEventMapper, org.mockito.Mockito.times(2)).save(captor.capture()); + List types = captor.getAllValues().stream().map(BusinessEvent::getEventType).toList(); + assertTrue(types.contains(BusinessEventService.APPOINTMENT_STATUS_CHANGED)); + assertTrue(types.contains(BusinessEventService.SERVICE_STARTED)); + } + + @Test + void reportOpenStoresReportIdButNeverToken() { + Report report = new Report(); + report.setId(9L); + report.setStoreId(10L); + report.setCustomerUserId(88L); + when(reportMapper.findFirstByReportTokenAndDeletedFalse("secret-token-abc")) + .thenReturn(Optional.of(report)); + when(storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(10L, 88L)) + .thenReturn(Optional.empty()); + when(businessEventMapper.save(any(BusinessEvent.class))).thenAnswer(inv -> inv.getArgument(0)); + + boolean recorded = service.recordReportOpenByToken( + "secret-token-abc", + "repeat", + LocalDateTime.of(2026, 8, 1, 10, 0) + ); + + assertTrue(recorded); + ArgumentCaptor captor = ArgumentCaptor.forClass(BusinessEvent.class); + verify(businessEventMapper).save(captor.capture()); + BusinessEvent event = captor.getValue(); + assertEquals(BusinessEventService.REPORT_REOPENED, event.getEventType()); + assertEquals(9L, event.getAggregateId()); + assertFalse(event.getMetadataJson().contains("secret-token-abc")); + assertFalse(event.getMetadataJson().toLowerCase().contains("token")); + } + + @Test + void idempotentReportSubmissionReturnsExistingEvent() { + Report report = new Report(); + report.setId(9L); + report.setStoreId(10L); + BusinessEvent existing = new BusinessEvent(); + existing.setId(50L); + when(businessEventMapper.findFirstByIdempotencyKey("report_submitted:9")) + .thenReturn(Optional.of(existing)); + + BusinessEvent result = service.recordReportSubmitted(report, "staff"); + + assertEquals(50L, result.getId()); + verify(businessEventMapper, never()).save(any()); + } +} diff --git a/src/test/java/com/petstore/service/ReportLeadServiceStoreCustomerTest.java b/src/test/java/com/petstore/service/ReportLeadServiceStoreCustomerTest.java index dc3f1e5..35bdde1 100644 --- a/src/test/java/com/petstore/service/ReportLeadServiceStoreCustomerTest.java +++ b/src/test/java/com/petstore/service/ReportLeadServiceStoreCustomerTest.java @@ -31,6 +31,7 @@ class ReportLeadServiceStoreCustomerTest { @Mock private UserService userService; @Mock private WechatMiniProgramService wechatMiniProgramService; @Mock private StoreCustomerService storeCustomerService; + @Mock private BusinessEventService businessEventService; @InjectMocks private ReportLeadService reportLeadService; @Test @@ -58,5 +59,6 @@ class ReportLeadServiceStoreCustomerTest { assertFalse(outcome.repeatSubmit()); verify(storeCustomerService).touchLead(eq(10L), eq("13800138001"), eq(null), any(LocalDateTime.class)); + verify(businessEventService).recordLeadSubmitted(any(ReportLead.class), eq(null), eq(false)); } } diff --git a/src/test/java/com/petstore/service/ReportServiceTest.java b/src/test/java/com/petstore/service/ReportServiceTest.java index bbc7f43..f16db4f 100644 --- a/src/test/java/com/petstore/service/ReportServiceTest.java +++ b/src/test/java/com/petstore/service/ReportServiceTest.java @@ -35,6 +35,7 @@ class ReportServiceTest { @Mock private ReportImageMapper reportImageMapper; @Mock private AppointmentMapper appointmentMapper; @Mock private UserMapper userMapper; + @Mock private BusinessEventService businessEventService; @InjectMocks private ReportService reportService; @@ -99,6 +100,10 @@ class ReportServiceTest { assertEquals(5L, created.getUserId()); assertEquals("技师甲", created.getStaffName()); assertNotNull(created.getReportToken()); + verify(businessEventService).recordAppointmentStatusChanged( + eq(appt), eq("doing"), eq("done"), eq(5L), isNull(), any(LocalDateTime.class) + ); + verify(businessEventService).recordReportSubmitted(created, null); } @Test