Merge origin/master into codex/wechat

This commit is contained in:
2026-05-12 16:20:45 +08:00
993 changed files with 154111 additions and 6329 deletions

View File

@@ -5,50 +5,56 @@ version.workspace = true
license.workspace = true
[dependencies]
async-stream = "0.3"
axum = "0.8"
base64 = "0.22"
dotenvy = "0.15"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
webp = "0.3"
module-ai = { path = "../module-ai" }
module-assets = { path = "../module-assets" }
module-auth = { path = "../module-auth" }
module-big-fish = { path = "../module-big-fish" }
module-combat = { path = "../module-combat" }
module-custom-world = { path = "../module-custom-world" }
module-inventory = { path = "../module-inventory" }
module-match3d = { path = "../module-match3d" }
module-npc = { path = "../module-npc" }
module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" }
module-runtime-story = { path = "../module-runtime-story" }
module-runtime-item = { path = "../module-runtime-item" }
module-story = { path = "../module-story" }
platform-auth = { path = "../platform-auth" }
platform-llm = { path = "../platform-llm" }
platform-oss = { path = "../platform-oss" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-contracts = { path = "../shared-contracts" }
shared-kernel = { path = "../shared-kernel" }
shared-logging = { path = "../shared-logging" }
spacetime-client = { path = "../spacetime-client" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time"] }
tokio-stream = "0.1"
time = { version = "0.3", features = ["formatting"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1"
url = "2"
urlencoding = "2"
uuid = { version = "1", features = ["v4"] }
async-stream = { workspace = true }
axum = { workspace = true, features = ["ws"] }
base64 = { workspace = true }
dotenvy = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "webp"] }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
webp = { workspace = true }
module-ai = { workspace = true }
module-assets = { workspace = true, features = ["server-service"] }
module-auth = { workspace = true }
module-big-fish = { workspace = true }
module-combat = { workspace = true }
module-creative-agent = { workspace = true }
module-custom-world = { workspace = true }
module-inventory = { workspace = true }
module-match3d = { workspace = true }
module-npc = { workspace = true }
module-puzzle = { workspace = true }
module-runtime = { workspace = true }
module-runtime-story = { workspace = true }
module-runtime-item = { workspace = true }
module-square-hole = { workspace = true }
module-story = { workspace = true }
module-visual-novel = { workspace = true }
platform-agent = { workspace = true }
platform-auth = { workspace = true }
platform-llm = { workspace = true }
platform-oss = { workspace = true }
platform-speech = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
shared-contracts = { workspace = true, features = ["oss-contracts"] }
shared-kernel = { workspace = true }
shared-logging = { workspace = true }
spacetime-client = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time"] }
tokio-stream = { workspace = true }
futures-util = { workspace = true }
time = { workspace = true, features = ["formatting"] }
tower-http = { workspace = true, features = ["trace"] }
tracing = { workspace = true }
url = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
zip = { workspace = true, features = ["deflate"] }
[dev-dependencies]
base64 = "0.22"
hmac = "0.12"
httpdate = "1"
http-body-util = "0.1"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
sha1 = "0.10"
tower = { version = "0.5", features = ["util"] }
base64 = { workspace = true }
hmac = { workspace = true }
http-body-util = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
sha2 = { workspace = true }
tower = { workspace = true, features = ["util"] }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
use axum::{
Json,
body::Body,
extract::{Extension, Query, State},
http::StatusCode,
http::{StatusCode, header},
response::Response,
};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, INITIAL_ASSET_OBJECT_VERSION,
@@ -17,23 +19,40 @@ use shared_contracts::assets::{
AssetBindingPayload, AssetHistoryEntryPayload, AssetHistoryListResponse, AssetHistoryQuery,
AssetObjectPayload, AssetReadUrlPayload, BindAssetObjectRequest, BindAssetObjectResponse,
ConfirmAssetObjectAccessPolicy, ConfirmAssetObjectRequest, ConfirmAssetObjectResponse,
CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse, DirectUploadTicketPayload,
GetAssetReadUrlResponse, GetReadUrlQuery,
CreateDirectUploadTicketRequest, CreateDirectUploadTicketResponse, DirectUploadObjectAccess,
DirectUploadTicketFormFields, DirectUploadTicketPayload, GetAssetReadUrlResponse,
GetReadUrlQuery,
};
use spacetime_client::SpacetimeClientError;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
platform_errors::map_oss_error, request_context::RequestContext, state::AppState,
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
tracking::{TrackingEventDraft, record_tracking_event_after_success},
};
// 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 3] =
["character_visual", "scene_image", "puzzle_cover_image"];
const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 7] = [
"character_visual",
"scene_image",
"puzzle_cover_image",
"square_hole_cover_image",
"square_hole_background_image",
"square_hole_shape_image",
"square_hole_hole_image",
];
// 中文注释:同源字节读取同时服务图片转 Data URL 与 Match3D 私有 GLB 预览Rodin GLB 可能明显超过图片上限。
const ASSET_READ_BYTES_MAX_SIZE_BYTES: u64 = 120 * 1024 * 1024;
const ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS: u64 = 300;
pub async fn create_direct_upload_ticket(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<CreateDirectUploadTicketRequest>,
) -> Result<Json<Value>, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
@@ -56,7 +75,10 @@ pub async fn create_direct_upload_ticket(
path_segments: payload.path_segments,
file_name: payload.file_name,
content_type: payload.content_type,
access: payload.access.unwrap_or(OssObjectAccess::Private),
access: payload
.access
.map(direct_upload_access_to_oss)
.unwrap_or(OssObjectAccess::Private),
metadata: payload.metadata,
max_size_bytes: payload.max_size_bytes,
expire_seconds: payload.expire_seconds,
@@ -68,12 +90,33 @@ pub async fn create_direct_upload_ticket(
"message": error.to_string(),
}))
})?;
let upload = direct_upload_ticket_payload_from_oss(signed);
record_asset_tracking_event(
&state,
&request_context,
&authenticated,
"asset_upload_ticket_create",
json!({
"asset": {
"operation": "asset_upload_ticket_create",
"operationFamily": "upload_ticket",
"objectKey": upload.object_key.clone(),
"legacyPublicPath": upload.legacy_public_path.clone(),
"bucket": upload.bucket.clone(),
"contentType": upload.content_type.clone(),
"access": upload.access,
"keyPrefix": upload.key_prefix.clone(),
"maxSizeBytes": upload.max_size_bytes,
"successActionStatus": upload.success_action_status,
}
}),
)
.await;
Ok(json_success_body(
Some(&request_context),
CreateDirectUploadTicketResponse {
upload: DirectUploadTicketPayload::from(signed),
},
CreateDirectUploadTicketResponse { upload },
))
}
@@ -111,11 +154,164 @@ pub async fn get_asset_read_url(
Ok(json_success_body(
Some(&request_context),
GetAssetReadUrlResponse {
read: AssetReadUrlPayload::from(signed),
read: asset_read_url_payload_from_oss(signed),
},
))
}
fn direct_upload_access_to_oss(value: DirectUploadObjectAccess) -> OssObjectAccess {
match value {
DirectUploadObjectAccess::Public => OssObjectAccess::Public,
DirectUploadObjectAccess::Private => OssObjectAccess::Private,
}
}
fn direct_upload_access_from_oss(value: OssObjectAccess) -> DirectUploadObjectAccess {
match value {
OssObjectAccess::Public => DirectUploadObjectAccess::Public,
OssObjectAccess::Private => DirectUploadObjectAccess::Private,
}
}
fn direct_upload_ticket_payload_from_oss(
value: platform_oss::OssPostObjectResponse,
) -> DirectUploadTicketPayload {
DirectUploadTicketPayload {
signature_version: value.signature_version.to_string(),
provider: value.provider.to_string(),
bucket: value.bucket,
endpoint: value.endpoint,
host: value.host,
object_key: value.object_key,
legacy_public_path: value.legacy_public_path,
content_type: value.content_type,
access: direct_upload_access_from_oss(value.access),
key_prefix: value.key_prefix,
expires_at: value.expires_at,
max_size_bytes: value.max_size_bytes,
success_action_status: value.success_action_status,
form_fields: direct_upload_ticket_form_fields_from_oss(value.form_fields),
}
}
fn direct_upload_ticket_form_fields_from_oss(
value: platform_oss::OssPostObjectFormFields,
) -> DirectUploadTicketFormFields {
DirectUploadTicketFormFields {
key: value.key,
policy: value.policy,
signature_version: value.signature_version,
credential: value.credential,
date: value.date,
signature: value.signature,
success_action_status: value.success_action_status,
content_type: value.content_type,
metadata: value.metadata,
}
}
fn asset_read_url_payload_from_oss(
value: platform_oss::OssSignedGetObjectUrlResponse,
) -> AssetReadUrlPayload {
AssetReadUrlPayload {
provider: value.provider.to_string(),
bucket: value.bucket,
endpoint: value.endpoint,
host: value.host,
object_key: value.object_key,
expires_at: value.expires_at,
signed_url: value.signed_url,
}
}
pub async fn get_asset_read_bytes(
State(state): State<AppState>,
Query(query): Query<GetReadUrlQuery>,
) -> Result<Response, AppError> {
// 中文注释:浏览器可以用签名 URL 渲染图片,但不能稳定跨域 fetch 私有 OSS 字节Rodin 图生模型参考图转 Data URL 走同源中转。
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let object_key = resolve_object_key_from_query(&query).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"field": "objectKey",
"reason": "必须提供 objectKey 或 legacyPublicPath",
}))
})?;
let signed = oss_client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key,
expire_seconds: Some(
query
.expire_seconds
.unwrap_or(ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS),
),
})
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
let upstream = reqwest::Client::new()
.get(signed.signed_url.as_str())
.send()
.await
.map_err(|error| map_asset_read_bytes_upstream_error(error.to_string()))?;
let upstream_status = upstream.status();
let content_type = upstream
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("application/octet-stream")
.to_string();
if upstream_status == reqwest::StatusCode::NOT_FOUND {
return Err(
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
"provider": "aliyun-oss",
"message": "资源不存在",
"objectKey": signed.object_key,
})),
);
}
if !upstream_status.is_success() {
return Err(map_asset_read_bytes_upstream_error(format!(
"OSS 读取返回非成功状态:{}",
upstream_status.as_u16()
)));
}
if upstream
.content_length()
.is_some_and(|size| size > ASSET_READ_BYTES_MAX_SIZE_BYTES)
{
return Err(map_asset_read_bytes_too_large());
}
let bytes = upstream
.bytes()
.await
.map_err(|error| map_asset_read_bytes_upstream_error(error.to_string()))?;
if bytes.len() as u64 > ASSET_READ_BYTES_MAX_SIZE_BYTES {
return Err(map_asset_read_bytes_too_large());
}
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header(header::CACHE_CONTROL, "private, max-age=60")
.body(Body::from(bytes))
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "asset-read-bytes",
"message": format!("构造资源内容响应失败:{error}"),
}))
})
}
pub async fn get_asset_history(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -183,6 +379,7 @@ pub async fn create_sts_upload_credentials(
pub async fn confirm_asset_object(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<ConfirmAssetObjectRequest>,
) -> Result<Json<Value>, AppError> {
let oss_client = state.oss_client().ok_or_else(|| {
@@ -202,33 +399,60 @@ pub async fn confirm_asset_object(
.await
.map_err(map_confirm_asset_object_error)?;
let asset_object = AssetObjectPayload {
asset_object_id: result.asset_object_id,
bucket: result.bucket,
object_key: result.object_key,
access_policy: result.access_policy.as_str().to_string(),
content_type: result.content_type,
content_length: result.content_length,
content_hash: result.content_hash,
version: result.version,
source_job_id: result.source_job_id,
owner_user_id: result.owner_user_id,
profile_id: result.profile_id,
entity_id: result.entity_id,
asset_kind: result.asset_kind,
created_at: result.created_at,
updated_at: result.updated_at,
};
record_asset_tracking_event(
&state,
&request_context,
&authenticated,
"asset_upload_confirm",
json!({
"asset": {
"operation": "asset_upload_confirm",
"operationFamily": "object_confirm",
"assetObjectId": asset_object.asset_object_id,
"assetKind": asset_object.asset_kind,
"objectKey": asset_object.object_key,
"bucket": asset_object.bucket,
"accessPolicy": asset_object.access_policy,
"contentType": asset_object.content_type,
"contentLength": asset_object.content_length,
"version": asset_object.version,
"sourceJobId": asset_object.source_job_id,
"ownerUserId": asset_object.owner_user_id,
"profileId": asset_object.profile_id,
"entityId": asset_object.entity_id,
}
}),
)
.await;
Ok(json_success_body(
Some(&request_context),
ConfirmAssetObjectResponse {
asset_object: AssetObjectPayload {
asset_object_id: result.asset_object_id,
bucket: result.bucket,
object_key: result.object_key,
access_policy: result.access_policy.as_str().to_string(),
content_type: result.content_type,
content_length: result.content_length,
content_hash: result.content_hash,
version: result.version,
source_job_id: result.source_job_id,
owner_user_id: result.owner_user_id,
profile_id: result.profile_id,
entity_id: result.entity_id,
asset_kind: result.asset_kind,
created_at: result.created_at,
updated_at: result.updated_at,
},
},
ConfirmAssetObjectResponse { asset_object },
))
}
pub async fn bind_asset_object_to_entity(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<BindAssetObjectRequest>,
) -> Result<Json<Value>, AppError> {
let now_micros = current_utc_micros();
@@ -251,25 +475,60 @@ pub async fn bind_asset_object_to_entity(
.await
.map_err(map_confirm_asset_object_error)?;
let asset_binding = AssetBindingPayload {
binding_id: result.binding_id,
asset_object_id: result.asset_object_id,
entity_kind: result.entity_kind,
entity_id: result.entity_id,
slot: result.slot,
asset_kind: result.asset_kind,
owner_user_id: result.owner_user_id,
profile_id: result.profile_id,
created_at: result.created_at,
updated_at: result.updated_at,
};
record_asset_tracking_event(
&state,
&request_context,
&authenticated,
"asset_bind",
json!({
"asset": {
"operation": "asset_bind",
"operationFamily": "object_bind",
"bindingId": asset_binding.binding_id,
"assetObjectId": asset_binding.asset_object_id,
"assetKind": asset_binding.asset_kind,
"entityKind": asset_binding.entity_kind,
"entityId": asset_binding.entity_id,
"slot": asset_binding.slot,
"ownerUserId": asset_binding.owner_user_id,
"profileId": asset_binding.profile_id,
}
}),
)
.await;
Ok(json_success_body(
Some(&request_context),
BindAssetObjectResponse {
asset_binding: AssetBindingPayload {
binding_id: result.binding_id,
asset_object_id: result.asset_object_id,
entity_kind: result.entity_kind,
entity_id: result.entity_id,
slot: result.slot,
asset_kind: result.asset_kind,
owner_user_id: result.owner_user_id,
profile_id: result.profile_id,
created_at: result.created_at,
updated_at: result.updated_at,
},
},
BindAssetObjectResponse { asset_binding },
))
}
async fn record_asset_tracking_event(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
event_key: &'static str,
metadata: Value,
) {
let user_id = authenticated.claims().user_id().to_string();
let mut draft = TrackingEventDraft::user(event_key, "asset", user_id.as_str());
draft.metadata = metadata;
record_tracking_event_after_success(state, request_context, draft).await;
}
fn resolve_object_key_from_query(query: &GetReadUrlQuery) -> Option<String> {
if let Some(object_key) = query
.object_key
@@ -420,6 +679,23 @@ fn map_confirm_asset_object_error(error: SpacetimeClientError) -> AppError {
}))
}
fn map_asset_read_bytes_upstream_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": format!("读取资源内容失败:{message}"),
}))
}
fn map_asset_read_bytes_too_large() -> AppError {
AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({
"provider": "aliyun-oss",
"message": format!(
"资源内容超过读取上限:{}MB",
ASSET_READ_BYTES_MAX_SIZE_BYTES / 1024 / 1024
),
}))
}
fn current_utc_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
@@ -466,32 +742,41 @@ mod tests {
error::Error,
fs,
path::{Path, PathBuf},
time::SystemTime,
};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use hmac::{Hmac, Mac};
use http_body_util::BodyExt;
use httpdate::fmt_http_date;
use reqwest::{Method, multipart};
use serde_json::{Value, json};
use sha1::Sha1;
use sha2::{Digest, Sha256};
use shared_kernel::new_uuid_simple_string;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
type HmacSha1 = Hmac<Sha1>;
type HmacSha256 = Hmac<Sha256>;
#[test]
fn asset_history_kind_support_includes_puzzle_cover_image() {
assert!(super::is_supported_asset_history_kind("character_visual"));
assert!(super::is_supported_asset_history_kind("scene_image"));
assert!(super::is_supported_asset_history_kind("puzzle_cover_image"));
assert!(super::is_supported_asset_history_kind(
"square_hole_cover_image"
));
assert!(super::is_supported_asset_history_kind(
"square_hole_background_image"
));
assert!(super::is_supported_asset_history_kind(
"square_hole_shape_image"
));
assert!(super::is_supported_asset_history_kind(
"square_hole_hole_image"
));
assert!(!super::is_supported_asset_history_kind(
"puzzle_preview_image"
));
@@ -501,7 +786,7 @@ mod tests {
fn asset_history_kind_message_lists_all_supported_kinds() {
assert_eq!(
super::supported_asset_history_kind_message(),
"历史素材类型只支持 character_visual、scene_image、puzzle_cover_image"
"历史素材类型只支持 character_visual、scene_image、puzzle_cover_image、square_hole_cover_image、square_hole_background_image、square_hole_shape_image、square_hole_hole_image"
);
}
@@ -634,8 +919,13 @@ mod tests {
Value::String("private".to_string())
);
assert_eq!(
payload["data"]["upload"]["formFields"]["OSSAccessKeyId"],
Value::String("test-access-key-id".to_string())
payload["data"]["upload"]["formFields"]["x-oss-signature-version"],
Value::String("OSS4-HMAC-SHA256".to_string())
);
assert!(
payload["data"]["upload"]["formFields"]["x-oss-credential"]
.as_str()
.is_some_and(|value| value.starts_with("test-access-key-id/"))
);
assert!(payload["data"]["upload"].get("publicUrl").is_none());
}
@@ -683,7 +973,7 @@ mod tests {
assert!(
payload["data"]["read"]["signedUrl"]
.as_str()
.is_some_and(|value| value.contains("OSSAccessKeyId=test-access-key-id"))
.is_some_and(|value| value.contains("x-oss-signature-version=OSS4-HMAC-SHA256"))
);
}
@@ -755,6 +1045,51 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn read_bytes_returns_service_unavailable_when_oss_missing() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/assets/read-bytes?legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png")
.header("x-genarrative-response-envelope", "1")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn read_bytes_rejects_missing_identifier() {
let config = AppConfig {
oss_bucket: Some("genarrative-assets".to_string()),
oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()),
oss_access_key_id: Some("test-access-key-id".to_string()),
oss_access_key_secret: Some("test-access-key-secret".to_string()),
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/assets/read-bytes")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn sts_upload_credentials_are_disabled_for_browser_writes() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -1391,13 +1726,27 @@ mod tests {
.oss_access_key_secret
.as_deref()
.ok_or_else(|| std::io::Error::other("缺少 oss access key secret"))?;
let date = fmt_http_date(SystemTime::now());
let canonical_resource = match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => format!("/{bucket}/{}", object_key.trim_start_matches('/')),
None => format!("/{bucket}/"),
};
let string_to_sign = format!("{}\n\n\n{}\n{}", method.as_str(), date, canonical_resource);
let signature = sign_oss_string(access_key_secret, &string_to_sign)?;
let signed_at = time::OffsetDateTime::now_utc();
let signed_at_text = build_oss_v4_signature_date(signed_at);
let signature_scope = build_oss_v4_signature_scope(endpoint, signed_at)?;
let object_path = object_key.map(str::trim).filter(|value| !value.is_empty());
let canonical_uri = build_oss_v4_canonical_uri(bucket, object_path);
let payload_hash = "UNSIGNED-PAYLOAD";
let canonical_headers = format!(
"host:{bucket}.{endpoint}\nx-oss-content-sha256:{payload_hash}\nx-oss-date:{signed_at_text}\n"
);
let additional_headers = "host";
let canonical_request = format!(
"{}\n{}\n\n{}\n{}\n{}",
method.as_str(),
canonical_uri,
canonical_headers,
additional_headers,
payload_hash
);
let string_to_sign =
build_oss_v4_string_to_sign(&signed_at_text, &signature_scope, &canonical_request);
let signature = sign_oss_v4_content(access_key_secret, &signature_scope, &string_to_sign)?;
let target_url = match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => build_object_url(config, object_key)?,
None => reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/"))?,
@@ -1405,18 +1754,152 @@ mod tests {
let response = client
.request(method, target_url)
.header("Date", date)
.header("Authorization", format!("OSS {access_key_id}:{signature}"))
.header("x-oss-content-sha256", payload_hash)
.header("x-oss-date", signed_at_text)
.header(
"Authorization",
format!(
"OSS4-HMAC-SHA256 Credential={access_key_id}/{signature_scope},AdditionalHeaders={additional_headers},Signature={signature}"
),
)
.send()
.await?;
Ok(response)
}
fn sign_oss_string(secret: &str, content: &str) -> Result<String, Box<dyn Error>> {
let mut signer = HmacSha1::new_from_slice(secret.as_bytes())?;
fn build_oss_v4_signature_scope(
endpoint: &str,
signed_at: time::OffsetDateTime,
) -> Result<String, Box<dyn Error>> {
let date = format_oss_v4_signature_scope_date(signed_at);
let region = endpoint
.trim()
.split('.')
.next()
.and_then(|segment| segment.strip_prefix("oss-"))
.ok_or_else(|| std::io::Error::other("OSS endpoint 无法解析 region"))?;
Ok(format!("{date}/{region}/oss/aliyun_v4_request"))
}
fn build_oss_v4_signature_date(signed_at: time::OffsetDateTime) -> String {
format!(
"{}T{:02}{:02}{:02}Z",
format_oss_v4_signature_scope_date(signed_at),
signed_at.hour(),
signed_at.minute(),
signed_at.second()
)
}
fn format_oss_v4_signature_scope_date(signed_at: time::OffsetDateTime) -> String {
format!(
"{:04}{:02}{:02}",
signed_at.year(),
signed_at.month() as u8,
signed_at.day()
)
}
fn build_oss_v4_canonical_uri(bucket: &str, object_key: Option<&str>) -> String {
match object_key.map(str::trim).filter(|value| !value.is_empty()) {
Some(object_key) => format!(
"/{}/{}",
encode_oss_url_query_value(bucket),
encode_oss_url_path(object_key.trim_start_matches('/'))
),
None => format!("/{}/", encode_oss_url_query_value(bucket)),
}
}
fn build_oss_v4_string_to_sign(
signature_date: &str,
signature_scope: &str,
canonical_request: &str,
) -> String {
format!(
"OSS4-HMAC-SHA256\n{signature_date}\n{signature_scope}\n{}",
sha256_hex(canonical_request.as_bytes())
)
}
fn sign_oss_v4_content(
secret: &str,
signature_scope: &str,
content: &str,
) -> Result<String, Box<dyn Error>> {
let signing_key = build_oss_v4_signing_key(secret, signature_scope)?;
let mut signer = HmacSha256::new_from_slice(&signing_key)?;
signer.update(content.as_bytes());
Ok(BASE64_STANDARD.encode(signer.finalize().into_bytes()))
Ok(hex_lower(&signer.finalize().into_bytes()))
}
fn build_oss_v4_signing_key(
secret: &str,
signature_scope: &str,
) -> Result<Vec<u8>, Box<dyn Error>> {
let mut parts = signature_scope.split('/');
let date = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少日期"))?;
let region = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少 region"))?;
let service = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少 service"))?;
let request = parts
.next()
.ok_or_else(|| std::io::Error::other("OSS V4 scope 缺少 request"))?;
let date_key = hmac_sha256_raw(format!("aliyun_v4{secret}").as_bytes(), date)?;
let region_key = hmac_sha256_raw(&date_key, region)?;
let service_key = hmac_sha256_raw(&region_key, service)?;
hmac_sha256_raw(&service_key, request)
}
fn hmac_sha256_raw(key: &[u8], content: &str) -> Result<Vec<u8>, Box<dyn Error>> {
let mut signer = HmacSha256::new_from_slice(key)?;
signer.update(content.as_bytes());
Ok(signer.finalize().into_bytes().to_vec())
}
fn sha256_hex(content: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(content);
hex_lower(&hasher.finalize())
}
fn hex_lower(bytes: &[u8]) -> String {
bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
fn encode_oss_url_path(path: &str) -> String {
path.split('/')
.map(encode_oss_url_query_value)
.collect::<Vec<_>>()
.join("/")
}
fn encode_oss_url_query_value(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char)
}
_ => {
use std::fmt::Write as _;
let _ = write!(&mut encoded, "%{byte:02X}");
}
}
}
encoded
}
fn ensure_success_status(status: u16, message: &str) -> Result<(), Box<dyn Error>> {

View File

@@ -63,9 +63,13 @@ pub async fn require_bearer_auth(
&& let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers())
{
request
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims.clone()));
let mut response = next.run(request).await;
response
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims));
return Ok(next.run(request).await);
return Ok(response);
}
let bearer_token = extract_bearer_token(request.headers())?;
@@ -114,10 +118,15 @@ pub async fn require_bearer_auth(
}
request
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims.clone()));
let mut response = next.run(request).await;
response
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims));
Ok(next.run(request).await)
Ok(response)
}
pub async fn inspect_auth_claims(
@@ -191,6 +200,7 @@ fn allows_internal_forwarded_auth(path: &str) -> bool {
// Node 代理已经完成平台账号 JWT 校验Rust 运行时只信任这些明确的内部转发路径。
path.starts_with("/api/runtime/big-fish/")
|| path.starts_with("/api/runtime/chat/")
|| path.starts_with("/api/runtime/creative-agent/")
|| path.starts_with("/api/runtime/puzzle/")
}
@@ -287,6 +297,9 @@ mod tests {
assert!(allows_internal_forwarded_auth(
"/api/runtime/chat/npc/turn/stream"
));
assert!(allows_internal_forwarded_auth(
"/api/runtime/creative-agent/sessions"
));
assert!(allows_internal_forwarded_auth("/api/runtime/puzzle/works"));
assert!(!allows_internal_forwarded_auth("/api/auth/me"));
}

View File

@@ -10,7 +10,10 @@ use platform_auth::{
use time::OffsetDateTime;
use crate::session_client::SessionClientContext;
use crate::{http_error::AppError, state::AppState};
use crate::{
http_error::AppError, request_context::RequestContext, state::AppState,
tracking::record_daily_login_tracking_event_after_success as record_daily_login_tracking_event_via_unified_path,
};
#[derive(Debug, Clone)]
pub struct SignedAuthSession {
@@ -26,6 +29,33 @@ pub fn create_password_auth_session(
create_auth_session(state, user, session_client, AuthLoginMethod::Password)
}
#[cfg(not(test))]
pub async fn record_daily_login_tracking_event_after_auth_success(
state: &AppState,
request_context: &RequestContext,
user_id: &str,
login_method: AuthLoginMethod,
) {
// 登录埋点是运营数据,不应反向阻断已经成功的认证会话签发;每日登录也走统一埋点 helper/procedure。
record_daily_login_tracking_event_via_unified_path(
state,
request_context,
user_id,
login_method,
)
.await;
}
#[cfg(test)]
pub async fn record_daily_login_tracking_event_after_auth_success(
_state: &AppState,
_request_context: &RequestContext,
_user_id: &str,
_login_method: AuthLoginMethod,
) {
// 单元测试默认不启动 SpacetimeDB这里仅验证登录链路调用点能通过编译并保持非阻断语义。
}
pub fn create_auth_session(
state: &AppState,
user: &AuthUser,

View File

@@ -65,6 +65,7 @@ use crate::{
request_context::RequestContext,
state::AppState,
work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
pub async fn create_big_fish_session(
@@ -235,7 +236,7 @@ pub async fn record_big_fish_play(
let items = state
.spacetime_client()
.record_big_fish_play(BigFishPlayReportRecordInput {
session_id,
session_id: session_id.clone(),
user_id: authenticated.claims().user_id().to_string(),
elapsed_ms: payload.elapsed_ms.unwrap_or(0),
reported_at_micros: current_utc_micros(),
@@ -245,6 +246,19 @@ pub async fn record_big_fish_play(
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
record_work_play_start_after_success(
&state,
&request_context,
WorkPlayTrackingDraft::new(
"big-fish",
session_id.clone(),
&authenticated,
"/api/runtime/big-fish/sessions/{session_id}/play",
)
.run_id(session_id.clone()),
)
.await;
Ok(json_success_body(
Some(&request_context),
BigFishWorksResponse {

View File

@@ -121,7 +121,7 @@ pub async fn generate_character_visual(
"sourceMode": payload.source_mode,
"size": size,
"referenceImageCount": payload.reference_image_data_urls.len(),
"provider": "apimart",
"provider": "vector-engine",
})
.to_string(),
),
@@ -193,7 +193,7 @@ pub async fn generate_character_visual(
),
structured_payload_json: Some(
json!({
"provider": "apimart",
"provider": "vector-engine",
"taskId": generated.task_id,
"model": model,
"imageCount": generated.images.len(),
@@ -1172,7 +1172,7 @@ fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError {
fn map_image_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"provider": "vector-engine",
"message": message,
}))
}
@@ -1184,7 +1184,7 @@ fn map_image_upstream_error(raw_text: &str, fallback_message: &str) -> AppError
value => value.to_string(),
};
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"provider": "vector-engine",
"message": message,
"raw": raw_text.trim(),
}))

View File

@@ -4,8 +4,12 @@ use platform_llm::{
DEFAULT_ARK_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_RETRY_BACKOFF_MS, LlmProvider,
};
use platform_speech::{
DEFAULT_ASR_RESOURCE_ID, DEFAULT_ASR_WS_URL,
DEFAULT_REQUEST_TIMEOUT_MS as DEFAULT_SPEECH_REQUEST_TIMEOUT_MS,
DEFAULT_TTS_BIDIRECTION_WS_URL, DEFAULT_TTS_RESOURCE_ID, DEFAULT_TTS_SSE_URL,
};
const DEFAULT_LLM_MODEL: &str = "doubao-1-5-pro-32k-character-250715";
const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge";
const DEFAULT_AUTH_STORE_PATH: &str = "server-rs/.data/auth-store.json";
const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json";
@@ -95,7 +99,22 @@ pub struct AppConfig {
pub dashscope_image_request_timeout_ms: u64,
pub apimart_base_url: String,
pub apimart_api_key: Option<String>,
pub apimart_image_request_timeout_ms: u64,
pub vector_engine_base_url: String,
pub vector_engine_api_key: Option<String>,
pub vector_engine_image_request_timeout_ms: u64,
pub vector_engine_audio_request_timeout_ms: u64,
pub hyper3d_base_url: String,
pub hyper3d_api_key: Option<String>,
pub hyper3d_model_request_timeout_ms: u64,
pub volcengine_speech_api_key: Option<String>,
pub volcengine_speech_app_id: Option<String>,
pub volcengine_speech_access_key: Option<String>,
pub volcengine_speech_asr_resource_id: String,
pub volcengine_speech_tts_resource_id: String,
pub volcengine_speech_asr_ws_url: String,
pub volcengine_speech_tts_bidirection_ws_url: String,
pub volcengine_speech_tts_sse_url: String,
pub volcengine_speech_request_timeout_ms: u64,
pub draft_asset_generation_max_concurrent_requests: usize,
pub ark_character_video_base_url: String,
pub ark_character_video_api_key: Option<String>,
@@ -178,9 +197,9 @@ impl Default for AppConfig {
spacetime_pool_size: 4,
spacetime_procedure_timeout: Duration::from_secs(30),
llm_provider: LlmProvider::Ark,
llm_base_url: DEFAULT_ARK_BASE_URL.to_string(),
llm_base_url: String::new(),
llm_api_key: None,
llm_model: DEFAULT_LLM_MODEL.to_string(),
llm_model: String::new(),
llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
llm_max_retries: DEFAULT_MAX_RETRIES,
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
@@ -188,18 +207,33 @@ impl Default for AppConfig {
creation_agent_llm_web_search_enabled: true,
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
dashscope_api_key: None,
dashscope_scene_image_model: "wan2.2-t2i-flash".to_string(),
dashscope_reference_image_model: "qwen-image-2.0".to_string(),
dashscope_cover_image_model: "wan2.2-t2i-flash".to_string(),
dashscope_scene_image_model: String::new(),
dashscope_reference_image_model: String::new(),
dashscope_cover_image_model: String::new(),
dashscope_image_request_timeout_ms: 150_000,
apimart_base_url: "https://api.apimart.ai/v1".to_string(),
apimart_base_url: String::new(),
apimart_api_key: None,
apimart_image_request_timeout_ms: 180_000,
vector_engine_base_url: String::new(),
vector_engine_api_key: None,
vector_engine_image_request_timeout_ms: 180_000,
vector_engine_audio_request_timeout_ms: 180_000,
hyper3d_base_url: "https://api.hyper3d.com/api/v2".to_string(),
hyper3d_api_key: None,
hyper3d_model_request_timeout_ms: 180_000,
volcengine_speech_api_key: None,
volcengine_speech_app_id: None,
volcengine_speech_access_key: None,
volcengine_speech_asr_resource_id: DEFAULT_ASR_RESOURCE_ID.to_string(),
volcengine_speech_tts_resource_id: DEFAULT_TTS_RESOURCE_ID.to_string(),
volcengine_speech_asr_ws_url: DEFAULT_ASR_WS_URL.to_string(),
volcengine_speech_tts_bidirection_ws_url: DEFAULT_TTS_BIDIRECTION_WS_URL.to_string(),
volcengine_speech_tts_sse_url: DEFAULT_TTS_SSE_URL.to_string(),
volcengine_speech_request_timeout_ms: DEFAULT_SPEECH_REQUEST_TIMEOUT_MS,
draft_asset_generation_max_concurrent_requests: 4,
ark_character_video_base_url: DEFAULT_ARK_BASE_URL.to_string(),
ark_character_video_base_url: String::new(),
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(),
ark_character_video_model: String::new(),
character_animation_ffmpeg_path: "ffmpeg".to_string(),
character_animation_ffprobe_path: "ffprobe".to_string(),
character_animation_frame_extract_timeout_ms: 120_000,
@@ -472,6 +506,8 @@ impl AppConfig {
read_first_non_empty_env(&["GENARRATIVE_LLM_BASE_URL", "LLM_BASE_URL"])
{
config.llm_base_url = llm_base_url;
} else if config.llm_provider == LlmProvider::Ark {
config.llm_base_url = DEFAULT_ARK_BASE_URL.to_string();
}
config.llm_api_key =
@@ -553,10 +589,73 @@ impl AppConfig {
config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]);
if let Some(apimart_image_request_timeout_ms) =
read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"])
if let Some(vector_engine_base_url) = read_first_non_empty_env(&["VECTOR_ENGINE_BASE_URL"])
{
config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms;
config.vector_engine_base_url = vector_engine_base_url;
}
config.vector_engine_api_key = read_first_non_empty_env(&["VECTOR_ENGINE_API_KEY"]);
if let Some(vector_engine_image_request_timeout_ms) =
read_first_positive_u64_env(&["VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"])
{
config.vector_engine_image_request_timeout_ms = vector_engine_image_request_timeout_ms;
}
if let Some(vector_engine_audio_request_timeout_ms) =
read_first_positive_u64_env(&["VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS"])
{
config.vector_engine_audio_request_timeout_ms = vector_engine_audio_request_timeout_ms;
}
if let Some(hyper3d_base_url) =
read_first_non_empty_env(&["HYPER3D_BASE_URL", "RODIN_BASE_URL"])
{
config.hyper3d_base_url = hyper3d_base_url;
}
config.hyper3d_api_key = read_first_non_empty_env(&["HYPER3D_API_KEY", "RODIN_API_KEY"]);
if let Some(hyper3d_model_request_timeout_ms) = read_first_positive_u64_env(&[
"HYPER3D_MODEL_REQUEST_TIMEOUT_MS",
"RODIN_MODEL_REQUEST_TIMEOUT_MS",
]) {
config.hyper3d_model_request_timeout_ms = hyper3d_model_request_timeout_ms;
}
config.volcengine_speech_api_key =
read_first_non_empty_env(&["VOLCENGINE_SPEECH_API_KEY", "VOLCENGINE_API_KEY"]);
config.volcengine_speech_app_id =
read_first_non_empty_env(&["VOLCENGINE_SPEECH_APP_ID", "VOLCENGINE_ACCESS_KEY_ID"]);
config.volcengine_speech_access_key = read_first_non_empty_env(&[
"VOLCENGINE_SPEECH_ACCESS_KEY",
"VOLCENGINE_SECRET_ACCESS_KEY",
]);
if let Some(asr_resource_id) =
read_first_non_empty_env(&["VOLCENGINE_SPEECH_ASR_RESOURCE_ID"])
{
config.volcengine_speech_asr_resource_id = asr_resource_id;
}
if let Some(tts_resource_id) =
read_first_non_empty_env(&["VOLCENGINE_SPEECH_TTS_RESOURCE_ID"])
{
config.volcengine_speech_tts_resource_id = tts_resource_id;
}
if let Some(asr_ws_url) = read_first_non_empty_env(&["VOLCENGINE_SPEECH_ASR_WS_URL"]) {
config.volcengine_speech_asr_ws_url = asr_ws_url;
}
if let Some(tts_bidirection_ws_url) =
read_first_non_empty_env(&["VOLCENGINE_SPEECH_TTS_BIDIRECTION_WS_URL"])
{
config.volcengine_speech_tts_bidirection_ws_url = tts_bidirection_ws_url;
}
if let Some(tts_sse_url) = read_first_non_empty_env(&["VOLCENGINE_SPEECH_TTS_SSE_URL"]) {
config.volcengine_speech_tts_sse_url = tts_sse_url;
}
if let Some(request_timeout_ms) =
read_first_positive_u64_env(&["VOLCENGINE_SPEECH_REQUEST_TIMEOUT_MS"])
{
config.volcengine_speech_request_timeout_ms = request_timeout_ms;
}
if let Some(max_concurrent_requests) = read_first_usize_env(&[
@@ -573,6 +672,8 @@ impl AppConfig {
"LLM_BASE_URL",
]) {
config.ark_character_video_base_url = ark_character_video_base_url;
} else if config.llm_provider == LlmProvider::Ark {
config.ark_character_video_base_url = DEFAULT_ARK_BASE_URL.to_string();
}
config.ark_character_video_api_key = read_first_non_empty_env(&[
@@ -832,11 +933,118 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
#[cfg(test)]
mod tests {
use super::AppConfig;
use super::{AppConfig, LlmProvider};
use std::sync::{Mutex, OnceLock};
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
#[test]
fn default_keeps_non_public_model_and_base_url_empty() {
let config = AppConfig::default();
assert!(config.llm_model.is_empty());
assert!(config.llm_base_url.is_empty());
assert!(config.apimart_base_url.is_empty());
assert!(config.vector_engine_base_url.is_empty());
assert!(config.ark_character_video_base_url.is_empty());
assert_eq!(config.hyper3d_base_url, "https://api.hyper3d.com/api/v2");
assert!(config.ark_character_video_model.is_empty());
assert!(config.dashscope_scene_image_model.is_empty());
assert!(config.dashscope_reference_image_model.is_empty());
assert!(config.dashscope_cover_image_model.is_empty());
assert_eq!(
config.dashscope_base_url,
"https://dashscope.aliyuncs.com/api/v1"
);
assert_eq!(config.sms_endpoint, "dypnsapi.aliyuncs.com");
assert_eq!(
config.wechat_authorize_endpoint,
"https://open.weixin.qq.com/connect/qrconnect"
);
}
#[test]
fn from_env_reads_non_public_models_and_urls() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("GENARRATIVE_LLM_PROVIDER");
std::env::remove_var("GENARRATIVE_LLM_BASE_URL");
std::env::remove_var("GENARRATIVE_LLM_MODEL");
std::env::remove_var("APIMART_BASE_URL");
std::env::remove_var("VECTOR_ENGINE_BASE_URL");
std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("HYPER3D_BASE_URL");
std::env::remove_var("DASHSCOPE_SCENE_IMAGE_MODEL");
std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL");
std::env::remove_var("DASHSCOPE_COVER_IMAGE_MODEL");
std::env::remove_var("ARK_CHARACTER_VIDEO_BASE_URL");
std::env::remove_var("ARK_CHARACTER_VIDEO_MODEL");
std::env::set_var("GENARRATIVE_LLM_PROVIDER", "openai-compatible");
std::env::set_var(
"GENARRATIVE_LLM_BASE_URL",
"https://llm.internal.example/v1",
);
std::env::set_var("GENARRATIVE_LLM_MODEL", "internal-text-model");
std::env::set_var("APIMART_BASE_URL", "https://responses.internal.example/v1");
std::env::set_var("VECTOR_ENGINE_BASE_URL", "https://vector.internal.example");
std::env::set_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS", "210000");
std::env::set_var("HYPER3D_BASE_URL", "https://model.internal.example/api/v2");
std::env::set_var("DASHSCOPE_SCENE_IMAGE_MODEL", "scene-model");
std::env::set_var("DASHSCOPE_REFERENCE_IMAGE_MODEL", "reference-model");
std::env::set_var("DASHSCOPE_COVER_IMAGE_MODEL", "cover-model");
std::env::set_var(
"ARK_CHARACTER_VIDEO_BASE_URL",
"https://video.internal.example/v1",
);
std::env::set_var("ARK_CHARACTER_VIDEO_MODEL", "video-model");
}
let config = AppConfig::from_env();
assert_eq!(config.llm_provider, LlmProvider::OpenAiCompatible);
assert_eq!(config.llm_base_url, "https://llm.internal.example/v1");
assert_eq!(config.llm_model, "internal-text-model");
assert_eq!(
config.apimart_base_url,
"https://responses.internal.example/v1"
);
assert_eq!(
config.vector_engine_base_url,
"https://vector.internal.example"
);
assert_eq!(config.vector_engine_image_request_timeout_ms, 210_000);
assert_eq!(
config.hyper3d_base_url,
"https://model.internal.example/api/v2"
);
assert_eq!(config.dashscope_scene_image_model, "scene-model");
assert_eq!(config.dashscope_reference_image_model, "reference-model");
assert_eq!(config.dashscope_cover_image_model, "cover-model");
assert_eq!(
config.ark_character_video_base_url,
"https://video.internal.example/v1"
);
assert_eq!(config.ark_character_video_model, "video-model");
unsafe {
std::env::remove_var("GENARRATIVE_LLM_PROVIDER");
std::env::remove_var("GENARRATIVE_LLM_BASE_URL");
std::env::remove_var("GENARRATIVE_LLM_MODEL");
std::env::remove_var("APIMART_BASE_URL");
std::env::remove_var("VECTOR_ENGINE_BASE_URL");
std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("HYPER3D_BASE_URL");
std::env::remove_var("DASHSCOPE_SCENE_IMAGE_MODEL");
std::env::remove_var("DASHSCOPE_REFERENCE_IMAGE_MODEL");
std::env::remove_var("DASHSCOPE_COVER_IMAGE_MODEL");
std::env::remove_var("ARK_CHARACTER_VIDEO_BASE_URL");
std::env::remove_var("ARK_CHARACTER_VIDEO_MODEL");
}
}
#[test]
fn from_env_reads_spacetime_pool_size() {
let _guard = ENV_LOCK

View File

@@ -1,3 +1,5 @@
use std::io::{Cursor, Read};
use axum::{Json, extract::Extension, http::StatusCode};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use serde_json::{Value, json};
@@ -12,7 +14,7 @@ use crate::{
const MAX_DOCUMENT_INPUT_BYTES: usize = 256 * 1024;
const MAX_DOCUMENT_INPUT_BASE64_CHARS: usize = 360 * 1024;
const SUPPORTED_DOCUMENT_EXTENSIONS: &[&str] = &["txt", "md", "markdown", "csv", "json"];
const SUPPORTED_DOCUMENT_EXTENSIONS: &[&str] = &["txt", "md", "markdown", "docx", "csv", "json"];
pub async fn parse_creation_agent_document_input(
Extension(request_context): Extension<RequestContext>,
@@ -58,12 +60,8 @@ pub async fn parse_creation_agent_document_input(
);
}
let text = String::from_utf8(decoded.clone()).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "暂时只支持 UTF-8 文本文档,请转换编码后再上传。",
"field": "contentBase64",
}))
})?;
let extension = document_extension(&file_name)?;
let text = decode_document_text(&decoded, extension.as_str())?;
let normalized_text = normalize_document_text(&text);
if normalized_text.trim().is_empty() {
@@ -88,6 +86,7 @@ pub async fn parse_creation_agent_document_input(
.map(str::to_string),
size_bytes: decoded.len(),
text: normalized_text,
source_asset_id: None,
},
},
))
@@ -115,11 +114,7 @@ fn normalize_file_name(value: &str) -> Result<String, AppError> {
}
fn ensure_supported_extension(file_name: &str) -> Result<(), AppError> {
let extension = file_name
.rsplit_once('.')
.map(|(_, extension)| extension.trim().to_ascii_lowercase())
.filter(|extension| !extension.is_empty())
.ok_or_else(|| unsupported_document_error(file_name))?;
let extension = document_extension(file_name)?;
if !SUPPORTED_DOCUMENT_EXTENSIONS.contains(&extension.as_str()) {
return Err(unsupported_document_error(file_name));
@@ -128,15 +123,100 @@ fn ensure_supported_extension(file_name: &str) -> Result<(), AppError> {
Ok(())
}
fn document_extension(file_name: &str) -> Result<String, AppError> {
file_name
.rsplit_once('.')
.map(|(_, extension)| extension.trim().to_ascii_lowercase())
.filter(|extension| !extension.is_empty())
.ok_or_else(|| unsupported_document_error(file_name))
}
fn unsupported_document_error(file_name: &str) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "暂时只支持 txt、md、csv、json 文本文档。",
"message": "暂时只支持 txt、md、docx、csv、json 文档。",
"field": "fileName",
"fileName": file_name,
"supportedExtensions": SUPPORTED_DOCUMENT_EXTENSIONS,
}))
}
fn decode_document_text(bytes: &[u8], extension: &str) -> Result<String, AppError> {
if extension == "docx" {
return extract_docx_text(bytes);
}
String::from_utf8(bytes.to_vec()).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "暂时只支持 UTF-8 文本文档,请转换编码后再上传。",
"field": "contentBase64",
}))
})
}
fn extract_docx_text(bytes: &[u8]) -> Result<String, AppError> {
let reader = Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(reader).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "docx 文档结构无效,请重新选择文件。",
"field": "contentBase64",
}))
})?;
let mut document_xml = String::new();
archive
.by_name("word/document.xml")
.map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "docx 文档缺少正文内容。",
"field": "contentBase64",
}))
})?
.read_to_string(&mut document_xml)
.map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "docx 文档正文读取失败。",
"field": "contentBase64",
}))
})?;
Ok(extract_docx_visible_text(document_xml.as_str()))
}
fn extract_docx_visible_text(xml: &str) -> String {
let mut output = String::new();
let mut cursor = 0usize;
while let Some(start_offset) = xml[cursor..].find("<w:t") {
let start = cursor + start_offset;
let Some(tag_end_offset) = xml[start..].find('>') else {
break;
};
let text_start = start + tag_end_offset + 1;
let Some(end_offset) = xml[text_start..].find("</w:t>") else {
break;
};
let text_end = text_start + end_offset;
output.push_str(&decode_xml_text(&xml[text_start..text_end]));
cursor = text_end + "</w:t>".len();
if let Some(next_break) = xml[cursor..].find("<w:br") {
if next_break == 0 {
output.push('\n');
}
}
}
output
}
fn decode_xml_text(value: &str) -> String {
value
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&amp;", "&")
}
fn normalize_document_text(value: &str) -> String {
value
.trim_start_matches('\u{feff}')

View File

@@ -3,6 +3,8 @@ use serde_json::Value as JsonValue;
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
pub(crate) const CREATION_AGENT_STREAM_REQUEST_TIMEOUT_MS: u64 = 120_000;
#[derive(Clone, Copy, Debug)]
pub(crate) struct CreationAgentLlmTurnErrorMessages<'a> {
pub model_unavailable: &'a str,
@@ -64,8 +66,12 @@ where
};
turn_output.map_err(|error| match error {
CreationAgentJsonTurnFailure::Stream(_) => {
build_error(messages.generation_failed.to_string())
CreationAgentJsonTurnFailure::Stream(error) => {
tracing::warn!(
error = %error,
"创作 Agent 流式 LLM 请求失败"
);
build_error(format!("{}{error}", messages.generation_failed))
}
CreationAgentJsonTurnFailure::Parse => build_error(messages.parse_failed.to_string()),
})
@@ -134,6 +140,7 @@ fn build_creation_agent_llm_request(
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
.with_web_search(enable_web_search)
.with_request_timeout_ms(CREATION_AGENT_STREAM_REQUEST_TIMEOUT_MS)
}
pub(crate) async fn request_creation_agent_json_turn<E>(
@@ -242,9 +249,9 @@ mod tests {
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
use super::{
CreationAgentLlmTurnErrorMessages, build_creation_agent_llm_request,
extract_reply_text_from_partial_json, is_web_search_tool_unavailable,
parse_json_response_text, stream_creation_agent_json_turn,
CREATION_AGENT_STREAM_REQUEST_TIMEOUT_MS, CreationAgentLlmTurnErrorMessages,
build_creation_agent_llm_request, extract_reply_text_from_partial_json,
is_web_search_tool_unavailable, parse_json_response_text, stream_creation_agent_json_turn,
};
#[test]
@@ -273,6 +280,10 @@ mod tests {
assert_eq!(request.model.as_deref(), Some(CREATION_TEMPLATE_LLM_MODEL));
assert_eq!(request.protocol, platform_llm::LlmTextProtocol::Responses);
assert_eq!(request.messages.len(), 2);
assert_eq!(
request.request_timeout_ms,
Some(CREATION_AGENT_STREAM_REQUEST_TIMEOUT_MS)
);
}
#[test]

View File

@@ -0,0 +1,177 @@
use axum::{
Json,
body::Body,
extract::{Extension, State},
http::{Request, StatusCode},
middleware::Next,
response::Response,
};
use serde_json::{Value, json};
#[cfg(test)]
use module_runtime::build_creation_entry_config_response;
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
};
/// 中文注释:入口配置由 SpacetimeDB 表提供api-server 只负责读取同一份配置并熔断运行态路由。
pub async fn get_creation_entry_config_handler(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
let config = state.get_creation_entry_config().await.map_err(|error| {
creation_entry_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
})),
)
})?;
Ok(json_success_body(Some(&request_context), config))
}
/// 中文注释api-server 路由熔断只拦创作/运行态 API 请求,不改变前端入口展示规则。
pub async fn require_creation_entry_route_enabled(
State(state): State<AppState>,
request: Request<Body>,
next: Next,
) -> Response {
let path = request.uri().path();
let route_id = resolve_creation_entry_route_id(path);
if route_id.is_some() {
let route_id = route_id.expect("route id should exist");
match state.is_creation_entry_route_enabled(route_id).await {
Ok(true) => {}
Ok(false) => {
return AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("该玩法入口暂不可用")
.with_details(json!({
"reason": "creation_entry_disabled",
"creationTypeId": route_id,
}))
.into();
}
Err(error) => {
return AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("读取玩法入口配置失败")
.with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
.into();
}
}
}
next.run(request).await
}
pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
let normalized = path.trim_end_matches('/');
if normalized.starts_with("/api/runtime/puzzle") {
return Some("puzzle");
}
if normalized.starts_with("/api/runtime/match3d") {
return Some("match3d");
}
if normalized.starts_with("/api/runtime/square-hole") {
return Some("square-hole");
}
if normalized.starts_with("/api/runtime/big-fish") {
return Some("big-fish");
}
if normalized.starts_with("/api/runtime/visual-novel") {
return Some("visual-novel");
}
if normalized.starts_with("/api/creation/visual-novel") {
return Some("visual-novel");
}
None
}
fn creation_entry_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
#[cfg(test)]
pub(crate) fn test_creation_entry_config_response()
-> shared_contracts::creation_entry_config::CreationEntryConfigResponse {
build_creation_entry_config_response(module_runtime::CreationEntryConfigSnapshot {
config_id: module_runtime::CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(),
start_card: module_runtime::CreationEntryStartCardSnapshot {
title: module_runtime::DEFAULT_CREATION_ENTRY_START_TITLE.to_string(),
description: module_runtime::DEFAULT_CREATION_ENTRY_START_DESCRIPTION.to_string(),
idle_badge: module_runtime::DEFAULT_CREATION_ENTRY_START_IDLE_BADGE.to_string(),
busy_badge: module_runtime::DEFAULT_CREATION_ENTRY_START_BUSY_BADGE.to_string(),
},
type_modal: module_runtime::CreationEntryTypeModalSnapshot {
title: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(),
description: module_runtime::DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(),
},
creation_types: vec![
test_creation_type("rpg", false, true, 10),
test_creation_type("big-fish", false, true, 20),
test_creation_type("puzzle", true, true, 30),
test_creation_type("match3d", true, true, 40),
test_creation_type("square-hole", false, true, 50),
test_creation_type("visual-novel", true, false, 60),
test_creation_type("airp", true, false, 70),
test_creation_type("creative-agent", false, true, 80),
],
updated_at_micros: 0,
})
}
#[cfg(test)]
fn test_creation_type(
id: &str,
visible: bool,
open: bool,
sort_order: i32,
) -> module_runtime::CreationEntryTypeSnapshot {
module_runtime::CreationEntryTypeSnapshot {
id: id.to_string(),
title: id.to_string(),
subtitle: "测试入口".to_string(),
badge: "测试".to_string(),
image_src: format!("/creation-type-references/{id}.webp"),
visible,
open,
sort_order,
updated_at_micros: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_runtime_paths_to_creation_type_ids() {
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/puzzle/works"),
Some("puzzle"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/match3d/runs/run-1"),
Some("match3d"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/square-hole/runs/run-1"),
Some("square-hole"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/visual-novel/works"),
Some("visual-novel"),
);
assert_eq!(
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
Some("visual-novel"),
);
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
use axum::response::sse::Event;
use serde::Serialize;
use serde_json::{Value, json};
use shared_contracts::creative_agent::{CreativeAgentErrorEvent, CreativeAgentSseEventType};
pub fn creative_sse_json_event<T>(event: CreativeAgentSseEventType, payload: T) -> Event
where
T: Serialize,
{
let event_name = creative_event_name(event);
match serde_json::to_value(payload)
.ok()
.and_then(|value| Event::default().event(event_name).json_data(value).ok())
{
Some(event) => event,
None => creative_sse_error_event(None, "SSE_SERIALIZE_FAILED", "SSE payload 序列化失败"),
}
}
pub fn creative_sse_json_value_event(event_name: &str, payload: Value) -> Event {
Event::default()
.event(event_name)
.json_data(payload)
.unwrap_or_else(|_| {
creative_sse_error_event(None, "SSE_SERIALIZE_FAILED", "SSE payload 序列化失败")
})
}
pub fn creative_sse_error_event(
session_id: Option<String>,
code: impl Into<String>,
message: impl Into<String>,
) -> Event {
let payload = serde_json::to_string(&CreativeAgentErrorEvent {
session_id,
code: code.into(),
message: message.into(),
recoverable: false,
})
.unwrap_or_else(|_| {
json!({
"sessionId": null,
"code": "SSE_ERROR_SERIALIZE_FAILED",
"message": "SSE 错误事件序列化失败",
"recoverable": false,
})
.to_string()
});
Event::default().event("error").data(payload)
}
fn creative_event_name(event: CreativeAgentSseEventType) -> &'static str {
match event {
CreativeAgentSseEventType::Stage => "stage",
CreativeAgentSseEventType::AgentMessageDelta => "agent_message_delta",
CreativeAgentSseEventType::ThoughtSummaryDelta => "thought_summary_delta",
CreativeAgentSseEventType::PuzzleTemplateCatalog => "puzzle_template_catalog",
CreativeAgentSseEventType::PuzzleTemplateSelection => "puzzle_template_selection",
CreativeAgentSseEventType::PuzzleCostRange => "puzzle_cost_range",
CreativeAgentSseEventType::PuzzleLevelPlan => "puzzle_level_plan",
CreativeAgentSseEventType::ToolStarted => "tool_started",
CreativeAgentSseEventType::ToolCompleted => "tool_completed",
CreativeAgentSseEventType::Reflection => "reflection",
CreativeAgentSseEventType::TargetSession => "target_session",
CreativeAgentSseEventType::Session => "session",
CreativeAgentSseEventType::Error => "error",
CreativeAgentSseEventType::Done => "done",
}
}

View File

@@ -74,6 +74,7 @@ use crate::{
request_context::RequestContext,
state::AppState,
work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3;
@@ -827,7 +828,7 @@ pub async fn record_custom_world_gallery_play(
State(state): State<AppState>,
Path((owner_user_id, profile_id)): Path<(String, String)>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() {
return Err(custom_world_error_response(
@@ -842,8 +843,8 @@ pub async fn record_custom_world_gallery_play(
let mutation = state
.spacetime_client()
.record_custom_world_profile_play(CustomWorldProfilePlayReportRecordInput {
owner_user_id,
profile_id,
owner_user_id: owner_user_id.clone(),
profile_id: profile_id.clone(),
played_at_micros: current_utc_micros(),
})
.await
@@ -851,6 +852,20 @@ pub async fn record_custom_world_gallery_play(
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
record_work_play_start_after_success(
&state,
&request_context,
WorkPlayTrackingDraft::new(
"custom-world",
profile_id.clone(),
&authenticated,
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/play",
)
.owner_user_id(owner_user_id.clone())
.profile_id(profile_id.clone()),
)
.await;
Ok(json_success_body(
Some(&request_context),
CustomWorldGalleryDetailResponse {

View File

@@ -28,7 +28,7 @@ use webp::Encoder as WebpEncoder;
use crate::{
api_response::json_success_body,
asset_billing::execute_billable_asset_operation,
asset_billing::{execute_billable_asset_operation, execute_billable_asset_operation_with_cost},
auth::AuthenticatedAccessToken,
custom_world_result_prompts::{
build_result_entity_system_prompt, build_result_entity_user_prompt,
@@ -115,6 +115,12 @@ pub(crate) struct CustomWorldCoverUploadRequest {
crop_rect: CustomWorldCoverCropRect,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CustomWorldOpeningCgGenerateRequest {
profile: Value,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct GeneratedAssetResponse {
@@ -133,6 +139,38 @@ struct GeneratedAssetResponse {
actual_prompt: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct GeneratedOpeningCgResponse {
opening_cg: CustomWorldOpeningCgProfileResponse,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CustomWorldOpeningCgProfileResponse {
id: String,
status: &'static str,
storyboard_image_src: String,
storyboard_asset_id: String,
video_src: String,
video_asset_id: String,
poster_image_src: Option<String>,
poster_asset_id: Option<String>,
storyboard_prompt: String,
video_prompt: String,
image_model: &'static str,
video_model: String,
aspect_ratio: &'static str,
image_size: &'static str,
video_resolution: &'static str,
duration_seconds: u32,
point_cost: u64,
estimated_wait_minutes: u32,
generated_at: String,
updated_at: String,
error_message: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct GeneratedCustomWorldSceneImage {
pub image_src: String,
@@ -317,6 +355,22 @@ struct DownloadedRemoteImage {
}
const RPG_SCENE_IMAGE_MODEL: &str = GPT_IMAGE_2_MODEL;
const OPENING_CG_POINTS_COST: u64 = 80;
const OPENING_CG_ESTIMATED_WAIT_MINUTES: u32 = 10;
const OPENING_CG_IMAGE_SIZE_LABEL: &str = "2k";
const OPENING_CG_STORYBOARD_IMAGE_SIZE: &str = "2048x1152";
const OPENING_CG_VIDEO_PROMPT: &str = "利用参考图作为故事板,生成一段连贯的动画,没有旁白";
const OPENING_CG_VIDEO_RESOLUTION: &str = "480p";
const OPENING_CG_VIDEO_RATIO: &str = "16:9";
const OPENING_CG_VIDEO_DURATION_SECONDS: u32 = 15;
const OPENING_CG_VIDEO_MIN_REQUEST_TIMEOUT_MS: u64 = 600_000;
const OPENING_CG_ASPECT_RATIO: &str = "16:9";
const OPENING_CG_STORYBOARD_ASSET_KIND: &str = "custom_world_opening_cg_storyboard";
const OPENING_CG_VIDEO_ASSET_KIND: &str = "custom_world_opening_cg_video";
const OPENING_CG_ENTITY_KIND: &str = "custom_world_profile";
const OPENING_CG_STORYBOARD_SLOT: &str = "opening_cg_storyboard";
const OPENING_CG_VIDEO_SLOT: &str = "opening_cg_video";
const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000;
struct CoverPromptContext {
opening_act_title: String,
@@ -336,6 +390,39 @@ struct NormalizedSceneImageRequest {
reference_image_src: Option<String>,
}
struct NormalizedOpeningCgRequest {
profile_id: Option<String>,
world_name: String,
opening_cg_id: String,
storyboard_prompt: String,
video_prompt: String,
player_role_image_src: String,
opening_scene_image_src: String,
}
struct ArkVideoSettings {
base_url: String,
api_key: String,
request_timeout_ms: u64,
model: String,
}
struct GeneratedOpeningCgStoryboard {
image_src: String,
asset_id: String,
}
struct GeneratedOpeningCgVideo {
video_src: String,
asset_id: String,
}
struct DownloadedRemoteVideo {
mime_type: String,
extension: String,
bytes: Vec<u8>,
}
#[derive(Debug)]
struct NormalizedCropRect {
left: u32,
@@ -496,7 +583,7 @@ pub async fn generate_custom_world_scene_image(
.map(downloaded_openai_to_custom_world_image)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"provider": "vector-engine",
"message": "场景图片生成成功但未返回图片。",
}))
})?;
@@ -600,7 +687,7 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
.map(downloaded_openai_to_custom_world_image)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"provider": "vector-engine",
"message": "场景图片生成成功但未返回图片。",
}))
})?;
@@ -884,6 +971,119 @@ pub async fn upload_custom_world_cover_image(
Ok(json_success_body(Some(&request_context), asset))
}
pub async fn generate_custom_world_opening_cg(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<CustomWorldOpeningCgGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
custom_world_ai_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-opening-cg",
"message": error.body_text(),
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let normalized = normalize_opening_cg_request(&payload.profile)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
require_openai_image_settings(&state)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
require_ark_video_settings(&state)
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let opening_cg_id = normalized.opening_cg_id.clone();
let generated = execute_billable_asset_operation_with_cost(
&state,
&owner_user_id,
"custom_world_opening_cg",
opening_cg_id.as_str(),
OPENING_CG_POINTS_COST,
async {
let image_settings = require_openai_image_settings(&state)?;
let image_http_client = build_openai_image_http_client(&image_settings)?;
let video_settings = require_ark_video_settings(&state)?;
let video_http_client = build_upstream_http_client(video_settings.request_timeout_ms)?;
let player_role_reference = resolve_reference_image_as_data_url(
&state,
&image_http_client,
normalized.player_role_image_src.as_str(),
"playerRoleImageSrc",
)
.await?;
let opening_scene_reference = resolve_reference_image_as_data_url(
&state,
&image_http_client,
normalized.opening_scene_image_src.as_str(),
"openingSceneImageSrc",
)
.await?;
let storyboard = generate_opening_cg_storyboard(
&state,
&owner_user_id,
&image_http_client,
&image_settings,
&normalized,
&[player_role_reference, opening_scene_reference],
)
.await?;
let storyboard_reference = resolve_reference_image_as_data_url(
&state,
&image_http_client,
storyboard.image_src.as_str(),
"storyboardImageSrc",
)
.await?;
let video = generate_opening_cg_video(
&state,
&owner_user_id,
&video_http_client,
&video_settings,
&normalized,
storyboard_reference.as_str(),
)
.await?;
let generated_at = current_utc_iso_text();
Ok(CustomWorldOpeningCgProfileResponse {
id: opening_cg_id.clone(),
status: "ready",
storyboard_image_src: storyboard.image_src,
storyboard_asset_id: storyboard.asset_id,
video_src: video.video_src,
video_asset_id: video.asset_id,
poster_image_src: None,
poster_asset_id: None,
storyboard_prompt: normalized.storyboard_prompt.clone(),
video_prompt: normalized.video_prompt.clone(),
image_model: GPT_IMAGE_2_MODEL,
video_model: video_settings.model,
aspect_ratio: OPENING_CG_ASPECT_RATIO,
image_size: OPENING_CG_IMAGE_SIZE_LABEL,
video_resolution: OPENING_CG_VIDEO_RESOLUTION,
duration_seconds: OPENING_CG_VIDEO_DURATION_SECONDS,
point_cost: OPENING_CG_POINTS_COST,
estimated_wait_minutes: OPENING_CG_ESTIMATED_WAIT_MINUTES,
generated_at: generated_at.clone(),
updated_at: generated_at,
error_message: None,
})
},
)
.await
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
Ok(json_success_body(
Some(&request_context),
GeneratedOpeningCgResponse {
opening_cg: generated,
},
))
}
async fn persist_custom_world_asset(
state: &AppState,
owner_user_id: &str,
@@ -974,6 +1174,347 @@ async fn persist_custom_world_asset(
Ok(response)
}
async fn generate_opening_cg_storyboard(
state: &AppState,
owner_user_id: &str,
http_client: &reqwest::Client,
settings: &crate::openai_image_generation::OpenAiImageSettings,
normalized: &NormalizedOpeningCgRequest,
reference_images: &[String],
) -> Result<GeneratedOpeningCgStoryboard, AppError> {
let generated = create_openai_image_generation(
http_client,
settings,
normalized.storyboard_prompt.as_str(),
None,
OPENING_CG_STORYBOARD_IMAGE_SIZE,
1,
reference_images,
"开局 CG 故事板生成失败",
)
.await?;
let downloaded = generated
.images
.into_iter()
.next()
.map(downloaded_openai_to_custom_world_image)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine",
"message": "开局 CG 故事板生成成功但未返回图片。",
}))
})?;
let asset_id = format!("opening-cg-storyboard-{}", current_utc_millis());
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
sanitize_storage_segment(
normalized
.profile_id
.as_deref()
.unwrap_or(normalized.world_name.as_str()),
"world",
),
"opening-cg".to_string(),
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
],
file_name: format!("storyboard.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: OPENING_CG_STORYBOARD_ASSET_KIND,
entity_kind: OPENING_CG_ENTITY_KIND,
entity_id: normalized
.profile_id
.clone()
.unwrap_or_else(|| normalized.world_name.clone()),
profile_id: normalized.profile_id.clone(),
slot: OPENING_CG_STORYBOARD_SLOT,
source_job_id: Some(generated.task_id.clone()),
};
let asset = persist_custom_world_asset(
state,
owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some(GPT_IMAGE_2_MODEL.to_string()),
size: Some(OPENING_CG_STORYBOARD_IMAGE_SIZE.to_string()),
task_id: Some(generated.task_id.clone()),
prompt: Some(normalized.storyboard_prompt.clone()),
actual_prompt: generated.actual_prompt,
},
)
.await?;
Ok(GeneratedOpeningCgStoryboard {
image_src: asset.image_src,
asset_id,
})
}
async fn generate_opening_cg_video(
state: &AppState,
owner_user_id: &str,
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
normalized: &NormalizedOpeningCgRequest,
storyboard_reference_data_url: &str,
) -> Result<GeneratedOpeningCgVideo, AppError> {
let upstream_task_id = create_ark_storyboard_to_video_task(
http_client,
settings,
normalized.video_prompt.as_str(),
storyboard_reference_data_url,
)
.await?;
let video_url =
wait_for_ark_content_generation_task(http_client, settings, upstream_task_id.as_str())
.await?;
let downloaded =
download_generated_video(http_client, video_url.as_str(), "下载开局 CG 视频失败").await?;
let asset_id = format!("opening-cg-video-{}", current_utc_millis());
let video_src = persist_opening_cg_video_asset(
state,
owner_user_id,
normalized,
asset_id.as_str(),
Some(upstream_task_id.clone()),
downloaded,
)
.await?;
Ok(GeneratedOpeningCgVideo {
video_src,
asset_id,
})
}
async fn persist_opening_cg_video_asset(
state: &AppState,
owner_user_id: &str,
normalized: &NormalizedOpeningCgRequest,
asset_id: &str,
source_job_id: Option<String>,
video: DownloadedRemoteVideo,
) -> Result<String, AppError> {
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
sanitize_storage_segment(
normalized
.profile_id
.as_deref()
.unwrap_or(normalized.world_name.as_str()),
"world",
),
"opening-cg".to_string(),
sanitize_storage_segment(normalized.opening_cg_id.as_str(), "opening-cg"),
],
file_name: format!("opening.{}", video.extension),
content_type: video.mime_type,
body: video.bytes,
asset_kind: OPENING_CG_VIDEO_ASSET_KIND,
entity_kind: OPENING_CG_ENTITY_KIND,
entity_id: normalized
.profile_id
.clone()
.unwrap_or_else(|| normalized.world_name.clone()),
profile_id: normalized.profile_id.clone(),
slot: OPENING_CG_VIDEO_SLOT,
source_job_id,
};
let asset = persist_custom_world_asset(
state,
owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.to_string(),
source_type: "generated".to_string(),
model: Some("ark-seedance".to_string()),
size: Some(format!(
"{}:{}:{}s",
OPENING_CG_VIDEO_RESOLUTION,
OPENING_CG_VIDEO_RATIO,
OPENING_CG_VIDEO_DURATION_SECONDS
)),
task_id: None,
prompt: Some(normalized.video_prompt.clone()),
actual_prompt: None,
},
)
.await?;
Ok(asset.image_src)
}
async fn create_ark_storyboard_to_video_task(
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
prompt: &str,
storyboard_reference_data_url: &str,
) -> Result<String, AppError> {
let response = http_client
.post(format!("{}/contents/generations/tasks", settings.base_url))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.json(&json!({
"model": settings.model,
"content": [
{
"type": "text",
"text": prompt,
},
{
"type": "image_url",
"image_url": {
"url": storyboard_reference_data_url,
},
"role": "reference_image",
}
],
"resolution": OPENING_CG_VIDEO_RESOLUTION,
"ratio": OPENING_CG_VIDEO_RATIO,
"duration": OPENING_CG_VIDEO_DURATION_SECONDS,
"watermark": false,
"audio": true,
"generate_audio": true,
"web_search": true,
"enable_web_search": true,
}))
.send()
.await
.map_err(|error| {
map_ark_video_request_error(format!("请求 Seedance 视频服务失败:{error}"))
})?;
let status = response.status();
let text = response.text().await.map_err(|error| {
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
})?;
if !status.is_success() {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"创建开局 CG 视频任务失败。",
));
}
let payload = parse_ark_video_json_payload(text.as_str(), "创建开局 CG 视频任务失败。")?;
extract_ark_task_id(&payload.payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频任务未返回任务 id。",
}))
})
}
async fn wait_for_ark_content_generation_task(
http_client: &reqwest::Client,
settings: &ArkVideoSettings,
task_id: &str,
) -> Result<String, AppError> {
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
while Instant::now() < deadline {
let response = http_client
.get(format!(
"{}/contents/generations/tasks/{}",
settings.base_url, task_id
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_ark_video_request_error(format!("查询 Seedance 视频任务失败:{error}"))
})?;
let status = response.status();
let text = response.text().await.map_err(|error| {
map_ark_video_request_error(format!("读取 Seedance 视频任务响应失败:{error}"))
})?;
if !status.is_success() {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"查询开局 CG 视频任务失败。",
));
}
let payload = parse_ark_video_json_payload(text.as_str(), "查询开局 CG 视频任务失败。")?;
if let Some(video_url) = extract_video_url(&payload.payload) {
return Ok(video_url);
}
let normalized_status = normalize_generation_task_status(
extract_generation_task_status(&payload.payload).as_str(),
);
if is_completed_generation_task_status(normalized_status.as_str()) {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频任务完成但没有返回 video_url。",
"taskId": task_id,
})),
);
}
if is_failed_generation_task_status(normalized_status.as_str()) {
return Err(parse_ark_video_upstream_error(
text.as_str(),
"开局 CG 视频任务执行失败。",
));
}
sleep(Duration::from_millis(ARK_VIDEO_TASK_POLL_INTERVAL_MS)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": "开局 CG 视频生成超时,请稍后重试。",
"taskId": task_id,
})),
)
}
async fn download_generated_video(
http_client: &reqwest::Client,
video_url: &str,
fallback_message: &str,
) -> Result<DownloadedRemoteVideo, AppError> {
let response = http_client
.get(video_url)
.send()
.await
.map_err(|error| map_ark_video_request_error(format!("{fallback_message}{error}")))?;
let status = response.status();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("video/mp4")
.to_string();
let body = response
.bytes()
.await
.map_err(|error| map_ark_video_request_error(format!("{fallback_message}{error}")))?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": fallback_message,
"status": status.as_u16(),
})),
);
}
let normalized_mime_type = normalize_downloaded_video_mime_type(content_type.as_str());
Ok(DownloadedRemoteVideo {
extension: video_mime_to_extension(normalized_mime_type.as_str()).to_string(),
mime_type: normalized_mime_type,
bytes: body.to_vec(),
})
}
fn build_asset_metadata(
asset_kind: &str,
owner_user_id: &str,
@@ -1225,6 +1766,175 @@ fn normalize_scene_image_request(
})
}
fn normalize_opening_cg_request(profile: &Value) -> Result<NormalizedOpeningCgRequest, AppError> {
let object = profile.as_object().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-opening-cg",
"message": "profile 必须是 JSON object",
}))
})?;
let world_name = read_string_field(object, "name").unwrap_or_else(|| "未命名世界".to_string());
let profile_id = read_string_field(object, "id");
let world_tone = read_string_field(object, "tone")
.ok_or_else(|| missing_opening_cg_field_error("世界基调缺失,无法生成开局 CG。"))?;
let world_summary = read_string_field(object, "summary")
.ok_or_else(|| missing_opening_cg_field_error("世界概述缺失,无法生成开局 CG。"))?;
let core_conflicts = read_string_array_field(object, "coreConflicts");
if core_conflicts.is_empty() {
return Err(missing_opening_cg_field_error(
"核心冲突缺失,无法生成开局 CG。",
));
}
let player_role = object
.get("playableNpcs")
.and_then(Value::as_array)
.and_then(|roles| roles.first())
.and_then(Value::as_object)
.ok_or_else(|| missing_opening_cg_field_error("缺少玩家扮演角色。"))?;
let player_role_image_src = read_string_field(player_role, "imageSrc")
.ok_or_else(|| missing_opening_cg_field_error("玩家扮演角色缺少角色参考图。"))?;
let player_role_brief = build_opening_cg_player_role_brief(player_role);
let opening_scene_image_src = profile
.pointer("/sceneChapterBlueprints/0/acts/0/backgroundImageSrc")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(|| {
missing_opening_cg_field_error("首个场景第一幕背景图缺失,无法生成开局 CG。")
})?;
let opening_cg_id = format!("opening-cg-{}", current_utc_millis());
let storyboard_prompt = build_opening_cg_storyboard_prompt(
world_tone.as_str(),
player_role_brief.as_str(),
world_summary.as_str(),
core_conflicts.as_slice(),
);
Ok(NormalizedOpeningCgRequest {
profile_id,
world_name,
opening_cg_id,
storyboard_prompt,
video_prompt: OPENING_CG_VIDEO_PROMPT.to_string(),
player_role_image_src,
opening_scene_image_src,
})
}
fn missing_opening_cg_field_error(message: &str) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-opening-cg",
"message": message,
}))
}
fn build_opening_cg_storyboard_prompt(
world_tone: &str,
player_role_brief: &str,
world_summary: &str,
core_conflicts: &[String],
) -> String {
format!(
"以3*4网格格式创建故事板169。像素风角色扮演游戏开场动画CG。\n\n故事流程:先展示角色,展示故事背景,然后表现核心冲突,最后衔接开局场景\n故事基调:{}\n\n玩家扮演:将玩家扮演角色作为角色参考图并引用世界草稿中的角色简介:{}\n故事背景:{}\n核心冲突:{}\n开局场景:将首个场景的第一幕背景图作为参考图",
clamp_opening_cg_prompt_text(world_tone, 160),
clamp_opening_cg_prompt_text(player_role_brief, 320),
clamp_opening_cg_prompt_text(world_summary, 420),
clamp_opening_cg_prompt_text(core_conflicts.join("").as_str(), 360),
)
}
fn build_opening_cg_player_role_brief(role: &Map<String, Value>) -> String {
[
read_string_field(role, "name")
.map(|value| format!("姓名:{value}"))
.unwrap_or_default(),
read_string_field(role, "role")
.map(|value| format!("身份:{value}"))
.unwrap_or_default(),
read_string_field(role, "description")
.map(|value| format!("简介:{value}"))
.unwrap_or_default(),
read_string_field(role, "visualDescription")
.map(|value| format!("形象:{value}"))
.unwrap_or_default(),
]
.into_iter()
.filter(|value| !value.trim().is_empty())
.collect::<Vec<_>>()
.join("")
}
fn read_string_array_field(object: &Map<String, Value>, key: &str) -> Vec<String> {
object
.get(key)
.and_then(Value::as_array)
.map(|entries| {
entries
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default()
}
fn clamp_opening_cg_prompt_text(value: &str, max_length: usize) -> String {
clamp_text(value, max_length, false)
}
fn require_ark_video_settings(state: &AppState) -> Result<ArkVideoSettings, AppError> {
let base_url = state
.config
.ark_character_video_base_url
.trim()
.trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "ark",
"reason": "ARK_CHARACTER_VIDEO_BASE_URL 未配置",
})),
);
}
let api_key = state
.config
.ark_character_video_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "ark",
"reason": "ARK_CHARACTER_VIDEO_API_KEY 未配置",
}))
})?;
Ok(ArkVideoSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state
.config
.ark_character_video_request_timeout_ms
.max(OPENING_CG_VIDEO_MIN_REQUEST_TIMEOUT_MS),
model: state.config.ark_character_video_model.clone(),
})
}
fn build_upstream_http_client(timeout_ms: u64) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-opening-cg",
"message": format!("构造上游 HTTP 客户端失败:{error}"),
}))
})
}
fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, AppError> {
// Stage 2 的真实图片生成统一走 DashScope这里先把配置缺失拦在业务入口前。
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
@@ -2143,6 +2853,20 @@ fn parse_json_payload(
})
}
fn parse_ark_video_json_payload(
raw_text: &str,
fallback_message: &str,
) -> Result<ParsedJsonPayload, AppError> {
serde_json::from_str::<Value>(raw_text)
.map(|payload| ParsedJsonPayload { payload })
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": format!("{fallback_message}:解析响应失败:{error}"),
}))
})
}
fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String {
if raw_text.trim().is_empty() {
return fallback_message.to_string();
@@ -2193,6 +2917,13 @@ fn map_dashscope_request_error(message: String) -> AppError {
}))
}
fn map_ark_video_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": message,
}))
}
fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
@@ -2200,6 +2931,13 @@ fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppEr
}))
}
fn parse_ark_video_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "ark",
"message": parse_api_error_message(raw_text, fallback_message),
}))
}
fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec<String>) {
match value {
Value::Array(entries) => {
@@ -2236,6 +2974,61 @@ fn extract_task_id(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "task_id")
}
fn extract_ark_task_id(payload: &Value) -> Option<String> {
payload
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| find_first_string_by_key(payload, "task_id"))
.or_else(|| find_first_string_by_key(payload, "taskId"))
.or_else(|| find_first_string_by_key(payload, "id"))
}
fn extract_video_url(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "video_url")
.or_else(|| find_first_string_by_key(payload, "videoUrl"))
.or_else(|| find_first_string_by_key(payload, "url"))
}
fn extract_generation_task_status(payload: &Value) -> String {
payload
.get("status")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| find_first_string_by_key(payload, "task_status"))
.or_else(|| find_first_string_by_key(payload, "status"))
.unwrap_or_default()
}
fn normalize_generation_task_status(value: &str) -> String {
value.trim().to_ascii_lowercase().replace(' ', "_")
}
fn is_completed_generation_task_status(status: &str) -> bool {
matches!(
status,
"completed" | "complete" | "done" | "finished" | "success" | "succeeded" | "succeed"
)
}
fn is_failed_generation_task_status(status: &str) -> bool {
matches!(
status,
"failed"
| "canceled"
| "cancelled"
| "error"
| "aborted"
| "rejected"
| "expired"
| "unknown"
)
}
fn extract_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_strings_by_key(payload, "image", &mut urls);
@@ -2263,6 +3056,18 @@ fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
}
}
fn normalize_downloaded_video_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or("video/mp4");
match mime_type {
"video/mp4" | "video/quicktime" | "video/webm" | "video/x-msvideo" => mime_type.to_string(),
_ => "video/mp4".to_string(),
}
}
fn mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
@@ -2272,6 +3077,15 @@ fn mime_to_extension(mime_type: &str) -> &str {
}
}
fn video_mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"video/quicktime" => "mov",
"video/webm" => "webm",
"video/x-msvideo" => "avi",
_ => "mp4",
}
}
fn conditional_prompt_line(prefix: &str, value: &str) -> String {
if value.is_empty() {
String::new()
@@ -2391,6 +3205,12 @@ fn current_utc_micros() -> i64 {
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
fn current_utc_iso_text() -> String {
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| format!("{}.000000Z", current_utc_millis()))
}
fn custom_world_ai_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
@@ -2454,9 +3274,10 @@ mod tests {
serde_json::from_slice(&body).expect("body should be valid json")
}
fn build_state_without_apimart_key() -> AppState {
fn build_state_without_vector_engine_key() -> AppState {
let mut config = AppConfig::default();
config.apimart_api_key = None;
config.vector_engine_base_url = "https://api.vectorengine.test".to_string();
config.vector_engine_api_key = None;
AppState::new(config).expect("state should build")
}
@@ -2467,8 +3288,8 @@ mod tests {
}
#[tokio::test]
async fn scene_image_returns_service_unavailable_when_apimart_missing() {
let state = build_state_without_apimart_key();
async fn scene_image_returns_service_unavailable_when_vector_engine_missing() {
let state = build_state_without_vector_engine_key();
let request_context = build_request_context("POST /api/runtime/custom-world/scene-image");
let authenticated = build_authenticated(&state);
@@ -2491,7 +3312,7 @@ mod tests {
})),
)
.await
.expect_err("missing apimart should fail");
.expect_err("missing vector engine should fail");
let payload = read_error_response(response).await;
assert_eq!(
@@ -2500,7 +3321,7 @@ mod tests {
);
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("apimart".to_string())
Value::String("vector-engine".to_string())
);
}

View File

@@ -89,6 +89,20 @@ impl IntoResponse for AppError {
}
}
impl std::fmt::Display for AppError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.body_text().as_str())
}
}
impl std::error::Error for AppError {}
impl From<AppError> for Response {
fn from(error: AppError) -> Self {
error.into_response()
}
}
fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
match status_code {
StatusCode::BAD_REQUEST => ("BAD_REQUEST", "请求参数不合法"),

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,7 @@ pub async fn proxy_llm_chat_completions(
.collect::<Vec<_>>(),
max_tokens: None,
enable_web_search: false,
request_timeout_ms: None,
};
if payload.stream {

View File

@@ -1,2 +1,3 @@
pub(crate) const RPG_STORY_LLM_MODEL: &str = "doubao-seed-character-251128";
pub(crate) const CREATION_TEMPLATE_LLM_MODEL: &str = "deepseek-v3-2-251201";
pub(crate) const PUZZLE_LEVEL_NAME_VISION_LLM_MODEL: &str = "gpt-4o-mini";

View File

@@ -23,6 +23,9 @@ mod creation_agent_anchor_templates;
mod creation_agent_chat;
mod creation_agent_document_input;
mod creation_agent_llm_turn;
mod creation_entry_config;
mod creative_agent;
mod creative_agent_sse;
mod custom_world;
mod custom_world_agent_entities;
mod custom_world_agent_turn;
@@ -34,6 +37,7 @@ mod custom_world_rpg_draft_prompts;
mod error_middleware;
mod health;
mod http_error;
mod hyper3d_generation;
mod llm;
mod llm_model_routing;
mod login_options;
@@ -61,25 +65,54 @@ mod runtime_profile;
mod runtime_save;
mod runtime_settings;
mod session_client;
mod square_hole;
mod square_hole_agent_turn;
mod state;
mod story_battles;
mod story_sessions;
mod tracking;
mod vector_engine_audio_generation;
mod visual_novel;
mod volcengine_speech;
mod wechat_auth;
mod wechat_provider;
mod work_author;
mod work_play_tracking;
use shared_logging::init_tracing;
use std::{collections::HashSet, env, fs, io, panic, thread};
use tokio::net::TcpListener;
use tokio::runtime::Builder as TokioRuntimeBuilder;
use tracing::info;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// 运行本地开发与联调时,优先从仓库根目录加载本地变量,避免手工逐项导出 OSS / APIMart 配置。
let _ = dotenvy::from_filename(".env");
let _ = dotenvy::from_filename(".env.local");
let _ = dotenvy::from_filename(".env.secrets.local");
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
fn main() -> Result<(), io::Error> {
// Windows 本地调试下 Axum 路由树和启动恢复链较重,显式放大启动线程栈,避免 debug 构建在进入监听前栈溢出。
let server_thread = thread::Builder::new()
.name("api-server-bootstrap".to_string())
.stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
.spawn(|| {
TokioRuntimeBuilder::new_multi_thread()
.enable_all()
.thread_name("api-server-worker")
.thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
.build()?
.block_on(run_server())
})?;
match server_thread.join() {
Ok(result) => result,
Err(payload) => panic::resume_unwind(payload),
}
}
async fn run_server() -> Result<(), io::Error> {
// 运行本地开发与联调时,优先从仓库根目录加载本地变量。
// 只尊重外层 shell 先注入的变量;后续本地文件需要能覆盖前序本地文件。
load_local_env_files();
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
let config = AppConfig::from_env();
@@ -97,3 +130,92 @@ async fn main() -> Result<(), std::io::Error> {
axum::serve(listener, router).await
}
fn load_local_env_files() {
let shell_env_keys = env::vars().map(|(key, _)| key).collect::<HashSet<_>>();
for path in [".env", ".env.local", ".env.secrets.local"] {
load_env_file(path, &shell_env_keys);
}
}
fn load_env_file(path: &str, shell_env_keys: &HashSet<String>) {
let Ok(raw_text) = fs::read_to_string(path) else {
return;
};
let raw_text = raw_text.trim_start_matches('\u{feff}');
for raw_line in raw_text.split('\n') {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((raw_key, raw_value)) = line.split_once('=') else {
continue;
};
let key = raw_key.trim().trim_start_matches('\u{feff}');
if !is_valid_env_key(key) || shell_env_keys.contains(key) {
continue;
}
// 这里只在启动前、Tokio runtime 创建前写入进程环境,避免并发读写 env。
unsafe {
env::set_var(key, strip_env_value(raw_value));
}
}
}
fn strip_env_value(raw_value: &str) -> String {
let value = raw_value.trim_end_matches('\r');
if value.len() >= 2 {
let bytes = value.as_bytes();
let first = bytes[0];
let last = bytes[value.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return value[1..value.len() - 1].to_string();
}
}
value.to_string()
}
fn is_valid_env_key(key: &str) -> bool {
let mut chars = key.chars();
match chars.next() {
Some(first) if first == '_' || first.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
#[cfg(test)]
mod tests {
use super::{is_valid_env_key, strip_env_value};
#[test]
fn strip_env_value_removes_wrapping_quotes() {
assert_eq!(strip_env_value("\"true\""), "true");
assert_eq!(strip_env_value("'aliyun'"), "aliyun");
assert_eq!(strip_env_value("plain\r"), "plain");
}
#[test]
fn load_env_key_can_strip_utf8_bom_prefix() {
let key = "\u{feff}SMS_AUTH_ENABLED"
.trim()
.trim_start_matches('\u{feff}');
assert_eq!(key, "SMS_AUTH_ENABLED");
}
#[test]
fn is_valid_env_key_accepts_dotenv_key_subset() {
assert!(is_valid_env_key("SMS_AUTH_ENABLED"));
assert!(is_valid_env_key("_LOCAL_KEY_1"));
assert!(!is_valid_env_key("1_BAD"));
assert!(!is_valid_env_key("BAD-KEY"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,15 @@
use std::time::{Duration, Instant};
use std::time::Duration;
use axum::http::StatusCode;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use reqwest::header;
use serde_json::{Map, Value, json};
use tokio::time::sleep;
use crate::{http_error::AppError, state::AppState};
pub(crate) const GPT_IMAGE_2_MODEL: &str = "gpt-image-2";
pub(crate) const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = "gpt-image-2-all";
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
#[derive(Clone, Debug)]
pub(crate) struct OpenAiImageSettings {
@@ -31,37 +32,41 @@ pub(crate) struct DownloadedOpenAiImage {
pub extension: String,
}
// 中文注释RPG 图片资产与拼图一样走 APIMart 的 OpenAI 兼容图片入口,避免把密钥或供应商协议暴露到前端。
// 中文注释RPG、方洞等图片资产统一走 VectorEngine GPT-image-2-all,避免把密钥或供应商协议暴露到前端。
pub(crate) fn require_openai_image_settings(
state: &AppState,
) -> Result<OpenAiImageSettings, AppError> {
let base_url = state.config.apimart_base_url.trim().trim_end_matches('/');
let base_url = state
.config
.vector_engine_base_url
.trim()
.trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "apimart",
"reason": "APIMART_BASE_URL 未配置",
"provider": VECTOR_ENGINE_PROVIDER,
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
})),
);
}
let api_key = state
.config
.apimart_api_key
.vector_engine_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "apimart",
"reason": "APIMART_API_KEY 未配置",
"provider": VECTOR_ENGINE_PROVIDER,
"reason": "VECTOR_ENGINE_API_KEY 未配置",
}))
})?;
Ok(OpenAiImageSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.config.apimart_image_request_timeout_ms.max(1),
request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1),
})
}
@@ -73,8 +78,8 @@ pub(crate) fn build_openai_image_http_client(
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "apimart",
"message": format!("构造 APIMart 图片生成 HTTP 客户端失败:{error}"),
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"),
}))
})
}
@@ -97,11 +102,12 @@ pub(crate) async fn create_openai_image_generation(
reference_images,
);
let response = http_client
.post(format!("{}/images/generations", settings.base_url))
.post(vector_engine_images_generation_url(settings))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(header::ACCEPT, "application/json")
.header(header::CONTENT_TYPE, "application/json")
.json(&request_body)
.send()
@@ -124,40 +130,31 @@ pub(crate) async fn create_openai_image_generation(
}
let response_json = parse_json_payload(response_text.as_str(), failure_context)?;
let generation_id = extract_generation_id(&response_json.payload)
.unwrap_or_else(|| format!("vector-engine-{}", current_utc_micros()));
let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt")
.or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt"));
let image_urls = extract_image_urls(&response_json.payload);
if !image_urls.is_empty() {
return download_images_from_urls(
http_client,
format!("apimart-{}", current_utc_micros()),
image_urls,
candidate_count,
)
.await;
let mut generated =
download_images_from_urls(http_client, generation_id, image_urls, candidate_count)
.await?;
generated.actual_prompt = actual_prompt;
return Ok(generated);
}
let b64_images = extract_b64_images(&response_json.payload);
if !b64_images.is_empty() {
return Ok(images_from_base64(
format!("apimart-{}", current_utc_micros()),
b64_images,
candidate_count,
));
let mut generated = images_from_base64(generation_id, b64_images, candidate_count);
generated.actual_prompt = actual_prompt;
return Ok(generated);
}
let task_id = extract_task_id(&response_json.payload).ok_or_else(|| {
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": format!("{failure_context}上游未返回 task_id 或图片"),
}))
})?;
wait_openai_generated_images(
http_client,
settings,
task_id.as_str(),
candidate_count,
failure_context,
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("{failure_context}VectorEngine 未返回图片地址"),
})),
)
.await
}
pub(crate) fn build_openai_image_request_body(
@@ -170,7 +167,7 @@ pub(crate) fn build_openai_image_request_body(
let mut body = Map::from_iter([
(
"model".to_string(),
Value::String(GPT_IMAGE_2_MODEL.to_string()),
Value::String(VECTOR_ENGINE_GPT_IMAGE_2_MODEL.to_string()),
),
(
"prompt".to_string(),
@@ -184,7 +181,7 @@ pub(crate) fn build_openai_image_request_body(
]);
if !reference_images.is_empty() {
body.insert("image_urls".to_string(), json!(reference_images));
body.insert("image".to_string(), json!(reference_images));
}
Value::Object(body)
@@ -204,109 +201,16 @@ fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> St
fn normalize_image_size(size: &str) -> String {
match size.trim() {
"1024*1024" | "1024x1024" | "1:1" => "1:1",
"1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" => "16:9",
"1024*1024" | "1024x1024" | "1:1" => "1024x1024",
"1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2048x1152"
| "2k" => "1536x1024",
"1024*1536" | "1024x1536" | "9:16" => "1024x1536",
value if !value.is_empty() => value,
_ => "1:1",
_ => "1024x1024",
}
.to_string()
}
async fn wait_openai_generated_images(
http_client: &reqwest::Client,
settings: &OpenAiImageSettings,
task_id: &str,
candidate_count: u32,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
sleep(Duration::from_secs(10)).await;
while Instant::now() < deadline {
let poll_response = http_client
.get(format!("{}/tasks/{}", settings.base_url, task_id))
.header(
header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.send()
.await
.map_err(|error| {
map_openai_image_request_error(format!(
"{failure_context}:查询图片生成任务失败:{error}"
))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_openai_image_request_error(format!(
"{failure_context}:读取图片生成任务响应失败:{error}"
))
})?;
if !poll_status.is_success() {
return Err(map_openai_image_upstream_error(
poll_status.as_u16(),
poll_text.as_str(),
failure_context,
));
}
let poll_json = parse_json_payload(poll_text.as_str(), failure_context)?;
let task_status = find_first_string_by_key(&poll_json.payload, "status")
.or_else(|| find_first_string_by_key(&poll_json.payload, "task_status"))
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(task_status.as_str(), "completed" | "succeeded" | "success") {
let image_urls = extract_image_urls(&poll_json.payload);
if image_urls.is_empty() {
let b64_images = extract_b64_images(&poll_json.payload);
if b64_images.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
json!({
"provider": "apimart",
"message": format!("{failure_context}:任务成功但未返回图片"),
}),
));
}
let mut generated =
images_from_base64(task_id.to_string(), b64_images, candidate_count);
generated.actual_prompt =
find_first_string_by_key(&poll_json.payload, "actual_prompt");
return Ok(generated);
}
let mut generated = download_images_from_urls(
http_client,
task_id.to_string(),
image_urls,
candidate_count,
)
.await?;
generated.actual_prompt = find_first_string_by_key(&poll_json.payload, "actual_prompt");
return Ok(generated);
}
if matches!(
task_status.as_str(),
"failed" | "error" | "canceled" | "cancelled" | "unknown"
) {
return Err(map_openai_image_upstream_error(
poll_status.as_u16(),
poll_text.as_str(),
failure_context,
));
}
sleep(Duration::from_secs(3)).await;
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"message": format!("{failure_context}:图片生成超时或未返回图片地址"),
})),
)
}
async fn download_images_from_urls(
http_client: &reqwest::Client,
task_id: String,
@@ -376,7 +280,7 @@ pub(crate) async fn download_remote_image(
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"provider": VECTOR_ENGINE_PROVIDER,
"message": "下载生成图片失败",
"status": status.as_u16(),
})),
@@ -399,7 +303,7 @@ fn parse_json_payload(
.map(|payload| ParsedJsonPayload { payload })
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"provider": VECTOR_ENGINE_PROVIDER,
"message": format!("{failure_context}:解析响应失败:{error}"),
"rawExcerpt": truncate_raw(raw_text),
}))
@@ -408,7 +312,7 @@ fn parse_json_payload(
fn map_openai_image_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"provider": VECTOR_ENGINE_PROVIDER,
"message": message,
}))
}
@@ -420,14 +324,14 @@ fn map_openai_image_upstream_error(
) -> AppError {
let message = parse_api_error_message(raw_text, failure_context);
tracing::warn!(
provider = "apimart",
provider = VECTOR_ENGINE_PROVIDER,
upstream_status,
raw_excerpt = %truncate_raw(raw_text),
message,
"APIMart 图片生成上游错误"
"VectorEngine 图片生成上游错误"
);
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "apimart",
"provider": VECTOR_ENGINE_PROVIDER,
"message": message,
"upstreamStatus": upstream_status,
"rawExcerpt": truncate_raw(raw_text),
@@ -515,10 +419,10 @@ fn find_first_string_by_key(value: &Value, target_key: &str) -> Option<String> {
results.into_iter().next()
}
fn extract_task_id(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "task_id")
.or_else(|| find_first_string_by_key(payload, "taskId"))
.or_else(|| find_first_string_by_key(payload, "id"))
fn extract_generation_id(payload: &Value) -> Option<String> {
find_first_string_by_key(payload, "id")
.or_else(|| find_first_string_by_key(payload, "created"))
.or_else(|| find_first_string_by_key(payload, "request_id"))
}
fn extract_image_urls(payload: &Value) -> Vec<String> {
@@ -541,6 +445,14 @@ fn extract_b64_images(payload: &Value) -> Vec<String> {
values
}
fn vector_engine_images_generation_url(settings: &OpenAiImageSettings) -> String {
if settings.base_url.ends_with("/v1") {
format!("{}/images/generations", settings.base_url)
} else {
format!("{}/v1/images/generations", settings.base_url)
}
}
fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
@@ -601,7 +513,7 @@ mod tests {
use super::*;
#[test]
fn gpt_image_2_request_normalizes_legacy_sizes_and_reference_images() {
fn gpt_image_2_request_uses_vector_engine_contract() {
let body = build_openai_image_request_body(
"雾海神殿",
Some("文字,水印"),
@@ -610,10 +522,11 @@ mod tests {
&["data:image/png;base64,abcd".to_string()],
);
assert_eq!(body["model"], GPT_IMAGE_2_MODEL);
assert_eq!(body["size"], "16:9");
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
assert_eq!(body["size"], "1536x1024");
assert_eq!(body["n"], 2);
assert_eq!(body["image_urls"][0], "data:image/png;base64,abcd");
assert!(body.get("official_fallback").is_none());
assert_eq!(body["image"][0], "data:image/png;base64,abcd");
assert!(body["prompt"].as_str().unwrap_or_default().contains("避免"));
}

View File

@@ -4,7 +4,7 @@ use axum::{
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use module_auth::{PasswordEntryError, PasswordEntryInput};
use module_auth::{AuthLoginMethod, PasswordEntryError, PasswordEntryInput};
use serde_json::json;
use shared_contracts::auth::{PasswordEntryRequest, PasswordEntryResponse};
@@ -12,7 +12,8 @@ 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,
attach_set_cookie_header, build_refresh_session_cookie_header,
create_password_auth_session, record_daily_login_tracking_event_after_auth_success,
},
http_error::AppError,
request_context::RequestContext,
@@ -49,6 +50,13 @@ pub async fn password_entry(
}
let session_client = resolve_session_client_context(&headers);
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
AuthLoginMethod::Password,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await

View File

@@ -16,6 +16,7 @@ use crate::{
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
record_daily_login_tracking_event_after_auth_success,
},
http_error::AppError,
phone_auth::map_phone_auth_error,
@@ -39,6 +40,13 @@ pub async fn change_password(
})
.await
.map_err(map_password_management_error)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
Ok(json_success_body(
Some(&request_context),
@@ -79,6 +87,20 @@ pub async fn reset_password(
&session_client,
module_auth::AuthLoginMethod::Password,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
module_auth::AuthLoginMethod::Password,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(

View File

@@ -20,6 +20,7 @@ use crate::{
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
record_daily_login_tracking_event_after_auth_success,
},
http_error::AppError,
platform_errors::{attach_retry_after, map_phone_auth_platform_store_error},
@@ -176,6 +177,13 @@ pub async fn phone_login(
&session_client,
AuthLoginMethod::Phone,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
AuthLoginMethod::Phone,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
@@ -312,6 +320,12 @@ pub fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
PhoneAuthError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
PhoneAuthError::SmsProviderInvalidConfig(_) => {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(error.to_string())
}
PhoneAuthError::SmsProviderUpstream(_) => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(error.to_string())
}
PhoneAuthError::Store(_) | PhoneAuthError::PasswordHash(_) => {
map_phone_auth_platform_store_error(error.to_string())
}

View File

@@ -4,6 +4,8 @@ pub(crate) mod character_visual;
pub(crate) mod puzzle;
pub(crate) mod rpg;
pub(crate) mod scene_background;
pub(crate) mod square_hole;
pub(crate) mod visual_novel;
pub(crate) use rpg::agent_chat;
pub(crate) use rpg::foundation_draft;

View File

@@ -58,15 +58,12 @@ mod tests {
#[test]
fn form_seed_prompt_keeps_only_user_visible_fields() {
let prompt = build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts {
title: Some(" 暖灯猫街 "),
work_description: Some("雨夜礼物拼图"),
title: None,
work_description: None,
picture_description: Some("猫咪在灯牌下回头"),
});
assert_eq!(
prompt,
"作品名称:暖灯猫街\n作品描述:雨夜礼物拼图\n画面描述:猫咪在灯牌下回头"
);
assert_eq!(prompt, "画面描述:猫咪在灯牌下回头");
}
#[test]

View File

@@ -0,0 +1,50 @@
/// 拼图首关关卡名生成提示词。
///
/// 模型只负责把画面描述压缩成可直接展示的中文关卡名;写回草稿和作品卡由业务路由处理。
pub(crate) const PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT: &str = r#"你是一个中文拼图关卡命名编辑。
你会收到拼图第一关的画面描述,部分请求还会附带已经生成完成的正式图片。请综合图片内容和画面描述,生成 1 个适合直接展示在游戏关卡卡片上的中文关卡名。
硬约束:
1. 只输出 JSON不要输出 Markdown、解释或代码块。
2. JSON 格式必须是 {"levelName":"关卡名"}。
3. levelName 必须是 2 到 8 个中文字符为主。
4. 不要输出“第一关”“画面”“拼图”“作品”等泛词。
5. 不要输出标点、引号、编号、英文、emoji 或空白。
6. 关卡名要抓住画面主体、场景和氛围,读起来像一个具体可玩的关卡。
"#;
pub(crate) fn build_puzzle_first_level_name_user_prompt(picture_description: &str) -> String {
format!(
"画面描述:{picture_description}\n\n请生成第一关关卡名。",
picture_description = picture_description.trim(),
)
}
pub(crate) fn build_puzzle_first_level_name_vision_user_text(picture_description: &str) -> String {
format!(
"画面描述:{picture_description}\n\n请观察随消息附带的正式拼图图片,生成第一关关卡名。",
picture_description = picture_description.trim(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn level_name_prompt_contains_picture_description() {
let prompt = build_puzzle_first_level_name_user_prompt("一只猫在雨夜灯牌下回头。");
assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。"));
assert!(prompt.contains("第一关关卡名"));
}
#[test]
fn level_name_vision_prompt_mentions_generated_image() {
let prompt = build_puzzle_first_level_name_vision_user_text("一只猫在雨夜灯牌下回头。");
assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。"));
assert!(prompt.contains("正式拼图图片"));
}
}

View File

@@ -1,3 +1,5 @@
pub(crate) mod agent_chat;
pub(crate) mod draft;
pub(crate) mod image;
pub(crate) mod level_name;
pub(crate) mod tags;

View File

@@ -0,0 +1,40 @@
/// 拼图作品标签生成提示词。
///
/// 这里只负责标签生成的文本契约,业务路由负责调用 LLM、解析结果和写回草稿。
pub(crate) const PUZZLE_TAG_GENERATION_SYSTEM_PROMPT: &str = r#"你是一个中文内容标签编辑。
你会收到拼图作品名称和作品描述。请生成 6 个适合作品广场检索和相似推荐的中文短标签。
硬约束:
1. 只输出 JSON不要输出 Markdown、解释或代码块。
2. JSON 格式必须是 {"tags":["标签1","标签2","标签3","标签4","标签5","标签6"]}。
3. tags 必须正好 6 个。
4. 每个标签 2 到 6 个中文字符为主,不要整句描述。
5. 不要输出空标签、重复标签、英文标签、编号、标点或井号。
6. 标签要覆盖题材、主体、氛围、场景、风格和拼图辨识点。
"#;
pub(crate) fn build_puzzle_tag_generation_user_prompt(
work_title: &str,
work_description: &str,
) -> String {
format!(
"作品名称:{work_title}\n作品描述:{work_description}\n\n请生成 6 个作品标签。",
work_title = work_title.trim(),
work_description = work_description.trim(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tag_prompt_contains_title_and_description() {
let prompt = build_puzzle_tag_generation_user_prompt("雨夜猫街", "一套暖灯街角主题拼图。");
assert!(prompt.contains("作品名称:雨夜猫街"));
assert!(prompt.contains("作品描述:一套暖灯街角主题拼图。"));
assert!(prompt.contains("6 个作品标签"));
}
}

View File

@@ -0,0 +1,225 @@
use serde_json::{Value as JsonValue, json};
use spacetime_client::{SquareHoleAgentMessageRecord, SquareHoleAgentSessionRecord};
use crate::creation_agent_chat::render_quick_fill_extra_rules;
/// 方洞挑战共创 Agent 的系统提示词。
///
/// 这里只定义模型职责与输出约束,具体的模型调用、解析和写库由方洞 Agent turn 负责。
pub(crate) const SQUARE_HOLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和百梦主共创“方洞挑战”竖屏玩法的中文创意策划。
你要把用户灵感收束成一个反直觉形状分拣小游戏:玩家会本能把形状投入对应洞口,但真实规则可能让所有形状都优先进入方洞,形成类似参考视频“所有东西都进方洞”的喜剧反差。
你必须同时输出:
1. 一段直接发给用户的中文回复 replyText
2. 当前进度 progressPercent
3. 下一轮完整可用的 nextConfig
硬约束:
1. 只能输出 JSON不能输出代码块或解释
2. nextConfig 必须是完整对象,不能只输出 patch
3. replyText 必须是自然中文不能提“字段”“结构”“JSON”“后端”等内部词
4. replyText 一次最多推进一个最关键问题
5. 如果用户要求自动配置,就直接补齐可发布草稿需要的题材、反差规则、形状数量、难度、形状选项、洞口选项和背景提示,不要继续提问
6. 默认核心反差优先使用“方洞万能”或“方洞优先”,但可以根据用户题材包装成更有记忆点的规则
7. progressPercent 范围只能是 0 到 100
8. shapeCount 只能是 6 到 24 的整数difficulty 只能是 1 到 10 的整数
9. shapeOptions 至少给 6 个,每个 shapeOptions.targetHoleId 必须指向某个 holeOptions.holeId
10. holeOptions 给 3 到 6 个,每个洞口都要有 imagePrompt
11. imagePrompt 和 backgroundPrompt 必须适合直接生成图片,不要包含 UI、文字、水印或解释
"#;
const SQUARE_HOLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextConfig": {
"themeText": "",
"twistRule": "",
"shapeCount": 12,
"difficulty": 4,
"shapeOptions": [
{
"optionId": "square-block",
"shapeKind": "square",
"label": "方块",
"targetHoleId": "hole-1",
"imagePrompt": "玩具纸箱主题的方块贴纸图,透明背景,明亮可爱,游戏资产"
}
],
"holeOptions": [
{
"holeId": "hole-1",
"holeKind": "hole-1",
"label": "洞口 1",
"imagePrompt": "玩具纸箱主题的洞口 1 贴纸图,透明背景,明亮可爱,游戏资产"
}
],
"backgroundPrompt": "玩具桌面上的纸箱洞板背景,中央留出操作空间"
}
}"#;
pub(crate) const SQUARE_HOLE_AGENT_JSON_TURN_USER_PROMPT: &str = "请按约定输出这一轮的 JSON。";
/// 方洞挑战草稿生成对话提示词脚本。
///
/// 方洞 Agent 负责输出完整玩法配置;后端会继续归一化缺失选项,避免模型偶发漏项导致草稿失败。
pub(crate) fn build_square_hole_agent_prompt(
session: &SquareHoleAgentSessionRecord,
quick_fill_requested: bool,
) -> String {
let quick_fill_rules = if quick_fill_requested {
format!(
"\n\n{}",
render_quick_fill_extra_rules(
"当前方洞挑战方向里的题材、反差规则、形状数量和难度",
"不要要求用户再提供洞口、形状、背景或难度信息",
"输出完整 nextConfig直接补齐空缺或仍过于泛化的项",
"生成结果页",
)
)
} else {
String::new()
};
format!(
"模板目标:收束成可试玩、可发布的方洞挑战玩法草稿。{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动配置:{quick_fill_requested_text}\n\n当前配置:\n{current_config}\n\n最近聊天记录:\n{chat_history}\n\n收束要求:\n1. themeText 描述本局的玩具、道具或场景题材,保持短句。\n2. twistRule 描述真实判定规则,强调每轮当前选项需要拖进指定洞口形成反直觉效果。\n3. shapeCount 决定单局形状数量,移动端短局建议 8 到 16。\n4. difficulty 决定误导强度和节奏,建议 3 到 7。\n5. shapeOptions 必须给出至少 6 个可生成贴图的候选,每个 imagePrompt 都围绕主题生成,每个 targetHoleId 指向一个洞口 holeId。\n6. holeOptions 必须给出 3 到 6 个洞口holeId 使用 hole-1、hole-2 这类稳定 IDholeKind 保持同 ID每个洞口都要有 imagePrompt。\n7. backgroundPrompt 用于生成运行态背景,必须描述画面,不要写规则说明。\n8. 用户给出明确方向时优先吸收并推进,不要机械问完所有字段。\n\n{contract}",
quick_fill_rules = quick_fill_rules,
turn = session.current_turn.saturating_add(1),
progress = session.progress_percent,
quick_fill_requested_text = if quick_fill_requested { "" } else { "" },
current_config = serialize_square_hole_session_config(session),
chat_history =
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
.unwrap_or_else(|_| "[]".to_string()),
contract = SQUARE_HOLE_AGENT_OUTPUT_CONTRACT,
)
}
fn serialize_square_hole_session_config(session: &SquareHoleAgentSessionRecord) -> String {
let shape_options: Vec<JsonValue> = session
.config
.shape_options
.iter()
.map(|option| {
json!({
"optionId": option.option_id,
"shapeKind": option.shape_kind,
"label": option.label,
"targetHoleId": option.target_hole_id,
"imagePrompt": option.image_prompt,
"imageSrc": option.image_src,
})
})
.collect();
let hole_options: Vec<JsonValue> = session
.config
.hole_options
.iter()
.map(|option| {
json!({
"holeId": option.hole_id,
"holeKind": option.hole_kind,
"label": option.label,
"imagePrompt": option.image_prompt,
"imageSrc": option.image_src,
})
})
.collect();
serde_json::to_string_pretty(&json!({
"themeText": session.config.theme_text,
"twistRule": session.config.twist_rule,
"shapeCount": session.config.shape_count,
"difficulty": session.config.difficulty,
"shapeOptions": shape_options,
"holeOptions": hole_options,
"backgroundPrompt": session.config.background_prompt,
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn build_chat_history(messages: &[SquareHoleAgentMessageRecord]) -> Vec<JsonValue> {
messages
.iter()
.map(|message| {
json!({
"role": message.role,
"kind": message.kind,
"content": message.text,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::build_square_hole_agent_prompt;
fn message(role: &str, text: &str) -> spacetime_client::SquareHoleAgentMessageRecord {
spacetime_client::SquareHoleAgentMessageRecord {
id: format!("message-{role}"),
role: role.to_string(),
kind: "chat".to_string(),
text: text.to_string(),
created_at: "2026-05-04T10:00:00.000Z".to_string(),
}
}
fn session_record() -> spacetime_client::SquareHoleAgentSessionRecord {
spacetime_client::SquareHoleAgentSessionRecord {
session_id: "square-hole-session-test".to_string(),
current_turn: 1,
progress_percent: 25,
stage: "collecting_config".to_string(),
anchor_pack: spacetime_client::SquareHoleAnchorPackRecord {
theme: anchor("theme", "题材主题", "积木纸箱"),
twist_rule: anchor("twistRule", "反差规则", ""),
shape_count: anchor("shapeCount", "形状数量", "12"),
difficulty: anchor("difficulty", "难度", "4"),
},
config: spacetime_client::SquareHoleCreatorConfigRecord {
theme_text: "积木纸箱".to_string(),
twist_rule: "方洞万能".to_string(),
shape_count: 12,
difficulty: 4,
shape_options: Vec::new(),
hole_options: Vec::new(),
background_prompt: "积木纸箱桌面背景".to_string(),
cover_image_src: None,
background_image_src: None,
},
draft: None,
messages: vec![message("user", "做成办公室文具版")],
last_assistant_reply: Some("这次可以从办公室文具题材开始。".to_string()),
published_profile_id: None,
updated_at: "2026-05-04T10:00:00.000Z".to_string(),
}
}
fn anchor(key: &str, label: &str, value: &str) -> spacetime_client::SquareHoleAnchorItemRecord {
spacetime_client::SquareHoleAnchorItemRecord {
key: key.to_string(),
label: label.to_string(),
value: value.to_string(),
status: if value.is_empty() {
"missing"
} else {
"confirmed"
}
.to_string(),
}
}
#[test]
fn quick_fill_prompt_requires_complete_config() {
let prompt = build_square_hole_agent_prompt(&session_record(), true);
assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字"));
assert!(prompt.contains("不要再继续提问"));
assert!(prompt.contains("nextConfig"));
assert!(prompt.contains("shapeOptions"));
assert!(prompt.contains("holeOptions"));
assert!(prompt.contains("backgroundPrompt"));
assert!(prompt.contains("progressPercent 直接输出为 100"));
}
}

View File

@@ -0,0 +1,690 @@
#![allow(dead_code)]
use platform_llm::{LlmMessage, LlmTextRequest};
use serde_json::{Value as JsonValue, json};
use shared_contracts::visual_novel::{VisualNovelResultDraft, VisualNovelRuntimeStep};
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
pub(crate) const VISUAL_NOVEL_CREATION_SYSTEM_PROMPT: &str = r#"你是百梦平台内的视觉小说模板创作导演。
你的任务是把用户的一句话、文档摘要或空白创建意图,生成一份可以进入结果页继续编辑的 VisualNovelResultDraft。
硬约束:
1. 只能输出一个 JSON 对象,不要输出 Markdown、代码块、解释或 UI 规则说明。
2. 输出内容必须是中文视觉小说底稿,补齐世界观、玩家身份、角色、场景、剧情阶段和开场。
3. 每个角色必须有可生成立绘的 appearance每个场景必须有可生成背景图的 description。
4. sourceMode 必须沿用输入的 idea、document 或 blank。
5. 图片、音乐、文档只能写平台资产引用或 null不能写大段 data URL。
6. 不要输出旧 TXT 播放记录、分享播放包、外部商业、运营、活动、展示横幅、交易或独立账号字段。
7. 不要发明第二套存档、发布、钱包、广场或资产系统。
8. publishReady 只有在 opening 场景、主要角色、剧情阶段和 2 到 4 个 initialChoices 都齐备时才可以为 true。
"#;
pub(crate) const VISUAL_NOVEL_RUNTIME_GM_SYSTEM_PROMPT: &str = r#"你是百梦视觉小说运行时 GM。
你的任务是读取作品底稿、当前 run snapshot、玩家动作和最近历史然后输出下一轮 VisualNovelRuntimeStep[]。
硬约束:
1. 只能输出一个 JSON 数组不要输出对象包裹、Markdown、代码块、解释或 UI 规则说明。
2. 每轮 step 数量不能超过输入的 maxAssistantStepCountPerTurn。
3. 场景变化必须先输出 scene_change。
4. 旁白使用 narration角色说话使用 dialogue转场使用 transition。
5. 需要玩家选择时必须输出 choicechoice 内每项必须有 choiceId 和 text。
6. 关键剧情事实变化使用 flag数值倾向变化使用 metric。
7. 不要让前端从 raw_text 猜业务 step不要输出未定义 step 类型。
8. 不要输出旧 TXT 播放记录、分享播放包、屏幕记录、外部商业、运营、活动或独立保存元数据。
"#;
pub(crate) const VISUAL_NOVEL_REPAIR_SYSTEM_PROMPT: &str = r#"你是视觉小说结构化输出修复器。
你的任务是把上一次模型输出修复为目标 JSON 契约。
硬约束:
1. 只能输出目标 JSON不要解释错误原因。
2. 不能新增目标契约之外的字段。
3. 不要把普通历史、运行事件或 raw_text 改写成旧 TXT 播放包、屏幕记录或分享片段。
4. 如果原文缺失必要信息,只补最小可运行占位值,并保持中文内容。
"#;
const VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT: &str = r#"{
"profileId": null,
"workTitle": "",
"workDescription": "",
"workTags": [],
"coverImageSrc": null,
"sourceMode": "idea",
"sourceAssetIds": [],
"world": {
"title": "",
"summary": "",
"background": "",
"premise": "",
"literaryStyle": "",
"playerRole": "",
"defaultTone": ""
},
"characters": [
{
"characterId": "char-main-1",
"name": "",
"gender": null,
"role": "main",
"appearance": "",
"personality": "",
"tone": "",
"background": "",
"relationshipToPlayer": "",
"imageAssets": [],
"defaultExpression": null,
"isPlayerVisible": false
}
],
"scenes": [
{
"sceneId": "scene-opening",
"name": "",
"description": "",
"backgroundImageSrc": null,
"musicSrc": null,
"ambientSoundSrc": null,
"availability": "opening",
"phaseIds": []
}
],
"storyPhases": [
{
"phaseId": "phase-opening",
"title": "",
"goal": "",
"summary": "",
"entryCondition": "",
"exitCondition": "",
"sceneIds": ["scene-opening"],
"characterIds": ["char-main-1"],
"suggestedChoices": []
}
],
"opening": {
"sceneId": "scene-opening",
"narration": "",
"speakerCharacterId": null,
"firstDialogue": null,
"initialChoices": [
{ "choiceId": "choice-opening-1", "text": "", "actionHint": null },
{ "choiceId": "choice-opening-2", "text": "", "actionHint": null }
]
},
"runtimeConfig": {
"textModeEnabled": true,
"defaultTextMode": false,
"maxHistoryEntries": 80,
"maxAssistantStepCountPerTurn": 8,
"allowFreeTextAction": true,
"allowHistoryRegeneration": true,
"attributePanelMode": "off",
"saveArchiveEnabled": true
},
"publishReady": false,
"validationIssues": [],
"updatedAt": "ISO-8601"
}"#;
const VISUAL_NOVEL_RUNTIME_OUTPUT_CONTRACT: &str = r#"[
{ "type": "scene_change", "sceneId": "scene-opening", "backgroundImageSrc": null, "musicSrc": null },
{ "type": "narration", "text": "" },
{ "type": "dialogue", "characterId": "char-main-1", "characterName": "", "expression": null, "text": "" },
{ "type": "transition", "transitionKind": "fade", "text": null },
{ "type": "flag", "key": "", "value": true },
{ "type": "metric", "key": "", "delta": 1 },
{ "type": "choice", "choices": [{ "choiceId": "choice-next-1", "text": "", "actionHint": null }] }
]"#;
#[derive(Clone, Debug)]
pub(crate) struct VisualNovelCreationPromptParams<'a> {
pub(crate) source_mode: &'a str,
pub(crate) seed_text: Option<&'a str>,
pub(crate) source_asset_ids: &'a [String],
pub(crate) document_summary: Option<&'a str>,
pub(crate) current_draft: Option<&'a JsonValue>,
pub(crate) recent_messages: &'a [JsonValue],
pub(crate) now_iso: &'a str,
}
#[derive(Clone, Debug)]
pub(crate) struct VisualNovelRuntimePromptParams<'a> {
pub(crate) work_profile: &'a JsonValue,
pub(crate) run_snapshot: &'a JsonValue,
pub(crate) runtime_action: &'a JsonValue,
pub(crate) recent_history: &'a [JsonValue],
pub(crate) max_assistant_step_count_per_turn: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum VisualNovelRepairTarget {
ResultDraft,
RuntimeSteps,
}
impl VisualNovelRepairTarget {
fn label(self) -> &'static str {
match self {
Self::ResultDraft => "VisualNovelResultDraft",
Self::RuntimeSteps => "VisualNovelRuntimeStep[]",
}
}
fn contract(self) -> &'static str {
match self {
Self::ResultDraft => VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT,
Self::RuntimeSteps => VISUAL_NOVEL_RUNTIME_OUTPUT_CONTRACT,
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct VisualNovelRepairPromptParams<'a> {
pub(crate) target: VisualNovelRepairTarget,
pub(crate) raw_text: &'a str,
pub(crate) parse_error: &'a str,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct VisualNovelPromptParseFailure {
pub(crate) target: VisualNovelRepairTarget,
pub(crate) message: String,
}
impl VisualNovelPromptParseFailure {
pub(crate) fn retryable_message(&self) -> String {
format!(
"{} 输出结构不可解析,可重试或进入 repair{}",
self.target.label(),
self.message
)
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct VisualNovelToolDescriptor {
pub(crate) name: &'static str,
pub(crate) description: &'static str,
pub(crate) input_schema: JsonValue,
}
pub(crate) fn build_visual_novel_creation_user_prompt(
params: VisualNovelCreationPromptParams<'_>,
) -> String {
json!({
"task": "generate_visual_novel_result_draft",
"sourceMode": params.source_mode,
"seedText": params.seed_text.unwrap_or("").trim(),
"sourceAssetIds": params.source_asset_ids,
"documentSummary": params.document_summary.unwrap_or("").trim(),
"currentDraft": params.current_draft,
"recentMessages": params.recent_messages,
"nowIso": params.now_iso,
"draftRequirements": {
"mainCharacters": "3 到 6 个,至少 1 个非玩家主要角色",
"scenes": "3 到 8 个,至少 1 个 opening 场景",
"storyPhases": "3 到 6 个,第一阶段可从 opening 进入",
"initialChoices": "2 到 4 个",
"runtimeConfigDefaults": "沿用契约默认值attributePanelMode 默认为 off"
},
"outputContract": VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT
})
.to_string()
}
pub(crate) fn build_visual_novel_runtime_user_prompt(
params: VisualNovelRuntimePromptParams<'_>,
) -> String {
json!({
"task": "generate_visual_novel_runtime_steps",
"workProfile": params.work_profile,
"runSnapshot": params.run_snapshot,
"runtimeAction": params.runtime_action,
"recentHistory": params.recent_history,
"maxAssistantStepCountPerTurn": params.max_assistant_step_count_per_turn,
"runtimeRules": [
"只以 step 数组作为正式业务输出",
"当前选择项必须来自 runSnapshot.availableChoices 或由本轮 choice step 重新给出",
"如果玩家自由输入改变事实,必须用 flag 或 metric 表达可持久化变化",
"不要在输出中夹带 raw_text、debug、prompt、historyPlayback 或平台运营字段"
],
"outputContract": VISUAL_NOVEL_RUNTIME_OUTPUT_CONTRACT
})
.to_string()
}
pub(crate) fn build_visual_novel_repair_user_prompt(
params: VisualNovelRepairPromptParams<'_>,
) -> String {
json!({
"task": "repair_visual_novel_structured_output",
"target": params.target.label(),
"parseError": params.parse_error,
"rawText": params.raw_text,
"outputContract": params.target.contract()
})
.to_string()
}
pub(crate) fn build_visual_novel_creation_llm_request(
params: VisualNovelCreationPromptParams<'_>,
enable_web_search: bool,
) -> LlmTextRequest {
LlmTextRequest::new(vec![
LlmMessage::system(VISUAL_NOVEL_CREATION_SYSTEM_PROMPT),
LlmMessage::user(build_visual_novel_creation_user_prompt(params)),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
.with_web_search(enable_web_search)
}
pub(crate) fn build_visual_novel_runtime_llm_request(
params: VisualNovelRuntimePromptParams<'_>,
) -> LlmTextRequest {
LlmTextRequest::new(vec![
LlmMessage::system(VISUAL_NOVEL_RUNTIME_GM_SYSTEM_PROMPT),
LlmMessage::user(build_visual_novel_runtime_user_prompt(params)),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
}
pub(crate) fn build_visual_novel_repair_llm_request(
params: VisualNovelRepairPromptParams<'_>,
) -> LlmTextRequest {
LlmTextRequest::new(vec![
LlmMessage::system(VISUAL_NOVEL_REPAIR_SYSTEM_PROMPT),
LlmMessage::user(build_visual_novel_repair_user_prompt(params)),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
}
pub(crate) fn visual_novel_tool_descriptors() -> Vec<VisualNovelToolDescriptor> {
vec![
VisualNovelToolDescriptor {
name: "visual_novel_apply_creation_action",
description: "执行视觉小说创作 action写回 VisualNovelResultDraft 或编译平台 work profile 草稿。",
input_schema: json!({
"type": "object",
"required": ["kind"],
"additionalProperties": false,
"properties": {
"kind": {
"type": "string",
"enum": [
"generate_draft",
"patch_world",
"patch_character",
"patch_scene",
"patch_story_phase",
"compile_work_profile"
]
},
"targetId": { "type": ["string", "null"] },
"payload": { "type": "object", "additionalProperties": true }
}
}),
},
VisualNovelToolDescriptor {
name: "visual_novel_generate_image_asset",
description: "为视觉小说角色立绘或场景背景生成图片,并返回平台资产引用。",
input_schema: json!({
"type": "object",
"required": ["kind", "targetId", "prompt"],
"additionalProperties": false,
"properties": {
"kind": {
"type": "string",
"enum": ["generate_scene_image", "generate_character_image"]
},
"targetId": { "type": "string", "minLength": 1 },
"prompt": { "type": "string", "minLength": 1 },
"styleHints": { "type": "array", "items": { "type": "string" } },
"sourceImageAssetId": { "type": ["string", "null"] }
}
}),
},
]
}
pub(crate) fn parse_visual_novel_result_draft_fixture(
text: &str,
) -> Result<VisualNovelResultDraft, VisualNovelPromptParseFailure> {
let value = extract_json_root(
text,
JsonRootShape::Object,
VisualNovelRepairTarget::ResultDraft,
)?;
serde_json::from_value(value).map_err(|error| VisualNovelPromptParseFailure {
target: VisualNovelRepairTarget::ResultDraft,
message: error.to_string(),
})
}
pub(crate) fn parse_visual_novel_runtime_steps_fixture(
text: &str,
) -> Result<Vec<VisualNovelRuntimeStep>, VisualNovelPromptParseFailure> {
let value = extract_json_root(
text,
JsonRootShape::Array,
VisualNovelRepairTarget::RuntimeSteps,
)?;
serde_json::from_value(value).map_err(|error| VisualNovelPromptParseFailure {
target: VisualNovelRepairTarget::RuntimeSteps,
message: error.to_string(),
})
}
#[derive(Clone, Copy)]
enum JsonRootShape {
Object,
Array,
}
fn extract_json_root(
text: &str,
shape: JsonRootShape,
target: VisualNovelRepairTarget,
) -> Result<JsonValue, VisualNovelPromptParseFailure> {
let trimmed = strip_json_code_fence(text.trim());
if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed) {
return Ok(value);
}
let (start_char, end_char) = match shape {
JsonRootShape::Object => ('{', '}'),
JsonRootShape::Array => ('[', ']'),
};
let start = trimmed.find(start_char);
let end = trimmed.rfind(end_char);
match (start, end) {
(Some(start), Some(end)) if end > start => {
serde_json::from_str::<JsonValue>(&trimmed[start..=end]).map_err(|error| {
VisualNovelPromptParseFailure {
target,
message: error.to_string(),
}
})
}
_ => Err(VisualNovelPromptParseFailure {
target,
message: format!("未找到目标 JSON {}", target.label()),
}),
}
}
fn strip_json_code_fence(text: &str) -> &str {
let trimmed = text.trim();
if !trimmed.starts_with("```") {
return trimmed;
}
let without_start = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```JSON"))
.or_else(|| trimmed.strip_prefix("```"))
.unwrap_or(trimmed)
.trim();
without_start
.strip_suffix("```")
.unwrap_or(without_start)
.trim()
}
#[cfg(test)]
mod tests {
use platform_llm::LlmTextProtocol;
use serde_json::json;
use super::*;
fn source_asset_ids() -> Vec<String> {
vec!["asset-doc-1".to_string()]
}
fn creation_params<'a>(source_asset_ids: &'a [String]) -> VisualNovelCreationPromptParams<'a> {
VisualNovelCreationPromptParams {
source_mode: "idea",
seed_text: Some("雨夜里,只在午夜出现的书店会归还人们遗失的名字。"),
source_asset_ids,
document_summary: None,
current_draft: None,
recent_messages: &[],
now_iso: "2026-05-05T12:00:00Z",
}
}
fn runtime_params<'a>(
work_profile: &'a JsonValue,
run_snapshot: &'a JsonValue,
runtime_action: &'a JsonValue,
) -> VisualNovelRuntimePromptParams<'a> {
VisualNovelRuntimePromptParams {
work_profile,
run_snapshot,
runtime_action,
recent_history: &[],
max_assistant_step_count_per_turn: 8,
}
}
fn sample_draft() -> JsonValue {
json!({
"profileId": null,
"workTitle": "雨夜书店",
"workDescription": "一名失去名字的读者在午夜书店寻找真相。",
"workTags": ["悬疑", "治愈"],
"coverImageSrc": null,
"sourceMode": "idea",
"sourceAssetIds": [],
"world": {
"title": "雨夜书店",
"summary": "午夜书店会收留遗失名字的人。",
"background": "旧城区尽头有一家只在雨夜开门的书店,书架保存着人们遗忘的片段。",
"premise": "玩家要在天亮前找回自己的名字。",
"literaryStyle": "细腻、克制、轻悬疑",
"playerRole": "失去名字的读者",
"defaultTone": "雨夜、温柔、隐秘"
},
"characters": [
{
"characterId": "char-keeper",
"name": "林栖",
"gender": "",
"role": "main",
"appearance": "银灰短发,深绿围裙,手中常拿一盏铜灯,适合半身立绘。",
"personality": "温和但不轻易透露真相",
"tone": "低声、像在翻旧书",
"background": "午夜书店的看守者。",
"relationshipToPlayer": "知道玩家名字的一部分。",
"imageAssets": [],
"defaultExpression": "calm",
"isPlayerVisible": false
}
],
"scenes": [
{
"sceneId": "scene-bookstore",
"name": "午夜书店",
"description": "窄巷尽头的木门半开,暖黄灯光落在潮湿石板上,室内书架高而幽深。",
"backgroundImageSrc": null,
"musicSrc": null,
"ambientSoundSrc": null,
"availability": "opening",
"phaseIds": ["phase-opening"]
}
],
"storyPhases": [
{
"phaseId": "phase-opening",
"title": "失名之夜",
"goal": "确认玩家为何失去名字",
"summary": "玩家进入书店,与林栖第一次交谈。",
"entryCondition": "opening",
"exitCondition": "找到第一张名字书签",
"sceneIds": ["scene-bookstore"],
"characterIds": ["char-keeper"],
"suggestedChoices": ["询问书店来历", "查看柜台上的旧书"]
}
],
"opening": {
"sceneId": "scene-bookstore",
"narration": "雨水顺着伞尖落下时,你发现门牌上的字正在一点点亮起。",
"speakerCharacterId": "char-keeper",
"firstDialogue": "你终于来了。名字丢失的人,总会先听见这场雨。",
"initialChoices": [
{ "choiceId": "choice-ask-name", "text": "询问自己的名字在哪里", "actionHint": "向林栖确认线索" },
{ "choiceId": "choice-look-book", "text": "查看柜台上的旧书", "actionHint": "寻找名字书签" }
]
},
"runtimeConfig": {
"textModeEnabled": true,
"defaultTextMode": false,
"maxHistoryEntries": 80,
"maxAssistantStepCountPerTurn": 8,
"allowFreeTextAction": true,
"allowHistoryRegeneration": true,
"attributePanelMode": "off",
"saveArchiveEnabled": true
},
"publishReady": true,
"validationIssues": [],
"updatedAt": "2026-05-05T12:00:00Z"
})
}
#[test]
fn creation_fixture_parses_as_visual_novel_result_draft() {
let raw_text = format!("模型输出如下:\n{}", sample_draft());
let draft = parse_visual_novel_result_draft_fixture(raw_text.as_str())
.expect("draft fixture should parse");
assert_eq!(draft.work_title, "雨夜书店");
assert_eq!(draft.characters[0].character_id, "char-keeper");
assert_eq!(draft.opening.initial_choices.len(), 2);
}
#[test]
fn runtime_fixture_parses_as_typed_steps() {
let raw_text = json!([
{ "type": "scene_change", "sceneId": "scene-bookstore", "backgroundImageSrc": null, "musicSrc": null },
{ "type": "narration", "text": "门铃轻响,雨声像被书页吸走。" },
{ "type": "dialogue", "characterId": "char-keeper", "characterName": "林栖", "expression": "calm", "text": "先别急着找答案,先告诉我你还记得什么。" },
{ "type": "flag", "key": "met_keeper", "value": true },
{ "type": "metric", "key": "keeper_trust", "delta": 1 },
{
"type": "choice",
"choices": [
{ "choiceId": "choice-tell-memory", "text": "说出最后记得的街名", "actionHint": "提供线索" },
{ "choiceId": "choice-stay-silent", "text": "保持沉默观察她", "actionHint": "观察林栖反应" }
]
}
])
.to_string();
let steps = parse_visual_novel_runtime_steps_fixture(raw_text.as_str())
.expect("runtime fixture should parse");
assert_eq!(steps.len(), 6);
assert!(matches!(
steps[0],
VisualNovelRuntimeStep::SceneChange { .. }
));
assert!(matches!(steps[5], VisualNovelRuntimeStep::Choice { .. }));
}
#[test]
fn bad_runtime_output_can_enter_repair_prompt() {
let failure = parse_visual_novel_runtime_steps_fixture("林栖说:欢迎来到书店。")
.expect_err("bad output should fail");
let retryable_message = failure.retryable_message();
let repair_prompt = build_visual_novel_repair_user_prompt(VisualNovelRepairPromptParams {
target: failure.target,
raw_text: "林栖说:欢迎来到书店。",
parse_error: failure.message.as_str(),
});
assert!(retryable_message.contains("可重试"));
assert!(repair_prompt.contains("VisualNovelRuntimeStep[]"));
assert!(repair_prompt.contains("林栖说"));
assert!(repair_prompt.contains("scene_change"));
}
#[test]
fn llm_requests_use_responses_template_model() {
let asset_ids = source_asset_ids();
let creation_request =
build_visual_novel_creation_llm_request(creation_params(asset_ids.as_slice()), true);
assert_eq!(
creation_request.model.as_deref(),
Some(CREATION_TEMPLATE_LLM_MODEL)
);
assert_eq!(creation_request.protocol, LlmTextProtocol::Responses);
assert!(creation_request.enable_web_search);
assert!(
creation_request.messages[0]
.content
.contains("VisualNovelResultDraft")
);
assert!(
creation_request.messages[1]
.content
.contains("sourceAssetIds")
);
let work_profile = sample_draft();
let run_snapshot = json!({ "runId": "run-1", "availableChoices": [] });
let runtime_action = json!({ "actionKind": "continue", "clientEventId": "event-1" });
let runtime_request = build_visual_novel_runtime_llm_request(runtime_params(
&work_profile,
&run_snapshot,
&runtime_action,
));
assert_eq!(
runtime_request.model.as_deref(),
Some(CREATION_TEMPLATE_LLM_MODEL)
);
assert_eq!(runtime_request.protocol, LlmTextProtocol::Responses);
assert!(!runtime_request.enable_web_search);
assert!(
runtime_request.messages[0]
.content
.contains("VisualNovelRuntimeStep[]")
);
}
#[test]
fn prompts_and_tools_guard_against_external_platform_fields() {
assert!(VISUAL_NOVEL_CREATION_SYSTEM_PROMPT.contains("外部商业"));
assert!(VISUAL_NOVEL_CREATION_SYSTEM_PROMPT.contains("独立账号"));
assert!(VISUAL_NOVEL_RUNTIME_GM_SYSTEM_PROMPT.contains("独立保存"));
let tools = visual_novel_tool_descriptors();
let tool_payload = serde_json::to_string(&json!(
tools
.iter()
.map(|tool| json!({
"name": tool.name,
"description": tool.description,
"inputSchema": tool.input_schema,
}))
.collect::<Vec<_>>()
))
.expect("tools should serialize");
assert!(tool_payload.contains("generate_scene_image"));
assert!(tool_payload.contains("generate_character_image"));
assert!(tool_payload.contains("compile_work_profile"));
let legacy_playback_marker = format!("{}{}", "re", "play");
assert!(!tool_payload.contains(&legacy_playback_marker));
assert!(!tool_payload.contains(&legacy_playback_marker.to_uppercase()));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,8 @@ use crate::{
auth::RefreshSessionToken,
auth_session::{
attach_set_cookie_header, build_clear_refresh_session_cookie_header,
build_refresh_session_cookie_header, map_refresh_session_error, sign_access_token_for_user,
build_refresh_session_cookie_header, map_refresh_session_error,
record_daily_login_tracking_event_after_auth_success, sign_access_token_for_user,
},
http_error::AppError,
request_context::RequestContext,
@@ -54,6 +55,13 @@ pub async fn refresh_session(
&rotated.session.session_id,
Some(&rotated.session.issued_by_provider),
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&rotated.user.id,
rotated.session.issued_by_provider.clone(),
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await

View File

@@ -1,23 +1,35 @@
use axum::{
Json,
extract::{Extension, State},
extract::{Extension, Path, Query, State},
http::StatusCode,
response::Response,
};
use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileInviteCodeRecord,
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot,
RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord,
RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord,
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileWalletLedgerSourceType,
RuntimeReferralInviteCenterRecord,
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord,
RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle,
RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType,
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind,
};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::runtime::{
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRedeemCodeRequest, CreateProfileRechargeOrderRequest,
CreateProfileRechargeOrderResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER,
ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest,
AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest,
AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse,
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
PROFILE_FEEDBACK_STATUS_OPEN, PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE,
PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD,
@@ -25,14 +37,21 @@ use shared_contracts::runtime::{
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse,
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse,
ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
ProfileReferralInviteCenterResponse, ProfileReferralInvitedUserResponse,
ProfileFeedbackEvidenceItemResponse, ProfileFeedbackSubmissionResponse,
ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse,
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
ProfileRechargeProductResponse, ProfileRedeemCodeAdminListResponse,
ProfileRedeemCodeAdminResponse, ProfileReferralInviteCenterResponse,
ProfileReferralInvitedUserResponse, ProfileTaskCenterResponse,
ProfileTaskConfigAdminListResponse, ProfileTaskConfigAdminResponse, ProfileTaskItemResponse,
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse,
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest,
SubmitProfileFeedbackResponse, TRACKING_SCOPE_KIND_MODULE, TRACKING_SCOPE_KIND_SITE,
TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK,
};
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
@@ -91,14 +110,7 @@ pub async fn get_profile_wallet_ledger(
ProfileWalletLedgerResponse {
entries: entries
.into_iter()
.map(|entry| ProfileWalletLedgerEntryResponse {
id: entry.wallet_ledger_id,
amount_delta: entry.amount_delta,
balance_after: entry.balance_after,
source_type: format_profile_wallet_ledger_source_type(entry.source_type)
.to_string(),
created_at: entry.created_at,
})
.map(build_profile_wallet_ledger_entry_response)
.collect(),
},
))
@@ -135,6 +147,9 @@ fn format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
}
RuntimeProfileWalletLedgerSourceType::DailyTaskReward => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD
}
}
}
@@ -197,6 +212,51 @@ pub async fn create_profile_recharge_order(
))
}
pub async fn submit_profile_feedback(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<SubmitProfileFeedbackRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let created_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let evidence_items = payload
.evidence_items
.into_iter()
.map(|item| RuntimeProfileFeedbackEvidenceSnapshot {
evidence_id: String::new(),
file_name: item.file_name,
content_type: item.content_type,
size_bytes: item.size_bytes,
data_url: item.data_url,
})
.collect();
let record = state
.spacetime_client()
.submit_profile_feedback(
user_id,
payload.description,
payload.contact_phone,
evidence_items,
created_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
SubmitProfileFeedbackResponse {
feedback: build_profile_feedback_submission_response(record),
},
))
}
pub async fn get_profile_referral_invite_center(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -270,6 +330,237 @@ pub async fn redeem_profile_reward_code(
))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalyticsMetricQueryParams {
pub event_key: String,
pub scope_kind: String,
pub scope_id: String,
pub granularity: String,
}
pub async fn get_profile_analytics_metric(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Query(query): Query<AnalyticsMetricQueryParams>,
) -> Result<Json<Value>, Response> {
let scope_kind = parse_tracking_scope_kind(&query.scope_kind).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let granularity = parse_analytics_granularity(&query.granularity).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let record = state
.spacetime_client()
.query_analytics_metric(query.event_key, scope_kind, query.scope_id, granularity)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_analytics_metric_query_response(record),
))
}
pub async fn get_profile_task_center(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_profile_task_center(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_task_center_response(record),
))
}
pub async fn claim_profile_task_reward(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(task_id): Path<String>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.claim_profile_task_reward(user_id, task_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_claim_profile_task_reward_response(record),
))
}
pub async fn admin_list_profile_task_configs(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.admin_list_profile_task_configs(admin.session().subject.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileTaskConfigAdminListResponse {
entries: entries
.into_iter()
.map(build_profile_task_config_admin_response)
.collect(),
},
))
}
pub async fn admin_upsert_profile_task_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertProfileTaskConfigRequest>,
) -> Result<Json<Value>, Response> {
let cycle = parse_profile_task_cycle(&payload.cycle).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let scope_kind = parse_tracking_scope_kind(&payload.scope_kind).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
// 中文注释:个人任务配置首版只开放 User scopeHTTP 层先返回清晰错误,领域层再兜底。
if scope_kind != RuntimeTrackingScopeKind::User {
return Err(runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("个人任务 scopeKind 首版仅支持 user"),
));
}
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.admin_upsert_profile_task_config(
admin.session().subject.clone(),
payload.task_id,
payload.title,
payload.description.unwrap_or_default(),
payload.event_key,
cycle,
scope_kind,
payload.threshold,
payload.reward_points,
payload.enabled,
payload.sort_order.unwrap_or(10),
updated_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_task_config_admin_response(record),
))
}
pub async fn admin_disable_profile_task_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminDisableProfileTaskConfigRequest>,
) -> Result<Json<Value>, Response> {
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.admin_disable_profile_task_config(
admin.session().subject.clone(),
payload.task_id,
updated_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_task_config_admin_response(record),
))
}
pub async fn admin_list_profile_redeem_codes(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.admin_list_profile_redeem_codes(admin.session().subject.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileRedeemCodeAdminListResponse {
entries: entries
.into_iter()
.map(build_profile_redeem_code_admin_response)
.collect(),
},
))
}
pub async fn admin_upsert_profile_redeem_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -338,6 +629,33 @@ pub async fn admin_disable_profile_redeem_code(
))
}
pub async fn admin_list_profile_invite_codes(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.admin_list_profile_invite_codes(admin.session().subject.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileInviteCodeAdminListResponse {
entries: entries
.into_iter()
.map(build_profile_invite_code_admin_response)
.collect(),
},
))
}
pub async fn admin_upsert_profile_invite_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -346,6 +664,10 @@ pub async fn admin_upsert_profile_invite_code(
) -> Result<Json<Value>, Response> {
let metadata_json = normalize_admin_invite_code_metadata(payload.metadata)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
let starts_at_micros = parse_admin_invite_code_time_field("startsAt", payload.starts_at)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
let expires_at_micros = parse_admin_invite_code_time_field("expiresAt", payload.expires_at)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
@@ -353,6 +675,8 @@ pub async fn admin_upsert_profile_invite_code(
admin.session().username.clone(),
payload.invite_code,
metadata_json,
starts_at_micros,
expires_at_micros,
updated_at_micros as i64,
)
.await
@@ -507,6 +831,36 @@ fn build_profile_recharge_order_response(
}
}
fn build_profile_feedback_submission_response(
record: RuntimeProfileFeedbackSubmissionRecord,
) -> ProfileFeedbackSubmissionResponse {
ProfileFeedbackSubmissionResponse {
feedback_id: record.feedback_id,
status: match record.status {
module_runtime::RuntimeProfileFeedbackStatus::Open => {
PROFILE_FEEDBACK_STATUS_OPEN.to_string()
}
},
created_at: record.created_at,
evidence_items: record
.evidence_items
.into_iter()
.map(build_profile_feedback_evidence_response)
.collect(),
}
}
fn build_profile_feedback_evidence_response(
record: RuntimeProfileFeedbackEvidenceRecord,
) -> ProfileFeedbackEvidenceItemResponse {
ProfileFeedbackEvidenceItemResponse {
evidence_id: record.evidence_id,
file_name: record.file_name,
content_type: record.content_type,
size_bytes: record.size_bytes,
}
}
fn build_profile_referral_invite_center_response(
record: RuntimeReferralInviteCenterRecord,
) -> ProfileReferralInviteCenterResponse {
@@ -553,14 +907,104 @@ fn build_redeem_profile_reward_code_response(
RedeemProfileRewardCodeResponse {
wallet_balance: record.wallet_balance,
amount_granted: record.amount_granted,
ledger_entry: ProfileWalletLedgerEntryResponse {
id: record.ledger_entry.wallet_ledger_id,
amount_delta: record.ledger_entry.amount_delta,
balance_after: record.ledger_entry.balance_after,
source_type: format_profile_wallet_ledger_source_type(record.ledger_entry.source_type)
.to_string(),
created_at: record.ledger_entry.created_at,
},
ledger_entry: build_profile_wallet_ledger_entry_response(record.ledger_entry),
}
}
fn build_profile_wallet_ledger_entry_response(
record: module_runtime::RuntimeProfileWalletLedgerEntryRecord,
) -> ProfileWalletLedgerEntryResponse {
ProfileWalletLedgerEntryResponse {
id: record.wallet_ledger_id,
amount_delta: record.amount_delta,
balance_after: record.balance_after,
source_type: format_profile_wallet_ledger_source_type(record.source_type).to_string(),
created_at: record.created_at,
}
}
fn build_profile_task_center_response(
record: RuntimeProfileTaskCenterRecord,
) -> ProfileTaskCenterResponse {
ProfileTaskCenterResponse {
day_key: record.day_key,
wallet_balance: record.wallet_balance,
tasks: record
.tasks
.into_iter()
.map(build_profile_task_item_response)
.collect(),
updated_at: record.updated_at,
}
}
fn build_analytics_metric_query_response(
record: module_runtime::AnalyticsMetricQueryResponse,
) -> AnalyticsMetricQueryResponse {
AnalyticsMetricQueryResponse {
buckets: record
.buckets
.into_iter()
.map(|bucket| AnalyticsBucketMetricResponse {
bucket_key: bucket.bucket_key,
bucket_start_date_key: bucket.bucket_start_date_key,
bucket_end_date_key: bucket.bucket_end_date_key,
value: bucket.value,
})
.collect(),
}
}
fn build_profile_task_item_response(
record: RuntimeProfileTaskItemRecord,
) -> ProfileTaskItemResponse {
ProfileTaskItemResponse {
task_id: record.task_id,
title: record.title,
description: record.description,
event_key: record.event_key,
cycle: format_profile_task_cycle(record.cycle).to_string(),
threshold: record.threshold,
progress_count: record.progress_count,
reward_points: record.reward_points,
status: format_profile_task_status(record.status).to_string(),
day_key: record.day_key,
claimed_at: record.claimed_at,
updated_at: record.updated_at,
}
}
fn build_claim_profile_task_reward_response(
record: RuntimeProfileTaskClaimRecord,
) -> ClaimProfileTaskRewardResponse {
ClaimProfileTaskRewardResponse {
task_id: record.task_id,
day_key: record.day_key,
reward_points: record.reward_points,
wallet_balance: record.wallet_balance,
ledger_entry: build_profile_wallet_ledger_entry_response(record.ledger_entry),
center: build_profile_task_center_response(record.center),
}
}
fn build_profile_task_config_admin_response(
record: RuntimeProfileTaskConfigRecord,
) -> ProfileTaskConfigAdminResponse {
ProfileTaskConfigAdminResponse {
task_id: record.task_id,
title: record.title,
description: record.description,
event_key: record.event_key,
cycle: format_profile_task_cycle(record.cycle).to_string(),
scope_kind: format_tracking_scope_kind(record.scope_kind).to_string(),
threshold: record.threshold,
reward_points: record.reward_points,
enabled: record.enabled,
sort_order: record.sort_order,
created_by: record.created_by,
created_at: record.created_at,
updated_by: record.updated_by,
updated_at: record.updated_at,
}
}
@@ -588,6 +1032,27 @@ fn normalize_admin_invite_code_metadata(metadata: Option<Value>) -> Result<Strin
Ok(metadata_json)
}
fn parse_admin_invite_code_time_field(
field: &'static str,
value: Option<String>,
) -> Result<Option<i64>, AppError> {
let Some(value) = value else {
return Ok(None);
};
let value = value.trim();
if value.is_empty() {
return Ok(None);
}
let parsed = parse_rfc3339(value).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message(format!("邀请码 {field} 必须是 RFC3339 时间字符串"))
.with_details(json!({ "field": field, "message": error }))
})?;
Ok(Some(offset_datetime_to_unix_micros(parsed)))
}
fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeMode, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"public" => Ok(RuntimeProfileRedeemCodeMode::Public),
@@ -597,6 +1062,58 @@ fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeM
}
}
fn parse_profile_task_cycle(raw: &str) -> Result<RuntimeProfileTaskCycle, String> {
match raw.trim().to_ascii_lowercase().as_str() {
PROFILE_TASK_CYCLE_DAILY => Ok(RuntimeProfileTaskCycle::Daily),
_ => Err("任务周期无效".to_string()),
}
}
fn parse_tracking_scope_kind(raw: &str) -> Result<RuntimeTrackingScopeKind, String> {
match raw.trim().to_ascii_lowercase().as_str() {
TRACKING_SCOPE_KIND_SITE => Ok(RuntimeTrackingScopeKind::Site),
TRACKING_SCOPE_KIND_WORK => Ok(RuntimeTrackingScopeKind::Work),
TRACKING_SCOPE_KIND_MODULE => Ok(RuntimeTrackingScopeKind::Module),
TRACKING_SCOPE_KIND_USER => Ok(RuntimeTrackingScopeKind::User),
_ => Err("埋点范围无效".to_string()),
}
}
fn parse_analytics_granularity(raw: &str) -> Result<AnalyticsGranularity, String> {
match raw.trim().to_ascii_lowercase().as_str() {
ANALYTICS_GRANULARITY_DAY => Ok(AnalyticsGranularity::Day),
ANALYTICS_GRANULARITY_WEEK => Ok(AnalyticsGranularity::Week),
ANALYTICS_GRANULARITY_MONTH => Ok(AnalyticsGranularity::Month),
ANALYTICS_GRANULARITY_QUARTER => Ok(AnalyticsGranularity::Quarter),
ANALYTICS_GRANULARITY_YEAR => Ok(AnalyticsGranularity::Year),
_ => Err("统计粒度无效".to_string()),
}
}
fn format_profile_task_cycle(cycle: RuntimeProfileTaskCycle) -> &'static str {
match cycle {
RuntimeProfileTaskCycle::Daily => PROFILE_TASK_CYCLE_DAILY,
}
}
fn format_profile_task_status(status: RuntimeProfileTaskStatus) -> &'static str {
match status {
RuntimeProfileTaskStatus::Incomplete => PROFILE_TASK_STATUS_INCOMPLETE,
RuntimeProfileTaskStatus::Claimable => PROFILE_TASK_STATUS_CLAIMABLE,
RuntimeProfileTaskStatus::Claimed => PROFILE_TASK_STATUS_CLAIMED,
RuntimeProfileTaskStatus::Disabled => PROFILE_TASK_STATUS_DISABLED,
}
}
fn format_tracking_scope_kind(scope_kind: RuntimeTrackingScopeKind) -> &'static str {
match scope_kind {
RuntimeTrackingScopeKind::Site => TRACKING_SCOPE_KIND_SITE,
RuntimeTrackingScopeKind::Work => TRACKING_SCOPE_KIND_WORK,
RuntimeTrackingScopeKind::Module => TRACKING_SCOPE_KIND_MODULE,
RuntimeTrackingScopeKind::User => TRACKING_SCOPE_KIND_USER,
}
}
fn build_profile_invite_code_admin_response(
record: RuntimeProfileInviteCodeRecord,
) -> ProfileInviteCodeAdminResponse {
@@ -606,6 +1123,9 @@ fn build_profile_invite_code_admin_response(
user_id: record.user_id,
invite_code: record.invite_code,
metadata,
starts_at: record.starts_at,
expires_at: record.expires_at,
status: record.status.as_str().to_string(),
created_at: record.created_at,
updated_at: record.updated_at,
}
@@ -675,6 +1195,12 @@ mod tests {
),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
);
assert_eq!(
format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::DailyTaskReward
),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD
);
}
#[tokio::test]
@@ -713,6 +1239,36 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_tasks_require_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let list_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/tasks")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let claim_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/tasks/daily_login/claim")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(list_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(claim_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_play_stats_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -768,6 +1324,27 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_feedback_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/feedback")
.header("content-type", "application/json")
.body(Body::from(
r#"{"description":"反馈页面上传图片后没有显示预览"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_referral_invite_center_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -868,6 +1445,7 @@ mod tests {
"/api/runtime/profile/wallet-ledger",
"/api/runtime/profile/recharge-center",
"/api/runtime/profile/recharge/orders",
"/api/runtime/profile/feedback",
"/api/runtime/profile/referrals/invite-center",
"/api/runtime/profile/referrals/redeem-code",
"/api/runtime/profile/redeem-codes/redeem",
@@ -892,6 +1470,76 @@ mod tests {
}
}
#[tokio::test]
async fn admin_profile_task_routes_require_admin_authentication() {
let app =
build_router(AppState::new(admin_enabled_test_config()).expect("state should build"));
let list_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/admin/api/profile/tasks")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let upsert_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/profile/tasks")
.header("content-type", "application/json")
.body(Body::from(r#"{"taskId":"daily_login"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
let disable_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/profile/tasks/disable")
.header("content-type", "application/json")
.body(Body::from(r#"{"taskId":"daily_login"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(list_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(upsert_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(disable_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn admin_profile_code_list_routes_require_admin_authentication() {
let app =
build_router(AppState::new(admin_enabled_test_config()).expect("state should build"));
for uri in [
"/admin/api/profile/redeem-codes",
"/admin/api/profile/invite-codes",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{uri}");
}
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(fast_spacetime_timeout_config()).expect("state should build");
state
@@ -908,6 +1556,14 @@ mod tests {
}
}
fn admin_enabled_test_config() -> AppConfig {
AppConfig {
admin_username: Some("root".to_string()),
admin_password: Some("secret123".to_string()),
..AppConfig::default()
}
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,569 @@
use module_square_hole::{
SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MAX_SHAPE_COUNT, SQUARE_HOLE_MIN_DIFFICULTY,
SQUARE_HOLE_MIN_SHAPE_COUNT, SquareHoleHoleOption, SquareHoleShapeOption,
default_background_prompt, normalize_hole_options, normalize_shape_options,
};
use platform_llm::LlmClient;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use spacetime_client::{SquareHoleAgentMessageFinalizeRecordInput, SquareHoleAgentSessionRecord};
use crate::creation_agent_llm_turn::{
CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn,
};
use crate::prompt::square_hole::{
SQUARE_HOLE_AGENT_JSON_TURN_USER_PROMPT, SQUARE_HOLE_AGENT_SYSTEM_PROMPT,
build_square_hole_agent_prompt,
};
#[derive(Clone, Debug)]
pub(crate) struct SquareHoleAgentTurnRequest<'a> {
pub llm_client: Option<&'a LlmClient>,
pub session: &'a SquareHoleAgentSessionRecord,
pub quick_fill_requested: bool,
pub enable_web_search: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct SquareHoleAgentTurnResult {
pub assistant_reply_text: String,
pub stage: String,
pub progress_percent: u32,
pub config_json: String,
pub error_message: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct SquareHoleAgentTurnError {
message: String,
}
impl SquareHoleAgentTurnError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for SquareHoleAgentTurnError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for SquareHoleAgentTurnError {}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleAgentModelOutput {
reply_text: String,
progress_percent: u32,
next_config: SquareHoleAgentConfigOutput,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleAgentConfigOutput {
theme_text: String,
twist_rule: String,
shape_count: u32,
difficulty: u32,
shape_options: Vec<SquareHoleAgentShapeOptionOutput>,
hole_options: Vec<SquareHoleAgentHoleOptionOutput>,
background_prompt: String,
#[serde(default)]
cover_image_src: String,
#[serde(default)]
background_image_src: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleAgentShapeOptionOutput {
option_id: String,
shape_kind: String,
label: String,
target_hole_id: String,
image_prompt: String,
#[serde(default)]
image_src: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SquareHoleAgentHoleOptionOutput {
hole_id: String,
hole_kind: String,
label: String,
image_prompt: String,
#[serde(default)]
image_src: String,
}
pub(crate) async fn run_square_hole_agent_turn<F>(
request: SquareHoleAgentTurnRequest<'_>,
on_reply_update: F,
) -> Result<SquareHoleAgentTurnResult, SquareHoleAgentTurnError>
where
F: FnMut(&str),
{
let prompt = build_square_hole_agent_prompt(request.session, request.quick_fill_requested);
let turn_output = stream_creation_agent_json_turn(
request.llm_client,
format!("{SQUARE_HOLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}"),
SQUARE_HOLE_AGENT_JSON_TURN_USER_PROMPT,
request.enable_web_search,
CreationAgentLlmTurnErrorMessages {
model_unavailable: "当前模型不可用,请稍后重试。",
generation_failed: "方洞挑战聊天生成失败,请稍后重试。",
parse_failed: "方洞挑战聊天结果解析失败,请稍后重试。",
},
on_reply_update,
SquareHoleAgentTurnError::new,
)
.await?;
let output = parse_model_output(&turn_output.parsed, request.session)?;
let progress_percent = if request.quick_fill_requested {
100
} else {
output.progress_percent.min(100)
};
Ok(SquareHoleAgentTurnResult {
assistant_reply_text: output.reply_text,
stage: resolve_stage(progress_percent),
progress_percent,
config_json: serde_json::to_string(&output.next_config)
.map_err(|_| SquareHoleAgentTurnError::new("方洞挑战配置序列化失败。"))?,
error_message: None,
})
}
pub(crate) fn build_finalize_record_input(
session_id: String,
owner_user_id: String,
assistant_message_id: String,
result: SquareHoleAgentTurnResult,
updated_at_micros: i64,
) -> SquareHoleAgentMessageFinalizeRecordInput {
SquareHoleAgentMessageFinalizeRecordInput {
session_id,
owner_user_id,
assistant_message_id: Some(assistant_message_id),
assistant_reply_text: Some(result.assistant_reply_text),
config_json: Some(result.config_json),
progress_percent: result.progress_percent,
stage: result.stage,
updated_at_micros,
error_message: result.error_message,
}
}
fn parse_model_output(
parsed: &JsonValue,
session: &SquareHoleAgentSessionRecord,
) -> Result<SquareHoleAgentModelOutput, SquareHoleAgentTurnError> {
let reply_text = parsed
.get("replyText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| SquareHoleAgentTurnError::new("方洞挑战聊天结果缺少有效回复,请稍后重试。"))?
.to_string();
let progress_percent = parsed
.get("progressPercent")
.and_then(JsonValue::as_u64)
.map(|value| value.min(100) as u32)
.unwrap_or(session.progress_percent);
let next_config_value = parsed
.get("nextConfig")
.ok_or_else(|| SquareHoleAgentTurnError::new("方洞挑战聊天结果缺少 nextConfig。"))?;
let next_config = parse_model_config(next_config_value, session)?;
Ok(SquareHoleAgentModelOutput {
reply_text,
progress_percent,
next_config,
})
}
fn parse_model_config(
value: &JsonValue,
session: &SquareHoleAgentSessionRecord,
) -> Result<SquareHoleAgentConfigOutput, SquareHoleAgentTurnError> {
if !value.is_object() {
return Err(SquareHoleAgentTurnError::new(
"方洞挑战聊天结果中的 nextConfig 必须是对象。",
));
}
let theme_text =
read_text_field(value, "themeText").unwrap_or_else(|| session.config.theme_text.clone());
let twist_rule =
read_text_field(value, "twistRule").unwrap_or_else(|| session.config.twist_rule.clone());
let hole_options = parse_hole_options(value, session, &theme_text);
let shape_options = parse_shape_options(value, session, &theme_text, hole_options.as_slice());
let background_prompt = read_text_field(value, "backgroundPrompt")
.or_else(|| {
session
.config
.background_prompt
.trim()
.is_empty()
.then(|| default_background_prompt(&theme_text))
})
.unwrap_or_else(|| session.config.background_prompt.clone());
Ok(SquareHoleAgentConfigOutput {
theme_text,
twist_rule,
shape_count: read_u32_field(value, "shapeCount")
.unwrap_or(session.config.shape_count)
.clamp(SQUARE_HOLE_MIN_SHAPE_COUNT, SQUARE_HOLE_MAX_SHAPE_COUNT),
difficulty: read_u32_field(value, "difficulty")
.unwrap_or(session.config.difficulty)
.clamp(SQUARE_HOLE_MIN_DIFFICULTY, SQUARE_HOLE_MAX_DIFFICULTY),
shape_options: shape_options
.into_iter()
.map(SquareHoleAgentShapeOptionOutput::from)
.collect(),
hole_options: hole_options
.into_iter()
.map(SquareHoleAgentHoleOptionOutput::from)
.collect(),
background_prompt,
cover_image_src: session.config.cover_image_src.clone().unwrap_or_default(),
background_image_src: session
.config
.background_image_src
.clone()
.unwrap_or_default(),
})
}
fn parse_shape_options(
value: &JsonValue,
session: &SquareHoleAgentSessionRecord,
theme_text: &str,
hole_options: &[SquareHoleHoleOption],
) -> Vec<SquareHoleShapeOption> {
let parsed = value
.get("shapeOptions")
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.enumerate()
.map(|(index, item)| SquareHoleShapeOption {
option_id: read_text_field(item, "optionId")
.unwrap_or_else(|| format!("shape-option-{index}")),
shape_kind: read_text_field(item, "shapeKind")
.unwrap_or_else(|| fallback_shape_kind(index).to_string()),
label: read_text_field(item, "label")
.unwrap_or_else(|| fallback_shape_label(index).to_string()),
target_hole_id: read_text_field(item, "targetHoleId")
.filter(|value| hole_options.iter().any(|option| option.hole_id == *value))
.unwrap_or_else(|| {
hole_options
.get(index % hole_options.len().max(1))
.map(|option| option.hole_id.clone())
.unwrap_or_else(|| fallback_target_hole_id(index).to_string())
}),
image_prompt: read_text_field(item, "imagePrompt").unwrap_or_else(|| {
format!(
"{theme_text}主题的{}贴纸图,透明背景,明亮游戏资产",
fallback_shape_label(index)
)
}),
image_src: read_text_field(item, "imageSrc"),
})
.collect::<Vec<_>>()
})
.unwrap_or_else(|| {
session
.config
.shape_options
.iter()
.map(|option| SquareHoleShapeOption {
option_id: option.option_id.clone(),
shape_kind: option.shape_kind.clone(),
label: option.label.clone(),
target_hole_id: option.target_hole_id.clone(),
image_prompt: option.image_prompt.clone(),
image_src: option.image_src.clone(),
})
.collect()
});
normalize_shape_options(parsed, theme_text, hole_options)
}
fn parse_hole_options(
value: &JsonValue,
session: &SquareHoleAgentSessionRecord,
theme_text: &str,
) -> Vec<SquareHoleHoleOption> {
let parsed = value
.get("holeOptions")
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.enumerate()
.map(|(index, item)| SquareHoleHoleOption {
hole_id: read_text_field(item, "holeId")
.unwrap_or_else(|| format!("hole-option-{index}")),
hole_kind: read_text_field(item, "holeKind")
.unwrap_or_else(|| format!("hole-{}", index + 1)),
label: read_text_field(item, "label")
.unwrap_or_else(|| fallback_hole_label(index).to_string()),
image_prompt: read_text_field(item, "imagePrompt").unwrap_or_else(|| {
format!(
"{theme_text}主题的{}贴纸图,透明背景,明亮游戏资产",
fallback_hole_label(index)
)
}),
image_src: read_text_field(item, "imageSrc"),
})
.collect::<Vec<_>>()
})
.unwrap_or_else(|| {
session
.config
.hole_options
.iter()
.map(|option| SquareHoleHoleOption {
hole_id: option.hole_id.clone(),
hole_kind: option.hole_kind.clone(),
label: option.label.clone(),
image_prompt: option.image_prompt.clone(),
image_src: option.image_src.clone(),
})
.collect()
});
normalize_hole_options(parsed, theme_text)
}
fn read_text_field(value: &JsonValue, field_name: &str) -> Option<String> {
value
.get(field_name)
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(str::to_string)
}
fn read_u32_field(value: &JsonValue, field_name: &str) -> Option<u32> {
value
.get(field_name)
.and_then(JsonValue::as_u64)
.and_then(|number| u32::try_from(number).ok())
}
fn fallback_shape_kind(index: usize) -> &'static str {
match index % 6 {
0 => "square",
1 => "circle",
2 => "triangle",
3 => "diamond",
4 => "star",
_ => "arch",
}
}
fn fallback_shape_label(index: usize) -> &'static str {
match fallback_shape_kind(index) {
"square" => "方块",
"circle" => "圆块",
"triangle" => "三角块",
"diamond" => "菱形块",
"star" => "星形块",
_ => "拱形块",
}
}
fn fallback_hole_label(index: usize) -> String {
format!("洞口 {}", index + 1)
}
fn fallback_target_hole_id(index: usize) -> &'static str {
match index % 3 {
0 => "hole-1",
1 => "hole-2",
_ => "hole-3",
}
}
impl From<SquareHoleShapeOption> for SquareHoleAgentShapeOptionOutput {
fn from(option: SquareHoleShapeOption) -> Self {
Self {
option_id: option.option_id,
shape_kind: option.shape_kind,
label: option.label,
target_hole_id: option.target_hole_id,
image_prompt: option.image_prompt,
image_src: option.image_src.unwrap_or_default(),
}
}
}
impl From<SquareHoleHoleOption> for SquareHoleAgentHoleOptionOutput {
fn from(option: SquareHoleHoleOption) -> Self {
Self {
hole_id: option.hole_id,
hole_kind: option.hole_kind,
label: option.label,
image_prompt: option.image_prompt,
image_src: option.image_src.unwrap_or_default(),
}
}
}
fn resolve_stage(progress_percent: u32) -> String {
if progress_percent >= 100 {
"ReadyToCompile"
} else {
"Collecting"
}
.to_string()
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{parse_model_output, resolve_stage};
fn session_record() -> spacetime_client::SquareHoleAgentSessionRecord {
spacetime_client::SquareHoleAgentSessionRecord {
session_id: "square-hole-session-test".to_string(),
current_turn: 1,
progress_percent: 25,
stage: "collecting_config".to_string(),
anchor_pack: spacetime_client::SquareHoleAnchorPackRecord {
theme: anchor("theme", "题材主题", "纸箱"),
twist_rule: anchor("twistRule", "反差规则", "方洞万能"),
shape_count: anchor("shapeCount", "形状数量", "12"),
difficulty: anchor("difficulty", "难度", "4"),
},
config: spacetime_client::SquareHoleCreatorConfigRecord {
theme_text: "纸箱".to_string(),
twist_rule: "方洞万能".to_string(),
shape_count: 12,
difficulty: 4,
shape_options: Vec::new(),
hole_options: Vec::new(),
background_prompt: "纸箱玩具桌面背景".to_string(),
cover_image_src: None,
background_image_src: None,
},
draft: None,
messages: Vec::new(),
last_assistant_reply: None,
published_profile_id: None,
updated_at: "2026-05-04T10:00:00.000Z".to_string(),
}
}
fn anchor(key: &str, label: &str, value: &str) -> spacetime_client::SquareHoleAnchorItemRecord {
spacetime_client::SquareHoleAnchorItemRecord {
key: key.to_string(),
label: label.to_string(),
value: value.to_string(),
status: if value.is_empty() {
"missing"
} else {
"confirmed"
}
.to_string(),
}
}
#[test]
fn parse_model_output_accepts_camel_case_config_contract() {
let model_output = json!({
"replyText": "可以,把办公室文具都做成会被方洞吞进去的挑战。",
"progressPercent": 86,
"nextConfig": {
"themeText": "办公室文具",
"twistRule": "所有文具最终都优先进入方洞",
"shapeCount": 14,
"difficulty": 6,
"shapeOptions": [
{
"optionId": "stamp",
"shapeKind": "circle",
"label": "圆形印章",
"targetHoleId": "folder",
"imagePrompt": "办公室圆形印章贴纸"
}
],
"holeOptions": [
{
"holeId": "folder",
"holeKind": "folder",
"label": "档案盒方洞",
"imagePrompt": "办公室档案盒洞口贴纸"
}
],
"backgroundPrompt": "办公室桌面纸箱玩具背景"
}
});
let output =
parse_model_output(&model_output, &session_record()).expect("模型输出应能解析");
assert_eq!(
output.reply_text,
"可以,把办公室文具都做成会被方洞吞进去的挑战。"
);
assert_eq!(output.progress_percent, 86);
assert_eq!(output.next_config.theme_text, "办公室文具");
assert_eq!(output.next_config.twist_rule, "所有文具最终都优先进入方洞");
assert_eq!(output.next_config.shape_count, 14);
assert_eq!(output.next_config.difficulty, 6);
assert!(output.next_config.shape_options.len() >= 6);
assert_eq!(output.next_config.shape_options[0].label, "圆形印章");
assert_eq!(output.next_config.shape_options[0].target_hole_id, "folder");
assert_eq!(output.next_config.hole_options[0].label, "档案盒方洞");
assert_eq!(
output.next_config.hole_options[0].image_prompt,
"办公室档案盒洞口贴纸"
);
assert_eq!(
output.next_config.background_prompt,
"办公室桌面纸箱玩具背景"
);
}
#[test]
fn parse_model_output_clamps_numeric_config() {
let model_output = json!({
"replyText": "我先把数字压到可试玩范围里。",
"progressPercent": 120,
"nextConfig": {
"themeText": "霓虹积木",
"twistRule": "方洞优先",
"shapeCount": 99,
"difficulty": 0,
"shapeOptions": [],
"holeOptions": [],
"backgroundPrompt": ""
}
});
let output =
parse_model_output(&model_output, &session_record()).expect("模型输出应能解析");
assert_eq!(output.progress_percent, 100);
assert_eq!(output.next_config.shape_count, 24);
assert_eq!(output.next_config.difficulty, 1);
}
#[test]
fn resolve_stage_switches_to_compile_only_at_complete_progress() {
assert_eq!(resolve_stage(99), "Collecting");
assert_eq!(resolve_stage(100), "ReadyToCompile");
}
}

View File

@@ -1,7 +1,10 @@
use std::{error::Error, fmt, sync::Arc};
#[cfg(test)]
use std::{collections::HashMap, sync::Mutex};
use std::{
collections::HashMap,
error::Error,
fmt, fs,
sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
};
use module_ai::{AiTaskService, InMemoryAiTaskStore};
use module_auth::{
@@ -11,14 +14,17 @@ use module_auth::{
use module_runtime::RuntimeSnapshotRecord;
#[cfg(test)]
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_agent::MockLangChainRustAgentExecutor;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError,
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider,
SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token,
};
use platform_llm::{LlmClient, LlmConfig, LlmError};
use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider};
use platform_oss::{OssClient, OssConfig, OssError};
use serde_json::Value;
use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
use time::OffsetDateTime;
use tracing::{info, warn};
@@ -37,6 +43,8 @@ pub struct AppState {
auth_jwt_config: JwtConfig,
admin_runtime: Option<AdminRuntime>,
refresh_cookie_config: RefreshCookieConfig,
#[cfg(test)]
test_creation_entry_config: Arc<Mutex<Option<CreationEntryConfigResponse>>>,
oss_client: Option<OssClient>,
#[cfg_attr(test, allow(dead_code))]
auth_store: InMemoryAuthStore,
@@ -51,11 +59,22 @@ pub struct AppState {
ai_task_service: AiTaskService,
spacetime_client: SpacetimeClient,
llm_client: Option<LlmClient>,
creative_agent_gpt5_client: Option<LlmClient>,
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
// Phase 1 任务 E 的 creative session facade 暂存在 api-server。
// creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。
creative_agent_sessions: Arc<Mutex<HashMap<String, CreativeAgentSessionRuntimeRecord>>>,
#[cfg(test)]
// 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
}
#[derive(Clone, Debug)]
struct CreativeAgentSessionRuntimeRecord {
owner_user_id: String,
snapshot: CreativeAgentSessionSnapshot,
}
// 后台管理员运行态独立于普通玩家登录体系,只从环境变量构造。
#[derive(Clone, Debug)]
pub struct AdminRuntime {
@@ -167,12 +186,17 @@ impl AppState {
procedure_timeout: config.spacetime_procedure_timeout,
});
let llm_client = build_llm_client(&config)?;
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
Ok(Self {
config,
auth_jwt_config,
admin_runtime,
refresh_cookie_config,
#[cfg(test)]
test_creation_entry_config: Arc::new(Mutex::new(Some(
crate::creation_entry_config::test_creation_entry_config_response(),
))),
oss_client,
auth_store,
password_entry_service,
@@ -185,6 +209,9 @@ impl AppState {
ai_task_service,
spacetime_client,
llm_client,
creative_agent_gpt5_client,
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())),
#[cfg(test)]
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
})
@@ -202,6 +229,88 @@ impl AppState {
&self.refresh_cookie_config
}
pub async fn upsert_creation_entry_type_config(
&self,
input: module_runtime::CreationEntryTypeAdminUpsertInput,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
match self
.spacetime_client
.upsert_creation_entry_type_config(input)
.await
{
Ok(config) => {
#[cfg(test)]
self.cache_test_creation_entry_config(config.clone());
Ok(config)
}
#[cfg(test)]
Err(_) => Ok(self.read_test_creation_entry_config()),
#[cfg(not(test))]
Err(error) => Err(error),
}
}
pub async fn get_creation_entry_config(
&self,
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
match self.spacetime_client.get_creation_entry_config().await {
Ok(config) => {
#[cfg(test)]
self.cache_test_creation_entry_config(config.clone());
Ok(config)
}
#[cfg(test)]
Err(_) => Ok(self.read_test_creation_entry_config()),
#[cfg(not(test))]
Err(error) => Err(error),
}
}
pub async fn is_creation_entry_route_enabled(
&self,
creation_type_id: &str,
) -> Result<bool, SpacetimeClientError> {
let config = self.get_creation_entry_config().await?;
Ok(config
.creation_types
.iter()
.find(|item| item.id == creation_type_id)
.map(|item| item.open)
.unwrap_or(true))
}
#[cfg(test)]
pub(crate) fn set_test_creation_entry_route_enabled(
&self,
creation_type_id: impl AsRef<str>,
enabled: bool,
) {
let creation_type_id = creation_type_id.as_ref();
let mut config = self.read_test_creation_entry_config();
if let Some(item) = config
.creation_types
.iter_mut()
.find(|item| item.id == creation_type_id)
{
item.open = enabled;
} else {
config.creation_types.push(
shared_contracts::creation_entry_config::CreationEntryTypeResponse {
id: creation_type_id.to_string(),
title: creation_type_id.to_string(),
subtitle: String::new(),
badge: String::new(),
image_src: format!("/creation-type-references/{creation_type_id}.webp"),
visible: enabled,
open: enabled,
sort_order: i32::try_from(config.creation_types.len()).unwrap_or(i32::MAX),
updated_at_micros: 0,
},
);
}
self.cache_test_creation_entry_config(config);
}
pub fn oss_client(&self) -> Option<&OssClient> {
self.oss_client.as_ref()
}
@@ -261,18 +370,18 @@ impl AppState {
pool_size: config.spacetime_pool_size,
procedure_timeout: config.spacetime_procedure_timeout,
});
let mut candidates = Vec::new();
match spacetime_client
.export_auth_store_snapshot_from_tables()
.await
{
Ok(snapshot) => {
if let Some(snapshot_json) = snapshot.snapshot_json {
if !snapshot_json.trim().is_empty() {
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
.map_err(AppStateInitError::AuthStore)?;
info!("已从 SpacetimeDB 表恢复认证快照");
return Self::new_with_auth_store(config, auth_store);
}
if let Some(candidate) = auth_store_candidate_from_snapshot_record(
snapshot,
AuthStoreRestoreSource::SpacetimeTables,
)? {
candidates.push(candidate);
}
}
Err(error) => {
@@ -282,13 +391,11 @@ impl AppState {
match spacetime_client.get_auth_store_snapshot().await {
Ok(snapshot) => {
if let Some(snapshot_json) = snapshot.snapshot_json {
if !snapshot_json.trim().is_empty() {
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
.map_err(AppStateInitError::AuthStore)?;
info!("已从 SpacetimeDB 快照记录恢复认证快照");
return Self::new_with_auth_store(config, auth_store);
}
if let Some(candidate) = auth_store_candidate_from_snapshot_record(
snapshot,
AuthStoreRestoreSource::SpacetimeSnapshot,
)? {
candidates.push(candidate);
}
}
Err(error) => {
@@ -296,6 +403,30 @@ impl AppState {
}
}
if let Some(candidate) = auth_store_candidate_from_local_file(&config)? {
candidates.push(candidate);
}
if let Some(candidate) = select_auth_store_restore_candidate(candidates) {
let source = candidate.source;
let should_sync_to_spacetime = source == AuthStoreRestoreSource::LocalFile;
let state = Self::new_with_auth_store(config, candidate.auth_store)?;
info!(
source = source.as_str(),
updated_at_micros = candidate.updated_at_micros,
"已恢复认证快照"
);
if should_sync_to_spacetime {
if let Err(error) = state.sync_auth_store_snapshot_to_spacetime().await {
warn!(
error = %error,
"本地认证快照回写 SpacetimeDB 失败,当前启动继续"
);
}
}
return Ok(state);
}
Self::new(config)
}
@@ -336,6 +467,44 @@ impl AppState {
self.llm_client.as_ref()
}
pub fn creative_agent_gpt5_client(&self) -> Option<&LlmClient> {
self.creative_agent_gpt5_client.as_ref()
}
pub fn creative_agent_executor(&self) -> Arc<MockLangChainRustAgentExecutor> {
self.creative_agent_executor.clone()
}
pub fn get_creative_agent_session(
&self,
session_id: &str,
owner_user_id: &str,
) -> Option<CreativeAgentSessionSnapshot> {
self.creative_agent_sessions
.lock()
.expect("creative agent session store should lock")
.get(session_id)
.filter(|record| record.owner_user_id == owner_user_id)
.map(|record| record.snapshot.clone())
}
pub fn put_creative_agent_session(
&self,
owner_user_id: String,
session: CreativeAgentSessionSnapshot,
) {
self.creative_agent_sessions
.lock()
.expect("creative agent session store should lock")
.insert(
session.session_id.clone(),
CreativeAgentSessionRuntimeRecord {
owner_user_id,
snapshot: session,
},
);
}
pub async fn get_runtime_snapshot_record(
&self,
user_id: String,
@@ -431,6 +600,21 @@ impl AppState {
#[cfg(test)]
impl AppState {
fn cache_test_creation_entry_config(&self, config: CreationEntryConfigResponse) {
*self
.test_creation_entry_config
.lock()
.expect("test creation entry config should lock") = Some(config);
}
fn read_test_creation_entry_config(&self) -> CreationEntryConfigResponse {
self.test_creation_entry_config
.lock()
.expect("test creation entry config should lock")
.clone()
.unwrap_or_else(crate::creation_entry_config::test_creation_entry_config_response)
}
pub(crate) async fn seed_test_phone_user_with_password(
&self,
phone_number: &str,
@@ -534,6 +718,95 @@ impl AppState {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AuthStoreRestoreSource {
SpacetimeTables,
SpacetimeSnapshot,
LocalFile,
}
impl AuthStoreRestoreSource {
fn as_str(self) -> &'static str {
match self {
Self::SpacetimeTables => "spacetime_tables",
Self::SpacetimeSnapshot => "spacetime_snapshot",
Self::LocalFile => "local_file",
}
}
}
#[derive(Debug)]
struct AuthStoreRestoreCandidate {
source: AuthStoreRestoreSource,
updated_at_micros: Option<i64>,
auth_store: InMemoryAuthStore,
}
fn auth_store_candidate_from_snapshot_record(
snapshot: spacetime_client::AuthStoreSnapshotRecord,
source: AuthStoreRestoreSource,
) -> Result<Option<AuthStoreRestoreCandidate>, AppStateInitError> {
let Some(snapshot_json) = snapshot
.snapshot_json
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
.map_err(AppStateInitError::AuthStore)?;
Ok(Some(AuthStoreRestoreCandidate {
source,
updated_at_micros: snapshot.updated_at_micros,
auth_store,
}))
}
fn auth_store_candidate_from_local_file(
config: &AppConfig,
) -> Result<Option<AuthStoreRestoreCandidate>, AppStateInitError> {
if !config.auth_store_path.is_file() {
return Ok(None);
}
let updated_at_micros = fs::metadata(&config.auth_store_path)
.ok()
.and_then(|metadata| metadata.modified().ok())
.and_then(system_time_to_unix_micros);
let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone())
.map_err(AppStateInitError::AuthStore)?;
Ok(Some(AuthStoreRestoreCandidate {
source: AuthStoreRestoreSource::LocalFile,
updated_at_micros,
auth_store,
}))
}
fn system_time_to_unix_micros(system_time: SystemTime) -> Option<i64> {
let duration = system_time.duration_since(UNIX_EPOCH).ok()?;
i64::try_from(duration.as_micros()).ok()
}
fn select_auth_store_restore_candidate(
candidates: Vec<AuthStoreRestoreCandidate>,
) -> Option<AuthStoreRestoreCandidate> {
candidates.into_iter().max_by_key(|candidate| {
(
candidate.updated_at_micros.unwrap_or(i64::MIN),
auth_store_restore_source_priority(candidate.source),
)
})
}
fn auth_store_restore_source_priority(source: AuthStoreRestoreSource) -> u8 {
match source {
AuthStoreRestoreSource::SpacetimeSnapshot => 3,
AuthStoreRestoreSource::SpacetimeTables => 2,
AuthStoreRestoreSource::LocalFile => 1,
}
}
impl fmt::Display for AppStateInitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -710,6 +983,32 @@ fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateIni
Ok(Some(LlmClient::new(llm_config)?))
}
fn build_creative_agent_gpt5_client(
config: &AppConfig,
) -> Result<Option<LlmClient>, AppStateInitError> {
let Some(api_key) = config
.apimart_api_key
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let llm_config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
config.apimart_base_url.clone(),
api_key.to_string(),
platform_agent::CREATIVE_AGENT_GPT5_MODEL.to_string(),
config.llm_request_timeout_ms,
0,
config.llm_retry_backoff_ms,
)?
.with_official_fallback(true);
Ok(Some(LlmClient::new(llm_config)?))
}
// 只有在用户名和密码都已配置时才启用后台,避免半配置状态暴露伪入口。
fn build_admin_runtime(
config: &AppConfig,
@@ -783,5 +1082,29 @@ mod tests {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert!(state.llm_client().is_none());
assert!(state.creative_agent_gpt5_client().is_none());
}
#[test]
fn app_state_builds_creative_agent_gpt5_client_from_apimart_settings() {
let mut config = AppConfig::default();
config.llm_api_key = None;
config.apimart_base_url = "https://api.apimart.test/v1".to_string();
config.apimart_api_key = Some("apimart-key".to_string());
let state = AppState::new(config).expect("state should build");
let client = state
.creative_agent_gpt5_client()
.expect("creative agent gpt5 client should exist");
assert_eq!(
client.config().model(),
platform_agent::CREATIVE_AGENT_GPT5_MODEL
);
assert_eq!(
client.config().responses_url(),
"https://api.apimart.test/v1/responses"
);
assert!(client.config().official_fallback());
}
}

View File

@@ -115,7 +115,7 @@ pub async fn begin_story_runtime_session(
story_session_payload_from_record(story_result.session),
vec![story_event_payload_from_record(story_result.event)],
&persisted,
persisted.version,
None,
),
},
))
@@ -257,7 +257,7 @@ pub async fn resolve_story_runtime_action(
story_session_payload_from_record(story_result.session),
vec![story_event_payload_from_record(story_result.event)],
&persisted,
resolved.server_version.max(persisted.version),
Some(resolved.server_version),
),
},
))
@@ -395,7 +395,7 @@ fn build_story_runtime_projection_from_persisted(
story_session: StorySessionPayload,
story_events: Vec<StoryEventPayload>,
record: &RuntimeSnapshotRecord,
server_version: u32,
resolved_version: Option<u32>,
) -> shared_contracts::story::StoryRuntimeProjectionResponse {
let snapshot = story_runtime_snapshot_payload_from_record(record);
let current_story = snapshot.current_story.as_ref();
@@ -405,6 +405,8 @@ fn build_story_runtime_projection_from_persisted(
.or_else(|| Some(story_session.latest_narrative_text.clone()));
let action_result_text = read_story_runtime_current_field(current_story, "resultText");
let toast = read_story_runtime_current_field(current_story, "toast");
let server_version =
resolve_story_runtime_projection_version(&snapshot.game_state, resolved_version);
module_runtime_story::build_story_runtime_projection(
module_runtime_story::StoryRuntimeProjectionSource {
@@ -420,6 +422,15 @@ fn build_story_runtime_projection_from_persisted(
)
}
fn resolve_story_runtime_projection_version(
game_state: &Value,
resolved_version: Option<u32>,
) -> u32 {
module_runtime_story::read_u32_field(game_state, "runtimeActionVersion")
.or(resolved_version)
.unwrap_or(1)
}
fn read_story_runtime_current_text(current_story: Option<&Value>) -> Option<String> {
read_story_runtime_current_field(current_story, "text")
.or_else(|| read_story_runtime_current_field(current_story, "storyText"))
@@ -619,10 +630,12 @@ mod tests {
use time::OffsetDateTime;
use tower::ServiceExt;
use super::require_story_session_owner;
use super::{build_story_runtime_projection_from_persisted, require_story_session_owner};
use crate::{
app::build_router, config::AppConfig, request_context::RequestContext, state::AppState,
};
use module_runtime::RuntimeSnapshotRecord;
use shared_contracts::story::StorySessionPayload;
#[tokio::test]
async fn begin_story_session_requires_authentication() {
@@ -1028,6 +1041,56 @@ mod tests {
);
}
#[test]
fn story_runtime_projection_version_prefers_runtime_action_version() {
let projection = build_story_runtime_projection_from_persisted(
StorySessionPayload {
story_session_id: "storysess_001".to_string(),
runtime_session_id: "runtime_001".to_string(),
actor_user_id: "user_1".to_string(),
world_profile_id: "profile_1".to_string(),
initial_prompt: "进入营地".to_string(),
opening_summary: Some("营地开场".to_string()),
latest_narrative_text: "最新故事".to_string(),
latest_choice_function_id: Some("npc_chat".to_string()),
status: "active".to_string(),
version: 9,
created_at: "1.000000Z".to_string(),
updated_at: "3.000000Z".to_string(),
},
vec![],
&RuntimeSnapshotRecord {
user_id: "user_1".to_string(),
version: 2,
saved_at: "3.000000Z".to_string(),
saved_at_micros: 3,
bottom_tab: "adventure".to_string(),
game_state: json!({
"runtimeSessionId": "runtime_001",
"runtimeActionVersion": 7,
"playerHp": 30,
"playerMaxHp": 40,
"playerMana": 10,
"playerMaxMana": 20,
"playerCurrency": 0,
"playerInventory": [],
"playerEquipment": { "weapon": null, "armor": null, "relic": null },
"inBattle": false,
"npcInteractionActive": false,
"storyHistory": []
}),
current_story: None,
game_state_json: "{}".to_string(),
current_story_json: None,
created_at_micros: 1,
updated_at_micros: 3,
},
None,
);
assert_eq!(projection.server_version, 7);
}
#[test]
fn story_session_owner_guard_rejects_mismatched_actor() {
let context = RequestContext::new(

View File

@@ -0,0 +1,588 @@
use axum::http::{Method, StatusCode};
use module_auth::AuthLoginMethod;
use module_runtime::RuntimeTrackingScopeKind;
use serde_json::{Value, json};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::{auth::AuthenticatedAccessToken, request_context::RequestContext, state::AppState};
/// 后端用户行为埋点入口统一走这里:写入失败只记录日志,不反向阻断主业务。
#[derive(Clone, Debug)]
pub struct TrackingEventDraft {
pub event_key: &'static str,
pub scope_kind: RuntimeTrackingScopeKind,
pub scope_id: String,
pub user_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub module_key: Option<&'static str>,
pub metadata: Value,
}
impl TrackingEventDraft {
pub fn new(event_key: &'static str, module_key: &'static str) -> Self {
Self {
event_key,
scope_kind: RuntimeTrackingScopeKind::Site,
scope_id: "site".to_string(),
user_id: None,
owner_user_id: None,
profile_id: None,
module_key: Some(module_key),
metadata: json!({}),
}
}
pub fn user(event_key: &'static str, module_key: &'static str, user_id: &str) -> Self {
let normalized_user_id = user_id.trim().to_string();
let mut draft = Self::new(event_key, module_key);
draft.scope_kind = RuntimeTrackingScopeKind::User;
draft.scope_id = normalized_user_id.clone();
draft.user_id = Some(normalized_user_id.clone());
draft.owner_user_id = Some(normalized_user_id);
draft
}
}
#[derive(Clone, Debug)]
struct RouteTrackingSpec {
event_key: &'static str,
module_key: &'static str,
scope_kind: RuntimeTrackingScopeKind,
scope_id: &'static str,
}
pub async fn record_route_tracking_event_after_success(
state: &AppState,
request_context: &RequestContext,
method: &Method,
path: &str,
status: StatusCode,
authenticated: Option<&AuthenticatedAccessToken>,
) {
if !status.is_success() {
return;
}
let Some(spec) = resolve_route_tracking_spec(method, path) else {
return;
};
let user_id = authenticated.map(|auth| auth.claims().user_id().to_string());
let scope_id = match spec.scope_kind {
RuntimeTrackingScopeKind::User => {
user_id.clone().unwrap_or_else(|| spec.scope_id.to_string())
}
RuntimeTrackingScopeKind::Site => spec.scope_id.to_string(),
_ => spec.scope_id.to_string(),
};
let mut draft = TrackingEventDraft::new(spec.event_key, spec.module_key);
draft.scope_kind = spec.scope_kind;
draft.scope_id = scope_id;
draft.user_id = user_id;
draft.metadata = build_route_tracking_metadata(&spec, request_context, method, path, status);
if draft.user_id.is_some() {
draft.owner_user_id = draft.user_id.clone();
}
record_tracking_event_after_success(state, request_context, draft).await;
}
fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option<RouteTrackingSpec> {
use RuntimeTrackingScopeKind::{Site, User};
// 后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 明确排除,不走通用用户行为埋点。
if path.starts_with("/admin/")
|| path.contains("/big-fish")
|| path.contains("/visual-novel")
|| path.contains("/story")
|| path.contains("/combat")
|| path.contains("/rpg")
|| path.starts_with("/api/runtime/chat/")
{
return None;
}
let route = normalize_route_path(path);
match (method.as_str(), route.as_str()) {
("GET", "/api/auth/login-options") => {
Some(route_spec("auth_login_options_view", "auth", Site, "site"))
}
("POST", "/api/auth/phone/send-code") => {
Some(route_spec("auth_phone_code_send", "auth", Site, "site"))
}
("POST", "/api/auth/phone/login") => Some(route_spec(
"auth_phone_login_success",
"auth",
User,
"anonymous",
)),
("GET", "/api/auth/me") => Some(route_spec("auth_me_view", "auth", User, "anonymous")),
("GET", "/api/auth/sessions") => {
Some(route_spec("auth_sessions_view", "auth", User, "anonymous"))
}
("POST", "/api/auth/refresh") => {
Some(route_spec("auth_refresh_success", "auth", Site, "site"))
}
("POST", "/api/auth/logout") => Some(route_spec("auth_logout", "auth", User, "anonymous")),
("POST", "/api/auth/logout-all") => {
Some(route_spec("auth_logout_all", "auth", User, "anonymous"))
}
("POST", "/api/auth/wechat/bind-phone") => Some(route_spec(
"auth_wechat_bind_phone_success",
"auth",
User,
"anonymous",
)),
("PATCH", "/api/profile/me") => Some(route_spec(
"profile_identity_update",
"profile",
User,
"anonymous",
)),
("GET", "/api/profile/dashboard") => Some(route_spec(
"profile_dashboard_view",
"profile",
User,
"anonymous",
)),
("GET", "/api/profile/wallet-ledger") => Some(route_spec(
"wallet_ledger_view",
"profile",
User,
"anonymous",
)),
("GET", "/api/profile/recharge-center") => Some(route_spec(
"recharge_center_view",
"profile",
User,
"anonymous",
)),
("POST", "/api/profile/recharge/orders") => Some(route_spec(
"recharge_order_create",
"profile",
User,
"anonymous",
)),
("POST", "/api/profile/feedback") => {
Some(route_spec("feedback_submit", "profile", User, "anonymous"))
}
("GET", "/api/profile/referrals/invite-center") => Some(route_spec(
"invite_center_view",
"profile",
User,
"anonymous",
)),
("POST", "/api/profile/referrals/redeem-code") => Some(route_spec(
"referral_invite_code_redeem",
"profile",
User,
"anonymous",
)),
("POST", "/api/profile/redeem-codes/redeem") => Some(route_spec(
"redeem_code_submit",
"profile",
User,
"anonymous",
)),
("GET", "/api/profile/tasks") => {
Some(route_spec("task_center_view", "profile", User, "anonymous"))
}
("POST", "/api/profile/tasks/{id}/claim") => Some(route_spec(
"task_reward_claim",
"profile",
User,
"anonymous",
)),
("GET", "/api/profile/save-archives") => Some(route_spec(
"save_archive_list_view",
"profile",
User,
"anonymous",
)),
("GET", "/api/profile/save-archives/{id}") => Some(route_spec(
"save_archive_detail_view",
"profile",
User,
"anonymous",
)),
("GET", "/api/profile/browse-history") => Some(route_spec(
"browse_history_view",
"profile",
User,
"anonymous",
)),
("POST", "/api/profile/browse-history") => Some(route_spec(
"browse_history_record",
"profile",
User,
"anonymous",
)),
("DELETE", "/api/profile/browse-history") => Some(route_spec(
"browse_history_clear",
"profile",
User,
"anonymous",
)),
("GET", "/api/profile/play-stats") => {
Some(route_spec("play_stats_view", "profile", User, "anonymous"))
}
("GET", "/api/profile/analytics/metric") => Some(route_spec(
"profile_analytics_metric_view",
"profile",
User,
"anonymous",
)),
("POST", "/api/ai/tasks") => Some(route_spec("ai_task_create", "ai", User, "anonymous")),
("POST", "/api/ai/tasks/{id}/start") => {
Some(route_spec("ai_task_start", "ai", User, "anonymous"))
}
("POST", "/api/ai/tasks/{id}/stages/{id}/start") => {
Some(route_spec("ai_task_stage_start", "ai", User, "anonymous"))
}
("POST", "/api/ai/tasks/{id}/chunks") => {
Some(route_spec("ai_task_chunk_append", "ai", User, "anonymous"))
}
("POST", "/api/ai/tasks/{id}/stages/{id}/complete") => Some(route_spec(
"ai_task_stage_complete",
"ai",
User,
"anonymous",
)),
("POST", "/api/ai/tasks/{id}/references") => Some(route_spec(
"ai_task_reference_attach",
"ai",
User,
"anonymous",
)),
("POST", "/api/ai/tasks/{id}/complete") => {
Some(route_spec("ai_task_complete", "ai", User, "anonymous"))
}
("POST", "/api/ai/tasks/{id}/fail") => {
Some(route_spec("ai_task_fail", "ai", User, "anonymous"))
}
("POST", "/api/ai/tasks/{id}/cancel") => {
Some(route_spec("ai_task_cancel", "ai", User, "anonymous"))
}
("POST", "/api/assets/sts-upload-credentials") => Some(route_spec(
"asset_sts_credentials_create",
"asset",
User,
"anonymous",
)),
("POST", "/api/assets/character-visual/generate") => Some(route_spec(
"asset_character_visual_generate",
"asset",
User,
"anonymous",
)),
("POST", "/api/assets/character-visual/publish") => Some(route_spec(
"asset_character_visual_publish",
"asset",
User,
"anonymous",
)),
("POST", "/api/assets/character-animation/generate") => Some(route_spec(
"asset_character_animation_generate",
"asset",
User,
"anonymous",
)),
("POST", "/api/assets/character-animation/publish") => Some(route_spec(
"asset_character_animation_publish",
"asset",
User,
"anonymous",
)),
("POST", "/api/assets/character-animation/import-video") => Some(route_spec(
"asset_character_animation_import",
"asset",
User,
"anonymous",
)),
("POST", "/api/assets/character-workflow-cache") => Some(route_spec(
"asset_character_workflow_cache_save",
"asset",
User,
"anonymous",
)),
("GET", "/api/assets/history") => {
Some(route_spec("asset_history_view", "asset", User, "anonymous"))
}
("POST", "/api/llm/chat/completions") => {
Some(route_spec("llm_request", "llm", User, "anonymous"))
}
("GET", "/api/speech/volcengine/config") => Some(route_spec(
"speech_config_view",
"speech",
User,
"anonymous",
)),
("GET", "/api/speech/volcengine/asr/stream") => {
Some(route_spec("asr_stream_start", "speech", User, "anonymous"))
}
("GET", "/api/speech/volcengine/tts/bidirection") => Some(route_spec(
"tts_bidirection_start",
"speech",
User,
"anonymous",
)),
("POST", "/api/speech/volcengine/tts/sse") => {
Some(route_spec("tts_sse_start", "speech", User, "anonymous"))
}
("GET", "/api/runtime/settings") => Some(route_spec(
"runtime_settings_view",
"runtime",
User,
"anonymous",
)),
("PUT", "/api/runtime/settings") => Some(route_spec(
"runtime_settings_update",
"runtime",
User,
"anonymous",
)),
("GET", "/api/runtime/save/snapshot") => Some(route_spec(
"runtime_snapshot_view",
"runtime",
User,
"anonymous",
)),
("PUT", "/api/runtime/save/snapshot") => Some(route_spec(
"runtime_snapshot_save",
"runtime",
User,
"anonymous",
)),
("DELETE", "/api/runtime/save/snapshot") => Some(route_spec(
"runtime_snapshot_delete",
"runtime",
User,
"anonymous",
)),
_ if route.starts_with("/api/runtime/puzzle/") => Some(route_spec(
"puzzle_route_success",
"puzzle",
user_scope_for(method),
"anonymous",
)),
_ if route.starts_with("/api/creation/match3d/")
|| route.starts_with("/api/runtime/match3d/") =>
{
Some(route_spec(
"match3d_route_success",
"match3d",
user_scope_for(method),
"anonymous",
))
}
_ if route.starts_with("/api/creation/square-hole/")
|| route.starts_with("/api/runtime/square-hole/") =>
{
Some(route_spec(
"square_hole_route_success",
"square-hole",
user_scope_for(method),
"anonymous",
))
}
_ if route.starts_with("/api/runtime/custom-world") => Some(route_spec(
"custom_world_route_success",
"custom-world",
user_scope_for(method),
"anonymous",
)),
_ if route.starts_with("/api/runtime/creative-agent") => Some(route_spec(
"creative_agent_route_success",
"creative-agent",
user_scope_for(method),
"anonymous",
)),
_ => None,
}
}
fn route_spec(
event_key: &'static str,
module_key: &'static str,
scope_kind: RuntimeTrackingScopeKind,
scope_id: &'static str,
) -> RouteTrackingSpec {
RouteTrackingSpec {
event_key,
module_key,
scope_kind,
scope_id,
}
}
fn user_scope_for(method: &Method) -> RuntimeTrackingScopeKind {
if matches!(*method, Method::GET) {
RuntimeTrackingScopeKind::Site
} else {
RuntimeTrackingScopeKind::User
}
}
fn build_route_tracking_metadata(
spec: &RouteTrackingSpec,
request_context: &RequestContext,
method: &Method,
path: &str,
status: StatusCode,
) -> Value {
let mut metadata = json!({
"route": path,
"method": method.as_str(),
"status": status.as_u16(),
"operation": request_context.operation(),
});
if spec.module_key == "asset" {
metadata["asset"] = build_asset_route_metadata(spec.event_key, path);
metadata["assetOperation"] = json!(spec.event_key);
}
metadata
}
fn build_asset_route_metadata(event_key: &str, path: &str) -> Value {
json!({
"operation": event_key,
"operationFamily": resolve_asset_operation_family(event_key),
"route": path,
})
}
fn resolve_asset_operation_family(event_key: &str) -> &'static str {
match event_key {
"asset_upload_ticket_create" => "upload_ticket",
"asset_sts_credentials_create" => "sts_credentials",
"asset_upload_confirm" => "object_confirm",
"asset_bind" => "object_bind",
"asset_character_visual_generate" => "character_visual_generate",
"asset_character_visual_publish" => "character_visual_publish",
"asset_character_animation_generate" => "character_animation_generate",
"asset_character_animation_publish" => "character_animation_publish",
"asset_character_animation_import" => "character_animation_import",
"asset_character_workflow_cache_save" => "character_workflow_cache_save",
"asset_history_view" => "history_view",
_ => "asset_operation",
}
}
fn normalize_route_path(path: &str) -> String {
let mut normalized = String::new();
for segment in path.trim_end_matches('/').split('/') {
if segment.is_empty() {
continue;
}
normalized.push('/');
normalized.push_str(if is_dynamic_path_segment(segment) {
"{id}"
} else {
segment
});
}
if normalized.is_empty() {
"/".to_string()
} else {
normalized
}
}
fn is_dynamic_path_segment(segment: &str) -> bool {
let lower = segment.to_ascii_lowercase();
segment.len() >= 8
|| segment.chars().any(|ch| ch.is_ascii_digit())
|| lower.starts_with("world")
|| lower.starts_with("task")
|| lower.starts_with("profile")
|| lower.starts_with("session")
}
pub async fn record_daily_login_tracking_event_after_success(
state: &AppState,
request_context: &RequestContext,
user_id: &str,
login_method: AuthLoginMethod,
) {
let mut draft = TrackingEventDraft::user("daily_login", "profile", user_id);
draft.metadata = json!({
"operation": request_context.operation(),
"loginMethod": login_method.as_str(),
});
record_tracking_event_after_success(state, request_context, draft).await;
}
pub async fn record_tracking_event_after_success(
state: &AppState,
request_context: &RequestContext,
draft: TrackingEventDraft,
) {
let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let event_id = build_tracking_event_id(&draft, occurred_at_micros);
let event_key = draft.event_key.to_string();
let scope_kind = draft.scope_kind;
let scope_id = draft.scope_id;
let metadata_json = draft.metadata.to_string();
match state
.spacetime_client()
.record_tracking_event(
event_id,
event_key.clone(),
scope_kind,
scope_id.clone(),
draft.user_id,
draft.owner_user_id,
draft.profile_id,
draft.module_key.map(str::to_string),
metadata_json,
occurred_at_micros as i64,
)
.await
{
Ok(()) => tracing::info!(
request_id = request_context.request_id(),
operation = request_context.operation(),
event_key = %event_key,
scope_kind = %scope_kind.as_str(),
scope_id = %scope_id,
"后端埋点已记录"
),
Err(error) => tracing::warn!(
request_id = request_context.request_id(),
operation = request_context.operation(),
event_key = %event_key,
scope_kind = %scope_kind.as_str(),
scope_id = %scope_id,
error = %error,
"后端埋点记录失败,主业务流程继续"
),
}
}
fn build_tracking_event_id(draft: &TrackingEventDraft, occurred_at_micros: i128) -> String {
if draft.event_key == "daily_login"
&& draft.scope_kind == RuntimeTrackingScopeKind::User
&& !draft.scope_id.trim().is_empty()
{
let day_key = runtime_profile_beijing_day_key(occurred_at_micros as i64);
return format!("daily-login:{}:{}", draft.scope_id.trim(), day_key);
}
format!(
"api:{}:{}:{}",
draft.event_key,
occurred_at_micros,
Uuid::new_v4()
)
}
fn runtime_profile_beijing_day_key(occurred_at_micros: i64) -> i64 {
const PROFILE_TASK_BEIJING_OFFSET_MICROS: i64 = 28_800_000_000;
const PROFILE_RUNTIME_DAY_MICROS: i64 = 86_400_000_000;
(occurred_at_micros + PROFILE_TASK_BEIJING_OFFSET_MICROS).div_euclid(PROFILE_RUNTIME_DAY_MICROS)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,551 @@
use axum::{
Json,
body::Body,
extract::{
State,
ws::{Message as ClientWsMessage, WebSocket, WebSocketUpgrade},
},
http::{HeaderValue, StatusCode, header},
response::Response,
};
use futures_util::{SinkExt, StreamExt, TryStreamExt};
use platform_speech::{
AsrAudioConfig, AsrFrameKind, PublicSpeechConfig, PublicSpeechEndpoints, SpeechError,
TtsAudioParams, TtsBidirectionClientEvent, TtsSseRequest, UpstreamWsError, UpstreamWsMessage,
VolcengineSpeechClient, VolcengineSpeechConfig, build_asr_frame, build_asr_full_client_request,
build_tts_bidirection_frame_from_client_event, default_asr_request_payload,
parse_asr_response_frame, parse_tts_response_frame, tts_response_to_client_value,
};
use serde_json::{Value, json};
use tracing::{info, warn};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
};
const PROVIDER: &str = "volcengine-speech";
pub async fn get_volcengine_speech_config(
State(state): State<AppState>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
) -> Json<Value> {
json_success_body(Some(&request_context), public_speech_config(&state))
}
pub async fn stream_volcengine_asr(
State(state): State<AppState>,
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
ws: WebSocketUpgrade,
) -> Result<Response, Response> {
let client = build_speech_client(&state)
.map_err(|error| map_speech_error(error).into_response_with_context(None))?;
let user_id = authenticated.claims().user_id().to_string();
Ok(ws.on_upgrade(move |socket| proxy_asr_websocket(socket, client, user_id)))
}
pub async fn stream_volcengine_tts_bidirection(
State(state): State<AppState>,
ws: WebSocketUpgrade,
) -> Result<Response, Response> {
let client = build_speech_client(&state)
.map_err(|error| map_speech_error(error).into_response_with_context(None))?;
Ok(ws.on_upgrade(move |socket| proxy_tts_bidirection_websocket(socket, client)))
}
pub async fn stream_volcengine_tts_sse(
State(state): State<AppState>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
payload: Result<Json<TtsSseRequest>, axum::extract::rejection::JsonRejection>,
) -> Result<Response, Response> {
let Json(payload) = payload.map_err(|rejection| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message(format!("请求体 JSON 不合法:{rejection}"))
.into_response_with_context(Some(&request_context))
})?;
let client = build_speech_client(&state).map_err(|error| {
map_speech_error(error).into_response_with_context(Some(&request_context))
})?;
let upstream_request = client
.build_tts_sse_upstream_request(payload, authenticated.claims().user_id())
.map_err(|error| {
map_speech_error(error).into_response_with_context(Some(&request_context))
})?;
let http_client = reqwest::Client::builder()
.timeout(upstream_request.timeout)
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_details(json!({
"provider": PROVIDER,
"message": format!("构造火山语音 HTTP 客户端失败:{error}"),
}))
.into_response_with_context(Some(&request_context))
})?;
let upstream_response = http_client
.post(upstream_request.url)
.headers(upstream_request.headers)
.json(&upstream_request.body)
.send()
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_details(json!({
"provider": PROVIDER,
"message": format!("请求火山 TTS SSE 失败:{error}"),
}))
.into_response_with_context(Some(&request_context))
})?;
let status = upstream_response.status();
let log_id = upstream_response
.headers()
.get("X-Tt-Logid")
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned);
if !status.is_success() {
let raw_text = upstream_response.text().await.unwrap_or_default();
return Err(AppError::from_status(StatusCode::BAD_GATEWAY)
.with_details(json!({
"provider": PROVIDER,
"status": status.as_u16(),
"logId": log_id,
"rawExcerpt": raw_text.chars().take(800).collect::<String>(),
}))
.into_response_with_context(Some(&request_context)));
}
let byte_stream = upstream_response
.bytes_stream()
.map_err(std::io::Error::other);
let mut response = Response::new(Body::from_stream(byte_stream));
*response.status_mut() = StatusCode::OK;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/event-stream; charset=utf-8"),
);
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
if let Some(log_id) = log_id.and_then(|value| HeaderValue::from_str(&value).ok()) {
response.headers_mut().insert("x-volcengine-logid", log_id);
}
Ok(response)
}
async fn proxy_asr_websocket(socket: WebSocket, client: VolcengineSpeechClient, user_id: String) {
let (mut browser_sender, mut browser_receiver) = socket.split();
let Ok((upstream, response_headers)) = client.connect_asr().await else {
let _ = browser_sender
.send(ClientWsMessage::Text(
json!({
"type": "error",
"provider": PROVIDER,
"message": "连接火山 ASR WebSocket 失败",
})
.to_string()
.into(),
))
.await;
return;
};
if let Some(log_id) = response_headers.get("x-tt-logid") {
info!(%log_id, "火山 ASR WebSocket 已连接");
}
let (mut upstream_sender, mut upstream_receiver) = upstream.split();
let mut has_sent_start = false;
let mut last_audio_sent = false;
let browser_to_upstream = async {
while let Some(message) = browser_receiver.next().await {
match message {
Ok(ClientWsMessage::Text(text)) => {
let value = serde_json::from_str::<Value>(text.as_str()).unwrap_or_else(|_| {
json!({
"request": {
"context": text.as_str(),
}
})
});
if value
.get("type")
.and_then(Value::as_str)
.is_some_and(|kind| kind.eq_ignore_ascii_case("finish"))
{
let frame = build_asr_frame(AsrFrameKind::LastAudio, &[])?;
upstream_sender
.send(UpstreamWsMessage::Binary(frame.into()))
.await
.map_err(map_ws_send_error)?;
last_audio_sent = true;
continue;
}
if !has_sent_start {
let payload = default_asr_request_payload(&user_id, Some(value));
let frame = build_asr_full_client_request(&payload)?;
upstream_sender
.send(UpstreamWsMessage::Binary(frame.into()))
.await
.map_err(map_ws_send_error)?;
has_sent_start = true;
}
}
Ok(ClientWsMessage::Binary(bytes)) => {
if !has_sent_start {
let payload = default_asr_request_payload(&user_id, None);
let frame = build_asr_full_client_request(&payload)?;
upstream_sender
.send(UpstreamWsMessage::Binary(frame.into()))
.await
.map_err(map_ws_send_error)?;
has_sent_start = true;
}
let frame = build_asr_frame(AsrFrameKind::Audio, &bytes)?;
upstream_sender
.send(UpstreamWsMessage::Binary(frame.into()))
.await
.map_err(map_ws_send_error)?;
}
Ok(ClientWsMessage::Close(_)) => break,
Ok(ClientWsMessage::Ping(bytes)) => {
upstream_sender
.send(UpstreamWsMessage::Ping(bytes))
.await
.map_err(map_ws_send_error)?;
}
Ok(ClientWsMessage::Pong(_)) => {}
Err(error) => {
return Err(SpeechError::Upstream(format!(
"读取浏览器 ASR WebSocket 失败:{error}"
)));
}
}
}
if has_sent_start && !last_audio_sent {
let frame = build_asr_frame(AsrFrameKind::LastAudio, &[])?;
let _ = upstream_sender
.send(UpstreamWsMessage::Binary(frame.into()))
.await;
}
Ok::<(), SpeechError>(())
};
let upstream_to_browser = async {
while let Some(message) = upstream_receiver.next().await {
match message {
Ok(UpstreamWsMessage::Binary(bytes)) => {
let parsed = parse_asr_response_frame(&bytes)?;
let value = json!({
"type": "asr_response",
"sequence": parsed.sequence,
"payload": parsed.payload,
"errorCode": parsed.error_code,
});
browser_sender
.send(ClientWsMessage::Text(value.to_string().into()))
.await
.map_err(map_client_ws_send_error)?;
}
Ok(UpstreamWsMessage::Text(text)) => {
browser_sender
.send(ClientWsMessage::Text(text.to_string().into()))
.await
.map_err(map_client_ws_send_error)?;
}
Ok(UpstreamWsMessage::Close(_)) => {
let _ = browser_sender.send(ClientWsMessage::Close(None)).await;
break;
}
Ok(UpstreamWsMessage::Ping(bytes)) => {
browser_sender
.send(ClientWsMessage::Ping(bytes))
.await
.map_err(map_client_ws_send_error)?;
}
Ok(UpstreamWsMessage::Pong(_)) => {}
Ok(UpstreamWsMessage::Frame(_)) => {}
Err(error) => {
return Err(SpeechError::Upstream(format!(
"读取火山 ASR WebSocket 失败:{error}"
)));
}
}
}
Ok::<(), SpeechError>(())
};
let mut browser_to_upstream = Box::pin(browser_to_upstream);
let mut upstream_to_browser = Box::pin(upstream_to_browser);
let result = tokio::select! {
result = &mut browser_to_upstream => result,
result = &mut upstream_to_browser => result,
};
if let Err(error) = result {
warn!(error = %error, "火山 ASR WebSocket 代理中断");
}
}
async fn proxy_tts_bidirection_websocket(socket: WebSocket, client: VolcengineSpeechClient) {
let (mut browser_sender, mut browser_receiver) = socket.split();
let Ok((upstream, response_headers)) = client.connect_tts_bidirection().await else {
let _ = browser_sender
.send(ClientWsMessage::Text(
json!({
"type": "error",
"provider": PROVIDER,
"message": "连接火山 TTS WebSocket 失败",
})
.to_string()
.into(),
))
.await;
return;
};
if let Some(log_id) = response_headers.get("x-tt-logid") {
info!(%log_id, "火山 TTS WebSocket 已连接");
}
let (mut upstream_sender, mut upstream_receiver) = upstream.split();
let browser_to_upstream = async {
while let Some(message) = browser_receiver.next().await {
match message {
Ok(ClientWsMessage::Text(text)) => {
let event = serde_json::from_str::<TtsBidirectionClientEvent>(text.as_str())
.map_err(|error| {
SpeechError::InvalidFrame(format!(
"TTS 浏览器事件 JSON 不合法:{error}"
))
})?;
let frame = build_tts_bidirection_frame_from_client_event(event)?;
upstream_sender
.send(UpstreamWsMessage::Binary(frame.into()))
.await
.map_err(map_ws_send_error)?;
}
Ok(ClientWsMessage::Close(_)) => break,
Ok(ClientWsMessage::Ping(bytes)) => {
upstream_sender
.send(UpstreamWsMessage::Ping(bytes))
.await
.map_err(map_ws_send_error)?;
}
Ok(ClientWsMessage::Binary(_)) | Ok(ClientWsMessage::Pong(_)) => {}
Err(error) => {
return Err(SpeechError::Upstream(format!(
"读取浏览器 TTS WebSocket 失败:{error}"
)));
}
}
}
Ok::<(), SpeechError>(())
};
let upstream_to_browser = async {
while let Some(message) = upstream_receiver.next().await {
match message {
Ok(UpstreamWsMessage::Binary(bytes)) => {
let parsed = parse_tts_response_frame(&bytes)?;
if let Some(audio) = parsed.audio.clone() {
browser_sender
.send(ClientWsMessage::Binary(audio.into()))
.await
.map_err(map_client_ws_send_error)?;
}
if parsed.payload.is_some() || parsed.error_code.is_some() {
browser_sender
.send(ClientWsMessage::Text(
tts_response_to_client_value(&parsed).to_string().into(),
))
.await
.map_err(map_client_ws_send_error)?;
}
}
Ok(UpstreamWsMessage::Text(text)) => {
browser_sender
.send(ClientWsMessage::Text(text.to_string().into()))
.await
.map_err(map_client_ws_send_error)?;
}
Ok(UpstreamWsMessage::Close(_)) => {
let _ = browser_sender.send(ClientWsMessage::Close(None)).await;
break;
}
Ok(UpstreamWsMessage::Ping(bytes)) => {
browser_sender
.send(ClientWsMessage::Ping(bytes))
.await
.map_err(map_client_ws_send_error)?;
}
Ok(UpstreamWsMessage::Pong(_)) => {}
Ok(UpstreamWsMessage::Frame(_)) => {}
Err(error) => {
return Err(SpeechError::Upstream(format!(
"读取火山 TTS WebSocket 失败:{error}"
)));
}
}
}
Ok::<(), SpeechError>(())
};
let mut browser_to_upstream = Box::pin(browser_to_upstream);
let mut upstream_to_browser = Box::pin(upstream_to_browser);
let result = tokio::select! {
result = &mut browser_to_upstream => result,
result = &mut upstream_to_browser => result,
};
if let Err(error) = result {
warn!(error = %error, "火山 TTS WebSocket 代理中断");
}
}
fn build_speech_client(state: &AppState) -> Result<VolcengineSpeechClient, SpeechError> {
Ok(VolcengineSpeechClient::new(VolcengineSpeechConfig::new(
state.config.volcengine_speech_api_key.clone(),
state.config.volcengine_speech_app_id.clone(),
state.config.volcengine_speech_access_key.clone(),
state.config.volcengine_speech_asr_resource_id.clone(),
state.config.volcengine_speech_tts_resource_id.clone(),
state.config.volcengine_speech_asr_ws_url.clone(),
state
.config
.volcengine_speech_tts_bidirection_ws_url
.clone(),
state.config.volcengine_speech_tts_sse_url.clone(),
state.config.volcengine_speech_request_timeout_ms,
)?))
}
fn public_speech_config(state: &AppState) -> PublicSpeechConfig {
PublicSpeechConfig {
asr_resource_id: state.config.volcengine_speech_asr_resource_id.clone(),
tts_resource_id: state.config.volcengine_speech_tts_resource_id.clone(),
asr_audio: AsrAudioConfig::default(),
tts_audio: TtsAudioParams::default(),
endpoints: PublicSpeechEndpoints {
asr_stream: "/api/speech/volcengine/asr/stream",
tts_bidirection: "/api/speech/volcengine/tts/bidirection",
tts_sse: "/api/speech/volcengine/tts/sse",
},
}
}
fn map_speech_error(error: SpeechError) -> AppError {
match error {
SpeechError::InvalidConfig(message) => {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": PROVIDER,
"message": message,
}))
}
SpeechError::InvalidHeader(message)
| SpeechError::InvalidFrame(message)
| SpeechError::Serialize(message)
| SpeechError::Io(message)
| SpeechError::Upstream(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_details(json!({
"provider": PROVIDER,
"message": message,
})),
}
}
fn map_ws_send_error(error: UpstreamWsError) -> SpeechError {
SpeechError::Upstream(format!("发送火山语音 WebSocket 帧失败:{error}"))
}
fn map_client_ws_send_error(error: axum::Error) -> SpeechError {
SpeechError::Upstream(format!("发送浏览器语音 WebSocket 帧失败:{error}"))
}
#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use serde_json::Value;
use tower::ServiceExt;
use super::*;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::test]
async fn speech_config_route_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/api/speech/volcengine/config")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn speech_config_route_returns_no_secret_fields() {
let mut config = AppConfig::default();
config.volcengine_speech_api_key = Some("secret-key".to_string());
let state = AppState::new(config).expect("state should build");
state
.seed_test_phone_user_with_password("13800138088", "Password123")
.await;
let app = build_router(state);
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
json!({
"phone": "13800138088",
"password": "Password123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login should complete");
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 body should be json");
let token = login_payload["token"].as_str().expect("token should exist");
let response = app
.oneshot(
Request::builder()
.uri("/api/speech/volcengine/config")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload_text = String::from_utf8_lossy(&body);
assert!(!payload_text.contains("secret-key"));
assert!(!payload_text.contains("apiKey"));
assert!(payload_text.contains("asrResourceId"));
}
}

View File

@@ -22,6 +22,7 @@ use crate::{
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
record_daily_login_tracking_event_after_auth_success,
},
http_error::AppError,
platform_errors::{attach_retry_after, map_wechat_provider_error},
@@ -75,6 +76,7 @@ pub async fn start_wechat_login(
pub async fn handle_wechat_callback(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
headers: HeaderMap,
Query(query): Query<WechatCallbackQuery>,
) -> Result<impl IntoResponse, AppError> {
@@ -142,6 +144,13 @@ pub async fn handle_wechat_callback(
&session_client,
AuthLoginMethod::Wechat,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
AuthLoginMethod::Wechat,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
@@ -209,6 +218,13 @@ pub async fn bind_wechat_phone(
&session_client,
AuthLoginMethod::Wechat,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
AuthLoginMethod::Wechat,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
@@ -443,6 +459,12 @@ fn map_wechat_bind_phone_error(error: module_auth::PhoneAuthError) -> AppError {
module_auth::PhoneAuthError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
module_auth::PhoneAuthError::SmsProviderInvalidConfig(_) => {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(error.to_string())
}
module_auth::PhoneAuthError::SmsProviderUpstream(_) => {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(error.to_string())
}
module_auth::PhoneAuthError::Store(_) | module_auth::PhoneAuthError::PasswordHash(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}

View File

@@ -0,0 +1,111 @@
use module_runtime::RuntimeTrackingScopeKind;
use serde_json::{Value, json};
use crate::{
auth::AuthenticatedAccessToken,
request_context::RequestContext,
state::AppState,
tracking::{TrackingEventDraft, record_tracking_event_after_success},
};
pub(crate) const WORK_PLAY_START_EVENT_KEY: &str = "work_play_start";
pub(crate) struct WorkPlayTrackingDraft {
pub play_type: &'static str,
pub work_id: String,
pub user_id: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub run_id: Option<String>,
pub source_route: &'static str,
pub extra: Value,
}
impl WorkPlayTrackingDraft {
pub(crate) fn new(
play_type: &'static str,
work_id: impl Into<String>,
authenticated: &AuthenticatedAccessToken,
source_route: &'static str,
) -> Self {
let user_id = authenticated.claims().user_id().to_string();
Self {
play_type,
work_id: work_id.into(),
user_id,
owner_user_id: None,
profile_id: None,
run_id: None,
source_route,
extra: json!({}),
}
}
pub(crate) fn owner_user_id(mut self, owner_user_id: impl Into<String>) -> Self {
self.owner_user_id = Some(owner_user_id.into());
self
}
pub(crate) fn profile_id(mut self, profile_id: impl Into<String>) -> Self {
self.profile_id = Some(profile_id.into());
self
}
pub(crate) fn run_id(mut self, run_id: impl Into<String>) -> Self {
self.run_id = Some(run_id.into());
self
}
pub(crate) fn extra(mut self, extra: Value) -> Self {
self.extra = extra;
self
}
}
/// 作品级正式游玩埋点scope 固定为 workscope_id 固定为稳定作品 ID。
/// 中文注释:该埋点用于“某作品被多少用户玩过”等分析,写入失败不阻断 runtime 主流程。
pub(crate) async fn record_work_play_start_after_success(
state: &AppState,
request_context: &RequestContext,
draft: WorkPlayTrackingDraft,
) {
let mut metadata = json!({
"operation": WORK_PLAY_START_EVENT_KEY,
"playType": draft.play_type,
"workId": draft.work_id,
"sourceRoute": draft.source_route,
});
metadata["userId"] = json!(draft.user_id);
if let Some(owner_user_id) = draft.owner_user_id.as_deref() {
metadata["ownerUserId"] = json!(owner_user_id);
}
if let Some(profile_id) = draft.profile_id.as_deref() {
metadata["profileId"] = json!(profile_id);
}
if let Some(run_id) = draft.run_id.as_deref() {
metadata["runId"] = json!(run_id);
}
if !draft.extra.is_null() {
metadata["extra"] = draft.extra;
}
let mut tracking = TrackingEventDraft::new(WORK_PLAY_START_EVENT_KEY, draft.play_type);
tracking.scope_kind = RuntimeTrackingScopeKind::Work;
tracking.scope_id = draft.work_id;
tracking.user_id = Some(draft.user_id);
tracking.owner_user_id = draft.owner_user_id;
tracking.profile_id = draft.profile_id;
tracking.metadata = metadata;
record_tracking_event_after_success(state, request_context, tracking).await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn work_play_event_key_is_stable() {
assert_eq!(WORK_PLAY_START_EVENT_KEY, "work_play_start");
}
}