fix: @Transactional竞态 + H5 Canvas DPR适配
This commit is contained in:
parent
a6b48f892d
commit
afcd39011a
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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<>();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
/** 微信小程序 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")
|
@Column(name = "create_time")
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
/** 可选:本地绝对路径,商用授权 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<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 + ")");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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", "手机号已注册");
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user