chore: checkpoint local workspace changes
This commit is contained in:
2
server-rs/Cargo.lock
generated
2
server-rs/Cargo.lock
generated
@@ -1520,6 +1520,7 @@ dependencies = [
|
||||
"shared-kernel",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -1859,6 +1860,7 @@ dependencies = [
|
||||
"shared-kernel",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -42,7 +42,8 @@ use std::convert::Infallible;
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
custom_world_agent_turn::{
|
||||
CustomWorldAgentTurnRequest, build_finalize_record_input, run_custom_world_agent_turn,
|
||||
CustomWorldAgentTurnRequest, build_failed_finalize_record_input,
|
||||
build_finalize_record_input, run_custom_world_agent_turn,
|
||||
},
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
@@ -566,21 +567,47 @@ pub async fn submit_custom_world_agent_message(
|
||||
|_| {},
|
||||
)
|
||||
.await;
|
||||
let finalized_operation = state
|
||||
.spacetime_client()
|
||||
.finalize_custom_world_agent_message(build_finalize_record_input(
|
||||
session_id,
|
||||
owner_user_id,
|
||||
operation_id,
|
||||
let finalize_input = match turn_result {
|
||||
Ok(turn_result) => build_finalize_record_input(
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
operation_id.clone(),
|
||||
format!("assistant-{}", operation.operation_id),
|
||||
turn_result,
|
||||
current_utc_micros(),
|
||||
))
|
||||
),
|
||||
Err(error) => build_failed_finalize_record_input(
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
operation_id.clone(),
|
||||
&session,
|
||||
error.to_string(),
|
||||
current_utc_micros(),
|
||||
),
|
||||
};
|
||||
let finalized_operation = state
|
||||
.spacetime_client()
|
||||
.finalize_custom_world_agent_message(finalize_input)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
||||
})?;
|
||||
|
||||
if finalized_operation.status == "failed" {
|
||||
let message = finalized_operation
|
||||
.error_message
|
||||
.clone()
|
||||
.unwrap_or_else(|| "消息处理失败".to_string());
|
||||
return Err(custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "custom-world-agent",
|
||||
"message": message,
|
||||
"operationId": finalized_operation.operation_id,
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
@@ -695,16 +722,27 @@ pub async fn stream_custom_world_agent_message(
|
||||
));
|
||||
}
|
||||
|
||||
let finalize_result = state
|
||||
.spacetime_client()
|
||||
.finalize_custom_world_agent_message(build_finalize_record_input(
|
||||
let finalize_input = match turn_result {
|
||||
Ok(turn_result) => build_finalize_record_input(
|
||||
session_id_for_stream.clone(),
|
||||
owner_user_id_for_stream.clone(),
|
||||
operation_id.clone(),
|
||||
format!("assistant-{operation_id}"),
|
||||
turn_result,
|
||||
current_utc_micros(),
|
||||
))
|
||||
),
|
||||
Err(error) => build_failed_finalize_record_input(
|
||||
session_id_for_stream.clone(),
|
||||
owner_user_id_for_stream.clone(),
|
||||
operation_id.clone(),
|
||||
&session,
|
||||
error.to_string(),
|
||||
current_utc_micros(),
|
||||
),
|
||||
};
|
||||
let finalize_result = state
|
||||
.spacetime_client()
|
||||
.finalize_custom_world_agent_message(finalize_input)
|
||||
.await;
|
||||
let _finalized_operation = match finalize_result {
|
||||
Ok(operation) => operation,
|
||||
@@ -716,6 +754,18 @@ pub async fn stream_custom_world_agent_message(
|
||||
return;
|
||||
}
|
||||
};
|
||||
if _finalized_operation.status == "failed" {
|
||||
yield Ok::<Event, Infallible>(custom_world_sse_json_event_or_error(
|
||||
"error",
|
||||
json!({
|
||||
"message": _finalized_operation
|
||||
.error_message
|
||||
.clone()
|
||||
.unwrap_or_else(|| "消息处理失败".to_string())
|
||||
}),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
let final_session = match state
|
||||
.spacetime_client()
|
||||
@@ -1113,6 +1163,14 @@ fn map_custom_world_client_error(error: SpacetimeClientError) -> AppError {
|
||||
{
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
SpacetimeClientError::Procedure(message)
|
||||
if message.contains("当前模型不可用")
|
||||
|| message.contains("设定生成失败")
|
||||
|| message.contains("解析失败")
|
||||
|| message.contains("缺少有效回复") =>
|
||||
{
|
||||
StatusCode::BAD_GATEWAY
|
||||
}
|
||||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
@@ -248,9 +248,29 @@ struct SingleTurnModelOutput {
|
||||
next_anchor_content: EightAnchorContent,
|
||||
progress_percent: u32,
|
||||
reply_text: String,
|
||||
fallback_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CustomWorldTurnError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl CustomWorldTurnError {
|
||||
fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CustomWorldTurnError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CustomWorldTurnError {}
|
||||
|
||||
const BASE_SYSTEM_PROMPT: &str = r#"你是一个负责共创游戏世界设定的专业策划。
|
||||
|
||||
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
|
||||
@@ -477,7 +497,7 @@ const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结构输出
|
||||
pub(crate) async fn run_custom_world_agent_turn<F>(
|
||||
request: CustomWorldAgentTurnRequest<'_>,
|
||||
on_reply_update: F,
|
||||
) -> CustomWorldAgentTurnResult
|
||||
) -> Result<CustomWorldAgentTurnResult, CustomWorldTurnError>
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
@@ -495,7 +515,7 @@ where
|
||||
¤t_anchor_content,
|
||||
on_reply_update,
|
||||
)
|
||||
.await;
|
||||
.await?;
|
||||
|
||||
let next_anchor_content = assistant_turn.next_anchor_content.clone();
|
||||
let next_creator_intent = build_creator_intent_from_eight_anchor_content(&next_anchor_content);
|
||||
@@ -607,30 +627,12 @@ where
|
||||
))
|
||||
};
|
||||
|
||||
let (phase_label, phase_detail, operation_status, operation_progress, error_message) =
|
||||
match assistant_turn.fallback_error {
|
||||
Some(message) => (
|
||||
"模型暂不可用".to_string(),
|
||||
message.clone(),
|
||||
"failed".to_string(),
|
||||
100,
|
||||
Some(message),
|
||||
),
|
||||
None => (
|
||||
"消息已处理".to_string(),
|
||||
"本轮回复已由大模型生成并回写会话。".to_string(),
|
||||
"completed".to_string(),
|
||||
100,
|
||||
None,
|
||||
),
|
||||
};
|
||||
|
||||
CustomWorldAgentTurnResult {
|
||||
Ok(CustomWorldAgentTurnResult {
|
||||
assistant_reply_text: assistant_turn.reply_text,
|
||||
phase_label,
|
||||
phase_detail,
|
||||
operation_status,
|
||||
operation_progress,
|
||||
phase_label: "消息已处理".to_string(),
|
||||
phase_detail: "本轮回复已由大模型生成并回写会话。".to_string(),
|
||||
operation_status: "completed".to_string(),
|
||||
operation_progress: 100,
|
||||
stage: next_stage,
|
||||
progress_percent,
|
||||
focus_card_id: if should_stay_in_draft_stage {
|
||||
@@ -648,8 +650,8 @@ where
|
||||
recommended_replies_json,
|
||||
quality_findings_json,
|
||||
asset_coverage_json,
|
||||
error_message,
|
||||
}
|
||||
error_message: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_finalize_record_input(
|
||||
@@ -664,8 +666,8 @@ pub(crate) fn build_finalize_record_input(
|
||||
session_id,
|
||||
owner_user_id,
|
||||
operation_id,
|
||||
assistant_message_id,
|
||||
assistant_reply_text: result.assistant_reply_text,
|
||||
assistant_message_id: Some(assistant_message_id),
|
||||
assistant_reply_text: Some(result.assistant_reply_text),
|
||||
phase_label: result.phase_label,
|
||||
phase_detail: result.phase_detail,
|
||||
operation_status: result.operation_status,
|
||||
@@ -688,6 +690,75 @@ pub(crate) fn build_finalize_record_input(
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_optional_json_object(value: &JsonValue) -> Option<String> {
|
||||
if value.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(serialize_json(value, &empty_json_object()))
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_string_array(values: &[String]) -> String {
|
||||
serialize_json(
|
||||
&JsonValue::Array(
|
||||
values
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(JsonValue::String)
|
||||
.collect::<Vec<_>>(),
|
||||
),
|
||||
&empty_json_array(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_failed_finalize_record_input(
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
operation_id: String,
|
||||
session: &CustomWorldAgentSessionRecord,
|
||||
error_message: String,
|
||||
updated_at_micros: i64,
|
||||
) -> CustomWorldAgentMessageFinalizeRecordInput {
|
||||
CustomWorldAgentMessageFinalizeRecordInput {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
operation_id,
|
||||
assistant_message_id: None,
|
||||
assistant_reply_text: None,
|
||||
phase_label: "消息处理失败".to_string(),
|
||||
phase_detail: error_message.clone(),
|
||||
operation_status: "failed".to_string(),
|
||||
operation_progress: 100,
|
||||
stage: session.stage.clone(),
|
||||
progress_percent: session.progress_percent,
|
||||
focus_card_id: session.focus_card_id.clone(),
|
||||
anchor_content_json: serialize_json(&session.anchor_content, &empty_agent_anchor_content_json()),
|
||||
creator_intent_json: serialize_optional_json_object(&session.creator_intent),
|
||||
creator_intent_readiness_json: serialize_json(
|
||||
&session.creator_intent_readiness,
|
||||
&empty_agent_creator_intent_readiness_json(),
|
||||
),
|
||||
anchor_pack_json: serialize_optional_json_object(&session.anchor_pack),
|
||||
draft_profile_json: serialize_optional_json_object(&session.draft_profile),
|
||||
pending_clarifications_json: serialize_json(
|
||||
&JsonValue::Array(session.pending_clarifications.clone()),
|
||||
&empty_json_array(),
|
||||
),
|
||||
suggested_actions_json: serialize_json(
|
||||
&JsonValue::Array(session.suggested_actions.clone()),
|
||||
&empty_json_array(),
|
||||
),
|
||||
recommended_replies_json: serialize_string_array(&session.recommended_replies),
|
||||
quality_findings_json: serialize_json(
|
||||
&JsonValue::Array(session.quality_findings.clone()),
|
||||
&empty_json_array(),
|
||||
),
|
||||
asset_coverage_json: serialize_json(&session.asset_coverage, &empty_agent_asset_coverage_json()),
|
||||
error_message: Some(error_message),
|
||||
updated_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_single_turn<F>(
|
||||
llm_client: Option<&LlmClient>,
|
||||
messages: &[CustomWorldAgentMessageRecord],
|
||||
@@ -696,17 +767,13 @@ async fn stream_single_turn<F>(
|
||||
quick_fill_requested: bool,
|
||||
current_anchor_content: &EightAnchorContent,
|
||||
mut on_reply_update: F,
|
||||
) -> SingleTurnModelOutput
|
||||
) -> Result<SingleTurnModelOutput, CustomWorldTurnError>
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
if llm_client.is_none() {
|
||||
let fallback = build_unavailable_output(current_anchor_content, progress_percent, true);
|
||||
on_reply_update(fallback.reply_text.as_str());
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let llm_client = llm_client.expect("checked above");
|
||||
let llm_client = llm_client.ok_or_else(|| {
|
||||
CustomWorldTurnError::new("当前模型不可用,请稍后重试。")
|
||||
})?;
|
||||
let chat_history = build_chat_history(messages);
|
||||
let dynamic_state =
|
||||
resolve_dynamic_state(llm_client, current_turn, progress_percent, quick_fill_requested, current_anchor_content, &chat_history)
|
||||
@@ -739,21 +806,13 @@ where
|
||||
)
|
||||
.await;
|
||||
|
||||
let Ok(response) = response else {
|
||||
let fallback = build_unavailable_output(current_anchor_content, progress_percent, false);
|
||||
if fallback.reply_text != latest_reply_text {
|
||||
on_reply_update(fallback.reply_text.as_str());
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
let response = response.map_err(|_| {
|
||||
CustomWorldTurnError::new("这一轮设定生成失败,请稍后重试。")
|
||||
})?;
|
||||
|
||||
let Ok(parsed) = parse_json_response_text(response.content.as_str()) else {
|
||||
let fallback = build_unavailable_output(current_anchor_content, progress_percent, false);
|
||||
if fallback.reply_text != latest_reply_text {
|
||||
on_reply_update(fallback.reply_text.as_str());
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
let parsed = parse_json_response_text(response.content.as_str()).map_err(|_| {
|
||||
CustomWorldTurnError::new("模型返回结果解析失败,请稍后重试。")
|
||||
})?;
|
||||
|
||||
let next_anchor_content = normalize_eight_anchor_content(
|
||||
parsed
|
||||
@@ -765,19 +824,18 @@ where
|
||||
} else {
|
||||
clamp_progress_percent(parsed.get("progressPercent"))
|
||||
};
|
||||
let reply_text = to_text(parsed.get("replyText")).unwrap_or_else(|| {
|
||||
build_unavailable_output(current_anchor_content, progress_percent, false).reply_text
|
||||
});
|
||||
let reply_text = to_text(parsed.get("replyText")).ok_or_else(|| {
|
||||
CustomWorldTurnError::new("模型返回结果缺少有效回复,请稍后重试。")
|
||||
})?;
|
||||
if reply_text != latest_reply_text {
|
||||
on_reply_update(reply_text.as_str());
|
||||
}
|
||||
|
||||
SingleTurnModelOutput {
|
||||
Ok(SingleTurnModelOutput {
|
||||
next_anchor_content,
|
||||
progress_percent,
|
||||
reply_text,
|
||||
fallback_error: None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_dynamic_state(
|
||||
@@ -1605,28 +1663,6 @@ fn render_chat_history_context(chat_history: &[JsonValue]) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn build_unavailable_output(
|
||||
current_anchor_content: &EightAnchorContent,
|
||||
progress_percent: u32,
|
||||
unavailable: bool,
|
||||
) -> SingleTurnModelOutput {
|
||||
SingleTurnModelOutput {
|
||||
next_anchor_content: current_anchor_content.clone(),
|
||||
progress_percent,
|
||||
reply_text: if unavailable {
|
||||
"当前模型不可用,这一轮设定先保留上一版。你可以稍后重试。".to_string()
|
||||
} else {
|
||||
"这一轮设定还没成功更新,我先保留上一版。你可以再发一次,我继续接着收。"
|
||||
.to_string()
|
||||
},
|
||||
fallback_error: Some(if unavailable {
|
||||
"当前模型不可用,这一轮设定先保留上一版。".to_string()
|
||||
} else {
|
||||
"这一轮设定还没成功更新,我先保留上一版。".to_string()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
|
||||
let trimmed = text.trim();
|
||||
if let Some(start) = trimmed.find('{')
|
||||
@@ -1642,53 +1678,47 @@ fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
|
||||
let key_index = text.find("\"replyText\"")?;
|
||||
let colon_index = text[key_index..].find(':')? + key_index;
|
||||
let mut cursor = colon_index + 1;
|
||||
let bytes = text.as_bytes();
|
||||
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
|
||||
while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() {
|
||||
cursor += 1;
|
||||
}
|
||||
if bytes.get(cursor).copied() != Some(b'"') {
|
||||
if text.as_bytes().get(cursor).copied() != Some(b'"') {
|
||||
return None;
|
||||
}
|
||||
cursor += 1;
|
||||
let mut decoded = String::new();
|
||||
while cursor < bytes.len() {
|
||||
let current = bytes[cursor];
|
||||
if current == b'"' {
|
||||
let remainder = text.get(cursor..)?;
|
||||
let mut characters = remainder.chars().peekable();
|
||||
while let Some(current) = characters.next() {
|
||||
if current == '"' {
|
||||
return Some(decoded);
|
||||
}
|
||||
if current == b'\\' {
|
||||
cursor += 1;
|
||||
if cursor >= bytes.len() {
|
||||
break;
|
||||
}
|
||||
match bytes[cursor] {
|
||||
b'"' => decoded.push('"'),
|
||||
b'\\' => decoded.push('\\'),
|
||||
b'/' => decoded.push('/'),
|
||||
b'b' => decoded.push('\u{0008}'),
|
||||
b'f' => decoded.push('\u{000C}'),
|
||||
b'n' => decoded.push('\n'),
|
||||
b'r' => decoded.push('\r'),
|
||||
b't' => decoded.push('\t'),
|
||||
b'u' => {
|
||||
if cursor + 4 < bytes.len()
|
||||
&& let Ok(hex) = std::str::from_utf8(&bytes[cursor + 1..cursor + 5])
|
||||
&& let Ok(code) = u16::from_str_radix(hex, 16)
|
||||
if current == '\\' {
|
||||
let escaped = characters.next()?;
|
||||
match escaped {
|
||||
'"' => decoded.push('"'),
|
||||
'\\' => decoded.push('\\'),
|
||||
'/' => decoded.push('/'),
|
||||
'b' => decoded.push('\u{0008}'),
|
||||
'f' => decoded.push('\u{000C}'),
|
||||
'n' => decoded.push('\n'),
|
||||
'r' => decoded.push('\r'),
|
||||
't' => decoded.push('\t'),
|
||||
'u' => {
|
||||
let mut hex = String::new();
|
||||
for _ in 0..4 {
|
||||
hex.push(characters.next()?);
|
||||
}
|
||||
if let Ok(code) = u16::from_str_radix(hex.as_str(), 16)
|
||||
&& let Some(character) = char::from_u32(code as u32)
|
||||
{
|
||||
decoded.push(character);
|
||||
cursor += 5;
|
||||
continue;
|
||||
}
|
||||
decoded.push('u');
|
||||
}
|
||||
other => decoded.push(other as char),
|
||||
other => decoded.push(other),
|
||||
}
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
decoded.push(current as char);
|
||||
cursor += 1;
|
||||
decoded.push(current);
|
||||
}
|
||||
Some(decoded)
|
||||
}
|
||||
@@ -2010,3 +2040,17 @@ impl PromptConversationMode {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_reply_text_from_partial_json;
|
||||
|
||||
#[test]
|
||||
fn extract_reply_text_from_partial_json_preserves_chinese_characters() {
|
||||
let partial_json = r#"{"replyText":"你好,潮雾列岛","progressPercent":32"#;
|
||||
|
||||
let extracted = extract_reply_text_from_partial_json(partial_json);
|
||||
|
||||
assert_eq!(extracted.as_deref(), Some("你好,潮雾列岛"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ pub async fn health_check(Extension(request_context): Extension<RequestContext>)
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"ok": true,
|
||||
"service": "genarrative-node-server",
|
||||
"service": "genarrative-api-server",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use shared_contracts::auth::{
|
||||
PhoneSendCodeResponse,
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
@@ -37,17 +38,54 @@ pub async fn send_phone_code(
|
||||
);
|
||||
}
|
||||
let scene = map_phone_auth_scene(payload.scene.as_deref())?;
|
||||
let result = state
|
||||
let phone_input_masked = mask_phone_input(payload.phone.as_str());
|
||||
info!(
|
||||
request_id = request_context.request_id(),
|
||||
operation = request_context.operation(),
|
||||
scene = scene.as_str(),
|
||||
provider = state.config.sms_auth_provider.as_str(),
|
||||
phone_input_masked = phone_input_masked.as_str(),
|
||||
"收到手机号验证码发送请求"
|
||||
);
|
||||
let result = match state
|
||||
.phone_auth_service()
|
||||
.send_code(
|
||||
SendPhoneCodeInput {
|
||||
phone_number: payload.phone,
|
||||
scene,
|
||||
scene: scene.clone(),
|
||||
},
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_phone_auth_error)?;
|
||||
{
|
||||
Ok(result) => {
|
||||
info!(
|
||||
request_id = request_context.request_id(),
|
||||
operation = request_context.operation(),
|
||||
scene = %result.scene,
|
||||
phone_masked = %result.phone_number_masked,
|
||||
provider = %result.provider,
|
||||
provider_request_id = %result.provider_request_id.as_deref().unwrap_or("unknown"),
|
||||
provider_out_id = %result.provider_out_id.as_deref().unwrap_or("unknown"),
|
||||
cooldown_seconds = result.cooldown_seconds,
|
||||
expires_in_seconds = result.expires_in_seconds,
|
||||
"手机号验证码发送请求已提交"
|
||||
);
|
||||
result
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
request_id = request_context.request_id(),
|
||||
operation = request_context.operation(),
|
||||
scene = scene.as_str(),
|
||||
provider = state.config.sms_auth_provider.as_str(),
|
||||
phone_input_masked = phone_input_masked.as_str(),
|
||||
error = %error,
|
||||
"手机号验证码发送失败"
|
||||
);
|
||||
return Err(map_phone_auth_error(error));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -72,7 +110,7 @@ pub async fn phone_login(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
|
||||
);
|
||||
}
|
||||
let result = state
|
||||
let result = match state
|
||||
.phone_auth_service()
|
||||
.login(
|
||||
PhoneLoginInput {
|
||||
@@ -82,7 +120,32 @@ pub async fn phone_login(
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_phone_auth_error)?;
|
||||
{
|
||||
Ok(result) => {
|
||||
info!(
|
||||
request_id = request_context.request_id(),
|
||||
operation = request_context.operation(),
|
||||
scene = "login",
|
||||
phone_masked = %result.phone_number_masked,
|
||||
provider = %result.provider,
|
||||
provider_out_id = %result.provider_out_id.as_deref().unwrap_or("unknown"),
|
||||
user_id = %result.user.id,
|
||||
created = result.created,
|
||||
"手机号验证码登录成功"
|
||||
);
|
||||
result
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
request_id = request_context.request_id(),
|
||||
operation = request_context.operation(),
|
||||
scene = "login",
|
||||
error = %error,
|
||||
"手机号验证码登录失败"
|
||||
);
|
||||
return Err(map_phone_auth_error(error));
|
||||
}
|
||||
};
|
||||
let session_client = resolve_session_client_context(&headers);
|
||||
let signed_session = create_auth_session(
|
||||
&state,
|
||||
@@ -128,6 +191,37 @@ fn map_phone_auth_scene(raw_scene: Option<&str>) -> Result<PhoneAuthScene, AppEr
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_phone_input(phone: &str) -> String {
|
||||
let trimmed = phone.trim();
|
||||
if trimmed.is_empty() {
|
||||
return "empty".to_string();
|
||||
}
|
||||
let digits: String = trimmed.chars().filter(|ch| ch.is_ascii_digit()).collect();
|
||||
let target = if digits.len() >= 7 {
|
||||
digits
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
};
|
||||
mask_phone_digits(&target)
|
||||
}
|
||||
|
||||
fn mask_phone_digits(value: &str) -> String {
|
||||
let chars: Vec<char> = value.chars().collect();
|
||||
if chars.len() <= 4 {
|
||||
return "*".repeat(chars.len().max(1));
|
||||
}
|
||||
let prefix_len = chars.len().min(3);
|
||||
let suffix_len = 4.min(chars.len().saturating_sub(prefix_len));
|
||||
let mask_len = chars.len().saturating_sub(prefix_len + suffix_len);
|
||||
let mut masked = String::new();
|
||||
masked.extend(chars.iter().take(prefix_len));
|
||||
masked.push_str(&"*".repeat(mask_len.max(1)));
|
||||
if suffix_len > 0 {
|
||||
masked.extend(chars.iter().skip(chars.len() - suffix_len));
|
||||
}
|
||||
masked
|
||||
}
|
||||
|
||||
fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
|
||||
match error {
|
||||
PhoneAuthError::InvalidPhoneNumber
|
||||
|
||||
@@ -8,6 +8,7 @@ license.workspace = true
|
||||
platform-auth = { path = "../platform-auth" }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
time = { version = "0.3", features = ["formatting", "parsing"] }
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -14,6 +14,7 @@ use shared_kernel::{
|
||||
normalize_optional_string, normalize_required_string, parse_rfc3339,
|
||||
};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use tracing::{info, warn};
|
||||
|
||||
const USERNAME_MIN_LENGTH: usize = 3;
|
||||
const USERNAME_MAX_LENGTH: usize = 24;
|
||||
@@ -90,6 +91,10 @@ pub struct SendPhoneCodeResult {
|
||||
pub cooldown_seconds: u64,
|
||||
pub expires_in_seconds: u64,
|
||||
pub provider_request_id: Option<String>,
|
||||
pub provider_out_id: Option<String>,
|
||||
pub provider: String,
|
||||
pub scene: String,
|
||||
pub phone_number_masked: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -102,6 +107,9 @@ pub struct PhoneLoginInput {
|
||||
pub struct PhoneLoginResult {
|
||||
pub user: AuthUser,
|
||||
pub created: bool,
|
||||
pub provider: String,
|
||||
pub provider_out_id: Option<String>,
|
||||
pub phone_number_masked: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -577,7 +585,18 @@ impl PhoneAuthService {
|
||||
input: SendPhoneCodeInput,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<SendPhoneCodeResult, PhoneAuthError> {
|
||||
let scene = input.scene.clone();
|
||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
||||
let national_phone_number = build_national_phone_number(&normalized_phone.e164)?;
|
||||
info!(
|
||||
scene = scene.as_str(),
|
||||
provider = self.sms_provider.kind().as_str(),
|
||||
phone_e164_masked = mask_phone_number(&normalized_phone.e164).as_str(),
|
||||
phone_national_masked = normalized_phone.masked_national_number.as_str(),
|
||||
"手机号验证码发送准备调用 provider"
|
||||
);
|
||||
self.store
|
||||
.ensure_phone_code_not_cooling_down(&normalized_phone.e164, &scene, now)?;
|
||||
let expires_at = now
|
||||
.checked_add(Duration::minutes(SMS_CODE_TTL_MINUTES))
|
||||
.ok_or_else(|| PhoneAuthError::Store("短信验证码过期时间计算溢出".to_string()))?;
|
||||
@@ -588,16 +607,27 @@ impl PhoneAuthService {
|
||||
let provider_result = self
|
||||
.sms_provider
|
||||
.send_code(SmsSendCodeRequest {
|
||||
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
|
||||
national_phone_number,
|
||||
scene: input.scene.as_str().to_string(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_sms_provider_error_to_phone_error)?;
|
||||
info!(
|
||||
scene = scene.as_str(),
|
||||
provider = self.sms_provider.kind().as_str(),
|
||||
phone_e164_masked = mask_phone_number(&normalized_phone.e164).as_str(),
|
||||
phone_national_masked = normalized_phone.masked_national_number.as_str(),
|
||||
cooldown_seconds = provider_result.cooldown_seconds,
|
||||
expires_in_seconds = provider_result.expires_in_seconds,
|
||||
provider_request_id = provider_result.provider_request_id.as_deref().unwrap_or("unknown"),
|
||||
provider_out_id = provider_result.provider_out_id.as_deref().unwrap_or("unknown"),
|
||||
"手机号验证码 provider 调用成功,准备写入本地快照"
|
||||
);
|
||||
|
||||
self.store.upsert_phone_code(
|
||||
StoredPhoneCode {
|
||||
phone_number: normalized_phone.e164.clone(),
|
||||
scene: input.scene,
|
||||
scene,
|
||||
expires_at,
|
||||
last_sent_at: format_rfc3339(now).map_err(|message| {
|
||||
PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}"))
|
||||
@@ -612,6 +642,10 @@ impl PhoneAuthService {
|
||||
cooldown_seconds: provider_result.cooldown_seconds,
|
||||
expires_in_seconds: provider_result.expires_in_seconds,
|
||||
provider_request_id: provider_result.provider_request_id,
|
||||
provider_out_id: provider_result.provider_out_id,
|
||||
provider: self.sms_provider.kind().as_str().to_string(),
|
||||
scene: input.scene.as_str().to_string(),
|
||||
phone_number_masked: normalized_phone.masked_national_number,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -656,6 +690,9 @@ impl PhoneAuthService {
|
||||
..user
|
||||
},
|
||||
created: false,
|
||||
provider: self.sms_provider.kind().as_str().to_string(),
|
||||
provider_out_id,
|
||||
phone_number_masked: normalized_phone.masked_national_number,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -671,6 +708,9 @@ impl PhoneAuthService {
|
||||
Ok(PhoneLoginResult {
|
||||
user: created_user,
|
||||
created: true,
|
||||
provider: self.sms_provider.kind().as_str().to_string(),
|
||||
provider_out_id,
|
||||
phone_number_masked: normalized_phone.masked_national_number,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1205,7 +1245,7 @@ impl InMemoryAuthStore {
|
||||
fn upsert_phone_code(
|
||||
&self,
|
||||
code: StoredPhoneCode,
|
||||
now: OffsetDateTime,
|
||||
_now: OffsetDateTime,
|
||||
) -> Result<(), PhoneAuthError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
@@ -1213,26 +1253,49 @@ impl InMemoryAuthStore {
|
||||
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
|
||||
// 手机号和业务场景共同决定同一份验证码快照,重复发送时直接覆盖旧值。
|
||||
let key = build_phone_code_key(&code.phone_number, &code.scene);
|
||||
if let Some(stored) = state.phone_codes_by_key.get(&key).cloned() {
|
||||
let expires_at = parse_phone_code_time(&stored.expires_at, "过期时间")?;
|
||||
if expires_at > now {
|
||||
let last_sent_at = parse_phone_code_time(&stored.last_sent_at, "发送时间")?;
|
||||
let cooling_until = last_sent_at
|
||||
.checked_add(Duration::seconds(SMS_CODE_COOLDOWN_SECONDS as i64))
|
||||
.ok_or_else(|| {
|
||||
PhoneAuthError::Store("短信验证码冷却时间计算溢出".to_string())
|
||||
})?;
|
||||
if cooling_until > now {
|
||||
return Err(PhoneAuthError::SendCoolingDown {
|
||||
retry_after_seconds: seconds_until(now, cooling_until),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
state.phone_codes_by_key.insert(key, code);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_phone_code_not_cooling_down(
|
||||
&self,
|
||||
phone_number: &str,
|
||||
scene: &PhoneAuthScene,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<(), PhoneAuthError> {
|
||||
let state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
|
||||
let key = build_phone_code_key(phone_number, scene);
|
||||
let Some(stored) = state.phone_codes_by_key.get(&key).cloned() else {
|
||||
return Ok(());
|
||||
};
|
||||
drop(state);
|
||||
|
||||
let expires_at = parse_phone_code_time(&stored.expires_at, "过期时间")?;
|
||||
if expires_at <= now {
|
||||
return Ok(());
|
||||
}
|
||||
let last_sent_at = parse_phone_code_time(&stored.last_sent_at, "发送时间")?;
|
||||
let cooling_until = last_sent_at
|
||||
.checked_add(Duration::seconds(SMS_CODE_COOLDOWN_SECONDS as i64))
|
||||
.ok_or_else(|| PhoneAuthError::Store("短信验证码冷却时间计算溢出".to_string()))?;
|
||||
if cooling_until <= now {
|
||||
return Ok(());
|
||||
}
|
||||
let retry_after_seconds = seconds_until(now, cooling_until);
|
||||
warn!(
|
||||
scene = scene.as_str(),
|
||||
phone_masked = mask_phone_number(phone_number).as_str(),
|
||||
retry_after_seconds,
|
||||
"手机号验证码发送命中本地冷却限制"
|
||||
);
|
||||
Err(PhoneAuthError::SendCoolingDown {
|
||||
retry_after_seconds,
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_phone_code_active(
|
||||
&self,
|
||||
phone_number: &str,
|
||||
|
||||
@@ -514,8 +514,8 @@ pub struct CustomWorldAgentMessageFinalizeInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub operation_id: String,
|
||||
pub assistant_message_id: String,
|
||||
pub assistant_reply_text: String,
|
||||
pub assistant_message_id: Option<String>,
|
||||
pub assistant_reply_text: Option<String>,
|
||||
pub phase_label: String,
|
||||
pub phase_detail: String,
|
||||
pub operation_status: RpgAgentOperationStatus,
|
||||
@@ -1106,11 +1106,23 @@ pub fn validate_custom_world_agent_message_finalize_input(
|
||||
return Err(CustomWorldFieldError::MissingOwnerUserId);
|
||||
}
|
||||
|
||||
validate_custom_world_agent_message_fields(
|
||||
&input.assistant_message_id,
|
||||
&input.session_id,
|
||||
&input.assistant_reply_text,
|
||||
)?;
|
||||
match input.operation_status {
|
||||
RpgAgentOperationStatus::Completed => {
|
||||
validate_custom_world_agent_message_fields(
|
||||
input.assistant_message_id.as_deref().unwrap_or_default(),
|
||||
&input.session_id,
|
||||
input.assistant_reply_text.as_deref().unwrap_or_default(),
|
||||
)?;
|
||||
}
|
||||
RpgAgentOperationStatus::Failed => {}
|
||||
_ => {
|
||||
validate_custom_world_agent_message_fields(
|
||||
input.assistant_message_id.as_deref().unwrap_or_default(),
|
||||
&input.session_id,
|
||||
input.assistant_reply_text.as_deref().unwrap_or_default(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
validate_custom_world_agent_operation_fields(
|
||||
&input.operation_id,
|
||||
&input.session_id,
|
||||
@@ -1733,8 +1745,8 @@ mod tests {
|
||||
session_id: "session_001".to_string(),
|
||||
owner_user_id: "user_001".to_string(),
|
||||
operation_id: "operation_001".to_string(),
|
||||
assistant_message_id: "message_001".to_string(),
|
||||
assistant_reply_text: "已生成回复".to_string(),
|
||||
assistant_message_id: Some("message_001".to_string()),
|
||||
assistant_reply_text: Some("已生成回复".to_string()),
|
||||
phase_label: "消息已处理".to_string(),
|
||||
phase_detail: "这一轮已完成推理并写回".to_string(),
|
||||
operation_status: RpgAgentOperationStatus::Completed,
|
||||
@@ -1761,6 +1773,37 @@ mod tests {
|
||||
assert_eq!(error, CustomWorldFieldError::InvalidJsonPayload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_message_finalize_allows_missing_assistant_reply_when_failed() {
|
||||
validate_custom_world_agent_message_finalize_input(&CustomWorldAgentMessageFinalizeInput {
|
||||
session_id: "session_001".to_string(),
|
||||
owner_user_id: "user_001".to_string(),
|
||||
operation_id: "operation_001".to_string(),
|
||||
assistant_message_id: None,
|
||||
assistant_reply_text: None,
|
||||
phase_label: "消息处理失败".to_string(),
|
||||
phase_detail: "当前模型不可用,请稍后重试。".to_string(),
|
||||
operation_status: RpgAgentOperationStatus::Failed,
|
||||
operation_progress: 100,
|
||||
stage: RpgAgentStage::Clarifying,
|
||||
progress_percent: 20,
|
||||
focus_card_id: None,
|
||||
anchor_content_json: "{}".to_string(),
|
||||
creator_intent_json: Some("{}".to_string()),
|
||||
creator_intent_readiness_json: "{}".to_string(),
|
||||
anchor_pack_json: Some("{}".to_string()),
|
||||
draft_profile_json: Some("{}".to_string()),
|
||||
pending_clarifications_json: "[]".to_string(),
|
||||
suggested_actions_json: "[]".to_string(),
|
||||
recommended_replies_json: "[]".to_string(),
|
||||
quality_findings_json: "[]".to_string(),
|
||||
asset_coverage_json: "{}".to_string(),
|
||||
error_message: Some("当前模型不可用,请稍后重试。".to_string()),
|
||||
updated_at_micros: 1,
|
||||
})
|
||||
.expect("failed finalize should allow empty assistant message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn published_profile_compile_merges_legacy_theme_and_latest_assets() {
|
||||
let snapshot = build_custom_world_published_profile_compile_snapshot(
|
||||
|
||||
@@ -17,6 +17,7 @@ rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
time = { version = "0.3", features = ["std"] }
|
||||
tracing = "0.1"
|
||||
urlencoding = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ use sha1::Sha1;
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared_kernel::{new_uuid_simple_string, normalize_optional_string, normalize_required_string};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
|
||||
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
|
||||
@@ -110,7 +111,7 @@ pub struct RefreshCookieConfig {
|
||||
refresh_session_ttl_days: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SmsAuthProviderKind {
|
||||
Mock,
|
||||
Aliyun,
|
||||
@@ -203,15 +204,16 @@ pub enum SmsProviderError {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AliyunSendSmsVerifyCodeResponse {
|
||||
#[serde(default)]
|
||||
// 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。
|
||||
#[serde(default, rename = "Code")]
|
||||
code: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, rename = "Message")]
|
||||
message: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, rename = "RequestId")]
|
||||
request_id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, rename = "Success")]
|
||||
success: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[serde(default, rename = "Model")]
|
||||
model: Option<AliyunSendSmsVerifyCodeModel>,
|
||||
}
|
||||
|
||||
@@ -227,13 +229,14 @@ struct AliyunSendSmsVerifyCodeModel {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AliyunCheckSmsVerifyCodeResponse {
|
||||
#[serde(default)]
|
||||
// 校验接口同样返回首字母大写字段名,保持和发送接口一致的显式映射。
|
||||
#[serde(default, rename = "Code")]
|
||||
code: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, rename = "Message")]
|
||||
message: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default, rename = "Success")]
|
||||
success: Option<bool>,
|
||||
#[serde(default)]
|
||||
#[serde(default, rename = "Model")]
|
||||
model: Option<AliyunCheckSmsVerifyCodeModel>,
|
||||
}
|
||||
|
||||
@@ -356,6 +359,13 @@ impl SmsAuthProviderKind {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Mock => "mock",
|
||||
Self::Aliyun => "aliyun",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SmsAuthConfig {
|
||||
@@ -477,6 +487,13 @@ impl SmsAuthProvider {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> SmsAuthProviderKind {
|
||||
match self {
|
||||
Self::Mock(_) => SmsAuthProviderKind::Mock,
|
||||
Self::Aliyun(_) => SmsAuthProviderKind::Aliyun,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_code(
|
||||
&self,
|
||||
request: SmsSendCodeRequest,
|
||||
@@ -530,11 +547,25 @@ impl AliyunSmsAuthProvider {
|
||||
request: SmsSendCodeRequest,
|
||||
) -> Result<SmsSendCodeResult, SmsProviderError> {
|
||||
let provider_out_id = build_sms_provider_out_id(&request.scene, &request.national_phone_number);
|
||||
let phone_masked = mask_phone_number(&request.national_phone_number);
|
||||
let template_param = serde_json::json!({
|
||||
self.config.template_param_key.clone(): "##code##",
|
||||
"min": self.config.valid_time_seconds,
|
||||
})
|
||||
.to_string();
|
||||
info!(
|
||||
provider = "aliyun",
|
||||
scene = request.scene.as_str(),
|
||||
phone_masked = phone_masked.as_str(),
|
||||
endpoint = self.config.endpoint.as_str(),
|
||||
sign_name = self.config.sign_name.as_str(),
|
||||
template_code = self.config.template_code.as_str(),
|
||||
code_length = self.config.code_length,
|
||||
valid_time_seconds = self.config.valid_time_seconds,
|
||||
interval_seconds = self.config.interval_seconds,
|
||||
provider_out_id = provider_out_id.as_str(),
|
||||
"准备调用阿里云短信发送接口"
|
||||
);
|
||||
|
||||
let mut query = BTreeMap::new();
|
||||
query.insert("Action".to_string(), "SendSmsVerifyCode".to_string());
|
||||
@@ -596,9 +627,49 @@ impl AliyunSmsAuthProvider {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| SmsProviderError::Upstream(format!("短信验证码发送失败:{error}")))?;
|
||||
let http_status = payload.status();
|
||||
|
||||
let body = parse_aliyun_json_response(payload, "短信验证码发送失败").await?;
|
||||
info!(
|
||||
provider = "aliyun",
|
||||
scene = request.scene.as_str(),
|
||||
phone_masked = phone_masked.as_str(),
|
||||
http_status = http_status.as_u16(),
|
||||
provider_code = body.code.as_deref().unwrap_or("unknown"),
|
||||
provider_message = body.message.as_deref().unwrap_or("unknown"),
|
||||
provider_request_id = body
|
||||
.request_id
|
||||
.as_deref()
|
||||
.or_else(|| body.model.as_ref().and_then(|model| model.request_id.as_deref()))
|
||||
.unwrap_or("unknown"),
|
||||
provider_out_id = body
|
||||
.model
|
||||
.as_ref()
|
||||
.and_then(|model| model.out_id.as_deref())
|
||||
.unwrap_or("unknown"),
|
||||
success = body.success.unwrap_or(false),
|
||||
"阿里云短信发送接口返回响应"
|
||||
);
|
||||
if !body.success.unwrap_or(false) || body.code.as_deref() != Some("OK") {
|
||||
warn!(
|
||||
provider = "aliyun",
|
||||
scene = request.scene.as_str(),
|
||||
phone_masked = phone_masked.as_str(),
|
||||
http_status = http_status.as_u16(),
|
||||
provider_code = body.code.as_deref().unwrap_or("unknown"),
|
||||
provider_message = body.message.as_deref().unwrap_or("unknown"),
|
||||
provider_request_id = body
|
||||
.request_id
|
||||
.as_deref()
|
||||
.or_else(|| body.model.as_ref().and_then(|model| model.request_id.as_deref()))
|
||||
.unwrap_or("unknown"),
|
||||
provider_out_id = body
|
||||
.model
|
||||
.as_ref()
|
||||
.and_then(|model| model.out_id.as_deref())
|
||||
.unwrap_or("unknown"),
|
||||
"阿里云短信发送接口返回业务失败"
|
||||
);
|
||||
return Err(map_aliyun_provider_error(
|
||||
"短信验证码发送失败",
|
||||
body.message,
|
||||
@@ -1173,6 +1244,23 @@ fn build_provider_error_message(prefix: &str, provider_message: &str) -> String
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_phone_number(phone_number: &str) -> String {
|
||||
let chars: Vec<char> = phone_number.chars().collect();
|
||||
if chars.len() <= 4 {
|
||||
return "*".repeat(chars.len().max(1));
|
||||
}
|
||||
let prefix_len = chars.len().min(3);
|
||||
let suffix_len = 4.min(chars.len().saturating_sub(prefix_len));
|
||||
let mask_len = chars.len().saturating_sub(prefix_len + suffix_len);
|
||||
let mut masked = String::new();
|
||||
masked.extend(chars.iter().take(prefix_len));
|
||||
masked.push_str(&"*".repeat(mask_len.max(1)));
|
||||
if suffix_len > 0 {
|
||||
masked.extend(chars.iter().skip(chars.len() - suffix_len));
|
||||
}
|
||||
masked
|
||||
}
|
||||
|
||||
impl fmt::Display for SmsProviderError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -1469,4 +1557,65 @@ mod tests {
|
||||
"Action=SendSmsVerifyCode&PhoneNumber=13800138000&TemplateParam=%7B%22code%22%3A%22%23%23code%23%23%22%7D"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliyun_send_response_deserializes_pascal_case_fields() {
|
||||
let payload = serde_json::from_str::<AliyunSendSmsVerifyCodeResponse>(
|
||||
r#"{
|
||||
"Code": "OK",
|
||||
"Message": "成功",
|
||||
"RequestId": "req_123",
|
||||
"Success": true,
|
||||
"Model": {
|
||||
"BizId": "biz_456",
|
||||
"OutId": "out_789",
|
||||
"RequestId": "req_model_001"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("aliyun send response should deserialize");
|
||||
|
||||
assert_eq!(payload.code.as_deref(), Some("OK"));
|
||||
assert_eq!(payload.message.as_deref(), Some("成功"));
|
||||
assert_eq!(payload.request_id.as_deref(), Some("req_123"));
|
||||
assert_eq!(payload.success, Some(true));
|
||||
assert_eq!(
|
||||
payload.model.as_ref().and_then(|model| model.out_id.as_deref()),
|
||||
Some("out_789")
|
||||
);
|
||||
assert_eq!(
|
||||
payload
|
||||
.model
|
||||
.as_ref()
|
||||
.and_then(|model| model.request_id.as_deref()),
|
||||
Some("req_model_001")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliyun_verify_response_deserializes_pascal_case_fields() {
|
||||
let payload = serde_json::from_str::<AliyunCheckSmsVerifyCodeResponse>(
|
||||
r#"{
|
||||
"Code": "OK",
|
||||
"Message": "成功",
|
||||
"Success": true,
|
||||
"Model": {
|
||||
"OutId": "out_789",
|
||||
"VerifyResult": "PASS"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("aliyun verify response should deserialize");
|
||||
|
||||
assert_eq!(payload.code.as_deref(), Some("OK"));
|
||||
assert_eq!(payload.message.as_deref(), Some("成功"));
|
||||
assert_eq!(payload.success, Some(true));
|
||||
assert_eq!(
|
||||
payload
|
||||
.model
|
||||
.as_ref()
|
||||
.and_then(|model| model.verify_result.as_deref()),
|
||||
Some("PASS")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{error::Error, fmt, time::Duration};
|
||||
use std::{error::Error, fmt, str as std_str, time::Duration};
|
||||
|
||||
use log::{debug, warn};
|
||||
use reqwest::{Client, StatusCode};
|
||||
@@ -419,6 +419,7 @@ impl LlmClient {
|
||||
let mut parser = OpenAiCompatibleSseParser::default();
|
||||
let mut accumulated_text = String::new();
|
||||
let mut finish_reason = None;
|
||||
let mut undecoded_chunk_bytes = Vec::new();
|
||||
|
||||
loop {
|
||||
let next_chunk = response
|
||||
@@ -430,7 +431,13 @@ impl LlmClient {
|
||||
break;
|
||||
};
|
||||
|
||||
let chunk_text = String::from_utf8_lossy(chunk.as_ref());
|
||||
undecoded_chunk_bytes.extend_from_slice(chunk.as_ref());
|
||||
let (chunk_text, remaining_bytes) =
|
||||
decode_utf8_stream_chunk(undecoded_chunk_bytes.as_slice())?;
|
||||
undecoded_chunk_bytes = remaining_bytes;
|
||||
if chunk_text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for event in parser.push_chunk(chunk_text.as_ref())? {
|
||||
if let Some(delta_text) = event.delta_text
|
||||
&& !delta_text.is_empty()
|
||||
@@ -450,6 +457,34 @@ impl LlmClient {
|
||||
}
|
||||
}
|
||||
|
||||
if !undecoded_chunk_bytes.is_empty() {
|
||||
let trailing_text = std_str::from_utf8(undecoded_chunk_bytes.as_slice())
|
||||
.map_err(|error| {
|
||||
LlmError::Deserialize(format!(
|
||||
"解析 LLM 流式 UTF-8 响应失败:{error}"
|
||||
))
|
||||
})?;
|
||||
if !trailing_text.is_empty() {
|
||||
for event in parser.push_chunk(trailing_text)? {
|
||||
if let Some(delta_text) = event.delta_text
|
||||
&& !delta_text.is_empty()
|
||||
{
|
||||
accumulated_text.push_str(delta_text.as_str());
|
||||
let update = LlmStreamDelta {
|
||||
accumulated_text: accumulated_text.clone(),
|
||||
delta_text,
|
||||
finish_reason: event.finish_reason.clone(),
|
||||
};
|
||||
on_delta(&update);
|
||||
}
|
||||
|
||||
if event.finish_reason.is_some() {
|
||||
finish_reason = event.finish_reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for event in parser.finish()? {
|
||||
if let Some(delta_text) = event.delta_text
|
||||
&& !delta_text.is_empty()
|
||||
@@ -719,6 +754,27 @@ fn extract_content_text(content: &ChatCompletionsContent) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_utf8_stream_chunk(bytes: &[u8]) -> Result<(String, Vec<u8>), LlmError> {
|
||||
match std_str::from_utf8(bytes) {
|
||||
Ok(text) => Ok((text.to_string(), Vec::new())),
|
||||
Err(error) => {
|
||||
let valid_up_to = error.valid_up_to();
|
||||
let Some(_) = error.error_len() else {
|
||||
let decoded = std_str::from_utf8(&bytes[..valid_up_to]).map_err(|inner_error| {
|
||||
LlmError::Deserialize(format!(
|
||||
"解析 LLM 流式 UTF-8 响应失败:{inner_error}"
|
||||
))
|
||||
})?;
|
||||
return Ok((decoded.to_string(), bytes[valid_up_to..].to_vec()));
|
||||
};
|
||||
|
||||
Err(LlmError::Deserialize(format!(
|
||||
"解析 LLM 流式 UTF-8 响应失败:{error}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_sse_event_block(block: &str) -> Result<Option<ParsedStreamEvent>, LlmError> {
|
||||
let data_lines = block
|
||||
.lines()
|
||||
@@ -873,6 +929,22 @@ mod tests {
|
||||
assert_eq!(events_b[0].finish_reason.as_deref(), Some("stop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_utf8_stream_chunk_preserves_incomplete_multibyte_suffix() {
|
||||
let full_bytes = "你好".as_bytes();
|
||||
let first_result = decode_utf8_stream_chunk(&full_bytes[..2])
|
||||
.expect("incomplete utf-8 chunk should be buffered");
|
||||
assert_eq!(first_result.0, "");
|
||||
assert_eq!(first_result.1, full_bytes[..2].to_vec());
|
||||
|
||||
let mut combined = first_result.1;
|
||||
combined.extend_from_slice(&full_bytes[2..]);
|
||||
let second_result = decode_utf8_stream_chunk(combined.as_slice())
|
||||
.expect("completed utf-8 bytes should decode");
|
||||
assert_eq!(second_result.0, "你好");
|
||||
assert!(second_result.1.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_text_parses_non_stream_response() {
|
||||
let server_url = spawn_mock_server(vec![MockResponse {
|
||||
|
||||
@@ -16,6 +16,7 @@ pub fn init_tracing(default_filter: &str) -> Result<(), io::Error> {
|
||||
fmt()
|
||||
.with_env_filter(env_filter)
|
||||
.with_target(true)
|
||||
.with_ansi(false)
|
||||
.compact()
|
||||
.try_init()
|
||||
.map_err(|error| io::Error::other(format!("初始化 tracing subscriber 失败:{error}")))
|
||||
|
||||
@@ -5788,8 +5788,8 @@ pub struct CustomWorldAgentMessageFinalizeRecordInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub operation_id: String,
|
||||
pub assistant_message_id: String,
|
||||
pub assistant_reply_text: String,
|
||||
pub assistant_message_id: Option<String>,
|
||||
pub assistant_reply_text: Option<String>,
|
||||
pub phase_label: String,
|
||||
pub phase_detail: String,
|
||||
pub operation_status: String,
|
||||
|
||||
@@ -18,8 +18,8 @@ pub struct CustomWorldAgentMessageFinalizeInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub operation_id: String,
|
||||
pub assistant_message_id: String,
|
||||
pub assistant_reply_text: String,
|
||||
pub assistant_message_id: Option::<String>,
|
||||
pub assistant_reply_text: Option::<String>,
|
||||
pub phase_label: String,
|
||||
pub phase_detail: String,
|
||||
pub operation_status: RpgAgentOperationStatus,
|
||||
|
||||
@@ -2622,52 +2622,71 @@ fn finalize_custom_world_agent_message_turn_tx(
|
||||
.filter(|row| row.session_id == input.session_id)
|
||||
.ok_or_else(|| "custom_world_agent_operation 不存在".to_string())?;
|
||||
|
||||
if ctx
|
||||
.db
|
||||
.custom_world_agent_message()
|
||||
.message_id()
|
||||
.find(&input.assistant_message_id)
|
||||
.is_some()
|
||||
{
|
||||
return Err("custom_world_agent_message.assistant_message_id 已存在".to_string());
|
||||
}
|
||||
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
||||
|
||||
ctx.db
|
||||
.custom_world_agent_message()
|
||||
.insert(CustomWorldAgentMessage {
|
||||
message_id: input.assistant_message_id.clone(),
|
||||
session_id: input.session_id.clone(),
|
||||
role: RpgAgentMessageRole::Assistant,
|
||||
kind: RpgAgentMessageKind::Chat,
|
||||
text: input.assistant_reply_text.clone(),
|
||||
related_operation_id: Some(input.operation_id.clone()),
|
||||
created_at: updated_at,
|
||||
});
|
||||
let next_session = if input.operation_status == RpgAgentOperationStatus::Failed {
|
||||
rebuild_custom_world_agent_session_row(
|
||||
&session,
|
||||
CustomWorldAgentSessionPatch {
|
||||
updated_at_micros: Some(input.updated_at_micros),
|
||||
..CustomWorldAgentSessionPatch::default()
|
||||
},
|
||||
)?
|
||||
} else {
|
||||
let assistant_message_id = input
|
||||
.assistant_message_id
|
||||
.clone()
|
||||
.ok_or_else(|| "custom_world_agent_message.assistant_message_id 不能为空".to_string())?;
|
||||
let assistant_reply_text = input
|
||||
.assistant_reply_text
|
||||
.clone()
|
||||
.ok_or_else(|| "custom_world_agent_message.text 不能为空".to_string())?;
|
||||
|
||||
let next_session = rebuild_custom_world_agent_session_row(
|
||||
&session,
|
||||
CustomWorldAgentSessionPatch {
|
||||
current_turn: Some(session.current_turn.saturating_add(1)),
|
||||
progress_percent: Some(input.progress_percent),
|
||||
stage: Some(input.stage),
|
||||
focus_card_id: Some(input.focus_card_id.clone()),
|
||||
anchor_content_json: Some(input.anchor_content_json.clone()),
|
||||
creator_intent_json: Some(input.creator_intent_json.clone()),
|
||||
creator_intent_readiness_json: Some(input.creator_intent_readiness_json.clone()),
|
||||
anchor_pack_json: Some(input.anchor_pack_json.clone()),
|
||||
draft_profile_json: Some(input.draft_profile_json.clone()),
|
||||
last_assistant_reply: Some(Some(input.assistant_reply_text.clone())),
|
||||
pending_clarifications_json: Some(input.pending_clarifications_json.clone()),
|
||||
quality_findings_json: Some(input.quality_findings_json.clone()),
|
||||
suggested_actions_json: Some(input.suggested_actions_json.clone()),
|
||||
recommended_replies_json: Some(input.recommended_replies_json.clone()),
|
||||
asset_coverage_json: Some(input.asset_coverage_json.clone()),
|
||||
updated_at_micros: Some(input.updated_at_micros),
|
||||
..CustomWorldAgentSessionPatch::default()
|
||||
},
|
||||
)?;
|
||||
if ctx
|
||||
.db
|
||||
.custom_world_agent_message()
|
||||
.message_id()
|
||||
.find(&assistant_message_id)
|
||||
.is_some()
|
||||
{
|
||||
return Err("custom_world_agent_message.assistant_message_id 已存在".to_string());
|
||||
}
|
||||
|
||||
ctx.db
|
||||
.custom_world_agent_message()
|
||||
.insert(CustomWorldAgentMessage {
|
||||
message_id: assistant_message_id,
|
||||
session_id: input.session_id.clone(),
|
||||
role: RpgAgentMessageRole::Assistant,
|
||||
kind: RpgAgentMessageKind::Chat,
|
||||
text: assistant_reply_text.clone(),
|
||||
related_operation_id: Some(input.operation_id.clone()),
|
||||
created_at: updated_at,
|
||||
});
|
||||
|
||||
rebuild_custom_world_agent_session_row(
|
||||
&session,
|
||||
CustomWorldAgentSessionPatch {
|
||||
current_turn: Some(session.current_turn.saturating_add(1)),
|
||||
progress_percent: Some(input.progress_percent),
|
||||
stage: Some(input.stage),
|
||||
focus_card_id: Some(input.focus_card_id.clone()),
|
||||
anchor_content_json: Some(input.anchor_content_json.clone()),
|
||||
creator_intent_json: Some(input.creator_intent_json.clone()),
|
||||
creator_intent_readiness_json: Some(input.creator_intent_readiness_json.clone()),
|
||||
anchor_pack_json: Some(input.anchor_pack_json.clone()),
|
||||
draft_profile_json: Some(input.draft_profile_json.clone()),
|
||||
last_assistant_reply: Some(Some(assistant_reply_text)),
|
||||
pending_clarifications_json: Some(input.pending_clarifications_json.clone()),
|
||||
quality_findings_json: Some(input.quality_findings_json.clone()),
|
||||
suggested_actions_json: Some(input.suggested_actions_json.clone()),
|
||||
recommended_replies_json: Some(input.recommended_replies_json.clone()),
|
||||
asset_coverage_json: Some(input.asset_coverage_json.clone()),
|
||||
updated_at_micros: Some(input.updated_at_micros),
|
||||
..CustomWorldAgentSessionPatch::default()
|
||||
},
|
||||
)?
|
||||
};
|
||||
replace_custom_world_agent_session(ctx, &session, next_session);
|
||||
|
||||
let next_operation = rebuild_custom_world_agent_operation_row(
|
||||
|
||||
Reference in New Issue
Block a user