feat: persist core business events

This commit is contained in:
malei 2026-08-01 22:29:20 +08:00
parent 0cf6178288
commit b0149ff0eb
22 changed files with 1307 additions and 13 deletions

View File

@ -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。 脚本创建 `t_store_customer`,从预约和报告留资回填本店客户关系。末尾 `appointments_without_store_customer`、`leads_without_store_customer`、`store_customers_linked_to_non_customer` 必须全部为 0。
6. **业务事件落库**:客户主档验证完成后执行:
```bash
mysql -h <host> -u <user> -p petstore < db/migrations/20260801_create_business_event.sql
```
脚本创建不可变 `t_business_event` 并回填可确认的历史事实。末尾四个验证计数必须全部为 0报告打开和服务开始没有可靠历史数据不做猜测性回填。
### production profile ### production profile
```bash ```bash
@ -148,6 +154,7 @@ npm --prefix frontend run build:mp-weixin
- [ ] 历史图片 URL 已执行一次性修复 SQL - [ ] 历史图片 URL 已执行一次性修复 SQL
- [ ] 已执行 `20260801_split_service_identity.sql`,并留存未解析预约/报告验证结果 - [ ] 已执行 `20260801_split_service_identity.sql`,并留存未解析预约/报告验证结果
- [ ] 已按顺序执行 `20260801_create_store_customer.sql`,三项主档验证计数均为 0 - [ ] 已按顺序执行 `20260801_create_store_customer.sql`,三项主档验证计数均为 0
- [ ] 已执行 `20260801_create_business_event.sql`,四项事件回填验证计数均为 0
- [ ] 已暴露凭据DB 密码、微信 AppSecret已轮换 - [ ] 已暴露凭据DB 密码、微信 AppSecret已轮换
## 安全说明 ## 安全说明

View File

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

View File

@ -12,4 +12,6 @@
`20260801_create_store_customer.sql` 必须在上述身份迁移之后执行:建立 `t_store_customer` 门店客户主档,并从预约与报告留资回填历史关系。脚本末尾三个验证计数必须为 0。 `20260801_create_store_customer.sql` 必须在上述身份迁移之后执行:建立 `t_store_customer` 门店客户主档,并从预约与报告留资回填历史关系。脚本末尾三个验证计数必须为 0。
`20260801_create_business_event.sql` 必须在客户主档迁移之后执行:建立不可变 `t_business_event`,回填预约创建、报告提交、可确认的服务完成和留资提交。脚本末尾四个验证计数必须为 0脚本不回填无法从数据库可靠恢复的报告打开与服务开始事件。
正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。 正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。

View File

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

View File

@ -209,7 +209,7 @@ public class AppointmentController {
if (u.storeId() != null && !u.storeId().equals(appt.getStoreId())) { if (u.storeId() != null && !u.storeId().equals(appt.getStoreId())) {
return Map.of("code", 403, "message", "无权操作他店预约", "bizCode", "FORBIDDEN"); 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 { } else {
return Map.of("code", 403, "message", "无权操作", "bizCode", "FORBIDDEN"); return Map.of("code", 403, "message", "无权操作", "bizCode", "FORBIDDEN");
} }
return appointmentService.transitionStatus(id, status); return appointmentService.transitionStatus(id, status, u.userId(), u.role());
} }
/** 删除预约:仅 boss/staff 且同店。 */ /** 删除预约:仅 boss/staff 且同店。 */

View File

@ -9,6 +9,7 @@ import com.petstore.entity.Store;
import com.petstore.entity.User; import com.petstore.entity.User;
import com.petstore.entity.Appointment; import com.petstore.entity.Appointment;
import com.petstore.service.AppointmentService; import com.petstore.service.AppointmentService;
import com.petstore.service.BusinessEventService;
import com.petstore.service.ReportHighlightVideoService; import com.petstore.service.ReportHighlightVideoService;
import com.petstore.service.ReportService; import com.petstore.service.ReportService;
import com.petstore.service.ReportTestimonialService; import com.petstore.service.ReportTestimonialService;
@ -23,6 +24,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import java.util.*; import java.util.*;
import java.time.LocalDateTime;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@ -37,6 +39,7 @@ public class ReportController {
private final ReportHighlightVideoService reportHighlightVideoService; private final ReportHighlightVideoService reportHighlightVideoService;
private final ReportTestimonialService reportTestimonialService; private final ReportTestimonialService reportTestimonialService;
private final UserService userService; private final UserService userService;
private final BusinessEventService businessEventService;
@Value("${app.base-url:http://localhost:8080}") @Value("${app.base-url:http://localhost:8080}")
private String baseUrl; private String baseUrl;
@ -52,7 +55,7 @@ public class ReportController {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8)); byte[] digest = md.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(); 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])); sb.append(String.format("%02x", digest[i]));
} }
return sb.toString(); return sb.toString();
@ -135,7 +138,7 @@ public class ReportController {
} }
/** /**
* P0 埋点报告打开首访/再次当前写入应用日志便于日志平台统计不落库 * 报告打开首访/再次写入低敏 BusinessEvent日志仅保留 token hash
*/ */
@PostMapping("/open-track") @PostMapping("/open-track")
public Map<String, Object> trackReportOpen(@RequestBody Map<String, Object> body) { public Map<String, Object> trackReportOpen(@RequestBody Map<String, Object> body) {
@ -145,7 +148,8 @@ public class ReportController {
return Map.of("code", 400, "message", "token 必填"); return Map.of("code", 400, "message", "token 必填");
} }
String visitType = body.get("visitType") == null ? "" : body.get("visitType").toString(); 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); return Map.of("code", 200);
} }

View File

@ -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;
/**
* 不可变业务事实用于经营漏斗客户时间线与后续审计不承载业务对象当前状态
*
* <p>metadataJson 只允许服务端构造的低敏枚举/布尔维度不得写手机号报告 token
* 媒体 URLIPopenid/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;
}

View File

@ -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<BusinessEvent, Long> {
Optional<BusinessEvent> findFirstByIdempotencyKey(String idempotencyKey);
List<BusinessEvent> 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<String> eventTypes,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to
);
}

View File

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

View File

@ -3,6 +3,7 @@ package com.petstore.service;
import com.petstore.entity.Appointment; import com.petstore.entity.Appointment;
import com.petstore.entity.Pet; import com.petstore.entity.Pet;
import com.petstore.entity.Report; import com.petstore.entity.Report;
import com.petstore.entity.StoreCustomer;
import com.petstore.entity.User; import com.petstore.entity.User;
import com.petstore.mapper.AppointmentMapper; import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.PetMapper; import com.petstore.mapper.PetMapper;
@ -39,6 +40,7 @@ public class AppointmentService {
private final ReportMapper reportMapper; private final ReportMapper reportMapper;
private final PetMapper petMapper; private final PetMapper petMapper;
private final StoreCustomerService storeCustomerService; private final StoreCustomerService storeCustomerService;
private final BusinessEventService businessEventService;
// 宠主查看归属于自己的预约兼容 customer_user_id 尚未回填的旧数据 // 宠主查看归属于自己的预约兼容 customer_user_id 尚未回填的旧数据
public List<Appointment> getByUserId(Long customerUserId) { public List<Appointment> getByUserId(Long customerUserId) {
@ -201,8 +203,9 @@ public class AppointmentService {
appointment.setCreateTime(LocalDateTime.now()); appointment.setCreateTime(LocalDateTime.now());
appointment.setUpdateTime(LocalDateTime.now()); appointment.setUpdateTime(LocalDateTime.now());
appointment.setDeleted(false); appointment.setDeleted(false);
StoreCustomer storeCustomer;
try { try {
storeCustomerService.touchBooking( storeCustomer = storeCustomerService.touchBooking(
appointment.getStoreId(), appointment.getStoreId(),
appointment.getCustomerUserId(), appointment.getCustomerUserId(),
appointment.getCreatedByUserId(), appointment.getCreatedByUserId(),
@ -216,6 +219,11 @@ public class AppointmentService {
); );
} }
Appointment saved = appointmentMapper.save(appointment); Appointment saved = appointmentMapper.save(appointment);
businessEventService.recordAppointmentCreated(
saved,
storeCustomer,
resolveActorRole(saved.getCreatedByUserId())
);
Map<String, Object> ok = new HashMap<>(); Map<String, Object> ok = new HashMap<>();
ok.put("code", 200); ok.put("code", 200);
ok.put("message", "创建成功"); ok.put("message", "创建成功");
@ -227,7 +235,18 @@ public class AppointmentService {
* 预约状态迁移PUT /appointment/status * 预约状态迁移PUT /appointment/status
* 允许newcanceldoingdonenewdoing 请走 {@link #startService} * 允许newcanceldoingdonenewdoing 请走 {@link #startService}
*/ */
@Transactional
public Map<String, Object> transitionStatus(Long id, String nextStatus) { public Map<String, Object> transitionStatus(Long id, String nextStatus) {
return transitionStatus(id, nextStatus, null, null);
}
@Transactional
public Map<String, Object> transitionStatus(
Long id,
String nextStatus,
Long actorUserId,
String actorRole
) {
Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(id).orElse(null); Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(id).orElse(null);
if (appointment == null) { if (appointment == null) {
return Map.of("code", 404, "message", "预约不存在", "bizCode", "NOT_FOUND"); 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"); return Map.of("code", 400, "message", "不支持的状态变更", "bizCode", "INVALID_STATUS");
} }
LocalDateTime occurredAt = LocalDateTime.now();
appointment.setStatus(to); appointment.setStatus(to);
appointment.setUpdateTime(LocalDateTime.now()); appointment.setUpdateTime(occurredAt);
Appointment saved = appointmentMapper.save(appointment); Appointment saved = appointmentMapper.save(appointment);
businessEventService.recordAppointmentStatusChanged(
saved,
from,
to,
actorUserId,
actorRole,
occurredAt
);
return Map.of("code", 200, "message", "更新成功", "data", saved); return Map.of("code", 200, "message", "更新成功", "data", saved);
} }
/** 开始服务:仅 new → doing并指定技师 */ /** 开始服务:仅 new → doing并指定技师 */
@Transactional
public Map<String, Object> startService(Long appointmentId, Long staffUserId) { public Map<String, Object> startService(Long appointmentId, Long staffUserId) {
return startService(appointmentId, staffUserId, resolveActorRole(staffUserId));
}
@Transactional
public Map<String, Object> startService(Long appointmentId, Long staffUserId, String actorRole) {
Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(appointmentId).orElse(null); Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(appointmentId).orElse(null);
if (appointment == null) { if (appointment == null) {
return Map.of("code", 404, "message", "预约不存在", "bizCode", "NOT_FOUND"); return Map.of("code", 404, "message", "预约不存在", "bizCode", "NOT_FOUND");
@ -284,13 +318,31 @@ public class AppointmentService {
"bizCode", "INVALID_STATUS" "bizCode", "INVALID_STATUS"
); );
} }
LocalDateTime occurredAt = LocalDateTime.now();
appointment.setStatus("doing"); appointment.setStatus("doing");
appointment.setAssignedUserId(staffUserId); appointment.setAssignedUserId(staffUserId);
appointment.setUpdateTime(LocalDateTime.now()); appointment.setUpdateTime(occurredAt);
Appointment saved = appointmentMapper.save(appointment); Appointment saved = appointmentMapper.save(appointment);
businessEventService.recordAppointmentStatusChanged(
saved,
from,
"doing",
staffUserId,
actorRole,
occurredAt
);
return Map.of("code", 200, "message", "已开始服务", "data", saved); 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) { public boolean softDelete(Long id) {
Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(id).orElse(null); Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(id).orElse(null);
if (appointment == null) { if (appointment == null) {

View File

@ -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<String> 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<String, Object> 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<String, Object> metadata) {
Map<String, Object> safe = new LinkedHashMap<>();
if (metadata != null) {
for (Map.Entry<String, Object> 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<String, Object> metadata
) {}
}

View File

@ -3,6 +3,7 @@ package com.petstore.service;
import com.petstore.entity.Report; import com.petstore.entity.Report;
import com.petstore.entity.ReportLead; import com.petstore.entity.ReportLead;
import com.petstore.entity.ServiceInterval; import com.petstore.entity.ServiceInterval;
import com.petstore.entity.StoreCustomer;
import com.petstore.entity.User; import com.petstore.entity.User;
import com.petstore.mapper.ReportLeadMapper; import com.petstore.mapper.ReportLeadMapper;
import com.petstore.mapper.ReportMapper; import com.petstore.mapper.ReportMapper;
@ -32,6 +33,7 @@ public class ReportLeadService {
private final UserService userService; private final UserService userService;
private final WechatMiniProgramService wechatMiniProgramService; private final WechatMiniProgramService wechatMiniProgramService;
private final StoreCustomerService storeCustomerService; private final StoreCustomerService storeCustomerService;
private final BusinessEventService businessEventService;
/** 留资提交结果:记录 + 是否已将微信 openid 绑定到宠主账号S3→S4+ 是否同报告同手机号再次提交(幂等更新) */ /** 留资提交结果:记录 + 是否已将微信 openid 绑定到宠主账号S3→S4+ 是否同报告同手机号再次提交(幂等更新) */
public record LeadSubmitOutcome(ReportLead lead, boolean wechatBound, boolean repeatSubmit) {} 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); return new LeadSubmitOutcome(lead, wechatBound, repeatSubmit);
} }

View File

@ -8,6 +8,7 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@ -20,7 +21,9 @@ public class ReportService {
private final ReportImageMapper reportImageMapper; private final ReportImageMapper reportImageMapper;
private final AppointmentMapper appointmentMapper; private final AppointmentMapper appointmentMapper;
private final UserMapper userMapper; private final UserMapper userMapper;
private final BusinessEventService businessEventService;
@Transactional
public Report create(Report report) { public Report create(Report report) {
// 一约一份不变量报告必须绑定预约同一预约重复提交拒绝 // 一约一份不变量报告必须绑定预约同一预约重复提交拒绝
if (report.getAppointmentId() == null) { if (report.getAppointmentId() == null) {
@ -35,11 +38,15 @@ public class ReportService {
Long requestedAuthorStaffId = report.getAuthorStaffId() != null Long requestedAuthorStaffId = report.getAuthorStaffId() != null
? report.getAuthorStaffId() ? report.getAuthorStaffId()
: report.getUserId(); : report.getUserId();
Appointment boundAppointment = null;
boolean completedByReport = false;
LocalDateTime completedAt = null;
// 填充客户/技师快照字段并自动完成预约 // 填充客户/技师快照字段并自动完成预约
if (report.getAppointmentId() != null) { if (report.getAppointmentId() != null) {
Appointment appt = appointmentMapper.findByIdAndDeletedFalse(report.getAppointmentId()).orElse(null); Appointment appt = appointmentMapper.findByIdAndDeletedFalse(report.getAppointmentId()).orElse(null);
if (appt != null) { if (appt != null) {
boundAppointment = appt;
report.setPetName(appt.getPetName()); report.setPetName(appt.getPetName());
report.setServiceType(appt.getServiceType()); report.setServiceType(appt.getServiceType());
report.setAppointmentTime(appt.getAppointmentTime()); report.setAppointmentTime(appt.getAppointmentTime());
@ -54,9 +61,11 @@ public class ReportService {
} }
// 填写完报告仅进行中 已完成与预约状态机一致 // 填写完报告仅进行中 已完成与预约状态机一致
if ("doing".equalsIgnoreCase(appt.getStatus())) { if ("doing".equalsIgnoreCase(appt.getStatus())) {
completedAt = LocalDateTime.now();
appt.setStatus("done"); appt.setStatus("done");
appt.setUpdateTime(LocalDateTime.now()); appt.setUpdateTime(completedAt);
appointmentMapper.save(appt); appointmentMapper.save(appt);
completedByReport = true;
} }
} }
} }
@ -82,8 +91,9 @@ public class ReportService {
} }
} }
report.setCreateTime(LocalDateTime.now()); LocalDateTime now = LocalDateTime.now();
report.setUpdateTime(LocalDateTime.now()); report.setCreateTime(now);
report.setUpdateTime(now);
report.setDeleted(false); report.setDeleted(false);
// 先保存 Report 以获取生成的 ID // 先保存 Report 以获取生成的 ID
@ -96,10 +106,32 @@ public class ReportService {
reportImageMapper.save(img); 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; 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) { private Report enrichImages(Report r) {
if (r == null) return null; if (r == null) return null;

View File

@ -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<String, Object> 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<String, Object> result = controller.reportFunnel(date);
assertEquals(200, result.get("code"));
verify(adminBusinessEventService).reportFunnel(10L, date);
}
}

View File

@ -7,6 +7,7 @@ import com.petstore.entity.Report;
import com.petstore.entity.ReportImage; import com.petstore.entity.ReportImage;
import com.petstore.entity.Store; import com.petstore.entity.Store;
import com.petstore.service.AppointmentService; import com.petstore.service.AppointmentService;
import com.petstore.service.BusinessEventService;
import com.petstore.service.ReportHighlightVideoService; import com.petstore.service.ReportHighlightVideoService;
import com.petstore.service.ReportService; import com.petstore.service.ReportService;
import com.petstore.service.ReportTestimonialService; import com.petstore.service.ReportTestimonialService;
@ -44,6 +45,7 @@ class ReportControllerTest {
@Mock private ReportHighlightVideoService reportHighlightVideoService; @Mock private ReportHighlightVideoService reportHighlightVideoService;
@Mock private ReportTestimonialService reportTestimonialService; @Mock private ReportTestimonialService reportTestimonialService;
@Mock private UserService userService; @Mock private UserService userService;
@Mock private BusinessEventService businessEventService;
private ReportController controller; private ReportController controller;
@ -55,7 +57,8 @@ class ReportControllerTest {
storeService, storeService,
reportHighlightVideoService, reportHighlightVideoService,
reportTestimonialService, reportTestimonialService,
userService userService,
businessEventService
); );
ReflectionTestUtils.setField(controller, "baseUrl", "http://localhost:8080"); ReflectionTestUtils.setField(controller, "baseUrl", "http://localhost:8080");
} }
@ -271,6 +274,22 @@ class ReportControllerTest {
assertEquals(1L, captor.getValue().getUserId()); assertEquals(1L, captor.getValue().getUserId());
} }
@Test
void reportOpenPersistsLowSensitivityBusinessEvent() {
when(businessEventService.recordReportOpenByToken(eq("secret-token-abc"), eq("first"), any()))
.thenReturn(true);
Map<String, Object> 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() { private void setCurrentUserBoss() {
CurrentUserContext.set(new CurrentUser(1L, 10L, "boss")); CurrentUserContext.set(new CurrentUser(1L, 10L, "boss"));
} }

View File

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

View File

@ -1,6 +1,7 @@
package com.petstore.mapper; package com.petstore.mapper;
import com.petstore.entity.Appointment; import com.petstore.entity.Appointment;
import com.petstore.entity.BusinessEvent;
import com.petstore.entity.Report; import com.petstore.entity.Report;
import com.petstore.entity.StoreCustomer; import com.petstore.entity.StoreCustomer;
import com.petstore.service.ServiceTypeService; import com.petstore.service.ServiceTypeService;
@ -12,6 +13,8 @@ import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
@ -29,6 +32,7 @@ class IdentityOwnershipQueryTest {
@Autowired private AppointmentMapper appointmentMapper; @Autowired private AppointmentMapper appointmentMapper;
@Autowired private ReportMapper reportMapper; @Autowired private ReportMapper reportMapper;
@Autowired private StoreCustomerMapper storeCustomerMapper; @Autowired private StoreCustomerMapper storeCustomerMapper;
@Autowired private BusinessEventMapper businessEventMapper;
@MockBean private ServiceTypeService serviceTypeService; @MockBean private ServiceTypeService serviceTypeService;
@Test @Test
@ -78,6 +82,24 @@ class IdentityOwnershipQueryTest {
.findFirstByStoreIdAndPhone(10L, "13800138001").orElseThrow().getId()); .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) { private Appointment appointment(Long customerUserId, Long legacyUserId) {
Appointment appointment = new Appointment(); Appointment appointment = new Appointment();
appointment.setPetName("小白"); appointment.setPetName("小白");
@ -103,4 +125,19 @@ class IdentityOwnershipQueryTest {
report.setCreateTime(LocalDateTime.of(2026, 8, 2, 11, 0)); report.setCreateTime(LocalDateTime.of(2026, 8, 2, 11, 0));
return report; 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;
}
} }

View File

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

View File

@ -35,6 +35,7 @@ class AppointmentServiceTest {
@Mock private ReportMapper reportMapper; @Mock private ReportMapper reportMapper;
@Mock private PetMapper petMapper; @Mock private PetMapper petMapper;
@Mock private StoreCustomerService storeCustomerService; @Mock private StoreCustomerService storeCustomerService;
@Mock private BusinessEventService businessEventService;
@InjectMocks private AppointmentService appointmentService; @InjectMocks private AppointmentService appointmentService;
@ -73,6 +74,9 @@ class AppointmentServiceTest {
var result = appointmentService.transitionStatus(1L, "done"); var result = appointmentService.transitionStatus(1L, "done");
assertEquals(200, result.get("code")); assertEquals(200, result.get("code"));
assertEquals("done", appt.getStatus()); assertEquals("done", appt.getStatus());
verify(businessEventService).recordAppointmentStatusChanged(
eq(appt), eq("doing"), eq("done"), isNull(), isNull(), any(LocalDateTime.class)
);
} }
@Test @Test
@ -103,6 +107,9 @@ class AppointmentServiceTest {
assertEquals(200, result.get("code")); assertEquals(200, result.get("code"));
assertEquals("doing", appt.getStatus()); assertEquals("doing", appt.getStatus());
assertEquals(5L, appt.getAssignedUserId()); assertEquals(5L, appt.getAssignedUserId());
verify(businessEventService).recordAppointmentStatusChanged(
eq(appt), eq("new"), eq("doing"), eq(5L), isNull(), any(LocalDateTime.class)
);
} }
@Test @Test
@ -135,6 +142,7 @@ class AppointmentServiceTest {
assertEquals(200, result.get("code")); assertEquals(200, result.get("code"));
verify(storeCustomerService).touchBooking(eq(10L), eq(88L), eq(7L), any(LocalDateTime.class)); verify(storeCustomerService).touchBooking(eq(10L), eq(88L), eq(7L), any(LocalDateTime.class));
verify(appointmentMapper).save(appointment); verify(appointmentMapper).save(appointment);
verify(businessEventService).recordAppointmentCreated(eq(appointment), isNull(), isNull());
} }
private Appointment newAppointment(Long id, String status) { private Appointment newAppointment(Long id, String status) {

View File

@ -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<BusinessEvent> captor = ArgumentCaptor.forClass(BusinessEvent.class);
verify(businessEventMapper, org.mockito.Mockito.times(2)).save(captor.capture());
List<String> 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<BusinessEvent> 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());
}
}

View File

@ -31,6 +31,7 @@ class ReportLeadServiceStoreCustomerTest {
@Mock private UserService userService; @Mock private UserService userService;
@Mock private WechatMiniProgramService wechatMiniProgramService; @Mock private WechatMiniProgramService wechatMiniProgramService;
@Mock private StoreCustomerService storeCustomerService; @Mock private StoreCustomerService storeCustomerService;
@Mock private BusinessEventService businessEventService;
@InjectMocks private ReportLeadService reportLeadService; @InjectMocks private ReportLeadService reportLeadService;
@Test @Test
@ -58,5 +59,6 @@ class ReportLeadServiceStoreCustomerTest {
assertFalse(outcome.repeatSubmit()); assertFalse(outcome.repeatSubmit());
verify(storeCustomerService).touchLead(eq(10L), eq("13800138001"), eq(null), any(LocalDateTime.class)); verify(storeCustomerService).touchLead(eq(10L), eq("13800138001"), eq(null), any(LocalDateTime.class));
verify(businessEventService).recordLeadSubmitted(any(ReportLead.class), eq(null), eq(false));
} }
} }

View File

@ -35,6 +35,7 @@ class ReportServiceTest {
@Mock private ReportImageMapper reportImageMapper; @Mock private ReportImageMapper reportImageMapper;
@Mock private AppointmentMapper appointmentMapper; @Mock private AppointmentMapper appointmentMapper;
@Mock private UserMapper userMapper; @Mock private UserMapper userMapper;
@Mock private BusinessEventService businessEventService;
@InjectMocks private ReportService reportService; @InjectMocks private ReportService reportService;
@ -99,6 +100,10 @@ class ReportServiceTest {
assertEquals(5L, created.getUserId()); assertEquals(5L, created.getUserId());
assertEquals("技师甲", created.getStaffName()); assertEquals("技师甲", created.getStaffName());
assertNotNull(created.getReportToken()); assertNotNull(created.getReportToken());
verify(businessEventService).recordAppointmentStatusChanged(
eq(appt), eq("doing"), eq("done"), eq(5L), isNull(), any(LocalDateTime.class)
);
verify(businessEventService).recordReportSubmitted(created, null);
} }
@Test @Test