diff --git a/README.md b/README.md index 5af481e..04a53d7 100644 --- a/README.md +++ b/README.md @@ -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 -u -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)已轮换 ## 安全说明 diff --git a/db/migrations/20260801_split_service_identity.sql b/db/migrations/20260801_split_service_identity.sql new file mode 100644 index 0000000..effcc08 --- /dev/null +++ b/db/migrations/20260801_split_service_identity.sql @@ -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; diff --git a/db/migrations/README.md b/db/migrations/README.md new file mode 100644 index 0000000..c5affb5 --- /dev/null +++ b/db/migrations/README.md @@ -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 后续任务;接入前不得把本目录误当成自动执行目录。 diff --git a/pom.xml b/pom.xml index 6d7f50e..b789660 100644 --- a/pom.xml +++ b/pom.xml @@ -51,6 +51,11 @@ spring-boot-starter-test test + + com.h2database + h2 + test + diff --git a/src/main/java/com/petstore/controller/AppointmentController.java b/src/main/java/com/petstore/controller/AppointmentController.java index a9703e9..4a339e2 100644 --- a/src/main/java/com/petstore/controller/AppointmentController.java +++ b/src/main/java/com/petstore/controller/AppointmentController.java @@ -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 派生。 - * - customer:storeId 取 body.storeId(宠主选店);userId = current.userId - * - boss/staff(代客预约):storeId = current.storeId(必须本店);userId = current.userId + * 创建预约:客户、创建人分别记录;不再把代客操作员工写成客户。 + * - customer:customerUserId = createdByUserId = current.userId + * - boss/staff:createdByUserId = current.userId;customerUserId 由 body.customerUserId/customerPhone 解析 + * - legacy userId 仅镜像 customerUserId,兼容旧客户端与历史查询 */ @PostMapping("/create") public Map create(@RequestBody Map 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/staff;staffUserId = 当前用户(谁点谁服务);预约必须属于本店。 */ @@ -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()) { diff --git a/src/main/java/com/petstore/controller/ReportController.java b/src/main/java/com/petstore/controller/ReportController.java index 47f8b76..4c2340c 100644 --- a/src/main/java/com/petstore/controller/ReportController.java +++ b/src/main/java/com/petstore/controller/ReportController.java @@ -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 imgs = r.getImages(); List 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()); } diff --git a/src/main/java/com/petstore/entity/Appointment.java b/src/main/java/com/petstore/entity/Appointment.java index 8bfe500..8420832 100644 --- a/src/main/java/com/petstore/entity/Appointment.java +++ b/src/main/java/com/petstore/entity/Appointment.java @@ -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,16 +32,42 @@ 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; diff --git a/src/main/java/com/petstore/entity/Report.java b/src/main/java/com/petstore/entity/Report.java index 6be216f..df34e06 100644 --- a/src/main/java/com/petstore/entity/Report.java +++ b/src/main/java/com/petstore/entity/Report.java @@ -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,8 +37,18 @@ public class Report { @Transient private List 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; @@ -47,6 +60,13 @@ public class Report { private String serviceType; 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; diff --git a/src/main/java/com/petstore/mapper/AppointmentMapper.java b/src/main/java/com/petstore/mapper/AppointmentMapper.java index 3dc58cb..862bade 100644 --- a/src/main/java/com/petstore/mapper/AppointmentMapper.java +++ b/src/main/java/com/petstore/mapper/AppointmentMapper.java @@ -12,17 +12,48 @@ import java.util.List; import java.util.Optional; public interface AppointmentMapper extends JpaRepository { + /** @deprecated 兼容旧调用;新代码使用 customer_user_id 语义查询。 */ + @Deprecated List findByUserIdAndDeletedFalse(Long userId); + /** @deprecated 兼容旧调用;新代码使用 customer_user_id 语义查询。 */ + @Deprecated List findByUserIdAndStatusAndDeletedFalse(Long userId, String status); List findByStoreIdAndDeletedFalse(Long storeId); List findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status); + /** @deprecated 兼容旧调用;新代码使用 customer_user_id 语义查询。 */ + @Deprecated Page findByUserIdAndDeletedFalse(Long userId, Pageable pageable); + /** @deprecated 兼容旧调用;新代码使用 customer_user_id 语义查询。 */ + @Deprecated Page findByUserIdAndStatusAndDeletedFalse(Long userId, String status, Pageable pageable); Page findByStoreIdAndDeletedFalse(Long storeId, Pageable pageable); Page findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status, Pageable pageable); Optional 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 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 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 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 pageForCustomerByStatus( + @Param("customerUserId") Long customerUserId, + @Param("status") String status, + Pageable pageable); + /** 某宠物档案关联的全部预约(按预约时间倒序) */ List findByPetIdAndDeletedFalseOrderByAppointmentTimeDesc(Long petId); diff --git a/src/main/java/com/petstore/mapper/ReportMapper.java b/src/main/java/com/petstore/mapper/ReportMapper.java index f5992ec..f9a4e09 100644 --- a/src/main/java/com/petstore/mapper/ReportMapper.java +++ b/src/main/java/com/petstore/mapper/ReportMapper.java @@ -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 { List findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(Long storeId); + /** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */ + @Deprecated List findByUserIdAndDeletedFalseOrderByCreateTimeDesc(Long userId); + /** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */ + @Deprecated List findByStoreIdAndUserIdAndDeletedFalseOrderByCreateTimeDesc(Long storeId, Long userId); List findAllByDeletedFalseOrderByCreateTimeDesc(); Page findByStoreIdAndDeletedFalse(Long storeId, Pageable pageable); + /** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */ + @Deprecated Page findByUserIdAndDeletedFalse(Long userId, Pageable pageable); + /** @deprecated user_id 表示旧报告作者,不再用于客户归属查询。 */ + @Deprecated Page 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 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 pageForCustomer(@Param("customerUserId") Long customerUserId, Pageable pageable); Page findByDeletedFalse(Pageable pageable); Optional findByIdAndDeletedFalse(Long id); diff --git a/src/main/java/com/petstore/service/AdminServiceCustomerService.java b/src/main/java/com/petstore/service/AdminServiceCustomerService.java index a71cbfc..d52dfd6 100644 --- a/src/main/java/com/petstore/service/AdminServiceCustomerService.java +++ b/src/main/java/com/petstore/service/AdminServiceCustomerService.java @@ -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 leads = reportLeadMapper.findByStoreIdOrderByCreateTimeDesc(storeId); List reports = reportMapper.findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(storeId); - Map> apptsByUser = appointments.stream() - .filter(a -> a.getUserId() != null) - .collect(Collectors.groupingBy(Appointment::getUserId)); - - Set userIds = new HashSet<>(apptsByUser.keySet()); + Map appointmentsById = appointments.stream() + .filter(a -> a.getId() != null) + .collect(Collectors.toMap(Appointment::getId, a -> a, (a, b) -> a)); + Set userIds = appointments.stream() + .map(Appointment::resolvedCustomerUserId) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toSet()); Map 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 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> 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> petsByOwner = new HashMap<>(); for (Long uid : users.keySet()) { petsByOwner.put(uid, petMapper.findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(uid)); @@ -109,10 +120,17 @@ public class AdminServiceCustomerService { Map 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> leadsByPhone = leads.stream() diff --git a/src/main/java/com/petstore/service/AppointmentService.java b/src/main/java/com/petstore/service/AppointmentService.java index 735aa6a..ae6805d 100644 --- a/src/main/java/com/petstore/service/AppointmentService.java +++ b/src/main/java/com/petstore/service/AppointmentService.java @@ -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 getByUserId(Long userId) { - return appointmentMapper.findByUserIdAndDeletedFalse(userId); + // 宠主查看归属于自己的预约;兼容 customer_user_id 尚未回填的旧数据。 + public List getByUserId(Long customerUserId) { + return appointmentMapper.findForCustomer(customerUserId); } // 老板查看本店所有预约 @@ -46,9 +49,9 @@ public class AppointmentService { return appointmentMapper.findByStoreIdAndDeletedFalse(storeId); } - // 员工按状态查 - public List getByUserIdAndStatus(Long userId, String status) { - return appointmentMapper.findByUserIdAndStatusAndDeletedFalse(userId, status); + // 宠主按状态查归属于自己的预约。 + public List 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 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 toAdminView(Appointment a) { Set 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())); diff --git a/src/main/java/com/petstore/service/ReportHighlightVideoService.java b/src/main/java/com/petstore/service/ReportHighlightVideoService.java index 720aad4..921b6e5 100644 --- a/src/main/java/com/petstore/service/ReportHighlightVideoService.java +++ b/src/main/java/com/petstore/service/ReportHighlightVideoService.java @@ -946,7 +946,7 @@ public class ReportHighlightVideoService { return false; } Optional ap = appointmentMapper.findByIdAndDeletedFalse(apptId); - return ap.isPresent() && operatorUserId.equals(ap.get().getUserId()); + return ap.isPresent() && operatorUserId.equals(ap.get().resolvedCustomerUserId()); } return false; } diff --git a/src/main/java/com/petstore/service/ReportService.java b/src/main/java/com/petstore/service/ReportService.java index af25c24..75b65ec 100644 --- a/src/main/java/com/petstore/service/ReportService.java +++ b/src/main/java/com/petstore/service/ReportService.java @@ -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 list(Long storeId, Long userId) { + public List list(Long storeId, Long customerUserId) { List 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 page(Long storeId, Long userId, int pageNo, int pageSize) { + public Page 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 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); } diff --git a/src/main/java/com/petstore/service/UserService.java b/src/main/java/com/petstore/service/UserService.java index 6bcbea3..b07b223 100644 --- a/src/main/java/com/petstore/service/UserService.java +++ b/src/main/java/com/petstore/service/UserService.java @@ -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); + } + /** * 已通过短信或微信授权校验后的手机号登录(与验证码登录成功后的逻辑一致)。 */ diff --git a/src/test/java/com/petstore/controller/AppointmentControllerDetailTest.java b/src/test/java/com/petstore/controller/AppointmentControllerDetailTest.java index 62aa362..425fd08 100644 --- a/src/test/java/com/petstore/controller/AppointmentControllerDetailTest.java +++ b/src/test/java/com/petstore/controller/AppointmentControllerDetailTest.java @@ -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 result = controller.detail(1L); + assertEquals(200, result.get("code")); + } + @Test void customerCannotViewOthersAppointment() { CurrentUserContext.set(new CurrentUser(7L, null, "customer")); diff --git a/src/test/java/com/petstore/controller/AppointmentControllerIdentityTest.java b/src/test/java/com/petstore/controller/AppointmentControllerIdentityTest.java new file mode 100644 index 0000000..35e7763 --- /dev/null +++ b/src/test/java/com/petstore/controller/AppointmentControllerIdentityTest.java @@ -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 body = validBody(); + body.put("storeId", 10L); + body.put("customerUserId", 999L); // 必须忽略请求体伪造身份 + + Map result = controller.create(body); + assertEquals(200, result.get("code")); + + ArgumentCaptor 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 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 body = validBody(); + body.put("customerPhone", "13800138000"); + body.put("customerName", "小白妈妈"); + Map result = controller.create(body); + assertEquals(200, result.get("code")); + + ArgumentCaptor 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 validBody() { + Map body = new HashMap<>(); + body.put("petName", "小白"); + body.put("petType", "狗"); + body.put("serviceType", "洗澡"); + body.put("appointmentTime", "2099-08-01T10:00:00"); + return body; + } +} diff --git a/src/test/java/com/petstore/controller/ReportControllerTest.java b/src/test/java/com/petstore/controller/ReportControllerTest.java index fb7accc..4dfcfaf 100644 --- a/src/test/java/com/petstore/controller/ReportControllerTest.java +++ b/src/test/java/com/petstore/controller/ReportControllerTest.java @@ -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 result = controller.create(report); + assertEquals(200, result.get("code")); + ArgumentCaptor 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")); } diff --git a/src/test/java/com/petstore/mapper/IdentityOwnershipQueryTest.java b/src/test/java/com/petstore/mapper/IdentityOwnershipQueryTest.java new file mode 100644 index 0000000..e89bce6 --- /dev/null +++ b/src/test/java/com/petstore/mapper/IdentityOwnershipQueryTest.java @@ -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; + } +} diff --git a/src/test/java/com/petstore/service/AppointmentServiceTest.java b/src/test/java/com/petstore/service/AppointmentServiceTest.java index 1d87843..fc4281d 100644 --- a/src/test/java/com/petstore/service/AppointmentServiceTest.java +++ b/src/test/java/com/petstore/service/AppointmentServiceTest.java @@ -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; diff --git a/src/test/java/com/petstore/service/ReportHighlightVideoServiceTest.java b/src/test/java/com/petstore/service/ReportHighlightVideoServiceTest.java index 3c39ccf..b369302 100644 --- a/src/test/java/com/petstore/service/ReportHighlightVideoServiceTest.java +++ b/src/test/java/com/petstore/service/ReportHighlightVideoServiceTest.java @@ -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 result = service.requestGenerate(1L, 99L, "customer", "preset"); + assertEquals(400, result.get("code"), "通过归属校验后应进入素材校验,而不是 403"); + } + @Test void requestGenerateRejectsInvalidComposeMode() { Map result = service.requestGenerate(1L, 1L, "boss", "shuffle"); diff --git a/src/test/java/com/petstore/service/ReportServiceTest.java b/src/test/java/com/petstore/service/ReportServiceTest.java index 8a9151c..bbc7f43 100644 --- a/src/test/java/com/petstore/service/ReportServiceTest.java +++ b/src/test/java/com/petstore/service/ReportServiceTest.java @@ -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()); + } } diff --git a/src/test/java/com/petstore/service/UserServiceBookingCustomerTest.java b/src/test/java/com/petstore/service/UserServiceBookingCustomerTest.java new file mode 100644 index 0000000..556cf04 --- /dev/null +++ b/src/test/java/com/petstore/service/UserServiceBookingCustomerTest.java @@ -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")); + } +}