Compare commits
No commits in common. "main" and "be63a3a3771d83d2961cfdc788e353147a407aee" have entirely different histories.
main
...
be63a3a377
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,2 @@
|
||||
target/
|
||||
uploads/
|
||||
# 本地密钥,勿提交;用 application-example.yml 复制
|
||||
src/main/resources/application.yml
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
# 反代到 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;
|
||||
@ -1,22 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -15,9 +15,13 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
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(">>> /2026 exists: " + new File(uploadDir + "2026/04/01/").exists());
|
||||
registry.addResourceHandler("/uploads/**")
|
||||
.addResourceLocations("file:" + uploadDir);
|
||||
registry.addResourceHandler("/2026/**")
|
||||
.addResourceLocations("file:" + uploadDir);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,10 +3,9 @@ package com.petstore.controller;
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.service.AppointmentService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ -17,76 +16,29 @@ import java.util.Map;
|
||||
public class AppointmentController {
|
||||
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")
|
||||
public Map<String, Object> list(
|
||||
@RequestParam(required = false) Long userId,
|
||||
@RequestParam(required = false) Long storeId,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) Integer page,
|
||||
@RequestParam(required = false) Integer pageSize) {
|
||||
@RequestParam(required = false) String status) {
|
||||
|
||||
if (storeId == null && userId == null) {
|
||||
return Map.of("code", 400, "message", "userId或storeId必填");
|
||||
}
|
||||
|
||||
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) {
|
||||
appointments = (status != null && !status.isEmpty())
|
||||
? appointmentService.getByStoreIdAndStatus(storeId, status)
|
||||
: appointmentService.getByStoreId(storeId);
|
||||
} else {
|
||||
} else if (userId != null) {
|
||||
appointments = (status != null && !status.isEmpty())
|
||||
? appointmentService.getByUserIdAndStatus(userId, status)
|
||||
: appointmentService.getByUserId(userId);
|
||||
} else {
|
||||
return Map.of("code", 400, "message", "userId或storeId必填");
|
||||
}
|
||||
|
||||
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")
|
||||
public Map<String, Object> create(@RequestBody Map<String, Object> params) {
|
||||
@ -105,11 +57,9 @@ public class AppointmentController {
|
||||
if (params.containsKey("remark") && params.get("remark") != null) {
|
||||
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()));
|
||||
}
|
||||
|
||||
return appointmentService.createBooking(appointment);
|
||||
|
||||
Appointment created = appointmentService.create(appointment);
|
||||
return Map.of("code", 200, "message", "创建成功", "data", created);
|
||||
}
|
||||
|
||||
/** 开始服务:状态变进行中 + 指定技师 */
|
||||
@ -133,13 +83,4 @@ public class AppointmentController {
|
||||
}
|
||||
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", "删除成功");
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@ -14,12 +13,11 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@ -27,82 +25,42 @@ import java.util.UUID;
|
||||
@RequiredArgsConstructor
|
||||
@CrossOrigin
|
||||
public class FileController {
|
||||
|
||||
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}")
|
||||
|
||||
private static final String UPLOAD_BASE = "/Users/wac/Desktop/www/_src/petstore/backend/uploads/";
|
||||
|
||||
@Value("${upload.path:uploads}")
|
||||
private String uploadPath;
|
||||
|
||||
/**
|
||||
* 微信小程序 uni.uploadFile:multipart 里 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/**")
|
||||
public ResponseEntity<Resource> getImage(HttpServletRequest request) throws IOException {
|
||||
String path = request.getRequestURI().replace("/api/upload/image", "");
|
||||
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
|
||||
File file = new File(basePath + path);
|
||||
File file = new File(UPLOAD_BASE + path);
|
||||
if (!file.exists()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String contentType = Files.probeContentType(file.toPath());
|
||||
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()
|
||||
.contentType(mediaType)
|
||||
.contentLength(file.length())
|
||||
.body(new FileSystemResource(file));
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.body(new FileSystemResource(file));
|
||||
}
|
||||
|
||||
|
||||
// 兼容旧路径:/2026/04/01/xxx.jpg
|
||||
@GetMapping("/legacy/**")
|
||||
public ResponseEntity<Resource> getLegacyImage(HttpServletRequest request) throws IOException {
|
||||
String path = request.getRequestURI().replace("/api/upload/legacy", "");
|
||||
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
|
||||
File file = new File(basePath + path);
|
||||
File file = new File(UPLOAD_BASE + path);
|
||||
if (!file.exists()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
String contentType = Files.probeContentType(file.toPath());
|
||||
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()
|
||||
.contentType(mediaType)
|
||||
.contentLength(file.length())
|
||||
.body(new FileSystemResource(file));
|
||||
.contentType(MediaType.parseMediaType(contentType))
|
||||
.body(new FileSystemResource(file));
|
||||
}
|
||||
|
||||
/** produces 显式 UTF-8,避免网关/客户端按 ISO-8859-1 解码导致 message 中文乱码 */
|
||||
@PostMapping(value = "/image", produces = MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
|
||||
|
||||
@PostMapping("/image")
|
||||
public Map<String, Object> uploadImage(@RequestParam("file") MultipartFile file) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
@ -112,44 +70,31 @@ public class FileController {
|
||||
return result;
|
||||
}
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String contentType = file.getContentType();
|
||||
|
||||
if (!isAllowedMediaType(contentType, originalFilename)) {
|
||||
if (contentType == null || !contentType.startsWith("image/")) {
|
||||
result.put("code", 400);
|
||||
result.put("message", "只能上传图片或视频");
|
||||
result.put("message", "只能上传图片");
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// 创建上传目录
|
||||
// 创建上传目录(使用绝对路径)
|
||||
String datePath = LocalDate.now().toString().replace("-", "/");
|
||||
String dirPath = uploadPath.endsWith("/") ? uploadPath + datePath : uploadPath + "/" + datePath;
|
||||
String dirPath = UPLOAD_BASE + datePath; // /.../uploads/2026/04/01
|
||||
File dir = new File(dirPath);
|
||||
if (!dir.exists()) dir.mkdirs();
|
||||
|
||||
// 生成文件名
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String ext = "";
|
||||
if (originalFilename != null && originalFilename.contains(".")) {
|
||||
ext = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase(Locale.ROOT);
|
||||
if (ext.length() > 10) {
|
||||
ext = "";
|
||||
}
|
||||
ext = originalFilename.substring(originalFilename.lastIndexOf("."));
|
||||
}
|
||||
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;
|
||||
|
||||
// 保存文件(流式写入,避免大视频一次性进内存)
|
||||
// 保存文件
|
||||
Path filePath = Paths.get(dirPath, filename);
|
||||
file.transferTo(filePath.toFile());
|
||||
Files.write(filePath, file.getBytes());
|
||||
|
||||
// 返回访问URL(/api/upload/image/ + 日期路径 + 文件名)
|
||||
String url = "/api/upload/image/" + datePath + "/" + filename;
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,15 @@
|
||||
package com.petstore.controller;
|
||||
|
||||
import com.petstore.entity.Report;
|
||||
import com.petstore.entity.ReportImage;
|
||||
import com.petstore.entity.Store;
|
||||
import com.petstore.service.ReportHighlightVideoService;
|
||||
import com.petstore.service.ReportService;
|
||||
import com.petstore.service.StoreService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@ -22,60 +19,18 @@ import java.util.stream.Collectors;
|
||||
public class ReportController {
|
||||
private final ReportService reportService;
|
||||
private final StoreService storeService;
|
||||
private final ReportHighlightVideoService reportHighlightVideoService;
|
||||
|
||||
@Value("${app.base-url:http://localhost:8080}")
|
||||
private String baseUrl;
|
||||
private static final String BASE_URL = "http://localhost:8080";
|
||||
|
||||
private String fullUrl(String path) {
|
||||
if (path == null || path.isEmpty()) return path;
|
||||
if (path.startsWith("http")) return 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);
|
||||
return BASE_URL + path;
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
public Map<String, Object> create(@RequestBody Report report) {
|
||||
System.out.println(">>> Report create received: appointmentId=" + report.getAppointmentId() + ", userId=" + report.getUserId() + ", images count=" + (report.getImages() == null ? 0 : report.getImages().size()));
|
||||
System.out.println(">>> Report create received: appointmentId=" + report.getAppointmentId() + ", userId=" + report.getUserId() + ", before=" + report.getBeforePhoto());
|
||||
Report created = reportService.create(report);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
@ -90,22 +45,8 @@ public class ReportController {
|
||||
|
||||
@GetMapping("/list")
|
||||
public Map<String, Object> list(@RequestParam(required = false) Long storeId,
|
||||
@RequestParam(required = false) Long 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);
|
||||
}
|
||||
@RequestParam(required = false) Long userId) {
|
||||
List<Report> reports = reportService.list(storeId, userId);
|
||||
// 附加技师名称,并补全图片URL
|
||||
List<Map<String, Object>> data = reports.stream().map(r -> {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
@ -117,44 +58,12 @@ public class ReportController {
|
||||
item.put("staffName", r.getStaffName());
|
||||
item.put("reportToken", r.getReportToken());
|
||||
item.put("createTime", r.getCreateTime());
|
||||
item.put("beforePhoto", fullUrl(r.getBeforePhoto()));
|
||||
item.put("afterPhoto", fullUrl(r.getAfterPhoto()));
|
||||
item.put("storeId", r.getStoreId());
|
||||
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;
|
||||
}).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);
|
||||
}
|
||||
|
||||
@ -178,28 +87,8 @@ public class ReportController {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("id", report.getId());
|
||||
data.put("appointmentId", report.getAppointmentId());
|
||||
// 图片列表
|
||||
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("beforePhoto", report.getBeforePhoto());
|
||||
data.put("afterPhoto", report.getAfterPhoto());
|
||||
data.put("remark", report.getRemark());
|
||||
data.put("userId", report.getUserId());
|
||||
data.put("storeId", report.getStoreId());
|
||||
@ -217,7 +106,6 @@ public class ReportController {
|
||||
storeInfo.put("address", store.getAddress());
|
||||
data.put("store", storeInfo);
|
||||
}
|
||||
putHighlightFields(data, report);
|
||||
result.put("code", 200);
|
||||
result.put("data", data);
|
||||
} else {
|
||||
@ -226,13 +114,4 @@ public class ReportController {
|
||||
}
|
||||
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", "删除成功");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
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>
|
||||
* slotStart:ISO 本地时间,如 2026-04-17T10:00:00<br>
|
||||
* blockType:walk_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,9 +17,8 @@ public class ServiceTypeController {
|
||||
private final ServiceTypeService serviceTypeService;
|
||||
|
||||
@GetMapping("/list")
|
||||
public Map<String, Object> list(@RequestParam(required = false) Long storeId) {
|
||||
List<ServiceType> list =
|
||||
storeId == null ? serviceTypeService.listSystemDefaultsOnly() : serviceTypeService.getByStoreId(storeId);
|
||||
public Map<String, Object> list(@RequestParam Long storeId) {
|
||||
List<ServiceType> list = serviceTypeService.getByStoreId(storeId);
|
||||
return Map.of("code", 200, "data", list);
|
||||
}
|
||||
|
||||
|
||||
@ -6,9 +6,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/store")
|
||||
@ -27,26 +25,6 @@ public class StoreController {
|
||||
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")
|
||||
public Map<String, Object> get(@RequestParam Long id) {
|
||||
Store store = storeService.findById(id);
|
||||
@ -64,9 +42,6 @@ public class StoreController {
|
||||
@PutMapping("/update")
|
||||
public Map<String, Object> update(@RequestBody Store store) {
|
||||
Store updated = storeService.update(store);
|
||||
if (updated == null) {
|
||||
return Map.of("code", 404, "message", "店铺不存在");
|
||||
}
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("code", 200);
|
||||
result.put("message", "更新成功");
|
||||
@ -74,15 +49,6 @@ public class StoreController {
|
||||
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")
|
||||
public Map<String, Object> getByInviteCode(@RequestParam String code) {
|
||||
Store store = storeService.findByInviteCode(code);
|
||||
|
||||
@ -2,23 +2,19 @@ package com.petstore.controller;
|
||||
|
||||
import com.petstore.entity.User;
|
||||
import com.petstore.service.UserService;
|
||||
import com.petstore.service.WechatMiniProgramService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/user")
|
||||
@RequiredArgsConstructor
|
||||
@CrossOrigin
|
||||
public class UserController {
|
||||
private final UserService userService;
|
||||
private final WechatMiniProgramService wechatMiniProgramService;
|
||||
|
||||
/** 老板注册店铺 */
|
||||
@PostMapping("/register-boss")
|
||||
@ -38,36 +34,6 @@ public class UserController {
|
||||
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")
|
||||
public Map<String, Object> registerStaff(@RequestBody Map<String, String> params) {
|
||||
@ -99,10 +65,7 @@ public class UserController {
|
||||
/** 老板:删除员工 */
|
||||
@DeleteMapping("/staff")
|
||||
public Map<String, Object> deleteStaff(@RequestParam Long staffId) {
|
||||
boolean ok = userService.deleteStaff(staffId);
|
||||
if (!ok) {
|
||||
return Map.of("code", 404, "message", "员工不存在");
|
||||
}
|
||||
userService.deleteStaff(staffId);
|
||||
return Map.of("code", 200, "message", "删除成功");
|
||||
}
|
||||
|
||||
|
||||
@ -6,13 +6,7 @@ import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Entity
|
||||
@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")
|
||||
}
|
||||
)
|
||||
@Table(name = "t_appointment")
|
||||
public class Appointment {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@ -31,10 +25,6 @@ public class Appointment {
|
||||
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
/** 关联宠物档案(可选,用于本店「服务过的宠物」统计) */
|
||||
@Column(name = "pet_id")
|
||||
private Long petId;
|
||||
|
||||
/** 技师ID,开始服务时赋值 */
|
||||
@Column(name = "assigned_user_id")
|
||||
@ -47,7 +37,4 @@ public class Appointment {
|
||||
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private Boolean deleted = false;
|
||||
}
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -4,19 +4,10 @@ import jakarta.persistence.*;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Entity
|
||||
@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")
|
||||
}
|
||||
)
|
||||
@Table(name = "t_report")
|
||||
public class Report {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@ -26,10 +17,13 @@ public class Report {
|
||||
@Column(name = "appointment_id")
|
||||
private Long appointmentId;
|
||||
|
||||
private String remark;
|
||||
@Column(name = "before_photo", columnDefinition = "TEXT")
|
||||
private String beforePhoto;
|
||||
|
||||
@Transient
|
||||
private List<ReportImage> images = new ArrayList<>();
|
||||
@Column(name = "after_photo", columnDefinition = "TEXT")
|
||||
private String afterPhoto;
|
||||
|
||||
private String remark;
|
||||
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
@ -50,21 +44,4 @@ public class Report {
|
||||
|
||||
@Column(name = "update_time")
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -6,12 +6,7 @@ import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_service_type",
|
||||
indexes = {
|
||||
@Index(name = "idx_service_type_store_id", columnList = "store_id")
|
||||
}
|
||||
)
|
||||
@Table(name = "t_service_type")
|
||||
public class ServiceType {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
|
||||
@ -2,19 +2,11 @@ package com.petstore.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_store",
|
||||
indexes = {
|
||||
@Index(name = "idx_store_invite_code", columnList = "invite_code"),
|
||||
@Index(name = "idx_store_owner_id", columnList = "owner_id")
|
||||
}
|
||||
)
|
||||
@Table(name = "t_store")
|
||||
public class Store {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@ -35,27 +27,10 @@ public class Store {
|
||||
|
||||
@Column(name = "invite_code")
|
||||
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")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private Boolean deleted = false;
|
||||
}
|
||||
|
||||
@ -6,13 +6,7 @@ import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Entity
|
||||
@Table(
|
||||
name = "t_user",
|
||||
indexes = {
|
||||
@Index(name = "idx_user_phone", columnList = "phone"),
|
||||
@Index(name = "idx_user_store_id", columnList = "store_id")
|
||||
}
|
||||
)
|
||||
@Table(name = "t_user")
|
||||
public class User {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@ -35,7 +29,4 @@ public class User {
|
||||
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private Boolean deleted = false;
|
||||
}
|
||||
|
||||
@ -1,48 +1,13 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
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.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AppointmentMapper extends JpaRepository<Appointment, Long> {
|
||||
List<Appointment> findByUserIdAndDeletedFalse(Long userId);
|
||||
List<Appointment> findByUserIdAndStatusAndDeletedFalse(Long userId, String status);
|
||||
List<Appointment> findByStoreIdAndDeletedFalse(Long storeId);
|
||||
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);
|
||||
List<Appointment> findByUserId(Long userId);
|
||||
List<Appointment> findByUserIdAndStatus(Long userId, String status);
|
||||
List<Appointment> findByStoreId(Long storeId);
|
||||
List<Appointment> findByStoreIdAndStatus(Long storeId, String status);
|
||||
}
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@ -1,32 +1,7 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
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 java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
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);
|
||||
|
||||
}
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@ -8,7 +8,4 @@ import java.util.List;
|
||||
public interface ServiceTypeMapper extends JpaRepository<ServiceType, Long> {
|
||||
List<ServiceType> findByStoreIdOrStoreIdIsNull(Long storeId);
|
||||
List<ServiceType> findByStoreId(Long storeId);
|
||||
|
||||
/** 系统内置(store_id 为空) */
|
||||
List<ServiceType> findByStoreIdIsNull();
|
||||
}
|
||||
|
||||
@ -3,11 +3,6 @@ package com.petstore.mapper;
|
||||
import com.petstore.entity.Store;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface StoreMapper extends JpaRepository<Store, Long> {
|
||||
Store findByInviteCodeAndDeletedFalse(String inviteCode);
|
||||
Optional<Store> findByIdAndDeletedFalse(Long id);
|
||||
List<Store> findAllByDeletedFalse();
|
||||
Store findByInviteCode(String inviteCode);
|
||||
}
|
||||
|
||||
@ -4,11 +4,9 @@ import com.petstore.entity.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserMapper extends JpaRepository<User, Long> {
|
||||
User findByUsernameAndDeletedFalse(String username);
|
||||
User findByPhoneAndDeletedFalse(String phone);
|
||||
List<User> findByStoreIdAndDeletedFalse(Long storeId);
|
||||
Optional<User> findByIdAndDeletedFalse(Long id);
|
||||
User findByUsername(String username);
|
||||
User findByPhone(String phone);
|
||||
List<User> findByStoreId(Long storeId);
|
||||
}
|
||||
|
||||
@ -2,183 +2,45 @@ package com.petstore.service;
|
||||
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.mapper.AppointmentMapper;
|
||||
import com.petstore.mapper.ScheduleBlockMapper;
|
||||
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.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
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.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AppointmentService {
|
||||
private final AppointmentMapper appointmentMapper;
|
||||
private final ScheduleBlockMapper scheduleBlockMapper;
|
||||
private final StoreService storeService;
|
||||
|
||||
// 员工查看自己的预约
|
||||
public List<Appointment> getByUserId(Long userId) {
|
||||
return appointmentMapper.findByUserIdAndDeletedFalse(userId);
|
||||
return appointmentMapper.findByUserId(userId);
|
||||
}
|
||||
|
||||
// 老板查看本店所有预约
|
||||
public List<Appointment> getByStoreId(Long storeId) {
|
||||
return appointmentMapper.findByStoreIdAndDeletedFalse(storeId);
|
||||
return appointmentMapper.findByStoreId(storeId);
|
||||
}
|
||||
|
||||
// 员工按状态查
|
||||
public List<Appointment> getByUserIdAndStatus(Long userId, String status) {
|
||||
return appointmentMapper.findByUserIdAndStatusAndDeletedFalse(userId, status);
|
||||
return appointmentMapper.findByUserIdAndStatus(userId, status);
|
||||
}
|
||||
|
||||
// 老板按状态查
|
||||
public List<Appointment> getByStoreIdAndStatus(Long storeId, String status) {
|
||||
return appointmentMapper.findByStoreIdAndStatusAndDeletedFalse(storeId, status);
|
||||
return appointmentMapper.findByStoreIdAndStatus(storeId, status);
|
||||
}
|
||||
|
||||
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", "该时段已被占用,请更换其它时段");
|
||||
}
|
||||
public Appointment create(Appointment appointment) {
|
||||
appointment.setCreateTime(LocalDateTime.now());
|
||||
appointment.setUpdateTime(LocalDateTime.now());
|
||||
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;
|
||||
return appointmentMapper.save(appointment);
|
||||
}
|
||||
|
||||
public Appointment updateStatus(Long id, String status) {
|
||||
Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(id).orElse(null);
|
||||
Appointment appointment = appointmentMapper.findById(id).orElse(null);
|
||||
if (appointment != null) {
|
||||
appointment.setStatus(status);
|
||||
appointment.setUpdateTime(LocalDateTime.now());
|
||||
@ -189,7 +51,7 @@ public class AppointmentService {
|
||||
|
||||
/** 开始服务:状态变为进行中,同时指定技师为当前用户 */
|
||||
public Appointment startService(Long appointmentId, Long staffUserId) {
|
||||
Appointment appointment = appointmentMapper.findByIdAndDeletedFalse(appointmentId).orElse(null);
|
||||
Appointment appointment = appointmentMapper.findById(appointmentId).orElse(null);
|
||||
if (appointment != null) {
|
||||
appointment.setStatus("doing");
|
||||
appointment.setAssignedUserId(staffUserId);
|
||||
@ -198,15 +60,4 @@ public class AppointmentService {
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
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", "已删除");
|
||||
}
|
||||
}
|
||||
@ -1,428 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,23 +1,23 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.entity.*;
|
||||
import com.petstore.mapper.*;
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.Report;
|
||||
import com.petstore.entity.User;
|
||||
import com.petstore.mapper.AppointmentMapper;
|
||||
import com.petstore.mapper.ReportMapper;
|
||||
import com.petstore.mapper.UserMapper;
|
||||
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 java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ReportService {
|
||||
private final ReportMapper reportMapper;
|
||||
private final ReportImageMapper reportImageMapper;
|
||||
private final AppointmentMapper appointmentMapper;
|
||||
private final UserMapper userMapper;
|
||||
|
||||
@ -27,7 +27,7 @@ public class ReportService {
|
||||
|
||||
// 填充冗余字段,并自动完成预约
|
||||
if (report.getAppointmentId() != null) {
|
||||
Appointment appt = appointmentMapper.findByIdAndDeletedFalse(report.getAppointmentId()).orElse(null);
|
||||
Appointment appt = appointmentMapper.findById(report.getAppointmentId()).orElse(null);
|
||||
if (appt != null) {
|
||||
report.setPetName(appt.getPetName());
|
||||
report.setServiceType(appt.getServiceType());
|
||||
@ -35,7 +35,7 @@ public class ReportService {
|
||||
report.setStoreId(appt.getStoreId());
|
||||
// 技师取预约分配的技师(开始服务时指定的)
|
||||
if (appt.getAssignedUserId() != null) {
|
||||
User staff = userMapper.findByIdAndDeletedFalse(appt.getAssignedUserId()).orElse(null);
|
||||
User staff = userMapper.findById(appt.getAssignedUserId()).orElse(null);
|
||||
if (staff != null) {
|
||||
report.setUserId(staff.getId());
|
||||
report.setStaffName(staff.getName());
|
||||
@ -49,7 +49,7 @@ public class ReportService {
|
||||
}
|
||||
// 如果预约没分配技师,则用当前操作人
|
||||
if (report.getUserId() != null && report.getStaffName() == null) {
|
||||
User staff = userMapper.findByIdAndDeletedFalse(report.getUserId()).orElse(null);
|
||||
User staff = userMapper.findById(report.getUserId()).orElse(null);
|
||||
if (staff != null) {
|
||||
report.setStaffName(staff.getName());
|
||||
if (report.getStoreId() == null) {
|
||||
@ -60,100 +60,27 @@ public class ReportService {
|
||||
|
||||
report.setCreateTime(LocalDateTime.now());
|
||||
report.setUpdateTime(LocalDateTime.now());
|
||||
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;
|
||||
return reportMapper.save(report);
|
||||
}
|
||||
|
||||
public Report getByAppointmentId(Long appointmentId) {
|
||||
if (appointmentId == null) {
|
||||
return null;
|
||||
}
|
||||
return enrichImages(reportMapper.findFirstByAppointmentIdAndDeletedFalseOrderByCreateTimeDesc(appointmentId).orElse(null));
|
||||
return reportMapper.findAll().stream()
|
||||
.filter(r -> r.getAppointmentId().equals(appointmentId))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public Report getByToken(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return enrichImages(reportMapper.findFirstByReportTokenAndDeletedFalse(token).orElse(null));
|
||||
return reportMapper.findAll().stream()
|
||||
.filter(r -> token.equals(r.getReportToken()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public List<Report> list(Long storeId, Long userId) {
|
||||
List<Report> reports;
|
||||
if (storeId != null && userId != null) {
|
||||
reports = reportMapper.findByStoreIdAndUserIdAndDeletedFalseOrderByCreateTimeDesc(storeId, userId);
|
||||
} 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;
|
||||
return reportMapper.findAll().stream()
|
||||
.filter(r -> storeId == null || storeId.equals(r.getStoreId()))
|
||||
.filter(r -> userId == null || userId.equals(r.getUserId()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,174 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@ -18,11 +18,6 @@ public class ServiceTypeService {
|
||||
return serviceTypeMapper.findByStoreIdOrStoreIdIsNull(storeId);
|
||||
}
|
||||
|
||||
/** 仅系统默认(未传门店或 C 端尚未绑定门店时用于展示可选名称) */
|
||||
public List<ServiceType> listSystemDefaultsOnly() {
|
||||
return serviceTypeMapper.findByStoreIdIsNull();
|
||||
}
|
||||
|
||||
/** 老板新增服务类型 */
|
||||
public ServiceType create(Long storeId, String name) {
|
||||
ServiceType st = new ServiceType();
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -6,8 +6,6 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@ -19,87 +17,20 @@ public class StoreService {
|
||||
store.setInviteCode(generateInviteCode());
|
||||
store.setCreateTime(LocalDateTime.now());
|
||||
store.setUpdateTime(LocalDateTime.now());
|
||||
store.setDeleted(false);
|
||||
return storeMapper.save(store);
|
||||
}
|
||||
|
||||
public Store findById(Long id) {
|
||||
return storeMapper.findByIdAndDeletedFalse(id).orElse(null);
|
||||
return storeMapper.findById(id).orElse(null);
|
||||
}
|
||||
|
||||
public Store findByInviteCode(String code) {
|
||||
return storeMapper.findByInviteCodeAndDeletedFalse(code);
|
||||
return storeMapper.findByInviteCode(code);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
public Store update(Store store) {
|
||||
store.setUpdateTime(LocalDateTime.now());
|
||||
storeMapper.save(store);
|
||||
return true;
|
||||
return storeMapper.save(store);
|
||||
}
|
||||
|
||||
private String generateInviteCode() {
|
||||
|
||||
@ -17,7 +17,7 @@ public class UserService {
|
||||
private final StoreMapper storeMapper;
|
||||
|
||||
public Map<String, Object> registerBoss(String storeName, String bossName, String phone, String password) {
|
||||
if (userMapper.findByPhoneAndDeletedFalse(phone) != null) {
|
||||
if (userMapper.findByPhone(phone) != null) {
|
||||
return Map.of("code", 400, "message", "手机号已注册");
|
||||
}
|
||||
|
||||
@ -28,7 +28,6 @@ public class UserService {
|
||||
store.setInviteCode(UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase());
|
||||
store.setCreateTime(LocalDateTime.now());
|
||||
store.setUpdateTime(LocalDateTime.now());
|
||||
store.setDeleted(false);
|
||||
store = storeMapper.save(store);
|
||||
|
||||
User boss = new User();
|
||||
@ -40,7 +39,6 @@ public class UserService {
|
||||
boss.setRole("boss");
|
||||
boss.setCreateTime(LocalDateTime.now());
|
||||
boss.setUpdateTime(LocalDateTime.now());
|
||||
boss.setDeleted(false);
|
||||
boss = userMapper.save(boss);
|
||||
|
||||
store.setOwnerId(boss.getId());
|
||||
@ -57,32 +55,21 @@ public class UserService {
|
||||
if (!"123456".equals(code)) {
|
||||
return Map.of("code", 401, "message", "验证码错误");
|
||||
}
|
||||
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);
|
||||
User user = userMapper.findByPhone(phone);
|
||||
if (user == null) {
|
||||
// 自动注册为 C 端用户 (customer)
|
||||
user = new User();
|
||||
user.setUsername(phone);
|
||||
user.setPhone(phone);
|
||||
user.setName("微信用户" + phone.substring(7));
|
||||
user.setPassword("wx_no_password");
|
||||
user.setRole("customer");
|
||||
user.setCreateTime(LocalDateTime.now());
|
||||
user.setUpdateTime(LocalDateTime.now());
|
||||
user.setDeleted(false);
|
||||
user = userMapper.save(user);
|
||||
}
|
||||
Store store = null;
|
||||
if (user.getStoreId() != null) {
|
||||
store = storeMapper.findByIdAndDeletedFalse(user.getStoreId()).orElse(null);
|
||||
store = storeMapper.findById(user.getStoreId()).orElse(null);
|
||||
}
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("user", user);
|
||||
@ -91,10 +78,10 @@ public class UserService {
|
||||
}
|
||||
|
||||
public Map<String, Object> registerStaff(String phone, String password, String name, String inviteCode) {
|
||||
if (userMapper.findByPhoneAndDeletedFalse(phone) != null) {
|
||||
if (userMapper.findByPhone(phone) != null) {
|
||||
return Map.of("code", 400, "message", "手机号已注册");
|
||||
}
|
||||
Store store = storeMapper.findByInviteCodeAndDeletedFalse(inviteCode);
|
||||
Store store = storeMapper.findByInviteCode(inviteCode);
|
||||
if (store == null) {
|
||||
return Map.of("code", 400, "message", "邀请码无效");
|
||||
}
|
||||
@ -107,7 +94,6 @@ public class UserService {
|
||||
staff.setRole("staff");
|
||||
staff.setCreateTime(LocalDateTime.now());
|
||||
staff.setUpdateTime(LocalDateTime.now());
|
||||
staff.setDeleted(false);
|
||||
staff = userMapper.save(staff);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("user", staff);
|
||||
@ -119,7 +105,7 @@ public class UserService {
|
||||
if (storeId == null) {
|
||||
return Map.of("code", 400, "message", "店铺ID不能为空");
|
||||
}
|
||||
if (userMapper.findByPhoneAndDeletedFalse(phone) != null) {
|
||||
if (userMapper.findByPhone(phone) != null) {
|
||||
return Map.of("code", 400, "message", "手机号已存在");
|
||||
}
|
||||
String pwd = String.format("%06d", (int)(Math.random() * 999999));
|
||||
@ -132,33 +118,25 @@ public class UserService {
|
||||
staff.setRole("staff");
|
||||
staff.setCreateTime(LocalDateTime.now());
|
||||
staff.setUpdateTime(LocalDateTime.now());
|
||||
staff.setDeleted(false);
|
||||
staff = userMapper.save(staff);
|
||||
return Map.of("code", 200, "message", "创建成功,初始密码:" + pwd, "data", staff);
|
||||
}
|
||||
|
||||
public List<User> getStaffList(Long storeId) {
|
||||
return userMapper.findByStoreIdAndDeletedFalse(storeId);
|
||||
return userMapper.findByStoreId(storeId);
|
||||
}
|
||||
|
||||
public boolean deleteStaff(Long 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 void deleteStaff(Long staffId) {
|
||||
userMapper.deleteById(staffId);
|
||||
}
|
||||
|
||||
public User findById(Long id) {
|
||||
return userMapper.findByIdAndDeletedFalse(id).orElse(null);
|
||||
return userMapper.findById(id).orElse(null);
|
||||
}
|
||||
|
||||
public Map<String, Object> updateUser(Map<String, Object> params) {
|
||||
Long userId = Long.valueOf(params.get("id").toString());
|
||||
User user = userMapper.findByIdAndDeletedFalse(userId).orElse(null);
|
||||
User user = userMapper.findById(userId).orElse(null);
|
||||
if (user == null) {
|
||||
return Map.of("code", 404, "message", "用户不存在");
|
||||
}
|
||||
@ -173,7 +151,7 @@ public class UserService {
|
||||
return Map.of("code", 400, "message", "验证码错误");
|
||||
}
|
||||
// 检查手机号是否被占用
|
||||
User existing = userMapper.findByPhoneAndDeletedFalse(newPhone);
|
||||
User existing = userMapper.findByPhone(newPhone);
|
||||
if (existing != null && !existing.getId().equals(userId)) {
|
||||
return Map.of("code", 400, "message", "手机号已被占用");
|
||||
}
|
||||
|
||||
@ -1,250 +0,0 @@
|
||||
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("微信返回非 JSON(HTTP " + 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:443(curl/浏览器),检查防火墙、安全组、出站 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;
|
||||
}
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
# 复制为 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
|
||||
@ -21,37 +21,22 @@ spring:
|
||||
format_sql: true
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 200MB
|
||||
max-request-size: 200MB
|
||||
max-file-size: 10MB
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
servlet:
|
||||
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:
|
||||
path: /www/petstore/uploads
|
||||
path: uploads
|
||||
|
||||
logging:
|
||||
level:
|
||||
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:
|
||||
appid: ${WECHAT_APPID:wx8ca2dfa89af72edf}
|
||||
appsecret: ${WECHAT_APPSECRET:7afb49f3a31fe9b5a083c7c40be45c5b}
|
||||
appid: YOUR_APPID
|
||||
appsecret: YOUR_APPSECRET
|
||||
redirect_uri: http://localhost:8080/api/wechat/callback
|
||||
|
||||
Loading…
Reference in New Issue
Block a user