From 349cdf26e6f1388979b1d1bb971b214d8f3c90f6 Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Fri, 24 Jul 2026 14:05:35 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AF=AD=E9=9F=B3=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpeechRecognitionSchemaInitializer.java | 67 +++ .../SpeechRecognitionAdminController.java | 34 ++ .../unis/crm/controller/WorkController.java | 9 + .../speech/SpeechRecognitionConfigDTO.java | 218 ++++++++ .../dto/work/WorkSpeechTranscriptionDTO.java | 21 + .../SpeechRecognitionConfigService.java | 318 +++++++++++ .../com/unis/crm/service/WorkService.java | 3 + .../crm/service/impl/WorkServiceImpl.java | 447 +++++++++++++++ backend/src/main/resources/application.yml | 4 +- .../crm/service/impl/WorkServiceImplTest.java | 5 + frontend/nginx/default.conf.template | 12 +- frontend/src/lib/auth.ts | 13 + frontend/src/pages/Work.tsx | 522 +++++++++++++++++- frontend/vite.config.ts | 3 + frontend1/dist/index.html | 2 +- .../src/features/speech-recognition/api.ts | 12 + .../src/features/speech-recognition/index.ts | 1 + .../speech-recognition-settings/index.less | 91 +++ .../speech-recognition-settings/index.tsx | 359 ++++++++++++ .../src/features/speech-recognition/types.ts | 24 + frontend1/src/routes/routes.tsx | 2 + sql/init_speech_recognition_config_pg17.sql | 209 +++++++ 22 files changed, 2345 insertions(+), 31 deletions(-) create mode 100644 backend/src/main/java/com/unis/crm/common/SpeechRecognitionSchemaInitializer.java create mode 100644 backend/src/main/java/com/unis/crm/controller/SpeechRecognitionAdminController.java create mode 100644 backend/src/main/java/com/unis/crm/dto/speech/SpeechRecognitionConfigDTO.java create mode 100644 backend/src/main/java/com/unis/crm/dto/work/WorkSpeechTranscriptionDTO.java create mode 100644 backend/src/main/java/com/unis/crm/service/SpeechRecognitionConfigService.java create mode 100644 frontend1/src/features/speech-recognition/api.ts create mode 100644 frontend1/src/features/speech-recognition/index.ts create mode 100644 frontend1/src/features/speech-recognition/pages/speech-recognition-settings/index.less create mode 100644 frontend1/src/features/speech-recognition/pages/speech-recognition-settings/index.tsx create mode 100644 frontend1/src/features/speech-recognition/types.ts create mode 100644 sql/init_speech_recognition_config_pg17.sql diff --git a/backend/src/main/java/com/unis/crm/common/SpeechRecognitionSchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/SpeechRecognitionSchemaInitializer.java new file mode 100644 index 00000000..c06fb05d --- /dev/null +++ b/backend/src/main/java/com/unis/crm/common/SpeechRecognitionSchemaInitializer.java @@ -0,0 +1,67 @@ +package com.unis.crm.common; + +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import javax.sql.DataSource; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +public class SpeechRecognitionSchemaInitializer implements ApplicationRunner { + + private final DataSource dataSource; + + public SpeechRecognitionSchemaInitializer(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public void run(ApplicationArguments args) { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute(""" + create table if not exists speech_recognition_config ( + id bigint generated by default as identity primary key, + tenant_id bigint not null, + enabled boolean not null default true, + base_url varchar(512), + transcribe_path varchar(255) not null default '/v1/audio/transcriptions', + task_status_path varchar(255) not null default '/v1/tasks/{task_id}', + file_field_name varchar(64) not null default 'file', + language varchar(32), + response_format varchar(32) not null default 'json', + model varchar(128), + prompt text, + hotwords text, + timestamp_granularities varchar(128), + enable_speaker_diarization boolean, + enable_speaker_identification boolean, + enable_text_cleanup boolean, + temperature double precision, + api_key varchar(512), + api_key_header varchar(128) not null default 'X-API-Key', + connect_timeout_seconds integer not null default 10, + read_timeout_seconds integer not null default 300, + task_timeout_seconds integer not null default 300, + task_poll_interval_millis integer not null default 1500, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uk_speech_recognition_config_tenant unique (tenant_id) + ) + """); + statement.execute("alter table if exists speech_recognition_config add column if not exists response_format varchar(32) not null default 'json'"); + statement.execute("alter table if exists speech_recognition_config add column if not exists model varchar(128)"); + statement.execute("alter table if exists speech_recognition_config add column if not exists prompt text"); + statement.execute("alter table if exists speech_recognition_config add column if not exists hotwords text"); + statement.execute("alter table if exists speech_recognition_config add column if not exists timestamp_granularities varchar(128)"); + statement.execute("alter table if exists speech_recognition_config add column if not exists enable_speaker_diarization boolean"); + statement.execute("alter table if exists speech_recognition_config add column if not exists enable_speaker_identification boolean"); + statement.execute("alter table if exists speech_recognition_config add column if not exists enable_text_cleanup boolean"); + statement.execute("alter table if exists speech_recognition_config add column if not exists temperature double precision"); + } catch (SQLException exception) { + throw new IllegalStateException("Failed to initialize speech recognition schema", exception); + } + } +} diff --git a/backend/src/main/java/com/unis/crm/controller/SpeechRecognitionAdminController.java b/backend/src/main/java/com/unis/crm/controller/SpeechRecognitionAdminController.java new file mode 100644 index 00000000..f1e654de --- /dev/null +++ b/backend/src/main/java/com/unis/crm/controller/SpeechRecognitionAdminController.java @@ -0,0 +1,34 @@ +package com.unis.crm.controller; + +import com.unis.crm.common.ApiResponse; +import com.unis.crm.dto.speech.SpeechRecognitionConfigDTO; +import com.unis.crm.service.SpeechRecognitionConfigService; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/sys/api/admin") +public class SpeechRecognitionAdminController { + + private final SpeechRecognitionConfigService speechRecognitionConfigService; + + public SpeechRecognitionAdminController(SpeechRecognitionConfigService speechRecognitionConfigService) { + this.speechRecognitionConfigService = speechRecognitionConfigService; + } + + @GetMapping("/speech-recognition-config") + public ApiResponse getSpeechRecognitionConfig( + @RequestParam(value = "tenantId", required = false) Long tenantId) { + return ApiResponse.success(speechRecognitionConfigService.getConfig(tenantId)); + } + + @PutMapping("/speech-recognition-config") + public ApiResponse updateSpeechRecognitionConfig(@Valid @RequestBody SpeechRecognitionConfigDTO payload) { + return ApiResponse.success(speechRecognitionConfigService.saveConfig(payload)); + } +} diff --git a/backend/src/main/java/com/unis/crm/controller/WorkController.java b/backend/src/main/java/com/unis/crm/controller/WorkController.java index 4ee4c053..8cc728c8 100644 --- a/backend/src/main/java/com/unis/crm/controller/WorkController.java +++ b/backend/src/main/java/com/unis/crm/controller/WorkController.java @@ -13,6 +13,7 @@ import com.unis.crm.dto.work.WorkHistoryPageDTO; import com.unis.crm.dto.work.WorkOverviewDTO; import com.unis.crm.dto.work.WorkReportAttachmentDTO; import com.unis.crm.dto.work.WorkReportToUserDTO; +import com.unis.crm.dto.work.WorkSpeechTranscriptionDTO; import com.unis.crm.service.WorkService; import com.unisbase.common.annotation.Log; import jakarta.validation.Valid; @@ -174,6 +175,14 @@ public class WorkController { .body(resource); } + @PostMapping(path = "/report-speech/transcribe", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Log(type = "工作管理", value = "日报语音转写") + public ApiResponse transcribeReportSpeech( + @RequestHeader("X-User-Id") Long userId, + @RequestPart("file") MultipartFile file) { + return ApiResponse.success(workService.transcribeReportSpeech(CurrentUserUtils.requireCurrentUserId(userId), file)); + } + @PostMapping("/daily-reports") @Log(type = "工作管理", value = "提交日报") public ApiResponse saveDailyReport( diff --git a/backend/src/main/java/com/unis/crm/dto/speech/SpeechRecognitionConfigDTO.java b/backend/src/main/java/com/unis/crm/dto/speech/SpeechRecognitionConfigDTO.java new file mode 100644 index 00000000..b91dd997 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/speech/SpeechRecognitionConfigDTO.java @@ -0,0 +1,218 @@ +package com.unis.crm.dto.speech; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +public class SpeechRecognitionConfigDTO { + + private Long tenantId; + private boolean enabled = true; + private String baseUrl; + private String transcribePath = "/v1/audio/transcriptions"; + private String taskStatusPath = "/v1/tasks/{task_id}"; + private String fileFieldName = "file"; + private String language; + private String responseFormat = "json"; + private String model; + private String prompt; + private String hotwords; + private String timestampGranularities; + private Boolean enableSpeakerDiarization; + private Boolean enableSpeakerIdentification; + private Boolean enableTextCleanup; + private Double temperature; + private String apiKey; + private String apiKeyHeader = "X-API-Key"; + + @Min(1) + @Max(120) + private long connectTimeoutSeconds = 10; + + @Min(1) + @Max(1800) + private long readTimeoutSeconds = 300; + + @Min(1) + @Max(1800) + private long taskTimeoutSeconds = 300; + + @Min(300) + @Max(60000) + private long taskPollIntervalMillis = 1500; + + public Long getTenantId() { + return tenantId; + } + + public void setTenantId(Long tenantId) { + this.tenantId = tenantId; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getTranscribePath() { + return transcribePath; + } + + public void setTranscribePath(String transcribePath) { + this.transcribePath = transcribePath; + } + + public String getTaskStatusPath() { + return taskStatusPath; + } + + public void setTaskStatusPath(String taskStatusPath) { + this.taskStatusPath = taskStatusPath; + } + + public String getFileFieldName() { + return fileFieldName; + } + + public void setFileFieldName(String fileFieldName) { + this.fileFieldName = fileFieldName; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public String getResponseFormat() { + return responseFormat; + } + + public void setResponseFormat(String responseFormat) { + this.responseFormat = responseFormat; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getPrompt() { + return prompt; + } + + public void setPrompt(String prompt) { + this.prompt = prompt; + } + + public String getHotwords() { + return hotwords; + } + + public void setHotwords(String hotwords) { + this.hotwords = hotwords; + } + + public String getTimestampGranularities() { + return timestampGranularities; + } + + public void setTimestampGranularities(String timestampGranularities) { + this.timestampGranularities = timestampGranularities; + } + + public Boolean getEnableSpeakerDiarization() { + return enableSpeakerDiarization; + } + + public void setEnableSpeakerDiarization(Boolean enableSpeakerDiarization) { + this.enableSpeakerDiarization = enableSpeakerDiarization; + } + + public Boolean getEnableSpeakerIdentification() { + return enableSpeakerIdentification; + } + + public void setEnableSpeakerIdentification(Boolean enableSpeakerIdentification) { + this.enableSpeakerIdentification = enableSpeakerIdentification; + } + + public Boolean getEnableTextCleanup() { + return enableTextCleanup; + } + + public void setEnableTextCleanup(Boolean enableTextCleanup) { + this.enableTextCleanup = enableTextCleanup; + } + + public Double getTemperature() { + return temperature; + } + + public void setTemperature(Double temperature) { + this.temperature = temperature; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getApiKeyHeader() { + return apiKeyHeader; + } + + public void setApiKeyHeader(String apiKeyHeader) { + this.apiKeyHeader = apiKeyHeader; + } + + public long getConnectTimeoutSeconds() { + return connectTimeoutSeconds; + } + + public void setConnectTimeoutSeconds(long connectTimeoutSeconds) { + this.connectTimeoutSeconds = connectTimeoutSeconds; + } + + public long getReadTimeoutSeconds() { + return readTimeoutSeconds; + } + + public void setReadTimeoutSeconds(long readTimeoutSeconds) { + this.readTimeoutSeconds = readTimeoutSeconds; + } + + public long getTaskTimeoutSeconds() { + return taskTimeoutSeconds; + } + + public void setTaskTimeoutSeconds(long taskTimeoutSeconds) { + this.taskTimeoutSeconds = taskTimeoutSeconds; + } + + public long getTaskPollIntervalMillis() { + return taskPollIntervalMillis; + } + + public void setTaskPollIntervalMillis(long taskPollIntervalMillis) { + this.taskPollIntervalMillis = taskPollIntervalMillis; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkSpeechTranscriptionDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkSpeechTranscriptionDTO.java new file mode 100644 index 00000000..3429f7c9 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkSpeechTranscriptionDTO.java @@ -0,0 +1,21 @@ +package com.unis.crm.dto.work; + +public class WorkSpeechTranscriptionDTO { + + private String text; + + public WorkSpeechTranscriptionDTO() { + } + + public WorkSpeechTranscriptionDTO(String text) { + this.text = text; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } +} diff --git a/backend/src/main/java/com/unis/crm/service/SpeechRecognitionConfigService.java b/backend/src/main/java/com/unis/crm/service/SpeechRecognitionConfigService.java new file mode 100644 index 00000000..32b88def --- /dev/null +++ b/backend/src/main/java/com/unis/crm/service/SpeechRecognitionConfigService.java @@ -0,0 +1,318 @@ +package com.unis.crm.service; + +import com.unis.crm.common.BusinessException; +import com.unis.crm.common.UnauthorizedException; +import com.unis.crm.dto.speech.SpeechRecognitionConfigDTO; +import com.unisbase.security.PermissionService; +import com.unisbase.security.SpringSecurityTenantProvider; +import java.net.URI; +import java.util.List; +import java.util.Set; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Service +public class SpeechRecognitionConfigService { + + private static final String VIEW_PERM = "speech_recognition_config:view"; + private static final String UPDATE_PERM = "speech_recognition_config:update"; + private static final long GLOBAL_TENANT_ID = 0L; + + private final JdbcTemplate jdbcTemplate; + private final PermissionService permissionService; + private final SpringSecurityTenantProvider tenantProvider; + + public SpeechRecognitionConfigService( + JdbcTemplate jdbcTemplate, + PermissionService permissionService, + SpringSecurityTenantProvider tenantProvider) { + this.jdbcTemplate = jdbcTemplate; + this.permissionService = permissionService; + this.tenantProvider = tenantProvider; + } + + public SpeechRecognitionConfigDTO getConfig(Long tenantId) { + requirePermission(VIEW_PERM, "无权查看语音识别配置"); + Long resolvedTenantId = resolveTenantId(tenantId); + SpeechRecognitionConfigDTO config = findConfig(resolvedTenantId); + return config == null ? defaultConfig(resolvedTenantId) : config; + } + + @Transactional + public boolean saveConfig(SpeechRecognitionConfigDTO payload) { + requirePermission(UPDATE_PERM, "无权修改语音识别配置"); + Long tenantId = resolveTenantId(payload == null ? null : payload.getTenantId()); + SpeechRecognitionConfigDTO normalized = normalizeConfig(payload, tenantId); + jdbcTemplate.update(""" + insert into speech_recognition_config ( + tenant_id, + enabled, + base_url, + transcribe_path, + task_status_path, + file_field_name, + language, + response_format, + model, + prompt, + hotwords, + timestamp_granularities, + enable_speaker_diarization, + enable_speaker_identification, + enable_text_cleanup, + temperature, + api_key, + api_key_header, + connect_timeout_seconds, + read_timeout_seconds, + task_timeout_seconds, + task_poll_interval_millis, + created_at, + updated_at + ) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now()) + on conflict (tenant_id) do update + set enabled = excluded.enabled, + base_url = excluded.base_url, + transcribe_path = excluded.transcribe_path, + task_status_path = excluded.task_status_path, + file_field_name = excluded.file_field_name, + language = excluded.language, + response_format = excluded.response_format, + model = excluded.model, + prompt = excluded.prompt, + hotwords = excluded.hotwords, + timestamp_granularities = excluded.timestamp_granularities, + enable_speaker_diarization = excluded.enable_speaker_diarization, + enable_speaker_identification = excluded.enable_speaker_identification, + enable_text_cleanup = excluded.enable_text_cleanup, + temperature = excluded.temperature, + api_key = excluded.api_key, + api_key_header = excluded.api_key_header, + connect_timeout_seconds = excluded.connect_timeout_seconds, + read_timeout_seconds = excluded.read_timeout_seconds, + task_timeout_seconds = excluded.task_timeout_seconds, + task_poll_interval_millis = excluded.task_poll_interval_millis, + updated_at = now() + """, + tenantId, + normalized.isEnabled(), + normalized.getBaseUrl(), + normalized.getTranscribePath(), + normalized.getTaskStatusPath(), + normalized.getFileFieldName(), + normalized.getLanguage(), + normalized.getResponseFormat(), + normalized.getModel(), + normalized.getPrompt(), + normalized.getHotwords(), + normalized.getTimestampGranularities(), + normalized.getEnableSpeakerDiarization(), + normalized.getEnableSpeakerIdentification(), + normalized.getEnableTextCleanup(), + normalized.getTemperature(), + normalized.getApiKey(), + normalized.getApiKeyHeader(), + normalized.getConnectTimeoutSeconds(), + normalized.getReadTimeoutSeconds(), + normalized.getTaskTimeoutSeconds(), + normalized.getTaskPollIntervalMillis()); + return true; + } + + public SpeechRecognitionConfigDTO getEffectiveConfig(Long tenantId) { + Long resolvedTenantId = tenantId == null ? resolveCurrentTenantId() : tenantId; + if (resolvedTenantId == null) { + resolvedTenantId = GLOBAL_TENANT_ID; + } + + SpeechRecognitionConfigDTO tenantConfig = findConfig(resolvedTenantId); + if (tenantConfig != null) { + return tenantConfig; + } + + if (resolvedTenantId != GLOBAL_TENANT_ID) { + SpeechRecognitionConfigDTO globalConfig = findConfig(GLOBAL_TENANT_ID); + if (globalConfig != null) { + return globalConfig; + } + } + + return defaultConfig(resolvedTenantId); + } + + private SpeechRecognitionConfigDTO normalizeConfig(SpeechRecognitionConfigDTO payload, Long tenantId) { + SpeechRecognitionConfigDTO defaults = defaultConfig(tenantId); + SpeechRecognitionConfigDTO config = payload == null ? defaults : payload; + + SpeechRecognitionConfigDTO normalized = new SpeechRecognitionConfigDTO(); + normalized.setTenantId(tenantId); + normalized.setEnabled(config.isEnabled()); + normalized.setBaseUrl(defaultIfBlank(config.getBaseUrl(), defaults.getBaseUrl())); + normalized.setTranscribePath(defaultIfBlank(config.getTranscribePath(), defaults.getTranscribePath())); + normalized.setTaskStatusPath(defaultIfBlank(config.getTaskStatusPath(), defaults.getTaskStatusPath())); + normalized.setFileFieldName("file"); + normalized.setLanguage(trimToNull(config.getLanguage())); + normalized.setResponseFormat(defaultIfBlank(config.getResponseFormat(), defaults.getResponseFormat())); + normalized.setModel(trimToNull(config.getModel())); + normalized.setPrompt(trimToNull(config.getPrompt())); + normalized.setHotwords(trimToNull(config.getHotwords())); + normalized.setTimestampGranularities(trimToNull(config.getTimestampGranularities())); + normalized.setEnableSpeakerDiarization(config.getEnableSpeakerDiarization()); + normalized.setEnableSpeakerIdentification(config.getEnableSpeakerIdentification()); + normalized.setEnableTextCleanup(config.getEnableTextCleanup()); + normalized.setTemperature(config.getTemperature()); + normalized.setApiKey(trimToNull(config.getApiKey())); + normalized.setApiKeyHeader(defaultIfBlank(config.getApiKeyHeader(), defaults.getApiKeyHeader())); + normalized.setConnectTimeoutSeconds(clamp(config.getConnectTimeoutSeconds(), 1, 120, defaults.getConnectTimeoutSeconds())); + normalized.setReadTimeoutSeconds(clamp(config.getReadTimeoutSeconds(), 1, 1800, defaults.getReadTimeoutSeconds())); + normalized.setTaskTimeoutSeconds(clamp(config.getTaskTimeoutSeconds(), 1, 1800, defaults.getTaskTimeoutSeconds())); + normalized.setTaskPollIntervalMillis(clamp(config.getTaskPollIntervalMillis(), 300, 60000, defaults.getTaskPollIntervalMillis())); + + if (normalized.isEnabled()) { + validateBaseUrl(normalized.getBaseUrl()); + } + return normalized; + } + + private SpeechRecognitionConfigDTO defaultConfig(Long tenantId) { + SpeechRecognitionConfigDTO config = new SpeechRecognitionConfigDTO(); + config.setTenantId(tenantId); + config.setEnabled(true); + config.setBaseUrl(null); + config.setTranscribePath("/v1/audio/transcriptions"); + config.setTaskStatusPath("/v1/tasks/{task_id}"); + config.setFileFieldName("file"); + config.setLanguage(null); + config.setResponseFormat("json"); + config.setApiKey(null); + config.setApiKeyHeader("X-API-Key"); + config.setConnectTimeoutSeconds(10); + config.setReadTimeoutSeconds(300); + config.setTaskTimeoutSeconds(300); + config.setTaskPollIntervalMillis(1500); + return config; + } + + private SpeechRecognitionConfigDTO findConfig(Long tenantId) { + if (tenantId == null) { + return null; + } + List rows = jdbcTemplate.query(""" + select tenant_id, + enabled, + base_url, + transcribe_path, + task_status_path, + file_field_name, + language, + response_format, + model, + prompt, + hotwords, + timestamp_granularities, + enable_speaker_diarization, + enable_speaker_identification, + enable_text_cleanup, + temperature, + api_key, + api_key_header, + connect_timeout_seconds, + read_timeout_seconds, + task_timeout_seconds, + task_poll_interval_millis + from speech_recognition_config + where tenant_id = ? + limit 1 + """, configRowMapper(), tenantId); + return rows.isEmpty() ? null : rows.get(0); + } + + private RowMapper configRowMapper() { + return (resultSet, rowNum) -> { + SpeechRecognitionConfigDTO dto = new SpeechRecognitionConfigDTO(); + dto.setTenantId(resultSet.getLong("tenant_id")); + dto.setEnabled(resultSet.getBoolean("enabled")); + dto.setBaseUrl(trimToNull(resultSet.getString("base_url"))); + dto.setTranscribePath(trimToNull(resultSet.getString("transcribe_path"))); + dto.setTaskStatusPath(trimToNull(resultSet.getString("task_status_path"))); + dto.setFileFieldName("file"); + dto.setLanguage(trimToNull(resultSet.getString("language"))); + dto.setResponseFormat(defaultIfBlank(resultSet.getString("response_format"), "json")); + dto.setModel(trimToNull(resultSet.getString("model"))); + dto.setPrompt(trimToNull(resultSet.getString("prompt"))); + dto.setHotwords(trimToNull(resultSet.getString("hotwords"))); + dto.setTimestampGranularities(trimToNull(resultSet.getString("timestamp_granularities"))); + dto.setEnableSpeakerDiarization((Boolean) resultSet.getObject("enable_speaker_diarization")); + dto.setEnableSpeakerIdentification((Boolean) resultSet.getObject("enable_speaker_identification")); + dto.setEnableTextCleanup((Boolean) resultSet.getObject("enable_text_cleanup")); + dto.setTemperature((Double) resultSet.getObject("temperature")); + dto.setApiKey(trimToNull(resultSet.getString("api_key"))); + dto.setApiKeyHeader(trimToNull(resultSet.getString("api_key_header"))); + dto.setConnectTimeoutSeconds(resultSet.getLong("connect_timeout_seconds")); + dto.setReadTimeoutSeconds(resultSet.getLong("read_timeout_seconds")); + dto.setTaskTimeoutSeconds(resultSet.getLong("task_timeout_seconds")); + dto.setTaskPollIntervalMillis(resultSet.getLong("task_poll_interval_millis")); + return dto; + }; + } + + private void validateBaseUrl(String baseUrl) { + if (!StringUtils.hasText(baseUrl)) { + throw new BusinessException("语音识别服务地址不能为空"); + } + try { + URI uri = URI.create(baseUrl); + if (!uri.isAbsolute() || uri.getScheme() == null || !Set.of("http", "https").contains(uri.getScheme().toLowerCase())) { + throw new IllegalArgumentException(); + } + } catch (Exception exception) { + throw new BusinessException("语音识别服务地址格式不正确"); + } + } + + private Long resolveTenantId(Long tenantId) { + return tenantId == null ? resolveCurrentTenantId() : tenantId; + } + + private Long resolveCurrentTenantId() { + if (tenantProvider == null) { + return GLOBAL_TENANT_ID; + } + try { + Long tenantId = tenantProvider.getCurrentTenantId(); + return tenantId == null ? GLOBAL_TENANT_ID : tenantId; + } catch (Exception ignored) { + return GLOBAL_TENANT_ID; + } + } + + private void requirePermission(String perm, String message) { + if (!permissionService.hasPermi(perm)) { + throw new UnauthorizedException(message); + } + } + + private String defaultIfBlank(String value, String defaultValue) { + String trimmed = trimToNull(value); + return trimmed == null ? defaultValue : trimmed; + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private long clamp(long value, long min, long max, long defaultValue) { + if (value <= 0) { + return defaultValue; + } + return Math.max(min, Math.min(max, value)); + } +} diff --git a/backend/src/main/java/com/unis/crm/service/WorkService.java b/backend/src/main/java/com/unis/crm/service/WorkService.java index 3434a004..be3a8847 100644 --- a/backend/src/main/java/com/unis/crm/service/WorkService.java +++ b/backend/src/main/java/com/unis/crm/service/WorkService.java @@ -10,6 +10,7 @@ import com.unis.crm.dto.work.WorkOverviewDTO; import com.unis.crm.dto.work.WorkDailyReportRequirementDTO; import com.unis.crm.dto.work.WorkReportAttachmentDTO; import com.unis.crm.dto.work.WorkReportToUserDTO; +import com.unis.crm.dto.work.WorkSpeechTranscriptionDTO; import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; @@ -59,4 +60,6 @@ public interface WorkService { WorkReportAttachmentDTO uploadReportAttachment(Long userId, MultipartFile file); Resource loadReportAttachment(String fileName); + + WorkSpeechTranscriptionDTO transcribeReportSpeech(Long userId, MultipartFile file); } diff --git a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java index 68756f6c..96c6f6c8 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.fasterxml.jackson.core.type.TypeReference; import com.unis.crm.common.BusinessException; import com.unis.crm.config.WorkReportProperties; +import com.unis.crm.dto.speech.SpeechRecognitionConfigDTO; import com.unis.crm.dto.work.CreateWorkCheckInRequest; import com.unis.crm.dto.work.CreateWorkDailyReportRequest; import com.unis.crm.dto.work.WorkCheckInDTO; @@ -21,6 +22,7 @@ import com.unis.crm.dto.work.WorkTomorrowPlanItemRequest; import com.unis.crm.dto.work.WorkOverviewDTO; import com.unis.crm.dto.work.WorkReportAttachmentDTO; import com.unis.crm.dto.work.WorkSuggestedActionDTO; +import com.unis.crm.dto.work.WorkSpeechTranscriptionDTO; import com.unis.crm.mapper.OpportunityMapper; import com.unis.crm.mapper.ProfileMapper; import com.unis.crm.mapper.WorkMapper; @@ -28,6 +30,7 @@ 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.SpeechRecognitionConfigService; import com.unis.crm.service.WorkService; import com.unisbase.service.SysPermissionService; import com.unisbase.spi.UnisBaseTenantProvider; @@ -36,8 +39,10 @@ import java.math.BigDecimal; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; +import java.net.http.HttpConnectTimeoutException; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.LocalDate; @@ -52,6 +57,7 @@ import java.util.Comparator; import java.util.LinkedHashSet; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -59,12 +65,22 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.client.RestTemplate; import org.springframework.web.multipart.MultipartFile; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -114,6 +130,7 @@ public class WorkServiceImpl implements WorkService { private final CrmDataVisibilityService crmDataVisibilityService; private final ReportReminderService reportReminderService; private final WorkReportProperties workReportProperties; + private final SpeechRecognitionConfigService speechRecognitionConfigService; private final SysPermissionService sysPermissionService; private final JdbcTemplate jdbcTemplate; private final FileStorageService fileStorageService; @@ -126,6 +143,7 @@ public class WorkServiceImpl implements WorkService { ProfileMapper profileMapper, ReportReminderService reportReminderService, WorkReportProperties workReportProperties, + SpeechRecognitionConfigService speechRecognitionConfigService, ObjectMapper objectMapper, UnisBaseTenantProvider tenantProvider, CrmDataVisibilityService crmDataVisibilityService, @@ -138,6 +156,7 @@ public class WorkServiceImpl implements WorkService { this.profileMapper = profileMapper; this.reportReminderService = reportReminderService; this.workReportProperties = workReportProperties == null ? new WorkReportProperties() : workReportProperties; + this.speechRecognitionConfigService = speechRecognitionConfigService; this.objectMapper = objectMapper; this.tenantProvider = tenantProvider; this.crmDataVisibilityService = crmDataVisibilityService; @@ -493,6 +512,429 @@ public class WorkServiceImpl implements WorkService { return fileStorageService.load("work-report-attachments/" + normalizedFileName); } + @Override + public WorkSpeechTranscriptionDTO transcribeReportSpeech(Long userId, MultipartFile file) { + requireUser(userId); + ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录"); + SpeechRecognitionConfigDTO speechConfig = resolveSpeechRecognitionConfig(); + if (!speechConfig.isEnabled()) { + throw new BusinessException("日报语音识别功能未启用"); + } + if (file == null || file.isEmpty()) { + throw new BusinessException("请先录制语音"); + } + + try { + HttpClient speechHttpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(Math.max(1, speechConfig.getConnectTimeoutSeconds()))) + .build(); + ResponseEntity response = postSpeechRecognitionMultipart(file, speechConfig); + int statusCode = response.getStatusCode().value(); + String responseBody = response.getBody(); + if (statusCode == 401 || statusCode == 403) { + throw new BusinessException("语音识别服务鉴权失败,请检查 API Key 配置"); + } + if (statusCode < 200 || statusCode >= 300) { + String errorMessage = extractSpeechRecognitionErrorMessage(responseBody); + throw new BusinessException(errorMessage == null + ? "语音识别服务返回异常:" + statusCode + : "语音识别失败:" + errorMessage); + } + + String text = extractSpeechRecognitionText(responseBody); + if (text == null) { + String taskId = extractSpeechRecognitionTaskId(responseBody); + if (taskId == null) { + throw new BusinessException("语音识别结果为空,请重新录音"); + } + text = pollSpeechRecognitionTask(taskId, speechConfig, speechHttpClient); + } + return new WorkSpeechTranscriptionDTO(text); + } catch (RestClientResponseException exception) { + if (exception.getStatusCode().value() == 401 || exception.getStatusCode().value() == 403) { + throw new BusinessException("语音识别服务鉴权失败,请检查 API Key 配置"); + } + String errorMessage = extractSpeechRecognitionErrorMessage(exception.getResponseBodyAsString()); + throw new BusinessException(errorMessage == null + ? "语音识别服务返回异常:" + exception.getStatusCode().value() + : "语音识别失败:" + errorMessage); + } catch (BusinessException exception) { + throw exception; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new BusinessException("语音识别已中断,请重试"); + } catch (HttpConnectTimeoutException exception) { + throw new BusinessException("语音识别服务连接超时,请检查 ASR 服务网络或稍后重试"); + } catch (HttpTimeoutException exception) { + throw new BusinessException("语音识别服务响应超时,请缩短录音时长后重试"); + } catch (Exception exception) { + throw new BusinessException("语音识别失败,请稍后重试"); + } + } + + private ResponseEntity postSpeechRecognitionMultipart(MultipartFile file, SpeechRecognitionConfigDTO speechConfig) throws IOException { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout((int) Duration.ofSeconds(Math.max(1, speechConfig.getConnectTimeoutSeconds())).toMillis()); + requestFactory.setReadTimeout((int) Duration.ofSeconds(Math.max(1, speechConfig.getReadTimeoutSeconds())).toMillis()); + RestTemplate restTemplate = new RestTemplate(requestFactory); + + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(List.of(MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN, MediaType.ALL)); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + applySpeechRecognitionAuthentication(headers, speechConfig); + + MultiValueMap body = new LinkedMultiValueMap<>(); + String language = normalizeOptionalText(speechConfig.getLanguage()); + if (language != null) { + body.add("language", language); + } + addSpeechRecognitionTextPart(body, "response_format", defaultIfBlank(speechConfig.getResponseFormat(), "json")); + addSpeechRecognitionTextPart(body, "model", speechConfig.getModel()); + addSpeechRecognitionTextPart(body, "prompt", speechConfig.getPrompt()); + addSpeechRecognitionTextPart(body, "hotwords", speechConfig.getHotwords()); + addSpeechRecognitionTextPart(body, "timestamp_granularities", speechConfig.getTimestampGranularities()); + addSpeechRecognitionBooleanPart(body, "enable_speaker_diarization", speechConfig.getEnableSpeakerDiarization()); + addSpeechRecognitionBooleanPart(body, "enable_speaker_identification", speechConfig.getEnableSpeakerIdentification()); + addSpeechRecognitionBooleanPart(body, "enable_text_cleanup", speechConfig.getEnableTextCleanup()); + if (speechConfig.getTemperature() != null) { + body.add("temperature", String.valueOf(speechConfig.getTemperature())); + } + body.add("file", buildSpeechRecognitionFilePart(file)); + + return restTemplate.postForEntity(buildSpeechRecognitionUri(speechConfig), new HttpEntity<>(body, headers), String.class); + } + + private void addSpeechRecognitionTextPart(MultiValueMap body, String name, String value) { + String normalizedValue = normalizeOptionalText(value); + if (normalizedValue != null) { + body.add(name, normalizedValue); + } + } + + private void addSpeechRecognitionBooleanPart(MultiValueMap body, String name, Boolean value) { + if (value != null) { + body.add(name, String.valueOf(value)); + } + } + + private HttpEntity buildSpeechRecognitionFilePart(MultipartFile file) throws IOException { + String fileName = normalizeOptionalText(file.getOriginalFilename()); + if (fileName == null) { + fileName = "report-speech.webm"; + } + String resolvedFileName = fileName; + String contentType = normalizeOptionalText(file.getContentType()); + + HttpHeaders fileHeaders = new HttpHeaders(); + fileHeaders.setContentType(contentType == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(contentType)); + ByteArrayResource resource = new ByteArrayResource(file.getBytes()) { + @Override + public String getFilename() { + return resolvedFileName; + } + + @Override + public long contentLength() { + return file.getSize(); + } + }; + return new HttpEntity<>(resource, fileHeaders); + } + + private String pollSpeechRecognitionTask(String taskId, SpeechRecognitionConfigDTO speechConfig, HttpClient speechHttpClient) throws IOException, InterruptedException { + long timeoutSeconds = Math.max(1, speechConfig.getTaskTimeoutSeconds()); + long pollIntervalMillis = Math.max(300, speechConfig.getTaskPollIntervalMillis()); + long deadlineNanos = System.nanoTime() + Duration.ofSeconds(timeoutSeconds).toNanos(); + URI taskUri = buildSpeechRecognitionTaskUri(taskId, speechConfig); + + while (true) { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(taskUri) + .timeout(Duration.ofSeconds(Math.max(1, speechConfig.getReadTimeoutSeconds()))) + .header("Accept", "application/json,text/plain,*/*") + .GET(); + applySpeechRecognitionAuthentication(requestBuilder, speechConfig); + HttpResponse response = speechHttpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() == 401 || response.statusCode() == 403) { + throw new BusinessException("语音识别服务鉴权失败,请检查 API Key 配置"); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + String errorMessage = extractSpeechRecognitionErrorMessage(response.body()); + throw new BusinessException(errorMessage == null + ? "语音识别任务查询异常:" + response.statusCode() + : "语音识别失败:" + errorMessage); + } + + String text = extractSpeechRecognitionText(response.body()); + if (text != null) { + return text; + } + + String status = extractSpeechRecognitionTaskStatus(response.body()); + if (isSpeechRecognitionFailedStatus(status)) { + String errorMessage = extractSpeechRecognitionErrorMessage(response.body()); + throw new BusinessException(errorMessage == null ? "语音识别任务处理失败" : "语音识别失败:" + errorMessage); + } + if (isSpeechRecognitionCompletedStatus(status)) { + throw new BusinessException("语音识别结果为空,请重新录音"); + } + if (System.nanoTime() >= deadlineNanos) { + throw new BusinessException("语音识别服务响应超时,请缩短录音时长后重试"); + } + Thread.sleep(Math.min(pollIntervalMillis, Math.max(300, Duration.ofNanos(deadlineNanos - System.nanoTime()).toMillis()))); + } + } + + private SpeechRecognitionConfigDTO resolveSpeechRecognitionConfig() { + if (speechRecognitionConfigService != null) { + return speechRecognitionConfigService.getEffectiveConfig(resolveCurrentTenantId()); + } + SpeechRecognitionConfigDTO config = new SpeechRecognitionConfigDTO(); + config.setEnabled(true); + config.setBaseUrl(null); + config.setTranscribePath("/v1/audio/transcriptions"); + config.setTaskStatusPath("/v1/tasks/{task_id}"); + config.setFileFieldName("file"); + config.setLanguage(null); + config.setResponseFormat("json"); + config.setApiKey(null); + config.setApiKeyHeader("X-API-Key"); + config.setConnectTimeoutSeconds(10); + config.setReadTimeoutSeconds(300); + config.setTaskTimeoutSeconds(300); + config.setTaskPollIntervalMillis(1500); + return config; + } + + private void applySpeechRecognitionAuthentication(HttpRequest.Builder requestBuilder, SpeechRecognitionConfigDTO speechConfig) { + String apiKey = normalizeOptionalText(speechConfig.getApiKey()); + if (apiKey == null) { + return; + } + String apiKeyHeader = normalizeOptionalText(speechConfig.getApiKeyHeader()); + requestBuilder.header(apiKeyHeader == null ? "X-API-Key" : apiKeyHeader, apiKey); + } + + private void applySpeechRecognitionAuthentication(HttpHeaders headers, SpeechRecognitionConfigDTO speechConfig) { + String apiKey = normalizeOptionalText(speechConfig.getApiKey()); + if (apiKey == null) { + return; + } + String apiKeyHeader = normalizeOptionalText(speechConfig.getApiKeyHeader()); + headers.set(apiKeyHeader == null ? "X-API-Key" : apiKeyHeader, apiKey); + } + + private URI buildSpeechRecognitionUri(SpeechRecognitionConfigDTO speechConfig) { + String baseUrl = normalizeOptionalText(speechConfig.getBaseUrl()); + if (baseUrl == null) { + throw new BusinessException("语音识别服务地址未配置"); + } + String transcribePath = normalizeOptionalText(speechConfig.getTranscribePath()); + String normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; + String normalizedPath = transcribePath == null ? "" : transcribePath.replaceFirst("^/+", ""); + return URI.create(normalizedBaseUrl).resolve(normalizedPath); + } + + private URI buildSpeechRecognitionTaskUri(String taskId, SpeechRecognitionConfigDTO speechConfig) { + String baseUrl = normalizeOptionalText(speechConfig.getBaseUrl()); + if (baseUrl == null) { + throw new BusinessException("语音识别服务地址未配置"); + } + String taskStatusPath = normalizeOptionalText(speechConfig.getTaskStatusPath()); + String normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; + String encodedTaskId = URLEncoder.encode(taskId, StandardCharsets.UTF_8).replace("+", "%20"); + String normalizedPath = taskStatusPath == null ? "v1/tasks/" + encodedTaskId : taskStatusPath.replaceFirst("^/+", ""); + if (normalizedPath.contains("{task_id}")) { + normalizedPath = normalizedPath.replace("{task_id}", encodedTaskId); + } else if (normalizedPath.contains("{taskId}")) { + normalizedPath = normalizedPath.replace("{taskId}", encodedTaskId); + } else if (!normalizedPath.endsWith("/" + encodedTaskId)) { + normalizedPath = (normalizedPath.endsWith("/") ? normalizedPath : normalizedPath + "/") + encodedTaskId; + } + return URI.create(normalizedBaseUrl).resolve(normalizedPath); + } + + private String extractSpeechRecognitionText(String rawBody) { + String rawText = normalizeOptionalText(rawBody); + if (rawText == null) { + return null; + } + try { + JsonNode root = objectMapper.readTree(rawText); + String text = pickSpeechRecognitionText(root); + return normalizeOptionalText(text); + } catch (Exception ignored) { + return rawText; + } + } + + private String extractSpeechRecognitionTaskId(String rawBody) { + String rawText = normalizeOptionalText(rawBody); + if (rawText == null) { + return null; + } + try { + JsonNode root = objectMapper.readTree(rawText); + return normalizeOptionalText(pickSpeechRecognitionFieldText(root, List.of("task_id", "taskId", "taskID", "id"))); + } catch (Exception ignored) { + return null; + } + } + + private String extractSpeechRecognitionTaskStatus(String rawBody) { + String rawText = normalizeOptionalText(rawBody); + if (rawText == null) { + return null; + } + try { + JsonNode root = objectMapper.readTree(rawText); + return normalizeOptionalText(pickSpeechRecognitionFieldText(root, List.of("status", "state", "task_status", "taskStatus"))); + } catch (Exception ignored) { + return null; + } + } + + private String extractSpeechRecognitionErrorMessage(String rawBody) { + String rawText = normalizeOptionalText(rawBody); + if (rawText == null) { + return null; + } + try { + JsonNode root = objectMapper.readTree(rawText); + String detail = pickSpeechRecognitionErrorText(root.get("detail")); + if (normalizeOptionalText(detail) != null) { + return detail; + } + String message = pickSpeechRecognitionErrorText(root.get("message")); + if (normalizeOptionalText(message) != null) { + return message; + } + String error = pickSpeechRecognitionErrorText(root.get("error")); + if (normalizeOptionalText(error) != null) { + return error; + } + } catch (Exception ignored) { + return rawText; + } + return rawText; + } + + private String pickSpeechRecognitionErrorText(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + if (node.isTextual()) { + return node.asText(); + } + if (node.isArray()) { + List parts = new ArrayList<>(); + for (JsonNode child : node) { + String text = normalizeOptionalText(pickSpeechRecognitionErrorText(child)); + if (text != null) { + parts.add(text); + } + } + return parts.isEmpty() ? null : String.join(";", parts); + } + for (String fieldName : List.of("msg", "message", "detail", "error")) { + String text = pickSpeechRecognitionErrorText(node.get(fieldName)); + if (normalizeOptionalText(text) != null) { + return text; + } + } + return null; + } + + private String pickSpeechRecognitionFieldText(JsonNode node, List fieldNames) { + if (node == null || node.isNull()) { + return null; + } + if (node.isObject()) { + for (String fieldName : fieldNames) { + JsonNode value = node.get(fieldName); + if (value == null || value.isNull()) { + continue; + } + if (value.isTextual() || value.isNumber() || value.isBoolean()) { + return value.asText(); + } + String nestedText = pickSpeechRecognitionFieldText(value, fieldNames); + if (normalizeOptionalText(nestedText) != null) { + return nestedText; + } + } + for (String fieldName : List.of("data", "payload", "result", "task")) { + String nestedText = pickSpeechRecognitionFieldText(node.get(fieldName), fieldNames); + if (normalizeOptionalText(nestedText) != null) { + return nestedText; + } + } + } + if (node.isArray()) { + for (JsonNode child : node) { + String nestedText = pickSpeechRecognitionFieldText(child, fieldNames); + if (normalizeOptionalText(nestedText) != null) { + return nestedText; + } + } + } + return null; + } + + private boolean isSpeechRecognitionCompletedStatus(String status) { + String normalizedStatus = normalizeSpeechRecognitionStatus(status); + return normalizedStatus != null && Set.of("completed", "complete", "succeeded", "success", "done", "finished").contains(normalizedStatus); + } + + private boolean isSpeechRecognitionFailedStatus(String status) { + String normalizedStatus = normalizeSpeechRecognitionStatus(status); + return normalizedStatus != null && Set.of("failed", "failure", "error", "canceled", "cancelled", "timeout", "timedout").contains(normalizedStatus); + } + + private String normalizeSpeechRecognitionStatus(String status) { + String normalizedStatus = normalizeOptionalText(status); + return normalizedStatus == null ? null : normalizedStatus.toLowerCase(Locale.ROOT).replace("_", "").replace("-", ""); + } + + private String pickSpeechRecognitionText(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + if (node.isTextual()) { + return node.asText(); + } + if (node.isArray()) { + List parts = new ArrayList<>(); + for (JsonNode child : node) { + String text = normalizeOptionalText(pickSpeechRecognitionText(child)); + if (text != null) { + parts.add(text); + } + } + return parts.isEmpty() ? null : String.join("", parts); + } + for (String fieldName : List.of("text", "transcription", "result", "content")) { + JsonNode value = node.get(fieldName); + if (value == null || value.isNull()) { + continue; + } + if (value.isTextual()) { + return value.asText(); + } + String nestedText = pickSpeechRecognitionText(value); + if (normalizeOptionalText(nestedText) != null) { + return nestedText; + } + } + for (String fieldName : List.of("data", "payload", "segments")) { + String nestedText = pickSpeechRecognitionText(node.get(fieldName)); + if (normalizeOptionalText(nestedText) != null) { + return nestedText; + } + } + return null; + } + private void requireUser(Long userId) { if (userId == null || userId <= 0) { throw new BusinessException("未获取到当前登录用户"); @@ -2454,6 +2896,11 @@ public class WorkServiceImpl implements WorkService { return null; } + private String defaultIfBlank(String value, String defaultValue) { + String normalized = normalizeOptionalText(value); + return normalized == null ? defaultValue : normalized; + } + private String normalizeBizType(String value) { String normalized = normalizeOptionalText(value); if (normalized == null) { diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 7b0fd03a..77562d96 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -17,7 +17,7 @@ spring: max-file-size: 500MB max-request-size: 600MB datasource: - url: jdbc:postgresql://192.168.124.202:5432/unis_crm + url: jdbc:postgresql://192.168.124.202:5432/unis_crm_dev username: postgres password: unis@123 driver-class-name: org.postgresql.Driver @@ -26,7 +26,7 @@ spring: host: 192.168.124.202 port: 6379 password: zghz@123 - database: 12 + database: 10 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml diff --git a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java index 9c6a3d74..7a9d8ab7 100644 --- a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java +++ b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java @@ -28,6 +28,7 @@ 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.unis.crm.service.SpeechRecognitionConfigService; import com.unisbase.service.SysPermissionService; import com.unisbase.spi.UnisBaseTenantProvider; import java.util.List; @@ -61,6 +62,9 @@ class WorkServiceImplTest { @Mock private ReportReminderService reportReminderService; + @Mock + private SpeechRecognitionConfigService speechRecognitionConfigService; + @Mock private UnisBaseTenantProvider tenantProvider; @@ -89,6 +93,7 @@ class WorkServiceImplTest { profileMapper, reportReminderService, workReportProperties, + speechRecognitionConfigService, new ObjectMapper(), tenantProvider, crmDataVisibilityService, diff --git a/frontend/nginx/default.conf.template b/frontend/nginx/default.conf.template index ee8523d3..f8e57b79 100644 --- a/frontend/nginx/default.conf.template +++ b/frontend/nginx/default.conf.template @@ -22,8 +22,8 @@ server { 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; + proxy_read_timeout 360s; + proxy_send_timeout 360s; } # CRM controllers already use /api as part of their canonical path. @@ -36,8 +36,8 @@ server { 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; + proxy_read_timeout 360s; + proxy_send_timeout 360s; } location /sys/ { @@ -49,7 +49,7 @@ server { 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; + proxy_read_timeout 360s; + proxy_send_timeout 360s; } } diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 29f2b4eb..17d63bc6 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -334,6 +334,10 @@ export interface UploadReportAttachmentOptions { onProgress?: (progress: number) => void; } +export interface WorkSpeechTranscription { + text?: string; +} + export interface WorkReportToUser { userId: CrmId; name: string; @@ -1600,6 +1604,15 @@ export async function uploadWorkReportAttachment(file: File, options?: UploadRep }); } +export async function transcribeWorkReportSpeech(file: File) { + const formData = new FormData(); + formData.append("file", file); + return request("/api/work/report-speech/transcribe", { + method: "POST", + body: formData, + }, true); +} + export async function saveWorkDailyReport(payload: CreateWorkDailyReportPayload) { return request("/api/work/daily-reports", { method: "POST", diff --git a/frontend/src/pages/Work.tsx b/frontend/src/pages/Work.tsx index e8ddb089..4aca419c 100644 --- a/frontend/src/pages/Work.tsx +++ b/frontend/src/pages/Work.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty import { format } from "date-fns"; import { zhCN } from "date-fns/locale"; import { motion } from "motion/react"; -import { ArrowUp, Camera, CheckCircle2, ChevronDown, Download, FileText, Hash, ListTodo, MapPin, NotebookPen, Paperclip, Plus, RefreshCw, RotateCcw, Search, Send, Trash2, UserRoundPlus, X } from "lucide-react"; +import { ArrowUp, Camera, CheckCircle2, ChevronDown, Download, FileText, Hash, ListTodo, MapPin, Mic, NotebookPen, Paperclip, Plus, RefreshCw, RotateCcw, Search, Send, Square, Trash2, UserRoundPlus, X } from "lucide-react"; import { flushSync } from "react-dom"; import { Link, Navigate, useLocation, useNavigate } from "react-router-dom"; import { @@ -29,6 +29,7 @@ import { reverseWorkGeocode, saveWorkCheckIn, saveWorkDailyReport, + transcribeWorkReportSpeech, uploadWorkCheckInPhoto, uploadWorkReportAttachment, type ChannelExpansionItem, @@ -83,6 +84,7 @@ const OPPORTUNITY_NEXT_PLAN_LABEL = "下一步销售计划"; const OPPORTUNITY_STAGE_LABEL = "项目阶段"; const WORK_DETAIL_NEXT_PLAN_HEADER = "后续规划 / 下一步销售计划"; const REPORT_ATTACHMENT_MAX_SIZE_BYTES = 500 * 1024 * 1024; +const REPORT_SPEECH_MIN_RECORDING_MS = 1000; const reportFieldLabels = { sales: ["沟通内容", LEGACY_NEXT_PLAN_LABEL], @@ -148,6 +150,19 @@ type WorkRelationOption = { type BizType = WorkReportLineItem["bizType"]; type PickerMode = "report" | "checkin"; type WorkSection = (typeof workSectionItems)[number]["key"]; +type ReportSpeechStatus = "idle" | "recording" | "transcribing" | "error"; +type ReportSpeechState = { + lineIndex: number; + targetField: string; + insertionStart?: number; + insertionEnd?: number; + status: ReportSpeechStatus; + error: string; +}; +type ReportSpeechInsertionRange = { + start: number; + end: number; +}; type LocationPoint = { latitude: number; longitude: number; @@ -2290,6 +2305,7 @@ export default function Work() { uploadingReportAttachmentProgress={uploadingReportAttachmentProgress} reportAttachmentUploadErrors={reportAttachmentUploadErrors} dailyReportFeatureLoaded={dailyReportFeatureLoaded} + objectSelectionRequired={dailyReportObjectSelectionRequired} notifyUsersVisible={dailyReportNotifyUsersVisible} attachmentsVisible={dailyReportAttachmentsVisible} onReportAttachmentChange={(index, event) => void handleReportAttachmentChange(index, event)} @@ -4172,6 +4188,7 @@ function ReportPanel({ uploadingReportAttachmentProgress, reportAttachmentUploadErrors, dailyReportFeatureLoaded, + objectSelectionRequired, notifyUsersVisible, attachmentsVisible, onReportAttachmentChange, @@ -4204,6 +4221,7 @@ function ReportPanel({ uploadingReportAttachmentProgress: Record; reportAttachmentUploadErrors: Record; dailyReportFeatureLoaded: boolean; + objectSelectionRequired: boolean; notifyUsersVisible: boolean; attachmentsVisible: boolean; onReportAttachmentChange: (index: number, event: ChangeEvent) => void; @@ -4222,14 +4240,32 @@ function ReportPanel({ }) { const [editingReportLineIndex, setEditingReportLineIndex] = useState(null); const [editingPlanItemIndex, setEditingPlanItemIndex] = useState(null); + const [reportSpeechState, setReportSpeechState] = useState(null); + const [speechElapsedSeconds, setSpeechElapsedSeconds] = useState(0); const reportLineTextareaRefs = useRef<(HTMLTextAreaElement | null)[]>([]); + const reportLineSelectionRefs = useRef>({}); + const pendingReportSpeechInsertionRef = useRef<{ lineIndex: number } & ReportSpeechInsertionRange | null>(null); const reportAttachmentInputRefs = useRef<(HTMLInputElement | null)[]>([]); const planItemInputRefs = useRef<(HTMLInputElement | null)[]>([]); + const reportLineItemsRef = useRef(reportForm.lineItems); + const speechStreamRef = useRef(null); + const speechAudioContextRef = useRef(null); + const speechAudioSourceRef = useRef(null); + const speechAudioProcessorRef = useRef(null); + const speechSampleChunksRef = useRef([]); + const speechSampleCountRef = useRef(0); + const speechTimerRef = useRef(null); + const speechCancelledRef = useRef(false); + const speechRecordingStartedAtRef = useRef(0); const previousReportLineCountRef = useRef(reportForm.lineItems.length); const previousPlanItemCountRef = useRef(reportForm.planItems.length); const pendingNewReportLineFocusRef = useRef(null); const pendingNewPlanItemFocusRef = useRef(null); + useEffect(() => { + reportLineItemsRef.current = reportForm.lineItems; + }, [reportForm.lineItems]); + const focusTextControl = useCallback((element: HTMLTextAreaElement | HTMLInputElement | null) => { if (!element) { return; @@ -4242,11 +4278,45 @@ function ReportPanel({ element.setSelectionRange(textLength, textLength); }, []); - const activateReportLineEditor = useCallback((index: number) => { + const blurActiveTextControl = useCallback(() => { + const activeElement = document.activeElement; + if (activeElement instanceof HTMLTextAreaElement || activeElement instanceof HTMLInputElement) { + activeElement.blur(); + } + }, []); + + const rememberReportLineSelection = useCallback((lineIndex: number, element: HTMLTextAreaElement | null) => { + if (!element) { + return; + } + reportLineSelectionRefs.current[lineIndex] = { + start: element.selectionStart ?? element.value.length, + end: element.selectionEnd ?? element.selectionStart ?? element.value.length, + }; + }, []); + + const getReportSpeechInsertionRange = useCallback((lineIndex: number): ReportSpeechInsertionRange | undefined => { + const element = reportLineTextareaRefs.current[lineIndex]; + if (!element) { + return undefined; + } + if (document.activeElement === element) { + return { + start: element.selectionStart ?? element.value.length, + end: element.selectionEnd ?? element.selectionStart ?? element.value.length, + }; + } + return reportLineSelectionRefs.current[lineIndex] ?? { start: element.value.length, end: element.value.length }; + }, []); + + const activateReportLineEditor = useCallback((index: number, options?: { focus?: boolean }) => { flushSync(() => { setEditingPlanItemIndex(null); setEditingReportLineIndex(index); }); + if (options?.focus === false) { + return; + } const target = reportLineTextareaRefs.current[index]; if (target) { focusTextControl(target); @@ -4268,6 +4338,222 @@ function ReportPanel({ requestAnimationFrame(() => focusTextControl(planItemInputRefs.current[index])); }, [focusTextControl]); + const clearSpeechTimer = useCallback(() => { + if (speechTimerRef.current !== null) { + window.clearInterval(speechTimerRef.current); + speechTimerRef.current = null; + } + }, []); + + const resetSpeechSampleBuffer = useCallback(() => { + speechSampleChunksRef.current = []; + speechSampleCountRef.current = 0; + }, []); + + const stopCurrentSpeechStream = useCallback(() => { + speechAudioProcessorRef.current?.disconnect(); + speechAudioSourceRef.current?.disconnect(); + speechAudioProcessorRef.current = null; + speechAudioSourceRef.current = null; + const audioContext = speechAudioContextRef.current; + speechAudioContextRef.current = null; + if (audioContext && audioContext.state !== "closed") { + void audioContext.close(); + } + speechStreamRef.current?.getTracks().forEach((track) => track.stop()); + speechStreamRef.current = null; + clearSpeechTimer(); + }, [clearSpeechTimer]); + + const writeReportSpeechText = useCallback((lineIndex: number, speechText: string, insertionRange?: ReportSpeechInsertionRange) => { + const item = reportLineItemsRef.current[lineIndex]; + const text = speechText.trim(); + if (!item || !text) { + return; + } + const textArea = reportLineTextareaRefs.current[lineIndex]; + let currentEditorBody = textArea?.value ?? ""; + if (!item.bizName) { + currentEditorBody = currentEditorBody || item.editorText || ""; + } else { + currentEditorBody = currentEditorBody || ( + item.editorText + ? item.editorText.replace(/\r/g, "").split("\n").slice(1).join("\n") + : buildEditorTemplate(item.bizType, item.bizName).split("\n").slice(1).join("\n") + ); + } + const nextEditorBody = insertReportSpeechText(currentEditorBody, text, insertionRange); + const nextEditorText = item.bizName + ? `${buildEditorMentionLine(item.bizType, item.bizName)}\n${nextEditorBody}` + : nextEditorBody; + const nextItems = [...reportLineItemsRef.current]; + nextItems[lineIndex] = { ...item, editorText: nextEditorText }; + reportLineItemsRef.current = nextItems; + onReportLineChange(lineIndex, nextEditorText); + const nextCaretPosition = resolveInsertedSpeechCaretPosition(currentEditorBody, text, insertionRange); + reportLineSelectionRefs.current[lineIndex] = { start: nextCaretPosition, end: nextCaretPosition }; + blurActiveTextControl(); + }, [blurActiveTextControl, onReportLineChange]); + + const startReportSpeechRecording = useCallback(async (lineIndex: number, insertionRange?: ReportSpeechInsertionRange) => { + const item = reportLineItemsRef.current[lineIndex]; + if (!item || (!item.bizName && objectSelectionRequired)) { + return; + } + blurActiveTextControl(); + activateReportLineEditor(lineIndex, { focus: false }); + const AudioContextConstructor = getAudioContextConstructor(); + if (!navigator.mediaDevices?.getUserMedia || !AudioContextConstructor) { + setReportSpeechState({ + lineIndex, + targetField: "", + insertionStart: insertionRange?.start, + insertionEnd: insertionRange?.end, + status: "error", + error: "当前浏览器不支持语音录音,请使用新版 Chrome、Edge 或企业微信内置浏览器。", + }); + return; + } + + try { + speechCancelledRef.current = false; + resetSpeechSampleBuffer(); + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const audioContext = new AudioContextConstructor(); + if (audioContext.state === "suspended") { + await audioContext.resume(); + } + const source = audioContext.createMediaStreamSource(stream); + const processor = audioContext.createScriptProcessor(4096, 1, 1); + processor.onaudioprocess = (event) => { + event.outputBuffer.getChannelData(0).fill(0); + if (speechCancelledRef.current) { + return; + } + const input = event.inputBuffer.getChannelData(0); + speechSampleChunksRef.current.push(new Float32Array(input)); + speechSampleCountRef.current += input.length; + }; + source.connect(processor); + processor.connect(audioContext.destination); + speechStreamRef.current = stream; + speechAudioContextRef.current = audioContext; + speechAudioSourceRef.current = source; + speechAudioProcessorRef.current = processor; + + speechRecordingStartedAtRef.current = Date.now(); + setSpeechElapsedSeconds(0); + speechTimerRef.current = window.setInterval(() => { + setSpeechElapsedSeconds((current) => current + 1); + }, 1000); + setReportSpeechState({ + lineIndex, + targetField: "", + insertionStart: insertionRange?.start, + insertionEnd: insertionRange?.end, + status: "recording", + error: "", + }); + } catch (error) { + stopCurrentSpeechStream(); + setReportSpeechState({ + lineIndex, + targetField: "", + insertionStart: insertionRange?.start, + insertionEnd: insertionRange?.end, + status: "error", + error: error instanceof Error ? error.message : "无法访问麦克风,请检查浏览器权限。", + }); + } + }, [activateReportLineEditor, blurActiveTextControl, objectSelectionRequired, resetSpeechSampleBuffer, stopCurrentSpeechStream]); + + const stopReportSpeechRecording = useCallback(() => { + const currentState = reportSpeechState; + if (!currentState || currentState.status !== "recording") { + return; + } + const recordingDurationMs = Date.now() - speechRecordingStartedAtRef.current; + const audioContext = speechAudioContextRef.current; + const sampleRate = audioContext?.sampleRate || 16000; + const sampleCount = speechSampleCountRef.current; + const samples = sampleCount ? mergeFloat32Chunks(speechSampleChunksRef.current, sampleCount) : null; + stopCurrentSpeechStream(); + resetSpeechSampleBuffer(); + if (!samples || sampleCount / sampleRate * 1000 < REPORT_SPEECH_MIN_RECORDING_MS) { + setReportSpeechState({ + lineIndex: currentState.lineIndex, + targetField: currentState.targetField, + status: "error", + error: recordingDurationMs < REPORT_SPEECH_MIN_RECORDING_MS + ? "录音时间太短,请至少录制 1 秒后再识别。" + : "没有录到声音,请检查麦克风后重试。", + }); + return; + } + setReportSpeechState({ + lineIndex: currentState.lineIndex, + targetField: currentState.targetField, + status: "transcribing", + error: "", + }); + const file = buildReportSpeechWavFile(samples, sampleRate); + transcribeWorkReportSpeech(file) + .then((result) => { + const text = result.text?.trim() || ""; + if (!text) { + setReportSpeechState({ + lineIndex: currentState.lineIndex, + targetField: currentState.targetField, + status: "error", + error: "语音识别结果为空,请重新录音。", + }); + return; + } + writeReportSpeechText( + currentState.lineIndex, + text, + currentState.insertionStart !== undefined + ? { + start: currentState.insertionStart, + end: currentState.insertionEnd ?? currentState.insertionStart, + } + : undefined, + ); + setReportSpeechState(null); + setSpeechElapsedSeconds(0); + }) + .catch((error) => { + setReportSpeechState({ + lineIndex: currentState.lineIndex, + targetField: currentState.targetField, + status: "error", + error: error instanceof Error ? error.message : "语音识别失败,请重试。", + }); + }); + }, [reportSpeechState, resetSpeechSampleBuffer, stopCurrentSpeechStream, writeReportSpeechText]); + + const handleReportSpeechToggle = useCallback((lineIndex: number) => { + const pendingInsertion = pendingReportSpeechInsertionRef.current?.lineIndex === lineIndex + ? pendingReportSpeechInsertionRef.current + : undefined; + pendingReportSpeechInsertionRef.current = null; + const insertionRange = pendingInsertion ?? getReportSpeechInsertionRange(lineIndex); + blurActiveTextControl(); + if (reportSpeechState?.lineIndex === lineIndex && reportSpeechState.status === "recording") { + stopReportSpeechRecording(); + return; + } + if (reportSpeechState?.status === "recording" || reportSpeechState?.status === "transcribing") { + return; + } + void startReportSpeechRecording(lineIndex, insertionRange); + }, [blurActiveTextControl, getReportSpeechInsertionRange, reportSpeechState, startReportSpeechRecording, stopReportSpeechRecording]); + + useEffect(() => () => { + speechCancelledRef.current = true; + stopCurrentSpeechStream(); + }, [stopCurrentSpeechStream]); + const handleAddReportLineAndFocus = useCallback(() => { pendingNewReportLineFocusRef.current = reportForm.lineItems.length; onAddReportLine(); @@ -4389,6 +4675,13 @@ function ReportPanel({ const isUploadingAttachment = uploadingReportAttachmentIndex === index; const attachmentProgress = uploadingReportAttachmentProgress[index] ?? 0; const attachmentUploadError = reportAttachmentUploadErrors[index]; + const speechLineState = reportSpeechState?.lineIndex === index ? reportSpeechState : null; + const speechInputBlocked = objectSelectionRequired && !item.bizName; + const isSpeechRecording = speechLineState?.status === "recording"; + const isSpeechTranscribing = speechLineState?.status === "transcribing"; + const isSpeechBusyOnAnotherLine = !!reportSpeechState + && reportSpeechState.lineIndex !== index + && (reportSpeechState.status === "recording" || reportSpeechState.status === "transcribing"); return (
) : null} -