feat: split booking and report identities

This commit is contained in:
malei 2026-08-01 20:31:53 +08:00
parent e74f376f52
commit 3f1bfffbed
23 changed files with 791 additions and 57 deletions

View File

@ -76,6 +76,12 @@ curl http://localhost:8080/api/store/list
3. **`t_report.appointment_id` 列**P0 口径要求报告必须绑定预约,但历史 DDL 曾将该列改为 `NULL`。新环境由 JPA 实体 `@UniqueConstraint` 创建约束;老环境若该列允许 NULL 且无重复,可直接加唯一约束。
4. **预约/报告身份字段拆分**:升级到 2026-08-01 之后的版本前,生产库必须先执行:
```bash
mysql -h <host> -u <user> -p petstore < db/migrations/20260801_split_service_identity.sql
```
脚本新增 `customer_user_id`、`created_by_user_id`、`author_staff_id` 并回填可确定的历史数据。末尾三个验证查询必须留档;`unresolved_assisted_appointments` 非 0 时需人工确认,禁止把旧员工 `user_id` 冒认成客户。
### production profile
```bash
@ -134,6 +140,7 @@ npm --prefix frontend run build:mp-weixin
- [ ] 启动使用 `--spring.profiles.active=production`
- [ ] `uk_report_appointment` 唯一约束上线前已清理重复数据
- [ ] 历史图片 URL 已执行一次性修复 SQL
- [ ] 已执行 `20260801_split_service_identity.sql`,并留存未解析预约/报告验证结果
- [ ] 已暴露凭据DB 密码、微信 AppSecret已轮换
## 安全说明

View File

@ -0,0 +1,59 @@
-- Petstore Phase 0: split customer / creator / service staff / report author identity.
-- Target: MySQL. One-time migration; back up the database before execution.
-- Existing legacy columns remain for compatibility and are not dropped here.
ALTER TABLE t_appointment
ADD COLUMN customer_user_id BIGINT NULL COMMENT '被服务客户账号',
ADD COLUMN created_by_user_id BIGINT NULL COMMENT '预约创建账号';
CREATE INDEX idx_appt_customer_status_time
ON t_appointment (customer_user_id, status, appointment_time);
ALTER TABLE t_report
ADD COLUMN customer_user_id BIGINT NULL COMMENT '报告归属客户账号',
ADD COLUMN author_staff_id BIGINT NULL COMMENT '报告提交门店账号';
CREATE INDEX idx_report_customer_time
ON t_report (customer_user_id, create_time);
CREATE INDEX idx_report_author_time
ON t_report (author_staff_id, create_time);
-- Appointment backfill priority:
-- 1. pet.owner_user_id (strongest customer ownership evidence)
-- 2. legacy user_id only when that account is a customer
-- Legacy staff-created rows without pet ownership remain NULL for manual correction.
UPDATE t_appointment a
LEFT JOIN t_pet p
ON p.id = a.pet_id AND p.deleted = 0
LEFT JOIN t_user legacy_user
ON legacy_user.id = a.user_id AND legacy_user.deleted = 0
SET a.customer_user_id = COALESCE(
a.customer_user_id,
p.owner_user_id,
CASE WHEN legacy_user.role = 'customer' THEN a.user_id ELSE NULL END
),
a.created_by_user_id = COALESCE(a.created_by_user_id, a.user_id)
WHERE a.customer_user_id IS NULL OR a.created_by_user_id IS NULL;
-- Report user_id historically means report author/service staff.
-- Customer ownership must come from the linked appointment, never from report.user_id.
UPDATE t_report r
LEFT JOIN t_appointment a
ON a.id = r.appointment_id AND a.deleted = 0
SET r.customer_user_id = COALESCE(r.customer_user_id, a.customer_user_id),
r.author_staff_id = COALESCE(r.author_staff_id, r.user_id)
WHERE r.customer_user_id IS NULL OR r.author_staff_id IS NULL;
-- Verification queries. unresolved_assisted_appointments must be reviewed manually.
SELECT COUNT(*) AS unresolved_assisted_appointments
FROM t_appointment
WHERE deleted = 0 AND customer_user_id IS NULL;
SELECT COUNT(*) AS reports_without_customer
FROM t_report
WHERE deleted = 0 AND customer_user_id IS NULL;
SELECT COUNT(*) AS reports_without_author
FROM t_report
WHERE deleted = 0 AND author_staff_id IS NULL;

13
db/migrations/README.md Normal file
View File

@ -0,0 +1,13 @@
# 数据库迁移
当前项目尚未接入 Flyway/Liquibase。本目录存放经过评审的一次性 MySQL 迁移脚本;生产执行规则:
1. 先完成数据库备份并记录备份位置。
2. 在测试库执行脚本,核对文件末尾的验证查询。
3. 由发布负责人在生产低峰期执行,同一个脚本不得重复执行。
4. 验证通过后再启动使用新字段的 backend 版本。
5. 迁移脚本只允许向前兼容;本阶段不得删除 legacy 字段。
`20260801_split_service_identity.sql` 为身份语义拆分迁移:新增客户、创建人和报告作者字段,保留 `user_id` / `assigned_user_id` 兼容旧版本。
正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。

View File

@ -51,6 +51,11 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@ -3,7 +3,9 @@ package com.petstore.controller;
import com.petstore.auth.CurrentUser;
import com.petstore.auth.CurrentUserContext;
import com.petstore.entity.Appointment;
import com.petstore.entity.User;
import com.petstore.service.AppointmentService;
import com.petstore.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*;
@ -18,6 +20,7 @@ import java.util.Map;
@CrossOrigin
public class AppointmentController {
private final AppointmentService appointmentService;
private final UserService userService;
/**
* 门店某日可预约时段半小时一档每档最多一单
@ -95,7 +98,7 @@ public class AppointmentController {
return Map.of("code", 404, "message", "预约不存在", "bizCode", "NOT_FOUND");
}
if (u.isCustomer()) {
if (!u.userId().equals(appointment.getUserId())) {
if (!u.userId().equals(appointment.resolvedCustomerUserId())) {
return Map.of("code", 403, "message", "无权查看他人预约", "bizCode", "FORBIDDEN");
}
} else if (u.isStoreUser()) {
@ -112,9 +115,10 @@ public class AppointmentController {
}
/**
* 创建预约userId 从当前登录用户派生storeId role 派生
* - customerstoreId body.storeId宠主选店userId = current.userId
* - boss/staff代客预约storeId = current.storeId必须本店userId = current.userId
* 创建预约客户创建人分别记录不再把代客操作员工写成客户
* - customercustomerUserId = createdByUserId = current.userId
* - boss/staffcreatedByUserId = current.userIdcustomerUserId body.customerUserId/customerPhone 解析
* - legacy userId 仅镜像 customerUserId兼容旧客户端与历史查询
*/
@PostMapping("/create")
public Map<String, Object> create(@RequestBody Map<String, Object> params) {
@ -128,13 +132,38 @@ public class AppointmentController {
appointment.setAppointmentTime(java.time.LocalDateTime.parse(timeStr));
Long storeId;
Long customerUserId;
if (u.isStoreUser()) {
storeId = u.storeId();
} else {
try {
Long requestedCustomerId = optionalLong(params.get("customerUserId"));
String customerPhone = optionalText(params.get("customerPhone"));
String customerName = optionalText(params.get("customerName"));
if (requestedCustomerId == null && customerPhone.isBlank()) {
return Map.of(
"code", 400,
"message", "代客预约必须填写宠主手机号",
"bizCode", "CUSTOMER_REQUIRED"
);
}
User customer = userService.resolveBookingCustomer(requestedCustomerId, customerPhone, customerName);
customerUserId = customer.getId();
} catch (IllegalArgumentException e) {
return Map.of("code", 400, "message", e.getMessage(), "bizCode", "CUSTOMER_INVALID");
}
} else if (u.isCustomer()) {
if (params.get("storeId") == null) {
return Map.of("code", 400, "message", "请选择预约门店", "bizCode", "STORE_REQUIRED");
}
storeId = Long.valueOf(params.get("storeId").toString());
customerUserId = u.userId();
} else {
return Map.of("code", 403, "message", "当前账号不可创建预约", "bizCode", "FORBIDDEN");
}
appointment.setStoreId(storeId);
appointment.setUserId(u.userId());
appointment.setCustomerUserId(customerUserId);
appointment.setCreatedByUserId(u.userId());
appointment.setUserId(customerUserId); // legacy compatibility
appointment.setStatus("new");
if (params.containsKey("remark") && params.get("remark") != null) {
@ -147,6 +176,21 @@ public class AppointmentController {
return appointmentService.createBooking(appointment);
}
private static Long optionalLong(Object value) {
if (value == null || value.toString().isBlank()) {
return null;
}
try {
return Long.valueOf(value.toString());
} catch (NumberFormatException e) {
throw new IllegalArgumentException("宠主 ID 格式不正确");
}
}
private static String optionalText(Object value) {
return value == null ? "" : value.toString().trim();
}
/**
* 开始服务 boss/staffstaffUserId = 当前用户谁点谁服务预约必须属于本店
*/
@ -185,7 +229,7 @@ public class AppointmentController {
if (!"cancel".equalsIgnoreCase(status)) {
return Map.of("code", 403, "message", "宠主仅可取消自己的预约", "bizCode", "FORBIDDEN");
}
if (!u.userId().equals(appt.getUserId())) {
if (!u.userId().equals(appt.resolvedCustomerUserId())) {
return Map.of("code", 403, "message", "无权操作他人预约", "bizCode", "FORBIDDEN");
}
} else if (u.isStoreUser()) {

View File

@ -176,7 +176,8 @@ public class ReportController {
"bizCode", "INVALID_STATUS"
);
}
// userId 从上下文派生覆盖请求体
// 报告作者从上下文派生legacy userId 仅镜像作者客户归属由预约派生
report.setAuthorStaffId(u.userId());
report.setUserId(u.userId());
// 照片必填兜底服务前/服务后至少各 1 video
if (!hasPhotoType(report, "before")) {
@ -252,6 +253,8 @@ public class ReportController {
item.put("updateTime", r.getUpdateTime());
item.put("storeId", r.getStoreId());
item.put("userId", r.getUserId());
item.put("customerUserId", r.getCustomerUserId());
item.put("authorStaffId", r.resolvedAuthorStaffId());
// 图片列表
List<ReportImage> imgs = r.getImages();
List<String> beforePhotos = new ArrayList<>();
@ -339,8 +342,15 @@ public class ReportController {
data.put("appointmentTime", report.getAppointmentTime());
data.put("staffName", report.getStaffName());
data.put("createTime", report.getCreateTime());
if (report.getUserId() != null) {
User staff = userService.findById(report.getUserId());
Long serviceStaffId = report.resolvedAuthorStaffId();
if (report.getAppointmentId() != null) {
Appointment appointment = appointmentService.getById(report.getAppointmentId());
if (appointment != null && appointment.getAssignedUserId() != null) {
serviceStaffId = appointment.getAssignedUserId();
}
}
if (serviceStaffId != null) {
User staff = userService.findById(serviceStaffId);
if (staff != null && staff.getAvatar() != null && !staff.getAvatar().isBlank()) {
data.put("staffAvatar", fullUrl(staff.getAvatar()));
} else {
@ -368,6 +378,8 @@ public class ReportController {
if (!publicTokenRequest) {
// 门店端 appointmentId 查询保留内部字段供门店端使用
data.put("userId", report.getUserId());
data.put("customerUserId", report.getCustomerUserId());
data.put("authorStaffId", report.resolvedAuthorStaffId());
data.put("storeId", report.getStoreId());
data.put("reportToken", report.getReportToken());
}

View File

@ -1,5 +1,7 @@
package com.petstore.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
@ -10,7 +12,8 @@ import java.time.LocalDateTime;
name = "t_appointment",
indexes = {
@Index(name = "idx_appt_store_status_time", columnList = "store_id,status,appointment_time"),
@Index(name = "idx_appt_user_status_time", columnList = "user_id,status,appointment_time")
@Index(name = "idx_appt_user_status_time", columnList = "user_id,status,appointment_time"),
@Index(name = "idx_appt_customer_status_time", columnList = "customer_user_id,status,appointment_time")
}
)
public class Appointment {
@ -29,17 +32,43 @@ public class Appointment {
@Column(name = "store_id")
private Long storeId;
/**
* 兼容字段新数据镜像 customerUserId历史数据曾混用客户/创建人
* 新业务逻辑不得再用它判断创建人
*/
@Deprecated
@Column(name = "user_id")
private Long userId;
/** 被服务的客户账号;门店代客预约时不得写入操作员工账号。 */
@Column(name = "customer_user_id")
private Long customerUserId;
/** 实际创建预约的登录账号customer / boss / staff。 */
@Column(name = "created_by_user_id")
private Long createdByUserId;
/** 关联宠物档案(可选,用于本店「服务过的宠物」统计) */
@Column(name = "pet_id")
private Long petId;
/** 技师ID开始服务时赋值 */
/** 服务技师 ID开始服务时赋值。数据库列名暂保留 assigned_user_id。 */
@Column(name = "assigned_user_id")
private Long assignedUserId;
/** 新 API 语义别名;兼容期仍同时序列化 assignedUserId。 */
@JsonProperty("assignedStaffId")
public Long getAssignedStaffId() {
return assignedUserId;
}
/** 读取兼容:新字段优先,旧数据回退到 legacy user_id。 */
@Transient
@JsonIgnore
public Long resolvedCustomerUserId() {
return customerUserId != null ? customerUserId : userId;
}
private String remark;
@Column(name = "create_time")

View File

@ -1,7 +1,8 @@
package com.petstore.entity;
import jakarta.persistence.*;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
@ -14,7 +15,9 @@ import java.util.List;
indexes = {
@Index(name = "idx_report_appointment_id", columnList = "appointment_id"),
@Index(name = "idx_report_token", columnList = "report_token"),
@Index(name = "idx_report_store_user_time", columnList = "store_id,user_id,create_time")
@Index(name = "idx_report_store_user_time", columnList = "store_id,user_id,create_time"),
@Index(name = "idx_report_customer_time", columnList = "customer_user_id,create_time"),
@Index(name = "idx_report_author_time", columnList = "author_staff_id,create_time")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_report_appointment", columnNames = "appointment_id")
@ -34,9 +37,19 @@ public class Report {
@Transient
private List<ReportImage> images = new ArrayList<>();
/** 兼容字段:新数据镜像 authorStaffId。 */
@Deprecated
@Column(name = "user_id")
private Long userId;
/** 报告归属的客户账号,从关联预约 customer_user_id 派生。 */
@Column(name = "customer_user_id")
private Long customerUserId;
/** 实际提交报告的门店账号,可与服务技师不同。 */
@Column(name = "author_staff_id")
private Long authorStaffId;
@Column(name = "store_id")
private Long storeId;
@ -48,6 +61,13 @@ public class Report {
private LocalDateTime appointmentTime;
private String staffName;
/** 读取兼容:新字段优先,旧报告回退到 legacy user_id。 */
@Transient
@JsonIgnore
public Long resolvedAuthorStaffId() {
return authorStaffId != null ? authorStaffId : userId;
}
@Column(name = "create_time")
private LocalDateTime createTime;

View File

@ -12,17 +12,48 @@ import java.util.List;
import java.util.Optional;
public interface AppointmentMapper extends JpaRepository<Appointment, Long> {
/** @deprecated 兼容旧调用;新代码使用 customer_user_id 语义查询。 */
@Deprecated
List<Appointment> findByUserIdAndDeletedFalse(Long userId);
/** @deprecated 兼容旧调用;新代码使用 customer_user_id 语义查询。 */
@Deprecated
List<Appointment> findByUserIdAndStatusAndDeletedFalse(Long userId, String status);
List<Appointment> findByStoreIdAndDeletedFalse(Long storeId);
List<Appointment> findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status);
/** @deprecated 兼容旧调用;新代码使用 customer_user_id 语义查询。 */
@Deprecated
Page<Appointment> findByUserIdAndDeletedFalse(Long userId, Pageable pageable);
/** @deprecated 兼容旧调用;新代码使用 customer_user_id 语义查询。 */
@Deprecated
Page<Appointment> findByUserIdAndStatusAndDeletedFalse(Long userId, String status, Pageable pageable);
Page<Appointment> findByStoreIdAndDeletedFalse(Long storeId, Pageable pageable);
Page<Appointment> findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status, Pageable pageable);
Optional<Appointment> findByIdAndDeletedFalse(Long id);
@Query("SELECT a FROM Appointment a WHERE a.deleted = false AND "
+ "(a.customerUserId = :customerUserId OR (a.customerUserId IS NULL AND a.userId = :customerUserId)) "
+ "ORDER BY a.appointmentTime DESC")
List<Appointment> findForCustomer(@Param("customerUserId") Long customerUserId);
@Query("SELECT a FROM Appointment a WHERE a.deleted = false AND a.status = :status AND "
+ "(a.customerUserId = :customerUserId OR (a.customerUserId IS NULL AND a.userId = :customerUserId)) "
+ "ORDER BY a.appointmentTime DESC")
List<Appointment> findForCustomerByStatus(
@Param("customerUserId") Long customerUserId,
@Param("status") String status);
@Query("SELECT a FROM Appointment a WHERE a.deleted = false AND "
+ "(a.customerUserId = :customerUserId OR (a.customerUserId IS NULL AND a.userId = :customerUserId))")
Page<Appointment> pageForCustomer(@Param("customerUserId") Long customerUserId, Pageable pageable);
@Query("SELECT a FROM Appointment a WHERE a.deleted = false AND a.status = :status AND "
+ "(a.customerUserId = :customerUserId OR (a.customerUserId IS NULL AND a.userId = :customerUserId))")
Page<Appointment> pageForCustomerByStatus(
@Param("customerUserId") Long customerUserId,
@Param("status") String status,
Pageable pageable);
/** 某宠物档案关联的全部预约(按预约时间倒序) */
List<Appointment> findByPetIdAndDeletedFalseOrderByAppointmentTimeDesc(Long petId);

View File

@ -4,6 +4,8 @@ import com.petstore.entity.Report;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
@ -18,17 +20,42 @@ public interface ReportMapper extends JpaRepository<Report, Long> {
List<Report> findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(Long storeId);
/** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */
@Deprecated
List<Report> findByUserIdAndDeletedFalseOrderByCreateTimeDesc(Long userId);
/** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */
@Deprecated
List<Report> findByStoreIdAndUserIdAndDeletedFalseOrderByCreateTimeDesc(Long storeId, Long userId);
List<Report> findAllByDeletedFalseOrderByCreateTimeDesc();
Page<Report> findByStoreIdAndDeletedFalse(Long storeId, Pageable pageable);
/** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */
@Deprecated
Page<Report> findByUserIdAndDeletedFalse(Long userId, Pageable pageable);
/** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */
@Deprecated
Page<Report> findByStoreIdAndUserIdAndDeletedFalse(Long storeId, Long userId, Pageable pageable);
@Query("SELECT r FROM Report r WHERE r.deleted = false AND "
+ "(r.customerUserId = :customerUserId OR (r.customerUserId IS NULL AND r.appointmentId IN "
+ "(SELECT a.id FROM Appointment a WHERE a.deleted = false AND "
+ "(a.customerUserId = :customerUserId OR (a.customerUserId IS NULL AND a.userId = :customerUserId))))) "
+ "ORDER BY r.createTime DESC")
List<Report> findForCustomer(@Param("customerUserId") Long customerUserId);
@Query(value = "SELECT r FROM Report r WHERE r.deleted = false AND "
+ "(r.customerUserId = :customerUserId OR (r.customerUserId IS NULL AND r.appointmentId IN "
+ "(SELECT a.id FROM Appointment a WHERE a.deleted = false AND "
+ "(a.customerUserId = :customerUserId OR (a.customerUserId IS NULL AND a.userId = :customerUserId)))))",
countQuery = "SELECT COUNT(r) FROM Report r WHERE r.deleted = false AND "
+ "(r.customerUserId = :customerUserId OR (r.customerUserId IS NULL AND r.appointmentId IN "
+ "(SELECT a.id FROM Appointment a WHERE a.deleted = false AND "
+ "(a.customerUserId = :customerUserId OR (a.customerUserId IS NULL AND a.userId = :customerUserId)))))")
Page<Report> pageForCustomer(@Param("customerUserId") Long customerUserId, Pageable pageable);
Page<Report> findByDeletedFalse(Pageable pageable);
Optional<Report> findByIdAndDeletedFalse(Long id);

View File

@ -18,7 +18,6 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@ -61,15 +60,17 @@ public class AdminServiceCustomerService {
List<ReportLead> leads = reportLeadMapper.findByStoreIdOrderByCreateTimeDesc(storeId);
List<Report> reports = reportMapper.findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(storeId);
Map<Long, List<Appointment>> apptsByUser = appointments.stream()
.filter(a -> a.getUserId() != null)
.collect(Collectors.groupingBy(Appointment::getUserId));
Set<Long> userIds = new HashSet<>(apptsByUser.keySet());
Map<Long, Appointment> appointmentsById = appointments.stream()
.filter(a -> a.getId() != null)
.collect(Collectors.toMap(Appointment::getId, a -> a, (a, b) -> a));
Set<Long> userIds = appointments.stream()
.map(Appointment::resolvedCustomerUserId)
.filter(java.util.Objects::nonNull)
.collect(Collectors.toSet());
Map<String, User> usersByPhone = new HashMap<>();
if (!userIds.isEmpty()) {
for (User u : userMapper.findAllById(userIds)) {
if (Boolean.TRUE.equals(u.getDeleted())) {
if (Boolean.TRUE.equals(u.getDeleted()) || !"customer".equals(u.getRole())) {
continue;
}
if (u.getPhone() != null && !u.getPhone().isBlank()) {
@ -87,7 +88,7 @@ public class AdminServiceCustomerService {
continue;
}
User byPhone = userMapper.findByPhoneAndDeletedFalse(lead.getPhone());
if (byPhone != null) {
if (byPhone != null && "customer".equals(byPhone.getRole())) {
usersByPhone.put(lead.getPhone(), byPhone);
userIds.add(byPhone.getId());
}
@ -96,12 +97,22 @@ public class AdminServiceCustomerService {
Map<Long, User> users = new HashMap<>();
if (!userIds.isEmpty()) {
for (User u : userMapper.findAllById(userIds)) {
if (!Boolean.TRUE.equals(u.getDeleted())) {
if (!Boolean.TRUE.equals(u.getDeleted()) && "customer".equals(u.getRole())) {
users.put(u.getId(), u);
}
}
}
// 新字段优先legacy user_id 只有确认为 customer 角色时才纳入避免把代客员工当客户
Map<Long, List<Appointment>> apptsByUser = new LinkedHashMap<>();
for (Appointment appointment : appointments) {
Long customerUserId = appointment.resolvedCustomerUserId();
if (customerUserId == null || !users.containsKey(customerUserId)) {
continue;
}
apptsByUser.computeIfAbsent(customerUserId, ignored -> new ArrayList<>()).add(appointment);
}
Map<Long, List<Pet>> petsByOwner = new HashMap<>();
for (Long uid : users.keySet()) {
petsByOwner.put(uid, petMapper.findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(uid));
@ -109,10 +120,17 @@ public class AdminServiceCustomerService {
Map<Long, Report> latestReportByUser = new HashMap<>();
for (Report r : reports) {
if (r.getUserId() == null) {
Long customerUserId = r.getCustomerUserId();
if (customerUserId == null && r.getAppointmentId() != null) {
Appointment appointment = appointmentsById.get(r.getAppointmentId());
if (appointment != null) {
customerUserId = appointment.resolvedCustomerUserId();
}
}
if (customerUserId == null || !users.containsKey(customerUserId)) {
continue;
}
latestReportByUser.putIfAbsent(r.getUserId(), r);
latestReportByUser.putIfAbsent(customerUserId, r);
}
Map<String, List<ReportLead>> leadsByPhone = leads.stream()

View File

@ -1,9 +1,11 @@
package com.petstore.service;
import com.petstore.entity.Appointment;
import com.petstore.entity.Pet;
import com.petstore.entity.Report;
import com.petstore.entity.User;
import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.PetMapper;
import com.petstore.mapper.ReportMapper;
import com.petstore.mapper.ScheduleBlockMapper;
import com.petstore.mapper.UserMapper;
@ -35,10 +37,11 @@ public class AppointmentService {
private final StoreService storeService;
private final UserMapper userMapper;
private final ReportMapper reportMapper;
private final PetMapper petMapper;
// 员工查看自己的预约
public List<Appointment> getByUserId(Long userId) {
return appointmentMapper.findByUserIdAndDeletedFalse(userId);
// 宠主查看归属于自己的预约兼容 customer_user_id 尚未回填的旧数据
public List<Appointment> getByUserId(Long customerUserId) {
return appointmentMapper.findForCustomer(customerUserId);
}
// 老板查看本店所有预约
@ -46,9 +49,9 @@ public class AppointmentService {
return appointmentMapper.findByStoreIdAndDeletedFalse(storeId);
}
// 员工按状态查
public List<Appointment> getByUserIdAndStatus(Long userId, String status) {
return appointmentMapper.findByUserIdAndStatusAndDeletedFalse(userId, status);
// 宠主按状态查归属于自己的预约
public List<Appointment> getByUserIdAndStatus(Long customerUserId, String status) {
return appointmentMapper.findForCustomerByStatus(customerUserId, status);
}
// 老板按状态查
@ -70,8 +73,8 @@ public class AppointmentService {
}
if (userId != null) {
return hasStatus
? appointmentMapper.findByUserIdAndStatusAndDeletedFalse(userId, status, pageable)
: appointmentMapper.findByUserIdAndDeletedFalse(userId, pageable);
? appointmentMapper.pageForCustomerByStatus(userId, status, pageable)
: appointmentMapper.pageForCustomer(userId, pageable);
}
return Page.empty(pageable);
}
@ -149,6 +152,26 @@ public class AppointmentService {
if (appointment.getStoreId() == null) {
return Map.of("code", 400, "message", "缺少门店");
}
if (appointment.getCustomerUserId() == null) {
appointment.setCustomerUserId(appointment.getUserId());
}
if (appointment.getCustomerUserId() == null) {
return Map.of("code", 400, "message", "缺少宠主", "bizCode", "CUSTOMER_REQUIRED");
}
if (appointment.getCreatedByUserId() == null) {
appointment.setCreatedByUserId(appointment.getCustomerUserId());
}
appointment.setUserId(appointment.getCustomerUserId()); // legacy compatibility
if (appointment.getPetId() != null) {
Pet pet = petMapper.findByIdAndDeletedFalse(appointment.getPetId()).orElse(null);
if (pet == null) {
return Map.of("code", 404, "message", "宠物档案不存在", "bizCode", "PET_NOT_FOUND");
}
if (pet.getOwnerUserId() == null || !pet.getOwnerUserId().equals(appointment.getCustomerUserId())) {
return Map.of("code", 403, "message", "宠物档案不属于该宠主", "bizCode", "PET_CUSTOMER_MISMATCH");
}
}
com.petstore.entity.Store store = storeService.findById(appointment.getStoreId());
if (store == null) {
return Map.of("code", 404, "message", "门店不存在");
@ -273,8 +296,8 @@ public class AppointmentService {
}
Set<Long> userIds = new HashSet<>();
for (Appointment a : appointments) {
if (a.getUserId() != null) {
userIds.add(a.getUserId());
if (a.resolvedCustomerUserId() != null) {
userIds.add(a.resolvedCustomerUserId());
}
if (a.getAssignedUserId() != null) {
userIds.add(a.getAssignedUserId());
@ -295,8 +318,8 @@ public class AppointmentService {
public Map<String, Object> toAdminView(Appointment a) {
Set<Long> userIds = new HashSet<>();
if (a.getUserId() != null) {
userIds.add(a.getUserId());
if (a.resolvedCustomerUserId() != null) {
userIds.add(a.resolvedCustomerUserId());
}
if (a.getAssignedUserId() != null) {
userIds.add(a.getAssignedUserId());
@ -319,13 +342,20 @@ public class AppointmentService {
row.put("status", a.getStatus());
row.put("storeId", a.getStoreId());
row.put("userId", a.getUserId());
row.put("customerUserId", a.resolvedCustomerUserId());
row.put("createdByUserId", a.getCreatedByUserId());
row.put("petId", a.getPetId());
row.put("assignedUserId", a.getAssignedUserId());
row.put("assignedStaffId", a.getAssignedUserId());
row.put("remark", a.getRemark());
row.put("createTime", a.getCreateTime());
row.put("updateTime", a.getUpdateTime());
User customer = a.getUserId() == null ? null : users.get(a.getUserId());
Long customerUserId = a.resolvedCustomerUserId();
User customer = customerUserId == null ? null : users.get(customerUserId);
if (customer != null && !"customer".equals(customer.getRole())) {
customer = null;
}
if (customer != null) {
row.put("customerName", customer.getName() != null ? customer.getName() : customer.getUsername());
row.put("customerPhoneMasked", maskPhone(customer.getPhone()));

View File

@ -946,7 +946,7 @@ public class ReportHighlightVideoService {
return false;
}
Optional<Appointment> ap = appointmentMapper.findByIdAndDeletedFalse(apptId);
return ap.isPresent() && operatorUserId.equals(ap.get().getUserId());
return ap.isPresent() && operatorUserId.equals(ap.get().resolvedCustomerUserId());
}
return false;
}

View File

@ -32,7 +32,11 @@ public class ReportService {
// 生成唯一令牌
report.setReportToken(UUID.randomUUID().toString().replace("-", ""));
// 填充冗余字段并自动完成预约
Long requestedAuthorStaffId = report.getAuthorStaffId() != null
? report.getAuthorStaffId()
: report.getUserId();
// 填充客户/技师快照字段并自动完成预约
if (report.getAppointmentId() != null) {
Appointment appt = appointmentMapper.findByIdAndDeletedFalse(report.getAppointmentId()).orElse(null);
if (appt != null) {
@ -40,11 +44,11 @@ public class ReportService {
report.setServiceType(appt.getServiceType());
report.setAppointmentTime(appt.getAppointmentTime());
report.setStoreId(appt.getStoreId());
// 技师取预约分配的技师开始服务时指定的
report.setCustomerUserId(appt.resolvedCustomerUserId());
// 展示技师取预约分配的服务技师可能与报告提交人不同
if (appt.getAssignedUserId() != null) {
User staff = userMapper.findByIdAndDeletedFalse(appt.getAssignedUserId()).orElse(null);
if (staff != null) {
report.setUserId(staff.getId());
report.setStaffName(staff.getName());
}
}
@ -56,9 +60,20 @@ public class ReportService {
}
}
}
// 如果预约没分配技师则用当前操作人
if (report.getUserId() != null && report.getStaffName() == null) {
User staff = userMapper.findByIdAndDeletedFalse(report.getUserId()).orElse(null);
// 兼容旧服务调用未显式传 author 以预约服务技师兜底
if (requestedAuthorStaffId == null && report.getAppointmentId() != null) {
Appointment appt = appointmentMapper.findByIdAndDeletedFalse(report.getAppointmentId()).orElse(null);
if (appt != null) {
requestedAuthorStaffId = appt.getAssignedUserId();
}
}
report.setAuthorStaffId(requestedAuthorStaffId);
report.setUserId(requestedAuthorStaffId); // legacy compatibility
// 预约未分配技师时展示提交报告人
if (requestedAuthorStaffId != null && report.getStaffName() == null) {
User staff = userMapper.findByIdAndDeletedFalse(requestedAuthorStaffId).orElse(null);
if (staff != null) {
report.setStaffName(staff.getName());
if (report.getStoreId() == null) {
@ -120,33 +135,36 @@ public class ReportService {
return enrichImages(reportMapper.findFirstByReportTokenAndDeletedFalse(token).orElse(null));
}
public List<Report> list(Long storeId, Long userId) {
public List<Report> list(Long storeId, Long customerUserId) {
List<Report> reports;
if (storeId != null && userId != null) {
reports = reportMapper.findByStoreIdAndUserIdAndDeletedFalseOrderByCreateTimeDesc(storeId, userId);
if (storeId != null && customerUserId != null) {
reports = reportMapper.findForCustomer(customerUserId).stream()
.filter(r -> storeId.equals(r.getStoreId()))
.toList();
} else if (storeId != null) {
reports = reportMapper.findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(storeId);
} else if (userId != null) {
reports = reportMapper.findByUserIdAndDeletedFalseOrderByCreateTimeDesc(userId);
} else if (customerUserId != null) {
reports = reportMapper.findForCustomer(customerUserId);
} else {
reports = reportMapper.findAllByDeletedFalseOrderByCreateTimeDesc();
}
return enrichImages(reports);
}
public Page<Report> page(Long storeId, Long userId, int pageNo, int pageSize) {
public Page<Report> page(Long storeId, Long customerUserId, int pageNo, int pageSize) {
Pageable pageable = PageRequest.of(
Math.max(pageNo, 0),
Math.max(pageSize, 1),
Sort.by(Sort.Direction.DESC, "createTime")
);
Page<Report> paged;
if (storeId != null && userId != null) {
paged = reportMapper.findByStoreIdAndUserIdAndDeletedFalse(storeId, userId, pageable);
if (storeId != null && customerUserId != null) {
// 当前角色模型不会同时出现 store/customer scope保守按客户归属查询
paged = reportMapper.pageForCustomer(customerUserId, pageable);
} else if (storeId != null) {
paged = reportMapper.findByStoreIdAndDeletedFalse(storeId, pageable);
} else if (userId != null) {
paged = reportMapper.findByUserIdAndDeletedFalse(userId, pageable);
} else if (customerUserId != null) {
paged = reportMapper.pageForCustomer(customerUserId, pageable);
} else {
paged = reportMapper.findByDeletedFalse(pageable);
}

View File

@ -8,6 +8,7 @@ import com.petstore.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
@ -103,6 +104,63 @@ public class UserService {
return user;
}
/**
* 门店代客预约时解析真实客户customerUserId 优先否则按手机号查找或创建 customer
* 已绑定 boss/staff 的手机号不能被静默当成客户避免再次混淆身份
*/
@Transactional
public User resolveBookingCustomer(Long customerUserId, String phone, String customerName) {
String phoneNorm = phone == null ? "" : phone.trim();
String nameNorm = customerName == null ? "" : customerName.trim();
User customer;
if (customerUserId != null) {
customer = userMapper.findByIdAndDeletedFalse(customerUserId).orElse(null);
if (customer == null || !"customer".equals(customer.getRole())) {
throw new IllegalArgumentException("所选账号不是有效宠主");
}
if (!phoneNorm.isEmpty() && customer.getPhone() != null && !phoneNorm.equals(customer.getPhone())) {
throw new IllegalArgumentException("宠主账号与手机号不一致");
}
return customer;
}
if (!phoneNorm.matches("^1[3-9]\\d{9}$")) {
throw new IllegalArgumentException("请填写正确的宠主手机号");
}
customer = userMapper.findByPhoneAndDeletedFalse(phoneNorm);
if (customer != null && !"customer".equals(customer.getRole())) {
throw new IllegalArgumentException("该手机号已绑定门店账号,暂不能作为宠主代约");
}
if (customer == null) {
customer = new User();
customer.setUsername(phoneNorm);
customer.setPhone(phoneNorm);
customer.setName(nameNorm.isEmpty() ? "到店宠主" + phoneNorm.substring(7) : truncate(nameNorm, 64));
customer.setPassword("assisted_booking_no_password");
customer.setRole("customer");
customer.setCreateTime(LocalDateTime.now());
customer.setUpdateTime(LocalDateTime.now());
customer.setDeleted(false);
return userMapper.save(customer);
}
if (!nameNorm.isEmpty()
&& (customer.getName() == null
|| customer.getName().isBlank()
|| customer.getName().startsWith("微信用户")
|| customer.getName().startsWith("到店宠主"))) {
customer.setName(truncate(nameNorm, 64));
customer.setUpdateTime(LocalDateTime.now());
customer = userMapper.save(customer);
}
return customer;
}
private static String truncate(String value, int maxLength) {
return value.length() <= maxLength ? value : value.substring(0, maxLength);
}
/**
* 已通过短信或微信授权校验后的手机号登录与验证码登录成功后的逻辑一致
*/

View File

@ -36,7 +36,8 @@ class AppointmentControllerDetailTest {
CurrentUserContext.set(new CurrentUser(7L, null, "customer"));
Appointment appt = new Appointment();
appt.setId(1L);
appt.setUserId(7L);
appt.setCustomerUserId(7L);
appt.setUserId(99L); // canonical 字段必须优先于 legacy
appt.setStoreId(10L);
when(appointmentService.getById(1L)).thenReturn(appt);
@ -45,6 +46,19 @@ class AppointmentControllerDetailTest {
assertSame(appt, result.get("data"));
}
@Test
void customerCanViewLegacyOwnAppointmentBeforeBackfill() {
CurrentUserContext.set(new CurrentUser(7L, null, "customer"));
Appointment appt = new Appointment();
appt.setId(1L);
appt.setUserId(7L);
appt.setStoreId(10L);
when(appointmentService.getById(1L)).thenReturn(appt);
Map<String, Object> result = controller.detail(1L);
assertEquals(200, result.get("code"));
}
@Test
void customerCannotViewOthersAppointment() {
CurrentUserContext.set(new CurrentUser(7L, null, "customer"));

View File

@ -0,0 +1,99 @@
package com.petstore.controller;
import com.petstore.auth.CurrentUser;
import com.petstore.auth.CurrentUserContext;
import com.petstore.entity.Appointment;
import com.petstore.entity.User;
import com.petstore.service.AppointmentService;
import com.petstore.service.UserService;
import org.junit.jupiter.api.AfterEach;
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.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AppointmentControllerIdentityTest {
@Mock private AppointmentService appointmentService;
@Mock private UserService userService;
@InjectMocks private AppointmentController controller;
@AfterEach
void clearContext() {
CurrentUserContext.clear();
}
@Test
void customerCreateDerivesCustomerAndCreatorFromSession() {
CurrentUserContext.set(new CurrentUser(7L, null, "customer"));
when(appointmentService.createBooking(any())).thenReturn(Map.of("code", 200));
Map<String, Object> body = validBody();
body.put("storeId", 10L);
body.put("customerUserId", 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());
Appointment appointment = captor.getValue();
assertEquals(7L, appointment.getCustomerUserId());
assertEquals(7L, appointment.getCreatedByUserId());
assertEquals(7L, appointment.getUserId());
verifyNoInteractions(userService);
}
@Test
void staffCreateRequiresCustomerIdentity() {
CurrentUserContext.set(new CurrentUser(2L, 10L, "staff"));
Map<String, Object> result = controller.create(validBody());
assertEquals(400, result.get("code"));
assertEquals("CUSTOMER_REQUIRED", result.get("bizCode"));
verifyNoInteractions(appointmentService);
}
@Test
void staffCreateSeparatesCustomerFromCreator() {
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())).thenReturn(Map.of("code", 200));
Map<String, Object> body = validBody();
body.put("customerPhone", "13800138000");
body.put("customerName", "小白妈妈");
Map<String, Object> result = controller.create(body);
assertEquals(200, result.get("code"));
ArgumentCaptor<Appointment> captor = ArgumentCaptor.forClass(Appointment.class);
verify(appointmentService).createBooking(captor.capture());
Appointment appointment = captor.getValue();
assertEquals(88L, appointment.getCustomerUserId());
assertEquals(2L, appointment.getCreatedByUserId());
assertEquals(88L, appointment.getUserId());
assertEquals(10L, appointment.getStoreId());
}
private static Map<String, Object> validBody() {
Map<String, Object> body = new HashMap<>();
body.put("petName", "小白");
body.put("petType", "");
body.put("serviceType", "洗澡");
body.put("appointmentTime", "2099-08-01T10:00:00");
return body;
}
}

View File

@ -17,6 +17,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
@ -25,7 +26,7 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.*;
/**
* ReportController 单元测试
@ -93,6 +94,8 @@ class ReportControllerTest {
assertNotNull(data);
// 公开 token 请求不返回 top-level 内部字段
assertFalse(data.containsKey("userId"), "公开请求不应返回 top-level userId");
assertFalse(data.containsKey("customerUserId"), "公开请求不应返回客户 ID");
assertFalse(data.containsKey("authorStaffId"), "公开请求不应返回报告作者 ID");
assertFalse(data.containsKey("storeId"), "公开请求不应返回 top-level storeId");
assertFalse(data.containsKey("reportToken"), "公开请求不应返回 reportToken");
// store 对象内应包含 id用于预约跳转深链
@ -232,6 +235,42 @@ class ReportControllerTest {
assertEquals("APPOINTMENT_REQUIRED", result.get("bizCode"));
}
@Test
void createDerivesReportAuthorFromSession() {
setCurrentUserBoss();
Report report = new Report();
report.setAppointmentId(100L);
report.setUserId(999L);
ReportImage before = new ReportImage();
before.setPhotoType("before");
before.setPhotoUrl("/x.jpg");
before.setMediaType("photo");
ReportImage after = new ReportImage();
after.setPhotoType("after");
after.setPhotoUrl("/y.jpg");
after.setMediaType("photo");
report.setImages(List.of(before, after));
Appointment appt = new Appointment();
appt.setId(100L);
appt.setStatus("doing");
appt.setStoreId(10L);
when(appointmentService.getById(100L)).thenReturn(appt);
when(reportService.create(any(Report.class))).thenAnswer(inv -> {
Report saved = inv.getArgument(0);
saved.setId(1L);
saved.setReportToken("token");
return saved;
});
Map<String, Object> result = controller.create(report);
assertEquals(200, result.get("code"));
ArgumentCaptor<Report> captor = ArgumentCaptor.forClass(Report.class);
verify(reportService).create(captor.capture());
assertEquals(1L, captor.getValue().getAuthorStaffId());
assertEquals(1L, captor.getValue().getUserId());
}
private void setCurrentUserBoss() {
CurrentUserContext.set(new CurrentUser(1L, 10L, "boss"));
}

View File

@ -0,0 +1,84 @@
package com.petstore.mapper;
import com.petstore.entity.Appointment;
import com.petstore.entity.Report;
import com.petstore.service.ServiceTypeService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.domain.PageRequest;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
@DataJpaTest(showSql = false, properties = {
"spring.datasource.url=jdbc:h2:mem:identity-query;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
"spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect",
"spring.jpa.hibernate.ddl-auto=create-drop",
"spring.jpa.show-sql=false"
})
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class IdentityOwnershipQueryTest {
@Autowired private AppointmentMapper appointmentMapper;
@Autowired private ReportMapper reportMapper;
@MockBean private ServiceTypeService serviceTypeService;
@Test
void canonicalCustomerWinsOverLegacyActorForAppointmentAndReportQueries() {
Appointment appointment = appointment(88L, 7L);
appointment = appointmentMapper.saveAndFlush(appointment);
Report report = report(appointment.getId(), 88L, 7L);
reportMapper.saveAndFlush(report);
assertEquals(1, appointmentMapper.findForCustomer(88L).size());
assertEquals(0, appointmentMapper.findForCustomer(7L).size());
assertEquals(1, reportMapper.findForCustomer(88L).size());
assertEquals(0, reportMapper.findForCustomer(7L).size());
assertEquals(1, reportMapper.pageForCustomer(88L, PageRequest.of(0, 10)).getTotalElements());
}
@Test
void legacyAppointmentFallbackKeepsOldCustomerReportsReadable() {
Appointment legacyAppointment = appointment(null, 66L);
legacyAppointment = appointmentMapper.saveAndFlush(legacyAppointment);
Report legacyReport = report(legacyAppointment.getId(), null, 5L);
reportMapper.saveAndFlush(legacyReport);
assertEquals(1, appointmentMapper.findForCustomer(66L).size());
assertEquals(1, reportMapper.findForCustomer(66L).size());
}
private Appointment appointment(Long customerUserId, Long legacyUserId) {
Appointment appointment = new Appointment();
appointment.setPetName("小白");
appointment.setPetType("");
appointment.setServiceType("洗护");
appointment.setAppointmentTime(LocalDateTime.of(2026, 8, 2, 10, 0));
appointment.setStatus("done");
appointment.setStoreId(10L);
appointment.setCustomerUserId(customerUserId);
appointment.setUserId(legacyUserId);
appointment.setCreatedByUserId(7L);
return appointment;
}
private Report report(Long appointmentId, Long customerUserId, Long authorStaffId) {
Report report = new Report();
report.setAppointmentId(appointmentId);
report.setCustomerUserId(customerUserId);
report.setAuthorStaffId(authorStaffId);
report.setUserId(authorStaffId);
report.setStoreId(10L);
report.setReportToken("token-" + appointmentId);
report.setCreateTime(LocalDateTime.of(2026, 8, 2, 11, 0));
return report;
}
}

View File

@ -2,6 +2,7 @@ package com.petstore.service;
import com.petstore.entity.Appointment;
import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.PetMapper;
import com.petstore.mapper.ReportMapper;
import com.petstore.mapper.ScheduleBlockMapper;
import com.petstore.mapper.UserMapper;
@ -28,6 +29,7 @@ class AppointmentServiceTest {
@Mock private StoreService storeService;
@Mock private UserMapper userMapper;
@Mock private ReportMapper reportMapper;
@Mock private PetMapper petMapper;
@InjectMocks private AppointmentService appointmentService;

View File

@ -1,6 +1,7 @@
package com.petstore.service;
import com.petstore.entity.HighlightFailReason;
import com.petstore.entity.Appointment;
import com.petstore.entity.Report;
import com.petstore.entity.ReportImage;
import com.petstore.entity.User;
@ -105,6 +106,22 @@ class ReportHighlightVideoServiceTest {
assertEquals(403, result.get("code"));
}
@Test
void requestGenerateUsesCanonicalAppointmentCustomer() {
Report report = report(1L, 10L, null);
report.setAppointmentId(100L);
Appointment appointment = new Appointment();
appointment.setId(100L);
appointment.setCustomerUserId(99L);
appointment.setUserId(1L);
when(reportMapper.findByIdAndDeletedFalse(1L)).thenReturn(Optional.of(report));
when(appointmentMapper.findByIdAndDeletedFalse(100L)).thenReturn(Optional.of(appointment));
when(reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(1L)).thenReturn(List.of());
Map<String, Object> result = service.requestGenerate(1L, 99L, "customer", "preset");
assertEquals(400, result.get("code"), "通过归属校验后应进入素材校验,而不是 403");
}
@Test
void requestGenerateRejectsInvalidComposeMode() {
Map<String, Object> result = service.requestGenerate(1L, 1L, "boss", "shuffle");

View File

@ -124,4 +124,41 @@ class ReportServiceTest {
verify(reportImageMapper).save(any(com.petstore.entity.ReportImage.class));
}
@Test
void createSeparatesCustomerAuthorAndServiceStaff() {
stubSaveReturnsReportWithId();
Report report = new Report();
report.setAppointmentId(100L);
report.setAuthorStaffId(7L);
report.setImages(List.of());
Appointment appt = new Appointment();
appt.setId(100L);
appt.setStatus("doing");
appt.setStoreId(10L);
appt.setCustomerUserId(88L);
appt.setAssignedUserId(5L);
User serviceStaff = new User();
serviceStaff.setId(5L);
serviceStaff.setName("服务技师");
when(reportMapper.existsByAppointmentIdAndDeletedFalse(100L)).thenReturn(false);
when(appointmentMapper.findByIdAndDeletedFalse(100L)).thenReturn(Optional.of(appt));
when(userMapper.findByIdAndDeletedFalse(5L)).thenReturn(Optional.of(serviceStaff));
Report created = reportService.create(report);
assertEquals(88L, created.getCustomerUserId());
assertEquals(7L, created.getAuthorStaffId());
assertEquals(7L, created.getUserId(), "legacy userId 应镜像报告作者");
assertEquals("服务技师", created.getStaffName());
}
@Test
void customerListUsesCanonicalOwnershipQuery() {
when(reportMapper.findForCustomer(88L)).thenReturn(List.of());
assertTrue(reportService.list(null, 88L).isEmpty());
verify(reportMapper).findForCustomer(88L);
verify(reportMapper, never()).findByUserIdAndDeletedFalseOrderByCreateTimeDesc(anyLong());
}
}

View File

@ -0,0 +1,71 @@
package com.petstore.service;
import com.petstore.auth.SessionTokenService;
import com.petstore.entity.User;
import com.petstore.mapper.StoreMapper;
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.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceBookingCustomerTest {
@Mock private UserMapper userMapper;
@Mock private StoreMapper storeMapper;
@Mock private SessionTokenService sessionTokenService;
@InjectMocks private UserService userService;
@Test
void createsCustomerForNewAssistedBookingPhone() {
when(userMapper.findByPhoneAndDeletedFalse("13800138000")).thenReturn(null);
when(userMapper.save(any(User.class))).thenAnswer(inv -> {
User user = inv.getArgument(0);
user.setId(88L);
return user;
});
User customer = userService.resolveBookingCustomer(null, "13800138000", "小白妈妈");
assertEquals(88L, customer.getId());
assertEquals("customer", customer.getRole());
assertEquals("小白妈妈", customer.getName());
assertNull(customer.getStoreId());
}
@Test
void rejectsStoreAccountPhoneAsCustomer() {
User staff = new User();
staff.setId(2L);
staff.setRole("staff");
staff.setPhone("13800138000");
when(userMapper.findByPhoneAndDeletedFalse("13800138000")).thenReturn(staff);
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
() -> userService.resolveBookingCustomer(null, "13800138000", "")
);
assertTrue(error.getMessage().contains("门店账号"));
verify(userMapper, never()).save(any());
}
@Test
void resolvesExistingCustomerByIdAndChecksPhone() {
User customer = new User();
customer.setId(88L);
customer.setRole("customer");
customer.setPhone("13800138000");
when(userMapper.findByIdAndDeletedFalse(88L)).thenReturn(Optional.of(customer));
assertSame(customer, userService.resolveBookingCustomer(88L, "13800138000", "ignored"));
assertThrows(IllegalArgumentException.class,
() -> userService.resolveBookingCustomer(88L, "13900139000", "ignored"));
}
}