This commit is contained in:
2026-05-08 11:44:42 +08:00
parent b08127031c
commit abf1f1ebea
249 changed files with 39411 additions and 887 deletions

View File

@@ -49,6 +49,11 @@ use crate::{
generate_character_visual, get_character_visual_job, publish_character_visual,
},
creation_agent_document_input::parse_creation_agent_document_input,
creative_agent::{
cancel_creative_agent_session, confirm_creative_puzzle_template,
create_creative_agent_session, get_creative_agent_session, stream_creative_agent_message,
stream_creative_draft_edit,
},
custom_world::{
create_custom_world_agent_session, delete_custom_world_agent_session,
delete_custom_world_library_profile, execute_custom_world_agent_action,
@@ -140,6 +145,14 @@ use crate::{
begin_story_runtime_session, begin_story_session, continue_story,
get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action,
},
visual_novel::{
compile_visual_novel_session, create_visual_novel_session, delete_visual_novel_work,
execute_visual_novel_action, get_visual_novel_run, get_visual_novel_session,
get_visual_novel_work, list_visual_novel_gallery, list_visual_novel_history,
list_visual_novel_works, publish_visual_novel_work, regenerate_visual_novel_run,
start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message,
submit_visual_novel_message, update_visual_novel_work,
},
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
};
@@ -1014,6 +1027,8 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.merge(creative_agent_router(state.clone()))
.merge(visual_novel_router(state.clone()))
.route(
"/api/runtime/puzzle/onboarding/generate",
post(generate_puzzle_onboarding_work).layer(DefaultBodyLimit::max(
@@ -1428,6 +1443,173 @@ pub fn build_router(state: AppState) -> Router {
.with_state(state)
}
fn creative_agent_router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/runtime/creative-agent/sessions",
post(create_creative_agent_session)
// 中文注释:创意 Agent 首轮允许携带参考图 URL/Data URL沿用拼图参考图入口上限。
.layer(DefaultBodyLimit::max(
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions/{session_id}",
get(get_creative_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions/{session_id}/messages/stream",
post(stream_creative_agent_message)
// 中文注释message stream 同样可能带图片素材,避免默认 JSON limit 过早拒绝。
.layer(DefaultBodyLimit::max(
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions/{session_id}/confirm-template",
post(confirm_creative_puzzle_template).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions/{session_id}/draft-edits/stream",
post(stream_creative_draft_edit)
// 中文注释:草稿编辑会携带当前 puzzle draft JSON保持和拼图草稿入口一致的 body 上限。
.layer(DefaultBodyLimit::max(
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions/{session_id}/cancel",
post(cancel_creative_agent_session)
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
)
}
fn visual_novel_router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/creation/visual-novel/sessions",
post(create_visual_novel_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}",
get(get_visual_novel_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/messages",
post(submit_visual_novel_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/messages/stream",
post(stream_visual_novel_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/actions",
post(execute_visual_novel_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/compile",
post(compile_visual_novel_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/works",
get(list_visual_novel_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/works/{profile_id}",
get(get_visual_novel_work)
.put(update_visual_novel_work)
.patch(update_visual_novel_work)
.delete(delete_visual_novel_work)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/works/{profile_id}/publish",
post(publish_visual_novel_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/gallery",
get(list_visual_novel_gallery),
)
.route(
"/api/runtime/visual-novel/works/{profile_id}/runs",
post(start_visual_novel_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}",
get(get_visual_novel_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}/actions/stream",
post(stream_visual_novel_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}/history",
get(list_visual_novel_history).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}/regenerate",
post(regenerate_visual_novel_run)
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
)
}
#[cfg(test)]
mod tests {
use axum::{
@@ -1450,6 +1632,8 @@ mod tests {
use super::build_router;
const TEST_PASSWORD: &str = "secret123";
const INTERNAL_TEST_SECRET: &str = "test-internal-secret";
async fn seed_phone_user_with_password(
state: &AppState,
phone_number: &str,
@@ -1507,6 +1691,43 @@ mod tests {
.expect("password login request should succeed")
}
fn build_internal_creative_agent_app() -> Router {
let mut config = AppConfig::default();
config.internal_api_secret = Some(INTERNAL_TEST_SECRET.to_string());
build_router(AppState::new(config).expect("state should build"))
}
fn internal_creative_agent_request(method: &str, uri: &str, body: Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.header("x-genarrative-authenticated-user-id", "user-creative-test")
.header("x-genarrative-internal-api-secret", INTERNAL_TEST_SECRET)
.body(Body::from(body.to_string()))
.expect("creative agent request should build")
}
async fn read_json_response(response: axum::response::Response) -> Value {
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
serde_json::from_slice(&body).expect("response body should be valid json")
}
async fn read_text_response(response: axum::response::Response) -> String {
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
String::from_utf8(body.to_vec()).expect("response body should be utf8")
}
#[tokio::test]
async fn healthz_returns_legacy_compatible_payload_and_headers() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -1600,6 +1821,244 @@ mod tests {
);
}
#[tokio::test]
async fn creative_agent_draft_edit_rejects_unconfirmed_template_session() {
let app = build_internal_creative_agent_app();
let create_response = app
.clone()
.oneshot(internal_creative_agent_request(
"POST",
"/api/runtime/creative-agent/sessions",
serde_json::json!({
"text": "做一个生日拼图",
"entryContext": "creation_home"
}),
))
.await
.expect("create session request should succeed");
assert_eq!(create_response.status(), StatusCode::OK);
let create_payload = read_json_response(create_response).await;
let session_id = create_payload["session"]["sessionId"]
.as_str()
.expect("session id should exist");
let edit_response = app
.clone()
.oneshot(internal_creative_agent_request(
"POST",
&format!("/api/runtime/creative-agent/sessions/{session_id}/draft-edits/stream"),
serde_json::json!({
"clientMessageId": "creative-edit-test",
"instruction": "把标题改轻松一点",
"targetPuzzleSessionId": "puzzle-session-unconfirmed",
"currentDraft": {
"workTitle": "旧标题",
"workDescription": "旧描述",
"summary": "旧描述",
"themeTags": ["创意", "拼图", "灵感"],
"levels": [{
"levelId": "puzzle-level-1",
"levelName": "第一关",
"pictureDescription": "旧图面",
"pictureReference": null,
"generationStatus": "idle",
"candidates": []
}]
}
}),
))
.await
.expect("draft edit request should be handled");
assert_eq!(edit_response.status(), StatusCode::BAD_REQUEST);
let edit_payload = read_json_response(edit_response).await;
assert_eq!(
edit_payload["error"]["details"]["message"],
Value::String("尚未绑定拼图草稿".to_string())
);
let session_response = app
.oneshot(internal_creative_agent_request(
"GET",
&format!("/api/runtime/creative-agent/sessions/{session_id}"),
Value::Null,
))
.await
.expect("get session request should succeed");
let session_payload = read_json_response(session_response).await;
assert_eq!(session_payload["session"]["targetBinding"], Value::Null);
}
#[tokio::test]
async fn creative_agent_message_stream_returns_template_confirmation_events() {
let app = build_internal_creative_agent_app();
let create_response = app
.clone()
.oneshot(internal_creative_agent_request(
"POST",
"/api/runtime/creative-agent/sessions",
serde_json::json!({
"text": "做一个生日拼图",
"entryContext": "creation_home"
}),
))
.await
.expect("create session request should succeed");
assert_eq!(create_response.status(), StatusCode::OK);
let create_payload = read_json_response(create_response).await;
let session_id = create_payload["session"]["sessionId"]
.as_str()
.expect("session id should exist");
let stream_response = app
.clone()
.oneshot(internal_creative_agent_request(
"POST",
&format!("/api/runtime/creative-agent/sessions/{session_id}/messages/stream"),
serde_json::json!({
"clientMessageId": "creative-message-stream-test",
"content": [{
"type": "input_text",
"text": "做一个温暖的生日拼图"
}]
}),
))
.await
.expect("message stream request should be handled");
assert_eq!(stream_response.status(), StatusCode::OK);
assert_eq!(
stream_response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok()),
Some("text/event-stream")
);
let stream_body = read_text_response(stream_response).await;
assert!(stream_body.contains("event: stage"));
assert!(stream_body.contains("event: tool_started"));
assert!(stream_body.contains("event: tool_completed"));
assert!(stream_body.contains("event: puzzle_template_catalog"));
assert!(!stream_body.contains("event: puzzle_template_selection"));
assert!(!stream_body.contains("event: puzzle_cost_range"));
assert!(stream_body.contains("event: done"));
let tool_started_id = stream_body
.lines()
.skip_while(|line| *line != "event: tool_started")
.nth(1)
.and_then(|line| line.strip_prefix("data: "))
.and_then(|data| serde_json::from_str::<Value>(data).ok())
.and_then(|payload| payload["toolCallId"].as_str().map(ToString::to_string))
.expect("tool_started should include toolCallId");
let tool_completed_id = stream_body
.lines()
.skip_while(|line| *line != "event: tool_completed")
.nth(1)
.and_then(|line| line.strip_prefix("data: "))
.and_then(|data| serde_json::from_str::<Value>(data).ok())
.and_then(|payload| payload["toolCallId"].as_str().map(ToString::to_string))
.expect("tool_completed should include toolCallId");
assert_eq!(tool_started_id, tool_completed_id);
let session_response = app
.oneshot(internal_creative_agent_request(
"GET",
&format!("/api/runtime/creative-agent/sessions/{session_id}"),
Value::Null,
))
.await
.expect("get session request should succeed");
let session_payload = read_json_response(session_response).await;
assert_eq!(
session_payload["session"]["stage"],
Value::String("waiting_template_confirmation".to_string())
);
assert_eq!(
session_payload["session"]["puzzleTemplateSelection"],
Value::Null
);
assert!(
session_payload["session"]["puzzleTemplateCatalog"]
.as_array()
.map(|templates| templates.len() >= 3)
.unwrap_or(false)
);
}
#[tokio::test]
async fn creative_agent_confirm_template_rejects_non_puzzle_template() {
let app = build_internal_creative_agent_app();
let create_response = app
.clone()
.oneshot(internal_creative_agent_request(
"POST",
"/api/runtime/creative-agent/sessions",
serde_json::json!({
"text": "做一个角色扮演开场",
"entryContext": "creation_home"
}),
))
.await
.expect("create session request should succeed");
assert_eq!(create_response.status(), StatusCode::OK);
let create_payload = read_json_response(create_response).await;
let session_id = create_payload["session"]["sessionId"]
.as_str()
.expect("session id should exist");
let confirm_response = app
.clone()
.oneshot(internal_creative_agent_request(
"POST",
&format!("/api/runtime/creative-agent/sessions/{session_id}/confirm-template"),
serde_json::json!({
"selection": {
"templateId": "rpg.unsupported",
"title": "RPG",
"reason": "用户想创建 RPG",
"costRange": {
"minPoints": 2,
"maxPoints": 12,
"pricingUnit": "point",
"reason": "按关卡数和每关图片生成次数估算,实际扣费以后端任务结算为准"
},
"supportedLevelMode": "single_or_multi",
"selectedLevelMode": "single_level",
"plannedLevelCount": 1,
"requiresUserConfirmation": true
}
}),
))
.await
.expect("confirm template request should be handled");
assert_eq!(confirm_response.status(), StatusCode::BAD_REQUEST);
let confirm_payload = read_json_response(confirm_response).await;
assert_eq!(
confirm_payload["error"]["details"]["provider"],
Value::String("module-puzzle".to_string())
);
let session_response = app
.oneshot(internal_creative_agent_request(
"GET",
&format!("/api/runtime/creative-agent/sessions/{session_id}"),
Value::Null,
))
.await
.expect("get session request should succeed");
let session_payload = read_json_response(session_response).await;
assert_eq!(
session_payload["session"]["stage"],
Value::String("idle".to_string())
);
assert_eq!(session_payload["session"]["targetBinding"], Value::Null);
}
#[tokio::test]
async fn runtime_story_legacy_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -3982,4 +4441,58 @@ mod tests {
assert_eq!(debug_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn visual_novel_creation_route_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/creation/visual-novel/sessions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"sourceMode": "idea",
"seedText": "雨夜书店",
"sourceAssetIds": []
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn visual_novel_forbidden_playback_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let legacy_playback_segment = concat!("re", "play");
for path in [
format!("/api/creation/visual-novel/{legacy_playback_segment}"),
format!("/api/runtime/visual-novel/{legacy_playback_segment}"),
format!("/api/runtime/visual-novel/{legacy_playback_segment}s"),
format!("/api/visual/{legacy_playback_segment}"),
format!("/api/galgame/{legacy_playback_segment}"),
format!("/api/txt/{legacy_playback_segment}"),
] {
let response = app
.clone()
.oneshot(
Request::builder()
.uri(path.as_str())
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}");
}
}
}