mcp权限添加、界面加载添加懒加载、docker镜像打包配置文件

main
kangwenjing 2026-07-21 15:25:23 +08:00
parent 5da2565298
commit f3966a45ed
50 changed files with 1893 additions and 236 deletions

View File

@ -0,0 +1,11 @@
target/*
!target/unis-crm-backend-1.0.0-SNAPSHOT.jar
.git
.gitignore
.idea
.vscode
*.md
*.log
.DS_Store
build
sql

19
backend/Dockerfile 100644
View File

@ -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"]

View File

@ -20,6 +20,7 @@
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.6</mybatis-plus.version>
<minio.version>8.5.17</minio.version>
</properties>
<dependencies>
@ -53,6 +54,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>${minio.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@ -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 {

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -54,8 +54,10 @@ public class ExpansionController {
@GetMapping("/overview")
public ApiResponse<ExpansionOverviewDTO> 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")

View File

@ -42,8 +42,11 @@ public class OpportunityController {
public ApiResponse<OpportunityOverviewDTO> 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}")

View File

@ -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<String, Object> selectUserProfile(@Param("userId") Long userId);
List<Map<String, Object>> selectUserRoles(@Param("userId") Long userId);
List<Map<String, Object>> selectUserRoles(@Param("userId") Long userId, @Param("tenantId") Long tenantId);
List<Map<String, Object>> selectUserOrgs(@Param("userId") Long userId);
List<Map<String, Object>> selectUserOrgs(@Param("userId") Long userId, @Param("tenantId") Long tenantId);
@DataScope(tableAlias = "r", ownerColumn = "user_id")
List<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> dashboardWonOpportunityMetric(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId);
@DataScope(tableAlias = "r", ownerColumn = "user_id")
Map<String, Object> dashboardDailyReportMetric(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId);
@DataScope(tableAlias = "c", ownerColumn = "user_id")
Map<String, Object> dashboardCheckinMetric(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate,
@Param("ownerUserId") Long ownerUserId,
@Param("tenantId") Long tenantId);
@DataScope(tableAlias = "u", ownerColumn = "user_id")
List<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> selectWorkTodayStatus(
@Param("targetUserId") Long targetUserId,
@Param("tenantId") Long tenantId,
@Param("queryDate") LocalDate queryDate);
@DataScope(tableAlias = "t", ownerColumn = "user_id")
List<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> selectCustomerDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
Map<String, Object> selectOpportunityDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
@DataScope(tableAlias = "r", ownerColumn = "user_id")
Map<String, Object> selectWorkReportDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
@DataScope(tableAlias = "c", ownerColumn = "user_id")
Map<String, Object> selectCheckinDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
Map<String, Object> selectSalesExpansionDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
Map<String, Object> selectChannelExpansionDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
@DataScope(tableAlias = "t", ownerColumn = "user_id")
Map<String, Object> selectTodoDetail(@Param("id") Long id, @Param("tenantId") Long tenantId);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
List<Map<String, Object>> selectCustomerOpportunities(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
List<Map<String, Object>> selectOpportunityFollowups(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
@DataScope(tableAlias = "r", ownerColumn = "user_id")
List<Map<String, Object>> selectWorkReportComments(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
List<Map<String, Object>> selectSalesExpansionFollowups(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
List<Map<String, Object>> selectSalesExpansionOpportunities(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
List<Map<String, Object>> selectChannelExpansionContacts(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
List<Map<String, Object>> selectChannelExpansionFollowups(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
List<Map<String, Object>> selectChannelExpansionOpportunities(@Param("id") Long id, @Param("tenantId") Long tenantId, @Param("limit") int limit);
List<Map<String, Object>> selectDictTypes(@Param("keyword") String keyword, @Param("limit") int limit, @Param("offset") int offset);

View File

@ -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<String, QueryPolicy> POLICIES = buildPolicies();
private final ObjectProvider<CrmDataVisibilityService> visibilityServiceProvider;
public McpDataPermissionInterceptor(ObjectProvider<CrmDataVisibilityService> 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<String, DataVisibility> 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<String> 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<String, DataVisibility> 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<String, String> 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<String> 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<DataVisibility> visibilities) {
if (visibilities.stream().anyMatch(value -> value != null && value.allDataAccess())) {
return "1 = 1";
}
List<Long> 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<String> 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<String> currentUserNames(LoginUser loginUser) {
return Stream.of(loginUser.getDisplayName(), loginUser.getUsername())
.filter(value -> value != null && !value.isBlank())
.distinct()
.toList();
}
static Set<String> policyMethodNames() {
java.util.LinkedHashSet<String> methodNames = new java.util.LinkedHashSet<>(POLICIES.keySet());
methodNames.add("searchFollowups");
methodNames.add("salesPerformance");
return Set.copyOf(methodNames);
}
private String buildOwnerCondition(String ownerColumn, List<Long> ownerUserIds) {
String ids = normalizeIds(ownerUserIds);
return ids.isEmpty() ? "1 = 0" : ownerColumn + " in (" + ids + ")";
}
private String buildOwnerAreaCondition(QueryPolicy policy, List<OwnerAreaRule> rules) {
if (rules == null || rules.isEmpty()) {
return "1 = 0";
}
List<String> 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<Long> 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<String, QueryPolicy> buildPolicies() {
Map<String, QueryPolicy> 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<String, QueryPolicy> 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) {
}
}

View File

@ -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<Map<String, Object>> 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<Map<String, Object>> dashboardSummary(QueryContext context) {
List<Map<String, Object>> rows = new ArrayList<>();
addRow(rows, llmMcpMapper.dashboardCustomerMetric(context.startDate(), context.endDate(), context.ownerUserId(), context.tenantId()));

View File

@ -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;
}
}

View File

@ -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<String, Object> integerProperty(String description) {
return property("integer", description);
}

View File

@ -30,18 +30,20 @@ public interface ExpansionMapper {
String selectNextChannelCode();
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
List<SalesExpansionItemDTO> selectSalesExpansions(@Param("userId") Long userId, @Param("keyword") String keyword);
List<SalesExpansionItemDTO> selectSalesExpansions(@Param("userId") Long userId, @Param("keyword") String keyword, @Param("limit") Integer limit);
List<SalesExpansionItemDTO> selectSalesExpansionsByOwnerUserIds(
@Param("ownerUserIds") List<Long> ownerUserIds,
@Param("keyword") String keyword);
@Param("keyword") String keyword,
@Param("limit") Integer limit);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
List<ChannelExpansionItemDTO> selectChannelExpansions(@Param("userId") Long userId, @Param("keyword") String keyword);
List<ChannelExpansionItemDTO> selectChannelExpansions(@Param("userId") Long userId, @Param("keyword") String keyword, @Param("limit") Integer limit);
List<ChannelExpansionItemDTO> selectChannelExpansionsByOwnerUserIds(
@Param("ownerUserIds") List<Long> ownerUserIds,
@Param("keyword") String keyword);
@Param("keyword") String keyword,
@Param("limit") Integer limit);
List<SalesExpansionItemDTO> selectSalesExpansionsForTenant(
@Param("tenantId") Long tenantId,

View File

@ -43,7 +43,9 @@ public interface OpportunityMapper {
@Param("visibleOwnerUserIds") List<Long> visibleOwnerUserIds,
@Param("visibleOwnerAreaRules") List<OwnerAreaRule> visibleOwnerAreaRules,
@Param("preSalesUserId") Long preSalesUserId,
@Param("preSalesUserNames") List<String> preSalesUserNames);
@Param("preSalesUserNames") List<String> preSalesUserNames,
@Param("limit") Integer limit,
@Param("archived") Boolean archived);
OpportunityItemDTO selectOpportunityDetail(
@Param("userId") Long userId,

View File

@ -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);

View File

@ -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);

View File

@ -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);
}

View File

@ -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<OmsPreSalesOptionDTO> getOmsPreSalesOptions(Long userId);

View File

@ -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<Long> extraVisibleOwnerUserIds = resolveExtraVisibleOwnerUserIds(userId);
List<SalesExpansionItemDTO> 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<ChannelExpansionItemDTO> 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);

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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<String, String> 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) {

View File

@ -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}

View File

@ -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}

View File

@ -359,6 +359,7 @@
)
</if>
order by s.updated_at desc, s.id desc
<if test="limit != null and limit > 0">limit #{limit}</if>
</select>
<select id="selectSalesExpansionsByOwnerUserIds" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
@ -372,6 +373,7 @@
</foreach>
<include refid="salesExpansionKeywordFilter"/>
order by s.updated_at desc, s.id desc
<if test="limit != null and limit > 0">limit #{limit}</if>
</select>
<select id="selectChannelExpansions" resultType="com.unis.crm.dto.expansion.ChannelExpansionItemDTO">
@ -507,6 +509,7 @@
)
</if>
order by c.updated_at desc, c.id desc
<if test="limit != null and limit > 0">limit #{limit}</if>
</select>
<select id="selectChannelExpansionsByOwnerUserIds" resultType="com.unis.crm.dto.expansion.ChannelExpansionItemDTO">
@ -520,6 +523,7 @@
</foreach>
<include refid="channelExpansionKeywordFilter"/>
order by c.updated_at desc, c.id desc
<if test="limit != null and limit > 0">limit #{limit}</if>
</select>
<select id="selectSalesExpansionsForTenant" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">

View File

@ -28,6 +28,7 @@
from sys_user_role ur
join sys_role r on r.role_id = ur.role_id
where ur.user_id = #{userId}
and ur.tenant_id = #{tenantId}
and coalesce(ur.is_deleted, 0) = 0
and coalesce(r.is_deleted, 0) = 0
order by r.role_id asc
@ -41,6 +42,7 @@
from sys_tenant_user tu
left join sys_org o on o.id = tu.org_id and coalesce(o.is_deleted, 0) = 0
where tu.user_id = #{userId}
and tu.tenant_id = #{tenantId}
and coalesce(tu.is_deleted, 0) = 0
order by tu.id asc
</select>
@ -799,40 +801,41 @@
<if test="tenantId != null">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)</if>
),
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'

View File

@ -258,6 +258,9 @@
and operator_dict.status = 1
and coalesce(operator_dict.is_deleted, 0) = 0
where 1 = 1
<if test="archived != null">
and coalesce(o.archived, false) = #{archived}
</if>
<if test="keyword != null and keyword != ''">
and (
o.opportunity_name ilike concat('%', #{keyword}, '%')
@ -276,6 +279,9 @@
</if>
<include refid="opportunityVisibilityCondition"/>
order by coalesce(o.updated_at, o.created_at) desc, o.id desc
<if test="limit != null and limit > 0">
limit #{limit}
</if>
</select>
<select id="selectOpportunityDetail" resultType="com.unis.crm.dto.opportunity.OpportunityItemDTO">

View File

@ -0,0 +1,118 @@
package com.unis.crm.llm.security;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.unisbase.auth.McpBotAuthenticationFilter;
import com.unisbase.llm.auth.McpAuthenticationException;
import com.unisbase.llm.auth.McpBotAuthService;
import com.unisbase.security.LoginUser;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
class McpBotAuthenticationFilterTest {
private McpBotAuthService authService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
authService = mock(McpBotAuthService.class);
mockMvc = MockMvcBuilders.standaloneSetup(new TestController())
.addFilters(new McpBotAuthenticationFilter(authService))
.build();
}
@AfterEach
void tearDown() {
org.springframework.security.core.context.SecurityContextHolder.clearContext();
}
@Test
void missingCredentialShouldReturnHttp401() throws Exception {
when(authService.authenticate(isNull(), isNull(), isNull(), anyString()))
.thenThrow(new McpAuthenticationException("X-Bot-Id and X-Bot-Secret are required"));
mockMvc.perform(post("/mcp").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isUnauthorized())
.andExpect(header().string("WWW-Authenticate", org.hamcrest.Matchers.containsString("invalid_request")))
.andExpect(content().string(org.hamcrest.Matchers.containsString("X-Bot-Id and X-Bot-Secret are required")));
}
@Test
void invalidCredentialShouldReturnHttp401() throws Exception {
when(authService.authenticate(
org.mockito.ArgumentMatchers.eq("bad-id"),
org.mockito.ArgumentMatchers.eq("bad-secret"),
isNull(),
anyString()))
.thenThrow(new McpAuthenticationException("Invalid bot credentials"));
mockMvc.perform(post("/mcp")
.header("X-Bot-Id", "bad-id")
.header("X-Bot-Secret", "bad-secret")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized())
.andExpect(header().string("WWW-Authenticate", org.hamcrest.Matchers.containsString("invalid_client")));
}
@Test
void validCredentialShouldReachMcpEndpoint() throws Exception {
LoginUser loginUser = new LoginUser(9L, 100L, "user", false, false, Set.of());
when(authService.authenticate(
org.mockito.ArgumentMatchers.eq("bot-id"),
org.mockito.ArgumentMatchers.eq("bot-secret"),
org.mockito.ArgumentMatchers.eq("100"),
anyString()))
.thenReturn(loginUser);
mockMvc.perform(post("/mcp")
.header("X-Bot-Id", "bot-id")
.header("X-Bot-Secret", "bot-secret")
.header("X-Tenant-Id", "100")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isOk())
.andExpect(content().string("ok"));
verify(authService).authenticate("bot-id", "bot-secret", "100", "127.0.0.1");
}
@Test
void nonMcpRequestShouldNotInvokeBotAuthentication() throws Exception {
mockMvc.perform(post("/health"))
.andExpect(status().isOk());
verifyNoInteractions(authService);
}
@RestController
private static final class TestController {
@PostMapping("/mcp")
String mcp() {
return "ok";
}
@PostMapping("/health")
String health() {
return "ok";
}
}
}

View File

@ -0,0 +1,242 @@
package com.unis.crm.llm.security;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
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.unis.crm.llm.mapper.LlmMcpMapper;
import com.unisbase.security.LoginUser;
import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.Arrays;
import java.time.LocalDate;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
class McpDataPermissionInterceptorTest {
private McpDataPermissionInterceptor interceptor;
private LoginUser loginUser;
@BeforeEach
void setUp() {
@SuppressWarnings("unchecked")
ObjectProvider<CrmDataVisibilityService> provider = mock(ObjectProvider.class);
interceptor = new McpDataPermissionInterceptor(provider);
loginUser = new LoginUser(9L, 100L, "zhangsan", false, false, Set.of());
loginUser.setDisplayName("张三");
}
@Test
void opportunityConditionShouldMergeOwnerAreaAndPreSalesVisibility() {
DataVisibility visibility = new DataVisibility(false, List.of(2L, 3L), List.of(
new OwnerAreaRule(2L, true, List.of()),
new OwnerAreaRule(3L, false, List.of("110000", "310000"))));
String condition = interceptor.buildVisibilityCondition("searchOpportunities", visibility, loginUser);
assertTrue(condition.contains("o.owner_user_id = 2"));
assertTrue(condition.contains("o.owner_user_id = 3"));
assertTrue(condition.contains("o.project_ownership_location in ('110000','310000')"));
assertTrue(condition.contains("o.pre_sales_id = 9"));
assertTrue(condition.contains("'张三','zhangsan'"));
}
@Test
void regularConditionShouldUseMergedVisibleOwners() {
DataVisibility visibility = new DataVisibility(false, List.of(9L, 20L, 20L), List.of());
String condition = interceptor.buildVisibilityCondition("searchWorkReports", visibility, loginUser);
assertEquals("r.user_id in (9,20)", condition);
}
@Test
void emptyVisibilityShouldFailClosed() {
DataVisibility visibility = new DataVisibility(false, List.of(), List.of());
assertEquals("1 = 0", interceptor.buildVisibilityCondition("searchCustomers", visibility, loginUser));
}
@Test
void allAccessShouldNotAddOwnerRestriction() {
DataVisibility visibility = new DataVisibility(true, List.of(), List.of());
assertEquals("1 = 1", interceptor.buildVisibilityCondition("searchCustomers", visibility, loginUser));
}
@Test
void rewriteSqlShouldAppendConditionToExistingWhere() {
String sql = interceptor.rewriteSql(
"select o.id from crm_opportunity o where o.archived = false order by o.id desc",
"o.owner_user_id in (9,20)");
assertTrue(sql.contains("o.archived = false"));
assertTrue(sql.contains("o.owner_user_id IN (9, 20)"));
}
@Test
void rewriteSqlShouldSupportCteOuterScope() {
String sql = interceptor.rewriteSql(
"with expected_rows as (select u.user_id from sys_user u) "
+ "select e.user_id from expected_rows e where e.user_id > 0",
"e.user_id in (9,20)");
assertTrue(sql.contains("e.user_id IN (9, 20)"));
}
@Test
void rewriteSqlShouldSupportQuotedCteProjection() {
String sql = interceptor.rewriteSql(
"with followup_rows as (select f.followup_user_id as \"userId\" from crm_followup f) "
+ "select \"userId\" from followup_rows",
"\"userId\" in (9,20)");
assertTrue(sql.contains("\"userId\" IN (9, 20)"));
}
@Test
void followupConditionShouldUseParentResourcePermissions() {
DataVisibility opportunityVisibility = new DataVisibility(false, List.of(2L), List.of(
new OwnerAreaRule(2L, false, List.of("110000"))));
DataVisibility expansionVisibility = new DataVisibility(false, List.of(3L), List.of());
String condition = interceptor.buildFollowupVisibilityCondition(
opportunityVisibility, expansionVisibility, loginUser);
assertTrue(condition.contains("\"scopeResourceType\" = 'OPPORTUNITY'"));
assertTrue(condition.contains("\"scopeOwnerUserId\" = 2"));
assertTrue(condition.contains("\"scopeAreaCode\" in ('110000')"));
assertTrue(condition.contains("\"scopePreSalesId\" = 9"));
assertTrue(condition.contains("\"scopeResourceType\" = 'EXPANSION'"));
assertTrue(condition.contains("\"scopeOwnerUserId\" in (3)"));
}
@Test
void unknownPolicyShouldBeRejected() {
DataVisibility visibility = new DataVisibility(false, List.of(9L), List.of());
assertThrows(BusinessException.class,
() -> interceptor.buildVisibilityCondition("unknownQuery", visibility, loginUser));
}
@Test
void everyProtectedMapperStatementShouldProduceRewritableSql() throws Exception {
Configuration configuration = new Configuration();
Path mapperPath = Path.of("src/main/resources/mapper/llm/LlmMcpMapper.xml");
try (InputStream inputStream = Files.newInputStream(mapperPath)) {
new XMLMapperBuilder(inputStream, configuration, mapperPath.toString(), configuration.getSqlFragments()).parse();
}
Map<String, Object> parameters = allParameters();
DataVisibility visibility = new DataVisibility(false, List.of(9L, 20L), List.of(
new OwnerAreaRule(9L, true, List.of()),
new OwnerAreaRule(20L, false, List.of("110000"))));
for (String methodName : McpDataPermissionInterceptor.policyMethodNames()) {
MappedStatement statement = configuration.getMappedStatement(
"com.unis.crm.llm.mapper.LlmMcpMapper." + methodName);
BoundSql boundSql = statement.getBoundSql(parameters);
String rewritten;
if ("salesPerformance".equals(methodName)) {
rewritten = interceptor.rewriteSalesPerformanceSql(boundSql.getSql(), Map.of(
CrmDataVisibilityService.RESOURCE_CUSTOMER, visibility,
CrmDataVisibilityService.RESOURCE_OPPORTUNITY, visibility,
CrmDataVisibilityService.RESOURCE_DAILY_REPORT, visibility,
CrmDataVisibilityService.RESOURCE_CHECKIN, visibility));
} else {
String condition = "searchFollowups".equals(methodName)
? interceptor.buildFollowupVisibilityCondition(visibility, visibility, loginUser)
: interceptor.buildVisibilityCondition(methodName, visibility, loginUser);
rewritten = interceptor.rewriteSql(boundSql.getSql(), condition);
}
assertTrue(rewritten.toLowerCase().contains("where"), methodName + " should contain a permission predicate");
}
}
@Test
void everyMcpMapperMethodShouldBeProtectedOrExplicitlyTenantSafe() {
Set<String> tenantSafeMethods = Set.of(
"selectUserProfile",
"selectUserRoles",
"selectUserOrgs",
"selectDictTypes",
"selectDictOptions",
"searchOrganizations",
"searchRoles");
Set<String> protectedMethods = McpDataPermissionInterceptor.policyMethodNames();
Arrays.stream(LlmMcpMapper.class.getDeclaredMethods()).forEach(method ->
assertTrue(
protectedMethods.contains(method.getName()) || tenantSafeMethods.contains(method.getName()),
method.getName() + " must have an MCP permission policy"));
}
@Test
void salesPerformanceShouldApplyEachResourceScopeInsideItsOwnCte() throws Exception {
Configuration configuration = new Configuration();
Path mapperPath = Path.of("src/main/resources/mapper/llm/LlmMcpMapper.xml");
try (InputStream inputStream = Files.newInputStream(mapperPath)) {
new XMLMapperBuilder(inputStream, configuration, mapperPath.toString(), configuration.getSqlFragments()).parse();
}
BoundSql boundSql = configuration.getMappedStatement(
"com.unis.crm.llm.mapper.LlmMcpMapper.salesPerformance")
.getBoundSql(allParameters());
String rewritten = interceptor.rewriteSalesPerformanceSql(boundSql.getSql(), Map.of(
CrmDataVisibilityService.RESOURCE_CUSTOMER,
new DataVisibility(false, List.of(11L), List.of(new OwnerAreaRule(11L, true, List.of()))),
CrmDataVisibilityService.RESOURCE_OPPORTUNITY,
new DataVisibility(false, List.of(12L), List.of(new OwnerAreaRule(12L, false, List.of("110000")))),
CrmDataVisibilityService.RESOURCE_DAILY_REPORT,
new DataVisibility(false, List.of(13L), List.of(new OwnerAreaRule(13L, true, List.of()))),
CrmDataVisibilityService.RESOURCE_CHECKIN,
new DataVisibility(false, List.of(14L), List.of(new OwnerAreaRule(14L, true, List.of())))));
assertTrue(rewritten.contains("c.owner_user_id IN (11)"));
assertTrue(rewritten.contains("o.owner_user_id = 12"));
assertTrue(rewritten.contains("o.project_ownership_location IN ('110000')"));
assertTrue(rewritten.contains("r.user_id IN (13)"));
assertTrue(rewritten.contains("ck.user_id IN (14)"));
assertTrue(rewritten.contains("u.user_id IN (11, 12, 13, 14)"));
}
private Map<String, Object> allParameters() {
Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("startDate", LocalDate.of(2026, 7, 1));
parameters.put("endDate", LocalDate.of(2026, 7, 20));
parameters.put("queryDate", LocalDate.of(2026, 7, 20));
parameters.put("targetUserId", 9L);
parameters.put("ownerUserId", null);
parameters.put("tenantId", 100L);
parameters.put("keyword", "");
parameters.put("status", "");
parameters.put("stage", "");
parameters.put("source", "");
parameters.put("industry", "");
parameters.put("priority", "");
parameters.put("bizType", "all");
parameters.put("intentLevel", "");
parameters.put("includeArchived", false);
parameters.put("groupBy", "day");
parameters.put("bucket", "day");
parameters.put("id", 1L);
parameters.put("limit", 10);
parameters.put("offset", 0);
return parameters;
}
}

View File

@ -0,0 +1,63 @@
package com.unis.crm.llm.tools;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unis.crm.common.BusinessException;
import com.unis.crm.llm.mapper.LlmMcpMapper;
import com.unisbase.security.LoginUser;
import com.unisbase.security.PermissionService;
import com.unisbase.security.SpringSecurityTenantProvider;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
class CrmReportQueryToolProviderTest {
private PermissionService permissionService;
private CrmReportQueryToolProvider provider;
@BeforeEach
void setUp() {
permissionService = mock(PermissionService.class);
SpringSecurityTenantProvider tenantProvider = mock(SpringSecurityTenantProvider.class);
when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
provider = new CrmReportQueryToolProvider(
new ObjectMapper(), mock(LlmMcpMapper.class), tenantProvider, permissionService);
LoginUser loginUser = new LoginUser(9L, 100L, "user", false, false, Set.of());
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()));
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
void dashboardSummaryShouldRequireStatsCardPermission() {
when(permissionService.hasPermi("dashboard_stats_card:view")).thenReturn(false);
assertThrows(BusinessException.class, () -> provider.buildTool().getHandler().apply(Map.of(
"reportType", "dashboard_summary")));
verify(permissionService).hasPermi("dashboard_stats_card:view");
}
@Test
void analyticsReportShouldRequireAnalyticsCardPermission() {
when(permissionService.hasPermi("dashboard_analytics_card:view")).thenReturn(false);
assertThrows(BusinessException.class, () -> provider.buildTool().getHandler().apply(Map.of(
"reportType", "opportunity_funnel")));
verify(permissionService).hasPermi("dashboard_analytics_card:view");
}
}

View File

@ -0,0 +1,83 @@
package com.unis.crm.llm.tools.support;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unis.crm.common.BusinessException;
import com.unisbase.llm.McpTool;
import com.unisbase.security.LoginUser;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
class PermissionedMcpToolProviderTest {
private final TestProvider provider = new TestProvider();
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
void anonymousInvocationShouldBeRejected() {
McpTool tool = provider.buildTool();
assertThrows(BusinessException.class, () -> tool.getHandler().apply(Map.of()));
}
@Test
void tenantZeroInvocationShouldBeRejected() {
authenticate(new LoginUser(9L, 0L, "admin", true, false, Set.of()));
McpTool tool = provider.buildTool();
assertThrows(BusinessException.class, () -> tool.getHandler().apply(Map.of()));
}
@Test
void authenticatedTenantInvocationShouldExecuteTool() {
authenticate(new LoginUser(9L, 100L, "user", false, false, Set.of()));
Object result = provider.buildTool().getHandler().apply(Map.of());
assertEquals("ok", result);
}
private void authenticate(LoginUser loginUser) {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()));
}
private static final class TestProvider extends PermissionedMcpToolProvider {
private TestProvider() {
super(new ObjectMapper());
}
@Override
protected String getToolName() {
return "test";
}
@Override
protected String getToolDescription() {
return "test";
}
@Override
protected Map<String, Object> buildInputSchema() {
return objectSchema(new LinkedHashMap<>());
}
@Override
protected Object handle(Map<String, Object> params) {
return "ok";
}
}
}

View File

@ -60,15 +60,15 @@ class ExpansionServiceImplTest {
when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
when(crmDataVisibilityService.resolveVisibility(29L, 100L, CrmDataVisibilityService.RESOURCE_EXPANSION))
.thenReturn(new DataVisibility(false, List.of(29L, 36L), List.of()));
when(expansionMapper.selectSalesExpansions(29L, null)).thenReturn(List.of(ownedItem));
when(expansionMapper.selectSalesExpansionsByOwnerUserIds(List.of(36L), null)).thenReturn(List.of(grantedItem));
when(expansionMapper.selectChannelExpansions(29L, null)).thenReturn(List.of());
when(expansionMapper.selectChannelExpansionsByOwnerUserIds(List.of(36L), null)).thenReturn(List.of());
when(expansionMapper.selectSalesExpansions(29L, null, null)).thenReturn(List.of(ownedItem));
when(expansionMapper.selectSalesExpansionsByOwnerUserIds(List.of(36L), null, null)).thenReturn(List.of(grantedItem));
when(expansionMapper.selectChannelExpansions(29L, null, null)).thenReturn(List.of());
when(expansionMapper.selectChannelExpansionsByOwnerUserIds(List.of(36L), null, null)).thenReturn(List.of());
ExpansionOverviewDTO result = expansionService.getOverview(29L, null);
assertEquals(2, result.getSalesItems().size());
assertEquals(12L, result.getSalesItems().get(1).getId());
verify(expansionMapper).selectSalesExpansionsByOwnerUserIds(List.of(36L), null);
verify(expansionMapper).selectSalesExpansionsByOwnerUserIds(List.of(36L), null, null);
}
@Test
@ -166,8 +166,8 @@ class ExpansionServiceImplTest {
assertEquals(0, result.getChannelItems().size());
verify(expansionMapper).selectSalesExpansionsForTenant(100L, null, 20);
verify(expansionMapper).selectChannelExpansionsForTenant(100L, null, 20);
verify(expansionMapper, never()).selectSalesExpansions(any(), any());
verify(expansionMapper, never()).selectChannelExpansions(any(), any());
verify(expansionMapper, never()).selectSalesExpansions(any(), any(), any());
verify(expansionMapper, never()).selectChannelExpansions(any(), any(), any());
}
@Test

View File

@ -0,0 +1,41 @@
package com.unis.crm.service.impl;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.unis.crm.config.MinioProperties;
import io.minio.GetObjectResponse;
import io.minio.MinioClient;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import okhttp3.Headers;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.Resource;
class MinioFileStorageServiceTest {
@Test
void contentLengthDoesNotConsumeObjectStream() throws Exception {
byte[] content = "photo-content".getBytes(StandardCharsets.UTF_8);
Headers headers = new Headers.Builder()
.add("Content-Length", String.valueOf(content.length))
.build();
GetObjectResponse response = new GetObjectResponse(
headers, "bucket", "region", "checkin-photos/photo.jpg", new ByteArrayInputStream(content));
MinioClient minioClient = mock(MinioClient.class);
when(minioClient.getObject(any())).thenReturn(response);
MinioProperties properties = new MinioProperties();
properties.setBucket("bucket");
properties.setBasePath("crm");
Resource resource = new MinioFileStorageService(minioClient, properties)
.load("checkin-photos/photo.jpg");
assertEquals("photo.jpg", resource.getFilename());
assertEquals(content.length, resource.contentLength());
assertArrayEquals(content, resource.getInputStream().readAllBytes());
}
}

View File

@ -258,7 +258,9 @@ class OpportunityServiceImplTest {
eq(List.of(2L, 3L)),
eq(ownerAreaRules),
eq(9L),
eq(List.of("张售前", "presales.zhang")))).thenReturn(List.of(item));
eq(List.of("张售前", "presales.zhang")),
eq(null),
eq(null))).thenReturn(List.of(item));
when(opportunityMapper.selectOpportunityFollowUps(
eq(9L),
eq(List.of(10L)),

View File

@ -24,6 +24,7 @@ import java.time.LocalDate;
import com.unis.crm.mapper.ProfileMapper;
import com.unis.crm.mapper.WorkMapper;
import com.unis.crm.service.CrmDataVisibilityService;
import com.unis.crm.service.FileStorageService;
import com.unis.crm.service.CrmDataVisibilityService.DataVisibility;
import com.unis.crm.service.ReportReminderService;
import com.unisbase.service.SysPermissionService;
@ -71,6 +72,9 @@ class WorkServiceImplTest {
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private FileStorageService fileStorageService;
private WorkServiceImpl workService;
private WorkReportProperties workReportProperties;
@ -89,7 +93,7 @@ class WorkServiceImplTest {
crmDataVisibilityService,
sysPermissionService,
jdbcTemplate,
"build/test-uploads",
fileStorageService,
"");
}
@ -348,6 +352,9 @@ class WorkServiceImplTest {
assertEquals("project-pack.zip", attachment.getName());
assertEquals("application/zip", attachment.getContentType());
assertEquals(4L, attachment.getSize());
verify(fileStorageService).upload(
org.mockito.ArgumentMatchers.startsWith("work-report-attachments/17-"),
eq(file));
}
@Test

42
docker-compose.yml 100644
View File

@ -0,0 +1,42 @@
services:
crm:
container_name: unis-crm-backend
platform: linux/amd64
build:
context: ./backend
dockerfile: Dockerfile
restart: always
ports:
- "9001:8080"
volumes:
- /home/application/crm/logs:/app/logs
environment:
- TZ=Asia/Shanghai
- SPRING_PROFILES_ACTIVE=prod
frontend:
container_name: unis-crm-frontend
platform: linux/amd64
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "9002:80"
environment:
BACKEND_HOST: crm
BACKEND_PORT: "8080"
restart: unless-stopped
system:
container_name: unis-crm-system
platform: linux/amd64
build:
context: ./frontend1
dockerfile: Dockerfile
ports:
- "9003:80"
environment:
BACKEND_HOST: crm
BACKEND_PORT: "8080"
restart: unless-stopped

View File

@ -0,0 +1,11 @@
node_modules
dist
.git
.gitignore
.idea
.vscode
*.md
*.log
.DS_Store
.cert
.env*

View File

@ -0,0 +1,27 @@
# ============ 构建阶段 ============
FROM node:20-alpine AS builder
WORKDIR /app
# 先复制 package.json利用 Docker 缓存加速依赖安装
COPY package.json package-lock.json* ./
RUN npm ci --registry=https://registry.npmmirror.com
# 复制源码并构建
COPY . .
RUN npm run build
# ============ 运行阶段 ============
FROM nginx:1.27-alpine
COPY nginx/default.conf.template /etc/nginx/default.conf.template
COPY nginx/start.sh /etc/nginx/start.sh
# 从构建阶段复制 dist
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
RUN rm -f /etc/nginx/conf.d/default.conf && chmod +x /etc/nginx/start.sh
ENTRYPOINT ["/etc/nginx/start.sh"]

View File

@ -0,0 +1,55 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
resolver ${RESOLVER} valid=30s ipv6=off;
location / {
try_files $uri $uri/ /index.html;
}
# The system frontend prefixes canonical /sys paths with /api.
location /api/sys/ {
set $backend_host __BACKEND_HOST__;
set $backend_port __BACKEND_PORT__;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://$backend_host:$backend_port;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# CRM controllers already use /api as part of their canonical path.
location /api/ {
set $backend_host __BACKEND_HOST__;
set $backend_port __BACKEND_PORT__;
proxy_pass http://$backend_host:$backend_port;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /sys/ {
set $backend_host __BACKEND_HOST__;
set $backend_port __BACKEND_PORT__;
proxy_pass http://$backend_host:$backend_port;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}

View File

@ -0,0 +1,13 @@
#!/bin/sh
RESOLVER=$(cat /etc/resolv.conf | grep nameserver | awk '{print $2}' | head -1)
BACKEND_HOST=${BACKEND_HOST:-crm-backend.default.svc.cluster.local}
BACKEND_PORT=${BACKEND_PORT:-8080}
sed \
-e "s|\${RESOLVER}|$RESOLVER|g" \
-e "s|__BACKEND_HOST__|$BACKEND_HOST|g" \
-e "s|__BACKEND_PORT__|$BACKEND_PORT|g" \
/etc/nginx/default.conf.template > /etc/nginx/conf.d/default.conf
exec nginx -g "daemon off;"

View File

@ -1607,7 +1607,7 @@ export async function saveWorkDailyReport(payload: CreateWorkDailyReportPayload)
}, true);
}
export async function getOpportunityOverview(keyword?: string, stage?: string) {
export async function getOpportunityOverview(keyword?: string, stage?: string, includeDetails = true, limit?: number, archived?: boolean) {
const params = new URLSearchParams();
if (keyword && keyword.trim()) {
params.set("keyword", keyword.trim());
@ -1615,6 +1615,13 @@ export async function getOpportunityOverview(keyword?: string, stage?: string) {
if (stage && stage.trim() && stage !== "全部") {
params.set("stage", stage.trim());
}
params.set("includeDetails", String(includeDetails));
if (limit !== undefined) {
params.set("limit", String(limit));
}
if (archived !== undefined) {
params.set("archived", String(archived));
}
const query = params.toString();
return request<OpportunityOverview>(`/api/opportunities/overview${query ? `?${query}` : ""}`, undefined, true);
}
@ -1659,11 +1666,15 @@ export async function createOpportunityFollowUp(opportunityId: CrmId, payload: C
}, true);
}
export async function getExpansionOverview(keyword?: string) {
export async function getExpansionOverview(keyword?: string, includeDetails = true, limit?: number) {
const params = new URLSearchParams();
if (keyword && keyword.trim()) {
params.set("keyword", keyword.trim());
}
params.set("includeDetails", String(includeDetails));
if (limit !== undefined) {
params.set("limit", String(limit));
}
const query = params.toString();
return request<ExpansionOverview>(`/api/expansion/overview${query ? `?${query}` : ""}`, undefined, true);
}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Search, Plus, Download, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { useLocation } from "react-router-dom";
@ -32,6 +32,7 @@ import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
import { cn } from "@/lib/utils";
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
const LIST_PAGE_SIZE = 10;
type ExpansionTab = "sales" | "channel";
type ExpansionLocationState = { tab?: ExpansionTab; selectedId?: CrmId } | null;
type ExpansionExportFilters = {
@ -1338,6 +1339,9 @@ export default function Expansion() {
const [keyword, setKeyword] = useState("");
const [salesData, setSalesData] = useState<SalesExpansionItem[]>([]);
const [channelData, setChannelData] = useState<ChannelExpansionItem[]>([]);
const [visibleItemCount, setVisibleItemCount] = useState(LIST_PAGE_SIZE);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const loadingMoreRef = useRef(false);
const [officeOptions, setOfficeOptions] = useState<ExpansionDictOption[]>([]);
const [industryOptions, setIndustryOptions] = useState<ExpansionDictOption[]>([]);
const [provinceOptions, setProvinceOptions] = useState<ExpansionDictOption[]>([]);
@ -1550,7 +1554,7 @@ export default function Expansion() {
async function loadExpansionData() {
try {
const data = await getExpansionOverview(keyword);
const data = await getExpansionOverview(keyword, false, LIST_PAGE_SIZE + 1);
if (cancelled) {
return;
}
@ -1574,8 +1578,61 @@ export default function Expansion() {
};
}, [keyword, refreshTick]);
useEffect(() => {
setVisibleItemCount(LIST_PAGE_SIZE);
}, [keyword, activeTab]);
const activeData = activeTab === "sales" ? salesData : channelData;
const hasMoreItems = visibleItemCount < activeData.length;
const loadMoreItems = async () => {
if (!hasMoreItems || loadingMoreRef.current) {
return;
}
loadingMoreRef.current = true;
const nextVisibleCount = visibleItemCount + LIST_PAGE_SIZE;
try {
const data = await getExpansionOverview(keyword, false, nextVisibleCount + 1);
setSalesData(dedupeExpansionItemsById(data.salesItems ?? []));
setChannelData(dedupeExpansionItemsById(data.channelItems ?? []));
setVisibleItemCount(nextVisibleCount);
} finally {
loadingMoreRef.current = false;
}
};
useEffect(() => {
if (!hasMoreItems) {
return;
}
const handleScroll = () => {
const loadMoreElement = loadMoreRef.current;
if (loadMoreElement && loadMoreElement.getBoundingClientRect().top <= window.innerHeight + 120) {
void loadMoreItems();
}
};
window.addEventListener("scroll", handleScroll, true);
return () => window.removeEventListener("scroll", handleScroll, true);
}, [hasMoreItems, visibleItemCount, activeData.length, keyword, activeTab]);
const followUpRecords: ExpansionFollowUp[] = selectedItem?.followUps ?? [];
const handleSelectItem = (item: ExpansionItem) => {
setSelectedItem(item);
void getExpansionOverview(keyword, true)
.then((data) => {
const detailedItem = item.type === "sales"
? (data.salesItems ?? []).find((candidate) => candidate.id === item.id)
: (data.channelItems ?? []).find((candidate) => candidate.id === item.id);
if (detailedItem) {
setSelectedItem((current) => current?.id === item.id ? detailedItem : current);
}
})
.catch(() => undefined);
};
useEffect(() => {
if (selectedItem?.type === "sales") {
setSalesDetailTab("projects");
@ -2599,7 +2656,7 @@ export default function Expansion() {
<div className="crm-list-stack">
{activeTab === "sales" ? (
salesData.length > 0 ? (
salesData.map((item, i) => {
salesData.slice(0, visibleItemCount).map((item, i) => {
const isOwnedByCurrentUser = currentUserId !== undefined && item.ownerUserId === currentUserId;
return (
<motion.div
@ -2607,7 +2664,7 @@ export default function Expansion() {
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
key={item.id}
onClick={() => setSelectedItem(item)}
onClick={() => handleSelectItem(item)}
className={cn(
"crm-card crm-card-pad relative rounded-2xl transition-shadow transition-colors",
isOwnedByCurrentUser
@ -2658,7 +2715,7 @@ export default function Expansion() {
})
) : renderEmpty()
) : channelData.length > 0 ? (
channelData.map((item, i) => {
channelData.slice(0, visibleItemCount).map((item, i) => {
const isOwnedByCurrentUser = currentUserId !== undefined && item.ownerUserId === currentUserId;
return (
<motion.div
@ -2666,7 +2723,7 @@ export default function Expansion() {
animate={{ opacity: 1, y: 0 }}
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
key={item.id}
onClick={() => setSelectedItem(item)}
onClick={() => handleSelectItem(item)}
className={cn(
"crm-card crm-card-pad relative rounded-2xl transition-shadow transition-colors",
isOwnedByCurrentUser
@ -2718,6 +2775,23 @@ export default function Expansion() {
);
})
) : renderEmpty()}
{hasMoreItems ? (
<div
ref={loadMoreRef}
role="button"
tabIndex={0}
onClick={() => void loadMoreItems()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
void loadMoreItems();
}
}}
className="cursor-pointer rounded-lg border border-slate-200 bg-white py-3 text-center text-sm font-medium text-violet-600 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-violet-300"
>
</div>
) : null}
</div>
<AnimatePresence>

View File

@ -72,6 +72,7 @@ const OPPORTUNITY_EXPANSION_SEARCH_DEBOUNCE_MS = 300;
const OPPORTUNITY_NEXT_PLAN_LABEL = "下一步销售计划";
const LEGACY_OPPORTUNITY_NEXT_PLAN_LABEL = "后续规划";
const OPPORTUNITY_CREATE_PERMISSION = "opportunity:create";
const LIST_PAGE_SIZE = 10;
const OPPORTUNITY_EXPORT_PREFERENCES_STORAGE_KEY = "crm:opportunity-export-preferences";
const COMPETITOR_OPTIONS = [
@ -1904,6 +1905,9 @@ export default function Opportunities() {
const [error, setError] = useState("");
const [exportError, setExportError] = useState("");
const [items, setItems] = useState<OpportunityItem[]>([]);
const [visibleItemCount, setVisibleItemCount] = useState(LIST_PAGE_SIZE);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const loadingMoreRef = useRef(false);
const [salesExpansionOptions, setSalesExpansionOptions] = useState<SalesExpansionItem[]>([]);
const [channelExpansionOptions, setChannelExpansionOptions] = useState<ChannelExpansionItem[]>([]);
const [selectedSalesExpansionOption, setSelectedSalesExpansionOption] = useState<SearchableOption | null>(null);
@ -1987,7 +1991,7 @@ export default function Opportunities() {
async function load() {
try {
const data = await getOpportunityOverview(keyword, filter);
const data = await getOpportunityOverview(keyword, filter, false, LIST_PAGE_SIZE + 1, archiveTab === "archived");
if (!cancelled) {
setItems(data.items ?? []);
setSelectedItem(null);
@ -2004,7 +2008,11 @@ export default function Opportunities() {
return () => {
cancelled = true;
};
}, [keyword, filter]);
}, [keyword, filter, archiveTab]);
useEffect(() => {
setVisibleItemCount(LIST_PAGE_SIZE);
}, [keyword, filter, archiveTab]);
useEffect(() => {
const requestedState = location.state as OpportunityLocationState;
@ -2311,7 +2319,40 @@ export default function Opportunities() {
const detailItem = selectedItem ? (selectedItemDetail ?? selectedItem) : null;
const followUpRecords: OpportunityFollowUp[] = detailItem?.followUps ?? [];
const visibleItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)));
const filteredItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)));
const visibleItems = filteredItems.slice(0, visibleItemCount);
const hasMoreItems = visibleItems.length < filteredItems.length;
const loadMoreItems = async () => {
if (!hasMoreItems || loadingMoreRef.current) {
return;
}
loadingMoreRef.current = true;
const nextVisibleCount = visibleItemCount + LIST_PAGE_SIZE;
try {
const data = await getOpportunityOverview(keyword, filter, false, nextVisibleCount + 1, archiveTab === "archived");
setItems(data.items ?? []);
setVisibleItemCount(nextVisibleCount);
} finally {
loadingMoreRef.current = false;
}
};
useEffect(() => {
if (!hasMoreItems) {
return;
}
const handleScroll = () => {
const loadMoreElement = loadMoreRef.current;
if (loadMoreElement && loadMoreElement.getBoundingClientRect().top <= window.innerHeight + 120) {
void loadMoreItems();
}
};
window.addEventListener("scroll", handleScroll, true);
return () => window.removeEventListener("scroll", handleScroll, true);
}, [hasMoreItems, visibleItemCount, filteredItems.length, keyword, filter, archiveTab]);
const stageFilterOptions = [
{ label: "全部", value: "全部" },
...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })),
@ -2798,7 +2839,7 @@ export default function Opportunities() {
};
const reload = async (preferredSelectedId?: CrmId) => {
const data = await getOpportunityOverview(keyword, filter);
const data = await getOpportunityOverview(keyword, filter, false);
const nextItems = data.items ?? [];
setItems(nextItems);
if (preferredSelectedId) {
@ -3175,6 +3216,23 @@ export default function Opportunities() {
);
})
) : renderEmpty()}
{hasMoreItems ? (
<div
ref={loadMoreRef}
role="button"
tabIndex={0}
onClick={() => void loadMoreItems()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
void loadMoreItems();
}
}}
className="cursor-pointer rounded-lg border border-slate-200 bg-white py-3 text-center text-sm font-medium text-violet-600 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-violet-300"
>
</div>
) : null}
</div>
<AnimatePresence>

View File

@ -0,0 +1,11 @@
node_modules
dist
.git
.gitignore
.idea
.vscode
*.md
*.log
.DS_Store
.cert
.env*

View File

@ -0,0 +1,27 @@
# ============ 构建阶段 ============
FROM node:20-alpine AS builder
WORKDIR /app
# 先复制 package.json利用 Docker 缓存加速依赖安装
COPY package.json package-lock.json* ./
RUN npm ci --registry=https://registry.npmmirror.com
# 复制源码并构建
COPY . .
RUN npm run build
# ============ 运行阶段 ============
FROM nginx:1.27-alpine
COPY nginx/default.conf.template /etc/nginx/default.conf.template
COPY nginx/start.sh /etc/nginx/start.sh
# 从构建阶段复制 dist
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
RUN rm -f /etc/nginx/conf.d/default.conf && chmod +x /etc/nginx/start.sh
ENTRYPOINT ["/etc/nginx/start.sh"]

View File

@ -0,0 +1,53 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
resolver ${RESOLVER} valid=30s ipv6=off;
location / {
try_files $uri $uri/ /index.html;
}
location /api/sys/ {
set $backend_host __BACKEND_HOST__;
set $backend_port __BACKEND_PORT__;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://$backend_host:$backend_port;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /api/ {
set $backend_host __BACKEND_HOST__;
set $backend_port __BACKEND_PORT__;
proxy_pass http://$backend_host:$backend_port;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
location /sys/ {
set $backend_host __BACKEND_HOST__;
set $backend_port __BACKEND_PORT__;
proxy_pass http://$backend_host:$backend_port;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}

View File

@ -0,0 +1,13 @@
#!/bin/sh
RESOLVER=$(cat /etc/resolv.conf | grep nameserver | awk '{print $2}' | head -1)
BACKEND_HOST=${BACKEND_HOST:-crm-backend.default.svc.cluster.local}
BACKEND_PORT=${BACKEND_PORT:-8080}
sed \
-e "s|\${RESOLVER}|$RESOLVER|g" \
-e "s|__BACKEND_HOST__|$BACKEND_HOST|g" \
-e "s|__BACKEND_PORT__|$BACKEND_PORT|g" \
/etc/nginx/default.conf.template > /etc/nginx/conf.d/default.conf
exec nginx -g "daemon off;"