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
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`
+
+