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:
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("不得把主题物品画在画面中央"));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,22 @@ pub fn build_system_username(prefix: &str, sequence: u64) -> String {
|
||||
format!("{prefix}_{sequence:08}")
|
||||
}
|
||||
|
||||
pub fn build_wechat_username(display_name: &str, provider_uid: &str) -> String {
|
||||
let normalized_display_name = display_name.trim();
|
||||
let normalized_provider_uid = provider_uid.trim();
|
||||
let fallback_display_name = if normalized_display_name.is_empty() {
|
||||
"微信旅人"
|
||||
} else {
|
||||
normalized_display_name
|
||||
};
|
||||
let fallback_provider_uid = if normalized_provider_uid.is_empty() {
|
||||
"openid"
|
||||
} else {
|
||||
normalized_provider_uid
|
||||
};
|
||||
format!("{fallback_display_name}_{fallback_provider_uid}")
|
||||
}
|
||||
|
||||
// 公开陶泥号是稳定的公开检索键,不替代内部 user_id,仅用于展示、分享与搜索。
|
||||
pub fn build_public_user_code(sequence: u64) -> String {
|
||||
format!("SY-{sequence:08}")
|
||||
|
||||
@@ -12,8 +12,6 @@ pub use events::*;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
@@ -33,7 +31,6 @@ use tracing::{info, warn};
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InMemoryAuthStore {
|
||||
inner: Arc<Mutex<InMemoryAuthStoreState>>,
|
||||
persistence_path: Option<Arc<PathBuf>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -804,6 +801,21 @@ impl AuthUserService {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
pub fn ensure_orphan_work_owner_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
username: &str,
|
||||
display_name: &str,
|
||||
public_user_code: &str,
|
||||
) -> Result<AuthUser, PasswordEntryError> {
|
||||
self.store.ensure_orphan_work_owner_user(
|
||||
user_id,
|
||||
username,
|
||||
display_name,
|
||||
public_user_code,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_user_by_id(&self, user_id: &str) -> Result<Option<AuthUser>, LogoutError> {
|
||||
self.store
|
||||
.find_by_user_id(user_id)
|
||||
@@ -888,7 +900,6 @@ impl Default for InMemoryAuthStore {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(InMemoryAuthStoreState::default())),
|
||||
persistence_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -937,14 +948,6 @@ impl InMemoryAuthStoreState {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_temp_persistence_path(path: &Path) -> PathBuf {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("auth-store.json");
|
||||
path.with_file_name(format!("{file_name}.tmp"))
|
||||
}
|
||||
|
||||
impl InMemoryAuthStore {
|
||||
pub fn from_snapshot_json(snapshot_json: &str) -> Result<Self, String> {
|
||||
let snapshot = serde_json::from_str::<PersistentAuthStoreSnapshot>(snapshot_json)
|
||||
@@ -953,25 +956,6 @@ impl InMemoryAuthStore {
|
||||
inner: Arc::new(Mutex::new(
|
||||
InMemoryAuthStoreState::from_persistent_snapshot(snapshot),
|
||||
)),
|
||||
persistence_path: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_persistence_path(path: impl Into<PathBuf>) -> Result<Self, String> {
|
||||
let path = path.into();
|
||||
let state = if path.is_file() {
|
||||
let raw_text =
|
||||
fs::read_to_string(&path).map_err(|error| format!("读取认证快照失败:{error}"))?;
|
||||
let snapshot = serde_json::from_str::<PersistentAuthStoreSnapshot>(&raw_text)
|
||||
.map_err(|error| format!("解析认证快照失败:{error}"))?;
|
||||
InMemoryAuthStoreState::from_persistent_snapshot(snapshot)
|
||||
} else {
|
||||
InMemoryAuthStoreState::default()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(state)),
|
||||
persistence_path: Some(Arc::new(path)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -986,30 +970,8 @@ impl InMemoryAuthStore {
|
||||
}
|
||||
|
||||
fn persist_state(&self, state: &InMemoryAuthStoreState) -> Result<(), String> {
|
||||
let Some(path) = self.persistence_path.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Some(parent_dir) = path.parent() {
|
||||
fs::create_dir_all(parent_dir).map_err(|error| {
|
||||
format!(
|
||||
"创建认证快照目录失败:{},路径:{}",
|
||||
error,
|
||||
parent_dir.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let snapshot = state.to_persistent_snapshot();
|
||||
let raw_text = serde_json::to_string_pretty(&snapshot)
|
||||
.map_err(|error| format!("序列化认证快照失败:{error}"))?;
|
||||
let temp_path = build_temp_persistence_path(path);
|
||||
fs::write(&temp_path, raw_text)
|
||||
.map_err(|error| format!("写入认证快照临时文件失败:{error}"))?;
|
||||
fs::rename(&temp_path, path).map_err(|error| {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
format!("替换认证快照文件失败:{error}")
|
||||
})
|
||||
let _ = state;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_password_state(
|
||||
@@ -1051,6 +1013,68 @@ impl InMemoryAuthStore {
|
||||
.cloned())
|
||||
}
|
||||
|
||||
fn ensure_orphan_work_owner_user(
|
||||
&self,
|
||||
user_id: &str,
|
||||
username: &str,
|
||||
display_name: &str,
|
||||
public_user_code: &str,
|
||||
) -> Result<AuthUser, PasswordEntryError> {
|
||||
let user_id = normalize_required_string(user_id).ok_or_else(|| {
|
||||
PasswordEntryError::Store("孤儿作品占位用户 id 不能为空".to_string())
|
||||
})?;
|
||||
let username = normalize_required_string(username).ok_or_else(|| {
|
||||
PasswordEntryError::Store("孤儿作品占位用户名不能为空".to_string())
|
||||
})?;
|
||||
let display_name = normalize_required_string(display_name).ok_or_else(|| {
|
||||
PasswordEntryError::Store("孤儿作品占位展示名不能为空".to_string())
|
||||
})?;
|
||||
let public_user_code = normalize_required_string(public_user_code).ok_or_else(|| {
|
||||
PasswordEntryError::Store("孤儿作品占位陶泥号不能为空".to_string())
|
||||
})?;
|
||||
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
if let Some(stored) = state
|
||||
.users_by_username
|
||||
.values()
|
||||
.find(|stored_user| stored_user.user.id == user_id)
|
||||
{
|
||||
return Ok(stored.user.clone());
|
||||
}
|
||||
|
||||
let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| {
|
||||
PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}"))
|
||||
})?;
|
||||
let user = AuthUser {
|
||||
id: user_id,
|
||||
public_user_code,
|
||||
username: username.clone(),
|
||||
display_name,
|
||||
avatar_url: None,
|
||||
phone_number_masked: None,
|
||||
login_method: AuthLoginMethod::Password,
|
||||
binding_status: AuthBindingStatus::Active,
|
||||
wechat_bound: false,
|
||||
token_version: 1,
|
||||
created_at,
|
||||
};
|
||||
state.users_by_username.insert(
|
||||
username,
|
||||
StoredPasswordUser {
|
||||
user: user.clone(),
|
||||
password_hash: String::new(),
|
||||
password_login_enabled: false,
|
||||
phone_number: None,
|
||||
},
|
||||
);
|
||||
self.persist_password_state(&state)?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
fn find_by_public_user_code(
|
||||
&self,
|
||||
public_user_code: &str,
|
||||
@@ -1153,7 +1177,7 @@ impl InMemoryAuthStore {
|
||||
PhoneAuthError::Store(format!("用户创建时间格式化失败:{message}"))
|
||||
})?;
|
||||
let sequence = state.next_user_id;
|
||||
let user_id = format!("user_{sequence:08}");
|
||||
let user_id = build_prefixed_uuid_id("user_");
|
||||
let public_user_code = build_public_user_code(sequence);
|
||||
state.next_user_id += 1;
|
||||
let username = build_system_username("phone", state.next_user_id);
|
||||
@@ -1205,7 +1229,7 @@ impl InMemoryAuthStore {
|
||||
PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}"))
|
||||
})?;
|
||||
let sequence = state.next_user_id;
|
||||
let user_id = format!("user_{sequence:08}");
|
||||
let user_id = build_prefixed_uuid_id("user_");
|
||||
let public_user_code = build_public_user_code(sequence);
|
||||
state.next_user_id += 1;
|
||||
let username = build_system_username("phone", state.next_user_id);
|
||||
@@ -1253,10 +1277,9 @@ impl InMemoryAuthStore {
|
||||
WechatAuthError::Store(format!("用户创建时间格式化失败:{message}"))
|
||||
})?;
|
||||
let sequence = state.next_user_id;
|
||||
let user_id = format!("user_{sequence:08}");
|
||||
let user_id = build_prefixed_uuid_id("user_");
|
||||
let public_user_code = build_public_user_code(sequence);
|
||||
state.next_user_id += 1;
|
||||
let username = build_system_username("wechat", state.next_user_id);
|
||||
let avatar_url = normalize_optional_string(profile.avatar_url.clone());
|
||||
let display_name = profile
|
||||
.display_name
|
||||
@@ -1265,6 +1288,7 @@ impl InMemoryAuthStore {
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("微信旅人")
|
||||
.to_string();
|
||||
let username = build_wechat_username(&display_name, &profile.provider_uid);
|
||||
let user = AuthUser {
|
||||
id: user_id.clone(),
|
||||
public_user_code,
|
||||
@@ -2224,6 +2248,18 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_wechat_username_uses_display_name_and_provider_uid() {
|
||||
assert_eq!(
|
||||
build_wechat_username("小明", "wx-openid-123"),
|
||||
"小明_wx-openid-123"
|
||||
);
|
||||
assert_eq!(
|
||||
build_wechat_username(" ", "wx-openid-123"),
|
||||
"微信旅人_wx-openid-123"
|
||||
);
|
||||
}
|
||||
|
||||
fn build_store() -> InMemoryAuthStore {
|
||||
InMemoryAuthStore::default()
|
||||
}
|
||||
@@ -2552,15 +2588,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persistent_store_restores_user_and_refresh_session_after_restart() {
|
||||
let store_path = std::env::temp_dir().join(format!(
|
||||
"genarrative-auth-store-{}.json",
|
||||
new_uuid_simple_string()
|
||||
));
|
||||
let _ = std::fs::remove_file(&store_path);
|
||||
|
||||
let store = InMemoryAuthStore::from_persistence_path(store_path.clone())
|
||||
.expect("persistent store should initialize");
|
||||
async fn snapshot_json_restores_user_and_refresh_session_after_roundtrip() {
|
||||
let store = InMemoryAuthStore::default();
|
||||
let user = create_phone_login_user(store.clone(), "13800138003").await;
|
||||
let password_service = build_password_service(store.clone());
|
||||
let refresh_service = build_refresh_service(store.clone());
|
||||
@@ -2583,10 +2612,12 @@ mod tests {
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("refresh session should be persisted");
|
||||
drop(store);
|
||||
|
||||
let restored_store = InMemoryAuthStore::from_persistence_path(store_path.clone())
|
||||
.expect("persistent store should restore");
|
||||
let snapshot_json = store
|
||||
.export_snapshot_json()
|
||||
.expect("snapshot export should succeed");
|
||||
let restored_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
|
||||
.expect("snapshot json should restore");
|
||||
let restored_user = build_password_service(restored_store.clone())
|
||||
.get_user_by_id(&user.id)
|
||||
.expect("restored user query should succeed")
|
||||
@@ -2604,8 +2635,6 @@ mod tests {
|
||||
)
|
||||
.expect("restored refresh session should rotate");
|
||||
assert_eq!(rotated.user.id, user.id);
|
||||
|
||||
let _ = std::fs::remove_file(&store_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3211,6 +3240,9 @@ mod tests {
|
||||
first_wechat.user.binding_status,
|
||||
AuthBindingStatus::PendingBindPhone
|
||||
);
|
||||
assert_eq!(first_wechat.user.username, "微信旅人甲_wx-openid-first");
|
||||
assert!(first_wechat.user.id.starts_with("user_"));
|
||||
assert!(!first_wechat.user.id.ends_with("00000001"));
|
||||
|
||||
let second_wechat = wechat_service
|
||||
.resolve_login(ResolveWechatLoginInput {
|
||||
@@ -3229,6 +3261,7 @@ mod tests {
|
||||
assert_eq!(second_wechat.user.id, first_wechat.user.id);
|
||||
assert_ne!(second_wechat.user.id, phone_user.id);
|
||||
assert_eq!(second_wechat.user.login_method, AuthLoginMethod::Wechat);
|
||||
assert_eq!(second_wechat.user.username, first_wechat.user.username);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1811,7 +1811,10 @@ pub fn select_runtime_next_profile<'a>(
|
||||
prefer_similar_work: bool,
|
||||
) -> Option<&'a PuzzleWorkProfile> {
|
||||
if prefer_similar_work {
|
||||
similar_work_profiles.first().copied().or(same_work_next_profile)
|
||||
similar_work_profiles
|
||||
.first()
|
||||
.copied()
|
||||
.or(same_work_next_profile)
|
||||
} else {
|
||||
same_work_next_profile.or_else(|| similar_work_profiles.first().copied())
|
||||
}
|
||||
@@ -3281,7 +3284,10 @@ mod tests {
|
||||
assert_eq!(failed.generation_status, "failed");
|
||||
assert_eq!(failed.levels[0].generation_status, "failed");
|
||||
assert_eq!(failed.levels[1].generation_status, "ready");
|
||||
assert_eq!(failed.levels[1].cover_image_src.as_deref(), Some("/ready.png"));
|
||||
assert_eq!(
|
||||
failed.levels[1].cover_image_src.as_deref(),
|
||||
Some("/ready.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3338,12 +3344,8 @@ mod tests {
|
||||
let same_work = build_published_profile("same", "owner-a", vec!["奇幻"]);
|
||||
let similar_work = build_published_profile("similar", "owner-b", vec!["奇幻"]);
|
||||
let similar_work_profiles = [&similar_work];
|
||||
let selected = select_runtime_next_profile(
|
||||
Some(&same_work),
|
||||
&similar_work_profiles,
|
||||
true,
|
||||
)
|
||||
.expect("should select similar work first");
|
||||
let selected = select_runtime_next_profile(Some(&same_work), &similar_work_profiles, true)
|
||||
.expect("should select similar work first");
|
||||
assert_eq!(selected.profile_id, "similar");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use serde_json::json;
|
||||
use shared_contracts::hyper3d as contract;
|
||||
|
||||
use super::status::normalize_task_status;
|
||||
use super::{
|
||||
build_submit_response, extract_download_files, extract_job_statuses,
|
||||
resolve_hyper3d_overall_status,
|
||||
};
|
||||
use super::status::normalize_task_status;
|
||||
|
||||
#[test]
|
||||
fn extracts_submit_response_from_nested_payload() {
|
||||
|
||||
@@ -20,46 +20,6 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_auth_store_snapshot(
|
||||
&self,
|
||||
) -> Result<AuthStoreSnapshotRecord, SpacetimeClientError> {
|
||||
self.call_after_connect("get_auth_store_snapshot", move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.get_auth_store_snapshot_then(move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_auth_store_snapshot_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn upsert_auth_store_snapshot(
|
||||
&self,
|
||||
snapshot_json: String,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<AuthStoreSnapshotRecord, SpacetimeClientError> {
|
||||
let procedure_input = AuthStoreSnapshotUpsertInput {
|
||||
snapshot_json,
|
||||
updated_at_micros,
|
||||
};
|
||||
|
||||
self.call_after_connect("upsert_auth_store_snapshot", move |connection, sender| {
|
||||
connection.procedures().upsert_auth_store_snapshot_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_auth_store_snapshot_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn import_auth_store_snapshot_json(
|
||||
&self,
|
||||
snapshot_json: String,
|
||||
@@ -85,20 +45,4 @@ impl SpacetimeClient {
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn import_auth_store_snapshot(
|
||||
&self,
|
||||
) -> Result<AuthStoreSnapshotImportRecord, SpacetimeClientError> {
|
||||
self.call_after_connect("import_auth_store_snapshot", move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.import_auth_store_snapshot_then(move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_auth_store_snapshot_import_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,15 +53,15 @@ pub use mapper::{
|
||||
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
|
||||
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
|
||||
PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord,
|
||||
PuzzleFormDraftRecord,
|
||||
PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
|
||||
PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord,
|
||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
|
||||
|
||||
@@ -102,15 +102,15 @@ pub use self::puzzle::{
|
||||
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
|
||||
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
|
||||
PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord,
|
||||
PuzzleFormDraftRecord,
|
||||
PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
|
||||
PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord,
|
||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
};
|
||||
|
||||
@@ -344,7 +344,6 @@ pub mod finish_match_3_d_time_up_procedure;
|
||||
pub mod finish_square_hole_time_up_procedure;
|
||||
pub mod finish_wooden_fish_run_procedure;
|
||||
pub mod generate_big_fish_asset_procedure;
|
||||
pub mod get_auth_store_snapshot_procedure;
|
||||
pub mod get_bark_battle_run_procedure;
|
||||
pub mod get_bark_battle_runtime_config_procedure;
|
||||
pub mod get_battle_state_procedure;
|
||||
@@ -393,7 +392,6 @@ pub mod grant_new_user_registration_wallet_reward_procedure;
|
||||
pub mod grant_player_progression_experience_and_return_procedure;
|
||||
pub mod grant_player_progression_experience_reducer;
|
||||
pub mod import_auth_store_snapshot_json_procedure;
|
||||
pub mod import_auth_store_snapshot_procedure;
|
||||
pub mod import_database_migration_from_chunks_procedure;
|
||||
pub mod import_database_migration_from_file_procedure;
|
||||
pub mod import_database_migration_incremental_from_chunks_procedure;
|
||||
@@ -942,7 +940,6 @@ pub mod update_puzzle_work_procedure;
|
||||
pub mod update_square_hole_work_procedure;
|
||||
pub mod update_visual_novel_work_procedure;
|
||||
pub mod update_wooden_fish_work_procedure;
|
||||
pub mod upsert_auth_store_snapshot_procedure;
|
||||
pub mod upsert_chapter_progression_and_return_procedure;
|
||||
pub mod upsert_chapter_progression_reducer;
|
||||
pub mod upsert_creation_entry_type_config_procedure;
|
||||
@@ -1379,7 +1376,6 @@ pub use finish_match_3_d_time_up_procedure::finish_match_3_d_time_up;
|
||||
pub use finish_square_hole_time_up_procedure::finish_square_hole_time_up;
|
||||
pub use finish_wooden_fish_run_procedure::finish_wooden_fish_run;
|
||||
pub use generate_big_fish_asset_procedure::generate_big_fish_asset;
|
||||
pub use get_auth_store_snapshot_procedure::get_auth_store_snapshot;
|
||||
pub use get_bark_battle_run_procedure::get_bark_battle_run;
|
||||
pub use get_bark_battle_runtime_config_procedure::get_bark_battle_runtime_config;
|
||||
pub use get_battle_state_procedure::get_battle_state;
|
||||
@@ -1428,7 +1424,6 @@ pub use grant_new_user_registration_wallet_reward_procedure::grant_new_user_regi
|
||||
pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return;
|
||||
pub use grant_player_progression_experience_reducer::grant_player_progression_experience;
|
||||
pub use import_auth_store_snapshot_json_procedure::import_auth_store_snapshot_json;
|
||||
pub use import_auth_store_snapshot_procedure::import_auth_store_snapshot;
|
||||
pub use import_database_migration_from_chunks_procedure::import_database_migration_from_chunks;
|
||||
pub use import_database_migration_from_file_procedure::import_database_migration_from_file;
|
||||
pub use import_database_migration_incremental_from_chunks_procedure::import_database_migration_incremental_from_chunks;
|
||||
@@ -1977,7 +1972,6 @@ pub use update_puzzle_work_procedure::update_puzzle_work;
|
||||
pub use update_square_hole_work_procedure::update_square_hole_work;
|
||||
pub use update_visual_novel_work_procedure::update_visual_novel_work;
|
||||
pub use update_wooden_fish_work_procedure::update_wooden_fish_work;
|
||||
pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot;
|
||||
pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return;
|
||||
pub use upsert_chapter_progression_reducer::upsert_chapter_progression;
|
||||
pub use upsert_creation_entry_type_config_procedure::upsert_creation_entry_type_config;
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::auth_store_snapshot_procedure_result_type::AuthStoreSnapshotProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct GetAuthStoreSnapshotArgs {}
|
||||
|
||||
impl __sdk::InModule for GetAuthStoreSnapshotArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `get_auth_store_snapshot`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait get_auth_store_snapshot {
|
||||
fn get_auth_store_snapshot(&self) {
|
||||
self.get_auth_store_snapshot_then(|_, _| {});
|
||||
}
|
||||
|
||||
fn get_auth_store_snapshot_then(
|
||||
&self,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AuthStoreSnapshotProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl get_auth_store_snapshot for super::RemoteProcedures {
|
||||
fn get_auth_store_snapshot_then(
|
||||
&self,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AuthStoreSnapshotProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AuthStoreSnapshotProcedureResult>(
|
||||
"get_auth_store_snapshot",
|
||||
GetAuthStoreSnapshotArgs {},
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::auth_store_snapshot_import_procedure_result_type::AuthStoreSnapshotImportProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct ImportAuthStoreSnapshotArgs {}
|
||||
|
||||
impl __sdk::InModule for ImportAuthStoreSnapshotArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `import_auth_store_snapshot`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait import_auth_store_snapshot {
|
||||
fn import_auth_store_snapshot(&self) {
|
||||
self.import_auth_store_snapshot_then(|_, _| {});
|
||||
}
|
||||
|
||||
fn import_auth_store_snapshot_then(
|
||||
&self,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AuthStoreSnapshotImportProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl import_auth_store_snapshot for super::RemoteProcedures {
|
||||
fn import_auth_store_snapshot_then(
|
||||
&self,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AuthStoreSnapshotImportProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AuthStoreSnapshotImportProcedureResult>(
|
||||
"import_auth_store_snapshot",
|
||||
ImportAuthStoreSnapshotArgs {},
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::auth_store_snapshot_procedure_result_type::AuthStoreSnapshotProcedureResult;
|
||||
use super::auth_store_snapshot_upsert_input_type::AuthStoreSnapshotUpsertInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct UpsertAuthStoreSnapshotArgs {
|
||||
pub input: AuthStoreSnapshotUpsertInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for UpsertAuthStoreSnapshotArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `upsert_auth_store_snapshot`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait upsert_auth_store_snapshot {
|
||||
fn upsert_auth_store_snapshot(&self, input: AuthStoreSnapshotUpsertInput) {
|
||||
self.upsert_auth_store_snapshot_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn upsert_auth_store_snapshot_then(
|
||||
&self,
|
||||
input: AuthStoreSnapshotUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AuthStoreSnapshotProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl upsert_auth_store_snapshot for super::RemoteProcedures {
|
||||
fn upsert_auth_store_snapshot_then(
|
||||
&self,
|
||||
input: AuthStoreSnapshotUpsertInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<AuthStoreSnapshotProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, AuthStoreSnapshotProcedureResult>(
|
||||
"upsert_auth_store_snapshot",
|
||||
UpsertAuthStoreSnapshotArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -183,15 +183,12 @@ impl SpacetimeClient {
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.mark_puzzle_draft_generation_failed_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_puzzle_agent_session_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
.mark_puzzle_draft_generation_failed_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_puzzle_agent_session_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp};
|
||||
|
||||
use super::{
|
||||
@@ -13,8 +15,14 @@ use super::{
|
||||
},
|
||||
};
|
||||
|
||||
const AUTH_STORE_SNAPSHOT_ID: &str = "default";
|
||||
const AUTH_STORE_PROJECTION_META_ID: &str = "default";
|
||||
const AUTH_STORE_SNAPSHOT_META_NEXT_USER_ID: &str = "meta/next_user_id";
|
||||
const AUTH_STORE_SNAPSHOT_USER_PREFIX: &str = "user/";
|
||||
const AUTH_STORE_SNAPSHOT_PHONE_PREFIX: &str = "phone/";
|
||||
const AUTH_STORE_SNAPSHOT_SESSION_PREFIX: &str = "session/";
|
||||
const AUTH_STORE_SNAPSHOT_SESSION_HASH_PREFIX: &str = "session_hash/";
|
||||
const AUTH_STORE_SNAPSHOT_WECHAT_PREFIX: &str = "wechat/";
|
||||
const AUTH_STORE_SNAPSHOT_UNION_PREFIX: &str = "union/";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct AuthStoreSnapshotRecord {
|
||||
@@ -41,6 +49,74 @@ fn normalize_user_account_tags(
|
||||
module_runtime::normalize_profile_user_tags(tags.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn prefixed_snapshot_id(prefix: &str, value: &str) -> String {
|
||||
format!("{prefix}{}", sanitize_identity_component(value))
|
||||
}
|
||||
|
||||
fn upsert_auth_snapshot_row(
|
||||
ctx: &ReducerContext,
|
||||
snapshot_id: String,
|
||||
snapshot_json: String,
|
||||
updated_at: Timestamp,
|
||||
) {
|
||||
if ctx
|
||||
.db
|
||||
.auth_store_snapshot()
|
||||
.snapshot_id()
|
||||
.find(&snapshot_id)
|
||||
.is_some()
|
||||
{
|
||||
ctx.db.auth_store_snapshot().snapshot_id().delete(&snapshot_id);
|
||||
}
|
||||
|
||||
ctx.db.auth_store_snapshot().insert(AuthStoreSnapshot {
|
||||
snapshot_id,
|
||||
snapshot_json,
|
||||
updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
fn auth_store_snapshot_user_row_id(user_id: &str) -> String {
|
||||
prefixed_snapshot_id(AUTH_STORE_SNAPSHOT_USER_PREFIX, user_id)
|
||||
}
|
||||
|
||||
fn auth_store_snapshot_phone_row_id(phone_number: &str, user_id: &str) -> String {
|
||||
prefixed_snapshot_id(
|
||||
AUTH_STORE_SNAPSHOT_PHONE_PREFIX,
|
||||
&format!("{phone_number}|{user_id}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn auth_store_snapshot_session_row_id(session_id: &str) -> String {
|
||||
prefixed_snapshot_id(AUTH_STORE_SNAPSHOT_SESSION_PREFIX, session_id)
|
||||
}
|
||||
|
||||
fn auth_store_snapshot_session_hash_row_id(refresh_token_hash: &str, session_id: &str) -> String {
|
||||
prefixed_snapshot_id(
|
||||
AUTH_STORE_SNAPSHOT_SESSION_HASH_PREFIX,
|
||||
&format!("{refresh_token_hash}|{session_id}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn auth_store_snapshot_wechat_row_id(provider_uid: &str, user_id: &str) -> String {
|
||||
prefixed_snapshot_id(
|
||||
AUTH_STORE_SNAPSHOT_WECHAT_PREFIX,
|
||||
&format!("{provider_uid}|{user_id}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn auth_store_snapshot_union_row_id(union_id: &str, user_id: &str) -> String {
|
||||
prefixed_snapshot_id(AUTH_STORE_SNAPSHOT_UNION_PREFIX, &format!("{union_id}|{user_id}"))
|
||||
}
|
||||
|
||||
fn snapshot_has_user_rows(snapshot: &PersistentAuthStoreSnapshot) -> bool {
|
||||
!snapshot.users_by_username.is_empty()
|
||||
}
|
||||
|
||||
fn to_snapshot_row_json<T: Serialize>(label: &str, value: &T) -> Result<String, String> {
|
||||
serde_json::to_string(value).map_err(|error| format!("{label} 序列化失败:{error}"))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct AuthStoreSnapshotImportRecord {
|
||||
pub imported_user_count: u32,
|
||||
@@ -55,44 +131,7 @@ pub struct AuthStoreSnapshotImportProcedureResult {
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
// Axum 启动恢复认证状态时读取当前快照;记录不存在代表尚未产生登录态。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn get_auth_store_snapshot(ctx: &mut ProcedureContext) -> AuthStoreSnapshotProcedureResult {
|
||||
match ctx.try_with_tx(|tx| get_auth_store_snapshot_tx(tx)) {
|
||||
Ok(record) => AuthStoreSnapshotProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AuthStoreSnapshotProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 历史迁移入口:覆盖写入整份快照,供旧库从 `auth_store_snapshot/default` 导入正式表。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn upsert_auth_store_snapshot(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AuthStoreSnapshotUpsertInput,
|
||||
) -> AuthStoreSnapshotProcedureResult {
|
||||
match ctx.try_with_tx(|tx| upsert_auth_store_snapshot_tx(tx, input.clone())) {
|
||||
Ok(record) => AuthStoreSnapshotProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AuthStoreSnapshotProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Axum 运行期认证变更直接导入正式认证表,不再继续刷新 `auth_store_snapshot/default`。
|
||||
// Axum 运行期认证变更直接导入正式认证表,并把快照拆成行级记录;禁止再写 `auth_store_snapshot/default`。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn import_auth_store_snapshot_json(
|
||||
ctx: &mut ProcedureContext,
|
||||
@@ -112,24 +151,6 @@ pub fn import_auth_store_snapshot_json(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn import_auth_store_snapshot(
|
||||
ctx: &mut ProcedureContext,
|
||||
) -> AuthStoreSnapshotImportProcedureResult {
|
||||
match ctx.try_with_tx(|tx| import_auth_store_snapshot_tx(tx)) {
|
||||
Ok(record) => AuthStoreSnapshotImportProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AuthStoreSnapshotImportProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Axum 启动时可从正式表重新导出 module-auth 使用的整份认证快照。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn export_auth_store_snapshot_from_tables(
|
||||
@@ -149,78 +170,6 @@ pub fn export_auth_store_snapshot_from_tables(
|
||||
}
|
||||
}
|
||||
|
||||
fn get_auth_store_snapshot_tx(ctx: &ReducerContext) -> Result<AuthStoreSnapshotRecord, String> {
|
||||
Ok(
|
||||
match ctx
|
||||
.db
|
||||
.auth_store_snapshot()
|
||||
.snapshot_id()
|
||||
.find(&AUTH_STORE_SNAPSHOT_ID.to_string())
|
||||
{
|
||||
Some(row) => AuthStoreSnapshotRecord {
|
||||
snapshot_json: Some(row.snapshot_json),
|
||||
updated_at_micros: Some(row.updated_at.to_micros_since_unix_epoch()),
|
||||
},
|
||||
None => AuthStoreSnapshotRecord {
|
||||
snapshot_json: None,
|
||||
updated_at_micros: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn upsert_auth_store_snapshot_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AuthStoreSnapshotUpsertInput,
|
||||
) -> Result<AuthStoreSnapshotRecord, String> {
|
||||
let snapshot_json = input.snapshot_json.trim().to_string();
|
||||
if snapshot_json.is_empty() {
|
||||
return Err("认证快照 JSON 不能为空".to_string());
|
||||
}
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
||||
|
||||
if ctx
|
||||
.db
|
||||
.auth_store_snapshot()
|
||||
.snapshot_id()
|
||||
.find(&AUTH_STORE_SNAPSHOT_ID.to_string())
|
||||
.is_some()
|
||||
{
|
||||
ctx.db
|
||||
.auth_store_snapshot()
|
||||
.snapshot_id()
|
||||
.delete(&AUTH_STORE_SNAPSHOT_ID.to_string());
|
||||
}
|
||||
|
||||
ctx.db.auth_store_snapshot().insert(AuthStoreSnapshot {
|
||||
snapshot_id: AUTH_STORE_SNAPSHOT_ID.to_string(),
|
||||
snapshot_json: snapshot_json.clone(),
|
||||
updated_at,
|
||||
});
|
||||
|
||||
Ok(AuthStoreSnapshotRecord {
|
||||
snapshot_json: Some(snapshot_json),
|
||||
updated_at_micros: Some(input.updated_at_micros),
|
||||
})
|
||||
}
|
||||
|
||||
fn import_auth_store_snapshot_tx(
|
||||
ctx: &ReducerContext,
|
||||
) -> Result<AuthStoreSnapshotImportRecord, String> {
|
||||
let snapshot = ctx
|
||||
.db
|
||||
.auth_store_snapshot()
|
||||
.snapshot_id()
|
||||
.find(&AUTH_STORE_SNAPSHOT_ID.to_string())
|
||||
.ok_or_else(|| "认证快照不存在,无法导入正式表".to_string())?;
|
||||
|
||||
import_auth_store_snapshot_json_value_tx(
|
||||
ctx,
|
||||
&snapshot.snapshot_json,
|
||||
snapshot.updated_at.to_micros_since_unix_epoch(),
|
||||
)
|
||||
}
|
||||
|
||||
fn import_auth_store_snapshot_json_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AuthStoreSnapshotUpsertInput,
|
||||
@@ -239,8 +188,11 @@ fn import_auth_store_snapshot_json_value_tx(
|
||||
}
|
||||
let parsed = serde_json::from_str::<PersistentAuthStoreSnapshot>(snapshot_json)
|
||||
.map_err(|error| format!("认证快照 JSON 解析失败:{error}"))?;
|
||||
if !snapshot_has_user_rows(&parsed) {
|
||||
return Err("认证快照缺少用户记录,拒绝导入正式表".to_string());
|
||||
}
|
||||
|
||||
clear_auth_target_tables(ctx);
|
||||
upsert_auth_store_snapshot_rows(ctx, &parsed, updated_at_micros)?;
|
||||
upsert_auth_projection_meta(ctx, updated_at_micros);
|
||||
|
||||
let mut imported_user_count = 0_u32;
|
||||
@@ -249,8 +201,18 @@ fn import_auth_store_snapshot_json_value_tx(
|
||||
|
||||
for stored_user in parsed.users_by_username.into_values() {
|
||||
let user = stored_user.user;
|
||||
let user_id = user.id.clone();
|
||||
if ctx
|
||||
.db
|
||||
.user_account()
|
||||
.user_id()
|
||||
.find(&user_id)
|
||||
.is_some()
|
||||
{
|
||||
ctx.db.user_account().user_id().delete(&user_id);
|
||||
}
|
||||
ctx.db.user_account().insert(UserAccount {
|
||||
user_id: user.id.clone(),
|
||||
user_id: user_id.clone(),
|
||||
public_user_code: user.public_user_code,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
@@ -271,9 +233,19 @@ fn import_auth_store_snapshot_json_value_tx(
|
||||
imported_user_count += 1;
|
||||
|
||||
if let Some(phone_number) = stored_user.phone_number {
|
||||
let identity_id = format!("authi_phone_{}", sanitize_identity_component(&phone_number));
|
||||
if ctx
|
||||
.db
|
||||
.auth_identity()
|
||||
.identity_id()
|
||||
.find(&identity_id)
|
||||
.is_some()
|
||||
{
|
||||
ctx.db.auth_identity().identity_id().delete(&identity_id);
|
||||
}
|
||||
ctx.db.auth_identity().insert(AuthIdentity {
|
||||
identity_id: format!("authi_phone_{}", sanitize_identity_component(&phone_number)),
|
||||
user_id: user.id,
|
||||
identity_id,
|
||||
user_id,
|
||||
provider: "phone".to_string(),
|
||||
provider_uid: phone_number.clone(),
|
||||
provider_union_id: None,
|
||||
@@ -286,11 +258,21 @@ fn import_auth_store_snapshot_json_value_tx(
|
||||
}
|
||||
|
||||
for identity in parsed.wechat_identity_by_provider_uid.into_values() {
|
||||
let identity_id = format!(
|
||||
"authi_wechat_{}",
|
||||
sanitize_identity_component(&identity.provider_uid)
|
||||
);
|
||||
if ctx
|
||||
.db
|
||||
.auth_identity()
|
||||
.identity_id()
|
||||
.find(&identity_id)
|
||||
.is_some()
|
||||
{
|
||||
ctx.db.auth_identity().identity_id().delete(&identity_id);
|
||||
}
|
||||
ctx.db.auth_identity().insert(AuthIdentity {
|
||||
identity_id: format!(
|
||||
"authi_wechat_{}",
|
||||
sanitize_identity_component(&identity.provider_uid)
|
||||
),
|
||||
identity_id,
|
||||
user_id: identity.user_id,
|
||||
provider: "wechat".to_string(),
|
||||
provider_uid: identity.provider_uid,
|
||||
@@ -306,6 +288,18 @@ fn import_auth_store_snapshot_json_value_tx(
|
||||
let session = stored_session.session;
|
||||
let client_info_json = serde_json::to_string(&session.client_info)
|
||||
.map_err(|error| format!("客户端身份序列化失败:{error}"))?;
|
||||
if ctx
|
||||
.db
|
||||
.refresh_session()
|
||||
.session_id()
|
||||
.find(&session.session_id)
|
||||
.is_some()
|
||||
{
|
||||
ctx.db
|
||||
.refresh_session()
|
||||
.session_id()
|
||||
.delete(&session.session_id);
|
||||
}
|
||||
ctx.db.refresh_session().insert(RefreshSession {
|
||||
session_id: session.session_id,
|
||||
user_id: session.user_id,
|
||||
@@ -328,6 +322,120 @@ fn import_auth_store_snapshot_json_value_tx(
|
||||
})
|
||||
}
|
||||
|
||||
fn upsert_auth_store_snapshot_rows(
|
||||
ctx: &ReducerContext,
|
||||
snapshot: &PersistentAuthStoreSnapshot,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<(), String> {
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(updated_at_micros);
|
||||
let desired_ids = auth_store_snapshot_row_ids(snapshot);
|
||||
for row in ctx.db.auth_store_snapshot().iter().collect::<Vec<_>>() {
|
||||
if !desired_ids.contains(&row.snapshot_id) {
|
||||
ctx.db
|
||||
.auth_store_snapshot()
|
||||
.snapshot_id()
|
||||
.delete(&row.snapshot_id);
|
||||
}
|
||||
}
|
||||
|
||||
upsert_auth_snapshot_row(
|
||||
ctx,
|
||||
AUTH_STORE_SNAPSHOT_META_NEXT_USER_ID.to_string(),
|
||||
to_snapshot_row_json("认证快照 next_user_id", &snapshot.next_user_id)?,
|
||||
updated_at,
|
||||
);
|
||||
|
||||
for user in snapshot.users_by_username.values() {
|
||||
upsert_auth_snapshot_row(
|
||||
ctx,
|
||||
auth_store_snapshot_user_row_id(&user.user.id),
|
||||
to_snapshot_row_json("认证快照用户", user)?,
|
||||
updated_at,
|
||||
);
|
||||
}
|
||||
|
||||
for (phone_number, user_id) in &snapshot.phone_to_user_id {
|
||||
upsert_auth_snapshot_row(
|
||||
ctx,
|
||||
auth_store_snapshot_phone_row_id(phone_number, user_id),
|
||||
to_snapshot_row_json("认证快照手机号索引", user_id)?,
|
||||
updated_at,
|
||||
);
|
||||
}
|
||||
|
||||
for session in snapshot.sessions_by_id.values() {
|
||||
upsert_auth_snapshot_row(
|
||||
ctx,
|
||||
auth_store_snapshot_session_row_id(&session.session.session_id),
|
||||
to_snapshot_row_json("认证快照会话", session)?,
|
||||
updated_at,
|
||||
);
|
||||
}
|
||||
|
||||
for (refresh_token_hash, session_id) in &snapshot.session_id_by_refresh_token_hash {
|
||||
upsert_auth_snapshot_row(
|
||||
ctx,
|
||||
auth_store_snapshot_session_hash_row_id(refresh_token_hash, session_id),
|
||||
to_snapshot_row_json("认证快照 refresh token 索引", session_id)?,
|
||||
updated_at,
|
||||
);
|
||||
}
|
||||
|
||||
for identity in snapshot.wechat_identity_by_provider_uid.values() {
|
||||
upsert_auth_snapshot_row(
|
||||
ctx,
|
||||
auth_store_snapshot_wechat_row_id(&identity.provider_uid, &identity.user_id),
|
||||
to_snapshot_row_json("认证快照微信身份", identity)?,
|
||||
updated_at,
|
||||
);
|
||||
}
|
||||
|
||||
for (union_id, user_id) in &snapshot.user_id_by_provider_union_id {
|
||||
upsert_auth_snapshot_row(
|
||||
ctx,
|
||||
auth_store_snapshot_union_row_id(union_id, user_id),
|
||||
to_snapshot_row_json("认证快照微信 union 索引", user_id)?,
|
||||
updated_at,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn auth_store_snapshot_row_ids(
|
||||
snapshot: &PersistentAuthStoreSnapshot,
|
||||
) -> std::collections::HashSet<String> {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
ids.insert(AUTH_STORE_SNAPSHOT_META_NEXT_USER_ID.to_string());
|
||||
for user in snapshot.users_by_username.values() {
|
||||
ids.insert(auth_store_snapshot_user_row_id(&user.user.id));
|
||||
}
|
||||
for (phone_number, user_id) in &snapshot.phone_to_user_id {
|
||||
ids.insert(auth_store_snapshot_phone_row_id(phone_number, user_id));
|
||||
}
|
||||
for session in snapshot.sessions_by_id.values() {
|
||||
ids.insert(auth_store_snapshot_session_row_id(
|
||||
&session.session.session_id,
|
||||
));
|
||||
}
|
||||
for (refresh_token_hash, session_id) in &snapshot.session_id_by_refresh_token_hash {
|
||||
ids.insert(auth_store_snapshot_session_hash_row_id(
|
||||
refresh_token_hash,
|
||||
session_id,
|
||||
));
|
||||
}
|
||||
for identity in snapshot.wechat_identity_by_provider_uid.values() {
|
||||
ids.insert(auth_store_snapshot_wechat_row_id(
|
||||
&identity.provider_uid,
|
||||
&identity.user_id,
|
||||
));
|
||||
}
|
||||
for (union_id, user_id) in &snapshot.user_id_by_provider_union_id {
|
||||
ids.insert(auth_store_snapshot_union_row_id(union_id, user_id));
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
fn export_auth_store_snapshot_from_tables_tx(
|
||||
ctx: &ReducerContext,
|
||||
) -> Result<AuthStoreSnapshotRecord, String> {
|
||||
@@ -455,6 +563,9 @@ fn export_auth_store_snapshot_from_tables_tx(
|
||||
wechat_identity_by_provider_uid,
|
||||
user_id_by_provider_union_id,
|
||||
};
|
||||
if let Some(updated_at_micros) = updated_at_micros {
|
||||
upsert_auth_store_snapshot_rows(ctx, &snapshot, updated_at_micros)?;
|
||||
}
|
||||
let snapshot_json = serde_json::to_string_pretty(&snapshot)
|
||||
.map_err(|error| format!("序列化认证快照失败:{error}"))?;
|
||||
|
||||
@@ -464,24 +575,6 @@ fn export_auth_store_snapshot_from_tables_tx(
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_auth_target_tables(ctx: &ReducerContext) {
|
||||
for row in ctx.db.refresh_session().iter().collect::<Vec<_>>() {
|
||||
ctx.db
|
||||
.refresh_session()
|
||||
.session_id()
|
||||
.delete(&row.session_id);
|
||||
}
|
||||
for row in ctx.db.auth_identity().iter().collect::<Vec<_>>() {
|
||||
ctx.db
|
||||
.auth_identity()
|
||||
.identity_id()
|
||||
.delete(&row.identity_id);
|
||||
}
|
||||
for row in ctx.db.user_account().iter().collect::<Vec<_>>() {
|
||||
ctx.db.user_account().user_id().delete(&row.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) {
|
||||
let meta_id = AUTH_STORE_PROJECTION_META_ID.to_string();
|
||||
if ctx
|
||||
@@ -503,3 +596,121 @@ fn upsert_auth_projection_meta(ctx: &ReducerContext, updated_at_micros: i64) {
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_snapshot() -> PersistentAuthStoreSnapshot {
|
||||
let user = StoredPasswordUserSnapshot {
|
||||
user: AuthUserSnapshot {
|
||||
id: "user_00000042".to_string(),
|
||||
public_user_code: "GN-000042".to_string(),
|
||||
username: "phone_42".to_string(),
|
||||
display_name: "测试玩家".to_string(),
|
||||
avatar_url: None,
|
||||
phone_number_masked: Some("138****8000".to_string()),
|
||||
login_method: "phone".to_string(),
|
||||
binding_status: "active".to_string(),
|
||||
wechat_bound: true,
|
||||
token_version: 3,
|
||||
user_tags: vec!["early".to_string()],
|
||||
},
|
||||
password_hash: "hash-42".to_string(),
|
||||
password_login_enabled: true,
|
||||
phone_number: Some("+8613800008000".to_string()),
|
||||
};
|
||||
let session = StoredRefreshSessionSnapshot {
|
||||
session: RefreshSessionSnapshot {
|
||||
session_id: "usess_42".to_string(),
|
||||
user_id: "user_00000042".to_string(),
|
||||
refresh_token_hash: "refresh-hash-42".to_string(),
|
||||
issued_by_provider: "phone".to_string(),
|
||||
client_info: serde_json::json!({"clientType":"web"}),
|
||||
expires_at: "2026-06-01T00:00:00Z".to_string(),
|
||||
revoked_at: None,
|
||||
created_at: "2026-05-27T00:00:00Z".to_string(),
|
||||
updated_at: "2026-05-27T00:00:00Z".to_string(),
|
||||
last_seen_at: "2026-05-27T00:00:00Z".to_string(),
|
||||
},
|
||||
};
|
||||
let identity = StoredWechatIdentitySnapshot {
|
||||
user_id: "user_00000042".to_string(),
|
||||
provider_uid: "wx-openid-42".to_string(),
|
||||
provider_union_id: Some("wx-union-42".to_string()),
|
||||
display_name: Some("微信玩家".to_string()),
|
||||
avatar_url: None,
|
||||
};
|
||||
|
||||
PersistentAuthStoreSnapshot {
|
||||
next_user_id: 43,
|
||||
users_by_username: std::collections::HashMap::from([(
|
||||
"phone_42".to_string(),
|
||||
user,
|
||||
)]),
|
||||
phone_to_user_id: std::collections::HashMap::from([(
|
||||
"+8613800008000".to_string(),
|
||||
"user_00000042".to_string(),
|
||||
)]),
|
||||
sessions_by_id: std::collections::HashMap::from([("usess_42".to_string(), session)]),
|
||||
session_id_by_refresh_token_hash: std::collections::HashMap::from([(
|
||||
"refresh-hash-42".to_string(),
|
||||
"usess_42".to_string(),
|
||||
)]),
|
||||
wechat_identity_by_provider_uid: std::collections::HashMap::from([(
|
||||
"wx-openid-42".to_string(),
|
||||
identity,
|
||||
)]),
|
||||
user_id_by_provider_union_id: std::collections::HashMap::from([(
|
||||
"wx-union-42".to_string(),
|
||||
"user_00000042".to_string(),
|
||||
)]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_store_snapshot_row_ids_are_row_level_without_default_aggregate() {
|
||||
let ids = auth_store_snapshot_row_ids(&sample_snapshot());
|
||||
|
||||
assert!(!ids.contains("default"));
|
||||
assert!(ids.contains(AUTH_STORE_SNAPSHOT_META_NEXT_USER_ID));
|
||||
assert!(ids.contains(&auth_store_snapshot_user_row_id("user_00000042")));
|
||||
assert!(ids.contains(&auth_store_snapshot_phone_row_id(
|
||||
"+8613800008000",
|
||||
"user_00000042"
|
||||
)));
|
||||
assert!(ids.contains(&auth_store_snapshot_session_row_id("usess_42")));
|
||||
assert!(ids.contains(&auth_store_snapshot_session_hash_row_id(
|
||||
"refresh-hash-42",
|
||||
"usess_42"
|
||||
)));
|
||||
assert!(ids.contains(&auth_store_snapshot_wechat_row_id(
|
||||
"wx-openid-42",
|
||||
"user_00000042"
|
||||
)));
|
||||
assert!(ids.contains(&auth_store_snapshot_union_row_id(
|
||||
"wx-union-42",
|
||||
"user_00000042"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_store_snapshot_user_row_key_is_stable_after_username_change() {
|
||||
let mut before = sample_snapshot();
|
||||
let mut after = sample_snapshot();
|
||||
after.users_by_username.clear();
|
||||
let mut renamed_user = before
|
||||
.users_by_username
|
||||
.remove("phone_42")
|
||||
.expect("sample user exists");
|
||||
renamed_user.user.username = "renamed_42".to_string();
|
||||
after
|
||||
.users_by_username
|
||||
.insert("renamed_42".to_string(), renamed_user);
|
||||
|
||||
assert_eq!(
|
||||
auth_store_snapshot_row_ids(&before),
|
||||
auth_store_snapshot_row_ids(&after)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ use module_puzzle::{
|
||||
PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
|
||||
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
|
||||
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
|
||||
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput,
|
||||
PuzzleDraftCompileFailureInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput,
|
||||
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileFailureInput, PuzzleDraftCompileInput,
|
||||
PuzzleFormDraftSaveInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput,
|
||||
PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus,
|
||||
PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput,
|
||||
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult,
|
||||
@@ -2193,7 +2193,7 @@ fn advance_puzzle_next_level_tx(
|
||||
&similar_work_profiles,
|
||||
input.prefer_similar_work,
|
||||
)
|
||||
.ok_or_else(|| "没有可用的下一关候选".to_string())?;
|
||||
.ok_or_else(|| "没有可用的下一关候选".to_string())?;
|
||||
let mut next_run = if similar_work_next_profile.is_some() {
|
||||
module_puzzle::advance_to_new_work_first_level_at(
|
||||
¤t_run,
|
||||
@@ -3745,6 +3745,12 @@ mod tests {
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
level_scene_image_src: None,
|
||||
level_scene_image_object_key: None,
|
||||
ui_spritesheet_image_src: None,
|
||||
ui_spritesheet_image_object_key: None,
|
||||
level_background_image_src: None,
|
||||
level_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates: candidates.clone(),
|
||||
selected_candidate_id: None,
|
||||
|
||||
@@ -13,6 +13,10 @@ use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use spacetimedb::AnonymousViewContext;
|
||||
|
||||
const DEFAULT_WOODEN_FISH_BACK_BUTTON_ASSET_ID: &str = "wooden-fish-default-back-button";
|
||||
const DEFAULT_WOODEN_FISH_BACK_BUTTON_IMAGE_SRC: &str = "/UI/11_left_arrow.png";
|
||||
const DEFAULT_WOODEN_FISH_BACK_BUTTON_IMAGE_OBJECT_KEY: &str = "public/UI/11_left_arrow.png";
|
||||
|
||||
#[spacetimedb::view(accessor = wooden_fish_gallery_view, public)]
|
||||
pub fn wooden_fish_gallery_view(ctx: &AnonymousViewContext) -> Vec<WoodenFishGalleryViewRow> {
|
||||
let mut items = ctx
|
||||
@@ -593,10 +597,14 @@ fn start_wooden_fish_run_tx(
|
||||
input: WoodenFishRunStartInput,
|
||||
) -> Result<WoodenFishRunSnapshot, String> {
|
||||
require_non_empty(&input.run_id, "wooden_fish run_id")?;
|
||||
let work = find_work(ctx, &input.profile_id)?;
|
||||
let stored_work = find_work(ctx, &input.profile_id)?;
|
||||
let work = backfill_historical_runtime_content(&stored_work);
|
||||
if !is_publish_ready(&work) {
|
||||
return Err("敲木鱼运行态需要完整作品配置".to_string());
|
||||
}
|
||||
if work.back_button_asset_json != stored_work.back_button_asset_json {
|
||||
replace_work(ctx, &stored_work, clone_work(&work));
|
||||
}
|
||||
let snapshot = WoodenFishRunSnapshot {
|
||||
run_id: input.run_id.clone(),
|
||||
profile_id: input.profile_id.clone(),
|
||||
@@ -740,6 +748,7 @@ fn build_session_snapshot(
|
||||
}
|
||||
|
||||
fn build_work_snapshot(row: &WoodenFishWorkProfileRow) -> Result<WoodenFishWorkSnapshot, String> {
|
||||
let row = backfill_historical_runtime_content(row);
|
||||
Ok(WoodenFishWorkSnapshot {
|
||||
work_id: row.work_id.clone(),
|
||||
profile_id: row.profile_id.clone(),
|
||||
@@ -775,7 +784,7 @@ fn build_work_snapshot(row: &WoodenFishWorkProfileRow) -> Result<WoodenFishWorkS
|
||||
)),
|
||||
cover_image_src: row.cover_image_src.clone(),
|
||||
publication_status: row.publication_status.clone(),
|
||||
publish_ready: is_publish_ready(row),
|
||||
publish_ready: is_publish_ready(&row),
|
||||
play_count: row.play_count,
|
||||
generation_status: row.generation_status.clone(),
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
@@ -1009,6 +1018,15 @@ fn insert_event(
|
||||
}
|
||||
|
||||
fn is_publish_ready(row: &WoodenFishWorkProfileRow) -> bool {
|
||||
is_publish_ready_except_back_button(row)
|
||||
&& row
|
||||
.back_button_asset_json
|
||||
.as_deref()
|
||||
.and_then(clean_optional)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn is_publish_ready_except_back_button(row: &WoodenFishWorkProfileRow) -> bool {
|
||||
!row.work_title.trim().is_empty()
|
||||
&& !row.hit_object_asset_json.trim().is_empty()
|
||||
&& row
|
||||
@@ -1016,14 +1034,40 @@ fn is_publish_ready(row: &WoodenFishWorkProfileRow) -> bool {
|
||||
.as_deref()
|
||||
.and_then(clean_optional)
|
||||
.is_some()
|
||||
&& row
|
||||
&& !row.hit_sound_asset_json.trim().is_empty()
|
||||
&& !row.floating_words_json.trim().is_empty()
|
||||
&& row.generation_status == WOODEN_FISH_GENERATION_READY
|
||||
}
|
||||
|
||||
fn backfill_historical_runtime_content(row: &WoodenFishWorkProfileRow) -> WoodenFishWorkProfileRow {
|
||||
if row.publication_status != WOODEN_FISH_PUBLICATION_PUBLISHED
|
||||
|| !is_publish_ready_except_back_button(row)
|
||||
|| row
|
||||
.back_button_asset_json
|
||||
.as_deref()
|
||||
.and_then(clean_optional)
|
||||
.is_some()
|
||||
&& !row.hit_sound_asset_json.trim().is_empty()
|
||||
&& !row.floating_words_json.trim().is_empty()
|
||||
&& row.generation_status == WOODEN_FISH_GENERATION_READY
|
||||
{
|
||||
return clone_work(row);
|
||||
}
|
||||
|
||||
WoodenFishWorkProfileRow {
|
||||
back_button_asset_json: Some(to_json_string(&default_wooden_fish_back_button_asset())),
|
||||
..clone_work(row)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_wooden_fish_back_button_asset() -> WoodenFishImageAssetSnapshot {
|
||||
WoodenFishImageAssetSnapshot {
|
||||
asset_id: DEFAULT_WOODEN_FISH_BACK_BUTTON_ASSET_ID.to_string(),
|
||||
image_src: DEFAULT_WOODEN_FISH_BACK_BUTTON_IMAGE_SRC.to_string(),
|
||||
image_object_key: DEFAULT_WOODEN_FISH_BACK_BUTTON_IMAGE_OBJECT_KEY.to_string(),
|
||||
asset_object_id: DEFAULT_WOODEN_FISH_BACK_BUTTON_ASSET_ID.to_string(),
|
||||
generation_provider: "bundled-default".to_string(),
|
||||
prompt: "历史敲木鱼默认返回按钮".to_string(),
|
||||
width: 28,
|
||||
height: 28,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_config_from_input(
|
||||
@@ -1288,3 +1332,82 @@ fn clone_run(row: &WoodenFishRuntimeRunRow) -> WoodenFishRuntimeRunRow {
|
||||
updated_at: row.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn historical_published_work_without_back_button_gets_runtime_backfill() {
|
||||
let row = published_ready_work_without_back_button();
|
||||
|
||||
assert!(!is_publish_ready(&row));
|
||||
let repaired = backfill_historical_runtime_content(&row);
|
||||
let snapshot = build_work_snapshot(&repaired).expect("历史作品补齐后应可映射运行态快照");
|
||||
|
||||
assert!(is_publish_ready(&repaired));
|
||||
assert!(snapshot.publish_ready);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.back_button_asset
|
||||
.as_ref()
|
||||
.map(|asset| asset.image_src.as_str()),
|
||||
Some("/UI/11_left_arrow.png")
|
||||
);
|
||||
}
|
||||
|
||||
fn published_ready_work_without_back_button() -> WoodenFishWorkProfileRow {
|
||||
let now = Timestamp::from_micros_since_unix_epoch(1_770_000_000_000_000);
|
||||
WoodenFishWorkProfileRow {
|
||||
profile_id: "wooden-fish-profile-history".to_string(),
|
||||
work_id: "wooden-fish-profile-history".to_string(),
|
||||
owner_user_id: "user-history".to_string(),
|
||||
source_session_id: "wooden-fish-session-history".to_string(),
|
||||
author_display_name: "敲木鱼玩家".to_string(),
|
||||
work_title: "今日敲木鱼".to_string(),
|
||||
work_description: String::new(),
|
||||
theme_tags_json: to_json_string(&vec!["敲木鱼".to_string(), "解压".to_string()]),
|
||||
hit_object_prompt: "默认敲击物图案,圆润木质质感,透明背景".to_string(),
|
||||
hit_object_reference_image_src: String::new(),
|
||||
hit_sound_prompt: String::new(),
|
||||
hit_object_asset_json: to_json_string(&WoodenFishImageAssetSnapshot {
|
||||
asset_id: "wooden-fish-hit-object-history".to_string(),
|
||||
image_src: "/wooden-fish/default-hit-object.png".to_string(),
|
||||
image_object_key: "public/wooden-fish/default-hit-object.png".to_string(),
|
||||
asset_object_id: "wooden-fish-hit-object-history".to_string(),
|
||||
generation_provider: "bundled-default".to_string(),
|
||||
prompt: "默认敲击物图案".to_string(),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}),
|
||||
hit_sound_asset_json: to_json_string(&WoodenFishAudioAssetSnapshot {
|
||||
asset_id: "wooden-fish-hit-sound-history".to_string(),
|
||||
audio_src: "/wooden-fish/default-hit-sound.mp3".to_string(),
|
||||
audio_object_key: "public/wooden-fish/default-hit-sound.mp3".to_string(),
|
||||
asset_object_id: "wooden-fish-hit-sound-history".to_string(),
|
||||
source: "bundled-default".to_string(),
|
||||
prompt: Some("默认木鱼音".to_string()),
|
||||
duration_ms: Some(3_000),
|
||||
}),
|
||||
floating_words_json: to_json_string(&default_floating_words()),
|
||||
cover_image_src: "/wooden-fish/default-hit-object.png".to_string(),
|
||||
generation_status: WOODEN_FISH_GENERATION_READY.to_string(),
|
||||
publication_status: WOODEN_FISH_PUBLICATION_PUBLISHED.to_string(),
|
||||
play_count: 0,
|
||||
updated_at: now,
|
||||
published_at: Some(now),
|
||||
background_asset_json: Some(to_json_string(&WoodenFishImageAssetSnapshot {
|
||||
asset_id: "wooden-fish-background-history".to_string(),
|
||||
image_src: "/generated-wooden-fish-assets/history/background/image.png".to_string(),
|
||||
image_object_key: "generated-wooden-fish-assets/history/background/image.png"
|
||||
.to_string(),
|
||||
asset_object_id: "wooden-fish-background-history".to_string(),
|
||||
generation_provider: "image2".to_string(),
|
||||
prompt: "历史背景".to_string(),
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
})),
|
||||
back_button_asset_json: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user