diff --git a/src/main/java/com/petstore/controller/ReportController.java b/src/main/java/com/petstore/controller/ReportController.java index dc7fbe0..e103c23 100644 --- a/src/main/java/com/petstore/controller/ReportController.java +++ b/src/main/java/com/petstore/controller/ReportController.java @@ -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)。composeMode:preset | interleave;时长由素材自动计算(有上限)。 */ @PostMapping(value = "/highlight/start", produces = MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8") public Map startHighlight(@RequestBody Map 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") diff --git a/src/main/java/com/petstore/controller/ReportLeadController.java b/src/main/java/com/petstore/controller/ReportLeadController.java index 72e9a29..3034ae3 100644 --- a/src/main/java/com/petstore/controller/ReportLeadController.java +++ b/src/main/java/com/petstore/controller/ReportLeadController.java @@ -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 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()); diff --git a/src/main/java/com/petstore/entity/HighlightFailReason.java b/src/main/java/com/petstore/entity/HighlightFailReason.java new file mode 100644 index 0000000..6d0be5e --- /dev/null +++ b/src/main/java/com/petstore/entity/HighlightFailReason.java @@ -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 -> "生成失败"; + }; + } +} diff --git a/src/main/java/com/petstore/entity/Report.java b/src/main/java/com/petstore/entity/Report.java index 3034ac3..bae7511 100644 --- a/src/main/java/com/petstore/entity/Report.java +++ b/src/main/java/com/petstore/entity/Report.java @@ -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; } diff --git a/src/main/java/com/petstore/service/ReportHighlightVideoService.java b/src/main/java/com/petstore/service/ReportHighlightVideoService.java index 0f7e4d1..f468062 100644 --- a/src/main/java/com/petstore/service/ReportHighlightVideoService.java +++ b/src/main/java/com/petstore/service/ReportHighlightVideoService.java @@ -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)。 - *

图片段使用轻微 Ken Burns(缩放平移);视频段保留原音(无音轨时补静音)。可选配置文件路径叠加 BGM。

- *

依赖本机安装 {@code ffmpeg};可选 {@code ffprobe} 用于读取视频时长与是否含音轨。

+ * 将服务报告素材拼为竖屏短片,时长按素材量自动计算(受 {@code max-duration-sec} 上限约束)。 + *

preset 预设顺序:

+ *
    + *
  1. 封面:服务前第一张 + 服务后第一张 横向对比图(均为照片且文件存在时);
  2. + *
  3. 服务前其余照片(上传顺序);
  4. + *
  5. 过程中照片与视频(上传顺序);
  6. + *
  7. 服务后除第一张外的其余照片;
  8. + *
  9. 收尾:服务后第一张再次作为最后一镜。
  10. + *
+ *

interleave:在「服务前→过程→服务后」排序后,将全部照片与全部小视频均匀穿插(组间插入)。

+ *

图片段 Ken Burns;视频段保留原音(无音轨补静音)。可选 BGM。

*/ @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; + /** 可选:本地绝对路径,商用授权 BGM(mp3/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 requestGenerate(Long reportId, int durationSec, Long operatorUserId, String role) { + /** + * @param composeModeRaw {@code preset} 预设顺序;{@code interleave} 穿插(亦接受 ordered / spread) + */ + public Map 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 opt = reportMapper.findByIdAndDeletedFalse(reportId); if (opt.isEmpty()) { return; } List ordered = sortedImages(reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(reportId)); - List photos = new ArrayList<>(); - List videos = new ArrayList<>(); - for (ReportImage img : ordered) { - if (isVideoMedia(img)) { - videos.add(img); - } else { - photos.add(img); - } - } - List timeline = interleaveSpread(photos, videos); - - List sources = new ArrayList<>(); - List 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 before = filterByPhotoType(ordered, "before"); + List during = filterByPhotoType(ordered, "during"); + List after = filterByPhotoType(ordered, "after"); Path workDir = Files.createTempDirectory("highlight_" + reportId + "_"); try { + List sources = new ArrayList<>(); + List isVideo = new ArrayList<>(); + + if ("interleave".equals(composeMode)) { + List photos = new ArrayList<>(); + List 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 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 interleaveSpread(List photos, List videos) { int np = photos.size(); @@ -332,7 +426,51 @@ public class ReportHighlightVideoService { return a; } - private double[] computeSegmentDurations(List sources, List isVideo, int totalSec) { + private static List filterByPhotoType(List imgs, String photoType) { + List 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 sources, List 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 sources, List 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; } diff --git a/src/main/java/com/petstore/service/ReportLeadService.java b/src/main/java/com/petstore/service/ReportLeadService.java index e92089d..6067155 100644 --- a/src/main/java/com/petstore/service/ReportLeadService.java +++ b/src/main/java/com/petstore/service/ReportLeadService.java @@ -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 找记录并置状态 */ diff --git a/src/main/java/com/petstore/service/UserService.java b/src/main/java/com/petstore/service/UserService.java index 14e28c2..a8de07f 100644 --- a/src/main/java/com/petstore/service/UserService.java +++ b/src/main/java/com/petstore/service/UserService.java @@ -61,11 +61,11 @@ public class UserService { } /** - * 已通过短信或微信授权校验后的手机号登录(与验证码登录成功后的逻辑一致)。 + * 宠主侧:按手机号确保存在 customer 账号(不存在则创建),已存在老板/员工账号时原样返回,不降级角色。 */ - public Map 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 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); diff --git a/src/main/resources/application-example.yml b/src/main/resources/application-example.yml index 5201eda..7bf058b 100644 --- a/src/main/resources/application-example.yml +++ b/src/main/resources/application-example.yml @@ -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 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d2e837b..d99dc11 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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