后端重写提交

This commit is contained in:
2026-04-22 12:34:49 +08:00
parent cf8da3f50f
commit 997a8daada
438 changed files with 53355 additions and 865 deletions

View File

@@ -0,0 +1,223 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreateAiTaskRequest {
pub task_kind: String,
pub request_label: String,
pub source_module: String,
#[serde(default)]
pub source_entity_id: Option<String>,
#[serde(default)]
pub request_payload_json: Option<String>,
#[serde(default)]
pub stage_kinds: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AppendAiTextChunkRequest {
pub stage_kind: String,
pub sequence: u32,
pub delta_text: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CompleteAiStageRequest {
#[serde(default)]
pub text_output: Option<String>,
#[serde(default)]
pub structured_payload_json: Option<String>,
#[serde(default)]
pub warning_messages: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AttachAiResultReferenceRequest {
pub reference_kind: String,
pub reference_id: String,
#[serde(default)]
pub label: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct FailAiTaskRequest {
pub failure_message: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiTaskStagePayload {
pub stage_kind: String,
pub label: String,
pub detail: String,
pub order: u32,
pub status: String,
#[serde(default)]
pub text_output: Option<String>,
#[serde(default)]
pub structured_payload_json: Option<String>,
pub warning_messages: Vec<String>,
#[serde(default)]
pub started_at: Option<String>,
#[serde(default)]
pub completed_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiResultReferencePayload {
pub result_ref_id: String,
pub task_id: String,
pub reference_kind: String,
pub reference_id: String,
#[serde(default)]
pub label: Option<String>,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiTextChunkPayload {
pub chunk_id: String,
pub task_id: String,
pub stage_kind: String,
pub sequence: u32,
pub delta_text: String,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiTaskPayload {
pub task_id: String,
pub task_kind: String,
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
#[serde(default)]
pub source_entity_id: Option<String>,
#[serde(default)]
pub request_payload_json: Option<String>,
pub status: String,
#[serde(default)]
pub failure_message: Option<String>,
pub stages: Vec<AiTaskStagePayload>,
pub result_references: Vec<AiResultReferencePayload>,
#[serde(default)]
pub latest_text_output: Option<String>,
#[serde(default)]
pub latest_structured_payload_json: Option<String>,
pub version: u32,
pub created_at: String,
#[serde(default)]
pub started_at: Option<String>,
#[serde(default)]
pub completed_at: Option<String>,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiTaskMutationResponse {
pub ai_task: AiTaskPayload,
#[serde(default)]
pub ai_text_chunk: Option<AiTextChunkPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiTaskAcceptedResponse {
pub accepted: bool,
pub task_id: String,
pub action: String,
#[serde(default)]
pub stage_kind: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn create_ai_task_request_uses_camel_case_fields() {
let payload = serde_json::to_value(CreateAiTaskRequest {
task_kind: "story_generation".to_string(),
request_label: "营地开场".to_string(),
source_module: "story".to_string(),
source_entity_id: Some("storysess_001".to_string()),
request_payload_json: Some("{\"scene\":\"camp\"}".to_string()),
stage_kinds: vec!["prepare_prompt".to_string(), "request_model".to_string()],
})
.expect("payload should serialize");
assert_eq!(
payload,
json!({
"taskKind": "story_generation",
"requestLabel": "营地开场",
"sourceModule": "story",
"sourceEntityId": "storysess_001",
"requestPayloadJson": "{\"scene\":\"camp\"}",
"stageKinds": ["prepare_prompt", "request_model"]
})
);
}
#[test]
fn ai_task_mutation_response_uses_camel_case_fields() {
let payload = serde_json::to_value(AiTaskMutationResponse {
ai_task: AiTaskPayload {
task_id: "aitask_001".to_string(),
task_kind: "npc_chat".to_string(),
owner_user_id: "user_001".to_string(),
request_label: "试探问话".to_string(),
source_module: "npc".to_string(),
source_entity_id: Some("npc_001".to_string()),
request_payload_json: None,
status: "running".to_string(),
failure_message: None,
stages: vec![AiTaskStagePayload {
stage_kind: "request_model".to_string(),
label: "请求模型".to_string(),
detail: "向上游模型发起正式推理请求。".to_string(),
order: 1,
status: "running".to_string(),
text_output: Some("你盯着对方的刀柄。".to_string()),
structured_payload_json: None,
warning_messages: vec![],
started_at: Some("2026-04-22T12:00:00Z".to_string()),
completed_at: None,
}],
result_references: vec![],
latest_text_output: Some("你盯着对方的刀柄。".to_string()),
latest_structured_payload_json: None,
version: 2,
created_at: "2026-04-22T12:00:00Z".to_string(),
started_at: Some("2026-04-22T12:00:01Z".to_string()),
completed_at: None,
updated_at: "2026-04-22T12:00:02Z".to_string(),
},
ai_text_chunk: Some(AiTextChunkPayload {
chunk_id: "aichunk_001".to_string(),
task_id: "aitask_001".to_string(),
stage_kind: "request_model".to_string(),
sequence: 1,
delta_text: "".to_string(),
created_at: "2026-04-22T12:00:02Z".to_string(),
}),
})
.expect("payload should serialize");
assert_eq!(payload["aiTask"]["taskId"], json!("aitask_001"));
assert_eq!(
payload["aiTask"]["stages"][0]["stageKind"],
json!("request_model")
);
assert_eq!(payload["aiTextChunk"]["deltaText"], json!(""));
}
}

View File

@@ -0,0 +1,168 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const API_VERSION: &str = "2026-04-08";
pub const API_RESPONSE_ENVELOPE_HEADER: &str = "x-genarrative-response-envelope";
pub const X_REQUEST_ID_HEADER: &str = "x-request-id";
pub const API_VERSION_HEADER: &str = "x-api-version";
pub const ROUTE_VERSION_HEADER: &str = "x-route-version";
pub const RESPONSE_TIME_HEADER: &str = "x-response-time-ms";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct ApiResponseMeta {
#[serde(rename = "apiVersion")]
pub api_version: String,
#[serde(rename = "requestId", skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(rename = "routeVersion")]
pub route_version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub operation: Option<String>,
#[serde(rename = "latencyMs")]
pub latency_ms: u64,
pub timestamp: String,
}
impl ApiResponseMeta {
pub fn new(
api_version: impl Into<String>,
request_id: Option<String>,
route_version: impl Into<String>,
operation: Option<String>,
latency_ms: u64,
timestamp: impl Into<String>,
) -> Self {
Self {
api_version: api_version.into(),
request_id,
route_version: route_version.into(),
operation,
latency_ms,
timestamp: timestamp.into(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ApiErrorPayload {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
impl ApiErrorPayload {
pub fn new(
code: impl Into<String>,
message: impl Into<String>,
details: Option<Value>,
) -> Self {
Self {
code: code.into(),
message: message.into(),
details,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ApiSuccessEnvelope<T> {
pub ok: bool,
pub data: T,
pub error: Option<ApiErrorPayload>,
pub meta: ApiResponseMeta,
}
impl<T> ApiSuccessEnvelope<T> {
pub fn new(data: T, meta: ApiResponseMeta) -> Self {
Self {
ok: true,
data,
error: None,
meta,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ApiErrorEnvelope {
pub ok: bool,
pub data: Option<Value>,
pub error: ApiErrorPayload,
pub meta: ApiResponseMeta,
}
impl ApiErrorEnvelope {
pub fn new(error: ApiErrorPayload, meta: ApiResponseMeta) -> Self {
Self {
ok: false,
data: None,
error,
meta,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct LegacyApiErrorResponse {
pub error: ApiErrorPayload,
pub meta: ApiResponseMeta,
}
impl LegacyApiErrorResponse {
pub fn new(error: ApiErrorPayload, meta: ApiResponseMeta) -> Self {
Self { error, meta }
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn success_envelope_serializes_null_error_field() {
let payload = serde_json::to_value(ApiSuccessEnvelope::new(
json!({ "service": "genarrative" }),
ApiResponseMeta::new(
API_VERSION,
Some("req-1".to_string()),
API_VERSION,
Some("GET /healthz".to_string()),
12,
"2026-04-21T00:00:00Z",
),
))
.expect("payload should serialize");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(payload["error"], Value::Null);
assert_eq!(
payload["meta"]["apiVersion"],
Value::String(API_VERSION.to_string())
);
}
#[test]
fn error_envelope_serializes_null_data_field() {
let payload = serde_json::to_value(ApiErrorEnvelope::new(
ApiErrorPayload::new("BAD_REQUEST", "请求参数不合法", None),
ApiResponseMeta::new(
API_VERSION,
Some("req-2".to_string()),
API_VERSION,
Some("POST /api/test".to_string()),
21,
"2026-04-21T00:00:01Z",
),
))
.expect("payload should serialize");
assert_eq!(payload["ok"], Value::Bool(false));
assert_eq!(payload["data"], Value::Null);
assert_eq!(
payload["error"]["code"],
Value::String("BAD_REQUEST".to_string())
);
}
}

View File

@@ -0,0 +1,361 @@
use std::collections::BTreeMap;
use platform_oss::{
OssObjectAccess, OssPostObjectFormFields, OssPostObjectResponse, OssSignedGetObjectUrlResponse,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CreateDirectUploadTicketRequest {
pub legacy_prefix: String,
#[serde(default)]
pub path_segments: Vec<String>,
pub file_name: String,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub access: Option<OssObjectAccess>,
#[serde(default)]
pub metadata: BTreeMap<String, String>,
#[serde(default)]
pub max_size_bytes: Option<u64>,
#[serde(default)]
pub expire_seconds: Option<u64>,
#[serde(default)]
pub success_action_status: Option<u16>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct GetReadUrlQuery {
#[serde(default)]
pub object_key: Option<String>,
#[serde(default)]
pub legacy_public_path: Option<String>,
#[serde(default)]
pub expire_seconds: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConfirmAssetObjectAccessPolicy {
Private,
PublicRead,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmAssetObjectRequest {
#[serde(default)]
pub bucket: Option<String>,
pub object_key: String,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub content_length: Option<u64>,
#[serde(default)]
pub content_hash: Option<String>,
pub asset_kind: String,
#[serde(default)]
pub access_policy: Option<ConfirmAssetObjectAccessPolicy>,
#[serde(default)]
pub source_job_id: Option<String>,
#[serde(default)]
pub owner_user_id: Option<String>,
#[serde(default)]
pub profile_id: Option<String>,
#[serde(default)]
pub entity_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BindAssetObjectRequest {
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
#[serde(default)]
pub owner_user_id: Option<String>,
#[serde(default)]
pub profile_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CreateDirectUploadTicketResponse {
pub upload: DirectUploadTicketPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DirectUploadTicketPayload {
pub signature_version: String,
pub provider: String,
pub bucket: String,
pub endpoint: String,
pub host: String,
pub object_key: String,
pub legacy_public_path: String,
#[serde(default)]
pub content_type: Option<String>,
pub access: OssObjectAccess,
pub key_prefix: String,
pub expires_at: String,
pub max_size_bytes: u64,
pub success_action_status: u16,
pub form_fields: DirectUploadTicketFormFields,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct DirectUploadTicketFormFields {
pub key: String,
pub policy: String,
#[serde(rename = "OSSAccessKeyId")]
pub oss_access_key_id: String,
#[serde(rename = "Signature")]
pub signature: String,
#[serde(rename = "success_action_status")]
pub success_action_status: String,
#[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(flatten)]
pub metadata: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct GetAssetReadUrlResponse {
pub read: AssetReadUrlPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AssetReadUrlPayload {
pub provider: String,
pub bucket: String,
pub endpoint: String,
pub host: String,
pub object_key: String,
pub expires_at: String,
pub signed_url: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmAssetObjectResponse {
pub asset_object: AssetObjectPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AssetObjectPayload {
pub asset_object_id: String,
pub bucket: String,
pub object_key: String,
pub access_policy: String,
#[serde(default)]
pub content_type: Option<String>,
pub content_length: u64,
#[serde(default)]
pub content_hash: Option<String>,
pub version: u32,
#[serde(default)]
pub source_job_id: Option<String>,
#[serde(default)]
pub owner_user_id: Option<String>,
#[serde(default)]
pub profile_id: Option<String>,
#[serde(default)]
pub entity_id: Option<String>,
pub asset_kind: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BindAssetObjectResponse {
pub asset_binding: AssetBindingPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AssetBindingPayload {
pub binding_id: String,
pub asset_object_id: String,
pub entity_kind: String,
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
#[serde(default)]
pub owner_user_id: Option<String>,
#[serde(default)]
pub profile_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
impl From<OssPostObjectFormFields> for DirectUploadTicketFormFields {
fn from(value: OssPostObjectFormFields) -> Self {
Self {
key: value.key,
policy: value.policy,
oss_access_key_id: value.oss_access_key_id,
signature: value.signature,
success_action_status: value.success_action_status,
content_type: value.content_type,
metadata: value.metadata,
}
}
}
impl From<OssPostObjectResponse> for DirectUploadTicketPayload {
fn from(value: OssPostObjectResponse) -> Self {
Self {
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: 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: value.form_fields.into(),
}
}
}
impl From<OssSignedGetObjectUrlResponse> for AssetReadUrlPayload {
fn from(value: OssSignedGetObjectUrlResponse) -> Self {
Self {
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,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn confirm_asset_object_access_policy_uses_snake_case() {
let payload = serde_json::to_value(ConfirmAssetObjectAccessPolicy::PublicRead)
.expect("payload should serialize");
assert_eq!(payload, json!("public_read"));
}
#[test]
fn bind_asset_object_request_uses_camel_case_fields() {
let payload = serde_json::to_value(BindAssetObjectRequest {
asset_object_id: "assetobj_1".to_string(),
entity_kind: "character".to_string(),
entity_id: "npc_1".to_string(),
slot: "primary_visual".to_string(),
asset_kind: "character_visual".to_string(),
owner_user_id: Some("user_1".to_string()),
profile_id: Some("profile_1".to_string()),
})
.expect("payload should serialize");
assert_eq!(
payload,
json!({
"assetObjectId": "assetobj_1",
"entityKind": "character",
"entityId": "npc_1",
"slot": "primary_visual",
"assetKind": "character_visual",
"ownerUserId": "user_1",
"profileId": "profile_1"
})
);
}
#[test]
fn direct_upload_ticket_response_keeps_form_fields_shape() {
let payload = serde_json::to_value(CreateDirectUploadTicketResponse {
upload: DirectUploadTicketPayload::from(OssPostObjectResponse {
signature_version: "v1",
provider: "aliyun-oss",
bucket: "genarrative-assets".to_string(),
endpoint: "oss-cn-shanghai.aliyuncs.com".to_string(),
host: "https://genarrative-assets.oss-cn-shanghai.aliyuncs.com".to_string(),
object_key: "generated-characters/hero/master.png".to_string(),
legacy_public_path: "/generated-characters/hero/master.png".to_string(),
content_type: Some("image/png".to_string()),
access: OssObjectAccess::Private,
key_prefix: "generated-characters/hero".to_string(),
expires_at: "2026-04-21T00:00:00Z".to_string(),
max_size_bytes: 1024,
success_action_status: 200,
form_fields: OssPostObjectFormFields {
key: "generated-characters/hero/master.png".to_string(),
policy: "policy".to_string(),
oss_access_key_id: "ak".to_string(),
signature: "sig".to_string(),
success_action_status: "200".to_string(),
content_type: Some("image/png".to_string()),
metadata: BTreeMap::from([(
"x-oss-meta-asset-kind".to_string(),
"character_visual".to_string(),
)]),
},
}),
})
.expect("payload should serialize");
assert_eq!(payload["upload"]["signatureVersion"], json!("v1"));
assert_eq!(
payload["upload"]["formFields"]["OSSAccessKeyId"],
json!("ak")
);
assert_eq!(
payload["upload"]["formFields"]["x-oss-meta-asset-kind"],
json!("character_visual")
);
}
#[test]
fn confirm_asset_object_response_uses_camel_case_fields() {
let payload = serde_json::to_value(ConfirmAssetObjectResponse {
asset_object: AssetObjectPayload {
asset_object_id: "assetobj_1".to_string(),
bucket: "genarrative-assets".to_string(),
object_key: "generated-characters/hero/master.png".to_string(),
access_policy: "private".to_string(),
content_type: Some("image/png".to_string()),
content_length: 1024,
content_hash: Some("etag-1".to_string()),
version: 1,
source_job_id: Some("job_1".to_string()),
owner_user_id: Some("user_1".to_string()),
profile_id: Some("profile_1".to_string()),
entity_id: Some("entity_1".to_string()),
asset_kind: "character_visual".to_string(),
created_at: "1.000000Z".to_string(),
updated_at: "1.000000Z".to_string(),
},
})
.expect("payload should serialize");
assert_eq!(payload["assetObject"]["assetObjectId"], json!("assetobj_1"));
assert_eq!(payload["assetObject"]["accessPolicy"], json!("private"));
assert_eq!(payload["assetObject"]["contentLength"], json!(1024));
}
}

View File

@@ -0,0 +1,218 @@
use serde::{Deserialize, Serialize};
pub const AUTH_LOGIN_METHOD_PASSWORD: &str = "password";
pub const AUTH_LOGIN_METHOD_PHONE: &str = "phone";
pub const AUTH_LOGIN_METHOD_WECHAT: &str = "wechat";
pub const AUTH_BINDING_STATUS_ACTIVE: &str = "active";
pub const AUTH_BINDING_STATUS_PENDING_BIND_PHONE: &str = "pending_bind_phone";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AuthLoginOptionsResponse {
pub available_login_methods: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AuthUserPayload {
pub id: String,
pub username: String,
pub display_name: String,
pub phone_number_masked: Option<String>,
pub login_method: String,
pub binding_status: String,
pub wechat_bound: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PasswordEntryRequest {
pub username: String,
pub password: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PasswordEntryResponse {
pub token: String,
pub user: AuthUserPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AuthMeResponse {
pub user: AuthUserPayload,
pub available_login_methods: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AuthSessionsResponse {
pub sessions: Vec<AuthSessionSummaryPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AuthSessionSummaryPayload {
pub session_id: String,
pub client_type: String,
pub client_runtime: String,
pub client_platform: String,
pub client_label: String,
pub device_display_name: String,
pub mini_program_app_id: Option<String>,
pub mini_program_env: Option<String>,
pub user_agent: Option<String>,
pub ip_masked: Option<String>,
pub is_current: bool,
pub created_at: String,
pub last_seen_at: String,
pub expires_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct RefreshSessionResponse {
pub token: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct LogoutResponse {
pub ok: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct LogoutAllResponse {
pub ok: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PhoneSendCodeRequest {
pub phone: String,
pub scene: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PhoneSendCodeResponse {
pub ok: bool,
pub cooldown_seconds: u64,
pub expires_in_seconds: u64,
pub provider_request_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PhoneLoginRequest {
pub phone: String,
pub code: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PhoneLoginResponse {
pub token: String,
pub user: AuthUserPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WechatStartQuery {
pub redirect_path: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WechatStartResponse {
pub authorization_url: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct WechatCallbackQuery {
pub state: Option<String>,
pub code: Option<String>,
pub mock_code: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WechatBindPhoneRequest {
pub phone: String,
pub code: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WechatBindPhoneResponse {
pub token: String,
pub user: AuthUserPayload,
}
pub fn build_available_login_methods(
sms_auth_enabled: bool,
wechat_auth_enabled: bool,
) -> Vec<String> {
let mut methods = Vec::new();
if sms_auth_enabled {
methods.push(AUTH_LOGIN_METHOD_PHONE.to_string());
}
if wechat_auth_enabled {
methods.push(AUTH_LOGIN_METHOD_WECHAT.to_string());
}
methods
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn available_login_methods_keep_phone_then_wechat_order() {
let methods = build_available_login_methods(true, true);
assert_eq!(
methods,
vec![
AUTH_LOGIN_METHOD_PHONE.to_string(),
AUTH_LOGIN_METHOD_WECHAT.to_string()
]
);
}
#[test]
fn password_entry_request_uses_camel_case_fields() {
let payload = serde_json::to_value(PasswordEntryRequest {
username: "guest_001".to_string(),
password: "secret123".to_string(),
})
.expect("payload should serialize");
assert_eq!(
payload,
json!({
"username": "guest_001",
"password": "secret123"
})
);
}
#[test]
fn wechat_callback_query_keeps_provider_compatible_field_names() {
let payload = serde_json::to_value(WechatCallbackQuery {
state: Some("state-1".to_string()),
code: Some("code-1".to_string()),
mock_code: Some("mock-1".to_string()),
})
.expect("payload should serialize");
assert_eq!(
payload,
json!({
"state": "state-1",
"code": "code-1",
"mock_code": "mock-1"
})
);
}
}

View File

@@ -0,0 +1,8 @@
pub mod ai;
pub mod api;
pub mod assets;
pub mod auth;
pub mod llm;
pub mod runtime;
pub mod runtime_story;
pub mod story;

View File

@@ -0,0 +1,62 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LlmChatMessageRole {
System,
User,
Assistant,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct LlmChatMessagePayload {
pub role: LlmChatMessageRole,
pub content: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct LlmChatCompletionRequest {
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub stream: bool,
pub messages: Vec<LlmChatMessagePayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct LlmChatCompletionResponse {
pub id: Option<String>,
pub model: String,
pub content: String,
pub finish_reason: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn llm_chat_completion_request_keeps_openai_compatible_field_names() {
let payload = serde_json::to_value(LlmChatCompletionRequest {
model: Some("doubao-test".to_string()),
stream: false,
messages: vec![
LlmChatMessagePayload {
role: LlmChatMessageRole::System,
content: "系统".to_string(),
},
LlmChatMessagePayload {
role: LlmChatMessageRole::User,
content: "用户".to_string(),
},
],
})
.expect("payload should serialize");
assert_eq!(payload["model"], json!("doubao-test"));
assert_eq!(payload["stream"], json!(false));
assert_eq!(payload["messages"][0]["role"], json!("system"));
}
}

View File

@@ -0,0 +1,517 @@
use serde::{Deserialize, Serialize};
pub const RUNTIME_PLATFORM_THEME_LIGHT: &str = "light";
pub const RUNTIME_PLATFORM_THEME_DARK: &str = "dark";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC: &str = "snapshot_sync";
pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial";
pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane";
pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina";
pub const BROWSE_HISTORY_THEME_MODE_TIDE: &str = "tide";
pub const BROWSE_HISTORY_THEME_MODE_RIFT: &str = "rift";
pub const BROWSE_HISTORY_THEME_MODE_MYTHIC: &str = "mythic";
pub const CUSTOM_WORLD_VISIBILITY_DRAFT: &str = "draft";
pub const CUSTOM_WORLD_VISIBILITY_PUBLISHED: &str = "published";
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSettingsResponse {
pub music_volume: f32,
pub platform_theme: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PutRuntimeSettingsRequest {
pub music_volume: f32,
pub platform_theme: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlatformBrowseHistoryEntryResponse {
pub owner_user_id: String,
pub profile_id: String,
pub world_name: String,
pub subtitle: String,
pub summary_text: String,
pub cover_image_src: Option<String>,
pub theme_mode: String,
pub author_display_name: String,
pub visited_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlatformBrowseHistoryWriteEntryRequest {
pub owner_user_id: String,
pub profile_id: String,
pub world_name: String,
#[serde(default)]
pub subtitle: Option<String>,
#[serde(default)]
pub summary_text: Option<String>,
#[serde(default)]
pub cover_image_src: Option<String>,
#[serde(default)]
pub theme_mode: Option<String>,
#[serde(default)]
pub author_display_name: Option<String>,
#[serde(default)]
pub visited_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlatformBrowseHistoryBatchSyncRequest {
pub entries: Vec<PlatformBrowseHistoryWriteEntryRequest>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum PlatformBrowseHistoryUpsertRequest {
Single(PlatformBrowseHistoryWriteEntryRequest),
Batch(PlatformBrowseHistoryBatchSyncRequest),
}
impl PlatformBrowseHistoryUpsertRequest {
pub fn into_entries(self) -> Vec<PlatformBrowseHistoryWriteEntryRequest> {
match self {
Self::Single(entry) => vec![entry],
Self::Batch(batch) => batch.entries,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlatformBrowseHistoryResponse {
pub entries: Vec<PlatformBrowseHistoryEntryResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileDashboardSummaryResponse {
pub wallet_balance: u64,
pub total_play_time_ms: u64,
pub played_world_count: u32,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileWalletLedgerEntryResponse {
pub id: String,
pub amount_delta: i64,
pub balance_after: u64,
pub source_type: String,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileWalletLedgerResponse {
pub entries: Vec<ProfileWalletLedgerEntryResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfilePlayedWorkSummaryResponse {
pub world_key: String,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub world_type: Option<String>,
pub world_title: String,
pub world_subtitle: String,
pub first_played_at: String,
pub last_played_at: String,
pub last_observed_play_time_ms: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfilePlayStatsResponse {
pub total_play_time_ms: u64,
pub played_works: Vec<ProfilePlayedWorkSummaryResponse>,
pub updated_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeInventorySlotResponse {
pub slot_id: String,
pub container_kind: String,
pub slot_key: String,
pub item_id: String,
pub category: String,
pub name: String,
pub description: Option<String>,
pub quantity: u32,
pub rarity: String,
pub tags: Vec<String>,
pub stackable: bool,
pub stack_key: String,
pub equipment_slot_id: Option<String>,
pub source_kind: String,
pub source_reference_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeInventoryStateResponse {
pub runtime_session_id: String,
pub actor_user_id: String,
pub backpack_items: Vec<RuntimeInventorySlotResponse>,
pub equipment_items: Vec<RuntimeInventorySlotResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CustomWorldProfileUpsertRequest {
pub profile: serde_json::Value,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CustomWorldLibraryEntryResponse {
pub owner_user_id: String,
pub profile_id: String,
pub profile: serde_json::Value,
pub visibility: String,
pub published_at: Option<String>,
pub updated_at: String,
pub author_display_name: String,
pub world_name: String,
pub subtitle: String,
pub summary_text: String,
pub cover_image_src: Option<String>,
pub theme_mode: String,
pub playable_npc_count: u32,
pub landmark_count: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CustomWorldGalleryCardResponse {
pub owner_user_id: String,
pub profile_id: String,
pub visibility: String,
pub published_at: Option<String>,
pub updated_at: String,
pub author_display_name: String,
pub world_name: String,
pub subtitle: String,
pub summary_text: String,
pub cover_image_src: Option<String>,
pub theme_mode: String,
pub playable_npc_count: u32,
pub landmark_count: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CustomWorldLibraryResponse {
pub entries: Vec<CustomWorldLibraryEntryResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CustomWorldLibraryMutationResponse {
pub entry: CustomWorldLibraryEntryResponse,
pub entries: Vec<CustomWorldLibraryEntryResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CustomWorldGalleryResponse {
pub entries: Vec<CustomWorldGalleryCardResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CustomWorldGalleryDetailResponse {
pub entry: CustomWorldLibraryEntryResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CreateCustomWorldAgentSessionRequest {
#[serde(default)]
pub seed_text: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SendCustomWorldAgentMessageRequest {
pub client_message_id: String,
pub text: String,
#[serde(default)]
pub quick_fill_requested: Option<bool>,
#[serde(default)]
pub focus_card_id: Option<String>,
#[serde(default)]
pub selected_card_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CustomWorldAgentMessageResponse {
pub id: String,
pub role: String,
pub kind: String,
pub text: String,
pub created_at: String,
pub related_operation_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CustomWorldAgentOperationResponse {
pub operation_id: String,
#[serde(rename = "type")]
pub operation_type: String,
pub status: String,
pub phase_label: String,
pub phase_detail: String,
pub progress: u32,
pub error: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CustomWorldDraftCardSummaryResponse {
pub id: String,
pub kind: String,
pub title: String,
pub subtitle: String,
pub summary: String,
pub status: String,
pub linked_ids: Vec<String>,
pub warning_count: u32,
pub asset_status: Option<String>,
pub asset_status_label: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CustomWorldAgentCheckpointResponse {
pub checkpoint_id: String,
pub created_at: String,
pub label: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CustomWorldSupportedActionResponse {
pub action: String,
pub enabled: bool,
pub reason: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CustomWorldAgentSessionSnapshotResponse {
pub session_id: String,
pub current_turn: u32,
pub anchor_content: serde_json::Value,
pub progress_percent: u32,
pub last_assistant_reply: Option<String>,
pub stage: String,
pub focus_card_id: Option<String>,
pub creator_intent: serde_json::Value,
pub creator_intent_readiness: serde_json::Value,
pub anchor_pack: serde_json::Value,
pub lock_state: serde_json::Value,
pub draft_profile: serde_json::Value,
pub messages: Vec<CustomWorldAgentMessageResponse>,
pub draft_cards: Vec<CustomWorldDraftCardSummaryResponse>,
pub pending_clarifications: Vec<serde_json::Value>,
pub suggested_actions: Vec<serde_json::Value>,
pub recommended_replies: Vec<String>,
pub quality_findings: Vec<serde_json::Value>,
pub asset_coverage: serde_json::Value,
pub checkpoints: Vec<CustomWorldAgentCheckpointResponse>,
pub supported_actions: Vec<CustomWorldSupportedActionResponse>,
pub result_preview: Option<serde_json::Value>,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CustomWorldAgentSessionResponse {
pub session: CustomWorldAgentSessionSnapshotResponse,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn runtime_settings_request_uses_camel_case_fields() {
let payload = serde_json::to_value(PutRuntimeSettingsRequest {
music_volume: 0.42,
platform_theme: RUNTIME_PLATFORM_THEME_LIGHT.to_string(),
})
.expect("payload should serialize");
assert_eq!(payload["platformTheme"], json!("light"));
let music_volume = payload["musicVolume"]
.as_f64()
.expect("musicVolume should serialize as number");
assert!((music_volume - 0.42).abs() < 0.0001);
}
#[test]
fn browse_history_response_uses_camel_case_fields() {
let payload = serde_json::to_value(PlatformBrowseHistoryResponse {
entries: vec![PlatformBrowseHistoryEntryResponse {
owner_user_id: "owner-1".to_string(),
profile_id: "profile-1".to_string(),
world_name: "世界".to_string(),
subtitle: "".to_string(),
summary_text: "".to_string(),
cover_image_src: None,
theme_mode: BROWSE_HISTORY_THEME_MODE_MYTHIC.to_string(),
author_display_name: "玩家".to_string(),
visited_at: "2026-04-21T00:00:00Z".to_string(),
}],
})
.expect("payload should serialize");
assert_eq!(payload["entries"][0]["ownerUserId"], json!("owner-1"));
assert_eq!(payload["entries"][0]["themeMode"], json!("mythic"));
assert_eq!(
payload["entries"][0]["visitedAt"],
json!("2026-04-21T00:00:00Z")
);
}
#[test]
fn browse_history_upsert_request_accepts_single_or_batch_shape() {
let single: PlatformBrowseHistoryUpsertRequest = serde_json::from_value(json!({
"ownerUserId": "owner-1",
"profileId": "profile-1",
"worldName": "世界"
}))
.expect("single shape should deserialize");
let batch: PlatformBrowseHistoryUpsertRequest = serde_json::from_value(json!({
"entries": [{
"ownerUserId": "owner-1",
"profileId": "profile-1",
"worldName": "世界"
}]
}))
.expect("batch shape should deserialize");
assert_eq!(single.into_entries().len(), 1);
assert_eq!(batch.into_entries().len(), 1);
}
#[test]
fn profile_dashboard_response_uses_camel_case_fields() {
let payload = serde_json::to_value(ProfileDashboardSummaryResponse {
wallet_balance: 8,
total_play_time_ms: 16,
played_world_count: 3,
updated_at: Some("2026-04-22T10:00:00Z".to_string()),
})
.expect("payload should serialize");
assert_eq!(payload["walletBalance"], json!(8));
assert_eq!(payload["totalPlayTimeMs"], json!(16));
assert_eq!(payload["playedWorldCount"], json!(3));
assert_eq!(payload["updatedAt"], json!("2026-04-22T10:00:00Z"));
}
#[test]
fn profile_wallet_ledger_response_uses_camel_case_fields() {
let payload = serde_json::to_value(ProfileWalletLedgerResponse {
entries: vec![ProfileWalletLedgerEntryResponse {
id: "ledger-1".to_string(),
amount_delta: 12,
balance_after: 80,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(),
created_at: "2026-04-22T10:00:00Z".to_string(),
}],
})
.expect("payload should serialize");
assert_eq!(payload["entries"][0]["amountDelta"], json!(12));
assert_eq!(payload["entries"][0]["balanceAfter"], json!(80));
assert_eq!(payload["entries"][0]["sourceType"], json!("snapshot_sync"));
assert_eq!(
payload["entries"][0]["createdAt"],
json!("2026-04-22T10:00:00Z")
);
}
#[test]
fn profile_play_stats_response_uses_camel_case_fields() {
let payload = serde_json::to_value(ProfilePlayStatsResponse {
total_play_time_ms: 18,
played_works: vec![ProfilePlayedWorkSummaryResponse {
world_key: "builtin:WUXIA".to_string(),
owner_user_id: None,
profile_id: None,
world_type: Some("WUXIA".to_string()),
world_title: "武侠世界".to_string(),
world_subtitle: "".to_string(),
first_played_at: "2026-04-20T10:00:00Z".to_string(),
last_played_at: "2026-04-22T10:00:00Z".to_string(),
last_observed_play_time_ms: 1200,
}],
updated_at: Some("2026-04-22T10:00:00Z".to_string()),
})
.expect("payload should serialize");
assert_eq!(payload["totalPlayTimeMs"], json!(18));
assert_eq!(
payload["playedWorks"][0]["worldKey"],
json!("builtin:WUXIA")
);
assert_eq!(
payload["playedWorks"][0]["lastObservedPlayTimeMs"],
json!(1200)
);
assert_eq!(payload["updatedAt"], json!("2026-04-22T10:00:00Z"));
}
#[test]
fn runtime_inventory_state_response_uses_camel_case_fields() {
let payload = serde_json::to_value(RuntimeInventoryStateResponse {
runtime_session_id: "runtime_001".to_string(),
actor_user_id: "user_001".to_string(),
backpack_items: vec![RuntimeInventorySlotResponse {
slot_id: "invslot_001".to_string(),
container_kind: "backpack".to_string(),
slot_key: "invslot_001".to_string(),
item_id: "consumable_heal_potion".to_string(),
category: "消耗品".to_string(),
name: "疗伤药".to_string(),
description: Some("用于恢复少量气血。".to_string()),
quantity: 2,
rarity: "common".to_string(),
tags: vec!["healing".to_string()],
stackable: true,
stack_key: "heal_potion".to_string(),
equipment_slot_id: None,
source_kind: "treasure_reward".to_string(),
source_reference_id: Some("treasure_001".to_string()),
created_at: "2026-04-22T10:00:00Z".to_string(),
updated_at: "2026-04-22T10:01:00Z".to_string(),
}],
equipment_items: vec![],
})
.expect("payload should serialize");
assert_eq!(payload["runtimeSessionId"], json!("runtime_001"));
assert_eq!(payload["actorUserId"], json!("user_001"));
assert_eq!(payload["backpackItems"][0]["slotId"], json!("invslot_001"));
assert_eq!(
payload["backpackItems"][0]["sourceKind"],
json!("treasure_reward")
);
}
}

View File

@@ -0,0 +1,324 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStorySnapshotPayload {
pub saved_at: String,
pub bottom_tab: String,
pub game_state: Value,
#[serde(default)]
pub current_story: Option<Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryStateResolveRequest {
pub session_id: String,
#[serde(default)]
pub client_version: Option<u32>,
#[serde(default)]
pub snapshot: Option<RuntimeStorySnapshotPayload>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryOptionView {
pub function_id: String,
pub action_text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail_text: Option<String>,
pub scope: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interaction: Option<RuntimeStoryOptionInteraction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payload: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum RuntimeStoryOptionInteraction {
#[serde(rename_all = "camelCase")]
Npc {
npc_id: String,
action: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
quest_id: Option<String>,
},
#[serde(rename_all = "camelCase")]
Treasure {
action: String,
},
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryPlayerViewModel {
pub hp: i32,
pub max_hp: i32,
pub mana: i32,
pub max_mana: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryCompanionViewModel {
pub npc_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub character_id: Option<String>,
pub joined_at_affinity: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryEncounterViewModel {
pub id: String,
pub kind: String,
pub npc_name: String,
pub hostile: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub affinity: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recruited: Option<bool>,
pub interaction_active: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub battle_mode: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryStatusViewModel {
pub in_battle: bool,
pub npc_interaction_active: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_npc_battle_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_npc_battle_outcome: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeBattlePresentation {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub damage_dealt: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub damage_taken: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryViewModel {
pub player: RuntimeStoryPlayerViewModel,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encounter: Option<RuntimeStoryEncounterViewModel>,
pub companions: Vec<RuntimeStoryCompanionViewModel>,
pub available_options: Vec<RuntimeStoryOptionView>,
pub status: RuntimeStoryStatusViewModel,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryPresentation {
pub action_text: String,
pub result_text: String,
pub story_text: String,
pub options: Vec<RuntimeStoryOptionView>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub toast: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub battle: Option<RuntimeBattlePresentation>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RuntimeStoryPatch {
#[serde(rename_all = "camelCase")]
StoryHistoryAppend {
action_text: String,
result_text: String,
},
#[serde(rename_all = "camelCase")]
NpcAffinityChanged {
npc_id: String,
previous_affinity: i32,
next_affinity: i32,
},
#[serde(rename_all = "camelCase")]
BattleResolved {
function_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
target_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
damage_dealt: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
damage_taken: Option<i32>,
outcome: String,
},
#[serde(rename_all = "camelCase")]
StatusChanged {
in_battle: bool,
npc_interaction_active: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
current_npc_battle_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
current_npc_battle_outcome: Option<String>,
},
#[serde(rename_all = "camelCase")]
EncounterChanged {
#[serde(default, skip_serializing_if = "Option::is_none")]
encounter_id: Option<String>,
},
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeStoryActionResponse {
pub session_id: String,
pub server_version: u32,
pub view_model: RuntimeStoryViewModel,
pub presentation: RuntimeStoryPresentation,
pub patches: Vec<RuntimeStoryPatch>,
pub snapshot: RuntimeStorySnapshotPayload,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn runtime_story_state_resolve_request_uses_camel_case_fields() {
let payload = serde_json::to_value(RuntimeStoryStateResolveRequest {
session_id: "runtime-main".to_string(),
client_version: Some(7),
snapshot: Some(RuntimeStorySnapshotPayload {
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
bottom_tab: "adventure".to_string(),
game_state: json!({ "runtimeSessionId": "runtime-main" }),
current_story: Some(json!({ "text": "营地里的火光还没有熄灭。" })),
}),
})
.expect("payload should serialize");
assert_eq!(payload["sessionId"], json!("runtime-main"));
assert_eq!(payload["clientVersion"], json!(7));
assert_eq!(payload["snapshot"]["savedAt"], json!("2026-04-22T12:00:00.000Z"));
assert_eq!(payload["snapshot"]["bottomTab"], json!("adventure"));
assert_eq!(payload["snapshot"]["gameState"]["runtimeSessionId"], json!("runtime-main"));
assert_eq!(
payload["snapshot"]["currentStory"]["text"],
json!("营地里的火光还没有熄灭。")
);
}
#[test]
fn runtime_story_action_response_uses_camel_case_fields() {
let payload = serde_json::to_value(RuntimeStoryActionResponse {
session_id: "runtime-main".to_string(),
server_version: 8,
view_model: RuntimeStoryViewModel {
player: RuntimeStoryPlayerViewModel {
hp: 32,
max_hp: 40,
mana: 18,
max_mana: 20,
},
encounter: Some(RuntimeStoryEncounterViewModel {
id: "npc_camp_firekeeper".to_string(),
kind: "npc".to_string(),
npc_name: "守火人".to_string(),
hostile: false,
affinity: Some(12),
recruited: Some(false),
interaction_active: true,
battle_mode: None,
}),
companions: vec![RuntimeStoryCompanionViewModel {
npc_id: "npc_companion_001".to_string(),
character_id: Some("char_companion_001".to_string()),
joined_at_affinity: 64,
}],
available_options: vec![RuntimeStoryOptionView {
function_id: "npc_chat".to_string(),
action_text: "继续交谈".to_string(),
detail_text: Some("围绕当前话题继续推进关系判断。".to_string()),
scope: "npc".to_string(),
interaction: Some(RuntimeStoryOptionInteraction::Npc {
npc_id: "npc_camp_firekeeper".to_string(),
action: "chat".to_string(),
quest_id: None,
}),
payload: Some(json!({ "note": "server-runtime-test" })),
disabled: None,
reason: None,
}],
status: RuntimeStoryStatusViewModel {
in_battle: false,
npc_interaction_active: true,
current_npc_battle_mode: None,
current_npc_battle_outcome: None,
},
},
presentation: RuntimeStoryPresentation {
action_text: "".to_string(),
result_text: "".to_string(),
story_text: "守火人抬眼看了你一瞬,示意你把想问的话继续说完。".to_string(),
options: vec![RuntimeStoryOptionView {
function_id: "npc_chat".to_string(),
action_text: "继续交谈".to_string(),
detail_text: Some("围绕当前话题继续推进关系判断。".to_string()),
scope: "npc".to_string(),
interaction: Some(RuntimeStoryOptionInteraction::Npc {
npc_id: "npc_camp_firekeeper".to_string(),
action: "chat".to_string(),
quest_id: None,
}),
payload: Some(json!({ "note": "server-runtime-test" })),
disabled: None,
reason: None,
}],
toast: None,
battle: None,
},
patches: vec![RuntimeStoryPatch::StatusChanged {
in_battle: false,
npc_interaction_active: true,
current_npc_battle_mode: None,
current_npc_battle_outcome: None,
}],
snapshot: RuntimeStorySnapshotPayload {
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
bottom_tab: "adventure".to_string(),
game_state: json!({ "runtimeSessionId": "runtime-main" }),
current_story: Some(json!({
"text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。"
})),
},
})
.expect("payload should serialize");
assert_eq!(payload["sessionId"], json!("runtime-main"));
assert_eq!(payload["serverVersion"], json!(8));
assert_eq!(payload["viewModel"]["player"]["maxHp"], json!(40));
assert_eq!(
payload["viewModel"]["availableOptions"][0]["interaction"]["npcId"],
json!("npc_camp_firekeeper")
);
assert_eq!(
payload["presentation"]["storyText"],
json!("守火人抬眼看了你一瞬,示意你把想问的话继续说完。")
);
assert_eq!(payload["patches"][0]["type"], json!("status_changed"));
assert_eq!(payload["snapshot"]["bottomTab"], json!("adventure"));
}
}

View File

@@ -0,0 +1,164 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BeginStorySessionRequest {
pub runtime_session_id: String,
pub world_profile_id: String,
pub initial_prompt: String,
#[serde(default)]
pub opening_summary: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ContinueStoryRequest {
pub story_session_id: String,
pub narrative_text: String,
#[serde(default)]
pub choice_function_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct StorySessionPayload {
pub story_session_id: String,
pub runtime_session_id: String,
pub actor_user_id: String,
pub world_profile_id: String,
pub initial_prompt: String,
#[serde(default)]
pub opening_summary: Option<String>,
pub latest_narrative_text: String,
#[serde(default)]
pub latest_choice_function_id: Option<String>,
pub status: String,
pub version: u32,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct StoryEventPayload {
pub event_id: String,
pub story_session_id: String,
pub event_kind: String,
pub narrative_text: String,
#[serde(default)]
pub choice_function_id: Option<String>,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct StorySessionMutationResponse {
pub story_session: StorySessionPayload,
pub story_event: StoryEventPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct StorySessionStateResponse {
pub story_session: StorySessionPayload,
pub story_events: Vec<StoryEventPayload>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn continue_story_request_uses_camel_case_fields() {
let payload = serde_json::to_value(ContinueStoryRequest {
story_session_id: "storysess_1".to_string(),
narrative_text: "继续前进".to_string(),
choice_function_id: Some("npc_chat".to_string()),
})
.expect("payload should serialize");
assert_eq!(
payload,
json!({
"storySessionId": "storysess_1",
"narrativeText": "继续前进",
"choiceFunctionId": "npc_chat"
})
);
}
#[test]
fn story_session_mutation_response_uses_camel_case_fields() {
let payload = serde_json::to_value(StorySessionMutationResponse {
story_session: StorySessionPayload {
story_session_id: "storysess_1".to_string(),
runtime_session_id: "runtime_1".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("talk".to_string()),
status: "active".to_string(),
version: 1,
created_at: "1.000000Z".to_string(),
updated_at: "1.000000Z".to_string(),
},
story_event: StoryEventPayload {
event_id: "storyevt_1".to_string(),
story_session_id: "storysess_1".to_string(),
event_kind: "session_started".to_string(),
narrative_text: "篝火正在燃烧。".to_string(),
choice_function_id: Some("talk".to_string()),
created_at: "1.000000Z".to_string(),
},
})
.expect("payload should serialize");
assert_eq!(
payload["storySession"]["storySessionId"],
json!("storysess_1")
);
assert_eq!(payload["storyEvent"]["eventKind"], json!("session_started"));
assert_eq!(payload["storyEvent"]["choiceFunctionId"], json!("talk"));
}
#[test]
fn story_session_state_response_uses_camel_case_fields() {
let payload = serde_json::to_value(StorySessionStateResponse {
story_session: StorySessionPayload {
story_session_id: "storysess_1".to_string(),
runtime_session_id: "runtime_1".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("talk_to_npc".to_string()),
status: "active".to_string(),
version: 2,
created_at: "1.000000Z".to_string(),
updated_at: "2.000000Z".to_string(),
},
story_events: vec![StoryEventPayload {
event_id: "storyevt_2".to_string(),
story_session_id: "storysess_1".to_string(),
event_kind: "story_continued".to_string(),
narrative_text: "你看见篝火边有人招手。".to_string(),
choice_function_id: Some("talk_to_npc".to_string()),
created_at: "2.000000Z".to_string(),
}],
})
.expect("payload should serialize");
assert_eq!(
payload["storySession"]["latestChoiceFunctionId"],
json!("talk_to_npc")
);
assert_eq!(
payload["storyEvents"][0]["eventKind"],
json!("story_continued")
);
}
}