历史数据同步后,修复新增商机无法保存的问题

main
kangwenjing 2026-07-14 09:00:38 +08:00
parent b56d509cc9
commit 5da2565298
25 changed files with 870 additions and 185 deletions

View File

@ -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<Map<String, Object>> 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<String, Object> handleUnexpectedException(Exception ex, HttpServletRequest request) {
public ResponseEntity<Map<String, Object>> 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 extends Throwable> T findCause(Throwable throwable, Class<T> causeType) {
Throwable current = throwable;
while (current != null) {
if (causeType.isInstance(current)) {
return causeType.cast(current);
}
current = current.getCause();
}
return null;
}
private ResponseEntity<Map<String, Object>> compatibleErrorResponse(
HttpStatus status,
String message,
String path) {
Map<String, Object> 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<ApiResponse<Object>> errorResponse(HttpStatus status, String message) {

View File

@ -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<BeanPropertyWriter> changeProperties(
SerializationConfig config,
BeanDescription beanDesc,
List<BeanPropertyWriter> 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<Object> {
@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<Object> {
@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;
}
}

View File

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

View File

@ -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<Long> createSales(
public ApiResponse<Object> 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<Long> createChannel(
public ApiResponse<Object> 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}")

View File

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

View File

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

View File

@ -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}
</select>
<select id="countSalesExpansionById" resultType="int">
select count(1)
from crm_sales_expansion
where id = #{id}
</select>
<select id="countChannelExpansionById" resultType="int">
select count(1)
from crm_channel_expansion
where id = #{id}
</select>
<select id="selectArchived" resultType="java.lang.Boolean">
select coalesce(archived, false)
from crm_opportunity o

View File

@ -0,0 +1,67 @@
package com.unis.crm.common;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unis.crm.dto.opportunity.CreateOpportunityRequest;
import java.util.List;
import org.junit.jupiter.api.Test;
class CrmIdJacksonConfigTest {
@Test
void shouldSerializeLongIdsAsStringsWithoutChangingCounts() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new CrmIdJacksonConfig().crmIdSerializationModule());
JsonNode json = objectMapper.readTree(objectMapper.writeValueAsString(new TestPayload()));
assertEquals("2074420816598036481", json.path("id").asText());
assertTrue(json.path("id").isTextual());
assertEquals(50L, json.path("count").asLong());
assertTrue(json.path("count").isIntegralNumber());
assertEquals(42L, json.path("userId").asLong());
assertTrue(json.path("userId").isIntegralNumber());
assertEquals("2074420816598036481", json.path("ownerUserIds").get(0).asText());
assertTrue(json.path("ownerUserIds").get(0).isTextual());
}
@Test
void shouldDeserializeStringIdIntoLongRequestFieldWithoutPrecisionLoss() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
CreateOpportunityRequest request = objectMapper.readValue(
"{\"channelExpansionId\":\"2074420816598036481\"}",
CreateOpportunityRequest.class);
assertEquals(2_074_420_816_598_036_481L, request.getChannelExpansionId());
}
@Test
void shouldKeepSafeResponseIdsNumericAndConvertUnsafeIdsToStrings() {
assertEquals(42L, CrmIdJacksonConfig.toJsonCompatibleValue(42L));
assertEquals(
"2074420816598036481",
CrmIdJacksonConfig.toJsonCompatibleValue(2_074_420_816_598_036_481L));
}
static class TestPayload {
public Long getId() {
return 2_074_420_816_598_036_481L;
}
public Long getCount() {
return 50L;
}
public Long getUserId() {
return 42L;
}
public List<Long> getOwnerUserIds() {
return List.of(2_074_420_816_598_036_481L);
}
}
}

View File

@ -0,0 +1,48 @@
package com.unis.crm.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.mockito.Mockito.when;
import com.unis.crm.common.ApiResponse;
import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
import com.unis.crm.service.ExpansionService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class ExpansionControllerIdContractTest {
@Mock
private ExpansionService expansionService;
@InjectMocks
private ExpansionController expansionController;
@Test
void createSalesShouldKeepSafeIdNumeric() {
CreateSalesExpansionRequest request = new CreateSalesExpansionRequest();
when(expansionService.createSalesExpansion(1L, request)).thenReturn(42L);
ApiResponse<Object> response = expansionController.createSales(1L, request);
assertInstanceOf(Long.class, response.getData());
assertEquals(42L, response.getData());
}
@Test
void createChannelShouldReturnUnsafeIdAsString() {
CreateChannelExpansionRequest request = new CreateChannelExpansionRequest();
long unsafeId = 2_074_420_816_598_036_481L;
when(expansionService.createChannelExpansion(1L, request)).thenReturn(unsafeId);
ApiResponse<Object> response = expansionController.createChannel(1L, request);
assertInstanceOf(String.class, response.getData());
assertEquals("2074420816598036481", response.getData());
}
}

View File

@ -3,13 +3,16 @@ package com.unis.crm.controller;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.unis.crm.common.CrmGlobalExceptionHandler;
import java.sql.SQLException;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;
@ -44,6 +47,22 @@ class ProtocolExceptionHandlingWebMvcTest {
.andExpect(status().isUnsupportedMediaType());
}
@Test
void databaseConstraintDetailsShouldNotLeakToClient() throws Exception {
mockMvc.perform(get("/protocol-test/database-error"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.msg").value("所选渠道不存在或已失效,请重新选择"))
.andExpect(jsonPath("$.message").value("所选渠道不存在或已失效,请重新选择"));
}
@Test
void unexpectedExceptionDetailsShouldNotLeakToClient() throws Exception {
mockMvc.perform(get("/protocol-test/unexpected-error"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.msg").value("系统内部错误,请稍后重试"))
.andExpect(jsonPath("$.message").value("系统内部错误,请稍后重试"));
}
@RestController
static class ProtocolTestController {
@ -51,5 +70,17 @@ class ProtocolExceptionHandlingWebMvcTest {
public Map<String, Object> jsonOnly(@RequestBody Map<String, Object> body) {
return body;
}
@GetMapping("/protocol-test/database-error")
public void databaseError() {
throw new DataIntegrityViolationException(
"mapper SQL leaked",
new SQLException("violates constraint fk_crm_opportunity_channel_expansion"));
}
@GetMapping("/protocol-test/unexpected-error")
public void unexpectedError() {
throw new IllegalStateException("sensitive mapper and SQL details");
}
}
}

View File

@ -121,6 +121,24 @@ class OpportunityServiceImplTest {
verify(opportunityMapper, never()).insertOpportunity(any(), any(), any());
}
@Test
void createOpportunity_shouldRejectMissingChannelBeforeWriting() {
when(permissionService.hasPermi("opportunity:create")).thenReturn(true);
long channelId = 2_074_420_816_598_036_481L;
when(opportunityMapper.countChannelExpansionById(channelId)).thenReturn(0);
CreateOpportunityRequest request = updateRequest("客户A");
request.setOperatorName("渠道");
request.setChannelExpansionId(channelId);
BusinessException exception = assertThrows(
BusinessException.class,
() -> opportunityService.createOpportunity(1L, request));
assertEquals("所选渠道不存在或已失效,请重新选择", exception.getMessage());
verify(opportunityMapper, never()).insertCustomer(any(), any(), any(), any());
verify(opportunityMapper, never()).insertOpportunity(any(), any(), any());
}
@Test
void updateOpportunity_shouldKeepCurrentCustomerWhenCustomerNameUnchanged() {
when(opportunityMapper.countOwnedOpportunity(1L, 10L)).thenReturn(1);

View File

@ -1,10 +1,12 @@
export type CrmId = string | number;
export interface CaptchaResponse {
captchaId: string;
imageBase64: string;
}
export interface TenantInfo {
tenantId: number;
tenantId: CrmId;
tenantCode: string;
tenantName: string;
}
@ -26,8 +28,8 @@ export interface LoginPayload {
}
export interface UserProfile {
userId: number;
tenantId: number;
userId: CrmId;
tenantId: CrmId;
username: string;
displayName: string;
email?: string;
@ -41,14 +43,14 @@ export interface UserProfile {
}
export interface UserRole {
roleId?: number;
roleId?: CrmId;
roleCode?: string;
roleName?: string;
}
export interface UserPermission {
permId?: number;
parentId?: number;
permId?: CrmId;
parentId?: CrmId;
name?: string;
code: string;
permType?: string;
@ -57,12 +59,12 @@ export interface UserPermission {
}
export interface AdminUserSummary {
userId: number;
userId: CrmId;
username: string;
displayName: string;
status?: number;
tenantId?: number;
orgId?: number;
tenantId?: CrmId;
orgId?: CrmId;
isAdmin?: boolean;
isPlatformAdmin?: boolean;
roleCodes?: string[];
@ -108,13 +110,13 @@ export interface DashboardTodo {
}
export interface DashboardActivity {
id: number;
id: CrmId;
bizType?: string;
bizId?: number;
bizId?: CrmId;
actionType?: string;
title?: string;
content?: string;
operatorUserId?: number;
operatorUserId?: CrmId;
operatorName?: string;
targetTab?: string;
createdAt?: string;
@ -122,13 +124,13 @@ export interface DashboardActivity {
}
export interface DashboardMessage {
id: number;
reportId?: number;
senderUserId?: number;
id: CrmId;
reportId?: CrmId;
senderUserId?: CrmId;
senderName?: string;
reportDate?: string;
bizType?: string;
bizId?: number;
bizId?: CrmId;
bizName?: string;
content?: string;
lineIndexes?: string;
@ -138,7 +140,7 @@ export interface DashboardMessage {
}
export interface DashboardAnalyticsCard {
id?: number;
id?: CrmId;
cardKey?: string;
groupName?: string;
title?: string;
@ -180,7 +182,7 @@ export interface DashboardAnalyticsPanel {
}
export interface DashboardHome {
userId?: number;
userId?: CrmId;
realName?: string;
jobTitle?: string;
deptName?: string;
@ -197,7 +199,7 @@ export interface DashboardHome {
}
export interface ProfileOverview {
userId?: number;
userId?: CrmId;
monthlyOpportunityCount?: number;
monthlyExpansionCount?: number;
averageScore?: number;
@ -209,14 +211,14 @@ export interface ProfileOverview {
}
export interface UpdateCurrentUserProfilePayload {
userId?: number;
userId?: CrmId;
username?: string;
displayName?: string;
email?: string;
phone?: string;
pwdResetRequired?: number;
isPlatformAdmin?: boolean;
orgId?: number;
orgId?: CrmId;
}
export interface UpdateCurrentUserPasswordPayload {
@ -230,17 +232,17 @@ export interface OwnerTransferConflict {
}
export interface OwnerTransferItem {
id: number;
id: CrmId;
name: string;
code?: string;
conflict: boolean;
}
export interface OwnerTransferPreview {
tenantId: number;
fromUserId: number;
tenantId: CrmId;
fromUserId: CrmId;
fromUserName: string;
toUserId: number;
toUserId: CrmId;
toUserName: string;
opportunityCount: number;
salesExpansionCount: number;
@ -252,14 +254,14 @@ export interface OwnerTransferPreview {
}
export interface OwnerTransferSelection {
opportunityIds?: number[];
salesExpansionIds?: number[];
channelExpansionIds?: number[];
opportunityIds?: CrmId[];
salesExpansionIds?: CrmId[];
channelExpansionIds?: CrmId[];
}
export interface OwnerTransferPayload {
fromUserId: number;
toUserId: number;
fromUserId: CrmId;
toUserId: CrmId;
transferOpportunities: boolean;
transferSalesExpansions: boolean;
transferChannelExpansions: boolean;
@ -267,16 +269,16 @@ export interface OwnerTransferPayload {
}
export interface OwnerTransferResult {
tenantId: number;
fromUserId: number;
toUserId: number;
tenantId: CrmId;
fromUserId: CrmId;
toUserId: CrmId;
transferredOpportunityCount: number;
transferredSalesExpansionCount: number;
transferredChannelExpansionCount: number;
}
export interface WorkCheckIn {
id: number;
id: CrmId;
date?: string;
time?: string;
locationText?: string;
@ -286,14 +288,14 @@ export interface WorkCheckIn {
latitude?: number;
photoUrls?: string[];
bizType?: "sales" | "channel" | "opportunity";
bizId?: number;
bizId?: CrmId;
bizName?: string;
userName?: string;
deptName?: string;
}
export interface WorkDailyReport {
id: number;
id: CrmId;
date?: string;
submitTime?: string;
workContent?: string;
@ -308,7 +310,7 @@ export interface WorkDailyReport {
}
export interface WorkHistoryItem {
id: number;
id: CrmId;
type?: string;
date?: string;
time?: string;
@ -333,7 +335,7 @@ export interface UploadReportAttachmentOptions {
}
export interface WorkReportToUser {
userId: number;
userId: CrmId;
name: string;
username?: string;
}
@ -411,7 +413,7 @@ export interface CreateWorkCheckInPayload {
latitude?: number;
photoUrls?: string[];
bizType?: "sales" | "channel" | "opportunity";
bizId?: number;
bizId?: CrmId;
bizName?: string;
userName?: string;
deptName?: string;
@ -420,7 +422,7 @@ export interface CreateWorkCheckInPayload {
export interface WorkReportLineItem {
workDate: string;
bizType: "sales" | "channel" | "opportunity";
bizId: number;
bizId: CrmId;
bizName?: string;
editorText?: string;
content: string;
@ -449,8 +451,8 @@ export interface CreateWorkDailyReportPayload {
}
export interface OpportunityFollowUp {
id: number;
opportunityId?: number;
id: CrmId;
opportunityId?: CrmId;
date?: string;
type?: string;
content?: string;
@ -464,8 +466,8 @@ export interface OpportunityFollowUp {
}
export interface OpportunityItem {
id: number;
ownerUserId?: number;
id: CrmId;
ownerUserId?: CrmId;
code?: string;
name?: string;
client?: string;
@ -490,15 +492,15 @@ export interface OpportunityItem {
isPoc?: boolean;
product?: string;
source?: string;
salesExpansionId?: number;
salesExpansionId?: CrmId;
salesExpansionName?: string;
salesExpansionIntent?: string;
salesExpansionActive?: boolean;
channelExpansionId?: number;
channelExpansionId?: CrmId;
channelExpansionName?: string;
channelExpansionIntent?: string;
channelExpansionEstablishedDate?: string;
preSalesId?: number;
preSalesId?: CrmId;
preSalesName?: string;
competitorName?: string;
latestProgress?: string;
@ -527,7 +529,7 @@ export interface OpportunityMeta {
}
export interface OmsPreSalesOption {
userId: number;
userId: CrmId;
loginName?: string;
userName?: string;
}
@ -545,8 +547,8 @@ export interface CreateOpportunityPayload {
opportunityType?: string;
productType?: string;
source?: string;
salesExpansionId?: number;
channelExpansionId?: number;
salesExpansionId?: CrmId;
channelExpansionId?: CrmId;
competitorName?: string;
latestProgress?: string;
nextPlan?: string;
@ -555,7 +557,7 @@ export interface CreateOpportunityPayload {
}
export interface PushOpportunityToOmsPayload {
preSalesId?: number;
preSalesId?: CrmId;
preSalesName?: string;
}
@ -567,8 +569,8 @@ export interface CreateOpportunityFollowUpPayload {
}
export interface ExpansionFollowUp {
id: number;
bizId?: number;
id: CrmId;
bizId?: CrmId;
bizType?: string;
date?: string;
type?: string;
@ -580,8 +582,8 @@ export interface ExpansionFollowUp {
}
export interface SalesExpansionItem {
id: number;
ownerUserId?: number;
id: CrmId;
ownerUserId?: CrmId;
owner?: string;
type: "sales";
createdAt?: string;
@ -612,7 +614,7 @@ export interface SalesExpansionItem {
}
export interface RelatedProjectSummary {
opportunityId: number;
opportunityId: CrmId;
opportunityCode?: string;
opportunityName?: string;
stageCode?: string;
@ -621,8 +623,8 @@ export interface RelatedProjectSummary {
}
export interface ChannelExpansionItem {
id: number;
ownerUserId?: number;
id: CrmId;
ownerUserId?: CrmId;
owner?: string;
type: "channel";
createdAt?: string;
@ -662,14 +664,14 @@ export interface ChannelExpansionItem {
}
export interface ChannelExpansionContact {
id?: number;
id?: CrmId;
name?: string;
mobile?: string;
title?: string;
}
export interface ChannelRelatedProjectSummary {
opportunityId: number;
opportunityId: CrmId;
opportunityCode?: string;
opportunityName?: string;
stageCode?: string;
@ -843,8 +845,8 @@ const AUTH_REQUIRED_MESSAGE_PATTERNS = [
type AccessTokenPayload = {
exp?: number;
userId?: number;
tenantId?: number;
userId?: CrmId;
tenantId?: CrmId;
};
function isSuccessCode(code: unknown) {
@ -1223,8 +1225,9 @@ function getStoredUserId() {
const rawProfile = sessionStorage.getItem("userProfile");
if (rawProfile) {
const profile = JSON.parse(rawProfile) as Partial<UserProfile>;
if (typeof profile.userId === "number" && Number.isFinite(profile.userId)) {
return profile.userId;
const profileUserId = normalizeStoredId(profile.userId);
if (profileUserId) {
return profileUserId;
}
}
} catch {
@ -1238,8 +1241,9 @@ function getStoredUserId() {
}
const tokenPayload = getAccessTokenPayload(token);
if (typeof tokenPayload.userId === "number" && Number.isFinite(tokenPayload.userId)) {
return tokenPayload.userId;
const tokenUserId = normalizeStoredId(tokenPayload.userId);
if (tokenUserId) {
return tokenUserId;
}
} catch {
return undefined;
@ -1248,6 +1252,16 @@ function getStoredUserId() {
return undefined;
}
function normalizeStoredId(value: unknown): CrmId | undefined {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) {
return value;
}
return undefined;
}
export function getStoredCurrentUserId() {
return getStoredUserId();
}
@ -1369,7 +1383,7 @@ export async function completeDashboardTodo(todoId: string) {
}, true);
}
export async function readDashboardMessage(messageId: number) {
export async function readDashboardMessage(messageId: CrmId) {
return request<void>(`/api/dashboard/messages/${messageId}/read`, {
method: "POST",
}, true);
@ -1398,14 +1412,14 @@ export async function updateCurrentUserPassword(payload: UpdateCurrentUserPasswo
}, true);
}
export async function updateUserProfileById(userId: number, payload: UpdateCurrentUserProfilePayload) {
export async function updateUserProfileById(userId: CrmId, payload: UpdateCurrentUserProfilePayload) {
return request<boolean>(`/api/sys/api/users/${userId}`, {
method: "PUT",
body: JSON.stringify(payload),
}, true);
}
export async function listAdminUsers(params?: { tenantId?: number; orgId?: number }) {
export async function listAdminUsers(params?: { tenantId?: CrmId; orgId?: CrmId }) {
const searchParams = new URLSearchParams();
if (params?.tenantId !== undefined) {
searchParams.set("tenantId", String(params.tenantId));
@ -1417,7 +1431,7 @@ export async function listAdminUsers(params?: { tenantId?: number; orgId?: numbe
return request<AdminUserSummary[]>(`/api/sys/api/users${query ? `?${query}` : ""}`, undefined, true);
}
export async function previewOwnerTransfer(fromUserId: number, toUserId: number) {
export async function previewOwnerTransfer(fromUserId: CrmId, toUserId: CrmId) {
const params = new URLSearchParams({
fromUserId: String(fromUserId),
toUserId: String(toUserId),
@ -1485,7 +1499,7 @@ export async function getWorkHistory(type: "checkin" | "report", page = 1, size
return request<WorkHistoryPage>(`/api/work/history?${params.toString()}`, undefined, true);
}
export async function getWorkReportHistoryItem(reportId: number) {
export async function getWorkReportHistoryItem(reportId: CrmId) {
return request<WorkHistoryItem>(`/api/work/history/reports/${reportId}`, undefined, true);
}
@ -1605,7 +1619,7 @@ export async function getOpportunityOverview(keyword?: string, stage?: string) {
return request<OpportunityOverview>(`/api/opportunities/overview${query ? `?${query}` : ""}`, undefined, true);
}
export async function getOpportunityDetail(opportunityId: number) {
export async function getOpportunityDetail(opportunityId: CrmId) {
return request<OpportunityItem>(`/api/opportunities/${opportunityId}`, undefined, true);
}
@ -1624,21 +1638,21 @@ export async function createOpportunity(payload: CreateOpportunityPayload) {
}, true);
}
export async function updateOpportunity(opportunityId: number, payload: CreateOpportunityPayload) {
export async function updateOpportunity(opportunityId: CrmId, payload: CreateOpportunityPayload) {
return request<number>(`/api/opportunities/${opportunityId}`, {
method: "PUT",
body: JSON.stringify(payload),
}, true);
}
export async function pushOpportunityToOms(opportunityId: number, payload?: PushOpportunityToOmsPayload) {
export async function pushOpportunityToOms(opportunityId: CrmId, payload?: PushOpportunityToOmsPayload) {
return request<number>(`/api/opportunities/${opportunityId}/push-oms`, {
method: "POST",
body: JSON.stringify(payload ?? {}),
}, true);
}
export async function createOpportunityFollowUp(opportunityId: number, payload: CreateOpportunityFollowUpPayload) {
export async function createOpportunityFollowUp(opportunityId: CrmId, payload: CreateOpportunityFollowUpPayload) {
return request<number>(`/api/opportunities/${opportunityId}/followups`, {
method: "POST",
body: JSON.stringify(payload),
@ -1675,7 +1689,7 @@ export async function getExpansionCityOptions(provinceName: string) {
return request<ExpansionDictOption[]>(`/api/expansion/areas/cities?${params.toString()}`, undefined, true);
}
export async function checkSalesExpansionDuplicate(employeeNo: string, excludeId?: number) {
export async function checkSalesExpansionDuplicate(employeeNo: string, excludeId?: CrmId) {
const params = new URLSearchParams({ employeeNo });
if (excludeId) {
params.set("excludeId", String(excludeId));
@ -1683,7 +1697,7 @@ export async function checkSalesExpansionDuplicate(employeeNo: string, excludeId
return request<ExpansionDuplicateCheck>(`/api/expansion/sales/duplicate-check?${params.toString()}`, undefined, true);
}
export async function checkChannelExpansionDuplicate(channelName: string, excludeId?: number) {
export async function checkChannelExpansionDuplicate(channelName: string, excludeId?: CrmId) {
const params = new URLSearchParams({ channelName });
if (excludeId) {
params.set("excludeId", String(excludeId));
@ -1692,27 +1706,27 @@ export async function checkChannelExpansionDuplicate(channelName: string, exclud
}
export async function createSalesExpansion(payload: CreateSalesExpansionPayload) {
return request<number>("/api/expansion/sales", {
return request<CrmId>("/api/expansion/sales", {
method: "POST",
body: JSON.stringify(payload),
}, true);
}
export async function createChannelExpansion(payload: CreateChannelExpansionPayload) {
return request<number>("/api/expansion/channel", {
return request<CrmId>("/api/expansion/channel", {
method: "POST",
body: JSON.stringify(serializeChannelExpansionPayload(payload)),
}, true);
}
export async function updateSalesExpansion(id: number, payload: UpdateSalesExpansionPayload) {
export async function updateSalesExpansion(id: CrmId, payload: UpdateSalesExpansionPayload) {
return request<void>(`/api/expansion/sales/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
}, true);
}
export async function updateChannelExpansion(id: number, payload: UpdateChannelExpansionPayload) {
export async function updateChannelExpansion(id: CrmId, payload: UpdateChannelExpansionPayload) {
return request<void>(`/api/expansion/channel/${id}`, {
method: "PUT",
body: JSON.stringify(serializeChannelExpansionPayload(payload)),
@ -1721,7 +1735,7 @@ export async function updateChannelExpansion(id: number, payload: UpdateChannelE
export async function createExpansionFollowUp(
bizType: "sales" | "channel",
bizId: number,
bizId: CrmId,
payload: CreateExpansionFollowUpPayload,
) {
return request<number>(`/api/expansion/${bizType}/${bizId}/followups`, {

View File

@ -715,9 +715,9 @@ export default function Dashboard() {
const visibleHistoryTodos = showAllHistoryTodos ? historyTodos : historyTodos.slice(0, DASHBOARD_HISTORY_PREVIEW_COUNT);
const activities = (home.activities?.length
? home.activities
: [{ id: 0, title: "无", content: "无", timeText: "无" }]) as DashboardActivity[];
: [{ id: "0", title: "无", content: "无", timeText: "无" }]) as DashboardActivity[];
const visibleActivities = showAllActivities ? activities : activities.slice(0, DASHBOARD_PREVIEW_COUNT);
const hasMoreActivities = activities.length > DASHBOARD_PREVIEW_COUNT && activities[0]?.id !== 0;
const hasMoreActivities = activities.length > DASHBOARD_PREVIEW_COUNT && activities[0]?.id !== "0";
const hasMoreHistoryTodos = historyTodos.length > DASHBOARD_HISTORY_PREVIEW_COUNT;
const showStatsCard = home.statsCardVisible !== false;
const showTodoCard = home.todoCardVisible !== false;

View File

@ -19,6 +19,7 @@ import {
updateSalesExpansion,
type ChannelExpansionContact,
type ChannelExpansionItem,
type CrmId,
type CreateChannelExpansionPayload,
type CreateSalesExpansionPayload,
type ExpansionDictOption,
@ -32,7 +33,7 @@ import { cn } from "@/lib/utils";
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
type ExpansionTab = "sales" | "channel";
type ExpansionLocationState = { tab?: ExpansionTab; selectedId?: number } | null;
type ExpansionLocationState = { tab?: ExpansionTab; selectedId?: CrmId } | null;
type ExpansionExportFilters = {
keyword?: string;
intent?: string;

View File

@ -23,6 +23,7 @@ import {
updateOpportunity,
type ChannelExpansionContact,
type ChannelExpansionItem,
type CrmId,
type CreateChannelExpansionPayload,
type CreateOpportunityPayload,
type CreateSalesExpansionPayload,
@ -86,7 +87,7 @@ const COMPETITOR_OPTIONS = [
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
type OperatorMode = "none" | "h3c" | "channel" | "both";
type OpportunityArchiveTab = "active" | "archived";
type OpportunityLocationState = { selectedId?: number; archiveTab?: OpportunityArchiveTab } | null;
type OpportunityLocationState = { selectedId?: CrmId; archiveTab?: OpportunityArchiveTab } | null;
type OpportunityExportFilters = {
keyword?: string;
expectedStartDate?: string;
@ -769,8 +770,8 @@ function buildOpportunitySubmitPayload(
function validateOperatorRelations(
operatorMode: OperatorMode,
salesExpansionId: number | undefined,
channelExpansionId: number | undefined,
salesExpansionId: CrmId | undefined,
channelExpansionId: CrmId | undefined,
) {
if (operatorMode === "h3c" && !salesExpansionId) {
throw new Error("运作方选择“新华三”时,新华三负责人必须填写");
@ -1290,7 +1291,7 @@ function OpportunityExportFilterModal({
}
type SearchableOption = {
value: number | string;
value: CrmId;
label: string;
keywords?: string[];
};
@ -1372,7 +1373,7 @@ function SearchableSelect({
onCreate,
onQueryChange,
}: {
value?: number;
value?: CrmId;
options: SearchableOption[];
placeholder: string;
searchPlaceholder: string;
@ -1380,7 +1381,7 @@ function SearchableSelect({
loading?: boolean;
createActionLabel?: string;
className?: string;
onChange: (value?: number) => void;
onChange: (value?: CrmId) => void;
onCreate?: (query: string) => void;
onQueryChange?: (query: string) => void;
}) {
@ -1512,7 +1513,7 @@ function SearchableSelect({
type="button"
key={item.value}
onClick={() => {
onChange(Number(item.value));
onChange(item.value);
setOpen(false);
resetQuery();
}}
@ -2594,14 +2595,14 @@ export default function Opportunities() {
}
};
const handleSalesExpansionChange = (value?: number) => {
const handleSalesExpansionChange = (value?: CrmId) => {
setSelectedSalesExpansionOption(
value ? salesExpansionSearchOptions.find((option) => option.value === value) ?? null : null,
);
handleChange("salesExpansionId", value);
};
const handleChannelExpansionChange = (value?: number) => {
const handleChannelExpansionChange = (value?: CrmId) => {
setSelectedChannelExpansionOption(
value ? channelExpansionSearchOptions.find((option) => option.value === value) ?? null : null,
);
@ -2716,7 +2717,7 @@ export default function Opportunities() {
setQuickCreateSubmitting(true);
try {
let createdId: number;
let createdId: CrmId;
if (quickCreateType === "sales") {
const duplicateResult = await checkSalesExpansionDuplicate(quickSalesForm.employeeNo.trim());
if (duplicateResult.duplicated) {
@ -2796,7 +2797,7 @@ export default function Opportunities() {
setCustomCompetitorName("");
};
const reload = async (preferredSelectedId?: number) => {
const reload = async (preferredSelectedId?: CrmId) => {
const data = await getOpportunityOverview(keyword, filter);
const nextItems = data.items ?? [];
setItems(nextItems);

View File

@ -22,6 +22,7 @@ import {
listOwnerTransferTargetUsers,
previewOwnerTransfer,
type AdminUserSummary,
type CrmId,
type OwnerTransferItem,
type OwnerTransferPayload,
type OwnerTransferPreview,
@ -31,9 +32,9 @@ import { cn } from "@/lib/utils";
type TransferTab = "opportunities" | "sales" | "channels";
type TransferKind = "opportunity" | "sales" | "channel";
type SelectionState = {
opportunityIds: number[];
salesExpansionIds: number[];
channelExpansionIds: number[];
opportunityIds: CrmId[];
salesExpansionIds: CrmId[];
channelExpansionIds: CrmId[];
};
type TransferConfirmState = {
@ -61,7 +62,7 @@ const TAB_META: Array<{
function getActiveTenantId() {
const rawValue = localStorage.getItem("activeTenantId");
const tenantId = Number(rawValue || 0);
return Number.isFinite(tenantId) ? tenantId : 0;
return Number.isSafeInteger(tenantId) ? tenantId : 0;
}
function getDisplayUserName(user?: AdminUserSummary | null) {
@ -83,6 +84,18 @@ function countLabel(value?: number) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function normalizeSelectedId(value?: string): CrmId | undefined {
const normalized = value?.trim();
if (!normalized || normalized === "0") {
return undefined;
}
return normalized;
}
function isSameCrmId(left?: CrmId | null, right?: CrmId | null) {
return left !== undefined && left !== null && right !== undefined && right !== null && String(left) === String(right);
}
function createEmptySelectionState(): SelectionState {
return {
opportunityIds: [],
@ -145,15 +158,15 @@ export default function OwnerTransfer({
const activeTenantId = getActiveTenantId();
const tenantSelected = activeTenantId > 0;
const numericFromUserId = fromUserId ? Number(fromUserId) : undefined;
const numericToUserId = toUserId ? Number(toUserId) : undefined;
const selectedFromUserId = normalizeSelectedId(fromUserId);
const selectedToUserId = normalizeSelectedId(toUserId);
const fromUser = useMemo(
() => users.find((item) => item.userId === numericFromUserId) ?? currentUser,
[currentUser, numericFromUserId, users],
() => users.find((item) => isSameCrmId(item.userId, selectedFromUserId)) ?? currentUser,
[currentUser, selectedFromUserId, users],
);
const toUser = useMemo(
() => users.find((item) => item.userId === numericToUserId) ?? null,
[numericToUserId, users],
() => users.find((item) => isSameCrmId(item.userId, selectedToUserId)) ?? null,
[selectedToUserId, users],
);
const selectedPreviewHasSalesConflict = Boolean(preview?.salesConflicts?.length);
@ -196,7 +209,7 @@ export default function OwnerTransfer({
}
}, [activeTenantId, tenantSelected]);
const loadPreview = useCallback(async (sourceUserId?: number, targetUserId?: number) => {
const loadPreview = useCallback(async (sourceUserId?: CrmId, targetUserId?: CrmId) => {
if (!currentUser?.userId) {
setPreview(null);
return;
@ -205,10 +218,8 @@ export default function OwnerTransfer({
!tenantSelected
|| !sourceUserId
|| !targetUserId
|| sourceUserId <= 0
|| targetUserId <= 0
|| sourceUserId === targetUserId
|| sourceUserId !== currentUser.userId
|| isSameCrmId(sourceUserId, targetUserId)
|| !isSameCrmId(sourceUserId, currentUser.userId)
) {
setPreview(null);
return;
@ -236,8 +247,8 @@ export default function OwnerTransfer({
}, [loadUsers]);
useEffect(() => {
void loadPreview(numericFromUserId, numericToUserId);
}, [loadPreview, numericFromUserId, numericToUserId]);
void loadPreview(selectedFromUserId, selectedToUserId);
}, [loadPreview, selectedFromUserId, selectedToUserId]);
useEffect(() => {
setSearchKeyword("");
@ -351,11 +362,11 @@ export default function OwnerTransfer({
}, [confirmState, runTransfer]);
const handleExecute = async () => {
if (!numericFromUserId || !numericToUserId) {
if (!selectedFromUserId || !selectedToUserId) {
setPageError("请先选择原归属人和新归属人");
return;
}
if (numericFromUserId === numericToUserId) {
if (isSameCrmId(selectedFromUserId, selectedToUserId)) {
setPageError("原归属人和新归属人不能相同");
return;
}
@ -368,8 +379,8 @@ export default function OwnerTransfer({
title: "确认执行转移?",
description: "",
payload: {
fromUserId: numericFromUserId,
toUserId: numericToUserId,
fromUserId: selectedFromUserId,
toUserId: selectedToUserId,
transferOpportunities: selection.opportunityIds.length > 0,
transferSalesExpansions: selection.salesExpansionIds.length > 0,
transferChannelExpansions: selection.channelExpansionIds.length > 0,
@ -392,7 +403,7 @@ export default function OwnerTransfer({
};
const handleSingleTransfer = async (kind: TransferKind, item: OwnerTransferItem) => {
if (!numericFromUserId || !numericToUserId) {
if (!selectedFromUserId || !selectedToUserId) {
setPageError("请先选择原归属人和新归属人");
return;
}
@ -406,8 +417,8 @@ export default function OwnerTransfer({
title: "确认转移当前数据?",
description: "",
payload: {
fromUserId: numericFromUserId,
toUserId: numericToUserId,
fromUserId: selectedFromUserId,
toUserId: selectedToUserId,
transferOpportunities: kind === "opportunity",
transferSalesExpansions: kind === "sales",
transferChannelExpansions: kind === "channel",
@ -426,7 +437,7 @@ export default function OwnerTransfer({
});
};
const toggleItemSelection = (kind: TransferKind, itemId: number) => {
const toggleItemSelection = (kind: TransferKind, itemId: CrmId) => {
const selectionKey = getSelectionKey(kind);
setSelection((current) => {
const currentIds = current[selectionKey];

View File

@ -34,6 +34,7 @@ import {
type ChannelExpansionItem,
type ChannelExpansionContact,
type CreateChannelExpansionPayload,
type CrmId,
type CreateOpportunityPayload,
type CreateWorkCheckInPayload,
type CreateWorkDailyReportPayload,
@ -140,7 +141,7 @@ const LOCATION_DISPLAY_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
const locationDisplayCache = new Map<string, string>();
type WorkRelationOption = {
id: number;
id: CrmId;
label: string;
};
@ -222,7 +223,7 @@ function createEmptyReportLine(): WorkReportLineItem {
return {
workDate: format(new Date(), "yyyy-MM-dd"),
bizType: "sales",
bizId: 0,
bizId: "",
bizName: "",
editorText: "",
content: "",
@ -896,8 +897,10 @@ export default function Work() {
const routeOpenReportId = useMemo(() => {
const state = routerLocation.state as { openReportId?: unknown } | null;
const rawId = state?.openReportId;
const numericId = typeof rawId === "number" ? rawId : Number(rawId);
return Number.isFinite(numericId) && numericId > 0 ? numericId : null;
if (typeof rawId === "string" && rawId.trim()) {
return rawId.trim();
}
return typeof rawId === "number" && Number.isSafeInteger(rawId) && rawId > 0 ? rawId : null;
}, [routerLocation.state]);
const pickerOptions = useMemo(() => {
@ -928,9 +931,9 @@ export default function Work() {
: 0;
const opportunityItemsById = useMemo(() => {
const entries = opportunityItems
.filter((item): item is OpportunityItem & { id: number } => typeof item.id === "number")
.filter((item): item is OpportunityItem & { id: CrmId } => isValidCrmId(item.id))
.map((item) => [item.id, item] as const);
return new Map<number, OpportunityItem>(entries);
return new Map<CrmId, OpportunityItem>(entries);
}, [opportunityItems]);
const quickOpportunityOperatorMode = useMemo(
() => resolveOperatorMode(quickOpportunityForm.operatorName, quickOpportunityOperatorOptions),
@ -2752,7 +2755,7 @@ export default function Work() {
...salesOptions.map((item) => ({ value: String(item.id), label: item.label })),
]}
className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.salesExpansionId))}
onChange={(value) => handleQuickOpportunityChange("salesExpansionId", value ? Number(value) : undefined)}
onChange={(value) => handleQuickOpportunityChange("salesExpansionId", value || undefined)}
/>
{quickOpportunityFieldErrors.salesExpansionId ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.salesExpansionId}</p> : null}
</label>
@ -2771,7 +2774,7 @@ export default function Work() {
...channelOptions.map((item) => ({ value: String(item.id), label: item.label })),
]}
className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.channelExpansionId))}
onChange={(value) => handleQuickOpportunityChange("channelExpansionId", value ? Number(value) : undefined)}
onChange={(value) => handleQuickOpportunityChange("channelExpansionId", value || undefined)}
/>
{quickOpportunityFieldErrors.channelExpansionId ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.channelExpansionId}</p> : null}
</label>
@ -5188,9 +5191,9 @@ function hasOnlySeeRole(user: UserProfile | null) {
}
function buildSalesOptions(items: SalesExpansionItem[]): WorkRelationOption[] {
const seenIds = new Set<number>();
const seenIds = new Set<CrmId>();
return items
.filter((item): item is SalesExpansionItem & { id: number } => typeof item.id === "number")
.filter((item): item is SalesExpansionItem & { id: CrmId } => isValidCrmId(item.id))
.filter((item) => {
if (seenIds.has(item.id)) {
return false;
@ -5202,9 +5205,9 @@ function buildSalesOptions(items: SalesExpansionItem[]): WorkRelationOption[] {
}
function buildChannelOptions(items: ChannelExpansionItem[]): WorkRelationOption[] {
const seenIds = new Set<number>();
const seenIds = new Set<CrmId>();
return items
.filter((item): item is ChannelExpansionItem & { id: number } => typeof item.id === "number")
.filter((item): item is ChannelExpansionItem & { id: CrmId } => isValidCrmId(item.id))
.filter((item) => {
if (seenIds.has(item.id)) {
return false;
@ -5216,9 +5219,9 @@ function buildChannelOptions(items: ChannelExpansionItem[]): WorkRelationOption[
}
function buildOpportunityOptions(items: OpportunityItem[]): WorkRelationOption[] {
const seenIds = new Set<number>();
const seenIds = new Set<CrmId>();
return items
.filter((item): item is OpportunityItem & { id: number } => typeof item.id === "number")
.filter((item): item is OpportunityItem & { id: CrmId } => isValidCrmId(item.id))
.filter((item) => {
if (seenIds.has(item.id)) {
return false;
@ -5229,6 +5232,13 @@ function buildOpportunityOptions(items: OpportunityItem[]): WorkRelationOption[]
.map((item) => ({ id: item.id, label: item.name || `商机#${item.id}` }));
}
function isValidCrmId(value: CrmId | null | undefined): value is CrmId {
if (typeof value === "string") {
return Boolean(value.trim());
}
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
}
function getBizTypeLabel(bizType: BizType) {
if (bizType === "sales") {
return "人员拓展";

View File

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UnisBase - 智能会议系统</title>
<script type="module" crossorigin src="/assets/index-BaO1tuFT.js"></script>
<script type="module" crossorigin src="/assets/index-CmIS0HGH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CaWPk49l.css">
</head>
<body>

View File

@ -27,7 +27,7 @@ import PageHeader from "@/components/shared/PageHeader";
import { usePermission } from "@/hooks/usePermission";
import type { SysUser } from "@/types";
import { executeOwnerTransfer, previewOwnerTransfer } from "@/features/owner-transfer/api";
import type { OwnerTransferItem, OwnerTransferPayload, OwnerTransferPreview } from "@/features/owner-transfer/types";
import type { CrmId, OwnerTransferItem, OwnerTransferPayload, OwnerTransferPreview } from "@/features/owner-transfer/types";
import "./index.less";
interface FormValues {
@ -66,6 +66,12 @@ function filterItems(items: OwnerTransferItem[], keyword: string) {
return items.filter((item) => `${item.name || ""} ${item.code || ""}`.toLowerCase().includes(normalizedKeyword));
}
function normalizeSelectedIds(ids: React.Key[]): CrmId[] {
return ids.filter((id): id is CrmId =>
typeof id === "string" || (typeof id === "number" && Number.isSafeInteger(id))
);
}
function hasBlockedTransferRole(user: SysUser) {
return (user.roles ?? []).some((role) => {
const normalizedRoleCode = role.roleCode?.trim().toUpperCase();
@ -282,9 +288,9 @@ export default function OwnerTransferPage() {
transferChannelExpansions: hasRowSelection ? selectedChannelRowKeys.length > 0 : values.transferChannelExpansions,
selection: hasRowSelection
? {
opportunityIds: selectedOpportunityRowKeys.map((id) => Number(id)),
salesExpansionIds: selectedSalesRowKeys.map((id) => Number(id)),
channelExpansionIds: selectedChannelRowKeys.map((id) => Number(id)),
opportunityIds: normalizeSelectedIds(selectedOpportunityRowKeys),
salesExpansionIds: normalizeSelectedIds(selectedSalesRowKeys),
channelExpansionIds: normalizeSelectedIds(selectedChannelRowKeys),
}
: undefined,
};
@ -369,9 +375,9 @@ export default function OwnerTransferPage() {
transferSalesExpansions: selectedSalesRowKeys.length > 0,
transferChannelExpansions: selectedChannelRowKeys.length > 0,
selection: {
opportunityIds: selectedOpportunityRowKeys.map((id) => Number(id)),
salesExpansionIds: selectedSalesRowKeys.map((id) => Number(id)),
channelExpansionIds: selectedChannelRowKeys.map((id) => Number(id)),
opportunityIds: normalizeSelectedIds(selectedOpportunityRowKeys),
salesExpansionIds: normalizeSelectedIds(selectedSalesRowKeys),
channelExpansionIds: normalizeSelectedIds(selectedChannelRowKeys),
},
};

View File

@ -1,10 +1,12 @@
export type CrmId = string | number;
export interface OwnerTransferConflict {
employeeNo: string;
candidateName: string;
}
export interface OwnerTransferItem {
id: number;
id: CrmId;
name: string;
code?: string;
conflict: boolean;
@ -26,9 +28,9 @@ export interface OwnerTransferPreview {
}
export interface OwnerTransferSelection {
opportunityIds?: number[];
salesExpansionIds?: number[];
channelExpansionIds?: number[];
opportunityIds?: CrmId[];
salesExpansionIds?: CrmId[];
channelExpansionIds?: CrmId[];
}
export interface OwnerTransferPayload {

View File

@ -0,0 +1,153 @@
-- Fix historical customer-code duplicates before restoring the intended constraint.
-- The first row keeps its business code; later duplicates receive a traceable migration code.
begin;
set local lock_timeout = '10s';
set local statement_timeout = '5min';
select pg_advisory_xact_lock(hashtext('20260713_fix_crm_codes_pg17'));
with ranked_customer_codes as (
select
id,
row_number() over (partition by customer_code order by created_at nulls last, id) as row_no
from crm_customer
where customer_code is not null
and btrim(customer_code) <> ''
)
update crm_customer customer
set customer_code = 'CUS-MIG-' || customer.id::text,
updated_at = now()
from ranked_customer_codes duplicate
where duplicate.id = customer.id
and duplicate.row_no > 1;
do $$
declare
constraint_definition text;
begin
select pg_get_constraintdef(oid)
into constraint_definition
from pg_constraint
where conrelid = 'crm_customer'::regclass
and conname = 'uk_crm_customer_code';
if constraint_definition is not null
and constraint_definition not ilike 'UNIQUE (customer_code)%' then
alter table crm_customer drop constraint uk_crm_customer_code;
constraint_definition := null;
end if;
if constraint_definition is null then
alter table crm_customer
add constraint uk_crm_customer_code unique (customer_code);
end if;
end $$;
create sequence if not exists crm_customer_code_seq start with 1 increment by 1 minvalue 1;
create sequence if not exists crm_opportunity_code_seq start with 1 increment by 1 minvalue 1;
-- Lock sequence consumers before reading their current values.
alter sequence crm_customer_code_seq no cycle;
alter sequence crm_opportunity_code_seq no cycle;
do $$
declare
sales_id_sequence regclass;
channel_id_sequence regclass;
begin
if to_regclass('crm_sales_expansion') is not null then
sales_id_sequence := pg_get_serial_sequence('crm_sales_expansion', 'id')::regclass;
end if;
if to_regclass('crm_channel_expansion') is not null then
channel_id_sequence := pg_get_serial_sequence('crm_channel_expansion', 'id')::regclass;
end if;
if sales_id_sequence is not null then
execute format('alter sequence %s no cycle', sales_id_sequence);
end if;
if channel_id_sequence is not null then
execute format('alter sequence %s no cycle', channel_id_sequence);
end if;
end $$;
-- ALTER SEQUENCE RESTART is transactional; setval() would survive a rollback.
do $$
declare
customer_code_next bigint;
opportunity_code_next bigint;
begin
select greatest(
coalesce((
select max(substring(customer_code from '([0-9]+)$')::bigint) + 1
from crm_customer
where customer_code ~ '^CUS-[0-9]{8}-[0-9]+$'
), 1),
(select case when is_called then last_value + 1 else last_value end from crm_customer_code_seq)
)
into customer_code_next;
select greatest(
coalesce((
select max(substring(opportunity_code from '([0-9]+)$')::bigint) + 1
from crm_opportunity
where opportunity_code ~ '^OPP-[0-9]{8}-[0-9]+$'
), 1),
(select case when is_called then last_value + 1 else last_value end from crm_opportunity_code_seq)
)
into opportunity_code_next;
execute format('alter sequence crm_customer_code_seq restart with %s', customer_code_next);
execute format('alter sequence crm_opportunity_code_seq restart with %s', opportunity_code_next);
end $$;
do $$
declare
sales_id_sequence regclass;
channel_id_sequence regclass;
sales_id_max bigint;
sales_id_current_next bigint;
channel_id_max bigint;
channel_id_current_next bigint;
begin
if to_regclass('crm_sales_expansion') is not null then
sales_id_sequence := pg_get_serial_sequence('crm_sales_expansion', 'id')::regclass;
end if;
if to_regclass('crm_channel_expansion') is not null then
channel_id_sequence := pg_get_serial_sequence('crm_channel_expansion', 'id')::regclass;
end if;
if sales_id_sequence is not null then
select coalesce(max(id), 0) into sales_id_max from crm_sales_expansion;
if sales_id_max = 9223372036854775807 then
raise exception 'crm_sales_expansion identity sequence has exhausted bigint values';
end if;
execute format(
'select case when is_called then last_value + 1 else last_value end from %s',
sales_id_sequence
) into sales_id_current_next;
execute format(
'alter sequence %s restart with %s',
sales_id_sequence,
greatest(sales_id_max + 1, sales_id_current_next)
);
end if;
if channel_id_sequence is not null then
select coalesce(max(id), 0) into channel_id_max from crm_channel_expansion;
if channel_id_max = 9223372036854775807 then
raise exception 'crm_channel_expansion identity sequence has exhausted bigint values';
end if;
execute format(
'select case when is_called then last_value + 1 else last_value end from %s',
channel_id_sequence
) into channel_id_current_next;
execute format(
'alter sequence %s restart with %s',
channel_id_sequence,
greatest(channel_id_max + 1, channel_id_current_next)
);
end if;
end $$;
commit;

View File

@ -117,6 +117,8 @@ create table if not exists crm_customer (
constraint uk_crm_customer_code unique (customer_code)
);
create sequence if not exists crm_customer_code_seq start with 1 increment by 1 minvalue 1;
create table if not exists crm_opportunity (
id bigint generated by default as identity primary key,
opportunity_code varchar(50) not null,
@ -156,6 +158,8 @@ create table if not exists crm_opportunity (
constraint fk_crm_opportunity_customer foreign key (customer_id) references crm_customer(id)
);
create sequence if not exists crm_opportunity_code_seq start with 1 increment by 1 minvalue 1;
create table if not exists crm_opportunity_followup (
id bigint generated by default as identity primary key,
opportunity_id bigint not null,