Compare commits

...

28 Commits
master ... main

Author SHA1 Message Date
MaDaLei
6cbf23c06a fix: FileController恢复标准实现,确保编译和部署正常 2026-04-17 20:31:50 +08:00
MaDaLei
fd6bf7c071 fix: 用StreamingResponseBody手动控制Content-Type,彻底解决charset注入问题 2026-04-17 20:27:09 +08:00
MaDaLei
d7d585ed42 fix: 直接header设置Content-Type,彻底避免charset注入 2026-04-17 20:24:48 +08:00
MaDaLei
66c21ae449 fix: highlightVideoUrl存完整URL;添加app.base-url配置 2026-04-17 20:21:53 +08:00
MaDaLei
677227441f fix: 修复 verified 变量作用域导致的编译错误 2026-04-17 20:04:57 +08:00
MaDaLei
2cef1d7993 fix: 修复视频 Content-Type charset 异常 + ffmpeg concat 改用转码(Baseline Profile + faststart) 2026-04-17 19:55:19 +08:00
MaDaLei
ce21bad400 feat: 服务回顾短片后端支持(ffmpeg合成) 2026-04-17 18:58:04 +08:00
MaDaLei
cd1555ef0a feat: 宠物及预约查询服务优化 2026-04-17 14:33:43 +08:00
MaDaLei
6d154f1f9a fix: 文件上传接口调整 2026-04-17 14:11:26 +08:00
MaDaLei
f9b5a7abb3 fix: 修复门店控制器 2026-04-17 13:56:30 +08:00
MaDaLei
4c95bf585f chore: 优化部署配置 2026-04-17 13:43:34 +08:00
MaDaLei
da64a495ac fix: WebConfig读取upload.path配置而非硬编码路径 2026-04-17 13:21:05 +08:00
MaDaLei
f308892ba4 fix: 上传路径改为绝对路径 2026-04-17 13:19:38 +08:00
MaDaLei
0a5b2696c2 fix: 修复文件上传接口 2026-04-17 13:09:22 +08:00
MaDaLei
f6679fd11a feat: 新增排班时段管理后端接口 2026-04-17 12:26:37 +08:00
MaDaLei
418dbb5801 feat: 优化预约时段服务 2026-04-17 12:13:46 +08:00
MaDaLei
e4410f5794 feat: 优化预约时段支持及门店配置 2026-04-17 10:57:31 +08:00
MaDaLei
870190c709 feat: 添加逻辑删除字段,提升上传限制至120MB 2026-04-17 08:09:03 +08:00
malei
9008b5bece fix: return during media and stabilize report image ordering 2026-04-16 23:44:34 +08:00
MaDaLei
fa9cdecfb1 fix: 移除create日志中已删除的beforePhoto字段引用 2026-04-16 21:22:15 +08:00
MaDaLei
3298574159 feat: 洗美报告图片表重构 - ReportImage实体,支持多图上传 2026-04-16 21:20:28 +08:00
malei
3604b4bb78 perf: optimize report/appointment query paths and add paging 2026-04-15 07:22:01 +08:00
MaDaLei
5b19922fd3 feat: 新增宠物管理模块及相关服务更新 2026-04-14 21:47:24 +08:00
MaDaLei
b4cbbb6939 feat: 微信小程序登录服务及配置更新 2026-04-14 21:04:19 +08:00
MaDaLei
d6c41f5db1 fix: 微信登录新建用户时设置占位password避免数据库约束报错 2026-04-14 19:01:49 +08:00
MaDaLei
6e3c49d02d feat: 微信小程序用户登录服务及配置 2026-04-14 16:56:02 +08:00
MaDaLei
3c7c8d1f73 fix: 修复预约相关逻辑 2026-04-14 08:54:43 +08:00
MaDaLei
3c04019af4 feat: make upload path and base-url configurable via properties 2026-04-13 14:29:35 +08:00
42 changed files with 2382 additions and 123 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
target/ target/
uploads/ uploads/
# 本地密钥,勿提交;用 application-example.yml 复制
src/main/resources/application.yml

View File

@ -0,0 +1,7 @@
# 反代到 Spring Boot 时加入server 或 location /api/ 内)
# 默认 client_max_body_size 仅 1m会导致大视频返回 HTTP 413小程序提示「文件过大」
client_max_body_size 200m;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;

View File

@ -0,0 +1,22 @@
package com.petstore.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
public class HighlightExecutorConfig {
@Bean(name = "highlightTaskExecutor")
public Executor highlightTaskExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(1);
ex.setMaxPoolSize(2);
ex.setQueueCapacity(30);
ex.setThreadNamePrefix("highlight-video-");
ex.initialize();
return ex;
}
}

View File

@ -15,13 +15,9 @@ public class WebConfig implements WebMvcConfigurer {
@Override @Override
public void addResourceHandlers(ResourceHandlerRegistry registry) { public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 硬编码绝对路径确保能找到文件 String uploadDir = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
String uploadDir = "/Users/wac/Desktop/www/_src/petstore/backend/uploads/";
System.out.println(">>> WebConfig uploadDir: " + uploadDir); System.out.println(">>> WebConfig uploadDir: " + uploadDir);
System.out.println(">>> /2026 exists: " + new File(uploadDir + "2026/04/01/").exists());
registry.addResourceHandler("/uploads/**") registry.addResourceHandler("/uploads/**")
.addResourceLocations("file:" + uploadDir); .addResourceLocations("file:" + uploadDir);
registry.addResourceHandler("/2026/**")
.addResourceLocations("file:" + uploadDir);
} }
} }

View File

@ -3,9 +3,10 @@ package com.petstore.controller;
import com.petstore.entity.Appointment; import com.petstore.entity.Appointment;
import com.petstore.service.AppointmentService; import com.petstore.service.AppointmentService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.HashMap; import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -16,29 +17,76 @@ import java.util.Map;
public class AppointmentController { public class AppointmentController {
private final AppointmentService appointmentService; private final AppointmentService appointmentService;
/**
* 门店某日可预约时段半小时一档每档最多一单
* date 格式yyyy-MM-dd
*/
@GetMapping("/available-slots")
public Map<String, Object> availableSlots(@RequestParam Long storeId, @RequestParam String date) {
LocalDate d;
try {
d = LocalDate.parse(date);
} catch (Exception e) {
return Map.of("code", 400, "message", "日期格式应为 yyyy-MM-dd");
}
return appointmentService.availableSlots(storeId, d);
}
/** 获取预约列表(员工查自己/老板查全店) */ /** 获取预约列表(员工查自己/老板查全店) */
@GetMapping("/list") @GetMapping("/list")
public Map<String, Object> list( public Map<String, Object> list(
@RequestParam(required = false) Long userId, @RequestParam(required = false) Long userId,
@RequestParam(required = false) Long storeId, @RequestParam(required = false) Long storeId,
@RequestParam(required = false) String status) { @RequestParam(required = false) String status,
@RequestParam(required = false) Integer page,
@RequestParam(required = false) Integer pageSize) {
if (storeId == null && userId == null) {
return Map.of("code", 400, "message", "userId或storeId必填");
}
List<Appointment> appointments; List<Appointment> appointments;
boolean usePaging = page != null || pageSize != null;
if (usePaging) {
int pageNo = page == null ? 1 : page;
int size = pageSize == null ? 20 : pageSize;
pageNo = Math.max(pageNo, 1);
size = Math.min(Math.max(size, 1), 200);
Page<Appointment> paged = appointmentService.pageByScope(userId, storeId, status, pageNo - 1, size);
return Map.of(
"code", 200,
"data", paged.getContent(),
"page", pageNo,
"pageSize", size,
"total", paged.getTotalElements(),
"totalPages", paged.getTotalPages(),
"hasNext", paged.hasNext()
);
}
if (storeId != null) { if (storeId != null) {
appointments = (status != null && !status.isEmpty()) appointments = (status != null && !status.isEmpty())
? appointmentService.getByStoreIdAndStatus(storeId, status) ? appointmentService.getByStoreIdAndStatus(storeId, status)
: appointmentService.getByStoreId(storeId); : appointmentService.getByStoreId(storeId);
} else if (userId != null) { } else {
appointments = (status != null && !status.isEmpty()) appointments = (status != null && !status.isEmpty())
? appointmentService.getByUserIdAndStatus(userId, status) ? appointmentService.getByUserIdAndStatus(userId, status)
: appointmentService.getByUserId(userId); : appointmentService.getByUserId(userId);
} else {
return Map.of("code", 400, "message", "userId或storeId必填");
} }
return Map.of("code", 200, "data", appointments); return Map.of("code", 200, "data", appointments);
} }
/** 预约详情 */
@GetMapping("/detail")
public Map<String, Object> detail(@RequestParam Long id) {
Appointment appointment = appointmentService.getById(id);
if (appointment != null) {
return Map.of("code", 200, "data", appointment);
}
return Map.of("code", 404, "message", "预约不存在");
}
/** 创建预约 */ /** 创建预约 */
@PostMapping("/create") @PostMapping("/create")
public Map<String, Object> create(@RequestBody Map<String, Object> params) { public Map<String, Object> create(@RequestBody Map<String, Object> params) {
@ -57,9 +105,11 @@ public class AppointmentController {
if (params.containsKey("remark") && params.get("remark") != null) { if (params.containsKey("remark") && params.get("remark") != null) {
appointment.setRemark(params.get("remark").toString()); appointment.setRemark(params.get("remark").toString());
} }
if (params.containsKey("petId") && params.get("petId") != null && !params.get("petId").toString().isBlank()) {
appointment.setPetId(Long.valueOf(params.get("petId").toString()));
}
Appointment created = appointmentService.create(appointment); return appointmentService.createBooking(appointment);
return Map.of("code", 200, "message", "创建成功", "data", created);
} }
/** 开始服务:状态变进行中 + 指定技师 */ /** 开始服务:状态变进行中 + 指定技师 */
@ -83,4 +133,13 @@ public class AppointmentController {
} }
return Map.of("code", 404, "message", "预约不存在"); return Map.of("code", 404, "message", "预约不存在");
} }
@DeleteMapping("/delete")
public Map<String, Object> delete(@RequestParam Long id) {
boolean ok = appointmentService.softDelete(id);
if (!ok) {
return Map.of("code", 404, "message", "预约不存在");
}
return Map.of("code", 200, "message", "删除成功");
}
} }

View File

@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -13,11 +14,12 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.HashMap; import java.util.HashMap;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
@RestController @RestController
@ -26,22 +28,56 @@ import java.util.UUID;
@CrossOrigin @CrossOrigin
public class FileController { public class FileController {
private static final String UPLOAD_BASE = "/Users/wac/Desktop/www/_src/petstore/backend/uploads/"; private final Set<String> IMAGE_EXT = Set.of(".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".heic", ".heif");
private final Set<String> VIDEO_EXT = Set.of(".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".webm");
@Value("${upload.path:uploads}") @Value("${upload.path}")
private String uploadPath; private String uploadPath;
/**
* 微信小程序 uni.uploadFilemultipart file Content-Type 经常为空或 octet-stream
* 临时路径也可能没有.jpg等后缀不能因 MIME 为空直接拒绝
*/
static boolean isAllowedMediaType(String contentType, String originalFilename) {
if (hasMediaExtension(originalFilename)) {
return true;
}
if (contentType == null || contentType.isBlank()) {
// MIME无扩展名时仍放行保存时用默认后缀 + 大小启发式避免线上全部 400
return true;
}
String ct = contentType.toLowerCase(Locale.ROOT).trim();
if (ct.startsWith("image/") || ct.startsWith("video/")) {
return true;
}
return "application/octet-stream".equals(ct);
}
private static boolean hasMediaExtension(String originalFilename) {
if (originalFilename == null || !originalFilename.contains(".")) {
return false;
}
String ext = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase(Locale.ROOT);
return IMAGE_EXT.contains(ext) || VIDEO_EXT.contains(ext);
}
@GetMapping("/image/**") @GetMapping("/image/**")
public ResponseEntity<Resource> getImage(HttpServletRequest request) throws IOException { public ResponseEntity<Resource> getImage(HttpServletRequest request) throws IOException {
String path = request.getRequestURI().replace("/api/upload/image", ""); String path = request.getRequestURI().replace("/api/upload/image", "");
File file = new File(UPLOAD_BASE + path); String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
File file = new File(basePath + path);
if (!file.exists()) { if (!file.exists()) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
String contentType = Files.probeContentType(file.toPath()); String contentType = Files.probeContentType(file.toPath());
if (contentType == null) contentType = "image/jpeg"; if (contentType == null) contentType = "image/jpeg";
// 去掉 charset 参数Spring Accept-Charset 会错误地给 binary 类型加上 charset
int semi = contentType.indexOf(';');
if (semi >= 0) contentType = contentType.substring(0, semi).trim();
MediaType mediaType = MediaType.parseMediaType(contentType);
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType)) .contentType(mediaType)
.contentLength(file.length())
.body(new FileSystemResource(file)); .body(new FileSystemResource(file));
} }
@ -49,18 +85,24 @@ public class FileController {
@GetMapping("/legacy/**") @GetMapping("/legacy/**")
public ResponseEntity<Resource> getLegacyImage(HttpServletRequest request) throws IOException { public ResponseEntity<Resource> getLegacyImage(HttpServletRequest request) throws IOException {
String path = request.getRequestURI().replace("/api/upload/legacy", ""); String path = request.getRequestURI().replace("/api/upload/legacy", "");
File file = new File(UPLOAD_BASE + path); String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
File file = new File(basePath + path);
if (!file.exists()) { if (!file.exists()) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
String contentType = Files.probeContentType(file.toPath()); String contentType = Files.probeContentType(file.toPath());
if (contentType == null) contentType = "image/jpeg"; if (contentType == null) contentType = "image/jpeg";
int semi = contentType.indexOf(';');
if (semi >= 0) contentType = contentType.substring(0, semi).trim();
MediaType mediaType = MediaType.parseMediaType(contentType);
return ResponseEntity.ok() return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType)) .contentType(mediaType)
.contentLength(file.length())
.body(new FileSystemResource(file)); .body(new FileSystemResource(file));
} }
@PostMapping("/image") /** produces 显式 UTF-8避免网关/客户端按 ISO-8859-1 解码导致 message 中文乱码 */
@PostMapping(value = "/image", produces = MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
public Map<String, Object> uploadImage(@RequestParam("file") MultipartFile file) { public Map<String, Object> uploadImage(@RequestParam("file") MultipartFile file) {
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
@ -70,31 +112,44 @@ public class FileController {
return result; return result;
} }
String originalFilename = file.getOriginalFilename();
String contentType = file.getContentType(); String contentType = file.getContentType();
if (contentType == null || !contentType.startsWith("image/")) {
if (!isAllowedMediaType(contentType, originalFilename)) {
result.put("code", 400); result.put("code", 400);
result.put("message", "只能上传图片"); result.put("message", "只能上传图片或视频");
return result; return result;
} }
try { try {
// 创建上传目录使用绝对路径 // 创建上传目录
String datePath = LocalDate.now().toString().replace("-", "/"); String datePath = LocalDate.now().toString().replace("-", "/");
String dirPath = UPLOAD_BASE + datePath; // /.../uploads/2026/04/01 String dirPath = uploadPath.endsWith("/") ? uploadPath + datePath : uploadPath + "/" + datePath;
File dir = new File(dirPath); File dir = new File(dirPath);
if (!dir.exists()) dir.mkdirs(); if (!dir.exists()) dir.mkdirs();
// 生成文件名 // 生成文件名
String originalFilename = file.getOriginalFilename();
String ext = ""; String ext = "";
if (originalFilename != null && originalFilename.contains(".")) { if (originalFilename != null && originalFilename.contains(".")) {
ext = originalFilename.substring(originalFilename.lastIndexOf(".")); ext = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase(Locale.ROOT);
if (ext.length() > 10) {
ext = "";
} }
}
if (ext.isEmpty()) {
boolean looksVideo = contentType != null && contentType.toLowerCase(Locale.ROOT).startsWith("video/");
// application/octet-stream 且无扩展名时大文件更可能是视频
if (!looksVideo && "application/octet-stream".equalsIgnoreCase(String.valueOf(contentType).trim())) {
looksVideo = file.getSize() > 3 * 1024 * 1024L;
}
ext = looksVideo ? ".mp4" : ".jpg";
}
String filename = UUID.randomUUID().toString().replace("-", "") + ext; String filename = UUID.randomUUID().toString().replace("-", "") + ext;
// 保存文件 // 保存文件流式写入避免大视频一次性进内存
Path filePath = Paths.get(dirPath, filename); Path filePath = Paths.get(dirPath, filename);
Files.write(filePath, file.getBytes()); file.transferTo(filePath.toFile());
// 返回访问URL/api/upload/image/ + 日期路径 + 文件名 // 返回访问URL/api/upload/image/ + 日期路径 + 文件名
String url = "/api/upload/image/" + datePath + "/" + filename; String url = "/api/upload/image/" + datePath + "/" + filename;

View File

@ -0,0 +1,58 @@
package com.petstore.controller;
import com.petstore.entity.Pet;
import com.petstore.service.PetService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/pet")
@RequiredArgsConstructor
@CrossOrigin
public class PetController {
private final PetService petService;
/**
* 列表 ownerUserId= 查该客户全部宠物 storeId= 查本店预约关联出现过的宠物商家/员工
*/
@GetMapping("/list")
public Map<String, Object> list(@RequestParam(required = false) Long ownerUserId,
@RequestParam(required = false) Long storeId) {
if (ownerUserId != null) {
return Map.of("code", 200, "data", petService.listByOwner(ownerUserId));
}
if (storeId != null) {
return Map.of("code", 200, "data", petService.listByStoreServed(storeId));
}
return Map.of("code", 400, "message", "请传 ownerUserId 或 storeId");
}
@PostMapping("/create")
public Map<String, Object> create(@RequestBody Pet pet) {
return petService.create(pet);
}
@PutMapping("/update")
public Map<String, Object> update(@RequestBody Map<String, Object> body) {
Long operatorUserId = Long.valueOf(body.get("operatorUserId").toString());
String role = body.get("role").toString();
Pet input = new Pet();
input.setId(Long.valueOf(body.get("id").toString()));
if (body.containsKey("name")) input.setName(body.get("name").toString());
if (body.containsKey("petType")) input.setPetType(body.get("petType").toString());
if (body.containsKey("breed")) input.setBreed(body.get("breed") != null ? body.get("breed").toString() : null);
if (body.containsKey("avatar")) input.setAvatar(body.get("avatar") != null ? body.get("avatar").toString() : null);
if (body.containsKey("remark")) input.setRemark(body.get("remark") != null ? body.get("remark").toString() : null);
return petService.update(operatorUserId, role, input);
}
@DeleteMapping("/delete")
public Map<String, Object> delete(@RequestParam Long id,
@RequestParam Long operatorUserId,
@RequestParam String role) {
return petService.delete(id, operatorUserId, role);
}
}

View File

@ -1,15 +1,18 @@
package com.petstore.controller; package com.petstore.controller;
import com.petstore.entity.Report; import com.petstore.entity.Report;
import com.petstore.entity.ReportImage;
import com.petstore.entity.Store; import com.petstore.entity.Store;
import com.petstore.service.ReportHighlightVideoService;
import com.petstore.service.ReportService; import com.petstore.service.ReportService;
import com.petstore.service.StoreService; import com.petstore.service.StoreService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import java.util.HashMap; import java.util.*;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@ -19,18 +22,60 @@ import java.util.stream.Collectors;
public class ReportController { public class ReportController {
private final ReportService reportService; private final ReportService reportService;
private final StoreService storeService; private final StoreService storeService;
private final ReportHighlightVideoService reportHighlightVideoService;
private static final String BASE_URL = "http://localhost:8080"; @Value("${app.base-url:http://localhost:8080}")
private String baseUrl;
private String fullUrl(String path) { private String fullUrl(String path) {
if (path == null || path.isEmpty()) return path; if (path == null || path.isEmpty()) return path;
if (path.startsWith("http")) return path; if (path.startsWith("http")) return path;
return BASE_URL + path; return baseUrl + path;
}
private String resolveMediaType(ReportImage img) {
String mt = img.getMediaType();
if (mt != null && !mt.isBlank()) {
return mt;
}
String url = img.getPhotoUrl();
if (url == null) {
return "photo";
}
String lower = url.toLowerCase(Locale.ROOT);
if (lower.endsWith(".mp4") || lower.endsWith(".mov") || lower.endsWith(".m4v")
|| lower.endsWith(".webm") || lower.endsWith(".avi")) {
return "video";
}
return "photo";
}
private void putHighlightFields(Map<String, Object> target, Report r) {
target.put("highlightVideoUrl", r.getHighlightVideoUrl() != null ? fullUrl(r.getHighlightVideoUrl()) : null);
target.put("highlightVideoStatus", r.getHighlightVideoStatus());
target.put("highlightVideoError", r.getHighlightVideoError());
target.put("highlightDurationSec", r.getHighlightDurationSec());
}
/** 触发服务回顾短片生成(异步;依赖服务器 ffmpeg */
@PostMapping(value = "/highlight/start", produces = MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
public Map<String, Object> startHighlight(@RequestBody Map<String, Object> body) {
if (body.get("reportId") == null || body.get("operatorUserId") == null || body.get("role") == null) {
return Map.of("code", 400, "message", "缺少 reportId / operatorUserId / role");
}
Long reportId = Long.valueOf(body.get("reportId").toString());
Long operatorUserId = Long.valueOf(body.get("operatorUserId").toString());
String role = body.get("role").toString();
int durationSec = 15;
if (body.get("durationSec") != null) {
durationSec = Integer.parseInt(body.get("durationSec").toString());
}
return reportHighlightVideoService.requestGenerate(reportId, durationSec, operatorUserId, role);
} }
@PostMapping("/create") @PostMapping("/create")
public Map<String, Object> create(@RequestBody Report report) { public Map<String, Object> create(@RequestBody Report report) {
System.out.println(">>> Report create received: appointmentId=" + report.getAppointmentId() + ", userId=" + report.getUserId() + ", before=" + report.getBeforePhoto()); System.out.println(">>> Report create received: appointmentId=" + report.getAppointmentId() + ", userId=" + report.getUserId() + ", images count=" + (report.getImages() == null ? 0 : report.getImages().size()));
Report created = reportService.create(report); Report created = reportService.create(report);
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
@ -45,8 +90,22 @@ public class ReportController {
@GetMapping("/list") @GetMapping("/list")
public Map<String, Object> list(@RequestParam(required = false) Long storeId, public Map<String, Object> list(@RequestParam(required = false) Long storeId,
@RequestParam(required = false) Long userId) { @RequestParam(required = false) Long userId,
List<Report> reports = reportService.list(storeId, userId); @RequestParam(required = false) Integer page,
@RequestParam(required = false) Integer pageSize) {
boolean usePaging = page != null || pageSize != null;
List<Report> reports;
Page<Report> paged = null;
Integer pageNo = null;
Integer size = null;
if (usePaging) {
pageNo = Math.max(page == null ? 1 : page, 1);
size = Math.min(Math.max(pageSize == null ? 20 : pageSize, 1), 200);
paged = reportService.page(storeId, userId, pageNo - 1, size);
reports = paged.getContent();
} else {
reports = reportService.list(storeId, userId);
}
// 附加技师名称并补全图片URL // 附加技师名称并补全图片URL
List<Map<String, Object>> data = reports.stream().map(r -> { List<Map<String, Object>> data = reports.stream().map(r -> {
Map<String, Object> item = new HashMap<>(); Map<String, Object> item = new HashMap<>();
@ -58,12 +117,44 @@ public class ReportController {
item.put("staffName", r.getStaffName()); item.put("staffName", r.getStaffName());
item.put("reportToken", r.getReportToken()); item.put("reportToken", r.getReportToken());
item.put("createTime", r.getCreateTime()); item.put("createTime", r.getCreateTime());
item.put("beforePhoto", fullUrl(r.getBeforePhoto()));
item.put("afterPhoto", fullUrl(r.getAfterPhoto()));
item.put("storeId", r.getStoreId()); item.put("storeId", r.getStoreId());
item.put("userId", r.getUserId()); item.put("userId", r.getUserId());
// 图片列表
List<ReportImage> imgs = r.getImages();
List<String> beforePhotos = new ArrayList<>();
List<String> afterPhotos = new ArrayList<>();
List<Map<String, Object>> duringMedia = new ArrayList<>();
if (imgs != null) {
for (ReportImage img : imgs) {
if ("before".equals(img.getPhotoType())) {
beforePhotos.add(fullUrl(img.getPhotoUrl()));
} else if ("after".equals(img.getPhotoType())) {
afterPhotos.add(fullUrl(img.getPhotoUrl()));
} else if ("during".equals(img.getPhotoType())) {
Map<String, Object> m = new HashMap<>();
m.put("url", fullUrl(img.getPhotoUrl()));
m.put("mediaType", resolveMediaType(img));
duringMedia.add(m);
}
}
}
item.put("beforePhotos", beforePhotos);
item.put("afterPhotos", afterPhotos);
item.put("duringMedia", duringMedia);
putHighlightFields(item, r);
return item; return item;
}).collect(Collectors.toList()); }).collect(Collectors.toList());
if (usePaging && paged != null) {
return Map.of(
"code", 200,
"data", data,
"page", pageNo,
"pageSize", size,
"total", paged.getTotalElements(),
"totalPages", paged.getTotalPages(),
"hasNext", paged.hasNext()
);
}
return Map.of("code", 200, "data", data); return Map.of("code", 200, "data", data);
} }
@ -87,8 +178,28 @@ public class ReportController {
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("id", report.getId()); data.put("id", report.getId());
data.put("appointmentId", report.getAppointmentId()); data.put("appointmentId", report.getAppointmentId());
data.put("beforePhoto", report.getBeforePhoto()); // 图片列表
data.put("afterPhoto", report.getAfterPhoto()); List<ReportImage> imgs = report.getImages();
List<String> beforePhotos = new ArrayList<>();
List<String> afterPhotos = new ArrayList<>();
List<Map<String, Object>> duringMedia = new ArrayList<>();
if (imgs != null) {
for (ReportImage img : imgs) {
if ("before".equals(img.getPhotoType())) {
beforePhotos.add(fullUrl(img.getPhotoUrl()));
} else if ("after".equals(img.getPhotoType())) {
afterPhotos.add(fullUrl(img.getPhotoUrl()));
} else if ("during".equals(img.getPhotoType())) {
Map<String, Object> m = new HashMap<>();
m.put("url", fullUrl(img.getPhotoUrl()));
m.put("mediaType", resolveMediaType(img));
duringMedia.add(m);
}
}
}
data.put("beforePhotos", beforePhotos);
data.put("afterPhotos", afterPhotos);
data.put("duringMedia", duringMedia);
data.put("remark", report.getRemark()); data.put("remark", report.getRemark());
data.put("userId", report.getUserId()); data.put("userId", report.getUserId());
data.put("storeId", report.getStoreId()); data.put("storeId", report.getStoreId());
@ -106,6 +217,7 @@ public class ReportController {
storeInfo.put("address", store.getAddress()); storeInfo.put("address", store.getAddress());
data.put("store", storeInfo); data.put("store", storeInfo);
} }
putHighlightFields(data, report);
result.put("code", 200); result.put("code", 200);
result.put("data", data); result.put("data", data);
} else { } else {
@ -114,4 +226,13 @@ public class ReportController {
} }
return result; return result;
} }
@DeleteMapping("/delete")
public Map<String, Object> delete(@RequestParam Long id) {
boolean ok = reportService.softDelete(id);
if (!ok) {
return Map.of("code", 404, "message", "报告不存在");
}
return Map.of("code", 200, "message", "删除成功");
}
} }

View File

@ -0,0 +1,72 @@
package com.petstore.controller;
import com.petstore.service.ScheduleService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Map;
@RestController
@RequestMapping("/api/schedule")
@RequiredArgsConstructor
@CrossOrigin
public class ScheduleController {
private final ScheduleService scheduleService;
/** date 格式yyyy-MM-dd */
@GetMapping("/day")
public Map<String, Object> day(@RequestParam Long storeId, @RequestParam String date) {
LocalDate d;
try {
d = LocalDate.parse(date);
} catch (Exception e) {
return Map.of("code", 400, "message", "日期格式应为 yyyy-MM-dd");
}
return scheduleService.dayAgenda(storeId, d);
}
/**
* 手动占用半小时档<br>
* slotStartISO 本地时间 2026-04-17T10:00:00<br>
* blockTypewalk_in到店 / blocked暂停线上预约<br>
* createdByUserId当前登录用户 id与前端会话一致即可
*/
@PostMapping("/block")
public Map<String, Object> createBlock(@RequestBody Map<String, Object> body) {
Long storeId = parseLong(body.get("storeId"));
String slotStr = body.get("slotStart") != null ? body.get("slotStart").toString() : null;
String blockType = body.get("blockType") != null ? body.get("blockType").toString() : null;
String note = body.get("note") != null ? body.get("note").toString() : null;
Long createdBy = parseLong(body.get("createdByUserId"));
LocalDateTime slotStart;
try {
slotStart = LocalDateTime.parse(slotStr);
} catch (Exception e) {
return Map.of("code", 400, "message", "slotStart 格式无效");
}
return scheduleService.createBlock(storeId, slotStart, blockType, note, createdBy);
}
@DeleteMapping("/block")
public Map<String, Object> deleteBlock(@RequestParam Long id) {
return scheduleService.deleteBlock(id);
}
private static Long parseLong(Object o) {
if (o == null) {
return null;
}
if (o instanceof Number n) {
return n.longValue();
}
try {
return Long.valueOf(o.toString());
} catch (Exception e) {
return null;
}
}
}

View File

@ -17,8 +17,9 @@ public class ServiceTypeController {
private final ServiceTypeService serviceTypeService; private final ServiceTypeService serviceTypeService;
@GetMapping("/list") @GetMapping("/list")
public Map<String, Object> list(@RequestParam Long storeId) { public Map<String, Object> list(@RequestParam(required = false) Long storeId) {
List<ServiceType> list = serviceTypeService.getByStoreId(storeId); List<ServiceType> list =
storeId == null ? serviceTypeService.listSystemDefaultsOnly() : serviceTypeService.getByStoreId(storeId);
return Map.of("code", 200, "data", list); return Map.of("code", 200, "data", list);
} }

View File

@ -6,7 +6,9 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/store") @RequestMapping("/api/store")
@ -25,6 +27,26 @@ public class StoreController {
return result; return result;
} }
/**
* 客户预约门店列表不含邀请码等敏感字段
*/
@GetMapping("/list")
public Map<String, Object> list() {
List<Store> all = storeService.listAll();
List<Map<String, Object>> rows = all.stream().map(s -> {
Map<String, Object> m = new HashMap<>();
m.put("id", s.getId());
m.put("name", s.getName());
m.put("address", s.getAddress());
m.put("latitude", s.getLatitude());
m.put("longitude", s.getLongitude());
m.put("phone", s.getPhone());
m.put("logo", s.getLogo());
return m;
}).collect(Collectors.toList());
return Map.of("code", 200, "data", rows);
}
@GetMapping("/get") @GetMapping("/get")
public Map<String, Object> get(@RequestParam Long id) { public Map<String, Object> get(@RequestParam Long id) {
Store store = storeService.findById(id); Store store = storeService.findById(id);
@ -42,6 +64,9 @@ public class StoreController {
@PutMapping("/update") @PutMapping("/update")
public Map<String, Object> update(@RequestBody Store store) { public Map<String, Object> update(@RequestBody Store store) {
Store updated = storeService.update(store); Store updated = storeService.update(store);
if (updated == null) {
return Map.of("code", 404, "message", "店铺不存在");
}
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("code", 200); result.put("code", 200);
result.put("message", "更新成功"); result.put("message", "更新成功");
@ -49,6 +74,15 @@ public class StoreController {
return result; return result;
} }
@DeleteMapping("/delete")
public Map<String, Object> delete(@RequestParam Long id) {
boolean ok = storeService.softDelete(id);
if (!ok) {
return Map.of("code", 404, "message", "店铺不存在");
}
return Map.of("code", 200, "message", "删除成功");
}
@GetMapping("/invite-code") @GetMapping("/invite-code")
public Map<String, Object> getByInviteCode(@RequestParam String code) { public Map<String, Object> getByInviteCode(@RequestParam String code) {
Store store = storeService.findByInviteCode(code); Store store = storeService.findByInviteCode(code);

View File

@ -2,19 +2,23 @@ package com.petstore.controller;
import com.petstore.entity.User; import com.petstore.entity.User;
import com.petstore.service.UserService; import com.petstore.service.UserService;
import com.petstore.service.WechatMiniProgramService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@Slf4j
@RestController @RestController
@RequestMapping("/api/user") @RequestMapping("/api/user")
@RequiredArgsConstructor @RequiredArgsConstructor
@CrossOrigin @CrossOrigin
public class UserController { public class UserController {
private final UserService userService; private final UserService userService;
private final WechatMiniProgramService wechatMiniProgramService;
/** 老板注册店铺 */ /** 老板注册店铺 */
@PostMapping("/register-boss") @PostMapping("/register-boss")
@ -34,6 +38,36 @@ public class UserController {
return userService.login(phone, code); return userService.login(phone, code);
} }
/**
* 微信小程序button open-type=getPhoneNumber 拿到的 phoneCode 换手机号并登录
*/
@PostMapping("/wx-phone-login")
public Map<String, Object> wxPhoneLogin(@RequestBody(required = false) Map<String, String> params) {
try {
if (params == null) {
return Map.of("code", 400, "message", "请求体不能为空");
}
String phoneCode = params.get("phoneCode");
if (phoneCode == null || phoneCode.isBlank()) {
return Map.of("code", 400, "message", "缺少手机号授权码");
}
var wx = wechatMiniProgramService.exchangePhoneCode(phoneCode);
if (!wx.isOk()) {
Map<String, Object> err = new HashMap<>();
err.put("code", 400);
err.put("message", wx.errorMessage() != null ? wx.errorMessage() : "微信登录失败");
return err;
}
return userService.loginByVerifiedPhone(wx.phone());
} catch (Exception e) {
log.error("wx-phone-login 异常", e);
Map<String, Object> err = new HashMap<>();
err.put("code", 500);
err.put("message", "登录处理失败: " + e.getMessage());
return err;
}
}
/** 员工注册(邀请码方式) */ /** 员工注册(邀请码方式) */
@PostMapping("/register-staff") @PostMapping("/register-staff")
public Map<String, Object> registerStaff(@RequestBody Map<String, String> params) { public Map<String, Object> registerStaff(@RequestBody Map<String, String> params) {
@ -65,7 +99,10 @@ public class UserController {
/** 老板:删除员工 */ /** 老板:删除员工 */
@DeleteMapping("/staff") @DeleteMapping("/staff")
public Map<String, Object> deleteStaff(@RequestParam Long staffId) { public Map<String, Object> deleteStaff(@RequestParam Long staffId) {
userService.deleteStaff(staffId); boolean ok = userService.deleteStaff(staffId);
if (!ok) {
return Map.of("code", 404, "message", "员工不存在");
}
return Map.of("code", 200, "message", "删除成功"); return Map.of("code", 200, "message", "删除成功");
} }

View File

@ -6,7 +6,13 @@ import java.time.LocalDateTime;
@Data @Data
@Entity @Entity
@Table(name = "t_appointment") @Table(
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")
}
)
public class Appointment { public class Appointment {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@ -26,6 +32,10 @@ public class Appointment {
@Column(name = "user_id") @Column(name = "user_id")
private Long userId; private Long userId;
/** 关联宠物档案(可选,用于本店「服务过的宠物」统计) */
@Column(name = "pet_id")
private Long petId;
/** 技师ID开始服务时赋值 */ /** 技师ID开始服务时赋值 */
@Column(name = "assigned_user_id") @Column(name = "assigned_user_id")
private Long assignedUserId; private Long assignedUserId;
@ -37,4 +47,7 @@ public class Appointment {
@Column(name = "update_time") @Column(name = "update_time")
private LocalDateTime updateTime; private LocalDateTime updateTime;
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
private Boolean deleted = false;
} }

View File

@ -0,0 +1,46 @@
package com.petstore.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 宠物档案归属某客户商家/员工通过预约关联查看本店服务过的宠物
*/
@Data
@Entity
@Table(name = "t_pet")
public class Pet {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 昵称 */
private String name;
/** 类型:猫/狗/其他 */
@Column(name = "pet_type")
private String petType;
/** 品种(可选) */
private String breed;
/** 头像相对路径 */
private String avatar;
/** 所属用户(客户) */
@Column(name = "owner_user_id")
private Long ownerUserId;
private String remark;
@Column(name = "create_time")
private LocalDateTime createTime;
@Column(name = "update_time")
private LocalDateTime updateTime;
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
private Boolean deleted = false;
}

View File

@ -4,10 +4,19 @@ import jakarta.persistence.*;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Data @Data
@Entity @Entity
@Table(name = "t_report") @Table(
name = "t_report",
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")
}
)
public class Report { public class Report {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@ -17,14 +26,11 @@ public class Report {
@Column(name = "appointment_id") @Column(name = "appointment_id")
private Long appointmentId; private Long appointmentId;
@Column(name = "before_photo", columnDefinition = "TEXT")
private String beforePhoto;
@Column(name = "after_photo", columnDefinition = "TEXT")
private String afterPhoto;
private String remark; private String remark;
@Transient
private List<ReportImage> images = new ArrayList<>();
@Column(name = "user_id") @Column(name = "user_id")
private Long userId; private Long userId;
@ -44,4 +50,21 @@ public class Report {
@Column(name = "update_time") @Column(name = "update_time")
private LocalDateTime updateTime; private LocalDateTime updateTime;
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
private Boolean deleted = false;
/** 服务回顾短片(由服务前/中/后素材合成)相对 URL如 /api/upload/image/... */
@Column(name = "highlight_video_url", length = 500)
private String highlightVideoUrl;
/** processing | done | failed */
@Column(name = "highlight_video_status", length = 32)
private String highlightVideoStatus;
@Column(name = "highlight_video_error", length = 500)
private String highlightVideoError;
@Column(name = "highlight_duration_sec")
private Integer highlightDurationSec;
} }

View File

@ -0,0 +1,54 @@
package com.petstore.entity;
import jakarta.persistence.*;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "t_report_image")
public class ReportImage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "report_id", insertable = false, updatable = false)
@JsonIgnore
private Report report;
@Column(name = "report_id")
private Long reportId;
@Column(name = "photo_url", length = 500)
private String photoUrl;
/** 媒体分组before=服务前after=服务后during=服务过程中 */
@Column(name = "photo_type", length = 20)
private String photoType;
/** 媒体类型photo=图片video=视频 */
@Column(name = "media_type", length = 20)
private String mediaType;
/** 排序序号,同类型内按顺序展示 */
@Column(name = "sort_order")
private Integer sortOrder;
@Column(name = "create_time")
private LocalDateTime createTime;
@PrePersist
protected void onCreate() {
if (createTime == null) {
createTime = LocalDateTime.now();
}
if (sortOrder == null) {
sortOrder = 0;
}
if (mediaType == null || mediaType.isBlank()) {
mediaType = "photo";
}
}
}

View File

@ -0,0 +1,52 @@
package com.petstore.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 门店日程手动占用与线上预约一样占用半小时档用于到店客或暂停接受预约等
*/
@Data
@Entity
@Table(
name = "t_schedule_block",
indexes = {
@Index(name = "idx_sched_block_store_slot", columnList = "store_id,slot_start")
}
)
public class ScheduleBlock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
/** 该半小时档的起始时刻(与预约 appointment_time 对齐规则一致) */
@Column(name = "slot_start", nullable = false)
private LocalDateTime slotStart;
/**
* walk_in到店占用未走线上预约<br>
* blocked暂停预约外出等线上不可再约该档
*/
@Column(name = "block_type", nullable = false, length = 32)
private String blockType;
@Column(length = 500)
private String note;
@Column(name = "created_by_user_id")
private Long createdByUserId;
@Column(name = "create_time")
private LocalDateTime createTime;
@Column(name = "update_time")
private LocalDateTime updateTime;
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
private Boolean deleted = false;
}

View File

@ -6,7 +6,12 @@ import java.time.LocalDateTime;
@Data @Data
@Entity @Entity
@Table(name = "t_service_type") @Table(
name = "t_service_type",
indexes = {
@Index(name = "idx_service_type_store_id", columnList = "store_id")
}
)
public class ServiceType { public class ServiceType {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@ -2,11 +2,19 @@ package com.petstore.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalTime;
@Data @Data
@Entity @Entity
@Table(name = "t_store") @Table(
name = "t_store",
indexes = {
@Index(name = "idx_store_invite_code", columnList = "invite_code"),
@Index(name = "idx_store_owner_id", columnList = "owner_id")
}
)
public class Store { public class Store {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@ -28,9 +36,26 @@ public class Store {
@Column(name = "invite_code") @Column(name = "invite_code")
private String inviteCode; private String inviteCode;
/**
* 可预约每日首个半点号源起始时刻须为整点或半点
* null 时后端按默认 09:00 处理
*/
@Column(name = "booking_day_start")
private LocalTime bookingDayStart;
/**
* 可预约每日最后一个可约时段的起始时刻须为整点或半点且不早于 {@link #bookingDayStart}
* null 时后端按默认 21:30 处理
*/
@Column(name = "booking_last_slot_start")
private LocalTime bookingLastSlotStart;
@Column(name = "create_time") @Column(name = "create_time")
private LocalDateTime createTime; private LocalDateTime createTime;
@Column(name = "update_time") @Column(name = "update_time")
private LocalDateTime updateTime; private LocalDateTime updateTime;
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
private Boolean deleted = false;
} }

View File

@ -6,7 +6,13 @@ import java.time.LocalDateTime;
@Data @Data
@Entity @Entity
@Table(name = "t_user") @Table(
name = "t_user",
indexes = {
@Index(name = "idx_user_phone", columnList = "phone"),
@Index(name = "idx_user_store_id", columnList = "store_id")
}
)
public class User { public class User {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@ -29,4 +35,7 @@ public class User {
@Column(name = "update_time") @Column(name = "update_time")
private LocalDateTime updateTime; private LocalDateTime updateTime;
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
private Boolean deleted = false;
} }

View File

@ -1,13 +1,48 @@
package com.petstore.mapper; package com.petstore.mapper;
import com.petstore.entity.Appointment; import com.petstore.entity.Appointment;
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.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional;
public interface AppointmentMapper extends JpaRepository<Appointment, Long> { public interface AppointmentMapper extends JpaRepository<Appointment, Long> {
List<Appointment> findByUserId(Long userId); List<Appointment> findByUserIdAndDeletedFalse(Long userId);
List<Appointment> findByUserIdAndStatus(Long userId, String status); List<Appointment> findByUserIdAndStatusAndDeletedFalse(Long userId, String status);
List<Appointment> findByStoreId(Long storeId); List<Appointment> findByStoreIdAndDeletedFalse(Long storeId);
List<Appointment> findByStoreIdAndStatus(Long storeId, String status); List<Appointment> findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status);
Page<Appointment> findByUserIdAndDeletedFalse(Long userId, Pageable pageable);
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.appointmentTime FROM Appointment a WHERE a.storeId = :storeId AND a.deleted = false AND (a.status IS NULL OR a.status <> 'cancel') AND a.appointmentTime >= :start AND a.appointmentTime < :end")
List<LocalDateTime> findOccupiedAppointmentTimes(
@Param("storeId") Long storeId,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end
);
@Query("SELECT COUNT(a) > 0 FROM Appointment a WHERE a.storeId = :storeId AND a.appointmentTime = :t AND a.deleted = false AND (a.status IS NULL OR a.status <> 'cancel')")
boolean existsActiveBookingAt(@Param("storeId") Long storeId, @Param("t") LocalDateTime t);
/** 某日门店内有效预约(不含已取消),用于日程视图 */
@Query("SELECT a FROM Appointment a WHERE a.storeId = :storeId AND a.deleted = false "
+ "AND a.appointmentTime >= :start AND a.appointmentTime < :end "
+ "AND (a.status IS NULL OR a.status <> 'cancel') ORDER BY a.appointmentTime ASC")
List<Appointment> findActiveByStoreAndDateRange(
@Param("storeId") Long storeId,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/** 本店是否存在关联该宠物的预约(与 listByStoreServed 口径一致) */
@Query("SELECT COUNT(a) > 0 FROM Appointment a WHERE a.petId = :petId AND a.storeId = :storeId AND a.deleted = false")
boolean existsByPetIdAndStoreIdAndDeletedFalse(@Param("petId") Long petId, @Param("storeId") Long storeId);
} }

View File

@ -0,0 +1,19 @@
package com.petstore.mapper;
import com.petstore.entity.Pet;
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;
public interface PetMapper extends JpaRepository<Pet, Long> {
List<Pet> findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(Long ownerUserId);
/** 本店预约中关联过的宠物(预约.pet_id 指向该宠物) */
@Query("SELECT DISTINCT p FROM Pet p, Appointment a WHERE a.petId = p.id AND a.storeId = :storeId AND p.deleted = false AND a.deleted = false ORDER BY p.updateTime DESC")
List<Pet> findDistinctPetsServedAtStore(@Param("storeId") Long storeId);
Optional<Pet> findByIdAndDeletedFalse(Long id);
}

View File

@ -0,0 +1,16 @@
package com.petstore.mapper;
import com.petstore.entity.ReportImage;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ReportImageMapper extends JpaRepository<ReportImage, Long> {
List<ReportImage> findByReportIdOrderBySortOrderAscIdAsc(Long reportId);
List<ReportImage> findByReportIdAndPhotoTypeOrderBySortOrderAscIdAsc(Long reportId, String photoType);
void deleteByReportId(Long reportId);
List<ReportImage> findByReportIdInOrderByReportIdAscSortOrderAscIdAsc(List<Long> reportIds);
}

View File

@ -1,7 +1,32 @@
package com.petstore.mapper; package com.petstore.mapper;
import com.petstore.entity.Report; 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.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ReportMapper extends JpaRepository<Report, Long> { public interface ReportMapper extends JpaRepository<Report, Long> {
Optional<Report> findFirstByAppointmentIdAndDeletedFalseOrderByCreateTimeDesc(Long appointmentId);
Optional<Report> findFirstByReportTokenAndDeletedFalse(String reportToken);
List<Report> findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(Long storeId);
List<Report> findByUserIdAndDeletedFalseOrderByCreateTimeDesc(Long userId);
List<Report> findByStoreIdAndUserIdAndDeletedFalseOrderByCreateTimeDesc(Long storeId, Long userId);
List<Report> findAllByDeletedFalseOrderByCreateTimeDesc();
Page<Report> findByStoreIdAndDeletedFalse(Long storeId, Pageable pageable);
Page<Report> findByUserIdAndDeletedFalse(Long userId, Pageable pageable);
Page<Report> findByStoreIdAndUserIdAndDeletedFalse(Long storeId, Long userId, Pageable pageable);
Page<Report> findByDeletedFalse(Pageable pageable);
Optional<Report> findByIdAndDeletedFalse(Long id);
} }

View File

@ -0,0 +1,29 @@
package com.petstore.mapper;
import com.petstore.entity.ScheduleBlock;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface ScheduleBlockMapper extends JpaRepository<ScheduleBlock, Long> {
Optional<ScheduleBlock> findByIdAndDeletedFalse(Long id);
@Query("SELECT b.slotStart FROM ScheduleBlock b WHERE b.storeId = :storeId AND b.deleted = false "
+ "AND b.slotStart >= :start AND b.slotStart < :end")
List<LocalDateTime> findOccupiedSlotStarts(
@Param("storeId") Long storeId,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
boolean existsByStoreIdAndSlotStartAndDeletedFalse(Long storeId, LocalDateTime slotStart);
List<ScheduleBlock> findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc(
Long storeId,
LocalDateTime start,
LocalDateTime endExclusive);
}

View File

@ -8,4 +8,7 @@ import java.util.List;
public interface ServiceTypeMapper extends JpaRepository<ServiceType, Long> { public interface ServiceTypeMapper extends JpaRepository<ServiceType, Long> {
List<ServiceType> findByStoreIdOrStoreIdIsNull(Long storeId); List<ServiceType> findByStoreIdOrStoreIdIsNull(Long storeId);
List<ServiceType> findByStoreId(Long storeId); List<ServiceType> findByStoreId(Long storeId);
/** 系统内置store_id 为空) */
List<ServiceType> findByStoreIdIsNull();
} }

View File

@ -3,6 +3,11 @@ package com.petstore.mapper;
import com.petstore.entity.Store; import com.petstore.entity.Store;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface StoreMapper extends JpaRepository<Store, Long> { public interface StoreMapper extends JpaRepository<Store, Long> {
Store findByInviteCode(String inviteCode); Store findByInviteCodeAndDeletedFalse(String inviteCode);
Optional<Store> findByIdAndDeletedFalse(Long id);
List<Store> findAllByDeletedFalse();
} }

View File

@ -4,9 +4,11 @@ import com.petstore.entity.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
import java.util.Optional;
public interface UserMapper extends JpaRepository<User, Long> { public interface UserMapper extends JpaRepository<User, Long> {
User findByUsername(String username); User findByUsernameAndDeletedFalse(String username);
User findByPhone(String phone); User findByPhoneAndDeletedFalse(String phone);
List<User> findByStoreId(Long storeId); List<User> findByStoreIdAndDeletedFalse(Long storeId);
Optional<User> findByIdAndDeletedFalse(Long id);
} }

View File

@ -2,45 +2,183 @@ package com.petstore.service;
import com.petstore.entity.Appointment; import com.petstore.entity.Appointment;
import com.petstore.mapper.AppointmentMapper; import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.ScheduleBlockMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AppointmentService { public class AppointmentService {
private final AppointmentMapper appointmentMapper; private final AppointmentMapper appointmentMapper;
private final ScheduleBlockMapper scheduleBlockMapper;
private final StoreService storeService;
// 员工查看自己的预约 // 员工查看自己的预约
public List<Appointment> getByUserId(Long userId) { public List<Appointment> getByUserId(Long userId) {
return appointmentMapper.findByUserId(userId); return appointmentMapper.findByUserIdAndDeletedFalse(userId);
} }
// 老板查看本店所有预约 // 老板查看本店所有预约
public List<Appointment> getByStoreId(Long storeId) { public List<Appointment> getByStoreId(Long storeId) {
return appointmentMapper.findByStoreId(storeId); return appointmentMapper.findByStoreIdAndDeletedFalse(storeId);
} }
// 员工按状态查 // 员工按状态查
public List<Appointment> getByUserIdAndStatus(Long userId, String status) { public List<Appointment> getByUserIdAndStatus(Long userId, String status) {
return appointmentMapper.findByUserIdAndStatus(userId, status); return appointmentMapper.findByUserIdAndStatusAndDeletedFalse(userId, status);
} }
// 老板按状态查 // 老板按状态查
public List<Appointment> getByStoreIdAndStatus(Long storeId, String status) { public List<Appointment> getByStoreIdAndStatus(Long storeId, String status) {
return appointmentMapper.findByStoreIdAndStatus(storeId, status); return appointmentMapper.findByStoreIdAndStatusAndDeletedFalse(storeId, status);
} }
public Appointment create(Appointment appointment) { public Page<Appointment> pageByScope(Long userId, Long storeId, String status, int pageNo, int pageSize) {
Pageable pageable = PageRequest.of(
Math.max(pageNo, 0),
Math.max(pageSize, 1),
Sort.by(Sort.Direction.DESC, "appointmentTime")
);
boolean hasStatus = status != null && !status.isBlank();
if (storeId != null) {
return hasStatus
? appointmentMapper.findByStoreIdAndStatusAndDeletedFalse(storeId, status, pageable)
: appointmentMapper.findByStoreIdAndDeletedFalse(storeId, pageable);
}
if (userId != null) {
return hasStatus
? appointmentMapper.findByUserIdAndStatusAndDeletedFalse(userId, status, pageable)
: appointmentMapper.findByUserIdAndDeletedFalse(userId, pageable);
}
return Page.empty(pageable);
}
public Appointment getById(Long id) {
return appointmentMapper.findByIdAndDeletedFalse(id).orElse(null);
}
/**
* 某门店某日半小时预约档列表已占用 / 已过 / 可约
*/
public Map<String, Object> availableSlots(Long storeId, LocalDate date) {
if (storeId == null || date == null) {
return Map.of("code", 400, "message", "缺少门店或日期");
}
com.petstore.entity.Store store = storeService.findById(storeId);
if (store == null) {
return Map.of("code", 404, "message", "门店不存在");
}
StoreBookingWindow window = StoreBookingWindow.fromStore(store);
LocalDateTime dayStart = date.atStartOfDay();
LocalDateTime dayEnd = date.plusDays(1).atStartOfDay();
List<LocalDateTime> occupiedRaw = appointmentMapper.findOccupiedAppointmentTimes(storeId, dayStart, dayEnd);
Set<LocalDateTime> occupied = new HashSet<>();
for (LocalDateTime t : occupiedRaw) {
occupied.add(AppointmentSlotSupport.alignToHalfHour(t));
}
List<LocalDateTime> blockSlots = scheduleBlockMapper.findOccupiedSlotStarts(storeId, dayStart, dayEnd);
for (LocalDateTime t : blockSlots) {
occupied.add(AppointmentSlotSupport.alignToHalfHour(t));
}
LocalDateTime now = LocalDateTime.now();
List<Map<String, Object>> rows = new ArrayList<>();
for (LocalDateTime slotStart : AppointmentSlotSupport.allSlotStartsOnDay(date, window)) {
String hhmm = formatHhMm(slotStart);
boolean past = slotStart.isBefore(now);
boolean taken = occupied.contains(slotStart);
boolean available = !past && !taken;
String reason = null;
if (past) {
reason = "已过时段";
} else if (taken) {
reason = "已占用";
}
Map<String, Object> one = new LinkedHashMap<>();
one.put("time", hhmm);
one.put("available", available);
if (reason != null) {
one.put("reason", reason);
}
rows.add(one);
}
Map<String, Object> data = new HashMap<>();
data.put("slots", rows);
data.put("dayStart", window.dayStart().toString());
data.put("lastSlotStart", window.lastSlotStart().toString());
return Map.of("code", 200, "data", data);
}
private static String formatHhMm(LocalDateTime dt) {
return String.format("%02d:%02d", dt.getHour(), dt.getMinute());
}
/**
* 创建预约半小时占号取消({@code cancel})不占号
*/
@Transactional
public Map<String, Object> createBooking(Appointment appointment) {
if (appointment.getAppointmentTime() == null) {
return Map.of("code", 400, "message", "请填写预约时间");
}
LocalDateTime t = AppointmentSlotSupport.alignToHalfHour(appointment.getAppointmentTime());
appointment.setAppointmentTime(t);
if (appointment.getStoreId() == null) {
return Map.of("code", 400, "message", "缺少门店");
}
com.petstore.entity.Store store = storeService.findById(appointment.getStoreId());
if (store == null) {
return Map.of("code", 404, "message", "门店不存在");
}
StoreBookingWindow window = StoreBookingWindow.fromStore(store);
if (!AppointmentSlotSupport.isWithinBookableWindow(t, window)) {
return Map.of(
"code", 400,
"message",
String.format(
"仅可预约 %s%s 的半点档",
window.dayStart(),
window.lastSlotStart()
)
);
}
if (!t.isAfter(LocalDateTime.now())) {
return Map.of("code", 400, "message", "不能预约当前及已过去的时段");
}
if (appointmentMapper.existsActiveBookingAt(appointment.getStoreId(), t)) {
return Map.of("code", 409, "message", "该时段已被占用,请更换其它时段");
}
if (scheduleBlockMapper.existsByStoreIdAndSlotStartAndDeletedFalse(appointment.getStoreId(), t)) {
return Map.of("code", 409, "message", "该时段已被占用,请更换其它时段");
}
appointment.setCreateTime(LocalDateTime.now()); appointment.setCreateTime(LocalDateTime.now());
appointment.setUpdateTime(LocalDateTime.now()); appointment.setUpdateTime(LocalDateTime.now());
return appointmentMapper.save(appointment); appointment.setDeleted(false);
Appointment saved = appointmentMapper.save(appointment);
Map<String, Object> ok = new HashMap<>();
ok.put("code", 200);
ok.put("message", "创建成功");
ok.put("data", saved);
return ok;
} }
public Appointment updateStatus(Long id, String status) { public Appointment updateStatus(Long id, String status) {
Appointment appointment = appointmentMapper.findById(id).orElse(null); Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(id).orElse(null);
if (appointment != null) { if (appointment != null) {
appointment.setStatus(status); appointment.setStatus(status);
appointment.setUpdateTime(LocalDateTime.now()); appointment.setUpdateTime(LocalDateTime.now());
@ -51,7 +189,7 @@ public class AppointmentService {
/** 开始服务:状态变为进行中,同时指定技师为当前用户 */ /** 开始服务:状态变为进行中,同时指定技师为当前用户 */
public Appointment startService(Long appointmentId, Long staffUserId) { public Appointment startService(Long appointmentId, Long staffUserId) {
Appointment appointment = appointmentMapper.findById(appointmentId).orElse(null); Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(appointmentId).orElse(null);
if (appointment != null) { if (appointment != null) {
appointment.setStatus("doing"); appointment.setStatus("doing");
appointment.setAssignedUserId(staffUserId); appointment.setAssignedUserId(staffUserId);
@ -60,4 +198,15 @@ public class AppointmentService {
} }
return null; return null;
} }
public boolean softDelete(Long id) {
Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(id).orElse(null);
if (appointment == null) {
return false;
}
appointment.setDeleted(true);
appointment.setUpdateTime(LocalDateTime.now());
appointmentMapper.save(appointment);
return true;
}
} }

View File

@ -0,0 +1,50 @@
package com.petstore.service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
/**
* 预约时段半小时一档与前端 halfHourTime 对齐
*/
public final class AppointmentSlotSupport {
private AppointmentSlotSupport() {
}
/**
* 将时间对齐到半点档00 30 秒纳秒归零
*/
public static LocalDateTime alignToHalfHour(LocalDateTime dt) {
if (dt == null) {
return null;
}
int m = dt.getMinute();
int nm = m < 30 ? 0 : 30;
return dt.withMinute(nm).withSecond(0).withNano(0);
}
public static boolean isWithinBookableWindow(LocalDateTime slotStart, StoreBookingWindow window) {
if (slotStart == null || window == null) {
return false;
}
LocalTime t = slotStart.toLocalTime();
return !t.isBefore(window.dayStart()) && !t.isAfter(window.lastSlotStart());
}
/**
* 在给定可预约时间窗口内生成某日所有半点起始时刻含首尾
*/
public static List<LocalDateTime> allSlotStartsOnDay(LocalDate date, StoreBookingWindow window) {
List<LocalDateTime> list = new ArrayList<>();
LocalDateTime cur = LocalDateTime.of(date, window.dayStart());
LocalDateTime end = LocalDateTime.of(date, window.lastSlotStart());
while (!cur.isAfter(end)) {
list.add(cur);
cur = cur.plusMinutes(30);
}
return list;
}
}

View File

@ -0,0 +1,117 @@
package com.petstore.service;
import com.petstore.entity.Pet;
import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.PetMapper;
import com.petstore.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class PetService {
private final PetMapper petMapper;
private final UserMapper userMapper;
private final AppointmentMapper appointmentMapper;
/** 客户:仅自己的宠物 */
public List<Pet> listByOwner(Long ownerUserId) {
if (ownerUserId == null) {
return List.of();
}
return petMapper.findByOwnerUserIdAndDeletedFalseOrderByUpdateTimeDesc(ownerUserId);
}
/** 商家/员工:本店预约中关联过的宠物(预约带 pet_id */
public List<Pet> listByStoreServed(Long storeId) {
if (storeId == null) {
return List.of();
}
return petMapper.findDistinctPetsServedAtStore(storeId);
}
public Map<String, Object> create(Pet pet) {
if (pet.getName() == null || pet.getName().isBlank()) {
return Map.of("code", 400, "message", "请填写宠物名字");
}
if (pet.getPetType() == null || pet.getPetType().isBlank()) {
return Map.of("code", 400, "message", "请选择宠物类型");
}
if (pet.getOwnerUserId() == null) {
return Map.of("code", 400, "message", "缺少所属用户");
}
pet.setCreateTime(LocalDateTime.now());
pet.setUpdateTime(LocalDateTime.now());
pet.setDeleted(false);
Pet saved = petMapper.save(pet);
return Map.of("code", 200, "message", "保存成功", "data", saved);
}
public Map<String, Object> update(Long operatorUserId, String role, Pet input) {
if (input.getId() == null) {
return Map.of("code", 400, "message", "缺少宠物ID");
}
Optional<Pet> opt = petMapper.findByIdAndDeletedFalse(input.getId());
if (opt.isEmpty()) {
return Map.of("code", 404, "message", "宠物不存在");
}
Pet pet = opt.get();
if ("customer".equals(role)) {
if (!pet.getOwnerUserId().equals(operatorUserId)) {
return Map.of("code", 403, "message", "无权修改该宠物");
}
} else if ("boss".equals(role) || "staff".equals(role)) {
var uOpt = userMapper.findByIdAndDeletedFalse(operatorUserId);
if (uOpt.isEmpty()) {
return Map.of("code", 403, "message", "无权修改该宠物");
}
Long storeId = uOpt.get().getStoreId();
if (storeId == null) {
return Map.of("code", 403, "message", "无权修改该宠物");
}
if (!appointmentMapper.existsByPetIdAndStoreIdAndDeletedFalse(pet.getId(), storeId)) {
return Map.of("code", 403, "message", "仅可修改本店预约关联过的宠物");
}
} else {
return Map.of("code", 403, "message", "无权修改该宠物");
}
if (input.getName() != null && !input.getName().isBlank()) {
pet.setName(input.getName());
}
if (input.getPetType() != null && !input.getPetType().isBlank()) {
pet.setPetType(input.getPetType());
}
if (input.getBreed() != null) {
pet.setBreed(input.getBreed());
}
if (input.getAvatar() != null) {
pet.setAvatar(input.getAvatar());
}
if (input.getRemark() != null) {
pet.setRemark(input.getRemark());
}
pet.setUpdateTime(LocalDateTime.now());
petMapper.save(pet);
return Map.of("code", 200, "message", "更新成功", "data", pet);
}
public Map<String, Object> delete(Long petId, Long operatorUserId, String role) {
Optional<Pet> opt = petMapper.findByIdAndDeletedFalse(petId);
if (opt.isEmpty()) {
return Map.of("code", 404, "message", "宠物不存在");
}
Pet pet = opt.get();
if (!"customer".equals(role) || !pet.getOwnerUserId().equals(operatorUserId)) {
return Map.of("code", 403, "message", "无权删除");
}
pet.setDeleted(true);
pet.setUpdateTime(LocalDateTime.now());
petMapper.save(pet);
return Map.of("code", 200, "message", "已删除");
}
}

View File

@ -0,0 +1,428 @@
package com.petstore.service;
import com.petstore.entity.Appointment;
import com.petstore.entity.Report;
import com.petstore.entity.ReportImage;
import com.petstore.entity.User;
import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.ReportImageMapper;
import com.petstore.mapper.ReportMapper;
import com.petstore.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.InputStreamReader;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
/**
* 将服务报告中的服务前 / 过程中 / 服务后图片与短视频按时间线拼接为竖屏短片默认 15s 30s
* <p>依赖本机安装 {@code ffmpeg}后续可在此类接入云端生成式视频API与本地管线切换</p>
*/
@Service
public class ReportHighlightVideoService {
private static final int[] ALLOWED_DURATIONS = {15, 30};
private final ReportMapper reportMapper;
private final ReportImageMapper reportImageMapper;
private final UserMapper userMapper;
private final AppointmentMapper appointmentMapper;
private final Executor highlightExecutor;
@Value("${upload.path:uploads/}")
private String uploadPath;
@Value("${app.highlight-video.ffmpeg-binary:ffmpeg}")
private String ffmpegBinary;
private final ConcurrentHashMap<Long, Object> reportLocks = new ConcurrentHashMap<>();
public ReportHighlightVideoService(
ReportMapper reportMapper,
ReportImageMapper reportImageMapper,
UserMapper userMapper,
AppointmentMapper appointmentMapper,
@Qualifier("highlightTaskExecutor") Executor highlightExecutor) {
this.reportMapper = reportMapper;
this.reportImageMapper = reportImageMapper;
this.userMapper = userMapper;
this.appointmentMapper = appointmentMapper;
this.highlightExecutor = highlightExecutor;
}
private Object lockFor(Long reportId) {
return reportLocks.computeIfAbsent(reportId, k -> new Object());
}
public Map<String, Object> requestGenerate(Long reportId, int durationSec, Long operatorUserId, String role) {
if (reportId == null || operatorUserId == null || role == null || role.isBlank()) {
return Map.of("code", 400, "message", "参数不完整");
}
boolean okDur = false;
for (int d : ALLOWED_DURATIONS) {
if (d == durationSec) {
okDur = true;
break;
}
}
if (!okDur) {
return Map.of("code", 400, "message", "durationSec 仅支持 15 或 30");
}
synchronized (lockFor(reportId)) {
Optional<Report> opt = reportMapper.findByIdAndDeletedFalse(reportId);
if (opt.isEmpty()) {
return Map.of("code", 404, "message", "报告不存在");
}
Report report = opt.get();
if (!canOperate(operatorUserId, role, report)) {
return Map.of("code", 403, "message", "无权生成该报告的短片");
}
List<ReportImage> imgs = reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(reportId);
if (!hasRenderableMedia(imgs)) {
return Map.of("code", 400, "message", "请先上传服务前、过程中或服务后的照片或视频");
}
if ("processing".equals(report.getHighlightVideoStatus())) {
return Map.of("code", 409, "message", "短片正在生成中,请稍候刷新");
}
if (!ffmpegAvailable()) {
return Map.of("code", 503, "message", "服务器未安装或未配置 ffmpeg无法生成短片");
}
report.setHighlightVideoStatus("processing");
report.setHighlightVideoError(null);
report.setHighlightDurationSec(durationSec);
report.setUpdateTime(LocalDateTime.now());
reportMapper.save(report);
int d = durationSec;
highlightExecutor.execute(() -> composeSafely(reportId, d));
return Map.of("code", 200, "message", "已开始生成", "data", Map.of("status", "processing"));
}
}
private void composeSafely(Long reportId, int durationSec) {
try {
composeInternal(reportId, durationSec);
} catch (Exception e) {
markFailed(reportId, e.getMessage() != null ? e.getMessage() : "合成失败");
}
}
private void markFailed(Long reportId, String err) {
synchronized (lockFor(reportId)) {
reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> {
r.setHighlightVideoStatus("failed");
r.setHighlightVideoError(err != null && err.length() > 500 ? err.substring(0, 500) : err);
r.setUpdateTime(LocalDateTime.now());
reportMapper.save(r);
});
}
}
private void composeInternal(Long reportId, int durationSec) throws Exception {
Optional<Report> opt = reportMapper.findByIdAndDeletedFalse(reportId);
if (opt.isEmpty()) {
return;
}
List<ReportImage> imgs = sortedImages(reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(reportId));
List<Path> sources = new ArrayList<>();
List<Boolean> isVideo = new ArrayList<>();
for (ReportImage img : imgs) {
Path p = resolveUploadFile(img.getPhotoUrl());
if (p == null || !Files.isRegularFile(p)) {
continue;
}
boolean video = isVideoMedia(img);
sources.add(p);
isVideo.add(video);
}
if (sources.isEmpty()) {
markFailed(reportId, "素材文件在服务器上不存在或无法读取");
return;
}
int n = sources.size();
double perClip = (double) durationSec / n;
if (perClip < 0.35) {
perClip = 0.35;
}
Path workDir = Files.createTempDirectory("highlight_" + reportId + "_");
try {
List<Path> segments = new ArrayList<>();
for (int i = 0; i < n; i++) {
Path seg = workDir.resolve("seg_" + i + ".mp4");
if (Boolean.TRUE.equals(isVideo.get(i))) {
encodeVideoSegment(sources.get(i), seg, perClip);
} else {
encodeImageSegment(sources.get(i), seg, perClip);
}
segments.add(seg);
}
Path listFile = workDir.resolve("concat.txt");
try (BufferedWriter w = Files.newBufferedWriter(listFile, StandardCharsets.UTF_8)) {
for (Path seg : segments) {
String abs = seg.toAbsolutePath().toString().replace('\\', '/');
abs = abs.replace("'", "'\\''");
w.write("file '");
w.write(abs);
w.write("'\n");
}
}
Path merged = workDir.resolve("merged.mp4");
Path sourceToSave; // 最终用于保存的文件会在下面赋值
int c1 = runFfmpeg(List.of(
ffmpegBinary, "-y", "-f", "concat", "-safe", "0", "-i", listFile.toString(),
"-c", "copy", merged.toString()
));
if (c1 != 0) {
// stream copy 失败改用转码同时做尺寸 + Baseline Profile 标准化
int c2 = runFfmpeg(List.of(
ffmpegBinary, "-y", "-f", "concat", "-safe", "0", "-i", listFile.toString(),
"-vf", "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2",
"-c:v", "libx264", "-preset", "fast", "-crf", "22",
"-profile:v", "baseline", "-level", "3.0", // 最高兼容 WeChat/Android
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart", // MP4 streaming 优化微信要求
"-t", String.valueOf(durationSec),
merged.toString()
));
if (c2 != 0) {
throw new IllegalStateException("ffmpeg 拼接失败(退出码 " + c2 + "");
}
sourceToSave = merged; // 转码后文件已满足兼容性要求
} else {
sourceToSave = merged; // stream copy 成功直接用
}
String datePath = LocalDate.now().toString().replace("-", "/");
String base = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
Path outDir = Paths.get(base + datePath);
Files.createDirectories(outDir);
String filename = "highlight_" + reportId + "_" + UUID.randomUUID().toString().replace("-", "") + ".mp4";
Path finalPath = outDir.resolve(filename);
Files.copy(sourceToSave, finalPath, StandardCopyOption.REPLACE_EXISTING);
// 用完整 URL包含 https://api.s-good.com与前端 imgUrl() 逻辑一致
String url = "https://api.s-good.com/api/upload/image/" + datePath + "/" + filename;
synchronized (lockFor(reportId)) {
reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> {
r.setHighlightVideoUrl(url);
r.setHighlightVideoStatus("done");
r.setHighlightVideoError(null);
r.setHighlightDurationSec(durationSec);
r.setUpdateTime(LocalDateTime.now());
reportMapper.save(r);
});
}
} finally {
try {
Files.walk(workDir)
.sorted(Comparator.reverseOrder())
.forEach(p -> {
try {
Files.deleteIfExists(p);
} catch (Exception ignored) {
}
});
} catch (Exception ignored) {
}
}
}
private boolean hasRenderableMedia(List<ReportImage> imgs) {
if (imgs == null || imgs.isEmpty()) {
return false;
}
for (ReportImage img : imgs) {
Path p = resolveUploadFile(img.getPhotoUrl());
if (p != null && Files.isRegularFile(p)) {
return true;
}
}
return false;
}
private boolean ffmpegAvailable() {
try {
ProcessBuilder pb = new ProcessBuilder(ffmpegBinary, "-version");
pb.redirectErrorStream(true);
Process p = pb.start();
drain(p);
return p.waitFor() == 0;
} catch (Exception e) {
return false;
}
}
private int runFfmpeg(List<String> cmd) throws Exception {
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.redirectErrorStream(true);
Process p = pb.start();
drain(p);
return p.waitFor();
}
private void drain(Process p) throws Exception {
try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
while (r.readLine() != null) {
// discard
}
}
}
private void encodeImageSegment(Path image, Path out, double seconds) throws Exception {
String t = String.format(Locale.ROOT, "%.3f", seconds);
String vf = "scale=1080:1920:force_original_aspect_ratio=decrease,"
+ "pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p,fps=30";
int code = runFfmpeg(List.of(
ffmpegBinary, "-y",
"-loop", "1", "-framerate", "1", "-i", image.toString(),
"-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100",
"-vf", vf,
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
"-c:a", "aac", "-b:a", "96k",
"-pix_fmt", "yuv420p",
"-t", t,
out.toString()
));
if (code != 0) {
throw new IllegalStateException("图片转视频失败(退出码 " + code + "");
}
}
private void encodeVideoSegment(Path video, Path out, double seconds) throws Exception {
String t = String.format(Locale.ROOT, "%.3f", seconds);
String vf = "scale=1080:1920:force_original_aspect_ratio=decrease,"
+ "pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p,fps=30";
int code = runFfmpeg(List.of(
ffmpegBinary, "-y",
"-i", video.toString(),
"-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100",
"-vf", vf,
"-map", "0:v:0", "-map", "1:a:0",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
"-c:a", "aac", "-b:a", "96k",
"-pix_fmt", "yuv420p",
"-t", t,
out.toString()
));
if (code != 0) {
throw new IllegalStateException("视频片段重编码失败(退出码 " + code + "");
}
}
private List<ReportImage> sortedImages(List<ReportImage> imgs) {
if (imgs == null) {
return List.of();
}
List<ReportImage> copy = new ArrayList<>(imgs);
copy.sort(Comparator
.comparingInt((ReportImage i) -> typeOrder(i.getPhotoType()))
.thenComparingInt(i -> Optional.ofNullable(i.getSortOrder()).orElse(0))
.thenComparingLong(i -> Optional.ofNullable(i.getId()).orElse(0L)));
return copy;
}
private static int typeOrder(String photoType) {
if ("before".equals(photoType)) {
return 0;
}
if ("during".equals(photoType)) {
return 1;
}
if ("after".equals(photoType)) {
return 2;
}
return 3;
}
private boolean isVideoMedia(ReportImage img) {
String mt = img.getMediaType();
if (mt != null && mt.toLowerCase(Locale.ROOT).contains("video")) {
return true;
}
String u = img.getPhotoUrl();
if (u == null) {
return false;
}
String lower = u.toLowerCase(Locale.ROOT);
return lower.endsWith(".mp4") || lower.endsWith(".mov") || lower.endsWith(".m4v")
|| lower.endsWith(".webm") || lower.endsWith(".avi") || lower.endsWith(".mkv")
|| lower.endsWith(".3gp");
}
private Path resolveUploadFile(String photoUrl) {
if (photoUrl == null || photoUrl.isBlank()) {
return null;
}
String p = photoUrl.trim();
try {
if (p.startsWith("http://") || p.startsWith("https://")) {
URI uri = URI.create(p);
p = uri.getPath();
}
} catch (Exception ignored) {
}
String rel = null;
if (p.startsWith("/api/upload/image/")) {
rel = p.substring("/api/upload/image/".length());
} else if (p.startsWith("/api/upload/legacy/")) {
rel = p.substring("/api/upload/legacy/".length());
}
if (rel == null || rel.isBlank()) {
return null;
}
String rawBase = uploadPath.endsWith("/") ? uploadPath.substring(0, uploadPath.length() - 1) : uploadPath;
Path base = Paths.get(rawBase).toAbsolutePath().normalize();
Path full = base.resolve(rel).normalize();
if (!full.startsWith(base)) {
return null;
}
return full;
}
private boolean canOperate(Long operatorUserId, String role, Report report) {
String r = role == null ? "" : role.trim();
if ("boss".equals(r) || "staff".equals(r)) {
Optional<User> u = userMapper.findByIdAndDeletedFalse(operatorUserId);
if (u.isEmpty()) {
return false;
}
Long sid = u.get().getStoreId();
return sid != null && sid.equals(report.getStoreId());
}
if ("customer".equals(r)) {
Long apptId = report.getAppointmentId();
if (apptId == null) {
return false;
}
Optional<Appointment> ap = appointmentMapper.findByIdAndDeletedFalse(apptId);
return ap.isPresent() && operatorUserId.equals(ap.get().getUserId());
}
return false;
}
}

View File

@ -1,23 +1,23 @@
package com.petstore.service; package com.petstore.service;
import com.petstore.entity.Appointment; import com.petstore.entity.*;
import com.petstore.entity.Report; import com.petstore.mapper.*;
import com.petstore.entity.User;
import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.ReportMapper;
import com.petstore.mapper.UserMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class ReportService { public class ReportService {
private final ReportMapper reportMapper; private final ReportMapper reportMapper;
private final ReportImageMapper reportImageMapper;
private final AppointmentMapper appointmentMapper; private final AppointmentMapper appointmentMapper;
private final UserMapper userMapper; private final UserMapper userMapper;
@ -27,7 +27,7 @@ public class ReportService {
// 填充冗余字段并自动完成预约 // 填充冗余字段并自动完成预约
if (report.getAppointmentId() != null) { if (report.getAppointmentId() != null) {
Appointment appt = appointmentMapper.findById(report.getAppointmentId()).orElse(null); Appointment appt = appointmentMapper.findByIdAndDeletedFalse(report.getAppointmentId()).orElse(null);
if (appt != null) { if (appt != null) {
report.setPetName(appt.getPetName()); report.setPetName(appt.getPetName());
report.setServiceType(appt.getServiceType()); report.setServiceType(appt.getServiceType());
@ -35,7 +35,7 @@ public class ReportService {
report.setStoreId(appt.getStoreId()); report.setStoreId(appt.getStoreId());
// 技师取预约分配的技师开始服务时指定的 // 技师取预约分配的技师开始服务时指定的
if (appt.getAssignedUserId() != null) { if (appt.getAssignedUserId() != null) {
User staff = userMapper.findById(appt.getAssignedUserId()).orElse(null); User staff = userMapper.findByIdAndDeletedFalse(appt.getAssignedUserId()).orElse(null);
if (staff != null) { if (staff != null) {
report.setUserId(staff.getId()); report.setUserId(staff.getId());
report.setStaffName(staff.getName()); report.setStaffName(staff.getName());
@ -49,7 +49,7 @@ public class ReportService {
} }
// 如果预约没分配技师则用当前操作人 // 如果预约没分配技师则用当前操作人
if (report.getUserId() != null && report.getStaffName() == null) { if (report.getUserId() != null && report.getStaffName() == null) {
User staff = userMapper.findById(report.getUserId()).orElse(null); User staff = userMapper.findByIdAndDeletedFalse(report.getUserId()).orElse(null);
if (staff != null) { if (staff != null) {
report.setStaffName(staff.getName()); report.setStaffName(staff.getName());
if (report.getStoreId() == null) { if (report.getStoreId() == null) {
@ -60,27 +60,100 @@ public class ReportService {
report.setCreateTime(LocalDateTime.now()); report.setCreateTime(LocalDateTime.now());
report.setUpdateTime(LocalDateTime.now()); report.setUpdateTime(LocalDateTime.now());
return reportMapper.save(report); report.setDeleted(false);
// 先保存 Report 以获取生成的 ID
Report saved = reportMapper.save(report);
// 处理图片列表设置 reportId 后通过 reportImageMapper 保存
if (saved.getImages() != null && !saved.getImages().isEmpty()) {
for (ReportImage img : saved.getImages()) {
img.setReportId(saved.getId());
reportImageMapper.save(img);
}
}
return saved;
}
/** 查询单条报告并填充图片 */
private Report enrichImages(Report r) {
if (r == null) return null;
List<ReportImage> imgs = reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(r.getId());
r.setImages(imgs);
return r;
}
/** 批量填充图片(列表用) */
private List<Report> enrichImages(List<Report> reports) {
if (reports == null || reports.isEmpty()) return reports;
List<Long> ids = reports.stream().map(Report::getId).toList();
List<ReportImage> allImages = reportImageMapper.findByReportIdInOrderByReportIdAscSortOrderAscIdAsc(ids);
java.util.Map<Long, List<ReportImage>> map = allImages.stream()
.collect(java.util.stream.Collectors.groupingBy(ReportImage::getReportId));
for (Report r : reports) {
r.setImages(map.getOrDefault(r.getId(), java.util.List.of()));
}
return reports;
} }
public Report getByAppointmentId(Long appointmentId) { public Report getByAppointmentId(Long appointmentId) {
return reportMapper.findAll().stream() if (appointmentId == null) {
.filter(r -> r.getAppointmentId().equals(appointmentId)) return null;
.findFirst() }
.orElse(null); return enrichImages(reportMapper.findFirstByAppointmentIdAndDeletedFalseOrderByCreateTimeDesc(appointmentId).orElse(null));
} }
public Report getByToken(String token) { public Report getByToken(String token) {
return reportMapper.findAll().stream() if (token == null || token.isBlank()) {
.filter(r -> token.equals(r.getReportToken())) return null;
.findFirst() }
.orElse(null); return enrichImages(reportMapper.findFirstByReportTokenAndDeletedFalse(token).orElse(null));
} }
public List<Report> list(Long storeId, Long userId) { public List<Report> list(Long storeId, Long userId) {
return reportMapper.findAll().stream() List<Report> reports;
.filter(r -> storeId == null || storeId.equals(r.getStoreId())) if (storeId != null && userId != null) {
.filter(r -> userId == null || userId.equals(r.getUserId())) reports = reportMapper.findByStoreIdAndUserIdAndDeletedFalseOrderByCreateTimeDesc(storeId, userId);
.collect(Collectors.toList()); } else if (storeId != null) {
reports = reportMapper.findByStoreIdAndDeletedFalseOrderByCreateTimeDesc(storeId);
} else if (userId != null) {
reports = reportMapper.findByUserIdAndDeletedFalseOrderByCreateTimeDesc(userId);
} else {
reports = reportMapper.findAllByDeletedFalseOrderByCreateTimeDesc();
}
return enrichImages(reports);
}
public Page<Report> page(Long storeId, Long userId, 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);
} else if (storeId != null) {
paged = reportMapper.findByStoreIdAndDeletedFalse(storeId, pageable);
} else if (userId != null) {
paged = reportMapper.findByUserIdAndDeletedFalse(userId, pageable);
} else {
paged = reportMapper.findByDeletedFalse(pageable);
}
// 填充图片
enrichImages(paged.getContent());
return paged;
}
public boolean softDelete(Long id) {
Report report = reportMapper.findByIdAndDeletedFalse(id).orElse(null);
if (report == null) {
return false;
}
report.setDeleted(true);
report.setUpdateTime(LocalDateTime.now());
reportMapper.save(report);
return true;
} }
} }

View File

@ -0,0 +1,174 @@
package com.petstore.service;
import com.petstore.entity.Appointment;
import com.petstore.entity.ScheduleBlock;
import com.petstore.entity.Store;
import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.ScheduleBlockMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class ScheduleService {
private final ScheduleBlockMapper scheduleBlockMapper;
private final AppointmentMapper appointmentMapper;
private final StoreService storeService;
public static boolean isAllowedBlockType(String blockType) {
return "walk_in".equals(blockType) || "blocked".equals(blockType);
}
/**
* 某日门店日程按营业时间生成半点行挂载预约或手动占用
*/
public Map<String, Object> dayAgenda(Long storeId, LocalDate date) {
if (storeId == null || date == null) {
return Map.of("code", 400, "message", "缺少门店或日期");
}
Store store = storeService.findById(storeId);
if (store == null) {
return Map.of("code", 404, "message", "门店不存在");
}
StoreBookingWindow window = StoreBookingWindow.fromStore(store);
LocalDateTime rangeStart = date.atStartOfDay();
LocalDateTime rangeEnd = date.plusDays(1).atStartOfDay();
List<Appointment> appointments = appointmentMapper.findActiveByStoreAndDateRange(storeId, rangeStart, rangeEnd);
Map<LocalDateTime, Appointment> apptBySlot = new LinkedHashMap<>();
for (Appointment a : appointments) {
LocalDateTime slot = AppointmentSlotSupport.alignToHalfHour(a.getAppointmentTime());
if (slot != null) {
apptBySlot.putIfAbsent(slot, a);
}
}
List<ScheduleBlock> blocks =
scheduleBlockMapper.findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc(
storeId, rangeStart, rangeEnd);
Map<LocalDateTime, ScheduleBlock> blockBySlot = new LinkedHashMap<>();
for (ScheduleBlock b : blocks) {
LocalDateTime slot = AppointmentSlotSupport.alignToHalfHour(b.getSlotStart());
if (slot != null) {
blockBySlot.putIfAbsent(slot, b);
}
}
LocalDateTime now = LocalDateTime.now();
List<Map<String, Object>> rows = new ArrayList<>();
for (LocalDateTime slotStart : AppointmentSlotSupport.allSlotStartsOnDay(date, window)) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("time", formatHhMm(slotStart));
row.put("slotStart", slotStart.toString());
row.put("past", slotStart.isBefore(now));
Appointment appt = apptBySlot.get(slotStart);
ScheduleBlock block = blockBySlot.get(slotStart);
if (appt != null) {
row.put("kind", "appointment");
row.put("appointment", appt);
row.put("block", null);
} else if (block != null) {
row.put("kind", "block");
row.put("appointment", null);
row.put("block", block);
} else {
row.put("kind", "empty");
row.put("appointment", null);
row.put("block", null);
}
rows.add(row);
}
Map<String, Object> data = new HashMap<>();
data.put("date", date.toString());
data.put("dayStart", window.dayStart().toString());
data.put("lastSlotStart", window.lastSlotStart().toString());
data.put("rows", rows);
return Map.of("code", 200, "data", data);
}
@Transactional
public Map<String, Object> createBlock(
Long storeId,
LocalDateTime slotStartRaw,
String blockType,
String note,
Long createdByUserId) {
if (storeId == null) {
return Map.of("code", 400, "message", "缺少门店");
}
if (slotStartRaw == null) {
return Map.of("code", 400, "message", "缺少时段");
}
if (!isAllowedBlockType(blockType)) {
return Map.of("code", 400, "message", "blockType 须为 walk_in 或 blocked");
}
Store store = storeService.findById(storeId);
if (store == null) {
return Map.of("code", 404, "message", "门店不存在");
}
LocalDateTime slot = AppointmentSlotSupport.alignToHalfHour(slotStartRaw);
StoreBookingWindow window = StoreBookingWindow.fromStore(store);
if (!AppointmentSlotSupport.isWithinBookableWindow(slot, window)) {
return Map.of(
"code",
400,
"message",
String.format("仅可在 %s%s 的半点档内占用", window.dayStart(), window.lastSlotStart())
);
}
if (appointmentMapper.existsActiveBookingAt(storeId, slot)) {
return Map.of("code", 409, "message", "该时段已有客户预约,无法占用");
}
if (scheduleBlockMapper.existsByStoreIdAndSlotStartAndDeletedFalse(storeId, slot)) {
return Map.of("code", 409, "message", "该时段已被占用");
}
ScheduleBlock b = new ScheduleBlock();
b.setStoreId(storeId);
b.setSlotStart(slot);
b.setBlockType(blockType);
b.setNote(note != null && !note.isBlank() ? note.trim() : null);
b.setCreatedByUserId(createdByUserId);
LocalDateTime n = LocalDateTime.now();
b.setCreateTime(n);
b.setUpdateTime(n);
b.setDeleted(false);
ScheduleBlock saved = scheduleBlockMapper.save(b);
Map<String, Object> ok = new HashMap<>();
ok.put("code", 200);
ok.put("message", "已占用该时段");
ok.put("data", saved);
return ok;
}
@Transactional
public Map<String, Object> deleteBlock(Long id) {
if (id == null) {
return Map.of("code", 400, "message", "缺少 id");
}
ScheduleBlock b = scheduleBlockMapper.findByIdAndDeletedFalse(id).orElse(null);
if (b == null) {
return Map.of("code", 404, "message", "记录不存在");
}
b.setDeleted(true);
b.setUpdateTime(LocalDateTime.now());
scheduleBlockMapper.save(b);
return Map.of("code", 200, "message", "已取消占用");
}
private static String formatHhMm(LocalDateTime dt) {
return String.format("%02d:%02d", dt.getHour(), dt.getMinute());
}
}

View File

@ -18,6 +18,11 @@ public class ServiceTypeService {
return serviceTypeMapper.findByStoreIdOrStoreIdIsNull(storeId); return serviceTypeMapper.findByStoreIdOrStoreIdIsNull(storeId);
} }
/** 仅系统默认(未传门店或 C 端尚未绑定门店时用于展示可选名称) */
public List<ServiceType> listSystemDefaultsOnly() {
return serviceTypeMapper.findByStoreIdIsNull();
}
/** 老板新增服务类型 */ /** 老板新增服务类型 */
public ServiceType create(Long storeId, String name) { public ServiceType create(Long storeId, String name) {
ServiceType st = new ServiceType(); ServiceType st = new ServiceType();

View File

@ -0,0 +1,36 @@
package com.petstore.service;
import com.petstore.entity.Store;
import java.time.LocalTime;
/**
* 门店线上可预约时间窗口半小时一档的首末时刻
*/
public record StoreBookingWindow(LocalTime dayStart, LocalTime lastSlotStart) {
public static final LocalTime DEFAULT_DAY_START = LocalTime.of(9, 0);
public static final LocalTime DEFAULT_LAST_SLOT_START = LocalTime.of(21, 30);
public static StoreBookingWindow fromStore(Store store) {
LocalTime a = (store != null && store.getBookingDayStart() != null)
? snapHalfHour(store.getBookingDayStart())
: DEFAULT_DAY_START;
LocalTime b = (store != null && store.getBookingLastSlotStart() != null)
? snapHalfHour(store.getBookingLastSlotStart())
: DEFAULT_LAST_SLOT_START;
if (b.isBefore(a)) {
b = a;
}
return new StoreBookingWindow(a, b);
}
public static LocalTime snapHalfHour(LocalTime t) {
if (t == null) {
return DEFAULT_DAY_START;
}
int m = t.getMinute();
int nm = m < 30 ? 0 : 30;
return LocalTime.of(t.getHour(), nm);
}
}

View File

@ -6,6 +6,8 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import java.util.UUID; import java.util.UUID;
@Service @Service
@ -17,20 +19,87 @@ public class StoreService {
store.setInviteCode(generateInviteCode()); store.setInviteCode(generateInviteCode());
store.setCreateTime(LocalDateTime.now()); store.setCreateTime(LocalDateTime.now());
store.setUpdateTime(LocalDateTime.now()); store.setUpdateTime(LocalDateTime.now());
store.setDeleted(false);
return storeMapper.save(store); return storeMapper.save(store);
} }
public Store findById(Long id) { public Store findById(Long id) {
return storeMapper.findById(id).orElse(null); return storeMapper.findByIdAndDeletedFalse(id).orElse(null);
} }
public Store findByInviteCode(String code) { public Store findByInviteCode(String code) {
return storeMapper.findByInviteCode(code); return storeMapper.findByInviteCodeAndDeletedFalse(code);
} }
public Store update(Store store) { /** C 端预约:列出全部门店供选择 */
public List<Store> listAll() {
return storeMapper.findAllByDeletedFalse();
}
public Store update(Store incoming) {
if (incoming == null || incoming.getId() == null) {
return null;
}
Store existing = storeMapper.findByIdAndDeletedFalse(incoming.getId()).orElse(null);
if (existing == null) {
return null;
}
if (incoming.getName() != null) {
existing.setName(incoming.getName());
}
if (incoming.getPhone() != null) {
existing.setPhone(incoming.getPhone());
}
if (incoming.getAddress() != null) {
existing.setAddress(incoming.getAddress());
}
if (incoming.getIntro() != null) {
existing.setIntro(incoming.getIntro());
}
if (incoming.getLatitude() != null) {
existing.setLatitude(incoming.getLatitude());
}
if (incoming.getLongitude() != null) {
existing.setLongitude(incoming.getLongitude());
}
if (incoming.getLogo() != null) {
existing.setLogo(incoming.getLogo());
}
LocalTime start = existing.getBookingDayStart() != null
? StoreBookingWindow.snapHalfHour(existing.getBookingDayStart())
: StoreBookingWindow.DEFAULT_DAY_START;
LocalTime last = existing.getBookingLastSlotStart() != null
? StoreBookingWindow.snapHalfHour(existing.getBookingLastSlotStart())
: StoreBookingWindow.DEFAULT_LAST_SLOT_START;
if (incoming.getBookingDayStart() != null) {
start = StoreBookingWindow.snapHalfHour(incoming.getBookingDayStart());
}
if (incoming.getBookingLastSlotStart() != null) {
last = StoreBookingWindow.snapHalfHour(incoming.getBookingLastSlotStart());
}
if (last.isBefore(start)) {
throw new IllegalArgumentException("预约「末号」必须不早于「首号」");
}
existing.setBookingDayStart(start);
existing.setBookingLastSlotStart(last);
existing.setUpdateTime(LocalDateTime.now());
if (existing.getDeleted() == null) {
existing.setDeleted(false);
}
return storeMapper.save(existing);
}
public boolean softDelete(Long id) {
Store store = storeMapper.findByIdAndDeletedFalse(id).orElse(null);
if (store == null) {
return false;
}
store.setDeleted(true);
store.setUpdateTime(LocalDateTime.now()); store.setUpdateTime(LocalDateTime.now());
return storeMapper.save(store); storeMapper.save(store);
return true;
} }
private String generateInviteCode() { private String generateInviteCode() {

View File

@ -17,7 +17,7 @@ public class UserService {
private final StoreMapper storeMapper; private final StoreMapper storeMapper;
public Map<String, Object> registerBoss(String storeName, String bossName, String phone, String password) { public Map<String, Object> registerBoss(String storeName, String bossName, String phone, String password) {
if (userMapper.findByPhone(phone) != null) { if (userMapper.findByPhoneAndDeletedFalse(phone) != null) {
return Map.of("code", 400, "message", "手机号已注册"); return Map.of("code", 400, "message", "手机号已注册");
} }
@ -28,6 +28,7 @@ public class UserService {
store.setInviteCode(UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase()); store.setInviteCode(UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase());
store.setCreateTime(LocalDateTime.now()); store.setCreateTime(LocalDateTime.now());
store.setUpdateTime(LocalDateTime.now()); store.setUpdateTime(LocalDateTime.now());
store.setDeleted(false);
store = storeMapper.save(store); store = storeMapper.save(store);
User boss = new User(); User boss = new User();
@ -39,6 +40,7 @@ public class UserService {
boss.setRole("boss"); boss.setRole("boss");
boss.setCreateTime(LocalDateTime.now()); boss.setCreateTime(LocalDateTime.now());
boss.setUpdateTime(LocalDateTime.now()); boss.setUpdateTime(LocalDateTime.now());
boss.setDeleted(false);
boss = userMapper.save(boss); boss = userMapper.save(boss);
store.setOwnerId(boss.getId()); store.setOwnerId(boss.getId());
@ -55,21 +57,32 @@ public class UserService {
if (!"123456".equals(code)) { if (!"123456".equals(code)) {
return Map.of("code", 401, "message", "验证码错误"); return Map.of("code", 401, "message", "验证码错误");
} }
User user = userMapper.findByPhone(phone); return loginByVerifiedPhone(phone);
}
/**
* 已通过短信或微信授权校验后的手机号登录与验证码登录成功后的逻辑一致
*/
public Map<String, Object> loginByVerifiedPhone(String phone) {
if (phone == null || !phone.matches("^1\\d{10}$")) {
return Map.of("code", 400, "message", "手机号格式不正确");
}
User user = userMapper.findByPhoneAndDeletedFalse(phone);
if (user == null) { if (user == null) {
// 自动注册为 C 端用户 (customer)
user = new User(); user = new User();
user.setUsername(phone); user.setUsername(phone);
user.setPhone(phone); user.setPhone(phone);
user.setName("微信用户" + phone.substring(7)); user.setName("微信用户" + phone.substring(7));
user.setPassword("wx_no_password");
user.setRole("customer"); user.setRole("customer");
user.setCreateTime(LocalDateTime.now()); user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now()); user.setUpdateTime(LocalDateTime.now());
user.setDeleted(false);
user = userMapper.save(user); user = userMapper.save(user);
} }
Store store = null; Store store = null;
if (user.getStoreId() != null) { if (user.getStoreId() != null) {
store = storeMapper.findById(user.getStoreId()).orElse(null); store = storeMapper.findByIdAndDeletedFalse(user.getStoreId()).orElse(null);
} }
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("user", user); data.put("user", user);
@ -78,10 +91,10 @@ public class UserService {
} }
public Map<String, Object> registerStaff(String phone, String password, String name, String inviteCode) { public Map<String, Object> registerStaff(String phone, String password, String name, String inviteCode) {
if (userMapper.findByPhone(phone) != null) { if (userMapper.findByPhoneAndDeletedFalse(phone) != null) {
return Map.of("code", 400, "message", "手机号已注册"); return Map.of("code", 400, "message", "手机号已注册");
} }
Store store = storeMapper.findByInviteCode(inviteCode); Store store = storeMapper.findByInviteCodeAndDeletedFalse(inviteCode);
if (store == null) { if (store == null) {
return Map.of("code", 400, "message", "邀请码无效"); return Map.of("code", 400, "message", "邀请码无效");
} }
@ -94,6 +107,7 @@ public class UserService {
staff.setRole("staff"); staff.setRole("staff");
staff.setCreateTime(LocalDateTime.now()); staff.setCreateTime(LocalDateTime.now());
staff.setUpdateTime(LocalDateTime.now()); staff.setUpdateTime(LocalDateTime.now());
staff.setDeleted(false);
staff = userMapper.save(staff); staff = userMapper.save(staff);
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("user", staff); data.put("user", staff);
@ -105,7 +119,7 @@ public class UserService {
if (storeId == null) { if (storeId == null) {
return Map.of("code", 400, "message", "店铺ID不能为空"); return Map.of("code", 400, "message", "店铺ID不能为空");
} }
if (userMapper.findByPhone(phone) != null) { if (userMapper.findByPhoneAndDeletedFalse(phone) != null) {
return Map.of("code", 400, "message", "手机号已存在"); return Map.of("code", 400, "message", "手机号已存在");
} }
String pwd = String.format("%06d", (int)(Math.random() * 999999)); String pwd = String.format("%06d", (int)(Math.random() * 999999));
@ -118,25 +132,33 @@ public class UserService {
staff.setRole("staff"); staff.setRole("staff");
staff.setCreateTime(LocalDateTime.now()); staff.setCreateTime(LocalDateTime.now());
staff.setUpdateTime(LocalDateTime.now()); staff.setUpdateTime(LocalDateTime.now());
staff.setDeleted(false);
staff = userMapper.save(staff); staff = userMapper.save(staff);
return Map.of("code", 200, "message", "创建成功,初始密码:" + pwd, "data", staff); return Map.of("code", 200, "message", "创建成功,初始密码:" + pwd, "data", staff);
} }
public List<User> getStaffList(Long storeId) { public List<User> getStaffList(Long storeId) {
return userMapper.findByStoreId(storeId); return userMapper.findByStoreIdAndDeletedFalse(storeId);
} }
public void deleteStaff(Long staffId) { public boolean deleteStaff(Long staffId) {
userMapper.deleteById(staffId); User user = userMapper.findByIdAndDeletedFalse(staffId).orElse(null);
if (user == null) {
return false;
}
user.setDeleted(true);
user.setUpdateTime(LocalDateTime.now());
userMapper.save(user);
return true;
} }
public User findById(Long id) { public User findById(Long id) {
return userMapper.findById(id).orElse(null); return userMapper.findByIdAndDeletedFalse(id).orElse(null);
} }
public Map<String, Object> updateUser(Map<String, Object> params) { public Map<String, Object> updateUser(Map<String, Object> params) {
Long userId = Long.valueOf(params.get("id").toString()); Long userId = Long.valueOf(params.get("id").toString());
User user = userMapper.findById(userId).orElse(null); User user = userMapper.findByIdAndDeletedFalse(userId).orElse(null);
if (user == null) { if (user == null) {
return Map.of("code", 404, "message", "用户不存在"); return Map.of("code", 404, "message", "用户不存在");
} }
@ -151,7 +173,7 @@ public class UserService {
return Map.of("code", 400, "message", "验证码错误"); return Map.of("code", 400, "message", "验证码错误");
} }
// 检查手机号是否被占用 // 检查手机号是否被占用
User existing = userMapper.findByPhone(newPhone); User existing = userMapper.findByPhoneAndDeletedFalse(newPhone);
if (existing != null && !existing.getId().equals(userId)) { if (existing != null && !existing.getId().equals(userId)) {
return Map.of("code", 400, "message", "手机号已被占用"); return Map.of("code", 400, "message", "手机号已被占用");
} }

View File

@ -0,0 +1,250 @@
package com.petstore.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.petstore.config.WechatConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
/**
* 微信小程序通过 getPhoneNumber 返回的 code 换取用户手机号
* 使用 JDK HttpClient 直连微信避免部分环境下 RestTemplate 对响应解析为空
* 文档https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/phonenumber/phonenumber.getPhoneNumber.html
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class WechatMiniProgramService {
private final WechatConfig wechatConfig;
private final ObjectMapper objectMapper = new ObjectMapper();
/** 部分代理/网关对 HTTP/2 支持差,固定 HTTP/1.1 更稳 */
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.NORMAL)
.version(HttpClient.Version.HTTP_1_1)
.build();
private volatile String cachedAccessToken;
private volatile long tokenExpiresAtEpochMs;
public record WxPhoneExchangeResult(String phone, String errorMessage) {
public static WxPhoneExchangeResult ok(String phone) {
return new WxPhoneExchangeResult(phone, null);
}
public static WxPhoneExchangeResult fail(String errorMessage) {
String msg = (errorMessage != null && !errorMessage.isBlank()) ? errorMessage : "未知错误";
return new WxPhoneExchangeResult(null, msg);
}
public boolean isOk() {
return phone != null && !phone.isBlank();
}
}
private boolean isConfigured() {
String id = wechatConfig.getAppid();
String sec = wechatConfig.getAppsecret();
return id != null && !id.isBlank()
&& sec != null && !sec.isBlank()
&& !"YOUR_APPID".equals(id)
&& !"YOUR_APPSECRET".equals(sec);
}
private void invalidateAccessToken() {
cachedAccessToken = null;
tokenExpiresAtEpochMs = 0;
}
/**
* @param phoneCode 前端 button getPhoneNumber 回调中的 detail.code
*/
public WxPhoneExchangeResult exchangePhoneCode(String phoneCode) {
if (phoneCode == null || phoneCode.isBlank()) {
return WxPhoneExchangeResult.fail("缺少手机号授权码");
}
if (!isConfigured()) {
log.warn("微信 appid/appsecret 未配置或为占位符,无法换取手机号");
return WxPhoneExchangeResult.fail("服务端未配置微信小程序 AppID/AppSecret或仍为占位符 YOUR_APPID");
}
for (int attempt = 0; attempt < 2; attempt++) {
try {
String accessToken = getAccessToken();
if (accessToken == null) {
return WxPhoneExchangeResult.fail("无法获取微信 access_token请核对服务端 AppID/AppSecret 是否与微信公众平台一致");
}
String url = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=" + accessToken;
String jsonBody = objectMapper.writeValueAsString(Map.of("code", phoneCode));
HttpResult hr = httpPostJson(url, jsonBody);
String body = hr.body();
if (body == null || body.isBlank()) {
log.warn("getuserphonenumber 响应为空, httpStatus={}, urlLen={}", hr.statusCode(), url.length());
return WxPhoneExchangeResult.fail(emptyBodyHint("getuserphonenumber", hr.statusCode()));
}
JsonNode root;
try {
root = objectMapper.readTree(body);
} catch (Exception parseEx) {
log.warn("解析微信响应失败 body={}", body, parseEx);
return WxPhoneExchangeResult.fail("微信返回非 JSONHTTP " + hr.statusCode() + "),可能被网关替换");
}
if (root.hasNonNull("phone_info")) {
JsonNode pi = root.path("phone_info");
String raw = pi.path("phoneNumber").asText("");
if (raw.isBlank()) {
raw = pi.path("purePhoneNumber").asText("");
}
String phone = normalizeMainlandPhone(raw);
if (phone == null) {
log.warn("无法解析手机号,原始响应: {}", body);
return WxPhoneExchangeResult.fail("微信返回的手机号格式异常");
}
return WxPhoneExchangeResult.ok(phone);
}
int errcode = root.path("errcode").asInt(-1);
String errmsg = root.path("errmsg").asText("");
log.warn("getuserphonenumber 无 phone_info: {}", body);
if ((errcode == 40001 || errcode == 42001) && attempt == 0) {
invalidateAccessToken();
continue;
}
if (errcode <= 0 && errmsg.isBlank()) {
return WxPhoneExchangeResult.fail("微信未返回手机号,请重新点击授权");
}
return WxPhoneExchangeResult.fail(formatWxError(errcode, errmsg));
} catch (Exception e) {
log.warn("换取手机号异常", e);
return WxPhoneExchangeResult.fail("连接微信服务异常: " + e.getMessage());
}
}
return WxPhoneExchangeResult.fail("获取手机号失败,请重试");
}
private static String emptyBodyHint(String api, int status) {
return "微信 " + api + " 返回空内容HTTP " + status + ")。"
+ "请在该机器上测试能否访问 api.weixin.qq.com:443curl/浏览器),检查防火墙、安全组、出站 HTTPS、公司代理"
+ "需代理时可设置 JVM 参数 https.proxyHost / https.proxyPort若 IPv6 异常可试 -Djava.net.preferIPv4Stack=true";
}
private static String formatWxError(int errcode, String errmsg) {
String base = (errmsg != null && !errmsg.isBlank()) ? errmsg : "未知错误";
String hint = switch (errcode) {
case 40029 -> "code 无效或已过期,请重新点击「微信授权登录」)";
case 40163 -> "code 已被使用,请重新授权)";
case 40001, 42001 -> "access_token 无效,已自动重试;若仍失败请检查 AppSecret";
case 40125 -> "AppSecret 错误,请登录微信公众平台核对后更新服务端配置)";
case 40013 -> "AppID 无效,请核对是否为小程序 AppID";
default -> "";
};
if (errcode > 0) {
return "微信接口错误 " + errcode + "" + base + hint;
}
return "获取手机号失败:" + base;
}
private String getAccessToken() {
long now = System.currentTimeMillis();
if (cachedAccessToken != null && now < tokenExpiresAtEpochMs - 60_000) {
return cachedAccessToken;
}
synchronized (this) {
now = System.currentTimeMillis();
if (cachedAccessToken != null && now < tokenExpiresAtEpochMs - 60_000) {
return cachedAccessToken;
}
try {
String appid = URLEncoder.encode(wechatConfig.getAppid(), StandardCharsets.UTF_8);
String secret = URLEncoder.encode(wechatConfig.getAppsecret(), StandardCharsets.UTF_8);
String url = String.format(
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
appid, secret);
HttpResult hr = httpGet(url);
String body = hr.body();
if (body == null || body.isBlank()) {
log.warn("获取 access_token 微信返回空 body, http={}", hr.statusCode());
return null;
}
JsonNode root;
try {
root = objectMapper.readTree(body);
} catch (Exception parseEx) {
log.warn("解析 token 响应失败 body={}", body, parseEx);
return null;
}
if (root.hasNonNull("errcode") && root.path("errcode").asInt() != 0) {
int ec = root.path("errcode").asInt();
String em = root.path("errmsg").asText("");
log.warn("获取 access_token 失败: errcode={} errmsg={} body={}", ec, em, body);
return null;
}
String token = root.path("access_token").asText(null);
int expiresIn = root.path("expires_in").asInt(7200);
if (token == null || token.isBlank()) {
return null;
}
cachedAccessToken = token;
tokenExpiresAtEpochMs = System.currentTimeMillis() + expiresIn * 1000L;
return cachedAccessToken;
} catch (Exception e) {
log.warn("获取 access_token 异常", e);
return null;
}
}
}
private record HttpResult(int statusCode, String body) {}
private HttpResult httpGet(String url) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(25))
.header("Accept", "application/json, text/plain, */*")
.header("User-Agent", "PetstoreBackend/1.0")
.GET()
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return new HttpResult(resp.statusCode(), resp.body());
}
private HttpResult httpPostJson(String url, String jsonBody) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(25))
.header("Content-Type", "application/json; charset=UTF-8")
.header("Accept", "application/json, text/plain, */*")
.header("User-Agent", "PetstoreBackend/1.0")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return new HttpResult(resp.statusCode(), resp.body());
}
private String normalizeMainlandPhone(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
String digits = raw.replaceAll("\\D", "");
if (digits.startsWith("86") && digits.length() == 13) {
digits = digits.substring(2);
}
if (digits.length() == 11) {
return digits;
}
log.warn("无法解析为 11 位手机号: {}", raw);
return null;
}
}

View File

@ -0,0 +1,46 @@
# 复制为 application.yml 后填写本地/生产配置application.yml 已加入 .gitignore勿提交密钥
spring:
application:
name: petstore-backend
datasource:
url: jdbc:mysql://127.0.0.1:3306/petstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&autoReconnect=true
username: root
password: ${MYSQL_PASSWORD:changeme}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
max-lifetime: 1800000
connection-test-query: SELECT 1
validation-timeout: 3000
idle-timeout: 600000
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
format_sql: true
servlet:
multipart:
max-file-size: 200MB
max-request-size: 200MB
server:
port: 8080
servlet:
context-path:
tomcat:
max-http-form-post-size: 200MB
max-swallow-size: 200MB
upload:
path: uploads
logging:
level:
com.petstore: debug
wechat:
appid: ${WECHAT_APPID:}
appsecret: ${WECHAT_APPSECRET:}
redirect_uri: http://localhost:8080/api/wechat/callback

View File

@ -21,22 +21,37 @@ spring:
format_sql: true format_sql: true
servlet: servlet:
multipart: multipart:
max-file-size: 10MB max-file-size: 200MB
max-request-size: 200MB
server: server:
port: 8080 port: 8080
servlet: servlet:
context-path: context-path:
encoding:
charset: UTF-8
enabled: true
force: true
# 与 multipart 一致,避免嵌入式 Tomcat 仍限制表单体积导致大视频 413
tomcat:
max-http-form-post-size: 200MB
max-swallow-size: 200MB
upload: upload:
path: uploads path: /www/petstore/uploads
logging: logging:
level: level:
com.petstore: debug com.petstore: debug
# 微信登录配置(需替换为实际值) # 服务回顾短片:本地 ffmpeg 拼接(可换为云端生成式视频 API
app:
base-url: ${APP_BASE_URL:https://api.s-good.com}
highlight-video:
ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg}
# 微信小程序(与 manifest / 微信后台一致;生产环境建议用环境变量覆盖)
wechat: wechat:
appid: YOUR_APPID appid: ${WECHAT_APPID:wx8ca2dfa89af72edf}
appsecret: YOUR_APPSECRET appsecret: ${WECHAT_APPSECRET:7afb49f3a31fe9b5a083c7c40be45c5b}
redirect_uri: http://localhost:8080/api/wechat/callback redirect_uri: http://localhost:8080/api/wechat/callback