Compare commits

...
17 Commits
Author SHA1 Message Date
timi 135996ce19 Merge pull request 'v1.0.52' (#64) from dev into master
Reviewed-on: #64
2026-09-20 17:22:39 +00:00
Timi e5bfb59a1c v1.0.52
CI / build-deploy (pull_request) Successful in 27s
CI / notify-on-failure (pull_request) Skipped
2026-09-21 01:18:35 +08:00
Timi 51c1d20d3c more filter for sales order 2026-09-20 15:40:03 +08:00
timi 6b85cddede Merge pull request 'v1.0.51' (#63) from dev into master
Reviewed-on: #63
2026-09-18 17:27:14 +00:00
Timi 9256e08cfc v1.0.51
CI / build-deploy (pull_request) Successful in 26s
CI / notify-on-failure (pull_request) Skipped
2026-09-19 01:26:29 +08:00
Timi d0dcd0283e add customer order summary 2026-09-19 01:26:03 +08:00
Timi 76c5495c32 add package filter for SalesOrderPage 2026-09-18 18:28:15 +08:00
timi e486f850f2 Merge pull request 'v1.0.50' (#62) from dev into master
Reviewed-on: #62
2026-09-17 17:52:23 +00:00
Timi cee7dd2442 v1.0.50
CI / build-deploy (pull_request) Successful in 28s
CI / notify-on-failure (pull_request) Skipped
2026-09-18 01:51:00 +08:00
Timi 8f2545f271 add customer followup 2026-09-18 01:47:54 +08:00
timi 76c2f3a5fd Merge pull request 'v1.0.49' (#61) from dev into master
Reviewed-on: #61
2026-09-16 17:34:23 +00:00
Timi b5a7c0d46c v1.0.49
CI / build-deploy (pull_request) Successful in 25s
CI / notify-on-failure (pull_request) Skipped
2026-09-17 01:31:21 +08:00
Timi 088afbe5c1 update customer in list 2026-09-17 01:31:02 +08:00
Timi 0d155ec5b2 update AIAgent 2026-09-16 15:15:36 +08:00
Timi 7d53d31c3f add GaoProductStockBatchRequest 2026-09-16 15:15:19 +08:00
Timi cd8bcae5be auto verify for order fulfillment and payment 2026-09-16 13:06:39 +08:00
Timi a2b6253d1d add customerCount for list event record 2026-09-16 12:54:39 +08:00
68 changed files with 2469 additions and 96 deletions
+2 -2
View File
@@ -11,7 +11,7 @@
<groupId>com.imyeyu.timiserverapi</groupId> <groupId>com.imyeyu.timiserverapi</groupId>
<artifactId>TimiServerAPI</artifactId> <artifactId>TimiServerAPI</artifactId>
<version>1.0.48</version> <version>1.0.52</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>TimiServerAPI</name> <name>TimiServerAPI</name>
<description>imyeyu.com API</description> <description>imyeyu.com API</description>
@@ -188,7 +188,7 @@
<dependency> <dependency>
<groupId>com.imyeyu.spring</groupId> <groupId>com.imyeyu.spring</groupId>
<artifactId>timi-spring</artifactId> <artifactId>timi-spring</artifactId>
<version>0.0.19</version> <version>0.0.20</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.imyeyu.network</groupId> <groupId>com.imyeyu.network</groupId>
@@ -56,6 +56,8 @@ public class Attachment extends UUIDEntity {
GAO_POINT_REDEMPTION_RETURN, GAO_POINT_REDEMPTION_RETURN,
GAO_CUSTOMER_FOLLOW_UP_REMINDER,
TEMP_FILE, TEMP_FILE,
MIRROR MIRROR
@@ -18,6 +18,16 @@ public interface NotifyMapper extends BaseMapper<Notify, String> {
/// 原子创建业务提醒 /// 原子创建业务提醒
int insertIfAbsent(Notify notify); 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 /// @param userId 管理员用户 ID
@@ -26,6 +26,12 @@ public interface NotifyDetailService extends BaseService<NotifyDetail, String> {
/// @param detailId 通知详情 ID /// @param detailId 通知详情 ID
void requeue(String detailId); void requeue(String detailId);
/// 取消尚未生效的通知详情
///
/// @param detailId 通知详情 ID
/// @param reason 取消原因
void cancelPending(String detailId, String reason);
/// 分页查询用户的站内通知 /// 分页查询用户的站内通知
/// ///
/// @param page 分页参数 /// @param page 分页参数
@@ -19,6 +19,14 @@ public interface NotifyService extends BaseService<Notify, String> {
/// @param notify 通知 /// @param notify 通知
void createIfAbsent(Notify notify); void createIfAbsent(Notify notify);
/// 按业务查重键查询未删除通知
///
/// @param bizType 业务类型
/// @param bizRemindedType 业务提醒类型
/// @param bizRemindedKey 业务提醒查重键
/// @return 通知
Notify getByBusinessKey(String bizType, String bizRemindedType, String bizRemindedKey);
/// 分页查询当前用户创建的通知 /// 分页查询当前用户创建的通知
/// ///
/// @param page 分页参数 /// @param page 分页参数
@@ -57,7 +57,7 @@ public class AttachmentVariantServiceImplement implements AttachmentVariantServi
private static final int THUMB_MAX_HEIGHT = 4096; private static final int THUMB_MAX_HEIGHT = 4096;
private static final long THUMB_MAX_PIXELS = 16_000_000L; 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_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 double THUMB_TARGET_SECONDS = 2;
private static final int VARIANT_NAME_MAX_LENGTH = 255; private static final int VARIANT_NAME_MAX_LENGTH = 255;
private static final String THUMB_OUTPUT_FORMAT = "png"; private static final String THUMB_OUTPUT_FORMAT = "png";
@@ -108,6 +108,36 @@ public class NotifyDetailServiceImplement extends AbstractEntityService<NotifyDe
super.delete(detailId); super.delete(detailId);
} }
/// 取消尚未生效的通知详情
///
/// @param detailId 通知详情 ID
/// @param reason 取消原因
@Override
@Transactional(TimiServerDBConfig.ROLLBACKER)
public void cancelPending(String detailId, String reason) {
TimiException.required(detailId, "未找到通知详情 ID");
NotifyDetail detail = get(detailId);
TimiException.required(detail, "未找到通知详情");
if (detail.getStatus() == NotifyDetail.Status.CANCEL) {
return;
}
long now = Time.now();
boolean canCancel = detail.getMsgType() == NotifyDetail.MsgType.INTERNAL
? detail.getSendAt() != null && now < detail.getSendAt()
: detail.getMsgType() != null && detail.getMsgType().isQueue()
&& detail.getStatus() == NotifyDetail.Status.WAITING;
if (!canCancel) {
return;
}
if (detail.getMsgType().isQueue()) {
detail.getMsgType().getService().destroyQueue(detail);
}
detail.setStatus(NotifyDetail.Status.CANCEL);
detail.setResultDesc(TimiJava.defaultIfEmpty(reason, "通知已取消"));
update(detail);
}
/// 重新将通知详情放入消息队列 /// 重新将通知详情放入消息队列
/// ///
/// @param detailId 通知详情 ID /// @param detailId 通知详情 ID
@@ -69,6 +69,20 @@ public class NotifyServiceImplement extends AbstractEntityService<Notify, String
createDetails(notify); createDetails(notify);
} }
/// 按业务查重键查询未删除通知
///
/// @param bizType 业务类型
/// @param bizRemindedType 业务提醒类型
/// @param bizRemindedKey 业务提醒查重键
/// @return 通知
@Override
public Notify getByBusinessKey(String bizType, String bizRemindedType, String bizRemindedKey) {
TimiException.required(bizType, "未找到通知业务类型");
TimiException.required(bizRemindedType, "未找到通知提醒类型");
TimiException.required(bizRemindedKey, "未找到通知查重键");
return mapper.selectByBusinessKey(bizType, bizRemindedType, bizRemindedKey);
}
/// 校验并补全通知创建字段 /// 校验并补全通知创建字段
/// ///
/// @param notify 通知 /// @param notify 通知
@@ -14,10 +14,6 @@ import org.springframework.stereotype.Component;
@Component @Component
public class GaoAiProperties { public class GaoAiProperties {
/// MCP 服务间密钥
@Value("${gao.ai.mcp-service-key:}")
private String mcpServiceKey;
/// AI 会话有效期,单位秒 /// AI 会话有效期,单位秒
@Value("${gao.ai.session-ttl-seconds:300}") @Value("${gao.ai.session-ttl-seconds:300}")
private long sessionTtlSeconds; private long sessionTtlSeconds;
@@ -33,16 +29,4 @@ public class GaoAiProperties {
/// Dify 流式读取空闲超时,单位秒 /// Dify 流式读取空闲超时,单位秒
@Value("${gao.ai.read-timeout-seconds:600}") @Value("${gao.ai.read-timeout-seconds:600}")
private long readTimeoutSeconds; private long readTimeoutSeconds;
/// true 为允许缺少 AI 会话时使用固定测试会话,仅用于本地调试
@Value("${gao.ai.allow-missing-session:false}")
private boolean allowMissingSession;
/// 缺少 AI 会话时使用的测试用户 ID
@Value("${gao.ai.debug-user-id:dify-agent-debug}")
private String debugUserId;
/// 缺少 AI 会话时使用的测试门店 ID
@Value("${gao.ai.debug-store-id:00000000-0000-0000-0000-000000000001}")
private String debugStoreId;
} }
@@ -1,8 +1,11 @@
package com.imyeyu.api.modules.gao.ai.controller; package com.imyeyu.api.modules.gao.ai.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.imyeyu.api.modules.gao.ai.service.GaoAiAccessException; import com.imyeyu.api.modules.gao.ai.service.GaoAiAccessException;
import com.imyeyu.api.modules.gao.ai.service.GaoAiChatService; import com.imyeyu.api.modules.gao.ai.service.GaoAiChatService;
import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest; import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest;
import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationListRequest;
import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationReadRequest;
import com.imyeyu.spring.annotation.IgnoreGlobalReturn; import com.imyeyu.spring.annotation.IgnoreGlobalReturn;
import com.imyeyu.spring.annotation.RequestRateLimit; import com.imyeyu.spring.annotation.RequestRateLimit;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -10,6 +13,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@@ -38,7 +42,57 @@ public class GaoAiChatController {
try { try {
return chatService.chat(request); return chatService.chat(request);
} catch (GaoAiAccessException e) { } catch (GaoAiAccessException e) {
throw new ResponseStatusException(HttpStatus.valueOf(e.getStatus()), e.getMessage(), e); throw accessException(e);
} }
} }
/// 列出当前登录用户的历史会话
///
/// @param request 历史会话分页请求
/// @return 历史会话分页结果
@RequestRateLimit
@PostMapping("/conversation/list")
public JsonNode listConversation(@RequestBody(required = false) GaoAiConversationListRequest request) {
try {
return chatService.listConversation(request);
} catch (GaoAiAccessException e) {
throw accessException(e);
}
}
/// 读取指定历史会话的消息
///
/// @param request 历史会话读取请求
/// @return 会话消息分页结果
@RequestRateLimit
@PostMapping("/conversation/read")
public JsonNode readConversation(@RequestBody GaoAiConversationReadRequest request) {
try {
return chatService.readConversation(request);
} catch (GaoAiAccessException e) {
throw accessException(e);
}
}
/// 删除当前登录用户的历史会话
///
/// @param conversationId Dify 会话 ID
/// @return 无
@RequestRateLimit
@PostMapping("/conversation/delete")
public void deleteConversation(@RequestParam String conversationId) {
try {
chatService.deleteConversation(conversationId);
} catch (GaoAiAccessException e) {
throw accessException(e);
}
}
/// 转换智能体访问异常
///
/// @param exception 智能体访问异常
/// @return HTTP 响应状态异常
private ResponseStatusException accessException(GaoAiAccessException exception) {
return new ResponseStatusException(HttpStatus.valueOf(exception.getStatus()), exception.getMessage(), exception);
}
} }
@@ -1,9 +1,10 @@
package com.imyeyu.api.modules.gao.ai.mcp; package com.imyeyu.api.modules.gao.ai.mcp;
import com.imyeyu.api.modules.gao.ai.config.GaoAiProperties; import com.imyeyu.api.modules.common.service.SettingService;
import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession;
import com.imyeyu.api.modules.gao.ai.service.GaoAiSessionService; import com.imyeyu.api.modules.gao.ai.service.GaoAiSessionService;
import com.imyeyu.api.modules.gao.ai.service.implement.GaoAiSessionServiceImplement.GaoAiSessionException; import com.imyeyu.api.modules.gao.ai.service.implement.GaoAiSessionServiceImplement.GaoAiSessionException;
import com.imyeyu.api.modules.gao.bean.GaoSystemSetting;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -20,23 +21,21 @@ import java.security.MessageDigest;
@RequiredArgsConstructor @RequiredArgsConstructor
public class GaoAiMcpAuthentication { public class GaoAiMcpAuthentication {
private final GaoAiProperties properties; private final SettingService settingService;
private final GaoAiSessionService sessionService; private final GaoAiSessionService sessionService;
/// 校验 Dify 服务密钥和 AI 会话 /// 校验 Dify 服务密钥和 AI 会话
/// ///
/// @param authorization Authorization 请求头 /// @param authorization Authorization 请求头
/// @param sessionId X-Timi-AI-Session 请求头 /// @param headerSessionId X-Timi-AI-Session 请求头
/// @param argumentSessionId MCP 工具参数中的 AI 会话 ID
/// @param toolName 工具名称,可为空 /// @param toolName 工具名称,可为空
/// @return 已认证会话 /// @return 已认证会话
public GaoAiSession authenticate(String authorization, String sessionId, String toolName) { public GaoAiSession authenticate(String authorization, String headerSessionId, String argumentSessionId, String toolName) {
authenticateService(authorization); authenticateService(authorization);
if (isBlank(sessionId) && properties.isAllowMissingSession()) { String sessionId = headerSessionId;
GaoAiSession session = debugSession(); if (isBlank(sessionId) && !isBlank(argumentSessionId)) {
if (toolName != null && !session.getAllowedTools().contains(toolName)) { sessionId = argumentSessionId;
throw new GaoAiSessionException("AI 会话无权调用该工具", 403);
}
return session;
} }
GaoAiSession session = sessionService.require(sessionId); GaoAiSession session = sessionService.require(sessionId);
if (toolName != null && (session.getAllowedTools() == null || !session.getAllowedTools().contains(toolName))) { if (toolName != null && (session.getAllowedTools() == null || !session.getAllowedTools().contains(toolName))) {
@@ -51,9 +50,9 @@ public class GaoAiMcpAuthentication {
/// ///
/// @param authorization Authorization 请求头 /// @param authorization Authorization 请求头
public void authenticateService(String authorization) { public void authenticateService(String authorization) {
String expectedKey = trim(properties.getMcpServiceKey()); String key = settingService.getSystem(GaoSystemSetting.DIFY_MCP_SERVICE_KEY).asString();
String actualKey = bearerValue(authorization); String actualKey = bearerValue(authorization);
if (expectedKey.isEmpty() || actualKey.isEmpty() || !constantTimeEquals(expectedKey, actualKey)) { if (key.isEmpty() || actualKey.isEmpty() || !constantTimeEquals(key, actualKey)) {
throw new GaoAiSessionException("MCP 服务认证失败", 401); throw new GaoAiSessionException("MCP 服务认证失败", 401);
} }
} }
@@ -77,24 +76,6 @@ public class GaoAiMcpAuthentication {
return value == null ? "" : value.trim(); return value == null ? "" : value.trim();
} }
/// 创建固定的开发测试会话
///
/// @return 开发测试会话
private GaoAiSession debugSession() {
String userId = trim(properties.getDebugUserId());
String storeId = trim(properties.getDebugStoreId());
if (userId.isEmpty() || storeId.isEmpty()) {
throw new GaoAiSessionException("开发测试会话缺少用户或门店配置", 401);
}
GaoAiSession session = new GaoAiSession();
session.setSessionId("dify-agent-debug-bypass");
session.setUserId(userId);
session.setStoreId(storeId);
session.setAllowedTools(GaoAiToolNames.orderedReadOnlyTools());
session.setExpiresAt(Long.MAX_VALUE);
return session;
}
/// 判断字符串是否为空 /// 判断字符串是否为空
/// ///
/// @param value 待判断字符串 /// @param value 待判断字符串
@@ -2,6 +2,7 @@ package com.imyeyu.api.modules.gao.ai.mcp;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.imyeyu.api.modules.gao.ai.entity.GaoAiAuditLog; import com.imyeyu.api.modules.gao.ai.entity.GaoAiAuditLog;
import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession; import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession;
import com.imyeyu.api.modules.gao.ai.mcp.tool.GaoAiTool; import com.imyeyu.api.modules.gao.ai.mcp.tool.GaoAiTool;
@@ -90,12 +91,13 @@ public class GaoAiMcpController {
yield response(200, success(id, toolsResult())); yield response(200, success(id, toolsResult()));
} }
case "tools/call" -> { case "tools/call" -> {
GaoAiSession session = authentication.authenticate(authorization, aiSessionId, text(request.path("params"), "name")); JsonNode params = request.path("params");
if (!hasText(aiSessionId)) { String argumentSessionId = text(params.path("arguments"), "aiSessionId");
log.warn("GAO MCP 使用开发测试会话:toolName={}, userId={}, storeId={}", GaoAiSession session = authentication.authenticate(authorization, aiSessionId, argumentSessionId, text(params, "name"));
toolName, session.getUserId(), session.getStoreId()); if (hasText(argumentSessionId) && !hasText(aiSessionId)) {
log.info("GAO MCP 使用工具参数中的 AI 会话:toolName={}, aiSessionLength={}", toolName, length(argumentSessionId));
} }
yield response(200, success(id, executeTool(session, request.path("params"), aiSessionId))); yield response(200, success(id, executeTool(session, params, hasText(aiSessionId) ? aiSessionId : argumentSessionId)));
} }
default -> response(400, error(id, -32601, "MCP 方法不支持")); default -> response(400, error(id, -32601, "MCP 方法不支持"));
}; };
@@ -126,12 +128,14 @@ public class GaoAiMcpController {
if (arguments == null || !arguments.isObject()) { if (arguments == null || !arguments.isObject()) {
throw new GaoAiToolException("工具参数格式无效", 400); throw new GaoAiToolException("工具参数格式无效", 400);
} }
if (containsField(arguments, "storeId")) { ObjectNode businessArguments = ((ObjectNode) arguments).deepCopy();
businessArguments.remove("aiSessionId");
if (containsField(businessArguments, "storeId")) {
throw new GaoAiToolException("工具请求不允许携带 storeId", 403); throw new GaoAiToolException("工具请求不允许携带 storeId", 403);
} }
GaoAiToolRequest request; GaoAiToolRequest request;
try { try {
request = jackson.treeToValue(arguments, GaoAiToolRequest.class); request = jackson.treeToValue(businessArguments, GaoAiToolRequest.class);
} catch (Exception e) { } catch (Exception e) {
throw new GaoAiToolException("工具参数格式无效", 400); throw new GaoAiToolException("工具参数格式无效", 400);
} }
@@ -4,6 +4,7 @@ import com.imyeyu.api.modules.gao.ai.entity.GaoAiSession;
import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest; import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolRequest;
import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult; import com.imyeyu.api.modules.gao.ai.vo.GaoAiToolResult;
import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
/// GAO 智能体只读工具 /// GAO 智能体只读工具
@@ -36,15 +37,19 @@ public interface GaoAiTool {
), ),
"additionalProperties", false "additionalProperties", false
); );
Map<String, Object> properties = new LinkedHashMap<>();
properties.put("query", Map.of("type", "string", "description", "自然语言关键词"));
properties.put("timeRange", timeRange);
properties.put("filters", Map.of("type", "object"));
properties.put("pageSize", Map.of("type", "integer", "minimum", 1, "maximum", 20));
properties.put("cursor", Map.of("type", "string"));
properties.put("aiSessionId", Map.of(
"type", "string",
"description", "由工作流变量注入的 AI 会话 ID,不允许由模型生成"
));
return Map.of( return Map.of(
"type", "object", "type", "object",
"properties", Map.of( "properties", properties,
"query", Map.of("type", "string", "description", "自然语言关键词"),
"timeRange", timeRange,
"filters", Map.of("type", "object"),
"pageSize", Map.of("type", "integer", "minimum", 1, "maximum", 20),
"cursor", Map.of("type", "string")
),
"additionalProperties", false "additionalProperties", false
); );
} }
@@ -1,6 +1,9 @@
package com.imyeyu.api.modules.gao.ai.service; package com.imyeyu.api.modules.gao.ai.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest; import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest;
import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationListRequest;
import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationReadRequest;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/// GAO 智能体聊天服务 /// GAO 智能体聊天服务
@@ -14,4 +17,21 @@ public interface GaoAiChatService {
/// @param request 聊天请求 /// @param request 聊天请求
/// @return SSE 发射器 /// @return SSE 发射器
SseEmitter chat(GaoAiChatRequest request); SseEmitter chat(GaoAiChatRequest request);
/// 列出当前登录用户的历史会话
///
/// @param request 历史会话分页请求
/// @return 历史会话分页结果
JsonNode listConversation(GaoAiConversationListRequest request);
/// 读取当前登录用户的历史会话消息
///
/// @param request 历史会话读取请求
/// @return 会话消息分页结果
JsonNode readConversation(GaoAiConversationReadRequest request);
/// 删除当前登录用户的历史会话
///
/// @param conversationId Dify 会话 ID
void deleteConversation(String conversationId);
} }
@@ -2,6 +2,7 @@ package com.imyeyu.api.modules.gao.ai.service.implement;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.imyeyu.api.bean.ModuleCode; import com.imyeyu.api.bean.ModuleCode;
import com.imyeyu.api.modules.common.service.SettingService; import com.imyeyu.api.modules.common.service.SettingService;
@@ -13,6 +14,8 @@ import com.imyeyu.api.modules.gao.ai.service.GaoAiAuditService;
import com.imyeyu.api.modules.gao.ai.service.GaoAiChatService; import com.imyeyu.api.modules.gao.ai.service.GaoAiChatService;
import com.imyeyu.api.modules.gao.ai.service.GaoAiSessionService; import com.imyeyu.api.modules.gao.ai.service.GaoAiSessionService;
import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest; import com.imyeyu.api.modules.gao.ai.vo.GaoAiChatRequest;
import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationListRequest;
import com.imyeyu.api.modules.gao.ai.vo.GaoAiConversationReadRequest;
import com.imyeyu.api.modules.gao.bean.GaoRoleCode; import com.imyeyu.api.modules.gao.bean.GaoRoleCode;
import com.imyeyu.api.modules.gao.bean.GaoSystemSetting; import com.imyeyu.api.modules.gao.bean.GaoSystemSetting;
import com.imyeyu.api.modules.gao.entity.GaoStore; import com.imyeyu.api.modules.gao.entity.GaoStore;
@@ -33,6 +36,7 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.net.URI; import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
@@ -101,13 +105,7 @@ public class GaoAiChatServiceImplement implements GaoAiChatService {
if (request.getConversationId() != null && 128 < request.getConversationId().length()) { if (request.getConversationId() != null && 128 < request.getConversationId().length()) {
throw new GaoAiAccessException("会话 ID 无效", 400); throw new GaoAiAccessException("会话 ID 无效", 400);
} }
User user = userLoginService.getLoginUser(); User user = requireAiUser();
if (user == null) {
throw new GaoAiAccessException("登录已过期,请重新登录", 401);
}
if (!roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.STORE_MANAGER.name())) {
throw new GaoAiAccessException("当前账号无权使用门店智能助手", 403);
}
String storeId; String storeId;
try { try {
storeId = storeScopeService.resolveStoreId(request.getStoreId()); storeId = storeScopeService.resolveStoreId(request.getStoreId());
@@ -122,6 +120,50 @@ public class GaoAiChatServiceImplement implements GaoAiChatService {
return stream(session, message); return stream(session, message);
} }
@Override
public JsonNode listConversation(GaoAiConversationListRequest request) {
User user = requireAiUser();
String lastId = optionalValue(request == null ? null : request.getLastId(), "上一页游标", 128);
int limit = normalizeLimit(request == null ? null : request.getLimit());
String sortBy = normalizeSortBy(request == null ? null : request.getSortBy());
StringBuilder url = new StringBuilder("/conversations");
appendQueryParameter(url, "user", difyUser(user));
appendQueryParameter(url, "last_id", lastId);
appendQueryParameter(url, "limit", String.valueOf(limit));
appendQueryParameter(url, "sort_by", sortBy);
return sanitizeConversationList(parseJson(sendDify(newDifyRequest(url.toString()).GET().build(), "查询历史会话")));
}
@Override
public JsonNode readConversation(GaoAiConversationReadRequest request) {
User user = requireAiUser();
if (request == null) {
throw new GaoAiAccessException("会话读取参数不能为空", 400);
}
String conversationId = requiredValue(request.getConversationId(), "会话 ID", 128);
String firstId = optionalValue(request.getFirstId(), "上一页游标", 128);
int limit = normalizeLimit(request.getLimit());
StringBuilder url = new StringBuilder("/messages");
appendQueryParameter(url, "user", difyUser(user));
appendQueryParameter(url, "conversation_id", conversationId);
appendQueryParameter(url, "first_id", firstId);
appendQueryParameter(url, "limit", String.valueOf(limit));
return sanitizeConversationMessages(parseJson(sendDify(newDifyRequest(url.toString()).GET().build(), "读取历史会话")));
}
@Override
public void deleteConversation(String conversationId) {
User user = requireAiUser();
String value = requiredValue(conversationId, "会话 ID", 128);
ObjectNode body = jackson.createObjectNode();
body.put("user", difyUser(user));
HttpRequest request = newDifyRequest("/conversations/" + encodePathSegment(value))
.method("DELETE", HttpRequest.BodyPublishers.ofString(writeJson(body), StandardCharsets.UTF_8))
.header("Content-Type", "application/json")
.build();
sendDify(request, "删除历史会话");
}
private SseEmitter stream(GaoAiSession session, String message) { private SseEmitter stream(GaoAiSession session, String message) {
long readTimeoutSeconds = positive(properties.getReadTimeoutSeconds(), 600L); long readTimeoutSeconds = positive(properties.getReadTimeoutSeconds(), 600L);
long emitterTimeout = Math.min(Long.MAX_VALUE - 1L, readTimeoutSeconds * 1000L + 30_000L); long emitterTimeout = Math.min(Long.MAX_VALUE - 1L, readTimeoutSeconds * 1000L + 30_000L);
@@ -190,19 +232,250 @@ public class GaoAiChatServiceImplement implements GaoAiChatService {
GaoStore store = storeService.get(session.getStoreId()); GaoStore store = storeService.get(session.getStoreId());
inputs.put("storeName", store == null ? "当前门店" : store.getName()); inputs.put("storeName", store == null ? "当前门店" : store.getName());
inputs.put("today", LocalDate.now(ZONE).toString()); inputs.put("today", LocalDate.now(ZONE).toString());
String url = settingService.getSystem(DifySetting.BASE_URL).asString(); return newDifyRequest("/chat-messages")
String appApiKey = settingService.getSystem(GaoSystemSetting.DIFY_APP_API_KEY).asString();
return HttpRequest.newBuilder()
.uri(URI.create(url + "/chat-messages"))
.timeout(Duration.ofSeconds(readTimeoutSeconds)) .timeout(Duration.ofSeconds(readTimeoutSeconds))
.header("Authorization", "Bearer " + appApiKey)
.header("X-Timi-AI-Session", session.getSessionId()) .header("X-Timi-AI-Session", session.getSessionId())
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("Accept", "text/event-stream") .setHeader("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(writeJson(body), StandardCharsets.UTF_8)) .POST(HttpRequest.BodyPublishers.ofString(writeJson(body), StandardCharsets.UTF_8))
.build(); .build();
} }
/// 获取当前登录且有权使用智能体的用户
///
/// @return 当前登录用户
private User requireAiUser() {
User user = userLoginService.getLoginUser();
if (user == null) {
throw new GaoAiAccessException("登录已过期,请重新登录", 401);
}
if (!roleChecker.hasAny(ModuleCode.GAO, GaoRoleCode.STORE_MANAGER.name())) {
throw new GaoAiAccessException("当前账号无权使用门店智能助手", 403);
}
return user;
}
/// 创建 Dify API 请求
///
/// @param path Dify API 路径,可包含已编码的查询参数
/// @return Dify API 请求构造器
private HttpRequest.Builder newDifyRequest(String path) {
String appApiKey = settingService.getSystem(GaoSystemSetting.DIFY_APP_API_KEY).asString();
if (isBlank(appApiKey)) {
throw new GaoAiAccessException("智能体服务未配置", 503);
}
return HttpRequest.newBuilder(difyUri(path))
.timeout(Duration.ofSeconds(Math.min(60L, positive(properties.getReadTimeoutSeconds(), 600L))))
.header("Authorization", "Bearer " + appApiKey)
.header("Accept", "application/json");
}
/// 调用 Dify API
///
/// @param request Dify API 请求
/// @param operation 操作名称
/// @return Dify 响应正文
private String sendDify(HttpRequest request, String operation) {
HttpResponse<String> response;
try {
response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new GaoAiAccessException("智能体服务请求被中断", 503);
} catch (IOException | RuntimeException e) {
log.warn("{}失败", operation, e);
throw new GaoAiAccessException("智能体服务暂时不可用,请稍后重试", 503);
}
if (response.statusCode() < 200 || 299 < response.statusCode()) {
log.warn("{}失败:statusCode={}", operation, response.statusCode());
int status = response.statusCode() == 404 ? 404 : 503;
String message = status == 404 ? "历史会话不存在" : "智能体服务暂时不可用,请稍后重试";
throw new GaoAiAccessException(message, status);
}
return response.body();
}
/// 解析 Dify JSON 响应
///
/// @param responseBody 响应正文
/// @return JSON 节点
private JsonNode parseJson(String responseBody) {
try {
return jackson.readTree(responseBody);
} catch (IOException | RuntimeException e) {
log.warn("解析智能体历史会话响应失败", e);
throw new GaoAiAccessException("智能体服务返回数据无效", 503);
}
}
/// 过滤会话列表中的 Dify 内部字段
///
/// @param response Dify 响应
/// @return 脱敏后的响应
private JsonNode sanitizeConversationList(JsonNode response) {
if (response == null || !response.isObject()) {
return response;
}
ObjectNode result = jackson.createObjectNode();
copyFields(response, result, "has_more", "limit");
ArrayNode data = result.putArray("data");
for (JsonNode item : response.path("data")) {
if (!item.isObject()) {
continue;
}
ObjectNode conversation = data.addObject();
copyFields(item, conversation, "id", "name", "status", "introduction", "created_at", "updated_at");
}
return result;
}
/// 过滤会话消息中的 Dify 内部字段
///
/// @param response Dify 响应
/// @return 脱敏后的响应
private JsonNode sanitizeConversationMessages(JsonNode response) {
if (response == null || !response.isObject()) {
return response;
}
ObjectNode result = jackson.createObjectNode();
copyFields(response, result, "has_more", "limit");
ArrayNode data = result.putArray("data");
for (JsonNode item : response.path("data")) {
if (!item.isObject()) {
continue;
}
ObjectNode message = data.addObject();
copyFields(item, message, "id", "conversation_id", "query", "answer", "created_at", "status", "feedback");
}
return result;
}
/// 复制指定的 JSON 字段
///
/// @param source 源 JSON 对象
/// @param target 目标 JSON 对象
/// @param fieldNames 字段名称
/// @return 无
private void copyFields(JsonNode source, ObjectNode target, String... fieldNames) {
for (String fieldName : fieldNames) {
if (source.has(fieldName)) {
target.set(fieldName, source.get(fieldName));
}
}
}
/// 生成 Dify 用户标识
///
/// @param user 登录用户
/// @return Dify 用户标识
private String difyUser(User user) {
return "gao-store-manager:" + user.getId();
}
/// 添加 URL 查询参数
///
/// @param url URL 构造器
/// @param name 参数名称
/// @param value 参数值
/// @return 无
private void appendQueryParameter(StringBuilder url, String name, String value) {
if (isBlank(value)) {
return;
}
url.append(url.indexOf("?") < 0 ? '?' : '&')
.append(name)
.append('=')
.append(URLEncoder.encode(value, StandardCharsets.UTF_8));
}
/// 生成 Dify API URI
///
/// @param path Dify API 路径
/// @return Dify API URI
private URI difyUri(String path) {
String baseUrl = settingService.getSystem(DifySetting.BASE_URL).asString();
if (isBlank(baseUrl)) {
throw new GaoAiAccessException("智能体服务未配置", 503);
}
String normalizedBaseUrl = baseUrl.trim();
while (normalizedBaseUrl.endsWith("/")) {
normalizedBaseUrl = normalizedBaseUrl.substring(0, normalizedBaseUrl.length() - 1);
}
try {
URI uri = URI.create(normalizedBaseUrl + (path.startsWith("/") ? path : "/" + path));
if (!uri.isAbsolute() || isBlank(uri.getScheme()) || isBlank(uri.getHost())) {
throw new IllegalArgumentException("Dify 地址必须是绝对 HTTP 地址");
}
return uri;
} catch (IllegalArgumentException e) {
throw new GaoAiAccessException("智能体服务配置无效", 503);
}
}
/// 编码 URL 路径片段
///
/// @param value 路径片段
/// @return 编码后的路径片段
private String encodePathSegment(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
}
/// 标准化分页条数
///
/// @param limit 请求的分页条数
/// @return 标准化后的分页条数
private int normalizeLimit(Integer limit) {
int value = limit == null ? 20 : limit;
if (!(0 < value) || 100 < value) {
throw new GaoAiAccessException("分页条数必须在 1 至 100 之间", 400);
}
return value;
}
/// 标准化会话排序方式
///
/// @param sortBy 请求的排序方式
/// @return 标准化后的排序方式
private String normalizeSortBy(String sortBy) {
String value = isBlank(sortBy) ? "-updated_at" : sortBy.trim();
if (!"created_at".equals(value) && !"-created_at".equals(value)
&& !"updated_at".equals(value) && !"-updated_at".equals(value)) {
throw new GaoAiAccessException("会话排序方式无效", 400);
}
return value;
}
/// 校验必填文本参数
///
/// @param value 参数值
/// @param name 参数名称
/// @param maxLength 最大长度
/// @return 去除首尾空白后的参数值
private String requiredValue(String value, String name, int maxLength) {
String result = optionalValue(value, name, maxLength);
if (result == null) {
throw new GaoAiAccessException(name + "不能为空", 400);
}
return result;
}
/// 校验可选文本参数
///
/// @param value 参数值
/// @param name 参数名称
/// @param maxLength 最大长度
/// @return 去除首尾空白后的参数值,无值时返回 null
private String optionalValue(String value, String name, int maxLength) {
if (isBlank(value)) {
return null;
}
String result = value.trim();
if (maxLength < result.length()) {
throw new GaoAiAccessException(name + "无效", 400);
}
return result;
}
private void readResponse(HttpResponse<InputStream> response, SseEmitter emitter, AtomicBoolean finished, private void readResponse(HttpResponse<InputStream> response, SseEmitter emitter, AtomicBoolean finished,
AtomicReference<CompletableFuture<HttpResponse<InputStream>>> upstreamRef, AtomicReference<ScheduledFuture<?>> timeoutRef, AtomicReference<CompletableFuture<HttpResponse<InputStream>>> upstreamRef, AtomicReference<ScheduledFuture<?>> timeoutRef,
AtomicReference<String> status, AtomicBoolean auditRecorded, GaoAiSession session, String message, long startedAt, AtomicReference<String> status, AtomicBoolean auditRecorded, GaoAiSession session, String message, long startedAt,
@@ -228,7 +501,11 @@ public class GaoAiChatServiceImplement implements GaoAiChatService {
if (line.startsWith("event:")) { if (line.startsWith("event:")) {
sseEvent.set(line.substring(6).trim()); sseEvent.set(line.substring(6).trim());
} else if (line.startsWith("data:")) { } else if (line.startsWith("data:")) {
data.append(line.substring(5).trim()); if (!data.isEmpty()) {
data.append('\n');
}
String dataLine = line.substring(5);
data.append(dataLine.startsWith(" ") ? dataLine.substring(1) : dataLine);
} else if (line.isEmpty() && !data.isEmpty()) { } else if (line.isEmpty() && !data.isEmpty()) {
processData(data.toString(), sseEvent.get(), emitter, status, finished, timeoutRef, auditRecorded, processData(data.toString(), sseEvent.get(), emitter, status, finished, timeoutRef, auditRecorded,
session, message, startedAt); session, message, startedAt);
@@ -292,9 +569,13 @@ public class GaoAiChatServiceImplement implements GaoAiChatService {
} }
switch (event) { switch (event) {
case "message", "agent_message", "text_chunk" -> { case "message", "agent_message", "text_chunk" -> {
String answer = firstText(text(payload, "answer", null), text(payload, "text", null)); String answer = firstRawText(payload, "answer");
if (answer != null) { if (answer == null) {
safeSend(emitter, SseEmitter.event().name("message").data(answer), finished); answer = firstRawText(payload, "text");
}
if (answer != null && !answer.isEmpty()) {
Map<String, String> messageData = Map.of("answer", answer);
safeSend(emitter, SseEmitter.event().name("message").data(messageData), finished);
} }
} }
case "message_end" -> emitDone(emitter, session, startedAt, finished, timeoutRef, auditRecorded, message, status); case "message_end" -> emitDone(emitter, session, startedAt, finished, timeoutRef, auditRecorded, message, status);
@@ -446,6 +727,13 @@ public class GaoAiChatServiceImplement implements GaoAiChatService {
return fallback; return fallback;
} }
private String firstRawText(JsonNode node, String field) {
if (node != null && node.hasNonNull(field)) {
return node.get(field).asText();
}
return null;
}
private String firstText(String... values) { private String firstText(String... values) {
for (String value : values) { for (String value : values) {
if (!isBlank(value)) { if (!isBlank(value)) {
@@ -0,0 +1,22 @@
package com.imyeyu.api.modules.gao.ai.vo;
import lombok.Data;
/// GAO 智能体历史会话列表请求
///
/// 字段与 Dify 会话列表分页参数对应,但用户标识由后端注入
///
/// @author Codex
/// @since 2026-09-16
@Data
public class GaoAiConversationListRequest {
/// 上一页最后一条会话 ID,可为空
private String lastId;
/// 返回条数,可为空
private Integer limit;
/// 排序方式,可选 `created_at`、`-created_at`、`updated_at`、`-updated_at`
private String sortBy;
}
@@ -0,0 +1,22 @@
package com.imyeyu.api.modules.gao.ai.vo;
import lombok.Data;
/// GAO 智能体历史会话消息读取请求
///
/// 用户标识由后端注入,前端只能指定会话和分页游标
///
/// @author Codex
/// @since 2026-09-16
@Data
public class GaoAiConversationReadRequest {
/// Dify 会话 ID
private String conversationId;
/// 上一页第一条消息 ID,可为空
private String firstId;
/// 返回条数,可为空
private Integer limit;
}
@@ -39,6 +39,16 @@ public enum GaoPermissionCode implements BuiltinPermissionCode {
CUSTOMER_DELETE("CUSTOMER:DELETE", "客户删除", false, false), CUSTOMER_DELETE("CUSTOMER:DELETE", "客户删除", false, false),
CUSTOMER_FOLLOW_UP_REMINDER_CREATE("CUSTOMER_FOLLOW_UP_REMINDER:CREATE", "客户跟进提醒创建", false, false),
CUSTOMER_FOLLOW_UP_REMINDER_READ("CUSTOMER_FOLLOW_UP_REMINDER:READ", "客户跟进提醒读取", false, false),
CUSTOMER_FOLLOW_UP_REMINDER_UPDATE("CUSTOMER_FOLLOW_UP_REMINDER:UPDATE", "客户跟进提醒修改", false, false),
CUSTOMER_FOLLOW_UP_REMINDER_DELETE("CUSTOMER_FOLLOW_UP_REMINDER:DELETE", "客户跟进提醒删除", false, false),
CUSTOMER_FOLLOW_UP_REMINDER_COMPLETE("CUSTOMER_FOLLOW_UP_REMINDER:COMPLETE", "客户跟进提醒完成", false, false),
EVENT_CREATE("EVENT:CREATE", "登记事件创建", false, false), EVENT_CREATE("EVENT:CREATE", "登记事件创建", false, false),
EVENT_READ("EVENT:READ", "登记事件读取", false, false), EVENT_READ("EVENT:READ", "登记事件读取", false, false),
@@ -17,11 +17,11 @@ public enum GaoSystemSetting implements SettingEnum {
DIFY_APP_API_KEY(null, Setting.ValueType.STRING), DIFY_APP_API_KEY(null, Setting.ValueType.STRING),
SERVICE_LICENSE(null, Setting.ValueType.STRING), DIFY_MCP_SERVICE_KEY(null, Setting.ValueType.STRING),
PRIVACY_LICENSE(null, Setting.ValueType.STRING), AI_AGENT_EXAMPLE(null, Setting.ValueType.JSON_LIST),
OPEN_SOURCE_LICENSE(null, Setting.ValueType.STRING); AGREEMENT(null, Setting.ValueType.JSON_OBJECT);
final String defaultValue; final String defaultValue;
@@ -0,0 +1,182 @@
package com.imyeyu.api.modules.gao.controller;
import com.fasterxml.jackson.annotation.JsonView;
import com.imyeyu.api.modules.common.entity.Attachment;
import com.imyeyu.api.modules.common.service.AttachmentService;
import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission;
import com.imyeyu.api.modules.gao.bean.GaoPermissionCode;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
import com.imyeyu.api.modules.gao.entity.GaoCustomerFollowUpReminder;
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.UserService;
import com.imyeyu.java.bean.timi.TimiException;
import com.imyeyu.spring.annotation.AOPLog;
import com.imyeyu.spring.annotation.RequestRateLimit;
import com.imyeyu.spring.util.ResponseView;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/// GAO 客户跟进提醒接口
///
/// @author Codex
/// @since 2026-09-17
@RestController
@RequiredArgsConstructor
@RequestMapping("/gao/customer/follow-up-reminder")
public class GaoCustomerFollowUpReminderController {
private final GaoCustomerFollowUpReminderService service;
private final GaoCustomerService customerService;
private final GaoStoreScopeService storeScopeService;
private final UserService userService;
private final AttachmentService attachmentService;
/// 分页查询客户跟进提醒
///
/// @param page 查询条件
/// @return 分页结果
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.CUSTOMER_FOLLOW_UP_REMINDER_READ)
@PostMapping("/list")
@JsonView(ResponseView.Public.class)
public GaoCustomerFollowUpReminderPageResult list(@RequestBody GaoCustomerFollowUpReminderPage page) {
TimiException.required(page, "未找到分页参数");
page.setStoreId(storeScopeService.resolveStoreId(page.getStoreId()));
GaoCustomerFollowUpReminderPageResult result = service.pageByQuery(page);
fillList(result.getList());
return result;
}
/// 查询客户跟进提醒详情
///
/// @param id 跟进提醒 ID
/// @return 跟进提醒
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.CUSTOMER_FOLLOW_UP_REMINDER_READ)
@PostMapping("/detail")
@JsonView(ResponseView.Public.class)
public GaoCustomerFollowUpReminder detail(@RequestParam String id) {
GaoCustomerFollowUpReminder reminder = service.get(id);
TimiException.required(reminder, "未找到客户跟进提醒");
storeScopeService.checkStoreId(reminder.getStoreId());
fillDetail(reminder);
return reminder;
}
/// 创建客户跟进提醒
///
/// @param reminder 跟进提醒
/// @return 跟进提醒 ID
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.CUSTOMER_FOLLOW_UP_REMINDER_CREATE)
@PostMapping("/create")
public String create(@RequestBody GaoCustomerFollowUpReminder reminder) {
service.create(reminder);
return reminder.getId();
}
/// 修改客户跟进提醒
///
/// @param reminder 跟进提醒
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.CUSTOMER_FOLLOW_UP_REMINDER_UPDATE)
@PostMapping("/update")
public void update(@RequestBody GaoCustomerFollowUpReminder reminder) {
TimiException.required(reminder, "未找到客户跟进提醒");
TimiException.required(reminder.getId(), "未找到客户跟进提醒 ID");
GaoCustomerFollowUpReminder dbReminder = service.get(reminder.getId());
TimiException.required(dbReminder, "未找到客户跟进提醒");
storeScopeService.checkStoreId(dbReminder.getStoreId());
service.update(reminder);
}
/// 完成客户跟进提醒
///
/// @param id 跟进提醒 ID
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.CUSTOMER_FOLLOW_UP_REMINDER_COMPLETE)
@PostMapping("/complete")
public void complete(@RequestParam String id) {
GaoCustomerFollowUpReminder reminder = service.get(id);
TimiException.required(reminder, "未找到客户跟进提醒");
storeScopeService.checkStoreId(reminder.getStoreId());
service.complete(id);
}
/// 删除客户跟进提醒
///
/// @param id 跟进提醒 ID
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.CUSTOMER_FOLLOW_UP_REMINDER_DELETE)
@PostMapping("/delete")
public void delete(@RequestParam String id) {
GaoCustomerFollowUpReminder reminder = service.get(id);
TimiException.required(reminder, "未找到客户跟进提醒");
storeScopeService.checkStoreId(reminder.getStoreId());
service.delete(id);
}
/// 回填列表中的客户、负责人和附件
///
/// @param reminderList 提醒列表
private void fillList(List<GaoCustomerFollowUpReminder> reminderList) {
if (reminderList == null || reminderList.isEmpty()) {
return;
}
List<String> customerIdList = reminderList.stream()
.map(GaoCustomerFollowUpReminder::getCustomerId)
.distinct()
.toList();
Map<String, GaoCustomer> customerMap = customerService.mapByIdList(customerIdList);
Map<String, List<Attachment>> customerAttachmentMap = attachmentService.mapByBizIdList(
Attachment.BizType.GAO_CUSTOMER,
customerIdList);
Map<String, User> userMap = userService.mapByIdList(
reminderList.stream().map(GaoCustomerFollowUpReminder::getResponsibleUserId).distinct().toList());
Map<String, List<Attachment>> 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();
}
}
@@ -69,6 +69,7 @@ public class GaoEventRecordController {
result.setTotal(pageResult.getTotal()); result.setTotal(pageResult.getTotal());
result.setList(pageResult.getList()); result.setList(pageResult.getList());
result.setNumberValueTotal(resolveNumberValueTotal(page)); result.setNumberValueTotal(resolveNumberValueTotal(page));
result.setCustomerCount(service.countCustomerByRange(page));
{ {
List<String> customerIdList = result.getList().stream().map(GaoEventRecord::getCustomerId).toList(); List<String> customerIdList = result.getList().stream().map(GaoEventRecord::getCustomerId).toList();
Map<String, GaoEvent> eventMap = eventService.mapByIdList(result.getList().stream().map(GaoEventRecord::getEventId).toList()); Map<String, GaoEvent> eventMap = eventService.mapByIdList(result.getList().stream().map(GaoEventRecord::getEventId).toList());
@@ -4,7 +4,9 @@ import com.imyeyu.api.modules.common.entity.Attachment;
import com.imyeyu.api.modules.common.service.AttachmentService; import com.imyeyu.api.modules.common.service.AttachmentService;
import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission; import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission;
import com.imyeyu.api.modules.gao.bean.GaoPermissionCode; import com.imyeyu.api.modules.gao.bean.GaoPermissionCode;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
import com.imyeyu.api.modules.gao.entity.GaoFundRecord; import com.imyeyu.api.modules.gao.entity.GaoFundRecord;
import com.imyeyu.api.modules.gao.service.GaoCustomerService;
import com.imyeyu.api.modules.gao.service.GaoFundRecordService; import com.imyeyu.api.modules.gao.service.GaoFundRecordService;
import com.imyeyu.api.modules.gao.service.GaoOrderPaymentService; import com.imyeyu.api.modules.gao.service.GaoOrderPaymentService;
import com.imyeyu.api.modules.gao.service.GaoStoreScopeService; import com.imyeyu.api.modules.gao.service.GaoStoreScopeService;
@@ -39,6 +41,7 @@ import java.util.stream.Stream;
public class GaoFundRecordController { public class GaoFundRecordController {
private final GaoFundRecordService service; private final GaoFundRecordService service;
private final GaoCustomerService customerService;
private final GaoOrderPaymentService orderPaymentService; private final GaoOrderPaymentService orderPaymentService;
private final GaoStoreScopeService storeScopeService; private final GaoStoreScopeService storeScopeService;
private final AttachmentService attachmentService; private final AttachmentService attachmentService;
@@ -124,10 +127,23 @@ public class GaoFundRecordController {
.distinct() .distinct()
.toList(); .toList();
Map<String, User> userMap = userIdList.isEmpty() ? Map.of() : userService.mapByIdList(userIdList); Map<String, User> userMap = userIdList.isEmpty() ? Map.of() : userService.mapByIdList(userIdList);
List<String> customerIdList = recordList.stream()
.map(GaoFundRecord::getCustomerId)
.filter(value -> value != null && !value.isBlank())
.distinct()
.toList();
Map<String, GaoCustomer> customerMap = customerIdList.isEmpty() ? Map.of() : customerService.mapByIdList(customerIdList);
Map<String, List<Attachment>> customerPhotoMap = customerIdList.isEmpty()
? Map.of() : attachmentService.mapByBizIdList(Attachment.BizType.GAO_CUSTOMER, customerIdList, GaoCustomer.AttachType.PHOTO.name());
for (GaoFundRecord record : recordList) { for (GaoFundRecord record : recordList) {
record.setAttachmentList(attachmentMap.getOrDefault(record.getId(), List.of())); record.setAttachmentList(attachmentMap.getOrDefault(record.getId(), List.of()));
record.setOperator(userMap.get(record.getOperatorUserId())); record.setOperator(userMap.get(record.getOperatorUserId()));
record.setVerifier(userMap.get(record.getVerifierUserId())); record.setVerifier(userMap.get(record.getVerifierUserId()));
GaoCustomer customer = customerMap.get(record.getCustomerId());
if (customer != null) {
customer.setAttachmentList(customerPhotoMap.getOrDefault(customer.getId(), List.of()));
}
record.setCustomer(customer);
} }
} }
} }
@@ -2,6 +2,7 @@ package com.imyeyu.api.modules.gao.controller;
import com.imyeyu.api.modules.common.entity.Attachment; import com.imyeyu.api.modules.common.entity.Attachment;
import com.imyeyu.api.modules.common.service.AttachmentService; import com.imyeyu.api.modules.common.service.AttachmentService;
import com.imyeyu.api.bean.PermissionMatchMode;
import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission; import com.imyeyu.api.modules.gao.annotation.RequireGaoPermission;
import com.imyeyu.api.modules.gao.bean.GaoPermissionCode; import com.imyeyu.api.modules.gao.bean.GaoPermissionCode;
import com.imyeyu.api.modules.gao.entity.GaoCustomer; import com.imyeyu.api.modules.gao.entity.GaoCustomer;
@@ -78,6 +79,16 @@ public class GaoOrderFulfillmentController {
return result; return result;
} }
@AOPLog
@RequestRateLimit
@RequireGaoPermission(value = { GaoPermissionCode.ORDER_FULFILLMENT_CREATE, GaoPermissionCode.ORDER_FULFILLMENT_VERIFY }, mode = PermissionMatchMode.ALL)
@PostMapping("/create-and-verify")
public GaoOrderFulfillment createAndVerify(@RequestBody GaoOrderFulfillmentCreateRequest request) {
GaoOrderFulfillment result = service.createAndVerify(request);
fillTransient(List.of(result));
return result;
}
@AOPLog @AOPLog
@RequestRateLimit @RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.ORDER_FULFILLMENT_VERIFY) @RequireGaoPermission(GaoPermissionCode.ORDER_FULFILLMENT_VERIFY)
@@ -101,8 +101,8 @@ public class GaoPackageConfigController {
if (skuIdList.isEmpty()) { if (skuIdList.isEmpty()) {
return; return;
} }
Map<String, GaoProductSku> skuMap = skuIdList.stream() Map<String, GaoProductSku> skuMap = skuService.listByIdList(skuIdList).stream()
.collect(java.util.stream.Collectors.toMap(item -> item, skuService::detail)); .collect(java.util.stream.Collectors.toMap(GaoProductSku::getId, item -> item));
for (GaoPackageConfig config : configList) { for (GaoPackageConfig config : configList) {
for (GaoPackageConfigItem item : config.getItemList()) { for (GaoPackageConfigItem item : config.getItemList()) {
item.setSku(skuMap.get(item.getSkuId())); item.setSku(skuMap.get(item.getSkuId()));
@@ -7,6 +7,7 @@ import com.imyeyu.api.modules.gao.entity.GaoProductStockRecord;
import com.imyeyu.api.modules.gao.service.GaoProductStockService; import com.imyeyu.api.modules.gao.service.GaoProductStockService;
import com.imyeyu.api.modules.gao.service.GaoProductStockRecordService; import com.imyeyu.api.modules.gao.service.GaoProductStockRecordService;
import com.imyeyu.api.modules.gao.service.GaoStoreScopeService; import com.imyeyu.api.modules.gao.service.GaoStoreScopeService;
import com.imyeyu.api.modules.gao.vo.GaoProductStockBatchRequest;
import com.imyeyu.api.modules.gao.vo.GaoProductStockChangeRequest; import com.imyeyu.api.modules.gao.vo.GaoProductStockChangeRequest;
import com.imyeyu.api.modules.gao.vo.GaoProductStockManualRevokeRequest; import com.imyeyu.api.modules.gao.vo.GaoProductStockManualRevokeRequest;
import com.imyeyu.api.modules.gao.vo.GaoProductStockPage; import com.imyeyu.api.modules.gao.vo.GaoProductStockPage;
@@ -23,6 +24,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/// GAO 产品库存接口 /// GAO 产品库存接口
/// ///
/// @author Codex /// @author Codex
@@ -64,6 +67,16 @@ public class GaoProductStockController {
return stockService.detail(storeScopeService.resolveStoreId(storeId), skuId); return stockService.detail(storeScopeService.resolveStoreId(storeId), skuId);
} }
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.PRODUCT_STOCK_READ)
@PostMapping("/batch-detail")
public List<GaoProductStock> batchDetail(@RequestBody GaoProductStockBatchRequest request) {
TimiException.required(request, "not found stock batch request");
request.setStoreId(storeScopeService.resolveStoreId(request.getStoreId()));
return stockService.batchDetail(request.getStoreId(), request.getSkuIdList());
}
@AOPLog @AOPLog
@RequestRateLimit @RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.PRODUCT_STOCK_CREATE) @RequireGaoPermission(GaoPermissionCode.PRODUCT_STOCK_CREATE)
@@ -9,6 +9,8 @@ import com.imyeyu.api.modules.gao.service.GaoOrderPaymentService;
import com.imyeyu.api.modules.gao.service.GaoSalesOrderService; import com.imyeyu.api.modules.gao.service.GaoSalesOrderService;
import com.imyeyu.api.modules.gao.service.GaoStoreScopeService; import com.imyeyu.api.modules.gao.service.GaoStoreScopeService;
import com.imyeyu.api.modules.gao.vo.GaoDirectProductOrderCreateRequest; import com.imyeyu.api.modules.gao.vo.GaoDirectProductOrderCreateRequest;
import com.imyeyu.api.modules.gao.vo.GaoCustomerProductPage;
import com.imyeyu.api.modules.gao.vo.GaoCustomerProductCustomerView;
import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentActionRequest; import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentActionRequest;
import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentNoRefundRequest; import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentNoRefundRequest;
import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentReceiveRequest; import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentReceiveRequest;
@@ -16,6 +18,7 @@ import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentRefundRequest;
import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentRevokeRequest; import com.imyeyu.api.modules.gao.vo.GaoOrderPaymentRevokeRequest;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderCancelRequest; import com.imyeyu.api.modules.gao.vo.GaoSalesOrderCancelRequest;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderPage; import com.imyeyu.api.modules.gao.vo.GaoSalesOrderPage;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderReceivableStatView;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderUpdateRequest; import com.imyeyu.api.modules.gao.vo.GaoSalesOrderUpdateRequest;
import com.imyeyu.api.modules.user.service.UserService; import com.imyeyu.api.modules.user.service.UserService;
import com.imyeyu.java.TimiJava; import com.imyeyu.java.TimiJava;
@@ -54,6 +57,26 @@ public class GaoSalesOrderController {
return service.pageByQuery(page); return service.pageByQuery(page);
} }
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.SALES_ORDER_READ)
@PostMapping("/summary")
public GaoSalesOrderReceivableStatView summary(@RequestBody GaoSalesOrderPage page) {
TimiException.required(page, "not found sales order summary page");
page.setStoreId(storeScopeService.resolveStoreId(page.getStoreId()));
return service.summary(page);
}
@AOPLog
@RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.SALES_ORDER_READ)
@PostMapping("/customer-product/list")
public PageResult<GaoCustomerProductCustomerView> customerProductList(@RequestBody GaoCustomerProductPage page) {
TimiException.required(page, "not found customer product page");
page.setStoreId(storeScopeService.resolveStoreId(page.getStoreId()));
return service.pageCustomerProduct(page);
}
@AOPLog @AOPLog
@RequestRateLimit @RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.SALES_ORDER_READ) @RequireGaoPermission(GaoPermissionCode.SALES_ORDER_READ)
@@ -102,6 +125,14 @@ public class GaoSalesOrderController {
return paymentService.receive(request); return paymentService.receive(request);
} }
@AOPLog
@RequestRateLimit
@RequireGaoPermission(value = { GaoPermissionCode.FUND_RECEIVE_CREATE, GaoPermissionCode.FUND_RECEIVE_VERIFY }, mode = PermissionMatchMode.ALL)
@PostMapping("/payment/receive-and-verify")
public GaoFundRecord receiveAndVerify(@RequestBody GaoOrderPaymentReceiveRequest request) {
return paymentService.receiveAndVerify(request);
}
@AOPLog @AOPLog
@RequestRateLimit @RequestRateLimit
@RequireGaoPermission(GaoPermissionCode.FUND_RECEIVE_VERIFY) @RequireGaoPermission(GaoPermissionCode.FUND_RECEIVE_VERIFY)
@@ -102,9 +102,16 @@ public class GaoUserController {
List<String> userIdList = result.getList().stream().map(GaoUser::getUserId).toList(); List<String> userIdList = result.getList().stream().map(GaoUser::getUserId).toList();
Map<String, User> userMap = userService.mapByIdList(userIdList); Map<String, User> userMap = userService.mapByIdList(userIdList);
Map<String, List<Role>> roleMap = userRoleService.mapRoleListByUserIdList(userIdList); Map<String, List<Role>> roleMap = userRoleService.mapRoleListByUserIdList(userIdList);
Map<String, List<Attachment>> avatarMap = userIdList.isEmpty()
? Map.of()
: attachmentService.mapByBizIdList(Attachment.BizType.USER, userIdList, User.AttachType.AVATAR.name());
for (GaoUser gaoUser : result.getList()) { for (GaoUser gaoUser : result.getList()) {
gaoUser.setUser(userMap.get(gaoUser.getUserId())); User user = userMap.get(gaoUser.getUserId());
gaoUser.getUser().setRoleEntityList(roleMap.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; return result;
@@ -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<NotifyChannel> notifyChannelList;
/// 客户
@Transient
private GaoCustomer customer;
/// 负责人用户
@Transient
private User responsibleUser;
/// 附件列表
@Transient
private List<Attachment> attachmentList;
/// 根据持久化通知开关生成通知渠道列表
public void resolveNotifyChannelList() {
List<NotifyChannel> channelList = new ArrayList<>();
if (Boolean.TRUE.equals(internalNotifyEnabled)) {
channelList.add(NotifyChannel.INTERNAL);
}
if (Boolean.TRUE.equals(smsNotifyEnabled)) {
channelList.add(NotifyChannel.SMS);
}
notifyChannelList = channelList;
}
}
@@ -120,6 +120,10 @@ public class GaoFundRecord extends UUIDEntity {
@Transient @Transient
private User verifier; private User verifier;
/// 所属客户
@Transient
private GaoCustomer customer;
/// 附件 /// 附件
@Transient @Transient
private List<Attachment> attachmentList; private List<Attachment> attachmentList;
@@ -168,6 +168,10 @@ public class GaoProductStockRecord implements IDEntity<String>, Creatable {
@Transient @Transient
private User operator; private User operator;
/// 所属客户
@Transient
private GaoCustomer customer;
/// 附件 /// 附件
@Transient @Transient
private List<Attachment> attachmentList; private List<Attachment> attachmentList;
@@ -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<GaoCustomerFollowUpReminder, String> {
/// 统计跟进提醒数量
///
/// @param page 查询条件
/// @return 数量
long countByQuery(@Param("page") GaoCustomerFollowUpReminderPage page);
/// 分页查询跟进提醒
///
/// @param page 查询条件
/// @return 提醒列表
List<GaoCustomerFollowUpReminder> selectByQuery(@Param("page") GaoCustomerFollowUpReminderPage page);
/// 查询待创建短信批次的提醒
///
/// @param beginAt 当天起点
/// @param endAt 次日当天起点
/// @return 待处理提醒
List<GaoCustomerFollowUpReminder> 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<String> idList,
@Param("smsNotifyDetailId") String smsNotifyDetailId);
}
@@ -87,6 +87,12 @@ public interface GaoEventRecordMapper extends BaseMapper<GaoEventRecord, String>
/// @return 登记事件记录列表 /// @return 登记事件记录列表
List<GaoEventRecord> selectByRange(GaoEventRecordPage page); List<GaoEventRecord> selectByRange(GaoEventRecordPage page);
/// 按登记记录列表条件统计去重客户数量
///
/// @param page 登记记录范围查询请求
/// @return 去重后的客户数量
long countCustomerByRange(GaoEventRecordPage page);
/// 按登记记录列表条件汇总数值 /// 按登记记录列表条件汇总数值
/// ///
/// @param page 登记记录范围查询请求 /// @param page 登记记录范围查询请求
@@ -5,6 +5,7 @@ import com.imyeyu.api.modules.gao.vo.GaoOrderFulfillmentPage;
import com.imyeyu.spring.mapper.BaseMapper; import com.imyeyu.spring.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import java.util.Collection;
import java.util.List; import java.util.List;
/// GAO 统一履约 Mapper /// GAO 统一履约 Mapper
@@ -19,6 +20,9 @@ public interface GaoOrderFulfillmentMapper extends BaseMapper<GaoOrderFulfillmen
/// 按 ID 查询履约单 /// 按 ID 查询履约单
GaoOrderFulfillment selectById(@Param("id") String id); GaoOrderFulfillment selectById(@Param("id") String id);
/// 按 ID 批量查询履约单
List<GaoOrderFulfillment> selectByIdList(@Param("idList") Collection<String> idList);
/// 锁定履约单 /// 锁定履约单
GaoOrderFulfillment selectForUpdate(@Param("id") String id); GaoOrderFulfillment selectForUpdate(@Param("id") String id);
@@ -32,6 +32,9 @@ public interface GaoProductStockMapper extends BaseMapper<GaoProductStock, Strin
/// 按门店和产品查询库存余额 /// 按门店和产品查询库存余额
GaoProductStock selectByStoreIdAndSkuId(@Param("storeId") String storeId, @Param("skuId") String skuId); GaoProductStock selectByStoreIdAndSkuId(@Param("storeId") String storeId, @Param("skuId") String skuId);
/// 按门店和 SKU 批量查询库存余额
List<GaoProductStock> selectByStoreIdAndSkuIdList(@Param("storeId") String storeId, @Param("skuIdList") List<String> skuIdList);
/// 条件更新库存余额 /// 条件更新库存余额
int updateQuantity(@Param("id") String id, @Param("onHandDelta") java.math.BigDecimal onHandDelta, @Param("reservedDelta") java.math.BigDecimal reservedDelta, @Param("updatedBy") String updatedBy); int updateQuantity(@Param("id") String id, @Param("onHandDelta") java.math.BigDecimal onHandDelta, @Param("reservedDelta") java.math.BigDecimal reservedDelta, @Param("updatedBy") String updatedBy);
@@ -1,7 +1,11 @@
package com.imyeyu.api.modules.gao.mapper; package com.imyeyu.api.modules.gao.mapper;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrder; import com.imyeyu.api.modules.gao.entity.GaoSalesOrder;
import com.imyeyu.api.modules.gao.vo.GaoCustomerProductPage;
import com.imyeyu.api.modules.gao.vo.GaoCustomerProductCustomerRow;
import com.imyeyu.api.modules.gao.vo.GaoCustomerProductView;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderPage; import com.imyeyu.api.modules.gao.vo.GaoSalesOrderPage;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderReceivableStatView;
import com.imyeyu.spring.mapper.BaseMapper; import com.imyeyu.spring.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
@@ -29,6 +33,18 @@ public interface GaoSalesOrderMapper extends BaseMapper<GaoSalesOrder, String> {
/// 按条件查询订单 /// 按条件查询订单
List<GaoSalesOrder> selectByQuery(GaoSalesOrderPage page); List<GaoSalesOrder> selectByQuery(GaoSalesOrderPage page);
/// 按条件汇总订单金额
GaoSalesOrderReceivableStatView selectSummaryByQuery(GaoSalesOrderPage page);
/// 按条件统计客户产品客户
long countCustomerProductCustomerByQuery(GaoCustomerProductPage page);
/// 按条件查询客户产品客户
List<GaoCustomerProductCustomerRow> selectCustomerProductCustomerByQuery(GaoCustomerProductPage page);
/// 按条件查询客户产品明细
List<GaoCustomerProductView> selectCustomerProductByQuery(GaoCustomerProductPage page);
/// 更新订单付款汇总 /// 更新订单付款汇总
int updatePayment(@Param("id") String id, @Param("pendingPaidAmount") Long pendingPaidAmount, @Param("paidAmount") Long paidAmount, int updatePayment(@Param("id") String id, @Param("pendingPaidAmount") Long pendingPaidAmount, @Param("paidAmount") Long paidAmount,
@Param("paidAt") Long paidAt, @Param("refundedAmount") Long refundedAmount, @Param("paymentStatus") GaoSalesOrder.PaymentStatus paymentStatus, @Param("paidAt") Long paidAt, @Param("refundedAmount") Long refundedAmount, @Param("paymentStatus") GaoSalesOrder.PaymentStatus paymentStatus,
@@ -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<GaoCustomerFollowUpReminder, String> {
/// 分页查询客户跟进提醒
///
/// @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();
}
@@ -117,6 +117,12 @@ public interface GaoEventRecordService extends BaseService<GaoEventRecord, Strin
/// @return 登记事件记录列表 /// @return 登记事件记录列表
PageResult<GaoEventRecord> pageByRange(GaoEventRecordPage req); PageResult<GaoEventRecord> pageByRange(GaoEventRecordPage req);
/// 按登记记录列表条件统计去重客户数量
///
/// @param req 登记记录范围查询请求
/// @return 去重后的客户数量
long countCustomerByRange(GaoEventRecordPage req);
/// 按时间范围统计门店登记数 /// 按时间范围统计门店登记数
/// ///
/// @param req 门店报表查询请求 /// @param req 门店报表查询请求
@@ -32,6 +32,9 @@ public interface GaoOrderFulfillmentService extends BaseService<GaoOrderFulfillm
/// 创建履约单 /// 创建履约单
GaoOrderFulfillment create(GaoOrderFulfillmentCreateRequest request); GaoOrderFulfillment create(GaoOrderFulfillmentCreateRequest request);
/// 创建并直接核销履约单
GaoOrderFulfillment createAndVerify(GaoOrderFulfillmentCreateRequest request);
/// 为库存不足履约单准备库存 /// 为库存不足履约单准备库存
GaoOrderFulfillment prepareStock(GaoOrderFulfillmentPrepareStockRequest request); GaoOrderFulfillment prepareStock(GaoOrderFulfillmentPrepareStockRequest request);
@@ -26,6 +26,9 @@ public interface GaoProductStockService {
/// 查询库存余额详情 /// 查询库存余额详情
GaoProductStock detail(String storeId, String skuId); GaoProductStock detail(String storeId, String skuId);
/// 批量查询指定 SKU 库存余额
List<GaoProductStock> batchDetail(String storeId, List<String> skuIdList);
/// 期初入库 /// 期初入库
GaoProductStock initialIn(GaoProductStockChangeRequest request); GaoProductStock initialIn(GaoProductStockChangeRequest request);
@@ -3,9 +3,12 @@ package com.imyeyu.api.modules.gao.service;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrder; import com.imyeyu.api.modules.gao.entity.GaoSalesOrder;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrderItem; import com.imyeyu.api.modules.gao.entity.GaoSalesOrderItem;
import com.imyeyu.api.modules.gao.vo.GaoDirectProductOrderCreateRequest; import com.imyeyu.api.modules.gao.vo.GaoDirectProductOrderCreateRequest;
import com.imyeyu.api.modules.gao.vo.GaoCustomerProductPage;
import com.imyeyu.api.modules.gao.vo.GaoCustomerProductCustomerView;
import com.imyeyu.api.modules.gao.vo.GaoPackageOrderCreateData; import com.imyeyu.api.modules.gao.vo.GaoPackageOrderCreateData;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderCancelRequest; import com.imyeyu.api.modules.gao.vo.GaoSalesOrderCancelRequest;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderPage; import com.imyeyu.api.modules.gao.vo.GaoSalesOrderPage;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderReceivableStatView;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderUpdateRequest; import com.imyeyu.api.modules.gao.vo.GaoSalesOrderUpdateRequest;
import com.imyeyu.spring.bean.PageResult; import com.imyeyu.spring.bean.PageResult;
import com.imyeyu.spring.service.BaseService; import com.imyeyu.spring.service.BaseService;
@@ -24,6 +27,12 @@ public interface GaoSalesOrderService extends BaseService<GaoSalesOrder, String>
/// 按条件分页查询订单 /// 按条件分页查询订单
PageResult<GaoSalesOrder> pageByQuery(GaoSalesOrderPage page); PageResult<GaoSalesOrder> pageByQuery(GaoSalesOrderPage page);
/// 按条件汇总订单金额
GaoSalesOrderReceivableStatView summary(GaoSalesOrderPage page);
/// 按条件分页查询客户产品
PageResult<GaoCustomerProductCustomerView> pageCustomerProduct(GaoCustomerProductPage page);
/// 查询订单详情 /// 查询订单详情
GaoSalesOrder detail(String id); GaoSalesOrder detail(String id);
@@ -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<GaoCustomerFollowUpReminder, String> 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<GaoCustomerFollowUpReminder, String> 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<GaoCustomerFollowUpReminder> reminderList = mapper.selectPendingSmsByRange(beginAt, endAt);
if (reminderList.isEmpty()) {
return;
}
Map<String, User> userMap = userService.mapByIdList(reminderList.stream()
.map(GaoCustomerFollowUpReminder::getResponsibleUserId).distinct().toList());
Map<String, GaoCustomer> customerMap = customerService.mapByIdList(reminderList.stream()
.map(GaoCustomerFollowUpReminder::getCustomerId).distinct().toList());
Map<SmsBatchGroup, List<GaoCustomerFollowUpReminder>> 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<SmsBatchGroup, List<GaoCustomerFollowUpReminder>> 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<GaoCustomerFollowUpReminder.NotifyChannel> 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<GaoCustomerFollowUpReminder> reminderList,
Map<String, GaoCustomer> 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<String> 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<GaoCustomerFollowUpReminder> reminderList,
Map<String, GaoCustomer> 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<GaoCustomerFollowUpReminder> reminderList,
Map<String, GaoCustomer> customerMap) {
Map<String, String> 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<String> 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) {
}
}
@@ -159,6 +159,17 @@ public class GaoCustomerServiceImplement extends AbstractEntityService<GaoCustom
logger.setContent(jackson.writeValueAsString(customer)); logger.setContent(jackson.writeValueAsString(customer));
GaoCustomer dbCustomer = get(customer.getId()); GaoCustomer dbCustomer = get(customer.getId());
TimiException.requiredTrue(dbCustomer.getStoreId().equals(customer.getStoreId()), "customer.storeId invalid"); TimiException.requiredTrue(dbCustomer.getStoreId().equals(customer.getStoreId()), "customer.storeId invalid");
String oldIntroducerCustomerId = dbCustomer.getIntroducerCustomerId();
String requestIntroducerCustomerId = customer.getIntroducerCustomerId();
String introducerCustomerId = requestIntroducerCustomerId == null
? oldIntroducerCustomerId
: TimiJava.defaultIfEmpty(requestIntroducerCustomerId, null);
if (TimiJava.isNotEmpty(introducerCustomerId)) {
TimiException.requiredTrue(!introducerCustomerId.equals(customer.getId()), "customer.introducerCustomerId invalid");
GaoCustomer introducerCustomer = get(introducerCustomerId);
TimiException.required(introducerCustomer, "not found introducer customer");
TimiException.requiredTrue(customer.getStoreId().equals(introducerCustomer.getStoreId()), "介绍人和客户不属于同一门店");
}
String code = TimiJava.defaultIfEmpty(customer.getCode(), null); String code = TimiJava.defaultIfEmpty(customer.getCode(), null);
{ {
if (!Objects.equals(dbCustomer.getCode(), code) && TimiJava.isNotEmpty(code)) { if (!Objects.equals(dbCustomer.getCode(), code) && TimiJava.isNotEmpty(code)) {
@@ -184,7 +195,12 @@ public class GaoCustomerServiceImplement extends AbstractEntityService<GaoCustom
dbCustomer.setRemark(customer.getRemark()); dbCustomer.setRemark(customer.getRemark());
dbCustomer.setDisease(customer.getDisease()); dbCustomer.setDisease(customer.getDisease());
dbCustomer.setConditioningPart(customer.getConditioningPart()); dbCustomer.setConditioningPart(customer.getConditioningPart());
dbCustomer.setIntroducerCustomerId(introducerCustomerId);
mapper.update(dbCustomer); mapper.update(dbCustomer);
if (!Objects.equals(oldIntroducerCustomerId, introducerCustomerId)) {
changeInvitedCount(oldIntroducerCustomerId, -1);
changeInvitedCount(introducerCustomerId, 1);
}
// 标签 // 标签
if (customer.getTagApply() != null) { if (customer.getTagApply() != null) {
@@ -467,6 +467,12 @@ public class GaoEventRecordServiceImplement extends AbstractEntityService<GaoEve
return result; return result;
} }
@Override
public long countCustomerByRange(GaoEventRecordPage page) {
TimiException.required(page.getStoreId(), "not found page.storeId");
return mapper.countCustomerByRange(page);
}
@Override @Override
public List<GaoEventRecordDailyStatView> stateDailyByRange(GaoStoreChartReq req) { public List<GaoEventRecordDailyStatView> stateDailyByRange(GaoStoreChartReq req) {
return mapper.stateDailyByRange(req); return mapper.stateDailyByRange(req);
@@ -153,6 +153,19 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
return createInternal(request); return createInternal(request);
} }
@OperationAudit(AuditOperation.GAO_ORDER_FULFILLMENT_CREATE)
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GaoOrderFulfillment createAndVerify(GaoOrderFulfillmentCreateRequest request) {
GaoOrderFulfillment fulfillment = createInternal(request);
if (fulfillment.getStatus() == GaoOrderFulfillment.Status.VERIFIED) {
return fulfillment;
}
TimiException.requiredTrue(fulfillment.getStatus() == GaoOrderFulfillment.Status.PENDING,
"库存不足,不能直接核销履约");
return verifyInternal(actionRequest(fulfillment.getId()));
}
private GaoOrderFulfillment createInternal(GaoOrderFulfillmentCreateRequest request) { private GaoOrderFulfillment createInternal(GaoOrderFulfillmentCreateRequest request) {
TimiException.required(request, "not found fulfillment request"); TimiException.required(request, "not found fulfillment request");
String requestNo = requiredText(request.getRequestNo(), "not found requestNo"); String requestNo = requiredText(request.getRequestNo(), "not found requestNo");
@@ -588,6 +601,12 @@ public class GaoOrderFulfillmentServiceImplement extends AbstractEntityService<G
} }
} }
private GaoOrderFulfillmentActionRequest actionRequest(String fulfillmentId) {
GaoOrderFulfillmentActionRequest request = new GaoOrderFulfillmentActionRequest();
request.setFulfillmentId(fulfillmentId);
return request;
}
private GaoOrderFulfillmentItem copyFulfillmentItem(GaoOrderFulfillmentItem source) { private GaoOrderFulfillmentItem copyFulfillmentItem(GaoOrderFulfillmentItem source) {
GaoOrderFulfillmentItem result = new GaoOrderFulfillmentItem(); GaoOrderFulfillmentItem result = new GaoOrderFulfillmentItem();
result.setSkuId(source.getSkuId()); result.setSkuId(source.getSkuId());
@@ -8,7 +8,9 @@ import com.imyeyu.api.modules.gao.entity.GaoProductCategory;
import com.imyeyu.api.modules.gao.entity.GaoProductSpec; import com.imyeyu.api.modules.gao.entity.GaoProductSpec;
import com.imyeyu.api.modules.gao.entity.GaoProductSpecValue; import com.imyeyu.api.modules.gao.entity.GaoProductSpecValue;
import com.imyeyu.api.modules.gao.entity.GaoProductSku; import com.imyeyu.api.modules.gao.entity.GaoProductSku;
import com.imyeyu.api.modules.gao.entity.GaoProductStock;
import com.imyeyu.api.modules.gao.mapper.GaoProductMapper; import com.imyeyu.api.modules.gao.mapper.GaoProductMapper;
import com.imyeyu.api.modules.gao.mapper.GaoProductStockMapper;
import com.imyeyu.api.modules.gao.service.GaoProductCategoryService; import com.imyeyu.api.modules.gao.service.GaoProductCategoryService;
import com.imyeyu.api.modules.gao.service.GaoProductService; import com.imyeyu.api.modules.gao.service.GaoProductService;
import com.imyeyu.api.modules.gao.service.GaoProductSkuService; import com.imyeyu.api.modules.gao.service.GaoProductSkuService;
@@ -48,6 +50,7 @@ import java.util.stream.Collectors;
public class GaoProductServiceImplement extends AbstractEntityService<GaoProduct, String> implements GaoProductService { public class GaoProductServiceImplement extends AbstractEntityService<GaoProduct, String> implements GaoProductService {
private final GaoProductMapper mapper; private final GaoProductMapper mapper;
private final GaoProductStockMapper stockMapper;
private final AttachmentService attachmentService; private final AttachmentService attachmentService;
private final ObjectProvider<GaoProductCategoryService> categoryServiceProvider; private final ObjectProvider<GaoProductCategoryService> categoryServiceProvider;
private final ObjectProvider<GaoProductSkuService> skuServiceProvider; private final ObjectProvider<GaoProductSkuService> skuServiceProvider;
@@ -120,6 +123,9 @@ public class GaoProductServiceImplement extends AbstractEntityService<GaoProduct
TimiException.required(request.getId(), "not found product.id"); TimiException.required(request.getId(), "not found product.id");
GaoProduct product = get(request.getId()); GaoProduct product = get(request.getId());
TimiException.required(product, "not found product"); TimiException.required(product, "not found product");
if (request.getProductType() == GaoProduct.ProductType.SERVICE && product.getProductType() != GaoProduct.ProductType.SERVICE) {
verifyNoReservedStock(product);
}
product.setCategoryId(request.getCategoryId()); product.setCategoryId(request.getCategoryId());
product.setCode(TimiJava.defaultIfNull(request.getCode(), product.getCode())); product.setCode(TimiJava.defaultIfNull(request.getCode(), product.getCode()));
product.setName(TimiJava.defaultIfNull(request.getName(), product.getName())); product.setName(TimiJava.defaultIfNull(request.getName(), product.getName()));
@@ -139,6 +145,19 @@ public class GaoProductServiceImplement extends AbstractEntityService<GaoProduct
} }
} }
private void verifyNoReservedStock(GaoProduct product) {
List<String> skuIdList = skuServiceProvider.getObject().listByProductId(product.getId()).stream()
.map(GaoProductSku::getId)
.filter(id -> id != null && !id.isBlank())
.toList();
if (skuIdList.isEmpty()) {
return;
}
boolean hasReservedStock = stockMapper.selectByStoreIdAndSkuIdList(product.getStoreId(), skuIdList).stream()
.anyMatch(stock -> stock.getReservedQuantity() != null && 0 < stock.getReservedQuantity().signum());
TimiException.requiredTrue(!hasReservedStock, "当前产品存在预占库存,不能切换为服务产品,请先完成或取消相关订单");
}
@OperationAudit(AuditOperation.GAO_PRODUCT_DISABLE) @OperationAudit(AuditOperation.GAO_PRODUCT_DISABLE)
@Transactional(TimiServerDBConfig.ROLLBACKER) @Transactional(TimiServerDBConfig.ROLLBACKER)
@Override @Override
@@ -2,12 +2,17 @@ package com.imyeyu.api.modules.gao.service.implement;
import com.imyeyu.api.modules.common.entity.Attachment; import com.imyeyu.api.modules.common.entity.Attachment;
import com.imyeyu.api.modules.common.service.AttachmentService; import com.imyeyu.api.modules.common.service.AttachmentService;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
import com.imyeyu.api.modules.gao.entity.GaoOrderFulfillment;
import com.imyeyu.api.modules.gao.entity.GaoProductStockRecord; import com.imyeyu.api.modules.gao.entity.GaoProductStockRecord;
import com.imyeyu.api.modules.gao.mapper.GaoOrderFulfillmentMapper;
import com.imyeyu.api.modules.gao.mapper.GaoProductStockRecordMapper; import com.imyeyu.api.modules.gao.mapper.GaoProductStockRecordMapper;
import com.imyeyu.api.modules.gao.service.GaoCustomerService;
import com.imyeyu.api.modules.gao.service.GaoProductStockRecordService; import com.imyeyu.api.modules.gao.service.GaoProductStockRecordService;
import com.imyeyu.api.modules.gao.vo.GaoProductStockRecordPage; import com.imyeyu.api.modules.gao.vo.GaoProductStockRecordPage;
import com.imyeyu.api.modules.user.entity.User; import com.imyeyu.api.modules.user.entity.User;
import com.imyeyu.api.modules.user.service.UserService; 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.TimiCode;
import com.imyeyu.java.bean.timi.TimiException; import com.imyeyu.java.bean.timi.TimiException;
import com.imyeyu.spring.bean.PageResult; import com.imyeyu.spring.bean.PageResult;
@@ -18,6 +23,7 @@ import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
/// GAO 库存流水服务实现 /// GAO 库存流水服务实现
/// ///
@@ -28,7 +34,9 @@ import java.util.Map;
public class GaoProductStockRecordServiceImplement extends AbstractEntityService<GaoProductStockRecord, String> implements GaoProductStockRecordService { public class GaoProductStockRecordServiceImplement extends AbstractEntityService<GaoProductStockRecord, String> implements GaoProductStockRecordService {
private final GaoProductStockRecordMapper mapper; private final GaoProductStockRecordMapper mapper;
private final GaoOrderFulfillmentMapper orderFulfillmentMapper;
private final AttachmentService attachmentService; private final AttachmentService attachmentService;
private final GaoCustomerService customerService;
private final UserService userService; private final UserService userService;
@Override @Override
@@ -133,10 +141,50 @@ public class GaoProductStockRecordServiceImplement extends AbstractEntityService
List<String> userIdList = recordList.stream().map(GaoProductStockRecord::getOperatorUserId) List<String> userIdList = recordList.stream().map(GaoProductStockRecord::getOperatorUserId)
.filter(value -> value != null && !value.isBlank()).distinct().toList(); .filter(value -> value != null && !value.isBlank()).distinct().toList();
Map<String, User> userMap = userIdList.isEmpty() ? Map.of() : userService.mapByIdList(userIdList); Map<String, User> userMap = userIdList.isEmpty() ? Map.of() : userService.mapByIdList(userIdList);
Map<String, List<Attachment>> userAvatarMap = userIdList.isEmpty()
? Map.of() : attachmentService.mapByBizIdList(Attachment.BizType.USER, userIdList, User.AttachType.AVATAR.name());
List<String> fulfillmentIdList = recordList.stream()
.filter(record -> record.getBizType() == GaoProductStockRecord.BizType.ORDER_FULFILLMENT)
.map(GaoProductStockRecord::getBizId)
.filter(value -> value != null && !value.isBlank())
.distinct()
.toList();
Map<String, GaoOrderFulfillment> fulfillmentMap = fulfillmentIdList.isEmpty()
? Map.of() : orderFulfillmentMapper.selectByIdList(fulfillmentIdList).stream()
.collect(Collectors.toMap(GaoOrderFulfillment::getId, item -> item));
List<String> customerIdList = recordList.stream()
.map(record -> getCustomerId(record, fulfillmentMap))
.filter(value -> value != null && !value.isBlank())
.distinct()
.toList();
Map<String, GaoCustomer> customerMap = customerIdList.isEmpty() ? Map.of() : customerService.mapByIdList(customerIdList);
Map<String, List<Attachment>> customerPhotoMap = customerIdList.isEmpty()
? Map.of() : attachmentService.mapByBizIdList(Attachment.BizType.GAO_CUSTOMER, customerIdList, GaoCustomer.AttachType.PHOTO.name());
for (GaoProductStockRecord record : recordList) { for (GaoProductStockRecord record : recordList) {
record.setAttachmentList(attachmentMap.getOrDefault(record.getId(), List.of())); record.setAttachmentList(attachmentMap.getOrDefault(record.getId(), List.of()));
record.setOperator(userMap.get(record.getOperatorUserId())); User operator = userMap.get(record.getOperatorUserId());
if (operator != null) {
operator.setAttachmentList(userAvatarMap.getOrDefault(operator.getId(), List.of()));
}
record.setOperator(operator);
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()));
}
record.setCustomer(customer);
} }
} }
private String getCustomerId(GaoProductStockRecord record, Map<String, GaoOrderFulfillment> fulfillmentMap) {
if (record.getBizType() != GaoProductStockRecord.BizType.ORDER_FULFILLMENT) {
return null;
}
GaoOrderFulfillment fulfillment = fulfillmentMap.get(record.getBizId());
return fulfillment == null ? null : fulfillment.getCustomerId();
}
} }
@@ -68,7 +68,8 @@ public class GaoProductStockServiceImplement implements GaoProductStockService {
List<GaoProductStock> list = stockMapper.selectByQuery(page); List<GaoProductStock> list = stockMapper.selectByQuery(page);
Map<String, Attachment> photoMap = attachmentService.mapFirstImageByIdList( Map<String, Attachment> photoMap = attachmentService.mapFirstImageByIdList(
Attachment.BizType.GAO_PRODUCT_SKU, Attachment.BizType.GAO_PRODUCT_SKU,
list.stream().map(GaoProductStock::getSkuId).filter(id -> id != null && !id.isBlank()).toList() list.stream().map(GaoProductStock::getSkuId).filter(id -> id != null && !id.isBlank()).toList(),
"PHOTO"
); );
for (GaoProductStock stock : list) { for (GaoProductStock stock : list) {
fillBalance(stock); fillBalance(stock);
@@ -86,7 +87,17 @@ public class GaoProductStockServiceImplement implements GaoProductStockService {
TimiException.required(page, "not found stock product page"); TimiException.required(page, "not found stock product page");
PageResult<GaoProductStockProductView> result = new PageResult<>(); PageResult<GaoProductStockProductView> result = new PageResult<>();
result.setTotal(stockMapper.countProductByQuery(page)); result.setTotal(stockMapper.countProductByQuery(page));
result.setList(stockMapper.selectProductByQuery(page)); List<GaoProductStockProductView> list = stockMapper.selectProductByQuery(page);
Map<String, Attachment> photoMap = attachmentService.mapFirstImageByIdList(
Attachment.BizType.GAO_PRODUCT,
list.stream().map(GaoProductStockProductView::getProductId).filter(id -> id != null && !id.isBlank()).toList(),
"PHOTO"
);
for (GaoProductStockProductView product : list) {
Attachment photo = photoMap.get(product.getProductId());
product.setAttachmentList(photo == null ? List.of() : List.of(photo));
}
result.setList(list);
return result; return result;
} }
@@ -111,6 +122,27 @@ public class GaoProductStockServiceImplement implements GaoProductStockService {
return stock; return stock;
} }
@Override
public List<GaoProductStock> batchDetail(String storeId, List<String> skuIdList) {
TimiException.required(storeId, "not found storeId");
storeScopeService.checkStoreId(storeId);
if (skuIdList == null || skuIdList.isEmpty()) {
return List.of();
}
List<String> normalizedSkuIdList = skuIdList.stream()
.filter(value -> value != null && !value.isBlank())
.distinct()
.toList();
if (normalizedSkuIdList.isEmpty()) {
return List.of();
}
List<GaoProductStock> result = stockMapper.selectByStoreIdAndSkuIdList(storeId, normalizedSkuIdList);
for (GaoProductStock stock : result) {
fillBalance(stock);
}
return result;
}
@OperationAudit(AuditOperation.GAO_PRODUCT_STOCK_INITIAL_IN) @OperationAudit(AuditOperation.GAO_PRODUCT_STOCK_INITIAL_IN)
@Transactional(TimiServerDBConfig.ROLLBACKER) @Transactional(TimiServerDBConfig.ROLLBACKER)
@Override @Override
@@ -30,10 +30,16 @@ import com.imyeyu.api.modules.gao.service.GaoStoreScopeService;
import com.imyeyu.api.modules.gao.service.GaoStoreService; import com.imyeyu.api.modules.gao.service.GaoStoreService;
import com.imyeyu.api.modules.gao.vo.GaoDirectProductOrderCreateRequest; import com.imyeyu.api.modules.gao.vo.GaoDirectProductOrderCreateRequest;
import com.imyeyu.api.modules.gao.vo.GaoPackageOrderCreateData; import com.imyeyu.api.modules.gao.vo.GaoPackageOrderCreateData;
import com.imyeyu.api.modules.gao.vo.GaoCustomerProductCustomerRow;
import com.imyeyu.api.modules.gao.vo.GaoCustomerProductCustomerView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerProductPage;
import com.imyeyu.api.modules.gao.vo.GaoCustomerProductSummaryView;
import com.imyeyu.api.modules.gao.vo.GaoCustomerProductView;
import com.imyeyu.api.modules.gao.vo.GaoProductSkuUnitSelection; import com.imyeyu.api.modules.gao.vo.GaoProductSkuUnitSelection;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderCancelRequest; import com.imyeyu.api.modules.gao.vo.GaoSalesOrderCancelRequest;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderItemRequest; import com.imyeyu.api.modules.gao.vo.GaoSalesOrderItemRequest;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderPage; import com.imyeyu.api.modules.gao.vo.GaoSalesOrderPage;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderReceivableStatView;
import com.imyeyu.api.modules.gao.vo.GaoSalesOrderUpdateRequest; import com.imyeyu.api.modules.gao.vo.GaoSalesOrderUpdateRequest;
import com.imyeyu.api.modules.system.audit.AuditOperation; import com.imyeyu.api.modules.system.audit.AuditOperation;
import com.imyeyu.api.modules.system.audit.OperationAudit; import com.imyeyu.api.modules.system.audit.OperationAudit;
@@ -59,6 +65,7 @@ import java.util.Collection;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -111,6 +118,80 @@ public class GaoSalesOrderServiceImplement extends AbstractEntityService<GaoSale
return result; return result;
} }
@Override
public GaoSalesOrderReceivableStatView summary(GaoSalesOrderPage page) {
TimiException.required(page, "not found sales order summary page");
return mapper.selectSummaryByQuery(page);
}
@Override
public PageResult<GaoCustomerProductCustomerView> pageCustomerProduct(GaoCustomerProductPage page) {
TimiException.required(page, "not found customer product page");
PageResult<GaoCustomerProductCustomerView> result = new PageResult<>();
result.setTotal(mapper.countCustomerProductCustomerByQuery(page));
List<GaoCustomerProductCustomerRow> customerRows = mapper.selectCustomerProductCustomerByQuery(page);
if (customerRows.isEmpty()) {
result.setList(List.of());
return result;
}
List<String> customerIdList = customerRows.stream().map(GaoCustomerProductCustomerRow::getCustomerId).toList();
page.setCustomerIdList(customerIdList);
List<GaoCustomerProductView> itemList = mapper.selectCustomerProductByQuery(page);
Map<String, GaoCustomerProductCustomerView> customerMap = new LinkedHashMap<>();
Map<String, Map<String, GaoCustomerProductSummaryView>> purchasedProductMap = new HashMap<>();
Map<String, Map<String, GaoCustomerProductSummaryView>> availableProductMap = new HashMap<>();
for (GaoCustomerProductCustomerRow row : customerRows) {
GaoCustomerProductCustomerView customerView = new GaoCustomerProductCustomerView();
GaoCustomer customer = new GaoCustomer();
customer.setId(row.getCustomerId());
customer.setCode(row.getCustomerCode());
customer.setName(row.getCustomerName());
customerView.setCustomer(customer);
customerView.setOrderCount(row.getOrderCount());
customerView.setPurchasedProducts(new ArrayList<>());
customerView.setAvailableProducts(new ArrayList<>());
customerMap.put(row.getCustomerId(), customerView);
}
for (GaoCustomerProductView item : itemList) {
if (!customerMap.containsKey(item.getCustomerId())) {
continue;
}
mergeCustomerProduct(purchasedProductMap, item, item.getBaseQuantity());
if (decimal(item.getAvailableBaseQuantity()).compareTo(ZERO) > 0) {
mergeCustomerProduct(availableProductMap, item, item.getAvailableBaseQuantity());
}
}
for (GaoCustomerProductCustomerRow row : customerRows) {
GaoCustomerProductCustomerView customerView = customerMap.get(row.getCustomerId());
customerView.setPurchasedProducts(new ArrayList<>(purchasedProductMap.getOrDefault(row.getCustomerId(), Map.of()).values()));
customerView.setAvailableProducts(new ArrayList<>(availableProductMap.getOrDefault(row.getCustomerId(), Map.of()).values()));
}
result.setList(new ArrayList<>(customerMap.values()));
return result;
}
private void mergeCustomerProduct(Map<String, Map<String, GaoCustomerProductSummaryView>> productMap,
GaoCustomerProductView item, BigDecimal quantity) {
String customerId = item.getCustomerId();
String productName = item.getProductNameSnapshot() == null ? "未命名产品" : item.getProductNameSnapshot();
String unit = item.getBaseUnitName() == null ? item.getUnitSnapshot() : item.getBaseUnitName();
unit = unit == null ? "基础单位" : unit;
String productKey = (item.getProductIdSnapshot() == null ? productName : item.getProductIdSnapshot()) + "::" + unit;
Map<String, GaoCustomerProductSummaryView> customerProducts = productMap.computeIfAbsent(customerId, key -> new LinkedHashMap<>());
GaoCustomerProductSummaryView product = customerProducts.get(productKey);
if (product == null) {
product = new GaoCustomerProductSummaryView();
product.setProductIdSnapshot(item.getProductIdSnapshot());
product.setProductName(productName);
product.setQuantity(decimal(quantity));
product.setUnit(unit);
customerProducts.put(productKey, product);
return;
}
product.setQuantity(decimal(product.getQuantity()).add(decimal(quantity)));
}
@Override @Override
public GaoSalesOrder detail(String id) { public GaoSalesOrder detail(String id) {
GaoSalesOrder order = get(id); GaoSalesOrder order = get(id);
@@ -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);
}
}
}
@@ -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;
}
@@ -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<GaoCustomerFollowUpReminder> {
}
@@ -0,0 +1,23 @@
package com.imyeyu.api.modules.gao.vo;
import lombok.Data;
/// GAO 客户产品客户分页行
///
/// @author Codex
/// @since 2026-09-18
@Data
public class GaoCustomerProductCustomerRow {
/// 客户 ID
private String customerId;
/// 客户编码
private String customerCode;
/// 客户姓名
private String customerName;
/// 相关订单数
private Long orderCount;
}
@@ -0,0 +1,26 @@
package com.imyeyu.api.modules.gao.vo;
import com.imyeyu.api.modules.gao.entity.GaoCustomer;
import lombok.Data;
import java.util.List;
/// GAO 客户产品客户视图
///
/// @author Codex
/// @since 2026-09-18
@Data
public class GaoCustomerProductCustomerView {
/// 客户
private GaoCustomer customer;
/// 相关订单数
private Long orderCount;
/// 已购买产品
private List<GaoCustomerProductSummaryView> purchasedProducts;
/// 当前可履约产品
private List<GaoCustomerProductSummaryView> availableProducts;
}
@@ -0,0 +1,32 @@
package com.imyeyu.api.modules.gao.vo;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrderItem;
import com.imyeyu.java.bean.BasePage;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/// GAO 客户产品分页请求
///
/// @author Codex
/// @since 2026-09-18
@Data
@EqualsAndHashCode(callSuper = true)
public class GaoCustomerProductPage extends BasePage {
/// 门店 ID
private String storeId;
/// 客户 ID
private String customerId;
/// 客户、订单或产品关键词
private String keyword;
/// 产品来源
private GaoSalesOrderItem.ItemType itemType;
/// 内部批量查询客户 ID
private List<String> customerIdList;
}
@@ -0,0 +1,25 @@
package com.imyeyu.api.modules.gao.vo;
import lombok.Data;
import java.math.BigDecimal;
/// GAO 客户产品汇总视图
///
/// @author Codex
/// @since 2026-09-18
@Data
public class GaoCustomerProductSummaryView {
/// 产品 ID 快照
private String productIdSnapshot;
/// 产品名称
private String productName;
/// 产品数量,使用基础单位
private BigDecimal quantity;
/// 基础单位名称
private String unit;
}
@@ -0,0 +1,98 @@
package com.imyeyu.api.modules.gao.vo;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrder;
import com.imyeyu.api.modules.gao.entity.GaoSalesOrderItem;
import lombok.Data;
import java.math.BigDecimal;
/// GAO 客户产品明细视图
///
/// 每一行对应一个订单明细,数量使用基础单位,便于汇总客户产品。
///
/// @author Codex
/// @since 2026-09-18
@Data
public class GaoCustomerProductView {
/// 订单明细 ID
private String id;
/// 销售订单 ID
private String salesOrderId;
/// 客户 ID
private String customerId;
/// 客户编码
private String customerCode;
/// 客户姓名
private String customerName;
/// 订单号
private String orderNo;
/// 订单类型
private GaoSalesOrder.OrderType orderType;
/// 订单状态
private GaoSalesOrder.Status orderStatus;
/// 下单时间
private Long purchasedAt;
/// 订单项类型
private GaoSalesOrderItem.ItemType itemType;
/// 是否赠送权益
private Boolean gifted;
/// SKU ID
private String skuId;
/// SKU 单位 ID
private String skuUnitId;
/// 产品名称快照
private String productNameSnapshot;
/// 产品 ID 快照
private String productIdSnapshot;
/// SKU 编码快照
private String skuCodeSnapshot;
/// SKU 名称快照
private String skuNameSnapshot;
/// 规格摘要快照
private String specSummarySnapshot;
/// 单位快照
private String unitSnapshot;
/// 当前基础单位名称
private String baseUnitName;
/// 基础单位数量系数快照
private BigDecimal baseQuantityPerUnitSnapshot;
/// 购买数量
private BigDecimal quantity;
/// 购买基础单位数量
private BigDecimal baseQuantity;
/// 待履约基础单位数量
private BigDecimal reservedBaseQuantity;
/// 已履约基础单位数量
private BigDecimal fulfilledBaseQuantity;
/// 剩余权益基础单位数量
private BigDecimal remainingBaseQuantity;
/// 当前可立即履约基础单位数量
private BigDecimal availableBaseQuantity;
}
@@ -19,4 +19,8 @@ public class GaoEventRecordPageResult extends PageResult<GaoEventRecord> {
/// 指定单个数值事件的登记数值合计,未指定单个 NUMBER 事件时为空 /// 指定单个数值事件的登记数值合计,未指定单个 NUMBER 事件时为空
@JsonView(ResponseView.Public.class) @JsonView(ResponseView.Public.class)
private Long numberValueTotal; private Long numberValueTotal;
/// 查询条件下去重后的客户数量
@JsonView(ResponseView.Public.class)
private Long customerCount;
} }
@@ -0,0 +1,19 @@
package com.imyeyu.api.modules.gao.vo;
import lombok.Data;
import java.util.List;
/// GAO 产品库存批量查询请求
///
/// @author Codex
/// @since 2026-09-16
@Data
public class GaoProductStockBatchRequest {
/// 门店 ID
private String storeId;
/// SKU ID 列表
private List<String> skuIdList;
}
@@ -1,9 +1,11 @@
package com.imyeyu.api.modules.gao.vo; package com.imyeyu.api.modules.gao.vo;
import com.imyeyu.api.modules.common.entity.Attachment;
import com.imyeyu.api.modules.gao.entity.GaoProduct; import com.imyeyu.api.modules.gao.entity.GaoProduct;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List;
/// 产品库存第一层汇总视图 /// 产品库存第一层汇总视图
/// ///
@@ -21,6 +23,9 @@ public class GaoProductStockProductView {
/// 产品名称 /// 产品名称
private String productName; private String productName;
/// 产品图片
private List<Attachment> attachmentList;
/// 产品类型 /// 产品类型
private GaoProduct.ProductType productType; private GaoProduct.ProductType productType;
@@ -5,6 +5,8 @@ import com.imyeyu.java.bean.BasePage;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import java.util.List;
/// GAO 销售订单分页请求 /// GAO 销售订单分页请求
/// ///
/// @author Codex /// @author Codex
@@ -19,18 +21,33 @@ public class GaoSalesOrderPage extends BasePage {
/// 客户 ID /// 客户 ID
private String customerId; private String customerId;
/// 套餐配置 ID
private String packageConfigId;
/// 订单类型 /// 订单类型
private GaoSalesOrder.OrderType orderType; private GaoSalesOrder.OrderType orderType;
/// 订单类型列表
private List<GaoSalesOrder.OrderType> orderTypeList;
/// 订单状态 /// 订单状态
private GaoSalesOrder.Status status; private GaoSalesOrder.Status status;
/// 订单状态列表
private List<GaoSalesOrder.Status> statusList;
/// 付款状态 /// 付款状态
private GaoSalesOrder.PaymentStatus paymentStatus; private GaoSalesOrder.PaymentStatus paymentStatus;
/// 付款状态列表
private List<GaoSalesOrder.PaymentStatus> paymentStatusList;
/// 退款状态 /// 退款状态
private GaoSalesOrder.RefundStatus refundStatus; private GaoSalesOrder.RefundStatus refundStatus;
/// 退款状态列表
private List<GaoSalesOrder.RefundStatus> refundStatusList;
/// true 为筛选可收款且未付清订单 /// true 为筛选可收款且未付清订单
private Boolean pendingReceive; private Boolean pendingReceive;
+78 -1
View File
@@ -16,10 +16,18 @@ import java.io.InputStream;
/// @since 2025-10-23 17:05 /// @since 2025-10-23 17:05
public class JavaCV { public class JavaCV {
/// 单次跳转分片上限
///
/// 任何真实文件的当前位置加该值都不会溢出 long
private static final long SKIP_PIECE = 1L << 40;
/// 从视频流顺序解码并提取指定时间附近的一帧 PNG 图片 /// 从视频流顺序解码并提取指定时间附近的一帧 PNG 图片
/// ///
/// 不执行时间定位,避免为了取实时缩略图而把整个视频复制到临时文件 /// 不执行时间定位,避免为了取实时缩略图而把整个视频复制到临时文件
/// ///
/// moov 原子位于文件尾部的视频(非 faststart)仍会触发 FFmpeg 回调 AVSEEK_SIZE 探测文件大小,
/// 依赖底层流的 skip/reset 语义正确,因此必须配合 `SkipSafeInputStream` 规避 GridFS 的溢出缺陷
///
/// @param stream 视频输入流 /// @param stream 视频输入流
/// @param targetSeconds 目标时间,单位为秒 /// @param targetSeconds 目标时间,单位为秒
/// @return PNG 图片字节流 /// @return PNG 图片字节流
@@ -27,7 +35,7 @@ public class JavaCV {
public static ByteArrayOutputStream captureThumbnail(InputStream stream, double targetSeconds) throws Exception { public static ByteArrayOutputStream captureThumbnail(InputStream stream, double targetSeconds) throws Exception {
long targetMicros = toMicros(targetSeconds); long targetMicros = toMicros(targetSeconds);
ByteArrayOutputStream firstFrame = new ByteArrayOutputStream(); ByteArrayOutputStream firstFrame = new ByteArrayOutputStream();
try (FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(stream)) { try (FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(new SkipSafeInputStream(stream))) {
grabber.start(); grabber.start();
try (Java2DFrameConverter converter = new Java2DFrameConverter()) { try (Java2DFrameConverter converter = new Java2DFrameConverter()) {
Frame frame; Frame frame;
@@ -61,4 +69,73 @@ public class JavaCV {
} }
return new ByteArrayOutputStream(); 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();
}
}
} }
@@ -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 客户跟进提醒';
@@ -26,6 +26,17 @@
ON DUPLICATE KEY UPDATE `id` = `id` ON DUPLICATE KEY UPDATE `id` = `id`
</insert> </insert>
<select id="selectByBusinessKey" resultType="com.imyeyu.api.modules.common.entity.Notify">
SELECT *
FROM `notify`
WHERE `biz_type` = #{bizType}
AND `biz_reminded_type` = #{bizRemindedType}
AND `biz_reminded_key` = #{bizRemindedKey}
AND `deleted_at` IS NULL
ORDER BY `created_at` DESC, `id` DESC
LIMIT 1
</select>
<select id="selectManagementPage" resultType="com.imyeyu.api.modules.common.vo.NotifyManagementItem"> <select id="selectManagementPage" resultType="com.imyeyu.api.modules.common.vo.NotifyManagementItem">
SELECT SELECT
n.`id`, n.`id`,
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.imyeyu.api.modules.gao.mapper.GaoCustomerFollowUpReminderMapper">
<sql id="queryWhere">
`r`.`deleted_at` IS NULL
<if test="page.storeId != null and page.storeId != ''">
AND `r`.`store_id` = #{page.storeId}
</if>
<if test="page.customerId != null and page.customerId != ''">
AND `r`.`customer_id` = #{page.customerId}
</if>
<if test="page.responsibleUserId != null and page.responsibleUserId != ''">
AND `r`.`responsible_user_id` = #{page.responsibleUserId}
</if>
<if test="page.status != null">
AND `r`.`status` = #{page.status}
</if>
<if test="page.beginRemindAt != null">
AND `r`.`remind_at` &gt;= #{page.beginRemindAt}
</if>
<if test="page.endRemindAt != null">
AND `r`.`remind_at` &lt;= #{page.endRemindAt}
</if>
</sql>
<select id="countByQuery" resultType="long">
SELECT COUNT(1)
FROM `gao_customer_follow_up_reminder` `r`
WHERE <include refid="queryWhere"/>
</select>
<select id="selectByQuery" resultType="com.imyeyu.api.modules.gao.entity.GaoCustomerFollowUpReminder">
SELECT `r`.*
FROM `gao_customer_follow_up_reminder` `r`
WHERE <include refid="queryWhere"/>
ORDER BY `r`.`remind_at` ASC, `r`.`created_at` ASC, `r`.`id` ASC
LIMIT #{page.offset}, #{page.limit}
</select>
<select id="selectPendingSmsByRange" resultType="com.imyeyu.api.modules.gao.entity.GaoCustomerFollowUpReminder">
SELECT *
FROM `gao_customer_follow_up_reminder`
WHERE `status` = 'PENDING'
AND `deleted_at` IS NULL
AND `sms_notify_enabled` = 1
AND `sms_notify_detail_id` IS NULL
AND `remind_at` &gt;= #{beginAt}
AND `remind_at` &lt; #{endAt}
ORDER BY `store_id`, `responsible_user_id`, `remind_at`, `created_at`, `id`
FOR UPDATE
</select>
<update id="complete">
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>
<update id="bindSmsNotifyDetailId">
UPDATE `gao_customer_follow_up_reminder`
SET `sms_notify_detail_id` = #{smsNotifyDetailId},
`updated_at` = FLOOR(UNIX_TIMESTAMP(NOW(3)) * 1000)
WHERE `id` IN
<foreach collection="idList" item="id" separator="," open="(" close=")">
#{id}
</foreach>
AND `status` = 'PENDING'
AND `deleted_at` IS NULL
AND `sms_notify_enabled` = 1
AND `sms_notify_detail_id` IS NULL
</update>
</mapper>
@@ -167,6 +167,13 @@
ORDER BY r.`registered_at` DESC, r.`created_at` DESC ORDER BY r.`registered_at` DESC, r.`created_at` DESC
LIMIT #{offset}, #{limit} LIMIT #{offset}, #{limit}
</select> </select>
<select id="countCustomerByRange" resultType="long">
SELECT
COUNT(DISTINCT r.`customer_id`)
<include refid="rangeFrom"/>
WHERE
<include refid="rangeWhere"/>
</select>
<select id="sumNumberValueByRange" resultType="java.lang.Long"> <select id="sumNumberValueByRange" resultType="java.lang.Long">
SELECT SELECT
COALESCE(SUM(r.`number_value`), 0) COALESCE(SUM(r.`number_value`), 0)
@@ -10,6 +10,11 @@
SELECT * FROM `gao_order_fulfillment` SELECT * FROM `gao_order_fulfillment`
WHERE `id` = #{id} WHERE `id` = #{id}
</select> </select>
<select id="selectByIdList" resultType="com.imyeyu.api.modules.gao.entity.GaoOrderFulfillment">
SELECT * FROM `gao_order_fulfillment`
WHERE `id` IN
<foreach collection="idList" item="id" open="(" separator="," close=")">#{id}</foreach>
</select>
<select id="selectForUpdate" resultType="com.imyeyu.api.modules.gao.entity.GaoOrderFulfillment"> <select id="selectForUpdate" resultType="com.imyeyu.api.modules.gao.entity.GaoOrderFulfillment">
SELECT * FROM `gao_order_fulfillment` SELECT * FROM `gao_order_fulfillment`
WHERE `id` = #{id} WHERE `id` = #{id}
@@ -10,6 +10,25 @@
SELECT * FROM `gao_product_stock` SELECT * FROM `gao_product_stock`
WHERE `store_id` = #{storeId} AND `sku_id` = #{skuId} WHERE `store_id` = #{storeId} AND `sku_id` = #{skuId}
</select> </select>
<select id="selectByStoreIdAndSkuIdList" resultType="com.imyeyu.api.modules.gao.entity.GaoProductStock">
SELECT s.`id`,
k.`store_id`,
k.`id` AS `sku_id`,
COALESCE(s.`on_hand_quantity`, 0) AS `on_hand_quantity`,
COALESCE(s.`reserved_quantity`, 0) AS `reserved_quantity`,
s.`updated_by`,
s.`created_at`,
s.`updated_at`
FROM `gao_product_sku` k
JOIN `gao_product` p ON p.`id` = k.`product_id`
LEFT JOIN `gao_product_stock` s ON s.`sku_id` = k.`id` AND s.`store_id` = #{storeId}
WHERE k.`store_id` = #{storeId}
AND p.`deleted_at` IS NULL AND k.`deleted_at` IS NULL
AND k.`id` IN
<foreach collection="skuIdList" item="skuId" open="(" separator="," close=")">
#{skuId}
</foreach>
</select>
<select id="countByQuery" resultType="long"> <select id="countByQuery" resultType="long">
SELECT COUNT(1) SELECT COUNT(1)
FROM `gao_product_sku` k FROM `gao_product_sku` k
@@ -23,10 +23,50 @@
o.`deleted_at` IS NULL o.`deleted_at` IS NULL
<if test="storeId != null and storeId != ''">AND o.`store_id` = #{storeId}</if> <if test="storeId != null and storeId != ''">AND o.`store_id` = #{storeId}</if>
<if test="customerId != null and customerId != ''">AND o.`customer_id` = #{customerId}</if> <if test="customerId != null and customerId != ''">AND o.`customer_id` = #{customerId}</if>
<if test="orderType != null">AND o.`order_type` = #{orderType}</if> <if test="packageConfigId != null and packageConfigId != ''">
<if test="status != null">AND o.`status` = #{status}</if> AND EXISTS (
<if test="paymentStatus != null">AND o.`payment_status` = #{paymentStatus}</if> SELECT 1 FROM `gao_customer_package` cp
<if test="refundStatus != null">AND o.`refund_status` = #{refundStatus}</if> WHERE cp.`sales_order_id` = o.`id`
AND cp.`package_config_id` = #{packageConfigId}
AND cp.`deleted_at` IS NULL
)
</if>
<choose>
<when test="orderTypeList != null and 0 &lt; orderTypeList.size()">
AND o.`order_type` IN
<foreach collection="orderTypeList" item="orderTypeItem" open="(" separator="," close=")">
#{orderTypeItem}
</foreach>
</when>
<when test="orderType != null">AND o.`order_type` = #{orderType}</when>
</choose>
<choose>
<when test="statusList != null and 0 &lt; statusList.size()">
AND o.`status` IN
<foreach collection="statusList" item="statusItem" open="(" separator="," close=")">
#{statusItem}
</foreach>
</when>
<when test="status != null">AND o.`status` = #{status}</when>
</choose>
<choose>
<when test="paymentStatusList != null and 0 &lt; paymentStatusList.size()">
AND o.`payment_status` IN
<foreach collection="paymentStatusList" item="paymentStatusItem" open="(" separator="," close=")">
#{paymentStatusItem}
</foreach>
</when>
<when test="paymentStatus != null">AND o.`payment_status` = #{paymentStatus}</when>
</choose>
<choose>
<when test="refundStatusList != null and 0 &lt; refundStatusList.size()">
AND o.`refund_status` IN
<foreach collection="refundStatusList" item="refundStatusItem" open="(" separator="," close=")">
#{refundStatusItem}
</foreach>
</when>
<when test="refundStatus != null">AND o.`refund_status` = #{refundStatus}</when>
</choose>
<if test="pendingReceive != null and pendingReceive">AND o.`status` IN ('ACTIVE', 'FULFILLED') AND o.`payment_status` IN ('UNPAID', 'PARTIALLY_PAID')</if> <if test="pendingReceive != null and pendingReceive">AND o.`status` IN ('ACTIVE', 'FULFILLED') AND o.`payment_status` IN ('UNPAID', 'PARTIALLY_PAID')</if>
<if test="fulfilledButUnpaid != null and fulfilledButUnpaid">AND o.`status` = 'FULFILLED' AND o.`paid_amount` &lt; o.`sale_amount`</if> <if test="fulfilledButUnpaid != null and fulfilledButUnpaid">AND o.`status` = 'FULFILLED' AND o.`paid_amount` &lt; o.`sale_amount`</if>
<if test="keyword != null and keyword.trim() != ''">AND (o.`order_no` LIKE CONCAT('%', #{keyword}, '%') OR c.`name` LIKE CONCAT('%', #{keyword}, '%') OR c.`code` LIKE CONCAT('%', #{keyword}, '%'))</if> <if test="keyword != null and keyword.trim() != ''">AND (o.`order_no` LIKE CONCAT('%', #{keyword}, '%') OR c.`name` LIKE CONCAT('%', #{keyword}, '%') OR c.`code` LIKE CONCAT('%', #{keyword}, '%'))</if>
@@ -41,6 +81,76 @@
ORDER BY o.`purchased_at` DESC, o.`created_at` DESC ORDER BY o.`purchased_at` DESC, o.`created_at` DESC
LIMIT #{offset}, #{limit} LIMIT #{offset}, #{limit}
</select> </select>
<select id="selectSummaryByQuery" resultType="com.imyeyu.api.modules.gao.vo.GaoSalesOrderReceivableStatView">
SELECT
COUNT(1) AS `orderCount`,
COALESCE(SUM(o.`sale_amount`), 0) AS `saleAmount`,
COALESCE(SUM(o.`paid_amount`), 0) AS `paidAmount`,
COALESCE(SUM(o.`pending_paid_amount`), 0) AS `pendingPaidAmount`,
COALESCE(SUM(o.`sale_amount` - o.`paid_amount`), 0) AS `unpaidAmount`,
COALESCE(SUM(o.`refunded_amount`), 0) AS `refundedAmount`,
COALESCE(SUM(o.`paid_amount` - o.`refunded_amount`), 0) AS `netPaidAmount`
FROM `gao_sales_order` o LEFT JOIN `gao_customer` c ON c.`id` = o.`customer_id`
WHERE <include refid="queryWhere"/>
</select>
<sql id="customerProductWhere">
i.`deleted_at` IS NULL
AND o.`deleted_at` IS NULL
AND o.`store_id` = #{storeId}
AND o.`status` &lt;&gt; 'CANCELLED'
<if test="customerId != null and customerId != ''">AND o.`customer_id` = #{customerId}</if>
<if test="customerIdList != null and customerIdList.size() &gt; 0">
AND o.`customer_id` IN
<foreach collection="customerIdList" item="customerIdItem" open="(" separator="," close=")">
#{customerIdItem}
</foreach>
</if>
<if test="itemType != null">AND i.`item_type` = #{itemType}</if>
<if test="keyword != null and keyword.trim() != ''">
AND (
c.`name` LIKE CONCAT('%', #{keyword}, '%')
OR c.`code` LIKE CONCAT('%', #{keyword}, '%')
OR o.`order_no` LIKE CONCAT('%', #{keyword}, '%')
OR i.`product_name_snapshot` LIKE CONCAT('%', #{keyword}, '%')
OR i.`sku_name_snapshot` LIKE CONCAT('%', #{keyword}, '%')
OR i.`sku_code_snapshot` LIKE CONCAT('%', #{keyword}, '%')
OR i.`spec_summary_snapshot` LIKE CONCAT('%', #{keyword}, '%')
)
</if>
</sql>
<select id="countCustomerProductCustomerByQuery" resultType="long">
SELECT COUNT(DISTINCT o.`customer_id`)
FROM `gao_sales_order_item` i
JOIN `gao_sales_order` o ON o.`id` = i.`sales_order_id`
LEFT JOIN `gao_customer` c ON c.`id` = o.`customer_id`
WHERE <include refid="customerProductWhere"/>
</select>
<select id="selectCustomerProductCustomerByQuery" resultType="com.imyeyu.api.modules.gao.vo.GaoCustomerProductCustomerRow">
SELECT o.`customer_id`, c.`code` AS `customer_code`, c.`name` AS `customer_name`, COUNT(DISTINCT o.`id`) AS `order_count`
FROM `gao_sales_order_item` i
JOIN `gao_sales_order` o ON o.`id` = i.`sales_order_id`
LEFT JOIN `gao_customer` c ON c.`id` = o.`customer_id`
WHERE <include refid="customerProductWhere"/>
GROUP BY o.`customer_id`, c.`code`, c.`name`
ORDER BY c.`name` ASC, c.`code` ASC, o.`customer_id` ASC
LIMIT #{offset}, #{limit}
</select>
<select id="selectCustomerProductByQuery" resultType="com.imyeyu.api.modules.gao.vo.GaoCustomerProductView">
SELECT i.`id`, i.`sales_order_id`, o.`customer_id`, c.`code` AS `customer_code`, c.`name` AS `customer_name`,
o.`order_no`, o.`order_type`, o.`status` AS `order_status`, o.`purchased_at`,
i.`item_type`, i.`gifted`, i.`sku_id`, i.`sku_unit_id`, i.`product_name_snapshot`, i.`product_id_snapshot`,
i.`sku_code_snapshot`, i.`sku_name_snapshot`, i.`spec_summary_snapshot`, i.`unit_snapshot`, base_unit.`name` AS `base_unit_name`,
i.`base_quantity_per_unit_snapshot`, i.`quantity`, i.`base_quantity`, i.`reserved_base_quantity`,
i.`fulfilled_base_quantity`,
COALESCE(i.`base_quantity`, 0) - COALESCE(i.`fulfilled_base_quantity`, 0) AS `remaining_base_quantity`,
COALESCE(i.`base_quantity`, 0) - COALESCE(i.`reserved_base_quantity`, 0) - COALESCE(i.`fulfilled_base_quantity`, 0) AS `available_base_quantity`
FROM `gao_sales_order_item` i
JOIN `gao_sales_order` o ON o.`id` = i.`sales_order_id`
LEFT JOIN `gao_customer` c ON c.`id` = o.`customer_id`
LEFT JOIN `gao_product_sku_unit` base_unit ON base_unit.`sku_id` = i.`sku_id` AND base_unit.`is_base` = 1 AND base_unit.`deleted_at` IS NULL
WHERE <include refid="customerProductWhere"/>
ORDER BY o.`purchased_at` DESC, o.`created_at` DESC, i.`sort` ASC, i.`created_at` ASC
</select>
<update id="updatePayment"> <update id="updatePayment">
UPDATE `gao_sales_order` UPDATE `gao_sales_order`
SET `pending_paid_amount` = #{pendingPaidAmount}, `paid_amount` = #{paidAmount}, `paid_at` = #{paidAt}, `refunded_amount` = #{refundedAmount}, SET `pending_paid_amount` = #{pendingPaidAmount}, `paid_amount` = #{paidAmount}, `paid_at` = #{paidAt}, `refunded_amount` = #{refundedAmount},