fix: @Transactional竞态 + H5 Canvas DPR适配

This commit is contained in:
MaDaLei 2026-04-18 17:34:23 +08:00
parent a6b48f892d
commit afcd39011a
16 changed files with 613 additions and 40 deletions

View File

@ -55,4 +55,14 @@ public class PetController {
@RequestParam String role) { @RequestParam String role) {
return petService.delete(id, operatorUserId, role); return petService.delete(id, operatorUserId, role);
} }
/**
* 某宠物的预约 + 报告时间线宠主本人或本店员工/老板
*/
@GetMapping("/history")
public Map<String, Object> history(@RequestParam Long petId,
@RequestParam Long operatorUserId,
@RequestParam String role) {
return petService.getServiceHistory(petId, operatorUserId, role);
}
} }

View File

@ -3,10 +3,12 @@ package com.petstore.controller;
import com.petstore.entity.Report; import com.petstore.entity.Report;
import com.petstore.entity.ReportImage; import com.petstore.entity.ReportImage;
import com.petstore.entity.Store; import com.petstore.entity.Store;
import com.petstore.entity.User;
import com.petstore.service.ReportHighlightVideoService; import com.petstore.service.ReportHighlightVideoService;
import com.petstore.service.ReportService; import com.petstore.service.ReportService;
import com.petstore.service.ReportTestimonialService; import com.petstore.service.ReportTestimonialService;
import com.petstore.service.StoreService; import com.petstore.service.StoreService;
import com.petstore.service.UserService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -25,6 +27,7 @@ public class ReportController {
private final StoreService storeService; private final StoreService storeService;
private final ReportHighlightVideoService reportHighlightVideoService; private final ReportHighlightVideoService reportHighlightVideoService;
private final ReportTestimonialService reportTestimonialService; private final ReportTestimonialService reportTestimonialService;
private final UserService userService;
@Value("${app.base-url:http://localhost:8080}") @Value("${app.base-url:http://localhost:8080}")
private String baseUrl; private String baseUrl;
@ -211,6 +214,16 @@ public class ReportController {
data.put("appointmentTime", report.getAppointmentTime()); data.put("appointmentTime", report.getAppointmentTime());
data.put("staffName", report.getStaffName()); data.put("staffName", report.getStaffName());
data.put("createTime", report.getCreateTime()); 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) { if (store != null) {
Map<String, Object> storeInfo = new HashMap<>(); Map<String, Object> storeInfo = new HashMap<>();
storeInfo.put("name", store.getName()); storeInfo.put("name", store.getName());

View File

@ -40,12 +40,13 @@ public class ReportLeadController {
@RequestBody Map<String, Object> body, @RequestBody Map<String, Object> body,
HttpServletRequest request) { HttpServletRequest request) {
String phone = body.get("phone") == null ? null : body.get("phone").toString().trim(); 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"); Object consent = body.get("consent");
if (consent == null || !Boolean.parseBoolean(consent.toString())) { if (consent == null || !Boolean.parseBoolean(consent.toString())) {
return Map.of("code", 400, "message", "需要勾选同意接收服务提醒"); return Map.of("code", 400, "message", "需要勾选同意接收服务提醒");
} }
try { try {
ReportLead lead = reportLeadService.submit(token, phone, extractClientIp(request)); ReportLead lead = reportLeadService.submit(token, phone, extractClientIp(request), loginCode);
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
data.put("ok", true); data.put("ok", true);
data.put("leadId", lead.getId()); data.put("leadId", lead.getId());
@ -85,6 +86,8 @@ public class ReportLeadController {
m.put("petName", l.getPetName()); m.put("petName", l.getPetName());
m.put("serviceType", l.getServiceType()); m.put("serviceType", l.getServiceType());
m.put("phone", l.getPhone()); m.put("phone", l.getPhone());
m.put("wechatOpenid", l.getWechatOpenid());
m.put("wechatUnionid", l.getWechatUnionid());
m.put("reminderType", l.getReminderType()); m.put("reminderType", l.getReminderType());
m.put("remindDate", l.getRemindDate() == null ? null : l.getRemindDate().toString()); m.put("remindDate", l.getRemindDate() == null ? null : l.getRemindDate().toString());
m.put("remindStatus", l.getRemindStatus()); m.put("remindStatus", l.getRemindStatus());

View File

@ -48,6 +48,8 @@ public class UserController {
return Map.of("code", 400, "message", "请求体不能为空"); return Map.of("code", 400, "message", "请求体不能为空");
} }
String phoneCode = params.get("phoneCode"); String phoneCode = params.get("phoneCode");
/** 与 getPhoneNumber 不同:来自 uni.login / wx.login 的 js_code用于 jscode2session 沉淀 openid */
String loginCode = params.get("loginCode");
if (phoneCode == null || phoneCode.isBlank()) { if (phoneCode == null || phoneCode.isBlank()) {
return Map.of("code", 400, "message", "缺少手机号授权码"); return Map.of("code", 400, "message", "缺少手机号授权码");
} }
@ -58,7 +60,30 @@ public class UserController {
err.put("message", wx.errorMessage() != null ? wx.errorMessage() : "微信登录失败"); err.put("message", wx.errorMessage() != null ? wx.errorMessage() : "微信登录失败");
return err; return err;
} }
return userService.loginByVerifiedPhone(wx.phone()); Map<String, Object> 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<String, Object> data = (Map<String, Object>) 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) { } catch (Exception e) {
log.error("wx-phone-login 异常", e); log.error("wx-phone-login 异常", e);
Map<String, Object> err = new HashMap<>(); Map<String, Object> err = new HashMap<>();

View File

@ -42,10 +42,14 @@ public class ReportLead {
@Column(length = 20) @Column(length = 20)
private String phone; private String phone;
/** 预留:微信 openid后续公众号/小程序接入后回填) */ /** 微信 openid小程序留资时 jscode2session 或与用户手机号对齐回填) */
@Column(name = "wechat_openid", length = 64) @Column(name = "wechat_openid", length = 64)
private String wechatOpenid; private String wechatOpenid;
/** 微信 unionid开放平台账号关联后才有 */
@Column(name = "wechat_unionid", length = 64)
private String wechatUnionid;
/** next_wash / next_grooming / next_bath_groom / next_nail / next_deworm 等 */ /** next_wash / next_grooming / next_bath_groom / next_nail / next_deworm 等 */
@Column(name = "reminder_type", length = 32) @Column(name = "reminder_type", length = 32)
private String reminderType; private String reminderType;

View File

@ -1,5 +1,6 @@
package com.petstore.entity; package com.petstore.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -30,6 +31,16 @@ public class User {
/** 角色boss / staff / customer */ /** 角色boss / staff / customer */
private String role; private String role;
/** 微信小程序 openidjscode2session 沉淀;仅服务端使用,不随 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") @Column(name = "create_time")
private LocalDateTime createTime; private LocalDateTime createTime;

View File

@ -23,6 +23,9 @@ public interface AppointmentMapper extends JpaRepository<Appointment, Long> {
Page<Appointment> findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status, Pageable pageable); Page<Appointment> findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status, Pageable pageable);
Optional<Appointment> findByIdAndDeletedFalse(Long id); Optional<Appointment> findByIdAndDeletedFalse(Long id);
/** 某宠物档案关联的全部预约(按预约时间倒序) */
List<Appointment> 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") @Query("SELECT a.appointmentTime FROM Appointment a WHERE a.storeId = :storeId AND a.deleted = false AND (a.status IS NULL OR a.status <> 'cancel') AND a.appointmentTime >= :start AND a.appointmentTime < :end")
List<LocalDateTime> findOccupiedAppointmentTimes( List<LocalDateTime> findOccupiedAppointmentTimes(
@Param("storeId") Long storeId, @Param("storeId") Long storeId,

View File

@ -9,6 +9,9 @@ import java.util.Optional;
public interface UserMapper extends JpaRepository<User, Long> { public interface UserMapper extends JpaRepository<User, Long> {
User findByUsernameAndDeletedFalse(String username); User findByUsernameAndDeletedFalse(String username);
User findByPhoneAndDeletedFalse(String phone); User findByPhoneAndDeletedFalse(String phone);
Optional<User> findByWechatOpenidAndDeletedFalse(String wechatOpenid);
Optional<User> findByWechatUnionidAndDeletedFalse(String wechatUnionid);
List<User> findByStoreIdAndDeletedFalse(Long storeId); List<User> findByStoreIdAndDeletedFalse(Long storeId);
Optional<User> findByIdAndDeletedFalse(Long id); Optional<User> findByIdAndDeletedFalse(Long id);
} }

View File

@ -1,13 +1,19 @@
package com.petstore.service; package com.petstore.service;
import com.petstore.entity.Appointment;
import com.petstore.entity.Pet; import com.petstore.entity.Pet;
import com.petstore.entity.Report;
import com.petstore.entity.Store;
import com.petstore.mapper.AppointmentMapper; import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.PetMapper; import com.petstore.mapper.PetMapper;
import com.petstore.mapper.ReportMapper;
import com.petstore.mapper.UserMapper; import com.petstore.mapper.UserMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@ -18,6 +24,8 @@ public class PetService {
private final PetMapper petMapper; private final PetMapper petMapper;
private final UserMapper userMapper; private final UserMapper userMapper;
private final AppointmentMapper appointmentMapper; private final AppointmentMapper appointmentMapper;
private final ReportMapper reportMapper;
private final StoreService storeService;
/** 客户:仅自己的宠物 */ /** 客户:仅自己的宠物 */
public List<Pet> listByOwner(Long ownerUserId) { public List<Pet> listByOwner(Long ownerUserId) {
@ -114,4 +122,86 @@ public class PetService {
petMapper.save(pet); petMapper.save(pet);
return Map.of("code", 200, "message", "已删除"); return Map.of("code", 200, "message", "已删除");
} }
/**
* 按宠物维度预约与服务报告时间线复购与情感连接
* 仅宠主本人或本店曾服务过该宠物的老板/员工可查看
*/
public Map<String, Object> getServiceHistory(Long petId, Long operatorUserId, String role) {
if (petId == null || operatorUserId == null || role == null || role.isBlank()) {
return Map.of("code", 400, "message", "参数不完整");
}
Optional<Pet> 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<Appointment> appts = appointmentMapper.findByPetIdAndDeletedFalseOrderByAppointmentTimeDesc(petId);
List<Map<String, Object>> 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<String, Object> 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<Report> 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<String, Object> summary = new HashMap<>();
summary.put("totalAppointments", appts.size());
summary.put("completedCount", doneCount);
summary.put("cancelledCount", cancelCount);
Map<String, Object> 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;
}
} }

View File

@ -34,13 +34,15 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
/** /**
* 将服务报告中的服务前 / 过程中 / 服务后图片与短视频按时间线拼接为竖屏短片默认 15s 30s * 将服务报告中的服务前 / 过程中 / 服务后图片与短视频照片与视频穿插编排后拼接为竖屏短片默认 15s 30s
* <p>依赖本机安装 {@code ffmpeg}后续可在此类接入云端生成式视频API与本地管线切换</p> * <p>图片段使用轻微 Ken Burns缩放平移视频段保留原音无音轨时补静音可选配置文件路径叠加 BGM</p>
* <p>依赖本机安装 {@code ffmpeg}可选 {@code ffprobe} 用于读取视频时长与是否含音轨</p>
*/ */
@Service @Service
public class ReportHighlightVideoService { public class ReportHighlightVideoService {
private static final int[] ALLOWED_DURATIONS = {15, 30}; private static final int[] ALLOWED_DURATIONS = {15, 30};
private static final int OUTPUT_FPS = 30;
private final ReportMapper reportMapper; private final ReportMapper reportMapper;
private final ReportImageMapper reportImageMapper; private final ReportImageMapper reportImageMapper;
@ -51,9 +53,30 @@ public class ReportHighlightVideoService {
@Value("${upload.path:uploads/}") @Value("${upload.path:uploads/}")
private String uploadPath; private String uploadPath;
@Value("${app.base-url:http://localhost:8080}")
private String baseUrl;
@Value("${app.highlight-video.ffmpeg-binary:ffmpeg}") @Value("${app.highlight-video.ffmpeg-binary:ffmpeg}")
private String ffmpegBinary; 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;
/** 可选:本地绝对路径,商用授权 BGMmp3/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<Long, Object> reportLocks = new ConcurrentHashMap<>(); private final ConcurrentHashMap<Long, Object> reportLocks = new ConcurrentHashMap<>();
public ReportHighlightVideoService( public ReportHighlightVideoService(
@ -144,17 +167,27 @@ public class ReportHighlightVideoService {
if (opt.isEmpty()) { if (opt.isEmpty()) {
return; return;
} }
List<ReportImage> imgs = sortedImages(reportImageMapper.findByReportIdOrderBySortOrderAscIdAsc(reportId)); 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<Path> sources = new ArrayList<>();
List<Boolean> isVideo = new ArrayList<>(); List<Boolean> isVideo = new ArrayList<>();
for (ReportImage img : imgs) { for (ReportImage img : timeline) {
Path p = resolveUploadFile(img.getPhotoUrl()); Path p = resolveUploadFile(img.getPhotoUrl());
if (p == null || !Files.isRegularFile(p)) { if (p == null || !Files.isRegularFile(p)) {
continue; continue;
} }
boolean video = isVideoMedia(img);
sources.add(p); sources.add(p);
isVideo.add(video); isVideo.add(isVideoMedia(img));
} }
if (sources.isEmpty()) { if (sources.isEmpty()) {
markFailed(reportId, "素材文件在服务器上不存在或无法读取"); markFailed(reportId, "素材文件在服务器上不存在或无法读取");
@ -162,9 +195,14 @@ public class ReportHighlightVideoService {
} }
int n = sources.size(); int n = sources.size();
double perClip = (double) durationSec / n; double[] segmentSecs = computeSegmentDurations(sources, isVideo, durationSec);
if (perClip < 0.35) { double sumSeg = 0;
perClip = 0.35; for (double s : segmentSecs) {
sumSeg += s;
}
if (sumSeg < 0.5) {
markFailed(reportId, "分段时长计算异常");
return;
} }
Path workDir = Files.createTempDirectory("highlight_" + reportId + "_"); Path workDir = Files.createTempDirectory("highlight_" + reportId + "_");
@ -173,9 +211,9 @@ public class ReportHighlightVideoService {
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
Path seg = workDir.resolve("seg_" + i + ".mp4"); Path seg = workDir.resolve("seg_" + i + ".mp4");
if (Boolean.TRUE.equals(isVideo.get(i))) { if (Boolean.TRUE.equals(isVideo.get(i))) {
encodeVideoSegment(sources.get(i), seg, perClip); encodeVideoSegment(sources.get(i), seg, segmentSecs[i]);
} else { } else {
encodeImageSegment(sources.get(i), seg, perClip); encodeImageSegment(sources.get(i), seg, segmentSecs[i]);
} }
segments.add(seg); segments.add(seg);
} }
@ -192,41 +230,42 @@ public class ReportHighlightVideoService {
} }
Path merged = workDir.resolve("merged.mp4"); Path merged = workDir.resolve("merged.mp4");
Path sourceToSave; // 最终用于保存的文件会在下面赋值 Path sourceToSave;
int c1 = runFfmpeg(List.of( int c1 = runFfmpeg(List.of(
ffmpegBinary, "-y", "-f", "concat", "-safe", "0", "-i", listFile.toString(), ffmpegBinary, "-y", "-f", "concat", "-safe", "0", "-i", listFile.toString(),
"-c", "copy", merged.toString() "-c", "copy", merged.toString()
)); ));
if (c1 != 0) { if (c1 != 0) {
// stream copy 失败改用转码同时做尺寸 + Baseline Profile 标准化
int c2 = runFfmpeg(List.of( int c2 = runFfmpeg(List.of(
ffmpegBinary, "-y", "-f", "concat", "-safe", "0", "-i", listFile.toString(), 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", "-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", "-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", "-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart", // MP4 streaming 优化微信要求 "-movflags", "+faststart",
"-t", String.valueOf(durationSec), "-t", String.valueOf(durationSec),
merged.toString() merged.toString()
)); ));
if (c2 != 0) { if (c2 != 0) {
throw new IllegalStateException("ffmpeg 拼接失败(退出码 " + c2 + ""); throw new IllegalStateException("ffmpeg 拼接失败(退出码 " + c2 + "");
} }
sourceToSave = merged; // 转码后文件已满足兼容性要求 sourceToSave = merged;
} else { } else {
sourceToSave = merged; // stream copy 成功直接用 sourceToSave = merged;
} }
Path afterBgm = applyOptionalBgm(sourceToSave, workDir);
String datePath = LocalDate.now().toString().replace("-", "/"); String datePath = LocalDate.now().toString().replace("-", "/");
String base = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; String base = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
Path outDir = Paths.get(base + datePath); Path outDir = Paths.get(base + datePath);
Files.createDirectories(outDir); Files.createDirectories(outDir);
String filename = "highlight_" + reportId + "_" + UUID.randomUUID().toString().replace("-", "") + ".mp4"; String filename = "highlight_" + reportId + "_" + UUID.randomUUID().toString().replace("-", "") + ".mp4";
Path finalPath = outDir.resolve(filename); 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 publicBase = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
String url = "https://api.s-good.com/api/upload/image/" + datePath + "/" + filename; String url = publicBase + "/api/upload/image/" + datePath + "/" + filename;
synchronized (lockFor(reportId)) { synchronized (lockFor(reportId)) {
reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> { reportMapper.findByIdAndDeletedFalse(reportId).ifPresent(r -> {
@ -253,6 +292,173 @@ public class ReportHighlightVideoService {
} }
} }
/**
* np 张照片均匀拆成 nv+1 组与组之间插入视频实现穿插编排类似相册成片
*/
private static List<ReportImage> interleaveSpread(List<ReportImage> photos, List<ReportImage> 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<ReportImage> 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<Path> sources, List<Boolean> 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<ReportImage> imgs) { private boolean hasRenderableMedia(List<ReportImage> imgs) {
if (imgs == null || imgs.isEmpty()) { if (imgs == null || imgs.isEmpty()) {
return false; return false;
@ -295,9 +501,41 @@ public class ReportHighlightVideoService {
} }
private void encodeImageSegment(Path image, Path out, double seconds) throws Exception { 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 t = String.format(Locale.ROOT, "%.3f", seconds);
String vf = "scale=1080:1920:force_original_aspect_ratio=decrease," 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( int code = runFfmpeg(List.of(
ffmpegBinary, "-y", ffmpegBinary, "-y",
"-loop", "1", "-framerate", "1", "-i", image.toString(), "-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 { private void encodeVideoSegment(Path video, Path out, double seconds) throws Exception {
String t = String.format(Locale.ROOT, "%.3f", seconds); String t = String.format(Locale.ROOT, "%.3f", seconds);
String vf = "scale=1080:1920:force_original_aspect_ratio=decrease," 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( 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", ffmpegBinary, "-y",
"-i", video.toString(), "-i", video.toString(),
"-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100",
"-vf", vf, "-vf", vf,
"-map", "0:v:0", "-map", "1:a:0", "-map", "0:v:0", "-map", "1:a:0",
"-t", t,
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
"-c:a", "aac", "-b:a", "96k", "-c:a", "aac", "-b:a", "96k",
"-pix_fmt", "yuv420p", "-pix_fmt", "yuv420p",
"-t", t,
out.toString() out.toString()
)); ));
}
if (code != 0) { if (code != 0) {
throw new IllegalStateException("视频片段重编码失败(退出码 " + code + ""); throw new IllegalStateException("视频片段重编码失败(退出码 " + code + "");
} }

View File

@ -3,9 +3,11 @@ package com.petstore.service;
import com.petstore.entity.Report; import com.petstore.entity.Report;
import com.petstore.entity.ReportLead; import com.petstore.entity.ReportLead;
import com.petstore.entity.ServiceInterval; import com.petstore.entity.ServiceInterval;
import com.petstore.entity.User;
import com.petstore.mapper.ReportLeadMapper; import com.petstore.mapper.ReportLeadMapper;
import com.petstore.mapper.ReportMapper; import com.petstore.mapper.ReportMapper;
import com.petstore.mapper.ServiceIntervalMapper; import com.petstore.mapper.ServiceIntervalMapper;
import com.petstore.mapper.UserMapper;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -25,6 +27,8 @@ public class ReportLeadService {
private final ReportLeadMapper reportLeadMapper; private final ReportLeadMapper reportLeadMapper;
private final ReportMapper reportMapper; private final ReportMapper reportMapper;
private final ServiceIntervalMapper serviceIntervalMapper; private final ServiceIntervalMapper serviceIntervalMapper;
private final UserMapper userMapper;
private final WechatMiniProgramService wechatMiniProgramService;
/** 当服务类型不在已配置表中时使用的兜底间隔(天) */ /** 当服务类型不在已配置表中时使用的兜底间隔(天) */
private static final int FALLBACK_MIN = 28; private static final int FALLBACK_MIN = 28;
@ -101,8 +105,10 @@ public class ReportLeadService {
/** /**
* 幂等创建/更新留资 * 幂等创建/更新留资
* 已存在 (reportId, phone) 组合时只更新状态和提醒日期不重复创建 * 已存在 (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); Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null);
if (report == null) { if (report == null) {
throw new IllegalArgumentException("报告不存在或已失效"); throw new IllegalArgumentException("报告不存在或已失效");
@ -133,6 +139,32 @@ public class ReportLeadService {
lead.setConsentAt(now); lead.setConsentAt(now);
lead.setConsentIp(consentIp); lead.setConsentIp(consentIp);
lead.setUpdateTime(now); 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); return reportLeadMapper.save(lead);
} }

View File

@ -5,6 +5,7 @@ import com.petstore.entity.ReportTestimonial;
import com.petstore.mapper.ReportMapper; import com.petstore.mapper.ReportMapper;
import com.petstore.mapper.ReportTestimonialMapper; import com.petstore.mapper.ReportTestimonialMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -19,6 +20,7 @@ public class ReportTestimonialService {
/** /**
* 保存或更新该报告下的宠主寄语幂等 report_id 唯一 * 保存或更新该报告下的宠主寄语幂等 report_id 唯一
*/ */
@Transactional
public ReportTestimonial submit(String reportToken, String content, boolean isPublic) { public ReportTestimonial submit(String reportToken, String content, boolean isPublic) {
Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null); Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null);
if (report == null) { if (report == null) {

View File

@ -90,6 +90,49 @@ public class UserService {
return Map.of("code", 200, "message", "登录成功", "data", data); 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<String, Object> registerStaff(String phone, String password, String name, String inviteCode) { public Map<String, Object> registerStaff(String phone, String password, String name, String inviteCode) {
if (userMapper.findByPhoneAndDeletedFalse(phone) != null) { if (userMapper.findByPhoneAndDeletedFalse(phone) != null) {
return Map.of("code", 400, "message", "手机号已注册"); return Map.of("code", 400, "message", "手机号已注册");

View File

@ -38,6 +38,68 @@ public class WechatMiniProgramService {
private volatile String cachedAccessToken; private volatile String cachedAccessToken;
private volatile long tokenExpiresAtEpochMs; 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 record WxPhoneExchangeResult(String phone, String errorMessage) {
public static WxPhoneExchangeResult ok(String phone) { public static WxPhoneExchangeResult ok(String phone) {
return new WxPhoneExchangeResult(phone, null); return new WxPhoneExchangeResult(phone, null);

View File

@ -44,3 +44,14 @@ wechat:
appid: ${WECHAT_APPID:} appid: ${WECHAT_APPID:}
appsecret: ${WECHAT_APPSECRET:} appsecret: ${WECHAT_APPSECRET:}
redirect_uri: http://localhost:8080/api/wechat/callback 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

View File

@ -44,11 +44,19 @@ logging:
level: level:
com.petstore: debug com.petstore: debug
# 服务回顾短片:本地 ffmpeg 拼接(可换为云端生成式视频 API # 服务回顾短片:本地 ffmpeg 拼接(穿插编排、Ken Burns、可选 BGM
app: app:
base-url: ${APP_BASE_URL:https://api.s-good.com} base-url: ${APP_BASE_URL:https://api.s-good.com}
highlight-video: highlight-video:
ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg} 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 / 微信后台一致;生产环境建议用环境变量覆盖) # 微信小程序(与 manifest / 微信后台一致;生产环境建议用环境变量覆盖)
wechat: wechat: