diff --git a/backend/build/test-uploads/work-report-attachments/17-369a7e5f9e5b4fd58d5519d13c68ad0e.zip b/backend/build/test-uploads/work-report-attachments/17-369a7e5f9e5b4fd58d5519d13c68ad0e.zip new file mode 100644 index 00000000..82090ee2 --- /dev/null +++ b/backend/build/test-uploads/work-report-attachments/17-369a7e5f9e5b4fd58d5519d13c68ad0e.zip @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/build/test-uploads/work-report-attachments/17-549f73368b3c44a1a1142fba5c00edc4.zip b/backend/build/test-uploads/work-report-attachments/17-549f73368b3c44a1a1142fba5c00edc4.zip new file mode 100644 index 00000000..82090ee2 --- /dev/null +++ b/backend/build/test-uploads/work-report-attachments/17-549f73368b3c44a1a1142fba5c00edc4.zip @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/build/test-uploads/work-report-attachments/17-be54a89fd93d4385b686741614343c2d.zip b/backend/build/test-uploads/work-report-attachments/17-be54a89fd93d4385b686741614343c2d.zip new file mode 100644 index 00000000..82090ee2 --- /dev/null +++ b/backend/build/test-uploads/work-report-attachments/17-be54a89fd93d4385b686741614343c2d.zip @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/src/main/java/com/unis/crm/common/CrmGlobalExceptionHandler.java b/backend/src/main/java/com/unis/crm/common/CrmGlobalExceptionHandler.java index 50c6a047..f5560f1c 100644 --- a/backend/src/main/java/com/unis/crm/common/CrmGlobalExceptionHandler.java +++ b/backend/src/main/java/com/unis/crm/common/CrmGlobalExceptionHandler.java @@ -1,9 +1,13 @@ package com.unis.crm.common; import jakarta.servlet.http.HttpServletRequest; +import java.sql.SQLException; import java.time.OffsetDateTime; import java.util.LinkedHashMap; import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -21,6 +25,7 @@ import org.springframework.validation.method.ParameterValidationResult; @RestControllerAdvice public class CrmGlobalExceptionHandler { + private static final Logger log = LoggerFactory.getLogger(CrmGlobalExceptionHandler.class); private static final String CURRENT_USER_HEADER = "X-User-Id"; private static final String UNAUTHORIZED_MESSAGE = "登录已失效,请重新登录"; @@ -95,16 +100,63 @@ public class CrmGlobalExceptionHandler { return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).build(); } + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity> handleDataIntegrityViolation( + DataIntegrityViolationException ex, + HttpServletRequest request) { + log.warn("Database constraint violation on {}", request.getRequestURI(), ex); + return compatibleErrorResponse(HttpStatus.CONFLICT, resolveConstraintMessage(ex), request.getRequestURI()); + } + @ExceptionHandler(Exception.class) - @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) - public Map handleUnexpectedException(Exception ex, HttpServletRequest request) { + public ResponseEntity> handleUnexpectedException(Exception ex, HttpServletRequest request) { + log.error("Unexpected request failure on {}", request.getRequestURI(), ex); + return compatibleErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, "系统内部错误,请稍后重试", request.getRequestURI()); + } + + private String resolveConstraintMessage(Throwable throwable) { + SQLException sqlException = findCause(throwable, SQLException.class); + String databaseMessage = sqlException == null ? "" : String.valueOf(sqlException.getMessage()); + if (databaseMessage.contains("uk_crm_customer_code")) { + return "客户编码生成冲突,请重试"; + } + if (databaseMessage.contains("uk_crm_opportunity_code")) { + return "商机编码生成冲突,请重试"; + } + if (databaseMessage.contains("fk_crm_opportunity_channel_expansion")) { + return "所选渠道不存在或已失效,请重新选择"; + } + if (databaseMessage.contains("fk_crm_opportunity_sales_expansion")) { + return "所选销售拓展不存在或已失效,请重新选择"; + } + return "数据保存失败,请检查输入后重试"; + } + + private T findCause(Throwable throwable, Class causeType) { + Throwable current = throwable; + while (current != null) { + if (causeType.isInstance(current)) { + return causeType.cast(current); + } + current = current.getCause(); + } + return null; + } + + private ResponseEntity> compatibleErrorResponse( + HttpStatus status, + String message, + String path) { Map body = new LinkedHashMap<>(); + body.put("code", "-1"); + body.put("msg", message); + body.put("data", null); body.put("timestamp", OffsetDateTime.now().toString()); - body.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); - body.put("error", HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()); - body.put("message", ex.getMessage()); - body.put("path", request.getRequestURI()); - return body; + body.put("status", status.value()); + body.put("error", status.getReasonPhrase()); + body.put("message", message); + body.put("path", path); + return ResponseEntity.status(status).body(body); } private ResponseEntity> errorResponse(HttpStatus status, String message) { diff --git a/backend/src/main/java/com/unis/crm/common/CrmIdJacksonConfig.java b/backend/src/main/java/com/unis/crm/common/CrmIdJacksonConfig.java new file mode 100644 index 00000000..632757b4 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/common/CrmIdJacksonConfig.java @@ -0,0 +1,105 @@ +package com.unis.crm.common; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.SerializationConfig; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; +import com.fasterxml.jackson.databind.ser.BeanSerializerModifier; +import java.io.IOException; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CrmIdJacksonConfig { + + private static final long JS_MAX_SAFE_INTEGER = 9_007_199_254_740_991L; + + public static Object toJsonCompatibleValue(Long id) { + if (id == null) { + return null; + } + return isJavaScriptSafeInteger(id) ? id : id.toString(); + } + + @Bean + public SimpleModule crmIdSerializationModule() { + SimpleModule module = new SimpleModule("crm-id-serialization"); + module.setSerializerModifier(new CrmIdBeanSerializerModifier()); + return module; + } + + private static final class CrmIdBeanSerializerModifier extends BeanSerializerModifier { + + @Override + public List changeProperties( + SerializationConfig config, + BeanDescription beanDesc, + List beanProperties) { + for (BeanPropertyWriter property : beanProperties) { + if (!isIdProperty(property.getName()) || property.getSerializer() != null) { + continue; + } + JavaType type = property.getType(); + if (isLong(type)) { + property.assignSerializer(new CompatibleLongIdSerializer()); + } else if (type.isCollectionLikeType() && isLong(type.getContentType())) { + property.assignSerializer(new LongIdCollectionSerializer()); + } + } + return beanProperties; + } + + private boolean isIdProperty(String name) { + return "id".equalsIgnoreCase(name) || name.endsWith("Id") || name.endsWith("Ids"); + } + + private boolean isLong(JavaType type) { + return type != null && (type.hasRawClass(Long.class) || type.hasRawClass(long.class)); + } + } + + private static final class CompatibleLongIdSerializer extends JsonSerializer { + + @Override + public void serialize(Object value, JsonGenerator generator, SerializerProvider serializers) throws IOException { + long id = ((Number) value).longValue(); + if (isJavaScriptSafeInteger(id)) { + generator.writeNumber(id); + } else { + generator.writeString(Long.toString(id)); + } + } + } + + private static final class LongIdCollectionSerializer extends JsonSerializer { + + @Override + public void serialize(Object value, JsonGenerator generator, SerializerProvider serializers) throws IOException { + generator.writeStartArray(); + if (value instanceof Iterable items) { + for (Object item : items) { + if (item == null) { + generator.writeNull(); + } else { + long id = ((Number) item).longValue(); + if (isJavaScriptSafeInteger(id)) { + generator.writeNumber(id); + } else { + generator.writeString(Long.toString(id)); + } + } + } + } + generator.writeEndArray(); + } + } + + private static boolean isJavaScriptSafeInteger(long value) { + return value >= -JS_MAX_SAFE_INTEGER && value <= JS_MAX_SAFE_INTEGER; + } +} diff --git a/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java index 8fd6a622..4547a46b 100644 --- a/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java +++ b/backend/src/main/java/com/unis/crm/common/OpportunitySchemaInitializer.java @@ -16,6 +16,8 @@ import org.springframework.stereotype.Component; public class OpportunitySchemaInitializer implements ApplicationRunner { private static final Logger log = LoggerFactory.getLogger(OpportunitySchemaInitializer.class); + private static final String CUSTOMER_CODE_SEQUENCE = "crm_customer_code_seq"; + private static final String OPPORTUNITY_CODE_SEQUENCE = "crm_opportunity_code_seq"; private final DataSource dataSource; @@ -26,37 +28,165 @@ public class OpportunitySchemaInitializer implements ApplicationRunner { @Override public void run(ApplicationArguments args) { try (Connection connection = dataSource.getConnection()) { - if (!tableExists(connection, "crm_opportunity")) { - return; + if (tableExists(connection, "crm_opportunity")) { + ensureOpportunitySchema(connection); } - try (Statement statement = connection.createStatement()) { - statement.execute("alter table crm_opportunity add column if not exists pre_sales_id bigint"); - statement.execute("alter table crm_opportunity add column if not exists pre_sales_name varchar(100)"); - statement.execute("alter table crm_opportunity add column if not exists project_ownership_location varchar(100)"); - statement.execute("alter table crm_opportunity add column if not exists latest_progress text"); - statement.execute("alter table crm_opportunity add column if not exists next_plan text"); - statement.execute("alter table crm_opportunity add column if not exists updated_by bigint"); - statement.execute("alter table crm_opportunity add column if not exists archived_at timestamptz"); - statement.execute("alter table crm_opportunity add column if not exists actual_signed_amount numeric(18, 2)"); - statement.execute("alter table crm_opportunity add column if not exists is_poc boolean not null default false"); - statement.execute("create index if not exists idx_crm_opportunity_archived_at on crm_opportunity(archived_at)"); - statement.execute("comment on column crm_opportunity.project_ownership_location is '业绩归属地编码,对应 cnarea.area_code'"); - statement.execute("comment on column crm_opportunity.latest_progress is '项目最新进展'"); - statement.execute("comment on column crm_opportunity.next_plan is '下一步销售计划'"); - statement.execute("comment on column crm_opportunity.updated_by is '更新人ID'"); - statement.execute("comment on column crm_opportunity.archived_at is '归档时间'"); - statement.execute("comment on column crm_opportunity.actual_signed_amount is '实际签约金额'"); - statement.execute("comment on column crm_opportunity.is_poc is '是否POC测试项目'"); - } - ensureArchivedAtStorage(connection); - ensureConfidenceGradeStorage(connection); - migrateLegacyOmsProjectCode(connection); - log.info("Ensured compatibility columns exist for crm_opportunity"); + ensureSequences(connection); + log.info("Ensured CRM compatibility columns and sequences exist"); } catch (SQLException exception) { throw new IllegalStateException("Failed to initialize crm_opportunity schema compatibility", exception); } } + private void ensureOpportunitySchema(Connection connection) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute("alter table crm_opportunity add column if not exists pre_sales_id bigint"); + statement.execute("alter table crm_opportunity add column if not exists pre_sales_name varchar(100)"); + statement.execute("alter table crm_opportunity add column if not exists project_ownership_location varchar(100)"); + statement.execute("alter table crm_opportunity add column if not exists latest_progress text"); + statement.execute("alter table crm_opportunity add column if not exists next_plan text"); + statement.execute("alter table crm_opportunity add column if not exists updated_by bigint"); + statement.execute("alter table crm_opportunity add column if not exists archived_at timestamptz"); + statement.execute("alter table crm_opportunity add column if not exists actual_signed_amount numeric(18, 2)"); + statement.execute("alter table crm_opportunity add column if not exists is_poc boolean not null default false"); + statement.execute("create index if not exists idx_crm_opportunity_archived_at on crm_opportunity(archived_at)"); + statement.execute("comment on column crm_opportunity.project_ownership_location is '业绩归属地编码,对应 cnarea.area_code'"); + statement.execute("comment on column crm_opportunity.latest_progress is '项目最新进展'"); + statement.execute("comment on column crm_opportunity.next_plan is '下一步销售计划'"); + statement.execute("comment on column crm_opportunity.updated_by is '更新人ID'"); + statement.execute("comment on column crm_opportunity.archived_at is '归档时间'"); + statement.execute("comment on column crm_opportunity.actual_signed_amount is '实际签约金额'"); + statement.execute("comment on column crm_opportunity.is_poc is '是否POC测试项目'"); + } + ensureArchivedAtStorage(connection); + ensureConfidenceGradeStorage(connection); + migrateLegacyOmsProjectCode(connection); + } + + private void ensureSequences(Connection connection) throws SQLException { + boolean originalAutoCommit = connection.getAutoCommit(); + try { + connection.setAutoCommit(false); + if (tableExists(connection, "crm_customer")) { + ensureCodeSequence(connection, CUSTOMER_CODE_SEQUENCE, "crm_customer", "customer_code", "CUS"); + } + if (tableExists(connection, "crm_opportunity")) { + ensureCodeSequence(connection, OPPORTUNITY_CODE_SEQUENCE, "crm_opportunity", "opportunity_code", "OPP"); + } + if (tableExists(connection, "crm_sales_expansion")) { + ensureIdentitySequence(connection, "crm_sales_expansion", "id"); + } + if (tableExists(connection, "crm_channel_expansion")) { + ensureIdentitySequence(connection, "crm_channel_expansion", "id"); + } + connection.commit(); + } catch (SQLException exception) { + rollback(connection, exception); + throw exception; + } finally { + connection.setAutoCommit(originalAutoCommit); + } + } + + private void ensureCodeSequence( + Connection connection, + String sequenceName, + String tableName, + String columnName, + String codePrefix) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute("create sequence if not exists " + sequenceName + " start with 1 increment by 1 minvalue 1"); + } + + lockSequence(connection, sequenceName); + long maxSuffix = selectMaxNumericSuffix(connection, tableName, columnName, codePrefix); + long nextSequenceValue = selectNextSequenceValue(connection, sequenceName); + if (nextSequenceValue <= maxSuffix) { + restartSequence(connection, sequenceName, increment(maxSuffix, sequenceName)); + } + } + + private void ensureIdentitySequence(Connection connection, String tableName, String columnName) throws SQLException { + String sequenceName = selectIdentitySequenceName(connection, tableName, columnName); + if (sequenceName == null || sequenceName.isBlank()) { + return; + } + lockSequence(connection, sequenceName); + long maxId = selectMaxId(connection, tableName, columnName); + long nextSequenceValue = selectNextSequenceValue(connection, sequenceName); + if (nextSequenceValue <= maxId) { + restartSequence(connection, sequenceName, increment(maxId, sequenceName)); + } + } + + private String selectIdentitySequenceName(Connection connection, String tableName, String columnName) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement("select pg_get_serial_sequence(?, ?)")) { + statement.setString(1, tableName); + statement.setString(2, columnName); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next() ? resultSet.getString(1) : null; + } + } + } + + private long selectMaxId(Connection connection, String tableName, String columnName) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery( + "select coalesce(max(" + columnName + "), 0) from " + tableName)) { + return resultSet.next() ? resultSet.getLong(1) : 0; + } + } + + private void lockSequence(Connection connection, String sequenceName) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute("alter sequence " + sequenceName + " no cycle"); + } + } + + private void restartSequence(Connection connection, String sequenceName, long nextValue) throws SQLException { + try (Statement statement = connection.createStatement()) { + statement.execute("alter sequence " + sequenceName + " restart with " + nextValue); + } + } + + private long increment(long value, String sequenceName) throws SQLException { + if (value == Long.MAX_VALUE) { + throw new SQLException("Sequence " + sequenceName + " has exhausted bigint values"); + } + return value + 1; + } + + private void rollback(Connection connection, SQLException originalException) { + try { + connection.rollback(); + } catch (SQLException rollbackException) { + originalException.addSuppressed(rollbackException); + } + } + + private long selectMaxNumericSuffix( + Connection connection, + String tableName, + String columnName, + String codePrefix) throws SQLException { + String sql = "select coalesce(max((substring(" + columnName + " from '([0-9]+)$'))::bigint), 0) from " + tableName + + " where " + columnName + " ~ '^" + codePrefix + "-[0-9]{8}-[0-9]+$'"; + try (Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(sql)) { + return resultSet.next() ? resultSet.getLong(1) : 0; + } + } + + private long selectNextSequenceValue(Connection connection, String sequenceName) throws SQLException { + try (Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery( + "select case when is_called then last_value + 1 else last_value end from " + sequenceName)) { + if (!resultSet.next()) { + throw new SQLException("Unable to read sequence " + sequenceName); + } + return resultSet.getLong(1); + } + } + private void ensureArchivedAtStorage(Connection connection) throws SQLException { try (Statement statement = connection.createStatement()) { statement.execute(""" diff --git a/backend/src/main/java/com/unis/crm/controller/ExpansionController.java b/backend/src/main/java/com/unis/crm/controller/ExpansionController.java index 4212f233..6f437889 100644 --- a/backend/src/main/java/com/unis/crm/controller/ExpansionController.java +++ b/backend/src/main/java/com/unis/crm/controller/ExpansionController.java @@ -1,6 +1,7 @@ package com.unis.crm.controller; import com.unis.crm.common.ApiResponse; +import com.unis.crm.common.CrmIdJacksonConfig; import com.unis.crm.common.CurrentUserUtils; import com.unis.crm.dto.expansion.CreateChannelExpansionRequest; import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest; @@ -85,18 +86,20 @@ public class ExpansionController { @PostMapping("/sales") @Log(type = "拓展管理", value = "新增售前拓展") - public ApiResponse createSales( + public ApiResponse createSales( @RequestHeader("X-User-Id") Long userId, @Valid @RequestBody CreateSalesExpansionRequest request) { - return ApiResponse.success(expansionService.createSalesExpansion(CurrentUserUtils.requireCurrentUserId(userId), request)); + Long id = expansionService.createSalesExpansion(CurrentUserUtils.requireCurrentUserId(userId), request); + return ApiResponse.success(CrmIdJacksonConfig.toJsonCompatibleValue(id)); } @PostMapping("/channel") @Log(type = "拓展管理", value = "新增渠道拓展") - public ApiResponse createChannel( + public ApiResponse createChannel( @RequestHeader("X-User-Id") Long userId, @Valid @RequestBody CreateChannelExpansionRequest request) { - return ApiResponse.success(expansionService.createChannelExpansion(CurrentUserUtils.requireCurrentUserId(userId), request)); + Long id = expansionService.createChannelExpansion(CurrentUserUtils.requireCurrentUserId(userId), request); + return ApiResponse.success(CrmIdJacksonConfig.toJsonCompatibleValue(id)); } @PutMapping("/sales/{id}") diff --git a/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java b/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java index 3e72cd9f..f6eee155 100644 --- a/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java @@ -91,6 +91,10 @@ public interface OpportunityMapper { @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") int countOwnedOpportunity(@Param("userId") Long userId, @Param("id") Long id); + int countSalesExpansionById(@Param("id") Long id); + + int countChannelExpansionById(@Param("id") Long id); + @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") Boolean selectArchived(@Param("userId") Long userId, @Param("id") Long id); diff --git a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java index f7e8766a..10e222a3 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java @@ -560,6 +560,16 @@ public class OpportunityServiceImpl implements OpportunityService { request.setLatestProgress(normalizeSnapshotText(request.getLatestProgress())); request.setNextPlan(normalizeSnapshotText(request.getNextPlan())); validateOperatorRelations(request.getOperatorName(), request.getSalesExpansionId(), request.getChannelExpansionId()); + validateExpansionRelations(request.getSalesExpansionId(), request.getChannelExpansionId()); + } + + private void validateExpansionRelations(Long salesExpansionId, Long channelExpansionId) { + if (salesExpansionId != null && opportunityMapper.countSalesExpansionById(salesExpansionId) <= 0) { + throw new BusinessException("所选销售拓展不存在或已失效,请重新选择"); + } + if (channelExpansionId != null && opportunityMapper.countChannelExpansionById(channelExpansionId) <= 0) { + throw new BusinessException("所选渠道不存在或已失效,请重新选择"); + } } private String normalizeSnapshotText(String value) { diff --git a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml index 0abb7002..128f8529 100644 --- a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml +++ b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml @@ -486,7 +486,7 @@ updated_at ) values ( #{id}, - 'CUS-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad((coalesce((select count(1) from crm_customer), 0) + 1)::text, 3, '0'), + 'CUS-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad(nextval('crm_customer_code_seq')::text, 6, '0'), #{customerName}, #{userId}, coalesce(#{source}, '主动开发'), @@ -525,7 +525,7 @@ created_at, updated_at ) values ( - 'OPP-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad((coalesce((select count(1) from crm_opportunity), 0) + 1)::text, 3, '0'), + 'OPP-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad(nextval('crm_opportunity_code_seq')::text, 6, '0'), #{request.opportunityName}, #{customerId}, #{userId}, @@ -564,6 +564,18 @@ where o.id = #{id} + + + +