using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using NCC.Common.Core.Manager; using NCC.Dependency; using NCC.DynamicApiController; using NCC.Extend.Entitys.Dto.LqAi; using NCC.Extend.Entitys.Enum; using NCC.Extend.Entitys.lq_upload_record; using NCC.Extend.Interfaces.LqAi; using NCC.FriendlyException; using NCC.System.Entitys.Dto.System.SysConfig; using NCC.System.Interfaces.System; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using SqlSugar; using OSS = AlibabaCloud.OSS.V2; using Yitter.IdGenerator; namespace NCC.Extend { /// /// 通义千问 / DashScope:Paraformer 异步转写与 OpenAI 兼容对话(备注归纳)。 /// /// /// - 语音识别:阿里云百炼异步接口与公网可访问的文件 URL, /// 可先走 OSS 直传再传 uploadRecordId 或本接口 multipart 上传到 OSS。
/// - 系统配置键:aiSpeechApiUrl、aiSpeechApiKey、aiSpeechModel(Paraformer 等与百炼「录音文件识别」一致)、aiChatApiUrl、aiChatApiKey、aiChatModel、aiSummarizeSystemPrompt。
/// 若误将「对话」用的 compatible-mode 地址或非 Paraformer 模型填在语音项,提交任务会失败;服务端会对明显误配做自动纠正(仍建议在后台改为正确值)。 ///
[ApiDescriptionSettings(Tag = "绿纤AI助手", Name = "LqAi", Order = 205)] [Route("api/Extend/[controller]")] [ApiController] public class LqAiService : ILqAiService, IDynamicApiController, ITransient { private const string DefaultTranscriptionUrl = "https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription"; private const string DefaultDashScopeTasksBase = "https://dashscope.aliyuncs.com/api/v1/tasks"; private const string DefaultChatCompatibleBase = "https://dashscope.aliyuncs.com/compatible-mode/v1"; private const string DefaultAsrModel = "paraformer-v2"; /// /// 未在系统配置填写「备注归纳提示词」时使用的默认 system 文案。 /// public static readonly string DefaultRemarkSummarySystemPrompt = "你是美业门店健康师的备注助手。请将用户口述录音转写后的杂乱中文整理成可直接写入系统备注的一句话或一小段:" + "删除口头语和无信息量的赘述;保留顾客诉求、皮肤问题、服务项目、产品/耗材、频次、到店时间、嘱托等关键信息;" + "不要添加「以下为备注」之类的前缀说明;不要编造未出现的事实;输出简体中文。"; private static readonly HashSet AllowedVoiceExtensions = new(StringComparer.OrdinalIgnoreCase) { "mp3", "wav", "m4a", "aac", "amr", "webm", "mp4", "ogg", "mpeg", "opus" }; private readonly ISysConfigService _sysConfigService; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly IConfiguration _configuration; private readonly ISqlSugarClient _db; private readonly IUserManager _userManager; /// /// Initializes a new instance of the class. /// public LqAiService( ISysConfigService sysConfigService, IHttpClientFactory httpClientFactory, ILogger logger, IConfiguration configuration, ISqlSugarClient sqlSugarClient, IUserManager userManager) { _sysConfigService = sysConfigService; _httpClientFactory = httpClientFactory; _logger = logger; _configuration = configuration; _db = sqlSugarClient; _userManager = userManager; } /// /// /// Paraformer:公网音频 URL → 异步转写(密钥见「语音 API Key」)。 /// [HttpPost("speech/transcribe-from-urls")] public async Task TranscribeFromPublicUrlsAsync( [FromBody] LqAiSpeechFromUrlsInput input, CancellationToken cancellationToken = default) { input ??= new LqAiSpeechFromUrlsInput(); var urls = input.FileUrls?.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct() .ToList(); if (urls == null || urls.Count == 0) { throw NCCException.Oh("请至少提供一个公网可访问的音频文件 URL"); } if (urls.Count > 100) { throw NCCException.Oh("单次最多 100 个文件 URL"); } foreach (var u in urls) { if (!Uri.TryCreate(u, UriKind.Absolute, out var abs) || (abs.Scheme != Uri.UriSchemeHttp && abs.Scheme != Uri.UriSchemeHttps)) { throw NCCException.Oh($"无效的音频 URL:{u}"); } } var cfg = await _sysConfigService.GetInfo(); var apiKey = cfg?.aiSpeechApiKey?.Trim(); if (string.IsNullOrEmpty(apiKey)) { throw NCCException.Oh("未配置语音 API Key,请在系统配置 → AI 配置中填写「语音 API Key」"); } var submitUrl = ResolveSpeechSubmitUrl(cfg?.aiSpeechApiUrl); var model = ResolveAsrModelName(cfg, input.Model); var client = CreateHttpClient(); var submitBody = new JObject { ["model"] = model, ["input"] = new JObject { ["file_urls"] = new JArray(urls.Select(x => new JValue(x))) }, ["parameters"] = new JObject { ["language_hints"] = new JArray("zh", "en") } }; using var submitReq = new HttpRequestMessage(HttpMethod.Post, submitUrl); submitReq.Headers.TryAddWithoutValidation("Authorization", $"Bearer {apiKey}"); submitReq.Headers.TryAddWithoutValidation("X-DashScope-Async", "enable"); submitReq.Content = new StringContent(submitBody.ToString(Formatting.None), Encoding.UTF8, "application/json"); using var submitResp = await client.SendAsync(submitReq, cancellationToken); var submitText = await submitResp.Content.ReadAsStringAsync(); ThrowIfDashScopeError(submitText, submitResp.IsSuccessStatusCode); if (!submitResp.IsSuccessStatusCode) { var detail = string.IsNullOrWhiteSpace(submitText) ? $"HTTP {(int)submitResp.StatusCode} {submitResp.ReasonPhrase}".Trim() : Truncate(submitText, 800); throw NCCException.Oh("语音识别任务提交失败:" + detail); } var submitJson = JObject.Parse(submitText); var requestId = submitJson["request_id"]?.ToString(); var taskId = submitJson["output"]?["task_id"]?.ToString() ?? submitJson["task_id"]?.ToString(); if (string.IsNullOrEmpty(taskId)) { throw NCCException.Oh("语音识别提交未返回 task_id:" + Truncate(submitText, 500)); } var tasksBase = DeriveTasksBaseUri(submitUrl); var taskPayload = await PollTaskUntilDoneAsync(client, apiKey, tasksBase, taskId, cancellationToken); var rawText = await BuildTranscriptTextAsync(client, taskPayload, cancellationToken); return new LqAiTranscribeOutput { RequestId = requestId, RawText = rawText }; } /// /// /// OpenAI 兼容对话:冗长口述 → 简短备注;system 文案优先读系统配置的「备注归纳提示词」。 /// [HttpPost("text/summarize-remark")] public async Task SummarizeRemarkAsync( [FromBody] LqAiSummarizeRemarkInput input, CancellationToken cancellationToken = default) { input ??= new LqAiSummarizeRemarkInput(); if (string.IsNullOrWhiteSpace(input.RawText)) { throw NCCException.Oh("待归纳的文字不能为空"); } var cfg = await _sysConfigService.GetInfo(); var apiKey = cfg?.aiChatApiKey?.Trim(); if (string.IsNullOrEmpty(apiKey)) { throw NCCException.Oh("未配置文本对话 API Key,请在系统配置 → AI 配置中填写「文本 API Key」"); } var baseUrl = string.IsNullOrWhiteSpace(cfg.aiChatApiUrl) ? DefaultChatCompatibleBase : cfg.aiChatApiUrl.Trim().TrimEnd('/'); var model = string.IsNullOrWhiteSpace(cfg.aiChatModel) ? "qwen-plus" : cfg.aiChatModel.Trim(); var systemPrompt = ResolveRemarkSystemPrompt(cfg?.aiSummarizeSystemPrompt); var userContent = input.RawText.Trim(); if (!string.IsNullOrWhiteSpace(input.ExtraContext)) { userContent = "[补充背景] " + input.ExtraContext.Trim() + "\n\n[转写正文]\n" + userContent; } var chatBody = new JObject { ["model"] = model, ["temperature"] = 0.3, ["messages"] = new JArray( new JObject { ["role"] = "system", ["content"] = systemPrompt }, new JObject { ["role"] = "user", ["content"] = userContent }) }; var url = $"{baseUrl}/chat/completions"; var client = CreateHttpClient(); using var req = new HttpRequestMessage(HttpMethod.Post, url); req.Headers.TryAddWithoutValidation("Authorization", $"Bearer {apiKey}"); req.Content = new StringContent(chatBody.ToString(Formatting.None), Encoding.UTF8, "application/json"); using var resp = await client.SendAsync(req, cancellationToken); var text = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) { throw NCCException.Oh($"对话接口失败(HTTP {(int)resp.StatusCode}):{Truncate(text, 800)}"); } var jo = JObject.Parse(text); if (jo["error"] is JObject err) { throw NCCException.Oh( "对话接口错误:" + (err["message"]?.ToString() ?? err.ToString(Formatting.None))); } var choices = jo["choices"] as JArray; var firstChoice = choices != null && choices.Count > 0 ? choices[0] as JObject : null; var content = firstChoice?["message"]?["content"]?.ToString(); if (string.IsNullOrWhiteSpace(content)) { throw NCCException.Oh("对话接口未返回内容:" + Truncate(text, 600)); } return new LqAiSummarizeOutput { RemarkText = content.Trim() }; } /// [HttpPost("pipeline/transcribe-and-summarize")] public async Task TranscribeAndSummarizeAsync( [FromBody] LqAiSpeechFromUrlsInput input, CancellationToken cancellationToken = default) { var tr = await TranscribeFromPublicUrlsAsync(input, cancellationToken); var sum = await SummarizeRemarkAsync( new LqAiSummarizeRemarkInput { RawText = tr.RawText }, cancellationToken); return new LqAiPipelineOutput { RawTranscript = tr.RawText, RemarkText = sum.RemarkText }; } /// /// /// 依据 OssDirectUploadService.ConfirmUpload 返回的 uploadRecordId 解析公网 HTTPS 音频地址后再转写。 /// /// 推荐上传目录 type 使用 aiVoice,并配置 OSS 绑定域名或可公网访问的站点地址。 [HttpPost("speech/transcribe-from-upload-record")] public async Task TranscribeFromUploadRecordAsync( [FromBody] LqAiTranscribeFromUploadInput input, CancellationToken cancellationToken = default) { input ??= new LqAiTranscribeFromUploadInput(); if (string.IsNullOrWhiteSpace(input.UploadRecordId)) { throw NCCException.Oh("uploadRecordId 不能为空"); } var publicUrl = await ResolvePublicHttpsUrlFromUploadRecordAsync(input.UploadRecordId.Trim()); var tr = await TranscribeFromPublicUrlsAsync( new LqAiSpeechFromUrlsInput { FileUrls = new List { publicUrl }, Model = input.Model }, cancellationToken); tr.UploadRecordId = input.UploadRecordId.Trim(); return tr; } /// [HttpPost("pipeline/transcribe-and-summarize-from-upload-record")] public async Task TranscribeAndSummarizeFromUploadRecordAsync( [FromBody] LqAiTranscribeFromUploadInput input, CancellationToken cancellationToken = default) { var tr = await TranscribeFromUploadRecordAsync(input, cancellationToken); var sum = await SummarizeRemarkAsync(new LqAiSummarizeRemarkInput { RawText = tr.RawText }, cancellationToken); return new LqAiPipelineOutput { RawTranscript = tr.RawText, RemarkText = sum.RemarkText, UploadRecordId = tr.UploadRecordId }; } /// /// 服务端接收录音文件后直接上传 OSS 并入库,再异步转写(需配置 OSS 密钥与 Bucket;公网可达 URL 推荐配置 CustomDomain)。 /// /// 音频二进制 /// Paraformer 模型,可空 [HttpPost("speech/upload-binary-and-transcribe")] [DisableRequestSizeLimit] public async Task UploadVoiceBinaryAndTranscribeAsync( [FromForm(Name = "file")] IFormFile file, [FromQuery] string model = null, CancellationToken cancellationToken = default) { if (file == null || file.Length == 0) { throw NCCException.Oh("请上传非空音频文件"); } if (file.Length > 80L * 1024 * 1024) { throw NCCException.Oh("单文件不能超过 80MB"); } var ext = (Path.GetExtension(file.FileName) ?? "").TrimStart('.').ToLowerInvariant(); if (string.IsNullOrEmpty(ext) || !AllowedVoiceExtensions.Contains(ext)) { throw NCCException.Oh("不支持的音频格式,推荐使用 mp3、m4a、wav、amr、webm 等"); } var ak = _configuration["NCC_App:AliyunOSS:AccessKeyId"] ?? _configuration["NCC_APP:AliyunOSS:AccessKeyId"]; var sk = _configuration["NCC_App:AliyunOSS:AccessKeySecret"] ?? _configuration["NCC_APP:AliyunOSS:AccessKeySecret"]; var endpoint = _configuration["NCC_App:AliyunOSS:Endpoint"] ?? _configuration["NCC_APP:AliyunOSS:Endpoint"]; var bucket = _configuration["NCC_App:BucketName"] ?? _configuration["NCC_App:OSS:Bucket"] ?? _configuration["NCC_APP:BucketName"]; var region = _configuration["NCC_App:AliyunOSS:Region"] ?? _configuration["NCC_APP:AliyunOSS:Region"] ?? "cn-chengdu"; if (string.IsNullOrWhiteSpace(ak) || string.IsNullOrWhiteSpace(sk) || string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(bucket)) { throw NCCException.Oh( "未配置阿里云 OSS(AccessKeyId/AccessKeySecret/Endpoint/BucketName),无法服务端上传音频;可先使用 OSS 直传 + transcribe-from-upload-record。"); } if (!endpoint.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { endpoint = "https://" + endpoint; } var now = DateTime.Now; var objectKey = $"aiVoice/{now:yyyy/MM/dd}/{YitIdHelper.NextId()}.{ext}"; using var ms = new MemoryStream(); using (var readStream = file.OpenReadStream()) { await readStream.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); } ms.Position = 0; var ossCfg = OSS.Configuration.LoadDefault(); ossCfg.CredentialsProvider = new OSS.Credentials.StaticCredentialsProvider(ak, sk); ossCfg.Region = region; ossCfg.Endpoint = endpoint; using (var ossClient = new OSS.Client(ossCfg)) { await ossClient.PutObjectAsync(new OSS.Models.PutObjectRequest { Bucket = bucket, Key = objectKey, Body = ms }).ConfigureAwait(false); } var publicBase = (_configuration["NCC_App:LocalFileBaseUrl"] ?? _configuration["NCC_App:Domain"] ?? "") .Trim().TrimEnd('/'); var customDomain = _configuration["NCC_App:AliyunOSS:CustomDomain"] ?? _configuration["NCC_APP:AliyunOSS:CustomDomain"]; string resolvedUrlForAsr; string accessPersist = null; var fileOnly = Path.GetFileName(objectKey); // 与同项目 ConfirmUpload 一致:OSS 绑定域名则用 https 直链(DashScope 可拉取)。 // 若无 CustomDomain:用 Domain/LocalFileBaseUrl + `/api/File/Image/aiVoice/` 网关(需能被公网访问)。 string urlCandidate; if (!string.IsNullOrEmpty(customDomain)) { urlCandidate = $"{customDomain.TrimEnd('/')}/{objectKey}"; resolvedUrlForAsr = urlCandidate.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? urlCandidate : CombinePublicUrl(publicBase, urlCandidate); accessPersist = CombinePublicUrl(publicBase, urlCandidate); } else { var proxy = $"/api/File/Image/aiVoice/{fileOnly}"; urlCandidate = CombinePublicUrl(publicBase, proxy); resolvedUrlForAsr = urlCandidate; accessPersist = urlCandidate; } if (!resolvedUrlForAsr.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { throw NCCException.Oh( "无法组成公网 HTTP(S) 音频地址用于阿里云识别(请配置 NCC_App:AliyunOSS:CustomDomain 或 NCC_App:Domain)。"); } var recordId = YitIdHelper.NextId().ToString(); var entity = new LqUploadRecordEntity { Id = recordId, ObjectKey = objectKey, OriginalFileName = file.FileName ?? fileOnly, FileType = "aiVoice", Extension = ext, AuditStatus = (int)ImageAuditStatusEnum.Pass, AuditTime = DateTime.Now, AuditReason = "语音文件不参与图片审核", UserId = _userManager?.UserId, CreateTime = DateTime.Now, AccessUrl = accessPersist, ThumbnailPublicUrl = null }; await _db.Insertable(entity).ExecuteCommandAsync(); var sysCfg = await _sysConfigService.GetInfo(); var tr = await TranscribeFromPublicUrlsAsync( new LqAiSpeechFromUrlsInput { FileUrls = new List { resolvedUrlForAsr }, Model = ResolveAsrModelName(sysCfg, model) }, cancellationToken); tr.UploadRecordId = recordId; return tr; } /// /// 语音识别提交地址:空则用百炼默认录音文件识别端点;若误填「OpenAI 兼容 / 对话」网关则自动改用默认(与系统配置里对话地址区分)。 /// private static string ResolveSpeechSubmitUrl(string configuredUrl) { var u = configuredUrl?.Trim(); if (string.IsNullOrEmpty(u)) { return DefaultTranscriptionUrl; } if (u.IndexOf("compatible-mode", StringComparison.OrdinalIgnoreCase) >= 0 || u.IndexOf("/chat/completions", StringComparison.OrdinalIgnoreCase) >= 0 || u.IndexOf("openapi.alibaba.com", StringComparison.OrdinalIgnoreCase) >= 0) { return DefaultTranscriptionUrl; } return u; } /// /// ASR:单次请求传入的 model 优先,否则系统配置的 aiSpeechModel,再否则默认 。 /// 若误填对话/实时翻译等模型名则回退 ,避免 Paraformer 异步任务提交失败。 /// public static string ResolveAsrModelName(SysConfigOutput cfg, string explicitOptional) { if (!string.IsNullOrWhiteSpace(explicitOptional)) { return SanitizeParaformerModelName(explicitOptional.Trim()); } if (!string.IsNullOrWhiteSpace(cfg?.aiSpeechModel)) { return SanitizeParaformerModelName(cfg.aiSpeechModel.Trim()); } return DefaultAsrModel; } /// /// Paraformer 录音文件识别可用 model;常见误配为 qwen/live 等对话或实时模型名,需回退默认。 /// private static string SanitizeParaformerModelName(string model) { if (string.IsNullOrEmpty(model)) { return DefaultAsrModel; } var m = model.Trim(); if (m.IndexOf("qwen", StringComparison.OrdinalIgnoreCase) >= 0 || m.IndexOf("livetranslate", StringComparison.OrdinalIgnoreCase) >= 0 || m.IndexOf("compatible", StringComparison.OrdinalIgnoreCase) >= 0 || (m.IndexOf("realtime", StringComparison.OrdinalIgnoreCase) >= 0 && m.IndexOf("paraformer", StringComparison.OrdinalIgnoreCase) < 0)) { return DefaultAsrModel; } return m; } /// /// 系统配置中的「备注归纳提示词」为空或未配置时使用 。 /// public static string ResolveRemarkSystemPrompt(string configuredAiSummarizeSystemPrompt) { var t = configuredAiSummarizeSystemPrompt?.Trim(); return string.IsNullOrEmpty(t) ? DefaultRemarkSummarySystemPrompt : t; } private HttpClient CreateHttpClient() { var client = _httpClientFactory.CreateClient(); client.Timeout = TimeSpan.FromMinutes(5); return client; } private static string DeriveTasksBaseUri(string transcriptionSubmitUrl) { if (string.IsNullOrWhiteSpace(transcriptionSubmitUrl) || !Uri.TryCreate(transcriptionSubmitUrl, UriKind.Absolute, out var u)) { return DefaultDashScopeTasksBase; } return $"{u.Scheme}://{u.Host}/api/v1/tasks"; } private async Task PollTaskUntilDoneAsync( HttpClient client, string apiKey, string tasksBase, string taskId, CancellationToken cancellationToken) { var queryUrl = $"{tasksBase.TrimEnd('/')}/{Uri.EscapeDataString(taskId)}"; const int maxAttempts = 120; const int delayMs = 2000; for (var attempt = 0; attempt < maxAttempts; attempt++) { cancellationToken.ThrowIfCancellationRequested(); using var req = new HttpRequestMessage(HttpMethod.Get, queryUrl); req.Headers.TryAddWithoutValidation("Authorization", $"Bearer {apiKey}"); using var resp = await client.SendAsync(req, cancellationToken); var text = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) { throw NCCException.Oh($"查询识别任务失败(HTTP {(int)resp.StatusCode}):{Truncate(text, 800)}"); } var jo = JObject.Parse(text); ThrowIfDashScopeError(text, true); var output = jo["output"] as JObject ?? jo; var status = output["task_status"]?.ToString(); _logger.LogDebug("ASR task {TaskId} status {Status}", taskId, status); if (string.Equals(status, "SUCCEEDED", StringComparison.OrdinalIgnoreCase)) { return output; } if (string.Equals(status, "FAILED", StringComparison.OrdinalIgnoreCase)) { throw NCCException.Oh("语音识别任务失败:" + Truncate(text, 1200)); } if (string.Equals(status, "UNKNOWN", StringComparison.OrdinalIgnoreCase)) { throw NCCException.Oh("语音识别任务状态异常 UNKNOWN:" + Truncate(text, 800)); } await Task.Delay(delayMs, cancellationToken); } throw NCCException.Oh("语音识别任务轮询超时,请稍后重试。"); } private async Task BuildTranscriptTextAsync( HttpClient client, JObject taskOutput, CancellationToken cancellationToken) { var results = taskOutput["results"] as JArray; if (results == null || results.Count == 0) { return string.Empty; } var parts = new List(); foreach (var item in results) { var o = item as JObject; if (o == null) { continue; } var sub = o["subtask_status"]?.ToString(); if (!string.IsNullOrEmpty(sub) && !string.Equals(sub, "SUCCEEDED", StringComparison.OrdinalIgnoreCase)) { var err = o["message"]?.ToString() ?? o["code"]?.ToString() ?? "子任务失败"; throw NCCException.Oh($"语音文件转写失败:{err}"); } var transcriptionUrl = o["transcription_url"]?.ToString(); if (string.IsNullOrEmpty(transcriptionUrl)) { continue; } using var get = new HttpRequestMessage(HttpMethod.Get, transcriptionUrl); using var tr = await client.SendAsync(get, cancellationToken); var jsonText = await tr.Content.ReadAsStringAsync(); if (!tr.IsSuccessStatusCode) { throw NCCException.Oh("下载转写结果失败:" + Truncate(jsonText, 600)); } var tj = JObject.Parse(jsonText); parts.Add(ExtractTranscriptFromResultJson(tj)); } return string.Join("\n", parts.Where(x => !string.IsNullOrWhiteSpace(x))).Trim(); } private static string ExtractTranscriptFromResultJson(JObject root) { var transcripts = root["transcripts"] as JArray; if (transcripts == null || transcripts.Count == 0) { return string.Empty; } var lines = new List(); foreach (var ch in transcripts) { var text = ch["text"]?.ToString()?.Trim(); if (!string.IsNullOrEmpty(text)) { lines.Add(text); } } return string.Join(" ", lines).Trim(); } private static void ThrowIfDashScopeError(string body, bool successStatus) { if (string.IsNullOrWhiteSpace(body)) { return; } JObject jo; try { jo = JObject.Parse(body); } catch { if (!successStatus) { throw NCCException.Oh("接口返回非 JSON:" + Truncate(body, 400)); } return; } if (jo["code"] != null && jo["message"] != null) { throw NCCException.Oh($"DashScope:{jo["code"]} {jo["message"]}"); } } private static string Truncate(string s, int max) { if (string.IsNullOrEmpty(s) || s.Length <= max) { return s; } return s.Substring(0, max) + "…"; } /// /// 与 中逻辑一致:拼出用户/阿里可访问的 HTTPS 地址。 /// private async Task ResolvePublicHttpsUrlFromUploadRecordAsync(string uploadRecordId) { var row = await _db.Queryable() .Where(x => x.Id == uploadRecordId) .FirstAsync(); if (row == null) { throw NCCException.Oh("上传记录不存在"); } var customDomain = _configuration["NCC_App:AliyunOSS:CustomDomain"] ?? _configuration["NCC_APP:AliyunOSS:CustomDomain"]; var publicBase = (_configuration["NCC_App:LocalFileBaseUrl"] ?? _configuration["NCC_App:Domain"] ?? "") .Trim().TrimEnd('/'); string resolved; if (!string.IsNullOrWhiteSpace(row.AccessUrl)) { resolved = row.AccessUrl; } else if (!string.IsNullOrEmpty(row.ObjectKey)) { if (!string.IsNullOrEmpty(customDomain)) { var u = $"{customDomain.TrimEnd('/')}/{row.ObjectKey}"; resolved = u.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? u : CombinePublicUrl(publicBase, u); } else { var fileName = Path.GetFileName(row.ObjectKey); var rel = $"/api/File/Image/{row.FileType}/{fileName}"; resolved = CombinePublicUrl(publicBase, rel); } } else { throw NCCException.Oh("上传记录缺少 ObjectKey,无法解析音频地址"); } if (!resolved.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { throw NCCException.Oh( "无法解析公网 HTTPS 音频地址,请配置 NCC_App:AliyunOSS:CustomDomain 或 NCC_App:Domain / LocalFileBaseUrl。"); } return resolved; } private static string CombinePublicUrl(string publicBase, string pathOrAbsolute) { if (string.IsNullOrWhiteSpace(pathOrAbsolute)) { return null; } if (pathOrAbsolute.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || pathOrAbsolute.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { return pathOrAbsolute; } if (string.IsNullOrWhiteSpace(publicBase)) { return pathOrAbsolute.StartsWith("/") ? pathOrAbsolute : "/" + pathOrAbsolute; } var path = pathOrAbsolute.StartsWith("/") ? pathOrAbsolute : "/" + pathOrAbsolute; return $"{publicBase.TrimEnd('/')}{path}"; } } }