feat: 服务回顾短片后端支持(ffmpeg合成)
This commit is contained in:
parent
cd1555ef0a
commit
ce21bad400
@ -0,0 +1,22 @@
|
||||
package com.petstore.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
@Configuration
|
||||
public class HighlightExecutorConfig {
|
||||
|
||||
@Bean(name = "highlightTaskExecutor")
|
||||
public Executor highlightTaskExecutor() {
|
||||
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
|
||||
ex.setCorePoolSize(1);
|
||||
ex.setMaxPoolSize(2);
|
||||
ex.setQueueCapacity(30);
|
||||
ex.setThreadNamePrefix("highlight-video-");
|
||||
ex.initialize();
|
||||
return ex;
|
||||
}
|
||||
}
|
||||
@ -3,9 +3,11 @@ 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;
|
||||
@ -20,6 +22,7 @@ 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;
|
||||
@ -47,6 +50,29 @@ public class ReportController {
|
||||
return "photo";
|
||||
}
|
||||
|
||||
private void putHighlightFields(Map<String, Object> target, Report r) {
|
||||
target.put("highlightVideoUrl", r.getHighlightVideoUrl() != null ? fullUrl(r.getHighlightVideoUrl()) : null);
|
||||
target.put("highlightVideoStatus", r.getHighlightVideoStatus());
|
||||
target.put("highlightVideoError", r.getHighlightVideoError());
|
||||
target.put("highlightDurationSec", r.getHighlightDurationSec());
|
||||
}
|
||||
|
||||
/** 触发服务回顾短片生成(异步;依赖服务器 ffmpeg) */
|
||||
@PostMapping(value = "/highlight/start", produces = MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8")
|
||||
public Map<String, Object> startHighlight(@RequestBody Map<String, Object> body) {
|
||||
if (body.get("reportId") == null || body.get("operatorUserId") == null || body.get("role") == null) {
|
||||
return Map.of("code", 400, "message", "缺少 reportId / operatorUserId / role");
|
||||
}
|
||||
Long reportId = Long.valueOf(body.get("reportId").toString());
|
||||
Long operatorUserId = Long.valueOf(body.get("operatorUserId").toString());
|
||||
String role = body.get("role").toString();
|
||||
int durationSec = 15;
|
||||
if (body.get("durationSec") != null) {
|
||||
durationSec = Integer.parseInt(body.get("durationSec").toString());
|
||||
}
|
||||
return reportHighlightVideoService.requestGenerate(reportId, durationSec, operatorUserId, role);
|
||||
}
|
||||
|
||||
@PostMapping("/create")
|
||||
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()));
|
||||
@ -115,6 +141,7 @@ public class ReportController {
|
||||
item.put("beforePhotos", beforePhotos);
|
||||
item.put("afterPhotos", afterPhotos);
|
||||
item.put("duringMedia", duringMedia);
|
||||
putHighlightFields(item, r);
|
||||
return item;
|
||||
}).collect(Collectors.toList());
|
||||
if (usePaging && paged != null) {
|
||||
@ -190,6 +217,7 @@ public class ReportController {
|
||||
storeInfo.put("address", store.getAddress());
|
||||
data.put("store", storeInfo);
|
||||
}
|
||||
putHighlightFields(data, report);
|
||||
result.put("code", 200);
|
||||
result.put("data", data);
|
||||
} else {
|
||||
|
||||
@ -53,4 +53,18 @@ public class Report {
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
@ -0,0 +1,417 @@
|
||||
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");
|
||||
int c1 = runFfmpeg(List.of(
|
||||
ffmpegBinary, "-y", "-f", "concat", "-safe", "0", "-i", listFile.toString(),
|
||||
"-c", "copy", merged.toString()
|
||||
));
|
||||
if (c1 != 0) {
|
||||
int c2 = runFfmpeg(List.of(
|
||||
ffmpegBinary, "-y", "-f", "concat", "-safe", "0", "-i", listFile.toString(),
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
|
||||
"-c:a", "aac", "-b:a", "96k", merged.toString()
|
||||
));
|
||||
if (c2 != 0) {
|
||||
throw new IllegalStateException("ffmpeg 拼接失败(退出码 " + c2 + ")");
|
||||
}
|
||||
}
|
||||
|
||||
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(merged, finalPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
String url = "/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;
|
||||
}
|
||||
}
|
||||
@ -44,6 +44,11 @@ logging:
|
||||
level:
|
||||
com.petstore: debug
|
||||
|
||||
# 服务回顾短片:本地 ffmpeg 拼接(可换为云端生成式视频 API)
|
||||
app:
|
||||
highlight-video:
|
||||
ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg}
|
||||
|
||||
# 微信小程序(与 manifest / 微信后台一致;生产环境建议用环境变量覆盖)
|
||||
wechat:
|
||||
appid: ${WECHAT_APPID:wx8ca2dfa89af72edf}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user