Merge remote-tracking branch 'origin/master' into codex/wechat-mini-program-virtual-payment

# Conflicts:
#	.hermes/shared-memory/decision-log.md
This commit is contained in:
kdletters
2026-05-28 00:43:00 +08:00
57 changed files with 2533 additions and 890 deletions

View File

@@ -2,11 +2,12 @@ use axum::{
Router,
body::Body,
extract::{Extension, FromRef},
http::Request,
http::{Request, StatusCode},
middleware,
response::Response,
routing::{get, post},
};
use serde_json::json;
use tower_http::{
classify::ServerErrorsFailureClass,
trace::{DefaultOnRequest, TraceLayer},
@@ -18,6 +19,7 @@ use crate::{
backpressure::limit_concurrent_requests,
creation_entry_config::require_creation_entry_route_enabled,
error_middleware::normalize_error_response,
http_error::AppError,
modules,
request_context::{RequestContext, attach_request_context, resolve_request_id},
response_headers::propagate_request_id_header,
@@ -164,6 +166,96 @@ pub fn build_router(state: AppState) -> Router {
.with_state(state)
}
pub fn build_spacetime_unavailable_router(message: String) -> Router {
Router::new()
.fallback(spacetime_unavailable_handler)
.layer(Extension(SpacetimeUnavailableState {
message: message.into(),
}))
// 依赖不可用模式不挂业务 state统一返回 503并继续保留 request_id / API 版本 / 耗时响应头。
.layer(middleware::from_fn(normalize_error_response))
.layer(middleware::from_fn(propagate_request_id_header))
.layer(
TraceLayer::new_for_http()
.make_span_with(|request: &Request<Body>| {
let request_id =
resolve_request_id(request).unwrap_or_else(|| "unknown".to_string());
let route = crate::telemetry::observability_route(request.uri().path());
let scheme = crate::telemetry::resolve_request_scheme(request.headers());
let span_name = format!("{} {}", request.method(), route);
info_span!(
"http.request",
otel.kind = "server",
otel.name = %span_name,
otel.status_code = tracing::field::Empty,
http.response.status_code = tracing::field::Empty,
method = %request.method(),
http.request.method = %request.method(),
http.route = %route,
url.scheme = %scheme,
url.path = %request.uri().path(),
request_id = %request_id,
status = tracing::field::Empty,
latency_ms = tracing::field::Empty,
)
})
.on_request(DefaultOnRequest::new().level(Level::INFO))
.on_response(
|response: &axum::response::Response,
latency: std::time::Duration,
span: &Span| {
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
let status = response.status().as_u16();
span.record("status", status);
span.record("http.response.status_code", status);
span.record(
"otel.status_code",
if response.status().is_server_error() {
"ERROR"
} else {
"OK"
},
);
span.record("latency_ms", latency_ms);
},
)
.on_failure(
|failure: ServerErrorsFailureClass,
latency: std::time::Duration,
span: &Span| {
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
error!(
parent: span,
latency_ms,
failure = %failure,
"http request failed"
);
},
),
)
.layer(middleware::from_fn(attach_request_context))
}
#[derive(Clone, Debug)]
struct SpacetimeUnavailableState {
message: std::sync::Arc<str>,
}
async fn spacetime_unavailable_handler(
Extension(state): Extension<SpacetimeUnavailableState>,
Extension(request_context): Extension<RequestContext>,
) -> Response {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("SpacetimeDB 暂不可用api-server 正在等待数据库恢复")
.with_details(json!({
"provider": "spacetimedb",
"reason": "spacetime_startup_unavailable",
"message": state.message.as_ref(),
}))
.into_response_with_context(Some(&request_context))
}
async fn record_api_tracking_after_success(
axum::extract::State(state): axum::extract::State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -368,7 +460,7 @@ mod tests {
use crate::{config::AppConfig, state::AppState};
use super::build_router;
use super::{build_router, build_spacetime_unavailable_router};
const TEST_PASSWORD: &str = "secret123";
const INTERNAL_TEST_SECRET: &str = "test-internal-secret";
@@ -564,6 +656,38 @@ mod tests {
);
}
#[tokio::test]
async fn spacetime_unavailable_router_returns_service_unavailable_for_requests() {
let app = build_spacetime_unavailable_router("SpacetimeDB 启动恢复认证快照超时".to_string());
let response = app
.oneshot(
Request::builder()
.uri("/api/auth/login-options")
.header("x-request-id", "req-spacetime-unavailable")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response
.headers()
.get("x-request-id")
.and_then(|value| value.to_str().ok()),
Some("req-spacetime-unavailable")
);
let body = read_json_response(response).await;
assert_eq!(body["error"]["code"], "SERVICE_UNAVAILABLE");
assert_eq!(
body["error"]["details"]["reason"],
"spacetime_startup_unavailable"
);
assert_eq!(body["error"]["details"]["provider"], "spacetimedb");
}
#[tokio::test]
async fn creation_entry_route_disabled_returns_service_unavailable() {
let state = AppState::new(AppConfig::default()).expect("state should build");

View File

@@ -51,6 +51,7 @@ use crate::{
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
@@ -1015,17 +1016,7 @@ fn resolve_bark_battle_author_display_name_for_record(state: &AppState, value: &
}
fn resolve_bark_battle_author_display_name(state: &AppState, owner_user_id: &str) -> String {
let display_name = if owner_user_id.trim().is_empty() {
None
} else {
state
.auth_user_service()
.get_user_by_id(owner_user_id)
.ok()
.flatten()
.map(|user| user.display_name)
};
normalize_author_display_name(display_name)
resolve_work_author_by_user_id(state, owner_user_id, None, None).display_name
}
fn normalize_author_display_name(display_name: Option<String>) -> String {

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::tracking::record_external_generation_run_after_success;
struct BigFishDashScopeSettings {
base_url: String,
@@ -39,52 +40,99 @@ pub(super) async fn generate_big_fish_formal_asset(
motion_key: Option<&str>,
generated_at_micros: i64,
) -> Result<String, AppError> {
let session = state
.spacetime_client()
.get_big_fish_session(session_id.to_string(), owner_user_id.to_string())
.await
.map_err(map_big_fish_client_error)?;
let draft = session.draft.as_ref().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": "玩法草稿尚未编译,不能生成正式图片。",
}))
})?;
let context = build_big_fish_formal_asset_context(
&session,
draft,
asset_kind,
level,
motion_key,
generated_at_micros,
)?;
let settings = require_big_fish_dashscope_settings(state)?;
let http_client = build_big_fish_dashscope_http_client(&settings)?;
let generated = create_big_fish_text_to_image_generation(
&http_client,
&settings,
context.prompt.as_str(),
context.negative_prompt.as_str(),
context.size.as_str(),
)
.await?;
let downloaded = download_big_fish_remote_image(
&http_client,
generated.image_url.as_str(),
"下载 Big Fish 正式图片失败",
context.apply_transparent_background_post_process,
)
.await?;
let started_at_micros = current_utc_micros();
let request_payload = json!({
"assetKind": asset_kind,
"level": level,
"motionKey": motion_key,
"sessionId": session_id,
"ownerUserId": owner_user_id,
});
let outcome = async {
let session = state
.spacetime_client()
.get_big_fish_session(session_id.to_string(), owner_user_id.to_string())
.await
.map_err(map_big_fish_client_error)?;
let draft = session.draft.as_ref().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": "玩法草稿尚未编译,不能生成正式图片。",
}))
})?;
let context = build_big_fish_formal_asset_context(
&session,
draft,
asset_kind,
level,
motion_key,
generated_at_micros,
)?;
let settings = require_big_fish_dashscope_settings(state)?;
let http_client = build_big_fish_dashscope_http_client(&settings)?;
let generated = create_big_fish_text_to_image_generation(
&http_client,
&settings,
context.prompt.as_str(),
context.negative_prompt.as_str(),
context.size.as_str(),
)
.await?;
let downloaded = download_big_fish_remote_image(
&http_client,
generated.image_url.as_str(),
"下载 Big Fish 正式图片失败",
context.apply_transparent_background_post_process,
)
.await?;
persist_big_fish_formal_asset(
state,
owner_user_id,
&context,
generated,
downloaded,
generated_at_micros,
)
.await
persist_big_fish_formal_asset(
state,
owner_user_id,
&context,
generated,
downloaded,
generated_at_micros,
)
.await
}
.await;
match outcome {
Ok(value) => {
record_external_generation_run_after_success(
state,
"dashscope",
"big_fish_text_to_image",
"大鱼正式图片生成",
request_payload,
started_at_micros,
true,
None,
None,
Some(json!({
"legacyPublicPath": value.clone(),
})),
)
.await;
Ok(value)
}
Err(error) => {
record_external_generation_run_after_success(
state,
"dashscope",
"big_fish_text_to_image",
"大鱼正式图片生成",
request_payload,
started_at_micros,
false,
Some(error.to_string()),
None,
None,
)
.await;
Err(error)
}
}
}
fn build_big_fish_formal_asset_context(
@@ -626,6 +674,10 @@ fn map_big_fish_asset_binding_prepare_error(error: AssetObjectFieldError) -> App
}))
}
fn current_utc_micros() -> i64 {
(time::OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64
}
fn map_big_fish_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",

View File

@@ -11,7 +11,6 @@ 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;
@@ -45,7 +44,6 @@ pub struct AppConfig {
pub refresh_cookie_secure: bool,
pub refresh_cookie_same_site: String,
pub refresh_session_ttl_days: u32,
pub auth_store_path: PathBuf,
pub dev_password_entry_auto_register_enabled: bool,
pub sms_auth_enabled: bool,
pub sms_auth_provider: String,
@@ -188,7 +186,6 @@ impl Default for AppConfig {
refresh_cookie_secure: false,
refresh_cookie_same_site: "Lax".to_string(),
refresh_session_ttl_days: 30,
auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH),
dev_password_entry_auto_register_enabled: false,
sms_auth_enabled: false,
sms_auth_provider: "mock".to_string(),
@@ -441,9 +438,6 @@ impl AppConfig {
config.refresh_session_ttl_days = refresh_session_ttl_days;
}
if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) {
config.auth_store_path = PathBuf::from(auth_store_path);
}
if let Some(enabled) =
read_first_bool_env(&["GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED"])
{

View File

@@ -236,7 +236,6 @@ mod tests {
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::{Value, json};
use std::path::PathBuf;
use time::OffsetDateTime;
use tower::ServiceExt;
@@ -394,12 +393,7 @@ mod tests {
}
async fn build_test_state(label: &str) -> AppState {
let mut config = AppConfig::default();
config.auth_store_path = PathBuf::from(format!(
".codex-temp/api-server-auth-store-creation-doc-{label}.json"
));
let _ = std::fs::remove_file(&config.auth_store_path);
AppState::new(config).expect("state should build")
let _ = label;
AppState::new(AppConfig::default()).expect("state should build")
}
}

View File

@@ -107,9 +107,13 @@ use std::{
use tokio::net::TcpListener;
use tokio::runtime::Builder as TokioRuntimeBuilder;
use tokio::time::timeout;
use tracing::{info, warn};
use tracing::{error, info};
use crate::{app::build_router, config::AppConfig, state::AppState};
use crate::{
app::{build_router, build_spacetime_unavailable_router},
config::AppConfig,
state::{AppState, AppStateInitError},
};
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
const AUTH_STORE_STARTUP_RESTORE_TIMEOUT: Duration = Duration::from_secs(8);
@@ -156,14 +160,21 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
let otel_enabled = config.otel_enabled;
let listener = build_tcp_listener(bind_address, listen_backlog)?;
let state = restore_app_state_for_startup(config)
.await
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
state.puzzle_gallery_cache().spawn_cleanup_task();
if let Some(outbox) = state.tracking_outbox() {
outbox.spawn_worker();
}
let router = build_router(state);
let router = match restore_app_state_for_startup(config).await {
Ok(state) => {
state.puzzle_gallery_cache().spawn_cleanup_task();
if let Some(outbox) = state.tracking_outbox() {
outbox.spawn_worker();
}
build_router(state)
}
Err(AppStateInitError::DependencyUnavailable(message)) => {
build_spacetime_unavailable_router(message)
}
Err(error) => {
return Err(std::io::Error::other(format!("初始化应用状态失败:{error}")));
}
};
info!(
%bind_address,
@@ -192,7 +203,6 @@ fn build_tcp_listener(
async fn restore_app_state_for_startup(
config: AppConfig,
) -> Result<AppState, state::AppStateInitError> {
let fallback_config = config.clone();
match timeout(
AUTH_STORE_STARTUP_RESTORE_TIMEOUT,
AppState::try_restore_auth_store_from_spacetime(config),
@@ -201,11 +211,13 @@ async fn restore_app_state_for_startup(
{
Ok(result) => result,
Err(_) => {
warn!(
error!(
timeout_seconds = AUTH_STORE_STARTUP_RESTORE_TIMEOUT.as_secs(),
"启动恢复认证快照超时,跳过远端恢复并继续启动 api-server"
"启动等待 SpacetimeDB 恢复认证快照超时api-server 将进入依赖不可用模式"
);
AppState::new(fallback_config)
Err(state::AppStateInitError::DependencyUnavailable(
"SpacetimeDB 启动恢复认证快照超时".to_string(),
))
}
}
}

View File

@@ -8,6 +8,7 @@ use platform_image::{
vector_engine_images_generation_url,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
use crate::{
external_api_audit::{
@@ -16,6 +17,7 @@ use crate::{
},
http_error::AppError,
state::AppState,
tracking::record_external_generation_run_after_success,
};
pub(crate) use platform_image::GPT_IMAGE_2_MODEL;
@@ -105,6 +107,14 @@ pub(crate) async fn create_openai_image_generation(
reference_images: &[String],
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let started_at_micros = current_utc_micros();
let request_payload = json!({
"size": size,
"candidateCount": candidate_count,
"promptChars": prompt.chars().count(),
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(),
});
let result = create_vector_engine_image_generation(
http_client,
&settings.provider_settings(),
@@ -116,7 +126,15 @@ pub(crate) async fn create_openai_image_generation(
failure_context,
)
.await;
map_platform_image_result(settings, result).await
map_platform_image_result(
settings,
result,
"image_generation",
failure_context,
request_payload,
started_at_micros,
)
.await
}
pub(crate) async fn create_openai_image_edit(
@@ -128,6 +146,13 @@ pub(crate) async fn create_openai_image_edit(
reference_image: &OpenAiReferenceImage,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let started_at_micros = current_utc_micros();
let request_payload = json!({
"size": size,
"promptChars": prompt.chars().count(),
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": 1,
});
let result = create_vector_engine_image_edit(
http_client,
&settings.provider_settings(),
@@ -138,7 +163,15 @@ pub(crate) async fn create_openai_image_edit(
failure_context,
)
.await;
map_platform_image_result(settings, result).await
map_platform_image_result(
settings,
result,
"image_edit",
failure_context,
request_payload,
started_at_micros,
)
.await
}
pub(crate) async fn create_openai_image_edit_with_references(
@@ -151,6 +184,14 @@ pub(crate) async fn create_openai_image_edit_with_references(
reference_images: &[OpenAiReferenceImage],
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let started_at_micros = current_utc_micros();
let request_payload = json!({
"size": size,
"candidateCount": candidate_count,
"promptChars": prompt.chars().count(),
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(),
});
let result = create_vector_engine_image_edit_with_references(
http_client,
&settings.provider_settings(),
@@ -162,7 +203,15 @@ pub(crate) async fn create_openai_image_edit_with_references(
failure_context,
)
.await;
map_platform_image_result(settings, result).await
map_platform_image_result(
settings,
result,
"image_edit_with_references",
failure_context,
request_payload,
started_at_micros,
)
.await
}
pub(crate) async fn download_remote_image(
@@ -200,19 +249,57 @@ impl OpenAiImageSettings {
}
}
async fn map_platform_image_result<T>(
async fn map_platform_image_result(
settings: &OpenAiImageSettings,
result: Result<T, PlatformImageError>,
) -> Result<T, AppError> {
result: Result<OpenAiGeneratedImages, PlatformImageError>,
operation: &'static str,
failure_context: &str,
request_payload: Value,
started_at_micros: i64,
) -> Result<OpenAiGeneratedImages, AppError> {
match result {
Ok(value) => Ok(value),
Ok(value) => {
if let Some(state) = settings.external_api_audit_state.as_ref() {
record_external_generation_run_after_success(
state,
VECTOR_ENGINE_PROVIDER,
operation,
failure_context,
request_payload,
started_at_micros,
true,
None,
Some(value.task_id.clone()),
Some(json!({
"imageCount": value.images.len(),
"actualPromptChars": value.actual_prompt.as_ref().map(|prompt| prompt.chars().count()),
})),
)
.await;
}
Ok(value)
}
Err(error) => {
if let Some(state) = settings.external_api_audit_state.as_ref() {
record_external_generation_run_after_success(
state,
VECTOR_ENGINE_PROVIDER,
operation,
failure_context,
request_payload,
started_at_micros,
false,
Some(error.message().to_string()),
None,
None,
)
.await;
}
record_openai_image_failure_if_configured(settings, &error).await;
Err(map_platform_image_error(error))
}
}
}
pub(crate) async fn record_openai_image_failure_if_configured(
settings: &OpenAiImageSettings,
error: &PlatformImageError,
@@ -457,3 +544,7 @@ mod tests {
);
}
}
fn current_utc_micros() -> i64 {
(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64
}

View File

@@ -62,8 +62,8 @@ use spacetime_client::{
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput,
PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,

View File

@@ -1,9 +1,8 @@
use std::{
collections::HashMap,
error::Error,
fmt, fs,
fmt,
sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
};
use axum::extract::FromRef;
@@ -36,6 +35,9 @@ use crate::puzzle_gallery_cache::PuzzleGalleryCache;
use crate::tracking_outbox::TrackingOutbox;
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
use crate::wechat_provider::build_wechat_provider;
use crate::work_author::{
ORPHAN_WORK_AUTHOR_DISPLAY_NAME, ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID,
};
const ADMIN_ROLE: &str = "admin";
@@ -300,6 +302,7 @@ pub enum AppStateInitError {
Jwt(JwtError),
RefreshCookie(RefreshCookieError),
AuthStore(String),
DependencyUnavailable(String),
SmsProvider(SmsProviderError),
WechatPay(String),
Oss(OssError),
@@ -308,12 +311,12 @@ pub enum AppStateInitError {
impl AppState {
pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> {
#[cfg(test)]
let auth_store = InMemoryAuthStore::default();
#[cfg(not(test))]
let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone())
.map_err(AppStateInitError::AuthStore)?;
Self::new_with_auth_store(config, auth_store)
Self::new_with_empty_auth_store(config)
}
pub fn new_with_empty_auth_store(config: AppConfig) -> Result<Self, AppStateInitError> {
// 中文注释api-server 不再把本地 auth-store.json 当作用户认证真相源,启动恢复只允许来自 SpacetimeDB。
Self::new_with_auth_store(config, InMemoryAuthStore::default())
}
fn new_with_auth_store(
@@ -361,6 +364,14 @@ impl AppState {
)?)?;
let password_entry_service = PasswordEntryService::new(auth_store.clone());
let auth_user_service = AuthUserService::new(auth_store.clone());
auth_user_service
.ensure_orphan_work_owner_user(
ORPHAN_WORK_OWNER_USER_ID,
ORPHAN_WORK_OWNER_USER_ID,
ORPHAN_WORK_AUTHOR_DISPLAY_NAME,
ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE,
)
.map_err(|error| AppStateInitError::AuthStore(error.to_string()))?;
let phone_auth_service = PhoneAuthService::new(auth_store.clone(), sms_provider);
let wechat_auth_state_service =
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
@@ -549,8 +560,8 @@ impl AppState {
OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000,
)
.map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?;
// 本地 auth_store 是当前认证请求的即时真相源SpacetimeDB 正式认证表用于跨进程恢复。
// 远端数据库挂起或网络异常时,只降级远端恢复能力,不能让已成功的登录/刷新/退出回滚为失败。
// 当前进程内 auth_store 是认证请求的即时工作集SpacetimeDB 正式认证表用于跨进程恢复。
// 远端数据库挂起或网络异常时,只降级后续恢复能力,不能让已成功的登录/刷新/退出回滚为失败。
#[cfg(not(test))]
if let Err(error) = self
.spacetime_client
@@ -577,64 +588,42 @@ impl AppState {
pool_size: config.spacetime_pool_size,
procedure_timeout: config.spacetime_procedure_timeout,
});
let mut candidates = Vec::new();
let mut spacetime_restore_available = false;
let mut restore_errors = Vec::new();
match spacetime_client
.export_auth_store_snapshot_from_tables()
.await
{
Ok(snapshot) => {
spacetime_restore_available = true;
if let Some(candidate) = auth_store_candidate_from_snapshot_record(
snapshot,
AuthStoreRestoreSource::SpacetimeTables,
)? {
candidates.push(candidate);
let state = Self::new_with_auth_store(config, candidate.auth_store)?;
info!(
source = candidate.source.as_str(),
updated_at_micros = candidate.updated_at_micros,
"已恢复认证快照"
);
return Ok(state);
}
}
Err(error) => {
warn!(error = %error, "从 SpacetimeDB 表恢复认证快照失败");
restore_errors.push(error.to_string());
}
}
match spacetime_client.get_auth_store_snapshot().await {
Ok(snapshot) => {
if let Some(candidate) = auth_store_candidate_from_snapshot_record(
snapshot,
AuthStoreRestoreSource::SpacetimeSnapshot,
)? {
candidates.push(candidate);
}
}
Err(error) => {
warn!(error = %error, "从 SpacetimeDB 快照记录恢复认证快照失败");
}
if !spacetime_restore_available {
return Err(AppStateInitError::DependencyUnavailable(format!(
"SpacetimeDB 认证恢复不可用:{}",
restore_errors.join("; ")
)));
}
if let Some(candidate) = auth_store_candidate_from_local_file(&config)? {
candidates.push(candidate);
}
if let Some(candidate) = select_auth_store_restore_candidate(candidates) {
let source = candidate.source;
let should_sync_to_spacetime = source == AuthStoreRestoreSource::LocalFile;
let state = Self::new_with_auth_store(config, candidate.auth_store)?;
info!(
source = source.as_str(),
updated_at_micros = candidate.updated_at_micros,
"已恢复认证快照"
);
if should_sync_to_spacetime {
if let Err(error) = state.sync_auth_store_snapshot_to_spacetime().await {
warn!(
error = %error,
"本地认证快照回写 SpacetimeDB 失败,当前启动继续"
);
}
}
return Ok(state);
}
Self::new(config)
Self::new_with_empty_auth_store(config)
}
pub fn refresh_session_service(&self) -> &RefreshSessionService {
@@ -988,16 +977,12 @@ impl AppState {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AuthStoreRestoreSource {
SpacetimeTables,
SpacetimeSnapshot,
LocalFile,
}
impl AuthStoreRestoreSource {
fn as_str(self) -> &'static str {
match self {
Self::SpacetimeTables => "spacetime_tables",
Self::SpacetimeSnapshot => "spacetime_snapshot",
Self::LocalFile => "local_file",
}
}
}
@@ -1029,57 +1014,14 @@ fn auth_store_candidate_from_snapshot_record(
}))
}
fn auth_store_candidate_from_local_file(
config: &AppConfig,
) -> Result<Option<AuthStoreRestoreCandidate>, AppStateInitError> {
if !config.auth_store_path.is_file() {
return Ok(None);
}
let updated_at_micros = fs::metadata(&config.auth_store_path)
.ok()
.and_then(|metadata| metadata.modified().ok())
.and_then(system_time_to_unix_micros);
let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone())
.map_err(AppStateInitError::AuthStore)?;
Ok(Some(AuthStoreRestoreCandidate {
source: AuthStoreRestoreSource::LocalFile,
updated_at_micros,
auth_store,
}))
}
fn system_time_to_unix_micros(system_time: SystemTime) -> Option<i64> {
let duration = system_time.duration_since(UNIX_EPOCH).ok()?;
i64::try_from(duration.as_micros()).ok()
}
fn select_auth_store_restore_candidate(
candidates: Vec<AuthStoreRestoreCandidate>,
) -> Option<AuthStoreRestoreCandidate> {
candidates.into_iter().max_by_key(|candidate| {
(
candidate.updated_at_micros.unwrap_or(i64::MIN),
auth_store_restore_source_priority(candidate.source),
)
})
}
fn auth_store_restore_source_priority(source: AuthStoreRestoreSource) -> u8 {
match source {
AuthStoreRestoreSource::SpacetimeTables => 3,
AuthStoreRestoreSource::SpacetimeSnapshot => 2,
AuthStoreRestoreSource::LocalFile => 1,
}
}
impl fmt::Display for AppStateInitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Jwt(error) => write!(f, "{error}"),
Self::RefreshCookie(error) => write!(f, "{error}"),
Self::AuthStore(error) | Self::WechatPay(error) => write!(f, "{error}"),
Self::AuthStore(error) | Self::DependencyUnavailable(error) | Self::WechatPay(error) => {
write!(f, "{error}")
}
Self::SmsProvider(error) => write!(f, "{error}"),
Self::Oss(error) => write!(f, "{error}"),
Self::Llm(error) => write!(f, "{error}"),

View File

@@ -53,6 +53,55 @@ struct RouteTrackingSpec {
scope_id: &'static str,
}
pub async fn record_external_generation_run_after_success(
state: &AppState,
provider: &str,
operation: &str,
request_label: &str,
request_payload: Value,
started_at_micros: i64,
success: bool,
failure_reason: Option<String>,
provider_request_id: Option<String>,
result_payload: Option<Value>,
) {
let completed_at_micros = current_utc_micros();
let duration_ms = completed_at_micros.saturating_sub(started_at_micros).max(0) / 1_000;
let mut draft = TrackingEventDraft::new("external_generation_run", "external-generation");
draft.scope_kind = RuntimeTrackingScopeKind::Module;
draft.scope_id = provider.to_string();
draft.metadata = json!({
"runId": format!("external-generation-{}", Uuid::new_v4()),
"provider": provider,
"operation": operation,
"requestLabel": request_label.trim(),
"requestPayload": request_payload,
"status": if success { "succeeded" } else { "failed" },
"success": success,
"failureReason": failure_reason,
"providerRequestId": provider_request_id,
"resultPayload": result_payload,
"startedAtMicros": started_at_micros,
"completedAtMicros": completed_at_micros,
"durationMs": duration_ms,
});
record_tracking_event_after_success(state, &external_generation_request_context(), draft).await;
}
fn current_utc_micros() -> i64 {
(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64
}
fn external_generation_request_context() -> RequestContext {
RequestContext::new(
format!("external-generation-{}", Uuid::new_v4()),
"external generation run".to_string(),
std::time::Duration::ZERO,
false,
)
}
pub async fn record_route_tracking_event_after_success(
state: &AppState,
request_context: &RequestContext,

View File

@@ -1,9 +1,12 @@
use serde_json::json;
use shared_contracts::creation_audio;
use crate::{http_error::AppError, state::AppState};
use crate::{
http_error::AppError, state::AppState, tracking::record_external_generation_run_after_success,
};
use super::{
clock::current_utc_iso_text,
clock::{current_utc_iso_text, current_utc_micros},
errors::{map_platform_audio_error, vector_engine_bad_gateway},
publish::wait_for_generated_audio_asset,
tasks::{create_background_music_task_response, create_sound_effect_task_response},
@@ -18,45 +21,69 @@ pub(crate) async fn generate_sound_effect_asset_for_creation(
seed: Option<u64>,
target: GeneratedCreationAudioTarget,
) -> Result<creation_audio::CreationAudioAsset, AppError> {
let started_at_micros = current_utc_micros();
let normalized_prompt = platform_audio::normalize_limited_text(
&prompt,
"prompt",
platform_audio::VIDU_PROMPT_MAX_CHARS,
)
.map_err(map_platform_audio_error)?;
let task =
create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed).await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::SoundEffect,
target,
)
.await?;
let audio_src = generated
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?;
let request_payload = json!({
"kind": "sound_effect",
"promptChars": normalized_prompt.chars().count(),
"duration": duration,
"seed": seed,
"targetEntityKind": target.entity_kind,
"targetEntityId": target.entity_id,
"targetSlot": target.slot,
"targetAssetKind": target.asset_kind,
});
let outcome = async {
let task =
create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed)
.await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::SoundEffect,
target,
)
.await?;
let audio_src = generated
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?;
Ok(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: None,
updated_at: Some(current_utc_iso_text()),
})
Ok::<_, AppError>(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: None,
updated_at: Some(current_utc_iso_text()),
})
}
.await;
record_creation_audio_generation_run(
state,
"sound_effect",
request_payload,
started_at_micros,
&outcome,
)
.await;
outcome
}
pub(crate) async fn generate_background_music_asset_for_creation(
@@ -68,6 +95,7 @@ pub(crate) async fn generate_background_music_asset_for_creation(
model: Option<String>,
target: GeneratedCreationAudioTarget,
) -> Result<creation_audio::CreationAudioAsset, AppError> {
let started_at_micros = current_utc_micros();
let normalized_prompt = platform_audio::normalize_limited_text_allow_empty(
&prompt,
"prompt",
@@ -80,43 +108,111 @@ pub(crate) async fn generate_background_music_asset_for_creation(
platform_audio::SUNO_TITLE_MAX_CHARS,
)
.map_err(map_platform_audio_error)?;
let task = create_background_music_task_response(
state,
normalized_prompt.clone(),
normalized_title.clone(),
tags,
model,
)
.await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::BackgroundMusic,
target,
)
.await?;
let audio_src = generated
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?;
let request_payload = json!({
"kind": "background_music",
"promptChars": normalized_prompt.chars().count(),
"titleChars": normalized_title.chars().count(),
"hasTags": tags.as_ref().is_some_and(|value| !value.trim().is_empty()),
"model": model,
"targetEntityKind": target.entity_kind,
"targetEntityId": target.entity_id,
"targetSlot": target.slot,
"targetAssetKind": target.asset_kind,
});
let outcome = async {
let task = create_background_music_task_response(
state,
normalized_prompt.clone(),
normalized_title.clone(),
tags,
model,
)
.await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::BackgroundMusic,
target,
)
.await?;
let audio_src = generated
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?;
Ok(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: Some(normalized_title),
updated_at: Some(current_utc_iso_text()),
})
Ok::<_, AppError>(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: Some(normalized_title),
updated_at: Some(current_utc_iso_text()),
})
}
.await;
record_creation_audio_generation_run(
state,
"background_music",
request_payload,
started_at_micros,
&outcome,
)
.await;
outcome
}
async fn record_creation_audio_generation_run(
state: &AppState,
operation: &'static str,
request_payload: serde_json::Value,
started_at_micros: i64,
outcome: &Result<creation_audio::CreationAudioAsset, AppError>,
) {
match outcome {
Ok(asset) => {
record_external_generation_run_after_success(
state,
asset.provider.as_str(),
operation,
"创作音频生成",
request_payload,
started_at_micros,
true,
None,
Some(asset.task_id.clone()),
Some(json!({
"assetObjectId": asset.asset_object_id,
"assetKind": asset.asset_kind,
"hasAudioSrc": !asset.audio_src.trim().is_empty(),
})),
)
.await;
}
Err(error) => {
record_external_generation_run_after_success(
state,
"vector-engine-audio",
operation,
"创作音频生成",
request_payload,
started_at_micros,
false,
Some(error.to_string()),
None,
None,
)
.await;
}
}
}

View File

@@ -20,8 +20,8 @@ use shared_contracts::wooden_fish::{
WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryDetailResponse,
WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse,
WoodenFishSessionResponse, WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest,
WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkspaceCreateRequest,
WoodenFishWorksResponse,
WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorksResponse,
WoodenFishWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
@@ -758,7 +758,7 @@ fn build_wooden_fish_hit_object_prompt(prompt: &str) -> String {
fn build_wooden_fish_background_prompt(prompt: &str) -> String {
format!(
"生成敲木鱼背景,要求主题画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。尺寸竖屏9:16。参考图必须是第一步敲击物抠图完成后的透明图不继承任何绿色底色、绿幕底色或纯绿色画布并要求最终输出完整不透明的背景环境图。中央主体预留区必须保持干净画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;主题元素只允许出现在外围氛围,不得把主题物品画在画面中央,也不要把主题物品作为背景中心装饰。\n主题为:{}",
"生成敲木鱼背景,要求主题画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,只生成竖屏背景环境图,不生成、不描绘、不暗示新木鱼物品本体,也不要出现木槌互动物品。尺寸竖屏9:16。参考图必须是第一步敲击物抠图完成后的透明图不继承任何绿色底色、绿幕底色或纯绿色画布并要求最终输出完整不透明的背景环境图。中央主体预留区必须保持干净中央区域是运行态叠放敲击物的留白区域,画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;主题元素只允许出现在外围氛围,不得把主题物品画在画面中央,也不要把主题物品作为背景中心装饰。\n主题为:{}",
clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT)
)
}
@@ -1228,14 +1228,17 @@ mod tests {
fn wooden_fish_background_prompt_uses_hidden_image2_flow() {
let prompt = build_wooden_fish_background_prompt("苹果");
assert!(prompt.contains(
"生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。"
));
assert!(prompt.contains("只生成竖屏背景环境图"));
assert!(prompt.contains("生成、不描绘、不暗示新木鱼物品本体"));
assert!(prompt.contains("不要出现木槌互动物品"));
assert!(!prompt.contains("木鱼预设在屏幕中央位置"));
assert!(!prompt.contains("木鱼主体周围元素保持干净"));
assert!(prompt.contains("尺寸竖屏9:16"));
assert!(prompt.contains("抠图完成后的透明图"));
assert!(prompt.contains("不继承任何绿色底色"));
assert!(prompt.contains("完整不透明的背景环境图"));
assert!(prompt.contains("中央主体预留区"));
assert!(prompt.contains("中央区域是运行态叠放敲击物的留白区域"));
assert!(prompt.contains("禁止出现主题主体"));
assert!(prompt.contains("苹果"));
assert!(prompt.contains("不得把主题物品画在画面中央"));

View File

@@ -2,6 +2,10 @@ use module_auth::AuthUser;
use crate::state::{AppState, PuzzleApiState};
pub const ORPHAN_WORK_OWNER_USER_ID: &str = "wx-openid-placeholder";
pub const ORPHAN_WORK_AUTHOR_DISPLAY_NAME: &str = "失效作者";
pub const ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE: &str = "SY-00000000";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkAuthorSummary {
pub display_name: String,
@@ -45,21 +49,15 @@ fn resolve_work_author_by_user_id_with_service(
) -> WorkAuthorSummary {
let fallback_display_name =
normalize_optional_text(fallback_display_name).unwrap_or_else(|| "玩家".to_string());
let fallback_public_user_code = normalize_optional_text(fallback_public_user_code);
let _fallback_public_user_code = normalize_optional_text(fallback_public_user_code);
let Some(owner_user_id) = normalize_optional_text(Some(owner_user_id)) else {
return WorkAuthorSummary {
display_name: fallback_display_name,
public_user_code: fallback_public_user_code,
};
return orphan_work_author_summary();
};
match auth_user_service.get_user_by_id(&owner_user_id) {
Ok(Some(user)) => map_auth_user_to_work_author_summary(user, fallback_display_name),
Ok(None) | Err(_) => WorkAuthorSummary {
display_name: fallback_display_name,
public_user_code: fallback_public_user_code,
},
Ok(None) | Err(_) => orphan_work_author_summary(),
}
}
@@ -80,3 +78,65 @@ fn normalize_optional_text(value: Option<&str>) -> Option<String> {
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn orphan_work_author_summary() -> WorkAuthorSummary {
WorkAuthorSummary {
display_name: ORPHAN_WORK_AUTHOR_DISPLAY_NAME.to_string(),
public_user_code: Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string()),
}
}
/// 中文注释:运维回填只处理空作者或认证仓储不可再解析的历史 owner_user_id避免把有效作品误转给占位账号。
pub fn should_rebind_orphan_work_owner(
auth_user_service: &module_auth::AuthUserService,
owner_user_id: &str,
) -> bool {
let Some(owner_user_id) = normalize_optional_text(Some(owner_user_id)) else {
return true;
};
if owner_user_id == ORPHAN_WORK_OWNER_USER_ID {
return false;
}
!matches!(auth_user_service.get_user_by_id(&owner_user_id), Ok(Some(_)))
}
#[cfg(test)]
mod tests {
use module_auth::{AuthUserService, InMemoryAuthStore};
use super::*;
#[test]
fn orphan_work_author_summary_uses_placeholder_account() {
assert_eq!(
orphan_work_author_summary(),
WorkAuthorSummary {
display_name: "失效作者".to_string(),
public_user_code: Some("SY-00000000".to_string()),
}
);
}
#[test]
fn missing_author_resolves_to_placeholder_account() {
let service = AuthUserService::new(InMemoryAuthStore::default());
let author = resolve_work_author_by_user_id_with_service(
&service,
"user_missing",
Some("历史昵称"),
Some("SY-00000001"),
);
assert_eq!(author, orphan_work_author_summary());
}
#[test]
fn should_rebind_orphan_work_owner_detects_missing_and_empty_author() {
let service = AuthUserService::new(InMemoryAuthStore::default());
assert!(should_rebind_orphan_work_owner(&service, ""));
assert!(should_rebind_orphan_work_owner(&service, "user_missing"));
assert!(!should_rebind_orphan_work_owner(&service, ORPHAN_WORK_OWNER_USER_ID));
}
}