feat: add stable store customer master

This commit is contained in:
malei 2026-08-01 21:56:07 +08:00
parent 3f1bfffbed
commit 0cf6178288
16 changed files with 977 additions and 80 deletions

View File

@ -82,6 +82,12 @@ curl http://localhost:8080/api/store/list
``` ```
脚本新增 `customer_user_id`、`created_by_user_id`、`author_staff_id` 并回填可确定的历史数据。末尾三个验证查询必须留档;`unresolved_assisted_appointments` 非 0 时需人工确认,禁止把旧员工 `user_id` 冒认成客户。 脚本新增 `customer_user_id`、`created_by_user_id`、`author_staff_id` 并回填可确定的历史数据。末尾三个验证查询必须留档;`unresolved_assisted_appointments` 非 0 时需人工确认,禁止把旧员工 `user_id` 冒认成客户。
5. **门店客户主档**:身份拆分验证完成后,紧接着执行:
```bash
mysql -h <host> -u <user> -p petstore < db/migrations/20260801_create_store_customer.sql
```
脚本创建 `t_store_customer`,从预约和报告留资回填本店客户关系。末尾 `appointments_without_store_customer`、`leads_without_store_customer`、`store_customers_linked_to_non_customer` 必须全部为 0。
### production profile ### production profile
```bash ```bash
@ -141,6 +147,7 @@ npm --prefix frontend run build:mp-weixin
- [ ] `uk_report_appointment` 唯一约束上线前已清理重复数据 - [ ] `uk_report_appointment` 唯一约束上线前已清理重复数据
- [ ] 历史图片 URL 已执行一次性修复 SQL - [ ] 历史图片 URL 已执行一次性修复 SQL
- [ ] 已执行 `20260801_split_service_identity.sql`,并留存未解析预约/报告验证结果 - [ ] 已执行 `20260801_split_service_identity.sql`,并留存未解析预约/报告验证结果
- [ ] 已按顺序执行 `20260801_create_store_customer.sql`,三项主档验证计数均为 0
- [ ] 已暴露凭据DB 密码、微信 AppSecret已轮换 - [ ] 已暴露凭据DB 密码、微信 AppSecret已轮换
## 安全说明 ## 安全说明

View File

@ -0,0 +1,138 @@
-- Petstore Phase 0: create stable store-customer relationship master.
-- Target: MySQL. Run after 20260801_split_service_identity.sql.
-- One-time migration; back up the database before execution.
CREATE TABLE t_store_customer (
id BIGINT NOT NULL AUTO_INCREMENT,
store_id BIGINT NOT NULL COMMENT '门店 ID',
customer_user_id BIGINT NULL COMMENT '全局 customer 账号,留资客户可空',
phone VARCHAR(20) NULL COMMENT '本店最后确认的联系手机号',
display_name VARCHAR(64) NULL COMMENT '门店视角客户称呼',
source VARCHAR(32) NOT NULL COMMENT '首次来源',
first_contact_at DATETIME NULL COMMENT '首次接触时间',
last_contact_at DATETIME NULL COMMENT '最近接触时间',
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_store_customer_user (store_id, customer_user_id),
UNIQUE KEY uk_store_customer_phone (store_id, phone),
KEY idx_store_customer_active_update (store_id, deleted, update_time),
KEY idx_store_customer_last_contact (store_id, last_contact_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店客户关系主档';
-- 历史预约客户建档。旧数据无法精确确定首次是自助或代客,统一记为 appointment。
INSERT INTO t_store_customer (
store_id,
customer_user_id,
phone,
display_name,
source,
first_contact_at,
last_contact_at,
create_time,
update_time,
deleted
)
SELECT
a.store_id,
a.customer_user_id,
MAX(u.phone),
MAX(COALESCE(NULLIF(u.name, ''), NULLIF(u.username, ''), '客户')),
'appointment',
MIN(COALESCE(a.create_time, a.appointment_time, NOW())),
MAX(COALESCE(a.update_time, a.create_time, a.appointment_time, NOW())),
NOW(),
NOW(),
0
FROM t_appointment a
JOIN t_user u
ON u.id = a.customer_user_id
AND u.deleted = 0
AND u.role = 'customer'
WHERE a.deleted = 0
AND a.store_id IS NOT NULL
AND a.customer_user_id IS NOT NULL
GROUP BY a.store_id, a.customer_user_id;
-- 报告留资建档。若同店同手机已由预约建档,只更新最近接触时间并保留首次来源。
INSERT INTO t_store_customer (
store_id,
customer_user_id,
phone,
display_name,
source,
first_contact_at,
last_contact_at,
create_time,
update_time,
deleted
)
SELECT
l.store_id,
MAX(CASE WHEN u.role = 'customer' AND u.deleted = 0 THEN u.id ELSE NULL END),
l.phone,
MAX(CASE
WHEN u.role = 'customer' AND u.deleted = 0
THEN COALESCE(NULLIF(u.name, ''), NULLIF(u.username, ''), '留资客户')
ELSE '留资客户'
END),
'report_lead',
MIN(COALESCE(l.create_time, NOW())),
MAX(COALESCE(l.update_time, l.create_time, NOW())),
NOW(),
NOW(),
0
FROM t_report_lead l
LEFT JOIN t_user u
ON u.phone = l.phone
WHERE l.store_id IS NOT NULL
AND l.phone IS NOT NULL
AND l.phone <> ''
GROUP BY l.store_id, l.phone
ON DUPLICATE KEY UPDATE
customer_user_id = COALESCE(t_store_customer.customer_user_id, VALUES(customer_user_id)),
phone = COALESCE(t_store_customer.phone, VALUES(phone)),
display_name = CASE
WHEN t_store_customer.display_name IS NULL OR t_store_customer.display_name = ''
THEN VALUES(display_name)
ELSE t_store_customer.display_name
END,
first_contact_at = LEAST(
COALESCE(t_store_customer.first_contact_at, VALUES(first_contact_at)),
VALUES(first_contact_at)
),
last_contact_at = GREATEST(
COALESCE(t_store_customer.last_contact_at, VALUES(last_contact_at)),
VALUES(last_contact_at)
),
update_time = NOW(),
deleted = 0;
-- Verification queries. All three counts should be 0 before application release.
SELECT COUNT(*) AS appointments_without_store_customer
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
WHERE a.deleted = 0
AND a.customer_user_id IS NOT NULL
AND sc.id IS NULL;
SELECT COUNT(*) AS leads_without_store_customer
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
AND l.phone IS NOT NULL
AND l.phone <> ''
AND sc.id IS NULL;
SELECT COUNT(*) AS store_customers_linked_to_non_customer
FROM t_store_customer sc
JOIN t_user u ON u.id = sc.customer_user_id
WHERE sc.deleted = 0
AND (u.deleted <> 0 OR u.role <> 'customer');

View File

@ -10,4 +10,6 @@
`20260801_split_service_identity.sql` 为身份语义拆分迁移:新增客户、创建人和报告作者字段,保留 `user_id` / `assigned_user_id` 兼容旧版本。 `20260801_split_service_identity.sql` 为身份语义拆分迁移:新增客户、创建人和报告作者字段,保留 `user_id` / `assigned_user_id` 兼容旧版本。
`20260801_create_store_customer.sql` 必须在上述身份迁移之后执行:建立 `t_store_customer` 门店客户主档,并从预约与报告留资回填历史关系。脚本末尾三个验证计数必须为 0。
正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。 正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。

View File

@ -0,0 +1,78 @@
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>它不是会员资产表余额积分次卡不属于本聚合
* customerUserId 允许为空用于承接尚未登录/建号的报告留资客户
*/
@Data
@Entity
@Table(
name = "t_store_customer",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_store_customer_user",
columnNames = {"store_id", "customer_user_id"}
),
@UniqueConstraint(
name = "uk_store_customer_phone",
columnNames = {"store_id", "phone"}
)
},
indexes = {
@Index(name = "idx_store_customer_active_update", columnList = "store_id,deleted,update_time"),
@Index(name = "idx_store_customer_last_contact", columnList = "store_id,last_contact_at")
}
)
public class StoreCustomer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
/** 全局 customer 账号;留资客户可先为空,后续再绑定。 */
@Column(name = "customer_user_id")
private Long customerUserId;
/** 本店最后确认的联系手机号API 默认只返回脱敏值。 */
@Column(length = 20)
private String phone;
/** 门店视角的客户称呼。 */
@Column(name = "display_name", length = 64)
private String displayName;
/** 首次关系来源customer_booking / assisted_booking / report_lead。 */
@Column(length = 32, nullable = false)
private String source;
@Column(name = "first_contact_at")
private LocalDateTime firstContactAt;
@Column(name = "last_contact_at")
private LocalDateTime lastContactAt;
@Column(name = "create_time", nullable = false)
private LocalDateTime createTime;
@Column(name = "update_time", nullable = false)
private LocalDateTime updateTime;
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
private Boolean deleted = false;
}

View File

@ -0,0 +1,16 @@
package com.petstore.mapper;
import com.petstore.entity.StoreCustomer;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface StoreCustomerMapper extends JpaRepository<StoreCustomer, Long> {
List<StoreCustomer> findByStoreIdAndDeletedFalseOrderByLastContactAtDesc(Long storeId);
Optional<StoreCustomer> findFirstByStoreIdAndCustomerUserId(Long storeId, Long customerUserId);
Optional<StoreCustomer> findFirstByStoreIdAndPhone(Long storeId, String phone);
}

View File

@ -4,11 +4,13 @@ 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.ReportLead; import com.petstore.entity.ReportLead;
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;
import com.petstore.mapper.ReportLeadMapper; import com.petstore.mapper.ReportLeadMapper;
import com.petstore.mapper.ReportMapper; import com.petstore.mapper.ReportMapper;
import com.petstore.mapper.StoreCustomerMapper;
import com.petstore.mapper.UserMapper; import com.petstore.mapper.UserMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -27,8 +29,8 @@ import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* 门店后台服务客户聚合本店预约客户 + 报告留资手机号投影 * 门店后台服务客户读模型StoreCustomer 稳定主档 + 预约/宠物/报告/留资动态事实
* 无独立表不做余额/次卡/会员字段 * 不做余额/次卡/会员字段
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@ -39,6 +41,7 @@ public class AdminServiceCustomerService {
private final PetMapper petMapper; private final PetMapper petMapper;
private final ReportMapper reportMapper; private final ReportMapper reportMapper;
private final ReportLeadMapper reportLeadMapper; private final ReportLeadMapper reportLeadMapper;
private final StoreCustomerMapper storeCustomerMapper;
public Map<String, Object> list( public Map<String, Object> list(
Long storeId, Long storeId,
@ -59,40 +62,16 @@ public class AdminServiceCustomerService {
List<Appointment> appointments = appointmentMapper.findByStoreIdAndDeletedFalse(storeId); List<Appointment> appointments = appointmentMapper.findByStoreIdAndDeletedFalse(storeId);
List<ReportLead> leads = reportLeadMapper.findByStoreIdOrderByCreateTimeDesc(storeId); List<ReportLead> leads = reportLeadMapper.findByStoreIdOrderByCreateTimeDesc(storeId);
List<Report> reports = reportMapper.findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(storeId); List<Report> reports = reportMapper.findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(storeId);
List<StoreCustomer> masters = storeCustomerMapper
.findByStoreIdAndDeletedFalseOrderByLastContactAtDesc(storeId);
Map<Long, Appointment> appointmentsById = appointments.stream() Map<Long, Appointment> appointmentsById = appointments.stream()
.filter(a -> a.getId() != null) .filter(a -> a.getId() != null)
.collect(Collectors.toMap(Appointment::getId, a -> a, (a, b) -> a)); .collect(Collectors.toMap(Appointment::getId, a -> a, (a, b) -> a));
Set<Long> userIds = appointments.stream() Set<Long> userIds = masters.stream()
.map(Appointment::resolvedCustomerUserId) .map(StoreCustomer::getCustomerUserId)
.filter(java.util.Objects::nonNull) .filter(java.util.Objects::nonNull)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
Map<String, User> usersByPhone = new HashMap<>();
if (!userIds.isEmpty()) {
for (User u : userMapper.findAllById(userIds)) {
if (Boolean.TRUE.equals(u.getDeleted()) || !"customer".equals(u.getRole())) {
continue;
}
if (u.getPhone() != null && !u.getPhone().isBlank()) {
usersByPhone.put(u.getPhone(), u);
}
}
}
// 留资手机号可能尚未有预约 user按手机号补齐用户
for (ReportLead lead : leads) {
if (lead.getPhone() == null || lead.getPhone().isBlank()) {
continue;
}
if (usersByPhone.containsKey(lead.getPhone())) {
continue;
}
User byPhone = userMapper.findByPhoneAndDeletedFalse(lead.getPhone());
if (byPhone != null && "customer".equals(byPhone.getRole())) {
usersByPhone.put(lead.getPhone(), byPhone);
userIds.add(byPhone.getId());
}
}
Map<Long, User> users = new HashMap<>(); Map<Long, User> users = new HashMap<>();
if (!userIds.isEmpty()) { if (!userIds.isEmpty()) {
@ -103,11 +82,11 @@ public class AdminServiceCustomerService {
} }
} }
// 新字段优先legacy user_id 只有确认为 customer 角色时才纳入避免把代客员工当客户 // 只聚合已经进入本店客户主档的 customer迁移脚本负责历史回填
Map<Long, List<Appointment>> apptsByUser = new LinkedHashMap<>(); Map<Long, List<Appointment>> apptsByUser = new LinkedHashMap<>();
for (Appointment appointment : appointments) { for (Appointment appointment : appointments) {
Long customerUserId = appointment.resolvedCustomerUserId(); Long customerUserId = appointment.resolvedCustomerUserId();
if (customerUserId == null || !users.containsKey(customerUserId)) { if (customerUserId == null || !userIds.contains(customerUserId)) {
continue; continue;
} }
apptsByUser.computeIfAbsent(customerUserId, ignored -> new ArrayList<>()).add(appointment); apptsByUser.computeIfAbsent(customerUserId, ignored -> new ArrayList<>()).add(appointment);
@ -127,7 +106,7 @@ public class AdminServiceCustomerService {
customerUserId = appointment.resolvedCustomerUserId(); customerUserId = appointment.resolvedCustomerUserId();
} }
} }
if (customerUserId == null || !users.containsKey(customerUserId)) { if (customerUserId == null || !userIds.contains(customerUserId)) {
continue; continue;
} }
latestReportByUser.putIfAbsent(customerUserId, r); latestReportByUser.putIfAbsent(customerUserId, r);
@ -137,25 +116,28 @@ public class AdminServiceCustomerService {
.filter(l -> l.getPhone() != null && !l.getPhone().isBlank()) .filter(l -> l.getPhone() != null && !l.getPhone().isBlank())
.collect(Collectors.groupingBy(ReportLead::getPhone)); .collect(Collectors.groupingBy(ReportLead::getPhone));
// key: userId "phone:" + phone无用户 List<CustomerAgg> aggs = new ArrayList<>();
Map<String, CustomerAgg> aggs = new LinkedHashMap<>(); LocalDateTime now = LocalDateTime.now();
for (StoreCustomer master : masters) {
for (Map.Entry<Long, List<Appointment>> e : apptsByUser.entrySet()) { Long userId = master.getCustomerUserId();
Long userId = e.getKey(); User user = userId == null ? null : users.get(userId);
User user = users.get(userId); CustomerAgg agg = CustomerAgg.fromMaster(master, user);
if (user == null) { if (isBookingSource(master.getSource())) {
continue; agg.sources.add("appointment");
} else if (StoreCustomerService.SOURCE_REPORT_LEAD.equals(master.getSource())) {
agg.sources.add("lead");
} }
CustomerAgg agg = aggs.computeIfAbsent("u:" + userId, k -> CustomerAgg.fromUser(user));
agg.sources.add("appointment"); for (Appointment a : apptsByUser.getOrDefault(userId, List.of())) {
for (Appointment a : e.getValue()) { agg.sources.add("appointment");
agg.touchVisit(a.getAppointmentTime()); if (isActualVisit(a, now)) {
agg.touchVisit(a.getAppointmentTime());
}
if (a.getPetName() != null && !a.getPetName().isBlank()) { if (a.getPetName() != null && !a.getPetName().isBlank()) {
agg.petNames.add(a.getPetName().trim()); agg.petNames.add(a.getPetName().trim());
} }
} }
List<Pet> pets = petsByOwner.getOrDefault(userId, List.of()); for (Pet p : petsByOwner.getOrDefault(userId, List.of())) {
for (Pet p : pets) {
if (p.getName() != null && !p.getName().isBlank()) { if (p.getName() != null && !p.getName().isBlank()) {
agg.petNames.add(p.getName().trim()); agg.petNames.add(p.getName().trim());
} }
@ -164,25 +146,10 @@ public class AdminServiceCustomerService {
if (latest != null) { if (latest != null) {
agg.lastReportId = latest.getId(); agg.lastReportId = latest.getId();
} }
if (user.getPhone() != null) { if (agg.phone != null) {
List<ReportLead> phoneLeads = leadsByPhone.getOrDefault(user.getPhone(), List.of()); applyLeads(agg, leadsByPhone.getOrDefault(agg.phone, List.of()));
applyLeads(agg, phoneLeads);
} }
} aggs.add(agg);
for (Map.Entry<String, List<ReportLead>> e : leadsByPhone.entrySet()) {
String phone = e.getKey();
User user = usersByPhone.get(phone);
if (user != null && aggs.containsKey("u:" + user.getId())) {
continue;
}
if (user != null) {
CustomerAgg agg = aggs.computeIfAbsent("u:" + user.getId(), k -> CustomerAgg.fromUser(user));
applyLeads(agg, e.getValue());
continue;
}
CustomerAgg agg = aggs.computeIfAbsent("p:" + phone, k -> CustomerAgg.fromPhone(phone));
applyLeads(agg, e.getValue());
} }
String sourceNorm = source == null || source.isBlank() || "all".equalsIgnoreCase(source) String sourceNorm = source == null || source.isBlank() || "all".equalsIgnoreCase(source)
@ -191,7 +158,7 @@ public class AdminServiceCustomerService {
String qNorm = q == null ? "" : q.trim().toLowerCase(Locale.ROOT); String qNorm = q == null ? "" : q.trim().toLowerCase(Locale.ROOT);
List<CustomerAgg> filtered = new ArrayList<>(); List<CustomerAgg> filtered = new ArrayList<>();
for (CustomerAgg agg : aggs.values()) { for (CustomerAgg agg : aggs) {
if (!matchSource(agg, sourceNorm)) { if (!matchSource(agg, sourceNorm)) {
continue; continue;
} }
@ -278,6 +245,21 @@ public class AdminServiceCustomerService {
return true; return true;
} }
private static boolean isBookingSource(String source) {
return StoreCustomerService.SOURCE_CUSTOMER_BOOKING.equals(source)
|| StoreCustomerService.SOURCE_ASSISTED_BOOKING.equals(source)
|| "appointment".equals(source);
}
/** 「到店」只认已开始/已完成的服务,不把未来预约或过期未履约 new 单计为到店。 */
private static boolean isActualVisit(Appointment appointment, LocalDateTime now) {
if (appointment.getAppointmentTime() == null || appointment.getAppointmentTime().isAfter(now)) {
return false;
}
String status = appointment.getStatus() == null ? "" : appointment.getStatus().trim().toLowerCase(Locale.ROOT);
return "doing".equals(status) || "done".equals(status);
}
static String maskPhone(String phone) { static String maskPhone(String phone) {
if (phone == null || phone.isBlank()) { if (phone == null || phone.isBlank()) {
return null; return null;
@ -293,10 +275,14 @@ public class AdminServiceCustomerService {
} }
private static final class CustomerAgg { private static final class CustomerAgg {
Long storeCustomerId;
Long userId; Long userId;
String displayName; String displayName;
String phone; String phone;
String phoneMasked; String phoneMasked;
String originSource;
LocalDateTime firstContactAt;
LocalDateTime lastContactAt;
final Set<String> petNames = new LinkedHashSet<>(); final Set<String> petNames = new LinkedHashSet<>();
final Set<String> sources = new LinkedHashSet<>(); final Set<String> sources = new LinkedHashSet<>();
LocalDateTime lastVisitAt; LocalDateTime lastVisitAt;
@ -304,24 +290,31 @@ public class AdminServiceCustomerService {
String leadStatus; String leadStatus;
boolean hasPendingLead; boolean hasPendingLead;
static CustomerAgg fromUser(User user) { static CustomerAgg fromMaster(StoreCustomer master, User user) {
CustomerAgg a = new CustomerAgg(); CustomerAgg a = new CustomerAgg();
a.userId = user.getId(); a.storeCustomerId = master.getId();
a.displayName = user.getName() != null && !user.getName().isBlank() a.userId = master.getCustomerUserId();
? user.getName() a.displayName = firstNonBlank(
: (user.getUsername() != null ? user.getUsername() : "客户"); master.getDisplayName(),
a.phone = user.getPhone(); user == null ? null : user.getName(),
a.phoneMasked = maskPhone(user.getPhone()); user == null ? null : user.getUsername(),
"留资客户"
);
a.phone = firstNonBlank(master.getPhone(), user == null ? null : user.getPhone());
a.phoneMasked = maskPhone(a.phone);
a.originSource = master.getSource();
a.firstContactAt = master.getFirstContactAt();
a.lastContactAt = master.getLastContactAt();
return a; return a;
} }
static CustomerAgg fromPhone(String phone) { private static String firstNonBlank(String... values) {
CustomerAgg a = new CustomerAgg(); for (String value : values) {
a.userId = null; if (value != null && !value.isBlank()) {
a.displayName = "留资客户"; return value;
a.phone = phone; }
a.phoneMasked = maskPhone(phone); }
return a; return null;
} }
void touchVisit(LocalDateTime t) { void touchVisit(LocalDateTime t) {
@ -353,9 +346,13 @@ public class AdminServiceCustomerService {
Map<String, Object> toRow() { Map<String, Object> toRow() {
Map<String, Object> row = new LinkedHashMap<>(); Map<String, Object> row = new LinkedHashMap<>();
row.put("storeCustomerId", storeCustomerId);
row.put("userId", userId); row.put("userId", userId);
row.put("displayName", displayName); row.put("displayName", displayName);
row.put("phoneMasked", phoneMasked); row.put("phoneMasked", phoneMasked);
row.put("originSource", originSource);
row.put("firstContactAt", firstContactAt == null ? null : firstContactAt.toString());
row.put("lastContactAt", lastContactAt == null ? null : lastContactAt.toString());
row.put("pets", petNames.stream() row.put("pets", petNames.stream()
.map(n -> Map.of("name", n)) .map(n -> Map.of("name", n))
.toList()); .toList());

View File

@ -38,6 +38,7 @@ public class AppointmentService {
private final UserMapper userMapper; private final UserMapper userMapper;
private final ReportMapper reportMapper; private final ReportMapper reportMapper;
private final PetMapper petMapper; private final PetMapper petMapper;
private final StoreCustomerService storeCustomerService;
// 宠主查看归属于自己的预约兼容 customer_user_id 尚未回填的旧数据 // 宠主查看归属于自己的预约兼容 customer_user_id 尚未回填的旧数据
public List<Appointment> getByUserId(Long customerUserId) { public List<Appointment> getByUserId(Long customerUserId) {
@ -200,6 +201,20 @@ 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);
try {
storeCustomerService.touchBooking(
appointment.getStoreId(),
appointment.getCustomerUserId(),
appointment.getCreatedByUserId(),
appointment.getCreateTime()
);
} catch (IllegalArgumentException e) {
return Map.of(
"code", 409,
"message", e.getMessage(),
"bizCode", "CUSTOMER_IDENTITY_CONFLICT"
);
}
Appointment saved = appointmentMapper.save(appointment); Appointment saved = appointmentMapper.save(appointment);
Map<String, Object> ok = new HashMap<>(); Map<String, Object> ok = new HashMap<>();
ok.put("code", 200); ok.put("code", 200);

View File

@ -31,6 +31,7 @@ public class ReportLeadService {
private final UserMapper userMapper; private final UserMapper userMapper;
private final UserService userService; private final UserService userService;
private final WechatMiniProgramService wechatMiniProgramService; private final WechatMiniProgramService wechatMiniProgramService;
private final StoreCustomerService storeCustomerService;
/** 留资提交结果:记录 + 是否已将微信 openid 绑定到宠主账号S3→S4+ 是否同报告同手机号再次提交(幂等更新) */ /** 留资提交结果:记录 + 是否已将微信 openid 绑定到宠主账号S3→S4+ 是否同报告同手机号再次提交(幂等更新) */
public record LeadSubmitOutcome(ReportLead lead, boolean wechatBound, boolean repeatSubmit) {} public record LeadSubmitOutcome(ReportLead lead, boolean wechatBound, boolean repeatSubmit) {}
@ -208,6 +209,8 @@ public class ReportLeadService {
} }
} }
storeCustomerService.touchLead(report.getStoreId(), phone, null, now);
return new LeadSubmitOutcome(lead, wechatBound, repeatSubmit); return new LeadSubmitOutcome(lead, wechatBound, repeatSubmit);
} }

View File

@ -0,0 +1,206 @@
package com.petstore.service;
import com.petstore.entity.StoreCustomer;
import com.petstore.entity.User;
import com.petstore.mapper.StoreCustomerMapper;
import com.petstore.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Objects;
/** 门店客户主档建档与身份合并。 */
@Service
@RequiredArgsConstructor
public class StoreCustomerService {
public static final String SOURCE_CUSTOMER_BOOKING = "customer_booking";
public static final String SOURCE_ASSISTED_BOOKING = "assisted_booking";
public static final String SOURCE_REPORT_LEAD = "report_lead";
private final StoreCustomerMapper storeCustomerMapper;
private final UserMapper userMapper;
@Transactional
public StoreCustomer touchBooking(
Long storeId,
Long customerUserId,
Long createdByUserId,
LocalDateTime occurredAt
) {
User customer = customerUserId == null
? null
: userMapper.findByIdAndDeletedFalse(customerUserId).orElse(null);
if (customer == null || !"customer".equals(customer.getRole())) {
throw new IllegalArgumentException("预约客户不是有效宠主账号");
}
String source = Objects.equals(customerUserId, createdByUserId)
? SOURCE_CUSTOMER_BOOKING
: SOURCE_ASSISTED_BOOKING;
return upsert(
storeId,
customerUserId,
customer.getPhone(),
preferredName(customer),
source,
occurredAt
);
}
@Transactional
public StoreCustomer touchLead(
Long storeId,
String phone,
String displayName,
LocalDateTime occurredAt
) {
String phoneNorm = normalizePhone(phone);
User matchedUser = userMapper.findByPhoneAndDeletedFalse(phoneNorm);
User customer = matchedUser != null && "customer".equals(matchedUser.getRole()) ? matchedUser : null;
Long customerUserId = customer == null ? null : customer.getId();
String resolvedName = displayName;
if ((resolvedName == null || resolvedName.isBlank()) && customer != null) {
resolvedName = preferredName(customer);
}
if (resolvedName == null || resolvedName.isBlank()) {
resolvedName = "留资客户";
}
return upsert(storeId, customerUserId, phoneNorm, resolvedName, SOURCE_REPORT_LEAD, occurredAt);
}
private StoreCustomer upsert(
Long storeId,
Long customerUserId,
String phone,
String displayName,
String source,
LocalDateTime occurredAt
) {
if (storeId == null) {
throw new IllegalArgumentException("门店不能为空");
}
String phoneNorm = normalizeOptionalPhone(phone);
if (customerUserId == null && phoneNorm == null) {
throw new IllegalArgumentException("客户账号与手机号不能同时为空");
}
LocalDateTime at = occurredAt == null ? LocalDateTime.now() : occurredAt;
StoreCustomer byUser = customerUserId == null
? null
: storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(storeId, customerUserId).orElse(null);
StoreCustomer byPhone = phoneNorm == null
? null
: storeCustomerMapper.findFirstByStoreIdAndPhone(storeId, phoneNorm).orElse(null);
if (byPhone != null
&& byPhone.getCustomerUserId() != null
&& customerUserId != null
&& !customerUserId.equals(byPhone.getCustomerUserId())) {
throw new IllegalArgumentException("该手机号已关联其他宠主");
}
StoreCustomer target;
if (byUser != null && byPhone != null && !Objects.equals(byUser.getId(), byPhone.getId())) {
// 历史上先按账号后按手机号产生两条时保留账号主档软删除无账号的留资投影
if (byPhone.getCustomerUserId() != null) {
throw new IllegalArgumentException("客户账号与手机号主档冲突");
}
mergeContactWindow(byUser, byPhone);
byPhone.setPhone(null);
byPhone.setDeleted(true);
byPhone.setUpdateTime(LocalDateTime.now());
storeCustomerMapper.saveAndFlush(byPhone);
target = byUser;
} else {
target = byUser != null ? byUser : byPhone;
}
boolean creating = target == null;
if (creating) {
target = new StoreCustomer();
target.setStoreId(storeId);
target.setSource(source);
target.setFirstContactAt(at);
target.setCreateTime(LocalDateTime.now());
}
target.setDeleted(false);
if (customerUserId != null) {
target.setCustomerUserId(customerUserId);
}
if (phoneNorm != null) {
target.setPhone(phoneNorm);
}
if (isUsefulName(displayName) && (!isUsefulName(target.getDisplayName()) || isGenericName(target.getDisplayName()))) {
target.setDisplayName(truncate(displayName.trim(), 64));
}
if (target.getSource() == null || target.getSource().isBlank() || "migration".equals(target.getSource())) {
target.setSource(source);
}
if (target.getFirstContactAt() == null || at.isBefore(target.getFirstContactAt())) {
target.setFirstContactAt(at);
}
if (target.getLastContactAt() == null || at.isAfter(target.getLastContactAt())) {
target.setLastContactAt(at);
}
target.setUpdateTime(LocalDateTime.now());
return storeCustomerMapper.save(target);
}
private static void mergeContactWindow(StoreCustomer target, StoreCustomer duplicate) {
if (duplicate.getFirstContactAt() != null
&& (target.getFirstContactAt() == null || duplicate.getFirstContactAt().isBefore(target.getFirstContactAt()))) {
target.setFirstContactAt(duplicate.getFirstContactAt());
}
if (duplicate.getLastContactAt() != null
&& (target.getLastContactAt() == null || duplicate.getLastContactAt().isAfter(target.getLastContactAt()))) {
target.setLastContactAt(duplicate.getLastContactAt());
}
if ((!isUsefulName(target.getDisplayName()) || isGenericName(target.getDisplayName()))
&& isUsefulName(duplicate.getDisplayName())) {
target.setDisplayName(duplicate.getDisplayName());
}
}
private static String preferredName(User user) {
if (user.getName() != null && !user.getName().isBlank()) {
return user.getName();
}
if (user.getUsername() != null && !user.getUsername().isBlank()) {
return user.getUsername();
}
return "客户";
}
private static String normalizePhone(String phone) {
String normalized = phone == null ? "" : phone.trim();
if (!normalized.matches("^1[3-9]\\d{9}$")) {
throw new IllegalArgumentException("手机号格式不正确");
}
return normalized;
}
private static String normalizeOptionalPhone(String phone) {
if (phone == null || phone.isBlank()) {
return null;
}
return normalizePhone(phone);
}
private static boolean isUsefulName(String name) {
return name != null && !name.isBlank();
}
private static boolean isGenericName(String name) {
return name == null
|| name.isBlank()
|| name.startsWith("留资客户")
|| name.startsWith("微信用户")
|| name.startsWith("到店宠主");
}
private static String truncate(String value, int maxLength) {
return value.length() <= maxLength ? value : value.substring(0, maxLength);
}
}

View File

@ -68,6 +68,7 @@ class AdminServiceCustomerControllerTest {
void responseHasNoBalanceFields() { void responseHasNoBalanceFields() {
CurrentUserContext.set(new CurrentUser(1L, 10L, "boss")); CurrentUserContext.set(new CurrentUser(1L, 10L, "boss"));
Map<String, Object> row = Map.of( Map<String, Object> row = Map.of(
"storeCustomerId", 20L,
"userId", 5L, "userId", 5L,
"displayName", "张三", "displayName", "张三",
"phoneMasked", "138****8001", "phoneMasked", "138****8001",
@ -93,6 +94,8 @@ class AdminServiceCustomerControllerTest {
assertFalse(first.containsKey("balance")); assertFalse(first.containsKey("balance"));
assertFalse(first.containsKey("points")); assertFalse(first.containsKey("points"));
assertFalse(first.containsKey("membershipLevel")); assertFalse(first.containsKey("membershipLevel"));
assertFalse(first.containsKey("phone"));
assertEquals(20L, first.get("storeCustomerId"));
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Object> summary = (Map<String, Object>) data.get("summary"); Map<String, Object> summary = (Map<String, Object>) data.get("summary");
assertFalse(summary.containsKey("totalBalance")); assertFalse(summary.containsKey("totalBalance"));

View File

@ -2,6 +2,7 @@ package com.petstore.mapper;
import com.petstore.entity.Appointment; import com.petstore.entity.Appointment;
import com.petstore.entity.Report; import com.petstore.entity.Report;
import com.petstore.entity.StoreCustomer;
import com.petstore.service.ServiceTypeService; import com.petstore.service.ServiceTypeService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -27,6 +28,7 @@ class IdentityOwnershipQueryTest {
@Autowired private AppointmentMapper appointmentMapper; @Autowired private AppointmentMapper appointmentMapper;
@Autowired private ReportMapper reportMapper; @Autowired private ReportMapper reportMapper;
@Autowired private StoreCustomerMapper storeCustomerMapper;
@MockBean private ServiceTypeService serviceTypeService; @MockBean private ServiceTypeService serviceTypeService;
@Test @Test
@ -56,6 +58,26 @@ class IdentityOwnershipQueryTest {
assertEquals(1, reportMapper.findForCustomer(66L).size()); assertEquals(1, reportMapper.findForCustomer(66L).size());
} }
@Test
void storeCustomerCanBeResolvedByStableUserAndPhoneKeys() {
StoreCustomer customer = new StoreCustomer();
customer.setStoreId(10L);
customer.setCustomerUserId(88L);
customer.setPhone("13800138001");
customer.setDisplayName("小白妈妈");
customer.setSource("assisted_booking");
customer.setFirstContactAt(LocalDateTime.of(2026, 8, 1, 10, 0));
customer.setLastContactAt(LocalDateTime.of(2026, 8, 1, 10, 0));
customer.setCreateTime(LocalDateTime.of(2026, 8, 1, 10, 0));
customer.setUpdateTime(LocalDateTime.of(2026, 8, 1, 10, 0));
storeCustomerMapper.saveAndFlush(customer);
assertEquals(customer.getId(), storeCustomerMapper
.findFirstByStoreIdAndCustomerUserId(10L, 88L).orElseThrow().getId());
assertEquals(customer.getId(), storeCustomerMapper
.findFirstByStoreIdAndPhone(10L, "13800138001").orElseThrow().getId());
}
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("小白");

View File

@ -0,0 +1,90 @@
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;
class StoreCustomerMigrationTest {
@Test
void migrationCreatesAndMergesAppointmentAndLeadMaster() throws Exception {
try (Connection connection = DriverManager.getConnection(
"jdbc:h2:mem:store-customer-migration;MODE=MySQL;DATABASE_TO_LOWER=TRUE",
"sa",
""
)) {
try (Statement statement = connection.createStatement()) {
statement.execute("""
CREATE TABLE t_user (
id BIGINT PRIMARY KEY,
phone VARCHAR(20),
name VARCHAR(64),
username VARCHAR(64),
role VARCHAR(32),
deleted TINYINT NOT NULL DEFAULT 0
)
""");
statement.execute("""
CREATE TABLE t_appointment (
id BIGINT PRIMARY KEY,
store_id BIGINT,
customer_user_id BIGINT,
appointment_time DATETIME,
create_time DATETIME,
update_time DATETIME,
deleted TINYINT NOT NULL DEFAULT 0
)
""");
statement.execute("""
CREATE TABLE t_report_lead (
id BIGINT PRIMARY KEY,
store_id BIGINT,
phone VARCHAR(20),
create_time DATETIME,
update_time DATETIME
)
""");
statement.execute("""
INSERT INTO t_user VALUES
(88, '13800138001', '小白妈妈', '13800138001', 'customer', 0)
""");
statement.execute("""
INSERT INTO t_appointment VALUES
(1, 10, 88, '2026-08-01 10:00:00', '2026-07-28 09:00:00', '2026-08-01 11:00:00', 0)
""");
statement.execute("""
INSERT INTO t_report_lead VALUES
(9, 10, '13800138001', '2026-08-01 11:30:00', '2026-08-01 11:30:00')
""");
}
try (Reader reader = Files.newBufferedReader(
Path.of("db/migrations/20260801_create_store_customer.sql")
)) {
RunScript.execute(connection, reader);
}
try (Statement statement = connection.createStatement();
ResultSet rows = statement.executeQuery("""
SELECT customer_user_id, phone, source
FROM t_store_customer
WHERE store_id = 10 AND deleted = 0
""")) {
rows.next();
assertEquals(88L, rows.getLong("customer_user_id"));
assertEquals("13800138001", rows.getString("phone"));
assertEquals("appointment", rows.getString("source"));
assertEquals(false, rows.next(), "同一客户的预约与留资应合并为一条主档");
}
}
}
}

View File

@ -1,12 +1,40 @@
package com.petstore.service; package com.petstore.service;
import com.petstore.entity.Appointment;
import com.petstore.entity.StoreCustomer;
import com.petstore.entity.User;
import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.PetMapper;
import com.petstore.mapper.ReportLeadMapper;
import com.petstore.mapper.ReportMapper;
import com.petstore.mapper.StoreCustomerMapper;
import com.petstore.mapper.UserMapper;
import org.junit.jupiter.api.Test; 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.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AdminServiceCustomerServiceTest { class AdminServiceCustomerServiceTest {
@Mock private AppointmentMapper appointmentMapper;
@Mock private UserMapper userMapper;
@Mock private PetMapper petMapper;
@Mock private ReportMapper reportMapper;
@Mock private ReportLeadMapper reportLeadMapper;
@Mock private StoreCustomerMapper storeCustomerMapper;
@InjectMocks private AdminServiceCustomerService service;
@Test @Test
void maskPhoneElevenDigits() { void maskPhoneElevenDigits() {
assertEquals("138****8001", AdminServiceCustomerService.maskPhone("13800138001")); assertEquals("138****8001", AdminServiceCustomerService.maskPhone("13800138001"));
@ -16,4 +44,62 @@ class AdminServiceCustomerServiceTest {
void maskPhoneNull() { void maskPhoneNull() {
assertNull(AdminServiceCustomerService.maskPhone(null)); assertNull(AdminServiceCustomerService.maskPhone(null));
} }
@SuppressWarnings("unchecked")
@Test
void listUsesStableMasterAndDoesNotCountFutureBookingAsVisit() {
LocalDateTime actualVisit = LocalDateTime.now().minusDays(2).withNano(0);
LocalDateTime futureBooking = LocalDateTime.now().plusDays(2).withNano(0);
StoreCustomer master = new StoreCustomer();
master.setId(501L);
master.setStoreId(10L);
master.setCustomerUserId(88L);
master.setPhone("13800138001");
master.setDisplayName("小白妈妈");
master.setSource(StoreCustomerService.SOURCE_ASSISTED_BOOKING);
master.setFirstContactAt(LocalDateTime.now().minusMonths(1));
master.setLastContactAt(LocalDateTime.now().minusDays(1));
master.setDeleted(false);
User user = new User();
user.setId(88L);
user.setRole("customer");
user.setPhone("13800138001");
user.setDeleted(false);
Appointment done = appointment(1L, 88L, "done", actualVisit, "小白");
Appointment future = appointment(2L, 88L, "new", futureBooking, "小白");
when(storeCustomerMapper.findByStoreIdAndDeletedFalseOrderByLastContactAtDesc(10L))
.thenReturn(List.of(master));
when(appointmentMapper.findByStoreIdAndDeletedFalse(10L)).thenReturn(List.of(done, future));
when(reportLeadMapper.findByStoreIdOrderByCreateTimeDesc(10L)).thenReturn(List.of());
when(reportMapper.findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(10L)).thenReturn(List.of());
when(userMapper.findAllById(any())).thenReturn(List.of(user));
when(petMapper.findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(88L)).thenReturn(List.of());
Map<String, Object> data = service.list(10L, null, null, null, null, null, 1, 20);
List<Map<String, Object>> rows = (List<Map<String, Object>>) data.get("list");
assertEquals(1, rows.size());
Map<String, Object> row = rows.get(0);
assertEquals(501L, row.get("storeCustomerId"));
assertEquals(88L, row.get("userId"));
assertEquals("138****8001", row.get("phoneMasked"));
assertEquals(StoreCustomerService.SOURCE_ASSISTED_BOOKING, row.get("originSource"));
assertEquals(actualVisit.toString(), row.get("lastVisitAt"));
assertEquals("appointment", row.get("source"));
}
private Appointment appointment(Long id, Long customerUserId, String status, LocalDateTime time, String petName) {
Appointment appointment = new Appointment();
appointment.setId(id);
appointment.setCustomerUserId(customerUserId);
appointment.setStatus(status);
appointment.setAppointmentTime(time);
appointment.setPetName(petName);
appointment.setDeleted(false);
return appointment;
}
} }

View File

@ -1,6 +1,7 @@
package com.petstore.service; package com.petstore.service;
import com.petstore.entity.Appointment; import com.petstore.entity.Appointment;
import com.petstore.entity.Store;
import com.petstore.mapper.AppointmentMapper; import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.PetMapper; import com.petstore.mapper.PetMapper;
import com.petstore.mapper.ReportMapper; import com.petstore.mapper.ReportMapper;
@ -12,6 +13,9 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@ -30,6 +34,7 @@ class AppointmentServiceTest {
@Mock private UserMapper userMapper; @Mock private UserMapper userMapper;
@Mock private ReportMapper reportMapper; @Mock private ReportMapper reportMapper;
@Mock private PetMapper petMapper; @Mock private PetMapper petMapper;
@Mock private StoreCustomerService storeCustomerService;
@InjectMocks private AppointmentService appointmentService; @InjectMocks private AppointmentService appointmentService;
@ -108,6 +113,30 @@ class AppointmentServiceTest {
assertEquals("NOT_FOUND", result.get("bizCode")); assertEquals("NOT_FOUND", result.get("bizCode"));
} }
@Test
void createBookingTouchesStoreCustomerMaster() {
LocalDateTime appointmentTime = LocalDate.now().plusDays(1).atTime(10, 0);
Appointment appointment = new Appointment();
appointment.setStoreId(10L);
appointment.setCustomerUserId(88L);
appointment.setCreatedByUserId(7L);
appointment.setAppointmentTime(appointmentTime);
appointment.setPetName("小白");
Store store = new Store();
store.setId(10L);
store.setBookingDayStart(LocalTime.of(9, 0));
store.setBookingLastSlotStart(LocalTime.of(21, 30));
when(storeService.findById(10L)).thenReturn(store);
when(appointmentMapper.save(any(Appointment.class))).thenAnswer(inv -> inv.getArgument(0));
var result = appointmentService.createBooking(appointment);
assertEquals(200, result.get("code"));
verify(storeCustomerService).touchBooking(eq(10L), eq(88L), eq(7L), any(LocalDateTime.class));
verify(appointmentMapper).save(appointment);
}
private Appointment newAppointment(Long id, String status) { private Appointment newAppointment(Long id, String status) {
Appointment a = new Appointment(); Appointment a = new Appointment();
a.setId(id); a.setId(id);

View File

@ -0,0 +1,62 @@
package com.petstore.service;
import com.petstore.entity.Report;
import com.petstore.entity.ReportLead;
import com.petstore.mapper.ReportLeadMapper;
import com.petstore.mapper.ReportMapper;
import com.petstore.mapper.ServiceIntervalMapper;
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.LocalDateTime;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertFalse;
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 ReportLeadServiceStoreCustomerTest {
@Mock private ReportLeadMapper reportLeadMapper;
@Mock private ReportMapper reportMapper;
@Mock private ServiceIntervalMapper serviceIntervalMapper;
@Mock private UserMapper userMapper;
@Mock private UserService userService;
@Mock private WechatMiniProgramService wechatMiniProgramService;
@Mock private StoreCustomerService storeCustomerService;
@InjectMocks private ReportLeadService reportLeadService;
@Test
void submittingLeadTouchesStoreCustomerMaster() {
Report report = new Report();
report.setId(9L);
report.setStoreId(10L);
report.setServiceType("洗澡");
report.setPetName("小白");
report.setAppointmentTime(LocalDateTime.of(2026, 8, 1, 10, 0));
when(reportMapper.findFirstByReportTokenAndDeletedFalse("token")).thenReturn(Optional.of(report));
when(serviceIntervalMapper.findFirstByStoreIdAndServiceName(10L, "洗澡")).thenReturn(Optional.empty());
when(serviceIntervalMapper.findFirstByStoreIdIsNullAndServiceName("洗澡")).thenReturn(Optional.empty());
when(reportLeadMapper.findFirstByReportIdAndPhone(9L, "13800138001")).thenReturn(Optional.empty());
when(reportLeadMapper.save(any(ReportLead.class))).thenAnswer(inv -> {
ReportLead lead = inv.getArgument(0);
lead.setId(100L);
return lead;
});
when(userMapper.findByPhoneAndDeletedFalse("13800138001")).thenReturn(null);
ReportLeadService.LeadSubmitOutcome outcome = reportLeadService.submit(
"token", "13800138001", "127.0.0.1", ""
);
assertFalse(outcome.repeatSubmit());
verify(storeCustomerService).touchLead(eq(10L), eq("13800138001"), eq(null), any(LocalDateTime.class));
}
}

View File

@ -0,0 +1,143 @@
package com.petstore.service;
import com.petstore.entity.StoreCustomer;
import com.petstore.entity.User;
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.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
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.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.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class StoreCustomerServiceTest {
@Mock private StoreCustomerMapper storeCustomerMapper;
@Mock private UserMapper userMapper;
@InjectMocks private StoreCustomerService service;
@Test
void assistedBookingCreatesStableStoreCustomer() {
User customer = customer(88L, "13800138001", "小白妈妈");
when(userMapper.findByIdAndDeletedFalse(88L)).thenReturn(Optional.of(customer));
when(storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(10L, 88L)).thenReturn(Optional.empty());
when(storeCustomerMapper.findFirstByStoreIdAndPhone(10L, "13800138001")).thenReturn(Optional.empty());
when(storeCustomerMapper.save(any(StoreCustomer.class))).thenAnswer(inv -> inv.getArgument(0));
LocalDateTime at = LocalDateTime.of(2026, 8, 2, 9, 0);
StoreCustomer saved = service.touchBooking(10L, 88L, 7L, at);
assertEquals(10L, saved.getStoreId());
assertEquals(88L, saved.getCustomerUserId());
assertEquals("13800138001", saved.getPhone());
assertEquals("小白妈妈", saved.getDisplayName());
assertEquals(StoreCustomerService.SOURCE_ASSISTED_BOOKING, saved.getSource());
assertEquals(at, saved.getFirstContactAt());
assertEquals(at, saved.getLastContactAt());
}
@Test
void leadPhoneMasterBindsToCustomerAccountLater() {
User customer = customer(88L, "13800138001", "张三");
StoreCustomer phoneOnly = new StoreCustomer();
phoneOnly.setId(5L);
phoneOnly.setStoreId(10L);
phoneOnly.setPhone("13800138001");
phoneOnly.setDisplayName("留资客户");
phoneOnly.setSource(StoreCustomerService.SOURCE_REPORT_LEAD);
phoneOnly.setDeleted(false);
when(userMapper.findByPhoneAndDeletedFalse("13800138001")).thenReturn(customer);
when(storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(10L, 88L)).thenReturn(Optional.empty());
when(storeCustomerMapper.findFirstByStoreIdAndPhone(10L, "13800138001")).thenReturn(Optional.of(phoneOnly));
when(storeCustomerMapper.save(any(StoreCustomer.class))).thenAnswer(inv -> inv.getArgument(0));
StoreCustomer saved = service.touchLead(10L, "13800138001", null, LocalDateTime.now());
assertEquals(5L, saved.getId());
assertEquals(88L, saved.getCustomerUserId());
assertEquals("张三", saved.getDisplayName());
assertEquals(StoreCustomerService.SOURCE_REPORT_LEAD, saved.getSource(), "首次来源不应被后续触达覆盖");
}
@Test
void conflictingPhoneOwnershipIsRejected() {
User customer = customer(88L, "13800138001", "张三");
StoreCustomer other = new StoreCustomer();
other.setId(9L);
other.setStoreId(10L);
other.setPhone("13800138001");
other.setCustomerUserId(99L);
when(userMapper.findByPhoneAndDeletedFalse("13800138001")).thenReturn(customer);
when(storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(10L, 88L)).thenReturn(Optional.empty());
when(storeCustomerMapper.findFirstByStoreIdAndPhone(10L, "13800138001")).thenReturn(Optional.of(other));
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
() -> service.touchLead(10L, "13800138001", null, LocalDateTime.now())
);
assertTrue(error.getMessage().contains("其他宠主"));
}
@Test
void accountMasterAbsorbsUnboundPhoneProjection() {
User customer = customer(88L, "13800138001", "张三");
StoreCustomer accountMaster = new StoreCustomer();
accountMaster.setId(1L);
accountMaster.setStoreId(10L);
accountMaster.setCustomerUserId(88L);
accountMaster.setSource(StoreCustomerService.SOURCE_CUSTOMER_BOOKING);
accountMaster.setFirstContactAt(LocalDateTime.of(2026, 8, 2, 10, 0));
accountMaster.setDeleted(false);
StoreCustomer phoneProjection = new StoreCustomer();
phoneProjection.setId(2L);
phoneProjection.setStoreId(10L);
phoneProjection.setPhone("13800138001");
phoneProjection.setSource(StoreCustomerService.SOURCE_REPORT_LEAD);
phoneProjection.setFirstContactAt(LocalDateTime.of(2026, 8, 1, 10, 0));
phoneProjection.setDeleted(false);
when(userMapper.findByIdAndDeletedFalse(88L)).thenReturn(Optional.of(customer));
when(storeCustomerMapper.findFirstByStoreIdAndCustomerUserId(10L, 88L)).thenReturn(Optional.of(accountMaster));
when(storeCustomerMapper.findFirstByStoreIdAndPhone(10L, "13800138001")).thenReturn(Optional.of(phoneProjection));
when(storeCustomerMapper.save(any(StoreCustomer.class))).thenAnswer(inv -> inv.getArgument(0));
StoreCustomer saved = service.touchBooking(10L, 88L, 88L, LocalDateTime.of(2026, 8, 3, 10, 0));
assertEquals(1L, saved.getId());
assertEquals(LocalDateTime.of(2026, 8, 1, 10, 0), saved.getFirstContactAt());
assertEquals("13800138001", saved.getPhone());
assertTrue(phoneProjection.getDeleted());
assertNull(phoneProjection.getPhone());
verify(storeCustomerMapper).saveAndFlush(phoneProjection);
ArgumentCaptor<StoreCustomer> captor = ArgumentCaptor.forClass(StoreCustomer.class);
verify(storeCustomerMapper).save(captor.capture());
assertFalse(captor.getValue().getDeleted());
}
private User customer(Long id, String phone, String name) {
User user = new User();
user.setId(id);
user.setPhone(phone);
user.setName(name);
user.setRole("customer");
user.setDeleted(false);
return user;
}
}