From afcd39011a30cc41850f7d90c971e6f07fac0aa5 Mon Sep 17 00:00:00 2001 From: MaDaLei Date: Sat, 18 Apr 2026 17:34:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20@Transactional=E7=AB=9E=E6=80=81=20+=20H?= =?UTF-8?q?5=20Canvas=20DPR=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../petstore/controller/PetController.java | 10 + .../petstore/controller/ReportController.java | 15 +- .../controller/ReportLeadController.java | 5 +- .../petstore/controller/UserController.java | 27 +- .../java/com/petstore/entity/ReportLead.java | 6 +- src/main/java/com/petstore/entity/User.java | 11 + .../petstore/mapper/AppointmentMapper.java | 3 + .../java/com/petstore/mapper/UserMapper.java | 3 + .../java/com/petstore/service/PetService.java | 90 +++++ .../service/ReportHighlightVideoService.java | 321 ++++++++++++++++-- .../petstore/service/ReportLeadService.java | 34 +- .../service/ReportTestimonialService.java | 2 + .../com/petstore/service/UserService.java | 43 +++ .../service/WechatMiniProgramService.java | 62 ++++ src/main/resources/application-example.yml | 11 + src/main/resources/application.yml | 10 +- 16 files changed, 613 insertions(+), 40 deletions(-) diff --git a/src/main/java/com/petstore/controller/PetController.java b/src/main/java/com/petstore/controller/PetController.java index 05636e4..706d1d5 100644 --- a/src/main/java/com/petstore/controller/PetController.java +++ b/src/main/java/com/petstore/controller/PetController.java @@ -55,4 +55,14 @@ public class PetController { @RequestParam String role) { return petService.delete(id, operatorUserId, role); } + + /** + * 某宠物的预约 + 报告时间线(宠主本人或本店员工/老板)。 + */ + @GetMapping("/history") + public Map history(@RequestParam Long petId, + @RequestParam Long operatorUserId, + @RequestParam String role) { + return petService.getServiceHistory(petId, operatorUserId, role); + } } diff --git a/src/main/java/com/petstore/controller/ReportController.java b/src/main/java/com/petstore/controller/ReportController.java index fb6244d..dc7fbe0 100644 --- a/src/main/java/com/petstore/controller/ReportController.java +++ b/src/main/java/com/petstore/controller/ReportController.java @@ -3,10 +3,12 @@ package com.petstore.controller; import com.petstore.entity.Report; import com.petstore.entity.ReportImage; import com.petstore.entity.Store; +import com.petstore.entity.User; import com.petstore.service.ReportHighlightVideoService; import com.petstore.service.ReportService; import com.petstore.service.ReportTestimonialService; import com.petstore.service.StoreService; +import com.petstore.service.UserService; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; @@ -25,7 +27,8 @@ public class ReportController { private final StoreService storeService; private final ReportHighlightVideoService reportHighlightVideoService; private final ReportTestimonialService reportTestimonialService; - + private final UserService userService; + @Value("${app.base-url:http://localhost:8080}") private String baseUrl; @@ -211,6 +214,16 @@ public class ReportController { data.put("appointmentTime", report.getAppointmentTime()); data.put("staffName", report.getStaffName()); data.put("createTime", report.getCreateTime()); + if (report.getUserId() != null) { + User staff = userService.findById(report.getUserId()); + if (staff != null && staff.getAvatar() != null && !staff.getAvatar().isBlank()) { + data.put("staffAvatar", fullUrl(staff.getAvatar())); + } else { + data.put("staffAvatar", null); + } + } else { + data.put("staffAvatar", null); + } if (store != null) { Map storeInfo = new HashMap<>(); storeInfo.put("name", store.getName()); diff --git a/src/main/java/com/petstore/controller/ReportLeadController.java b/src/main/java/com/petstore/controller/ReportLeadController.java index 4542e8d..72e9a29 100644 --- a/src/main/java/com/petstore/controller/ReportLeadController.java +++ b/src/main/java/com/petstore/controller/ReportLeadController.java @@ -40,12 +40,13 @@ public class ReportLeadController { @RequestBody Map body, HttpServletRequest request) { String phone = body.get("phone") == null ? null : body.get("phone").toString().trim(); + String loginCode = body.get("loginCode") == null ? null : body.get("loginCode").toString().trim(); Object consent = body.get("consent"); if (consent == null || !Boolean.parseBoolean(consent.toString())) { return Map.of("code", 400, "message", "需要勾选同意接收服务提醒"); } try { - ReportLead lead = reportLeadService.submit(token, phone, extractClientIp(request)); + ReportLead lead = reportLeadService.submit(token, phone, extractClientIp(request), loginCode); Map data = new HashMap<>(); data.put("ok", true); data.put("leadId", lead.getId()); @@ -85,6 +86,8 @@ public class ReportLeadController { m.put("petName", l.getPetName()); m.put("serviceType", l.getServiceType()); m.put("phone", l.getPhone()); + m.put("wechatOpenid", l.getWechatOpenid()); + m.put("wechatUnionid", l.getWechatUnionid()); m.put("reminderType", l.getReminderType()); m.put("remindDate", l.getRemindDate() == null ? null : l.getRemindDate().toString()); m.put("remindStatus", l.getRemindStatus()); diff --git a/src/main/java/com/petstore/controller/UserController.java b/src/main/java/com/petstore/controller/UserController.java index 8938e7d..c1c8992 100644 --- a/src/main/java/com/petstore/controller/UserController.java +++ b/src/main/java/com/petstore/controller/UserController.java @@ -48,6 +48,8 @@ public class UserController { return Map.of("code", 400, "message", "请求体不能为空"); } String phoneCode = params.get("phoneCode"); + /** 与 getPhoneNumber 不同:来自 uni.login / wx.login 的 js_code,用于 jscode2session 沉淀 openid */ + String loginCode = params.get("loginCode"); if (phoneCode == null || phoneCode.isBlank()) { return Map.of("code", 400, "message", "缺少手机号授权码"); } @@ -58,7 +60,30 @@ public class UserController { err.put("message", wx.errorMessage() != null ? wx.errorMessage() : "微信登录失败"); return err; } - return userService.loginByVerifiedPhone(wx.phone()); + Map result = userService.loginByVerifiedPhone(wx.phone()); + if (!Integer.valueOf(200).equals(result.get("code"))) { + return result; + } + if (loginCode != null && !loginCode.isBlank()) { + var js = wechatMiniProgramService.exchangeJsCode(loginCode); + if (js.isOk()) { + @SuppressWarnings("unchecked") + Map data = (Map) result.get("data"); + if (data != null) { + User u = (User) data.get("user"); + if (u != null && u.getId() != null) { + User bound = userService.bindWechatMiniIdentity(u.getId(), js.openid(), js.unionid()); + if (bound != null) { + bound.setPassword(null); + data.put("user", bound); + } + } + } + } else { + log.debug("openid 沉淀跳过(jscode2session 失败): {}", js.errorMessage()); + } + } + return result; } catch (Exception e) { log.error("wx-phone-login 异常", e); Map err = new HashMap<>(); diff --git a/src/main/java/com/petstore/entity/ReportLead.java b/src/main/java/com/petstore/entity/ReportLead.java index 18e12e7..5619bfe 100644 --- a/src/main/java/com/petstore/entity/ReportLead.java +++ b/src/main/java/com/petstore/entity/ReportLead.java @@ -42,10 +42,14 @@ public class ReportLead { @Column(length = 20) private String phone; - /** 预留:微信 openid(后续公众号/小程序接入后回填) */ + /** 微信 openid(小程序留资时 jscode2session 或与用户手机号对齐回填) */ @Column(name = "wechat_openid", length = 64) private String wechatOpenid; + /** 微信 unionid(开放平台账号关联后才有) */ + @Column(name = "wechat_unionid", length = 64) + private String wechatUnionid; + /** next_wash / next_grooming / next_bath_groom / next_nail / next_deworm 等 */ @Column(name = "reminder_type", length = 32) private String reminderType; diff --git a/src/main/java/com/petstore/entity/User.java b/src/main/java/com/petstore/entity/User.java index 174e994..8e62e3a 100644 --- a/src/main/java/com/petstore/entity/User.java +++ b/src/main/java/com/petstore/entity/User.java @@ -1,5 +1,6 @@ package com.petstore.entity; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; import lombok.Data; import java.time.LocalDateTime; @@ -29,6 +30,16 @@ public class User { /** 角色:boss / staff / customer */ private String role; + + /** 微信小程序 openid(jscode2session 沉淀;仅服务端使用,不随 JSON 返回前端) */ + @JsonIgnore + @Column(name = "wechat_openid", length = 64, unique = true) + private String wechatOpenid; + + /** 微信 unionid(需小程序绑定开放平台;未绑定时可能为空) */ + @JsonIgnore + @Column(name = "wechat_unionid", length = 64, unique = true) + private String wechatUnionid; @Column(name = "create_time") private LocalDateTime createTime; diff --git a/src/main/java/com/petstore/mapper/AppointmentMapper.java b/src/main/java/com/petstore/mapper/AppointmentMapper.java index 82d5503..3dc58cb 100644 --- a/src/main/java/com/petstore/mapper/AppointmentMapper.java +++ b/src/main/java/com/petstore/mapper/AppointmentMapper.java @@ -23,6 +23,9 @@ public interface AppointmentMapper extends JpaRepository { Page findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status, Pageable pageable); Optional findByIdAndDeletedFalse(Long id); + /** 某宠物档案关联的全部预约(按预约时间倒序) */ + List findByPetIdAndDeletedFalseOrderByAppointmentTimeDesc(Long petId); + @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 findOccupiedAppointmentTimes( @Param("storeId") Long storeId, diff --git a/src/main/java/com/petstore/mapper/UserMapper.java b/src/main/java/com/petstore/mapper/UserMapper.java index f9ce49a..516da39 100644 --- a/src/main/java/com/petstore/mapper/UserMapper.java +++ b/src/main/java/com/petstore/mapper/UserMapper.java @@ -9,6 +9,9 @@ import java.util.Optional; public interface UserMapper extends JpaRepository { User findByUsernameAndDeletedFalse(String username); User findByPhoneAndDeletedFalse(String phone); + Optional findByWechatOpenidAndDeletedFalse(String wechatOpenid); + + Optional findByWechatUnionidAndDeletedFalse(String wechatUnionid); List findByStoreIdAndDeletedFalse(Long storeId); Optional findByIdAndDeletedFalse(Long id); } diff --git a/src/main/java/com/petstore/service/PetService.java b/src/main/java/com/petstore/service/PetService.java index 6c8e99e..c713ec1 100644 --- a/src/main/java/com/petstore/service/PetService.java +++ b/src/main/java/com/petstore/service/PetService.java @@ -1,13 +1,19 @@ package com.petstore.service; +import com.petstore.entity.Appointment; import com.petstore.entity.Pet; +import com.petstore.entity.Report; +import com.petstore.entity.Store; import com.petstore.mapper.AppointmentMapper; import com.petstore.mapper.PetMapper; +import com.petstore.mapper.ReportMapper; import com.petstore.mapper.UserMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -18,6 +24,8 @@ public class PetService { private final PetMapper petMapper; private final UserMapper userMapper; private final AppointmentMapper appointmentMapper; + private final ReportMapper reportMapper; + private final StoreService storeService; /** 客户:仅自己的宠物 */ public List listByOwner(Long ownerUserId) { @@ -114,4 +122,86 @@ public class PetService { petMapper.save(pet); return Map.of("code", 200, "message", "已删除"); } + + /** + * 按宠物维度:预约与服务报告时间线(复购与情感连接)。 + * 仅宠主本人或本店曾服务过该宠物的老板/员工可查看。 + */ + public Map getServiceHistory(Long petId, Long operatorUserId, String role) { + if (petId == null || operatorUserId == null || role == null || role.isBlank()) { + return Map.of("code", 400, "message", "参数不完整"); + } + Optional pOpt = petMapper.findByIdAndDeletedFalse(petId); + if (pOpt.isEmpty()) { + return Map.of("code", 404, "message", "宠物不存在"); + } + Pet pet = pOpt.get(); + if (!canViewPetHistory(pet, operatorUserId, role)) { + return Map.of("code", 403, "message", "无权查看该宠物的服务记录"); + } + + List appts = appointmentMapper.findByPetIdAndDeletedFalseOrderByAppointmentTimeDesc(petId); + List> items = new ArrayList<>(); + int doneCount = 0; + int cancelCount = 0; + for (Appointment a : appts) { + if ("done".equals(a.getStatus())) { + doneCount++; + } else if ("cancel".equals(a.getStatus())) { + cancelCount++; + } + Map row = new HashMap<>(); + row.put("appointmentId", a.getId()); + row.put("appointmentTime", a.getAppointmentTime()); + row.put("serviceType", a.getServiceType()); + row.put("status", a.getStatus()); + row.put("storeId", a.getStoreId()); + if (a.getStoreId() != null) { + Store st = storeService.findById(a.getStoreId()); + row.put("storeName", st != null ? st.getName() : null); + } else { + row.put("storeName", null); + } + row.put("remark", a.getRemark()); + Optional rOpt = reportMapper.findFirstByAppointmentIdAndDeletedFalseOrderByCreateTimeDesc(a.getId()); + if (rOpt.isPresent()) { + Report r = rOpt.get(); + row.put("reportId", r.getId()); + row.put("reportToken", r.getReportToken()); + } else { + row.put("reportId", null); + row.put("reportToken", null); + } + items.add(row); + } + + Map summary = new HashMap<>(); + summary.put("totalAppointments", appts.size()); + summary.put("completedCount", doneCount); + summary.put("cancelledCount", cancelCount); + + Map data = new HashMap<>(); + data.put("pet", pet); + data.put("summary", summary); + data.put("items", items); + return Map.of("code", 200, "data", data); + } + + private boolean canViewPetHistory(Pet pet, Long operatorUserId, String role) { + if ("customer".equals(role)) { + return pet.getOwnerUserId() != null && pet.getOwnerUserId().equals(operatorUserId); + } + if ("boss".equals(role) || "staff".equals(role)) { + var uOpt = userMapper.findByIdAndDeletedFalse(operatorUserId); + if (uOpt.isEmpty()) { + return false; + } + Long storeId = uOpt.get().getStoreId(); + if (storeId == null) { + return false; + } + return appointmentMapper.existsByPetIdAndStoreIdAndDeletedFalse(pet.getId(), storeId); + } + return false; + } } diff --git a/src/main/java/com/petstore/service/ReportHighlightVideoService.java b/src/main/java/com/petstore/service/ReportHighlightVideoService.java index ac01cf0..0f7e4d1 100644 --- a/src/main/java/com/petstore/service/ReportHighlightVideoService.java +++ b/src/main/java/com/petstore/service/ReportHighlightVideoService.java @@ -34,13 +34,15 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; /** - * 将服务报告中的服务前 / 过程中 / 服务后图片与短视频,按时间线拼接为竖屏短片(默认 15s 或 30s)。 - *

依赖本机安装 {@code ffmpeg};后续可在此类接入云端「生成式视频」API,与本地管线切换。

+ * 将服务报告中的服务前 / 过程中 / 服务后图片与短视频,按「照片与视频穿插」编排后拼接为竖屏短片(默认 15s 或 30s)。 + *

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

+ *

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

*/ @Service public class ReportHighlightVideoService { private static final int[] ALLOWED_DURATIONS = {15, 30}; + private static final int OUTPUT_FPS = 30; private final ReportMapper reportMapper; private final ReportImageMapper reportImageMapper; @@ -51,9 +53,30 @@ public class ReportHighlightVideoService { @Value("${upload.path:uploads/}") private String uploadPath; + @Value("${app.base-url:http://localhost:8080}") + private String baseUrl; + @Value("${app.highlight-video.ffmpeg-binary:ffmpeg}") private String ffmpegBinary; + @Value("${app.highlight-video.ffprobe-binary:ffprobe}") + private String ffprobeBinary; + + /** 在总时长内,单张图片的「权重」秒数(会与视频一起按比例缩放) */ + @Value("${app.highlight-video.image-segment-sec:2.0}") + private double imageSegmentSec; + + /** 单段小视频最多取用时长(秒),实际还会按总时长预算缩放 */ + @Value("${app.highlight-video.video-cap-sec:8.0}") + private double videoCapSec; + + /** 可选:本地绝对路径,商用授权 BGM(mp3/aac/m4a 等 ffmpeg 可读格式) */ + @Value("${app.highlight-video.bgm-path:}") + private String bgmPath; + + @Value("${app.highlight-video.ken-burns-enabled:true}") + private boolean kenBurnsEnabled; + private final ConcurrentHashMap reportLocks = new ConcurrentHashMap<>(); public ReportHighlightVideoService( @@ -144,17 +167,27 @@ public class ReportHighlightVideoService { if (opt.isEmpty()) { return; } - List imgs = sortedImages(reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(reportId)); + 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 : imgs) { + for (ReportImage img : timeline) { Path p = resolveUploadFile(img.getPhotoUrl()); if (p == null || !Files.isRegularFile(p)) { continue; } - boolean video = isVideoMedia(img); sources.add(p); - isVideo.add(video); + isVideo.add(isVideoMedia(img)); } if (sources.isEmpty()) { markFailed(reportId, "素材文件在服务器上不存在或无法读取"); @@ -162,9 +195,14 @@ public class ReportHighlightVideoService { } int n = sources.size(); - double perClip = (double) durationSec / n; - if (perClip < 0.35) { - perClip = 0.35; + double[] segmentSecs = computeSegmentDurations(sources, isVideo, durationSec); + double sumSeg = 0; + for (double s : segmentSecs) { + sumSeg += s; + } + if (sumSeg < 0.5) { + markFailed(reportId, "分段时长计算异常"); + return; } Path workDir = Files.createTempDirectory("highlight_" + reportId + "_"); @@ -173,9 +211,9 @@ public class ReportHighlightVideoService { 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); + encodeVideoSegment(sources.get(i), seg, segmentSecs[i]); } else { - encodeImageSegment(sources.get(i), seg, perClip); + encodeImageSegment(sources.get(i), seg, segmentSecs[i]); } segments.add(seg); } @@ -192,41 +230,42 @@ public class ReportHighlightVideoService { } Path merged = workDir.resolve("merged.mp4"); - Path sourceToSave; // 最终用于保存的文件(会在下面赋值) + 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 + "-profile:v", "baseline", "-level", "3.0", "-c:a", "aac", "-b:a", "128k", - "-movflags", "+faststart", // MP4 streaming 优化,微信要求 + "-movflags", "+faststart", "-t", String.valueOf(durationSec), merged.toString() )); if (c2 != 0) { throw new IllegalStateException("ffmpeg 拼接失败(退出码 " + c2 + ")"); } - sourceToSave = merged; // 转码后文件已满足兼容性要求 + sourceToSave = merged; } else { - sourceToSave = merged; // stream copy 成功,直接用 + sourceToSave = merged; } + Path afterBgm = applyOptionalBgm(sourceToSave, workDir); + 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); + Files.copy(afterBgm, finalPath, StandardCopyOption.REPLACE_EXISTING); - // 用完整 URL(包含 https://api.s-good.com),与前端 imgUrl() 逻辑一致 - String url = "https://api.s-good.com/api/upload/image/" + datePath + "/" + filename; + String publicBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + String url = publicBase + "/api/upload/image/" + datePath + "/" + filename; synchronized (lockFor(reportId)) { reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> { @@ -253,6 +292,173 @@ public class ReportHighlightVideoService { } } + /** + * 将 np 张照片均匀拆成 nv+1 组,组与组之间插入视频,实现「穿插」编排(类似相册成片)。 + */ + private static List interleaveSpread(List photos, List videos) { + int np = photos.size(); + int nv = videos.size(); + if (nv == 0) { + return new ArrayList<>(photos); + } + int groups = nv + 1; + int[] sizes = splitEven(np, groups); + List out = new ArrayList<>(np + nv); + int pi = 0; + for (int g = 0; g < groups; g++) { + for (int j = 0; j < sizes[g]; j++) { + out.add(photos.get(pi++)); + } + if (g < nv) { + out.add(videos.get(g)); + } + } + return out; + } + + private static int[] splitEven(int total, int parts) { + if (parts <= 0) { + return new int[0]; + } + int[] a = new int[parts]; + if (total == 0) { + return a; + } + int base = total / parts; + int rem = total % parts; + for (int i = 0; i < parts; i++) { + a[i] = base + (i < rem ? 1 : 0); + } + return a; + } + + private double[] computeSegmentDurations(List sources, List isVideo, int totalSec) { + int n = sources.size(); + double[] raw = new double[n]; + double sum = 0; + for (int i = 0; i < n; i++) { + if (Boolean.TRUE.equals(isVideo.get(i))) { + double probed = probeDurationSeconds(sources.get(i)); + if (probed <= 0) { + probed = 10.0; + } + raw[i] = Math.min(probed, videoCapSec); + } else { + raw[i] = imageSegmentSec; + } + sum += raw[i]; + } + if (sum <= 0) { + double eq = (double) totalSec / n; + for (int i = 0; i < n; i++) { + raw[i] = eq; + } + return raw; + } + double scale = totalSec / sum; + double[] out = new double[n]; + double rounded = 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]; + } + // 修正浮点误差,使总长接近 totalSec + if (n > 0 && Math.abs(rounded - totalSec) > 0.05) { + out[n - 1] = Math.max(0.35, out[n - 1] + (totalSec - rounded)); + } + return out; + } + + private Path applyOptionalBgm(Path mergedMp4, Path workDir) throws Exception { + if (bgmPath == null || bgmPath.isBlank()) { + return mergedMp4; + } + Path bgm = Paths.get(bgmPath.trim()); + if (!Files.isRegularFile(bgm)) { + return mergedMp4; + } + Path out = workDir.resolve("with_bgm.mp4"); + // BGM 循环铺满成片长度,与人声音轨混合(人声音量来自原片) + int code = runFfmpeg(List.of( + ffmpegBinary, "-y", + "-i", mergedMp4.toString(), + "-stream_loop", "-1", "-i", bgm.toString(), + "-filter_complex", "[1:a]volume=0.28[bg];[0:a][bg]amix=inputs=2:duration=first:dropout_transition=2[aout]", + "-map", "0:v:0", "-map", "[aout]", + "-c:v", "copy", + "-c:a", "aac", "-b:a", "160k", + "-movflags", "+faststart", + "-shortest", + out.toString() + )); + if (code != 0) { + return mergedMp4; + } + return out; + } + + private double probeDurationSeconds(Path videoFile) { + if (!ffprobeAvailable()) { + return -1; + } + try { + ProcessBuilder pb = new ProcessBuilder( + ffprobeBinary, "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", videoFile.toString()); + pb.redirectErrorStream(true); + Process p = pb.start(); + String line; + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + line = r.readLine(); + } + p.waitFor(); + if (line != null && !line.isBlank()) { + return Double.parseDouble(line.trim()); + } + } catch (Exception ignored) { + } + return -1; + } + + private boolean hasAudioStream(Path videoFile) { + if (!ffprobeAvailable()) { + return false; + } + try { + ProcessBuilder pb = new ProcessBuilder( + ffprobeBinary, "-v", "error", "-select_streams", "a", + "-show_entries", "stream=codec_type", "-of", "csv=p=0", videoFile.toString()); + pb.redirectErrorStream(true); + Process p = pb.start(); + StringBuilder sb = new StringBuilder(); + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + String ln; + while ((ln = r.readLine()) != null) { + sb.append(ln); + } + } + int code = p.waitFor(); + return code == 0 && sb.toString().toLowerCase(Locale.ROOT).contains("audio"); + } catch (Exception e) { + return false; + } + } + + private boolean ffprobeAvailable() { + try { + ProcessBuilder pb = new ProcessBuilder(ffprobeBinary, "-version"); + pb.redirectErrorStream(true); + Process p = pb.start(); + drain(p); + return p.waitFor() == 0; + } catch (Exception e) { + return false; + } + } + private boolean hasRenderableMedia(List imgs) { if (imgs == null || imgs.isEmpty()) { return false; @@ -295,9 +501,41 @@ public class ReportHighlightVideoService { } private void encodeImageSegment(Path image, Path out, double seconds) throws Exception { + String t = String.format(Locale.ROOT, "%.3f", seconds); + int frames = Math.max(2, (int) Math.round(seconds * OUTPUT_FPS)); + String baseScale = "scale=1080:1920:force_original_aspect_ratio=decrease," + + "pad=1080:1920:(ow-iw)/2:(oh-ih)/2"; + String vf; + if (kenBurnsEnabled) { + vf = baseScale + ",zoompan=z='if(eq(on,1),1,min(zoom+0.0015,1.22))':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=" + + frames + ":s=1080x1920:fps=" + OUTPUT_FPS + ",format=yuv420p"; + } else { + vf = baseScale + ",format=yuv420p,fps=" + OUTPUT_FPS; + } + 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) { + if (kenBurnsEnabled) { + encodeImageSegmentStatic(image, out, seconds); + return; + } + throw new IllegalStateException("图片转视频失败(退出码 " + code + ")"); + } + } + + private void encodeImageSegmentStatic(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"; + + "pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p,fps=" + OUTPUT_FPS; int code = runFfmpeg(List.of( ffmpegBinary, "-y", "-loop", "1", "-framerate", "1", "-i", image.toString(), @@ -317,19 +555,34 @@ public class ReportHighlightVideoService { 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() - )); + + "pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p,fps=" + OUTPUT_FPS; + boolean audio = hasAudioStream(video); + int code; + if (audio) { + code = runFfmpeg(List.of( + ffmpegBinary, "-y", + "-i", video.toString(), + "-vf", vf, + "-t", t, + "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", + "-c:a", "aac", "-b:a", "128k", + "-pix_fmt", "yuv420p", + out.toString() + )); + } else { + 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", + "-t", t, + "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", + "-c:a", "aac", "-b:a", "96k", + "-pix_fmt", "yuv420p", + out.toString() + )); + } if (code != 0) { throw new IllegalStateException("视频片段重编码失败(退出码 " + code + ")"); } diff --git a/src/main/java/com/petstore/service/ReportLeadService.java b/src/main/java/com/petstore/service/ReportLeadService.java index 9b44977..e92089d 100644 --- a/src/main/java/com/petstore/service/ReportLeadService.java +++ b/src/main/java/com/petstore/service/ReportLeadService.java @@ -3,9 +3,11 @@ package com.petstore.service; import com.petstore.entity.Report; import com.petstore.entity.ReportLead; import com.petstore.entity.ServiceInterval; +import com.petstore.entity.User; import com.petstore.mapper.ReportLeadMapper; import com.petstore.mapper.ReportMapper; import com.petstore.mapper.ServiceIntervalMapper; +import com.petstore.mapper.UserMapper; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -25,6 +27,8 @@ public class ReportLeadService { private final ReportLeadMapper reportLeadMapper; private final ReportMapper reportMapper; private final ServiceIntervalMapper serviceIntervalMapper; + private final UserMapper userMapper; + private final WechatMiniProgramService wechatMiniProgramService; /** 当服务类型不在已配置表中时使用的兜底间隔(天) */ private static final int FALLBACK_MIN = 28; @@ -101,8 +105,10 @@ public class ReportLeadService { /** * 幂等创建/更新留资。 * 已存在 (reportId, phone) 组合时只更新状态和提醒日期,不重复创建。 + * + * @param loginCode 小程序 uni.login 的 code,服务端 jscode2session 取 openid/unionid 写入留资;H5 可空。 */ - public ReportLead submit(String reportToken, String phone, String consentIp) { + public ReportLead submit(String reportToken, String phone, String consentIp, String loginCode) { Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null); if (report == null) { throw new IllegalArgumentException("报告不存在或已失效"); @@ -133,6 +139,32 @@ public class ReportLeadService { lead.setConsentAt(now); lead.setConsentIp(consentIp); lead.setUpdateTime(now); + + String oid = null; + String uid = null; + if (loginCode != null && !loginCode.isBlank()) { + WechatMiniProgramService.JsCode2SessionResult js = wechatMiniProgramService.exchangeJsCode(loginCode); + if (js.isOk()) { + oid = js.openid(); + uid = js.unionid(); + } + } + User byPhone = userMapper.findByPhoneAndDeletedFalse(phone); + if (byPhone != null) { + if (oid == null || oid.isBlank()) { + oid = byPhone.getWechatOpenid(); + } + if (uid == null || uid.isBlank()) { + uid = byPhone.getWechatUnionid(); + } + } + if (oid != null && !oid.isBlank()) { + lead.setWechatOpenid(oid); + } + if (uid != null && !uid.isBlank()) { + lead.setWechatUnionid(uid); + } + return reportLeadMapper.save(lead); } diff --git a/src/main/java/com/petstore/service/ReportTestimonialService.java b/src/main/java/com/petstore/service/ReportTestimonialService.java index 6e69598..b175b3e 100644 --- a/src/main/java/com/petstore/service/ReportTestimonialService.java +++ b/src/main/java/com/petstore/service/ReportTestimonialService.java @@ -5,6 +5,7 @@ import com.petstore.entity.ReportTestimonial; import com.petstore.mapper.ReportMapper; import com.petstore.mapper.ReportTestimonialMapper; import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -19,6 +20,7 @@ public class ReportTestimonialService { /** * 保存或更新该报告下的宠主寄语(幂等:按 report_id 唯一)。 */ + @Transactional public ReportTestimonial submit(String reportToken, String content, boolean isPublic) { Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null); if (report == null) { diff --git a/src/main/java/com/petstore/service/UserService.java b/src/main/java/com/petstore/service/UserService.java index 97a1516..14e28c2 100644 --- a/src/main/java/com/petstore/service/UserService.java +++ b/src/main/java/com/petstore/service/UserService.java @@ -90,6 +90,49 @@ public class UserService { return Map.of("code", 200, "message", "登录成功", "data", data); } + /** + * 绑定小程序 openid / unionid;若标识曾绑定其他账号则先解绑(以当前登录身份为准)。 + * unionid 为空字符串时不覆盖库内已有 unionid。 + */ + public User bindWechatMiniIdentity(Long userId, String openid, String unionid) { + if (userId == null || openid == null || openid.isBlank()) { + return null; + } + User target = userMapper.findByIdAndDeletedFalse(userId).orElse(null); + if (target == null) { + return null; + } + boolean sameOpen = openid.equals(target.getWechatOpenid()); + String uNorm = (unionid != null && !unionid.isBlank()) ? unionid : null; + boolean sameUnion = (uNorm == null && target.getWechatUnionid() == null) + || (uNorm != null && uNorm.equals(target.getWechatUnionid())); + if (sameOpen && sameUnion) { + return target; + } + userMapper.findByWechatOpenidAndDeletedFalse(openid).ifPresent(other -> { + if (!other.getId().equals(userId)) { + other.setWechatOpenid(null); + other.setUpdateTime(LocalDateTime.now()); + userMapper.save(other); + } + }); + if (uNorm != null) { + userMapper.findByWechatUnionidAndDeletedFalse(uNorm).ifPresent(other -> { + if (!other.getId().equals(userId)) { + other.setWechatUnionid(null); + other.setUpdateTime(LocalDateTime.now()); + userMapper.save(other); + } + }); + } + target.setWechatOpenid(openid); + if (uNorm != null) { + target.setWechatUnionid(uNorm); + } + target.setUpdateTime(LocalDateTime.now()); + return userMapper.save(target); + } + public Map registerStaff(String phone, String password, String name, String inviteCode) { if (userMapper.findByPhoneAndDeletedFalse(phone) != null) { return Map.of("code", 400, "message", "手机号已注册"); diff --git a/src/main/java/com/petstore/service/WechatMiniProgramService.java b/src/main/java/com/petstore/service/WechatMiniProgramService.java index 574ac63..5188f41 100644 --- a/src/main/java/com/petstore/service/WechatMiniProgramService.java +++ b/src/main/java/com/petstore/service/WechatMiniProgramService.java @@ -38,6 +38,68 @@ public class WechatMiniProgramService { private volatile String cachedAccessToken; private volatile long tokenExpiresAtEpochMs; + /** + * 小程序 wx.login 返回的 code 换 session(含 openid)。 + * 文档:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html + */ + public record JsCode2SessionResult(String openid, String unionid, String errorMessage) { + public static JsCode2SessionResult ok(String openid, String unionid) { + return new JsCode2SessionResult(openid, unionid, null); + } + + public static JsCode2SessionResult fail(String errorMessage) { + String msg = (errorMessage != null && !errorMessage.isBlank()) ? errorMessage : "未知错误"; + return new JsCode2SessionResult(null, null, msg); + } + + public boolean isOk() { + return openid != null && !openid.isBlank(); + } + } + + /** + * @param jsCode 前端 uni.login / wx.login 返回的 code,与 getPhoneNumber 的 code 不同 + */ + public JsCode2SessionResult exchangeJsCode(String jsCode) { + if (jsCode == null || jsCode.isBlank()) { + return JsCode2SessionResult.fail("缺少登录凭证"); + } + if (!isConfigured()) { + log.warn("微信 appid/appsecret 未配置,无法换取 openid"); + return JsCode2SessionResult.fail("服务端未配置微信小程序 AppID/AppSecret"); + } + try { + String appid = URLEncoder.encode(wechatConfig.getAppid(), StandardCharsets.UTF_8); + String secret = URLEncoder.encode(wechatConfig.getAppsecret(), StandardCharsets.UTF_8); + String code = URLEncoder.encode(jsCode, StandardCharsets.UTF_8); + String url = String.format( + "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", + appid, secret, code); + HttpResult hr = httpGet(url); + String body = hr.body(); + if (body == null || body.isBlank()) { + log.warn("jscode2session 响应为空, httpStatus={}", hr.statusCode()); + return JsCode2SessionResult.fail(emptyBodyHint("jscode2session", hr.statusCode())); + } + JsonNode root = objectMapper.readTree(body); + if (root.hasNonNull("openid")) { + String oid = root.path("openid").asText(""); + String uniRaw = root.path("unionid").asText(""); + String uni = (uniRaw == null || uniRaw.isBlank()) ? null : uniRaw; + if (!oid.isBlank()) { + return JsCode2SessionResult.ok(oid, uni); + } + } + int errcode = root.path("errcode").asInt(-1); + String errmsg = root.path("errmsg").asText(""); + log.warn("jscode2session 失败: {}", body); + return JsCode2SessionResult.fail(formatWxError(errcode, errmsg)); + } catch (Exception e) { + log.warn("jscode2session 异常", e); + return JsCode2SessionResult.fail("连接微信服务异常: " + e.getMessage()); + } + } + public record WxPhoneExchangeResult(String phone, String errorMessage) { public static WxPhoneExchangeResult ok(String phone) { return new WxPhoneExchangeResult(phone, null); diff --git a/src/main/resources/application-example.yml b/src/main/resources/application-example.yml index b906372..5201eda 100644 --- a/src/main/resources/application-example.yml +++ b/src/main/resources/application-example.yml @@ -44,3 +44,14 @@ wechat: appid: ${WECHAT_APPID:} appsecret: ${WECHAT_APPSECRET:} redirect_uri: http://localhost:8080/api/wechat/callback + +# 服务回顾短片(ffmpeg;可选 ffprobe 读时长/音轨) +app: + base-url: ${APP_BASE_URL:http://localhost:8080} + highlight-video: + ffmpeg-binary: ffmpeg + ffprobe-binary: ffprobe + image-segment-sec: 2.0 + video-cap-sec: 8.0 + bgm-path: "" + ken-burns-enabled: true diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d00ac11..d2e837b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,11 +44,19 @@ logging: level: com.petstore: debug -# 服务回顾短片:本地 ffmpeg 拼接(可换为云端生成式视频 API) +# 服务回顾短片:本地 ffmpeg 拼接(穿插编排、Ken Burns、可选 BGM) app: base-url: ${APP_BASE_URL:https://api.s-good.com} highlight-video: ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg} + ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe} + # 总时长内单张图的权重(秒),与视频段一起按比例缩放 + image-segment-sec: 2.0 + # 单段小视频最多取用秒数(源更长则截断) + video-cap-sec: 8.0 + # 可选:服务器上商用授权 BGM 文件绝对路径;留空则不混音 + bgm-path: ${HIGHLIGHT_BGM_PATH:} + ken-burns-enabled: true # 微信小程序(与 manifest / 微信后台一致;生产环境建议用环境变量覆盖) wechat: