feat: split booking and report identities
This commit is contained in:
parent
e74f376f52
commit
3f1bfffbed
@ -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)已轮换
|
||||
|
||||
## 安全说明
|
||||
|
||||
59
db/migrations/20260801_split_service_identity.sql
Normal file
59
db/migrations/20260801_split_service_identity.sql
Normal 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
13
db/migrations/README.md
Normal 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 后续任务;接入前不得把本目录误当成自动执行目录。
|
||||
5
pom.xml
5
pom.xml
@ -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>
|
||||
|
||||
@ -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<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/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()) {
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<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;
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()));
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已通过短信或微信授权校验后的手机号登录(与验证码登录成功后的逻辑一致)。
|
||||
*/
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user