fix: sync rust api-server runtime and bindings

This commit is contained in:
2026-04-23 20:32:06 +08:00
parent 9d25a47b23
commit 27e84c46a0
82 changed files with 9534 additions and 2222 deletions

View File

@@ -21,6 +21,7 @@ module-combat = { path = "../module-combat" }
module-custom-world = { path = "../module-custom-world" }
module-inventory = { path = "../module-inventory" }
module-npc = { path = "../module-npc" }
module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" }
module-runtime-story-compat = { path = "../module-runtime-story-compat" }
module-runtime-item = { path = "../module-runtime-item" }

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,10 @@ use tower_http::{
use tracing::{Level, Span, error, info, info_span, warn};
use crate::{
admin::{
admin_console_page, admin_debug_http, admin_login, admin_me, admin_overview,
require_admin_auth,
},
ai_tasks::{
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
@@ -26,10 +30,11 @@ use crate::{
require_bearer_auth,
},
auth_me::auth_me,
auth_public_user::get_public_user_by_code,
auth_sessions::auth_sessions,
big_fish::{
create_big_fish_session, execute_big_fish_action, get_big_fish_run, get_big_fish_session,
start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
get_big_fish_works, start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
submit_big_fish_message,
},
character_animation_assets::{
@@ -44,8 +49,9 @@ use crate::{
create_custom_world_agent_session, delete_custom_world_library_profile,
execute_custom_world_agent_action, get_custom_world_agent_card_detail,
get_custom_world_agent_operation, get_custom_world_agent_session,
get_custom_world_gallery_detail, get_custom_world_library, get_custom_world_library_detail,
get_custom_world_works, list_custom_world_gallery, publish_custom_world_library_profile,
get_custom_world_gallery_detail, get_custom_world_gallery_detail_by_code,
get_custom_world_library, get_custom_world_library_detail, get_custom_world_works,
list_custom_world_gallery, publish_custom_world_library_profile,
put_custom_world_library_profile, stream_custom_world_agent_message,
submit_custom_world_agent_message, unpublish_custom_world_library_profile,
},
@@ -105,6 +111,29 @@ pub fn build_router(state: AppState) -> Router {
let slow_request_threshold_ms = state.config.slow_request_threshold_ms;
Router::new()
.route("/admin", get(admin_console_page))
.route("/admin/api/login", post(admin_login))
.route(
"/admin/api/me",
get(admin_me).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/overview",
get(admin_overview).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/debug/http",
post(admin_debug_http).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/healthz",
get(|Extension(request_context): Extension<_>| async move {
@@ -126,6 +155,10 @@ pub fn build_router(state: AppState) -> Router {
)),
)
.route("/api/auth/login-options", get(auth_login_options))
.route(
"/api/auth/public-users/by-code/{code}",
get(get_public_user_by_code),
)
.route(
"/generated-character-drafts/{*path}",
get(proxy_generated_character_drafts),
@@ -391,6 +424,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}",
get(get_custom_world_gallery_detail),
)
.route(
"/api/runtime/custom-world-gallery/by-code/{code}",
get(get_custom_world_gallery_detail_by_code),
)
.route(
"/api/runtime/custom-world/agent/sessions",
post(create_custom_world_agent_session).route_layer(middleware::from_fn_with_state(
@@ -482,6 +519,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/works",
get(get_big_fish_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/sessions/{session_id}/runs",
post(start_big_fish_run).route_layer(middleware::from_fn_with_state(
@@ -2905,4 +2949,168 @@ mod tests {
.is_some_and(|value| value.contains("Max-Age=0"))
);
}
#[tokio::test]
async fn admin_login_returns_token_when_configured() {
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "root",
"password": "secret123"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert!(payload["token"].as_str().is_some());
assert_eq!(payload["admin"]["username"], Value::String("root".to_string()));
}
#[tokio::test]
async fn admin_route_rejects_regular_user_token() {
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let state = AppState::new(config).expect("state should build");
let app = build_router(state.clone());
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_admin_forbidden",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login should succeed");
let login_body = login_response
.into_body()
.collect()
.await
.expect("login body should collect")
.to_bytes();
let login_payload: Value =
serde_json::from_slice(&login_body).expect("login payload should be json");
let access_token = login_payload["token"]
.as_str()
.expect("token should exist")
.to_string();
let response = app
.oneshot(
Request::builder()
.uri("/admin/api/me")
.header("authorization", format!("Bearer {access_token}"))
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_debug_http_can_probe_healthz() {
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let app = build_router(AppState::new(config).expect("state should build"));
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "root",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login should succeed");
let login_body = login_response
.into_body()
.collect()
.await
.expect("login body should collect")
.to_bytes();
let login_payload: Value =
serde_json::from_slice(&login_body).expect("login payload should be json");
let access_token = login_payload["token"]
.as_str()
.expect("token should exist")
.to_string();
let debug_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/debug/http")
.header("authorization", format!("Bearer {access_token}"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"method": "GET",
"path": "/healthz",
"headers": [],
"body": ""
})
.to_string(),
))
.expect("debug request should build"),
)
.await
.expect("debug request should succeed");
assert_eq!(debug_response.status(), StatusCode::OK);
let body = debug_response
.into_body()
.collect()
.await
.expect("debug body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("debug payload should be json");
assert_eq!(payload["status"], Value::Number(200.into()));
}
}

View File

@@ -3,11 +3,12 @@ use axum::{
extract::{Extension, State},
http::StatusCode,
};
use shared_contracts::auth::{AuthMeResponse, AuthUserPayload, build_available_login_methods};
use shared_contracts::auth::{AuthMeResponse, build_available_login_methods};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
api_response::json_success_body, auth::AuthenticatedAccessToken,
auth_payload::map_auth_user_payload, http_error::AppError, request_context::RequestContext,
state::AppState,
};
pub async fn auth_me(
@@ -30,15 +31,7 @@ pub async fn auth_me(
Ok(json_success_body(
Some(&request_context),
AuthMeResponse {
user: AuthUserPayload {
id: user.user.id,
username: user.user.username,
display_name: user.user.display_name,
phone_number_masked: user.user.phone_number_masked,
login_method: user.user.login_method.as_str().to_string(),
binding_status: user.user.binding_status.as_str().to_string(),
wechat_bound: user.user.wechat_bound,
},
user: map_auth_user_payload(user.user),
available_login_methods: build_available_login_methods(
state.config.sms_auth_enabled,
state.config.wechat_auth_enabled,

View File

@@ -0,0 +1,23 @@
use module_auth::AuthUser;
use shared_contracts::auth::{AuthUserPayload, PublicUserSummaryPayload};
pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload {
AuthUserPayload {
id: user.id,
public_user_code: user.public_user_code,
username: user.username,
display_name: user.display_name,
phone_number_masked: user.phone_number_masked,
login_method: user.login_method.as_str().to_string(),
binding_status: user.binding_status.as_str().to_string(),
wechat_bound: user.wechat_bound,
}
}
pub fn map_public_user_summary_payload(user: AuthUser) -> PublicUserSummaryPayload {
PublicUserSummaryPayload {
id: user.id,
public_user_code: user.public_user_code,
display_name: user.display_name,
}
}

View File

@@ -0,0 +1,50 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
};
use shared_contracts::auth::PublicUserSearchResponse;
use crate::{
api_response::json_success_body,
auth_payload::map_public_user_summary_payload,
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
pub async fn get_public_user_by_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Path(code): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let user = state
.password_entry_service()
.get_user_by_public_user_code(&code)
.map_err(map_public_user_search_error)?
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应叙世号用户")
})?;
Ok(json_success_body(
Some(&request_context),
PublicUserSearchResponse {
user: map_public_user_summary_payload(user.user),
},
))
}
fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppError {
match error {
module_auth::PasswordEntryError::InvalidPublicUserCode => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("叙世号格式不正确")
}
module_auth::PasswordEntryError::Store(_)
| module_auth::PasswordEntryError::PasswordHash(_)
| module_auth::PasswordEntryError::InvalidUsername
| module_auth::PasswordEntryError::InvalidPasswordLength
| module_auth::PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}
}

View File

@@ -26,7 +26,8 @@ use shared_contracts::big_fish::{
BigFishVector2Response, CreateBigFishSessionRequest, ExecuteBigFishActionRequest,
SendBigFishMessageRequest, SubmitBigFishInputRequest,
};
use shared_kernel::build_prefixed_uuid_id;
use shared_contracts::big_fish_works::{BigFishWorkSummaryResponse, BigFishWorksResponse};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::{
BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord,
BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord,
@@ -34,7 +35,7 @@ use spacetime_client::{
BigFishMessageSubmitRecordInput, BigFishRunInputSubmitRecordInput, BigFishRunStartRecordInput,
BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRecord,
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record,
SpacetimeClientError,
BigFishWorkSummaryRecord, SpacetimeClientError,
};
use tokio::time::sleep;
@@ -107,6 +108,30 @@ pub async fn get_big_fish_session(
))
}
pub async fn get_big_fish_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_big_fish_works(authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishWorksResponse {
items: items
.into_iter()
.map(map_big_fish_work_summary_response)
.collect(),
},
))
}
pub async fn submit_big_fish_message(
State(state): State<AppState>,
Path(session_id): Path<String>,
@@ -610,6 +635,26 @@ fn map_big_fish_runtime_response(run: BigFishRuntimeRecord) -> BigFishRuntimeSna
}
}
fn map_big_fish_work_summary_response(
item: BigFishWorkSummaryRecord,
) -> BigFishWorkSummaryResponse {
BigFishWorkSummaryResponse {
work_id: item.work_id,
source_session_id: item.source_session_id,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
cover_image_src: item.cover_image_src,
status: item.status,
updated_at: current_timestamp_micros_to_string(item.updated_at_micros),
publish_ready: item.publish_ready,
level_count: item.level_count,
level_main_image_ready_count: item.level_main_image_ready_count,
level_motion_ready_count: item.level_motion_ready_count,
background_ready: item.background_ready,
}
}
fn map_big_fish_entity_response(
entity: BigFishRuntimeEntityRecord,
) -> BigFishRuntimeEntityResponse {
@@ -1475,3 +1520,7 @@ fn current_utc_micros() -> i64 {
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
fn current_timestamp_micros_to_string(value: i64) -> String {
format_timestamp_micros(value)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,9 @@ pub struct AppConfig {
pub bind_host: String,
pub bind_port: u16,
pub log_filter: String,
pub admin_username: Option<String>,
pub admin_password: Option<String>,
pub admin_token_ttl_seconds: u64,
pub internal_api_secret: Option<String>,
pub jwt_issuer: String,
pub jwt_secret: String,
@@ -78,6 +81,13 @@ pub struct AppConfig {
pub dashscope_base_url: String,
pub dashscope_api_key: Option<String>,
pub dashscope_image_request_timeout_ms: u64,
pub ark_character_video_base_url: String,
pub ark_character_video_api_key: Option<String>,
pub ark_character_video_request_timeout_ms: u64,
pub ark_character_video_model: String,
pub character_animation_ffmpeg_path: String,
pub character_animation_ffprobe_path: String,
pub character_animation_frame_extract_timeout_ms: u64,
pub slow_request_threshold_ms: u64,
}
@@ -87,6 +97,9 @@ impl Default for AppConfig {
bind_host: "127.0.0.1".to_string(),
bind_port: 3000,
log_filter: "info,tower_http=info".to_string(),
admin_username: None,
admin_password: None,
admin_token_ttl_seconds: 4 * 60 * 60,
internal_api_secret: Some(DEFAULT_INTERNAL_API_SECRET.to_string()),
jwt_issuer: "https://auth.genarrative.local".to_string(),
jwt_secret: "genarrative-dev-secret".to_string(),
@@ -151,6 +164,13 @@ impl Default for AppConfig {
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
dashscope_api_key: None,
dashscope_image_request_timeout_ms: 150_000,
ark_character_video_base_url: DEFAULT_ARK_BASE_URL.to_string(),
ark_character_video_api_key: None,
ark_character_video_request_timeout_ms: 420_000,
ark_character_video_model: "doubao-seedance-2-0-fast-260128".to_string(),
character_animation_ffmpeg_path: "ffmpeg".to_string(),
character_animation_ffprobe_path: "ffprobe".to_string(),
character_animation_frame_extract_timeout_ms: 120_000,
slow_request_threshold_ms: 1_000,
}
}
@@ -182,6 +202,14 @@ impl AppConfig {
config.log_filter = log_filter;
}
config.admin_username = read_first_non_empty_env(&["GENARRATIVE_ADMIN_USERNAME"]);
config.admin_password = read_first_non_empty_env(&["GENARRATIVE_ADMIN_PASSWORD"]);
if let Some(admin_token_ttl_seconds) =
read_first_duration_seconds_env(&["GENARRATIVE_ADMIN_TOKEN_TTL_SECONDS"])
{
config.admin_token_ttl_seconds = admin_token_ttl_seconds;
}
config.internal_api_secret = read_first_non_empty_env(&["GENARRATIVE_INTERNAL_API_SECRET"]);
if let Some(jwt_issuer) =
@@ -430,6 +458,56 @@ impl AppConfig {
config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms;
}
if let Some(ark_character_video_base_url) = read_first_non_empty_env(&[
"ARK_CHARACTER_VIDEO_BASE_URL",
"ARK_BASE_URL",
"GENARRATIVE_LLM_BASE_URL",
"LLM_BASE_URL",
]) {
config.ark_character_video_base_url = ark_character_video_base_url;
}
config.ark_character_video_api_key = read_first_non_empty_env(&[
"ARK_CHARACTER_VIDEO_API_KEY",
"ARK_API_KEY",
"GENARRATIVE_LLM_API_KEY",
"LLM_API_KEY",
]);
if let Some(ark_character_video_request_timeout_ms) = read_first_positive_u64_env(&[
"ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS",
"DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS",
]) {
config.ark_character_video_request_timeout_ms =
ark_character_video_request_timeout_ms;
}
if let Some(ark_character_video_model) = read_first_non_empty_env(&[
"ARK_CHARACTER_VIDEO_MODEL",
"DASHSCOPE_CHARACTER_VIDEO_MODEL",
]) {
config.ark_character_video_model = ark_character_video_model;
}
if let Some(character_animation_ffmpeg_path) =
read_first_non_empty_env(&["CHARACTER_ANIMATION_FFMPEG_PATH"])
{
config.character_animation_ffmpeg_path = character_animation_ffmpeg_path;
}
if let Some(character_animation_ffprobe_path) =
read_first_non_empty_env(&["CHARACTER_ANIMATION_FFPROBE_PATH"])
{
config.character_animation_ffprobe_path = character_animation_ffprobe_path;
}
if let Some(character_animation_frame_extract_timeout_ms) = read_first_positive_u64_env(&[
"CHARACTER_ANIMATION_FRAME_EXTRACT_TIMEOUT_MS",
]) {
config.character_animation_frame_extract_timeout_ms =
character_animation_frame_extract_timeout_ms;
}
if let Some(slow_request_threshold_ms) =
read_first_positive_u64_env(&["GENARRATIVE_SLOW_REQUEST_THRESHOLD_MS"])
{

View File

@@ -40,12 +40,19 @@ use spacetime_client::{
use std::convert::Infallible;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
api_response::json_success_body,
auth::AuthenticatedAccessToken,
custom_world_agent_turn::{
CustomWorldAgentTurnRequest, build_failed_finalize_record_input,
build_finalize_record_input, run_custom_world_agent_turn,
},
request_context::RequestContext, state::AppState,
custom_world_foundation_draft::{
DraftFoundationPayloadError, build_draft_foundation_action_payload_json,
generate_custom_world_foundation_draft,
},
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
pub async fn get_custom_world_library(
@@ -142,12 +149,16 @@ pub async fn put_custom_world_library_profile(
})),
)
})?;
let author_display_name = resolve_author_display_name(&authenticated);
let author_display_name = resolve_author_display_name(&state, &authenticated);
let author_public_user_code =
resolve_author_public_user_code(&state, &authenticated, &request_context)?;
let mutation = state
.spacetime_client()
.upsert_custom_world_profile(CustomWorldProfileUpsertRecordInput {
profile_id: profile_id.clone(),
owner_user_id: owner_user_id.clone(),
public_work_code: None,
author_public_user_code: Some(author_public_user_code),
source_agent_session_id: payload.source_agent_session_id.clone(),
world_name: metadata.world_name,
subtitle: metadata.subtitle,
@@ -240,7 +251,9 @@ pub async fn publish_custom_world_library_profile(
.publish_custom_world_profile(
profile_id,
owner_user_id,
resolve_author_display_name(&authenticated),
None,
resolve_author_public_user_code(&state, &authenticated, &request_context)?,
resolve_author_display_name(&state, &authenticated),
current_utc_micros(),
)
.await
@@ -279,7 +292,7 @@ pub async fn unpublish_custom_world_library_profile(
.unpublish_custom_world_profile(
profile_id,
owner_user_id,
resolve_author_display_name(&authenticated),
resolve_author_display_name(&state, &authenticated),
current_utc_micros(),
)
.await
@@ -350,6 +363,37 @@ pub async fn get_custom_world_gallery_detail(
))
}
pub async fn get_custom_world_gallery_detail_by_code(
State(state): State<AppState>,
Path(code): Path<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
if code.trim().is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-gallery",
"message": "code is required",
})),
));
}
let detail = state
.spacetime_client()
.get_custom_world_gallery_detail_by_code(code)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldGalleryDetailResponse {
entry: map_custom_world_library_entry_response(detail.entry),
},
))
}
pub async fn create_custom_world_agent_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -870,25 +914,88 @@ pub async fn execute_custom_world_agent_action(
));
}
let payload_json = serde_json::to_string(&payload).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let submitted_at_micros = current_utc_micros();
let payload_json = if action == "draft_foundation" {
let session = state
.spacetime_client()
.get_custom_world_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
if session.progress_percent < 100 {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "draft_foundation requires progressPercent >= 100",
})),
));
}
let llm_client = state.llm_client().ok_or_else(|| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "custom-world-agent",
"message": "服务端尚未配置可用的 LLM API Key",
})),
)
})?;
let draft_result = generate_custom_world_foundation_draft(llm_client, &session)
.await
.map_err(|message| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "custom-world-agent",
"message": message,
})),
)
})?;
build_draft_foundation_action_payload_json(&payload, &draft_result.draft_profile_json)
.map_err(|error| {
let (status, message) = match error {
DraftFoundationPayloadError::SerializePayload(message) => {
(StatusCode::BAD_REQUEST, message)
}
DraftFoundationPayloadError::InvalidPayloadShape => (
StatusCode::BAD_REQUEST,
"action payload 必须是 object".to_string(),
),
DraftFoundationPayloadError::InvalidGeneratedDraft(message) => {
(StatusCode::BAD_GATEWAY, message)
}
};
custom_world_error_response(
&request_context,
AppError::from_status(status).with_details(json!({
"provider": "custom-world-agent",
"message": message,
})),
)
})?
} else {
serde_json::to_string(&payload).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?
};
let result = state
.spacetime_client()
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
owner_user_id,
operation_id: build_prefixed_uuid_id("operation-"),
action,
payload_json: Some(payload_json),
submitted_at_micros: current_utc_micros(),
submitted_at_micros,
})
.await
.map_err(|error| {
@@ -909,6 +1016,8 @@ fn map_custom_world_library_entry_response(
CustomWorldLibraryEntryResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: entry.author_public_user_code,
profile: entry.profile,
visibility: entry.visibility,
published_at: entry.published_at,
@@ -930,6 +1039,8 @@ fn map_custom_world_gallery_card_response(
CustomWorldGalleryCardResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: entry.author_public_user_code,
visibility: entry.visibility,
published_at: entry.published_at,
updated_at: entry.updated_at,
@@ -1213,8 +1324,48 @@ fn custom_world_sse_error_event_message(message: String) -> Event {
Event::default().event("error").data(payload)
}
fn resolve_author_display_name(_authenticated: &AuthenticatedAccessToken) -> String {
"玩家".to_string()
fn resolve_author_display_name(
state: &AppState,
authenticated: &AuthenticatedAccessToken,
) -> String {
state
.auth_user_service()
.get_user_by_id(authenticated.claims().user_id())
.ok()
.flatten()
.map(|user| user.display_name)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "玩家".to_string())
}
fn resolve_author_public_user_code(
state: &AppState,
authenticated: &AuthenticatedAccessToken,
request_context: &RequestContext,
) -> Result<String, Response> {
state
.auth_user_service()
.get_user_by_id(authenticated.claims().user_id())
.map_err(|error| {
custom_world_error_response(
request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-library",
"message": format!("作者叙世号读取失败:{error}"),
})),
)
})?
.map(|user| user.public_user_code)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
custom_world_error_response(
request_context,
AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
"provider": "custom-world-library",
"message": "当前登录用户缺少叙世号",
})),
)
})
}
fn build_custom_world_agent_welcome_text(seed_text: &str) -> String {

View File

@@ -0,0 +1,669 @@
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde_json::{Map as JsonMap, Value as JsonValue, json};
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
use spacetime_client::CustomWorldAgentSessionRecord;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldFoundationDraftResult {
pub draft_profile_json: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DraftFoundationPayloadError {
SerializePayload(String),
InvalidPayloadShape,
InvalidGeneratedDraft(String),
}
pub async fn generate_custom_world_foundation_draft(
llm_client: &LlmClient,
session: &CustomWorldAgentSessionRecord,
) -> Result<CustomWorldFoundationDraftResult, String> {
let system_prompt = build_foundation_draft_system_prompt();
let user_prompt = build_foundation_draft_user_prompt(session);
let response = llm_client
.request_text(LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
]))
.await
.map_err(|error| format!("foundation draft LLM 请求失败:{error}"))?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|error| format!("foundation draft JSON 解析失败:{error}"))?;
let draft_profile = normalize_foundation_draft_profile(parsed, session);
let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile))
.map_err(|error| format!("foundation draft JSON 序列化失败:{error}"))?;
Ok(CustomWorldFoundationDraftResult { draft_profile_json })
}
// foundation draft 已经由 api-server 真实生成,落库前只负责把它注入现有 action payload。
pub fn build_draft_foundation_action_payload_json(
payload: &ExecuteCustomWorldAgentActionRequest,
draft_profile_json: &str,
) -> Result<String, DraftFoundationPayloadError> {
let mut payload_value = serde_json::to_value(payload).map_err(|error| {
DraftFoundationPayloadError::SerializePayload(format!(
"action payload JSON 序列化失败:{error}"
))
})?;
let payload_object = payload_value
.as_object_mut()
.ok_or(DraftFoundationPayloadError::InvalidPayloadShape)?;
let draft_profile_value =
serde_json::from_str::<JsonValue>(draft_profile_json).map_err(|error| {
DraftFoundationPayloadError::InvalidGeneratedDraft(format!(
"foundation draft JSON 非法:{error}"
))
})?;
if !draft_profile_value.is_object() {
return Err(DraftFoundationPayloadError::InvalidGeneratedDraft(
"foundation draft JSON 必须是 object".to_string(),
));
}
payload_object.insert("draftProfile".to_string(), draft_profile_value);
serde_json::to_string(&payload_value).map_err(|error| {
DraftFoundationPayloadError::SerializePayload(format!(
"action payload JSON 序列化失败:{error}"
))
})
}
fn build_foundation_draft_system_prompt() -> String {
[
"你是 RPG 世界共创后端里的底稿编译器。",
"你的任务是根据当前会话已经确认的世界锚点,生成第一版“世界设定草稿” JSON。",
"必须只输出一个 JSON object不要输出 markdown、解释、前后缀。",
"输出必须使用中文内容。",
"不要返回占位符不要写“待补充”“略”“TBD”“placeholder”。",
"如果某些信息不完整,也要基于已知锚点给出一版合理、可继续精修的首稿。",
"字段必须至少包含name、subtitle、summary、worldHook、playerPremise、coreConflicts、playableNpcs、storyNpcs、landmarks、chapters、sceneChapterBlueprints。",
"sceneChapterBlueprints 至少包含 1 个 chapter且 chapter.acts 至少包含 1 个 act。",
"playableNpcs、storyNpcs、landmarks 可以是小规模首批关键对象,不要求长尾铺满。",
]
.join("\n")
}
fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String {
let anchor_content = to_pretty_json(&session.anchor_content);
let creator_intent = to_pretty_json(&session.creator_intent);
let anchor_pack = to_pretty_json(&session.anchor_pack);
let current_draft = if is_non_null_json(&session.draft_profile) {
to_pretty_json(&session.draft_profile)
} else {
"{}".to_string()
};
let quality_findings = to_pretty_json(&JsonValue::Array(session.quality_findings.clone()));
[
format!("seedText{}", session.seed_text.trim()),
format!("当前 stage{}", session.stage.trim()),
format!("当前 progressPercent{}", session.progress_percent),
format!(
"当前最后一条 assistant 回复:{}",
session.last_assistant_reply.clone().unwrap_or_default()
),
format!("当前 anchorContent\n{anchor_content}"),
format!("当前 creatorIntent\n{creator_intent}"),
format!("当前 anchorPack\n{anchor_pack}"),
format!("当前已有 draftProfile\n{current_draft}"),
format!("当前 qualityFindings\n{quality_findings}"),
"请直接返回第一版 foundation draft JSON。".to_string(),
"约束:".to_string(),
"1. worldHook 必须是一句可以直接用于发布门禁校验的世界钩子。".to_string(),
"2. playerPremise 必须明确玩家身份与切入前提。".to_string(),
"3. coreConflicts 必须至少 1 条。".to_string(),
"4. chapters 或 sceneChapterBlueprints 必须体现主线第一幕。".to_string(),
"5. sceneChapterBlueprints[0].acts 至少 1 条。".to_string(),
"6. summary 要像结果页摘要,不要只是原始 seed 重复。".to_string(),
]
.join("\n\n")
}
fn normalize_foundation_draft_profile(
value: JsonValue,
session: &CustomWorldAgentSessionRecord,
) -> JsonMap<String, JsonValue> {
let mut object = value.as_object().cloned().unwrap_or_default();
let fallback_title = derive_world_name(&object, session);
let fallback_world_hook = derive_world_hook(&object, session);
let fallback_player_premise = derive_player_premise(&object, session);
ensure_text_field(&mut object, "name", fallback_title.as_str());
ensure_text_field(&mut object, "subtitle", "世界底稿已生成");
ensure_text_field(
&mut object,
"summary",
"第一版世界底稿已经整理完成,可继续精修关键角色、地点和主线第一幕。",
);
ensure_text_field(&mut object, "worldHook", fallback_world_hook.as_str());
ensure_text_field(
&mut object,
"playerPremise",
fallback_player_premise.as_str(),
);
ensure_text_array_field(
&mut object,
"coreConflicts",
vec!["核心冲突仍需继续深化,但已经具备第一版主线推进方向。"],
);
ensure_object_array_field(&mut object, "playableNpcs");
ensure_object_array_field(&mut object, "storyNpcs");
ensure_object_array_field(&mut object, "landmarks");
ensure_object_array_field(&mut object, "chapters");
ensure_scene_chapter_blueprints(&mut object);
object
}
fn ensure_text_field(object: &mut JsonMap<String, JsonValue>, key: &str, fallback: &str) {
let current = object
.get(key)
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
object.insert(
key.to_string(),
JsonValue::String(current.unwrap_or_else(|| fallback.to_string())),
);
}
fn ensure_text_array_field(
object: &mut JsonMap<String, JsonValue>,
key: &str,
fallback_items: Vec<&str>,
) {
let current_items = object
.get(key)
.and_then(JsonValue::as_array)
.map(|entries| {
entries
.iter()
.filter_map(|entry| entry.as_str().map(str::trim))
.filter(|value| !value.is_empty())
.map(|value| JsonValue::String(value.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
if current_items.is_empty() {
object.insert(
key.to_string(),
JsonValue::Array(
fallback_items
.into_iter()
.map(|value| JsonValue::String(value.to_string()))
.collect(),
),
);
} else {
object.insert(key.to_string(), JsonValue::Array(current_items));
}
}
fn ensure_object_array_field(object: &mut JsonMap<String, JsonValue>, key: &str) {
let current = object
.get(key)
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
object.insert(key.to_string(), JsonValue::Array(current));
}
fn ensure_scene_chapter_blueprints(object: &mut JsonMap<String, JsonValue>) {
let blueprints = object
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
if blueprints.is_empty() {
object.insert(
"sceneChapterBlueprints".to_string(),
JsonValue::Array(vec![build_fallback_scene_chapter_blueprint()]),
);
return;
}
let normalized = blueprints
.into_iter()
.map(|chapter| normalize_scene_chapter_blueprint(chapter))
.collect::<Vec<_>>();
object.insert(
"sceneChapterBlueprints".to_string(),
JsonValue::Array(normalized),
);
}
fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
let mut object = chapter.as_object().cloned().unwrap_or_default();
let title = object
.get("title")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("第一幕");
object.insert("title".to_string(), JsonValue::String(title.to_string()));
let acts = object
.get("acts")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
if acts.is_empty() {
object.insert(
"acts".to_string(),
JsonValue::Array(vec![build_fallback_scene_act()]),
);
}
JsonValue::Object(object)
}
fn build_fallback_scene_chapter_blueprint() -> JsonValue {
json!({
"id": "chapter-act-1",
"title": "第一幕",
"summary": "第一幕用于让玩家进入当前世界的主线矛盾,并看见最初的风险与方向。",
"acts": [build_fallback_scene_act()],
})
}
fn build_fallback_scene_act() -> JsonValue {
json!({
"id": "scene-act-1",
"title": "开场场景幕",
"summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
})
}
fn derive_world_name(
object: &JsonMap<String, JsonValue>,
session: &CustomWorldAgentSessionRecord,
) -> String {
read_text_field(object, &["name", "title"])
.or_else(|| {
session
.anchor_content
.get("worldPromise")
.and_then(JsonValue::as_object)
.and_then(|entry| entry.get("hook"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| "未命名世界草稿".to_string())
}
fn derive_world_hook(
object: &JsonMap<String, JsonValue>,
session: &CustomWorldAgentSessionRecord,
) -> String {
read_text_field(object, &["worldHook"])
.or_else(|| {
session
.anchor_content
.get("worldPromise")
.and_then(JsonValue::as_object)
.and_then(|entry| entry.get("hook"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| {
"这个世界正被一条持续扩大的主线危机推向失衡,而玩家会被卷入其中心。".to_string()
})
}
fn derive_player_premise(
object: &JsonMap<String, JsonValue>,
session: &CustomWorldAgentSessionRecord,
) -> String {
read_text_field(object, &["playerPremise"])
.or_else(|| {
session
.anchor_content
.get("playerEntryPoint")
.and_then(JsonValue::as_object)
.map(|entry| {
let identity = entry
.get("openingIdentity")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
let problem = entry
.get("openingProblem")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
let motivation = entry
.get("entryMotivation")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
[identity, problem, motivation]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("")
})
.filter(|value| !value.trim().is_empty())
})
.unwrap_or_else(|| {
"玩家会以一名已经卷入当前局势的人物进入世界,并被迫尽快确认自己的立场与行动方向。"
.to_string()
})
}
fn read_text_field(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<String> {
for key in keys {
let mut current = JsonValue::Object(object.clone());
let mut found = true;
for segment in key.split('.') {
if let Some(next) = current.get(segment) {
current = next.clone();
} else {
found = false;
break;
}
}
if found
&& let Some(value) = current
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(value.to_string());
}
}
None
}
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
fn to_pretty_json(value: &JsonValue) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string())
}
fn is_non_null_json(value: &JsonValue) -> bool {
!matches!(value, JsonValue::Null)
}
#[cfg(test)]
mod tests {
use std::{
io::{Read, Write},
net::TcpListener,
sync::{Arc, Mutex},
thread,
time::Duration as StdDuration,
};
use platform_llm::{DEFAULT_REQUEST_TIMEOUT_MS, LlmConfig, LlmProvider};
use super::*;
#[test]
fn foundation_prompt_uses_real_seed_text() {
let session = build_test_session();
let prompt = build_foundation_draft_user_prompt(&session);
assert!(prompt.contains("seedText海雾会吞掉记错航线的人。"));
assert!(!prompt.contains("seedTextcustom-world-agent-session-1"));
}
#[test]
fn build_draft_foundation_action_payload_json_injects_generated_profile() {
let payload = ExecuteCustomWorldAgentActionRequest {
action: "draft_foundation".to_string(),
profile_id: Some("profile-1".to_string()),
draft_profile: Some(json!({ "name": "旧草稿" })),
legacy_result_profile: None,
setting_text: Some("旧设定".to_string()),
card_id: None,
sections: None,
profile: None,
count: None,
prompt_text: Some("补充提示".to_string()),
anchor_card_ids: Some(vec!["card-1".to_string()]),
role_ids: None,
role_id: None,
portrait_path: None,
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: None,
scene_ids: None,
scene_id: None,
scene_kind: None,
image_src: None,
generated_scene_asset_id: None,
generated_scene_prompt: None,
generated_scene_model: None,
checkpoint_id: None,
};
let payload_json = build_draft_foundation_action_payload_json(
&payload,
r#"{"name":"新草稿","worldHook":"失灯海域会吞掉所有记错航线的人。"}"#,
)
.expect("payload json should build");
let payload_value =
serde_json::from_str::<JsonValue>(&payload_json).expect("payload json should parse");
assert_eq!(
payload_value.get("action"),
Some(&json!("draft_foundation"))
);
assert_eq!(payload_value.get("profileId"), Some(&json!("profile-1")));
assert_eq!(
payload_value
.get("draftProfile")
.and_then(|value| value.get("name")),
Some(&json!("新草稿"))
);
}
#[tokio::test]
async fn generate_custom_world_foundation_draft_uses_seed_text_and_normalizes_fields() {
let request_capture = Arc::new(Mutex::new(String::new()));
let server_url = spawn_mock_server(
request_capture.clone(),
r#"{"id":"resp_01","choices":[{"message":{"content":"{\"name\":\"雾港归航\",\"coreConflicts\":[\"守灯塔的旧档案被人改写。\"]}"}}]}"#
.to_string(),
);
let llm_client = build_test_llm_client(server_url);
let session = build_test_session();
let result = generate_custom_world_foundation_draft(&llm_client, &session)
.await
.expect("draft generation should succeed");
let draft_profile = serde_json::from_str::<JsonValue>(&result.draft_profile_json)
.expect("draft profile should parse");
let request_text = request_capture
.lock()
.expect("request capture should lock")
.clone();
assert!(request_text.contains("海雾会吞掉记错航线的人。"));
assert!(!request_text.contains("seedText\\uff1acustom-world-agent-session-1"));
assert_eq!(draft_profile.get("name"), Some(&json!("雾港归航")));
assert!(
draft_profile
.get("worldHook")
.and_then(JsonValue::as_str)
.is_some()
);
assert!(
draft_profile
.get("playerPremise")
.and_then(JsonValue::as_str)
.is_some()
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.map(|entries| !entries.is_empty()),
Some(true)
);
}
fn build_test_session() -> CustomWorldAgentSessionRecord {
CustomWorldAgentSessionRecord {
session_id: "custom-world-agent-session-1".to_string(),
seed_text: "海雾会吞掉记错航线的人。".to_string(),
current_turn: 2,
anchor_content: json!({
"worldPromise": {
"hook": "在失真的海图上追查一场被篡改的沉船事故。"
},
"playerEntryPoint": {
"openingIdentity": "被停职返乡的守灯人",
"openingProblem": "灯塔记录被人改写",
"entryMotivation": "查清父亲沉船真相"
}
}),
progress_percent: 100,
last_assistant_reply: Some("世界锚点已经基本齐全,可以整理第一版底稿。".to_string()),
stage: "foundation_review".to_string(),
focus_card_id: None,
creator_intent: json!({
"theme": "悬疑航海",
"playerPremise": "玩家是返乡调查旧案的守灯人。"
}),
creator_intent_readiness: json!({
"isReady": true
}),
anchor_pack: json!({
"coreConflict": "群岛议会正在掩盖沉船真相。"
}),
lock_state: json!({}),
draft_profile: JsonValue::Null,
messages: Vec::new(),
draft_cards: Vec::new(),
pending_clarifications: Vec::new(),
suggested_actions: Vec::new(),
recommended_replies: Vec::new(),
quality_findings: Vec::new(),
asset_coverage: json!({}),
checkpoints: Vec::new(),
supported_actions: Vec::new(),
publish_gate: None,
result_preview: None,
updated_at: "2026-04-23T00:00:00Z".to_string(),
}
}
fn build_test_llm_client(base_url: String) -> LlmClient {
let config = LlmConfig::new(
LlmProvider::Ark,
base_url,
"test-key".to_string(),
"test-model".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
0,
1,
)
.expect("llm config should build");
LlmClient::new(config).expect("llm client should build")
}
fn spawn_mock_server(request_capture: Arc<Mutex<String>>, response_body: String) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener
.local_addr()
.expect("listener should expose address");
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
*request_capture.lock().expect("request capture should lock") = request_text;
write_response(&mut stream, response_body);
});
format!("http://{address}")
}
fn read_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_read_timeout(Some(StdDuration::from_secs(1)))
.expect("read timeout should be configured");
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
let mut expected_total = None;
loop {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(bytes_read) => {
buffer.extend_from_slice(&chunk[..bytes_read]);
if expected_total.is_none()
&& let Some(header_end) = find_header_end(&buffer)
{
let content_length =
read_content_length(&buffer[..header_end]).unwrap_or(0);
expected_total = Some(header_end + content_length);
}
if let Some(total_bytes) = expected_total
&& buffer.len() >= total_bytes
{
break;
}
}
Err(error)
if error.kind() == std::io::ErrorKind::WouldBlock
|| error.kind() == std::io::ErrorKind::TimedOut =>
{
break;
}
Err(error) => panic!("mock server failed to read request: {error}"),
}
}
String::from_utf8(buffer).expect("request should be utf-8")
}
fn write_response(stream: &mut std::net::TcpStream, body: String) {
let raw_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(raw_response.as_bytes())
.expect("mock response should be written");
stream.flush().expect("mock response should flush");
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
}
fn read_content_length(headers: &[u8]) -> Option<usize> {
let text = String::from_utf8_lossy(headers);
text.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
return value.trim().parse::<usize>().ok();
}
None
})
}
}

View File

@@ -1,9 +1,12 @@
mod admin;
mod ai_tasks;
mod api_response;
mod app;
mod assets;
mod auth;
mod auth_me;
mod auth_payload;
mod auth_public_user;
mod auth_session;
mod auth_sessions;
mod big_fish;
@@ -13,6 +16,7 @@ mod config;
mod custom_world;
mod custom_world_agent_turn;
mod custom_world_ai;
mod custom_world_foundation_draft;
mod error_middleware;
mod health;
mod http_error;
@@ -24,6 +28,7 @@ mod logout_all;
mod password_entry;
mod phone_auth;
mod puzzle;
mod puzzle_agent_turn;
mod refresh_session;
mod request_context;
mod response_headers;

View File

@@ -6,10 +6,11 @@ use axum::{
};
use module_auth::{PasswordEntryError, PasswordEntryInput};
use serde_json::json;
use shared_contracts::auth::{AuthUserPayload, PasswordEntryRequest, PasswordEntryResponse};
use shared_contracts::auth::{PasswordEntryRequest, PasswordEntryResponse};
use crate::{
api_response::json_success_body,
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_password_auth_session,
},
@@ -48,15 +49,7 @@ pub async fn password_entry(
Some(&request_context),
PasswordEntryResponse {
token: signed_session.access_token,
user: AuthUserPayload {
id: result.user.id,
username: result.user.username,
display_name: result.user.display_name,
phone_number_masked: result.user.phone_number_masked,
login_method: result.user.login_method.as_str().to_string(),
binding_status: result.user.binding_status.as_str().to_string(),
wechat_bound: result.user.wechat_bound,
},
user: map_auth_user_payload(result.user),
},
),
))
@@ -74,6 +67,11 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError {
.with_details(json!({
"field": "password",
})),
PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("叙世号格式不正确")
.with_details(json!({
"field": "username",
})),
PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误")
}

View File

@@ -9,8 +9,7 @@ use module_auth::{
};
use serde_json::json;
use shared_contracts::auth::{
AuthUserPayload, PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest,
PhoneSendCodeResponse,
PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest, PhoneSendCodeResponse,
};
use time::OffsetDateTime;
use tracing::{info, warn};
@@ -20,6 +19,7 @@ use crate::{
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
auth_payload::map_auth_user_payload,
http_error::AppError,
request_context::RequestContext,
session_client::resolve_session_client_context,
@@ -166,15 +166,7 @@ pub async fn phone_login(
Some(&request_context),
PhoneLoginResponse {
token: signed_session.access_token,
user: AuthUserPayload {
id: result.user.id,
username: result.user.username,
display_name: result.user.display_name,
phone_number_masked: result.user.phone_number_masked,
login_method: result.user.login_method.as_str().to_string(),
binding_status: result.user.binding_status.as_str().to_string(),
wechat_bound: result.user.wechat_bound,
},
user: map_auth_user_payload(result.user),
},
),
))

View File

@@ -8,7 +8,10 @@ use axum::{
Json,
extract::{Extension, Path as AxumPath, State, rejection::JsonRejection},
http::{HeaderName, StatusCode, header},
response::{IntoResponse, Response},
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use serde_json::{Value, json};
use shared_contracts::{
@@ -46,9 +49,14 @@ use spacetime_client::{
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
};
use std::convert::Infallible;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
puzzle_agent_turn::{
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
run_puzzle_agent_turn,
},
request_context::RequestContext, state::AppState,
};
@@ -169,11 +177,12 @@ pub async fn submit_puzzle_agent_message(
));
}
let session = state
let owner_user_id = authenticated.claims().user_id().to_string();
let submitted_session = state
.spacetime_client()
.submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput {
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
user_message_id: client_message_id,
user_message_text: message_text,
submitted_at_micros: current_utc_micros(),
@@ -186,6 +195,41 @@ pub async fn submit_puzzle_agent_message(
map_puzzle_client_error(error),
)
})?;
let turn_result = run_puzzle_agent_turn(
PuzzleAgentTurnRequest {
llm_client: state.llm_client(),
session: &submitted_session,
},
|_| {},
)
.await;
let finalize_input = match turn_result {
Ok(turn_result) => build_finalize_record_input(
session_id.clone(),
owner_user_id.clone(),
format!("assistant-{session_id}-{}", current_utc_micros()),
turn_result,
current_utc_micros(),
),
Err(error) => build_failed_finalize_record_input(
session_id.clone(),
owner_user_id.clone(),
&submitted_session,
error.to_string(),
current_utc_micros(),
),
};
let session = state
.spacetime_client()
.finalize_puzzle_agent_message(finalize_input)
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
@@ -219,11 +263,12 @@ pub async fn stream_puzzle_agent_message(
"sessionId",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let session = state
.spacetime_client()
.submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput {
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
user_message_id: payload.client_message_id.trim().to_string(),
user_message_text: payload.text.trim().to_string(),
submitted_at_micros: current_utc_micros(),
@@ -236,32 +281,100 @@ pub async fn stream_puzzle_agent_message(
map_puzzle_client_error(error),
)
})?;
let state = state.clone();
let session_id_for_stream = session_id.clone();
let owner_user_id_for_stream = owner_user_id.clone();
let stream = async_stream::stream! {
let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let turn_result = {
let run_turn = run_puzzle_agent_turn(
PuzzleAgentTurnRequest {
llm_client: state.llm_client(),
session: &session,
},
move |text| {
let _ = reply_tx.send(text.to_string());
},
);
tokio::pin!(run_turn);
let session_response = map_puzzle_agent_session_response(session);
let reply_text = session_response
.last_assistant_reply
.clone()
.unwrap_or_else(|| "拼图锚点已更新。".to_string());
let mut sse_body = String::new();
append_sse_event(
&request_context,
&mut sse_body,
"reply_delta",
&json!({ "text": reply_text }),
)?;
append_sse_event(
&request_context,
&mut sse_body,
"session",
&json!({ "session": session_response }),
)?;
append_sse_event(
&request_context,
&mut sse_body,
"done",
&json!({ "ok": true }),
)?;
Ok(build_event_stream_response(sse_body))
loop {
tokio::select! {
result = &mut run_turn => break result,
maybe_text = reply_rx.recv() => {
if let Some(text) = maybe_text {
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),
));
}
}
}
}
};
while let Some(text) = reply_rx.recv().await {
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),
));
}
let finalize_input = match turn_result {
Ok(turn_result) => build_finalize_record_input(
session_id_for_stream.clone(),
owner_user_id_for_stream.clone(),
format!("assistant-{session_id_for_stream}-{}", current_utc_micros()),
turn_result,
current_utc_micros(),
),
Err(error) => build_failed_finalize_record_input(
session_id_for_stream.clone(),
owner_user_id_for_stream.clone(),
&session,
error.to_string(),
current_utc_micros(),
),
};
let finalize_result = state
.spacetime_client()
.finalize_puzzle_agent_message(finalize_input)
.await;
let _final_session = match finalize_result {
Ok(session) => session,
Err(error) => {
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"error",
json!({ "message": error.to_string() }),
));
return;
}
};
let final_session = match state
.spacetime_client()
.get_puzzle_agent_session(session_id_for_stream, owner_user_id_for_stream)
.await
{
Ok(session) => session,
Err(error) => {
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"error",
json!({ "message": error.to_string() }),
));
return;
}
};
let session_response = map_puzzle_agent_session_response(final_session);
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"session",
json!({ "session": session_response }),
));
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"done",
json!({ "ok": true }),
));
};
Ok(Sse::new(stream).into_response())
}
pub async fn execute_puzzle_agent_action(
@@ -413,13 +526,15 @@ pub async fn execute_puzzle_agent_action(
)
}
"publish_puzzle_work" => {
let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id);
let profile = state
.spacetime_client()
.publish_puzzle_work(PuzzlePublishRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
work_id: build_prefixed_uuid_id("puzzle-work-"),
profile_id: build_prefixed_uuid_id("puzzle-profile-"),
// 发布沿用 session 派生的稳定作品 ID避免草稿卡与已发布卡重复。
work_id,
profile_id,
author_display_name: resolve_author_display_name(&state, &authenticated),
level_name: payload.level_name.clone(),
summary: payload.summary.clone(),
@@ -1153,6 +1268,14 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String {
"我先接住你的画面灵感,再一起把它收束成正式拼图关卡。".to_string()
}
fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
let stable_suffix = session_id.strip_prefix("puzzle-session-").unwrap_or(session_id);
(
format!("puzzle-work-{stable_suffix}"),
format!("puzzle-profile-{stable_suffix}"),
)
}
fn ensure_non_empty(
request_context: &RequestContext,
provider: &str,
@@ -1193,6 +1316,14 @@ fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError {
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("当前模型不可用")
|| message.contains("生成失败")
|| message.contains("解析失败")
|| message.contains("缺少有效回复") =>
{
StatusCode::BAD_GATEWAY
}
_ => StatusCode::BAD_GATEWAY,
};
@@ -1216,41 +1347,32 @@ fn puzzle_error_response(
response
}
fn append_sse_event(
request_context: &RequestContext,
body: &mut String,
event_name: &str,
payload: &Value,
) -> Result<(), Response> {
let payload = serde_json::to_string(payload).map_err(|error| {
puzzle_error_response(
request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
fn puzzle_sse_json_event(event_name: &str, payload: Value) -> Result<Event, AppError> {
Event::default()
.event(event_name)
.json_data(payload)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"provider": "sse",
"message": format!("SSE payload 序列化失败:{error}"),
})),
)
})?;
body.push_str("event: ");
body.push_str(event_name);
body.push('\n');
body.push_str("data: ");
body.push_str(&payload);
body.push_str("\n\n");
Ok(())
}))
})
}
fn build_event_stream_response(body: String) -> Response {
(
[
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache, no-transform"),
(header::CONNECTION, "keep-alive"),
],
body,
)
.into_response()
fn puzzle_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
match puzzle_sse_json_event(event_name, payload) {
Ok(event) => event,
Err(_) => puzzle_sse_error_event_message("SSE payload 序列化失败".to_string()),
}
}
fn puzzle_sse_error_event_message(message: String) -> Event {
let payload = format!(
"{{\"message\":{}}}",
serde_json::to_string(&message)
.unwrap_or_else(|_| "\"SSE 错误事件序列化失败\"".to_string())
);
Event::default().event("error").data(payload)
}
fn build_placeholder_puzzle_candidates(

View File

@@ -0,0 +1,372 @@
use module_puzzle::{
PuzzleAgentStage, PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack,
};
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use spacetime_client::{
PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, PuzzleAgentSessionRecord,
};
#[derive(Clone, Debug)]
pub(crate) struct PuzzleAgentTurnRequest<'a> {
pub llm_client: Option<&'a LlmClient>,
pub session: &'a PuzzleAgentSessionRecord,
}
#[derive(Clone, Debug)]
pub(crate) struct PuzzleAgentTurnResult {
pub assistant_reply_text: String,
pub stage: String,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct PuzzleAgentTurnError {
message: String,
}
impl PuzzleAgentTurnError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for PuzzleAgentTurnError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for PuzzleAgentTurnError {}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PuzzleAgentModelOutput {
reply_text: String,
progress_percent: u32,
next_anchor_pack: PuzzleAnchorPack,
}
const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创拼图画面的中文创意策划。
你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。
你必须同时输出:
1. 一段直接发给用户的中文回复 replyText
2. 当前进度 progressPercent
3. 下一轮完整可用的 nextAnchorPack
拼图创作固定围绕 5 个视觉锚点:
1. themePromise题材承诺
2. visualSubject画面主体
3. visualMood视觉气质
4. compositionHooks拼图记忆点
5. tagsAndForbidden标签与禁忌
硬约束:
1. 只能输出 JSON不能输出代码块或解释
2. nextAnchorPack 必须是完整对象,不能只输出 patch
3. replyText 必须是自然中文不能提“字段”“锚点”“结构”“JSON”等内部词
4. replyText 一次最多推进一个最关键问题
5. 如果用户已经给出明确方向,就优先吸收和收束,不要机械反问
6. progressPercent 范围只能是 0 到 100
7. status 只能使用 missing / inferred / confirmed / locked
"#;
const PUZZLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorPack": {
"themePromise": {
"key": "themePromise",
"label": "题材承诺",
"value": "",
"status": "missing"
},
"visualSubject": {
"key": "visualSubject",
"label": "画面主体",
"value": "",
"status": "missing"
},
"visualMood": {
"key": "visualMood",
"label": "视觉气质",
"value": "",
"status": "missing"
},
"compositionHooks": {
"key": "compositionHooks",
"label": "拼图记忆点",
"value": "",
"status": "missing"
},
"tagsAndForbidden": {
"key": "tagsAndForbidden",
"label": "标签与禁忌",
"value": "",
"status": "missing"
}
}
}"#;
pub(crate) async fn run_puzzle_agent_turn<F>(
request: PuzzleAgentTurnRequest<'_>,
mut on_reply_update: F,
) -> Result<PuzzleAgentTurnResult, PuzzleAgentTurnError>
where
F: FnMut(&str),
{
let llm_client = request
.llm_client
.ok_or_else(|| PuzzleAgentTurnError::new("当前模型不可用,请稍后重试。"))?;
let prompt = build_puzzle_agent_prompt(request.session);
let mut latest_reply_text = String::new();
let response = llm_client
.stream_text(
LlmTextRequest::new(vec![
LlmMessage::system(format!("{PUZZLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}")),
LlmMessage::user("请按约定输出这一轮的 JSON。"),
]),
|delta: &LlmStreamDelta| {
if let Some(reply_progress) =
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
&& reply_progress != latest_reply_text
{
latest_reply_text = reply_progress.clone();
on_reply_update(reply_progress.as_str());
}
},
)
.await
.map_err(|_| PuzzleAgentTurnError::new("拼图聊天生成失败,请稍后重试。"))?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| PuzzleAgentTurnError::new("拼图聊天结果解析失败,请稍后重试。"))?;
let output = parse_model_output(&parsed)?;
if output.reply_text != latest_reply_text {
on_reply_update(output.reply_text.as_str());
}
Ok(PuzzleAgentTurnResult {
assistant_reply_text: output.reply_text,
stage: resolve_puzzle_agent_stage(output.progress_percent).as_str().to_string(),
progress_percent: output.progress_percent,
anchor_pack_json: serde_json::to_string(&output.next_anchor_pack)
.unwrap_or_else(|_| serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string())),
error_message: None,
})
}
pub(crate) fn build_finalize_record_input(
session_id: String,
owner_user_id: String,
assistant_message_id: String,
result: PuzzleAgentTurnResult,
updated_at_micros: i64,
) -> PuzzleAgentMessageFinalizeRecordInput {
PuzzleAgentMessageFinalizeRecordInput {
session_id,
owner_user_id,
assistant_message_id: Some(assistant_message_id),
assistant_reply_text: Some(result.assistant_reply_text),
stage: result.stage,
progress_percent: result.progress_percent,
anchor_pack_json: result.anchor_pack_json,
error_message: result.error_message,
updated_at_micros,
}
}
pub(crate) fn build_failed_finalize_record_input(
session_id: String,
owner_user_id: String,
session: &PuzzleAgentSessionRecord,
error_message: String,
updated_at_micros: i64,
) -> PuzzleAgentMessageFinalizeRecordInput {
let anchor_pack_json = serde_json::to_string(&map_record_anchor_pack(&session.anchor_pack))
.unwrap_or_else(|_| serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string()));
PuzzleAgentMessageFinalizeRecordInput {
session_id,
owner_user_id,
assistant_message_id: None,
assistant_reply_text: None,
stage: session.stage.clone(),
progress_percent: session.progress_percent,
anchor_pack_json,
error_message: Some(error_message),
updated_at_micros,
}
}
fn build_puzzle_agent_prompt(session: &PuzzleAgentSessionRecord) -> String {
format!(
"当前是第 {turn} 轮,当前进度 {progress}% 。\n\n当前 anchor pack\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
turn = session.current_turn.saturating_add(1),
progress = session.progress_percent,
anchor_pack = serde_json::to_string_pretty(&map_record_anchor_pack(&session.anchor_pack))
.unwrap_or_else(|_| "{}".to_string()),
chat_history = serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
.unwrap_or_else(|_| "[]".to_string()),
contract = PUZZLE_AGENT_OUTPUT_CONTRACT,
)
}
fn build_chat_history(messages: &[PuzzleAgentMessageRecord]) -> Vec<JsonValue> {
messages
.iter()
.map(|message| {
json!({
"role": message.role,
"kind": message.kind,
"content": message.text,
})
})
.collect()
}
fn parse_model_output(parsed: &JsonValue) -> Result<PuzzleAgentModelOutput, PuzzleAgentTurnError> {
let reply_text = parsed
.get("replyText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| PuzzleAgentTurnError::new("拼图聊天结果缺少有效回复,请稍后重试。"))?
.to_string();
let progress_percent = parsed
.get("progressPercent")
.and_then(JsonValue::as_u64)
.map(|value| value.min(100) as u32)
.unwrap_or(0);
let next_anchor_pack = parsed
.get("nextAnchorPack")
.cloned()
.ok_or_else(|| PuzzleAgentTurnError::new("拼图聊天结果缺少 nextAnchorPack。"))
.and_then(|value| {
serde_json::from_value::<PuzzleAnchorPack>(value)
.map_err(|_| PuzzleAgentTurnError::new("拼图 anchor pack 解析失败,请稍后重试。"))
})?;
Ok(PuzzleAgentModelOutput {
reply_text,
progress_percent,
next_anchor_pack,
})
}
fn resolve_puzzle_agent_stage(progress_percent: u32) -> PuzzleAgentStage {
if progress_percent >= 85 {
PuzzleAgentStage::DraftReady
} else {
PuzzleAgentStage::CollectingAnchors
}
}
fn map_record_anchor_pack(record: &spacetime_client::PuzzleAnchorPackRecord) -> PuzzleAnchorPack {
PuzzleAnchorPack {
theme_promise: map_record_anchor_item(&record.theme_promise),
visual_subject: map_record_anchor_item(&record.visual_subject),
visual_mood: map_record_anchor_item(&record.visual_mood),
composition_hooks: map_record_anchor_item(&record.composition_hooks),
tags_and_forbidden: map_record_anchor_item(&record.tags_and_forbidden),
}
}
fn map_record_anchor_item(record: &spacetime_client::PuzzleAnchorItemRecord) -> module_puzzle::PuzzleAnchorItem {
module_puzzle::PuzzleAnchorItem {
key: record.key.clone(),
label: record.label.clone(),
value: record.value.clone(),
status: parse_anchor_status(record.status.as_str()),
}
}
fn parse_anchor_status(value: &str) -> PuzzleAnchorStatus {
match value {
"confirmed" => PuzzleAnchorStatus::Confirmed,
"locked" => PuzzleAnchorStatus::Locked,
"inferred" => PuzzleAnchorStatus::Inferred,
_ => PuzzleAnchorStatus::Missing,
}
}
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
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;
while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() {
cursor += 1;
}
if text.as_bytes().get(cursor).copied() != Some(b'"') {
return None;
}
cursor += 1;
let mut decoded = String::new();
let remainder = text.get(cursor..)?;
let mut characters = remainder.chars().peekable();
while let Some(current) = characters.next() {
if current == '"' {
return Some(decoded);
}
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);
}
}
other => decoded.push(other),
}
continue;
}
decoded.push(current);
}
Some(decoded)
}
#[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":42"#;
let extracted = extract_reply_text_from_partial_json(partial_json);
assert_eq!(extracted.as_deref(), Some("夜雨猫咪遗迹"));
}
}

View File

@@ -1,4 +1,4 @@
use std::{error::Error, fmt};
use std::{error::Error, fmt, sync::Arc};
#[cfg(test)]
use std::{
@@ -15,17 +15,22 @@ use module_runtime::RuntimeSnapshotRecord;
#[cfg(test)]
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_auth::{
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError,
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
sign_access_token, verify_access_token,
SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind, SmsProviderError,
};
use platform_llm::{LlmClient, LlmConfig, LlmError};
use platform_oss::{OssClient, OssConfig, OssError};
use serde_json::Value;
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
use time::OffsetDateTime;
use crate::config::AppConfig;
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
const ADMIN_ROLE: &str = "admin";
// 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。
#[derive(Clone, Debug)]
pub struct AppState {
@@ -33,6 +38,7 @@ pub struct AppState {
#[allow(dead_code)]
pub config: AppConfig,
auth_jwt_config: JwtConfig,
admin_runtime: Option<AdminRuntime>,
refresh_cookie_config: RefreshCookieConfig,
oss_client: Option<OssClient>,
password_entry_service: PasswordEntryService,
@@ -51,6 +57,34 @@ pub struct AppState {
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
}
#[derive(Clone, Debug)]
pub struct AdminRuntime {
username: Arc<str>,
password: Arc<str>,
subject: Arc<str>,
display_name: Arc<str>,
token_ttl_seconds: u64,
jwt_config: JwtConfig,
}
#[derive(Clone, Debug)]
pub struct AdminClaims {
pub subject: String,
pub username: String,
pub issued_at: OffsetDateTime,
pub expires_at: OffsetDateTime,
}
#[derive(Clone, Debug)]
pub struct AdminSession {
pub subject: String,
pub username: String,
pub display_name: String,
pub roles: Vec<String>,
pub issued_at: OffsetDateTime,
pub expires_at: OffsetDateTime,
}
#[derive(Debug)]
pub enum AppStateInitError {
Jwt(JwtError),
@@ -67,6 +101,7 @@ impl AppState {
config.jwt_secret.clone(),
config.jwt_access_token_ttl_seconds,
)?;
let admin_runtime = build_admin_runtime(&config, &auth_jwt_config)?;
let refresh_cookie_same_site =
RefreshCookieSameSite::parse(&config.refresh_cookie_same_site).ok_or(
RefreshCookieError::InvalidConfig("refresh cookie SameSite 取值非法"),
@@ -123,6 +158,7 @@ impl AppState {
Ok(Self {
config,
auth_jwt_config,
admin_runtime,
refresh_cookie_config,
oss_client,
password_entry_service,
@@ -144,6 +180,10 @@ impl AppState {
&self.auth_jwt_config
}
pub fn admin_runtime(&self) -> Option<&AdminRuntime> {
self.admin_runtime.as_ref()
}
pub fn refresh_cookie_config(&self) -> &RefreshCookieConfig {
&self.refresh_cookie_config
}
@@ -394,6 +434,90 @@ impl From<LlmError> for AppStateInitError {
}
}
impl AdminRuntime {
pub fn is_enabled(&self) -> bool {
!self.username.trim().is_empty() && !self.password.trim().is_empty()
}
pub fn username(&self) -> &str {
&self.username
}
pub fn password(&self) -> &str {
&self.password
}
pub fn build_claims(&self, now: OffsetDateTime) -> Result<AdminClaims, String> {
let expires_at = now
.checked_add(time::Duration::seconds(
i64::try_from(self.token_ttl_seconds)
.map_err(|_| "后台 token TTL 超出 i64 上限".to_string())?,
))
.ok_or_else(|| "后台 token 过期时间计算溢出".to_string())?;
Ok(AdminClaims {
subject: self.subject.to_string(),
username: self.username.to_string(),
issued_at: now,
expires_at,
})
}
pub fn sign_token(&self, claims: &AdminClaims) -> Result<String, String> {
let jwt_claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: claims.subject.clone(),
session_id: format!("admin-session-{}", claims.username),
provider: AuthProvider::Password,
roles: vec![ADMIN_ROLE.to_string()],
token_version: 1,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some(self.display_name.to_string()),
},
&self.jwt_config,
claims.issued_at,
)
.map_err(|error| error.to_string())?;
sign_access_token(&jwt_claims, &self.jwt_config).map_err(|error| error.to_string())
}
pub fn verify_token(&self, token: &str) -> Result<AccessTokenClaims, String> {
verify_access_token(token, &self.jwt_config).map_err(|error| error.to_string())
}
pub fn validate_claims(&self, claims: &AccessTokenClaims) -> Result<AdminSession, String> {
if claims.user_id() != self.subject.as_ref() {
return Err("后台管理员主体不匹配".to_string());
}
if !claims.roles.iter().any(|role| role == ADMIN_ROLE) {
return Err("当前令牌不是管理员令牌".to_string());
}
let issued_at = OffsetDateTime::from_unix_timestamp(claims.iat as i64)
.map_err(|_| "后台令牌签发时间无效".to_string())?;
let expires_at = OffsetDateTime::from_unix_timestamp(claims.exp as i64)
.map_err(|_| "后台令牌过期时间无效".to_string())?;
Ok(AdminSession {
subject: claims.user_id().to_string(),
username: self.username.to_string(),
display_name: self.display_name.to_string(),
roles: claims.roles.clone(),
issued_at,
expires_at,
})
}
pub fn build_session(&self, claims: &AdminClaims) -> AdminSession {
AdminSession {
subject: claims.subject.clone(),
username: claims.username.clone(),
display_name: self.display_name.to_string(),
roles: vec![ADMIN_ROLE.to_string()],
issued_at: claims.issued_at,
expires_at: claims.expires_at,
}
}
}
fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateInitError> {
let has_any_oss_field = config.oss_bucket.is_some()
|| config.oss_endpoint.is_some()
@@ -441,6 +565,42 @@ fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateIni
Ok(Some(LlmClient::new(llm_config)?))
}
fn build_admin_runtime(
config: &AppConfig,
base_jwt_config: &JwtConfig,
) -> Result<Option<AdminRuntime>, AppStateInitError> {
let Some(username) = config
.admin_username
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let Some(password) = config
.admin_password
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let jwt_config = JwtConfig::new(
base_jwt_config.issuer().to_string(),
config.jwt_secret.clone(),
config.admin_token_ttl_seconds,
)?;
Ok(Some(AdminRuntime {
username: Arc::<str>::from(username),
password: Arc::<str>::from(password),
subject: Arc::<str>::from(format!("admin:{username}")),
display_name: Arc::<str>::from(format!("管理员 {username}")),
token_ttl_seconds: config.admin_token_ttl_seconds,
jwt_config,
}))
}
#[cfg(test)]
mod tests {
use module_ai::{AiTaskKind, generate_ai_task_id};

View File

@@ -9,8 +9,8 @@ use module_auth::{
WechatAuthScene,
};
use shared_contracts::auth::{
AuthUserPayload, WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery,
WechatStartQuery, WechatStartResponse,
WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, WechatStartQuery,
WechatStartResponse,
};
use time::OffsetDateTime;
use url::Url;
@@ -18,6 +18,7 @@ use url::Url;
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
@@ -199,15 +200,7 @@ pub async fn bind_wechat_phone(
Some(&request_context),
WechatBindPhoneResponse {
token: signed_session.access_token,
user: AuthUserPayload {
id: result.user.id,
username: result.user.username,
display_name: result.user.display_name,
phone_number_masked: result.user.phone_number_masked,
login_method: result.user.login_method.as_str().to_string(),
binding_status: result.user.binding_status.as_str().to_string(),
wechat_bound: result.user.wechat_bound,
},
user: map_auth_user_payload(result.user),
},
),
))