feat: persist core business events
This commit is contained in:
parent
0cf6178288
commit
b0149ff0eb
@ -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 <host> -u <user> -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)已轮换
|
||||
|
||||
## 安全说明
|
||||
|
||||
190
db/migrations/20260801_create_business_event.sql
Normal file
190
db/migrations/20260801_create_business_event.sql
Normal 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;
|
||||
@ -12,4 +12,6 @@
|
||||
|
||||
`20260801_create_store_customer.sql` 必须在上述身份迁移之后执行:建立 `t_store_customer` 门店客户主档,并从预约与报告留资回填历史关系。脚本末尾三个验证计数必须为 0。
|
||||
|
||||
`20260801_create_business_event.sql` 必须在客户主档迁移之后执行:建立不可变 `t_business_event`,回填预约创建、报告提交、可确认的服务完成和留资提交。脚本末尾四个验证计数必须为 0;脚本不回填无法从数据库可靠恢复的报告打开与服务开始事件。
|
||||
|
||||
正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 且同店。 */
|
||||
|
||||
@ -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<String, Object> trackReportOpen(@RequestBody Map<String, Object> 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);
|
||||
}
|
||||
|
||||
|
||||
88
src/main/java/com/petstore/entity/BusinessEvent.java
Normal file
88
src/main/java/com/petstore/entity/BusinessEvent.java
Normal 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、
|
||||
* 媒体 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;
|
||||
}
|
||||
48
src/main/java/com/petstore/mapper/BusinessEventMapper.java
Normal file
48
src/main/java/com/petstore/mapper/BusinessEventMapper.java
Normal 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
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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<Appointment> 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<String, Object> 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<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);
|
||||
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<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);
|
||||
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) {
|
||||
|
||||
316
src/main/java/com/petstore/service/BusinessEventService.java
Normal file
316
src/main/java/com/petstore/service/BusinessEventService.java
Normal 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
|
||||
) {}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -97,9 +107,31 @@ public class ReportService {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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<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() {
|
||||
CurrentUserContext.set(new CurrentUser(1L, 10L, "boss"));
|
||||
}
|
||||
|
||||
@ -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')
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
143
src/test/java/com/petstore/service/BusinessEventServiceTest.java
Normal file
143
src/test/java/com/petstore/service/BusinessEventServiceTest.java
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user