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。
|
脚本创建 `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)已轮换
|
||||||
|
|
||||||
## 安全说明
|
## 安全说明
|
||||||
|
|||||||
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_store_customer.sql` 必须在上述身份迁移之后执行:建立 `t_store_customer` 门店客户主档,并从预约与报告留资回填历史关系。脚本末尾三个验证计数必须为 0。
|
||||||
|
|
||||||
|
`20260801_create_business_event.sql` 必须在客户主档迁移之后执行:建立不可变 `t_business_event`,回填预约创建、报告提交、可确认的服务完成和留资提交。脚本末尾四个验证计数必须为 0;脚本不回填无法从数据库可靠恢复的报告打开与服务开始事件。
|
||||||
|
|
||||||
正式迁移框架仍属于 Phase 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())) {
|
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 且同店。 */
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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.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 等)。
|
||||||
* 允许:new→cancel、doing→done;new→doing 请走 {@link #startService}。
|
* 允许:new→cancel、doing→done;new→doing 请走 {@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) {
|
||||||
|
|||||||
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.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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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.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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 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) {
|
||||||
|
|||||||
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 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user