Merge origin/master into hermes/hermes-4fd30995
This commit is contained in:
@@ -17,6 +17,7 @@ module-ai = { workspace = true }
|
||||
module-assets = { workspace = true, features = ["server-service"] }
|
||||
module-auth = { workspace = true }
|
||||
module-big-fish = { workspace = true }
|
||||
module-bark-battle = { workspace = true }
|
||||
module-combat = { workspace = true }
|
||||
module-creative-agent = { workspace = true }
|
||||
module-custom-world = { workspace = true }
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
1. 进程启动时通过 `shared-logging` 统一初始化 `tracing subscriber`。
|
||||
2. 默认日志过滤器来自 `GENARRATIVE_API_LOG`,未提供时回落到 `info,tower_http=info`。
|
||||
3. HTTP 访问日志统一通过 Axum 路由层的 `TraceLayer` 输出,后续 `request_id`、响应头与错误中间件继续在同一层扩展。
|
||||
4. 本地启动器 `npm run api-server` 和完整联调入口 `npm run dev` / `npm run dev:rust` 会在保留终端实时输出的同时,把同一份 `cargo` / `api-server` 输出持久化到 `logs/api-server/`。如需固定文件或目录,可设置 `GENARRATIVE_API_SERVER_LOG_FILE` 或 `GENARRATIVE_API_SERVER_LOG_DIR`。
|
||||
|
||||
当前 request context 约定:
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"next_user_id": 2,
|
||||
"users_by_username": {
|
||||
"phone_00000002": {
|
||||
"user": {
|
||||
"id": "user_00000001",
|
||||
"public_user_code": "SY-00000001",
|
||||
"username": "phone_00000002",
|
||||
"display_name": "138****8000",
|
||||
"phone_number_masked": "138****8000",
|
||||
"login_method": "Phone",
|
||||
"binding_status": "Active",
|
||||
"wechat_bound": false,
|
||||
"token_version": 1
|
||||
},
|
||||
"password_hash": "$argon2id$v=19$m=19456,t=2,p=1$hoXmK/LzABj2QfWZSO3SNA$Qg71V2iZCPyLOsoQLffiCv3KPkWVNSAsP6IooTIXi/w",
|
||||
"password_login_enabled": false,
|
||||
"phone_number": "+8613800138000"
|
||||
}
|
||||
},
|
||||
"phone_to_user_id": {
|
||||
"+8613800138000": "user_00000001"
|
||||
},
|
||||
"sessions_by_id": {
|
||||
"usess_52522126b58d40e3b9e503808dd11e2c": {
|
||||
"session": {
|
||||
"session_id": "usess_52522126b58d40e3b9e503808dd11e2c",
|
||||
"user_id": "user_00000001",
|
||||
"refresh_token_hash": "f42140526caea3e4a9f533bcc2d8799feae4f96769ea975ef771b1ae11e4dbe9",
|
||||
"issued_by_provider": "Phone",
|
||||
"client_info": {
|
||||
"client_type": "web_browser",
|
||||
"client_runtime": "unknown",
|
||||
"client_platform": "unknown",
|
||||
"client_instance_id": null,
|
||||
"device_fingerprint": null,
|
||||
"device_display_name": "未知设备 / 未知客户端",
|
||||
"mini_program_app_id": null,
|
||||
"mini_program_env": null,
|
||||
"user_agent": null,
|
||||
"ip": null
|
||||
},
|
||||
"expires_at": "2026-05-25T15:41:01.0856147Z",
|
||||
"revoked_at": null,
|
||||
"created_at": "2026-04-25T15:41:01.0856147Z",
|
||||
"updated_at": "2026-04-25T15:41:01.0856147Z",
|
||||
"last_seen_at": "2026-04-25T15:41:01.0856147Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
"session_id_by_refresh_token_hash": {
|
||||
"f42140526caea3e4a9f533bcc2d8799feae4f96769ea975ef771b1ae11e4dbe9": "usess_52522126b58d40e3b9e503808dd11e2c"
|
||||
},
|
||||
"wechat_identity_by_provider_uid": {},
|
||||
"user_id_by_provider_union_id": {}
|
||||
}
|
||||
@@ -725,7 +725,7 @@ fn parse_admin_database_table_rows_sql_response(
|
||||
.ok_or_else(|| "SQL rows 字段格式非法".to_string())?;
|
||||
let rows = row_values
|
||||
.iter()
|
||||
.map(|row| build_admin_database_table_row(row, &columns))
|
||||
.map(|row| build_admin_database_table_row_for_table(table_name, row, &columns))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(AdminDatabaseTableRowsResponse {
|
||||
table_name: table_name.to_string(),
|
||||
@@ -769,7 +769,15 @@ fn extract_sql_statement_columns(statement: &Value) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatabaseTableRowPayload {
|
||||
let raw = normalize_admin_database_value(row);
|
||||
build_admin_database_table_row_for_table("", row, columns)
|
||||
}
|
||||
|
||||
fn build_admin_database_table_row_for_table(
|
||||
table_name: &str,
|
||||
row: &Value,
|
||||
columns: &[String],
|
||||
) -> AdminDatabaseTableRowPayload {
|
||||
let raw = normalize_admin_database_table_row_raw(table_name, row, columns);
|
||||
let mut cells = Map::new();
|
||||
if let Some(values) = row.as_array() {
|
||||
for (index, value) in values.iter().enumerate() {
|
||||
@@ -777,11 +785,17 @@ fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatab
|
||||
.get(index)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("col_{}", index + 1));
|
||||
cells.insert(key, normalize_admin_database_value(value));
|
||||
cells.insert(
|
||||
key.clone(),
|
||||
normalize_admin_database_table_cell(table_name, &key, value),
|
||||
);
|
||||
}
|
||||
} else if let Some(object) = row.as_object() {
|
||||
for (key, value) in object {
|
||||
cells.insert(key.clone(), normalize_admin_database_value(value));
|
||||
cells.insert(
|
||||
key.clone(),
|
||||
normalize_admin_database_table_cell(table_name, key, value),
|
||||
);
|
||||
}
|
||||
}
|
||||
AdminDatabaseTableRowPayload {
|
||||
@@ -790,6 +804,85 @@ fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatab
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_admin_database_table_row_raw(
|
||||
table_name: &str,
|
||||
row: &Value,
|
||||
columns: &[String],
|
||||
) -> Value {
|
||||
if let Some(values) = row.as_array() {
|
||||
return Value::Array(
|
||||
values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| {
|
||||
let key = columns.get(index).map(String::as_str).unwrap_or_default();
|
||||
normalize_admin_database_table_cell(table_name, key, value)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(object) = row.as_object() {
|
||||
return Value::Object(
|
||||
object
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key.clone(),
|
||||
normalize_admin_database_table_cell(table_name, key, value),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
normalize_admin_database_value(row)
|
||||
}
|
||||
|
||||
fn normalize_admin_database_table_cell(
|
||||
table_name: &str,
|
||||
column_name: &str,
|
||||
value: &Value,
|
||||
) -> Value {
|
||||
if let Some(enum_value) = normalize_admin_database_known_enum(table_name, column_name, value) {
|
||||
return enum_value;
|
||||
}
|
||||
normalize_admin_database_value(value)
|
||||
}
|
||||
|
||||
fn normalize_admin_database_known_enum(
|
||||
table_name: &str,
|
||||
column_name: &str,
|
||||
value: &Value,
|
||||
) -> Option<Value> {
|
||||
let variant_index = extract_sats_enum_variant_index(value)?;
|
||||
let label = match (table_name, column_name) {
|
||||
("profile_recharge_order", "kind") => match variant_index {
|
||||
0 => "points",
|
||||
1 => "membership",
|
||||
_ => return None,
|
||||
},
|
||||
("profile_recharge_order", "status") => match variant_index {
|
||||
0 => "pending",
|
||||
1 => "paid",
|
||||
2 => "failed",
|
||||
3 => "closed",
|
||||
4 => "refunded",
|
||||
_ => return None,
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
Some(Value::String(label.to_string()))
|
||||
}
|
||||
|
||||
fn extract_sats_enum_variant_index(value: &Value) -> Option<u64> {
|
||||
let items = value.as_array()?;
|
||||
if items.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
items.first()?.as_u64()
|
||||
}
|
||||
|
||||
fn normalize_admin_database_value(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Array(items) if items.len() == 1 => normalize_admin_database_value(&items[0]),
|
||||
@@ -1526,6 +1619,46 @@ mod tests {
|
||||
assert_eq!(response.rows[0].cells["points"], json!(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_admin_database_table_rows_sql_response_maps_recharge_order_enum_cells() {
|
||||
let payload = json!([
|
||||
{
|
||||
"schema": {
|
||||
"elements": [
|
||||
{"name": {"some": "order_id"}},
|
||||
{"name": {"some": "kind"}},
|
||||
{"name": {"some": "status"}},
|
||||
{"name": {"some": "paid_at"}}
|
||||
]
|
||||
},
|
||||
"rows": [[
|
||||
"recharge:user_00000001:1778757456811099:points_60",
|
||||
[0, []],
|
||||
[0, []],
|
||||
[1, []]
|
||||
]]
|
||||
}
|
||||
]);
|
||||
|
||||
let response =
|
||||
parse_admin_database_table_rows_sql_response("profile_recharge_order", 100, payload)
|
||||
.expect("recharge order rows should parse");
|
||||
|
||||
let cells = &response.rows[0].cells;
|
||||
assert_eq!(cells["kind"], json!("points"));
|
||||
assert_eq!(cells["status"], json!("pending"));
|
||||
assert_eq!(cells["paid_at"], json!(null));
|
||||
assert_eq!(
|
||||
response.rows[0].raw,
|
||||
json!([
|
||||
"recharge:user_00000001:1778757456811099:points_60",
|
||||
"points",
|
||||
"pending",
|
||||
null
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_admin_database_table_row_normalizes_optional_sats_values() {
|
||||
let row = build_admin_database_table_row(
|
||||
|
||||
@@ -53,8 +53,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(modules::assets::router(state.clone()))
|
||||
.merge(modules::platform::router(state.clone()))
|
||||
.merge(modules::story::router(state.clone()))
|
||||
.merge(modules::edutainment::router(state.clone()))
|
||||
.merge(modules::custom_world::router(state.clone()))
|
||||
.merge(modules::big_fish::router(state.clone()))
|
||||
.merge(modules::bark_battle::router(state.clone()))
|
||||
.merge(modules::match3d::router(state.clone()))
|
||||
.merge(modules::square_hole::router(state.clone()))
|
||||
.merge(modules::puzzle::router(state.clone()))
|
||||
|
||||
@@ -165,6 +165,7 @@ pub(crate) fn should_skip_asset_operation_billing_for_connectivity(
|
||||
|| message.contains("Service Unavailable")
|
||||
|| message.contains("Failed to connect")
|
||||
|| message.contains("WebSocket")
|
||||
|| message.contains("No such procedure")
|
||||
|| message.contains("连接已断开")
|
||||
|| message.contains("连接在返回结果前已断开")
|
||||
}
|
||||
@@ -190,6 +191,11 @@ mod tests {
|
||||
"Failed to connect: HTTP error: 503 Service Unavailable".to_string(),
|
||||
),
|
||||
));
|
||||
assert!(should_skip_asset_operation_billing_for_connectivity(
|
||||
&SpacetimeClientError::Procedure(
|
||||
"No such procedure: consume_profile_wallet_points_and_return".to_string(),
|
||||
),
|
||||
));
|
||||
assert!(!should_skip_asset_operation_billing_for_connectivity(
|
||||
&SpacetimeClientError::Procedure("泥点余额不足".to_string()),
|
||||
));
|
||||
|
||||
776
server-rs/crates/api-server/src/bark_battle.rs
Normal file
776
server-rs/crates/api-server/src/bark_battle.rs
Normal file
@@ -0,0 +1,776 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State, rejection::JsonRejection},
|
||||
http::{HeaderName, StatusCode, header},
|
||||
response::Response,
|
||||
};
|
||||
use module_bark_battle::{BARK_BATTLE_RULESET_VERSION_V1, BarkBattleRuleset};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::bark_battle::{
|
||||
BarkBattleConfigEditorPayload, BarkBattleDerivedMetrics, BarkBattleDifficultyPreset,
|
||||
BarkBattleDraftConfig, BarkBattleDraftCreateRequest, BarkBattleFinishStatus,
|
||||
BarkBattlePublishedConfig, BarkBattleRunFinishRequest, BarkBattleRunFinishResponse,
|
||||
BarkBattleRunStartRequest, BarkBattleRunStartResponse, BarkBattleScoreSummary,
|
||||
BarkBattleServerResult, BarkBattleWorkPublishRequest,
|
||||
};
|
||||
use shared_kernel::{
|
||||
build_prefixed_uuid_id, format_rfc3339, format_timestamp_micros,
|
||||
offset_datetime_to_unix_micros, parse_rfc3339,
|
||||
};
|
||||
use spacetime_client::{
|
||||
BarkBattleDraftCreateRecordInput, BarkBattleRunFinishRecordInput, BarkBattleRunRecord,
|
||||
BarkBattleRunStartRecordInput, BarkBattleWorkPublishRecordInput, SpacetimeClientError,
|
||||
};
|
||||
use time::{Duration as TimeDuration, OffsetDateTime};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||
};
|
||||
|
||||
const BARK_BATTLE_RUNTIME_PROVIDER: &str = "bark-battle-runtime";
|
||||
const BARK_BATTLE_DRAFT_ID_PREFIX: &str = "bark-battle-draft-";
|
||||
const BARK_BATTLE_WORK_ID_PREFIX: &str = "bark-battle-work-";
|
||||
const BARK_BATTLE_RUN_ID_PREFIX: &str = "bark-battle-run-";
|
||||
const BARK_BATTLE_RUN_TOKEN_PREFIX: &str = "bark-battle-token-";
|
||||
const BARK_BATTLE_PLAY_TYPE_ID: &str = "bark-battle";
|
||||
const BARK_BATTLE_RUN_TTL_SECONDS: i64 = 10 * 60;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BarkBattleRunSnapshotRecord {
|
||||
run_id: String,
|
||||
work_id: String,
|
||||
config_version: u64,
|
||||
ruleset_version: String,
|
||||
difficulty_preset: String,
|
||||
#[serde(default)]
|
||||
client_started_at_micros: i64,
|
||||
#[serde(default)]
|
||||
server_started_at_micros: i64,
|
||||
#[serde(default)]
|
||||
server_finished_at_micros: Option<i64>,
|
||||
#[serde(default)]
|
||||
metrics_json: String,
|
||||
#[serde(default)]
|
||||
server_result: Option<String>,
|
||||
#[serde(default)]
|
||||
validation_status: String,
|
||||
#[serde(default)]
|
||||
anti_cheat_flags_json: String,
|
||||
#[serde(default)]
|
||||
leaderboard_score: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BarkBattleDraftConfigSnapshotRecord {
|
||||
draft_id: String,
|
||||
#[allow(dead_code)]
|
||||
work_id: String,
|
||||
#[allow(dead_code)]
|
||||
config_version: u64,
|
||||
#[allow(dead_code)]
|
||||
ruleset_version: String,
|
||||
#[serde(default)]
|
||||
config_json: String,
|
||||
updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BarkBattleRuntimeConfigSnapshotRecord {
|
||||
work_id: String,
|
||||
source_draft_id: Option<String>,
|
||||
config_version: u64,
|
||||
ruleset_version: String,
|
||||
#[serde(default)]
|
||||
config_json: String,
|
||||
published_at_micros: i64,
|
||||
updated_at_micros: i64,
|
||||
}
|
||||
|
||||
pub async fn create_bark_battle_draft(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<BarkBattleDraftCreateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
||||
let now = current_utc_micros();
|
||||
let draft = state
|
||||
.spacetime_client()
|
||||
.create_bark_battle_draft(BarkBattleDraftCreateRecordInput {
|
||||
draft_id: build_prefixed_uuid_id(BARK_BATTLE_DRAFT_ID_PREFIX),
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
work_id: build_prefixed_uuid_id(BARK_BATTLE_WORK_ID_PREFIX),
|
||||
title: Some(payload.title),
|
||||
description: payload.description,
|
||||
theme_preset: payload.theme_preset,
|
||||
player_dog_skin_preset: payload.player_dog_skin_preset,
|
||||
opponent_dog_skin_preset: payload.opponent_dog_skin_preset,
|
||||
difficulty_preset: Some(
|
||||
difficulty_to_spacetime_string(&payload.difficulty_preset).to_string(),
|
||||
),
|
||||
leaderboard_enabled: Some(payload.leaderboard_enabled),
|
||||
editor_state_json: Some("{}".to_string()),
|
||||
created_at_micros: now,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||
})?;
|
||||
let draft = map_draft_config_record(draft, &request_context)?;
|
||||
Ok(json_success_body(Some(&request_context), draft))
|
||||
}
|
||||
|
||||
pub async fn publish_bark_battle_work(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<BarkBattleWorkPublishRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
||||
ensure_non_empty(&request_context, &payload.draft_id, "draftId")?;
|
||||
let work_id = payload
|
||||
.work_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| build_prefixed_uuid_id(BARK_BATTLE_WORK_ID_PREFIX));
|
||||
let published_snapshot_json = payload
|
||||
.published_snapshot
|
||||
.as_ref()
|
||||
.map(serde_json::to_string)
|
||||
.transpose()
|
||||
.map_err(|error| {
|
||||
bark_battle_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
||||
"message": format!("publishedSnapshot JSON 序列化失败: {error}"),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let published = state
|
||||
.spacetime_client()
|
||||
.publish_bark_battle_work(BarkBattleWorkPublishRecordInput {
|
||||
draft_id: payload.draft_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
work_id,
|
||||
published_snapshot_json,
|
||||
published_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||
})?;
|
||||
let published = map_published_config_record(published, &request_context)?;
|
||||
Ok(json_success_body(Some(&request_context), published))
|
||||
}
|
||||
|
||||
pub async fn get_bark_battle_runtime_config(
|
||||
State(state): State<AppState>,
|
||||
Path(work_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &work_id, "workId")?;
|
||||
|
||||
let config = state
|
||||
.spacetime_client()
|
||||
.get_bark_battle_runtime_config(work_id, Some(authenticated.claims().user_id().to_string()))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||
})?;
|
||||
let config = map_runtime_config_record(config, &request_context)?;
|
||||
|
||||
Ok(json_success_body(Some(&request_context), config))
|
||||
}
|
||||
|
||||
pub async fn start_bark_battle_run(
|
||||
State(state): State<AppState>,
|
||||
Path(work_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<BarkBattleRunStartRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let maybe_payload = payload.ok().map(|Json(payload)| payload);
|
||||
let request = maybe_payload.unwrap_or_else(|| BarkBattleRunStartRequest {
|
||||
work_id: work_id.clone(),
|
||||
config_version: None,
|
||||
source_route: None,
|
||||
client_runtime_version: None,
|
||||
});
|
||||
let work_id = if request.work_id.trim().is_empty() {
|
||||
work_id
|
||||
} else {
|
||||
request.work_id.trim().to_string()
|
||||
};
|
||||
ensure_non_empty(&request_context, &work_id, "workId")?;
|
||||
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let runtime_config = state
|
||||
.spacetime_client()
|
||||
.get_bark_battle_runtime_config(work_id.clone(), Some(owner_user_id.clone()))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||
})?;
|
||||
let runtime_config = map_runtime_config_record(runtime_config, &request_context)?;
|
||||
if !request.work_id.trim().is_empty() && request.work_id.trim() != work_id {
|
||||
return Err(bark_battle_bad_request(
|
||||
&request_context,
|
||||
"workId 与路径参数不一致",
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(expected_version) = request.config_version {
|
||||
if expected_version != runtime_config.config_version {
|
||||
return Err(bark_battle_bad_request(
|
||||
&request_context,
|
||||
"configVersion 与已发布配置不一致",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let client_started_at_micros = current_utc_micros();
|
||||
let run_token = build_prefixed_uuid_id(BARK_BATTLE_RUN_TOKEN_PREFIX);
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.start_bark_battle_run(BarkBattleRunStartRecordInput {
|
||||
run_id: build_prefixed_uuid_id(BARK_BATTLE_RUN_ID_PREFIX),
|
||||
run_token: run_token.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
work_id: work_id.clone(),
|
||||
config_version: u64::from(runtime_config.config_version),
|
||||
ruleset_version: runtime_config.ruleset_version.clone(),
|
||||
difficulty_preset: difficulty_to_spacetime_string(&runtime_config.difficulty_preset)
|
||||
.to_string(),
|
||||
client_started_at_micros,
|
||||
server_started_at_micros: client_started_at_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||
})?;
|
||||
let run_snapshot = parse_run_record(run, &request_context)?;
|
||||
|
||||
record_work_play_start_after_success(
|
||||
&state,
|
||||
&request_context,
|
||||
WorkPlayTrackingDraft::new(
|
||||
BARK_BATTLE_PLAY_TYPE_ID,
|
||||
work_id.clone(),
|
||||
&authenticated,
|
||||
"/api/runtime/bark-battle/...",
|
||||
)
|
||||
.extra(json!({
|
||||
"runId": run_snapshot.run_id,
|
||||
"workId": work_id,
|
||||
"configVersion": runtime_config.config_version,
|
||||
"rulesetVersion": runtime_config.ruleset_version,
|
||||
"difficultyPreset": runtime_config.difficulty_preset,
|
||||
"sourceRoute": request.source_route,
|
||||
"clientRuntimeVersion": request.client_runtime_version,
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
|
||||
let server_started_at = format_timestamp_micros(run_snapshot.server_started_at_micros);
|
||||
let expires_at = format_timestamp_micros(
|
||||
run_snapshot
|
||||
.server_started_at_micros
|
||||
.saturating_add(BARK_BATTLE_RUN_TTL_SECONDS * 1_000_000),
|
||||
);
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
BarkBattleRunStartResponse {
|
||||
run_id: run_snapshot.run_id,
|
||||
run_token,
|
||||
work_id: run_snapshot.work_id,
|
||||
config_version: runtime_config.config_version,
|
||||
ruleset_version: runtime_config.ruleset_version.clone(),
|
||||
difficulty_preset: runtime_config.difficulty_preset.clone(),
|
||||
runtime_config,
|
||||
server_started_at,
|
||||
expires_at,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_bark_battle_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.get_bark_battle_run(run_id, authenticated.claims().user_id().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||
})?;
|
||||
let run = parse_run_record(run, &request_context)?;
|
||||
|
||||
Ok(json_success_body(Some(&request_context), run))
|
||||
}
|
||||
|
||||
pub async fn finish_bark_battle_run(
|
||||
State(state): State<AppState>,
|
||||
Path(run_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<BarkBattleRunFinishRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = bark_battle_json(payload, &request_context)?;
|
||||
ensure_non_empty(&request_context, &run_id, "runId")?;
|
||||
ensure_non_empty(&request_context, &payload.work_id, "workId")?;
|
||||
ensure_non_empty(&request_context, &payload.run_token, "runToken")?;
|
||||
if payload.run_id != run_id {
|
||||
return Err(bark_battle_bad_request(
|
||||
&request_context,
|
||||
"runId 与路径参数不一致",
|
||||
));
|
||||
}
|
||||
if payload.ruleset_version != BARK_BATTLE_RULESET_VERSION_V1 {
|
||||
return Err(bark_battle_bad_request(
|
||||
&request_context,
|
||||
"rulesetVersion 不支持",
|
||||
));
|
||||
}
|
||||
|
||||
let client_finished_at_micros = parse_client_time_to_micros(&payload.client_finished_at)
|
||||
.map_err(|message| bark_battle_bad_request(&request_context, &message))?;
|
||||
let derived = &payload.derived_metrics;
|
||||
let opponent_final_energy = derive_server_opponent_final_energy(derived);
|
||||
let metrics_json = serde_json::to_string(&json!({
|
||||
"clientStartedAt": payload.client_started_at,
|
||||
"clientFinishedAt": payload.client_finished_at,
|
||||
"durationMs": payload.duration_ms,
|
||||
"derivedMetrics": payload.derived_metrics,
|
||||
"clientResult": payload.client_result,
|
||||
"sampleDigest": payload.sample_digest,
|
||||
"clientRuntimeVersion": payload.client_runtime_version,
|
||||
}))
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
let derived_metrics_json = serde_json::to_string(derived).unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.finish_bark_battle_run(BarkBattleRunFinishRecordInput {
|
||||
run_id,
|
||||
run_token: payload.run_token,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
work_id: payload.work_id.clone(),
|
||||
config_version: u64::from(payload.config_version),
|
||||
ruleset_version: payload.ruleset_version.clone(),
|
||||
difficulty_preset: difficulty_to_spacetime_string(&payload.difficulty_preset)
|
||||
.to_string(),
|
||||
client_finished_at_micros,
|
||||
server_finished_at_micros: current_utc_micros(),
|
||||
duration_ms: payload.duration_ms,
|
||||
trigger_count: u64::from(derived.trigger_count),
|
||||
max_volume_millis: unit_to_millis(derived.max_volume),
|
||||
average_volume_millis: unit_to_millis(derived.average_volume),
|
||||
final_energy_millis: energy_to_millis(derived.final_energy),
|
||||
opponent_final_energy_millis: energy_to_millis(opponent_final_energy),
|
||||
max_combo: derived.combo_max,
|
||||
metrics_json,
|
||||
derived_metrics_json,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
|
||||
})?;
|
||||
let run = parse_run_record(run, &request_context)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
map_finish_response(run, &payload.derived_metrics),
|
||||
))
|
||||
}
|
||||
|
||||
fn map_finish_response(
|
||||
run: BarkBattleRunSnapshotRecord,
|
||||
fallback_metrics: &BarkBattleDerivedMetrics,
|
||||
) -> BarkBattleRunFinishResponse {
|
||||
let score_summary =
|
||||
parse_score_summary(&run.metrics_json).unwrap_or_else(|| BarkBattleScoreSummary {
|
||||
duration_ms: 0,
|
||||
trigger_count: fallback_metrics.trigger_count,
|
||||
max_volume: fallback_metrics.max_volume,
|
||||
average_volume: fallback_metrics.average_volume,
|
||||
final_energy: fallback_metrics.final_energy,
|
||||
combo_max: fallback_metrics.combo_max,
|
||||
});
|
||||
BarkBattleRunFinishResponse {
|
||||
status: parse_finish_status(&run.validation_status),
|
||||
run_id: run.run_id,
|
||||
work_id: run.work_id,
|
||||
config_version: run.config_version.min(u64::from(u32::MAX)) as u32,
|
||||
ruleset_version: run.ruleset_version,
|
||||
difficulty_preset: parse_difficulty_lossy(&run.difficulty_preset),
|
||||
server_result: parse_server_result_lossy(run.server_result.as_deref()),
|
||||
score_summary,
|
||||
leaderboard_score: run.leaderboard_score,
|
||||
anti_cheat_flags: parse_string_vec(&run.anti_cheat_flags_json),
|
||||
updated_at: format_timestamp_micros(
|
||||
run.server_finished_at_micros
|
||||
.unwrap_or(run.server_started_at_micros),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_run_record(
|
||||
value: BarkBattleRunRecord,
|
||||
request_context: &RequestContext,
|
||||
) -> Result<BarkBattleRunSnapshotRecord, Response> {
|
||||
serde_json::from_value(value).map_err(|error| {
|
||||
bark_battle_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
||||
"message": format!("Bark Battle run JSON 解析失败: {error}"),
|
||||
})),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_draft_snapshot_record(
|
||||
value: Value,
|
||||
request_context: &RequestContext,
|
||||
) -> Result<BarkBattleDraftConfigSnapshotRecord, Response> {
|
||||
serde_json::from_value(value)
|
||||
.map_err(|error| bark_battle_snapshot_parse_error(request_context, "draft config", error))
|
||||
}
|
||||
|
||||
fn parse_runtime_snapshot_record(
|
||||
value: Value,
|
||||
request_context: &RequestContext,
|
||||
) -> Result<BarkBattleRuntimeConfigSnapshotRecord, Response> {
|
||||
serde_json::from_value(value)
|
||||
.map_err(|error| bark_battle_snapshot_parse_error(request_context, "runtime config", error))
|
||||
}
|
||||
|
||||
fn map_draft_config_record(
|
||||
value: Value,
|
||||
request_context: &RequestContext,
|
||||
) -> Result<BarkBattleDraftConfig, Response> {
|
||||
let snapshot = parse_draft_snapshot_record(value, request_context)?;
|
||||
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
|
||||
Ok(BarkBattleDraftConfig {
|
||||
draft_id: snapshot.draft_id,
|
||||
title: editor_config.title,
|
||||
description: editor_config.description,
|
||||
theme_preset: editor_config.theme_preset,
|
||||
player_dog_skin_preset: editor_config.player_dog_skin_preset,
|
||||
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
|
||||
difficulty_preset: editor_config.difficulty_preset,
|
||||
leaderboard_enabled: editor_config.leaderboard_enabled,
|
||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||
})
|
||||
}
|
||||
|
||||
fn map_runtime_config_record(
|
||||
value: Value,
|
||||
request_context: &RequestContext,
|
||||
) -> Result<shared_contracts::bark_battle::BarkBattleRuntimeConfig, Response> {
|
||||
let snapshot = parse_runtime_snapshot_record(value, request_context)?;
|
||||
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
|
||||
let ruleset = BarkBattleRuleset::v1();
|
||||
Ok(shared_contracts::bark_battle::BarkBattleRuntimeConfig {
|
||||
work_id: snapshot.work_id,
|
||||
config_version: snapshot.config_version.min(u64::from(u32::MAX)) as u32,
|
||||
ruleset_version: snapshot.ruleset_version,
|
||||
play_type_id: BARK_BATTLE_PLAY_TYPE_ID.to_string(),
|
||||
duration_ms: ruleset.standard_duration_ms,
|
||||
energy_min: 0.0,
|
||||
energy_max: 100.0,
|
||||
draw_threshold: ruleset.draw_threshold_energy as f32,
|
||||
min_bark_gap_ms: ruleset.min_bark_gap_ms,
|
||||
difficulty_preset: editor_config.difficulty_preset,
|
||||
theme_preset: editor_config.theme_preset,
|
||||
player_dog_skin_preset: editor_config.player_dog_skin_preset,
|
||||
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
|
||||
leaderboard_enabled: editor_config.leaderboard_enabled,
|
||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||
})
|
||||
}
|
||||
|
||||
fn map_published_config_record(
|
||||
value: Value,
|
||||
request_context: &RequestContext,
|
||||
) -> Result<BarkBattlePublishedConfig, Response> {
|
||||
let snapshot = parse_runtime_snapshot_record(value, request_context)?;
|
||||
let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?;
|
||||
Ok(BarkBattlePublishedConfig {
|
||||
work_id: snapshot.work_id,
|
||||
draft_id: snapshot.source_draft_id,
|
||||
config_version: snapshot.config_version.min(u64::from(u32::MAX)) as u32,
|
||||
ruleset_version: snapshot.ruleset_version,
|
||||
play_type_id: BARK_BATTLE_PLAY_TYPE_ID.to_string(),
|
||||
title: editor_config.title,
|
||||
description: editor_config.description,
|
||||
theme_preset: editor_config.theme_preset,
|
||||
player_dog_skin_preset: editor_config.player_dog_skin_preset,
|
||||
opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset,
|
||||
difficulty_preset: editor_config.difficulty_preset,
|
||||
leaderboard_enabled: editor_config.leaderboard_enabled,
|
||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||
published_at: format_timestamp_micros(snapshot.published_at_micros),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_editor_config_record(
|
||||
config_json: &str,
|
||||
request_context: &RequestContext,
|
||||
) -> Result<BarkBattleConfigEditorPayload, Response> {
|
||||
serde_json::from_str(config_json).map_err(|error| {
|
||||
bark_battle_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
||||
"message": format!("Bark Battle configJson 解析失败: {error}"),
|
||||
})),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn bark_battle_snapshot_parse_error(
|
||||
request_context: &RequestContext,
|
||||
label: &str,
|
||||
error: serde_json::Error,
|
||||
) -> Response {
|
||||
bark_battle_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
||||
"message": format!("Bark Battle {label} JSON 解析失败: {error}"),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn bark_battle_json<T>(
|
||||
payload: Result<Json<T>, JsonRejection>,
|
||||
request_context: &RequestContext,
|
||||
) -> Result<Json<T>, Response> {
|
||||
payload.map_err(|error| {
|
||||
bark_battle_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_non_empty(
|
||||
request_context: &RequestContext,
|
||||
value: &str,
|
||||
field_name: &str,
|
||||
) -> Result<(), Response> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(bark_battle_bad_request(
|
||||
request_context,
|
||||
&format!("{field_name} is required"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bark_battle_bad_request(request_context: &RequestContext, message: &str) -> Response {
|
||||
bark_battle_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn map_bark_battle_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let status = match &error {
|
||||
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
|
||||
SpacetimeClientError::Procedure(message)
|
||||
if message.contains("不存在")
|
||||
|| message.contains("not found")
|
||||
|| message.contains("does not exist") =>
|
||||
{
|
||||
StatusCode::NOT_FOUND
|
||||
}
|
||||
SpacetimeClientError::Procedure(message)
|
||||
if message.contains("不能为空")
|
||||
|| message.contains("不匹配")
|
||||
|| message.contains("不支持")
|
||||
|| message.contains("已结束")
|
||||
|| message.contains("已存在") =>
|
||||
{
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn bark_battle_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
let mut response = error.into_response_with_context(Some(request_context));
|
||||
response.headers_mut().insert(
|
||||
HeaderName::from_static("x-genarrative-provider"),
|
||||
header::HeaderValue::from_static(BARK_BATTLE_RUNTIME_PROVIDER),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
fn parse_client_time_to_micros(value: &str) -> Result<i64, String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("client timestamp is required".to_string());
|
||||
}
|
||||
if let Ok(micros) = trimmed.parse::<i64>() {
|
||||
return Ok(micros);
|
||||
}
|
||||
parse_rfc3339(trimmed).map(offset_datetime_to_unix_micros)
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
(duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros())
|
||||
}
|
||||
|
||||
fn unit_to_millis(value: f32) -> u32 {
|
||||
(value.clamp(0.0, 1.0) * 1_000.0).round() as u32
|
||||
}
|
||||
|
||||
fn energy_to_millis(value: f32) -> u32 {
|
||||
(value.clamp(0.0, 100.0) * 1_000.0).round() as u32
|
||||
}
|
||||
|
||||
fn derive_server_opponent_final_energy(metrics: &BarkBattleDerivedMetrics) -> f32 {
|
||||
let ruleset = BarkBattleRuleset::v1();
|
||||
let pressure = (metrics.average_volume * 24.0)
|
||||
+ (metrics.max_volume * 16.0)
|
||||
+ (metrics.trigger_count as f32 * 0.35)
|
||||
+ (metrics.combo_max as f32 * 0.2);
|
||||
(ruleset.max_final_energy - pressure).clamp(ruleset.min_final_energy, ruleset.max_final_energy)
|
||||
}
|
||||
|
||||
fn difficulty_to_spacetime_string(value: &BarkBattleDifficultyPreset) -> &'static str {
|
||||
match value {
|
||||
BarkBattleDifficultyPreset::Easy => "easy",
|
||||
BarkBattleDifficultyPreset::Normal => "normal",
|
||||
BarkBattleDifficultyPreset::Hard => "hard",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_difficulty(value: &str) -> Result<BarkBattleDifficultyPreset, AppError> {
|
||||
match value {
|
||||
"easy" => Ok(BarkBattleDifficultyPreset::Easy),
|
||||
"normal" => Ok(BarkBattleDifficultyPreset::Normal),
|
||||
"hard" => Ok(BarkBattleDifficultyPreset::Hard),
|
||||
_ => Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": BARK_BATTLE_RUNTIME_PROVIDER,
|
||||
"message": format!("Bark Battle difficultyPreset 不支持: {value}"),
|
||||
})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_difficulty_lossy(value: &str) -> BarkBattleDifficultyPreset {
|
||||
parse_difficulty(value).unwrap_or(BarkBattleDifficultyPreset::Normal)
|
||||
}
|
||||
|
||||
fn parse_finish_status(value: &str) -> BarkBattleFinishStatus {
|
||||
match value {
|
||||
"accepted" => BarkBattleFinishStatus::Accepted,
|
||||
"accepted_with_flags" => BarkBattleFinishStatus::AcceptedWithFlags,
|
||||
"rejected" => BarkBattleFinishStatus::Rejected,
|
||||
_ => BarkBattleFinishStatus::Rejected,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_server_result_lossy(value: Option<&str>) -> BarkBattleServerResult {
|
||||
match value {
|
||||
Some("player_win") => BarkBattleServerResult::PlayerWin,
|
||||
Some("opponent_win") => BarkBattleServerResult::OpponentWin,
|
||||
Some("draw") => BarkBattleServerResult::Draw,
|
||||
_ => BarkBattleServerResult::Draw,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_score_summary(metrics_json: &str) -> Option<BarkBattleScoreSummary> {
|
||||
let value: Value = serde_json::from_str(metrics_json).ok()?;
|
||||
let derived = value.get("derivedMetrics")?;
|
||||
Some(BarkBattleScoreSummary {
|
||||
duration_ms: value.get("durationMs")?.as_u64()?,
|
||||
trigger_count: derived
|
||||
.get("triggerCount")?
|
||||
.as_u64()?
|
||||
.min(u64::from(u32::MAX)) as u32,
|
||||
max_volume: derived.get("maxVolume")?.as_f64()? as f32,
|
||||
average_volume: derived.get("averageVolume")?.as_f64()? as f32,
|
||||
final_energy: derived.get("finalEnergy")?.as_f64()? as f32,
|
||||
combo_max: derived.get("comboMax")?.as_u64()?.min(u64::from(u32::MAX)) as u32,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_string_vec(value: &str) -> Vec<String> {
|
||||
serde_json::from_str(value).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn format_rfc3339_or_timestamp_micros(micros: i64) -> String {
|
||||
let seconds = micros.div_euclid(1_000_000);
|
||||
let subsec_micros = micros.rem_euclid(1_000_000);
|
||||
let Ok(value) = OffsetDateTime::from_unix_timestamp(seconds)
|
||||
.map(|value| value + TimeDuration::microseconds(subsec_micros))
|
||||
else {
|
||||
return format_timestamp_micros(micros);
|
||||
};
|
||||
format_rfc3339(value).unwrap_or_else(|_| format_timestamp_micros(micros))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unit_and_energy_are_clamped_to_spacetime_millis() {
|
||||
assert_eq!(unit_to_millis(0.625), 625);
|
||||
assert_eq!(unit_to_millis(3.0), 1000);
|
||||
assert_eq!(energy_to_millis(88.456), 88_456);
|
||||
assert_eq!(energy_to_millis(120.0), 100_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_rfc3339_and_numeric_client_timestamps() {
|
||||
assert_eq!(
|
||||
parse_client_time_to_micros("1713686401234567").unwrap(),
|
||||
1_713_686_401_234_567
|
||||
);
|
||||
assert_eq!(
|
||||
parse_client_time_to_micros("2024-04-21T04:00:01.234567Z").unwrap(),
|
||||
1_713_672_001_234_567
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -47,15 +47,16 @@ use crate::big_fish_agent_turn::{
|
||||
run_big_fish_agent_turn,
|
||||
};
|
||||
use crate::big_fish_draft_compiler::compile_big_fish_draft_with_fallback;
|
||||
use crate::generated_image_assets::{
|
||||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||||
adapter::GeneratedImageAssetAdapterMetadata, adapter::GeneratedImageAssetPersistInput,
|
||||
normalize_generated_image_asset_mime,
|
||||
};
|
||||
use crate::prompt::big_fish::{
|
||||
BIG_FISH_DEFAULT_NEGATIVE_PROMPT, BIG_FISH_TRANSPARENT_ASSET_NEGATIVE_PROMPT,
|
||||
build_big_fish_level_main_image_prompt, build_big_fish_level_motion_prompt,
|
||||
build_big_fish_stage_background_prompt,
|
||||
};
|
||||
use crate::generated_image_assets::{
|
||||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, adapter::GeneratedImageAssetAdapterMetadata,
|
||||
adapter::GeneratedImageAssetPersistInput, normalize_generated_image_asset_mime,
|
||||
};
|
||||
use crate::{
|
||||
ai_generation_drafts::{
|
||||
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
|
||||
|
||||
@@ -427,27 +427,28 @@ async fn persist_big_fish_formal_asset(
|
||||
})?;
|
||||
let http_client = reqwest::Client::new();
|
||||
let image_format = normalize_generated_image_asset_mime(downloaded.mime_type.as_str());
|
||||
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||||
prefix: LegacyAssetPrefix::BigFishAssets,
|
||||
path_segments: context.path_segments.clone(),
|
||||
file_stem: "image".to_string(),
|
||||
image: GeneratedImageAssetDataUrl {
|
||||
format: image_format,
|
||||
bytes: downloaded.bytes,
|
||||
},
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: GeneratedImageAssetAdapterMetadata {
|
||||
asset_kind: Some(context.asset_object_kind.clone()),
|
||||
owner_user_id: Some(owner_user_id.to_string()),
|
||||
entity_kind: Some(BIG_FISH_ENTITY_KIND.to_string()),
|
||||
entity_id: Some(context.entity_id.clone()),
|
||||
slot: Some(context.binding_slot.clone()),
|
||||
provider: Some("dashscope".to_string()),
|
||||
task_id: Some(generated.task_id.clone()),
|
||||
},
|
||||
extra_metadata: BTreeMap::new(),
|
||||
})
|
||||
.map_err(map_big_fish_generated_image_asset_error)?;
|
||||
let prepared =
|
||||
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||||
prefix: LegacyAssetPrefix::BigFishAssets,
|
||||
path_segments: context.path_segments.clone(),
|
||||
file_stem: "image".to_string(),
|
||||
image: GeneratedImageAssetDataUrl {
|
||||
format: image_format,
|
||||
bytes: downloaded.bytes,
|
||||
},
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: GeneratedImageAssetAdapterMetadata {
|
||||
asset_kind: Some(context.asset_object_kind.clone()),
|
||||
owner_user_id: Some(owner_user_id.to_string()),
|
||||
entity_kind: Some(BIG_FISH_ENTITY_KIND.to_string()),
|
||||
entity_id: Some(context.entity_id.clone()),
|
||||
slot: Some(context.binding_slot.clone()),
|
||||
provider: Some("dashscope".to_string()),
|
||||
task_id: Some(generated.task_id.clone()),
|
||||
},
|
||||
extra_metadata: BTreeMap::new(),
|
||||
})
|
||||
.map_err(map_big_fish_generated_image_asset_error)?;
|
||||
let persisted_mime_type = prepared.format.mime_type.clone();
|
||||
let put_result = oss_client
|
||||
.put_object(&http_client, prepared.request)
|
||||
@@ -582,7 +583,6 @@ fn normalize_big_fish_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn map_big_fish_dashscope_request_error(message: String) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
@@ -642,4 +642,3 @@ fn build_big_fish_level_part(level: Option<u32>) -> String {
|
||||
.map(|value| format!("level-{value}"))
|
||||
.unwrap_or_else(|| "stage".to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn map_big_fish_session_response(session: BigFishSessionRecord) -> BigFishSessionSnapshotResponse {
|
||||
pub(super) fn map_big_fish_session_response(
|
||||
session: BigFishSessionRecord,
|
||||
) -> BigFishSessionSnapshotResponse {
|
||||
BigFishSessionSnapshotResponse {
|
||||
session_id: session.session_id,
|
||||
current_turn: session.current_turn,
|
||||
@@ -36,7 +38,9 @@ pub(super) fn map_big_fish_anchor_pack_response(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_big_fish_anchor_item_response(anchor: BigFishAnchorItemRecord) -> BigFishAnchorItemResponse {
|
||||
pub(super) fn map_big_fish_anchor_item_response(
|
||||
anchor: BigFishAnchorItemRecord,
|
||||
) -> BigFishAnchorItemResponse {
|
||||
BigFishAnchorItemResponse {
|
||||
key: anchor.key,
|
||||
label: anchor.label,
|
||||
@@ -45,7 +49,9 @@ pub(super) fn map_big_fish_anchor_item_response(anchor: BigFishAnchorItemRecord)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_big_fish_draft_response(draft: BigFishGameDraftRecord) -> BigFishGameDraftResponse {
|
||||
pub(super) fn map_big_fish_draft_response(
|
||||
draft: BigFishGameDraftRecord,
|
||||
) -> BigFishGameDraftResponse {
|
||||
BigFishGameDraftResponse {
|
||||
title: draft.title,
|
||||
subtitle: draft.subtitle,
|
||||
@@ -114,7 +120,9 @@ pub(super) fn map_big_fish_runtime_params_response(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_big_fish_asset_slot_response(slot: BigFishAssetSlotRecord) -> BigFishAssetSlotResponse {
|
||||
pub(super) fn map_big_fish_asset_slot_response(
|
||||
slot: BigFishAssetSlotRecord,
|
||||
) -> BigFishAssetSlotResponse {
|
||||
BigFishAssetSlotResponse {
|
||||
slot_id: slot.slot_id,
|
||||
asset_kind: slot.asset_kind,
|
||||
@@ -140,7 +148,9 @@ pub(super) fn map_big_fish_asset_coverage_response(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_big_fish_run_response(run: BigFishRuntimeRunRecord) -> BigFishRuntimeSnapshotResponse {
|
||||
pub(super) fn map_big_fish_run_response(
|
||||
run: BigFishRuntimeRunRecord,
|
||||
) -> BigFishRuntimeSnapshotResponse {
|
||||
BigFishRuntimeSnapshotResponse {
|
||||
run_id: run.run_id,
|
||||
session_id: run.session_id,
|
||||
@@ -178,7 +188,9 @@ pub(super) fn map_big_fish_runtime_entity_response(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_big_fish_vector2_response(vector: BigFishVector2Record) -> BigFishVector2Response {
|
||||
pub(super) fn map_big_fish_vector2_response(
|
||||
vector: BigFishVector2Record,
|
||||
) -> BigFishVector2Response {
|
||||
BigFishVector2Response {
|
||||
x: vector.x,
|
||||
y: vector.y,
|
||||
@@ -317,5 +329,3 @@ pub(super) fn build_big_fish_welcome_text(seed_text: &str) -> String {
|
||||
}
|
||||
"我已经收到你的玩法起点,会先把它整理成锚点并准备结果页草稿。".to_string()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use platform_speech::{
|
||||
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";
|
||||
pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000;
|
||||
|
||||
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -248,7 +249,7 @@ impl Default for AppConfig {
|
||||
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_image_request_timeout_ms: DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
|
||||
vector_engine_audio_request_timeout_ms: 180_000,
|
||||
hyper3d_base_url: "https://api.hyper3d.com/api/v2".to_string(),
|
||||
hyper3d_api_key: None,
|
||||
@@ -675,7 +676,9 @@ impl AppConfig {
|
||||
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;
|
||||
// 中文注释:VectorEngine image-2 实测可能超过 500 秒;旧环境文件中常见的 180 秒值不能再提前截断真实生图。
|
||||
config.vector_engine_image_request_timeout_ms = vector_engine_image_request_timeout_ms
|
||||
.max(DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
if let Some(vector_engine_audio_request_timeout_ms) =
|
||||
@@ -1009,7 +1012,7 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AppConfig, LlmProvider};
|
||||
use super::{AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
@@ -1094,7 +1097,10 @@ mod tests {
|
||||
config.vector_engine_base_url,
|
||||
"https://vector.internal.example"
|
||||
);
|
||||
assert_eq!(config.vector_engine_image_request_timeout_ms, 210_000);
|
||||
assert_eq!(
|
||||
config.vector_engine_image_request_timeout_ms,
|
||||
DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS
|
||||
);
|
||||
assert_eq!(
|
||||
config.hyper3d_base_url,
|
||||
"https://model.internal.example/api/v2"
|
||||
|
||||
@@ -78,6 +78,9 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
||||
if normalized.starts_with("/api/runtime/match3d") {
|
||||
return Some("match3d");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/bark-battle") {
|
||||
return Some("bark-battle");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/square-hole") {
|
||||
return Some("square-hole");
|
||||
}
|
||||
@@ -90,6 +93,12 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
||||
if normalized.starts_with("/api/creation/visual-novel") {
|
||||
return Some("visual-novel");
|
||||
}
|
||||
if normalized.starts_with("/api/creation/edutainment/baby-object-match") {
|
||||
return Some("baby-object-match");
|
||||
}
|
||||
if normalized.starts_with("/api/creation/edutainment/baby-love-drawing") {
|
||||
return Some("baby-love-drawing");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -112,40 +121,11 @@ pub(crate) fn test_creation_entry_config_response()
|
||||
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),
|
||||
],
|
||||
creation_types: module_runtime::default_creation_entry_type_snapshots(0),
|
||||
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::*;
|
||||
@@ -172,6 +152,33 @@ mod tests {
|
||||
resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"),
|
||||
Some("visual-novel"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"),
|
||||
Some("bark-battle"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/creation/edutainment/baby-object-match/assets"),
|
||||
Some("baby-object-match"),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_creation_entry_route_id("/api/creation/edutainment/baby-love-drawing/magic"),
|
||||
Some("baby-love-drawing"),
|
||||
);
|
||||
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_creation_entry_config_response_keeps_baby_object_match_visible() {
|
||||
let config = test_creation_entry_config_response();
|
||||
let baby_object_match = config
|
||||
.creation_types
|
||||
.iter()
|
||||
.find(|item| item.id == "baby-object-match")
|
||||
.expect("test creation entry config should include baby-object-match");
|
||||
|
||||
assert_eq!(baby_object_match.title, "宝贝识物");
|
||||
assert!(baby_object_match.visible);
|
||||
assert!(baby_object_match.open);
|
||||
assert_eq!(baby_object_match.sort_order, 90);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,4 +377,3 @@ pub(super) fn normalize_json_object_value(value: &Value) -> Option<Value> {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use super::*;
|
||||
use super::opening_cg::{
|
||||
map_asset_binding_prepare_error, map_asset_object_prepare_error,
|
||||
map_custom_world_asset_oss_error, map_custom_world_asset_spacetime_error,
|
||||
map_custom_world_generated_image_asset_error,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn persist_custom_world_asset(
|
||||
state: &AppState,
|
||||
@@ -36,30 +36,31 @@ pub(super) async fn persist_custom_world_asset(
|
||||
.map(|(stem, _)| stem)
|
||||
.unwrap_or(file_name.as_str())
|
||||
.to_string();
|
||||
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||||
prefix,
|
||||
path_segments,
|
||||
file_stem,
|
||||
image: GeneratedImageAssetDataUrl {
|
||||
format: normalize_generated_image_asset_mime(content_type.as_str()),
|
||||
bytes: body,
|
||||
},
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: GeneratedImageAssetAdapterMetadata {
|
||||
asset_kind: Some(asset_kind.to_string()),
|
||||
owner_user_id: Some(owner_user_id.to_string()),
|
||||
entity_kind: Some(entity_kind.to_string()),
|
||||
entity_id: Some(entity_id.clone()),
|
||||
slot: Some(slot.to_string()),
|
||||
provider: None,
|
||||
task_id: source_job_id.clone(),
|
||||
},
|
||||
extra_metadata: profile_id
|
||||
.as_ref()
|
||||
.map(|profile_id| BTreeMap::from([("profile_id".to_string(), profile_id.clone())]))
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.map_err(map_custom_world_generated_image_asset_error)?;
|
||||
let prepared =
|
||||
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||||
prefix,
|
||||
path_segments,
|
||||
file_stem,
|
||||
image: GeneratedImageAssetDataUrl {
|
||||
format: normalize_generated_image_asset_mime(content_type.as_str()),
|
||||
bytes: body,
|
||||
},
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: GeneratedImageAssetAdapterMetadata {
|
||||
asset_kind: Some(asset_kind.to_string()),
|
||||
owner_user_id: Some(owner_user_id.to_string()),
|
||||
entity_kind: Some(entity_kind.to_string()),
|
||||
entity_id: Some(entity_id.clone()),
|
||||
slot: Some(slot.to_string()),
|
||||
provider: None,
|
||||
task_id: source_job_id.clone(),
|
||||
},
|
||||
extra_metadata: profile_id
|
||||
.as_ref()
|
||||
.map(|profile_id| BTreeMap::from([("profile_id".to_string(), profile_id.clone())]))
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.map_err(map_custom_world_generated_image_asset_error)?;
|
||||
let persisted_mime_type = prepared.format.mime_type.clone();
|
||||
let put_result = oss_client
|
||||
.put_object(&http_client, prepared.request)
|
||||
@@ -119,4 +120,3 @@ pub(super) async fn persist_custom_world_asset(
|
||||
response.image_src = put_result.legacy_public_path;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
|
||||
@@ -374,4 +374,3 @@ pub(super) fn map_custom_world_asset_spacetime_error(error: SpacetimeClientError
|
||||
pub(super) fn map_custom_world_asset_oss_error(error: platform_oss::OssError) -> AppError {
|
||||
map_oss_error(error, "aliyun-oss")
|
||||
}
|
||||
|
||||
|
||||
337
server-rs/crates/api-server/src/edutainment_baby_drawing.rs
Normal file
337
server-rs/crates/api-server/src/edutainment_baby_drawing.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use image::{ColorType, ImageEncoder, codecs::png::PngEncoder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
|
||||
require_openai_image_settings,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const BABY_LOVE_DRAWING_PROVIDER: &str = "vector-engine-gpt-image-2";
|
||||
const BABY_LOVE_DRAWING_IMAGE_SIZE: &str = "1024x1024";
|
||||
const BABY_LOVE_DRAWING_MAX_STROKES: usize = 600;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CreateBabyLoveDrawingMagicRequest {
|
||||
original_image_src: String,
|
||||
#[serde(default)]
|
||||
stroke_trace: Vec<BabyLoveDrawingStrokePayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BabyLoveDrawingStrokePayload {
|
||||
stroke_id: String,
|
||||
tool: String,
|
||||
color: String,
|
||||
#[serde(default)]
|
||||
points: Vec<BabyLoveDrawingPointPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BabyLoveDrawingPointPayload {
|
||||
x: f64,
|
||||
y: f64,
|
||||
t: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CreateBabyLoveDrawingMagicResponse {
|
||||
magic_image_src: String,
|
||||
generation_provider: String,
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
pub async fn create_baby_love_drawing_magic(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
payload: Result<Json<CreateBabyLoveDrawingMagicRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
baby_love_drawing_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "edutainment-baby-drawing",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
validate_magic_request(&payload)
|
||||
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
|
||||
|
||||
let settings = require_openai_image_settings(&state)
|
||||
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
|
||||
let http_client = build_openai_image_http_client(&settings)
|
||||
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
|
||||
let prompt = build_baby_love_drawing_magic_prompt(payload.stroke_trace.as_slice());
|
||||
let reference_images = vec![payload.original_image_src.trim().to_string()];
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
prompt.as_str(),
|
||||
Some(build_baby_love_drawing_negative_prompt()),
|
||||
BABY_LOVE_DRAWING_IMAGE_SIZE,
|
||||
1,
|
||||
reference_images.as_slice(),
|
||||
"宝贝爱画绘画魔法图片生成失败",
|
||||
)
|
||||
.await
|
||||
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
|
||||
let generated_image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
baby_love_drawing_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "宝贝爱画绘画魔法没有返回图片。",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let magic_image_src = build_png_data_url(generated_image)
|
||||
.map_err(|error| baby_love_drawing_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
CreateBabyLoveDrawingMagicResponse {
|
||||
magic_image_src,
|
||||
generation_provider: BABY_LOVE_DRAWING_PROVIDER.to_string(),
|
||||
prompt,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn validate_magic_request(payload: &CreateBabyLoveDrawingMagicRequest) -> Result<(), AppError> {
|
||||
let original_image_src = payload.original_image_src.trim();
|
||||
if !original_image_src.starts_with("data:image/") || !original_image_src.contains(";base64,") {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "edutainment-baby-drawing",
|
||||
"message": "绘画原图必须是图片 Data URL。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if payload.stroke_trace.len() > BABY_LOVE_DRAWING_MAX_STROKES {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "edutainment-baby-drawing",
|
||||
"message": "绘画笔触数量过多,请重新完成绘画后再使用魔法。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_baby_love_drawing_magic_prompt(stroke_trace: &[BabyLoveDrawingStrokePayload]) -> String {
|
||||
let stroke_count = stroke_trace.len();
|
||||
let brush_count = stroke_trace
|
||||
.iter()
|
||||
.filter(|stroke| stroke.tool.trim() == "brush")
|
||||
.count();
|
||||
let eraser_count = stroke_trace
|
||||
.iter()
|
||||
.filter(|stroke| stroke.tool.trim() == "eraser")
|
||||
.count();
|
||||
let color_summary = summarize_stroke_colors(stroke_trace);
|
||||
let trace_bounds = summarize_trace_bounds(stroke_trace);
|
||||
|
||||
format!(
|
||||
"根据参考图中的儿童绘画内容,为寓教于乐独立关卡“宝贝爱画”生成一张绘本风格图片。\n\
|
||||
必须保留小朋友原始画面的主体构图、线条方向、颜色关系和童趣笔触,不要改成与原图无关的新内容。\n\
|
||||
输出风格:明亮、温暖、柔和、卡通绘本风格,适合 4-8 岁儿童,画面干净,边缘柔和,有轻微纸面质感。\n\
|
||||
笔触信息:总笔触 {stroke_count} 条,画笔 {brush_count} 条,橡皮 {eraser_count} 条,主要颜色 {color_summary},绘制范围 {trace_bounds}。\n\
|
||||
不要生成文字、水印、Logo、按钮、UI 面板、真实照片风、恐怖或成人化内容。"
|
||||
)
|
||||
}
|
||||
|
||||
fn summarize_stroke_colors(stroke_trace: &[BabyLoveDrawingStrokePayload]) -> String {
|
||||
let mut colors = Vec::new();
|
||||
for stroke in stroke_trace {
|
||||
if stroke.stroke_id.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let color = stroke.color.trim();
|
||||
if color.is_empty() || colors.iter().any(|value| value == color) {
|
||||
continue;
|
||||
}
|
||||
colors.push(color.to_string());
|
||||
if colors.len() >= 5 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if colors.is_empty() {
|
||||
"无明显颜色记录".to_string()
|
||||
} else {
|
||||
colors.join("、")
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_trace_bounds(stroke_trace: &[BabyLoveDrawingStrokePayload]) -> String {
|
||||
let mut min_x = 1.0_f64;
|
||||
let mut min_y = 1.0_f64;
|
||||
let mut max_x = 0.0_f64;
|
||||
let mut max_y = 0.0_f64;
|
||||
let mut has_point = false;
|
||||
|
||||
for point in stroke_trace.iter().flat_map(|stroke| stroke.points.iter()) {
|
||||
if !(point.x.is_finite() && point.y.is_finite() && point.t.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
has_point = true;
|
||||
min_x = min_x.min(point.x.clamp(0.0, 1.0));
|
||||
min_y = min_y.min(point.y.clamp(0.0, 1.0));
|
||||
max_x = max_x.max(point.x.clamp(0.0, 1.0));
|
||||
max_y = max_y.max(point.y.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
if !has_point {
|
||||
return "无可用坐标记录".to_string();
|
||||
}
|
||||
|
||||
format!("x {:.2}-{:.2}, y {:.2}-{:.2}", min_x, max_x, min_y, max_y)
|
||||
}
|
||||
|
||||
fn build_baby_love_drawing_negative_prompt() -> &'static str {
|
||||
"文字,水印,Logo,按钮,UI,面板,复杂背景,真实照片风,恐怖元素,成人化内容,攻击性内容,替换原图主体,完全无关的新画面"
|
||||
}
|
||||
|
||||
fn build_png_data_url(image: DownloadedOpenAiImage) -> Result<String, AppError> {
|
||||
let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?;
|
||||
Ok(format!(
|
||||
"data:image/png;base64,{}",
|
||||
BASE64_STANDARD.encode(png_bytes)
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_generated_image_to_png(source: &[u8]) -> Result<Vec<u8>, AppError> {
|
||||
let rgba_image = image::load_from_memory(source)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": format!("解析宝贝爱画魔法图片失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.to_rgba8();
|
||||
let (width, height) = rgba_image.dimensions();
|
||||
let mut encoded = Vec::new();
|
||||
let encoder = PngEncoder::new(&mut encoded);
|
||||
encoder
|
||||
.write_image(rgba_image.as_raw(), width, height, ColorType::Rgba8.into())
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": format!("转换宝贝爱画魔法图片为 PNG 失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
fn baby_love_drawing_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_request() -> CreateBabyLoveDrawingMagicRequest {
|
||||
CreateBabyLoveDrawingMagicRequest {
|
||||
original_image_src: "data:image/png;base64,abcd".to_string(),
|
||||
stroke_trace: vec![BabyLoveDrawingStrokePayload {
|
||||
stroke_id: "stroke-1".to_string(),
|
||||
tool: "brush".to_string(),
|
||||
color: "#ef4444".to_string(),
|
||||
points: vec![
|
||||
BabyLoveDrawingPointPayload {
|
||||
x: 0.2,
|
||||
y: 0.3,
|
||||
t: 1.0,
|
||||
},
|
||||
BabyLoveDrawingPointPayload {
|
||||
x: 0.7,
|
||||
y: 0.8,
|
||||
t: 2.0,
|
||||
},
|
||||
],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_prompt_keeps_child_drawing_and_picture_book_style() {
|
||||
let request = sample_request();
|
||||
let prompt = build_baby_love_drawing_magic_prompt(request.stroke_trace.as_slice());
|
||||
|
||||
assert!(prompt.contains("宝贝爱画"));
|
||||
assert!(prompt.contains("绘本风格"));
|
||||
assert!(prompt.contains("保留小朋友原始画面"));
|
||||
assert!(prompt.contains("#ef4444"));
|
||||
assert!(prompt.contains("x 0.20-0.70"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_request_requires_image_data_url() {
|
||||
let request = sample_request();
|
||||
assert!(validate_magic_request(&request).is_ok());
|
||||
|
||||
let invalid = CreateBabyLoveDrawingMagicRequest {
|
||||
original_image_src: "https://example.test/image.png".to_string(),
|
||||
..sample_request()
|
||||
};
|
||||
|
||||
assert!(validate_magic_request(&invalid).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_png_to_png_data_url() {
|
||||
let mut source = Vec::new();
|
||||
let pixels = vec![255u8; 4 * 2 * 2];
|
||||
let encoder = PngEncoder::new(&mut source);
|
||||
encoder
|
||||
.write_image(pixels.as_slice(), 2, 2, ColorType::Rgba8.into())
|
||||
.expect("test png should encode");
|
||||
|
||||
let image_src = build_png_data_url(DownloadedOpenAiImage {
|
||||
bytes: source,
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
})
|
||||
.expect("test png should normalize");
|
||||
|
||||
assert!(image_src.starts_with("data:image/png;base64,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_summary_ignores_invalid_points() {
|
||||
let mut request = sample_request();
|
||||
request.stroke_trace[0]
|
||||
.points
|
||||
.push(BabyLoveDrawingPointPayload {
|
||||
x: f64::NAN,
|
||||
y: 0.1,
|
||||
t: 3.0,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
summarize_trace_bounds(request.stroke_trace.as_slice()),
|
||||
"x 0.20-0.70, y 0.30-0.80",
|
||||
);
|
||||
}
|
||||
}
|
||||
642
server-rs/crates/api-server/src/edutainment_baby_object.rs
Normal file
642
server-rs/crates/api-server/src/edutainment_baby_object.rs
Normal file
@@ -0,0 +1,642 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use futures_util::{StreamExt, stream::FuturesUnordered};
|
||||
use image::{ColorType, ImageEncoder, codecs::png::PngEncoder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body,
|
||||
character_visual_assets::try_apply_background_alpha_to_png,
|
||||
config::DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
|
||||
http_error::AppError,
|
||||
openai_image_generation::{
|
||||
DownloadedOpenAiImage, OpenAiImageSettings, build_openai_image_http_client,
|
||||
create_openai_image_generation, require_openai_image_settings,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const BABY_OBJECT_MATCH_PROVIDER: &str = "vector-engine-gpt-image-2";
|
||||
const BABY_OBJECT_MATCH_IMAGE_SIZE: &str = "1024x1024";
|
||||
const BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE: &str = "1536x1024";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GenerateBabyObjectMatchAssetsRequest {
|
||||
item_names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GenerateBabyObjectMatchAssetsResponse {
|
||||
assets: Vec<BabyObjectMatchItemAssetPayload>,
|
||||
visual_package: BabyObjectMatchVisualPackagePayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BabyObjectMatchItemAssetPayload {
|
||||
item_id: String,
|
||||
item_name: String,
|
||||
image_src: String,
|
||||
asset_object_id: Option<String>,
|
||||
generation_provider: String,
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum BabyObjectMatchVisualAssetKind {
|
||||
Background,
|
||||
UiFrame,
|
||||
GiftBox,
|
||||
Basket,
|
||||
SmokePuff,
|
||||
}
|
||||
|
||||
impl BabyObjectMatchVisualAssetKind {
|
||||
fn asset_id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Background => "baby-object-visual-background",
|
||||
Self::UiFrame => "baby-object-visual-ui-frame",
|
||||
Self::GiftBox => "baby-object-visual-gift-box",
|
||||
Self::Basket => "baby-object-visual-basket",
|
||||
Self::SmokePuff => "baby-object-visual-smoke-puff",
|
||||
}
|
||||
}
|
||||
|
||||
fn contract_kind(self) -> &'static str {
|
||||
match self {
|
||||
Self::Background => "background",
|
||||
Self::UiFrame => "ui-frame",
|
||||
Self::GiftBox => "gift-box",
|
||||
Self::Basket => "basket",
|
||||
Self::SmokePuff => "smoke-puff",
|
||||
}
|
||||
}
|
||||
|
||||
fn requires_transparency(self) -> bool {
|
||||
!matches!(self, Self::Background)
|
||||
}
|
||||
|
||||
fn image_size(self) -> &'static str {
|
||||
match self {
|
||||
Self::Background => BABY_OBJECT_MATCH_BACKGROUND_IMAGE_SIZE,
|
||||
Self::UiFrame | Self::GiftBox | Self::Basket | Self::SmokePuff => {
|
||||
BABY_OBJECT_MATCH_IMAGE_SIZE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn failure_context(self) -> &'static str {
|
||||
match self {
|
||||
Self::Background => "宝贝识物背景环境图片生成失败",
|
||||
Self::UiFrame => "宝贝识物 UI 装饰图片生成失败",
|
||||
Self::GiftBox => "宝贝识物礼物盒图片生成失败",
|
||||
Self::Basket => "宝贝识物篮子图片生成失败",
|
||||
Self::SmokePuff => "宝贝识物烟雾特效图片生成失败",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BabyObjectMatchVisualPackagePayload {
|
||||
theme_prompt: String,
|
||||
assets: Vec<BabyObjectMatchVisualAssetPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct BabyObjectMatchVisualAssetPayload {
|
||||
asset_id: String,
|
||||
asset_kind: String,
|
||||
image_src: String,
|
||||
asset_object_id: Option<String>,
|
||||
generation_provider: String,
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
pub async fn generate_baby_object_match_assets(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
payload: Result<Json<GenerateBabyObjectMatchAssetsRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
baby_object_match_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "edutainment-baby-object",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let item_names = normalize_item_names(payload.item_names)
|
||||
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
|
||||
|
||||
let settings = require_openai_image_settings(&state)
|
||||
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
|
||||
let settings = with_baby_object_match_image_timeout(settings);
|
||||
let http_client = build_openai_image_http_client(&settings)
|
||||
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
|
||||
|
||||
let request_started_at = Instant::now();
|
||||
tracing::info!(
|
||||
item_count = item_names.len(),
|
||||
"宝贝识物 image-2 资源生成开始"
|
||||
);
|
||||
let (assets, visual_package) = tokio::try_join!(
|
||||
build_baby_object_match_item_assets(&http_client, &settings, item_names.as_slice()),
|
||||
build_baby_object_match_visual_package(&http_client, &settings, item_names.as_slice()),
|
||||
)
|
||||
.map_err(|error| baby_object_match_error_response(&request_context, error))?;
|
||||
tracing::info!(
|
||||
elapsed_ms = request_started_at.elapsed().as_millis() as u64,
|
||||
"宝贝识物 image-2 资源生成完成"
|
||||
);
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
GenerateBabyObjectMatchAssetsResponse {
|
||||
assets,
|
||||
visual_package,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_item_names(item_names: Vec<String>) -> Result<Vec<String>, AppError> {
|
||||
let normalized = item_names
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if normalized.len() != 2 || normalized.iter().any(|value| value.is_empty()) {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "edutainment-baby-object",
|
||||
"message": "请填写两个物品名称。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn build_baby_object_match_item_prompt(item_name: &str) -> String {
|
||||
format!(
|
||||
"为儿童动作 Demo 玩法“宝贝识物”生成物品素材。关键词:{item_name}。\n\
|
||||
风格必须与寓教于乐板块统一:明亮、温暖、卡通绘本质感,适合 4-8 岁儿童,物体边缘清晰,色彩干净,能自然放在草地舞台插画中。\n\
|
||||
画面只允许出现一个围绕关键词“{item_name}”的单一物品主体,不要生成组合物、多个物体、人物、手、篮子、礼物盒或玩法 UI。\n\
|
||||
不要生成背景、场景、氛围渲染、阴影地面、文字、水印、边框或按钮。背景必须是纯白或直接透明,便于服务端做透明抠图。\n\
|
||||
输出为居中完整物品,留少量透明安全边距,最终素材将作为透明 PNG 进入游戏。"
|
||||
)
|
||||
}
|
||||
|
||||
fn build_baby_object_match_negative_prompt() -> &'static str {
|
||||
"背景,场景,草地,天空,房间,光效氛围,多个物品,组合套装,人物,手,篮子,礼物盒,包装文字,标签文字,水印,Logo,UI,按钮,边框,真实照片风,复杂投影"
|
||||
}
|
||||
|
||||
fn with_baby_object_match_image_timeout(mut settings: OpenAiImageSettings) -> OpenAiImageSettings {
|
||||
settings.request_timeout_ms = settings
|
||||
.request_timeout_ms
|
||||
.max(DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS);
|
||||
settings
|
||||
}
|
||||
|
||||
async fn build_baby_object_match_item_assets(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &OpenAiImageSettings,
|
||||
item_names: &[String],
|
||||
) -> Result<Vec<BabyObjectMatchItemAssetPayload>, AppError> {
|
||||
let mut pending = FuturesUnordered::new();
|
||||
|
||||
// 中文注释:两个物品图互不依赖,并发生成可缩短创作等待时间。
|
||||
for (index, item_name) in item_names.iter().cloned().enumerate() {
|
||||
let prompt = build_baby_object_match_item_prompt(item_name.as_str());
|
||||
pending.push(async move {
|
||||
let asset_started_at = Instant::now();
|
||||
tracing::info!(
|
||||
asset_kind = "item",
|
||||
item_index = index + 1,
|
||||
item_name = %item_name,
|
||||
"宝贝识物 image-2 物品资源生成开始"
|
||||
);
|
||||
let generated = create_openai_image_generation(
|
||||
http_client,
|
||||
settings,
|
||||
prompt.as_str(),
|
||||
Some(build_baby_object_match_negative_prompt()),
|
||||
BABY_OBJECT_MATCH_IMAGE_SIZE,
|
||||
1,
|
||||
&[],
|
||||
"宝贝识物物品图片生成失败",
|
||||
)
|
||||
.await?;
|
||||
let generated_image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": "宝贝识物物品图片生成没有返回图片。",
|
||||
}))
|
||||
})?;
|
||||
let image_src = build_transparent_png_data_url(generated_image)?;
|
||||
tracing::info!(
|
||||
asset_kind = "item",
|
||||
item_index = index + 1,
|
||||
item_name = %item_name,
|
||||
elapsed_ms = asset_started_at.elapsed().as_millis() as u64,
|
||||
"宝贝识物 image-2 物品资源生成完成"
|
||||
);
|
||||
|
||||
Ok::<_, AppError>(BabyObjectMatchItemAssetPayload {
|
||||
item_id: format!("baby-object-item-{}", index + 1),
|
||||
item_name,
|
||||
image_src,
|
||||
asset_object_id: None,
|
||||
generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(),
|
||||
prompt,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
let mut assets = Vec::with_capacity(item_names.len());
|
||||
while let Some(result) = pending.next().await {
|
||||
assets.push(result?);
|
||||
}
|
||||
assets.sort_by_key(|asset| asset.item_id.clone());
|
||||
|
||||
Ok(assets)
|
||||
}
|
||||
|
||||
async fn build_baby_object_match_visual_package(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &OpenAiImageSettings,
|
||||
item_names: &[String],
|
||||
) -> Result<BabyObjectMatchVisualPackagePayload, AppError> {
|
||||
let package_started_at = Instant::now();
|
||||
let theme_prompt = build_baby_object_match_visual_theme_prompt(item_names);
|
||||
let kinds = [
|
||||
BabyObjectMatchVisualAssetKind::Background,
|
||||
BabyObjectMatchVisualAssetKind::UiFrame,
|
||||
BabyObjectMatchVisualAssetKind::GiftBox,
|
||||
BabyObjectMatchVisualAssetKind::Basket,
|
||||
BabyObjectMatchVisualAssetKind::SmokePuff,
|
||||
];
|
||||
let mut pending = FuturesUnordered::new();
|
||||
tracing::info!(
|
||||
asset_count = kinds.len(),
|
||||
"宝贝识物 image-2 视觉主题包生成开始"
|
||||
);
|
||||
|
||||
for kind in kinds.iter().copied() {
|
||||
let prompt = build_baby_object_match_visual_asset_prompt(kind, item_names, &theme_prompt);
|
||||
pending.push(async move {
|
||||
let asset_started_at = Instant::now();
|
||||
let asset_kind = kind.contract_kind();
|
||||
tracing::info!(asset_kind, "宝贝识物 image-2 视觉资源生成开始");
|
||||
let generated = create_openai_image_generation(
|
||||
http_client,
|
||||
settings,
|
||||
prompt.as_str(),
|
||||
Some(build_baby_object_match_visual_negative_prompt(kind)),
|
||||
kind.image_size(),
|
||||
1,
|
||||
&[],
|
||||
kind.failure_context(),
|
||||
)
|
||||
.await?;
|
||||
let generated_image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": format!("{}:VectorEngine 没有返回图片。", kind.failure_context()),
|
||||
}))
|
||||
})?;
|
||||
let image_src = if kind.requires_transparency() {
|
||||
build_transparent_png_data_url(generated_image)?
|
||||
} else {
|
||||
build_png_data_url(generated_image)?
|
||||
};
|
||||
tracing::info!(
|
||||
asset_kind,
|
||||
elapsed_ms = asset_started_at.elapsed().as_millis() as u64,
|
||||
"宝贝识物 image-2 视觉资源生成完成"
|
||||
);
|
||||
|
||||
Ok::<_, AppError>(BabyObjectMatchVisualAssetPayload {
|
||||
asset_id: kind.asset_id().to_string(),
|
||||
asset_kind: asset_kind.to_string(),
|
||||
image_src,
|
||||
asset_object_id: None,
|
||||
generation_provider: BABY_OBJECT_MATCH_PROVIDER.to_string(),
|
||||
prompt,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
let mut assets = Vec::with_capacity(kinds.len());
|
||||
while let Some(result) = pending.next().await {
|
||||
assets.push(result?);
|
||||
}
|
||||
assets.sort_by_key(|asset| match asset.asset_kind.as_str() {
|
||||
"background" => 0,
|
||||
"ui-frame" => 1,
|
||||
"gift-box" => 2,
|
||||
"basket" => 3,
|
||||
"smoke-puff" => 4,
|
||||
_ => 5,
|
||||
});
|
||||
tracing::info!(
|
||||
elapsed_ms = package_started_at.elapsed().as_millis() as u64,
|
||||
"宝贝识物 image-2 视觉主题包生成完成"
|
||||
);
|
||||
|
||||
Ok(BabyObjectMatchVisualPackagePayload {
|
||||
theme_prompt,
|
||||
assets,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_baby_object_match_visual_theme_prompt(item_names: &[String]) -> String {
|
||||
let item_a = item_names.first().map(String::as_str).unwrap_or_default();
|
||||
let item_b = item_names.get(1).map(String::as_str).unwrap_or_default();
|
||||
let theme_hint = resolve_baby_object_match_theme_hint(item_names);
|
||||
|
||||
format!(
|
||||
"根据创作者填写的两个物品关键词“{item_a}”和“{item_b}”,为儿童动作 Demo 玩法“宝贝识物”生成一套完整游戏视觉包装。\n\
|
||||
视觉必须保持寓教于乐板块统一的明亮、温暖、卡通绘本插画风,适合 4-8 岁儿童。\n\
|
||||
主题匹配:{theme_hint}\n\
|
||||
所有资源需要围绕这两个关键词形成统一主题,但不能改变物品识别和左右篮子固定规则。"
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_baby_object_match_theme_hint(item_names: &[String]) -> &'static str {
|
||||
let joined = item_names.join(" ").to_lowercase();
|
||||
let fruit_keywords = [
|
||||
"苹果",
|
||||
"橘子",
|
||||
"桔子",
|
||||
"香蕉",
|
||||
"葡萄",
|
||||
"草莓",
|
||||
"西瓜",
|
||||
"梨",
|
||||
"桃",
|
||||
"水果",
|
||||
"apple",
|
||||
"orange",
|
||||
"banana",
|
||||
"grape",
|
||||
"strawberry",
|
||||
"watermelon",
|
||||
"fruit",
|
||||
];
|
||||
let character_keywords = [
|
||||
"佩琪",
|
||||
"小猪佩奇",
|
||||
"小猪佩琪",
|
||||
"奥特曼",
|
||||
"动漫",
|
||||
"动画",
|
||||
"卡通",
|
||||
"玩具",
|
||||
"角色",
|
||||
"公仔",
|
||||
"peppa",
|
||||
"ultraman",
|
||||
"anime",
|
||||
"cartoon",
|
||||
"toy",
|
||||
"doll",
|
||||
"figure",
|
||||
];
|
||||
|
||||
if fruit_keywords
|
||||
.iter()
|
||||
.any(|keyword| joined.contains(keyword))
|
||||
{
|
||||
return "若关键词属于水果,背景环境和 UI 元素匹配果园、自然、阳光、树叶等主题。";
|
||||
}
|
||||
|
||||
if character_keywords
|
||||
.iter()
|
||||
.any(|keyword| joined.contains(keyword))
|
||||
{
|
||||
return "若关键词属于动漫角色、玩具或公仔,背景环境和 UI 元素匹配动漫、玩具房、儿童玩具等主题。";
|
||||
}
|
||||
|
||||
"根据关键词语义自然匹配合适主题,保持儿童寓教于乐插画风。"
|
||||
}
|
||||
|
||||
fn build_baby_object_match_visual_asset_prompt(
|
||||
kind: BabyObjectMatchVisualAssetKind,
|
||||
item_names: &[String],
|
||||
theme_prompt: &str,
|
||||
) -> String {
|
||||
let item_a = item_names.first().map(String::as_str).unwrap_or_default();
|
||||
let item_b = item_names.get(1).map(String::as_str).unwrap_or_default();
|
||||
let base = format!(
|
||||
"{theme_prompt}\n\
|
||||
当前两个关键词:{item_a}、{item_b}。\n\
|
||||
输出必须是无文字、无水印、无 Logo 的游戏美术资源。"
|
||||
);
|
||||
|
||||
match kind {
|
||||
BabyObjectMatchVisualAssetKind::Background => format!(
|
||||
"{base}\n\
|
||||
生成游戏背景环境图。背景需要根据关键词主题匹配环境,例如水果可偏果园自然,动漫角色或玩具可偏动漫玩具主题。\n\
|
||||
保持中间、屏幕中下方和底部左右篮子区域清爽,给放大后的礼物盒、中央物品和左右大篮子预留足够空间,不能画入礼物盒、篮子、物品、人物、文字或操作 UI。"
|
||||
),
|
||||
BabyObjectMatchVisualAssetKind::UiFrame => format!(
|
||||
"{base}\n\
|
||||
生成透明 PNG 的 UI 装饰框资源,用于字幕条和计数器的风格化包装。\n\
|
||||
只生成柔和装饰边框、贴纸感边缘和少量主题点缀,不生成任何文字、数字、按钮、图标说明或大面积背景。背景需要纯白或透明友好,便于抠图。"
|
||||
),
|
||||
BabyObjectMatchVisualAssetKind::GiftBox => format!(
|
||||
"{base}\n\
|
||||
生成透明 PNG 的大号礼物盒资源。礼物盒会在游戏中以约 2 倍视觉尺寸展示,需要主体饱满、轮廓清晰、中心构图、边缘安全留白少,打开动画时可被烟雾遮罩后移除。\n\
|
||||
礼物盒要与关键词主题匹配,可以带主题贴纸感装饰,但不能出现任何文字、人物、手、篮子或待分类物品。背景需要纯白或透明友好,便于抠图。"
|
||||
),
|
||||
BabyObjectMatchVisualAssetKind::Basket => format!(
|
||||
"{base}\n\
|
||||
生成透明 PNG 的大号篮子资源,游戏左右两侧会复用同一个篮子造型并以约 1.5 倍视觉尺寸展示。篮子主体要饱满、开口清晰、可读性高、边缘安全留白少。\n\
|
||||
篮子要与关键词主题匹配,可以有主题色和贴纸感边缘,但不能出现任何文字、礼物盒、人物、手或待分类物品。背景需要纯白或透明友好,便于抠图。"
|
||||
),
|
||||
BabyObjectMatchVisualAssetKind::SmokePuff => format!(
|
||||
"{base}\n\
|
||||
生成透明 PNG 的烟雾弹出特效资源,用于礼物盒打开瞬间。画面只允许出现一团柔和、圆润、儿童绘本风的云朵烟雾和少量主题色星点,不要生成礼物盒、篮子、物品、人物、手、文字或 UI。\n\
|
||||
烟雾需要中心构图、边缘柔和、透明边界干净,适合覆盖礼物盒打开区域并衬托中央物品弹出。背景需要纯白或透明友好,便于抠图。"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_baby_object_match_visual_negative_prompt(
|
||||
kind: BabyObjectMatchVisualAssetKind,
|
||||
) -> &'static str {
|
||||
match kind {
|
||||
BabyObjectMatchVisualAssetKind::Background => {
|
||||
"文字,数字,水印,Logo,按钮,说明面板,人物,手,礼物盒,篮子,中心物品,复杂前景遮挡,真实照片风,暗黑风"
|
||||
}
|
||||
BabyObjectMatchVisualAssetKind::UiFrame => {
|
||||
"文字,数字,水印,Logo,按钮,复杂面板,大面积实心背景,人物,手,礼物盒,篮子,物品主体,真实照片风"
|
||||
}
|
||||
BabyObjectMatchVisualAssetKind::GiftBox => {
|
||||
"文字,数字,水印,Logo,人物,手,篮子,待分类物品,大面积背景,场景,真实照片风"
|
||||
}
|
||||
BabyObjectMatchVisualAssetKind::Basket => {
|
||||
"文字,数字,水印,Logo,人物,手,礼物盒,待分类物品,大面积背景,场景,真实照片风"
|
||||
}
|
||||
BabyObjectMatchVisualAssetKind::SmokePuff => {
|
||||
"文字,数字,水印,Logo,人物,手,礼物盒,篮子,待分类物品,大面积背景,场景,真实照片风,硬边爆炸,火焰"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_transparent_png_data_url(image: DownloadedOpenAiImage) -> Result<String, AppError> {
|
||||
let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?;
|
||||
let transparent_png_bytes =
|
||||
try_apply_background_alpha_to_png(png_bytes.as_slice()).unwrap_or(png_bytes);
|
||||
Ok(format!(
|
||||
"data:image/png;base64,{}",
|
||||
BASE64_STANDARD.encode(transparent_png_bytes)
|
||||
))
|
||||
}
|
||||
|
||||
fn build_png_data_url(image: DownloadedOpenAiImage) -> Result<String, AppError> {
|
||||
let png_bytes = normalize_generated_image_to_png(image.bytes.as_slice())?;
|
||||
Ok(format!(
|
||||
"data:image/png;base64,{}",
|
||||
BASE64_STANDARD.encode(png_bytes)
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_generated_image_to_png(source: &[u8]) -> Result<Vec<u8>, AppError> {
|
||||
let rgba_image = image::load_from_memory(source)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": format!("解析宝贝识物物品图片失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.to_rgba8();
|
||||
let (width, height) = rgba_image.dimensions();
|
||||
let mut encoded = Vec::new();
|
||||
let encoder = PngEncoder::new(&mut encoded);
|
||||
encoder
|
||||
.write_image(rgba_image.as_raw(), width, height, ColorType::Rgba8.into())
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine",
|
||||
"message": format!("转换宝贝识物物品图片为 PNG 失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
fn baby_object_match_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_locks_single_transparent_object_constraints() {
|
||||
let prompt = build_baby_object_match_item_prompt("苹果");
|
||||
|
||||
assert!(prompt.contains("苹果"));
|
||||
assert!(prompt.contains("卡通绘本"));
|
||||
assert!(prompt.contains("单一物品"));
|
||||
assert!(prompt.contains("不要生成背景"));
|
||||
assert!(prompt.contains("透明 PNG"));
|
||||
assert!(prompt.contains("纯白或直接透明"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_theme_prompt_maps_fruit_keywords_to_nature_theme() {
|
||||
let names = vec!["苹果".to_string(), "橘子".to_string()];
|
||||
let prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
|
||||
|
||||
assert!(prompt.contains("寓教于乐"));
|
||||
assert!(prompt.contains("卡通绘本"));
|
||||
assert!(prompt.contains("果园"));
|
||||
assert!(prompt.contains("自然"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_theme_prompt_maps_character_keywords_to_toy_theme() {
|
||||
let names = vec!["小猪佩琪".to_string(), "奥特曼".to_string()];
|
||||
let prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
|
||||
|
||||
assert!(prompt.contains("寓教于乐"));
|
||||
assert!(prompt.contains("动漫"));
|
||||
assert!(prompt.contains("玩具"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_asset_prompt_keeps_background_clear_for_playfield() {
|
||||
let names = vec!["苹果".to_string(), "香蕉".to_string()];
|
||||
let theme_prompt = build_baby_object_match_visual_theme_prompt(names.as_slice());
|
||||
let prompt = build_baby_object_match_visual_asset_prompt(
|
||||
BabyObjectMatchVisualAssetKind::Background,
|
||||
names.as_slice(),
|
||||
theme_prompt.as_str(),
|
||||
);
|
||||
|
||||
assert!(prompt.contains("背景环境图"));
|
||||
assert!(prompt.contains("中间"));
|
||||
assert!(prompt.contains("屏幕中下方"));
|
||||
assert!(prompt.contains("无文字"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_item_names_requires_two_non_empty_names() {
|
||||
let names = normalize_item_names(vec![" 苹果 ".to_string(), "香蕉".to_string()])
|
||||
.expect("two names should be valid");
|
||||
|
||||
assert_eq!(names, vec!["苹果".to_string(), "香蕉".to_string()]);
|
||||
assert!(normalize_item_names(vec!["苹果".to_string()]).is_err());
|
||||
assert!(normalize_item_names(vec!["苹果".to_string(), " ".to_string()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baby_object_match_image_timeout_keeps_long_generation_alive() {
|
||||
let settings = with_baby_object_match_image_timeout(OpenAiImageSettings {
|
||||
base_url: "https://vector.example".to_string(),
|
||||
api_key: "secret".to_string(),
|
||||
request_timeout_ms: 180_000,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
settings.request_timeout_ms,
|
||||
DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_png_to_transparent_png_data_url() {
|
||||
let mut source = Vec::new();
|
||||
let pixels = vec![255u8; 4 * 2 * 2];
|
||||
let encoder = PngEncoder::new(&mut source);
|
||||
encoder
|
||||
.write_image(pixels.as_slice(), 2, 2, ColorType::Rgba8.into())
|
||||
.expect("test png should encode");
|
||||
|
||||
let image_src = build_transparent_png_data_url(DownloadedOpenAiImage {
|
||||
bytes: source,
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
})
|
||||
.expect("test png should normalize");
|
||||
|
||||
assert!(image_src.starts_with("data:image/png;base64,"));
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,7 @@ fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
|
||||
StatusCode::UNAUTHORIZED => ("UNAUTHORIZED", "未授权访问"),
|
||||
StatusCode::FORBIDDEN => ("FORBIDDEN", "禁止访问"),
|
||||
StatusCode::NOT_FOUND => ("NOT_FOUND", "资源不存在"),
|
||||
StatusCode::GONE => ("GONE", "资源已失效"),
|
||||
StatusCode::NOT_IMPLEMENTED => ("NOT_IMPLEMENTED", "功能暂未实现"),
|
||||
StatusCode::CONFLICT => ("CONFLICT", "请求冲突"),
|
||||
StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"),
|
||||
|
||||
@@ -13,6 +13,7 @@ mod auth_payload;
|
||||
mod auth_public_user;
|
||||
mod auth_session;
|
||||
mod auth_sessions;
|
||||
mod bark_battle;
|
||||
mod big_fish;
|
||||
mod big_fish_agent_turn;
|
||||
mod big_fish_draft_compiler;
|
||||
@@ -34,6 +35,8 @@ mod custom_world_asset_prompts;
|
||||
mod custom_world_foundation_draft;
|
||||
mod custom_world_result_prompts;
|
||||
mod custom_world_rpg_draft_prompts;
|
||||
mod edutainment_baby_drawing;
|
||||
mod edutainment_baby_object;
|
||||
mod error_middleware;
|
||||
mod generated_image_assets;
|
||||
mod health;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -84,7 +84,9 @@ pub(super) fn map_match3d_anchor_pack_response_for_turn(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_anchor_item_response(anchor: Match3DAnchorItemRecord) -> Match3DAnchorItemResponse {
|
||||
pub(super) fn map_match3d_anchor_item_response(
|
||||
anchor: Match3DAnchorItemRecord,
|
||||
) -> Match3DAnchorItemResponse {
|
||||
Match3DAnchorItemResponse {
|
||||
key: anchor.key,
|
||||
label: anchor.label,
|
||||
@@ -109,7 +111,9 @@ pub(super) fn map_match3d_anchor_item_response_for_collected(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_config_response(config: Match3DCreatorConfigRecord) -> Match3DCreatorConfigResponse {
|
||||
pub(super) fn map_match3d_config_response(
|
||||
config: Match3DCreatorConfigRecord,
|
||||
) -> Match3DCreatorConfigResponse {
|
||||
Match3DCreatorConfigResponse {
|
||||
theme_text: config.theme_text,
|
||||
reference_image_src: config.reference_image_src,
|
||||
@@ -122,7 +126,9 @@ pub(super) fn map_match3d_config_response(config: Match3DCreatorConfigRecord) ->
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_draft_response(draft: Match3DResultDraftRecord) -> Match3DResultDraftResponse {
|
||||
pub(super) fn map_match3d_draft_response(
|
||||
draft: Match3DResultDraftRecord,
|
||||
) -> Match3DResultDraftResponse {
|
||||
Match3DResultDraftResponse {
|
||||
profile_id: draft.profile_id,
|
||||
game_name: draft.game_name,
|
||||
@@ -280,7 +286,9 @@ pub(super) fn find_match3d_generated_background_asset(
|
||||
.find_map(|asset| asset.background_asset.clone())
|
||||
}
|
||||
|
||||
pub(super) fn resolve_match3d_default_cover_image_src(assets: &[Match3DGeneratedItemAsset]) -> Option<String> {
|
||||
pub(super) fn resolve_match3d_default_cover_image_src(
|
||||
assets: &[Match3DGeneratedItemAsset],
|
||||
) -> Option<String> {
|
||||
find_match3d_generated_background_asset(assets).and_then(|asset| {
|
||||
asset
|
||||
.container_image_src
|
||||
@@ -335,7 +343,27 @@ pub(super) fn apply_match3d_background_asset_to_agent_draft(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_message_response(message: Match3DAgentMessageRecord) -> Match3DAgentMessageResponse {
|
||||
pub(super) fn build_match3d_work_profile_record_with_assets(
|
||||
mut item: Match3DWorkProfileRecord,
|
||||
assets: &[Match3DGeneratedItemAsset],
|
||||
) -> Match3DWorkProfileRecord {
|
||||
item.generated_item_assets_json = serialize_match3d_generated_item_assets(assets);
|
||||
if let Some(background_asset) = find_match3d_generated_background_asset(assets) {
|
||||
item.cover_image_src = item.cover_image_src.or_else(|| {
|
||||
background_asset
|
||||
.container_image_src
|
||||
.clone()
|
||||
.or(background_asset.container_image_object_key.clone())
|
||||
.or(background_asset.image_src.clone())
|
||||
.or(background_asset.image_object_key.clone())
|
||||
});
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_message_response(
|
||||
message: Match3DAgentMessageRecord,
|
||||
) -> Match3DAgentMessageResponse {
|
||||
Match3DAgentMessageResponse {
|
||||
id: message.message_id,
|
||||
role: message.role,
|
||||
@@ -345,7 +373,9 @@ pub(super) fn map_match3d_message_response(message: Match3DAgentMessageRecord) -
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_work_summary_response(item: Match3DWorkProfileRecord) -> Match3DWorkSummaryResponse {
|
||||
pub(super) fn map_match3d_work_summary_response(
|
||||
item: Match3DWorkProfileRecord,
|
||||
) -> Match3DWorkSummaryResponse {
|
||||
let generated_item_asset_json =
|
||||
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
|
||||
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
|
||||
@@ -413,7 +443,9 @@ pub(super) fn require_match3d_background_music_title(
|
||||
Ok(title)
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_work_profile_response(item: Match3DWorkProfileRecord) -> Match3DWorkProfileResponse {
|
||||
pub(super) fn map_match3d_work_profile_response(
|
||||
item: Match3DWorkProfileRecord,
|
||||
) -> Match3DWorkProfileResponse {
|
||||
Match3DWorkProfileResponse {
|
||||
summary: map_match3d_work_summary_response(item),
|
||||
}
|
||||
@@ -450,7 +482,9 @@ pub(super) fn map_match3d_run_response(run: Match3DRunRecord) -> Match3DRunSnaps
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_item_response(item: Match3DItemSnapshotRecord) -> Match3DItemSnapshotResponse {
|
||||
pub(super) fn map_match3d_item_response(
|
||||
item: Match3DItemSnapshotRecord,
|
||||
) -> Match3DItemSnapshotResponse {
|
||||
Match3DItemSnapshotResponse {
|
||||
item_instance_id: item.item_instance_id,
|
||||
item_type_id: item.item_type_id,
|
||||
@@ -465,7 +499,9 @@ pub(super) fn map_match3d_item_response(item: Match3DItemSnapshotRecord) -> Matc
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_tray_slot_response(slot: Match3DTraySlotRecord) -> Match3DTraySlotResponse {
|
||||
pub(super) fn map_match3d_tray_slot_response(
|
||||
slot: Match3DTraySlotRecord,
|
||||
) -> Match3DTraySlotResponse {
|
||||
Match3DTraySlotResponse {
|
||||
slot_index: slot.slot_index,
|
||||
item_instance_id: slot.item_instance_id,
|
||||
@@ -487,4 +523,3 @@ pub(super) fn map_match3d_click_confirmation_response(
|
||||
run: map_match3d_run_response(confirmation.run),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,9 @@ pub(super) fn normalize_match3d_tag(value: &str) -> String {
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
pub(super) fn normalize_match3d_tag_candidates<S>(candidates: impl IntoIterator<Item = S>) -> Vec<String>
|
||||
pub(super) fn normalize_match3d_tag_candidates<S>(
|
||||
candidates: impl IntoIterator<Item = S>,
|
||||
) -> Vec<String>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
@@ -169,4 +171,3 @@ pub(super) fn parse_match3d_tags_from_text(raw: &str) -> Vec<String> {
|
||||
pub(super) fn fallback_match3d_work_tags(game_name: &str, theme_text: &str) -> Vec<String> {
|
||||
normalize_match3d_tag_candidates([theme_text, game_name, "抓大鹅", "经典消除", "2D素材"])
|
||||
}
|
||||
|
||||
|
||||
57
server-rs/crates/api-server/src/modules/bark_battle.rs
Normal file
57
server-rs/crates/api-server/src/modules/bark_battle.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
bark_battle::{
|
||||
create_bark_battle_draft, finish_bark_battle_run, get_bark_battle_run,
|
||||
get_bark_battle_runtime_config, publish_bark_battle_work, start_bark_battle_run,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/creation/bark-battle/drafts",
|
||||
post(create_bark_battle_draft).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/bark-battle/works/publish",
|
||||
post(publish_bark_battle_work).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/works/{work_id}/config",
|
||||
get(get_bark_battle_runtime_config).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/works/{work_id}/runs",
|
||||
post(start_bark_battle_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/runs/{run_id}",
|
||||
get(get_bark_battle_run).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/bark-battle/runs/{run_id}/finish",
|
||||
post(finish_bark_battle_run)
|
||||
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
|
||||
)
|
||||
}
|
||||
27
server-rs/crates/api-server/src/modules/edutainment.rs
Normal file
27
server-rs/crates/api-server/src/modules/edutainment.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use axum::{Router, extract::DefaultBodyLimit, middleware, routing::post};
|
||||
|
||||
use crate::{
|
||||
auth::require_bearer_auth, edutainment_baby_drawing::create_baby_love_drawing_magic,
|
||||
edutainment_baby_object::generate_baby_object_match_assets, state::AppState,
|
||||
};
|
||||
|
||||
const BABY_LOVE_DRAWING_MAGIC_BODY_LIMIT_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/creation/edutainment/baby-object-match/assets",
|
||||
post(generate_baby_object_match_assets).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/edutainment/baby-love-drawing/magic",
|
||||
post(create_baby_love_drawing_magic)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
BABY_LOVE_DRAWING_MAGIC_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
|
||||
)
|
||||
}
|
||||
@@ -8,12 +8,13 @@ use crate::{
|
||||
match3d::{
|
||||
click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session,
|
||||
delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up,
|
||||
generate_match3d_background_image_for_work, generate_match3d_cover_image,
|
||||
generate_match3d_item_assets_for_work, generate_match3d_work_tags,
|
||||
get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works,
|
||||
list_match3d_gallery, persist_match3d_generated_model, publish_match3d_work,
|
||||
put_match3d_audio_assets, put_match3d_work, restart_match3d_run, start_match3d_run,
|
||||
stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message,
|
||||
generate_match3d_background_image_for_work, generate_match3d_container_image_for_work,
|
||||
generate_match3d_cover_image, generate_match3d_item_assets_for_work,
|
||||
generate_match3d_work_tags, get_match3d_agent_session, get_match3d_run,
|
||||
get_match3d_work_detail, get_match3d_works, list_match3d_gallery,
|
||||
persist_match3d_generated_model, publish_match3d_work, put_match3d_audio_assets,
|
||||
put_match3d_work, restart_match3d_run, start_match3d_run, stop_match3d_run,
|
||||
stream_match3d_agent_message, submit_match3d_agent_message,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -107,6 +108,12 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/match3d/works/{profile_id}/container-image",
|
||||
post(generate_match3d_container_image_for_work).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/match3d/works/{profile_id}/item-assets",
|
||||
post(generate_match3d_item_assets_for_work).route_layer(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
pub mod admin;
|
||||
pub mod assets;
|
||||
pub mod auth;
|
||||
pub mod bark_battle;
|
||||
pub mod big_fish;
|
||||
pub mod custom_world;
|
||||
pub mod edutainment;
|
||||
pub mod health;
|
||||
pub mod internal;
|
||||
pub mod match3d;
|
||||
|
||||
@@ -10,10 +10,11 @@ use crate::{
|
||||
delete_runtime_browse_history, get_runtime_browse_history, post_runtime_browse_history,
|
||||
},
|
||||
runtime_profile::{
|
||||
claim_profile_task_reward, create_profile_recharge_order, get_profile_analytics_metric,
|
||||
get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center,
|
||||
get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger,
|
||||
redeem_profile_referral_invite_code, redeem_profile_reward_code, submit_profile_feedback,
|
||||
claim_profile_task_reward, confirm_wechat_profile_recharge_order,
|
||||
create_profile_recharge_order, get_profile_analytics_metric, get_profile_dashboard,
|
||||
get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
|
||||
get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code,
|
||||
redeem_profile_reward_code, submit_profile_feedback,
|
||||
},
|
||||
runtime_save::{list_profile_save_archives, resume_profile_save_archive},
|
||||
state::AppState,
|
||||
@@ -66,6 +67,12 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/recharge/orders/{order_id}/wechat/confirm",
|
||||
post(confirm_wechat_profile_recharge_order).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/profile/feedback",
|
||||
post(submit_profile_feedback)
|
||||
|
||||
@@ -32,6 +32,13 @@ pub(crate) struct DownloadedOpenAiImage {
|
||||
pub extension: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct OpenAiReferenceImage {
|
||||
pub bytes: Vec<u8>,
|
||||
pub mime_type: String,
|
||||
pub file_name: String,
|
||||
}
|
||||
|
||||
// 中文注释:RPG、方洞等图片资产统一走 VectorEngine GPT-image-2-all,避免把密钥或供应商协议暴露到前端。
|
||||
pub(crate) fn require_openai_image_settings(
|
||||
state: &AppState,
|
||||
@@ -75,6 +82,8 @@ pub(crate) fn build_openai_image_http_client(
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||
// 中文注释:同一客户端也会承载 `/v1/images/edits` multipart 图生图请求,强制 HTTP/1.1 可避开部分网关对 HTTP/2 multipart 流的兼容问题。
|
||||
.http1_only()
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
@@ -157,6 +166,82 @@ pub(crate) async fn create_openai_image_generation(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn create_openai_image_edit(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &OpenAiImageSettings,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
size: &str,
|
||||
reference_image: &OpenAiReferenceImage,
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let task_id = format!("vector-engine-edit-{}", current_utc_micros());
|
||||
let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone())
|
||||
.file_name(reference_image.file_name.clone())
|
||||
.mime_str(reference_image.mime_type.as_str())
|
||||
.map_err(|error| {
|
||||
map_openai_image_request_error(format!("{failure_context}:构造参考图失败:{error}"))
|
||||
})?;
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("image", image_part)
|
||||
.text("model", GPT_IMAGE_2_MODEL.to_string())
|
||||
.text(
|
||||
"prompt",
|
||||
build_prompt_with_negative(prompt, negative_prompt),
|
||||
)
|
||||
.text("n", "1")
|
||||
.text("size", normalize_image_size(size));
|
||||
let response = http_client
|
||||
.post(vector_engine_images_edit_url(settings).as_str())
|
||||
.header(
|
||||
header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_openai_image_request_error(format!(
|
||||
"{failure_context}:创建图片编辑任务失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let response_status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_openai_image_request_error(format!("{failure_context}:读取图片编辑响应失败:{error}"))
|
||||
})?;
|
||||
if !response_status.is_success() {
|
||||
return Err(map_openai_image_upstream_error(
|
||||
response_status.as_u16(),
|
||||
response_text.as_str(),
|
||||
failure_context,
|
||||
));
|
||||
}
|
||||
|
||||
let response_json = parse_json_payload(response_text.as_str(), failure_context)?;
|
||||
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() {
|
||||
let mut generated = download_images_from_urls(http_client, task_id, image_urls, 1).await?;
|
||||
generated.actual_prompt = actual_prompt;
|
||||
return Ok(generated);
|
||||
}
|
||||
let b64_images = extract_b64_images(&response_json.payload);
|
||||
if !b64_images.is_empty() {
|
||||
let mut generated = images_from_base64(task_id, b64_images, 1);
|
||||
generated.actual_prompt = actual_prompt;
|
||||
return Ok(generated);
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": format!("{failure_context}:VectorEngine 未返回编辑图片"),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_openai_image_request_body(
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
@@ -453,6 +538,14 @@ fn vector_engine_images_generation_url(settings: &OpenAiImageSettings) -> String
|
||||
}
|
||||
}
|
||||
|
||||
fn vector_engine_images_edit_url(settings: &OpenAiImageSettings) -> String {
|
||||
if settings.base_url.ends_with("/v1") {
|
||||
format!("{}/images/edits", settings.base_url)
|
||||
} else {
|
||||
format!("{}/v1/images/edits", settings.base_url)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
@@ -530,6 +623,29 @@ mod tests {
|
||||
assert!(body["prompt"].as_str().unwrap_or_default().contains("避免"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_engine_edit_url_uses_images_edits_endpoint() {
|
||||
let root_settings = OpenAiImageSettings {
|
||||
base_url: "https://vector.example".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
};
|
||||
let v1_settings = OpenAiImageSettings {
|
||||
base_url: "https://vector.example/v1".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
vector_engine_images_edit_url(&root_settings),
|
||||
"https://vector.example/v1/images/edits"
|
||||
);
|
||||
assert_eq!(
|
||||
vector_engine_images_edit_url(&v1_settings),
|
||||
"https://vector.example/v1/images/edits"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b64_json_response_decodes_png_image() {
|
||||
let images = images_from_base64(
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
/// 拼图首关关卡名生成提示词。
|
||||
/// 拼图首关关卡名、作品元信息与 UI 背景提示词生成提示词。
|
||||
///
|
||||
/// 模型只负责把画面描述压缩成可直接展示的中文关卡名;写回草稿和作品卡由业务路由处理。
|
||||
/// 模型只负责把画面描述压缩成可直接展示的中文关卡名、作品描述、作品标签,
|
||||
/// 并产出运行态 UI 背景的正向视觉提示词;写回草稿和作品卡由业务路由处理。
|
||||
pub(crate) const PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT: &str = r#"你是一个中文拼图关卡命名编辑。
|
||||
|
||||
你会收到拼图第一关的画面描述,部分请求还会附带已经生成完成的正式图片。请综合图片内容和画面描述,生成 1 个适合直接展示在游戏关卡卡片上的中文关卡名。
|
||||
你会收到拼图第一关的画面描述,部分请求还会附带已经生成完成的正式图片。请综合图片内容和画面描述,同时生成:
|
||||
- 1 个适合直接展示在游戏关卡卡片上的中文关卡名。
|
||||
- 1 段适合默认填入拼图草稿的中文作品描述。
|
||||
- 6 个适合作品广场检索和相似推荐的中文作品标签。
|
||||
- 1 段用于生成 9:16 拼图运行态 UI 纯背景图的中文正向视觉提示词。
|
||||
|
||||
硬约束:
|
||||
1. 只输出 JSON,不要输出 Markdown、解释或代码块。
|
||||
2. JSON 格式必须是 {"levelName":"关卡名"}。
|
||||
2. JSON 格式必须是 {"levelName":"关卡名","workDescription":"作品描述","workTags":["标签1","标签2","标签3","标签4","标签5","标签6"],"uiBackgroundPrompt":"提示词"}。
|
||||
3. levelName 必须是 2 到 8 个中文字符为主。
|
||||
4. 不要输出“第一关”“画面”“拼图”“作品”等泛词。
|
||||
5. 不要输出标点、引号、编号、英文、emoji 或空白。
|
||||
6. 关卡名要抓住画面主体、场景和氛围,读起来像一个具体可玩的关卡。
|
||||
7. workDescription 必须是 18 到 80 个中文字符,描述这套拼图的画面主题、氛围和游玩期待,不要复述字段名。
|
||||
8. workTags 必须正好 6 个,每个标签 2 到 6 个中文字符为主,覆盖题材、主体、氛围、场景、风格和拼图辨识点。
|
||||
9. uiBackgroundPrompt 必须是 30 到 160 个中文字符,描述题材氛围、环境、色彩、光影和空间层次。
|
||||
10. uiBackgroundPrompt 只写正向画面描述,不要写规则说明,不要出现拼图槽、棋盘、HUD、按钮、文字、水印、数字、拼图碎片、完整拼图图像或教程浮层。
|
||||
"#;
|
||||
|
||||
pub(crate) fn build_puzzle_first_level_name_user_prompt(picture_description: &str) -> String {
|
||||
format!(
|
||||
"画面描述:{picture_description}\n\n请生成第一关关卡名。",
|
||||
"画面描述:{picture_description}\n\n请生成第一关关卡名、作品描述、6 个作品标签和 UI 背景提示词。",
|
||||
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}\n\n请观察随消息附带的正式拼图图片,生成第一关关卡名、作品描述、6 个作品标签和 UI 背景提示词。",
|
||||
picture_description = picture_description.trim(),
|
||||
)
|
||||
}
|
||||
@@ -38,6 +47,9 @@ mod tests {
|
||||
|
||||
assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。"));
|
||||
assert!(prompt.contains("第一关关卡名"));
|
||||
assert!(prompt.contains("作品描述"));
|
||||
assert!(prompt.contains("6 个作品标签"));
|
||||
assert!(prompt.contains("UI 背景提示词"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -46,5 +58,8 @@ mod tests {
|
||||
|
||||
assert!(prompt.contains("画面描述:一只猫在雨夜灯牌下回头。"));
|
||||
assert!(prompt.contains("正式拼图图片"));
|
||||
assert!(prompt.contains("作品描述"));
|
||||
assert!(prompt.contains("6 个作品标签"));
|
||||
assert!(prompt.contains("UI 背景提示词"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,7 +42,9 @@ pub(super) fn map_puzzle_anchor_pack_response(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_anchor_item_response(anchor: PuzzleAnchorItemRecord) -> PuzzleAnchorItemResponse {
|
||||
pub(super) fn map_puzzle_anchor_item_response(
|
||||
anchor: PuzzleAnchorItemRecord,
|
||||
) -> PuzzleAnchorItemResponse {
|
||||
PuzzleAnchorItemResponse {
|
||||
key: anchor.key,
|
||||
label: anchor.label,
|
||||
@@ -51,7 +53,9 @@ pub(super) fn map_puzzle_anchor_item_response(anchor: PuzzleAnchorItemRecord) ->
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_result_draft_response(draft: PuzzleResultDraftRecord) -> PuzzleResultDraftResponse {
|
||||
pub(super) fn map_puzzle_result_draft_response(
|
||||
draft: PuzzleResultDraftRecord,
|
||||
) -> PuzzleResultDraftResponse {
|
||||
PuzzleResultDraftResponse {
|
||||
work_title: draft.work_title,
|
||||
work_description: draft.work_description,
|
||||
@@ -79,7 +83,9 @@ pub(super) fn map_puzzle_result_draft_response(draft: PuzzleResultDraftRecord) -
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_form_draft_response(draft: PuzzleFormDraftRecord) -> PuzzleFormDraftResponse {
|
||||
pub(super) fn map_puzzle_form_draft_response(
|
||||
draft: PuzzleFormDraftRecord,
|
||||
) -> PuzzleFormDraftResponse {
|
||||
PuzzleFormDraftResponse {
|
||||
work_title: draft.work_title,
|
||||
work_description: draft.work_description,
|
||||
@@ -87,7 +93,9 @@ pub(super) fn map_puzzle_form_draft_response(draft: PuzzleFormDraftRecord) -> Pu
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraftLevelResponse {
|
||||
pub(super) fn map_puzzle_draft_level_response(
|
||||
level: PuzzleDraftLevelRecord,
|
||||
) -> PuzzleDraftLevelResponse {
|
||||
PuzzleDraftLevelResponse {
|
||||
level_id: level.level_id,
|
||||
level_name: level.level_name,
|
||||
@@ -111,7 +119,9 @@ pub(super) fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) ->
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_audio_asset_record_response(asset: PuzzleAudioAssetRecord) -> CreationAudioAsset {
|
||||
pub(super) fn map_puzzle_audio_asset_record_response(
|
||||
asset: PuzzleAudioAssetRecord,
|
||||
) -> CreationAudioAsset {
|
||||
CreationAudioAsset {
|
||||
task_id: asset.task_id,
|
||||
provider: asset.provider,
|
||||
@@ -157,7 +167,9 @@ pub(super) fn puzzle_audio_asset_response_module_json(asset: &Option<CreationAud
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
pub(super) fn puzzle_audio_asset_record_module_json(asset: &Option<PuzzleAudioAssetRecord>) -> Value {
|
||||
pub(super) fn puzzle_audio_asset_record_module_json(
|
||||
asset: &Option<PuzzleAudioAssetRecord>,
|
||||
) -> Value {
|
||||
asset
|
||||
.as_ref()
|
||||
.map(|asset| {
|
||||
@@ -415,6 +427,7 @@ pub(super) fn map_puzzle_runtime_level_response(
|
||||
theme_tags: level.theme_tags,
|
||||
cover_image_src: level.cover_image_src,
|
||||
ui_background_image_src: level.ui_background_image_src,
|
||||
ui_background_image_object_key: level.ui_background_image_object_key,
|
||||
background_music: level
|
||||
.background_music
|
||||
.map(map_puzzle_audio_asset_record_response),
|
||||
@@ -526,4 +539,3 @@ pub(super) fn build_puzzle_welcome_text(seed_text: &str) -> String {
|
||||
|
||||
"拼图创作信息已准备好。".to_string()
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,9 @@ pub(super) fn parse_puzzle_tags_from_text(text: &str) -> Vec<String> {
|
||||
normalize_puzzle_tag_candidates(tags.iter().filter_map(Value::as_str))
|
||||
}
|
||||
|
||||
pub(super) fn normalize_puzzle_tag_candidates<S>(candidates: impl IntoIterator<Item = S>) -> Vec<String>
|
||||
pub(super) fn normalize_puzzle_tag_candidates<S>(
|
||||
candidates: impl IntoIterator<Item = S>,
|
||||
) -> Vec<String>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
@@ -108,7 +110,10 @@ pub(super) fn normalize_puzzle_tag(value: &str) -> String {
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
pub(super) fn build_fallback_puzzle_tags(work_title: &str, work_description: &str) -> Vec<&'static str> {
|
||||
pub(super) fn build_fallback_puzzle_tags(
|
||||
work_title: &str,
|
||||
work_description: &str,
|
||||
) -> Vec<&'static str> {
|
||||
let source = format!("{work_title} {work_description}");
|
||||
let mut tags = Vec::new();
|
||||
for (keyword, tag) in [
|
||||
@@ -330,7 +335,11 @@ pub(super) fn ensure_non_empty(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn puzzle_bad_request(request_context: &RequestContext, provider: &str, message: &str) -> Response {
|
||||
pub(super) fn puzzle_bad_request(
|
||||
request_context: &RequestContext,
|
||||
provider: &str,
|
||||
message: &str,
|
||||
) -> Response {
|
||||
puzzle_error_response(
|
||||
request_context,
|
||||
provider,
|
||||
@@ -515,4 +524,3 @@ pub(super) fn puzzle_sse_error_event_message(message: String) -> Event {
|
||||
);
|
||||
Event::default().event("error").data(payload)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ use module_runtime::{
|
||||
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
|
||||
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
|
||||
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
|
||||
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
|
||||
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
|
||||
RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord,
|
||||
RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus,
|
||||
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
|
||||
RuntimeTrackingScopeKind,
|
||||
RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductRecord,
|
||||
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
|
||||
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord,
|
||||
RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle,
|
||||
RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType,
|
||||
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
@@ -25,10 +25,10 @@ use shared_contracts::runtime::{
|
||||
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,
|
||||
ConfirmWechatProfileRechargeOrderResponse, 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,
|
||||
@@ -63,7 +63,10 @@ use crate::{
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
wechat_pay::{build_wechat_payment_request, current_unix_micros, map_wechat_pay_error},
|
||||
wechat_pay::{
|
||||
WechatPayNotifyOrder, build_wechat_payment_request, current_unix_micros,
|
||||
map_wechat_pay_error,
|
||||
},
|
||||
};
|
||||
|
||||
pub async fn get_profile_dashboard(
|
||||
@@ -244,6 +247,106 @@ pub async fn create_profile_recharge_order(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn confirm_wechat_profile_recharge_order(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Path(order_id): Path<String>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user_id = authenticated.claims().user_id().to_string();
|
||||
let (center, order) = state
|
||||
.spacetime_client()
|
||||
.get_profile_recharge_order(order_id.clone())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
if order.user_id != user_id {
|
||||
return Err(runtime_profile_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"),
|
||||
));
|
||||
}
|
||||
if order.payment_channel != PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM {
|
||||
return Err(runtime_profile_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("该充值订单不是微信小程序支付订单"),
|
||||
));
|
||||
}
|
||||
if order.status == RuntimeProfileRechargeOrderStatus::Paid {
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
));
|
||||
}
|
||||
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let wechat_order = state
|
||||
.wechat_pay_client()
|
||||
.query_order_by_out_trade_no(&order.order_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
|
||||
})?;
|
||||
if wechat_order.out_trade_no != order.order_id {
|
||||
return Err(runtime_profile_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("微信支付查单返回的商户订单号与本地订单不一致")
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
));
|
||||
}
|
||||
if wechat_order.trade_state != "SUCCESS" {
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let paid_at_micros = paid_at_micros_from_wechat_order(&wechat_order);
|
||||
let (center, order) = state
|
||||
.spacetime_client()
|
||||
.mark_profile_recharge_order_paid(
|
||||
wechat_order.out_trade_no,
|
||||
paid_at_micros,
|
||||
wechat_order.transaction_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_profile_error_response(
|
||||
&request_context,
|
||||
map_runtime_profile_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ConfirmWechatProfileRechargeOrderResponse {
|
||||
order: build_profile_recharge_order_response(order),
|
||||
center: build_profile_recharge_center_response(center),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn submit_profile_feedback(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -801,6 +904,15 @@ async fn resolve_wechat_identity_for_payment(
|
||||
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付"))
|
||||
}
|
||||
|
||||
fn paid_at_micros_from_wechat_order(order: &WechatPayNotifyOrder) -> i64 {
|
||||
order
|
||||
.success_time
|
||||
.as_deref()
|
||||
.and_then(|value| parse_rfc3339(value).ok())
|
||||
.map(offset_datetime_to_unix_micros)
|
||||
.unwrap_or_else(current_unix_micros)
|
||||
}
|
||||
|
||||
fn build_profile_recharge_center_response(
|
||||
record: RuntimeProfileRechargeCenterRecord,
|
||||
) -> ProfileRechargeCenterResponse {
|
||||
@@ -1260,6 +1372,7 @@ mod tests {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
@@ -1271,6 +1384,20 @@ mod tests {
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let confirm_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/profile/recharge/orders/rcgtest001/wechat/confirm")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(confirm_response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -200,7 +200,9 @@ pub(super) fn map_square_hole_work_profile_response(
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_square_hole_run_response(run: SquareHoleRunRecord) -> SquareHoleRunSnapshotResponse {
|
||||
pub(super) fn map_square_hole_run_response(
|
||||
run: SquareHoleRunRecord,
|
||||
) -> SquareHoleRunSnapshotResponse {
|
||||
SquareHoleRunSnapshotResponse {
|
||||
run_id: run.run_id,
|
||||
profile_id: run.profile_id,
|
||||
@@ -312,4 +314,3 @@ pub(super) fn map_square_hole_feedback_response(
|
||||
message: feedback.message,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -468,33 +468,34 @@ async fn persist_square_hole_generated_asset(
|
||||
})?;
|
||||
let http_client = reqwest::Client::new();
|
||||
let storage_slot = sanitize_square_hole_asset_segment(slot, "slot");
|
||||
let prepared = GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||||
prefix: LegacyAssetPrefix::SquareHoleAssets,
|
||||
path_segments: vec![
|
||||
sanitize_square_hole_asset_segment(session_id, "session"),
|
||||
sanitize_square_hole_asset_segment(profile_id, "profile"),
|
||||
sanitize_square_hole_asset_segment(asset_kind, "asset"),
|
||||
storage_slot.clone(),
|
||||
format!("asset-{generated_at_micros}"),
|
||||
],
|
||||
file_stem: "image".to_string(),
|
||||
image: GeneratedImageAssetDataUrl {
|
||||
format: normalize_generated_image_asset_mime(image.mime_type.as_str()),
|
||||
bytes: image.bytes,
|
||||
},
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: GeneratedImageAssetAdapterMetadata {
|
||||
asset_kind: Some(asset_kind.to_string()),
|
||||
owner_user_id: Some(owner_user_id.to_string()),
|
||||
entity_kind: Some(SQUARE_HOLE_ENTITY_KIND.to_string()),
|
||||
entity_id: Some(profile_id.to_string()),
|
||||
slot: Some(slot.to_string()),
|
||||
provider: Some("openai".to_string()),
|
||||
task_id: Some(task_id.to_string()),
|
||||
},
|
||||
extra_metadata: BTreeMap::from([("profile_id".to_string(), profile_id.to_string())]),
|
||||
})
|
||||
.map_err(map_square_hole_generated_image_asset_error)?;
|
||||
let prepared =
|
||||
GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
|
||||
prefix: LegacyAssetPrefix::SquareHoleAssets,
|
||||
path_segments: vec![
|
||||
sanitize_square_hole_asset_segment(session_id, "session"),
|
||||
sanitize_square_hole_asset_segment(profile_id, "profile"),
|
||||
sanitize_square_hole_asset_segment(asset_kind, "asset"),
|
||||
storage_slot.clone(),
|
||||
format!("asset-{generated_at_micros}"),
|
||||
],
|
||||
file_stem: "image".to_string(),
|
||||
image: GeneratedImageAssetDataUrl {
|
||||
format: normalize_generated_image_asset_mime(image.mime_type.as_str()),
|
||||
bytes: image.bytes,
|
||||
},
|
||||
access: OssObjectAccess::Private,
|
||||
metadata: GeneratedImageAssetAdapterMetadata {
|
||||
asset_kind: Some(asset_kind.to_string()),
|
||||
owner_user_id: Some(owner_user_id.to_string()),
|
||||
entity_kind: Some(SQUARE_HOLE_ENTITY_KIND.to_string()),
|
||||
entity_id: Some(profile_id.to_string()),
|
||||
slot: Some(slot.to_string()),
|
||||
provider: Some("openai".to_string()),
|
||||
task_id: Some(task_id.to_string()),
|
||||
},
|
||||
extra_metadata: BTreeMap::from([("profile_id".to_string(), profile_id.to_string())]),
|
||||
})
|
||||
.map_err(map_square_hole_generated_image_asset_error)?;
|
||||
let persisted_mime_type = prepared.format.mime_type.clone();
|
||||
let put_result = oss_client
|
||||
.put_object(&http_client, prepared.request)
|
||||
@@ -683,4 +684,3 @@ fn build_square_hole_hole_prompt(
|
||||
fn build_square_hole_negative_prompt() -> String {
|
||||
"文字、水印、复杂 UI、真实人物、恐怖血腥、低清晰度、过度模糊、主体被裁切、多个主体".to_string()
|
||||
}
|
||||
|
||||
|
||||
@@ -173,21 +173,13 @@ pub async fn create_visual_novel_background_music_task(
|
||||
}
|
||||
|
||||
pub async fn create_background_music_task(
|
||||
State(state): State<AppState>,
|
||||
State(_state): State<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
payload: Result<Json<creation_audio::CreateBackgroundMusicRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
create_background_music_task_response(
|
||||
&state,
|
||||
payload.prompt,
|
||||
payload.title,
|
||||
payload.tags,
|
||||
payload.model,
|
||||
)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
let _ = parse_json_payload(&request_context, payload)?;
|
||||
Err(creation_audio_generation_disabled_error()
|
||||
.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub async fn create_visual_novel_sound_effect_task(
|
||||
@@ -241,15 +233,13 @@ pub async fn create_visual_novel_sound_effect_task(
|
||||
}
|
||||
|
||||
pub async fn create_sound_effect_task(
|
||||
State(state): State<AppState>,
|
||||
State(_state): State<AppState>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
payload: Result<Json<creation_audio::CreateSoundEffectRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = parse_json_payload(&request_context, payload)?;
|
||||
create_sound_effect_task_response(&state, payload.prompt, payload.duration, payload.seed)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
let _ = parse_json_payload(&request_context, payload)?;
|
||||
Err(creation_audio_generation_disabled_error()
|
||||
.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_sound_effect_asset_for_creation(
|
||||
@@ -516,45 +506,27 @@ pub async fn publish_visual_novel_sound_effect_asset(
|
||||
}
|
||||
|
||||
pub async fn publish_background_music_asset(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
State(_state): State<AppState>,
|
||||
Path(_task_id): Path<String>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
axum::extract::Extension(_authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = parse_json_payload(&request_context, payload)?.0;
|
||||
let target = build_creation_audio_target(payload)?;
|
||||
publish_generated_audio_asset(
|
||||
&state,
|
||||
authenticated.claims().user_id(),
|
||||
task_id,
|
||||
AudioAssetSlot::BackgroundMusic,
|
||||
target,
|
||||
)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
Err(creation_audio_generation_disabled_error_for_target(payload)
|
||||
.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
pub async fn publish_sound_effect_asset(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
State(_state): State<AppState>,
|
||||
Path(_task_id): Path<String>,
|
||||
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
|
||||
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
axum::extract::Extension(_authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let payload = parse_json_payload(&request_context, payload)?.0;
|
||||
let target = build_creation_audio_target(payload)?;
|
||||
publish_generated_audio_asset(
|
||||
&state,
|
||||
authenticated.claims().user_id(),
|
||||
task_id,
|
||||
AudioAssetSlot::SoundEffect,
|
||||
target,
|
||||
)
|
||||
.await
|
||||
.map(|payload| json_success_body(Some(&request_context), payload))
|
||||
.map_err(|error| error.into_response_with_context(Some(&request_context)))
|
||||
Err(creation_audio_generation_disabled_error_for_target(payload)
|
||||
.into_response_with_context(Some(&request_context)))
|
||||
}
|
||||
|
||||
async fn publish_generated_audio_asset(
|
||||
@@ -888,33 +860,21 @@ fn build_visual_novel_audio_target(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_creation_audio_target(
|
||||
fn creation_audio_generation_disabled_error() -> AppError {
|
||||
AppError::from_status(StatusCode::GONE).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": "拼图与抓大鹅音频生成入口已临时关闭",
|
||||
}))
|
||||
}
|
||||
|
||||
fn creation_audio_generation_disabled_error_for_target(
|
||||
payload: creation_audio::PublishGeneratedAudioAssetRequest,
|
||||
) -> Result<AudioAssetBindingTarget, AppError> {
|
||||
let entity_kind = normalize_limited_text(&payload.entity_kind, "entityKind", 80)?;
|
||||
let entity_id = normalize_limited_text(&payload.entity_id, "entityId", 160)?;
|
||||
let slot = normalize_limited_text(&payload.slot, "slot", 80)?;
|
||||
let asset_kind = normalize_limited_text(&payload.asset_kind, "assetKind", 80)?;
|
||||
let storage_prefix = match payload.storage_prefix {
|
||||
Some(creation_audio::CreationAudioStoragePrefix::PuzzleAssets) => {
|
||||
LegacyAssetPrefix::PuzzleAssets
|
||||
}
|
||||
Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets) => {
|
||||
LegacyAssetPrefix::Match3DAssets
|
||||
}
|
||||
Some(creation_audio::CreationAudioStoragePrefix::CustomWorldScenes) | None => {
|
||||
LegacyAssetPrefix::CustomWorldScenes
|
||||
}
|
||||
};
|
||||
Ok(AudioAssetBindingTarget {
|
||||
storage_scope: entity_kind.clone(),
|
||||
entity_kind,
|
||||
entity_id,
|
||||
slot,
|
||||
asset_kind,
|
||||
profile_id: normalize_optional_text(payload.profile_id.as_deref()),
|
||||
storage_prefix,
|
||||
})
|
||||
) -> AppError {
|
||||
creation_audio_generation_disabled_error().with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": "拼图与抓大鹅音频生成入口已临时关闭",
|
||||
"entityKind": payload.entity_kind.trim(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn require_vector_engine_audio_settings(
|
||||
@@ -1473,6 +1433,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_creation_audio_targets_return_gone() {
|
||||
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
|
||||
entity_kind: "puzzle_work".to_string(),
|
||||
entity_id: "puzzle-profile-1".to_string(),
|
||||
slot: "background_music".to_string(),
|
||||
asset_kind: "puzzle_background_music".to_string(),
|
||||
profile_id: Some("puzzle-profile-1".to_string()),
|
||||
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::PuzzleAssets),
|
||||
};
|
||||
let error = creation_audio_generation_disabled_error_for_target(payload);
|
||||
assert_eq!(error.status_code(), StatusCode::GONE);
|
||||
|
||||
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
|
||||
entity_kind: "match3d_work".to_string(),
|
||||
entity_id: "match3d-profile-1".to_string(),
|
||||
slot: "background_music".to_string(),
|
||||
asset_kind: "match3d_background_music".to_string(),
|
||||
profile_id: Some("match3d-profile-1".to_string()),
|
||||
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets),
|
||||
};
|
||||
let error = creation_audio_generation_disabled_error_for_target(payload);
|
||||
assert_eq!(error.status_code(), StatusCode::GONE);
|
||||
|
||||
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
|
||||
entity_kind: "match3d_item".to_string(),
|
||||
entity_id: "match3d-item-1".to_string(),
|
||||
slot: "click_sound".to_string(),
|
||||
asset_kind: "match3d_click_sound".to_string(),
|
||||
profile_id: Some("match3d-profile-1".to_string()),
|
||||
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::Match3DAssets),
|
||||
};
|
||||
let error = creation_audio_generation_disabled_error_for_target(payload);
|
||||
assert_eq!(error.status_code(), StatusCode::GONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_prompt_length() {
|
||||
let prompt = "声".repeat(VIDU_PROMPT_MAX_CHARS + 1);
|
||||
|
||||
@@ -18,6 +18,7 @@ use shared_contracts::runtime::WechatMiniProgramPayParamsResponse;
|
||||
use shared_kernel::offset_datetime_to_unix_micros;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, warn};
|
||||
use url::Url;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
@@ -25,7 +26,17 @@ const WECHAT_PAY_PROVIDER_MOCK: &str = "mock";
|
||||
const WECHAT_PAY_PROVIDER_REAL: &str = "real";
|
||||
const WECHAT_PAY_BODY_SIGNATURE_METHOD: &str = "WECHATPAY2-SHA256-RSA2048";
|
||||
const WECHAT_PAY_PAY_SIGN_TYPE: &str = "RSA";
|
||||
const WECHAT_PAY_NOTIFY_SUCCESS: &str = "<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>";
|
||||
const WECHAT_PAY_ACCEPT_HEADER: &str = "application/json";
|
||||
const WECHAT_PAY_CONTENT_TYPE_HEADER: &str = "application/json";
|
||||
const WECHAT_PAY_USER_AGENT: &str = "Genarrative-WechatPay/1.0";
|
||||
const WECHAT_PAY_SERIAL_HEADER: &str = "Wechatpay-Serial";
|
||||
const WECHAT_PAY_SIGNATURE_TEST_PREFIX: &str = "WECHATPAY/SIGNTEST/";
|
||||
const WECHAT_PAY_APP_ID_MAX_CHARS: usize = 32;
|
||||
const WECHAT_PAY_MCH_ID_MAX_CHARS: usize = 32;
|
||||
const WECHAT_PAY_DESCRIPTION_MAX_CHARS: usize = 127;
|
||||
const WECHAT_PAY_OUT_TRADE_NO_MAX_CHARS: usize = 32;
|
||||
const WECHAT_PAY_NOTIFY_URL_MAX_CHARS: usize = 255;
|
||||
const WECHAT_PAY_OPENID_MAX_CHARS: usize = 128;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WechatPayClient {
|
||||
@@ -46,6 +57,7 @@ pub struct RealWechatPayClient {
|
||||
api_v3_key: String,
|
||||
notify_url: String,
|
||||
jsapi_endpoint: String,
|
||||
query_order_endpoint_base: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -73,11 +85,10 @@ pub enum WechatPayError {
|
||||
Upstream(String),
|
||||
Deserialize(String),
|
||||
Crypto(String),
|
||||
InvalidSignature,
|
||||
InvalidSignature(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WechatJsapiOrderRequest<'a> {
|
||||
appid: &'a str,
|
||||
mchid: &'a str,
|
||||
@@ -130,6 +141,16 @@ struct WechatPayTransactionResource {
|
||||
success_time: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WechatPayQueryOrderResponse {
|
||||
out_trade_no: String,
|
||||
#[serde(default)]
|
||||
transaction_id: Option<String>,
|
||||
trade_state: String,
|
||||
#[serde(default)]
|
||||
success_time: Option<String>,
|
||||
}
|
||||
|
||||
impl WechatPayClient {
|
||||
pub fn from_config(config: &crate::config::AppConfig) -> Result<Self, WechatPayError> {
|
||||
if !config.wechat_pay_enabled {
|
||||
@@ -196,10 +217,12 @@ impl WechatPayClient {
|
||||
config.wechat_pay_notify_url.as_deref(),
|
||||
"WECHAT_PAY_NOTIFY_URL",
|
||||
)?;
|
||||
validate_notify_url(¬ify_url, "WECHAT_PAY_NOTIFY_URL")?;
|
||||
let jsapi_endpoint = normalize_required_url(
|
||||
&config.wechat_pay_jsapi_endpoint,
|
||||
"WECHAT_PAY_JSAPI_ENDPOINT",
|
||||
)?;
|
||||
let query_order_endpoint_base = resolve_query_order_endpoint_base(&jsapi_endpoint)?;
|
||||
|
||||
Ok(Self::Real(Arc::new(RealWechatPayClient {
|
||||
client: reqwest::Client::new(),
|
||||
@@ -212,6 +235,7 @@ impl WechatPayClient {
|
||||
api_v3_key,
|
||||
notify_url,
|
||||
jsapi_endpoint,
|
||||
query_order_endpoint_base,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -237,6 +261,22 @@ impl WechatPayClient {
|
||||
Self::Real(client) => client.parse_notify(headers, body),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn query_order_by_out_trade_no(
|
||||
&self,
|
||||
order_id: &str,
|
||||
) -> Result<WechatPayNotifyOrder, WechatPayError> {
|
||||
match self {
|
||||
Self::Disabled => Err(WechatPayError::Disabled),
|
||||
Self::Mock => Ok(WechatPayNotifyOrder {
|
||||
out_trade_no: normalize_out_trade_no(order_id)?,
|
||||
transaction_id: Some(format!("mock-{order_id}")),
|
||||
trade_state: "SUCCESS".to_string(),
|
||||
success_time: Some(OffsetDateTime::now_utc().to_string()),
|
||||
}),
|
||||
Self::Real(client) => client.query_order_by_out_trade_no(order_id).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RealWechatPayClient {
|
||||
@@ -244,6 +284,7 @@ impl RealWechatPayClient {
|
||||
&self,
|
||||
request: WechatMiniProgramOrderRequest,
|
||||
) -> Result<WechatMiniProgramPayParamsResponse, WechatPayError> {
|
||||
validate_jsapi_order_request(self, &request)?;
|
||||
let amount_total = i64::try_from(request.amount_cents)
|
||||
.map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?;
|
||||
let body = serde_json::to_string(&WechatJsapiOrderRequest {
|
||||
@@ -270,18 +311,18 @@ impl RealWechatPayClient {
|
||||
&nonce,
|
||||
&body,
|
||||
)?;
|
||||
let response = self
|
||||
.client
|
||||
.post(&self.jsapi_endpoint)
|
||||
.header("Authorization", authorization)
|
||||
.header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
WechatPayError::RequestFailed(format!("微信支付 JSAPI 下单请求失败:{error}"))
|
||||
})?;
|
||||
let response = with_wechat_pay_jsapi_headers(
|
||||
self.client
|
||||
.post(&self.jsapi_endpoint)
|
||||
.header("Authorization", authorization),
|
||||
&self.platform_serial_no,
|
||||
)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
WechatPayError::RequestFailed(format!("微信支付 JSAPI 下单请求失败:{error}"))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
WechatPayError::Deserialize(format!("微信支付 JSAPI 下单响应读取失败:{error}"))
|
||||
@@ -381,6 +422,58 @@ impl RealWechatPayClient {
|
||||
})
|
||||
}
|
||||
|
||||
async fn query_order_by_out_trade_no(
|
||||
&self,
|
||||
order_id: &str,
|
||||
) -> Result<WechatPayNotifyOrder, WechatPayError> {
|
||||
let order_id = normalize_out_trade_no(order_id)?;
|
||||
let path = format!(
|
||||
"/v3/pay/transactions/out-trade-no/{}?mchid={}",
|
||||
urlencoding::encode(&order_id),
|
||||
urlencoding::encode(&self.mch_id),
|
||||
);
|
||||
let request_url = format!(
|
||||
"{}/{}?mchid={}",
|
||||
self.query_order_endpoint_base.trim_end_matches('/'),
|
||||
urlencoding::encode(&order_id),
|
||||
urlencoding::encode(&self.mch_id),
|
||||
);
|
||||
let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
|
||||
let nonce = create_nonce()?;
|
||||
let authorization = self.build_authorization("GET", &path, ×tamp, &nonce, "")?;
|
||||
let response = with_wechat_pay_json_headers(
|
||||
self.client
|
||||
.get(request_url)
|
||||
.header("Authorization", authorization),
|
||||
&self.platform_serial_no,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| WechatPayError::RequestFailed(format!("微信支付查单请求失败:{error}")))?;
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
WechatPayError::Deserialize(format!("微信支付查单响应读取失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(WechatPayError::Upstream(format!(
|
||||
"微信支付查单失败:HTTP {status},{response_text}"
|
||||
)));
|
||||
}
|
||||
let payload = serde_json::from_str::<WechatPayQueryOrderResponse>(&response_text).map_err(
|
||||
|error| WechatPayError::Deserialize(format!("微信支付查单响应解析失败:{error}")),
|
||||
)?;
|
||||
|
||||
Ok(WechatPayNotifyOrder {
|
||||
out_trade_no: payload.out_trade_no,
|
||||
transaction_id: payload
|
||||
.transaction_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
trade_state: payload.trade_state,
|
||||
success_time: payload.success_time,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_notify_signature(
|
||||
&self,
|
||||
headers: &HeaderMap,
|
||||
@@ -391,25 +484,33 @@ impl RealWechatPayClient {
|
||||
let signature = read_required_header(headers, "Wechatpay-Signature")?;
|
||||
let serial = read_required_header(headers, "Wechatpay-Serial")?;
|
||||
if serial != self.platform_serial_no {
|
||||
return Err(WechatPayError::InvalidSignature);
|
||||
warn!(
|
||||
received_serial = serial,
|
||||
configured_serial = self.platform_serial_no.as_str(),
|
||||
"微信支付通知平台公钥序列号不匹配"
|
||||
);
|
||||
return Err(WechatPayError::InvalidSignature(format!(
|
||||
"微信支付通知平台公钥序列号不匹配:received={serial}"
|
||||
)));
|
||||
}
|
||||
if signature.starts_with(WECHAT_PAY_SIGNATURE_TEST_PREFIX) {
|
||||
warn!("收到微信支付签名探测通知");
|
||||
return Err(WechatPayError::InvalidSignature(
|
||||
"微信支付签名探测通知".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let message = format!(
|
||||
"{}\n{}\n{}\n",
|
||||
timestamp,
|
||||
nonce,
|
||||
String::from_utf8_lossy(body)
|
||||
);
|
||||
let signature_bytes = BASE64_STANDARD
|
||||
.decode(signature)
|
||||
.map_err(|_| WechatPayError::InvalidSignature)?;
|
||||
let message = build_notify_signature_message(timestamp.as_bytes(), nonce.as_bytes(), body);
|
||||
let signature_bytes = BASE64_STANDARD.decode(signature).map_err(|_| {
|
||||
WechatPayError::InvalidSignature("微信支付通知签名 base64 无效".to_string())
|
||||
})?;
|
||||
let public_key = signature::UnparsedPublicKey::new(
|
||||
&signature::RSA_PKCS1_2048_8192_SHA256,
|
||||
&self.platform_public_key_der,
|
||||
);
|
||||
public_key
|
||||
.verify(message.as_bytes(), &signature_bytes)
|
||||
.map_err(|_| WechatPayError::InvalidSignature)
|
||||
.verify(&message, &signature_bytes)
|
||||
.map_err(|_| WechatPayError::InvalidSignature("微信支付通知签名验签失败".to_string()))
|
||||
}
|
||||
|
||||
fn sign_message(&self, message: &str) -> Result<String, WechatPayError> {
|
||||
@@ -431,7 +532,7 @@ pub async fn handle_wechat_pay_notify(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<&'static str, AppError> {
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let notify = state
|
||||
.wechat_pay_client()
|
||||
.parse_notify(&headers, &body)
|
||||
@@ -442,7 +543,7 @@ pub async fn handle_wechat_pay_notify(
|
||||
trade_state = notify.trade_state.as_str(),
|
||||
"收到非成功微信支付通知"
|
||||
);
|
||||
return Ok(WECHAT_PAY_NOTIFY_SUCCESS);
|
||||
return Ok(StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
let paid_at_micros = notify
|
||||
@@ -469,7 +570,7 @@ pub async fn handle_wechat_pay_notify(
|
||||
"微信支付通知已确认订单入账"
|
||||
);
|
||||
|
||||
Ok(WECHAT_PAY_NOTIFY_SUCCESS)
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
||||
@@ -491,9 +592,11 @@ pub fn map_wechat_pay_error(error: WechatPayError) -> AppError {
|
||||
| WechatPayError::Crypto(message) => AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message(message)
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::InvalidSignature => AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("微信支付通知签名无效")
|
||||
.with_details(json!({ "provider": "wechat_pay" })),
|
||||
WechatPayError::InvalidSignature(message) => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED)
|
||||
.with_message("微信支付通知签名无效")
|
||||
.with_details(json!({ "provider": "wechat_pay", "reason": message }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,6 +628,27 @@ fn map_wechat_pay_notify_error(error: WechatPayError) -> AppError {
|
||||
map_wechat_pay_error(error)
|
||||
}
|
||||
|
||||
fn with_wechat_pay_json_headers(
|
||||
builder: reqwest::RequestBuilder,
|
||||
platform_serial_no: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
builder
|
||||
.header(reqwest::header::ACCEPT, WECHAT_PAY_ACCEPT_HEADER)
|
||||
.header(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
WECHAT_PAY_CONTENT_TYPE_HEADER,
|
||||
)
|
||||
.header(reqwest::header::USER_AGENT, WECHAT_PAY_USER_AGENT)
|
||||
.header(WECHAT_PAY_SERIAL_HEADER, platform_serial_no)
|
||||
}
|
||||
|
||||
fn with_wechat_pay_jsapi_headers(
|
||||
builder: reqwest::RequestBuilder,
|
||||
platform_serial_no: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
with_wechat_pay_json_headers(builder, platform_serial_no)
|
||||
}
|
||||
|
||||
fn build_mock_pay_params(order_id: &str) -> WechatMiniProgramPayParamsResponse {
|
||||
let time_stamp = OffsetDateTime::now_utc().unix_timestamp().to_string();
|
||||
let nonce_str = "mock-nonce".to_string();
|
||||
@@ -595,6 +719,122 @@ fn normalize_required_url(value: &str, key: &str) -> Result<String, WechatPayErr
|
||||
)))
|
||||
}
|
||||
|
||||
fn validate_notify_url(value: &str, key: &str) -> Result<(), WechatPayError> {
|
||||
if value.chars().count() > WECHAT_PAY_NOTIFY_URL_MAX_CHARS {
|
||||
return Err(WechatPayError::InvalidConfig(format!(
|
||||
"{key} 不能超过 {WECHAT_PAY_NOTIFY_URL_MAX_CHARS} 字符"
|
||||
)));
|
||||
}
|
||||
if value.contains('?') || value.contains('#') {
|
||||
return Err(WechatPayError::InvalidConfig(format!(
|
||||
"{key} 不能包含 query 或 fragment"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_query_order_endpoint_base(jsapi_endpoint: &str) -> Result<String, WechatPayError> {
|
||||
let url = Url::parse(jsapi_endpoint)
|
||||
.map_err(|_| WechatPayError::InvalidConfig("WECHAT_PAY_JSAPI_ENDPOINT 无效".to_string()))?;
|
||||
let origin = url
|
||||
.origin()
|
||||
.ascii_serialization()
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
Ok(format!("{origin}/v3/pay/transactions/out-trade-no"))
|
||||
}
|
||||
|
||||
fn normalize_out_trade_no(value: &str) -> Result<String, WechatPayError> {
|
||||
let value = value.trim();
|
||||
validate_out_trade_no(value)?;
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn validate_jsapi_order_request(
|
||||
client: &RealWechatPayClient,
|
||||
request: &WechatMiniProgramOrderRequest,
|
||||
) -> Result<(), WechatPayError> {
|
||||
validate_non_empty_max_chars(
|
||||
&client.app_id,
|
||||
WECHAT_PAY_APP_ID_MAX_CHARS,
|
||||
"微信支付 appid",
|
||||
)?;
|
||||
if !client.app_id.starts_with("wx") {
|
||||
return Err(WechatPayError::InvalidConfig(
|
||||
"微信支付 appid 必须使用小程序 AppID".to_string(),
|
||||
));
|
||||
}
|
||||
validate_non_empty_max_chars(
|
||||
&client.mch_id,
|
||||
WECHAT_PAY_MCH_ID_MAX_CHARS,
|
||||
"微信支付 mchid",
|
||||
)?;
|
||||
if !client.mch_id.chars().all(|ch| ch.is_ascii_digit()) {
|
||||
return Err(WechatPayError::InvalidConfig(
|
||||
"微信支付 mchid 必须是数字字符串".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
validate_non_empty_max_chars(
|
||||
&request.description,
|
||||
WECHAT_PAY_DESCRIPTION_MAX_CHARS,
|
||||
"微信支付商品描述",
|
||||
)?;
|
||||
validate_out_trade_no(&request.order_id)?;
|
||||
if request.amount_cents == 0 {
|
||||
return Err(WechatPayError::InvalidRequest(
|
||||
"微信支付金额必须大于 0 分".to_string(),
|
||||
));
|
||||
}
|
||||
validate_non_empty_max_chars(
|
||||
&request.payer_openid,
|
||||
WECHAT_PAY_OPENID_MAX_CHARS,
|
||||
"微信支付 payer.openid",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_non_empty_max_chars(
|
||||
value: &str,
|
||||
max_chars: usize,
|
||||
field_name: &str,
|
||||
) -> Result<(), WechatPayError> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err(WechatPayError::InvalidRequest(format!(
|
||||
"{field_name} 不能为空"
|
||||
)));
|
||||
}
|
||||
if value.chars().count() > max_chars {
|
||||
return Err(WechatPayError::InvalidRequest(format!(
|
||||
"{field_name} 不能超过 {max_chars} 字符"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_out_trade_no(value: &str) -> Result<(), WechatPayError> {
|
||||
validate_non_empty_max_chars(
|
||||
value,
|
||||
WECHAT_PAY_OUT_TRADE_NO_MAX_CHARS,
|
||||
"微信支付 out_trade_no",
|
||||
)?;
|
||||
if value.chars().count() < 6 {
|
||||
return Err(WechatPayError::InvalidRequest(
|
||||
"微信支付 out_trade_no 不能少于 6 字符".to_string(),
|
||||
));
|
||||
}
|
||||
if !value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '|' | '*'))
|
||||
{
|
||||
return Err(WechatPayError::InvalidRequest(
|
||||
"微信支付 out_trade_no 只能包含数字、大小写字母、_、-、|、*".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_private_key_pem(
|
||||
inline_pem: Option<&str>,
|
||||
path: Option<&Path>,
|
||||
@@ -724,7 +964,18 @@ fn read_required_header<'a>(
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or(WechatPayError::InvalidSignature)
|
||||
.ok_or_else(|| WechatPayError::InvalidSignature(format!("微信支付通知缺少 {name} 请求头")))
|
||||
}
|
||||
|
||||
fn build_notify_signature_message(timestamp: &[u8], nonce: &[u8], body: &[u8]) -> Vec<u8> {
|
||||
let mut message = Vec::with_capacity(timestamp.len() + nonce.len() + body.len() + 3);
|
||||
message.extend_from_slice(timestamp);
|
||||
message.push(b'\n');
|
||||
message.extend_from_slice(nonce);
|
||||
message.push(b'\n');
|
||||
message.extend_from_slice(body);
|
||||
message.push(b'\n');
|
||||
message
|
||||
}
|
||||
|
||||
fn hex_sha256(content: &[u8]) -> String {
|
||||
@@ -747,7 +998,7 @@ impl std::fmt::Display for WechatPayError {
|
||||
| Self::Upstream(message)
|
||||
| Self::Deserialize(message)
|
||||
| Self::Crypto(message) => formatter.write_str(message),
|
||||
Self::InvalidSignature => formatter.write_str("微信支付通知签名无效"),
|
||||
Self::InvalidSignature(message) => formatter.write_str(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -768,6 +1019,115 @@ mod tests {
|
||||
assert!(!params.pay_sign.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsapi_order_request_uses_wechat_v3_snake_case_fields() {
|
||||
let body = serde_json::to_value(WechatJsapiOrderRequest {
|
||||
appid: "wx-test-app",
|
||||
mchid: "1900000001",
|
||||
description: "陶泥儿 - 60泥点",
|
||||
out_trade_no: "rcgtest001",
|
||||
notify_url: "https://api.example.com/api/profile/recharge/wechat/notify",
|
||||
amount: WechatJsapiAmount {
|
||||
total: 600,
|
||||
currency: "CNY",
|
||||
},
|
||||
payer: WechatJsapiPayer {
|
||||
openid: "openid-test",
|
||||
},
|
||||
})
|
||||
.expect("JSAPI order request should serialize");
|
||||
|
||||
assert_eq!(body["out_trade_no"], "rcgtest001");
|
||||
assert_eq!(
|
||||
body["notify_url"],
|
||||
"https://api.example.com/api/profile/recharge/wechat/notify"
|
||||
);
|
||||
assert!(body.get("outTradeNo").is_none());
|
||||
assert!(body.get("notifyUrl").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsapi_order_request_rejects_provider_field_limit_violations() {
|
||||
assert!(validate_out_trade_no("abc12").is_err());
|
||||
assert!(validate_out_trade_no("abc123").is_ok());
|
||||
assert!(validate_out_trade_no("abc123_-|*").is_ok());
|
||||
assert!(validate_out_trade_no("abc123中文").is_err());
|
||||
assert!(validate_out_trade_no("a".repeat(33).as_str()).is_err());
|
||||
|
||||
assert!(validate_notify_url("https://api.example.com/pay/notify", "notify").is_ok());
|
||||
assert!(validate_notify_url("https://api.example.com/pay/notify?x=1", "notify").is_err());
|
||||
assert!(validate_notify_url(&format!("https://{}", "a".repeat(248)), "notify").is_err());
|
||||
|
||||
validate_non_empty_max_chars("陶泥儿 - 60泥点", WECHAT_PAY_DESCRIPTION_MAX_CHARS, "描述")
|
||||
.expect("short description should pass");
|
||||
assert!(
|
||||
validate_non_empty_max_chars(
|
||||
&"泥".repeat(128),
|
||||
WECHAT_PAY_DESCRIPTION_MAX_CHARS,
|
||||
"描述"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
validate_non_empty_max_chars("openid-test", WECHAT_PAY_OPENID_MAX_CHARS, "openid")
|
||||
.expect("short openid should pass");
|
||||
assert!(
|
||||
validate_non_empty_max_chars(&"o".repeat(129), WECHAT_PAY_OPENID_MAX_CHARS, "openid")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jsapi_order_request_sets_wechat_required_http_headers() {
|
||||
let request = with_wechat_pay_jsapi_headers(
|
||||
reqwest::Client::new()
|
||||
.post("https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi")
|
||||
.header(
|
||||
"Authorization",
|
||||
"WECHATPAY2-SHA256-RSA2048 mchid=\"1900000001\"",
|
||||
),
|
||||
"PUB_KEY_ID_0119000000012026051400000000000001",
|
||||
)
|
||||
.build()
|
||||
.expect("request should build");
|
||||
|
||||
let headers = request.headers();
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(reqwest::header::ACCEPT)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(WECHAT_PAY_ACCEPT_HEADER)
|
||||
);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(WECHAT_PAY_CONTENT_TYPE_HEADER)
|
||||
);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(reqwest::header::USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(WECHAT_PAY_USER_AGENT)
|
||||
);
|
||||
assert_eq!(
|
||||
headers
|
||||
.get(WECHAT_PAY_SERIAL_HEADER)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("PUB_KEY_ID_0119000000012026051400000000000001")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_signature_message_preserves_raw_body_bytes() {
|
||||
let body = b"{\"message\":\"hello\\r\\nworld\"}\r\n";
|
||||
let message = build_notify_signature_message(b"1778759600", b"nonce-1", body);
|
||||
|
||||
assert_eq!(
|
||||
message,
|
||||
b"1778759600\nnonce-1\n{\"message\":\"hello\\r\\nworld\"}\r\n\n".to_vec()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mock_notify_defaults_success_state() {
|
||||
let notify =
|
||||
|
||||
Reference in New Issue
Block a user