From f3966a45edf66004f90564a18161778c24c0d046 Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Tue, 21 Jul 2026 15:25:23 +0800 Subject: [PATCH] =?UTF-8?q?mcp=E6=9D=83=E9=99=90=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E3=80=81=E7=95=8C=E9=9D=A2=E5=8A=A0=E8=BD=BD=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=87=92=E5=8A=A0=E8=BD=BD=E3=80=81docker=E9=95=9C=E5=83=8F?= =?UTF-8?q?=E6=89=93=E5=8C=85=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.dockerignore | 11 + backend/Dockerfile | 19 + backend/pom.xml | 6 + .../unis/crm/UnisCrmBackendApplication.java | 3 +- .../InternalIntegrationSecurityConfig.java | 30 -- .../java/com/unis/crm/config/MinioConfig.java | 17 + .../com/unis/crm/config/MinioProperties.java | 70 +++ .../crm/config/WecomSsoSecurityConfig.java | 30 -- .../crm/controller/ExpansionController.java | 6 +- .../crm/controller/OpportunityController.java | 7 +- .../com/unis/crm/llm/mapper/LlmMcpMapper.java | 53 +- .../McpDataPermissionInterceptor.java | 462 ++++++++++++++++++ .../llm/tools/CrmReportQueryToolProvider.java | 18 +- .../llm/tools/UserProfileToolProvider.java | 7 +- .../support/PermissionedMcpToolProvider.java | 28 ++ .../com/unis/crm/mapper/ExpansionMapper.java | 10 +- .../unis/crm/mapper/OpportunityMapper.java | 4 +- .../crm/service/CrmDataVisibilityService.java | 2 + .../unis/crm/service/ExpansionService.java | 4 + .../unis/crm/service/FileStorageService.java | 11 + .../unis/crm/service/OpportunityService.java | 6 + .../service/impl/ExpansionServiceImpl.java | 32 +- .../service/impl/MinioFileStorageService.java | 117 +++++ .../service/impl/OpportunityServiceImpl.java | 23 +- .../crm/service/impl/WorkServiceImpl.java | 70 +-- .../src/main/resources/application-prod.yml | 12 +- backend/src/main/resources/application.yml | 9 +- .../mapper/expansion/ExpansionMapper.xml | 4 + .../resources/mapper/llm/LlmMcpMapper.xml | 59 ++- .../mapper/opportunity/OpportunityMapper.xml | 6 + .../McpBotAuthenticationFilterTest.java | 118 +++++ .../McpDataPermissionInterceptorTest.java | 242 +++++++++ .../tools/CrmReportQueryToolProviderTest.java | 63 +++ .../PermissionedMcpToolProviderTest.java | 83 ++++ .../impl/ExpansionServiceImplTest.java | 14 +- .../impl/MinioFileStorageServiceTest.java | 41 ++ .../impl/OpportunityServiceImplTest.java | 4 +- .../crm/service/impl/WorkServiceImplTest.java | 9 +- docker-compose.yml | 42 ++ frontend/.dockerignore | 11 + frontend/Dockerfile | 27 + frontend/nginx/default.conf.template | 55 +++ frontend/nginx/start.sh | 13 + frontend/src/lib/auth.ts | 15 +- frontend/src/pages/Expansion.tsx | 86 +++- frontend/src/pages/Opportunities.tsx | 66 ++- frontend1/.dockerignore | 11 + frontend1/Dockerfile | 27 + frontend1/nginx/default.conf.template | 53 ++ frontend1/nginx/start.sh | 13 + 50 files changed, 1893 insertions(+), 236 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile delete mode 100644 backend/src/main/java/com/unis/crm/config/InternalIntegrationSecurityConfig.java create mode 100644 backend/src/main/java/com/unis/crm/config/MinioConfig.java create mode 100644 backend/src/main/java/com/unis/crm/config/MinioProperties.java delete mode 100644 backend/src/main/java/com/unis/crm/config/WecomSsoSecurityConfig.java create mode 100644 backend/src/main/java/com/unis/crm/llm/security/McpDataPermissionInterceptor.java create mode 100644 backend/src/main/java/com/unis/crm/service/FileStorageService.java create mode 100644 backend/src/main/java/com/unis/crm/service/impl/MinioFileStorageService.java create mode 100644 backend/src/test/java/com/unis/crm/llm/security/McpBotAuthenticationFilterTest.java create mode 100644 backend/src/test/java/com/unis/crm/llm/security/McpDataPermissionInterceptorTest.java create mode 100644 backend/src/test/java/com/unis/crm/llm/tools/CrmReportQueryToolProviderTest.java create mode 100644 backend/src/test/java/com/unis/crm/llm/tools/support/PermissionedMcpToolProviderTest.java create mode 100644 backend/src/test/java/com/unis/crm/service/impl/MinioFileStorageServiceTest.java create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx/default.conf.template create mode 100644 frontend/nginx/start.sh create mode 100644 frontend1/.dockerignore create mode 100644 frontend1/Dockerfile create mode 100644 frontend1/nginx/default.conf.template create mode 100644 frontend1/nginx/start.sh diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..309a44fc --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,11 @@ +target/* +!target/unis-crm-backend-1.0.0-SNAPSHOT.jar +.git +.gitignore +.idea +.vscode +*.md +*.log +.DS_Store +build +sql diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..28ee6376 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +# ============ 运行阶段 ============ +FROM eclipse-temurin:17-jre + +WORKDIR /app + +# 依赖 com.unisbase 为内部 Maven 依赖,在镜像构建前由 CI/本地 Maven 构建产物 +COPY target/unis-crm-backend-1.0.0-SNAPSHOT.jar app.jar + +# 创建日志目录 +RUN mkdir -p /app/logs + +ENV TZ=Asia/Shanghai + +EXPOSE 8080 + +# 默认使用 prod profile,可通过环境变量覆盖 +ENV SPRING_PROFILES_ACTIVE=prod + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/pom.xml b/backend/pom.xml index e8a7496d..d4d78dee 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -20,6 +20,7 @@ 17 3.5.6 + 8.5.17 @@ -53,6 +54,11 @@ org.springframework.boot spring-boot-starter-data-redis + + io.minio + minio + ${minio.version} + org.springframework.boot spring-boot-starter-test diff --git a/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java b/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java index c796b881..5808ba2c 100644 --- a/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java +++ b/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java @@ -4,6 +4,7 @@ import com.unis.crm.config.WecomProperties; import com.unis.crm.config.InternalAuthProperties; import com.unis.crm.config.OmsProperties; import com.unis.crm.config.WorkReportProperties; +import com.unis.crm.config.MinioProperties; import java.util.TimeZone; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; @@ -13,7 +14,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication(scanBasePackages = "com.unis.crm") @MapperScan({"com.unis.crm.mapper", "com.unis.crm.llm.mapper"}) -@EnableConfigurationProperties({WecomProperties.class, OmsProperties.class, InternalAuthProperties.class, WorkReportProperties.class}) +@EnableConfigurationProperties({WecomProperties.class, OmsProperties.class, InternalAuthProperties.class, WorkReportProperties.class, MinioProperties.class}) @EnableScheduling public class UnisCrmBackendApplication { diff --git a/backend/src/main/java/com/unis/crm/config/InternalIntegrationSecurityConfig.java b/backend/src/main/java/com/unis/crm/config/InternalIntegrationSecurityConfig.java deleted file mode 100644 index a4b57627..00000000 --- a/backend/src/main/java/com/unis/crm/config/InternalIntegrationSecurityConfig.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.unis.crm.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.annotation.Order; -import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -public class InternalIntegrationSecurityConfig { - - @Bean - public WebSecurityCustomizer internalIntegrationWebSecurityCustomizer() { - return web -> web.ignoring().requestMatchers("/api/opportunities/integration/**"); - } - - @Bean - @Order(1) - public SecurityFilterChain internalIntegrationSecurityFilterChain(HttpSecurity http) throws Exception { - http.securityMatcher("/api/opportunities/integration/**") - .csrf(csrf -> csrf.disable()) - .cors(Customizer.withDefaults()) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); - return http.build(); - } -} diff --git a/backend/src/main/java/com/unis/crm/config/MinioConfig.java b/backend/src/main/java/com/unis/crm/config/MinioConfig.java new file mode 100644 index 00000000..1b7d0077 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/config/MinioConfig.java @@ -0,0 +1,17 @@ +package com.unis.crm.config; + +import io.minio.MinioClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class MinioConfig { + + @Bean + public MinioClient minioClient(MinioProperties properties) { + return MinioClient.builder() + .endpoint(properties.getEndpointUrl()) + .credentials(properties.getAccessKey(), properties.getSecretKey()) + .build(); + } +} diff --git a/backend/src/main/java/com/unis/crm/config/MinioProperties.java b/backend/src/main/java/com/unis/crm/config/MinioProperties.java new file mode 100644 index 00000000..849633b8 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/config/MinioProperties.java @@ -0,0 +1,70 @@ +package com.unis.crm.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "minio") +public class MinioProperties { + + private String endpoint; + private String accessKey; + private String secretKey; + private String bucket; + private String basePath; + private boolean useSsl; + + public String getEndpoint() { + return endpoint; + } + + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } + + public String getAccessKey() { + return accessKey; + } + + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + public String getBucket() { + return bucket; + } + + public void setBucket(String bucket) { + this.bucket = bucket; + } + + public String getBasePath() { + return basePath; + } + + public void setBasePath(String basePath) { + this.basePath = basePath; + } + + public boolean isUseSsl() { + return useSsl; + } + + public void setUseSsl(boolean useSsl) { + this.useSsl = useSsl; + } + + public String getEndpointUrl() { + String value = endpoint == null ? "" : endpoint.trim(); + if (value.startsWith("http://") || value.startsWith("https://")) { + return value; + } + return (useSsl ? "https://" : "http://") + value; + } +} diff --git a/backend/src/main/java/com/unis/crm/config/WecomSsoSecurityConfig.java b/backend/src/main/java/com/unis/crm/config/WecomSsoSecurityConfig.java deleted file mode 100644 index c66dc4b3..00000000 --- a/backend/src/main/java/com/unis/crm/config/WecomSsoSecurityConfig.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.unis.crm.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.annotation.Order; -import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -public class WecomSsoSecurityConfig { - - @Bean - public WebSecurityCustomizer wecomSsoWebSecurityCustomizer() { - return web -> web.ignoring().requestMatchers("/api/wecom/sso/**"); - } - - @Bean - @Order(0) - public SecurityFilterChain wecomSsoSecurityFilterChain(HttpSecurity http) throws Exception { - http.securityMatcher("/api/wecom/sso/**") - .csrf(csrf -> csrf.disable()) - .cors(Customizer.withDefaults()) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); - return http.build(); - } -} diff --git a/backend/src/main/java/com/unis/crm/controller/ExpansionController.java b/backend/src/main/java/com/unis/crm/controller/ExpansionController.java index 6f437889..740de72d 100644 --- a/backend/src/main/java/com/unis/crm/controller/ExpansionController.java +++ b/backend/src/main/java/com/unis/crm/controller/ExpansionController.java @@ -54,8 +54,10 @@ public class ExpansionController { @GetMapping("/overview") public ApiResponse getOverview( @RequestHeader("X-User-Id") Long userId, - @RequestParam(value = "keyword", required = false) String keyword) { - return ApiResponse.success(expansionService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword)); + @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "includeDetails", defaultValue = "true") boolean includeDetails, + @RequestParam(value = "limit", required = false) Integer limit) { + return ApiResponse.success(expansionService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, includeDetails, limit)); } @GetMapping("/opportunity-form-options") diff --git a/backend/src/main/java/com/unis/crm/controller/OpportunityController.java b/backend/src/main/java/com/unis/crm/controller/OpportunityController.java index 887c2374..ef4e64bc 100644 --- a/backend/src/main/java/com/unis/crm/controller/OpportunityController.java +++ b/backend/src/main/java/com/unis/crm/controller/OpportunityController.java @@ -42,8 +42,11 @@ public class OpportunityController { public ApiResponse getOverview( @RequestHeader("X-User-Id") Long userId, @RequestParam(value = "keyword", required = false) String keyword, - @RequestParam(value = "stage", required = false) String stage) { - return ApiResponse.success(opportunityService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, stage)); + @RequestParam(value = "stage", required = false) String stage, + @RequestParam(value = "includeDetails", defaultValue = "true") boolean includeDetails, + @RequestParam(value = "limit", required = false) Integer limit, + @RequestParam(value = "archived", required = false) Boolean archived) { + return ApiResponse.success(opportunityService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword, stage, includeDetails, limit, archived)); } @GetMapping("/{opportunityId}") diff --git a/backend/src/main/java/com/unis/crm/llm/mapper/LlmMcpMapper.java b/backend/src/main/java/com/unis/crm/llm/mapper/LlmMcpMapper.java index 69b0fcb5..8070f0d5 100644 --- a/backend/src/main/java/com/unis/crm/llm/mapper/LlmMcpMapper.java +++ b/backend/src/main/java/com/unis/crm/llm/mapper/LlmMcpMapper.java @@ -5,18 +5,16 @@ import java.util.List; import java.util.Map; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; -import com.unisbase.annotation.DataScope; @Mapper public interface LlmMcpMapper { Map selectUserProfile(@Param("userId") Long userId); - List> selectUserRoles(@Param("userId") Long userId); + List> selectUserRoles(@Param("userId") Long userId, @Param("tenantId") Long tenantId); - List> selectUserOrgs(@Param("userId") Long userId); + List> selectUserOrgs(@Param("userId") Long userId, @Param("tenantId") Long tenantId); - @DataScope(tableAlias = "r", ownerColumn = "user_id") List> searchWorkReports( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @@ -27,7 +25,6 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") List> searchOpportunities( @Param("ownerUserId") Long ownerUserId, @Param("tenantId") Long tenantId, @@ -37,7 +34,6 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") List> universalSearchCustomers( @Param("keyword") String keyword, @Param("ownerUserId") Long ownerUserId, @@ -46,7 +42,6 @@ public interface LlmMcpMapper { @Param("endDate") LocalDate endDate, @Param("limit") int limit); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") List> universalSearchOpportunities( @Param("keyword") String keyword, @Param("ownerUserId") Long ownerUserId, @@ -55,7 +50,6 @@ public interface LlmMcpMapper { @Param("endDate") LocalDate endDate, @Param("limit") int limit); - @DataScope(tableAlias = "s", ownerColumn = "owner_user_id") List> universalSearchSalesExpansions( @Param("keyword") String keyword, @Param("ownerUserId") Long ownerUserId, @@ -64,7 +58,6 @@ public interface LlmMcpMapper { @Param("endDate") LocalDate endDate, @Param("limit") int limit); - @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") List> universalSearchChannelExpansions( @Param("keyword") String keyword, @Param("ownerUserId") Long ownerUserId, @@ -73,7 +66,6 @@ public interface LlmMcpMapper { @Param("endDate") LocalDate endDate, @Param("limit") int limit); - @DataScope(tableAlias = "r", ownerColumn = "user_id") List> universalSearchWorkReports( @Param("keyword") String keyword, @Param("ownerUserId") Long ownerUserId, @@ -82,7 +74,6 @@ public interface LlmMcpMapper { @Param("endDate") LocalDate endDate, @Param("limit") int limit); - @DataScope(tableAlias = "c", ownerColumn = "user_id") List> universalSearchCheckins( @Param("keyword") String keyword, @Param("ownerUserId") Long ownerUserId, @@ -91,7 +82,6 @@ public interface LlmMcpMapper { @Param("endDate") LocalDate endDate, @Param("limit") int limit); - @DataScope(tableAlias = "f", ownerColumn = "followup_user_id") List> universalSearchFollowups( @Param("keyword") String keyword, @Param("ownerUserId") Long ownerUserId, @@ -100,7 +90,6 @@ public interface LlmMcpMapper { @Param("endDate") LocalDate endDate, @Param("limit") int limit); - @DataScope(tableAlias = "t", ownerColumn = "user_id") List> universalSearchTodos( @Param("keyword") String keyword, @Param("ownerUserId") Long ownerUserId, @@ -109,7 +98,6 @@ public interface LlmMcpMapper { @Param("endDate") LocalDate endDate, @Param("limit") int limit); - @DataScope(tableAlias = "l", ownerColumn = "operator_user_id") List> universalSearchActivities( @Param("keyword") String keyword, @Param("ownerUserId") Long ownerUserId, @@ -118,42 +106,36 @@ public interface LlmMcpMapper { @Param("endDate") LocalDate endDate, @Param("limit") int limit); - @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") Map dashboardCustomerMetric( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @Param("ownerUserId") Long ownerUserId, @Param("tenantId") Long tenantId); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") Map dashboardNewOpportunityMetric( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @Param("ownerUserId") Long ownerUserId, @Param("tenantId") Long tenantId); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") Map dashboardWonOpportunityMetric( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @Param("ownerUserId") Long ownerUserId, @Param("tenantId") Long tenantId); - @DataScope(tableAlias = "r", ownerColumn = "user_id") Map dashboardDailyReportMetric( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @Param("ownerUserId") Long ownerUserId, @Param("tenantId") Long tenantId); - @DataScope(tableAlias = "c", ownerColumn = "user_id") Map dashboardCheckinMetric( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @Param("ownerUserId") Long ownerUserId, @Param("tenantId") Long tenantId); - @DataScope(tableAlias = "u", ownerColumn = "user_id") List> salesPerformance( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @@ -162,7 +144,6 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") List> opportunityFunnel( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @@ -171,7 +152,6 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") List> opportunityTrend( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @@ -181,7 +161,6 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "u", ownerColumn = "user_id") List> dailyReportCompletion( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @@ -191,7 +170,6 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "c", ownerColumn = "user_id") List> checkinSummary( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @@ -201,21 +179,18 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "s", ownerColumn = "owner_user_id") Map salesExpansionSummary( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @Param("ownerUserId") Long ownerUserId, @Param("tenantId") Long tenantId); - @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") Map channelExpansionSummary( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @Param("ownerUserId") Long ownerUserId, @Param("tenantId") Long tenantId); - @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") List> customerSummary( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @@ -225,7 +200,6 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "t", ownerColumn = "user_id") List> todoSummary( @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate, @@ -235,13 +209,11 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "u", ownerColumn = "user_id") Map selectWorkTodayStatus( @Param("targetUserId") Long targetUserId, @Param("tenantId") Long tenantId, @Param("queryDate") LocalDate queryDate); - @DataScope(tableAlias = "t", ownerColumn = "user_id") List> searchTodos( @Param("keyword") String keyword, @Param("status") String status, @@ -254,7 +226,6 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "u", ownerColumn = "user_id") List> searchOrgUsers( @Param("keyword") String keyword, @Param("status") Integer status, @@ -274,7 +245,6 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") List> searchCustomers( @Param("keyword") String keyword, @Param("status") String status, @@ -287,7 +257,6 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "c", ownerColumn = "user_id") List> searchCheckins( @Param("keyword") String keyword, @Param("status") String status, @@ -299,7 +268,6 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "s", ownerColumn = "owner_user_id") List> searchSalesExpansions( @Param("keyword") String keyword, @Param("stage") String stage, @@ -311,7 +279,6 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") List> searchChannelExpansions( @Param("keyword") String keyword, @Param("stage") String stage, @@ -323,7 +290,6 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "f", ownerColumn = "followup_user_id") List> searchFollowups( @Param("bizType") String bizType, @Param("keyword") String keyword, @@ -334,49 +300,34 @@ public interface LlmMcpMapper { @Param("limit") int limit, @Param("offset") int offset); - @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") Map selectCustomerDetail(@Param("id") Long id, @Param("tenantId") Long tenantId); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") Map selectOpportunityDetail(@Param("id") Long id, @Param("tenantId") Long tenantId); - @DataScope(tableAlias = "r", ownerColumn = "user_id") Map selectWorkReportDetail(@Param("id") Long id, @Param("tenantId") Long tenantId); - @DataScope(tableAlias = "c", ownerColumn = "user_id") Map selectCheckinDetail(@Param("id") Long id, @Param("tenantId") Long tenantId); - @DataScope(tableAlias = "s", ownerColumn = "owner_user_id") Map selectSalesExpansionDetail(@Param("id") Long id, @Param("tenantId") Long tenantId); - @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") Map selectChannelExpansionDetail(@Param("id") Long id, @Param("tenantId") Long tenantId); - @DataScope(tableAlias = "t", ownerColumn = "user_id") Map selectTodoDetail(@Param("id") Long id, @Param("tenantId") Long tenantId); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") List> selectCustomerOpportunities(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") List> selectOpportunityFollowups(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit); - @DataScope(tableAlias = "r", ownerColumn = "user_id") List> selectWorkReportComments(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit); - @DataScope(tableAlias = "s", ownerColumn = "owner_user_id") List> selectSalesExpansionFollowups(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") List> selectSalesExpansionOpportunities(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit); - @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") List> selectChannelExpansionContacts(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit); - @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") List> selectChannelExpansionFollowups(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") List> selectChannelExpansionOpportunities(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit); List> selectDictTypes(@Param("keyword") String keyword, @Param("limit") int limit, @Param("offset") int offset); diff --git a/backend/src/main/java/com/unis/crm/llm/security/McpDataPermissionInterceptor.java b/backend/src/main/java/com/unis/crm/llm/security/McpDataPermissionInterceptor.java new file mode 100644 index 00000000..96ef762b --- /dev/null +++ b/backend/src/main/java/com/unis/crm/llm/security/McpDataPermissionInterceptor.java @@ -0,0 +1,462 @@ +package com.unis.crm.llm.security; + +import com.baomidou.mybatisplus.core.toolkit.PluginUtils; +import com.unis.crm.common.BusinessException; +import com.unis.crm.service.CrmDataVisibilityService; +import com.unis.crm.service.CrmDataVisibilityService.DataVisibility; +import com.unis.crm.service.CrmDataVisibilityService.OwnerAreaRule; +import com.unisbase.security.LoginUser; +import java.sql.Connection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.select.ParenthesedSelect; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.select.Select; +import net.sf.jsqlparser.statement.select.SetOperationList; +import net.sf.jsqlparser.statement.select.WithItem; +import org.apache.ibatis.executor.statement.StatementHandler; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.plugin.Interceptor; +import org.apache.ibatis.plugin.Intercepts; +import org.apache.ibatis.plugin.Invocation; +import org.apache.ibatis.plugin.Plugin; +import org.apache.ibatis.plugin.Signature; +import org.apache.ibatis.reflection.MetaObject; +import org.apache.ibatis.reflection.SystemMetaObject; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +@Intercepts(@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})) +public class McpDataPermissionInterceptor implements Interceptor { + + private static final String MAPPER_PREFIX = "com.unis.crm.llm.mapper.LlmMcpMapper."; + private static final Map POLICIES = buildPolicies(); + + private final ObjectProvider visibilityServiceProvider; + + public McpDataPermissionInterceptor(ObjectProvider visibilityServiceProvider) { + this.visibilityServiceProvider = visibilityServiceProvider; + } + + @Override + public Object intercept(Invocation invocation) throws Throwable { + StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); + MetaObject metaObject = SystemMetaObject.forObject(statementHandler); + MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement"); + if (mappedStatement == null || !mappedStatement.getId().startsWith(MAPPER_PREFIX)) { + return invocation.proceed(); + } + + LoginUser loginUser = requireLoginUser(); + String methodName = mappedStatement.getId().substring(MAPPER_PREFIX.length()); + BoundSql boundSql = statementHandler.getBoundSql(); + validateParameters(methodName, boundSql.getParameterObject(), loginUser); + + CrmDataVisibilityService visibilityService = visibilityServiceProvider.getIfAvailable(); + if ("searchFollowups".equals(methodName)) { + if (visibilityService == null) { + throw new BusinessException("MCP 数据权限服务不可用"); + } + DataVisibility opportunityVisibility = visibilityService.resolveVisibility( + loginUser.getUserId(), loginUser.getTenantId(), CrmDataVisibilityService.RESOURCE_OPPORTUNITY); + DataVisibility expansionVisibility = visibilityService.resolveVisibility( + loginUser.getUserId(), loginUser.getTenantId(), CrmDataVisibilityService.RESOURCE_EXPANSION); + String condition = buildFollowupVisibilityCondition(opportunityVisibility, expansionVisibility, loginUser); + PluginUtils.mpBoundSql(boundSql).sql(rewriteSql(boundSql.getSql(), condition)); + return invocation.proceed(); + } + if ("salesPerformance".equals(methodName)) { + if (visibilityService == null) { + throw new BusinessException("MCP 数据权限服务不可用"); + } + Map visibilityByResource = new LinkedHashMap<>(); + for (String resourceType : List.of( + CrmDataVisibilityService.RESOURCE_CUSTOMER, + CrmDataVisibilityService.RESOURCE_OPPORTUNITY, + CrmDataVisibilityService.RESOURCE_DAILY_REPORT, + CrmDataVisibilityService.RESOURCE_CHECKIN)) { + visibilityByResource.put(resourceType, visibilityService.resolveVisibility( + loginUser.getUserId(), loginUser.getTenantId(), resourceType)); + } + PluginUtils.mpBoundSql(boundSql).sql(rewriteSalesPerformanceSql( + boundSql.getSql(), visibilityByResource)); + return invocation.proceed(); + } + + QueryPolicy policy = POLICIES.get(methodName); + if (policy != null) { + if (visibilityService == null) { + throw new BusinessException("MCP 数据权限服务不可用"); + } + DataVisibility visibility = visibilityService.resolveVisibility( + loginUser.getUserId(), loginUser.getTenantId(), policy.resourceType()); + String condition = buildVisibilityCondition(methodName, visibility, loginUser); + PluginUtils.mpBoundSql(boundSql).sql(rewriteSql(boundSql.getSql(), condition)); + } + return invocation.proceed(); + } + + String buildFollowupVisibilityCondition( + DataVisibility opportunityVisibility, + DataVisibility expansionVisibility, + LoginUser loginUser) { + QueryPolicy opportunityPolicy = new QueryPolicy( + CrmDataVisibilityService.RESOURCE_OPPORTUNITY, + "\"scopeOwnerUserId\"", + "\"scopeAreaCode\"", + false, + ""); + String opportunityOwner = opportunityVisibility != null && opportunityVisibility.allDataAccess() + ? "1 = 1" + : buildOwnerAreaCondition( + opportunityPolicy, + opportunityVisibility == null ? List.of() : opportunityVisibility.visibleOwnerAreaRules()); + String opportunityCondition = addProjectedPreSalesCondition(opportunityOwner, loginUser); + String expansionCondition = expansionVisibility != null && expansionVisibility.allDataAccess() + ? "1 = 1" + : buildOwnerCondition( + "\"scopeOwnerUserId\"", + expansionVisibility == null ? List.of() : expansionVisibility.visibleOwnerUserIds()); + return "((\"scopeResourceType\" = 'OPPORTUNITY' and (" + opportunityCondition + "))" + + " or (\"scopeResourceType\" = 'EXPANSION' and (" + expansionCondition + ")))"; + } + + private String addProjectedPreSalesCondition(String ownerCondition, LoginUser loginUser) { + StringBuilder condition = new StringBuilder("(").append(ownerCondition) + .append(" or \"scopePreSalesId\" = ").append(loginUser.getUserId()); + List names = currentUserNames(loginUser); + if (!names.isEmpty()) { + condition.append(" or nullif(btrim(\"scopePreSalesName\"), '') in (") + .append(names.stream().map(this::quote).collect(Collectors.joining(","))) + .append(")"); + } + return condition.append(")").toString(); + } + + String rewriteSalesPerformanceSql(String sql, Map visibilityByResource) { + DataVisibility customer = visibilityByResource.get(CrmDataVisibilityService.RESOURCE_CUSTOMER); + DataVisibility opportunity = visibilityByResource.get(CrmDataVisibilityService.RESOURCE_OPPORTUNITY); + DataVisibility dailyReport = visibilityByResource.get(CrmDataVisibilityService.RESOURCE_DAILY_REPORT); + DataVisibility checkin = visibilityByResource.get(CrmDataVisibilityService.RESOURCE_CHECKIN); + + Map cteConditions = new LinkedHashMap<>(); + cteConditions.put("users_scope", buildUnionOwnerCondition( + "u.user_id", List.of(customer, opportunity, dailyReport, checkin))); + cteConditions.put("customer_stats", scopedOwnerCondition("c.owner_user_id", customer)); + cteConditions.put("opportunity_stats", scopedOpportunityOwnerCondition(opportunity)); + cteConditions.put("followup_stats", scopedOpportunityOwnerCondition(opportunity)); + cteConditions.put("report_stats", scopedOwnerCondition("r.user_id", dailyReport)); + cteConditions.put("checkin_stats", scopedOwnerCondition("ck.user_id", checkin)); + + try { + Statement statement = CCJSqlParserUtil.parse(sql); + if (!(statement instanceof Select select) || select.getWithItemsList() == null) { + throw new BusinessException("MCP 经营业绩报表缺少权限分段"); + } + java.util.LinkedHashSet applied = new java.util.LinkedHashSet<>(); + for (WithItem withItem : select.getWithItemsList()) { + String name = withItem.getAlias() == null ? null : withItem.getAlias().getName(); + String conditionSql = cteConditions.get(name); + if (conditionSql == null) { + continue; + } + if (!applyCondition(withItem.getSelect(), CCJSqlParserUtil.parseCondExpression(conditionSql))) { + throw new BusinessException("MCP 无法应用经营业绩分段权限"); + } + applied.add(name); + } + if (!applied.equals(cteConditions.keySet())) { + throw new BusinessException("MCP 经营业绩权限分段不完整"); + } + return statement.toString(); + } catch (BusinessException exception) { + throw exception; + } catch (Exception exception) { + throw new BusinessException("MCP 经营业绩权限 SQL 处理失败"); + } + } + + private String scopedOwnerCondition(String ownerColumn, DataVisibility visibility) { + return visibility != null && visibility.allDataAccess() + ? "1 = 1" + : buildOwnerCondition(ownerColumn, visibility == null ? List.of() : visibility.visibleOwnerUserIds()); + } + + private String scopedOpportunityOwnerCondition(DataVisibility visibility) { + QueryPolicy policy = new QueryPolicy( + CrmDataVisibilityService.RESOURCE_OPPORTUNITY, + "o.owner_user_id", + "o.project_ownership_location", + false, + "o"); + return visibility != null && visibility.allDataAccess() + ? "1 = 1" + : buildOwnerAreaCondition(policy, visibility == null ? List.of() : visibility.visibleOwnerAreaRules()); + } + + private String buildUnionOwnerCondition(String ownerColumn, List visibilities) { + if (visibilities.stream().anyMatch(value -> value != null && value.allDataAccess())) { + return "1 = 1"; + } + List ownerUserIds = visibilities.stream() + .filter(Objects::nonNull) + .flatMap(value -> value.visibleOwnerUserIds().stream()) + .distinct() + .toList(); + return buildOwnerCondition(ownerColumn, ownerUserIds); + } + + @Override + public Object plugin(Object target) { + return Plugin.wrap(target, this); + } + + private LoginUser requireLoginUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser loginUser)) { + throw new BusinessException("MCP 数据查询缺少认证上下文"); + } + if (loginUser.getUserId() == null || loginUser.getUserId() <= 0 + || loginUser.getTenantId() == null || loginUser.getTenantId() <= 0) { + throw new BusinessException("MCP 数据查询缺少有效用户或租户"); + } + return loginUser; + } + + private void validateParameters(String methodName, Object parameterObject, LoginUser loginUser) { + Map parameters = parameterObject instanceof Map map ? map : Map.of(); + if (methodName.equals("selectUserProfile") || methodName.equals("selectUserRoles") || methodName.equals("selectUserOrgs")) { + Long requestedUserId = asLong(parameters.get("userId")); + if (!Objects.equals(requestedUserId, loginUser.getUserId())) { + throw new BusinessException("MCP 禁止查询其他用户的账号画像"); + } + if (parameters.containsKey("tenantId") + && !Objects.equals(asLong(parameters.get("tenantId")), loginUser.getTenantId())) { + throw new BusinessException("MCP 用户画像租户与认证租户不一致"); + } + return; + } + if (methodName.equals("selectDictTypes") || methodName.equals("selectDictOptions")) { + return; + } + Long requestedTenantId = asLong(parameters.get("tenantId")); + if (!Objects.equals(requestedTenantId, loginUser.getTenantId())) { + throw new BusinessException("MCP 查询租户与认证租户不一致"); + } + } + + String buildVisibilityCondition(String methodName, DataVisibility visibility, LoginUser loginUser) { + QueryPolicy policy = POLICIES.get(methodName); + if (policy == null) { + throw new BusinessException("MCP 查询未配置数据权限策略"); + } + if (visibility != null && visibility.allDataAccess()) { + return "1 = 1"; + } + String ownerCondition = policy.areaColumn() == null + ? buildOwnerCondition(policy.ownerColumn(), visibility == null ? List.of() : visibility.visibleOwnerUserIds()) + : buildOwnerAreaCondition(policy, visibility == null ? List.of() : visibility.visibleOwnerAreaRules()); + if (!policy.preSalesVisible()) { + return ownerCondition; + } + + StringBuilder condition = new StringBuilder("(").append(ownerCondition) + .append(" or ").append(policy.opportunityAlias()).append(".pre_sales_id = ").append(loginUser.getUserId()); + List names = currentUserNames(loginUser); + if (!names.isEmpty()) { + condition.append(" or nullif(btrim(").append(policy.opportunityAlias()).append(".pre_sales_name), '') in (") + .append(names.stream().map(this::quote).collect(Collectors.joining(","))) + .append(")"); + } + return condition.append(")").toString(); + } + + private List currentUserNames(LoginUser loginUser) { + return Stream.of(loginUser.getDisplayName(), loginUser.getUsername()) + .filter(value -> value != null && !value.isBlank()) + .distinct() + .toList(); + } + + static Set policyMethodNames() { + java.util.LinkedHashSet methodNames = new java.util.LinkedHashSet<>(POLICIES.keySet()); + methodNames.add("searchFollowups"); + methodNames.add("salesPerformance"); + return Set.copyOf(methodNames); + } + + private String buildOwnerCondition(String ownerColumn, List ownerUserIds) { + String ids = normalizeIds(ownerUserIds); + return ids.isEmpty() ? "1 = 0" : ownerColumn + " in (" + ids + ")"; + } + + private String buildOwnerAreaCondition(QueryPolicy policy, List rules) { + if (rules == null || rules.isEmpty()) { + return "1 = 0"; + } + List conditions = rules.stream() + .filter(rule -> rule != null && rule.ownerUserId() != null && rule.ownerUserId() > 0) + .map(rule -> { + if (rule.allAreas()) { + return policy.ownerColumn() + " = " + rule.ownerUserId(); + } + String areas = rule.areaCodes() == null ? "" : rule.areaCodes().stream() + .filter(value -> value != null && !value.isBlank()) + .distinct() + .map(this::quote) + .collect(Collectors.joining(",")); + if (areas.isEmpty()) { + return null; + } + return "(" + policy.ownerColumn() + " = " + rule.ownerUserId() + + " and " + policy.areaColumn() + " in (" + areas + "))"; + }) + .filter(Objects::nonNull) + .toList(); + return conditions.isEmpty() ? "1 = 0" : "(" + String.join(" or ", conditions) + ")"; + } + + String rewriteSql(String sql, String conditionSql) { + try { + Statement statement = CCJSqlParserUtil.parse(sql); + if (!(statement instanceof Select select)) { + throw new BusinessException("MCP 数据权限仅允许查询语句"); + } + Expression condition = CCJSqlParserUtil.parseCondExpression(conditionSql); + if (!applyCondition(select, condition)) { + throw new BusinessException("MCP 无法对当前查询应用数据权限"); + } + return statement.toString(); + } catch (BusinessException exception) { + throw exception; + } catch (Exception exception) { + throw new BusinessException("MCP 数据权限 SQL 处理失败"); + } + } + + private boolean applyCondition(Select select, Expression condition) throws Exception { + PlainSelect plainSelect = select.getPlainSelect(); + if (plainSelect != null) { + Expression current = plainSelect.getWhere(); + plainSelect.setWhere(current == null + ? condition + : CCJSqlParserUtil.parseCondExpression("(" + current + ") and (" + condition + ")")); + return true; + } + SetOperationList setOperationList = select.getSetOperationList(); + if (setOperationList != null && setOperationList.getSelects() != null) { + boolean applied = false; + for (Select child : setOperationList.getSelects()) { + applied = applyCondition(child, condition) || applied; + } + return applied; + } + if (select instanceof ParenthesedSelect parenthesedSelect) { + return applyCondition(parenthesedSelect.getSelect(), condition); + } + return false; + } + + private String normalizeIds(List ids) { + if (ids == null) { + return ""; + } + return ids.stream() + .filter(value -> value != null && value > 0) + .distinct() + .map(String::valueOf) + .collect(Collectors.joining(",")); + } + + private Long asLong(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + if (value == null) { + return null; + } + try { + return Long.valueOf(String.valueOf(value)); + } catch (NumberFormatException exception) { + return null; + } + } + + private String quote(String value) { + return "'" + value.replace("'", "''") + "'"; + } + + private static Map buildPolicies() { + Map policies = new LinkedHashMap<>(); + add(policies, CrmDataVisibilityService.RESOURCE_DAILY_REPORT, "r.user_id", null, false, + "searchWorkReports", "universalSearchWorkReports", "dashboardDailyReportMetric", + "selectWorkReportDetail", "selectWorkReportComments"); + add(policies, CrmDataVisibilityService.RESOURCE_CHECKIN, "c.user_id", null, false, + "universalSearchCheckins", "dashboardCheckinMetric", "checkinSummary", + "searchCheckins", "selectCheckinDetail"); + add(policies, CrmDataVisibilityService.RESOURCE_CUSTOMER, "c.owner_user_id", null, false, + "universalSearchCustomers", "dashboardCustomerMetric", "customerSummary", + "searchCustomers", "selectCustomerDetail"); + add(policies, CrmDataVisibilityService.RESOURCE_EXPANSION, "s.owner_user_id", null, false, + "universalSearchSalesExpansions", "salesExpansionSummary", "searchSalesExpansions", + "selectSalesExpansionDetail", "selectSalesExpansionFollowups"); + add(policies, CrmDataVisibilityService.RESOURCE_EXPANSION, "c.owner_user_id", null, false, + "universalSearchChannelExpansions", "channelExpansionSummary", "searchChannelExpansions", + "selectChannelExpansionDetail", "selectChannelExpansionContacts", "selectChannelExpansionFollowups"); + add(policies, CrmDataVisibilityService.RESOURCE_WORK, "t.user_id", null, false, + "universalSearchTodos", "searchTodos", "todoSummary", "selectTodoDetail"); + add(policies, CrmDataVisibilityService.RESOURCE_WORK, "l.operator_user_id", null, false, + "universalSearchActivities"); + add(policies, CrmDataVisibilityService.RESOURCE_OPPORTUNITY, "o.owner_user_id", + "o.project_ownership_location", true, "universalSearchFollowups"); + add(policies, CrmDataVisibilityService.RESOURCE_WORK, "u.user_id", null, false, + "selectWorkTodayStatus"); + add(policies, CrmDataVisibilityService.RESOURCE_ALL, "u.user_id", null, false, + "searchOrgUsers"); + + add(policies, CrmDataVisibilityService.RESOURCE_OPPORTUNITY, "o.owner_user_id", + "o.project_ownership_location", true, + "searchOpportunities", "universalSearchOpportunities", "dashboardNewOpportunityMetric", + "dashboardWonOpportunityMetric", "opportunityFunnel", "opportunityTrend", + "selectOpportunityDetail", "selectCustomerOpportunities", "selectOpportunityFollowups", + "selectSalesExpansionOpportunities", "selectChannelExpansionOpportunities"); + add(policies, CrmDataVisibilityService.RESOURCE_DAILY_REPORT, "e.user_id", null, false, + "dailyReportCompletion"); + return Map.copyOf(policies); + } + + private static void add( + Map target, + String resourceType, + String ownerColumn, + String areaColumn, + boolean preSalesVisible, + String... methodNames) { + int separator = ownerColumn.indexOf('.'); + String alias = separator > 0 ? ownerColumn.substring(0, separator) : ""; + QueryPolicy policy = new QueryPolicy(resourceType, ownerColumn, areaColumn, preSalesVisible, alias); + for (String methodName : methodNames) { + target.put(methodName, policy); + } + } + + private record QueryPolicy( + String resourceType, + String ownerColumn, + String areaColumn, + boolean preSalesVisible, + String opportunityAlias) { + } +} diff --git a/backend/src/main/java/com/unis/crm/llm/tools/CrmReportQueryToolProvider.java b/backend/src/main/java/com/unis/crm/llm/tools/CrmReportQueryToolProvider.java index e5924900..6d0e9711 100644 --- a/backend/src/main/java/com/unis/crm/llm/tools/CrmReportQueryToolProvider.java +++ b/backend/src/main/java/com/unis/crm/llm/tools/CrmReportQueryToolProvider.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.unis.crm.common.BusinessException; import com.unis.crm.llm.mapper.LlmMcpMapper; import com.unis.crm.llm.tools.support.PermissionedMcpToolProvider; +import com.unisbase.security.PermissionService; import com.unisbase.security.SpringSecurityTenantProvider; import java.time.LocalDate; import java.time.temporal.ChronoUnit; @@ -18,17 +19,22 @@ import org.springframework.util.StringUtils; public class CrmReportQueryToolProvider extends PermissionedMcpToolProvider { private static final int MAX_DATE_RANGE_DAYS = 366; + private static final String STATS_PERMISSION = "dashboard_stats_card:view"; + private static final String ANALYTICS_PERMISSION = "dashboard_analytics_card:view"; private final LlmMcpMapper llmMcpMapper; private final SpringSecurityTenantProvider tenantProvider; + private final PermissionService permissionService; public CrmReportQueryToolProvider( ObjectMapper objectMapper, LlmMcpMapper llmMcpMapper, - SpringSecurityTenantProvider tenantProvider) { + SpringSecurityTenantProvider tenantProvider, + PermissionService permissionService) { super(objectMapper); this.llmMcpMapper = llmMcpMapper; this.tenantProvider = tenantProvider; + this.permissionService = permissionService; } @Override @@ -56,7 +62,7 @@ public class CrmReportQueryToolProvider extends PermissionedMcpToolProvider { "todo_summary"))); queryProperties.put("startDate", stringProperty("开始日期 YYYY-MM-DD;不传默认本月。")); queryProperties.put("endDate", stringProperty("结束日期 YYYY-MM-DD;不传默认今天,最大跨度 366 天。")); - queryProperties.put("ownerUserId", integerProperty("目标用户 ID。普通用户只能查自己,平台管理员可查指定人员或全员。")); + queryProperties.put("ownerUserId", integerProperty("目标用户 ID,实际可见范围仍按系统数据权限裁剪。")); queryProperties.put("groupBy", enumStringProperty("分组维度。", List.of("none", "day", "month", "owner", "stage", "status", "source"))); queryProperties.put("page", integerProperty("页码,默认 1。")); queryProperties.put("pageSize", integerProperty("每页条数,默认 20,最大 50。")); @@ -74,6 +80,7 @@ public class CrmReportQueryToolProvider extends PermissionedMcpToolProvider { if (!StringUtils.hasText(reportType)) { throw new BusinessException("reportType 不能为空"); } + requireReportPermission(reportType); QueryContext context = buildContext(arguments); List> rows = switch (reportType) { case "dashboard_summary" -> dashboardSummary(context); @@ -101,6 +108,13 @@ public class CrmReportQueryToolProvider extends PermissionedMcpToolProvider { return result; } + private void requireReportPermission(String reportType) { + String permission = "dashboard_summary".equals(reportType) ? STATS_PERMISSION : ANALYTICS_PERMISSION; + if (!permissionService.hasPermi(permission)) { + throw new BusinessException("无权查询该 CRM 报表"); + } + } + private List> dashboardSummary(QueryContext context) { List> rows = new ArrayList<>(); addRow(rows, llmMcpMapper.dashboardCustomerMetric(context.startDate(), context.endDate(), context.ownerUserId(), context.tenantId())); diff --git a/backend/src/main/java/com/unis/crm/llm/tools/UserProfileToolProvider.java b/backend/src/main/java/com/unis/crm/llm/tools/UserProfileToolProvider.java index df144ce6..0203bae8 100644 --- a/backend/src/main/java/com/unis/crm/llm/tools/UserProfileToolProvider.java +++ b/backend/src/main/java/com/unis/crm/llm/tools/UserProfileToolProvider.java @@ -60,9 +60,10 @@ public class UserProfileToolProvider extends PermissionedMcpToolProvider { } profile = new LinkedHashMap<>(profile); - profile.put("currentTenantId", tenantProvider.getCurrentTenantId()); - profile.put("roles", llmMcpMapper.selectUserRoles(userId)); - profile.put("orgs", llmMcpMapper.selectUserOrgs(userId)); + Long tenantId = tenantProvider.getCurrentTenantId(); + profile.put("currentTenantId", tenantId); + profile.put("roles", llmMcpMapper.selectUserRoles(userId, tenantId)); + profile.put("orgs", llmMcpMapper.selectUserOrgs(userId, tenantId)); return profile; } } diff --git a/backend/src/main/java/com/unis/crm/llm/tools/support/PermissionedMcpToolProvider.java b/backend/src/main/java/com/unis/crm/llm/tools/support/PermissionedMcpToolProvider.java index d4ad5cc5..b0b30d24 100644 --- a/backend/src/main/java/com/unis/crm/llm/tools/support/PermissionedMcpToolProvider.java +++ b/backend/src/main/java/com/unis/crm/llm/tools/support/PermissionedMcpToolProvider.java @@ -3,12 +3,16 @@ package com.unis.crm.llm.tools.support; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.unis.crm.common.BusinessException; +import com.unisbase.llm.McpTool; +import com.unisbase.security.LoginUser; import java.time.LocalDate; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.util.StringUtils; public abstract class PermissionedMcpToolProvider extends com.unisbase.llm.tools.support.AbstractMcpToolProvider { @@ -22,6 +26,30 @@ public abstract class PermissionedMcpToolProvider extends com.unisbase.llm.tools this.objectMapper = objectMapper; } + @Override + public McpTool buildTool() { + McpTool tool = super.buildTool(); + tool.setHandler(arguments -> { + requireAuthenticatedContext(); + return handle(arguments); + }); + return tool; + } + + protected LoginUser requireAuthenticatedContext() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser loginUser)) { + throw new BusinessException("MCP 身份认证失败"); + } + if (loginUser.getUserId() == null || loginUser.getUserId() <= 0) { + throw new BusinessException("MCP 未获取到有效用户"); + } + if (loginUser.getTenantId() == null || loginUser.getTenantId() <= 0) { + throw new BusinessException("MCP 必须进入有效租户后才能查询 CRM 数据"); + } + return loginUser; + } + protected Map integerProperty(String description) { return property("integer", description); } diff --git a/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java b/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java index bc967732..dc03fa3c 100644 --- a/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java @@ -30,18 +30,20 @@ public interface ExpansionMapper { String selectNextChannelCode(); @DataScope(tableAlias = "s", ownerColumn = "owner_user_id") - List selectSalesExpansions(@Param("userId") Long userId, @Param("keyword") String keyword); + List selectSalesExpansions(@Param("userId") Long userId, @Param("keyword") String keyword, @Param("limit") Integer limit); List selectSalesExpansionsByOwnerUserIds( @Param("ownerUserIds") List ownerUserIds, - @Param("keyword") String keyword); + @Param("keyword") String keyword, + @Param("limit") Integer limit); @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") - List selectChannelExpansions(@Param("userId") Long userId, @Param("keyword") String keyword); + List selectChannelExpansions(@Param("userId") Long userId, @Param("keyword") String keyword, @Param("limit") Integer limit); List selectChannelExpansionsByOwnerUserIds( @Param("ownerUserIds") List ownerUserIds, - @Param("keyword") String keyword); + @Param("keyword") String keyword, + @Param("limit") Integer limit); List selectSalesExpansionsForTenant( @Param("tenantId") Long tenantId, diff --git a/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java b/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java index f6eee155..e82f1930 100644 --- a/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java @@ -43,7 +43,9 @@ public interface OpportunityMapper { @Param("visibleOwnerUserIds") List visibleOwnerUserIds, @Param("visibleOwnerAreaRules") List visibleOwnerAreaRules, @Param("preSalesUserId") Long preSalesUserId, - @Param("preSalesUserNames") List preSalesUserNames); + @Param("preSalesUserNames") List preSalesUserNames, + @Param("limit") Integer limit, + @Param("archived") Boolean archived); OpportunityItemDTO selectOpportunityDetail( @Param("userId") Long userId, diff --git a/backend/src/main/java/com/unis/crm/service/CrmDataVisibilityService.java b/backend/src/main/java/com/unis/crm/service/CrmDataVisibilityService.java index 3a235371..0c71842a 100644 --- a/backend/src/main/java/com/unis/crm/service/CrmDataVisibilityService.java +++ b/backend/src/main/java/com/unis/crm/service/CrmDataVisibilityService.java @@ -9,6 +9,8 @@ public interface CrmDataVisibilityService { String RESOURCE_EXPANSION = "EXPANSION"; String RESOURCE_DAILY_REPORT = "DAILY_REPORT"; String RESOURCE_CHECKIN = "CHECKIN"; + String RESOURCE_CUSTOMER = "CUSTOMER"; + String RESOURCE_WORK = "WORK"; DataVisibility resolveVisibility(Long currentUserId, Long tenantId, String resourceType); diff --git a/backend/src/main/java/com/unis/crm/service/ExpansionService.java b/backend/src/main/java/com/unis/crm/service/ExpansionService.java index 141eb8d5..15f96d9f 100644 --- a/backend/src/main/java/com/unis/crm/service/ExpansionService.java +++ b/backend/src/main/java/com/unis/crm/service/ExpansionService.java @@ -19,6 +19,10 @@ public interface ExpansionService { ExpansionOverviewDTO getOverview(Long userId, String keyword); + ExpansionOverviewDTO getOverview(Long userId, String keyword, boolean includeDetails); + + ExpansionOverviewDTO getOverview(Long userId, String keyword, boolean includeDetails, Integer limit); + ExpansionOverviewDTO getOpportunityFormOptions(Long userId, String keyword, Integer limit); ExpansionDuplicateCheckDTO checkSalesEmployeeNoDuplicate(Long userId, String employeeNo, Long excludeId); diff --git a/backend/src/main/java/com/unis/crm/service/FileStorageService.java b/backend/src/main/java/com/unis/crm/service/FileStorageService.java new file mode 100644 index 00000000..a0d7a8e4 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/service/FileStorageService.java @@ -0,0 +1,11 @@ +package com.unis.crm.service; + +import org.springframework.core.io.Resource; +import org.springframework.web.multipart.MultipartFile; + +public interface FileStorageService { + + void upload(String objectName, MultipartFile file); + + Resource load(String objectName); +} diff --git a/backend/src/main/java/com/unis/crm/service/OpportunityService.java b/backend/src/main/java/com/unis/crm/service/OpportunityService.java index 6942e3f5..f44d4ee7 100644 --- a/backend/src/main/java/com/unis/crm/service/OpportunityService.java +++ b/backend/src/main/java/com/unis/crm/service/OpportunityService.java @@ -16,6 +16,12 @@ public interface OpportunityService { OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage); + OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails); + + OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit); + + OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit, Boolean archived); + OpportunityItemDTO getDetail(Long userId, Long opportunityId); List getOmsPreSalesOptions(Long userId); diff --git a/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java index 996460a7..e34dc1f3 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java @@ -106,24 +106,42 @@ public class ExpansionServiceImpl implements ExpansionService { @Override public ExpansionOverviewDTO getOverview(Long userId, String keyword) { + return getOverview(userId, keyword, true); + } + + @Override + public ExpansionOverviewDTO getOverview(Long userId, String keyword, boolean includeDetails) { + return getOverview(userId, keyword, includeDetails, null); + } + + @Override + public ExpansionOverviewDTO getOverview(Long userId, String keyword, boolean includeDetails, Integer limit) { String normalizedKeyword = normalizeKeyword(keyword); + Integer normalizedLimit = limit != null && limit > 0 ? Math.min(limit, 1000) : null; List extraVisibleOwnerUserIds = resolveExtraVisibleOwnerUserIds(userId); List salesItems = mergeSalesExpansionItems( - expansionMapper.selectSalesExpansions(userId, normalizedKeyword), + expansionMapper.selectSalesExpansions(userId, normalizedKeyword, normalizedLimit), extraVisibleOwnerUserIds.isEmpty() ? List.of() - : expansionMapper.selectSalesExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword)); + : expansionMapper.selectSalesExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword, normalizedLimit)); List channelItems = mergeChannelExpansionItems( - expansionMapper.selectChannelExpansions(userId, normalizedKeyword), + expansionMapper.selectChannelExpansions(userId, normalizedKeyword, normalizedLimit), extraVisibleOwnerUserIds.isEmpty() ? List.of() - : expansionMapper.selectChannelExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword)); + : expansionMapper.selectChannelExpansionsByOwnerUserIds(extraVisibleOwnerUserIds, normalizedKeyword, normalizedLimit)); + + if (normalizedLimit != null) { + salesItems = new ArrayList<>(salesItems.subList(0, Math.min(normalizedLimit, salesItems.size()))); + channelItems = new ArrayList<>(channelItems.subList(0, Math.min(normalizedLimit, channelItems.size()))); + } - attachSalesFollowUps(userId, salesItems); attachSalesRelatedProjects(userId, salesItems); - attachChannelFollowUps(userId, channelItems); - attachChannelContacts(userId, channelItems); attachChannelRelatedProjects(userId, channelItems); + if (includeDetails) { + attachSalesFollowUps(userId, salesItems); + attachChannelFollowUps(userId, channelItems); + attachChannelContacts(userId, channelItems); + } fillChannelDisplayFields(channelItems); return new ExpansionOverviewDTO(salesItems, channelItems); diff --git a/backend/src/main/java/com/unis/crm/service/impl/MinioFileStorageService.java b/backend/src/main/java/com/unis/crm/service/impl/MinioFileStorageService.java new file mode 100644 index 00000000..db05f71d --- /dev/null +++ b/backend/src/main/java/com/unis/crm/service/impl/MinioFileStorageService.java @@ -0,0 +1,117 @@ +package com.unis.crm.service.impl; + +import com.unis.crm.common.BusinessException; +import com.unis.crm.config.MinioProperties; +import com.unis.crm.service.FileStorageService; +import io.minio.BucketExistsArgs; +import io.minio.GetObjectArgs; +import io.minio.GetObjectResponse; +import io.minio.MakeBucketArgs; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +public class MinioFileStorageService implements FileStorageService { + + private static final Logger log = LoggerFactory.getLogger(MinioFileStorageService.class); + + private final MinioClient minioClient; + private final MinioProperties properties; + private final AtomicBoolean bucketReady = new AtomicBoolean(false); + + public MinioFileStorageService(MinioClient minioClient, MinioProperties properties) { + this.minioClient = minioClient; + this.properties = properties; + } + + @Override + public void upload(String objectName, MultipartFile file) { + ensureBucket(); + try { + minioClient.putObject(PutObjectArgs.builder() + .bucket(properties.getBucket()) + .object(resolveObjectName(objectName)) + .stream(file.getInputStream(), file.getSize(), -1) + .contentType(file.getContentType()) + .build()); + } catch (Exception exception) { + throw new BusinessException("文件上传到MinIO失败,请稍后重试"); + } + } + + @Override + public Resource load(String objectName) { + String resolvedObjectName = resolveObjectName(objectName); + try { + GetObjectResponse response = minioClient.getObject(GetObjectArgs.builder() + .bucket(properties.getBucket()) + .object(resolvedObjectName) + .build()); + String fileName = Path.of(resolvedObjectName).getFileName().toString(); + return new InputStreamResource(response) { + @Override + public String getFilename() { + return fileName; + } + + @Override + public long contentLength() { + String contentLength = response.headers().get("Content-Length"); + if (contentLength == null) { + return -1; + } + try { + return Long.parseLong(contentLength); + } catch (NumberFormatException ignored) { + return -1; + } + } + }; + } catch (Exception exception) { + log.error( + "Failed to read MinIO object bucket={}, object={}", + properties.getBucket(), + resolvedObjectName, + exception); + throw new BusinessException("MinIO文件读取失败"); + } + } + + private void ensureBucket() { + if (bucketReady.get()) { + return; + } + synchronized (bucketReady) { + if (bucketReady.get()) { + return; + } + try { + boolean exists = minioClient.bucketExists(BucketExistsArgs.builder() + .bucket(properties.getBucket()) + .build()); + if (!exists) { + minioClient.makeBucket(MakeBucketArgs.builder().bucket(properties.getBucket()).build()); + } + bucketReady.set(true); + } catch (Exception exception) { + throw new BusinessException("MinIO存储桶初始化失败"); + } + } + } + + private String resolveObjectName(String objectName) { + String basePath = properties.getBasePath() == null ? "" : properties.getBasePath().trim(); + basePath = basePath.replace('\\', '/').replaceAll("^/+|/+$", ""); + String normalizedObjectName = objectName.replace('\\', '/').replaceAll("^/+", ""); + return basePath.isEmpty() ? normalizedObjectName : basePath + "/" + normalizedObjectName; + } + +} diff --git a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java index 10e222a3..ac93579e 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java @@ -128,6 +128,21 @@ public class OpportunityServiceImpl implements OpportunityService { @Override public OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage) { + return getOverview(userId, keyword, stage, true); + } + + @Override + public OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails) { + return getOverview(userId, keyword, stage, includeDetails, null); + } + + @Override + public OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit) { + return getOverview(userId, keyword, stage, includeDetails, limit, null); + } + + @Override + public OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage, boolean includeDetails, Integer limit, Boolean archived) { String normalizedKeyword = normalizeKeyword(keyword); String normalizedStage = normalizeStage(stage); OpportunityVisibility visibility = resolveOpportunityVisibility(userId); @@ -139,8 +154,12 @@ public class OpportunityServiceImpl implements OpportunityService { visibility.visibleOwnerUserIds(), visibility.visibleOwnerAreaRules(), visibility.preSalesUserId(), - visibility.preSalesUserNames()); - attachFollowUps(userId, items, visibility); + visibility.preSalesUserNames(), + limit != null && limit > 0 ? Math.min(limit, 1000) : null, + archived); + if (includeDetails) { + attachFollowUps(userId, items, visibility); + } return new OpportunityOverviewDTO(items); } diff --git a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java index 6c3d1227..15e78f8e 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java @@ -26,6 +26,7 @@ import com.unis.crm.mapper.ProfileMapper; import com.unis.crm.mapper.WorkMapper; import com.unis.crm.service.CrmDataVisibilityService; import com.unis.crm.service.CrmDataVisibilityService.DataVisibility; +import com.unis.crm.service.FileStorageService; import com.unis.crm.service.ReportReminderService; import com.unis.crm.service.WorkService; import com.unisbase.service.SysPermissionService; @@ -38,10 +39,6 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; @@ -63,7 +60,6 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; -import org.springframework.core.io.UrlResource; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -119,8 +115,7 @@ public class WorkServiceImpl implements WorkService { private final WorkReportProperties workReportProperties; private final SysPermissionService sysPermissionService; private final JdbcTemplate jdbcTemplate; - private final Path checkInPhotoDirectory; - private final Path reportAttachmentDirectory; + private final FileStorageService fileStorageService; private final String tencentMapKey; private final Map locationNameCache = new ConcurrentHashMap<>(); @@ -135,7 +130,7 @@ public class WorkServiceImpl implements WorkService { CrmDataVisibilityService crmDataVisibilityService, SysPermissionService sysPermissionService, JdbcTemplate jdbcTemplate, - @Value("${unisbase.app.upload-path}") String uploadPath, + FileStorageService fileStorageService, @Value("${unisbase.app.tencent-map.key:}") String tencentMapKey) { this.workMapper = workMapper; this.opportunityMapper = opportunityMapper; @@ -147,11 +142,10 @@ public class WorkServiceImpl implements WorkService { this.crmDataVisibilityService = crmDataVisibilityService; this.sysPermissionService = sysPermissionService; this.jdbcTemplate = jdbcTemplate; + this.fileStorageService = fileStorageService; this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(8)) .build(); - this.checkInPhotoDirectory = Paths.get(uploadPath, "work-checkin"); - this.reportAttachmentDirectory = Paths.get(uploadPath, "work-report-attachments"); this.tencentMapKey = normalizeOptionalText(tencentMapKey); } @@ -446,17 +440,8 @@ public class WorkServiceImpl implements WorkService { String extension = resolveFileExtension(contentType, file.getOriginalFilename()); String fileName = userId + "-" + UUID.randomUUID().toString().replace("-", "") + extension; - try { - Files.createDirectories(checkInPhotoDirectory); - Path targetPath = checkInPhotoDirectory.resolve(fileName).normalize(); - if (!targetPath.startsWith(checkInPhotoDirectory)) { - throw new BusinessException("图片路径非法"); - } - Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING); - return "/api/work/checkin-photos/" + fileName; - } catch (IOException exception) { - throw new BusinessException("现场照片上传失败,请稍后重试"); - } + fileStorageService.upload("work-checkin/" + fileName, file); + return "/api/work/checkin-photos/" + fileName; } @Override @@ -466,15 +451,7 @@ public class WorkServiceImpl implements WorkService { throw new BusinessException("图片不存在"); } - try { - Path filePath = checkInPhotoDirectory.resolve(normalizedFileName).normalize(); - if (!filePath.startsWith(checkInPhotoDirectory) || !Files.exists(filePath)) { - throw new BusinessException("图片不存在"); - } - return new UrlResource(filePath.toUri()); - } catch (IOException exception) { - throw new BusinessException("图片读取失败"); - } + return fileStorageService.load("work-checkin/" + normalizedFileName); } @Override @@ -496,22 +473,13 @@ public class WorkServiceImpl implements WorkService { String extension = resolveAttachmentExtension(contentType, originalName); String fileName = userId + "-" + UUID.randomUUID().toString().replace("-", "") + extension; - try { - Files.createDirectories(reportAttachmentDirectory); - Path targetPath = reportAttachmentDirectory.resolve(fileName).normalize(); - if (!targetPath.startsWith(reportAttachmentDirectory)) { - throw new BusinessException("附件路径非法"); - } - Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING); - WorkReportAttachmentDTO attachment = new WorkReportAttachmentDTO(); - attachment.setName(originalName); - attachment.setUrl("/api/work/report-attachments/" + fileName); - attachment.setContentType(contentType); - attachment.setSize(file.getSize()); - return attachment; - } catch (IOException exception) { - throw new BusinessException("附件上传失败,请稍后重试"); - } + fileStorageService.upload("work-report-attachments/" + fileName, file); + WorkReportAttachmentDTO attachment = new WorkReportAttachmentDTO(); + attachment.setName(originalName); + attachment.setUrl("/api/work/report-attachments/" + fileName); + attachment.setContentType(contentType); + attachment.setSize(file.getSize()); + return attachment; } @Override @@ -521,15 +489,7 @@ public class WorkServiceImpl implements WorkService { throw new BusinessException("附件不存在"); } - try { - Path filePath = reportAttachmentDirectory.resolve(normalizedFileName).normalize(); - if (!filePath.startsWith(reportAttachmentDirectory) || !Files.exists(filePath)) { - throw new BusinessException("附件不存在"); - } - return new UrlResource(filePath.toUri()); - } catch (IOException exception) { - throw new BusinessException("附件读取失败"); - } + return fileStorageService.load("work-report-attachments/" + normalizedFileName); } private void requireUser(Long userId) { diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 337cad8d..ad6334c9 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -1,3 +1,14 @@ +server: + port: 8080 + +minio: + endpoint: https://miniodown.nex.unisspace.com + access_key: admin + secret_key: Admin@123456 + bucket: crm + base_path: uploadPath + use_ssl: false + spring: application: name: unis-crm-backend @@ -49,7 +60,6 @@ unisbase: secret: f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77 header-name: X-Internal-Secret app: - upload-path: /Users/kangwenjing/Downloads/crm/uploads resource-prefix: /sys/api/static/ tencent-map: key: ${TENCENT_MAP_KEY:LJYBZ-HCQCV-N37PU-5FIOX-QFA26-FPB6U} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index a7c97ebf..1708106a 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,6 +1,14 @@ server: port: 8080 +minio: + endpoint: 192.168.124.202:9000 + access_key: admin + secret_key: Admin@123456 + bucket: crm + base_path: uploadPath + use_ssl: false + spring: application: name: unis-crm-backend @@ -58,7 +66,6 @@ unisbase: secret: f0eb247f84db4e328fb27ce8ff6e7be96e73a53a7e9c4793395ad10d999e0d77 header-name: X-Internal-Secret app: - upload-path: /Users/kangwenjing/Downloads/crm/uploads resource-prefix: /sys/api/static/ tencent-map: key: ${TENCENT_MAP_KEY:LJYBZ-HCQCV-N37PU-5FIOX-QFA26-FPB6U} diff --git a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml index 951c391e..4c399e1e 100644 --- a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml +++ b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml @@ -359,6 +359,7 @@ ) order by s.updated_at desc, s.id desc + limit #{limit} @@ -799,40 +801,41 @@ and exists (select 1 from sys_tenant_user tu where tu.user_id = u.user_id and tu.tenant_id = #{tenantId} and coalesce(tu.is_deleted, 0) = 0) ), customer_stats as ( - select owner_user_id, count(1)::bigint as customer_count - from crm_customer - where created_at::date between #{startDate} and #{endDate} - group by owner_user_id + select c.owner_user_id, count(1)::bigint as customer_count + from crm_customer c + where c.created_at::date between #{startDate} and #{endDate} + group by c.owner_user_id ), opportunity_stats as ( select - owner_user_id, + o.owner_user_id, count(1)::bigint as opportunity_count, coalesce(sum(amount), 0) as opportunity_amount, count(case when coalesce(status, '') = 'won' then 1 end)::bigint as won_count, coalesce(sum(case when coalesce(status, '') = 'won' then amount else 0 end), 0) as won_amount - from crm_opportunity - where created_at::date between #{startDate} and #{endDate} - group by owner_user_id + from crm_opportunity o + where o.created_at::date between #{startDate} and #{endDate} + group by o.owner_user_id ), followup_stats as ( - select followup_user_id as user_id, count(1)::bigint as followup_count - from crm_opportunity_followup - where followup_time::date between #{startDate} and #{endDate} - group by followup_user_id + select f.followup_user_id as user_id, count(1)::bigint as followup_count + from crm_opportunity_followup f + join crm_opportunity o on o.id = f.opportunity_id + where f.followup_time::date between #{startDate} and #{endDate} + group by f.followup_user_id ), report_stats as ( - select user_id, count(1)::bigint as report_count - from work_daily_report - where report_date between #{startDate} and #{endDate} - and coalesce(status, 'submitted') in ('submitted', 'read', 'reviewed') - group by user_id + select r.user_id, count(1)::bigint as report_count + from work_daily_report r + where r.report_date between #{startDate} and #{endDate} + and coalesce(r.status, 'submitted') in ('submitted', 'read', 'reviewed') + group by r.user_id ), checkin_stats as ( - select user_id, count(1)::bigint as checkin_count - from work_checkin - where checkin_date between #{startDate} and #{endDate} - group by user_id + select ck.user_id, count(1)::bigint as checkin_count + from work_checkin ck + where ck.checkin_date between #{startDate} and #{endDate} + group by ck.user_id ) select u.user_id as "ownerUserId", @@ -1501,7 +1504,12 @@ left(coalesce(f.next_action, ''), 300) as "nextAction", f.followup_user_id as "userId", coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "userName", - f.followup_time as "followupTime" + f.followup_time as "followupTime", + 'OPPORTUNITY' as "scopeResourceType", + o.owner_user_id as "scopeOwnerUserId", + o.project_ownership_location as "scopeAreaCode", + o.pre_sales_id as "scopePreSalesId", + o.pre_sales_name as "scopePreSalesName" from crm_opportunity_followup f join crm_opportunity o on o.id = f.opportunity_id left join sys_user u on u.user_id = f.followup_user_id and coalesce(u.is_deleted, 0) = 0 @@ -1530,7 +1538,12 @@ left(coalesce(f.next_action, f.next_plan, ''), 300) as "nextAction", f.followup_user_id as "userId", coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as "userName", - f.followup_time as "followupTime" + f.followup_time as "followupTime", + 'EXPANSION' as "scopeResourceType", + case when f.biz_type = 'sales' then s.owner_user_id else c.owner_user_id end as "scopeOwnerUserId", + null::varchar as "scopeAreaCode", + null::bigint as "scopePreSalesId", + null::varchar as "scopePreSalesName" from crm_expansion_followup f left join crm_sales_expansion s on s.id = f.biz_id and f.biz_type = 'sales' left join crm_channel_expansion c on c.id = f.biz_id and f.biz_type = 'channel' diff --git a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml index 128f8529..963d6a54 100644 --- a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml +++ b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml @@ -258,6 +258,9 @@ and operator_dict.status = 1 and coalesce(operator_dict.is_deleted, 0) = 0 where 1 = 1 + + and coalesce(o.archived, false) = #{archived} + and ( o.opportunity_name ilike concat('%', #{keyword}, '%') @@ -276,6 +279,9 @@ order by coalesce(o.updated_at, o.created_at) desc, o.id desc + + limit #{limit} +