From 8f2545f271c9bc58ae85e2bf7decc4bee6f9746d Mon Sep 17 00:00:00 2001 From: Timi Date: Fri, 18 Sep 2026 01:47:54 +0800 Subject: [PATCH 1/2] add customer followup --- .../api/modules/common/entity/Attachment.java | 2 + .../modules/common/mapper/NotifyMapper.java | 10 + .../common/service/NotifyDetailService.java | 6 + .../modules/common/service/NotifyService.java | 8 + .../AttachmentVariantServiceImplement.java | 2 +- .../NotifyDetailServiceImplement.java | 30 + .../implement/NotifyServiceImplement.java | 14 + .../modules/gao/bean/GaoPermissionCode.java | 10 + ...GaoCustomerFollowUpReminderController.java | 182 +++++++ .../gao/controller/GaoUserController.java | 11 +- .../entity/GaoCustomerFollowUpReminder.java | 110 ++++ .../GaoCustomerFollowUpReminderMapper.java | 53 ++ .../GaoCustomerFollowUpReminderService.java | 39 ++ ...tomerFollowUpReminderServiceImplement.java | 512 ++++++++++++++++++ ...GaoProductStockRecordServiceImplement.java | 7 +- .../task/GaoCustomerFollowUpReminderTask.java | 31 ++ .../vo/GaoCustomerFollowUpReminderPage.java | 33 ++ ...GaoCustomerFollowUpReminderPageResult.java | 13 + src/main/java/com/imyeyu/api/util/JavaCV.java | 79 ++- ...gao_customer_follow_up_reminder_tables.sql | 26 + .../timi-server/common/NotifyMapper.xml | 11 + .../gao/GaoCustomerFollowUpReminderMapper.xml | 78 +++ 22 files changed, 1262 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/imyeyu/api/modules/gao/controller/GaoCustomerFollowUpReminderController.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/entity/GaoCustomerFollowUpReminder.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/mapper/GaoCustomerFollowUpReminderMapper.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/service/GaoCustomerFollowUpReminderService.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerFollowUpReminderServiceImplement.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/task/GaoCustomerFollowUpReminderTask.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/vo/GaoCustomerFollowUpReminderPage.java create mode 100644 src/main/java/com/imyeyu/api/modules/gao/vo/GaoCustomerFollowUpReminderPageResult.java create mode 100644 src/main/resources/db/migration/timiserver/V80__create_gao_customer_follow_up_reminder_tables.sql create mode 100644 src/main/resources/mapper/timi-server/gao/GaoCustomerFollowUpReminderMapper.xml diff --git a/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java b/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java index 0683663..366ab9c 100644 --- a/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java +++ b/src/main/java/com/imyeyu/api/modules/common/entity/Attachment.java @@ -56,6 +56,8 @@ public class Attachment extends UUIDEntity { GAO_POINT_REDEMPTION_RETURN, + GAO_CUSTOMER_FOLLOW_UP_REMINDER, + TEMP_FILE, MIRROR diff --git a/src/main/java/com/imyeyu/api/modules/common/mapper/NotifyMapper.java b/src/main/java/com/imyeyu/api/modules/common/mapper/NotifyMapper.java index 44df6e9..feb4690 100644 --- a/src/main/java/com/imyeyu/api/modules/common/mapper/NotifyMapper.java +++ b/src/main/java/com/imyeyu/api/modules/common/mapper/NotifyMapper.java @@ -18,6 +18,16 @@ public interface NotifyMapper extends BaseMapper { /// 原子创建业务提醒 int insertIfAbsent(Notify notify); + /// 按业务查重键查询未删除通知 + /// + /// @param bizType 业务类型 + /// @param bizRemindedType 业务提醒类型 + /// @param bizRemindedKey 业务提醒查重键 + /// @return 通知 + Notify selectByBusinessKey(@Param("bizType") String bizType, + @Param("bizRemindedType") String bizRemindedType, + @Param("bizRemindedKey") String bizRemindedKey); + /// 分页查询管理端通知 /// /// @param userId 管理员用户 ID diff --git a/src/main/java/com/imyeyu/api/modules/common/service/NotifyDetailService.java b/src/main/java/com/imyeyu/api/modules/common/service/NotifyDetailService.java index a6ee10e..6671539 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/NotifyDetailService.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/NotifyDetailService.java @@ -26,6 +26,12 @@ public interface NotifyDetailService extends BaseService { /// @param detailId 通知详情 ID void requeue(String detailId); + /// 取消尚未生效的通知详情 + /// + /// @param detailId 通知详情 ID + /// @param reason 取消原因 + void cancelPending(String detailId, String reason); + /// 分页查询用户的站内通知 /// /// @param page 分页参数 diff --git a/src/main/java/com/imyeyu/api/modules/common/service/NotifyService.java b/src/main/java/com/imyeyu/api/modules/common/service/NotifyService.java index 1b738c8..a42aa78 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/NotifyService.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/NotifyService.java @@ -19,6 +19,14 @@ public interface NotifyService extends BaseService { /// @param notify 通知 void createIfAbsent(Notify notify); + /// 按业务查重键查询未删除通知 + /// + /// @param bizType 业务类型 + /// @param bizRemindedType 业务提醒类型 + /// @param bizRemindedKey 业务提醒查重键 + /// @return 通知 + Notify getByBusinessKey(String bizType, String bizRemindedType, String bizRemindedKey); + /// 分页查询当前用户创建的通知 /// /// @param page 分页参数 diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/AttachmentVariantServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/AttachmentVariantServiceImplement.java index 7b45bc0..4fe0d5c 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/implement/AttachmentVariantServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/AttachmentVariantServiceImplement.java @@ -57,7 +57,7 @@ public class AttachmentVariantServiceImplement implements AttachmentVariantServi private static final int THUMB_MAX_HEIGHT = 4096; private static final long THUMB_MAX_PIXELS = 16_000_000L; private static final long THUMB_CACHE_TTL = Time.D * 7; - private static final long THUMB_LOCK_TTL = Time.M * 10; + private static final long THUMB_LOCK_TTL = Time.M; private static final double THUMB_TARGET_SECONDS = 2; private static final int VARIANT_NAME_MAX_LENGTH = 255; private static final String THUMB_OUTPUT_FORMAT = "png"; diff --git a/src/main/java/com/imyeyu/api/modules/common/service/implement/NotifyDetailServiceImplement.java b/src/main/java/com/imyeyu/api/modules/common/service/implement/NotifyDetailServiceImplement.java index 8e5c05b..bf19ad9 100644 --- a/src/main/java/com/imyeyu/api/modules/common/service/implement/NotifyDetailServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/common/service/implement/NotifyDetailServiceImplement.java @@ -108,6 +108,36 @@ public class NotifyDetailServiceImplement extends AbstractEntityService reminderList) { + if (reminderList == null || reminderList.isEmpty()) { + return; + } + List customerIdList = reminderList.stream() + .map(GaoCustomerFollowUpReminder::getCustomerId) + .distinct() + .toList(); + Map customerMap = customerService.mapByIdList(customerIdList); + Map> customerAttachmentMap = attachmentService.mapByBizIdList( + Attachment.BizType.GAO_CUSTOMER, + customerIdList); + Map userMap = userService.mapByIdList( + reminderList.stream().map(GaoCustomerFollowUpReminder::getResponsibleUserId).distinct().toList()); + Map> attachmentMap = attachmentService.mapByBizIdList( + Attachment.BizType.GAO_CUSTOMER_FOLLOW_UP_REMINDER, + reminderList.stream().map(GaoCustomerFollowUpReminder::getId).toList()); + for (GaoCustomerFollowUpReminder reminder : reminderList) { + GaoCustomer customer = customerMap.get(reminder.getCustomerId()); + if (customer != null) { + customer.setAttachmentList(customerAttachmentMap.getOrDefault(customer.getId(), List.of())); + } + reminder.setCustomer(customer); + reminder.setResponsibleUser(userMap.get(reminder.getResponsibleUserId())); + reminder.setAttachmentList(attachmentMap.getOrDefault(reminder.getId(), List.of())); + reminder.resolveNotifyChannelList(); + } + } + + /// 回填详情中的客户、负责人和附件 + /// + /// @param reminder 跟进提醒 + private void fillDetail(GaoCustomerFollowUpReminder reminder) { + GaoCustomer customer = customerService.get(reminder.getCustomerId()); + customer.setAttachmentList(attachmentService.listByBizId(Attachment.BizType.GAO_CUSTOMER, customer.getId(), GaoCustomer.AttachType.PHOTO.name())); + User responsibleUser = userService.get(reminder.getResponsibleUserId()); + reminder.setCustomer(customer); + reminder.setResponsibleUser(responsibleUser); + reminder.setAttachmentList(attachmentService.listByBizId( + Attachment.BizType.GAO_CUSTOMER_FOLLOW_UP_REMINDER, reminder.getId())); + reminder.resolveNotifyChannelList(); + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoUserController.java b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoUserController.java index df788b4..a739f82 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/controller/GaoUserController.java +++ b/src/main/java/com/imyeyu/api/modules/gao/controller/GaoUserController.java @@ -102,9 +102,16 @@ public class GaoUserController { List userIdList = result.getList().stream().map(GaoUser::getUserId).toList(); Map userMap = userService.mapByIdList(userIdList); Map> roleMap = userRoleService.mapRoleListByUserIdList(userIdList); + Map> avatarMap = userIdList.isEmpty() + ? Map.of() + : attachmentService.mapByBizIdList(Attachment.BizType.USER, userIdList, User.AttachType.AVATAR.name()); for (GaoUser gaoUser : result.getList()) { - gaoUser.setUser(userMap.get(gaoUser.getUserId())); - gaoUser.getUser().setRoleEntityList(roleMap.get(gaoUser.getUserId())); + User user = userMap.get(gaoUser.getUserId()); + if (user != null) { + user.setAttachmentList(avatarMap.getOrDefault(user.getId(), List.of())); + user.setRoleEntityList(roleMap.get(gaoUser.getUserId())); + } + gaoUser.setUser(user); } } return result; diff --git a/src/main/java/com/imyeyu/api/modules/gao/entity/GaoCustomerFollowUpReminder.java b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoCustomerFollowUpReminder.java new file mode 100644 index 0000000..3bb7a40 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/entity/GaoCustomerFollowUpReminder.java @@ -0,0 +1,110 @@ +package com.imyeyu.api.modules.gao.entity; + +import com.imyeyu.api.modules.common.entity.Attachment; +import com.imyeyu.api.modules.user.entity.User; +import com.imyeyu.spring.annotation.table.Transient; +import com.imyeyu.spring.entity.UUIDEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.ArrayList; +import java.util.List; + +/// GAO 客户跟进提醒 +/// +/// @author Codex +/// @since 2026-09-17 +@Data +@EqualsAndHashCode(callSuper = true) +public class GaoCustomerFollowUpReminder extends UUIDEntity { + + /// 提醒状态 + public enum Status { + + /// 待完成 + PENDING, + + /// 已完成 + COMPLETED + } + + /// 通知渠道 + public enum NotifyChannel { + + /// 站内通知 + INTERNAL, + + /// 短信通知 + SMS + } + + /// 门店 ID + private String storeId; + + /// 客户 ID + private String customerId; + + /// 负责人用户 ID + private String responsibleUserId; + + /// 跟进内容,纯文本 + private String content; + + /// 提醒时间,Unix 毫秒时间戳,精确到分钟 + private Long remindAt; + + /// 提醒状态 + private Status status; + + /// true 为发送站内通知 + private Boolean internalNotifyEnabled; + + /// true 为发送短信通知 + private Boolean smsNotifyEnabled; + + /// 站内通知详情 ID + private String internalNotifyDetailId; + + /// 短信聚合通知详情 ID + private String smsNotifyDetailId; + + /// 完成人用户 ID + private String completedBy; + + /// 完成时间 + private Long completedAt; + + /// 创建人用户 ID + private String createdBy; + + /// 最后修改人用户 ID + private String updatedBy; + + /// 通知渠道列表 + @Transient + private List notifyChannelList; + + /// 客户 + @Transient + private GaoCustomer customer; + + /// 负责人用户 + @Transient + private User responsibleUser; + + /// 附件列表 + @Transient + private List attachmentList; + + /// 根据持久化通知开关生成通知渠道列表 + public void resolveNotifyChannelList() { + List channelList = new ArrayList<>(); + if (Boolean.TRUE.equals(internalNotifyEnabled)) { + channelList.add(NotifyChannel.INTERNAL); + } + if (Boolean.TRUE.equals(smsNotifyEnabled)) { + channelList.add(NotifyChannel.SMS); + } + notifyChannelList = channelList; + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoCustomerFollowUpReminderMapper.java b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoCustomerFollowUpReminderMapper.java new file mode 100644 index 0000000..a423e51 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/mapper/GaoCustomerFollowUpReminderMapper.java @@ -0,0 +1,53 @@ +package com.imyeyu.api.modules.gao.mapper; + +import com.imyeyu.api.modules.gao.entity.GaoCustomerFollowUpReminder; +import com.imyeyu.api.modules.gao.vo.GaoCustomerFollowUpReminderPage; +import com.imyeyu.spring.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; + +import java.util.Collection; +import java.util.List; + +/// GAO 客户跟进提醒 Mapper +/// +/// @author Codex +/// @since 2026-09-17 +public interface GaoCustomerFollowUpReminderMapper extends BaseMapper { + + /// 统计跟进提醒数量 + /// + /// @param page 查询条件 + /// @return 数量 + long countByQuery(@Param("page") GaoCustomerFollowUpReminderPage page); + + /// 分页查询跟进提醒 + /// + /// @param page 查询条件 + /// @return 提醒列表 + List selectByQuery(@Param("page") GaoCustomerFollowUpReminderPage page); + + /// 查询待创建短信批次的提醒 + /// + /// @param beginAt 当天起点 + /// @param endAt 次日当天起点 + /// @return 待处理提醒 + List selectPendingSmsByRange(@Param("beginAt") long beginAt, @Param("endAt") long endAt); + + /// 条件完成跟进提醒 + /// + /// @param id 提醒 ID + /// @param updatedBy 修改人 + /// @param completedBy 完成人 + /// @param completedAt 完成时间 + /// @return 受影响行数 + int complete(@Param("id") String id, @Param("updatedBy") String updatedBy, + @Param("completedBy") String completedBy, @Param("completedAt") long completedAt); + + /// 为仍符合条件的提醒回写短信详情 ID + /// + /// @param idList 提醒 ID 列表 + /// @param smsNotifyDetailId 短信详情 ID + /// @return 受影响行数 + int bindSmsNotifyDetailId(@Param("idList") Collection idList, + @Param("smsNotifyDetailId") String smsNotifyDetailId); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/GaoCustomerFollowUpReminderService.java b/src/main/java/com/imyeyu/api/modules/gao/service/GaoCustomerFollowUpReminderService.java new file mode 100644 index 0000000..3e5d9ce --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/service/GaoCustomerFollowUpReminderService.java @@ -0,0 +1,39 @@ +package com.imyeyu.api.modules.gao.service; + +import com.imyeyu.api.modules.gao.entity.GaoCustomerFollowUpReminder; +import com.imyeyu.api.modules.gao.vo.GaoCustomerFollowUpReminderPage; +import com.imyeyu.api.modules.gao.vo.GaoCustomerFollowUpReminderPageResult; +import com.imyeyu.spring.service.BaseService; + +/// GAO 客户跟进提醒服务 +/// +/// @author Codex +/// @since 2026-09-17 +public interface GaoCustomerFollowUpReminderService extends BaseService { + + /// 分页查询客户跟进提醒 + /// + /// @param page 查询条件 + /// @return 分页结果 + GaoCustomerFollowUpReminderPageResult pageByQuery(GaoCustomerFollowUpReminderPage page); + + /// 创建客户跟进提醒 + /// + /// @param reminder 跟进提醒 + @Override + void create(GaoCustomerFollowUpReminder reminder); + + /// 修改客户跟进提醒 + /// + /// @param reminder 跟进提醒 + @Override + void update(GaoCustomerFollowUpReminder reminder); + + /// 完成客户跟进提醒 + /// + /// @param id 跟进提醒 ID + void complete(String id); + + /// 创建当天的短信聚合批次 + void createSmsBatch(); +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerFollowUpReminderServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerFollowUpReminderServiceImplement.java new file mode 100644 index 0000000..789fa68 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoCustomerFollowUpReminderServiceImplement.java @@ -0,0 +1,512 @@ +package com.imyeyu.api.modules.gao.service.implement; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imyeyu.api.config.dbsource.TimiServerDBConfig; +import com.imyeyu.api.modules.common.entity.Attachment; +import com.imyeyu.api.modules.common.entity.Notify; +import com.imyeyu.api.modules.common.entity.NotifyDetail; +import com.imyeyu.api.modules.common.service.AttachmentService; +import com.imyeyu.api.modules.common.service.NotifyDetailService; +import com.imyeyu.api.modules.common.service.NotifyService; +import com.imyeyu.api.modules.gao.entity.GaoCustomer; +import com.imyeyu.api.modules.gao.entity.GaoCustomerFollowUpReminder; +import com.imyeyu.api.modules.gao.entity.GaoUser; +import com.imyeyu.api.modules.gao.mapper.GaoCustomerFollowUpReminderMapper; +import com.imyeyu.api.modules.gao.mapper.GaoUserMapper; +import com.imyeyu.api.modules.gao.service.GaoCustomerFollowUpReminderService; +import com.imyeyu.api.modules.gao.service.GaoCustomerService; +import com.imyeyu.api.modules.gao.service.GaoStoreScopeService; +import com.imyeyu.api.modules.gao.vo.GaoCustomerFollowUpReminderPage; +import com.imyeyu.api.modules.gao.vo.GaoCustomerFollowUpReminderPageResult; +import com.imyeyu.api.modules.user.entity.User; +import com.imyeyu.api.modules.user.service.UserLoginService; +import com.imyeyu.api.modules.user.service.UserService; +import com.imyeyu.java.TimiJava; +import com.imyeyu.java.bean.timi.TimiException; +import com.imyeyu.spring.mapper.BaseMapper; +import com.imyeyu.spring.service.AbstractEntityService; +import com.imyeyu.utils.Time; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/// GAO 客户跟进提醒服务实现 +/// +/// @author Codex +/// @since 2026-09-17 +@Slf4j +@Service +@RequiredArgsConstructor +public class GaoCustomerFollowUpReminderServiceImplement extends AbstractEntityService implements GaoCustomerFollowUpReminderService { + + private static final String NOTIFY_BIZ_TYPE = "GAO_CUSTOMER_FOLLOW_UP_REMINDER"; + private static final String INTERNAL_NOTIFY_TYPE = "INTERNAL"; + private static final String SMS_BATCH_NOTIFY_TYPE = "SMS"; + private static final String ZONE_ID = "Asia/Shanghai"; + private static final String SMS_BATCH_KEY_PREFIX = "GAO_CUSTOMER_FOLLOW_UP_SMS"; + private static final long MINUTE_MILLIS = 60_000L; + private static final String SMS_TEMPLATE_ID = "2734293"; + private static final LocalTime SMS_CUTOFF_TIME = LocalTime.of(8, 0); + private static final DateTimeFormatter DAY_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + + private final ObjectMapper jackson; + private final AttachmentService attachmentService; + private final NotifyDetailService notifyDetailService; + private final NotifyService notifyService; + private final GaoCustomerService customerService; + private final GaoStoreScopeService storeScopeService; + private final GaoUserMapper gaoUserMapper; + private final UserService userService; + private final UserLoginService userLoginService; + + private final GaoCustomerFollowUpReminderMapper mapper; + + @Override + protected BaseMapper mapper() { + return mapper; + } + + /// 分页查询客户跟进提醒 + /// + /// @param page 查询条件 + /// @return 分页结果 + @Override + public GaoCustomerFollowUpReminderPageResult pageByQuery(GaoCustomerFollowUpReminderPage page) { + TimiException.required(page, "未找到分页参数"); + GaoCustomerFollowUpReminderPageResult result = new GaoCustomerFollowUpReminderPageResult(); + result.setTotal(mapper.countByQuery(page)); + result.setList(mapper.selectByQuery(page)); + return result; + } + + /// 创建客户跟进提醒 + /// + /// @param reminder 跟进提醒 + @Override + @Transactional(TimiServerDBConfig.ROLLBACKER) + public void create(GaoCustomerFollowUpReminder reminder) { + TimiException.required(reminder, "未找到客户跟进提醒"); + resolveChannelFlags(reminder); + GaoCustomer customer = validateReminder(reminder, null); + User responsibleUser = validateResponsibleUser(reminder.getStoreId(), reminder.getResponsibleUserId(), + Boolean.TRUE.equals(reminder.getSmsNotifyEnabled())); + + reminder.setStatus(GaoCustomerFollowUpReminder.Status.PENDING); + reminder.setCreatedBy(userLoginService.getRequireLoginUserId()); + reminder.setUpdatedBy(null); + reminder.setCompletedBy(null); + reminder.setCompletedAt(null); + super.create(reminder); + attachmentService.updateByBizId(Attachment.BizType.GAO_CUSTOMER_FOLLOW_UP_REMINDER, + reminder.getId(), reminder.getAttachmentList()); + if (Boolean.TRUE.equals(reminder.getInternalNotifyEnabled())) { + reminder.setInternalNotifyDetailId(createInternalNotify(reminder, customer, responsibleUser)); + super.update(reminder); + } + reminder.resolveNotifyChannelList(); + } + + /// 修改客户跟进提醒 + /// + /// @param reminder 跟进提醒 + @Override + @Transactional(TimiServerDBConfig.ROLLBACKER) + public void update(GaoCustomerFollowUpReminder reminder) { + TimiException.required(reminder, "未找到客户跟进提醒"); + TimiException.required(reminder.getId(), "未找到客户跟进提醒 ID"); + GaoCustomerFollowUpReminder dbReminder = get(reminder.getId()); + TimiException.required(dbReminder, "未找到客户跟进提醒"); + TimiException.requiredTrue(dbReminder.getStatus() == GaoCustomerFollowUpReminder.Status.PENDING, + "已完成的客户跟进提醒不能修改"); + resolveChannelFlags(reminder); + GaoCustomer customer = validateReminder(reminder, dbReminder); + User responsibleUser = validateResponsibleUser(reminder.getStoreId(), reminder.getResponsibleUserId(), + Boolean.TRUE.equals(reminder.getSmsNotifyEnabled())); + checkGeneratedSmsMutation(dbReminder, reminder); + + boolean oldInternalEnabled = Boolean.TRUE.equals(dbReminder.getInternalNotifyEnabled()); + dbReminder.setCustomerId(reminder.getCustomerId()); + dbReminder.setStoreId(reminder.getStoreId()); + dbReminder.setResponsibleUserId(reminder.getResponsibleUserId()); + dbReminder.setContent(reminder.getContent()); + dbReminder.setRemindAt(reminder.getRemindAt()); + dbReminder.setInternalNotifyEnabled(reminder.getInternalNotifyEnabled()); + dbReminder.setSmsNotifyEnabled(reminder.getSmsNotifyEnabled()); + dbReminder.setUpdatedBy(userLoginService.getRequireLoginUserId()); + + syncInternalNotify(dbReminder, customer, responsibleUser, oldInternalEnabled); + super.update(dbReminder); + attachmentService.updateByBizId(Attachment.BizType.GAO_CUSTOMER_FOLLOW_UP_REMINDER, + dbReminder.getId(), reminder.getAttachmentList()); + dbReminder.resolveNotifyChannelList(); + } + + /// 完成客户跟进提醒 + /// + /// @param id 跟进提醒 ID + @Override + @Transactional(TimiServerDBConfig.ROLLBACKER) + public void complete(String id) { + TimiException.required(id, "未找到客户跟进提醒 ID"); + GaoCustomerFollowUpReminder reminder = get(id); + TimiException.required(reminder, "未找到客户跟进提醒"); + storeScopeService.checkStoreId(reminder.getStoreId()); + TimiException.requiredTrue(reminder.getStatus() == GaoCustomerFollowUpReminder.Status.PENDING, + "客户跟进提醒已经完成"); + long now = Time.now(); + String loginUserId = userLoginService.getRequireLoginUserId(); + int affected = mapper.complete(id, loginUserId, loginUserId, now); + TimiException.requiredTrue(0 < affected, "客户跟进提醒状态已发生变化"); + cancelInternalNotify(reminder, "跟进提醒已完成"); + } + + /// 删除客户跟进提醒 + /// + /// @param id 跟进提醒 ID + @Override + @Transactional(TimiServerDBConfig.ROLLBACKER) + public void delete(String id) { + TimiException.required(id, "未找到客户跟进提醒 ID"); + GaoCustomerFollowUpReminder reminder = get(id); + TimiException.required(reminder, "未找到客户跟进提醒"); + storeScopeService.checkStoreId(reminder.getStoreId()); + cancelInternalNotify(reminder, "跟进提醒已删除"); + attachmentService.deleteByBizId(Attachment.BizType.GAO_CUSTOMER_FOLLOW_UP_REMINDER, id); + super.delete(id); + } + + /// 创建当天的短信聚合批次 + @Override + @Transactional(TimiServerDBConfig.ROLLBACKER) + public void createSmsBatch() { + ZoneId zoneId = ZoneId.of(ZONE_ID); + LocalDate today = LocalDate.now(zoneId); + long beginAt = today.atStartOfDay(zoneId).toInstant().toEpochMilli(); + long endAt = today.plusDays(1).atStartOfDay(zoneId).toInstant().toEpochMilli(); + List reminderList = mapper.selectPendingSmsByRange(beginAt, endAt); + if (reminderList.isEmpty()) { + return; + } + + Map userMap = userService.mapByIdList(reminderList.stream() + .map(GaoCustomerFollowUpReminder::getResponsibleUserId).distinct().toList()); + Map customerMap = customerService.mapByIdList(reminderList.stream() + .map(GaoCustomerFollowUpReminder::getCustomerId).distinct().toList()); + Map> groupMap = new LinkedHashMap<>(); + for (GaoCustomerFollowUpReminder reminder : reminderList) { + User user = userMap.get(reminder.getResponsibleUserId()); + String phoneNo = user == null ? null : user.getPhoneNo(); + boolean validPhone = user != null && user.isPhoneNoVerified() && user.isValidPhone(); + SmsBatchGroup group = new SmsBatchGroup(reminder.getStoreId(), reminder.getResponsibleUserId(), phoneNo, validPhone); + groupMap.computeIfAbsent(group, ignored -> new ArrayList<>()).add(reminder); + } + + for (Map.Entry> entry : groupMap.entrySet()) { + createSmsBatch(entry.getKey(), entry.getValue(), customerMap, today, endAt - 1, SMS_TEMPLATE_ID); + } + } + + /// 校验提醒基础字段和客户门店范围 + /// + /// @param reminder 跟进提醒 + /// @param dbReminder 数据库提醒,创建时为空 + /// @return 客户 + private GaoCustomer validateReminder(GaoCustomerFollowUpReminder reminder, + GaoCustomerFollowUpReminder dbReminder) { + TimiException.required(reminder.getCustomerId(), "未找到客户 ID"); + TimiException.required(reminder.getResponsibleUserId(), "未找到负责人用户 ID"); + TimiException.required(reminder.getContent(), "未找到跟进内容"); + TimiException.requiredTrue(!reminder.getContent().isBlank(), "跟进内容不能为空"); + validateRemindAt(reminder.getRemindAt()); + GaoCustomer customer = customerService.get(reminder.getCustomerId()); + TimiException.required(customer, "未找到客户"); + storeScopeService.checkStoreId(customer.getStoreId()); + if (TimiJava.isNotEmpty(reminder.getStoreId())) { + TimiException.requiredTrue(customer.getStoreId().equals(reminder.getStoreId()), "客户和提醒门店不一致"); + } + if (dbReminder != null && dbReminder.getStoreId() != null) { + TimiException.requiredTrue(dbReminder.getStoreId().equals(customer.getStoreId()), "客户不能跨门店修改"); + } + reminder.setStoreId(customer.getStoreId()); + if (Boolean.TRUE.equals(reminder.getSmsNotifyEnabled()) + && (dbReminder == null || TimiJava.isEmpty(dbReminder.getSmsNotifyDetailId()))) { + validateSmsDate(reminder.getRemindAt()); + } + return customer; + } + + /// 校验负责人属于当前门店并处于启用状态 + /// + /// @param storeId 门店 ID + /// @param userId 用户 ID + /// @param smsEnabled true 为需要校验短信手机号 + /// @return 核心用户 + private User validateResponsibleUser(String storeId, String userId, boolean smsEnabled) { + GaoUser example = new GaoUser(); + example.setStoreId(storeId); + example.setUserId(userId); + example.setEnabled(true); + TimiException.required(gaoUserMapper.selectByExample(example), "负责人不是当前门店启用中的 GAO 用户"); + User user = userService.get(userId); + TimiException.required(user, "未找到负责人用户"); + if (smsEnabled) { + TimiException.requiredTrue(user.isPhoneNoVerified() && user.isValidPhone(), "负责人没有有效已验证手机号"); + } + return user; + } + + /// 校验提醒时间 + /// + /// @param remindAt 提醒时间 + private void validateRemindAt(Long remindAt) { + TimiException.required(remindAt, "未找到提醒时间"); + TimiException.requiredTrue(0 == remindAt % MINUTE_MILLIS, "提醒时间必须精确到分钟"); + TimiException.requiredTrue(Time.now() < remindAt, "提醒时间不能早于当前时间"); + } + + /// 校验短信当天八点截止规则 + /// + /// @param remindAt 提醒时间 + private void validateSmsDate(long remindAt) { + ZoneId zoneId = ZoneId.of(ZONE_ID); + var now = Instant.ofEpochMilli(Time.now()).atZone(zoneId); + var remindTime = Instant.ofEpochMilli(remindAt).atZone(zoneId); + TimiException.requiredTrue(!now.toLocalDate().equals(remindTime.toLocalDate()) + || now.toLocalTime().isBefore(SMS_CUTOFF_TIME), "当天八点后不能选择当天短信通知"); + } + + /// 根据请求渠道解析持久化开关 + /// + /// @param reminder 跟进提醒 + private void resolveChannelFlags(GaoCustomerFollowUpReminder reminder) { + List channelList = reminder.getNotifyChannelList(); + reminder.setInternalNotifyEnabled(channelList != null && channelList.contains(GaoCustomerFollowUpReminder.NotifyChannel.INTERNAL)); + reminder.setSmsNotifyEnabled(channelList != null && channelList.contains(GaoCustomerFollowUpReminder.NotifyChannel.SMS)); + } + + /// 校验短信批次生成后的不可变字段 + /// + /// @param dbReminder 数据库提醒 + /// @param request 请求提醒 + private void checkGeneratedSmsMutation(GaoCustomerFollowUpReminder dbReminder, + GaoCustomerFollowUpReminder request) { + if (TimiJava.isEmpty(dbReminder.getSmsNotifyDetailId())) { + return; + } + TimiException.requiredTrue(Objects.equals(dbReminder.getCustomerId(), request.getCustomerId()), + "短信批次已生成后不能修改客户"); + TimiException.requiredTrue(Objects.equals(dbReminder.getResponsibleUserId(), request.getResponsibleUserId()), + "短信批次已生成后不能修改负责人"); + TimiException.requiredTrue(Objects.equals(dbReminder.getSmsNotifyEnabled(), request.getSmsNotifyEnabled()), + "短信批次已生成后不能修改短信通知配置"); + TimiException.requiredTrue(sameLocalDate(dbReminder.getRemindAt(), request.getRemindAt()), + "短信批次已生成后不能修改提醒日期"); + } + + /// 判断两个时间是否属于同一业务日期 + /// + /// @param first 第一个时间 + /// @param second 第二个时间 + /// @return true 为同一天 + private boolean sameLocalDate(Long first, Long second) { + if (first == null || second == null) { + return false; + } + ZoneId zoneId = ZoneId.of(ZONE_ID); + return Instant.ofEpochMilli(first).atZone(zoneId).toLocalDate() + .equals(Instant.ofEpochMilli(second).atZone(zoneId).toLocalDate()); + } + + /// 同步站内通知 + /// + /// @param reminder 数据库提醒 + /// @param customer 客户 + /// @param responsibleUser 负责人 + /// @param oldInternalEnabled 修改前站内通知开关 + private void syncInternalNotify(GaoCustomerFollowUpReminder reminder, GaoCustomer customer, + User responsibleUser, boolean oldInternalEnabled) { + boolean enabled = Boolean.TRUE.equals(reminder.getInternalNotifyEnabled()); + if (!enabled) { + cancelInternalNotify(reminder, "站内通知已取消"); + return; + } + NotifyDetail detail = TimiJava.isEmpty(reminder.getInternalNotifyDetailId()) + ? null : notifyDetailService.get(reminder.getInternalNotifyDetailId()); + if (!oldInternalEnabled || detail == null || detail.getStatus() == NotifyDetail.Status.CANCEL) { + reminder.setInternalNotifyDetailId(createInternalNotify(reminder, customer, responsibleUser)); + return; + } + if (detail.getMsgType() == NotifyDetail.MsgType.INTERNAL + && detail.getSendAt() != null && Time.now() < detail.getSendAt() + && detail.getStatus() == NotifyDetail.Status.WAITING) { + detail.setContent(buildInternalContent(customer, reminder)); + detail.setSendAt(reminder.getRemindAt()); + notifyDetailService.update(detail); + } + } + + /// 创建站内通知 + /// + /// @param reminder 跟进提醒 + /// @param customer 客户 + /// @param responsibleUser 负责人 + /// @return 通知详情 ID + private String createInternalNotify(GaoCustomerFollowUpReminder reminder, GaoCustomer customer, + User responsibleUser) { + NotifyDetail detail = new NotifyDetail(); + detail.setMsgType(NotifyDetail.MsgType.INTERNAL); + detail.setSubject("客户跟进提醒"); + detail.setContent(buildInternalContent(customer, reminder)); + detail.setContentType(NotifyDetail.ContentType.TEXT); + detail.setSendFrom(userLoginService.getRequireLoginUserId()); + detail.setSendTo(responsibleUser.getId()); + detail.setSendAt(reminder.getRemindAt()); + detail.setTimeoutAt(reminder.getRemindAt() + Time.D); + + Notify oldNotify = null; + if (TimiJava.isNotEmpty(reminder.getInternalNotifyDetailId())) { + NotifyDetail oldDetail = notifyDetailService.get(reminder.getInternalNotifyDetailId()); + if (oldDetail != null) { + oldNotify = notifyService.get(oldDetail.getNotifyId()); + } + } + if (oldNotify == null) { + oldNotify = notifyService.getByBusinessKey(NOTIFY_BIZ_TYPE, INTERNAL_NOTIFY_TYPE, reminder.getId()); + } + if (oldNotify == null) { + Notify notify = new Notify(); + notify.setBizType(NOTIFY_BIZ_TYPE); + notify.setBizRemindedType(INTERNAL_NOTIFY_TYPE); + notify.setBizRemindedKey(reminder.getId()); + notify.setCreatedBy(userLoginService.getRequireLoginUserId()); + notify.setDetailList(List.of(detail)); + notifyService.create(notify); + } else { + detail.setNotifyId(oldNotify.getId()); + notifyDetailService.create(detail); + } + return detail.getId(); + } + + /// 构建站内通知正文 + /// + /// @param customer 客户 + /// @param reminder 跟进提醒 + /// @return 通知正文 + private String buildInternalContent(GaoCustomer customer, GaoCustomerFollowUpReminder reminder) { + String content = reminder.getContent(); + String summary = content.length() <= 100 ? content : content.substring(0, 100) + "..."; + return "客户「%s」跟进提醒:%s\n提醒 ID:%s".formatted(customer.getName(), summary, reminder.getId()); + } + + /// 取消尚未生效的站内通知 + /// + /// @param reminder 跟进提醒 + /// @param reason 取消原因 + private void cancelInternalNotify(GaoCustomerFollowUpReminder reminder, String reason) { + if (TimiJava.isNotEmpty(reminder.getInternalNotifyDetailId())) { + notifyDetailService.cancelPending(reminder.getInternalNotifyDetailId(), reason); + } + } + + /// 创建一个短信聚合通知并回写提醒 + /// + /// @param group 聚合分组 + /// @param reminderList 分组提醒 + /// @param customerMap 客户映射 + /// @param today 目标日期 + /// @param timeoutAt 当天结束时间 + /// @param templateId 短信模板 ID + private void createSmsBatch(SmsBatchGroup group, List reminderList, + Map customerMap, LocalDate today, long timeoutAt, String templateId) { + String key = "%s:%s:%s:%s".formatted(SMS_BATCH_KEY_PREFIX, group.storeId(), group.responsibleUserId(), + today.format(DAY_FORMATTER)); + Notify notify = notifyService.getByBusinessKey(NOTIFY_BIZ_TYPE, SMS_BATCH_NOTIFY_TYPE, key); + if (notify == null) { + Notify newNotify = new Notify(); + newNotify.setBizType(NOTIFY_BIZ_TYPE); + newNotify.setBizRemindedType(SMS_BATCH_NOTIFY_TYPE); + newNotify.setBizRemindedKey(key); + newNotify.setDetailList(List.of(buildSmsDetail(group, reminderList, customerMap, timeoutAt, templateId))); + notifyService.createIfAbsent(newNotify); + notify = notifyService.getByBusinessKey(NOTIFY_BIZ_TYPE, SMS_BATCH_NOTIFY_TYPE, key); + } + TimiException.required(notify, "创建客户跟进提醒短信批次失败"); + NotifyDetail smsDetail = notifyDetailService.listByNotifyId(notify.getId()).stream() + .filter(item -> item.getMsgType() == NotifyDetail.MsgType.SMS) + .findFirst().orElse(null); + if (smsDetail == null) { + smsDetail = buildSmsDetail(group, reminderList, customerMap, timeoutAt, templateId); + smsDetail.setNotifyId(notify.getId()); + notifyDetailService.create(smsDetail); + } + if (!group.validPhone() && smsDetail.getStatus() == NotifyDetail.Status.WAITING) { + smsDetail.getMsgType().getService().destroyQueue(smsDetail); + smsDetail.setStatus(NotifyDetail.Status.FAIL); + smsDetail.setResultDesc("负责人当前没有有效已验证手机号"); + notifyDetailService.update(smsDetail); + } + Collection reminderIdList = reminderList.stream().map(GaoCustomerFollowUpReminder::getId).toList(); + mapper.bindSmsNotifyDetailId(reminderIdList, smsDetail.getId()); + } + + /// 构建短信详情 + /// + /// @param group 聚合分组 + /// @param reminderList 分组提醒 + /// @param customerMap 客户映射 + /// @param timeoutAt 超时时间 + /// @param templateId 模板 ID + /// @return 短信详情 + private NotifyDetail buildSmsDetail(SmsBatchGroup group, List reminderList, + Map customerMap, long timeoutAt, String templateId) { + String customerNames = formatCustomerNames(reminderList, customerMap); + NotifyDetail detail = new NotifyDetail(); + detail.setMsgType(NotifyDetail.MsgType.SMS); + detail.setTemplateId(templateId); + detail.setContent("客户跟进提醒:请及时跟进客户%s。".formatted(customerNames)); + detail.setContentType(NotifyDetail.ContentType.TEXT); + detail.setArgs(jackson.valueToTree(List.of(customerNames))); + detail.setSendTo(group.phoneNo()); + detail.setSendAt(Time.now()); + detail.setTimeoutAt(timeoutAt); + return detail; + } + + /// 格式化短信中的客户名称 + /// + /// @param reminderList 提醒列表 + /// @param customerMap 客户映射 + /// @return 客户名称摘要 + private String formatCustomerNames(List reminderList, + Map customerMap) { + Map nameMap = new LinkedHashMap<>(); + for (GaoCustomerFollowUpReminder reminder : reminderList) { + GaoCustomer customer = customerMap.get(reminder.getCustomerId()); + String name = customer == null ? null : customer.getName(); + nameMap.putIfAbsent(reminder.getCustomerId(), TimiJava.isEmpty(name) ? "未知客户" : name.trim()); + } + List nameList = new ArrayList<>(nameMap.values()); + if (nameList.size() <= 2) { + return String.join("、", nameList); + } + return "%s、%s等 %d 位".formatted(nameList.get(0), nameList.get(1), nameList.size()); + } + + /// 短信聚合分组 + private record SmsBatchGroup(String storeId, String responsibleUserId, String phoneNo, boolean validPhone) { + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockRecordServiceImplement.java b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockRecordServiceImplement.java index f4b9590..c83f25b 100644 --- a/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockRecordServiceImplement.java +++ b/src/main/java/com/imyeyu/api/modules/gao/service/implement/GaoProductStockRecordServiceImplement.java @@ -12,6 +12,7 @@ import com.imyeyu.api.modules.gao.service.GaoProductStockRecordService; import com.imyeyu.api.modules.gao.vo.GaoProductStockRecordPage; import com.imyeyu.api.modules.user.entity.User; import com.imyeyu.api.modules.user.service.UserService; +import com.imyeyu.java.TimiJava; import com.imyeyu.java.bean.timi.TimiCode; import com.imyeyu.java.bean.timi.TimiException; import com.imyeyu.spring.bean.PageResult; @@ -166,7 +167,11 @@ public class GaoProductStockRecordServiceImplement extends AbstractEntityService operator.setAttachmentList(userAvatarMap.getOrDefault(operator.getId(), List.of())); } record.setOperator(operator); - GaoCustomer customer = customerMap.get(getCustomerId(record, fulfillmentMap)); + String customerId = getCustomerId(record, fulfillmentMap); + if (TimiJava.isEmpty(customerId)) { + continue; + } + GaoCustomer customer = customerMap.get(customerId); if (customer != null) { customer.setAttachmentList(customerPhotoMap.getOrDefault(customer.getId(), List.of())); } diff --git a/src/main/java/com/imyeyu/api/modules/gao/task/GaoCustomerFollowUpReminderTask.java b/src/main/java/com/imyeyu/api/modules/gao/task/GaoCustomerFollowUpReminderTask.java new file mode 100644 index 0000000..4489a5e --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/task/GaoCustomerFollowUpReminderTask.java @@ -0,0 +1,31 @@ +package com.imyeyu.api.modules.gao.task; + +import com.imyeyu.api.modules.gao.service.GaoCustomerFollowUpReminderService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +/// GAO 客户跟进提醒定时任务 +/// +/// @author Codex +/// @since 2026-09-17 +@Slf4j +@Service +@RequiredArgsConstructor +public class GaoCustomerFollowUpReminderTask { + + private static final String ZONE_ID = "Asia/Shanghai"; + + private final GaoCustomerFollowUpReminderService service; + + /// 每天八点开始创建当天短信批次,并在当天每分钟重试未完成的批次 + @Scheduled(cron = "0 * 8-23 * * ?", zone = ZONE_ID) + public void createSmsBatch() { + try { + service.createSmsBatch(); + } catch (Exception e) { + log.error("GAO 客户跟进提醒短信批次创建失败", e); + } + } +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoCustomerFollowUpReminderPage.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoCustomerFollowUpReminderPage.java new file mode 100644 index 0000000..d158d23 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoCustomerFollowUpReminderPage.java @@ -0,0 +1,33 @@ +package com.imyeyu.api.modules.gao.vo; + +import com.imyeyu.api.modules.gao.entity.GaoCustomerFollowUpReminder; +import com.imyeyu.java.bean.BasePage; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/// GAO 客户跟进提醒分页查询请求 +/// +/// @author Codex +/// @since 2026-09-17 +@Data +@EqualsAndHashCode(callSuper = true) +public class GaoCustomerFollowUpReminderPage extends BasePage { + + /// 门店 ID + private String storeId; + + /// 客户 ID + private String customerId; + + /// 负责人用户 ID + private String responsibleUserId; + + /// 提醒状态 + private GaoCustomerFollowUpReminder.Status status; + + /// 提醒时间起点,包含 + private Long beginRemindAt; + + /// 提醒时间终点,包含 + private Long endRemindAt; +} diff --git a/src/main/java/com/imyeyu/api/modules/gao/vo/GaoCustomerFollowUpReminderPageResult.java b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoCustomerFollowUpReminderPageResult.java new file mode 100644 index 0000000..b001d12 --- /dev/null +++ b/src/main/java/com/imyeyu/api/modules/gao/vo/GaoCustomerFollowUpReminderPageResult.java @@ -0,0 +1,13 @@ +package com.imyeyu.api.modules.gao.vo; + +import com.imyeyu.api.modules.gao.entity.GaoCustomerFollowUpReminder; +import com.imyeyu.spring.bean.PageResult; +import lombok.EqualsAndHashCode; + +/// GAO 客户跟进提醒分页查询结果 +/// +/// @author Codex +/// @since 2026-09-17 +@EqualsAndHashCode(callSuper = true) +public class GaoCustomerFollowUpReminderPageResult extends PageResult { +} diff --git a/src/main/java/com/imyeyu/api/util/JavaCV.java b/src/main/java/com/imyeyu/api/util/JavaCV.java index ece6fc8..23cbb92 100644 --- a/src/main/java/com/imyeyu/api/util/JavaCV.java +++ b/src/main/java/com/imyeyu/api/util/JavaCV.java @@ -16,10 +16,18 @@ import java.io.InputStream; /// @since 2025-10-23 17:05 public class JavaCV { + /// 单次跳转分片上限 + /// + /// 任何真实文件的当前位置加该值都不会溢出 long + private static final long SKIP_PIECE = 1L << 40; + /// 从视频流顺序解码并提取指定时间附近的一帧 PNG 图片 /// /// 不执行时间定位,避免为了取实时缩略图而把整个视频复制到临时文件 /// + /// moov 原子位于文件尾部的视频(非 faststart)仍会触发 FFmpeg 回调 AVSEEK_SIZE 探测文件大小, + /// 依赖底层流的 skip/reset 语义正确,因此必须配合 `SkipSafeInputStream` 规避 GridFS 的溢出缺陷 + /// /// @param stream 视频输入流 /// @param targetSeconds 目标时间,单位为秒 /// @return PNG 图片字节流 @@ -27,7 +35,7 @@ public class JavaCV { public static ByteArrayOutputStream captureThumbnail(InputStream stream, double targetSeconds) throws Exception { long targetMicros = toMicros(targetSeconds); ByteArrayOutputStream firstFrame = new ByteArrayOutputStream(); - try (FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(stream)) { + try (FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(new SkipSafeInputStream(stream))) { grabber.start(); try (Java2DFrameConverter converter = new Java2DFrameConverter()) { Frame frame; @@ -61,4 +69,73 @@ public class JavaCV { } return new ByteArrayOutputStream(); } + + /// 分片跳转流包装 + /// + /// GridFSDownloadStreamImpl 的 skip 以 `currentPosition + n` 计算目标位置且不防护 long 溢出, + /// 当 javacv 的 SeekCallback 以 `skip(Long.MAX_VALUE)` 探测流结尾时,位于流末尾的第二次调用 + /// 会因溢出落入正常分支,返回未实际跳过的 `Long.MAX_VALUE` 并污染内部位置, + /// 导致 `avformat_open_input` 在 `while (true)` 内 100% CPU 死循环 + /// + /// 此包装将大额跳转拆分为不超过 {@link #SKIP_PIECE} 的分片,保证每次调用的目标位置不溢出 + private static class SkipSafeInputStream extends InputStream { + + private final InputStream stream; + + private SkipSafeInputStream(InputStream stream) { + this.stream = stream; + } + + @Override + public int read() throws IOException { + return stream.read(); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return stream.read(b, off, len); + } + + @Override + public long skip(long n) throws IOException { + if (n <= 0) { + return 0; + } + long total = 0; + while (0 < n) { + long skipped = stream.skip(Math.min(n, SKIP_PIECE)); + if (skipped <= 0) { + break; + } + total += skipped; + n -= skipped; + } + return total; + } + + @Override + public int available() throws IOException { + return stream.available(); + } + + @Override + public void close() throws IOException { + stream.close(); + } + + @Override + public synchronized void mark(int readlimit) { + stream.mark(readlimit); + } + + @Override + public synchronized void reset() throws IOException { + stream.reset(); + } + + @Override + public boolean markSupported() { + return stream.markSupported(); + } + } } diff --git a/src/main/resources/db/migration/timiserver/V80__create_gao_customer_follow_up_reminder_tables.sql b/src/main/resources/db/migration/timiserver/V80__create_gao_customer_follow_up_reminder_tables.sql new file mode 100644 index 0000000..24c875b --- /dev/null +++ b/src/main/resources/db/migration/timiserver/V80__create_gao_customer_follow_up_reminder_tables.sql @@ -0,0 +1,26 @@ +CREATE TABLE `gao_customer_follow_up_reminder` ( + `id` VARCHAR(36) NOT NULL, + `store_id` VARCHAR(36) NOT NULL COMMENT '门店 ID', + `customer_id` VARCHAR(36) NOT NULL COMMENT '客户 ID', + `responsible_user_id` VARCHAR(36) NOT NULL COMMENT '负责人用户 ID', + `content` TEXT NOT NULL COMMENT '跟进内容,纯文本', + `remind_at` BIGINT NOT NULL COMMENT '提醒时间,Unix 毫秒时间戳', + `status` VARCHAR(32) NOT NULL COMMENT 'PENDING 待完成,COMPLETED 已完成', + `internal_notify_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'true 为发送站内通知', + `sms_notify_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'true 为发送短信通知', + `internal_notify_detail_id` VARCHAR(36) NULL COMMENT '站内通知详情 ID', + `sms_notify_detail_id` VARCHAR(36) NULL COMMENT '短信聚合通知详情 ID,可被多条提醒共享', + `completed_by` VARCHAR(36) NULL COMMENT '完成人用户 ID', + `completed_at` BIGINT NULL COMMENT '完成时间', + `created_by` VARCHAR(36) NOT NULL COMMENT '创建人用户 ID', + `updated_by` VARCHAR(36) NULL COMMENT '最后修改人用户 ID', + `created_at` BIGINT NOT NULL, + `updated_at` BIGINT NULL, + `deleted_at` BIGINT NULL, + PRIMARY KEY (`id`), + KEY `idx_gao_customer_follow_up_reminder_customer_time` (`store_id`, `customer_id`, `deleted_at`, `remind_at`), + KEY `idx_gao_customer_follow_up_reminder_responsible_time` (`store_id`, `responsible_user_id`, `status`, `deleted_at`, `remind_at`), + KEY `idx_gao_customer_follow_up_reminder_sms_batch` (`store_id`, `sms_notify_enabled`, `status`, `deleted_at`, `remind_at`, `sms_notify_detail_id`), + KEY `idx_gao_customer_follow_up_reminder_internal_detail` (`internal_notify_detail_id`), + KEY `idx_gao_customer_follow_up_reminder_sms_detail` (`sms_notify_detail_id`) +) COMMENT='GAO 客户跟进提醒'; diff --git a/src/main/resources/mapper/timi-server/common/NotifyMapper.xml b/src/main/resources/mapper/timi-server/common/NotifyMapper.xml index e2bdba7..12cf54c 100644 --- a/src/main/resources/mapper/timi-server/common/NotifyMapper.xml +++ b/src/main/resources/mapper/timi-server/common/NotifyMapper.xml @@ -26,6 +26,17 @@ ON DUPLICATE KEY UPDATE `id` = `id` + + + SELECT COUNT(1) + FROM `gao_customer_follow_up_reminder` `r` + WHERE + + + + + + + + UPDATE `gao_customer_follow_up_reminder` + SET `status` = 'COMPLETED', + `updated_by` = #{updatedBy}, + `completed_by` = #{completedBy}, + `completed_at` = #{completedAt}, + `updated_at` = #{completedAt} + WHERE `id` = #{id} + AND `status` = 'PENDING' + AND `deleted_at` IS NULL + + + + UPDATE `gao_customer_follow_up_reminder` + SET `sms_notify_detail_id` = #{smsNotifyDetailId}, + `updated_at` = FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000) + WHERE `id` IN + + #{id} + + AND `status` = 'PENDING' + AND `deleted_at` IS NULL + AND `sms_notify_enabled` = 1 + AND `sms_notify_detail_id` IS NULL + + From cee7dd2442717795a6d8a4561104aec106cbc003 Mon Sep 17 00:00:00 2001 From: Timi Date: Fri, 18 Sep 2026 01:51:00 +0800 Subject: [PATCH 2/2] v1.0.50 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index b091fc7..8a5a97a 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ com.imyeyu.timiserverapi TimiServerAPI - 1.0.49 + 1.0.50 jar TimiServerAPI imyeyu.com API @@ -188,7 +188,7 @@ com.imyeyu.spring timi-spring - 0.0.19 + 0.0.20 com.imyeyu.network