feat: add follow-up task lifecycle and rebook attribution

This commit is contained in:
malei 2026-08-02 02:11:28 +08:00
parent 07bad405cd
commit c0771b34ff
27 changed files with 2024 additions and 38 deletions

View File

@ -119,6 +119,12 @@ curl http://localhost:8080/api/store/list
```
脚本为被合并的软删除客户投影增加同店 canonical 指针。既有 `BusinessEvent` 保持不可变,时间线通过主档与别名共同读取历史事实。末尾两项别名状态/目标验证必须全部为 0。
11. **可执行回访任务与真实再次预约归因**:客户时间线迁移验证完成后执行:
```bash
mysql -h <host> -u <user> -p petstore < db/migrations/20260802_create_follow_up_task.sql
```
脚本仅为仍为 `pending` 的历史留资建立开放任务,并写入低敏 `follow_up_created` 事实;不会猜测已发送、已取消或已退订线索的处理结果。末尾五项客户归属、状态机、开放任务唯一性和事件完整性验证必须全部为 0。
### production profile
```bash
@ -134,14 +140,14 @@ production profile 下:
### 生产只读预检
完成备份和个迁移后,以最终生产环境变量运行:
完成备份和个迁移后,以最终生产环境变量运行:
```bash
chmod +x deploy/release-preflight.sh deploy/production-smoke.sh
BUILD_FIRST=1 deploy/release-preflight.sh
```
脚本先校验生产配置、上传目录、Java 与 FFmpeg再启动无 Web 的 `production` profile执行 JPA schema `validate`26 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。
脚本先校验生产配置、上传目录、Java 与 FFmpeg再启动无 Web 的 `production` profile执行 JPA schema `validate`31 项只读数据不变量检查,通过后退出。它不会执行迁移,也不会修复数据。
## 测试
@ -199,6 +205,8 @@ API_ORIGIN=https://<api-domain> backend/deploy/production-smoke.sh
- [ ] 已执行 `20260801_create_booking_capacity.sql`,容量与时长验证计数均为 0
- [ ] 已执行 `20260801_create_report_send_status.sql`,历史 `unknown` 与三项回执一致性验证均为 0
- [ ] 已执行 `20260802_create_store_onboarding.sql`,历史开通状态与六项邀请/回执验证均为 0
- [ ] 已执行 `20260802_create_store_customer_timeline.sql`,两项 StoreCustomer 别名不变量均为 0
- [ ] 已执行 `20260802_create_follow_up_task.sql`,五项回访任务与事件验证计数均为 0
- [ ] `CORS_ALLOWED_ORIGINS` 仅包含本次发布的显式 HTTPS Web 源
- [ ] `deploy/release-preflight.sh` 已以最终环境变量通过并留存输出
- [ ] readiness 与上线后只读冒烟全部通过
@ -211,6 +219,7 @@ API_ORIGIN=https://<api-domain> backend/deploy/production-smoke.sh
- **跨店/跨用户边界**:所有 protected 接口从 `CurrentUserContext` 派生 `userId/storeId/role`,不信任请求体里的身份字段。
- **公开报告页**`GET /api/report/get?token=` 不返回 top-level `userId/storeId/reportToken`;日志只记 token SHA-256 前 8 位 hash。
- **回访池脱敏**`/api/report/leads` 不返回完整 `wechatOpenid/wechatUnionid`,只返回 `wechatBound` + 脱敏 id。
- **回访执行权限**`/api/admin/follow-up-tasks` 仅本店 boss/staff列表中的完整手机号只用于已认证员工实际联系不写入 BusinessEvent、AuditLog、时间线或工作台预览。
- **上传路径保护**`FileController` 归一化路径 + 扩展名/MIME 校验,拒绝 `..` 逃逸。
- **员工邀请**:原始 256-bit token 只在创建响应返回一次,数据库只存 SHA-256邀请绑定微信核验手机号、限时、一次使用、可撤销。
- **操作审计**:门店开通/设置和员工权限操作写入不可变低敏 AuditLogmetadata 禁止 PII、凭证和原始请求体。

View File

@ -0,0 +1,164 @@
-- Petstore Phase 1可执行回访任务与再次预约归因
-- 前置20260802_create_store_customer_timeline.sql 已执行。
-- ReportLead 保留宠主同意/来源事实FollowUpTask 承载门店操作状态。
CREATE TABLE t_follow_up_task (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
task_id VARCHAR(36) NOT NULL,
store_id BIGINT NOT NULL,
store_customer_id BIGINT NOT NULL,
source_lead_id BIGINT NOT NULL,
source_report_id BIGINT NULL,
status VARCHAR(16) NOT NULL COMMENT 'pending | in_progress | completed | canceled',
outcome VARCHAR(24) NULL COMMENT 'rebooked | not_interested | invalid_contact | unsubscribed',
due_date DATE NOT NULL,
assigned_user_id BIGINT NULL,
attempt_count INT NOT NULL DEFAULT 0,
last_attempt_outcome VARCHAR(24) NULL COMMENT 'no_answer | follow_later',
last_contacted_at DATETIME NULL,
last_contacted_by_user_id BIGINT NULL,
closed_at DATETIME NULL,
closed_by_user_id BIGINT NULL,
rebooked_appointment_id BIGINT NULL,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
CONSTRAINT uk_follow_up_task_public_id UNIQUE (task_id),
INDEX idx_follow_up_store_status_due (store_id, status, due_date),
INDEX idx_follow_up_customer_status (store_customer_id, status),
INDEX idx_follow_up_lead_status (source_lead_id, status),
INDEX idx_follow_up_rebook (rebooked_appointment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店回访执行任务';
-- 只为仍待处理的历史留资建立开放任务;不得猜测已发送/取消记录的真实结果。
INSERT INTO t_follow_up_task (
task_id, store_id, store_customer_id, source_lead_id, source_report_id,
status, outcome, due_date, assigned_user_id, attempt_count,
last_attempt_outcome, last_contacted_at, last_contacted_by_user_id,
closed_at, closed_by_user_id, rebooked_appointment_id,
create_time, update_time, version
)
SELECT
UUID(),
l.store_id,
sc.id,
l.id,
l.report_id,
'pending',
NULL,
COALESCE(l.remind_date, CURRENT_DATE),
NULL,
0,
NULL,
NULL,
NULL,
NULL,
NULL,
NULL,
COALESCE(l.create_time, l.update_time, NOW()),
COALESCE(l.update_time, l.create_time, NOW()),
0
FROM t_report_lead l
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
AND l.remind_status = 'pending';
-- 历史开放任务形成可靠的 task_created 事实不复制手机号、IP、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(),
'follow_up_created',
1,
t.store_id,
t.store_customer_id,
'follow_up_task',
t.id,
NULL,
NULL,
'migration',
t.create_time,
CONCAT('{"dueDate":"', t.due_date, '"}'),
CONCAT('follow_up_created:', t.id),
NOW()
FROM t_follow_up_task t;
-- 验证:以下五项必须全部为 0。
SELECT COUNT(*) AS pending_leads_without_open_task_count
FROM t_report_lead l
LEFT JOIN t_follow_up_task t
ON t.source_lead_id = l.id
AND t.status IN ('pending', 'in_progress')
WHERE l.store_id IS NOT NULL
AND l.remind_status = 'pending'
AND t.id IS NULL;
SELECT COUNT(*) AS invalid_follow_up_customer_scope_count
FROM t_follow_up_task t
LEFT JOIN t_store_customer sc
ON sc.id = t.store_customer_id
AND sc.store_id = t.store_id
LEFT JOIN t_store_customer canonical
ON canonical.id = CASE
WHEN sc.deleted = 0 THEN sc.id
ELSE sc.merged_into_store_customer_id
END
AND canonical.store_id = t.store_id
AND canonical.deleted = 0
AND canonical.merged_into_store_customer_id IS NULL
WHERE sc.id IS NULL OR canonical.id IS NULL;
SELECT COUNT(*) AS invalid_follow_up_state_count
FROM t_follow_up_task t
WHERE t.status NOT IN ('pending', 'in_progress', 'completed', 'canceled')
OR (t.status IN ('pending', 'in_progress')
AND (t.outcome IS NOT NULL OR t.closed_at IS NOT NULL OR t.closed_by_user_id IS NOT NULL
OR t.rebooked_appointment_id IS NOT NULL))
OR (t.status = 'pending' AND t.assigned_user_id IS NOT NULL)
OR (t.status = 'in_progress' AND t.assigned_user_id IS NULL)
OR (t.status = 'completed'
AND (COALESCE(t.outcome, '') NOT IN ('rebooked', 'not_interested', 'invalid_contact')
OR t.closed_at IS NULL OR t.closed_by_user_id IS NULL
OR t.last_contacted_at IS NULL OR t.last_contacted_by_user_id IS NULL
OR t.attempt_count < 1))
OR (t.status = 'canceled'
AND (COALESCE(t.outcome, '') <> 'unsubscribed' OR t.closed_at IS NULL
OR t.closed_by_user_id IS NOT NULL))
OR (t.last_attempt_outcome IS NOT NULL
AND (t.last_attempt_outcome NOT IN ('no_answer', 'follow_later')
OR t.last_contacted_at IS NULL OR t.last_contacted_by_user_id IS NULL))
OR (t.outcome = 'rebooked' AND t.rebooked_appointment_id IS NULL)
OR (COALESCE(t.outcome, '') <> 'rebooked' AND t.rebooked_appointment_id IS NOT NULL)
OR (t.rebooked_appointment_id IS NOT NULL AND NOT EXISTS (
SELECT 1
FROM t_appointment a
WHERE a.id = t.rebooked_appointment_id
AND a.store_id = t.store_id
AND a.deleted = 0
))
OR t.attempt_count < 0;
SELECT COUNT(*) AS duplicate_open_follow_up_task_count
FROM (
SELECT source_lead_id
FROM t_follow_up_task
WHERE status IN ('pending', 'in_progress')
GROUP BY source_lead_id
HAVING COUNT(*) > 1
) duplicated;
SELECT COUNT(*) AS follow_up_tasks_without_created_event_count
FROM t_follow_up_task t
LEFT JOIN t_business_event e
ON e.idempotency_key = CONCAT('follow_up_created:', t.id)
AND e.event_type = 'follow_up_created'
AND e.store_id = t.store_id
AND e.aggregate_type = 'follow_up_task'
AND e.aggregate_id = t.id
WHERE e.id IS NULL;

View File

@ -22,4 +22,6 @@
`20260802_create_store_customer_timeline.sql` 必须在门店开通迁移之后执行:为被合并的软删除 StoreCustomer 投影增加同店 canonical 指针。既有 BusinessEvent 不改写,事实时间线同时读取 canonical 与别名的历史事件;脚本末尾两个验证计数必须为 0。
`20260802_create_follow_up_task.sql` 必须在客户时间线迁移之后执行:建立可领取、改期、关闭和真实再次预约归因的 `t_follow_up_task`。只为仍待处理的历史留资建立开放任务,不猜测其他历史结果;脚本末尾五个验证计数必须为 0。
正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。

View File

@ -76,6 +76,83 @@ public class ProductionDatabasePreflightRunner implements ApplicationRunner {
WHERE sc_alias.merged_into_store_customer_id IS NOT NULL
AND canonical.id IS NULL
"""),
new Invariant("pending_leads_without_open_follow_up_task", """
SELECT COUNT(*) FROM t_report_lead l
LEFT JOIN t_follow_up_task t
ON t.source_lead_id = l.id
AND t.status IN ('pending', 'in_progress')
WHERE l.store_id IS NOT NULL
AND l.remind_status = 'pending'
AND t.id IS NULL
"""),
new Invariant("invalid_follow_up_customer_scope", """
SELECT COUNT(*) FROM t_follow_up_task t
LEFT JOIN t_store_customer sc
ON sc.id = t.store_customer_id
AND sc.store_id = t.store_id
LEFT JOIN t_store_customer canonical
ON canonical.id = CASE
WHEN sc.deleted = 0 THEN sc.id
ELSE sc.merged_into_store_customer_id
END
AND canonical.store_id = t.store_id
AND canonical.deleted = 0
AND canonical.merged_into_store_customer_id IS NULL
WHERE sc.id IS NULL OR canonical.id IS NULL
"""),
new Invariant("invalid_follow_up_state", """
SELECT COUNT(*) FROM t_follow_up_task t
WHERE t.status NOT IN ('pending', 'in_progress', 'completed', 'canceled')
OR (t.status IN ('pending', 'in_progress')
AND (t.outcome IS NOT NULL OR t.closed_at IS NOT NULL OR t.closed_by_user_id IS NOT NULL
OR t.rebooked_appointment_id IS NOT NULL))
OR (t.status = 'pending' AND t.assigned_user_id IS NOT NULL)
OR (t.status = 'in_progress' AND t.assigned_user_id IS NULL)
OR (t.status = 'completed'
AND (COALESCE(t.outcome, '') NOT IN ('rebooked', 'not_interested', 'invalid_contact')
OR t.closed_at IS NULL OR t.closed_by_user_id IS NULL
OR t.last_contacted_at IS NULL OR t.last_contacted_by_user_id IS NULL
OR t.attempt_count < 1))
OR (t.status = 'canceled'
AND (COALESCE(t.outcome, '') <> 'unsubscribed' OR t.closed_at IS NULL
OR t.closed_by_user_id IS NOT NULL))
OR (t.last_attempt_outcome IS NOT NULL
AND (t.last_attempt_outcome NOT IN ('no_answer', 'follow_later')
OR t.last_contacted_at IS NULL OR t.last_contacted_by_user_id IS NULL))
OR (t.outcome = 'rebooked' AND t.rebooked_appointment_id IS NULL)
OR (COALESCE(t.outcome, '') <> 'rebooked' AND t.rebooked_appointment_id IS NOT NULL)
OR (t.rebooked_appointment_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM t_appointment a
WHERE a.id = t.rebooked_appointment_id
AND a.store_id = t.store_id
AND a.deleted = 0
))
OR t.attempt_count < 0
"""),
new Invariant("duplicate_open_follow_up_task", """
SELECT COUNT(*) FROM (
SELECT source_lead_id FROM t_follow_up_task
WHERE status IN ('pending', 'in_progress')
GROUP BY source_lead_id HAVING COUNT(*) > 1
) duplicated
"""),
new Invariant("follow_up_tasks_without_events", """
SELECT COUNT(*) FROM t_follow_up_task t
LEFT JOIN t_business_event created
ON created.idempotency_key = CONCAT('follow_up_created:', t.id)
AND created.event_type = 'follow_up_created'
AND created.store_id = t.store_id
AND created.aggregate_type = 'follow_up_task'
AND created.aggregate_id = t.id
LEFT JOIN t_business_event rebooked
ON rebooked.idempotency_key = CONCAT('rebook_created:', t.rebooked_appointment_id)
AND rebooked.event_type = 'rebook_created'
AND rebooked.store_id = t.store_id
AND rebooked.aggregate_type = 'appointment'
AND rebooked.aggregate_id = t.rebooked_appointment_id
WHERE created.id IS NULL
OR (t.outcome = 'rebooked' AND rebooked.id IS NULL)
"""),
new Invariant("appointments_without_created_event", """
SELECT COUNT(*) FROM t_appointment a
LEFT JOIN t_business_event e

View File

@ -0,0 +1,117 @@
package com.petstore.controller;
import com.petstore.auth.CurrentUser;
import com.petstore.auth.CurrentUserContext;
import com.petstore.service.FlowException;
import com.petstore.service.FollowUpTaskService;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
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;
/** 门店 Web 后台回访任务池;所有 storeId/actor 均从会话派生。 */
@RestController
@RequestMapping("/api/admin/follow-up-tasks")
@RequiredArgsConstructor
@CrossOrigin
public class AdminFollowUpTaskController {
private final FollowUpTaskService followUpTaskService;
@GetMapping("")
public Map<String, Object> list(
@RequestParam(required = false, defaultValue = "open") String status,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dueDateTo,
@RequestParam(required = false, defaultValue = "1") int page,
@RequestParam(required = false, defaultValue = "20") int pageSize
) {
CurrentUser user = CurrentUserContext.require();
if (!isStoreUser(user)) return forbidden();
try {
return ok(followUpTaskService.list(
user.storeId(), status, dueDateTo, page, pageSize, user.userId()
));
} catch (FlowException e) {
return error(e);
}
}
@PostMapping("/{taskId}/start")
public Map<String, Object> start(@PathVariable String taskId) {
CurrentUser user = CurrentUserContext.require();
if (!isStoreUser(user)) return forbidden();
try {
return ok(followUpTaskService.start(
user.storeId(), taskId, user.userId(), user.role()
));
} catch (FlowException e) {
return error(e);
}
}
@PostMapping("/{taskId}/reschedule")
public Map<String, Object> reschedule(
@PathVariable String taskId,
@RequestBody Map<String, Object> body
) {
CurrentUser user = CurrentUserContext.require();
if (!isStoreUser(user)) return forbidden();
try {
return ok(followUpTaskService.reschedule(
user.storeId(),
taskId,
text(body.get("dueDate")),
text(body.get("reason")),
user.userId(),
user.role()
));
} catch (FlowException e) {
return error(e);
}
}
@PostMapping("/{taskId}/close")
public Map<String, Object> close(
@PathVariable String taskId,
@RequestBody Map<String, Object> body
) {
CurrentUser user = CurrentUserContext.require();
if (!isStoreUser(user)) return forbidden();
try {
return ok(followUpTaskService.close(
user.storeId(), taskId, text(body.get("outcome")), user.userId(), user.role()
));
} catch (FlowException e) {
return error(e);
}
}
private static boolean isStoreUser(CurrentUser user) {
return user.isStoreUser() && user.storeId() != null;
}
private static Map<String, Object> ok(Object data) {
return Map.of("code", 200, "data", data);
}
private static Map<String, Object> error(FlowException e) {
return Map.of("code", e.code(), "message", e.getMessage(), "bizCode", e.bizCode());
}
private static Map<String, Object> forbidden() {
return Map.of("code", 403, "message", "仅门店员工可处理回访任务", "bizCode", "FORBIDDEN");
}
private static String text(Object value) {
return value == null ? null : value.toString().trim();
}
}

View File

@ -176,7 +176,14 @@ public class AppointmentController {
appointment.setPetId(Long.valueOf(params.get("petId").toString()));
}
return appointmentService.createBooking(appointment);
String followUpTaskId = optionalText(params.get("followUpTaskId"));
try {
return followUpTaskId.isBlank()
? appointmentService.createBooking(appointment)
: appointmentService.createBooking(appointment, followUpTaskId);
} catch (com.petstore.service.FlowException e) {
return Map.of("code", e.code(), "message", e.getMessage(), "bizCode", e.bizCode());
}
}
private static Long optionalLong(Object value) {

View File

@ -0,0 +1,114 @@
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 jakarta.persistence.Version;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 门店回访执行任务ReportLead 保留宠主同意与来源事实本实体承载可领取改期和闭环的操作状态
*/
@Data
@Entity
@Table(
name = "t_follow_up_task",
uniqueConstraints = @UniqueConstraint(name = "uk_follow_up_task_public_id", columnNames = "task_id"),
indexes = {
@Index(name = "idx_follow_up_store_status_due", columnList = "store_id,status,due_date"),
@Index(name = "idx_follow_up_customer_status", columnList = "store_customer_id,status"),
@Index(name = "idx_follow_up_lead_status", columnList = "source_lead_id,status"),
@Index(name = "idx_follow_up_rebook", columnList = "rebooked_appointment_id")
}
)
public class FollowUpTask {
public static final String STATUS_PENDING = "pending";
public static final String STATUS_IN_PROGRESS = "in_progress";
public static final String STATUS_COMPLETED = "completed";
public static final String STATUS_CANCELED = "canceled";
public static final String ATTEMPT_NO_ANSWER = "no_answer";
public static final String ATTEMPT_FOLLOW_LATER = "follow_later";
public static final String OUTCOME_REBOOKED = "rebooked";
public static final String OUTCOME_NOT_INTERESTED = "not_interested";
public static final String OUTCOME_INVALID_CONTACT = "invalid_contact";
public static final String OUTCOME_UNSUBSCRIBED = "unsubscribed";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 对管理端暴露的随机 ID不使用自增 ID 承担跨请求标识。 */
@Column(name = "task_id", nullable = false, length = 36)
private String taskId;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "store_customer_id", nullable = false)
private Long storeCustomerId;
@Column(name = "source_lead_id", nullable = false)
private Long sourceLeadId;
@Column(name = "source_report_id")
private Long sourceReportId;
/** pending / in_progress / completed / canceled */
@Column(nullable = false, length = 16)
private String status;
/** 仅终态使用rebooked / not_interested / invalid_contact / unsubscribed。 */
@Column(length = 24)
private String outcome;
@Column(name = "due_date", nullable = false)
private LocalDate dueDate;
@Column(name = "assigned_user_id")
private Long assignedUserId;
@Column(name = "attempt_count", nullable = false)
private Integer attemptCount = 0;
/** 最近一次非终态联系结果no_answer / follow_later。 */
@Column(name = "last_attempt_outcome", length = 24)
private String lastAttemptOutcome;
@Column(name = "last_contacted_at")
private LocalDateTime lastContactedAt;
@Column(name = "last_contacted_by_user_id")
private Long lastContactedByUserId;
@Column(name = "closed_at")
private LocalDateTime closedAt;
@Column(name = "closed_by_user_id")
private Long closedByUserId;
/** 只能由真实 Appointment 创建成功后写入。 */
@Column(name = "rebooked_appointment_id")
private Long rebookedAppointmentId;
@Column(name = "create_time", nullable = false)
private LocalDateTime createTime;
@Column(name = "update_time", nullable = false)
private LocalDateTime updateTime;
/** 防止两个员工同时处理同一任务时发生静默覆盖。 */
@Version
@Column(nullable = false)
private Long version = 0L;
}

View File

@ -0,0 +1,38 @@
package com.petstore.mapper;
import com.petstore.entity.FollowUpTask;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
public interface FollowUpTaskMapper
extends JpaRepository<FollowUpTask, Long>, JpaSpecificationExecutor<FollowUpTask> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from FollowUpTask t where t.taskId = :taskId and t.storeId = :storeId")
Optional<FollowUpTask> findByTaskIdAndStoreIdForUpdate(
@Param("taskId") String taskId,
@Param("storeId") Long storeId
);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from FollowUpTask t where t.sourceLeadId = :sourceLeadId and t.status in :statuses order by t.createTime desc")
List<FollowUpTask> findOpenBySourceLeadIdForUpdate(
@Param("sourceLeadId") Long sourceLeadId,
@Param("statuses") Collection<String> statuses
);
List<FollowUpTask> findByStoreIdAndStatusInAndDueDateLessThanEqualOrderByDueDateAscIdAsc(
Long storeId,
Collection<String> statuses,
LocalDate dueDate
);
}

View File

@ -1,7 +1,11 @@
package com.petstore.mapper;
import com.petstore.entity.ReportLead;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
@ -13,6 +17,10 @@ public interface ReportLeadMapper extends JpaRepository<ReportLead, Long> {
Optional<ReportLead> findFirstByUnsubscribeToken(String unsubscribeToken);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select l from ReportLead l where l.id = :id")
Optional<ReportLead> findByIdForUpdate(@Param("id") Long id);
/** 老板回访池:按状态倒查、按提醒日期升序 */
List<ReportLead> findByStoreIdAndRemindStatusOrderByRemindDateAsc(Long storeId, String remindStatus);

View File

@ -34,6 +34,7 @@ public class AdminBusinessEventService {
to
);
long leads = count(storeId, BusinessEventService.LEAD_SUBMITTED, from, to);
long rebooked = count(storeId, BusinessEventService.REBOOK_CREATED, from, to);
Map<String, Object> data = new LinkedHashMap<>();
data.put("date", day.toString());
@ -41,9 +42,9 @@ public class AdminBusinessEventService {
data.put("reportSentCount", sent);
data.put("reportOpenedCount", opened);
data.put("leadSubmittedCount", leads);
data.put("rebookCreatedCount", null);
data.put("rebookCreatedCount", rebooked);
data.put("reportSentTrackingReady", true);
data.put("rebookTrackingReady", false);
data.put("rebookTrackingReady", true);
return data;
}

View File

@ -1,9 +1,11 @@
package com.petstore.service;
import com.petstore.entity.Appointment;
import com.petstore.entity.FollowUpTask;
import com.petstore.entity.Report;
import com.petstore.entity.ReportLead;
import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.FollowUpTaskMapper;
import com.petstore.mapper.ReportLeadMapper;
import com.petstore.mapper.ReportMapper;
import lombok.RequiredArgsConstructor;
@ -15,6 +17,9 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 单店今日行动读模型:准确计数、低敏预览,不返回手机号、报告 token 或媒体地址。 */
@Service
@ -32,6 +37,7 @@ public class AdminWorkbenchService {
private final AppointmentMapper appointmentMapper;
private final ReportMapper reportMapper;
private final FollowUpTaskMapper followUpTaskMapper;
private final ReportLeadMapper reportLeadMapper;
private final AdminBusinessEventService adminBusinessEventService;
@ -81,11 +87,20 @@ public class AdminWorkbenchService {
)
);
List<ReportLead> dueFollowUps = safeLeads(
reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(storeId, "pending")
).stream()
.filter(lead -> lead.getRemindDate() == null || !lead.getRemindDate().isAfter(day))
.toList();
List<FollowUpTask> dueFollowUps = followUpTaskMapper
.findByStoreIdAndStatusInAndDueDateLessThanEqualOrderByDueDateAscIdAsc(
storeId,
Set.of(FollowUpTask.STATUS_PENDING, FollowUpTask.STATUS_IN_PROGRESS),
day
);
if (dueFollowUps == null) dueFollowUps = List.of();
Map<Long, ReportLead> dueLeads = reportLeadMapper.findAllById(
dueFollowUps.stream()
.map(FollowUpTask::getSourceLeadId)
.filter(java.util.Objects::nonNull)
.collect(Collectors.toSet())
).stream()
.collect(Collectors.toMap(ReportLead::getId, Function.identity(), (a, b) -> a));
long todayAppointments = appointmentMapper
.countByStoreIdAndDeletedFalseAndAppointmentTimeGreaterThanEqualAndAppointmentTimeLessThan(
@ -111,7 +126,7 @@ public class AdminWorkbenchService {
addAppointmentGroup(actions, UPCOMING_TODAY, "normal", upcomingToday, now);
addReportGroup(actions, highlightAnomalies);
addUnsentReportGroup(actions, unsentReports);
addLeadGroup(actions, dueFollowUps);
addFollowUpGroup(actions, dueFollowUps, dueLeads);
Map<String, Object> reportFunnel = adminBusinessEventService.reportFunnel(storeId, day);
return new WorkbenchData(day.toString(), now, metrics, List.copyOf(actions), reportFunnel);
@ -129,10 +144,6 @@ public class AdminWorkbenchService {
return reports == null ? List.of() : reports;
}
private static List<ReportLead> safeLeads(List<ReportLead> leads) {
return leads == null ? List.of() : leads;
}
private static boolean isHighlightAnomaly(Report report, LocalDateTime staleBefore) {
if ("failed".equals(report.getHighlightVideoStatus())) {
return true;
@ -204,21 +215,28 @@ public class AdminWorkbenchService {
groups.add(new ActionGroup(UNSENT_REPORT, "high", reports.size(), items));
}
private static void addLeadGroup(List<ActionGroup> groups, List<ReportLead> leads) {
if (leads.isEmpty()) return;
List<ActionItem> items = leads.stream()
private static void addFollowUpGroup(
List<ActionGroup> groups,
List<FollowUpTask> tasks,
Map<Long, ReportLead> leads
) {
if (tasks.isEmpty()) return;
List<ActionItem> items = tasks.stream()
.limit(PREVIEW_LIMIT)
.map(lead -> new ActionItem(
"lead",
lead.getId(),
lead.getPetName(),
lead.getServiceType(),
lead.getRemindDate() == null ? null : lead.getRemindDate().toString(),
lead.getRemindStatus(),
.map(task -> {
ReportLead lead = leads.get(task.getSourceLeadId());
return new ActionItem(
"follow_up_task",
task.getId(),
lead == null ? null : lead.getPetName(),
lead == null ? null : lead.getServiceType(),
task.getDueDate() == null ? null : task.getDueDate().toString(),
task.getStatus(),
true
))
);
})
.toList();
groups.add(new ActionGroup(FOLLOW_UP_DUE, "normal", leads.size(), items));
groups.add(new ActionGroup(FOLLOW_UP_DUE, "normal", tasks.size(), items));
}
public record WorkbenchData(

View File

@ -43,6 +43,7 @@ public class AppointmentService {
private final BusinessEventService businessEventService;
private final ServiceTypeService serviceTypeService;
private final BookingCapacityService bookingCapacityService;
private final FollowUpTaskService followUpTaskService;
// 宠主查看归属于自己的预约兼容 customer_user_id 尚未回填的旧数据
public List<Appointment> getByUserId(Long customerUserId) {
@ -163,6 +164,11 @@ public class AppointmentService {
*/
@Transactional
public Map<String, Object> createBooking(Appointment appointment) {
return createBooking(appointment, null);
}
@Transactional
public Map<String, Object> createBooking(Appointment appointment, String followUpTaskId) {
if (appointment.getAppointmentTime() == null) {
return Map.of("code", 400, "message", "请填写预约时间");
}
@ -253,12 +259,29 @@ public class AppointmentService {
"bizCode", "CUSTOMER_IDENTITY_CONFLICT"
);
}
com.petstore.entity.FollowUpTask followUpTask = null;
if (followUpTaskId != null && !followUpTaskId.isBlank()) {
followUpTask = followUpTaskService.prepareRebook(
appointment.getStoreId(),
followUpTaskId,
storeCustomer.getId(),
appointment.getCreatedByUserId()
);
}
Appointment saved = appointmentMapper.save(appointment);
businessEventService.recordAppointmentCreated(
saved,
storeCustomer,
resolveActorRole(saved.getCreatedByUserId())
);
if (followUpTask != null) {
followUpTaskService.markRebooked(
followUpTask,
saved,
saved.getCreatedByUserId(),
resolveActorRole(saved.getCreatedByUserId())
);
}
Map<String, Object> ok = new HashMap<>();
ok.put("code", 200);
ok.put("message", "创建成功");

View File

@ -4,6 +4,7 @@ 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.FollowUpTask;
import com.petstore.entity.Report;
import com.petstore.entity.ReportLead;
import com.petstore.entity.StoreCustomer;
@ -36,6 +37,12 @@ public class BusinessEventService {
public static final String REPORT_OPENED = "report_opened";
public static final String REPORT_REOPENED = "report_reopened";
public static final String LEAD_SUBMITTED = "lead_submitted";
public static final String FOLLOW_UP_CREATED = "follow_up_created";
public static final String FOLLOW_UP_STARTED = "follow_up_started";
public static final String FOLLOW_UP_RESCHEDULED = "follow_up_rescheduled";
public static final String FOLLOW_UP_COMPLETED = "follow_up_completed";
public static final String FOLLOW_UP_CANCELED = "follow_up_canceled";
public static final String REBOOK_CREATED = "rebook_created";
public static final String STORE_REGISTERED = "store_registered";
private static final Set<String> FORBIDDEN_METADATA_FRAGMENTS = Set.of(
@ -210,6 +217,132 @@ public class BusinessEventService {
));
}
@Transactional
public BusinessEvent recordFollowUpCreated(FollowUpTask task) {
return record(new EventCommand(
FOLLOW_UP_CREATED,
task.getStoreId(),
task.getStoreCustomerId(),
"follow_up_task",
task.getId(),
null,
null,
"system",
task.getCreateTime(),
"follow_up_created:" + task.getId(),
Map.of("dueDate", task.getDueDate().toString())
));
}
@Transactional
public BusinessEvent recordFollowUpStarted(
FollowUpTask task,
Long actorUserId,
String actorRole
) {
return record(new EventCommand(
FOLLOW_UP_STARTED,
task.getStoreId(),
task.getStoreCustomerId(),
"follow_up_task",
task.getId(),
actorUserId,
actorRole,
"admin",
task.getUpdateTime(),
"follow_up_started:" + task.getId() + ":" + safeAttemptCount(task),
Map.of("attemptCount", safeAttemptCount(task))
));
}
@Transactional
public BusinessEvent recordFollowUpRescheduled(
FollowUpTask task,
Long actorUserId,
String actorRole
) {
return record(new EventCommand(
FOLLOW_UP_RESCHEDULED,
task.getStoreId(),
task.getStoreCustomerId(),
"follow_up_task",
task.getId(),
actorUserId,
actorRole,
"admin",
task.getLastContactedAt(),
"follow_up_rescheduled:" + task.getId() + ":" + safeAttemptCount(task),
Map.of(
"attemptOutcome", task.getLastAttemptOutcome(),
"dueDate", task.getDueDate().toString(),
"attemptCount", safeAttemptCount(task)
)
));
}
@Transactional
public BusinessEvent recordFollowUpCompleted(
FollowUpTask task,
Long actorUserId,
String actorRole
) {
return record(new EventCommand(
FOLLOW_UP_COMPLETED,
task.getStoreId(),
task.getStoreCustomerId(),
"follow_up_task",
task.getId(),
actorUserId,
actorRole,
"admin",
task.getClosedAt(),
"follow_up_completed:" + task.getId(),
Map.of(
"outcome", task.getOutcome(),
"attemptCount", safeAttemptCount(task)
)
));
}
@Transactional
public BusinessEvent recordFollowUpCanceled(FollowUpTask task) {
return record(new EventCommand(
FOLLOW_UP_CANCELED,
task.getStoreId(),
task.getStoreCustomerId(),
"follow_up_task",
task.getId(),
null,
null,
"public_report",
task.getClosedAt(),
"follow_up_canceled:" + task.getId(),
Map.of("outcome", task.getOutcome())
));
}
@Transactional
public BusinessEvent recordRebookCreated(
FollowUpTask task,
Appointment appointment,
Long actorUserId,
String actorRole
) {
return record(new EventCommand(
REBOOK_CREATED,
task.getStoreId(),
task.getStoreCustomerId(),
"appointment",
appointment.getId(),
actorUserId,
actorRole,
"admin",
appointment.getCreateTime(),
"rebook_created:" + appointment.getId(),
Map.of("bookingOrigin", "follow_up")
));
}
/**
* 公开报告打开事件无效 token 静默忽略避免借埋点接口枚举报告绝不持久化 token/hash
*/
@ -278,6 +411,10 @@ public class BusinessEventService {
.orElse(null);
}
private static int safeAttemptCount(FollowUpTask task) {
return task.getAttemptCount() == null ? 0 : Math.max(task.getAttemptCount(), 0);
}
private String serializeMetadata(Map<String, Object> metadata) {
Map<String, Object> safe = new LinkedHashMap<>();
if (metadata != null) {

View File

@ -0,0 +1,535 @@
package com.petstore.service;
import com.petstore.entity.Appointment;
import com.petstore.entity.FollowUpTask;
import com.petstore.entity.ReportLead;
import com.petstore.entity.StoreCustomer;
import com.petstore.entity.User;
import com.petstore.mapper.FollowUpTaskMapper;
import com.petstore.mapper.ReportLeadMapper;
import com.petstore.mapper.StoreCustomerMapper;
import com.petstore.mapper.UserMapper;
import jakarta.persistence.criteria.Predicate;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
/** FollowUpTask 状态机、同店权限、操作审计与真实再次预约归因。 */
@Service
@RequiredArgsConstructor
public class FollowUpTaskService {
private static final Set<String> OPEN_STATUSES = Set.of(
FollowUpTask.STATUS_PENDING,
FollowUpTask.STATUS_IN_PROGRESS
);
private static final Set<String> RESCHEDULE_REASONS = Set.of(
FollowUpTask.ATTEMPT_NO_ANSWER,
FollowUpTask.ATTEMPT_FOLLOW_LATER
);
private static final Set<String> CLOSE_OUTCOMES = Set.of(
FollowUpTask.OUTCOME_NOT_INTERESTED,
FollowUpTask.OUTCOME_INVALID_CONTACT
);
private final FollowUpTaskMapper followUpTaskMapper;
private final ReportLeadMapper reportLeadMapper;
private final StoreCustomerMapper storeCustomerMapper;
private final UserMapper userMapper;
private final StoreCustomerService storeCustomerService;
private final BusinessEventService businessEventService;
private final AuditLogService auditLogService;
/** 留资成功后确保存在且只存在一个开放任务;终态后的再次提交会形成新一轮任务。 */
@Transactional
public FollowUpTask ensureForLead(ReportLead lead, StoreCustomer storeCustomer) {
if (lead == null || lead.getId() == null || lead.getStoreId() == null
|| storeCustomer == null || storeCustomer.getId() == null
|| !Objects.equals(lead.getStoreId(), storeCustomer.getStoreId())
|| Boolean.TRUE.equals(storeCustomer.getDeleted())) {
throw new IllegalArgumentException("创建回访任务缺少稳定归属");
}
ReportLead lockedLead = reportLeadMapper.findByIdForUpdate(lead.getId())
.orElseThrow(() -> new IllegalArgumentException("留资记录不存在"));
if ("unsubscribed".equals(lockedLead.getRemindStatus())) {
throw new IllegalArgumentException("该提醒已退订");
}
List<FollowUpTask> open = followUpTaskMapper.findOpenBySourceLeadIdForUpdate(
lockedLead.getId(), OPEN_STATUSES
);
if (!open.isEmpty()) {
FollowUpTask existing = open.get(0);
boolean changed = false;
if (FollowUpTask.STATUS_PENDING.equals(existing.getStatus())
&& lockedLead.getRemindDate() != null
&& !lockedLead.getRemindDate().equals(existing.getDueDate())) {
existing.setDueDate(lockedLead.getRemindDate());
changed = true;
}
if (!Objects.equals(existing.getStoreCustomerId(), storeCustomer.getId())) {
existing.setStoreCustomerId(storeCustomer.getId());
changed = true;
}
if (changed) {
existing.setUpdateTime(LocalDateTime.now());
return followUpTaskMapper.save(existing);
}
return existing;
}
LocalDateTime now = LocalDateTime.now();
FollowUpTask task = new FollowUpTask();
task.setTaskId(UUID.randomUUID().toString());
task.setStoreId(lockedLead.getStoreId());
task.setStoreCustomerId(storeCustomer.getId());
task.setSourceLeadId(lockedLead.getId());
task.setSourceReportId(lockedLead.getReportId());
task.setStatus(FollowUpTask.STATUS_PENDING);
task.setDueDate(lockedLead.getRemindDate() == null ? LocalDate.now() : lockedLead.getRemindDate());
task.setAttemptCount(0);
task.setCreateTime(now);
task.setUpdateTime(now);
task.setVersion(0L);
task = followUpTaskMapper.save(task);
businessEventService.recordFollowUpCreated(task);
return task;
}
@Transactional(readOnly = true)
public TaskPage list(
Long storeId,
String status,
LocalDate dueDateTo,
int page,
int pageSize,
Long currentUserId
) {
requireStore(storeId);
String normalizedStatus = normalizeFilterStatus(status);
int safePage = Math.max(page, 1);
int safeSize = Math.min(Math.max(pageSize, 1), 100);
Specification<FollowUpTask> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("storeId"), storeId));
if ("open".equals(normalizedStatus)) {
predicates.add(root.get("status").in(OPEN_STATUSES));
} else if (normalizedStatus != null) {
predicates.add(cb.equal(root.get("status"), normalizedStatus));
}
if (dueDateTo != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("dueDate"), dueDateTo));
}
return cb.and(predicates.toArray(Predicate[]::new));
};
PageRequest pageable = PageRequest.of(
safePage - 1,
safeSize,
Sort.by(Sort.Order.asc("dueDate"), Sort.Order.asc("id"))
);
Page<FollowUpTask> result = followUpTaskMapper.findAll(spec, pageable);
List<TaskView> views = views(result.getContent(), currentUserId);
return new TaskPage(
result.getTotalElements(),
safePage,
safeSize,
safePage < result.getTotalPages(),
views
);
}
@Transactional
public TaskView start(Long storeId, String taskId, Long actorUserId, String actorRole) {
FollowUpTask task = taskForUpdate(storeId, taskId);
if (FollowUpTask.STATUS_IN_PROGRESS.equals(task.getStatus())) {
if (Objects.equals(task.getAssignedUserId(), actorUserId)) {
return view(task, actorUserId);
}
throw new FlowException(409, "FOLLOW_UP_ALREADY_CLAIMED", "该任务已由其他员工领取");
}
if (!FollowUpTask.STATUS_PENDING.equals(task.getStatus())) {
throw stateConflict();
}
task.setStatus(FollowUpTask.STATUS_IN_PROGRESS);
task.setAssignedUserId(actorUserId);
task.setUpdateTime(LocalDateTime.now());
task = followUpTaskMapper.save(task);
businessEventService.recordFollowUpStarted(task, actorUserId, actorRole);
audit(task, actorUserId, actorRole, "follow_up_started", Map.of());
return view(task, actorUserId);
}
@Transactional
public TaskView reschedule(
Long storeId,
String taskId,
String dueDateText,
String reason,
Long actorUserId,
String actorRole
) {
FollowUpTask task = taskForUpdate(storeId, taskId);
requireInProgressOwner(task, actorUserId);
String normalizedReason = normalizeRequired(reason);
if (!RESCHEDULE_REASONS.contains(normalizedReason)) {
throw new FlowException(400, "FOLLOW_UP_REASON_INVALID", "改期原因仅支持未接通或稍后联系");
}
LocalDate dueDate = parseDueDate(dueDateText);
if (dueDate.isBefore(LocalDate.now())) {
throw new FlowException(400, "FOLLOW_UP_DUE_DATE_INVALID", "下次回访日期不能早于今天");
}
LocalDateTime now = LocalDateTime.now();
task.setStatus(FollowUpTask.STATUS_PENDING);
task.setOutcome(null);
task.setDueDate(dueDate);
task.setAttemptCount(safeAttempts(task) + 1);
task.setLastAttemptOutcome(normalizedReason);
task.setLastContactedAt(now);
task.setLastContactedByUserId(actorUserId);
task.setAssignedUserId(null);
task.setUpdateTime(now);
task = followUpTaskMapper.save(task);
markLeadStatus(task.getSourceLeadId(), "pending");
businessEventService.recordFollowUpRescheduled(task, actorUserId, actorRole);
audit(task, actorUserId, actorRole, "follow_up_rescheduled", Map.of(
"reason", normalizedReason,
"dueDate", dueDate.toString(),
"attemptCount", task.getAttemptCount()
));
return view(task, actorUserId);
}
@Transactional
public TaskView close(
Long storeId,
String taskId,
String outcome,
Long actorUserId,
String actorRole
) {
FollowUpTask task = taskForUpdate(storeId, taskId);
requireInProgressOwner(task, actorUserId);
String normalizedOutcome = normalizeRequired(outcome);
if (!CLOSE_OUTCOMES.contains(normalizedOutcome)) {
throw new FlowException(400, "FOLLOW_UP_OUTCOME_INVALID", "关闭结果不受支持");
}
LocalDateTime now = LocalDateTime.now();
task.setStatus(FollowUpTask.STATUS_COMPLETED);
task.setOutcome(normalizedOutcome);
task.setAttemptCount(safeAttempts(task) + 1);
task.setLastAttemptOutcome(null);
task.setLastContactedAt(now);
task.setLastContactedByUserId(actorUserId);
task.setClosedAt(now);
task.setClosedByUserId(actorUserId);
task.setUpdateTime(now);
task = followUpTaskMapper.save(task);
markLeadStatus(task.getSourceLeadId(), "canceled");
businessEventService.recordFollowUpCompleted(task, actorUserId, actorRole);
audit(task, actorUserId, actorRole, "follow_up_closed", Map.of(
"outcome", normalizedOutcome,
"attemptCount", task.getAttemptCount()
));
return view(task, actorUserId);
}
/** Appointment 保存前调用:校验任务开放、领取人和 canonical 客户归属。 */
@Transactional
public FollowUpTask prepareRebook(
Long storeId,
String taskId,
Long canonicalStoreCustomerId,
Long actorUserId
) {
FollowUpTask task = taskForUpdate(storeId, taskId);
requireInProgressOwner(task, actorUserId);
StoreCustomer taskCustomer = storeCustomerService.resolveCanonical(storeId, task.getStoreCustomerId());
if (taskCustomer == null || !Objects.equals(taskCustomer.getId(), canonicalStoreCustomerId)) {
throw new FlowException(409, "FOLLOW_UP_CUSTOMER_MISMATCH", "回访任务与本次预约宠主不一致");
}
task.setStoreCustomerId(taskCustomer.getId());
return task;
}
/** Appointment 已真实保存后调用;同一事务内形成终态和 rebook_created 事实。 */
@Transactional
public void markRebooked(
FollowUpTask task,
Appointment appointment,
Long actorUserId,
String actorRole
) {
if (task == null || appointment == null || appointment.getId() == null) {
throw new FlowException(409, "FOLLOW_UP_REBOOK_INVALID", "再次预约归因缺少真实预约");
}
requireInProgressOwner(task, actorUserId);
if (!Objects.equals(task.getStoreId(), appointment.getStoreId())
|| Boolean.TRUE.equals(appointment.getDeleted())) {
throw new FlowException(409, "FOLLOW_UP_REBOOK_INVALID", "再次预约与回访任务归属不一致");
}
LocalDateTime now = LocalDateTime.now();
task.setStatus(FollowUpTask.STATUS_COMPLETED);
task.setOutcome(FollowUpTask.OUTCOME_REBOOKED);
task.setAttemptCount(safeAttempts(task) + 1);
task.setLastAttemptOutcome(null);
task.setLastContactedAt(now);
task.setLastContactedByUserId(actorUserId);
task.setClosedAt(now);
task.setClosedByUserId(actorUserId);
task.setRebookedAppointmentId(appointment.getId());
task.setUpdateTime(now);
followUpTaskMapper.save(task);
markLeadStatus(task.getSourceLeadId(), "sent");
businessEventService.recordFollowUpCompleted(task, actorUserId, actorRole);
businessEventService.recordRebookCreated(task, appointment, actorUserId, actorRole);
audit(task, actorUserId, actorRole, "follow_up_rebooked", Map.of(
"appointmentId", appointment.getId(),
"attemptCount", task.getAttemptCount()
));
}
/** 宠主退订时取消该留资下全部开放任务;不增加员工联系次数。 */
@Transactional
public void cancelForUnsubscribe(ReportLead lead) {
if (lead == null || lead.getId() == null) return;
List<FollowUpTask> open = followUpTaskMapper.findOpenBySourceLeadIdForUpdate(lead.getId(), OPEN_STATUSES);
LocalDateTime now = LocalDateTime.now();
for (FollowUpTask task : open) {
task.setStatus(FollowUpTask.STATUS_CANCELED);
task.setOutcome(FollowUpTask.OUTCOME_UNSUBSCRIBED);
task.setAssignedUserId(null);
task.setLastAttemptOutcome(null);
task.setClosedAt(now);
task.setClosedByUserId(null);
task.setUpdateTime(now);
followUpTaskMapper.save(task);
businessEventService.recordFollowUpCanceled(task);
}
}
private List<TaskView> views(List<FollowUpTask> tasks, Long currentUserId) {
if (tasks.isEmpty()) return List.of();
Map<Long, ReportLead> leads = reportLeadMapper.findAllById(ids(tasks, FollowUpTask::getSourceLeadId)).stream()
.collect(Collectors.toMap(ReportLead::getId, Function.identity(), (a, b) -> a));
Map<Long, StoreCustomer> customers = loadCustomers(tasks);
Map<Long, User> users = userMapper.findAllById(ids(tasks, FollowUpTask::getAssignedUserId)).stream()
.collect(Collectors.toMap(User::getId, Function.identity(), (a, b) -> a));
return tasks.stream()
.map(task -> toView(
task,
leads.get(task.getSourceLeadId()),
canonicalFromMap(task, customers),
users.get(task.getAssignedUserId()),
currentUserId
))
.toList();
}
private TaskView view(FollowUpTask task, Long currentUserId) {
return views(List.of(task), currentUserId).get(0);
}
private Map<Long, StoreCustomer> loadCustomers(List<FollowUpTask> tasks) {
Map<Long, StoreCustomer> customers = storeCustomerMapper.findAllById(
ids(tasks, FollowUpTask::getStoreCustomerId)
).stream()
.collect(Collectors.toMap(StoreCustomer::getId, Function.identity(), (a, b) -> a));
Set<Long> canonicalIds = customers.values().stream()
.map(StoreCustomer::getMergedIntoStoreCustomerId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (!canonicalIds.isEmpty()) {
for (StoreCustomer canonical : storeCustomerMapper.findAllById(canonicalIds)) {
customers.put(canonical.getId(), canonical);
}
}
return customers;
}
private static StoreCustomer canonicalFromMap(FollowUpTask task, Map<Long, StoreCustomer> customers) {
StoreCustomer customer = customers.get(task.getStoreCustomerId());
if (customer != null && Boolean.TRUE.equals(customer.getDeleted())
&& customer.getMergedIntoStoreCustomerId() != null) {
return customers.get(customer.getMergedIntoStoreCustomerId());
}
return customer;
}
private static TaskView toView(
FollowUpTask task,
ReportLead lead,
StoreCustomer customer,
User assigned,
Long currentUserId
) {
String phone = firstNonBlank(
customer == null ? null : customer.getPhone(),
lead == null ? null : lead.getPhone()
);
String displayName = firstNonBlank(customer == null ? null : customer.getDisplayName(), "客户");
boolean open = OPEN_STATUSES.contains(task.getStatus());
return new TaskView(
task.getTaskId(),
customer == null ? task.getStoreCustomerId() : customer.getId(),
task.getSourceLeadId(),
task.getSourceReportId(),
displayName,
open ? phone : null,
AdminServiceCustomerService.maskPhone(phone),
lead == null ? null : lead.getPetName(),
lead == null ? null : lead.getServiceType(),
task.getStatus(),
task.getOutcome(),
task.getDueDate(),
safeAttempts(task),
task.getLastAttemptOutcome(),
task.getAssignedUserId(),
assigned == null ? null : assigned.getName(),
task.getAssignedUserId() != null && Objects.equals(task.getAssignedUserId(), currentUserId),
task.getLastContactedAt(),
task.getClosedAt(),
task.getRebookedAppointmentId(),
open && task.getDueDate() != null && task.getDueDate().isBefore(LocalDate.now()),
task.getCreateTime(),
task.getUpdateTime()
);
}
private FollowUpTask taskForUpdate(Long storeId, String taskId) {
requireStore(storeId);
String normalized = normalizeRequired(taskId);
return followUpTaskMapper.findByTaskIdAndStoreIdForUpdate(normalized, storeId)
.orElseThrow(() -> new FlowException(404, "FOLLOW_UP_TASK_NOT_FOUND", "回访任务不存在"));
}
private static void requireInProgressOwner(FollowUpTask task, Long actorUserId) {
if (!FollowUpTask.STATUS_IN_PROGRESS.equals(task.getStatus())) {
throw stateConflict();
}
if (task.getAssignedUserId() == null || !Objects.equals(task.getAssignedUserId(), actorUserId)) {
throw new FlowException(409, "FOLLOW_UP_ALREADY_CLAIMED", "该任务已由其他员工领取");
}
}
private void markLeadStatus(Long leadId, String status) {
if (leadId == null) return;
ReportLead lead = reportLeadMapper.findByIdForUpdate(leadId).orElse(null);
if (lead == null || "unsubscribed".equals(lead.getRemindStatus())) return;
lead.setRemindStatus(status);
lead.setUpdateTime(LocalDateTime.now());
reportLeadMapper.save(lead);
}
private void audit(
FollowUpTask task,
Long actorUserId,
String actorRole,
String action,
Map<String, Object> metadata
) {
auditLogService.record(
task.getStoreId(), actorUserId, actorRole, action,
"follow_up_task", task.getTaskId(), "success", metadata
);
}
private static String normalizeFilterStatus(String status) {
if (status == null || status.isBlank() || "open".equalsIgnoreCase(status)) return "open";
String value = status.trim().toLowerCase();
if ("all".equals(value)) return null;
if (!Set.of(
FollowUpTask.STATUS_PENDING,
FollowUpTask.STATUS_IN_PROGRESS,
FollowUpTask.STATUS_COMPLETED,
FollowUpTask.STATUS_CANCELED
).contains(value)) {
throw new FlowException(400, "FOLLOW_UP_STATUS_INVALID", "回访任务状态不受支持");
}
return value;
}
private static LocalDate parseDueDate(String text) {
try {
return LocalDate.parse(normalizeRequired(text));
} catch (Exception e) {
throw new FlowException(400, "FOLLOW_UP_DUE_DATE_INVALID", "回访日期格式应为 yyyy-MM-dd");
}
}
private static String normalizeRequired(String value) {
String normalized = value == null ? "" : value.trim().toLowerCase();
if (normalized.isBlank()) {
throw new FlowException(400, "FOLLOW_UP_PARAMETER_REQUIRED", "缺少回访任务参数");
}
return normalized;
}
private static int safeAttempts(FollowUpTask task) {
return task.getAttemptCount() == null ? 0 : Math.max(task.getAttemptCount(), 0);
}
private static void requireStore(Long storeId) {
if (storeId == null) {
throw new FlowException(403, "FORBIDDEN", "仅门店员工可处理回访任务");
}
}
private static FlowException stateConflict() {
return new FlowException(409, "FOLLOW_UP_STATE_CONFLICT", "回访任务状态已变化,请刷新后重试");
}
private static <T> Set<Long> ids(Collection<T> rows, Function<T, Long> getter) {
return rows.stream().map(getter).filter(Objects::nonNull).collect(Collectors.toSet());
}
private static String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) return value.trim();
}
return null;
}
public record TaskPage(long total, int page, int pageSize, boolean hasMore, List<TaskView> list) {}
public record TaskView(
String taskId,
Long storeCustomerId,
Long sourceLeadId,
Long reportId,
String displayName,
String phone,
String phoneMasked,
String petName,
String serviceType,
String status,
String outcome,
LocalDate dueDate,
int attemptCount,
String lastAttemptOutcome,
Long assignedUserId,
String assignedUserName,
boolean assignedToCurrentUser,
LocalDateTime lastContactedAt,
LocalDateTime closedAt,
Long rebookedAppointmentId,
boolean overdue,
LocalDateTime createTime,
LocalDateTime updateTime
) {}
}

View File

@ -13,6 +13,7 @@ import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
@ -34,6 +35,7 @@ public class ReportLeadService {
private final WechatMiniProgramService wechatMiniProgramService;
private final StoreCustomerService storeCustomerService;
private final BusinessEventService businessEventService;
private final FollowUpTaskService followUpTaskService;
/** 留资提交结果:记录 + 是否已将微信 openid 绑定到宠主账号S3→S4+ 是否同报告同手机号再次提交(幂等更新) */
public record LeadSubmitOutcome(ReportLead lead, boolean wechatBound, boolean repeatSubmit) {}
@ -116,6 +118,7 @@ public class ReportLeadService {
*
* @param loginCode 小程序 uni.login code服务端 jscode2session openid/unionid 写入留资H5 可空
*/
@Transactional
public LeadSubmitOutcome submit(String reportToken, String phone, String consentIp, String loginCode) {
Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null);
if (report == null) {
@ -213,20 +216,26 @@ public class ReportLeadService {
StoreCustomer storeCustomer = storeCustomerService.touchLead(report.getStoreId(), phone, null, now);
businessEventService.recordLeadSubmitted(lead, storeCustomer, repeatSubmit);
followUpTaskService.ensureForLead(lead, storeCustomer);
return new LeadSubmitOutcome(lead, wechatBound, repeatSubmit);
}
/** 退订:按 unsubscribeToken 找记录并置状态 */
@Transactional
public boolean unsubscribe(String unsubscribeToken) {
if (unsubscribeToken == null || unsubscribeToken.isBlank()) return false;
Optional<ReportLead> opt = reportLeadMapper.findFirstByUnsubscribeToken(unsubscribeToken);
if (opt.isEmpty()) return false;
ReportLead lead = opt.get();
if ("unsubscribed".equals(lead.getRemindStatus())) return true;
if ("unsubscribed".equals(lead.getRemindStatus())) {
followUpTaskService.cancelForUnsubscribe(lead);
return true;
}
lead.setRemindStatus("unsubscribed");
lead.setUpdateTime(LocalDateTime.now());
reportLeadMapper.save(lead);
followUpTaskService.cancelForUnsubscribe(lead);
return true;
}

View File

@ -150,6 +150,27 @@ public class StoreCustomerService {
return storeCustomerMapper.save(target);
}
/** 同店内把活动主档或软删除合并别名解析为 canonical 主档;不存在/失效时返回 null。 */
@Transactional(readOnly = true)
public StoreCustomer resolveCanonical(Long storeId, Long storeCustomerId) {
if (storeId == null || storeCustomerId == null) {
return null;
}
StoreCustomer requested = storeCustomerMapper.findFirstByIdAndStoreId(storeCustomerId, storeId).orElse(null);
if (requested == null) {
return null;
}
if (!Boolean.TRUE.equals(requested.getDeleted())) {
return requested;
}
if (requested.getMergedIntoStoreCustomerId() == null) {
return null;
}
return storeCustomerMapper.findFirstByIdAndStoreIdAndDeletedFalse(
requested.getMergedIntoStoreCustomerId(), storeId
).orElse(null);
}
private static void mergeContactWindow(StoreCustomer target, StoreCustomer duplicate) {
if (duplicate.getFirstContactAt() != null
&& (target.getFirstContactAt() == null || duplicate.getFirstContactAt().isBefore(target.getFirstContactAt()))) {

View File

@ -4,12 +4,14 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.petstore.entity.Appointment;
import com.petstore.entity.BusinessEvent;
import com.petstore.entity.FollowUpTask;
import com.petstore.entity.Report;
import com.petstore.entity.ReportLead;
import com.petstore.entity.StoreCustomer;
import com.petstore.entity.User;
import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.BusinessEventMapper;
import com.petstore.mapper.FollowUpTaskMapper;
import com.petstore.mapper.ReportLeadMapper;
import com.petstore.mapper.ReportMapper;
import com.petstore.mapper.StoreCustomerMapper;
@ -45,6 +47,7 @@ public class StoreCustomerTimelineService {
private final AppointmentMapper appointmentMapper;
private final ReportMapper reportMapper;
private final ReportLeadMapper reportLeadMapper;
private final FollowUpTaskMapper followUpTaskMapper;
private final UserMapper userMapper;
private final ObjectMapper objectMapper;
@ -77,6 +80,7 @@ public class StoreCustomerTimelineService {
Map<Long, Appointment> appointments = loadAppointments(storeId, events);
Map<Long, Report> reports = loadReports(storeId, events);
Map<Long, ReportLead> leads = loadLeads(storeId, events);
Map<Long, FollowUpTask> followUpTasks = loadFollowUpTasks(storeId, events);
Map<Long, User> actors = loadActors(events);
User customerUser = customer.getCustomerUserId() == null ? null
: userMapper.findByIdAndDeletedFalse(customer.getCustomerUserId()).orElse(null);
@ -90,6 +94,8 @@ public class StoreCustomerTimelineService {
? reports.get(event.getAggregateId()) : null,
"report_lead".equals(event.getAggregateType())
? leads.get(event.getAggregateId()) : null,
"follow_up_task".equals(event.getAggregateType())
? followUpTasks.get(event.getAggregateId()) : null,
event.getActorUserId() == null ? null : actors.get(event.getActorUserId())
))
.toList();
@ -141,6 +147,14 @@ public class StoreCustomerTimelineService {
.collect(Collectors.toMap(ReportLead::getId, Function.identity(), (a, b) -> a));
}
private Map<Long, FollowUpTask> loadFollowUpTasks(Long storeId, List<BusinessEvent> events) {
Set<Long> ids = aggregateIds(events, "follow_up_task");
if (ids.isEmpty()) return Map.of();
return followUpTaskMapper.findAllById(ids).stream()
.filter(task -> Objects.equals(storeId, task.getStoreId()))
.collect(Collectors.toMap(FollowUpTask::getId, Function.identity(), (a, b) -> a));
}
private Map<Long, User> loadActors(List<BusinessEvent> events) {
Set<Long> ids = events.stream()
.map(BusinessEvent::getActorUserId)
@ -167,6 +181,7 @@ public class StoreCustomerTimelineService {
Appointment appointment,
Report report,
ReportLead lead,
FollowUpTask followUpTask,
User actor
) {
Map<String, Object> metadata = safeMetadata(event.getMetadataJson());
@ -188,6 +203,9 @@ public class StoreCustomerTimelineService {
if (lead != null && "report_lead".equals(event.getAggregateType())) {
item.put("lead", leadView(lead));
}
if (followUpTask != null && "follow_up_task".equals(event.getAggregateType())) {
item.put("followUpTask", followUpTaskView(followUpTask));
}
return item;
}
@ -242,6 +260,17 @@ public class StoreCustomerTimelineService {
return view;
}
private static Map<String, Object> followUpTaskView(FollowUpTask task) {
Map<String, Object> view = new LinkedHashMap<>();
view.put("taskId", task.getTaskId());
view.put("status", task.getStatus());
view.put("outcome", task.getOutcome());
view.put("dueDate", task.getDueDate() == null ? null : task.getDueDate().toString());
view.put("attemptCount", task.getAttemptCount());
view.put("rebookedAppointmentId", task.getRebookedAppointmentId());
return view;
}
private static String title(String eventType, Map<String, Object> metadata) {
if (BusinessEventService.APPOINTMENT_CREATED.equals(eventType)) return "预约已创建";
if (BusinessEventService.APPOINTMENT_STATUS_CHANGED.equals(eventType)) {
@ -259,6 +288,12 @@ public class StoreCustomerTimelineService {
if (BusinessEventService.REPORT_OPENED.equals(eventType)) return "宠主首次打开报告";
if (BusinessEventService.REPORT_REOPENED.equals(eventType)) return "宠主再次打开报告";
if (BusinessEventService.LEAD_SUBMITTED.equals(eventType)) return "宠主提交下次服务提醒";
if (BusinessEventService.FOLLOW_UP_CREATED.equals(eventType)) return "已生成回访任务";
if (BusinessEventService.FOLLOW_UP_STARTED.equals(eventType)) return "员工开始回访";
if (BusinessEventService.FOLLOW_UP_RESCHEDULED.equals(eventType)) return "回访已改期";
if (BusinessEventService.FOLLOW_UP_COMPLETED.equals(eventType)) return "回访已完成";
if (BusinessEventService.FOLLOW_UP_CANCELED.equals(eventType)) return "回访已取消";
if (BusinessEventService.REBOOK_CREATED.equals(eventType)) return "回访后已再次预约";
return "客户业务动态";
}
@ -291,6 +326,14 @@ public class StoreCustomerTimelineService {
} else if (BusinessEventService.LEAD_SUBMITTED.equals(event.getEventType())
&& Boolean.TRUE.equals(metadata.get("repeatSubmit"))) {
add(parts, "重复提交已合并");
} else if (BusinessEventService.FOLLOW_UP_CREATED.equals(event.getEventType())) {
addPrefixed(parts, "计划 ", textValue(metadata.get("dueDate")));
} else if (BusinessEventService.FOLLOW_UP_RESCHEDULED.equals(event.getEventType())) {
add(parts, attemptOutcomeLabel(text(metadata.get("attemptOutcome"))));
addPrefixed(parts, "下次 ", textValue(metadata.get("dueDate")));
} else if (BusinessEventService.FOLLOW_UP_COMPLETED.equals(event.getEventType())
|| BusinessEventService.FOLLOW_UP_CANCELED.equals(event.getEventType())) {
add(parts, followUpOutcomeLabel(text(metadata.get("outcome"))));
}
return parts.isEmpty() ? "已记录业务事实" : String.join(" · ", parts);
}
@ -300,7 +343,10 @@ public class StoreCustomerTimelineService {
try {
Map<String, Object> parsed = objectMapper.readValue(json, METADATA_TYPE);
Map<String, Object> safe = new LinkedHashMap<>();
for (String key : List.of("fromStatus", "toStatus", "channel", "repeatSubmit")) {
for (String key : List.of(
"fromStatus", "toStatus", "channel", "repeatSubmit",
"attemptOutcome", "outcome", "dueDate", "attemptCount", "bookingOrigin"
)) {
Object value = parsed.get(key);
if (value instanceof String || value instanceof Boolean || value instanceof Number) {
safe.put(key, value);
@ -327,6 +373,10 @@ public class StoreCustomerTimelineService {
if (value != null && !value.isBlank()) parts.add(value);
}
private static void addPrefixed(List<String> parts, String prefix, String value) {
if (value != null && !value.isBlank()) parts.add(prefix + value);
}
private static String iso(LocalDateTime value) {
return value == null ? null : value.toString();
}
@ -354,4 +404,26 @@ public class StoreCustomerTimelineService {
default -> channel.isBlank() ? null : channel;
};
}
private static String attemptOutcomeLabel(String outcome) {
return switch (outcome) {
case FollowUpTask.ATTEMPT_NO_ANSWER -> "未接通";
case FollowUpTask.ATTEMPT_FOLLOW_LATER -> "宠主希望稍后联系";
default -> outcome.isBlank() ? null : outcome;
};
}
private static String followUpOutcomeLabel(String outcome) {
return switch (outcome) {
case FollowUpTask.OUTCOME_REBOOKED -> "已再次预约";
case FollowUpTask.OUTCOME_NOT_INTERESTED -> "本轮暂无意向";
case FollowUpTask.OUTCOME_INVALID_CONTACT -> "联系方式无效";
case FollowUpTask.OUTCOME_UNSUBSCRIBED -> "宠主已退订";
default -> outcome.isBlank() ? null : outcome;
};
}
private static String textValue(Object value) {
return value == null ? "" : value.toString().trim();
}
}

View File

@ -0,0 +1,64 @@
package com.petstore.controller;
import com.petstore.auth.CurrentUser;
import com.petstore.auth.CurrentUserContext;
import com.petstore.service.FollowUpTaskService;
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.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.mock;
@ExtendWith(MockitoExtension.class)
class AdminFollowUpTaskControllerTest {
@Mock private FollowUpTaskService followUpTaskService;
@InjectMocks private AdminFollowUpTaskController controller;
@AfterEach
void clear() {
CurrentUserContext.clear();
}
@Test
void customerCannotReadOrMutateTasks() {
CurrentUserContext.set(new CurrentUser(99L, null, "customer"));
Map<String, Object> list = controller.list("open", null, 1, 20);
Map<String, Object> start = controller.start("task-1");
assertEquals(403, list.get("code"));
assertEquals(403, start.get("code"));
verifyNoInteractions(followUpTaskService);
}
@Test
void staffScopeAndActorAlwaysComeFromSession() {
CurrentUserContext.set(new CurrentUser(7L, 10L, "staff"));
FollowUpTaskService.TaskPage page = new FollowUpTaskService.TaskPage(0, 1, 20, false, List.of());
when(followUpTaskService.list(10L, "open", null, 1, 20, 7L)).thenReturn(page);
when(followUpTaskService.reschedule(
10L, "task-1", "2026-09-01", "no_answer", 7L, "staff"
)).thenReturn(mock(FollowUpTaskService.TaskView.class));
Map<String, Object> result = controller.list("open", null, 1, 20);
controller.reschedule("task-1", Map.of("dueDate", "2026-09-01", "reason", "no_answer"));
assertEquals(200, result.get("code"));
verify(followUpTaskService).list(10L, "open", null, 1, 20, 7L);
verify(followUpTaskService).reschedule(
eq(10L), eq("task-1"), eq("2026-09-01"), eq("no_answer"), eq(7L), eq("staff")
);
}
}

View File

@ -88,6 +88,31 @@ class AppointmentControllerIdentityTest {
assertEquals(10L, appointment.getStoreId());
}
@Test
void staffRebookForwardsTaskIdWithoutAcceptingStoreOrActorOverrides() {
CurrentUserContext.set(new CurrentUser(2L, 10L, "staff"));
User customer = new User();
customer.setId(88L);
customer.setRole("customer");
when(userService.resolveBookingCustomer(null, "13800138000", "小白妈妈"))
.thenReturn(customer);
when(appointmentService.createBooking(any(), eq("task-1"))).thenReturn(Map.of("code", 200));
Map<String, Object> body = validBody();
body.put("customerPhone", "13800138000");
body.put("customerName", "小白妈妈");
body.put("followUpTaskId", "task-1");
body.put("storeId", 999L);
Map<String, Object> result = controller.create(body);
assertEquals(200, result.get("code"));
ArgumentCaptor<Appointment> captor = ArgumentCaptor.forClass(Appointment.class);
verify(appointmentService).createBooking(captor.capture(), eq("task-1"));
assertEquals(10L, captor.getValue().getStoreId());
assertEquals(2L, captor.getValue().getCreatedByUserId());
}
private static Map<String, Object> validBody() {
Map<String, Object> body = new HashMap<>();
body.put("petName", "小白");

View File

@ -0,0 +1,118 @@
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 FollowUpTaskMigrationTest {
@Test
void migrationBackfillsOnlyActionableLeadAndCreatesLowSensitivityFact() throws Exception {
try (Connection connection = DriverManager.getConnection(
"jdbc:h2:mem:follow-up-task-migration;MODE=MySQL;DATABASE_TO_LOWER=TRUE",
"sa",
""
)) {
createPrerequisites(connection);
try (Reader reader = Files.newBufferedReader(
Path.of("db/migrations/20260802_create_follow_up_task.sql")
)) {
RunScript.execute(connection, reader);
}
try (Statement statement = connection.createStatement();
ResultSet task = statement.executeQuery("""
SELECT status, due_date, store_customer_id, attempt_count
FROM t_follow_up_task
""")) {
task.next();
assertEquals("pending", task.getString("status"));
assertEquals("2026-09-01", task.getDate("due_date").toString());
assertEquals(50L, task.getLong("store_customer_id"));
assertEquals(0, task.getInt("attempt_count"));
assertFalse(task.next());
}
try (Statement statement = connection.createStatement();
ResultSet event = statement.executeQuery("""
SELECT event_type, aggregate_type, metadata_json
FROM t_business_event
WHERE event_type = 'follow_up_created'
""")) {
event.next();
assertEquals("follow_up_task", event.getString("aggregate_type"));
assertEquals("{\"dueDate\":\"2026-09-01\"}", event.getString("metadata_json"));
assertFalse(event.getString("metadata_json").contains("13800138001"));
}
}
}
private static void createPrerequisites(Connection connection) throws Exception {
try (Statement statement = connection.createStatement()) {
statement.execute("""
CREATE TABLE t_store_customer (
id BIGINT PRIMARY KEY,
store_id BIGINT NOT NULL,
phone VARCHAR(20),
deleted TINYINT NOT NULL DEFAULT 0,
merged_into_store_customer_id BIGINT
)
""");
statement.execute("""
CREATE TABLE t_report_lead (
id BIGINT PRIMARY KEY,
report_id BIGINT,
store_id BIGINT,
phone VARCHAR(20),
remind_date DATE,
remind_status VARCHAR(16),
create_time DATETIME,
update_time DATETIME
)
""");
statement.execute("""
CREATE TABLE t_business_event (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_id VARCHAR(36) NOT NULL,
event_type VARCHAR(64) NOT NULL,
event_version INT NOT NULL,
store_id BIGINT NOT NULL,
store_customer_id BIGINT,
aggregate_type VARCHAR(32) NOT NULL,
aggregate_id BIGINT NOT NULL,
actor_user_id BIGINT,
actor_role VARCHAR(16),
source VARCHAR(32) NOT NULL,
occurred_at DATETIME NOT NULL,
metadata_json TEXT NOT NULL,
idempotency_key VARCHAR(128),
create_time DATETIME NOT NULL
)
""");
statement.execute("""
CREATE TABLE t_appointment (
id BIGINT PRIMARY KEY,
store_id BIGINT,
deleted TINYINT NOT NULL DEFAULT 0
)
""");
statement.execute("INSERT INTO t_store_customer VALUES (50, 10, '13800138001', 0, NULL)");
statement.execute("""
INSERT INTO t_report_lead VALUES
(70, 9, 10, '13800138001', '2026-09-01', 'pending',
'2026-08-01 10:00:00', '2026-08-01 10:00:00'),
(71, 9, 10, '13800138001', '2026-09-01', 'unsubscribed',
'2026-08-01 10:00:00', '2026-08-01 10:00:00')
""");
}
}
}

View File

@ -13,8 +13,6 @@ 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;
@ -40,6 +38,9 @@ class AdminBusinessEventServiceTest {
when(businessEventMapper.countDistinctAggregates(
eq(10L), eq(BusinessEventService.LEAD_SUBMITTED), any(LocalDateTime.class), any(LocalDateTime.class)
)).thenReturn(3L);
when(businessEventMapper.countDistinctAggregates(
eq(10L), eq(BusinessEventService.REBOOK_CREATED), any(LocalDateTime.class), any(LocalDateTime.class)
)).thenReturn(2L);
Map<String, Object> data = service.reportFunnel(10L, date);
@ -48,8 +49,8 @@ class AdminBusinessEventServiceTest {
assertEquals(6L, data.get("reportOpenedCount"));
assertEquals(3L, data.get("leadSubmittedCount"));
assertEquals(7L, data.get("reportSentCount"));
assertNull(data.get("rebookCreatedCount"));
assertEquals(2L, data.get("rebookCreatedCount"));
assertEquals(true, data.get("reportSentTrackingReady"));
assertFalse((Boolean) data.get("rebookTrackingReady"));
assertEquals(true, data.get("rebookTrackingReady"));
}
}

View File

@ -2,9 +2,11 @@ package com.petstore.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.petstore.entity.Appointment;
import com.petstore.entity.FollowUpTask;
import com.petstore.entity.Report;
import com.petstore.entity.ReportLead;
import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.FollowUpTaskMapper;
import com.petstore.mapper.ReportLeadMapper;
import com.petstore.mapper.ReportMapper;
import org.junit.jupiter.api.Test;
@ -32,6 +34,7 @@ class AdminWorkbenchServiceTest {
@Mock private AppointmentMapper appointmentMapper;
@Mock private ReportMapper reportMapper;
@Mock private FollowUpTaskMapper followUpTaskMapper;
@Mock private ReportLeadMapper reportLeadMapper;
@Mock private AdminBusinessEventService adminBusinessEventService;
@InjectMocks private AdminWorkbenchService service;
@ -75,9 +78,12 @@ class AdminWorkbenchServiceTest {
ReportLead noDate = lead(30L, "无日期线索", null, "pending");
ReportLead due = lead(31L, "今日回访", LocalDate.of(2026, 8, 1), "pending");
ReportLead future = lead(32L, "未来回访", LocalDate.of(2026, 8, 2), "pending");
when(reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(10L, "pending"))
.thenReturn(List.of(noDate, due, future));
FollowUpTask noDateTask = followUpTask(40L, 30L, LocalDate.of(2026, 8, 1));
FollowUpTask dueTask = followUpTask(41L, 31L, LocalDate.of(2026, 8, 1));
when(followUpTaskMapper.findByStoreIdAndStatusInAndDueDateLessThanEqualOrderByDueDateAscIdAsc(
eq(10L), any(), eq(LocalDate.of(2026, 8, 1))))
.thenReturn(List.of(noDateTask, dueTask));
when(reportLeadMapper.findAllById(any())).thenReturn(List.of(noDate, due));
when(adminBusinessEventService.reportFunnel(10L, LocalDate.of(2026, 8, 1)))
.thenReturn(Map.of("reportSubmittedCount", 2L));
@ -127,7 +133,8 @@ class AdminWorkbenchServiceTest {
when(reportMapper.findByStoreIdAndDeletedFalseAndHighlightVideoStatusInOrderByUpdateTimeAsc(
10L, List.of("failed", "processing")))
.thenReturn(List.of());
when(reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(10L, "pending"))
when(followUpTaskMapper.findByStoreIdAndStatusInAndDueDateLessThanEqualOrderByDueDateAscIdAsc(
eq(10L), any(), eq(LocalDate.of(2026, 8, 1))))
.thenReturn(List.of());
when(adminBusinessEventService.reportFunnel(10L, LocalDate.of(2026, 8, 1))).thenReturn(Map.of());
@ -178,4 +185,13 @@ class AdminWorkbenchServiceTest {
lead.setRemindStatus(status);
return lead;
}
private static FollowUpTask followUpTask(Long id, Long leadId, LocalDate dueDate) {
FollowUpTask task = new FollowUpTask();
task.setId(id);
task.setSourceLeadId(leadId);
task.setDueDate(dueDate);
task.setStatus(FollowUpTask.STATUS_PENDING);
return task;
}
}

View File

@ -1,8 +1,10 @@
package com.petstore.service;
import com.petstore.entity.Appointment;
import com.petstore.entity.FollowUpTask;
import com.petstore.entity.ServiceType;
import com.petstore.entity.Store;
import com.petstore.entity.StoreCustomer;
import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.PetMapper;
import com.petstore.mapper.ReportMapper;
@ -17,6 +19,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@ -39,6 +42,7 @@ class AppointmentServiceTest {
@Mock private BusinessEventService businessEventService;
@Mock private ServiceTypeService serviceTypeService;
@Mock private BookingCapacityService bookingCapacityService;
@Mock private FollowUpTaskService followUpTaskService;
@InjectMocks private AppointmentService appointmentService;
@ -156,6 +160,76 @@ class AppointmentServiceTest {
verify(businessEventService).recordAppointmentCreated(eq(appointment), isNull(), isNull());
}
@Test
void followUpRebookCompletesOnlyAfterAppointmentIsSaved() {
LocalDateTime appointmentTime = LocalDate.now().plusDays(2).atTime(10, 0);
Appointment appointment = new Appointment();
appointment.setStoreId(10L);
appointment.setCustomerUserId(88L);
appointment.setCreatedByUserId(7L);
appointment.setAppointmentTime(appointmentTime);
appointment.setPetName("豆豆");
appointment.setServiceType("洗澡");
Store store = new Store();
store.setId(10L);
store.setBookingDayStart(LocalTime.of(9, 0));
store.setBookingLastSlotStart(LocalTime.of(21, 30));
ServiceType serviceType = new ServiceType();
serviceType.setName("洗澡");
serviceType.setDurationMinutes(60);
StoreCustomer customer = new StoreCustomer();
customer.setId(50L);
FollowUpTask task = new FollowUpTask();
task.setTaskId("task-1");
when(storeService.findByIdForUpdate(10L)).thenReturn(store);
when(serviceTypeService.resolveForStore(10L, "洗澡")).thenReturn(serviceType);
when(bookingCapacityService.evaluateAppointment(any(), eq(60), eq(1), anyList(), anyList()))
.thenReturn(new BookingCapacityService.CapacityResult(true, null, 0));
when(storeCustomerService.touchBooking(eq(10L), eq(88L), eq(7L), any())).thenReturn(customer);
when(followUpTaskService.prepareRebook(10L, "task-1", 50L, 7L)).thenReturn(task);
when(appointmentMapper.save(any(Appointment.class))).thenAnswer(inv -> {
Appointment saved = inv.getArgument(0);
saved.setId(100L);
return saved;
});
Map<String, Object> result = appointmentService.createBooking(appointment, "task-1");
assertEquals(200, result.get("code"));
verify(followUpTaskService).markRebooked(task, appointment, 7L, null);
}
@Test
void capacityFailureLeavesFollowUpTaskUntouched() {
Appointment appointment = new Appointment();
appointment.setStoreId(10L);
appointment.setCustomerUserId(88L);
appointment.setCreatedByUserId(7L);
appointment.setAppointmentTime(LocalDate.now().plusDays(2).atTime(10, 0));
appointment.setPetName("豆豆");
appointment.setServiceType("洗澡");
Store store = new Store();
store.setId(10L);
store.setBookingDayStart(LocalTime.of(9, 0));
store.setBookingLastSlotStart(LocalTime.of(21, 30));
ServiceType serviceType = new ServiceType();
serviceType.setName("洗澡");
serviceType.setDurationMinutes(60);
when(storeService.findByIdForUpdate(10L)).thenReturn(store);
when(serviceTypeService.resolveForStore(10L, "洗澡")).thenReturn(serviceType);
when(bookingCapacityService.evaluateAppointment(any(), eq(60), eq(1), anyList(), anyList()))
.thenReturn(new BookingCapacityService.CapacityResult(false, "该时段容量已满", 1));
Map<String, Object> result = appointmentService.createBooking(appointment, "task-1");
assertEquals(409, result.get("code"));
assertEquals("CAPACITY_FULL", result.get("bizCode"));
verifyNoInteractions(followUpTaskService);
verify(appointmentMapper, never()).save(any());
}
private Appointment newAppointment(Long id, String status) {
Appointment a = new Appointment();
a.setId(id);

View File

@ -3,6 +3,7 @@ package com.petstore.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.petstore.entity.Appointment;
import com.petstore.entity.BusinessEvent;
import com.petstore.entity.FollowUpTask;
import com.petstore.entity.Report;
import com.petstore.entity.StoreCustomer;
import com.petstore.mapper.BusinessEventMapper;
@ -164,4 +165,28 @@ class BusinessEventServiceTest {
assertFalse(event.getMetadataJson().contains("token"));
assertEquals(7L, event.getActorUserId());
}
@Test
void rebookFactUsesRealAppointmentAndContainsNoContactData() {
FollowUpTask task = new FollowUpTask();
task.setId(50L);
task.setStoreId(10L);
task.setStoreCustomerId(30L);
Appointment appointment = new Appointment();
appointment.setId(100L);
appointment.setStoreId(10L);
appointment.setCreateTime(LocalDateTime.of(2026, 8, 2, 10, 0));
when(businessEventMapper.findFirstByIdempotencyKey("rebook_created:100"))
.thenReturn(Optional.empty());
when(businessEventMapper.save(any(BusinessEvent.class))).thenAnswer(inv -> inv.getArgument(0));
BusinessEvent event = service.recordRebookCreated(task, appointment, 7L, "staff");
assertEquals(BusinessEventService.REBOOK_CREATED, event.getEventType());
assertEquals("appointment", event.getAggregateType());
assertEquals(100L, event.getAggregateId());
assertEquals(30L, event.getStoreCustomerId());
assertEquals("{\"bookingOrigin\":\"follow_up\"}", event.getMetadataJson());
assertFalse(event.getMetadataJson().contains("phone"));
}
}

View File

@ -0,0 +1,266 @@
package com.petstore.service;
import com.petstore.entity.Appointment;
import com.petstore.entity.FollowUpTask;
import com.petstore.entity.ReportLead;
import com.petstore.entity.StoreCustomer;
import com.petstore.mapper.FollowUpTaskMapper;
import com.petstore.mapper.ReportLeadMapper;
import com.petstore.mapper.StoreCustomerMapper;
import com.petstore.mapper.UserMapper;
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.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class FollowUpTaskServiceTest {
@Mock private FollowUpTaskMapper followUpTaskMapper;
@Mock private ReportLeadMapper reportLeadMapper;
@Mock private StoreCustomerMapper storeCustomerMapper;
@Mock private UserMapper userMapper;
@Mock private StoreCustomerService storeCustomerService;
@Mock private BusinessEventService businessEventService;
@Mock private AuditLogService auditLogService;
@InjectMocks private FollowUpTaskService service;
@Test
void leadCreatesOnePendingTaskAndBusinessFact() {
ReportLead lead = lead();
StoreCustomer customer = customer();
when(reportLeadMapper.findByIdForUpdate(70L)).thenReturn(Optional.of(lead));
when(followUpTaskMapper.findOpenBySourceLeadIdForUpdate(eq(70L), any())).thenReturn(List.of());
when(followUpTaskMapper.save(any(FollowUpTask.class))).thenAnswer(inv -> {
FollowUpTask task = inv.getArgument(0);
task.setId(90L);
return task;
});
FollowUpTask task = service.ensureForLead(lead, customer);
assertEquals(FollowUpTask.STATUS_PENDING, task.getStatus());
assertEquals(LocalDate.of(2026, 9, 1), task.getDueDate());
assertEquals(50L, task.getStoreCustomerId());
assertEquals(0, task.getAttemptCount());
verify(businessEventService).recordFollowUpCreated(task);
}
@Test
void sameOpenLeadDoesNotCreateDuplicateTask() {
ReportLead lead = lead();
FollowUpTask existing = task(FollowUpTask.STATUS_PENDING, null);
existing.setDueDate(lead.getRemindDate());
when(reportLeadMapper.findByIdForUpdate(70L)).thenReturn(Optional.of(lead));
when(followUpTaskMapper.findOpenBySourceLeadIdForUpdate(eq(70L), any()))
.thenReturn(List.of(existing));
FollowUpTask result = service.ensureForLead(lead, customer());
assertEquals(existing, result);
assertNull(result.getOutcome());
}
@Test
void claimThenNoAnswerReturnsTaskToPendingAndIncrementsAttempt() {
FollowUpTask task = task(FollowUpTask.STATUS_PENDING, null);
stubTaskView(task);
when(followUpTaskMapper.findByTaskIdAndStoreIdForUpdate("task-1", 10L))
.thenReturn(Optional.of(task));
when(followUpTaskMapper.save(any(FollowUpTask.class))).thenAnswer(inv -> inv.getArgument(0));
service.start(10L, "task-1", 7L, "staff");
FollowUpTaskService.TaskView result = service.reschedule(
10L, "task-1", LocalDate.now().plusDays(2).toString(), "no_answer", 7L, "staff"
);
assertEquals(FollowUpTask.STATUS_PENDING, result.status());
assertEquals(1, result.attemptCount());
assertEquals(FollowUpTask.ATTEMPT_NO_ANSWER, result.lastAttemptOutcome());
assertNull(task.getAssignedUserId());
verify(businessEventService).recordFollowUpStarted(task, 7L, "staff");
verify(businessEventService).recordFollowUpRescheduled(task, 7L, "staff");
}
@Test
void taskClaimedByOtherStaffCannotBeMutated() {
FollowUpTask task = task(FollowUpTask.STATUS_IN_PROGRESS, null);
task.setAssignedUserId(8L);
when(followUpTaskMapper.findByTaskIdAndStoreIdForUpdate("task-1", 10L))
.thenReturn(Optional.of(task));
FlowException error = assertThrows(
FlowException.class,
() -> service.close(10L, "task-1", "not_interested", 7L, "staff")
);
assertEquals("FOLLOW_UP_ALREADY_CLAIMED", error.bizCode());
}
@Test
void taskFromAnotherStoreIsIndistinguishableFromMissingTask() {
when(followUpTaskMapper.findByTaskIdAndStoreIdForUpdate("task-1", 11L))
.thenReturn(Optional.empty());
FlowException error = assertThrows(
FlowException.class,
() -> service.start(11L, "task-1", 7L, "staff")
);
assertEquals(404, error.code());
assertEquals("FOLLOW_UP_TASK_NOT_FOUND", error.bizCode());
}
@Test
void pendingTaskCannotBeClosedWithoutBeingClaimed() {
FollowUpTask task = task(FollowUpTask.STATUS_PENDING, null);
when(followUpTaskMapper.findByTaskIdAndStoreIdForUpdate("task-1", 10L))
.thenReturn(Optional.of(task));
FlowException error = assertThrows(
FlowException.class,
() -> service.close(10L, "task-1", "not_interested", 7L, "staff")
);
assertEquals("FOLLOW_UP_STATE_CONFLICT", error.bizCode());
}
@Test
void onlyRealAppointmentCanCompleteRebookOutcome() {
FollowUpTask task = task(FollowUpTask.STATUS_IN_PROGRESS, null);
task.setAssignedUserId(7L);
StoreCustomer canonical = customer();
when(followUpTaskMapper.findByTaskIdAndStoreIdForUpdate("task-1", 10L))
.thenReturn(Optional.of(task));
when(storeCustomerService.resolveCanonical(10L, 50L)).thenReturn(canonical);
when(followUpTaskMapper.save(any(FollowUpTask.class))).thenAnswer(inv -> inv.getArgument(0));
when(reportLeadMapper.findByIdForUpdate(70L)).thenReturn(Optional.of(lead()));
when(reportLeadMapper.save(any(ReportLead.class))).thenAnswer(inv -> inv.getArgument(0));
Appointment appointment = new Appointment();
appointment.setId(100L);
appointment.setStoreId(10L);
appointment.setCreateTime(LocalDateTime.now());
FollowUpTask prepared = service.prepareRebook(10L, "task-1", 50L, 7L);
service.markRebooked(prepared, appointment, 7L, "staff");
assertEquals(FollowUpTask.STATUS_COMPLETED, task.getStatus());
assertEquals(FollowUpTask.OUTCOME_REBOOKED, task.getOutcome());
assertEquals(100L, task.getRebookedAppointmentId());
assertTrue(task.getAttemptCount() > 0);
verify(businessEventService).recordRebookCreated(task, appointment, 7L, "staff");
}
@Test
void crossStoreAppointmentCannotCompleteRebookOutcome() {
FollowUpTask task = task(FollowUpTask.STATUS_IN_PROGRESS, null);
task.setAssignedUserId(7L);
Appointment appointment = new Appointment();
appointment.setId(100L);
appointment.setStoreId(11L);
FlowException error = assertThrows(
FlowException.class,
() -> service.markRebooked(task, appointment, 7L, "staff")
);
assertEquals("FOLLOW_UP_REBOOK_INVALID", error.bizCode());
assertEquals(FollowUpTask.STATUS_IN_PROGRESS, task.getStatus());
assertNull(task.getOutcome());
}
@Test
void terminalTaskResponseKeepsOnlyMaskedPhone() {
FollowUpTask task = task(FollowUpTask.STATUS_IN_PROGRESS, null);
task.setAssignedUserId(7L);
stubTaskView(task);
when(followUpTaskMapper.findByTaskIdAndStoreIdForUpdate("task-1", 10L))
.thenReturn(Optional.of(task));
when(followUpTaskMapper.save(any(FollowUpTask.class))).thenAnswer(inv -> inv.getArgument(0));
FollowUpTaskService.TaskView result = service.close(
10L, "task-1", "not_interested", 7L, "staff"
);
assertNull(result.phone());
assertEquals("138****8001", result.phoneMasked());
}
@Test
void unsubscribeCancelsOpenTaskWithoutInventingContactAttempt() {
FollowUpTask task = task(FollowUpTask.STATUS_PENDING, null);
when(followUpTaskMapper.findOpenBySourceLeadIdForUpdate(eq(70L), any()))
.thenReturn(List.of(task));
when(followUpTaskMapper.save(any(FollowUpTask.class))).thenAnswer(inv -> inv.getArgument(0));
service.cancelForUnsubscribe(lead());
assertEquals(FollowUpTask.STATUS_CANCELED, task.getStatus());
assertEquals(FollowUpTask.OUTCOME_UNSUBSCRIBED, task.getOutcome());
assertEquals(0, task.getAttemptCount());
verify(businessEventService).recordFollowUpCanceled(task);
}
private void stubTaskView(FollowUpTask task) {
when(reportLeadMapper.findAllById(any())).thenReturn(List.of(lead()));
when(storeCustomerMapper.findAllById(any())).thenReturn(List.of(customer()));
when(userMapper.findAllById(any())).thenReturn(List.of());
when(reportLeadMapper.findByIdForUpdate(70L)).thenReturn(Optional.of(lead()));
when(reportLeadMapper.save(any(ReportLead.class))).thenAnswer(inv -> inv.getArgument(0));
}
private static ReportLead lead() {
ReportLead lead = new ReportLead();
lead.setId(70L);
lead.setReportId(9L);
lead.setStoreId(10L);
lead.setPhone("13800138001");
lead.setPetName("豆豆");
lead.setServiceType("洗护");
lead.setRemindDate(LocalDate.of(2026, 9, 1));
lead.setRemindStatus("pending");
return lead;
}
private static StoreCustomer customer() {
StoreCustomer customer = new StoreCustomer();
customer.setId(50L);
customer.setStoreId(10L);
customer.setPhone("13800138001");
customer.setDisplayName("豆豆家长");
customer.setDeleted(false);
return customer;
}
private static FollowUpTask task(String status, String outcome) {
FollowUpTask task = new FollowUpTask();
task.setId(90L);
task.setTaskId("task-1");
task.setStoreId(10L);
task.setStoreCustomerId(50L);
task.setSourceLeadId(70L);
task.setSourceReportId(9L);
task.setStatus(status);
task.setOutcome(outcome);
task.setDueDate(LocalDate.now());
task.setAttemptCount(0);
task.setCreateTime(LocalDateTime.now());
task.setUpdateTime(LocalDateTime.now());
return task;
}
}

View File

@ -2,6 +2,7 @@ package com.petstore.service;
import com.petstore.entity.Report;
import com.petstore.entity.ReportLead;
import com.petstore.entity.StoreCustomer;
import com.petstore.mapper.ReportLeadMapper;
import com.petstore.mapper.ReportMapper;
import com.petstore.mapper.ServiceIntervalMapper;
@ -32,6 +33,7 @@ class ReportLeadServiceStoreCustomerTest {
@Mock private WechatMiniProgramService wechatMiniProgramService;
@Mock private StoreCustomerService storeCustomerService;
@Mock private BusinessEventService businessEventService;
@Mock private FollowUpTaskService followUpTaskService;
@InjectMocks private ReportLeadService reportLeadService;
@Test
@ -52,6 +54,10 @@ class ReportLeadServiceStoreCustomerTest {
return lead;
});
when(userMapper.findByPhoneAndDeletedFalse("13800138001")).thenReturn(null);
StoreCustomer storeCustomer = new StoreCustomer();
storeCustomer.setId(50L);
when(storeCustomerService.touchLead(eq(10L), eq("13800138001"), eq(null), any(LocalDateTime.class)))
.thenReturn(storeCustomer);
ReportLeadService.LeadSubmitOutcome outcome = reportLeadService.submit(
"token", "13800138001", "127.0.0.1", ""
@ -59,6 +65,7 @@ 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));
verify(businessEventService).recordLeadSubmitted(any(ReportLead.class), eq(storeCustomer), eq(false));
verify(followUpTaskService).ensureForLead(any(ReportLead.class), eq(storeCustomer));
}
}

View File

@ -3,12 +3,14 @@ package com.petstore.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.petstore.entity.Appointment;
import com.petstore.entity.BusinessEvent;
import com.petstore.entity.FollowUpTask;
import com.petstore.entity.Report;
import com.petstore.entity.ReportLead;
import com.petstore.entity.StoreCustomer;
import com.petstore.entity.User;
import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.BusinessEventMapper;
import com.petstore.mapper.FollowUpTaskMapper;
import com.petstore.mapper.ReportLeadMapper;
import com.petstore.mapper.ReportMapper;
import com.petstore.mapper.StoreCustomerMapper;
@ -46,6 +48,7 @@ class StoreCustomerTimelineServiceTest {
@Mock private AppointmentMapper appointmentMapper;
@Mock private ReportMapper reportMapper;
@Mock private ReportLeadMapper reportLeadMapper;
@Mock private FollowUpTaskMapper followUpTaskMapper;
@Mock private UserMapper userMapper;
private StoreCustomerTimelineService service;
@ -58,6 +61,7 @@ class StoreCustomerTimelineServiceTest {
appointmentMapper,
reportMapper,
reportLeadMapper,
followUpTaskMapper,
userMapper,
new ObjectMapper()
);
@ -201,6 +205,40 @@ class StoreCustomerTimelineServiceTest {
);
}
@SuppressWarnings("unchecked")
@Test
void followUpFactExposesOnlyOperationalSummary() {
StoreCustomer customer = customer();
BusinessEvent completed = event(
5L, "event-follow-up", BusinessEventService.FOLLOW_UP_COMPLETED,
"follow_up_task", 400L, "{\"outcome\":\"not_interested\",\"phone\":\"13800138001\"}"
);
FollowUpTask task = new FollowUpTask();
task.setId(400L);
task.setTaskId("public-task-id");
task.setStoreId(10L);
task.setStatus(FollowUpTask.STATUS_COMPLETED);
task.setOutcome(FollowUpTask.OUTCOME_NOT_INTERESTED);
task.setDueDate(LocalDate.of(2026, 8, 2));
task.setAttemptCount(2);
when(storeCustomerMapper.findFirstByIdAndStoreId(50L, 10L)).thenReturn(Optional.of(customer));
when(storeCustomerMapper.findByStoreIdAndMergedIntoStoreCustomerId(10L, 50L)).thenReturn(List.of());
when(businessEventMapper.findByStoreIdAndStoreCustomerIdIn(any(), any(), any(Pageable.class)))
.thenAnswer(invocation -> new PageImpl<>(List.of(completed), invocation.getArgument(2), 1));
when(followUpTaskMapper.findAllById(any())).thenReturn(List.of(task));
when(userMapper.findByIdAndDeletedFalse(88L)).thenReturn(Optional.empty());
Map<String, Object> result = service.timeline(10L, 50L, 1, 20);
List<Map<String, Object>> items = (List<Map<String, Object>>) result.get("list");
assertEquals("回访已完成", items.get(0).get("title"));
assertEquals("本轮暂无意向", items.get(0).get("description"));
Map<String, Object> taskView = (Map<String, Object>) items.get(0).get("followUpTask");
assertEquals(2, taskView.get("attemptCount"));
assertFalse(result.toString().contains("13800138001"));
}
private static StoreCustomer customer() {
StoreCustomer customer = new StoreCustomer();
customer.setId(50L);