更新 2026-04-18 18:32

This commit is contained in:
MaDaLei 2026-04-18 18:32:41 +08:00
parent afcd39011a
commit e3b95b45b7
9 changed files with 324 additions and 86 deletions

View File

@ -1,5 +1,6 @@
package com.petstore.controller;
import com.petstore.entity.HighlightFailReason;
import com.petstore.entity.Report;
import com.petstore.entity.ReportImage;
import com.petstore.entity.Store;
@ -60,9 +61,25 @@ public class ReportController {
target.put("highlightVideoStatus", r.getHighlightVideoStatus());
target.put("highlightVideoError", r.getHighlightVideoError());
target.put("highlightDurationSec", r.getHighlightDurationSec());
target.put("highlightComposeMode", r.getHighlightComposeMode());
String failCode = r.getHighlightFailReason();
target.put("highlightFailReason", failCode);
target.put("highlightFailReasonLabel", resolveHighlightFailLabel(failCode));
}
/** 触发服务回顾短片生成(异步;依赖服务器 ffmpeg */
private static String resolveHighlightFailLabel(String code) {
if (code == null || code.isBlank()) {
return null;
}
for (HighlightFailReason fr : HighlightFailReason.values()) {
if (fr.getCode().equalsIgnoreCase(code)) {
return fr.getShortLabel();
}
}
return null;
}
/** 触发服务回顾短片生成(异步;依赖服务器 ffmpeg。composeModepreset | interleave时长由素材自动计算有上限。 */
@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) {
@ -71,11 +88,8 @@ public class ReportController {
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);
String composeMode = body.get("composeMode") == null ? "preset" : body.get("composeMode").toString().trim();
return reportHighlightVideoService.requestGenerate(reportId, operatorUserId, role, composeMode);
}
@PostMapping("/create")

View File

@ -46,12 +46,14 @@ public class ReportLeadController {
return Map.of("code", 400, "message", "需要勾选同意接收服务提醒");
}
try {
ReportLead lead = reportLeadService.submit(token, phone, extractClientIp(request), loginCode);
ReportLeadService.LeadSubmitOutcome outcome = reportLeadService.submit(token, phone, extractClientIp(request), loginCode);
ReportLead lead = outcome.lead();
Map<String, Object> data = new HashMap<>();
data.put("ok", true);
data.put("leadId", lead.getId());
data.put("remindDate", lead.getRemindDate() == null ? null : lead.getRemindDate().toString());
data.put("unsubscribeToken", lead.getUnsubscribeToken());
data.put("wechatBound", outcome.wechatBound());
return Map.of("code", 200, "data", data);
} catch (IllegalArgumentException e) {
return Map.of("code", 400, "message", e.getMessage());

View File

@ -0,0 +1,45 @@
package com.petstore.entity;
/**
* 报告短片生成失败时对宠主展示的原因归类不含技术细节
*/
public enum HighlightFailReason {
/** 素材缺失、无法读取或编码失败(单段) */
MATERIAL("material"),
/** 合成服务异常ffmpeg 拼接/转码、磁盘等) */
SERVICE("service"),
/** 网络类异常(极少见于服务端,预留) */
NETWORK("network"),
/** 未归类 */
UNKNOWN("unknown");
private final String code;
HighlightFailReason(String code) {
this.code = code;
}
public String getCode() {
return code;
}
/** 对宠主展示的说明(中文,无堆栈、无英文技术术语) */
public String getUserMessage() {
return switch (this) {
case MATERIAL -> "部分照片或视频无法用于合成,请调整素材后重新生成。";
case SERVICE -> "短片合成服务暂时不可用,请稍后重试。若多次失败请联系门店。";
case NETWORK -> "网络异常,请检查网络连接后重试。";
case UNKNOWN -> "短片生成失败,请稍后重试或联系门店。";
};
}
/** 短标题,供前端与 reason 并列展示 */
public String getShortLabel() {
return switch (this) {
case MATERIAL -> "素材问题";
case SERVICE -> "服务不可用";
case NETWORK -> "网络异常";
case UNKNOWN -> "生成失败";
};
}
}

View File

@ -67,4 +67,12 @@ public class Report {
@Column(name = "highlight_duration_sec")
private Integer highlightDurationSec;
/** 短片编排preset=对比封面+分段顺序interleave=全局照片/视频穿插 */
@Column(name = "highlight_compose_mode", length = 16)
private String highlightComposeMode;
/** 失败原因归类material | service | network | unknown对宠主展示无技术细节 */
@Column(name = "highlight_fail_reason", length = 16)
private String highlightFailReason;
}

View File

@ -1,6 +1,7 @@
package com.petstore.service;
import com.petstore.entity.Appointment;
import com.petstore.entity.HighlightFailReason;
import com.petstore.entity.Report;
import com.petstore.entity.ReportImage;
import com.petstore.entity.User;
@ -34,14 +35,21 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
/**
* 将服务报告中的服务前 / 过程中 / 服务后图片与短视频照片与视频穿插编排后拼接为竖屏短片默认 15s 30s
* <p>图片段使用轻微 Ken Burns缩放平移视频段保留原音无音轨时补静音可选配置文件路径叠加 BGM</p>
* <p>依赖本机安装 {@code ffmpeg}可选 {@code ffprobe} 用于读取视频时长与是否含音轨</p>
* 将服务报告素材拼为竖屏短片时长按素材量自动计算 {@code max-duration-sec} 上限约束
* <p><b>preset</b> 预设顺序</p>
* <ol>
* <li>封面服务前第一张 + 服务后第一张 横向对比图均为照片且文件存在时</li>
* <li>服务前其余照片上传顺序</li>
* <li>过程中照片与视频上传顺序</li>
* <li>服务后除第一张外的其余照片</li>
* <li>收尾服务后第一张再次作为最后一镜</li>
* </ol>
* <p><b>interleave</b>服务前过程服务后排序后将全部照片与全部小视频均匀穿插组间插入</p>
* <p>图片段 Ken Burns视频段保留原音无音轨补静音可选 BGM</p>
*/
@Service
public class ReportHighlightVideoService {
private static final int[] ALLOWED_DURATIONS = {15, 30};
private static final int OUTPUT_FPS = 30;
private final ReportMapper reportMapper;
@ -66,10 +74,14 @@ public class ReportHighlightVideoService {
@Value("${app.highlight-video.image-segment-sec:2.0}")
private double imageSegmentSec;
/** 单段小视频最多取用时长(秒),实际还会按总时长预算缩放 */
/** 单段小视频最多截取的时长(秒) */
@Value("${app.highlight-video.video-cap-sec:8.0}")
private double videoCapSec;
/** 成片最大总时长(秒);素材自然时长超过时整体等比压缩 */
@Value("${app.highlight-video.max-duration-sec:300}")
private double maxDurationSec;
/** 可选:本地绝对路径,商用授权 BGMmp3/aac/m4a 等 ffmpeg 可读格式) */
@Value("${app.highlight-video.bgm-path:}")
private String bgmPath;
@ -96,19 +108,16 @@ public class ReportHighlightVideoService {
return reportLocks.computeIfAbsent(reportId, k -> new Object());
}
public Map<String, Object> requestGenerate(Long reportId, int durationSec, Long operatorUserId, String role) {
/**
* @param composeModeRaw {@code preset} 预设顺序{@code interleave} 穿插亦接受 ordered / spread
*/
public Map<String, Object> requestGenerate(Long reportId, Long operatorUserId, String role, String composeModeRaw) {
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");
String mode = normalizeComposeMode(composeModeRaw);
if (mode == null) {
return Map.of("code", 400, "message", "composeMode 仅支持 preset预设顺序或 interleave穿插");
}
synchronized (lockFor(reportId)) {
@ -133,80 +142,162 @@ public class ReportHighlightVideoService {
report.setHighlightVideoStatus("processing");
report.setHighlightVideoError(null);
report.setHighlightDurationSec(durationSec);
report.setHighlightFailReason(null);
report.setHighlightComposeMode(mode);
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"));
highlightExecutor.execute(() -> composeSafely(reportId, mode));
return Map.of("code", 200, "message", "已开始生成", "data", Map.of("status", "processing", "composeMode", mode));
}
}
private void composeSafely(Long reportId, int durationSec) {
private static String normalizeComposeMode(String raw) {
if (raw == null || raw.isBlank()) {
return "preset";
}
String s = raw.trim().toLowerCase(Locale.ROOT);
if ("preset".equals(s) || "ordered".equals(s)) {
return "preset";
}
if ("interleave".equals(s) || "spread".equals(s)) {
return "interleave";
}
return null;
}
private void composeSafely(Long reportId, String composeMode) {
try {
composeInternal(reportId, durationSec);
composeInternal(reportId, composeMode);
} catch (Exception e) {
markFailed(reportId, e.getMessage() != null ? e.getMessage() : "合成失败");
markFailed(reportId, classifyFailure(e));
}
}
private void markFailed(Long reportId, String err) {
/**
* 将异常映射为宠主可见的失败类型不向用户暴露英文退出码与堆栈
*/
private static HighlightFailReason classifyFailure(Throwable e) {
Throwable cur = e;
int depth = 0;
while (cur != null && depth++ < 8) {
if (cur instanceof java.net.SocketTimeoutException
|| cur instanceof java.net.UnknownHostException
|| cur instanceof java.net.ConnectException) {
return HighlightFailReason.NETWORK;
}
cur = cur.getCause();
}
String m = e.getMessage() != null ? e.getMessage() : "";
if (m.contains("素材") || m.contains("不存在") || m.contains("无法读取")) {
return HighlightFailReason.MATERIAL;
}
if (m.contains("图片转视频") || m.contains("视频片段重编码")) {
return HighlightFailReason.MATERIAL;
}
if (m.contains("ffmpeg") || m.contains("拼接失败") || m.contains("退出码")) {
return HighlightFailReason.SERVICE;
}
if (m.contains("分段时长")) {
return HighlightFailReason.SERVICE;
}
if (e instanceof java.io.IOException) {
return HighlightFailReason.SERVICE;
}
return HighlightFailReason.UNKNOWN;
}
private void markFailed(Long reportId, HighlightFailReason reason) {
synchronized (lockFor(reportId)) {
reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> {
r.setHighlightVideoStatus("failed");
r.setHighlightVideoError(err != null && err.length() > 500 ? err.substring(0, 500) : err);
r.setHighlightFailReason(reason.getCode());
String msg = reason.getUserMessage();
r.setHighlightVideoError(msg.length() > 500 ? msg.substring(0, 500) : msg);
r.setUpdateTime(LocalDateTime.now());
reportMapper.save(r);
});
}
}
private void composeInternal(Long reportId, int durationSec) throws Exception {
private void composeInternal(Long reportId, String composeMode) throws Exception {
Optional<Report> opt = reportMapper.findByIdAndDeletedFalse(reportId);
if (opt.isEmpty()) {
return;
}
List<ReportImage> ordered = sortedImages(reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(reportId));
List<ReportImage> photos = new ArrayList<>();
List<ReportImage> videos = new ArrayList<>();
for (ReportImage img : ordered) {
if (isVideoMedia(img)) {
videos.add(img);
} else {
photos.add(img);
}
}
List<ReportImage> timeline = interleaveSpread(photos, videos);
List<Path> sources = new ArrayList<>();
List<Boolean> isVideo = new ArrayList<>();
for (ReportImage img : timeline) {
Path p = resolveUploadFile(img.getPhotoUrl());
if (p == null || !Files.isRegularFile(p)) {
continue;
}
sources.add(p);
isVideo.add(isVideoMedia(img));
}
if (sources.isEmpty()) {
markFailed(reportId, "素材文件在服务器上不存在或无法读取");
return;
}
int n = sources.size();
double[] segmentSecs = computeSegmentDurations(sources, isVideo, durationSec);
double sumSeg = 0;
for (double s : segmentSecs) {
sumSeg += s;
}
if (sumSeg < 0.5) {
markFailed(reportId, "分段时长计算异常");
return;
}
List<ReportImage> before = filterByPhotoType(ordered, "before");
List<ReportImage> during = filterByPhotoType(ordered, "during");
List<ReportImage> after = filterByPhotoType(ordered, "after");
Path workDir = Files.createTempDirectory("highlight_" + reportId + "_");
try {
List<Path> sources = new ArrayList<>();
List<Boolean> isVideo = new ArrayList<>();
if ("interleave".equals(composeMode)) {
List<ReportImage> photos = new ArrayList<>();
List<ReportImage> videos = new ArrayList<>();
for (ReportImage img : ordered) {
if (isVideoMedia(img)) {
videos.add(img);
} else {
photos.add(img);
}
}
for (ReportImage img : interleaveSpread(photos, videos)) {
appendResolvedMedia(sources, isVideo, img);
}
} else {
boolean coverAdded = false;
if (!before.isEmpty() && !after.isEmpty()) {
ReportImage b0 = before.get(0);
ReportImage a0 = after.get(0);
if (!isVideoMedia(b0) && !isVideoMedia(a0)) {
Path pb = resolveUploadFile(b0.getPhotoUrl());
Path pa = resolveUploadFile(a0.getPhotoUrl());
if (pb != null && pa != null && Files.isRegularFile(pb) && Files.isRegularFile(pa)) {
Path cover = workDir.resolve("cover_compare.jpg");
if (buildComparisonCover(pb, pa, cover)) {
sources.add(cover);
isVideo.add(false);
coverAdded = true;
}
}
}
}
int bStart = coverAdded ? 1 : 0;
for (int i = bStart; i < before.size(); i++) {
appendResolvedMedia(sources, isVideo, before.get(i));
}
for (ReportImage d : during) {
appendResolvedMedia(sources, isVideo, d);
}
for (int i = 1; i < after.size(); i++) {
appendResolvedMedia(sources, isVideo, after.get(i));
}
if (!after.isEmpty()) {
appendResolvedMedia(sources, isVideo, after.get(0));
}
}
if (sources.isEmpty()) {
markFailed(reportId, HighlightFailReason.MATERIAL);
return;
}
int n = sources.size();
double[] segmentSecs = computeSegmentDurationsNatural(sources, isVideo);
double totalDurationSec = 0;
for (double s : segmentSecs) {
totalDurationSec += s;
}
if (totalDurationSec < 0.5) {
markFailed(reportId, HighlightFailReason.SERVICE);
return;
}
List<Path> segments = new ArrayList<>();
for (int i = 0; i < n; i++) {
Path seg = workDir.resolve("seg_" + i + ".mp4");
@ -236,6 +327,7 @@ public class ReportHighlightVideoService {
"-c", "copy", merged.toString()
));
if (c1 != 0) {
String tStr = String.format(Locale.ROOT, "%.3f", totalDurationSec);
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",
@ -243,7 +335,7 @@ public class ReportHighlightVideoService {
"-profile:v", "baseline", "-level", "3.0",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
"-t", String.valueOf(durationSec),
"-t", tStr,
merged.toString()
));
if (c2 != 0) {
@ -267,12 +359,14 @@ public class ReportHighlightVideoService {
String publicBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
String url = publicBase + "/api/upload/image/" + datePath + "/" + filename;
int durationRoundedSec = (int) Math.ceil(totalDurationSec);
synchronized (lockFor(reportId)) {
reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> {
r.setHighlightVideoUrl(url);
r.setHighlightVideoStatus("done");
r.setHighlightVideoError(null);
r.setHighlightDurationSec(durationSec);
r.setHighlightFailReason(null);
r.setHighlightDurationSec(durationRoundedSec);
r.setUpdateTime(LocalDateTime.now());
reportMapper.save(r);
});
@ -293,7 +387,7 @@ public class ReportHighlightVideoService {
}
/**
* np 照片均匀拆成 nv+1 与组之间插入视频实现穿插编排类似相册成片
* 照片均匀拆成 nv+1 间插入小视频全局穿插模式使用
*/
private static List<ReportImage> interleaveSpread(List<ReportImage> photos, List<ReportImage> videos) {
int np = photos.size();
@ -332,7 +426,51 @@ public class ReportHighlightVideoService {
return a;
}
private double[] computeSegmentDurations(List<Path> sources, List<Boolean> isVideo, int totalSec) {
private static List<ReportImage> filterByPhotoType(List<ReportImage> imgs, String photoType) {
List<ReportImage> out = new ArrayList<>();
if (imgs == null) {
return out;
}
for (ReportImage img : imgs) {
if (photoType.equals(img.getPhotoType())) {
out.add(img);
}
}
return out;
}
private void appendResolvedMedia(List<Path> sources, List<Boolean> isVideo, ReportImage img) {
Path p = resolveUploadFile(img.getPhotoUrl());
if (p == null || !Files.isRegularFile(p)) {
return;
}
sources.add(p);
isVideo.add(isVideoMedia(img));
}
/**
* 服务前第一张 | 服务后第一张 左右拼接为 1080×1920 竖屏对比图左前右后
*/
private boolean buildComparisonCover(Path beforeFile, Path afterFile, Path outJpg) throws Exception {
String fc = "[0:v]scale=540:1920:force_original_aspect_ratio=decrease,pad=540:1920:(ow-iw)/2:(oh-ih)/2,setsar=1[va];"
+ "[1:v]scale=540:1920:force_original_aspect_ratio=decrease,pad=540:1920:(ow-iw)/2:(oh-ih)/2,setsar=1[vb];"
+ "[va][vb]hstack=inputs=2,format=yuv420p[v]";
int code = runFfmpeg(List.of(
ffmpegBinary, "-y",
"-i", beforeFile.toString(),
"-i", afterFile.toString(),
"-filter_complex", fc,
"-map", "[v]", "-frames:v", "1",
"-q:v", "2",
outJpg.toString()
));
return code == 0 && Files.isRegularFile(outJpg);
}
/**
* 按图片固定秒数 + 视频实长封顶得到自然总长超过 {@link #maxDurationSec} 时整体等比压缩
*/
private double[] computeSegmentDurationsNatural(List<Path> sources, List<Boolean> isVideo) {
int n = sources.size();
double[] raw = new double[n];
double sum = 0;
@ -349,25 +487,24 @@ public class ReportHighlightVideoService {
sum += raw[i];
}
if (sum <= 0) {
double eq = (double) totalSec / n;
for (int i = 0; i < n; i++) {
raw[i] = eq;
raw[i] = 1.0;
}
return raw;
sum = n;
}
double scale = totalSec / sum;
double targetTotal = Math.min(sum, maxDurationSec);
double scale = targetTotal / sum;
double[] out = new double[n];
double rounded = 0;
double acc = 0;
for (int i = 0; i < n; i++) {
out[i] = raw[i] * scale;
if (out[i] < 0.35) {
out[i] = 0.35;
}
rounded += out[i];
acc += out[i];
}
// 修正浮点误差使总长接近 totalSec
if (n > 0 && Math.abs(rounded - totalSec) > 0.05) {
out[n - 1] = Math.max(0.35, out[n - 1] + (totalSec - rounded));
if (n > 0) {
out[n - 1] = Math.max(0.35, out[n - 1] + (targetTotal - acc));
}
return out;
}

View File

@ -28,8 +28,12 @@ public class ReportLeadService {
private final ReportMapper reportMapper;
private final ServiceIntervalMapper serviceIntervalMapper;
private final UserMapper userMapper;
private final UserService userService;
private final WechatMiniProgramService wechatMiniProgramService;
/** 留资提交结果:记录 + 是否已将微信 openid 绑定到宠主账号S3→S4 */
public record LeadSubmitOutcome(ReportLead lead, boolean wechatBound) {}
/** 当服务类型不在已配置表中时使用的兜底间隔(天) */
private static final int FALLBACK_MIN = 28;
private static final int FALLBACK_MAX = 35;
@ -108,7 +112,7 @@ public class ReportLeadService {
*
* @param loginCode 小程序 uni.login code服务端 jscode2session openid/unionid 写入留资H5 可空
*/
public ReportLead submit(String reportToken, String phone, String consentIp, String loginCode) {
public LeadSubmitOutcome submit(String reportToken, String phone, String consentIp, String loginCode) {
Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null);
if (report == null) {
throw new IllegalArgumentException("报告不存在或已失效");
@ -165,7 +169,18 @@ public class ReportLeadService {
lead.setWechatUnionid(uid);
}
return reportLeadMapper.save(lead);
lead = reportLeadMapper.save(lead);
boolean wechatBound = false;
if (oid != null && !oid.isBlank()) {
User u = userService.ensureCustomerByPhone(phone);
if (u != null && "customer".equals(u.getRole())) {
User after = userService.bindWechatMiniIdentity(u.getId(), oid, uid);
wechatBound = after != null && after.getWechatOpenid() != null && !after.getWechatOpenid().isBlank();
}
}
return new LeadSubmitOutcome(lead, wechatBound);
}
/** 退订:按 unsubscribeToken 找记录并置状态 */

View File

@ -61,11 +61,11 @@ public class UserService {
}
/**
* 已通过短信或微信授权校验后的手机号登录与验证码登录成功后的逻辑一致
* 宠主侧按手机号确保存在 customer 账号不存在则创建已存在老板/员工账号时原样返回不降级角色
*/
public Map<String, Object> loginByVerifiedPhone(String phone) {
public User ensureCustomerByPhone(String phone) {
if (phone == null || !phone.matches("^1\\d{10}$")) {
return Map.of("code", 400, "message", "手机号格式不正确");
return null;
}
User user = userMapper.findByPhoneAndDeletedFalse(phone);
if (user == null) {
@ -80,6 +80,20 @@ public class UserService {
user.setDeleted(false);
user = userMapper.save(user);
}
return user;
}
/**
* 已通过短信或微信授权校验后的手机号登录与验证码登录成功后的逻辑一致
*/
public Map<String, Object> loginByVerifiedPhone(String phone) {
if (phone == null || !phone.matches("^1\\d{10}$")) {
return Map.of("code", 400, "message", "手机号格式不正确");
}
User user = ensureCustomerByPhone(phone);
if (user == null) {
return Map.of("code", 400, "message", "手机号格式不正确");
}
Store store = null;
if (user.getStoreId() != null) {
store = storeMapper.findByIdAndDeletedFalse(user.getStoreId()).orElse(null);

View File

@ -53,5 +53,6 @@ app:
ffprobe-binary: ffprobe
image-segment-sec: 2.0
video-cap-sec: 8.0
max-duration-sec: 300
bgm-path: ""
ken-burns-enabled: true

View File

@ -54,6 +54,8 @@ app:
image-segment-sec: 2.0
# 单段小视频最多取用秒数(源更长则截断)
video-cap-sec: 8.0
# 成片最大总秒数(素材自然时长超过则整体压缩)
max-duration-sec: ${HIGHLIGHT_MAX_DURATION_SEC:300}
# 可选:服务器上商用授权 BGM 文件绝对路径;留空则不混音
bgm-path: ${HIGHLIGHT_BGM_PATH:}
ken-burns-enabled: true