fix(aihr): close practice audio URL exposure

This commit is contained in:
2026-07-15 01:32:02 +08:00
parent 09c3cbecb0
commit 2f44e6b7d1
10 changed files with 51 additions and 19 deletions
@@ -75,7 +75,9 @@ public class AihrSpeechController {
return R.ok(new AsrResponse(text, "openai-compatible"));
}
}
return R.ok(new AsrResponse(text, "openai-compatible", oss.getOssId(), oss.getUrl()));
// The OSS URL is intentionally not exposed. Playback goes through the
// authenticated /api/aihr/mobile/oss/{ossId} endpoint after binding.
return R.ok(new AsrResponse(text, "openai-compatible", oss.getOssId(), ""));
} catch (Exception e) {
log.warn("asr audio upload failed(处理错误已隐藏)");
return R.ok(new AsrResponse(text, "openai-compatible"));
@@ -135,7 +137,9 @@ public class AihrSpeechController {
Files.write(tempFile, audio);
File file = tempFile.toFile();
SysOssVo oss = ossService.upload(file);
return R.ok(new TtsResponse(oss.getUrl(), "openai-compatible", oss.getOssId(), ""));
// Keep the old client contract playable without returning a public OSS URL.
String inlineAudioUrl = "data:audio/mp3;base64," + Base64.getEncoder().encodeToString(audio);
return R.ok(new TtsResponse(inlineAudioUrl, "openai-compatible", oss.getOssId(), inlineAudioUrl));
} catch (Exception e) {
log.warn("tts audio upload failed(处理错误已隐藏)");
String inlineAudioUrl = "data:audio/mp3;base64," + Base64.getEncoder().encodeToString(audio);
@@ -2290,7 +2290,7 @@ public class AihrPracticeSeedService {
}
DailyDrillParts parts = dailyDrillParts((String) rows.get(0).get("reason"), (String) rows.get(0).get("scenario_id"));
String answer = maskSensitiveText(firstNonBlank(request == null ? null : request.answer(), ""));
String audioUrl = firstNonBlank(request == null ? null : request.audioUrl(), "");
String audioUrl = exposedAudioUrl(request == null ? null : request.audioUrl());
Long audioOssId = request == null ? null : request.audioOssId();
validateAudioOwnership(audioOssId, extPartyId, ownerUserId);
DailyDrillScore score = scoreDailyDrill(parts.question(), answer, parts.referenceAnswer());
@@ -2327,7 +2327,7 @@ public class AihrPracticeSeedService {
return null;
}
bindPracticeAudio(audioOssId);
return new DailyDrillAnswerResponse(id, "已完成", score.correct(), score.score(), score.feedback(), parts.referenceAnswer(), score.mode(), audioUrl, audioOssId);
return new DailyDrillAnswerResponse(id, "已完成", score.correct(), score.score(), score.feedback(), parts.referenceAnswer(), score.mode(), exposedAudioUrl(audioUrl), audioOssId);
}
public List<PromptTemplateResponse> promptTemplates(String category, String code, Boolean enabled) {
@@ -2933,7 +2933,7 @@ public class AihrPracticeSeedService {
correctValue == null ? null : rs.getBoolean("correct"),
rs.getObject("score") == null ? null : rs.getInt("score"),
firstNonBlank(rs.getString("score_mode"), "RULE_FALLBACK"),
rs.getString("answer_audio_url"),
exposedAudioUrl(rs.getString("answer_audio_url")),
rs.getObject("answer_audio_oss_id") == null ? null : rs.getLong("answer_audio_oss_id")
);
}
@@ -3267,7 +3267,7 @@ public class AihrPracticeSeedService {
while (audioUrls.size() <= roundIndex) {
audioUrls.add("");
}
audioUrls.set(roundIndex, firstNonBlank(request.traineeAudioUrl(), ""));
audioUrls.set(roundIndex, exposedAudioUrl(request.traineeAudioUrl()));
}
List<Long> audioOssIds = session.traineeAudioOssIds();
synchronized (audioOssIds) {
@@ -3631,7 +3631,7 @@ public class AihrPracticeSeedService {
item.path("role").asText(),
item.path("label").asText(),
item.path("text").asText(),
item.path("audioUrl").asText(""),
exposedAudioUrl(item.path("audioUrl").asText("")),
item.hasNonNull("audioOssId") ? item.path("audioOssId").asLong() : null,
item.hasNonNull("emotion") ? item.path("emotion").asInt() : null,
item.hasNonNull("trust") ? item.path("trust").asInt() : null,
@@ -3667,7 +3667,7 @@ public class AihrPracticeSeedService {
"trainee",
"员工话术",
replies.get(i),
i < audioUrls.size() ? audioUrls.get(i) : "",
i < audioUrls.size() ? exposedAudioUrl(audioUrls.get(i)) : "",
i < audioOssIds.size() ? audioOssIds.get(i) : null,
evidence == null ? null : evidence.emotion(),
evidence == null ? null : evidence.trust(),
@@ -3725,7 +3725,7 @@ public class AihrPracticeSeedService {
turn.role(),
turn.label(),
maskSensitiveText(turn.text()),
firstNonBlank(turn.audioUrl(), ""),
exposedAudioUrl(turn.audioUrl()),
turn.audioOssId(),
turn.emotion(),
turn.trust(),
@@ -3739,6 +3739,18 @@ public class AihrPracticeSeedService {
return AihrSensitiveText.forModel(text);
}
/**
* Never expose a raw sys_oss URL through a practice response. OSS playback is
* authorized by audioOssId; only an inline data URL is safe to return directly.
*/
private static String exposedAudioUrl(String audioUrl) {
if (audioUrl == null || audioUrl.isBlank()) {
return "";
}
String value = audioUrl.trim();
return value.startsWith("data:audio/") ? value : "";
}
private Integer scoreValue(List<DimensionResponse> scores, String label) {
for (DimensionResponse score : scores) {
if (label.equals(score.label()) && score.value() instanceof Number) {
@@ -317,6 +317,8 @@ public class AihrPracticeSeedServiceTest {
assertTrue(service.contains("d.answer_audio_oss_id = ?"));
assertTrue(service.contains("aihr_practice_audio_upload"));
assertTrue(service.contains("cleanupExpiredPracticeAudioUploads"));
assertTrue(service.contains("private static String exposedAudioUrl(String audioUrl)"));
assertTrue(service.contains("return value.startsWith(\"data:audio/\") ? value : \"\";"));
assertTrue(controller.contains("currentAppUserId()"));
assertTrue(mobileController.contains("answerDailyDrill(id, request, ownMobileExtPartyId(null), currentAppUserId())"));
}
@@ -75,7 +75,9 @@ class AihrSensitiveTextTest {
void ttsUploadSuccessDoesNotDuplicateInlineAudio() throws Exception {
String speechControllerSource = Files.readString(controllerSource("AihrSpeechController.java"));
assertTrue(speechControllerSource.contains("new TtsResponse(oss.getUrl(), \"openai-compatible\", oss.getOssId(), \"\")"));
assertTrue(speechControllerSource.contains("new AsrResponse(text, \"openai-compatible\", oss.getOssId(), \"\")"));
assertTrue(speechControllerSource.contains("new TtsResponse(inlineAudioUrl, \"openai-compatible\", oss.getOssId(), inlineAudioUrl)"));
assertFalse(speechControllerSource.contains("new TtsResponse(oss.getUrl(),"));
assertTrue(speechControllerSource.contains("new TtsResponse(inlineAudioUrl, \"openai-compatible\")"));
}