Merge origin/master into codex/wechat
This commit is contained in:
@@ -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
@@ -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(®ion_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>> {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&")
|
||||
}
|
||||
|
||||
fn normalize_document_text(value: &str) -> String {
|
||||
value
|
||||
.trim_start_matches('\u{feff}')
|
||||
|
||||
@@ -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]
|
||||
|
||||
177
server-rs/crates/api-server/src/creation_entry_config.rs
Normal file
177
server-rs/crates/api-server/src/creation_entry_config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
1434
server-rs/crates/api-server/src/creative_agent.rs
Normal file
1434
server-rs/crates/api-server/src/creative_agent.rs
Normal file
File diff suppressed because it is too large
Load Diff
69
server-rs/crates/api-server/src/creative_agent_sse.rs
Normal file
69
server-rs/crates/api-server/src/creative_agent_sse.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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网格格式创建故事板,16:9。像素风角色扮演游戏开场动画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())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", "请求参数不合法"),
|
||||
|
||||
1163
server-rs/crates/api-server/src/hyper3d_generation.rs
Normal file
1163
server-rs/crates/api-server/src/hyper3d_generation.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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("避免"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
50
server-rs/crates/api-server/src/prompt/puzzle/level_name.rs
Normal file
50
server-rs/crates/api-server/src/prompt/puzzle/level_name.rs
Normal 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("正式拼图图片"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
40
server-rs/crates/api-server/src/prompt/puzzle/tags.rs
Normal file
40
server-rs/crates/api-server/src/prompt/puzzle/tags.rs
Normal 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 个作品标签"));
|
||||
}
|
||||
}
|
||||
225
server-rs/crates/api-server/src/prompt/square_hole.rs
Normal file
225
server-rs/crates/api-server/src/prompt/square_hole.rs
Normal 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 这类稳定 ID,holeKind 保持同 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"));
|
||||
}
|
||||
}
|
||||
690
server-rs/crates/api-server/src/prompt/visual_novel.rs
Normal file
690
server-rs/crates/api-server/src/prompt/visual_novel.rs
Normal 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. 需要玩家选择时必须输出 choice,choice 内每项必须有 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
@@ -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
|
||||
|
||||
@@ -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 scope,HTTP 层先返回清晰错误,领域层再兜底。
|
||||
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 {
|
||||
|
||||
2660
server-rs/crates/api-server/src/square_hole.rs
Normal file
2660
server-rs/crates/api-server/src/square_hole.rs
Normal file
File diff suppressed because it is too large
Load Diff
569
server-rs/crates/api-server/src/square_hole_agent_turn.rs
Normal file
569
server-rs/crates/api-server/src/square_hole_agent_turn.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
588
server-rs/crates/api-server/src/tracking.rs
Normal file
588
server-rs/crates/api-server/src/tracking.rs
Normal 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)
|
||||
}
|
||||
1253
server-rs/crates/api-server/src/vector_engine_audio_generation.rs
Normal file
1253
server-rs/crates/api-server/src/vector_engine_audio_generation.rs
Normal file
File diff suppressed because it is too large
Load Diff
1798
server-rs/crates/api-server/src/visual_novel.rs
Normal file
1798
server-rs/crates/api-server/src/visual_novel.rs
Normal file
File diff suppressed because it is too large
Load Diff
551
server-rs/crates/api-server/src/volcengine_speech.rs
Normal file
551
server-rs/crates/api-server/src/volcengine_speech.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
111
server-rs/crates/api-server/src/work_play_tracking.rs
Normal file
111
server-rs/crates/api-server/src/work_play_tracking.rs
Normal 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 固定为 work,scope_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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user